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に品質を採点させるのをやめた話 — ブラウザFPSを26,000行書いて踏んだ「計測の4つの罠」

0
Posted at

株式会社Good Labでエンジニアをしている コータロー です。
日々、Java・SQL・Gitなどの技術情報や、新人エンジニア向けの学習ノウハウ、
AI活用についての情報を発信しています。

Good Labについて気になった方は、コーポレートサイトもぜひご覧ください。
コーポレートサイト

この記事の結論

AI に品質を「採点」させると、物差しが毎回変わるので時系列比較が成立しない。

実体が改善しているのにスコアが下がる。そしてその状態は、単に役に立たないのではなく 無価値以下 である。依頼主に「悪化している」という誤ったシグナルを送り、存在しない問題を追いかけさせるからだ。

批評させるなら 「欠陥の発見」まで。採点権は自動計測に渡すべきだ。

批評は提案し、計測が裁く。

以下は、この結論に至るまでに私と Codex と批評エージェントが全員踏んだ罠と、その後に出てきた実バグの記録である。数値はすべて実測値で、盛っていない。

前提:何を作っていたか

Three.js(ブラウザ / WebGL2)で一人称シューターを作っていた。制約が1つある。

外部アセット禁止。 テクスチャは全て GPU 上での手続き生成、ジオメトリは全てコード生成。アセット総量 0MB。

30_harbor_gameplay.png

実装 Three.js ^0.185.1 / Vite ^8.1.5 / Node 25.6.1
規模 99コミット / src 配下 60ファイル / 25,997行
性能 1280×720 で 60fps(draw call 400 / 三角形 602,074)
アセット 0MB(外部ファイルの読み込みゼロ)

始まりは「最新の Call of Duty 級を作れ、AAA に見えるまで止めるな」という無茶な要求だった。

先に言っておくと、CoD には勝っていない。原理的に不可能だ。スキニングもモーションキャプチャもフォトグラメトリも GI も無く、アセットは 0MB 対 数百GB である。この点は最初から要求元にそう伝えている。この記事で達成したと言えるのは「ブラウザ FPS として最高水準を目指す過程で、計測の規律を確立したこと」だけだ。

そして皮肉なことに、価値があったのは絵そのものより、この規律のほうだった。


本題1:全員が踏んだ、計測の4つの罠

どれも実際に起きて、判断を歪めた。共通する形は1つだ。

「数値は出たが、それが自分の変更の効果ではない」

罠1:古いベースラインと比較する

Codex に実装を移譲していたところ、3回中2回、こう報告してきた。

「2.66 → 3.70 に改善しました」

しかし当時の HEAD は 既に 3.71 だった。私が並行して別の修正を入れていたため、Codex が「作業前の値」として参照した数値が、着手時点で既に古かったのだ。

つまり Codex は「自分が 2.66 を 3.70 にした」と信じていたが、実際にやったのは 3.71 を 3.70 に微減させること だった。改善報告の中身が、実質的に何もしていないどころか誤差でわずかに悪化、である。

これはエージェント側の怠慢ではない。プロンプトに書いてある数値を信じるのは合理的な振る舞いだ。悪いのは、古くなり得る数値をプロンプトに書いた私である。

対策

プロンプトの冒頭に、これを必ず書くようにした。

【計測の規則】
このプロンプト内に書かれた数値をベースラインにするな。
それらは執筆時点の値で、既に古い可能性がある。
まず自分で HEAD の状態を撮り直し、それを before とせよ。

3回目の移譲で、この誤りは消えた。

罠2:固定スクリーン矩形で A/B する

これは私自身が踏んだ。

画面の固定座標 (700, 430, 110x50) の矩形を切り出して「改善したか」を測っていた。ある変更のあと、こう判定した。

3.71 → 2.64 に悪化。この方向は失敗。

ところが、モデルの形状が変わって構図が少しずれていた。同じ矩形が、もはや同じ物体を指していなかった。 対象の物体は右下にずれ、矩形の中身は半分が背景の壁になっていた。

対象メッシュだけを非表示にした差分マスク(後述)で測り直すと、結果は逆だった。

測り方 before after 判定
固定矩形 (700,430,110x50) 3.71 2.64 悪化(誤)
オブジェクトマスク 4.76 5.50 改善(正)

危なかったのは、この誤判定を信じて 正しい方向の変更をロールバックしかけた ことだ。数値が出ていると、人は疑わない。

対策

固定矩形で A/B しない。物体を非表示にした差分でマスクを作り、その物体の画素だけを測る。

罠3:平均で判定する

「地面に見るものがあるか」を、局所標準偏差(5×5窓の SD)で測っていた。あるとき、エージェントが「目標を達成した」と報告してきた。

地面の局所SD 平均 5.90(目標 4.5 以上)→ 達成

自分で分布を出し直したら、こうだった。

平均   5.90
中央値 3.12

