diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 35a1cb1..a58c1a6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -34,6 +34,27 @@ jobs: - name: Lint run: pnpm -r --if-present lint + # ランナーイメージ同梱の python3 に依存すると、GitHub がイメージを更新した際に + # リポジトリ側の変更なしに ci-green(必須チェック)が壊れうる。版を固定する。 + - name: Setup Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + # 顔モザイクのテスト(tests/face-mosaic-check.py)が cv2 を使う。 + # requirements.txt 全体は文字起こしの重い依存を含み CI では不要なので opencv だけを入れる。 + # 版の指定は requirements.txt を唯一の正とし、そこから抜き出して使う(二重管理で食い違わせない)。 + # 入れずにテストをスキップさせるのは AGENTS.md が禁じる「偽の緑」にあたるため行わない。 + - name: Install Python deps for face tests (opencv only) + run: | + spec=$(grep -E '^opencv-python-headless' video-shorts/requirements.txt) + if [ -z "$spec" ]; then + echo "FAIL: opencv-python-headless が video-shorts/requirements.txt に見つかりません" + exit 1 + fi + echo "installing: $spec" + python -m pip install "$spec" + - name: Test run: pnpm -r test diff --git a/docs/roadmap.html b/docs/roadmap.html index 06724db..69c5515 100644 --- a/docs/roadmap.html +++ b/docs/roadmap.html @@ -118,11 +118,11 @@ { "meta": { "title": "kosespark video-shorts ロードマップ", - "active": "M-1-A", - "next": "M-1-A(動画に写っている顔の位置が分かる)から着手する。video-shorts/src/ に顔検出モジュールを追加し、tests/ に固定素材でのユニットテストを置く。実装順は M-1 → M-2 → M-3/M-4 → M-5(deps参照)。M-5-C(実素材での見落とし率)はマスターから実素材の動画提供を受け次第に実施する。P1-10/P1-11/P1-12 等の既存todoは G-MOSAIC 完了後に再開する。", + "active": "M-2-A", + "next": "M-2(特定の人だけ隠し方を変えられる)へ進む。SFace(Apache2.0)のモデルとライセンスを src/models/ へ同梱し、face_mosaic.py の mosaic_frames(block_for=...) に参照顔の照合を接続する。M-5-C(実素材での見落とし率)はマスターから実素材の動画提供を受け次第に実施する。P1-10/P1-11/P1-12 等の既存todoは G-MOSAIC 完了後に再開する。", "handoff": { - "done": "マスター指示により顔モザイク機能(G-MOSAIC)の調査と原子ツリー作成を実施。着手前の実機検証で技術選定を確定した(顔検出=YuNet/MIT、顔識別=SFace/Apache2.0、いずれもopencv-python同梱APIで動きPyTorch等は不要)。GUIは作らない方針を確定: build-dist.mjsがserver/・webapp-mockup/・ui/を配布除外しstart-here.mdが「すべてチャットとフォルダ操作で完結」と定めているため、対象者の指定は顔画像のファイル書き出し+チャットでの番号選択で行う。ツリーはG-MOSAIC配下にM-1〜M-5の5系統・葉17件として作成した。実装は未着手(AGENTS.md「ツリーが出来るまで実装に入らない」に従う)。", - "trouble": "着手前検証の過程で、実機確認を伴わない机上の技術提案と、検証コード自体の欠陥による誤った計測を複数回出した(ffmpegのpixelizeで位置を動かす案は当該フィルタにx/yが無く不成立、顔の最小サイズ閾値が過大で識別処理が一度も走らないまま「識別は0.01秒」と報告しかけた、出力検証でアスペクト比を無視して縮小し素顔漏れと誤判定した)。詳細と教訓はdocs/failures.md 2026-08-04の2件に追記。" + "done": "G-MOSAIC の原子ツリーを作成し(PR #27 マージ済み)、続けて M-1 の4葉を実装した。src/face_mosaic.py を新設し、YuNetモデルとMITライセンス本文を src/models/ へ同梱、tests/face-mosaic-check.py(16件)と固定素材を追加、requirements.txt と CI に opencv を配線した。技術選定・設計上の必須事項・実測値は G-MOSAIC / M-1-B / M-1-D の detail を参照。", + "trouble": "M-1-D の実装当初、モザイクの粗さを顔サイズ比のみで決めたところテストが落ちた。実測すると閾値は比率だけでも絶対値だけでも決まらず(小さい顔ほど必要な比が大きく、大きい顔では絶対値が10px前後で頭打ち)、両方の max を取る形に是正した。実測表は M-1-D の detail に記録。" }, "updated": "2026-08-04" }, @@ -963,12 +963,12 @@ "kind": "state", "theme": "動画に写っている顔の位置が分かる", "meaning": "隠す対象がどこにいるかを機械が把握できる", - "status": "todo", + "status": "done", "criteria": [ { "text": "顔が写った固定テスト素材を処理すると、写っている人数と同じ数の顔位置が返る", "verify": "既知の人数の顔を含む固定テスト素材で顔検出を実行し、検出数が期待値と一致することをテストで確認する", - "evidence": "" + "evidence": "https://github.com/rahiseko-alt/ai-editer/actions/runs/30875193419" } ] }, @@ -978,12 +978,12 @@ "theme": "顔が動いても隠したまま追いかける", "meaning": "人が動くとモザイクが置いていかれる、が起きない", "detail": "検出は間引き(数フレームおき)で行い、間はトラックIDで対応付けて補間する。事前検証で、順序で補間するとキーフレーム間で人物の並びが入れ替わった瞬間に枠が人物間を飛び素顔が露出するバグを実際に踏んだため、対応付けはトラックIDで行うことを設計上の必須事項とする。", - "status": "todo", + "status": "done", "criteria": [ { "text": "顔が移動する固定テスト動画で、全フレームの隠し枠が実際の顔位置に重なっている", "verify": "顔が移動する固定テスト動画を処理し、全フレームで隠し枠と実際の顔位置の重なり(IoU)が0.5以上であることをテストで確認する", - "evidence": "" + "evidence": "https://github.com/rahiseko-alt/ai-editer/actions/runs/30875193419" } ] }, @@ -992,12 +992,12 @@ "kind": "state", "theme": "一瞬顔を見失っても素顔が出ない", "meaning": "横を向いた瞬間だけモザイクが消える、が起きない", - "status": "todo", + "status": "done", "criteria": [ { "text": "顔検出が途切れたコマでも、直前の位置で隠しが継続する", "verify": "検出結果を人為的に欠落させた入力で処理し、欠落コマにも隠し枠が出力されることをテストで確認する", - "evidence": "" + "evidence": "https://github.com/rahiseko-alt/ai-editer/actions/runs/30875193419" } ] }, @@ -1006,13 +1006,13 @@ "kind": "state", "theme": "隠した顔は本当に判別できなくなっている", "meaning": "モザイクをかけた気になっているだけ、が起きない", - "detail": "事前検証で、顔の高さ250pxに対しブロック8px以下では顔検出も本人識別も通ってしまい(類似度0.76〜0.95)、10px以上で顔として検出されなくなることを実測した。粒度は固定pxではなく顔サイズに対する比で持つ設計とする(目安: ブロック >= 顔の高さの約1/14)。", - "status": "todo", + "detail": "事前検証で、顔の高さ250pxに対しブロック8px以下では顔検出も本人識別も通ってしまう(類似度0.76〜0.95)ことを実測した。実装時に顔サイズを変えて閾値を測り直したところ、必要なブロックサイズは比率だけでも絶対値だけでも決まらないと判明: 顔高さ65px->最小7px(1/9)、111px->9px(1/12)、181px->10px(1/18)、290px->10px(1/29)。小さい顔ほど必要な比が大きく、大きい顔では絶対値が10px前後で頭打ちになる。よって block = max(顔高さ x 1/8, 12px) と両方を効かせる実装にした(face_mosaic.py の BLOCK_RATIO_DEFAULT / BLOCK_MIN_PX)。なおこの表は「機械が顔として検出できなくなる」境界であって「人が本人と分からなくなる」境界ではないため、大きく写った顔ほど比を効かせて粗くしている。", + "status": "done", "criteria": [ { "text": "出力動画の顔部分を顔検出器にかけても顔として検出されない", "verify": "出力動画に対して顔検出を再実行し、検出数が0であることをテストで確認する", - "evidence": "" + "evidence": "https://github.com/rahiseko-alt/ai-editer/actions/runs/30875193419" } ] } diff --git a/video-shorts/.gitignore b/video-shorts/.gitignore index 64d0ae0..9852da1 100644 --- a/video-shorts/.gitignore +++ b/video-shorts/.gitignore @@ -3,6 +3,10 @@ output/ work/ samples/ *.mp4 +*.webm +*.mov +*.mkv +*.m4a *.wav *.ass *.srt diff --git a/video-shorts/package.json b/video-shorts/package.json index 4678a56..72e0ce4 100644 --- a/video-shorts/package.json +++ b/video-shorts/package.json @@ -2,6 +2,6 @@ "name": "video-shorts", "private": true, "scripts": { - "test": "node tests/smoke.mjs && python3 tests/transcribe-corrections-check.py && node tests/restart-reconnect-check.mjs && node tests/cli-job-isolation-check.mjs" + "test": "node tests/smoke.mjs && python3 tests/transcribe-corrections-check.py && python3 tests/face-mosaic-check.py && node tests/restart-reconnect-check.mjs && node tests/cli-job-isolation-check.mjs" } } diff --git a/video-shorts/requirements.txt b/video-shorts/requirements.txt index 682af77..4c8c44f 100644 --- a/video-shorts/requirements.txt +++ b/video-shorts/requirements.txt @@ -2,3 +2,8 @@ # 文字起こし: ローカル(faster-whisper) と クラウド(groq) の二段構え faster-whisper>=0.10.0 groq>=1.5.0 + +# 顔モザイク: 顔検出(YuNet)は opencv 同梱の cv2.FaceDetectorYN を使う。 +# モデル本体は src/models/ に同梱しているので追加ダウンロードは発生しない。 +# headless 版はGUI依存を持たないぶん軽い(本ツールはプレビュー画面を出さない)。 +opencv-python-headless>=4.9.0 diff --git a/video-shorts/src/face_mosaic.py b/video-shorts/src/face_mosaic.py new file mode 100644 index 0000000..f7aa243 --- /dev/null +++ b/video-shorts/src/face_mosaic.py @@ -0,0 +1,235 @@ +"""video-shorts [6] 顔モザイク — 顔を検出し、動いても追従したままモザイクで隠す。 + +ロードマップ M-1(顔を自動で見つけて隠せる)の実装。 + +設計上の必須事項(着手前の実機検証で実際に踏んだ落とし穴。roadmap の M-1-B / M-1-D 参照): + - フレーム間の対応付けは **トラックID** で行う。検出順で対応付けて補間すると、 + 人物の並びが入れ替わった瞬間に枠が別人へ飛び、素顔が露出する。 + - モザイクの粗さは **固定px ではなく顔サイズに対する比** で決める。顔の高さ250pxに対し + ブロック8px以下では顔検出も本人識別も通ってしまう(実測: 類似度0.76〜0.95)。 + +顔検出は YuNet(MIT・src/models/ に同梱)を opencv-python 同梱の cv2.FaceDetectorYN で使う。 +PyTorch や onnxruntime は不要。 +""" + +from __future__ import annotations + +import os +import sys +from dataclasses import dataclass, field + +try: + import cv2 +except ImportError: # 客の環境で未導入のとき、スタックトレースではなく直せる指示を出す + sys.stderr.write( + "[ERROR] opencv 未インストール。`pip install -r requirements.txt` を実行してください" + "(顔モザイクに必要です。`pip` が無ければ `pip3` / `python -m pip` を試してください)。\n" + ) + sys.exit(3) + +import numpy as np + +MODEL_PATH = os.path.join(os.path.dirname(__file__), "models", "face_detection_yunet_2023mar.onnx") + +# 検出のしきい値。低くすると拾いすぎ、高くすると横顔を落とす。 +SCORE_THRESHOLD = 0.6 +NMS_THRESHOLD = 0.3 + +# 顔の高さに対するモザイク1ブロックの比と、絶対値の下限。 +# +# 「顔として検出されなくなる最小ブロック」を実測すると、比率だけでも絶対値だけでも足りない: +# 顔高さ 65px -> 最小 7px(顔高さの 1/9) +# 顔高さ 111px -> 最小 9px(顔高さの 1/12) +# 顔高さ 181px -> 最小 10px(顔高さの 1/18) +# 顔高さ 290px -> 最小 10px(顔高さの 1/29) +# 小さい顔ほど必要な「比」が大きくなり、大きい顔では絶対値が 10px 前後で頭打ちになる。 +# よって ratio と最小px の両方を効かせる(max を取る)。 +# ratio=1/8 は最も厳しい 1/9 に余裕を持たせた値。BLOCK_MIN_PX=12 は絶対値側の 10px に余裕を足した値。 +# +# なお上表は「機械が顔として検出できなくなる」境界であって「人が本人と分からなくなる」境界ではない。 +# 大きく写った顔ほど比を効かせて粗くするのは、人の目に対する保護を優先しているため。 +BLOCK_RATIO_DEFAULT = 1.0 / 8.0 +BLOCK_MIN_PX = 12 +# 隠す矩形を顔枠より広げる比(髪・顎・輪郭を含めるため)。 +MARGIN_RATIO = 0.18 + +# 検出が途切れたとき、直前の位置で隠しを継続する最大フレーム数。 +# 横を向いた・手で顔を触った程度の一瞬の欠落を埋める。 +HOLD_FRAMES_DEFAULT = 8 +# 同一トラックとみなす中心距離の上限(顔の幅で正規化した値)。 +MATCH_DISTANCE_RATIO = 1.5 + + +@dataclass +class Track: + """1人ぶんの追跡状態。id はフレームをまたいで不変(=補間の対応付けに使う正)。""" + + id: int + box: tuple[float, float, float, float] + missed: int = 0 + seen: bool = False + label: str = "_other" + votes: dict[str, int] = field(default_factory=dict) + + +def create_detector(width: int, height: int, model_path: str = MODEL_PATH): + """YuNet 検出器を作る。model_path が無ければ理由の分かる例外にする(サイレント失敗禁止)。""" + if not os.path.exists(model_path): + raise FileNotFoundError( + f"顔検出モデルが見つかりません: {model_path}\n" + "配布物に同梱されているはずのファイルです。src/models/ を確認してください。" + ) + det = cv2.FaceDetectorYN.create(model_path, "", (width, height), SCORE_THRESHOLD, NMS_THRESHOLD, 5000) + det.setInputSize((width, height)) + return det + + +def detect_faces(image, detector=None) -> list[tuple[float, float, float, float]]: + """画像から顔の矩形 (x, y, w, h) を返す。検出ゼロなら空リスト。""" + h, w = image.shape[:2] + det = detector if detector is not None else create_detector(w, h) + det.setInputSize((w, h)) + _, faces = det.detect(image) + if faces is None: + return [] + return [(float(b[0]), float(b[1]), float(b[2]), float(b[3])) for b in faces] + + +def block_size_for(face_h: float, ratio: float = BLOCK_RATIO_DEFAULT) -> int: + """顔の高さから、隠すのに十分なモザイク1ブロックの大きさ(px)を決める。 + + 固定pxにすると、顔が大きく写った場面で保護が破れる(M-1-D)。 + """ + return max(BLOCK_MIN_PX, int(round(face_h * ratio))) + + +def expand_box(box, frame_w: int, frame_h: int, margin: float = MARGIN_RATIO): + """顔枠を輪郭ぶん広げ、画面外へはみ出さないよう丸める。""" + x, y, w, h = box + # 縦横それぞれの辺から広げる。顔枠は横より縦が長いので、幅由来の余白を上下にも + # 使うと縦方向の拡張率が足りず、髪と顎が枠から出る。 + mx = w * margin + my = h * margin + x0 = max(0, round(x - mx)) + y0 = max(0, round(y - my)) + x1 = min(frame_w, round(x + w + mx)) + y1 = min(frame_h, round(y + h + my)) + return x0, y0, max(0, x1 - x0), max(0, y1 - y0) + + +def apply_mosaic(frame, box, block: int | None = None, ratio: float = BLOCK_RATIO_DEFAULT): + """frame の矩形 box をモザイクで塗りつぶす(frame を破壊的に更新して返す)。""" + fh, fw = frame.shape[:2] + x, y, w, h = expand_box(box, fw, fh) + if w < 2 or h < 2: + return frame + blk = block if block is not None else block_size_for(box[3], ratio) + roi = frame[y : y + h, x : x + w] + small = cv2.resize( + roi, (max(1, w // blk), max(1, h // blk)), interpolation=cv2.INTER_AREA + ) + frame[y : y + h, x : x + w] = cv2.resize(small, (w, h), interpolation=cv2.INTER_NEAREST) + return frame + + +class FaceTracker: + """検出結果をトラックIDへ束ね、検出が途切れた区間を直前の位置で埋める。 + + 「検出漏れフレームで素顔が出る」を防ぐのが役目(M-1-C)。IoU ではなく顔幅で正規化した + 中心距離で対応付ける(小さく速く動く顔で IoU が 0 になりトラックが切れるため)。 + """ + + def __init__(self, hold_frames: int = HOLD_FRAMES_DEFAULT): + self.hold_frames = hold_frames + self.tracks: list[Track] = [] + self._next_id = 0 + # 検出が無くて保持で埋めたフレーム番号(確認用静止画の抽出に使う = M-3-A の入力) + self.held_frames: list[int] = [] + + def update(self, boxes, frame_index: int = 0) -> list[Track]: + """1フレームぶんの検出を取り込み、そのフレームで隠すべきトラック一覧を返す。""" + for t in self.tracks: + t.seen = False + + # 検出を順番に見て「空いている中で一番近いトラック」へ割り当てる貪欲法は、 + # 先に処理された検出が、後の検出にとってより近いトラックを奪いうる。 + # 探索半径は顔幅1.5個ぶんなので隣り合った2人は互いの半径に入り、これが起きると + # 人物が入れ替わる = M-1-B が防ごうとしている当の失敗そのものになる。 + # そこで全ての(検出, トラック)の距離を先に出し、近い順に一意割り当てする。 + # こうすると結果が検出の順序に依存しない。 + pairs = [] + for bi, b in enumerate(boxes): + cx, cy = b[0] + b[2] / 2, b[1] + b[3] / 2 + for t in self.tracks: + tx, ty = t.box[0] + t.box[2] / 2, t.box[1] + t.box[3] / 2 + d = ((cx - tx) ** 2 + (cy - ty) ** 2) ** 0.5 / max(b[2], 1.0) + if d < MATCH_DISTANCE_RATIO: + pairs.append((d, bi, t)) + matched: dict[int, Track] = {} + for _d, bi, t in sorted(pairs, key=lambda p: p[0]): + if bi in matched or t.seen: + continue + t.seen = True + matched[bi] = t + + for bi, b in enumerate(boxes): + t = matched.get(bi) + if t is None: + self._next_id += 1 + t = Track(id=self._next_id, box=tuple(b)) + t.seen = True + self.tracks.append(t) + t.box = tuple(b) + t.missed = 0 + + held = False + for t in self.tracks: + if not t.seen: + t.missed += 1 + held = True + if held: + self.held_frames.append(frame_index) + + # 保持の上限を超えたトラックは捨てる(居なくなった人を永久に隠し続けない) + self.tracks = [t for t in self.tracks if t.missed <= self.hold_frames] + return list(self.tracks) + + +def interpolate(prev_rows, next_rows, weight: float): + """2つのキーフレーム間を補間する。対応付けは必ずトラックIDで行う(M-1-B)。 + + prev_rows / next_rows は {track_id: (x, y, w, h)}。片側にしか無いトラックは + そのままの位置で出す(消えかけ・現れかけの顔を取りこぼさない)。 + """ + out: dict[int, tuple[float, float, float, float]] = {} + for tid, a in prev_rows.items(): + b = next_rows.get(tid) + if b is None: + out[tid] = a + else: + out[tid] = tuple(a[k] * (1.0 - weight) + b[k] * weight for k in range(4)) + for tid, b in next_rows.items(): + if tid not in prev_rows: + out[tid] = b + return out + + +def mosaic_frames(frames, hold_frames: int = HOLD_FRAMES_DEFAULT, ratio: float = BLOCK_RATIO_DEFAULT, + detector=None, block_for=None): + """フレーム列を順に処理し、顔を追従モザイクで隠したフレーム列を返す。 + + block_for(track) を渡すと、トラックごとにブロックサイズを差し替えられる(M-2 で使う)。 + """ + tracker = FaceTracker(hold_frames=hold_frames) + out = [] + det = detector + for i, frame in enumerate(frames): + work = frame.copy() + if det is None: + det = create_detector(work.shape[1], work.shape[0]) + boxes = detect_faces(work, det) + for t in tracker.update(boxes, frame_index=i): + blk = block_for(t) if block_for is not None else None + apply_mosaic(work, t.box, block=blk, ratio=ratio) + out.append(work) + return out, tracker diff --git a/video-shorts/src/models/LICENSE-yunet.txt b/video-shorts/src/models/LICENSE-yunet.txt new file mode 100644 index 0000000..4cdf89a --- /dev/null +++ b/video-shorts/src/models/LICENSE-yunet.txt @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2020 Shiqi Yu + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/video-shorts/src/models/face_detection_yunet_2023mar.onnx b/video-shorts/src/models/face_detection_yunet_2023mar.onnx new file mode 100644 index 0000000..f9beb30 Binary files /dev/null and b/video-shorts/src/models/face_detection_yunet_2023mar.onnx differ diff --git a/video-shorts/tests/face-mosaic-check.py b/video-shorts/tests/face-mosaic-check.py new file mode 100644 index 0000000..706e108 --- /dev/null +++ b/video-shorts/tests/face-mosaic-check.py @@ -0,0 +1,320 @@ +"""顔モザイク(ロードマップ M-1)のテスト。 + +各テストは roadmap の葉と1対1に対応する: + M-1-A 動画に写っている顔の位置が分かる + M-1-B 顔が動いても隠したまま追いかける + M-1-C 一瞬顔を見失っても素顔が出ない + M-1-D 隠した顔は本当に判別できなくなっている + +固定素材は tests/fixtures/(NASA の public domain 写真から顔部分を切り出したもの)。 +動画ファイルは .gitignore 対象のためコミットせず、動きのある場面はテスト内で +静止画を移動させて合成する(=素材が無くても同じ結果が再現できる)。 +""" + +import os +import sys + +import cv2 +import numpy as np + +sys.path.insert(0, os.path.join(os.path.dirname(__file__), "..", "src")) + +from face_mosaic import ( # noqa: E402 + FaceTracker, + apply_mosaic, + block_size_for, + create_detector, + detect_faces, + interpolate, + mosaic_frames, +) + +FIXTURES = os.path.join(os.path.dirname(__file__), "fixtures") +passed = 0 +failed = 0 + + +def check(name, cond, extra=""): + global passed, failed + if cond: + passed += 1 + print(f"PASS {name}") + else: + failed += 1 + print(f"FAIL {name} {extra}") + + +def load(name): + p = os.path.join(FIXTURES, name) + im = cv2.imread(p) + if im is None: + raise FileNotFoundError(f"固定素材が読めません: {p}") + return im + + +# ---------------------------------------------------------------- M-1-A +# 「写っている人数と同じ数の顔位置が返る」 +one = load("face-one.png") +two = load("face-two.png") + +# detect_faces は detector を渡さないと毎回 ONNX を読み直すので、結果は使い回す。 +boxes = detect_faces(one) +boxes_two = detect_faces(two) +check("M-1-A: 1人の固定素材から顔が1件検出される", len(boxes) == 1, f"got {len(boxes)}") +check("M-1-A: 2人の固定素材から顔が2件検出される", len(boxes_two) == 2, f"got {len(boxes_two)}") + +x, y, w, h = boxes[0] +check( + "M-1-A: 返る顔位置が画像内に収まった正の矩形である", + 0 <= x < one.shape[1] and 0 <= y < one.shape[0] and w > 0 and h > 0, + f"got {boxes[0]}", +) + + +# ---------------------------------------------------------------- M-1-B +# 「顔が移動する動画で、全フレームの隠し枠が実際の顔位置に重なっている」 +def iou(a, b): + ax, ay, aw, ah = a + bx, by, bw, bh = b + x1, y1 = max(ax, bx), max(ay, by) + x2, y2 = min(ax + aw, bx + bw), min(ay + ah, by + bh) + inter = max(0.0, x2 - x1) * max(0.0, y2 - y1) + return inter / (aw * ah + bw * bh - inter + 1e-9) + + +def moving_sequence(face_img, n=24, canvas=(720, 480)): + """顔画像を正弦軌道で動かしたフレーム列と、各フレームの貼り付け位置を返す。""" + W, H = canvas + fh, fw = face_img.shape[:2] + frames, positions = [], [] + for i in range(n): + t = i / n + px = int(40 + (W - fw - 80) * (0.5 + 0.5 * np.sin(2 * np.pi * t))) + py = int(30 + (H - fh - 60) * (0.5 + 0.5 * np.cos(2 * np.pi * t))) + cv = np.full((H, W, 3), (64, 48, 32), np.uint8) + cv[py : py + fh, px : px + fw] = face_img + frames.append(cv) + positions.append((px, py)) + return frames, positions + + +frames, _ = moving_sequence(one) +det = create_detector(frames[0].shape[1], frames[0].shape[0]) + +truth = [detect_faces(f, det) for f in frames] +check( + "M-1-B: 移動する顔が全フレームで検出できる(前提の確認)", + all(len(t) == 1 for t in truth), + f"検出できなかったフレーム={[i for i, t in enumerate(truth) if len(t) != 1]}", +) + +tracker = FaceTracker() +overlaps = [] +for i, t in enumerate(truth): + tracks = tracker.update(t, frame_index=i) + if t: + best = max((iou(tr.box, t[0]) for tr in tracks), default=0.0) + overlaps.append(best) +check( + "M-1-B: 全フレームで隠し枠が実際の顔位置と重なる(IoU>=0.5)", + overlaps and min(overlaps) >= 0.5, + f"min IoU={min(overlaps) if overlaps else 'n/a'}", +) + +check( + "M-1-B: 1人しか写っていない列でトラックが1本に保たれる(別人扱いで作り直されない)", + len(tracker.tracks) == 1, + f"tracks={len(tracker.tracks)}", +) + +# 補間はトラックIDで対応付ける。検出順が入れ替わっても枠が別人へ飛ばない。 +prev_rows = {1: (0.0, 0.0, 10.0, 10.0), 2: (100.0, 0.0, 10.0, 10.0)} +next_rows = {2: (110.0, 0.0, 10.0, 10.0), 1: (10.0, 0.0, 10.0, 10.0)} # 順序を反転 +mid = interpolate(prev_rows, next_rows, 0.5) +check( + "M-1-B: キーフレーム間の順序が入れ替わってもIDごとに補間される(枠が別人へ飛ばない)", + abs(mid[1][0] - 5.0) < 1e-6 and abs(mid[2][0] - 105.0) < 1e-6, + f"got {mid}", +) + + +def ids_for(order): + """隣り合う2人を、指定した検出順で2フレーム流したときの (位置 -> トラックID) を返す。""" + tr = FaceTracker() + a0, b0 = (100.0, 100.0, 60.0, 75.0), (170.0, 100.0, 60.0, 75.0) # 顔幅より近い間隔 + tr.update([a0, b0], frame_index=0) + a1, b1 = (104.0, 100.0, 60.0, 75.0), (174.0, 100.0, 60.0, 75.0) + nxt = [a1, b1] if order == "同順" else [b1, a1] + tracks = tr.update(nxt, frame_index=1) + return {round(t.box[0]): t.id for t in tracks} + + +check( + # 貪欲法だと、先に見た検出が後の検出にとってより近いトラックを奪い人物が入れ替わる。 + # 距離の近い順に一意割り当てすれば検出順に依存しない。 + "M-1-B: 隣り合う2人は、検出の順序が入れ替わってもトラックIDが入れ替わらない", + ids_for("同順") == ids_for("逆順"), + f"同順={ids_for('同順')} / 逆順={ids_for('逆順')}", +) + + +# ---------------------------------------------------------------- M-1-C +# 「顔検出が途切れたコマでも、直前の位置で隠しが継続する」 +gapped = [list(t) for t in truth] +GAP = range(8, 13) # 5フレームぶん検出を人為的に欠落させる +for i in GAP: + gapped[i] = [] + +tracker2 = FaceTracker(hold_frames=8) +held_ok = True +for i, t in enumerate(gapped): + tracks = tracker2.update(t, frame_index=i) + if i in GAP and len(tracks) == 0: + held_ok = False +check("M-1-C: 検出が欠落したコマにも隠し枠が出力される", held_ok) + +check( + "M-1-C: 欠落したコマが「保持で埋めた」として記録される(確認対象の抽出に使う)", + set(GAP).issubset(set(tracker2.held_frames)), + f"held={tracker2.held_frames}", +) + +tracker3 = FaceTracker(hold_frames=2) +for i in range(3): + tracker3.update(truth[0], frame_index=i) +for i in range(3, 12): + tracker3.update([], frame_index=i) +check( + "M-1-C: 保持の上限を超えたら隠しをやめる(居なくなった人を永久に隠さない)", + len(tracker3.tracks) == 0, + f"tracks={len(tracker3.tracks)}", +) + + +# ---------------------------------------------------------------- M-1-D +# 「出力の顔部分を顔検出器にかけても顔として検出されない」 +covered, _ = mosaic_frames(frames[:6], detector=create_detector(frames[0].shape[1], frames[0].shape[0])) +recheck = create_detector(covered[0].shape[1], covered[0].shape[0]) +leaks = [i for i, f in enumerate(covered) if len(detect_faces(f, recheck)) > 0] +check("M-1-D: モザイク後のフレームから顔が検出されない", not leaks, f"素顔が残ったフレーム={leaks}") + +# ブロックの大きさが顔サイズに追随する(固定pxだと大きい顔で保護が破れる) +check( + "M-1-D: 下限より上では、顔が大きいほどモザイクが粗くなる", + block_size_for(400) > block_size_for(200) > block_size_for(120), + f"{block_size_for(400)} / {block_size_for(200)} / {block_size_for(120)}", +) + +check( + "M-1-D: 顔が大きくなるほどブロックが小さくなることはない(単調非減少)", + all(block_size_for(a) <= block_size_for(b) for a, b in zip(range(20, 400, 20), range(40, 420, 20))), +) + +check( + "M-1-D: 小さく写った顔でもブロックが潰れない(絶対値の下限が効く)", + block_size_for(1) >= 12, + f"got {block_size_for(1)}", +) + +# 顔の大きさを変えても隠しきれることを、実際に検出器で確かめる。 +# 「比率だけ」「絶対値だけ」では隠しきれない大きさがあるため、両方効いていることの回帰防止。 +scale_leaks = [] +undetected = [] +fixed_leaks = [] +for scale in (0.6, 1.0, 1.6, 2.4): + im = cv2.resize(one, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC) + ih, iw = im.shape[:2] + cw, ch = iw + 200, ih + 120 + base = np.full((ch, cw, 3), (64, 48, 32), np.uint8) + base[60 : 60 + ih, 100 : 100 + iw] = im + d = create_detector(cw, ch) + found = detect_faces(base, d) + if not found: + # 黙って飛ばすとその倍率が何の根拠も出さないまま緑になる(サイレント失敗禁止) + undetected.append(scale) + continue + ratio_applied = base.copy() + apply_mosaic(ratio_applied, found[0]) + if detect_faces(ratio_applied, d): + scale_leaks.append((scale, int(found[0][3]))) + # 同じ場面を固定8pxでも隠してみる。比率を採った理由(固定値では大きい顔で破れる)を + # 検証するための対照。隠す範囲は同じで、ブロックの大きさだけが違う。 + fixed_applied = base.copy() + apply_mosaic(fixed_applied, found[0], block=8) + if detect_faces(fixed_applied, d): + fixed_leaks.append((scale, int(found[0][3]))) +check( + "M-1-D: 全ての倍率で顔が検出できる(前提の確認)", + not undetected, + f"検出できなかった scale={undetected}", +) +check( + "M-1-D: 顔の大きさが変わっても隠しきれる(小さい顔=比率/大きい顔=絶対値の両方が効く)", + not scale_leaks, + f"隠しきれなかった (scale, 顔高さ)={scale_leaks}", +) +check( + # ここが緑にならない=固定値でも隠せてしまう=比率を採る根拠が消える、ということ。 + # 根拠が実際に成立していることを毎回確かめる(対照が無いと設計判断が検証されない)。 + "M-1-D: 対照として、固定8pxでは大きい顔を隠しきれない(比率を採る根拠)", + fixed_leaks, + f"固定8pxでも全倍率で隠せてしまった(比率設計の根拠が成立していない) leaks={fixed_leaks}", +) + +# -------------------------------------------------- 実素材で必ず起きる状況への耐性 +# 顧客の動画は「顔が写らない場面」「顔が画面端で切れる場面」「スマホの縦動画」を必ず含む。 +# ここで落ちると処理全体が止まるため、葉の受入条件とは別に回帰を防ぐ。 + +blank = [np.full((240, 320, 3), (64, 48, 32), np.uint8) for _ in range(3)] +try: + out_blank, tr_blank = mosaic_frames(blank) + ok_blank = len(out_blank) == len(blank) and not tr_blank.tracks +except Exception as e: # noqa: BLE001 - 落ちないこと自体が検証対象 + ok_blank, e_blank = False, e +check("顔が1つも写っていない場面でも落ちず、フレーム数が変わらない", ok_blank) + +# 顔が画面の四隅にはみ出す位置にある場合(枠が画面外へ出る) +edge_ok = True +fh, fw = one.shape[:2] +for ox, oy in ((-fw // 3, -fh // 3), (0, -fh // 3), (-fw // 3, 0)): + canvas = np.full((260, 340, 3), (64, 48, 32), np.uint8) + sx0, sy0 = max(0, ox), max(0, oy) + crop = one[max(0, -oy) :, max(0, -ox) :] + ch2 = min(canvas.shape[0] - sy0, crop.shape[0]) + cw2 = min(canvas.shape[1] - sx0, crop.shape[1]) + canvas[sy0 : sy0 + ch2, sx0 : sx0 + cw2] = crop[:ch2, :cw2] + d_edge = create_detector(canvas.shape[1], canvas.shape[0]) + for b in detect_faces(canvas, d_edge): + try: + apply_mosaic(canvas, b) + except Exception: # noqa: BLE001 + edge_ok = False +check("顔が画面端にかかって枠が画面外へ出ても落ちない", edge_ok) + +# スマホの縦動画(9:16)でも検出と隠しが成立する +portrait = np.full((640, 360, 3), (64, 48, 32), np.uint8) +portrait[80 : 80 + fh, 70 : 70 + fw] = one +d_por = create_detector(360, 640) +found_por = detect_faces(portrait, d_por) +if found_por: + apply_mosaic(portrait, found_por[0]) +check( + "縦動画(9:16)でも顔を検出して隠せる", + bool(found_por) and not detect_faces(portrait, d_por), + f"検出={len(found_por)}件 / 隠した後の残り={len(detect_faces(portrait, d_por))}件", +) + +# 顔がブロックの下限より小さい場合(遠くに写った人)でも塗り潰せる +tiny = np.full((120, 160, 3), (64, 48, 32), np.uint8) +tiny_face = cv2.resize(one, (24, 27), interpolation=cv2.INTER_AREA) +tiny[40:67, 50:74] = tiny_face +before_tiny = tiny.copy() +apply_mosaic(tiny, (50.0, 40.0, 24.0, 27.0)) +check( + "顔がモザイク1ブロックより小さくても、その範囲が塗り潰される", + not np.array_equal(before_tiny[40:67, 50:74], tiny[40:67, 50:74]), +) + + +print(f"\n--- {passed} PASS / {failed} FAIL ---") +sys.exit(1 if failed else 0) diff --git a/video-shorts/tests/fixtures/README.md b/video-shorts/tests/fixtures/README.md new file mode 100644 index 0000000..4c46e8a --- /dev/null +++ b/video-shorts/tests/fixtures/README.md @@ -0,0 +1,25 @@ +# テスト固定素材(顔モザイク) + +`tests/face-mosaic-check.py` が使う固定素材。**顔検出は実在の顔でしか検証できない**ため、 +権利上クリーンな public domain 画像から顔部分だけを切り出して置いている。 + +| ファイル | 内容 | 期待する検出数 | +|---|---|---| +| `face-one.png` | 顔1つ | 1 | +| `face-two.png` | 顔2つを横に並べたもの | 2 | + +## 出典とライセンス + +いずれも **NASA が撮影した public domain(パブリックドメイン)の宇宙飛行士公式ポートレート**を +Wikimedia Commons 経由で取得し、顔の周辺を切り出して高さ240pxへ縮小したもの。 + +- `face-one.png` … Neil Armstrong の公式ポートレート(`Neil_Armstrong_pose.jpg`)より +- `face-two.png` … 上記に Buzz Aldrin の公式ポートレート(`Buzz_Aldrin.jpg`)を並べたもの + +NASA の著作物は原則としてパブリックドメインであり、再配布・改変に制限はない。 + +## 動画の固定素材を置かない理由 + +`video-shorts/.gitignore` が `*.mp4` と `samples/` を除外しているため、動画はコミットできない。 +動きのある場面が要るテスト(M-1-B / M-1-C)は、**この静止画をテスト内で移動させて合成する** +ことで再現している。素材が無くても誰でも同じ結果を再現できる。 diff --git a/video-shorts/tests/fixtures/face-one.png b/video-shorts/tests/fixtures/face-one.png new file mode 100644 index 0000000..5ba86ea Binary files /dev/null and b/video-shorts/tests/fixtures/face-one.png differ diff --git a/video-shorts/tests/fixtures/face-two.png b/video-shorts/tests/fixtures/face-two.png new file mode 100644 index 0000000..e67bb68 Binary files /dev/null and b/video-shorts/tests/fixtures/face-two.png differ