Files
remove-ai-watermarks/docs/python-api.md
T
Victor KuznetsovandClaude Opus 5 4a896cd4b5 Make InvisibleOptions engine knobs only and pin the forwarding
`InvisibleOptions` promises in its docstring that every default mirrors
`InvisibleEngine`. Two fields made that promise cost something to keep: `force` is
not an engine parameter at all, and `controlnet_scale` was a third spelling of the
engine's `controlnet_conditioning_scale`. The mirror test carried an exception
table for each. This removes both, so the comparison needs no exceptions -- a field
that needs one is a field that belongs somewhere else.

`force` decides WHETHER the engine runs, which is settled before it is built, so it
joins `backend` and `sensitivity` as a parameter of `remove_all` and `remove_batch`
and is threaded to `_run_invisible` as its own argument. `controlnet_scale` takes
the engine's own name; the click option stays `--controlnet-scale` and is now
translated exactly once instead of at three forwarding sites.

Safe to do today: both symbols landed after 0.25.0 and have never been published.

The forwarding turned out to be the weaker half. A defaults comparison cannot see a
hardcoded literal at the seam, and `_run_invisible` passed the entire suite with
`controlnet_conditioning_scale` pinned to a constant. Each of the two knobs also
reaches the engine through TWO paths -- `remove_all` versus `remove_batch(mode="all")`
for `force`, `_run_invisible` versus `_batch_engine` for the scale -- and guarding one
left the other free to hardcode with a green suite. So:

  * `test_every_field_arrives_at_the_engine_with_the_caller_s_value` drives the real
    seam with all 13 fields set off their defaults; mutating any one of them to its
    default fails it.
  * `test_force_reaches_the_scrub_gate_in_every_scrubbing_mode` and
    `test_batch_controlnet_scale_flows_to_the_cached_engine` are parametrized over
    both modes, so neither path can be pinned alone.

Also fixes an order-dependent test surfaced by the added tests reshuffling the xdist
shards. `test_visible_path_decodes_file_once` counted every `image_io.imread` in the
process, but the Gemini engine loads its own bundled capture assets on first
construction, so the count was 3 on a cold engine and 1 on a warm one and the test
passed only when an earlier test happened to build the engine first. It now counts
decodes of the SOURCE, which is the invariant it exists for, and still fails when the
shared decode is broken. The production path was never wrong: the source bitmap is
decoded exactly once.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:33:54 -07:00

18 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 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.
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")

Run the full pipeline

remove_all is the library form of the all command: visible marks, then the invisible watermark, then AI metadata. Stages are chained through a file in the system temp directory, so a partial result never appears at the output path.

import remove_ai_watermarks as raiw

result = raiw.remove_all("input.png", "clean.png")   # -> RemoveAllResult
print(result.output)          # the path written
print(result.visible_label)   # the marks removed, or None
print(result.invisible)       # "removed" | "no-signal" | "unavailable"

invisible is the field to check. "unavailable" means the GPU extra is not installed, so the output looks processed but still carries the watermark; "no-signal" means the scrub was deliberately skipped because nothing was locally detectable, which is a successful run.

Pass InvisibleOptions to tune the diffusion stage, and engine to reuse one loaded model across many calls:

from remove_ai_watermarks import InvisibleOptions

raiw.remove_all(
    "input.png",
    "clean.png",
    invisible=InvisibleOptions(strength=0.35),
    force=True,
    progress=print,
)

InvisibleOptions carries only what InvisibleEngine itself takes, and uses the engine's own parameter names and defaults. force, which decides whether the engine runs at all, is a parameter of remove_all and remove_batch alongside backend and sensitivity.

If AI metadata survives the strip, remove_all raises MetadataStripIncomplete before writing anything: an AI-readable output is worse than no output.

remove_batch runs one mode over a directory and never lets a single bad file end the run:

summary = raiw.remove_batch("in_dir", "out_dir", mode="visible")   # -> BatchSummary
print(summary.processed, summary.failed, summary.errors)
print(summary.invisible_unavailable)   # outputs that still carry the watermark

mode is all, visible, invisible, or metadata. Pass a constructed InvisibleEngine as engine to load the model once for the whole directory.

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 by default: it evaluates metadata only, and registered visible marks and pixel-backed invisible watermarks remain in the path-based identify call.

Pass image_path together with check_visible or check_invisible to add those pixel detectors on top of the SAME evidence. That is how a caller asking one file two provenance questions — which vendor is confirmed, and is there an invisible target — pays for the metadata extraction once:

from remove_ai_watermarks.identify import extract_provenance_evidence, identify_from_evidence

evidence = extract_provenance_evidence(source)
metadata_only = identify_from_evidence(evidence)
with_pixels = identify_from_evidence(evidence, image_path=source, check_invisible=True)

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.