# Module internals

This page documents the current implementation contract. It intentionally
avoids experiment logs, corpus counts, and calibration history. Those records
live in [the verification plan](verification-plan.md) and the research archive
listed in [the documentation index](index.md).

Read the relevant section before changing a subsystem. When this page and the
code disagree, the code and its tests are authoritative and this page must be
updated in the same change.

## Architecture

The package has four main paths:

```mermaid
flowchart LR
    Input[Input file] --> Identify[Identify provenance]
    Input --> Visible[Visible mark removal]
    Input --> Invisible[Diffusion regeneration]
    Input --> Metadata[Metadata stripping]

    Identify --> Report[ProvenanceReport]
    Visible --> VisibleOutput[Localized and filled image]
    Invisible --> InvisibleOutput[Regenerated image]
    Metadata --> MetadataOutput[Container with AI metadata removed]
```

The `all` command runs visible removal, optional invisible regeneration, and
metadata stripping in that order.

## Command line interface

[`cli.py`](../src/remove_ai_watermarks/cli.py) owns command parsing and
user-facing exit behavior.

Important contracts:

- Single-image arguments reject directories.
- `visible` writes no output when no registered mark is selected and exits with
  `EXIT_NO_VISIBLE_MARK`.
- `invisible` writes no output when no supported local signal is found, unless
  `--force` is supplied.
- The two no-signal conditions currently share exit code `2`.
- A quiet `metadata --check` and a successful `metadata --remove` (same for
  `video metadata`) end by repeating the `identify` limit: the pixel channel is
  untouched and a watermark such as SynthID has no local decoder once its
  metadata proxy is gone, so neither outcome is a clean verdict.
- Hard processing and write failures exit with code `1`.
- `all` can still write the completed visible and metadata stages when the
  diffusion dependencies are unavailable, but exits with code `1` so the
  partial result is not reported as complete.
- `batch` counts per-file failures and exits nonzero if any file failed or an
  applicable invisible stage was skipped because its dependencies were absent.

The decorators for diffusion options are shared by `invisible`, `all`, and
`batch`. The runtime help generated by Click is the source of truth for option
names and defaults.

`--adaptive-polish` is tri-state: it declares `default=None`, so "the user did not
choose" is a value the CLI passes through rather than a default it has to invent.
`resolve_adaptive_polish` in `watermark_profiles.py` turns that `None` into the
profile's answer (off for `qwen-zimage`, whose output already matches the input's
detail level; on for `sdxl-zimage`). The same call runs inside
`InvisibleEngine.remove_watermark`, so a library caller and a CLI caller on one
profile get the same output.

It used to read Click's parameter source in the CLI instead. That put per-profile
data in the argument-parsing layer, left the engine declaring the opposite default,
and silently lost the polish for anything supplying the flag non-interactively (an
envvar default or a wrapper calling `main()` with a defaulted list is classified
`DEFAULT`). The seed follows the same rule: the CLI does not pre-resolve it either.

Regression coverage:

- [`test_cli.py`](../tests/test_cli.py)
- [`test_cli_robustness.py`](../tests/test_cli_robustness.py)
- [`test_optional_deps.py`](../tests/test_optional_deps.py)

## High-level Python API

[`api.py`](../src/remove_ai_watermarks/api.py) provides the visible-mark entry
points:

- `remove_visible`
- `visible_provenance`

and the image pipeline that the `all` and `batch` commands are thin wrappers
over:

- `remove_all`, returning a `RemoveAllResult` after the visible, invisible, and
  metadata stages
- `remove_batch`, returning a `BatchSummary` for one directory and one mode
- `InvisibleOptions`, the invisible stage's knobs as one immutable value. Engine
  knobs only, under the engine's own names and defaults, so a bare
  `InvisibleOptions()` behaves exactly like calling the engine with no arguments.
  The engine takes them across two callables, `__init__` for what shapes the
  loaded stack and `remove_watermark` for the per-image ones, so `_run_invisible`
  forwards each field to the right one rather than splatting the whole bag. Two
  defaults silently stopped
  mirroring: `max_resolution=None` reached `_target_size`'s `max_resolution > 0`
  and raised `TypeError` on every library call, and `cpu_offload=True` made a
  library run slower than the identical CLI run.
  `TestInvisibleOptionsMirrorTheEngine` compares the two signatures field by
  field, and deliberately keeps no exception table: a field needing one is a
  field that belongs elsewhere. `force` was such a field, and it decides whether
  the engine runs rather than how, so it is a parameter of `remove_all` and
  `remove_batch` next to `backend` and `sensitivity`
- `MetadataStripIncomplete`, raised before any write when AI metadata survives

`remove_all` reports progress as `(stage, detail)` pairs of stable tokens, not
prose the caller has to parse back.

The package root exposes all of them lazily through
[`__getattr__`](../src/remove_ai_watermarks/__init__.py), keeping a plain package
import free of the heavier image and model imports.

For path inputs, `remove_visible` reads provenance metadata, preserves alpha,
and optionally writes and strips metadata. Array inputs are treated as BGR
arrays and have no file provenance or separate alpha plane.

When no visible mark is removed, a same-format path copy preserves the original
bytes. `write_noop=False` leaves the requested output path untouched instead.

Regression coverage:

- [`test_api.py`](../tests/test_api.py)
- [`test_image_io.py`](../tests/test_image_io.py)

[`video.py`](../src/remove_ai_watermarks/video.py) provides the high-level
video entry point:

- `identify_video`
- `inspect_video_metadata`
- `remove_video_all`
- `remove_video_batch`
- `remove_video_invisible`
- `remove_video_metadata`
- `remove_video_visible`

The video API validates both the supported extension and container signature,
then delegates all metadata detection and stripping to `metadata.py`. It
requires a separate same-container output, defaulting to `<source>_clean`, so
the product path does not overwrite an original. The package root exposes all
functions lazily.

`identify_video` runs the same stable-mark selection helper as
`remove_video_visible`, so a provenance report cannot authorize a mark that the
removal path would reject. It reports an empty local result as unknown rather
than clean. Identification skips the separate per-frame timestamp probe because
it never encodes frames. `remove_video_all` is the predictable-output
composition: visible removal plus verified metadata stripping by default, with
a same-container passthrough when neither signal exists. The lossy invisible
removal stage is an explicit opt-in through the oracle-certified profile.
`remove_video_batch` applies those contracts sequentially across a top-level
directory, returns every per-file failure, and byte-copies visible no-ops so a
successful output set has no silent holes. An invisible batch loads one VAE
runtime and reuses it across every compatible file; a failed model load is
reported per file without retrying the same multi-GB initialization.

Native MP4/MOV TC260 labels follow TC260-PG-20257A:
`moov.udta.meta.keys` maps an `AIGC` key to a raw JSON value in `ilst`.
[`_internal/isobmff.py`](../src/remove_ai_watermarks/_internal/isobmff.py) walks those
nested boxes by seeking, so detection reaches a tail `moov` without reading the
preceding `mdat`. Two Doubao iOS variants sit outside that normative placement
and are covered by the same walker (2026-08-17 corpus findings, both previously
undetected): a QuickTime-form `meta` box as a *direct* `moov` child (no FullBox
header, disambiguated by probing the child-box offset), and a QuickTime
`hdlr=mdir` metadata list under `udta.meta` whose `ilst` data items carry the
validated JSON with no `keys` box at all (content-validated, so only genuine
TC260 JSON matches; the keyless entry has no key name to blank, so its value
alone is spaced out). The MP4/MOV/M4V/M4A removal path first validates the top-level
box walk, then copies the source to a sibling temporary file in bounded chunks.
Supported C2PA/JUMBF/AI-label boxes become same-size `free` boxes with blank
payloads; TC260 removal changes the four-byte key to `free` and blanks only the
validated JSON value with same-length spaces. This preserves every box size,
`stco`/`co64` offset, encoded stream byte, and source-sized memory bound.
Publication is atomic, and a malformed top-level walk is copied unchanged. A
generic `AIGC` key whose value has no TC260 field is ignored.

[`_internal/ebml.py`](../src/remove_ai_watermarks/_internal/ebml.py) provides the
corresponding bounded Matroska/WebM reader. It seeks over clusters and accepts
only a `Segment.Tags.Tag.SimpleTag` pairing `TagName=AIGC` with a JSON
`TagString` carrying a TC260 field. The existing ffmpeg stream-copy path removes
those container tags without transcoding the encoded streams.

[`_internal/riff.py`](../src/remove_ai_watermarks/_internal/riff.py) and
[`_internal/flv.py`](../src/remove_ai_watermarks/_internal/flv.py) implement the remaining
normative TC260 video placements. The RIFF walker reads only AVI
`LIST/INFO/AIGC` children. The FLV walker skips media tags and parses the AMF0
`script.onMetaData.AIGC` string. Both require a recognized TC260 JSON field and
use the verified ffmpeg stream-copy path for removal.

[`video_encoding.py`](../src/remove_ai_watermarks/video_encoding.py) owns the
ffmpeg command and pipe lifecycle shared by visible removal and invisible
regeneration. It centralizes container codecs, optional audio stream copying,
metadata/chapter policy, encode-failure reporting, and atomic same-directory
publication. Each mapped stream is allowed to reach its own end, so a copied
audio tail is not shortened to the frame-input duration.
Both the raw-BGR and timestamped-NUT stdin modes redirect ffmpeg stderr to a
temporary file while frames are written. Waiting to read diagnostics until
after stdin closed allowed stderr backpressure to stop ffmpeg's frame reads,
which in turn blocked the producer before it could close stdin. The file consumes
no pipe capacity or RAM while ffmpeg runs; completion reports a bounded head and
tail when diagnostics are unusually large. Aborts release it even when ffmpeg
has already exited. A real subprocess regression writes diagnostics beyond pipe
capacity while streaming frames, checks bounded failure reporting, and the Linux
full-clip CI job guards the complete path.
Frame encoding and source-audio copying run as two ffmpeg processes in sequence.
The streaming encoder has only the frame pipe as input, so input probing or
demux queues cannot deadlock the producer against a second input. After that
pipe reaches EOF, a finite stream-copy mux combines the encoded video with the
source audio and applies the requested metadata/chapter policy. Both stages use
sibling temporary files, and only the completed mux is published atomically.
The mux also redirects diagnostics to disk and reports only a bounded head and
tail. Command regressions assert the single-input encoder and final map targets;
failure regressions cover bounded mux diagnostics and atomic cleanup.
`probe_video_encode_profile` reads the first source video stream with ffprobe
and preserves the supported properties that survive the 8-bit BGR boundary:
`yuv420p`/`yuv422p`/`yuv444p` chroma sampling, recognized color tags, encoder
time base, MP4/MOV track timescale, source pixel format, and component depth.
Both raw-CFR and timestamped-NUT inputs use ffmpeg's passthrough FPS mode. This
keeps one encoded frame per supplied frame when an older ffmpeg receives a
fine-grained source encoder time base such as `1/90000`; implicit synchronization
can otherwise synthesize thousands of duplicate frames between CFR timestamps.
HDR transfer functions and component depths above 8 bits are rejected before
encoding so the OpenCV boundary cannot silently reduce them to SDR 8-bit.
`probe_video_timestamps` reads authoritative per-frame display PTS through
ffprobe. OpenCV timestamps are only a count-matched fallback when ffprobe is
unavailable or fails; this avoids decoder anomalies such as one spurious
negative first-frame timestamp turning a CFR clip into false VFR. A uniform sequence keeps the
cheap raw-BGR pipe unless the source starts at a non-zero PTS. A variable or
offset sequence is packetized by the lazy PyAV bridge as rawvideo in an
in-memory NUT stream with explicit PTS. System ffmpeg reads that stream with
`-fps_mode passthrough`; `-copyts` additionally retains a non-zero video start
and the corresponding copied-audio offset. No temporary frame sequence or
second video encoder is introduced.

