0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

AIによるデザインレビューが確率的なので決定的に検知する仕組みを試してみた

0
Posted at

はじめに

最近、AIを使ってフロントエンドを開発する機会が増えました。もはやコードの生成は全てAIに任せられる場面が増えた一方で、フロントエンド実装の場合だとどうしても人の目で見てデザインどおりに実装できているかの確認する必要があり、まだ人間が介入しないといけない場面が多く残っています。
実際、デザイン画像をAIに渡して実装を依頼したあと、画面を目で見て「余白が違う」「文字が少し大きい」「カードの高さが合っていない」などなど、細かい修正を頼むことがよくあります。

またAIによるデザイン作成ではFigma MCPを使用したり、Storybookでコンポーネントのスクリーンショットを撮影し、それをAIにレビューさせるプラクティスも最近はやっているかと思います。しかし、AIによる画像レビューは、確率的なため、同じ画像を渡しても常に同じ指摘になるとは限りません。例えば事前にチェックリストを作成しても、コンテキストが膨大になりすぎて忘れ去られたりなどでAIがうっかり忘れてしまうみたいなことがあると思います。

この記事では、デザイン画像とStorybookのスクリーンショットを決定論的に比較し、その結果をhooksからAIへ返すことで、デザインレビューに決定論的なレイヤーを加える方法を試してみたので紹介します。

AIハーネスを「確率的」と「決定論的」に分ける

AIエージェントのハーネスを考えるときは、確率的な判断と決定論的な判定を分けて考える必要があります。(詳しくは参考記事参照)
例えば、AIへの指示やAIによるレビューを確率的なレイヤー、正規表現やファイルの存在確認など、毎回同じ結果を返す検査を決定論的なレイヤーとして整理しています。

例えば以下のような整理ができます。

レイヤー
決定論的 ESLint、TypeScript、Prettier、Biome、単体テスト
確率的 AIによる確認全般、LLMによるコードレビュー、AIによる命名提案、AIによるリファクタリング提案など

決定論的な確認は、確認できる範囲が狭いですが代わりに見逃しなく、必ず結果が出力されてその結果が変わらないという特徴があります。一方でAIによる確率的な確認は、曖昧なものに関しても確認させられますが、モデルの性能やコンテキストに応じて結果が変わってしまうという特徴があります。ハーネスエンジニアリングにおいては、決定論的に確認できるところを除いて残ったところをAIによる確率的な確認を行い、AIの生成結果の確認とAIでは判断できないことを人間が行うという構造がハーネスエンジニアリングではよくあると思っています。

フロントエンドのデザイン関連のハーネスで私が知っているのは、デザインガイドラインの作成やPlaywright MCPなどのプロンプトで制御する確率的な方法が主ですが、決定論的な方法でうまくいくのかなと疑問に思ったので今回の記事を書いています。
実際に思いつくをフロントエンドのデザイン確認を確率的・決定論的にに当てはめると、次のように分けることができるかと思います。

レイヤー 得意なこと
確率的 文脈や意図を踏まえた判断 デザインの印象、UX、情報の優先順位のレビュー、デザインガイドラインに従っているか
決定論的 数値や条件で判定できる検査 画像サイズ、変更ピクセル率、画像構造の類似度、デザイントークンの使用有無を検査する

確率的な観点では、「この画面は使いやすいか」「ブランドの印象に合っているか」といったUXに関連するような問いはAIでもある程度できると思いますが、細かい点に関しては人間と同じく見逃すことがあるかと思います。
一方で、「基準画像と同じサイズか」「差分が許容範囲内か」「実装で規定されているデザイントークンを使用しているか」などは、AIにレビューさせるよりもプログラムで検査した方が確実で、高速です。

作った開発ループ

今回試した流れは次のとおりです。

  1. AIにページ全体のデザイン画像を渡す
  2. AIがページをコンポーネントに分割する
  3. 分割したコンポーネントごとに基準となる画像をトリミングして用意する
  4. AIがコンポーネントを実装する
  5. コンポーネントのStorybook storyを作成する
  6. Storybookを表示してスクリーンショットを撮影する
  7. 基準画像とスクリーンショットを比較する
  8. 基準を満たさない場合は、比較レポートを基にAIが修正する
  9. 合格するまで、撮影・比較・修正を繰り返す
  10. 各コンポーネントの完了後、ページ全体を組み立てる

