mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-18 15:31:06 +02:00
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:
8 files changed
+115
-11
No files matched your search
Binary file not shown.
+2
-2
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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":
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
@@ -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):
|
||||
|
||||
Reference in new issue
Block a user