[`video_temporal.py`](../src/remove_ai_watermarks/video_temporal.py) owns the
shared optical-flow maps and temporal residual metric. Visible removal uses
`stabilize_filled_frame` after the selected image backend: it works on a
bounded crop around adjacent masks, backward-warps the prior cleaned frame,
requires high warped-mask coverage, and gates blending on an unmasked
source-context ring. Only covered current-mask pixels change. Scene cuts,
disjoint marks, and poor motion matches therefore retain the independent
current-frame fill. The same module supplies the motion-compensated metric used
by the invisible-video sweep.

[`video_invisible.py`](../src/remove_ai_watermarks/video_invisible.py)
implements the oracle-certified video SynthID removal engine. It samples frames
uniformly, resizes to a VAE-aligned geometry, encodes each frame to latent
space, applies one seeded spatial-noise field across the entire sequence, and
decodes fresh pixels. Reusing a single noise field avoids independent
frame-to-frame noise. The shipped path retains only one configured frame batch,
updates PSNR and temporal residuals incrementally, and streams BGR frames
directly to the video-only ffmpeg encoder. A separate stream-copy mux then adds
optional source audio and drops all source metadata. The result is written
through same-directory temporary files and atomically replaced only after both
stages succeed.

The engine returns PSNR and a motion-compensated temporal-residual ratio as
quality measurements. Neither is a watermark detector. The high-level result
reports completed removal without a separate verification-status flag. The companion
`scripts/video_synthid_sweep.py` imports the same engine helpers to build a
matched control and candidate grid, preventing research and shipped
regeneration paths from drifting.

The engine's `psnr_db` is measured against the already-resized frame and before
the encoder, so it scores the VAE round trip plus latent noise and cannot see
the downscale, the decimation, or the codec. No in-loop metric can: the
candidate frame is captured before it reaches the encoder pipe.
`scripts/video_fidelity_probe.py` covers the rest by decoding the delivered file
after muxing, upscaling it back to the source geometry, and scoring it against
the untouched source frames. It also reports the delivered file's bitrate, so a
fixed-crf bitrate rise cannot read as unchanged quality; because the mux copies
source audio verbatim, that figure is a container bitrate, not a video one. The
probe streams and accumulates the same way the engine does, so its peak memory
does not grow with clip length. It drives the source through the engine's own
`_iter_sampled_frames` at the source geometry rather than repeating the
selection rule: a frame-count check cannot catch a rule that reorders frames
without changing how many, so the rule itself has to be shared.

`load_video_vae_runtime` asserts the default model's latent scaling factor
against `VIDEO_SYNTHID_VAE_SCALING_FACTOR` and warns that no certified profile
exists for any other model. The published `sd-vae-ft-mse` config carries no
`scaling_factor` key, so the value is a `diffusers` class default under an
upper-unbounded pin, and the certified profile is a perturbation-to-signal ratio
rather than a bare `noise_std`. A library bump that moved that default would
otherwise rescale every perturbation with a green suite. The validated factor is
carried on `VideoVaeRuntime` and passed into encode and decode, so the gated
value and the applied value are one measurement rather than three independent
reads. `scripts/video_synthid_sweep.py` loads through the same function: the
harness that produces the certified rows is the last place that should be exempt
from the gate. The full-clip oracle floor is
`noise_std=0.15`: on the public eight-second Veo carrier, `0.10` remained
detected while `0.15` did not.

[`video_visible.py`](../src/remove_ai_watermarks/video_visible.py) implements
the first pixel stages for Sora, Veo, Seedance, Dola, Hailuo, and Kling. The
Sora detector searches a normalized frame with a fully synthetic
mascot-and-text silhouette at several scales. The Veo detector uses separate
synthetic silhouettes for the current four-point diamond and legacy `Veo`
text. Seedance uses a synthetic rounded boxed-`AI` silhouette, while Dola uses
an OpenCV-font `Dola AI` silhouette. Hailuo uses a synthetic waveform,
MINIMAX/Hailuo text, separator, and ring. Kling combines synthetic font
variants with a ring approximation of its swirl; the logo path rescues
wordmarks whose version or font differs, while the edge and white-label gates
reject recurring scene texture. All fixed-mark searches are bounded to the
expected lower-frame area and calibrated independently. A strong relocated Veo
diamond may bypass the known layout anchors, but weak free-corner matches never
enter the temporal arbiter.

The default `auto` route decodes each frame once, shares its grayscale and
normalized representations across all detectors, and caches resized synthetic
template features for the fixed stream geometry. Provider confidence scales
are not comparable: selection applies each provider's temporal arbiter and
takes the first stable result in specificity order (`sora`, `veo`, `seedance`,
`dola`, `hailuo`, `kling`). An explicit mark uses the same scan path with one
candidate. Removal also collects authoritative per-frame timestamps for the
encoder, while identification omits that unused ffprobe pass.

Every per-frame result is untrusted. Each provider's floors, minimum-run policy,
fill padding and mask style are one row in `VISIBLE_MARK_POLICIES`, and every mark
enters the same `stabilize_localizations` entry point; the recurrence
implementation underneath knows nothing about providers. That policy row also
carries `accepts_provenance`, which forces `provenance=False` for Hailuo and Kling
— they have no metadata that could confirm them, and the guarantee used to be
structural (their wrappers took no `provenance` parameter at all). Provenance can relax a low-contrast run only
after recurring visual evidence exists. Sora transition frames follow the
nearest confirmed moving position only with Sora provenance. Seedance, Dola,
Hailuo, and Kling additionally require candidates to remain anchored to the
start of a run. This rejects slowly drifting scene details that still have
high frame-to-frame overlap. Hailuo and Kling do not infer provenance from
technical encoder tags; their confirmed public samples carried no provider
metadata.

Removal runs in a second decode pass. Sora, legacy Veo text, Dola text,
Seedance, Hailuo, and Kling use box masks. Seedance deliberately fills the
complete localized box: a synthetic outline mask passed repeat detection but
left part of the real translucent border visible during visual end-to-end
review. Hailuo expands beyond the matched core to cover both provider icons.
Kling expands around the wordmark or swirl to include the version and optional
`PRO` suffix. The square Veo diamond uses a synthetic shape mask so transparent
corners do not erase unrelated pixels. Every mask goes through the shared
`watermark_registry.fill` backends. ffmpeg encodes the changed video stream and
copies optional audio. The default OpenCV fill is the speed floor; structured
backgrounds need MI-GAN or LaMa for better reconstruction. Invisible video
stages must continue to reuse the image and metadata implementations rather
than copying their logic.

Regression coverage:

- [`test_video.py`](../tests/test_video.py), including a real ffmpeg full-clip
  Sora/OpenCV path that generates a synthetic marked MP4 with AAC audio and
  C2PA provenance, runs both `remove_video_visible` and the composed
  `remove_video_all` API without mocks, and verifies complete removal, frame
  count, frame rate, duration, untouched-region PSNR, paired temporal deltas
  inside the filled region, byte-identical copied audio packets, source stream
  properties, metadata stripping, and a large-`mdat` metadata case that rejects
  any full-source `read_bytes()` call. CI installs ffmpeg explicitly for this
  test so the integration gate cannot silently skip.
- [`test_video_fidelity_probe.py`](../tests/test_video_fidelity_probe.py), which
  builds its clips from solid-color frames whose index is recoverable from the
  pixels, so a decimation that keeps the frame count but shifts the phase scores
  far worse instead of passing unnoticed. It also asserts that the probe binds the
  engine's sampler rather than a copy of it, and it holds the suite's only
  constraint on that sampler's phase. Nothing here needs a model, but it does need
  ffmpeg, so CI runs this file in the same job that installs it.

## Metadata and provenance

### C2PA

[`_internal/c2pa.py`](../src/remove_ai_watermarks/_internal/c2pa.py) reads C2PA with the
official `c2pa-python` reader first. Its byte-level PNG parser remains a fallback
for partial and synthetic fixtures that the official reader rejects.

Structured extraction is limited to the active manifest and the ingredient
manifests reachable from it. Validation is preserved as separate dimensions:
asset binding integrity, claim signature, signer trust, and signer certificate
validity. A matching asset hash and valid claim signature do not make an
untrusted or expired signer trusted, so those stay separate fields and separate
caveats -- but they do not lower confidence. `identify` assigns high confidence
when the asset binding and the claim signature both validate, medium confidence
to a claim that validated nothing (fallback parsing, unknown dimensions), and no
origin verdict to a binding failure, a signature failure, or a revoked signing
credential. The failed claim remains in the marker inventory and keeps the removal
gate fail-safe because a post-signing container edit can invalidate C2PA without
removing a declared pixel watermark.

Revocation reaches the disqualifying branch through `signer_validity`, not through
binding or signature. A check that read only the latter two returned a confident AI
verdict off a credential the issuer had disowned, with an empty `integrity_clashes`
-- quieter than a hash mismatch on the same file. Certificate expiry is deliberately
not disqualifying: an expired certificate does not imply the signed bytes changed,
and a signature actually made outside validity already arrives as
`claimSignature.outsideValidity`.

One rule, one place. `_validation_fields` maps status codes to the four dimensions
and also emits `c2pa_failed_codes`, the subset of failures that actually drove a
dimension to invalid; `c2pa_info_has_invalid_credential` maps those dimensions to
disqualified, and both the ingredient-reachability walk and the report consume that
single path. The displayed reason comes from `c2pa_failed_codes` rather than a
substring rescan of the full code list, so what a caller is shown as the cause
cannot become a looser rule than the verdict it explains.

The structured walk also treats an exact known AI product in a reachable
`claim_generator` as an AI assertion. This covers update chains where the active
manifest names only `c2pa-tool` while a validated ingredient names Dreamina, and
Firefly chains that identify `Adobe_Firefly` without repeating a digital source
type. Unreachable manifests remain excluded.