ページを最初から一度に実装するのではなく、コンポーネント単位でフィードバックループを回すのは、Figma MCPのベストプラクティスでも言われている手法になります。小さいコンポーネントで順々に作業したほうが、必要なコンテキスト量も少なりなり精度が向上しやすいです。

デザイン画像をどう検査するか

今回の合否判定では、次の条件を使用しました。

  • 画像サイズが一致していること
  • 変更ピクセル率が10%以下であること
  • SSIMが0.90以上であること

画像サイズ

比較する2枚の画像は、同じサイズで撮影します。サイズが異なる画像を比較時にリサイズすると、レイアウトの間違いを隠してしまう可能性があります。

そのため、基準画像の寸法に合わせてStorybookのviewportを設定し、撮影時点で幅と高さを一致させました。サイズが違う場合はリサイズせず、失敗として扱います。

変更ピクセル率

変更ピクセル率は、基準画像と比較対象画像の同じ座標にあるピクセルを比較し、「違いがある」と判定されたピクセルが画像全体の何%を占めるかを表す値です。値が0%なら変更ピクセルはなく、値が大きいほど広い範囲で差分が発生しています。

単純にRGBの値が1でも違えば差分とすると、アンチエイリアスや画像保存時のわずかな違いまで拾ってしまいます。そのため、今回の実装では2枚の画像をグレースケール化し、各座標の明るさの差が8を超えるピクセルを「変更あり」としました。変更ピクセル率は、次の考え方で求めます。

変更ピクセル率 = 変更ありと判定されたピクセル数 / 全ピクセル数

今回は、この値が10%を超えた場合に失敗としました。

変更ピクセル率の利点は、差分が画面内にどの程度広がっているかを直感的に把握できることです。たとえば、背景色が違えば広い範囲が差分になり、要素が数ピクセルずれればその輪郭の周囲に差分が現れます。差分画像やヒートマップと組み合わせると、どの場所が判定に影響したかも確認できます。

一方で、この値だけでは「何が違うか」や「その違いが重要か」までは判断できません。小さなアイコンが完全に消えても画面全体に占める割合は小さくなります。また、要素がわずかに移動した場合は、移動前と移動後の両方が差分として数えられます。あくまで、同じ撮影条件で差分の面積を測る指標として使用します。

SSIM

SSIMは「Structural Similarity Index Measure」の略で、2枚の画像の構造的な類似度を評価する指標です。単純な誤差量だけでなく、人間が画像から構造を捉える性質を踏まえ、主に次の3要素から画像を比較しているようです。

  • 輝度: 画像の平均的な明るさが似ているか
  • コントラスト: 明暗の幅が似ているか
  • 構造: 周辺のピクセル同士の変化やパターンが似ているか

画像全体を1つの値だけで比較するのではなく、局所的な領域ごとにこれらを計算して集約します。今回利用した実装では、同一画像に近いほど値が1に近づきます。そのため、変更ピクセル率は低いほど類似し、SSIMは高いほど類似するという、逆向きの指標になります。

ピクセル差分が「同じ座標の色がどれだけ違うか」を見るのに対し、SSIMは「明暗や輪郭などの構造がどれだけ保たれているか」を見ます。たとえば、画像全体に小さな明るさの差がある場合、変更ピクセル率は高くなっても構造自体は似ているため、SSIMは比較的高くなることがあります。

今回はSSIMが0.90未満の場合に失敗としました。ただし、SSIMが高ければデザインどおりであると断定できるわけではありません。広い単色の背景が多い画面では、一部の重要な要素が違っていても全体スコアへの影響が小さくなることがあります。また、数ピクセルの位置ずれにも影響を受けます。そのため、変更ピクセル率と組み合わせて合否を判定しています。

ORB

ORBは「Oriented FAST and Rotated BRIEF」の略で、画像から角や模様の変化が大きい特徴点を検出し、それぞれの特徴を短いデータとして表現する手法です。2枚の画像から得た特徴点を照合すると、「基準画像のこの特徴は、比較対象画像のどこに対応しているか」を調べられます。

ピクセル差分やSSIMが同じ座標を前提とするのに対し、特徴点マッチングは画像内の対応関係を調べられます。そのため、要素の位置が動いた場合や、画像全体に変形がある場合に、似た特徴が残っているかを確認する補助情報になります。