大きく乖離している。原因は明快で、地面に落ちている 少数の破片の硬い輪郭 が平均を持ち上げていただけだった。面の大半は平坦なままで、「地面が退屈」という元の問題は1ミリも解決していない。

これは合成データで簡単に再現できる。次のスクリプトは、ほぼ平坦な面に硬いエッジを6本だけ引いたものだ(node demo_sd.mjs で実行できる)。

// demo_sd.mjs — 局所SD(5x5) の平均と中央値の乖離を再現する
function localSd(L, w, h) {
  const v = [];
  for (let y = 0; y < h - 5; y += 2) {
    for (let x = 0; x < w - 5; x += 2) {
      let s = 0, s2 = 0;
      for (let j = 0; j < 5; j++) {
        for (let i = 0; i < 5; i++) {
          const val = L[(y + j) * w + x + i];
          s += val; s2 += val * val;
        }
      }
      v.push(Math.sqrt(Math.max(0, s2 / 25 - (s / 25) ** 2)));
    }
  }
  v.sort((a, b) => a - b);
  const mean = v.reduce((a, b) => a + b, 0) / v.length;
  return { mean: +mean.toFixed(2), median: +v[Math.floor(v.length / 2)].toFixed(2), n: v.length };
}

const W = 256, H = 256;
const L = new Float64Array(W * H);
let seed = 12345;
const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;

// 平坦な面(微細ノイズのみ)
for (let i = 0; i < W * H; i++) L[i] = 90 + (rnd() - 0.5) * 6;
// 硬いエッジを 6 本だけ引く(面積比 約 2.3%)
for (let k = 0; k < 6; k++) {
  const y0 = 20 + k * 38;
  for (let x = 0; x < W; x++) L[y0 * W + x] = 220;
}

console.log(localSd(L, W, H));

実行結果。

{ mean: 8.89, median: 1.73, n: 15876 }

面積のわずか 2.3% のエッジで、平均が中央値の 5 倍以上に化ける。 平均 8.89 なら「情報量たっぷりの面」に見えるが、実際は中央値 1.73 の、のっぺりした平面である。

対策

局所SD は 必ず中央値で判定する。評価表にもそう書いて凍結した。

罠4:ビルドの出力先を分けても、ソースの衝突は防げない

複数のエージェントに並行作業させていたので、ビルドの出力先を分けていた。

npx vite build --outDir dist-me      # 私
npx vite build --outDir dist-agent-a # エージェントA

これで独立していると思っていた。していなかった。

vite build が読むのは、--outDir が何であろうと 共有の src/ である。他の担当が編集途中のファイルが、そのまま自分のビルドに入る。

実害はこう出た。シェーダのパラメータ 1行だけ を変えて A/B を撮ったのに、三角形数が 5,696 減っていた。シェーダは三角形を1枚も増減させない。差の正体は、担当外のファイル(別のエージェントが同時に編集していた LOD 周り)の混入だった。

このとき私は一瞬「シェーダ変更が頂点処理に影響したのか?」と考えた。存在しない問題を追いかけ始めていた。

対策:git worktree で自分だけの木を作る

数値を報告するときだけ、以下の手順を踏む。見た目の確認だけなら共有の木で構わない。

# 1. 自分の変更だけをパッチとして切り出す
git diff src/render/PostFX.js > /tmp/mine.patch

# 2. HEAD だけの、誰も触っていない木を作る
git worktree add -q /tmp/wt-me HEAD
cd /tmp/wt-me && ln -sfn /path/to/repo/node_modules node_modules

# 3. before(HEAD のまま・自分の変更なし)
npx vite build --outDir dist-wt
npx vite preview --outDir dist-wt --port 5199 --strictPort &
node tools/qa.mjs --shots gameplay --out /tmp/wt-me/shots/before --port 5199

# 4. after(自分の変更だけを当てる)
git apply /tmp/mine.patch
npx vite build --outDir dist-wt
node tools/qa.mjs --shots gameplay --out /tmp/wt-me/shots/after --port 5199

# 5. 片付ける
cd /path/to/repo && git worktree remove --force /tmp/wt-me

ポイントは3つ。

  • ポートも専用にする--strictPort を付けて、他人のサーバに繋がった状態で気づかないのを防ぐ)
  • node_modules はシンボリックリンクで済ませる(再インストールは時間の無駄)
  • before と after の差分が、パッチ1枚ぶんであることが保証される

並列エージェント運用で数値を扱うなら、これは必須だと思っている。


本題2:スコアが 47 → 43 → 36 と下がったが、実体は改善していた

何が起きたか

批評エージェント(AAA タイトルのアートディレクター役)に、ラウンドごとに絵を採点させていた。スコアは下がり続けた。

ラウンド5: 47点
ラウンド6: 43点
ラウンド7: 36点

