Files
remove-ai-watermarks/docs/python-api.md
T
Victor Kuznetsov 07b07634cf Merge the docs/arxiv-paper-review research line into main (#95)
* Add rigorous SynthID research and evaluation harness

* Record SynthID cross-epoch validation results

* Reset SynthID detector hypotheses to paired training

* Add paired wavelet and spectral SynthID probes

* Record closed-corpus SynthID detector ablations

* Add registered SynthID phase probing

* Add periodic SynthID tile probing

* Add calibrated SynthID pixel detector

* Harden scale-registered SynthID detection

* Record SynthID phase and channel rescue findings

* Add production SynthID routing and OpenAI verification

* Reframe periodic pixel route as pipeline lattice; confirm and harden detection

The frozen periodic experts read an origin-anchored generation-pipeline lattice destroyed by a crop off the tile grid, not the crop-robust SynthID mark. Route the pixel result as an experimental pipeline_lattice signal kept out of the watermark inventory, and carry the crop sensitivity in every verdict envelope.

Add split-patch phase/amplitude/codeword confirmation for registered-v3, affine-lattice and cyclostationary research probes, and timeout/retry/error-taxonomy hardening for the official OpenAI verification path.

* Record pipeline-lattice reframing and registered-v3 phase lock

Document that the pixel route detects an origin-anchored lattice rather than the watermark, and record the registered-v3 two-pixel phase-lock measurement: a 2px diagonal crop killed all 36 tested detections (28 foreign-generator, 8 Google) with recovery only at offsets that are multiples of four. Update README, CLI, Python API, supported-signals, known-limitations, module-internals, the SynthID reference, and the detector research plan.

* Split SynthID campaign docs by detector, classifiers, and removal

Keep Model 1 as a research AI-versus-camera result outside the library.
Retire the mixed oracle-ladders page to a routing hub and mark the plan
file as a chronological archive.

* Keep 2026-08-22 SynthID campaign results in research docs only

Drop Bayer, Model 1, and lattice re-check numbers from user guides and
module internals. The library surface is unchanged.

* Unplug lattice detector from package, expand research docs

* Remove lattice detector sources from the public package

* Document SynthID GitHub sweep findings

* Document general AI classifier sweep

* Document modern-negative taxonomy audit and classifier doc split

Split general AI-classifier research into ai-generated-image-classifiers.md
and record the modern-negative campaign: Unsplash/Openverse date-clean cells,
contamination controls, quarter-hard paired result, closed linear veto and
taxonomy-continuation arms.

pre-commit: 1) maintain.sh - exit 1, known uv-secure lightning advisory with no upstream fix (same triage as main); core checks separately green (ruff, format, pyright, 1564 tests); 2) /simplify - docs-only single pass, no findings; 3) docs sync - split references updated everywhere, none stale; 4) CLAUDE.md - routing line already updated, compliant

* Record wild-AI audit, Meta provider class, and stock-negative expansion

The wild vendor-flagged AI cell (300 stratified rows) puts Model 1 recall at
69.7% on unknown-renderer stock AI; the stock-negative harvest triples the
modern fashion/product cells and confirms the combined-pool veto control;
the Meta muse-image corpus doubles to 132 rows with its margin sweep; a
per-channel cv2 reference fixes the latent fold test under cv2 4.10.0.

pre-commit: 1) maintain.sh - exit 1, known uv-secure lightning advisory with no upstream fix; core checks separately green (ruff, format, pyright, 1665 tests); 2) /simplify - docs-only single pass, no findings; 3) docs sync - new run references point at the gitignored research store, none stale; 4) CLAUDE.md - compact, no changes needed

* Close representation-change campaign: both candidates falsified

Pre-registered gates (AI-test >=1772, fresh OI <=50, Kodak 0, FLUX >=288,
EvalGEN >=95) rejected DINOv2-L ridge head (1427/1847, 162/3000, 1/24, 29/300,
5/100) and the paired-reconstruction FFT tower (14/1847, 12/3000, 0/24, 0/300,
0/100; contract learned at 0.949 pairwise separation but no transfer to
production generators). Campaign verdict: general classifier beyond local
scale as pursued; product stays on documented remainder.

pre-commit: 1) maintain.sh - exit 1, known uv-secure lightning advisory with no upstream fix; core checks green minutes ago (ruff, format, pyright, 1665 tests); 2) /simplify - docs-only single pass, no findings; 3) docs sync - verdict mirrored in data/research/REPRESENTATION-CAMPAIGN.md, no stale refs; 4) CLAUDE.md - no changes

* Record full failure map, H2 router falsification, and H3 process detector closure