今回のレポートでは、対応した特徴点の数と、その中で同じ変換関係にあると判断された点の数を出力しています。参考として下記のような画像が出力されます。

orb_matches.png

ORBは本来、物体検出や画像の位置合わせなどで使われる特徴量のようです。余白や背景色の差を直接評価するものではなく、文字や繰り返し模様では誤った対応が生じることもあります。UIがデザインどおりかを単独で判定するには向かないため、今回はAIや人間が大きな位置ずれを調査するための補足情報としました。

Butteraugli

Butteraugliは、2枚の画像の「人間の目にどの程度違って見えるか」を推定するための指標です。公式リポジトリでは、特に、かろうじて知覚できる程度の差を評価することを目的としたツールとして説明されています。

実行すると、画像全体の知覚差を表すスコアと、差が目立つ場所を示すヒートマップが得られます。Butteraugliのスコアは低いほど知覚上の差が小さいという読み方になります。

butteraugli_heatmap.png

ただし、Butteraugliは主にJPEGなどの非可逆圧縮による小さな画質差を評価する用途を想定しており、大きなレイアウト変更に対する性能は保証されていません。今回のようなUI比較へそのまま適用できる絶対的な合格ラインもないため、合否には使用しませんでした。ヒートマップをAIに渡し、人の目で差が目立ちそうな場所を探すための補足情報として使用しています。

なぜ複数の指標を組み合わせるのか

画像比較の指標には、それぞれ得意な差分と苦手な差分があります。1つのスコアだけで「デザインどおり」と判断するのではなく、目的の異なる結果を組み合わせます。

今回の役割分担は次のとおりです。

指標 見ているもの 数値の読み方 今回の用途
画像サイズ キャンバスの幅と高さ 一致または不一致 合否判定
変更ピクセル率 同じ座標で差があるピクセルの割合 低いほど差分が少ない 合否判定
SSIM 輝度、コントラスト、画像の構造 1に近いほど似ている 合否判定
ORB 角や模様などの特徴点と、その対応関係 対応点の数や位置を調べる 特徴点の対応を確認する補足情報
Butteraugli 人間が知覚すると推定される画像の差 低いほど差が小さい 知覚上の差を確認する補足情報

合否は、画像サイズ、変更ピクセル率、SSIMの3条件をすべて満たすかで決定します。ORBとButteraugliは合否を左右させず、失敗時に原因を調査してAIへ追加情報を与えるために利用します。

また閾値の設定もデザインしたWebサイトに応じて変わると思います。例えば、デザイン豊かなキラキラしたWebサイトでは閾値の設定を低くしないとそもそもPASSするのが難しいというケースもあり得ます。逆にCRUDの管理画面で画一的なものであれば閾値を高くして守らせることも簡単そうだなという印象があります。

ディレクトリ構造

今回の検証では、デザイン画像をreact-app/docs/design、Reactの実装とStorybookから撮影した画像をreact-app/src/featuresに配置しました。例として、次の構造になります。

react-app/
├── docs/
│   └── design/ # デザインされたコンポーネントのスクリーンショット
│       └── features/
│           └── plan/
│               ├── PlanPage.png
│               └── components/
│                   ├── HeroSection.png
│                   ├── IncludedFeatures.png
│                   ├── PerformanceBanner.png
│                   ├── PricingIntro.png
│                   ├── ProfessionalPlanCard.png
│                   ├── StarterPlanCard.png
│                   ├── TeamPlanCard.png
│                   ├── TrustBadge.png
│                   └── TrustStats.png
└── src/
    └── features/
        └── plan/
            ├── HeroSection.tsx
            ├── HeroSection.stories.tsx
            ├── HeroSection.test.tsx
            ├── HeroSection.png # 実装されたコンポーネントのstorybookによるスクリーンショット
            ├── IncludedFeatures.tsx
            ├── IncludedFeatures.stories.tsx
            ├── IncludedFeatures.test.tsx
            ├── IncludedFeatures.png
            ├── ...
            ├── TrustStats.tsx
            ├── TrustStats.stories.tsx
            ├── TrustStats.test.tsx
            └── TrustStats.png

docs/designは比較元のデザイン画像

react-app/docs/designには、Figmaなどから取得した比較元の画像を置きます。ページ全体の画像と、AIが分割したコンポーネント単位の画像を分けて管理しています。

