mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-10 08:00:32 +02:00
An adversarial review of52b2c11(five independent audits, each finding put to two skeptics, plus a completeness critic) found four defects that commit introduced and several stale claims it should have caught. The install hint no longer installs -- again. Folding five hints into one INVISIBLE_EXTRA constant dropped the shell quoting the originals had, so the printed remediation was `pip install remove-ai-watermarks[qwen-zimage]`. Bare brackets are a glob in zsh, the macOS default shell: it dies with "no matches found" before pip runs. That is the exact failure52b2c11existed to stop producing, reintroduced in a different form by a bulk replace. The constant is quoted now, and a test asserts the quotes rather than the bare substring -- the old assertions passed either way, which is why nothing caught it. Three tests were not guarding what they claimed: - The commit's headline behaviour change, per-profile polish resolution inside the engine, had no test at all. Rebinding resolve_adaptive_polish to the pre-commit `bool(value)` left the full suite green. Now covered by a test that drives the real engine and observes whether humanizer.adaptive_polish ran; that mutation now fails it. - TestAvailability still asserted the pre-commit (torch, diffusers) contract, so in a diffusion-only environment it was simply wrong, and comparing each gate to a tuple copied from itself could never catch the two gates disagreeing -- the drift the shared REMOVAL_MODULES was introduced to prevent. Replaced with a test that simulates each module's absence and requires BOTH gates to close. - Both CUDA-refusal guards skipped in every environment, including CI: they were gated on the diffusion stack, which no CI job installs. The refusal fires before any torch attribute is read, so they now run everywhere; only the dtype assertion keeps its skip. Also: the retired-knob test covered `invisible` but not `all` or `batch`, though all three declared those options separately; and smoke_matrix.py still called remove_watermark(region=...), a parameter52b2c11deleted, with the resulting TypeError swallowed into a skip by a broad except. Stale documentation the previous sweep missed: known-limitations still described an MPS out-of-memory fallback and a lighter-pipeline escape that no code can produce; module-internals declared Canny thresholds of 100/200 as a compatibility contract while the code uses 13/64, attributed enable_model_cpu_offload to deleted profiles, and still warned that the engine and CLI defaults differ (this commit's predecessor made them identical); cli.md gated `all` on the `diffusion` extra; python-api claimed "cuda" was the only accepted explicit device when "auto" is too. The claim that `device` is not a parameter was wrong in both module-internals and .claude/rules/development.md -- it is one, deliberately, and now says so. `--cpu-offload` help and the pipeline's CUDA guard both still pointed at MPS. Not fixed here, reported instead -- both are outside this repo: - ComfyUI-remove-ai-watermarks nodes.py:332 passes num_inference_steps and guidance_scale (plus min_resolution/upscaler frombf4bfc1). distribute.yml's comfyui job runs on every release and fails the release if the node sync fails, so 0.25.0 needs that node updated first. - raiw-app modal_app.py:422-425 forwards the same two kwargs into remove_watermark. Latent: it is pinned to1a77e24and nothing supplies a value today, so it fires on the next pin bump. pre-commit: 1) maintain.sh - exit 0 (1093 tests, Pyright 0 errors, no vulnerabilities); 2) /simplify - not re-run, this commit is the applied output of a five-dimension adversarial review; 3) docs sync - grepped MPS/mps, the extras names and every symbol touched across README, docs/, scripts/, .claude/; updated 6 docs; 4) CLAUDE.md - corrected the device claim in .claude/rules/development.md and added the shell-quoting rule Verified by execution, not assertion: smoke_matrix --quick 51 pass / 0 fail, _knob_rows driven directly 10 pass / 0 fail / 7 skip (no CUDA), the install hint rendered and round-tripped through zsh, and each new test confirmed to fail under the mutation it is meant to catch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
437 lines
15 KiB
Markdown
437 lines
15 KiB
Markdown
# Python API
|
|
|
|
Use the high level API for normal application integration. Low level detector
|
|
and pipeline modules are intended for maintainers and specialized workflows.
|
|
|
|
Dependency groups are identical for the CLI and Python API. The default install
|
|
covers metadata extraction, normalization, verdict logic, and stripping.
|
|
Array/pixel APIs use `pixels`; visible removal uses `visible`; DWT-DCT detection
|
|
uses `detect`; invisible image removal uses `qwen-zimage` and an NVIDIA GPU; and
|
|
visible video processing uses `video`. Video SynthID removal is a separate VAE
|
|
path that still runs on CPU and combines `video` and `diffusion`. Add `heif`
|
|
independently when path-based pixel APIs must decode HEIC, HEIF, or AVIF. See
|
|
the complete [feature-extra matrix](installation.md#feature-extras).
|
|
|
|
## Remove visible marks
|
|
|
|
Install `remove-ai-watermarks[visible]` before using the visible-removal API.
|
|
|
|
```python
|
|
import remove_ai_watermarks as raiw
|
|
|
|
result, removed = raiw.remove_visible(
|
|
"watermarked.png",
|
|
"clean.png",
|
|
)
|
|
```
|
|
|
|
The function returns:
|
|
|
|
- the result as a BGR NumPy array;
|
|
- a list of labels that were removed.
|
|
|
|
An empty `removed` list means that no registered visible mark was selected. It
|
|
does not prove the image has no metadata or invisible watermark.
|
|
|
|
### Path input
|
|
|
|
For a path input, `remove_visible`:
|
|
|
|
- reads metadata provenance for the default `auto` sensitivity;
|
|
- preserves a separate alpha channel;
|
|
- writes the output when an output path is supplied;
|
|
- strips AI metadata from the written output by default;
|
|
- preserves the original bytes for a same-format no-op copy.
|
|
|
|
```python
|
|
result, removed = raiw.remove_visible(
|
|
"watermarked.png",
|
|
"clean.png",
|
|
sensitivity="auto",
|
|
backend="auto",
|
|
strip_metadata=True,
|
|
)
|
|
```
|
|
|
|
Set `write_noop=False` if the output path must remain untouched when nothing is
|
|
removed:
|
|
|
|
```python
|
|
result, removed = raiw.remove_visible(
|
|
"input.png",
|
|
"clean.png",
|
|
write_noop=False,
|
|
)
|
|
```
|
|
|
|
### Array input
|
|
|
|
Array inputs are BGR NumPy arrays. They do not carry file metadata or a separate
|
|
alpha plane:
|
|
|
|
```python
|
|
import cv2
|
|
import remove_ai_watermarks as raiw
|
|
|
|
image = cv2.imread("input.png")
|
|
result, removed = raiw.remove_visible(image, backend="cv2")
|
|
```
|
|
|
|
## Inspect provenance
|
|
|
|
The default installation evaluates file metadata. Add `visible`, `detect`, or
|
|
`trustmark` to enable the corresponding optional pixel signals.
|
|
|
|
Get the vendor keys used by visible removal:
|
|
|
|
```python
|
|
import remove_ai_watermarks as raiw
|
|
|
|
vendors = raiw.visible_provenance("input.png")
|
|
```
|
|
|
|
Get the full provenance report:
|
|
|
|
```python
|
|
from pathlib import Path
|
|
|
|
from remove_ai_watermarks.identify import identify
|
|
|
|
report = identify(Path("input.png"))
|
|
print(report.platform)
|
|
print(report.signals)
|
|
```
|
|
|
|
Use `check_visible=False` and `check_invisible=False` for metadata-only
|
|
inspection through the compatible path-based API:
|
|
|
|
```python
|
|
report = identify(
|
|
Path("input.png"),
|
|
check_visible=False,
|
|
check_invisible=False,
|
|
)
|
|
```
|
|
|
|
Extraction and detection are also available as separate steps. This is useful
|
|
when a file-reading worker collects the metadata once and another component
|
|
evaluates the resulting evidence:
|
|
|
|
```python
|
|
from remove_ai_watermarks.identify import (
|
|
extract_provenance_evidence,
|
|
identify_from_evidence,
|
|
)
|
|
|
|
evidence = extract_provenance_evidence(Path("input.png"))
|
|
report = identify_from_evidence(evidence)
|
|
```
|
|
|
|
If metadata was collected by another component, normalize its nested record
|
|
without reopening the original file:
|
|
|
|
```python
|
|
from remove_ai_watermarks.identify import (
|
|
evidence_from_metadata_record,
|
|
identify_from_evidence,
|
|
)
|
|
|
|
record = {
|
|
"pil": {"info:parameters": "Steps: 20, Sampler: Euler"},
|
|
"exif": {"0th": {"Software": "Stable Diffusion"}},
|
|
}
|
|
evidence = evidence_from_metadata_record(record, path=Path("input.png"))
|
|
report = identify_from_evidence(evidence)
|
|
```
|
|
|
|
The normalizer recursively preserves text and byte values. It also decodes
|
|
strings prefixed with `hex:` and fields named `base64` or ending in
|
|
`_base64`. Diagnostic values under `error` and `kind` are ignored because they
|
|
describe the collector rather than the source file. Pass a C2PA manifest-store
|
|
dictionary in `record["c2pa_store"]`, or through the explicit
|
|
`c2pa_manifest_store` argument.
|
|
|
|
`identify_from_evidence` does not reopen the source file. It evaluates metadata
|
|
only; registered visible marks and pixel-backed invisible watermarks remain in
|
|
the path-based `identify` call.
|
|
|
|
## Strip metadata
|
|
|
|
```python
|
|
from pathlib import Path
|
|
|
|
from remove_ai_watermarks.metadata import has_ai_metadata, strip_and_verify
|
|
|
|
source = Path("input.png")
|
|
output = Path("clean.png")
|
|
|
|
if has_ai_metadata(source):
|
|
output_path, surviving_markers = strip_and_verify(source, output)
|
|
if surviving_markers:
|
|
raise RuntimeError(
|
|
f"AI metadata remains in {output_path}: {surviving_markers}"
|
|
)
|
|
```
|
|
|
|
Use `strip_and_verify` when your application reports that stripping succeeded.
|
|
It checks the written output and returns `(output_path, surviving_markers)`.
|
|
When the first strip leaves markers in a malformed but raster-decodable image,
|
|
it normalizes the container through `image_io` and checks again. That recovery
|
|
path preserves the pixels but drops standard metadata. Treat a nonempty
|
|
`surviving_markers` mapping as a failure.
|
|
|
|
`remove_ai_metadata` is the lower level fail-safe transformer. It may copy an
|
|
undecodable input through unchanged, so its return alone must not be presented
|
|
as proof that metadata was removed.
|
|
|
|
## Identify and clean video
|
|
|
|
The high level video API supports MP4, MOV, M4V, WebM, MKV, AVI, and FLV:
|
|
metadata-only calls work with the default install, while visible identification,
|
|
removal, and the complete pipeline require `remove-ai-watermarks[video]`.
|
|
|
|
```python
|
|
import remove_ai_watermarks as raiw
|
|
|
|
report = raiw.identify_video("input.mp4")
|
|
print(report.is_ai_generated)
|
|
print(report.platform)
|
|
print(report.visible_mark)
|
|
print(report.metadata_markers)
|
|
```
|
|
|
|
`identify_video` uses the same full-clip temporal arbiter as visible removal.
|
|
It reports a recurring registered mark and supported AI metadata as positive
|
|
signals. When neither is present, `is_ai_generated` is `None`, never `False`.
|
|
The absence of a public local video SynthID decoder is included in `caveats`.
|
|
Pass `check_visible=False` for a bounded metadata-only inspection.
|
|
|
|
For normal product integration, use the complete locally verifiable pipeline:
|
|
|
|
```python
|
|
result = raiw.remove_video_all("input.mp4", "clean.mp4")
|
|
if result.remaining_metadata:
|
|
raise RuntimeError(f"AI metadata remains: {result.remaining_metadata}")
|
|
```
|
|
|
|
The default removes one stable supported visible provider mark when present,
|
|
always strips verified AI metadata, and writes a same-container output even
|
|
when neither signal is found. This gives callers one predictable output path.
|
|
It does not run lossy invisible regeneration by default.
|
|
|
|
`include_invisible=True` explicitly adds VAE regeneration for MP4, MOV, or M4V.
|
|
`VideoAllResult.invisible_removed` reports whether the oracle-certified SynthID
|
|
stage ran.
|
|
|
|
Process a top-level directory sequentially:
|
|
|
|
```python
|
|
batch = raiw.remove_video_batch("videos", "videos_clean", mode="all")
|
|
if batch.failed:
|
|
for item in batch.items:
|
|
if item.error:
|
|
print(item.source, item.error)
|
|
```
|
|
|
|
Batch modes are `all`, `visible`, and `metadata`. Successful visible no-ops are
|
|
copied byte-for-byte, keeping the output directory complete. Per-file failures
|
|
are returned in `VideoBatchItem.error`; they do not discard successful outputs.
|
|
The invisible stage is available only as an explicit opt-in in `all` mode and
|
|
reuses one loaded VAE runtime across the batch.
|
|
|
|
## Inspect and strip video metadata
|
|
|
|
Metadata inspection and removal use the same supported video containers:
|
|
|
|
```python
|
|
import remove_ai_watermarks as raiw
|
|
|
|
report = raiw.inspect_video_metadata("input.mp4")
|
|
if report.has_ai_metadata:
|
|
result = raiw.remove_video_metadata("input.mp4")
|
|
if result.remaining:
|
|
raise RuntimeError(f"AI metadata remains: {result.remaining}")
|
|
```
|
|
|
|
`remove_video_metadata` does not transcode video or audio streams. Its default
|
|
output is `input_clean.mp4`, leaving the source untouched. An explicit output
|
|
must use the same container extension as the source.
|
|
|
|
The returned `VideoMetadataResult` records the source, output, metadata detected
|
|
before removal, and any markers remaining after the verified strip. MP4/MOV
|
|
inspection recognizes the native TC260 `AIGC` entry in
|
|
`moov.udta.meta.keys/ilst`; its removal preserves container size and encoded
|
|
stream bytes. MP4/MOV/M4V are copied in bounded chunks, so a large `mdat` is not
|
|
loaded into memory; publication is atomic. MKV/WebM inspection recognizes the corresponding
|
|
`Segment.Tags.Tag.SimpleTag` representation; its removal requires ffmpeg for a
|
|
stream-copy remux. AVI inspection reads `LIST/INFO/AIGC`, and FLV inspection
|
|
reads `script.onMetaData.AIGC`; both use the same verified ffmpeg stream-copy
|
|
removal path.
|
|
|
|
## Remove video SynthID
|
|
|
|
Install `remove-ai-watermarks[video,diffusion]` before using the video SynthID
|
|
API.
|
|
|
|
```python
|
|
import remove_ai_watermarks as raiw
|
|
|
|
result = raiw.remove_video_invisible(
|
|
"input.mp4",
|
|
"clean.mp4",
|
|
device="auto",
|
|
)
|
|
if result.remaining_metadata:
|
|
raise RuntimeError(f"AI metadata remains: {result.remaining_metadata}")
|
|
```
|
|
|
|
`remove_video_invisible` supports MP4, MOV, and M4V. It regenerates the complete
|
|
video through a VAE in bounded batches, shares one seeded latent-noise field
|
|
across all frames, streams pixels to ffmpeg, copies complete audio, strips
|
|
source metadata, and publishes atomically. The default output is
|
|
`input_clean.mp4`; a distinct same-container output is required.
|
|
|
|
The returned `VideoInvisibleResult` includes output geometry, frame rate, frame
|
|
count, paired PSNR, and the motion-compensated temporal-residual ratio. Those
|
|
fields measure fidelity and flicker only. They are not a SynthID detector.
|
|
The default `noise_std=0.15` is the current full-clip oracle floor; `0.10`
|
|
remained detected on the public eight-second Veo calibration carrier.
|
|
The default profile is oracle-certified. Google does not publish a local
|
|
decoder for this video payload, so a fresh source-positive, output-negative
|
|
pair from Gemini's built-in SynthID verifier remains an optional per-file audit.
|
|
A response inferred from a visible logo or metadata is not such a verdict, and
|
|
an adversarial follow-up asking ordinary Gemini to reinterpret the verifier is
|
|
not a second oracle run.
|
|
|
|
## Remove a supported visible video mark
|
|
|
|
```python
|
|
import remove_ai_watermarks as raiw
|
|
|
|
result = raiw.remove_video_visible(
|
|
"input.mp4",
|
|
"clean.mp4",
|
|
backend="cv2",
|
|
strip_metadata=True,
|
|
temporal_consistency=True,
|
|
)
|
|
if result.output is None:
|
|
print("No temporally stable supported mark was found")
|
|
else:
|
|
print(result.mark)
|
|
|
|
veo_result = raiw.remove_video_visible(
|
|
"veo.mp4",
|
|
"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",
|
|
)
|
|
hailuo_result = raiw.remove_video_visible(
|
|
"hailuo.mp4",
|
|
"hailuo_clean.mp4",
|
|
mark="hailuo",
|
|
)
|
|
kling_result = raiw.remove_video_visible(
|
|
"kling.mp4",
|
|
"kling_clean.mp4",
|
|
mark="kling",
|
|
)
|
|
```
|
|
|
|
`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.
|
|
`mark="auto"` is the default: it evaluates all providers in one decode pass and
|
|
selects the first stable match in specificity order (`sora`, `veo`, `seedance`,
|
|
`dola`, `hailuo`, `kling`). Provider confidence values are calibrated
|
|
independently and are not compared across detectors. Pass one of those explicit
|
|
values to restrict the scan to a single provider. The Veo detector recognizes
|
|
the current four-point diamond and the
|
|
legacy `Veo` text. Seedance recognizes the boxed `AI` label, Dola recognizes
|
|
its compact text label, Hailuo recognizes the composite MINIMAX/Hailuo label,
|
|
and Kling recognizes its bottom-right logo, wordmark, and version suffix. Each
|
|
variant has an independent synthetic silhouette and calibrated temporal policy.
|
|
After each accepted frame is filled, `temporal_consistency=True` motion-aligns
|
|
the preceding accepted fill and blends it only when the warped prior mask
|
|
covers the current mask and a surrounding source-context ring agrees. Scene
|
|
cuts and disjoint masks keep the independent current fill. Pass
|
|
`temporal_consistency=False` for the frame-local baseline.
|
|
|
|
The returned `VideoVisibleResult` records the selected `mark`, the total,
|
|
detected, and removed frame counts, plus any AI metadata that survived the
|
|
output encode. The function returns `output=None` and writes no file when no
|
|
stable mark is selected. Video pixels are transcoded through ffmpeg while the
|
|
complete source audio stream is copied. The encoder preserves supported 8-bit
|
|
source chroma sampling, color tags, MP4/MOV track timescale, and relative
|
|
variable-frame timestamps. It also retains a non-zero source start PTS and the
|
|
copied audio offset. A failed encode preserves any existing output; only a
|
|
completed result is published atomically.
|
|
SDR 8-bit video is the supported pixel contract. High-bit-depth, PQ, and HLG
|
|
sources raise `RuntimeError` before encoding instead of being silently reduced
|
|
to 8-bit SDR.
|
|
|
|
## Remove invisible watermarks
|
|
|
|
Install `remove-ai-watermarks[qwen-zimage]`. Both profiles need it, and both
|
|
need an NVIDIA GPU.
|
|
|
|
```python
|
|
from pathlib import Path
|
|
|
|
from remove_ai_watermarks.invisible_engine import InvisibleEngine
|
|
|
|
engine = InvisibleEngine(
|
|
pipeline="qwen-zimage", # the default; the only other value is "sdxl-zimage"
|
|
device=None,
|
|
cpu_offload=False,
|
|
)
|
|
|
|
engine.remove_watermark(
|
|
Path("watermarked.png"),
|
|
Path("clean.png"),
|
|
)
|
|
```
|
|
|
|
`device=None` and `device="auto"` both run detection. `"cuda"` pins it without
|
|
detecting. Every other value raises at construction rather than deferring a
|
|
guaranteed failure to model-load time.
|
|
|
|
For limited CUDA memory:
|
|
|
|
```python
|
|
engine = InvisibleEngine(
|
|
pipeline="qwen-zimage",
|
|
cpu_offload=True,
|
|
)
|
|
```
|
|
|
|
Both profiles are CUDA-only, so on a machine without an NVIDIA GPU `device=None`
|
|
resolves to `cpu` and construction raises. For the SDXL global stage instead of
|
|
Qwen:
|
|
|
|
```python
|
|
engine = InvisibleEngine(pipeline="sdxl-zimage")
|
|
```
|
|
|
|
The `qwen-zimage` extra is required for both profiles: each runs the same
|
|
DiffSynth Z-Image face stage.
|
|
|
|
`remove_watermark` takes strength, seed, tiling, resolution, and postprocessing
|
|
controls. It takes no model id, step count or guidance scale, and neither does the
|
|
constructor: each profile pins its model stack, its per-stage schedule and CFG
|
|
1.0, so passing one raises `TypeError` at the call rather than being accepted and
|
|
refused several layers down. Read the method signature in
|
|
[`invisible_engine.py`](../src/remove_ai_watermarks/invisible_engine.py) or use
|
|
the CLI guide for the concepts.
|
|
Defaults can differ between the Python method and CLI profile resolution, so
|
|
pass values explicitly when reproducibility matters.
|