Add Seedance and Dola video removal

This commit is contained in:
Victor Kuznetsov
2026-07-29 17:56:52 -07:00
parent f330dd9c94
commit 41b6fd0682
13 changed files with 529 additions and 67 deletions
+2 -2
View File
@@ -28,7 +28,7 @@ Per-command exit-code semantics (the no-signal / GPU-missing skip branches), tes
- `uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>` — strip all AI metadata
- `uv run remove-ai-watermarks video metadata <input.mp4> --check` — inspect AI metadata in MP4/MOV/M4V/WebM/MKV
- `uv run remove-ai-watermarks video metadata <input.mp4> --remove -o <clean.mp4>` — strip verified video metadata without transcoding streams; the experimental video path requires a separate same-container output and defaults to `<source>_clean`
- `uv run remove-ai-watermarks video visible <input.mp4> -o <clean.mp4>` — remove a temporally recurring Sora mark, or pass `--mark veo` for the current Veo diamond and legacy `Veo` text. It scans the full sequence first, transcodes video through ffmpeg, copies audio, strips AI metadata by default, and writes no output when no stable mark is found. `cv2` is the fast default; `migan`/`lama` improve difficult backgrounds.
- `uv run remove-ai-watermarks video visible <input.mp4> -o <clean.mp4>` — remove a temporally recurring Sora mark, or pass `--mark veo|seedance|dola` for the current Veo diamond and legacy `Veo` text, the Seedance boxed `AI`, or `Dola AI`. It scans the full sequence first, transcodes video through ffmpeg, copies audio, strips AI metadata by default, and writes no output when no stable mark is found. `cv2` is the fast default; `migan`/`lama` improve difficult backgrounds.
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the full `invisible` knob set above, plus `--backend` and `--sensitivity` for the visible localize -> fill pass. Applies the same no-signal skip per image; see the module doc. **Exit code:** non-zero when any image errored OR (mirroring single `all`) a `--mode invisible`/`all` image carried an invisible signal but the GPU extra was absent, so its SynthID scrub was skipped — it emits a loud warning and copies the input through (invisible mode) so the output dir stays complete; a wrapping service can then detect the incomplete run instead of trusting a silent exit 0.
## Test and lint
@@ -82,7 +82,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal
- `upscaler.py` — optional Real-ESRGAN pre-diffusion super-resolution for small inputs (extra `esrgan`, spandrel only). Manual opt-in; the default `--upscaler` stays `lanczos` and the engine always falls back to Lanczos on absence/error. ESRGAN can degrade faces and thin text.
- `image_io.py` — centralizes Unicode-safe image IO, alpha preservation, content-based format sniffing, and HEIC/AVIF fallbacks. Callers must check `imwrite` success. No-op visible removal preserves original bytes when the output format is unchanged.
- `api.py` — the high-level convenience API, re-exported lazily at the package top level via `__init__.__getattr__` (PEP 562, so `import remove_ai_watermarks` stays cheap): `remove_visible(source, output=None, *, sensitivity="auto", backend="auto", strip_metadata=True, write_noop=True) -> (result_bgr, [labels])` (source = path OR BGR ndarray; a PATH auto-reads metadata provenance and preserves alpha, an ARRAY does neither; `write_noop=True` writes a clean passthrough copy when nothing is removed, `False` leaves `output` untouched so a "no mark = produce nothing" caller like the CLI `visible` command does not clobber a pre-existing file there) and `visible_provenance(path) -> frozenset[str]` (the single metadata→vendor-keys mapper; `cli._visible_provenance` is a thin None-guarded wrapper over it). **`remove_visible` is the ONE path the CLI and library share** — `cli.cmd_visible`'s `--mark auto` branch delegates entirely to it (read → provenance → `remove_auto_marks` → write → `strip_metadata`), so there is no CLI-vs-library drift; `strip_metadata` defaults True to match `visible --strip-metadata`. This is where a library caller should start — NOT the engines directly (`GeminiEngine`/`TextMarkEngine` have no `remove_watermark` any more; removal is registry `remove_auto_marks`/`KnownMark.remove`; the old single-strongest `best_auto_mark` is gone — removal takes EVERY mark). `identify` is NOT top-level re-exported (it collides with the `identify` submodule); use `from remove_ai_watermarks.identify import identify`.
- `video.py` — the experimental high-level video API, also lazy at the package root: `inspect_video_metadata(source) -> VideoMetadataReport`, `remove_video_metadata(source, output=None, *, keep_standard=True) -> VideoMetadataResult`, and `remove_video_visible(source, output=None, *, mark="sora", backend="cv2", strip_metadata=True) -> VideoVisibleResult`. It validates the extension and container signature for MP4/MOV/M4V/WebM/MKV and requires a distinct same-container output. The metadata path never transcodes streams: native MP4/MOV TC260 is read from `moov.udta.meta.keys/ilst`, including a tail `moov` after a large `mdat`, and removal blanks the key/value in place; MKV/WebM TC260 is read by `noai/ebml.py` and stripped through ffmpeg stream copy. The visible path delegates to `video_visible.py`: fully synthetic Sora and Veo silhouettes propose frame boxes, provider-specific temporal recurrence authorizes them, the shared fill backends remove accepted masks in a second pass, and ffmpeg transcodes video while copying audio. Veo covers the current four-point diamond and legacy text; its diamond uses a shape mask rather than erasing the transparent corners of a full box. Metadata can relax a recurring low-contrast match but cannot create one. The inherited ISOBMFF metadata removal path still reads the complete container into memory, so a streaming box copier is required before large-video use. Other visible video labels and invisible video watermarks are not built yet.
- `video.py` — the experimental high-level video API, also lazy at the package root: `inspect_video_metadata(source) -> VideoMetadataReport`, `remove_video_metadata(source, output=None, *, keep_standard=True) -> VideoMetadataResult`, and `remove_video_visible(source, output=None, *, mark="sora", backend="cv2", strip_metadata=True) -> VideoVisibleResult`. It validates the extension and container signature for MP4/MOV/M4V/WebM/MKV and requires a distinct same-container output. The metadata path never transcodes streams: native MP4/MOV TC260 is read from `moov.udta.meta.keys/ilst`, including a tail `moov` after a large `mdat`, and removal blanks the key/value in place; MKV/WebM TC260 is read by `noai/ebml.py` and stripped through ffmpeg stream copy. The visible path delegates to `video_visible.py`: fully synthetic Sora, Veo, Seedance, and Dola silhouettes propose frame boxes, provider-specific temporal recurrence authorizes them, the shared fill backends remove accepted masks in a second pass, and ffmpeg transcodes video while copying audio. Fixed marks require anchored runs so a slowly drifting scene detail cannot pass on adjacent overlap alone. Veo covers the current four-point diamond and legacy text; its diamond uses a shape mask rather than erasing the transparent corners of a full box. Seedance uses the full localized box because an outline mask left part of the real translucent border behind. Metadata can relax a recurring low-contrast match but cannot create one. The inherited ISOBMFF metadata removal path still reads the complete container into memory, so a streaming box copier is required before large-video use. Other visible video labels and invisible video watermarks are not built yet.
For the Doubao alpha-distillation history (why content-image reverse-alpha distillation fails by physics and controlled captures were required), see `docs/research-doubao-distillation.md`.
+18 -9
View File
@@ -7,8 +7,8 @@ Remove AI provenance marks from images and video you generated yourself:
- C2PA, EXIF, XMP, IPTC, and related AI metadata.
Video support covers metadata inspection and removal plus experimental visible
Sora-wordmark removal. Invisible video-watermark removal remains a follow-up
stage.
Sora, Veo, Seedance, and Dola mark removal. Invisible video-watermark removal
remains a follow-up stage.
> Try it online at [raiw.cc](https://raiw.cc) if you do not want to install Python
> or run diffusion models locally.
@@ -33,7 +33,7 @@ stage.
| Erase a region you select | `erase` | No |
| Strip AI metadata | `metadata` | No |
| Strip AI metadata from video | `video metadata` | No |
| Remove a known Sora or Veo mark from video | `video visible` | No |
| Remove a known Sora, Veo, Seedance, or Dola mark from video | `video visible` | No |
| Regenerate an image to disrupt invisible watermarks | `invisible` | Recommended |
| Run visible, invisible, and metadata removal | `all` | Recommended |
| Process a directory | `batch` | Depends on mode |
@@ -78,11 +78,13 @@ inspection includes the native TC260 `AIGC` tag in
MKV and WebM inspection reads the normative
`Segment.Tags.Tag.SimpleTag` placement.
Remove a moving Sora wordmark or a Veo corner mark:
Remove a supported visible video mark:
```bash
remove-ai-watermarks video visible input.mp4 -o clean.mp4
remove-ai-watermarks video visible veo.mp4 --mark veo -o veo_clean.mp4
remove-ai-watermarks video visible seedance.mp4 --mark seedance -o seedance_clean.mp4
remove-ai-watermarks video visible dola.mp4 --mark dola -o dola_clean.mp4
```
This path scans the complete sequence before changing pixels. It accepts only a
@@ -90,8 +92,9 @@ mark that repeats at a stable position across adjacent frames, then reuses the
same OpenCV, MI-GAN, or LaMa fill backends as image removal. Audio is copied
without re-encoding; the video stream is transcoded because its pixels change.
Sora covers the moving Sora 2 mascot and wordmark. Veo covers both the current
four-point diamond and the legacy `Veo` text in the bottom-right corner. No
output is written when no stable mark is found.
four-point diamond and the legacy `Veo` text. Seedance covers the fixed boxed
`AI` label, and Dola covers the fixed `Dola AI` text. No output is written when
no stable mark is found.
For invisible watermark removal, install the diffusion dependencies:
@@ -243,6 +246,12 @@ report = raiw.inspect_video_metadata("input.mp4")
cleaned = raiw.remove_video_metadata("input.mp4")
visible = raiw.remove_video_visible("sora.mp4", "sora_clean.mp4")
veo = raiw.remove_video_visible("veo.mp4", "veo_clean.mp4", mark="veo")
seedance = raiw.remove_video_visible(
"seedance.mp4",
"seedance_clean.mp4",
mark="seedance",
)
dola = raiw.remove_video_visible("dola.mp4", "dola_clean.mp4", mark="dola")
```
The high level API accepts a file path or a BGR NumPy array. For path inputs it
@@ -267,9 +276,9 @@ invisible removal.
and selected fill backend.
- Invisible removal changes the whole image and may alter faces, text, or fine
detail.
- Visible video removal recognizes the moving Sora 2 wordmark and the current
Veo diamond plus legacy `Veo` text. It does not recognize the older Sora Turbo
corner swirl.
- Visible video removal recognizes the moving Sora 2 wordmark, the current Veo
diamond plus legacy `Veo` text, the Seedance boxed `AI` label, and the fixed
`Dola AI` text. It does not recognize the older Sora Turbo corner swirl.
The classical OpenCV backend can smear structured backgrounds; use MI-GAN or
LaMa when recovery quality matters. Video SynthID is not removed yet.
MP4/MOV/M4V metadata stripping currently reads the full container into
+9 -6
View File
@@ -153,18 +153,21 @@ different container extension.
Visible video labels and invisible video watermarks are not handled by this
command.
## Remove a visible Sora or Veo video mark
## Remove a supported visible video mark
```bash
remove-ai-watermarks video visible input.mp4 -o clean.mp4
remove-ai-watermarks video visible veo.mp4 --mark veo -o veo_clean.mp4
remove-ai-watermarks video visible seedance.mp4 --mark seedance -o seedance_clean.mp4
remove-ai-watermarks video visible dola.mp4 --mark dola -o dola_clean.mp4
```
The experimental command supports the moving Sora mascot and wordmark plus two
Veo corner variants: the current four-point diamond and the legacy `Veo` text.
Sora searches the whole frame at multiple scales. Veo searches the
bottom-right corner using separate synthetic silhouettes for the two variants.
Both require a spatially recurring candidate across adjacent frames. Matching
The experimental command supports the moving Sora mascot and wordmark, two Veo
corner variants, the Seedance boxed `AI` label, and the `Dola AI` text label.
Sora searches the whole frame at multiple scales. The other detectors search
bounded bottom-right regions with separate synthetic silhouettes. Every mark
requires a spatially recurring candidate across adjacent frames. Fixed marks
must also remain anchored instead of drifting with a scene object. Matching
provider provenance may relax the visual score, but metadata alone never
creates a detection. Clean API exports therefore remain untouched.
+14 -8
View File
@@ -174,16 +174,19 @@ WebM, Matroska, MP3, WAV, FLAC, OGG, Opus, and AAC container metadata is strippe
through ffmpeg with stream copying. The operation fails if ffmpeg is absent or
cannot parse the input.
### Visible video removal supports Sora and Veo and is still experimental
### Visible video removal is provider-specific and still experimental
The experimental `video metadata` command and high level video API inspect and
strip supported AI provenance metadata without transcoding streams.
`video visible` and `remove_video_visible` additionally support the moving
Sora 2 mascot and wordmark, the current Veo four-point diamond, and the legacy
`Veo` text. Detection requires a recurring visual candidate across adjacent
frames. Provider provenance can recover low-contrast runs only after visual
evidence exists, so metadata alone does not erase a clean API export.
Sora 2 mascot and wordmark, the current Veo four-point diamond, the legacy
`Veo` text, the Seedance boxed `AI` label, and the fixed `Dola AI` text.
Detection requires a recurring visual candidate across adjacent frames.
Seedance, Dola, and Veo candidates must remain anchored rather than drifting
with a scene object. Provider provenance can recover low-contrast runs only
after visual evidence exists, so metadata alone does not erase a clean API
export.
Historical Sora Turbo exports use a small OpenAI swirl in the corner rather
than the moving mascot-and-wordmark design; that earlier variant is not
detected by the `sora` video mark. Other provider video labels and proprietary
@@ -194,9 +197,12 @@ fill is not a motion-aware video inpainting model. OpenCV can leave a visible
smear where the mark overlaps a hard edge or structured texture, and the smear
can vary over time. MI-GAN and LaMa improve individual frames but do not
guarantee temporal coherence. The Veo diamond uses a shape mask to limit damage
outside the symbol, but OpenCV may still soften texture inside it. The current
encoder also emits a constant-frame-rate output at the decoded stream rate, so
variable-frame-rate preservation is not yet guaranteed.
outside the symbol. Seedance fills the full localized box because a synthetic
outline mask left part of the real translucent border visible in an end-to-end
check. OpenCV may therefore soften texture inside that small box; use MI-GAN or
LaMa when reconstruction quality matters. The current encoder also emits a
constant-frame-rate output at the decoded stream rate, so variable-frame-rate
preservation is not yet guaranteed.
Native TC260 metadata in MP4/MOV is supported at its normative
`moov.udta.meta.keys/ilst` placement, including non-faststart files whose
+21 -19
View File
@@ -115,32 +115,34 @@ only a `Segment.Tags.Tag.SimpleTag` pairing `TagName=AIGC` with a JSON
those container tags without transcoding the encoded streams.
[`video_visible.py`](../src/remove_ai_watermarks/video_visible.py) implements
the first pixel stages for Sora and Veo. 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` text, with bounded bottom-right searches calibrated
independently from Sora. A strong relocated-diamond match may bypass the known
the first pixel stages for Sora, Veo, Seedance, and Dola. 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` text. Seedance uses a synthetic
rounded boxed-`AI` silhouette, while Dola uses an OpenCV-font `Dola AI`
silhouette. All fixed-mark searches are bounded to the bottom-right area and
calibrated independently. A strong relocated Veo diamond may bypass the known
layout anchors, but weak free-corner matches never enter the temporal arbiter.
This prevents recurring scene details in clean API exports from being promoted
to a watermark.
Every per-frame result is untrusted. The provider-specific stabilization
wrappers share one recurrence implementation, while retaining separate visual
floors and minimum-run policy. 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. A confirmed Veo
run can cover low-contrast frames at its fixed position. This separation keeps
clean API exports from being modified merely because metadata names the same
generator.
nearest confirmed moving position only with Sora provenance. Veo, Seedance,
and Dola 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.
Removal runs in a second decode pass. Sora and legacy Veo text use padded box
masks. The square Veo diamond uses a synthetic shape mask so transparent box
corners do not erase unrelated pixels. Every mask goes through the shared
`watermark_registry.fill` backends. ffmpeg encodes the changed video stream and
copies optional audio. The default OpenCV fill is the speed floor; structured
backgrounds need MI-GAN or LaMa for better reconstruction. Invisible video
stages must continue to reuse the image and metadata implementations rather
than copying their logic.
Removal runs in a second decode pass. Sora, legacy Veo text, Dola text, and the
Seedance box use box masks. Seedance deliberately fills the complete localized
box: a synthetic outline mask passed repeat detection but left part of the real
translucent border visible during visual end-to-end review. The square Veo
diamond uses a synthetic shape mask so transparent corners do not erase
unrelated pixels. Every mask goes through the shared `watermark_registry.fill`
backends. ffmpeg encodes the changed video stream and copies optional audio.
The default OpenCV fill is the speed floor; structured backgrounds need MI-GAN
or LaMa for better reconstruction. Invisible video stages must continue to
reuse the image and metadata implementations rather than copying their logic.
The inherited ISOBMFF metadata path currently reads the complete container into
memory; replacing that with a streaming box copier is a prerequisite for large
+16 -4
View File
@@ -154,7 +154,7 @@ stream bytes. MKV/WebM inspection recognizes the corresponding
`Segment.Tags.Tag.SimpleTag` representation; its removal requires ffmpeg for a
stream-copy remux.
## Remove a visible Sora or Veo video mark
## Remove a supported visible video mark
```python
import remove_ai_watermarks as raiw
@@ -173,14 +173,26 @@ veo_result = raiw.remove_video_visible(
"veo_clean.mp4",
mark="veo",
)
seedance_result = raiw.remove_video_visible(
"seedance.mp4",
"seedance_clean.mp4",
mark="seedance",
)
dola_result = raiw.remove_video_visible(
"dola.mp4",
"dola_clean.mp4",
mark="dola",
)
```
`remove_video_visible` scans the complete video before writing output. It
combines synthetic multi-scale visual matching with temporal consistency, so an
isolated lookalike in one frame is not enough to authorize inpainting. The
supported `mark` values are `sora` and `veo`. The Veo detector recognizes the
current four-point diamond and the legacy `Veo` text with separate synthetic
silhouettes.
supported `mark` values are `sora`, `veo`, `seedance`, and `dola`. The Veo
detector recognizes the current four-point diamond and the legacy `Veo` text.
Seedance recognizes the boxed `AI` label, while Dola recognizes its compact
text label. Each variant has an independent synthetic silhouette and calibrated
temporal policy.
The returned `VideoVisibleResult` records the total, detected, and removed frame
counts plus any AI metadata that survived the output encode. The function
+2
View File
@@ -35,6 +35,8 @@ when you can select the affected area yourself.
| --- | --- | --- | --- |
| `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. |
| `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. |
Use `video visible` for this registry. It is separate from the image `visible`
command because selection is made over a sequence rather than one raster.
+13 -2
View File
@@ -56,8 +56,8 @@ the existing ffmpeg stream-copy path removes the tags without transcoding.
- **Built in the dated batch:** soft-binding vendor detection, IPTC Photo
Metadata AI-disclosure fields, C2PA detection and stripping for supported
ISOBMFF video, the optional Adobe TrustMark decoder, and temporally stabilized
visible Sora and Veo removal. Other visible video logos and proprietary
audio-watermark detection remain outside the package.
visible Sora, Veo, Seedance, and Dola removal. Other visible video logos and
proprietary audio-watermark detection remain outside the package.
Metadata stripping for supported audio containers is a separate implemented
path.
@@ -90,6 +90,17 @@ pixels or alpha maps from that project: it uses two synthetic silhouettes,
known-layout searches plus a strong relocated-diamond fallback, and a separate
temporal arbiter calibrated against raw watermarked clips and clean API exports.
**ByteDance video surfaces use distinct visible labels.** Public Seedance
showcase clips contain a fixed rounded box with `AI`, while the Dola sample in
[issue #16](https://github.com/wiltodelta/remove-ai-watermarks/issues/16) uses
fixed `Dola AI` text. The independent
[Seedance remover](https://github.com/SamurAIGPT/seedance-2.0-watermark-remover)
estimates a static corner from a temporal mean frame and edge density. Our
implementation instead matches provider-specific synthetic silhouettes on
every frame, then requires an anchored temporal run. This extra anchor check
was necessary because a moving clean scene detail could retain enough adjacent
overlap to pass a recurrence-only gate.
**The faint-visible-mark precision/recall wall is fundamental, not a heuristic artifact.** The visible-watermark-detection literature has moved to LEARNED segmentation / object-detection (WDNet WACV'21 arXiv:2012.07616; SLBR ACM MM'21, open code+weights; the PRCV'18 large-scale detector; Su et al. survey 2025), but three verified findings bound what a learned detector actually buys: (1) a claim that a confidence threshold "cleanly separates" true from false matches even with a learned CNN front-end was **REFUTED** in verification (arXiv:1705.08593) -- the precision/recall wall persists even with learned features. (2) Learned detectors need a LARGE, pattern-diverse labeled dataset trained on synthetic composites (PRCV'18: 60k images / 80 watermark classes; CLWD: 60k / 160 marks), and off-distribution degradation is a documented real axis (models trained on limited-pattern LVW transfer worse; diversity of training patterns drives generalization). (3) Inference is cheap (WDNet ~8 ms at 256x256) -- the cost is the data pipeline, not runtime. Net: a learned detector shifts the frontier but does NOT remove the wall; for a SINGLE mark the cheapest next step is a small patch classifier (real-sparkle vs false-positive) on top of the existing NCC localizer, not a full segmentation model. SLBR is a ready baseline. The current NCC + false-positive gate (core-ring brightness margin + gradient-NCC crispness + white-core saturation) is a sound operating point, and the residual miss is the information-theoretic wall the literature confirms.
**Visible-mark landscape beyond the registry.** Meta stamps a visible "Imagined with AI" mark (bottom-LEFT, a small symbol) on its OWN Meta AI / "Imagine" output; for third-party images it relies on C2PA / IPTC, not a visible mark. Samsung Galaxy AI additionally uses a **four-star icon** variant in a corner alongside the localized text wordmark `samsung_engine` calibrates (only the Italian text variant is covered) -- the icon is a distinct, uncovered variant. Every source agrees visible + metadata marks are trivially removable (crop / screenshot, ~2 s), which is the tool's premise.
+1 -1
View File
@@ -8,7 +8,7 @@ High-level API (lazy, so ``import remove_ai_watermarks`` stays cheap)::
raiw.visible_provenance("in.png") # -> frozenset of confirmed vendors
raiw.inspect_video_metadata("in.mp4") # -> VideoMetadataReport
raiw.remove_video_metadata("in.mp4", "out.mp4") # verified metadata strip
raiw.remove_video_visible("in.mp4", "out.mp4") # stable Sora or Veo mark removal
raiw.remove_video_visible("in.mp4", "out.mp4") # stable visible video-mark removal
For a provenance verdict use the ``identify`` submodule::
+1 -1
View File
@@ -1152,7 +1152,7 @@ def cmd_video_metadata(
)
@click.option(
"--mark",
type=click.Choice(["sora", "veo"]),
type=click.Choice(["sora", "veo", "seedance", "dola"]),
default="sora",
help="Visible AI mark to remove.",
)
+31 -10
View File
@@ -1,8 +1,8 @@
"""High-level video processing API.
Supported experimental stages are container-level AI metadata inspection and
removal plus temporally stabilized visible Sora and Veo removal. The pixel path
reuses the image package's shared fill backends.
removal plus temporally stabilized visible Sora, Veo, Seedance, and Dola
removal. The pixel path reuses the image package's shared fill backends.
"""
from __future__ import annotations
@@ -133,26 +133,31 @@ def remove_video_visible(
) -> VideoVisibleResult:
"""Remove a supported visible AI wordmark from a video.
Supported marks are ``sora`` and ``veo``. The full sequence is scanned before
pixels change, and only recurring candidates are accepted. Audio is copied
without re-encoding; video is transcoded because the pixels change. When no
stable mark is found, no output is written and ``output`` in the result is
``None``.
Supported marks are ``sora``, ``veo``, ``seedance``, and ``dola``. The full
sequence is scanned before pixels change, and only recurring candidates are
accepted. Audio is copied without re-encoding; video is transcoded because
the pixels change. When no stable mark is found, no output is written and
``output`` in the result is ``None``.
"""
from remove_ai_watermarks.metadata import get_ai_metadata
from remove_ai_watermarks.video_visible import (
encode_clean_video,
has_bytedance_video_provenance,
has_sora_provenance,
has_veo_provenance,
scan_dola_video,
scan_seedance_video,
scan_sora_video,
scan_veo_video,
stabilize_dola_localizations,
stabilize_seedance_localizations,
stabilize_sora_localizations,
stabilize_veo_localizations,
)
from remove_ai_watermarks.watermark_registry import resolve_backend
if mark not in {"sora", "veo"}:
raise ValueError("Unsupported visible video mark; expected 'sora' or 'veo'")
if mark not in {"sora", "veo", "seedance", "dola"}:
raise ValueError("Unsupported visible video mark; expected sora, veo, seedance, or dola")
if backend not in {"auto", "cv2", "migan", "lama"}:
raise ValueError("Unsupported fill backend; expected auto, cv2, migan, or lama")
@@ -167,7 +172,7 @@ def remove_video_visible(
)
padding_fraction = 0.28
mask_style = "box"
else:
elif mark == "veo":
scan = scan_veo_video(source_path)
regions = stabilize_veo_localizations(
scan.detections,
@@ -175,6 +180,22 @@ def remove_video_visible(
)
padding_fraction = 0.18
mask_style = "veo"
elif mark == "seedance":
scan = scan_seedance_video(source_path)
regions = stabilize_seedance_localizations(
scan.detections,
provenance=has_bytedance_video_provenance(markers),
)
padding_fraction = 0.0
mask_style = "box"
else:
scan = scan_dola_video(source_path)
regions = stabilize_dola_localizations(
scan.detections,
provenance=has_bytedance_video_provenance(markers),
)
padding_fraction = 0.20
mask_style = "box"
detected_frames = sum(region is not None for region in regions)
if detected_frames == 0:
return VideoVisibleResult(
+211 -4
View File
@@ -1,9 +1,10 @@
"""Visible AI-watermark localization and removal for video.
Supported marks use fully synthetic silhouettes made from geometric primitives
and Pillow's bundled font. Sora detection searches the full frame because the
wordmark moves. Veo detection covers both the current four-point diamond and the
legacy ``Veo`` text in the bottom-right corner. A single frame is never enough
Supported marks use fully synthetic silhouettes made from geometric primitives,
OpenCV's built-in font, and Pillow's bundled font. Sora detection searches the
full frame because the wordmark moves. Veo detection covers both the current
four-point diamond and legacy ``Veo`` text. Seedance detects the boxed ``AI``
label, while Dola detects its compact text label. A single frame is never enough
to authorize removal: the temporal arbiter requires the candidate to recur at
the same location across adjacent frames. This keeps isolated lookalikes in
clean videos from becoming removal masks.
@@ -53,8 +54,14 @@ _SORA_STRONG_CONFIDENCE = 0.65
_VEO_PROVENANCE_WEAK_CONFIDENCE = 0.45
_VEO_STRICT_WEAK_CONFIDENCE = 0.50
_VEO_STRONG_CONFIDENCE = 0.55
_SEEDANCE_WEAK_CONFIDENCE = 0.38
_SEEDANCE_STRONG_CONFIDENCE = 0.43
_DOLA_PROVENANCE_WEAK_CONFIDENCE = 0.48
_DOLA_STRICT_WEAK_CONFIDENCE = 0.50
_DOLA_STRONG_CONFIDENCE = 0.52
_MIN_STABLE_FRAMES = 5
_MIN_VEO_STABLE_FRAMES = 12
_MIN_FIXED_MARK_STABLE_FRAMES = 12
_MAX_STABLE_GAP = 2
_STABLE_IOU = 0.55
_VEO_REFERENCE_SHORT_SIDE = 720
@@ -63,6 +70,7 @@ _VEO_DIAMOND_PROFILES = (
(48, 72, 72),
(44, 29, 40),
)
_DOLA_RELATIVE_HEIGHTS = tuple(value / 1000 for value in range(22, 41))
@dataclass(frozen=True)
@@ -155,6 +163,43 @@ def _veo_templates() -> tuple[NDArray[Any], NDArray[Any]]:
return np.asarray(diamond_canvas, dtype=np.uint8), text
@lru_cache(maxsize=1)
def _seedance_template() -> NDArray[Any]:
"""Return a synthetic boxed-AI silhouette for Seedance exports."""
canvas = Image.new("L", (160, 120), 0)
draw = ImageDraw.Draw(canvas)
draw.rounded_rectangle((8, 8, 142, 105), radius=28, outline=255, width=7)
draw.text(
(35, 17),
"AI",
font=_scalable_default_font(70),
fill=255,
stroke_width=1,
stroke_fill=255,
)
draw.rounded_rectangle((142, 94, 157, 109), radius=3, outline=255, width=2)
draw.text((145, 94), "AI", font=_scalable_default_font(8), fill=255)
return np.asarray(canvas, dtype=np.uint8)
@lru_cache(maxsize=1)
def _dola_template() -> NDArray[Any]:
"""Return a synthetic Dola AI text silhouette using OpenCV's font."""
canvas = np.zeros((100, 400), dtype=np.uint8)
cv2.putText(
canvas,
"Dola AI",
(2, 72),
cv2.FONT_HERSHEY_DUPLEX,
2.2,
255,
3,
cv2.LINE_AA,
)
ys, xs = np.where(canvas > 0)
return canvas[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
def _top_hat(gray: NDArray[Any]) -> NDArray[Any]:
kernel = np.ones((7, 7), dtype=np.uint8)
return cv2.morphologyEx(gray, cv2.MORPH_TOPHAT, kernel)
@@ -270,6 +315,105 @@ def _match_template(
)
def _restore_region(
region: Region | None,
*,
scale: float,
frame_width: int,
frame_height: int,
) -> Region | None:
"""Map a localization from normalized pixels back to the source frame."""
if region is None:
return None
x, y, width, height = region
source_x = round(x / scale)
source_y = round(y / scale)
source_width = min(frame_width - source_x, max(1, round(width / scale)))
source_height = min(frame_height - source_y, max(1, round(height / scale)))
return source_x, source_y, source_width, source_height
def _detect_fixed_bottom_right_mark(
image_bgr: NDArray[Any],
template: NDArray[Any],
*,
relative_heights: tuple[float, ...],
search_origin: tuple[float, float],
kernel_fraction: float,
frame_index: int,
) -> FrameLocalization:
"""Match one fixed bottom-right synthetic mark on a normalized frame."""
if image_bgr.size == 0:
return FrameLocalization(frame_index, 0.0, None)
frame_height, frame_width = image_bgr.shape[:2]
gray, scale = _normalized_gray(image_bgr)
normalized_height, normalized_width = gray.shape[:2]
short_side = min(normalized_height, normalized_width)
search_x = round(normalized_width * search_origin[0])
search_y = round(normalized_height * search_origin[1])
search_region = (
search_x,
search_y,
normalized_width - search_x,
normalized_height - search_y,
)
best_confidence = 0.0
best_region: Region | None = None
for relative_height in relative_heights:
template_height = max(6, round(short_side * relative_height))
template_width = max(1, round(template.shape[1] * template_height / template.shape[0]))
resized = cv2.resize(
template,
(template_width, template_height),
interpolation=cv2.INTER_AREA,
)
confidence, candidate = _match_template(
gray,
resized,
region=search_region,
kernel_size=max(3, round(template_height * kernel_fraction) | 1),
)
if confidence > best_confidence:
best_confidence = confidence
best_region = candidate
return FrameLocalization(
frame_index,
best_confidence,
_restore_region(
best_region,
scale=scale,
frame_width=frame_width,
frame_height=frame_height,
),
)
def detect_seedance_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
"""Locate the strongest fixed Seedance boxed-AI candidate."""
return _detect_fixed_bottom_right_mark(
image_bgr,
_seedance_template(),
relative_heights=(0.065, 0.075, 0.085, 0.095, 0.105),
search_origin=(0.68, 0.72),
kernel_fraction=0.12,
frame_index=frame_index,
)
def detect_dola_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
"""Locate the strongest fixed Dola AI text candidate."""
return _detect_fixed_bottom_right_mark(
image_bgr,
_dola_template(),
relative_heights=_DOLA_RELATIVE_HEIGHTS,
search_origin=(0.65, 0.85),
kernel_fraction=0.50,
frame_index=frame_index,
)
def detect_veo_frame(image_bgr: NDArray[Any], *, frame_index: int = 0) -> FrameLocalization:
"""Locate the strongest current-diamond or legacy-text Veo candidate."""
if image_bgr.size == 0:
@@ -413,6 +557,43 @@ def stabilize_veo_localizations(
)
def stabilize_seedance_localizations(
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
*,
provenance: bool,
) -> list[Region | None]:
"""Accept a recurring Seedance boxed-AI mark at a fixed position."""
return _stabilize_localizations(
detections,
provenance=provenance,
weak_floor=_SEEDANCE_WEAK_CONFIDENCE,
strong_floor=_SEEDANCE_STRONG_CONFIDENCE,
transition_floor=0.30,
min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES,
cover_after_confirmation=True,
anchor_iou=0.80,
)
def stabilize_dola_localizations(
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
*,
provenance: bool,
) -> list[Region | None]:
"""Accept a recurring Dola AI text mark at a fixed position."""
weak_floor = _DOLA_PROVENANCE_WEAK_CONFIDENCE if provenance else _DOLA_STRICT_WEAK_CONFIDENCE
return _stabilize_localizations(
detections,
provenance=provenance,
weak_floor=weak_floor,
strong_floor=_DOLA_STRONG_CONFIDENCE,
transition_floor=0.40,
min_stable_frames=_MIN_FIXED_MARK_STABLE_FRAMES,
cover_after_confirmation=True,
anchor_iou=0.80,
)
def _stabilize_localizations(
detections: tuple[FrameLocalization, ...] | list[FrameLocalization],
*,
@@ -422,6 +603,7 @@ def _stabilize_localizations(
transition_floor: float,
min_stable_frames: int,
cover_after_confirmation: bool,
anchor_iou: float | None = None,
) -> list[Region | None]:
"""Apply the shared recurrence policy to provider-specific candidates."""
accepted: list[Region | None] = [None] * len(detections)
@@ -433,11 +615,14 @@ def _stabilize_localizations(
continue
if current:
previous = detections[current[-1]]
anchor = detections[current[0]]
frame_gap = detection.frame_index - previous.frame_index
if (
previous.region is None
or anchor.region is None
or frame_gap > _MAX_STABLE_GAP + 1
or _region_iou(previous.region, detection.region) < _STABLE_IOU
or (anchor_iou is not None and _region_iou(anchor.region, detection.region) < anchor_iou)
):
runs.append(current)
current = []
@@ -534,6 +719,16 @@ def scan_veo_video(source: Path) -> VideoScan:
return _scan_video(source, detect_veo_frame)
def scan_seedance_video(source: Path) -> VideoScan:
"""Decode a video once and collect one untrusted Seedance candidate per frame."""
return _scan_video(source, detect_seedance_frame)
def scan_dola_video(source: Path) -> VideoScan:
"""Decode a video once and collect one untrusted Dola candidate per frame."""
return _scan_video(source, detect_dola_frame)
def _ffmpeg_video_args(suffix: str) -> list[str]:
if suffix == ".webm":
return ["-c:v", "libvpx-vp9", "-crf", "18", "-b:v", "0"]
@@ -704,3 +899,15 @@ def has_veo_provenance(markers: dict[str, str]) -> bool:
)
).lower()
return "google" in identity and "trainedalgorithmicmedia" in markers.get("source_type", "").lower()
def has_bytedance_video_provenance(markers: dict[str, str]) -> bool:
"""Whether container provenance names ByteDance or BytePlus AI video."""
identity = " ".join(
(
markers.get("claim_generator", ""),
markers.get("issuer", ""),
)
).lower()
source_type = markers.get("source_type", "").lower()
return ("bytedance" in identity or "byteplus" in identity) and "trainedalgorithmicmedia" in source_type
+190 -1
View File
@@ -392,6 +392,85 @@ class TestVeoFrameLocalization:
assert mask[67, 67] == 0
class TestByteDanceFrameLocalization:
def test_localizes_independently_rendered_seedance_box(self):
from remove_ai_watermarks.video_visible import _region_iou, detect_seedance_frame
frame = np.full((720, 1280, 3), 30, dtype=np.uint8)
mark = Image.new("L", (80, 60), 0)
draw = ImageDraw.Draw(mark)
draw.rounded_rectangle((2, 2, 70, 53), radius=14, outline=255, width=4)
try:
font = ImageFont.load_default(size=35)
except TypeError:
font = ImageFont.load_default()
draw.text((18, 8), "AI", font=font, fill=255)
mark_array = np.asarray(mark, dtype=np.float32)
x, y = 1130, 620
alpha = mark_array[:, :, None] / 255 * 0.65
crop = frame[y : y + 60, x : x + 80].astype(np.float32)
frame[y : y + 60, x : x + 80] = np.clip(
crop * (1 - alpha) + 255 * alpha,
0,
255,
).astype(np.uint8)
detection = detect_seedance_frame(frame)
assert detection.region is not None
assert detection.confidence >= 0.43
assert _region_iou(detection.region, (x, y, 80, 60)) >= 0.75
def test_localizes_independently_rendered_dola_text(self):
from remove_ai_watermarks.video_visible import _region_iou, detect_dola_frame
frame = np.full((720, 1280, 3), 35, dtype=np.uint8)
mark = np.zeros((40, 150), dtype=np.uint8)
cv2.putText(
mark,
"Dola AI",
(2, 28),
cv2.FONT_HERSHEY_SIMPLEX,
0.9,
255,
2,
cv2.LINE_AA,
)
ys, xs = np.where(mark > 0)
mark = mark[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
mark_height, mark_width = mark.shape
x = frame.shape[1] - mark_width - 18
y = frame.shape[0] - mark_height - 14
alpha = mark.astype(np.float32)[:, :, None] / 255 * 0.75
crop = frame[y : y + mark_height, x : x + mark_width].astype(np.float32)
frame[y : y + mark_height, x : x + mark_width] = np.clip(
crop * (1 - alpha) + 255 * alpha,
0,
255,
).astype(np.uint8)
detection = detect_dola_frame(frame)
assert detection.region is not None
assert detection.confidence >= 0.52
assert _region_iou(detection.region, (x, y, mark_width, mark_height)) >= 0.75
def test_seedance_box_mask_covers_the_full_localized_mark(self):
from remove_ai_watermarks.video_visible import _mask_for_region
mask = _mask_for_region(
np.zeros((120, 160, 3), dtype=np.uint8),
(20, 20, 80, 60),
padding_fraction=0.0,
mask_style="box",
)
assert mask[15, 15] == 0
assert mask[16, 16] == 255
assert mask[83, 103] == 255
assert mask[84, 104] == 0
class TestSoraTemporalArbiter:
_BOX = (40, 60, 150, 54)
@@ -562,6 +641,65 @@ class TestVeoTemporalArbiter:
assert stabilize_veo_localizations(detections, provenance=False) == [None] * 12
class TestByteDanceTemporalArbiter:
_SEEDANCE_BOX = (1110, 610, 90, 66)
_DOLA_BOX = (1160, 680, 96, 22)
def test_seedance_requires_twelve_recurring_frames(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_seedance_localizations
detections = [FrameLocalization(index, 0.50, self._SEEDANCE_BOX) for index in range(11)]
assert stabilize_seedance_localizations(detections, provenance=False) == [None] * 11
def test_seedance_strong_run_covers_low_contrast_frames(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_seedance_localizations
detections = [FrameLocalization(index, 0.45, self._SEEDANCE_BOX) for index in range(12)]
detections.extend(FrameLocalization(index, 0.20, (200, 100, 80, 60)) for index in range(12, 15))
assert stabilize_seedance_localizations(detections, provenance=False) == [self._SEEDANCE_BOX] * 15
def test_seedance_rejects_a_slowly_drifting_scene_detail(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_seedance_localizations
detections = [FrameLocalization(index, 0.46, (1110 - index * 3, 610, 90, 66)) for index in range(14)]
assert stabilize_seedance_localizations(detections, provenance=False) == [None] * 14
def test_dola_requires_twelve_recurring_frames(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_dola_localizations
detections = [FrameLocalization(index, 0.60, self._DOLA_BOX) for index in range(11)]
assert stabilize_dola_localizations(detections, provenance=True) == [None] * 11
def test_dola_provenance_accepts_recurring_low_contrast_text(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_dola_localizations
detections = [FrameLocalization(index, 0.49, self._DOLA_BOX) for index in range(12)]
assert stabilize_dola_localizations(detections, provenance=True) == [self._DOLA_BOX] * 12
def test_dola_without_provenance_needs_a_strong_frame(self):
from remove_ai_watermarks.video_visible import FrameLocalization, stabilize_dola_localizations
detections = [FrameLocalization(index, 0.51, self._DOLA_BOX) for index in range(12)]
assert stabilize_dola_localizations(detections, provenance=False) == [None] * 12
def test_bytedance_provenance_requires_ai_source_type(self):
from remove_ai_watermarks.video_visible import has_bytedance_video_provenance
assert has_bytedance_video_provenance(
{
"issuer": "BytePlus (ByteDance)",
"source_type": "trainedAlgorithmicMedia (AI-generated)",
}
)
assert not has_bytedance_video_provenance({"issuer": "BytePlus (ByteDance)"})
class TestVideoVisibleApi:
def test_removes_stable_sora_run_and_writes_output(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
from remove_ai_watermarks import video_visible
@@ -666,6 +804,57 @@ class TestVideoVisibleApi:
assert result.detected_frames == 12
assert result.removed_frames == 12
@pytest.mark.parametrize(
("mark", "scan_name", "mask_style"),
[
("seedance", "scan_seedance_video", "box"),
("dola", "scan_dola_video", "box"),
],
)
def test_dispatches_bytedance_detectors(
self,
tmp_path: Path,
monkeypatch: pytest.MonkeyPatch,
mark: str,
scan_name: str,
mask_style: str,
):
from remove_ai_watermarks import video_visible
from remove_ai_watermarks.video import remove_video_visible
from remove_ai_watermarks.video_visible import FrameLocalization, VideoScan
source = _video_with_c2pa(tmp_path / "source.mp4")
output = tmp_path / "clean.mp4"
box = (40, 40, 20, 12)
scan = VideoScan(
width=64,
height=64,
fps=24.0,
detections=tuple(FrameLocalization(index, 0.60, box) for index in range(12)),
)
monkeypatch.setattr(video_visible, scan_name, lambda _source: scan)
def fake_encode(
_source: Path,
target: Path,
_scan: VideoScan,
regions: list[tuple[int, int, int, int] | None],
**kwargs: object,
) -> int:
assert regions == [box] * 12
assert kwargs["mask_style"] == mask_style
target.write_bytes(_MP4_FTYP + _box(b"mdat", _VIDEO_PAYLOAD))
return 12
monkeypatch.setattr(video_visible, "encode_clean_video", fake_encode)
result = remove_video_visible(source, output, mark=mark)
assert result.output == output
assert result.mark == mark
assert result.detected_frames == 12
assert result.removed_frames == 12
class TestVideoVisibleCli:
def test_help(self):
@@ -673,7 +862,7 @@ class TestVideoVisibleCli:
assert result.exit_code == 0, result.output
assert "temporally stable" in result.output
assert "sora|veo" in result.output
assert "sora|veo|seedance|dola" in result.output
def test_reports_removed_frames(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
from remove_ai_watermarks import video