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>
15 KiB
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.
Remove visible marks
Install remove-ai-watermarks[visible] before using the visible-removal API.
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
autosensitivity; - 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.
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:
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:
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:
import remove_ai_watermarks as raiw
vendors = raiw.visible_provenance("input.png")
Get the full provenance report:
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:
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:
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:
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
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].
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:
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:
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:
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.
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
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.
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:
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:
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 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.