Pillowで画像とキャラの重なりを自動で検出・回避する方法|止めると離れているのに重なる原因

Pillowで画像とキャラの重なりを自動で検出・回避する方法|止めると離れているのに重なる原因

止めると離れているのに、動画で見ると刺さっている。うちの掛け合い動画で、差し込んだ写真がキャラの王冠に刺さっていました。止めて確かめると、ちゃんと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事件簿】として動画にしています。

よかったらシェアしてね!
  • URLをコピーしました!
  • URLをコピーしました!

この記事を書いた人

AIを"部下"のように使って、ブログ運営や副業を効率化している運営者です。
煩雑な作業はAI部下たちに任せて、空いた時間を仕事・趣味・大切な人に。
「楽して稼ぐ」ではなく「時間を取り戻す」をモットーに、AI活用のリアルを正直に発信しています。

目次