mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-09 15:36:01 +02:00
Add Seedance and Dola video removal
This commit is contained in:
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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::
|
||||
|
||||
|
||||
@@ -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.",
|
||||
)
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user