Reachable `c2pa.soft-binding*` assertions retain their exact `alg` and bounded,
printable block `value` in addition to the normalized vendor label. A block value
without its algorithm is not surfaced because it cannot be attributed to a
decoder or registry entry. `com.microsoft.invismark.1` uses that value as the
pixel-watermark identifier in Microsoft Paint output. An InvisMark soft binding
keeps the invisible-removal gate fail-safe even when the C2PA asset binding has
since become invalid, because metadata damage does not prove the pixel carrier
disappeared. `identify` retains the generic `soft_binding` signal for schema-1
compatibility and adds `invismark` as the stable pixel-removal signal.
Content-fingerprint soft bindings do not trigger pixel regeneration.

The SDK default enables trust verification but supplies no production trust
anchors. Consequently, an installation without an explicitly maintained C2PA
trust bundle reports every otherwise valid signer chain as untrusted --
`signingCredential.trusted` appears in no default installation, from any vendor.
Confidence therefore must not depend on it. It did from 0.27.0 through 0.30.0, which made
the high-confidence branch unreachable in production while a hand-built fixture
stamping `signingCredential.trusted` kept it green in the suite; measured on the
committed provenance fixtures, every C2PA file from OpenAI, Adobe and Black Forest
Labs came back untrusted, and an intact manifest scored the same medium as a
fallback parse that validated nothing. That collapsed the one distinction the
official reader exists to draw.
`tests/test_identify.py::TestIdentifyRealSamples::test_no_committed_fixture_reports_a_trusted_signer`
is the reachability guard: it asserts the fixtures really are untrusted and still
reach high confidence.

Read `untrusted` here as a missing input, not a finding: nothing was checked,
because there was nothing to check against. If a maintained trust bundle is ever
configured, that stops being true and the confidence mapping in
`_c2pa_credential_level` must be re-read, because only then does a failed trust
check mean the signer was rejected. Shipping or fetching an official trust bundle
requires a separate update, provenance, and availability policy; do not silently
convert `signingCredential.untrusted` into trusted based on a vendor-name match.

Vendor attribution comes from the registry in
[`_internal/constants.py`](../src/remove_ai_watermarks/_internal/constants.py). Derived
issuer and platform maps should not be maintained separately.
For an AI C2PA claim, a recognized product in `claim_generator` takes precedence
over the certificate issuer: an application can sign through an upstream model
provider without becoming that provider's product. Only exact product mappings
receive this precedence; an unknown claim generator still falls back to issuer
attribution.

### Metadata scanning and stripping

[`metadata.py`](../src/remove_ai_watermarks/metadata.py) contains the shared
metadata scanners and `remove_ai_metadata`.

Key contracts:

- `scan_head` is the shared cached input for bounded byte scans. It fills the buffer
  in two layers. Structural readers first, one per container, each seeking past the
  pixel payload to reach metadata placed beyond the window: `isobmff.scan_c2pa_region`,
  `_png_late_metadata`, `_riff_late_metadata`. A decoder-backed fallback last,
  `_decoder_visible_text`, for metadata the raw bytes do not spell at all — a
  zlib-compressed PNG `zTXt` packet is readable only after inflation. The layers are
  ordered that way because the structural readers work on files no decoder can open.
- A C2PA reader failure is logged at warning, not debug. It returns the same `None` as
  a file with no manifest, so nothing downstream can distinguish "no credentials" from
  "the credentials could not be read", and the second silently downgrades a verdict.
- JPEG stripping walks metadata segments and preserves the entropy-coded image
  scan.
- Exact app-export JSON disclosures in EXIF `ImageDescription` or `UserComment`
  share one parser. Known AI-product provenance is removable without by itself
  asserting that the pixels were generated; explicit `aigc_info` discriminator
  values and Dreamina `exportType=generation` do assert AI origin. Ordinary
  Aweme, retouch, and `lv` editor exports are preserved.
- ISOBMFF containers use
  [`_internal/isobmff.py`](../src/remove_ai_watermarks/_internal/isobmff.py).
- Native MP4/MOV TC260 `AIGC` entries are read from
  `moov.udta.meta.keys/ilst` and blanked without changing box sizes.
- Native MKV/WebM TC260 `AIGC` entries are read from
  `Segment.Tags.Tag.SimpleTag` and removed through the ffmpeg stream-copy path.
- Native AVI and FLV TC260 entries are read from `LIST/INFO/AIGC` and
  `script.onMetaData.AIGC`, respectively, then removed through ffmpeg stream
  copying.
- Supported non-ISOBMFF audio and video containers use ffmpeg stream copying.
- The low-level remover is fail-safe and can copy an undecodable file through
  unchanged.
- A caller that reports success must use `strip_and_verify`, which scans the
  written output for surviving markers. If the metadata-preserving decoder
  rejected the container but `image_io` can still decode its raster,
  `strip_and_verify` normalizes that raster and scans again. A truly undecodable
  file keeps the surviving-marker result.

Detection and removal must stay in parity. A new marker is incomplete until the
scanner can find it, the remover can reach every supported placement, and a
test proves that it no longer appears in the output.

Regression coverage:

- [`test_metadata.py`](../tests/test_metadata.py)
- [`test_metadata_internals.py`](../tests/test_metadata_internals.py)
- [`test_security_clamp.py`](../tests/test_security_clamp.py)

### Provenance report

[`identify.py`](../src/remove_ai_watermarks/identify.py) separates file-backed
metadata extraction from verdict logic:

- `extract_provenance_evidence` reads the supported metadata signals into
  `ProvenanceEvidence`.
- `evidence_from_metadata_record` normalizes an externally collected nested
  metadata record into the same evidence type without file access. Versioned native
  records accept only source-derived fields; filenames, hashes, timings, errors,
  prior verdicts, and pixel results cannot become evidence. Unknown native schema
  versions and other record types are rejected.
- The vendor registries are matched over `_metadata_region(head)`, not the whole scan
  buffer: they see the container's metadata and not its coded pixels. The tokens are
  raw substrings and the shortest are four and five bytes, so over a megabyte of
  compressed data one turns up by chance, and the entry it hits may assert AI. The
  trim happens only when the container parses -- a malformed or unknown one is left
  whole, because dropping real evidence to avoid a chance match is the wrong trade.
- `identify_from_evidence` evaluates that evidence without reopening the source. Rules
  that decide a verdict live here, not in extraction: extraction has two
  implementations, and a rule in only one of them is a rule the other lacks. The
  SynthID provenance evidence is the worked example — its structured form comes from the manifest,
  and the byte-scan fallback for containers no parser reaches runs in the verdict, so
  both extractors reach the same answer. It did not, and the record path silently
  reported no SynthID for images the file path flagged.
- `identify` preserves the path-based API and adds the optional registered
  visible-mark and open invisible-watermark detectors after extraction.

### SynthID periodic carrier detector (research only)

The local lattice expert is not part of the public package. Runtime code lives
in [`scripts/synthid_runtime/`](../scripts/synthid_runtime/) and the campaign
log is [`synthid-detector-research.md`](synthid-detector-research.md). The
notes below are the calibration history of that research expert.

[`synthid_detector.py`](../scripts/synthid_runtime/synthid_detector.py) is the
runtime form of the frozen 2048x2048 periodic-tile experiment. It folds a
Gaussian high-pass residual modulo 16x16 within a calibrated pixel-count range
and compares the normalized RGB tile with the bundled float64 template
`scripts/synthid_runtime/synthid_periodic_tile_2048_v1.npz`. Exact multiples use the original
reshape-and-mean path; other sizes use count-correct modulo folding,
without resize. Channels are filtered and folded sequentially, and partial edge
blocks are accumulated without a full-frame padding buffer so the 18-megapixel
ceiling does not require multiple three-channel float workspaces. The model hash
is pinned by a test, and the unchanged operating threshold is
`0.17357069773071196` through 10 megapixels.

That fixed threshold is not production-qualified. A later source-fresh
Open Images test-split challenge produced 5 crossings among the 211 images in
its supported geometry. A precision-first `0.28` replacement is frozen as a
research candidate after retaining all 12 available source-diverse native
positives and rejecting those five crossings. It then failed the second
untouched holdout at 1/213, with score `0.322542963`. The replacement is
rejected and fixed-v2 remains an explicit diagnostic only.

The direct API returns `detected`, `indeterminate`, or `unsupported`. Passing
`register_scale=False` selects fixed-v2 from 1,000,000 through 10,000,000
decoded pixels as an explicit diagnostic. Its frozen threshold accepted
none of 5,000 public COCO views balanced across every observed target geometry,
and none of a separate 5,000-view challenge over 256 generated geometries
covering every pair of modulo-16 edge remainders. The original 2048x2048
verdicts and exact scores remain unchanged. Runtime matches do not attribute a
provider. `identify` adds only positive matches as high-confidence
evidence and never turns a local negative into a clean verdict.
The result envelope also names the signal family, provider scope, backend,
whether metadata contributed to the verdict, whether pixels were preserved,
and an explicit reason for unsupported or indeterminate results. These fields
are shared with the official OpenAI verifier's JSON boundary.

The production router selects `synthid-periodic-tile-large-v1` above 10 through 18
megapixels when both dimensions are at least 2,048 pixels. It evaluates all
phase-aligned 2,048-square windows and combines the minimum fixed-template,
Red-minus-Green, and Blue-minus-Yellow spatial correlations with the most
negative Blue-minus-Yellow mid-band correlation. The 3072x5504 portrait
geometry also applies a Green mid-band alias veto. Each component is normalized
to its frozen gate and the public threshold is `1.0`.

All 37 inferred large candidates cross the rule, and all seven metadata-free,
pixel-identical candidates checked by the official Gemini verifier were
detected. The constants rejected all 17,417 exposed external controls. A
post-freeze production-path challenge then rejected all 2,637 decoded-pixel-
unique controls drawn from 2,000 COCO images excluded from the earlier large
color-phase challenge and 637 deduplicated Picsum controls. Four large
geometries and four resampling kernels were balanced; the maximum score was
`0.0592777965`. The source collections were not freshly acquired, so this is a
feature-unseen holdout rather than a fresh-source estimate.

A separate post-freeze Open Images download yielded 41 completed,
decoded-pixel-unique controls after excluding incomplete `.aria2` files and all
prior Open Images hashes. The frozen production path accepted 0/41 and reached
a maximum score of `0.4083013324`. This source-fresh audit is too small to
replace the main holdout interval but checks the acquisition boundary.

The same seven official positives were then re-encoded at unchanged dimensions.
JPEG-95 and JPEG-90 each reduced detection from 7/7 native files to 0/7. The
large operating point is therefore native-pixel and lossless-copy support, not
a codec-robust claim.