20 measured negative domains with batch-2 (memes, whiteboards, tickets, book
pages). H2 domain router falsified: 97.9% of AI-test looks structured, every
family 94-100%. H3 process detector on 369 contrastive pairs from three
generators (OpenAI gpt-image-1-mini, Meta muse-image-1.0, Gemini flash-image)
achieves 0.909 pairwise AUC on training pairs but transfers at AUC 0.55-0.60
to production AI; human receipts score higher than any AI family. The
generator-specific spectral trace does not generalize across three generators,
closing the process-trace family in its current form.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1665 tests); 2) /simplify - docs + research scripts, single pass, no findings; 3) docs sync - all artifacts in data/research/, no stale refs; 4) CLAUDE.md - no changes

* Record H4-H6 research hypotheses: MLP probe, spectral invariant, TTA voting

H4 (MLP on frozen CLIP): 98.6% AI-test recall but 15.8% photo FPR — same
trade-off curve as quarter-hard. H5 (spectral invariant from CVPR 2025):
2.4% recall, the 1/f^alpha profile does not discriminate current generators.
H6 (TTA voting): fixes museum FP but not documents/UI/fashion, loses 9% recall.
All three falsified against frozen gates; ten total closed hypotheses now
document the CLIP-L representation limit from every angle.

Also records the provider cascade across all 20 domains (69.8% stray on
documents mirrors the detector failure map) and the full Y-phase campaign
PSNR data awaiting oracle rate-limit reset.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1665 tests); 2) /simplify - docs-only single pass, no findings; 3) docs sync - all artifacts in data/research/, no stale refs; 4) CLAUDE.md - no changes

* Fix Y-phase frequency convention, record H4-H6 and campaign results

The initial Y-phase campaign targeted FFT bins 16-32 instead of spatial
periods 16-32 px (1/32 to 1/16 cycles/px). After the fix, the full-octave
Y-phase scramble reproduces the known kill (not_detected at 20.5 dB on s1
at native 1122x1402). Partial sub-bands are visually invisible (42.5 dB)
but stay detected: the mark is distributed across the full octave, not
concentrated. Point intervention is not viable within this frequency band.

H4 MLP probe: 98.6% recall, 15.8% FPR (same trade-off wall).
H5 spectral invariant: 2.4% recall (1/f^alpha does not discriminate).
H6 TTA voting: fixes museum FP but not documents/UI/fashion.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (1665 tests, verified minutes ago); 2) /simplify - docs-only; 3) docs sync - all artifacts synced; 4) CLAUDE.md - no changes

* Add TC260 fifth class and per-class margin calibration to provider cascade

Five-head cascade (openai/google/tc260/meta/no_ai): openai 68.8%, google
76.6%, tc260 63.8%, meta 71.8% mean recall at uniform margin 0.50.
Per-class margins calibrated against 1.67% photo budget: openai 0.31,
google 0.29, tc260 0.39, meta 0.55. TC260 test recall 71.4% at calibrated
margins on 196 held-out images. Chinese-AIGC is a viable pixel class.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (verified within the hour); 2) /simplify - research script, single pass; 3) docs sync - artifacts in data/research/; 4) CLAUDE.md - no changes

* Record universal detector v3-v8: diverse training shifts the curve

Fresh MLP on frozen CLIP embeddings with 49 negative domains (12,258 images)
and 5,043 AI positives achieves 93.0% AI-test recall with 39/49 domains at
zero false positives. Digital art FP dropped from 99.3% (Model 1) to 24.8%,
fashion from 62-71% to 12%, UI from 99.3% to 12%. Photo FPR 0.7%. The
improvement comes from training data diversity on frozen embeddings, not
representation change (v5 fine-tune proved this by undoing the gains).
Also adds .local-eval to ruff exclude so the gate covers only tracked files.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1731 tests); 2) /simplify - docs + research; 3) docs sync - all artifacts in data/research/; 4) CLAUDE.md - no changes

* Add three-tier ensemble detector: DEFINITELY/POSSIBLY/LIKELY_HUMAN

Combines Model 1 and v8 into calibrated confidence tiers. DEFINITELY_AI
(both models agree) achieves 79.6% recall at 0.50% photo FPR with 46/49
domains at zero FP. The union reaches 93.3% recall at 1.80% photo FPR
with Kodak 0/24. First detector working across ALL content types with
per-tier calibrated error rates.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1731 tests); 2) /simplify - docs + ensemble; 3) docs sync - artifacts in data/research/; 4) CLAUDE.md - no changes

* Add provider v2: diversity training eliminates document leakage

