mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-20 16:07:17 +02:00
* 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
745 lines
29 KiB
Markdown
745 lines
29 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`; 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](installation.md#feature-extras).
|
|
|
|
## Verify OpenAI SynthID
|
|
|
|
```python
|
|
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.
|
|
|
|
```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")
|
|
```
|
|
|
|
## 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.
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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.
|
|
|
|
```python
|
|
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](photo-classify.md).
|
|
|
|
## 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)
|
|
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:
|
|
|
|
```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)
|
|
```
|
|
|
|
### 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.
|
|
|
|
```python
|
|
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:
|
|
|
|
```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)
|
|
```
|
|
|
|
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.
|
|
|
|
```python
|
|
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.
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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
|
|
|
|
```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` 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.
|
|
|
|
```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 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.
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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:
|
|
|
|
```python
|
|
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`](../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.
|