設計ボトルネック資料 Section 3 で示した sample01 event1 (t=81s) の検出結果では、25 件の検出のうち 7〜10 件が false positive。その位置を分析すると:
| 位置 | 原因物体 | 本前処理での扱い |
|---|---|---|
| 下部 | プレイヤーカード (顔写真 + 名前 + 点数) | ✅ マスクで除外 |
| 上部右 | スポンサー帯 (ZOZOTOWN / Semi Final ロゴ) | ✅ マスクで除外 |
| 上部 | 四局 / 北家マーカー / 局カウンタ | ✅ マスクで除外 |
| 右端 | 得点表示 (29,200 など) | ✅ マスクで除外 |
| 卓上 (緑フェルト) | 空きスペース・反射ハイライト | ⚠️ 卓内なので残る (本前処理では対処せず — ファインチューンで解決) |
本前処理だけで false positive の 50〜70% を除去可能と推定。 残りはファインチューン (アプローチ A/B/C) で対処する分業構成にする。
[入力動画フレーム 1920×1080]
│
▼
[1. HSV 緑域検出] ← Hue 30-95, S 30-255, V 30-230 で粗マスク
│
▼
[2. ノイズ除去] ← morphological open + close
│
▼
[3. 最大連結成分抽出] ← cv2.connectedComponentsWithStats
│
▼
[4. 凸包 + dilate 5%] ← 卓の四辺形 + 牌のはみ出しを含める
│
▼
[5. マスク適用] ← 卓外を黒塗り
│
▼
[YOLO/Roboflow 推論] ← 卓上だけが検出対象になる
│
▼
[既存の post-process] ← orientation 分類, 鳴き数判定, etc.
本パイプラインは 独立したスクリプト scripts/mask-table-region.py として実装済。
既存の検出パイプラインに「フレーム前処理ステップ」として 1 行差し込むだけで統合可能。
def green_felt_mask(frame_bgr):
hsv = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2HSV)
# 緑フェルト: Hue 30-95 (青緑〜黄緑) / Saturation 30+ / Value 30-230
mask = cv2.inRange(hsv, (30, 30, 30), (95, 255, 230))
# ノイズ除去: open でゴマ塩、close で穴埋め
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, k)
mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k, iterations=3)
return mask
HSV を選ぶ理由は照明変化への頑健性。Saturation を 30 以上に絞ることで、 緑がかったグレーや照明反射を弾く。
n, labels, stats, _ = cv2.connectedComponentsWithStats(mask, connectivity=8)
areas = stats[1:, cv2.CC_STAT_AREA]
max_label = 1 + int(np.argmax(areas))
table_only = (labels == max_label).astype(np.uint8) * 255
HSV 緑マスクには「プレイヤーシャツの緑」「観客席の緑バナー」など複数の緑領域が含まれる。 「画面上最大の緑連結成分 = 卓」と仮定して 1 つだけを採用する。 fallback: 最大成分が画像面積の 5% 未満なら「卓は映っていない」と判定し、マスクなしで通す。
contours, _ = cv2.findContours(table_only, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
hull = cv2.convexHull(max(contours, key=cv2.contourArea))
hull_mask = np.zeros_like(table_only)
cv2.fillPoly(hull_mask, [hull], 255)
# 牌が卓の縁から少しはみ出すことがあるので 5% dilate
d = int(min(h, w) * 0.05)
hull_mask = cv2.dilate(hull_mask, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (d, d)))
最大連結成分はそのままだと「牌で隠れている部分」が穴になる。凸包で四辺形に近づけ、 さらに 5% 膨張させて「卓の縁付近の牌」も含めるようにする。
masked = frame_bgr.copy()
masked[hull_mask == 0] = 0 # 卓外を黒塗り
# YOLO に渡す
detections = roboflow_model.predict(masked)
マスク外を黒で塗りつぶす。YOLO は黒領域に対して通常検出を出さないため、 効率的に false positive を抑制できる。
M-League sample01 のアガリピークフレームに本前処理を適用した結果。 左上=入力、右上=HSV 緑マスク、左下=凸包 + dilate、右下=マスク適用後 (YOLO へ渡す)。
| 項目 | 値 | 備考 |
|---|---|---|
| HSV 緑マスク占有率 | 34.7% | 緑検出された全ピクセル |
| 最大連結成分占有率 | 33.1% | = 卓本体 |
| 凸包 + 5% dilate 後 | 61.9% | YOLO 推論対象領域 |
| 除外領域 | 38.1% | = 探索空間の 38% を構造的に排除 |
本前処理は既存の検出パイプラインに 1 関数呼び出し で統合可能:
# 既存
frame = cv2.imread(image_path)
detections = roboflow_model.predict(frame)
# 本前処理を追加した版
from mask_table_region import table_region_mask, apply_mask
frame = cv2.imread(image_path)
mask, info = table_region_mask(frame, dilate_pct=5.0, min_area_pct=5.0)
if not info["fallback"]:
frame_masked = apply_mask(frame, mask)
else:
frame_masked = frame # 卓が映ってないなら元のまま
detections = roboflow_model.predict(frame_masked)
| スクリプト | 適用 | 備考 |
|---|---|---|
scripts/detect-agari-visual.py | ✅ 推奨 | 視覚アガリ検出で false positive 削減 |
scripts/analyze-still-frame.py | ✅ 推奨 | 静止画解析で誤検出表示が減る |
scripts/render-3d-overlay-cf.py | ✅ 推奨 | CF 用デモ動画でクリーンな表示 |
scripts/evaluate-agari-transition-detector.py | ✅ 推奨 | 評価フレームワークの dense 推論にも適用 |
scripts/voice-cli-mahjong.py | ✅ 推奨 | 音声 CLI の画像入力 mode に適用 |
| Roboflow Auto-Label | ⚠️ 検討 | マスクしてからアップロードすれば Auto-Label 精度向上 |
scripts/extract-roboflow-frames.py で抽出した 499 枚に本前処理を適用してから Roboflow にアップロードすると、
アノテーション工数の削減+Auto-Label 精度向上が見込める。
マスク後の画像で訓練すれば、推論時もマスク後を期待するので一貫性が保たれる。
# 1 枚処理
phase0/.venv/Scripts/python.exe scripts/mask-table-region.py \
--in-image mleague/frames/sample01-visual-v1-peak.jpg \
--out-image mleague/output/masked-peak.jpg \
--debug mleague/output/_table-mask-debug.png
# 一括処理 (PowerShell)
Get-ChildItem mleague/frames-roboflow-filtered/*.jpg | ForEach-Object {
phase0/.venv/Scripts/python.exe scripts/mask-table-region.py `
--in-image $_.FullName `
--out-image "mleague/frames-roboflow-masked/$($_.Name)"
}
min_area_pct を厳しくして「卓なし = 全てを黒塗り」する選択肢もある。
| 改善案 | 期待効果 | 実装コスト |
|---|---|---|
| ChArUco マーカーで卓四隅を取得 | マスクが正確な四辺形に | マーカーを物理的に貼る運用が必要 |
| Segment Anything (SAM) で卓セグメント | 緑シャツでも卓だけを抽出 | SAM API/ローカル推論コスト |
| Roboflow で「table vs non-table」セグ モデルを訓練 | M-League 専用に最適化 | セグメンテーション用アノテ 100 枚 |
| 時間軸フィルタ (前後 30 フレームのマスク平均) | カメラ切替フレームのノイズ抑制 | パイプラインへの状態管理追加 |
| カメラキャリブと組み合わせた 3D 卓平面投影 | 動的視点でも正確 | キャリブ完了が前提 |
--dilate-pct, --min-area-pct を最適化scripts/detect-agari-visual.py に組み込み、A/B 評価 (マスクあり vs なし) で false positive 削減数を測定