Same diversity-training insight applied to the provider cascade. Adding
3,769 structured negative features to training drops document/receipt
leakage from 69.8% to 5.1%, UI from 10% to 4.3%, fashion from 5-15% to
0-3%. AI recall improves: OpenAI 84% (was 75%), Google 88.8% (was 79%).
Photo leakage 1.4%. Worst regression: memes 47.9% (was 27%).

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1731 tests); 2) /simplify - provider v2; 3) docs sync - artifacts in data/research/; 4) CLAUDE.md - no changes

* Add universal v11: full taxonomy, 136 domains, 124 at zero FP

Master data plan mapped 148 visual domains. Massive collection (3,343
images, 73 domains from Openverse) plus gap-filler (724 images from
Safebooru anime/manga) expanded training to 19,768 images across 136
negative domains. Universal v11 (MLP-512 + Model 1 ensemble):
DEFINITELY 90.3% recall at 0.50% photo FPR, ANY 95.6% recall,
124/134 domains at exactly 0% FP, worst FP 13.8% (digital art).

New domains opened: anime, manga, chibi, mecha, vintage photos,
banknotes, newspapers, magazines, movie posters, antique maps,
watercolor/oil originals, patterns, cosmetics, real estate, 3D
renders, and 60+ more.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1731 tests); 2) /simplify - docs + research; 3) docs sync - artifacts in data/research/; 4) CLAUDE.md - no changes

* Confirm representation limit: v12 fine-tune destroys structured FP despite 6x data

Fine-tuning CLIP tower on 19,518 images achieves 95.1% standalone recall
but returns to 100% FP on art/UI and 14/59 zero-FP domains (vs 124/134
for frozen v11). This is a representation property, not a data issue.
Frozen CLIP + MLP (v11) is the only working architecture. Also records
provider v3 trained on all negative domains.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (ruff, format, pyright, 1731 tests); 2) /simplify - docs + research; 3) docs sync - artifacts in data/research/; 4) CLAUDE.md - no changes

* Document v13 plateau: 128/136 zero FP, frozen-CLIP ceiling confirmed

v13 with top-plan expansion (1,169 new images) improves domain coverage
to 128/136 at zero FP and reduces fashion FP to 5.3%, but DEFINITELY
recall plateaus at 89.6% and art FP at 15.9%. The frozen CLIP + MLP
architecture has reached its ceiling.

pre-commit: 1) maintain.sh - exit 1, known lightning advisory; core green (verified minutes ago, 1731 tests); 2) /simplify - docs-only; 3) docs sync - v13 recorded; 4) CLAUDE.md - no changes

* Document the 2026-08-31 photo-classifier freeze.

Record the AI-versus-camera contract, Meta v3 hold-out, Flickr UI
replacement, and that receipts stay out of the supported domain.

* Scrub Spaces catalog details from classifier research docs

* Record receipt layout-gate probe for the universal detector
2026-09-01 11:37:32 -07:00

29 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; pixel photo classification uses classify; 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.

Verify OpenAI SynthID

import remove_ai_watermarks as raiw

result = raiw.verify_openai_synthid("input.png", acknowledge_upload=True)
print(result.status)

Official OpenAI JSON results expose signal_family, provider_scope, backend, metadata_used_for_verdict, and pixels_preserved. Remote transport and response failures raise OpenAIProvenanceError. Its status_code, error_code, request_id, retry_after, and retryable attributes let a caller implement bounded backoff or a circuit breaker without turning an API outage into a false not_detected result. One function call still performs at most one upload.

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,
)

vendor="meta" names a strength cohort explicitly (the measured Content Seal floor) for a stripped file whose provenance no longer carries the AI IPTC tag, and implies the scrub runs:

raiw.remove_all(
    "muse_output.webp",
    "clean.png",
    invisible=InvisibleOptions(vendor="meta"),
)

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.

Classify a photograph from pixels

This is not provenance. identify does not call it. Install remove-ai-watermarks[classify] first.

from pathlib import Path

from remove_ai_watermarks.classify import classify_pixels

result = classify_pixels(Path("input.png"))
print(result.label, result.detector, result.provider)

label is ai only on a DEFINITELY detector result (ridge AND freeze MLP). POSSIBLY is unknown. Camera-like photographs are human. provider is openai, google, muse-image, or tc260 only when label is ai and the 124-d head beats no_ai by the freeze margin. tc260 is the China AIGC label standard (mixed producers), not a company. Otherwise it is None, including when 124-d extraction refuses the file.

device is a library parameter: None / "auto" detect, "cpu" or "cuda" pin. It is not a CLI option.

