止めると離れているのに、動画で見ると刺さっている。うちの掛け合い動画で、差し込んだ写真がキャラの王冠に刺さっていました。止めて確かめると、ちゃんと4ピクセル離れています。ところがキャラが喋った瞬間だけ、写真が王冠を貫通する。原因は写真ではなく、キャラのほうでした。
先に結論です。動くものと重ならないかは、止まっている姿ではなく「いちばん大きくなる瞬間」で確かめる必要があります。この記事では、うちで起きた2つの事故と、PythonのPillowで重なり(衝突)を全コマ自動で検出する方法、全部の姿を最大の大きさで重ねた「立ち入り禁止の領域」で画像を自動で縮めて回避する方法を、短いコードつきで置いておきます。
うちの動画の作り方(前提)
うちのYouTubeは、ボスと部下の2匹のキャラが掛け合う紙芝居型の動画です。PythonのPillowで1コマずつ絵を組み立てて、ffmpegで動画にしています。喋っているキャラは1.07倍に大きくなり、さらに小さく揺れます。揺れまで入れると、最大で約1.09倍です。画面の真ん中には、説明用の写真や図を差し込みます。
事故1:高さに合わせて拡大したら、画面からはみ出した
最初の事故は単純でした。差し込む画像は高さに合わせて拡大する決まりにしていたので、横長の画面写真を入れたところ、横幅が画面を超えてはみ出しました(横1920の画面に対して2457ピクセル)。

これは横幅に上限をつけて直しました。ここまでは、よくある話です。
事故2:喋った瞬間だけ、写真がキャラに重なった(王冠に刺さった)
上限をつけて、止めた画面で確かめると、写真の下端と王冠の間には余白がありました。ところが動画で見ると、ボスが喋った瞬間だけ、写真が王冠に刺さります。気づいたのは、検収で動画を見ていた人間の目でした。

当時と同じ条件(写真の横幅1087ピクセル、下端は止まっている姿の4ピクセル手前)で再現して測ると、こうなりました(画面の上からの位置)。
| ボスの大きさ | 写真の幅の範囲で、ボスのいちばん上 |
|---|---|
| 止まっている(1.00倍) | 434ピクセル |
| 喋っている(1.07倍) | 405ピクセル |
| 揺れのいちばん大きい所(約1.09倍) | 396ピクセル |
写真の下端は430ピクセルでした。止まっている時は4ピクセルの余白がありますが、喋ると王冠が最大34ピクセル写真の中に入ります。写真の大きさを、止まっている姿で決めていたのが原因でした。
Pillowで重なりを自動回避する:全部の姿を最大の大きさで重ねた「領域」を作る
キャラの表情は何種類もあり、どの表情で喋るかは場面ごとに違います。そこで、全部の表情の絵を、喋っている時の最大の倍率で重ね、さらに余白を足した「立ち入り禁止の領域」を先に1枚作っておきます。差し込む画像がこの領域に触れたら、触れなくなるまで少しずつ縮めます。
# キャラの「立ち入り禁止の領域」を作る(抜粋)
from PIL import Image, ImageFilter
W, H = 1920, 1080
SPRITE_H, BASELINE = 540, 860
MAX_SCALE = 1.07 + 0.018 + 0.01 # 喋る倍率+揺れ+念のため
mask = Image.new("L", (W, H), 0)
for (cx, sprite) in characters: # (キャラの中心x, 透過PNG) を全部の表情ぶん
h = int(SPRITE_H * MAX_SCALE); w = int(sprite.width * h / sprite.height)
a = sprite.getchannel("A").resize((w, h)).point(lambda v: 255 if v > 40 else 0)
mask.paste(255, (cx - w // 2, BASELINE - h), a)
mask = mask.filter(ImageFilter.MaxFilter(25)) # 周りに12ピクセルの余白
def fit_outside(img, y):
"""画像が領域に触れたら、触れなくなるまで2%ずつ縮める(中央に置く前提)"""
r = 1.0
while r > 0.4:
w, h = int(img.width * r), int(img.height * r)
x = (W - w) // 2
if mask.crop((x, y, x + w, y + h)).getbbox() is None:
break
r -= 0.02
return img.resize((int(img.width * r), int(img.height * r)))
ポイントは2つです。1つは、倍率に揺れの分まで足すこと。もう1つは、MaxFilterで領域を少し太らせて余白にすることです(25ピクセルの窓で、周りに12ピクセル)。縮めたときは、どの画像を何%にしたかをログに出すようにしています。

重なりを自動で検出する:全コマの「衝突判定」
回避の仕組みを入れても、それが効いているかは確かめたくなります。今回の事故も、見つけたのは人間の目でした。目で見なくても見つけられるように、動画を書き出す前に、全コマでキャラと画像の重なりを数える検査を置くと安心です。キャラの大きさはコマごとに決まっているので、絵を作る前に計算だけで調べられます。
# 全コマの衝突判定(抜粋): 画像の四角とキャラの形が1ピクセルでも重なったコマを数える
import numpy as np
def char_mask(scale):
# その倍率で置いたときのキャラの形(白=キャラ)を画面サイズで返す
...
hits = []
for i, scale in enumerate(scales_per_frame): # コマごとの倍率(喋る・揺れを含む)
m = np.array(char_mask(scale)) > 0
x0, y0, x1, y1 = image_box_per_frame[i] # そのコマで画像を置く四角
n = int(m[y0:y1, x0:x1].sum())
if n:
hits.append((i, n))
print(f"重なったコマ: {len(hits)}", hits[:5]) # 0でなければ書き出す前に止める
今回の条件で数えると、止まっている姿(1.00倍)では重なりは0、喋っているコマでは重なりが出ます。0でなければ書き出しを止めるようにしておけば、人間の目に頼らずに済みます。うちのレンダラーは、画像を縮めたときに「どの画像を何%にしたか」をログに出していて、これも検出の代わりになっています。
静止画のチェックが見落とすもの
今回の件で分かったのは、「止めて確かめた」は「動いても大丈夫」の証明にならないということです。動画やアニメーション、スマホの画面の回転、文字の長さが変わるボタンなど、大きさが変わるものは同じ罠を持っています。
- 動くものと重ならないかは、いちばん大きくなる瞬間で確かめる
- 「いちばん大きい」が場面で変わるなら、全部の姿を重ねた領域で確かめる
- 手で確かめる回数を減らすため、触れたら自動で縮める仕組みにする
まとめ
- 高さに合わせて拡大すると、横長の画像は画面からはみ出す(2457ピクセル)。横幅にも上限をつける
- キャラは喋ると1.07倍、揺れて最大約1.09倍。止まっている姿で決めた余白(4ピクセル)は、喋ると消える
- 直し方は、全部の表情を最大の倍率で重ねた領域を作り、触れたら2%ずつ縮める
- 止まっている時の「大丈夫」は、動いた時の保証にならない
動画のつなぎ目を数字で確かめる方法は、ループ動画のつなぎ目がカクつく原因を調べたら、2コマ飛んでいたにまとめています。今回の件は、うちのYouTubeでも【AI事件簿】として動画にしています。