Arbitrary geometry is not the same as arbitrary spatial resampling. On a
stratified 80-image fixed-positive sample, one-step resizes at seven nonidentity
scales from 0.5 through 1.5 reduced the unchanged 16x16 detector from 80 accepted
sources to zero at every scale. Restoring the original geometry recovered 58-80
sources, showing that the carrier period scaled with the pixels. A discovery
bank that scaled the template to integer periods 8, 10, 12, 14, 18, 20, and 24
was promising: a threshold frozen above 3,000 resized COCO controls accepted no
view in a 2,000-control final partition and accepted 672 of 800 source-disjoint
provider positives. It also stayed below threshold on the tracked OpenAI and
Adobe controls. The branch remains research-only because noninteger periods at
scales 0.8, 0.9, 1.1, 1.2, and 1.333 collapsed, while separate per-period
thresholds accepted five final controls. The runtime therefore keeps only the
fixed 16-pixel lattice.

A follow-up fractional-period probe sampled the 30 strongest template harmonics
over a continuous 7.5-24.5 period range. The correct period appeared within
0.05 pixels among the top three candidates for 58 of 60 transformed positives.
Testing nine neighboring reconstructed geometries then recovered 44 of 60 at
the native threshold, compared with an upper bound of 48 when the true source
geometry was supplied. The complete search still failed its small frozen
control split: a threshold above 250 development controls accepted two of 150
final controls. Multiplying the canonical score by spectral-period confidence
also accepted two. That baseline was rejected rather than shipped at its
discovery threshold.

The research-only
[`synthid_affine_lattice_probe.py`](../scripts/synthid_affine_lattice_probe.py)
adds split-confirm synchronization. It estimates complex harmonic coherence on
one checkerboard of patches, confirms the selected period on the other, and
reports amplitude-aware confirmation, a locally content-whitened multichannel
code match, and phase-preserving and cyclically registered template scores. A
0.1-pixel grid recovered the expected
12.8, 14.4, 17.6, and 19.2 periods in 20/20 transformed views from five official
positive parents. A provisional conjunction accepted none of 800 corresponding
views from 200 oracle-negative parents, none of 469 OpenAI-labeled rows, and
three of 276 broad non-Google rows whose TC260 or Samsung provenance prevents
treating them as clean oracle negatives. This probe is not runtime routing:
positive diversity is still inadequate, period 8 remains rejected, and the
amplitude stage currently handles isotropic scale at zero rotation only. Full
protocol and caveats are in the detector research plan.

The whitened match uses neighboring noncarrier bins to estimate complex
Green/opponent-color covariance around every selected harmonic. It corrected a
period-24 alias on one native official positive, giving the broad native search
the correct period 16 on all five ordinary-size positives. It was retained only
as a candidate reranker: its positive-to-negative margin was smaller than the
existing spatial-template margin in both the native pilot and a locked 0.8
resize challenge. Two of the three TC260/Samsung-provenance challenge crossings
also matched the full whitened code more strongly than the weakest official
large positive, so the pixel result identifies a compatible signal family, not
a provider.

The same probe also exposes a payload-agnostic H5 confirmation. It estimates a
complex harmonic vector independently on the two checkerboards and measures
their fixed relative-phase inner product; the codeword need not match the known
template. Free cyclic-shift selection was rejected because natural phase aliases
overlapped the controls. The fixed all-harmonic statistic separated five native
official positives from 130 controls with margin `0.1619`, and five 0.8-resized
views from 200 fresh-parent controls with margin `0.1541`; all seven large
official positives also passed the observed gap. It remains research-only
because these tests supplied the period, reuse one negative source family, and
contain only 12 independent positive parents.

The separate research-only
[`synthid_cyclostationary_probe.py`](../scripts/synthid_cyclostationary_probe.py)
measures full complex cross-channel spectral correlation at carrier shifts
against neighboring-shift same-image nulls. It recognizes synthetic amplitude
modulation and rejects both independent noise and a purely additive carrier.
The real native pilot overlapped completely: five official-positive joint
contrasts ranged from `-0.0027` to `0.0337`, while 130 controls reached `0.0593`.
The cyclostationary branch is rejected rather than tuned on new frequency bands.

The affine-lattice research CLI also supports symmetric in-memory JPEG and
top-left crop challenges. At fixed native period 16, codec-specific
phase-preserving template thresholds retained all five repeated positive views
and accepted none of 80 locked controls at JPEG qualities 95, 90, and 80. The
locked margins declined from `0.1054` to `0.0257`; whitened and unknown-codeword
scores were rejected for lossy JPEG. A separate registered crop conjunction
retained all five views and accepted none of 80 controls after both 5% and 10%
top-left crops, with registered-template margins above `0.22`. These are
research pilots, not runtime experts: the period was supplied, positive parents
were reused, and only one codec implementation and one negative family were
tested.

The `register_scale=True` path is the calibrated ordinary-size production
expert. It
keeps the harmonic search, adds three-level scale-space consistency and quadrant
agreement. Its calibrated decision requires the canonical winner to be the
strongest spectral-period candidate, its three-way amplitude to cross the
threshold for that period bin, and both independent high-frequency template
bands to correlate by at least `0.075`. The public registered score is the
minimum normalized margin across those gates, so its decision threshold is
`1.0`.

The earlier single-threshold version produced 68 false positives among 11,273
symmetrically downscaled Spaces controls. A top-candidate plus period-bin version
removed those but still produced 6 false positives among 1,000 source-independent
Open Images controls. The high-band gate removed them and then accepted none of
499 previously untouched Open Images reserve controls. The resulting rule
retained 229 of 355 source-disjoint transformed positives: 0/65 at scale 0.5 and
229/290 from scale 0.65 through 1.5. The explicit period-8 rescue is rejected
because resize lattices fully overlap its positive distribution.

A subsequently frozen 1,000-image Open Images reserve accepted zero in
registered mode. The fixed expert supported only 81 of those geometries and
accepted seven, so fixed and registered results cannot safely be unioned. In
overlapping geometry the registered decision remains the validated path;
fixed-only evidence is a diagnostic rather than a universal-cascade positive.

A later source-fresh 3,000-image Open Images test-split challenge superseded the
registered-v2 precision claim: it accepted 4 of 2,997 supported controls. The
registered-v3 confirmation uses disjoint patch groups for phase, cyclic
amplitude, and held-out-codeword evidence. It rejected all four crossings,
retained 147 of 148 base-accepted source-diverse positives, and retained all 359
base-accepted views in a dense 0.65-1.50 transform matrix over 12 independent
parents. Frozen unchanged, it then accepted 0/2,996 controls from a second
nonoverlapping Open Images cohort and 0/2,366 supported controls from a
3,000-image COCO second-family challenge. Registered-v3 is now the default
ordinary-size positive route. The exact gates, acquisition hashes, and rejected
weak-signal rescue are recorded in the detector research plan.

Every one of those control rates is photographic. Against 223 corpus images
whose C2PA names a non-Google generator, the unchanged entry point accepted 29
(`0.130`, Adobe Firefly `0.241`, highest foreign score `3.01`), all from
registered-v3. The branch reads a lattice shared across generation pipelines,
which is why it must not be reported as a watermark. The public `identify`
path no longer calls this expert.

The branch is also phase-locked to the image origin, exactly like the large
expert. A two-pixel diagonal crop killed all 28 in-geometry foreign detections
and all 8 detected Google provenance positives (maximum remaining scores
`0.779` and `0.311` against the `1.0` threshold); the signal recovers only at
offsets that are multiples of four. Registered-v3 therefore detects the same
crop-destroyed generation-pipeline lattice as `large-v1`, on ordinary sizes.

When registered-v3 abstains, opponent-registered-v1 searches the same frozen
template in Red-minus-Green and Blue-minus-Yellow space. It reranks three
separated scale candidates with fixed RGB and two spatial opponent-color gates,
then accepts only periods 7.9-12.0 on 1-10 megapixel rasters whose sides are at
least 768 pixels. Period-8 candidates additionally require Red-Green and
Blue-Yellow 8-pixel edge ratios no greater than `1.05`; this vetoes the
deterministic JPEG block lattice without using container metadata. The final
rule recovered 49/49 lossless 0.5x-0.75x views from seven official-positive
parents. It rejected all 1,790 measured period-8 codec crossings, while 350
identically resized controls had no base crossing and the earlier period-band
rule accepted 0/1,000 post-freeze Picsum controls. Period 12.8 remains excluded,
and lossy JPEG/WebP views remain inconclusive.

The runtime precedence is registered-v3, the bounded opponent fallback, then
large-v1 above 10 megapixels. Passing `register_scale=False` selects the legacy
fixed diagnostic explicitly. The research bank in
`scripts/synthid_routed_expert_bank.py` keeps all three observations for audits.
Neither runtime nor research routing returns a clean-image verdict.

An unchanged registered challenge from 10 to 18 megapixels retained only 1 of
37 Google candidates and zero of 89 non-Google controls. Twenty-eight positives
cleared amplitude, 21 had matching spatial and spectral periods, but only three
cleared high-band agreement. The 10-megapixel ceiling therefore remains.
Phase-aligned 2,048-square fixed windows did not provide a fallback: median
consensus retained 36 positives and accepted 10 controls, while all-window
consensus retained 36 and accepted eight.
One frozen full-frame pre-resize to eight megapixels also retained only the same
1 of 37 positives and zero controls; just three positives cleared high-band
agreement. Large images therefore cannot be routed through a canonical-size
registered fallback; the later native opponent-color branch is separate.
The remaining phase-aligned window variants closed this branch: a single center
2,048-square registered window retained 1 of 37 positives and zero of 89
controls, while accepting any phase-aligned 2,048-square window retained 2 of
37 and zero controls. The latter control maximum was already 0.968 against the
1.0 decision threshold. Neither the coverage nor the exposed specificity
margin supports a registered-window expert; these results do not apply to the
later native opponent-color branch.

A separate half-scale patch-consensus branch initially looked promising. Its
64-pixel, 90th-percentile patch statistic retained 33 of 49 validation positives
and zero of 166 controls, then 27 of 52 locked-test positives and zero of 140
controls. The frozen broad challenge rejected it: 61 of 10,906 symmetrically
half-scaled Spaces controls and 22 of 1,000 independent Open Images controls
crossed the unchanged threshold. A post-challenge Green negative-phase spread
gate removed those errors but retained only 2 of 234 later Google rows and zero
of 689 later controls. Half-scale support therefore remains explicitly absent.

An independent reproduction of the public `aloshdenny/reverse-SynthID` V4
cross-color codebook did not provide another expert. Its best-of-two-model phase
rule retained 141 of 355 positives but accepted 191 of 499 controls and 386 of
a fresh 1,000-control reserve; AUC was 0.517. The repository's stricter
aspect-ratio routing reduced coverage but preserved the same approximately 38%
positive and control acceptance within supported rows. Its older V3 phase score
had AUC 0.473, and two prespecified amplitude-aware V4 rerankings reached only
0.521 and 0.524 AUC. These external rules are research baselines only. Their useful
contribution is the solid-reference phase-consensus construction, not either
published single-image threshold. Full methodology and the DALL-E reference-set
confound are recorded in the detector research plan.
A later exact repeat on the hash-disjoint v7 challenge confirmed the same
failure: the published cut accepted 77/200 Google and 76/200 OpenAI, so it did
not separate the two target providers.
A direct exact-1024 reproduction of the same V4 artifact confirmed rather than
rescued that verdict: the union of its two published profiles accepted 177 of
443 Google rows and 60 of 162 controls. The artifact's floor left only about two
active bins in the inspected profile/channel, explaining why its score acts
as a weak generic phase-coherence test rather than a specific codebook match.