普通に読めば「作業するほど悪化している」である。しかし実体は毎ラウンド改善していた。原因は単純で、批評の厳密さが毎回上がっていた。同じ絵を、より厳しい物差しで測り直しているだけだった。

決定的だったのは、後のラウンドで初めてオブジェクトマスクが使われ、それ以前の批評が犯していた測定ミスが判明したことだ。

批評の主張 実際
「敵の輪郭コントラストは実効ゼロ。SD 60」 矩形に背景を含めており、SD 60 は背景の分散だった。敵だけをマスクすると中央値 20 あった
「その敵は約 35m」 シーングラフの座標から計算すると 16.6m
「コンテナの波板は印刷されたタイル。自己相関の周期 69px」 69px は 1m の UV タイルの周期。波板そのものはテクスチャに焼かれていた
(実装側の報告)「スペキュラAA を全マテリアルに追加した」 MaterialLibrary.get() の中でしか呼ばれておらず、ビューモデルには一切掛かっていなかった

つまり 47点と36点は、別の物差しで出た数字だった。 並べて時系列比較すること自体が成立していない。

厄介なのは、批評そのものが「賢くなっている」点だ。ラウンド7の批評は、ラウンド5の批評より確実に正しい。にもかかわらず、その2つを並べた瞬間に嘘になる。

打った手:評価表を凍結して git タグを打つ

docs/SCORECARD-v1.0.mdtools/scorecard.mjs を対で作り、scorecard-v1.0 という git タグを打った。規則は3つだけ。

  1. 物差しはここに凍結する。以後は変更しない
  2. 新しい欠陥は v1.1 として【追加】する。旧項目の再定義は永久にしない
  3. 批評の新指摘は、測定可能な検査に変換されて初めて評価表に載る

3 が肝で、批評エージェントには以降、指摘のたびに必ずこの項目を書かせるようにした。

【この指摘の合否をどう測るか】
- 測定コマンド:
- 対象領域の決め方(固定矩形は不可):
- 合格の閾値と、その閾値の根拠(同じ絵の中の別の物との比較):

これが書けない指摘は採用しない。 「なんとなく安っぽい」は指摘として受け取るが、評価表には載せない。

閾値の根拠を「同じ絵の中の別の物との比較」に限定したのも意図的だ。外から持ってきた絶対値(「AAA なら SD 8 は欲しい」等)は検証できないが、「地面の局所SD 中央値 ≥ 4.5 = 同じ絵の錆コンテナ 6.9・レンガ壁 6.7 の 2/3」なら、その場で誰でも確かめられる。

批評は殺していない

念のため書いておくと、批評エージェントは残した。理由は単純で、欠陥発見器としては極めて有能だったからだ。次章の実バグ3件は、全て批評の指摘が発端である。

殺したのは 採点権だけ。観察眼ではなく判定権を取り上げた、という話だ。


本題3:計測に切り替えたら、「見た目の問題」ではなく実バグが出てきた

これが本当のオチだ。主観の議論をやめた瞬間、本物のバグが次々に出てきた。

バグ1:ハイライトが上限 0.779 で頭打ちだった

グレードのショルダー(ハイライトの上端を寝かせる曲線)を、AgX トーンマップの 後段 に置いていた。

// 【抜粋・修正前のフラグメントシェーダ】
vec3 shoulder(vec3 c, float k) {
  vec3 t = max(c - k, vec3(0.0));
  float w = max(1.0 - k, 1e-4);
  return min(c, k + w * (1.0 - exp(-t / w)));   // ← バグ
}

この式は「入力が [0, ∞) で来る」前提の指数カーブだ。ところが AgX は出力を概ね [0,1] に収めるので、入力の最大が 1.0 にしかならない。正規化していないため、入力 1.0 に対する出力は

k + w * (1 - e^-1) = k + 0.632 * (1 - k)

k = 0.40 なら 0.779 が画面の物理的な上限 になる。

実測すると、13構図中12構図で最大輝度が 221〜232 に張り付き、255 に届く画素が1つも存在しなかった。太陽もマズルフラッシュも白い壁も、全部同じ値で潰れる。つまり 「光の強弱の序列」が画面から消えていた

「なんとなく眠い絵」の正体がこれだった。主観で議論している限り絶対に出てこない類のバグである。

修正は、正規化したうえで冪カーブに置き換えた。

// 【抜粋・修正後のフラグメントシェーダ】uShoulderPow は uniform(採用値 2.7)
vec3 shoulder(vec3 c, float k) {
  vec3 t = max(c - k, vec3(0.0));
  float w = max(1.0 - k, 1e-4);
  vec3 u = t / w;                                  // 入力 1.0 → u = 1.0 に正規化
  return min(c, k + w * pow(u, vec3(uShoulderPow)));
}