docs/design/features/plan/PlanPage.png
docs/design/features/plan/components/HeroSection.png

PlanPage.pngはページ全体のデザイン、components/HeroSection.pngはHeroSectionだけを切り出したデザインです。このディレクトリのPNGが、画像比較におけるreferenceになります。

src/featuresは実装と撮影結果

react-app/src/featuresには、機能単位でReactコンポーネント、Storybook story、テストを配置しています。スクリーンショット撮影スクリプトは、storyに対応するコンポーネントと同じディレクトリへPNGを保存します。

src/features/plan/HeroSection.tsx
src/features/plan/HeroSection.stories.tsx
src/features/plan/HeroSection.test.tsx
src/features/plan/HeroSection.png

ここにあるHeroSection.pngはデザイン画像ではなく、StorybookのDefault storyをPlaywrightで撮影した結果です。画像比較におけるcandidateになります。

ファイル名でreferenceとcandidateを対応付ける

デザイン画像と撮影画像は、ディレクトリ階層ではなくファイル名で対応付けます。

docs/design/features/plan/components/HeroSection.png
                              ↓ 同じファイル名
src/features/plan/HeroSection.png

この方式では、デザイン画像をコンポーネント用のディレクトリに整理したまま、撮影画像を実装ファイルの隣へ置けます。一方、src以下に同じ名前のPNGが複数あると対応先を一意に決められません。その場合はAMBIGUOUSとして失敗させるようにしています。またデザイン画像に対応する撮影画像がない場合はSKIPPEDになります。

Storybookのスクリーンショットを固定する

画像差分を安定させるには、毎回同じ条件でスクリーンショットを撮る必要があります。表示条件が変われば、実装を変更していなくても差分が発生するためです。

今回のスクリプトでは、主に次の条件を固定しました。

  • viewport
  • device scale factor
  • color scheme
  • reduced motion
  • animation
  • caret
  • フォントの読み込み完了

Playwrightで各storyを開き、Default storyのスクリーンショットを保存します。基準画像と同じ名前のコンポーネントを対応付けることで、その後の一括比較を自動化しました。

スクリーンショットの撮影は、次のコマンドで実行できます。

npm --prefix react-app run storybook:screenshots

package.jsonでは、このコマンドからcapture-storybook-screenshots.mjsをChromium指定で実行しています。

{
  "scripts": {
    "storybook": "storybook dev -p 6006",
    "storybook:screenshots": "STORYBOOK_SCREENSHOT_BROWSER=chromium node scripts/capture-storybook-screenshots.mjs"
  }
}

実際にスクリーンショットを撮影しているreact-app/scripts/capture-storybook-screenshots.mjsから、比較に関係する主要部分だけを抜粋します。Storybookの起動処理やエラー処理は省略しています。

const designImages = readdirSync(designDir, { recursive: true })
  .filter((path) => extname(path).toLowerCase() === ".png")
  .map((path) => resolve(designDir, path))

const stories = Object.values((await fetch(`${storybookUrl}/index.json`).then((r) => r.json())).entries)
  .filter((entry) => entry.type === "story" && entry.exportName === "Default")

const browser = await chromium.launch({ headless: true })
const context = await browser.newContext({
  viewport: { width: 1440, height: 1000 },
  deviceScaleFactor: 1,
  colorScheme: "light",
  reducedMotion: "reduce",
})
const page = await context.newPage()

for (const story of stories) {
  const componentPath = resolve(appDir, story.componentPath)
  const componentName = basename(componentPath, extname(componentPath))
  const screenshotPath = resolve(dirname(componentPath), `${componentName}.png`)
  const designImage = designImages.find((path) => basename(path) === `${componentName}.png`)
  const captureViewport = designImage ? readPngSize(designImage) : viewport

  await page.setViewportSize(captureViewport)
  await page.goto(`${storybookUrl}/iframe.html?id=${story.id}&viewMode=story`)
  await page.locator("#storybook-root > *").first().waitFor({ state: "visible" })
  await page.evaluate(() => document.fonts.ready)
  await page.screenshot({
    path: screenshotPath,
    animations: "disabled",
    caret: "hide",
  })
}