An exact-1086x1448 OpenAI follow-up also rejected the solid-reference transfer.
A low-texture 256-bin phase codebook achieved native and JPEG-95 test AUCs of
0.512, and independent train halves shared only two exact bins. The paper's
content-dependent alternative was tested with 60 OKLab masking-alignment
features. Its native/JPEG minimum reached test AUC 0.593 and 15/562 positives
versus 1/228 controls at the frozen boundary, but deterministic half-fits shared
no positive decision and had coefficient cosine 0.148. Neither branch is a
runtime OpenAI expert.

An exact-1254 assertion-contrast search selected an sRGB period-8 Blue-channel
direction at 45 of 259 validation asserted rows and zero of 16 same- or
other-provider validation rows. It failed its locked specificity check at 39 of
233 asserted rows and one of 18 controls. Across the complete exact-geometry
audit it accepted 185 of 992 asserted rows, one of 55 same-provider rows without
an assertion, and one of 13 other-provider rows. Raising the threshold above
the wider controls reduced recall to a small minority, so this branch remains
research-only.

Three attempted veto or missing-codeword rescues also failed. The official
InvisMark decoder checkpoint passed its own embedded-watermark self-test at
0.997 confidence and 0.97 bit accuracy, but all OpenAI and provider-control
cohorts clustered around 0.18 confidence with no useful separation. DTCWT
modulus before periodic folding retained 5 of 158 locked-test OpenAI rows and
zero of 135 controls, then zero of 40 fresh OpenAI rows. A 16-codeword whitened
period-8 model retained 5 of 158 and zero controls, then 1 of 40 fresh OpenAI
rows and 1 of 50 fresh controls. They neither explain the known oracle-positive
miss nor justify runtime routing.

An image-level patch-bag model over nine normalized high-pass patches was the
strongest later local OpenAI classifier. It accepted 324/1,364 development-test
OpenAI rows with 4/3,373 heterogeneous controls, then transferred to 120/486
OpenAI rows, 8/504 general controls, and 5/33 same-provider non-assertion rows
in the later temporal challenge. Assertion enrichment over the same-provider
stratum was not independently significant (`p = 0.151`, one-sided exact test).
JPEG-95 reduced the unchanged conjunction to
1/486 positives and zero controls. The native temporal hits were complementary
to the signed period-8 hits, but the same-provider rate and codec collapse
identify another export noiseprint rather than a runtime SynthID expert.

The separate OpenAI period-8 DTCWT component is persistent rather than tied to
one short rollout: exact-generator asserted hits were 3/16, 46/365, and 26/200
from May through July, with 75/581 overall versus 1/52 same-generator rows
without an assertion. Its native/JPEG minimum score reached 0.721 AUC between
those indeterminate strata. Sorting all 64 cyclic correlation scores removed
absolute phase but also removed locked-test discrimination at 0/158 positives
and 0/135 controls. This remains research evidence for a weak signed carrier,
not a runtime OpenAI detector.

A four-family open-proxy challenge also failed to justify a generic neural
watermark expert. A fixed residual frontend and cross-family residual mixing
were trained on three of TrustMark P, VideoSeal, DWT-DCT, and WAM while the
fourth encoder and its test sources remained unseen. Held-out AUCs ranged from
0.437 to 0.562. Equal-power phase-scrambled hard negatives prevented simple
spectral-energy shortcuts, but did not produce architecture transfer. A
separate translation-invariant Gemini bicoherence search selected none of 20
development positives and finished at 0/50 positives, 0/199 controls, and AUC
0.374. Neither branch is part of runtime routing; full split and oracle details
are in the detector research plan.

The separately measured registered geometry range remains 250,000 through
10,000,000 decoded pixels with both sides at least 256 pixels. The default path
and `identify` use registered-v3, then the narrower opponent-registered-v1
fallback in its 1-10 megapixel domain, and large-v1 above 10 megapixels. A
20-image real-corpus drift check was byte-identical after the earlier v2 integration.
The calibration history and caveats are in the linked detector research plan.

### Official OpenAI SynthID verifier

[`openai_provenance.py`](../src/remove_ai_watermarks/openai_provenance.py)
provides the explicit remote production backend exposed as
`verify-openai-synthid`. It is intentionally separate from `identify`, because
one invocation uploads a sanitized raster to OpenAI. The CLI requires
`--acknowledge-upload`, and the optional OpenAI SDK lives in the independent
`verify` extra.

The backend accepts only PNG, JPEG, and WebP. It computes a decoded RGBA pixel
fingerprint, removes AI provenance metadata into a temporary file through
`metadata.strip_and_verify`, recomputes the fingerprint, and aborts before any
request if metadata survived, the format changed, the pixels changed, or the
sanitized file exceeds the endpoint's 50 MiB limit. It then sends exactly one
multipart file to `content_provenance_checks.create` and parses exactly one
`type == "synthid"` result. The independent C2PA entry is never returned or
used as fallback evidence. Missing, duplicate, or unknown SynthID outcomes are
errors rather than negative detections.

The default SDK client has a 120-second request timeout and zero automatic
retries. One upload acknowledgement therefore authorizes at most one media
transmission rather than inheriting the SDK's retry default. Request logs keep
the endpoint, temporary basename, media type, byte count, timeout, retry policy,
duration, HTTP status, error code, and request id when available, but omit the
source path, image bytes, credentials, and decoded-pixel fingerprint.

`OpenAIProvenanceError` preserves the status, API error code, request id,
`Retry-After` value, and a transient-only `retryable` flag. The library does not
automatically act on that flag: an explicit caller invocation is required for
every additional upload. Transport and schema failures remain errors rather
than becoming `not_detected` or a local detector result.

The result remains provider-scoped and positive-evidence-only. `not_detected`
does not mean human-created, and the official endpoint's published prohibition
on repeated reverse-engineering or evasion queries prevents using this backend
as an adaptive training or removal oracle.

### Portable metadata record

[`metadata_record.py`](../src/remove_ai_watermarks/metadata_record.py) produces the
record `evidence_from_metadata_record` consumes, so collection and verdict can run
on different machines. Its contract is equality with the file path, and the three
defects found while establishing that equality are the reason each rule exists:

- It walks the file's RAW head, never the `scan_head` buffer. That buffer is the
  head concatenated with late metadata payloads, so a structural walk runs off the
  end of the real head and parses appended bytes as chunks, inflating the record and
  creating false signals.
- Samsung Galaxy AI splits its evidence: the `PhotoEditor_Re_Edit_Data` marker sits
  in the post-EOI trailer while the `genAIType` value it is gated on can sit inside
  the entropy-coded scan. A marked file therefore keeps the whole tail window, not
  just the trailer.
- PIL's info keys are emitted in the file path's own candidate order
  (`Software`, `Source`, `Title`, `Description`, then EXIF). `generator_from_metadata`
  returns the FIRST candidate carrying a known token, so preserving candidate order
  is part of verdict equivalence.

The transport is independently versioned as `provenance_metadata` schema 1. Native
records require the exact integer schema version and a `complete` status. Source
read failures are explicit error records and cannot be judged. WebP walks the full
declared RIFF container by seeking over `VP8`, `VP8L`, `ALPH`, and `ANMF`, so late
XMP/C2PA remains visible without shipping coded frames or parsing appended trailer
bytes as chunks.

Pixel forensics are deliberately absent: the provenance path does not read them.
Verdict equivalence is checked over tracked fixtures and a separate local evaluation
corpus.

### Broad forensic metadata

[`forensic_metadata.py`](../src/remove_ai_watermarks/forensic_metadata.py) owns the
wide metadata-only inspection record: hashes and timestamps, full EXIF/IPTC, C2PA,
container inventories, bounded binary metadata, and embedded-thumbnail forensics.
It is a separate `forensic_metadata` record type and is deliberately rejected by the
provenance normalizer. Integration code publishes the strict
`ProvenanceReport.to_dict()` alongside it rather than letting operational fields or
derived results influence detection.

### Pixel forensics

[`pixel_evidence.py`](../src/remove_ai_watermarks/pixel_evidence.py) measures six
families of scale-robust pixel statistics (block-DCT histograms and Benford
deviation, FFT band energies and CFA peaks, high-pass residual, error level,
gradient, color) in a single decode, sharing the intermediate maps between them.

It remains independent of verdict and removal. `PixelEvidence.to_dict()` is the
versioned service boundary: it omits the local path, exposes complete/partial/error
status, keeps exception details in logs, and can include opt-in per-stage timings.

The provenance metadata collector, broad forensic collector, provenance report,
and pixel report all accept an explicit output `schema_version`. Package releases
may add an output schema while retaining older serializers, so a rolling consumer
can keep requesting the version it already understands. Within one schema, changes
are additive; existing fields, types, meanings, signal names, and watermark labels
remain stable. Unsupported selections raise before a different shape is returned.

`artifacts=True` additionally returns the spatial layer: a perceptual hash, a 128px
JPEG thumbnail, and coarse ELA, residual and phase maps. Those identify the source
image rather than describe it, which is why they are opt-in and a separate field: a
caller storing them is handling image content, not statistics about it.

The DWT-DCT detector and the visible-mark stage share a single decode of the
source, held by
a per-call `_SharedDecode`. It exposes two accessors because the two arms need
opposite failure handling: the visible arm swallows a decode failure (no cv2, no
visible marks, metadata verdict untouched), while the invisible arm re-raises it
so `has_invisible_target` reaches its documented fail-safe `True`. Swallowing it
there would skip a diffusion scrub on a file that used to get one. TrustMark
deliberately keeps its own Pillow decode: cv2 and Pillow disagree on EXIF
orientation and on 16-bit PNG, so substituting one for the other is not
behavior-preserving.

The metadata probes (`aigc_label`, `xai_signature`, `iptc_ai_system`,
`huggingface_job`, `samsung_genai`) and `extract_c2pa_info` are memoized on
`(path, mtime_ns, size)`. One `identify` reaches each of them twice, and each
re-walks the container or re-runs the manifest reader. Size is in the key as well
as mtime because this package rewrites files in place, and an in-place rewrite
can land inside one mtime tick. The C2PA key additionally carries the
reader-availability flag: with the official reader the manifest comes back as a
store and without it from the PNG chunk parser, so the answer depends on process
state and not on the file alone.

Native-container TC260 readers (`isobmff`, `ebml`, `riff`, `flv`) all run, in that
order, on every file. Each self-gates on its own magic bytes after a 4-12 byte
read, so gating the AVI and FLV ones on the file extension as well was redundant
and made a correctly formatted container served under the wrong name invisible.
WebP is the one input class the now-unconditional RIFF reader newly touches; its
`AVI ` form check is what rejects it.