Missing extra raises RuntimeError with the quoted install command 'remove-ai-watermarks[classify]'. Guide: photo pixel classification.

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)
print(report.c2pa_validation)

c2pa_validation, when present, reports integrity, signature, signer_trust, and signer_validity independently, plus the reader status codes. A valid hash and signature is a high-confidence signed claim; an unanchored or expired signer appears in caveats and in these fields, not as a lower confidence, because the reader ships no trust anchors to check against and signer_trust is therefore a missing input rather than a finding. A hash or signature failure, or a revoked signing credential, does not confirm the claimed platform or AI origin.

A consumer must read integrity_clashes. When a credential fails validation, is_ai_generated becomes None, because a claim that cannot be tied to these bytes cannot establish origin -- the manifest may have been transplanted from a real AI image onto anything -- and the failure is reported in integrity_clashes instead. That is a different question from whether an AI watermark is physically present in the pixels, which is what has_invisible_target answers, and it stays fail-safe True on the same file. Reading only is_ai_generated turns a broken vendor manifest into silence.

c2pa_validation["state"] is the reader's own aggregate and is carried for diagnostics only; no verdict is derived from it, because it collapses a transplanted manifest and a merely expired certificate into one Invalid, and its Trusted level depends on anchors no default installation has. Fallback parsing reports unknown validation dimensions, while a raw marker in an unsupported or malformed container can leave c2pa_validation as None.

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)

Collect once, judge elsewhere

collect_metadata_record splits the two halves apart: it is the only step that touches the file, and it returns a JSON-serializable record the verdict can be built from on another machine, in another process, or later.

import json

from remove_ai_watermarks.identify import identify_metadata_record
from remove_ai_watermarks.metadata_record import collect_metadata_record

record = collect_metadata_record(Path("input.png"), schema_version=1)   # reads the file
blob = json.dumps(record)                             # ship it anywhere

report = identify_metadata_record(json.loads(blob), path=Path("input.png"))  # reads nothing
payload = report.to_dict(schema_version=1)            # versioned JSON contract

The collection record has record_type="provenance_metadata", schema_version=1, and a status. A vanished or unreadable source produces an error record with structured issues; identify_metadata_record rejects that record instead of turning a collection failure into an unknown-image verdict. Unknown schema versions, non-integer aliases, and native records without a complete collection status are rejected explicitly.

The verdict is the same one identify(path, check_visible=False, check_invisible=False) returns for that file. That equality is the record's whole contract and is verified over the tracked provenance fixtures and a separate local evaluation corpus. ProvenanceReport.to_dict() is the stable service boundary: it adds a schema_version, contains only JSON-safe values, and deliberately omits the local source path.

Package and transport versions evolve independently. Long-lived consumers should request the schema they implement, as above, instead of assuming the installed package's latest schema. Within schema 1, existing fields, types, meanings, signals[].name values, and watermarks[] labels remain compatible; releases may add fields that consumers must ignore. A breaking change requires a new schema while the schema 1 serializer remains available for rolling upgrades. Asking a release for an unsupported schema raises ValueError rather than silently returning another shape.

Microsoft InvisMark declarations emit both the compatible generic soft_binding signal and the additive invismark signal. Consumers should use invismark to route the image through pixel removal; the generic signal also covers content fingerprints that must not trigger regeneration.

A record carries metadata regions, not the primary coded-pixel stream: marker segments before the JPEG scan, every PNG chunk except IDAT, RIFF chunks except the coded image, the ISOBMFF provenance boxes, the container's trailer, the parsed EXIF tags the verdict reads by name, PIL's info mapping, and the C2PA manifest store. Record size is bounded by those metadata regions and trailers; images with large embedded manifests naturally produce larger records.

The path argument is metadata: it labels the report and is never opened by either function, so a record collected elsewhere can be judged against a path that does not exist locally.

If metadata was collected by another component instead, normalize its nested record the same way:

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)

Unversioned third-party records are normalized recursively for compatibility. The normalizer preserves text and byte values. It also decodes strings prefixed with hex: and fields named base64 or ending in _base64. Diagnostic, transport, timing, hash, provenance-result, and pixel-result subtrees are ignored because they describe the collector or a derived result rather than the source file. A versioned portable record is stricter still: only metadata_base64, tail_base64, pil, exif, and c2pa_store are accepted as source evidence. Other record_type values are rejected, so do not pass a broad forensic inspection record to this API. Pass a C2PA manifest-store dictionary in record["c2pa_store"], or through the explicit c2pa_manifest_store argument.

Broad metadata inspection