処理の流れは次のとおりです。

  1. http://127.0.0.1:6006/index.jsonへ接続し、Storybookが起動済みか確認する
  2. 起動していなければ、npm run storybookでStorybookを起動する
  3. Storybookのindexから、export名がDefaultのstoryだけを抽出する
  4. コンポーネント名と同名のデザインPNGを探す
  5. デザインPNGがあれば、その画像サイズをviewportに設定する
  6. storyをiframeで開き、コンポーネントとフォントの表示完了を待つ
  7. animationとcaretを無効化してスクリーンショットを保存する

基準画像が見つからないstoryは、#storybook-rootの表示領域を撮影します。基準画像があるstoryはviewport全体を基準画像と同じ寸法で撮影するため、比較時のリサイズは行いません。

画像比較はbashファイルで実行してみました。

scripts/image-diff/compare_storybook.sh

実際のスクリプトは長いため、中核部分だけを載せます。

compare_storybook.sh: 対象画像の列挙と集計

DESIGN_DIR="${ROOT_DIR}/react-app/docs/design"
STORYBOOK_DIR="${ROOT_DIR}/react-app/src"
OUT_DIR="${ROOT_DIR}/.out/frontend/image-diff"
THRESHOLD=8
MAX_CHANGED_RATIO=0.15
MIN_SSIM=0.70