`api._SourceEvidence` extracts that metadata once per `remove_all` call and serves
both the visible pass (which vendor is confirmed) and the scrub gate (is there an
invisible target). It is per-call, never module-level: `batch` may write its
output over its input, and a holder that outlived one call would answer the scrub
gate from pre-write evidence. In `batch` each stage builds its own holder after
any write that precedes it, for the same reason. Every accessor fails safe the way
the function it replaces does — no provenance means no relaxation, and an unknown
invisible target means scrub rather than skip.

The `detect` extra composes the shared `pixels` runtime with PyWavelets. Its
in-tree [`dwt_dct.py`](../src/remove_ai_watermarks/dwt_dct.py) decoder reproduces
the upstream algorithm's output bit for bit without installing Torch or
non-headless OpenCV; the block scan is vectorized rather than transcribed, so
the file no longer reads line by line against `maxDct.py`. The upstream MIT
notice ships inside the wheel under `licenses/`.

`is_ai_generated` is `True` or `None`; absence of evidence is not reported as a
human-made verdict. `ai_source_kind` distinguishes fully generated content from
AI-enhanced composites when the source metadata provides that distinction.

TrustMark is reported as a watermark signal but does not by itself assert AI
origin because it can also protect human-authored content. The decoder requests
binary mode, matching Adobe's Durable Content Credentials example, then requires
the same payload and schema after a quality-95 JPEG round-trip. Only Variant P
schemas 0-2 count as positives. Schema 3 is below the precision threshold: all
38 measured historical false-positive candidates used it, and six retained the
same false payload after re-encoding. The official Adobe Variant P schema-1
fixture in `data/fixtures/provenance/` is the positive regression control.

#### Why the DWT-DCT decoder looks the way it does

Two things in `dwt_dct.py` are load-bearing and neither is obvious from the code.

`_approximation` calls `pywt.dwt` twice instead of `pywt.dwt2`, and transposes
before each pass. A factorial ablation over both axes separates the two:
skipping the three detail bands `dwt2` computes and this code discards is worth
**4%**, while the transposes are worth **2.8x**, because pywt walks the axis it
transforms and on axis 0 of a C-contiguous plane that is a column walk. The
arithmetic saving is the intuitive explanation and it is the small term; four
independent profiles named it as the mechanism before the ablation contradicted
them.

Bit-identity is a hard requirement, not a preference. For uint8 input the exact
Haar LL value is a multiple of 0.5 and the bit test is `peak % 36 > 18.0`, a
threshold sitting exactly on a representable value that ~1 block in 72 lands on,
so a 1-ulp difference deterministically flips real bits. That is why the ~16x
available from a hand-rolled numpy Haar is unreachable rather than merely
untaken: pywt's C convolution contracts into an FMA that numpy has no ufunc for,
and `np.longdouble` is 64-bit on arm64 macOS.

Each pass is one flat `pywt.downcoef` call over a raveled strip rather than
`pywt.dwt(..., axis=1)[0]`, and the plane is processed in strips of `_STRIP`
block-rows so no full-plane float64 intermediate is ever materialized. The strip
height is not a tuned value: 8 through 64 all scored inside each other's noise
with unstable ordering, and only "strips at all" versus whole-plane matters.

`_approximation`'s even-last-axis check is load-bearing, not defensive. Haar's
filter is length 2, so an even row length keeps every pair inside its own row;
on an odd width the pairs walk across row boundaries and the reshape still
succeeds whenever the total is even, which would be wrong bits with no
exception. Both call sites are even by construction today, and
`TestRaveledHaarPass` pins both halves -- the `downcoef`/`dwt` equivalence,
which a pywt upgrade could take away, and the raise on an odd width.

Measured on a 1536x2816 image, all arms timed in one process: the decoder went
0.112 s (the original per-block Python loop) to 0.011 s vectorized to 0.007 s
with strips, and a warm `identify()` 1.757 s to 1.365 s on the first step and a
further 0.4% on the second. That last figure is the point at which this target
is finished: the decoder is now under 2% of `identify()`, so speed here has
stopped buying anything. What the strips buy is peak RSS in the stage, 111 MB to
21 MB on a 4.3 MP image, which is what matters on the memory-limited Space.

Both steps were verified by recording decoder output and detector verdict over
200 sampled `data/` images plus two synthesized carriers before and after: the
record is byte-identical, as are seven degenerate shapes (`1x65536` through
`8x8192`, plus an odd width) that clear the caller's area check.

Regression coverage:

- [`test_identify.py`](../tests/test_identify.py)
- [`test_trustmark_detector.py`](../tests/test_trustmark_detector.py)
- [`test_invisible_watermark.py`](../tests/test_invisible_watermark.py) --
  note `test_in_tree_decoder_matches_upstream` is the parity guard against
  upstream's own decoder, and the whole module is `skipif(not is_available())`.
  A green run without the `detect` extra installed has not checked parity at
  all, so a decoder change still owes the before/after verdict record.

## Visible mark removal

### Registry and decision flow

[`watermark_registry.py`](../src/remove_ai_watermarks/watermark_registry.py) is
the only visible-mark registry. `mark_keys()` supplies the CLI choices, so the
CLI must not maintain a separate mark list.

Automatic removal has three distinct stages:

1. Perception: each registered detector produces strict and relaxed candidates.
2. Decision: the pure `decide` arbiter applies sensitivity and corroborating
   provenance.
3. Action: each selected mark is localized to a mask and passed to the shared
   fill function.

`sensitivity="strict"` never relaxes a detector. `sensitivity="auto"` can relax
one only when metadata or a sufficiently strong same-product sibling confirms
that product. The removed blanket `assume_ai` mode is rejected explicitly.

The Jimeng pill has an additional decision gate because its visual detector is
weaker than the other registered marks. Keep that policy in the registry, not
inside unrelated detector engines.

Everything about a mark is one registry row: its product family, its label regime,
the platform sentence `identify` reports for it, and the metadata signals that
confirm its vendor. `identify._VISIBLE_MARK_PLATFORM` and the signal mapping in
`api.visible_provenance` are derived from those rows rather than hand-maintained
beside them, so registering a mark is one edit. Two marks carry no platform of
their own: the Gemini sparkle has its own higher-confidence path, and the
capture-less pill is too weak to attribute.

The set of marks that veto the pill is DERIVED from the registry rows: every mark
under the same label regime (`tc260`) belonging to a different product. It used to
be a hand-written list of keys, and that list drifted -- LibLibAI was registered
alongside RunningHub and Baidu, both of which were added to it, and LibLibAI was
not, so a confident LibLibAI detection did not suppress the pill the way its two
siblings did. Marks outside the TC260 regime (Gemini, Samsung) are deliberately
not vetoers: neither can put `jimeng` into `provenance`, so neither can enable the
arm it would be vetoing.

A TC260 label relaxes the vendor its `ContentProducer` names, resolved through
`KnownMark.tc260_producer_codes`. The label itself is vendor-agnostic, so this used to
relax ByteDance's two products on every China-AIGC image -- which both risked a
false fill on an image carrying some other vendor's mark and denied that vendor's
own mark the relaxed gate its `provenance_ncc_factor` was calibrated for. An
absent or unmapped producer still falls back to the ByteDance pair.

`remove_auto_marks` removes every selected mark, not only the strongest one.
This matters for images that carry marks in more than one corner.

Regression coverage:

- [`test_watermark_registry.py`](../tests/test_watermark_registry.py)
- [`test_api.py`](../tests/test_api.py)

### Gemini sparkle

[`gemini_engine.py`](../src/remove_ai_watermarks/gemini_engine.py) uses a
multi-scale shape search and a false-positive gate. Its captured sparkle assets
serve detection and mask geometry only. Pixel recovery is performed by the
shared fill backend.

`detect_sparkle_confidence` uses a process-wide shared engine because its loaded
assets and template ladder are immutable.

Regression coverage:

- [`test_gemini_engine.py`](../tests/test_gemini_engine.py)

### Text mark engines

[`_text_mark_engine.py`](../src/remove_ai_watermarks/_text_mark_engine.py)
provides common localization, detection front ends, template caching, rival
comparison, and footprint construction.

Each vendor module supplies a `TextMarkConfig` and only the behavior that cannot
be represented by the shared base:

- [`doubao_engine.py`](../src/remove_ai_watermarks/doubao_engine.py)
- [`jimeng_engine.py`](../src/remove_ai_watermarks/jimeng_engine.py)
- [`qwen_engine.py`](../src/remove_ai_watermarks/qwen_engine.py)
- [`kling_engine.py`](../src/remove_ai_watermarks/kling_engine.py)
- [`yuanbao_engine.py`](../src/remove_ai_watermarks/yuanbao_engine.py)
- [`samsung_engine.py`](../src/remove_ai_watermarks/samsung_engine.py)
- [`runninghub_engine.py`](../src/remove_ai_watermarks/runninghub_engine.py)
- [`baidu_engine.py`](../src/remove_ai_watermarks/baidu_engine.py)
- [`liblib_engine.py`](../src/remove_ai_watermarks/liblib_engine.py)

The detector and removal mask must use compatible geometry. A detector that
fires while producing an empty or misplaced mask is a removal failure even if
the detection test passes. That parity is now structural rather than a
convention: the three continuous front ends share one ladder sweep
(`_ladder_best`), and the winning box travels to the mask on
`TextMarkDetection.match_box` instead of being swept a second time.

Detection is split into a trust-level-blind `_scan` and a `_verdict` that applies
the threshold. `detect_both` returns the strict and relaxed verdicts from one
scan, which is what the arbiter's perception stage calls. A per-mark demotion
belongs in the `_post_gate` hook, never in a `detect` override: an override is
invisible to the single-pass path, and the RunningHub and Yuanbao anchor gates
were briefly skipped there for exactly that reason.

A mark whose removable footprint differs from what the detector localizes
overrides `_footprint_rect` (which policy) and `_extend_match_box` (how far the
box grows), not the whole `footprint_mask`. Baidu extends right to the corner tag
and LibLibAI extends left to the triangle logo; both inherit every guard around
that arithmetic.

Yuanbao uses the polarity-independent `contrast` front end because its standard
two-line mark can be light on dark scenes or dark on light scenes. Its detector
and footprint both use the same best-match box. The separate one-line overlay
variant is not covered.

The capture-less Jimeng pill lives in
[`pill_engine.py`](../src/remove_ai_watermarks/pill_engine.py). It uses a
synthetic silhouette for detection and a fixed top-left footprint.

Each engine has a corresponding test module under [`tests/`](../tests/).
Shared behavior is covered by:

- [`test_text_mark_engine.py`](../tests/test_text_mark_engine.py)
- [`test_text_mark_faint_mask.py`](../tests/test_text_mark_faint_mask.py)
- [`test_text_mark_memory.py`](../tests/test_text_mark_memory.py)

### Fill backends and region erasing

