デスクトップで動く AI エージェントを作っていて、デモ動画を撮った。
承認ダイアログに /Users/<利用者名>/Downloads/… がそのまま映っていた。
消せばいいだけの話だと思っていたが、目視の確認で 3 回続けて漏らした。
最後は完成品の全編を機械に走査させることになった。
結論を先に書くと、「映ってはまずいものが消えたか」を目で確かめるのは
成立しない。 消す処理と同じくらい、消えたことを機械で確かめる処理が要る。
決め打ちの枠では消えない
最初は素朴にやった。パスが出ているコマを 1 枚開いて座標を測り、
そこを delogo で潰す。
delogo=x=450:y=300:w=370:h=44:enable='between(t,99.9,102.4)'
出来上がりを確認すると、最初の 1〜2 秒だけ利用者名が出ていた。
原因を測って分かった。macOS のダイアログは飛び込んでくる。
出てから落ち着くまで、位置が動き続ける。
| 時刻 | ダイアログの上端 | 左端 |
|---|---|---|
| 98.4 | 372 | 458 |
| 98.8 | 230 | 180 |
| 99.2 | 19 | 462 |
| 99.6 | 71 | 407 |
| 100.0 | 94 | 398 |
| 102.4 | 94 | 398 |
落ち着いた 100.0 秒で測った枠は、98.4〜99.6 秒のどこにも当たらない。
しかもその間、文字はずっと読める。
区間を切り詰めて「落ち着いてから」だけ使う手もあるが、
今回はノーカットで出す動画だったので使えなかった。追いかけるしかない。
型紙を作って畳み込む
文字の大きさはダイアログが動いても変わらない。
なので、落ち着いたコマから /Users/sa の見た目を切り出して型紙にし、
各コマの上で滑らせて一番合う場所を探す。
素直に総当たりすると、1920×1080 の各位置で 150×38 の型紙と比べることになる。
200 万か所 × 5700 画素で、Python では動かない。
畳み込みはフーリエ変換で一発にできる。
def correlate(field, kernel):
"""畳み込みをフーリエ変換で。総当たりだと動かない"""
fh, fw = field.shape
F = np.fft.rfft2(field)
K = np.fft.rfft2(kernel[::-1, ::-1], s=(fh, fw))
out = np.fft.irfft2(F * K, s=(fh, fw))
kh, kw = kernel.shape
return out[kh - 1:, kw - 1:] # 左上を原点に揃える
一致の度合いは 2 つ見る。見落としと、食いつきすぎの両方を嫌う。
def find(img, tmpl):
d = dark(img)
hit = correlate(d, tmpl) # 型紙の黒 ∩ 画の黒
load = correlate(d, np.ones_like(tmpl)) # 窓の中の黒の総量
ink = tmpl.sum()
score = hit / (load + 0.35 * ink + 1e-6)
score[hit < 0.55 * ink] = 0 # 見落としは足切り
y, x = np.unravel_index(int(np.argmax(score)), score.shape)
return int(x), int(y), float(score[y, x])
hit だけ見ると、黒が密集した別の場所(コードの抜粋など)に食いつく。
load で割って「窓の中の余計な黒」を罰する。分母の 0.35 * ink は、
黒が少ない場所でスコアが爆発しないための下駄。
これで各コマの座標が出るので、delogo の enable に流し込む。
同じ位置が続く区間はまとめないと、フィルタが 500 本を超えて組めなくなる。
3 回漏らした
1 回目 — 飛び込んでいる途中
上に書いたやつ。固定の枠が当たらない。
2 回目 — 半透明で浮かび上がる間
追跡を入れても、まだ漏れた。ダイアログは薄いところから現れる。
dark() のしきい値を 120 にしていたので、薄いコマの文字が
「暗い画素」に数えられず、追跡がそこだけ始まらない。
文字は薄くても読める。
def dark(img):
# **しきい値は 150。** 120 だと、半透明で浮かび上がる間の
# 薄い文字を落とす。落とすと追跡が始まらず、そこだけ素通しになる
return (img < 150).astype(np.float32)
加えて、続いている区間の頭を 0.3 秒さかのぼり、尻を 0.2 秒延ばす。
3 回目 — ブラウザの URL 欄
これが効いた。
動画の最後で、作った HTML をブラウザで開く。
URL 欄に file:///Users/<利用者名>/… が 23 秒間出ていた。
ダイアログばかり見ていて、そこは探す場所として思いついていなかった。
思いつかないものは、目で探しても見つからない。
だから完成品を走査する
ここで方針を変えた。消す処理を信じるのをやめて、完成品を全編走査する。
TEMPLATES = [
(100.0, (452, 302, 150, 38)), # write_file … 大きめの字
(148.7, (850, 512, 90, 28)), # open_path … 小さい字
(152.0, (940, 12, 100, 30)), # ブラウザの URL 欄 … また別の字
]
型紙は 1 枚では足りない。 ダイアログの種類ごとに幅が違い、
字の大きさも違う。1 枚で通そうとすると、小さい方がまったく当たらず、
0.30 前後の偽の山に食いつく。
しきい値は追跡より厳しくする。実測では本物が 0.64〜0.74、
偽の山が 0.46 以下だったので 0.55 に置いた。
0.45 にしたらブラウザの小さなアイコンに 179 コマ食いついて、
本物が埋もれた。
この検査が、さらに 1 コマの漏れを見つけた。
追跡の刻みは、出力のコマ数に合わせる
見つかったのは 113.933… 秒の 1 コマだった。
追跡を 20 コマ/秒で回していた。つまり 113.90 と 113.95 を見ている。
出力は 30 コマ/秒なので、実際に出るのは 113.933… のコマ。
そこは誰も見ていない。
# (始め, 終わり, 1 秒あたり何コマ見るか)
WINDOWS = [
(113.4, 118.2, 30), # 飛び込んでくる。出力と同じ刻みで見る
(150.8, 174.8, 5), # 位置が動かない。粗くてよい
]
位置が動く区間だけ出力と同じ刻みにして、動かない区間は粗いままにする。
全部 30 にすると、フーリエ変換が 4000 回走って終わらない。
最終的に 2,225 コマを走査して漏れなし。
まとめ
- 動くものを固定の枠では隠せない。 ダイアログは飛び込んでくる
- 追跡はフーリエ変換で畳み込む。総当たりは動かない
- スコアは見落としと食いつきすぎの両方を罰する形にする
- 目視は 3 回漏れた。 うち 1 回は、探す場所として思いついていなかった
- 完成品を機械で走査する。 追跡の刻みは出力のコマ数に合わせる
そもそも画面に利用者名を出さなければこの作業は要らない。
アプリ側は ~/… 表記に直したので、次に撮る素材からは不要になる。
それでも検査は残す。 思いつかないものは、目では見つからないので。
踏んだ落とし穴だけを書いています。 同じところで止まった人の時間が少しでも減れば十分です。
他の記事も同じ形で、実際に測った数字と、直した手順だけを置いています。
(この話が出てきたのは Wisp という個人開発のアプリです)