while IFS= read -r -d "" reference; do
  file_name="$(basename "${reference}")"
  mapfile -d "" candidates < <(
    find "${STORYBOOK_DIR}" -type f -name "${file_name}" -print0 | sort -z
  )

  # 0件はSKIPPED、2件以上はAMBIGUOUSとしてsummaryへ記録する
  [[ ${#candidates[@]} -eq 1 ]] || continue

  candidate="${candidates[0]}"
  if ! scripts/image-diff/compare.sh \
    "${reference}" "${candidate}" \
    --out-dir "${pair_out}" \
    --threshold "${THRESHOLD}" \
    --max-changed-ratio "${MAX_CHANGED_RATIO}" \
    --min-ssim "${MIN_SSIM}" \
    --no-resize; then
    failed=$((failed + 1))
  fi

done < <(find "${DESIGN_DIR}" -type f -iname "*.png" -print0 | sort -z)

[[ "${compared}" -gt 0 && "${failed}" -eq 0 ]]

compare.sh: Python比較処理の呼び出し

REFERENCE="$1"
CANDIDATE="$2"
shift 2

# 仮想環境がなければsetup.shでOpenCV、NumPy、scikit-imageを準備する
if [[ ! -x "${VENV_PYTHON}" ]]; then
  "${SCRIPT_DIR}/setup.sh"
fi

exec "${VENV_PYTHON}" \
  "${SCRIPT_DIR}/compare_ui_images.py" \
  "${REFERENCE}" "${CANDIDATE}" "$@"

compare.shは画像比較アルゴリズムではなく、依存ライブラリを用意してcompare_ui_images.pyを実行するラッパーです。execを使うことで、Pythonの終了コードを呼び出し元へそのまま返します。

compare_ui_images.py: 指標の計算と合否判定

def pixel_diff(reference, candidate, threshold):
    diff = cv2.absdiff(reference, candidate)
    gray_diff = cv2.cvtColor(diff, cv2.COLOR_BGR2GRAY)
    changed_mask = gray_diff > threshold
    changed_ratio = np.count_nonzero(changed_mask) / gray_diff.size

    heatmap = cv2.applyColorMap(gray_diff, cv2.COLORMAP_INFERNO)
    overlay = cv2.addWeighted(candidate, 0.65, heatmap, 0.35, 0)
    return changed_ratio, gray_diff, overlay


def ssim_score(reference, candidate):
    reference_gray = cv2.cvtColor(reference, cv2.COLOR_BGR2GRAY)
    candidate_gray = cv2.cvtColor(candidate, cv2.COLOR_BGR2GRAY)
    return structural_similarity(reference_gray, candidate_gray, data_range=255)


def orb_match(reference, candidate, max_features=1500):
    orb = cv2.ORB_create(nfeatures=max_features)
    keypoints1, descriptors1 = orb.detectAndCompute(reference, None)
    keypoints2, descriptors2 = orb.detectAndCompute(candidate, None)
    matcher = cv2.BFMatcher(cv2.NORM_HAMMING)
    return matcher.knnMatch(descriptors1, descriptors2, k=2)


changed_ratio, pixel_diff_image, heatmap = pixel_diff(reference, candidate, threshold=8)
ssim = ssim_score(reference, candidate)
orb_matches = orb_match(reference, candidate)

passed = changed_ratio <= 0.15 and ssim >= 0.70

cv2.imwrite(str(out_dir / "pixel_diff.png"), pixel_diff_image)
cv2.imwrite(str(out_dir / "pixel_heatmap.png"), heatmap)
write_report(out_dir / "report.md", changed_ratio, ssim, orb_matches, passed)

if not passed:
    raise SystemExit(1)

このスクリプトは、docs/design以下にあるすべてのPNGを順番に処理します。src以下から同名のPNGを探し、1件だけ見つかった場合にcompare.shへ渡します。候補が0件ならSKIPPED、2件以上ならAMBIGUOUSです。

compare.shから実行されたcompare_ui_images.pyがPixel Diff、SSIM、ORBを計算し、閾値を満たさなければ終了コード1を返します。その後、compare_storybook.shがButteraugliのスコアとヒートマップをレポートへ追記します。すべての画像を処理したあと、比較対象が0件、または失敗が1件以上あれば、スクリプト全体も終了コード1になります。

比較結果はMarkdownのサマリーと、コンポーネントごとの詳細レポートとして出力されます。

.out/frontend/image-diff/
├── summary.md
└── pairs/
    └── ...
        ├── report.md
        ├── metrics.json
        ├── pixel_diff.png
        ├── pixel_heatmap.png
        ├── orb_matches.png
        └── butteraugli_heatmap.png

AIには合否だけでなく、変更ピクセル率、SSIM、差分画像、ヒートマップも渡します。これにより、「見た目を確認して適当に直す」のではなく、「どこに、どの程度の差が残っているか」を基に修正できます。レポートは以下の形式で出力されます

# UI Image Diff Report

- Reference: `react-app/docs/design/features/management/components/ProductSalesRanking.png`
- Candidate: `react-app/src/features/management/ProductSalesRanking.png`
- Size: 307x227
- Candidate resized: False

## Summary

- Evaluation: **FAIL**
- Changed pixels: 13104 / 69689 (18.803541%)
- Pixel MAE: 14.758771
- Pixel RMSE: 42.817180
- Max channel diff: 255
- SSIM: 0.587662
- ORB good matches: 79 / 962
- ORB homography inliers: 43
- Max changed ratio: 0.1
- Min SSIM: 0.9

## Outputs

- Metrics JSON: `.out/frontend/image-diff/management/pairs/components/ProductSalesRanking/metrics.json`
- Pixel diff: `.out/frontend/image-diff/management/pairs/components/ProductSalesRanking/pixel_diff.png`
- Pixel heatmap: `.out/frontend/image-diff/management/pairs/components/ProductSalesRanking/pixel_heatmap.png`
- ORB matches: `.out/frontend/image-diff/management/pairs/components/ProductSalesRanking/orb_matches.png`

## Butteraugli

- Score: 116.989563
- Heatmap: [butteraugli_heatmap.png](butteraugli_heatmap.png)
- Source heatmap: `butteraugli_heatmap.ppm`

hooksで不合格のまま進めない

画像比較スクリプトは、基準を満たさない画像が1つでもあれば終了コード1を返します。

今回はLefthookのpre-pushへ、Storybookの撮影と画像比較を組み込みました。

pre-push:
  jobs:
    - name: frontend image diff
      run: npm --prefix react-app run storybook:screenshots && scripts/image-diff/compare_storybook.sh

これにより、AIが自分の実装を「完成した」と判断しても、画像比較が不合格ならpushできません。AIはレポートを読み、実装を修正して、もう一度検査を実行する必要があります。

「デザインに合わせてください」という指示は確率的です。一方、「変更ピクセル率10%以下かつSSIM 0.90以上でなければ終了コード1を返す」という条件は、AIの注意力やコンテキストの状態に左右されません。

この検査が、今回追加した決定論的なレイヤーです。

実際の画面で試してみた

今回は、Figma Makeを使用して適当な体重管理アプリのメニューの
ページを対象にしました。

WeightPage.png

ページ全体をコンポーネント分割し、それぞれにデザイン画像とStorybook storyを用意しました。また比較用に今回用意した決定論的な確認を使う場合と使わない場合で動作確認してみました。

AIはcodexでGPT-5.6-soloで実行しました。

決定論的確認を使わない場合

できたページがこちらです。正直、これでも十分な気がします。
WeightPage.png

一見、ほとんど同じようですが、重ねてみると次のようになります。
pixel_diff.png

全体的に余白や文字サイズが少しずつずれています。とはいえ全体的なUIに関してはほぼ一致しているのでこれくらいのデザインであれば、問題ないという結果です。

決定論的確認を使った場合

次に決定論的確認を使用した場合はこちらです。こちらは今回設定した閾値をPASSしたものです。

WeightPage.png

先ほど同様に、全体的なUIとしては問題なく実装できています。こちらの差分は以下になります。

pixel_diff.png

先ほどと違って、文字サイズや全体的な余白がほとんど一致するようになりました。実際に実行中のログを見ているとCSSをpx単位で微調整を繰り返していて、AIがpx調整職人となっていました。

AIだけのレビューと比べて良かったこと

  • 合否条件が明確になった
    機械的に失敗を表現できるので、AIが決めた閾値を超えるまで修正し続けてくれます。ただし本当に実装で表現できるかの切り分けをしないとできないことをひたすらやり続けてしまうため、そこの判断は必要かなと思いました。

  • 余白の調整が全自動
    基本的に余白の調整や文字サイズはAIがpythonコードを書いて勝手に直してくれました。簡単だけどやると手間が多いことをある程度までやってくれるのでありがたかったです。

  • コンポーネント多くても見逃さない
    今回はpushするときに、hookで強制的に検査するので、AIによる確認のお願いだと見逃されることがありますが、それがないです。またビジュアルリグレッション用の資材をためていけるのが利点だと思いました

苦労したこと・誤検知

  • フォントやアセットによる描画差
    描画するブラウザに対象のフォントが入っていなかったりすると、そもそもフォントや画像を用意していなかったりすると対応はしきれないことが多かったです。そのため、明示的に必要なフォントやアセットを用意しておく必要がありました。

  • 閾値を厳しくしすぎた場合の過剰な失敗
    当然ですが、閾値を厳しくするとPASSするまで時間がかかります。その分トークンも消費するため、適切な閾値の設定が難しいです。

  • 閾値の値がコンポーネントに依存する
    例えばページのような大きいコンポーネントで、ある文字位置が10pxのずれているのと ボタンのような小さいコンポーネントで10pxずれているのでは相対的に見れば、ページコンポーネントのほうが影響が少なく、ボタンコンポーネントの方が影響が大きいです。

  • アニメーションは厳しい
    スクリーンショットベースなので、アニメーションの確認は難しいです。アニメーションの確認自体は人間の目やAIによる確認になると思います。

  • デザイントークンがあれば十分な場合
    基本的に余白や文字サイズはデザイントークンなどで規定されることがあると思います。今回、よくなった余白やフォントサイズはデザイントークンを統一さえしていれば統一できるのでそれだけで問題ない可能性もあるかと思います。(今回は検証なのでそこまで用意はしていませんでした。)

デザイン到達判定とリグレッションテストを分ける

人間またはAIによるデザインレビューが完了し、ブラウザで表示した結果が承認されたあとは docs/design の役割はFigmaなどのデザインからのスクリーンショットから実際の実装のスクリーンショットに置き換えることによって、ビジュアルリグレッションに使用できます。この時に、閾値判定もさらに上げることができるため、ビジュアルリグレッションテストと同様に、より厳しいピクセル単位の比較へ移行できます。

つまり、画像検査を次の2段階に分けます。

段階 比較対象 目的 判定の考え方
デザイン到達判定 デザイン画像と初回実装 AIの実装をデザインへ近づける 環境差を許容した変更率とSSIM
リグレッションテスト 承認済みのブラウザ画像と変更後の実装 意図しない見た目の変化を防ぐ より厳しいピクセル単位の比較

デザインへの到達と、その後の見た目の維持では、同じ画像比較でも目的が異なります。この2つを分けることで、初回実装では現実的な許容範囲を持たせつつ、承認後は厳格に変更を監視できます。

さいごに

今回は、デザインの修正を決定論的にやってみました。基本的にやったこととしては従来のビジュアルリグレッションの手法をローカルで実行できるように移しただけですが、決定論的にデザインを評価してAIにループを回させるようにしました。
検証中なくてもいいのではと思いつつ、自動化して決定論的に判定できて特に劣化することもないのでリソースが十分にあるなら導入してもいいかなという感想です。

参考記事

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?