[`region_eraser.py`](../src/remove_ai_watermarks/region_eraser.py) implements the
same backends used by visible removal and the user-directed `erase` command:

- `cv2`
- `migan`
- `lama`

`watermark_registry.resolve_backend` selects LaMa first, then MI-GAN, then
OpenCV for `auto`. A memory-constrained caller should explicitly select MI-GAN
or OpenCV instead of relying on `auto`.

MI-GAN and LaMa crop around the mask before model inference and paste back only
masked pixels. Their model sessions are loaded lazily. MI-GAN uses the inverse
mask polarity expected by its ONNX model.

Regression coverage:

- [`test_region_eraser.py`](../tests/test_region_eraser.py)
- [`test_inpaint_fallback.py`](../tests/test_inpaint_fallback.py)

## Invisible watermark regeneration

### Profiles and strength

[`_internal/watermark_profiles.py`](../src/remove_ai_watermarks/_internal/watermark_profiles.py)
is the source of truth for:

- profile names and their underscore spellings;
- the fixed seed;
- the SDXL global-stage checkpoint id (`SDXL_MODEL_ID`) and the Canny ControlNet id;
- strength resolution for both profiles.

The current profiles are `qwen-zimage` (the default) and `sdxl-zimage`, and both
are CUDA-only. `controlnet`, `sdxl`, `qwen` and `default` were removed rather than
kept as a CPU path, and are rejected rather than aliased onward. There is no
content-dependent automatic router.

