From 95f467b4adb1f3e2aab624138ef503fbe63cd73b Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Fri, 28 Aug 2026 19:44:25 -0700 Subject: [PATCH] Register the Doubao video mark with a corpus-calibrated single-floor policy detect_doubao_frame reuses the image engine's synthetic doubao alpha as its video template, so the glyph cannot drift between registries. The policy is seedance-style single-floor, calibrated by sequential decode over 39 TC260-confirmed Doubao corpus videos and 25 negatives: a 12-frame stable run at 0.35 accepts 33/39 positives and 0/25 negatives (0.30 already accepted a negative), and through the shipped arbiter identify_video selects 32/39 with no false selection. Also documents the measured impossibility of a bare-pill removal arm with the current detector (68 OCR-confirmed pills max 0.317 vs clean negatives max 0.353 -- no separating threshold exists). --- data/fixtures/visible/doubao/example.mp4 | Bin 0 -> 96689 bytes docs/cli.md | 4 +- docs/module-internals.md | 38 +++++++++++- docs/supported-signals.md | 1 + scripts/render_visible_examples.py | 2 + src/remove_ai_watermarks/video.py | 3 +- src/remove_ai_watermarks/video_visible.py | 68 ++++++++++++++++++++++ tests/test_video.py | 10 ++-- 8 files changed, 115 insertions(+), 11 deletions(-) create mode 100644 data/fixtures/visible/doubao/example.mp4 diff --git a/data/fixtures/visible/doubao/example.mp4 b/data/fixtures/visible/doubao/example.mp4 new file mode 100644 index 0000000000000000000000000000000000000000..46781ed7740a57f17f51bfa3e0be0bae89704df4 GIT binary patch literal 96689 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 np.ndarray: return _composite_light(base, cv2.resize(tmpl, (tw, th)).astype(np.float32) / 255.0, x, y, 250) elif key == "seedance": tmpl, scale, x, y = templates["seedance"], 0.095, int(w * 0.74), int(h * 0.80) + elif key == "doubao": + tmpl, scale, x, y = templates["doubao"], 0.048, int(w * 0.72), int(h * 0.82) elif key == "dola": tmpl, scale, x, y = templates["dola"], 0.036, int(w * 0.70), int(h * 0.88) elif key == "hailuo": diff --git a/src/remove_ai_watermarks/video.py b/src/remove_ai_watermarks/video.py index aac4900..3266c5e 100644 --- a/src/remove_ai_watermarks/video.py +++ b/src/remove_ai_watermarks/video.py @@ -25,7 +25,7 @@ if TYPE_CHECKING: from remove_ai_watermarks.video_visible import VideoScan VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v", ".webm", ".mkv", ".avi", ".flv"}) -VIDEO_VISIBLE_MARKS = ("sora", "veo", "seedance", "dola", "hailuo", "kling") +VIDEO_VISIBLE_MARKS = ("sora", "veo", "seedance", "doubao", "dola", "hailuo", "kling") _ISOBMFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".mp4", ".mov", ".m4v"}) _EBML_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".webm", ".mkv"}) _RIFF_VIDEO_EXTENSIONS: frozenset[str] = frozenset({".avi"}) @@ -178,6 +178,7 @@ _VISIBLE_PLATFORM = { "sora": "OpenAI Sora", "veo": "Google Veo", "seedance": "ByteDance Seedance", + "doubao": "ByteDance Doubao", "dola": "ByteDance Dola", "hailuo": "MiniMax Hailuo AI", "kling": "Kuaishou Kling AI", diff --git a/src/remove_ai_watermarks/video_visible.py b/src/remove_ai_watermarks/video_visible.py index 0ce442b..46bc91e 100644 --- a/src/remove_ai_watermarks/video_visible.py +++ b/src/remove_ai_watermarks/video_visible.py @@ -313,6 +313,28 @@ def _top_hat(gray: NDArray[Any]) -> NDArray[Any]: @lru_cache(maxsize=1) +def _asset_template(name: str) -> NDArray[Any]: + """Load a package asset as a video detect template (white glyph on black). + + The image-registry alpha maps are the same synthetic silhouettes the image + engines match; a video mark that shares the glyph reuses the asset instead of + re-rendering it. No source frame contributes pixels. + """ + from pathlib import Path as _P + + from remove_ai_watermarks.image_io import imread as _imread + + at = _imread(str(_P(__file__).parent / "assets" / name), 2) + if at is None: + raise RuntimeError(f"missing video template asset: {name}") + return at + + +@lru_cache(maxsize=1) +def _doubao_template() -> NDArray[Any]: + return _asset_template("doubao_alpha.png") + + def _template_sources() -> dict[str, NDArray[Any]]: """Return every immutable synthetic template by detector-local key.""" sora_word, sora_icon = _sora_templates() @@ -326,6 +348,9 @@ def _template_sources() -> dict[str, NDArray[Any]]: "dola": _dola_template(), "hailuo": _hailuo_template(), "kling-logo": _kling_logo_template(), + # The Doubao video mark is the same "豆包AI生成" run the image engine + # matches (its synthetic alpha asset), bottom-right on video frames. + "doubao": _doubao_template(), } sources.update({f"kling-{index}": template for index, template in enumerate(_kling_templates())}) return sources @@ -606,6 +631,32 @@ def detect_seedance_frame( ) +def detect_doubao_frame( + image_bgr: NDArray[Any], + *, + frame_index: int = 0, + prepared: _PreparedFrame | None = None, +) -> FrameLocalization: + """Locate the strongest fixed Doubao "豆包AI生成" text candidate. + + The ByteDance Doubao mark registered for IMAGES also appears burned into the + bottom-right of Doubao VIDEO exports; this detector reuses the image engine's + synthetic alpha as the template. Search profile measured on the local spaces + corpus (48 TC260-confirmed Doubao videos, 2026-08-28): the run sits at + cx 0.83-0.90 / bottom-flush, heights swept 3.2-6.0 percent of the short side. + """ + return _detect_fixed_mark( + image_bgr, + "doubao", + relative_heights=tuple(value / 1000 for value in range(32, 61, 4)), + search_origin=(0.60, 0.76), + kernel_fraction=0.14, + prefer_larger_within=0.04, + normalized=None if prepared is None else (prepared.normalized_gray, prepared.normalized_scale), + frame_index=frame_index, + ) + + def detect_dola_frame( image_bgr: NDArray[Any], *, @@ -952,6 +1003,22 @@ VISIBLE_MARK_POLICIES: dict[str, VisibleMarkPolicy] = { padding_fraction=0.0, mask_style="box", ), + "doubao": VisibleMarkPolicy( + # One floor at either trust level (seedance-style: the TC260 producer + # confirms the vendor inside the arbiter; the confidence bar does not + # move). Floors measured 2026-08-28 on the local spaces corpus with + # sequential decode: a >=0.35 stable run of 12+ frames accepts 33/39 + # TC260-confirmed Doubao videos and 0/25 no-Doubao negatives; 0.30 + # already accepted 1 negative, so the bar stays at 0.35. + weak_floor=0.35, + strong_floor=0.55, + transition_floor=0.30, + min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES, + cover_after_confirmation=True, + anchor_iou=0.80, + padding_fraction=0.0, + mask_style="box", + ), "dola": VisibleMarkPolicy( weak_floor=_DOLA_STRICT_WEAK_CONFIDENCE, provenance_weak_floor=_DOLA_PROVENANCE_WEAK_CONFIDENCE, @@ -1181,6 +1248,7 @@ def scan_video_marks( detect_sora_frame, detect_veo_frame, detect_seedance_frame, + detect_doubao_frame, detect_dola_frame, detect_hailuo_frame, detect_kling_frame, diff --git a/tests/test_video.py b/tests/test_video.py index d210de6..e51125c 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -924,7 +924,7 @@ class TestVideoProvenanceApi: assert report.visible_mark == "sora" assert report.visible_detected_frames == 5 assert report.total_frames == 5 - assert tuple(VIDEO_VISIBLE_MARKS) == ("sora", "veo", "seedance", "dola", "hailuo", "kling") + assert tuple(VIDEO_VISIBLE_MARKS) == ("sora", "veo", "seedance", "doubao", "dola", "hailuo", "kling") def test_reports_unknown_instead_of_clean(self, tmp_path: Path): from remove_ai_watermarks.video import identify_video @@ -2085,10 +2085,10 @@ class TestVideoVisibleScan: scans = scan_video_marks( tmp_path / "synthetic.mp4", - ("sora", "veo", "seedance", "dola", "hailuo", "kling"), + ("sora", "veo", "seedance", "doubao", "dola", "hailuo", "kling"), ) - assert set(scans) == {"sora", "veo", "seedance", "dola", "hailuo", "kling"} + assert set(scans) == {"sora", "veo", "seedance", "doubao", "dola", "hailuo", "kling"} assert all(scan.timestamps == (0.25,) for scan in scans.values()) assert len(prepared_ids) == 6 assert len(set(prepared_ids)) == 1 @@ -2576,7 +2576,7 @@ class TestVideoVisibleApi: ) def fake_scan(_source: Path, marks: tuple[str, ...]): - assert marks == ("sora", "veo", "seedance", "dola", "hailuo", "kling") + assert marks == ("sora", "veo", "seedance", "doubao", "dola", "hailuo", "kling") return { "sora": sora_scan, "veo": sora_scan, @@ -2792,7 +2792,7 @@ class TestVideoVisibleCli: assert result.exit_code == 0, result.output assert "temporally stable" in result.output - assert "auto|sora|veo|seedance|dola|hailuo|kling" in result.output + assert "auto|sora|veo|seedance|doubao|dola|hailuo|kling" in result.output assert "--temporal-consistency" in result.output def test_reports_removed_frames(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):