Compare commits

..
444 Commits
Author SHA1 Message Date
Victor Kuznetsov 8871eb3f67 Release 0.29.0
pre-commit: 1) maintain.sh - exit 1 on uv-secure, lightning PYSEC-2026-3624 unchanged from 0.28.1, no fixed release exists, vulnerable API unreachable (no load_from_checkpoint in project or trustmark); ruff, pyright src/, and 1386 tests passed separately; 2) /simplify - version bump only; 3) docs sync - no version refs outside pyproject, __init__.py, uv.lock; 4) CLAUDE.md - no change
2026-08-25 13:25:33 -07:00
Victor Kuznetsov 44ec203c22 Drop the stale untiled restriction from text restoration docs 2026-08-25 13:22:49 -07:00
Victor Kuznetsov 4beb61463a Say metadata check and strip results are not a clean verdict 2026-08-25 13:22:38 -07:00
Victor Kuznetsov 0d11e4099c Fix adaptive text restoration crop bounds 2026-08-25 12:44:05 -07:00
Victor Kuznetsov fa83c1444f Add geometry-only text manifest schema 2026-08-25 11:33:21 -07:00
Victor Kuznetsov f52e40ae24 Name Copilot, Dreamina, and Qwen in C2PA source labels 2026-08-21 14:43:39 -07:00
Victor Kuznetsov 0aa03c4e89 Release 0.28.1
pre-commit: 1 maintain.sh - uv-secure lightning PYSEC-2026-3624 known, no fix, same as 0.28.0; ruff/pyright/1378 tests passed; 2) /simplify - version bump only; 3) docs sync - no refs; 4) CLAUDE.md - no change
EOF
)
2026-08-19 21:46:14 -07:00
Victor Kuznetsov 91aa49b03a Allow verified-text restoration on tiled images
Tile the Qwen VAE donor the same way as the global pass. Glyph restore already runs on the blended full frame, so the old tile ban was an artificial gate.
2026-08-19 21:33:22 -07:00
Victor Kuznetsov ee865e95e4 Add geometry draft mode and pad silhouette descenders
pre-commit: 1 maintain.sh - not rerun full, ruff + 34 targeted tests passed; 2) /simplify - pad lives in silhouette crop, stable=False reuses probes; 3) docs sync - no README/cli refs to update; 4) CLAUDE.md - no change
EOF
)
2026-08-19 19:53:38 -07:00
Victor Kuznetsov d84fd1a125 Merge pull request #86 from wiltodelta/dependabot/uv/minor-and-patch-267694dd16
Bump the minor-and-patch group across 1 directory with 7 updates
2026-08-19 12:22:54 -07:00
Victor Kuznetsov e315eacb14 Document automated-operator verified manifests
verified:true may attest machine-verified geometry (stability-gated
boxes inside caps) set by a service; the pipeline consumes box/script
only, text is advisory, so the verification burden is on the geometry.
2026-08-19 12:21:32 -07:00
Victor Kuznetsov 3ba4ace7e0 Disable mkldnn in the draft engines (linux PIR crash) 2026-08-19 10:22:58 -07:00
dependabot[bot] 13888704f8 Bump the minor-and-patch group across 1 directory with 7 updates
Bumps the minor-and-patch group with 7 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [python-dotenv](https://github.com/theskumar/python-dotenv) | `1.2.2` | `1.2.3` |
| [c2pa-python](https://github.com/contentauth/c2pa-python) | `0.37.5` | `0.37.7` |
| [av](https://github.com/PyAV-Org/PyAV) | `18.0.0` | `18.1.0` |
| [transformers](https://github.com/huggingface/transformers) | `5.14.1` | `5.15.0` |
| diffsynth | `2.1.0` | `2.1.2` |
| [paddleocr](https://github.com/PaddlePaddle/PaddleOCR) | `3.4.1` | `3.7.0` |
| [ruff](https://github.com/astral-sh/ruff) | `0.16.2` | `0.16.3` |



Updates `python-dotenv` from 1.2.2 to 1.2.3
- [Release notes](https://github.com/theskumar/python-dotenv/releases)
- [Changelog](https://github.com/theskumar/python-dotenv/blob/main/CHANGELOG.md)
- [Commits](https://github.com/theskumar/python-dotenv/compare/v1.2.2...v1.2.3)

Updates `c2pa-python` from 0.37.5 to 0.37.7
- [Release notes](https://github.com/contentauth/c2pa-python/releases)
- [Changelog](https://github.com/contentauth/c2pa-python/blob/main/docs/release-notes.md)
- [Commits](https://github.com/contentauth/c2pa-python/compare/v0.37.5...v0.37.7)

Updates `av` from 18.0.0 to 18.1.0
- [Release notes](https://github.com/PyAV-Org/PyAV/releases)
- [Changelog](https://github.com/PyAV-Org/PyAV/blob/v18.1.0/CHANGELOG.rst)
- [Commits](https://github.com/PyAV-Org/PyAV/compare/v18.0.0...v18.1.0)

Updates `transformers` from 5.14.1 to 5.15.0
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.14.1...v5.15.0)

Updates `diffsynth` from 2.1.0 to 2.1.2

Updates `paddleocr` from 3.4.1 to 3.7.0
- [Release notes](https://github.com/PaddlePaddle/PaddleOCR/releases)
- [Commits](https://github.com/PaddlePaddle/PaddleOCR/compare/v3.4.1...v3.7.0)

Updates `ruff` from 0.16.2 to 0.16.3
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.2...0.16.3)

---
updated-dependencies:
- dependency-name: python-dotenv
  dependency-version: 1.2.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: c2pa-python
  dependency-version: 0.37.7
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: av
  dependency-version: 18.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: transformers
  dependency-version: 5.15.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: diffsynth
  dependency-version: 2.1.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: paddleocr
  dependency-version: 3.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.16.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-19 17:19:11 +00:00
Victor Kuznetsov e938b57f8c Package the verified-text draft annotator as text_draft
Move the draft-annotation logic (PaddleOCR line detection, word-box
grouping, three script-chosen recognition engines, crop-jitter
stability gate) from the evaluation-only scripts into the installable
package, with lazy paddle imports and a text-draft extra (CPU, no
torch). draft_text_lines() returns accepted (crop-stable, NEVER
ground-truth-correct - precision on the reference posters was 90.0% /
94.4%) and rejected proposals; source_pixel_sha256 is re-exported for
manifest building. scripts/infer_text_lines.py now dogfoods the
package module instead of loading the eval script by path.
2026-08-19 10:13:22 -07:00
Victor Kuznetsov 3cd1e47935 Add the oracle-measured Google strength floor to qwen-zimage
The resolution curve's 0.154 top left the 4.33 MP CJK-sign fixture
SynthID-detected x3 in Gemini on the full production path (visible
stage -> qwen-zimage seed 0 -> resize-back -> metadata strip), with a
valid pixel-identical stripped control in the same session
(2026-08-18). Google-provenance content now resolves to the flat
QWEN_ZIMAGE_GOOGLE_STRENGTH 0.30 floor instead of the area curve -
anchors at 0.30 measured clean in Gemini on two fixtures (CJK sign +
18-face) at 3/3 checks across two work accounts, and stayed clean
under the vae-glyphs donor layer. openai/unknown content keeps the
curve; an explicit strength still wins.
2026-08-18 22:34:41 -07:00
Victor Kuznetsov 2298282b8b Thread fidelity_anchor through WatermarkRemover
The engine forwards the new kwarg into WatermarkRemover.remove_watermark,
which rejected it with TypeError on the real (non-fake) path - caught by
the deployed Modal smoke test, not by the unit fakes.
2026-08-18 21:32:57 -07:00
Victor Kuznetsov 42d427731f Turn the verified-text fidelity anchor off by default
The whole-frame 15% Qwen-VAE blend returned detector-visible OpenAI
SynthID on poster-scale manifests through the engine text-manifest
path (official Content Provenance API, 2026-08-19: restored detected
x6 with the anchor, clean x6 without it; base outputs clean x6;
pixel-identical stripped controls detected, proving the pixel channel).
Add fidelity_anchor=False to remove_watermark and InvisibleOptions and
--fidelity-anchor on the CLI to reproduce the 0.27.0 research
behavior. Text-box MAE cost of the new default is under one point on
all three fixtures (11.60->11.72, 7.79->7.86, 7.57->8.13).
2026-08-18 21:23:25 -07:00
Victor Kuznetsov 50d9f44829 Guide text-heavy content away from forced diffusion scrubs
Issue #84: text, tables, and UI screenshots are the worst case for
--force regeneration. Document that the default pipeline skips the
invisible stage without a detectable signal, that visible/metadata
stages never redraw glyphs, and why paste-back is not offered.
2026-08-18 11:03:53 -07:00
Victor Kuznetsov 2e1428406e Release 0.27.0 2026-08-18 11:02:18 -07:00
Victor Kuznetsov 21ce1dfadd Cover QuickTime TC260 placements, Luma AI tags, and MiniMax video provenance 2026-08-17 19:38:37 -07:00
Victor Kuznetsov 2369d351a9 Merge pull request #83 from wiltodelta/chore/text-preservation-evaluation
Add verified text restoration
2026-08-15 12:30:27 -07:00
Victor Kuznetsov 432b63b6d7 Add verified text restoration 2026-08-15 12:25:35 -07:00
Victor Kuznetsov 8c00525946 Evaluate selective text restoration 2026-08-15 12:20:25 -07:00
Victor Kuznetsov f8003f505a Merge pull request #82 from wiltodelta/agent/c2pa-validation
Validate C2PA credentials before attribution
2026-08-15 11:38:47 -07:00
Victor Kuznetsov 2eab24a2e1 Validate C2PA credentials before attribution 2026-08-15 11:31:59 -07:00
Victor Kuznetsov 8201ada070 Merge pull request #81 from wiltodelta/docs/video-synthid-parked-state
Record the video SynthID measurements and park the investigation
2026-08-15 11:11:05 -07:00
Victor KuznetsovandClaude Opus 5 b7d44ff1a3 Warn users that the invisible video path leaves audio untouched
The finding lived only in the research archive, whose own banner says it may
describe prototypes that were later removed. It belongs where users read
limitations: the path strips every metadata marker while copying the audio
bitstream byte for byte, so a clean local report on a clip with generated audio
is unproven rather than a guarantee.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:08:09 -07:00
Victor KuznetsovandClaude Opus 5 e8f65022cc Record the video SynthID measurements and park the investigation
Everything here is evidence and state, not behavior: the shipped operating point
is untouched at 512 px / 12 fps / noise_std=0.15.

The carrier was downloaded and its sha256 matched the manifest, so its properties
are now measured rather than assumed: 1920x1080 at 24 fps with an AAC audio track.
Those three fields go into the two 2026-07-31 rows, which could not previously
tell a reader what the downscale factor even was.

The geometry prize is measured end to end for the first time. Against the
untouched source, 1024 scores +3.46 dB over the shipped 512, while the entire
noise_std axis is worth 1.92 dB. Two readings that the table alone hides are
recorded with it: the temporal residual IMPROVES with resolution, because the
shared noise field lives on the latent grid and is four times finer relative to
the frame at 1920; and the frame-rate arm cannot be judged by these metrics at
all, since they price its cost and not the smoothness it buys.

Two findings that outrank the quality question. The certified row does not
reproduce -- a rerun of the same configuration on a different device and dtype
gives a different hash and different metrics, and the manifest records neither.
And the pipeline copies audio byte for byte while stripping every metadata
marker, so it can emit a file our own detector calls clean with an untouched
Google-generated audio track inside it. The mechanism is proven on two carriers;
whether that audio carries a mark the verifier reads is not, and only the oracle
can say.

The six prepared oracle submissions were never run: file upload to the verifier
failed at the tool level. Their artifacts are gone with the scratch directory,
which is the intended lifecycle for generated media, and the document says what
rebuilding costs.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-15 11:01:14 -07:00
Victor KuznetsovandClaude Opus 5 4855586834 Halve decoder memory with strip processing and a raveled Haar pass
Each Haar pass is one flat pywt.downcoef call over a raveled strip instead of
pywt.dwt(..., axis=1)[0], and the plane is walked in strips so no full-plane
float64 intermediate exists. Exact only while the last axis is even, so
_approximation raises on an odd width rather than returning wrong bits, and
TestRaveledHaarPass pins both that raise and the downcoef/dwt equivalence a
pywt upgrade could take away.

Drops the block constructor knob: the fold chains are written for 4, nothing
ever passed another value, and a knob that silently decodes wrong is worse
than no knob.

Peak RSS 111 MB to 21 MB on a 4.3 MP image; the decoder itself 0.011s to
0.007s, which is only 0.4% of identify() now that it is under 2% of the run.
Output bits and detector verdicts over 200 sampled data/ images, two
synthesized carriers and eight degenerate shapes are byte-identical to the
pre-vectorization decoder.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 13:33:34 -07:00
Victor KuznetsovandClaude Opus 5 37789e02f6 Vectorize the DWT-DCT decode path, 15x on the decoder
Output stays bit-identical: decoder bits and detector verdicts recorded over
200 sampled data/ images plus two synthesized carriers before and after, and
the record is byte-identical.

Measured on a 1536x2816 image -- decoder 0.280s to 0.016s, warm identify()
1.757s to 1.365s with both arms timed in one process.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-13 11:59:56 -07:00
Victor Kuznetsov 2d03a00a39 Merge pull request #80 from wiltodelta/dependabot/uv/minor-and-patch-2677c98c32
Bump the minor-and-patch group across 1 directory with 5 updates
2026-08-12 20:49:38 -07:00
dependabot[bot] acbf6afac3 Bump the minor-and-patch group across 1 directory with 5 updates
Bumps the minor-and-patch group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [c2pa-python](https://github.com/contentauth/c2pa-python) | `0.37.2` | `0.37.5` |
| diffsynth | `2.0.18` | `2.1.0` |
| [huggingface-hub](https://github.com/huggingface/huggingface_hub) | `1.26.0` | `1.27.0` |
| [packaging](https://github.com/pypa/packaging) | `26.2` | `26.3` |
| [ruff](https://github.com/astral-sh/ruff) | `0.16.1` | `0.16.2` |



Updates `c2pa-python` from 0.37.2 to 0.37.5
- [Release notes](https://github.com/contentauth/c2pa-python/releases)
- [Changelog](https://github.com/contentauth/c2pa-python/blob/main/docs/release-notes.md)
- [Commits](https://github.com/contentauth/c2pa-python/compare/v0.37.2...v0.37.5)

Updates `diffsynth` from 2.0.18 to 2.1.0

Updates `huggingface-hub` from 1.26.0 to 1.27.0
- [Release notes](https://github.com/huggingface/huggingface_hub/releases)
- [Commits](https://github.com/huggingface/huggingface_hub/compare/v1.26.0...v1.27.0)

Updates `packaging` from 26.2 to 26.3
- [Release notes](https://github.com/pypa/packaging/releases)
- [Changelog](https://github.com/pypa/packaging/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pypa/packaging/compare/26.2...26.3)

Updates `ruff` from 0.16.1 to 0.16.2
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.16.1...0.16.2)

---
updated-dependencies:
- dependency-name: c2pa-python
  dependency-version: 0.37.5
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: diffsynth
  dependency-version: 2.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: huggingface-hub
  dependency-version: 1.27.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: packaging
  dependency-version: '26.3'
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.16.2
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-12 22:57:56 +00:00
Victor Kuznetsov 25eaa7458c Support Python 3.11 through 3.14 2026-08-12 15:52:11 -07:00
Victor KuznetsovandClaude Opus 5 58146dbc6d Say what the hosted service is instead of offering it as a fallback
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-12 14:24:56 -07:00
Victor KuznetsovandClaude Opus 5 b08899dc00 Note that the package name is the published distribution
Renaming it strands every pip install and every pinned dependency on it,
including the ComfyUI node package, and the console script has the same
property since users have it on PATH. Recorded at the name itself, where
whoever considers changing it will be looking.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-10 13:52:48 -07:00
Victor Kuznetsov b1319b1d5a Release 0.26.3 2026-08-09 21:15:15 -07:00
Victor Kuznetsov 353bc5f12c Harden TrustMark detection with an official fixture 2026-08-09 21:08:09 -07:00
Victor KuznetsovandClaude Opus 5 370556ffe6 Ignore other harnesses' generated config before it appears
These directories are empty today, so git shows nothing and the gap is
invisible. The moment the sync writes into one, a routine running
git add -A commits another harness's generated config, and the harness
audit then fails after the fact rather than before it. Paths are the
canonical untrackedConsumerPaths list, anchored to the repo root so a
same-named source file deeper in the tree is not caught.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 17:04:05 -07:00
Victor KuznetsovandClaude Opus 5 14a27d224d Drop the settings.json that only repeats global defaults
All four plugins it enabled are enabled globally, and its allow list says
nothing under the global defaultMode: bypassPermissions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-09 16:51:37 -07:00
Victor Kuznetsov 015df40f49 Release 0.26.2 and document distribution surfaces 2026-08-09 00:27:10 -07:00
Victor Kuznetsov f9beef365f Remove conda distribution support 2026-08-08 21:18:10 -07:00
Victor Kuznetsov 9b656513f1 Improve C2PA attribution and ByteDance metadata handling 2026-08-08 19:34:40 -07:00
github-actions[bot] ab03ecc507 Sync conda recipe with v0.26.1 2026-08-07 01:21:28 +00:00
Victor Kuznetsov 14b7247e0b Fix SynthID provenance evidence and release 0.26.1 2026-08-06 18:17:40 -07:00
Victor Kuznetsov d3eeba3a33 Merge pull request #78 from wiltodelta/research/video-synthid-quality-groundwork
Make the video SynthID operating point measurable and hard to move silently
2026-08-05 22:35:17 -07:00
Victor KuznetsovandClaude Opus 5 613b349358 Cover the fidelity probe, and run it where ffmpeg actually exists
The probe shipped with no tests while every sibling research script has them,
and it needs neither a model nor a GPU, so nothing justified the gap. Its whole
output is a ranking and a mispaired comparison still prints a plausible number,
so the pairing tests carry more weight than the metric ones.

The centerpiece builds a correctly phased and a wrong-phased delivery of the
same clip. Both have six frames, so the frame-count check the probe used to rely
on passes for both; the misaligned one scores near 9 dB against 40+. A separate
assertion pins that the probe binds the engine's sampler rather than a copy,
which is the contract a same-phase private copy would otherwise satisfy
silently. This file now holds the only constraint on that sampler's phase.

CI ran none of this. The test matrix installs no ffmpeg, and the job that does
install it ran one class from another file, so five of these tests would have
skipped on every runner. The video-e2e job now collects this file too.

Recorded in .claude/rules/development.md: mutating the sampler's >= to > proves
nothing, because the 1e-9 epsilon makes both branches agree on exact equality.
The phase or the period is what has to move. That inert mutation passed a green
suite here before the real one failed five tests.

Dropped the downscale test's PSNR bound: solid colors survive both the downscale
and the upscale exactly, so any score there pins the local ffmpeg's chroma
rounding rather than the probe. Geometry is the assertion that means something.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 22:24:49 -07:00
Victor Kuznetsov acd7092477 Merge remote-tracking branch 'origin/main' into research/video-synthid-quality-groundwork 2026-08-05 21:46:19 -07:00
github-actions[bot] e08644dcf2 Sync conda recipe with v0.26.0 2026-08-06 04:23:38 +00:00
Victor Kuznetsov 1a38c556f8 Merge pull request #77 from wiltodelta/claude/image-metadata-detection-benchmark-0bb6ec
Add versioned metadata and pixel evidence transports
2026-08-05 21:19:32 -07:00
Victor Kuznetsov a83952e375 Add versioned forensic metadata transports 2026-08-05 21:10:55 -07:00
Victor KuznetsovandClaude Opus 5 1124c591be Match the vendor registries against metadata, not coded pixels
The registries are raw substrings and the shortest tokens are four and five bytes
(`Bria`, `Adobe`, `Canva`). Over a megabyte of compressed pixel data such a sequence
turns up by chance: `Bria` matched inside the entropy-coded scan of 4 of 14,707
corpus JPEGs, in none of which the manifest names Bria. The rate is what a four-byte
pattern predicts on that corpus, and the Bria entry asserts AI, so a chance match can
declare an image AI-generated rather than merely mislabel its signer.

`_metadata_region` gives the registry scans the container's metadata: JPEG marker
segments before the coded scan, PNG chunks other than IDAT, both trailers, and
whatever `scan_head` appended past the window. Every other check keeps the full
buffer -- their markers are long and distinctive. A container that does not parse is
returned whole, since dropping real evidence to avoid a chance match is the wrong
trade. `c2pa_marker_in` already refuses a bare `c2pa` substring for this reason;
this is the same defence for the registries.

Verified the way the rules require for a change that MOVES a verdict: over all 48,905
corpus images, exactly one file changed, the one named in advance, from
"C2PA Content Credentials (Bria Artificial Intelligence)" to "(unknown signer)".
Record-path parity is 0 disagreements, down from 75 when this work started.

The audit's own baseline comparison is fixed here too. It compared confidence and
signals only, and so reported "0 changed" for the run whose single intended
correction was a watermark line -- the change it exists to show.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:55 -07:00
Victor KuznetsovandClaude Opus 5 9a29dcac8a Keep the pixel forensics in the library, drop the ai-score tooling
`scripts/ai_score.py` and the dataset scanner that fed it are gone: the detector
they trained is not something this project runs, and the corpus lived outside the
repository anyway. Nothing else referenced them.

The scanner's pixel layer was worth keeping, so it moves into the package as
`pixel_evidence.py` -- six families of scale-robust statistics (block-DCT histograms
and Benford deviation, FFT band energies and CFA peaks, high-pass residual, error
level, gradient, colour) measured in a single shared decode. The arithmetic was
verified against the scanner over 60 corpus images, families and artifacts alike,
before the scanner was removed; that comparison is no longer possible, which is why
the tests now pin behavior instead: determinism, empty-not-wrong on images too small
for a family, and one failing family not taking the others with it.

It has no consumer. Nothing in the package reads it, and the module says so.

`artifacts=True` returns the spatial layer -- perceptual hash, 128px thumbnail,
coarse ELA/residual/phase maps. Those identify the source image rather than describe
it, so they are opt-in and separate: everything else is a scalar or a fixed-length
histogram nothing can be reconstructed from.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:55 -07:00
Victor KuznetsovandClaude Opus 5 b5da0510c9 Release 0.26.0
Updates the three version sources the release doc names -- `pyproject.toml`,
`__init__.py`, and the root package entry in `uv.lock` -- and carries the marker
simplifications uv produced when it re-resolved the lock.

The release itself is not started here: the tag, push, and GitHub Release are the
remaining steps, and PyPI publishing triggers on the published Release rather than
on a tag push.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00
Victor KuznetsovandClaude Opus 5 bebff368fc Decide the SynthID proxy in the verdict, where both extractors meet
A full-corpus audit of the record path against the file path found 75 of 48,905
images disagreeing, and 74 were one gap: the SynthID byte scan for containers whose
manifest no parser reaches lived in `get_ai_metadata`, an extractor the record path
does not run. The record silently reported no SynthID for images `identify` flagged.

Moving the scan into `identify_from_evidence` fixes it by construction rather than by
copying the rule into a second extractor -- the same shape `soft_binding` already
uses. Its byte checks mirror `metadata.synthid_source` literally instead of reusing
the broader `has_c2pa` / `c2pa_source_kind` derived above, so the file path's answers
do not move: verdicts over a 4,000-image sample are byte-identical.

`scripts/record_parity_audit.py` is the audit itself, now repeatable. It walks a
dataset, judges every image through both seams with the record round-tripped through
JSON, and reports disagreements by field and by signal. The rule in
`.claude/rules/development.md` says to re-run both sides of this seam after changing
either; this is what to run.

Both timing and audit scripts now put the package's OWN `src` on the path. From a
worktree an editable install resolves to the main checkout, so the audit imported a
different tree than the one under test -- the failure the same rules file warns about,
reproduced within an hour of writing it down.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00
Victor KuznetsovandClaude Opus 5 2668f1302d Read WebP metadata past the scan window and surface C2PA reader failures
Three gaps found while measuring the record path against the file path, each one
a signal the library could not see:

WebP stores `XMP ` after the pixels, so on any WebP above the scan window a fixed
read stops short of the label. `_riff_late_metadata` steps over the coded image to
reach it, the RIFF analogue of the existing PNG and ISOBMFF readers. Three corpus
files hid an IPTC "Made with AI" tag and a C2PA `trainedAlgorithmicMedia` there.
The decoder-backed fallback now covers only what it is actually for -- metadata the
raw bytes do not spell, such as a compressed PNG `zTXt` packet.

A C2PA reader failure returned the same `None` as a file with no manifest, so a
verdict could fall back to the raw byte scan with no trace anywhere. Failures now
log at warning and only genuine ones do: a file without credentials never reaches
that branch, and an unsupported container is demoted to debug through the reader's
own `C2paError.NotSupported`. The first corpus run with it found a truncated PNG.

`scan_dataset.py` never registered the pillow-heif opener it declares as a
dependency, so every HEIC was scanned as unreadable -- no EXIF, and a pixel layer
that was 397 of 406 features NaN instead of 136.

`_riff_late_metadata` caps its total like `isobmff.scan_c2pa_region` does. Clamping
each chunk to the bytes remaining is not enough on its own: one chunk can declare a
length spanning most of the file, and this runs on the memoized verdict path over
images from arbitrary sources.

Also lands `identify_metadata_record` and `ProvenanceReport.to_dict()`, the
one-call entry point and the versioned JSON contract for the record path.

Record-vs-file equality holds over 3,478 corpus images, and the eight files these
fixes recovered still report AI.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00
Victor KuznetsovandClaude Opus 5 0c5961a0ed Add a portable metadata record so collection and verdict can run apart
`collect_metadata_record` returns a JSON-safe record carrying an image's
provenance metadata regions -- never its pixels -- and the existing
`evidence_from_metadata_record` + `identify_from_evidence` build the verdict
from it without opening the file. The contract is equality with
`identify(path, metadata only)`, verified over the tracked fixtures and over a
local corpus of 3,478 images (every file carrying a rare signal, plus a random
slice): zero differences.

Three placements defeated earlier drafts and each is now a rule with a test:
the `scan_head` buffer is the head CONCATENATED with late metadata, so a
structural walk must read the raw head instead; Samsung splits its evidence
between a post-EOI trailer and the coded scan; and PIL's info keys must be
emitted in the file path's candidate order, since the first token match wins.

Also fix a real detection gap found while establishing that equality: a label
the decoder can read but a raw byte scan cannot -- a compressed PNG `zTXt`
packet, or a WebP XMP chunk past the scan window -- was invisible to
`identify`. Eight corpus files carrying a China TC260 AIGC label or an IPTC
"Made with AI" tag were reported as no signal at all.

`scripts/detection_timing.py` and its report script measure the metadata path
per method; they write outside the repository and are read-only over a dataset.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 21:10:38 -07:00
Victor KuznetsovandClaude Opus 5 8fe0b0110f Make the video SynthID operating point measurable and hard to move silently
The shipped profile was certified by one oracle row, but only noise_std was
pinned: long_side and fps -- two thirds of what the verifier was actually shown
-- could move with a green suite. The test now derives the pin from
data/evaluations/video-synthid-oracle.csv, so a default without a certifying row
fails.

The certified profile is a perturbation-to-signal ratio, not a bare noise_std.
sd-vae-ft-mse publishes no scaling_factor key, so 0.18215 comes from the
AutoencoderKL class default under an upper-unbounded diffusers pin. The loader
now gates that value, carries it on VideoVaeRuntime, and passes it into encode
and decode so the validated value is the applied value. video_synthid_sweep.py
loads through the same function: the harness producing the certified rows was
the one path exempt from the gate it exists to feed.

psnr_db is measured against the already-resized frame and before the encoder, so
it cannot see the downscale, the decimation, or the codec, and no in-loop metric
can. scripts/video_fidelity_probe.py scores the delivered file end to end,
streaming the way the engine does and sharing its frame-selection rule rather
than copying it -- a frame-count check cannot catch a rule that reorders frames
without changing how many.

The manifest gains source geometry, vae, track, verbatim verdict and session
fields. The two 2026-07-31 rows keep them empty: they were never recorded and
are not recoverable. Verdicts now have four states, because the verifier's
unclear reading logged as not_detected is the silent regression the manifest
exists to prevent.

docs/video-synthid-quality-research.md records the research behind this: the
noise axis is worth about 2 dB and is nearly exhausted, resolution is the real
prize but is an uncertified destruction axis rather than a free win, and every
proposed autoencoder swap was refuted. First local measurements included.

Verified: engine output is byte-identical before and after the refactor on a
locally built clip, at noise_std 0.00 and 0.15.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-05 11:17:38 -07:00
Victor Kuznetsov f481e6f944 Merge pull request #76 from wiltodelta/refactor/invisible-options-are-engine-knobs
Make InvisibleOptions engine knobs only and pin the forwarding
2026-08-04 19:38:07 -07:00
Victor KuznetsovandClaude Opus 5 4a896cd4b5 Make InvisibleOptions engine knobs only and pin the forwarding
`InvisibleOptions` promises in its docstring that every default mirrors
`InvisibleEngine`. Two fields made that promise cost something to keep: `force` is
not an engine parameter at all, and `controlnet_scale` was a third spelling of the
engine's `controlnet_conditioning_scale`. The mirror test carried an exception
table for each. This removes both, so the comparison needs no exceptions -- a field
that needs one is a field that belongs somewhere else.

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

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

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

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

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

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 19:33:54 -07:00
Victor KuznetsovandClaude Opus 5 13095fb45c Verify every doc claim against the source and fix what drifted
Every code-referencing claim in the docs, the README and the rules files was
checked against src/, and each finding was re-derived independently before it
was applied. 35 held, 5 were false positives.

Two of them were code, not text. `InvisibleOptions` promises in its docstring to
mirror `InvisibleEngine`, and two defaults had silently stopped:
`max_resolution=None` reached `_target_size`'s `max_resolution > 0` and raised
`TypeError` on every library call that left the options alone, and
`cpu_offload=True` made a library run slower than the identical CLI run. Both are
fixed, and `TestInvisibleOptionsMirrorTheEngine` compares the two signatures
field by field rather than pinning the two values that happen to be known. A
companion assertion in `TestTargetSize` reads the engine's own declared default,
so a drift on the engine side -- which the mirror check alone would accept,
because both sides would still agree -- fails too.

The user-facing docs: README called `invisible` GPU-optional where it raises
without CUDA, and gave the image `metadata` command `video metadata`'s output
rule, promising the source survives a command that overwrites it. Yuanbao was
missing from the supported-mark list. `veo` was listed among the video policies
that require a run anchor, though its row sets no `anchor_iou`.
`known-limitations` called ControlNet the default profile and contradicted
itself ninety lines below. An unescaped pipe truncated the `hailuo` table row.
The `dev` extra, the CI shape, ffmpeg's role, the sdist boundary and the
strength-curve range were corrected, and `remove_all`/`remove_batch`, the pill
gate, `erase --keep-metadata` and `all`'s CUDA failure mode were documented.

Research notes that described removed modules, extras and flags in the present
tense now say so once in the page banner instead of sentence by sentence, which
covers the whole page rather than the lines that happened to be noticed, and one
fixture is referred to by role rather than by name.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-04 11:38:04 -07:00
Victor Kuznetsov 0261ec1bd7 Merge pull request #75 from wiltodelta/refactor/detection-path-and-library-pipeline
Collapse the duplicated detection path and lift the image pipeline into the library
2026-08-04 10:21:43 -07:00
Victor KuznetsovandClaude Opus 5 78d9e81d0f Collapse the duplicated detection path and lift the image pipeline into the library
The visible-mark path had grown three copies of one ladder sweep, four
near-identical `detect` arms, and four hand-rolled `footprint_mask` overrides;
mark knowledge sat in five hand-maintained tables across three modules; and the
flagship `all`/`batch` pipeline existed only in cli.py, written twice with
divergent behavior.

Detection is now one measurement. `_ladder_best` replaces the three sweeps,
`_scan`/`_verdict` replace the four arms, and the winning box travels to the
mask on `TextMarkDetection.match_box` instead of being swept a second time.
`detect_both` returns the strict and relaxed verdicts from one scan, which
halves the arbiter's perception cost (260 -> 130 matchTemplate calls on a 2048²
image, verdicts identical field for field). A per-mark demotion goes in the new
`_post_gate` hook, never in a `detect` override -- an override is invisible to
the single-pass path, which is how the RunningHub and Yuanbao anchor gates
briefly stopped applying.

Everything about a mark is now one registry row: product, label regime, the
platform sentence `identify` reports, the metadata signals that confirm it, and
its TC260 producer codes. `identify._VISIBLE_MARK_PLATFORM`, the signal mapping
in `api.visible_provenance`, `_PRODUCT_OF` and the pill veto are derived from
those rows.

`api.remove_all` / `api.remove_batch` are the library form of the `all` and
`batch` commands; the CLI is a wrapper that owns console text and exit codes.
Progress is a `(stage, detail)` pair of stable tokens, so the CLI keys its
wording off structure rather than parsing the library's prose back.

Two intentional behavior changes, both verified against a recorded 811-image
sample of detector verdicts, removal-mask hashes, arbiter decisions and
`identify` reports:

  * A TC260 label now relaxes the vendor its `ContentProducer` names rather than
    ByteDance's pair on every China-AIGC image. 333 of 811 samples move; on 185
    of them the previously relaxed pair was simply the wrong vendor, and the
    mark actually present never reached the relaxed gate its own
    `provenance_ncc_factor` was calibrated for.
  * A confident LibLibAI detection suppresses the Jimeng pill, like every other
    TC260 product's mark. It was registered alongside RunningHub and Baidu, both
    of which were added to the hand-written veto list, and it was not. 1 sample
    moves, and it is exactly the co-firing case.

Nothing else in that record changes: detector verdicts, mask hashes and
`identify` verdicts are byte-identical, and all 200 calibration constants are
untouched.

Also: `aigc_label` and friends plus `extract_c2pa_info` are memoized on
(path, mtime_ns, size) -- size because this package rewrites in place; the
native TC260 container readers route on magic bytes instead of the file
extension, so a mislabeled AVI or FLV is no longer invisible; `identify` shares
one pixel decode between the DWT-DCT and visible stages (TrustMark keeps its own
Pillow decode, which is not substitutable); and the six `stabilize_*` video
wrappers collapse into one policy table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:49:45 -07:00
Victor KuznetsovandClaude Opus 5 480f478484 Refresh the dependency lockfile
Patch and minor only: cffi, coverage, hf-xet, modelscope-hub, typer. The majors
the resolver held back (numpy 2, opencv 5, tokenizers 0.23) are the ones
.github/dependabot.yml already documents as blocked upstream.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 22:49:18 -07:00
github-actions[bot] cb4590c61f Sync conda recipe with v0.25.0 2026-08-04 00:03:04 +00:00
Victor KuznetsovandClaude Opus 5 38399e4eb0 Release 0.25.0
Breaking, on top of the released 0.24.0.

Removed from the CLI: --model, --steps, --guidance-scale, --device, and the
deprecated --auto. Removed from InvisibleEngine and WatermarkRemover: the
model_id, num_inference_steps and guidance_scale parameters, remove_watermark_batch,
and the remover's region/region_feather path. Each pinned a value the two
remaining profiles fix -- the model stack, the per-stage distilled schedule,
CFG 1.0, CUDA -- so their only outcome was an error raised several frames below
the caller.

Also removed: the public remove_ai_watermarks.upscaler module, the
--min-resolution and --upscaler options and the published esrgan extra (0.24.0
shipped them unreachable); the pytorch-xpu index; and "diffusion" from the "all"
extra, which qwen-zimage already pulls.

Behaviour changes a caller can see:
- invisible-watermark removal now requires the qwen-zimage extra, not diffusion.
  Both profiles run the DiffSynth Z-Image face stage, so a torch+diffusers-only
  environment used to pass the availability gate and then die there. Every
  install hint names qwen-zimage now.
- get_device() answers cuda or cpu only, and the CUDA-only refusal names the
  resolved device rather than the raw argument.
- adaptive_polish is tri-state. Unset follows the profile (off for qwen-zimage,
  on for sdxl-zimage) and is resolved inside the engine, so a library caller and
  a CLI caller on one profile now produce the same pixels; they did not before.

ComfyUI node 0.1.15 is already published and tracks this surface.

pre-commit: 1) maintain.sh - exit 0 (1093 tests, Pyright 0 errors, no
vulnerabilities); 2) /simplify - n/a, version bump only; 3) docs sync - version
appears in pyproject.toml, __init__.py and uv.lock, all three updated; 4)
CLAUDE.md - no rule change

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:59:10 -07:00
Victor KuznetsovandClaude Opus 5 2f72257996 Fix what the verification pass found in the knob removal
An adversarial review of 52b2c11 (five independent audits, each finding put to
two skeptics, plus a completeness critic) found four defects that commit
introduced and several stale claims it should have caught.

The install hint no longer installs -- again. Folding five hints into one
INVISIBLE_EXTRA constant dropped the shell quoting the originals had, so the
printed remediation was `pip install remove-ai-watermarks[qwen-zimage]`. Bare
brackets are a glob in zsh, the macOS default shell: it dies with "no matches
found" before pip runs. That is the exact failure 52b2c11 existed to stop
producing, reintroduced in a different form by a bulk replace. The constant is
quoted now, and a test asserts the quotes rather than the bare substring -- the
old assertions passed either way, which is why nothing caught it.

Three tests were not guarding what they claimed:

- The commit's headline behaviour change, per-profile polish resolution inside
  the engine, had no test at all. Rebinding resolve_adaptive_polish to the
  pre-commit `bool(value)` left the full suite green. Now covered by a test that
  drives the real engine and observes whether humanizer.adaptive_polish ran;
  that mutation now fails it.
- TestAvailability still asserted the pre-commit (torch, diffusers) contract, so
  in a diffusion-only environment it was simply wrong, and comparing each gate to
  a tuple copied from itself could never catch the two gates disagreeing -- the
  drift the shared REMOVAL_MODULES was introduced to prevent. Replaced with a
  test that simulates each module's absence and requires BOTH gates to close.
- Both CUDA-refusal guards skipped in every environment, including CI: they were
  gated on the diffusion stack, which no CI job installs. The refusal fires
  before any torch attribute is read, so they now run everywhere; only the dtype
  assertion keeps its skip.

Also: the retired-knob test covered `invisible` but not `all` or `batch`, though
all three declared those options separately; and smoke_matrix.py still called
remove_watermark(region=...), a parameter 52b2c11 deleted, with the resulting
TypeError swallowed into a skip by a broad except.

Stale documentation the previous sweep missed: known-limitations still described
an MPS out-of-memory fallback and a lighter-pipeline escape that no code can
produce; module-internals declared Canny thresholds of 100/200 as a compatibility
contract while the code uses 13/64, attributed enable_model_cpu_offload to
deleted profiles, and still warned that the engine and CLI defaults differ (this
commit's predecessor made them identical); cli.md gated `all` on the `diffusion`
extra; python-api claimed "cuda" was the only accepted explicit device when
"auto" is too. The claim that `device` is not a parameter was wrong in both
module-internals and .claude/rules/development.md -- it is one, deliberately, and
now says so. `--cpu-offload` help and the pipeline's CUDA guard both still
pointed at MPS.

Not fixed here, reported instead -- both are outside this repo:
- ComfyUI-remove-ai-watermarks nodes.py:332 passes num_inference_steps and
  guidance_scale (plus min_resolution/upscaler from bf4bfc1). distribute.yml's
  comfyui job runs on every release and fails the release if the node sync fails,
  so 0.25.0 needs that node updated first.
- raiw-app modal_app.py:422-425 forwards the same two kwargs into
  remove_watermark. Latent: it is pinned to 1a77e24 and nothing supplies a value
  today, so it fires on the next pin bump.

pre-commit: 1) maintain.sh - exit 0 (1093 tests, Pyright 0 errors, no
vulnerabilities); 2) /simplify - not re-run, this commit is the applied output of
a five-dimension adversarial review; 3) docs sync - grepped MPS/mps, the extras
names and every symbol touched across README, docs/, scripts/, .claude/; updated
6 docs; 4) CLAUDE.md - corrected the device claim in .claude/rules/development.md
and added the shell-quoting rule

Verified by execution, not assertion: smoke_matrix --quick 51 pass / 0 fail,
_knob_rows driven directly 10 pass / 0 fail / 7 skip (no CUDA), the install hint
rendered and round-tripped through zsh, and each new test confirmed to fail
under the mutation it is meant to catch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 16:31:39 -07:00
Victor KuznetsovandClaude Opus 5 52b2c115e8 Delete every knob the fixed profiles cannot honor
The CLI still advertised --model, --steps, --guidance-scale, --device and a
deprecated --auto. Each pinned a value the two surviving profiles fix -- the
model stack, the per-stage distilled schedule, CFG 1.0, CUDA -- so the only
outcome any of them had was an error raised several frames below the caller,
under a message naming an internal profile. A flag whose sole result is a
refusal is worse than no flag: it advertises a capability that does not exist,
and it lets a wrapper thread a value that will silently do nothing. They are
gone from the parser, from InvisibleEngine, and from WatermarkRemover, so the
failure is now a TypeError or a Click "No such option" at the point the caller
can act on.

The install hint was wrong in the same way. is_available() checked torch and
diffusers, then told the user to install [diffusion] -- which contains neither
DiffSynth nor the Z-Image face stage both profiles run. Following the advice
produced a second, different failure. The module list and the extra name now
live once in watermark_profiles (REMOVAL_MODULES, INVISIBLE_EXTRA) and are read
by both the CLI gate and the remover's precondition, which cannot drift apart
because they are the same tuple.

The adaptive-polish default moved out of the argument parser. It was resolved by
reading Click's parameter source, which put per-profile data in the CLI layer,
left the engine declaring the opposite default (False vs True) so a library
caller and a CLI caller on one profile got different output, and lost the polish
entirely for anything that supplies the flag non-interactively. The flag is now
tri-state (default=None) and resolve_adaptive_polish owns the per-profile
answer. The seed follows the same rule: the CLI stopped pre-resolving it.

Dead code removed with it: six scan_*_video wrappers and the _scan_video helper
none of them had a caller for, PNG_METADATA_KEYS, feather_region_composite and
the remover region path that was only reachable from a no-caller convenience
wrapper, remove_watermark_batch on both layers, try_empty_device_cache, the
_generate/_run_qwen_zimage pass-through pair, self.model_id, and the _internal
PEP 562 shim that no caller ever went through. get_device now answers cuda or
cpu only: mps and xpu travelled one frame to the same CUDA-only refusal while
costing a device probe each, and that refusal now names the resolved device, so
device=None on a CUDA-less host says 'cpu' rather than 'None'. The XPU wheel
index went with them.

Docs: README, cli, installation, python-api, supported-signals,
known-limitations and module-internals all still described the removed profiles,
the CPU/MPS/XPU ladder, a `default`->`sdxl` alias, and the wrong extra.
known-limitations still listed the retired SDXL strength ladder as current.
scripts/smoke_matrix.py and real_examples_e2e.py drove --device mps.

Next release is 0.25.0, not a patch: this removes public parameters and
narrows a published extra on top of the released 0.24.0.

pre-commit: 1) maintain.sh - exit 0 (1091 tests, Pyright 0 errors, no
vulnerabilities); 2) /simplify - 4 agents, 11 findings applied, 2 skipped
(dropping the `device` parameter entirely, which raiw-app pins; folding
diffsynth into the `diffusion` extra, which video-only callers do not need);
3) docs sync - grepped every removed identifier across README, docs/, scripts/,
.claude/; updated 9 docs; 4) CLAUDE.md - added the no-error-only-knobs rule to
.claude/rules/development.md

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 15:38:40 -07:00
Victor KuznetsovandClaude Opus 5 bf4bfc1ab7 Remove the unreachable ESRGAN upscale chain
The min-resolution floor lifted small inputs toward SDXL's ~1024 training size,
and Real-ESRGAN was an optional way to do that lifting. Both surviving profiles
run at native geometry, so the engine forced the floor to 0 on every path; the
floor never fired, `upscaling` was never true, and nothing downstream of it could
execute. Gone: upscaler.py, _esrgan_upscale, the min_resolution and upscaler
parameters, _target_size's floor branch, --min-resolution, --upscaler,
_warn_if_esrgan_unavailable and the `esrgan` extra. max_resolution stays and is
now the only lever on geometry; it can only scale down.

scripts/smoke_matrix.py was the one live consumer and neither gate saw it -
Pyright is scoped to src/ and Ruff cannot resolve its function-local import - so
`--diffusion` would have died at import. Its knob rows were written for the
removed profiles besides (--pipeline sdxl, --steps 20, --guidance-scale 5.0,
--device mps), so they are rewritten rather than patched: most now assert a knob
is REJECTED, which is the coverage worth having when the CLI accepts a value the
library refuses several layers down. Accepted-knob rows skip without CUDA, so the
row count is host-dependent and verification-plan.md no longer claims a fixed 68.

This removes a public module, a CLI option and a published extra, so the next
release is 0.25.0, not a patch.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 14:54:26 -07:00
github-actions[bot] 95a6964e04 Sync conda recipe with v0.24.0 2026-08-03 21:27:17 +00:00
Victor KuznetsovandClaude Opus 5 1a77e24b99 Stop the engine from supplying a model id the remover must reject
InvisibleEngine substituted DEFAULT_MODEL_ID whenever model_id was None. When
b0ca205 tightened the remover's fixed-stack check from `not in {None,
DEFAULT_MODEL_ID}` to `is not None`, that substitution turned every single
InvisibleEngine construction into a ValueError - including the deployed Modal
worker's setup(), which is how it was found.

The library suite missed it because these two are tested from opposite sides:
every remover test builds WatermarkRemover directly with model_id unset, and
every engine test mocks the remover away. Nothing exercised the seam between
them. TestEngineDoesNotFabricateAModelId now does, without a GPU.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 13:24:53 -07:00
Victor KuznetsovandClaude Opus 5 691f5d88c2 Skip the face-stage dtype guard without the qwen-zimage extra
CI's base test job installs the library without that extra, so the test died on
`import transformers` before reaching a single assertion. It monkeypatches the
Z-Image and SAM loaders rather than calling them, but the modules still have to
be importable to be patched, so pytest.importorskip is the right gate: the guard
still runs everywhere the extra is present and skips where it cannot.

Local runs could not have caught this - the dev environment has the extra.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:23:21 -07:00
Victor KuznetsovandClaude Opus 5 b0ca2054f6 Keep only the two-stage profiles and make CUDA a precondition
qwen-zimage becomes the default and sdxl-zimage the only alternative. The
controlnet, sdxl, qwen and default profiles are gone, and with them the CPU and
MPS paths for invisible-watermark removal: neither matched the two-stage
recipe's face preservation, so keeping them advertised a quality this library no
longer delivers. Visible-mark removal and every identify command still run
anywhere.

Retired names are rejected rather than remapped. Silently routing --pipeline
sdxl onward would run an old script at a different strength, on a different
model, at a different quality, and report success.

CUDA is now checked when the remover is constructed instead of when the model
loads. Auto-detection cheerfully returned mps on a Mac, so the failure arrived
several layers down, after the dependency check and the pipeline import, in a
message naming whichever internal pipeline happened to raise. _DEVICES collapses
to {"cuda"} and the cpu/mps float32 branch goes with it.

resolve_strength stays total. It briefly returned None for qwen-zimage, meaning
"ask the resolution curve", which pushed a branch onto both callers and left one
of the two strength policies outside the strength module; the CLI copy had
already grown an `or 0.0` guarding a path its own comment called unreachable. It
now takes the image size and answers for both profiles, so the displayed value
cannot drift from the executed one.

Deletion fallout removed with it: img2img_runner and progress.py (the MPS
recovery path and its progress monitor had no callers left), viable_steps, the
fp16 degenerate-output retry, the fp16 VAE fix, and the Qwen img2img call
builders. try_empty_device_cache moved into watermark_remover rather than
leaving a module whose docstring outlived its code. _HAS_DIFFUSERS routes
through optional_deps.module_available, which is what the rest of the library
uses and what correctly rejects a pruned namespace remnant.

--steps, --guidance-scale and --model now have exactly one legal value each and
are still accepted at parse time, then rejected in remove(). Their help text
says so, but validating them beside the option would be better.

Not addressed, and worth its own decision: invisible_engine forces
min_resolution to 0 for both profiles, so the --min-resolution floor, --upscaler,
_esrgan_upscale, upscaler.py and the esrgan extra are all unreachable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 12:16:14 -07:00
Victor KuznetsovandClaude Opus 5 3d43bac6a5 Load the face stage in its own dtype, not the global stage's
The sdxl-zimage profile crashed on every image containing a face. The remover
gives it torch.float16, because SDXL ships fp16 weights and an fp16-safe VAE,
and that dtype reached the inherited _load_zimage while _zimage_vram_config
hardcodes bfloat16 for its offload, onload and computation dtypes. Z-Image was
therefore built bf16 and handed fp16 latents, dying in the VAE with "Input type
(c10::Half) and bias type (c10::BFloat16) should be the same". Zero-face inputs
never enter _run_faces, so the profile passed every timing run it was given, and
its tests avoid model downloads, so nothing exercised the loader.

Every face-stage loader now reads _face_stage_dtype(), the computation dtype of
the VRAM config it is paired with. SAM is routed through it too: it never
crashed, since it casts its own inputs and leaves through .float(), but it read
the same field and would have re-landed the bug for the next profile with a
different global dtype. That field was never the global dtype on this profile
anyway - _load_sdxl hardcodes fp16 for its own ControlNet, VAE and pipeline - so
its only readers were face-stage code.

This also fixes a second instance transitively: the persisted prompt-embedding
cache restores payloads at the DiffSynth pipe's dtype, which was fp16 into a
bf16 stack before this change.

For qwen-zimage the whole change is a strict no-op. The remover already hands it
bfloat16, the same value _face_stage_dtype() returns, so production is
untouched; verified on an H100 against the deployed pin.

The guard asserts the dtype the Z-Image and SAM loaders actually receive rather
than comparing the accessor to the config it derives from, which would restate
the implementation and pass for any consistently wrong value. Both assertions
were mutation-tested against the pre-fix line.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 09:41:52 -07:00
github-actions[bot] 40853ae102 Sync conda recipe with v0.23.0 2026-08-03 04:31:36 +00:00
Victor KuznetsovandClaude Opus 5 7234cf7f4d Release 0.23.0
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:29:34 -07:00
Victor Kuznetsov db33823d8e Merge pull request #74 from wiltodelta/feat/sdxl-zimage-profile
Add the sdxl-zimage profile: the same recipe on an SDXL global stage
2026-08-02 21:27:54 -07:00
Victor KuznetsovandClaude Opus 5 a9e64b9628 Add the sdxl-zimage profile: the same recipe on an SDXL global stage
SdxlZImagePipeline subclasses QwenZImagePipeline and overrides only _run_global
and preload, so the face stage is inherited rather than copied and cannot drift
between the two profiles. A test asserts the shared methods are the same objects.

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

Strength is architecture-bound too, which is the easy mistake and cost two wrong
conclusions before it was caught. An SDXL global pass leaves SynthID at the
strength Qwen needs: through the Gemini app on a native 2816x1536 original, 0.154
is FOUND while 0.20, 0.25 and 0.30 are clean. The profile therefore takes a flat
vendor policy - OpenAI 0.15, Gemini 0.25, unknown following Gemini - rather than
resolution_adaptive_denoise, because flat values are what was measured and no
size dependence has been established for this stage.

requested_steps exists because the runtimes truncate differently: DiffSynth sets
sigma_start = denoising_strength and runs every requested step, while Diffusers
img2img truncates the step count, so four steps at 0.15 executes zero and returns
a bare VAE round-trip.

Also records both measured provider boundaries for the shipped qwen-zimage curve
- OpenAI detected at 0.06 and clean from 0.08, Gemini detected at 0.08 and clean
from 0.10 - together with the two low-resolution Gemini verdicts that explain why
the curve's sub-1 MP rungs are not under-driven despite looking short against a
boundary measured at 4.33 MP. The curve is left unchanged; nothing measured fails.

The profile is not deployed and not production-ready: every verdict so far comes
from one fixture and one seed.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 21:23:43 -07:00
Victor Kuznetsov a4d8de4367 Merge pull request #73 from wiltodelta/docs/face-stage-compositing
Document how the face stage actually composites
2026-08-02 20:47:57 -07:00
Victor KuznetsovandClaude Opus 5 af51afb586 Document how the face stage actually composites
The parameter names invite a misreading. _run_faces regenerates the ENTIRE
expanded crop with Z-Image and only then cross-fades on a blurred SAM mask, so
the generation is conditioned on a fully noised neighbourhood and the pixels the
mask later discards were regenerated too.

Records the alternative that has never been tried here - passing the mask into
the sampler as a latent noise mask, so only masked pixels are denoised and the
edge transition happens inside the generation - because the face stage is the
largest measured quality contributor, worth 3.5 dB and 6.1 dB inside the face
boxes on the two fixtures that have one.

Also records that FACE_DENOISE_SCALE = 0.5 is coupled to this compositing choice
rather than independently calibrated: regenerating a whole crop and blending is
stronger than masked denoising, so changing the compositing without revisiting
the scale would move output strength by about a factor of two.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 20:44:20 -07:00
Victor Kuznetsov f51c5b09dd Merge pull request #72 from wiltodelta/docs/qwen-zimage-denoise-boundary
Record the qwen-zimage global denoise boundary against the Gemini oracle
2026-08-02 20:16:58 -07:00
Victor KuznetsovandClaude Opus 5 9b07b3f0ec Validate the bottom of the qwen-zimage denoise curve against Gemini
Downscaled a Gemini original to 0.57 and 1.40 MP and ran the deployed worker on
both, so the profile applied its own low-end strengths of 0.0896 and 0.1066. Both
come back clean in the Gemini app.

That rules out the failure mode the ladder raised: 0.08 failed at 4.33 MP, and the
curve sends sub-1 MP images to 0.084-0.094, which looked like it might mean small
Gemini uploads were under-processed in production. They are not, at these sizes.

Written as validation of the shipped curve rather than as evidence that the
boundary moves with resolution. 0.0896 sits inside the untested gap at 4.33 MP,
where only 0.08 and 0.10 were probed, so it may clear at both sizes; the direction
of any resolution dependence stays unproven. These are also downscales rather than
natively small Gemini outputs, which remain untested.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:53:51 -07:00
Victor KuznetsovandClaude Opus 5 84583dd479 Drop the unproven claim that Gemini removal is resolution-sensitive
The document asserted it in two places and recommended capping Gemini at 1536
with 0.30, or native-calibrating to ~0.35+. Nothing measured that, and the one
relevant measurement points the other way: the 2026-06-14 deployed-worker re-test
cleared Gemini at 0.15 on two NATIVE 2816x1536 images, the same rung as capped
1536. The document already recorded that as contradicting the "native >= 0.30"
guess, then kept the guess anyway in the historical-certification paragraph and
restated it as fact in the strength floors.

Replaced with what was measured, plus an explicit statement that the direction is
unproven and the low-resolution end has never been through the Gemini oracle on
any pipeline.

Also removes the same appeal from the qwen-zimage denoise-boundary note added
earlier in this branch, which had used the unproven trend as reassurance that the
bottom of the adaptive curve is safe. It is not reassurance; it is an open
question, and it is now written as one.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:49:44 -07:00
Victor KuznetsovandClaude Opus 5 56d7aff3a1 Record the qwen-zimage global denoise boundary against the Gemini oracle
A ladder on one native 2816x1536 Gemini original at seed 0, verified through the
Gemini app, puts the boundary between 0.08 and 0.10: 0.154, 0.12 and 0.10 read
clean, 0.08 reads SynthID FOUND. Fidelity rises monotonically all the way down,
so 0.10 buys +1.54 dB whole-image and +0.98 dB inside the face boxes over the
0.154 the profile ships for that size.

Recorded with the two constraints that stop it being acted on directly. It
brackets rather than calibrates - one image, one seed, and shipping the lowest
clean rung means shipping at the measured cliff edge. And the untested end is the
bottom, not the top: every Gemini oracle fixture is 2816x1536, so the Google-side
certification only ever covered 0.154, while the curve sends sub-1 MP images to
0.084-0.094. The resolution trend already recorded in this document says lower
processing resolution needs less strength, which is the shape the curve has, but
that is an inference and no small Gemini original has ever been through the
oracle. Downscaling is valid test material since SynthID survives it by design.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 19:46:12 -07:00
Victor Kuznetsov 40cd122683 Merge pull request #71 from wiltodelta/feat/static-prompt-embedding-cache
Serve the static prompt embeddings from a persistent cache
2026-08-02 15:39:37 -07:00
Victor KuznetsovandClaude Opus 5 39523aba09 Serve the static prompt embeddings from a persistent cache
Both qwen-zimage stages prompt with module constants, and at CFG 1.0 DiffSynth's
PipelineUnitRunner reuses the positive embedding for the negative side rather
than encoding it, so exactly one embedding per stage is ever computed. Persist it
and neither text encoder has to be loaded at all.

Measured on an H100 volume: this drops 15.45 GiB (Qwen2.5-VL) and 7.49 GiB
(Z-Image) of an 87.6 GiB per-request read, worth a median 11.76 s and 4.10 s of
load time paired within five containers. A nine-face fixture returned
sha256 c8567e11077de32a both with and without the cache, so the output is
byte-identical and the provider-oracle clearance is untouched.

The cache key carries the cache version, model id, pipeline output params and the
exact prompt, so a model bump or a prompt edit recomputes instead of reading a
stale embedding. The write is atomic because a torn file must never read back as
a hit, and a miss after the text encoder was already dropped raises rather than
calling a model that is not loaded.

_model_cache_dir now prefers HF_HOME: on a scale-to-zero runner that is the only
persistently mounted path, so anything below it is re-derived every request. The
YuNet download follows the same root.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-02 15:36:55 -07:00
Victor KuznetsovandClaude Opus 5 94730f77d3 Merge the v0.20.1 residency fix into main
Joins fix/qwen-vram-residency, which carried the global-stack residency
change on the v0.20.1 line that raiw-app pins. The change itself is already
on main via port/qwen-vram-residency-main, reapplied there because the
package layout moved under _internal/ in between, so this merge is history
only and its tree is identical to the commit before it.

Conflicts resolved in favour of main throughout, including dropping the
noai/watermark_remover.py the branch resurrected; that module now lives at
_internal/watermark_remover.py.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:53:40 -07:00
Victor KuznetsovandClaude Opus 5 36e3743289 Keep the qwen-zimage global stack resident on a card that can hold it
Port of the same change made on the v0.20.1 line, reapplied here because the
package layout moved under _internal/ in the meantime.

The mandatory Qwen stack was configured to offload to disk unconditionally.
DiffSynth implements that 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 every pass, so
each generation paid a full model reload. That is the right trade on a
consumer card, where it is what makes a 20B model runnable at all, and pure
waste on a card that can simply hold the stack.

Residency is now resolved from total VRAM, mirroring how the optional
Z-Image face stack is already gated. Above the floor the config passes no
"disk" value anywhere, which is what actually disables the behavior:
DiffSynth latches disk_offload once from offload_dtype, so pointing every
device at CUDA while leaving the sentinel would keep both the meta-drop and
the re-read.

Measured on an H100: a warm global pass went from 37.3s at 0.8 GiB resident
to 2.2s at 28.7 GiB, with both stacks resident peaking at 48.0 GiB of 79.2.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:22:52 -07:00
Victor KuznetsovandClaude Opus 5 a64d81f446 Keep the qwen-zimage global stack resident on a card that can hold it
The mandatory Qwen stack was configured to offload to disk unconditionally.
DiffSynth implements that 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 every pass, so
each generation paid a full model reload. That is the right trade on a
consumer card, where it is what makes a 20B model runnable at all, and pure
waste on a card that can simply hold the stack.

Residency is now resolved from total VRAM, mirroring how the optional
Z-Image face stack is already gated. Above the floor the config passes no
"disk" value anywhere, which is what actually disables the behavior:
DiffSynth latches disk_offload once from offload_dtype, so pointing every
device at CUDA while leaving the sentinel would keep both the meta-drop and
the re-read.

The floor is set equal to the face floor rather than lower because that is
the configuration measured with both stacks resident; a tighter gate is
plausible but unvalidated. cpu_offload now forces both stacks to stream, so
a caller asking for low VRAM no longer gets the larger stack pinned anyway.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 21:06:13 -07:00
Victor Kuznetsov 9c71dc9c79 Refresh dependencies and compatibility guards 2026-07-31 17:24:05 -07:00
Victor Kuznetsov be7553b55f Merge pull request #70 from wiltodelta/feat/video-watermarks
Complete product video watermark pipeline
2026-07-31 17:00:56 -07:00
Victor Kuznetsov a6c0c1c6f0 Rewrite internal watermark pipeline and preserve behavior 2026-07-31 16:53:41 -07:00
Victor Kuznetsov 7c922e5133 Preserve one encoded frame per input frame 2026-07-31 15:31:00 -07:00
Victor Kuznetsov 7e63856a90 Split video encoding from audio muxing 2026-07-31 15:22:41 -07:00
Victor Kuznetsov 9b9f9c6ced Open source input before frame pipe 2026-07-31 15:07:15 -07:00
Victor Kuznetsov 7818e60824 Prevent ffmpeg stderr backpressure 2026-07-31 14:59:57 -07:00
Victor Kuznetsov 0fac8cd000 Bound ffmpeg filter threads 2026-07-31 14:43:01 -07:00
Victor Kuznetsov 166dab9d03 Bound ffmpeg video encoder threads 2026-07-31 14:33:50 -07:00
Victor Kuznetsov 7fb0e65455 Trace stalled Linux video encoder 2026-07-31 14:24:08 -07:00
Victor Kuznetsov 1fd859b58d Prevent ffmpeg pipe probe deadlock 2026-07-31 12:05:42 -07:00
Victor Kuznetsov f9a4e2ba10 Diagnose stalled video end-to-end test 2026-07-31 11:54:45 -07:00
Victor Kuznetsov 0e7a52820b Bound full-clip video CI runtime 2026-07-31 11:30:57 -07:00
Victor Kuznetsov de3ed75de5 Merge main into video watermark pipeline 2026-07-31 11:13:59 -07:00
Victor Kuznetsov 5767c1d7b3 Finalize video package boundary and product wording 2026-07-31 10:52:44 -07:00
github-actions[bot] 63d7cfa0a5 Sync conda recipe with v0.22.0 2026-07-31 17:43:40 +00:00
Victor Kuznetsov 6315b58628 feat: complete product video watermark pipeline 2026-07-31 10:42:39 -07:00
Victor Kuznetsov 08dc078d91 Release 0.22.0 with composable feature extras 2026-07-31 10:39:13 -07:00
Victor Kuznetsov 8975916b11 Harden automatic video watermark removal for product use 2026-07-30 18:36:04 -07:00
Victor Kuznetsov 9c9e81c756 Compact CLAUDE.md and route development guidance 2026-07-30 18:32:25 -07:00
Victor Kuznetsov fc177bacde Correct OpenCV Dependabot compatibility cutoff 2026-07-30 18:18:49 -07:00
Victor Kuznetsov 0c4b26bb77 Fix OpenCV Dependabot compatibility bound 2026-07-30 16:29:29 -07:00
Victor Kuznetsov 3d941069e5 Keep Dependabot updates within compatible dependency lines 2026-07-30 16:21:50 -07:00
Victor Kuznetsov 4e97076b22 Merge external metadata evidence into main 2026-07-30 12:49:06 -07:00
Victor Kuznetsov 07b678fa4c Add Hailuo, Kling, AVI, and FLV video coverage 2026-07-29 23:30:33 -07:00
Victor Kuznetsov 63cde7f32a Add streaming video SynthID regeneration 2026-07-29 22:03:23 -07:00
Victor Kuznetsov c9b8585105 Add video SynthID experiment harness 2026-07-29 21:03:46 -07:00
Victor Kuznetsov 41b6fd0682 Add Seedance and Dola video removal 2026-07-29 17:56:52 -07:00
Victor Kuznetsov f330dd9c94 Add video metadata and visible watermark removal 2026-07-29 17:28:11 -07:00
github-actions[bot] a67b209d66 Sync conda recipe with v0.21.2 2026-07-29 23:48:36 +00:00
Victor Kuznetsov 7c9bec0eba Ignore collector diagnostics in metadata evidence 2026-07-29 16:46:42 -07:00
github-actions[bot] 37f3b3b1c9 Sync conda recipe with v0.21.1 2026-07-29 21:34:11 +00:00
Victor Kuznetsov 4ef180541f Add external metadata evidence ingestion 2026-07-29 14:32:25 -07:00
github-actions[bot] c013704ded Sync conda recipe with v0.21.0 2026-07-29 20:17:17 +00:00
Victor Kuznetsov 408c4c9d5d Separate provenance extraction and detection, release 0.21.0 2026-07-29 13:13:39 -07:00
github-actions[bot] 28d23a3023 Sync conda recipe with v0.20.2 2026-07-28 19:14:22 +00:00
Victor Kuznetsov 59823b2eaf Recover malformed metadata containers, release 0.20.2 2026-07-28 12:12:26 -07:00
Victor Kuznetsov 1d333fe855 Use a dedicated ComfyUI release token 2026-07-26 12:11:35 -07:00
Victor Kuznetsov 5ea160f4ce Include ComfyUI in library releases 2026-07-26 11:56:09 -07:00
github-actions[bot] 643557b7bf Sync conda recipe with v0.20.1 2026-07-26 18:41:23 +00:00
Victor Kuznetsov e562b440db Improve Qwen face fidelity, release 0.20.1 2026-07-26 11:39:29 -07:00
Victor Kuznetsov ab525c1b90 Add global-only preload for qwen-zimage 2026-07-26 10:52:26 -07:00
Victor Kuznetsov 685f0679bd Automate conda recipe synchronization 2026-07-25 21:57:22 -07:00
Victor Kuznetsov d21dc62bbb Sync conda recipe with v0.20.0 2026-07-25 21:32:52 -07:00
Victor Kuznetsov 03cd00f132 Restructure documentation, validate metadata removal, consolidate assets 2026-07-25 21:08:04 -07:00
Victor Kuznetsov 214c9bb3e7 Add Tencent Yuanbao visible watermark removal 2026-07-25 20:52:15 -07:00
Victor Kuznetsov 952ce822fd Merge pull request #64 from salomonMuriel/feature/cuda-cpu-offload
Add --cpu-offload flag for low-VRAM CUDA cards
2026-07-25 19:08:24 -07:00
Victor Kuznetsov fbca7f7ed9 Sync CPU offload with dependency updates 2026-07-25 19:05:41 -07:00
Victor Kuznetsov 51d222f4ea Merge pull request #63 from wiltodelta/dependabot/uv/minor-and-patch-e56a0901c7
chore(deps): bump the minor-and-patch group across 1 directory with 5 updates
2026-07-25 19:04:06 -07:00
Victor Kuznetsov d6cf5f4e84 Sync dependency updates with current main 2026-07-25 19:01:40 -07:00
Victor Kuznetsov b8b69d93be Finish CPU offload support on current main 2026-07-25 18:44:44 -07:00
Victor Kuznetsov 88949c3c30 Isolate Qwen Z-Image tests from GPU extras 2026-07-25 17:13:04 -07:00
Victor Kuznetsov 43312b04eb Exclude Markdown from Ruff formatting 2026-07-25 17:10:47 -07:00
Victor Kuznetsov b0419a9d8a Merge Qwen Z-Image pipeline into main 2026-07-25 17:00:05 -07:00
Victor Kuznetsov b6204a24ce Add high-fidelity Qwen Z-Image removal pipeline 2026-07-25 16:53:22 -07:00
Victor KuznetsovandClaude cdec348349 Add v2 forensic features to the scorer, split without hash leakage
Train now dedups by sha256 (earliest date wins), splits by hash group,
and drops post-cutoff rows whose hash was seen in training, so neither
holdout reports memorized duplicates. The feature schema is explicit
(v1 = the original 97 structural features, v2 adds CFA peaks, DCT AC
histograms and JPEG quant/Huffman/scan stats), stored in the bundle and
read back at scoring time; legacy bundles default to v1. Vectors are
fixed-width with NaN padding, so a sparse record no longer shifts every
column. Scoring runs in batches instead of one predict_proba per record.

Co-Authored-By: Claude <noreply@anthropic.com>
2026-07-24 19:24:39 -07:00
dependabot[bot] bb9f9c959d chore(deps): bump the minor-and-patch group across 1 directory with 5 updates
Bumps the minor-and-patch group with 5 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [pillow-heif](https://github.com/bigcat88/pillow_heif) | `1.4.0` | `1.5.0` |
| [c2pa-python](https://github.com/contentauth/c2pa-python) | `0.36.0` | `0.37.1` |
| [transformers](https://github.com/huggingface/transformers) | `5.13.1` | `5.14.1` |
| [huggingface-hub](https://github.com/huggingface/huggingface_hub) | `1.23.0` | `1.24.0` |
| [ruff](https://github.com/astral-sh/ruff) | `0.15.21` | `0.16.0` |



Updates `pillow-heif` from 1.4.0 to 1.5.0
- [Release notes](https://github.com/bigcat88/pillow_heif/releases)
- [Changelog](https://github.com/bigcat88/pillow_heif/blob/master/CHANGELOG.md)
- [Commits](https://github.com/bigcat88/pillow_heif/compare/v1.4.0...v1.5.0)

Updates `c2pa-python` from 0.36.0 to 0.37.1
- [Release notes](https://github.com/contentauth/c2pa-python/releases)
- [Changelog](https://github.com/contentauth/c2pa-python/blob/main/docs/release-notes.md)
- [Commits](https://github.com/contentauth/c2pa-python/compare/v0.36.0...v0.37.1)

Updates `transformers` from 5.13.1 to 5.14.1
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.13.1...v5.14.1)

Updates `huggingface-hub` from 1.23.0 to 1.24.0
- [Release notes](https://github.com/huggingface/huggingface_hub/releases)
- [Commits](https://github.com/huggingface/huggingface_hub/compare/v1.23.0...v1.24.0)

Updates `ruff` from 0.15.21 to 0.16.0
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.21...0.16.0)

---
updated-dependencies:
- dependency-name: c2pa-python
  dependency-version: 0.37.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: huggingface-hub
  dependency-version: 1.24.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: pillow-heif
  dependency-version: 1.5.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.15.22
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: transformers
  dependency-version: 5.14.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-23 23:25:34 +00:00
Victor Kuznetsov 11afe68411 chore(release): v0.19.0 2026-07-23 16:20:27 -07:00
Victor Kuznetsov c11181b9b5 Detect and attribute Bria AI, fal.ai, and Apple Photos Clean Up
Corpus-mined vendor gaps (42k-file metadata scan, 2026-07-23):
- Bria AI signs C2PA as "Bria Artificial Intelligence" with source type
  empty (no trainedAlgorithmicMedia), so identify was completely blind;
  registered with asserts_ai like Dreamina.
- fal.ai ("fal - Features & Labels Inc.", fal-ai/<model> generators)
  was detected via the source type but never attributed; registered,
  also asserts_ai as a pure generative platform.
- Apple Photos Clean Up (Apple Intelligence object removal) was detected
  as a generic made-with-AI tag; now attributed as an AI edit via the
  photoshop:Credit marker, and the credit value joins
  AI_GENERATOR_TOKENS so removal strips it in parity.
Each new test was verified red without its fix.
2026-07-23 16:15:48 -07:00
Victor Kuznetsov eb544806f6 Add AI-generation scorer trained on metadata-labeled corpus
scripts/ai_score.py trains a gradient-boosted classifier on labels
derived from the scan_dataset metadata (C2PA AI generators, TC260,
local pipelines vs camera/screenshot/editor output) and scores every
file from pixel and container statistics alone, so metadata-stripped
files still get a score. Temporal holdout on the production corpus:
AUC 0.966 / AP 0.988. CLIP ViT-L/14 was evaluated as an alternative
and lost on both accuracy (0.82) and cost, so the model uses the
structural features the scanner already collects; scoring is CPU-only
and I/O-bound.
2026-07-23 16:01:35 -07:00
Salomón MurielandClaude Opus 4.8 39fdd59f6c Add --cpu-offload flag for low-VRAM CUDA cards
The invisible/SynthID diffusion pass loads the whole SDXL fp16 pipeline
into VRAM via `pipeline.to("cuda")`. On an 8 GB card the weights alone
(~7 GB) leave no room for activations, so the run OOMs and there is no
in-tool way to recover short of falling back to CPU (~9 min/image).

Add an opt-in `--cpu-offload` flag (default off) that calls diffusers'
`enable_model_cpu_offload()` instead: submodules are streamed to the GPU
on demand, dropping peak VRAM to roughly the largest single submodule at
the cost of per-step transfers. CUDA-only; a no-op on cpu/mps. Threaded
through `invisible`, `all`, and `batch` to keep the knob set identical
across the three, mirroring the existing `--device`/`--pipeline` options.

Measured on a GTX 1070 Ti (8 GB): `invisible --pipeline sdxl --cpu-offload`
runs the SynthID scrub on-GPU in ~2.5 min vs ~9 min on CPU, where the
default full-VRAM path OOMs.

Test drives the placement decision with a mock pipeline (no model/GPU),
gated on torch so it runs under the `gpu` extra and skips the core CI
matrix, consistent with the model-running test policy.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-23 16:06:43 -05:00
Victor Kuznetsov dfe69772cd Polish the scanner after review: shared b64 helper, tri-state pixel mode
Route the pixel layer's base64 through the capped _b64 helper, replace
the pixels/pixels_full booleans with one pixel_mode parameter, record a
pixel skipped marker on oversized files, read EXIF from the already-read
bytes instead of re-reading the file, chunk the noise residual
convolution and replace the mgrid with 1D broadcast in the FFT features
(bit-identical values, ~300 MB less peak memory at 2048px), and fix the
raw-only docstring to account for the derived pixel layer.
2026-07-23 11:54:08 -07:00
Victor Kuznetsov 68fb3dded6 Fix numpy-free metadata mode after the pixel-layer merge
Guard module-level numpy touchpoints so the metadata-only mode imports
and runs without numpy installed (deferred annotations, lazy DCT basis);
pixel modes now exit early with a clear numpy requirement message.
2026-07-23 11:13:13 -07:00
Victor Kuznetsov 47ef60c2a1 Add optional pixel forensics layer to the dataset scanner
--pixels adds aggregate, non-reconstructable pixel statistics (block-DCT
histograms with Benford deviation, high-pass noise stats, FFT band
energies and CFA peaks, ELA stats, gradient and color histograms) plus
per-section timing for pipeline latency planning. --pixels-full adds the
privacy-lifting artifacts behind an explicit flag (perceptual hash,
128px thumbnail, coarse ELA/noise-residual/FFT-phase maps). Expensive
maps are computed once and shared between the scalar and artifact paths
(2.2x speedup vs the first split implementation). numpy is required only
for the pixel modes; the metadata-only mode stays numpy-free.
2026-07-23 11:06:45 -07:00
Victor Kuznetsov a981eb84b1 Make the dataset scanner raw-only and harden format sniffing
Strip all derived output: the library verdict, the XMP edit-trail
parse, and the IJG quality estimate. The script now collects raw bytes
plus mechanical container decodes only, is fully standalone (no
remove_ai_watermarks import), and resumes interrupted runs. Add GIF,
BMP, and TIFF magic bytes to sniff_format (GIF files in the wild were
reported as unknown). Validated on the full 42k-file corpus: 100%
coverage, zero error records.
2026-07-22 20:32:36 -07:00
Victor Kuznetsov df62800bf1 Add standalone dataset metadata/forensics scanner script
Single-file read-only scanner producing one JSONL record per image:
AI verdict via identify (metadata-only, no visible marks), full EXIF/
IPTC/XMP/PNG/JPEG/WebP/ISOBMFF inventories, C2PA store, JPEG encoder
forensics (quant tables, IJG quality, scan script, Huffman, subsampling),
XMP edit-trail fields, hashes/timestamps, macOS download provenance,
Live Photo pairing id. Runs standalone (pillow/piexif/c2pa-python) with
the library optional; supports gzip output, resume after interruption,
and head-only scanning of oversized files.
2026-07-22 17:53:02 -07:00
Victor Kuznetsov 80cdaff2d5 chore(release): v0.18.0 2026-07-22 13:17:00 -07:00
Victor Kuznetsov f1a5eecf98 Register RunningHub, Baidu, and LibLibAI visible marks; park Qingyan and MiniMax (measured)
New engines, each calibrated on its TC260 USCC cohort and validated by a
full-corpus sweep (42009 files):
- runninghub: top-left corner (new corner="tl"), faint mid-gray text via
  the new raw-grayscale "gray" detection front-end, anchor-position gate
- baidu: text-run-only template (pill is a bright-blob magnet), load-bearing
  Doubao+Qwen rival margins, corner-extended footprint for the white tag
- liblib: bottom-center (new corner="bc"), Arial silhouette (font is the
  discriminative lever against latin UI text), logo-extended footprint

Qingyan parked (no clean-arm separation at any render/box), MiniMax/Hailuo
parked (1 visible frame, the xinghui rule); silhouettes kept as starting
points.
2026-07-22 13:03:03 -07:00
Victor Kuznetsov ba29eccc45 chore(release): v0.17.0 2026-07-22 09:15:07 -07:00
Victor Kuznetsov 5d63b9161f Register the Kling 可灵AI 3.0 visible text mark; park Yuanbao and cat-logo (measured)
Kling (USCC cohort 91110108335469089C, n=30): kling_engine.py, gate 0.35
(clean p99 0.304 / max 0.320), strict-only, unimodal 0.12/short on the
shared ladder, fitted locate box, no rival margin (crossfire 1/400 doubao
below gate, 0 jimeng, 0 clean), parity 9/9 detect->fill->re-detect.
Suppresses the jimeng pill like doubao/qwen. identify gains visible_kling.

Yuanbao: measured negative -- the two-line italic block does not separate
from clean corners on either front-end at any render/box/font setting;
the fitted recipe stays in render_vendor_silhouettes.py MARK_OPTS.

cat-logo: cohort has only 2 unique carriers, parked on evidence; the
draw_catlogo silhouette already separates (0.50 vs clean max 0.333), so
registration is a gate pick once more uniques arrive.

vendor_mark_calibrate: --fit-geometry takes locate-box overrides (two-line
marks were clipped by the inherited box) and the aspect sweep reaches 0.62.
2026-07-22 08:49:06 -07:00
Victor Kuznetsov 3a659ededc chore(release): v0.16.0 2026-07-21 18:35:04 -07:00
Victor Kuznetsov 008319c6a5 Register the Qwen 千问AI生成 visible text mark
Calibrated on the 117-frame TC260-producer cohort (vendor_cohort_harvest +
vendor_mark_calibrate, both committed here): per-mark 2-rung ladder
(0.78, 1.27) for the two measured size modes, fitted locate box (the mark
sits ~0.025 of the short side off the edge; doubao's box clipped the first
glyph), measured template aspect 0.26, gate 0.45 (clean p99 0.301).
Strict-only (the sub-gate band is non-Qwen banners), no rival margin
(0 cross-fires on 400 doubao / 298 jimeng / 286 clean frames).
83/83 real marks detector-clean after cv2 fill.

TextMarkConfig gains a per-mark ladder field; the shipped 3-rung default
is unchanged for every other mark.
2026-07-21 16:41:16 -07:00
Victor KuznetsovandClaude Opus 4.8 1d93704a4d Restructure the open items into a continuation list for the next session
The section had accumulated incremental edits: defects numbered out of order
(1, 2, 3a, 3, 5, 4), closed items mixed into the open table, and "what to do
next" spread across four subsections that partly repeated each other.

Now it opens with START HERE -- the prioritized next actions and the reason each
sits where it does -- followed by open defects only, renumbered 1-5. Closed items
move to their own subsection, keeping the faint-mask post-mortem because how it
escaped both parity and its own regression test is the instructive part.

Adds a table of the completed full runs with their artifacts and row counts, each
verified against the file on disk. That exists because the artifacts are the
answer to "did we actually cover X" -- relaunching a sweep costs hours and returns
nothing new, and this session nearly did exactly that before checking. It also
names the two fast confirmations (real_examples_e2e ~2 min, robustness_suite
~3 min) that a later change should run instead.

Adds invisible_engine.py:346 as an open defect: it still discards imwrite's
success flag, the same shape as the crash fixed in the previous commit.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-21 08:31:06 -07:00
Victor KuznetsovandClaude Opus 4.8 633fc3aa52 Report failed writes instead of crashing, and reject directories at parse time
The Tier E adversarial sweep (new, scripts/robustness_suite.py) drove the real CLI
over truncated, corrupt, zero-byte, absurdly-shaped and bomb inputs, unicode and
RTL paths, hostile output directories and concurrent runs. It found two crashes;
the /simplify review then reproduced a third and worse one.

1. A FAILED WRITE CRASHED ON THE SIZE REPORT. image_io.imwrite is contractually
   non-raising and returns False, but write_bgr_with_alpha discarded that bool and
   returned None, so no caller could tell a failed write from a successful one.
   Every write site then ran output.stat() to print the size, so a read-only
   destination died with a bare FileNotFoundError pointing at the stat rather than
   the write. The fix is deliberately NOT uniform: single-image commands exit via
   the new cli._write_output_or_exit; api._write_visible_result RAISES so a library
   caller gets an accurate error instead of a confusing FileNotFoundError from the
   downstream metadata strip; and the batch sites raise but never SystemExit,
   because the batch loop counts per-image exceptions and aborting would kill the
   whole run.

2. BATCH LOST DATA SILENTLY. Into a read-only output directory it wrote ZERO files
   for 2 inputs and exited 0 -- no traceback, no error, an empty output directory a
   wrapping service would read as a completed run. The robustness harness could not
   see this class at all, since it scored exit codes and traceback markers and this
   failure has neither; it now asserts on the artifacts written.

3. A DIRECTORY PASSED AS THE IMAGE crashed the metadata scanner with
   IsADirectoryError, because click.Path(exists=True) accepts directories. Fixed
   with dir_okay=False on all six source arguments, so argument parsing refuses it.

Also adds Tier B4 (scripts/resource_ceilings.py): peak RSS per fill backend from
1 MP to 25 MP, one fresh process per cell. migan 603->775 MB and lama 4679->4779 MB
are flat in input size, confirming the crop-around-the-mask design and both
documented figures; cv2 is the only backend that grows (74->440 MB, 5.9x). The
harness's own no-op check originally allocated a full-frame temp before reading
peak RSS and inflated the numbers with input size -- it now compares only the mask
box, and the conclusion survived re-measurement.

And scripts/real_examples_e2e.py, which drives every command over real corpus
examples and checks the outcome rather than the exit code: 6/6 provenance classes
identified, 10/10 metadata strips re-scan clean, all three fill backends write,
diffusion on MPS writes genuinely changed images. It records samsung as a real
partial (the faintest mark, 0.431 -> 0.404 against a 0.40 gate on the weakest of
its 3 corpus positives) and treats the gated pill's refusal to act as correct.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 22:45:02 -07:00
Victor Kuznetsov c2afb92832 Merge pull request #61 from wiltodelta/dependabot/uv/minor-and-patch-d57359b79f
chore(deps): bump the minor-and-patch group with 4 updates
2026-07-20 15:58:12 -07:00
Victor KuznetsovandClaude Opus 4.8 52eb40c2ca Mask faint text marks with the detector's match box, not a response threshold
The faint-mask fallback added for the tophat front-end thresholded the
max-normalized uint8 response at 0.5 -- which selects every non-zero pixel,
not "half the peak" as its comment claimed -- and filled ~120% of the corner
box on textured frames. Measured on 14 real faint-path frames (cv2 fill,
detector re-run after): the detector's own best-match box fills a 58.7%-median
corner box vs 120.9% for the threshold, both 100% detector-clean. Detection and
the mask now read one method, _tophat_best, whose score gates detection and
whose argmax box bounds the fill, so the two cannot drift by construction --
which is how the mismatch arose. The 0.5 constant is deleted.

Parity could not catch this (a mask that fills everything is trivially
detector-clean) and the regression test could not either: its flat fixture
gives every threshold the same box, so mutating the constant to 99.0 stayed
green. The fixture now carries texture and asserts the mask area is bounded,
not merely non-empty; it reproduces the corpus number (127% pre-fix).

Also lands the Tier B2 verification harnesses that found and bounded this:
- detector_response.py: response curves (detected AND maskable per cell); found
  the size response is a comb, contrast is near-irrelevant, no unmaskable cells.
- ladder_headroom.py: measured that a denser scale ladder recovers 7.6% of
  misses for a 2.52%->3.05% false-fire rise, and the one landscape rung that
  helps is a geometry shift that helps and hurts equally (1.7:1) -- do not add.
- cjk_tail_probe.py: a generic shared-tail (AI生成) template does not separate
  uncovered vendors from clean corners (0.407 vs clean p99 0.298).

Records the visible-parity re-run confirming the earlier front-end fix (doubao
91.8% -> 99.3%), and dedups the thrice-written stamp forward model into one
fill_quality.composite.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 15:41:18 -07:00
Victor KuznetsovandClaude Opus 4.8 7d00debdca Record the full visible-parity run and the per-mark residual split
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:11:27 -07:00
Victor KuznetsovandClaude Opus 4.8 ac20cf3a3d Record open defects, unrun verification tiers and the stale torch triage
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:10:12 -07:00
Victor KuznetsovandClaude Opus 4.8 21d7e05014 Drop AGENTS.md, CLAUDE.md is the single source
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 11:02:09 -07:00
Victor KuznetsovandClaude Opus 4.8 cd16e8129d Resync AGENTS.md with CLAUDE.md
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:57:52 -07:00
Victor KuznetsovandClaude Opus 4.8 836d87ed68 Mask faint text marks the tophat front-end detects but binarization loses
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 10:57:08 -07:00
Victor KuznetsovandClaude Opus 4.8 c150180acf Add full-surface verification harnesses and corpus sweep plan
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 08:14:50 -07:00
Victor KuznetsovandClaude Opus 4.8 cfefd9d819 Remove assume_ai, add tophat front-end and rival margin, fix two CLI defects
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-20 08:14:50 -07:00
Victor Kuznetsov a8f3536d3e Refactor watermark detection and provenance handling 2026-07-16 17:40:46 -07:00
Victor KuznetsovandClaude Opus 4.8 9618ac93c8 docs: record where the HF Space source lives and how it deploys
The Space's demo code (app.py etc.) lives in a SEPARATE private repo,
wiltodelta/raiw-hf-space, which nothing documented -- so finding it cost a long
detour through the Space's commit authorship and a hunt for a write token that
never existed locally. Record it, plus the deploy flow that replaced the old
web-UI editing: push to that repo's main -> sync-to-hf.yml mirrors the files via
HfApi.upload_folder (adds a commit on top of the Space history, never a force
push). Also call out the two automations that both touch the Space so they are
not confused: sync-to-hf.yml ships demo-code changes, while this repo's
distribute.yml factory-rebuilds the Space on a library release so its
`remove-ai-watermarks>=` pin re-resolves.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-16 10:24:46 -07:00
dependabot[bot] 976935f5fe chore(deps): bump the minor-and-patch group with 4 updates
Bumps the minor-and-patch group with 4 updates: [torch](https://github.com/pytorch/pytorch), [transformers](https://github.com/huggingface/transformers), [huggingface-hub](https://github.com/huggingface/huggingface_hub) and [ruff](https://github.com/astral-sh/ruff).


Updates `torch` from 2.12.1 to 2.13.0
- [Release notes](https://github.com/pytorch/pytorch/releases)
- [Changelog](https://github.com/pytorch/pytorch/blob/main/RELEASE.md)
- [Commits](https://github.com/pytorch/pytorch/compare/v2.12.1...v2.13.0)

Updates `transformers` from 5.13.0 to 5.13.1
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.13.0...v5.13.1)

Updates `huggingface-hub` from 1.22.0 to 1.23.0
- [Release notes](https://github.com/huggingface/huggingface_hub/releases)
- [Commits](https://github.com/huggingface/huggingface_hub/compare/v1.22.0...v1.23.0)

Updates `ruff` from 0.15.20 to 0.15.21
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.20...0.15.21)

---
updated-dependencies:
- dependency-name: torch
  dependency-version: 2.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: transformers
  dependency-version: 5.13.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: huggingface-hub
  dependency-version: 1.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.15.21
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-15 08:46:35 +00:00
Victor KuznetsovandClaude Opus 4.8 71dae8fe25 chore(release): bump version to 0.15.3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:17:01 +03:00
Victor KuznetsovandClaude Opus 4.8 9fe12c3997 docs: note content-based strip routing + SDXL watermarker fixes
Document both fixes in CLAUDE.md (the metadata.py and watermark_remover.py
bullets). Also add a --backend flag to the visible-removal audit script so a
realistic quality pass can run the production MI-GAN fill instead of cv2
(removal SUCCESS is backend-independent, but only migan/lama reflect the
recovered-region quality a user actually gets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:16:56 +03:00
Victor KuznetsovandClaude Opus 4.8 183793fbf2 fix(invisible): disable diffusers SDXL watermarker on removal output
The SDXL removal pipelines (sdxl + controlnet) were built without
add_watermarker=False, so diffusers embedded its default open "Stable
Diffusion XL" DWT-DCT invisible watermark on every output whenever
invisible-watermark is installed (the detect extra). A watermark REMOVER was
therefore replacing one detectable AI watermark (SynthID) with another: the
cleaned output re-read as AI (identify -> "Open invisible watermark: Stable
Diffusion XL"), observed on the SynthID validation sample.

Both SDXL loaders now call a shared _disable_sdxl_watermarker helper (mirrors
_maybe_add_fp16_vae; the ControlNetModel sub-model and the Qwen loader never
call it, since only the pipeline accepts the kwarg). Verified end to end: the
affected outputs re-run clean (is_ai=None, no open watermark).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:16:49 +03:00
Victor KuznetsovandClaude Opus 4.8 7f22852593 fix(metadata): route strip output format by content, not extension
remove_ai_metadata chose its save format (and the lossless-JPEG fast path)
from the OUTPUT file extension. On the ~2% of real uploads whose extension
lies about their content (a PNG served as .jpg is the common case, ~0.9% of
the corpus), the default flow -- which inherits the source's own extension --
re-encoded a lossless PNG/WebP into a real JPEG, silently degrading the pixels
and breaking the "work with originals" invariant.

Sniff the actual container from magic bytes (_sniff_image_format, reusing the
12-byte head already read for the ISOBMFF check) and route on content: a
misnamed lossless source (source-extension format != content) is preserved in
its true format, while a correctly-named source still honors a deliberate
output-extension conversion (source.png -> output.jpg). The JPEG-lossless gate
is likewise content-gated.

Found by a new metadata-removal parity audit over the local corpus
(scripts/metadata_removal_audit.py): 18170/18173 carriers strip cleanly, and
this fix takes the 208 pixel-integrity failures (all misnamed PNGs) to 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:16:36 +03:00
Victor Kuznetsov d9204e2e1f chore(release): bump version to 0.15.2 2026-07-13 12:27:06 +03:00
Victor KuznetsovandClaude Opus 4.8 2bdaa09e5f fix(metadata): strip bare AIGC in APP11 and AIGC in a standard PNG text chunk
Full-corpus strip audit surfaced 24 china_aigc survivors on real uploads:
- 19 JPEG carried the bare AIGC{...} blob in APP11 (0xEB). That marker's branch
  in _jpeg_app_carries_ai only tested for a C2PA/JUMBF manifest and RETURNED, so
  a bare AIGC there slipped past the generic AIGC check. The specific
  C2PA(APP11)/XMP(APP1)/IPTC(APP13) checks now fall through to the generic
  _is_aigc_exif_value drop, which runs for every APP marker they did not claim.
- PNG carried the {"AIGC":{...}} block in a STANDARD text chunk (Description).
  _is_ai_key keeps that key, so removal now also drops a text value carrying an
  AIGC block (_is_aigc_exif_value broadened to accept str; wired into the PNG
  re-save value filter), in parity with aigc_label's detection.

Verified on the corpus: decodable china_aigc survivors 27 -> 0; the 3 remaining
are truncated files the strip fail-safe (v0.15.1) copies through by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:27:05 +03:00
Victor Kuznetsov 72f6734b45 chore(release): bump version to 0.15.1 2026-07-13 11:54:53 +03:00
Victor KuznetsovandClaude Opus 4.8 220803c4d0 fix(metadata): remove_ai_metadata is fail-safe on a truncated/corrupt image
PIL raises OSError decoding a truncated file, which crashed remove_ai_metadata
(the PNG/WebP PIL re-save path) -- a direct library caller like a web worker
500s on a partial upload. ~0.2% of the real upload corpus is truncated. The
strip now probes decodability first and, on failure, copies the input through
unchanged and returns rather than raising (we cannot strip what we cannot parse),
mirroring strip_c2pa_boxes' fail-safe. identify already handled these.

The CLI `metadata --remove` on an unreadable file therefore now exits 0 with the
input passed through, not a clean error (exit 1) -- `visible`, which must decode
to remove a mark, still exits 1. Test updated to the per-command contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:54:33 +03:00
Victor Kuznetsov 1dc6fee91a chore(release): bump version to 0.15.0 2026-07-13 10:50:20 +03:00
Victor KuznetsovandClaude Opus 4.8 a4c901ff39 fix: metadata-strip parity, input robustness, and detection/clash coverage
Bug fixes (each with a regression test):
- metadata strip parity across every marker placement: IPTC digitalSourceType
  in XMP, the Samsung post-EOI trailer, the China TC260 AIGC block in EXIF
  UserComment, a bare AIGC block in a non-standard APP segment, and the ISOBMFF
  EXIF path (AIGC + xAI) are all now stripped -- anything a scanner flags, the
  strip reaches
- Samsung genAIType detected when its trailer sits past the 512 KB scan window
  (file-tail read on large photos)
- crashes on edge inputs: Gemini detector on images with a short side < 16px,
  footprint_mask on a zero-size ndarray, the humanizer on chromatic_shift >=
  width, and the CLI on unreadable/corrupt/empty input (clean error, not a
  traceback)
- WebP written losslessly (cv2 quality 101), not lossy at 100
- the IPTC digitalSourceType algorithmicMedia (procedural, not trained on
  sampled data) is no longer flagged as AI-generated, so clean procedural
  content is not scrubbed
- c2pa source-type: compositeWithTrainedAlgorithmicMedia is checked before the
  bare algorithmicMedia token, so an AI-enhanced composite is not misclassified

Detection:
- integrity-clash coverage now normalizes ByteDance / Canva / ElevenLabs /
  Black Forest Labs, so a transplanted manifest next to an independent
  conflicting stamp is caught; the generic China TC260 AIGC label is attributed
  to a co-present TC260 vendor, so a legit Doubao image (its own C2PA + TC260
  label) does not clash (corpus-validated: 0 new clashes on 5000 carriers)

CLI:
- batch exits non-zero (with a warning) when any image errors or a GPU-missing
  SynthID scrub is skipped, and copies the input through so the output dir stays
  complete -- it used to always exit 0 and could silently drop files

Perf:
- GeminiEngine reused as a process-wide singleton with a precomputed template
  ladder: -24% on the identify sparkle path, detection byte-identical

Internal: one shared _ai_exif_targets rule set feeds both EXIF scrubbers so
their coverage cannot drift; docs synced; maintain.sh hardened so the uv-secure
internal teardown crash no longer aborts the gate (still fails on a real finding).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 10:49:24 +03:00
Victor KuznetsovandClaude Opus 4.8 190dc89d23 perf(visible): crop MI-GAN around the mask so peak RAM is bounded by mark size
erase_migan fed the whole frame to the ONNX model, so peak RSS scaled with the
upload (~0.6 GB at 4 MP up to ~2.4 GB at 25 MP). Mirror erase_lama: crop a padded
region around the mask (pad = max(256, 2*bbox)), feed only that crop (at native
resolution -- MI-GAN accepts arbitrary dims, unlike LaMa's fixed 512 square), and
paste only masked pixels back. Peak RSS is now bounded by the mark size
(~0.6-0.9 GB), so a memory-tight host (a 1-2 GB web worker) can run MI-GAN on a
25 MP upload.

Fill quality is unchanged: verified by eye on real Gemini/Doubao marks plus a
ground-truth reconstruction sweep -- a tighter view if anything reduces the GAN's
hallucination of large background structure.

Extract the shared padded-crop-box math into _padded_crop_box (used by both
erase_lama and erase_migan).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-12 08:22:59 +03:00
Victor KuznetsovandClaude Opus 4.8 f69fedcf2d chore(release): bump version to 0.14.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:13:02 +03:00
Victor KuznetsovandClaude Opus 4.8 731af94639 docs(release): note HOMEBREW_TAP_TOKEN rotation on distribute.yml Bad credentials
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 11:13:02 +03:00
Victor KuznetsovandClaude Opus 4.8 49869ab02b feat(identify): detect Dreamina C2PA + Tencent Cloud AIGC schema
Two AI-provenance metadata types mined from the retained corpus that
identify previously read as no-signal:

- Dreamina (ByteDance's international Jimeng brand) signs C2PA as
  "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and NO
  digitalSourceType, so the generator name is the only AI signal. Add a
  C2paAiVendor row with a new asserts_ai flag (identity-AI: presence
  asserts AI without trainedAlgorithmicMedia) plus the derived
  C2PA_IDENTITY_AI_ORGS view, folded into identify's c2pa_is_ai. Keyed on
  the Dreamina generator token, not the "Bytedance Pte" issuer, so non-AI
  CapCut edits signed by the same entity stay unattributed. 7/7 corpus
  files now attribute to ByteDance.

- Tencent Cloud's TC260 AIGC variant uses a ServiceProvider/ServiceUser
  schema (vs the producer-side ContentProducer schema), embedded in EXIF
  ImageDescription; add those field names to _TC260_FIELDS so the generic
  {"AIGC":{...}} gate accepts it. 11/11 corpus files now flagged.

Test-first: reproducing tests in test_identify.py / test_metadata.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 10:54:56 +03:00
Victor Kuznetsov 41e20a0606 Merge pull request #60 from wiltodelta/claude/mystifying-cori-a4c1e7
v0.14.0: visible-mark localize→fill rewrite, backend priority, fixes
2026-07-11 09:53:07 +03:00
Victor KuznetsovandClaude Opus 4.8 e3e332c4b1 Merge origin/main into claude/mystifying-cori-a4c1e7
main landed #58 (pill-gate fix, superseded by this branch's localize->fill
rewrite) and #57 (deps bump). Resolved the 6 code/test/doc conflicts by keeping
this branch's post-rewrite versions; the deps bump auto-merged into
uv.lock/pyproject. Full gate green after resolution: ruff, pyright 0, 730 passed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:50:21 +03:00
Victor KuznetsovandClaude Opus 4.8 b579385c6f feat(visible): white-core rescue for the Gemini false-positive gate
The FP gate demotes a low-gradient match, but a real FAINT sparkle also has soft
edges, so metadata-stripped faint sparkles were dropped. Keep a low-grad match
that is a strong (conf >= 0.52), bright, near-WHITE-core sparkle: a real sparkle
core is white, a clean bright corner that shape-matches (sky/sun) is colored
(_core_saturation). Recovers ~14/20 stripped faint sparkles under the DEFAULT
strict/auto (no metadata, no flag) at ~1.25% clean false-fire (baseline 0.55%);
the ~0.51-scoring bright-background FPs stay demoted (below 0.52).

A learned classifier on the same features measured WORSE than the tuned gate
(tier-1: MLP 86.7% recall vs the gate's 90.8% at equal false-fire), so the
heuristic stays; a patch-CNN with richer features is roadmapped P2 with low
expected value -- the precision/recall wall is fundamental (deep-research +
tier-1 both confirm it).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-11 09:41:58 +03:00
Victor KuznetsovandClaude Opus 4.8 bd60be10f9 docs: fold visible-mark + detection-method research into watermarking-landscape
Deep-research 2026-07-10 (adversarially verified): the Gemini sparkle is
tier-gated (visible on Free/Pro, absent on Ultra/AI-Studio/API; no official
visible-mark detector or published glyph spec); the faint-visible-mark
precision/recall wall is fundamental (learned CNN front-end does not cleanly
separate true/false, arXiv:1705.08593 refuted); learned detectors need large
synthetic-composite datasets + carry off-distribution risk; landscape adds
Meta bottom-left + Samsung star-icon variants; China GB 45438-2025 is the
strongest visible-mark mandate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:00:12 +03:00
Victor KuznetsovandClaude Opus 4.8 27a921b54f docs(visible): record the 0.12.1-vs-0.14 head-to-head + backend quality
Full-dataset validation of reverse-alpha (v0.12.1) vs the current localize->fill:
doubao/jimeng identical (100% coverage + clearance across all backends); gemini
strict coverage a few points below reverse-alpha (the FP tightening), every
missed mark recovered under assume_ai, clearance ~98% both, no outside-box
damage. Clearance is fill-independent (cv2/MI-GAN/LaMa all strip the mark shape);
the difference is visual fill quality on textured/structured backgrounds -- LaMa
best, MI-GAN can ghost/hallucinate, cv2 smears -- which motivates auto = LaMa >
MI-GAN > cv2. Added to module-internals, known-limitations, and the CLAUDE.md
compact list.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 19:18:25 +03:00
Victor KuznetsovandClaude Opus 4.8 c858006e93 feat(visible): auto fill prefers LaMa > MI-GAN > cv2, warn on cv2 fallback
The auto backend now resolves best-first: LaMa (highest quality, recovers the
textured/structured backgrounds the classical fill smears) > MI-GAN > cv2. Both
learned backends share the same onnxruntime availability check, so auto cannot
tell them apart and always prefers the better one; a memory-tight deployment
that cannot afford LaMa's ~4.7 GB peak pins MI-GAN explicitly via
`--backend migan` / `backend="migan"` (the deployment's call, not the library's).
cv2 stays the no-deps floor and now emits a one-time quality warning when auto
falls back to it, since it smears texture/structure.

Motivated by a v0.12.1 reverse-alpha vs 0.14 localize->fill head-to-head:
reverse-alpha recovered structured backgrounds more cleanly than any inpaint;
LaMa closes most of that gap, MI-GAN can ghost/hallucinate, cv2 is weakest.
doubao/jimeng removal is identical between versions; gemini strict coverage is
4pp lower (all recovered via assume_ai) with cleaner clearance and no
outside-box damage.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 18:24:20 +03:00
Victor KuznetsovandClaude Opus 4.8 9756189eaf style: drop dead Candidate fields, simplify resolve_backend, US spelling
- Candidate carries only the fields the arbiter reads (key, label,
  detected_strict, detected_relaxed, features); location/region/confidence were
  vestigial from the removed best_auto_mark max-by-confidence path.
- resolve_backend returns preferred_inpaint_backend() directly (typed Literal)
  instead of an identity ternary.
- colour/normalise/behaviour -> US spelling across code comments and docs.

No behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:18:13 +03:00
Victor KuznetsovandClaude Opus 4.8 178fed69a7 fix(visible): thread detection into mask + guard removal/IO edge cases
Resolve 10 code-review findings on the v0.14.0 localize->fill path, several
release-blocking:

- gemini: build the removal mask from the decision's provenance-aware region
  instead of a strict internal re-detect. A relaxed/assume_ai sparkle was
  re-demoted by the FP gate into a None mask and reported removed while left in
  the image; this also drops the redundant double-detect.
- registry: report a mark removed only when a fill actually happened (remove()
  returns a None region for an empty mask), so a no-op is never claimed.
- api/cli: add write_noop so the CLI `visible` no-mark path writes nothing and
  cannot clobber a pre-existing -o file (was write-then-unlink -> data loss);
  create output.parent; skip the same-file copy (SameFileError on in-place).
- cli: catch the missing migan/lama backend RuntimeError on the visible/all
  paths (matches `erase`); route the single-mark relaxation through the shared
  resolve_relax instead of an inline copy.
- metadata: keep_standard=False no longer takes the AI-only lossless JPEG
  short-circuit (it left standard metadata); defer a malformed-marker JPEG to
  the PIL fallback instead of reporting a partial strip as complete.
- invisible: register the HEIF opener before Image.open (HEIC --force) and
  RGB-convert before the PNG temp (CMYK JPEG).
- pill: normalize via to_bgr so a 4-channel BGRA array cannot crash cvtColor.

Regression tests for each; docs synced (resolve_relax, write_noop,
best_auto_mark -> detect_marks).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 16:14:46 +03:00
Victor KuznetsovandClaude Opus 4.8 1f5d64e6c5 style: unify section dividers and logger variable naming
- cli.py section dividers -> the codebase-majority `# ── Title ──` form
- logger variable unified to `logger` across identify, isobmff, c2pa,
  trustmark_detector, invisible_watermark (matching the 9-file majority)
- cosmetic only, no behavior change (719 tests / pyright / ruff green)

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:31:40 +03:00
Victor KuznetsovandClaude Opus 4.8 1a955b096a feat(visible): localize->fill rewrite, sensitivity/backend + api, HEIC + lossless IO
- Replace reverse-alpha removal with localize -> fill (template-free mask + one
  shared cv2/MI-GAN/big-LaMa fill) for every mark; drops the colour-shift / dark-pit
  failure modes, version-robust to a moved or re-rendered mark
- Separate perception/decision/action: engines report Candidates, a pure
  decide(candidates, Context) arbiter owns all policy (sensitivity + provenance +
  pill gate), remove_auto_marks orchestrates -- behavior-preserving (corpus 46/46/92)
- Three orthogonal knobs replace --method: --backend cv2|migan|lama,
  --sensitivity auto|strict|assume-ai, provenance (auto from metadata)
- Add high-level api.remove_visible / visible_provenance (lazy top-level re-export);
  visible --mark auto delegates to it so CLI and library share ONE path
- Read+write HEIC/AVIF on the pixel path via pillow-heif; imwrite preserves the input
  format at max quality (JPEG q100/4:4:4); a no-op copies the original bytes verbatim
- Lossless byte-level JPEG metadata strip (no DCT re-encode); consolidate the two
  remove_ai_metadata into one, delete legacy noai/cleaner + best_auto_mark
- Bump 0.13.0 -> 0.14.0

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-09 14:20:52 +03:00
dependabot[bot] 3c6cfd3a9b chore(deps): bump the minor-and-patch group across 1 directory with 6 updates (#57)
Bumps the minor-and-patch group with 6 updates in the / directory:

| Package | From | To |
| --- | --- | --- |
| [pillow](https://github.com/python-pillow/Pillow) | `12.2.0` | `12.3.0` |
| [diffusers](https://github.com/huggingface/diffusers) | `0.38.0` | `0.39.0` |
| [transformers](https://github.com/huggingface/transformers) | `5.12.1` | `5.13.0` |
| [huggingface-hub](https://github.com/huggingface/huggingface_hub) | `1.20.1` | `1.22.0` |
| [ruff](https://github.com/astral-sh/ruff) | `0.15.19` | `0.15.20` |
| [pyright](https://github.com/RobertCraigie/pyright-python) | `1.1.410` | `1.1.411` |



Updates `pillow` from 12.2.0 to 12.3.0
- [Release notes](https://github.com/python-pillow/Pillow/releases)
- [Changelog](https://github.com/python-pillow/Pillow/blob/main/CHANGES.rst)
- [Commits](https://github.com/python-pillow/Pillow/compare/12.2.0...12.3.0)

Updates `diffusers` from 0.38.0 to 0.39.0
- [Release notes](https://github.com/huggingface/diffusers/releases)
- [Commits](https://github.com/huggingface/diffusers/compare/v0.38.0...v0.39.0)

Updates `transformers` from 5.12.1 to 5.13.0
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.12.1...v5.13.0)

Updates `huggingface-hub` from 1.20.1 to 1.22.0
- [Release notes](https://github.com/huggingface/huggingface_hub/releases)
- [Commits](https://github.com/huggingface/huggingface_hub/compare/v1.20.1...v1.22.0)

Updates `ruff` from 0.15.19 to 0.15.20
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.19...0.15.20)

Updates `pyright` from 1.1.410 to 1.1.411
- [Release notes](https://github.com/RobertCraigie/pyright-python/releases)
- [Commits](https://github.com/RobertCraigie/pyright-python/compare/v1.1.410...v1.1.411)

---
updated-dependencies:
- dependency-name: pillow
  dependency-version: 12.3.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: diffusers
  dependency-version: 0.39.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: transformers
  dependency-version: 5.13.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: huggingface-hub
  dependency-version: 1.22.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.15.20
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: pyright
  dependency-version: 1.1.411
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-07 12:37:13 +03:00
Victor KuznetsovandClaude Opus 4.8 3524e8442e fix(visible): safe-inpaint pill gate, cut metadata-only false fires (#58)
Verified 0.13.0 pill removal on a 32k real-upload corpus. The metadata-OR-wordmark
gate was only ~1/3 precise: TC260 metadata confirms Jimeng-class provenance, not pill
presence, so the weak edge-NCC detector's false fires (textured ceilings/walls, where
inpaint visibly smears) were admitted whenever metadata was present.

Split into two arms (_keep_pill): the reliable bottom-right wordmark (~94% precise,
survives metadata stripping) removes the pill unrestricted; the metadata-only arm
removes it ONLY when the top-left footprint is flat enough for an invisible inpaint
(PillEngine.footprint_is_flat, median-Sobel <= _FLAT_TEXTURE_MAX). Keeps real
flat-scene pills and harmless flat false fires; leaves the damaging textured false
fires untouched. Corpus: 270 -> 118 removals, ~90 true preserved, damaging FP -> ~0.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:29:17 +03:00
Victor KuznetsovandClaude Opus 4.8 a8fd02a8f7 fix(visible): safe-inpaint pill gate, cut metadata-only false fires
Verified 0.13.0 pill removal on a 32k real-upload corpus. The metadata-OR-wordmark
gate was only ~1/3 precise: TC260 metadata confirms Jimeng-class provenance, not pill
presence, so the weak edge-NCC detector's false fires (textured ceilings/walls, where
inpaint visibly smears) were admitted whenever metadata was present.

Split into two arms (_keep_pill): the reliable bottom-right wordmark (~94% precise,
survives metadata stripping) removes the pill unrestricted; the metadata-only arm
removes it ONLY when the top-left footprint is flat enough for an invisible inpaint
(PillEngine.footprint_is_flat, median-Sobel <= _FLAT_TEXTURE_MAX). Keeps real
flat-scene pills and harmless flat false fires; leaves the damaging textured false
fires untouched. Corpus: 270 -> 118 removals, ~90 true preserved, damaging FP -> ~0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-07 11:26:28 +03:00
Victor KuznetsovandClaude Opus 4.8 0e5a4cbc54 feat(visible): capture-less AI生成 pill (#54), inpaint fallback, MI-GAN backend (#56)
- Add the Jimeng-basic top-left "AI生成" pill as a CAPTURE-LESS mark
  (pill_engine.py): synthetic-silhouette edge-NCC detect + inpaint-only removal.
  Gated in remove_auto_marks: kept only when Jimeng is confirmed (TC260 metadata
  OR the bottom-right "★ 即梦AI" wordmark fired -- the wordmark keeps recall on
  metadata-STRIPPED uploads) AND Doubao did not fire.
- Add an inpaint-fallback removal path + MI-GAN ONNX backend (migan extra, MIT,
  ~28 MB / ~1 GB peak -- droplet-friendly) alongside big-LaMa. New
  --method auto|reverse-alpha|inpaint (shared across visible/all/batch) and
  erase --backend migan; footprint_mask on each engine.
- auto is deterministic: reverse-alpha for capture marks (recovers exact pixels,
  lighter -- measured cleaner than MI-GAN on structured backgrounds) and inpaint
  only for the capture-less pill.
- --mark auto now removes EVERY detected mark in one pass (remove_auto_marks),
  so a Jimeng-basic image's top-left pill AND bottom-right wordmark both clear.
- Bump 0.12.1 -> 0.13.0.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-06 20:38:23 +03:00
Victor KuznetsovandClaude Opus 4.8 0f54c6b54d fix(identify): kill three visible-detector false positives
Bright-background photos/renders and a tiny app icon were flagged as
AI-generated by the visible detectors. Two failure modes:

- Gemini sparkle on a bright background (snow+sky photo, white product
  render) scored ~0.51. The FP gate only demoted on a low core-ring
  brightness margin, which a bright background makes high. Add a gradient
  floor (_SPARKLE_FP_GRAD 0.55): a real sparkle is a crisp star (grad
  ~0.97-1.0), a smooth luminance blob that NCC-matches the diamond is not
  (the two FPs measured grad 0.105 / 0.463). The OR is a strict superset
  of the old margin-only demotion, so it cannot regress dark/mid (kept by
  margin) or white-bg (kept by confidence) real sparkles.

- A 48x48 geometric icon matched the Doubao/Jimeng CJK silhouette at
  0.41/0.47 NCC. Purely a small-size artifact (the same icon at >=256px
  collapses to ~0.06-0.10). Guard text-mark detection below a 200px short
  side (_MIN_DETECT_SHORT_SIDE); real marks ship on full-resolution
  renders (smallest captured sample 1086px).

Corpus re-sweep flips only OpenAI content and already-cleaned outputs,
all sub-0.5, so no provenance verdict changes. Add synthetic regression
fixtures for both modes; docs/module-internals.md updated.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-26 09:44:24 -07:00
Victor KuznetsovandClaude Opus 4.6 f269b75ded chore: add ruff scripts per-file-ignores, Claude Code settings, bump deps
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-06-24 12:32:44 -07:00
Victor KuznetsovandClaude Opus 4.8 0d9d7dcf6a docs: compact CLAUDE.md, relocate incident/CVE detail to docs
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-24 10:28:40 -07:00
dependabot[bot] 3bbcc0ee94 chore(deps): bump actions/checkout from 6 to 7 in the actions group (#52)
Bumps the actions group with 1 update: [actions/checkout](https://github.com/actions/checkout).


Updates `actions/checkout` from 6 to 7
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: actions
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 09:51:00 -07:00
dependabot[bot] d96e3f6334 chore(deps): bump the minor-and-patch group with 5 updates (#53)
Bumps the minor-and-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [c2pa-python](https://github.com/contentauth/c2pa-python) | `0.35.1` | `0.36.0` |
| [torch](https://github.com/pytorch/pytorch) | `2.12.0` | `2.12.1` |
| [huggingface-hub](https://github.com/huggingface/huggingface_hub) | `1.19.0` | `1.20.1` |
| [pytest](https://github.com/pytest-dev/pytest) | `9.1.0` | `9.1.1` |
| [ruff](https://github.com/astral-sh/ruff) | `0.15.17` | `0.15.19` |


Updates `c2pa-python` from 0.35.1 to 0.36.0
- [Release notes](https://github.com/contentauth/c2pa-python/releases)
- [Changelog](https://github.com/contentauth/c2pa-python/blob/main/docs/release-notes.md)
- [Commits](https://github.com/contentauth/c2pa-python/compare/v0.35.1...v0.36.0)

Updates `torch` from 2.12.0 to 2.12.1
- [Release notes](https://github.com/pytorch/pytorch/releases)
- [Changelog](https://github.com/pytorch/pytorch/blob/main/RELEASE.md)
- [Commits](https://github.com/pytorch/pytorch/compare/v2.12.0...v2.12.1)

Updates `huggingface-hub` from 1.19.0 to 1.20.1
- [Release notes](https://github.com/huggingface/huggingface_hub/releases)
- [Commits](https://github.com/huggingface/huggingface_hub/compare/v1.19.0...v1.20.1)

Updates `pytest` from 9.1.0 to 9.1.1
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.1.0...9.1.1)

Updates `ruff` from 0.15.17 to 0.15.19
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.17...0.15.19)

---
updated-dependencies:
- dependency-name: c2pa-python
  dependency-version: 0.36.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: torch
  dependency-version: 2.12.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: huggingface-hub
  dependency-version: 1.20.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: pytest
  dependency-version: 9.1.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.15.19
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-24 09:50:56 -07:00
Victor Kuznetsov ee4af800ce chore(release): v0.12.1 2026-06-23 15:15:04 -07:00
Victor KuznetsovandClaude Opus 4.8 675590e8b2 perf(invisible): read the fp16 weight variant to halve the cold-start weight load
InvisibleEngine loads SDXL/ControlNet in fp16 on CUDA/XPU but called from_pretrained
without variant="fp16", so it read the full fp32 weight files (~7 GB) and downcast in
memory. _load_from_pretrained now passes variant="fp16" when torch_dtype is float16,
reading the half-precision files (~3.5 GB) instead - roughly halving the cold-start
weight read + host->device transfer (a phase-timed Modal run measured weight load as
~half of the ~25s cold start). Falls back to the default weights when a checkpoint ships
no fp16 variant (a custom --model), so the worst case is the prior behavior. fp32
(cpu/mps) and bf16 (qwen) never request the variant.

Tests: TestFp16WeightVariant (variant requested on fp16, fallback on missing, never on
fp32).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-23 14:42:47 -07:00
Victor KuznetsovandClaude Opus 4.8 c8dbd0c3f9 docs(claude-md): record that new visible marks are blocked on real flat captures
Per user decision 2026-06-22: synthetic font-rendered alpha reconstruction is
rejected as below the quality bar; the reverse-alpha alpha map must be solved
from real controlled flat captures (visible_alpha_solve.py). Meta AI, more
Samsung locales, and any Grok visible mark are parked until captures exist.
Future sessions must not propose synthetic or derive assets from the corpus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:28:28 -07:00
Victor KuznetsovandClaude Opus 4.8 abb7be7e9b feat(identify): detect + strip NovelAI / Reve / Aphrodite generator stamps
Mined from the retained corpus 2026-06-22 (open-world EXIF/PNG-text/XMP scan,
minus the registry): three AI image generators that stamp a plain generator
name and no C2PA, so identify read them as no-signal -- and under the P0#5
no-signal skip would have skipped the scrub.

- NovelAI (anime SD): PNG tEXt Software/Source/Title. exif_generator now reads
  PNG text chunks (via img.info), not only EXIF/XMP.
- Reve (reve.com): EXIF Software / XMP CreatorTool. Token is the full
  "reve.com", not bare "reve" (would false-fire on "forever"/"reverie").
- Aphrodite AI: EXIF Make / Software.

Detection/removal parity: NovelAI stamps an AI-shaped VALUE under a non-AI KEY
(Title/Source), which _is_ai_key alone keeps. New _is_ai_value drops a text
chunk by value-token match on removal, mirroring exif_generator -- else the
cleaned file still read as NovelAI (verified on a real corpus file).

Tests: TestExifGenerator gains NovelAI PNG-text, Reve, Reve-not-overmatched,
Aphrodite, and a NovelAI detect/remove parity regression. Docs synced
(module-internals, watermarking-landscape, CLAUDE.md).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 16:23:33 -07:00
Victor KuznetsovandClaude Opus 4.8 19f9ab0947 feat(invisible): skip the diffusion scrub when no invisible watermark is detectable (P0#5)
Regenerating pixels removes SynthID / open watermarks but degrades a real
photo, so running it on a clean image is the dominant paid score-0 cause on
no-watermark uploads. Gate invisible/all/batch on identify.has_invisible_target:
when no invisible AI signal is locally detectable and --force is unset, skip the
regeneration. Per-command semantics:
  - invisible: write no output, exit EXIT_NO_INVISIBLE_SIGNAL (2)
  - all: skip step 2 but keep visible-removed pixels + strip metadata, exit 0
  - batch: skip the scrub; copy the input through in invisible mode
A skip never claims the image is clean (a pixel SynthID is undetectable once its
metadata proxy is gone); the message says so and routes to --force. The gate
fails safe (a detector error runs the removal).

has_invisible_target wraps identify(check_visible=False, check_invisible=True)
and returns the new ProvenanceReport.ai_from_metadata field (the confidence==high
union), so the raiw.cc worker can reuse the same gate. Gate placed before engine
construction so the skip path is cheap; shared via cli._should_skip_invisible_scrub.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:37:01 -07:00
Victor KuznetsovandClaude Opus 4.8 5a612adfef chore(release): v0.12.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 11:14:55 -07:00
Victor KuznetsovandClaude Opus 4.8 9695a7cbd2 docs(qwen): drop false 'pending seed-repeat cert' from pipeline docstring
The qwen oracle floors are certified, not pending. Near-threshold scrub is
seed-non-deterministic, but the prod path pins one fixed seed, so a certified
floor reproduces run-to-run -- pinning the seed is the release gate, not a seed
sweep. Reword the docstring so it stops implying an open seed-repeat gate.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 10:30:42 -07:00
Victor KuznetsovandClaude Opus 4.8 78e2ae65ad docs: neutralize local-pull path reference in doubao research note
Replace the `data/spaces/originals/` path with a generic "local corpus of
pristine originals" so the committed public doc carries no reference to the
local working-data pull (the data itself is gitignored). The analysis scripts'
default paths are left untouched (operational tooling, no content/provenance).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:41:23 -07:00
Victor KuznetsovandClaude Opus 4.8 719c7e9313 chore(gitignore): ignore local-only data/spaces working dir
21k user-pull images under data/spaces/ were untracked but not ignored, so a
stray `git add -A` could have committed them. Add the ignore entry alongside the
other local-data paths; the dir stays local analysis only, never a committed corpus.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-22 09:32:22 -07:00
Victor KuznetsovandClaude Opus 4.8 d5dd24140c fix(qwen): native-geometry img2img + pipeline-aware strength; record dropped auto/mixed/Z-Image leads
- watermark_remover: _build_qwen_kwargs now passes explicit height/width (via
  _qwen_target_size, floored to /16). Without it QwenImageImg2ImgPipeline defaults to
  1024x1024 and silently squishes non-square inputs, distorting the scene and garbling text.
- watermark_profiles: resolve_strength gains a `pipeline` arg + a Qwen strength ladder
  (_QWEN_VENDOR_STRENGTH, Gemini 0.25), so `--pipeline qwen` gets its certified floor
  automatically; retires the manual "pass --strength 0.25 for Gemini on qwen" workaround.
- fidelity_metrics: replace per-face nearest matching (collided on multi-face images when a
  variant dropped a face, corrupting the identity metric) with a collision-free one-to-one
  assignment (assign_faces_one_to_one). lapvar/LPIPS were always bbox-anchored and immune.
  Regression-guarded by tests/test_fidelity_matching.py.
- docs: record the measured outcomes of the qwen-improvement arc. The Qwen ControlNet
  face-fix is CLOSED (no permissive Qwen detail/tile ControlNet exists; canny carries edges,
  not skin grain). The `--pipeline auto` router + faces+text mixed dual-pass were prototyped
  and DROPPED (controlnet wins faces AND display text: abba CER 0.114 vs qwen 0.379).
  Z-Image-Turbo was tried and dropped (same regeneration limits). qwen stays a manual opt-in;
  controlnet is the default for everything.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 21:52:56 -07:00
Victor KuznetsovandClaude Opus 4.8 8f64869bfc docs: capture the Qwen-improvement research (ship vs improve)
Cited deep-research report (22 sources, 3-vote adversarial verification, 5 refuted)
behind the "ship qwen as-is or improve first?" decision. Verdict: shippable now as
an opt-in text lane; strongest improvement lead is adding a Qwen-Image ControlNet
(InstantX / DiffSynth, Apache-2.0, diffusers QwenImageControlNetPipeline) for face/
skin structure; Z-Image-Turbo (6B, Apache-2.0) is the best cheaper text-preserving
substitute. No improvement has measured face-fidelity at our scrub floors yet --
validate with scripts/fidelity_metrics.py first. Linked from known-limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:58:46 -07:00
Victor Kuznetsov 0d9033d63a Merge branch 'claude/modest-carson-d72243': corpus-mining provenance + removal fixes
Retained-corpus mining (2026-06-20) fixes, all gate-green:
- C2PA vendor coverage (Volcano Engine CJK legal name, ElevenLabs; TikTok/PixelBin vetted out)
- identify AI-generated vs AI-enhanced (ai_source_kind) + shared GEMINI_SPARKLE_TRUST_CONF (detect/remove threshold unify)
- text-mark over-subtraction guard (Doubao/Jimeng/Samsung)
- region-targeted regeneration for AI-enhanced composites (feather_region_composite + remove_watermark(region=))

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

# Conflicts:
#	CLAUDE.md
2026-06-20 15:39:29 -07:00
Victor KuznetsovandClaude Opus 4.8 737305858d docs: sync module map for the corpus-mining provenance + removal fixes
Update CLAUDE.md and docs/module-internals.md for: ProvenanceReport.ai_source_kind
(generated vs enhanced) and the shared GEMINI_SPARKLE_TRUST_CONF; the text-mark
over-subtraction guard; noai/tiling.feather_region_composite + the region-targeted
WatermarkRemover.remove_watermark(region=) path; the new C2PA vendor rows (Volcano
Engine Chinese legal name, ElevenLabs) and the documented TikTok/PixelBin
exclusion. Record the rejected gemini-gate-lowering experiment.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:34:39 -07:00
Victor KuznetsovandClaude Opus 4.8 c1971a3e8d feat(invisible): region-targeted regeneration for AI-enhanced composites
For AI-enhanced composites (digitalSourceType compositeWithTrainedAlgorithmicMedia,
identify ai_source_kind == "enhanced"; roadmap P1#8): regenerate ONLY the AI
region and preserve the real photo elsewhere, instead of regenerating the whole
frame.

- noai.tiling.feather_region_composite(base, regenerated, box, *, feather): pure,
  model-free compositor that blends the regenerated AI box back over the original
  with a feathered seam, leaving pixels OUTSIDE the box exactly equal to base.
  Fully unit-tested (outside-box exactness, interior == regenerated, hard paste at
  feather 0, monotonic seam ramp, dtype/grayscale/clamp/empty-box/shape-mismatch).
- WatermarkRemover.remove_watermark(region=, region_feather=) and the module-level
  convenience function thread it through: the remover regenerates (or tiles) the
  frame, then composites only the AI box back over the original input. The box is
  caller-supplied -- a C2PA composite manifest carries no reliable machine-readable
  region, so none is fabricated. The no-model lossless region path stays
  region_eraser.erase.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:34:39 -07:00
Victor KuznetsovandClaude Opus 4.8 33fddbc6fa fix(visible): over-subtraction guard for Doubao/Jimeng/Samsung text marks
Port the Gemini sparkle dark-pit guard (commit 41f6797) to the shared
TextMarkEngine reverse-alpha base (roadmap P0#8): on a dark or mid-tone
background the captured alpha can over-estimate this image's mark opacity, and
reverse-alpha leaves a darker-than-background glyph ghost instead of recovering
the true pixels. The sparkle-only fix left the text marks unhandled.

_reverse_alpha_oversubtracts predicts the reverse-alpha output PER PIXEL over the
glyph body from the INPUT ((obs - a*logo)/(1-a), the remover's own math); when
the predicted body lands more than _OVERSUB_DARK_MARGIN (25) gray levels below
the local background ring it abandons the reverse-alpha output for the footprint
and inpaints it from the original surroundings (_inpaint_footprint, wider dilate/
radius than the thin residual pass). Predicting per-pixel from the input (not the
produced output, which depends on which placement the remover picked) keeps a
cleanly captured full-strength mark byte-identical -- it predicts back to the
background everywhere, so the guard never trips on it (verified across all three
engines on white/mid/dark/midgray backgrounds).

Regression-guarded by tests/test_text_mark_oversubtraction.py: predicate True on
faint / False on clean, end-to-end no-dark-pit acceptance, clean-mark byte
identity, and textured-background footprint recovery.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:34:39 -07:00
Victor KuznetsovandClaude Opus 4.8 0c215b5b2f feat(identify): C2PA vendor coverage, AI-enhanced split, detect/remove threshold unify
Retained-corpus mining (2026-06-20) surfaced three provenance gaps; all are
oracle-free and regression-guarded.

- C2PA vendor coverage (roadmap): register Volcano Engine under its Chinese
  legal entity 北京火山引擎科技有限公司 (the latin "volcengine" needle misses
  those certs) -> normalizes to the same ByteDance platform; register ElevenLabs
  ("Eleven Labs Inc.", pure generative-AI) as a generator. Document the
  deliberate exclusion of TikTok Inc. and PixelBin.io/"Fynd" (provenance/transform
  signers, not generators) so they are not re-added.

- AI-generated vs AI-enhanced (roadmap): ProvenanceReport.ai_source_kind splits
  the C2PA digital-source-type into "generated" (trainedAlgorithmicMedia) vs
  "enhanced" (compositeWithTrainedAlgorithmicMedia) so a caller branches a
  full-frame scrub from a region-targeted clean. Parsed once in
  noai.c2pa._populate_registry_fields (PNG + any c2pa-python-readable container),
  with a raw head-scan fallback in identify for the non-PNG raw-blob path. CLI
  verdict reads "AI-generated (fully synthetic)" vs "AI-enhanced (real content
  with an AI-composited region)"; surfaced in --json.

- Detect-vs-remove threshold desync (P0#7): identify's sparkle threshold and the
  removal arbitration gate were two independent 0.5 constants. Unify them into the
  single GEMINI_SPARKLE_TRUST_CONF (identify imports it) so they can never drift.
  Lowering the gate to recover faint sub-0.5 sparkles was evaluated and REJECTED:
  a real Doubao text mark scores ~0.40-0.42 as a gemini match with a higher
  core-ring brightness margin than a genuine faint sparkle, so neither confidence
  nor the brightness gate separates them in [0.35, 0.5) -- lowering would trade a
  rare miss for false-positive removals on clean images. Regression-guarded by
  TestSparkleDetectRemoveAlignment (real demo sparkle at borderline opacities;
  identify and best_auto_mark must agree on either side of the line).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:34:20 -07:00
Victor KuznetsovandClaude Opus 4.8 e29d6624b9 docs(known-limitations): seed is a weak text-only quality lever; mark qwen floors certified
Measured (openai_1, 0.10, seeds 0-4): seed barely moves whole-image fidelity
(img LPIPS 0.062-0.065, SSIM/PSNR flat) but shifts text legibility (OCR CER
0.241-0.290, ~17% spread) -- it changes which details regenerate, not the level.
So per-image best-of-N-seed is a weak text-only lever (pin a seed in prod; reserve
best-of-N for text-heavy premium). Also retitle the qwen section "certified floors"
and drop the now-stale "uncertified / run seed-repeat / floor 0.30" tails.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:26:27 -07:00
Victor KuznetsovandClaude Opus 4.8 7dddfef14e docs: certify qwen scrub floors (OpenAI 0.10 seed-robust, Gemini 0.25)
Oracle seed-repeat + floor refinement (2026-06-20, data/qwen_in):
- OpenAI floor 0.10 is SEED-ROBUST: 0.05 and 0.075 still detected; 0.10 clean on
  seeds 0-4 (5/5) -> a random seed is safe.
- Gemini floor lowered 0.30 -> 0.25 (0.20 still detected, 0.25 clean on both
  images). Single-seed (seed 0): the Gemini oracle rate-limits volume seed-repeat,
  so pin a seed in prod rather than relying on seed-robustness there.

Re-measured fidelity at the certified floors (controlnet 0.15 vs Qwen 0.25 for
Gemini): faces still favor controlnet (ArcFace 0.546 vs 0.382, lapvar 0.62 vs
0.40); the short-CJK text case is now a TIE (gemini_1 0.037 vs 0.037 -- the earlier
Qwen 0.000 was at 0.30, not the floor). Qwen's text win holds on substantial
Latin/mixed text (OpenAI 0.385 vs 0.241 / 0.341 vs 0.290). Update watermark_profiles
comment, CLAUDE.md, module-internals, known-limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 15:16:51 -07:00
Victor KuznetsovandClaude Opus 4.8 373b910a60 docs: fix the qwen-vs-controlnet face comparison to oracle-confirmed scrub floors
The face fidelity numbers cited an equal-strength compare (both 0.15), but Qwen at
0.15 does NOT clear Gemini SynthID -- so that output is un-scrubbed and the compare
is invalid. Per the methodology rule (compare fidelity only between outputs where
SynthID is removed in BOTH), restate faces at each pipeline's scrub floor
(controlnet 0.15 / Qwen 0.30): ArcFace identity 0.546 vs 0.331, lapvar 0.62 vs 0.40,
face LPIPS 0.09 vs 0.19 -- controlnet still wins faces, conclusion unchanged. Drop
the "equal strength" framing in CLAUDE.md / module-internals / known-limitations.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:33:11 -07:00
Victor KuznetsovandClaude Opus 4.8 2d5b26ed18 test(eval): vision-transcribed ground truth for qwen_in + clean text-CER numbers
data/qwen_in/ground_truth.json is transcribed by vision (PaddleOCR mangled the
stylized Cyrillic), so the text metric scores variants against an accurate
reference instead of noisy OCR-vs-OCR. Re-measured text CER (controlnet vs qwen)
with this ground truth confirms qwen wins text across EN/RU/ZH: openai_1 0.385 vs
0.241, openai_2 0.341 vs 0.290, gemini_1 (ZH) 0.037 vs 0.000 (perfect Chinese even
at the higher 0.30 strength). Faces still favor controlnet. Refresh the numbers in
docs/known-limitations.md to this cleaner methodology.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:26:23 -07:00
Victor KuznetsovandClaude Opus 4.8 e29c156279 test(eval): fix the qwen_in pipeline-fidelity eval set + PaddleOCR ground-truth flow
- data/qwen_in/: a stable, committed set of 4 AI-generated images (OpenAI +
  Google, carrying SynthID/C2PA -- same class as data/samples fixtures) used to
  compare the controlnet/sdxl/qwen pipelines for fidelity. Two text-multi-script
  (incl. RU/CJK), one EN poster, one face grid. README documents the set + the
  ground-truth workflow. data/ is sdist-excluded so the wheel is unaffected.
- scripts/fidelity_metrics.py: switch text OCR from EasyOCR to PaddleOCR
  (PP-OCRv6, higher accuracy esp. CJK, single multilingual stack); split into
  `ocr` (seed a {basename: text} ground truth) and `compare` (--ground-truth for
  a clean CER vs the hand-verified reference instead of noisy OCR-vs-OCR). Spatial
  IoU-NMS keeps the best-scoring read per line so wrong-script models don't inject
  garbage over Cyrillic/CJK.
- Oracle methodology: validate the OpenAI arm FIRST (openai.com/verify is more
  accessible and the strongest Playwright/Chrome-MCP automation candidate; the
  Gemini app is more manual). Recorded in CLAUDE.md + docs/synthid.md.

Ground-truth JSON (data/qwen_in/ground_truth.json) lands in a follow-up once
hand-verified.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 14:17:04 -07:00
Victor KuznetsovandClaude Opus 4.8 a2c33af284 feat(scripts): fidelity_metrics.py + correct the qwen-vs-controlnet claim
Add scripts/fidelity_metrics.py: an objective eval harness comparing
watermark-removal outputs against the original (reference) across four groups
-- OCR character error rate (EasyOCR), ArcFace identity cosine (insightface),
face texture (LPIPS + Laplacian-variance ratio), and whole-image LPIPS/SSIM/
PSNR. PEP 723 inline deps so it stays out of the package / uv.lock; metrics
self-gate (faces only where faces, text only where text).

The metrics overturned an eyeball conclusion: at EQUAL strength Qwen beats
controlnet on TEXT (OpenAI typography 0.10: OCR CER 0.25 vs 0.37) but controlnet
beats Qwen on FACES (gemini_3, 18 faces, 0.15 each: Laplacian-variance retention
0.62 vs 0.41, face LPIPS 0.09 vs 0.13 -- Qwen smooths faces MORE; ArcFace
identity ~tied). So Qwen is the better TEXT-preserving remover, not a universal
fidelity win. Correct the earlier "qwen keeps faces faithful where controlnet
plasticizes" claim in CLAUDE.md, module-internals.md, known-limitations.md, README.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-20 09:58:22 -07:00
Victor KuznetsovandClaude Opus 4.8 76e3d4154c feat(invisible): add Qwen-Image img2img pipeline (--pipeline qwen)
A third diffusion pipeline alongside sdxl/controlnet: Qwen-Image (20B MMDiT,
Apache-2.0 code AND weights) img2img. The scrub still comes from the img2img
strength; Qwen preserves text (incl. CJK) and structure markedly better than
SDXL at the scrub floor, so it over-regenerates real photos far less (directly
targets the controlnet over-regeneration that degrades real uploads).

- watermark_profiles: QWEN_MODEL_ID, normalize_profile accepts "qwen".
- WatermarkRemover: _load_qwen_pipeline (bf16, loads Qwen base unless --model
  overridden, clear ImportError if diffusers lacks the class), _run_qwen (no
  MPS fallback -- 20B is CUDA/cloud-class), dispatch in _generate_one/preload,
  pure _build_qwen_kwargs (true_cfg_scale, not guidance_scale).
- Shared _base_load_kwargs() across all three loaders (dtype + token).
- CLI --pipeline gains "qwen"; invisible_engine threads it through.
- scripts/qwen_scrub_prototype.py: standalone PEP 723 GPU experiment.

Prototype oracle floors (Modal A100-80GB, single seed, controls SynthID-positive,
PENDING seed-repeat cert): OpenAI clears at strength ~0.10, Gemini at ~0.30 (0.20
still detected), with CJK text + faces faithful where controlnet plasticizes. The
Gemini floor is higher than the shared default ladder, so pass an explicit
--strength for Gemini on this pipeline until a Qwen-specific ladder is certified.

The model-running path is CUDA-only (untestable locally); unit tests cover the
pure call-shape (_build_qwen_kwargs) and profile normalization without torch.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 20:44:36 -07:00
Victor KuznetsovandClaude Opus 4.8 0c0c6c6b03 feat(invisible): sliding-window tiled diffusion for large inputs (--tile)
Add a lossless alternative to the --max-resolution downscale for large
images that OOM on MPS/GPU: regenerate in overlapping, feather-blended
tiles at native resolution.

- noai/tiling.py: pure plan_tiles (uniform tiles, last flush to edge) +
  feather_weights (strictly-positive separable taper -> partition-of-unity
  blend) + run_tiled (per-tile generate callable, decoupled from the
  pipeline). Unit-tested without the model.
- WatermarkRemover.remove_watermark: refactor _generate into _generate_one
  + a tiled branch that engages only when --tile is set and the long side
  exceeds tile_size (ControlNet canny is rebuilt per tile).
- Thread tile/tile_size/tile_overlap through InvisibleEngine and the
  invisible/all/batch CLI commands via a shared _tile_options decorator.

Verified end-to-end on the real SDXL pipeline (forced 2x2 tiling on a
1024px sample, MPS): non-degenerate output, no gross seam at tile borders.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 11:54:58 -07:00
Victor KuznetsovandClaude Opus 4.8 d5845a72f3 feat(metadata): blank AI-generator tokens in AVIF/HEIF Exif meta-box items
Closes a documented coverage gap (P2#9): an AI Software/Make/Artist/ImageDescription
token in an EXIF item (its TIFF bytes live in mdat/idat) survived remove_ai_metadata
because the top-level box stripper and (absent pillow-heif) the PIL EXIF reader can't
reach it. New isobmff.blank_ai_exif_tokens finds EXIF TIFF blocks by their II/MM
byte-order header, validates each with piexif (a coincidental II/MM run in pixels
won't parse as a TIFF IFD, so it's ignored), and overwrites any AI_GENERATOR_TOKENS-
bearing value with same-length spaces -- so box sizes and iloc offsets stay valid and
the coded image is untouched (mirrors blank_ai_xmp_packets; no iinf/iloc surgery, no
exiftool dep). Camera/editor EXIF without an AI token is preserved. Wired into
remove_ai_metadata's ISOBMFF path. Covers the realistic AI-generator-token case; xAI-
signature-in-meta-box-EXIF (Grok is JPEG-only) stays out.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:43:35 -07:00
Victor KuznetsovandClaude Opus 4.8 3f5d6a0af1 docs(landscape): back the DWT-DCT positive-only limitation with researched root cause + citations
Deep-research (2026-06-19, adversarially verified) confirms the open imwatermark
dwtDct mark is fragile by scheme, not by our usage: maintainers admit no 100%
clean-decode guarantee; measured ~0.79 bit accuracy clean (~38/48, below our 44
gate). Root causes (code-verified + locally reproduced): per-block max-coefficient
bit read (content flips bits) and YUV chroma 8-bit clamping on bright pixels (the
bright-flat / all-ones failure). No maintained fork or detector does this scheme
reliably (WAVES relegates it to an appendix; learned schemes are a different class;
dwtDctSvd cannot decode SDXL's dwtDct). Conclusion: keep it positive-only, rely on
C2PA. Sources: imwatermark READMEs, arXiv:2406.08337 (WMAdapter), arXiv:2401.08573
(WAVES), diffusers SDXL watermark.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:27:08 -07:00
Victor Kuznetsov f97fdc5b92 chore(release): v0.11.4 2026-06-19 10:06:04 -07:00
Victor KuznetsovandClaude Opus 4.8 4c8a57ec7b docs: dwtDct detector is carrier-fragile (all-ones = artifact), FLUX open-mark unresolvable
Final characterization after a positive-control sweep. The imwatermark dwtDct
round-trip fails (28-39/48, below the 44 gate) not on "high texture" as a prior
note claimed, but on a broad carrier class: the FLUX fox, doubao, a minimalist-FLAT
FLUX generation, AND a clean synthetic bright-flat fill with NO watermark all fail
identically. The degenerate all-ones decode is therefore a CARRIER ARTIFACT, not a
watermark (the no-watermark synthetic image reproduces it; a double-embed test shows
no interference). detect_invisible_watermark is positive-only: trust a hit, treat a
None as inconclusive unless a same-carrier positive control first recovers >=44.

Consequence: whether BFL hosted FLUX embeds the open DWT-DCT is unresolvable with
this detector on the available carriers (textured AND flat FLUX both fail the
control). C2PA stays the reliable FLUX signal. Low priority to chase further.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:03:34 -07:00
Victor KuznetsovandClaude Opus 4.8 a0a349cc66 docs: correct overstated FLUX open-watermark claim; record detector content-fragility
Earlier notes asserted BFL hosted output has no open DWT-DCT watermark. That was
overstated: the test carriers were high-texture fox images where a clean
encode->decode round-trip of a KNOWN-embedded watermark recovers only 28-35/48
bits (below the safe 44 gate), so the detector would miss a present mark there --
the None is inconclusive, not proof of absence.

Verified positive-control (2026-06-19): imwatermark dwtDct round-trips 48/48 on
synthetic carriers and on chatgpt-1.png (48/48) / firefly-1.png (45/48), but
FAILS on flux-1.png (28/48) and doubao-1.png (39/48). So invisible_watermark
detection is a positive-only signal: trust a hit, treat a miss on busy content as
inconclusive. Affects all open SD/SDXL/FLUX DWT-DCT detection. C2PA stays the
reliable FLUX identifier; whether BFL hosted embeds the open mark is unresolved
(needs a low-texture hosted sample).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:03:34 -07:00
Victor KuznetsovandClaude Opus 4.8 99e57c872f perf(text-mark): footprint-sized arrays in reverse-alpha CPU path
The reverse-alpha text-mark engine (Doubao/Jimeng/Samsung) allocated
full-frame arrays where only the glyph footprint is ever read:

  - _fixed_alpha_map / _aligned_alpha_map each built a full (h, w) float32
    alpha map non-zero only inside the glyph box, and two were held at once
    during removal (~96 MB of mostly-zeros on a 12 MP frame);
  - extract_mask built a full (h, w) uint8 mask that every caller cropped to
    the located box (~12 MB, rebuilt per text-mark detector on the
    memory-tight identify path).

Both now return footprint-sized arrays: the alpha helpers return the
glyph-sized block plus its placement (ax, ay, gw, gh), and extract_mask
returns the box-sized mask. _apply_reverse_alpha consumes the block
directly; the residual inpaint embeds it into one full-frame uint8 mask only
at cv2.inpaint time (which needs a full-frame mask). remove_watermark_
reverse_alpha tracks the winning region alongside best_amap to place it.

Peak allocation drops from O(image*4)x2 + O(image) to O(footprint)x2 +
one gated O(image*1) uint8 mask -- a win every consumer gets, motivated by
the 512 MB raiw.cc worker that OOMs on large decodes. GPU path untouched.

Byte-identical to the old full-frame path (verified: 17 output hashes
across the three engines, inpaint/no-inpaint, detect, and the real
doubao-1.png fixture, unchanged before/after). tests/test_text_mark_memory.py
guards it by reconstructing the old full-frame path inline and asserting
equality, so the proof survives a cv2/asset bump, and pins the O(footprint)
shape so a regression to full-frame fails loudly.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 10:01:07 -07:00
Victor KuznetsovandClaude Opus 4.8 9614615001 docs(landscape): confirm BFL hosted = C2PA-only on FLUX.1 [dev] too
Lossless-PNG check across both BFL Playground model lines (FLUX.2 [pro] and
FLUX.1 [dev]) confirms the open DWT-DCT pixel watermark is absent on hosted
output regardless of model or container; only the signed C2PA manifest is present.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 09:42:03 -07:00
Victor KuznetsovandClaude Opus 4.8 9e307d020e test(c2pa): add real FLUX.2 BFL C2PA fixtures (PNG + JPEG)
flux-1.png / flux-1.jpg are real Black Forest Labs FLUX.2 [pro] Playground
outputs (signed C2PA, issuer "Black Forest Labs" + trainedAlgorithmicMedia,
manifests verified to contain no personal data). flux-1.jpg is the first
committed JPEG-with-C2PA fixture, exercising the c2pa-python non-PNG reader path
end to end. Regression tests assert both attribute to "Black Forest Labs (FLUX)".

Also documents the verified finding (n=2, 2026-06-19): BFL's hosted output carries
the signed C2PA manifest but NOT the open invisible-watermark DWT-DCT (decodes to
degenerate all-ones, chance-level vs the FLUX reference) -- the open pixel mark is
dev-inference-code-optional only. So a hosted FLUX.2 image is identified by C2PA
alone, with no open-pixel fallback once C2PA is stripped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-19 09:37:40 -07:00
Victor KuznetsovandClaude Opus 4.8 d4d9429328 feat(identify): attribute Canva and BytePlus C2PA; fix BytePlus->Adobe mislabel
Mining the local production corpus (25,725 imgs) surfaced two AI vendors signing
C2PA that the registry missed:
- Canva (Magic Media) signed "Canva" + trainedAlgorithmicMedia -> detected AI but
  no platform attributed (disproves the old "Canva exports strip C2PA" assumption).
- BytePlus (ByteDance international: Seedream/Seededit) signs "Byteplus Pte. Ltd.";
  the bare volcengine needle missed it, so its output was mis-attributed to "Adobe
  Firefly" via an incidental "Adobe XMP" string the fallback byte-scan picked up.

Adding both to C2PA_AI_VENDORS lets the clean manifest issuer attribute them
directly. Corpus re-run: 16 platform changes, all improvements (3 Adobe->ByteDance
fixes, 4 None/TC260->ByteDance, 9 None->Canva), 0 regressions. An attempted
signer-based attribution fallback was measured and dropped: it regressed 18 images
(friendly ByteDance label -> raw Chinese cert org; IPTC tool name pre-empted).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 21:57:21 -07:00
Victor Kuznetsov be566e8868 chore(release): v0.11.3 2026-06-18 17:28:21 -07:00
Victor KuznetsovandClaude Opus 4.8 9f6c26a439 refactor(c2pa): read manifests via official c2pa-python, keep byte-scan fallback
extract_c2pa_info now uses the c2pa-python Reader first (any container, whole
manifest store incl. ingredient manifests), falling back to the hand-rolled caBX
parser for blobs the validator rejects (synthetic/partial, broken wheel). The
issuer/source-type/SynthID/soft-binding registry scan is shared by both paths
(_populate_registry_fields), so the return-dict contract is unchanged. Also
replaces the dead `from c2pa import has_c2pa_metadata` import in metadata.py with
a real Reader presence check. c2pa-python added as a core dep (MIT/Apache, ~+5MB
RSS, no torch; wheels cover the CI matrix).

Validated on the full local spaces corpus (25,725 imgs): 0 regressions; 384
manifests newly parsed (379 non-PNG JPEG/WebP + 2 PNGs the byte-scanner missed);
3 false Adobe/Microsoft->Google attributions fixed via real-manifest parsing.

The docs/module-internals.md section for this change already landed in 41f6797.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:24:58 -07:00
Victor KuznetsovandClaude Opus 4.8 41f67973ce fix(visible): inpaint mid-tone Gemini sparkle instead of a dark diamond
The free `visible` path over-subtracted a faint Gemini sparkle on a
mid-tone background into a darker-than-background brown diamond instead
of removing it (2026-06-18 prod NPS report, "the watermark was not
removed, just its color changed"). The existing over-subtraction guard
only tripped when reverse-alpha drove a footprint pixel fully negative
(the issue #30 dark-background black-pit case); on a mid-tone background
the over-subtraction darkens the core well below the background without
any pixel crossing zero, so the gate missed it and shipped the dark mark.

Add a second over-subtraction signal to `_reverse_alpha_oversubtracts`:
predict the reverse-alpha output at the bright core, (core - a*logo)/(1-a),
and route to the footprint inpaint when it lands more than
`_OVERSUB_DARK_MARGIN` (25) gray levels below the local background ring.
Calibrated wide: clean removals predict within ~12 of background
(demo_banana ~-1), the prod regression ~-40, the issue #30 dark case ~-82.
Corpus-validated on the 479 detected Gemini images: 10 switch reverse-alpha
to inpaint, all of them dark-diamond cases that improve or match; the
other 469 stay byte-identical. demo_banana stays on the reverse-alpha
path (byte-identical).

Also crop both reverse-alpha helpers to the region they actually touch,
a pure O(image) -> O(mark) win that is byte-identical to the full-frame
math (a uint8<->float32 round-trip is exact):
- `GeminiEngine._core_and_bg` converts only the footprint+ring crop to
  gray, not the whole frame (~70 ms -> 0.1 ms on a 12 MP image; it runs
  for both the alpha-gain estimate and the new gate). Verified identical
  across 479 images; detector confidence unchanged.
- `TextMarkEngine._apply_reverse_alpha` computes the blend on the glyph
  crop only (`amap` is zero outside it, so the math is a no-op there):
  ~275 ms -> ~2 ms per placement on a 12 MP frame, up to 2 placements per
  removal. Verified identical across 142 Doubao/Jimeng placements.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-18 17:19:41 -07:00
Victor Kuznetsov 09fdb4544a fix(invisible): preserve native output dimensions 2026-06-18 16:44:21 -07:00
Victor KuznetsovandClaude Sonnet 4.6 61aa76a591 perf(identify): decode the image once for all visible-mark detectors
identify(check_visible=True) ran the Gemini-sparkle detector and the
Doubao/Jimeng text-mark detector each with its own image_io.imread, so the
same bitmap was fully decoded twice. On a memory-constrained host (the raiw.cc
512 MB web worker, which runs identify on every upload) that doubled the peak
decode allocation and contributed to OOM restarts.

Decode once in identify() and pass the BGR array to both detectors. The detect
methods already accept an NDArray, so this only threads the pre-decoded array
through: detect_sparkle_confidence and the two _visible_* helpers gain an
optional image= param that, when None, preserves the old self-read behavior
(so direct callers and the cv2-missing/unreadable paths are unchanged).

Only the visible path is deduplicated; the optional check_invisible decoders
are unaffected (and off on the web hot path). Adds a test asserting
identify(check_visible=True, check_invisible=False) decodes exactly once.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-18 11:13:17 -07:00
dependabot[bot] cd0a79df38 chore(deps): bump the minor-and-patch group with 5 updates (#50)
Bumps the minor-and-patch group with 5 updates:

| Package | From | To |
| --- | --- | --- |
| [transformers](https://github.com/huggingface/transformers) | `5.10.2` | `5.12.1` |
| [accelerate](https://github.com/huggingface/accelerate) | `1.13.0` | `1.14.0` |
| [huggingface-hub](https://github.com/huggingface/huggingface_hub) | `1.18.0` | `1.19.0` |
| [pytest](https://github.com/pytest-dev/pytest) | `9.0.3` | `9.1.0` |
| [ruff](https://github.com/astral-sh/ruff) | `0.15.16` | `0.15.17` |


Updates `transformers` from 5.10.2 to 5.12.1
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.10.2...v5.12.1)

Updates `accelerate` from 1.13.0 to 1.14.0
- [Release notes](https://github.com/huggingface/accelerate/releases)
- [Commits](https://github.com/huggingface/accelerate/compare/v1.13.0...v1.14.0)

Updates `huggingface-hub` from 1.18.0 to 1.19.0
- [Release notes](https://github.com/huggingface/huggingface_hub/releases)
- [Commits](https://github.com/huggingface/huggingface_hub/compare/v1.18.0...v1.19.0)

Updates `pytest` from 9.0.3 to 9.1.0
- [Release notes](https://github.com/pytest-dev/pytest/releases)
- [Changelog](https://github.com/pytest-dev/pytest/blob/main/CHANGELOG.rst)
- [Commits](https://github.com/pytest-dev/pytest/compare/9.0.3...9.1.0)

Updates `ruff` from 0.15.16 to 0.15.17
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.16...0.15.17)

---
updated-dependencies:
- dependency-name: transformers
  dependency-version: 5.12.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: accelerate
  dependency-version: 1.14.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: huggingface-hub
  dependency-version: 1.19.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: pytest
  dependency-version: 9.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.15.17
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-06-18 10:07:41 -07:00
Victor KuznetsovandClaude Opus 4.8 4c6b56f888 lower(strength): drop vendor-adaptive floor to OpenAI 0.10 / Google 0.15
A 2026-06-14 oracle re-test on the deployed Modal controlnet worker (v0.10.0)
cleared SynthID at OpenAI 0.10 (2 photoreal) and Google 0.15 (2 native
2816x1536, retiring the "native >= 0.30" guess), while a pixel sweep showed the
2026-06-04 cert floors (0.20/0.30) over-regenerated for no efficacy gain
(Google MAE -20% at 0.15). Lowers OPENAI_STRENGTH 0.20->0.10, GEMINI_STRENGTH
and UNKNOWN_STRENGTH 0.30->0.15.

Caveats documented in watermark_profiles.py + docs: removal near this floor is
seed-non-deterministic (a service must pin a verified seed), and the n=2 re-test
did not cover flat-graphic hard cases.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-14 13:17:11 -07:00
Victor KuznetsovandClaude Opus 4.8 41a2af2ecb fix(cli): preserve SynthID uncertainty in no-visible-mark message
The 'no signal' branch of the visible no-mark path claimed 'No AI provenance
signal found either', which reads as 'the image is clean'. A missing metadata
proxy is not proof an invisible pixel watermark (SynthID) is absent: it cannot
be detected once metadata is gone and may have been stripped upstream. The
message now preserves that uncertainty and routes to both 'all' (regenerate
pixels) and 'erase'. Regression-guarded by the SynthID/all asserts in
test_cli.py. CLAUDE.md visible-command note updated to match.

Also adds a 'Scope and non-goals' section (CLAUDE.md + README): removing
AI-provenance marks on the user's own content is in scope; stripping
stock/paid-content watermarks (Shutterstock/Getty/iStock, classifieds) is out
of scope by principle, not by difficulty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 19:30:49 -07:00
Victor KuznetsovandClaude Opus 4.8 d8cdc9f478 docs: correct stale strength-ladder values in remove_watermark docstring
The convenience wrapper's docstring still quoted the pre-2026-06 ladder
(0.10 OpenAI / 0.15 Google / 0.15 unknown). The live constants in
watermark_profiles.py are 0.20 / 0.30 / 0.30, applied to both the controlnet
and sdxl pipelines. Docstring only; behaviour was already correct via
vendor_for_strength + resolve_strength.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-13 09:51:09 -07:00
Victor Kuznetsov 6237429610 chore(release): v0.11.2 2026-06-12 21:37:04 -07:00
Victor KuznetsovandClaude Opus 4.8 30b56f0ea3 fix(cli): stop silent passthrough when visible finds no known mark
When `visible --mark auto` (or an explicit `--mark` with detection on) found
no registered mark, it exited 0 without writing output -- which a wrapping
service reads as success and re-serves the unchanged input. ~74% of real
uploads carry no registered visible mark, so this was the dominant "it didn't
work" / NPS score-0 failure mode.

Now it runs a cheap metadata-only identify, prints actionable guidance (route
to `all` for an invisible/metadata mark, or `erase` for an arbitrary logo),
writes no output file, and exits EXIT_NO_VISIBLE_MARK (2) -- distinct from
success (0) and a hard error (1) so the caller can surface the message.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 21:36:56 -07:00
Victor KuznetsovandClaude Opus 4.8 b08405bece chore(release): v0.11.1
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:15:20 -07:00
Victor KuznetsovandClaude Opus 4.8 28569bd05d fix(gemini): recover sub-0.85 corner sparkles via top-K fusion selection
The 256->512 detection-search widening (v0.8) let a large, low-gradient
shape match outrank a genuine mid-size corner sparkle whose raw NCC sits
below the 0.85 corner-promote gate, so `identify` read `unknown` on Gemini
images that v0.7.2 caught (reporter osachub: scale-48 sparkle on light
bedding -- true sparkle spatial 0.775 / grad 0.960 / fusion 0.676, but the
size-weighted argmax locked onto a decoy at spatial 0.628 / grad 0.036).

detect_watermark now keeps the top-K (_SELECT_TOPK=3) size-weighted
candidates (NMS-deduped) plus the corner-promote candidate, scores each by
full fusion (spatial+gradient+variance) via the extracted _grad_var_scores
helper, and selects the highest -- the gradient term lifts the true sparkle
over the decoy. Ranking by the SIZE-WEIGHTED score (not a raw-NCC argmax)
preserves tiny-patch suppression: a raw-NCC argmax re-admitted 16-18px
content false positives (14/65 doubao + 4/11 jimeng visible images). Top-K
adds zero flips on the doubao/jimeng corpora and leaves the 495-image Gemini
set unchanged (479 detected) while recovering the reporter's image at 0.676.

- _grad_var_scores: gradient/variance scoring factored out of detect_watermark
- confidence = best_fused (drop the duplicated fusion recompute)
- tests: rename test_promotion_is_what_rescues_it ->
  test_size_weighted_search_alone_traps_on_the_decoy (corner-promote is no
  longer the sole rescue path); add a deterministic regression test mirroring
  the real spatial/grad signature
- docs: module-internals.md detector section + CLAUDE.md mechanism map

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-12 12:04:20 -07:00
Victor KuznetsovandClaude Fable 5 9feea4ac1e Slim CLAUDE.md: move module internals, limitations, landscape research to docs
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 15:50:03 -07:00
Victor KuznetsovandClaude Fable 5 3055aa6c4a test: patch is_available in full-pipeline all tests (fix no-gpu CI)
test_all_basic / test_all_visible_step_uses_registry asserted exit 0 but did
not patch is_available, so on CI (core+dev only, no gpu) they took the skip
branch and hit the new non-zero exit. Passed locally where gpu is present.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:07:05 -07:00
Victor KuznetsovandClaude Fable 5 c8bc4b7c68 chore(release): v0.11.0
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 10:03:51 -07:00
Victor KuznetsovandClaude Fable 5 a8e218acf6 Make all fail loudly when the gpu extra is missing
Step 2 (invisible/SynthID) was skipped with a quiet inline warning and the
run still exited 0, so a missing [gpu] extra was mistaken for a clean result
(recurring #14/#47). Add a prominent end-of-run banner and a non-zero exit.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:58:49 -07:00
Victor KuznetsovandClaude Fable 5 ad7e4ee08b feat(identify): close 3 detector gaps found on the spaces corpus (06-05..06-11)
- AIGC: parse the bare ``AIGC{...}`` blob form (label glued to its JSON in a
  JPEG APP segment near the JFIF header), and scan both raw-JSON forms in one
  fall-through loop so a quoted ``"AIGC"`` later in an XMP packet no longer
  shadows a real bare label earlier in the file (3 files read unknown before).
- Integrity clash rule 2: a camera device + an AI marker from the SAME C2PA
  manifest (Google Pixel Magic Editor / Pixel Studio edit chain) is a legitimate
  edit chain, not a contradiction. Fire only when the AI marker's source is
  independent of the camera's manifest; pure cameras (Leica/Sony/Nikon) are
  unaffected (2 Pixel files mis-flagged before).
- New c2pa_cloud_manifest detector: surface a C2PA 2.4 Durable Content
  Credentials cloud-manifest reference (Adobe cai-manifests.adobe.com) as a
  medium provenance signal when the embedded manifest is stripped. Provenance
  only, never asserts is_ai (2 files read fully unknown before).

identify reuses its already-loaded scan head for the cloud check (no second
read). +7 tests; CLAUDE.md + README synced.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-11 09:28:15 -07:00
Victor KuznetsovandClaude Opus 4.8 22bc171806 ci: bump checkout to v6 (Node 24), note dismissed torch alert
actions/checkout@v4 ran on the deprecated Node 20; bump to v6 to match
test.yml/publish.yml. Document the dismissed Dependabot torch alert
(GHSA-rrmf-rvhw-rf47, not_used: no torch.jit usage, gpu-extra-only, no patch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 16:00:35 -07:00
Victor KuznetsovandClaude Opus 4.8 d763581ed3 chore(release): v0.10.3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:53:50 -07:00
Victor KuznetsovandClaude Opus 4.8 0d99f403fb ci: auto-distribute releases to Homebrew tap + HF Space
distribute.yml fans a published GitHub Release out to the channels that
would otherwise be manual: it waits for the sdist on PyPI, bumps the
Homebrew formula (HOMEBREW_TAP_TOKEN) and factory-rebuilds the HF Space
(HF_TOKEN). PyPI stays on publish.yml; conda-forge on its autotick bot.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 15:47:03 -07:00
Victor KuznetsovandClaude Opus 4.8 e78e5f1154 docs: address HN feedback in README (scope, limitations, honest use case)
From the HN front-page discussion (news.ycombinator.com/item?id=48200569):
- Threat model: drop the 'third-party classifiers' overclaim. State scope
  honestly: it removes SynthID / visible marks / provenance metadata, does NOT
  defeat trained AI-vs-real classifiers (Hive), and watermarks are a weak trust
  signal to begin with.
- Replace the 'preserving art / historical record' use case (criticized as not
  holding) with the defensible one: clearing an overstated AI label from your
  own lightly-AI-edited photo.
- Add a Limitations section: lossless visible/metadata vs lossy content-dependent
  SynthID path, no local self-verify, large images not tiled yet, out-of-scope.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 11:41:39 -07:00
Victor KuznetsovandClaude Opus 4.8 0a77d3198e chore(release): v0.10.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:38:50 -07:00
Victor KuznetsovandClaude Opus 4.8 9aea5f240f chore: improve discoverability (PyPI keywords/classifiers, README badges)
Research-informed metadata for organic dev discovery:
- pyproject: add a keywords field (was absent; biggest PyPI search gap) and
  expand classifiers (audience, console, security, AI, utilities); rewrite the
  summary noun-first, naming Nano Banana / SynthID / C2PA verbatim.
- README: add PyPI version, Python versions, downloads, and license badges.

GitHub topics (comfyui, watermark-remover) and the repo description were
updated out of band. PyPI metadata ships on the next release.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-10 10:34:43 -07:00
Victor KuznetsovandClaude Opus 4.8 c3ddf8a801 docs: document Homebrew, conda-forge, and ComfyUI distribution channels
- README: add Homebrew install, conda (conda-forge, in review), and a
  ComfyUI custom-nodes section.
- CLAUDE.md: per-channel release/bump cadence (Homebrew formula, conda-forge
  autotick bot, ComfyUI Registry); note pip_check: false on the conda recipe.
- Add packaging/conda/recipe.yaml (v1, noarch core-only), verified green on
  conda-forge/staged-recipes PR #33674.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 19:29:40 -07:00
Victor KuznetsovandClaude Fable 5 5777458296 chore(release): v0.10.1
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 17:08:44 -07:00
Victor KuznetsovandClaude Fable 5 295e7ada2b chore: project review (dev tools in extras, dep upgrades, optional-deps guard, stale cleanup)
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-06-09 17:03:17 -07:00
Victor KuznetsovandClaude Opus 4.8 826cfdb82a chore(release): v0.10.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:24:37 -07:00
Victor KuznetsovandClaude Opus 4.8 2fcd00ced0 fix: address whole-project code review (visible all/batch, engine consolidation, I/O)
Nine findings from a high-effort project-wide review, fixed and verified
(571 passed, ruff/pyright clean):

Correctness:
- all/batch now remove Doubao/Jimeng/Samsung visible text marks: the visible
  step routes through the registry (new cli._remove_visible_auto) instead of a
  hardcoded GeminiEngine, so they no longer leave the wordmark intact.
- batch always reads the original source (dropped the out_path-reuse that
  re-processed already-cleaned outputs on a re-run).
- img2img_runner only retries the diffusion call on the deprecated-callback
  TypeError; any other TypeError now propagates instead of double-running.
- gemini detect/remove and the reverse-alpha engines normalize channels via a
  new image_io.to_bgr, fixing a grayscale/BGRA crash in the FP-gate path.
- _png_late_metadata advances its cursor by the clamped length, so a malformed
  chunk length no longer aborts the late AI-label scan.

Cleanup / efficiency:
- Consolidate the ~90%-identical Doubao/Jimeng/Samsung engines into a shared
  config-driven _text_mark_engine.TextMarkEngine base; each engine is now a thin
  subclass (TextMarkConfig + test shims). Behavior is byte-exact (the three
  engine test suites pass unchanged). Registry adapters collapse to one
  _text_mark(...) row each. Gemini stays a separate engine.
- scan_head is memoized per (path, size, mtime), so identify() reads the file
  head once instead of ~8 times.
- invisible_engine post-processing decodes/encodes the output once (chained in
  memory) instead of 2-4 times across stages.
- Remove the orphaned get_model_id_for_profile (+ CONTROLNET_PROFILE); derive
  the --strength help from the strength constants (strength_default_help) so it
  cannot drift; share the --pipeline/--strength click options; simplify the
  retired --auto resolver.

Net -835 lines. Tests added for the registry-routed visible pass, to_bgr,
the polish/model/guidance wiring, and strength_default_help. CLAUDE.md updated
for the new base module, the engine/registry changes, image_io.to_bgr, and the
scan_head cache.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 13:21:13 -07:00
Victor KuznetsovandClaude Opus 4.8 b1189549b8 feat(invisible): controlnet default, unified strength, retire --auto, add --model/--guidance-scale
Overhaul the diffusion-removal surface around a single robust default and a
complete, consistent CLI.

Pipeline + strength:
- controlnet is now the DEFAULT pipeline (CLI --pipeline + both engine ctors).
  With the certified higher strength it clears both photoreal and flat-graphic
  content, whereas plain SDXL left SynthID on flat graphics.
- Rename the plain-SDXL profile default -> sdxl; "default" stays as a back-compat
  alias (normalize_profile + a click callback that warns).
- Unify the strength ladder: resolve_strength applies ONE vendor-adaptive ladder
  (the certified controlnet floors OpenAI 0.20 / Google 0.30 / unknown 0.30) to
  both pipelines. sdxl is the weaker remover on its own hard case (flat fills),
  so the certified floor is the right floor for it too.

CLI completeness:
- Add --model (HF model id) to invisible + batch (was only on all) and
  --guidance-scale (CFG) to all three diffusion commands; both were library
  knobs the CLI did not expose.
- Flip --adaptive-polish to ON by default (it self-gates to a no-op where there
  is no detail deficit, so default-on is safe).
- Share --pipeline / --strength / --model / --guidance-scale as single
  decorators so invisible/all/batch keep an identical surface; the --strength
  help is derived from the strength constants (strength_default_help) so it can
  never drift from the ladder.

Removals:
- Delete the auto_config content-detection planner + its YuNet/DBNet assets
  (~2.6 MB): with controlnet always the pipeline and the polish self-gating, the
  face/text/edge detection no longer changed behavior. --auto is now a deprecated
  no-op that only warns (the polish it enabled is the default).

Docs (README, CLAUDE.md, docs/synthid.md) updated throughout; added an
InvisibleEngine Python API example. Tests cover the alias warnings, the
polish default, and the --model/--guidance-scale wiring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 12:40:45 -07:00
Victor KuznetsovandClaude Opus 4.8 efc5b4a9af docs(auto): drop stale face-restore mentions from --auto
The face-restore family was removed in 20d7eda, but the auto_config
module docstring still claimed "PhotoMaker face restoration is enabled
when a face is present" and the --auto help text (CLI + README example)
listed "face restore" as something --auto picks. A detected face now
only routes to the controlnet pipeline (canny preserves face STRUCTURE,
not identity); there is no identity restoration. Comments/docstrings/help
only, no code behavior change.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-09 11:12:53 -07:00
Victor KuznetsovandClaude Opus 4.8 ea098cf1be chore(release): v0.9.0
BREAKING:
- Drop `--restore-faces` / `--restore-faces-method` CLI flags
- Drop `restore`, `photomaker`, `instantid` extras
- Drop `restore_faces` / `restore_faces_method` params from
  InvisibleEngine.remove_watermark and AutoConfig

Rationale (full empirical record in
docs/synthid-robust-identity-research-2026-06-08.md "Empirical follow-up"):
every face-restore approach evaluated 2026-06-04 - 2026-06-08 (GFPGAN-on-
cleaned, PhotoMaker-V2, InstantID txt2img, InstantID img2img-on-cleaned
at three parameter sweeps) regenerates the face via SDXL diffusion --
output face pixels are diffusion-fresh, so the regenerated face inherits
SDXL's "clean skin" aesthetic and loses original identity precision. The
result looks MORE AI-generated than the cleaned image, not less. The
cleaned controlnet 0.20 image is the least-AI face state we can reach
without re-introducing SynthID.

License:
- MIT -> Apache 2.0 (Apache adds an explicit patent grant + trademark
  clause; better fit with the upstream Apache projects this library
  mirrors / depends on -- diffusers, transformers, controlnet-aux,
  xinsir's controlnet weights)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:28:09 -07:00
Victor KuznetsovandClaude Opus 4.8 a4554bb5d3 chore(license): switch from MIT to Apache 2.0
Replace LICENSE with the canonical Apache License 2.0 text + a brief
copyright notice for "wiltodelta 2025-2026". Update pyproject.toml's
`license` field to "Apache-2.0" and the PyPI classifier to "Apache
Software License". Update README's License section to point at the
LICENSE file and name the copyright holder.

Why: Apache 2.0 gives downstream users an explicit patent grant and the
trademark-use clause, which MIT doesn't carry. It is also the more
common license among the upstream projects this library depends on /
mirrors (diffusers, transformers, controlnet-aux, xinsir's canny
controlnet weights), so contributions can flow either way without a
permission-shape mismatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:23:57 -07:00
Victor KuznetsovandClaude Opus 4.8 20d7eda96a remove: drop all face-restore code (regeneration, not preservation)
Empirical conclusion from the 2026-06-04 - 2026-06-08 Modal cert sweeps:
every face-restore approach we built (GFPGAN-on-cleaned, PhotoMaker-V2,
InstantID txt2img, InstantID img2img-on-cleaned at three parameter
settings) regenerates the face via SDXL diffusion rather than preserves
it. Output face pixels are diffusion-fresh, so the regenerated face
inherits SDXL "clean skin" aesthetic and loses original identity
precision -- it looks MORE AI-generated than the cleaned image, not
less. The cleaned image from the main controlnet 0.20 removal pass is
the least-AI face state we can reach without re-introducing SynthID.

Nothing in the restore family achieves the actual goal (preserve the
original person's face). Keeping them around as opt-in invites users to
ship something that defeats the point. Removing entirely.

Library changes:
- Deleted src/remove_ai_watermarks/instantid_restore.py
- Deleted src/remove_ai_watermarks/photomaker_restore.py
- Deleted tests/test_instantid_restore.py
- Deleted tests/test_photomaker_restore.py
- Removed `instantid` and `photomaker` extras from pyproject.toml
- Removed `[tool.hatch.metadata] allow-direct-references = true` (was
  only needed for the photomaker git+ URL)
- InvisibleEngine.remove_watermark: dropped `restore_faces` +
  `restore_faces_method` params, removed both `_restore_faces_instantid`
  and `_restore_faces_photomaker` private methods, removed dispatch
- CLI: dropped `_restore_faces_options` decorator, all four cmd_*
  signatures lose `restore_faces` + `restore_faces_method`, kwarg passes
  to remove_watermark dropped
- _apply_auto: dropped `restore_faces` from tuple shape (was unused after
  the engine no longer takes it)
- auto_config.AutoConfig: dropped `restore_faces` field; `plan()` no
  longer sets it; `reason` no longer mentions it
- Tests updated accordingly (test_auto_config.TestReason no longer asserts
  "face-restore on" in the reason string)

Docs updated:
- CLAUDE.md: removed the photomaker extras bullet, the Face restore
  trade-off bullet, the instantid_restore.py + photomaker_restore.py
  module bullets; replaced restore mentions in watermark_remover and
  controlnet bullets and prod recipe with the empirical conclusion
- README.md: removed both `--restore-faces` callouts and the install
  snippet; the feature bullet and auto-mode comment updated
- docs/synthid-robust-identity-research.md: added Status-retired notice
  at the top pointing at the 2026-06-08 followup

raiw-app:
- modal_cert.py: dropped `--restore-faces` flag entirely; sweep() no
  longer takes restore_faces; pinned _LIB_SPEC to `[gpu]` extras (no
  `photomaker` / `instantid` extras), points at main

ruff + strict pyright clean; 569 tests pass; 18 restore-specific tests
gone.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:21:58 -07:00
Victor KuznetsovandClaude Opus 4.8 567f3ae729 docs(restore): document that restore methods REGENERATE, not preserve
Empirical conclusion from the 2026-06-04 - 2026-06-08 cert sweeps:
every shipped face-restore method (GFPGAN-on-cleaned, PhotoMaker-V2,
InstantID txt2img, InstantID img2img-on-cleaned at three parameter
settings) regenerates the face from an ArcFace embedding via SDXL
diffusion. Output face pixels are diffusion-fresh, which makes the
regenerated face look MORE AI-generated than the cleaned image (gloss,
symmetric pores, SDXL "clean skin" aesthetic) regardless of license.

The cleaned image from the main controlnet 0.20 removal pass is the
LEAST-AI state we can reach without re-introducing SynthID; any restore
on top trades original-look for embedding-driven regeneration. The
fundamental issue is structural: ArcFace encodes "general look" at 512
dimensions, SDXL decodes that into pixels with the inherent SDXL
aesthetic. Stronger identity push (higher strength + IP-Adapter scale)
makes the face closer to the embedding but more AI-looking; weaker push
leaves identity to drift further. No parameter setting recovers original
identity AND looks less AI than cleaned.

Production conclusion: do not ship `--restore-faces` in any monetized
deployment. The extras (`instantid`, `photomaker`) stay in the library
for research / personal use where users explicitly want regeneration.
Documented at every entry point:
- CLAUDE.md: new "Face restore trade-off" bullet + every restore mention
  rewritten to "REGENERATES, does NOT recover"; controlnet bullet updated
- README.md: feature bullet + callout + secondary mention all updated
- docs/synthid-robust-identity-research-2026-06-08.md: appended
  "Empirical follow-up" section documenting the InstantID sweep phases
  (Phase 1 txt2img v1/v2/v3, Phase 2 img2img defaults + stronger params)
- docs/controlnet-removal-pipeline-research.md: updated restore-faces
  bullet to reflect the empirical conclusion
- CLI help: `_restore_faces_options` docstring + `--restore-faces` /
  `--restore-faces-method` help text all updated

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 21:08:11 -07:00
Victor KuznetsovandClaude Opus 4.8 7d8af7882a tune(instantid): raise IP-Adapter + landmark scale + strength for stronger identity
First img2img cert sweep: scene/lighting integration was excellent on both
single (tatsunari) and group (gemini_3) photos, but the regenerated faces
were "recognizable similar people" rather than the original individuals.
The cleaned face crop (which has already drifted from original through the
main controlnet 0.20 removal pass) was competing as a structural prior;
at the previous parameter settings InstantID's ArcFace branch couldn't
dominate it.

Push the identity signal:
- `ip_adapter_scale`: 0.8 -> 1.0 at load time (full IP-Adapter strength)
- `controlnet_conditioning_scale`: 0.8 -> 1.0 default (landmark anchor)
- `img2img_strength`: 0.55 -> 0.7 default (more denoise, less cleaned
  structure survives, more room for the diffusion to render ArcFace)

The cleaned image already passed the SynthID oracle, so the absolute floor
on strength is "any positive value" -- raising it only increases the
freedom of the diffusion to inject identity (SynthID-safety isn't reduced
by higher strength, because the noise injection only destroys more of the
input pixels).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:54:41 -07:00
Victor KuznetsovandClaude Opus 4.8 8ed2d16a23 fix(instantid): pass trust_remote_code=True for local custom_pipeline
The img2img run silently produced an identity output because
DiffusionPipeline.from_pretrained refused to load the local custom_pipeline
.py without `trust_remote_code=True` (emits a single-line warning to stderr,
then falls back to a default class). load_ip_adapter_instantid then
AttributeError'd, our outer except logged + skipped, and the saved file
was the un-restored cleaned image (exact byte size match against the
no-restore baseline -- 250988 bytes).

We fetch the file from a pinned raw.githubusercontent URL we control, so
trust_remote_code is safe to opt in here.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:47:26 -07:00
Victor KuznetsovandClaude Opus 4.8 2687604b24 feat(instantid): switch from txt2img to img2img on cleaned crop
The txt2img architecture (generate face from scratch in a fresh 1024 scene)
fundamentally couldn't fix multi-face patchwork: each face was a studio
portrait that didn't belong in the surrounding scene (wrong lighting,
frontal pose, neutral expression vs the original group photo's varied
angles and smiles). Tight crop + elliptical alpha + color match smoothed
the seams but didn't make the faces look like they were SHOT in the scene.

Replacing with img2img-on-cleaned: feed the CLEANED face crop as the img2img
source, so the diffusion sees the actual scene context (shoulders, hair
edges, lighting direction, shadows) and harmonises the regenerated face
with it. Identity still flows through the ArcFace embedding (from original)
+ landmark ControlNet (kps from original) -- both semantic / pure geometry,
neither carries pixels.

SynthID safety preserved by construction:
- img2img source pixels = cleaned crop = already oracle-verified clean
- ArcFace embedding = 512-d semantic vector from original, no pixel content
- Landmark stick figure = colour-coded geometry, no source pixels
- img2img noise injection at strength 0.55 destroys any residual high-freq
  pattern in the cleaned crop
- Pipeline is the upstream StableDiffusionXLInstantIDImg2ImgPipeline,
  inherits from StableDiffusionXLControlNetImg2ImgPipeline; we still patch
  check_inputs to neutralise the same diffusers-0.38 positional shift the
  txt2img variant had

Implementation:
- New _fetch_img2img_pipeline_file() caches the upstream pipeline file from
  GitHub raw on first use (not on PyPI / HF Hub, has to be downloaded
  separately)
- _get_pipeline() now loads StableDiffusionXLInstantIDImg2ImgPipeline via
  custom_pipeline=<cached path>
- restore_faces_instantid() crops the SAME bbox from both original and
  cleaned, runs InsightFace on original (sharper embedding), feeds cleaned
  crop as img2img source, ArcFace+landmark as conditioning
- New img2img_strength=0.55 parameter (was no strength knob in txt2img mode)
- Composite path unchanged (elliptical alpha + color_match)
- 9 control-flow tests still pass (the mock pipe call shape change is
  absorbed by the kwargs-only fake)

Cert sweep will validate on tatsunari (single) first per user request.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:43:27 -07:00
Victor KuznetsovandClaude Opus 4.8 7c0c16fd66 test(instantid): update composite assertion to survive color-match
Last commit added `_color_match` which shifts the face crop's mean to the
canvas mean -- the old test fed a uniform face (210) into a uniform cleaned
canvas (90), so after color-match the face was uniform 90 and the
composite was undetectable by value. Switched the fake pipeline to a
gradient face so the color-match preserves variance, and the assertion
now checks that the face region has non-zero std (composite injected
gradient pixels) instead of a value threshold.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:26:56 -07:00
Victor KuznetsovandClaude Opus 4.8 cdd6bd1fea feat(instantid): tighter face ellipse + color match for cleaner multi-face composite
Second multi-face iteration. v1-rect: full-1024 frame + Gaussian rectangle ->
patchwork. v2-ellipse: tight crop + ellipse 0.45*bw x 0.55*bh -> ellipse
exceeds bbox vertically and clips forehead/chin on single portrait, plus
group-photo faces visibly drift cooler than the warm bar background. v3:

1. **Smaller ellipse axes**: 0.32*bw x 0.42*bh. Both fit inside the bbox (since
   axes are radii from center, 0.32*bw extends 0.64*bw total width and
   0.42*bh extends 0.84*bh total height) so no chin/forehead clip even on
   non-square boxes. Face shape: vertically elongated (0.42 vs 0.32),
   matching real face geometry.

2. **Wider feather**: `min(bw, bh) // 5` instead of // 8. Edges fade over a
   wider band so the elliptical seam is less visible.

3. **Per-channel mean color match** (`_color_match`): before compositing,
   shift the regenerated face's mean BGR to match the cleaned canvas region
   where it lands. Each InstantID generation has independent SDXL noise so
   white balance drifts -- matching means equalises tone (warm bar / cool
   face -> warm face) without rescaling contrast.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:25:34 -07:00
Victor KuznetsovandClaude Opus 4.8 92c7245e2d chore: drop unused _composite_faces import
Linter caught it after the elliptical composite swap.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:18:37 -07:00
Victor KuznetsovandClaude Opus 4.8 1786f6de9f feat(instantid): multi-face anti-patchwork (tight-crop + elliptical composite)
Group-photo cert sweep last round produced the same "patchwork quilt" failure
mode as PhotoMaker-V2: each face is regenerated as a fresh 1024x1024 SCENE
(face + background + lighting), then composited as a Gaussian-feathered
RECTANGLE into the 2x square box around the original face. The rectangle's
corners carry regenerated background pixels with different colors / textures
per face, and the rectangular Gaussian feather lets them bleed into the
cleaned image -- 9 face renders with 9 different backgrounds -> patchwork.

Two changes, both surgical:

1. **Tight-crop the regenerated face before composite.** After generation,
   run YuNet again on the 1024 frame to find where the face actually landed,
   then crop tightly around it (matching the 2x padding our input crop uses
   so the face fills its natural slot). Drops the regenerated background's
   peripheral pixels.

2. **Elliptical composite alpha** (`_composite_faces_elliptical`). Instead of
   reusing photomaker_restore's rectangular Gaussian alpha, inscribe an
   ellipse in each face bbox (axes ~0.45*bw x 0.55*bh so the feather edge
   tapers cleanly inside the rectangle, head-silhouette shape), feather only
   the ellipse edge. Bbox corners (regenerated scene context) end up at
   alpha=0 and the cleaned-canvas pixels there stay intact. Only the head
   region is replaced.

Net result: faces stay identity-restored (semantic ArcFace + landmark control
still drives generation) but the canvas around each face is the cleaned
image, not a regenerated frame. No more multi-face patchwork.

Single-portrait case unchanged: there's one face to composite and the cleaned
canvas around it is mostly the background that was already there.

All 9 InstantID control-flow tests still pass (the mock face analyser
responds to both .get() calls with the same fake bbox, so the new
generated-image YuNet step is exercised end-to-end).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:18:15 -07:00
Victor KuznetsovandClaude Opus 4.8 4ec8ffec6b fix(instantid): patch check_inputs for diffusers-0.38 + set scale at load time
Two compat bugs caught by the Modal cert sweep, both rooted in diffusers
0.38 vs InstantID's community pipeline expectations:

1. **Positional check_inputs misalignment.** InstantID's __call__ calls
   `self.check_inputs(...)` POSITIONALLY using the parent's ~v0.29 signature.
   Diffusers 0.38 added two new parameters BEFORE `controlnet_conditioning_scale`
   in the parent's signature (`ip_adapter_image`, `ip_adapter_image_embeds`),
   which shifts every positional arg by two slots. The argument that lands in
   the parent's `controlnet_conditioning_scale` slot is actually InstantID's
   `control_guidance_end` -- which a few lines earlier was converted to `[1.0]`
   (a list) by InstantID's auto-broadcasting for the single-controlnet case.
   The parent's check then trips on `not isinstance([1.0], float)` -> TypeError.

   Our inputs are programmatic and validated by our own callers, so neutralising
   `pipe.check_inputs = lambda *a, **k: None` after load is safe. This is the
   standard workaround community ComfyUI ports use for the same compat break.

2. **`ip_adapter_scale` was passed at call time and silently ignored.** It's not
   in `StableDiffusionXLInstantIDPipeline.__call__`'s signature -- the upstream
   API sets the IP-Adapter weight on the ArcFace cross-attention branch at LOAD
   time via `load_ip_adapter_instantid(scale=...)`. Moved the 0.8 default there,
   dropped the call-time kwarg.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 20:07:31 -07:00
Victor KuznetsovandClaude Opus 4.8 53d753f2ad fix(instantid): pre-fetch antelopev2 from HF mirror (InsightFace auto-link is broken)
InsightFace's built-in auto-download for the antelopev2 model pack
(github.com/deepinsight/insightface/releases/download/v0.7/antelopev2.zip)
has been broken since at least 2024 (upstream issues #2517, #2766, called
out in InstantID's README: "manually download via this URL to models/
antelopev2 as the default link is invalid").

When the .onnx files aren't in place, FaceAnalysis.prepare() raises
`assert 'detection' in self.models` -- which is exactly what our Modal
cert sweep hit on the first real run.

Fix: a tiny pre-flight `_ensure_antelopev2()` that pulls the five expected
.onnx files (1k3d68, 2d106det, genderage, glintr100, scrfd_10g_bnkps) from
the HuggingFace mirror `kidyu/antelopev2-for-InstantID-ComfyUI` into
./models/antelopev2/ before FaceAnalysis is instantiated. Idempotent
(skips files that already exist); uses huggingface_hub's cache for free
caching on the Modal volume.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:58:40 -07:00
Victor KuznetsovandClaude Opus 4.8 00c559482f fix(invisible-engine): log exc_info + exception class on restore_faces failure
The InstantID cert sweep emitted `restore_faces post-pass failed ()` -- the
exception's str() was empty so the log line told us nothing about what
actually failed. Adding `exc_info=True` plus `type(e).__name__` so the
full traceback and exception class land in the log even when the message
is empty.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:53:07 -07:00
Victor KuznetsovandClaude Opus 4.8 a296d5fe46 fix(instantid): inline YuNet detection (the imagined _get_yunet doesn't exist)
The InstantID restore module imported `_get_yunet` from `auto_config`, but
auto_config doesn't export that function -- the YuNet singleton lives inline
inside `detect_face()`. Caught by the Modal cert sweep:

  restore_faces post-pass failed (cannot import name '_get_yunet' from
  'remove_ai_watermarks.auto_config'); keeping un-restored output

Inline the YuNet builder the same way `photomaker_restore` does (read
`auto_config._FACE_SCORE` and the bundled `face_detection_yunet_2023mar.onnx`
asset, build a fresh `FaceDetectorYN` per call). This is the proven pattern
from PhotoMaker and avoids a private-API drift between the modules.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:48:21 -07:00
Victor KuznetsovandClaude Opus 4.8 70e8b3a517 feat(face-restore): add InstantID as the default non-commercial restore path
Per the 2026-06-08 deep-research synthesis (docs/synthid-robust-identity-
research-2026-06-08.md), the entire ArcFace-class identity-adapter ecosystem
for SDXL is blocked from commercial use by InsightFace's non-commercial model
packs (antelopev2 / buffalo_l). No commercial-safe ArcFace-grade identity
stack exists today. The user explicitly opted into shipping a non-commercial
restore path (research / personal use; raiw.cc must NOT install the extra).

Architectural choice: InstantID over PhotoMaker-V2 as the default.
- PhotoMaker-V2 (CLIP+ArcFace dual encoder, txt2img only): documented upstream
  identity drift on Asian male faces, visually confirmed in our cert sweep
  (tatsunari rendered as a generic woman; group photo collapsed into a
  patchwork).
- InstantID (ArcFace cross-attention + landmark ControlNet): semantic
  identity branch + spatial weak landmark control, decoupled. Per InstantID
  paper (arXiv:2401.07519) and the research report, stronger identity fidelity
  on single portraits. Critically: NO original face pixels enter the diffusion
  (ArcFace embedding is semantic, landmark stick figure is pure geometry), so
  SynthID is not transported.

Implementation:
- New `src/remove_ai_watermarks/instantid_restore.py` mirrors the
  `photomaker_restore.py` shape (lazy singletons for pipeline + FaceAnalysis,
  per-face crop + _composite_faces from photomaker_restore). Loads the
  InstantID community pipeline via `DiffusionPipeline.from_pretrained(
  custom_pipeline="pipeline_stable_diffusion_xl_instantid")` -- no upstream
  Python package needed; diffusers fetches the file from its community
  examples.
- New `instantid` extra in pyproject (insightface + onnxruntime +
  huggingface-hub). NON-COMMERCIAL block in the comment explains why.
- CLI: `--restore-faces-method [instantid|photomaker]`, default `instantid`.
  Both methods explicitly labeled NON-COMMERCIAL in the help text.
- Engine: dispatch on `restore_faces_method` to either
  `_restore_faces_instantid` or `_restore_faces_photomaker`.
- 9 control-flow tests for InstantID without model download (mirror the
  photomaker_restore.py test pattern + draw_kps helper checks). 587/587 pass.

Diffusers-0.38 compat verified by upstream code inspection: the InstantID
pipeline inherits from `StableDiffusionXLControlNetPipeline`, uses only
public diffusers APIs (`encode_prompt`, `prepare_image`, `prepare_latents`,
`get_guidance_scale_embedding`), uses legacy attention processor API which
diffusers preserves for backward compat. No PhotoMaker-V1-style internal
text_encoder access. End-to-end execution will be validated by the Modal
cert sweep in the next step.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:44:17 -07:00
Victor KuznetsovandClaude Opus 4.8 c486badaa8 fix(photomaker-v2): render at SDXL native 1024, use upstream prompt + neg_prompt
The 9-face grid + single-face cert outputs were still mosaic of training-time
faces even after the id_embeds shape fix. WebFetch of the upstream
inference_pmv2.py revealed three mismatches:

1. SDXL at width=height=512 falls into its low-res failure mode (small-detail
   collage / mosaic) on the V2 LoRA. Render at native 1024 then downscale into
   the original face bbox at composite time.
2. Upstream prompt is descriptive ("instagram photo, portrait photo of a woman
   img, colorful, perfect face, natural skin, hard shadows, film grain, best
   quality"). Our generic prompt let SDXL drift away from the ID embedding.
   Adopted the upstream pattern.
3. Upstream V2 explicitly passes negative_prompt; the CFG batch-mismatch we hit
   on V1 isn't a V2 issue. Re-added negative_prompt with the upstream wording
   (asymmetry/worst quality/etc).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:11:48 -07:00
Victor KuznetsovandClaude Opus 4.8 b1fed810fd fix(photomaker-v2): don't pre-unsqueeze id_embeds (the pipeline does it)
V2's pipeline forward at line 705 of upstream pipeline.py calls
`id_embeds.unsqueeze(0)` itself to add a batch dim, so callers pass a 2-D
(N_faces, 512) tensor and the pipeline turns it into 3-D. Upstream
inference_pmv2.py shows the canonical form: torch.stack([...]) of per-image
embeddings.

Our previous call .unsqueeze(0)'d on the way in, which the pipeline then
.unsqueeze(0)'d again, giving a (1, 1, 512) shape that the V2 id_encoder
consumed as garbage -- the resulting output was a training-time face collage
(verified visually 2026-06-04 against tatsunari + gemini_3 + the 9-face grid).

Fix: pass torch.stack([torch.from_numpy(embedding)]) -- shape (1, 512) -- so
the pipeline's internal unsqueeze gives the expected (1, 1, 512) inside the
forward. Don't pre-cast dtype either; the pipeline handles that internally.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 19:03:04 -07:00
Victor KuznetsovandClaude Opus 4.8 37817a610f test(photomaker): stub face_analyser + analyze_faces in the control-flow test
The previous commit added a real call into FaceAnalysis2 / analyze_faces inside
restore_faces_photomaker, which broke the model-free control-flow test. Stub it:
- monkeypatch _get_face_analyser to return a sentinel
- install a fake `photomaker` module with analyze_faces returning a single
  512-d zero embedding
- add dtype=torch.float32 to the fake pipeline class so .to(device, dtype=...) works

11/11 green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:51:26 -07:00
Victor KuznetsovandClaude Opus 4.8 3d00fed00c fix(photomaker-v2): compute id_embeds via FaceAnalysis2 before pipeline call
The Modal cert sweep against V2 hit the next layer of the API:

  PhotoMakerIDEncoder_CLIPInsightfaceExtendtoken.forward() missing 1 required
  positional argument: 'id_embeds'

V2 forward takes BOTH the CLIP image embedding (computed inside the pipeline from
input_id_images) AND an ArcFace identity embedding (id_embeds) that the caller
must compute. The upstream pipeline does NOT auto-compute it -- inference_pmv2.py
shows the caller using FaceAnalysis2 + analyze_faces to extract the ArcFace
vector from each input ID image and passing id_embeds=torch.stack([...]) into
pipe(...).

Wired the same flow here:
- New _get_face_analyser() singleton (double-checked lock) builds
  FaceAnalysis2(['CUDAExecutionProvider' | 'CPUExecutionProvider']).prepare(...).
  This is the non-commercial step (antelopev2/buffalo_l auto-download on first
  use). Module docstring already calls it out.
- Per face: analyze_faces() -> torch.from_numpy(embedding) -> .unsqueeze(0) to
  match the pipeline's expected (B, D) shape, casting to pipeline.device/dtype.
  Faces InsightFace can't detect inside the crop get skipped (the most likely
  cause would be the diffusion-cleaned face being too small or stylised after
  the main pass; YuNet already gated us into having a face per crop, so this
  should be rare).
- id_embeds= keyword threaded into the pipeline call site alongside the existing
  input_id_images=.

Tests untouched (the V1-only safety guard was already removed in the previous
commit when we swapped V1->V2; the existing 11 tests still pass).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:49:10 -07:00
Victor KuznetsovandClaude Opus 4.8 65de8df5c5 refactor(face-restore): drop GFPGAN, ship PhotoMaker-V2 as the sole restore (non-commercial)
Visual review of the GFPGAN-on-cleaned output (9-face grid, 1448x1086) showed it
only polished the already-drifted face without restoring identity — useless for the
"restore who is in the photo" intent. Dropping it.

The shipped restore path is now PhotoMaker-V2, which delivers true identity-from-
embedding face regeneration via a CLIP+ArcFace dual encoder. The ArcFace branch
pulls InsightFace antelopev2/buffalo_l model packs at runtime, which InsightFace
releases under a research-only license, so the whole extra is **NON-COMMERCIAL**.
raiw.cc and any monetized deployment must NOT install the `photomaker` extra.
This is called out at every entry point: CLI flag help, module docstring,
pyproject extra block, CLAUDE.md extras bullet, README install snippet.

Changes:
- Deleted `src/remove_ai_watermarks/face_restore.py` and its tests.
- Deleted the `restore` extra (gfpgan/facexlib/basicsr + scipy<1.18 / numba<0.60
  pins) and the basicsr setuptools<69 build pin from pyproject.toml.
- Restored `src/remove_ai_watermarks/photomaker_restore.py` (V2 this time:
  `TencentARC/PhotoMaker-V2`, `photomaker-v2.bin`, no `pm_version='v1'` override).
- Restored the `photomaker` extra in pyproject with all the upstream-compat
  pins (einops, peft, onnxruntime, insightface) and the `allow-direct-references`
  hatch metadata block.
- `InvisibleEngine` swapped `_restore_faces` -> `_restore_faces_photomaker`;
  `--restore-faces-method` removed (only one method, no choice).
- CLI flag help, CLAUDE.md, README, docs/synthid.md, and
  docs/controlnet-removal-pipeline-research.md all updated.
- docs/synthid-robust-identity-research.md status notice rewritten to list both
  abandoned commercial-safe attempts (V1 + GFPGAN-on-cleaned) and the
  non-commercial trade-off we accepted.

ruff + strict pyright(src/) clean; 578 tests pass (the 9 GFPGAN tests are gone,
the 11 PhotoMaker tests stay green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 18:41:01 -07:00
Victor KuznetsovandClaude Opus 4.8 01fe98bf54 refactor(face-restore): rollback PhotoMaker, restore GFPGAN on the CLEANED image
After 7 cascading upstream-compat fixes (insightface dep, peft dep, pm_version,
device, etc.), the PhotoMaker V1 cert sweep still hit a CFG batch-dim mismatch
inside the denoising loop. The upstream PhotoMaker `pipeline.py` is forked from
diffusers v0.29.1 and our env runs 0.38; SDXL prompt-encoder handling changed
significantly between those versions, so making PhotoMaker work end-to-end
needs a proper fork or a diffusers downgrade — both expensive. Not worth
shipping today.

Pivot: restore `face_restore.py` (GFPGAN) with a single-line fix that makes it
SynthID-safe by construction. The previous design ran GFPGAN.enhance on the
ORIGINAL watermarked image and was oracle-confirmed to re-add SynthID via the
weight-0.5 pixel blend. The fix is to run GFPGAN on the diffusion-CLEANED
image — whatever pixels GFPGAN derives from are already SynthID-free, so the
partial blend cannot transport the watermark. Identity fidelity is lower than
a true identity-as-embedding stack would deliver, but it ships and works.

Changes:
- `src/remove_ai_watermarks/face_restore.py` restored from pre-wipe state with
  one line changed: `restorer.enhance(cleaned_bgr, ...)` instead of
  `restorer.enhance(original_bgr, ...)`. `original_bgr` is kept as an unused
  positional argument for API stability.
- `src/remove_ai_watermarks/photomaker_restore.py` and its tests REMOVED. The
  research note (`docs/synthid-robust-identity-research.md`) keeps a "status
  notice" documenting why PhotoMaker is parked for now and what the path back
  in would look like.
- `pyproject.toml` `restore` extra restored (gfpgan/facexlib/basicsr +
  scipy<1.18 + numba<0.60 pins + the basicsr setuptools<69 build pin), plus
  `photomaker` extra (with its einops/insightface/peft pile) and the
  `[tool.hatch.metadata] allow-direct-references = true` block REMOVED.
- `InvisibleEngine._restore_faces_photomaker` removed; `_restore_faces`
  restored. The `--restore-faces` CLI flag and its plumbing through cmd_*
  signatures are unchanged.
- CLAUDE.md, README.md, docs/synthid.md, docs/controlnet-removal-pipeline-
  research.md updated to describe the shipped GFPGAN-on-cleaned design and to
  reference PhotoMaker only as the parked alternative.

ruff + strict pyright(src/) clean; 578 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:55:45 -07:00
Victor KuznetsovandClaude Opus 4.8 d1b85ee6a8 fix(photomaker): drop explicit negative_prompt to fix CFG batch mismatch
Modal cert sweep #6 made it INTO the denoising loop and died with
"Sizes of tensors must match except in dimension 1. Expected size 2 but got size 1
for tensor number 1 in the list."

In the PhotoMaker pipeline's denoising loop, the per-step embeddings are built
as torch.cat([negative_prompt_embeds, prompt_embeds(_text_only)], dim=0). The
text-encoder + ID-encoder flow can leave the negative branch at batch=2 and the
ID-injected branch at batch=1 when a custom negative_prompt is passed, so the
cat fails. The upstream gradio demo just passes no negative_prompt and relies
on the pipeline's empty default; do the same.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:35:40 -07:00
Victor KuznetsovandClaude Opus 4.8 031c38dc7f fix(photomaker): place id_encoder on the right device + dtype
Modal cert sweep #5 made it through component load (V1 id_encoder + lora_weights)
and died at inference with the classic
"Input type (torch.cuda.HalfTensor) and weight type (torch.HalfTensor) should be
the same" — id_encoder lived on CPU/fp32 while the rest of the pipeline ran on
CUDA/fp16. Two fixes:

1. Call `pipe.to(device)` BEFORE `load_photomaker_adapter` so the loader picks the
   right device/dtype from `self.device` / `self.unet.dtype` when it builds the
   encoder.
2. Belt: after load, explicitly `pipe.id_encoder.to(device, dtype)` because some
   torch/diffusers combos leave custom attributes on the old device even when
   `pipe.to` ran first.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:29:00 -07:00
Victor KuznetsovandClaude Opus 4.8 9435e12ce6 fix(photomaker extra): add peft dep (required by pipe.fuse_lora)
Modal cert sweep #4 got further -- PhotoMaker V1 components actually loaded
("Loading PhotoMaker v1 components [1] id_encoder ... [2] lora_weights") -- and
died on the next step: "PEFT backend is required for this method." That's
diffusers' fuse_lora call gated on the peft library, which PhotoMaker doesn't
declare in its install_requires either.

Pin peft>=0.10.0 in the photomaker extra.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:23:32 -07:00
Victor KuznetsovandClaude Opus 4.8 1fb2a64b56 fix(photomaker): pass pm_version='v1' to load_photomaker_adapter
Modal cert sweep #3 ran past the `insightface` import error and into a real
state_dict mismatch:

  Error(s) in loading state_dict for PhotoMakerIDEncoder_CLIPInsightfaceExtendtoken:
    Missing key(s) ... qformer_perceiver.token_proj.0.weight ...

The upstream `load_photomaker_adapter` defaults to `pm_version='v2'` regardless of
the .bin file passed -- the loader builds a V2 encoder
(PhotoMakerIDEncoder_CLIPInsightfaceExtendtoken) and then tries to load V1 weights
into it. We must pass `pm_version='v1'` explicitly so the loader instantiates the
CLIP-only PhotoMakerIDEncoder. The pipeline-level `input_id_images` API is the
same across V1 and V2, so the call site does not change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:18:52 -07:00
Victor KuznetsovandClaude Opus 4.8 860bde4a26 fix(photomaker extra): pin insightface for import resolution (MIT code only)
The upstream PhotoMaker package's `__init__.py` unconditionally imports a
face-analyser class from its `insightface_package` submodule, so JUST importing
`PhotoMakerStableDiffusionXLPipeline` (the V1 pipeline class we use) raises
`ModuleNotFoundError: No module named 'insightface'` if insightface isn't
present in the env. The Modal cert sweep caught this on the V1 image.

Resolution: pin `insightface>=0.7.3` (and its `onnxruntime` runtime dep) in the
`photomaker` extra. The PyPI insightface package is MIT-licensed CODE; the
non-commercial restriction sits on the pretrained model packs (antelopev2,
buffalo_l) which download only when `FaceAnalysis()` is instantiated. Our V1 path
never instantiates the face-analyser -- it loads photomaker-v1.bin (CLIP-only
encoder) via `load_photomaker_adapter` -- so the model-pack license does not
bind us; we depend only on the MIT code for the import to resolve.

Safety guards:
- Runtime check in `_get_pipeline`: raises if `_PHOTOMAKER_FILE` is ever pointed
  at v2 (so a future maintainer can't silently regress to the InsightFace path).
- New test class `TestV1OnlyCommercialSafetyGuard`: asserts repo + filename
  pin to V1 AND asserts the module source never references the face-analyser
  class (a static check that our codepath stays out of the runtime that would
  pull the non-commercial model packs).

Docs: documented the import dance + legal split inline at the top of
`photomaker_restore.py`.

ruff clean; 581 tests pass (the 9 PhotoMaker tests plus 3 new V1-guard tests).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:13:20 -07:00
Victor KuznetsovandClaude Opus 4.8 dfa5181309 fix(photomaker): switch to V1 — V2 actually requires InsightFace (non-commercial)
A Modal cert sweep caught what the research doc missed: PhotoMaker-V2 fails at
import without InsightFace ("No module named 'insightface'"). Reading the upstream
source confirms it: `photomaker/__init__.py` imports `FaceAnalysis2` (an InsightFace
wrapper) at module load, V2's encoder is named
`PhotoMakerIDEncoder_CLIPInsightfaceExtendtoken`, and `model_v2.py`'s forward
takes an `id_embeds` argument that the pipeline computes via
`insightface.app.FaceAnalysis(name='antelopev2', ...)`. So V2 is a DUAL encoder
(CLIP + ArcFace), not CLIP-only as the model card line "id_encoder includes
finetuned OpenCLIP-ViT-H-14 and a few fuse layers" implied.

InsightFace's pretrained model packs (antelopev2, buffalo_l) are research/
non-commercial only per their own README:
  "The pretrained models we provided with this library are available for
   non-commercial research purposes only."
So V2 is blocked for a paid service like raiw.cc.

PhotoMaker-V1 is the commercial-safe alternative — its `PhotoMakerIDEncoder`
(model.py) forward takes only `(id_pixel_values, prompt_embeds, class_tokens_mask)`,
no ArcFace branch. Identity is CLIP-only, license is Apache-2.0, no InsightFace.

Code change: swap the repo + filename constants in `photomaker_restore.py`
(TencentARC/PhotoMaker, photomaker-v1.bin). Tests still pass (the 9 PhotoMaker
tests use a fake pipeline, so the model swap is transparent to them).

Doc correction: rewrote the verdict / license table / section 5 of
`docs/synthid-robust-identity-research.md` to lead with V1 and add a correction
notice explaining the V2 misread. Bulk-renamed `PhotoMaker-V2` to `PhotoMaker-V1`
across CLAUDE.md, README.md, docs/synthid.md, and
docs/controlnet-removal-pipeline-research.md (kept V2 only in the correction
notice, the license table, and the anchor reference).

ruff clean; 578 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 16:05:58 -07:00
Victor KuznetsovandClaude Opus 4.8 7e6fc8bfb9 fix(photomaker extra): add einops explicitly (upstream missed it)
PhotoMaker imports einops in its forward path but its install_requires doesn't
declare it, so the photomaker extra resolved without einops on a clean install
and the Modal cert sweep died at the restore-faces step with
"No module named 'einops'" -- the post-pass failed gracefully and returned the
un-restored cleaned output, so the cert artifact had no face recovery.

Pin einops>=0.7.0 in the photomaker extra so the extra is self-contained.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:46:28 -07:00
Victor KuznetsovandClaude Opus 4.8 439eeadc07 refactor(face-restore): wipe GFPGAN path, --restore-faces is PhotoMaker-only
The GFPGAN `restore` extra and its `face_restore.py` module are gone. They were
oracle-confirmed to re-introduce SynthID by blending watermarked original face
pixels at fidelity weight 0.5 (clean A/B: gemini_3 controlnet 0.20 detected WITH
GFPGAN, clean WITHOUT). Keeping them as the default restore method was a footgun
for the removal pipeline. PhotoMaker-V2 (added in the previous commit) is the
single shipped restore path now -- identity-as-embedding, SynthID-safe by
construction.

Removed:
- src/remove_ai_watermarks/face_restore.py + tests/test_face_restore.py
- pyproject.toml `restore` extra (gfpgan/facexlib/basicsr + scipy/numba pins)
- pyproject.toml `[tool.uv.extra-build-dependencies] basicsr = [...]` build pin
- CLI: `--restore-faces-method` and `--restore-faces-weight` (no method choice
  to make, no GFPGAN weight knob to expose)
- InvisibleEngine._restore_faces method (only _restore_faces_photomaker remains)
- All restore-faces-method / restore-faces-weight threading through cmd_*
  signatures and _process_batch_image

Kept:
- `--restore-faces / --no-restore-faces`: now binds to PhotoMaker-V2.
- All adopted oracle findings about GFPGAN re-introducing SynthID (kept in the
  research docs as historical context that explains why the path was removed).

Docs updated: CLAUDE.md (restore extras bullet collapsed to photomaker, removed
face_restore Key-modules bullet, several inline GFPGAN refs scrubbed), README.md
(face-identity callout + install section now point to the photomaker extra),
docs/synthid.md 5.5 (net recipe), docs/controlnet-removal-pipeline-research.md
(recommendations).

ruff + strict pyright (src/) clean; 578 tests pass (the 9 GFPGAN tests are gone,
the 9 PhotoMaker tests stay green).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:35:37 -07:00
Victor KuznetsovandClaude Opus 4.8 1439eb0714 feat(photomaker): SynthID-safe face-identity restoration via PhotoMaker-V2
Adds the second face-restore mechanism, selectable via the new CLI option
`--restore-faces-method=photomaker`. Unlike the existing GFPGAN path (which runs on
the watermarked ORIGINAL and was oracle-confirmed to re-introduce SynthID by partial
pixel blending), PhotoMaker carries identity in a SynthID-invariant OpenCLIP
embedding and regenerates fresh face pixels conditioned on it — the pixels in the
output are diffusion-fresh, so the watermark cannot be transported.

The load-bearing assumption (embedding invariance to SynthID-magnitude pixel noise)
was empirically validated in the prior commit (smoke test): cosine drift 0.002
under a ±2 LSB low-freq carrier, an order of magnitude less than JPEG90 drift
which SynthID survives at >=99% TPR.

End-to-end commercial-safe:
- PhotoMaker-V2 weights: Apache-2.0 (TencentARC)
- ID encoder: OpenCLIP-ViT-H/14 (MIT)
- SDXL base: shared with the main pipeline
- NO InsightFace (the non-commercial blocker for IP-Adapter FaceID / InstantID /
  PuLID / Arc2Face)

Two-pass architecture (PhotoMaker has no ControlNetImg2img class in diffusers):
1) main controlnet/default removal pass cleans SynthID + drifts faces
2) PhotoMaker txt2img regenerates each face from its embedding, feather-composited
   back into the cleaned image

New module `photomaker_restore.py` mirrors `face_restore.py`: lazy pipeline
singleton (double-checked lock), `is_available()` gate, pure `_face_crop_square` and
`_composite_faces` helpers, all unit-tested without the model (9 new tests). New
`InvisibleEngine._restore_faces_photomaker` runs after the diffusion pass, mirroring
`_restore_faces`. CLI flag `--restore-faces-method=[gfpgan|photomaker]` threaded
through `cmd_invisible`/`cmd_all`/`cmd_batch` + `_process_batch_image`.

New optional `photomaker` extra (Apache-2.0 + Apache-2.0/MIT deps, no basicsr).
`[tool.hatch.metadata] allow-direct-references = true` is required because the
upstream PhotoMaker package lives only on GitHub.

The next step (separate work) is oracle validation: run a 6-image cert sweep
through the new pipeline (default/controlnet at the certified strength +
--restore-faces-method=photomaker) and confirm SynthID stays clean while face
identity is recovered. The required infrastructure (`raiw-app/modal_cert.py`) is
already in place.

ruff + strict pyright(src/) clean; 586 tests pass (+ 9 new in
tests/test_photomaker_restore.py).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:20:29 -07:00
Victor KuznetsovandClaude Opus 4.8 f8f247308b docs(identity): smoke test confirms OpenCLIP embedding is invariant to SynthID-magnitude noise
Empirical confirmation of the load-bearing assumption in the PhotoMaker-V2 path: the
identity embedding cannot transport an invisible pixel watermark.

Tested OpenCLIP-ViT-H/14 (laion2B-s32B-b79K — the same encoder PhotoMaker-V2
fine-tunes) on 31 face crops from gemini_3/gemini_4/openai_3 grid. cosine
similarity between embed(orig) and embed(perturbed):

- synthid_proxy (±2 LSB low-frequency noise, the regime SynthID actually lives in):
  mean 0.9977, min 0.9937. Embedding moves by 0.002 — an order of magnitude less
  than JPEG90 (mean 0.928), which SynthID survives at >=99% TPR by design.
- noise3 / jpeg70 / blur1: 0.89-0.95, all clearly above the SynthID floor.
- self check: 1.0000 (pipeline sane).

So the embedder discards exactly the dimensions SynthID hides in. PhotoMaker-V2
conditioned on a watermarked face will see the same identity vector as a clean
face of that person, so the generated face inherits identity, not the watermark.

This unblocks step 2 of the research plan: prototype PhotoMaker-V2 in the
controlnet pipeline. The previously logged ad-hoc "cos(orig, SDXL-cleaned)"
numbers (0.56-0.93) measured diffusion drift, not watermark invariance, and are
not relevant to the hypothesis.

Docs only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 15:05:15 -07:00
Victor KuznetsovandClaude Opus 4.8 310ce912ba docs: SynthID-robust identity research — PhotoMaker-V2 is the only commercial-safe SDXL stack
After GFPGAN restore was oracle-confirmed to RE-INTRODUCE SynthID (it is a fidelity-
restoration net conditioned on the watermarked input), the only identity path that
will not transport the watermark is identity-by-EMBEDDING: a semantic vector that
conditions a fresh generation. That requires a face-recognition / ArcFace-class or
CLIP-image embedder.

Verified the license stack of every credible 2025-2026 SDXL identity adapter by
fetching primary sources directly (HuggingFace model cards, insightface.ai):

- IP-Adapter FaceID family, InstantID, PuLID, Arc2Face -> all blocked. Each
  depends at runtime on InsightFace's antelopev2/buffalo_l ArcFace packs, and
  insightface.ai explicitly states "Code is MIT licensed; models require separate
  commercial licensing." IP-Adapter FaceID's own model card flags itself non-
  commercial for the same reason.
- PhotoMaker-V2 is the single commercial-safe end-to-end stack today: Apache-2.0
  adapter weights with identity encoded as a fine-tuned OpenCLIP-ViT-H/14 (the
  model card's exact phrase: "id_encoder includes finetuned OpenCLIP-ViT-H-14
  and a few fuse layers"). No InsightFace.

Mechanistic argument that an identity embedding cannot transport SynthID: the
embedder is trained to be invariant to low-amplitude pixel changes (JPEG, resize,
brightness, noise), which is exactly the regime SynthID hides in by design. So
the embedding extracted from a watermarked face should be ~identical to the
embedding from the cleaned face, and the embedding cannot carry the watermark
into a freshly generated face. Flagged explicitly as not-yet-measured -- the
first integration step is a cosine-similarity smoke test (no codegen) before
investing in a PhotoMaker prototype.

Process note: the deep-research harness was run but its verifier subagents failed
to call StructuredOutput (same harness bug as a prior session), so its synthesis
was unusable; the license claims here are direct quotes from the primary
sources, fetched and verified, not from the workflow synthesis.

Docs only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 14:58:11 -07:00
Victor KuznetsovandClaude Opus 4.8 be14eca207 docs: certified controlnet strength floors from the Modal GPU oracle sweep
Ran the isolated raiw-controlnet-cert Modal app (raiw-app/modal_cert.py) over a
strength x seed grid, restore OFF, --max-resolution 1536, each vendor checked on its
OWN oracle (OpenAI -> openai.com/verify, Gemini -> the Gemini app). Certified
controlnet SynthID-removal floors:

- OpenAI 0.20: 2 photoreal images (9-face grid + bracelet) x seed {1,2,3} = 6/6 clean;
  the bracelet that flipped at 0.15 is seed-robust at 0.20. Transfers to prod (OpenAI
  removal is resolution-independent).
- Gemini 0.30: 0.20 detected -> 0.30 clean on 2/2 seeds (hardest face). Holds only at
  <= 1536; Gemini is resolution-sensitive and raiw.cc runs NATIVE, so cap Gemini
  <= 1536 + use 0.30, or native-calibrate (~0.35+).

Prod recipe recorded: controlnet + a controlnet-specific per-vendor schedule in
resolve_strength (OpenAI 0.20 / Gemini 0.30, NOT the default 0.10/0.15 ladder) +
FIXED prod seed (kills the near-threshold non-determinism) + restore reworked/off.
Added to docs/controlnet-removal-pipeline-research.md (certified floors table),
docs/synthid.md 5.5, and the CLAUDE.md controlnet bullet. Docs only.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:44:56 -07:00
Victor KuznetsovandClaude Opus 4.8 d38b9a6122 docs: correct controlnet/restore SynthID-removal claims from the 2026-06-04 oracle pass
Oracle validation (openai.com/verify + the Gemini app) overturned three claims that
were on main, and consolidates the controlnet findings into one authoritative place.

- controlnet does NOT reliably remove SynthID at the low vendor-adaptive strength:
  removal is content x pipeline dependent and the survivors FLIP by content type
  (photoreal survives controlnet / clears default; flat graphic survives default /
  clears controlnet; flat text clears both). Root cause is insufficient strength,
  not the pipeline; controlnet needs a higher, per-vendor floor than default.
- removal near the threshold is SEED-non-deterministic (same image+pipeline+strength
  can pass or fail run-to-run); a single clean run does not certify a strength.
- `--restore-faces` RE-INTRODUCES SynthID: GFPGAN runs on the ORIGINAL watermarked
  face at weight 0.5 and composites it back over the cleaned result (clean A/B:
  a Gemini face stayed detected through controlnet 0.15/0.20/0.25 WITH restore,
  cleared at 0.20 with --no-restore-faces). The old "GFPGAN scrubs SynthID" claim
  was wrong.

Corrected in CLAUDE.md (watermark_remover controlnet bullet, controlnet
Known-limitations bullet, face_restore bullet, vendor-adaptive strength bullet) and
docs/synthid.md (5.1 controlnet/face-identity, 5.2 strength floors, new 5.5 oracle
validation log). docs/controlnet-removal-pipeline-research.md gains an authoritative
"Oracle validation 2026-06-04" section that the others point to as the single source.

Docs only; no code change.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-08 12:22:43 -07:00
Victor KuznetsovandClaude Opus 4.8 3aea21e632 feat(visible): Samsung Galaxy AI mark removal (bottom-left reverse-alpha, #37)
New samsung_engine.py mirrors the jimeng engine but anchors bottom-left; wired
into watermark_registry, the CLI (--mark samsung / auto), and identify
(visible_samsung, medium). visible_alpha_solve.py gains a corner=bl mode;
samsung_alpha.png solved from @f-liva's flat captures. Calibrated for the
Italian "Contenuti generati dall'AI" variant. Flat black/gray/white captures
committed, real photos gitignored. Tests + docs.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 10:27:44 -07:00
Victor KuznetsovandClaude Opus 4.8 6f4aa4c7b1 fix(invisible): retry in fp32 on a degenerate fp16 output (#41)
The fp16-fix VAE swap (#29) is gated to the default SDXL checkpoint, so a
custom model_id, a stale pre-fix install, or a fal/custom loader can still
decode to an all-black/NaN frame in fp16 (reporter: gpt-image 1448x1086,
the `image_processor.py invalid value encountered in cast` warning).

Add a model-agnostic backstop in remove_watermark: after generation, if the
run was fp16 and the output is degenerate (_is_degenerate_image: near-zero
mean and variance), rebuild the pipeline in fp32 on the same device and
re-run once. fp32 is the verified-clean path, so a black image is never
returned regardless of model_id or version. Mirrors the MPS->CPU fallback's
self-mutation pattern; batch inherits it. Verified e2e on MPS by forcing
fp16 with the swap disabled (first pass black, guard fired, retry clean).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 17:43:27 -07:00
Victor KuznetsovandClaude Opus 4.8 ec549b5c55 chore(deps): bump aiohttp 3.13.5 -> 3.14.0 for GHSA-hg6j-4rv6-33pg + GHSA-jg22-mg44-37j8
Targeted `uv lock --upgrade-package aiohttp`; only the aiohttp pin changes (no
other package added/removed). Clears the two moderate Dependabot alerts on the
transitive aiohttp. The third alert (basicsr GHSA-86w8-vhw6-q9qq, command
injection, no patch) is accepted: basicsr is the optional, off-by-default
`restore` extra pinned to 1.4.2 as the only buildable version.

Imports + targeted suite (identify/metadata/gemini) green after the bump.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:57:08 -07:00
Victor KuznetsovandClaude Opus 4.8 2c0b174dfa fix(gemini): self-verify repair for under-removed sparkles
After reverse-alpha, re-detect the sparkle; when one survives at or above the
registry fail line (conf >= 0.5) -- an alpha mismatch the per-image gain estimate
could not fully correct -- inpaint the footprint and keep that only when it lowers
the re-detect confidence. The footprint inpaint reconstructs the slot from its
darker surroundings, so it physically removes the bright sparkle; purely additive,
the common clean removal re-detects below 0.5 and is returned untouched.

Measured on the spaces visible-removal audit: gemini removal-audit failures drop
15 -> 11 (4 genuine rescues), doubao 65/65 and jimeng 11/11 unchanged, zero
regressions on the 468 already-clean removals.

An offset+scale alignment search was prototyped on the remaining 11 fails and
rejected: an audit "ceiling" suggested +4 more, but those were NCC-gaming -- the
lower-scoring placement left the sparkle as bright or brighter, just reshaping the
residual so the contrast-invariant shape-NCC scored lower (a5a9: first-pass slot
~76 at background level vs the "aligned win" ~164). A brightness sanity check
rejected every one, so it contributed nothing and was removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-04 16:45:18 -07:00
Victor KuznetsovandClaude Opus 4.8 6d11c11b52 feat(auto): DBNet text detector, Real-ESRGAN upscaler, batch --auto
Three content-quality features for the invisible/all/batch pipeline.

DBNet text detector (auto_config): replace the MSER text heuristic with
PP-OCRv3 differentiable-binarization via cv2.dnn.TextDetectionModel_DB,
using a bundled 2.4 MB Apache-2.0 model (en/cn detection nets are
byte-identical, so it ships language-neutral). cv2.dnn is core OpenCV, so
no new pip dep. MSER stays as the fallback when the model can't load.
Validated on real images: matches MSER everywhere and additionally catches
the Doubao CJK mark MSER missed; routing decisions unchanged otherwise.

Real-ESRGAN upscaler (new upscaler.py, esrgan extra): optional
pre-diffusion super-resolution for the min-resolution floor upscale, loaded
via spandrel (MIT, no basicsr) with BSD-3-Clause weights downloaded on
first use. New --upscaler {lanczos,esrgan} on invisible/all/batch; default
stays lanczos and the engine falls back to lanczos when the extra is absent
or the model errors (never breaks removal). It is a manual opt-in knob (the
auto plan never selects it) -- as a generic GAN it sharpens photo/texture
content strongly but can degrade faces (the diffusion pass regenerates
them) and thin text, documented accordingly.

batch --auto: wire the content-adaptive --auto (+ --adaptive-polish) into
cmd_batch. The plan is recomputed per image and the invisible engine is
cached per resolved pipeline (default/controlnet), so a mixed directory
builds at most one engine of each kind. Verified end-to-end: 3 mixed
images routed correctly with only 2 pipeline loads (controlnet reused).

ruff + strict pyright(src/) clean; 558 tests pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-04 16:04:33 -07:00
Victor KuznetsovandClaude Opus 4.8 4a6cd71ab2 Merge branch 'claude/silly-northcutt-c2bf06': unify C2PA vendor registry + code-health + uv publish
Brings in commit 5cf68a6 (single C2PA_AI_VENDORS registry, erase_lama
grayscale/BGRA support, batch device-cache clearing + --controlnet-scale,
uv publish via OIDC, hatchling pin <1.31). Auto-merged with no conflicts;
ruff/pytest(544)/pyright all clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:10:25 -07:00
Victor KuznetsovandClaude Opus 4.8 32a0779e1d fix(gemini): demote sparkle false positives with a core-brightness gate
detect_watermark's shape-only NCC (spatial/gradient/var fusion) fires on ornate
or flat content (text strips, banners, hatching) that coincidentally matches the
diamond shape. The NCC is contrast-invariant, so it cannot see the defining
property of a real Gemini sparkle: a bright WHITE overlay whose core sits above
the local background.

The fusion now demotes (caps confidence to 0.30) a match that is BOTH
low-confidence (< _SPARKLE_FP_CONF 0.65) AND has a low core-ring brightness
margin (_core_ring_margin < _SPARKLE_FP_MARGIN 5). Real sparkles escape via
EITHER high confidence (white-bg sparkles score >=0.79 despite a low margin) OR
high margin (dark/mid backgrounds, incl. the #36 faint-corner case), so both
must fail to demote. The gate is monotonic -- it only removes detections, never
adds -- so it cannot regress the verified-negative corpus (already 0 FPs).

On the spaces corpus it demoted 16/495 flagged sparkles (13 no AI metadata =
content FPs; the 3 AI-meta ones were visually FPs / a near-invisible
white-on-white sparkle whose AI verdict is held by metadata), and dropped the
removal-audit failures 20 -> 15.

- _core_and_bg shared helper (core 75th-pct brightness vs background-ring median);
  _estimate_alpha_gain refactored onto it, new _core_ring_margin wrapper.
- TestSparkleFalsePositiveGate: margin high/low, strong-sparkle kept (incl. on
  white via high conf), blurred no-core blob demoted.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 22:02:28 -07:00
Victor KuznetsovandClaude Opus 4.8 b686dbdd79 feat(auto): adaptive detail-targeting polish + --adaptive-polish flag
The fixed mild auto polish (unsharp 0.5 / grain 2.0) under-corrected soft
photo/face output (gemini_3 stayed at lap-var 84 vs its 592 original) and its
grain speckled small text. Replace it with humanizer.adaptive_polish: target the
input's Laplacian variance with a capped unsharp scaled to the deficit + edge-
masked grain (smooth regions only), calibrated by a short sigma search. Self-
limiting on text/graphics -- already high-frequency, so almost no polish lands
and text edges are masked out. Validated on the spaces corpus (gemini_3 84 -> 334
end-to-end; openai_1 text near-untouched).

Interface: every --auto decision is now independently overridable -- add
--adaptive-polish/--no-adaptive-polish (matching --restore-faces; works without
--auto too) so the polish can be disabled or used manually. _apply_auto overrides
exactly the three content-adaptive modes (pipeline, restore-faces, adaptive-
polish); --unsharp/--humanize stay independent fixed filters.

cv2-only, no new deps. Threaded through invisible/all (not batch).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:49:08 -07:00
Victor KuznetsovandClaude Opus 4.8 5cf68a6a3d refactor: unify C2PA vendor registry + code-health fixes + uv publish
Three P2 cleanups from a library-wide review.

Detection -- single C2PA_AI_VENDORS registry (noai/constants.py):
- C2PA_ISSUERS, SYNTHID_C2PA_ISSUERS, and identify._ISSUER_PLATFORM now derive
  from one C2paAiVendor table, so adding a C2PA vendor is one entry instead of
  edits in three places across two files. Behavior-identical (262 detection
  tests pass; the kept `needle` field is load-bearing -- it differs from `org`
  for Google and ByteDance, with no mechanical derivation).

Code-health:
- region_eraser.erase_lama now accepts grayscale/BGRA like erase_cv2 (it
  crashed on grayscale and silently dropped alpha on BGRA). +2 regression tests.
- batch frees the device cache between images via a shared try_empty_device_cache
  helper (generalized from the MPS-only _try_clear_mps_cache, now reused by both
  the MPS->CPU fallback and the batch loop).
- batch gained --controlnet-scale (parity with invisible/all).

CI / packaging:
- publish.yml uploads via `uv publish` (PyPI trusted publishing over OIDC),
  replacing pypa/gh-action-pypi-publish so uploads no longer depend on that
  action's bundled twine accepting the Metadata-Version. Workflow filename +
  pypi environment unchanged, so PyPI's trusted-publisher entry still matches.
- hatchling pin relaxed <1.28 -> <1.31 (verified against hatch's changelog:
  1.30.0 made Metadata 2.5 the default, 1.30.1 reverted to 2.4; 1.27-1.29 were
  always 2.4). Kept as belt-and-suspenders so the first uv-publish release ships
  2.4, isolating the uploader swap from the metadata-version bump.

Docs (CLAUDE.md, pyproject) synced; corrected the inaccurate "hatchling 1.28+
emits 2.5" note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 21:01:07 -07:00
Victor KuznetsovandClaude Opus 4.8 9bd2c17cc4 feat(auto): content-adaptive --auto quality mode, Phase 1
Add `auto_config.plan(image_path) -> AutoConfig`, the first step of the
invisible/all pipeline: it inspects the input image (before the diffusion model
loads) and picks the quality modes so the run adapts to content. Quality-priority
routing -- ControlNet (text/face-structure preservation) is the default, skipped for
plain SDXL only on a clearly structure-less image; GFPGAN face restore when a face is
present; a mild sharpen + grain polish when a smoothing pass ran. Exposed as `--auto`
on `all`/`invisible` (`_apply_auto`; explicit flags override via click's parameter
source). Not wired into batch (its engine is cached per-mode).

Detection is cv2-only and torch-free (~100 MB peak RSS, a few ms): OpenCV YuNet
(`cv2.FaceDetectorYN`, MIT, 232 KB model bundled in assets/) for faces, a Canny
edge-density + MSER heuristic for text/structure (a rough Phase-1 placeholder; DBNet
via cv2.dnn is the planned upgrade). ZERO new pip deps. Designed to run wherever the
pipeline runs -- the raiw.cc Modal GPU worker -- never on the 512 MB web host.

Real-ESRGAN-via-Spandrel upscaling (a new `esrgan` extra) and an adaptive
Laplacian-variance polish are deferred to later phases.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 20:52:17 -07:00
Victor KuznetsovandClaude Opus 4.8 ea59bdc3e2 chore(scripts): add invisible-removal quality audit tool
Pairs <hash>_src / <hash>_clean outputs, computes SSIM + detail/resolution
proxies, ranks the worst-preserved images for visual classification. Used to
characterize the classes the SDXL scrub degrades (line-art, faces, dense text).
Operates on gitignored data/spaces only; writes nothing tracked.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:56:49 -07:00
Victor KuznetsovandClaude Opus 4.8 e7fb64dca1 fix(gemini): remove more-opaque sparkles via per-image alpha gain
The captured sparkle alpha peaks ~0.51, but some real Gemini sparkles are
rendered more opaque. The fixed-alpha reverse blend then UNDER-subtracts and
leaves a bright residual the detector still fires on. A visible-removal audit
through the registry path on the spaces corpus showed this as a meaningful
fraction of marks -- all under-removals, not a background-brightness class
(failures and successes had the same input confidence and background luma; the
discriminator was the removal delta itself).

remove_watermark now estimates a per-image alpha gain (_estimate_alpha_gain:
effective sparkle opacity at the bright core vs the local background ring,
a_eff/a_cap, clamped [1.0, 1.94]) and scales the alpha to match before the
over-sub/blend branch. A 1.05 deadband keeps a sparkle that already matches the
capture byte-identical to the pre-fix output, so the fix is purely additive
(0 regressions on the audit set; failures dropped substantially). The over-sub
guard still runs on the scaled alpha as the safety net for an over-shoot.

- _estimate_alpha_gain + _ALPHA_GAIN_MAX/_DEADBAND/_CORE_FRAC in gemini_engine.
- TestUnderSubtractionGain asserts on footprint pixels, NOT the detector (its
  NCC is degenerate on a flat synthetic bg; the real corpus removal drops the
  detector ~0.80 -> ~0.27).
- scripts/visible_removal_audit.py: the detect -> remove -> re-detect audit tool
  that found and validated this (operates on gitignored data/spaces only).
- CLAUDE.md + README: document the under-subtraction gain.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 19:48:40 -07:00
Victor KuznetsovandClaude Opus 4.8 d7e4fe8835 feat(invisible): upscale-floor for small inputs + unsharp post-filter
Two quality knobs for the SDXL invisible pass:

- min_resolution floor (default 1024, --min-resolution): small inputs are
  upscaled to a 1024px long-side floor before diffusion, since SDXL img2img
  distorts on a tiny latent (a 381x512 portrait wrecks at native). The output
  is restored to the original input size, so it is a transparent quality boost;
  it adds time/memory on small inputs. 0 disables. Extends the pure _target_size
  helper (now cap-or-floor-or-native, min skipped on a min>max misconfig),
  unit-tested without a model.

- unsharp post-filter (humanizer.unsharp_mask, --unsharp, opt-in default 0):
  applied LAST, after the GFPGAN face pass (a pre-GFPGAN sharpen would be
  smoothed back over), to counter the soft/over-smoothed look that diffusion +
  restoration leave behind (an AI tell). Pairs with --humanize (grain).

Both threaded through invisible/all/batch + the module-level helper. Verified
end-to-end on a 381x512 portrait: upscaled to 1024, sharpened, restored to
381x512.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 18:30:39 -07:00
Victor KuznetsovandClaude Opus 4.8 a57af5da21 docs(claude): corpus cleaned/ examples must come from a shipped removal method
Capture the rule: archive only cleaned outputs from the current default SDXL
img2img pass; never archive examples from removed methods (ctrlregen, old
text/face protection, FaceID, CodeFormer) or experimental opt-in paths
(controlnet, GFPGAN). A removed method's output is not a reproducible example.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:13:56 -07:00
Victor KuznetsovandClaude Opus 4.8 8523f48fb6 data(corpus): archive June 2026 SynthID strength-study subjects
Back docs/synthid.md section 2.2 with the actual test set: the per-image
oracle-verified subjects were only in a local working dir, while the doc claimed
they were recorded in data/synthid_corpus/. Ingest the key pos+cleaned pairs so
the claim holds.

- pos: openai_1/2/3 originals (gpt-image, openai-verify) + gemini_1/2/3/4
  originals (Gemini app, gemini-app); all probe as C2PA-SynthID present.
- cleaned: OpenAI at strength 0.05 (openai_2 only s010 captured) + Gemini at 0.15
  --max-resolution 1536; oracle: SynthID NOT detected. Metadata stripped, so no
  C2PA on the cleaned rows.
- Excluded the third-party issue #14 image (pic3): oracle-verified but not
  committed to the public corpus.
- docs/synthid.md 2.2: state OpenAI n=4 = 3 archived + 1 external-only.
- CLAUDE.md: drop the drift-prone "~65 MB" corpus size from the sdist note.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 17:09:58 -07:00
Victor KuznetsovandClaude Opus 4.8 5ec8269949 chore: mark controlnet pipeline + GFPGAN restore-faces as experimental
Both content-preservation features are now flagged EXPERIMENTAL and opt-in.
--pipeline controlnet was already opt-in (default=default); --restore-faces
flips from on-by-default to OFF by default, matching the repo's prior pattern
for experimental preservation passes (the removed protect_text/protect_faces).

- cli.py: --restore-faces/--no-restore-faces default False; EXPERIMENTAL in the
  --restore-faces / --controlnet-scale / --pipeline help; batch default False.
- invisible_engine.py: remove_watermark restore_faces default False + docstring.
- CLAUDE.md / README.md / docs/synthid.md: label both experimental/opt-in.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:59:28 -07:00
Victor KuznetsovandClaude Opus 4.8 411ef16ec3 feat: GFPGAN face-identity restoration post-pass
Add an optional, commercial-safe face-restoration post-pass that recovers
face identity the diffusion removal pass drifts (canny holds structure, not
likeness) while still scrubbing the pixel watermark in the face regions.

- face_restore.py: GFPGANer singleton (CPU unless CUDA), the basicsr
  torchvision.transforms.functional_tensor shim, and the pure feather
  _composite_faces helper (unit-tested without the model). GFPGAN
  re-synthesizes each face from a StyleGAN2 prior, so composited face pixels
  are GAN-generated (no watermark, no pixel-copy) -- oracle-clean at weight 0.5
  with identity preserved.
- InvisibleEngine.remove_watermark: restore_faces / restore_faces_weight,
  best-effort, auto-skips when the extra is absent or no face is detected.
- CLI --restore-faces/--no-restore-faces + --restore-faces-weight on
  invisible/all/batch (on by default).
- restore extra (gfpgan/facexlib/basicsr), numpy<2-pinned (scipy<1.18,
  numba<0.60) and kept out of `all`; basicsr needs Python <3.13 + setuptools<69
  to build, so pin .python-version 3.12.

Commercial-safe: GFPGAN Apache-2.0, RetinaFace MIT. The CodeFormer alternative
is non-commercial and is not shipped. The earlier IP-Adapter FaceID layer was
removed (footgun: needs high strength, corrupts faces at the low removal
strength).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:59:28 -07:00
Victor KuznetsovandClaude Opus 4.8 d90d5d886a feat: controlnet pipeline for text/face-structure preservation
Add `--pipeline controlnet` (SDXL base + xinsir canny ControlNet via
StableDiffusionXLControlNetImg2ImgPipeline): the canny edge map conditions the
img2img regeneration so text and face STRUCTURE stay sharp, while the watermark
is still removed by the regeneration (`strength`) -- no original pixels are
copied or frozen, so SynthID does not survive. Oracle-verified clean on OpenAI
with better text/structure fidelity than plain img2img at equal strength.
`--controlnet-scale` tunes structure preservation; fp32 on mps/cpu (fp16-fixed
VAE on cuda/xpu). Shares the img2img runner (live progress + MPS->CPU fallback)
and the fp16-VAE-fix / device-move helpers with the default pipeline.

Remove the superseded subsystems -- ctrlregen (SD1.5 clean-noise),
text-protection (differential / region-hires) and face-protection: they either
destroyed real content or shielded the watermark by re-using original pixels.
controlnet replaces them by regenerating everything under edge conditioning.

Canny preserves face structure but not identity; face IDENTITY is a separate
face-restoration post-pass (CodeFormer/GFPGAN), researched + prototyped but not
yet shipped. An IP-Adapter FaceID attempt was built and removed (footgun: needs
high strength, corrupts faces at removal strength).

Docs: docs/controlnet-removal-pipeline-research.md, scripts/controlnet_sweep.py.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:59:28 -07:00
Victor KuznetsovandClaude Opus 4.8 175609b60a fix(gemini): rescue small corner sparkle buried by the size weight (#36)
detect_watermark's size-weighted global NCC search lets a larger, mediocre
match (e.g. a bright collar in a portrait) outrank a small, near-perfect
sparkle in the bottom-right corner, so a faint sparkle on a busy background
scored below threshold and the image read as clean -- the regression from
widening the search window 256px->512px between v0.7.2 and v0.8.8.

Add _corner_promote: a bottom-right-corner raw-NCC pass that overrides the
global pick when the corner holds a match with raw NCC >= 0.85 that beats it.
It only ever replaces a lower-fidelity pick (cannot weaken an existing
detection) and keeps the wider window for variant margins. The corner side is
relative-clamped (0.20 of the short side, [96, 384]) so it stays a true corner
at every scale: a fixed 256px covers ~70% of a small portrait, where a real
photo raw-matches the star at ~0.81; relative tightening drops that to ~0.69.
The 0.85 gate sits between the worst real-photo corner match (~0.78) and a
genuine faint sparkle (~0.93): zero false positives across native + downscaled
negatives, headshot rescued from below-threshold to 0.71.

Factor the shared multi-scale matchTemplate loop into _scan_scales.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-03 16:51:03 -07:00
Victor KuznetsovandClaude Opus 4.8 07c96bed53 docs: mark fp16 VAE black-output fix (#29) verified on CUDA
Confirmed on real CUDA hardware 2026-06-03: `all` on a 1086x1448 OpenAI
gpt-image at fp16 produces a normal (non-black) output, so the fp16-fix VAE
swap resolves the all-black decode. Removes the prior "NOT verifiable on this
MPS machine" caveat.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:18:04 -07:00
Victor KuznetsovandClaude Opus 4.8 35116d5e97 chore(release): v0.8.9
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:04:32 -07:00
Victor KuznetsovandClaude Opus 4.8 df0fafe94e fix(identify): stop flagging multi-actor C2PA manifests as integrity clashes
The C2PA issuer attribution (`c2pa`) and the SynthID proxy (`synthid`) are
derived from the same manifest, so treating them as independent signals made
rule 1 fire on legitimate multi-actor manifests where a product wraps another
vendor's engine (Microsoft Designer on OpenAI, Microsoft on Google) or an edit
chain re-signs (Adobe over a Gemini original). 19 such files in the
2026-06-01/02 spaces batches read as "likely spoofed/laundered" before this.

Group `c2pa` + `synthid` into one provenance source via `_CLASH_SOURCE`; rule 1
now requires two vendors from different sources. A manifest vendor still clashes
with a genuinely independent stamp (EXIF/XMP generator, IPTC AISystemUsed, AIGC,
xAI).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-02 19:02:35 -07:00
Victor KuznetsovandClaude Opus 4.8 9cb66992bd chore(release): v0.8.8
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:18:02 -07:00
Victor KuznetsovandClaude Opus 4.8 9ca2811938 fix(gemini): inpaint sparkle footprint when reverse-alpha over-subtracts (#30)
On a dark/textured background (e.g. grass) the captured alpha map over-estimates
the real Gemini sparkle's effective opacity (~0.51 captured vs ~0.31 effective),
so the fixed-alpha reverse blend over-subtracts (watermarked - alpha*logo goes
negative) and drives the footprint to black -- the white sparkle turns into a
black diamond (issue #30, reported by @CoolZimo1).

remove_watermark now detects this via _reverse_alpha_oversubtracts (fraction of
footprint pixels with a negative numerator > 5%) and inpaints the small sparkle
footprint from the surrounding pixels (cv2 NS, cropped to a padded box) instead.
Behavior-neutral on the working case: a bright background over-subtracts at ~0%,
so reverse-alpha is used and the output is byte-identical to before (verified:
demo_banana 0.0 frac vs the issue-#30 grass image 0.61 frac; issue-#30 footprint
recovers to background grass with no pit, residual sparkle conf 0.25 < 0.35).

Guard is scoped to GeminiEngine: doubao/jimeng already NCC-align their alpha to
the actual mark per image, which sidesteps the fixed-alpha mismatch.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-02 09:17:32 -07:00
Victor Kuznetsov b25276c4f2 chore(release): v0.8.7 2026-06-01 19:33:08 -07:00
Victor KuznetsovandClaude Opus 4.8 96038f960f feat(invisible): vendor-adaptive default strength (OpenAI 0.10 / Google 0.15)
The default img2img strength is now chosen from the detected SynthID vendor
(C2PA issuer) instead of a single fixed 0.30: OpenAI gpt-image -> 0.10, Google
Gemini -> 0.15, unknown source -> 0.15. Explicit --strength always wins.

Basis: an oracle-verified June 2026 controlled study (clean v0.8.6, text/face
protection OFF, per-image openai.com/verify or Gemini-app verdict). OpenAI's
SynthID clears at 0.05 across 1024-1600 px (n=4, resolution-independent);
Google's is ~3x more robust and needs 0.15 on the capped-1536 path (n=4). The
dominant factor is the VENDOR, not resolution. The earlier single 0.30 default
and the "resolution dependence" lore came from contaminated tests run with the
protect-text bug ON (issue #14) -- re-running those same 1600x1600 images clean
removes SynthID at 0.05.

`vendor_for_strength(path)` reads metadata.synthid_source on the ORIGINAL input
and is threaded through cli (invisible/all/batch) -> invisible_engine ->
watermark_remover -> resolve_strength(strength, profile, vendor), so display and
execution use the same vendor (the engine sees a temp path whose C2PA the visible
pass already stripped, so detection must happen in the CLI on the pristine
source). Caveat: Google's 0.15 was validated only on --max-resolution 1536;
native 2816 Gemini was not locally measurable (OOM on Apple Silicon) and is
pending GPU validation on raiw.cc.

Docs: docs/synthid.md sections 2.2/4.4/5.2 corrected (the contaminated
resolution-dependence findings replaced with the clean oracle-verified table);
README and CLAUDE.md updated; CLI --strength help reflects the adaptive default.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 19:29:47 -07:00
Victor KuznetsovandClaude Opus 4.8 1708857772 fix(gemini): expand sparkle search area 256 -> 512px from corner
The 256px limit caused misses when Gemini places the sparkle further from the
corner than the standard 160px (margin 64 + logo 96). Observed variant at ~300px
reported in issue #30. 512px covers all known Gemini margin variations with room
to spare; matchTemplate on a 512x512 region is still fast on CPU.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:42:04 -07:00
Victor Kuznetsov 25cc4750df chore(release): v0.8.5 2026-06-01 10:31:59 -07:00
Victor KuznetsovandClaude Opus 4.8 4b0b370ac0 fix(invisible): disable protect-text/protect-faces by default; add docs/synthid.md
Both text and face protection were shielding SynthID from removal. The
text-protection high-res re-scrub regenerates pixels at an upscaled resolution
where the per-region pass may not be strong enough to re-destroy the SynthID
payload, allowing it to survive in text areas. Face protection has an even more
direct mechanism: it pastes back the original (pre-diffusion, watermarked) face
pixels after the global pass, guaranteeing SynthID survives in face regions
regardless of strength.

Both --protect-text and --protect-faces are now off by default and opt-in.
Rename from --no-protect-text / --no-protect-faces to --protect-text /
--protect-faces. Extract shared click.option decorators to module-level
constants (_protect_text_option, _protect_faces_option) to eliminate
copy-paste between cmd_invisible and cmd_all.

Add docs/synthid.md: primary-source-cited technical reference for SynthID-Image
covering mechanism (post-hoc encoder/decoder, 136-bit payload, pixel-space, no
model-weight modification), robustness numbers (arXiv:2510.09263: ~99.98% TPR
at 0.1% FPR across 30 transforms), removal attacks and forensic detectability
(arXiv:2605.09203: all 6 attacks detectable >98% TPR@1%FPR), detectability
limits, oracle scope, adoption landscape, and practical implications including
the protect-text/faces SynthID-preservation finding.

Verified June 2026 on gpt-image 1600x1600 via openai.com/verify: with
--protect-text SynthID detected; without, SynthID removed.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-01 10:28:34 -07:00
Victor Kuznetsov 72812de03c chore(release): v0.8.4 2026-05-31 20:46:52 -07:00
Victor KuznetsovandClaude Opus 4.8 e501bec9ff feat(identify): detect visible Doubao/Jimeng marks; keep identify import torch-free
identify previously ran only the Gemini sparkle as a visible detector, so a
Doubao/Jimeng image with stripped TC260 metadata had no visible fallback. Add
`_visible_text_marks` (registry-backed) so the ByteDance Doubao 豆包AI生成 and
Jimeng 即梦AI marks are detected too, each gated by its own engine NCC threshold
via MarkDetection.detected. New signals `visible_doubao` / `visible_jimeng`
(medium), same stripped-metadata fallback role as the sparkle; excluded from
integrity-clash vendor claims; set platform only when no harder signal did.

Also make `noai/__init__` lazy (PEP 562 __getattr__): importing the light
`noai.c2pa` / `noai.constants` submodules (which identify needs) no longer
eagerly pulls `watermark_remover`, which imports torch + diffusers at module
top. `import remove_ai_watermarks.identify` drops from ~420 MB to ~21 MB in a
full gpu/detect install (torch not loaded), so it fits a 512 MB host; the
removal API resolves lazily on first access. Guarded by TestIdentifyImportIsLight.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 20:43:52 -07:00
Victor KuznetsovandClaude Opus 4.8 4b4049a6f1 docs(text-protection): update stale strength note (~0.05 -> ~0.30 SynthID threshold)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:53:48 -07:00
Victor KuznetsovandClaude Opus 4.8 2e23cf9c4b fix(build): pin hatchling<1.28 to keep Metadata-Version 2.4 (PyPI upload rejected 2.5)
hatchling 1.28+ emits Metadata-Version 2.5 (PEP 639); the twine in
pypa/gh-action-pypi-publish@release/v1 rejects it, which failed the v0.8.3 PyPI
upload (build + tag-match passed, upload step failed, nothing uploaded). 1.27.x
emits 2.4, which uploads fine (0.8.2). Pin the build backend; lift once the action
twine is 2.5-aware or the workflow uses uv publish.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:49:19 -07:00
Victor KuznetsovandClaude Opus 4.8 c155f81078 chore(release): v0.8.3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:41:10 -07:00
Victor KuznetsovandClaude Opus 4.8 6075ea2c55 docs(synthid): cite the genuine Gemini A/B (gemini_633uuy) for the protect_text finding
The protect_text correction was first cited only to qw1212ss_pic3, an OpenAI
image that carries no Google SynthID (so the Gemini oracle is not a valid check
for it). The updated study re-ran the 0.3 protect_text A/B on a genuine Gemini
SynthID image (gemini_633uuy, photo with a Chinese-text sign): SynthID removed
with protection ON and OFF, Gemini-oracle verified. Cite that as the load-bearing
evidence so the claim rests on a valid subject. Confirms the shipped 0.30 +
protect_text=ON default on a real Gemini target.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 17:39:16 -07:00
Victor KuznetsovandClaude Opus 4.8 b991b11a19 docs(synthid): correct protect_text guidance -- it does NOT block removal (keep ON)
An A/B at strength 0.3 on a real e-commerce infographic (updated GPU study)
reverses the earlier claim: SynthID is a GLOBAL watermark, so 0.3 removes it
whether protect_text is on or off, and protection SALVAGES text fidelity (medium
headings/body stay readable; off, they garble). The earlier 'protect_text shields
the watermark, use --no-protect-text' was wrong -- it mistook the 0.10 strength
failure for a protection effect. Recommended SynthID config: ~0.3 + protect_text ON
(the default). Also document the oracle scope: the Gemini app 'Verify with SynthID'
is the only valid SynthID oracle; openai.com/verify is provenance-scoped (C2PA) and
does NOT measure SynthID. Corrects CLAUDE.md + README + watermark_profiles comment
shipped in cddbaf6.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:50:13 -07:00
Victor KuznetsovandClaude Opus 4.8 cddbaf6413 fix(invisible): raise default strength 0.10 -> 0.30 (current SynthID threshold); flag ctrlregen experimental
An oracle-verified GPU strength study (Modal A100, native res, Gemini-app
'Verify with SynthID', n=3 fresh Gemini images, protect_text/faces off) found the
current Google SynthID survives strength 0.10/0.15/0.2 and is removed only at 0.3.
The previous 0.10 default (set from an n=1 result) no longer clears it -- Google
hardened SynthID and the threshold has climbed 0.05 -> 0.10 -> ~0.3. Bump
DEFAULT_STRENGTH to 0.30; OpenAI/ChatGPT carry C2PA not SynthID, so 0.10 is plenty
there (pass --strength 0.10). Note protect_text shields the text regions SynthID
hides in (use --no-protect-text for full removal on text-heavy images).

The same study found ctrlregen at clean-noise strength DESTROYS real images
(hallucinated micro-text in smooth regions), with no usable middle setting, so the
literature's 'clean-noise is the lever' did not hold empirically. Flag ctrlregen
EXPERIMENTAL in the CLI --pipeline help, README, and watermark_profiles; SDXL
img2img at ~0.3 stays the shippable path.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 16:38:49 -07:00
Victor KuznetsovandClaude Opus 4.8 729f5f2ecd chore(release): v0.8.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:46:47 -07:00
Victor KuznetsovandClaude Opus 4.8 b0aad476fb fix(scripts): drop rich import from analysis scripts (red CI after rich removal)
The cli refactor dropped rich from dependencies, but four scripts still did
`from rich.console import Console` / `rich.table import Table`. Their test
modules import the scripts, so a clean `uv sync --frozen` (CI: core+dev, no
rich) failed at collection with ModuleNotFoundError on macOS/Windows/Linux.

Add a shared plain-text shim `scripts/_plain_console.py` (Console/Table via
click.echo, markup stripped) and switch all four scripts to it. Verified: all
four import with rich blocked, and tests/test_synthid_corpus.py +
tests/test_synthid_pixel_probe.py pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:41:50 -07:00
Victor KuznetsovandClaude Opus 4.8 f16216cabc feat(cli): add --no-protect-faces to invisible/all (skip the YOLO face detector)
Mirrors --no-protect-text: when the image has no people, skip loading and
running the YOLO face detector entirely. The heavy extract+blend already only
ran when a face was found, but the detector itself always loaded+inferred to
decide; this flag lets callers skip that fixed cost.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:27:14 -07:00
Victor KuznetsovandClaude Opus 4.8 e42b7e9d6a refactor(cli): plain-text console output; drop rich; quiet transformers
cli.py now emits plain ASCII through a small click.echo shim
(_Console / _Table / _Progress) instead of rich: no colors, markup tags,
panels, progress bar, or Unicode glyphs (Warning: / -> / ... and dropped
checkmark/cross marks). identify and metadata tables render as indented
plain lines.

- drop rich from dependencies (pyproject.toml + uv.lock)
- __init__: set TRANSFORMERS_VERBOSITY=error (setdefault) plus a warnings
  filter so the transformers Siglip2ImageProcessorFast deprecation no
  longer prints at CLI startup (it fires from the eager noai import)
- TestGpuHintMarkup: the [gpu] hint is now printed verbatim; docstring updated
- CLAUDE.md: replace the obsolete rich-markup lesson, note the verbosity fix

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:21:29 -07:00
Victor KuznetsovandClaude Opus 4.8 2d49c3cb58 fix(invisible): ctrlregen defaults to clean-noise strength, not the SDXL 0.10
The ctrlregen profile inherited the SDXL img2img --strength default (0.10), a
near-identity pass that loaded ControlNet + DINOv2-giant and barely changed the
image -- a no-op for removal. resolve_strength() now resolves an unset strength
per profile: 0.10 for the SDXL default, CTRLREGEN_DEFAULT_STRENGTH (1.0,
clean-noise) for ctrlregen. It checks `is None` rather than falsiness, so an
explicit 0.0 is respected (the old `strength or DEFAULT` swallowed it).

Research basis: CtrlRegen (ICLR 2025, arXiv:2410.05470) removes robust
watermarks by regenerating from clean Gaussian noise; partial-noise img2img
retains watermark info that diffuses back, so a high (clean-noise) strength is
the lever, not a knob on the light SDXL pass. CLI wiring (--strength default
None) lands with the cli refactor.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 15:07:19 -07:00
Victor KuznetsovandClaude Opus 4.8 33bd401e2a fix(visible): guard remove_watermark_reverse_alpha on tiny images too
The previous commit guarded extract_mask, but the 2048x1 crash was
actually in _fixed_alpha_map's cv2.resize to a ~1-px-tall target (Windows:
"Unknown C++ exception" / access violation). Return image.copy() up front
when h < 32 or w < 64 (no real watermarked image is that small), before any
cv2 call. Same guard in both Doubao and Jimeng.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 14:00:52 -07:00
Victor KuznetsovandClaude Opus 4.8 7167d2bae7 fix(visible): guard extract_mask against degenerate ROIs (Windows CI crash)
The always-align removal scores each placement with a residual detect(),
which on an extremely wide/short image (2048x1, test_wide_short_does_not_raise)
fed cv2 GaussianBlur a ~1-px-tall ROI and faulted natively on Windows py3.12
(access violation, non-deterministic -- one CI cell went red, a re-run passed).
The old at-native path never ran detect() on degenerate sizes. Skip the cv2
pipeline and return an empty mask when bh < 16 or bw < 16; real images always
clear the guard (the WM_* box floors are max(16,..) / max(40,..)). Same fix in
both Doubao and Jimeng. Also sync the stale Doubao module docstring.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:25:57 -07:00
Victor KuznetsovandClaude Opus 4.8 196e8ea35f docs(claude): record the release flow + sdist-must-exclude-data lesson
PyPI rejected the 0.8.0 sdist (data/ test corpora over the file-size
limit); document the release steps and the [tool.hatch.build.targets.sdist]
exclude so it does not recur.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 13:01:08 -07:00
Victor KuznetsovandClaude Opus 4.8 e7c57e3892 chore(release): v0.8.1 — exclude data/ from sdist
The 0.8.0 PyPI publish uploaded the wheel but the sdist was rejected
(400 File too large): hatchling's default sdist bundled the committed
data/ test corpora (synthid_corpus images + the new visible-mark
captures), pushing it past PyPI's per-project file-size limit. Add a
sdist target that excludes /data, dropping it ~85 MB -> 9.8 MB. The
wheel already ships only src/ and is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:57:46 -07:00
Victor KuznetsovandClaude Opus 4.8 315320056b chore(release): v0.8.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:25:02 -07:00
Victor KuznetsovandClaude Opus 4.8 e572767555 feat(visible): add Jimeng remover, fix Doubao outline defect, reproducible mask build
Visible-watermark work across all three corner-mark engines plus a committed,
reproducible alpha-build pipeline (scripts/visible_alpha_solve.py) fed by committed
solid black/gray/white captures.

- jimeng: new "即梦AI" wordmark remover (reverse-alpha + thin residual inpaint,
  always NCC-aligned -- the mark re-rasterizes/jitters per image). Detect via glyph
  silhouette NCC (0.45 threshold; does not cross-fire with Doubao). Registered in the
  visible-mark catalog; `visible --mark jimeng` / `--mark auto`.
- doubao: fix a real production defect -- the shipped remover left a READABLE
  "豆包AI生成" outline on real samples while detect() returned conf 0.0 (fooled by a
  thin outline), so the test passed and the "56/56 clean" claim was detector-measured,
  not visual. Root cause: under-estimated alpha + fixed-geometry-no-inpaint + tight
  locate box. Rebuilt alpha (careful gray-self solve), always-align, thin inpaint,
  widened locate box -> readable outline becomes faint texture-level traces.
- gemini: rebuild gemini_bg_{96,48} from our own controlled captures (validated NCC
  0.9998 vs the prior third-party asset); removal re-verified clean, no behaviour change.
- tests: add textured-shift regression to both engines (guards the align-on-shift path
  the Doubao defect exposed; lesson: a detector-only removal test is insufficient,
  assert visual residual).
- docs: CLAUDE.md, README, capture READMEs and docstrings synced; stale
  "exact/pixel-exact/56-clean" claims removed.

Also includes a SynthID label-wording clarification in identify.py/cli.py
("SynthID pixel watermark" -> "SynthID watermark, inferred from C2PA metadata").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:20:19 -07:00
Victor KuznetsovandClaude Opus 4.8 5d0e6c3a65 fix: harden metadata parsers and engines; sync docs (full-repo review)
Apply fixes from a full-repo review (code, tests, docs).

Security / correctness:
- Clamp attacker-controlled PNG/caBX chunk lengths to the remaining file
  size in metadata.py and noai/c2pa.py (a malformed length no longer drives
  a multi-GB read); skipped chunks seek instead of read.
- noai/isobmff.strip_c2pa_boxes is now fail-safe on a malformed box: return
  the original bytes with a warning instead of silently truncating the tail,
  so metadata --remove can no longer emit a corrupt file.
- doubao_engine._fixed_alpha_map clamps the glyph box to the image (no crash
  on degenerate width-vs-height).
- watermark_remover._run_region_hires gates the phaseCorrelate offset on
  response and magnitude (a spurious shift no longer garbles text) and drops
  the generator after a CPU fallback (no MPS/CPU device mismatch).

Robustness:
- gemini_engine, doubao_engine, region_eraser normalize grayscale and RGBA
  inputs to BGR at the engine entry points.
- image_io.imwrite returns False on an unwritable path (matches cv2).
- invisible_engine guards a None imread result before use.
- trustmark_detector._decoder uses a double-checked threading lock.
- ctrlregen.tiling.tile_positions raises on overlap >= tile.
- humanizer chromatic shift no longer wraps opposite-edge pixels.
- identify OpenAI caveat keyed on the normalized vendor, not a substring.
- Remove the dead "visible --detect-threshold" CLI option.
- publish.yml verifies the release tag matches the package version.

Docs:
- README strength 0.05 to 0.10; .env.example HF_TOKEN marked optional;
  doubao_capture README updated to reverse-alpha-only; CLAUDE.md synced with
  the new behaviors and the batch command.

Tests: new test_security_clamp.py for the read clamp and isobmff fail-safe;
erase CLI coverage; integrity-clash rule 2 end-to-end; multi-tag EXIF
survival and cross-format strip guards; channel/size, tiling, humanizer, and
imwrite regressions. Full suite 493 passed, 2 skipped; ruff and pyright src/
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:00:39 -07:00
Victor KuznetsovandClaude Opus 4.8 5298dcc6a3 chore(release): v0.7.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:35:04 -07:00
Victor KuznetsovandClaude Opus 4.8 d88b87ca4e Fix #29 black output: use fp16-fixed SDXL VAE on fp16 GPUs
The stock SDXL VAE overflows to NaN in fp16, so the plain img2img path decodes
to an all-black image on a CUDA/XPU fp16 backend. This is the raiw.cc black
result HitaoLin reported (a 1086x1448 input came back uniformly black). cpu/mps
run fp32 and never hit it, and the differential / region-hires pipeline already
upcasts the VAE itself, so only the plain path on a fp16 GPU was exposed.

`_load_pipeline` now loads `madebyollin/sdxl-vae-fp16-fix` for the default SDXL
checkpoint when running fp16, gated by the pure helper `_needs_fp16_vae_fix`. A
custom non-SDXL model keeps its own VAE.

The decision logic is unit-tested without a download (TestFp16VaeFix). The
black->clean recovery itself needs a CUDA GPU and was not verifiable on this MPS
machine; it must be confirmed on the backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:31:51 -07:00
Victor KuznetsovandClaude Opus 4.8 9be66752c5 chore(release): v0.7.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:21:02 -07:00
Victor KuznetsovandClaude Opus 4.8 69559226d7 Clarify metadata command supports video/audio, drop misfiring format warning (#33)
The `metadata` command handles more than images: `remove_ai_metadata` strips
C2PA / AIGC provenance from MP4/MOV/M4V/M4A and from WebM/MP3/WAV/FLAC/OGG via
ffmpeg. But the help said "from images" and the shared `_validate_image` call
printed "Warning: .mp4 may not be supported" on exactly those supported
containers. The argument's `exists=True` already enforces the file exists, so
the validation call only added the wrong warning here.

Update the docstring to list the real format coverage and drop the
image-only validation from this command. The image commands keep it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 13:20:04 -07:00
Victor KuznetsovandClaude Opus 4.8 29da3c52b6 Raise default SynthID-removal strength 0.05 → 0.10 (current Google SynthID) (#32)
* Raise default SynthID-removal strength 0.05 -> 0.10 (current Google SynthID)

The old default (0.04/0.05) no longer removes the CURRENT Google SynthID (Nano
Banana / Gemini 3): verified 2026-05-30 via the Gemini 'Verify with SynthID'
oracle on a real image -- 0.05 still detected, 0.10 not detected (OpenAI's was
already cleared at 0.05). Add DEFAULT_STRENGTH=0.10 in watermark_profiles, route
the engine + CLI defaults to it. At 0.10 small text deforms more, which is why
text protection (_run_region_hires) runs by default. CLAUDE.md SynthID note
corrected. CAVEAT: n=1 Google + n=1 OpenAI; broad corpus oracle validation
pending (task tracked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Drop unused LOW/MEDIUM/HIGH strength profiles; CLI --strength defaults to DEFAULT_STRENGTH

The fixed strength presets (and get_recommended_strength) were dead -- nothing in
the pipeline used them, only tests. One knob now: DEFAULT_STRENGTH (0.10),
overridable per-call via the CLI --strength flag, which now defaults to that
constant (single source of truth). Removed the WatermarkRemover.LOW/MEDIUM/HIGH
class attrs and the get_recommended_strength tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:15:58 -07:00
Victor KuznetsovandClaude Opus 4.8 e4f558dccf Add per-region high-resolution text protection (regenerate crisp, scrub everywhere) (#31)
Replace the default text-protection path. Differential Diffusion froze text in
latent space, which left SynthID intact inside text (violating remove-everywhere)
and still softened sub-8px strokes (VAE latent limit). _run_region_hires instead
scrubs the whole image, then re-scrubs each detected text block at high resolution
and feather-composites it back: every pixel is regenerated (watermark removed
everywhere) while small text stays crisp (high-res strokes span >1 latent cell).

merge_text_regions + feather_paste are pure and unit-tested; each re-scrubbed
patch is phase-correlated back to the original crop to null the ~1-2px round-trip
offset. Synthetic 18px multilingual text: text-region SSIM 0.28 -> 0.48, visually
garbled -> readable across Latin/Cyrillic/CJK. Legacy _run_differential /
build_change_map remain but are no longer the default. Prod use still requires
confirming via the SynthID oracle that re-scrubbed text zones read watermark-free.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:59:29 -07:00
Victor KuznetsovandClaude Opus 4.8 c928ee6e42 chore(release): v0.7.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:35:32 -07:00
Victor KuznetsovandClaude Opus 4.8 89f427852f Fix #30 white box: stop zeroing alpha in the watermark region on save
On RGBA inputs the CLI forced the watermark bbox alpha to 0 on save, so the
removed-sparkle area became a transparent hole that renders as a solid white
box on any non-transparent viewer. The Gemini app exports opaque RGBA, so
every user hit it. Reverse-alpha already recovers the real pixels there (and
`erase` inpaints them), so there is no artifact to hide -- the hole was the
bug, introduced as an over-correction in d091b9f.

`_write_bgr_with_alpha` now rejoins the input alpha plane unchanged (drops the
`clear_region`/`pad` params); the `visible` / `erase` / `all` / `batch` call
sites drop the cleared-region argument and the orphaned region bookkeeping.
The registry `remove()` still returns the mark bbox (used for inpaint_residual
positioning); the CLI just no longer clears alpha with it.

Inverts the test that locked in the old behavior into a #30 regression guard
(watermark-region alpha stays opaque, no pixel forced transparent). Verified
end-to-end on a real Gemini RGBA export: sparkle gone, zero transparent
pixels, clean over a white background.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 12:27:37 -07:00
Victor KuznetsovandClaude Opus 4.8 25a1acc53b Detect TC260 AIGC label in JPEG EXIF and late/attribute PNG XMP
A corpus audit surfaced China TC260 AIGC-labeled images that `identify`
missed. Three detection gaps in `aigc_label`, all fixed:

- raw-JSON `{"AIGC":{...}}` in JPEG EXIF (UserComment): brace-matched from
  the scan head with `json.raw_decode`, gated on a TC260 field like the
  PNG-chunk path. (Doubao-class output via that export surface.)
- XMP attribute form `TC260:AIGC="{...}"` (PicWish): folded into the
  element regex as a second alternation.
- TC260 XMP packet appended after a large `IDAT`, past the 1 MB scan
  window: `scan_head` now appends late PNG metadata chunks via
  `_png_late_metadata`, mirroring the existing ISOBMFF late-box scan.

Adds `scripts/corpus_gap_scan.py`: runs `identify` over a corpus, writes
the per-file report CSV, and flags `unknown` files that carry a known
marker in their metadata region (the audit that found these gaps).
Scanning only the metadata region — not the whole file — avoids the
random short-token collisions inside compressed PNG/JPEG streams.

On the local corpus this lifts 3 files from `unknown` to AI (China AIGC)
and leaves zero false gap candidates. Synthetic piexif/PngInfo fixtures
cover all three forms.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 11:44:53 -07:00
Victor KuznetsovandClaude Opus 4.8 58bdf51c59 Visible-watermark registry: reverse-alpha-only Doubao + Gemini, exact native recovery (#28)
* fix(trustmark): gate detection on re-encode durability to kill false positives

TrustMark's wm_present flag is a BCH validity check that spuriously
validates on a content-correlated fraction of un-watermarked images
(AI textures trip it more than camera photos). On a 1343-image set all
20 raw detections were false, several on Gemini/OpenAI/Doubao output that
cannot carry Adobe's watermark, with random-bytes secrets.

A genuine TrustMark is a durable soft binding that survives re-encoding,
so detect_trustmark now re-decodes after a mild JPEG round-trip and
requires the same schema both times. Every observed false positive
collapsed under this gate; the second decode runs only on the rare hit.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(identify): Samsung Galaxy AI, FLUX, ByteDance C2PA; fix C2PA substring FP

Detection extensions verified on real signed files (2026-05-29):

- Samsung Galaxy AI: signer attribution via a new _SIGNER_C2PA_PLATFORM
  (Samsung Galaxy / ASUS Gallery) kept separate from the capture-camera
  _DEVICE_C2PA_PLATFORM so a Galaxy AI edit (device cert + AI source type)
  does not trip the camera-vs-AI integrity clash. Plus metadata.samsung_genai:
  the proprietary genAIType marker in PhotoEditor_Re_Edit_Data, a medium-
  confidence AI-editing signal (samsung_only branch).
- Black Forest Labs (FLUX) and ByteDance Volcano Engine (Doubao/Jimeng)
  added as C2PA issuers + issuer->platform mappings.
- fix: C2PA presence required only the bare 4-byte 'c2pa' substring, which
  false-positives on compressed pixel data (a recompressed PNG IDAT re-flagged
  C2PA after its manifest was correctly stripped). New c2pa_marker_in() requires
  the JUMBF wrapper (jumb+c2pa) or the C2PA uuid box; applied in identify +
  metadata. Verified: all 535 real C2PA files carry jumb.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doubao): gate detection on text structure to cut ~95% of false positives (#23)

Coverage alone over-fired: any textured bottom-right corner cleared the
threshold, so the detector false-positived on ~28% of arbitrary images.
The real '豆包AI生成' mark is six glyphs in one row, so detect now also
requires the text-structure signature (_glyph_structure): many connected
components, no single dominant blob, concentration in a thin horizontal
band. False positives dropped 343 -> 17 across the corpus while keeping
real-mark recall and the doubao-1.png sample. Also accept a no-op force
kwarg for remover-interface symmetry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(samsung): add Samsung Galaxy AI visible-badge remover

New samsung_engine.py removes the bottom-left sparkle + localized
'AI-generated content' badge that Galaxy AI tools stamp. Mirrors the
Doubao locate->mask->inpaint pattern but bottom-left, with a dual-polarity
top-hat mask (the badge is light-on-dark or dark-on-light). Detection gates
on a band + left-anchor signature (the Doubao CJK-component gate does not
transfer: Latin badge letters connect into few blobs). Explicit-only --
tuned on few real badges with a ~4% FP floor, so it is not used in auto.
Synthetic byte-blob fixtures (real badges are user content, not shipped).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(visible): unified known-watermark registry + LaMa inpaint backend

watermark_registry.py is a single catalog of known visible marks, each
tying {usual location, in_auto flag, recovery strategy, detect adapter,
remove adapter}: gemini (reverse-alpha, exact), doubao, samsung. cmd_visible
is now registry-driven (best_auto_mark for --mark auto; mark_keys() feeds the
CLI choices) -- the per-mark _run_doubao/_run_samsung helper branches are gone.

Cross-engine confidences are not comparable, so the gemini adapter applies the
corpus-validated 0.5 sparkle threshold for auto arbitration (its engine flag is
loose and weakly fired ~0.36 on Doubao text, hijacking auto).

--backend auto|cv2|lama chooses background reconstruction for the mask-based
marks; auto = LaMa when onnxruntime is present, else cv2. For LaMa the mask is
the FILLED glyph bounding box (sparse glyph masks leave anti-aliased edges
behind). cv2 stays the zero-dependency fallback.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* docs: watermark registry, Samsung/FLUX/ByteDance detection, LaMa backend, trustmark gate

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(doubao): exact reverse-alpha removal from captured alpha map

The Doubao '豆包AI生成' mark is a fixed semi-transparent white overlay, so
given its alpha map the original pixels are recovered exactly:
original = (wm - a*logo)/(1-a) -- no inpaint hallucination.

The alpha map + logo colour were solved from real black+gray Doubao captures
on a controlled background: on black captured = a*logo, and the black/gray pair
solves a per-pixel without assuming the logo colour (a_max~0.65, logo near-white);
the white capture cross-validates (mark vanishes to a flat fill). Bundled as
assets/doubao_alpha.png + geometry constants.

remove_watermark_reverse_alpha applies it scaled to image width; exact at the
captured width, so the registry routes doubao through it only when
reverse_alpha_available (width within the calibrated band) and the mark is
detected, falling back to mask inpaint (cv2/LaMa) otherwise. A light residual
inpaint cleans the sub-pixel rescaling error. Add captures at more resolutions
to widen exact coverage.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* refactor(visible): reverse-alpha only -- drop inpaint removal + heuristic detection

Per the principle that we only remove/detect what we can do exactly, the
visible-mark path is now reverse-alpha only:

- Doubao detect is reverse-alpha-consistent: match the bundled alpha glyph
  silhouette against the corner via TM_CCOEFF_NORMED (DETECT_NCC_THRESHOLD 0.4)
  -- keys on the '豆包AI生成' SHAPE, not coverage/structure heuristics. FP
  7/1243 (0.6%). Removes the cv2 inpaint path + the _glyph_structure gate.
- Registry is reverse-alpha only: dropped the cv2/LaMa backend (_glyph_remove,
  _lama_box_inpaint, default_backend, --backend) and the Samsung entry. Doubao
  outside the alpha resolution band is skipped, never inpainted.
- Removed samsung_engine.py + tests + --mark samsung (no alpha map captured;
  Samsung C2PA/genAIType metadata detection in identify is unaffected).
- The universal erase --region (cv2/LaMa) is unchanged -- arbitrary-region
  inpainting stays a user-directed tool, separate from the known-mark registry.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* feat(doubao): NCC sub-pixel alignment -> reverse-alpha at any resolution

A pure width-scale of the captured alpha map is only sub-pixel-accurate at the
captured width and leaves a faint ghost elsewhere. remove_watermark_reverse_alpha
now registers the alpha glyph to the actual mark via a TM_CCOEFF_NORMED
scale+position search (_aligned_alpha_map) before inverting the blend, so the
single 2048 capture works at any resolution -- verified clean on the 1773x2364
(3:4) corpus size, the biggest coverage gap (23 files).

reverse_alpha_available is now just 'asset present' (no width band); the registry
still gates removal on detect so a clean corner is never touched. Drops the
_ALPHA_WIDTH_TOLERANCE gate.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doubao): keep native recovery exact -- fixed geometry at captured width

Integer-pixel NCC alignment landed ~1px off at the captured width, degrading the
otherwise-exact native reverse-alpha (synthetic recovery error 0.94 -> 1.39).
remove_watermark_reverse_alpha now uses exact width-relative geometry within
_ALPHA_NATIVE_BAND of the captured width and the NCC search only off it -- best
of both: native back to 0.94, other resolutions still aligned.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* fix(doubao): harden alignment -- try fixed+aligned, keep least residual (56/56)

On a faint/busy-background mark the NCC alignment peak can wander a few px off
the true mark and leave a residual (2/56 real corpus files). Off the captured
width, remove_watermark_reverse_alpha now builds BOTH the fixed-geometry and the
NCC-aligned alpha map, applies each, and keeps whichever leaves the least
residual mark (re-detect confidence on the bare reverse-alpha) -- geometry wins
on faint marks, alignment on clear ones, no magic threshold. Real-file round-trip
now removes 56/56 detected Doubao clean across every corpus resolution (was 54).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* perf(doubao): skip residual inpaint at native width for exact recovery

At the captured width the fixed-geometry reverse-alpha is pixel-exact, so
inpainting over it only replaced exactly-recovered interior pixels with a
cv2 hallucination -- measured worse on a textured background (native error
vs true bg 1.6 reverse-alpha-only vs 2.6 with the old always-on
full-footprint inpaint). Native now returns the bare recovery untouched;
off-native, where NCC alignment is only sub-pixel-approximate, the footprint
inpaint stays to clean the seam. Real round-trip still 56/56 across all
corpus resolutions; negatives 0/60, Gemini unaffected.

Add test_native_returns_exact_reverse_alpha_no_inpaint as the regression
guard. Sync CLAUDE.md + README (the table cell and prose described the
pre-NCC "skipped off native / cv2-LaMa" behavior, now stale). Gitignore the
session scheduled_tasks.lock, and add the text-protection research note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 19:49:09 -07:00
Victor KuznetsovandClaude Opus 4.8 ef6fdaeeec Detect text at native resolution (capped), fixing small-text recall on large images (#27)
The text-protection detector scaled every image to a fixed 736 px long side, so
small text on large canvases (e.g. ~16 px on 2048) was downscaled below the
detector and missed -> deformed by the SDXL pass (issue #14). Detect at the
native long side capped at 1536, never upscaled (_detection_input_size, a pure
unit-tested helper). Detection is script-agnostic (DB segments regions, not
characters), so this is language-agnostic: a new benchmark
(scripts/text_detection_benchmark.py) measures recall across Latin/Cyrillic/CJK/
Hangul/Arabic/digits x sizes x canvas -> overall hit-rate 0.91 -> 1.00, worst
cell (2048/16 px) 0.06 -> 1.00. Docs updated.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 12:28:30 -07:00
0c7ff1874e feat(device): support xpu backend (#24)
* feat(device): support xpu backend

* Fall back to CPU seed generator when device RNG unsupported (xpu)

Some torch-xpu builds have no device-side RNG, so torch.Generator(device="xpu")
raises when --seed is used. _make_seed_generator tries the device generator and
falls back to a backend-agnostic CPU generator. Adds a fallback unit test.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Victor Kuznetsov <kuznetsov.va@gmail.com>
Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:13:23 -07:00
Victor KuznetsovandClaude Opus 4.8 1598c499fe Record Doubao reverse-alpha finding: needs black-background capture, not more content images (#26)
Session 2026-05-29: content-image reverse-alpha distillation fails (persistent
ghost) because the mark is never observed on a dark background (median darkest
bg over glyph pixels 58/255), so alpha is unidentifiable; no dark halo (white-
logo model is right); LaMa O is a hallucination. Gemini is clean only because
its map is the watermark on pure black (alpha=capture/255). Real unlock = black
Doubao captures (requested in #13). Shipped direction stays mask + cv2/lama.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:12:58 -07:00
Victor KuznetsovandClaude Opus 4.8 a46268f6eb Add cross-platform CI test matrix + PyPI classifiers (#25)
* Add cross-platform CI test matrix, PyPI classifiers

CI: new test.yml runs lint (ubuntu) + a test matrix (ubuntu/macos/windows
x py3.10/3.12, core+dev, GPU tests skip) on push to main and PRs, closing the
gap where only the release publish.yml ran (ubuntu, no tests). Add PyPI
classifiers (OS/Python/topic). README Tests badge, CLAUDE.md CI note.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Make availability tests reflect installed deps, not assume gpu extra

The new core+dev CI matrix has no diffusers, so the invisible-engine
availability tests (asserting is_available() is True unconditionally) and the
two mocked invisible CLI tests (whose command gates on is_available before the
mock) failed. Assert availability == actual importability of torch+diffusers,
and patch the CLI availability gate so the mocked-engine tests run regardless of
the gpu extra.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-29 11:04:12 -07:00
Victor KuznetsovandClaude Opus 4.7 96b3653b9e chore(release): v0.6.12
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:54:44 -07:00
Victor KuznetsovandClaude Opus 4.7 9aaa53fe32 fix(metadata): preserve upload format and quality on strip
remove_ai_metadata now writes JPEG at quality 95 with 4:4:4 (no chroma
subsampling) instead of the lossy PIL defaults (q75, 4:2:0), and preserves
WebP losslessly instead of silently rewriting it as PNG.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 18:46:26 -07:00
Victor KuznetsovandClaude Opus 4.7 41e4365cd4 fix(identify): explain the unknown verdict inline (#22)
A bare "unknown" verdict reads as the tool being broken. Print a one-line note
right under the verdict explaining that no locally-readable AI signal was found,
that this is not the same as clean (metadata is often stripped), and that
SynthID-class pixel watermarks have no local detector. The why was previously
only in the caveats section below.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:16:14 -07:00
Victor KuznetsovandClaude Opus 4.7 888c8c2556 chore(types): clear strict-pyright debt across src (0 errors)
Make `pyright src/` strict-clean via a hybrid: pure-logic files are fully typed
(piexif gets a local typings/ stub; PIL info-dict loops guard isinstance(key, str);
progress returns Callable[..., None]; availability checks use importlib.util.find_spec
instead of unused imports), while the irreducibly-untyped cv2/torch/diffusers boundary
files carry a documented per-file `# pyright:` relax pragma (or a ctrlregen
executionEnvironment) that disables only the unknown-type rules. Public ndarray-returning
signatures on the relaxed engines are annotated NDArray[Any] so strict consumers (cli.py)
stay clean.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 14:00:15 -07:00
Victor KuznetsovandClaude Opus 4.7 f326bab189 chore(release): v0.6.11
Ships the China TC260 AIGC PNG-chunk and HuggingFace hf-job-id provenance
detectors (223cbcf). Also syncs src/__init__.__version__, which had drifted to
0.6.9 (not bumped in the 0.6.10 release).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:43:52 -07:00
Victor KuznetsovandClaude Opus 4.7 223cbcf171 feat(metadata): detect China TC260 AIGC PNG chunk and HuggingFace hf-job-id
aigc_label now reads the TC260 label from a raw-JSON `AIGC` PNG tEXt chunk
(as Doubao/ByteDance write it, with no namespaced XMP marker) in addition to
the `<TC260:AIGC>` XMP block, via a shared _parse helper gated on a TC260 field
so a generic AIGC key cannot false-positive. New huggingface_job() reads the
hf-job-id PNG chunk; identify surfaces it as a medium-confidence hf_job signal
(parallel to the visible sparkle, never overriding a hard metadata verdict).
Both wired into has_ai_metadata/get_ai_metadata; the PNG save whitelist already
strips them on removal. Found by auditing 646 corpus originals: 28 AIGC and 3
hf-job files the library previously reported as Unknown.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:40:17 -07:00
Victor KuznetsovandClaude Opus 4.7 0eec3001bb feat(invisible): protect text automatically by default (#21)
Mirror protect_faces: protect_text defaults to True in invisible_engine and
watermark_remover, so the SDXL pipeline detects text per image and switches to
Differential Diffusion only when glyphs are found. Text-free inputs fall back to
plain img2img with no differential-pipeline load, so the autonomy is free. The
CLI now exposes a single off-switch --no-protect-text instead of the positive
flag, keeping the interface minimal.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 12:24:09 -07:00
Victor KuznetsovandClaude Opus 4.7 a0bf62e601 feat(invisible): preserve text/CJK via Differential Diffusion (--protect-text) (v0.6.10)
SDXL img2img regenerates every pixel, so small text and CJK glyphs deform
at the strengths that defeat SynthID (issue #21). With --protect-text a
CJK-native PP-OCRv3 detector (2.4 MB ONNX, cv2.dnn, no torch, cached on
first use) locates text regions and the pass switches to the SDXL
Differential-Diffusion community pipeline: a per-pixel change map keeps
text regions largely intact while the background is regenerated to strip
the watermark. Gated to the SDXL default model; falls back to plain
img2img with a warning when unavailable.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:59:15 -07:00
Victor KuznetsovandClaude Opus 4.7 7db4e231e8 fix(deps): require transformers 5.x with stable tokenizers for SDXL load
diffusers 0.38's auto-pipeline registry imports a transformers 5.x-only
symbol, so the gpu extra needs transformers>=5. Cap tokenizers to the
stable 0.22 line so the global prerelease="allow" no longer drags in the
0.23.0rc0 whose CLIP tokenizer breaks SDXL loading.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 11:58:53 -07:00
test-userandClaude Opus 4.7 27539c0da9 docs: tidy roadmap to open items only (drop shipped v0.6.7-0.6.9)
The Roadmap is the project TODO; shipped features (Integrity Clash, streaming-MP4
scan window, meta-box XMP blanking) no longer belong under "not yet implemented".
Removed them and kept the still-open remainder as its own item (AVIF/HEIF Exif
*item* inside the meta box). Net open TODO: SynthID v2 regression test, local
SynthID pixel detector, grow the SynthID corpus, real non-PNG C2PA fixtures,
pyright maintenance debt, meta-box Exif item, Canon/Samsung device signers,
Resemble PerTh (dead end), video pipeline.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:18:28 -07:00
test-userandClaude Opus 4.7 5bfed00553 feat(metadata): blank AI-label XMP inside the HEIF/AVIF meta box (v0.6.9)
HEIF/AVIF store XMP as a meta-box `mime` item whose bytes live in mdat/idat, out
of reach of the top-level uuid/jumb box stripper. An AI-label XMP packet there
(TC260 AIGC, IPTC "Made with AI", IPTC 2025.1) was therefore left in place.

isobmff.blank_ai_xmp_packets locates each XMP packet by its <?xpacket begin ...
end?> delimiters and, if it carries an AI marker (_AI_LABEL_MARKERS), overwrites
it with spaces of the SAME length. Equal length means no box size or iloc offset
shifts -- the coded image stays bit-for-bit intact, the item stays structurally
valid, only the AI label content is destroyed. Plain (non-AI) XMP is left alone,
mirroring the top-level XMP-uuid content match. Wired into remove_ai_metadata's
ISOBMFF branch after strip_c2pa_boxes.

Chosen over exiftool (a non-bundled binary dep) to stay pure-Python and
droplet-compatible; over full iinf/iloc surgery to avoid offset-rewrite
corruption risk. The AI labels we target are all XMP, so this closes the
practical gap. An Exif *item* inside the meta box (rare) still needs iinf/iloc
surgery or exiftool -- documented.

4 new tests (TestMetaBoxXmpBlanking): AI packet blanked (same length, marker
gone, surrounding image bytes intact), plain XMP preserved, no-packet no-op, and
end-to-end remove_ai_metadata on a .heic.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 18:15:48 -07:00
test-userandClaude Opus 4.7 31f0a82906 feat(metadata): detect C2PA/AIGC/IPTC manifests after a large mdat in MP4 (v0.6.8)
Provenance detection no longer relies on a fixed first-MB read. In a streaming /
non-faststart MP4 the C2PA manifest sits AFTER a multi-megabyte mdat, beyond the
1 MB scan window, so it was missed.

- isobmff.scan_c2pa_region(path): a file-seeking top-level box walker that
  returns the payloads of uuid/jumb (provenance) boxes, seeking past mdat by
  size without reading it -- works on multi-GB files. Returns b"" for
  non-ISOBMFF or on read error. Mirrors the box-size encoding of the existing
  in-memory _iter_top_level_boxes (largesize / size==0).
- metadata.scan_head(path, size): the shared input for every C2PA/AIGC/IPTC
  byte scan -- first __TEXT	__DATA	__OBJC	others	dec	hex bytes plus, for ISOBMFF, the late provenance-box
  payloads. Behavior-neutral (f.read(size)) for non-ISOBMFF inputs.
- Routed all six metadata scan sites (has_ai_metadata, aigc_label,
  iptc_ai_system, synthid_source, exif_generator XMP, get_ai_metadata
  soft-binding) and identify's head read through scan_head.

6 new tests: late box found by scan_c2pa_region / scan_head, the fixed window
provably misses it, non-ISOBMFF -> b"", front-placed (faststart) regression.

The remaining gap stays documented: EXIF/XMP stored as items inside the meta
box (AVIF/HEIF stills) still needs meta-box surgery or exiftool.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:42:29 -07:00
test-userandClaude Opus 4.7 18160fe269 feat(identify): integrity-clash detection for contradictory provenance (v0.6.7)
Surface contradictions between independent provenance signals instead of
collapsing to a single verdict -- a strong tell of spoofed, transplanted, or
laundered metadata. Inspired by arXiv:2603.02378.

Two rules in the new _integrity_clashes helper:
- Conflicting AI-origin attributions: two or more distinct AI vendors named by
  independent generator stamps (e.g. a C2PA OpenAI manifest on an image whose
  EXIF says Make="Ideogram AI").
- Camera + AI: a camera-capture C2PA device (Pixel/Leica/Sony/Nikon/Truepic)
  coexisting with an AI-generation marker -- a genuine capture is not AI.

High-precision by design: only hard generator stamps feed it (C2PA issuer when
the source is AI, SynthID proxy, EXIF/XMP generator, IPTC AISystemUsed, xAI,
AIGC). The fuzzy visible sparkle and the open invisible watermark are excluded
-- the latter can be a by-product of our own SDXL removal pass. Vendor
normalization (_vendor_of over _AI_VENDOR_TOKENS) keeps consistent signals from
clashing (C2PA "Google (Gemini)" + SynthID-Google agree); the C2PA vendor is
read from the issuer attribution, not the resolved platform, so a camera label
like "Google Pixel" cannot mis-normalize to an AI vendor.

Surfaced as ProvenanceReport.integrity_clashes (red in the table view, included
in --json). 19 new tests; all real single-origin fixtures (chatgpt/firefly/
doubao/grok/mj) verified to produce zero clashes (false-positive guard).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 13:27:25 -07:00
dependabot[bot] 6694a79514 chore(deps): bump ultralytics in the minor-and-patch group (#20)
Bumps the minor-and-patch group with 1 update: [ultralytics](https://github.com/ultralytics/ultralytics).


Updates `ultralytics` from 8.4.55 to 8.4.56
- [Release notes](https://github.com/ultralytics/ultralytics/releases)
- [Commits](https://github.com/ultralytics/ultralytics/compare/v8.4.55...v8.4.56)

---
updated-dependencies:
- dependency-name: ultralytics
  dependency-version: 8.4.56
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-27 12:04:47 -07:00
test-userandClaude Opus 4.7 7b47fa9f6a fix(io): Unicode-safe cv2 image IO + un-eat the [gpu] install hint (v0.6.6)
Two CLI/IO robustness bugs surfaced by issues #17 and #19.

#17 -- non-ASCII image paths (Chinese/Cyrillic/accented) failed on Windows:
cv2.imread/imwrite use the platform ANSI code-page API, so the decode came back
empty with a "can't open/read file" warning. New image_io.imread/imwrite route
through np.fromfile+cv2.imdecode / cv2.imencode+tofile (Unicode-safe, byte-
identical output, cv2.imread None-semantics preserved); all 8 cv2 read/write
call sites now go through it. Behavior-neutral on macOS/Linux (already accept
UTF-8 paths), so the fix is correct-by-construction for the Windows-only bug.

#19 (incidental) -- rich parsed the "[gpu]" in the GPU-extra install hint as a
style tag and dropped it, so the printed command was the un-installable
"pip install 'remove-ai-watermarks'". Escaped as \[gpu] at both call sites.

Tests: test_image_io.py (non-ASCII round-trip, alpha, missing/empty/garbage
semantics); test_cli.py::TestGpuHintMarkup (install hint keeps the extra).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:52:48 -07:00
test-userandClaude Opus 4.7 d847b39292 docs: lawful-use disclaimer + primary-source Legal-table corrections
README:
- surface a lawful-use / no-liability disclaimer near the top
- reword two feature bullets away from detection-evasion framing
  ("bypass AI image classifiers" -> neutral post-processing; drop the
  platform-targeting language from the "Made with AI" bullet)
- Legal table, each corrected against the primary text:
  - CA AB 2655 was struck down on Section 230 ONLY (Kohls v. Bonta,
    E.D. Cal., Aug 2025); the court did not reach the First Amendment
    (the companion AB 2839 was separately enjoined on 1A grounds)
  - COPIED Act: add the bill number (S. 1396, 119th Cong.)
  - South Korea AI Framework Act: in force 22 January 2026 (exact date)

CLAUDE.md: sync the South Korea date to 22 January 2026.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 11:50:20 -07:00
test-userandClaude Opus 4.7 fee0e139af docs: refresh README roadmap + metadata-strip coverage for v0.6.x
- Metadata-strip feature now lists the audio/video container coverage shipped in
  v0.6.0-v0.6.4 (MP4/MOV/M4V/M4A via ISOBMFF box walker; WebM/MP3/WAV/FLAC/OGG
  losslessly via ffmpeg).
- Roadmap updated: the AVIF/HEIF item now reflects that top-level XMP/C2PA boxes
  and non-ISOBMFF audio/video are handled, with only meta-box-item EXIF/XMP left
  (needs exiftool). Added the open backlog: multi-signal "Integrity Clash"
  reporting (arXiv:2603.02378), Canon/Samsung device signers pending a real
  sample, the streaming-MP4 scan-window limit, and Resemble PerTh audio as
  evaluated-but-infeasible.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:42:40 -07:00
test-userandClaude Opus 4.7 e1c99b5937 fix(identify): gate C2PA issuer->generator attribution on AI source type (v0.6.5)
Prevents an unmapped C2PA device whose manifest incidentally contains a mapped
issuer substring (e.g. the "Adobe XMP" toolkit string in a Canon/Sony camera
capture) from being mislabeled as that AI generator ("Adobe Firefly").
_attribute_platform now names a specific AI-generator platform only when the
digital-source-type is trainedAlgorithmicMedia; otherwise it degrades to the
neutral "C2PA signer: X" label. Real Firefly/OpenAI/Google output carries the
AI source-type and is unaffected (verified: chatgpt-1.png->OpenAI,
firefly-1.png->Adobe Firefly still attribute). Closes the only real downside of
leaving Canon/Samsung/Bria device signers unmapped: detection and removal were
already unaffected; now the platform label degrades gracefully too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-27 10:29:12 -07:00
test-userandClaude Opus 4.7 f9cf14c372 feat(metadata): strip container metadata from WebM/MP3/WAV/FLAC/OGG via ffmpeg (v0.6.4)
remove_ai_metadata now handles non-ISOBMFF audio/video (which the box walker
can't reach) by shelling out to ffmpeg with a lossless stream copy
(`-map_metadata -1 -map_chapters -1 -c copy`): codec data is untouched, only
container tags/chapters (ID3 / RIFF / Vorbis comments / EBML tags) are dropped.
Requires ffmpeg on PATH; raises a clear RuntimeError if absent or if ffmpeg
can't parse the input (instead of crashing in the image path).

Verified end-to-end: a real ffmpeg-made WAV/MP3 with a "Suno AI" title tag ->
tag gone, audio bytes preserved.

NOT built (evaluated, deliberate): Resemble PerTh audio *detection* --
`get_watermark()` returns a raw bit array with no presence/confidence flag, so
reliably telling watermarked from clean needs Resemble's fixed payload or a
confidence API (neither public; no real sample to calibrate). Same wall as the
SynthID pixel detector. AVIF/HEIF meta-box EXIF/XMP stripping also stays a gap
(needs exiftool, a non-installed binary). Both documented in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:39:42 -07:00
test-userandClaude Opus 4.7 bc3228d387 feat(visible): Doubao text-mark removal + universal region eraser
Add deterministic, CPU-only removal of the visible Doubao "豆包AI生成" mark and
a position-agnostic region eraser for any other visible watermark/logo.

- doubao_engine.py: locate (geometry, scales with width) + polarity-aware
  white-top-hat glyph mask + cv2 inpaint; coverage-gated detection and a
  dense-text safety guard. No GPU, ~30ms.
- region_eraser.py + `erase` command: inpaint arbitrary --region box(es).
  Default cv2 backend (no deps); optional big-LaMa via onnxruntime (`lama`
  extra, Carve/LaMa-ONNX, model downloaded on first use, never bundled).
- cli `visible --mark auto|gemini|doubao`: auto routes by detector confidence.
- tests for both engines; seed previously-unseeded CLI image fixtures to stop
  the Doubao detector flaking on random corners.
- .gitignore: doubao_capture/{seeds,captures} scratch (alpha-map calibration).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:31:51 -07:00
test-userandClaude Opus 4.7 9f93d9c0c5 feat(identify): add Sony C2PA device attribution, verified (v0.6.3)
Adds Sony to _DEVICE_C2PA_PLATFORM, matching Sony's own `sony.sig` / `sony.cert`
C2PA assertion namespace (NOT bare "Sony", which is a common EXIF Make). Verified
against a real Sony-signed file (Sony PXW-Z300, signer "Sony Corporation") found
in the Security4Media/c2pa-video-player repo. The sample is video (MP4) -- our
ISOBMFF C2PA path detects it; Sony Alpha stills likely share the namespace.

Verified device set is now Leica, Nikon, Google Pixel, Sony, Truepic. Canon /
Samsung / Bria still have no public direct-download C2PA sample to verify.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 21:13:49 -07:00
test-userandClaude Opus 4.7 64be9598f2 fix(identify): device-token-first C2PA attribution; add verified Pixel (v0.6.2)
Replaces the claim-generator-string match with a distinctive device-token scan
of the manifest bytes (_device_platform / _DEVICE_C2PA_PLATFORM), which is more
robust: it catches devices where the generator name lives under a non-standard
CBOR key (Pixel uses `claim_generator_info`, so it has no `claim_generator`).

- Adds Google Pixel, verified against a real Pixel 10 Pro C2PA file (attached to
  c2pa-rs issue #1609/#1554): cert CN "Pixel Camera", digitalSourceType
  `computationalCapture` -> capture authenticity, not AI (is_ai stays None).
- Token distinctiveness is load-bearing: bare "Truepic" matched the OpenAI
  chatgpt-1.png fixture (Truepic is a trust-chain signing authority), so the
  token is the specific "Truepic_Lens"; "Pixel Camera" (cert CN) not "Pixel".
- Verified Leica/Nikon/Truepic/Pixel attribute correctly and OpenAI/Adobe/MJ
  do not regress. Sony/Canon/Samsung/Bria stay unmapped: no public direct-
  download C2PA sample exists to verify their in-manifest string.
- Regression tests: device token beats incidental issuer mentions (Leica,
  Pixel-vs-Google).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:43:40 -07:00
test-userandClaude Opus 4.7 dda2ee7fbb fix(identify): attribute C2PA by claim_generator, not incidental issuer tokens (v0.6.1)
Verified on real signed files that the issuer byte-scan mis-attributes
multi-entity manifests: Leica read as "Truepic" (timestamp authority in the
chain), Nikon as "Adobe Firefly" (XMP-toolkit "Adobe" + the sample's
"Adobe_MAX" name), Truepic as "Google". Platform attribution now prefers the
claim generator (what produced the asset) and falls back to the issuer scan.

- New _CLAIM_GENERATOR_PLATFORM map + _platform_from_generator; claim generator
  read for non-PNG via the now-public c2pa.cbor_text_after.
- Device tokens listed only where verified against a real C2PA file (Leica
  lc_c2pa, Nikon, Truepic Lens); Pixel/Samsung/Sony/Canon/Bria deferred until a
  real sample confirms the in-manifest string. Camera C2PA marks capture
  authenticity, so these never set is_ai.
- cbor_text_after made public (was _cbor_text_after); call sites + tests updated.
- Regression test: claim_generator beats incidental Adobe/Google/Truepic tokens.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 20:10:07 -07:00
test-userandClaude Opus 4.7 2676325184 feat(c2pa): expand soft-binding vendor map with registry-verified algs
Adds Trufo, Overlai, MarkAny, Mentaport, LumaTrace, VerdaAI, ContentLens, ISCC
(io.iscc content code), and Adobe ICN fingerprint to C2PA_SOFT_BINDINGS, and
notes AIWatermark wraps Meta PixelSeal. All `alg` prefixes verified against the
official c2pa-org/softbinding-algorithm-list registry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 18:00:16 -07:00
test-userandClaude Opus 4.7 c196a16900 feat: detect soft-binding vendors, IPTC 2025.1, video/audio C2PA, TrustMark (v0.6.0)
Broadens metadata provenance coverage at the detection and container-strip level.

Detection:
- C2PA soft-binding `alg` -> forensic-watermark vendor (Adobe TrustMark,
  Digimarc, Imatag, Steg.AI, Microsoft, ...) via C2PA_SOFT_BINDINGS +
  soft_binding_vendors_in(); names the watermark vendor even when the watermark
  itself can't be decoded.
- IPTC Photo Metadata 2025.1 AI-disclosure XMP fields (AISystemUsed etc.) via
  iptc_ai_system() + IPTC_AI_FIELD_MARKERS.
- Adobe TrustMark open keyless decoder (trustmark_detector.py, optional extra
  `trustmark`) -- the watermark behind Adobe Durable Content Credentials.
  Detects provenance, not AI origin, so it does not assert is_ai.

Removal / containers:
- isobmff.strip_c2pa_boxes now also drops a top-level XMP uuid box that carries
  an AI label (matched by AI-marker content, byte-order-robust; plain XMP kept).
- remove_ai_metadata routes MP4/MOV/M4V/M4A (and any ftyp-sniffed ISOBMFF)
  through the box stripper; raises a clear error for non-ISOBMFF audio/video
  (WebM/MP3/WAV) instead of crashing in the image path.

Tests: soft-binding scan, IPTC element/attribute/presence, MP4 + M4A detect/
strip, ISOBMFF XMP surgical strip, content-sniff, unsupported-container guard,
TrustMark absent-safety + identify integration. ruff clean; pyright clean on
all new modules.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 17:56:48 -07:00
test-userandClaude Opus 4.7 ba94de8275 feat: strip AI-provenance EXIF tags on removal (v0.5.6)
remove_ai_metadata now scrubs AI tags from the JPEG EXIF instead of passing
the block through wholesale. Closes the v0.5.5 follow-up: the xAI/Grok
Signature + UUID-Artist pair was detected but not removed.

- metadata._scrub_ai_exif(): deletes the xAI signature pair and any
  Software/Make/Artist/ImageDescription tag carrying an AI_GENERATOR_TOKENS
  token (so Ideogram's Make="Ideogram AI" is scrubbed too), keeping genuine
  camera/editor EXIF intact.
- Shared _is_xai_signature_pair / _exif_text helpers (module-level compiled
  regexes) are now the single source of truth, used by both xai_signature
  and _scrub_ai_exif.
- Tests: Grok signature stripped on JPEG output, Ideogram Make stripped,
  real-camera Make ("Apple") preserved. 325 passing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:26:20 -07:00
test-userandClaude Opus 4.7 74618b91a7 feat: detect xAI/Grok EXIF signature; refresh watermarking landscape (v0.5.5)
xAI Grok (Aurora) images carry no C2PA/SynthID/IPTC -- their only provenance
signal is an EXIF pair: ImageDescription "Signature: <base64>" + a UUID Artist.
Verified stable across 3 genuine generations (a real download previously read
as unknown / "no AI metadata").

- metadata.xai_signature(): matches the Signature blob + UUID Artist pair;
  wired into has_ai_metadata, get_ai_metadata, and identify (platform
  "xAI (Grok / Aurora)").
- data/samples/grok-1.jpg: real Grok fixture (neutral content; the Artist UUID
  is the public image id, not PII).
- Tests: synthetic-fixture unit tests, real-sample assertion, identify
  integration (322 passing).

Docs (research refresh, May 2026):
- C2PA 2.4 Durable Content Credentials (soft-binding re-discovery after the
  embedded manifest is stripped).
- New AI-labeling laws, primary-source verified: EU AI Act Art 50 (2026-08-02),
  South Korea AI Framework Act Art 31(3), California AB 853.
- Hedge removal claims: defeating the SynthID verifier is not forensic
  invisibility (arXiv:2605.09203); cite SynthID-Image (arXiv:2510.09263).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 14:14:35 -07:00
test-userandClaude Opus 4.7 5e29c69e7b README: lead with hosted raiw.cc CTA, honest free/paid split
Move the raiw.cc call-to-action above the sponsor ask and drop the
misleading "free web service" framing: visible-watermark and metadata
removal are free, invisible removal runs on paid cloud GPUs. Also point
no-GPU users to the hosted service from the invisible-removal feature
bullet, where the GPU requirement is stated.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-26 12:47:28 -07:00
test-userandClaude Opus 4.7 8ed4a754ff Add GitHub Sponsors donation button (FUNDING.yml + README badge)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 16:09:52 -07:00
test-userandClaude Opus 4.7 03fb460f77 Track the labeled SynthID corpus; complete metadata-source test coverage
Corpus images were gitignored (local-only). The negatives were reviewed and
cleared for publishing, so the labeled set is now committed (regular git, 65 MB
across 25 files) -- making the removal regression set reproducible and CI-able.

Corpus:
- Track data/synthid_corpus/images/ (pos 9, neg 15, cleaned 1); keep only the
  synthetic refs/ calibration fills gitignored.
- Reconcile manifest.csv to the on-disk files: 117 -> 25 rows (92 dangling rows
  for removed images pruned; dedup left one cleaned output, f6dd47a5).
- Rewrite the corpus README layout/policy (images committed; review every image
  for private content before adding -- public repo, permanent history).

Test fixtures:
- Remove data/samples/not-ai-1/2/3 (personal iPhone photos, incl. GPS EXIF).
- Add the clean_photo conftest fixture serving a verified-negative image from
  the corpus neg/ set; repoint the three "non-AI / clean photo" tests onto it
  (skips if the corpus is absent).

Metadata-source coverage (close the last sub-variant gaps):
- c2pa digitalSourceType: algorithmicMedia (procedural, not flagged AI) and
  compositeWithTrainedAlgorithmicMedia (AI + SynthID proxy).
- exif_generator: EXIF Artist and ImageDescription fields (Software/Make/XMP
  CreatorTool were already covered).

All 8 metadata-source kinds are now tested at both the unit and identify()
level. 313 tests pass. CLAUDE.md updated (corpus tracked, clean_photo fixture).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:46:47 -07:00
test-userandClaude Opus 4.7 3ebdee57b8 Test the untested pure logic: MPS fallback, tiling, isobmff/c2pa edges
Coverage audit (pytest --cov) found real, non-model logic at 0%/low cover.
Add unit tests that need no model download:

- img2img_runner.py 0% -> 100%: the MPS->CPU fallback orchestration, mocked
  via injected load_pipeline/reload_on_cpu callables. Guards the production
  behavior hit this session (native-res SDXL OOMs on MPS, must retry on CPU;
  non-MPS errors must propagate; "mps"-worded error on a cpu device must not
  reload).
- ctrlregen/tiling.py 0% -> 40%: the pure tile math (tile_positions,
  make_blend_weight, resize_center_crop) that decides how large images are
  split and blended. (run_tiled stays model-bound, untested.)
- isobmff.py 93% -> 100%: size==0 (box-to-EOF) and truncated 64-bit largesize
  parsing branches for AVIF/HEIF/JXL C2PA stripping.
- c2pa.py: non-PNG-signed .png reads as clean (has_c2pa_metadata /
  extract_c2pa_chunk) instead of mis-parsing.

309 tests pass (+23). Document in CLAUDE.md that these pure helpers are
unit-tested without downloads so future sessions don't skip them as "ML".
No src/ change, no release.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:21:32 -07:00
test-userandClaude Opus 4.7 d24d8a4b14 Extract _target_size helper + regression-test native resolution (v0.5.4)
The native-vs-downscale decision in InvisibleEngine.remove_watermark (the
issue #10/#15 fix: max_resolution=0 must not pre-downscale, since any
downscale both loses quality and lets SynthID survive) had no test. Extract
it into a pure helper invisible_engine._target_size(w, h, max_resolution)
and cover it with tests/test_invisible_engine.py::TestTargetSize so a
re-introduced forced downscale fails CI instead of silently regressing #15.

Also:
- Clamp the short side to >=1 in _target_size: extreme aspect ratios (e.g.
  5000x3 with --max-resolution 1024) truncated it to 0 and crashed
  image.resize(). Pre-existing in the inline math; fixed now that it is a
  named, tested function.
- Consolidate the two duplicated temp-file save blocks into one
  unconditional save (behavior unchanged: the EXIF-transposed image is
  still always persisted before WatermarkRemover reloads it by path), and
  drop the now-redundant `_tmp_path is not None` guard in finally.
- Bump version 0.5.3 -> 0.5.4 (pyproject, __init__, uv.lock); document the
  helper as the regression guard in CLAUDE.md.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 14:09:33 -07:00
test-userandClaude Opus 4.7 28fe13db8f Document native-res MPS OOM -> CPU-fallback behavior in limitations
Concrete data point from the 2026-05-25 gpt-image SDXL run: native
1254x1254 fp32 OOMs at the UNet step (not just VAE) on a 20 GB MPS
ceiling, and img2img_runner auto-falls back to CPU and completes
(slow, weight-identical, still defeats SynthID). enable_vae_tiling()
alone does not prevent it. Fast Mac workarounds: fp16 on MPS or
--max-resolution; neither is the default.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:57:13 -07:00
test-userandClaude Opus 4.7 59d72c5db7 Record verified gpt-image-2 SynthID-cleaned chain in corpus
Add manifest row for the 4ef377bd -> f6dd47a5 chain: a gpt-image-2 sample
(openai.com/verify: SynthID + C2PA detected) cleaned via v0.5.3 `all` at
native 1254x1254 (prod-equivalent SDXL base, strength 0.05, 50 steps).
openai.com/verify reports SynthID NOT detected after the run, re-confirming
that the #10 native-resolution default defeats OpenAI SynthID and resolving
the #15 root cause (older SD-1.5/768px downscale default did not).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 13:55:10 -07:00
test-userandClaude Opus 4.7 e27f24f520 test(samples): commit real Doubao fixture + AIGC real-sample test
data/samples/doubao-1.png is the real #13 sample: carries the China TC260
<TC260:AIGC> XMP label and a visible '豆包AI生成' text mark (bottom-right).
Grounds the AIGC detection on a real file (alongside the synthetic tests)
and serves as the fixture for visible-watermark removal work.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:37:15 -07:00
test-userandClaude Opus 4.7 1afc1e60ef test(samples): add real Doubao TC260 AIGC reference sample
2048x2048 PNG carrying China's TC260 <TC260:AIGC> label; identify reports
it as a China AIGC-labeled generator (TC260). Reference fixture for manual
re-verification of the TC260 detection path -- the automated tests use
synthetic blobs, so nothing depends on this file being present.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:36:28 -07:00
test-userandClaude Opus 4.7 d45f0806a0 chore(release): v0.5.3 — detect China TC260 AIGC label (Doubao)
- feat(identify): detect the China TC260 <TC260:AIGC> XMP label (Doubao
  and other China-served generators); reports platform + ContentProducer.
  Removal already strips it via the existing metadata cleaner.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:30:40 -07:00
test-userandClaude Opus 4.7 c7f0d71f90 feat(identify): detect China TC260 AIGC label (Doubao et al.)
China-served generators embed an XMP <TC260:AIGC>{"Label":"1",...} block
(China's mandatory AI-content labeling, TC260 standard). Doubao (ByteDance)
uses it -- verified on the real #13 sample. It's none of C2PA / SynthID /
imwatermark / IPTC, so identify() previously returned unknown.

- metadata: AIGC_MARKERS + aigc_label() (json-decodes the HTML-entity-encoded
  block); has_ai_metadata + get_ai_metadata now surface it.
- identify: new 'aigc' signal -> is_ai True, platform 'China AIGC-labeled
  generator (TC260; e.g. Doubao)', carries the ContentProducer code.
- Container-agnostic raw-byte scan, so it covers the whole China-AIGC ecosystem
  (Jimeng/Kling/Qwen/Ernie share the standard).
- Tests: synthetic TC260 block (metadata + identify). Docs updated.

Addresses #13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:29:51 -07:00
test-userandClaude Opus 4.7 768d997ef0 docs: scope SynthID provenance claims to source-verified facts
Threat model: replace the unverified deployment list (Gemini 3 Pro /
Nano Banana Pro / Imagen 4 / Veo) with the source-verified scope -- SynthID
across Imagen / Veo / Lyria plus Gemini app outputs (>10B items by Dec 2025),
and attribute the 136-bit payload to the paper's SynthID-O variant.

openai-images-2 sample: note the file predates the 19 May 2026 SynthID
rollout across ChatGPT / Codex / API, and that openai.com/verify is now the
public oracle (still no local decoder).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 12:18:13 -07:00
test-userandClaude Opus 4.7 37769453a9 chore(release): v0.5.2 — native-resolution invisible removal (fixes #10)
- fix(invisible): process at native resolution by default; the forced
  downscale-to-1024 -> upscale-back round-trip was the main quality loss
  (#10). Matches the raiw.cc backend (fal fast-sdxl = sdxl-base-1.0).
  New --max-resolution opt-in cap for GPU/MPS memory.
- docs: verified fal checkpoint, native-res, gpt-image-2 SynthID.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 10:00:25 -07:00
test-userandClaude Opus 4.7 f3ecea348a docs: gpt-image-2 carries SynthID (no local decoder; openai.com/verify oracle)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:59:16 -07:00
test-userandClaude Opus 4.7 fb42295b3a docs: record verified fal fast-sdxl checkpoint + native-resolution updates
- fal's llms.txt confirms fast-sdxl is stabilityai/stable-diffusion-xl-base-1.0,
  the exact checkpoint the local CLI defaults to -> local == prod weights.
  Recorded in CLAUDE.md and README.
- README How it works + sample README: replace the old downscale->upscale
  description with native-resolution processing (matches the #10 fix);
  document --max-resolution as an opt-in OOM cap.
- README roadmap: idna already bumped (uv-secure clean).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:57:03 -07:00
test-userandClaude Opus 4.7 18740969ae fix(invisible): process at native resolution by default
The invisible pipeline force-downscaled inputs >1024px to 1024 before
diffusion, then upscaled the result back -- a lossy round-trip that was
the main cause of the quality loss reported in #10. The hosted raiw.cc
backend (fal fast-sdxl) does no pre-downscale, and at strength ~0.05
SDXL img2img doesn't need it.

Default is now native resolution (max_resolution=0). New --max-resolution
flag (invisible / all / batch) re-introduces an opt-in long-side cap only
to bound GPU/MPS memory on very large inputs.

Addresses #10. End-to-end quality/removal not re-verified locally (no GPU
here); matches raiw-app's proven production config.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:50:06 -07:00
test-userandClaude Opus 4.7 93c664f7fb docs: sync README + corpus map with v0.5.x detection coverage
- README Features: add the identify / provenance-detection capability.
- README Supported models: add FLUX, Stability AI, Microsoft/Bing
  (MAI-Image), Meta AI rows; note SD/SDXL/FLUX imwatermark is locally
  detectable; add a detection note pointing at identify.
- corpus README per-platform map: add Stability / Ideogram / Recraft /
  Krea-FLUX rows + an imwatermark column; correct Bing (MAI-Image,
  signs 'Microsoft'); note imwatermark fires only on pristine pipeline
  output, not re-hosts/exports.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:32:03 -07:00
test-userandClaude Opus 4.7 20a754ef98 chore(release): v0.5.1 — security + bug fixes
- security: bump idna 3.11 -> 3.16 (GHSA-65pc-fj4g-8rjx)
- fix(ctrlregen): correct module import paths (#11, @neosun100)
- fix(cli): preserve alpha channel through visible/all/batch (#8, @rlorenzo)
- fix(cli): safer re-exec via -m instead of repr(sys.argv) -c string (#9, @eskibars)

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:21:53 -07:00
test-userandClaude Opus 4.7 e60f183d29 style(ctrlregen): sort imports (follow-up to #11)
#11 left the import block un-sorted (ruff I001); reorder so diffusers
precedes the local ctrlregen import.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:21:53 -07:00
Rex Lorenzo d091b9f822 fix(cli): preserve alpha channel in visible-watermark pipeline
`cv2.imread(..., IMREAD_COLOR)` was silently stripping the alpha channel
on RGBA inputs, and `cv2.imwrite` then wrote opaque 3-channel PNGs — so
images with transparent backgrounds came back with an opaque-black (or
white) background and the sparkle area baked in as a solid blob.

Read the source with `IMREAD_UNCHANGED`, keep the alpha plane out of the
detection/inpaint path (those still operate on BGR), and rejoin alpha at
save time. The detected watermark bbox is also zeroed in the alpha plane
so the sparkle region becomes transparent rather than an opaque artifact.

Applies to `visible`, `all`, and `batch` modes. RGB-only inputs and JPEG
outputs are unaffected.
2026-05-25 09:18:39 -07:00
test-userandClaude Opus 4.7 e8d698814a fix(cli): re-exec via -m instead of a repr(sys.argv) -c string
Based on #9 by @eskibars. Replaces the os.execl(..., "-c", repr-string)
restart (used after the CUDA-torch auto-install) with os.execv -m, so we
no longer build an exec string from repr(sys.argv). Forwards sys.argv[1:]
only: under -m Python sets argv[0] to the module path, so passing the full
argv would re-inject the program name as a spurious Click argument.

Verified: python -m remove_ai_watermarks.cli --version works; test_cli green.

Closes #9

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:15:55 -07:00
Neo 孫 663c8c64ca fix(ctrlregen): correct module import paths (#11)
The CtrlRegen engine module references a non-existent top-level package
'ctrlregen' in four locations:

  src/remove_ai_watermarks/noai/ctrlregen/engine.py
    L39:  from ctrlregen.pipeline import CustomCtrlRegenPipeline
    L57:  from ctrlregen.color import color_match
    L242: from ctrlregen.tiling import resize_center_crop, run_tiled
    L267: from ctrlregen.tiling import resize_center_crop

These should be absolute imports of the package's own subpackage. As a
result, the top-level try/except sets _HAS_DIFFUSERS=False and
_HAS_COLOR_MATCHER=False even when the [gpu] extra is correctly
installed, and is_ctrlregen_available() always returns False.

Effect on users: invoking the ctrlregen profile crashes with

  ImportError: Failed to auto-install missing dependencies:
  controlnet-aux, color-matcher, safetensors

regardless of whether those packages are installed. The auto-install
fallback also fails in uv-managed venvs (uv does not ship pip in the
venv by default), so the error path is unrecoverable.

Reproduction (before fix):
  uv sync --all-extras
  uv run remove-ai-watermarks invisible <image> --pipeline ctrlregen
  # → ImportError as above

Fix: change the four imports to use the package-qualified path
(matching the absolute-import style used elsewhere in the codebase,
e.g. watermark_remover.py).

Verified post-fix on Linux/CUDA (NVIDIA L40S):
  - is_ctrlregen_available() returns True
  - CtrlRegen pipeline loads, downloads weights, and runs end-to-end
  - Tile-based path (image > 512px) processes 6 tiles cleanly
  - 142 existing pytest tests still pass
2026-05-25 09:11:20 -07:00
test-userandClaude Opus 4.7 b45e2a5731 chore(deps): bump idna 3.11 -> 3.16 (GHSA-65pc-fj4g-8rjx)
Fixes the uv-secure abort that stopped maintain.sh: idna 3.11 had
GHSA-65pc-fj4g-8rjx (fix in 3.15). uv lock --upgrade-package idna pulls
3.16; uv-secure now reports no vulnerabilities. Lock-only change, 266
tests still pass. Updates the stale CLAUDE.md note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 09:04:24 -07:00
test-userandClaude Opus 4.7 0762807e42 chore(release): v0.5.0 — image provenance (identify) + SD/SDXL/FLUX + EXIF/XMP detection
New since v0.4.1:
- identify command: aggregate C2PA, IPTC, SD/ComfyUI params, SynthID
  proxy, visible sparkle, open invisible watermark into one provenance
  verdict (--json, --no-visible).
- Open SD/SDXL/FLUX invisible-watermark detection (imwatermark, extra: detect).
- EXIF Software / XMP CreatorTool generator-tag reading (incl. AVIF/HEIF).
- Stability AI + Microsoft/Bing C2PA issuers; SynthID metadata detection.
- SynthID reference corpus + experimental pixel-carrier probe.
- Fix: __version__ was stuck at 0.3.4 (banner mismatch), now synced.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:58:45 -07:00
test-userandClaude Opus 4.7 626f43aec9 docs: correct SynthID spectral-carrier understanding
Deeper re-examination (2026-05-25) of github.com/aloshdenny/reverse-SynthID
on our own data corrects the earlier over-stated dead-end:

- The carrier IS real on solid fills -- measured via per-bin PHASE
  COHERENCE (the prior probe used spatial/FFT-magnitude NCC, which can't
  see a fixed-phase carrier). White gemini-2.5-flash fills: coherence 0.86
  at carriers (0,+/-7..12,20..23) vs 0.31 random; single-image phase-match
  +0.83 vs -0.24 for real photos.
- But it does not generalize: carriers are model-version/resolution/color
  specific (v4 codebook for 3.1-flash/nb-pro scores ~0.5 on 2.5-flash),
  and collapse on real content (coherence ~random; v4 content 0.518 vs
  neg 0.504, no separation).

Net: a controlled-fill characterizer, not a real-content detector.
Metadata proxy + visible sparkle + online oracles remain the ceiling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-25 08:57:35 -07:00
test-userandClaude Opus 4.7 ede35a3db5 feat(metadata): read EXIF Make tag; collect Ideogram/Recraft/Krea-FLUX
Collected live samples from three popular generators we lacked:

- Ideogram tags its downloads with EXIF Make="Ideogram AI" (no C2PA, no
  SynthID, no imwatermark) -- the Make tag is its only signal. exif_generator
  only read Software/Artist/ImageDescription, so it missed this; now reads
  Make too. Real cameras put "Apple"/"Canon" in Make (no AI token), so this
  stays low-false-positive. 4 originals ingested.
- Recraft (PNG export) and Krea hosting FLUX 2: downloads carry NO detectable
  signal -- no C2PA/EXIF/IPTC, and notably no imwatermark despite Krea running
  FLUX. identify correctly reports 'unknown'. Both ingested as neg fixtures.

Lesson recorded in CLAUDE.md: the imwatermark detector fires only on pristine
output from a pipeline that runs the encoder (diffusers default, official BFL),
not from re-hosts (Krea/Stability) or re-encoded exports (Recraft/Canva).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 18:38:56 -07:00
test-userandClaude Opus 4.7 ad3b8ee248 feat(identify): read EXIF Software / XMP CreatorTool generator tags
Closes the documented gap where EXIF/XMP fields inside AVIF/HEIF/JXL went
unparsed. metadata.exif_generator extracts the EXIF Software/Artist tag
(via PIL+piexif, which opens AVIF natively) and the XMP CreatorTool (via a
container-agnostic raw-byte scan that also covers HEIF/JXL that PIL can't
open), and matches against AI_GENERATOR_TOKENS so only generator names
(Firefly, DALL-E, Midjourney, ComfyUI, ...) fire -- a plain 'Adobe
Photoshop' or 'GIMP' tag is not flagged.

identify() surfaces it as a high-confidence signal and uses it for
platform attribution when no C2PA names a platform, so an AVIF/HEIF whose
only AI signal is an EXIF/XMP generator tag is now caught.

Validated with synthesized fixtures (the 'no positive fixtures' blocker
was self-imposed): real AVIF and JPEG written with EXIF Software via PIL,
plus an XMP CreatorTool raw-scan fixture. Zero false positives across the
109-image corpus (real iPhone photos carry no AI generator token).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:56:39 -07:00
test-userandClaude Opus 4.7 3a1c5427c8 feat(c2pa): recognize Stability AI issuer; fix Microsoft platform label
Collected live C2PA positives from Bing Image Creator and Stability Brand
Studio (DreamStudio successor) and learned two things our scan got wrong:

- Bing now runs Microsoft's own MAI-Image model, not DALL-E, and signs
  C2PA as 'Microsoft'. The scan caught it, but the platform label claimed
  'Microsoft Designer (DALL-E / OpenAI backend)'. Relabeled model-neutral:
  'Microsoft (Bing Image Creator / Designer)'.
- Stability signs C2PA as 'Stability AI' (cert 'Stability AI Ltd'), which
  was not in C2PA_ISSUERS, so it read as 'unknown signer'. Added the issuer
  and a platform mapping. Stability uses no SynthID and (on its current
  Stable Image model) no imwatermark watermark -- verified, both negative.

Both ingested as SynthID-negative corpus fixtures (they are AI but not
SynthID) for issuer-coverage. Canva skipped: its downloads are re-encoded
design exports that strip C2PA, so a Canva sample would be inconclusive.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 17:12:42 -07:00
test-userandClaude Opus 4.7 27ad5b7645 feat(identify): detect open SD/SDXL/FLUX invisible watermark
Research found one locally-fillable detection gap: Stable Diffusion, SDXL,
and FLUX all embed an open DWT-DCT watermark via the invisible-watermark
(imwatermark) library -- a PUBLIC decoder, no secret key, unlike SynthID.
New invisible_watermark.py decodes the known fixed patterns (verified
against upstream source: diffusers SDXL WATERMARK_MESSAGE, FLUX.2
src/flux2/watermark.py, and the 'StableDiffusionV1' default string) and
identify() reports the scheme as a high-confidence signal.

Verified locally end-to-end: embedding SDXL's exact 48-bit message and
decoding it back recovers 48/48 bits; a clean image and our own fal-SDXL
outputs decode to ~21/48 (no match). Caveat baked into the report: the
watermark is fragile -- gone after JPEG q90 -- so it confirms origin only
on pristine files; absence is never proof.

imwatermark is an optional dep (extra 'detect'; pulls non-headless opencv),
so the import is guarded and the signal is skipped when absent. CLI
--no-visible now means metadata-only (skips both pixel-domain detectors).

Also records the broader watermarking landscape in CLAUDE.md: which
services are locally detectable (SD/SDXL/FLUX), C2PA-covered (Bing/Canva/
Getty/Shutterstock unsampled), or proprietary-only like SynthID (Amazon
Titan/Nova, Kakao). Midjourney embeds neither C2PA nor an invisible mark.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:53:59 -07:00
test-userandClaude Opus 4.7 7dcc922617 feat(probe): solid-fill SynthID carrier probe; corpus reconfirms no pixel detector
scripts/synthid_pixel_probe.py is an experimental/diagnostic tool for the
one pixel-domain question that isn't a dead-end: on solid-color fills the
zero-mean residual IS essentially the watermark carrier. Two modes:
'consistency' (mean pairwise NCC of carriers across fills vs random
baseline) and 'removal' (does the pipeline drop the carrier toward
baseline?). Logic validated synthetically (injected carrier correlates,
random noise doesn't, simulated removal collapses it) -- no real fills or
GPU needed.

Running its metric on the corpus independently re-confirms the documented
dead-end for real content: at matched resolution SynthID positives do not
cluster apart from negatives (within-Gemini 0.07; at 1024 px pos-vs-neg
>= pos-vs-pos). An apparent 0.62 among 1254px ChatGPT positives turned out
to be near-duplicate content (5 renders of one prompt at ~0.92; a distinct
ChatGPT image scored ~0 against them), not a shared carrier. The probe is
solid-fills-only; do not use on real content.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:35:39 -07:00
test-userandClaude Opus 4.7 144b98cf0b docs: record external AI-detector models as out of scope
Generic HuggingFace AI-vs-real classifiers are per-generator, degrade
off-distribution, are untested on the metadata-stripped surfaces we
care about (gpt-image, Gemini Nano Banana), and our own SDXL pass would
likely defeat them as it does SynthID. Detection stays local +
signal-based. Decision 2026-05-24.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:27:00 -07:00
test-userandClaude Opus 4.7 1a9f3e4fe5 test(identify): cover provenance branches, CLI, sparkle helper
Adds 20 tests around the new provenance path:

- identify(): local SD/ComfyUI params -> local-pipeline attribution;
  visible-sparkle gating at the 0.5 threshold (mocked detector: above,
  below, unavailable, opt-out); metadata verdict not downgraded by a
  sparkle hit; OpenAI/SynthID caveats + dedup; ProvenanceReport is
  JSON-serializable (the CLI --json path); and the honest edge where a
  C2PA manifest without an AI source marker stays 'unknown'.
- CLI 'identify': help, clean PNG, AI PNG platform, valid --json,
  missing file.
- gemini_engine.detect_sparkle_confidence: float in range for a real
  image, None for an unreadable file.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:27:00 -07:00
test-userandClaude Opus 4.7 fa104bcade feat(identify): provenance command (platform + watermark inventory)
New 'identify' command and identify.py module: upload an image, get one
ProvenanceReport answering where it was made and what watermarks it
carries. Aggregates every locally-readable signal:

- C2PA Content Credentials -> generating platform (issuer + generator).
- IPTC digitalSourceType 'Made with AI' (Meta and others).
- Embedded SD/ComfyUI generation parameters (local pipelines).
- SynthID metadata proxy (Google / OpenAI C2PA companion).
- Visible Gemini sparkle (cv2 fallback for the stripped-metadata case),
  promoted only at confidence >= 0.5 (corpus-tuned: Gemini sparkles
  score >= 0.56, non-sparkle <= 0.49).

is_ai_generated is True or None, never asserted False -- stripped
metadata leaves no local proof of a clean origin, so absence of signals
is reported as 'unknown' with an explicit caveat. The SynthID *pixel*
watermark remains locally undecodable; the report says so.

Non-PNG containers (JPEG/WebP/AVIF/HEIF/JXL) get the same issuer +
generator attribution via a binary scan (the caBX parser is PNG-only).
The cv2 dependency is isolated in gemini_engine.detect_sparkle_confidence
so identify.py stays type-clean. CLI supports --json and --no-visible.

Validated against the 109-image corpus: 14/14 positives flagged AI,
93/94 negatives clean (the one 'neg' flagged is a Meta image that
genuinely carries the IPTC tag -- correct), zero true errors.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:19:26 -07:00
test-userandClaude Opus 4.7 f36320ff39 fix(metadata): guard get_ai_metadata PIL open against non-OSError
get_ai_metadata opened the file with PIL unguarded, so a HEIC (or any
format PIL can't open without optional plugins) raised
UnidentifiedImageError instead of falling through to the binary scan --
unlike has_ai_metadata, which already guards. Wrap the open in
except Exception and continue to the C2PA/IPTC path. Regression test
feeds an unopenable .heic shell and asserts no raise.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 16:19:15 -07:00
test-userandClaude Opus 4.7 af787fd8d6 docs(corpus): per-platform watermark map + surface-dependent blind spot
Grow the SynthID corpus to 109 originals (91 iPhone-photo negatives,
2 positives) and document what was learned studying 8 platforms:

- README: per-platform watermark map (C2PA issuer / SynthID pixel / IPTC
  / visible sparkle per platform) and an "originals, not previews" note
  (re-encoded previews strip metadata, so a clean preview is not proof).
- CLAUDE.md: surface-dependent blind spot -- the same Google model wraps
  C2PA in the Gemini app but emits the SynthID pixel watermark + sparkle
  with no C2PA/IPTC via the API/playground (AI Studio, Nano Banana), so
  synthid_source returns None despite SynthID being present; only the
  pixel oracle or the visible-sparkle detector catches those.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:55:17 -07:00
test-userandClaude Opus 4.7 be853011f3 feat(corpus): read HEIC dimensions via macOS sips
PIL cannot open iPhone HEIC without pillow-heif, so width/height stayed
0 for those negatives. Fall back to sips -g pixelWidth/pixelHeight on
macOS when PIL fails; returns (0,0) elsewhere.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:55:11 -07:00
test-userandClaude Opus 4.7 6cef1d59f0 fix(c2pa): drop non-printable claim_generator garbage
On some manifests (observed: Microsoft Designer) the first CBOR "name"
key precedes a binary hash field, not the generator string, so
_cbor_text_after returns control-char garbage. Guard with isprintable()
to drop it; issuer detection (byte-search) and the SynthID verdict are
unaffected. Adds TestParseChunkGuards covering kept-vs-dropped cases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 15:55:07 -07:00
test-userandClaude Opus 4.7 da0edcbddc chore(corpus): grow SynthID reference set + document autonomous Chrome collection
Adds content positives (OpenAI gpt-image: forest, fisherman, tokyo; Google
gemini: fisherman, mug) and SDXL/non-SynthID negatives to the local corpus
manifest. Now spans 4 resolutions across 2 vendors (was solid-black only).

README: documents driving generation via Chrome MCP -- Gemini single-click
download; ChatGPT via in-page fetch+blob (preserves original C2PA bytes,
unlike the flaky UI download / a canvas re-encode).

Images stay gitignored; only the manifest (sha256 + labels + extracted
metadata) and protocol are tracked.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 12:46:46 -07:00
test-userandClaude Opus 4.7 c006f9b8b4 docs(roadmap): record next steps for SynthID detector work
Captures the forward plan so a future session picks it up: local pixel
detector is blocked pending a generation API or raw watermarked dataset
(spectral methods shown insufficient); grow the oracle-labeled corpus;
replace synthetic non-PNG C2PA fixtures with real ones; and the maintenance
debt (idna bump, strict-pyright cleanup) needed for a green maintain.sh.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:34:43 -07:00
test-userandClaude Opus 4.7 f07ce10c72 feat(metadata): SynthID-source detection, C2PA parser consolidation, corpus + tests
Detect SynthID-bearing images via their C2PA companion: a manifest signed by a
SynthID-using vendor (Google/OpenAI) on AI-generated content implies an
invisible SynthID pixel watermark. Verified end-to-end against the vendor
oracles (openai.com/verify, Gemini "Verify with SynthID").

- metadata: synthid_source() + synthid_watermark verdict in get_ai_metadata,
  surfaced as a `metadata --check` callout. Format-agnostic (PNG caBX parser +
  JPEG/WebP/AVIF/HEIF/JXL binary scan).
- constants: SYNTHID_C2PA_ISSUERS {Google, OpenAI}; +opened/placed actions.
- c2pa: single CBOR-aware parser (_cbor_text_after) replaces glitchy regex
  (fixes fGPT-4o claim_generator); removed duplicate _scan_png_c2pa_chunk from
  metadata; shared synthid_verdict / synthid_vendors_in helpers.
- corpus: scripts/synthid_corpus.py ingest tool + data/synthid_corpus/
  (manifest tracked, images gitignored) for a labeled reference set.
- tests: +38 across C2PA parser internals, extract/inject round-trip, ISOBMFF
  container stripping, all IPTC AI markers, and invisible watermark strength
  tiers (SynthID/StableSignature/TreeRing/StegaStamp/RingID/RivaGAN/...).

Pixel-level SynthID detection remains out of reach locally (Google's decoder is
proprietary); a from-scratch spectral pilot confirmed it does not separate real
content. See CLAUDE.md for the full evaluation.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 11:32:46 -07:00
test-userandClaude Opus 4.7 c1ff4e1cd9 CLAUDE.md: document maintain.sh in Test and lint section
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-23 15:02:06 -07:00
test-userandClaude Opus 4.7 95606ddd5d docs: SynthID v2 defeat by SDXL pipeline now verified end-to-end locally
Local SDXL run on a Gemini 3 Pro output (snowboard scene, 2816x1536), seed 42,
strength 0.05, steps 50, ~10 min on MPS. Gemini app's "Verify with SynthID"
returned "no SynthID watermark detected" on the cleaned file. This closes the
verification gap noted in v0.4.0 release notes and confirms architectural
equivalence to the raiw-app production fal-ai/fast-sdxl path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 18:12:56 -07:00
test-userandClaude Opus 4.7 578e229713 style(cli): fix closing paren indentation in cmd_batch
Whitespace-only ruff format alignment, no functional change.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:58:35 -07:00
test-userandClaude Opus 4.7 0e04c4d388 chore(release): v0.4.1 — security fix (diffusers, urllib3)
- Bump diffusers minimum to 0.38.0 (closes GHSA-98h9-4798-4q5v).
- Refresh uv.lock to pull urllib3 2.7.0 (closes GHSA-qccp-gfcp-xxvc and
  GHSA-mf9v-mfxr-j63j via transitive update from requests / huggingface-hub).
- Allow pre-releases globally (`[tool.uv] prerelease = "allow"`) because
  diffusers 0.38.0 declares a dependency on safetensors>=0.8.0rc0. Drop
  once safetensors 0.8.0 stable is published or diffusers re-pins.

uv-secure --ignore-unfixed now reports zero vulnerabilities.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 13:14:50 -07:00
test-userandClaude Opus 4.7 1c1ebec148 chore(release): v0.4.0
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:57:13 -07:00
test-userandClaude Opus 4.7 f2fc5e09ab feat: SDXL default; AVIF/HEIF/JPEG-XL C2PA stripping
SD-1.5 dreamshaper at 768 px did not defeat SynthID v2 on Gemini 3 Pro
outputs (verified May 2026 via Gemini app's "Verify with SynthID"). Switch
the default invisible engine to SDXL at 1024 px, matching the raiw-app
production config (strength 0.05, steps 50). Drop the SD-1.5 pipeline.

Metadata layer: add C2PA UUID and IPTC AI marker byte-scan detection
across all formats, plus an ISOBMFF box walker (noai/isobmff.py) that
strips top-level C2PA uuid and JUMBF jumb boxes from AVIF/HEIF/JPEG-XL
containers without re-encoding.

README gets a Legal table and a Threat-model section about SynthID v2's
136-bit payload. CLAUDE.md tracks the SD-1.5 regression as historical
context.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-17 12:54:37 -07:00
dependabot[bot] 89b7633e5c chore(deps): bump the minor-and-patch group with 4 updates (#4)
Bumps the minor-and-patch group with 4 updates: [transformers](https://github.com/huggingface/transformers), [ultralytics](https://github.com/ultralytics/ultralytics), [ruff](https://github.com/astral-sh/ruff) and [pyright](https://github.com/RobertCraigie/pyright-python).


Updates `transformers` from 5.6.0 to 5.7.0
- [Release notes](https://github.com/huggingface/transformers/releases)
- [Commits](https://github.com/huggingface/transformers/compare/v5.6.0...v5.7.0)

Updates `ultralytics` from 8.4.41 to 8.4.43
- [Release notes](https://github.com/ultralytics/ultralytics/releases)
- [Commits](https://github.com/ultralytics/ultralytics/compare/v8.4.41...v8.4.43)

Updates `ruff` from 0.15.11 to 0.15.12
- [Release notes](https://github.com/astral-sh/ruff/releases)
- [Changelog](https://github.com/astral-sh/ruff/blob/main/CHANGELOG.md)
- [Commits](https://github.com/astral-sh/ruff/compare/0.15.11...0.15.12)

Updates `pyright` from 1.1.408 to 1.1.409
- [Release notes](https://github.com/RobertCraigie/pyright-python/releases)
- [Commits](https://github.com/RobertCraigie/pyright-python/compare/v1.1.408...v1.1.409)

---
updated-dependencies:
- dependency-name: transformers
  dependency-version: 5.7.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: minor-and-patch
- dependency-name: ultralytics
  dependency-version: 8.4.43
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: ruff
  dependency-version: 0.15.12
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
- dependency-name: pyright
  dependency-version: 1.1.409
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: minor-and-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-10 10:25:39 -07:00
dependabot[bot] bd2852af0e chore(deps): bump rich from 14.3.3 to 15.0.0 (#5)
Bumps [rich](https://github.com/Textualize/rich) from 14.3.3 to 15.0.0.
- [Release notes](https://github.com/Textualize/rich/releases)
- [Changelog](https://github.com/Textualize/rich/blob/master/CHANGELOG.md)
- [Commits](https://github.com/Textualize/rich/compare/v14.3.3...v15.0.0)

---
updated-dependencies:
- dependency-name: rich
  dependency-version: 15.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-10 10:25:36 -07:00
268 changed files with 59672 additions and 6454 deletions

No files matched your search

+139
View File
@@ -0,0 +1,139 @@
---
globs: ["src/**/*.py", "tests/**/*.py", "scripts/**/*.py", "pyproject.toml", "uv.lock", "maintain.sh", ".github/workflows/*.yml"]
description: Command contracts, project gate, typing boundaries, model-adjacent test invariants, and the detection-path measurement rule.
---
# Development invariants
## Command contracts
Every single-image command declares `source` with `dir_okay=False`; `batch` declares its directory with `file_okay=False`. Keep `tests/test_cli_robustness.py::TestDirectoryInputIsRejected` as the regression guard.
Exit-code and no-signal behavior is a public contract. Read the command-line section of [`../../docs/module-internals.md`](../../docs/module-internals.md) before changing it.
Do not add an option whose only outcome is an error. Model id, step count and CFG are fixed by the profile, so none of them is a parameter of the CLI, `InvisibleEngine`, or `WatermarkRemover` -- they were accepted-then-rejected for a while, which moved the failure several frames below the caller and advertised choices the pinned stack cannot honor. If a value cannot vary, delete the knob rather than validating it.
`device` is the deliberate exception and stays a library parameter: `None`/`"auto"` detect, `"cuda"` pins without detecting, and everything else raises at construction. It is not a CLI option, because the only value a user could usefully type is the one auto-detection already finds.
The same rule applies to install hints: name the extra that actually makes the command work (`qwen-zimage`, not `diffusion`), and keep the printed command shell-quoted -- bare `pkg[extra]` is a glob in zsh.
## Local gate
Run `bash maintain.sh` from the repository root. The authoritative type gate is scoped to `src/`; full-project Pyright can exhaust Node memory on the ML dependency graph.
Boundary modules for cv2, Torch, and Diffusers may carry narrow per-file relaxations for unknown third-party types. Keep pure-logic files strict, preserve the local piexif stub, and fix real errors before widening a pragma.
From a worktree, `uv run` imports the package from the MAIN checkout -- that is where the editable install points. A script measuring a worktree's edit must insert that worktree's `src` at `sys.path[0]` and assert `module.__file__` resolves inside it, or it silently compares unmodified code against itself.
## Model-adjacent tests
Do not classify an entire module as untestable because its main path downloads a model. Keep pure behavior covered without downloads, including:
- target-size selection in `test_invisible_engine.py`;
- unsharp and adaptive-polish helpers in `test_humanizer.py`;
- tiling geometry and blending in `test_tiling.py`;
- prompt-embedding cache keying, storage round-trip, and the cross-pipeline reuse
that lets a stack load without its text encoder, in `test_qwen_zimage_pipeline.py`;
- the face stack's dtype, in `test_qwen_zimage_pipeline.py`. A subclass that changes
the pipeline dtype for its own global model must not change the inherited face
stage's; `sdxl-zimage` shipped doing exactly that and crashed on every image with a
face. When one profile inherits another's stage, guard the invariants that stage
relies on, not just the code path.
- the `InvisibleOptions` defaults, in `test_api.py`. When one signature promises to
mirror another, compare them field by field rather than pinning the values you happen
to know about, so the next field added on one side and not the other fails at the
seam. Two of these defaults drifted in practice and neither needed a GPU to catch;
the incident is recorded in `docs/module-internals.md`. Keep the comparison free of
an exception table: a field that needs one is a field that belongs elsewhere, which
is what `force` turned out to be.
A defaults comparison is not a forwarding test, and the two fail differently. Pin the
VALUE at the seam, not just the name -- `_run_invisible` passed the whole suite with
`controlnet_conditioning_scale` hardcoded, because nothing asserted the caller's value
arrived. `test_every_field_arrives_at_the_engine_with_the_caller_s_value` drives the
real seam with every field set off its default, so one test covers the whole bag
instead of one assertion per knob.
Count the seams before believing a knob is covered. Each of `force` and
`controlnet_conditioning_scale` reaches the engine through TWO paths -- `remove_all`
versus `remove_batch(mode="all")` for the first, `_run_invisible` versus `_batch_engine`
for the second -- and in both cases guarding one path left the other free to hardcode a
constant with a green suite. The mode-parametrized guards in
`TestRemoveBatchLibrary::test_force_reaches_the_scrub_gate_in_every_scrubbing_mode` and
`TestBatchCommand::test_batch_controlnet_scale_flows_to_the_cached_engine` exist because
that is what actually happened.
Use availability checks only for paths that actually load large models.
## One measurement, one gate seam
A detector is split into a trust-level-blind scan and a verdict that applies the
threshold, so `detect` and `detect_both` reach the same numbers by construction. Two
rules follow, and both were broken in practice before they were written down:
- A per-mark demotion goes in the `_post_gate` hook (or, for a whole-scan precondition
like LibLibAI's size floor, in `_scan`) -- never in a `detect` override. An override
is invisible to `detect_both`, so the RunningHub and Yuanbao anchor gates silently
stopped applying on the arbiter's perception path. `TestSinglePassPerception` is the
guard: it asserts `detect_both` equals two `detect` calls field for field.
- Detection and the removal mask must read ONE sweep. The winning box travels on
`TextMarkDetection.match_box` and the registry threads the detection into the mask
builder; a mask path that re-runs its own sweep is how the two drift apart.
The C2PA manifest-store JSON is NOT stable across reads: the reader regenerates manifest
URNs and instance ids. Compare the derived `c2pa_info`, never the raw store.
A third seam reaches the same verdict: `collect_metadata_record` ->
`evidence_from_metadata_record` -> `identify_from_evidence`, the path a caller uses when
collection and verdict run on different machines. Its contract is equality with
`identify(path, check_visible=False, check_invisible=False)` on the same image, and it
can break from EITHER side -- a region the collector stops walking, or a placement the
file path learns to read and the record does not. `tests/test_metadata_record.py` pins
it over the tracked fixtures; a separate local evaluation corpus catches placements the
fixtures do not cover. Change either side and re-run both.
Before changing anything in the detection path, record the detectors' exact verdicts
over a local sample first and diff them after. A refactor here is only correct if that
record is byte-identical, and a green test suite does not establish that on its own. A
change that is meant to FIX detection is the exception that proves the rule: the diff
must then be exactly the files you intended to change, named in advance.
Two corollaries in `dwt_dct.py`, where "close enough" has an exact meaning. The bit test
is `peak % 36 > 18.0` and for uint8 input the exact Haar value is a multiple of 0.5, so it
lands ON the threshold once per 72 blocks and a 1-ulp difference flips real bits: leave the
transform to `pywt` however slow it looks, because its C convolution contracts into an FMA
that numpy has no ufunc for (`docs/module-internals.md` carries what that costs). And
`tests/test_invisible_watermark.py` is `skipif(not is_available())` -- without the `detect`
extra the upstream-parity test never runs, so a green suite is not evidence here and the
verdict record is not optional.
## A certified operating point is data, not a constant
The video SynthID default is only meaningful as a row in
`data/evaluations/video-synthid-oracle.csv`, so
`test_shipped_defaults_match_a_certified_manifest_row` derives the pin from that
manifest instead of restating literals. Pinning `noise_std` alone had let `long_side`
and `fps` -- two thirds of what the oracle was actually shown -- move with a green
suite.
The certified profile is a perturbation-to-signal ratio, not a bare `noise_std`, so
the latent scaling factor is gated in `load_video_vae_runtime`, carried on
`VideoVaeRuntime`, and passed into encode and decode: the validated value and the
applied value are one measurement. Anything that produces oracle evidence loads
through that function. `video_synthid_sweep.py` hand-rolled the load and was the one
path exempt from the gate it exists to feed, which is exactly backwards.
Prove a video-path refactor the same way the detection path is proven, and without
needing an oracle carrier: build a clip from a tracked fixture with ffmpeg, run the
engine before and after, and require an identical output sha256. Keep the generated
media outside the repository.
Frame sampling is compared as `timestamp + 1e-9 >= next_sample_time`, so mutating
that `>=` to `>` changes nothing and a green suite proves nothing. Mutate the phase
or the period instead -- starting the accumulator half a period late shifts the
selection by one frame while keeping the count identical, which is the drift a
frame-count check cannot see and what
`test_pairing_follows_the_engine_sampling_rule_not_just_the_frame_count` exists to
catch.
Environment setup, dependency recovery, CI behavior, and fixture policy: [`../../docs/development.md`](../../docs/development.md).
-14
View File
@@ -1,14 +0,0 @@
{
"permissions": {
"allow": [
"WebSearch",
"WebFetch"
]
},
"enabledPlugins": {
"pyright-lsp@claude-plugins-official": true,
"context7@claude-plugins-official": true,
"code-simplifier@claude-plugins-official": true,
"claude-md-management@claude-plugins-official": true
}
}
+2 -2
View File
@@ -1,3 +1,3 @@
# HuggingFace token (required for invisible watermark removal)
# HuggingFace token (optional; only needed for gated/private models)
# Get yours at: https://huggingface.co/settings/tokens
HF_TOKEN=
# HF_TOKEN=
+2
View File
@@ -0,0 +1,2 @@
# Enables the "Sponsor" button on the repository page.
github: wiltodelta
+15
View File
@@ -14,6 +14,21 @@ updates:
update-types:
- "minor"
- "patch"
# The `all` extra must remain jointly resolvable. These candidates violate
# upstream constraints: TrustMark still requires NumPy 1.x, OpenCV 4.12+
# requires NumPy 2 on newer Python versions, and this project keeps tokenizers
# below 0.23 for the stable Transformers path. Keep Dependabot on the
# compatible lines until those constraints move.
ignore:
- dependency-name: "numpy"
versions:
- ">=2"
- dependency-name: "opencv-python-headless"
versions:
- ">=4.12"
- dependency-name: "tokenizers"
versions:
- ">=0.23"
- package-ecosystem: "github-actions"
directory: "/"
+179
View File
@@ -0,0 +1,179 @@
name: Distribute on release
# Fans a published GitHub Release out to the channels that need a nudge.
# PyPI is handled by publish.yml. This workflow event-drives the three channels
# that would otherwise be manual:
# - Homebrew tap: rewrite the formula's url + sha256 to the new sdist.
# - HF Space: factory-rebuild so it reinstalls the latest sdist from PyPI.
# - ComfyUI: sync, test, and publish the node against the exact library release.
# All wait for the freshly published sdist to appear on PyPI first, since the
# Release event fires in parallel with publish.yml's upload.
on:
release:
types: [published]
workflow_dispatch:
inputs:
version:
description: "Version to distribute (no leading v), e.g. 0.10.3. Blank = latest on PyPI."
required: false
jobs:
resolve:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.v.outputs.version }}
url: ${{ steps.sdist.outputs.url }}
sha: ${{ steps.sdist.outputs.sha }}
steps:
- name: Resolve target version
id: v
run: |
set -euo pipefail
if [ "${{ github.event_name }}" = "release" ]; then
v="${GITHUB_REF_NAME#v}"
elif [ -n "${{ github.event.inputs.version }}" ]; then
v="${{ github.event.inputs.version }}"
else
v=$(curl -fsSL https://pypi.org/pypi/remove-ai-watermarks/json \
| python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])")
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "Target version: $v"
- name: Wait for the sdist on PyPI, capture url + sha256
id: sdist
run: |
set -euo pipefail
v="${{ steps.v.outputs.version }}"
for i in $(seq 1 30); do
if rel=$(curl -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$v/json" 2>/dev/null); then
url=$(echo "$rel" | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((u['url'] for u in d['urls'] if u['packagetype']=='sdist'),''))")
sha=$(echo "$rel" | python3 -c "import sys,json;d=json.load(sys.stdin);print(next((u['digests']['sha256'] for u in d['urls'] if u['packagetype']=='sdist'),''))")
if [ -n "$url" ] && [ -n "$sha" ]; then
echo "url=$url" >> "$GITHUB_OUTPUT"
echo "sha=$sha" >> "$GITHUB_OUTPUT"
echo "Found sdist for $v"
exit 0
fi
fi
echo "sdist for $v not on PyPI yet (attempt $i), waiting..."
sleep 20
done
echo "::error::sdist for $v did not appear on PyPI in time"
exit 1
homebrew:
needs: resolve
runs-on: ubuntu-latest
steps:
- name: Checkout the tap
uses: actions/checkout@v7
with:
repository: wiltodelta/homebrew-tap
token: ${{ secrets.HOMEBREW_TAP_TOKEN }}
- name: Bump the formula
env:
URL: ${{ needs.resolve.outputs.url }}
SHA: ${{ needs.resolve.outputs.sha }}
VERSION: ${{ needs.resolve.outputs.version }}
run: |
set -euo pipefail
F=Formula/remove-ai-watermarks.rb
sed -i -E "s|url \"[^\"]*\"|url \"$URL\"|" "$F"
sed -i -E "s|sha256 \"[^\"]*\"|sha256 \"$SHA\"|" "$F"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git add "$F"
git commit -m "remove-ai-watermarks $VERSION" || { echo "Formula already current"; exit 0; }
git push
hf-space:
needs: resolve
runs-on: ubuntu-latest
steps:
- name: Factory-rebuild the HuggingFace Space
env:
HF_TOKEN: ${{ secrets.HF_TOKEN }}
run: |
python3 -m pip install --quiet huggingface_hub
python3 - <<'PY'
import os
from huggingface_hub import HfApi
HfApi(token=os.environ["HF_TOKEN"]).restart_space(
"wiltodelta/remove-ai-watermarks", factory_reboot=True
)
print("HF Space factory rebuild triggered")
PY
comfyui:
needs: resolve
runs-on: ubuntu-latest
steps:
- name: Sync and publish the ComfyUI nodes
env:
GH_TOKEN: ${{ secrets.COMFYUI_RELEASE_TOKEN }}
run: |
set -euo pipefail
repo=wiltodelta/ComfyUI-remove-ai-watermarks
workflow=sync-library.yml
dispatched_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh workflow run "$workflow" \
--repo "$repo" \
--ref main \
--raw-field library_version="${{ needs.resolve.outputs.version }}"
run_id=""
for attempt in $(seq 1 30); do
run_id=$(
gh run list \
--repo "$repo" \
--workflow "$workflow" \
--event workflow_dispatch \
--limit 10 \
--json databaseId,createdAt \
| python3 -c '
import json
import sys
dispatched_at = sys.argv[1]
runs = json.load(sys.stdin)
print(next(
(run["databaseId"] for run in runs if run["createdAt"] >= dispatched_at),
"",
))
' "$dispatched_at"
)
if [ -n "$run_id" ]; then
break
fi
echo "ComfyUI sync run not visible yet (attempt $attempt), waiting..."
sleep 2
done
if [ -z "$run_id" ]; then
echo "::error::The dispatched ComfyUI sync run did not appear"
exit 1
fi
for attempt in $(seq 1 180); do
read -r status conclusion <<< "$(
gh run view "$run_id" \
--repo "$repo" \
--json status,conclusion \
--jq '[.status, (.conclusion // "")] | @tsv'
)"
if [ "$status" = "completed" ]; then
if [ "$conclusion" = "success" ]; then
echo "ComfyUI sync run $run_id succeeded"
exit 0
fi
echo "::error::ComfyUI sync run $run_id completed with $conclusion"
exit 1
fi
echo "ComfyUI sync run $run_id is $status (attempt $attempt), waiting..."
sleep 5
done
echo "::error::ComfyUI sync run $run_id did not finish in time"
exit 1
+19 -2
View File
@@ -13,12 +13,29 @@ jobs:
runs-on: ubuntu-latest
environment: pypi
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- name: Verify release tag matches package version
run: |
tag="${{ github.event.release.tag_name }}"
version="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/^version = "([^"]+)"/\1/')"
if [ "${tag#v}" != "$version" ]; then
echo "Release tag '$tag' does not match pyproject.toml version '$version'" >&2
exit 1
fi
echo "Release tag '$tag' matches package version '$version'"
- name: Build package
run: uv build
# Publish with `uv publish` (its own uploader, not the twine bundled in the
# old pypa action). Trusted publishing is automatic in GitHub Actions: the
# `id-token: write` permission + the `pypi` environment supply the OIDC token,
# and PyPI's trusted-publisher entry matches on repo + workflow filename +
# environment name (all unchanged from the pypa-action setup), so no API token
# is needed. uv's uploader also accepts Metadata-Version 2.5 -- the permanent
# fix that lets the hatchling pin be relaxed (see pyproject [build-system]).
- name: Publish to PyPI
uses: pypa/gh-action-pypi-publish@release/v1
run: uv publish
+99
View File
@@ -0,0 +1,99 @@
name: Tests
on:
push:
branches: [main]
pull_request:
concurrency:
group: tests-${{ github.ref }}
cancel-in-progress: true
jobs:
lint:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
- name: Sync dev environment
run: uv sync --frozen --extra dev
- name: Ruff lint
run: uv run ruff check
- name: Ruff format check
run: uv run ruff format --check
test:
name: test ${{ matrix.os }} py${{ matrix.python-version }}
runs-on: ${{ matrix.os }}
strategy:
fail-fast: false
matrix:
# Exercise every supported minor. The floor and latest minor run on all
# operating systems; intermediate minors run on Linux to bound the
# matrix without leaving a declared Python version untested.
include:
- os: ubuntu-latest
python-version: "3.11"
- os: macos-latest
python-version: "3.11"
- os: windows-latest
python-version: "3.11"
- os: ubuntu-latest
python-version: "3.12"
- os: ubuntu-latest
python-version: "3.13"
- os: ubuntu-latest
python-version: "3.14"
- os: macos-latest
python-version: "3.14"
- os: windows-latest
python-version: "3.14"
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with:
python-version: ${{ matrix.python-version }}
- name: Sync dev environment
run: uv sync --frozen --extra dev
- name: Run tests
run: uv run pytest -q
transport-contract-python-314:
name: versioned transport contracts py3.14
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with:
python-version: "3.14"
- name: Create clean runtime contract environment
run: uv venv --python 3.14 .venv-contract
- name: Install runtime contract dependencies
run: >-
uv pip install --python .venv-contract/bin/python
".[pixels,heif]" "numpy>=2,<3" "pytest>=8"
- name: Run versioned transport contract tests
run: >-
.venv-contract/bin/python -m pytest -q
tests/test_forensic_metadata.py
tests/test_metadata_record.py
tests/test_pixel_evidence.py
video-e2e:
name: video full-clip end-to-end
runs-on: ubuntu-latest
timeout-minutes: 5
steps:
- uses: actions/checkout@v7
- uses: astral-sh/setup-uv@v7
with:
python-version: "3.12"
- name: Install ffmpeg
run: sudo apt-get update && sudo apt-get install --yes ffmpeg
- name: Sync dev environment
run: uv sync --frozen --extra dev
- name: Run full-clip video test
run: >-
uv run pytest -vv -o faulthandler_timeout=60
tests/test_video.py::TestVideoVisibleFullClip
tests/test_video_fidelity_probe.py
+30 -2
View File
@@ -4,6 +4,8 @@ __pycache__/
*.egg-info/
dist/
build/
tmp/
.sc/
# Environment secrets
.env
@@ -18,8 +20,8 @@ Thumbs.db
*.swp
*.swo
# Test results
data/results/
# Generated calibration inputs
data/synthid_corpus/refs/
# Reference materials
_refs/
@@ -30,3 +32,29 @@ yolov8n.pt
# Claude Code local settings
.claude/settings.local.json
.claude/scheduled_tasks.lock
# Generated calibration inputs and local evaluation artifacts
data/doubao_capture/seeds/
data/jimeng_capture/seeds/
data/jimeng_capture/captures/jimeng_content_*.png
data/gemini_capture/seeds/
data/gemini_capture/captures/gemini_content_*.png
data/samsung_capture/seeds/
data/samsung_capture/captures/samsung_content_*
# Runtime model artifacts
gfpgan/
# Local evaluation data
.local-eval/
# Other harnesses' generated config. Claude's sync owns these; a committed copy
# is a second source nobody reconciles. Anchored so a same-named source file
# deeper in the tree is not caught.
/.agents/
/.codex/
/sch_*.yaml
/.opencode/
/opencode.json
/opencode.jsonc
+1
View File
@@ -0,0 +1 @@
3.12
+43 -14
View File
@@ -1,25 +1,54 @@
# Remove-AI-Watermarks
# Remove AI Watermarks
You are a **principal Python engineer** maintaining a CLI tool and library for removing visible and invisible AI watermarks from images.
You are a **principal Python engineer** maintaining a CLI tool and library for removing visible and invisible AI provenance watermarks.
## Scope and non-goals
The project gives users control over provenance marks on content they generated or edited themselves. It does not automatically remove stock-agency, marketplace, classifieds, tiled-preview, or other marks that protect a third party's paid or copyrighted asset.
- Add visible templates only for AI-generation labels.
- Do not add stock, agency, or classifieds marks to `watermark_registry.py`.
- Keep `erase --region` generic and user-directed; do not build an automatic stock-watermark remover on it.
Full boundary and legal context: [`docs/legal-and-safety.md`](docs/legal-and-safety.md).
## How to run
- `uv run remove-ai-watermarks all <image.png> -o <output.png>`
- `uv run remove-ai-watermarks metadata <image.png> --check` — inspect AI metadata (C2PA, EXIF, PNG chunks)
- `uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>` — strip all AI metadata
```bash
uv run remove-ai-watermarks --help
bash maintain.sh
```
Run `uv` from the repository root. Command selection, options, defaults, and examples live in [`docs/cli.md`](docs/cli.md). Before changing command routing, no-signal behavior, or exit codes, read the command-line section of [`docs/module-internals.md`](docs/module-internals.md).
## Configuration
- GPU/ML modules (invisible_engine, ctrlregen, watermark_remover) are optional — guard imports with `is_available()` checks
- Tests for ML modules are limited to availability checks (require multi-GB downloads)
GPU and ML modules are optional. Guard their imports with `is_available()`.
## Key modules
Optional features and installation groups are documented in [`docs/installation.md`](docs/installation.md). Model-running paths may use availability tests, while pure helpers in ML-adjacent modules must remain unit-tested without downloads.
- `noai/c2pa.py` — PNG chunk parser; use `extract_c2pa_chunk(path)` to get raw caBX payload, `has_c2pa_metadata(path)` to detect. Do not reimplement chunk parsing.
- `noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES constants
- `face_protector.py` — YOLO detect + soft-blend pattern; mirror this for any "protect region during diffusion" features
## Test and lint
## Known limitations
`maintain.sh` runs dependency freshness and security checks, Ruff, Pyright scoped to `src/`, and the parallel test suite. Full-project Pyright is not the project gate because the ML dependency graph can exhaust Node memory.
- `invisible` pipeline downscales to 768 px before diffusion → degrades fine text in infographics. Tracked; fix is tile-based or skip-downscale approach.
- Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs)
Command, gate, typing, and model-test invariants auto-load from [`.claude/rules/development.md`](.claude/rules/development.md). Environment recovery, CI behavior, and fixture policy live in [`docs/development.md`](docs/development.md).
Before a release, read [`docs/release-and-distribution.md`](docs/release-and-distribution.md). Treat the release as complete only after PyPI, Homebrew, the Hugging Face Space, and the ComfyUI Registry are verified; conda is not published. Keep the source-distribution public allowlist.
## Module architecture
[`docs/module-internals.md`](docs/module-internals.md) is the canonical per-module map, including design decisions, thresholds, calibration history, incident records, and regression guards. Read the relevant section before changing a subsystem.
Research and current constraints are routed through [`docs/index.md`](docs/index.md), especially [`docs/known-limitations.md`](docs/known-limitations.md), [`docs/supported-signals.md`](docs/supported-signals.md), [`docs/synthid.md`](docs/synthid.md), and [`docs/watermarking-landscape.md`](docs/watermarking-landscape.md).
## Data safety
Follow [`data/README.md`](data/README.md) for public fixture, calibration, oracle, and evaluation layout. Store each tracked binary once and keep generated evaluation outputs outside the repository.
## Rules and conventions
Topic-specific rules live in `.claude/rules/*.md` and are auto-loaded when matching files are touched.
| File | Covers |
|---|---|
| `development.md` | Command contracts, project gate, typing boundaries, model-adjacent tests, and the detection-path measurement rule |
+213 -17
View File
@@ -1,21 +1,217 @@
MIT License
Copyright (c) 2025 wiltodelta
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
1. Definitions.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
Copyright 2025-2026 wiltodelta
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+423 -220
View File
@@ -1,266 +1,469 @@
# Remove-AI-Watermarks
# Remove AI Watermarks
Remove **visible** and **invisible** AI watermarks from images generated by Google Gemini (Nano Banana), ChatGPT / DALL-E, Stable Diffusion, Adobe Firefly, Midjourney, and other AI models.
Remove AI provenance marks from images and video you generated yourself:
Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and visible sparkle overlays — all in one command.
- known visible labels such as the Gemini sparkle and vendor text marks;
- invisible pixel watermarks through diffusion regeneration;
- C2PA, EXIF, XMP, IPTC, and related AI metadata.
## Features
Video support covers provenance identification, complete visible-plus-metadata
cleaning, directory batches, visible Sora, Veo, Seedance, Dola, Hailuo, and
Kling mark removal, and oracle-certified VAE regeneration for video SynthID
removal.
- **Visible watermark removal** — Gemini / Nano Banana sparkle logo via reverse alpha blending (fast, offline, deterministic)
- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests, XMP DigitalSourceType
- **"Made with AI" label removal** — removes the metadata that triggers AI labels on Instagram, Facebook, X (Twitter)
- **Analog Humanizer** — film grain and chromatic aberration to bypass AI image classifiers
- **Smart Face Protection** — automatic extraction and blending of human faces to prevent AI distortion
- **Batch processing** — process entire directories
- **Detection** — three-stage NCC watermark detection with confidence scoring
> [raiw.cc](https://raiw.cc) runs this library as a hosted service, with the GPU
> included and nothing to install. Visible mark and metadata removal are free
> there; invisible watermark removal is paid.
> **Try it online** — don't want to install anything? Use [raiw.cc](https://raiw.cc), a free web service powered by this library.
[![PyPI](https://img.shields.io/pypi/v/remove-ai-watermarks?logo=pypi&logoColor=white)](https://pypi.org/project/remove-ai-watermarks/)
[![Python](https://img.shields.io/pypi/pyversions/remove-ai-watermarks?logo=python&logoColor=white)](https://pypi.org/project/remove-ai-watermarks/)
[![Downloads](https://static.pepy.tech/badge/remove-ai-watermarks/month)](https://pepy.tech/project/remove-ai-watermarks)
[![License](https://img.shields.io/pypi/l/remove-ai-watermarks?color=blue)](LICENSE)
[![Tests](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml/badge.svg)](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml)
[![Sponsor](https://img.shields.io/badge/Sponsor-GitHub-db61a2?logo=githubsponsors&logoColor=white)](https://github.com/sponsors/wiltodelta)
> This project is for lawful use on content you own. It does not target stock
> agency previews or other watermarks that protect third party paid content.
> See [scope, safety, and legal notes](docs/legal-and-safety.md).
## Choose what you want to do
| Goal | Command | GPU |
| --- | --- | --- |
| Find provenance signals and watermarks | `identify` | No |
| Remove known visible AI marks | `visible` | No |
| Erase a region you select | `erase` | No |
| Strip AI metadata | `metadata` | No |
| Identify supported video provenance | `video identify` | No |
| Remove visible marks and AI metadata from video | `video all` | No |
| Strip AI metadata from video | `video metadata` | No |
| Remove a registered visible AI mark from video | `video visible` | No |
| Process a directory of videos | `video batch` | Depends on mode |
| Remove video SynthID with the certified VAE profile | `video invisible` | Recommended |
| Regenerate an image to disrupt invisible watermarks | `invisible` | Required (CUDA) |
| Run visible, invisible, and metadata removal | `all` | Recommended |
| Process a directory | `batch` | Depends on mode |
## Installation modes
| Need | Install |
| --- | --- |
| Metadata inspection and stripping | `remove-ai-watermarks` |
| Visible detection and removal | `remove-ai-watermarks[visible]` |
| Visible video processing | `remove-ai-watermarks[video]` |
| Video SynthID removal | `remove-ai-watermarks[video,diffusion]` |
| Torch-free DWT-DCT detection | `remove-ai-watermarks[detect]` |
| Invisible image removal (needs CUDA) | `remove-ai-watermarks[qwen-zimage]` |
| Every production feature available on the active Python | `remove-ai-watermarks[all]` |
Lower-level and specialized extras include `pixels`, `heif`, `trustmark`,
`migan`, `lama`, and `diffusion`. The
[installation guide](docs/installation.md#feature-extras) documents their exact
dependency composition, Python compatibility, and model requirements.
## Quick start
Install the metadata-focused default CLI:
```bash
uv tool install remove-ai-watermarks
```
Inspect an image:
```bash
remove-ai-watermarks identify image.png
```
For visible watermark removal, install the pixel dependencies:
```bash
uv tool install --force "remove-ai-watermarks[visible]"
```
Then remove a known visible mark and AI metadata:
```bash
remove-ai-watermarks visible image.png -o clean.png
```
Strip metadata without running visible inpainting or diffusion:
```bash
remove-ai-watermarks metadata image.png --remove -o clean.png
```
Without `-o` this command overwrites the source in place.
Inspect or remove AI metadata from an MP4, MOV, M4V, WebM, MKV, AVI, or FLV
file:
```bash
remove-ai-watermarks video metadata input.mp4 --check
remove-ai-watermarks video metadata input.mp4 --remove -o clean.mp4
```
The `video metadata` command does not transcode video or audio streams. Unlike
the image command above, when `-o` is omitted it writes `<source>_clean` and
preserves the original. MP4 and MOV
inspection includes the native TC260 `AIGC` tag in
`moov.udta.meta.keys/ilst`, including a `moov` placed after the media payload,
plus the QuickTime-form `meta` variants Doubao's iOS export writes (a bare
`meta` box as a direct `moov` child, and a keyless `hdlr=mdir` metadata list).
MKV and WebM inspection reads the normative
`Segment.Tags.Tag.SimpleTag` placement. AVI uses `LIST/INFO/AIGC`, while FLV
uses `script.onMetaData.AIGC`. The non-ISOBMFF formats are remuxed with stream
copy for removal.
Use the product-oriented video path to identify or clean a file:
```bash
uv tool install --force "remove-ai-watermarks[video]"
remove-ai-watermarks video identify input.mp4
remove-ai-watermarks video all input.mp4 -o clean.mp4
```
`video all` removes a stable registered visible mark when present and always
strips verified AI metadata. If neither signal is found, it still writes a
same-container passthrough, so application callers get one predictable output
contract. Proprietary invisible-video removal is excluded by default.
`--invisible` opts into the lossy, oracle-certified video SynthID profile.
Process a directory with the same contract:
```bash
remove-ai-watermarks video batch ./videos --mode all
```
Remove a supported visible video mark:
```bash
remove-ai-watermarks video visible input.mp4 -o clean.mp4
remove-ai-watermarks video visible veo.mp4 --mark veo -o veo_clean.mp4
remove-ai-watermarks video visible seedance.mp4 --mark seedance -o seedance_clean.mp4
remove-ai-watermarks video visible dola.mp4 --mark dola -o dola_clean.mp4
remove-ai-watermarks video visible hailuo.mp4 --mark hailuo -o hailuo_clean.mp4
remove-ai-watermarks video visible kling.mp4 --mark kling -o kling_clean.mp4
```
This path scans the complete sequence before changing pixels. It accepts only a
mark that repeats at a stable position across adjacent frames, then reuses the
same OpenCV, MI-GAN, or LaMa fill backends as image removal. Audio is copied
without re-encoding and is allowed to reach its natural end; the video stream
is transcoded because its pixels change. By default, a guarded optical-flow
pass motion-aligns the preceding accepted fill and blends it only when the
nearby source context agrees; use `--no-temporal-consistency` to disable it.
The encoder preserves supported
8-bit source chroma sampling, color tags, and MP4/MOV track timescale instead
of relying on ffmpeg's implicit raw-BGR defaults. Variable frame intervals are
preserved through a timestamped in-memory NUT bridge instead of being flattened
to the average frame rate. Non-zero source start timestamps are retained
together with the copied audio offset. The default `--mark auto`
scans all providers in one decode pass and selects the first stable match in
the specificity order shown below. Pass an explicit mark to restrict detection
to one provider.
Sora covers the moving Sora 2 mascot and wordmark. Veo covers both the current
four-point diamond and the legacy `Veo` text. Seedance covers the fixed boxed
`AI` label, Dola covers the fixed `Dola AI` text, Hailuo covers the composite
`MINIMAX | hailuo AI` label, and Kling covers the bottom-right `KLING AI`
label with its version suffix. A completed encode is published atomically. No
output is written when no stable mark is found.
HDR, PQ/HLG, and greater-than-8-bit inputs are rejected before encoding rather
than silently reduced through OpenCV's 8-bit BGR boundary.
Remove video SynthID:
```bash
uv tool install --force "remove-ai-watermarks[video,diffusion]"
remove-ai-watermarks video invisible input.mp4 -o clean.mp4
```
This path regenerates the complete sequence with one latent-noise field shared
across time, copies complete audio, strips source metadata, and publishes the
completed encode atomically. The default `noise_std=0.15` profile passed both
the two-carrier calibration and a complete public eight-second Veo oracle
check. Google does not publish a local decoder, so a fresh provider check
remains useful for unusually important files or after provider changes, but it
is not a product result state.
For invisible watermark removal, install the `qwen-zimage` extra. **An NVIDIA GPU
is required**: both profiles are CUDA-only, and there is no CPU or MPS fallback.
```bash
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
remove-ai-watermarks invisible image.png -o clean.png
```
If the local detectors cannot confirm an invisible watermark but you know the
image came from an AI generator, add `--force`:
```bash
remove-ai-watermarks invisible image.png -o clean.png --force
```
Typography-heavy images can opt into the experimental verified-text post-pass.
It accepts manually reviewed strings and line boxes or an operator-verified
geometry-only manifest; it never treats raw OCR output as ground truth or runs
automatically:
```bash
uv tool install --force "remove-ai-watermarks[text-restoration]"
remove-ai-watermarks invisible image.png -o clean.png \
--text-manifest verified-lines.json --force
```
See the [CLI guide](docs/cli.md#restore-operator-verified-text) for the manifest
schema, compatibility restrictions, and oracle caveats.
See the [installation guide](docs/installation.md) for Homebrew, uv, optional
features, and development setup.
## Examples
| Before (Watermarked) | After (Cleaned) |
### Visible Gemini mark
| Before | After |
| --- | --- |
| ![Before](demo_banana_before.png) | ![After](demo_banana_after.png) |
| ![Image with a visible Gemini watermark](demo_banana_before.png) | ![Image after visible watermark removal](demo_banana_after.png) |
## Supported models
### High quality invisible removal
| AI model | Visible watermark | Invisible watermark | Metadata | Our approach |
| --- | --- | --- | --- | --- |
| **Google Gemini / Nano Banana** | ✅ Sparkle logo | ✅ SynthID | ✅ C2PA + EXIF | Alpha reversal + diffusion + metadata strip |
| **OpenAI DALL-E 3 / ChatGPT** | — | — | ✅ C2PA manifest | Metadata strip |
| **OpenAI ChatGPT Images 2.0** (gpt-image-2) | — | ⚠️ imperceptible pixel watermark (no public detector yet) | ✅ C2PA manifest (verified) | Diffusion regeneration + metadata strip |
| **Stable Diffusion (AUTOMATIC1111, ComfyUI)** | — | ✅ DWT / steganographic | ✅ PNG text chunks | Diffusion regeneration + metadata strip |
| **Adobe Firefly** | — | — | ✅ Content Credentials (C2PA) | Metadata strip |
| **Midjourney** | — | — | ✅ EXIF + XMP (prompt, model, seed) | Metadata strip |
| **StableSignature** (Meta) | — | ✅ In-model watermark | — | Diffusion regeneration |
| **TreeRing** | — | ✅ Latent space watermark | — | Diffusion regeneration |
`qwen-zimage` is the default profile: a Qwen-Image-2512 Lightning pass under Canny
ControlNet, followed by SAM-masked Z-Image repair of any detected face. The
alternative, `sdxl-zimage`, swaps the global stage for SDXL and keeps the same face
stage. Both are CUDA only.
> Visible watermarks (logo overlays) are currently used only by Google Gemini / Nano Banana. Other services rely on invisible watermarks and/or metadata. Our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain.
```bash
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
remove-ai-watermarks invisible image.png -o clean.png --force
```
| OpenAI example before | OpenAI example after |
| --- | --- |
| [![OpenAI portrait grid before qwen-zimage](data/synthid/originals/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM.png)](data/synthid/originals/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM.png) | [![OpenAI portrait grid after qwen-zimage](docs/images/qwen-zimage/ChatGPT/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM_full_clean.png)](docs/images/qwen-zimage/ChatGPT/ChatGPT%20Image%20May%2030,%202026,%2010_31_08%20AM_full_clean.png) |
| Gemini example before | Gemini example after |
| --- | --- |
| [![Gemini sign before qwen-zimage](data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png)](data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png) | [![Gemini sign after qwen-zimage](docs/images/qwen-zimage/Gemini/Gemini_Generated_Image_633uuy633uuy633u_full_clean.png)](docs/images/qwen-zimage/Gemini/Gemini_Generated_Image_633uuy633uuy633u_full_clean.png) |
These exact output files were checked with the matching provider verifiers. That
result applies to these files, not to every seed, image, or future watermark
version.
## Common recipes
### Remove every detected visible mark
```bash
remove-ai-watermarks visible image.png -o clean.png
```
The default `--mark auto` checks all registered visible marks and removes every
match. If the mark is visible to you but the detector misses it, select its
region explicitly:
```bash
remove-ai-watermarks erase image.png \
--region 1640,1930,400,100 \
-o clean.png
```
`--region` uses `x,y,width,height` and may be repeated.
### Use a learned fill backend
The `visible` extra uses OpenCV inpainting when no learned backend is installed.
For more difficult backgrounds, the learned-backend extras include the same
pixel dependencies automatically:
```bash
uv tool install --force "remove-ai-watermarks[migan]"
remove-ai-watermarks visible image.png -o clean.png --backend migan
```
```bash
uv tool install --force "remove-ai-watermarks[lama]"
remove-ai-watermarks visible image.png -o clean.png --backend lama
```
### Reduce CUDA memory use
```bash
remove-ai-watermarks invisible image.png -o clean.png \
--cpu-offload --force
```
CPU offload lowers CUDA memory pressure by moving model components between CPU
and GPU, at the cost of speed.
### Process a directory
```bash
remove-ai-watermarks batch ./images --mode visible
remove-ai-watermarks batch ./images --mode all
```
## What the tool can recognize
Visible mark support includes:
- Google Gemini and Nano Banana sparkle;
- Doubao, Jimeng, Qwen, Kling, Yuanbao, Baidu, LibLibAI, and RunningHub labels;
- one calibrated Samsung Galaxy AI label variant.
Metadata and provenance inspection covers C2PA, EXIF, XMP, IPTC, common
generator parameters, China TC260 AIGC labels, and several vendor specific
signals. Optional decoders add support for open DWT-DCT watermarks and Adobe
TrustMark.
The exact support matrix, including important locale and detector limits, lives
in [supported signals](docs/supported-signals.md).
## How it works
### Removing the Gemini / Nano Banana sparkle watermark
Visible removal follows three steps:
Google Gemini (internally codenamed **Nano Banana**) adds a visible sparkle logo to generated images using alpha blending:
1. Detect a registered mark in its expected area.
2. Build a mask around the mark.
3. Fill only the masked region with OpenCV, MI-GAN, or LaMa.
```text
watermarked = α × logo + (1 − α) × original
```
Metadata removal uses format aware stripping. JPEG metadata removal preserves
the encoded image scan instead of recompressing it. Native MP4/MOV TC260 values
are blanked without changing box sizes or media offsets. Other supported
containers use their corresponding metadata path.
We reverse this with a known alpha map (extracted from Gemini / Nano Banana output on a pure-black background):
Invisible removal is different. It regenerates the image through a diffusion
pipeline to disrupt pixel and frequency domain watermarks. This changes the
image and cannot guarantee that a proprietary verifier will reject every
output.
```text
original = (watermarked − α × logo) / (1 − α)
```
See [supported signals](docs/supported-signals.md) and
[known limitations](docs/known-limitations.md) for the full technical boundary.
A three-stage NCC (Normalized Cross-Correlation) detector finds the watermark position and scale dynamically, so it works even if the image was resized or cropped. After removal, residual sparkle-edge artifacts are cleaned via gradient-masked inpainting.
## Python API
**Speed**: ~0.05s per image. No GPU needed.
### Removing SynthID and other invisible watermarks
Google embeds **SynthID** into every image generated by Gemini / Nano Banana. Other AI services use StableSignature, TreeRing, and similar schemes. These imperceptible frequency-domain patterns survive cropping, resizing, and JPEG compression.
The removal pipeline:
```text
image → downscale to 768px → encode to latent space (VAE)
→ add controlled noise (forward diffusion)
→ denoise (reverse diffusion, ~2 steps at strength 0.02)
→ decode back to pixels (VAE) → upscale to original resolution
```
The key insight: even minimal noise injection (strength 0.02 = 2% perturbation) breaks the watermark signal while preserving visual quality. The diffusion model acts as a learned image prior — it reconstructs the image faithfully while destroying the watermark pattern.
**Face Protection**: before diffusion, YOLO detects people in the image and extracts them. After diffusion, the original faces are blended back with a soft elliptical mask to prevent AI distortion of facial features.
**Analog Humanizer**: optional film grain and chromatic aberration injection that makes the output indistinguishable from a photo of a screen, defeating AI-generated image classifiers.
### Stripping C2PA, EXIF, and "Made with AI" metadata
AI tools embed generation metadata that social platforms use to show "Made with AI" labels:
- **EXIF tags** — prompt, seed, model hash, sampler settings (Stable Diffusion, Midjourney)
- **XMP DigitalSourceType** — `trainedAlgorithmicMedia` tag used by Instagram, Facebook, and X (Twitter) to show "Made with AI"
- **PNG text chunks** — ComfyUI workflows, AUTOMATIC1111 parameters
- **C2PA Content Credentials** — cryptographic provenance manifests from Google Imagen, OpenAI DALL-E, Adobe Firefly
The cleaner parses each layer, removes AI-related fields, and preserves standard metadata (Author, Copyright, Title).
## Installation
### Recommended
Install as an isolated CLI tool — no need to manage virtual environments:
```bash
# Using pipx (https://pipx.pypa.io)
pipx install git+https://github.com/wiltodelta/remove-ai-watermarks.git
# Or using uv (https://docs.astral.sh/uv)
uv tool install git+https://github.com/wiltodelta/remove-ai-watermarks.git
```
To update to the latest version:
```bash
pipx upgrade remove-ai-watermarks
# or
uv tool upgrade remove-ai-watermarks
```
### Install from repository
**Prerequisites:** Python 3.10+ and `pip` (or [`uv`](https://docs.astral.sh/uv/)).
```bash
# 1. Clone the repository
git clone https://github.com/wiltodelta/remove-ai-watermarks.git
cd remove-ai-watermarks
# 2. Install the package in editable mode
pip install -e .
# Or, if you use uv:
uv pip install -e .
```
After installation the `remove-ai-watermarks` command is available system-wide.
> **Note**: The base install covers visible watermark removal and metadata stripping.
> For invisible watermark removal (SynthID etc.), install GPU dependencies:
>
> ```bash
> pip install -e ".[gpu]" # or: uv pip install -e ".[gpu]"
> ```
#### Invisible watermark removal
Invisible removal uses diffusion models and a GPU for reasonable speed.
```bash
# On first run, the model (~2 GB) will be downloaded automatically.
# Device is auto-detected: CUDA (Linux/Windows) > MPS (macOS) > CPU.
# To force a device: --device cuda / --device mps / --device cpu
# Optional: set a HuggingFace token for gated/private models
cp .env.example .env
# Edit .env and set HF_TOKEN=hf_your_token_here
```
#### Developer setup
```bash
# Install with dev dependencies (pytest, ruff, pyright)
pip install -e ".[dev]"
# Or with uv:
uv pip install -e ".[dev]"
# Run tests
pytest
# Run linters
./maintain.sh
```
## Usage
### CLI
```bash
# Remove all watermarks from a single image (visible + invisible + metadata)
remove-ai-watermarks all image.png -o clean.png
# Process an entire directory
remove-ai-watermarks batch ./images/ --mode all
```
#### Individual commands
```bash
# Visible watermark only (Gemini / Nano Banana sparkle) — fast, offline
remove-ai-watermarks visible image.png -o clean.png
# Invisible watermark only (SynthID etc.) — requires GPU
remove-ai-watermarks invisible image.png -o clean.png --humanize 4.0
# Check / strip AI metadata (C2PA, EXIF, "Made with AI" labels)
remove-ai-watermarks metadata image.png --check
remove-ai-watermarks metadata image.png --remove
# Batch with a specific mode
remove-ai-watermarks batch ./images/ --mode visible
```
### Python API
The visible-removal API requires `remove-ai-watermarks[visible]`.
```python
from remove_ai_watermarks.gemini_engine import GeminiEngine
import cv2
import remove_ai_watermarks as raiw
engine = GeminiEngine()
image = cv2.imread("watermarked.png")
result, removed = raiw.remove_visible("watermarked.png", "clean.png")
print(removed)
# Detect
result = engine.detect_watermark(image)
print(f"Detected: {result.detected} (confidence: {result.confidence:.1%})")
# Remove
clean = engine.remove_watermark(image)
cv2.imwrite("clean.png", clean)
provenance = raiw.identify_video("input.mp4")
report = raiw.inspect_video_metadata("input.mp4")
complete = raiw.remove_video_all("input.mp4", "clean.mp4")
batch = raiw.remove_video_batch("videos", "videos_clean")
cleaned = raiw.remove_video_metadata("input.mp4")
synthid_cleaned = raiw.remove_video_invisible("input.mp4", "synthid_clean.mp4")
visible = raiw.remove_video_visible("input.mp4", "clean.mp4")
print(visible.mark)
veo = raiw.remove_video_visible("veo.mp4", "veo_clean.mp4", mark="veo")
seedance = raiw.remove_video_visible(
"seedance.mp4",
"seedance_clean.mp4",
mark="seedance",
)
dola = raiw.remove_video_visible("dola.mp4", "dola_clean.mp4", mark="dola")
```
### Metadata stripping
The high level API accepts a file path or a BGR NumPy array. For path inputs it
also reads provenance metadata, preserves alpha, and can strip AI metadata from
the written result.
```python
from remove_ai_watermarks.metadata import has_ai_metadata, remove_ai_metadata
from pathlib import Path
See the [Python API guide](docs/python-api.md) for visible removal, the full
`remove_all` and `remove_batch` pipeline, provenance inspection, metadata
stripping, and diffusion usage.
if has_ai_metadata(Path("image.png")):
remove_ai_metadata(Path("image.png"), Path("clean.png"))
```
## ComfyUI
## Requirements
The separate
[ComfyUI Remove AI Watermarks](https://github.com/wiltodelta/ComfyUI-remove-ai-watermarks)
package provides nodes for visible removal, detection, region erasing, and
invisible removal.
- Python ≥ 3.10
- **Visible removal / metadata**: CPU only, no GPU required
- **Invisible removal**: GPU recommended (CUDA or MPS), works on CPU (slow)
## Important limitations
## Troubleshooting
- A missing local signal means unknown, not clean. Proprietary pixel
watermarks may remain after metadata has been stripped.
- Visible removal reconstructs a small region. Results depend on the background
and selected fill backend.
- Invisible removal changes the whole image and may alter faces, text, or fine
detail.
- Visible video removal recognizes the moving Sora 2 wordmark, the current Veo
diamond plus legacy `Veo` text, the Seedance boxed `AI` label, and the fixed
Dola, Hailuo, and Kling labels. It does not recognize the older Sora Turbo
corner swirl or unregistered layouts from those providers.
The classical OpenCV backend can smear structured backgrounds; use MI-GAN or
LaMa when recovery quality matters.
- Video SynthID regeneration changes resolution, frame rate, and image detail.
The shipped profile is oracle-certified, but no public local decoder can
certify an arbitrary output at runtime. Recheck unusually important outputs
after provider changes.
- Invisible-watermark removal requires CUDA. Both profiles refuse any other
device at construction rather than falling back to one that cannot run them.
Visible removal, metadata stripping and `identify` still run anywhere.
- Provider watermark systems can change. Validate important outputs with the
provider's own verifier when one is available.
**SSL certificate error** (`CERTIFICATE_VERIFY_FAILED`):
The shipped `video invisible` command uses the certified `noise_std=0.15`
profile. The companion `scripts/video_synthid_sweep.py` research harness builds
a matched re-encode control plus VAE-regenerated candidates and leaves the
verifier verdict blank:
```bash
# Install certifi (the tool auto-detects it)
pip install certifi
# macOS only: run the Python certificate installer
/Applications/Python\ 3.*/Install\ Certificates.command
uv run --extra video --extra diffusion python scripts/video_synthid_sweep.py input.mp4 -o sweep/
```
**First run is slow** — this is expected. The tool downloads model weights (~2 GB) on first launch. Subsequent runs use cached models.
To score what an output actually cost, use `scripts/video_fidelity_probe.py`:
the engine's own PSNR is measured before the resize and the encode, so only the
probe sees the delivered picture.
## Credits
```bash
uv run --extra video python scripts/video_fidelity_probe.py input.mp4 input_clean.mp4
```
- [noai-watermark](https://github.com/mertizci/noai-watermark) by mertizci — invisible watermark removal engine
- [GeminiWatermarkTool](https://github.com/allenk/GeminiWatermarkTool) by Allen Kuo (MIT) — visible watermark removal algorithm
- [CtrlRegen](https://github.com/yepengliu/CtrlRegen) by Liu et al. (ICLR 2025) — controllable regeneration pipeline
- NeuralBleach (MIT) — analog humanizer technique
The control must still be SynthID-positive before a negative candidate can
count as removal evidence. In the 2026-07-29 two-clip calibration, both matched
controls were positive in Gemini's built-in SynthID verifier; the stronger
candidate was negative on both carriers, while a weaker candidate was
negative on one. A later adversarial follow-up that asked ordinary Gemini to
reinterpret the pixel result returned `UNAVAILABLE`; that follow-up was not a
verifier rerun and does not invalidate the built-in verdicts. A 2026-07-31
full-clip check on a public eight-second Veo sample found `0.10` still detected
and `0.15` not detected, so `0.15` is now the certified default. The
reproducible hashes and verdicts live in
`data/evaluations/video-synthid-oracle.csv`.
## ⚠️ Disclaimer
## Documentation
This tool is provided for **educational and research purposes only**.
Start with the [documentation index](docs/index.md).
Removing AI watermarks to misrepresent AI-generated content as human-created
may violate applicable laws, including the U.S. Digital Millennium Copyright Act
(DMCA) and the COPIED Act. Users are solely responsible for ensuring their use
complies with all applicable laws and platform terms of service.
- [Installation](docs/installation.md)
- [CLI guide](docs/cli.md)
- [Python API](docs/python-api.md)
- [Supported signals](docs/supported-signals.md)
- [Known limitations](docs/known-limitations.md)
- [Scope, safety, and legal notes](docs/legal-and-safety.md)
- [Module internals](docs/module-internals.md)
- [Release and distribution](docs/release-and-distribution.md)
The authors do not condone the use of this tool for deception, fraud,
or any activity that violates applicable laws or regulations.
Research notes and historical experiments are listed separately in the
[documentation index](docs/index.md). They explain past decisions but do not
define the current public API.
## Contributing
Install the development environment and run the project gate:
```bash
uv sync --frozen --extra dev
bash maintain.sh
```
See [module internals](docs/module-internals.md) before changing a subsystem
with documented invariants.
## License
MIT
[Apache 2.0](LICENSE). Copyright 2025-2026 wiltodelta.
+80
View File
@@ -0,0 +1,80 @@
# Repository data
Tracked data is organized by purpose:
```text
data/
fixtures/
provenance/ Real format and provenance fixtures used by tests
(source records live in fixtures/README.md)
calibration/
<vendor>/ Minimal controlled inputs needed to rebuild detector assets
synthid/
originals/ Canonical provider-oracle fixtures, stored once
manifest.csv Provenance and verification record for each original
full-pipeline-quality.csv
Reusable full-pipeline evaluation selection
evaluations/
fidelity/ Evaluation instructions and hand-verified ground truth
video-synthid-oracle.csv
Reproducible full-clip Gemini SynthID verdicts
```
## Storage rules
1. Store each binary image once. Evaluation manifests and documentation point
to its canonical location.
2. Put executable test fixtures in `fixtures/`.
3. Put only the minimal reproducible detector inputs in `calibration/`.
4. Put externally verified SynthID originals in `synthid/originals/` and keep
both CSV files synchronized.
5. Keep evaluation outputs outside the repository. Record reproducible
commands, hashes, and oracle verdicts instead of committing another corpus
copy. A small curated before-and-after example may live in `docs/images/`
when it is part of the public documentation.
6. Keep third-party fixture license notices in `licenses/`, outside directories
that tests enumerate as media inputs.
7. Runtime detector assets belong in `src/remove_ai_watermarks/assets/`.
Unregistered research candidates belong in
`scripts/assets/visible-mark-candidates/` so they are not shipped in the
wheel.
The source distribution excludes `data/`; the wheel contains only package
runtime assets.
## Video SynthID oracle manifest
`evaluations/video-synthid-oracle.csv` is the only evidence that the shipped
video removal profile works, so it is also the source of truth for three shipped
defaults: `tests/test_video_invisible.py` asserts that `noise_std`, `long_side`,
and `fps` together match a row this manifest records as certified. Changing one
of those three without adding the row that certifies it fails the suite. `vae` is
deliberately outside that check because neither tracked row records one; add it
to the assertion in the same commit as the first row that does.
| Column | Meaning |
| --- | --- |
| `date`, `source_url`, `source_sha256` | Identify the carrier. |
| `source_width`, `source_height`, `source_fps` | Carrier geometry. Without it the actual downscale factor of a row cannot be recovered later. |
| `duration_seconds`, `source_verdict` | Clip length submitted and the verifier's reading of the untouched carrier. |
| `vae`, `noise_std`, `long_side`, `fps`, `seed` | The full run configuration. |
| `output_sha256` | Identifies the exact submitted file. |
| `output_verdict` | One of `detected`, `not_detected`, `indeterminate`, `refused`. |
| `output_verdict_text` | The verifier's wording, verbatim. |
| `output_detected_range` | Time range the verifier reported for the output. |
| `track` | `visual`, `audio`, `both`, or empty. The verifier scores tracks separately and this path copies source audio unchanged. |
| `session_id` | Groups rows submitted in one oracle session, so per-session drift stays visible. |
| `stratum` | Content class of the carrier, for stratified certification. |
| `psnr_db`, `temporal_residual_ratio` | Fidelity measurements. Neither is a watermark verdict. |
Record `indeterminate` when the verifier answers with its unclear state rather
than a negative: an unclear reading logged as `not_detected` is exactly the
silent regression this manifest exists to prevent. Leave a field empty when it
was not recorded, and never backfill it with a plausible value.
`psnr_db` is measured against the already-resized frame and before the encode,
so it excludes the downscale, the decimation, and the codec. Rows stay
comparable to each other only while that definition holds.
The two 2026-07-31 rows predate this schema; their empty fields were never
recorded and are not recoverable from the row.
+85
View File
@@ -0,0 +1,85 @@
# Doubao visible watermark capture
> **Status (captured 2026-05-29; asset rebuilt 2026-05-31):** the
> black and gray captures are committed here. The generated alpha
> asset is now a detection silhouette and mask-shaping input only. Current
> removal localizes the mark and sends its footprint to the shared fill backend.
> Reverse-alpha recovery was retired. The details below preserve the historical
> capture rationale.
Goal: capture the Doubao "豆包AI生成" visible watermark over known flat backgrounds so we can
rebuild and validate the detection silhouette used by
`src/remove_ai_watermarks/doubao_engine.py`.
## Historical findings from the capture work
- Blend model: **alpha compositing with a white logo** `watermarked = a*logo + (1-a)*original`,
`logo = (255,255,255)`. Inversion: `original = (watermarked - a*logo) / (1-a)`.
Confirmed by two independent sources (an open-source remover's algorithm doc + aiwatermarkremover.dev,
both say "alpha map"). One commercial blog (pixelcleanai) claims "screen blend" instead; the gray
capture below settles it empirically.
- Position: **bottom-right corner**, small margins (right ~8-20px, bottom ~5px), scales with image size.
Confirmed by our sample `data/fixtures/provenance/doubao-1.png` (2048x2048) plus three sources.
- Size **scales with resolution**. Third-party numbers (~90x18 at <=1024, ~180x40 at >1024) are
approximate and calibrated for ~1024-1280 outputs; at 2048 the strip is much larger. A shipped
third-party alpha map is only 120x20, too small for our 2K/4K target -> capture fresh.
- The original implementation used reverse-alpha recovery. That path was later
removed in favor of localize then fill because the latter also handles moved
and re-rendered marks.
## Use doubao.com specifically
The "豆包AI生成" mark is Doubao's. Jimeng / Dreamina use a different mark. Generate on doubao.com so
the captured template matches our target.
## How to capture (image-edit path, most reliable)
For each locally generated solid-color seed:
1. Open Doubao image generation, use the image-edit / reference mode, upload the seed.
2. Prompt (Chinese preferred):
`请完全按照原图重新生成这张图片,保持完全一致,不要添加或修改任何内容`
(English: `Recreate this image exactly as it is, keep it identical, do not add or change anything`)
3. Download the ORIGINAL output file (not a screenshot). Do not crop / edit / re-save.
Prior art confirms uploading a pure-black image and letting Doubao stamp it works.
If edit mode is unavailable and text-to-image refuses a solid color, fall back to generating 10-12
normal-content images at one fixed resolution; the mark is the only constant across them and can be
extracted by per-pixel min/median.
## What to capture (priority top to bottom)
| Aspect | black | white | gray128 | why |
|--------|-------|-------|---------|-----|
| 1:1 | 3 | 1 | 1 | primary alpha map + confirm the stamp is pixel-identical across runs + settle blend mode |
| 16:9 | 2 | 1 | 1 | anchor rule in landscape |
| 9:16 | 2 | 1 | 1 | anchor rule in portrait |
| 4:3, 3:4 | 1 each | - | - | optional, refines anchor rule |
- 3 blacks on 1:1: if the first two are byte-identical in the watermark region, the third is optional.
- gray128 is the blend-mode test: predict the gray result from the black capture under alpha vs screen;
whichever matches the real gray output is the true blend.
- If the UI offers multiple output resolutions (1K / 2K / 4K), capture one black per resolution on 1:1 -
needed to learn how the watermark scales.
- Also grab 3-5 normal-content images on 1:1 for end-to-end removal validation.
## Hygiene
- Original download, never a screenshot. PNG preferred; if Doubao only gives JPEG, note it.
- No crop / edit / re-save. Default settings, watermark left ON.
## Committed inputs
```
doubao_black_1x1_1.png
doubao_gray_1x1_1.png
```
The black and gray captures feed `scripts/visible_alpha_solve.py doubao`.
## Also report back
1. Which resolutions and aspect ratios the Doubao UI actually offers.
2. Whether there is a watermark on/off toggle in the UI.
3. Download format (PNG or JPEG).
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

+46
View File
@@ -0,0 +1,46 @@
# Gemini (Nano Banana) visible sparkle capture
> **Status (captured 2026-05-31):** the required black capture is committed
> here. The sparkle-on-black assets
> `gemini_bg_{96,48}.png` are rebuilt by `scripts/visible_alpha_solve.py gemini`.
> They support detection and footprint geometry. Current pixel recovery uses the
> shared fill backend, not reverse-alpha inversion.
Google Gemini (Nano Banana) stamps a four-point sparkle icon in the bottom-right corner
via alpha compositing: `watermarked = a*logo + (1-a)*original`. Unlike the Doubao/Jimeng
text marks, the sparkle is captured over a **pure-black** background, where
`watermarked = a*255` (logo is near-white), so the alpha reads directly off the capture
(`alpha = max(R,G,B)/255`) -- no background fit needed. This is the "golden" capture case.
## What the captures confirmed (2026-05-31)
- The sparkle at a 2048-wide image is **96x96 px** (width_frac ~0.047), bottom-right,
margins ~0.031, alpha max ~0.51 -- matching the engine's existing 96px asset.
- Our own controlled capture matches the previously third-party-sourced
`gemini_bg_96.png` to **NCC 0.9998**, so the bundled asset is validated and now
reproducible from our own capture.
## How to capture (image-edit path)
For each locally generated black, gray, or white 2048 px seed:
1. Open Gemini image generation, image-edit / reference mode, upload the seed.
2. Prompt: `Recreate this image exactly as it is, keep it identical, do not add or change anything`
3. Download the ORIGINAL output (not a screenshot). Do not crop / edit / re-save.
Black is the key one (sparkle on black -> exact alpha). Gray/white cross-check.
## Committed input
```
gemini_black_2048.png # the key capture (sparkle on black)
```
The gray and white captures were calibration cross-checks and are not needed to
rebuild the assets. Synthetic seeds and real-content validation downloads stay
local.
Rebuild the assets with:
```
uv run python scripts/visible_alpha_solve.py gemini # or: all
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

+68
View File
@@ -0,0 +1,68 @@
# Jimeng (即梦AI) visible watermark capture
> **Status (completed 2026-05-30):** solid black and gray Jimeng captures were
> obtained and the detection asset was solved. The asset now supports detection
> and mask geometry only. Current removal localizes the wordmark and uses the
> shared fill backend. The text below records the capture and calibration work.
Goal: capture the Jimeng / Dreamina "★ 即梦AI" visible wordmark over known flat
backgrounds so we can rebuild and validate the silhouette used by
`src/remove_ai_watermarks/jimeng_engine.py`.
## What we learned (verified from the captures, 2026-05-30)
- Mark: a four-point sparkle icon followed by the "即梦AI" characters, near-white
semi-transparent overlay, bottom-right corner.
- Blend model: **alpha compositing with a pure-white logo** `watermarked =
a*255 + (1-a)*original`, confirmed in sRGB (a linear-light solve made the
black/gray cross-residual much worse, so the compositing is plain sRGB). An
L-pair-solve (independent of the L assumption) lands at ~254.6, confirming white.
- **Alpha is solved from the GRAY capture**, not black: `a = (I - B)/(255 - B)`
with B a per-capture CUBIC background fit over the non-glyph pixels, averaged
over channels, at FULL halo extent (down to a~0.02) and UNBLURRED. Gray (bg ~132)
is the best proxy for real content (the mark sits on bright photo areas, not on
black). This careful build drops the gray self-residual to ~1.3; an earlier
max-channel / quadratic-bg / blurred / halo-truncated build (and a black-dominated
least-squares solve) left a visible outline -- the mask quality, not the method,
was the limit.
- Geometry (fraction of image WIDTH, at the captured 2048): asset width ~0.211,
height ~0.068, right margin ~0.023, bottom margin ~0.023. The mark scales with
width; a real 1440-wide download matched width_frac ~0.21.
- **Per-image render variation:** the alpha maps solved independently from the
black and the gray capture correlate 0.998 but not 1.0 (mean |Δa| ~0.02). Jimeng
re-rasterizes the mark per generation and jitters its position a few pixels.
This was one reason the old reverse-alpha path needed alignment and residual
inpainting. The current path instead detects the silhouette, localizes the
glyph footprint, and fills that footprint.
## How to capture (image-edit path, most reliable)
For each solid-color seed:
1. Open Jimeng image generation, use the image-edit / reference mode, upload the seed.
2. Prompt (Chinese preferred):
`请完全按照原图重新生成这张图片,保持完全一致,不要添加或修改任何内容`
3. Download the ORIGINAL output file (not a screenshot). Do not crop / edit / re-save.
The black capture is the key one (white logo on black -> `captured ~= a*255`); the
gray capture refines the alpha at mid-tones; the white capture confirms the logo is
pure white (the mark is nearly invisible on white, as expected).
## Hygiene
- Original download, never a screenshot. PNG preferred; if Jimeng only gives JPEG, note it.
- No crop / edit / re-save. Default settings, watermark left ON.
## Committed inputs
```
jimeng_cap_A.png # black seed run through Jimeng
jimeng_cap_C.png # gray seed
```
The A and C captures feed
`scripts/visible_alpha_solve.py jimeng`. Rebuild the detection asset with:
```
uv run python scripts/visible_alpha_solve.py jimeng # or: all
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+61
View File
@@ -0,0 +1,61 @@
# Samsung Galaxy AI visible watermark capture
> **Status (built 2026-06-05):** flat black and gray Samsung Galaxy AI captures
> were obtained and the detection asset was solved. The asset now supports
> detection and mask geometry only. Current removal localizes the wordmark and
> uses the shared fill backend. The text below records the capture plan and its
> locale and resolution limits.
Goal: capture the Samsung Galaxy AI "✦ Contenuti generati dall'AI" visible wordmark
over known flat backgrounds so we can rebuild and validate the silhouette used
by `src/remove_ai_watermarks/samsung_engine.py`.
## What we learned (verified from the captures, 2026-06-05)
- Mark: a sparkle icon followed by the locale string "Contenuti generati dall'AI"
(Italian), a light low-opacity (peak alpha ~0.38) semi-transparent **white**
overlay, anchored **bottom-LEFT** (Doubao/Jimeng are bottom-right). The string is
locale-specific, so the alpha template is per-locale; this build is the Italian
variant. Other locales need their own captured template.
- Blend model: alpha compositing with a pure-white logo, `watermarked =
a*255 + (1-a)*original`, solved from the GRAY capture (same careful recipe as
Doubao/Jimeng: cubic-background fit, mean over channels, full halo extent,
unblurred). The white capture confirms the logo is white; on white the mark is
white-on-white and not detectable (no contrast), which is fine -- there is nothing
to recover there.
- Geometry (fraction of image WIDTH): asset width ~0.32, height ~0.038, left margin
~0.011, bottom margin ~0.006. The mark scales with width: a 1086-wide flat capture
and a 2958-wide real photo both measure width_frac ~0.31.
- **Resolution caveat (open quality follow-up):** the flat black/gray/white captures
arrived at the phone's flat-edit size (1086 wide and a landscape 1920 set), while
the real photos are ~3000 wide, so the captured glyph (~334 px) is ~2.7x smaller
than on a real photo (~900 px). The alpha is solved at the capture size and
width-scaled + NCC-aligned per image, which removes the mark cleanly (verified on a
real 2958-wide photo: re-detect 0.79 -> 0.00, no readable text or outline), but a
flat capture taken at the real photo resolution (~3000 wide) would let the alpha be
pixel-sharp instead of upscaled. Not a blocker; a quality upgrade if a full-res
flat capture is provided.
## Capture protocol (to re-capture or add a locale)
On a Samsung Galaxy AI device (set the UI language to the target locale):
1. Run the AI edit (Generative Edit / Sketch to Image) on a solid black image, so
the overlay lands on a flat black background. Download the ORIGINAL output file
(not a screenshot, no crop or re-save).
2. Repeat over solid white and solid gray (those pin the exact glyph color).
3. Ideally run all three flat edits at the same resolution as real photos (~3000
wide) so the alpha map is pixel-sharp rather than upscaled.
4. Plus 3-5 real outputs with the visible mark over normal content for validation.
## Files
- `samsung_black_1.png` and `samsung_gray_1.png` are the retained solver inputs.
- White, secondary-resolution, and real-content captures were validation
material and are not required to rebuild the asset.
Rebuild the detection asset with:
```
uv run python scripts/visible_alpha_solve.py samsung
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 434 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 904 KiB

+400
View File
@@ -0,0 +1,400 @@
# Pipeline fidelity evaluation
This evaluation compares diffusion pipelines with
`scripts/fidelity_metrics.py`. The images themselves have one canonical home
in `data/synthid/originals/`; this directory stores only evaluation-specific
ground truth and instructions.
| Original | Provider | Content | Exercises |
| --- | --- | --- | --- |
| `ChatGPT Image May 31, 2026, 02_02_23 PM.png` | OpenAI | Light multilingual typography | Text preservation |
| `ChatGPT Image May 31, 2026, 02_03_55 PM.png` | OpenAI | Multilingual typography | Text preservation |
| `Gemini_Generated_Image_633uuy633uuy633u.png` | Google | Landscape with a Chinese sign | CJK text preservation |
| `Gemini_Generated_Image_y48j3cy48j3cy48j.png` | Google | Portrait grid | Face identity and skin texture |
## Text ground truth
`ground-truth.json` contains hand-verified OCR for the three text-bearing
originals. To regenerate an OCR seed:
```bash
uv run scripts/fidelity_metrics.py ocr \
"data/synthid/originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png" \
"data/synthid/originals/ChatGPT Image May 31, 2026, 02_02_23 PM.png" \
data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png \
--langs en,ru,ch \
--out data/evaluations/fidelity/ground-truth.json
```
Verify and correct the generated text by hand before using it as ground truth.
`text-lines.json` contains the verified per-line strings and source-space boxes
used by the evaluation-only selective-restoration experiment. It is not an
automatic scene-text annotation set.
`scripts/infer_text_lines.py` can generate a draft from source pixels without
existing annotations. On the two posters it proposed 20 and 18 lines at the
default threshold, but exact-text precision was only 90.0% and 94.4% because
high-confidence OCR still dropped an English comma and replaced a Chinese comma
with ASCII. Its output therefore requires manual verification of every line;
`accepted` means crop-stable, not ground-truth-correct.
## Text-preservation benchmark
`text-preservation-2026-08-13.csv` records a fixed-seed comparison of the two
current profiles and a global Z-Image Turbo prototype on all three text
fixtures. Each candidate ran through the
complete `visible -> invisible -> metadata` route with its profile default
strength and adaptive-polish setting. The Z-Image prototype instead sweeps
0.08, 0.10, 0.15, 0.20, and 0.25 without polish because its provider-specific
removal floor is not known. The output hash identifies the exact bytes
measured; generated outputs remain outside the repository.
The character-weighted added CER is 0.262 for `qwen-zimage` and 0.256 for
`sdxl-zimage`. That 0.006 absolute difference is not a
stable ordering: SDXL wins the light poster, Qwen wins the dark poster, and the
Chinese sign is tied. A paired sign test on the two non-ties is 1-1 (`p=1.0`).
The measured sample therefore does not support a general text-preservation
winner. Both profiles substantially degrade the smallest multilingual poster
text and preserve the larger Chinese sign.
Z-Image Turbo is the clear fidelity lead. At strength 0.10 its
character-weighted CER is 0.093, against the unchanged sources' 0.124 OCR
floor, while whole-image LPIPS stays between 0.047 and 0.086. Visual inspection
still finds substitutions in the smallest Cyrillic and Chinese poster lines,
so a favorable OCR score does not mean pixel-exact text. Quality drops quickly
above 0.15; at 0.25 the weighted CER is 0.428.
Provider-oracle checks on 2026-08-13 bracket the OpenAI requirement at no more
than 0.10 for this sample. Both original OpenAI controls were detected by
`openai.com/verify`; the light poster was clean at 0.08, while the dark poster
was detected at 0.08 and clean at 0.10. The light 0.10 output was not separately
checked. Google fails the entire tested sweep. The original Gemini image was
detected through C2PA, and a pixel-identical copy with AI metadata stripped was
separately detected by Gemini's built-in SynthID verifier. Z-Image outputs at
0.08, 0.10, 0.15, 0.20, and 0.25 were all still detected. At 0.25 the weighted
CER has already risen to 0.428, worse than the raw weighted CER of both current
profiles (Qwen 0.387, SDXL 0.381). Increasing strength beyond the measured grid
would therefore no longer serve the text-preservation objective without a new
mechanism or hypothesis. Z-Image is not a viable global replacement on this
evidence: no clean Google operating point was found before it lost its fidelity
advantage.
Qwen-Image-2.0 was not added to the numeric comparison. Its weights are not
published, and its hosted edit API exposes an editing instruction and seed but
no low-strength denoise control. It can be evaluated as a separate hosted edit
strategy when credentials are available, but it is not a drop-in replacement
for the partial-regeneration mechanism measured here.
The OCR floor is the source image scored against the hand-verified text. Use
`added_cer = text_cer - ocr_floor` when interpreting pipeline damage, because the
unchanged poster sources already score 0.127 CER. `oracle_rechecked=true` marks
the exact Z-Image bytes checked above; the remaining rows were not rechecked.
The table does not certify other seeds, content classes, or strengths beyond
the recorded provider verdicts.
## Text-restoration prototype
`text-restoration-2026-08-13.csv` evaluates an OCR-driven post-pass on the exact
Qwen outputs above. The prototype recognizes English and Russian with macOS
Vision and CJK with PaddleOCR, derives glyph masks independently from the
source and Qwen output, removes both sets of glyphs with block-wise LaMa, and
draws the recognized strings with new system-font pixels. It never composites
source pixels back into the result.
On the two multilingual posters, character-weighted CER fell from 0.338 and
0.305 to 0.007 on both. OpenAI Verify reported no OpenAI signals for both Qwen
controls and both restored outputs in the same run. The improvement comes with
a substantial whole-image fidelity cost: LPIPS rose from 0.107 to 0.174 and
from 0.095 to 0.162, while PSNR fell by about 10.5-11.4 dB. Visual inspection
found one residual shadow in the smallest English line of the light poster;
the dark poster was clean but the substitute fonts visibly changed typography.
The Chinese sign did not improve: CER rose from 0.074 to 0.111 because the OCR
and renderer changed punctuation. The Gemini verifier returned detected for
both the restored output and its byte-identical Qwen control on a third work
account, although that Qwen hash had previously returned clean on another work
account. This run therefore does not isolate a restoration-stage regression;
its Google verdict is inconclusive until a source-positive, Qwen-negative
control can be reproduced in the same available account.
This prototype is not ready to ship. Its strongest result establishes that
fresh-glyph reconstruction can recover literal text without reintroducing an
OpenAI signal, but portable OCR, font/style reconstruction, a tighter mask, and
a reproducible Google oracle control remain prerequisites.
### Selective restoration follow-up
`selective-text-restoration-2026-08-13.csv` compares that full compositor with
a selective prototype on the same two posters. The prototype leaves a Qwen line
unchanged when padded source and output recognition agree, and applies the same
LaMa plus fresh-system-font reconstruction only to lines whose recognized text
changed. This reduced the edited area from 15.4% and 17.0% to 5.5% on both
posters.
Under one consistent Paddle `en+ru+ch` measurement route, selective restoration
reduced Qwen CER from 0.378 to 0.101 on the light poster and from 0.413 to 0.112
on the dark poster. Its image LPIPS was 0.120 and 0.103, substantially closer to
Qwen than the full compositor's 0.174 and 0.162. These CER values must not be
mixed with the preceding table's Vision/Paddle hybrid values: the comparison
file remeasures all three variants through Paddle so their relative result is
valid on one OCR route.
OpenAI Verify returned `No OpenAI signals detected` for both selective outputs,
then detected the original light poster as `Generated with OpenAI tools` in the
same Chrome sequence. Visual inspection found the dark output clean, but the
light output still retained a local shadow around one replaced fine-text line.
Selective restoration is therefore the strongest current direction, not a
production-ready default. The next implementation needs automatic line matching
and a tighter source-plus-candidate glyph mask before it can be proposed for the
pipeline.
A mask-only follow-up added two pixels of dilation around every selected glyph.
It visually removed the light poster's shadow and improved its CER from 0.101 to
0.081 with effectively unchanged LPIPS, but increased the edited area from 5.5%
to 7.0%. Applying the same rule to the dark poster worsened CER from 0.112 to
0.147. These expanded-mask rows were not resubmitted to the provider oracle.
The opposing result rules out one global dilation constant and makes per-line
residual detection the next fidelity gate.
An automatic follow-up removed the hand-selected line list. For each known
source line, it runs a recognition-only model on padded, upscaled source and
Qwen crops. It preserves a line only when both confidence scores are at least
0.75, the source recognition is within 0.25 normalized edit distance of the
verified line, and normalized source and candidate recognition match exactly.
This reproduced the manual dark-poster line selection and additionally kept one
correct large Russian line on the light poster.
The automatic prototype also replaced uniform dilation with a second measured
LaMa pass. After the first erase, it extracts contrast remaining at the original
glyph positions, dilates only that residual, and inpaints it before drawing fresh
text. Visual inspection found no remaining double glyphs on either output. The
effective edited fractions were 4.1% and 5.7%. Image LPIPS was 0.113 and 0.104,
against 0.120 and 0.103 for the manual selective version. Detector-based page CER
was 0.123 and 0.119; the light value is inflated by the detector splitting the
preserved Cyrillic line into Latin-like fragments even though the padded Russian
recognizer reads it exactly. This is another reason that line decisions use
recognition-only crops rather than the page detector's aggregate CER.
Both exact automatic output hashes returned `No OpenAI signals detected`, and
the original light poster returned `Generated with OpenAI tools` immediately
afterward in the same Chrome sequence. The tracked script reproduced the dark
hash exactly. Its light output differed from the initial prototype in only 386
pixels by at most one channel value because separate ONNX environments rounded
differently; the script's exact `900def5a...` hash was therefore submitted and
also returned clean before the positive source control.
The script and its required verified line annotations are tracked as
`scripts/selective_text_restoration.py` and `text-lines.json`. For example:
```bash
PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK=True \
uv run scripts/selective_text_restoration.py \
data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png \
path/to/qwen-candidate.png \
--restoration vae-glyphs \
--glyph-donor path/to/qwen-vae-reconstruction.png \
--glyph-feather 0.5 \
--output path/to/selective-output.png \
--manifest path/to/manifest.json
```
The restoration mode is deliberately explicit. `vae-glyphs` requires a
separately generated VAE reconstruction in `--glyph-donor`; it takes exact
thresholded glyph cores from that donor, uses a fresh silhouette beneath them,
and defaults to a narrow 0.5-pixel donor edge. The donor and the resulting exact bytes still require
full-pipeline oracle evaluation. `rerender` reproduces the tracked
fresh-system-font experiment. `source-glyphs` is only for compositing a
separately regenerated, oracle-evaluated layer; feeding it the watermarked
original would paste provenance-bearing pixels back into the result. The
`--keep-background --composite-mask boxes` combination is an aligned-layer
experiment and is not a production text restorer.
The `source-silhouette` follow-up keeps the source glyph geometry but discards
its pixel amplitudes: it thresholds each source line to a binary shape, samples
one foreground color, and synthesizes new antialiasing over the scrubbed image.
On a 24-image matrix spanning serif, Latin-diacritic, Cyrillic, CJK, tiny-UI,
and rotated text, the original outer-feather compositor preserved all 34
source-readable lines exactly under crop OCR, with median text-box SSIM 0.859.
An inner-antialias variant raised median text-box SSIM to 0.902 and left
whole-image SSIM unchanged at 0.672, but preserved 33/34 lines: OCR read one
middle dot as a colon. The higher-fidelity antialiasing is retained in the
evaluation script, with that punctuation miss recorded as a caveat. Its exact
outputs were not submitted to the provider oracle because the
public verifier reached its request limit; a clean verdict from the earlier
outer-feather bytes does not transfer to the new hashes.
Follow-up visual review on a typography-rich control rejected both silhouette
compositors despite their OCR and SSIM scores. They preserved literal content
and approximate glyph geometry, but changed stroke weight, color variation,
edge antialiasing, and small decorative details enough to be plainly different
from the source. OCR exactness and text-box SSIM are therefore screening metrics,
not acceptance gates for source-typography preservation. A candidate must also
retain source-like edge pixels and pass direct visual comparison at native size.
A narrower Qwen-VAE donor follow-up keeps the scrubbed background, then copies
only VAE-reconstructed pixels through the source-silhouette mask with a
0.5-pixel feather. Across the 48-case typography matrix (548 annotated lines),
text-box SSIM improved in 47/48 cases and glyph-edge error improved in 48/48.
Median text-box SSIM rose from 0.854 to 0.914, while median glyph-edge MAE fell
from 37.59 to 32.42. The median nonzero alpha area was 3.62%; on dense or large
text this is still substantially wider than the target control. These are
fidelity results only; the 48 matrix outputs were not submitted to the provider
oracle. On a separate untracked dense-typography control, the exact core-only
Qwen-VAE donor with a 1.43% source-silhouette area returned `No OpenAI signals
detected`. Its 0.5-pixel feathered sibling, which raises the nonzero donor area
to 2.75%, returned the same verdict and improved mean text-box SSIM from 0.957
to 0.960. Crop OCR recovered from 7/15 exact lines on the raw pass to 14/15,
matching the source OCR floor. Whole-image LPIPS was 0.082, but only 0.108% of
pixels were exactly unchanged and the detected face retained 0.670 of source
Laplacian variance. The tracked script reproduced the feathered file byte for
byte. These two exact-byte verdicts do not certify other images or the larger
matrix masks, and the global smoothing fails a strict unchanged-image criterion.
The opt-in production port was rechecked separately on 2026-08-15. Its current
LaMa runtime did not reproduce the earlier evaluation PNG byte for byte, but all
changed pixels were confined to the erased background outside the donor glyph
core. The exact production artifact returned `No OpenAI signals detected` in
3/3 OpenAI Verify runs, while the matched source control returned `Generated
with OpenAI tools` in 2/2 runs in the same Chrome session; expanded details
identified SynthID and no C2PA manifest on the control. The private control and
artifact hashes remain outside the public repository. This certifies only that
runtime, verified manifest, and output, not arbitrary text masks or images.
The Google result is negative. On the synthetic CJK sign case, two separate
work-account runs both detected SynthID in the resaved source control and in the
exact Qwen-VAE donor output. The candidate improved mean text-box SSIM from
0.791 to 0.835 and glyph-edge MAE from 35.48 to 22.49 with a 3.67% donor area,
but Gemini still detected it. The intermediate Qwen silhouette base and the
earlier original-sign Qwen baseline were also detected in the same account.
This isolates the current blocker upstream of text restoration: the global
Google pass did not reach the SynthID removal floor, so the OpenAI-clean
`vae-glyphs` configuration cannot be used as a Google operating point.
On the large Chinese-sign control, source and candidate recognition agreed on
all three lines. The script selected no changed lines, emitted a zero mask, and
copied the candidate byte for byte instead of re-encoding it. This corrects the
earlier unnecessary CJK rerender and proves the no-edit branch. It does not add
a Google-negative oracle result: the available Google account still cannot
reproduce a source-positive, Qwen-negative control sequence.
The earlier automatic rerender was the first variant in the experiment to pass
the recorded visual, fidelity, and OpenAI-oracle gates on its two poster
fixtures. The later typography-rich control shows that result does not
generalize to source-typography preservation. It remains evaluation-only: it
depends on verified source text and source line boxes, uses macOS system fonts,
and has not been validated on natural scene text, rotated text, or automatic
line-box discovery.
An opt-in `--detect-boxes` follow-up tested automatic geometry. Grouping Paddle
word detections by vertical overlap found exactly 20/20, 20/20, and 3/3 lines;
mean IoU with verified boxes was 0.857, 0.847, and 1.000. Reusing the annotation
crop padding was unstable and preserved only 4/20 dark-poster lines. Reducing
vertical recognition padding to 10% restored the exact 8/20 and 7/20 selection
decisions, but detector CER was 0.127 and 0.154 rather than 0.123 and 0.119. The
dark regression failed the fidelity gate, so these hashes were not submitted to
the provider oracle. The flag is retained only to reproduce that negative
evaluation and still requires verified strings and an exact line-count match.
### AnyText2 glyph-conditioned follow-up
`anytext2-restoration-2026-08-13.csv` tests the official Apache-2.0
AnyText2 checkpoint as a local text-editing pass over the exact Qwen Chinese-sign
output. The checkpoint hash was verified against ModelScope. Its own edit example
successfully replaced a masked blackboard line with the requested `DADDY`, so the
runtime and checkpoint were functional before the tracked fixture was measured.
AnyText2 failed the fidelity gate. The standard full-image detector scored the
default edit at CER 0.185 and the source-font mimic at 0.222, against 0.074 for
Qwen; image LPIPS moved from 0.289 to 0.338 and 0.345. A padded crop-recognition
cross-check removed the detector's punctuation misses: Qwen scored 0.000, the
default edit still scored 0.074 with two substituted characters, and the mimic
edit stayed at 0.222. Visual inspection agreed, with additional line-shape and
punctuation errors in the mimic output. No provider-oracle submission was made
because neither candidate preserved the requested content better than its
already-clean Qwen input.
This result covers the published SD1.5 checkpoint, three Chinese lines of no more
than 20 characters, one seed, and one fixture. The repository still describes
AnyText2XL as unreleased, and the inference wrapper truncates each requested line
after 20 characters, so the published model cannot directly cover the longer
English and Russian poster lines.
The padded cross-check also changes the interpretation of this fixture's earlier
restoration result. Its Qwen image already contains all three correct Chinese
lines; the reported 0.074 came from the detector cropping the final punctuation
from two lines. A deterministic rerender with horizontally padded recognition
and a mask-matched Hiragino Sans GB W6 font reached detector CER 0.000, but image
LPIPS worsened from 0.289 to 0.327 and the font was visibly heavier. The correct
policy on this large-text fixture is therefore to leave a Qwen line unchanged
when padded recognition confirms the source and candidate strings agree.
## Face-stage isolation
`face-stage-isolation-2026-08-13.csv` separates the two stages on the 18-face
Gemini portrait-grid fixture. All variants use the same visible-cleaned,
metadata-stripped input and seed 0. The prepared input remained positive in the
Gemini SynthID verifier, so the surrounding visible and metadata stages did not
create the clean verdicts.
| Route | Gemini oracle | ID cosine | Face LPIPS | Laplacian ratio | Image LPIPS |
| --- | --- | ---: | ---: | ---: | ---: |
| Prepared control | detected | - | - | - | - |
| Z-Image face stage only | detected | 0.927 | 0.028 | 0.612 | 0.013 |
| Qwen global stage only | clean | 0.589 | 0.217 | 0.331 | 0.288 |
| Full Qwen then Z-Image | clean | 0.852 | 0.050 | 0.492 | 0.252 |
This isolates the roles: the Qwen global pass removes the watermark, while the
masked Z-Image pass restores face identity and detail without reintroducing a
detectable whole-image signal. Z-Image face-only does not remove SynthID. The
result explains why the face stage succeeded while global Z-Image failed: the
earlier clean verdict belonged to the two-stage pipeline, not to Z-Image as a
standalone remover. This is one fixture, one seed, and one oracle pass per
variant; it does not establish a general reintroduction threshold for mask size.
## Upstream Synthid-Bypass v2 reproduction
`upstream-v2-reproduction-2026-08-13.csv` records a source-level audit and a
close reproduction of the public
[`Synthid-Bypass-v2.0.json`](https://github.com/cebeuq/Synthid-Bypass/blob/3007d0351596ae0a78b7074dae7ad179710b1e48/Synthid-Bypass-v2.0.json).
The audited workflow is SHA-256
`41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6` at
upstream commit `3007d0351596ae0a78b7074dae7ad179710b1e48`.
Tracing links backward from `SaveImage` confirms that the active global stage is
Qwen-Image-2512 Q4 with the Lightning four-step LoRA at 0.8, DiffSynth Canny at
1.0, AuraFlow shift 3, `dpmpp_2m` plus `sgm_uniform`, CFG 1, and four steps. Its
resolution helper uses adaptive level 6 over 0.08..0.15, which resolves to the
0.154 ceiling for the 2816x1536 portrait fixture. Z-Image Turbo is used only by
the face detailer: eight steps, CFG 1, `res_2s` plus `bong_tangent`, a 768 px
guide, 1024 px cap, and direct adaptive denoise
`clamp(0.10 * largest_face_ratio / 0.03, 0.05, 0.28)`. The active face regions
come from YOLOv8-face plus SAM. Although the README describes MediaPipe as part
of the strict path, the MediaPipe nodes do not reach the saved output. The
1.2-megapixel scaler is also bypassed (`mode=4`).
The published upstream pair 12 changed from SynthID-positive to clean in the
Gemini verifier and scored 0.975 face identity. On the project portrait fixture,
the prepared control was positive, while both the close global reproduction and
the close full reproduction were clean. The global stage scored 0.589 identity
and 0.217 face LPIPS; the upstream-strength face stage improved those to 0.783
and 0.083. The maintained profile's weaker face pass scored 0.852 and 0.050 on
the same control, so copying upstream's roughly doubled face strength would be a
quality regression on this fixture.
The close reproduction is intentionally labeled rather than presented as an
exact ComfyUI run. It uses full-precision DiffSynth bf16 weights instead of the
Q4 GGUF files, the maintained DiffSynth Lightning scheduler approximation
instead of the ComfyUI sampler pair, YuNet plus SAM instead of YOLOv8-face plus
SAM, and fixed seed 0 instead of randomized seeds. The two independent oracle
controls and the published upstream pair establish the direction of the result;
they do not make the local output byte-equivalent to upstream.
## Compare
```bash
uv run scripts/fidelity_metrics.py compare \
--original data/synthid/originals/Gemini_Generated_Image_y48j3cy48j3cy48j.png \
--variant qwen-zimage=<qwen-out>.png \
--variant sdxl-zimage=<sdxl-out>.png \
--ocr-langs ""
```
@@ -0,0 +1,4 @@
date,source_commit,fixture,variant,model,model_revision,model_sha256,ocr_route,text_cer,img_lpips,ssim,psnr_db,output_sha256,provider_oracle,notes
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage,,,,"Paddle ch",0.074,0.289,0.696,23.3,4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621,not_run,Exact Qwen baseline reused from text-restoration benchmark
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage-anytext2-default,AnyText2-SD1.5,b06c583a583818f3679665ef67b51363f107853c,7d5d593928d842500e0da101ab6a1ae28a8d21403441ca62c813c4f92529ace8,"Paddle ch",0.185,0.338,0.642,18.2,87729fe7e0482d2cead6d3b7c7dd386ecc75ab3d8658131c54e67528b8f9feeb,not_run,Fidelity gate failed; padded crop recognition CER 0.074 still shows two substituted characters; locally composited three-line edit; 20 DDIM steps; seed 0
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage-anytext2-mimic,AnyText2-SD1.5,b06c583a583818f3679665ef67b51363f107853c,7d5d593928d842500e0da101ab6a1ae28a8d21403441ca62c813c4f92529ace8,"Paddle ch",0.222,0.345,0.644,17.9,f5198d8f2365b79880aba8ea6b0c3885142b9de21c9f7f4f6712ac4a0db8dd85,not_run,Fidelity gate failed; source glyph masks supplied as font hints; 20 DDIM steps; seed 0
1 date source_commit fixture variant model model_revision model_sha256 ocr_route text_cer img_lpips ssim psnr_db output_sha256 provider_oracle notes
2 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png qwen-zimage Paddle ch 0.074 0.289 0.696 23.3 4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621 not_run Exact Qwen baseline reused from text-restoration benchmark
3 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png qwen-zimage-anytext2-default AnyText2-SD1.5 b06c583a583818f3679665ef67b51363f107853c 7d5d593928d842500e0da101ab6a1ae28a8d21403441ca62c813c4f92529ace8 Paddle ch 0.185 0.338 0.642 18.2 87729fe7e0482d2cead6d3b7c7dd386ecc75ab3d8658131c54e67528b8f9feeb not_run Fidelity gate failed; padded crop recognition CER 0.074 still shows two substituted characters; locally composited three-line edit; 20 DDIM steps; seed 0
4 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png qwen-zimage-anytext2-mimic AnyText2-SD1.5 b06c583a583818f3679665ef67b51363f107853c 7d5d593928d842500e0da101ab6a1ae28a8d21403441ca62c813c4f92529ace8 Paddle ch 0.222 0.345 0.644 17.9 f5198d8f2365b79880aba8ea6b0c3885142b9de21c9f7f4f6712ac4a0db8dd85 not_run Fidelity gate failed; source glyph masks supplied as font hints; 20 DDIM steps; seed 0
@@ -0,0 +1,5 @@
date,source_commit,fixture,route,seed,global_strength,face_strength,face_count,id_cos,face_lpips,lapvar_ratio,img_lpips,ssim,psnr_db,output_sha256,gemini_oracle
2026-08-13,2d03a00,Gemini_Generated_Image_y48j3cy48j3cy48j.png,visible-metadata-control,0,,,18,,,,,,,9f67264beb1da66a710885464a11f2ae85989094149f6145521d302cd2db940b,synthid_detected
2026-08-13,2d03a00,Gemini_Generated_Image_y48j3cy48j3cy48j.png,zimage-face-only,0,,0.06604512532552083,18,0.927,0.028,0.612,0.013,0.959,34.7,c388e9bbf737bb9b22a7cb2216ffb8026321ca632f9db29e1e456b7c14a8553f,synthid_detected
2026-08-13,2d03a00,Gemini_Generated_Image_y48j3cy48j3cy48j.png,qwen-global-only,0,0.153999996,,18,0.589,0.217,0.331,0.288,0.646,24.2,281a1d28d786d157cbbd13d00a32b215de4dfb3174a3c5e4e83c843e5402138f,clean
2026-08-13,2d03a00,Gemini_Generated_Image_y48j3cy48j3cy48j.png,qwen-zimage-full,0,0.153999996,0.06604512532552083,18,0.852,0.050,0.492,0.252,0.671,24.7,69de191bddbfbf53348c7638bdba661b6d45d257c00a8a2646b2558670c5a4c2,clean
1 date source_commit fixture route seed global_strength face_strength face_count id_cos face_lpips lapvar_ratio img_lpips ssim psnr_db output_sha256 gemini_oracle
2 2026-08-13 2d03a00 Gemini_Generated_Image_y48j3cy48j3cy48j.png visible-metadata-control 0 18 9f67264beb1da66a710885464a11f2ae85989094149f6145521d302cd2db940b synthid_detected
3 2026-08-13 2d03a00 Gemini_Generated_Image_y48j3cy48j3cy48j.png zimage-face-only 0 0.06604512532552083 18 0.927 0.028 0.612 0.013 0.959 34.7 c388e9bbf737bb9b22a7cb2216ffb8026321ca632f9db29e1e456b7c14a8553f synthid_detected
4 2026-08-13 2d03a00 Gemini_Generated_Image_y48j3cy48j3cy48j.png qwen-global-only 0 0.153999996 18 0.589 0.217 0.331 0.288 0.646 24.2 281a1d28d786d157cbbd13d00a32b215de4dfb3174a3c5e4e83c843e5402138f clean
5 2026-08-13 2d03a00 Gemini_Generated_Image_y48j3cy48j3cy48j.png qwen-zimage-full 0 0.153999996 0.06604512532552083 18 0.852 0.050 0.492 0.252 0.671 24.7 69de191bddbfbf53348c7638bdba661b6d45d257c00a8a2646b2558670c5a4c2 clean
@@ -0,0 +1,5 @@
{
"ChatGPT Image May 31, 2026, 02_02_23 PM.png": "This is a longer sample text in English.\nTypography can flow smoothly from large to medium size.\nSmaller lines help demonstrate hierarchy, rhythm, and clarity.\nEven the finest text should remain clean and readable.\n这是一段较长的中文示例文本。\n排版可以从大字号逐渐过渡到中字号。\n更小的文字能够展示层次、节奏与清晰度。\n即使是最小的一行,也应该保持清楚易读。\nЭто более длинный пример текста на русском языке.\nТипографика может плавно переходить от крупного размера к среднему.\nБолее мелкие строки показывают иерархию, ритм и ясность.\nДаже самый маленький текст должен оставаться чистым и читаемым.",
"ChatGPT Image May 31, 2026, 02_03_55 PM.png": "This is a longer sample text in English.\nTypography can flow smoothly from large to medium size.\nSmaller lines help demonstrate hierarchy, rhythm, and clarity.\nEven the finest text should remain clean and readable.\n这是一段较长的中文示例文本。\n排版可以从大字号逐渐过渡到中字号。\n更小的文字能够展示层次、节奏与清晰度。\n即使是最小的一行,也应该保持清楚易读。\nЭто более длинный пример текста на русском языке.\nТипографика может плавно переходить от крупного размера к среднему.\nБолее мелкие строки показывают иерархию, ритм и ясность.\nДаже самый маленький текст должен оставаться чистым и читаемым.",
"Gemini_Generated_Image_633uuy633uuy633u.png": "每天都是一个新的机会。\n用微笑开始它。\n世界也会向你微笑。"
}
@@ -0,0 +1,14 @@
date,source_commit,fixture,variant,ocr_route,text_cer,img_lpips,ssim,psnr_db,mask_fraction,output_sha256,provider_oracle,oracle_control
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage,"Paddle en+ru+ch",0.378,0.107,0.832,24.4,,bed68cf79a023cad582a11b41c4c2962bd90a218037ce931a2b3a8697e3bf4bd,no_openai_signals_detected,source-positive control established earlier
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage-text-restored,"Paddle en+ru+ch",0.048,0.174,0.703,13.9,0.154,3221b2694f39420d509dd37cdaf69581432f9a189e87397346de0c78762402c6,no_openai_signals_detected,qwen-zimage clean in same run
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage-text-selective,"Paddle en+ru+ch",0.101,0.120,0.796,19.5,0.054799,15b40b8cdf9e97558c66d4acdcb97d46769e64f04f143f8061746bc731fbbc21,no_openai_signals_detected,source detected in same browser sequence
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage-text-selective-expanded-mask,"Paddle en+ru+ch",0.081,0.119,0.796,19.5,0.070267,5a8a0bcbe5763d1b1af0d64149cec7e35b16c5751e995d3bacf17581d64177ec,not_checked,fidelity-only follow-up
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage-text-selective-automatic,"Paddle detector en+ru+ch; padded recognizer selection",0.123,0.113,0.808,21.2,0.041279,900def5a433c039aa474789eddc4dc4c63b8126ba1aff6829d2e8d600b9f9c71,no_openai_signals_detected,source detected immediately afterward in the same Chrome sequence
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage,"Paddle en+ru+ch",0.413,0.095,0.818,26.0,,83e777ad28bf80a8e5aa7d2e6ef2348337c667212e2c71075460352ad7423713,no_openai_signals_detected,source-positive control established earlier
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage-text-restored,"Paddle en+ru+ch",0.086,0.162,0.682,14.6,0.170,95812399424feb70fc56d3e518d1ae0a3a50a3c2049d5902b00686ede9d3022d,no_openai_signals_detected,qwen-zimage clean in same run
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage-text-selective,"Paddle en+ru+ch",0.112,0.103,0.780,20.8,0.054912,846f39db0ad4a48a734a5019f0b0ff5d110eecb3210223407e72529dfa4fcf54,no_openai_signals_detected,source detected in same browser sequence
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage-text-selective-expanded-mask,"Paddle en+ru+ch",0.147,0.104,0.779,20.8,0.072556,8ae76e1dedeec26bebb8d35e71fd92d77f142dbb0f3440b330cdf33b79558afe,not_checked,fidelity-only follow-up
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage-text-selective-automatic,"Paddle detector en+ru+ch; padded recognizer selection",0.119,0.104,0.779,20.8,0.057110,ac4201bd2e342b1a37737319438cedb2203f6bb58cd9113f825cf9b80d913755,no_openai_signals_detected,source detected in same browser sequence
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage-text-selective-automatic-passthrough,"Paddle detector ch; horizontal-only padded recognizer selection",0.074,0.289,0.696,23.3,0.000000,4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621a6,not_checked,all three source and candidate recognitions agreed exactly; output is byte-identical to candidate
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage-text-selective-detected-boxes,"Paddle ch detector boxes; 0.10-height recognizer padding",0.127,0.113,0.805,20.9,0.041280,70c52fed8b8a1d98f4cda8558ae08825313e510dfb372e448370d8dcf1da73b0,not_checked,fidelity-only automatic-box follow-up; same 8/20 preserve decisions
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage-text-selective-detected-boxes,"Paddle ch detector boxes; 0.10-height recognizer padding",0.154,0.104,0.778,20.8,0.057146,1fa01ce706042c003a61429d5d97fc6c928ec32278071a91926d588f1e760b00,not_checked,fidelity-only automatic-box follow-up; same 7/20 preserve decisions but worse CER
1 date source_commit fixture variant ocr_route text_cer img_lpips ssim psnr_db mask_fraction output_sha256 provider_oracle oracle_control
2 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png qwen-zimage Paddle en+ru+ch 0.378 0.107 0.832 24.4 bed68cf79a023cad582a11b41c4c2962bd90a218037ce931a2b3a8697e3bf4bd no_openai_signals_detected source-positive control established earlier
3 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png qwen-zimage-text-restored Paddle en+ru+ch 0.048 0.174 0.703 13.9 0.154 3221b2694f39420d509dd37cdaf69581432f9a189e87397346de0c78762402c6 no_openai_signals_detected qwen-zimage clean in same run
4 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png qwen-zimage-text-selective Paddle en+ru+ch 0.101 0.120 0.796 19.5 0.054799 15b40b8cdf9e97558c66d4acdcb97d46769e64f04f143f8061746bc731fbbc21 no_openai_signals_detected source detected in same browser sequence
5 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png qwen-zimage-text-selective-expanded-mask Paddle en+ru+ch 0.081 0.119 0.796 19.5 0.070267 5a8a0bcbe5763d1b1af0d64149cec7e35b16c5751e995d3bacf17581d64177ec not_checked fidelity-only follow-up
6 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png qwen-zimage-text-selective-automatic Paddle detector en+ru+ch; padded recognizer selection 0.123 0.113 0.808 21.2 0.041279 900def5a433c039aa474789eddc4dc4c63b8126ba1aff6829d2e8d600b9f9c71 no_openai_signals_detected source detected immediately afterward in the same Chrome sequence
7 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png qwen-zimage Paddle en+ru+ch 0.413 0.095 0.818 26.0 83e777ad28bf80a8e5aa7d2e6ef2348337c667212e2c71075460352ad7423713 no_openai_signals_detected source-positive control established earlier
8 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png qwen-zimage-text-restored Paddle en+ru+ch 0.086 0.162 0.682 14.6 0.170 95812399424feb70fc56d3e518d1ae0a3a50a3c2049d5902b00686ede9d3022d no_openai_signals_detected qwen-zimage clean in same run
9 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png qwen-zimage-text-selective Paddle en+ru+ch 0.112 0.103 0.780 20.8 0.054912 846f39db0ad4a48a734a5019f0b0ff5d110eecb3210223407e72529dfa4fcf54 no_openai_signals_detected source detected in same browser sequence
10 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png qwen-zimage-text-selective-expanded-mask Paddle en+ru+ch 0.147 0.104 0.779 20.8 0.072556 8ae76e1dedeec26bebb8d35e71fd92d77f142dbb0f3440b330cdf33b79558afe not_checked fidelity-only follow-up
11 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png qwen-zimage-text-selective-automatic Paddle detector en+ru+ch; padded recognizer selection 0.119 0.104 0.779 20.8 0.057110 ac4201bd2e342b1a37737319438cedb2203f6bb58cd9113f825cf9b80d913755 no_openai_signals_detected source detected in same browser sequence
12 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png qwen-zimage-text-selective-automatic-passthrough Paddle detector ch; horizontal-only padded recognizer selection 0.074 0.289 0.696 23.3 0.000000 4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621a6 not_checked all three source and candidate recognitions agreed exactly; output is byte-identical to candidate
13 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png qwen-zimage-text-selective-detected-boxes Paddle ch detector boxes; 0.10-height recognizer padding 0.127 0.113 0.805 20.9 0.041280 70c52fed8b8a1d98f4cda8558ae08825313e510dfb372e448370d8dcf1da73b0 not_checked fidelity-only automatic-box follow-up; same 8/20 preserve decisions
14 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png qwen-zimage-text-selective-detected-boxes Paddle ch detector boxes; 0.10-height recognizer padding 0.154 0.104 0.778 20.8 0.057146 1fa01ce706042c003a61429d5d97fc6c928ec32278071a91926d588f1e760b00 not_checked fidelity-only automatic-box follow-up; same 7/20 preserve decisions but worse CER
+51
View File
@@ -0,0 +1,51 @@
{
"ChatGPT Image May 31, 2026, 02_02_23 PM.png": [
{"box": [156, 206, 709, 296], "text": "This is a longer", "script": "alphabetic"},
{"box": [157, 293, 939, 379], "text": "sample text in English.", "script": "alphabetic"},
{"box": [165, 393, 774, 434], "text": "Typography can flow smoothly from", "script": "alphabetic"},
{"box": [161, 434, 521, 477], "text": "large to medium size.", "script": "alphabetic"},
{"box": [165, 495, 566, 522], "text": "Smaller lines help demonstrate hierarchy,", "script": "alphabetic"},
{"box": [165, 524, 354, 550], "text": "rhythm, and clarity.", "script": "alphabetic"},
{"box": [165, 564, 448, 585], "text": "Even the finest text should remain", "script": "alphabetic"},
{"box": [165, 593, 330, 613], "text": "clean and readable.", "script": "alphabetic"},
{"box": [158, 673, 903, 740], "text": "这是一段较长的中文示例文本。", "script": "cjk"},
{"box": [162, 753, 697, 793], "text": "排版可以从大字号逐渐过渡到中字号。", "script": "cjk"},
{"box": [164, 807, 524, 829], "text": "更小的文字能够展示层次、节奏与清晰度。", "script": "cjk"},
{"box": [163, 845, 483, 868], "text": "即使是最小的一行,也应该保持清楚易读。", "script": "cjk"},
{"box": [162, 951, 780, 1009], "text": "Это более длинный пример", "script": "alphabetic"},
{"box": [161, 1019, 723, 1062], "text": "текста на русском языке.", "script": "alphabetic"},
{"box": [165, 1076, 639, 1104], "text": "Типографика может плавно переходить от", "script": "alphabetic"},
{"box": [165, 1111, 505, 1133], "text": "крупного размера к среднему.", "script": "alphabetic"},
{"box": [165, 1151, 513, 1172], "text": "Более мелкие строки показывают иерархию,", "script": "alphabetic"},
{"box": [167, 1178, 287, 1192], "text": "ритм и ясность.", "script": "alphabetic"},
{"box": [165, 1210, 417, 1227], "text": "Даже самый маленький текст должен", "script": "alphabetic"},
{"box": [165, 1235, 381, 1247], "text": "оставаться чистым и читаемым.", "script": "alphabetic"}
],
"ChatGPT Image May 31, 2026, 02_03_55 PM.png": [
{"box": [165, 204, 754, 299], "text": "This is a longer", "script": "alphabetic"},
{"box": [167, 295, 1006, 385], "text": "sample text in English.", "script": "alphabetic"},
{"box": [171, 401, 831, 446], "text": "Typography can flow smoothly from", "script": "alphabetic"},
{"box": [169, 446, 560, 491], "text": "large to medium size.", "script": "alphabetic"},
{"box": [173, 507, 605, 536], "text": "Smaller lines help demonstrate hierarchy,", "script": "alphabetic"},
{"box": [173, 538, 379, 567], "text": "rhythm, and clarity.", "script": "alphabetic"},
{"box": [175, 583, 481, 603], "text": "Even the finest text should remain", "script": "alphabetic"},
{"box": [173, 611, 352, 632], "text": "clean and readable.", "script": "alphabetic"},
{"box": [170, 693, 956, 757], "text": "这是一段较长的中文示例文本。", "script": "cjk"},
{"box": [174, 775, 740, 812], "text": "排版可以从大字号逐渐过渡到中字号。", "script": "cjk"},
{"box": [175, 829, 561, 852], "text": "更小的文字能够展示层次、节奏与清晰度。", "script": "cjk"},
{"box": [174, 869, 534, 892], "text": "即使是最小的一行,也应该保持清楚易读。", "script": "cjk"},
{"box": [169, 958, 824, 1022], "text": "Это более длинный пример", "script": "alphabetic"},
{"box": [171, 1029, 764, 1074], "text": "текста на русском языке.", "script": "alphabetic"},
{"box": [175, 1090, 678, 1119], "text": "Типографика может плавно переходить от", "script": "alphabetic"},
{"box": [175, 1125, 546, 1151], "text": "крупного размера к среднему.", "script": "alphabetic"},
{"box": [175, 1169, 552, 1192], "text": "Более мелкие строки показывают иерархию,", "script": "alphabetic"},
{"box": [175, 1196, 312, 1217], "text": "ритм и ясность.", "script": "alphabetic"},
{"box": [175, 1233, 479, 1253], "text": "Даже самый маленький текст должен", "script": "alphabetic"},
{"box": [177, 1259, 440, 1272], "text": "оставаться чистым и читаемым.", "script": "alphabetic"}
],
"Gemini_Generated_Image_633uuy633uuy633u.png": [
{"box": [1281, 650, 2357, 818], "text": "每天都是一个新的机会。", "script": "cjk"},
{"box": [1466, 788, 2167, 957], "text": "用微笑开始它。", "script": "cjk"},
{"box": [1367, 910, 2264, 1112], "text": "世界也会向你微笑。", "script": "cjk"}
]
}
@@ -0,0 +1,25 @@
date,source_commit,fixture,profile,route,seed,strength,adaptive_polish,text_cer,ocr_floor,added_cer,img_lpips,ssim,psnr_db,output_sha256,oracle_rechecked
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",original,reference,,,,0.127,0.127,0.000,0.000,1.000,inf,,not_applicable
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage,visible-invisible-metadata,0,0.11020972541176471,false,0.378,0.127,0.251,0.107,0.832,24.4,bed68cf79a023cad582a11b41c4c2962bd90a218037ce931a2b3a8697e3bf4bd,false
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",sdxl-zimage,visible-invisible-metadata,0,0.15,true,0.356,0.127,0.229,0.125,0.707,25.3,9bb932d6e66b52f34ff07c3fcdbdb9c33eb601ae2cd84bec6b8c7b5e30df9926,false
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",original,reference,,,,0.127,0.127,0.000,0.000,1.000,inf,,not_applicable
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage,visible-invisible-metadata,0,0.11020972541176471,false,0.413,0.127,0.286,0.095,0.818,26.0,83e777ad28bf80a8e5aa7d2e6ef2348337c667212e2c71075460352ad7423713,false
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",sdxl-zimage,visible-invisible-metadata,0,0.15,true,0.422,0.127,0.295,0.099,0.754,25.9,7605b1175b0c99f481ae6929acc488f34d98bc0f2571d42a1d2972b671d9b8cb,false
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,original,reference,,,,0.037,0.037,0.000,0.000,1.000,inf,,not_applicable
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage,visible-invisible-metadata,0,0.153999996,false,0.074,0.037,0.037,0.289,0.696,23.3,4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621a6,false
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,sdxl-zimage,visible-invisible-metadata,0,0.25,true,0.074,0.037,0.037,0.176,0.769,26.1,d9f89465c31bd2d38ffb111775b9bbea4577f40713fcbebf421e05d344ab27ad,false
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",zimage-global,visible-invisible-metadata,0,0.08,false,0.068,0.127,-0.059,0.042,0.874,31.4,534de6422a3a03cfde44377aa9bf824a8267db3b73511240f8f07fb8dcb95eff,true
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",zimage-global,visible-invisible-metadata,0,0.10,false,0.086,0.127,-0.041,0.047,0.862,30.8,4dba8211e8d84ebbfff72de00f54d4365d0d202394acd412d1419ae93d1e9f71,false
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",zimage-global,visible-invisible-metadata,0,0.15,false,0.177,0.127,0.050,0.065,0.838,29.1,fc45c5fff6d33f15d30e05dd02580fe0addd97098b735b2894015f9abdaa43a7,false
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",zimage-global,visible-invisible-metadata,0,0.20,false,0.248,0.127,0.121,0.093,0.813,27.3,1e55e11f0e289a752098370e9f1d53fed6cd4fab7ceff46fe86dd20c927f9ffb,false
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",zimage-global,visible-invisible-metadata,0,0.25,false,0.343,0.127,0.216,0.120,0.787,25.7,f5a2d2829e88a790cff5bedc50fca1f878a92cdb392d530afb49d0d95c23b884,false
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",zimage-global,visible-invisible-metadata,0,0.08,false,0.143,0.127,0.016,0.042,0.877,31.5,5cfa466e276ab37cf532f7458d6dd86ea4f0807e2cbdb6788b7c01ca6ee35a1a,true
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",zimage-global,visible-invisible-metadata,0,0.10,false,0.101,0.127,-0.026,0.047,0.864,30.9,ade869ec7c5b08098495d9b9d67ee900773f189f94bbb411b043f242f5c2fa37,true
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",zimage-global,visible-invisible-metadata,0,0.15,false,0.208,0.127,0.081,0.060,0.839,29.3,a11976963e3f2ca856ce66278e22064d51345d449fa6d672029039a79517d60a,false
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",zimage-global,visible-invisible-metadata,0,0.20,false,0.341,0.127,0.214,0.070,0.819,27.6,1ab1ae075565e9d39960a80cf5d61e5b0c50d7e23283493ea0277056b3e964ab,false
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",zimage-global,visible-invisible-metadata,0,0.25,false,0.538,0.127,0.411,0.084,0.799,25.6,5ff5a364d84f738de4c56ead7d4950a2f9a1aaf4bec16e97bda6a65915744a57,false
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,zimage-global,visible-invisible-metadata,0,0.08,false,0.074,0.037,0.037,0.068,0.865,30.8,81419eddc3e5150956859dd10c26dd841bb93d9c8b34c01f344e6d69bca6edbf,true
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,zimage-global,visible-invisible-metadata,0,0.10,false,0.074,0.037,0.037,0.086,0.844,29.8,4d1f32bd0dac0e05885ff943e7c10230b06de7d8547e6215f8e2941f732a1df5,true
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,zimage-global,visible-invisible-metadata,0,0.15,false,0.000,0.037,-0.037,0.135,0.793,27.6,526c7987b34896f9513f7439b63b8a87573b59d15930fa307f3bb924435f3cec,true
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,zimage-global,visible-invisible-metadata,0,0.20,false,0.000,0.037,-0.037,0.179,0.746,25.9,325364cf7a3b5d39ac0c44af82181753ca4d6fde0f1b522c896414191725e8fc,true
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,zimage-global,visible-invisible-metadata,0,0.25,false,0.000,0.037,-0.037,0.218,0.703,24.5,f01d64dc0cabd45d66a93c1be39b819b988f29cda1f0a7bc0d5a1954571d807e,true
1 date source_commit fixture profile route seed strength adaptive_polish text_cer ocr_floor added_cer img_lpips ssim psnr_db output_sha256 oracle_rechecked
2 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png original reference 0.127 0.127 0.000 0.000 1.000 inf not_applicable
3 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png qwen-zimage visible-invisible-metadata 0 0.11020972541176471 false 0.378 0.127 0.251 0.107 0.832 24.4 bed68cf79a023cad582a11b41c4c2962bd90a218037ce931a2b3a8697e3bf4bd false
4 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png sdxl-zimage visible-invisible-metadata 0 0.15 true 0.356 0.127 0.229 0.125 0.707 25.3 9bb932d6e66b52f34ff07c3fcdbdb9c33eb601ae2cd84bec6b8c7b5e30df9926 false
5 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png original reference 0.127 0.127 0.000 0.000 1.000 inf not_applicable
6 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png qwen-zimage visible-invisible-metadata 0 0.11020972541176471 false 0.413 0.127 0.286 0.095 0.818 26.0 83e777ad28bf80a8e5aa7d2e6ef2348337c667212e2c71075460352ad7423713 false
7 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png sdxl-zimage visible-invisible-metadata 0 0.15 true 0.422 0.127 0.295 0.099 0.754 25.9 7605b1175b0c99f481ae6929acc488f34d98bc0f2571d42a1d2972b671d9b8cb false
8 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png original reference 0.037 0.037 0.000 0.000 1.000 inf not_applicable
9 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png qwen-zimage visible-invisible-metadata 0 0.153999996 false 0.074 0.037 0.037 0.289 0.696 23.3 4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621a6 false
10 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png sdxl-zimage visible-invisible-metadata 0 0.25 true 0.074 0.037 0.037 0.176 0.769 26.1 d9f89465c31bd2d38ffb111775b9bbea4577f40713fcbebf421e05d344ab27ad false
11 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png zimage-global visible-invisible-metadata 0 0.08 false 0.068 0.127 -0.059 0.042 0.874 31.4 534de6422a3a03cfde44377aa9bf824a8267db3b73511240f8f07fb8dcb95eff true
12 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png zimage-global visible-invisible-metadata 0 0.10 false 0.086 0.127 -0.041 0.047 0.862 30.8 4dba8211e8d84ebbfff72de00f54d4365d0d202394acd412d1419ae93d1e9f71 false
13 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png zimage-global visible-invisible-metadata 0 0.15 false 0.177 0.127 0.050 0.065 0.838 29.1 fc45c5fff6d33f15d30e05dd02580fe0addd97098b735b2894015f9abdaa43a7 false
14 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png zimage-global visible-invisible-metadata 0 0.20 false 0.248 0.127 0.121 0.093 0.813 27.3 1e55e11f0e289a752098370e9f1d53fed6cd4fab7ceff46fe86dd20c927f9ffb false
15 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png zimage-global visible-invisible-metadata 0 0.25 false 0.343 0.127 0.216 0.120 0.787 25.7 f5a2d2829e88a790cff5bedc50fca1f878a92cdb392d530afb49d0d95c23b884 false
16 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png zimage-global visible-invisible-metadata 0 0.08 false 0.143 0.127 0.016 0.042 0.877 31.5 5cfa466e276ab37cf532f7458d6dd86ea4f0807e2cbdb6788b7c01ca6ee35a1a true
17 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png zimage-global visible-invisible-metadata 0 0.10 false 0.101 0.127 -0.026 0.047 0.864 30.9 ade869ec7c5b08098495d9b9d67ee900773f189f94bbb411b043f242f5c2fa37 true
18 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png zimage-global visible-invisible-metadata 0 0.15 false 0.208 0.127 0.081 0.060 0.839 29.3 a11976963e3f2ca856ce66278e22064d51345d449fa6d672029039a79517d60a false
19 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png zimage-global visible-invisible-metadata 0 0.20 false 0.341 0.127 0.214 0.070 0.819 27.6 1ab1ae075565e9d39960a80cf5d61e5b0c50d7e23283493ea0277056b3e964ab false
20 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png zimage-global visible-invisible-metadata 0 0.25 false 0.538 0.127 0.411 0.084 0.799 25.6 5ff5a364d84f738de4c56ead7d4950a2f9a1aaf4bec16e97bda6a65915744a57 false
21 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png zimage-global visible-invisible-metadata 0 0.08 false 0.074 0.037 0.037 0.068 0.865 30.8 81419eddc3e5150956859dd10c26dd841bb93d9c8b34c01f344e6d69bca6edbf true
22 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png zimage-global visible-invisible-metadata 0 0.10 false 0.074 0.037 0.037 0.086 0.844 29.8 4d1f32bd0dac0e05885ff943e7c10230b06de7d8547e6215f8e2941f732a1df5 true
23 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png zimage-global visible-invisible-metadata 0 0.15 false 0.000 0.037 -0.037 0.135 0.793 27.6 526c7987b34896f9513f7439b63b8a87573b59d15930fa307f3bb924435f3cec true
24 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png zimage-global visible-invisible-metadata 0 0.20 false 0.000 0.037 -0.037 0.179 0.746 25.9 325364cf7a3b5d39ac0c44af82181753ca4d6fde0f1b522c896414191725e8fc true
25 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png zimage-global visible-invisible-metadata 0 0.25 false 0.000 0.037 -0.037 0.218 0.703 24.5 f01d64dc0cabd45d66a93c1be39b819b988f29cda1f0a7bc0d5a1954571d807e true
@@ -0,0 +1,7 @@
date,source_commit,fixture,variant,ocr_route,text_cer,img_lpips,ssim,psnr_db,mask_fraction,output_sha256,provider_oracle,oracle_control
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage,"Vision en+ru; Paddle ch",0.338,0.107,0.832,24.4,,bed68cf79a023cad582a11b41c4c2962bd90a218037ce931a2b3a8697e3bf4bd,no_openai_signals_detected,source-positive control established earlier
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",qwen-zimage-text-restored,"Vision en+ru; Paddle ch",0.007,0.174,0.703,13.9,0.154,3221b2694f39420d509dd37cdaf69581432f9a189e87397346de0c78762402c6,no_openai_signals_detected,qwen-zimage clean in same run
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage,"Vision en+ru; Paddle ch",0.305,0.095,0.818,26.0,,83e777ad28bf80a8e5aa7d2e6ef2348337c667212e2c71075460352ad7423713,no_openai_signals_detected,source-positive control established earlier
2026-08-13,2d03a00,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",qwen-zimage-text-restored,"Vision en+ru; Paddle ch",0.007,0.162,0.682,14.6,0.170,95812399424feb70fc56d3e518d1ae0a3a50a3c2049d5902b00686ede9d3022d,no_openai_signals_detected,qwen-zimage clean in same run
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage,"Paddle ch",0.074,0.289,0.696,23.3,,4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621a6,synthid_detected,inconclusive account: clean baseline not reproduced
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage-text-restored,"Paddle ch",0.111,0.328,0.653,18.1,0.051,2ab7666a4161ff0e12c53d9412b23d013970c7613a8a55a1b1ae8a2744e3290d,synthid_detected,inconclusive account: qwen-zimage control also detected
1 date source_commit fixture variant ocr_route text_cer img_lpips ssim psnr_db mask_fraction output_sha256 provider_oracle oracle_control
2 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png qwen-zimage Vision en+ru; Paddle ch 0.338 0.107 0.832 24.4 bed68cf79a023cad582a11b41c4c2962bd90a218037ce931a2b3a8697e3bf4bd no_openai_signals_detected source-positive control established earlier
3 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_02_23 PM.png qwen-zimage-text-restored Vision en+ru; Paddle ch 0.007 0.174 0.703 13.9 0.154 3221b2694f39420d509dd37cdaf69581432f9a189e87397346de0c78762402c6 no_openai_signals_detected qwen-zimage clean in same run
4 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png qwen-zimage Vision en+ru; Paddle ch 0.305 0.095 0.818 26.0 83e777ad28bf80a8e5aa7d2e6ef2348337c667212e2c71075460352ad7423713 no_openai_signals_detected source-positive control established earlier
5 2026-08-13 2d03a00 ChatGPT Image May 31, 2026, 02_03_55 PM.png qwen-zimage-text-restored Vision en+ru; Paddle ch 0.007 0.162 0.682 14.6 0.170 95812399424feb70fc56d3e518d1ae0a3a50a3c2049d5902b00686ede9d3022d no_openai_signals_detected qwen-zimage clean in same run
6 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png qwen-zimage Paddle ch 0.074 0.289 0.696 23.3 4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621a6 synthid_detected inconclusive account: clean baseline not reproduced
7 2026-08-13 2d03a00 Gemini_Generated_Image_633uuy633uuy633u.png qwen-zimage-text-restored Paddle ch 0.111 0.328 0.653 18.1 0.051 2ab7666a4161ff0e12c53d9412b23d013970c7613a8a55a1b1ae8a2744e3290d synthid_detected inconclusive account: qwen-zimage control also detected
@@ -0,0 +1,6 @@
date,upstream_commit,workflow_sha256,fixture,route,seed,global_strength,face_strength,face_count,id_cos,face_lpips,lapvar_ratio,img_lpips,ssim,psnr_db,output_sha256,gemini_oracle,implementation
2026-08-13,3007d0351596ae0a78b7074dae7ad179710b1e48,41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6,comparison/before/12.png,official-before,,,,10,,,,,,,056276464387c883f5fd89bd2f1415faeb425abb3153cd10efb25d1b9053bdc2,synthid_detected,upstream-published-bytes
2026-08-13,3007d0351596ae0a78b7074dae7ad179710b1e48,41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6,comparison/after/12.png,official-after,,,,10,0.975,0.093,0.354,0.172,0.820,23.8,a822d805666224adb3e360b3ee89e14f20a341f1e7c4d3a5d3d5251e9286aefe,clean,upstream-published-bytes
2026-08-13,3007d0351596ae0a78b7074dae7ad179710b1e48,41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6,Gemini_Generated_Image_y48j3cy48j3cy48j.png,visible-metadata-control,0,,,18,,,,,,,9f67264beb1da66a710885464a11f2ae85989094149f6145521d302cd2db940b,synthid_detected,project-runtime-control
2026-08-13,3007d0351596ae0a78b7074dae7ad179710b1e48,41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6,Gemini_Generated_Image_y48j3cy48j3cy48j.png,upstream-global-close-reproduction,0,0.153999996,,18,0.589,0.217,0.331,0.286,0.647,24.3,281a1d28d786d157cbbd13d00a32b215de4dfb3174a3c5e4e83c843e5402138f,clean,diffsynth-bf16-yunet-sam
2026-08-13,3007d0351596ae0a78b7074dae7ad179710b1e48,41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6,Gemini_Generated_Image_y48j3cy48j3cy48j.png,upstream-full-close-reproduction,0,0.153999996,0.13209025065104166,18,0.783,0.083,0.470,0.260,0.659,24.7,2d7a8f76f98b905f14d69e5f9e11979610c72c6be7b6ac298dec4659f17bd819,clean,diffsynth-bf16-yunet-sam
1 date upstream_commit workflow_sha256 fixture route seed global_strength face_strength face_count id_cos face_lpips lapvar_ratio img_lpips ssim psnr_db output_sha256 gemini_oracle implementation
2 2026-08-13 3007d0351596ae0a78b7074dae7ad179710b1e48 41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6 comparison/before/12.png official-before 10 056276464387c883f5fd89bd2f1415faeb425abb3153cd10efb25d1b9053bdc2 synthid_detected upstream-published-bytes
3 2026-08-13 3007d0351596ae0a78b7074dae7ad179710b1e48 41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6 comparison/after/12.png official-after 10 0.975 0.093 0.354 0.172 0.820 23.8 a822d805666224adb3e360b3ee89e14f20a341f1e7c4d3a5d3d5251e9286aefe clean upstream-published-bytes
4 2026-08-13 3007d0351596ae0a78b7074dae7ad179710b1e48 41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6 Gemini_Generated_Image_y48j3cy48j3cy48j.png visible-metadata-control 0 18 9f67264beb1da66a710885464a11f2ae85989094149f6145521d302cd2db940b synthid_detected project-runtime-control
5 2026-08-13 3007d0351596ae0a78b7074dae7ad179710b1e48 41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6 Gemini_Generated_Image_y48j3cy48j3cy48j.png upstream-global-close-reproduction 0 0.153999996 18 0.589 0.217 0.331 0.286 0.647 24.3 281a1d28d786d157cbbd13d00a32b215de4dfb3174a3c5e4e83c843e5402138f clean diffsynth-bf16-yunet-sam
6 2026-08-13 3007d0351596ae0a78b7074dae7ad179710b1e48 41911b3b8e01bf51450361dc8beccd84c9513f78ddb160430cdd9bebc294adf6 Gemini_Generated_Image_y48j3cy48j3cy48j.png upstream-full-close-reproduction 0 0.153999996 0.13209025065104166 18 0.783 0.083 0.470 0.260 0.659 24.7 2d7a8f76f98b905f14d69e5f9e11979610c72c6be7b6ac298dec4659f17bd819 clean diffsynth-bf16-yunet-sam
@@ -0,0 +1,3 @@
date,source_url,source_sha256,source_width,source_height,source_fps,duration_seconds,source_verdict,vae,noise_std,long_side,fps,seed,output_sha256,output_verdict,output_verdict_text,output_detected_range,track,session_id,stratum,psnr_db,temporal_residual_ratio
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,1920,1080,24,8,detected_all_frames,,0.10,512,12,0,079165105d4c56e1612091987c08c2627049423025f74c0d4e245fb47c2ff0e3,detected,,,,,,26.2932,1.0072
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,1920,1080,24,8,detected_all_frames,,0.15,512,12,0,1c4046bcfdead138353b4e2a73339ba227bb5e544878d80c5bc6cd8427c7b00e,not_detected,,,,,,25.3911,1.0578
1 date source_url source_sha256 source_width source_height source_fps duration_seconds source_verdict vae noise_std long_side fps seed output_sha256 output_verdict output_verdict_text output_detected_range track session_id stratum psnr_db temporal_residual_ratio
2 2026-07-31 https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4 79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d 1920 1080 24 8 detected_all_frames 0.10 512 12 0 079165105d4c56e1612091987c08c2627049423025f74c0d4e245fb47c2ff0e3 detected 26.2932 1.0072
3 2026-07-31 https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4 79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d 1920 1080 24 8 detected_all_frames 0.15 512 12 0 1c4046bcfdead138353b4e2a73339ba227bb5e544878d80c5bc6cd8427c7b00e not_detected 25.3911 1.0578
+15
View File
@@ -0,0 +1,15 @@
# Provenance fixtures
These files exercise real container, metadata, and pixel-provenance formats.
They are test inputs, not evaluation corpora.
## Adobe TrustMark
`adobe-trustmark-p.png` is Adobe's official `images/ghost_P.png` Variant P
example from [`adobe/trustmark`](https://github.com/adobe/trustmark) at commit
`0ed40cbe8188f664fd9cbbeacd969807de27440a`.
- Source: `https://raw.githubusercontent.com/adobe/trustmark/0ed40cbe8188f664fd9cbbeacd969807de27440a/images/ghost_P.png`
- SHA-256: `e58c5825ed7e5d9fb04710ea541b61bd55879cad65554c0d46260aa24b3d0755`
- Expected signal: TrustMark Variant P, schema 1
- License: MIT, reproduced in `../licenses/adobe-trustmark-MIT.txt`
Binary file not shown.

After

Width:  |  Height:  |  Size: 360 KiB

File renamed without changes.
File renamed without changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 5.1 MiB

File renamed without changes.
Binary file not shown.

After

Width:  |  Height:  |  Size: 495 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 356 KiB

File renamed without changes.
+25
View File
@@ -0,0 +1,25 @@
Copyright 2023 Adobe
All Rights Reserved.
NOTICE: Adobe permits you to use, modify, and distribute this file in
accordance with the terms of the license agreement accompanying it.
MIT License
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.2 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.6 MiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 KiB

-41
View File
@@ -1,41 +0,0 @@
# OpenAI ChatGPT Images 2.0 sample
Reference image generated by OpenAI's ChatGPT Images 2.0 (`gpt-image-2`, launched 2026-04-21). Kept in the repo so the C2PA manifest parser and invisible-watermark pipeline can be re-verified against a real production output.
## `amur-leopard.png`
- Resolution: 1055 x 1491, PNG, 3.0 MB
- Downloaded: 2026-04-22
- Content: AI-generated infographic about the Amur leopard (*Panthera pardus orientalis*), chosen to exercise the model's new accurate-text-rendering feature
### Embedded C2PA manifest (caBX chunk, 23607 bytes)
Parsed by `remove-ai-watermarks metadata --check`:
| Field | Value |
| --- | --- |
| `claim_generator` | GPT-4o |
| `c2pa_spec` | 2.2.0 |
| `digital_source_type` | http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia |
| `c2pa_actions` | created, converted |
| Signer | OpenAI OpCo, LLC / OpenAI Media Service API |
| Claim signing CA | Trufo C2PA Claim Signing CA (2025) -> Trufo C2PA Root CA (ECC P384) |
| Timestamp authority | OpenAI TSA Issuing CA -> OpenAI TSA Root CA |
The `trainedAlgorithmicMedia` tag is what triggers "Made with AI" labels on Instagram, Facebook, and X.
### Invisible pixel-level watermark
OpenAI's system card for Images 2.0 states the model embeds an "imperceptible, robust, and content-specific" pixel-level watermark alongside C2PA. No public detector exists, so bypass cannot be verified empirically for this sample.
## Reproducing the removal
```bash
# C2PA strip only
remove-ai-watermarks metadata amur-leopard.png --remove -o amur-leopard.clean.png
# Full pipeline (visible + diffusion regeneration + metadata)
remove-ai-watermarks all amur-leopard.png -o amur-leopard.all.png --device mps
```
Note: the diffusion step downscales to 768 px, which degrades fine text on text-heavy infographics like this one. Tracked as a known limitation; see project TODO.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 MiB

+166
View File
@@ -0,0 +1,166 @@
# SynthID oracle fixtures
A compact set of externally verified originals for two uses:
1. **Signal and detector-limit research** with exact native resolutions and
recorded verification sources.
2. **Removal regression set**: verify that a pipeline turns a SynthID-positive
image into a negative one.
There is no reliable local detector of the SynthID pixel watermark (Google's
decoder is proprietary). The ground-truth label therefore comes from an
external oracle, recorded per image in `verified_via` (see below).
## Layout
```
data/synthid/
README.md # collection and verification protocol
manifest.csv # one row per committed original
full-pipeline-quality.csv # reusable evaluation selection
originals/ # one canonical copy of each verified source
```
The originals are committed so the evaluation set is reproducible and its
hashes can be checked in CI. `manifest.csv` is kept in sync with
`originals/`, one row per file. Generated outputs do not belong here. Store
them outside the repository and record only a reproducible verdict in the
evaluation manifest or documentation.
## Reusable removal-quality set
`full-pipeline-quality.csv` is the canonical reusable input set for
full-pipeline visual-quality and watermark-removal tests. Its fixtures are
cleared for permanent public test reuse. Tests, benchmarks, and manual
evaluation bundles may read these images repeatedly without requesting new
permission.
The CSV preserves each platform's original filename and maps it to the one
canonical corpus copy. Use `corpus_path` to read the input, but preserve
`source_filename` in generated result names and keep OpenAI and Gemini outputs
in separate provider groups. This avoids duplicating large PNGs while retaining
the names needed for the corresponding provider oracle.
Rows with a dated negative `final_clean_oracle` were checked after the complete
`visible -> qwen-zimage -> metadata` route with the matching provider oracle.
`not-checked-in-final-candidate` means the input is available as an additional
quality stress fixture, but a newly generated output still needs the matching
oracle before claiming removal. The checked result applies to those exact
output bytes; re-run the oracle after changing the pipeline, seed, model
versions, or runtime settings.
## Verification levels (`verified_via`)
Ground-truth quality, strongest first:
- `gemini-app` — checked via the Gemini app "Verify with SynthID" feature. Gold standard for the pixel watermark (Google models).
- `openai-verify` — checked via openai.com/verify (gold standard for OpenAI ChatGPT/Codex/API images).
- `synthid-portal` — checked via Google's SynthID Detector portal.
- `c2pa-metadata` — supported provenance evidence (Google AI C2PA, or OpenAI
C2PA with an explicit `c2pa.watermarked.*` action). Weaker than a provider
oracle: C2PA can be stripped while the pixel watermark remains.
- `third-party` — label asserted by an external dataset, not independently verified.
- `none` — unverified.
Prefer `gemini-app` for any Google image that will gate a test.
## What to collect
For the **regression set**:
- A small number of positive originals with a recorded provider oracle.
- A quality-set row for every original used by repeatable evaluation.
- Re-run the provider oracle on each newly generated output. Do not commit the
generated output as another corpus copy.
The corpus is committed to a public repo: review every image before adding it
and keep out anything private or identifiable you would not publish.
## Ingesting
Use `scripts/synthid_corpus.py` to copy a file into `originals/`, record its
SHA-256, resolution, format, and C2PA issuer through our detector, and append a
row to `manifest.csv`:
```bash
uv run python scripts/synthid_corpus.py ingest path/to/*.png \
--label pos --source "Gemini app" --model gemini-3-pro \
--verified-via gemini-app --notes "1024x1024 batch"
uv run python scripts/synthid_corpus.py status # counts by label / resolution / verification
```
## Autonomous collection via Chrome MCP
> Historical collection notes. Browser interfaces and download behavior can
> change independently of this repository. These steps do not define package
> behavior.
Generation can be driven through the browser (the account must be logged in):
- **Gemini** (`gemini.google.com`): type `Create an image: <prompt>`, wait, hover the
result, click the download icon (top-right). Single, reliable click. Outputs
carry Google C2PA + SynthID. Occasionally the composer stalls in a
"generating" state -> start a New chat to reset.
- **ChatGPT** (`chatgpt.com`): the UI download is flaky (the fullscreen viewer
races and can grab the previous image; the share-modal path works but is
multi-step). Reliable path is an in-page fetch of the rendered image, which
preserves the original bytes (C2PA intact, unlike a canvas re-encode):
```js
// run in the ChatGPT tab via the browser MCP javascript tool
(async () => {
const imgs = [...document.querySelectorAll('img')].filter(i => i.naturalWidth >= 400);
const img = imgs[imgs.length - 1]; // newest large image
const b = await (await fetch(img.currentSrc || img.src)).blob();
const a = document.createElement('a');
a.href = URL.createObjectURL(b); a.download = 'dl.png';
document.body.appendChild(a); a.click(); a.remove();
return 'size=' + b.size; // do NOT return the src (privacy guard blocks query strings)
})()
```
Gotcha: confirm the returned `size` differs from the previous image before
ingesting -- if the new image has not finished rendering, the script grabs the
prior one (the corpus dedups by sha256, but the notes would mislabel it).
ChatGPT also shows an A/B "which is better?" picker; click Skip first.
**Originals, not previews.** Some platforms render a low-res preview in the chat
(Grok serves a ~20KB 1024px JPEG/PNG; the in-page `<img>` fetch grabs *that*, not
the original). Previews are re-encoded and **strip metadata**, so a "clean"
preview is not proof the original is clean. Always pull the original via the
platform's native Download / lightbox button and sanity-check the file size (a
20KB "1024x1024" is a preview). ChatGPT's in-chat `<img>` *is* the full-res
oaiusercontent original, so fetch+blob is fine there; Grok needs the lightbox
Download; Leonardo serves the original JPEG in-chat (download button matches).
## Per-platform watermark map (observed, May 2026)
This is a dated observation table, not a live provider compatibility promise.
The detector's coverage is complementary: metadata catches C2PA /
IPTC; `exif_generator` catches EXIF `Make`/`Software` + XMP `CreatorTool`;
`invisible_watermark.py` (imwatermark) catches the open SD/SDXL/FLUX DWT-DCT
watermark on pristine files; the visible detector catches the Gemini-family
sparkle; the SynthID *pixel* itself has no local detector (oracle only).
| Platform | C2PA issuer | SynthID pixel | IPTC "Made with AI" | Visible sparkle | imwatermark | Corpus label |
|---|---|---|---|---|---|---|
| Gemini app | Google | yes | - | yes | - | pos |
| ChatGPT / gpt-image | OpenAI | yes | - | - | - | pos |
| Microsoft Designer | OpenAI + Microsoft | yes (via OpenAI) | - | - | - | pos |
| Bing Image Creator | Microsoft (MAI-Image) | no | - | - | - | pos (C2PA "Microsoft", not OpenAI) |
| Google AI Studio (Nano Banana) | **none** | yes (oracle-confirmed) | - | yes | - | pos (metadata blind spot) |
| Stability AI (Brand Studio) | Stability AI Ltd | no | - | - | no | pos (C2PA only) |
| Ideogram | none | no | - | - | no | pos (EXIF `Make="Ideogram AI"` only) |
| Meta AI | none | no | **yes** | - | - | neg (for SynthID) |
| Leonardo.ai | none | no | no | - | no | neg |
| Recraft | none (export strips) | no | no | - | no | neg (re-encoded export, no signal) |
| Krea (FLUX 2 host) | none | no | no | - | no | neg (host omits the imwatermark encoder) |
| Grok (xAI) | none (non-adopter) | no | no | - | no | neg (captured: clean low-res preview) |
Key takeaways:
- The same model differs by *surface*: Gemini app wraps C2PA, AI Studio (API/playground) emits none -- only the pixel + sparkle survive.
- Microsoft Designer's DALL-E backend inherits OpenAI's C2PA+SynthID (issuer "OpenAI, Microsoft"); Bing now runs Microsoft's own **MAI-Image** and signs C2PA as "Microsoft" (not OpenAI/DALL-E).
- Meta uses the IPTC `digitalSourceType` marker, not C2PA or SynthID.
- The open imwatermark fires only on *pristine* output from a pipeline that runs the encoder (diffusers default, official BFL) -- not from re-hosts (Krea, Stability hosted SDXL) or re-encoded design exports (Recraft, Canva). Ideogram's only signal is the EXIF `Make` tag.
- Bing and Grok web UIs are uncooperative for autonomous capture (no document_idle for screenshots; blob downloads intermittently no-op; low-res in-chat previews). Use their native download button manually if a full-res sample is needed.
+8
View File
@@ -0,0 +1,8 @@
provider,source_filename,corpus_path,sha256,original_signal_verified_via,final_clean_oracle
openai,"ChatGPT Image May 30, 2026, 10_31_08 AM.png","originals/ChatGPT Image May 30, 2026, 10_31_08 AM.png",28ff8732b037f98a4ef5bc277bbcdaa32e5eb9ccbd00b6c8c616e46ef68ae8a0,openai-verify,openai-verify-negative-2026-07-25
openai,"ChatGPT Image May 31, 2026, 02_02_23 PM.png","originals/ChatGPT Image May 31, 2026, 02_02_23 PM.png",228e54413a3ef62b313176dcdba774a56a58c36b54227d1440bc26cbe0aefd82,c2pa-metadata,not-checked-in-final-candidate
openai,"ChatGPT Image May 31, 2026, 02_03_55 PM.png","originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png",05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e,openai-verify,openai-verify-negative-2026-07-25
gemini,"Gemini_Generated_Image_3mc4t93mc4t93mc4.png","originals/Gemini_Generated_Image_3mc4t93mc4t93mc4.png",2c33e75a2db614ce74c83cc0a6ac6c3ac735aca83ab88c9c9345843b124f7856,gemini-app,gemini-app-negative-2026-07-25
gemini,"Gemini_Generated_Image_633uuy633uuy633u.png","originals/Gemini_Generated_Image_633uuy633uuy633u.png",4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236,gemini-app,gemini-app-negative-2026-07-25
gemini,"Gemini_Generated_Image_akdbeiakdbeiakdb.png","originals/Gemini_Generated_Image_akdbeiakdbeiakdb.png",8c1a6fb03ef3d45a1f958fb3401e4264e409ff88c2a793061db7f29023454d0e,gemini-app,gemini-app-negative-2026-07-25
gemini,"Gemini_Generated_Image_y48j3cy48j3cy48j.png","originals/Gemini_Generated_Image_y48j3cy48j3cy48j.png",45d79a683134fcba1b147b2aedb669783d474e1fb8a4df329729a0904fd1b46b,gemini-app,gemini-app-negative-2026-07-25
1 provider source_filename corpus_path sha256 original_signal_verified_via final_clean_oracle
2 openai ChatGPT Image May 30, 2026, 10_31_08 AM.png originals/ChatGPT Image May 30, 2026, 10_31_08 AM.png 28ff8732b037f98a4ef5bc277bbcdaa32e5eb9ccbd00b6c8c616e46ef68ae8a0 openai-verify openai-verify-negative-2026-07-25
3 openai ChatGPT Image May 31, 2026, 02_02_23 PM.png originals/ChatGPT Image May 31, 2026, 02_02_23 PM.png 228e54413a3ef62b313176dcdba774a56a58c36b54227d1440bc26cbe0aefd82 c2pa-metadata not-checked-in-final-candidate
4 openai ChatGPT Image May 31, 2026, 02_03_55 PM.png originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png 05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e openai-verify openai-verify-negative-2026-07-25
5 gemini Gemini_Generated_Image_3mc4t93mc4t93mc4.png originals/Gemini_Generated_Image_3mc4t93mc4t93mc4.png 2c33e75a2db614ce74c83cc0a6ac6c3ac735aca83ab88c9c9345843b124f7856 gemini-app gemini-app-negative-2026-07-25
6 gemini Gemini_Generated_Image_633uuy633uuy633u.png originals/Gemini_Generated_Image_633uuy633uuy633u.png 4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236 gemini-app gemini-app-negative-2026-07-25
7 gemini Gemini_Generated_Image_akdbeiakdbeiakdb.png originals/Gemini_Generated_Image_akdbeiakdbeiakdb.png 8c1a6fb03ef3d45a1f958fb3401e4264e409ff88c2a793061db7f29023454d0e gemini-app gemini-app-negative-2026-07-25
8 gemini Gemini_Generated_Image_y48j3cy48j3cy48j.png originals/Gemini_Generated_Image_y48j3cy48j3cy48j.png 45d79a683134fcba1b147b2aedb669783d474e1fb8a4df329729a0904fd1b46b gemini-app gemini-app-negative-2026-07-25
+8
View File
@@ -0,0 +1,8 @@
sha256,filename,label,source,model,width,height,format,c2pa_issuer,synthid_metadata,verified_via,added,notes
05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e,"ChatGPT Image May 31, 2026, 02_03_55 PM.png",pos,ChatGPT,gpt-image,1122,1402,png,OpenAI,yes,openai-verify,2026-06-04T00:07:53Z,June 2026 strength-study subject; openai.com/verify: SynthID detected (docs/synthid.md 2.2)
28ff8732b037f98a4ef5bc277bbcdaa32e5eb9ccbd00b6c8c616e46ef68ae8a0,"ChatGPT Image May 30, 2026, 10_31_08 AM.png",pos,ChatGPT,gpt-image,1448,1086,png,OpenAI,yes,openai-verify,2026-06-04T00:07:53Z,June 2026 strength-study subject; openai.com/verify: SynthID detected (docs/synthid.md 2.2)
4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236,Gemini_Generated_Image_633uuy633uuy633u.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2)
8c1a6fb03ef3d45a1f958fb3401e4264e409ff88c2a793061db7f29023454d0e,Gemini_Generated_Image_akdbeiakdbeiakdb.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2)
45d79a683134fcba1b147b2aedb669783d474e1fb8a4df329729a0904fd1b46b,Gemini_Generated_Image_y48j3cy48j3cy48j.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2)
2c33e75a2db614ce74c83cc0a6ac6c3ac735aca83ab88c9c9345843b124f7856,Gemini_Generated_Image_3mc4t93mc4t93mc4.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,gemini-app,2026-06-04T00:08:05Z,June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2)
228e54413a3ef62b313176dcdba774a56a58c36b54227d1440bc26cbe0aefd82,"ChatGPT Image May 31, 2026, 02_02_23 PM.png",pos,ChatGPT,gpt-image,1122,1402,png,OpenAI,yes,c2pa-metadata,2026-07-25T20:21:33Z,Reusable full-pipeline quality fixture; original filename preserved in quality-set manifest; cleared for public test reuse
1 sha256 filename label source model width height format c2pa_issuer synthid_metadata verified_via added notes
2 05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e ChatGPT Image May 31, 2026, 02_03_55 PM.png pos ChatGPT gpt-image 1122 1402 png OpenAI yes openai-verify 2026-06-04T00:07:53Z June 2026 strength-study subject; openai.com/verify: SynthID detected (docs/synthid.md 2.2)
3 28ff8732b037f98a4ef5bc277bbcdaa32e5eb9ccbd00b6c8c616e46ef68ae8a0 ChatGPT Image May 30, 2026, 10_31_08 AM.png pos ChatGPT gpt-image 1448 1086 png OpenAI yes openai-verify 2026-06-04T00:07:53Z June 2026 strength-study subject; openai.com/verify: SynthID detected (docs/synthid.md 2.2)
4 4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236 Gemini_Generated_Image_633uuy633uuy633u.png pos Gemini app gemini 2816 1536 png Google LLC yes gemini-app 2026-06-04T00:08:05Z June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2)
5 8c1a6fb03ef3d45a1f958fb3401e4264e409ff88c2a793061db7f29023454d0e Gemini_Generated_Image_akdbeiakdbeiakdb.png pos Gemini app gemini 2816 1536 png Google LLC yes gemini-app 2026-06-04T00:08:05Z June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2)
6 45d79a683134fcba1b147b2aedb669783d474e1fb8a4df329729a0904fd1b46b Gemini_Generated_Image_y48j3cy48j3cy48j.png pos Gemini app gemini 2816 1536 png Google LLC yes gemini-app 2026-06-04T00:08:05Z June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2)
7 2c33e75a2db614ce74c83cc0a6ac6c3ac735aca83ab88c9c9345843b124f7856 Gemini_Generated_Image_3mc4t93mc4t93mc4.png pos Gemini app gemini 2816 1536 png Google LLC yes gemini-app 2026-06-04T00:08:05Z June 2026 strength-study subject; Gemini-app Verify with SynthID: detected (docs/synthid.md 2.2)
8 228e54413a3ef62b313176dcdba774a56a58c36b54227d1440bc26cbe0aefd82 ChatGPT Image May 31, 2026, 02_02_23 PM.png pos ChatGPT gpt-image 1122 1402 png OpenAI yes c2pa-metadata 2026-07-25T20:21:33Z Reusable full-pipeline quality fixture; original filename preserved in quality-set manifest; cleared for public test reuse
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 MiB

+548
View File
@@ -0,0 +1,548 @@
# CLI guide
The command line interface is organized around the type of work you want to do.
```text
remove-ai-watermarks [OPTIONS] COMMAND [ARGS]
```
Run `remove-ai-watermarks COMMAND --help` for the complete option list and
defaults. This page focuses on choosing the right command.
## Command dependency map
| Command or signal | Required installation |
| --- | --- |
| `metadata` and metadata-only `identify` | Default package |
| Visible signals in `identify` | `remove-ai-watermarks[visible]` (`pixels` is the minimal runtime) |
| Open DWT-DCT signals in `identify` | `remove-ai-watermarks[detect]` |
| Adobe TrustMark signals in `identify` on Python 3.11-3.12 | `remove-ai-watermarks[trustmark]` |
| `visible` and `erase` with OpenCV | `remove-ai-watermarks[visible]` (`pixels` is the minimal runtime) |
| `visible` or `erase` with MI-GAN | `remove-ai-watermarks[migan]` |
| `visible` or `erase` with big-LaMa | `remove-ai-watermarks[lama]` |
| `invisible` and `all` (needs CUDA) | `remove-ai-watermarks[qwen-zimage]` |
| `video metadata` and `video identify --no-visible` | Default package |
| `video identify` | `remove-ai-watermarks[video]` |
| `video visible`, `video all`, and visible/all batch modes | `remove-ai-watermarks[video]` plus ffmpeg on PATH |
| `video invisible` and `video all --invisible` | `remove-ai-watermarks[video,diffusion]` plus ffmpeg on PATH |
| HEIC/HEIF/AVIF pixel input | Add `remove-ai-watermarks[heif]` |
| Every production command and Python-compatible backend | `remove-ai-watermarks[all]` |
`batch` requires the same extra as its selected mode. Extras can be combined in
one installation, for example `remove-ai-watermarks[visible,detect,heif]`.
## Inspect an image
```bash
remove-ai-watermarks identify image.png
```
`identify` always inspects supported metadata. When pixel extras are installed,
it also evaluates supported visible and invisible pixel signals. When no signal
is found, it reports the origin as unknown. It does not claim the image is
clean. For C2PA files, the text report shows asset integrity, claim-signature,
signer-trust, and signer-validity results separately. An intact claim from an
untrusted or expired signer is reported at medium confidence; a failed asset
binding or signature does not confirm the claimed origin.
Machine readable output:
```bash
remove-ai-watermarks identify image.png --json
```
Metadata only inspection:
```bash
remove-ai-watermarks identify image.png --no-visible
```
Despite the historical option name, `--no-visible` skips both visible and open
invisible pixel detectors. Metadata inspection still runs.
## Remove known visible marks
Install `remove-ai-watermarks[visible]` before using `visible` or `erase`.
```bash
remove-ai-watermarks visible image.png -o clean.png
```
The default behavior:
- checks every registered visible mark;
- removes every detected match, except the weakly detected Jimeng label pill,
which needs corroboration (see [supported signals](supported-signals.md));
- selects the best installed fill backend;
- strips AI metadata from the output.
Use a specific mark:
```bash
remove-ai-watermarks visible image.png --mark gemini -o clean.png
```
Available mark names are printed by:
```bash
remove-ai-watermarks visible --help
```
Keep metadata:
```bash
remove-ai-watermarks visible image.png --keep-metadata -o clean.png
```
Use the strict visual gate without metadata or sibling corroboration:
```bash
remove-ai-watermarks visible image.png --sensitivity strict -o clean.png
```
When no known mark is detected, the command does not write a new output. Use
`erase` if you can identify the affected region yourself.
## Erase a region
```bash
remove-ai-watermarks erase image.png \
--region 1640,1930,400,100 \
-o clean.png
```
The region format is `x,y,width,height`. Repeat `--region` to erase more than
one box:
```bash
remove-ai-watermarks erase image.png \
--region 20,20,180,60 \
--region 1640,1930,400,100 \
-o clean.png
```
Choose the fill backend:
```bash
remove-ai-watermarks erase image.png \
--region 1640,1930,400,100 \
--backend migan \
-o clean.png
```
`erase` accepts `cv2`, `migan`, and `lama`. The corresponding optional extra
must be installed for a learned backend.
Two more knobs tune the fill. `--dilate N` (default 3) grows every box by `N`
pixels before inpainting, which helps when a mark has a soft edge or a drop
shadow just outside the box you measured; it applies to every backend because it
shapes the mask. `--inpaint-method telea|ns` selects the classical algorithm and
only affects the `cv2` backend. Like `visible`, `erase` strips AI metadata from
the output by default; pass `--keep-metadata` to retain it.
## Strip AI metadata
Inspect metadata:
```bash
remove-ai-watermarks metadata image.png --check
```
Remove AI metadata and write a new file:
```bash
remove-ai-watermarks metadata image.png --remove -o clean.png
```
When `-o` is omitted, removal overwrites the source. Standard metadata is kept
unless you pass `--remove-all`.
A quiet `--check` or a successful `--remove` is not a clean verdict. The command
only inspects and strips embedded AI metadata; a pixel watermark such as SynthID
has no local decoder once that metadata proxy is gone. `identify` reports the
same limit.
The command also supports the audio and video containers listed in
[supported signals](supported-signals.md). ffmpeg must be available for the
non-ISOBMFF audio and video path.
## Identify and clean video
Install the video pixel and timestamp runtime for visible identification,
removal, and the complete pipeline:
```bash
uv tool install --force "remove-ai-watermarks[video]"
```
Inspect every locally supported video signal:
```bash
remove-ai-watermarks video identify input.mp4
remove-ai-watermarks video identify input.mp4 --json
remove-ai-watermarks video identify input.mp4 --no-visible
```
The default scans the complete clip for stable registered visible marks and
inspects supported metadata. A result with no signals is reported as unknown,
not clean, because proprietary pixel watermarks have no public local decoder.
`--no-visible` performs metadata-only inspection.
Use the complete locally verifiable cleaning path:
```bash
remove-ai-watermarks video all input.mp4 -o clean.mp4
```
It removes a stable supported visible mark when found and always strips
verified AI metadata. When neither signal is found, it writes a same-container
passthrough instead of returning a missing output. The source is never
overwritten.
Invisible regeneration is deliberately opt-in:
```bash
remove-ai-watermarks video all input.mp4 -o clean.mp4 --invisible
```
That option is supported only for MP4, MOV, and M4V. It is lossy and uses the
same oracle-certified profile as `video invisible`.
Process all supported files in a top-level directory:
```bash
remove-ai-watermarks video batch ./videos --mode all
remove-ai-watermarks video batch ./videos --mode visible
remove-ai-watermarks video batch ./videos --mode metadata
```
The batch runs sequentially, preserves successful outputs when another file
fails, and exits nonzero if any item failed. Visible no-op files are copied
byte-for-byte so the output directory remains complete. `--invisible` is
available only with `--mode all`.
## Strip AI metadata from video
Metadata inspection and removal are also available as an isolated operation:
```bash
remove-ai-watermarks video metadata input.mp4 --check
remove-ai-watermarks video metadata input.mp4 --remove -o clean.mp4
```
As with the generic `metadata` command, a quiet check or a successful strip is
not a clean verdict: video SynthID is not decoded locally after the metadata
proxy is gone.
Supported containers are MP4, MOV, M4V, WebM, MKV, AVI, and FLV. The operation
delegates to the same verified metadata scanner and stripper as the generic
`metadata` command, so detection and removal stay in parity. Video and audio
streams are not transcoded. For MP4 and MOV, this includes the native TC260
`AIGC` key and JSON value stored in `moov.udta.meta.keys/ilst`, plus the
QuickTime-form `meta` variants Doubao's iOS export writes (a bare `meta` box
as a direct `moov` child, and a keyless `hdlr=mdir` metadata list). The inspector
seeks past a large `mdat` to find a tail `moov`. Removal stream-copies the
container in bounded chunks, converts supported top-level provenance boxes to
same-size `free` boxes, and blanks the TC260 key/value in place. Box sizes,
media offsets, and encoded stream bytes do not move; the result is atomically
published only after the complete copy succeeds.
For MKV and WebM, the inspector reads the native TC260
`Segment.Tags.Tag.SimpleTag` entry. Removal uses ffmpeg stream copying to
discard container tags and chapters without transcoding the streams.
AVI uses the normative `LIST/INFO/AIGC` chunk, while FLV uses the
`script.onMetaData.AIGC` AMF0 string. Their bounded readers skip media payloads,
and removal also uses ffmpeg stream copying.
When `-o` is omitted, the command writes `<source>_clean` with the same
extension. It never overwrites the source, and it rejects an output with a
different container extension.
Visible video labels and invisible video watermarks are not handled by this
command.
## Remove video SynthID
```bash
uv tool install --force "remove-ai-watermarks[video,diffusion]"
remove-ai-watermarks video invisible input.mp4 -o clean.mp4
```
The command supports MP4, MOV, and M4V. It samples the complete
sequence at the configured frame rate, resizes frames to the configured long
side, regenerates them through a VAE, and applies one deterministic latent-noise
field to every frame. Reusing one spatial field avoids the unnecessary flicker
caused by independent per-frame noise. Frames are regenerated in bounded
batches and streamed directly to ffmpeg, which encodes the result, copies
audio, and drops source metadata.
The default `noise_std=0.15` profile is oracle-certified. The project has no
local video SynthID decoder, so an optional per-file recheck is still useful
for unusually important files or after provider changes. In a new Gemini chat,
upload the original first, invoke the built-in verifier with `@synthid`, and ask:
> For the video attached to this message, was it created or edited by Google
> AI? Use the built-in SynthID content verification result.
The source must be positive. Then upload the processed result in a separate new
chat and repeat the same built-in check. Only a source-positive, output-negative
pair is a fresh per-file verification. Do not ask an adversarial follow-up that tells the
chat model to ignore the verifier and reason about raw pixels: that is ordinary
Gemini reasoning, not a second oracle check.
The default output is `<source>_clean` in the same container. The
source is never overwritten. Use `--noise-std`, `--long-side`, `--fps`,
`--batch-size`, `--seed`, and `--device` to control the regeneration. The
default noise level is `0.15`. It cleared both carriers in the 2026-07-29
short-clip calibration and the complete public eight-second Veo carrier in the
2026-07-31 full-clip check; `0.10` remained detected on that complete clip. This
calibration certifies the shipped operating point; the paired check above is an
optional runtime audit, not a separate result state.
## Remove a supported visible video mark
```bash
remove-ai-watermarks video visible input.mp4 -o clean.mp4
remove-ai-watermarks video visible veo.mp4 --mark veo -o veo_clean.mp4
remove-ai-watermarks video visible seedance.mp4 --mark seedance -o seedance_clean.mp4
remove-ai-watermarks video visible dola.mp4 --mark dola -o dola_clean.mp4
remove-ai-watermarks video visible hailuo.mp4 --mark hailuo -o hailuo_clean.mp4
remove-ai-watermarks video visible kling.mp4 --mark kling -o kling_clean.mp4
```
The command supports the moving Sora mascot and wordmark, two Veo
corner variants, the Seedance boxed `AI` label, the `Dola AI` text label, the
composite `MINIMAX | hailuo AI` label, and the bottom-right Kling label. Sora
searches the whole frame at multiple scales. The other detectors search bounded
lower-frame regions with separate synthetic silhouettes. Kling additionally
requires its bright low-saturation label near the frame edge. Every mark
requires a spatially recurring candidate across adjacent frames. Fixed marks
must also remain anchored instead of drifting with a scene object. Matching
provider provenance may relax the visual score only for registered
provenance-aware marks; metadata alone never creates a detection.
`--mark auto` is the default. It evaluates all providers in one decode pass and
selects the first stable match in specificity order: Sora, Veo, Seedance, Dola,
Hailuo, then Kling. Their confidence scores are independently calibrated and
are not compared across providers. Pass an explicit `--mark` to scan only that
provider.
The video stream is transcoded and the complete original audio stream is
copied without truncating an audio tail that extends beyond the final video
frame. The encoder probes the source stream and preserves supported 8-bit
chroma sampling, color range/matrix/transfer/primaries tags, and MP4/MOV track
timescale. For a variable-frame-rate source, decoded PTS are carried through a
timestamped in-memory NUT bridge so the output retains the source frame
intervals instead of flattening them to a constant rate. A non-zero source
start PTS and the copied audio start offset are preserved as well.
Supported input and output containers are MP4, MOV, M4V, WebM, MKV, AVI, and
FLV; the output extension must match the input. The default `cv2` backend is
fast but can smear structured backgrounds. Select `--backend migan` or
`--backend lama` for a learned fill, or `--backend auto` to choose the best
installed backend.
`--temporal-consistency` is enabled by default. It motion-aligns the preceding
accepted fill, requires overlapping removal masks and matching source context,
and blends only the safely covered pixels. Scene cuts, disjoint moving marks,
or a poor motion match keep the independent current-frame fill. Use
`--no-temporal-consistency` for an exact frame-local baseline.
The pixel path is intentionally limited to SDR 8-bit video. A high-bit-depth,
PQ, or HLG source is rejected before ffmpeg starts, preserving any existing
output instead of silently downconverting it through OpenCV's 8-bit boundary.
On CPU, MI-GAN is the practical learned tier. LaMa remains an explicit offline
quality option because full-sequence inference is too slow and memory-heavy
for an online worker.
AI metadata is stripped from the encoded output by default. Use
`--keep-metadata` to retain mapped container metadata. When no temporally stable
mark is found, the command writes no output and exits with the no-visible-mark
status. The final path is replaced atomically only after ffmpeg completes, so a
failed encode does not overwrite an existing result.
## Remove invisible watermarks
Install the removal dependencies first. Both profiles are CUDA-only and both
run the DiffSynth Z-Image face stage, so this is the extra either one needs:
```bash
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
```
Then run:
```bash
remove-ai-watermarks invisible image.png -o clean.png
```
The command normally skips regeneration when no supported local signal is
detected. Use `--force` when you know the image should be processed:
```bash
remove-ai-watermarks invisible image.png -o clean.png --force
```
### Choose a pipeline
| Pipeline | When to use it |
| --- | --- |
| `qwen-zimage` | Default. Qwen-Image-2512 global pass plus a SAM-masked Z-Image face stage |
| `sdxl-zimage` | The same recipe and face stage on an SDXL global pass, at a higher denoise |
**Both are CUDA-only.** There is no CPU or MPS profile for invisible-watermark
removal. The former `controlnet`, `sdxl`, `qwen` and `default` profiles were removed
rather than kept as a CPU path: none of them matched this recipe's face preservation,
so offering them implied a quality the library no longer delivers. Passing a retired
name is rejected at parse time rather than remapped. Visible-mark removal and every
identify path still run anywhere.
Example:
```bash
remove-ai-watermarks invisible image.png -o clean.png \
--pipeline qwen-zimage --force
```
There is no `--model`, `--steps`, `--guidance-scale` or `--device` option, and the
deprecated `--auto` is gone. Each profile pins its model stack, its per-stage
schedule, CFG 1.0 and CUDA, so every one of those flags existed only to be refused
several layers down. They are not parsed at all now, which fails at the point the
user can act on rather than after a model load.
### Restore operator-verified text
`--text-manifest` enables the experimental `vae-glyphs` post-pass. It reconstructs
the source with the Qwen VAE, blends 15% of that reconstruction into the normal
`qwen-zimage` result, erases the annotated candidate glyphs with LaMa, and composites
only the reconstructed glyph cores through source-derived silhouettes. It does not
run OCR or choose which strings are correct.
Install the combined extra and run only with an operator-verified manifest:
```bash
uv tool install --force "remove-ai-watermarks[text-restoration]"
remove-ai-watermarks invisible image.png -o clean.png \
--pipeline qwen-zimage --text-manifest verified-lines.json --force
```
``verified: true`` may also be set by an automated operator that attests
machine-verified geometry: stability-gated detector boxes inside sane caps. Such
operators should use the geometry-only schema 2, which carries no transcription
or script metadata.
Since 0.27.1 the global 15% Qwen-VAE fidelity-anchor blend is off by default: it
was measured to return detector-visible OpenAI SynthID on poster-scale manifests
(official Content Provenance API, 2026-08-19). `--fidelity-anchor` restores the
0.27.0 research behavior; text-box fidelity lost by the default is well under one
MAE point on the measured fixtures.
The manifest is a JSON object with `verified: true`, decoded RGB dimensions,
`source_pixel_sha256`, and a non-empty `lines` array. Schema 1 is retained for
manually reviewed annotations: each line has an integer `[x1, y1, x2, y2]` box,
exact `text`, a non-empty `script`, and an optional angle from -30 to 30 degrees.
Schema 2 is geometry-only: each line has the box and optional angle, with no
required `text` or `script`. Lines must be in top-to-bottom, left-to-right order.
The hash binds the annotations to decoded RGB geometry and pixels, so metadata-only
container changes remain valid while a resized or edited source fails closed. The
experimental helper
`remove_ai_watermarks._internal.text_restoration.source_pixel_sha256` computes it.
This mode is supported only by `qwen-zimage` at native geometry with
`humanize=0`, `unsharp=0`, and adaptive polish disabled. `all` also accepts the flag,
but its manifest must match the pixels entering the invisible stage; if visible-mark
removal changes those pixels, the hash check rejects the run. One oracle verdict does
not certify another manifest, seed, model/runtime version, or output hash.
### Work with limited memory
Lower CUDA memory pressure:
```bash
remove-ai-watermarks invisible image.png -o clean.png \
--cpu-offload --force
```
Keep large images at native resolution while processing them in overlapping
tiles:
```bash
remove-ai-watermarks invisible image.png -o clean.png \
--tile --max-resolution 0 --force
```
Or set a resolution cap:
```bash
remove-ai-watermarks invisible image.png -o clean.png \
--max-resolution 2048 --force
```
Tiling avoids the explicit downscale but each tile is regenerated separately.
It is a memory strategy, not a guarantee of better quality.
## Run the full pipeline
The `all` command and the `all` installation extra are separate concepts. The
command runs every applicable stage. Installing `remove-ai-watermarks[all]`
makes every backend compatible with the active Python available; a smaller installation such as
`remove-ai-watermarks[visible,qwen-zimage]` can also run the command with fewer
optional backends.
```bash
remove-ai-watermarks all image.png -o clean.png
```
The command runs:
1. visible mark removal;
2. invisible watermark removal when available and applicable;
3. AI metadata stripping.
The visible options and diffusion options are also available on `all`.
When the `qwen-zimage` extra is unavailable, `all` still writes the result of
the visible and metadata stages, prints a prominent warning, and exits with code
1. That happens on every run without the extra: the skipped stage is what would
have decided whether a signal was there. This prevents a partial result from
being reported as complete.
If the extra is installed but the machine has no CUDA, which is the usual macOS
case, the run fails at engine construction instead: `all` prints
`Error: Invisible-watermark removal is CUDA-only ...`, writes no output at all,
and exits with code 1.
## Process a directory
```bash
remove-ai-watermarks batch ./images --mode visible
```
Modes:
- `visible`;
- `invisible`;
- `metadata`;
- `all`.
Set an output directory:
```bash
remove-ai-watermarks batch ./images \
--mode all \
--output-dir ./clean
```
The invisible and full modes accept the same main diffusion controls as their
single image counterparts. Run `batch --help` for the authoritative option
list.
## Exit behavior
The CLI uses nonzero exit codes for meaningful incomplete outcomes, including
no detected target on commands that would otherwise regenerate or create a
misleading unchanged result, processing errors, and a required invisible step
that could not run.
Scripts should check the process exit code and the output path. The detailed
per-command contract is maintained in
[module internals](module-internals.md#command-line-interface).
+21
View File
@@ -0,0 +1,21 @@
# Code provenance
This page records notices required by source dependencies and licensed derivative work.
## Licensed derivative work
- The DWT-DCT implementation derives from ShieldMnt's
[`invisible-watermark`](https://github.com/ShieldMnt/invisible-watermark), licensed
under MIT. Its notice ships in `src/remove_ai_watermarks/licenses/invisible-watermark-MIT.txt`.
The decode path is a vectorized reformulation, not a transcription: it produces
the same bits and is checked against upstream's own decoder, but it no longer
corresponds line by line to `imwatermark/maxDct.py`. Diffing the two files will
show structurally different code, which is expected and does not mean the
derivation notice is stale.
## Licensed test fixtures
- `data/fixtures/provenance/adobe-trustmark-p.png` is Adobe's official
TrustMark Variant P example, licensed under MIT. Its source commit, digest,
and reproduced license are recorded beside the fixture in
`data/fixtures/README.md` and `data/licenses/adobe-trustmark-MIT.txt`.
@@ -0,0 +1,654 @@
# ControlNet-as-removal-pipeline research: can structure-conditioned regeneration scrub SynthID and keep text?
> Research archive. This document records experiments, superseded defaults, and
> deployment considerations from the time of the study. It does not define the
> current CLI or Python API. See `README.md`, `docs/cli.md`, and
> `docs/known-limitations.md` for current behavior. Everything it names on the
> face-restoration path was removed after this study: the `--restore-faces` and
> `--face-id` flags, the `instantid`, `photomaker` and `faceid` extras, and their
> modules. So were the `controlnet`, `sdxl`, `qwen` and `default` profiles.
Date: 2026-06-02. Source: a manual primary-source pass (WebSearch + WebFetch over the
watermark-removal-attack and SDXL-ControlNet literature). Prompted by issue #35
(@newideas99 / Jacob): "as we use SDXL even at low strength that kills small text ... Do you
think ControlNet could be added to preserve and still remove the watermark?" Clarified scope:
Jacob means **replacing the removal pipeline itself** with a ControlNet-conditioned
regeneration (structure held by the control signal), NOT a separate text-protection add-on.
A deep-research workflow run was attempted first (`wf_3244411d-ffd`) and failed at the harness
level (97 agents completed without emitting StructuredOutput; ~4.3 M tokens, no report). This
note is the hand-run replacement.
## The question, precisely
Can a single full-image ControlNet-conditioned diffusion pass **replace** plain SDXL base 1.0
img2img as the watermark remover, so that one structure-guided regeneration removes the
invisible robust pixel watermark (Google SynthID) **everywhere** while keeping fine detail and
small/CJK **text** legible across the whole image? The hard constraint is unchanged from
`text-protection-research.md`: the watermark must be scrubbed everywhere including inside text,
so any path that freezes or composites original text pixels is disqualified.
## Executive summary
The idea is **already academically validated as a watermark remover and is literally what we
already ship** — CtrlRegen (ICLR 2025) is a canny-ControlNet + DINOv2-semantic pipeline that
regenerates from clean noise. But the make-or-break gap is exact: **none of the
watermark-removal papers validate TEXT or fine-detail preservation at all** — CtrlRegen reports
only FID/PSNR/quality-model scores and explicitly contains no text, fine-detail, or
hallucination analysis. Our shipped ctrlregen's empirical failure ("destroys real content,
hallucinates micro-text in smooth regions") is precisely this unstudied failure mode of the
published method, most likely driven by our **512 px tiling** (text occupies too few pixels per
tile to regenerate legibly; edge-free smooth regions get DINOv2-semantic hallucination). The
constructive path is NOT to keep fixing the SD1.5 CtrlRegen, but to port the structure-control
idea onto an **SDXL-native** ControlNet (xinsir tile-sdxl / ControlNet-Union-SDXL) as a control
add to our existing SDXL base 1.0 img2img, run it at **1024+, not 512 tiles**, and empirically
sweep the (denoise strength x conditioning scale x resolution) cube against the SynthID oracle
AND a text-legibility check. The central tension may be fundamental and must be measured: the
conditioning strong enough to keep text legible may suppress regeneration enough to let SynthID
survive; the regeneration strong enough to scrub may deform text regardless of edges.
## Oracle validation 2026-06-04 — measured answer (AUTHORITATIVE; supersedes pre-oracle "scrubs SynthID" claims below)
The central tension the summary predicted is now MEASURED against the live oracles
(OpenAI `openai.com/verify` for OpenAI content; the Gemini app "Verify with SynthID"
for Google content — each detects only its own vendor's payload). **Verdict: at the
shipped low vendor-adaptive strength, controlnet is NOT a drop-in SynthID remover.**
It preserves structure so well that the watermark survives on exactly the photoreal
content it protects. Controlnet is the text/structure PRESERVATION pipeline; removal
is set by STRENGTH, separately calibrated, not by the pipeline choice.
This section is the single consolidated reference for the controlnet pipeline's
removal behavior. (Mirrored briefly in `docs/synthid.md` §5.5, which points here.)
### What we measured (real gpt-image + Gemini originals)
**1. Content × pipeline — neither pipeline clears all content at low strength.**
OpenAI set, strength = vendor-adaptive (0.10 OpenAI / 0.15 no-C2PA), `--max-resolution 1536`:
| content | controlnet (`--auto`) | plain `default` |
|---|---|---|
| flat text card | clean | clean |
| flat graphic (logo/poster, large flat fills) | clean | **SynthID detected** |
| photoreal (9-face grid) | **SynthID detected** | clean |
| photoreal (bracelet product photo) | **SynthID detected** | clean |
Mechanism: controlnet's dense edge map keeps the regeneration very close to the original
on photoreal, so the SynthID-destroying perturbation never happens; but it freely
repaints large flat fills. Plain img2img at low strength perturbs photoreal texture
enough yet barely touches flat fills. So the survivors FLIP by content type — pipeline
choice alone does not guarantee removal.
**2. Seed non-determinism near threshold.** img2img uses a random seed unless `--seed`
is passed, and there is no local SynthID detector to self-verify. The bracelet survived
controlnet @0.15 in one run and CLEARED @0.15 in another (same pipeline+strength+res).
So a single clean run does NOT establish a strength as safe — characterizing a reliable
floor needs a seed-repeatability sweep (N runs, varied seed), not one pass.
**3. Per-vendor controlnet strength ladder (photoreal, oracle):**
- **OpenAI:** 0.10 detected → 0.15 borderline/non-deterministic → 0.20 cleared (with margin).
- **Gemini** (harder vendor; default 0.15 vs OpenAI 0.10): most cleared at 0.15–0.25.
- **Resolution is NOT the lever:** SynthID is robust to downscaling, and the study's
trend says LOWER processing res needs LESS strength, so 1024 was never the wall. A
Gemini face that resisted 0.15/0.20/0.25 was blocked by face-restore (#4), not strength.
**4. `--restore-faces` RE-INTRODUCES SynthID (was the "stubborn face" mystery).** A
Gemini face image stayed SynthID-detected through controlnet 0.15/0.20/0.25 WITH restore,
but CLEARED at 0.20 with `--no-restore-faces` (clean single-variable A/B). GFPGAN runs on
the **ORIGINAL watermarked** face at fidelity weight 0.5, blends ~half its pixels with the
StyleGAN2 prior, and composites that back OVER the diffusion-cleaned face → the watermark
returns in the face region. Content-dependent (smaller faces can clear with restore). So
raising strength cannot fix it — the face is re-pasted from the original after diffusion.
This also corrects the prior "GFPGAN scrubs SynthID / oracle-confirmed clean" claim (it was
checked on one lucky image).
### Certified controlnet strength floors (Modal GPU sweep + oracle, 2026-06-04)
Run via an isolated Modal certification harness:
controlnet, `restore_faces` OFF (it re-introduces SynthID), `--max-resolution 1536`,
each image checked on ITS OWN vendor oracle (OpenAI -> openai.com/verify, Gemini -> the
Gemini app; the two payloads are vendor-specific and never cross-checked):
| vendor | **floor** | evidence (oracle, restore OFF, <= 1536) |
|---|---|---|
| **OpenAI** | **0.20** | 2 photoreal images (9-face grid + bracelet) x seed {1,2,3} = **6/6 clean**; the bracelet that FLIPPED at 0.15 is seed-robust at 0.20 |
| **Gemini** | **0.30** | hardest face (gemini_3): 0.20 detected -> 0.30 clean on **2/2 seeds**; Gemini is the harder vendor (default 0.15 vs OpenAI 0.10) AND resolution-sensitive |
- **OpenAI 0.20 transfers to prod as-is** (OpenAI removal is resolution-independent:
the study clears it at 0.05 across 1024-1600).
- **Gemini 0.30 is the floor at <= 1536 only.** Gemini is resolution-sensitive (study:
native 2816 likely needs >= 0.30 even on `default`). A native-resolution
deployment should either cap Gemini to <= 1536 and
use 0.30, or run a native-resolution Gemini cert and expect a higher floor (~0.35+).
### Recommendations for a removal pipeline
- **Treat controlnet as PRESERVATION, not removal.** Choose it for text/structure content,
`default` for photoreal; removal efficacy comes from STRENGTH in both.
- **Give controlnet a higher, per-vendor strength than `default`** (today both share
`resolve_strength` 0.10/0.15, tuned for plain img2img). **Certified controlnet floors:
OpenAI 0.20, Gemini 0.30** (see table above) — add a controlnet-specific per-vendor
schedule to `resolve_strength` rather than reusing the `default` ladder.
- **Fix the seed in prod.** The non-determinism is purely `seed=None` (random); a fixed
`--seed` makes every run reproduce the certified-clean result, so you ship a
deterministic, re-certifiable config (and the seed sweep collapses to one config).
- **`--restore-faces` is OFF in prod and stays opt-in.** Two methods ship
(`instantid` default, `photomaker`), both NON-COMMERCIAL. They REGENERATE the face
from an ArcFace embedding via SDXL diffusion, making the output face look more
AI-generated than the cleaned image (gloss, symmetric pores, SDXL "clean skin"
aesthetic). For production face preservation the cleaned image from controlnet 0.20
is the LEAST-AI state we can reach — any restore on top trades original-look for
embedding-driven regeneration. Empirical sweep summary: GFPGAN-on-cleaned polished
without identity recovery; PhotoMaker-V2 produced a different person; InstantID
txt2img produced studio-portrait patchwork on group photos; InstantID
img2img-on-cleaned with three parameter settings integrated scene context cleanly
but never recovered original identity precisely — every setting traded one problem
for another. See `docs/synthid-robust-identity-research-2026-06-08.md`
"Empirical follow-up" for the full sweep.
- **No local SynthID detector exists** → the service can't self-verify; bake in strength
margin and periodic oracle spot-checks.
- **Lesson:** visual-quality / face-identity recovery does NOT prove removal — only the
oracle does, across MULTIPLE content types; never conclude from a partial result (the
photoreal-only data first read as "controlnet shields, default removes"; the flat-graphic
result reversed it; the face mystery was restore, not strength).
## Findings (with confidence and sources)
### Finding 1 — confidence: high
**Claim.** "ControlNet as the removal pipeline" is exactly CtrlRegen (ICLR 2025), and our
shipped `ctrlregen` profile is a faithful implementation of it. Its **spatial control is canny
edges** extracted from the watermarked image; its **semantic control is DINOv2-giant** via a
trainable projection + decoupled cross-attention. Clean-noise (full-strength) regeneration
scrubs the watermark from both pixel and latent space while the two control nets hold structure.
**Evidence.** CtrlRegen: spatial control "conditioned on Canny edge images extracted from the
watermarked image," integrated into the U-Net decoder blocks via a ControlNet structure;
semantic control on "DINOv2-giant" embeddings. Removal is strong: TPR@1%FPR driven from 1.00 ->
0.01 (StegaStamp) and 0.99 -> 0.12 (TreeRing). This matches our `ctrlregen/engine.py` exactly
(canny detector + `facebook/dinov2-giant` + spatial ControlNet from `yepengliu/ctrlregen`).
**Sources.** https://arxiv.org/html/2410.05470v1 · https://github.com/yepengliu/CtrlRegen ·
https://openreview.net/forum?id=mDKxlfraAn
### Finding 2 — confidence: high
**Claim.** Regeneration provably removes any bounded-perturbation pixel watermark **given enough
noise** — the operative constraint is the amount of regeneration, which is the same knob that
trades against fidelity.
**Evidence.** Zhao et al., "Invisible Image Watermarks Are Provably Removable Using Generative
AI" (NeurIPS 2024): a noise-then-reconstruct regeneration attack "guarantees the removal of any
invisible watermark" that perturbs the image within a bounded L2 distance. The guarantee is a
function of injected noise magnitude — low noise preserves detail but leaves the watermark; high
noise scrubs but discards original signal. This is the knob ControlNet conditioning is meant to
make survivable (push regeneration high while the control signal holds composition).
**Sources.** https://arxiv.org/abs/2306.01953 · https://github.com/XuandongZhao/WatermarkAttacker
### Finding 3 — confidence: high
**Claim.** The make-or-break gap: **no watermark-removal paper validates text or fine-detail
preservation.** CtrlRegen's "high perceptual quality" is FID/PSNR/quality-model only and
explicitly omits text, fine-detail, and hallucination analysis. So the literature does NOT
support the specific claim Jacob needs (text survives), it is simply unmeasured.
**Evidence.** CtrlRegen reports CLIP-FID, PSNR, Q-Align, LIQE; the fetched analysis confirms
"the paper contains no discussion of text preservation, fine-detail retention, or hallucination
artifacts," and "explicitly avoids discussing failure modes." Pixel metrics like PSNR are
acknowledged not to reflect perception, and text legibility is a different axis than FID.
**Sources.** https://arxiv.org/html/2410.05470v1
### Finding 4 — confidence: medium-high
**Claim.** Resolution is the prime suspect for our shipped ctrlregen's content destruction. We
tile to **512 px** and run full clean-noise per tile; at 512 px text occupies too few pixels per
tile to regenerate legibly, and smooth edge-free regions (no canny signal) are filled by the
DINOv2 semantic prior, which hallucinates texture/micro-text. The paper omits resolution
entirely, so this is an implementation regime it never characterized.
**Evidence.** Our `ctrlregen/engine.py`: `PROCESS_SIZE = 512`, `TILE_SIZE = 512`, full strength
on each tile. This mirrors the `_run_region_hires` insight (text needs MORE pixels under
regeneration so strokes exceed the VAE's ~8 px latent floor), but ctrlregen runs the regeneration
at LOW res, the opposite. CtrlRegen's paper gives no resolution/tiling spec to contradict this.
**Sources.** the former internal
`src/remove_ai_watermarks/_internal/ctrlregen/engine.py` (removed after this study);
resolution-omission
confirmed against https://arxiv.org/html/2410.05470v1
### Finding 5 — confidence: high
**Claim.** SDXL-native ControlNets exist, so the removal-pipeline upgrade need NOT be the SD1.5
re-architecture our current ctrlregen is. xinsir `controlnet-tile-sdxl-1.0` and
`controlnet-union-sdxl-1.0` (ControlNet++) run on SDXL base 1.0. The tile model has a `tile_var`
image-variation mode purpose-built to regenerate detail while preserving structure, at
`controlnet_conditioning_scale = 1.0`, optimal 1024 px. This is a drop-in control add to our
existing SDXL img2img.
**Evidence.** xinsir tile-sdxl model card: use cases = deblur/detail-repaint, **image variation
(preserving structure)**, super-resolution; `controlnet_conditioning_scale = 1.0`, ~30 steps,
optimal 1024x1024, works with `madebyollin/sdxl-vae-fp16-fix` (the same VAE our fp16 path
already swaps in). ControlNet-Union-SDXL / ControlNet++ merges 10+ control types (canny, HED,
tile, depth, lineart) into one SDXL model.
**Sources.** https://huggingface.co/xinsir/controlnet-tile-sdxl-1.0 ·
https://huggingface.co/xinsir/controlnet-union-sdxl-1.0 · https://github.com/xinsir6/ControlNetPlus
### Finding 6 — confidence: high
**Claim.** The community tile-ControlNet upscale workflow runs at **LOW denoise (0.3-0.4)** —
the wrong regime for watermark removal. It preserves detail precisely by regenerating little, so
a naive tile-upscale preserves text AND preserves the watermark. The open empirical question is
whether at `conditioning_scale ~1.0` you can push denoise high enough to scrub SynthID while the
tile conditioning still holds text — the exact cell to test.
**Evidence.** Stable-Diffusion-Art ControlNet-tile upscale: denoise "typically 0.3, max ~0.4 to
avoid artifacts"; some users push 0.6 with ControlNet strength 0.5. Our own data: SynthID
survives below the removal-strength threshold (current Gemini needs notably higher denoise than
the tile-upscale regime). So the detail-preserving regime and the watermark-scrubbing regime are
on opposite ends of the denoise axis; ControlNet conditioning is the bet that they can meet.
**Sources.** https://stable-diffusion-art.com/controlnet-upscale/ ·
internal (`docs/synthid.md` strength data)
### Finding 7 — confidence: high
**Claim.** Forensic-stealth caveat: diffusion-based regeneration is among the MOST detectable
removal families. Even a ControlNet-regeneration that fools the SynthID oracle leaves forensic
traces flagging the output as "removal-processed" at >98% TPR@1%FPR. This bounds the claim (do
not over-promise "indistinguishable from an original") but does not block the use case — the
SynthID oracle still reads negative.
**Evidence.** "Removing the Watermark Is Not Enough: Forensic Stealth in Generative-AI Watermark
Removal" (arXiv:2605.09203, Goonatilake & Ateniese, GMU): across six removal attacks including
diffusion-based regeneration, independent forensic detectors separate removal-processed from
clean content at >98% TPR under a 1% FPR budget.
**Sources.** https://arxiv.org/html/2605.09203v1
### Finding 8 — confidence: low (watch, do not build on yet)
**Claim.** Partial/semantic-guided regeneration is an active sub-direction that explicitly targets
the removal-vs-fidelity tradeoff, but the specific fidelity-on-text claims were not verifiable
from the source in this pass.
**Evidence.** "Removing Watermarks with Partial Regeneration using Semantic Information"
(arXiv:2505.08234) proposes focusing regeneration on watermarked regions with semantic (VLM)
conditioning to preserve untouched areas; the PDF body did not render cleanly enough to confirm
its quantitative text/detail results. Treat as a pointer, not evidence.
**Sources.** https://arxiv.org/pdf/2505.08234
## Recommendation / decision
**ControlNet-as-removal-pipeline is worth prototyping — but not by fixing the SD1.5 ctrlregen.**
Port the structure-control idea onto an SDXL-native ControlNet as a control add to the existing
SDXL base 1.0 img2img, run it at full resolution (1024+, NOT 512 tiles), and treat the
text-vs-scrub tension as an empirical question to measure, not assume.
**Prototype (runs locally on 32 GB MPS — no dedicated GPU required):**
Compute is NOT the bottleneck. On a 32 GB Apple-silicon machine (M5 here) native SDXL already
runs entirely on MPS with no CPU fallback (~155 s at 1122x1402, verified 2026-05-31).
The prototype runs at **1024** (fewer pixels than that) with SDXL base + an SDXL
ControlNet + activations in **fp32** (MPS fp16 decodes to all-black NaN — issue #29 — confirmed
on run 1 below; fp32 is the required default on mps/cpu) — fits the 32 GB budget with vae-tiling +
attention-slicing; ~1-2 min/image, so a coarse sweep is a sub-hour background run. A dedicated GPU
is needed ONLY for the separate
native-large-Gemini (2816 px) case, which OOMs even without a ControlNet (that requires a
GPU task). The genuine external dependency is NOT compute but the **manual SynthID oracle**:
there is no local SynthID detector, so removal is verified by hand in the Gemini app
("Verify with SynthID") per image, regardless of where the diffusion runs.
Runner: **`scripts/controlnet_sweep.py`** (built 2026-06-02) implements exactly this sweep —
SDXL base 1.0 + an SDXL-native ControlNet img2img, one output per (control x strength x scale)
cell, plus a `sweep_index.csv` with empty `synthid_oracle` / `text_legible` columns to fill by
hand. It uses the dedicated single-type xinsir models (`controlnet-canny-sdxl-1.0`,
`controlnet-tile-sdxl-1.0`) rather than the Union model to keep the diffusers API path robust.
uv run python scripts/controlnet_sweep.py watermarked.png -o sweep_out
1. SDXL base 1.0 img2img + `xinsir/controlnet-canny-sdxl-1.0` / `controlnet-tile-sdxl-1.0`
(sweep both `tile` and `canny` control), full image at 1024, `sdxl-vae-fp16-fix`.
2. Sweep the cube on fresh Gemini + gpt-image inputs that contain small/CJK text:
- denoise strength {0.15, 0.3, 0.5, 0.7, 1.0}
- `controlnet_conditioning_scale` {0.5, 0.8, 1.0}
- control type {tile, canny}
3. Per cell, measure BOTH axes:
- **removal**: Gemini app "Verify with SynthID" oracle (the only valid SynthID oracle; for
gpt-image also openai.com/verify for provenance) — must read clean.
- **text**: OCR round-trip / visual legibility of the small text.
- secondary: SSIM/FID vs original for global fidelity.
4. Find the Pareto cell where the oracle is clean AND text stays legible.
**The honest fork the prototype resolves:**
- If such a cell exists -> the answer to Jacob is YES, ship an SDXL-native ControlNet removal
profile (replacing the SD1.5 ctrlregen) tuned to that cell.
- If no cell clears both (the tension is fundamental: scrub-strength always deforms text, or
text-preserving conditioning always spares the watermark) -> the canny/tile-ControlNet middle
path is dead for text, and the standing answer reverts to `text-protection-research.md`: a full
**glyph-conditioned re-render** (EasyText / TextSR on a FLUX-DiT base) is required, which is a
base-model migration, not a control add.
**Do not:** keep tuning the 512 px SD1.5 ctrlregen for text (wrong resolution, wrong base model);
run tile-ControlNet at the community 0.3-0.4 upscale denoise and expect watermark removal (that
regime preserves the watermark); over-claim forensic invisibility (Finding 7).
## Prototype run 1 — 2026-06-02 (text axis measured; watermark axis pending the oracle)
First sweep on a real, SynthID-positive, text-dense input: the corpus tokyo-street-night
gpt-image (`88e61a38-chatgpt_tokyo.png`, 1023x1537 -> 680x1024, dense small CJK + Latin neon
signage; SynthID + C2PA confirmed, so its valid oracle is openai.com/verify). Grid: control
{canny, tile} x strength {0.3, 0.5, 0.7, 1.0} x `conditioning_scale` 1.0, fp32 on MPS. Outputs +
`sweep_index.csv` (text verdicts filled by visual inspection; `synthid_oracle` left for the
manual run) are under `/tmp/cnsweep/` (not committed — derived regenerations of corpus content).
**Measured — PSNR vs input (proxy for how much was regenerated):**
- canny: 0.3 -> 16.91, 0.5 -> 15.91, 0.7 -> 14.82, 1.0 -> 13.22 (monotonic drop = progressively
more regeneration as strength rises; canny only pins edges, so flat regions change).
- tile: 0.3 -> 17.89, 0.5 -> 17.84, 0.7 -> 17.83, 1.0 -> 17.74 (**flat and high — near-identity
even at strength 1.0**; tile@scale1.0 pins the whole image to the input and barely regenerates).
**Measured — text legibility (visual, focused on SMALL text; large high-contrast glyphs survive
everything because canny/tile hold their edges):**
- canny: legible at 0.3, softening at 0.5 (partial), garbling at 0.7, hallucinated pseudo-glyphs
at 1.0 ("NEC" -> "NWENES"). Same plain-img2img small-text deformation, only big text protected.
- tile: near-identity through 0.7, only tiny alterations at 1.0 — small text preserved throughout.
**Reading (the make-or-break tension, now visible in the data):**
- **tile@scale1.0 does not actually regenerate** (flat PSNR), so it preserves all text but almost
certainly leaves the watermark intact — it is a near-identity pass, exactly the community
"tile-upscale preserves detail by not regenerating" regime (Finding 6), confirmed.
- **canny@scale1.0 regenerates progressively** (PSNR drops) and so could scrub — but small text
breaks at exactly the strength where scrubbing would start to bite. canny saves big edges, not
sub-stroke small text.
- Net on the text axis: neither cell at scale 1.0 cleanly gives "high regeneration + legible small
text." This is the literature prior (Findings 3, 6) reproduced empirically. Lowering
`conditioning_scale` to force small-text regeneration is the same tradeoff knob, not an escape.
**Still pending (the decisive half, cannot be done locally):** run the 8 cells through the SynthID
oracle and fill `synthid_oracle`. The most informative cells: canny 1.0 (text dead — does it at
least scrub? if not, the canny path is dead outright), canny 0.5 (text partial — does it scrub?),
tile 1.0 (text perfect — predicted to still read present). If no cell is `oracle=clean` AND
`text=yes`, the fork resolves to the glyph-re-render path (`text-protection-research.md`).
**Incidental bug caught:** the first run used fp16 on MPS (the script's original default) and
produced **all-black** outputs across every cell (2 KB PNGs, PSNR 9.22 flat) — the issue #29
fp16-VAE-NaN failure, and the fp16-fix VAE did not save it on MPS. Fixed `scripts/controlnet_sweep.py`
to default fp32 on mps/cpu (fp16 only on cuda/xpu), matching the production pipeline.
## Tuning ControlNet for text preservation across image types (research 2026-06-03)
Goal: how to configure the canny-ControlNet path to best preserve text (and faces) on diverse
images. Primary sources: diffusers ControlNet doc, the ControlNet paper (arXiv:2302.05543),
xinsir model cards, practitioner guides. The **critical reframe**: almost all community ControlNet
advice optimizes a txt2img *generation* tradeoff (control vs creative freedom). OUR context is
img2img *watermark removal*, where the objective is the opposite -- maximum faithful preservation
while regenerating just enough to scrub. So several common recommendations INVERT here.
**Removal is `strength`; everything below is preservation and does not change removal efficacy**
(only the watermark-shielding risk -- see the caveat). Set `strength` by the oracle/vendor need;
tune these to keep text/faces intact at that strength.
Knobs, ranked by impact for text:
1. **Canny edge density (the per-image lever, currently hardcoded `_CANNY_LOW=100`/`_CANNY_HIGH=200`).**
Lower thresholds capture more/finer edges; higher thresholds keep only major outlines (diffusers
doc + practitioner guides; ControlNet paper uses 100/200 as the default). Small-text strokes and
fine facial features fall below the default 100/200 and are missed. **For dense small text
(infographics, signage) lower the thresholds (~50/120, even 30/100 for facial likeness per
practitioner tests); for high-contrast large text 100/200 already suffices.** Denser canny is
still a BINARY thresholded edge map, so it does not carry the low-amplitude SynthID pixel pattern
-- it passes more shape, not the watermark (still oracle-verify). This is the single highest-value
unexplored lever and should become a CLI knob.
2. **`controlnet_conditioning_scale` -> keep at 1.0 (max structure hold).** Community defaults to 0.5
for creative balance; we want maximum preservation, so 1.0 (xinsir canny/tile cards also recommend
1.0). We measured text on a clean high-contrast image surviving across strength 0.1-0.5 at scale
1.0 (PSNR ~26 flat), so scale 1.0 is the right default; only lower it if a specific image needs
more regeneration to scrub (raises shielding risk the other way).
3. **`control_guidance_start=0.0`, `control_guidance_end=1.0` (full window) -- KEEP, do not shorten.**
The common "end=0.5: establish structure early then let the model render detail freely" is a
creative-generation recipe; for text it is HARMFUL -- the late free steps re-render and deform the
glyphs. We want the edge control active through ALL denoise steps so text stays pinned. (Our
pipeline already uses the 0->1 default; the point is to NOT adopt the shorten-the-window advice.)
4. **Control type, per image type:**
- **Text / graphics / high-contrast -> canny** (the literature's reliable choice for defined edges
and text; what we ship).
- **Faces / smooth tonal content -> soft-edge / HED is a candidate worth testing.** Canny's hard
binary threshold fractures smooth skin gradients; HED/soft-edge gives gradual edges that may hold
faces better. UNVERIFIED for removal (softer edges may carry slightly more original signal ->
oracle-check). A face-heavy image is the test (gemini group photos).
- **tile -> NOT for removal.** It is near-identity (detail-enhancement at low denoise); it shields
the watermark (measured flat PSNR ~17.8 across strength on the tokyo sweep). Do not use it as the
removal control.
5. **Resolution** -- higher long-side = strokes span more VAE latent cells = less softening, while
still fully regenerating. Already a knob (`--max-resolution`); for tiny text prefer native/large.
**Multi-ControlNet (canny + soft-edge), list scales e.g. `[1.0, 0.8]`** (diffusers MultiControlNet):
could hold text edges AND face geometry at once, but doubles ControlNet memory/latency and raises the
shielding risk; defer to a v2 after the single-canny path is dialed in.
**Image-type playbook (proposed, to validate with the oracle):**
- Clean high-contrast text (openai_1-style): canny 100/200, scale 1.0, full window -- already optimal.
- Dense small text / infographics (big_pic3, neon signage): canny **lower thresholds (~50/120)**,
scale 1.0, full window, larger resolution.
- Faces / portraits: try **soft-edge/HED** control, scale 1.0; or multi-ControlNet canny+softedge.
**Hard caveat:** every change that increases preservation (higher scale, denser canny, fuller window,
softer edges) marginally REDUCES effective regeneration and so raises the chance the watermark
survives -- exactly the shielding failure mode. There is no local SynthID detector, so each tuning
change must be re-confirmed on the oracle. These are img2img-context recommendations derived from
generation-context sources plus our own measurements; treat the playbook as hypotheses to verify, not
settled defaults.
**Sources.** https://huggingface.co/docs/diffusers/en/using-diffusers/controlnet ·
https://arxiv.org/pdf/2302.05543 · https://huggingface.co/xinsir/controlnet-canny-sdxl-1.0 ·
https://huggingface.co/xinsir/controlnet-tile-sdxl-1.0 · https://blog.cephalon.ai/canny-and-softedge/
## FaceID research: identity-preserving face conditioning (research 2026-06-03)
Motivation: canny alone preserves face STRUCTURE/position better than plain SDXL but does NOT hold
IDENTITY -- verified on a real Gemini group photo (gemini_3, s015): faces drift in expression and
likeness (the smile/mouth and eyes change), they are "a similar person," not the same one. Canny
carries edges, not identity, so the regenerated face is identity-drifted. To hold identity WITHOUT
copying original pixels (the hard constraint -- copied pixels carry SynthID), the conditioning must
be an identity EMBEDDING, not pixels. Primary sources: diffusers IP-Adapter doc, InstantID
(arXiv:2401.07519), IP-Adapter (arXiv:2308.06721), practitioner comparisons.
### Findings
**1. IP-Adapter FaceID conditions on an ArcFace identity VECTOR, not pixels (confidence: high).**
FaceID extracts `insightface` ArcFace `normed_embedding` (a ~512-d identity vector) via
`FaceAnalysis`, and passes it as `ip_adapter_image_embeds` -- NOT a CLIP image embedding, NOT the
original pixels. So it is constraint-compatible: the watermark (a pixel-amplitude pattern) is not in
the identity vector, and the img2img still regenerates the pixels (removal via `strength` unchanged).
It loads on any SDXL via `load_ip_adapter` (~100 MB), is fast/low-VRAM, but identity fidelity on SDXL
is ~5-10% lower than the SD1.5 line / dedicated methods.
**2. Multiple distinct faces ARE handled, via regional attention masks (confidence: high -- THE key
unlock).** This is the make-or-break for group photos (our hardest case). diffusers supports a LIST
of IP-Adapter face images each with its own binary region mask: `IPAdapterMaskProcessor` builds the
masks, `set_ip_adapter_scale([[s1, s2, ...]])`, and `cross_attention_kwargs={"ip_adapter_masks":
masks}`. So you detect each face, extract its own ArcFace embedding, assign it a region mask, and one
pass preserves N different identities simultaneously. (InstantID, by contrast, is single-subject --
it averages embeddings for multiple refs, which is wrong for distinct people -- so for group photos
**IP-Adapter FaceID + masks beats InstantID**.)
**3. IP-Adapter + ControlNet + img2img compose (confidence: high).** The doc shows IP-Adapter +
ControlNet (depth) in one pipeline and IP-Adapter + img2img (`strength`). Our target stack is the
union: `StableDiffusionXLControlNetImg2ImgPipeline` (canny = structure) + `load_ip_adapter` (FaceID =
identity) + `strength` (removal). `set_ip_adapter_scale` (1.0 = image-only, 0.5 = balanced) is the
identity-hold knob. API friction to verify in implementation: that `ip_adapter_masks` via
`cross_attention_kwargs` works on the *ControlNet img2img* pipeline (the masking is an attention-
processor feature, so it should be pipeline-agnostic, but confirm).
**4. InstantID / PuLID positioning (confidence: medium).** InstantID does not train the UNet so it
composes with canny/depth ControlNets, and gives better single-face fidelity than FaceID -- but it is
single-subject (needs its own landmark ControlNet + dedicated weights). PuLID has the best identity
fidelity but is heaviest and Flux-leaning. For our multi-face, constraint-bound, SDXL-canny case,
IP-Adapter FaceID + masks is the right first build; InstantID/PuLID are single-portrait upgrades.
### Architecture (proposed)
```
detect faces (insightface) -> per face: ArcFace embed + region mask
one img2img pass:
image=init, control_image=canny(init), # structure (existing)
ip_adapter_image_embeds=[face_embeds], # identity per face
cross_attention_kwargs={"ip_adapter_masks": face_masks}, # each face -> its region
controlnet_conditioning_scale=1.0, set_ip_adapter_scale(~0.6),
strength=vendor-adaptive # removal (unchanged)
```
Pixels are regenerated (SynthID removed by `strength`), structure held by canny, each face's identity
held by its masked ArcFace vector -- no original pixel copied.
### Risks / honest costs
- **Shielding risk (same wall):** FaceID conditioning, like canny, reduces effective regeneration ->
higher `set_ip_adapter_scale` raises the chance SynthID survives in the face region (echo of why the
old region-hires failed). MUST oracle-verify removal at the chosen FaceID scale; keep `strength` at
the vendor threshold.
- **New heavy dependency:** `insightface` + `onnxruntime` + the `buffalo_l` model (~300 MB, downloaded
on first use). Detection + embedding is CPU/ONNX, separate from the diffusion.
- **Detection floor:** insightface needs faces large enough (det_size ~640); tiny faces in a dense
group may not be detected -> not preserved (falls back to canny-only for those).
- **Identity ceiling:** SDXL FaceID is ~5-10% off true identity -- a meaningful boost over canny-only
drift, NOT a perfect face swap. Set expectations; PuLID/InstantID are the higher-fidelity (heavier)
paths if needed.
- **Value scales with strength:** at low strength (OpenAI 0.10) faces barely drift, so FaceID is
marginal; at the higher strength a hard vendor (Google 0.30) needs, FaceID earns its keep.
### Build plan (staged)
- v1: optional `--face-id` flag on `--pipeline controlnet`. Detect faces; if any, run the masked
FaceID pass (works for 1 or N faces -- masks generalize). If none detected, fall through to plain
canny. Oracle-verify SynthID removal is preserved at the default FaceID scale on a face image.
- v2 (if identity still short): InstantID for single-portrait, or PuLID, as a higher-fidelity opt-in.
**Sources.** https://huggingface.co/docs/diffusers/main/en/using-diffusers/ip_adapter ·
https://huggingface.co/h94/IP-Adapter-FaceID · https://arxiv.org/pdf/2401.07519 (InstantID) ·
https://instantid.github.io/ · https://arxiv.org/abs/2308.06721 (IP-Adapter)
### FaceID prototype run 1 -- 2026-06-03 (NEGATIVE on dense small-face groups)
Built and shipped the masked multi-face FaceID layer (`--face-id`, `face_id.py`, `faceid` extra;
all three were removed after this run and never reached the tracked tree).
First real run on the gemini_3 group photo (Google, s015, scale 0.6, native 2816 via cap 1536):
insightface detected **17 faces**, the masked multi-face pass composed and ran end-to-end (non-black
output), so the API is correct. **At s015 the result is a clear FAILURE: every face corrupted --
melted/discolored/psychedelic, materially WORSE than canny-only.**
**ROOT CAUSE FOUND (confirmed by ablation, not speculation) -- it is STRENGTH, not scale/masks/faces.**
Investigated the real data: masks are fine (max overlap depth 2, 33% coverage, only 0.2% of pixels
double-covered -- NOT an overlap problem), embeddings are fine (`normed_embedding` norm 1.000), the
FaceID LoRA is not required for SDXL (h94 model card), and faces span 34-181 px (7 medium + 10 tiny).
None of those is the cause. The decisive test: the SAME image + FaceID at **strength 0.5** produces
**clean, coherent faces across the whole group** (no psychedelic artifacts). So FaceID needs
substantial regeneration: the h94 usage is full generation (txt2img, 30 steps); at our removal
strength (0.10-0.15 = ~7 effective steps) the strong identity cross-attention cannot reconcile with
a latent that is ~85% the untouched original, so it smears identity-colored noise onto the faces.
**This is a FUNDAMENTAL tension, not a tuning bug:** watermark removal wants LOW strength (minimal
degradation, just enough to scrub), FaceID wants HIGH strength (regenerate the face to impose
identity). They are opposed. At strength 0.5 FaceID works AND removes the watermark, but the whole
image regenerates much more (canny still holds text/edge structure, but texture/detail drifts well
beyond the 0.15 "minimal degradation" target). So `--face-id` is a HIGH-STRENGTH option: it trades
whole-image fidelity for face identity, and is a footgun at the low default strength (guaranteed
garbage). Required follow-up code guard: when `--face-id` is set, floor `strength` at ~0.5 (or refuse
+ warn) -- never run FaceID at the vendor-adaptive removal strength. Open question: whether
high-strength FaceID's whole-image drift is acceptable for face-centric images, or whether identity
preservation at LOW strength needs a different mechanism entirely (FaceID structurally cannot do it). (Infra lesson: the `faceid` extra must
stay numpy<2.0 -- pin `onnx<1.18` + `scipy<1.18`; pinning numpy UP, as the first build did, leaves a
numpy-1.26 env with a numpy-2-only scipy that crashes the diffusers import via `np.long`.)
## Face preservation, done properly (research 2026-06-03, after the FaceID failure)
The FaceID run failed and I wrongly concluded "faces can't be preserved." Re-research corrected the
understanding. The hard constraint is unchanged: to remove the watermark FROM a face the face MUST
be regenerated (freezing it leaves SynthID), so the goal is identity-preserving REGENERATION of the
face, at minimal overall image degradation. Three things I got wrong and the corrected picture:
**What I got wrong:** (1) I applied FaceID at GLOBAL high strength -- the literature is clear the
architecture must be REGION-ADAPTIVE (face region handled separately, background stays low-strength);
(2) I used IP-Adapter FaceID, the WEAKEST identity tool -- InstantID uses an ArcFace encoder and hits
82-86% face-recognition similarity vs FaceID's weak CLIP-ish signal; (3) I missed the entire
face-restoration class (CodeFormer / GFPGAN), which is purpose-built for "regenerate a face, keep
identity."
**The most promising mechanism -- CodeFormer face-restoration post-pass (confidence: high on the
mechanism, unverified on our watermark).** CodeFormer is a VQ-VAE: a frozen discrete CODEBOOK of HQ
facial priors + a Transformer that predicts code *tokens* from the input, and a frozen decoder that
regenerates the face FROM THE CODEBOOK ENTRIES -- "does not depend on feature fusion with low-quality
cues." So the output face pixels come from a finite learned codebook, NOT from the input pixels:
**the SynthID pixel-amplitude pattern physically cannot survive a codebook re-synthesis** -- a
stronger scrub than low-strength img2img (which keeps ~85% of the latent). Fidelity knob `w` in
[0,1]: higher w preserves identity but fuses MORE low-quality (input) cues (more watermark risk),
lower w leans on the codebook (cleaner scrub, identity drift) -- the same scrub-vs-fidelity tension,
settled per-image by the oracle; there is likely a `w` that holds identity AND clears the oracle.
**Constraint-compatible architecture:** run the normal canny low-strength controlnet removal globally
(minimal degradation everywhere), then detect+align each face, run CodeFormer on the **ORIGINAL** face
crop (to capture true identity AND re-synthesize from the codebook = scrub), and composite the
CodeFormer output (codebook-generated, not original pixels -> no copy, no watermark) into the cleaned
image. Decouples whole-image minimal-degradation from face identity -- no high GLOBAL strength needed.
**Honest costs/caveats:** (a) **License -- CodeFormer is NTU S-Lab 1.0 (non-commercial/research)**, so
it cannot be bundled in this MIT tool for general use; the license-clean alternative is **GFPGAN
(Apache-2.0)**, slightly lower quality. (b) Deps (basicsr/facexlib) are heavy and numpy-version-finicky
(same class of conflict as insightface). (c) CodeFormer is a *restoration* model -- it can subtly
alter expression/asymmetry; identity is held but not pixel-identical. (d) **The watermark-scrub is
mechanistically strong but UNVERIFIED -- must oracle-check.** InstantID + region-adaptive strength is
the alternative if the restoration route disappoints, but it is more complex (differential strength).
Prototype plan: validate CodeFormer on a real face in a THROWAWAY env (identity held? oracle clean?)
before any project-env integration or the license/GFPGAN decision.
### CodeFormer prototype -- VALIDATED end-to-end 2026-06-03 (oracle-confirmed)
Prototyped the CodeFormer face-restoration post-pass (codeformer-pip in a throwaway venv, forced CPU
-- the pip wrapper has an MPS device-mismatch bug) on the gemini_3 group photo (18 faces). Pipeline:
`all --pipeline controlnet --strength 0.15` (sparkle + SynthID removed from the whole image, minimal
degradation) -> CodeFormer on the ORIGINAL faces -> feather-composite the CodeFormer faces into the
all-cleaned image. Oracle results (Gemini app "Verify with SynthID"), isolating each part:
- pure controlnet-0.15 background (no faces): **clean** -> the background scrub works at 0.15 (no
ControlNet-shielding problem for Google on this image).
- composite with CodeFormer faces at **w0.7**: **SynthID DETECTED** -> high fidelity fuses too much of
the original face signal (the watermark) through.
- composite at **w0.5**: **clean**. composite at **w0.3**: **clean**.
So the scrub-vs-fidelity threshold is between 0.5 and 0.7; **w=0.5 is the sweet spot** (highest
fidelity / best identity that still clears the oracle). Identity at w0.3-0.7 all looks like the same
person (the face is large enough), so the lower w costs little.
**This VALIDATES the corrected face-preservation approach** (and refutes my earlier "faces can't be
preserved" / FaceID conclusion): controlnet low-strength background scrub + CodeFormer-codebook face
re-synthesis at w~0.5 + feather composite = oracle-clean SynthID removal everywhere (background AND
faces), identity preserved, minimal overall degradation, zero original-pixel copying (CodeFormer faces
are codebook-generated). CodeFormer's discrete-codebook re-synthesis DOES scrub the pixel watermark,
but only when w is low enough that the decoder leans on the codebook rather than fusing the input
(watermark-carrying) features -- exactly the predicted fidelity-vs-scrub tension, with an empirical
clean threshold at w<=0.5.
**Production TODO (not built -- still a throwaway prototype):** (1) license -- CodeFormer is NTU S-Lab
(non-commercial); decide CodeFormer-as-user-installed-extra vs GFPGAN (Apache-2.0, re-verify it scrubs
at its fidelity setting); (2) wire a `--restore-faces` post-pass (detect -> restore w~0.5 -> feather
composite) onto the controlnet pipeline; (3) handle the MPS device bug (force CPU for the face model
or fix); (4) re-verify the w threshold on more images / vendors (w=0.5 confirmed on one Gemini group
photo only).
**Sources.** https://arxiv.org/abs/2206.11253 (CodeFormer) · https://github.com/sczhou/CodeFormer ·
https://arxiv.org/pdf/2401.07519 (InstantID) ·
https://openaccess.thecvf.com/content/WACV2024/papers/Suin_Diffuse_and_Restore... (region-adaptive) ·
https://arxiv.org/pdf/2504.12809 (saliency-aware watermark removal)
## Provenance
Hand-run primary-source pass, 2026-06-02. Sources fetched and quoted above; the central
make-or-break claim (structure-conditioned high-strength regeneration scrubs the watermark while
keeping text) is **unverified and explicitly flagged as the thing the local prototype must
measure** (against the manual Gemini SynthID oracle) — the literature supports removal (Findings 1, 2) and supports structure-preserving
regeneration (Finding 5) but never jointly validated text (Finding 3). No code change implied
until the prototype validates a Pareto cell on the SynthID oracle.
+44
View File
@@ -0,0 +1,44 @@
# Development
Read this reference for environment setup, dependency recovery, CI behavior, and fixture policy. The always-loaded invariants remain in [`.claude/rules/development.md`](../.claude/rules/development.md).
## Local environment
- Use `uv sync --frozen --extra dev` and add only the feature extras needed for the task.
- Do not use `uv pip install` for development tools. It can re-resolve `uv.lock` outside the compatible ML dependency set.
- A default-only sync removes every pixel and model package by design. Package imports remain light through lazy exports.
- On an unreliable connection, sync `dev` plus only the required feature extras, such as `diffusion`, and run the checks directly instead of downloading every optional learned backend.
- Run `uv` from the repository root or it may create a bare environment without the project dependencies.
The optional TrustMark decoder downloads weights into its installed package directory. After pruning that extra, a leftover weights directory can make availability checks see an empty namespace package. If Pyright reports an unknown `TrustMark` import and `find_spec("trustmark")` returns a loader-less spec, remove that regenerable remnant from the active virtual environment and resync.
### Known security-gate blocks
`maintain.sh` fails while PyPI ships no fixed release for a transitive CVE. Current case (triaged 2026-08-17): `lightning` 2.6.5, pulled only by the optional `trustmark` extra, carries PYSEC-2026-3624 (RCE via `load_from_checkpoint` on an attacker-crafted checkpoint). The vulnerable API is unreachable here, because the TrustMark decoder loads only its own pinned weights downloaded from the TrustMark release, never a user-supplied checkpoint. The upstream fix is merged but unreleased, and ignores are never added, so run and report the remaining core checks (Ruff, Pyright, tests) separately until a fixed `lightning` release lands; bump it with `uv lock --upgrade-package lightning` as soon as one does.
## CI
`.github/workflows/test.yml` runs Ruff, a test matrix over every supported Python
minor with default plus development dependencies, cross-platform coverage at the
oldest and newest minors, and a separate job that installs ffmpeg on Ubuntu to
run the full-clip video test. Diffusion and model-running tests skip in that
matrix; metadata, identification, visible removal, the DWT-DCT decoder, and the
OpenCV eraser remain covered across operating systems.
Keep `uv.lock` compatible with `uv sync --frozen`. Dependency pull-request checks use GitHub's merge result against current `main`; if `main` moves, merge it locally and rerun the full gate because a newer linter can expose stale directives in later code.
Release and distribution behavior is canonical in [`release-and-distribution.md`](release-and-distribution.md).
## Fixture and data policy
[`../data/README.md`](../data/README.md) is the source of truth:
- executable provenance fixtures live under `data/fixtures/`;
- minimal controlled detector inputs live under `data/calibration/`;
- canonical provider-oracle originals and their manifests live under `data/synthid/`;
- evaluation-only ground truth lives under `data/evaluations/`;
- runtime detector assets live in the package; unregistered research candidates remain outside the shipped wheel.
Store each binary once. Point tests and manifests at its canonical path. Keep generated and cleaned outputs outside the repository and retain only reproducible public records allowed by the data policy.
Use synthetic byte blobs for unsupported format paths and deterministic generated negatives where a real negative fixture is unnecessary. Detection and removal tests must preserve their format-specific invariants.
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.8 MiB

+41
View File
@@ -0,0 +1,41 @@
# Documentation
This documentation is split by purpose. Start with the user guides if you want
to run the tool. Use the maintainer references only when changing the code.
## User guides
| Page | Use it when |
| --- | --- |
| [Installation](installation.md) | You need the CLI, an optional model backend, or a development environment. |
| [CLI guide](cli.md) | You want a command for an image, video metadata or visible marks, a directory, or a specific watermark type. |
| [Python API](python-api.md) | You want to call the package from Python. |
| [Supported signals](supported-signals.md) | You need to know which visible marks, metadata formats, and invisible signals are covered. |
| [Known limitations](known-limitations.md) | You need the quality, device, format, or verification boundaries. |
| [Scope, safety, and legal notes](legal-and-safety.md) | You need the intended use and legal context. |
## Maintainer references
| Page | Purpose |
| --- | --- |
| [Module internals](module-internals.md) | Current architecture, invariants, and regression guards by module. |
| [Development](development.md) | Environment setup, dependency recovery, CI behavior, and fixture policy. |
| [Code provenance](code-provenance.md) | Required notices for licensed derivative work. |
| [Verification plan](verification-plan.md) | Verification methods, completed measurements, and remaining validation gaps. |
| [Release and distribution](release-and-distribution.md) | PyPI, Homebrew, Hugging Face Space, ComfyUI Registry, and the release workflow. |
| [Watermarking landscape](watermarking-landscape.md) | Vendor signals and detection approaches. |
| [SynthID technical reference](synthid.md) | Mechanism, detector access, robustness, and implications for this project. |
## Research archive
These pages record experiments and the evidence behind past decisions. They are
not command references and may describe prototypes that were later removed.
The current behavior is defined by the code, tests, README, and user guides.
- [ControlNet removal research](controlnet-removal-pipeline-research.md)
- [Qwen improvement research](qwen-improvement-research.md)
- [Doubao reverse-alpha research](research-doubao-distillation.md)
- [SynthID identity research](synthid-robust-identity-research.md)
- [SynthID identity follow-up](synthid-robust-identity-research-2026-06-08.md)
- [Video SynthID quality research](video-synthid-quality-research.md)
- [Text protection research](text-protection-research.md)
+241
View File
@@ -0,0 +1,241 @@
# Installation
Python 3.11 through 3.14 are supported.
## Default metadata mode
The default package provides:
- provenance inspection;
- AI metadata inspection and removal.
It installs Pillow, piexif, and c2pa-python for reading metadata directly from
files. It does not install NumPy, OpenCV, pillow-heif, Torch, diffusion models,
or invisible-watermark decoders.
Install it as an isolated command with uv:
```bash
uv tool install remove-ai-watermarks
```
Or with pipx:
```bash
pipx install remove-ai-watermarks
```
You can also install the Homebrew package on macOS or Linux:
```bash
brew install wiltodelta/tap/remove-ai-watermarks
```
## Visible watermark removal
Visible mark detection, OpenCV inpainting, and manual region erasing need the
`visible` extra:
```bash
uv tool install --force "remove-ai-watermarks[visible]"
```
Add `heif` only when the pixel path must decode HEIC, HEIF, or AVIF:
```bash
uv tool install --force "remove-ai-watermarks[visible,heif]"
```
## Video processing
Video metadata inspection works with the default package, and MP4 and MOV
stripping uses the in-tree ISOBMFF box walker. Stripping the non-ISOBMFF
containers (MKV, WebM, AVI, FLV, and the audio formats) and writing any cleaned
video need ffmpeg on PATH, for example `brew install ffmpeg`. Stable
visible-mark identification and removal, full video cleaning, and visible/all
batch modes need the `video` extra:
```bash
uv tool install --force "remove-ai-watermarks[video]"
```
The extra includes the visible pixel runtime and PyAV for preserving variable
frame timestamps. Video SynthID regeneration also needs the diffusion stack:
```bash
uv tool install --force "remove-ai-watermarks[video,diffusion]"
```
## Invisible watermark removal
Install the `qwen-zimage` extra:
```bash
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
```
Both remaining profiles run a Z-Image face stage on the DiffSynth runtime, so
both need this extra. It includes the `diffusion` dependencies; `diffusion` on its
own covers the torch and diffusers imports but not the face stage, so it is not
enough to run a removal.
**An NVIDIA GPU is required.** `qwen-zimage` and `sdxl-zimage` are CUDA-only, and
construction refuses any other device rather than falling back to a slow or broken
one. There is no CPU, MPS or XPU path for invisible-watermark removal. Visible-mark
removal, metadata stripping and every `identify` command still run anywhere.
Video SynthID regeneration is a separate VAE path and does still run on CPU or MPS;
it needs the `diffusion` extra, not this one.
The experimental verified-text post-pass additionally needs LaMa:
```bash
uv tool install --force "remove-ai-watermarks[text-restoration]"
```
That extra includes `qwen-zimage` and `lama`; it does not add OCR. Text strings and
line boxes must be reviewed before the run.
## Feature extras
Extras are composable. Install only the capabilities and file formats the
application actually uses:
| Extra | Capability | Automatically includes | Torch or model download |
| --- | --- | --- | --- |
| `pixels` | Shared BGR array and image-processing runtime | NumPy, headless OpenCV | No |
| `heif` | HEIC, HEIF, and AVIF pixel decoding | pillow-heif | No |
| `visible` | Visible mark detection, OpenCV inpainting, and manual erasing | `pixels` | No |
| `video` | Visible video identification/removal and timestamp preservation | `visible`, PyAV | No |
| `detect` | Open DWT-DCT detection for Stable Diffusion, SDXL, and FLUX | `pixels`, PyWavelets | No |
| `trustmark` | Adobe TrustMark detection on Python 3.11-3.12 | trustmark | Yes |
| `diffusion` | Torch and Diffusers runtime; video SynthID regeneration | `pixels`, Torch, Diffusers | Yes |
| `migan` | MI-GAN ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
| `lama` | big-LaMa ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
| `qwen-zimage` | Invisible image-watermark removal, both CUDA-only profiles | `diffusion`, DiffSynth | Yes |
| `text-restoration` | Opt-in verified Qwen-VAE glyph restoration | `qwen-zimage`, `lama` | Yes |
| `all` | Every production feature available on the active Python | All compatible rows above | Yes |
| `dev` | Tests, linting, typing, and upstream parity checks | `video`, `detect`, upstream invisible-watermark | Yes, for parity tests |
Dependency composition:
```mermaid
flowchart LR
visible --> pixels
video --> visible
detect --> pixels
diffusion --> pixels
migan --> visible
lama --> visible
qwen["qwen-zimage"] --> diffusion
text["text-restoration"] --> qwen
text --> lama
heif
trustmark
```
`heif` and `trustmark` are independent branches. Combine them explicitly with
another feature when required. TrustMark requires NumPy 1.x, which has no
CPython 3.13 or 3.14 wheels, so that branch is available only on Python
3.11-3.12. The `all` bundle contains every production branch compatible with
the active Python and never includes `dev`.
Examples:
```bash
# Metadata plus torch-free DWT-DCT detection
uv tool install --force "remove-ai-watermarks[detect]"
# Visible removal with HEIC/AVIF support and MI-GAN
uv tool install --force "remove-ai-watermarks[migan,heif]"
# Visible video removal with preserved timestamps
uv tool install --force "remove-ai-watermarks[video]"
# DWT-DCT and TrustMark detection without diffusion removal
uv tool install --force "remove-ai-watermarks[detect,trustmark]"
# Every production capability compatible with this Python
uv tool install --force "remove-ai-watermarks[all]"
# An arbitrary minimal combination
uv tool install --force "remove-ai-watermarks[migan,detect]"
```
`heif` stays independent so applications that only process PNG, JPEG, or WebP
do not install libheif. `detect` uses the in-tree torch-free decoder and does
not install the upstream `invisible-watermark` package. Optional models download
their weights on first use.
The old `gpu` and `remove` aliases are intentionally not provided. Use
`diffusion` and `visible` respectively.
## Install from the repository
```bash
git clone https://github.com/wiltodelta/remove-ai-watermarks.git
cd remove-ai-watermarks
uv sync --frozen
```
Add the feature groups required for your work:
```bash
uv sync --frozen --extra dev
uv sync --frozen --extra dev --extra diffusion
```
Run commands from the repository root:
```bash
uv run remove-ai-watermarks --help
```
## Development setup
Install development dependencies:
```bash
uv sync --frozen --extra dev
```
Run the complete project gate:
```bash
bash maintain.sh
```
The script syncs every optional backend on top of the `dev` environment above,
then runs dependency checks, linting, formatting, type checking, and the test
suite. It applies Ruff fixes and formatting in place rather than only reporting
them.
## Hugging Face authentication
Pass a Hugging Face token directly when the selected model or account requires
one:
```bash
remove-ai-watermarks invisible image.png --hf-token "$HF_TOKEN"
```
The CLI also loads `HF_TOKEN` from the environment and from a local `.env`
file. The same name is documented in `.env.example`.
## Troubleshooting
### The first model run is slow
Diffusion and learned fill backends may download model weights on first use.
Later runs reuse their caches.
### The command skips invisible removal
The normal behavior is to skip diffusion when no supported local signal is
found. A missing signal does not prove that the image is clean. If you know the
image came from a relevant generator, use `--force`.
If the CLI reports that the removal dependencies are unavailable, install the
`qwen-zimage` extra. `diffusion` alone covers Torch and Diffusers but not the
DiffSynth face stage that both profiles run. Video SynthID removal is a separate
path and needs `video` and `diffusion`.
+427
View File
@@ -0,0 +1,427 @@
# Known limitations
This page describes current product limits. Historical measurements and
superseded experiments live in the research archive listed in
[the documentation index](index.md).
## Visible removal
### Fill quality depends on the background
Visible removal changes only the selected mask, but the hidden pixels still
have to be reconstructed.
- OpenCV is fast and requires no model download. It works well on flat
backgrounds but
can smear texture or repeated structure.
- MI-GAN is a lighter learned backend. It can improve natural texture but may
ghost or invent structure.
- LaMa is the heaviest learned backend and is generally the strongest option
for difficult backgrounds.
`--backend auto` selects LaMa when available, then MI-GAN, then OpenCV.
No backend can recover detail that is completely hidden by an opaque mark. A
successful detection therefore does not guarantee a visually perfect fill.
### Automatic detection covers registered variants only
The registry contains vendor and locale specific templates. A redesigned mark,
an unsupported locale, a different position, or a crop may be missed.
Known examples:
- Samsung detection is calibrated for the Italian
`Contenuti generati dall'AI` text variant.
- The Jimeng top-left pill has a weak visual detector and is intentionally
subject to additional product and background checks.
- Kling support covers the calibrated variants rather than every Kling label.
Use `erase --region` when you can see and select an unsupported or missed mark.
### Strict and automatic sensitivity trade recall for precision
`--sensitivity strict` uses the visual gate alone. The default `auto` mode may
relax a mark only when metadata or a confidently detected sibling mark
corroborates the same product.
There is no blanket "this image is AI" relaxation. That information does not
identify the vendor, mark, or location and caused unacceptable false
detections in the removed experimental mode.
## Invisible removal
### Regeneration is lossy
Invisible removal does not decode and delete a payload. It regenerates the
image through a diffusion pipeline. Faces, text, colors, and fine detail can
change even when the watermark is successfully disrupted.
Text, tables, and UI screenshots are the worst case for a forced scrub: the
model redraws glyphs as plausible-but-wrong shapes. Do not run the invisible
stage on such content unless a locally-detectable invisible signal exists.
The default `all`, `invisible`, and `batch` commands already skip it then, and
`visible` (which strips AI metadata by default) or `metadata` alone never
redraws a glyph outside the filled watermark box. `--force` is for content you
know carries a pixel watermark despite no local signal; on a clean screenshot
it only buys distortion. Preserving or pasting back the original text pixels
during a real scrub is deliberately not offered: an invisible watermark such
as SynthID lives in the pixels everywhere, including inside the glyphs, so
frozen text regions would keep the watermark
([text protection research](text-protection-research.md)).
`qwen-zimage` is the default profile and `sdxl-zimage` the only alternative.
Both are CUDA only and differ only in the global regeneration model: each
conditions that stage on a canny edge map, which preserves structure but not
identity or exact texture, and each then runs the same face stage.
Existing face evaluations favor `qwen-zimage`, but there is no blanket fidelity
ordering across content types. A fixed-seed, three-scene text comparison at the
profile defaults found no stable winner: SDXL won one poster, Qwen won one, and
the Chinese sign tied. Both are large, slow, and may still alter small text or
difficult faces. The measurements and their OCR and oracle caveats are tracked
in [`data/evaluations/fidelity/`](../data/evaluations/fidelity/README.md).
A global Z-Image Turbo prototype preserved text substantially better at low
strength, but it has no useful cross-provider operating point and is not a
supported profile. Automatic text restorers also remain research-only:
fresh-font and silhouette variants visibly changed typography. The higher-fidelity
`vae-glyphs` route is available only as an experimental opt-in with verified strings
and line geometry. It builds its donor internally but still requires an independently
clean global anchor. Automatic OCR and line-box proposals are not reliable enough to
remove those requirements, and exact oracle results do not establish a general mask,
seed, runtime, or provider operating range. Qwen-Image-2.0 is hosted-only and exposes
no equivalent low-strength denoise control. Exact experiments, controls, and pass
rates are kept in
[`text-protection-research.md`](text-protection-research.md) and the
[`fidelity` evaluation record](../data/evaluations/fidelity/README.md).
### Removal cannot be verified locally for proprietary SynthID
The project has no public local SynthID pixel decoder. It recognizes presence
from supported provenance metadata: Google AI C2PA under Google's all-media
policy, or current OpenAI C2PA with an explicit watermark action. A missing
provenance signal is not a negative pixel verdict.
For important outputs:
1. preserve the original;
2. process a copy;
3. verify with the matching provider tool when available;
4. do not assume one provider's verifier covers another provider's payload.
Provider systems can change, so a result verified on one file, seed, or version
is not a permanent certification.
### Video SynthID removal is lossy and content-dependent
The `video invisible` command and `remove_video_invisible` API regenerate video
pixels through a VAE. The shipped `noise_std=0.15` profile is oracle-certified,
but Google does not publish a local decoder for arbitrary runtime outputs. A
quiet metadata scan, paired PSNR, and the temporal-residual metric are fidelity
measurements, not independent SynthID verdicts.
The control must use the same clip, frame rate, dimensions, and final codec as
the candidates. The separate `scripts/video_synthid_sweep.py` harness produces
that matched control. If the control is not detected by the matching provider
oracle, the experiment cannot attribute a quiet candidate to regeneration.
The 2026-07-29 two-clip calibration used Gemini's built-in content verifier:
both matched controls were SynthID-positive, the stronger candidate was
negative on both carriers, and a weaker candidate was negative on one. A
2026-07-30 adversarial follow-up incorrectly asked the ordinary chat model to
reinterpret the verifier while excluding every other input; its `UNAVAILABLE`
answer was not another detector run and does not invalidate the original
built-in results. The calibrated default remains content-dependent; a fresh
source-positive, output-negative pair is an optional audit for unusually
important files or after provider changes.
The 2026-07-31 full-clip check used the public eight-second Veo off-road sample.
The source was detected across the full clip, the complete product path at
`noise_std=0.10` remained detected, and `0.15` returned no SynthID detection.
The default was raised to `0.15`. At 512 px / 12 fps, the accepted candidate
measured 25.39 dB paired PSNR and a 1.058 motion-compensated temporal-residual
ratio. This is one carrier, not a universal guarantee; hashes and exact verdicts
are tracked in `data/evaluations/video-synthid-oracle.csv`.
### The invisible video path does not touch the audio track
`remove_video_invisible` regenerates the video stream and copies the source audio
verbatim: the extracted audio bitstream of input and output has an identical
sha256, measured on two carriers. It also strips every metadata marker, so the
output can report clean from `get_ai_metadata` and `identify_video` while carrying
an untouched generated audio track. Google's verifier scores audio and visual
tracks separately, and a track this path never modifies is a track it cannot have
cleaned.
Whether a given carrier's audio actually holds a mark the verifier reads has not
been established -- that needs a provider verdict, which has not been obtained.
Treat a clean local report on a clip with generated audio as unproven, not as a
guarantee, and check the audio separately when it matters.
The shipped engine streams sampled frames in bounded batches, computes its
fidelity metrics incrementally, and pipes regenerated pixels directly to
ffmpeg. Its frame and latent memory is therefore bounded by `--batch-size`
rather than clip duration. Runtime still grows linearly with duration, and the
separate multi-candidate research sweep deliberately retains its short sampled
prefix so it can reuse identical latents across candidate strengths.
The reported `psnr_db` is measured against the already-resized frame and before
the H.264 encode, so it excludes the downscale, the frame decimation, and the
encoder. It measures the VAE round trip plus latent noise at the working geometry.
[`video-synthid-quality-research.md`](video-synthid-quality-research.md) records
what the manifest rows constrain, what a higher-resolution or higher-frame-rate
profile would need in order to be certified, and the audio-track question this
path has not yet answered.
### Strength is content and seed dependent
The two profiles resolve an unset strength differently, because different things
were measured for each.
`qwen-zimage` reads it from image area, through the resolution-adaptive denoise
curve. The vendor is deliberately ignored for openai/unknown content: the curve,
not the issuer, is what was calibrated. One measured exception (0.27.2):
Google-provenance content takes the flat `QWEN_ZIMAGE_GOOGLE_STRENGTH` 0.30 floor
- the curve's 0.154 top left a 4.33 MP fixture oracle-detected on the full
production path (2026-08-18), while 0.30 anchors measured clean on two fixtures
in two accounts.
`sdxl-zimage` reads it from the C2PA issuer, on a flat ladder:
- OpenAI: `0.15`;
- Google: `0.25`;
- unknown: `0.25`, following the stricter of the two.
An SDXL global pass needs more denoise than Qwen at the same fidelity, and the
values are flat rather than a curve because flat values are what was measured: each
verdict came from a fixed strength at one size, and no size dependence has been
established for that stage.
An explicit `--strength` overrides both. The defaults are operating points, not
universal guarantees. Near a removal threshold, different content or a different
random seed may change the verifier result, which is why both profiles are
certified at a fixed seed. The live resolver is
[`watermark_profiles.py`](../src/remove_ai_watermarks/_internal/watermark_profiles.py).
### Pipelines have different quality tradeoffs
| Pipeline | Main limit |
| --- | --- |
| `qwen-zimage` | CUDA only, large model stack, and limited broad certification across seeds and content. |
| `sdxl-zimage` | CUDA only. Its strength ladder is flat per vendor, not a resolution curve, because flat values are what was measured. |
Only manually verified `vae-glyphs` is an optional production stage, and it is
experimental rather than a default.
OCR plus LaMa recovered literal poster text but changed fonts and worsened whole-image
fidelity. Restricting it to OCR-mismatched lines improved the tradeoff but still
left a local shadow on one poster. The published AnyText2 SD1.5 checkpoint
substituted Chinese characters and increased CER on the sign fixture. AnyText2XL
is not published, and the released wrapper truncates individual text lines after
20 characters.
The `controlnet`, `sdxl`, `qwen` and `default` profiles were removed, not aliased
onward: a retired name is rejected at parse time rather than routed into a profile
the caller never chose. There is no `--model`, `--steps`, `--guidance-scale`,
`--device` or `--auto` option either; each profile pins its model stack, its
per-stage schedule, CFG 1.0 and CUDA.
## Resolution and memory
### Small images are processed at their native size
There is no minimum-resolution floor. It existed to enlarge small inputs toward
SDXL's ~1024 training resolution and was removed with the SDXL profiles, which
never applied it anyway. Both surviving profiles run at native geometry, so a
small input is neither enlarged before diffusion nor restored afterward.
`--max-resolution` still caps very large inputs, and only ever scales down.
### Large images stay at native resolution unless capped
`--max-resolution 0` means no explicit downscale cap. A positive value caps the
long side before diffusion and restores the result afterward. This reduces
memory use but introduces a downscale and upscale round trip.
`--tile` preserves the input dimensions while running the diffusion stage in
overlapping tiles. It avoids the explicit downscale, but it is not pixel
lossless: each tile is independently regenerated. With `qwen-zimage`, only the
global Qwen stage is tiled; the face stage runs after tile blending.
### CPU offload trades speed for VRAM
`--cpu-offload` forces both stacks of the two-stage profile out of automatic
device residency, streaming weights instead of pinning them. It reduces CUDA
memory pressure at the cost of speed.
Residency is otherwise chosen from the card's total VRAM. On a card large enough
to hold a stack, offloading is pure waste: DiffSynth drops weights to the meta
device and re-reads every parameter from disk on each stage transition.
### There is no CPU or MPS fallback
Invisible-watermark removal refuses any device but CUDA at construction. There is
no MPS out-of-memory fallback that continues on CPU, and no lighter profile to
drop to: both remaining profiles need an NVIDIA GPU.
When a run does not fit, the levers are `--tile` (native geometry, tiled
diffusion), `--max-resolution` (an explicit downscale), and `--cpu-offload`.
Memory needs depend on the profile, input size, dtype, and card.
## Metadata and formats
### Missing metadata does not mean clean
Screenshots, social platforms, and re-encoding can remove metadata while a
pixel watermark remains. `identify` therefore reports unknown rather than
clean when no supported signal is found.
### JPEG XL is metadata only
The metadata path recognizes JPEG XL containers, but the visible and diffusion
image paths do not list `.jxl` as a supported pixel format because the package
does not include a JPEG XL pixel decoder.
### HEIC, HEIF, and AVIF pixel decoding uses an optional Pillow fallback
OpenCV does not decode these formats in the project. `image_io.imread` falls
back to Pillow with `pillow-heif` when the `heif` extra is installed alongside
a pixel feature. The
default metadata path scans these containers without that plugin. A corrupt or
truncated file may still fail to decode.
### Some metadata removal requires ffmpeg
WebM, Matroska, MP3, WAV, FLAC, OGG, Opus, and AAC container metadata is stripped
through ffmpeg with stream copying. The operation fails if ffmpeg is absent or
cannot parse the input.
### Video pixel removal is provider-specific
The `video metadata` command and high level video API inspect and strip
supported AI provenance metadata without transcoding streams.
`video visible` and `remove_video_visible` additionally support the moving
Sora 2 mascot and wordmark, the current Veo four-point diamond, the legacy
`Veo` text, the Seedance boxed `AI` label, the fixed `Dola AI` text, the Hailuo
MINIMAX/Hailuo composite label, and the bottom-right Kling label with its
version suffix. Detection requires a recurring visual candidate across
adjacent frames. Fixed-mark candidates must remain anchored rather than
drifting with a scene object. Kling also requires a bright low-saturation
candidate near the expected frame edge. Provider provenance can recover
low-contrast runs only after visual evidence exists for the marks that define a
provenance prior, so metadata alone does not erase a clean API export.
The default auto-router evaluates all detectors in one decode pass but does not
rank their raw confidence values. Those scores are provider-specific and known
to cross-match in some layouts, so the router applies the independent temporal
policies and selects the first stable result in specificity order. Use an
explicit mark when the provider is already known.
Historical Sora Turbo exports use a small OpenAI swirl in the corner rather
than the moving mascot-and-wordmark design; that earlier variant is not
detected by the `sora` video mark. Hailuo and Kling coverage is specific to the
verified lower-edge layouts; a new provider layout needs a separate calibrated
silhouette. Other provider video labels are not supported yet. Google video
SynthID has an oracle-certified VAE removal path, while other proprietary
invisible video watermarks have no registered attack.
Visible removal transcodes the video stream and copies the complete audio
stream without shortening an audio tail. Completed visible and invisible
encodes are published atomically, so an encode failure preserves an existing
output. Visible removal now applies a guarded motion-compensated blend after
the per-frame fill. It uses adjacent optical flow only when the warped prior
mask covers the current mask and a source-context ring agrees; scene cuts and
disjoint marks keep the independent fill. This reduces measured paired
temporal error, but it is not a generative video-inpainting model and cannot
recover structure that no frame exposes. OpenCV can still leave a visible
smear where the mark overlaps a hard edge or structured texture. MI-GAN
improves difficult individual frames and is the practical learned CPU tier.
LaMa remains an offline quality option: a full real sequence confirmed its
multi-GB memory use and CPU throughput unsuitable for an online worker. The Veo diamond
uses a shape mask to limit damage outside the symbol. Seedance fills the full
localized box because a synthetic outline mask left part of the real
translucent border visible in an end-to-end check. OpenCV may therefore soften
texture inside that small box; use MI-GAN or LaMa when reconstruction quality
matters. Relative variable-frame timestamps are preserved through a timestamped
NUT bridge. A non-zero absolute video start PTS and the corresponding copied
audio offset are preserved through ffmpeg timestamp passthrough.
The encoder is source-aware for common 8-bit inputs: it probes and preserves
supported chroma sampling, recognized color metadata, encoder time base, and
MP4/MOV track timescale instead of accepting ffmpeg's implicit `yuv444p`
raw-BGR output. OpenCV still decodes through 8-bit BGR, so HDR/high-bit-depth
inputs are rejected before encoding rather than silently falling back to
`yuv420p`. The synthetic
Sora/OpenCV full-clip CI gate covers complete removal, untouched-region PSNR,
frame count, frame rate, duration, copied-audio identity, source stream
properties, paired temporal deltas against an independently encoded
frame-local baseline inside the filled region, and metadata
stripping through real ffmpeg. A second synthetic VFR clip verifies all display
timestamps to the source time-base tick, including a non-zero source start,
and checks both video and audio stream offsets. A separate constant-rate case
guards the non-zero-start routing independently of VFR detection. A local
full-sequence audit over all six providers passed both OpenCV and MI-GAN for
complete-frame removal, quiet second detection, stream starts, duration, and
copied audio. A full LaMa sequence passed the same checks but established that
the backend belongs in the offline tier on CPU. These bounded local checks are
still not universal evidence for every provider layout or source.
Native TC260 metadata in MP4/MOV is supported at its normative
`moov.udta.meta.keys/ilst` placement, including non-faststart files whose
`moov` follows a large media payload. MKV/WebM is supported at the normative
`Segment.Tags.Tag.SimpleTag` placement and uses ffmpeg for stream-copy removal.
AVI is supported at `LIST/INFO/AIGC`, and FLV at
`script.onMetaData.AIGC`; both use ffmpeg stream-copy removal. Stock ffmpeg can
write and strip the FLV form, but writing the nonstandard AVI child for fixture
generation requires dedicated muxer support, so the AVI reader is verified
against an exact synthetic RIFF structure.
MP4/MOV/M4V metadata removal now stream-copies the container in bounded chunks,
keeps every box size and media offset fixed, and publishes atomically. The
large-`mdat` regression rejects a full-source `read_bytes()` call and verifies
that the encoded payload is byte-identical. HEIF/AVIF/JPEG-XL image metadata
still uses the in-memory path because their XMP/EXIF items may live inside
`mdat`/`idat` and require bounded format-item parsing before that path can
stream safely.
### Metadata transformation is fail safe
`remove_ai_metadata` may copy an undecodable file through unchanged instead of
raising. User facing callers must use `strip_and_verify` and inspect its
surviving marker mapping before reporting success. `strip_and_verify` recovers
when `image_io` can still decode the raster by normalizing the container and
checking again. A truly undecodable file still reports the surviving markers.
The CLI uses this verified path.
### Sixteen bit PNG output is not preserved
The Pillow based PNG metadata rewrite uses the normal image save path and may
reduce a sixteen bit PNG to eight bits. A byte-level PNG metadata stripper
would be required to preserve that bit depth.
## Detection extras
The `detect` extra decodes an open DWT-DCT watermark used in some Stable
Diffusion, SDXL, and FLUX workflows. That decoder is sensitive to the carrier
and transformations. A negative result is not a universal negative.
The `trustmark` extra adds Adobe TrustMark decoding. The implementation retains
an additional JPEG re-encode gate and requires the binary payload and schema to
remain identical because isolated decoder hits can otherwise be content noise.
It accepts Variant P schemas 0-2. Variant Q requires a different model, and
schema 3 is rejected at the measured precision threshold. Its NumPy 1.x runtime
limits the extra to Python 3.11-3.12; the rest of the package remains supported
through Python 3.14. The calibration history is in
[module internals](module-internals.md#metadata-and-provenance).
External AI versus real image classifiers are out of scope. The project
identifies concrete local provenance signals instead of shipping a generic
statistical classifier.
## Output and traceability
Removing file-local signals does not remove:
- provider account history;
- server side copies or provenance stores;
- perceptual fingerprints;
- evidence that an image passed through a removal pipeline;
- legal disclosure duties.
See [scope, safety, and legal notes](legal-and-safety.md).
+88
View File
@@ -0,0 +1,88 @@
# Scope, safety, and legal notes
This page explains the project's intended boundary. It is not legal advice.
Laws and platform rules change, so check the current rules that apply to your
location and use case.
## Intended scope
The project removes AI provenance marks that a platform adds to content the
user generated or edited themselves. Examples include:
- visible AI generation labels;
- invisible provenance watermarks;
- C2PA and metadata based AI disclosures.
The purpose is user control over the user's own output, false positive cleanup,
interoperability work, and watermark robustness research.
## Out of scope
The project does not provide automatic removal for marks that protect a third
party's paid or copyrighted asset, including:
- stock agency previews;
- marketplace and classifieds marks;
- tiled overlays used to gate a purchase;
- artist protection systems such as Nightshade or Glaze.
The `erase` command is a generic region tool. Users are responsible for having
the right to edit the selected content.
## What removal does not prove
Removing a local signal does not:
- prove that an image is human made;
- remove server side generation history;
- anonymize the generating account;
- defeat every statistical AI detector;
- guarantee that a provider's current verifier will reject the result;
- make deceptive or unlawful use permissible.
An original file may remain linked to an account or generation session in a
provider's systems even after a local copy is changed.
## Legal context
Some jurisdictions and platforms require AI generated content to carry visible
or machine readable disclosures. Rules may apply to providers, publishers,
users, or a combination of them. Some laws also restrict removing or
suppressing provenance information.
Before removing a mark, consider:
1. whether you own or are authorized to edit the content;
2. whether a disclosure is legally required where the content will be used;
3. whether removal would mislead a viewer about authorship or origin;
4. whether copyright management information is involved;
5. whether a platform's terms prohibit the change.
The repository does not provide jurisdiction specific legal advice. The user is
responsible for checking current law and policy.
## Appropriate uses
Examples that fit the project scope include:
- removing metadata that exposes an account identifier from your own file;
- correcting an AI label applied to a human photograph after a limited edit;
- publishing your own generated artwork under the disclosure rules that apply
to you;
- testing the robustness of watermarking and provenance systems;
- evaluating image processing pipelines in a controlled environment.
## Uses the project does not condone
- fraud or impersonation;
- nonconsensual sexual imagery;
- hiding copyright infringement;
- presenting generated content as human made where that claim is deceptive;
- evading a disclosure that the law requires;
- removing protection from someone else's paid asset.
## Reporting security or safety concerns
Open a GitHub issue when the concern can be discussed publicly without exposing
private data. Do not attach confidential images, credentials, or personal
information to a public issue.
File diff suppressed because it is too large. Load diff
+660
View File
@@ -0,0 +1,660 @@
# Python API
Use the high level API for normal application integration. Low level detector
and pipeline modules are intended for maintainers and specialized workflows.
Dependency groups are identical for the CLI and Python API. The default install
covers metadata extraction, normalization, verdict logic, and stripping.
Array/pixel APIs use `pixels`; visible removal uses `visible`; DWT-DCT detection
uses `detect`; invisible image removal uses `qwen-zimage` and an NVIDIA GPU; and
visible video processing uses `video`. Video SynthID removal is a separate VAE
path that still runs on CPU and combines `video` and `diffusion`. Add `heif`
independently when path-based pixel APIs must decode HEIC, HEIF, or AVIF. See
the complete [feature-extra matrix](installation.md#feature-extras).
## Remove visible marks
Install `remove-ai-watermarks[visible]` before using the visible-removal API.
```python
import remove_ai_watermarks as raiw
result, removed = raiw.remove_visible(
"watermarked.png",
"clean.png",
)
```
The function returns:
- the result as a BGR NumPy array;
- a list of labels that were removed.
An empty `removed` list means that no registered visible mark was selected. It
does not prove the image has no metadata or invisible watermark.
### Path input
For a path input, `remove_visible`:
- reads metadata provenance for the default `auto` sensitivity;
- preserves a separate alpha channel;
- writes the output when an output path is supplied;
- strips AI metadata from the written output by default;
- preserves the original bytes for a same-format no-op copy.
```python
result, removed = raiw.remove_visible(
"watermarked.png",
"clean.png",
sensitivity="auto",
backend="auto",
strip_metadata=True,
)
```
Set `write_noop=False` if the output path must remain untouched when nothing is
removed:
```python
result, removed = raiw.remove_visible(
"input.png",
"clean.png",
write_noop=False,
)
```
### Array input
Array inputs are BGR NumPy arrays. They do not carry file metadata or a separate
alpha plane:
```python
import cv2
import remove_ai_watermarks as raiw
image = cv2.imread("input.png")
result, removed = raiw.remove_visible(image, backend="cv2")
```
## Run the full pipeline
`remove_all` is the library form of the `all` command: visible marks, then the
invisible watermark, then AI metadata. Stages are chained through a file in the
system temp directory, so a partial result never appears at the output path.
```python
import remove_ai_watermarks as raiw
result = raiw.remove_all("input.png", "clean.png") # -> RemoveAllResult
print(result.output) # the path written
print(result.visible_label) # the marks removed, or None
print(result.invisible) # "removed" | "no-signal" | "unavailable"
```
`invisible` is the field to check. `"unavailable"` means the GPU extra is not
installed, so the output *looks* processed but still carries the watermark;
`"no-signal"` means the scrub was deliberately skipped because nothing was
locally detectable, which is a successful run.
Pass `InvisibleOptions` to tune the diffusion stage, and `engine` to reuse one
loaded model across many calls:
```python
from remove_ai_watermarks import InvisibleOptions
raiw.remove_all(
"input.png",
"clean.png",
invisible=InvisibleOptions(strength=0.35),
force=True,
progress=print,
)
```
`InvisibleOptions` carries only what `InvisibleEngine` itself takes, and uses the
engine's own parameter names and defaults. `force`, which decides whether the
engine runs at all, is a parameter of `remove_all` and `remove_batch` alongside
`backend` and `sensitivity`.
If AI metadata survives the strip, `remove_all` raises `MetadataStripIncomplete`
**before** writing anything: an AI-readable output is worse than no output.
`remove_batch` runs one mode over a directory and never lets a single bad file
end the run:
```python
summary = raiw.remove_batch("in_dir", "out_dir", mode="visible") # -> BatchSummary
print(summary.processed, summary.failed, summary.errors)
print(summary.invisible_unavailable) # outputs that still carry the watermark
```
`mode` is `all`, `visible`, `invisible`, or `metadata`. Pass a constructed
`InvisibleEngine` as `engine` to load the model once for the whole directory.
## Inspect provenance
The default installation evaluates file metadata. Add `visible`, `detect`, or
`trustmark` to enable the corresponding optional pixel signals.
Get the vendor keys used by visible removal:
```python
import remove_ai_watermarks as raiw
vendors = raiw.visible_provenance("input.png")
```
Get the full provenance report:
```python
from pathlib import Path
from remove_ai_watermarks.identify import identify
report = identify(Path("input.png"))
print(report.platform)
print(report.signals)
print(report.c2pa_validation)
```
`c2pa_validation`, when present, reports `integrity`, `signature`,
`signer_trust`, and `signer_validity` independently, plus the reader status
codes. A valid hash and signature with an untrusted or expired signer is a
medium-confidence signed claim. A hash or signature failure does not confirm the
claimed platform or AI origin. Fallback parsing reports unknown validation
dimensions, while a raw marker in an unsupported or malformed container can
leave `c2pa_validation` as `None`.
Use `check_visible=False` and `check_invisible=False` for metadata-only
inspection through the compatible path-based API:
```python
report = identify(
Path("input.png"),
check_visible=False,
check_invisible=False,
)
```
Extraction and detection are also available as separate steps. This is useful
when a file-reading worker collects the metadata once and another component
evaluates the resulting evidence:
```python
from remove_ai_watermarks.identify import (
extract_provenance_evidence,
identify_from_evidence,
)
evidence = extract_provenance_evidence(Path("input.png"))
report = identify_from_evidence(evidence)
```
### Collect once, judge elsewhere
`collect_metadata_record` splits the two halves apart: it is the only step that
touches the file, and it returns a JSON-serializable record the verdict can be
built from on another machine, in another process, or later.
```python
import json
from remove_ai_watermarks.identify import identify_metadata_record
from remove_ai_watermarks.metadata_record import collect_metadata_record
record = collect_metadata_record(Path("input.png"), schema_version=1) # reads the file
blob = json.dumps(record) # ship it anywhere
report = identify_metadata_record(json.loads(blob), path=Path("input.png")) # reads nothing
payload = report.to_dict(schema_version=1) # versioned JSON contract
```
The collection record has `record_type="provenance_metadata"`,
`schema_version=1`, and a `status`. A vanished or unreadable source produces an
`error` record with structured `issues`; `identify_metadata_record` rejects that
record instead of turning a collection failure into an unknown-image verdict.
Unknown schema versions, non-integer aliases, and native records without a
`complete` collection status are rejected explicitly.
The verdict is the same one `identify(path, check_visible=False,
check_invisible=False)` returns for that file. That equality is the record's whole
contract and is verified over the tracked provenance fixtures and a separate local
evaluation corpus. `ProvenanceReport.to_dict()` is the stable service boundary: it
adds a `schema_version`, contains only JSON-safe values, and deliberately omits the
local source path.
Package and transport versions evolve independently. Long-lived consumers should
request the schema they implement, as above, instead of assuming the installed
package's latest schema. Within schema 1, existing fields, types, meanings,
`signals[].name` values, and `watermarks[]` labels remain compatible; releases may
add fields that consumers must ignore. A breaking change requires a new schema while
the schema 1 serializer remains available for rolling upgrades. Asking a release for
an unsupported schema raises `ValueError` rather than silently returning another
shape.
A record carries metadata regions, not the primary coded-pixel stream: marker
segments before the JPEG scan, every PNG chunk except `IDAT`, RIFF chunks except the
coded image, the ISOBMFF provenance boxes, the container's trailer, the parsed EXIF tags the
verdict reads by name, PIL's info mapping, and the C2PA manifest store. Record size
is bounded by those metadata regions and trailers; images with large embedded
manifests naturally produce larger records.
The `path` argument is metadata: it labels the report and is never opened by
either function, so a record collected elsewhere can be judged against a path that
does not exist locally.
If metadata was collected by another component instead, normalize its nested
record the same way:
```python
from remove_ai_watermarks.identify import (
evidence_from_metadata_record,
identify_from_evidence,
)
record = {
"pil": {"info:parameters": "Steps: 20, Sampler: Euler"},
"exif": {"0th": {"Software": "Stable Diffusion"}},
}
evidence = evidence_from_metadata_record(record, path=Path("input.png"))
report = identify_from_evidence(evidence)
```
Unversioned third-party records are normalized recursively for compatibility. The
normalizer preserves text and byte values. It also decodes
strings prefixed with `hex:` and fields named `base64` or ending in
`_base64`. Diagnostic, transport, timing, hash, provenance-result, and pixel-result
subtrees are ignored because they describe the collector or a derived result rather
than the source file. A versioned portable record is stricter still: only
`metadata_base64`, `tail_base64`, `pil`, `exif`, and `c2pa_store` are accepted as
source evidence. Other `record_type` values are rejected, so do not pass a broad
forensic inspection record to this API. Pass a C2PA manifest-store dictionary in
`record["c2pa_store"]`, or through the explicit
`c2pa_manifest_store` argument.
### Broad metadata inspection
`collect_forensic_metadata` provides the wide metadata-only record used by forensic
inspection and migration adapters. It preserves hashes and timestamps, full EXIF and
IPTC, C2PA, container inventories, bounded raw metadata payloads, and embedded
thumbnail forensics. It does not calculate a provenance verdict or pixel statistics.
```python
from remove_ai_watermarks.forensic_metadata import collect_forensic_metadata
record = collect_forensic_metadata(Path("input.png"), schema_version=1)
assert record["record_type"] == "forensic_metadata"
```
This record is intentionally not accepted by `identify_metadata_record`. Collect the
small strict provenance record separately and publish the resulting
`ProvenanceReport.to_dict()` as the detector contract.
### Pixel evidence
`extract_pixel_evidence` decodes once and calculates the DCT, FFT, residual, ELA,
gradient, and color families. Its versioned `to_dict()` result has a semantic
`status`: `complete`, `partial` when an individual family failed, or `error` when the
source could not be decoded. Transported errors contain only the exception class, so
local paths stay in the caller's logs rather than crossing the service boundary.
```python
from remove_ai_watermarks.pixel_evidence import extract_pixel_evidence
pixels = extract_pixel_evidence(Path("input.png"), artifacts=False, timings=True)
payload = pixels.to_dict(schema_version=1)
```
Timings and spatial artifacts are opt-in. Artifacts include image-identifying data
such as a thumbnail and perceptual hash; aggregate feature families do not.
`identify_from_evidence` does not reopen the source file by default: it evaluates
metadata only, and registered visible marks and pixel-backed invisible watermarks
remain in the path-based `identify` call.
Pass `image_path` together with `check_visible` or `check_invisible` to add those
pixel detectors on top of the SAME evidence. That is how a caller asking one file
two provenance questions — which vendor is confirmed, and is there an invisible
target — pays for the metadata extraction once:
```python
from remove_ai_watermarks.identify import extract_provenance_evidence, identify_from_evidence
evidence = extract_provenance_evidence(source)
metadata_only = identify_from_evidence(evidence)
with_pixels = identify_from_evidence(evidence, image_path=source, check_invisible=True)
```
## Strip metadata
```python
from pathlib import Path
from remove_ai_watermarks.metadata import has_ai_metadata, strip_and_verify
source = Path("input.png")
output = Path("clean.png")
if has_ai_metadata(source):
output_path, surviving_markers = strip_and_verify(source, output)
if surviving_markers:
raise RuntimeError(
f"AI metadata remains in {output_path}: {surviving_markers}"
)
```
Use `strip_and_verify` when your application reports that stripping succeeded.
It checks the written output and returns `(output_path, surviving_markers)`.
When the first strip leaves markers in a malformed but raster-decodable image,
it normalizes the container through `image_io` and checks again. That recovery
path preserves the pixels but drops standard metadata. Treat a nonempty
`surviving_markers` mapping as a failure.
`remove_ai_metadata` is the lower level fail-safe transformer. It may copy an
undecodable input through unchanged, so its return alone must not be presented
as proof that metadata was removed.
## Identify and clean video
The high level video API supports MP4, MOV, M4V, WebM, MKV, AVI, and FLV:
metadata-only calls work with the default install, while visible identification,
removal, and the complete pipeline require `remove-ai-watermarks[video]`.
```python
import remove_ai_watermarks as raiw
report = raiw.identify_video("input.mp4")
print(report.is_ai_generated)
print(report.platform)
print(report.visible_mark)
print(report.metadata_markers)
```
`identify_video` uses the same full-clip temporal arbiter as visible removal.
It reports a recurring registered mark and supported AI metadata as positive
signals. When neither is present, `is_ai_generated` is `None`, never `False`.
The absence of a public local video SynthID decoder is included in `caveats`.
Pass `check_visible=False` for a bounded metadata-only inspection.
For normal product integration, use the complete locally verifiable pipeline:
```python
result = raiw.remove_video_all("input.mp4", "clean.mp4")
if result.remaining_metadata:
raise RuntimeError(f"AI metadata remains: {result.remaining_metadata}")
```
The default removes one stable supported visible provider mark when present,
always strips verified AI metadata, and writes a same-container output even
when neither signal is found. This gives callers one predictable output path.
It does not run lossy invisible regeneration by default.
`include_invisible=True` explicitly adds VAE regeneration for MP4, MOV, or M4V.
`VideoAllResult.invisible_removed` reports whether the oracle-certified SynthID
stage ran.
Process a top-level directory sequentially:
```python
batch = raiw.remove_video_batch("videos", "videos_clean", mode="all")
if batch.failed:
for item in batch.items:
if item.error:
print(item.source, item.error)
```
Batch modes are `all`, `visible`, and `metadata`. Successful visible no-ops are
copied byte-for-byte, keeping the output directory complete. Per-file failures
are returned in `VideoBatchItem.error`; they do not discard successful outputs.
The invisible stage is available only as an explicit opt-in in `all` mode and
reuses one loaded VAE runtime across the batch.
## Inspect and strip video metadata
Metadata inspection and removal use the same supported video containers:
```python
import remove_ai_watermarks as raiw
report = raiw.inspect_video_metadata("input.mp4")
if report.has_ai_metadata:
result = raiw.remove_video_metadata("input.mp4")
if result.remaining:
raise RuntimeError(f"AI metadata remains: {result.remaining}")
```
`remove_video_metadata` does not transcode video or audio streams. Its default
output is `input_clean.mp4`, leaving the source untouched. An explicit output
must use the same container extension as the source.
The returned `VideoMetadataResult` records the source, output, metadata detected
before removal, and any markers remaining after the verified strip. MP4/MOV
inspection recognizes the native TC260 `AIGC` entry in
`moov.udta.meta.keys/ilst` and the QuickTime-form `meta` variants Doubao's iOS
export writes; its removal preserves container size and encoded
stream bytes. MP4/MOV/M4V are copied in bounded chunks, so a large `mdat` is not
loaded into memory; publication is atomic. MKV/WebM inspection recognizes the corresponding
`Segment.Tags.Tag.SimpleTag` representation; its removal requires ffmpeg for a
stream-copy remux. AVI inspection reads `LIST/INFO/AIGC`, and FLV inspection
reads `script.onMetaData.AIGC`; both use the same verified ffmpeg stream-copy
removal path.
## Remove video SynthID
Install `remove-ai-watermarks[video,diffusion]` before using the video SynthID
API.
```python
import remove_ai_watermarks as raiw
result = raiw.remove_video_invisible(
"input.mp4",
"clean.mp4",
device="auto",
)
if result.remaining_metadata:
raise RuntimeError(f"AI metadata remains: {result.remaining_metadata}")
```
`remove_video_invisible` supports MP4, MOV, and M4V. It regenerates the complete
video through a VAE in bounded batches, shares one seeded latent-noise field
across all frames, streams pixels to ffmpeg, copies complete audio, strips
source metadata, and publishes atomically. The default output is
`input_clean.mp4`; a distinct same-container output is required.
The returned `VideoInvisibleResult` includes output geometry, frame rate, frame
count, paired PSNR, and the motion-compensated temporal-residual ratio. Those
fields measure fidelity and flicker only. They are not a SynthID detector.
The default `noise_std=0.15` is the current full-clip oracle floor; `0.10`
remained detected on the public eight-second Veo calibration carrier.
The default profile is oracle-certified. Google does not publish a local
decoder for this video payload, so a fresh source-positive, output-negative
pair from Gemini's built-in SynthID verifier remains an optional per-file audit.
A response inferred from a visible logo or metadata is not such a verdict, and
an adversarial follow-up asking ordinary Gemini to reinterpret the verifier is
not a second oracle run.
## Remove a supported visible video mark
```python
import remove_ai_watermarks as raiw
result = raiw.remove_video_visible(
"input.mp4",
"clean.mp4",
backend="cv2",
strip_metadata=True,
temporal_consistency=True,
)
if result.output is None:
print("No temporally stable supported mark was found")
else:
print(result.mark)
veo_result = raiw.remove_video_visible(
"veo.mp4",
"veo_clean.mp4",
mark="veo",
)
seedance_result = raiw.remove_video_visible(
"seedance.mp4",
"seedance_clean.mp4",
mark="seedance",
)
dola_result = raiw.remove_video_visible(
"dola.mp4",
"dola_clean.mp4",
mark="dola",
)
hailuo_result = raiw.remove_video_visible(
"hailuo.mp4",
"hailuo_clean.mp4",
mark="hailuo",
)
kling_result = raiw.remove_video_visible(
"kling.mp4",
"kling_clean.mp4",
mark="kling",
)
```
`remove_video_visible` scans the complete video before writing output. It
combines synthetic multi-scale visual matching with temporal consistency, so an
isolated lookalike in one frame is not enough to authorize inpainting.
`mark="auto"` is the default: it evaluates all providers in one decode pass and
selects the first stable match in specificity order (`sora`, `veo`, `seedance`,
`dola`, `hailuo`, `kling`). Provider confidence values are calibrated
independently and are not compared across detectors. Pass one of those explicit
values to restrict the scan to a single provider. The Veo detector recognizes
the current four-point diamond and the
legacy `Veo` text. Seedance recognizes the boxed `AI` label, Dola recognizes
its compact text label, Hailuo recognizes the composite MINIMAX/Hailuo label,
and Kling recognizes its bottom-right logo, wordmark, and version suffix. Each
variant has an independent synthetic silhouette and calibrated temporal policy.
After each accepted frame is filled, `temporal_consistency=True` motion-aligns
the preceding accepted fill and blends it only when the warped prior mask
covers the current mask and a surrounding source-context ring agrees. Scene
cuts and disjoint masks keep the independent current fill. Pass
`temporal_consistency=False` for the frame-local baseline.
The returned `VideoVisibleResult` records the selected `mark`, the total,
detected, and removed frame counts, plus any AI metadata that survived the
output encode. The function returns `output=None` and writes no file when no
stable mark is selected. Video pixels are transcoded through ffmpeg while the
complete source audio stream is copied. The encoder preserves supported 8-bit
source chroma sampling, color tags, MP4/MOV track timescale, and relative
variable-frame timestamps. It also retains a non-zero source start PTS and the
copied audio offset. A failed encode preserves any existing output; only a
completed result is published atomically.
SDR 8-bit video is the supported pixel contract. High-bit-depth, PQ, and HLG
sources raise `RuntimeError` before encoding instead of being silently reduced
to 8-bit SDR.
## Remove invisible watermarks
Install `remove-ai-watermarks[qwen-zimage]`. Both profiles need it, and both
need an NVIDIA GPU.
```python
from pathlib import Path
from remove_ai_watermarks.invisible_engine import InvisibleEngine
engine = InvisibleEngine(
pipeline="qwen-zimage", # the default; the only other value is "sdxl-zimage"
device=None,
cpu_offload=False,
)
engine.remove_watermark(
Path("watermarked.png"),
Path("clean.png"),
)
```
`device=None` and `device="auto"` both run detection. `"cuda"` pins it without
detecting. Every other value raises at construction rather than deferring a
guaranteed failure to model-load time.
For limited CUDA memory:
```python
engine = InvisibleEngine(
pipeline="qwen-zimage",
cpu_offload=True,
)
```
Both profiles are CUDA-only, so on a machine without an NVIDIA GPU `device=None`
resolves to `cpu` and construction raises. For the SDXL global stage instead of
Qwen:
```python
engine = InvisibleEngine(pipeline="sdxl-zimage")
```
The `qwen-zimage` extra is required for both profiles: each runs the same
DiffSynth Z-Image face stage.
The opt-in verified-text stage uses the same `text_manifest` argument as the CLI:
```python
engine.remove_watermark(
Path("watermarked.png"),
Path("clean.png"),
text_manifest=Path("verified-lines.json"),
)
```
Install `remove-ai-watermarks[text-restoration]`. The manifest schema and safety
constraints are documented in the CLI guide. The engine verifies its decoded RGB
hash before loading the diffusion models and rejects SDXL, downscaling, and
postprocessing combinations that were not evaluated. Tiling is allowed: the VAE
donor uses the same overlapping tiles as the global pass. `InvisibleOptions` exposes the
same field for `remove_all`; after a visible-stage edit, the manifest must be built
against the staged pixels rather than the pristine source.
Use manifest schema 1 for manually reviewed text plus script metadata. Automated
operators that verify only text-region geometry should emit schema 2 lines with a
`box` and optional `angle`; no placeholder transcription or script is required.
Since 0.27.1 the mode's global 15% Qwen-VAE fidelity-anchor blend is **off by
default** (`fidelity_anchor=False`): that whole-frame blend was measured to
return detector-visible OpenAI SynthID on poster-scale manifests (official
Content Provenance API, 2026-08-19 - detected x6 with the anchor, clean x6
without it, controls and base outputs validated in the same sessions). Pass
`fidelity_anchor=True` to reproduce the 0.27.0 research behavior.
### Drafting manifest lines
`remove_ai_watermarks.text_draft` proposes lines for a manifest; it never
produces verified ones:
```python
from remove_ai_watermarks.text_draft import draft_text_lines
draft = draft_text_lines(Path("watermarked.png"))
for line in draft.accepted:
print(line.box, line.script, line.min_score, line.text)
```
Install `remove-ai-watermarks[text-draft]` (CPU, no torch: PaddleOCR detection
plus three script-chosen recognition engines). A line lands in `accepted` only
when three crop paddings normalize identically and every confidence clears
`min_score` (default 0.85); `accepted` means crop-stable, NOT ground-truth-
correct - on the reference posters the draft's exact-text precision was 90.0%
and 94.4% because high-confidence OCR still lost punctuation. Every accepted
line needs a human yes/no before a manifest may claim `verified: true`.
`source_pixel_sha256` is re-exported here for building the manifest's
pixel-binding hash against the exact source the engine will decode.
`remove_watermark` takes strength, seed, tiling, resolution, and postprocessing
controls. It takes no model id, step count or guidance scale, and neither does the
constructor: each profile pins its model stack, its per-stage schedule and CFG
1.0, so passing one raises `TypeError` at the call rather than being accepted and
refused several layers down. Read the method signature in
[`invisible_engine.py`](../src/remove_ai_watermarks/invisible_engine.py) or use
the CLI guide for the concepts.
Defaults can differ between the Python method and CLI profile resolution, so
pass values explicitly when reproducibility matters.
+270
View File
@@ -0,0 +1,270 @@
# Qwen-Image improvement research (2026-06-20)
> Research archive. This page records experiments and decisions from the date
> above. It may mention prototypes or defaults that were later changed. Use the
> user guides and current source code for the supported interface.
Cited research behind the decision **"ship the `qwen` pipeline as-is, or improve it
first?"** Produced by the multi-source deep-research harness (5 search angles, 22
sources fetched, 85 claims extracted, 25 verified by a 3-vote adversarial check, 20
confirmed / 5 killed, 104 agent calls). Findings carry their confidence and vote.
## Context
The `qwen` pipeline runs base Qwen-Image (20B MMDiT, Apache-2.0) as a low-strength
img2img scrub (removal comes from the denoising `strength`). Certified oracle scrub
floors: OpenAI 0.10 (seed-robust), Gemini 0.25 (pinned seed). Measured against the
SDXL + canny-ControlNet pipeline (`scripts/fidelity_metrics.py`): Qwen preserves
**text** markedly better (incl. CJK and Cyrillic, lower OCR CER) but preserves
**faces** worse, smoothing skin (Laplacian-variance retention 0.40 vs 0.62, face
LPIPS 0.17 vs 0.09, ArcFace identity 0.38 vs 0.55 at the scrub floors). The goal of
the research: keep Qwen's text advantage while fixing the face-smoothing, and judge
production-readiness.
## Verdict
Base Qwen-Image is **shippable now as an opt-in text-content lane** (Apache-2.0 on
code and weights, scrub lever confirmed), but it is not a universal upgrade (it loses
faces). The strongest verified improvement path is to **add structure conditioning**
(a Qwen-Image ControlNet) to the existing base pass, the direct analog of the SDXL +
canny conditioning that wins on faces. Separately, **Z-Image / Z-Image-Turbo** (6B,
Apache-2.0) is the best-verified lighter alternative to evaluate before committing to
the 20B cost. At research time none of the improvements had measured face-fidelity
numbers at our scrub floors. The later `qwen-zimage` follow-up below adds a crowded
fixture, two direct upstream comparisons, and a provider-oracle-negative final candidate.
A broader seeded text/face matrix is still needed for general certification.
## Follow-up: ControlNet experiment + deeper research (2026-06-20)
The verdict's strongest lead -- adding a Qwen-Image ControlNet -- was **built, measured, and
CLOSED**.
**Experiment** (Modal A100-80GB; DiffSynth-Studio `QwenImagePipeline` + the Apache-2.0
`DiffSynth-Studio/Qwen-Image-Blockwise-ControlNet-Canny` -- the only framework exposing
Qwen-Image + canny ControlNet + img2img `denoising_strength` in ONE call; diffusers ships no
`QwenImageControlNetImg2ImgPipeline`, its three Qwen ControlNet pipelines are txt2img only).
Measured on `gemini_3` (18 faces) at the Gemini scrub floor 0.25 vs base-Qwen 0.25 with
`scripts/fidelity_metrics.py`:
- **The actual failure mode (face skin texture) was NOT restored:** Laplacian-variance
retention stayed flat (base 0.40 -> qwen+canny 0.40; per-face 13/16 within +-0.02 after a
one-to-one face match, sd 0.016 -- not an averaging artifact). The SDXL+canny target 0.62
was not approached.
- Identity rose modestly and broadly (ArcFace 0.346 -> 0.415, 12/16 faces improved) but the
absolute stays ~0.42 ("a different person, slightly closer").
- Mechanism (verified, not inferred): canny conditioning was applied fully (scale 1.0, full
denoise schedule); the canny edge map is clean facial geometry with BLANK skin (4.83% edge
density) -- canny carries edges, not skin grain. Root cause: Qwen's Gemini floor (0.25) is
higher than SDXL+canny's (0.15), forcing more denoising -> more smoothing; structure
conditioning cannot compensate for that.
**Deeper research** (deep-research harness, 103 agents, 3-vote adversarial):
- **[high, unanimous] No permissively-licensed Qwen-Image tile / detail / realism / skin
ControlNet exists anywhere** -- DiffSynth first-party is Canny/Depth/Inpaint only, InstantX
Union is canny/soft-edge/depth/pose, the official QwenLM repo ships none. Every Qwen
conditioning is GEOMETRY, the same class as the tested canny. **The "add a Qwen ControlNet to
fix faces" lead is closed for good.**
- **[high, unanimous] Z-Image / Z-Image-Turbo (6B, Apache-2.0 on code AND weights, ~1/3 of
Qwen 20B)** ships a documented `ZImageImg2ImgPipeline` with standard strength denoising, so
it preserves the scrub mechanism. Its own SynthID scrub floor and broad text fidelity
remain unmeasured. The later `qwen-zimage` follow-up provides direct face metrics.
- **[medium] Lowering Qwen's scrub floor has no off-the-shelf SynthID answer:** the "partial
img2img ~0.3 breaks robust watermarks" literature tests open schemes
(StegaStamp/TrustMark/VINE), NEVER SynthID (proprietary decoder) -- analogy, not proof. No
minimal-strength SynthID attack under a named permissive license was found.
- **REFUTED [0-3]:** "re-injecting high-frequency detail from a clean diffusion output would
not carry the watermark back." So non-regenerative detail transfer is NOT safe by
assumption -- the transferred high-frequency band must be gated against the SynthID oracle.
**Net for the single-pass `qwen` pipeline:** faces stay on SDXL+controlnet; Canny alone is not
a Qwen face fix. The next distinct architecture was Z-Image-Turbo on original masked face
crops, not another Qwen geometry conditioner.
**Implementation follow-up (2026-07-24, revised 2026-07-31):** an early experimental
`qwen-zimage` prototype reproduced a broad two-stage shape demonstrated by a public
experiment: structure-guided Qwen regeneration followed by masked Z-Image face
refinement. The maintained profile was subsequently
rewritten with project-owned prompts, adaptive strength and sizing policies, YuNet face
detection, SAM selection, masks, and compositing. It does not include the upstream workflow
JSON or source code. The upstream result supplied by the user was Gemini-oracle negative.
The first exact-path Modal run completed without SAM fallback or face seams. On one crowded
18-face `gemini_3` fixture, ArcFace identity improved materially over controlnet
(0.795 vs 0.587/0.588 raw/polished), while face LPIPS was 0.082 vs 0.087/0.078.
Two official upstream before/after pairs then reproduced the same identity advantage:
local `qwen-zimage` scored 0.950/0.947 ArcFace identity versus polished ControlNet's
0.701/0.548. The published upstream outputs remained slightly higher at 0.976/0.976.
On the matched-size group example, local face LPIPS essentially matched upstream
(0.015 vs 0.014) while whole-image LPIPS and SSIM were better (0.085/0.896 vs
0.111/0.777). ControlNet retained more texture but changed the identities.
That comparison also caught a real /16 alignment defect: DiffSynth dimensions were floored
without resizing the corresponding PIL input. The official non-grid input failed with a
VAE/noise-grid shape mismatch. Regression assertions were observed failing before global
and face pixels were aligned to the same grid as their explicit dimensions.
The final verifier follow-up is positive but exact-output scoped. On 2026-07-25 the user
checked all six current outputs in the provider-separated
`full-clean-final-candidate-2026-07-25-by-oracle` bundle with the corresponding provider
oracles and confirmed that none retained SynthID or the provider generation signal. The
checked bytes used the complete `visible -> qwen-zimage -> metadata` route, the calibrated
YuNet 0.5 gate, and the shipped prompt-cache/model-residency optimizations. This supersedes
the earlier prototype batch check as the release-candidate result, but it is not a
certification across seeds, resolutions, and content classes. YuNet's threshold was
calibrated independently from upstream YOLO: 0.5 retained the visible faces in the
comparison fixtures while removing the false and duplicate boxes admitted by the copied
0.2 gate. The remaining questions are now narrower: how well does text hold across a
broader set and how stable removal is across seeds and Google content types. See
`docs/known-limitations.md`.
The shipped profile therefore resolves an omitted seed to `0`; explicit seeds still
override it, and every other pipeline keeps its existing random default. This makes the
documented `--pipeline qwen-zimage` command reproduce the seed condition used for the
release-candidate oracle evidence without claiming cross-seed certification.
**Product recommendation:** keep ControlNet as the default because it is much cheaper and
supports CUDA, XPU, MPS, and CPU. Treat it as the compatibility baseline, not the
highest-fidelity result. When CUDA is available and visual quality matters more than
latency or cost, recommend `qwen-zimage`, especially for face-heavy content. This
recommendation is based on the measured identity advantage and the oracle-negative exact
candidate; it does not upgrade that exact-output result into broad certification.
**Follow-up (2026-06-20) — the content-routed lane / mixed dual-pass was tested and DROPPED.**
A `--pipeline auto` router (Haar+MSER → text→qwen / faces→controlnet / both→mixed) and a
faces+text mixed dual-pass (scrub the whole frame on both, graft qwen text regions onto the
controlnet base) were built and run on Modal (the abba poster: faces + display text). On that
canonical faces+text case **controlnet won EVERY metric, including text** (CER 0.114 vs qwen
0.379; ID 0.64 vs 0.36) — canny holds existing letter shapes, qwen re-renders display text and
garbles it, so grafting qwen text only hurts. Qwen beats controlnet on text ONLY for clean body
text on a plain background with no faces (openai_1/2), a niche `--pipeline qwen` alone covers;
the faces+clean-body-text intersection is near-empty, and "text→qwen" is undecidable cheaply
(body-vs-display text is what matters). So the router + mixed modules were removed and **`qwen`
is a manual `--pipeline qwen` opt-in only.** KEPT (independently valid): the qwen geometry fix
(it squished non-square inputs to 1024²), the pipeline-aware `resolve_strength` Qwen ladder, and
the `fidelity_metrics.py` one-to-one face matcher below.
**Tooling fix surfaced by this run:** `scripts/fidelity_metrics.py` face matching was changed
from per-face nearest-center to a collision-free one-to-one assignment
(`assign_faces_one_to_one`, gated by face size), after the 18-face `gemini_3` exposed
collisions (the regenerated variants detected 17 faces, so two originals mapped to the same
variant face, corrupting the identity metric). lapvar/LPIPS were always anchored to the
original bbox and stayed collision-immune. Regression-guarded by
`tests/test_fidelity_matching.py`.
## Findings
1. **[high, 3-0] A permissively-licensed Qwen-Image ControlNet exists today and is
CUDA/diffusers-runnable.** InstantX Qwen-Image-ControlNet-Union supports
canny/soft-edge/depth/pose; DiffSynth-Studio maintains blockwise Canny/Depth/Inpaint
plus an In-Context-Control-Union; diffusers exposes `QwenImageControlNetPipeline`
and `QwenImageMultiControlNetModel` with `controlnet_conditioning_scale` (default
1.0) and `control_guidance_start`/`end`. This is the direct analog of the certified
SDXL+canny structure conditioning that wins on faces. Caveat: canny/depth preserve
geometric structure, not face identity per se, and none is a **tile**-ControlNet
(the variant most tied to fine-detail/skin retention in the SDXL world).
Sources: InstantX/Qwen-Image-ControlNet-Union, InstantX/Qwen-Image-ControlNet-Inpainting,
DiffSynth-Studio Qwen-Image docs, diffusers qwenimage pipeline docs.
2. **[high, 3-0] The scrub mechanism is preserved, and the license is clean.**
`QwenImageImg2ImgPipeline.strength` (default 0.6, range 0-1; DiffSynth names it
`denoising_strength`) keeps the partial-regeneration scrub the project relies on,
lower values staying closer to the input. Qwen-Image and Qwen-Image-Edit-2509 are
Apache-2.0 on both code and weights.
3. **[medium, mixed 2-1 / 3-0] Qwen-Image-Edit improves identity consistency, but that
is not proof it fixes our metric.** The instruction-edit pipeline (2511 better than
2509) improves identity/character consistency, but only for identity *through edits*
of an input portrait, which is not the same as measured face-skin Laplacian/LPIPS
fidelity at a low scrub strength. Architecture: 20B base + Qwen2.5-VL (semantic
control) + VAE Encoder (appearance control). Several stronger edit-model face claims
were refuted (see below).
4. **[high, 3-0] Z-Image / Z-Image-Turbo is the best-verified lighter alternative.** A
6B model (~1/3 of Qwen-Image's 20B), Apache-2.0 on code and weights, strong bilingual
(Chinese + English) native text rendering, with an official diffusers
`ZImageImg2ImgPipeline` exposing the same 0-1 denoising-strength scrub lever; Turbo
runs at ~8 steps (guidance_scale=0.0) vs ~40. A material cost/footprint reduction vs
20B/A100-80GB (but see caveat 4 on the refuted consumer-GPU claim).
5. **[high, 3-0] EliGen-V2 is NOT relevant** to the face-smoothing problem. It is an
entity-level/regional control model (LoRA + regional attention placing entities via
text + mask maps, plus entity-level inpainting); it provides no
ControlNet/canny/depth/tile structure conditioning or face-skin-detail retention.
6. **[medium, 2-1] flymy-ai/qwen-image-realism-lora** is Apache-2.0 (code+weights) on
base Qwen-Image, so it is permissively usable with the existing base img2img pass,
but it is NOT verified to specifically fix the face/skin-smoothing failure mode.
## Caveats
1. The research did NOT surface verified evidence for two things specifically asked:
(a) a Qwen-Image **tile**-ControlNet (the variant most tied to fine-detail/skin
retention; only canny/soft-edge/depth/pose/inpaint were confirmed), and (b) any
**non-regenerative detail-restoration** technique (high-frequency residual transfer,
guided filtering) that recovers smoothed faces without re-introducing the watermark.
Research angle 4 produced zero surviving claims, so it is unanswered.
2. No external claim provides measured face-fidelity numbers (ArcFace/LPIPS/Laplacian) for
any recommended intervention at the project's scrub floors. The later direct
`qwen-zimage` comparisons are the project's own measurements, not external evidence or
a certification.
3. Several vendor model cards are marketing-register primary sources (Qwen blog,
Z-Image card). Load-bearing facts (license, params, API levers) are independently
corroborated, but comparative quality framings are author glosses.
4. Z-Image's "sub-second" figure is H800-specific and author-benchmarked; consumer-GPU
third-party benchmarks are still limited (seconds, not sub-second, though within the
<16GB envelope).
5. Time-sensitivity: Qwen-Image-Edit-2511 and Z-Image are late-2025/2026 releases; the
diffusers pipelines cited are on the main/dev branch, so confirm released-version
availability before pinning.
6. Five claims were refuted (below), clustering on over-strong edit-model face-fidelity
and one over-strong Z-Image cost claim.
## Open questions
- Does a Qwen-Image **tile-ControlNet** (or equivalent high-resolution detail
conditioning) exist under a permissive license?
- What **non-regenerative detail-restoration** method recovers smoothed faces WITHOUT
re-introducing SynthID? Note: residual transfer from the ORIGINAL risks copying back
watermark-carrying high frequencies, so it must be verified against the SynthID oracle.
- Head-to-head: does `qwen-zimage` retain its measured ArcFace gain across more portraits
and mixed scenes, match Qwen's text advantage (CJK+Cyrillic CER), and clear SynthID
robustly across content types and seeds?
- Can YuNet's serial face workload be bounded to foreground/relevant faces without losing the
small faces the upstream YOLO path would process?
## Refuted claims (do NOT rely on these)
- [0-3] "Qwen-Image-Edit-2511 specifically targets/mitigates image drift, the same
failure mode as face-detail loss in a low-strength scrub." (qwen.ai/blog, 2511)
- [0-3] "Qwen-Image-Edit-2509 explicitly improves facial identity preservation and
supports portrait styles and pose transformations." (HF Qwen-Image-Edit-2509)
- [0-3] "Qwen-Image-Edit-2509 has native built-in ControlNet support (depth/edge/
keypoint)." (HF Qwen-Image-Edit-2509)
- [1-2] "flymy realism LoRA specifically targets facial and skin detail, the exact
failure mode." (HF flymy-ai/qwen-image-realism-lora)
- [0-3] "Z-Image-Turbo runs on consumer 16GB-VRAM hardware, far below the A100-80GB of
Qwen-Image 20B, materially lowering per-image cost." (HF Tongyi-MAI/Z-Image-Turbo)
## Sources
1. https://qwen.ai/blog?id=qwen-image-edit-2511
2. https://qwenlm.github.io/blog/qwen-image-edit/
3. https://docs.comfy.org/tutorials/image/qwen/qwen-image-edit
4. https://github.com/FurkanGozukara/Stable-Diffusion/wiki/Qwen-Image-Edit-2511-Free-and-Open-Source-Crushes-Qwen-Image-Edit-2509-and-Challenges-Nano-Banana-Pro
5. https://myaiforce.com/qie-2511/
6. https://huggingface.co/Qwen/Qwen-Image-Edit-2509
7. https://huggingface.co/InstantX/Qwen-Image-ControlNet-Union
8. https://huggingface.co/InstantX/Qwen-Image-ControlNet-Inpainting
9. https://huggingface.co/DiffSynth-Studio/Qwen-Image-EliGen-V2
10. https://github.com/modelscope/DiffSynth-Studio/blob/main/docs/en/Model_Details/Qwen-Image.md
11. https://blog.comfy.org/p/day-1-support-of-qwen-image-instantx
12. https://learn.thinkdiffusion.com/how-to-use-qwen-image-with-instantx-union-controlnet-in-comfyui-guide-workflow/
13. https://huggingface.co/flymy-ai/qwen-image-realism-lora
14. https://huggingface.co/lightx2v/Qwen-Image-Lightning/discussions/4
15. https://huggingface.co/docs/diffusers/main/en/api/pipelines/qwenimage
16. https://www.diyphotography.net/skin-retouching-technique-frequency-separation/
17. https://link.springer.com/content/pdf/10.1007/978-3-642-15549-9_1.pdf
18. https://github.com/ShieldMnt/invisible-watermark/wiki/Frequency-Methods
19. https://huggingface.co/Tongyi-MAI/Z-Image-Turbo
20. https://github.com/huggingface/diffusers/blob/main/src/diffusers/pipelines/z_image/pipeline_z_image_img2img.py
21. https://arxiv.org/pdf/2511.22699
22. https://github.com/ModelTC/LightX2V-Qwen-Image-Lightning
+119
View File
@@ -0,0 +1,119 @@
# Release and distribution
This page describes the release behavior defined in this repository. External
registry state can change independently, so verify it during a release.
## Published surfaces
A release is complete only after all four published surfaces are verified:
| Surface | Published result | Automation |
|---|---|---|
| PyPI | The `remove-ai-watermarks` wheel and source distribution | `publish.yml` |
| Homebrew | The `remove-ai-watermarks` formula in `wiltodelta/homebrew-tap` | `distribute.yml` |
| Hugging Face Space | A factory rebuild of `wiltodelta/remove-ai-watermarks` against the new PyPI release | `distribute.yml` |
| ComfyUI Registry | A compatible release of `wiltodelta/ComfyUI-remove-ai-watermarks` with its own node version | `distribute.yml` and the node repository's workflows |
The GitHub Release is the trigger and release record for this flow. Conda is
not a supported publishing surface: this repository has no conda recipe or
conda publication job.
## Release sources of truth
The package version appears in:
- `pyproject.toml`;
- `src/remove_ai_watermarks/__init__.py`;
- the root package entry generated in `uv.lock`.
Update the first two, then refresh the lock file with uv. Do not edit a
line-number-specific location in `uv.lock`; its package order changes.
## Publish flow
PyPI publishing is triggered by a published GitHub Release, not by a tag push
alone.
The expected sequence:
1. update the version sources and lock file;
2. run the complete project gate;
3. commit the release change;
4. create an annotated `vX.Y.Z` tag;
5. push the commit and tag;
6. publish the GitHub Release.
`.github/workflows/publish.yml` then:
1. checks that the release tag matches `pyproject.toml`;
2. builds the package with uv;
3. publishes with `uv publish` through PyPI trusted publishing.
The workflow uses GitHub OIDC through the `pypi` environment. It does not read a
PyPI API token from the repository.
## Post-release distribution
`.github/workflows/distribute.yml` runs on the same published-release event. It
waits for the matching source distribution to appear on PyPI, then:
- updates the Homebrew tap formula URL and SHA-256;
- triggers a factory rebuild of the Hugging Face Space;
- synchronizes, tests, versions, and publishes the ComfyUI nodes.
The workflow can also be started manually with an optional version input.
If a distribution job fails because a repository or Hugging Face credential is
invalid, rotate the corresponding GitHub secret and rerun the failed job. A
manual Homebrew formula update is the fallback when its automation is blocked.
## Source distribution boundary
The wheel includes the package under `src/`.
The source distribution uses an explicit allowlist for `/src`, `/LICENSE`,
`/README.md`, and `/pyproject.toml` through
`[tool.hatch.build.targets.sdist]` in `pyproject.toml`. It also defensively
excludes `/data`, `/tmp`, and `/.sc`. Keep both controls: calibration captures,
test corpora, generated research outputs, and local session state do not belong
in the published package archive. Hatchling always adds the root `.gitignore` to
the sdist, so keep its comments generic and free of local operational context.
Ignore rules are not the build boundary: `data/` is deliberately tracked, while
the sdist configuration keeps it and the other excluded paths out of the archive.
## Build backend
The package uses hatchling through the unpinned `hatchling` build requirement in
`pyproject.toml`. Uploading uses uv rather than the older twine-based action.
## Other channels
The ComfyUI nodes are maintained and versioned in their own repository. After
the matching source distribution appears on PyPI, `distribute.yml` dispatches
that repository's sync workflow with the exact library version and waits for it
to finish. The sync updates the dependency floor, runs compatibility tests,
bumps the node patch version, and publishes to the ComfyUI Registry only when
those tests pass. Its daily schedule remains as a recovery path if a release
dispatch is interrupted. The `COMFYUI_RELEASE_TOKEN` repository secret is a
fine-grained token limited to the ComfyUI node repository, with Actions read and
write access.
## Release verification
Forensic transports are versioned independently from the package. Before publishing
a change to provenance metadata, provenance reports, broad forensic metadata, or
pixel evidence, run their schema 1 contract tests. Additive fields are compatible;
renaming a field, changing its type or meaning, changing a signal name or watermark
label, or removing a field requires a new output schema. Add the new serializer
without removing schema 1 so long-lived consumers can update separately. A package
release must never silently substitute its latest schema when a caller explicitly
requests an older supported one.
After publication, verify:
- both wheel and source distribution exist on PyPI;
- the package version matches the tag;
- the Homebrew formula points to the new source distribution;
- the distribution workflow completed successfully;
- the ComfyUI Registry node requires the new library version;
- a clean install can run `remove-ai-watermarks --version`.
+33
View File
@@ -0,0 +1,33 @@
# Doubao clean-reverse-alpha distillation (re-investigated 2026-05-29)
> Research archive. Reverse-alpha pixel recovery is no longer part of the
> current visible-removal pipeline. The current implementation uses
> localize-then-fill; see `docs/module-internals.md`.
> Relocated verbatim from `CLAUDE.md` on 2026-06-11 to keep the always-loaded
> context small. Long single-line entries were reformatted into paragraphs;
> no content was changed or summarized.
**RESOLVED 2026-05-29: black+gray Doubao captures were obtained and a reverse-alpha was built.**
That historical method, `doubao_engine.remove_watermark_reverse_alpha`, has since
been removed. Its detection silhouette remains at
`src/remove_ai_watermarks/assets/doubao_alpha.png`. The committed captures in
`data/calibration/doubao/` confirmed the alpha-composite model: on black
`captured = a*logo`, logo pure white.
**UPDATE 2026-05-31 (issue #13 follow-up): the first build was NOT "exact"** — it left a readable "豆包AI生成" outline on the real sample (the detector was fooled, conf 0.0). The alpha is now rebuilt by `scripts/visible_alpha_solve.py` (the careful gray-self solve shared with Jimeng), removal always-aligns + thin-inpaints, and the locate box was widened; see the `doubao_engine.py` section in `docs/module-internals.md`. The notes below (the failed content-image distillation) are retained as the record of why controlled captures were necessary.
**Conclusion (historical): pure reverse-alpha distilled from content images does NOT work, and the blocker is the WRONG kind of data, not too little of it.**
Curate same-resolution originals with `DoubaoEngine.detect` and an NCC filter against
a clean glyph template, keeping only aligned marks. Even with aligned inputs,
LaMa-clean `O` plus weighted least squares and per-pixel regression for `α` and logo
color still leaves a persistent ghost outline.
Diagnosed why, empirically (cached stacks, `/tmp/doubao_distill`): (1) the mark is a clean white overlay with **no dark halo** -- over glyph pixels ~54% are brighter than the clean bg, only ~4% darker -- so the white-logo model `I=(1-α)O+α·255` is correct; (2) but content backgrounds are almost never dark *under* the mark (median darkest available bg over glyph pixels = **58/255**; only ~13% of mark pixels are ever observed on a bg < 40), so on bright backgrounds the equation is ill-conditioned and `α` is unidentifiable; (3) LaMa's `O` is a plausible **hallucination**, not the true pre-mark background, which compounds the error, and per-pixel regression on ~15 obs overfits into color noise.
**Why Gemini's engine is clean: its alpha map is the watermark stamped on a PURE-BLACK background**, where `watermarked = α·255 + (1-α)·0 = α·255`, so `alpha = capture/255` exactly -- no estimation. (`gemini_bg_*.png` is literally the sparkle in gray on black.) So the real Doubao unlock is the same controlled capture, **not more content images**. The retained black and gray outputs live in `data/calibration/doubao/`; local solid-color seeds are regenerable and are not committed.
**Until black captures arrive, the shipped direction is precise canonical glyph mask + inpaint (cv2 default, lama optional), NOT reverse-alpha.**
The consensus glyph silhouette across the aligned marks distills cleanly (proto: a tight "豆包AI生成" strip, width ≈ 0.156 × image-width) and is good both as an exact inpaint mask and as an NCC localiser -- the latter also fixes the #23 detector false-positives (match the real glyph shape, not any bright low-saturation corner). Do **not** retry content-image reverse-alpha: it is data-limited by physics (no dark-background observations), not by effort.
+177
View File
@@ -0,0 +1,177 @@
# Supported signals
This page describes the current support boundary. A check mark means that the
repository contains a corresponding code path. It does not guarantee detection
or removal on every future vendor version.
## Visible marks
The `visible` command registers these mark keys:
| Key | Mark | Expected area | Important limit |
| --- | --- | --- | --- |
| `gemini` | Google Gemini sparkle | Usually bottom right | Detection includes a false positive gate. |
| `doubao` | `豆包AI生成` | Bottom right | Vendor specific text detector. |
| `jimeng` | `★ 即梦AI` | Bottom right | Vendor specific text detector. |
| `qwen` | `千问AI生成` | Bottom right | Strict visual gate. |
| `kling` | `可灵AI 3.0` | Bottom right | Only calibrated variants are covered. |
| `yuanbao` | `元宝` over `AI生成` | Bottom right | Standard two-line variant only. |
| `samsung` | `✦ Contenuti generati dall'AI` | Bottom left | Calibrated for the Italian text variant. |
| `runninghub` | `RunningHub AI生成` | Top left | Strict visual and position gates. |
| `baidu` | `百度 AI生成` | Bottom right | Detector and extended removal footprint. |
| `liblib` | `LibLibAI` | Bottom center | Includes a minimum image size gate. |
| `jimeng_pill` | `AI生成` pill | Top left | Weak detector with additional product and background gates. |
`--mark auto` evaluates all registered marks and removes every selected match.
Known marks are localized to a mask, then the selected fill backend reconstructs
the masked area.
Marks from other vendors are not detected automatically. Use `erase --region`
when you can select the affected area yourself.
### Visible video marks
| Key | Mark | Motion | Important limit |
| --- | --- | --- | --- |
| `sora` | Sora 2 mascot and wordmark | Moves among frame positions | Requires a temporally recurring visual match; the older Sora Turbo corner swirl is a different unsupported mark. |
| `veo` | Current four-point diamond and legacy `Veo` text | Fixed bottom-right corner | Uses separate silhouettes and requires a recurring match; learned fill is preferable on structured backgrounds. |
| `seedance` | Boxed `AI` label | Fixed bottom-right corner | Requires an anchored recurring match; the full localized box is filled because a thinner synthetic shape mask leaves the real translucent rim behind. |
| `dola` | `Dola AI` text | Fixed bottom-right corner | Requires an anchored recurring match; ByteDance or BytePlus provenance can relax only an existing visual run. |
| `hailuo` | `MINIMAX \| hailuo AI` composite label | Fixed lower edge | Uses a synthetic waveform, text, separator, and ring silhouette; the complete recurring label box is filled. A TC260 label naming MiniMax as producer can relax only an existing stable run. |
| `kling` | Kling swirl, `KLING AI`, version, and optional `PRO` suffix | Fixed bottom-right edge | Combines a synthetic logo rescue with font variants, an edge gate, a white-label gate, and anchored temporal recurrence. |
`video identify`, `video visible`, and `video all` share this registry and the
same temporal arbiter. It is separate from the image registry because selection
is made over a sequence rather than one raster. The default `auto` mode scans
all six entries in one decode pass and selects the first temporally stable
match in table order; an explicit mark restricts the scan to that row.
Accepted fills are motion-aligned across adjacent frames by default. The prior
fill contributes only where its warped mask covers the current removal mask and
nearby source context agrees. Scene cuts or disjoint marks retain the
independent frame fill.
## Fill backends
| Backend | Install | Behavior |
| --- | --- | --- |
| `cv2` | `remove-ai-watermarks[visible]` | Classical OpenCV inpainting |
| `migan` | `remove-ai-watermarks[migan]` | MI-GAN through ONNX Runtime; practical learned CPU video tier |
| `lama` | `remove-ai-watermarks[lama]` | big-LaMa through ONNX Runtime; offline video quality tier |
| `auto` | Depends on installed extras | Selects LaMa, then MI-GAN, then OpenCV |
The learned backends download model files on first use.
## Metadata and provenance
The inspection and stripping code handles signals in these groups:
- C2PA Content Credentials and supported cloud manifest references;
- EXIF and XMP generator fields;
- exact app-export provenance and AIGC disclosures from supported
ByteDance-family products, with product-only provenance excluded from the
generated-image verdict;
- IPTC AI disclosure fields;
- PNG text chunks and embedded generation parameters;
- China TC260 AIGC labels in supported image placements and the normative
MP4/MOV `moov.udta.meta.keys/ilst`, MKV/WebM
`Segment.Tags.Tag.SimpleTag`, AVI `LIST/INFO/AIGC`, and FLV
`script.onMetaData.AIGC` placements;
- xAI and Grok EXIF signature fields;
- Samsung AI editing markers;
- Hugging Face job metadata;
- open Stable Diffusion style DWT-DCT watermarks with the `detect` extra;
- Adobe TrustMark Variant P schemas 0-2 with the `trustmark` extra. Variant Q
needs a different model, while schema 3 is deliberately rejected because it
produced persistent false positives on unrelated generators.
`identify` combines detected signals into a `ProvenanceReport`. It reports
unknown when evidence is absent. It never treats missing metadata as proof that
an image is human made. C2PA presence alone is not a verified identity: the
report distinguishes asset binding, claim signature, signer trust, and signer
validity. High-confidence C2PA attribution requires all four; intact but
untrusted or fallback claims are medium-confidence, while a failed binding or
signature contributes no origin verdict.
## File and container formats
Pixel based image commands discover these extensions:
- PNG;
- JPEG;
- WebP;
- HEIC and HEIF;
- AVIF.
HEIC, HEIF, and AVIF pixel decoding requires the independent `heif` extra in
addition to the selected pixel feature. Metadata scanning does not.
Metadata inspection and removal additionally have container paths for:
- JPEG XL metadata;
- MP4, MOV, M4V, and M4A;
- WebM, MKV, MKA, AVI, FLV, MP3, WAV, FLAC, OGG, OGA, Opus, and AAC when
ffmpeg is available.
JPEG image metadata stripping removes targeted metadata segments without
re-encoding the entropy coded image scan. PNG and WebP removal preserves pixel
values through lossless output paths. HEIC, HEIF, AVIF, and other containers
use their format specific paths.
## Invisible watermarks
The `invisible` command uses diffusion regeneration. It targets watermark
patterns by changing the image rather than decoding and deleting a known
payload.
Current pipeline values, both CUDA-only:
- `qwen-zimage`, the default;
- `sdxl-zimage`, the same recipe and the same face stage on an SDXL global pass.
The `controlnet`, `sdxl`, `qwen` and `default` values were removed. A retired name
is rejected at parse time rather than remapped onto a surviving profile.
SynthID does not have a public local pixel decoder in this project. The tool
recognizes presence from supported provenance: Google AI C2PA under Google's
all-media watermark policy, and current OpenAI C2PA carrying an explicit
`c2pa.watermarked.*` action. Legacy OpenAI C2PA without that action does not
assert SynthID. After provenance metadata is removed, a local negative result
is still inconclusive.
For MP4, MOV, and M4V, `video invisible` or the explicit
`video all --invisible` option can regenerate the video through a VAE and strip
source metadata. The shipped profile is oracle-certified, but it is not a local
decoder. A fresh source-positive, output-negative pair from Gemini's built-in
SynthID verifier is an optional per-file audit. A normal Gemini answer may instead
infer from a visible logo or metadata; asking it to reinterpret a completed
verifier result is not a second oracle run.
The optional `detect` extra is different: it provides a local decoder for the
open DWT-DCT watermark used by some Stable Diffusion, SDXL, and FLUX workflows.
That signal is carrier and transformation sensitive, so a negative is still
not a universal clean verdict.
## Provider overview
| Provider or family | Visible | Invisible path | Metadata or provenance |
| --- | --- | --- | --- |
| Google Gemini | Sparkle | Diffusion regeneration for SynthID | C2PA and related source signals |
| Google Veo video | Veo diamond and legacy text | Oracle-certified VAE removal for SynthID | C2PA and related source signals |
| OpenAI image generators | None registered | Diffusion regeneration for supported invisible signals | C2PA and generator provenance |
| Stable Diffusion and SDXL | None registered | Diffusion regeneration; optional open decoder | Embedded parameters and text metadata |
| FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources |
| Adobe Firefly | None registered | Optional TrustMark Variant P decoder | C2PA |
| Midjourney | None registered | No registered pixel decoder | EXIF, XMP, and IPTC signals |
| Luma AI | None registered | No registered pixel decoder | PNG text generator tags (Uni-1) |
| ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC, supported C2PA, and exact app-export AIGC disclosures |
| Qwen | Qwen mark | No registered pixel decoder | TC260 AIGC |
| Kling | Kling image and video marks | No registered pixel decoder | TC260 AIGC |
| Hailuo / MiniMax video | Hailuo composite video label | No registered pixel decoder | TC260 AIGC where present |
| Baidu | Baidu mark | No registered pixel decoder | TC260 AIGC |
| LibLibAI | LibLibAI mark | No registered pixel decoder | TC260 AIGC |
| RunningHub | RunningHub mark | No registered pixel decoder | TC260 AIGC |
| Samsung Galaxy AI | One locale specific mark | No registered pixel decoder | C2PA and Samsung markers |
For detector thresholds, measured limits, and incident history, see
[module internals](module-internals.md) and
[known limitations](known-limitations.md).
@@ -0,0 +1,191 @@
# Deep research: SynthID-safe face-identity recovery for SDXL (2026-06-08)
> Research archive. This dated follow-up records evidence available during the
> study. It is not the current command reference. The `instantid` and `photomaker`
> extras it discusses, the `--restore-faces` flag, and their modules were all removed
> from the library afterwards.
**Stats:** {"angles": 6, "sourcesFetched": 28, "claimsExtracted": 104, "claimsVerified": 25, "confirmed": 19, "killed": 6, "afterSynthesis": 6, "urlDupes": 1, "budgetDropped": 7, "agentCalls": 111}
## Summary
For SDXL-based SynthID-removal pipelines, the entire ArcFace-class identity-adapter ecosystem (PhotoMaker-V2, InstantID, PuLID, IP-Adapter FaceID, Arc2Face) is blocked from commercial use by a single chokepoint: InsightFace's pretrained model packs (buffalo_l, antelopev2, buffalo_s, buffalo_m) are explicitly non-commercial research-only, regardless of the adapter weights' Apache-2.0/MIT license. The InstantX maintainers themselves acknowledged this on HuggingFace ("cannot be Apache 2.0 if it is using Insight Face") and stated intent to retrain on commercially-licensed embedders — work that as of the verified sources has not shipped. The only verified commercial-safe SDXL adapter surfaced is MS-Diffusion (ICLR 2025), which uses CLIP-ViT-bigG-14 instead of ArcFace and runs on SDXL-base-1.0, but it is an IP-Adapter-class subject-similarity method, not a face-identity method — CLIP image embeddings are empirically weaker for face identity than ArcFace (~81% vs ~88% face-ID accuracy), so it solves the license problem but likely not the identity-fidelity problem. Arc2Face is SD1.5-only and so not a drop-in regardless of license; StyleGAN2-based methods (e4e) terminate in a GAN generator with no SDXL wiring. Net: no fully commercial-safe SynthID-robust SDXL identity-preservation stack with ArcFace-grade fidelity exists today — the space is genuinely blocked by InsightFace's grip on commercial ArcFace embeddings, and AdaFace as a permissive ArcFace alternative was REFUTED in verification.
## Findings
### 1. The InsightFace pretrained model packs (buffalo_l, antelopev2, buffalo_s, buffalo_m) are non-commercial research-only and this restriction propagates at runtime to any adapter that calls FaceAnalysis(), regardless of the adapter's own license tag.
**Confidence:** high
**Vote:** 3-0 unanimous across 4 supporting claims
InsightFace upstream: code is MIT but 'The training data containing the annotation (and the models trained with these data) are available for non-commercial research purposes only.' Licensing page enumerates buffalo_l, antelopev2, buffalo_s, buffalo_m as needing separate commercial licensing. InstantX maintainer on HF: 'cannot be Apache 2.0 if it is using Insight Face... we plan to train on other face encoders that support commercial license.' Mechanical propagation: the runtime FaceAnalysis() call pulls these packs.
- https://github.com/deepinsight/insightface
- https://www.insightface.ai/solutions/face-recognition-licensing
- https://huggingface.co/InstantX/InstantID/discussions/2
### 2. InstantID is Apache-2.0 on the adapter weights and architecturally a clean plugin to SDXL-base-1.0 (no UNet training, semantic IdentityNet conditioning + landmark weak-spatial, no pixel copying) — but at runtime it instantiates FaceAnalysis(name='antelopev2'), inheriting the InsightFace non-commercial restriction.
**Confidence:** high
**Vote:** 3-0 across 5 claims
HF model card: 'License: apache-2.0' AND 'For face encoder, you need to manutally download via this URL to models/antelopev2' with 'from insightface.app import FaceAnalysis' in usage. Diffusers community pipeline shows literal `app = FaceAnalysis(name='antelopev2', ...)` then `face_emb = face_info['embedding']`. Paper: 'IdentityNet by imposing strong semantic and weak spatial conditions, integrating facial and landmark images with textual prompts' + 'seamlessly integrates with SD1.5 and SDXL'. So mechanism is semantic (good for SynthID-no-pixel-leak) but license is blocked.
- https://huggingface.co/InstantX/InstantID
- https://github.com/huggingface/diffusers/blob/main/examples/community/pipeline_stable_diffusion_xl_instantid.py
- https://arxiv.org/pdf/2401.07519
- https://instantid.github.io/
### 3. Arc2Face is SD1.5-only (not SDXL) AND requires InsightFace antelopev2 with arcface.onnx replacing glintr100.onnx — so it is doubly blocked: not a drop-in for the SDXL pipeline AND non-commercial via the model pack.
**Confidence:** high
**Vote:** 3-0 across 3 claims
Arc2Face README verbatim: 'Arc2Face is built upon SD1.5' with stable-diffusion-v1-5 checkpoint as base. Same README: 'manually download the antelopev2 package and place the checkpoints under models/antelopev2', 'Download arcface.onnx from HuggingFace', 'delete glintr100.onnx (the default backbone from insightface)'. Code is MIT but the runtime antelopev2 dependency carries the non-commercial restriction. No SDXL adaptation exists in the official repo.
- https://github.com/foivospar/Arc2Face
### 4. MS-Diffusion (ICLR 2025) is the one verified candidate that combines SDXL-base-1.0 as its foundation with a non-ArcFace image encoder (CLIP-ViT-bigG-14), so it avoids the InsightFace non-commercial blocker — but it is an IP-Adapter-class multi-subject personalization method, NOT a face-identity-recognition method, so license safety does not equal face-identity fidelity.
**Confidence:** medium
**Vote:** 3-0 on the narrow factual claims (SDXL base + CLIP-G encoder)
GitHub README explicitly instructs 'Download the pretrained base models from SDXL-base-1.0 and CLIP-G' (CLIP-ViT-bigG-14-laion2B-39B-b160k). Neither README nor arXiv 2406.07209 mention ArcFace/InsightFace/antelopev2/buffalo_l. Architecturally descended from IP-Adapter (CLIP-image-embedding family), not from FaceID/InstantID/PhotoMaker-V2. Verifier caveat (high confidence on the license-narrow claim, medium on suitability): CLIP-image face-ID accuracy ~80.95% vs specialized face recognition ~87.61% — license-safe but probably not identity-grade for portraits. Confidence is medium because suitability for portrait and group-photo inputs has not been validated empirically.
- https://proceedings.iclr.cc/paper_files/paper/2025/file/ed4df1609bf7d8602435341c9ce2ab5f-Paper-Conference.pdf
- https://github.com/MS-Diffusion/MS-Diffusion
### 5. ID-Aligner and the StyleGAN2/e4e-based identity method (arXiv 2510.25084) do not solve the problem: the former does not disclose embedder/license in the primary source; the latter terminates identity in a StyleGAN2 generator with no SDXL adapter, so it cannot be wired into the SDXL+canny ControlNet pipeline.
**Confidence:** high
**Vote:** 3-0 across 3 claims
ID-Aligner paper: no license terms, no weight-release status, no specific face embedder named in the primary source — commercial-safety unresolved. arXiv 2510.25084: 'facial identity features... mapped into the W+ latent space of StyleGAN2 using the e4e encoder' — identity pixels come from StyleGAN2's generator, not from any SDXL-compatible adapter, so architecturally incompatible with our SDXL+canny ControlNet pipeline. Abstract also does not name the face embedder or disclose weights license.
- https://arxiv.org/pdf/2404.15449
- https://arxiv.org/pdf/2510.25084
### 6. The honest verdict: no fully commercial-safe SynthID-robust ArcFace-grade face-identity stack for SDXL exists today; the space is blocked by InsightFace's grip on ArcFace-class pretrained packs, and the verified permissive alternative (AdaFace as a drop-in replacement) was REFUTED in this verification round.
**Confidence:** high
**Vote:** 3-0 on the chokepoint claim, 0-3 against the AdaFace escape hatch
Every ArcFace-grade SDXL adapter audited (PhotoMaker-V2, InstantID, PuLID, IP-Adapter FaceID, Arc2Face) instantiates an InsightFace FaceAnalysis pack at runtime. The maintainer-acknowledged commercial-retrain path (InstantX) has not shipped. AdaFace as a permissive ArcFace alternative was refuted 0-3 by the adversarial verifier (MIT-licensed claim and drop-in-replacement claim both failed). Outcome: commercial-safe option for SDXL today is CLIP-image-embedding-based (MS-Diffusion class), which is weaker for face identity than ArcFace. For non-commercial / research-only deployments, InstantID on SDXL is the strongest semantic-only (no-pixel-leak) candidate.
- https://github.com/deepinsight/insightface
- https://huggingface.co/InstantX/InstantID/discussions/2
- https://github.com/mk-minchul/AdaFace
## Caveats
Six claims were refuted in adversarial verification, two of them load-bearing: AdaFace as a permissive ArcFace drop-in (both the MIT license and the drop-in characterization failed 0-3) — so the most attractive escape hatch from the InsightFace chokepoint did not survive verification. PuLID's superiority claim and ID-Aligner's embedder claim also failed, leaving those methods uncharacterized at the mechanism level. None of the verified claims directly answered the diffusers-0.38 compat question — InstantID's compat with our shipped diffusers version is unverified by primary source. MS-Diffusion's identity-fidelity for portraits is not empirically validated for the SynthID-removal use case; it is verified as a CLIP-G/SDXL adapter, not as a face-identity method. No 2025-2026 candidate other than MS-Diffusion was both surfaced AND verified as license-safe and SDXL-compatible — the verification round did not produce a confirmed CCSR-V2/ConsistencyID/OmniGen-V2/ConsistentID/MagicID candidate. The multi-face scenario (group photos) was not addressed by any verified claim — MS-Diffusion is the only multi-subject candidate but its face-identity strength is unmeasured. Time-sensitivity: InstantX's stated intent to retrain on commercial embedders ("We agree, we plan to train on other face encoders that support commercial license") is dated and unfulfilled per the verified sources; this could change.
## Open questions
- Does MS-Diffusion (or any CLIP-image-embedding SDXL adapter) achieve usable face-identity fidelity on portraits and group photos, or is the ArcFace gap (~7 pp face-ID accuracy) visually disqualifying — and can a face-specific CLIP fine-tune close it?
- Has InstantX (or any community fork) actually shipped an InstantID variant retrained on a commercially-licensed face embedder since the maintainer's 2024 commitment, and if so what is its identity-fidelity vs the antelopev2 original?
- What is the exact diffusers-0.38 compat status of InstantID, MS-Diffusion, and PuLID-FLUX inference scripts — does any need a fork the way PhotoMaker-V1 did, and if so what specifically breaks?
- Is there a single-pipeline multi-subject identity-preservation method (mask-guided regional ID-adapters, multi-subject InstantID, MS-Diffusion multi-subject mode) that handles group photos without the per-face crop+composite patchwork that PhotoMaker-V2 produced?
## Refuted claims
- **AdaFace code is released under the MIT license, making it permissively licensed for commercial use (in contrast to InsightFace's research-only model packs).** — vote 0-3 — source: https://github.com/mk-minchul/AdaFace
- **AdaFace produces 512-dim face recognition embeddings comparable to ArcFace, positioning it as a drop-in alternative for ID-conditioning adapters that currently depend on InsightFace ArcFace.** — vote 0-3 — source: https://github.com/mk-minchul/AdaFace
- **The paper claims PuLID achieves superior performance in both ID fidelity and editability compared to prior ID-customization methods.** — vote 0-3 — source: https://arxiv.org/pdf/2404.16022
- **Identity consistency in ID-Aligner is enforced by reward feedback from face detection and recognition models, implying dependency on an external face-recognition embedder (typically ArcFace/InsightFace-class) rather than a CLIP-only path.** — vote 1-2 — source: https://arxiv.org/pdf/2404.15449
- **The pipeline decouples face detection from inference by accepting a pre-computed embedding via image_embeds, so any ArcFace-class embedder could be swapped in if a commercially-licensed equivalent existed.** — vote 1-2 — source: https://github.com/huggingface/diffusers/blob/main/examples/community/pipeline_stable_diffusion_xl_instantid.py
- **The model card does not explicitly prohibit commercial use, but the license of the required InsightFace antelopev2 embedder is not specified on this page.** — vote 1-2 — source: https://huggingface.co/InstantX/InstantID
## Sources
- [source](https://huggingface.co/InstantX/InstantID/discussions/2)
- [source](https://www.insightface.ai/solutions/face-recognition-licensing)
- [source](https://github.com/mk-minchul/AdaFace)
- [source](https://github.com/foivospar/Arc2Face)
- [source](https://apatero.com/blog/instantid-vs-pulid-vs-faceid-ultimate-face-swap-comparison-2025)
- [source](https://arxiv.org/pdf/2404.16022)
- [source](https://instantid.github.io/)
- [source](https://arxiv.org/pdf/2404.15449)
- [source](https://arxiv.org/pdf/2511.11989)
- [source](https://arxiv.org/pdf/2510.25084)
- [source](https://github.com/huggingface/diffusers/blob/main/examples/community/pipeline_stable_diffusion_xl_instantid.py)
- [source](https://huggingface.co/InstantX/InstantID)
- [source](https://github.com/huggingface/diffusers/issues/9158)
- [source](https://github.com/huggingface/diffusers/issues/5904)
- [source](https://github.com/Mikubill/sd-webui-controlnet/discussions/2589)
- [source](https://arxiv.org/pdf/2401.07519)
- [source](https://proceedings.iclr.cc/paper_files/paper/2025/file/ed4df1609bf7d8602435341c9ce2ab5f-Paper-Conference.pdf)
- [source](https://github.com/huggingface/diffusers/issues/8626)
- [source](https://arxiv.org/pdf/2404.04243)
- [source](https://huggingface.co/OmniGen2/OmniGen2)
- [source](https://github.com/VectorSpaceLab/OmniGen)
- [source](https://github.com/ToTheBeginning/PuLID)
- [source](https://arc2face.github.io/)
- [source](https://github.com/mk-minchul/AdaFace/blob/master/LICENSE)
- [source](https://github.com/IrvingMeng/MagFace/blob/main/LICENSE)
- [source](https://github.com/askerlee/AdaFace-dev)
- [source](https://openreview.net/forum?id=Hc2ZwCYgmB)
- [source](https://github.com/tencent-ailab/IP-Adapter/wiki/IP%E2%80%90Adapter%E2%80%90Face)
## Empirical follow-up (2026-06-08, end of session)
After the research synthesis above, InstantID was integrated end-to-end and cert-swept
on Modal A100 in two phases:
1. **Phase 1: InstantID txt2img per-face crop + composite.** Per-face InstantID
txt2img with the upstream `pipeline_stable_diffusion_xl_instantid`, ArcFace
embedding from the original face, landmark stick figure. Three composite
iterations:
- v1 (rectangular Gaussian alpha on the 2x square_box around each face):
visible patchwork on group photos, generated 1024 backgrounds clashing.
- v2 (tight crop on YuNet-detected face in the generated 1024 + elliptical
alpha 0.45*bw x 0.55*bh + soft feather): ellipse axis exceeded bbox
vertically, clipped forehead/chin on single portrait, group still had
visible elliptical seams + cool-vs-warm tone clash with scene.
- v3 (tighter ellipse 0.32*bw x 0.42*bh + per-channel mean color match to
local cleaned canvas + softer feather): patchwork visually softened; faces
still read as studio portraits inserted into the scene, not as people
shot in the scene. Single portrait identity drifted (the single-portrait
fixture regenerated as a generic "round Asian male" vs the original's thin
face).
2. **Phase 2: InstantID img2img on cleaned crop.** Switched to the upstream
`pipeline_stable_diffusion_xl_instantid_img2img` (downloaded at first use
from raw.githubusercontent.com; requires `trust_remote_code=True`). Same
ArcFace + landmark conditioning but the SDXL diffusion source is the
CLEANED face crop, so the diffusion sees scene lighting / shoulders /
shadow direction directly. Multi-face composition jumped substantially:
faces sit in the bar scene with matching warm tone, no more elliptical
seams. Single-portrait identity at the default (`strength=0.55`,
`ip_adapter_scale=0.8`, `controlnet_conditioning_scale=0.8`) was "similar
person, not exactly the original"; raising to `strength=0.7`,
`ip_adapter_scale=1.0`, `controlnet_scale=1.0` brought identity closer to
original but introduced more "SDXL gloss / clean skin" aesthetic.
**Net finding for a commercial deployment (load-bearing).** The fundamental issue is structural:
ArcFace encodes "this person's general look" (ethnicity, gender, basic facial
geometry) at 512 dimensions; SDXL decodes that embedding into pixels with the
inherent SDXL aesthetic (smooth skin, symmetric pores, AI-photoreal look).
Stronger identity push (higher strength / IP-Adapter scale) makes the face
CLOSER to the embedded identity but MORE AI-looking; weaker push leaves
identity to drift but face looks less AI-generated. There is no parameter
setting that simultaneously recovers original identity AND looks less AI than
the cleaned image, because the cleaned image is itself a controlnet-light
denoise of the original (closer to original pixels) while a restore pass is a
full SDXL regeneration (further from original pixels).
**Operational conclusion.** Do not ship `--restore-faces` in any monetized
deployment. The cleaned image from the main controlnet 0.20 pass is the
LEAST-AI state we can reach without re-introducing SynthID; every restore
method tested (GFPGAN-on-cleaned, PhotoMaker-V2, InstantID txt2img,
InstantID img2img-on-cleaned at three parameter sweeps) trades original-look
for embedding-driven regeneration and makes the face read as "AI-generated"
rather than "the original person". The `instantid` and `photomaker` extras
stay in the library as opt-in for research / personal use where users
explicitly want identity regeneration; the CLI flag and module docstrings
state the trade-off at every entry point.
+290
View File
@@ -0,0 +1,290 @@
# SynthID-robust face identity for an SDXL removal pipeline (research)
> Research archive. This document evaluates approaches considered during the
> study. It does not describe the current public interface. The current
> pipelines and limits are documented in `docs/cli.md` and
> `docs/known-limitations.md`. The path it calls shipped -- `photomaker_restore.py`
> and the `photomaker` extra -- was removed from the library afterwards.
> **Status (2026-06-08): retired.** Every approach described below was empirically
> tested and rejected -- see `docs/synthid-robust-identity-research-2026-06-08.md`
> "Empirical follow-up" for the final conclusion. The library no longer ships any
> face-restore extra: the cleaned image from the main controlnet 0.20 pass is the
> least-AI face state we can reach without re-introducing SynthID. This document
> is kept as historical record of the exploration.
**Question.** Which face identity-preservation mechanism for an SDXL img2img +
canny-ControlNet watermark-removal pipeline (denoise 0.20-0.30) is BOTH (a)
commercial-safe end-to-end and (b) does not re-introduce the SynthID pixel
watermark the removal pass just destroyed?
**Constraint.** A paid deployment requires every component (adapter weights AND
the face embedder it conditions on AND any base model) must be Apache-2.0 / MIT /
BSD or otherwise clearly commercial-permitted. Non-commercial is disqualifying.
**One-line verdict.** Today there is **ONE** SDXL identity-conditioning stack that
is commercial-safe end-to-end: **PhotoMaker-V1** (Apache-2.0, identity encoded as a
fine-tuned OpenCLIP-ViT-H/14 image embedding -- NO InsightFace). Every other
candidate -- **including PhotoMaker-V2**, IP-Adapter FaceID, InstantID, PuLID,
Arc2Face -- inherits InsightFace's non-commercial model-pack license through an
ArcFace-class embedder and is therefore blocked for paid services, regardless of
the adapter's own license header. Below is the evidence per component and the
integration plan.
**Correction notice (2026-06-04).** An earlier version of this doc claimed
PhotoMaker-V2 was commercial-safe end-to-end. That was WRONG -- the V2 model card
phrase *"id_encoder includes finetuned OpenCLIP-ViT-H-14 and a few fuse layers"*
described one of TWO ID branches; the V2 source (model_v2.py) defines
`PhotoMakerIDEncoder_CLIPInsightfaceExtendtoken` whose forward takes an
ArcFace `id_embeds` from `insightface.app.FaceAnalysis`, and the upstream package
`__init__.py` imports InsightFace at module load. A Modal cert sweep caught this
empirically (`No module named 'insightface'` from `restore_faces_photomaker`). V1
is the correct commercial-safe target: its `PhotoMakerIDEncoder` (model.py)
forward takes only `(id_pixel_values, prompt_embeds, class_tokens_mask)` -- no
ArcFace branch -- so identity is CLIP-only.
**Status notice (2026-06-04, end of session).** Two commercial-safe paths were
tried and abandoned:
1. **PhotoMaker-V1** (commercial-safe by license but blocked by upstream compat).
The cert sweep hit a cascade of upstream compatibility issues with the diffusers
version we ship (0.38): missing `einops` declaration, missing `peft` declaration,
default `pm_version='v2'` that mis-loads V1 weights into the V2 encoder, custom
`id_encoder` left on CPU after `pipe.to(device)`, and a CFG-batch tensor-shape
mismatch in the denoising loop (`Expected size 2 but got size 1`). 7 cascading
fixes did not get the pipeline running end-to-end. The PhotoMaker `pipeline.py`
header notes it was forked from diffusers v0.29.1; SDXL prompt-encoder handling
changed significantly between 0.29 and 0.38.
2. **GFPGAN on the diffusion-CLEANED image** (commercial-safe, but no identity
recovery). A one-line change made it SynthID-safe (input pixels are already
clean, so the partial blend cannot transport the watermark), but visual
inspection of the cert output showed it only polished the already-drifted face
without actually restoring identity. Trade-off was real and the value too low
to keep.
**The shipped path is PhotoMaker-V2** (`photomaker_restore.py`, the `photomaker`
extra). V2 uses a DUAL ID encoder (CLIP image features + ArcFace embedding),
which delivers true identity-from-embedding face regeneration. The cost is that
the ArcFace embedding comes from InsightFace's `antelopev2`/`buffalo_l` model
packs, which are released under a non-commercial / research-only license. **So
the shipped restore path is NON-COMMERCIAL.** Any monetized
deployment must NOT install the `photomaker` extra. The CLI flag and module
docstring both call this out at every entry point.
A future commercial-safe path would need either (a) the PhotoMaker upstream to
land its diffusers 0.38 compat fix so V1 can run, or (b) an equally good
ArcFace-class face-recognition model released under a permissive license that
PhotoMaker-V2 can be retargeted to. Neither is on a near-term horizon as of
this writing.
## 1. Why identity-by-embedding (not by pixel) is the only SynthID-robust path
The pipeline regenerates pixels to destroy SynthID. Any identity-restoration that
is "faithful to the input pixels" (GFPGAN, CodeFormer, face-swap-by-blending, our
previous restore-on-original pass) reproduces the watermark, because SynthID is
engineered to be robust to fidelity-preserving transforms (resize, JPEG, partial
blend). Oracle-confirmed on a real Gemini face: controlnet @ 0.20/0.25 WITH the
GFPGAN restore pass left SynthID detected; the SAME controlnet @ 0.20 with
`--no-restore-faces` cleared it (clean A/B, see `docs/synthid.md` 5.5 and
`docs/controlnet-removal-pipeline-research.md`).
The only mechanism that can preserve identity AND not re-introduce SynthID is to
carry identity in a SEMANTIC EMBEDDING (a vector that encodes "who is in this
picture") and use it to CONDITION a fresh generation -- the pixels are new, so
the watermark is not transported. Two embedding families exist in practice:
- **ArcFace-class face-recognition embeddings** (the InsightFace family). Used by
IP-Adapter FaceID, InstantID, PuLID, Arc2Face. Highest identity fidelity, but
the embedder weights are non-commercial.
- **CLIP image embeddings of a face crop**. Used by PhotoMaker (and the original
IP-Adapter image variant). Lower identity fidelity at small scale than ArcFace,
but the encoder (OpenCLIP-ViT-H/14, MIT) is commercial-safe.
## 2. License table (verified against primary sources, 2026-06-04)
| stack | adapter weights | identity encoder | end-to-end commercial-safe? |
|---|---|---|---|
| **PhotoMaker-V1** | **Apache-2.0** ([HF][pmhf]) | **OpenCLIP-ViT-H/14 (MIT)** finetuned, identity from `PhotoMakerIDEncoder` (`model.py`); forward takes only ``(id_pixel_values, prompt_embeds, class_tokens_mask)`` -- no ArcFace branch | **YES** |
| PhotoMaker-V2 | Apache-2.0 (adapter) ([HF][pm2hf]) | DUAL encoder: OpenCLIP-ViT-H/14 AND InsightFace antelopev2/buffalo_l -- `PhotoMakerIDEncoder_CLIPInsightfaceExtendtoken` (`model_v2.py`) forward takes `id_embeds` from `insightface.app.FaceAnalysis`, and `photomaker/__init__.py` imports InsightFace at module load | NO -- InsightFace pack is non-commercial |
| IP-Adapter FaceID | non-commercial per model card: *"AS InsightFace pretrained models are available for non-commercial research purposes, IP-Adapter-FaceID models are released exclusively for research purposes and is not intended for commercial use"* ([HF][ipafhf]) | InsightFace antelopev2 (non-commercial for the model pack) | NO -- both layers block |
| InstantID | Apache-2.0 (adapter only) ([HF][insthf]) | requires InsightFace antelopev2 face-analysis at runtime (`FaceAnalysis(name='antelopev2', ...)` per the README usage snippet, [HF][insthf]) | NO -- embedder pack is non-commercial |
| PuLID | apache-2.0 (HF model metadata, [HF][pulidhf]) | depends on InsightFace face-analysis for ArcFace embedding (per the upstream README; PuLID's own card is sparse and the GitHub README documents the InsightFace install step) | NO -- same embedder issue as IP-Adapter FaceID |
| Arc2Face | MIT (HF model metadata, [HF][arc2hf]) | uses `insightface.app.FaceAnalysis` to extract the ArcFace embedding ([HF][arc2hf]); also based on SD-v1-5 (NOT SDXL) | NO -- non-commercial embedder + not SDXL |
**The crux is InsightFace.** InsightFace explicitly splits its license: *"Code is
MIT licensed; models require separate commercial licensing"* and frames the
pretrained packs as *"Commercial licensing for InsightFace's open-source model
packages"* requiring users to *"obtain commercial usage rights for model
packages"* ([insightface.ai][iflic]). antelopev2 and buffalo_l fall under the
model-pack license, not MIT. So any stack that calls
`insightface.app.FaceAnalysis(name='antelopev2', ...)` to compute its ArcFace
embedding is blocked by default, REGARDLESS of the adapter's own Apache header
above it. This is the same reason IP-Adapter FaceID's card flags itself
non-commercial.
(Note on PuLID's HF metadata: the model card declares apache-2.0 for the adapter
weights but the upstream repo's quickstart requires the InsightFace package to
extract the ID embedding. So PuLID's adapter license is permissive; the BLOCKER
is the embedder it expects at runtime. This is the same trap as InstantID.)
[pmhf]: https://huggingface.co/TencentARC/PhotoMaker
[pm2hf]: https://huggingface.co/TencentARC/PhotoMaker-V2
[ipafhf]: https://huggingface.co/h94/IP-Adapter-FaceID
[insthf]: https://huggingface.co/InstantX/InstantID
[pulidhf]: https://huggingface.co/guozinan/PuLID
[arc2hf]: https://huggingface.co/FoivosPar/Arc2Face
[iflic]: https://www.insightface.ai/solutions/face-recognition-licensing
## 3. Is there a commercial-safe ArcFace replacement?
Short answer: **no clean drop-in**. The widely deployed pretrained ArcFace packs
(antelopev2, buffalo_l, glint360k) come from InsightFace and are non-commercial.
ArcFace as an ARCHITECTURE is published in a paper, so retraining is legally fine,
but you would need:
- a commercial-licensed training dataset (the big public ones -- MS-Celeb-1M,
Glint360K, WebFace -- carry research-only or licensing-uncertain restrictions);
- compute + time to train an ArcFace-class model on the legal dataset;
- the result would be a one-off effort, not a maintained dependency.
For a removal service this is a multi-month side project that delivers what
PhotoMaker already gives us with one pip install. So the practical answer is to
take the CLIP-embedding path (PhotoMaker-V1; V2 adds InsightFace and is non-commercial), accept the identity-fidelity
trade-off, and revisit ArcFace later if quality is insufficient.
## 4. Does an identity embedding leak SynthID?
This is the load-bearing assumption of the whole approach. The argument:
- SynthID is a low-amplitude, perceptually-invisible pixel watermark engineered
to be robust to "fidelity-preserving" transforms (it survives JPEG, resize,
crop, color, noise at >=99% TPR -- see arXiv:2510.09263 referenced in
`docs/synthid.md`).
- A face-recognition / CLIP-image embedding is by design INVARIANT to such low-
amplitude pixel changes (compression, brightness, small noise should not change
"who is in the photo"). That is the whole training objective.
- Therefore the embedding extracted from a watermarked face vs. the same face
cleaned should be ~identical -- the embedding cannot CARRY the watermark
pattern, only the identity, because the watermark sits in exactly the
dimensions the embedding learned to discard.
**MEASURED 2026-06-04 — hypothesis confirmed.** Ran a low-amplitude
perturbation sweep on 31 face crops (3 photoreal originals: gemini_3, gemini_4,
openai_3 grid), comparing `cos(embedding(orig), embedding(perturbed))` for OpenCLIP-
ViT-H/14 (laion2B-s32B-b79K, the same OpenCLIP-ViT-H/14 encoder PhotoMaker V1 and V2 both finetune for CLIP-side identity):
| perturbation | mean cos | min | max |
|---|---|---|---|
| **synthid_proxy** (±2 LSB low-freq noise, σ=4 px Gaussian carrier — same regime SynthID hides in) | **0.9977** | 0.9937 | 0.9996 |
| noise3 (Gaussian σ=3, full-spectrum) | 0.9541 | 0.9055 | 0.9825 |
| jpeg90 (SynthID survives this) | 0.9280 | 0.8806 | 0.9566 |
| blur1 (Gaussian σ=1) | 0.9139 | 0.8103 | 0.9875 |
| jpeg70 | 0.8945 | 0.8125 | 0.9603 |
| (self check: identical crop) | 1.0000 | 1.0000 | 1.0000 |
The SynthID-magnitude perturbation moves the embedding by **0.002** (cosine 0.9977),
an order of magnitude less than JPEG90 — which SynthID survives at >=99% TPR by
design. So the embedding cannot carry the watermark pattern: its discriminative
signal is in dimensions the SynthID payload does not occupy. PhotoMaker-V1
conditioned on a watermarked face will see ~the same identity vector as if
conditioned on a clean face of the same person, so the freshly generated face
inherits the identity, not the watermark.
A first, naive smoke run measured `cos(orig, SDXL-cleaned)` instead — that test is
about diffusion drift, not watermark invariance (diffusion at strength 0.20-0.30 is a
much larger perturbation than SynthID), so its 0.56-0.93 spread is the identity
drift the PhotoMaker pipeline is meant to fix in the first place. The
synthid_proxy result above is the one that actually answers the load-bearing
question. Script: `/tmp/identity_smoke/test2_proxy.py` (not committed; reproducible
from the test set + this doc).
## 5. PhotoMaker-V1 properties for our pipeline
- **SDXL-native.** PhotoMaker v1 and v2 target Stable Diffusion XL; the pipeline
is a stacked-ID embedding fused into SDXL's cross-attention via the fuse layers
bundled in the released weights.
- **Identity from a SINGLE reference image works** but the method was designed
for "stacked" multi-reference; with one image identity fidelity is lower than
with 3-4, and a service has only one (the upload). This is the failure mode to
guard.
- **Compatibility with img2img + canny ControlNet.** PhotoMaker is typically
exposed in txt2img workflows in the upstream demo. SDXL img2img + ControlNet
is the same denoising backbone, so the cross-attention injection works the same
way; community examples on Diffusers and ComfyUI confirm PhotoMaker stacks with
ControlNet. Validate this on a representative image before adopting.
- **Failure modes to expect:**
- identity drift on small / multi-face groups (the 9-face grid case);
- "plastic" / over-smoothed faces if PhotoMaker's identity weighting is high
while the img2img strength is low;
- canny ControlNet conditioning can fight the ID embedding (edges of the
ORIGINAL face vs identity of the SAME person regenerated) -- expect to tune
`controlnet_conditioning_scale` down a notch on photoreal faces;
- PhotoMaker was trained on a celebrity-skew distribution; representative faces
(especially non-white, non-Western, elderly, children) may have lower
fidelity. Measure on a representative evaluation distribution.
## 6. Integration cost (rough)
- New deps: `diffusers` already in the diffusion extra; PhotoMaker ships as a `.bin`
loaded via `pipeline.load_photomaker_adapter(...)`. The OpenCLIP encoder is the
same one diffusers already pulls. No new heavy pip dep.
- Weight download: PhotoMaker-V1 weights are ~3 GB. Add to the Modal HF volume
alongside SDXL.
- VRAM: SDXL + canny ControlNet + PhotoMaker-V1 fits comfortably in A100-40GB.
- Latency: a few extra seconds on cold start (load PhotoMaker), negligible per
request after warm-up.
- No InsightFace install: huge win for `restore` extra's basicsr/numpy hell --
this path simply does not touch that ecosystem.
## 7. Recommended path
1. **Embedding-invariance smoke test FIRST** (one afternoon, no codegen):
- For ~10 OpenAI / Gemini watermarked faces, compute OpenCLIP-ViT-H/14
embeddings; for the same images after our SDXL `default` pass at the
certified strength, compute the embeddings again; assert mean cosine
similarity > ~0.95.
- If yes -> the embedding does not carry SynthID, proceed.
- If no -> the assumption is wrong; PhotoMaker would re-introduce the
watermark. Stop and reconsider.
2. **PhotoMaker-V1 prototype** in the existing `controlnet` pipeline:
- Mirror the `_load_controlnet_pipeline` path (removed in 0.24.0 with the
controlnet profile; the equivalent seam is now `SdxlZImagePipeline._load_sdxl`):
add a PhotoMaker variant that
loads SDXL + canny ControlNet + PhotoMaker adapter on the same engine.
- Extract the OpenCLIP face embedding from the watermarked face crops (use
OpenCV YuNet, already bundled for `auto`, to find the face boxes).
- Pass the embedding as PhotoMaker's `id_embeds` to the SDXL pipeline; run
img2img at the certified strength (0.20 OpenAI, 0.30 Gemini-capped-1536)
with the canny edge map.
3. **Oracle validation** on the cert sweep: run the new PhotoMaker variant
through the isolated Modal certification harness over the same 6 image set, certify on the
per-vendor oracles. Expected: SynthID cleared (the regeneration is the same)
AND identity recovered (the embedding adds it back).
4. **Honest exit criteria.** Ship only if BOTH oracle reads clean AND a small
user-perception test on representative inputs says "looks like me". If identity is
still too soft on small faces -> add stacked-reference (multiple crops of the
same upload at different scales) before reaching for a non-commercial
embedder.
## 8. What we are NOT doing, and why
- **No InsightFace.** Non-commercial for model packs (see License table).
- **No CodeFormer.** Non-commercial.
- **No GFPGAN on the original image.** It re-introduces SynthID
(oracle-confirmed).
- **No GFPGAN on the cleaned image.** It cannot RECOVER identity that the
diffusion pass already drifted -- it can only smooth/sharpen whatever face is
already there. Useful as cosmetic polish, not as identity restoration.
- **No retraining of an in-house ArcFace.** Out of scope for a removal service.
---
## Process note
The deep-research harness was run but its verifier subagents failed to call
`StructuredOutput` (same harness bug as the prior 2026-05-XX run), so its synthesis
was unusable. The license claims above were verified by directly fetching the HF
model cards and the InsightFace licensing page and quoting them; the
embedding-invariance argument is mechanistic and explicitly flagged as not yet
measured (it is the first integration step). Do not treat the deep-research
output as ground truth for this file.
+783
View File
@@ -0,0 +1,783 @@
# SynthID: technical reference
> Technical research reference. Current package behavior is defined by the
> [supported signals](supported-signals.md), [known limitations](known-limitations.md),
> and [module internals](module-internals.md). Dated measurements below are
> historical evidence and should not be read as current CLI defaults.
This document covers how Google SynthID for images works mechanically, what it
survives, what removes it, the external video-verification workflow, and the
current deployment landscape. It is written for engineers working on watermark
detection and removal -- specifically to inform decisions about strength
settings, test methodology, and what oracle results mean.
Primary sources are cited inline. Marketing-only claims are flagged separately
from independently-verified results.
---
## 1. Mechanism
### 1.1 Post-hoc, model-independent design
SynthID-Image is **not** baked into a diffusion model's weights. It is a
post-hoc, model-independent system: a separate encoder `f` is applied to an
already-generated image, and a separate decoder `g` reads it back.
> "We deliberately designed SynthID-Image as a post-hoc, model-independent
> approach, a choice largely based on deployment considerations."
> -- Gowal et al., arXiv:2510.09263
The formal definition from the paper:
> "A post-hoc watermarking scheme is a pair f, g consisting of an encoder
> function f: X -> X, which adds an identification mark, and a decoder
> function g: X -> {+-1}, which tries to detect if the mark is present."
This is the key architectural fact: **the generative model (Imagen, Gemini's
image model) is not modified**. The watermark is stamped onto the pixel output
after generation, by a separate neural network. This means:
- The watermark is in **pixel space**, not in the model's latent activations.
- Replacing the generative model does not remove the watermarking capability.
- The encoder/decoder pair can be updated independently of the generative model.
The paper does not disclose the internal architecture of the encoder/decoder
networks (layer types, capacity). The external variant SynthID-O is available
to partners; the production internal variant is not published.
### 1.2 How it differs from classical DWT-DCT watermarks
The open watermarks used by Stable Diffusion / SDXL / FLUX (via the
`imwatermark` library) use classical **DWT-DCT** frequency-domain embedding: a
fixed bit pattern is added to specific frequency coefficients of the image's
wavelet transform. This is fast, key-free, and locally detectable with a public
decoder.
SynthID-Image uses **jointly-trained deep learning models**:
> "SynthID uses two deep learning models -- for watermarking and identifying --
> that have been trained together on a diverse set of images. The combined model
> is optimised on a range of objectives, including correctly identifying
> watermarked content and improving imperceptibility by visually aligning the
> watermark to the original content."
> -- Google DeepMind blog, 2023
The practical difference for robustness: the deep learning encoder learns to
spread the signal across the image in a way that is optimized to survive a
specific perturbation distribution seen during training. Classical DWT-DCT
embeds in fixed, predictable frequency bins, making it brittle to any
operation that hits those bins (e.g., JPEG re-quantization wipes it cleanly at
quality <= 90).
### 1.3 Payload capacity
SynthID-O (the external/partnership variant) encodes:
- **136 bits** within a **512x512 pixel image**
For comparison (from the same paper):
| Method | Bits | Resolution |
|-------------|------|------------|
| SynthID-O | 136 | 512x512 |
| StegaStamp | 100 | 400x400 |
| TrustMark | 100 | 256x256 |
| WAM | 32 | 256x256 |
The payload carries an identification mark (not a user-readable secret). The
paper separates watermark **detection** (is this watermarked?) from payload
**recovery** (what does the payload say?): the detection path is what oracles
like the Gemini app's "Verify with SynthID" exercise.
### 1.4 Where in the pipeline it lives
```
[Diffusion model]
|
raw pixel output
|
[SynthID encoder f] <-- separate neural net, stamps the watermark
|
watermarked image
|
[served / downloaded]
|
[SynthID decoder g] <-- separate neural net, run by Google's verifier only
|
present / not present
```
The VAE decoder of the diffusion model is **not** involved in watermarking.
Some in-generation watermark approaches (like the research method "Tree Ring")
inject the signal into the initial noise latent so it propagates through the
diffusion process and appears in the final image; SynthID-Image does not do
this -- it is applied after the VAE has already decoded latents to pixels.
---
## 2. Robustness
### 2.1 What the paper claims it survives (primary-source verified)
The SynthID-Image paper (arXiv:2510.09263) evaluates SynthID-O against **30
image transformations** grouped into 6 categories:
| Category | Examples |
|-------------|-----------------------------------------------|
| Color | brightness, contrast, saturation, hue shifts |
| Combination | combinations of multiple transforms |
| Noise | Gaussian noise, impulse noise, median filter |
| Overlay | text overlays, logos, stickers |
| Quality | JPEG compression, WebP, format conversion |
| Spatial | crop, resize, rotate, flip, padding |
**TPR at 0.1% FPR -- SynthID-O vs. baselines (resized to 512x512):**
| Category | SynthID-O | Best baseline (WAM) | Worst baseline (StegaStamp spatial) |
|------------------|-----------|---------------------|--------------------------------------|
| Identity (none) | 100.00% | 100.00% | 100.00% |
| Aggregated | 99.98% | 90.62% | ~70% |
| Color | 100.00% | 81.29% | ~75% |
| Combination | 99.96% | 96.08% | ~22% |
| Noise | 99.98% | 100.00% | ~92% |
| Overlay | 100.00% | 100.00% | 100.00% |
| Quality | 99.99% | -- | ~89% |
| Spatial (worst) | 99.97% | 76.04% | 15.25% |
The "Spatial worst" row is the hardest case (aggressive crop + resize).
SynthID-O retains 99.97% TPR; StegaStamp collapses to 15.25%. This is where
the deep-learning approach gains the most over classical methods.
Google's marketing page states the watermark is:
> "designed to stand up to modifications like cropping, adding filters, changing
> frame rates, or lossy compression."
> -- deepmind.google/models/synthid/
The marketing claim is broadly consistent with the paper's numbers for these
specific categories.
**JPEG and format conversion specifically** fall under the "Quality" category,
where SynthID-O achieves 99.99% TPR. This is the empirical basis for the fact
that **GitHub-recompressed JPEGs from issue attachments are valid SynthID test
subjects**: the re-encoding does not remove the pixel watermark.
### 2.2 Stated limits (vendor claim, not independently verified)
> "SynthID isn't foolproof against extreme image manipulations."
> -- Google DeepMind blog, 2023
This is the only public failure-mode statement Google has made. No specific
perturbation type, threshold, or quantitative boundary is named. The
Limitations section of the paper (Section 10) was not recoverable from the
public HTML version of arXiv:2510.09263v1 due to a rendering failure in the
conversion (the body text of Section 10 is absent from the HTML).
**What is known empirically from our own oracle-verified testing.**
A controlled study (June 2026, clean v0.8.6 with text/face protection OFF,
native resolution on this repo's default SDXL pipeline) measured the minimum
img2img strength that removes the SynthID pixel watermark, verified per image on
the vendor's own oracle (openai.com/verify for OpenAI, the Gemini app "Verify
with SynthID" for Google). The reusable originals are stored once in
`data/synthid/originals/`, with their input verification in `manifest.csv`.
Generated cleaned outputs are not committed; the table below is the durable
record of the historical oracle verdicts. One third-party image from issue #14
was oracle-verified but is not committed.
**Oracle validation order: start with OpenAI.** When validating removal across
vendors, run the OpenAI arm first. `openai.com/verify` is more accessible than the
Gemini app -- fewer per-check restrictions, so it gives the fastest signal and is
the strongest candidate for automation (Playwright / Chrome MCP driving
`openai.com/verify`); the Gemini "Verify with SynthID" flow is more manual. This is
an ordering/throughput choice, not a substitution: each oracle only reads its own
vendor's SynthID (`openai.com/verify` is OpenAI-scoped), so Google content still
needs the Gemini app.
| Vendor | Images | Resolution(s) | Pipeline | Removed at |
|--------|--------|---------------|----------|------------|
| OpenAI (gpt-image) | n=4 (3 archived + 1 external-only) | 1024x1536 .. 1600x1600 | native | **0.05** |
| Google (Gemini) | n=4 | 2816x1536 -> capped 1536 | `--max-resolution 1536` | **0.15** (0.05 and 0.10 do NOT clear) |
**Two findings, both oracle-verified:**
1. **Vendor is the dominant factor, not resolution.** Google's SynthID is
roughly 3x more robust than OpenAI's: at a comparable (small) working
resolution, OpenAI clears at 0.05 while Google needs 0.15. This matches
Google having hardened SynthID more aggressively over time.
2. **OpenAI SynthID removal is resolution-independent in the tested range.**
All four OpenAI images (including a 1600x1600) cleared at 0.05.
**CORRECTION (supersedes the earlier "resolution dependence" claim).** A prior
version of this doc and CLAUDE.md stated that strength 0.30 failed to remove
SynthID on 1600x1600 gpt-image and that removal was resolution-dependent. That
was a **measurement artifact of a since-removed per-region re-scrub step** (issue
#14): on the dense-text infographics tested, that step could reconstitute SynthID
in text regions. Re-running the *same* 1600x1600 image on the clean current
pipeline removes SynthID at **0.05**. The "large images resist removal" conclusion
was false; the resistance was that region-rescrub shielding, since removed.
**Open / not locally testable:**
- **Native large Gemini (2816x1536, ~4.3 MP).** The Gemini floor of 0.15 was
measured on the *capped* (`--max-resolution 1536`) path, which is the
practical local route on Apple-Silicon (native 2816 OOMs / falls back to slow
CPU on a 32 GB M-series). Native large Gemini was not measured here; the
vendor and resolution effects would stack, so it plausibly needs >= 0.30 or a
discrete GPU. Confirm on a CUDA box if needed.
- **Heavy JPEG compression** (quality < ~50-60): not oracle-tested; the DL
approach is more robust than DWT-DCT but Google acknowledges limits at
"extreme" manipulation.
### 2.3 Removal attacks and forensic detectability
The paper arXiv:2605.09203 ("Removing the Watermark Is Not Enough",
Goonatilake & Ateniese, 2026) evaluates 6 removal attacks against a ResNet-50
forensic detector. All attacks defeat the watermark verifier but are detected
by the forensic classifier:
| Attack | Family | AUROC | TPR @ 1% FPR | TPR @ 0.1% FPR |
|-----------------|------------------|--------|--------------|----------------|
| UnMarker | Distortion | 0.9994 | 99.81% | 98.28% |
| WatermarkAttacker| Regeneration | 0.9997 | 99.95% | 99.38% |
| CtrlRegen+ | Regeneration | 0.9999 | 99.97% | 99.64% |
| NFPA | Inversion/Pert. | 0.9984 | 99.24% | 62.10% |
| Boundary Leak. | Inversion/Pert. | 0.9991 | 99.24% | 88.34% |
| WiTS | Erosion | 0.9999 | 99.80% | 99.55% |
The forensic detector is a standard ResNet-50 fine-tuned end-to-end; no exotic
architecture needed. The key finding:
> "These removers do not return images to a clean forensic state. They often
> trade an explicit watermark for an implicit watermark: a detectable artifact
> introduced by the removal process itself."
This means: even when our SDXL img2img pass defeats the SynthID pixel
watermark (oracle reads negative), the output may still be classifiable as
"an image that went through a removal pipeline" by an independent detector --
even if that detector is not trained on SynthID specifically. **Defeating the
verifier does not restore forensic deniability.**
CtrlRegen+ is the most detectable removal method (AUROC 0.9999), which is
notable because it is also the most powerful removal attack. The paper notes
that diffusion regeneration "leaves a strong reconstruction signature from the
diffusion prior."
---
## 3. Detectability and verifier access
### 3.1 No public local detector
The SynthID decoder is proprietary and not released:
> "SynthID-Image has been used to watermark over ten billion images and video
> frames across Google's services and its corresponding verification service is
> available to trusted testers."
> -- Gowal et al., arXiv:2510.09263
There is no public API, no released decoder weights, and no reproducible
algorithm for local detection. The verification service (SynthID Detector) is:
> "a verification portal" in early testing with "journalists and media
> professionals" on a waitlist
> -- deepmind.google/models/synthid/
The external variant SynthID-O is available "through partnerships" only. Our
tool cannot locally detect SynthID presence or absence -- this is by design,
not a gap we can fill.
### 3.2 How our tool recognizes SynthID from provenance
We recognize SynthID indirectly from supported C2PA evidence; this is not a
pixel watermark decode. Google states that all media generated by its tools is
watermarked, so Google AI C2PA establishes SynthID. OpenAI C2PA predates its
SynthID rollout, but current manifests add the signed
`c2pa.watermarked.unbound` action; OpenAI provenance establishes SynthID only
when that action is present. Legacy OpenAI C2PA without it remains valid origin
evidence but does not assert a pixel watermark.
This works while the C2PA manifest is intact and is silent once the manifest is
stripped or the image is re-encoded without C2PA (e.g., a screenshot, a
social-media re-upload, or after `metadata --remove`).
This is why:
- `identify` on a GitHub-recompressed issue attachment returns Unknown (C2PA is
gone) even though the pixel SynthID is still present and detectable by
openai.com/verify.
- A quiet `identify` output is not proof that SynthID was removed -- it only
means the metadata signal is gone.
### 3.3 Oracle scope: each vendor detects only their own
From openai.com/research/verify (verbatim, verified 2026-05-31):
> "OpenAI generation signals will only be detected if the image was generated
> with our tools."
> "Content could also still be AI-generated by another company's model, which
> the tool currently does not detect."
SynthID technology is used by multiple vendors, but each verifier is keyed to
its own payload:
| Oracle | Detects | Does NOT detect |
|-------------------------------|------------------|-------------------------|
| Gemini app "Verify with SynthID" | Google SynthID | OpenAI SynthID |
| openai.com/research/verify | OpenAI SynthID | Google SynthID |
A Google-SynthID image reads clean on openai.com/verify. An OpenAI image reads
clean in the Gemini oracle. They are different payloads within the same
framework.
### 3.4 Video verification and attack harness
Gemini's built-in verification flow reports whether and where it detects Google
SynthID in a video. This remains a proprietary oracle: invoke `@synthid`, use
the supported content-verification question, and keep every file in a separate
new chat. A normal Gemini answer that discusses visual clues or metadata is not
an oracle verdict. Nor is an adversarial follow-up that asks the chat model to
ignore and reinterpret a completed verifier result.
The research harness `scripts/video_synthid_sweep.py` tests a VAE regeneration
attack without pretending to detect success locally. It emits:
1. a re-encode control using the same sampled frames, dimensions, frame rate,
and codec as the candidates;
2. VAE round-trip candidates with one spatial latent-noise field shared across
time;
3. paired PSNR and motion-compensated temporal-residual metrics;
4. an empty oracle column for the external verdict.
The control is the first oracle submission. If it is not SynthID-positive, stop:
the surrounding transcode already changed the verifier result. Only a
control-positive, candidate-negative pair is evidence about the regeneration
attack. PSNR and temporal residual measure fidelity and flicker, never watermark
presence.
The shipped `video invisible` command and `remove_video_invisible` API reuse the
same VAE regeneration mechanism for a complete input sequence. The shipped
default is oracle-certified and does not expose a separate verification-status
flag. In the 2026-07-29
two-carrier calibration, both matched controls were positive in the built-in
verifier; the stronger candidate was negative on both, while a weaker
candidate was negative on one. A 2026-07-30 `UNAVAILABLE` response came from an
ordinary-model follow-up that asked Gemini to reinterpret the already returned
verdict and therefore did not invalidate it. The default is a calibrated,
content-dependent operating point. A per-file provider check remains an
optional audit after provider changes or for unusually important files.
The 2026-07-31 full-clip calibration used Google's public eight-second Veo
off-road sample through the complete product command. The original was detected
across 00:00-00:07, the `noise_std=0.10` output remained detected, and the
`0.15` output was not detected. The positive `0.10` result proves that the
surrounding 512 px / 12 fps / H.264 path did not create the negative result by
itself. `0.15` is therefore the shipped default. The tracked manifest
`data/evaluations/video-synthid-oracle.csv` records the public source URL,
hashes, fidelity metrics, and verdicts without committing generated videos.
What that calibration does and does not constrain, the ranked experiment program
for trading less quality for the same removal, and the change candidates that were
refuted along the way are recorded in
[`video-synthid-quality-research.md`](video-synthid-quality-research.md).
The VAE perturbation follows the general regeneration-attack construction from
Zhao et al. The video-specific control and temporal metric are local additions.
VideoMarkBench motivates testing frame aggregation and matched perturbations,
but it does not evaluate Google's proprietary SynthID, so its findings cannot
stand in for the Gemini oracle.
---
## 4. Adoption and current state (as of June 2026)
### 4.1 Google products
Google has watermarked **over 10 billion** images and video frames. The
deployment split by surface matters for our tool:
| Surface | SynthID pixel | C2PA metadata | Visible sparkle |
|--------------------------------------|---------------|---------------|-----------------|
| Gemini app (generated images) | YES | YES (Google) | YES |
| Gemini API / AI Studio / Nano Banana | YES | NO | YES |
The Gemini API surface is a key blind spot: it embeds the pixel watermark and
the visible sparkle but **no C2PA or IPTC at all**. Our `identify` returns
Unknown on API-generated images unless the visible sparkle is detected (via
`check_visible=True`) or the user runs the Gemini app oracle.
### 4.2 OpenAI
OpenAI confirmed SynthID adoption (Help Center, updated 2026-05-21):
> "ChatGPT images include both C2PA metadata and SynthID watermarks."
This is time-gated: pre-rollout ChatGPT/gpt-image images carry C2PA without
SynthID. Current OpenAI manifests distinguish the watermarked output with the
signed `c2pa.watermarked.unbound` action. The detector requires that action, so
old OpenAI C2PA remains an origin signal without becoming a SynthID claim.
### 4.3 Other vendors
- **Kakao** (South Korea): SynthID adopter as of May 2026 (Google announcement)
- **NVIDIA Cosmos**: SynthID for video (not still images; different pipeline)
- **Meta AI**: does NOT use SynthID; uses IPTC `digitalSourceType` marker instead
### 4.4 Version evolution (v1 vs v2 hardening)
Google has not publicly documented version numbers for the SynthID image
watermark in a way that maps to our testing observations. What is known
empirically from oracle tests:
- **Before May 2026 (Gemini)**: strength 0.05 removed the watermark
- **May 2026 (Gemini)**: strength 0.05 insufficient; 0.10 required
- **Current (Gemini, June 2026)**: on the capped 1536 path, 0.05 and 0.10 do
NOT clear; 0.15 clears (n=4, Gemini app oracle). See section 2.2.
- **OpenAI (June 2026)**: clears at 0.05 across 1024-1600 (n=4, clean v0.8.6).
The earlier "0.30 still detected on 1600x1600" report (issue #14) was the
text-protection bug, not a hardening of the watermark -- see the correction in
section 2.2.
Google has hardened SynthID relative to OpenAI's (vendor gap measured at ~3x
strength), but the year-over-year "0.05 -> 0.10 -> 0.30" progression above
conflates a real hardening trend with the now-debunked region-rescrub artifact;
treat only the section 2.2 controlled numbers as authoritative.
---
## 5. Practical implications for this tool
### 5.1 Preserving content means regenerating it, never copying it
**Core rule:** SynthID is a pixel-amplitude pattern, so any approach that FREEZES
or RESTORES original pixels in a region re-introduces the watermark there. Early
region-based text/face "protection" (since removed) proved this: restoring the
original face pixels guaranteed SynthID survived in faces, and even a per-region
high-resolution re-scrub from an upscaled crop could be insufficient to destroy
the payload, reconstituting SynthID in text. The lesson held and shaped the
current design: **content is preserved by REGENERATING it under structural
conditioning, never by copying original pixels.**
> **Superseded in 0.24.0.** The `controlnet`, `sdxl`, `qwen` and `default` profiles
> were removed, and a retired name is now rejected at parse time rather than routed
> onward. `--pipeline` accepts only `qwen-zimage` (the default) and `sdxl-zimage`;
> both are CUDA-only, both condition their global stage on a canny edge map, and both
> run the same Z-Image face stage. The bullets below are a record of what was measured
> on the removed profiles, kept because the oracle verdicts are still the evidence the
> current design rests on.
- **Text + structure:** `--pipeline controlnet` (SDXL img2img + a canny ControlNet) is
**THE DEFAULT pipeline since 2026-06-09** (`--pipeline default` opts down to plain
SDXL img2img for inputs without text/faces). It conditions the regeneration on the
edge map, so text and structure stay sharp while every pixel is still regenerated. Text legibility is
better than plain img2img at the same strength (text stays readable where plain
garbles it). **BUT removal efficacy at the low vendor-adaptive strength is CONTENT ×
PIPELINE dependent and NEITHER pipeline clears all content -- oracle-validated
2026-06-04 (8 OpenAI images, strength 0.10/0.15, max-res 1536).** The survivors FLIP
by content type: **photoreal** (a 9-face grid, a bracelet product photo) SURVIVES
controlnet but CLEARS `default`; **flat graphic** (a logo/poster with large flat
color fills) SURVIVES `default` but CLEARS controlnet; a flat **text** card cleared
under both. Why: controlnet's dense edge map keeps the regen too close to the
original on photoreal (so SynthID survives) but freely repaints flat fills (so it
clears them); plain img2img at low strength perturbs photoreal texture enough but
barely touches flat fills. **Root cause = insufficient STRENGTH, not the pipeline:
the vendor-adaptive 0.10 is NOT universally sufficient (the June numbers below held
for the content they were measured on). The robust fix is a HIGHER strength,
oracle-revalidated per content type (controlnet can be cranked harder without losing
structure; a lower `controlnet_conditioning_scale` also frees the regen on
photoreal).** So neither `--pipeline controlnet` nor plain `default` is a drop-in
removal guarantee at today's strength -- pick by what you must PRESERVE (controlnet
for text/structure), then raise strength until the oracle reads clean. (The earlier
"reads clean on the oracle" claim held only for the one flat/text-background case it
was checked on; it does not generalize.) **UPDATE 2026-06-09: the default strengths
were raised and made pipeline-aware (controlnet ladder = the certified
0.20/0.30/0.30 floors, applied to BOTH pipelines as a single ladder -- see §5.2 for
why one ladder covers plain `sdxl` too) and controlnet is now the default pipeline.
The plain-SDXL profile was also renamed `default` -> `sdxl` (`default` stays as an
alias). The 0.10/0.15 numbers in this analysis are the PRE-raise values it was
measured at. See §5.2.**
- **Highest-fidelity CUDA option:** `--pipeline qwen-zimage` is the recommended
quality mode when preserving face identity matters more than latency, model size,
and GPU cost. ControlNet was then the default, because it was much cheaper and ran on
CUDA, XPU, MPS, and CPU, but canny conditioning preserves edges rather than identity.
On two direct upstream comparisons, `qwen-zimage` retained substantially more
ArcFace identity than polished ControlNet. On 2026-07-25 the exact six-output
`visible -> qwen-zimage -> metadata` candidate was negative in the corresponding
OpenAI and Gemini oracles. This is a quality recommendation for the measured content,
not broad removal certification; very small text can still degrade.
See `docs/qwen-improvement-research.md` for the identity and text metrics and the
validation scope of those comparisons.
- **Face identity:** canny holds face *structure* but not *identity*. The removed
SDXL and ControlNet profiles did not run a separate face-restoration stage, and
earlier GFPGAN, PhotoMaker, and FaceID experiments were dropped after they
degraded identity or risked reintroducing source pixels. Both shipped profiles
now run the same face-specific stage: YuNet and SAM locate faces, then Z-Image
regenerates the selected original face crops before a feathered composite. See
`docs/controlnet-removal-pipeline-research.md` for the historical experiments.
### 5.2 Strength setting
There is no single permanent correct strength, but the controlled June 2026
study (section 2.2) gives empirical floors:
- **OpenAI**: 0.05 clears across 1024-1600 (n=4) -- **but content-dependent, NOT
universal.** The follow-up oracle pass (2026-06-04, 8 images) found a flat-graphic
OpenAI logo/poster still SynthID-detected after `default` at 0.10, and photoreal
images still detected after controlnet at 0.10/0.15: at low strength the
low-change regions (large flat fills under `default`, dense edges under controlnet)
are not perturbed enough. So the 0.05 floor held only for the n=4 content it was
measured on; treat it as a lower bound, not a guarantee, and raise + oracle-recheck
per content type (see §5.1 controlnet bullet).
- **Google (capped 1536)**: 0.15 (n=4); 0.05 and 0.10 do not clear.
- **Google native 2816**: 0.15 clears (n=2, deployed controlnet worker, 2026-06-14) --
the same rung as capped 1536, so no resolution penalty was observed.
> **Superseded in 0.24.0.** The `sdxl`, `controlnet`, `qwen` and `default` profiles
> were removed, and `OPENAI_STRENGTH` / `GEMINI_STRENGTH` / `UNKNOWN_STRENGTH` went
> with them. Everything from here to the end of this section is a record of what was
> measured on those profiles, kept because the oracle verdicts are still the evidence
> base. For the strength policy in force now see `module-internals.md`: `qwen-zimage`
> uses `resolution_adaptive_denoise`, `sdxl-zimage` a flat vendor ladder.
The default was **vendor-adaptive** (`watermark_profiles.resolve_strength` +
`vendor_for_strength`): the tool read the C2PA issuer on the original input and picked
`OPENAI_STRENGTH` 0.10 / `GEMINI_STRENGTH` 0.15 / `UNKNOWN_STRENGTH` 0.15 **(LOWERED
2026-06-14 from the 2026-06-04 cert floors 0.20/0.30/0.30)**. **The SAME ladder applied
to both pipelines** (`sdxl` and `controlnet`). The 2026-06-14 re-test on the deployed
Modal controlnet worker (v0.10.0) cleared SynthID on the oracle at OpenAI 0.10 (2
photoreal) and Google 0.15 (2 NATIVE 2816x1536, contradicting the "native >= 0.30" guess
on line above), and a pixel sweep showed 0.20/0.30 over-regenerated for no efficacy gain.
**This re-opens a genuine tension with the 2026-06-04 pass, which found photoreal STILL
detected after controlnet at 0.10/0.15 (lines above):** either the v0.10.0 controlnet
default improved the floor, or n=2 landed on the lucky side of the seed-non-determinism
(§5.5). So a SERVICE on this ladder MUST pin a fixed, oracle-verified seed (not random),
and flat-graphic hard cases (NOT in the n=2 re-test) still need a per-content oracle
recheck -- raise `--strength` there. The prior cert floors are the §5.5 record. Why one ladder
covers plain `sdxl` too: the certification was run on controlnet and does NOT transfer
by symmetry (the two pipelines have OPPOSITE hard cases -- controlnet leaves SynthID on
photoreal, `sdxl` on flat graphics, the §5.1 content-x-pipeline table), BUT on its own
hard case (flat fills) `sdxl` is the WEAKER remover (plain img2img barely perturbs a
flat region at low strength), so it needs AT LEAST controlnet's strength -- the
certified floor is therefore the right floor for `sdxl` too. This is a MARGIN argument
for `sdxl`, not a separate certification (no local SynthID detector to self-verify).
The higher strength costs little quality where it matters, because `controlnet` is now
the default pipeline, so `sdxl` is reached only via an explicit `--pipeline sdxl` (a
deliberate opt-down), where over-regeneration has no faces/text to damage.
This uses the vendor signal we DO have locally (the C2PA SynthID provenance) to avoid the
overkill of a single high default on OpenAI images, without needing a local pixel
detector. An explicit `--strength` always wins. If the watermark still survives (e.g. a
large native Gemini beyond the capped-1536 validation), raise toward 0.35-0.40 (0.40
visibly corrupts dense text), using the lowest value that reads clean on the oracle.
**qwen-zimage global denoise, Gemini boundary bracketed (2026-08-02).** The profile does
not use the vendor ladder above; `resolution_adaptive_denoise` maps megapixels onto
roughly 0.084 (sub-0.3 MP) to 0.154 (>= 3.7 MP). A ladder on one native 2816x1536 Gemini
original, seed 0, everything else at profile defaults, verified through the Gemini app:
| global denoise | Gemini app | whole-image PSNR | face-box PSNR | edge IoU |
|---|---|---|---|---|
| 0.154 (profile top) | clean | 24.72 | 31.19 | 0.188 |
| 0.12 | clean | 25.65 | 31.82 | 0.202 |
| 0.10 | **clean** | 26.26 | 32.17 | 0.212 |
| 0.08 | **SynthID FOUND** | 26.95 | 32.51 | 0.227 |
So the boundary sits between 0.08 and 0.10 for this image, and the profile's shipped
0.154 carries roughly half a rung more strength than that content needed. Fidelity rises
monotonically all the way down - dropping to 0.10 buys **+1.54 dB whole-image and
+0.98 dB inside the face boxes** - which is exactly why the temptation is to move the
ceiling, and exactly why one fixture is not enough to do it.
Two constraints on reading this:
- **It brackets, it does not calibrate.** One image, one seed. Shipping the lowest clean
rung means shipping at the measured cliff edge; another sample, seed, or content class
can sit on the other side of it. Note §5.2's flat-graphic hard cases were not in this
set at all.
- **The bottom of the curve was the untested end. It has now been measured, and it
holds.** Every Gemini oracle fixture is 2816x1536, so the Google-side certification
only ever covered 0.154, while `resolution_adaptive_denoise` sends sub-1 MP images to
0.084-0.094 - at or below the rung that failed at 4.33 MP. Downscaling a Gemini
original (valid test material: SynthID survives it by design) and running the deployed
worker on it gives, through the Gemini app:
| processing size | profile denoise | Gemini app |
|---|---|---|
| 1024x559 (0.57 MP) | 0.0896 | clean |
| 1600x873 (1.40 MP) | 0.1066 | clean |
So production's own low end clears Google, and the "small images are under-processed"
failure mode is ruled out at these two sizes. **Read this as validation of the shipped
curve, not as proof that the boundary moves with resolution.** 0.0896 sits inside the
untested gap at 4.33 MP, where only 0.08 (found) and 0.10 (clean) were probed, so it
may well clear at both sizes. The direction of any resolution dependence remains
unproven (§5.5). Also note these are downscales of a 2816x1536 original rather than
natively small Gemini outputs, which have still never been tested.
### 5.3 Test methodology
- **GitHub-recompressed JPEGs from issue attachments are valid SynthID test
subjects.** JPEG re-encoding removes C2PA metadata but does NOT remove the
SynthID pixel watermark (verified June 2026 on issue #14 pic3). Do not
dismiss these as "not faithful originals" for SynthID-removal tests.
- **The correct oracle for OpenAI images is openai.com/verify**, not the Gemini
app. The two oracles detect different payloads.
- **A quiet `identify` output after processing is not proof of removal.** It
means the provenance evidence is gone. The pixel watermark state is unknown without
an oracle check.
- **After removal, the output may carry forensic artifacts** detectable by an
independent classifier even if the vendor oracle reads negative. Defeating the
verifier is not the same as being forensically indistinguishable from clean
content (arXiv:2605.09203).
### 5.4 Strength vs forensic detectability: the tradeoff
Higher img2img strength removes the watermark but introduces detectable
regeneration artifacts. The Goonatilake & Ateniese paper shows the strongest
diffusion-based removers are simultaneously the most forensically detectable
(AUROC up to 0.9999). The tradeoff is unavoidable with current diffusion-based
approaches: defeating the vendor's verifier is not the same as being clean.
### 5.5 Oracle validation log -- 2026-06-04 OpenAI pass
Eight OpenAI `gpt-image` originals run through both pipelines and checked on
openai.com/verify (the OpenAI SynthID oracle). `--max-resolution 1536`; strength
is the vendor-adaptive default (`vendor_for_strength`): images with an OpenAI C2PA
manifest get `OPENAI_STRENGTH` 0.10, the one without C2PA falls to
`UNKNOWN_STRENGTH` 0.15. "detected" = SynthID still found (removal FAILED);
"clean" = SynthID not detected.
| image | content type | size | strength | `--auto`/controlnet | `default` |
|---|---|---|---|---|---|
| typography card | flat text | 1122x1402 | 0.10 | clean | clean |
| Flat poster | flat graphic (logo + flat fills) | 1024x1536 | 0.10 | clean | **detected** |
| 9-face grid | photoreal | 1448x1086 | 0.10 | **detected** | clean |
| bracelet product photo | photoreal | 1600x1600 | 0.15 | **detected** | clean |
(The other four cleared under both and are omitted.) **Reading:** at this strength
NEITHER pipeline removes SynthID on all content -- the survivors flip by content
type. Photoreal survives controlnet / clears `default`; flat graphic survives
`default` / clears controlnet; flat text clears both.
**Follow-up: removal near the threshold is NON-DETERMINISTIC (seed-dependent).**
Re-running the two photoreal survivors through controlnet at an explicit
`--strength 0.15` (`--auto`, same `--max-resolution 1536`) cleared BOTH on the
oracle (SynthID not detected). But the bracelet had SURVIVED controlnet at the
SAME 0.15 in the first pass (it was the no-C2PA image, so its vendor-adaptive
strength was already 0.15) -- same pipeline + strength + resolution, only the
random (unset) seed differed between runs. So **0.15 is the borderline floor for
controlnet photoreal, not a robust guarantee**: at the threshold the same
image+settings can pass or fail run-to-run. img2img runs with `seed=None` (random)
unless `--seed` is passed, so a removal SERVICE gets a coin-flip near threshold and
has no local SynthID detector to self-verify.
**Controlnet strength ladder on the two photoreal images (oracle, `--auto`,
`--max-resolution 1536`):**
| controlnet strength | 9-face grid | bracelet photo |
|---|---|---|
| 0.10 | detected | (was 0.15) |
| 0.15 | clean | **non-deterministic** (survived pass 1, clean pass 2) |
| **0.20** | **clean** | **clean** |
**Recommended robust controlnet strength = 0.20** (0.05 of margin above the 0.15
non-deterministic borderline); both photoreal survivors cleared at 0.20. Honest
caveat: 0.20 is one confirming run WITH margin, not an N-run repeatability proof --
for a removal service, add a little more margin or validate repeatability, since
there is no local SynthID detector to self-check. **Implications:** (1) the
content×pipeline table above conflates a borderline/non-deterministic 0.15 result
with deterministic content behavior -- the photoreal-survives-controlnet effect is
solid at 0.10 but at 0.15 it is near-threshold noise; (2) for reliable removal pick
a strength with MARGIN above the borderline (controlnet >= 0.20), not exactly on
it; (3) **historical engineering conclusion:** this dated run argued for a
higher ControlNet strength than the then-current default. That proposal was
later superseded. The current resolver intentionally shares the 0.10/0.15
ladder between SDXL and ControlNet and uses a separate Qwen ladder; see
`_internal/watermark_profiles.py`.
Source images are private (faces / product shots), not committed; reproduce on any
photoreal + flat-graphic gpt-image pair, varying the seed, and re-checking the
oracle.
**Gemini pass + the face-restore re-introduction (2026-06-04).** Four Gemini
originals via the then-current `--auto` ControlNet path at `--max-resolution 1024`,
checked on the
Gemini "Verify with SynthID" oracle (Google content needs the Google oracle, not
openai.com/verify):
- Most cleared at controlnet 0.15-0.25; `gemini_3` (a large central FACE, +restore)
stayed **SynthID-detected at controlnet 0.15, 0.20 AND 0.25** -- raising strength
did not crack it.
- **Root cause was the face-restore pass, not strength/resolution.** `gemini_3` at
controlnet 0.20 with `--no-restore-faces` read **SynthID-NOT-detected** (clean
A/B, only restore differed). GFPGAN runs on the ORIGINAL watermarked face and at
weight 0.5 blends ~half its pixels back, re-introducing SynthID into the
composited face over the diffusion-cleaned result (see §5.1 face-identity bullet).
- (Side note: reducing the processing resolution does NOT weaken SynthID -- it is
robust to downscaling by design, so 1024 was never the wall. Whether a lower
processing resolution then needs more or less removal strength is NOT established;
see the note below.)
**Historical controlnet certification, superseded by the current vendor-adaptive
defaults (isolated GPU sweep + oracle,
restore OFF, <= 1536, each vendor on its own oracle):** OpenAI **0.20** (2 photoreal x
seed {1,2,3} = 6/6 clean; the 0.15-flipper is seed-robust at 0.20) and Gemini **0.30**
(0.20 detected -> 0.30 clean on 2/2 seeds). Both were measured at <= 1536 only. See
`docs/controlnet-removal-pipeline-research.md` for the table.
**Whether Gemini removal is resolution-sensitive is UNPROVEN, in either direction.**
This document previously asserted it was, and recommended capping Gemini at 1536 with
0.30 or "native-calibrating" to ~0.35+. Nothing measured that. The one relevant
measurement points the other way: the 2026-06-14 deployed-worker re-test cleared Gemini
at **0.15 on two NATIVE 2816x1536 images**, the same rung as capped 1536. So there is no
observed native-resolution penalty, and no observed benefit either -- the low-resolution
end has simply never been through the Gemini oracle on any pipeline. Do not reason from
a resolution trend here; measure it.
**Current implication:** the old floor table remains evidence about the dated
test set, not the current resolver. The SDXL and ControlNet profiles it measured
no longer exist; the shipped defaults are defined in `watermark_profiles.py`, and
both surviving profiles run face repair as a built-in second stage rather than as
an optional restore. Removal near a threshold remains seed dependent, so
reproducible verification requires a fixed seed.
---
## References
1. Gowal et al. (2025). **SynthID-Image: Image watermarking at internet scale.**
arXiv:2510.09263. https://arxiv.org/abs/2510.09263
2. Google DeepMind. **Identifying AI-generated images with SynthID.** Blog post,
2023. https://deepmind.google/blog/identifying-ai-generated-images-with-synthid/
3. Google DeepMind. **SynthID.** Product page.
https://deepmind.google/models/synthid/
4. Goonatilake & Ateniese (2026). **Removing the Watermark Is Not Enough:
Forensic Stealth in Generative-AI Watermark Removal.** arXiv:2605.09203.
https://arxiv.org/abs/2605.09203
5. OpenAI. **Verify tool for AI-generated images.** openai.com/research/verify.
Accessed 2026-05-31.
6. Google. **Verify AI-generated images, videos, and audio.**
https://support.google.com/gemini/answer/16722517
7. Zhao et al. (2024). **Invisible Image Watermarks Are Provably Removable
Using Generative AI.** NeurIPS 2024, arXiv:2306.01953.
https://arxiv.org/abs/2306.01953
8. Jiang et al. (2025). **VideoMarkBench: Benchmarking Robustness of Video
Watermarking.** arXiv:2505.21620.
https://arxiv.org/abs/2505.21620
**Google floor on qwen-zimage (0.27.2, 2026-08-19).** The bracket above did not
survive the full production path: on the 4.33 MP CJK-sign fixture (visible-stage
sparkle removal -> qwen-zimage seed 0 at the curve's 0.154 top -> resize-back ->
metadata strip), the Gemini verifier detected SynthID x3 with a valid
pixel-identical stripped control in the same session. Google-provenance content
now takes a flat `QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.30` floor instead of the area
curve - 0.30 anchors measured clean in Gemini on two fixtures (CJK sign +
18-face) at 3/3 checks across two work accounts, and stayed clean with the
vae-glyphs donor layer on top; openai/unknown content keeps the resolution
curve. An explicit strength still wins. Certification artifacts: raiw-app
`data/certification/text-restoration-2026-08-18/` (the failing re-baseline).
+371
View File
@@ -0,0 +1,371 @@
# Text protection research: crisp text under a "watermark removed everywhere" constraint
> Research archive. This page records evaluated ideas, including rejected
> prototypes. Current behavior is documented in the user guides and source.
> The `text_protector.py` module it describes was removed from the library
> afterwards.
Date: 2026-05-29. Source: a deep-research run (104 agents, 5 search angles, sources
fetched and 3-vote adversarially verified). Not committed automatically — saved as a
research note for the next session.
## The constraint that frames everything
The invisible watermark (Google SynthID) must be removed **everywhere, including inside
text regions**. Therefore any technique that keeps or composites the **original
(watermarked) text pixels** is disqualified — the text must be *regenerated / freshly
synthesized* enough to scrub the watermark, yet rendered crisply. This single rule is the
filter applied to every candidate below.
## Problem recap
The `invisible` pipeline is SDXL base 1.0 img2img to defeat SynthID. The default
strength has risen over time as Google hardens SynthID (0.05 -> 0.10 -> **~0.30**, the
current threshold for fresh Gemini output); higher strength deforms text more, which is
exactly why text protection matters. Text is protected via Differential Diffusion with a
per-pixel change map (`preserve` ~0.9) driven by the PP-OCRv3 DB detector
(`text_protector.py`). Large text survives; **small text (sub ~8 px strokes) softens or
garbles** (issue #14, confirmed on real content).
## Executive summary
The fine-text softening is an **architectural consequence of latent-space processing, not
a tuning problem**: SDXL's 4-channel VAE (~48x compression) discards high-frequency signal
on encode, and Differential Diffusion blends in latent space with the change map
downsampled by 8x, so any stroke under ~8 px sits inside one latent cell and cannot be
preserved or edited cleanly **regardless of `preserve`** (the Differential Diffusion
authors state this limit explicitly). Two structurally sound directions keep the
"watermark removed everywhere" guarantee because they **synthesize fresh glyph pixels**
rather than compositing originals: (1) glyph/text-conditioned diffusion re-render of
detected text (AnyText2, EasyText), and (2) a two-stage architecture — global scrub, then
a dedicated text-restoration / text-aware super-resolution pass over detected regions
(TIGER, TextSR, TeReDiff/TAIR). **EasyText** and **TextSR** are the most promising for this
CJK-first pipeline (both multilingual via DiT/ByT5, both regenerate from glyph or
character-shape priors). The deepest fix — a 16-channel (SD3/FLUX) VAE — materially reduces
the softening but means switching the base model, not a drop-in VAE swap.
## Constraint reconciliation (important)
The generic research "quick win: bump `preserve` toward 1.0" is **invalid under our hard
constraint**: raising `preserve` freezes the text region, so SynthID there is **not
scrubbed**. Likewise, pixel paste-back of the original text is disqualified. The only
constraint-compatible quick win is **higher resolution / tiled diffusion** (strokes span
more latent cells, less VAE softening, while the text is still fully regenerated and thus
scrubbed). The real answer is **regenerate text crisply**, not freeze it.
## Findings (with confidence and sources)
### Finding 1 — confidence: high
**Claim.** The small-text softening is an architectural latent-space limit, not a tuning issue. SDXL's VAE compressively encodes (losing exact color and fine detail on every round-trip), and Differential Diffusion blends in latent space with the change map downsampled to latent resolution (8x), so the method explicitly caps edit/preserve granularity at ~8 px under SD settings. Text strokes below one latent cell cannot be cleanly preserved even at preserve ~0.9.
**Evidence.** Differential Diffusion's paper states a "cap on the resolution of the change map ... can limit the ability to precisely edit small objects (less than 8 pixels for Stable-Diffusion's settings)"; the official SDXL pipeline downsamples the map by `vae_scale_factor=8` and blends `latents = original*mask + latents*(1-mask)` in latent space. The VAE encode is "compressive ... exact color qualities and exact visual fine-details are lost." arXiv:2512.05198 confirms "resizing the pixel mask to latent resolution discards fine structure ... downsamples by 1/8" and that linear latent blending "cannot be pixel-equivalent." Higher compression = more high-frequency loss (arXiv:2305.02541).
**Sources.** https://onlinelibrary.wiley.com/doi/10.1111/cgf.70040 · https://differential-diffusion.github.io/ · https://github.com/exx8/differential-diffusion · https://arxiv.org/abs/2512.05198 · https://omriavrahami.com/blended-latent-diffusion-page/ · https://arxiv.org/pdf/2305.02541
### Finding 2 — confidence: low (do not build on it yet)
**Claim.** Pixel-space differential / blended-latent variants exist as a research direction, but the specific full-resolution-mask solution (PELC/DecFormer, arXiv:2512.05198) was NOT verified to deliver its claimed seam/edge improvements.
**Evidence.** arXiv:2512.05198 argues linear latent blending is not pixel-equivalent and proposes decoder-equivariant compositing; PixPerfect (arXiv:2512.03247) does pixel-space refinement of chromatic shifts at edit boundaries. But the specific PELC full-resolution-mask and DecFormer "53% error reduction" claims were **refuted on adversarial vote (0-3 and 1-2)**. Treat pixel-equivalent latent compositing as an emerging idea to watch, not a production fix.
**Sources.** https://arxiv.org/abs/2512.05198 · https://arxiv.org/abs/2512.03247
### Finding 3 — confidence: high
**Claim.** Glyph/text-conditioned diffusion can re-render detected text as freshly synthesized pixels (not copied), which inherently scrubs any watermark in the text region while rendering glyphs crisply. AnyText/AnyText2 inject text-rendering into a pretrained T2I model and support generation AND editing of existing scene images; multilingual including CJK and English.
**Evidence.** AnyText2 "enables precise control over multilingual text attributes in natural scene image generation and editing" (WriteNet+AttnX); +3.3% (Chinese) / +9.3% (English) accuracy over AnyText v1. AnyText "can be plugged into existing diffusion models ... for rendering or editing text" and synthesizes text latent features through diffusion (fresh pixels), supporting zh/en/ja/ko/ar/bn/hi. **Caveat:** both are SD1.5-based, so NOT a drop-in into the SDXL scrub (separate base model); AnyText's own limitation: "the inpainting manner ... impedes editing quality on small text," and it ranks weak on STRICT (EMNLP 2025) — small-text crispness not guaranteed.
**Sources.** https://github.com/tyxsspa/AnyText2 · https://arxiv.org/abs/2411.15245 · https://arxiv.org/abs/2311.03054
### Finding 4 — confidence: high
**Claim.** EasyText is a strong glyph-conditioned re-render candidate: built on the FLUX-dev DiT framework with LoRA tuning, renders compact per-character glyph patches (64px-high adaptive for alphabetic, 64x64 for logographic) concatenated in latent space, supports 10+ languages including Chinese, Japanese, Korean, Thai, Vietnamese, Greek, and Latin.
**Evidence.** AAAI 2025 + arXiv:2505.24417: "implemented based on the open-source FLUX-dev framework with LoRA-based parameter-efficient tuning," VAE and text encoder frozen, two-stage 512->1024 training. Glyph conditioning via "64-pixel-high images ... adaptive widths for alphabetic; fixed 64x64 for logographic," VAE-encoded and concatenated with denoised latents, "less than one-tenth the spatial size of layout-matching methods." FLUX-based (16-channel VAE, DiT) also sidesteps the SDXL 4-channel wall. Fresh-pixel generation preserves the watermark-removal guarantee. Cyrillic/Arabic crispness not separately benchmarked.
**Sources.** https://arxiv.org/html/2505.24417 · https://ojs.aaai.org/index.php/AAAI/article/view/37697
### Finding 5 — confidence: high
**Claim.** A two-stage "global watermark scrub then text-restoration pass" architecture is validated by recent literature, and the restoration stage can synthesize glyph pixels from priors (no original-pixel reintroduction). TIGER reconstructs stroke geometry then injects it as guidance into full-image super-resolution; TextSR uses a detector + multilingual OCR to regenerate text from character-shape priors; TeReDiff/TAIR couples a jointly-trained text-spotter with diffusion.
**Evidence.** TIGER (arXiv:2510.21590): "a diffusion-based local text refiner ... reconstructing fine-grained stroke geometry ... injected as conditional guidance into the subsequent full-image restoration." TextSR (arXiv:2505.23119, Google): "leverages a text detector ... then employs OCR to extract multilingual text," regenerating from "multilingual character-to-shape diffusion priors" that "produce character shapes solely based on text prompts, even without visual input" — fresh pixels. TAIR/TeReDiff (ICLR 2026): standard restoration "frequently generates plausible but incorrect textures"; TeReDiff feeds text-spotter outputs back as prompts. **Caveat:** TIGER orders text-first then global (reverse of scrub-then-text); these target degraded-input super-resolution, not watermark removal, so the SynthID-scrub of the restoration stage must be verified empirically (the stages are themselves diffusion-based, so fresh-pixel = no SynthID is plausible but unproven here).
**Sources.** https://arxiv.org/html/2510.21590v1 · https://arxiv.org/html/2505.23119v1 · https://cvlab-kaist.github.io/TAIR/ · https://arxiv.org/abs/2506.09993
### Finding 6 — confidence: high
**Claim.** Switching to a 16-channel VAE (SD3/FLUX class) materially reduces small-text/latent softening vs SDXL's 4-channel VAE, but it requires switching the base model — not a drop-in latent swap into an SDXL UNet img2img pipeline. RAE approaches are DiT-native and likewise not drop-in.
**Evidence.** SD3/FLUX moved from 4-channel (48x) to 16-channel (12x) VAEs specifically to preserve fine detail (diffusers Discussion #8713; madebyollin VAE notes; arXiv:2305.02541). RAE (arXiv:2510.11690) "should be the new default for diffusion transformer training" but produces high-dimensional latents needing a DiT wide-DDT head — NOT compatible with an SDXL 4-channel UNet. EasyText shows the practical path: adopt a FLUX-DiT base rather than retrofit SDXL. The VAE upgrade couples to a base-model migration.
**Sources.** https://arxiv.org/abs/2510.11690 · https://arxiv.org/pdf/2305.02541 · https://arxiv.org/html/2505.24417
## Recommendation
Under the hard constraint, the correct architecture is **not "protect text during the
scrub" (Differential Diffusion)** but **"scrub everywhere, then restore text crisply by
regeneration"**:
1. Global SDXL scrub with text protection OFF (text region is scrubbed too).
2. On detected text regions, a **glyph-conditioned restoration** that re-renders the same
glyphs as fresh pixels (no original reused).
This is the only path that delivers both "watermark everywhere" and crisp text.
**Top-2 to prototype:**
- **TextSR** — detector + multilingual OCR + character-shape diffusion priors; closest to
the existing detector-driven pipeline.
- **EasyText** — FLUX-DiT glyph re-render, multilingual incl. CJK; also gets the 16-channel
VAE for free.
**Honest costs / unknowns:** this is a re-architecture, not a quick fix. It needs a new
**OCR-recognition** step (we currently only detect text; we must know *what* to re-render).
Models are FLUX/DiT-class (heavy) -> serverless GPU. Maturity is research-grade; CJK is
covered, Cyrillic/Arabic crispness is not separately benchmarked -> a prototype must
measure real fidelity. The restoration stage being diffusion-based makes "fresh pixels =
no SynthID" plausible but **must be verified empirically** (run the SynthID oracle on the
restored output).
**Constraint-compatible quick win to try first:** run the global scrub at **higher
resolution / tiled** so strokes exceed the latent cell — less softening, full scrub, no
freezing. Cheap to test; quantify recall/quality vs cost.
**Do not pursue:** raising `preserve` toward 1.0 or pixel paste-back (both leave original
watermarked pixels in text); PELC/DecFormer pixel-equivalent latent compositing (refuted,
not production-ready).
## Provenance
Deep-research workflow run `wf_118b9a03-3eb` (2026-05-29). Findings adversarially verified
(2/3 refutes required to kill a claim). This note records research only; no code change is
implied until a prototype validates fidelity and the SynthID-scrub guarantee on the
restored output.
## 2026-08-13 OCR plus LaMa prototype
A lightweight version of the recommended two-stage architecture was tested on
the three tracked text fixtures. It used the already-clean Qwen result as the
base, recognized the source text, removed source and Qwen glyph contours with
LaMa, and rendered the recognized strings as fresh pixels. No source pixels
were pasted back.
The result validates only part of the hypothesis. Character-weighted CER on the
two multilingual posters fell from 0.338/0.305 to 0.007/0.007, and OpenAI Verify
remained clean for both matched Qwen and restored pairs. However, replacement
fonts changed the design, whole-image LPIPS worsened by 0.067 on each poster,
and the light poster retained a shadow in one small English line. On the Chinese
sign, punctuation errors moved CER from 0.074 to 0.111. The Google verifier run
was inconclusive because the available account detected both the restored image
and the byte-identical Qwen control that a previous account had called clean.
The experiment supports a proper glyph-conditioned restorer, not shipping the
plain OCR/system-font compositor. Exact hashes, metrics, masks, and provider
verdicts are tracked in
[`data/evaluations/fidelity/text-restoration-2026-08-13.csv`](../data/evaluations/fidelity/text-restoration-2026-08-13.csv).
## 2026-08-13 AnyText2 follow-up
The official AnyText2 SD1.5 checkpoint was then tested as the glyph-conditioned
restorer. Its ModelScope entry and code are Apache 2.0, and the downloaded
checkpoint matched the published SHA-256. An official blackboard edit completed
successfully before the fixture run, establishing that the runtime reproduced
the model rather than silently exercising a fallback.
On the Chinese sign, a three-line local edit over the exact Qwen output scored
CER 0.185 under the standard detector. Font mimic from the source glyph masks
scored 0.222. Whole-image LPIPS changed from 0.289 to 0.338 and 0.345 respectively.
A padded crop-recognition check showed why the comparison matters: Qwen contained
the full correct text and scored 0.000, while default AnyText2 still scored 0.074
with two substituted characters and mimic remained at 0.222. The provider oracle
was deliberately not called because these variants had already failed the
content-fidelity gate.
AnyText2 is therefore not the missing production restorer in its published form.
The negative result is narrower than the model family: only the SD1.5 checkpoint
is public, while AnyText2XL remains listed as future work. The current wrapper
also truncates each quoted text line to 20 characters, which prevents a direct
test of several tracked English and Russian poster lines. Exact model provenance,
output hashes, and metrics are recorded in
[`data/evaluations/fidelity/anytext2-restoration-2026-08-13.csv`](../data/evaluations/fidelity/anytext2-restoration-2026-08-13.csv).
The same cross-check exposes a measurement bug in the earlier lightweight
restoration result. Paddle detection made tight boxes that omitted the final
Chinese full stop from two lines, producing Qwen CER 0.074 although the pixels
and padded recognition were correct. Adding 16-32 pixels of horizontal context
restored every punctuation mark. A deterministic rerender using the full OCR
strings and the closest of nine mask-scored CJK fonts also reached detector CER
0.000, but its heavier Hiragino Sans GB W6 glyphs raised LPIPS from 0.289 to 0.327.
The actionable design is selective restoration: compare padded source and output
recognition first, and preserve the Qwen output when they already match.
That policy was tested on the two multilingual posters. A manual prototype kept
the lines whose padded source and Qwen recognition agreed and rerendered only the
changed lines. The edited mask fell from 15.4%/17.0% in the full compositor to
5.5%/5.5%. On a single Paddle `en+ru+ch` route, CER changed from 0.378/0.413 for
Qwen to 0.101/0.112, while LPIPS was only 0.120/0.103 rather than the full
compositor's 0.174/0.162. The different OCR route is intentional and all three
variants were remeasured through it; these CER values are not directly
comparable with the earlier Vision/Paddle hybrid results.
Both selective outputs returned `No OpenAI signals detected` in OpenAI Verify,
and the original light poster returned `Generated with OpenAI tools` immediately
afterward as the positive control. The dark poster was visually clean. The light
poster still showed one local old-text shadow, so mask construction remains the
blocking defect. Exact hashes, metrics, mask fractions, and oracle controls are
in
[`data/evaluations/fidelity/selective-text-restoration-2026-08-13.csv`](../data/evaluations/fidelity/selective-text-restoration-2026-08-13.csv).
Uniformly expanding every selected glyph mask by two pixels removed that light
poster shadow and moved CER from 0.101 to 0.081 without a meaningful LPIPS cost.
It also expanded the edited area from 5.5% to 7.0%, and the identical rule made
the dark poster worse at CER 0.147 rather than 0.112. Those follow-up bytes have
not been oracle-checked. The next prototype should detect residual source glyphs
per line and expand only the failing component, rather than introducing another
poster-wide tuning constant.
The manual selection was then replaced with per-line padded recognition. A line
is left untouched only when the source recognizer is sufficiently consistent
with the verified line and normalized source/candidate recognition agree
exactly. The automatic rule reproduced the manual selection on the dark poster
and correctly kept one additional large Russian line on the light poster. It
reduced that poster's effective edited area from 5.5% to 4.1%.
Mask cleanup became a measured second pass rather than a global dilation. After
the first LaMa erase, the prototype finds contrast that remains specifically at
the original glyph positions, dilates only that residual, and erases it before
fresh text is drawn. This removed the visible double glyphs from both posters.
The automatic outputs scored LPIPS 0.113/0.104 and detector CER 0.123/0.119, with
effective edited fractions of 4.1%/5.7%. The light aggregate CER is pessimistic:
the page detector fragments its untouched Cyrillic line into Latin-like pieces,
while the padded Russian recognizer returns the exact expected text.
OpenAI Verify returned `No OpenAI signals detected` for both new hashes and then
`Generated with OpenAI tools` for the original light poster in the same browser
sequence. Reproducing the prototype from the tracked script exposed harmless
ONNX rounding on the light image: 386 pixels differed by at most one channel
value from the initially checked hash. The exact tracked output hash
`900def5a...` was therefore submitted separately, returned clean, and was
followed immediately by another positive source result. The dark tracked output
was byte-identical to the initially checked hash.
The Chinese sign provides a no-edit control for the selector. Detector boxes for
its three large lines overlap vertically, so Latin-style vertical padding
incorrectly mixed neighboring lines. Horizontal-only CJK padding captured the
terminal punctuation without mixing neighbors and made source and candidate
recognition agree exactly on all three lines.
The script then produced a zero mask and copied the Qwen candidate byte for byte.
This prevents the heavier-font regression seen in the earlier CJK compositor,
but does not resolve the unavailable Google-negative oracle control.
The result clears the measured OpenAI gate without a manual line selection, but
is not a production design yet. It still assumes verified source strings and
line boxes, relies on macOS system fonts, and needs evaluation on uncurated
layouts, rotated text, false OCR matches, automatic line-box discovery, and a
reproducible Google-negative control.
The later source-silhouette experiment removed the font lookup but did not meet
the actual visual requirement. Native-size review found changed stroke weight,
color variation, edge antialiasing, and decorative details even where crop OCR
was exact. A narrower `vae-glyphs` experiment uses the silhouette only as a
support mask: exact pixels come from a Qwen-VAE reconstruction, while a fresh
flat-color silhouette supplies only the outer edge beneath that core. Across 48
typography cases (548 annotated lines), this improved glyph-edge MAE in 48/48
and text-box SSIM in 47/48. The median values moved from 37.59 to 32.42 and from
0.854 to 0.914 respectively. One separate untracked core-only output with a
1.43% donor area returned `No OpenAI signals detected`; the tracked script then
reproduced those exact bytes. A 0.5-pixel feathered sibling with 2.75% nonzero
donor area improved mean text-box SSIM from 0.957 to 0.960, with a 0.918 minimum
across 15 verified lines, while full-image RGB SSIM reached 0.864. Crop OCR
recovered from 7/15 exact lines on the raw pass to 14/15, equal to the source's
own recognizer floor. Whole-image LPIPS was 0.082, but only 0.108% of pixels
remained exactly equal to the source and the detected face's Laplacian-variance
ratio was 0.670, confirming residual global smoothing. A same-session
OpenAI Verify sequence detected the exact source control in 1/1 check, returned
clean for the raw global pass and clean-fidelity anchor in 1/1 each, and returned
clean for the feathered output in 3/3 repeats. The tracked script reproduced
that feathered file byte for byte. This proves two materially better exact-output
Pareto points, not a general operating range. The 48 matrix outputs and other
mask sizes remain oracle-unverified.
The first Google oracle sample did not transfer. For the synthetic CJK sign,
two separate work accounts detected SynthID in both the resaved positive source
control and the exact Qwen-VAE donor candidate. The candidate's text-box SSIM
improved from 0.791 to 0.835 and its glyph-edge MAE from 35.48 to 22.49, but its
3.67% donor layer did not create a clean operating point. The Qwen silhouette
base was already detected, as was the earlier original-sign Qwen baseline in the
same account. The measured cause is therefore insufficient global Google
regeneration, not a demonstrated watermark regression from the text donor.
Google needs a stronger oracle-clean global anchor before the narrow donor can
be evaluated there.
A targeted follow-up supplied that missing anchor for one CJK case. An explicit
Qwen global pass at strength 0.30 returned no reliable SynthID signal in Gemini,
while the matched source control was detected. Applying the 0.5-pixel
`vae-glyphs` layer to that clean anchor changed 1.17% of the image and raised
mean text-box SSIM from 0.765 to 0.964 (minimum 0.963), with mean text-box MAE
falling from 20.74 to 5.38. The post-donor output then returned clean in 3/3
Gemini checks across two work accounts. A separate 18-face fixture with four
tiny UI-text lines also had a clean strength-0.30 anchor; its donor layer changed
0.70% of the image, raised mean text-box SSIM from 0.617 to 0.976 (minimum
0.972), and returned clean in 3/3 checks across the same two accounts. One
quota-exceeded response is excluded from both pass rates. These are two exact
oracle-certified outputs, not a general operating range: other layouts, masks,
strengths, seeds, and donor areas remain unverified.
An automatic-box follow-up merged Paddle word detections by vertical overlap.
It found exactly 20/20 poster lines and 3/3 sign lines, with mean IoU 0.857,
0.847, and 1.000 against the verified boxes. That structural match was not
sufficient: reusing the annotation crop padding changed recognition decisions,
reduced preserved dark-poster lines from 7 to 4, and expanded its edited
fraction from 5.7% to 11.2%.
A recognition-only sweep isolated the crop sensitivity. Limiting vertical
padding around detector boxes to 8-12% of line height reproduced the verified-
box decision vectors on both posters; 10% was used for a full follow-up. It kept
8/20 and 7/20 lines with edited fractions of 4.1% and 5.7%, and visual inspection
found no double glyphs. Whole-image LPIPS remained 0.113/0.104, but detector CER
was 0.127/0.154 instead of 0.123/0.119. The dark regression failed the fidelity
gate, so these hashes were not submitted to the provider oracle. The opt-in flag
remains only to reproduce the negative result. Count, IoU, and matching decision
vectors are therefore insufficient gates for automatic boxes; the next design
needs box rectification or recognition stability under crop jitter before it
can replace verified geometry.
Removing verified strings was tested separately with an annotation-seed dry
run. It detects boxes, chooses `en`, `ru`, or `ch` from Unicode script, and
accepts a draft only when three crop paddings normalize identically and every
confidence is at least 0.85. One execution proposed 20 and 18 poster lines, but
exact-text precision against the held-out annotations was only 90.0% and 94.4%.
The stable errors were punctuation: one lost English comma and an ideographic
comma consistently replaced by ASCII. Thus crop agreement and confidence do
not verify literal text. The helper is retained only to seed annotations; every
proposal needs manual review before it can enter the restoration compositor.
## Production-path anchor leak and fix (2026-08-19)
The engine `text_manifest=` path (unlike the research compositor above) blends a
15% Qwen-VAE fidelity anchor across the WHOLE frame before glyph restoration.
Certified through the official OpenAI Content Provenance API on the exact
production path (qwen-zimage, seed 0, no strength override, untiled, LANCZOS
resize-back, metadata strip; library 0.27.0 @ 50d9f448):
- controls (pixel-identical metadata-stripped sources): `synthid: detected` -
the pixel channel was proven live before every session;
- base outputs: `clean` x6 (two 20-line posters, 3 attempts each);
- text-restored WITH the anchor: `synthid: detected` x6, deterministic;
- text-restored with `FIDELITY_BLEND_ALPHA = 0` (runtime ablation, base bytes
identical between runs): `clean` x6.
The anchor was therefore the leak channel: a 15% global donor admixture over a
scrubbed frame returns enough OpenAI SynthID for the provider detector. Fidelity
cost of dropping it (source-referenced text-box MAE): 11.60->11.72, 7.79->7.86,
and 7.57->8.13 across the two posters and the CJK sign - well under one point
against base MAEs of 24.7/16.8/10.2. Since 0.27.1 the anchor is off by default
(`fidelity_anchor` parameter on `remove_watermark` / `InvisibleOptions` /
`--fidelity-anchor`); Google rows were not re-queried because the default
strength leaves that fixture's base detected (the vendor-strength lane tracks
that separately). Certification artifacts: raiw-app
`data/certification/text-restoration-2026-08-19-A1/` (sha256-stamped rows).
+378
View File
@@ -0,0 +1,378 @@
# Full verification plan
> Verification log and historical plan. Dated results describe the code and
> datasets at the time of each run. They are evidence for maintainers, not a
> current CLI contract. Recheck open items against the current code and issue
> tracker before treating them as work that remains.
How we convince ourselves the library actually works, across its whole surface, on real data.
This is the pre-release and periodic-audit plan. It is deliberately organized by **oracle
strength** rather than by module, because the hard part is never "call the function" -- it
is "know what the right answer was". A sweep with no oracle proves only that nothing threw.
Performance depends on format, enabled extras, and hardware. Measure it locally with representative inputs; do not publish private dataset sizes or run statistics.
## Public test sources
| source | role |
|---|---|
| `data/synthid/` | labeled SynthID oracle fixtures and evaluation manifest |
| `data/fixtures/provenance/` | deterministic format and provenance fixtures |
| `data/calibration/<vendor>/` | minimal controlled inputs used to derive detection silhouettes |
| synthesized inputs | constructed ground truth for pure and format-level checks |
## Tier A -- self-evident oracles (full corpus, unattended)
Properties that are true or false without anyone labelling anything. These are the
backbone: they scale to large local datasets and catch regressions with zero human cost.
### A1. Recorded-verdict regression
A dated local cache of `identify` verdicts can be replayed against the same
inputs to detect drift in verdict, platform, confidence, or signal set.
Caveat that makes this honest: a diff is not automatically a bug -- the sidecars were
written by older versions, so intended improvements also show up. The output is therefore a
**classified diff** (new detections / lost detections / changed platform / changed
confidence), reviewed once, then re-baselined. Lost detections are the alarm.
Implemented as `scripts/sidecar_regression.py` (resumable, ~1.5 h at 8 workers).
A 2026-08-15 metadata-only C2PA regression audit over the local historical
corpus caught two structured-parser gaps before release: an exact Firefly claim
generator without a repeated source type, and a Dreamina generator carried by a
reachable ingredient under a generic update manifest. Both now have focused
tests. The same audit confirmed that invalid hash/signature claims lose origin
attribution without losing the C2PA inventory, and exposed the absence of
production trust anchors in the SDK defaults. Dataset-derived counts and
identifiers remain in the gitignored audit output.
#### Local run protocol
Re-run `identify` against locally recorded sidecars, classify losses separately from intended new detections, and keep generated reports under `.local-eval/`. Do not commit dataset-derived counts or identifiers.
### A2. Parity: whatever we detect, we must be able to remove
For every image where a signal fires: remove, re-scan with the same oracle, assert quiet.
- metadata: `scripts/metadata_removal_audit.py`;
- visible: `scripts/visible_removal_audit.py`, once per backend.
Detection does not depend on the fill backend. Run `scripts/visible_positives.py` once
and pass its output through the audit's `--paths-file` option.
#### Local parity protocol
Run detection, removal, and re-detection over a representative local set. Confirm that decoded pixels remain unchanged for metadata-only operations. Keep reports untracked.
### A3. Byte-level invariants
- no-op `remove_visible` returns the ORIGINAL bytes (not a re-encode)
- pixels outside the fill mask are bit-identical to the input
- JPEG metadata strip is pixel-lossless on the DEFAULT path (`--remove-all` re-encodes by
design -- see `metadata.py`; assert the split, not losslessness everywhere)
- lossless source formats survive a misnamed extension
### A4. Idempotence and order-independence
- `remove_visible(remove_visible(x)) == remove_visible(x)`
- `strip(remove(x)) == remove(strip(x))` in signal terms
- a second `identify` on a cleaned output reports no metadata signals
### A5. Contract sweep across every parameter choice
`scripts/smoke_matrix.py` covers every choice-valued flag on fixtures. Its knob rows
were rewritten when the CPU/MPS profiles and the ESRGAN chain were removed: most now
assert a knob is REJECTED, and the accepted-knob rows skip without a CUDA device, so
the row count is host-dependent rather than the fixed 68 recorded here before. Extend from fixtures to a stratified corpus slice
(~500 images spanning format x provenance x aspect ratio), asserting exit-code semantics
rather than just absence of crash.
Known trap to encode: **exit 2 is triply overloaded** (no-visible-mark, no-invisible-signal,
Click usage error). A wrapper cannot distinguish them without parsing stderr. Either the
sweep asserts on stderr, or the codes get split -- the latter is the better fix.
#### Coverage method
Compare the flags and values exercised by the matrix against the options declared by
the CLI. Include optional backends, batch modes, tiling, and the ffmpeg audio/video
strip. (Region-targeted diffusion composition was on this list until the parameter
was deleted: nothing but a caller-less convenience wrapper ever reached it, and the
user-facing region path is `erase`, which inpaints instead.) The gap to find is not only
"logic untested" but
"never executed on real data", which is precisely what this campaign is for.
#### Bug found by the extension: `--steps` below ~7 crashed inside torch
**Fixed by deletion.** `--steps` no longer exists, on the CLI or in the Python API,
so this class of failure is unreachable. Kept as a record of why.
Effective timesteps were `int(steps * strength)`. At the vendor-adaptive default
strength (0.15, or 0.10 for OpenAI) any `--steps` under 7 rounded to **zero**, and the
pipeline died with a raw traceback:
```
RuntimeError: cannot reshape tensor of 0 elements into shape [0, -1, 1, 512]
```
Fully valid CLI arguments, no special flags, no `--force`. The value was accepted, the
crash was a torch internal, and nothing told the user that steps and strength interact.
The considered fixes were a clamp to >=1 effective step or an up-front validation
naming both values; what shipped instead is that each stage owns its own distilled
schedule and no caller can set it. The general lesson stands: a knob whose valid range
depends on another knob's value needs the interaction validated where both are known,
or it needs to not be a knob.
Method note: the first run of the knob rows failed 12 times with this identical error,
which read like twelve broken features. It was one bad harness parameter (`--steps 4`)
sitting on top of one real bug. An error that is IDENTICAL across unrelated rows is
evidence of a common cause, not of many faults -- check the shared input first.
## Tier B -- constructed ground truth (automatable, no labelling)
Where reality gives no answer key, build one. This is the tier that closes the two biggest
holes: fill quality and detector response at the edge of the operating range.
### B1. Fill quality with a true reference
Real marks have no clean counterpart, so quality has only ever been eyeballed. Construct it
instead: take a clean corpus image, stamp a known mark at a known position (the captured
alpha maps make this exact), remove it, and compare against the **true original**.
Yields PSNR / SSIM per `--backend` (cv2 / migan / lama), sliced by background class, which
is exactly the axis where the docs say quality varies but no number exists.
Implemented as `scripts/fill_quality.py`. Two reporting rules are load-bearing: score
INSIDE the footprint (whole-frame PSNR sits near 60 dB whatever the backend does), and use
the MEDIAN (a fill that reproduces a flat background exactly scores PSNR=inf, and one inf
makes a mean inf -- the first run reported "+inf" for every flat bucket).
#### Local fill-quality protocol
Construct marked images from clean local references, compare each backend against the known original inside the affected footprint, and keep the report under `.local-eval/`.
### B2. Detector response curves
Use `scripts/detector_response.py` to sweep mark size, opacity, background, and aspect. Report detection and maskability separately. Generated reports stay under `.local-eval/`.
### B3. Invisible round-trip, positive-control gated
The open DWT-DCT detector is positive-only and carrier-fragile: "not found" on a fragile
carrier proves nothing (measured: `chatgpt-1.png` recovers 114/128, below the 118 gate).
Every invisible assertion must first embed on the SAME carrier and confirm recovery, and
**degrade to a skip rather than a pass** when the control fails. Already implemented in
`smoke_matrix.py`; apply the same discipline anywhere else this detector is used.
### B4. Resource ceilings
Peak RSS and wall time per backend x input size, up to 25 MP. The memory-constrained CPU
tier is a real constraint (MI-GAN must stay ~0.6-0.9 GB by cropping around the mask); a
regression here is invisible today and would only surface under load.
## Tier C -- human-labelled accuracy (bounded by labelling effort)
The machinery exists: `visible_recall_sample.py` -> `visible_sheets.py` ->
`visible_groundtruth.py` -> `visible_eval.py`.
- **Recall** needs representative sampling per mark and aspect ratio.
- **Precision** must be benchmarked before and after every detector change with
`--vs <snapshot>`.
- **Coverage** is separate from tuning. A missing vendor detector cannot be repaired by
changing another detector's threshold.
Three harness rules are load-bearing and must not be relaxed: score a mark only within its
crop's adjudication scope; take provenance from metadata, never from labels; and never
report recall from the detector-sampled set.
## Tier D -- external oracles (manual, not automatable here)
SynthID removal cannot be verified locally by design -- no public decoder exists. Each
vendor has its own oracle and it covers only that vendor's content: `openai.com/verify` for
OpenAI (more accessible, the automation candidate), the Gemini app for Google (manual,
rate-limited). A quiet metadata proxy is **not** proof the pixel watermark is gone.
Scope honestly: this tier certifies strength floors on a handful of images per vendor, and
that is all it can do. See `docs/synthid.md`.
### D1. Sampling frame
Select a representative local sample without committing images, identifiers, dataset sizes, or oracle results. Stratify by signal family and preserve the sampling method outside the public repository.
### D2. The oracle is the bottleneck, not the GPU
Measured on MPS (2026-07-19), single invocation of `invisible`:
| `--max-resolution` | 256 | 384 | 512 | 768 | 1024 |
|---|---|---|---|---|---|
| wall time | 37.9 s | 58.9 s | 59.4 s | 65.2 s | 118.3 s |
384/512/768 are indistinguishable, so below ~768 the cost is dominated by **fixed model
load, not diffusion**. Confirmed by batching: 4 images in one process took 105.5 s
(26.4 s/image) against 59.4 s/image one at a time -- roughly 40 s fixed overhead per
invocation and ~15 s marginal per image at 512 (rough: run-to-run variance is large).
Two consequences for the harness:
- **Amortize the fixed cost**: one long-lived process over many images, never one
invocation per image. That is a 2-4x win. Shrinking below 512 is not.
- The binding constraint is the **external oracle's throughput**, which is manual and rate
limited. So do not run a uniform grid; spend each oracle check where the answer is
uncertain -- **bisect strength per content class** to certify a floor in ~10 checks
instead of ~100.
### D3. Mandatory control before trusting any reduced-size run
`--max-resolution` is downscale -> diffuse -> Lanczos upscale. If the **resize round trip
alone** damages SynthID, the oracle goes quiet for a reason unrelated to removal and the
result does not transfer to production at native resolution.
Before any reduced-size sweep, run the resize round trip with **no diffusion** and put the
result through the vendor oracle. If the watermark survives, the reduced size is a valid
test bed; if it does not, reduced-size results are measuring the resizer. This is the same
failure shape as the imwatermark carrier-fragility trap in B3: an oracle that falls silent
for the wrong reason reads exactly like success.
`docs/synthid.md` cites ~99.98% TPR across 30 transforms including resize, which predicts
the control passes -- but that is Google's claim about their own decoder, not our
measurement, so it is a hypothesis to test, not a reason to skip the control.
### D4. Video candidates require a matched transcode control
Video experiments add frame sampling, resizing, frame-rate conversion, and a
final video codec around the actual attack. `scripts/video_synthid_sweep.py`
therefore emits `control.mp4` from the same selected frames and encoder settings
as every VAE candidate.
Verify the control first in a new Gemini chat by invoking `@synthid` and using
the supported built-in content-verification question. Continue only when the
provider oracle still detects SynthID in it. Verify each candidate in its own
new chat. A generic response that discusses visual clues or metadata is not an
oracle result. Do not ask the chat model to reinterpret or second-guess the
built-in verdict; that follow-up is ordinary model reasoning. Record only the
explicit built-in SynthID verification verdict in the generated CSV.
The harness shares one latent-noise field across the sequence to avoid adding
independent frame noise. Its temporal-residual metric is a fidelity check, not a
watermark detector.
The 2026-07-29 two-carrier calibration produced genuine built-in verifier
results: both matched controls were positive, the stronger candidate
was negative on both carriers, and a weaker candidate was negative on one. On
2026-07-30, adversarial follow-up prompts returned `UNAVAILABLE` after asking
ordinary Gemini to ignore and reinterpret the detector result. Those follow-ups
were mistakenly treated as a stricter oracle; they were not detector reruns.
The implementation remains exposed as `video invisible` and
`remove_video_invisible`. Its oracle-certified default is the product operating
point. A fresh control-positive, candidate-negative pair remains an optional
per-file audit after provider changes or for unusually important files.
The 2026-07-31 full-clip check added a public eight-second Veo carrier. The
source and the complete `0.10` product output were both detected, proving the
surrounding resize / frame-rate / codec path had not silenced the oracle. The
complete `0.15` product output was not detected, so `0.15` became the default.
The tracked source and output hashes, fidelity metrics, and verdicts are in
`data/evaluations/video-synthid-oracle.csv`; generated media remains untracked.
## Tier E -- robustness and adversarial inputs
Malformed and hostile inputs, including truncated files:
at many offsets, corrupt headers, 16-bit and CMYK, absurd dimensions, decompression bombs,
zero-byte files, unicode and RTL filenames, symlinks, read-only output dirs, concurrent runs
on one file. The bar is never "handles it" but **never raises and never silently degrades**.
## Build order
1. **A1 sidecar regression** -- highest value per hour, unattended, needs no new labels.
2. **A2/A3/A4 parity and invariants** -- representative local set, reusing existing audit scripts.
3. **B1 fill quality** -- closes the oldest unmeasured claim in the project.
4. **B2 detector curves** -- cheap, and directly guards the geometry class of bug.
5. **A5 contract sweep over a representative local set**.
6. **B4 resource ceilings**, **E robustness**.
7. **C recall expansion** -- gated by labelling appetite.
8. **D oracles** -- manual, per release.
Every tier writes a versioned snapshot so runs are comparable over time; a run that cannot
be diffed against the last one is a one-off, not a regression suite.
## What the measurements imply for detection work
Recorded here because each item is grounded in a number from this campaign, not because
it is a prioritized plan (that lives elsewhere -- see the note at the end of this section).
### Metadata absence does not disable the detectors -- it disables the RELAXATION
The detectors are pixel-based and need no metadata. What metadata does is relax the
false-positive gate (`auto` vs `strict`). So "work better without metadata" means
strengthening the strict-path detectors themselves; it is not a gating problem.
Per mark, what actually goes away when metadata is stripped:
- **The pill loses its metadata-confirmed arm.** Without metadata it depends on the
bottom-right Jimeng wordmark.
- **The sparkle already runs on pixels.** Its threshold remains a deliberate
recall-versus-precision trade.
- **Uncovered vendors are metadata-independent gaps.** They require a detector of their
own.
### Where the evidence points
1. Prefer per-vendor CJK templates when fitted geometry separates similar marks.
2. Recalibrate every detector when changing its front-end. Thresholds do not transfer
between `binary`, `tophat`, and `gray`.
3. Treat the Jimeng wordmark as a load-bearing confirmation path for the pill.
### Measure before improving
Use Tier B2 detector-response curves before tuning a detector with sparse labelled
examples. Sweep size, contrast, aspect, and background texture so geometry regressions
are visible without exposing private evaluation statistics.
This section records what the measurements imply technically. Prioritization is tracked
separately, outside this repo.
## Local end-to-end verification
Run `scripts/real_examples_e2e.py` against representative local inputs before releases that affect image handling. The script must read from `.local-eval/`, write only untracked temporary output, and report behavior without exposing dataset provenance or aggregate private measurements.
The cheap video seam is covered automatically by
`TestVideoVisibleFullClip::test_removes_complete_clip_and_preserves_sequence_and_audio`.
It constructs a full synthetic Sora-like MP4 with AAC audio and C2PA
provenance, then exercises detection, temporal arbitration, OpenCV fill, real
ffmpeg encoding, metadata stripping, audio stream copy, and atomic publication
through the public API. A separately encoded clean control supplies the paired
frame-to-frame deltas inside the filled region, so the gate detects temporal
flicker rather than treating all output motion as an error. The default
motion-compensated fill must score strictly below a separately encoded
frame-local opt-out on the median paired error, while its high-percentile error
cannot regress. The dedicated Linux CI job installs ffmpeg explicitly. Keep real-provider and learned-backend
sequence evaluation local because those inputs or model downloads do not
belong in the core matrix.
The local real-provider audit runs one complete clip for every registered video
mark. OpenCV and MI-GAN must remove every accepted frame, leave the second-pass
detector quiet, preserve source stream starts and duration, and copy AAC
packets exactly when present. Run one complete LaMa clip to verify wiring and
resource tier; its CPU throughput makes a six-provider online matrix
counterproductive. Store generated outputs and the detailed CSV only under
`.local-eval/`.
The same full-clip gate runs the public metadata-only path against its real MP4
and verifies unchanged file size, decoded frames, stream properties, and AAC
packets. A separate synthetic large-`mdat` test rejects full-source
`read_bytes()`, hashes the copied media payload, and mutation-checks both C2PA
and TC260 survival.
A companion full-clip VFR case alternates three frame durations, runs the
public visible-removal API, and compares every output display timestamp to the
source within one source time-base tick. Its source starts at a non-zero PTS,
so the test also verifies retained video/audio stream offsets, container
duration, and copied AAC identity. Mutations that disable the timestamped NUT
bridge or reset its start PTS must fail this gate.
A separate constant-rate clip with the same non-zero start guards the
start-offset routing without relying on the VFR branch.
## Standing gap
None of this is in `maintain.sh`, and it should not all be -- the sweeps take hours. But
that means **no detector-accuracy or CLI-contract regression is caught automatically**
today. The endpoint of this plan is a cheap subset (fixtures-only smoke + a sidecar diff on
a fixed 500-image slice) that CI can run, with the full sweeps staying pre-release.
+607
View File
@@ -0,0 +1,607 @@
# Video SynthID quality research (2026-08-05)
> Research archive. This page records experiments and decisions from the date
> above. It may mention prototypes or defaults that were later changed. Use the
> user guides and current source code for the supported interface.
Cited research behind the question **"can the video SynthID path keep removing the
mark while giving up far less quality than the shipped 512 px / 12 fps profile?"**
Produced by a 13-agent workflow: 6 parallel scouts (pipeline ablation, SynthID
internals, attack literature, autoencoder landscape, perceptual masking, experiment
design), 11 change proposals from 3 independent design angles, and 3 adversarial
critics (signal theory, repository engineering, verifiability). No experiment was
run against the provider oracle for this page; every claim is labeled MEASURED,
REPORTED, or INFERRED.
Repository claims below were re-verified against source after the workflow
returned: the PSNR reference frame, the audio stream copy, the missing `_fit_size`
clamp, the absent `enable_tiling` call, the crf asymmetry, the test pins, and the
absent `scaling_factor` key in the cached checkpoint config.
## Context
`remove_video_invisible` regenerates video pixels through `sd-vae-ft-mse`: frames
are decimated to 12 fps, resized to a 512 px long side, encoded to latents with
`latent_dist.mode()`, perturbed by one seeded spatial noise field shared across
every frame, decoded, and streamed to an H.264 encoder at crf 18. A separate
stream-copy mux re-adds source audio and strips metadata. The shipped
`noise_std=0.15` is certified by one oracle row on one carrier.
The quality complaint is real: a 1080p source is delivered at roughly a quarter of
its linear resolution and half its frame rate. This page asks what part of that
cost is buying removal and what part is buying nothing.
## What the two oracle rows do and do not prove
`data/evaluations/video-synthid-oracle.csv`, one carrier (Veo 3 off-road,
sha256 `79a552b9...`), one seed, one geometry:
| noise_std | long_side | fps | verdict | psnr_db | temporal_residual_ratio |
| --- | --- | --- | --- | --- | --- |
| 0.10 | 512 | 12 | detected | 26.2932 | 1.0072 |
| 0.15 | 512 | 12 | not_detected | 25.3911 | 1.0578 |
They prove exactly one thing: the envelope of 512 px, 12 fps, crf 18, and a full
VAE round trip does **not** silence the oracle on its own. The verdict flip is
bought by the last 0.05 of latent noise.
They cannot decompose that conjunction. No row varies `long_side` or `fps`, so the
contribution of the downscale is unmeasured. The frequent reading "the 512 px
downscale was probably doing the removal work" is not supported, and neither is its
opposite.
## Measured facts from the repository
- **The fidelity metric is blind to the expensive steps.** `_iter_sampled_frames`
applies `cv2.resize` at [`video_invisible.py:236`](../src/remove_ai_watermarks/video_invisible.py);
the same generator feeds the loop at `:401-407`; the accumulator at `:429-435`
zips those already-resized frames against `regenerated` captured at `:423`, which
is before `frame_pipe.write` at `:430`. `psnr_db` therefore excludes the
downscale, the decimation, and the encoder. It measures the VAE round trip plus
latent noise at the working geometry, nothing else.
- **Frame decimation cannot destroy the carrier.** `_iter_sampled_frames`
(`:224-238`) is pure subset selection: the only statement inside the threshold
test is `yield cv2.resize(...)`, with no arithmetic across frames. The
perturbation is equally time-blind: `shared_noise.expand(latents.shape[0], -1, -1, -1)`
at `:295` broadcasts one CHW field across the batch and never mixes across time.
Any fps effect on the verdict is a detection-probability effect, not carrier
destruction.
- **`_fit_size` has no clamp at 1.0.** `scale = long_side / max(width, height)` at
`:88` upscales any source whose long side is below 512, and both axes are floored
independently to a multiple of 8 at `:89-96`, which introduces an anamorphic
shift at scale 1.0 (1366x768 becomes 1360x768).
- **`enable_tiling` is never called** anywhere in `src/`, `scripts/`, or `tests/`.
`enable_slicing()` at `:135` already splits both encode and decode to single
frames, so activation VRAM is set by one frame and does not scale with
`--batch-size`.
- **crf asymmetry:** 18 on the invisible path (`:321`, `:386`) against 14 on the
visible path ([`video_visible.py:1343`](../src/remove_ai_watermarks/video_visible.py))
through the same encoder.
- **No HDR guard on the invisible path.** `_HDR_TRANSFERS` (`video_visible.py:97`)
and the `component_depth > 8` rejection (`:1322`) exist only for visible removal.
- **Audio is byte-copied.** `-map 0:v:0`, `-map 1:a?`, `-c copy` at
[`video_encoding.py:413`](../src/remove_ai_watermarks/video_encoding.py).
- **Only `noise_std` is pinned.** `tests/test_video_invisible.py:285` asserts
`DEFAULT_VIDEO_SYNTHID_NOISE_STD == 0.15`. Neither `long_side` nor `fps` is
pinned anywhere, so changing either breaks no test.
- **`scaling_factor` 0.18215 is a class default, not a checkpoint fact.** The
cached `config.json` for `stabilityai/sd-vae-ft-mse` has no `scaling_factor` key
at all (verified locally: `_class_name`, `latent_channels`, `block_out_channels`,
`sample_size`, and block types only). The value comes from the `AutoencoderKL`
class default under `diffusers>=0.38.0` with no upper bound (`pyproject.toml:94`,
`uv.lock` resolves 0.39.0), while `maintain.sh` runs `uv-outdated`. A dependency
bump can move the certified operating point with a fully green test suite.
- **The manifest schema cannot record what a real program needs:** no source
geometry, no control row, no track column, no verbatim verdict, no indeterminate
state.
## Measured facts from external sources
- Gemini video verification quota: 10 checks per rolling 24 hours, up to 5 minutes
of video total, under 90 seconds and 100 MB per file. The verifier reports which
parts of the video carry the mark, and it has a **third** state beyond detected
and not detected: unclear, with documented causes including "not enough details
to watermark".
- The verifier scores **audio and visual tracks separately**. Google's published
example verdict reads as SynthID detected in the audio over a time range with no
SynthID detected in the visuals.
- SynthID-Image (arXiv:2510.09263) is a post-hoc, model-independent pixel-space
watermark: a separate encoder network stamps an already-decoded image rather than
being injected into the generator's latents. Its published payload figure is 136
bits within a 512x512 image, and its product setup runs at 1536x1536.
## Inferred, with the reasoning that makes them weak
- **The noise axis is nearly exhausted.** Fitting `MSE = A + B * noise_std^2` to the
two measured rows gives A = 124.5 and B = 2815, so a pure round trip
(`noise_std = 0`) lands near 27.2 dB. The entire noise budget is worth at most
**+1.79 dB**; everything else is the autoencoder. This is a two-parameter fit to
two points with zero degrees of freedom, and the assumptions that MSE is
quadratic in `noise_std` and that reconstruction and noise errors are orthogonal
are untested. One local run at `noise_std=0` replaces it with a measurement.
- **Statistical power of the current certification.** With zero failures at n = 1,
the exact one-sided Clopper-Pearson bound `1 - 0.05^(1/n)` is 95%: the data are
compatible with removal failing almost always. n = 15 gives 18.1%, n = 30 gives
9.5%.
- **Size of the geometry prize.** At a 1280x720 source, `_fit_size(1280, 720, 512)`
is `(512, 288)` (pinned in `tests/test_video_synthid_sweep.py:30`), so 6.25x of
the pixels are discarded by the downscale and another 2x by decimation from a
24 fps source. Ladder rungs: 768 gives 2.25x the current area, 1024 gives 4.0x.
The carrier's own geometry is recorded nowhere, so this is conditional.
- **The direction of the resolution effect is disputed, and both sides are
inference.** Against raising it: the absolute frequency ceiling below which an f8
VAE reconstructs faithfully is tied to the latent pitch, so 512x288 (a 64x36
latent) preserves roughly up to 32 cycles per frame width while 1920 (a 240x135
latent) preserves up to about 120. Raising resolution moves the carrier band out
of the regime the decoder synthesizes and into the regime it reproduces
faithfully, handing the detector more evidence. For raising it: Google's product
operating point is 1536x1536, so native is closer to the distribution the
watermark encoder targets. Note that the second argument cuts against the
proposal rather than for it.
## First local measurements (2026-08-05)
Run without the oracle on a locally built carrier: a 6-second 1280x720 24 fps
clip panning across `data/fixtures/provenance/doubao-1.png`, processed by the
shipped path at 512 px / 12 fps on MPS. Generated media stayed outside the
repository.
| noise_std | engine `psnr_db` | end-to-end PSNR | end-to-end SSIM | bitrate |
| --- | --- | --- | --- | --- |
| 0.00 | 27.8049 | 27.6935 | 0.6955 | 2564 kbps |
| 0.15 | 25.8853 | 25.8541 | 0.6703 | 2716 kbps |
Three readings, all MEASURED, none of them about SynthID:
1. **The two-point fit's shape survives contact with independent content.** The
pure round trip lands at 27.80 dB against the 27.2 dB the carrier fit
predicted, and the whole noise budget costs 1.92 dB against the predicted
1.79 dB. These are not the carrier's numbers, but the decomposition holds:
the autoencoder is the floor and the entire `noise_std` axis is worth about
2 dB.
2. **On this content the downscale is nearly free in PSNR terms.** End-to-end
PSNR sits within 0.11 dB of the in-loop number, meaning the 512 px geometry
cost almost nothing next to the VAE damage. This clip is a pan over a smooth
generated image with little high-frequency detail, so it is the friendly
case: real camera texture should widen that gap. Run the probe across content
types before trusting any estimate of the geometry prize.
3. **`temporal_residual_ratio` is not meaningful on a near-static shot.** It read
1.82 at `noise_std=0` and 2.47 at 0.15, far outside the [1.0072, 1.0578] band
ever observed before. A slow pan gives the source almost no motion residual,
so the `max(temporal_baseline, 1e-6)` denominator collapses and the ratio
inflates. This is the predicted defect, now observed rather than argued.
## Measurements on the real carrier (2026-08-06)
The public Veo 3 off-road sample was downloaded and its sha256 matched the manifest
exactly, so everything below is measured on the same file the 2026-07-31
calibration used. Its properties, none of which the manifest recorded at the time:
H.264 1920x1080 at 24 fps, 8.000 s, 5,155,063 bps video, plus an **AAC 48 kHz
stereo 192,241 bps audio track of 7.914 s**.
The shipped profile therefore delivers 512x288 at 12 fps: **7.1% of the frame area
and 3.6% of the pixels per second.**
### The geometry prize, measured end to end
Scored with `scripts/video_fidelity_probe.py` against the untouched 1920x1080
source, all at `noise_std=0.15`:
| Geometry | Frame area | PSNR | SSIM | Temporal | Bitrate |
| --- | --- | --- | --- | --- | --- |
| 512x288 @12 (shipped) | 7.1% | 25.3971 | 0.8215 | 1.0407 | 2187 kbps |
| 768x432 @12 | 16.0% | 27.3796 | 0.8556 | 1.0323 | 3782 kbps |
| 1024x576 @12 | 28.4% | 28.8542 | 0.8819 | 1.0262 | 5604 kbps |
| 512x288 @24 | 7.1% | 25.6142 | 0.8270 | 1.0995 | 3181 kbps |
**512 to 1024 is worth +3.46 dB, against 1.92 dB for the entire `noise_std` axis.**
The geometry lever is roughly twice the noise lever, which is the first hard number
this investigation has for the size of the prize. What it still costs in removal is
unknown: none of these was ever submitted.
Two readings that are not obvious from the table:
- **Temporal residual improves as resolution rises** (1.0407 to 1.0262). The shared
noise field lives on the latent grid, so at 1920 it is four times finer relative
to the frame than at 512, where it stretches into low-frequency blotches. Higher
resolution plausibly makes the perturbation *less* visible, not more.
- **The frame-rate arm looks bad here and the metric is the wrong judge.** 24 fps
buys +0.22 dB and worsens the temporal ratio to 1.0995, but what 24 fps actually
buys is smoothness, which neither PSNR nor SSIM measures at all. The metric sees
only the cost. Do not decide the fps question from this table.
Encoder cost on an M-series MPS device, 8 seconds of source: 51 s at 512/12, 180 s
at 768/12, 380 s at 1024/12, 190 s at 512/24. Native 1920x1080 at 24 fps
extrapolates to roughly 46 minutes for the same 8 seconds, around 350x real time. A
native run was started and killed before producing anything, so that figure remains
an extrapolation.
### The certified row does not reproduce
Re-running the certified configuration (512/12/0.15, seed 0) on this machine gave
25.5002 dB and 1.0675 against the manifest's 25.3911 and 1.0578, and a different
output sha256. The manifest records neither device nor dtype, and the original was
almost certainly CUDA fp16 while this was MPS fp32. **The oracle certified a
specific file that cannot be rebuilt**, so any re-run produces an artifact the
verifier never saw. Device and dtype belong in the schema.
### Audio passes through untouched, and the output still reads clean
Established on two carriers, by comparing the extracted audio bitstream sha256 of
input and output:
- Veo carrier: `674063df...` in, `674063df...` out.
- A Lyria 3 track generated for this purpose: `6ee4c041...` in, `6ee4c041...` out.
The Lyria case is the sharper one because its provenance is known. Google's own
generator stamps it: `get_ai_metadata` reads a C2PA manifest naming
`Google C2PA Core Generator Library`, issuer `Google LLC`, `trainedAlgorithmicMedia`.
After the shipped path runs, that output reports `{}` -- every metadata marker
stripped, `remaining_metadata` empty, visuals regenerated -- while the audio the
generator produced is bit-for-bit intact.
So the pipeline can produce a file that **our own detector calls clean while an
untouched Google-generated audio track sits inside it**. The mechanism is proven;
whether that audio carries an audible-to-the-verifier SynthID mark is not, and only
the oracle can say. If it does, this is a shipped defect rather than a quality
question, and it outranks everything else in this document.
Worth recording alongside it: the Veo carrier itself returns `{}` from
`get_ai_metadata`. The public sample carries no C2PA at all, which is exactly why it
needed an oracle verdict in the first place.
## State when this work was parked (2026-08-06)
Landed in `main` through PR #78: the manifest-backed default pin, the
scaling-factor gate and its single seam, the sweep loading through the engine's
loader, `scripts/video_fidelity_probe.py` with its tests, the extended manifest
schema, and this document. **The shipped operating point did not move**: it is
still 512 px / 12 fps / `noise_std=0.15`.
A product requirement arrived late and reframes the target: output resolution,
frame rate and bitrate must not be reduced. Resolution, frame rate and crf are
parameters and can be preserved, but zero quality loss is not achievable by this
mechanism at all -- removal works by rebuilding pixels, so a regeneration can never
return its input. The honest goal is to preserve container properties and make the
perceptual loss imperceptible, judged by DISTS and VMAF rather than PSNR. Two
consequences follow: crf 18 on this path against crf 14 on the visible path is an
unexplained asymmetry and a free quality win, and the oracle's 100 MB limit means a
higher-quality profile is *harder* to certify than the one it replaces.
Nothing is blocked on analysis. Six oracle submissions were prepared and never run,
because file upload to the verifier failed at the tool level:
1. A Lyria carrier and the pipeline's output from it, answering the audio question.
2. A multiplexed control-plus-candidate file at the shipped geometry, validating
whether the verifier reads a concatenated pair by time range. Verified locally
frame by frame: half one is bit-identical to the control, half two to the
candidate.
3. Three more multiplexed pairs for 768, 1024 and 24 fps, worth three queries
instead of six if the instrument validates.
Every generated artifact for those submissions has since been lost with its
scratch directory, which is the intended lifecycle for generated media. Rebuilding
costs one download and roughly 25 minutes of encoding; the Lyria track is the
exception, since regenerating it would produce a different track, and the copies
that survive are the only ones.
## The single most important unknown
**How much the 512 px downscale contributes to removal.** Every quality gain routes
through this question, and the critics moved it from "probably free" to "direction
unknown", which is exactly what makes it the highest-information experiment
available.
## Ranked experiment program
Ranked by information per oracle query. The budget is roughly 10 checks per 24
hours (MEASURED), so the program is paced by calendar, not by money.
### E0. The zero-oracle tier (0 submissions) - do this first
Not an experiment on the oracle; the precondition that makes everything else
interpretable.
- `ffprobe` the carrier and record `source_width`, `source_height`, `source_fps`.
The actual downscale factor of the existing rows is currently unrecoverable.
- Extend the manifest schema: variant/control, source geometry, `vae`, `track`
(audio|visual), verdict state in {DETECTED, NOT_DETECTED, INDETERMINATE,
REFUSED}, verbatim verdict text, detected time range for the output,
`session_id`, content stratum.
- **Measure the `noise_std = 0.0` round trip locally** on the same carrier. This
converts the inferred 27.2 dB ceiling into a measurement and bounds the whole
noise axis for one GPU pass and zero oracle cost.
- Freeze the current defaults' metrics on a fixed clip set, matching the
record-then-diff rule in `.claude/rules/development.md`.
- Determine whether the carrier has an audio track and whether it carries SynthID.
### E1. Instrument validation: multiplexing and a session anchor (1-2 submissions)
The verifier reports time ranges, so one file can carry several doses. A 24-second
file of `[control 8 s | certified 0.15 8 s | control 8 s]` should read detected on
the outer segments and not detected on the middle one, both halves already known
from the manifest.
`encode_video_frames` requires matching dimensions and one frame rate, so
multiplexing works **only along the `noise_std` axis**, not across geometries: one
multiplexed file per geometric envelope.
Separately, submitting the existing detected file first in each session costs 10%
of the quota, turns "the oracle may have changed" into a per-session gate, and
measures the flip rate on a byte-identical file, a quantity every plan silently
assumes is zero.
### E2. Resolution ladder at fixed dose - the decisive axis (2 submissions per rung)
A fixed value, not a ceiling. Rung 1 is `long_side = 768` with fps held at 12 so
exactly one destruction axis moves. Jumping straight to 1920 is a bad first rung:
a 3.75x jump makes a detected verdict uninformative about where the boundary lies.
- **Matched control:** 768 / 12 / `noise_std = 0.10`, expected DETECTED. It is
strictly stronger than a plain re-encode control because it discharges the
envelope, the VAE round trip, and a nonzero dose at once, and it diffs directly
against the existing 512 row.
- **Candidate:** 768 / 12 / 0.15.
- **Positive:** the resolution axis is open; next rungs 1024 and then native with a
clamp, 2 submissions each.
- **Negative:** the downscale contributes to removal. This is the most valuable
available negative: it kills the native-resolution proposals outright and turns
the question into how much dose must be spent to buy resolution back, at the
known exchange rate B = 2815.
- Watch for a monotonicity violation: 768 / 0.10 reading NOT_DETECTED would
overturn the critics' spectral argument.
### E3. Frame rate at fixed geometry and dose (2 submissions)
Control 512 / 24 / 0.10 expected DETECTED; candidate 512 / 24 / 0.15 expected
not detected. The mechanism is provable from source, so the prior is high, but the
only counter-mechanism - per-frame count aggregation - is probabilistic and n = 1
on an 8-second clip does not measure it. Certify on the longest clip that fits the
90 s / 100 MB limits, because the risk compounds with frame count.
Ranked below E2 because the outcome is nearly predetermined and the prize (judder)
is smaller than the geometry prize. It is the safest bet if a guaranteed win is
wanted.
### E4. `noise_std = 0.0` as an oracle probe (1 submission) - deliberately low rank
The local half of this probe is the cheapest high-information measurement in the
program and already sits in E0 at zero oracle cost. The **oracle** half ranks low:
it buys no quality by construction, and its likely DETECTED outcome is nearly
deducible from the 0.10 row under monotonicity. Its one real value is that
NOT_DETECTED here would be non-monotone against 0.10 and would refute the
assumption underneath every ladder and bisection in this program. Worth one query,
after E2 and E3.
### E5. Stratified certification of the surviving operating point (15-30 submissions)
Only after E2 and E3 produce a winner. Zero detections across at least 15
stratified carriers (at least 3 per stratum: face closeup, fast motion, flat sky,
dark night, text overlay) bounds the failure rate at 18.1%; 30 carriers bound it at
9.5%. Add the project's 1.5x margin convention, at least 2 seeds, at least 2
sessions, and per-track verdict recording.
For the flat-sky stratum, check the source reads detected first: Google documents a
"not enough details to watermark" state, so a clean reading there may say nothing
about removal.
## Surviving change candidates
**S1. Fix the metric's reference frame (0 oracle submissions).** Add
`source_psnr_db` (candidate upscaled back to native against the untouched source
frame) **and** a separate post-mux pass that decodes the delivered file. The
critics killed the original claim that this would expose crf 18: the candidate is
captured at `:423`, before `frame_pipe.write` at `:430`, so no in-loop metric can
see the codec. Only the post-mux pass covers resize, decimation, crf, and mux
together. Use `INTER_AREA` or a fixed analytic size for the upscale so the CPU
resize does not dominate the loop, and update both other consumers of the
generator in the same change (`read_sampled_frames` at `:188-209`, and
`scripts/video_synthid_sweep.py:147` where `np.stack(frames)` must keep receiving
resized frames). Do not redefine the existing `psnr_db`: the two manifest rows stay
comparable to each other only while that field keeps its exact current meaning.
**S2. Frame rate as a fixed certified value (after E3).**
`DEFAULT_VIDEO_SYNTHID_FPS` 12.0 to 24.0, keeping `min(fps, source_fps)`. A
ceiling of 60.0 was **rejected**: it makes the delivered operating point a function
of the user's source, so one sample from a family gets certified while the rest
ship uncertified, and the risk direction is unfavorable. Port the VFR/PTS bridge
(`probe_video_timestamps`, `timestamped_input`, currently only in
`video_visible.py`): at native frame rate the output stops reading as a proxy, and
silent CFR-ification becomes a master-quality defect. Add `fps` and `long_side`
pins next to `tests/test_video_invisible.py:285`, tied to the certifying manifest
row.
**S3. Clamp and align `_fit_size` (0 oracle submissions, with a caveat).**
`scale = min(1.0, long_side / max(width, height))` at `:88`; align the long side
and derive the short side from the true aspect, rounding to the nearest multiple of
8. The pinned `_fit_size(1280, 720, 512) == (512, 288)` survives. The caveat: the
clamp is an **uncertified operator change for a whole class of users**, since a
320x240 source is upscaled to 512x384 today and would run at 320x240 afterwards,
and neither has been tested. Ship it as its own documented change, not as a
drive-by fix.
**S4. Extend the manifest schema and oracle protocol (0 submissions).** A
precondition for the whole program. Record the verbatim verdict text, because an
unclear state logged as not detected is exactly the silent regression the protocol
exists to prevent.
**S5. A sigma normalization contract (0 submissions, applies to the current path).**
No VAE swap survived, but two elements are real risks today: `vae.config.scaling_factor`
cannot be trusted, so log `latents.std()` at `:276` and assert the effective
scaling factor at load; and any cross-model sigma transfer must match on the **RMS
of the decoded pixel perturbation**, not on latent units.
**S6. Additive texture masking - contingency on an E2 negative only.** Not energy
preserving: the certified sigma stays as a floor everywhere and the mask only adds
on top in textured regions. The original energy-preserving form was refuted - the
mechanism normalizes and then clips, which breaks the claimed invariant by a
content-dependent amount, and the proposed unit test asserted the invariant before
the clip and would have stayed green. Energy preservation is also unsafe in
principle: the detector does not need a full frame, and the verifier reports
per-segment, so a flat region perturbed only at the floor is a crop carrying nearly
the full carrier. The salvaged version has **no standalone PSNR win**; its only
value is freeing distortion budget to spend on resolution if E2 shows the dose must
rise. It additionally needs motion compensation for the mask (flow is computed at
`:442`, after the decode at `:423`, so the loop must be restructured) and one
deliberately bad candidate so `temporal_residual_ratio` acquires a known failing
value: it has only ever been observed in [1.0072, 1.0578], so "the ratio looks
fine" is currently an unfalsified claim.
## Refuted proposals - do not resurrect
- **Carrier-scale, resolution-invariant noise field.** "Carrier scale" is set by
our VAE's latent pitch, not the carrier's. The motivating arithmetic treats a
field on the latent grid as white at pixel resolution and is wrong by exactly the
square of the scale factor: white noise on a 240-wide latent already spans
0-120 cycles per frame, entirely inside the band the decoder reproduces. The
construction actually band-limits the perturbation from 0-120 to 0-32 cycles,
making it smoother - the first thing any spread-spectrum extractor's content
suppression removes - while moving its energy toward the peak of the contrast
sensitivity function, making it more visible. Bilinear upsampling is also
heteroscedastic, and global renormalization by `noise.std()` stamps a visible
amplitude lattice rather than fixing it.
- **Native resolution plus tiling plus a carrier-scale field.** Inherits the
arithmetic above, concedes that native processing destroys less carrier without
proposing a replacement mechanism, and bundles four destruction axes into one
candidate on a 4-query budget so a detected verdict is undecomposable. Its cost
model was also wrong by roughly 10x. The salvageable parts moved into S1 and S3.
- **`AutoencoderKLTemporalDecoder`.** Refuted independently by three critics.
Removal is a property of the encode-decode composition, so a strictly more
faithful decoder preserves more carrier at the same sigma. The class implements
neither slicing nor tiling, so `enable_slicing()` at `:135` raises
`NotImplementedError` and the documented bounded-memory property dies. Its
temporal receptive field needs windows of tens of frames, its license carries a
revenue restriction incompatible with an Apache-2.0 default, and its own
published table shows a **worse** FID (9.17 against 7.61).
- **Wan or another temporal video VAE.** Hard refusal in code: `AutoencoderKLWan._encode`
uses `iter_ = 1 + (num_frame - 1) // 4`, so at the shipped `batch_size = 4` only
frame 0 is encoded and a 4-frame batch decodes back to 1, which makes
`zip(..., strict=True)` at `:429` raise. The proposed remedy of carrying the
causal feature cache across chunks is impossible through the public API, since
`clear_cache()` runs on both entry and exit of `_encode` and `_decode`.
`float(vae.config.scaling_factor)` at `:270` and `:292` also raises, because Wan
exposes `latents_mean`/`latents_std` instead. Logically, feeding 4:1 temporal
compression frames 83 ms apart either reconstructs well (carrier preserved, no
removal gain) or hallucinates (no quality prize); the two claims are mutually
exclusive.
- **A 16-channel f8 VAE.** Self-refuted and confirmed by the critics. Quadrupling
latent channels roughly doubles L in Zhao's theorem, so sigma must roughly double
to hold removal, and the VAE frontier in the UnMarker results is monotone with no
published point where a more faithful autoencoder removes **more**. Predicted net
loss of 2-7.5 dB by its own arithmetic. Its config also carries
`scaling_factor = 0.2614`, so an unchanged `noise_std = 0.15` is about 30% weaker
in raw latent units. Only the normalization contract survived, as S5.
- **A per-channel luma/chroma probe.** The arms match in absolute latent units but
not in relative dose per channel, since `scaling_factor` normalizes the aggregate
latent, so the very reading the probe exists for is confounded with dose. The
chroma arm is additionally subsampled by the yuv420p encode, which no current
metric sees. Maximum prize under 1 dB by its own fit.
- **Post-processing with unsharp plus grain, as a shipped stage.** The
carrier-reimport argument is sound and was verified line by line: `unsharp_mask`
reads only its own argument ([`humanizer.py:79`](../src/remove_ai_watermarks/humanizer.py)),
and `adaptive_polish` touches the source only through one float. But reimport was
never the binding risk:
`cv2.addWeighted(img_f, 1.0 + amount, blurred, -amount, 0.0)` at
`_ADAPTIVE_MAX_UNSHARP = 1.0` multiplies the **surviving carrier residue** by up
to 2x as the terminal operation before encoding, and whether that re-arms a given
file depends on that file's unobservable margin. With no local decoder and only a
sampled oracle the stage is structurally uncertifiable, not merely expensive to
certify. The only rescue is moving sharpening **above** the VAE stage so the
removal operator stays terminal. Grain, which monotonically lowers detector SNR,
is the safe half and can be separated.
## Measuring quality properly
The current `psnr_db` cannot show the improvement this work exists to produce.
Minimum upgrade:
1. **`source_psnr_db`** - candidate upscaled back to native against the untouched
source frame. Good for ranking configurations against a common reference,
dominated by unrecoverable high-frequency content, so not a measure of what the
VAE costs.
2. **A post-mux end-to-end pass** - decode the delivered file after
`mux_encoded_video` (`:456`) and compare against the source. The only measure
covering resize, decimation, crf, and mux together. Keep it out of the streaming
loop so `test_stream_batches_consumes_only_one_batch_ahead` stays valid.
Implemented as `scripts/video_fidelity_probe.py`, which streams, reports the
delivered file's bitrate, and shares the engine's frame-selection rule rather
than copying it.
3. **DISTS** (arXiv:2004.07728), built to tolerate texture resampling - exactly
what the VAE does to foliage, skin, and grass, and exactly what PSNR and LPIPS
punish even when the result is perceptually equivalent. It separates "the VAE
resampled the grass" from "the VAE destroyed an edge".
4. **VMAF** through `ffmpeg` `libvmaf`, cheapest to add, whose ADM/DLM feature
names this exact complaint. Its temporal term is only a mean absolute luma
difference between neighboring frames, so it is not a flicker detector: keep the
motion-compensated ratio.
5. **Encoded file size as a third axis.** Every current metric is taken before the
pipe, so a bitrate explosion currently reads as "quality did not suffer". This
matters most for any perturbation that varies frame to frame: at fixed crf it
raises bitrate rather than lowering quality.
6. **`temporal_residual_ratio` repairs**: add a p95 across frames next to the mean
so localized flicker stops being averaged away, add a long-horizon term against
the first frame, and replace `max(temporal_baseline, 1e-6)` with an explicit
undefined result on static shots.
7. **A no-reference metric** (DOVER or FAST-VQA) for the case where no
full-reference measure can compare a 512/12 output against a 1024/24 output on
one scale. Relative ranking within a sweep only; absolute values are
uncalibrated for this artifact class.
Nothing local measures removal. `remove_video_invisible` checks `get_ai_metadata`
on the output, which is metadata, not pixels. Only a manifest row counts.
## Risks
**Error asymmetry is the governing constraint.** Shipping a leak means a user
receives a watermarked file believing it is clean, with no local pixel decoder to
catch it and no feedback path that would surface it. Staying conservative means a
user receives 512 px / 12 fps, a cost that is visible, bounded, and reversible with
an explicit flag. A symmetric test is therefore inadmissible, and the burden of
proof sits entirely on the new default.
**n = 1.** The current default is certified by one success on one carrier with one
seed. Any quality change inherits that weakness and must not deepen it.
**Content and seed dependence** is MEASURED on this project's image branch:
survivors switch by content type, and near the threshold the same input flipped
between runs on seed alone.
**Oracle instability.** Three verdict states, not two. Per-segment reporting.
Separate audio and visual tracks - and the pipeline byte-copies audio, so a Veo 3
clip with generated sound leaves with its audio SynthID intact, and the manifest
cannot even record which track the 2026-07-31 negative referred to. That is
potentially a shipped product hole, not only an experimental confound. Session
drift is currently unfalsifiable.
**Observability narrows exactly where quality rises.** At fixed crf the bitrate
grows with pixels per second, so the maximum duration fitting under 100 MB falls as
fps and resolution rise. The configuration users actually receive becomes **less**
observable than the one it replaces. That is a permanent property, not an
inconvenience.
**Ceilings instead of fixed values.** Any ceiling makes the delivered operating
point a function of the user's source, and with it the perturbation's cycles per
frame - the quantity carrying the certified margin. Ship fixed values.
**Documentation and test surface.** Only `DEFAULT_VIDEO_SYNTHID_NOISE_STD` is
pinned. Moving `long_side` or `fps` touches hardcoded numbers in `README.md`,
`docs/known-limitations.md`, `docs/cli.md`, `docs/python-api.md`,
`docs/module-internals.md`, `docs/synthid.md`, and `docs/verification-plan.md`.
Drift-prone operational numbers should not live in seven places of prose; their
source of truth is the manifest.
**Silent configuration drift.** `sd-vae-ft-mse/config.json` carries no
`scaling_factor`, so 0.18215 is a class default under an upper-unbounded
`diffusers>=0.38.0` while `maintain.sh` runs `uv-outdated`. A library bump can move
the certified operating point with a green suite.
**Missing guards that get worse at native resolution.** There is no HDR or >8-bit
rejection and no VFR/PTS bridge on the invisible path. A 512 px output plainly
reads as a proxy; a native-resolution output reads as a master, and the cost of
silently flattening a 10-bit PQ source to 8-bit SDR rises accordingly.
**Do not bundle axes.** Raising `long_side`, raising `fps`, and lowering crf all
reduce total destruction and all require recertification. The metric reference-frame
fix is the only exception, because it changes the measurement rather than the
pixels. Do not put a crf 18 to 14 change in the same candidate as a resolution
change.
+169
View File
@@ -0,0 +1,169 @@
# Watermarking landscape (research 2026-05-24)
> Research and signal inventory. Code-facing statements are updated with the
> implementation; dated vendor observations remain snapshots and may change
> independently of the package.
Who embeds what, and whether it is locally detectable (so we know which gaps are fillable). See `identify.py` for what we read.
- **Locally detectable (open decoder, no key/API):** Stable Diffusion / SDXL / FLUX via `imwatermark` DWT-DCT (now covered by `invisible_watermark.py`). FLUX uses the same library (upstream `black-forest-labs/flux2`, file `src/flux2/watermark.py`, 48-bit `0b001010101111111010000111100111001111010100101110`); SDXL is the diffusers `WATERMARK_MESSAGE` (`0b101100111110110010010000011110111011000110011110`). **Caveat: the `imwatermark` dwtDct decode is carrier-fragile on a broad class of real images, NOT just re-encode-fragile, and it is a POSITIVE-ONLY signal.** A clean encode->decode round-trip (no re-encode at all) recovers 48/48 bits on some carriers (random noise, chatgpt-1.png 48/48, firefly-1.png 45/48) but FAILS on many others — verified 2026-06-19 that a *known-embedded* watermark only round-trips 28-39/48 (below the safe `_MATCH_48` = 44 gate, random baseline ~24) on the FLUX fox sample (28), doubao-1.png (39), a 1024² minimalist-flat FLUX image (28), AND a **clean synthetic bright-flat fill with NO watermark at all (28)**. The failure does NOT track texture (firefly lapvar ~11 passes; the flat FLUX lapvar ~56 fails); it correlates with a degenerate decode where the raw bits read **all-ones (48/48 ones)** — which a clean synthetic image reproduces, so **all-ones is a CARRIER ARTIFACT, NOT a watermark signal** (a double-embed test also showed a pre-existing embed does not corrupt a second embed — no interference). Net: trust a `detect_invisible_watermark` hit, but treat a `None`/no-match as **inconclusive** whenever a positive-control embed on the same carrier does not first recover >=44/48. The 44 gate is a deliberate precision choice (lowering it would admit false positives).
**Root cause and external confirmation (deep-research 2026-06-19, adversarially verified).** This is the SCHEME's ceiling, not our usage — there is no better decoder to adopt. The imwatermark maintainers state verbatim (both the ShieldMnt and Stability-AI READMEs) that the algorithm "cannot guarantee to decode the original watermarks 100% accurately even though we don't apply any attack." Independent measurement (WMAdapter, arXiv:2406.08337 Table 2) puts dwtDct at only **~0.79 bit accuracy on CLEAN images (~38/48 bits — already below our 44 gate)**, collapsing to ~0.50 (chance) under crop/JPEG. Two code-verified + locally-reproduced mechanisms drive the content-dependent failures: (1) the decoder reads each bit as the **highest-magnitude DCT coefficient per block**, so any content coefficient exceeding the encoded target flips the bit; (2) the default embed is in the **YUV chroma channel, which 8-bit-clamps on white/bright pixels** (a +36 chroma delta survives a white-fill round-trip as only +4, ~89% loss) — this is the mechanism behind the bright-flat / minimalist failures and the all-ones degenerate decode. No maintained fork or detector decodes this scheme reliably: the WAVES benchmark (arXiv:2401.08573) relegates DWT-DCT to supplementary appendix G.5 and targets Stable Signature / Tree-Ring / StegaStamp instead; learned encoder/decoder schemes reach ~0.98-0.99 clean but are a DIFFERENT watermark class (not what SDXL/FLUX stamp). `dwtDctSvd` does not help (SDXL embeds `dwtDct`; dwtDctSvd cannot decode it, and its clean accuracy ~0.72 is lower). **Authoritative conclusion: the open DWT-DCT mark cannot be turned from positive-only into a reliable real-world detector; keep it positive-only and rely on C2PA.** (Refuted along the way: that the library is unmaintained, and that it is robust to JPEG but only fails on geometric attacks — both did not survive verification.)
Consequence for the FLUX hosted-output question (BFL Playground, FLUX.2 [pro] + FLUX.1 [dev], 2026-06-19): all samples carry the signed C2PA manifest (issuer "Black Forest Labs"); the open DWT-DCT decode returned `None`, but every available FLUX carrier (textured fox AND a minimalist-flat generation) failed the positive control (28/48), so the detector is blind on them and **whether BFL hosted output embeds the open pixel watermark is UNRESOLVED** (an earlier note here wrongly asserted it absent — overstated; a later note blamed "high texture" — also wrong, flat carriers fail too). What IS established: C2PA is the reliable FLUX identifier; the `_BITS_48` pattern is correct (round-trips on chatgpt/firefly/random). Resolving the hosted question needs a hosted FLUX carrier that first passes a >=44/48 positive control, which neither a textured nor a flat prompt produced — low priority (the open mark is only a stripped-metadata fallback).
- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Copilot + Designer; Bing Image Creator collected 2026-05-24 still signs as "Microsoft" and now runs **MAI-Image**, NOT OpenAI/DALL-E), **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`), and **Canva** (Magic Media signs C2PA as "Canva" + `trainedAlgorithmicMedia` with a generic `c2pa-rs` claim generator, no SynthID — issuer `b"Canva"` → "Canva (Magic Media)"; verified samples disproved the earlier assumption that Canva downloads always strip C2PA). Still unsampled: Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag).
**Samsung Galaxy AI** signs supported edits with C2PA and may carry the
proprietary `genAIType` marker. The registered visible detector covers the
Italian `✦ Contenuti generati dall'AI` bottom-left variant. Removal follows the
same localize-then-fill path as other registered text marks. Other locales and
icon-only variants need separate calibrated silhouettes.
**ASUS Gallery** also signs edited photos as C2PA (`com.asus.gallery`) but with no AI source type — a signer, not an AI marker.
**Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)").
Some applications sign C2PA through an upstream model or infrastructure
provider. For AI claims, exact product mappings in `claim_generator` therefore
take precedence over issuer attribution. Supported mappings include Higgsfield
AI, Topaz Labs Image API, and TikTok Ad Creative Toolbox; an unknown claim
generator still falls back to the certificate issuer.
**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the same content as **"Byteplus Pte. Ltd."**. The bare `volcengine` needle missed it, so BytePlus output was mis-attributed to "Adobe Firefly" through an incidental "Adobe XMP" toolkit string. Issuer `b"Byteplus"` now maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance (Dreamina)" with **`asserts_ai=True`**. Registering the broader **issuer** `b"Bytedance Pte"` was deliberately avoided because that same entity also signs non-AI CapCut edits; keying on the `Dreamina` generator token is precise.
- **EXIF/XMP/PNG-text generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal). Additional verified generator stamps include **NovelAI** (`Software`, `Source`, and `Title` PNG text chunks), **Reve** (`Software` or XMP `CreatorTool` = `reve.com`), and **Aphrodite AI** (`Make` or `Software` = `Aphrodite AI`).
- **App-export provenance and AIGC JSON:** supported ByteDance-family exports can place a JSON object in EXIF `ImageDescription` or `UserComment`, independently of C2PA or TC260. Exact `product` values for Doubao, Xinghui, and Dreamina are removable product provenance, but do not alone prove that the pixels were generated. Dreamina additionally requires `exportType=generation` for that verdict. A nested Aweme `aigc_type=1` or private ByteDance `aigc_label_type=1` / `2` is an AIGC disclosure; `0` is inconclusive and can occur on a Dreamina generation export. Plain Aweme, retouch, and `lv` exports are preserved. The lower-case private field is deliberately not interpreted as the normative TC260 `Label`, whose values `1` / `2` / `3` mean generated / possibly generated / suspected generated under [GB 45438-2025](https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf).
- **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).**
Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC** — only EXIF `Artist` = a UUID and EXIF `ImageDescription` = `Signature: <base64>` (a crypto signature, unverifiable locally without xAI's public key). This empirically kills the earlier unverified "xAI signs C2PA as xAI" lead — xAI is not even a C2PA member. `exif_generator` misses it (neither field holds an `AI_GENERATOR_TOKENS` token), so a dedicated detector `xai_signature(path)` matches the pair (`ImageDescription ~ ^Signature: [A-Za-z0-9+/=]{64,}` AND UUID `Artist`); wired into `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify` (signal `xai_signature`, platform "xAI (Grok / Aurora)").
**Format confirmed stable across n=3 genuine generations:** exactly three EXIF tags (`Artist`, `ExifOffset`, `ImageDescription`), `Signature:` prefix constant, base64 payload 300-1004 chars. Two capture facts: (a) the `Artist` UUID **equals the public image id** in the asset URL (`https://imagine-public.x.ai/imagine-public/images/<uuid>.jpg`), so it is NOT a private per-user secret — only the `Signature` blob is; (b) the Grok web-UI image is a re-encoded **WebP with no signature** — the EXIF survives only in the *original* JPEG (download button or that public tokenless URL), which is why screenshots / re-encodes are metadata-stripped. A real fixture `data/fixtures/provenance/grok-1.jpg` plus **synthetic** JPEG fixtures (fake UUID + fake `Signature:` blob) cover the detector; never add a real Grok image carrying private content (the repo is public).
**Stripped on removal too:** `remove_ai_metadata` calls `_scrub_ai_exif` on
JPEG EXIF, which deletes the xAI Signature and UUID Artist pair plus supported
AI generator values while retaining unrelated camera and editor EXIF. The
shared `xai_signature_pair` helper is the single source of truth for the
pair. On the ISOBMFF path, `blank_ai_exif_tokens` provides the corresponding
in-place scrub for supported EXIF values, TC260 AIGC blocks, and the xAI pair.
- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `<TC260:AIGC>{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). The label says only "this is AI", but its `ContentProducer` names the signing entity — `001` + `1` + an 18-char Unified Social Credit Code + a 5-digit product suffix, normalized by `metadata.uscc_of`, or for a few generators a bare product name. `KnownMark.tc260_producer_codes` maps the codes settled per vendor by `scripts/vendor_cohort_harvest.py` to registry mark keys, so an AIGC image relaxes the detector of the vendor it actually carries rather than a guessed pair; an unmapped or absent producer falls back to ByteDance's two products. A code identifies a legal entity, not necessarily one brand, so a hosting or aggregating platform that signs for several apps is a recall bet rather than a proof.
**Doubao** (ByteDance) uses it (verified on a public issue sample; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **four image serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `<TC260:AIGC>{...}</TC260:AIGC>` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on compatible samples) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on compatible samples, `ContentProducer="doubao"`), a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on compatible samples, `ContentProducer="001191440300708461136T1308L"`), **and** a bare `AIGC{...}` blob (the label glued straight to its JSON, no `"AIGC":` key wrapper) embedded in a **JPEG APP segment near the JFIF header** — confirmed on compatible samples. The two raw-JSON forms are scanned in one loop (`'"AIGC"'` then `AIGC{`) that **falls through on a non-TC260 / undecodable hit instead of returning** — a quoted `"AIGC"` can appear later in an XMP packet while the real label is a bare `AIGC{...}` earlier in the file, so an unconditional early return on the quoted form would shadow the bare form (the exact bug behind the 06-10 misses). Native MP4/MOV is a fifth serialization: TC260-PG-20257A stores an `AIGC` key in `moov.udta.meta.keys` and the raw JSON in the matching `ilst` item; Doubao's iOS export additionally writes two QuickTime-form variants (a bare `meta` box as a direct `moov` child, and a keyless `hdlr=mdir` metadata list whose `ilst` data items carry the JSON with no key name) — both verified on retained carriers (2026-08-17). The seeking parser reaches a tail `moov` without reading `mdat`; removal replaces the key with `free` and blanks the validated value at the same length so every box size and stream offset stays fixed. All generic forms are gated on at least one TC260 field (`TC260_AIGC_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. `TC260_AIGC_FIELDS` covers **two schemas**: the producer-side one (`Label` / `ContentProducer` / `ProduceID` / `ContentPropagator` / `PropagateID`, Doubao and most China gens) and the **service-provider** one (`ServiceProvider` / `ServiceUser`, plus generic `Time` / `ContentId` which are NOT gated on) — **Tencent Cloud's** AIGC variant (`ServiceProvider` = `腾讯云`), embedded in **EXIF `ImageDescription`**, verified on compatible samples. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated).
Native MKV/WebM is a sixth serialization. TC260-PG-20257A stores
`TagName=AIGC` and the raw JSON `TagString` in
`Segment.Tags.Tag.SimpleTag`. The bounded EBML reader skips cluster payloads;
the existing ffmpeg stream-copy path removes the tags without transcoding.
The same [TC260 video guide](https://www.tc260.org.cn/portal/article/303/4061772dcf684d8a96f395a4298e9e53)
defines two more native serializations. AVI stores an `AIGC` child in
`LIST/INFO`; FLV stores an AMF0 `AIGC` string under `script.onMetaData`. The
bounded RIFF and FLV readers validate the JSON field set and skip media
payloads. Removal remuxes either container through ffmpeg with stream copy.
- **HuggingFace-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** HuggingFace Jobs / Spaces can stamp generated PNGs with an `hf-job-id` tEXt chunk holding the job UUID. It marks the *hosting job*, not a model, so it lifts an Unknown verdict to a tentative AI via `hf_only` but never overrides a hard metadata signal. `_HF_JOB_CAVEAT` states the limit. Removal drops the chunk through the PNG metadata whitelist.
- **No detectable signal on some downloads:** Recraft exports and some hosted
FLUX surfaces can arrive without a supported local signal. Midjourney samples
may carry IPTC metadata but no registered C2PA or pixel watermark. The open
DWT-DCT decoder only applies when the producing pipeline actually ran its
encoder and the carrier remains decodable.
- **Invisible but NOT locally detectable (proprietary, API/oracle only — same wall as SynthID):** Amazon Titan Image Generator + Nova Canvas (Bedrock `DetectGeneratedContent` API), Kakao (new SynthID image adopter, May 2026), NVIDIA Cosmos (SynthID video). No local detector possible; treat like SynthID.
- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We now READ the soft-binding `alg` (`C2PA_SOFT_BINDINGS` / `soft_binding_vendors_in`) to name the forensic-watermark vendor, and locally DECODE the one open scheme, Adobe TrustMark (`trustmark_detector`); the rest (Digimarc/Imatag/Steg.AI/...) stay name-only (proprietary decoders).
- **Built in the dated batch:** soft-binding vendor detection, IPTC Photo
Metadata AI-disclosure fields, C2PA detection and stripping for supported
ISOBMFF video, the optional Adobe TrustMark decoder, and temporally stabilized
visible Sora, Veo, Seedance, Dola, Hailuo, and Kling removal. Other visible
video logos and proprietary audio-watermark detection remain outside the
package.
Metadata stripping for supported audio containers is a separate implemented
path.
**Box detection window — now handled (v0.6.8):** detection no longer relies on a fixed first-MB read. `metadata.scan_head(path, size)` reads the first `size` bytes and, for ISOBMFF, appends the payloads of late provenance boxes found by `isobmff.scan_c2pa_region` (a file-seeking top-level box walker that skips past `mdat` by size without reading it), so a C2PA/AIGC/IPTC manifest placed AFTER a large `mdat` in a streaming/non-faststart MP4 is now caught. Every C2PA/marker byte scan (`has_ai_metadata`, `aigc_label`, `iptc_ai_system`, `synthid_source`, `exif_generator` XMP, `get_ai_metadata` soft-binding, and `identify`) goes through `scan_head`; for PNG it likewise appends the payloads of `tEXt` / `iTXt` / `zTXt` / `eXIf` / `iCCP` chunks that start beyond the window (`_png_late_metadata`, seeking past `IDAT`), which is how a TC260 AIGC label appended after the pixel stream is caught; for WebP it appends the `EXIF` / `XMP ` / `ICCP` / `C2PA` chunks past the window (`_riff_late_metadata`, stepping over the coded image), which is how an IPTC "Made with AI" tag stored after the pixels is caught; and for a file at least `size` bytes long it finally appends the metadata text the decoder reaches but a raw read cannot (`_decoder_visible_text`), which covers a compressed PNG `zTXt` packet no byte scan can spell; for any file that fits inside `size`, it is exactly `f.read(size)`.
Native TC260 MP4/MOV tags do not live in those top-level provenance boxes.
`tc260_aigc_payloads` separately seeks through `moov.udta.meta.keys/ilst`, so
the normative tag is also found when a large `mdat` precedes `moov`.
**Meta-box XMP and EXIF removal are handled in place:** an AI-label XMP packet
stored as a meta-box `mime` item is blanked by
`isobmff.blank_ai_xmp_packets`. Supported EXIF items are handled by
`blank_ai_exif_tokens`. Both paths preserve box sizes and coded media offsets.
For current scope and legal context, see
[scope, safety, and legal notes](legal-and-safety.md). Re-verify legal facts
against primary sources before adding jurisdiction-specific claims.
## Visible AI-generation marks + detection methods (deep-research 2026-07-10, adversarially verified)
**Google Gemini visible "sparkle" -- tier-dependent, and spec-undocumented by Google.** Google primary sources (the Nano Banana Pro blog and the gemini.google image-generation page, both WebFetch-verified) confirm Gemini images carry BOTH the invisible SynthID (on ALL Google-AI media) AND a visible sparkle, but the visible mark is **tier-gated**: applied for FREE and Google AI **Pro** users, and **REMOVED** for Google AI **Ultra** subscribers, inside **Google AI Studio**, and on **API / dev** output. So a Google-C2PA image with NO visible sparkle is expected (Ultra / API), not evidence it is clean -- this reinforces the `identify` "no visible mark != clean" rule. The ONLY official verifier is the SynthID flow (upload to the Gemini app, ask if it is AI-generated), which reads the INVISIBLE mark; there is **no official visible-sparkle detector**, and Google publishes **no** glyph geometry / size / opacity / color / locale / placement spec. So our capture-based sparkle template is the only source of truth and cannot be validated against a vendor spec -- keep reverse-engineering from real captures (do not expect a published spec).
**Google Veo video marks use two incompatible visible designs.** Public raw
clips verify the current four-point diamond and the legacy bottom-right `Veo`
text. The independent
[VeoWatermarkRemover](https://github.com/allenk/VeoWatermarkRemover) project
reports the same current-versus-legacy split, multiple output layouts, and the
need for cross-frame position agreement. Our implementation copies no logo
pixels or alpha maps from that project: it uses two synthetic silhouettes,
known-layout searches plus a strong relocated-diamond fallback, and a separate
temporal arbiter calibrated against raw watermarked clips and clean API exports.
**ByteDance video surfaces use distinct visible labels.** Public Seedance
showcase clips contain a fixed rounded box with `AI`, while the Dola sample in
[issue #16](https://github.com/wiltodelta/remove-ai-watermarks/issues/16) uses
fixed `Dola AI` text. The independent
[Seedance remover](https://github.com/SamurAIGPT/seedance-2.0-watermark-remover)
estimates a static corner from a temporal mean frame and edge density. Our
implementation instead matches provider-specific synthetic silhouettes on
every frame, then requires an anchored temporal run. This extra anchor check
was necessary because a moving clean scene detail could retain enough adjacent
overlap to pass a recurrence-only gate.
**Hailuo and Kling use larger fixed composite labels.** Verified Hailuo exports
carry a lower-edge waveform, `MINIMAX`, separator, Hailuo ring, and
`hailuo AI` text. Verified Kling exports carry a bottom-right swirl,
`KLING AI`, a changing version suffix, and sometimes `PRO`. The detectors use
only synthetic primitives and fonts. Hailuo expands the matched core to cover
the complete composite. Kling combines a version-independent text core with a
synthetic ring rescue, then requires the recurring candidate to reach the
expected frame edge and contain enough bright low-saturation pixels. Those
extra gates were added after clean Luma and PixVerse scene details passed shape
and temporal recurrence alone. The generic
[WatermarkRemover-AI](https://github.com/D-Ogi/WatermarkRemover-AI) project
instead uses Florence-2 to identify arbitrary watermarks before LaMa
inpainting. That is broader, but it carries a much heavier model and a less
auditable detection boundary than the provider-specific synthetic path here.
**The faint-visible-mark precision/recall wall is fundamental, not a heuristic artifact.** The visible-watermark-detection literature has moved to LEARNED segmentation / object-detection (WDNet WACV'21 arXiv:2012.07616; SLBR ACM MM'21, open code+weights; the PRCV'18 large-scale detector; Su et al. survey 2025), but three verified findings bound what a learned detector actually buys: (1) a claim that a confidence threshold "cleanly separates" true from false matches even with a learned CNN front-end was **REFUTED** in verification (arXiv:1705.08593) -- the precision/recall wall persists even with learned features. (2) Learned detectors need a LARGE, pattern-diverse labeled dataset trained on synthetic composites (PRCV'18: 60k images / 80 watermark classes; CLWD: 60k / 160 marks), and off-distribution degradation is a documented real axis (models trained on limited-pattern LVW transfer worse; diversity of training patterns drives generalization). (3) Inference is cheap (WDNet ~8 ms at 256x256) -- the cost is the data pipeline, not runtime. Net: a learned detector shifts the frontier but does NOT remove the wall; for a SINGLE mark the cheapest next step is a small patch classifier (real-sparkle vs false-positive) on top of the existing NCC localizer, not a full segmentation model. SLBR is a ready baseline. The current NCC + false-positive gate (core-ring brightness margin + gradient-NCC crispness + white-core saturation) is a sound operating point, and the residual miss is the information-theoretic wall the literature confirms.
**Visible-mark landscape beyond the registry.** Meta stamps a visible "Imagined with AI" mark (bottom-LEFT, a small symbol) on its OWN Meta AI / "Imagine" output; for third-party images it relies on C2PA / IPTC, not a visible mark. Samsung Galaxy AI additionally uses a **four-star icon** variant in a corner alongside the localized text wordmark `samsung_engine` calibrates (only the Italian text variant is covered) -- the icon is a distinct, uncovered variant. Every source agrees visible + metadata marks are trivially removable (crop / screenshot, ~2 s), which is the tool's premise.
**Regulatory driver -- China GB 45438-2025 is the strongest VISIBLE-mark mandate.** The CAC / TC260 "Measures for Labeling AI-Generated Synthesized Content" (issued March 2025, **effective 2025-09-01**, technical standard **GB 45438-2025**, building on the TC260 Aug-2023 practice guide) MANDATE a **visible** label for AI images -- a visible textual mark whose height must be **>= 5% of the image's shortest side** -- plus the metadata (implicit) label. Several such CJK text marks are now registered; see [supported signals](supported-signals.md) for the current list. By contrast EU AI Act Article 50 mandates only the MACHINE-READABLE mark (enforceable 2026-08-02, grace to 2026-12-02); a visible label is proposed and modality-specific (visible for images) but is NOT a hard "fixed icon" mandate -- a claim that Art 50 requires a clearly-visible fixed icon for images was refuted in verification. Primary-source dates verified against the article/standard text, not search summaries.
## Uncovered visible marks: implementation specs (deep-research 2026-07-18)
Compatibility testing showed that TC260-labelled images can still produce no visible-mark
detection. The main causes were a fixed Doubao localization defect and genuinely
uncovered vendors. Verification status is labelled per claim; treat (b)/(c) as leads,
not ground truth.
**GB 45438-2025 clause 5.2, the binding constraint for every Chinese mark (VERIFIED (a) -- full standard text extracted from the TC260-hosted PDF).** Verbatim requirements for an image's explicit label:
- 应采用文字提示 (must be a TEXT prompt);
- must contain BOTH an AI element (`人工智能` or `AI`) AND a generation element (`生成` and/or `合成`);
- 应位于图片的边或角 -- **edge OR corner**, so bottom-right is NOT mandated (Annex C.2's bottom-right figure is a non-normative example);
- 字型应清晰可辨 (legible typeface -- no family named);
- **文字高度不应低于画面最短边长度的 5%** -- glyph HEIGHT >= 5% of the frame's SHORTEST side, with a note defining the shortest side for non-rectangular images.
Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a compliant CN mark's glyphs are large (>= 51 px on a 1024² image), so a CN silhouette ladder can be anchored at ~5-10% of the short side instead of swept broadly, which should cut false fires; (2) every compliant string shares the tail `AI生成` / `AI合成`, so a shared suffix silhouette plus a per-vendor prefix may beat five independent templates. NOT established: whether 文字高度 means cap height, em box, or rendered bounding box (a ~1.3x spread in template scale). Sources: `https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf`, parent CAC measure `https://www.cac.gov.cn/2025-03/14/c_1743654684782215.htm` (the CAC text itself specifies no size or corner).
**Alibaba Qwen -- two surfaces that differ (API tier VERIFIED (a)).** Model Studio docs state verbatim that the API adds a `Qwen-Image` watermark 在图像右下角 and 默认值为 false -- so API output is **unwatermarked by default**, and when enabled the mark is a LATIN wordmark, not CJK, and not GB-compliant in wording. The consumer app's `千问AI生成` (bottom-right) is (b) secondary only -- no Alibaba primary page states it. So Qwen needs TWO templates, and its absence is never evidence of a clean image. Source: `https://help.aliyun.com/zh/model-studio/qwen-image-api`.
**星绘 is ByteDance (VERIFIED (a): Baidu Baike + App Store listing, now branded 豆包旗下, team folded into Doubao April 2025).** So `星绘AI生成` is very likely the Doubao house style -- same typeface, same corner, possibly the same top-left `AI生成` pill. Starting from the Doubao `TextMarkConfig` and swapping the two lead glyphs is the cheap path. String/position themselves are (c) inferred.
**Baidu: RESOLVED 2026-07-22, registered (`baidu_engine.py`).** The mark is a white bold "百度" text run + a separate white rounded tag with dark "AI生成", bottom-right -- settled by the TC260 USCC cohort harvest (16 frames, USCC 91110000802100433B), not by web research. Detection keys on the text run only; details in `docs/module-internals.md`.
**Tencent Yuanbao: RESOLVED 2026-07-25, registered (`yuanbao_engine.py`).** The standard mark is a compact two-line italic `元宝` over `AI生成` block at bottom-right. It switches between light and dark strokes with the scene, so detection uses polarity-independent local contrast rather than a white top-hat. The separate one-line overlay variant remains evidence-limited to one example.
**Meta `Imagined with AI` (string VERIFIED (a) from Meta's own newsroom; POSITION NOT VERIFIED).** Sources conflict on placement. Do not encode a corner without a verified sample. `identify` reads the supported IPTC disclosure; it does not decode Meta's proprietary invisible watermark. Source: `https://about.fb.com/news/2024/02/labeling-ai-generated-images-on-facebook-instagram-and-threads/`.
**Samsung English/other locales: still not established.** Samsung's own support page says only that "A Galaxy AI watermark will appear on AI-generated images" -- no string, no corner. Every community thread carrying the exact English string returned HTTP 403 to WebFetch, so the search paraphrase (bottom-left) is deliberately NOT recorded as fact. Feature-tier detail (b): the mark is applied by Generative Edit / sketch-to-image but reportedly NOT by Object Eraser, so Samsung absence is feature-dependent. The four-star icon variant: nothing found.
**The one document that would settle ByteDance placement is BLOCKED.** Douyin's 《抖音关于人工智能生成内容标识的水印与元数据规范》 aims to give AI tools a unified watermark style and position, which would cover Doubao / Jimeng / 星绘 at once. Both mirrors return HTTP 403 to WebFetch; a secondary report (b, unconfirmed) says the watermark is `AI生成` + tool name + company name placed **top-left** -- which would explain the Jimeng pill's top-left position but contradicts the GB annex's bottom-right example. Worth one retry through Chrome MCP with a real browser session.
**No vendor publishes typeface, color, opacity, plate, or margin for ANY of these marks.** The only font-adjacent requirement anywhere is GB's "legible typeface". So each synthetic silhouette's font must be calibrated against corpus positives exactly as the Jimeng pill was; candidate CJK families by platform convention (inference): HarmonyOS Sans / Source Han Sans / Noto Sans CJK SC for Android-origin apps, PingFang SC for iOS-origin.
+21 -3
View File
@@ -3,9 +3,27 @@
set -euo pipefail
uv sync --all-extras
uv run uv-outdated
uv run uv-secure --ignore-unfixed
# uv-outdated / uv-secure run via uvx (isolated env), NOT `uv run`: resolving them
# inside the project env crashes and, with set -e, aborts the whole gate before
# ruff/pyright/tests (see CLAUDE.md "Test and lint").
uvx uv-outdated
# uv-secure prints its verdict but can then crash in an internal teardown with a
# NON-ZERO exit -- observed as "annotated-doc raised exception" and later "anyio raised
# exception"; both are bugs in uv-secure's OWN uvx env, not a project vulnerability. With
# set -e that teardown crash aborts the whole gate before ruff/pyright/tests. So gate on
# the VERDICT, not the exit code: capture the output, accept the run when uv-secure
# reported all-safe (even if it then crashed), but still FAIL on a real finding (no
# all-safe line) so a genuine CVE is never masked, and fail loud if it never got a
# verdict at all (so a broken run is never silently skipped).
secure_out="$(uvx uv-secure uv.lock 2>&1)" || true
printf '%s\n' "$secure_out"
if ! grep -qE "No vulnerabilities or maintenance issues detected|All dependencies appear safe" <<<"$secure_out"; then
echo "maintain.sh: uv-secure reported a finding or failed before its verdict -- triage before committing." >&2
exit 1
fi
uv run ruff check --fix
uv run ruff format
uv run pyright
# Scoped to src/: a full-project pyright run OOM-crashes node on this ML-heavy
# repo (see CLAUDE.md "Test and lint"); src/ is the authoritative strict gate.
uv run pyright src/
uv run pytest -n auto
+178 -19
View File
@@ -1,38 +1,180 @@
[project]
# This name is the published PyPI distribution, not a local label: changing it
# strands every `pip install remove-ai-watermarks` and every pinned dependency
# on it, including the ComfyUI node package. The console script below carries
# the same weight, since users have it on PATH.
name = "remove-ai-watermarks"
version = "0.3.5"
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
version = "0.29.0"
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
readme = "README.md"
requires-python = ">=3.10"
license = {text = "MIT"}
requires-python = ">=3.11,<3.15"
license = {text = "Apache-2.0"}
keywords = [
"ai-watermark",
"ai-watermark-remover",
"watermark-remover",
"watermark-removal",
"remove-watermark",
"synthid",
"c2pa",
"content-credentials",
"nano-banana",
"gemini",
"gemini-watermark",
"ai-metadata",
"metadata-removal",
"provenance",
"exif",
"stable-diffusion",
"comfyui",
"ai-detection",
]
classifiers = [
"Development Status :: 4 - Beta",
"Environment :: Console",
"Intended Audience :: Developers",
"Intended Audience :: End Users/Desktop",
"License :: OSI Approved :: Apache Software License",
"Operating System :: OS Independent",
"Programming Language :: Python :: 3 :: Only",
"Programming Language :: Python :: 3.11",
"Programming Language :: Python :: 3.12",
"Programming Language :: Python :: 3.13",
"Programming Language :: Python :: 3.14",
"Topic :: Multimedia :: Graphics",
"Topic :: Multimedia :: Graphics :: Graphics Conversion",
"Topic :: Scientific/Engineering :: Artificial Intelligence",
"Topic :: Scientific/Engineering :: Image Processing",
"Topic :: Security",
"Topic :: Utilities",
]
dependencies = [
"pillow>=10.0.0",
"piexif>=1.1.3",
"numpy>=1.24.0",
"opencv-python-headless>=4.8.0",
"click>=8.0.0",
"rich>=13.0.0",
"python-dotenv>=1.0.0",
# Official C2PA reader (Content Authenticity Initiative, MIT/Apache-2.0). The
# primary, spec-tracking manifest parser for the identify/metadata path; the
# hand-rolled caBX/CBOR scanner in _internal/c2pa.py is kept only as a fallback for
# synthetic/partial blobs the validator rejects. Binary wheel (Rust), but the
# import is light (no torch/numpy) so it fits the dependency-light identify
# host. Prebuilt wheels cover the full CI matrix (linux/macos/windows).
"c2pa-python>=0.35.0",
]
[project.optional-dependencies]
gpu = [
pixels = [
"numpy>=1.24.0; python_version < '3.13'",
# NumPy 1.x has no CPython 3.13+ wheels. Requiring 2.x prevents installers
# from attempting an unsupported source build on the newer Python lines.
"numpy>=2; python_version >= '3.13'",
"opencv-python-headless>=4.8.0",
]
# Optional HEIC/AVIF pixel decode. Metadata scanning handles these containers
# without this plugin; combine `heif` with any pixel feature only when needed.
heif = [
"pillow-heif>=0.13.0",
]
visible = ["remove-ai-watermarks[pixels]"]
# Video visible removal uses the shared pixel runtime. PyAV packetizes processed
# VFR frames with explicit PTS before system ffmpeg encodes them.
video = [
"remove-ai-watermarks[visible]",
"av>=18,<19",
]
# Open DWT-DCT watermarks used by Stable Diffusion / SDXL / FLUX. The in-tree
# decoder avoids the upstream invisible-watermark package's mandatory torch and
# non-headless OpenCV dependencies.
detect = [
"remove-ai-watermarks[pixels]",
"PyWavelets>=1.1.1",
]
diffusion = [
"remove-ai-watermarks[pixels]",
# A CUDA-enabled torch build is required: invisible-watermark removal has no
# CPU, MPS or XPU path. The default PyPI wheel carries CUDA on Linux/Windows;
# on macOS there is no CUDA build and this extra installs only for the
# non-diffusion imports it shares.
"torch>=2.0.0",
"diffusers>=0.25.0",
"transformers>=4.35.0",
"diffusers>=0.38.0",
# diffusers 0.38's auto-pipeline registry imports ``Qwen3VLForConditional
# Generation`` (its ``nucleusmoe_image`` pipeline), which only exists in
# transformers 5.x -- so ``from diffusers import AutoPipelineForImage2Image``
# fails on transformers 4.x. The real SDXL-loading break was NOT transformers
# 5.x but the tokenizers *release candidate* (0.23.0rc0) that the global
# ``prerelease = "allow"`` drags in: its CLIP tokenizer raises
# ``RobertaProcessing.__new__() got an unexpected keyword argument 'cls'``.
# Cap tokenizers to the stable 0.22 line (transformers 5.x accepts
# >=0.22,<=0.23.0) so the rc is excluded while SDXL still loads.
"transformers>=5,<6",
"tokenizers>=0.22,<0.23",
"accelerate>=0.25.0",
"controlnet-aux>=0.0.9",
"safetensors",
"ultralytics>=8.0.0",
"color-matcher>=0.5.0",
]
# Full two-stage high-fidelity profile: Qwen-Image-2512 Lightning + DiffSynth
# Canny ControlNet for the frame, then SAM-masked Z-Image Turbo face repair.
# CUDA-only and intentionally separate from the normal diffusion extra because the
# additional model stack and DiffSynth runtime are large.
qwen-zimage = [
"remove-ai-watermarks[diffusion]",
"diffsynth>=2.0.17,<3",
"torchvision>=0.20.0",
]
# Opt-in verified-text reconstruction over qwen-zimage. LaMa removes the changed
# candidate glyphs before exact Qwen-VAE cores are composited back.
text-restoration = [
"remove-ai-watermarks[qwen-zimage,lama]",
]
# Draft-only OCR proposals for verified-text manifests. CPU, no torch:
# PaddleOCR detection plus three script-chosen recognition engines. The models
# download on first use and are cached by huggingface_hub; never bundled.
text-draft = [
"paddleocr>=3.3.3",
"paddlepaddle>=3.0.0",
"huggingface-hub>=0.20.0",
]
# Adobe TrustMark decoder -- the open, keyless watermark behind Adobe Durable
# Content Credentials (soft-binding alg ``com.adobe.trustmark.P``). Optional
# because it pulls torch and downloads model weights on first use. identify()
# guards the import and skips the TrustMark signal when absent.
trustmark = [
# TrustMark requires NumPy <2, whose last release has no CPython 3.13+
# wheels. Keep this feature installable only on its real runtime range.
"trustmark>=0.8.0; python_version < '3.13'",
]
# Universal region eraser backend -- big-LaMa via onnxruntime (Carve/LaMa-ONNX,
# Apache-2.0). CPU, no torch. Model (~200 MB) is downloaded on first use and
# cached by huggingface_hub; it is never bundled in this repo. The default cv2
# eraser backend needs none of this.
lama = [
"remove-ai-watermarks[visible]",
"onnxruntime>=1.24.0",
"huggingface-hub>=0.20.0",
]
# Lightweight inpaint backend -- MI-GAN via onnxruntime (andraniksargsyan/migan,
# MIT). CPU, no torch. Model (~28 MB) downloaded on first use and cached by
# huggingface_hub; never bundled. ~700-950 MB peak RAM / ~0.19 s/call -- the
# memory-tight learned tier (vs big-LaMa's ~4.7 GB). Select it explicitly when
# LaMa, the quality-first `auto` choice, is too large. Same runtime as `lama`.
migan = [
"remove-ai-watermarks[visible]",
"onnxruntime>=1.24.0",
"huggingface-hub>=0.20.0",
]
dev = [
"remove-ai-watermarks[video]",
"remove-ai-watermarks[detect]",
"pytest>=8.0.0",
"pytest-cov>=4.1.0",
"pytest-xdist>=3.5.0",
"packaging>=24.0",
"ruff>=0.4.0",
"pyright>=1.1.0",
"invisible-watermark>=0.2.0",
]
all = ["remove-ai-watermarks[gpu,dev]"]
# ``qwen-zimage`` already pulls ``diffusion``; naming both would suggest diffusion is
# independently sufficient for a removal, which it is not.
all = ["remove-ai-watermarks[video,heif,detect,trustmark,text-restoration,migan]"]
[project.scripts]
remove-ai-watermarks = "remove_ai_watermarks.cli:main"
@@ -47,15 +189,32 @@ build-backend = "hatchling.build"
[tool.hatch.build.targets.wheel]
packages = ["src/remove_ai_watermarks"]
[tool.hatch.build.targets.sdist]
# Keep the source distribution small and public-safe: ship tracked source and
# metadata, not corpora or local research/session artifacts. The wheel ships
# only src/.
include = [
"/src",
"/LICENSE",
"/README.md",
"/pyproject.toml",
]
exclude = [
"/data",
"/tmp",
"/.sc",
]
[tool.pytest.ini_options]
testpaths = ["tests"]
pythonpath = ["src"]
addopts = "-v --tb=short"
[tool.ruff]
target-version = "py310"
target-version = "py311"
line-length = 120
exclude = ["_refs"]
extend-exclude = ["*.md"]
[tool.ruff.lint]
select = ["E", "F", "B", "I", "S", "UP", "SIM", "RET", "COM", "C4", "G", "PT", "PIE", "T20", "DTZ", "ICN", "TCH", "RUF", "ANN"]
@@ -65,17 +224,17 @@ ignore = [
]
[tool.ruff.lint.per-file-ignores]
"scripts/*.py" = ["G004", "S108", "S310", "T20"]
"tests/*.py" = ["ANN", "S101", "S105", "S106", "S108"]
"src/remove_ai_watermarks/noai/watermark_remover.py" = ["S603", "S606", "S607", "T201"] # subprocess calls for auto-install/CUDA fix
"src/remove_ai_watermarks/noai/c2pa.py" = ["S110"] # try-except-pass for corrupt file handling
"src/remove_ai_watermarks/noai/ctrlregen/engine.py" = ["S101", "S603"] # assert for loaded state, subprocess for auto-install
"src/remove_ai_watermarks/_internal/watermark_remover.py" = ["S603", "S606", "S607"] # nvidia-smi capability probe
"src/remove_ai_watermarks/_internal/c2pa.py" = ["S110"] # try-except-pass for corrupt file handling
[tool.ruff.format]
quote-style = "double"
indent-style = "space"
[tool.pyright]
pythonVersion = "3.10"
pythonVersion = "3.11"
typeCheckingMode = "strict"
exclude = ["_refs"]
+61
View File
@@ -0,0 +1,61 @@
"""Minimal plain-text stand-ins for the rich Console/Table API.
rich was dropped as a project dependency (see the CLI plain-text refactor), but
the analysis scripts still printed through it. These shims keep the scripts
runnable without rich: ``[bold]``/``[/]``-style markup is stripped and tables
render as aligned plain text. Output goes through ``click.echo`` to match the
package CLI (no bare ``print`` in tooling).
"""
from __future__ import annotations
import re
from typing import Any
import click
# Matches rich style tags: the bare close ``[/]`` and named open/close tags such
# as ``[yellow]``, ``[bold yellow]``, ``[/green]``. Anchored to lowercase-letter
# starts so numeric/data brackets (``[1024]``, ``[file.png]``) are left intact.
_MARKUP = re.compile(r"\[(?:/|/?[a-z][a-z ]*)\]")
def _strip(obj: Any) -> str:
return _MARKUP.sub("", str(obj))
class Table:
"""Drop-in for ``rich.table.Table`` covering add_column/add_row + render."""
def __init__(self, *args: Any, title: str | None = None, **kwargs: Any) -> None:
self.title = title
self._headers: list[str] = []
self._rows: list[list[str]] = []
def add_column(self, header: str = "", *args: Any, **kwargs: Any) -> None:
self._headers.append(_strip(header))
def add_row(self, *cells: Any) -> None:
self._rows.append([_strip(c) for c in cells])
def render(self) -> str:
all_rows = ([self._headers] if self._headers else []) + self._rows
cols = max((len(r) for r in all_rows), default=0)
widths = [0] * cols
for row in all_rows:
for i, cell in enumerate(row):
widths[i] = max(widths[i], len(cell))
lines: list[str] = []
if self.title:
lines.append(_strip(self.title))
if self._headers:
lines.append(" ".join(h.ljust(widths[i]) for i, h in enumerate(self._headers)))
lines.extend(" ".join(c.ljust(widths[i]) for i, c in enumerate(row)) for row in self._rows)
return "\n".join(lines)
class Console:
"""Drop-in for ``rich.console.Console`` covering ``print`` (with Table)."""
def print(self, *objects: Any, **kwargs: Any) -> None:
click.echo(" ".join(o.render() if isinstance(o, Table) else _strip(o) for o in objects))
+34
View File
@@ -0,0 +1,34 @@
"""Pure text-normalization helpers shared by evaluation scripts."""
from __future__ import annotations
import unicodedata
def normalize_text(text: str) -> str:
"""Normalize text for layout-independent evaluation comparisons."""
return "".join(unicodedata.normalize("NFC", text).casefold().split())
def levenshtein_normalized(left: str, right: str) -> float:
"""Return normalized Levenshtein distance without changing either input."""
if not left and not right:
return 0.0
previous = list(range(len(right) + 1))
for left_index, left_character in enumerate(left, start=1):
current = [left_index]
for right_index, right_character in enumerate(right, start=1):
current.append(
min(
current[-1] + 1,
previous[right_index] + 1,
previous[right_index - 1] + (left_character != right_character),
)
)
previous = current
return previous[-1] / max(len(left), len(right))
def normalized_edit_distance(left: str, right: str) -> float:
"""Normalize text, then return Levenshtein distance over the result."""
return levenshtein_normalized(normalize_text(left), normalize_text(right))
Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.1 KiB

+272
View File
@@ -0,0 +1,272 @@
"""Can one GENERIC template cover the CJK AI labels no per-vendor detector fires on?
THE OPPORTUNITY
Corpus inspection of doubao-provenance misses turned up `千问AI生成` (Alibaba Qwen) and
`百度 AI生成` (Baidu) sitting in the same bottom-right corner as the marks we do cover,
bold and plainly legible, with no detector able to fire on either. `docs/...landscape`
puts uncovered vendors at ~6% of sampled images -- larger than any tuning gain left in
the covered ones (a dense scale ladder recovers 1 miss in 26; see ladder_headroom.py).
WHY A SHARED TEMPLATE IS EVEN PLAUSIBLE
China's GB 45438-2025 mandates the label, and every compliant vendor ends it with the
same run: `AI生成`. The vendor PREFIX varies (豆包 / 千问 / 百度 / 星绘) and is what makes
per-vendor silhouettes expensive; the TAIL is guaranteed. So the tail is the part worth
detecting, and vendor attribution -- which the shared tail cannot provide anyway
(measured 千问-vs-doubao AUC ~0.5) -- moves to metadata, where it is reliable.
WHY IT IS WORTH RETRYING NOW
`render_vendor_silhouettes.py` records 千问 as ruled out, and its reasoning was sound:
the then-current front-end binarized the glyph, and a thin translucent overlay shatters
into specks that no template can match. It closes by naming what would be needed --
"a detection front-end that does not depend on binarizing the glyph (grayscale/edge
correlation on the raw top-hat)". That front-end now EXISTS: `detect_frontend="tophat"`
was built for doubao and correlates a soft template against the continuous response.
The blocker was removed by unrelated work, so the ruling deserves re-measurement rather
than inheritance.
THE MEASUREMENT
Three arms, one scorer (the tail template on the tophat response, swept over a dense
size ladder because an unregistered vendor's glyph size is genuinely unknown -- the one
place a dense ladder earns its cost):
covered a registered mark already fires -- a sanity arm; the tail is inside those
marks too, so a tail template that cannot score THESE is simply broken
uncovered TC260 provenance but no detector fires -- the target population
clean no metadata signal at all -- the precision arm
The decision rests on whether `uncovered` carries a high-scoring subpopulation that
`clean` does not. It does NOT rest on the raw rate: TC260 provenance does not imply a
visible mark, so a modest fire rate on that arm is expected even if the detector is
perfect. Any threshold this suggests must then be confirmed by eyeballing the crops it
selects -- the script writes a contact sheet for exactly that.
DATA SAFETY
Treat input datasets as sensitive and read-only, and keep output gitignored. The
template is font-rendered synthetic, never cut from an input image.
uv run python scripts/cjk_tail_probe.py --n 6000
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import random
import sys
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Any
import cv2
import numpy as np
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent))
REPO = Path(__file__).resolve().parents[1]
CORPUS = REPO / ".local-eval" / "originals"
OUT = REPO / ".local-eval" / "cjk-tail-probe.jsonl"
# Cached under the gitignored data dir, not in scripts/: this is a probe artifact, not a
# product asset. If the tail mark is ever registered, `render_vendor_silhouettes.py` is
# what writes the committed silhouette into src/.../assets/.
TAIL_PNG = REPO / ".local-eval" / "cjk-tail-silhouette.png"
# The tail is a fraction of a full vendor mark's width (`豆包AI生成` is ~5 CJK widths,
# `AI生成` ~3), and the prefix length differs per vendor, so the size is genuinely
# unknown here -- unlike a registered mark, whose fraction is calibrated. Hence a wide,
# dense ladder: this is the case a dense ladder is actually for.
TAIL_SCALES = tuple(round(0.30 * (1.08**i), 4) for i in range(18)) # ~0.30 .. ~1.15
def build_tail_silhouette() -> np.ndarray:
"""Font-rendered `AI生成` silhouette, cached next to this script (data-safe)."""
if TAIL_PNG.exists():
img = cv2.imread(str(TAIL_PNG), cv2.IMREAD_GRAYSCALE)
if img is not None:
return img
from render_vendor_silhouettes import render
sil = render("AI生成", width=200)
cv2.imwrite(str(TAIL_PNG), sil)
return sil
def tail_score(engine: Any, image: np.ndarray, sil: np.ndarray) -> tuple[float, float]:
"""Best tail correlation over the size ladder; returns (score, winning_scale)."""
loc = engine.locate(image)
resp = engine.tophat_response(image, loc)
if resp is None:
return (0.0, 0.0)
c = engine.config
base = engine.scale_base(image)
ar = sil.shape[0] / max(1, sil.shape[1])
best, best_s = 0.0, 0.0
for s in TAIL_SCALES:
gw = max(12, int(c.alpha_width_frac * base * s))
gh = max(6, int(gw * ar))
if gw >= resp.shape[1] or gh >= resp.shape[0]:
continue
t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_AREA)
v = float(cv2.matchTemplate(resp, t, cv2.TM_CCOEFF_NORMED).max())
if v > best:
best, best_s = v, s
return (best, best_s)
def _one(path_str: str) -> dict[str, Any] | None:
from remove_ai_watermarks.api import visible_provenance
from remove_ai_watermarks.doubao_engine import DoubaoEngine
from remove_ai_watermarks.identify import identify
from remove_ai_watermarks.image_io import imread
from remove_ai_watermarks.watermark_registry import detect_marks
path = Path(path_str)
try:
prov = visible_provenance(path)
except Exception:
return None
tc260 = bool({"doubao", "jimeng"} & prov)
if not tc260:
try:
if identify(path, check_visible=False).signals:
return None # some other provenance: neither target nor clean control
except Exception:
return None
img = imread(path_str)
if img is None or min(img.shape[:2]) < 200:
return None
try:
fired = [d.key for d in detect_marks(img) if d.detected]
except Exception:
return None
if fired:
arm = "covered"
elif tc260:
arm = "uncovered"
else:
arm = "clean"
score, scale = tail_score(DoubaoEngine(), img, build_tail_silhouette())
return {
"src": path.name,
"arm": arm,
"fired": fired,
"tail_score": round(score, 4),
"tail_scale": scale,
}
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--n", type=int, default=6000)
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--report-only", action="store_true")
ap.add_argument(
"--sheet-at", type=float, default=0.0, help="write a contact sheet of `uncovered` crops scoring >= this"
)
a = ap.parse_args()
if a.report_only:
rows = [json.loads(x) for x in a.out.read_text(encoding="utf-8").splitlines() if x.strip()]
report(rows)
if a.sheet_at:
contact_sheet(rows, a.sheet_at)
return
build_tail_silhouette()
pool = glob.glob(str(CORPUS / "*" / "*"))
random.Random(19).shuffle(pool) # noqa: S311 -- deterministic sampling, not crypto
pool = pool[: a.n]
print(f"scanning {len(pool)} corpus files workers={a.workers}")
print(f"tail ladder {TAIL_SCALES[0]} .. {TAIL_SCALES[-1]} ({len(TAIL_SCALES)} rungs)\n", flush=True)
rows: list[dict[str, Any]] = []
a.out.parent.mkdir(parents=True, exist_ok=True)
with open(a.out, "w", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=a.workers) as ex:
futures = [ex.submit(_one, p) for p in pool]
for i, fut in enumerate(as_completed(futures), 1):
try:
rec = fut.result()
except Exception: # noqa: S112 -- one bad file must not kill the sweep
continue
if rec is None:
continue
fh.write(json.dumps(rec) + "\n")
rows.append(rec)
if i % 500 == 0:
fh.flush()
print(f" {i}/{len(pool)} usable={len(rows)}", flush=True)
report(rows)
if a.sheet_at:
contact_sheet(rows, a.sheet_at)
def report(rows: list[dict[str, Any]]) -> None:
arms = {k: [r for r in rows if r["arm"] == k] for k in ("covered", "uncovered", "clean")}
print(f"\n{'=' * 80}\nGENERIC CJK TAIL PROBE ({', '.join(f'{k}={len(v)}' for k, v in arms.items())})\n{'=' * 80}")
print("\nScore distribution of the shared `AI生成` tail on the tophat response\n")
print(f"{'arm':11s} {'n':>6s} {'p50':>7s} {'p75':>7s} {'p90':>7s} {'p95':>7s} {'p99':>7s} {'max':>7s}")
for k, v in arms.items():
if not v:
continue
s = np.array([r["tail_score"] for r in v])
qs = [np.percentile(s, q) for q in (50, 75, 90, 95, 99)]
print(f"{k:11s} {len(v):6d} " + " ".join(f"{q:7.3f}" for q in qs) + f" {s.max():7.3f}")
if arms["clean"] and arms["uncovered"]:
clean = np.array([r["tail_score"] for r in arms["clean"]])
unc = np.array([r["tail_score"] for r in arms["uncovered"]])
print("\n\nOPERATING POINTS -- threshold set on the CLEAN arm, yield read on `uncovered`")
print("`clean` fires are the cost; `uncovered` fires are candidate recoveries that")
print("still need eyeballing, since TC260 provenance does not imply a visible mark.\n")
print(f"{'threshold':>10s} {'clean fire':>12s} {'uncovered fire':>16s} {'candidates':>12s}")
for q in (95, 97.5, 99, 99.5, 99.9):
t = float(np.percentile(clean, q))
cf = float((clean >= t).mean())
uf = float((unc >= t).mean())
print(f"{t:10.3f} {100 * cf:11.2f}% {100 * uf:15.1f}% {int((unc >= t).sum()):12d}")
cov = arms["covered"]
if cov:
s = np.array([r["tail_score"] for r in cov])
print(f"\n\nSANITY: on frames where a registered mark fires, the tail scores median {np.median(s):.3f}.")
print("A tail template that cannot score these is broken, whatever it does elsewhere.")
def contact_sheet(rows: list[dict[str, Any]], thresh: float, limit: int = 30) -> None:
"""Crop the corner of the top-scoring `uncovered` frames so the fires can be judged."""
from remove_ai_watermarks.doubao_engine import DoubaoEngine
from remove_ai_watermarks.image_io import imread
eng = DoubaoEngine()
picks = sorted(
(r for r in rows if r["arm"] == "uncovered" and r["tail_score"] >= thresh),
key=lambda r: -r["tail_score"],
)[:limit]
tiles = []
for r in picks:
hits = glob.glob(str(CORPUS / "*" / r["src"]))
if not hits:
continue
img = imread(hits[0])
if img is None:
continue
loc = eng.locate(img)
crop = img[loc.y : loc.y + loc.h, loc.x : loc.x + loc.w]
if crop.size:
tiles.append(cv2.resize(crop, (320, 96), interpolation=cv2.INTER_AREA))
if tiles:
dest = REPO / ".local-eval" / "cjk-tail-sheet.png"
cv2.imwrite(str(dest), np.vstack(tiles))
print(f"\ncontact sheet ({len(tiles)} crops, score >= {thresh:.3f}) -> {dest}")
print("scores: " + ", ".join(f"{r['tail_score']:.2f}" for r in picks[: len(tiles)]))
if __name__ == "__main__":
main()
+348
View File
@@ -0,0 +1,348 @@
"""ControlNet-as-removal-pipeline prototype sweep (issue #35 / Jacob).
Research prototype, NOT a shipped pipeline. It tests whether a full-image
SDXL-native ControlNet-conditioned img2img can REPLACE plain SDXL img2img as the
watermark remover: a single structure-guided regeneration that scrubs an invisible
robust watermark (SynthID) everywhere while keeping fine detail and small/CJK text
legible. See docs/controlnet-removal-pipeline-research.md for the full rationale.
The make-or-break tension (from the watermark-removal-attack literature): the
denoise strength high enough to scrub the watermark deforms text, while the
conditioning strong enough to keep text may spare the watermark. There is no local
SynthID detector, so this script CANNOT decide removal on its own -- it produces
one output per (control, strength, conditioning-scale) cell plus an index, and YOU
verify each output by hand in the Gemini app ("Verify with SynthID") and judge text
legibility visually. Fill the verdict columns in the emitted index, then read off
the Pareto cell (oracle clean AND text legible).
Pipeline: stabilityai/stable-diffusion-xl-base-1.0 +
- canny: xinsir/controlnet-canny-sdxl-1.0 (control = cv2.Canny(gray, 100, 200))
- tile: xinsir/controlnet-tile-sdxl-1.0 (control = the resized original, no preproc)
StableDiffusionXLControlNetImg2ImgPipeline (image=init, control_image=control).
Needs the gpu extra (torch + diffusers) and cv2. Runs locally on 32 GB MPS in
fp32 (MPS fp16 decodes to all-black NaN -- issue #29 -- so fp32 is the default on
mps/cpu, fp16 only on cuda/xpu); a dedicated GPU is not required for 1024 px. Run:
uv run python scripts/controlnet_sweep.py path/to/watermarked.png -o sweep_out
uv run python scripts/controlnet_sweep.py img.png --control canny tile \\
--strength 0.3 0.5 0.7 1.0 --scale 0.6 1.0 --size 1024
"""
from __future__ import annotations
# torch/diffusers/cv2 ship no usable types; relax the unknown-type + private-import
# rules for this boundary script (mirrors scripts/visible_alpha_solve.py and the
# cv2/torch engine modules). Pure-logic helpers here stay correct regardless.
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportPrivateImportUsage=false
import argparse
import contextlib
import csv
import importlib.util
import logging
import sys
from pathlib import Path
from typing import TYPE_CHECKING, Any
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
from _plain_console import Console, Table
if TYPE_CHECKING:
from PIL import Image
logging.basicConfig(level=logging.INFO, format="%(message)s")
log = logging.getLogger(__name__)
console = Console()
BASE_MODEL = "stabilityai/stable-diffusion-xl-base-1.0"
FP16_VAE = "madebyollin/sdxl-vae-fp16-fix"
CONTROLNETS = {
"canny": "xinsir/controlnet-canny-sdxl-1.0",
"tile": "xinsir/controlnet-tile-sdxl-1.0",
}
# A neutral quality prompt: the goal is faithful regeneration, not creative edits.
PROMPT = "best quality, high quality, sharp, detailed, photographic"
NEGATIVE_PROMPT = "blurry, lowres, deformed, distorted text, garbled text, watermark, jpeg artifacts"
def pick_device(requested: str) -> str:
"""Resolve the inference device without the CUDA-reinstaller side effect.
Deliberately does NOT call the package ``get_device`` (which can trigger a
torch-CUDA reinstall+restart). A research script should never do that.
"""
import torch
if requested != "auto":
return requested
if torch.cuda.is_available():
return "cuda"
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
return "mps"
return "cpu"
def resolve_dtype(device: str, requested: str) -> Any:
"""fp16 only on cuda/xpu; fp32 on cpu AND mps, unless overridden.
MPS fp16 produces all-black NaN output here (the SDXL UNet/VAE overflows on
the Metal backend -- issue #29; even the fp16-fix VAE does not save it), so the
production pipeline runs fp32 on MPS and so do we. fp32 SDXL + an SDXL ControlNet
at 1024 fits 32 GB unified memory with vae-tiling + attention-slicing.
"""
import torch
if requested == "fp16":
return torch.float16
if requested == "fp32":
return torch.float32
return torch.float16 if device in {"cuda", "xpu"} else torch.float32
def fit_size(image: Image.Image, long_side: int) -> Image.Image:
"""Resize so the long side is ``long_side``, each dim a multiple of 8 (SDXL)."""
from PIL import Image as PILImage
w, h = image.size
scale = long_side / max(w, h)
nw = max(8, round(w * scale) // 8 * 8)
nh = max(8, round(h * scale) // 8 * 8)
if (nw, nh) == (w, h):
return image
return image.resize((nw, nh), PILImage.Resampling.LANCZOS)
def make_control_image(init: Image.Image, control: str) -> Image.Image:
"""Build the ControlNet conditioning image for the given control type.
canny: cv2.Canny(gray, 100, 200) -> 3-channel edge map (xinsir canny recipe).
tile: the init image itself, no preprocessing (xinsir tile recipe).
"""
import cv2
import numpy as np
from PIL import Image as PILImage
if control == "tile":
return init
rgb = np.array(init.convert("RGB"))
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
edges = cv2.Canny(gray, 100, 200)
edges_rgb = np.stack([edges, edges, edges], axis=-1)
return PILImage.fromarray(edges_rgb)
def psnr(a: Image.Image, b: Image.Image) -> float:
"""Coarse global fidelity proxy vs the original (NOT a text or watermark metric)."""
import numpy as np
x = np.asarray(a.convert("RGB"), dtype=np.float64)
y = np.asarray(b.convert("RGB").resize(a.size), dtype=np.float64)
mse = float(np.mean((x - y) ** 2))
if mse == 0.0:
return 99.0
return float(10.0 * np.log10((255.0**2) / mse))
def load_pipeline(control: str, device: str, dtype: Any) -> Any:
"""Load SDXL base + the chosen SDXL ControlNet as an img2img pipeline."""
import torch
from diffusers import (
AutoencoderKL,
ControlNetModel,
StableDiffusionXLControlNetImg2ImgPipeline,
)
console.print(f"Loading {CONTROLNETS[control]} ({control}) ...")
controlnet = ControlNetModel.from_pretrained(CONTROLNETS[control], torch_dtype=dtype)
load_kwargs: dict[str, Any] = {"controlnet": controlnet, "torch_dtype": dtype}
if dtype == torch.float16:
# The stock SDXL VAE decodes to NaN/black in fp16; the fp16-fix VAE is the
# same swap the production pipeline uses (_SDXL_FP16_VAE_ID).
load_kwargs["vae"] = AutoencoderKL.from_pretrained(FP16_VAE, torch_dtype=dtype)
pipe = StableDiffusionXLControlNetImg2ImgPipeline.from_pretrained(BASE_MODEL, **load_kwargs)
pipe = pipe.to(device)
pipe.set_progress_bar_config(disable=True)
if device != "cpu":
# Keep the 1024 px + extra-ControlNet peak inside 32 GB unified memory.
with contextlib.suppress(Exception):
pipe.enable_vae_tiling()
with contextlib.suppress(Exception):
pipe.enable_attention_slicing()
return pipe
def run_cell(
pipe: Any,
init: Image.Image,
control_image: Image.Image,
strength: float,
scale: float,
steps: int,
guidance: float,
seed: int,
) -> Image.Image:
"""Run one ControlNet img2img cell and return the regenerated image.
The generator is created on CPU intentionally: a CPU generator is portable
across the mps/cuda/cpu backends (diffusers rejects a device-mismatched one),
matching the production runner's fallback behavior.
"""
import torch
generator = torch.Generator(device="cpu").manual_seed(seed)
result = pipe(
prompt=PROMPT,
negative_prompt=NEGATIVE_PROMPT,
image=init,
control_image=control_image,
controlnet_conditioning_scale=float(scale),
strength=float(strength),
num_inference_steps=steps,
guidance_scale=guidance,
generator=generator,
)
return result.images[0]
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description="ControlNet-as-removal-pipeline prototype sweep.")
p.add_argument("image", type=Path, help="Watermarked input image.")
p.add_argument("-o", "--out", type=Path, default=Path("controlnet_sweep_out"), help="Output directory.")
p.add_argument("--control", nargs="+", choices=list(CONTROLNETS), default=["canny", "tile"])
p.add_argument("--strength", nargs="+", type=float, default=[0.3, 0.5, 0.7, 1.0])
p.add_argument("--scale", nargs="+", type=float, default=[0.6, 1.0], help="controlnet_conditioning_scale values.")
p.add_argument("--size", type=int, default=1024, help="Long-side resolution (multiple of 8).")
p.add_argument("--steps", type=int, default=30)
p.add_argument("--guidance", type=float, default=7.5)
p.add_argument("--seed", type=int, default=0)
p.add_argument("--device", default="auto", choices=["auto", "mps", "cuda", "cpu"])
p.add_argument("--dtype", default="auto", choices=["auto", "fp16", "fp32"])
return p.parse_args()
def main() -> int:
args = parse_args()
if not args.image.exists():
log.error("Input image not found: %s", args.image)
return 1
try:
from PIL import Image as PILImage
except ImportError:
log.error("Pillow is required. Install the gpu extra: uv sync --extra gpu --extra dev")
return 1
if importlib.util.find_spec("diffusers") is None or importlib.util.find_spec("torch") is None:
log.error("diffusers/torch are required. Install: uv sync --extra gpu --extra dev")
return 1
device = pick_device(args.device)
dtype = resolve_dtype(device, args.dtype)
console.print(f"Device: {device} | dtype: {str(dtype).split('.')[-1]}")
init_full = PILImage.open(args.image).convert("RGB")
init = fit_size(init_full, args.size)
console.print(f"Input: {args.image.name} {init_full.size[0]}x{init_full.size[1]} -> {init.size[0]}x{init.size[1]}")
args.out.mkdir(parents=True, exist_ok=True)
stem = args.image.stem
init_path = args.out / f"{stem}__INPUT.png"
init.save(init_path)
rows: list[dict[str, Any]] = []
table = Table(title="ControlNet sweep")
for col in ("control", "strength", "scale", "psnr_vs_input", "file"):
table.add_column(col)
# Group by control so SDXL + the ControlNet load once per control type.
for control in args.control:
pipe = load_pipeline(control, device, dtype)
control_image = make_control_image(init, control)
if control == "canny":
control_image.save(args.out / f"{stem}__canny_edges.png")
for strength in args.strength:
for scale in args.scale:
tag = f"{control}_s{strength:g}_c{scale:g}"
console.print(f"Running {tag} ...")
try:
out = run_cell(
pipe,
init,
control_image,
strength,
scale,
args.steps,
args.guidance,
args.seed,
)
except Exception as exc:
log.warning("Cell %s failed: %s", tag, exc)
continue
fname = f"{stem}__{tag}.png"
out.save(args.out / fname)
quality = psnr(init, out)
rows.append(
{
"control": control,
"strength": strength,
"scale": scale,
"psnr_vs_input": round(quality, 2),
"file": fname,
"synthid_oracle": "", # fill: clean / present
"text_legible": "", # fill: yes / no / partial
}
)
table.add_row(control, f"{strength:g}", f"{scale:g}", f"{quality:.2f}", fname)
del pipe
_free_memory(device)
_write_index(args.out, stem, rows, init_path.name)
console.print(table)
console.print(f"\nWrote {len(rows)} cells to {args.out}/")
console.print(f"Next: open {args.out}/sweep_index.csv, run each PNG through the Gemini SynthID oracle,")
console.print("fill synthid_oracle (clean/present) + text_legible (yes/no/partial), find the Pareto cell.")
return 0
def _free_memory(device: str) -> None:
import gc
gc.collect()
with contextlib.suppress(Exception):
import torch
if device == "cuda":
torch.cuda.empty_cache()
elif device == "mps" and hasattr(torch, "mps"):
torch.mps.empty_cache()
def _write_index(out: Path, stem: str, rows: list[dict[str, Any]], input_name: str) -> None:
"""Write the CSV index (with empty verdict columns) and a README."""
fields = ["control", "strength", "scale", "psnr_vs_input", "file", "synthid_oracle", "text_legible"]
with (out / "sweep_index.csv").open("w", newline="", encoding="utf-8") as fh:
writer = csv.DictWriter(fh, fieldnames=fields)
writer.writeheader()
writer.writerows(rows)
readme = (
f"# ControlNet sweep for {stem}\n\n"
f"Input (resized): {input_name}\n\n"
"Each row in sweep_index.csv is one (control, strength, scale) cell. PSNR vs the\n"
"resized input is a COARSE global-fidelity proxy only -- it does NOT measure text\n"
"legibility or watermark presence. Decide those two by hand:\n\n"
"1. synthid_oracle: open the PNG in the Gemini app, 'Verify with SynthID'. Mark\n"
" 'clean' if no SynthID is detected, 'present' if it still is. (No local detector\n"
" exists; this manual check is the only valid SynthID oracle.)\n"
"2. text_legible: eyeball the small/CJK text. Mark yes / partial / no.\n\n"
"The Pareto cell is the one where synthid_oracle=clean AND text_legible=yes at the\n"
"lowest strength. If no cell satisfies both, the canny/tile-ControlNet middle path\n"
"is dead for text and a glyph re-render is required (see\n"
"docs/text-protection-research.md). Record the outcome in\n"
"docs/controlnet-removal-pipeline-research.md.\n"
)
(out / "sweep_README.md").write_text(readme, encoding="utf-8")
if __name__ == "__main__":
raise SystemExit(main())
+214
View File
@@ -0,0 +1,214 @@
"""Audit a local image corpus against the library's own ``identify`` detector.
Two jobs in one pass:
1. **Report** -- run ``identify`` over every image and write one CSV row per file
(verdict, platform, confidence, watermarks, signals, integrity clashes).
2. **Gap audit** -- for every ``unknown``-verdict file, scan only its *metadata
region* (PNG text/eXIf chunks, JPEG APPn segments before SOS, or the file
head for other containers) for known provenance markers. A marker found there
on a file the detector calls ``unknown`` is a concrete lib gap: a serialization
or generator we do not yet parse. Scanning the metadata region -- not the whole
file -- is deliberate: short tokens collide randomly inside compressed PNG
``IDAT`` / JPEG scan data, which produced false "xAI/Flux/AIGC" hits when the
first audit naively scanned the first megabyte.
This is how new detector gaps get found (it is what surfaced the JPEG-EXIF
``{"AIGC":{...}}`` form). Re-run after collecting a fresh evaluation batch.
Usage:
uv run python scripts/corpus_gap_scan.py --corpus .local-eval/originals
uv run python scripts/corpus_gap_scan.py --corpus .local-eval/originals \\
--report .local-eval/detector-report.csv
"""
from __future__ import annotations
import csv
import logging
from collections import Counter
from pathlib import Path
import click
from _plain_console import Console, Table
from remove_ai_watermarks.identify import identify
from remove_ai_watermarks.metadata import _png_late_metadata
log = logging.getLogger(__name__)
console = Console()
# Distinctive, multi-byte provenance markers worth flagging when they appear in a
# file the detector calls `unknown`. Kept long enough that a random collision in a
# (non-scanned) compressed stream is implausible; the metadata-region restriction
# below is the primary guard, this list is the second. Group: C2PA/JUMBF infra,
# AI source-type / labeling schemes, and distinctive generator name strings.
MARKERS: tuple[bytes, ...] = (
# C2PA / JUMBF infrastructure and AI source-type / labeling schemes.
b"c2pa",
b"jumbf",
b"contentauth",
b"trainedAlgorithmicMedia",
b"digitalSourceType",
b'"AIGC"',
b"<TC260:AIGC>",
b"TC260:AIGC",
b"tc260.org.cn",
b"AISystemUsed",
b"SynthID",
b"hf-job-id",
b"genAIType",
b"PhotoEditor_Re_Edit",
b"Signature:",
# Distinctive multi-word generator strings only. Bare single words (Luma,
# Gemini, Sora, ...) are omitted: they collide with unrelated metadata prose
# (e.g. "Luma" in Lightroom's EnhanceDenoiseLumaAmount), defeating precision.
b"Midjourney",
b"Stable Diffusion",
b"StableDiffusion",
b"ComfyUI",
b"Automatic1111",
b"DALL-E",
b"Ideogram AI",
b"Adobe Firefly",
b"Black Forest",
b"volcengine",
b"Doubao",
b"\xe8\xb1\x86\xe5\x8c\x85",
b"Nano Banana",
b"Stability AI",
b"Samsung Galaxy",
)
def _metadata_region(path: Path) -> bytes:
"""Return only the bytes where provenance metadata can live, never the
compressed pixel stream (which produces random short-token collisions)."""
try:
head = path.read_bytes()
except OSError:
return b""
if head[:8] == b"\x89PNG\r\n\x1a\n":
# All ancillary metadata chunks (window=0), via the library's own walker.
return _png_late_metadata(path, 0)
if head[:2] == b"\xff\xd8": # JPEG: APPn segments up to Start-Of-Scan
out = bytearray()
p = 2
n = len(head)
while p + 4 <= n and head[p] == 0xFF:
marker = head[p + 1]
if marker == 0xDA: # SOS -> compressed scan data follows
break
seg_len = (head[p + 2] << 8) | head[p + 3]
out += head[p + 4 : p + 2 + seg_len]
p += 2 + seg_len
return bytes(out)
return head[:65536] # webp/avif/heif/jxl: metadata sits near the head
def _row(rep) -> dict[str, str]: # noqa: ANN001 (ProvenanceReport)
return {
"path": "", # filled by caller (relative)
"is_ai": str(rep.is_ai_generated),
"platform": rep.platform or "",
"confidence": rep.confidence,
"watermarks": "|".join(rep.watermarks),
"signals": "|".join(s.name for s in rep.signals),
"integrity_clashes": "|".join(rep.integrity_clashes),
}
@click.command()
@click.option(
"--corpus",
type=click.Path(exists=True, file_okay=False, path_type=Path),
default=Path(".local-eval/originals"),
show_default=True,
help="Directory of images to scan (recursively).",
)
@click.option(
"--report",
type=click.Path(path_type=Path),
default=None,
help="Write the per-file CSV here (default: <corpus>/../detector_report.csv).",
)
@click.option("--limit", type=int, default=0, help="Scan at most N files (0 = all).")
def main(corpus: Path, report: Path | None, limit: int) -> None:
logging.basicConfig(level=logging.WARNING, format="%(message)s")
report = report or corpus.parent / "detector_report.csv"
files = sorted(p for p in corpus.rglob("*") if p.is_file())
if limit:
files = files[:limit]
console.print(f"Scanning [bold]{len(files)}[/bold] files under {corpus} ...")
verdicts: Counter[str] = Counter()
platforms: Counter[str] = Counter()
gap_tokens: Counter[str] = Counter()
gaps: list[tuple[str, list[str]]] = []
rows: list[dict[str, str]] = []
errors = 0
with click.progressbar(files, label="identify") as bar:
for p in bar:
rel = str(p.relative_to(corpus))
try:
rep = identify(p)
except Exception as exc:
log.warning("identify failed on %s: %s", rel, exc)
errors += 1
continue
row = _row(rep)
row["path"] = rel
rows.append(row)
if rep.is_ai_generated:
verdicts["ai"] += 1
platforms[rep.platform or "?"] += 1
continue
verdicts["unknown"] += 1
# A gap candidate is a file identify is *blind* to (no signal at all)
# yet whose metadata carries a known marker. A file that produced a
# signal but no AI verdict (e.g. an ASUS Gallery C2PA signer, which we
# attribute but do not call AI) is handled correctly -- not a gap.
if rep.signals:
continue
region = _metadata_region(p)
hits = sorted({m.decode("latin-1", "replace") for m in MARKERS if m in region})
if hits:
gaps.append((rel, hits))
gap_tokens.update(hits)
with report.open("w", newline="") as f:
writer = csv.DictWriter(
f,
fieldnames=["path", "is_ai", "platform", "confidence", "watermarks", "signals", "integrity_clashes"],
)
writer.writeheader()
writer.writerows(rows)
console.print(f"\nWrote [bold]{len(rows)}[/bold] rows -> {report}")
console.print(f"\n[bold]Verdicts:[/bold] AI {verdicts['ai']} | unknown {verdicts['unknown']} | errors {errors}")
plat = Table(title="AI platforms", show_header=False)
for name, n in platforms.most_common():
plat.add_row(str(n), name)
console.print(plat)
if gaps:
console.print(
f"\n[bold red]Gap candidates[/bold red]: {len(gaps)} unknown files carry a known "
f"marker in their metadata region (potential undetected serialization/generator):"
)
tok = Table(title="markers seen in unknown files")
tok.add_column("count", justify="right")
tok.add_column("marker")
for name, n in gap_tokens.most_common():
tok.add_row(str(n), name)
console.print(tok)
for rel, hits in gaps:
console.print(f" {rel} -> {', '.join(hits)}")
else:
console.print("\n[green]No gap candidates: every unknown file is metadata-free.[/green]")
if __name__ == "__main__":
main()
+241
View File
@@ -0,0 +1,241 @@
"""Per-method wall time for metadata extraction and the verdict built on it.
WHY THIS EXISTS
The detection path is documented by CAPABILITY -- which signals it reads, in what
order, with what confidence -- but not by COST. Batch throughput and the question
"which probe dominates, and does it depend on container or on file size" have never
been measured.
WHAT IT MEASURES
Only the file-backed half: metadata extraction, then the verdict evaluated on the
extracted evidence. The pixel-backed detectors are deliberately out of scope.
COLD pass: one measurement on a file the process has never touched --
``extract_provenance_evidence``. Only the first read of a file is genuinely cold,
so it buys exactly one number, and that is the one a single-shot run pays.
WARM pass, with the filesystem cache now hot:
1. ``extract_provenance_evidence`` as a whole.
2. Its nine components, timed IN THE ORDER the dataclass constructs them and with
the per-file caches cleared once beforehand -- so ``extract_c2pa_info`` carries
the Rust manifest reader and the later ``get_ai_metadata`` sees the same warm
cache it sees in production. Timing them in any other order moves that cost to
a different row and flatters whichever ran second.
3. ``identify_from_evidence``: pure verdict logic, the source is never reopened.
4. ``identify(check_visible=False, check_invisible=False)`` -- extraction plus
verdict as one call, the cross-check that 1 + 3 is the whole metadata path.
Every ``@lru_cache`` in the metadata and C2PA modules is cleared before each timed
unit. Without that the second measurement of a file answers from the memo and
reports a cost of zero -- the caches are keyed on (path, mtime, size) and this
script reads each file several times.
READING IT
Component times do NOT sum to the ``extract_provenance_evidence`` total for free:
they come from a separate cache-cleared run, so the sum is a cross-check. A gap
means a component is missing from the list. Both numbers are written.
DATA SAFETY
Read-only over a local dataset. Writes only to the given output prefix, which
belongs outside the repository.
uv run python scripts/detection_timing.py <dataset> <prefix> --limit 200
"""
from __future__ import annotations
import argparse
import json
import logging
import sys
import time
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
# The package's OWN tree, not the repository root: from a worktree, an editable
# install resolves `remove_ai_watermarks` to the MAIN checkout, so a script measuring
# this tree would silently import a different one.
sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "src"))
from remove_ai_watermarks import identify as identify_mod
from remove_ai_watermarks import metadata as metadata_mod
from remove_ai_watermarks._internal import c2pa as c2pa_mod
log = logging.getLogger(__name__)
SUPPORTED = frozenset({".png", ".jpg", ".jpeg", ".webp", ".heic", ".heif", ".avif"})
# Timed in the order ``ProvenanceEvidence`` is constructed in ``identify.py``. The
# report script imports this to label its columns, so the order is the contract.
COMPONENTS: tuple[tuple[str, Callable[[Path], Any]], ...] = (
("c2pa_info", c2pa_mod.extract_c2pa_info),
("ai_metadata", metadata_mod.get_ai_metadata),
("scan_head", lambda p: metadata_mod.scan_head(p, identify_mod._SCAN_BYTES)),
("iptc_ai_system", metadata_mod.iptc_ai_system),
("aigc_label", metadata_mod.aigc_label),
("exif_generator", metadata_mod.exif_generator),
("xai_signature", metadata_mod.xai_signature),
("huggingface_job", metadata_mod.huggingface_job),
("samsung_genai", metadata_mod.samsung_genai),
)
def _cache_clearers() -> tuple[Callable[[], None], ...]:
"""Every per-file memo in the metadata path, found by attribute, not by name list.
A hand-written list silently goes stale the next time a probe gains a cache, and a
stale entry shows up as a suspiciously fast row rather than as an error.
"""
found: list[Callable[[], None]] = []
for module in (metadata_mod, c2pa_mod):
for name in dir(module):
clear = getattr(getattr(module, name, None), "cache_clear", None)
if callable(clear):
found.append(clear)
return tuple(found)
_CLEARERS = _cache_clearers()
def _clear() -> None:
for clear in _CLEARERS:
clear()
def _ms(fn: Callable[[], Any]) -> tuple[float, Any]:
"""Wall time in milliseconds plus the call's result."""
start = time.perf_counter_ns()
value = fn()
return (time.perf_counter_ns() - start) / 1e6, value
def _pixel_geometry(path: Path) -> tuple[str | None, int | None, int | None]:
"""Container format and pixel dimensions from the header alone."""
try:
from PIL import Image
with Image.open(path) as img:
return img.format, img.width, img.height
except Exception: # unreadable or an unsupported container
return None, None, None
def _warm_breakdown(path: Path, row: dict[str, Any]) -> None:
"""Fill ``row`` with the warm-cache per-method breakdown."""
_clear()
row["extract_evidence_ms"], evidence = _ms(lambda: identify_mod.extract_provenance_evidence(path))
_clear()
component_sum = 0.0
for name, fn in COMPONENTS:
elapsed, _ = _ms(lambda fn=fn: fn(path))
row[f"meta_{name}_ms"] = elapsed
component_sum += elapsed
row["meta_components_sum_ms"] = component_sum
row["verdict_from_evidence_ms"], report = _ms(lambda: identify_mod.identify_from_evidence(evidence))
_clear()
row["identify_metadata_only_ms"], full = _ms(
lambda: identify_mod.identify(path, check_visible=False, check_invisible=False)
)
row["scan_bytes"] = len(evidence.scan)
row["has_c2pa"] = bool(evidence.c2pa_info)
row["has_ai_metadata"] = bool(evidence.ai_metadata)
row["is_ai_generated"] = full.is_ai_generated
row["confidence"] = full.confidence
row["platform"] = full.platform
row["signals"] = [signal.name for signal in full.signals]
# The two verdict paths must agree; a mismatch means the breakdown timed a
# different code path than the end-to-end call and the rows are not comparable.
row["verdict_agrees"] = (report.confidence, report.platform) == (full.confidence, full.platform)
def _measure(path: Path) -> dict[str, Any]:
row: dict[str, Any] = {"path": str(path), "ext": path.suffix.lower()}
try:
row["bytes"] = path.stat().st_size
except OSError as exc:
return {**row, "error": f"stat: {exc}"}
# COLD first: this is the only moment the file is untouched by this process.
_clear()
try:
row["cold_extract_evidence_ms"], _ = _ms(lambda: identify_mod.extract_provenance_evidence(path))
except Exception as exc:
return {**row, "error": f"cold extract: {type(exc).__name__}: {exc}"}
row["format"], row["width"], row["height"] = _pixel_geometry(path)
width, height = row["width"], row["height"]
row["megapixels"] = round(width * height / 1e6, 3) if width and height else None
try:
_warm_breakdown(path, row)
except Exception as exc:
row["error"] = f"warm pass: {type(exc).__name__}: {exc}"
return row
def _iter_images(root: Path) -> Iterator[Path]:
for path in sorted(root.rglob("*")):
if path.is_file() and path.suffix.lower() in SUPPORTED:
yield path
def _done_paths(out_path: Path) -> set[str]:
"""Paths already recorded, so a long run resumes instead of restarting."""
if not out_path.exists():
return set()
done: set[str] = set()
with out_path.open(encoding="utf-8") as handle:
for line in handle:
try:
done.add(json.loads(line)["path"])
except (ValueError, KeyError):
continue
return done
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("dataset", type=Path, help="directory of images, scanned recursively")
parser.add_argument("out_prefix", type=Path, help="output prefix; writes <prefix>.jsonl")
parser.add_argument("--limit", type=int, default=0, help="stop after N files (0 = all)")
parser.add_argument("--progress-every", type=int, default=200)
args = parser.parse_args()
logging.basicConfig(level=logging.INFO, format="%(asctime)s %(message)s")
out_path = args.out_prefix.with_suffix(".jsonl")
out_path.parent.mkdir(parents=True, exist_ok=True)
done = _done_paths(out_path)
if done:
log.info("resuming: %d files already recorded", len(done))
processed = 0
started = time.monotonic()
with out_path.open("a", encoding="utf-8") as handle:
for path in _iter_images(args.dataset):
if str(path) in done:
continue
row = _measure(path)
handle.write(json.dumps(row, ensure_ascii=False, default=str) + "\n")
handle.flush()
processed += 1
if processed % args.progress_every == 0:
log.info("%d files, %.2f files/s", processed, processed / (time.monotonic() - started))
if args.limit and processed >= args.limit:
break
elapsed = time.monotonic() - started
rate = processed / max(elapsed, 1e-9)
log.info("done: %d files in %.1f s (%.2f files/s) -> %s", processed, elapsed, rate, out_path)
return 0
if __name__ == "__main__":
raise SystemExit(main())
+232
View File
@@ -0,0 +1,232 @@
"""Aggregate ``detection_timing.py`` records into per-method, per-segment tables.
WHAT IT PRODUCES
Reading it back: the per-file JSONL is one row per image with a millisecond field
per method. This collapses it into percentiles per method, then repeats that per
segment -- container, megapixels, file size, C2PA presence, verdict confidence --
because a single median hides a path whose cost is carried entirely by one
container or by the files that actually have a manifest.
Writes ``<prefix>_summary.csv`` (long form: segment_kind, segment, method, n, p50,
p90, p99, mean) and prints a markdown report.
Percentiles are computed by nearest-rank on the sorted sample, not interpolated:
every reported number is a time some real file actually took.
DATA SAFETY
Reads and writes only the given prefix, which belongs outside the repository.
uv run python scripts/detection_timing_report.py .local-eval/timing/full
"""
from __future__ import annotations
import argparse
import csv
import json
import math
import statistics
import sys
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable, Iterator
sys.path.insert(0, str(Path(__file__).resolve().parent))
from detection_timing import COMPONENTS as _TIMED
# Taken from the script that WROTE the rows, in its order, so a probe added, removed
# or reordered there cannot silently leave a column missing or mislabelled here.
COMPONENTS = tuple(name for name, _ in _TIMED)
METHODS = (
("cold_extract_evidence_ms", "extract_provenance_evidence (cold)"),
("extract_evidence_ms", "extract_provenance_evidence (warm)"),
*((f"meta_{name}_ms", f" {name}") for name in COMPONENTS),
("meta_components_sum_ms", " (sum of components)"),
("verdict_from_evidence_ms", "identify_from_evidence (verdict)"),
("identify_metadata_only_ms", "identify(metadata only), end to end"),
)
# Median-only columns in the per-segment tables; the full percentile set goes to the CSV.
HEADLINE_LABELS = (
("extract_evidence_ms", "extract p50"),
("verdict_from_evidence_ms", "verdict p50"),
("identify_metadata_only_ms", "end-to-end p50"),
)
HEADLINE = tuple(key for key, _ in HEADLINE_LABELS)
def _bucket(value: float | None, edges: tuple[float, ...], labels: tuple[str, ...]) -> str:
if value is None:
return "unknown"
for edge, label in zip(edges, labels, strict=False):
if value < edge:
return label
return labels[-1]
SEGMENTS: tuple[tuple[str, Callable[[dict[str, Any]], str]], ...] = (
("container", lambda r: str(r.get("format") or "unknown")),
(
"megapixels",
lambda r: _bucket(r.get("megapixels"), (1, 4, 12), ("<1 MP", "1-4 MP", "4-12 MP", ">12 MP")),
),
(
"file size",
lambda r: _bucket(
(r["bytes"] / 1e6) if r.get("bytes") is not None else None,
(1, 5, 20),
("<1 MB", "1-5 MB", "5-20 MB", ">20 MB"),
),
),
("c2pa", lambda r: "with C2PA" if r.get("has_c2pa") else "no C2PA"),
("verdict", lambda r: f"confidence={r.get('confidence') or 'n/a'}"),
)
def _numeric(records: list[dict[str, Any]], key: str) -> list[float]:
"""The numeric values of ``key``, skipping rows where it is absent or non-numeric.
One definition of "counts as a measurement", so the printed table and the CSV can
never disagree about which rows a method was measured on.
"""
return [float(r[key]) for r in records if isinstance(r.get(key), (int, float))]
def _percentile(sorted_values: list[float], q: float) -> float:
"""Nearest-rank percentile: the reported value is one a real file produced."""
index = max(0, math.ceil(q * len(sorted_values)) - 1)
return sorted_values[index]
def _stats(values: list[float]) -> dict[str, float | int]:
ordered = sorted(values)
return {
"n": len(ordered),
"p50": _percentile(ordered, 0.50),
"p90": _percentile(ordered, 0.90),
"p99": _percentile(ordered, 0.99),
"mean": statistics.fmean(ordered),
}
def _rows(path: Path) -> Iterator[dict[str, Any]]:
with path.open(encoding="utf-8") as handle:
for line in handle:
try:
yield json.loads(line)
except ValueError: # a truncated tail while the run is still writing
continue
def _table(rows: list[dict[str, Any]], methods: tuple[tuple[str, str], ...]) -> list[str]:
out = ["| method | n | p50 | p90 | p99 | mean |", "|---|---:|---:|---:|---:|---:|"]
for key, label in methods:
values = _numeric(rows, key)
if not values:
continue
s = _stats(values)
out.append(f"| {label} | {s['n']} | {s['p50']:.3f} | {s['p90']:.3f} | {s['p99']:.3f} | {s['mean']:.3f} |")
return out
def _correlation(rows: list[dict[str, Any]], x_key: str, y_key: str) -> float | None:
both = [r for r in rows if isinstance(r.get(x_key), (int, float)) and isinstance(r.get(y_key), (int, float))]
if len(both) < 3:
return None
xs = _numeric(both, x_key)
ys = _numeric(both, y_key)
try:
return statistics.correlation(xs, ys)
except statistics.StatisticsError: # a constant column has no correlation
return None
def main() -> int:
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument("prefix", type=Path, help="prefix given to detection_timing.py")
args = parser.parse_args()
jsonl = args.prefix.with_suffix(".jsonl")
all_rows = list(_rows(jsonl))
failed = [r for r in all_rows if "error" in r]
rows = [r for r in all_rows if "error" not in r]
if not rows:
print(f"no usable records in {jsonl}")
return 1
disagreed = [r for r in rows if r.get("verdict_agrees") is False]
lines = [
f"# Detection timing over {len(rows)} images",
"",
f"Source: `{jsonl}`. Failed rows: {len(failed)}. Verdict-path mismatches: {len(disagreed)}.",
"",
"All times in milliseconds. Percentiles are nearest-rank.",
"",
"## Whole corpus",
"",
*_table(rows, METHODS),
"",
]
csv_rows: list[dict[str, Any]] = []
for key, label in METHODS:
values = _numeric(rows, key)
if values:
csv_rows.append({"segment_kind": "all", "segment": "all", "method": label.strip(), **_stats(values)})
for kind, classify in SEGMENTS:
groups: dict[str, list[dict[str, Any]]] = defaultdict(list)
for row in rows:
groups[classify(row)].append(row)
lines += [f"## By {kind}", ""]
header = ["| segment | n | " + " | ".join(label for _, label in HEADLINE_LABELS) + " |"]
header.append("|---|---:|" + "---:|" * len(HEADLINE))
lines += header
for segment, group in sorted(groups.items(), key=lambda item: -len(item[1])):
cells = []
for key in HEADLINE:
values = _numeric(group, key)
cells.append(f"{_percentile(sorted(values), 0.5):.2f}" if values else "-")
lines.append(f"| {segment} | {len(group)} | " + " | ".join(cells) + " |")
for key, label in METHODS:
values = _numeric(group, key)
if values:
csv_rows.append(
{"segment_kind": kind, "segment": segment, "method": label.strip(), **_stats(values)}
)
lines.append("")
corr = _correlation(rows, "scan_bytes", "verdict_from_evidence_ms")
corr_size = _correlation(rows, "bytes", "extract_evidence_ms")
lines += [
"## Scaling",
"",
f"- verdict time vs scan-buffer size: r = {corr:.3f}" if corr is not None else "- verdict correlation: n/a",
(
f"- extraction time vs file size: r = {corr_size:.3f}"
if corr_size is not None
else "- extraction correlation: n/a"
),
"",
]
summary_csv = args.prefix.with_name(args.prefix.name + "_summary.csv")
columns = ["segment_kind", "segment", "method", "n", "p50", "p90", "p99", "mean"]
with summary_csv.open("w", encoding="utf-8", newline="") as handle:
writer = csv.DictWriter(handle, fieldnames=columns)
writer.writeheader()
writer.writerows(csv_rows)
report = "\n".join(lines)
args.prefix.with_name(args.prefix.name + "_report.md").write_text(report + "\n", encoding="utf-8")
print(report)
print(f"\nwrote {summary_csv}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+319
View File
@@ -0,0 +1,319 @@
"""Tier B2: detector response curves -- recall vs mark size, contrast, background and aspect.
WHY THIS EXISTS
Every recall number the project quotes rests on found positives: doubao 89% on n=240,
jimeng 71% on n=14, the pill 50% on n=6. Those tell you how the detectors do on the
marks a corpus happened to contain, and nothing about WHERE they fall over. Two bugs
have now shipped in exactly that blind spot:
* `scale_basis` -- doubao detected 0 of 435 LANDSCAPE marks, a 100% miss, because
every fraction was calibrated on portrait captures where width == short side.
* the front-end mismatch -- detection moved to the continuous `tophat` response while
the removal mask still came from the BINARIZED blob, so ~8% of doubao detections
produced an empty mask and removal was a silent no-op.
Both are geometry/plumbing failures at the edge of the operating range, and both were
invisible to a corpus sweep because the corpus is concentrated in the middle of it.
This harness constructs the edges instead of waiting for them to turn up.
THE AGGREGATE HERE IS NOT RECALL
The grid deliberately visits sizes and opacities the engines were never calibrated for,
so a mark counted as missed at size=0.6 is not evidence of a weak detector -- it is the
point of the sweep. Quote the NOMINAL cell (size=1.0, alpha=1.0) as the reference, and
read the per-axis tables for shape. Production recall comes from an unbiased corpus
sample (`scripts/visible_recall_sample.py`), never from here.
WHAT IT MEASURES -- TWO NUMBERS, NOT ONE
`detected` the detector fires.
`maskable` the same call path then yields a NON-EMPTY removal mask.
The second is the one that has no natural home anywhere else. A mark that is detected
but not maskable is reported by `identify` and silently skipped by `visible` -- the user
sees a mark the tool says it removed. Anything that measures only detection scores that
bug as a success. They are reported side by side and their GAP is the headline.
THE CONSTRUCTION
Take a verified-clean corpus image, stamp a real mark onto it with the forward model the
reverse-alpha work established (`stamped = (1-a)*bg + a*white`), and sweep two knobs the
engine's own geometry normally pins:
size multiplies the glyph box the engine would use (a re-rendered or rescaled mark)
alpha multiplies the captured opacity (bold stamp -> faint translucent overlay)
Background texture and frame aspect are not swept -- they come from the corpus and are
recorded, so the grid is crossed with real backgrounds rather than synthetic flats.
WHY EVERY SOURCE ALSO RUNS UNSTAMPED
A recall curve with no false-fire baseline is uninterpretable in the same way a fill
PSNR with no damage baseline is: "detected on 70% of faint marks" is a triumph or a
scandal depending on whether the detector also fires on 70% of the clean frames those
marks were stamped onto. The control runs the identical call path on the identical
image with no stamp.
READING `mask_hit`
Fraction of the stamped glyph box the removal mask covers -- real localization accuracy
for the text marks and gemini, because the mask has to be placed from a detection.
For `jimeng_pill` it is CONSTRUCTED and means nothing: the pill's footprint is a fixed
top-left geometry box, so it covers the stamp by definition. Reported, and flagged.
DATA SAFETY
Treat input datasets as sensitive and read-only. Keep generated reports under
.local-eval/. Reports record source filenames and measurements, never image content.
uv run python scripts/detector_response.py --n 12 # trial, measures throughput
uv run python scripts/detector_response.py --n 150 # the real run, resumable
"""
from __future__ import annotations
import argparse
import collections
import json
import math
import os
import sys
from concurrent.futures import ProcessPoolExecutor, as_completed
from pathlib import Path
from typing import Any
import numpy as np
sys.path.insert(0, str(Path(__file__).parent.parent))
sys.path.insert(0, str(Path(__file__).parent))
from fill_quality import SLOT_STAMPABLE, STAMPABLE, clean_sources, stamp_any, texture_of
REPO = Path(__file__).resolve().parents[1]
OUT = REPO / ".local-eval" / "detector-response.jsonl"
# 1.0 = the geometry/opacity the engine's own constants assume. The sweep reaches below
# it (a mark rendered smaller, or a faint translucent overlay -- the class the tophat
# front-end was introduced for) and above it (a larger re-render, or an upscaled upload).
SIZES = (0.6, 0.8, 1.0, 1.3)
ALPHAS = (0.3, 0.5, 0.75, 1.0)
MARKS = (*STAMPABLE, *SLOT_STAMPABLE)
def aspect_of(image: np.ndarray) -> str:
h, w = image.shape[:2]
r = w / max(1, h)
return "portrait" if r < 0.9 else ("landscape" if r > 1.1 else "square")
def _probe(image: np.ndarray, mark_key: str, box: tuple[int, int, int, int] | None) -> dict[str, Any]:
"""Run the product detect -> localize path once and score it.
``box`` is the stamped glyph box, or None for the unstamped control.
"""
from remove_ai_watermarks.watermark_registry import get_mark
mark = get_mark(mark_key)
loc = mark.localize(image, force=False)
mask_px = 0 if loc.mask is None else int(np.count_nonzero(loc.mask))
hit = None
if box is not None and loc.mask is not None:
x, y, w, h = box
inside = loc.mask[y : y + h, x : x + w]
hit = round(float(np.count_nonzero(inside)) / max(1, w * h), 3)
return {
"detected": bool(loc.detected),
"confidence": round(float(loc.confidence), 4),
"maskable": mask_px > 0,
"mask_px": mask_px,
"mask_hit": hit,
}
def _one_source(path_str: str) -> list[dict[str, Any]]:
"""Every (mark, size, alpha) cell for one clean background, plus its controls."""
from remove_ai_watermarks.image_io import imread
base = imread(path_str)
if base is None:
return []
name = Path(path_str).name
asp = aspect_of(base)
h, w = base.shape[:2]
rows: list[dict[str, Any]] = []
for key in MARKS:
common = {"src": name, "mark": key, "aspect": asp, "px": w * h}
# Control first: the same call path on the same frame with nothing stamped.
rows.append({**common, "cell": "control", "size": None, "alpha": None, **_probe(base, key, None)})
for size in SIZES:
for alpha in ALPHAS:
st = stamp_any(base, key, size_mult=size, alpha_mult=alpha)
if st is None:
continue
stamped, box = st
# Texture of the region the mark sits on, from the CLEAN frame -- the
# same median-Sobel proxy the pill's flatness gate uses.
x, y, gw, gh = box
rows.append(
{
**common,
"cell": "stamped",
"size": size,
"alpha": alpha,
"texture": round(texture_of(base[y : y + gh, x : x + gw]), 2),
**_probe(stamped, key, box),
}
)
return rows
def wilson(k: int, n: int, z: float = 1.96) -> tuple[float, float]:
if n == 0:
return (0.0, 0.0)
p, d = k / n, 1 + z * z / n
c = (p + z * z / (2 * n)) / d
hw = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / d
return (100 * (c - hw), 100 * (c + hw))
def main() -> None:
ap = argparse.ArgumentParser()
ap.add_argument("--n", type=int, default=150, help="verified-clean source images")
ap.add_argument("--workers", type=int, default=max(1, (os.cpu_count() or 4) - 2))
ap.add_argument("--out", type=Path, default=OUT)
ap.add_argument("--restart", action="store_true")
ap.add_argument("--report-only", action="store_true", help="re-read --out and print the report")
a = ap.parse_args()
if a.report_only:
rows = [json.loads(line) for line in a.out.read_text(encoding="utf-8").splitlines() if line.strip()]
report(rows)
return
if a.restart and a.out.exists():
a.out.unlink()
done: set[str] = set()
if a.out.exists():
with open(a.out, encoding="utf-8") as fh:
for line in fh:
try:
done.add(json.loads(line)["src"])
except Exception: # noqa: S112 -- tolerate a torn last line
continue
print(f"selecting {a.n} verified-clean sources (already measured: {len(done)})...", flush=True)
sources = [p for p in clean_sources(a.n) if p.name not in done]
print(f"to do {len(sources)} workers {a.workers} grid {len(MARKS)}x{len(SIZES)}x{len(ALPHAS)}\n", flush=True)
rows: list[dict[str, Any]] = []
a.out.parent.mkdir(parents=True, exist_ok=True)
with open(a.out, "a", encoding="utf-8") as fh, ProcessPoolExecutor(max_workers=a.workers) as ex:
futures = {ex.submit(_one_source, str(p)): p for p in sources}
for i, fut in enumerate(as_completed(futures), 1):
try:
got = fut.result()
except Exception as e: # a crashed source must not kill the sweep
print(f" source failed: {type(e).__name__}: {e}", flush=True)
continue
for r in got:
fh.write(json.dumps(r) + "\n")
rows += got
if i % 10 == 0:
fh.flush()
print(f" {i}/{len(sources)}", flush=True)
if done: # merge the resumed rows so the report covers the whole file
rows = [json.loads(line) for line in a.out.read_text(encoding="utf-8").splitlines() if line.strip()]
report(rows)
def _rate(rs: list[dict[str, Any]], field: str) -> tuple[float, float, float, int]:
k, n = sum(bool(r[field]) for r in rs), len(rs)
lo, hi = wilson(k, n)
return (100 * k / n if n else 0.0, lo, hi, n)
def report(rows: list[dict[str, Any]]) -> None:
stamped = [r for r in rows if r["cell"] == "stamped"]
control = [r for r in rows if r["cell"] == "control"]
if not stamped:
print("no measurements")
return
srcs = len({r["src"] for r in rows})
print(f"\n{'=' * 84}\nDETECTOR RESPONSE {len(stamped)} stamped cells over {srcs} clean backgrounds\n{'=' * 84}")
# Lead with the nominal cell. The grid deliberately includes sizes and opacities the
# engines were never calibrated for, so an aggregate over the whole grid is an
# adversarial score, NOT production recall -- quoting it as recall would understate
# the detectors badly. The nominal cell is the honest reference point: the mark at the
# exact geometry and opacity the engine assumes, on a real clean background.
print("\nNOMINAL CELL (size=1.0, alpha=1.0) -- a mark exactly as the engine expects it.")
print("Read every other number against THIS, not as production recall: the grid is")
print("adversarial by design and its aggregate is not a recall figure.\n")
print(f"{'mark':13s} {'n':>5s} {'detected':>10s} {'maskable':>10s}")
for key in MARKS:
rs = [r for r in stamped if r["mark"] == key and r["size"] == 1.0 and r["alpha"] == 1.0]
if rs:
print(f"{key:13s} {len(rs):5d} {_rate(rs, 'detected')[0]:9.1f}% {_rate(rs, 'maskable')[0]:9.1f}%")
print("\n\nFALSE FIRE on the unstamped controls (the baseline every recall below is read against)\n")
print(f"{'mark':13s} {'n':>5s} {'fires':>8s} {'95% CI':>16s}")
for key in MARKS:
rs = [r for r in control if r["mark"] == key]
if rs:
pct, lo, hi, n = _rate(rs, "detected")
print(f"{key:13s} {n:5d} {pct:7.1f}% {f'{lo:.1f}-{hi:.1f}':>16s}")
print("\n\nDETECTED vs MASKABLE by mark. The GAP is the silent-no-op rate:")
print("a mark counted here as detected-but-not-maskable is reported by `identify` and")
print("skipped by `visible`, so the user is told it was removed when it was not.\n")
print(f"{'mark':13s} {'n':>6s} {'detected':>10s} {'maskable':>10s} {'gap':>7s} {'mask_hit':>9s}")
for key in MARKS:
rs = [r for r in stamped if r["mark"] == key]
if not rs:
continue
det = [r for r in rs if r["detected"]]
d, m = _rate(rs, "detected")[0], _rate(rs, "maskable")[0]
gap = (100 * sum(1 for r in det if not r["maskable"]) / len(det)) if det else 0.0
hits = [r["mask_hit"] for r in det if r["mask_hit"] is not None]
note = " (constructed)" if key == "jimeng_pill" else ""
hv = f"{float(np.median(hits)):.2f}" if hits else "-"
print(f"{key:13s} {len(rs):6d} {d:9.1f}% {m:9.1f}% {gap:6.1f}% {hv:>9s}{note}")
for axis, label in (("alpha", "CONTRAST (alpha multiplier)"), ("size", "SIZE (glyph box multiplier)")):
print(f"\n\nRECALL BY {label} -- 1.0 is the geometry/opacity the engine assumes\n")
vals = sorted({r[axis] for r in stamped})
print(f"{'mark':13s}" + "".join(f"{v:>12}" for v in vals))
for key in MARKS:
cells = []
for v in vals:
rs = [r for r in stamped if r["mark"] == key and r[axis] == v]
cells.append(f"{_rate(rs, 'detected')[0]:10.0f}%" if rs else f"{'-':>11s}")
print(f"{key:13s}" + "".join(f"{c:>12}" for c in cells))
print("\n\nRECALL BY FRAME ASPECT -- the axis the `scale_basis` bug lived on\n")
asps = ["portrait", "square", "landscape"]
print(f"{'mark':13s}" + "".join(f"{x:>14s}" for x in asps))
for key in MARKS:
cells = []
for asp in asps:
rs = [r for r in stamped if r["mark"] == key and r["aspect"] == asp]
cells.append(f"{_rate(rs, 'detected')[0]:.0f}% (n={len(rs)})" if rs else "-")
print(f"{key:13s}" + "".join(f"{c:>14s}" for c in cells))
tex = sorted(r["texture"] for r in stamped if "texture" in r)
if tex:
t1, t2 = tex[len(tex) // 3], tex[2 * len(tex) // 3]
print(f"\n\nRECALL BY BACKGROUND TEXTURE (terciles at {t1:.1f} / {t2:.1f} median-Sobel)\n")
print(f"{'mark':13s}" + "".join(f"{x:>14s}" for x in ("flat", "mid", "textured")))
for key in MARKS:
cells = []
for lo, hi in ((-1, t1), (t1, t2), (t2, 1e9)):
rs = [r for r in stamped if r["mark"] == key and lo < r.get("texture", -2) <= hi]
cells.append(f"{_rate(rs, 'detected')[0]:.0f}% (n={len(rs)})" if rs else "-")
print(f"{key:13s}" + "".join(f"{c:>14s}" for c in cells))
worst = collections.Counter((r["mark"], r["size"], r["alpha"]) for r in stamped if not r["detected"]).most_common(8)
if worst:
print("\n\nWORST CELLS (most misses)\n")
for (mark, size, alpha), n in worst:
print(f" {mark:13s} size={size} alpha={alpha} {n} misses")
if __name__ == "__main__":
main()
+419
View File
@@ -0,0 +1,419 @@
# /// script
# requires-python = ">=3.11"
# dependencies = [
# "click",
# "numpy",
# "opencv-python-headless<5",
# "pillow",
# "scikit-image",
# "torch",
# "lpips",
# "paddleocr",
# "paddlepaddle",
# "insightface",
# "onnxruntime",
# ]
# ///
"""Objective fidelity metrics for comparing watermark-removal outputs.
Given an ORIGINAL (the reference) and one or more cleaned VARIANTS that have all
ALREADY passed the scrub oracle, this scores how much real detail each variant
preserved -- so "closer to the original" is the right axis here (between two
equally-scrubbed outputs, the one that deviates less from the original wins).
It is a standalone eval tool, NOT part of the package: PEP 723 inline deps let
``uv run`` build a throwaway env so the heavy models (PaddleOCR, insightface,
LPIPS) never touch uv.lock or the shipped library. Metrics self-gate: face
metrics run only where faces are detected, text metrics only where text is.
Two subcommands:
ocr -- OCR images (PaddleOCR defaults) into a JSON {basename: text} file.
Run this on the ORIGINALS, hand-verify/correct the file, and it
becomes the ground truth for ``compare --ground-truth`` -- the clean
way to score text, since OCR-vs-OCR is doubly noisy (errors on both
images + reading-order differences inflate CER even on identical text).
compare -- Score each VARIANT against the ORIGINAL across four groups:
1. Text -- character error rate (CER) of the variant's OCR vs the
verified ground truth (or the original's OCR if no --ground-truth).
2. Face identity -- insightface (buffalo_l) ArcFace cosine similarity.
3. Face texture -- LPIPS + Laplacian-variance ratio on face crops
(catches "plastication": ratio < 1 = smoother than the original).
4. Whole image -- LPIPS / SSIM / PSNR vs the original.
Usage:
uv run scripts/fidelity_metrics.py ocr O1.png O2.png --langs en,ru,ch --out gt.json
# (edit gt.json by hand to fix any OCR slips, then:)
uv run scripts/fidelity_metrics.py compare --original O1.png \
--variant controlnet=C.png --variant qwen=Q.png \
--ocr-langs en,ru,ch --ground-truth gt.json
"""
from __future__ import annotations
import json
import sys
import unicodedata
from dataclasses import dataclass, field
from pathlib import Path
from typing import Any
import click
import cv2
import numpy as np
from _plain_console import Console, Table
console = Console()
ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(ROOT))
from scripts._text_eval import levenshtein_normalized # noqa: E402
# ── helpers ──────────────────────────────────────────────────────────
def _load_bgr(path: str) -> np.ndarray:
img = cv2.imread(path, cv2.IMREAD_COLOR)
if img is None:
raise click.ClickException(f"cannot read image: {path}")
return img
def _match_size(variant: np.ndarray, ref: np.ndarray) -> np.ndarray:
"""Resize a variant to the reference size (outputs differ by a grid-round)."""
if variant.shape[:2] != ref.shape[:2]:
variant = cv2.resize(variant, (ref.shape[1], ref.shape[0]), interpolation=cv2.INTER_LANCZOS4)
return variant
def _norm(text: str) -> str:
"""Normalize for CER: NFC + drop ALL whitespace (segmentation-order agnostic)."""
return "".join(unicodedata.normalize("NFC", text).split())
# ── text: PaddleOCR defaults ─────────────────────────────────────────
# Our lang codes -> PaddleOCR lang. The 'ch' model also reads Latin; 'ru' reads
# Cyrillic + Latin. Multiple langs in one image -> run each model, union detections.
_PADDLE_LANG = {"en": "en", "ru": "ru", "ch": "ch", "ch_sim": "ch", "latin": "latin"}
_paddle_cache: dict[str, Any] = {}
def _paddle(lang: str) -> Any:
if lang not in _paddle_cache:
from paddleocr import PaddleOCR
_paddle_cache[lang] = PaddleOCR(
lang=lang,
use_doc_orientation_classify=False,
use_doc_unwarping=False,
use_textline_orientation=False,
)
return _paddle_cache[lang]
def _box_xyxy(box: Any) -> tuple[float, float, float, float]:
"""Axis-aligned (x1, y1, x2, y2) of a PaddleOCR rec box ([x1,y1,x2,y2]) or poly (4x2)."""
arr = np.asarray(box, dtype=np.float32).reshape(-1)
if arr.size == 4:
return float(arr[0]), float(arr[1]), float(arr[2]), float(arr[3])
pts = arr.reshape(-1, 2)
return float(pts[:, 0].min()), float(pts[:, 1].min()), float(pts[:, 0].max()), float(pts[:, 1].max())
def _iou(a: tuple[float, float, float, float], b: tuple[float, float, float, float]) -> float:
ix1, iy1 = max(a[0], b[0]), max(a[1], b[1])
ix2, iy2 = min(a[2], b[2]), min(a[3], b[3])
iw, ih = max(0.0, ix2 - ix1), max(0.0, iy2 - iy1)
inter = iw * ih
if inter <= 0:
return 0.0
area_a = (a[2] - a[0]) * (a[3] - a[1])
area_b = (b[2] - b[0]) * (b[3] - b[1])
return inter / (area_a + area_b - inter + 1e-9)
def _ocr_lines(bgr: np.ndarray, langs: list[str], min_score: float = 0.5) -> list[str]:
"""Detected text lines in reading order, unioned across lang models with spatial NMS.
Several language models over one image re-detect the same lines -- and crucially the
WRONG-script models read e.g. Cyrillic as confident Latin gibberish. So instead of a
naive union, keep the HIGHEST-score detection per physical location (greedy IoU NMS):
the model that actually fits a line wins it (the 'ru' model takes the Cyrillic, 'ch'
the CJK, 'en' the Latin), and the cross-script garbage is dropped.
"""
raw: list[tuple[float, tuple[float, float, float, float], str]] = []
for lang in langs:
plang = _PADDLE_LANG.get(lang, lang)
for page in _paddle(plang).predict(bgr):
texts = page.get("rec_texts", [])
scores = page.get("rec_scores", [])
boxes = page.get("rec_boxes", None)
if boxes is None or len(boxes) == 0:
boxes = page.get("rec_polys", [])
for text, score, box in zip(texts, scores, boxes, strict=False):
if score < min_score or not text.strip():
continue
raw.append((float(score), _box_xyxy(box), text.strip()))
raw.sort(key=lambda d: d[0], reverse=True)
kept: list[tuple[tuple[float, float, float, float], str]] = []
for _score, box, text in raw:
if any(_iou(box, kbox) > 0.3 for kbox, _ in kept):
continue
kept.append((box, text))
kept.sort(key=lambda d: (round(d[0][1] / 20.0), d[0][0])) # reading order: y then x
return [t for _, t in kept]
def _cer(ref: str, hyp: str) -> float:
return levenshtein_normalized(_norm(ref), _norm(hyp))
# ── face: detection + ArcFace + texture ──────────────────────────────
@dataclass
class FaceStats:
n_faces: int = 0
identity: list[float] = field(default_factory=list)
lpips: list[float] = field(default_factory=list)
lapvar_ratio: list[float] = field(default_factory=list)
def _lap_var(bgr: np.ndarray) -> float:
gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY)
return float(cv2.Laplacian(gray, cv2.CV_64F).var())
def _bbox_center(bbox: Any) -> tuple[float, float]:
return (bbox[0] + bbox[2]) / 2, (bbox[1] + bbox[3]) / 2
def _bbox_diag(bbox: Any) -> float:
return float(((bbox[2] - bbox[0]) ** 2 + (bbox[3] - bbox[1]) ** 2) ** 0.5)
def assign_faces_one_to_one(
ref_centers: list[tuple[float, float]],
var_centers: list[tuple[float, float]],
ref_diags: list[float],
max_frac: float = 0.6,
) -> dict[int, int]:
"""One-to-one nearest-center face assignment (pure; unit-tested without insightface).
Per-face nearest matching collides on multi-face images -- two original faces can both
pick the SAME variant face (e.g. when regeneration drops a face, so the variant has fewer
detections), corrupting the identity metric (the lapvar/LPIPS metrics are immune: they are
anchored to the ORIGINAL bbox on both images). This greedy-by-distance assignment is
collision-free: it walks candidate pairs nearest-first and never reuses a ref or a variant
face. Faces are spatially well-separated, so greedy equals the optimal (Hungarian) result
here without the scipy dependency. A pair is dropped when the center distance exceeds
``max_frac`` of the original face diagonal (no plausible match -- the face was lost).
Returns a dict mapping ref-face index -> variant-face index for matched faces only.
"""
pairs: list[tuple[float, int, int]] = []
for i, (rx, ry) in enumerate(ref_centers):
for j, (vx, vy) in enumerate(var_centers):
pairs.append((((rx - vx) ** 2 + (ry - vy) ** 2) ** 0.5, i, j))
pairs.sort()
used_ref: set[int] = set()
used_var: set[int] = set()
matched: dict[int, int] = {}
for dist, i, j in pairs:
if i in used_ref or j in used_var:
continue
if dist > max_frac * ref_diags[i]:
continue
matched[i] = j
used_ref.add(i)
used_var.add(j)
return matched
def _cosine(a: np.ndarray, b: np.ndarray) -> float:
return float(np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b) + 1e-9))
def _crop(bgr: np.ndarray, bbox: Any) -> np.ndarray:
h, w = bgr.shape[:2]
x1, y1, x2, y2 = (int(max(0, bbox[0])), int(max(0, bbox[1])), int(min(w, bbox[2])), int(min(h, bbox[3])))
return bgr[y1:y2, x1:x2]
# ── whole image: LPIPS / SSIM / PSNR ─────────────────────────────────
def _lpips_model() -> tuple[Any, Any]:
import lpips
import torch
model = lpips.LPIPS(net="alex", verbose=False)
model.eval()
return model, torch
def _lpips_distance(model_torch: tuple[Any, Any], a_bgr: np.ndarray, b_bgr: np.ndarray) -> float:
model, torch = model_torch
def _t(img: np.ndarray) -> Any:
rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB).astype(np.float32) / 127.5 - 1.0
return torch.from_numpy(rgb).permute(2, 0, 1).unsqueeze(0)
with torch.no_grad():
return float(model(_t(a_bgr), _t(b_bgr)).item())
def _ssim_psnr(a_bgr: np.ndarray, b_bgr: np.ndarray) -> tuple[float, float]:
from skimage.metrics import peak_signal_noise_ratio, structural_similarity
a = cv2.cvtColor(a_bgr, cv2.COLOR_BGR2GRAY)
b = cv2.cvtColor(b_bgr, cv2.COLOR_BGR2GRAY)
return float(structural_similarity(a, b)), float(peak_signal_noise_ratio(a, b))
# ── reporting ────────────────────────────────────────────────────────
def _mean(xs: list[float]) -> float | None:
return sum(xs) / len(xs) if xs else None
def _fmt(v: float | None, nd: int = 3) -> str:
return "-" if v is None else f"{v:.{nd}f}"
# ── CLI ──────────────────────────────────────────────────────────────
@click.group()
def cli() -> None:
"""Objective fidelity metrics for watermark-removal outputs."""
@cli.command("ocr")
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True))
@click.option("--langs", default="en", help="Comma list of OCR langs (en,ru,ch).")
@click.option("--out", type=click.Path(), default=None, help="Write {basename: text} JSON here (for ground truth).")
def ocr_cmd(images: tuple[str, ...], langs: str, out: str | None) -> None:
"""OCR images into a ground-truth seed -- hand-verify the result before using it."""
lang_list = [x.strip() for x in langs.split(",") if x.strip()]
result: dict[str, str] = {}
for path in images:
lines = _ocr_lines(_load_bgr(path), lang_list)
text = "\n".join(lines)
result[Path(path).name] = text
console.print(f"\n=== {Path(path).name} ===")
console.print(text or "(no text detected)")
if out:
Path(out).write_text(json.dumps(result, ensure_ascii=False, indent=2), encoding="utf-8")
console.print(f"\n Wrote {out} -- verify/correct it by hand, then pass it to `compare --ground-truth`.")
@cli.command("compare")
@click.option("--original", required=True, type=click.Path(exists=True), help="Reference (unprocessed) image.")
@click.option(
"--variant", "variants", multiple=True, required=True, help="LABEL=PATH of a cleaned output (repeatable)."
)
@click.option("--ocr-langs", default="en", help="Comma list of OCR langs (en,ru,ch). Empty = skip text.")
@click.option("--ground-truth", type=click.Path(exists=True), default=None, help="Verified {basename: text} JSON.")
@click.option("--no-faces", is_flag=True, help="Skip face metrics.")
def compare(original: str, variants: tuple[str, ...], ocr_langs: str, ground_truth: str | None, no_faces: bool) -> None:
"""Score each VARIANT against ORIGINAL across the four fidelity groups."""
ref = _load_bgr(original)
parsed: list[tuple[str, np.ndarray]] = []
for spec in variants:
if "=" not in spec:
raise click.ClickException(f"--variant must be LABEL=PATH, got {spec!r}")
label, path = spec.split("=", 1)
parsed.append((label, _match_size(_load_bgr(path), ref)))
langs = [x.strip() for x in ocr_langs.split(",") if x.strip()]
lp = _lpips_model() # AlexNet LPIPS, loaded once and reused for face crops + whole image
# ── text ──
ocr_cer: dict[str, float | None] = {label: None for label, _ in parsed}
if langs:
ref_text: str | None = None
if ground_truth:
gt = json.loads(Path(ground_truth).read_text(encoding="utf-8"))
ref_text = gt.get(Path(original).name)
if ref_text is None:
console.print(f" (no ground-truth entry for {Path(original).name}; skipping text)")
else:
console.print(f" OCR original ({','.join(langs)})...")
ref_text = "\n".join(_ocr_lines(ref, langs))
if ref_text:
console.print(f" OCR variants ({','.join(langs)})...")
for label, img in parsed:
ocr_cer[label] = _cer(ref_text, "\n".join(_ocr_lines(img, langs)))
# ── faces ──
face_stats: dict[str, FaceStats] = {label: FaceStats() for label, _ in parsed}
if not no_faces:
console.print(" Faces (insightface buffalo_l)...")
from insightface.app import FaceAnalysis
app = FaceAnalysis(name="buffalo_l", providers=["CPUExecutionProvider"])
app.prepare(ctx_id=-1, det_size=(640, 640))
ref_faces = app.get(ref)
if ref_faces:
ref_centers = [_bbox_center(of.bbox) for of in ref_faces]
ref_diags = [_bbox_diag(of.bbox) for of in ref_faces]
for label, img in parsed:
vfaces = app.get(img)
st = face_stats[label]
# One-to-one assignment for identity (collision-free); lapvar/LPIPS stay
# anchored to the original bbox below, so they need no match.
matched = assign_faces_one_to_one(ref_centers, [_bbox_center(vf.bbox) for vf in vfaces], ref_diags)
for oi, of in enumerate(ref_faces):
st.n_faces += 1
vf = vfaces[matched[oi]] if oi in matched else None
if vf is not None:
st.identity.append(_cosine(of.normed_embedding, vf.normed_embedding))
oc, vc = _crop(ref, of.bbox), _crop(img, of.bbox)
if oc.size == 0 or vc.size == 0:
continue
vc_r = cv2.resize(vc, (oc.shape[1], oc.shape[0]), interpolation=cv2.INTER_LANCZOS4)
st.lpips.append(_lpips_distance(lp, oc, vc_r))
ov = _lap_var(oc)
st.lapvar_ratio.append(_lap_var(vc_r) / ov if ov > 1e-6 else 0.0)
else:
console.print(" (no faces detected in the original; skipping face metrics)")
# ── whole image ──
console.print(" Whole-image LPIPS/SSIM/PSNR...")
whole: dict[str, tuple[float, float, float]] = {}
for label, img in parsed:
ssim, psnr = _ssim_psnr(ref, img)
whole[label] = (_lpips_distance(lp, ref, img), ssim, psnr)
# ── report ──
table = Table(title=f"Fidelity vs {Path(original).name} (reference)")
for col in ("variant", "text CER↓", "faces", "ID cos↑", "face LPIPS↓", "lapvar↑", "img LPIPS↓", "SSIM↑", "PSNR↑"):
table.add_column(col)
for label, _ in parsed:
st = face_stats[label]
wl, ws, wp = whole[label]
table.add_row(
label,
_fmt(ocr_cer[label]),
str(st.n_faces),
_fmt(_mean(st.identity)),
_fmt(_mean(st.lpips)),
_fmt(_mean(st.lapvar_ratio)),
_fmt(wl),
_fmt(ws),
_fmt(wp, 1),
)
console.print(table)
console.print(
" Legend: CER lower=better; ID cos higher=better; face LPIPS lower=better; "
"lapvar ratio ~1=detail kept, <1=smoothed/plastic; img LPIPS lower=better; SSIM/PSNR higher=closer."
)
if __name__ == "__main__":
cli()
Loaded 100 of 268 files, more files were not shown because too many files have changed in this diff. Show more