`qwen-zimage` normally resolves global denoise from image area for unknown content.
Measured provider cohorts bypass that curve with flat operating points. The values,
measurement derivations, and corpus limits are canonical in
[`known-limitations.md`](known-limitations.md#strength-is-content-and-seed-dependent).

For serverless cold starts, `InvisibleEngine.preload(global_only=True)` loads the
mandatory global stage and YuNet while leaving the optional Z-Image and SAM face
stack lazy until a face is detected. The default `preload()` still loads every
stage.

**What is deliberately not a parameter.** Model id, step count and CFG are fixed
by the profile, so none of them appears in `WatermarkRemover.__init__`,
`remove_watermark`, `InvisibleEngine`, or the CLI. They used to be accepted and
then rejected several frames down; a signature that refuses the argument outright
fails where the caller can act on it, and stops a wrapper from threading a value
that would silently do nothing. The step count and CFG live with the stage that
runs them (`GLOBAL_STEPS`, `FACE_STEPS`, `GLOBAL_CFG`, `FACE_CFG` in
`qwen_zimage_pipeline.py`). The dtype is likewise profile-owned: see "Face-stage
dtype" for what an override cost the last time one existed.

`device` is the exception and remains a library parameter: `None` or `"auto"`
detect, `"cuda"` pins without detecting (which is what a container that knows its
hardware wants), and any other value raises at construction. It is not a CLI
option, because the only useful value a user could type is the one detection
already returns.

[`invisible_engine.py`](../src/remove_ai_watermarks/invisible_engine.py) handles
image sizing, postprocessing, and the public engine
interface. It delegates model execution to
[`_internal/watermark_remover.py`](../src/remove_ai_watermarks/_internal/watermark_remover.py).

`get_device` in that module answers only `cuda` or `cpu`. An `mps` or `xpu` answer
would travel one frame to the same CUDA-only refusal while costing a device probe,
and reporting it implied an Apple-silicon or Intel-GPU path that does not exist.
The refusal names the *resolved* device, so `device=None` on a CUDA-less host says
`'cpu'` rather than `'None'`.

The Python engine and the CLI now resolve the same defaults: the CLI forwards an
unset `--adaptive-polish` and `--seed` as `None` and the engine applies the
profile's answer, so a library caller and a CLI caller on one profile produce the
same pixels. They diverged before, in opposite directions, for exactly this knob.

The global and face prompts are calibrated model inputs, and the Canny edge map
uses fixed thresholds of `_CANNY_LOW = 13` / `_CANNY_HIGH = 64`
(`qwen_zimage_pipeline.py`). Treat those values as behavioral compatibility
contracts: a refactor must preserve them, and any deliberate change requires
image-quality evaluation rather than only a unit-test pass. The prompt and
edge-map regression guards are
`test_qwen_zimage_pipeline.py::test_global_kwargs_use_lightning_and_diffsynth_controlnet_shape`,
`::test_face_kwargs_use_project_zimage_settings` and
`::test_canny_control_image_is_three_channel_and_detects_an_edge`.

Regression coverage:

- [`test_invisible_engine.py`](../tests/test_invisible_engine.py)
- [`test_qwen_zimage_pipeline.py`](../tests/test_qwen_zimage_pipeline.py)
- [`test_platform.py`](../tests/test_platform.py)

### CPU offload

CPU offload is enabled only when requested. Nothing calls Diffusers'
`enable_model_cpu_offload` any more -- that belonged to the deleted single-stage
profiles. `--cpu-offload` now forces **both** stacks of the two-stage profiles out
of automatic device residency.

Residency is otherwise chosen from the card's total VRAM, once per stack:
`resolve_global_model_residency` gates the mandatory Qwen stack at
`RESIDENT_GLOBAL_MODEL_MIN_VRAM_GIB` and `resolve_face_model_residency` gates the
optional Z-Image stack at `RESIDENT_FACE_MODEL_MIN_VRAM_GIB`.

Below the global floor, `_qwen_vram_config` streams the stack from disk, which is
what makes a 20B model runnable on a consumer card. At or above it, streaming is
pure waste and the weights stay on the GPU. The difference is not marginal:
DiffSynth offloads by dropping the weights to the meta device and re-reading every
parameter through its `DiskMap` on the next onload, and the pipeline moves between
text encoder, transformer and VAE on each pass. Measured on an H100 (80 GiB) in
August 2026, a warm global pass took 37.3 s at 0.8 GiB resident with the streaming
config, against 2.2 s at 28.7 GiB with the stack resident; both stacks resident
peaked at 48.0 GiB. Faster storage cannot close that gap, because the cost is the
reload itself rather than the read.

The resident config deliberately passes no `"disk"` value anywhere. DiffSynth latches
`disk_offload` once, from `offload_dtype`, so leaving the sentinel in place while
pointing every device at CUDA would keep the meta-drop and re-read.

Regression coverage:

- [`test_cpu_offload.py`](../tests/test_cpu_offload.py)
- [`test_qwen_zimage_pipeline.py`](../tests/test_qwen_zimage_pipeline.py)

### Qwen plus Z-Image

[`_internal/qwen_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py)
implements the fixed CUDA-only two-stage profile:

1. Qwen Image with Canny conditioning regenerates the frame.
2. YuNet locates faces, SAM builds masks, and Z-Image regenerates the selected
   face regions.

The profile rejects a custom model identifier. Its global and face model stack
is fixed by the implementation. When tiling is enabled, only the global stage
is tiled; the face stage runs once after the tiles are blended.

#### How the face stage actually composites

The mechanism is easy to misread from the parameter names, so state it plainly.
`_run_faces` crops the expanded box from the **original** image, resizes it toward the
768 px face guide, runs Z-Image over **the entire crop**, resizes back, and only then
merges through `composite_face`, which cross-fades on a Gaussian-blurred SAM mask with
`feather=10`. The base it merges into is the global Qwen result.

Two consequences follow, and both matter when tuning:

- **Everything inside the crop is regenerated, including the pixels the mask later
  discards.** The generation is therefore conditioned on a fully noised neighbourhood,
  not on an intact one. An alternative design passes the mask into the sampler as a
  latent noise mask, so only masked pixels are ever denoised and the edge transition
  happens inside the generation rather than as a post-hoc blend. That approach has
  never been tried in this runtime and is an open lever, particularly since the face
  stage is the largest measured quality contributor: removing it costs 3.5 dB inside
  the face boxes on one fixture and 6.1 dB on another.
- **`FACE_DENOISE_SCALE = 0.5` is best understood as compensation for the above.**
  Regenerating a whole crop and blending is a stronger operation than denoising only
  inside a mask, so the halved strength brings the visible result back into range. Read
  it as coupled to the compositing design rather than as an independently calibrated
  constant: changing the compositing without revisiting the scale would change output
  strength by roughly a factor of two.

The maintained implementation preserves the previously oracle-tested strength,
conditioning, crop, and sampler parameters as compatibility contracts. Its Python
orchestration, YuNet integration, SAM selection, masks, sizing helpers, and pixel
compositing are implemented for this runtime. Changing a calibrated model input
requires the same provider-oracle and identity evaluation as a model change.

#### Verified text restoration

[`_internal/text_restoration.py`](../src/remove_ai_watermarks/_internal/text_restoration.py)
implements the opt-in `vae-glyphs` stage. A versioned manifest carries manually
reviewed strings and source-space line boxes in schema 1, or verified source-space
geometry alone in schema 2, plus a SHA-256 over decoded RGB width, height, and pixels.
Validation happens before model loading. The library never treats OCR confidence as
verification, and geometry-only operators do not need to invent text or script fields.

When enabled, `QwenZImagePipeline` reconstructs the source once through its already
loaded Qwen VAE, runs the ordinary global and face stages, blends 15% of the VAE
reconstruction into that clean result, and calls the shared restoration compositor.
The compositor derives binary source and candidate silhouettes, groups nearby lines,
uses LaMa for the initial and residual-glyph erase passes, paints fresh silhouette
edges, then copies the Qwen-VAE core with a 0.5-pixel feather. The evaluation script
imports these same mask and compositing helpers so the two implementations cannot
silently drift. Silhouette crops start 12% of line height beyond each horizontal
side, then expand each side independently while a foreground component anchored
inside the detector box still reaches that boundary, up to one line height. They
extend 8% above and 25% below. The anchored-component gate covers clipped leading
flourishes, trailing punctuation, icons, and descenders without walking into
disconnected decoration or background texture.

The stage is deliberately narrower than the engine: it rejects `sdxl-zimage`, tiles,
resolution caps, humanize, unsharp, and adaptive polish. Those combinations change
geometry or final pixels after the verified layer and have no measured oracle result.
It remains opt-in because annotations are manual and provider verdicts apply only to
the exact tested output hashes, not to the mechanism in general.

A matched stage-isolation check on the 18-face Gemini portrait grid confirms the
division of responsibility. The visible-cleaned, metadata-stripped control and the
Z-Image face-only output were both SynthID-positive; Qwen global-only and the full
Qwen-then-Z-Image output were both clean. The face stage raised identity cosine from
0.589 to 0.852 and reduced face LPIPS from 0.217 to 0.050 without reintroducing a
detectable whole-image signal. Thus Z-Image is a masked fidelity repair stage here,
not the watermark-removal stage. Exact hashes, metrics, strengths, and the one-fixture,
one-seed caveat are recorded in
[`data/evaluations/fidelity/face-stage-isolation-2026-08-13.csv`](../data/evaluations/fidelity/face-stage-isolation-2026-08-13.csv).

The public Synthid-Bypass v2 graph was subsequently audited at upstream commit
`3007d035`. Its saved-output path confirms the same division: Qwen-Image-2512
Lightning plus Canny is global, and Z-Image Turbo exists only inside the masked
face detailer. The connected face path is YOLOv8-face plus SAM; the MediaPipe nodes
described by the upstream README and the 1.2-megapixel normalization node do not
reach `SaveImage`. Upstream also applies its adaptive face strength directly,
whereas this implementation multiplies it by `FACE_DENOISE_SCALE = 0.5`.

A close reproduction on the same portrait fixture kept the control positive and
made both Qwen global-only and full Qwen-then-Z-Image outputs clean. Applying the
upstream-strength face pass raised identity from 0.589 to 0.783 and reduced face
LPIPS from 0.217 to 0.083, but remained worse than this profile's 0.852 and 0.050.
The published upstream pair 12 was also independently checked positive before and
clean after, with 0.975 identity. Exact workflow provenance, hashes, metrics,
oracle outcomes, and the DiffSynth/GGUF, scheduler, detector, and seed caveats are
recorded in
[`data/evaluations/fidelity/upstream-v2-reproduction-2026-08-13.csv`](../data/evaluations/fidelity/upstream-v2-reproduction-2026-08-13.csv).

### SDXL plus Z-Image

[`_internal/sdxl_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/sdxl_zimage_pipeline.py)
runs the same two-stage recipe on an SDXL global pass. `SdxlZImagePipeline` subclasses
`QwenZImagePipeline` and overrides only `_run_global` and `preload`, so the face stage
is inherited rather than copied and cannot drift between the profiles; a test asserts
the shared methods are the same objects.

Four things are architecture-bound and swap with the model: the ControlNet
(`xinsir/controlnet-canny-sdxl-1.0`), the four-step distillation LoRA
(`ByteDance/SDXL-Lightning` at its documented strength 1.0, not the reference graph's
0.8, which belongs to a different LoRA), the sampler (Euler with trailing spacing, no
AuraFlow shift), and the latent grid (8 px against Qwen's 16).

**Strength is architecture-bound too, and that is the easy mistake.** An SDXL global
pass leaves SynthID at the strength Qwen needs: verified through the Gemini app on a
native 2816x1536 original, 0.154 is FOUND while 0.20, 0.25 and 0.30 are clean. So this
profile takes a vendor policy (`SDXL_ZIMAGE_OPENAI_STRENGTH` 0.15,
`SDXL_ZIMAGE_GEMINI_STRENGTH` 0.25, unknown following Gemini) rather than
`resolution_adaptive_denoise`. Flat values are what was measured; no size dependence
has been established for this stage, so none is asserted.

`requested_steps` exists because the two runtimes truncate differently. DiffSynth sets
`sigma_start = denoising_strength` and runs every requested step across the shortened
sigma range; Diffusers img2img truncates the step *count*
(`init_timestep = int(steps * strength)`), so asking it for four steps at 0.15 executes
**zero** and returns a bare VAE round-trip.

**The face stage keeps its own dtype, and 0.23.0 shipped without that.** The remover
gives this profile `torch.float16`, because SDXL ships fp16 weights and an fp16-safe
VAE. That dtype reached the inherited `_load_zimage`, while `_zimage_vram_config()`
hardcodes bfloat16 for its offload, onload and computation dtypes -- so the Z-Image
modules were built bf16 and handed fp16 latents, and every image containing a face
died in the VAE with `Input type (c10::Half) and bias type (c10::BFloat16) should be
the same`. Every face-stage loader now reads `_face_stage_dtype()`, which returns the
computation dtype of the VRAM config it is paired with, so the two cannot drift again.

Two things hid this. Zero-face inputs never enter `_run_faces`, so the profile looked
healthy on exactly the images used to time it; and the profile's tests deliberately
avoid model downloads, so nothing exercised the loader. The lesson is narrower than
"add a GPU test": inheriting a stage means inheriting its *invariants*, and this one
was a dtype the subclass silently changed out from under it.

Note what the seam is, because it decides where the fix belongs.
`SdxlZImagePipeline._load_sdxl` hardcodes fp16 for its own ControlNet, VAE and
pipeline, so `self.torch_dtype` was never actually the global stage's dtype on this
profile -- its only remaining readers were face-stage code. SAM was the second one:
it never crashed, because it casts its own inputs and leaves through `.float()`, but it
was reading the same wrong field and would have re-landed the bug for the next profile
with a different global dtype. It is routed through the same accessor, which for
`qwen-zimage` is the bfloat16 it already used.

The guard is `test_face_stage_loads_in_its_own_dtype_when_the_global_stage_differs`.
It asserts the dtype the Z-Image and SAM loaders actually receive, not the accessor
against the config it is derived from -- that comparison would restate the
implementation and pass for any consistently wrong value. Both assertions were
mutation-tested against the pre-fix line. For `qwen-zimage` the whole change is a
strict no-op: the remover already handed it bfloat16, the same value
`_face_stage_dtype()` returns.

This profile is not deployed. Before it could be, it needs the other three Gemini
originals, OpenAI re-verified at 0.15, a flat-graphic content class, and a low
resolution case -- every verdict so far comes from one fixture and one seed.

### Measured provider boundaries for qwen-zimage

Both ends of the shipped curve now have oracle verdicts, and the shipped curve clears
everything it has been tested at:

| oracle | fixture size | detected at | clean from |
|---|---|---|---|
| openai.com/verify | 1.57 MP | 0.06 | 0.08 |
| Gemini app | 4.33 MP | 0.08 | 0.10 |
| Gemini app | 0.57 MP | -- | 0.0896 (the curve's own value) |
| Gemini app | 1.40 MP | -- | 0.1066 (the curve's own value) |

Read the last two rows before concluding the curve's low end is under-driven. Against
the 4.33 MP Gemini boundary the sub-1 MP rungs of 0.084-0.098 look short, but at those
sizes the curve's own values verify clean, which is what a resolution-scaled
requirement would predict. There is no measured size at which the shipped curve fails,
so it is left alone.

### Static prompt embeddings

Both stages prompt with module constants, and at CFG 1.0 DiffSynth's
`PipelineUnitRunner` reuses the positive embedding for the negative side instead of
encoding it. So exactly one embedding per stage is ever computed, from text that
cannot vary at runtime, which makes it cacheable across containers rather than only
within one pipeline.

`_cache_static_prompt_embeddings` therefore persists what the text encoder produced
under `_model_cache_dir()/prompt-embeddings`, keyed by cache version, model id,
pipeline output params, and the exact prompt string. Once that file exists,
`_load_qwen` and `_load_zimage` drop the text-encoder `ModelConfig` from the model
stack entirely and serve the stored tensors instead. Measured on an H100 volume in
August 2026, that removes **15.45 GiB** (Qwen2.5-VL) and **7.49 GiB** (Z-Image) of a
**87.6 GiB** per-request read, worth a median **11.76 s** and **4.10 s** of load time
(paired within five containers). The output is **byte-identical** -- the stored
tensors are the encoder's own -- so this needs no provider-oracle re-verification.

Three properties are load-bearing:

- **The key self-heals.** A model bump or a prompt edit changes the key, so the next
  container recomputes rather than reading a stale embedding. `_PROMPT_CACHE_VERSION`
  covers a change to the stored shape itself.
- **The write is atomic.** A torn write must never be readable as a cache hit, so the
  payload lands in a temp file and is renamed into place.
- **A miss after the encoder was dropped raises.** `require_cache` records that the
  stack was built without a text encoder on the strength of the file; falling back
  would call a model that is not loaded, which surfaces as an opaque crash.

`_model_cache_dir()` prefers `HF_HOME` for the same reason: on a scale-to-zero runner
that is the only persistently mounted path, and anything below it is re-derived per
request. The YuNet download follows the same root.

Regression coverage:

- [`test_qwen_zimage_pipeline.py`](../tests/test_qwen_zimage_pipeline.py)
- [`test_cpu_offload.py`](../tests/test_cpu_offload.py)

### Tiling

[`_internal/tiling.py`](../src/remove_ai_watermarks/_internal/tiling.py) contains pure
tile planning, feather weights, and tile orchestration.

Tiling engages only when requested and the long side exceeds the tile size.
It avoids an explicit full-image downscale but does not make diffusion
pixel-preserving. Each tile is still regenerated.

It also held a `feather_region_composite` for AI-*enhanced* composites, where only
the edited region should change. Nothing ever reached it: the `erase` command
inpaints through `region_eraser`, and the remover's `region` argument was only
reachable from a module-level convenience wrapper with no callers. Both went.

Regression coverage:

- [`test_tiling.py`](../tests/test_tiling.py)

### Postprocessing

[`humanizer.py`](../src/remove_ai_watermarks/humanizer.py) contains explicit
grain, unsharp masking, and adaptive polish helpers.

`upscaler.py` held an optional Real-ESRGAN path, reachable only when enlarging a
small image to the minimum-resolution floor. That floor existed to lift small
inputs toward SDXL's ~1024 training size; when the SDXL profiles were removed it
was forced to 0 on every path, so the module, the `--min-resolution` and
`--upscaler` options and the `esrgan` extra were all unreachable and went with
it. Only the `max_resolution` cap can move geometry now, and it only scales down.

Regression coverage:

- [`test_humanizer.py`](../tests/test_humanizer.py)

## Image input and output

[`image_io.py`](../src/remove_ai_watermarks/image_io.py) is the shared image
codec boundary.

Contracts:

- All package OpenCV file reads and writes use `image_io.imread` and
  `image_io.imwrite`.
- `to_bgr` normalizes grayscale and alpha-bearing arrays.
- `read_bgr_and_alpha` and `write_bgr_with_alpha` preserve the alpha plane.
- `imwrite` returns a success flag; every caller must check it.
- HEIC, HEIF, and AVIF pixel reads fall back to Pillow plus `pillow-heif` from
  the independent `heif` extra. Metadata scanning does not require that plugin.
- A visible no-op can preserve the original file bytes.

Regression coverage:

- [`test_image_io.py`](../tests/test_image_io.py)
- [`test_cli_robustness.py`](../tests/test_cli_robustness.py)

## Adding or changing behavior

For a new visible mark:

1. create a synthetic detection silhouette;
2. add or extend a vendor engine;
3. add one registry entry;
4. test detection, false positives, localization, and actual pixel change;
5. update [supported signals](supported-signals.md).

For a new metadata signal:

1. add the scanner;
2. add every supported removal placement;
3. verify the output through `strip_and_verify`;
4. add identification and removal tests;
5. update [supported signals](supported-signals.md) and, when relevant,
   [the watermarking landscape](watermarking-landscape.md).

For a diffusion change:

1. keep model-free logic in pure helpers where possible;
2. test option propagation and dispatch without downloading models;
3. run a real model smoke for the changed model path;
4. treat provider-verifier results as specific to the exact checked output;
5. update [known limitations](known-limitations.md).