なぜ「正規化した指数カーブ」ではなく冪カーブなのか。正規化した (1-e^-u)/(1-e^-1) は必ず直線より上に来るので、上端を 1.0 に届かせると中〜高輝度まで一緒に持ち上がってしまう(実測:肩 0.40 で白飛び 1.59%、日向ファサードの L>225 が 33.67%)。

冪カーブ u^g(g>1)なら、

  • u = 1 で必ず出力 1.0 → 太陽とマズルフラッシュは 255 に届く
  • u が小さいほど強く沈む → 日向の壁は肩の直上に留まる

という「上端だけを残して中〜高輝度を抑える」挙動になる。最終的に g = 2.7 で、日向ファサードの L>225 は 0.38% に収まった。

バグ2:被写界深度が一人称武器に掛かっていた

FPS のビューモデル(手と銃)は、深度バッファをクリアしてから別パスで描く。壁にめり込まないためだ。

その結果、DoF が参照する深度テクスチャに銃が存在しない。銃の画素のボケ量は、「銃の背後にある壁の深度」で決まっていた。

ADS(サイトを覗き込む動作)では焦点距離が遠方に寄る。したがって、

画面でいちばん鋭くあるべき照準器が、画面でいちばんボケていた。

実測(領域の平均勾配で「鋭さ」を測る)。

修正前 修正後
武器の鋭さ 3.07
中景の建物の鋭さ 3.88
鋭さ比(武器/背景) 0.93(逆転) 1.61 → 最終 1.80

修正は、DoF を単独パスに切り出して、ビューモデルを描く に終わらせた。

// 【抜粋・postprocessing の EffectComposer 前提】
// DoF は単独パスにして、ビューモデルより前に置く。
// ViewModelPass は深度をクリアしてから銃を描くため、DoF が参照する深度に銃が無い。
// 結果、銃のボケ量が「銃の背後の壁の深度」で決まってしまう。
// 「ビューモデルは常にシャープ」が正しいので、DoF を銃の【前】で終わらせる。
if (this.dof) {
  this.dofPass = new EffectPass(this.camera, this.dof);
  composer.addPass(this.dofPass);
}
composer.addPass(this.viewModelPass);

評価表には「ADS の鋭さ比(武器/背景)≥ 1.2」というゲートを追加した。FPS において最も鋭くあるべき対象が、画面で最も鋭いことを機械的に保証する項目である。

バグ3:撮影中も適応解像度が働いていて、全ての A/B が無効だった

一番効いたのはこれだ。

同じ構図を2回続けて撮り、ピクセルごとの差の絶対値を平均すると、2.4〜3.0 出ていた。何も変えていないのに、である。

差分マップを画像として出したら一目瞭然で、あらゆる物体の輪郭が光っていた

原因は適応解像度だった。fps に応じて内部レンダー解像度を上下させる仕組みが、撮影中も生きていた。撮影ごとに内部解像度が変わり、最終出力へのリサンプルだけで 全物体の輪郭が 1px ずれる

局所SD のような指標は、これだけで 2〜3倍動く。

つまり、これを直すまでに行った全ての A/B 比較は信用できなかった。 罠2の誤判定も、この非決定性が一因である可能性が高い。

撮影時に以下を固定した。

固定するもの 理由
適応解像度(スケール 1.0 に固定) リサンプルで輪郭が動く
フィルムグレインの位相 全画素に一様なノイズが乗る
雲の位相 空と影の分布が変わる
敵の配置・生死・アニメーション位相 そもそも被写体が変わる

結果、平均 |Δ| は 0.53〜0.94 に落ちた(後日の評価表凍結時の実測では、構図によって 0.57〜1.26)。ゼロにはならない。パーティクルは意図的に確率的だからだ。だから評価表のゲートは「≤ 1.5」にして、系統的な非決定性の検出用と位置づけた。

ここには教訓が2つある。

  1. A/B を始める前に、まず「同じものを2回測って一致するか」を確認する。 これをやっていなかったので、無効な比較を数十回積み上げた
  2. ゲートを 0 にしない。意図的にランダムな要素があるなら、その分は許容値に織り込んで文書化する

バグ4:照準器の穴が小さいのは「寸法」ではなく「見かけ角」の問題だった

批評から「ADS でサイトピクチャが成立していない。照門の見通し穴は等価直径 12.6px しかなく、狙う対象が見えない」という指摘が来た。

このとき、3案(近距離ボケで誤魔化す / ジオメトリを変える / 構図を変える)を隔離した worktree で独立に試作させ、それぞれに専属の検証者を付けた。3つの案が、独立に同じ真因へ到達した。

真因は寸法ではなく 見かけ角 だった。

実銃を構えたとき、射手の眼はリアアパチャの 5〜8cm 後方にある。しかし FPS のビューモデルでカメラを 6cm まで寄せることはできない(ストックも頬付けも画面を埋め尽くす)。実測でカメラ〜照門は 29.5cm あった。

同じ実寸で作ると、こうなる。

