diff --git a/data/fixtures/visible/doubao/example.mp4 b/data/fixtures/visible/doubao/example.mp4 new file mode 100644 index 0000000..46781ed Binary files /dev/null and b/data/fixtures/visible/doubao/example.mp4 differ diff --git a/docs/cli.md b/docs/cli.md index e7aa169..ff99e4d 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -327,8 +327,8 @@ provider provenance may relax the visual score only for registered provenance-aware marks; metadata alone never creates a detection. `--mark auto` is the default. It evaluates all providers in one decode pass and -selects the first stable match in specificity order: Sora, Veo, Seedance, Dola, -Hailuo AI, then Kling AI. Their confidence scores are independently calibrated and +selects the first stable match in specificity order: Sora, Veo, Seedance, +Doubao, Dola, Hailuo AI, then Kling AI. Their confidence scores are independently calibrated and are not compared across providers. Pass an explicit `--mark` to scan only that provider. diff --git a/docs/module-internals.md b/docs/module-internals.md index 90e8305..3e17577 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -296,7 +296,7 @@ from the gate. The full-clip oracle floor is detected while `0.15` did not. [`video_visible.py`](../src/remove_ai_watermarks/video_visible.py) implements -the first pixel stages for Sora, Veo, Seedance, Dola, Hailuo AI, and Kling AI. The +the first pixel stages for Sora, Veo, Seedance, Doubao, Dola, Hailuo AI, and Kling AI. The Sora detector searches a normalized frame with a fully synthetic mascot-and-text silhouette at several scales. The Veo detector uses separate synthetic silhouettes for the current four-point diamond and legacy `Veo` @@ -327,8 +327,8 @@ carries `accepts_provenance`, which forces `provenance=False` for Hailuo AI and — they have no metadata that could confirm them, and the guarantee used to be structural (their wrappers took no `provenance` parameter at all). Provenance can relax a low-contrast run only after recurring visual evidence exists. Sora transition frames follow the -nearest confirmed moving position only with Sora provenance. Seedance, Dola, -Hailuo AI, and Kling AI additionally require candidates to remain anchored to the +nearest confirmed moving position only with Sora provenance. Seedance, Doubao, +Dola, Hailuo AI, and Kling AI additionally require candidates to remain anchored to the start of a run. This rejects slowly drifting scene details that still have high frame-to-frame overlap. Hailuo AI and Kling AI do not infer provenance from technical encoder tags; their confirmed public samples carried no provider @@ -855,6 +855,22 @@ two-line mark can be light on dark scenes or dark on light scenes. Its detector and footprint both use the same best-match box. The separate one-line overlay variant is not covered. +#### Bare-upload pill arm: measured impossible with the current detector (2026-08-28) + +`_keep_pill` never removes a metadata-bare pill, and the question "can a bare arm +(score + footprint-flatness) be opened" was measured to a closed NO over the local +spaces corpus: 68 OCR-confirmed bare pills (an independent pixel-level label: the +band sweep read a flush-left `AI生成`) score p50 0.181 / max 0.317, while 799 clean +no-signal negatives reach 0.353 and 18 of them already pass the shipped 0.22 raw +gate. The true-pill and clean distributions overlap completely -- there is no +threshold -- and the engine's own high scores (0.34-0.48 on the flagged cohort) +correlate with textured corners, not with pills: the edge-NCC keys on "text-like +structure top-left". As with the Jimeng wordmark's silhouette, no threshold repairs +this; a bare arm needs a detector that keys on the pill's rounded-rectangle +geometry, and until one exists the bare-arm question is settled by this +measurement, not by gate tuning. Harness: `data/spaces/_pill_bare_measure.py` +(cohorts A/B/C over the no-signal pool). + The capture-less Jimeng pill lives in [`pill_engine.py`](../src/remove_ai_watermarks/pill_engine.py). It uses a synthetic silhouette for detection and a fixed top-left footprint. @@ -866,6 +882,22 @@ Shared behavior is covered by: - [`test_text_mark_faint_mask.py`](../tests/test_text_mark_faint_mask.py) - [`test_text_mark_memory.py`](../tests/test_text_mark_memory.py) +#### Doubao video mark (2026-08-28 registration) + +`detect_doubao_frame` reuses the image engine's synthetic `doubao_alpha.png` as +its video template (same `豆包AI生成` run, bottom-right) through +`_asset_template`, so image and video cannot drift apart on the glyph. Search +profile: heights 3.2-6.0 percent of the short side from origin (0.60, 0.76), +measured on the corpus. Policy is seedance-style single-floor: weak 0.35 / +strong 0.55 / anchor IoU 0.80, calibrated by sequential decode over 39 +TC260-confirmed Doubao videos and 25 negatives -- a 12-frame stable run at 0.35 +accepts 33/39 positives and 0/25 negatives, while 0.30 already accepted one +negative, so the bar stays at 0.35. Through the shipped arbiter +(`identify_video`): 32/39 selected as doubao, 0/25 false; the remainder split +between no stable run (5) and cross-template ties handed to earlier table +entries (sora 1, kling 1) -- the registry's known order-decides behavior, not a +doubao-specific defect. Harness: `data/spaces/_doubao_seq_measure.py`. + ### Fill backends and region erasing [`region_eraser.py`](../src/remove_ai_watermarks/region_eraser.py) implements the diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 6c9f5b5..f6d5274 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -40,6 +40,7 @@ detectors' canonical geometry and house style, not vendor raster fidelity. | --- | --- | --- | --- | | `sora` | Sora 2 mascot and wordmark | Moves among frame positions | Requires a temporally recurring visual match; the older Sora Turbo corner swirl is a different unsupported mark. | | `veo` | Current four-point diamond and legacy `Veo` text | Fixed bottom-right corner | Uses separate silhouettes and requires a recurring match; learned fill is preferable on structured backgrounds. | +| `doubao` | `豆包AI生成` text run | Fixed bottom-right corner | Reuses the image engine's synthetic alpha as the template; a stable 12-frame run above 0.35 is required. | | `seedance` | Boxed `AI` label | Fixed bottom-right corner | Requires an anchored recurring match; the full localized box is filled because a thinner synthetic shape mask leaves the real translucent rim behind. | | `dola` | `Dola AI` text | Fixed bottom-right corner | Requires an anchored recurring match; ByteDance or BytePlus provenance can relax only an existing visual run. | | `hailuo` | `MINIMAX \| hailuo AI` composite label | Fixed lower edge | Uses a synthetic waveform, text, separator, and ring silhouette; the complete recurring label box is filled. A TC260 label naming MiniMax as producer can relax only an existing stable run. | diff --git a/scripts/render_visible_examples.py b/scripts/render_visible_examples.py index 55918c7..d4fa65a 100644 --- a/scripts/render_visible_examples.py +++ b/scripts/render_visible_examples.py @@ -187,6 +187,8 @@ def _video_mark_frame(key: str, w: int, h: int) -> 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):