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).
This commit is contained in:
Victor Kuznetsov committed 2026-08-28 19:44:25 -07:00
1 parent 34505e5cc2
commit 95f467b4ad
8 files changed
+115 -11

No files matched your search

Binary file not shown.
+2 -2
View File
@@ -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.
+35 -3
View File
@@ -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
+1
View File
@@ -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. |
+2
View File
@@ -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":
+2 -1
View File
@@ -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",
+68
View File
@@ -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,
+5 -5
View File
@@ -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):