M4 の大径アパチャ φ5.08mm (0.200")
  実射:   5.08 / 60  = 0.08467 rad → 画面 79.4px(サイトピクチャが成立)
  ゲーム: 5.08 / 295 = 0.01722 rad → 画面 16.1px(穴の中に何も見えない)

実射の 1/5 の見かけ角にしかならない。 実銃の寸法を正確に再現していたことが、そのまま絵が成立しない原因だった。「アパチャを実銃の大径相当に広げても解けなかった」理由もこれで説明がつく。

そこで照門 だけ を見かけ角基準で逆算した。

角直径 = 5.08 / 60 = 0.08467 rad
29.5cm の面での直径 = 0.08467 × 295 = 24.98mm → 半径 12.5mm

結果、見通し穴は 123px → 3526px になった。

この例外は リアアパチャ1か所に限定し、コードにその旨と計算を書いた。手・弾倉・グリップは実寸のままが正しい。例外を作るときは「どこまでが例外か」を明示しないと、次に触る人(あるいは次に走らせるエージェント)が全体に広げてしまう。


本題4:数値を満たすためだけの変更を禁じる

自動計測に採点権を渡すと、必ず次の問題が来る。ゲートを通すためだけの変更である。

評価表に明文で禁止規則を書いた。実例を2つ挙げる。

例1:手の表面情報 — 数値は通るが「金たわし」になる

「手の局所SD 中央値 ≥ 7.0」というゲートがあった。法線マップの強度(normalScale)をスイープした実測がこれだ。

normalScale 局所SD 中央値 孤立画素(ごま塩)ノイズ 見た目
0.6 3.23 0.62% のっぺり
2.6 5.53 1.35%
3.6(採用) 6.84 1.64% 皮膚の質感
6.0 9.24 3.48% 金たわし/スポンジ

6.0 なら 9.24 で余裕をもって合格する。しかし 8倍に拡大すると金たわしにしか見えず、検証者も「錆」「カビ」と評した。

さらに悪いのは動きだ。手を 0.4mm 動かしたときの平均 |ΔL| が 2.08 → 4.18 と倍増する。 実プレイの武器スウェイで画面がシマーする、ということである。

採用したのは 3.6(局所SD 6.84 = 未達)。

そして重要なのはここからで、ゲートの数値は変更していない。代わりに、こう記録して未達のまま残した。

このゲートは「眼から 0.35m の物体が、8m 先のレンガ壁(5.83)を下回ってはいけない」という 意図 で置かれた。実測 6.65〜6.84 は 5.83 を明確に上回っており、意図は満たしている。7.0 への到達には normalScale 6.0 が必要で、それは絵を壊す(上表)。よって未達のまま出荷する。

閾値を 6.5 に下げれば「合格」にできた。やらなかった。一度でも「届かないから閾値を下げる」をやると、評価表は測定器ではなく合格証発行機になる。

例2:銃の輝度 — 数値は通るが「白い樹脂玩具」になる

「ビューモデル/背景の輝度中央値の比」というゲートに対して、単純にアルベドを上げれば数値は満たせた。結果は「白い樹脂玩具」で、これは却下された。

なお、この項目には後日「構図固有の制約」という決着をつけている。rooftop 構図は背景(日陰の屋上)の輝度中央値が 74.6 と暗く、比 0.85 を満たすには武器を L=64 まで落とす必要がある。それはビューモデルの可読性の下限(62)の手前で、過去に「黒い切り絵」と批評された領域に戻る。sun 構図では同じ変更で 0.94 → 0.84 と達成できているので、この構図でだけ基準と可読性が両立しない、と書いて残した。

出荷条件をこう定義した

全項目が「クリア済み」または「実測で到達不能と証明・文書化済み」のいずれかになった時点。

到達不能なら、なぜ届かないかを 実測付きで 書いて残す。これで初めて「終わり」が定義できた。

元の要求「CoD に勝つまで止めるな」は、構造的に停止しない終了条件だった。停止条件のないループを回している限り、どれだけ改善しても永遠に「まだ足りない」しか返ってこない。AI エージェントに長時間走らせるとき、これは想像以上に致命的だ。

評価表凍結時点の基準点は 41/41 合格。その後 v1.1 で項目を追加し、上記のような「未達だが文書化済み」を含む状態で出荷条件を満たした。

失敗も書いておく:「原理的に解けない」と言ったが、解けた

一度、日向の壁の白飛びについて「トーンカーブでは原理的に解けない」と結論づけた。

根拠はあった。壁の担当者が実測で「アルベドを 0.83 倍にしても出力は 6 しか動かない」と示しており、私はそこから「露出もトーンカーブも同じはずだ」と外挿した。過去に露出を下げる案を試して失敗していた記憶もあった。

しかし後のラウンドで批評 AI がこう返してきた。

前ラウンドの「露出・トーンマップの肩は 2 回試して却下、原理的に解けない」という結論は誤り。実測で解ける。

そして具体値まで出してきた。私が確かめると、そのとおりだった。

変更前 ショルダーを 0.72 → 0.40
日向ファサードの L>225 18.18% 0.02%
地面の輝度(犠牲になっていないか) 79.6 78.3
全体の平均輝度 124.7 122.3

壁だけが落ちて、地面はほぼ動いていない。 私が「解けない」と言っていたものは、パラメータ1つで解けた。

何を間違えたか。アルベドについての実測を、露出とトーンカーブにまで外挿したことだ。この3つは全部「明るさ」に効くので直感的には同じに見えるが、曲線上のどこに作用するかが違う。アルベドは入力側、ショルダーは出力の上端。ショルダーは「上端だけを寝かせる」ための道具で、まさにそのために存在している。実際に振ってみれば5分で分かったことを、実測せずに結論にしていた。

到達不能の宣言も、実測を伴わなければ単なる主観である。 これは評価表に「実測で到達不能と証明・文書化済み」という言い回しを使った理由でもある。「証明」と書いてあれば、次に読む人は根拠の実測値を探せる。無ければ疑える。

(なお、別の項目「暗部の床の平坦帯」については、実際にトウの形を振ってスイープしたうえで「トーンカーブでは解けない」と結論づけている。床を消すと山が L=0 へ移るだけで、しかも画面の 20.18% が真っ黒に潰れた。真因は最深部の遮蔽に間接光が無いことで、グレード側ではなく上流の問題として次に送った。同じ「解けない」でも、実測を伴うかどうかで意味がまったく違う。


実装:計測を「AI に採点させない」ための4つの道具

ここまでの話を支えている具体的な実装を置いておく。

1. オブジェクトマスク — その物体の画素だけを測る

考え方は素朴だ。対象メッシュを非表示にした絵を撮り、元の絵との差分を取る。 差が出た画素が、その物体が占めていた画素である。

Playwright でブラウザを操作している部分は、実プロジェクト(Three.js アプリ)に依存するので 抜粋 として示す。

// 【抜粋・Three.js アプリ + Playwright 前提】
// 指定した名前のメッシュだけを消した絵との差分でマスクを作る
async function maskByMeshName(page, shotName, namePredicateSrc, thresh = 18) {
  // (1) 通常の絵
  await page.evaluate(([n]) => window.__CAPTURE__.setShot(n, { warmup: 12 }), [shotName]);
  await page.waitForTimeout(220);
  const on = PNG.sync.read(await page.screenshot());

  // (2) 対象メッシュだけ visible=false にして、同じ構図をもう一度
  await page.evaluate(([src]) => {
    const pred = new Function('name', `return (${src});`);
    const hit = [];
    const walk = (root) => root.traverse((o) => {
      if (o.material && pred(o.name || '')) { hit.push(o); o.visible = false; }
    });
    walk(window.__GAME__.engine.viewScene);
    walk(window.__GAME__.engine.scene);
    window.__scHidden = hit;
    return hit.length;
  }, [namePredicateSrc]);
  await page.evaluate(([n]) => window.__CAPTURE__.setShot(n, { warmup: 12 }), [shotName]);
  await page.waitForTimeout(220);
  const off = PNG.sync.read(await page.screenshot());

  // (3) 元に戻す
  await page.evaluate(() => { (window.__scHidden || []).forEach((o) => { o.visible = true; }); });

  return buildObjectMask(on.data, off.data, on.width, on.height, thresh);
}

差分からマスクを作る部分と、それを使う測定関数は純粋な関数なので、そのまま動く形で示す。

// demo_mask.mjs — Node でそのまま動く
/** 2枚の RGBA から「消えた物体の画素」だけを 1 にしたマスクを作る */
function buildObjectMask(on, off, w, h, thresh = 18) {
  const mask = new Uint8Array(w * h);
  for (let i = 0, k = 0; i < on.length; i += 4, k++) {
    const d = Math.abs(on[i]     - off[i])
            + Math.abs(on[i + 1] - off[i + 1])
            + Math.abs(on[i + 2] - off[i + 2]);
    mask[k] = d > thresh ? 1 : 0;
  }
  return mask;
}

function toLuma(rgba, w, h) {
  const L = new Float64Array(w * h);
  for (let i = 0, k = 0; i < rgba.length; i += 4, k++) {
    L[k] = 0.2126 * rgba[i] + 0.7152 * rgba[i + 1] + 0.0722 * rgba[i + 2];
  }
  return L;
}

/** 局所SD(5x5) の中央値。mask を渡すと「窓が全てマスク内」の位置だけを使う */
function localSdMedian(L, w, h, mask = null) {
  const v = [];
  for (let y = 0; y < h - 5; y += 2) {
    for (let x = 0; x < w - 5; x += 2) {
      if (mask) {
        let all = true;
        for (let j = 0; j < 5 && all; j++)
          for (let i = 0; i < 5; i++) if (!mask[(y + j) * w + x + i]) { all = false; break; }
        if (!all) continue;    // 窓が物体からはみ出す位置は捨てる(=輪郭を拾わない)
      }
      let s = 0, s2 = 0;
      for (let j = 0; j < 5; j++) for (let i = 0; i < 5; i++) {
        const val = L[(y + j) * w + x + i]; s += val; s2 += val * val;
      }
      v.push(Math.sqrt(Math.max(0, s2 / 25 - (s / 25) ** 2)));
    }
  }
  if (!v.length) return null;
  v.sort((a, b) => a - b);
  return +v[Math.floor(v.length / 2)].toFixed(2);
}

/* --- 検証: 「平坦な背景 + ざらついた物体」を作り、固定矩形とマスクで比べる --- */
const W = 128, H = 128;
let seed = 99;
const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;
const on  = new Uint8ClampedArray(W * H * 4);
const off = new Uint8ClampedArray(W * H * 4);
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
  const i = (y * W + x) * 4;
  const bg = 60;                                          // 背景は完全に平坦
  off[i] = off[i + 1] = off[i + 2] = bg; off[i + 3] = 255; // 物体を消した絵
  const inObj = x >= 40 && x < 88 && y >= 40 && y < 88;    // 物体は中央 48x48
  const c = inObj ? 140 + (rnd() - 0.5) * 24 : bg;         // 物体はざらつく
  on[i] = on[i + 1] = on[i + 2] = c; on[i + 3] = 255;
}

const mask = buildObjectMask(on, off, W, H);
const L = toLuma(on, W, H);
console.log('マスク画素数:', mask.reduce((a, b) => a + b, 0), '(期待 48*48 =', 48 * 48, ')');
console.log('全画面(背景込み)の局所SD中央値:', localSdMedian(L, W, H));
console.log('オブジェクトマスク内の局所SD中央値:', localSdMedian(L, W, H, mask));

実行結果。

マスク画素数: 2304 (期待 48*48 = 2304 )
全画面(背景込み)の局所SD中央値: 0
オブジェクトマスク内の局所SD中央値: 6.75

同じ絵、同じ指標で、0 と 6.75。 背景が広いほど中央値は背景に支配される。批評エージェントが「敵の輪郭コントラストは実効ゼロ」と言ったのは、まさにこれをやっていたからだ。

実装上の注意点を2つ。

  • thresh は必要。ポストプロセス(AO・ブルーム・SSR)があると、物体を消した瞬間に周囲の画素もわずかに変わる。閾値なしだと画面の大半がマスクになる。私は 18(RGB の絶対差の和)にしている
  • 窓が全てマスク内である位置だけを使う。1画素でもはみ出す窓を許すと、物体の輪郭(=背景との段差)を拾って、値が実態より大きく出る

2. 構造テンソルの coherence — 「等方の粒」と「方向のある加工痕」を分ける

局所SD には根本的な弱点がある。情報量は測れるが、情報の質は測れない。

これが露呈したのが銃だった。局所SD は十分あるのに、絵は「砂岩を削り出した角材」に見える。原因を調べるために構造テンソルの coherence を測った。

銃の樹脂部品の coherence 中央値: 0.354
同じ絵の砂利:                    0.428
同じ絵の日向の砂:                0.419

銃の表面が、砂利や砂より方向性が無かった。 さらに normalScale を 0 にすると局所SD が 8.30 → 2.30 まで落ちた。つまり銃の情報量は、ほぼ全部が 法線マップの等方な粒ノイズでできていた。

人工物は加工の方向を持つ(押出成形の筋、機械加工のヘアライン、パーティングライン、成形のドラフト)。等方の粒しか無い表面は、どれだけ SD があっても「自然物」に見える。

coherence は、局所の勾配から構造テンソル

J = [ Σgx²  Σgxgy ]
    [ Σgxgy Σgy²  ]

を作り、固有値 λ1 ≥ λ2 の分離度 (λ1-λ2)/(λ1+λ2) を取る。これは行列を分解しなくても、√((Jxx-Jyy)² + 4Jxy²) / (Jxx+Jyy) で直接計算できる。

// demo_coh.mjs — Node でそのまま動く
/** 構造テンソルの coherence (9x9) の中央値。等方の粒か、方向のある痕かを分ける */
function coherenceMedian(L, w, h, mask = null) {
  const R = 4, v = [];
  for (let y = R + 1; y < h - R - 1; y += 2) {
    for (let x = R + 1; x < w - R - 1; x += 2) {
      if (mask) {
        let ok = true;
        for (let j = -R; j <= R && ok; j += 2)
          for (let i = -R; i <= R; i += 2) if (!mask[(y + j) * w + x + i]) { ok = false; break; }
        if (!ok) continue;
      }
      let jxx = 0, jyy = 0, jxy = 0;
      for (let j = -R; j <= R; j++) {
        for (let i = -R; i <= R; i++) {
          const k = (y + j) * w + x + i;
          const gx = (L[k + 1] - L[k - 1]) * 0.5;   // 中心差分
          const gy = (L[k + w] - L[k - w]) * 0.5;
          jxx += gx * gx; jyy += gy * gy; jxy += gx * gy;
        }
      }
      const tr = jxx + jyy;
      if (tr < 1e-6) continue;                       // 完全に平坦な窓は方向を定義できない
      const d = Math.sqrt((jxx - jyy) ** 2 + 4 * jxy * jxy);   // = λ1 - λ2
      v.push(d / tr);                                          // = (λ1-λ2)/(λ1+λ2)
    }
  }
  if (!v.length) return null;
  v.sort((a, b) => a - b);
  return +v[Math.floor(v.length / 2)].toFixed(3);
}

function sdOf(L) {
  let s = 0, s2 = 0;
  for (const x of L) { s += x; s2 += x * x; }
  return +Math.sqrt(s2 / L.length - (s / L.length) ** 2).toFixed(2);
}

/* --- 検証: 情報量はほぼ同じ、方向性だけが違う2枚 --- */
const W = 192, H = 192;
let seed = 7;
const rnd = () => (seed = (seed * 1103515245 + 12345) & 0x7fffffff) / 0x7fffffff;

// A: 等方の粒ノイズ(=どこから見ても同じ「砂」)
const A = new Float64Array(W * H);
for (let i = 0; i < W * H; i++) A[i] = 120 + (rnd() - 0.5) * 40;

// B: 方向のある加工痕(ヘアライン)+弱い粒
const B = new Float64Array(W * H);
for (let y = 0; y < H; y++) for (let x = 0; x < W; x++) {
  B[y * W + x] = 120 + 14 * Math.sin(y * 1.7) + (rnd() - 0.5) * 8;
}

console.log('A 等方の粒     SD=', sdOf(A), ' coherence=', coherenceMedian(A, W, H));
console.log('B 方向のある痕 SD=', sdOf(B), ' coherence=', coherenceMedian(B, W, H));

実行結果。

A 等方の粒     SD= 11.59  coherence= 0.136
B 方向のある痕 SD= 10.15  coherence= 0.947

SD はほぼ同じ(11.59 と 10.15)なのに、coherence は 0.136 と 0.947 で 7 倍違う。 「情報量が足りない」のか「情報の質が違う」のかを分離できる。

評価表には「武器の表面の方向性(構造テンソル coherence 中央値)≥ 0.55」という項目を追加した。対処も明確になる。等方の粒を減らし、失った情報量を「線」で取り返す(線は方向を持つので coherence が上がる)。

3. git worktree での A/B(再掲・要点)

本題1の罠4で書いたとおり。ポイントだけ再掲する。

  • --outDir を分けても src/ は共有。ソースの分離にはならない
  • 数値を報告するときだけ git worktree add /tmp/wt-me HEAD + パッチ適用
  • ポートも --strictPort で専用にする
  • before と after の差が、パッチ1枚ぶんであることを構造的に保証する

まとめ

この記事の主張を、もう一度だけ。

AI に品質を採点させるな。欠陥を見つけさせろ。

理由は「AI が信用できないから」ではない。むしろ逆で、AI は毎回賢くなるからである。賢くなるということは物差しが変わるということで、物差しが変わる採点者の点数を時系列で並べると嘘になる。

実運用でこうなった。

担当 性質
批評エージェント 欠陥の発見・仮説の提示 毎回変わってよい。むしろ賢くなってほしい
凍結した評価表 + 自動計測 合否の判定 絶対に変えない。追加はするが再定義はしない

そして、これをやると副作用がある。主観の議論をやめた瞬間、本物のバグが出てくる。

  • ハイライトの上限が 0.779 に固定されていた(光の序列が消えていた)
  • DoF が一人称武器に掛かっていた(照準器が画面で最もボケていた)
  • 撮影が非決定的だった(それ以前の全ての A/B が無効だった
  • 照準器の穴が「実寸は正しいのに見かけ角が実射の 1/5」だった

これらは全て「なんとなく安っぽい」「なんとなく眠い」として、主観の議論の下に埋まっていたものだ。

最後に、AI エージェントに長時間タスクを投げる人へ。

「AAA に見えるまで止めるな」のような終了条件を与えてはいけない。 それは停止しない。私が最終的に定義した終了条件はこれだった。

全項目が「クリア済み」または「実測で到達不能と証明・文書化済み」のいずれかになった時点。

「できませんでした」を、実測付きの文書として成果物に含めることを許す。これで初めてループは止まる。

参考

@kotaro_ai_lab
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?