collect_forensic_metadata provides the wide metadata-only record used by forensic inspection and migration adapters. It preserves hashes and timestamps, full EXIF and IPTC, C2PA, container inventories, bounded raw metadata payloads, and embedded thumbnail forensics. It does not calculate a provenance verdict or pixel statistics.

from remove_ai_watermarks.forensic_metadata import collect_forensic_metadata

record = collect_forensic_metadata(Path("input.png"), schema_version=1)
assert record["record_type"] == "forensic_metadata"

This record is intentionally not accepted by identify_metadata_record. Collect the small strict provenance record separately and publish the resulting ProvenanceReport.to_dict() as the detector contract.

Pixel evidence

extract_pixel_evidence decodes once and calculates the DCT, FFT, residual, ELA, gradient, and color families. Its versioned to_dict() result has a semantic status: complete, partial when an individual family failed, or error when the source could not be decoded. Transported errors contain only the exception class, so local paths stay in the caller's logs rather than crossing the service boundary.

from remove_ai_watermarks.pixel_evidence import extract_pixel_evidence

pixels = extract_pixel_evidence(Path("input.png"), artifacts=False, timings=True)
payload = pixels.to_dict(schema_version=1)

Timings and spatial artifacts are opt-in. Artifacts include image-identifying data such as a thumbnail and perceptual hash; aggregate feature families do not.

identify_from_evidence does not reopen the source file by default: it evaluates metadata only, and the pixel-backed checks remain in the path-based identify call: registered visible marks and open invisible-watermark decoders.

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 and the QuickTime-form meta variants Doubao's iOS export writes; 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 AI recognizes the composite MINIMAX/Hailuo AI label, and Kling AI 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]. All three profiles need it, and all need an NVIDIA GPU.

from pathlib import Path

from remove_ai_watermarks.invisible_engine import InvisibleEngine

engine = InvisibleEngine(
    pipeline="qwen-zimage",  # the default; also "sdxl-zimage", "chroma-zimage", or "auto"
    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,
)

All 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 every profile, including auto: each concrete engine runs the same DiffSynth Z-Image face stage. pipeline="auto" selects chroma-zimage for OpenAI and Microsoft provenance and qwen-zimage otherwise, after the vendor is known and before strength resolution.

The opt-in verified-text stage uses the same text_manifest argument as the CLI:

engine.remove_watermark(
    Path("watermarked.png"),
    Path("clean.png"),
    text_manifest=Path("verified-lines.json"),
)

Install remove-ai-watermarks[text-restoration]. The manifest schema and safety constraints are documented in the CLI guide. The engine verifies its decoded RGB hash before loading the diffusion models. Qwen and Chroma reconstruct the donor with the VAE already loaded for the one profile selected by auto; no second generative profile runs. The engine rejects SDXL, downscaling, and postprocessing combinations that were not evaluated. Tiling is also rejected because the combined tiled-restoration path has no provider-oracle calibration. InvisibleOptions exposes the same field for remove_all; after a visible-stage edit, the manifest must be built against the staged pixels rather than the pristine source.

Use manifest schema 1 for manually reviewed text plus script metadata. Automated operators that verify only text-region geometry should emit schema 2 lines with a box and optional angle; no placeholder transcription or script is required.

Since 0.27.1 the mode's global 15% Qwen-VAE fidelity-anchor blend is off by default (fidelity_anchor=False): that whole-frame blend was measured to return detector-visible OpenAI SynthID on poster-scale manifests (official Content Provenance API, 2026-08-19 - detected x6 with the anchor, clean x6 without it, controls and base outputs validated in the same sessions). Pass fidelity_anchor=True with qwen-zimage to reproduce the 0.27.0 research behavior. Chroma rejects that Qwen-specific reproduction flag.

Drafting manifest lines

remove_ai_watermarks.text_draft proposes lines for a manifest; it never produces verified ones:

from remove_ai_watermarks.text_draft import draft_text_lines

draft = draft_text_lines(Path("watermarked.png"))
for line in draft.accepted:
    print(line.box, line.script, line.min_score, line.text)

Install remove-ai-watermarks[text-draft] (CPU, no torch: PaddleOCR detection plus three script-chosen recognition engines). A line lands in accepted only when three crop paddings normalize identically and every confidence clears min_score (default 0.85); accepted means crop-stable, NOT ground-truth- correct - on the reference posters the draft's exact-text precision was 90.0% and 94.4% because high-confidence OCR still lost punctuation. Every accepted line needs a human yes/no before a manifest may claim verified: true. source_pixel_sha256 is re-exported here for building the manifest's pixel-binding hash against the exact source the engine will decode.

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.