縦動画(1080×1920、60fps、約27.7秒、HEVC動画、AAC音声)の上部に、黒フチ(文字の周囲の黒い輪郭線)つきの2行の黄色い焼き込み字幕(映像に直接合成された字幕)が入った素材がありました。被写体は建物、投光器、群衆が映る夜の街並みです。後工程の動画編集素材として使うため、最初の15秒間について字幕を除去した動画(1080×1920、30fps、450フレーム、H.264、音なし)が必要になりました。音声(AAC、15.0秒)は単独で切り出して最終動画に戻します。
本記事では字幕位置の測定、色マスク・膨張(cv2.dilate)・Telea法インペインティング(周囲の画素から欠損領域を補完する処理)による消去、ffmpegでの仕上げ、フレームグリッド(一定間隔のコマを並べた一覧画像)と差分画像(2画像を引き算して変化を可視化した画像)による1秒ごとの検証という4手順を実際のコードとコマンドで示します。
| 手法 | 誤差 |
|---|---|
| 字幕付きの入力 | 63.94 |
| 同一範囲へのffmpeg delogo | 11.24 |
| 色マスク+膨張+Telea(本記事) | 6.09 |
| 参考:再エンコードのみの差分(字幕外の画素) | 3.51 |
表A 処理範囲(960×165)における正解との平均絶対誤差(15フレーム)
表Aの通り、色マスク方式の誤差はdelogoの約半分で、再エンコード単体の誤差に近い値です。
各図や比較数値は、正解(字幕焼き込み前のフレーム)と比較できる練習用クリップ(字幕なし動画をlanczosで1080×1920に拡大し、同位置・同サイズで字幕を焼き込んだ15秒動画)の実測値です。作り方は最後の節に記載します。
動作環境
- macOS(Apple M2)
- Python 3.14.7
- opencv-python-headless 5.0.0(4.14.0でも動作確認済み)
- numpy 2.5.4
- ffmpeg 8.1.2
uvを使うと仮想環境を作らずにスクリプトを直接実行できます。
uv run --with opencv-python-headless --with numpy python locate_caption.py frame.png
手順1:字幕の位置を測定する
代表フレームを1枚画像として書き出します。
ffmpeg -ss 5 -i src.mp4 -frames:v 1 frame.png
書き出した画像から、黄色の画素が集中している矩形範囲を測定します。
# 1フレームの画像から、黄色い焼き込み字幕の外接矩形を測る
# 使い方: python locate_caption.py frame.png
import sys, cv2, numpy as np
im = cv2.imread(sys.argv[1])
hsv = cv2.cvtColor(im, cv2.COLOR_BGR2HSV)
m = cv2.inRange(hsv, (20, 120, 150), (40, 255, 255)) # 濃い黄色だけ(H は 0〜179)
ys, xs = np.where(m > 0)
sel = ys < im.shape[0] // 2 # 字幕は上半分にあるので、下半分の黄色は数えない
print(im.shape[1], im.shape[0], "yellow box",
xs[sel].min(), xs[sel].max(), ys[sel].min(), ys[sel].max(), sel.sum())
実動画のフレームに対する出力結果です。
1080 1920 yellow box 83 998 380 502 31678
OpenCVのHSV色空間ではH(色相)が0〜179(一般的な角度の半分)で表されるため、黄色は30前後に位置します。測定用の判定条件は誤検出を防ぐため、H 20〜40、S 120以上、V 150以上という鮮やかで明るい黄色のみに絞っています。画面下部に映る衣服などの黄色を拾わないよう、字幕が存在する画面上半分(ys < im.shape[0] // 2)の画素のみを数えます。
実動画の1秒、5秒、12秒、20秒の各フレームを測定したところ、すべてx 83〜998、y 380〜502で一致し、画素数も31,678〜31,715個でした。字幕の位置は固定されているため、全フレームを同一の固定矩形で処理できます。次手順の処理範囲(関心領域)は、この測定矩形の上下左右に約20pxの余白を加えた (60, 360)-(1020, 525)(幅960、高さ165)に設定します。
手順2:字幕を消去する(色マスク・膨張・Telea法)
動画の各フレームを読み込み、色マスクの生成、膨張処理、Telea法による補完を適用します。
# 焼き込み字幕を消す: 黄色マスク → 膨張で黒フチまで覆う → Telea で補完
# 使い方: python inpaint_caption.py 入力.mp4 出力.mp4 秒数 間引き
# 例) 60fps の元動画の最初の15秒を、1コマおき(30fps)で書き出す → 15 2
import sys, cv2, numpy as np
src, dst, seconds, step = sys.argv[1], sys.argv[2], float(sys.argv[3]), int(sys.argv[4])
cap = cv2.VideoCapture(src)
fps = cap.get(cv2.CAP_PROP_FPS)
w, h = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
out = cv2.VideoWriter(dst, cv2.VideoWriter_fourcc(*"mp4v"), fps / step, (w, h))
X0, Y0, X1, Y1 = 60, 360, 1020, 525 # 字幕の外接矩形 (83-998, 380-502) に約20pxの余白
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
n = kept = 0
while n < round(seconds * fps):
ok, f = cap.read()
if not ok:
break
if n % step == 0:
roi = f[Y0:Y1, X0:X1]
hsv = cv2.cvtColor(roi, cv2.COLOR_BGR2HSV)
m = cv2.inRange(hsv, (15, 90, 120), (45, 255, 255)) # 測るときより広めの黄色
m = cv2.dilate(m, k, iterations=1) # 黒フチまで覆う
f[Y0:Y1, X0:X1] = cv2.inpaint(roi, m, 9, cv2.INPAINT_TELEA)
out.write(f)
kept += 1
n += 1
out.release()
print(kept, "frames at", fps / step)
実動画では間引き(step)を2にして30fps化し、練習用クリップでは1を指定します。
# 60fps の元動画: 1コマおきに書き出して 30fps にする
uv run --with opencv-python-headless --with numpy python inpaint_caption.py src.mp4 raw15.mp4 15 2
# 30fps の練習用クリップ: 間引かない
uv run --with opencv-python-headless --with numpy python inpaint_caption.py src.mp4 raw15.mp4 15 1
どちらの実行でも450フレームが出力されます。
450 frames at 30.0
なぜ処理範囲を絞るのか
処理対象を960×165の矩形内に限定する理由は、背景の誤検出防止と処理速度の確保です。
字幕なし練習用動画の全体にマスク用の色範囲を適用すると、1フレームあたり68〜1,102個(1秒平均)の画素が黄色として検出されました。11.2秒時点では群衆の柄シャツを中心に8箇所で計1,784画素が検出され、うち1箇所は処理範囲右端から26pxの位置でした。
一方、960×165の矩形内では全450フレームで検出画素数が0でした。重い色変換とインペインティングをこの小領域に絞ることで処理時間を抑えられます。
マスクの色範囲を測定時より広げる理由
消去用のマスク生成では、測定用(H 20〜40、S 120以上、V 150以上)より広い色範囲(H 15〜45、S 90以上、V 120以上)を使用します。
練習用クリップでは狭い範囲で1フレームあたり43,371画素、広い範囲で1フレームあたり44,143画素が検出されました。差分の約800画素は黄色が黒フチへ溶け込む文字境界です。
膨張なしの場合、狭い範囲ではインペインティング後に文字色の画素が1フレームあたり772個残りましたが、広い範囲では0個でした。15×15の膨張を適用するとどちらも同一の誤差(6.25)になります。膨張が主要な役割を果たし、広い色指定は境界の安全マージンとなります。
黒フチを完全に覆う膨張処理
インペインティングはマスク外周の画素を参照して内側を補完します。黄色部分だけをマスクすると周囲の黒フチが参照され、文字の内側が黒く塗りつぶされます。
補完処理を行うcv2.inpaintの関数仕様では、入力画像、単一チャンネルのマスク、近傍半径、補完手法のフラグを指定します。
| カーネル | 誤差 | 暗い画素数 |
|---|---|---|
| なし | 108.31 | 78,642 |
| 5×5 | 113.21 | 83,528 |
| 9×9 | 96.77 | 77,770 |
| 15×15(採用) | 6.25 | 0 |
| 21×21 | 7.37 | 0 |
表B 膨張カーネル別の誤差と暗い画素数(Telea、半径9、15フレーム)
表Bの「暗い画素数」は正解より輝度が60以上暗い画素の1フレーム平均です。文字と黒フチの合計面積は約84,000画素、練習用クリップの黒フチ幅は6pxです。
9×9カーネル(4px膨張)ではフチを覆い切れず文字が黒く残ります。15×15カーネルは全方向に7px膨張させるため6pxのフチを完全に覆い、暗い画素が0になります。21×21でも消去できますが、補完面積が89,380画素から102,458画素に広がり誤差が7.37へ微増します。
半径と補完アルゴリズム
インペインティングにはTelea法(cv2.INPAINT_TELEA)とNS法(cv2.INPAINT_NS)があります。
| 手法 | 半径 | 誤差 | 1フレームの処理時間 |
|---|---|---|---|
| Telea(採用) | 9 | 6.27 | 194 ms |
| Telea | 3 | 6.31 | 32 ms |
| NS | 9 | 6.45 | 263 ms |
| NS | 3 | 6.54 | 41 ms |
表C 手法と半径による誤差と処理時間(15×15膨張、60フレーム、M2実測)
表Cの通り、実スクリプトで採用したTelea法・半径9に対し、Telea法・半径3は誤差6.31と半径9(6.27)に近い精度を保ちつつ、処理時間を約6分の1(194msから32ms)に短縮できます。NS法は両半径で誤差・時間ともにTelea法に及びませんでした。
スクリプトのstep引数はフレームの間引き間隔です。60fps動画で15 2を指定すると30fps・450フレームが出力され、30fpsの練習用クリップでは15 1を指定します。
手順3:ffmpegでH.264に変換して音声を戻す
cv2.VideoWriterでmp4v FourCCを指定して書き出した動画はMPEG-4 Part 2形式になり、音声は含まれません。
Chrome 155環境ではcanPlayType('video/mp4; codecs="mp4v.20.9"')が空文字列を返し再生不能です。一方H.264(avc1.640028)は"probably"を返します。そのためffmpegでH.264(CRF 18、yuv420p)に変換し、音声を結合します。
# 音声だけ、最初の15秒を切り出す
ffmpeg -i src.mp4 -t 15 -vn -c:a aac -b:a 160k sound-0-15.m4a
# mp4v → H.264(音なしのまま)
ffmpeg -i raw15.mp4 -c:v libx264 -crf 18 -pix_fmt yuv420p -an clip-0-15.mp4
# 映像と音を合わせる(再エンコードしない)
ffmpeg -i clip-0-15.mp4 -i sound-0-15.m4a -map 0:v -map 1:a -c copy -movflags +faststart clean15.mp4
# 確認
ffprobe -v error -show_entries stream=codec_name,nb_frames:format=duration -of compact clean15.mp4
音声は最初の15秒を切り出し、再エンコードなし(-c copy)で結合します。-movflags +faststartでインデックスを先頭に移し、再生開始を早めます。出力結果は以下の通りです。
stream|codec_name=h264|nb_frames=450
stream|codec_name=aac|nb_frames=646
format|duration=15.000000
練習用クリップでは切り出した音声が14.977秒と映像(15.000秒)よりわずかに短くなりました。結合時に-shortestを付けると短い音声に合わせて映像末尾が切り捨てられ449フレームになりますが、外せば全450フレームが維持されます。実動画では音声が15.000秒あり欠落しませんでしたが、素材に応じた確認が必要です。
実案件のパイプラインでは無音のH.264動画(clip-0-15.mp4)を後工程へ渡し、音声は最終動画に戻しました。
手順4:1秒ごとの画質検証を行う
消去処理の完了後、字幕の消え残りや周囲の崩れがないかを1秒単位で検証します。
1秒ごとのフレーム一覧グリッド
ffmpegのフィルタを連結し、1秒ごとのフレームを並べた一覧画像を出力します。
ffmpeg -i clean15.mp4 -vf "fps=1,crop=1080:702:0:0,scale=360:-2,tile=5x3" -frames:v 1 -q:v 3 top-1s.jpg
fps=1で0秒から14秒までの先頭15枚を抽出します。crop=1080:702:0:0で字幕帯を含む上部702pxを切り出し、scale=360:-2でスマホ表示に近い幅360pxに縮小した上で、tile=5x3で並べます。
グリッドでは黄色や黒フチの残り、ぼやけた文字形状、帯を横切る被写体の崩れを確認します。練習用クリップでは6秒付近で左から手が進入しますが、図3の全コマで黄色・フチ・文字形状の残りはなく、手の輪郭も崩れていません。
原本・処理後・差分の3段比較
同一フレームの元画像、処理後画像、差分画像を1枚に縦連結します。
ffmpeg -ss 6.3 -i src.mp4 -ss 6.3 -i clean15.mp4 -filter_complex \
"[0:v]crop=1080:220:0:332,format=rgb24,split[a][a2];\
[1:v]crop=1080:220:0:332,format=rgb24,split[b][b2];\
[a2][b2]blend=all_mode=difference,format=gray,lut=y='min(val*4\,255)',format=rgb24[d];\
[a][b][d]vstack=inputs=3" -frames:v 1 compare.png

図4 6.3秒時点における原本・処理後・差分(4倍強調)の比較
各ブランチにformat=rgb24を指定します。これを省くと差分ブランチのgray形式にvstackが引きずられ画像全体が白黒化します。図4の差分(4倍強調)では文字と黒フチの領域のみが白くなり、左の手は変化していません。
残留画素数の自動カウント
処理範囲内の黄色画素数を全フレーム数えるスクリプトを実行します。
# 字幕の範囲に黄色の画素がいくつ残っているかを、全フレームで数える
# 使い方: python count_yellow.py clean15.mp4
import sys, cv2
cap = cv2.VideoCapture(sys.argv[1])
X0, Y0, X1, Y1 = 60, 360, 1020, 525
counts = []
while True:
ok, f = cap.read()
if not ok:
break
hsv = cv2.cvtColor(f[Y0:Y1, X0:X1], cv2.COLOR_BGR2HSV)
counts.append(cv2.countNonZero(cv2.inRange(hsv, (15, 90, 120), (45, 255, 255))))
print(len(counts), "frames, max yellow px =", max(counts))
練習用クリップの実行結果です。
src.mp4 -> 450 frames, max yellow px = 44213
clean15.mp4 -> 450 frames, max yellow px = 42
消去後に黄色画素が残ったのは450コマ中2コマのみ(6.33秒で42px、6.37秒で11px)で、位置は左下隅(x 61〜73、y 510〜519)でした。拡大確認したところ進入した手の皮膚であり、元動画の同位置には該当画素がなく補完領域からも約70px離れていたため、再エンコードによる色変化です。
実動画では294コマで最大252pxが検出されました。背景の黄色系壁(右端のオレンジ壁や文字下の黄緑壁)が判定に入ったためです。検出数上位のコマを目視拡大し、字幕の残りではないことを確認しました。カウントで対象を絞り、目視で判定します。
練習用クリップの作成
検証用の練習用クリップは字幕なしの縦動画から作成できます。
ffmpeg -i sample.mp4 -vf "scale=1080:1920:flags=lanczos" -c:v libx264 -crf 16 -pix_fmt yuv420p -c:a copy base.mp4
uv run --with opencv-python-headless --with numpy python make_practice_clip.py base.mp4 practice_raw.mp4
ffmpeg -i practice_raw.mp4 -i base.mp4 -map 0:v -map 1:a -c:v libx264 -crf 18 -pix_fmt yuv420p -c:a copy src.mp4
字幕合成スクリプトの内容です。
# 練習用: 縦動画(1080x1920)に、黄色+黒フチの字幕を2行焼き込む
# 使い方: python make_practice_clip.py 入力.mp4 出力.mp4
import sys, cv2, numpy as np
src, dst = sys.argv[1], sys.argv[2]
lines = [("THIS CAPTION IS BURNED IN", 439), ("ERASE ME WITH OPENCV", 501)] # (文字列, ベースラインのy)
font, thick, edge = cv2.FONT_HERSHEY_DUPLEX, 8, 6 # edge = 黒フチの太さ(px)
# OpenCV 4 と 5 で字形が違うので、1行目の幅が 900px になるよう文字の大きさを決める
scale = 2.2 * 900 / cv2.getTextSize(lines[0][0], font, 2.2, thick)[0][0]
cap = cv2.VideoCapture(src)
fps = cap.get(cv2.CAP_PROP_FPS)
w, h = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)), int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
# 字幕は動かないので、文字のマスクとフチのマスクを最初に1回だけ作る
text = np.zeros((h, w), np.uint8)
for s, y in lines:
(tw, _), _ = cv2.getTextSize(s, font, scale, thick)
cv2.putText(text, s, ((w - tw) // 2, y), font, scale, 255, thick, cv2.LINE_AA)
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * edge + 1, 2 * edge + 1))
a_edge = (cv2.dilate(text, k) / 255.0)[..., None] # フチ = 文字を太らせた形
a_text = (text / 255.0)[..., None]
black, yellow = np.zeros(3), np.array([0, 255, 255]) # BGR
out = cv2.VideoWriter(dst, cv2.VideoWriter_fourcc(*"mp4v"), fps, (w, h))
while True:
ok, f = cap.read()
if not ok:
break
f = f * (1 - a_edge) + black * a_edge
f = f * (1 - a_text) + yellow * a_text
out.write(f.astype(np.uint8))
out.release()
OpenCVのバージョンに関する留意点が2点あります。
1点目はcv2.putTextの太さです。OpenCV 5.0では内蔵フォントが刷新され、thickness引数がフォントウェイトのように動作します。太い黒文字の上に細い黄文字を重ねてもフチが出ないため、文字マスクを6px膨張させてフチマスクを作っています。
2点目は描画幅です。OpenCV 4.14は同一スケールで文字が幅広になり(x 44〜1023)、処理範囲をはみ出します。そのため1行目が幅900pxになるよう動的にスケールを計算し、OpenCV 4.14でx 89〜979、OpenCV 5.0でx 91〜984に収まるようにしています。練習用クリップ(450フレーム)の消去処理時間はApple M2で93秒でした。
まとめ
- 処理範囲と余白: 字幕矩形に約20pxの余白を加えた領域に絞ることで、背景黄色の誤検出を防ぎ処理を高速化します。
- フチを超える膨張: インペインティング時の文字黒変を防ぐため、フチ幅(6px)を上回る15×15楕円カーネル(7px拡大)でマスクを膨張させます。
- Telea法と半径3: この練習用クリップではTelea法・半径3を選ぶことで、半径9同等の精度(誤差6.31対6.27)を保ち補完時間を約6分の1(32ms対194ms)に短縮できます。
-
ffmpeg変換と
-shortest: mp4vはChrome 155で非対応のためH.264へ変換し、音声の末尾欠落を防ぐため-shortestの要否を素材長から判断します。 - グリッド・差分・画素カウント: 1秒グリッドで全体を俯瞰し、差分で被写体の輪郭を確認し、残留色カウントで要確認コマを絞り込みます。
※ 筆者は写真1枚から被写体の人物が踊る15秒の縦型(9:16)ダンス動画を生成するWebサービス「Charme Dance AI」の開発に携わっています。本記事で解説した処理手順は同サービスの組み込みクリップの準備工程で使用されたものであり、練習用クリップも同サービスのサンプル動画をもとに作成しています。


