mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-18 23:37:14 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf30b4f95a | ||
|
|
8ce8bf14da | ||
|
|
3fb94b220d | ||
|
|
d2c5aa66fc | ||
|
|
380820fa7e | ||
|
|
12e9a87865 | ||
|
|
19fccf27cb | ||
|
|
a385aab433 | ||
|
|
1d7a6270d4 | ||
|
|
b5cd16aef0 | ||
|
|
bf63e8e187 | ||
|
|
d4181f3277 |
No files matched your search
@@ -6,7 +6,7 @@
|
||||
},
|
||||
"metadata": {
|
||||
"description": "Agent skill and Claude Code plugin for the remove-ai-watermarks CLI.",
|
||||
"version": "1.0.8"
|
||||
"version": "1.0.9"
|
||||
},
|
||||
"plugins": [
|
||||
{
|
||||
|
||||
@@ -8,7 +8,7 @@ historical data is sufficient.
|
||||
| Surface | Discovery evidence | Regression check | Removal proof |
|
||||
| --- | --- | --- | --- |
|
||||
| C2PA, EXIF, XMP, IPTC, PNG text, TC260, soft bindings, and container metadata | `scripts/corpus_gap_scan.py` plus review of unattributed and partially attributed reports | `scripts/sidecar_regression.py`; compare record-based and file-based identification when collection changes | `scripts/metadata_removal_audit.py`; signal absent after strip and decoded pixels/streams unchanged |
|
||||
| Registered visible image marks | `scripts/visible_positives.py`; metadata-derived vendor cohorts and blinded sheets for uncovered labels | `scripts/visible_eval.py`, registered examples, and full-corpus crossfire | `scripts/visible_removal_audit.py`; same-mark redetection plus visual residual review and fill-quality checks |
|
||||
| Registered visible image marks | `scripts/visible_positives.py` records strict and metadata-corroborated results, metadata cohorts, and blinded native-detail bands for uncovered cohorts plus an unbiased quiet sample | `scripts/visible_eval.py` exposes registry members without adjudicated scope, registered examples, and full-corpus crossfire | `scripts/visible_removal_audit.py`; same-mark redetection plus visual residual review and fill-quality checks |
|
||||
| Candidate China-AIGC visible marks | `scripts/vendor_cohort_harvest.py` partitions by producer identity without using pixels; inspect full-width top and bottom sheets | `scripts/vendor_mark_calibrate.py` against independently labelled positives, clean negatives, and neighbouring marks | Register only after the candidate gate survives full-corpus crossfire; then use the normal visible-removal audit |
|
||||
| Registered visible video marks | Review provider-labelled or controlled complete clips, not isolated frames alone | Full-clip tests and the local real-provider audit described in `docs/verification-plan.md` | Preserve sequence, timing, duration, and audio; second-pass detection must be quiet |
|
||||
| Locally decodable invisible image marks, including open DWT-DCT and TrustMark | Positive-control-gated corpus or constructed carriers; a negative on an uncontrolled carrier is inconclusive | `scripts/watermark_benchmark.py` and the relevant decoder tests with the required extras installed | Re-run the same decoder on output and retain a positive control in the same run |
|
||||
@@ -42,6 +42,12 @@ compressed pixels, but its inventory should be reconciled with the current
|
||||
metadata constants and registry on every audit. Do not infer completeness from
|
||||
`No gap candidates` until the other rows of the matrix also ran.
|
||||
|
||||
For a visible discovery pass, use a fresh report path and pass `--sheets` to
|
||||
`visible_positives.py`. Review the numbered PNG files before opening
|
||||
`MANIFEST_DO_NOT_OPEN.csv`; metadata cohorts select where a mark may occur but do
|
||||
not label the pixels. The separate `unbiased-quiet` stratum is required because a
|
||||
new provider may carry neither a mapped producer code nor readable provenance.
|
||||
|
||||
## When to replay old data
|
||||
|
||||
Yes, old data must be rerun. Current code can learn to parse an old
|
||||
|
||||
@@ -7,8 +7,8 @@ name: Distribute on release
|
||||
# - HF Space: factory-rebuild the version already pinned in the Space repo.
|
||||
# - ComfyUI: sync, test, and publish the node against the exact library release.
|
||||
# - ClawHub: publish the agent skill when its own version moved.
|
||||
# All wait for the freshly published sdist to appear on PyPI first, since the
|
||||
# Release event fires in parallel with publish.yml's upload.
|
||||
# All wait for the freshly published distribution to appear in both PyPI APIs
|
||||
# first, since the Release event fires in parallel with publish.yml's upload.
|
||||
|
||||
on:
|
||||
release:
|
||||
@@ -22,6 +22,9 @@ on:
|
||||
jobs:
|
||||
resolve:
|
||||
runs-on: ubuntu-latest
|
||||
# Thirty attempts may spend up to 20 seconds each in the JSON request,
|
||||
# simple-index request, and inter-attempt delay. Leave bounded headroom.
|
||||
timeout-minutes: 25
|
||||
outputs:
|
||||
version: ${{ steps.v.outputs.version }}
|
||||
url: ${{ steps.sdist.outputs.url }}
|
||||
@@ -63,26 +66,35 @@ jobs:
|
||||
path: release-identity.json
|
||||
if-no-files-found: error
|
||||
|
||||
- name: Wait for the sdist on PyPI, capture url + sha256
|
||||
- name: Wait for the PyPI JSON and simple indexes, capture sdist metadata
|
||||
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 rel=$(curl --connect-timeout 10 --max-time 20 -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$v/json" 2>/dev/null); then
|
||||
read -r url sha <<< "$(echo "$rel" | python3 -c "import sys,json;u=next((u for u in json.load(sys.stdin)['urls'] if u['packagetype']=='sdist'),{});print(u.get('url',''),u.get('digests',{}).get('sha256',''))")"
|
||||
if [ -n "$url" ] && [ -n "$sha" ]; then
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "Found sdist for $v"
|
||||
exit 0
|
||||
# PyPI's JSON API can show a freshly uploaded file before the
|
||||
# simple index used by pip has caught up. Downstream release
|
||||
# jobs install by package version, so waiting on JSON alone
|
||||
# creates a release-time race for them.
|
||||
simple=$(curl --connect-timeout 10 --max-time 20 -fsSL https://pypi.org/simple/remove-ai-watermarks/ 2>/dev/null || true)
|
||||
artifact=${url##*/}
|
||||
if [[ "$simple" == *"$artifact"* ]]; then
|
||||
echo "url=$url" >> "$GITHUB_OUTPUT"
|
||||
echo "sha=$sha" >> "$GITHUB_OUTPUT"
|
||||
echo "PyPI JSON and simple indexes list $v"
|
||||
exit 0
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
echo "sdist for $v not on PyPI yet (attempt $i), waiting..."
|
||||
sleep 20
|
||||
if [ "$i" -lt 30 ]; then
|
||||
echo "PyPI indexes do not both list $v yet (attempt $i), waiting..."
|
||||
sleep 20
|
||||
fi
|
||||
done
|
||||
echo "::error::sdist for $v did not appear on PyPI in time"
|
||||
echo "::error::PyPI JSON and simple indexes did not both list $v in time"
|
||||
exit 1
|
||||
|
||||
homebrew:
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
Remove AI provenance marks from images and video you generated yourself:
|
||||
|
||||
- known visible labels such as the Google Gemini sparkle watermark and vendor text marks;
|
||||
- invisible pixel watermarks through diffusion regeneration;
|
||||
- invisible pixel watermarks through direct local-format disruption or diffusion regeneration;
|
||||
- C2PA, EXIF, XMP, IPTC, and related AI metadata.
|
||||
|
||||
Video support covers provenance identification, complete visible-plus-metadata
|
||||
@@ -49,7 +49,9 @@ removal.
|
||||
|
||||
Microsoft Paint and Photos InvisMark declarations are routed automatically to
|
||||
pixel regeneration. The `all` command removes both the hidden pixel watermark and
|
||||
its linked C2PA manifest; metadata stripping alone removes only the manifest.
|
||||
its linked C2PA manifest; metadata stripping alone removes only the manifest. A
|
||||
specialized Python API can inspect and disrupt the validated local
|
||||
`Watermarker.dll` payload without diffusion.
|
||||
|
||||
## Installation modes
|
||||
|
||||
@@ -61,6 +63,7 @@ its linked C2PA manifest; metadata stripping alone removes only the manifest.
|
||||
| Visible video processing | `remove-ai-watermarks[video]` |
|
||||
| Video SynthID removal | `remove-ai-watermarks[video,diffusion]` |
|
||||
| Torch-free DWT-DCT detection | `remove-ai-watermarks[detect]` |
|
||||
| Direct local Paint InvisMark disruption | `remove-ai-watermarks[pixels]` |
|
||||
| Invisible image removal (needs CUDA) | `remove-ai-watermarks[qwen-zimage]` |
|
||||
| Every production feature available on the active Python | `remove-ai-watermarks[all]` |
|
||||
|
||||
@@ -213,8 +216,10 @@ is not a product result state. The CLI therefore describes video-pixel
|
||||
regeneration instead of claiming a per-file SynthID verdict, and reports the
|
||||
copied audio watermark as `UNVERIFIED`.
|
||||
|
||||
For invisible watermark removal, install the `qwen-zimage` extra. **An NVIDIA GPU
|
||||
is required**: all profiles are CUDA-only, and there is no CPU or MPS fallback.
|
||||
For invisible watermark removal through the CLI or high-level API, install the
|
||||
`qwen-zimage` extra. **An NVIDIA GPU is required**: all diffusion profiles are
|
||||
CUDA-only, and there is no CPU or MPS fallback. The specialized local Paint API
|
||||
uses the `pixels` extra without CUDA.
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
@@ -267,8 +272,8 @@ 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. A third profile, `chroma-zimage`, uses the Apache-2.0 Chroma1 global pass
|
||||
with its own flat vendor floors; see `docs/chroma1-engine-research.md` for the
|
||||
calibration. `--pipeline auto` picks chroma-zimage for Microsoft
|
||||
provenance and qwen-zimage otherwise. All are CUDA only.
|
||||
calibration. `--pipeline auto` picks sdxl-zimage for Google, chroma-zimage for
|
||||
Microsoft, and qwen-zimage otherwise. All are CUDA only.
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
@@ -375,10 +380,10 @@ and keyed generation parameters are blanked without changing box sizes or
|
||||
media offsets. Other supported containers use their corresponding metadata
|
||||
path.
|
||||
|
||||
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.
|
||||
Invisible removal through the CLI and high-level API regenerates the image through
|
||||
a diffusion pipeline. Specialized callers can instead decode and disrupt the
|
||||
positively identified local Microsoft Paint format with much smaller pixel changes.
|
||||
Neither path can guarantee that every proprietary verifier will reject every output.
|
||||
|
||||
See [supported signals](docs/supported-signals.md) and
|
||||
[known limitations](docs/known-limitations.md) for the full technical boundary.
|
||||
@@ -473,9 +478,10 @@ invisible removal.
|
||||
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. All 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.
|
||||
- Invisible-watermark removal through the CLI and high-level API requires CUDA.
|
||||
All diffusion profiles refuse any other device at construction rather than
|
||||
falling back to one that cannot run them. The specialized local Paint API,
|
||||
visible removal, metadata stripping and `identify` run without CUDA.
|
||||
- Provider watermark systems can change. Validate important outputs with the
|
||||
provider's own verifier when one is available.
|
||||
|
||||
|
||||
+11
-6
@@ -490,7 +490,7 @@ The same option exists on `all` and `batch`, and as
|
||||
| `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 |
|
||||
| `chroma-zimage` | The same face stage on an Apache-2.0 Chroma1 global pass; explicit OpenAI use needs a higher floor than qwen-zimage |
|
||||
| `auto` | Pick the engine from provenance: chroma-zimage for Microsoft, qwen-zimage for OpenAI, Google, unknown, and an explicitly selected Meta cohort |
|
||||
| `auto` | Pick the engine from provenance: sdxl-zimage for Google, chroma-zimage for Microsoft, qwen-zimage for OpenAI, unknown, and an explicitly selected Meta cohort |
|
||||
|
||||
**All four are CUDA-only.** There is no CPU or MPS profile for invisible-watermark
|
||||
removal. The former `controlnet`, `sdxl`, `qwen` and `default` profiles were removed
|
||||
@@ -526,9 +526,13 @@ 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 auto --text-manifest verified-lines.json --force
|
||||
--pipeline qwen-zimage --text-manifest verified-lines.json --force
|
||||
```
|
||||
|
||||
The option works with `qwen-zimage`, `sdxl-zimage`, `chroma-zimage`, and `auto`.
|
||||
Each concrete profile reconstructs the donor through its own already loaded VAE;
|
||||
for Google provenance, `auto` therefore keeps the normal SDXL route.
|
||||
|
||||
``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
|
||||
@@ -552,10 +556,11 @@ container changes remain valid while a resized or edited source fails closed. Th
|
||||
experimental helper
|
||||
`remove_ai_watermarks._internal.text_restoration.source_pixel_sha256` computes it.
|
||||
|
||||
This mode is supported by `qwen-zimage`, `chroma-zimage`, and `auto` at native
|
||||
geometry with `humanize=0`, `unsharp=0`, and adaptive polish disabled. The legacy
|
||||
`sdxl-zimage` profile does not expose a verified-text VAE donor. `all` also accepts the flag,
|
||||
but its manifest must match the pixels entering the invisible stage; if visible-mark
|
||||
This mode is supported by every profile at native geometry with `humanize=0` and
|
||||
`unsharp=0`. An unset adaptive-polish option is forced off so verified text remains
|
||||
the final pixel-writing stage; an explicit request to enable it is rejected. `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.
|
||||
|
||||
|
||||
@@ -94,11 +94,13 @@ Transformers cannot construct the Chroma tokenizer. It includes the `diffusion`
|
||||
dependencies; `diffusion` on its own covers the torch and diffusers imports but
|
||||
not the face stage or Chroma tokenizer, so it is not enough to run a removal.
|
||||
|
||||
**An NVIDIA GPU is required.** `qwen-zimage`, `sdxl-zimage` and `chroma-zimage`
|
||||
are CUDA-only, and
|
||||
**An NVIDIA GPU is required for the diffusion profiles.** `qwen-zimage`,
|
||||
`sdxl-zimage` and `chroma-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.
|
||||
one. There is no CPU, MPS or XPU path for those profiles. The specialized API for
|
||||
the validated local Microsoft Paint format uses the `pixels` extra and no GPU.
|
||||
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.
|
||||
@@ -119,7 +121,7 @@ application actually uses:
|
||||
|
||||
| Extra | Capability | Automatically includes | Torch or model download |
|
||||
| --- | --- | --- | --- |
|
||||
| `pixels` | Shared BGR image runtime | NumPy, headless OpenCV | No |
|
||||
| `pixels` | Shared BGR image runtime and direct local Paint InvisMark disruption | 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 |
|
||||
|
||||
+44
-24
@@ -87,12 +87,20 @@ detections in the removed experimental mode.
|
||||
|
||||
## Invisible removal
|
||||
|
||||
### Regeneration is lossy
|
||||
### Invisible processing changes pixels
|
||||
|
||||
Invisible removal does not decode and delete a payload. It regenerates the
|
||||
Most 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.
|
||||
|
||||
The local Microsoft Paint `Watermarker.dll` format is a narrower exception. Its
|
||||
direct path validates the repeated message and can require agreement with the
|
||||
signed C2PA UUID before rewriting confirmed carriers without redrawing the image.
|
||||
It still changes many pixels slightly, deliberately leaves an inverted periodic
|
||||
signal, and has no external detector that recognizes the positive control. The
|
||||
supported claim is therefore local payload invalidation, not certified removal by
|
||||
Microsoft.
|
||||
|
||||
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.
|
||||
@@ -107,17 +115,29 @@ frozen text regions would keep the watermark
|
||||
([text protection research](text-protection-research.md)).
|
||||
|
||||
`qwen-zimage` is the default profile. `sdxl-zimage` and `chroma-zimage` keep
|
||||
the same face stage and swap the global model. `auto` picks chroma-zimage for
|
||||
Microsoft provenance and qwen-zimage otherwise. All are CUDA only.
|
||||
the same face stage and swap the global model. `auto` picks sdxl-zimage for
|
||||
Google provenance, chroma-zimage for Microsoft, and qwen-zimage otherwise.
|
||||
All are CUDA only.
|
||||
Qwen and SDXL condition the global stage on a canny edge map, which preserves
|
||||
structure but not identity or exact texture. Chroma1 is a plain strength pass
|
||||
without Canny.
|
||||
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).
|
||||
There is no blanket fidelity ordering across providers or content types. At the
|
||||
current Google operating points, however, the 2026-09-15 three-carrier replication
|
||||
favored `sdxl-zimage` in whole-image metrics, localized face-crop LPIPS, and seven
|
||||
manually verified prominent CJK text regions. It retained 13/14 target lines exactly,
|
||||
versus 7/14 for Qwen and 4/14 for Chroma. ArcFace identity was less decisive against
|
||||
Qwen: SDXL won three of four scene-seed pairs and lost one, too few pairs for a
|
||||
significant result. A separate 18-face carrier at the current floors favored SDXL
|
||||
on ArcFace identity and whole-frame similarity in both seeds, while Chroma narrowly
|
||||
won face-crop LPIPS. SDXL also won a no-face/no-text photographic carrier, but Qwen
|
||||
won LPIPS on a flat graphic; one carrier cannot support content-adaptive routing.
|
||||
All three profiles regenerated fine newspaper body copy as pseudotext, so the result
|
||||
selects a relative Google default rather than promising exact face or text
|
||||
preservation. Both models are large, slow, and may still alter small text or difficult
|
||||
faces. The general fidelity corpus and its OCR caveats are tracked in
|
||||
[`data/evaluations/fidelity/`](../data/evaluations/fidelity/README.md), while the
|
||||
current-floor Google replication is recorded in
|
||||
[`module-internals.md`](module-internals.md#google-boundary-replication-2026-09-15).
|
||||
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:
|
||||
@@ -241,18 +261,18 @@ were measured for each.
|
||||
|
||||
`qwen-zimage` reads unknown content from the resolution-adaptive denoise curve.
|
||||
Measured provider cohorts instead take flat operating points: OpenAI `0.07675`,
|
||||
Google `0.27`, and Microsoft InvisMark `0.15`. OpenAI and Microsoft add one full
|
||||
Google `0.35`, and Microsoft InvisMark `0.15`. OpenAI and Microsoft add one full
|
||||
observed cross-source boundary spread to the worst clean source; Microsoft's three
|
||||
first-clean boundaries were `0.04125`, `0.055`, and `0.095`, giving `0.14875` before
|
||||
rounding up. Google's candidate was separately repeated across three valid sources
|
||||
and three accounts. The small corpora make these operating points, not universal
|
||||
thresholds.
|
||||
rounding up. Google's source-fresh candidate cleared three independent CJK carrier
|
||||
types after the original content matrix found CJK and Cyrillic to be the limiting
|
||||
strata. The small corpora make these operating points, not universal thresholds.
|
||||
|
||||
`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.
|
||||
- Google: `0.50`;
|
||||
- unknown: `0.50`, 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
|
||||
@@ -268,13 +288,13 @@ established for that stage.
|
||||
boundary gives `0.20`, verified clean three times on both, while qwen-zimage
|
||||
cleared both at its existing `0.07675` operating point;
|
||||
- Microsoft InvisMark: `0.125` (below qwen's `0.15`);
|
||||
- Google: `0.40` for zero-face content (above qwen's `0.27`; at this floor the
|
||||
regeneration destroys dense text and collapses face identity -- the tradeoff
|
||||
that motivates the adaptive arm below), or `0.125` when YuNet detects at
|
||||
least one face (both measured face fixtures clear at 0.12 with zero
|
||||
cross-source spread; oracle-verified 2026-08-30);
|
||||
- Google: `0.50`. A source-fresh CJK carrier remained detected at `0.40` and
|
||||
cleared at `0.50`; that candidate then cleared independent busy-scene,
|
||||
flat-text, and face-plus-text carriers. The old YuNet-only `0.125` face arm
|
||||
was removed because face detection cannot distinguish easy face-only images
|
||||
from harder face-plus-text content;
|
||||
- Meta Content Seal: `0.17` (above qwen's `0.1`);
|
||||
- unknown: `0.40`, following the strictest measured cohort.
|
||||
- unknown: `0.50`, following the strictest measured cohort.
|
||||
|
||||
An explicit `--strength` overrides all three. The defaults are operating points,
|
||||
not universal guarantees. Near a removal threshold, different content or a
|
||||
@@ -288,8 +308,8 @@ are certified at a fixed seed. The live resolver is
|
||||
| --- | --- |
|
||||
| `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. |
|
||||
| `chroma-zimage` | CUDA only. Higher Google and Meta floors than qwen; Google face content uses 0.125 instead of 0.40. |
|
||||
| `auto` | CUDA only. Routes Microsoft to chroma-zimage and everything else to qwen-zimage. |
|
||||
| `chroma-zimage` | CUDA only. Higher Google and Meta floors than qwen; no safe content-adaptive Google arm exists yet. |
|
||||
| `auto` | CUDA only. Routes Google to sdxl-zimage, Microsoft to chroma-zimage, and everything else to qwen-zimage. |
|
||||
|
||||
Only manually verified `vae-glyphs` is an optional production stage, and it is
|
||||
experimental rather than a default.
|
||||
|
||||
+272
-30
@@ -484,6 +484,19 @@ not added to the watermark inventory, do not trigger pixel regeneration, and do
|
||||
not suppress independently established SynthID watermark evidence. A registered
|
||||
watermark or an unknown soft-binding algorithm keeps that inference fail-safe.
|
||||
|
||||
The local Paint reader treats C2PA as corroboration, not as the pixel detector.
|
||||
It extracts 4-by-4 blocks from the luminance Haar LL plane, reads the largest
|
||||
singular-value residue of the 15 DCT AC coefficients, and votes over the repeating
|
||||
12-by-12 map. A payload is actionable only when every bit has at least three
|
||||
supporting carriers and the `0x4c` prefix and GUID checksum validate. A caller can
|
||||
also require the decoded Windows-order GUID to equal the one in pristine C2PA.
|
||||
Direct disruption moves each confirmed carrier to the opposite residue center,
|
||||
encodes to a temporary file, and re-runs the reader before atomically publishing
|
||||
it. Any missing or ambiguous
|
||||
condition returns no detection so a caller can retain its existing fallback. The
|
||||
implementation contains no Microsoft binary, PDB data, or extracted writer-quality
|
||||
forest.
|
||||
|
||||
The SDK default enables trust verification but supplies no production trust
|
||||
anchors. Consequently, an installation without an explicitly maintained C2PA
|
||||
trust bundle reports every otherwise valid signer chain as untrusted --
|
||||
@@ -510,13 +523,14 @@ convert `signingCredential.untrusted` into trusted based on a vendor-name match.
|
||||
Vendor attribution comes from the registry in
|
||||
[`_internal/constants.py`](../src/remove_ai_watermarks/_internal/constants.py). Derived
|
||||
issuer and platform maps should not be maintained separately.
|
||||
For an AI C2PA claim, a recognized product in `claim_generator` takes precedence
|
||||
over the certificate issuer: an application can sign through an upstream model
|
||||
provider without becoming that provider's product. Only exact product mappings
|
||||
receive this precedence; an unknown claim generator still falls back to issuer
|
||||
attribution. An unmapped issuer org reads as unknown-signer C2PA with no platform;
|
||||
that is how Ideogram was surfaced (4 private-corpus files signed "Ideogram, Inc",
|
||||
2026-08-08) before its vendor row was added on 2026-08-27.
|
||||
A recognized product in `claim_generator` takes precedence over the certificate
|
||||
issuer: an application can sign through an upstream model provider without
|
||||
becoming that provider's product. Exact products are also useful provenance on a
|
||||
non-AI edit, such as CapCut; recognizing the product does not itself change the AI
|
||||
verdict. An unknown claim generator falls back to issuer attribution. An unmapped
|
||||
issuer org reads as unknown-signer C2PA with no platform; that is how Ideogram was
|
||||
surfaced (4 private-corpus files signed "Ideogram, Inc", 2026-08-08) before its
|
||||
vendor row was added on 2026-08-27.
|
||||
|
||||
### Metadata scanning and stripping
|
||||
|
||||
@@ -1651,7 +1665,8 @@ The current profiles are `qwen-zimage` (the default), `sdxl-zimage`,
|
||||
`qwen` and `default` were removed rather than kept as a CPU path, and are
|
||||
rejected rather than aliased onward. `auto` is a deterministic per-cohort
|
||||
selection policy:
|
||||
chroma-zimage for Microsoft, qwen-zimage for OpenAI, Google, Meta, and unknown.
|
||||
sdxl-zimage for Google, chroma-zimage for Microsoft, and qwen-zimage for
|
||||
OpenAI, Meta, and unknown.
|
||||
It does not run a learned router or classify the image's genre.
|
||||
|
||||
`qwen-zimage` normally resolves global denoise from image area for unknown content.
|
||||
@@ -1824,8 +1839,8 @@ verification, and geometry-only operators do not need to invent text or script f
|
||||
|
||||
When enabled, the one profile selected before model loading reconstructs the source
|
||||
once through its already loaded VAE, runs the ordinary global and face stages, and
|
||||
calls the shared restoration compositor. Qwen and Chroma implement this donor hook;
|
||||
SDXL does not. The optional Qwen-only fidelity anchor first blends 15% of the VAE
|
||||
calls the shared restoration compositor. Qwen, SDXL, and Chroma implement this donor
|
||||
hook. The optional Qwen-only fidelity anchor first blends 15% of the VAE
|
||||
reconstruction into the clean result; it is off by default because the blend returned
|
||||
detector-visible OpenAI SynthID in the measured poster fixtures. The compositor derives
|
||||
binary source and candidate silhouettes, groups nearby lines, uses LaMa for the initial
|
||||
@@ -1839,8 +1854,9 @@ extend 8% above and 25% below. The anchored-component gate covers clipped leadin
|
||||
flourishes, trailing punctuation, icons, and descenders without walking into
|
||||
disconnected decoration or background texture.
|
||||
|
||||
The stage is deliberately narrower than the engine: it rejects `sdxl-zimage`, tiles,
|
||||
resolution caps, humanize, unsharp, and adaptive polish. Those combinations change
|
||||
The stage is deliberately narrower than the engine: it rejects tiles, resolution
|
||||
caps, humanize, unsharp, and explicitly enabled adaptive polish. An unset polish
|
||||
value resolves to off in this mode, including for SDXL, because those combinations change
|
||||
geometry or final pixels after the verified layer and have no measured oracle result.
|
||||
It remains opt-in because annotations are manual and provider verdicts apply only to
|
||||
the exact tested output hashes, not to the mechanism in general.
|
||||
@@ -1864,6 +1880,22 @@ the `chroma_text_restoration_study.py` harness (kept outside this repository)
|
||||
under the gitignored
|
||||
`out/text-restoration-engine-study/` directory.
|
||||
|
||||
The 2026-09-15 rerun added the SDXL donor implementation and exercised all three
|
||||
profiles on the same three manifests. SDXL improved text-box MAE from 11.319 to
|
||||
7.970 and from 10.704 to 7.657 on the two OpenAI posters, while PSNR rose from
|
||||
25.83 to 29.24 dB and from 26.17 to 29.04 dB. On the tracked Google CJK carrier
|
||||
(`4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236`, already
|
||||
recorded as SynthID-positive), SDXL at the calibrated 0.50 strength improved
|
||||
text-box MAE from 10.738 to 8.564 and PSNR from 23.93 to 24.00 dB. The exact
|
||||
restored output
|
||||
`035e4919e1d84129a347006c98f19eb4431eb5772788f6d2097b94a2c6baec84`
|
||||
was submitted to the Google Gemini oracle after provenance stripping preserved
|
||||
its decoded pixels; the sanitized upload hash was
|
||||
`3e4f06274a7b735f7d06863fd54dc8146f60aeb9cc8315f86294180deb660b82`.
|
||||
The oracle returned `not_detected` on 2026-09-16. This
|
||||
certifies that one carrier, manifest, seed, runtime, and output hash; it does not
|
||||
turn the compositor into a provider-wide guarantee.
|
||||
|
||||
A matched stage-isolation check on the 18-face Gemini portrait grid confirms the
|
||||
division of responsibility. The visible-cleaned, metadata-stripped control and the
|
||||
Z-Image face-only output were both SynthID-positive; Qwen global-only and the full
|
||||
@@ -1922,22 +1954,20 @@ prompt, which was never calibrated against Chroma1), guidance 5.0, and the
|
||||
calibrated `ceil(4 / strength)` requested-step schedule.
|
||||
`CHROMA_NOMINAL_STEPS = 4` is a nominal count: Chroma rounds the start
|
||||
index, so strengths 0.09 and 0.17 execute five steps, while 0.20, 0.125,
|
||||
and 0.40 execute four. Preserve those requests rather than changing the
|
||||
and 0.50 execute four. Preserve those requests rather than changing the
|
||||
calibration to force four effective steps. There is no Canny
|
||||
conditioning: the floors were measured on a plain strength pass.
|
||||
|
||||
The flat vendor floors (`CHROMA_ZIMAGE_*_STRENGTH` in watermark_profiles):
|
||||
OpenAI 0.20 and Microsoft 0.125, Google 0.40 for zero-face content / 0.125 when
|
||||
faces are detected (a content-adaptive arm from the clean face-count split
|
||||
in the calibration: both text cards need 0.25, both face fixtures clear at
|
||||
0.12, so the YuNet detector -- already loaded for the face stage -- routes
|
||||
the operating point), and Meta 0.17 (ABOVE qwen's, because Chroma1's
|
||||
per-fixture boundaries scatter wider). The matched-strength addendum in the
|
||||
research doc is the honest read: at the strength each image actually needs,
|
||||
Chroma1 regenerates better almost everywhere except face identity (which the
|
||||
inherited Z-Image face stage supplies); at the flat worst-case floors it
|
||||
destroys dense text and face identity. The Google face-content arm is the
|
||||
first shipped piece of the content-adaptive policy. A Meta arm was measured
|
||||
OpenAI 0.20, Microsoft 0.125, Google 0.50, and Meta 0.17. The source-fresh
|
||||
Google sweep supersedes the older 0.40 zero-face / 0.125 face split: fresh CJK
|
||||
remained detected at 0.40, and a face-plus-text carrier did not support treating
|
||||
face presence as evidence for the easy arm. The runtime has no independently
|
||||
measured text-content gate, so every Google carrier now uses the replicated global
|
||||
floor. The matched-strength addendum in the research doc remains useful history:
|
||||
at the strength each image actually needs, Chroma1 regenerates better almost
|
||||
everywhere except face identity (which the inherited Z-Image face stage supplies),
|
||||
but high flat floors can destroy dense text and face identity. A Meta arm was measured
|
||||
on 2026-08-30/31 (docs/chroma1-engine-research.md, expansion section) and
|
||||
does not ship: first-cleans from 0.03 to 0.10 form a continuum,
|
||||
`flat_ratio` overlaps the hard and easy clusters, and a high-flat
|
||||
@@ -1973,8 +2003,9 @@ is supported by this pass, so `auto` keeps the measured provenance-cohort table.
|
||||
|
||||
[`_internal/sdxl_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/sdxl_zimage_pipeline.py)
|
||||
runs the same two-stage recipe on an SDXL global pass. `SdxlZImagePipeline`
|
||||
subclasses `TwoStageZImagePipeline` and implements only the global stage
|
||||
(`_load_global`, `_run_global`), so the face stage is inherited rather than copied
|
||||
subclasses `TwoStageZImagePipeline` and implements the global stage
|
||||
(`_load_global`, `_run_global`) plus the deterministic verified-text VAE donor, so
|
||||
the face stage is inherited rather than copied
|
||||
and cannot drift between the profiles; a test asserts the shared methods are the
|
||||
same objects.
|
||||
|
||||
@@ -1985,13 +2016,213 @@ Four things are architecture-bound and swap with the model: the ControlNet
|
||||
AuraFlow shift), and the latent grid (8 px against Qwen's 16).
|
||||
|
||||
**Strength is architecture-bound too, and that is the easy mistake.** An SDXL global
|
||||
pass leaves SynthID at the strength Qwen needs: verified through the Gemini app on a
|
||||
native 2816x1536 original, 0.154 is FOUND while 0.20, 0.25 and 0.30 are clean. So this
|
||||
pass can leave SynthID at the strength Qwen needs. The source-fresh 2026-09-15
|
||||
matrix found its limiting Google CJK carrier detected through 0.45 and clean at
|
||||
0.50, with independent flat-text and face-plus-text replication at 0.50. So this
|
||||
profile takes a vendor policy (`SDXL_ZIMAGE_OPENAI_STRENGTH` 0.15,
|
||||
`SDXL_ZIMAGE_GEMINI_STRENGTH` 0.25, unknown following Gemini) rather than
|
||||
`SDXL_ZIMAGE_GEMINI_STRENGTH` 0.50, unknown following Google) rather than
|
||||
`resolution_adaptive_denoise`. Flat values are what was measured; no size dependence
|
||||
has been established for this stage, so none is asserted.
|
||||
|
||||
#### Cross-profile strength sweep, 2026-09-14
|
||||
|
||||
A fresh single-source, seed-0 sweep compared qwen-zimage, chroma-zimage, and
|
||||
sdxl-zimage on the same provider-specific carriers. The original content matrix covered
|
||||
photorealistic scenes, flat low-detail art, single and multiple faces, dense Latin,
|
||||
CJK and Cyrillic text, multilingual posters, and faces plus text. Meta completed
|
||||
48/48 real variants, Microsoft 12/12, the original OpenAI sweep 36/36, and Google
|
||||
44/48. Thirteen unique adaptive OpenAI extension candidates later closed the two
|
||||
missing content quadrants. The minimum
|
||||
tested global strengths that cleared every completed stratum were:
|
||||
|
||||
| Provider | qwen-zimage | chroma-zimage | sdxl-zimage |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| OpenAI | 0.06 | 0.12 | 0.06 |
|
||||
| Microsoft | 0.10 | <= 0.04 | <= 0.08 |
|
||||
| Meta | 0.06 | 0.10 | <= 0.08 |
|
||||
| Google | 0.35 | 0.50 | 0.50 |
|
||||
|
||||
`<=` means the first tested rung already cleared, not that its lower boundary is
|
||||
known. Google qwen cleared both CJK and Cyrillic only at 0.35; its face series was
|
||||
non-monotonic. Google SDXL cleared Cyrillic at 0.35 after detections through 0.28,
|
||||
but CJK remained detected at 0.35, 0.40, and 0.45 before clearing at 0.50. Google
|
||||
Chroma CJK remained detected through 0.40 and cleared at 0.50; Cyrillic cleared at
|
||||
0.40. Chroma face+text first cleared at
|
||||
0.22 after detections at 0.08 and 0.16; the face carrier cleared at 0.22 after a
|
||||
detection at 0.08. A recovered-quota recheck on 2026-09-15 also detected the face
|
||||
carrier at 0.16, closing that tested bracket at `0.16 detected -> 0.22 clean` and
|
||||
showing that the shipped 0.125 face arm is too low for this carrier. These are
|
||||
candidate rungs at this stage of the study, not replacement defaults: there is one
|
||||
source and one seed per case. The independent three-carrier replication below later
|
||||
closed the production seed-0 decision; size, crop/resize, JPEG, local-edit, and broader
|
||||
multi-seed robustness remain separate holdouts.
|
||||
|
||||
The original OpenAI set had faces-only and multilingual text-only carriers but no
|
||||
explicit no-face/no-text carrier. A fresh 1536x1024 photorealistic rocky-coast scene
|
||||
closed that stratum on 2026-09-15. The official OpenAI verifier found Qwen 0.04 and
|
||||
Chroma 0.10, then cleared Qwen 0.06 and Chroma 0.12. It found SDXL 0.04 and cleared
|
||||
0.06. These are 0.02-grid brackets, not mathematical minima. The public Web verifier
|
||||
settled the earlier points and exposed SynthID separately from C2PA. A later Chroma
|
||||
0.12 Web request remained in `Verifying` for more than 90 seconds, so it was recorded
|
||||
as indeterminate; the preferred official Content Provenance API returned
|
||||
`not_detected` for that same processed source hash. Three independent official API
|
||||
requests returned HTTP 200 for Chroma 0.12, SDXL 0.04, and SDXL 0.06 respectively. No source control was
|
||||
submitted.
|
||||
|
||||
The other missing OpenAI stratum, one face plus multilingual text, was added with a
|
||||
second 1536x1024 carrier. The official API found Qwen 0.04 and cleared 0.06, while
|
||||
Chroma 0.10 and SDXL 0.04 already cleared. All responses were HTTP 200 with C2PA
|
||||
`not_present`, and original-size review found the face identity, Latin, CJK, Cyrillic,
|
||||
and date text preserved at every generated rung. This stratum does not raise any
|
||||
global OpenAI candidate; the no-face/no-text scene remains the limiting case for
|
||||
Chroma and SDXL.
|
||||
|
||||
At the original non-Google candidate rungs, Chroma had the best mean quality in all
|
||||
three provider cohorts. For Meta, mean LPIPS/SSIM was 0.0320/0.8882 for Chroma 0.10,
|
||||
0.0357/0.8595 for Qwen 0.06, and 0.0531/0.8503 for SDXL 0.08. For OpenAI it was
|
||||
0.0183/0.8763, 0.0192/0.8595, and 0.0304/0.8459 respectively over the original three
|
||||
carriers. Recomputed over all five OpenAI carriers at the freshly verified candidates,
|
||||
Qwen 0.06 has the best LPIPS, SSIM, and edge F1 at 0.03073, 0.82461, and 0.89141.
|
||||
Chroma 0.12 has the best MAE by a narrow margin at 6.42, but its LPIPS/SSIM is worse
|
||||
at 0.05057/0.80940 and Qwen narrowly leads PSNR 27.99 to 27.97 dB; Chroma's
|
||||
no-face/no-text scene is the main loss.
|
||||
SDXL 0.06 is third on the aggregate perceptual and structural metrics at
|
||||
0.03766/0.77202, while keeping the mean Laplacian ratio closest to one. Original-size
|
||||
review agrees that there is no single winner on every criterion, but Qwen is the best
|
||||
overall OpenAI candidate when perceptual similarity, structure, edges, faces, and text
|
||||
are weighted together. Microsoft's
|
||||
single flat carrier likewise favored Chroma 0.04 at 0.00265/0.99621 over Qwen
|
||||
0.10 at 0.00347/0.99426 and SDXL 0.08 at 0.00324/0.99566; this is a carrier
|
||||
comparison, not a multi-source mean. At Google 0.35, SDXL preserved substantially
|
||||
more quality than Qwen (mean LPIPS 0.0655 versus 0.1576, SSIM 0.7285 versus
|
||||
0.5457), although SDXL needed 0.50 rather than 0.35 on the limiting CJK carrier.
|
||||
On that carrier, SDXL 0.50 measured LPIPS/SSIM/PSNR 0.1041/0.7437/23.93 dB,
|
||||
substantially better than Chroma 0.50 at 0.2129/0.5853/20.74 dB and Qwen 0.35 at
|
||||
0.1834/0.5550/18.93 dB. The newly cleared Chroma 0.22 face and face+text points also lost to
|
||||
SDXL at its corresponding tested rungs on every reported metric: LPIPS/SSIM was
|
||||
0.0476/0.6657 versus 0.0318/0.7036 for faces, and 0.0506/0.6359 versus
|
||||
0.0426/0.7016 for face+text. Original-resolution visual review agreed with that
|
||||
direction: Qwen
|
||||
0.35 changed the last glyph of the Cyrillic `УЛЫБКОЙ` into a Latin-looking `N` and
|
||||
altered substantially more wood grain and background structure; Chroma 0.40 and
|
||||
SDXL 0.35 retained the word, with SDXL visually closest to the source. Chroma 0.22
|
||||
preserved foreground face identity and text legibility but visibly changed
|
||||
background people, room structure, lighting, and confetti. The
|
||||
official-oracle access and rolling-quota caveats are recorded in
|
||||
[`provider-oracles.md`](provider-oracles.md).
|
||||
|
||||
The original Google carriers omitted the explicit no-face/no-text stratum. A
|
||||
2026-09-14 extension added two real Gemini-generated 2752x1536 carriers: a
|
||||
photorealistic alpine lake and a low-detail geometric desert. At the current
|
||||
candidate rungs, the flat carrier remained close under all three profiles;
|
||||
LPIPS/SSIM was 0.0237/0.9654 for Qwen 0.35, 0.0120/0.9706 for Chroma 0.40,
|
||||
and 0.0134/0.9655 for SDXL 0.35. On the photographic carrier, every profile
|
||||
visibly regenerated scene detail. SDXL 0.35 was least destructive at
|
||||
0.1174/0.4933, ahead of Qwen 0.35 at 0.2070/0.4120 and Chroma 0.40 at
|
||||
0.2216/0.4229. Original-resolution review found major mountain, shoreline,
|
||||
vegetation, rock, and reflection changes in Qwen and Chroma; SDXL retained the
|
||||
main composition and geometry but still changed fine detail. Full four-rung
|
||||
ladders produced 24 variants across the two carriers and three profiles. After the
|
||||
independent session allowances recovered on 2026-09-15, the official verifier cleared
|
||||
both Qwen carriers at the first 0.15 rung. Chroma's flat carrier cleared at 0.12,
|
||||
while its photographic carrier closed at `0.20 detected -> 0.30 clean`. SDXL's
|
||||
photographic carrier cleared at 0.15 and its quality-qualified flat 0.35 carrier
|
||||
cleared directly. These scene cases therefore do not raise the global Google
|
||||
candidate above the text-bound values in the table.
|
||||
The SDXL flat ladder was quality-non-monotonic: 0.15 introduced severe texture
|
||||
and scored LPIPS 0.1283, while 0.35 looked cleaner and scored 0.0134. Repeating
|
||||
0.15 and 0.22 with adaptive polish disabled changed the polished outputs by only
|
||||
0.51 and 0.57 mean byte levels per channel and left LPIPS effectively unchanged.
|
||||
The artifact therefore comes from the low-step SDXL reconstruction, not polish.
|
||||
The 0.15 flat variant is rejected before oracle use; 0.35 is the
|
||||
quality-qualified SynthID candidate for that one path.
|
||||
|
||||
A Chroma extension was run on an isolated CUDA research worker from the same
|
||||
`c4edc282` checkout. Strength 0.50 retained readable
|
||||
CJK and Cyrillic but was already borderline: LPIPS/SSIM/PSNR was
|
||||
0.2129/0.5853/20.74 dB and 0.2268/0.5490/20.83 dB respectively. At 0.60 both
|
||||
scripts were visibly destroyed and LPIPS exceeded 0.42. The official verifier
|
||||
subsequently detected CJK 0.40 and cleared 0.50, making the borderline 0.50 rung the
|
||||
fresh global Chroma candidate. Strength 0.60 remains rejected on fidelity.
|
||||
|
||||
The corresponding SDXL CJK extension used the same checkout and an isolated CUDA
|
||||
research worker. The official verifier detected 0.40 and 0.45 and cleared 0.50.
|
||||
At 0.50, LPIPS/SSIM/PSNR was 0.1041/0.7437/23.93 dB; original-resolution review
|
||||
found the CJK text fully legible and the scene recognizably preserved. SDXL is the
|
||||
best-quality Google candidate on the limiting carrier.
|
||||
|
||||
#### Google boundary replication, 2026-09-15
|
||||
|
||||
Three new Gemini-generated controls replicated the hard boundary on independent
|
||||
content: a busy night market with dense CJK and many faces, a flat CJK poster without
|
||||
faces, and a portrait reading a dense CJK newspaper. An isolated CUDA run generated
|
||||
both seed 0 and seed 1 at the candidate and neighboring lower rungs, 36 variants total. Production
|
||||
uses the fixed seed 0, and only processed seed-0 variants were sent to the official
|
||||
verifier; source controls were not uploaded.
|
||||
|
||||
At the candidate rungs, the official verifier returned `not_detected` for all nine
|
||||
profile-by-carrier cells: Qwen 0.35, Chroma 0.50, and SDXL 0.50. A separate lower-rung
|
||||
night-market check also cleared Chroma 0.40 and SDXL 0.45, but those two results do
|
||||
not lower the policy because the original fresh CJK carrier remained detected there.
|
||||
Three quota failures and one duplicate post-success tool-call failure were recorded
|
||||
as `unreachable` in separate immutable batches and excluded from the watermark
|
||||
count. Every settled batch passed source, upload, manifest, and result hash
|
||||
verification.
|
||||
|
||||
Quality over all three new carriers and both seeds selected SDXL for Google auto
|
||||
routing. Mean LPIPS/SSIM/PSNR was 0.0609/0.8178/25.15 dB for SDXL 0.50,
|
||||
0.0980/0.7055/21.85 dB for Qwen 0.35, and 0.1269/0.6909/22.53 dB for Chroma 0.50.
|
||||
Original-resolution review agreed: SDXL best preserved the night-market headline,
|
||||
newspaper layout, faces, and the flat poster. Therefore the production Google floors
|
||||
are Qwen 0.35, Chroma 0.50, and SDXL 0.50, and `auto` routes Google to SDXL.
|
||||
|
||||
A localized follow-up checked that aggregate image metrics were not hiding face or
|
||||
text failures. InsightFace `buffalo_l` matched three faces in the newspaper scene and
|
||||
15 in the night market one-to-one. Across the four scene-seed pairs, SDXL beat Qwen
|
||||
on ArcFace cosine in three and lost one, and beat Chroma in all four. That identity
|
||||
result is directional but small: the two-sided sign-test p-values are 0.625 and
|
||||
0.125, respectively. Face-crop LPIPS favored SDXL in all four pairs against both
|
||||
other profiles, also p=0.125 with only four non-tied pairs. This supports, but does
|
||||
not by itself prove, an identity advantage over Qwen.
|
||||
|
||||
Text was scored separately in seven manually verified fixed regions whose originals
|
||||
all produced an exact OCR line: the newspaper masthead, headline, two halves of its
|
||||
subhead, the night-market headline, and two poster lines. The metric was the closest
|
||||
OCR-line normalized edit distance to that fixed target, across both seeds. SDXL's
|
||||
mean was 0.0119 with 13/14 exact region-seed results, versus 0.3770 and 7/14 for Qwen
|
||||
and 0.5374 and 4/14 for Chroma. In paired sign tests SDXL was better than Qwen in
|
||||
seven non-tied cells and worse in none (two-sided p=0.015625), and better than Chroma
|
||||
in ten and worse in none (p=0.001953125). These results cover the selected prominent
|
||||
lines, not arbitrary text preservation: fine newspaper body copy was regenerated as
|
||||
pseudotext by every profile. The safe claim is therefore that SDXL was the relative
|
||||
quality winner on these replicated Google carriers, not that it preserves faces or
|
||||
text losslessly.
|
||||
|
||||
Two current-floor extensions closed the remaining content quadrants. On the
|
||||
face-only carrier, rerun at Qwen 0.35, Chroma 0.50, and SDXL 0.50 for seeds 0 and
|
||||
1, InsightFace matched all 18 faces. SDXL had the highest mean ArcFace cosine in
|
||||
both seeds, 0.828 and 0.842, versus 0.780/0.794 for Chroma and 0.744/0.773 for
|
||||
Qwen. Chroma narrowly won face-crop LPIPS at 0.059/0.058, versus 0.060/0.063 for
|
||||
SDXL, while SDXL was much closer over the whole frame. Its face Laplacian ratios
|
||||
of 1.152/1.176 also show slight texture amplification; Qwen and Chroma instead
|
||||
smoothed face detail to roughly half the source level. Original-resolution review
|
||||
therefore favors SDXL for overall composition and identity, but not every local
|
||||
face metric.
|
||||
|
||||
The no-face/no-text photographic and flat-graphic carriers were also rerun at the
|
||||
same current floors and two seeds. On the photographic scene, SDXL had the best
|
||||
LPIPS/SSIM in both seeds: 0.345/0.457 and 0.339/0.461, ahead of Qwen at
|
||||
0.413/0.412 and 0.404/0.414 and Chroma at 0.568/0.395 and 0.579/0.401.
|
||||
Original-resolution review likewise found SDXL closest, although every profile
|
||||
regenerated major landscape detail. The flat graphic reversed the LPIPS result:
|
||||
Qwen scored 0.045/0.042, Chroma 0.068/0.065, and SDXL 0.099/0.088; all SSIM values
|
||||
were 0.962-0.965. Visual review found model-specific gradient, object, or sun-halo
|
||||
artifacts in every output. One carrier and two seeds do not justify a new
|
||||
content-adaptive route. Together with the text-only and face-plus-text replication,
|
||||
these extensions cover all four primary face/text quadrants at the shipped Google
|
||||
floors, while also showing why `auto` is a relative default rather than a universal
|
||||
per-image optimum.
|
||||
|
||||
`requested_steps` is shared by SDXL and Chroma in `two_stage_pipeline.py`,
|
||||
which also owns `_target_size` with each profile's grid passed explicitly.
|
||||
The step helper exists because the two runtimes truncate differently. DiffSynth sets
|
||||
@@ -2041,6 +2272,17 @@ resolution case -- every verdict so far comes from one fixture and one seed.
|
||||
Both ends of the shipped curve now have oracle verdicts, and the shipped curve clears
|
||||
everything it has been tested at:
|
||||
|
||||
A source-fresh GPT Image 2.5 check on 2026-09-15 exercised the complete shipped
|
||||
profile rather than only a strength sweep. One 1024 x 1024 API generation from
|
||||
Flare and one from Sunburst both validated as current OpenAI C2PA, routed to the
|
||||
flat OpenAI strength, and remained OpenAI SynthID-positive after pixel-identical
|
||||
metadata removal. The complete `qwen-zimage` profile at 0.07675, seed 0, cleared
|
||||
both in the official OpenAI Content Provenance API. The Flare output measured
|
||||
37.48 dB PSNR / 0.9329 SSIM and the Sunburst output 37.01 dB / 0.9179 against
|
||||
their sources. Those fidelity numbers describe two neutral studio-photo
|
||||
carriers, not a content-wide quality estimate, and the source-positive/output-negative
|
||||
oracle pairs do not replace the wider OpenAI calibration that set the floor.
|
||||
|
||||
| oracle | fixture size | detected at | clean from |
|
||||
|---|---|---|---|
|
||||
| openai.com/verify | 1.57 MP | 0.06 | 0.08 |
|
||||
@@ -2078,7 +2320,7 @@ spread): 0.06 + (0.0525 - 0.015) = 0.0975, rounded up to **0.1**. Shipped as
|
||||
`QWEN_ZIMAGE_META_STRENGTH`; `--vendor meta` / `InvisibleOptions.vendor` names
|
||||
the cohort explicitly, implying the scrub runs (naming the cohort asserts the
|
||||
watermark is present). sdxl-zimage has no measured Meta rung and an
|
||||
explicit meta vendor there falls to the conservative unknown 0.25. The default
|
||||
explicit meta vendor there falls to the conservative unknown 0.50. The default
|
||||
resolution-adaptive curve (~0.1305 at 2.56 MP) also clears every measured
|
||||
source, so default behavior needed no change. Oracle verdicts carry a
|
||||
generation ID and creation timestamp embedded in the watermark payload; both
|
||||
|
||||
+133
-2
@@ -42,6 +42,112 @@ The old Google SynthID Detector portal does not work. On 2026-09-07, the
|
||||
official `labs.google/synthid` entry point redirected a signed-in browser to a
|
||||
Google 404. Historical corpus rows may still name `synthid-portal`; that value
|
||||
describes how an old result was obtained and is not an operational surface.
|
||||
Google's newer Search verification entry point did not provide an independent
|
||||
oracle allowance in the session tested on 2026-09-13. Lens accepted the
|
||||
metadata-stripped, previously Gemini-positive image
|
||||
`sha256:c86feaf788df2c423eeb53fcaee52b74f12a8e83829f94414e6dec2966e6e62e`,
|
||||
but ordinary Lens results only described and matched the visible content. An
|
||||
explicit `Is this made with AI? Check whether it contains SynthID.` request in
|
||||
Search AI Mode invoked the technical verifier and returned `The verification
|
||||
tool's daily limit has been exceeded.` after that session's Gemini image
|
||||
verification allowance was already exhausted. Record this as a quota refusal,
|
||||
not a watermark verdict and not a second quota pool.
|
||||
|
||||
A second Search check on 2026-09-15 used a real Qwen 0.15 no-face/no-text
|
||||
variant rather than a control. Lens and Search AI Mode accepted the upload, but
|
||||
both answered from visible-content reasoning and Web matches instead of
|
||||
returning a SynthID result. The exact Google-AI verification question even
|
||||
misattributed the file to an Imagen 4 Fast demonstration and invented a matching
|
||||
prompt. Search is therefore neither a usable independent oracle nor a substitute
|
||||
verdict when its response does not explicitly report the verification tool's
|
||||
SynthID outcome.
|
||||
|
||||
Fresh independent-session rechecks on 2026-09-14 split: one returned `detected`
|
||||
for the same control while another returned `unable to process your request due
|
||||
to a tool error or quota limit`. A later session returned five usable real-variant
|
||||
verdicts and refused the sixth, while the other tested sessions still refused real
|
||||
variants. Recovery is therefore not a global calendar event. Google [documents approximately ten
|
||||
image checks in a rolling 24-hour window](https://support.google.com/gemini/answer/16722517?hl=en-GB),
|
||||
but that approximation is orientation rather than a per-session reset schedule.
|
||||
When a campaign is conserving that allowance, probe each explicitly selected
|
||||
session with its next real pending variant and record a refusal without assigning
|
||||
a watermark verdict. Reserve a positive-control probe for surface diagnosis, not
|
||||
ordinary batch allocation.
|
||||
|
||||
A second independent-session sweep used one real pending variant per session and
|
||||
again obtained no watermark verdict: three responses explicitly reported a temporary
|
||||
verification quota, while one reported a verification tool-call error. Four completed immutable result batches preserve
|
||||
the exact uploads and settled responses. Twenty-four newly generated
|
||||
no-face/no-text scene variants form six four-rung ladders; the six lowest rungs
|
||||
were prepared in immutable batches, and none had been submitted at that point.
|
||||
The later completed sweep followed the planned lowest-rung-first escalation. Do
|
||||
not treat batch preparation or visual quality scores as SynthID results.
|
||||
|
||||
A session recovered earlier than the estimate derived from the oldest usable call
|
||||
preserved locally. Its next real pending
|
||||
variant, Chroma 0.16 on the face carrier, returned `detected`; the immutable batch
|
||||
is complete and hash-verified. This demonstrates why estimated rolling-window
|
||||
times are retry guidance rather than reset timestamps, and closes the tested
|
||||
face bracket at `0.16 detected -> 0.22 not_detected` without spending a duplicate
|
||||
request on the already settled 0.22 file.
|
||||
|
||||
The same recovered session returned `not_detected` for Chroma 0.40 on the Cyrillic
|
||||
carrier, then explicitly quota-refused SDXL CJK 0.35. The other selected sessions
|
||||
were checked with real pending CJK variants, not controls, and returned quota or
|
||||
tool limitations. One settled response exposed a request-specific instruction to
|
||||
wait 18 hours. Treat that as the retry point for that session and request class, not as a replacement
|
||||
for Google's documented approximate rolling 24-hour allowance or a universal
|
||||
reset time. All four sessions were unavailable again at their latest checks.
|
||||
Because one response did not distinguish service failure from quota, its real
|
||||
Chroma CJK 0.40 candidate was retried. This time the
|
||||
verifier explicitly named a temporary image-analysis quota. The refusal was
|
||||
recorded and the same bytes were prepared in a new immutable retry batch; no
|
||||
watermark verdict was inferred. One session was probed once more with a
|
||||
real Qwen 0.15 no-face/no-text candidate. It returned a verification-tool
|
||||
technical error rather than a SynthID result. That failure was recorded as
|
||||
`refused`, the batch was hash-verified complete, and a fresh immutable retry
|
||||
batch was prepared for the evidence-based session window.
|
||||
|
||||
All four sessions returned usable results again on 2026-09-15, earlier than the
|
||||
previous retry guidance. The remaining real
|
||||
variants were submitted adaptively without controls. SDXL CJK remained detected
|
||||
at 0.35, 0.40, and 0.45 and cleared at 0.50. Chroma CJK remained detected at
|
||||
0.40 and cleared at 0.50. The two no-face/no-text carriers cleared Qwen at 0.15;
|
||||
Chroma cleared the flat carrier at 0.12 and the photographic carrier at 0.30
|
||||
after detections at 0.12 and 0.20; SDXL cleared the photographic carrier at 0.15
|
||||
and the quality-qualified flat carrier at 0.35. One quota refusal and one tool-call
|
||||
failure encountered while routing SDXL CJK 0.40 were kept as separate completed
|
||||
batches; another session then returned the usable `detected` result for the same
|
||||
upload hash. Every settled batch passed immutable-source, upload, manifest, and
|
||||
result verification. The observed early recovery reinforces that a retry time is
|
||||
guidance for the next real request, not a promised reset.
|
||||
|
||||
A same-day production-boundary replication then used three new independent
|
||||
Gemini-generated controls: busy CJK plus many faces, flat CJK without faces, and a
|
||||
portrait plus dense CJK. Only processed variants were submitted. Across Qwen 0.35,
|
||||
Chroma 0.50, and SDXL 0.50, all nine profile-by-carrier cells returned
|
||||
`not_detected`. The night-market lower rungs Chroma 0.40 and SDXL 0.45 also returned
|
||||
`not_detected`; they do not supersede the earlier harder-source detections at those
|
||||
rungs. Two sessions returned explicit quota failures during this pass, one
|
||||
candidate retry also hit quota, and a byte-normalized duplicate after the usable
|
||||
SDXL result returned a tool-call error. Those four calls were recorded as
|
||||
`unreachable`, not spliced into the nine usable verdicts. All result batches were
|
||||
hash-verified.
|
||||
Google did not expose a reset timestamp: the returned quota text supports only
|
||||
waiting and retrying, not a universal 24-hour claim.
|
||||
|
||||
The documented Vertex AI Media Studio verification flow was also unavailable
|
||||
in the live console on 2026-09-14. Its old console URL redirected to Agent Media
|
||||
Studio after the Vertex-to-Agent Platform rename. The current Image playground
|
||||
was inspected in eligible configurations; its task menu exposed generation and image
|
||||
editing but no watermark upload or validation action. The deprecated Python SDK
|
||||
can still resolve `imageverification@001` to its publisher-model endpoint. A
|
||||
2026-09-15 request reached `predict`, but the real Qwen 0.15 variant returned
|
||||
HTTP 403 `The caller does not have permission` in both tested configurations. The current
|
||||
Python reference still documents `WatermarkVerificationModel.verify_image`, but
|
||||
the backing model was access-restricted in both tested configurations and the SDK path is
|
||||
past its announced removal date. Recheck the live console and a real pending
|
||||
variant before adding Search or Agent Media Studio to the surface table.
|
||||
Adobe Inspect and local C2PA, DWT-DCT, TrustMark, and research detectors are
|
||||
useful evidence readers, not interchangeable provider pixel-watermark oracles.
|
||||
|
||||
@@ -306,6 +412,17 @@ response settles as `indeterminate`, not clean. Meta's live positive wording
|
||||
changed from `AI signatures from Meta were found` to `AI signatures were found`
|
||||
by 2026-09-07; both exact forms remain accepted.
|
||||
|
||||
Isolation is a transport property, not a verdict requirement. On 2026-09-15,
|
||||
the isolated OpenAI runner returned one generic error and two Cloudflare
|
||||
refusals for a prepared three-file batch. Uploading those exact prepared hashes
|
||||
through an authenticated first-party browser session reached the official verifier
|
||||
and returned explicit SynthID results. A later Chroma 0.12 upload remained in
|
||||
`Verifying` for more than 90 seconds and was recorded as indeterminate rather
|
||||
than clean. The official API then returned HTTP 200 and SynthID `not_detected`
|
||||
for the same processed source hash. Keep each route in its own immutable batch;
|
||||
do not splice an isolated-runner failure and a first-party browser or API verdict into
|
||||
one response.
|
||||
|
||||
## OpenAI API
|
||||
|
||||
The one automated path calls the hardened metadata-independent adapter already
|
||||
@@ -332,6 +449,20 @@ OpenAI documents `not_detected` as absence of a supported signal, not proof that
|
||||
content was not AI-generated. The API must not become an adaptive removal loss
|
||||
or automatic search loop. See [`synthid.md`](synthid.md) for the oracle boundary.
|
||||
|
||||
The fresh no-face/no-text calibration on 2026-09-15 used three independent
|
||||
official API requests after the public Web surface stalled. All three returned HTTP 200
|
||||
with C2PA `not_present`: Chroma 0.12 and SDXL 0.06 were SynthID `not_detected`,
|
||||
while SDXL 0.04 was `detected`. Together with settled Web results for the same
|
||||
carrier, the tested 0.02-grid brackets are Qwen `0.04 detected -> 0.06
|
||||
not_detected`, Chroma `0.10 detected -> 0.12 not_detected`, and SDXL `0.04
|
||||
detected -> 0.06 not_detected`. The generated source control was not uploaded.
|
||||
|
||||
A separate face-plus-multilingual-text carrier then returned Qwen `0.04
|
||||
detected -> 0.06 not_detected`, Chroma 0.10 `not_detected`, and SDXL 0.04
|
||||
`not_detected`. The lower Chroma and SDXL results did not change the global
|
||||
candidate because the no-face/no-text carrier remained the limiting OpenAI
|
||||
stratum.
|
||||
|
||||
## Microsoft API
|
||||
|
||||
The direct Microsoft path uses the Azure Content Provenance Detection preview
|
||||
@@ -376,8 +507,8 @@ error rate estimates:
|
||||
|
||||
| Surface | Route and repetitions | Recorded result |
|
||||
| --- | --- | --- |
|
||||
| Gemini Web | User's real Chrome, three authenticated account slots | 3/3 `not_detected`; Gemini reported no reliable Google AI signal. |
|
||||
| OpenAI API | Three named API-key slots, direct transport | 3/3 SynthID `not_detected`; C2PA `not_present`; all responses were HTTP 200. |
|
||||
| Gemini Web | Official browser verifier, three independent authenticated sessions | 3/3 `not_detected`; Gemini reported no reliable Google AI signal. |
|
||||
| OpenAI API | Official API, three independent requests | 3/3 SynthID `not_detected`; C2PA `not_present`; all responses were HTTP 200. |
|
||||
| OpenAI Web | ThorData GB, isolated Playwright | `indeterminate`; after accepting the prepared upload, the provider returned `Something went wrong. Please try again.` |
|
||||
| Microsoft API | Direct API, private hash-matched Azure blob | HTTP 202, operation `Succeeded`; Watermark `not_detected`, C2PA `absent`, provider outcome `NoProvenanceDetected`. |
|
||||
| Microsoft Web | ThorData US, isolated Playwright | `not_detected`; the page's exact settled label was `Inconclusive`. |
|
||||
|
||||
+33
-6
@@ -153,6 +153,32 @@ 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`.
|
||||
|
||||
Specialized callers can inspect or apply the clean-room local Paint pixel
|
||||
operation directly. This is deliberately separate from the high-level diffusion
|
||||
route. Passing the UUID signed in pristine C2PA makes agreement with that UUID a
|
||||
write precondition:
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.microsoft_invismark import (
|
||||
detect_local_invismark,
|
||||
disrupt_local_invismark,
|
||||
invismark_id_from_c2pa,
|
||||
)
|
||||
|
||||
expected = invismark_id_from_c2pa("paint.png")
|
||||
detection = detect_local_invismark("paint.png")
|
||||
if expected is not None and detection is not None:
|
||||
result = disrupt_local_invismark(
|
||||
"paint.png",
|
||||
"clean.png",
|
||||
expected_watermark_id=expected,
|
||||
)
|
||||
```
|
||||
|
||||
The direct call needs the `pixels` extra. It writes atomically only after the
|
||||
encoded output no longer decodes as a valid local payload. The result does not
|
||||
claim acceptance by a proprietary Microsoft detector.
|
||||
|
||||
The complete field set, with the shipped defaults:
|
||||
|
||||
| Field | Default | Effect |
|
||||
@@ -739,8 +765,8 @@ engine = InvisibleEngine(pipeline="sdxl-zimage")
|
||||
|
||||
The `qwen-zimage` extra is required for every profile, including `auto`: each
|
||||
concrete engine runs the same DiffSynth Z-Image face stage. `pipeline="auto"`
|
||||
selects chroma-zimage for Microsoft provenance and qwen-zimage
|
||||
otherwise, after the vendor is known and before strength resolution.
|
||||
selects sdxl-zimage for Google provenance, chroma-zimage for Microsoft, and
|
||||
qwen-zimage otherwise, after the vendor is known and before strength resolution.
|
||||
|
||||
The opt-in verified-text stage uses the same `text_manifest` argument as the CLI:
|
||||
|
||||
@@ -754,10 +780,11 @@ engine.remove_watermark(
|
||||
|
||||
Install `remove-ai-watermarks[text-restoration]`. The manifest schema and safety
|
||||
constraints are documented in the CLI guide. The engine verifies its decoded RGB
|
||||
hash before loading the diffusion models. Qwen and Chroma reconstruct the donor with
|
||||
the VAE already loaded for the one profile selected by `auto`; no second generative
|
||||
profile runs. The engine rejects SDXL, downscaling, and postprocessing combinations
|
||||
that were not evaluated. Tiling is also rejected because the combined
|
||||
hash before loading the diffusion models. Qwen, SDXL, and Chroma reconstruct the
|
||||
donor with the VAE already loaded for the selected profile; no second generative
|
||||
profile runs. The engine turns an unset `adaptive_polish` off in verified-text mode
|
||||
and rejects explicit postprocessing or downscaling combinations that were not
|
||||
evaluated. Tiling is also rejected because the combined
|
||||
tiled-restoration path has no provider-oracle calibration. `InvisibleOptions` exposes
|
||||
the same field for `remove_all`; after a visible-stage edit, the manifest must be built
|
||||
against the staged pixels rather than the pristine source.
|
||||
|
||||
@@ -255,13 +255,15 @@ lists it: uv serves its cached pre-release view of the index. Re-run with a
|
||||
fresh cache (`UV_CACHE_DIR=$(mktemp -d)`) before treating it as a release
|
||||
failure.
|
||||
|
||||
The ComfyUI sync run can fail the same way on its own: it resolves the version
|
||||
from the PyPI JSON API (which updates first) but installs the test dependency
|
||||
with pip against the simple index, whose CDN edges lag by minutes. The
|
||||
`distribute.yml` waiter also has a clock: 0.40.0's node suite finished after
|
||||
the 15-minute poll, so the job now waits 30 minutes and then accepts a
|
||||
registry row that already requires this library version. Rerun the failed
|
||||
`comfyui` job only when the registry still has no such row.
|
||||
The ComfyUI sync resolves the version from the PyPI JSON API but installs its
|
||||
test dependency through pip's simple index, whose CDN edges can lag by
|
||||
minutes. `distribute.yml` gates every downstream job on both APIs listing the
|
||||
release, so it cannot dispatch that race again. The ComfyUI waiter itself has
|
||||
a clock: 0.40.0's node suite finished after the 15-minute poll, so it now
|
||||
waits 30 minutes and then accepts a registry row that already requires this
|
||||
library version. For an older distribution that started before the simple
|
||||
index caught up, rerun its failed `comfyui` job only when the registry still
|
||||
has no such row.
|
||||
|
||||
The automatic `verify-release.yml` run after `distribute.yml` checks the
|
||||
Hugging Face Space pin. When `RAIW_HF_SPACE_TOKEN` is set, that pin lands
|
||||
|
||||
+31
-20
@@ -148,16 +148,17 @@ 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.
|
||||
The `invisible` command uses diffusion regeneration. A specialized Python API can
|
||||
instead apply decoder-guided disruption to a positively identified local
|
||||
Microsoft Paint payload.
|
||||
|
||||
Current pipeline values, all CUDA-only:
|
||||
Current diffusion pipeline values, all CUDA-only:
|
||||
|
||||
- `qwen-zimage`, the default;
|
||||
- `sdxl-zimage`, the same recipe and the same face stage on an SDXL global pass;
|
||||
- `chroma-zimage`, the same face stage on a Chroma1 global pass;
|
||||
- `auto`, chroma-zimage for Microsoft provenance, otherwise qwen-zimage.
|
||||
- `auto`, sdxl-zimage for Google provenance, chroma-zimage for Microsoft,
|
||||
otherwise qwen-zimage.
|
||||
|
||||
The `controlnet`, `sdxl`, `qwen` and `default` values were removed. A retired name
|
||||
is rejected at parse time rather than remapped onto a surviving profile.
|
||||
@@ -170,7 +171,11 @@ and `scripts/synthid_runtime/`.
|
||||
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.
|
||||
does not assert SynthID. GPT Image 2.5 Flare and Sunburst were verified on
|
||||
2026-09-15: both API outputs carried valid OpenAI C2PA with
|
||||
`c2pa.watermarked.unbound` and exercised the existing model-independent OpenAI
|
||||
route. Automatic routing selected `qwen-zimage` with the OpenAI strength cohort,
|
||||
and both outputs cleared the official OpenAI SynthID check.
|
||||
|
||||
Provider verifiers and provenance APIs are reserved for development-time
|
||||
calibration. They are not exposed through the installed CLI, the top-level
|
||||
@@ -180,19 +185,24 @@ Microsoft Paint can name `com.microsoft.invismark.1` in a C2PA soft-binding
|
||||
assertion. Inspection reports both that exact algorithm and its signed `value`,
|
||||
which Paint uses as the identifier carried by the pixel watermark, and emits an
|
||||
additive `invismark` signal so callers can select pixel removal without parsing
|
||||
the generic `soft_binding` detail. Photos uses a parallel local writer path.
|
||||
Metadata stripping removes only the embedded manifest; `invisible` and `all`
|
||||
guarantee the supported InvisMark removal contract by regenerating the pixel
|
||||
layer as well. The project has no validated local InvisMark decoder, so local
|
||||
inspection cannot independently verify the output. Microsoft's official
|
||||
[Content Provenance Detection API](https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection)
|
||||
is the external oracle: it reports pixel `Watermark` and embedded `C2PA` results
|
||||
separately; a control-positive, output-negative pair is the available per-file
|
||||
verification path. The API needs Azure credentials; the page a human can check
|
||||
without an account is <https://ai.azure.com/nextgen/validate>. That page is the
|
||||
public provider oracle used for the measured removal certification. Its clean
|
||||
outcome is rendered as `Inconclusive`; the authenticated API exposes separate
|
||||
watermark and C2PA fields but is not a different class of certification.
|
||||
the generic `soft_binding` detail.
|
||||
|
||||
For Paint's local `Watermarker.dll` format, the reader recovers the repeated
|
||||
144-bit message and requires its prefix, GUID byte order, checksum, and per-bit
|
||||
support. A caller can additionally require agreement with the UUID signed in
|
||||
pristine C2PA. Only a positive local payload uses decoder-guided bit disruption;
|
||||
the encoded output is read back and must no longer contain a valid local payload
|
||||
before it is published. A Microsoft
|
||||
soft-binding declaration without a matching local payload, including the
|
||||
committed cloud Image Creator fixture, is rejected by this decoder. The CLI and
|
||||
high-level API retain their Chroma diffusion route. Metadata stripping alone still
|
||||
removes only the manifest.
|
||||
|
||||
This is local payload invalidation, not external removal certification. The
|
||||
available Azure Content Provenance Detection API did not detect a known-positive
|
||||
local-format control, so it cannot certify this direct output. Photos and future
|
||||
Paint builds may use another pixel format and therefore remain on the regeneration
|
||||
fallback unless this decoder positively identifies them.
|
||||
|
||||
Meta Muse Image stamps every output with Content Seal, a proprietary invisible
|
||||
pixel watermark, and ships no visible mark (the legacy `Imagined with AI`
|
||||
@@ -243,7 +253,8 @@ not a universal clean verdict.
|
||||
| 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 |
|
||||
| Meta Muse Image | None on Muse output (legacy `Imagined with AI` unregistered) | Diffusion regeneration for Content Seal, oracle-verified on the default profile | XMP IPTC `trainedAlgorithmicMedia` companion tag; no local Content Seal decoder |
|
||||
| Microsoft Paint and Photos | None registered | External Microsoft oracle for InvisMark; no validated local decoder | Paint C2PA soft-binding algorithm and identifier |
|
||||
| Microsoft Paint local `Watermarker.dll` format | None registered | Local repeated-payload decoder and decoder-guided disruption; no usable external oracle for this format | Paint C2PA soft-binding algorithm and matching UUID |
|
||||
| Microsoft cloud and other InvisMark variants | None registered | Chroma regeneration fallback | Microsoft C2PA attribution and soft-binding declaration |
|
||||
| Microsoft image outputs (measured variant) | One top-right white AI-badge variant | No registered pixel decoder | C2PA attribution |
|
||||
| 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 |
|
||||
|
||||
@@ -23,6 +23,95 @@ are explicit negatives, a strict rule can abstain, and any watermark claim uses
|
||||
an independent oracle. CLIP content embeddings and the 124-d origin-locked
|
||||
residual bank are different features for different jobs.
|
||||
|
||||
### Phase-free spectral pipeline probe, 2026-09-15
|
||||
|
||||
Mark's public
|
||||
[`bigdatamark/synthid-research`](https://huggingface.co/datasets/bigdatamark/synthid-research)
|
||||
corpus pairs 300 prompts across Gemini 2048-pixel JPEG, OpenAI 1254-pixel PNG,
|
||||
and FLUX 1280-pixel PNG exports. It is useful for testing source-pipeline
|
||||
features, but it is not a causal SynthID corpus: renderer, dimensions, encoder,
|
||||
and watermark state all change together.
|
||||
|
||||
The paper-inspired probe in [`scripts/spectral_pipeline_probe.py`](../scripts/spectral_pipeline_probe.py)
|
||||
discards Fourier phase, subtracts each image's radial log-magnitude envelope,
|
||||
pools luma plus two opponent-color spectra, and fits a dual ridge classifier.
|
||||
Matching prompt ids stay together in a grouped five-fold evaluation. The
|
||||
256-pixel / 32x32-grid / ridge-1000 configuration was selected after an
|
||||
exploratory comparison on these same folds, so this is a development result,
|
||||
not blind validation. Its complete-corpus confusion matrix was:
|
||||
|
||||
| Actual pipeline | Gemini | OpenAI | FLUX |
|
||||
| --- | ---: | ---: | ---: |
|
||||
| Gemini | 220 | 25 | 55 |
|
||||
| OpenAI | 33 | 219 | 48 |
|
||||
| FLUX | 75 | 48 | 177 |
|
||||
|
||||
Accuracy was 616/900 (68.4%), against 33.3% chance. Applying each transform
|
||||
only to held-out rows gave 67.3% after a two-pixel top-left crop, 67.8% after a
|
||||
95% resize, 67.9% after JPEG quality 75, and 68.4% after Gaussian blur radius
|
||||
0.7. This is materially more transform-stable than the origin-locked lattice,
|
||||
but it remains a source/export classifier. There are no photographs, foreign
|
||||
generators beyond FLUX, independently generated 1K Gemini files, or same-image
|
||||
SynthID on/off pairs in the corpus. No runtime detector, threshold, or product
|
||||
verdict is justified by this result.
|
||||
|
||||
The representation follows the frequency-domain premise of Zhang et al.,
|
||||
[*Detecting and Simulating Artifacts in GAN Fake Images*](https://arxiv.org/abs/1907.06515),
|
||||
not their ResNet-34 implementation. Their own post-processing results also
|
||||
support treating augmentation and architecture shift as first-class tests,
|
||||
rather than reading an untransformed source-classification score as watermark
|
||||
evidence.
|
||||
|
||||
### Abstaining fused source classifier, 2026-09-15
|
||||
|
||||
The follow-up experiment combined the existing 124-d forensic descriptor with
|
||||
the 16x16 phase-free spectral grid in a class-balanced ridge head. Its three
|
||||
outputs are `openai`, `google`, and `unknown`; a provider result is emitted only
|
||||
when it beats both the other provider and `unknown` by a margin selected on a
|
||||
separate calibration split. The public implementation is
|
||||
[`scripts/source_pipeline_classifier.py`](../scripts/source_pipeline_classifier.py).
|
||||
It includes training, strict pickle-free model loading, and JSON inference, but
|
||||
no fitted weights: the experimental training freeze contains inputs that are
|
||||
not redistributable. This keeps the code reproducible without publishing a
|
||||
derivative artifact whose data provenance does not permit redistribution.
|
||||
|
||||
Penalty 100 and margin 0.6392407698660646 were selected using only the training
|
||||
and calibration partitions. The 2,669-image locked internal test and Mark's
|
||||
900-image public set were not used for feature, penalty, or threshold selection.
|
||||
Feature-extraction failures count as `unknown` in every result below.
|
||||
|
||||
| Evaluation | Accuracy | OpenAI recall | Google recall | Unknown recall |
|
||||
| --- | ---: | ---: | ---: | ---: |
|
||||
| Locked internal, 2,669 images | 84.2% | 60.9% | 48.8% | 95.8% |
|
||||
| Mark original exports, 900 images | 90.1% | 76.3% | 94.3% | 99.7% |
|
||||
| Mark crop 2 px | 89.8% | 76.7% | 94.3% | 98.3% |
|
||||
| Mark resize 95% | 59.7% | 81.7% | 0.0% | 97.3% |
|
||||
| Mark JPEG quality 75 | 33.3% | 0.0% | 0.0% | 100.0% |
|
||||
| Mark blur radius 0.7 | 68.3% | 42.3% | 63.0% | 99.7% |
|
||||
|
||||
The untouched-export result is useful source-attribution evidence, but the
|
||||
transforms decide the claim. Resizing eliminated the Google branch, JPEG made
|
||||
the classifier abstain on every row, and blur materially reduced both provider
|
||||
recalls. The locked unknown false-attribution rate was 4.2%, versus 0.9% on the
|
||||
calibration split. This is therefore an inspectable research baseline, not a
|
||||
runtime detector or a product watermark verdict.
|
||||
|
||||
Training requires hash-disjoint `train` and `calibration` directories, each
|
||||
with `openai`, `google`, and `unknown` subdirectories:
|
||||
|
||||
```bash
|
||||
uv run python scripts/source_pipeline_classifier.py train \
|
||||
/path/to/train /path/to/calibration --model-out .local-eval/source-pipeline.npz
|
||||
|
||||
uv run python scripts/source_pipeline_classifier.py predict \
|
||||
.local-eval/source-pipeline.npz image.png
|
||||
```
|
||||
|
||||
A stronger public release needs a publication-cleared multi-era training set
|
||||
and a causal marked/unmarked contrast. Mark's same-prompt triples remain an
|
||||
external source/export test, not evidence that the fitted features read the
|
||||
SynthID payload.
|
||||
|
||||
Krawetz's Gemini-chat TPR critique is a verifier-quality claim, not a
|
||||
feature we can ship. [Lead Stories, 2026-07](https://leadstories.com/analysis/2026/07/google-gemini-synthid-detector-confuses-results-within-same-chat.html)
|
||||
documented Gemini repeating the first file's SynthID verdict inside a
|
||||
|
||||
@@ -95,10 +95,13 @@ Two real but non-local verifier paths surfaced. The historical Vertex SDK
|
||||
exposed `WatermarkVerificationModel.from_pretrained("imageverification@001")`.
|
||||
The class still exists in `google-cloud-aiplatform` 1.165.1, but warns that it
|
||||
was deprecated 2025-06-24 with removal scheduled for 2026-06-24. Calls in two
|
||||
projects with Vertex enabled now reject the publisher model id as invalid. A
|
||||
repository using `response.watermark_detected` is also stale: the actual SDK
|
||||
property was `watermark_verification_result`, backed by the prediction's
|
||||
`decision` field.
|
||||
eligible Vertex configurations were rechecked on 2026-09-15 with a real processed
|
||||
variant and correctly attributed user credentials. The publisher
|
||||
model resolved, but both `predict` calls returned HTTP 403 `The caller does not
|
||||
have permission`. The surviving class reference therefore does not establish
|
||||
public access to the backing model. A repository using
|
||||
`response.watermark_detected` is also stale: the actual SDK property was
|
||||
`watermark_verification_result`, backed by the prediction's `decision` field.
|
||||
|
||||
A newer unlisted upload route,
|
||||
`POST https://synthid.googleapis.com/upload/v1:verifyContent`, is live enough
|
||||
|
||||
@@ -775,6 +775,20 @@ 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.
|
||||
|
||||
**GPT Image 2.5 compatibility, verified 2026-09-15.** One fresh low-quality
|
||||
1024 x 1024 API generation from each released model, `gpt-image-2.5-flare` and
|
||||
`gpt-image-2.5-sunburst`, carried a valid bound OpenAI C2PA claim generated by
|
||||
`OpenAI Media Service API`, with `trainedAlgorithmicMedia` and the action set
|
||||
`created, converted, watermarked.unbound`. `identify` reported high-confidence
|
||||
OpenAI provenance plus SynthID, `has_invisible_target` returned true, and the
|
||||
automatic strength cohort resolved to OpenAI for both files. After C2PA was
|
||||
removed without changing decoded pixels, the official OpenAI Content Provenance
|
||||
API still returned SynthID `detected` for both sources. The complete shipped
|
||||
`qwen-zimage` profile at strength 0.07675 and seed 0 then produced two outputs
|
||||
for which the same metadata-independent API returned SynthID `not_detected`.
|
||||
This is one controlled source per model and proves the current routing and
|
||||
operating point on those two sources; it is not a provider-wide removal rate.
|
||||
|
||||
### 4.3 Other vendors
|
||||
|
||||
- **Kakao** (South Korea): SynthID adopter as of May 2026 (Google announcement)
|
||||
|
||||
@@ -501,6 +501,12 @@ invisible marks, detection/removal parity, or historical crossfire.
|
||||
The scan runs files in parallel and checkpoints each completed row beside the
|
||||
requested CSV as `.progress.jsonl`; repeat the same command to resume an
|
||||
interrupted run, and use a distinct report path for every code/corpus snapshot.
|
||||
The visible counterpart, `scripts/visible_positives.py`, records both strict and
|
||||
metadata-corroborated registry results in a resumable JSONL. With `--sheets` it
|
||||
adds blinded, native-resolution top and bottom bands from quiet metadata cohorts
|
||||
and from an unbiased quiet sample. The latter keeps a provider with no mapped
|
||||
metadata from being excluded by construction; neither stratum is ground truth
|
||||
until the pixels are adjudicated.
|
||||
|
||||
Run a fresh-delta discovery review weekly after the retained corpus is
|
||||
refreshed, using `corpus_gap_scan.py --since` with an overlap from the preceding
|
||||
|
||||
@@ -31,6 +31,15 @@ Exact claim-generator mappings also cover `Grok Imagine` as xAI Grok Imagine
|
||||
and `Suno` as Suno. Both can carry a valid AI source claim without a separately
|
||||
readable issuer, so issuer-only attribution leaves the platform blank.
|
||||
|
||||
Some signers expose the product in `signature_info` while retaining a generic
|
||||
claim generator. xAI Grok Imagine is recognized from that signed identity even
|
||||
when the generator is only `c2pa-rs`; Producer.ai is handled the same way.
|
||||
FastVid has an exact claim-generator mapping. SPRING (SG) PTE. LTD. is retained
|
||||
as the signer on AI-source claims without guessing which of its products created
|
||||
the file. TikTok, ByteDance, CapCut, Anthropic Claude file provenance, and vivo
|
||||
Camera are neutral signer or capture attributions: without an AI source assertion,
|
||||
none of them changes the AI verdict.
|
||||
|
||||
**OpenRouter raster metadata survey (2026-09-02):** a paid Recraft V3 API
|
||||
output carried a valid C2PA claim with `claim_generator` `recraft.ai`, a
|
||||
`c2pa.created` action, and `trainedAlgorithmicMedia`; that exact generator now
|
||||
@@ -39,7 +48,7 @@ Riverflow V2 Fast API outputs carried no locally readable C2PA, XMP, EXIF, or
|
||||
container metadata. Their absence is evidence about those exact files and API
|
||||
routes, not a claim that every Krea or Riverflow export is metadata-free.
|
||||
|
||||
**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 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"` 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.
|
||||
**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 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"` 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`**. The broader **issuer** `b"Bytedance Pte"` is registered only as a neutral signer because that same entity also signs non-AI CapCut edits; it has no AI platform and cannot assert an AI verdict. Keying the product attribution on the `Dreamina` generator token remains the precise path.
|
||||
- **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`). A JSON `generationParams.modelName` value is parsed structurally and reduced to a recognized model name, rather than exposing its surrounding prompt JSON as a platform label.
|
||||
- **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 signature scheme (DETECTED by `metadata.xai_signature`, built 2026-05-26, expanded 2026-09-13).**
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
# 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.40.2"
|
||||
version = "0.40.6"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
|
||||
+5
-3
@@ -9,7 +9,7 @@ explicitly names a tracked canonical result.
|
||||
|
||||
| Script | Purpose |
|
||||
| --- | --- |
|
||||
| `corpus_gap_scan.py` | Inventory `identify`, metadata markers, unattributed outcomes, errors, and gap candidates over a full or date-bounded corpus. |
|
||||
| `corpus_gap_scan.py` | Inventory `identify`, metadata markers, normalized C2PA review fields, unattributed outcomes, errors, and gap candidates over a full or date-bounded corpus. Its schema-versioned checkpoint resumes safely after interruption. |
|
||||
| `detection_timing.py` | Record per-method metadata and verdict timings. |
|
||||
| `detection_timing_report.py` | Aggregate timing records by method and segment. |
|
||||
| `fidelity_metrics.py` | Compute objective image-fidelity metrics for paired outputs. |
|
||||
@@ -26,7 +26,7 @@ explicitly names a tracked canonical result.
|
||||
| `sidecar_regression.py` | Compare current identification with recorded sidecars. |
|
||||
| `smoke_matrix.py` | Exercise CLI parameter choices on real local data. |
|
||||
| `video_fidelity_probe.py` | Compare delivered video fidelity with its source. |
|
||||
| `visible_eval.py` | Benchmark registered visible-mark detectors. |
|
||||
| `visible_eval.py` | Benchmark the adjudicated registry subset and expose unmeasured marks as scope-zero rows. |
|
||||
| `visible_removal_audit.py` | Audit visible-removal results over a local corpus. |
|
||||
| `watermark_benchmark.py` | Run hash-pinned image cases while keeping detection, removal observation, and fidelity separate. |
|
||||
| `watermark_benchmark_report.py` | Aggregate repeated benchmark results with artifact-aware counts and separate cold/warm timing. |
|
||||
@@ -46,10 +46,12 @@ explicitly names a tracked canonical result.
|
||||
| `vendor_mark_calibrate.py` | Calibrate a candidate vendor text detector. |
|
||||
| `visible_alpha_solve.py` | Rebuild visible-watermark alpha assets from controlled captures and the cleared Qwen symbol fixture. |
|
||||
| `visible_groundtruth.py` | Consolidate blinded contact-sheet labels into ground truth. |
|
||||
| `visible_positives.py` | List corpus images carrying a registered visible mark. |
|
||||
| `visible_positives.py` | Resumably scan registered marks plus metadata cohorts, and build blinded native-detail bands for uncovered and unbiased quiet samples. |
|
||||
| `visible_recall_sample.py` | Build an unbiased blinded sample for recall measurement. |
|
||||
| `visible_sheets.py` | Build blinded contact sheets for relaxation candidates. |
|
||||
| `watermark_benchmark_cohort.py` | Build the deterministic synthetic DWT-DCT and TrustMark cohort consumed by `watermark_benchmark.py`. |
|
||||
| `spectral_pipeline_probe.py` | Measure a prompt-grouped, phase-free spectral source-pipeline hypothesis without presenting it as SynthID detection. |
|
||||
| `source_pipeline_classifier.py` | Train or run an abstaining OpenAI/Google/unknown source-export classifier; research-only and not SynthID detection. |
|
||||
| `watermark_benchmark_real_cohort.py` | Build a publication-cleared, provider-paired and content-stratified real-image cohort. |
|
||||
| `retrain_photo_classify.py` | CPU-retrain the 2026-08-31 photo heads from a sha256-keyed cache pack, no images. |
|
||||
| `publish_photo_classify_hf.py` | Upload the photo-classify card and freeze weights to Hugging Face `wiltodelta/raiw-photo-classify`. Manual; the Action `publish-photo-classify-hf.yml` is the write-token path. |
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
"""Shared deterministic corpus walks and append-safe JSONL checkpoints."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import datetime
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Collection
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def corpus_day(path: Path, corpus: Path) -> datetime.date | None:
|
||||
"""Read the leading YYYY-MM-DD corpus segment, if present."""
|
||||
try:
|
||||
return datetime.date.fromisoformat(path.relative_to(corpus).parts[0])
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
|
||||
|
||||
def corpus_files(corpus: Path, since: datetime.date | None, *, suffixes: Collection[str] | None = None) -> list[Path]:
|
||||
"""Return a deterministic recursive walk, optionally date- and suffix-bounded."""
|
||||
if since is None:
|
||||
candidates = corpus.rglob("*")
|
||||
else:
|
||||
roots = [
|
||||
path
|
||||
for path in corpus.iterdir()
|
||||
if path.is_dir() and (day := corpus_day(path, corpus)) is not None and day >= since
|
||||
]
|
||||
candidates = (path for root in roots for path in root.rglob("*"))
|
||||
return sorted(
|
||||
path for path in candidates if path.is_file() and (suffixes is None or path.suffix.lower() in suffixes)
|
||||
)
|
||||
|
||||
|
||||
def repair_jsonl_tail(path: Path) -> None:
|
||||
"""Truncate an interrupted final JSONL fragment before another append."""
|
||||
if not path.exists():
|
||||
return
|
||||
with path.open("rb+") as stream:
|
||||
data = stream.read()
|
||||
if data and not data.endswith(b"\n"):
|
||||
stream.truncate(data.rfind(b"\n") + 1)
|
||||
+40
-30
@@ -26,9 +26,13 @@ Usage:
|
||||
--since 2026-09-01 --report .local-eval/detector-report-weekly.csv
|
||||
|
||||
Rows stream into ``<report>.progress.jsonl`` and resume by relative path after an
|
||||
interruption. Use a new report name for a new code/corpus snapshot, or pass
|
||||
``--restart`` when intentionally replacing the checkpoint. The final CSV is
|
||||
written atomically after every selected row is present.
|
||||
interruption. A schema version prevents an older checkpoint from silently
|
||||
producing blank fields after the report grows. Use a new report name for a new
|
||||
code/corpus snapshot, or pass ``--restart`` when intentionally replacing the
|
||||
checkpoint. The final CSV is written atomically after every selected row is
|
||||
present. C2PA candidates include normalized signer, claim-generator, action,
|
||||
source-type, and validation fields for aggregate review without reopening every
|
||||
manifest.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -40,16 +44,19 @@ import logging
|
||||
import os
|
||||
from collections import Counter
|
||||
from concurrent.futures import FIRST_COMPLETED, Future, ProcessPoolExecutor, wait
|
||||
from datetime import date
|
||||
from importlib.metadata import version
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from datetime import date
|
||||
|
||||
import click
|
||||
from _corpus_scan import corpus_day, corpus_files, repair_jsonl_tail
|
||||
from _plain_console import Console, Table
|
||||
|
||||
from remove_ai_watermarks.identify import _metadata_region as identify_metadata_region
|
||||
from remove_ai_watermarks.identify import identify
|
||||
from remove_ai_watermarks.identify import extract_provenance_evidence, identify_from_evidence
|
||||
from remove_ai_watermarks.metadata import (
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
@@ -90,6 +97,7 @@ MARKERS: tuple[bytes, ...] = (
|
||||
b"Stability AI",
|
||||
)
|
||||
REPORT_FIELDS: tuple[str, ...] = (
|
||||
"report_schema_version",
|
||||
"path",
|
||||
"suffix",
|
||||
"lib_version",
|
||||
@@ -98,18 +106,24 @@ REPORT_FIELDS: tuple[str, ...] = (
|
||||
"confidence",
|
||||
"watermarks",
|
||||
"signals",
|
||||
"c2pa_issuer",
|
||||
"c2pa_claim_generator",
|
||||
"c2pa_actions",
|
||||
"c2pa_source_type",
|
||||
"c2pa_validation",
|
||||
"integrity_clashes",
|
||||
"markers",
|
||||
"candidate_classes",
|
||||
"error",
|
||||
)
|
||||
_REPORT_SCHEMA_VERSION = "2"
|
||||
_DISPLAY_CANDIDATE_LIMIT = 50
|
||||
_WORKER_BATCH_SIZE = 16
|
||||
|
||||
|
||||
def _base_row(path: str, suffix: str, lib_version: str) -> dict[str, str]:
|
||||
row = dict.fromkeys(REPORT_FIELDS, "")
|
||||
row.update(path=path, suffix=suffix, lib_version=lib_version)
|
||||
row.update(report_schema_version=_REPORT_SCHEMA_VERSION, path=path, suffix=suffix, lib_version=lib_version)
|
||||
return row
|
||||
|
||||
|
||||
@@ -166,23 +180,12 @@ def _candidate_classes(rep: Any, hits: list[str]) -> list[str]:
|
||||
|
||||
def _day_of(path: Path, corpus: Path) -> date | None:
|
||||
"""Read the leading YYYY-MM-DD corpus segment, if this layout has one."""
|
||||
try:
|
||||
segment = path.relative_to(corpus).parts[0]
|
||||
return date.fromisoformat(segment)
|
||||
except (ValueError, IndexError):
|
||||
return None
|
||||
return corpus_day(path, corpus)
|
||||
|
||||
|
||||
def _files(corpus: Path, since: date | None) -> list[Path]:
|
||||
"""Return the deterministic corpus walk, optionally bounded by its date segment."""
|
||||
if since is None:
|
||||
return sorted(path for path in corpus.rglob("*") if path.is_file())
|
||||
roots = [
|
||||
path
|
||||
for path in corpus.iterdir()
|
||||
if path.is_dir() and (day := _day_of(path, corpus)) is not None and day >= since
|
||||
]
|
||||
return sorted(path for root in roots for path in root.rglob("*") if path.is_file())
|
||||
return corpus_files(corpus, since)
|
||||
|
||||
|
||||
def _scan_one(args: tuple[str, str, str]) -> dict[str, str]:
|
||||
@@ -191,7 +194,8 @@ def _scan_one(args: tuple[str, str, str]) -> dict[str, str]:
|
||||
path = Path(path_str)
|
||||
suffix = path.suffix.lower()
|
||||
try:
|
||||
rep = identify(path, check_visible=False, check_invisible=False)
|
||||
evidence = extract_provenance_evidence(path)
|
||||
rep = identify_from_evidence(evidence)
|
||||
except Exception as exc:
|
||||
log.warning("identify failed on %s: %s", relative_path, exc)
|
||||
row = _base_row(relative_path, suffix, lib_version)
|
||||
@@ -199,7 +203,15 @@ def _scan_one(args: tuple[str, str, str]) -> dict[str, str]:
|
||||
row["error"] = f"{type(exc).__name__}: {exc}"[:300].replace("\n", " ")
|
||||
else:
|
||||
row = _row(rep, path=relative_path, suffix=suffix, lib_version=lib_version)
|
||||
hits = _marker_hits_for_path(path)
|
||||
c2pa = evidence.c2pa_info
|
||||
row.update(
|
||||
c2pa_issuer=str(c2pa.get("issuer", "")),
|
||||
c2pa_claim_generator=str(c2pa.get("claim_generator", "")),
|
||||
c2pa_actions=str(c2pa.get("actions", "")),
|
||||
c2pa_source_type=str(c2pa.get("source_type", "")),
|
||||
c2pa_validation=str(c2pa.get("c2pa_validation_state", "")),
|
||||
)
|
||||
hits = _marker_hits(identify_metadata_region(evidence.scan))
|
||||
row["markers"] = "|".join(hits)
|
||||
row["candidate_classes"] = "|".join(_candidate_classes(rep, hits))
|
||||
return row
|
||||
@@ -223,20 +235,18 @@ def _read_checkpoint(path: Path) -> dict[str, dict[str, str]]:
|
||||
row = json.loads(line)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
if isinstance(row, dict) and isinstance(row.get("path"), str):
|
||||
if (
|
||||
isinstance(row, dict)
|
||||
and row.get("report_schema_version") == _REPORT_SCHEMA_VERSION
|
||||
and isinstance(row.get("path"), str)
|
||||
):
|
||||
rows[row["path"]] = {field: str(row.get(field, "")) for field in REPORT_FIELDS}
|
||||
return rows
|
||||
|
||||
|
||||
def _repair_checkpoint(path: Path) -> None:
|
||||
"""Remove an interrupted final JSONL fragment before another append."""
|
||||
if not path.exists():
|
||||
return
|
||||
with path.open("rb+") as stream:
|
||||
data = stream.read()
|
||||
if data and not data.endswith(b"\n"):
|
||||
last_newline = data.rfind(b"\n")
|
||||
stream.truncate(last_newline + 1)
|
||||
repair_jsonl_tail(path)
|
||||
|
||||
|
||||
def _summarize(rows: list[dict[str, str]]) -> None:
|
||||
|
||||
@@ -0,0 +1,371 @@
|
||||
"""Train or run an abstaining source/export-pipeline classifier.
|
||||
|
||||
This research tool combines the library's 124-d forensic descriptor with the
|
||||
phase-free spectral descriptor from ``spectral_pipeline_probe.py``. Its labels
|
||||
describe complete generation and export pipelines. It does not decode SynthID
|
||||
or establish that a watermark is present.
|
||||
|
||||
Expected training layout::
|
||||
|
||||
train/openai/*.png
|
||||
train/google/*.png
|
||||
train/unknown/*.png
|
||||
calibration/openai/*.png
|
||||
calibration/google/*.png
|
||||
calibration/unknown/*.png
|
||||
|
||||
Examples::
|
||||
|
||||
uv run python scripts/source_pipeline_classifier.py train \
|
||||
train calibration --model-out model.npz
|
||||
uv run python scripts/source_pipeline_classifier.py predict \
|
||||
model.npz image.png
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass, replace
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
import spectral_pipeline_probe as spectral
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image
|
||||
from watermark_benchmark import sha256_file
|
||||
|
||||
from remove_ai_watermarks._internal.forensic_124d import FEATURE_WIDTH, PATCH, image_features
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CLASSES = ("openai", "google", "unknown")
|
||||
UNKNOWN_INDEX = CLASSES.index("unknown")
|
||||
DEFAULT_GRID = 16
|
||||
DEFAULT_PENALTY = 100.0
|
||||
DEFAULT_UNKNOWN_FPR = 0.01
|
||||
CLAIM = "source/export pipeline classification; not SynthID detection"
|
||||
|
||||
FloatArray = NDArray[np.float64]
|
||||
IntArray = NDArray[np.int64]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RidgeModel:
|
||||
"""One standardized three-class ridge head with an abstention margin."""
|
||||
|
||||
mean: FloatArray
|
||||
scale: FloatArray
|
||||
weights: FloatArray
|
||||
margin: float
|
||||
grid: int
|
||||
penalty: float
|
||||
|
||||
|
||||
def discover_labeled_paths(root: Path) -> tuple[list[Path], IntArray, list[str]]:
|
||||
"""Find images under the three fixed label directories."""
|
||||
paths: list[Path] = []
|
||||
labels: list[int] = []
|
||||
hashes: list[str] = []
|
||||
for class_index, class_name in enumerate(CLASSES):
|
||||
directory = root / class_name
|
||||
if not directory.is_dir():
|
||||
raise ValueError(f"class directory does not exist: {directory}")
|
||||
class_paths = sorted(
|
||||
path for path in directory.rglob("*") if path.is_file() and path.suffix.lower() in spectral.IMAGE_SUFFIXES
|
||||
)
|
||||
if not class_paths:
|
||||
raise ValueError(f"class directory has no supported images: {directory}")
|
||||
for path in class_paths:
|
||||
paths.append(path)
|
||||
labels.append(class_index)
|
||||
hashes.append(sha256_file(path))
|
||||
if len(hashes) != len(set(hashes)):
|
||||
raise ValueError(f"duplicate image bytes found inside {root}")
|
||||
return paths, np.asarray(labels, dtype=np.int64), hashes
|
||||
|
||||
|
||||
def _feature_from_path(path: Path, geometry: spectral.SpectralGeometry) -> FloatArray | None:
|
||||
"""Return the fused representation for one path and prepared geometry."""
|
||||
with Image.open(path) as source:
|
||||
image = source.convert("RGB")
|
||||
forensic = image_features(np.asarray(image, dtype=np.uint8))
|
||||
if forensic is None:
|
||||
return None
|
||||
phase_free = spectral.feature_from_image(image, geometry, "original")
|
||||
return np.concatenate(
|
||||
(
|
||||
np.asarray(forensic, dtype=np.float64),
|
||||
np.asarray(phase_free, dtype=np.float64),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def feature_from_path(path: Path, grid: int) -> FloatArray | None:
|
||||
"""Return the fused forensic and phase-free spectral representation."""
|
||||
return _feature_from_path(path, spectral.make_geometry(size=PATCH, grid=grid))
|
||||
|
||||
|
||||
def extract_features(paths: list[Path], labels: IntArray, grid: int) -> tuple[FloatArray, IntArray, list[Path]]:
|
||||
"""Extract valid rows and report files whose forensic feature abstains."""
|
||||
geometry = spectral.make_geometry(size=PATCH, grid=grid)
|
||||
features: list[FloatArray] = []
|
||||
kept_labels: list[int] = []
|
||||
skipped: list[Path] = []
|
||||
for index, (path, label) in enumerate(zip(paths, labels, strict=True), start=1):
|
||||
try:
|
||||
feature = _feature_from_path(path, geometry)
|
||||
except (OSError, ValueError) as error:
|
||||
log.warning("Could not extract %s: %s", path, error)
|
||||
feature = None
|
||||
if feature is None:
|
||||
skipped.append(path)
|
||||
continue
|
||||
features.append(feature)
|
||||
kept_labels.append(int(label))
|
||||
if index % 100 == 0:
|
||||
log.info("Extracted %s/%s images", index, len(paths))
|
||||
if not features:
|
||||
raise ValueError("no image produced a valid feature vector")
|
||||
return np.asarray(features), np.asarray(kept_labels, dtype=np.int64), skipped
|
||||
|
||||
|
||||
def fit_ridge(
|
||||
features: FloatArray,
|
||||
labels: IntArray,
|
||||
penalty: float,
|
||||
grid: int = DEFAULT_GRID,
|
||||
) -> RidgeModel:
|
||||
"""Fit a class-balanced standardized ridge head before calibration."""
|
||||
if features.ndim != 2 or len(features) != len(labels):
|
||||
raise ValueError("features and labels must describe the same rows")
|
||||
counts = np.bincount(labels, minlength=len(CLASSES)).astype(np.float64)
|
||||
if np.any(counts == 0):
|
||||
raise ValueError("training data must contain all three classes")
|
||||
if penalty <= 0:
|
||||
raise ValueError("penalty must be positive")
|
||||
mean = features.mean(axis=0)
|
||||
raw_scale = features.std(axis=0)
|
||||
active = raw_scale > 1e-10
|
||||
scale = np.where(active, raw_scale, 1.0)
|
||||
normalized = (features[:, active] - mean[active]) / scale[active]
|
||||
sample_weight = len(labels) / (len(CLASSES) * counts[labels])
|
||||
root_weight = np.sqrt(sample_weight)[:, None]
|
||||
weighted = normalized * root_weight
|
||||
targets = np.eye(len(CLASSES), dtype=np.float64)[labels] * root_weight
|
||||
gram = weighted.T @ weighted + penalty * np.eye(weighted.shape[1])
|
||||
active_weights = cast("FloatArray", np.linalg.solve(gram, weighted.T @ targets))
|
||||
weights = np.zeros((features.shape[1], len(CLASSES)), dtype=np.float64)
|
||||
weights[active] = active_weights
|
||||
return RidgeModel(mean, scale, weights, 0.0, grid, penalty)
|
||||
|
||||
|
||||
def scores(features: FloatArray, model: RidgeModel) -> FloatArray:
|
||||
"""Return the uncalibrated ridge scores."""
|
||||
return ((features - model.mean) / model.scale) @ model.weights
|
||||
|
||||
|
||||
def decision_margins(raw_scores: FloatArray) -> FloatArray:
|
||||
"""Return each row's best-provider advantage over every alternative."""
|
||||
target_scores = raw_scores[:, :UNKNOWN_INDEX]
|
||||
best = np.argmax(target_scores, axis=1)
|
||||
other = 1 - best
|
||||
rows = np.arange(len(raw_scores))
|
||||
reference = np.maximum(raw_scores[rows, other], raw_scores[:, UNKNOWN_INDEX])
|
||||
return target_scores[rows, best] - reference
|
||||
|
||||
|
||||
def calibrate_margin(
|
||||
calibration_scores: FloatArray,
|
||||
calibration_labels: IntArray,
|
||||
unknown_fpr: float,
|
||||
) -> float:
|
||||
"""Choose the smallest empirical cut meeting the unknown false-positive target."""
|
||||
if not 0 <= unknown_fpr < 1:
|
||||
raise ValueError("unknown_fpr must be in [0, 1)")
|
||||
unknown_scores = calibration_scores[calibration_labels == UNKNOWN_INDEX]
|
||||
if not len(unknown_scores):
|
||||
raise ValueError("calibration data must contain unknown rows")
|
||||
return max(
|
||||
0.0,
|
||||
float(
|
||||
np.quantile(
|
||||
decision_margins(unknown_scores),
|
||||
1.0 - unknown_fpr,
|
||||
method="higher",
|
||||
)
|
||||
+ 1e-12
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def predict_scores(raw_scores: FloatArray, margin: float) -> IntArray:
|
||||
"""Return provider labels only when the calibrated margin is met."""
|
||||
best = np.argmax(raw_scores[:, :UNKNOWN_INDEX], axis=1)
|
||||
accepted = decision_margins(raw_scores) >= margin
|
||||
return np.where(accepted, best, UNKNOWN_INDEX).astype(np.int64)
|
||||
|
||||
|
||||
def save_model(path: Path, model: RidgeModel) -> None:
|
||||
"""Write a pickle-free model artifact."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
np.savez_compressed(
|
||||
path,
|
||||
classes=np.asarray(CLASSES),
|
||||
mean=model.mean,
|
||||
scale=model.scale,
|
||||
weights=model.weights,
|
||||
margin=np.asarray(model.margin),
|
||||
spectral_grid=np.asarray(model.grid),
|
||||
penalty=np.asarray(model.penalty),
|
||||
)
|
||||
|
||||
|
||||
def load_model(path: Path) -> RidgeModel:
|
||||
"""Load and validate a pickle-free model artifact."""
|
||||
with np.load(path, allow_pickle=False) as payload:
|
||||
required = {
|
||||
"classes",
|
||||
"mean",
|
||||
"scale",
|
||||
"weights",
|
||||
"margin",
|
||||
"spectral_grid",
|
||||
"penalty",
|
||||
}
|
||||
if set(payload.files) != required:
|
||||
raise ValueError(f"model fields must be exactly {sorted(required)}")
|
||||
classes = tuple(str(value) for value in payload["classes"])
|
||||
mean = np.asarray(payload["mean"], dtype=np.float64)
|
||||
scale = np.asarray(payload["scale"], dtype=np.float64)
|
||||
weights = np.asarray(payload["weights"], dtype=np.float64)
|
||||
margin = float(payload["margin"])
|
||||
grid = int(payload["spectral_grid"])
|
||||
penalty = float(payload["penalty"])
|
||||
expected_features = FEATURE_WIDTH + 3 * grid * grid
|
||||
if classes != CLASSES:
|
||||
raise ValueError(f"model classes must be {CLASSES}")
|
||||
if mean.shape != (expected_features,) or scale.shape != mean.shape:
|
||||
raise ValueError("model normalization vectors have the wrong shape")
|
||||
if weights.shape != (expected_features, len(CLASSES)):
|
||||
raise ValueError("model weights have the wrong shape")
|
||||
if not np.all(np.isfinite(mean)) or not np.all(np.isfinite(scale)):
|
||||
raise ValueError("model normalization vectors must be finite")
|
||||
if not np.all(np.isfinite(weights)) or np.any(scale <= 0):
|
||||
raise ValueError("model weights must be finite and scales positive")
|
||||
if not np.isfinite(margin) or margin < 0:
|
||||
raise ValueError("model margin must be finite and non-negative")
|
||||
if grid < 2 or PATCH % grid or not np.isfinite(penalty) or penalty <= 0:
|
||||
raise ValueError("model calibration fields are invalid")
|
||||
return RidgeModel(mean, scale, weights, margin, grid, penalty)
|
||||
|
||||
|
||||
@click.group()
|
||||
def main() -> None:
|
||||
"""Train or run the research-only source-pipeline classifier."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
|
||||
|
||||
@main.command("train")
|
||||
@click.argument("train_root", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
||||
@click.argument("calibration_root", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
||||
@click.option("--model-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
|
||||
@click.option("--grid", type=click.IntRange(min=2), default=DEFAULT_GRID, show_default=True)
|
||||
@click.option("--penalty", type=click.FloatRange(min=0.0, min_open=True), default=DEFAULT_PENALTY, show_default=True)
|
||||
@click.option(
|
||||
"--unknown-fpr",
|
||||
type=click.FloatRange(min=0.0, max=1.0, max_open=True),
|
||||
default=DEFAULT_UNKNOWN_FPR,
|
||||
show_default=True,
|
||||
)
|
||||
def train_command(
|
||||
train_root: Path,
|
||||
calibration_root: Path,
|
||||
model_out: Path,
|
||||
grid: int,
|
||||
penalty: float,
|
||||
unknown_fpr: float,
|
||||
) -> None:
|
||||
"""Fit on TRAIN_ROOT and calibrate abstention on CALIBRATION_ROOT."""
|
||||
if PATCH % grid:
|
||||
raise click.UsageError(f"--grid must divide {PATCH}")
|
||||
try:
|
||||
train_paths, train_labels, train_hashes = discover_labeled_paths(train_root)
|
||||
calibration_paths, calibration_labels, calibration_hashes = discover_labeled_paths(calibration_root)
|
||||
overlap = set(train_hashes) & set(calibration_hashes)
|
||||
if overlap:
|
||||
raise ValueError(f"train/calibration byte overlap: {len(overlap)} images")
|
||||
train_features, kept_train_labels, train_skipped = extract_features(train_paths, train_labels, grid)
|
||||
calibration_features, kept_calibration_labels, calibration_skipped = extract_features(
|
||||
calibration_paths, calibration_labels, grid
|
||||
)
|
||||
provisional = fit_ridge(train_features, kept_train_labels, penalty, grid)
|
||||
margin = calibrate_margin(
|
||||
scores(calibration_features, provisional),
|
||||
kept_calibration_labels,
|
||||
unknown_fpr,
|
||||
)
|
||||
model = replace(provisional, margin=margin)
|
||||
save_model(model_out, model)
|
||||
except ValueError as error:
|
||||
raise click.ClickException(str(error)) from error
|
||||
click.echo(
|
||||
json.dumps(
|
||||
{
|
||||
"model": str(model_out),
|
||||
"claim": CLAIM,
|
||||
"train_rows": len(train_features),
|
||||
"calibration_rows": len(calibration_features),
|
||||
"feature_abstentions": len(train_skipped) + len(calibration_skipped),
|
||||
"margin": margin,
|
||||
},
|
||||
indent=2,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@main.command("predict")
|
||||
@click.argument("model_path", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
||||
def predict_command(model_path: Path, images: tuple[Path, ...]) -> None:
|
||||
"""Classify original-export IMAGES with MODEL_PATH."""
|
||||
try:
|
||||
model = load_model(model_path)
|
||||
except (OSError, ValueError) as error:
|
||||
raise click.ClickException(str(error)) from error
|
||||
results: list[dict[str, object]] = []
|
||||
geometry = spectral.make_geometry(size=PATCH, grid=model.grid)
|
||||
for path in images:
|
||||
try:
|
||||
feature = _feature_from_path(path, geometry)
|
||||
except (OSError, ValueError) as error:
|
||||
raise click.ClickException(f"could not extract {path}: {error}") from error
|
||||
if feature is None:
|
||||
results.append(
|
||||
{
|
||||
"path": str(path),
|
||||
"label": "unknown",
|
||||
"reason": "124-d feature unavailable",
|
||||
"claim": CLAIM,
|
||||
}
|
||||
)
|
||||
continue
|
||||
raw_scores = scores(feature[None, :], model)
|
||||
prediction = int(predict_scores(raw_scores, model.margin)[0])
|
||||
results.append(
|
||||
{
|
||||
"path": str(path),
|
||||
"label": CLASSES[prediction],
|
||||
"decision_margin": float(decision_margins(raw_scores)[0]),
|
||||
"required_margin": model.margin,
|
||||
"scores": {name: float(raw_scores[0, index]) for index, name in enumerate(CLASSES)},
|
||||
"claim": CLAIM,
|
||||
}
|
||||
)
|
||||
click.echo(json.dumps(results, indent=2))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,346 @@
|
||||
"""Measure a phase-free spectral source-pipeline hypothesis.
|
||||
|
||||
This is a research harness, not a SynthID detector. It classifies complete
|
||||
generation/export pipelines whose renderer, output geometry, encoding, and
|
||||
watermark state may all be confounded. A watermark claim requires a causal
|
||||
marked/clean contrast and an independent oracle.
|
||||
|
||||
The input directory contains one subdirectory per class. Matching filename
|
||||
stems are treated as one prompt group and always stay in the same fold.
|
||||
|
||||
Example:
|
||||
uv run python scripts/spectral_pipeline_probe.py DATASET \
|
||||
--class-dir gemini --class-dir openai --class-dir flux \
|
||||
--attack original --attack crop2 --attack resize95 \
|
||||
--attack jpeg75 --attack blur \
|
||||
--report-out .local-eval/spectral-pipeline-report.json
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import cast
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
from PIL import Image, ImageFilter
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
IMAGE_SUFFIXES = frozenset({".jpg", ".jpeg", ".png", ".webp"})
|
||||
ATTACKS = ("original", "crop2", "resize95", "jpeg75", "blur")
|
||||
|
||||
FloatArray = NDArray[np.float64]
|
||||
IntArray = NDArray[np.int64]
|
||||
BoolArray = NDArray[np.bool_]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SpectralGeometry:
|
||||
"""Precomputed coordinates for one phase-free spectral representation."""
|
||||
|
||||
size: int
|
||||
grid: int
|
||||
radius: NDArray[np.int32]
|
||||
radius_counts: IntArray
|
||||
passband: BoolArray
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FoldModel:
|
||||
"""One standardized dual-ridge model and its held-out rows."""
|
||||
|
||||
test_rows: BoolArray
|
||||
mean: FloatArray
|
||||
scale: FloatArray
|
||||
active: BoolArray
|
||||
weights: FloatArray
|
||||
|
||||
|
||||
def make_geometry(size: int = 256, grid: int = 32) -> SpectralGeometry:
|
||||
"""Build the radial detrending and pooling geometry."""
|
||||
if size < 16 or grid < 2 or size % grid:
|
||||
raise ValueError("size must be at least 16 and divisible by grid")
|
||||
yy, xx = np.mgrid[:size, :size]
|
||||
radius = np.rint(np.hypot(yy - size // 2, xx - size // 2)).astype(np.int32)
|
||||
radius_counts = np.bincount(radius.ravel())
|
||||
passband = (radius >= 4) & (radius <= size * 0.48)
|
||||
return SpectralGeometry(size, grid, radius, radius_counts, passband)
|
||||
|
||||
|
||||
def phase_free_log_spectrum(channel: FloatArray) -> FloatArray:
|
||||
"""Return centered log FFT magnitude, deliberately discarding phase."""
|
||||
if channel.ndim != 2:
|
||||
raise ValueError("channel must be a two-dimensional array")
|
||||
centered = np.asarray(channel, dtype=np.float64) - float(np.mean(channel))
|
||||
return np.log1p(np.abs(np.fft.fftshift(np.fft.fft2(centered))))
|
||||
|
||||
|
||||
def spectral_feature(pixels: FloatArray, geometry: SpectralGeometry) -> FloatArray:
|
||||
"""Extract pooled radial-residual spectra from luma and opponent color."""
|
||||
if pixels.shape != (geometry.size, geometry.size, 3):
|
||||
raise ValueError(f"pixels must have shape {(geometry.size, geometry.size, 3)}, got {pixels.shape}")
|
||||
red, green, blue = np.moveaxis(np.asarray(pixels, dtype=np.float64), 2, 0)
|
||||
channels = (
|
||||
0.299 * red + 0.587 * green + 0.114 * blue,
|
||||
red - green,
|
||||
blue - (red + green) / 2.0,
|
||||
)
|
||||
block = geometry.size // geometry.grid
|
||||
features: list[FloatArray] = []
|
||||
for channel in channels:
|
||||
spectrum = phase_free_log_spectrum(channel)
|
||||
radial_sum = np.bincount(geometry.radius.ravel(), weights=spectrum.ravel())
|
||||
radial_mean = radial_sum / geometry.radius_counts
|
||||
residual = np.where(
|
||||
geometry.passband,
|
||||
spectrum - radial_mean[geometry.radius],
|
||||
0.0,
|
||||
)
|
||||
pooled = residual.reshape(geometry.grid, block, geometry.grid, block).mean(axis=(1, 3))
|
||||
features.append(pooled.ravel())
|
||||
return np.concatenate(features)
|
||||
|
||||
|
||||
def _apply_attack(image: Image.Image, attack: str) -> Image.Image:
|
||||
"""Apply one declared robustness transform before canonical resizing."""
|
||||
if attack == "original":
|
||||
return image
|
||||
if attack == "crop2":
|
||||
if image.width <= 4 or image.height <= 4:
|
||||
raise ValueError("crop2 requires both image dimensions to exceed four pixels")
|
||||
return image.crop((2, 2, image.width, image.height))
|
||||
if attack == "resize95":
|
||||
return image.resize(
|
||||
(max(1, round(image.width * 0.95)), max(1, round(image.height * 0.95))),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
if attack == "jpeg75":
|
||||
encoded = io.BytesIO()
|
||||
image.save(encoded, format="JPEG", quality=75)
|
||||
encoded.seek(0)
|
||||
with Image.open(encoded) as decoded:
|
||||
return decoded.convert("RGB")
|
||||
if attack == "blur":
|
||||
return image.filter(ImageFilter.GaussianBlur(0.7))
|
||||
raise ValueError(f"unsupported attack: {attack}")
|
||||
|
||||
|
||||
def feature_from_path(path: Path, geometry: SpectralGeometry, attack: str) -> FloatArray:
|
||||
"""Decode one path, apply ATTACK, and return its phase-free feature."""
|
||||
with Image.open(path) as source:
|
||||
return feature_from_image(source.convert("RGB"), geometry, attack)
|
||||
|
||||
|
||||
def feature_from_image(image: Image.Image, geometry: SpectralGeometry, attack: str) -> FloatArray:
|
||||
"""Extract one attacked feature from an already decoded RGB image."""
|
||||
attacked = _apply_attack(image, attack)
|
||||
canonical = attacked.resize((geometry.size, geometry.size), Image.Resampling.LANCZOS)
|
||||
pixels = np.asarray(canonical, dtype=np.float64) / 255.0
|
||||
return spectral_feature(pixels, geometry)
|
||||
|
||||
|
||||
def discover_grouped_paths(
|
||||
root: Path,
|
||||
class_dirs: tuple[str, ...],
|
||||
) -> tuple[list[Path], IntArray, IntArray, list[str]]:
|
||||
"""Return class-major paths over filename stems shared by every class."""
|
||||
if len(class_dirs) < 2 or len(set(class_dirs)) != len(class_dirs):
|
||||
raise ValueError("at least two unique class directories are required")
|
||||
per_class: list[dict[str, Path]] = []
|
||||
for class_dir in class_dirs:
|
||||
directory = root / class_dir
|
||||
if not directory.is_dir():
|
||||
raise ValueError(f"class directory does not exist: {directory}")
|
||||
mapping: dict[str, Path] = {}
|
||||
for path in sorted(directory.iterdir()):
|
||||
if path.is_file() and path.suffix.lower() in IMAGE_SUFFIXES:
|
||||
if path.stem in mapping:
|
||||
raise ValueError(f"duplicate filename stem in {directory}: {path.stem}")
|
||||
mapping[path.stem] = path
|
||||
per_class.append(mapping)
|
||||
common_names: set[str] = set(per_class[0])
|
||||
for mapping in per_class[1:]:
|
||||
common_names.intersection_update(mapping)
|
||||
group_names = sorted(common_names)
|
||||
if not group_names:
|
||||
raise ValueError("class directories have no shared filename stems")
|
||||
paths: list[Path] = []
|
||||
labels: list[int] = []
|
||||
groups: list[int] = []
|
||||
for class_index, mapping in enumerate(per_class):
|
||||
for group_index, group_name in enumerate(group_names):
|
||||
paths.append(mapping[group_name])
|
||||
labels.append(class_index)
|
||||
groups.append(group_index)
|
||||
return paths, np.asarray(labels), np.asarray(groups), group_names
|
||||
|
||||
|
||||
def fit_grouped_models(
|
||||
features: FloatArray,
|
||||
labels: IntArray,
|
||||
groups: IntArray,
|
||||
*,
|
||||
class_count: int,
|
||||
folds: int = 5,
|
||||
ridge: float = 1000.0,
|
||||
) -> list[FoldModel]:
|
||||
"""Fit prompt-grouped dual ridge models without test-fold leakage."""
|
||||
if features.ndim != 2 or len(features) != len(labels) or len(labels) != len(groups):
|
||||
raise ValueError("features, labels, and groups must describe the same rows")
|
||||
if folds < 2 or len(np.unique(groups)) < folds:
|
||||
raise ValueError("fold count must be between two and the number of groups")
|
||||
if class_count < 2 or ridge <= 0:
|
||||
raise ValueError("class_count must be at least two and ridge must be positive")
|
||||
targets = np.eye(class_count, dtype=np.float64)[labels]
|
||||
models: list[FoldModel] = []
|
||||
for fold in range(folds):
|
||||
test_rows = groups % folds == fold
|
||||
train_rows = ~test_rows
|
||||
train = features[train_rows]
|
||||
mean = np.mean(train, axis=0)
|
||||
scale = np.std(train, axis=0)
|
||||
active = scale > 1e-8
|
||||
train_features = (train[:, active] - mean[active]) / scale[active]
|
||||
kernel = train_features @ train_features.T
|
||||
dual_weights = cast(
|
||||
"FloatArray",
|
||||
np.linalg.solve(
|
||||
kernel + ridge * np.eye(len(kernel)),
|
||||
targets[train_rows],
|
||||
),
|
||||
)
|
||||
weights = train_features.T @ dual_weights
|
||||
models.append(FoldModel(test_rows, mean, scale, active, weights))
|
||||
return models
|
||||
|
||||
|
||||
def predict_grouped(models: list[FoldModel], features: FloatArray) -> IntArray:
|
||||
"""Predict every row only with the model that held out its group."""
|
||||
predictions = np.full(len(features), -1, dtype=np.int64)
|
||||
for model in models:
|
||||
test_features = (features[model.test_rows][:, model.active] - model.mean[model.active]) / model.scale[
|
||||
model.active
|
||||
]
|
||||
scores = test_features @ model.weights
|
||||
predictions[model.test_rows] = np.argmax(scores, axis=1)
|
||||
if np.any(predictions < 0):
|
||||
raise ValueError("fold models did not cover every row")
|
||||
return predictions
|
||||
|
||||
|
||||
def confusion_matrix(labels: IntArray, predictions: IntArray, class_count: int) -> IntArray:
|
||||
"""Count actual rows by predicted class."""
|
||||
matrix = np.zeros((class_count, class_count), dtype=np.int64)
|
||||
np.add.at(matrix, (labels, predictions), 1)
|
||||
return matrix
|
||||
|
||||
|
||||
def _extract_all_attacks(
|
||||
paths: list[Path],
|
||||
geometry: SpectralGeometry,
|
||||
attacks: tuple[str, ...],
|
||||
) -> dict[str, FloatArray]:
|
||||
"""Decode each path once and extract every requested attack feature."""
|
||||
log.info("Extracting %s attack views from %s images", len(attacks), len(paths))
|
||||
rows: dict[str, list[FloatArray]] = {attack: [] for attack in attacks}
|
||||
for path in paths:
|
||||
with Image.open(path) as source:
|
||||
image = source.convert("RGB")
|
||||
for attack in attacks:
|
||||
rows[attack].append(feature_from_image(image, geometry, attack))
|
||||
return {attack: np.asarray(features) for attack, features in rows.items()}
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("dataset", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
||||
@click.option(
|
||||
"class_dirs",
|
||||
"--class-dir",
|
||||
multiple=True,
|
||||
required=True,
|
||||
help="Class subdirectory; repeat in label order.",
|
||||
)
|
||||
@click.option(
|
||||
"attacks",
|
||||
"--attack",
|
||||
multiple=True,
|
||||
type=click.Choice(ATTACKS),
|
||||
default=("original",),
|
||||
show_default=True,
|
||||
)
|
||||
@click.option("--size", type=click.IntRange(min=16), default=256, show_default=True)
|
||||
@click.option("--grid", type=click.IntRange(min=2), default=32, show_default=True)
|
||||
@click.option("--folds", type=click.IntRange(min=2), default=5, show_default=True)
|
||||
@click.option("--ridge", type=click.FloatRange(min=0.0, min_open=True), default=1000.0, show_default=True)
|
||||
@click.option("--report-out", type=click.Path(dir_okay=False, path_type=Path), required=True)
|
||||
def main(
|
||||
dataset: Path,
|
||||
class_dirs: tuple[str, ...],
|
||||
attacks: tuple[str, ...],
|
||||
size: int,
|
||||
grid: int,
|
||||
folds: int,
|
||||
ridge: float,
|
||||
report_out: Path,
|
||||
) -> None:
|
||||
"""Run grouped spectral pipeline classification over DATASET."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s %(message)s")
|
||||
if "original" not in attacks:
|
||||
raise click.UsageError("--attack original is required because every model trains on original exports")
|
||||
unique_attacks = tuple(dict.fromkeys(attacks))
|
||||
try:
|
||||
geometry = make_geometry(size, grid)
|
||||
paths, labels, groups, group_names = discover_grouped_paths(dataset, class_dirs)
|
||||
features_by_attack = _extract_all_attacks(paths, geometry, unique_attacks)
|
||||
original = features_by_attack["original"]
|
||||
models = fit_grouped_models(
|
||||
original,
|
||||
labels,
|
||||
groups,
|
||||
class_count=len(class_dirs),
|
||||
folds=folds,
|
||||
ridge=ridge,
|
||||
)
|
||||
except ValueError as error:
|
||||
raise click.ClickException(str(error)) from error
|
||||
|
||||
results: dict[str, object] = {}
|
||||
for attack in unique_attacks:
|
||||
features = features_by_attack[attack]
|
||||
predictions = predict_grouped(models, features)
|
||||
matrix = confusion_matrix(labels, predictions, len(class_dirs))
|
||||
results[attack] = {
|
||||
"accuracy": float(np.trace(matrix) / np.sum(matrix)),
|
||||
"confusion_matrix": matrix.tolist(),
|
||||
}
|
||||
|
||||
report = {
|
||||
"claim": "phase-free generation/export pipeline classification; not SynthID detection",
|
||||
"causal_limit": (
|
||||
"Renderer, output geometry, encoding, and watermark state vary together. "
|
||||
"Only a marked/clean causal contrast plus an independent oracle can isolate the watermark."
|
||||
),
|
||||
"evaluation_limit": (
|
||||
"Grouped cross-validation estimates this corpus only. Hyperparameters selected on these folds "
|
||||
"need an independent locked corpus before they support a generalization claim."
|
||||
),
|
||||
"dataset": str(dataset),
|
||||
"classes": list(class_dirs),
|
||||
"group_count": len(group_names),
|
||||
"image_count": len(paths),
|
||||
"configuration": {"size": size, "grid": grid, "folds": folds, "ridge": ridge},
|
||||
"results": results,
|
||||
}
|
||||
report_out.parent.mkdir(parents=True, exist_ok=True)
|
||||
report_out.write_text(json.dumps(report, indent=2) + "\n", encoding="utf-8")
|
||||
log.info("Wrote spectral pipeline report: %s", report_out)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -41,7 +41,10 @@ from remove_ai_watermarks.image_io import imread
|
||||
|
||||
GT = Path(".local-eval/textmark-relaxation/groundtruth.jsonl")
|
||||
SNAP = Path(".local-eval/textmark-relaxation/snapshots")
|
||||
MARKS = ("gemini", "doubao", "jimeng", "samsung", "jimeng_pill")
|
||||
# Ground truth currently adjudicates only a subset, but inventory follows the
|
||||
# registry. A newly registered mark therefore appears with scope zero instead of
|
||||
# silently vanishing from the evaluation harness.
|
||||
MARKS = tuple(wr.mark_keys())
|
||||
|
||||
|
||||
def wilson(k: int, n: int) -> tuple[float, float]:
|
||||
@@ -122,7 +125,7 @@ def report(res: dict, prev: dict | None = None) -> None:
|
||||
lo, hi = wilson(tp, n)
|
||||
delta = ""
|
||||
if prev:
|
||||
pc = prev["per"][m]
|
||||
pc = prev["per"].get(m, {})
|
||||
pn = pc.get("tp", 0) + pc.get("fp", 0)
|
||||
if pn:
|
||||
d = tp / n - pc.get("tp", 0) / pn
|
||||
|
||||
+230
-34
@@ -1,4 +1,4 @@
|
||||
"""Parallel detection pass: list every corpus image carrying a known visible mark.
|
||||
"""Scan a retained corpus for registered and still-uncovered visible marks.
|
||||
|
||||
Why this exists separately from `visible_removal_audit.py`: that audit is single-process,
|
||||
so repeated full-dataset sweeps waste time. Its
|
||||
@@ -18,22 +18,43 @@ CRASH TOLERANCE IS NOT OPTIONAL AT THIS SCALE
|
||||
|
||||
Treat input datasets as sensitive and read-only, and keep output gitignored.
|
||||
|
||||
uv run python scripts/visible_positives.py --jobs 6
|
||||
The JSONL is also the input to visible-mark discovery. Each row records strict and
|
||||
metadata-corroborated detector results plus the metadata cohort independently of
|
||||
the pixels. ``--sheets`` then makes blinded, native-detail top/bottom bands from:
|
||||
|
||||
* quiet TC260 producer cohorts, including producer identities absent from the
|
||||
visible-mark registry;
|
||||
* quiet named-platform cohorts;
|
||||
* an unbiased sample of every other quiet image, so a completely novel mark with
|
||||
no useful metadata is not excluded by construction.
|
||||
|
||||
The sheets are candidates for human adjudication, not evidence that a watermark is
|
||||
present. Their manifest is deliberately separate and must stay closed until the
|
||||
image review finishes.
|
||||
|
||||
uv run python scripts/visible_positives.py --corpus .local-eval/originals --jobs 6
|
||||
uv run python scripts/visible_positives.py --since 2026-09-01 --sheets .local-eval/visible-fresh-sheets
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import collections
|
||||
import glob
|
||||
import json
|
||||
import os
|
||||
import random
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date
|
||||
from importlib.metadata import version
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
sys.path.insert(0, str(Path(__file__).parent))
|
||||
|
||||
from _corpus_scan import corpus_files, repair_jsonl_tail
|
||||
from _isolated_image_workers import run_batch
|
||||
|
||||
# The package's own format set. An inlined copy here silently skipped .heif, which
|
||||
@@ -43,20 +64,66 @@ from remove_ai_watermarks._internal.constants import SUPPORTED_FORMATS as _EXTS
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
CORPUS = REPO / ".local-eval" / "originals"
|
||||
OUT = REPO / ".local-eval" / "visible-positives.jsonl"
|
||||
PATHS = REPO / ".local-eval" / "visible-positives.txt"
|
||||
_SHEET_BAND_FRACTION = 0.12
|
||||
_SCHEMA_VERSION = 2
|
||||
|
||||
|
||||
def _one(path: str) -> dict[str, object]:
|
||||
from remove_ai_watermarks.api import _provenance_from_report
|
||||
from remove_ai_watermarks.identify import identify
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.watermark_registry import detect_marks
|
||||
from remove_ai_watermarks.metadata import aigc_label, uscc_of
|
||||
from remove_ai_watermarks.watermark_registry import _provenance_confirms_product, known_marks
|
||||
|
||||
try:
|
||||
img = imread(path)
|
||||
source = Path(path)
|
||||
img = imread(source)
|
||||
if img is None:
|
||||
return {"path": path, "keys": [], "status": "unreadable"}
|
||||
return {"path": path, "keys": [d.key for d in detect_marks(img) if d.detected], "status": "ok"}
|
||||
return _error_record(path, "unreadable")
|
||||
report = identify(source, check_visible=False, check_invisible=False)
|
||||
provenance = _provenance_from_report(report, source)
|
||||
strict = []
|
||||
accepted = []
|
||||
for mark in known_marks():
|
||||
strict_detection, relaxed_detection = mark.detect_both(img)
|
||||
if strict_detection.detected:
|
||||
strict.append(mark.key)
|
||||
detection = (
|
||||
relaxed_detection if _provenance_confirms_product(mark.product, provenance) else strict_detection
|
||||
)
|
||||
if detection.detected:
|
||||
accepted.append(mark.key)
|
||||
label = aigc_label(source) or {}
|
||||
producer = str(label.get("ContentProducer") or "")
|
||||
return {
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"path": path,
|
||||
"keys": accepted,
|
||||
"strict_keys": strict,
|
||||
"provenance": sorted(provenance),
|
||||
"platform": report.platform or "",
|
||||
"producer": producer,
|
||||
"uscc": uscc_of(producer) if producer else "",
|
||||
"status": "ok",
|
||||
"error": "",
|
||||
}
|
||||
except Exception as e:
|
||||
return {"path": path, "keys": [], "status": f"error:{type(e).__name__}"}
|
||||
return _error_record(path, f"error:{type(e).__name__}", str(e))
|
||||
|
||||
|
||||
def _error_record(path: str, status: str, error: str = "") -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
"path": path,
|
||||
"keys": [],
|
||||
"strict_keys": [],
|
||||
"provenance": [],
|
||||
"platform": "",
|
||||
"producer": "",
|
||||
"uscc": "",
|
||||
"status": status,
|
||||
"error": error[:300].replace("\n", " "),
|
||||
}
|
||||
|
||||
|
||||
def _run_batch(batch: list[str], jobs: int, timeout: int) -> list[dict[str, object]]:
|
||||
@@ -64,65 +131,194 @@ def _run_batch(batch: list[str], jobs: int, timeout: int) -> list[dict[str, obje
|
||||
return run_batch(Path(__file__), batch, jobs=jobs, timeout=timeout)
|
||||
|
||||
|
||||
def _files(corpus: Path, since: date | None) -> list[Path]:
|
||||
return corpus_files(corpus, since, suffixes=_EXTS)
|
||||
|
||||
|
||||
def _read_records(path: Path) -> list[dict[str, Any]]:
|
||||
rows: dict[str, dict[str, Any]] = {}
|
||||
if not path.exists():
|
||||
return []
|
||||
with path.open(encoding="utf-8") as stream:
|
||||
for line in stream:
|
||||
try:
|
||||
row = json.loads(line)
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
continue
|
||||
if isinstance(row, dict) and isinstance(row.get("path"), str):
|
||||
rows[row["path"]] = row
|
||||
return list(rows.values())
|
||||
|
||||
|
||||
def _repair_checkpoint(path: Path) -> None:
|
||||
"""Drop a torn tail before append, otherwise the next row joins invalid JSON."""
|
||||
repair_jsonl_tail(path)
|
||||
|
||||
|
||||
def _discovery_candidates(
|
||||
rows: list[dict[str, Any]], *, per_cohort: int, random_quiet: int, seed: int
|
||||
) -> list[dict[str, Any]]:
|
||||
"""Stratify quiet rows without treating provenance as a pixel-level label."""
|
||||
rng = random.Random(seed) # noqa: S311 -- reproducible review sampling
|
||||
quiet = [row for row in rows if row.get("status") == "ok" and not row.get("keys")]
|
||||
strata: dict[str, list[dict[str, Any]]] = collections.defaultdict(list)
|
||||
for row in quiet:
|
||||
if row.get("uscc"):
|
||||
strata[f"tc260:{row['uscc']}"].append(row)
|
||||
if row.get("platform"):
|
||||
strata[f"platform:{row['platform']}"].append(row)
|
||||
|
||||
selected: dict[str, dict[str, Any]] = {}
|
||||
labels: dict[str, set[str]] = collections.defaultdict(set)
|
||||
for label, members in sorted(strata.items()):
|
||||
for row in rng.sample(members, k=min(per_cohort, len(members))):
|
||||
selected[row["path"]] = row
|
||||
labels[row["path"]].add(label)
|
||||
|
||||
for row in rng.sample(quiet, k=min(random_quiet, len(quiet))):
|
||||
selected[row["path"]] = row
|
||||
labels[row["path"]].add("unbiased-quiet")
|
||||
|
||||
return [{**row, "discovery_strata": sorted(labels[path])} for path, row in sorted(selected.items())]
|
||||
|
||||
|
||||
def _bands(path: str) -> tuple[Any, Any] | None:
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
image = imread(path)
|
||||
if image is None:
|
||||
return None
|
||||
height = image.shape[0]
|
||||
band_height = max(24, int(height * _SHEET_BAND_FRACTION))
|
||||
return image[:band_height], image[height - band_height :]
|
||||
|
||||
|
||||
def _write_discovery_sheets(rows: list[dict[str, Any]], output: Path) -> int:
|
||||
import csv
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if output.exists() and any(output.iterdir()):
|
||||
raise ValueError(f"discovery output must be empty: {output}")
|
||||
output.mkdir(parents=True, exist_ok=True)
|
||||
manifest: list[dict[str, Any]] = []
|
||||
written = 0
|
||||
for row in rows:
|
||||
bands = _bands(row["path"])
|
||||
if bands is None:
|
||||
continue
|
||||
top, bottom = bands
|
||||
width = max(top.shape[1], bottom.shape[1])
|
||||
divider = np.full((6, width, 3), 90, np.uint8)
|
||||
header = np.full((28, width, 3), 40, np.uint8)
|
||||
cv2.putText(header, f"{written:05d}", (8, 20), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1)
|
||||
cv2.imwrite(str(output / f"{written:05d}.png"), np.vstack((header, top, divider, bottom)))
|
||||
manifest.append({"idx": written, **row})
|
||||
written += 1
|
||||
if manifest:
|
||||
fields = list(manifest[0])
|
||||
with (output / "MANIFEST_DO_NOT_OPEN.csv").open("w", newline="", encoding="utf-8") as stream:
|
||||
writer = csv.DictWriter(stream, fieldnames=fields, extrasaction="ignore")
|
||||
writer.writeheader()
|
||||
writer.writerows(manifest)
|
||||
return written
|
||||
|
||||
|
||||
def main() -> None:
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--corpus", type=Path, default=CORPUS)
|
||||
ap.add_argument("--since", type=date.fromisoformat, help="include dated corpus directories on or after YYYY-MM-DD")
|
||||
ap.add_argument("--jobs", type=int, default=max(1, (os.cpu_count() or 4) - 2))
|
||||
ap.add_argument("--limit", type=int, default=0)
|
||||
ap.add_argument("--batch", type=int, default=200)
|
||||
ap.add_argument("--timeout", type=int, default=600, help="seconds per image before terminating its isolated worker")
|
||||
ap.add_argument("--out", type=Path, default=OUT)
|
||||
ap.add_argument("--restart", action="store_true")
|
||||
ap.add_argument("--sheets", type=Path, help="write blinded discovery bands after the scan")
|
||||
ap.add_argument("--per-cohort", type=int, default=12, help="quiet images sampled from each metadata cohort")
|
||||
ap.add_argument("--random-quiet", type=int, default=400, help="unbiased quiet images sampled across the corpus")
|
||||
ap.add_argument("--seed", type=int, default=2026)
|
||||
a = ap.parse_args()
|
||||
|
||||
files = sorted(p for p in glob.glob(str(CORPUS / "*" / "*")) if Path(p).suffix.lower() in _EXTS)
|
||||
files = [str(path) for path in _files(a.corpus, a.since)]
|
||||
if a.limit:
|
||||
files = files[: a.limit]
|
||||
|
||||
done: set[str] = set()
|
||||
if a.restart and a.out.exists():
|
||||
a.out.unlink() # --restart must TRUNCATE; the file is reopened in append mode below
|
||||
_repair_checkpoint(a.out)
|
||||
if a.out.exists() and not a.restart:
|
||||
with open(a.out, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
done.add(json.loads(line)["path"])
|
||||
except Exception: # noqa: S112 -- tolerate a torn last line
|
||||
continue
|
||||
done = {row["path"] for row in _read_records(a.out) if row.get("schema_version") == _SCHEMA_VERSION}
|
||||
todo = [p for p in files if p not in done]
|
||||
print(f"images {len(files)} already done {len(done)} to do {len(todo)} jobs {a.jobs}", flush=True)
|
||||
|
||||
counts: collections.Counter[str] = collections.Counter()
|
||||
status: collections.Counter[str] = collections.Counter()
|
||||
seen = 0
|
||||
a.out.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(a.out, "a", encoding="utf-8") as fh:
|
||||
for start in range(0, len(todo), a.batch):
|
||||
for rec in _run_batch(todo[start : start + a.batch], a.jobs, a.timeout):
|
||||
fh.write(json.dumps(rec) + "\n")
|
||||
status[str(rec["status"])] += 1
|
||||
for k in rec["keys"]: # type: ignore[union-attr]
|
||||
counts[str(k)] += 1
|
||||
seen += 1
|
||||
fh.flush()
|
||||
print(f" {seen}/{len(todo)} {dict(counts)} {dict(status)}", flush=True)
|
||||
print(f" {seen}/{len(todo)}", flush=True)
|
||||
|
||||
# Rebuild the paths file from the FULL record set, not just this run's slice.
|
||||
hits = []
|
||||
with open(a.out, encoding="utf-8") as fh:
|
||||
for line in fh:
|
||||
try:
|
||||
r = json.loads(line)
|
||||
except Exception: # noqa: S112
|
||||
continue
|
||||
if r.get("keys"):
|
||||
hits.append(r["path"])
|
||||
records = _read_records(a.out)
|
||||
selected_paths = set(files)
|
||||
records = [row for row in records if row["path"] in selected_paths]
|
||||
hits = [row["path"] for row in records if row.get("keys")]
|
||||
counts = collections.Counter(str(key) for row in records for key in row.get("keys", []))
|
||||
strict_counts = collections.Counter(str(key) for row in records for key in row.get("strict_keys", []))
|
||||
status = collections.Counter(str(row.get("status")) for row in records)
|
||||
# Derive the paths file from --out so a trial run with a scratch --out cannot
|
||||
# overwrite the shared list a full sweep produced.
|
||||
paths_out = a.out.with_suffix(".txt")
|
||||
paths_out.write_text("\n".join(sorted(set(hits))) + "\n", encoding="utf-8")
|
||||
paths_tmp = paths_out.with_suffix(paths_out.suffix + ".tmp")
|
||||
paths_tmp.write_text("\n".join(sorted(set(hits))) + ("\n" if hits else ""), encoding="utf-8")
|
||||
paths_tmp.replace(paths_out)
|
||||
print(f"\npositives: {len(set(hits))} images")
|
||||
for k, v in counts.most_common():
|
||||
print(f" {v:6d} {k}")
|
||||
print(f" {v:6d} {k} ({strict_counts[k]} strict)")
|
||||
print(f"statuses: {dict(status)}\npaths -> {paths_out}\nrecords -> {a.out}")
|
||||
if a.sheets:
|
||||
candidates = _discovery_candidates(
|
||||
records,
|
||||
per_cohort=a.per_cohort,
|
||||
random_quiet=a.random_quiet,
|
||||
seed=a.seed,
|
||||
)
|
||||
written = _write_discovery_sheets(candidates, a.sheets)
|
||||
print(f"discovery candidates: {len(candidates)} sheets written: {written} -> {a.sheets}")
|
||||
|
||||
from remove_ai_watermarks.watermark_registry import mark_keys
|
||||
|
||||
git = shutil.which("git")
|
||||
commit = (
|
||||
subprocess.run( # noqa: S603 -- resolved git binary and fixed read-only arguments
|
||||
[git, "rev-parse", "HEAD"],
|
||||
cwd=REPO,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
).stdout.strip()
|
||||
if git
|
||||
else ""
|
||||
)
|
||||
metadata = {
|
||||
"command": [str(Path(__file__).relative_to(REPO)), *sys.argv[1:]],
|
||||
"library_commit": commit or None,
|
||||
"library_version": version("remove-ai-watermarks"),
|
||||
"corpus": str(a.corpus.resolve()),
|
||||
"since": a.since.isoformat() if a.since else None,
|
||||
"selected_images": len(files),
|
||||
"complete_records": len(records),
|
||||
"registry_keys": mark_keys(),
|
||||
"schema_version": _SCHEMA_VERSION,
|
||||
}
|
||||
a.out.with_suffix(a.out.suffix + ".run.json").write_text(
|
||||
json.dumps(metadata, indent=2, sort_keys=True) + "\n", encoding="utf-8"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"name": "remove-ai-watermarks",
|
||||
"displayName": "Remove AI Watermarks",
|
||||
"version": "1.0.8",
|
||||
"version": "1.0.9",
|
||||
"description": "Identify and remove AI provenance marks from images and video the user generated or edited, using the remove-ai-watermarks CLI.",
|
||||
"author": {
|
||||
"name": "wiltodelta",
|
||||
|
||||
@@ -5,7 +5,7 @@ license: Apache-2.0
|
||||
compatibility: Python 3.11-3.14. Invisible image removal needs NVIDIA CUDA. Some video writes need ffmpeg. Installer may be uv, pipx, or pip.
|
||||
metadata:
|
||||
author: wiltodelta
|
||||
version: "1.0.8"
|
||||
version: "1.0.9"
|
||||
homepage: https://raiw.cc
|
||||
repository: https://github.com/wiltodelta/remove-ai-watermarks
|
||||
---
|
||||
|
||||
@@ -89,11 +89,15 @@ run on.
|
||||
|
||||
Pipeline profiles: `qwen-zimage` (the default), `sdxl-zimage`, `chroma-zimage`,
|
||||
`auto`. All are CUDA-only and all install from the same `qwen-zimage` extra.
|
||||
`auto` picks the engine from the file's own provenance -- chroma-zimage for
|
||||
Microsoft, qwen-zimage for OpenAI (since CLI 0.39.0), Google, Meta and unknown
|
||||
`auto` picks the engine from the file's own provenance -- sdxl-zimage for Google,
|
||||
chroma-zimage for Microsoft, qwen-zimage for OpenAI (since CLI 0.39.0), Meta and unknown
|
||||
-- so prefer it over naming an engine when the user has no reason to care. `sdxl-zimage` is the
|
||||
heavier alternative and `chroma-zimage` is the Apache-2.0 global stage.
|
||||
|
||||
Verified-text manifests work with every profile (since CLI 0.40.5). Each profile
|
||||
uses its own already loaded VAE donor, so `auto` keeps the normal SDXL route for a
|
||||
Google image instead of switching engines.
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks all image.png -o clean.png
|
||||
remove-ai-watermarks batch ./images --mode visible --output-dir ./clean
|
||||
|
||||
@@ -32,7 +32,7 @@ _GPU_MARKERS = ("GPU ", "gpu:")
|
||||
# told an agent its CLI was current right before it typed a value that build
|
||||
# rejects. tests/test_agent_skill.py pins it against the package version and
|
||||
# against every "(since CLI X.Y.Z)" annotation in the skill.
|
||||
MIN_CLI_VERSION = (0, 40, 0)
|
||||
MIN_CLI_VERSION = (0, 40, 5)
|
||||
# Markers a build without the pixel stack prints. The first is the current CLI's own
|
||||
# install hint; the rest are what older builds emit before the guard existed, and the
|
||||
# Homebrew formula ships exactly such a build.
|
||||
|
||||
@@ -33,7 +33,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.40.2"
|
||||
__version__ = "0.40.6"
|
||||
|
||||
__all__ = [
|
||||
"BatchItemResult",
|
||||
|
||||
@@ -18,6 +18,7 @@ from remove_ai_watermarks._internal.constants import (
|
||||
C2PA_AI_TOOLS,
|
||||
C2PA_AI_VENDORS,
|
||||
C2PA_CHUNK_TYPE,
|
||||
C2PA_IDENTITY_AI_ORGS,
|
||||
C2PA_ISSUERS,
|
||||
C2PA_SIGNATURES,
|
||||
C2PA_SOFT_BINDING_REGISTRY,
|
||||
@@ -529,7 +530,8 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
info["ai_tool"] = ", ".join(tools)
|
||||
if actions:
|
||||
info["actions"] = ", ".join(actions)
|
||||
if claim_generator_asserts_ai:
|
||||
signer_identity_asserts_ai = not C2PA_IDENTITY_AI_ORGS.isdisjoint(issuers)
|
||||
if claim_generator_asserts_ai or signer_identity_asserts_ai:
|
||||
info["c2pa_identity_ai"] = True
|
||||
|
||||
generated = any(
|
||||
|
||||
@@ -36,7 +36,7 @@ from remove_ai_watermarks._internal.two_stage_pipeline import (
|
||||
_prompt_cache_path,
|
||||
_store_prompt_payload,
|
||||
_target_size,
|
||||
edge_pad_to_grid,
|
||||
diffusers_vae_roundtrip,
|
||||
requested_steps,
|
||||
)
|
||||
|
||||
@@ -182,28 +182,12 @@ class ChromaZImagePipeline(TwoStageZImagePipeline):
|
||||
|
||||
def _vae_roundtrip(self, image: Image.Image) -> Image.Image:
|
||||
"""Reconstruct source pixels through the already loaded Chroma VAE."""
|
||||
import torch
|
||||
|
||||
pipe = self._load_global()
|
||||
source_width, source_height = image.size
|
||||
padded = edge_pad_to_grid(image, _LATENT_GRID)
|
||||
|
||||
tensor = pipe.image_processor.preprocess(
|
||||
padded,
|
||||
height=padded.height,
|
||||
width=padded.width,
|
||||
).to(device=self.device, dtype=self.torch_dtype)
|
||||
with torch.inference_mode():
|
||||
encoded = pipe.vae.encode(tensor)
|
||||
if hasattr(encoded, "latent_dist"):
|
||||
# A restoration donor must be deterministic. Diffusers samples this
|
||||
# distribution for img2img noise initialization; its mode is the
|
||||
# faithful VAE reconstruction needed here.
|
||||
latents = encoded.latent_dist.mode()
|
||||
elif hasattr(encoded, "latents"):
|
||||
latents = encoded.latents
|
||||
else:
|
||||
raise AttributeError("Chroma VAE encoder output contains no latents")
|
||||
decoded = pipe.vae.decode(latents, return_dict=False)[0]
|
||||
reconstructed = pipe.image_processor.postprocess(decoded, output_type="pil")[0]
|
||||
return reconstructed.crop((0, 0, source_width, source_height)).convert("RGB")
|
||||
return diffusers_vae_roundtrip(
|
||||
pipe,
|
||||
image,
|
||||
grid=_LATENT_GRID,
|
||||
device=self.device,
|
||||
dtype=self.torch_dtype,
|
||||
profile_name=self.profile_name,
|
||||
)
|
||||
@@ -32,7 +32,7 @@ C2PA_SIGNATURES = tuple(
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class C2paAiVendor:
|
||||
"""One issuer signature and its normalized product attribution."""
|
||||
"""One C2PA identity and its AI-product or neutral-signer attribution."""
|
||||
|
||||
issuer: bytes
|
||||
org: str
|
||||
@@ -41,6 +41,8 @@ class C2paAiVendor:
|
||||
synthid: bool = False
|
||||
asserts_ai: bool = False
|
||||
synthid_requires_watermark_action: bool = False
|
||||
signer_platform: str | None = None
|
||||
raw_signer_platforms: tuple[tuple[bytes, str], ...] = ()
|
||||
|
||||
|
||||
def _vendor(
|
||||
@@ -52,6 +54,8 @@ def _vendor(
|
||||
synthid: bool = False,
|
||||
asserts_ai: bool = False,
|
||||
synthid_requires_watermark_action: bool = False,
|
||||
signer_platform: str | None = None,
|
||||
raw_signer_platforms: tuple[tuple[bytes, str], ...] = (),
|
||||
) -> C2paAiVendor:
|
||||
token = issuer.encode() if isinstance(issuer, str) else issuer
|
||||
return C2paAiVendor(
|
||||
@@ -62,6 +66,8 @@ def _vendor(
|
||||
synthid=synthid,
|
||||
asserts_ai=asserts_ai,
|
||||
synthid_requires_watermark_action=synthid_requires_watermark_action,
|
||||
signer_platform=signer_platform,
|
||||
raw_signer_platforms=raw_signer_platforms,
|
||||
)
|
||||
|
||||
|
||||
@@ -109,11 +115,68 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
# "Bria Artificial Intelligence"). Found as an unmapped signer on 4 corpus
|
||||
# uploads 2026-08-08 that identify reported as unknown-signer C2PA.
|
||||
_vendor(b"Ideogram", "Ideogram", "Ideogram", "Ideogram", asserts_ai=True),
|
||||
_vendor(b"xAI Grok Imagine", "xAI Grok Imagine", "xAI Grok Imagine", "xAI", asserts_ai=True),
|
||||
_vendor(b"Producer.ai", "Producer.ai", "Producer.ai", "Producer.ai", asserts_ai=True),
|
||||
# This signature names only SPRING's legal entity, not a product. Preserve
|
||||
# that signer identity without guessing which product produced a file.
|
||||
_vendor(b"SPRING (SG) PTE. LTD.", "SPRING (SG) PTE. LTD.", "SPRING (SG) PTE. LTD.", "SPRING"),
|
||||
# These companies also sign ordinary edits and publications. Register the
|
||||
# signer identity for provenance display, but provide no AI platform and do
|
||||
# not let the identity assert an AI verdict.
|
||||
_vendor(
|
||||
b"TikTok Inc.",
|
||||
"TikTok",
|
||||
None,
|
||||
None,
|
||||
signer_platform="TikTok (C2PA signer)",
|
||||
),
|
||||
_vendor(
|
||||
b"Bytedance Pte",
|
||||
"ByteDance",
|
||||
None,
|
||||
None,
|
||||
signer_platform="ByteDance (C2PA signer)",
|
||||
raw_signer_platforms=(
|
||||
(b"Bytedance Pte. Ltd.", "ByteDance (C2PA signer)"),
|
||||
(b"CapCut/", "CapCut (C2PA signer)"),
|
||||
(b"capcut c2pa-rs", "CapCut (C2PA signer)"),
|
||||
),
|
||||
),
|
||||
_vendor(
|
||||
b"Anthropic Claude Content Signing",
|
||||
"Anthropic Claude",
|
||||
None,
|
||||
None,
|
||||
signer_platform="Anthropic Claude (C2PA signer)",
|
||||
),
|
||||
_vendor(
|
||||
b"Samsung Galaxy",
|
||||
"Samsung Galaxy",
|
||||
None,
|
||||
None,
|
||||
signer_platform="Samsung Galaxy (C2PA)",
|
||||
),
|
||||
_vendor(
|
||||
b"com.asus.gallery",
|
||||
"ASUS Gallery",
|
||||
None,
|
||||
None,
|
||||
signer_platform="ASUS Gallery (C2PA signer)",
|
||||
),
|
||||
_vendor(b"Truepic", "Truepic", None, None),
|
||||
)
|
||||
|
||||
C2PA_ISSUERS = {vendor.issuer: vendor.org for vendor in C2PA_AI_VENDORS}
|
||||
C2PA_IDENTITY_AI_ORGS = frozenset(vendor.org for vendor in C2PA_AI_VENDORS if vendor.asserts_ai)
|
||||
C2PA_SIGNER_PLATFORM_BY_ORG = {
|
||||
vendor.org: vendor.signer_platform for vendor in C2PA_AI_VENDORS if vendor.signer_platform is not None
|
||||
}
|
||||
C2PA_SIGNER_PLATFORMS = tuple(
|
||||
pair
|
||||
for vendor in C2PA_AI_VENDORS
|
||||
if vendor.signer_platform is not None
|
||||
for pair in (vendor.raw_signer_platforms or ((vendor.issuer, vendor.signer_platform),))
|
||||
)
|
||||
|
||||
# Product-specific claim generators can sign through a different upstream issuer.
|
||||
# Keep this attribution beside the issuer registry so every C2PA consumer has one
|
||||
@@ -128,6 +191,8 @@ C2PA_CLAIM_GENERATOR_PLATFORMS: tuple[tuple[str, str], ...] = (
|
||||
("recraft.ai", "Recraft"),
|
||||
("topaz labs image api", "Topaz Labs"),
|
||||
("tiktok ad creative toolbox", "TikTok Ad Creative Toolbox"),
|
||||
("fastvid", "FastVid"),
|
||||
("capcut", "CapCut (C2PA signer)"),
|
||||
)
|
||||
|
||||
C2PA_AI_TOOLS = {
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
"""Small shared numerical transforms used by independent pixel analyzers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
|
||||
def dct_matrix(np: Any, size: int = 8) -> Any:
|
||||
"""Return an orthonormal ``size`` by ``size`` DCT-II basis."""
|
||||
frequencies = np.arange(size)[:, None]
|
||||
samples = np.arange(size)[None, :]
|
||||
matrix = np.cos(np.pi * (2 * samples + 1) * frequencies / (2 * size))
|
||||
matrix[0, :] *= 1 / np.sqrt(2)
|
||||
return matrix * np.sqrt(2 / size)
|
||||
@@ -28,6 +28,7 @@ from remove_ai_watermarks._internal.two_stage_pipeline import (
|
||||
TwoStageZImagePipeline,
|
||||
_target_size,
|
||||
build_canny_control_image,
|
||||
diffusers_vae_roundtrip,
|
||||
requested_steps,
|
||||
)
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
@@ -120,3 +121,15 @@ class SdxlZImagePipeline(TwoStageZImagePipeline):
|
||||
if result.size != image.size:
|
||||
result = result.resize(image.size, Image.Resampling.LANCZOS)
|
||||
return result.convert("RGB")
|
||||
|
||||
def _vae_roundtrip(self, image: Image.Image) -> Image.Image:
|
||||
"""Reconstruct source pixels through the already loaded SDXL VAE."""
|
||||
pipe = self._load_global()
|
||||
return diffusers_vae_roundtrip(
|
||||
pipe,
|
||||
image,
|
||||
grid=_LATENT_GRID,
|
||||
device=self.device,
|
||||
dtype=self.torch_dtype,
|
||||
profile_name=self.profile_name,
|
||||
)
|
||||
@@ -96,6 +96,41 @@ def edge_pad_to_grid(image: Image.Image, grid: int) -> Image.Image:
|
||||
)
|
||||
|
||||
|
||||
def diffusers_vae_roundtrip(
|
||||
pipe: Any,
|
||||
image: Image.Image,
|
||||
*,
|
||||
grid: int,
|
||||
device: Any,
|
||||
dtype: Any,
|
||||
profile_name: str,
|
||||
) -> Image.Image:
|
||||
"""Reconstruct pixels deterministically through a Diffusers pipeline VAE."""
|
||||
import torch
|
||||
|
||||
source_width, source_height = image.size
|
||||
padded = edge_pad_to_grid(image, grid)
|
||||
tensor = pipe.image_processor.preprocess(
|
||||
padded,
|
||||
height=padded.height,
|
||||
width=padded.width,
|
||||
).to(device=device, dtype=dtype)
|
||||
with torch.inference_mode():
|
||||
encoded = pipe.vae.encode(tensor)
|
||||
if hasattr(encoded, "latent_dist"):
|
||||
# A restoration donor must be deterministic. Diffusers samples this
|
||||
# distribution for img2img noise initialization; its mode is the
|
||||
# faithful VAE reconstruction needed here.
|
||||
latents = encoded.latent_dist.mode()
|
||||
elif hasattr(encoded, "latents"):
|
||||
latents = encoded.latents
|
||||
else:
|
||||
raise AttributeError(f"{profile_name} VAE encoder output contains no latents")
|
||||
decoded = pipe.vae.decode(latents, return_dict=False)[0]
|
||||
reconstructed = pipe.image_processor.postprocess(decoded, output_type="pil")[0]
|
||||
return reconstructed.crop((0, 0, source_width, source_height)).convert("RGB")
|
||||
|
||||
|
||||
def resolve_face_model_residency(
|
||||
requested: bool | None,
|
||||
*,
|
||||
|
||||
@@ -55,9 +55,8 @@ PROFILE_ADAPTIVE_POLISH = {
|
||||
QWEN_ZIMAGE_PROFILE: False,
|
||||
SDXL_ZIMAGE_PROFILE: True,
|
||||
CHROMA_ZIMAGE_PROFILE: False,
|
||||
# Auto resolves to qwen-zimage or chroma-zimage, both of which keep the
|
||||
# input's detail level. If it ever resolves to an engine that needs polish,
|
||||
# the resolved profile's own default applies.
|
||||
# This fallback applies only before a vendor is known. The engine resolves
|
||||
# auto first so Google-to-SDXL receives SDXL's enabled default.
|
||||
AUTO_PROFILE: False,
|
||||
}
|
||||
|
||||
@@ -80,26 +79,26 @@ SDXL_LIGHTNING_PATTERN = "sdxl_lightning_4step_lora.safetensors"
|
||||
PROFILE_SEED = 0
|
||||
|
||||
# sdxl-zimage runs the qwen-zimage recipe on an SDXL global stage, and strength is
|
||||
# architecture-bound: at Qwen's 0.154 an SDXL global pass leaves SynthID on a native
|
||||
# 2816x1536 Gemini original, while 0.20, 0.25 and 0.30 all read clean in the Gemini
|
||||
# app. 0.25 keeps a rung of margin over that boundary, which the historical SDXL
|
||||
# certification argues for -- it recorded 0.20 as DETECTED against Gemini on an
|
||||
# older SDXL pipeline. OpenAI is the easier oracle: the profile already cleared
|
||||
# openai.com/verify at 0.1102, so 0.15 sits above what was verified rather than on
|
||||
# it. Unknown follows Gemini, the stricter of the two.
|
||||
# architecture-bound. The source-fresh 2026-09-15 calibration found Google CJK
|
||||
# detectable through 0.45 and clean at 0.50. The 0.50 candidate was then replicated
|
||||
# clean on independent busy, flat-text, and face-plus-text carriers. OpenAI is
|
||||
# the easier oracle: the profile cleared openai.com/verify at 0.1102, so 0.15
|
||||
# sits above what was verified rather than on it. Unknown follows Google, the
|
||||
# stricter measured cohort.
|
||||
#
|
||||
# Unlike qwen-zimage this is a flat vendor policy rather than a resolution curve,
|
||||
# because flat values are what was measured. Every verdict above comes from a fixed
|
||||
# strength at one size; no size dependence has been established for this stage.
|
||||
SDXL_ZIMAGE_OPENAI_STRENGTH = 0.15
|
||||
SDXL_ZIMAGE_GEMINI_STRENGTH = 0.25
|
||||
SDXL_ZIMAGE_GEMINI_STRENGTH = 0.50
|
||||
SDXL_ZIMAGE_UNKNOWN_STRENGTH = SDXL_ZIMAGE_GEMINI_STRENGTH
|
||||
|
||||
# qwen-zimage keeps its resolution curve for unknown content, but measured vendor
|
||||
# cohorts bypass it. Google remained detectable through 0.24375; 0.25 cleared all
|
||||
# three valid sources and 0.27 was separately repeated clean across them and three
|
||||
# accounts, so the independently checked 0.27 candidate is the operating floor.
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.27
|
||||
# cohorts bypass it. A source-fresh 2026-09-15 sweep found Latin scenes and faces
|
||||
# clean at 0.15 but CJK and Cyrillic text requiring 0.35. The 0.35 candidate was
|
||||
# then independently replicated clean on busy, flat-text, and face-plus-text CJK
|
||||
# carriers, so 0.35 is the content-agnostic operating floor.
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.35
|
||||
|
||||
# The two OpenAI sources first cleared at 0.06225 and 0.0695. Add one full observed
|
||||
# cross-source spread (0.00725) to the worst clean boundary: 0.0695 + 0.00725.
|
||||
@@ -122,7 +121,7 @@ QWEN_ZIMAGE_MICROSOFT_STRENGTH = 0.15
|
||||
# mug <= 0.03, text <= 0.015. Worst clean boundary plus one full observed
|
||||
# cross-source spread: 0.06 + (0.0525 - 0.015) = 0.0975, rounded up to 0.1.
|
||||
# sdxl-zimage has no measured Meta floor; its vendor map stays without a meta
|
||||
# entry so an explicit --vendor meta there falls to the unknown 0.25, which is
|
||||
# entry so an explicit --vendor meta there falls to the unknown 0.50, which is
|
||||
# above this floor and therefore conservative.
|
||||
QWEN_ZIMAGE_META_STRENGTH = 0.1
|
||||
|
||||
@@ -165,11 +164,12 @@ _SDXL_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
|
||||
# - Microsoft InvisMark: paint-1 (0.06, 0.08], paint-2 <= 0.04, paint-3
|
||||
# (0.06, 0.08] -> 0.08 + (0.08 - 0.04) = 0.12, rounded up to the measured
|
||||
# rung 0.125, oracle-verified clean on both worst sources. BELOW qwen's 0.15.
|
||||
# - Google: 633uuy (0.20, 0.25], akdbei (0.20, 0.25], y48j3c (0.08, 0.12],
|
||||
# 3mc4t9 (0.08, 0.12] -> 0.25 + (0.25 - 0.12) = 0.38, rounded up to 0.40,
|
||||
# oracle-verified clean on the worst fixture. ABOVE qwen's 0.27; at 0.40 the
|
||||
# regeneration destroys dense text and collapses face identity, which is why
|
||||
# the matched-strength addendum recommends the adaptive-strength follow-up.
|
||||
# - Google: the source-fresh 2026-09-15 sweep superseded the four-fixture 0.40
|
||||
# derivation: CJK remained detected at 0.40 and cleared at 0.50. The 0.50
|
||||
# candidate was independently replicated clean on busy, flat-text, and
|
||||
# face-plus-text CJK carriers. Face detection cannot select the older 0.125
|
||||
# arm safely because fresh face-plus-text carriers require more denoise and the
|
||||
# runtime has no measured text-content gate.
|
||||
# - Meta Content Seal: lighthouse (0.08, 0.10], fox (0.06, 0.08], night_city
|
||||
# (0.045, 0.06], studio_mug (0.03, 0.045], text_poster <= 0.03 ->
|
||||
# 0.10 + (0.10 - 0.03) = 0.17. ABOVE qwen's 0.1; the floors exist because
|
||||
@@ -177,23 +177,10 @@ _SDXL_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
|
||||
# worse per unit strength.
|
||||
CHROMA_ZIMAGE_OPENAI_STRENGTH = 0.20
|
||||
CHROMA_ZIMAGE_MICROSOFT_STRENGTH = 0.125
|
||||
CHROMA_ZIMAGE_GOOGLE_STRENGTH = 0.40
|
||||
CHROMA_ZIMAGE_GOOGLE_STRENGTH = 0.50
|
||||
CHROMA_ZIMAGE_META_STRENGTH = 0.17
|
||||
CHROMA_ZIMAGE_UNKNOWN_STRENGTH = CHROMA_ZIMAGE_GOOGLE_STRENGTH
|
||||
|
||||
# Content-adaptive Google floor: the four-fixture calibration showed a clean
|
||||
# face-count split. Both zero-face fixtures (dense-text cards 633uuy and akdbei)
|
||||
# need 0.25 first-clean; both face fixtures (y48j3c with 17 detected faces and
|
||||
# 3mc4t9 with 7) clear at 0.12. The flat 0.40 floor is the zero-face policy
|
||||
# (worst 0.25 + spread 0.13 = 0.38, rounded to 0.40). Face content can use the
|
||||
# measured 0.125 rung instead: both face fixtures first-cleaned at 0.12, and
|
||||
# with identical boundaries the cross-source spread is zero, so 0.125 (the next
|
||||
# measured rung above 0.12) is the operating point. This split is
|
||||
# Google-SynthID-specific: OpenAI's face fixture was HARDER than its text
|
||||
# fixture (0.075 vs 0.06), so no other cohort gets an adaptive arm.
|
||||
# Oracle-verified 2026-08-30; see docs/chroma1-engine-research.md.
|
||||
CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH = 0.125
|
||||
|
||||
_CHROMA_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
|
||||
"openai": CHROMA_ZIMAGE_OPENAI_STRENGTH,
|
||||
"google": CHROMA_ZIMAGE_GOOGLE_STRENGTH,
|
||||
@@ -209,10 +196,11 @@ _ALIASES = {
|
||||
|
||||
# The deterministic per-cohort selection policy: which profile wins on which vendor, from the
|
||||
# 2026-08-29/30 four-cohort calibration (docs/chroma1-engine-research.md).
|
||||
# chroma-zimage has the lower measured Microsoft floor. qwen-zimage wins on
|
||||
# OpenAI, Google, and Meta. Unknown stays on qwen (the shipped default,
|
||||
# conservative).
|
||||
# chroma-zimage has the lower measured Microsoft floor. SDXL wins Google on
|
||||
# paired quality at the independently clean operating points. Qwen wins OpenAI
|
||||
# and Meta; unknown stays on Qwen, the shipped conservative fallback.
|
||||
_ENGINE_BY_VENDOR: dict[str, str] = {
|
||||
"google": SDXL_ZIMAGE_PROFILE,
|
||||
"microsoft": CHROMA_ZIMAGE_PROFILE,
|
||||
}
|
||||
|
||||
@@ -228,6 +216,14 @@ def normalize_profile(profile: str) -> str:
|
||||
return _ALIASES.get(value, value)
|
||||
|
||||
|
||||
def resolve_effective_profile(profile: str, vendor: str | None) -> str:
|
||||
"""Resolve a configured profile to the concrete engine for one image."""
|
||||
normalized = normalize_profile(profile)
|
||||
if normalized == AUTO_PROFILE:
|
||||
return resolve_auto_profile(vendor)
|
||||
return normalized
|
||||
|
||||
|
||||
def resolve_seed(seed: int | None) -> int:
|
||||
"""Keep every profile reproducible by default."""
|
||||
return PROFILE_SEED if seed is None else seed
|
||||
@@ -247,9 +243,7 @@ def global_offload_supported(pipeline: str) -> bool:
|
||||
the only unresolved ``auto`` load, and that path deliberately uses qwen-zimage
|
||||
as its fallback, so it has the same offload support as qwen-zimage.
|
||||
"""
|
||||
profile = normalize_profile(pipeline)
|
||||
if profile == AUTO_PROFILE:
|
||||
profile = resolve_auto_profile(None)
|
||||
profile = resolve_effective_profile(pipeline, None)
|
||||
return profile in GLOBAL_OFFLOAD_PROFILES
|
||||
|
||||
|
||||
@@ -273,7 +267,6 @@ def resolve_strength(
|
||||
pipeline: str | None = None,
|
||||
*,
|
||||
size: tuple[int, int] | None = None,
|
||||
face_count: int | None = None,
|
||||
) -> float:
|
||||
"""Resolve a user override or the calibrated policy for a profile and vendor.
|
||||
|
||||
@@ -288,13 +281,11 @@ def resolve_strength(
|
||||
"""
|
||||
if strength is not None:
|
||||
return strength
|
||||
normalized = normalize_profile(pipeline or "")
|
||||
normalized = resolve_effective_profile(pipeline or "", vendor)
|
||||
if normalized == SDXL_ZIMAGE_PROFILE:
|
||||
return _SDXL_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), SDXL_ZIMAGE_UNKNOWN_STRENGTH)
|
||||
if normalized == CHROMA_ZIMAGE_PROFILE:
|
||||
vendor_key = (vendor or "").casefold()
|
||||
if vendor_key == "google" and face_count is not None and face_count > 0:
|
||||
return CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH
|
||||
return _CHROMA_ZIMAGE_STRENGTH_BY_VENDOR.get(vendor_key, CHROMA_ZIMAGE_UNKNOWN_STRENGTH)
|
||||
if size is None:
|
||||
raise ValueError("qwen-zimage resolves strength from image area, so size is required")
|
||||
|
||||
@@ -11,7 +11,6 @@ from typing import TYPE_CHECKING, Any
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
AUTO_PROFILE,
|
||||
CHROMA_ZIMAGE_PROFILE,
|
||||
DEFAULT_PROFILE,
|
||||
INVISIBLE_EXTRA,
|
||||
@@ -21,7 +20,7 @@ from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
SDXL_ZIMAGE_PROFILE,
|
||||
global_offload_supported,
|
||||
normalize_profile,
|
||||
resolve_auto_profile,
|
||||
resolve_effective_profile,
|
||||
resolve_seed,
|
||||
resolve_strength,
|
||||
)
|
||||
@@ -43,6 +42,14 @@ except ImportError:
|
||||
torch = None # type: ignore[assignment]
|
||||
_HAS_TORCH = False
|
||||
|
||||
|
||||
def _dtype_for_profile(profile: str) -> Any:
|
||||
"""Return the weight dtype bound to a concrete regeneration profile."""
|
||||
if profile == SDXL_ZIMAGE_PROFILE:
|
||||
return torch.float16 # type: ignore[union-attr]
|
||||
return torch.bfloat16 # type: ignore[union-attr]
|
||||
|
||||
|
||||
# Probed once at import. ``torch`` is imported above rather than probed because this
|
||||
# module needs the object, not just the answer.
|
||||
_HAS_REMOVAL_MODULES = module_available(*(name for name in REMOVAL_MODULES if name != "torch"))
|
||||
@@ -111,13 +118,14 @@ class WatermarkRemover:
|
||||
controlnet_conditioning_scale: float = 1.0,
|
||||
cpu_offload: bool = False,
|
||||
) -> None:
|
||||
self.model_profile = normalize_profile(pipeline)
|
||||
if self.model_profile not in PROFILE_CHOICES:
|
||||
self.configured_profile = normalize_profile(pipeline)
|
||||
if self.configured_profile not in PROFILE_CHOICES:
|
||||
raise ValueError(f"Unsupported pipeline '{pipeline}'. Use one of: {', '.join(PROFILE_CHOICES)}.")
|
||||
# The auto profile resolves to a concrete engine per-image in
|
||||
# remove_watermark, once the provenance vendor is known. It never
|
||||
# resolves to sdxl-zimage, so the dtype below is correct either way.
|
||||
self._auto = self.model_profile == AUTO_PROFILE
|
||||
# Auto starts on the concrete Qwen fallback for preload, then resolves
|
||||
# from the immutable configured profile for every image. Keeping the
|
||||
# configured and active profiles separate prevents one image's vendor
|
||||
# from becoming the next image's policy input.
|
||||
self.model_profile = resolve_effective_profile(self.configured_profile, None)
|
||||
# There is no ``model_id`` parameter and no ``model_id`` attribute: each
|
||||
# profile pins a fixed model stack, and the dtype below is bound to that
|
||||
# stack's weights. Both used to be constructor overrides that existed only to
|
||||
@@ -136,13 +144,10 @@ class WatermarkRemover:
|
||||
"every identify command still run on CPU."
|
||||
)
|
||||
|
||||
if self.model_profile == SDXL_ZIMAGE_PROFILE:
|
||||
# SDXL ships fp16 weights and an fp16-safe VAE; bf16 would give up the
|
||||
# variant without buying anything on this architecture.
|
||||
self.torch_dtype = torch.float16 # type: ignore[union-attr]
|
||||
else:
|
||||
# qwen-zimage, chroma-zimage, and auto all resolve to bf16 engines.
|
||||
self.torch_dtype = torch.bfloat16 # type: ignore[union-attr]
|
||||
# SDXL ships fp16 weights and an fp16-safe VAE. Qwen and Chroma use
|
||||
# bf16; auto starts on the concrete Qwen fallback and switches together
|
||||
# with its active profile per image.
|
||||
self.torch_dtype = _dtype_for_profile(self.model_profile)
|
||||
|
||||
self.cpu_offload = cpu_offload
|
||||
self.controlnet_conditioning_scale = controlnet_conditioning_scale
|
||||
@@ -212,26 +217,6 @@ class WatermarkRemover:
|
||||
|
||||
remove_ai_metadata(output_path, output_path, keep_standard=True)
|
||||
|
||||
def _resolve_chroma_strength(
|
||||
self,
|
||||
strength: float | None,
|
||||
vendor: str | None,
|
||||
source: Image.Image,
|
||||
) -> float:
|
||||
"""Resolve strength, running face detection for the chroma Google arm.
|
||||
|
||||
The content-adaptive Google floor needs a face count. YuNet is fast
|
||||
(~50 ms) and the model download is already managed by the shared base.
|
||||
"""
|
||||
if self.model_profile != CHROMA_ZIMAGE_PROFILE or (vendor or "").casefold() != "google":
|
||||
return resolve_strength(strength, vendor, self.model_profile, size=source.size)
|
||||
if strength is not None:
|
||||
return strength
|
||||
from remove_ai_watermarks._internal.two_stage_pipeline import detect_faces
|
||||
|
||||
face_count = len(detect_faces(source))
|
||||
return resolve_strength(strength, vendor, self.model_profile, size=source.size, face_count=face_count)
|
||||
|
||||
def remove_watermark(
|
||||
self,
|
||||
image_path: Path,
|
||||
@@ -257,21 +242,19 @@ class WatermarkRemover:
|
||||
with Image.open(image_path) as opened:
|
||||
source = opened.convert("RGB")
|
||||
|
||||
# The auto policy resolves per-image once the provenance vendor is
|
||||
# known, BEFORE the strength resolution so the right engine's floors
|
||||
# are used. If the vendor changed the engine, reset the cached pipeline.
|
||||
if self._auto:
|
||||
resolved = resolve_auto_profile(vendor)
|
||||
if resolved != self.model_profile:
|
||||
self.model_profile = resolved
|
||||
self._qwen_zimage_pipeline = None
|
||||
if text_manifest is not None and self.model_profile == SDXL_ZIMAGE_PROFILE:
|
||||
raise ValueError("Verified text restoration is not supported by the sdxl-zimage profile")
|
||||
# Resolve from the configured policy for every image, before strength
|
||||
# resolution, so a previous image's vendor cannot pin the next one to
|
||||
# its engine. Reset cached weights only when the concrete engine changes.
|
||||
resolved = resolve_effective_profile(self.configured_profile, vendor)
|
||||
if resolved != self.model_profile:
|
||||
self.model_profile = resolved
|
||||
self.torch_dtype = _dtype_for_profile(resolved)
|
||||
self._qwen_zimage_pipeline = None
|
||||
if text_manifest is not None and tile:
|
||||
raise ValueError("Verified text restoration is not calibrated with tiled diffusion")
|
||||
if fidelity_anchor and self.model_profile != QWEN_ZIMAGE_PROFILE:
|
||||
raise ValueError("The fidelity anchor is supported only by the qwen-zimage profile")
|
||||
resolved_strength = self._resolve_chroma_strength(strength, vendor, source)
|
||||
resolved_strength = resolve_strength(strength, vendor, self.model_profile, size=source.size)
|
||||
if not 0.0 <= resolved_strength <= 1.0:
|
||||
raise ValueError(f"Strength must be between 0.0 and 1.0, got {resolved_strength}")
|
||||
|
||||
|
||||
@@ -227,8 +227,8 @@ _PIPELINE_HELP = (
|
||||
"followed by SAM-masked Z-Image face repair; sdxl-zimage = the same recipe and the "
|
||||
"same face stage on an SDXL global pass, which needs more denoise; chroma-zimage = "
|
||||
"the same face stage on an Apache-2.0 Chroma1 global pass; auto = pick the engine "
|
||||
"from the provenance (chroma-zimage for Microsoft, qwen-zimage for "
|
||||
"OpenAI/Google/Meta/unknown). "
|
||||
"from the provenance (sdxl-zimage for Google, chroma-zimage for Microsoft, "
|
||||
"qwen-zimage for OpenAI/Meta/unknown). "
|
||||
"All are CUDA-ONLY -- install the qwen-zimage extra. There is no CPU or MPS profile for "
|
||||
"invisible-watermark removal."
|
||||
)
|
||||
@@ -318,9 +318,8 @@ _text_manifest_option = click.option(
|
||||
type=click.Path(exists=True, dir_okay=False, path_type=Path),
|
||||
default=None,
|
||||
help=(
|
||||
"Experimental verified-text restoration manifest. Requires qwen-zimage or "
|
||||
"chroma-zimage, the text-restoration extra, native untiled geometry, and no "
|
||||
"postprocessing."
|
||||
"Experimental verified-text restoration manifest. Requires the text-restoration "
|
||||
"extra, native untiled geometry, and no postprocessing."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -44,6 +44,8 @@ from remove_ai_watermarks._internal.constants import (
|
||||
C2PA_CLAIM_GENERATOR_PLATFORMS,
|
||||
C2PA_IDENTITY_AI_ORGS,
|
||||
C2PA_ISSUERS,
|
||||
C2PA_SIGNER_PLATFORM_BY_ORG,
|
||||
C2PA_SIGNER_PLATFORMS,
|
||||
)
|
||||
from remove_ai_watermarks._internal.schema import require_schema_version
|
||||
from remove_ai_watermarks.metadata import (
|
||||
@@ -631,7 +633,8 @@ def _ai_tools_in(data: bytes) -> list[str]:
|
||||
# Only tokens verified against a real signed file are listed (Leica, Nikon,
|
||||
# Sony, Truepic, Google Pixel); add Canon/Bria as real samples are captured.
|
||||
# Samsung Galaxy is an AI-capable editing device, not a pure-capture camera, so
|
||||
# it lives in `_SIGNER_C2PA_PLATFORM` below (it must not feed the camera clash).
|
||||
# it lives in the neutral `C2PA_SIGNER_PLATFORMS` registry (it must not feed the
|
||||
# camera clash).
|
||||
_DEVICE_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
|
||||
(b"lc_c2pa", "Leica (camera, C2PA capture)"),
|
||||
(b"Leica Camera", "Leica (camera, C2PA capture)"),
|
||||
@@ -646,6 +649,8 @@ _DEVICE_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
|
||||
# Truepic is a C2PA signing authority whose name appears in the trust chain
|
||||
# of unrelated manifests (e.g. OpenAI), so the bare token mis-attributes.
|
||||
(b"Truepic_Lens", "Truepic Lens (verified capture)"),
|
||||
(b"vivo Camera", "vivo (camera, C2PA capture)"),
|
||||
(b"vivo X300 Pro", "vivo (camera, C2PA capture)"),
|
||||
)
|
||||
|
||||
|
||||
@@ -743,15 +748,14 @@ def _device_platform(head: bytes) -> str | None:
|
||||
# (distinct from the EXIF "SM-xxxx" model string on ordinary Samsung photos).
|
||||
# com.asus.gallery -- ASUS Gallery claim_generator (a C2PA-signed edit, no AI
|
||||
# source type or genAIType on the samples, so it never asserts is_ai).
|
||||
_SIGNER_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
|
||||
(b"Samsung Galaxy", "Samsung Galaxy (C2PA)"),
|
||||
(b"com.asus.gallery", "ASUS Gallery (C2PA signer)"),
|
||||
)
|
||||
|
||||
|
||||
def _signer_platform(head: bytes) -> str | None:
|
||||
"""Map a C2PA editing-app / AI-capable-device signer token to a platform."""
|
||||
return _first_token_match(head, _SIGNER_C2PA_PLATFORM)
|
||||
def _signer_platform(head: bytes, issuers: list[str]) -> str | None:
|
||||
"""Map a raw or normalized C2PA signer identity to a neutral platform."""
|
||||
raw = _first_token_match(head, C2PA_SIGNER_PLATFORMS)
|
||||
normalized = next(
|
||||
(C2PA_SIGNER_PLATFORM_BY_ORG[org] for org in issuers if org in C2PA_SIGNER_PLATFORM_BY_ORG),
|
||||
None,
|
||||
)
|
||||
return raw or normalized
|
||||
|
||||
|
||||
def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None:
|
||||
@@ -1178,7 +1182,6 @@ def _identify_from_evidence(
|
||||
# below keeps the full buffer: their markers are long and distinctive.
|
||||
region = _metadata_region(head)
|
||||
camera_label = _device_platform(region)
|
||||
signer_label = _signer_platform(region)
|
||||
|
||||
# ── C2PA Content Credentials ────────────────────────────────────
|
||||
has_c2pa = bool(info) or c2pa_marker_in(head)
|
||||
@@ -1189,6 +1192,7 @@ def _identify_from_evidence(
|
||||
# by substring made the displayed reason a second, looser rule than the verdict.
|
||||
failed_c2pa_codes = [str(code) for code in cast("list[object]", info.get("c2pa_failed_codes", []))]
|
||||
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(region)
|
||||
signer_label = _signer_platform(region, issuers)
|
||||
# Full AI generation (trainedAlgorithmicMedia) vs an AI-enhanced real photo
|
||||
# (compositeWithTrainedAlgorithmicMedia). The structured kind is parsed once in
|
||||
# _internal.c2pa._structured_manifest_fields (covers PNG + any container the c2pa-python
|
||||
@@ -1216,15 +1220,18 @@ def _identify_from_evidence(
|
||||
or (", ".join(tools) if (tools := _ai_tools_in(region)) else None)
|
||||
)
|
||||
# Platform: a distinctive device/camera token in the manifest wins (it is the
|
||||
# signer/producer), then an editing-app/AI-device signer (Samsung Galaxy,
|
||||
# ASUS Gallery), with the issuer byte-scan only as fallback. The issuer scan
|
||||
# alone mis-attributed real samples (Leica->Truepic timestamp authority,
|
||||
# Nikon->Adobe namespace, Pixel->Google Gemini) -- the token scans fix that.
|
||||
# signer/producer), then an exact product generator, then an editing-app or
|
||||
# AI-device signer (Samsung Galaxy, ASUS Gallery), with the issuer byte-scan
|
||||
# only as fallback. The issuer scan alone mis-attributed real samples
|
||||
# (Leica->Truepic timestamp authority, Nikon->Adobe namespace, Pixel->Google
|
||||
# Gemini) -- the token scans fix that.
|
||||
platform = (
|
||||
(
|
||||
camera_label
|
||||
# Exact product generators are useful provenance even when the
|
||||
# signed operation is a non-AI edit (for example, CapCut).
|
||||
or _claim_generator_platform(generator)
|
||||
or signer_label
|
||||
or (_claim_generator_platform(generator) if c2pa_is_ai else None)
|
||||
or (_claim_generator_platform(str(info.get("ai_tool"))) if c2pa_is_ai and info.get("ai_tool") else None)
|
||||
or _attribute_platform(issuers, is_ai=c2pa_is_ai)
|
||||
)
|
||||
|
||||
@@ -19,8 +19,8 @@ from typing import TYPE_CHECKING, Any
|
||||
from ._internal.watermark_profiles import (
|
||||
DEFAULT_PROFILE,
|
||||
REMOVAL_MODULES,
|
||||
SDXL_ZIMAGE_PROFILE,
|
||||
resolve_adaptive_polish,
|
||||
resolve_effective_profile,
|
||||
resolve_seed,
|
||||
)
|
||||
|
||||
@@ -191,9 +191,9 @@ class InvisibleEngine:
|
||||
def preload(self, *, global_only: bool = False) -> None:
|
||||
"""Eagerly load the pipeline so download progress is visible.
|
||||
|
||||
For ``qwen-zimage``, ``global_only=True`` loads the mandatory Qwen stage
|
||||
and leaves the optional Z-Image and SAM face stack lazy until a face is
|
||||
detected. Other profiles have no optional stage and ignore the flag.
|
||||
For every concrete profile, ``global_only=True`` loads its mandatory
|
||||
global stage and leaves the shared Z-Image and SAM face stack lazy until
|
||||
a face is detected.
|
||||
"""
|
||||
self._remover.preload(global_only=global_only)
|
||||
|
||||
@@ -267,13 +267,19 @@ class InvisibleEngine:
|
||||
import tempfile
|
||||
|
||||
seed = resolve_seed(seed)
|
||||
adaptive_polish = resolve_adaptive_polish(adaptive_polish, self._remover.model_profile)
|
||||
effective_profile = resolve_effective_profile(self._remover.configured_profile, vendor)
|
||||
# Verified text must be the final pixel-writing stage. SDXL normally enables
|
||||
# adaptive polish by profile default, so an unset value becomes False here;
|
||||
# an explicit True remains an error below instead of being silently ignored.
|
||||
adaptive_polish = (
|
||||
False
|
||||
if text_manifest is not None and adaptive_polish is None
|
||||
else resolve_adaptive_polish(adaptive_polish, effective_profile)
|
||||
)
|
||||
|
||||
if fidelity_anchor and text_manifest is None:
|
||||
raise ValueError("fidelity_anchor requires a text manifest")
|
||||
if text_manifest is not None:
|
||||
if self._remover.model_profile == SDXL_ZIMAGE_PROFILE:
|
||||
raise ValueError("--text-manifest is not supported by the sdxl-zimage profile")
|
||||
if tile:
|
||||
raise ValueError("--text-manifest is not calibrated with --tile")
|
||||
if max_resolution != 0:
|
||||
|
||||
@@ -0,0 +1,266 @@
|
||||
"""Detect and disrupt the pixel payload written by local Microsoft Paint.
|
||||
|
||||
This is a clean-room implementation of the observed ``Watermarker.dll`` wire
|
||||
format. It carries no Microsoft binary, symbols, or writer-quality model. The
|
||||
reader validates the repeated 144-bit message before the remover touches pixels;
|
||||
callers with pristine C2PA should also pass its signed watermark UUID.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import tempfile
|
||||
import uuid
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
_BLOCK_SIZE = 4
|
||||
_MAP_SIZE = 12
|
||||
_PAYLOAD_BITS = _MAP_SIZE * _MAP_SIZE
|
||||
_QUANTUM = 24.0
|
||||
_CENTERS = (6.0, 18.0)
|
||||
_MAX_CENTER_ERROR = 2.0
|
||||
_MIN_SUPPORT = 3
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LocalInvisMarkDetection:
|
||||
"""A validated local Paint payload recovered from repeated pixel carriers."""
|
||||
|
||||
watermark_id: uuid.UUID
|
||||
payload: bytes
|
||||
minimum_support: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class LocalInvisMarkRemoval(LocalInvisMarkDetection):
|
||||
"""A disrupted local Paint payload and the number of rewritten carriers."""
|
||||
|
||||
changed_blocks: int
|
||||
|
||||
|
||||
@dataclass(frozen=True, slots=True)
|
||||
class _Analysis:
|
||||
ll: NDArray[Any]
|
||||
bits: NDArray[Any]
|
||||
eligible: NDArray[Any]
|
||||
bit_indices: NDArray[Any]
|
||||
|
||||
|
||||
def _dct_matrix() -> NDArray[Any]:
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks._internal.image_math import dct_matrix
|
||||
|
||||
return dct_matrix(np, _BLOCK_SIZE)
|
||||
|
||||
|
||||
def _luminance(rgb: NDArray[Any]) -> NDArray[Any]:
|
||||
import numpy as np
|
||||
|
||||
return rgb.astype(np.float64) @ np.array((0.299, 0.587, 0.114), dtype=np.float64)
|
||||
|
||||
|
||||
def _ll_plane(luminance: NDArray[Any]) -> NDArray[Any]:
|
||||
height = luminance.shape[0] - luminance.shape[0] % 2
|
||||
width = luminance.shape[1] - luminance.shape[1] % 2
|
||||
plane = luminance[:height, :width]
|
||||
return (plane[0::2, 0::2] + plane[0::2, 1::2] + plane[1::2, 0::2] + plane[1::2, 1::2]) * 0.5
|
||||
|
||||
|
||||
def _analyze(rgb: NDArray[Any]) -> _Analysis:
|
||||
import numpy as np
|
||||
|
||||
ll = _ll_plane(_luminance(rgb))
|
||||
block_rows = ll.shape[0] // _BLOCK_SIZE
|
||||
block_columns = ll.shape[1] // _BLOCK_SIZE
|
||||
blocks = (
|
||||
ll[: block_rows * _BLOCK_SIZE, : block_columns * _BLOCK_SIZE]
|
||||
.reshape(block_rows, _BLOCK_SIZE, block_columns, _BLOCK_SIZE)
|
||||
.transpose(0, 2, 1, 3)
|
||||
)
|
||||
transform = _dct_matrix()
|
||||
coefficients = transform @ blocks @ transform.T
|
||||
matrices = coefficients.reshape(block_rows, block_columns, -1)[..., 1:].reshape(block_rows, block_columns, 3, 5)
|
||||
singular_values = np.linalg.svd(matrices, compute_uv=False)[..., 0]
|
||||
residue = singular_values % _QUANTUM
|
||||
center_errors = np.stack(
|
||||
[np.minimum(abs(residue - center), _QUANTUM - abs(residue - center)) for center in _CENTERS],
|
||||
axis=-1,
|
||||
)
|
||||
bits = np.argmin(center_errors, axis=-1).astype(np.uint8)
|
||||
eligible = np.min(center_errors, axis=-1) <= _MAX_CENTER_ERROR
|
||||
block_y, block_x = np.indices((block_rows, block_columns))
|
||||
bit_indices = (block_y % _MAP_SIZE) * _MAP_SIZE + block_x % _MAP_SIZE
|
||||
return _Analysis(ll=ll, bits=bits, eligible=eligible, bit_indices=bit_indices)
|
||||
|
||||
|
||||
def _validated_detection(rgb: NDArray[Any]) -> tuple[LocalInvisMarkDetection, _Analysis] | None:
|
||||
import numpy as np
|
||||
|
||||
analysis = _analyze(rgb)
|
||||
support = np.zeros((_PAYLOAD_BITS, 2), dtype=np.int32)
|
||||
np.add.at(support, (analysis.bit_indices[analysis.eligible], analysis.bits[analysis.eligible]), 1)
|
||||
winning_support = support.max(axis=1)
|
||||
if np.any(winning_support < _MIN_SUPPORT) or np.any(support[:, 0] == support[:, 1]):
|
||||
return None
|
||||
|
||||
bits = (support[:, 1] > support[:, 0]).astype(np.uint8)
|
||||
payload = np.packbits(bits).tobytes()
|
||||
if len(payload) != 18 or payload[0] != 0x4C or payload[-1] != sum(payload[1:17]) & 0xFF:
|
||||
return None
|
||||
watermark_id = uuid.UUID(bytes_le=payload[1:17])
|
||||
if watermark_id.int == 0:
|
||||
return None
|
||||
detection = LocalInvisMarkDetection(
|
||||
watermark_id=watermark_id,
|
||||
payload=payload,
|
||||
minimum_support=int(winning_support.min()),
|
||||
)
|
||||
return detection, analysis
|
||||
|
||||
|
||||
def _read_rgb(source: str | Path) -> tuple[NDArray[Any], NDArray[Any] | None]:
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
bgr, alpha = image_io.read_bgr_and_alpha(source)
|
||||
if bgr is None:
|
||||
raise ValueError(f"Could not read image: {source}")
|
||||
return bgr[..., ::-1], alpha
|
||||
|
||||
|
||||
def detect_local_invismark(source: str | Path) -> LocalInvisMarkDetection | None:
|
||||
"""Return a validated local Paint payload, or ``None`` for an uncertain image."""
|
||||
rgb, _alpha = _read_rgb(source)
|
||||
recovered = _validated_detection(rgb)
|
||||
return recovered[0] if recovered is not None else None
|
||||
|
||||
|
||||
def invismark_id_from_c2pa_info(info: dict[str, Any]) -> uuid.UUID | None:
|
||||
"""Return the one signed UUID for a Microsoft InvisMark soft binding."""
|
||||
algorithms = {value.strip() for value in str(info.get("soft_binding_algorithm") or "").split(",") if value.strip()}
|
||||
if "com.microsoft.invismark.1" not in algorithms:
|
||||
return None
|
||||
values = [value.strip() for value in str(info.get("soft_binding_value") or "").split(",") if value.strip()]
|
||||
parsed: list[uuid.UUID] = []
|
||||
for value in values:
|
||||
try:
|
||||
parsed.append(uuid.UUID(value))
|
||||
except ValueError:
|
||||
continue
|
||||
unique = tuple(dict.fromkeys(parsed))
|
||||
return unique[0] if len(unique) == 1 else None
|
||||
|
||||
|
||||
def invismark_id_from_c2pa(source: str | Path) -> uuid.UUID | None:
|
||||
"""Read the signed Microsoft InvisMark UUID from a pristine image."""
|
||||
from remove_ai_watermarks._internal.c2pa import extract_c2pa_info
|
||||
|
||||
try:
|
||||
return invismark_id_from_c2pa_info(extract_c2pa_info(Path(source)))
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
def disrupt_local_invismark(
|
||||
source: str | Path,
|
||||
output: str | Path,
|
||||
*,
|
||||
expected_watermark_id: str | uuid.UUID | None = None,
|
||||
) -> LocalInvisMarkRemoval | None:
|
||||
"""Invert confirmed local Paint carriers and atomically write the result.
|
||||
|
||||
No output is written unless the payload validates, an optional signed UUID
|
||||
agrees, and the encoded output no longer contains a valid local payload.
|
||||
This invalidates the observed message; it is not a claim about an unavailable
|
||||
external Microsoft detector.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
source_path, output_path = Path(source), Path(output)
|
||||
rgb, alpha = _read_rgb(source_path)
|
||||
recovered = _validated_detection(rgb)
|
||||
if recovered is None:
|
||||
return None
|
||||
detection, analysis = recovered
|
||||
if expected_watermark_id is not None:
|
||||
try:
|
||||
expected = uuid.UUID(str(expected_watermark_id))
|
||||
except ValueError:
|
||||
return None
|
||||
if detection.watermark_id != expected:
|
||||
return None
|
||||
|
||||
luminance = _luminance(rgb)
|
||||
ll = analysis.ll
|
||||
original_ll = ll.copy()
|
||||
transform = _dct_matrix()
|
||||
payload_bits = np.unpackbits(np.frombuffer(detection.payload, dtype=np.uint8))
|
||||
selected = analysis.eligible & (analysis.bits == payload_bits[analysis.bit_indices])
|
||||
selected_y, selected_x = np.nonzero(selected)
|
||||
changed_blocks = len(selected_y)
|
||||
block_rows, block_columns = analysis.bits.shape
|
||||
block_grid = (
|
||||
ll[: block_rows * _BLOCK_SIZE, : block_columns * _BLOCK_SIZE]
|
||||
.reshape(block_rows, _BLOCK_SIZE, block_columns, _BLOCK_SIZE)
|
||||
.transpose(0, 2, 1, 3)
|
||||
.copy()
|
||||
)
|
||||
for start in range(0, changed_blocks, 4096):
|
||||
batch_y = selected_y[start : start + 4096]
|
||||
batch_x = selected_x[start : start + 4096]
|
||||
coefficients = transform @ block_grid[batch_y, batch_x] @ transform.T
|
||||
matrices = coefficients.reshape(-1, 16)[:, 1:].reshape(-1, 3, 5)
|
||||
target_bits = 1 - analysis.bits[batch_y, batch_x]
|
||||
u, singular, vh = np.linalg.svd(matrices, full_matrices=False)
|
||||
singular[:, 0] = (np.floor(singular[:, 0] / _QUANTUM) + 0.25 + 0.5 * target_bits) * _QUANTUM
|
||||
rewritten = coefficients.reshape(-1, 16).copy()
|
||||
rewritten[:, 1:] = ((u * singular[:, None, :]) @ vh).reshape(-1, 15)
|
||||
block_grid[batch_y, batch_x] = transform.T @ rewritten.reshape(-1, 4, 4) @ transform
|
||||
ll[: block_rows * _BLOCK_SIZE, : block_columns * _BLOCK_SIZE] = block_grid.transpose(0, 2, 1, 3).reshape(
|
||||
block_rows * _BLOCK_SIZE, block_columns * _BLOCK_SIZE
|
||||
)
|
||||
|
||||
height, width = original_ll.shape
|
||||
delta = (ll - original_ll) * 0.5
|
||||
pixel_delta = np.zeros(luminance.shape, dtype=np.float64)
|
||||
pixel_delta[: height * 2 : 2, : width * 2 : 2] = delta
|
||||
pixel_delta[: height * 2 : 2, 1 : width * 2 : 2] = delta
|
||||
pixel_delta[1 : height * 2 : 2, : width * 2 : 2] = delta
|
||||
pixel_delta[1 : height * 2 : 2, 1 : width * 2 : 2] = delta
|
||||
cleaned_rgb = np.clip(np.rint(rgb.astype(np.float64) + pixel_delta[..., None]), 0, 255).astype(np.uint8)
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
file_descriptor, temporary_name = tempfile.mkstemp(
|
||||
prefix=f".{output_path.stem}-", suffix=output_path.suffix, dir=output_path.parent
|
||||
)
|
||||
os.close(file_descriptor)
|
||||
temporary_path = Path(temporary_name)
|
||||
try:
|
||||
if not image_io.write_bgr_with_alpha(
|
||||
temporary_path,
|
||||
cleaned_rgb[..., ::-1],
|
||||
alpha,
|
||||
display_tags_from=source_path,
|
||||
orientation_applied=False,
|
||||
):
|
||||
raise OSError(f"failed to write output (is the destination writable?): {output_path}")
|
||||
if detect_local_invismark(temporary_path) is not None:
|
||||
return None
|
||||
os.replace(temporary_path, output_path)
|
||||
finally:
|
||||
if temporary_path.exists():
|
||||
temporary_path.unlink()
|
||||
|
||||
return LocalInvisMarkRemoval(
|
||||
watermark_id=detection.watermark_id,
|
||||
payload=detection.payload,
|
||||
minimum_support=detection.minimum_support,
|
||||
changed_blocks=changed_blocks,
|
||||
)
|
||||
@@ -48,6 +48,7 @@ import time
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks._internal.image_math import dct_matrix as _dct_matrix
|
||||
from remove_ai_watermarks._internal.schema import require_schema_version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -148,15 +149,6 @@ def _numpy() -> Any:
|
||||
return np
|
||||
|
||||
|
||||
def _dct_matrix(np: Any, n: int = 8) -> Any:
|
||||
"""Orthonormal n x n DCT-II basis: M[i, j] = cos(pi (2j + 1) i / 2n)."""
|
||||
i = np.arange(n)[:, None]
|
||||
j = np.arange(n)[None, :]
|
||||
m = np.cos(np.pi * (2 * j + 1) * i / (2 * n))
|
||||
m[0, :] *= 1 / np.sqrt(2)
|
||||
return m * np.sqrt(2 / n)
|
||||
|
||||
|
||||
def read_gray(image_path: Path) -> tuple[Any, Any, dict[str, Any]]:
|
||||
"""Decode to float32 grayscale (and RGB for color stats), downscaled.
|
||||
|
||||
|
||||
@@ -32,8 +32,8 @@ def _engine(run=None):
|
||||
engine = object.__new__(invisible_engine.InvisibleEngine)
|
||||
engine._progress_callback = None
|
||||
remover = object.__new__(WatermarkRemover)
|
||||
remover.configured_profile = "qwen-zimage"
|
||||
remover.model_profile = "qwen-zimage"
|
||||
remover._auto = False
|
||||
remover._qwen_zimage_pipeline = SimpleNamespace(run=run or (lambda source, **kwargs: source))
|
||||
engine._remover = remover
|
||||
return engine
|
||||
|
||||
@@ -30,14 +30,15 @@ OFFLOAD_SUPPORT_CASES = (
|
||||
(QWEN_ZIMAGE_PROFILE, True),
|
||||
(CHROMA_ZIMAGE_PROFILE, False),
|
||||
(SDXL_ZIMAGE_PROFILE, False),
|
||||
# Unresolved auto appears only in preload, which builds qwen-zimage.
|
||||
# Auto preloads its concrete Qwen fallback before an image vendor is known.
|
||||
(AUTO_PROFILE, True),
|
||||
)
|
||||
|
||||
|
||||
def _remover(profile: str, cpu_offload: bool) -> WatermarkRemover:
|
||||
remover = WatermarkRemover.__new__(WatermarkRemover)
|
||||
remover.model_profile = profile
|
||||
remover.configured_profile = profile
|
||||
remover.model_profile = QWEN_ZIMAGE_PROFILE if profile == AUTO_PROFILE else profile
|
||||
remover.cpu_offload = cpu_offload
|
||||
remover.device = "cuda"
|
||||
remover.torch_dtype = None
|
||||
|
||||
+85
-9
@@ -46,6 +46,12 @@ _SPARKLE_TARGET = "remove_ai_watermarks.gemini_engine.detect_sparkle_confidence"
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
|
||||
|
||||
|
||||
def _write_c2pa_jpeg(tmp_path: Path, name: str, blob: bytes) -> Path:
|
||||
path = tmp_path / name
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9")
|
||||
return path
|
||||
|
||||
|
||||
class TestProvenanceEvidence:
|
||||
def test_exact_ai_claim_generator_can_assert_ai_without_source_type(self, tmp_path: Path):
|
||||
path = tmp_path / "firefly.png"
|
||||
@@ -646,13 +652,8 @@ class TestIdentifySamsungGalaxy:
|
||||
so the signer attribution must NOT trip the camera-vs-AI integrity clash.
|
||||
"""
|
||||
|
||||
def _jpeg(self, tmp_path: Path, name: str, blob: bytes) -> Path:
|
||||
path = tmp_path / name
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9")
|
||||
return path
|
||||
|
||||
def test_galaxy_trained_source_is_unverified_ai(self, tmp_path: Path):
|
||||
path = self._jpeg(tmp_path, "s25.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs trainedAlgorithmicMedia")
|
||||
path = _write_c2pa_jpeg(tmp_path, "s25.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "medium"
|
||||
@@ -664,7 +665,7 @@ class TestIdentifySamsungGalaxy:
|
||||
def test_galaxy_genai_only_is_medium_ai(self, tmp_path: Path):
|
||||
# The Galaxy S24 case: no trainedAlgorithmicMedia, genAIType is the only
|
||||
# AI marker -- previously missed, now a medium-confidence verdict.
|
||||
path = self._jpeg(
|
||||
path = _write_c2pa_jpeg(
|
||||
tmp_path, "s24.jpg", b'Samsung Galaxy Galaxy S24 c2pa-rs PhotoEditor_Re_Edit_Data{"genAIType":1}'
|
||||
)
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
@@ -677,7 +678,7 @@ class TestIdentifySamsungGalaxy:
|
||||
def test_asus_gallery_signer_not_ai(self, tmp_path: Path):
|
||||
# ASUS Gallery signs edited photos; no AI source-type or genAIType, so the
|
||||
# platform is attributed but the verdict stays unknown.
|
||||
path = self._jpeg(tmp_path, "asus.jpg", b"/com.asus.gallery/3.8.0.98 c2pa-rs no ai marker")
|
||||
path = _write_c2pa_jpeg(tmp_path, "asus.jpg", b"/com.asus.gallery/3.8.0.98 c2pa-rs no ai marker")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is None
|
||||
assert r.platform == "ASUS Gallery (C2PA signer)"
|
||||
@@ -687,13 +688,88 @@ class TestIdentifySamsungGalaxy:
|
||||
# A genuine Galaxy phone capture carries Samsung Galaxy C2PA provenance but
|
||||
# NO AI source-type / genAIType. It must stay is_ai=None -- the device cert
|
||||
# is authenticity provenance of a real photo, not an AI-generation signal.
|
||||
path = self._jpeg(tmp_path, "s25_capture.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs no ai marker")
|
||||
path = _write_c2pa_jpeg(tmp_path, "s25_capture.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs no ai marker")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is None
|
||||
assert r.platform == "Samsung Galaxy (C2PA)"
|
||||
assert any("C2PA" in w for w in r.watermarks)
|
||||
|
||||
|
||||
class TestIdentifyDiscoveredC2paSigners:
|
||||
@pytest.mark.parametrize(
|
||||
("blob", "platform"),
|
||||
[
|
||||
(b"Anthropic Claude Content Signing", "Anthropic Claude (C2PA signer)"),
|
||||
(b"CapCut/ c2pa-rs", "CapCut (C2PA signer)"),
|
||||
(b"TikTok Inc.", "TikTok (C2PA signer)"),
|
||||
(b"vivo Camera vivo X300 Pro", "vivo (camera, C2PA capture)"),
|
||||
],
|
||||
)
|
||||
def test_non_ai_signer_is_attributed_without_asserting_ai(self, tmp_path: Path, blob: bytes, platform: str):
|
||||
path = _write_c2pa_jpeg(tmp_path, "signed.jpg", blob)
|
||||
|
||||
report = identify(path, check_visible=False, check_invisible=False)
|
||||
|
||||
assert report.is_ai_generated is None
|
||||
assert report.platform == platform
|
||||
|
||||
def test_structured_non_ai_capcut_generator_is_attributed(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
path = tmp_path / "edited.png"
|
||||
from PIL import Image
|
||||
|
||||
Image.new("RGB", (32, 32)).save(path)
|
||||
monkeypatch.setattr(
|
||||
"remove_ai_watermarks.identify.extract_c2pa_info",
|
||||
lambda _path: {
|
||||
"has_c2pa": True,
|
||||
"issuer": "ByteDance",
|
||||
"claim_generator": "CapCut/ c2pa-tool/0.1.0 c2pa-rs/0.31.3",
|
||||
},
|
||||
)
|
||||
|
||||
report = identify(path, check_visible=False, check_invisible=False)
|
||||
|
||||
assert report.is_ai_generated is None
|
||||
assert report.platform == "CapCut (C2PA signer)"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("issuer", "claim_generator", "platform"),
|
||||
[
|
||||
("xAI Grok Imagine", "c2pa-rs", "xAI Grok Imagine"),
|
||||
("Producer.ai", "producer_ai", "Producer.ai"),
|
||||
("", "FastVid", "FastVid"),
|
||||
("SPRING (SG) PTE. LTD.", "c2pa-rs", "SPRING (SG) PTE. LTD."),
|
||||
],
|
||||
)
|
||||
def test_ai_signer_or_generator_is_attributed(
|
||||
self,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
issuer: str,
|
||||
claim_generator: str,
|
||||
platform: str,
|
||||
):
|
||||
path = tmp_path / "generated.png"
|
||||
from PIL import Image
|
||||
|
||||
Image.new("RGB", (32, 32)).save(path)
|
||||
monkeypatch.setattr(
|
||||
"remove_ai_watermarks.identify.extract_c2pa_info",
|
||||
lambda _path: {
|
||||
"has_c2pa": True,
|
||||
"issuer": issuer,
|
||||
"claim_generator": claim_generator,
|
||||
"source_type": "trainedAlgorithmicMedia (AI-generated)",
|
||||
"ai_source_kind": "generated",
|
||||
},
|
||||
)
|
||||
|
||||
report = identify(path, check_visible=False, check_invisible=False)
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert report.platform == platform
|
||||
|
||||
|
||||
# ── End-to-end verdicts on real fixtures ────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -55,7 +55,7 @@ class TestVerifiedTextMode:
|
||||
def _engine(profile: str = "qwen-zimage") -> InvisibleEngine:
|
||||
engine = object.__new__(InvisibleEngine)
|
||||
engine._progress_callback = None
|
||||
engine._remover = SimpleNamespace(model_profile=profile)
|
||||
engine._remover = SimpleNamespace(configured_profile=profile, model_profile=profile)
|
||||
return engine
|
||||
|
||||
def test_rejects_incompatible_pipeline_options(self, tmp_path):
|
||||
@@ -64,7 +64,6 @@ class TestVerifiedTextMode:
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text("{}", encoding="utf-8")
|
||||
cases = (
|
||||
("sdxl-zimage", {}, "not supported by the sdxl-zimage profile"),
|
||||
("qwen-zimage", {"tile": True}, "not calibrated with --tile"),
|
||||
("qwen-zimage", {"max_resolution": 1024}, "max-resolution 0"),
|
||||
("qwen-zimage", {"humanize": 1.0}, "humanize=0"),
|
||||
@@ -124,9 +123,10 @@ class TestVerifiedTextMode:
|
||||
engine._remover.remove_watermark = fake_remove
|
||||
monkeypatch.setattr(region_eraser, "lama_available", lambda: True)
|
||||
|
||||
engine.remove_watermark(source, output, text_manifest=manifest)
|
||||
engine.remove_watermark(source, output, text_manifest=manifest, vendor="google")
|
||||
|
||||
assert seen["text_manifest"].lines[0].text == "Exact"
|
||||
assert seen["vendor"] == "google"
|
||||
# Leak-safe default since 0.27.1: the global 15% donor blend is OFF unless
|
||||
# explicitly requested (measured to return detector-visible OpenAI SynthID
|
||||
# on poster-scale manifests; see docs/text-protection-research.md).
|
||||
@@ -149,7 +149,11 @@ class TestNativeOutputSize:
|
||||
Image.open(image_path).crop((0, 0, 24, 16)).save(out)
|
||||
return out
|
||||
|
||||
engine._remover = SimpleNamespace(remove_watermark=_remove_watermark, model_profile="qwen-zimage")
|
||||
engine._remover = SimpleNamespace(
|
||||
remove_watermark=_remove_watermark,
|
||||
configured_profile="qwen-zimage",
|
||||
model_profile="qwen-zimage",
|
||||
)
|
||||
engine._progress_callback = None
|
||||
src = tmp_path / "src.png"
|
||||
out = tmp_path / "out.png"
|
||||
@@ -262,10 +266,10 @@ class TestEngineResolvesThePolishPerProfile:
|
||||
|
||||
engine = object.__new__(InvisibleEngine)
|
||||
engine._progress_callback = None
|
||||
engine._remover = MagicMock(model_profile=profile)
|
||||
engine._remover = MagicMock(configured_profile=profile, model_profile=profile)
|
||||
return engine
|
||||
|
||||
def _polish_used(self, profile: str, requested, tmp_path, monkeypatch) -> bool:
|
||||
def _polish_used(self, profile: str, requested, tmp_path, monkeypatch, *, vendor: str | None = None) -> bool:
|
||||
seen: list[bool] = []
|
||||
monkeypatch.setattr(
|
||||
"remove_ai_watermarks.humanizer.adaptive_polish",
|
||||
@@ -278,13 +282,21 @@ class TestEngineResolvesThePolishPerProfile:
|
||||
Image.open(kw["image_path"]).save(kw["output_path"]),
|
||||
kw["output_path"],
|
||||
)[1]
|
||||
engine.remove_watermark(src, tmp_path / f"out_{profile}_{requested}.png", adaptive_polish=requested)
|
||||
engine.remove_watermark(
|
||||
src,
|
||||
tmp_path / f"out_{profile}_{requested}.png",
|
||||
adaptive_polish=requested,
|
||||
vendor=vendor,
|
||||
)
|
||||
return bool(seen)
|
||||
|
||||
def test_unset_follows_the_profile_not_the_signature_default(self, tmp_path, monkeypatch):
|
||||
assert self._polish_used("qwen-zimage", None, tmp_path, monkeypatch) is False
|
||||
assert self._polish_used("sdxl-zimage", None, tmp_path, monkeypatch) is True
|
||||
|
||||
def test_auto_google_uses_the_resolved_sdxl_default(self, tmp_path, monkeypatch):
|
||||
assert self._polish_used("auto", None, tmp_path, monkeypatch, vendor="google") is True
|
||||
|
||||
def test_an_explicit_value_still_wins_on_both_profiles(self, tmp_path, monkeypatch):
|
||||
assert self._polish_used("qwen-zimage", True, tmp_path, monkeypatch) is True
|
||||
assert self._polish_used("sdxl-zimage", False, tmp_path, monkeypatch) is False
|
||||
|
||||
@@ -211,6 +211,43 @@ class TestC2PA:
|
||||
assert info["ai_tool"] == "Dreamina"
|
||||
assert info["c2pa_identity_ai"] is True
|
||||
|
||||
def test_structured_xai_signer_identity_is_normalized(self):
|
||||
store = {
|
||||
"active_manifest": "active",
|
||||
"manifests": {
|
||||
"active": {
|
||||
"signature_info": {
|
||||
"issuer": "Self-signed ephemeral certificate (Content Authenticity SDK) -- LOCAL USE ONLY",
|
||||
"common_name": "xAI Grok Imagine",
|
||||
},
|
||||
"assertions": [],
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
info = c2pa_info_from_manifest_store(store)
|
||||
|
||||
assert info["issuer"] == "xAI Grok Imagine"
|
||||
assert info["c2pa_identity_ai"] is True
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("signature", "issuer"),
|
||||
[
|
||||
({"issuer": "Bytedance Pte. Ltd."}, "ByteDance"),
|
||||
({"issuer": "TikTok Inc."}, "TikTok"),
|
||||
],
|
||||
)
|
||||
def test_structured_publisher_identity_does_not_assert_ai(self, signature, issuer):
|
||||
store = {
|
||||
"active_manifest": "active",
|
||||
"manifests": {"active": {"signature_info": signature, "assertions": []}},
|
||||
}
|
||||
|
||||
info = c2pa_info_from_manifest_store(store)
|
||||
|
||||
assert info["issuer"] == issuer
|
||||
assert "c2pa_identity_ai" not in info
|
||||
|
||||
def test_structured_invismark_exposes_algorithm_and_watermark_id(self):
|
||||
watermark_id = "83424621-03cb-40e3-9808-a9fae837156d"
|
||||
store = {
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""Clean-room tests for Paint's local ``Watermarker.dll`` pixel format."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks.microsoft_invismark import (
|
||||
detect_local_invismark,
|
||||
disrupt_local_invismark,
|
||||
invismark_id_from_c2pa_info,
|
||||
)
|
||||
|
||||
_WATERMARK_ID = uuid.UUID("83424621-03cb-40e3-9808-a9fae837156d")
|
||||
|
||||
|
||||
def _dct_matrix() -> np.ndarray:
|
||||
matrix = np.empty((4, 4), dtype=np.float64)
|
||||
for frequency in range(4):
|
||||
scale = np.sqrt(1 / 4) if frequency == 0 else np.sqrt(2 / 4)
|
||||
for sample in range(4):
|
||||
matrix[frequency, sample] = scale * np.cos(np.pi * (2 * sample + 1) * frequency / 8)
|
||||
return matrix
|
||||
|
||||
|
||||
def _synthetic_writer(image: np.ndarray, watermark_id: uuid.UUID, *, valid_checksum: bool = True) -> np.ndarray:
|
||||
"""Independent format producer; it deliberately does not call production helpers."""
|
||||
guid = watermark_id.bytes_le
|
||||
checksum = sum(guid) & 0xFF
|
||||
if not valid_checksum:
|
||||
checksum ^= 1
|
||||
payload = bytes((0x4C,)) + guid + bytes((checksum,))
|
||||
bits = np.unpackbits(np.frombuffer(payload, dtype=np.uint8))
|
||||
|
||||
rgb = image.astype(np.float64)
|
||||
luminance = rgb @ np.array((0.299, 0.587, 0.114), dtype=np.float64)
|
||||
ll = (luminance[0::2, 0::2] + luminance[0::2, 1::2] + luminance[1::2, 0::2] + luminance[1::2, 1::2]) * 0.5
|
||||
original_ll = ll.copy()
|
||||
dct_matrix = _dct_matrix()
|
||||
|
||||
for block_y in range(ll.shape[0] // 4):
|
||||
for block_x in range(ll.shape[1] // 4):
|
||||
top, left = block_y * 4, block_x * 4
|
||||
block = ll[top : top + 4, left : left + 4]
|
||||
coefficients = dct_matrix @ block @ dct_matrix.T
|
||||
ac = coefficients.reshape(-1)[1:].reshape(3, 5)
|
||||
u, singular, vh = np.linalg.svd(ac, full_matrices=False)
|
||||
bit = int(bits[(block_y % 12) * 12 + (block_x % 12)])
|
||||
singular[0] = (np.floor(singular[0] / 24) + 0.25 + 0.5 * bit) * 24
|
||||
rewritten = coefficients.reshape(-1).copy()
|
||||
rewritten[1:] = (u @ np.diag(singular) @ vh).reshape(-1)
|
||||
ll[top : top + 4, left : left + 4] = dct_matrix.T @ rewritten.reshape(4, 4) @ dct_matrix
|
||||
|
||||
delta = (ll - original_ll) * 0.5
|
||||
pixel_delta = np.repeat(np.repeat(delta, 2, axis=0), 2, axis=1)
|
||||
return np.clip(np.rint(rgb + pixel_delta[..., None]), 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def _write_carrier(path: Path, *, valid_checksum: bool = True) -> np.ndarray:
|
||||
original = np.random.default_rng(20260913).integers(24, 232, size=(192, 192, 3), dtype=np.uint8)
|
||||
encoded = _synthetic_writer(original, _WATERMARK_ID, valid_checksum=valid_checksum)
|
||||
Image.fromarray(encoded, "RGB").save(path)
|
||||
return original
|
||||
|
||||
|
||||
def test_detects_repeated_payload_and_windows_guid_order(tmp_path: Path) -> None:
|
||||
source = tmp_path / "paint.png"
|
||||
_write_carrier(source)
|
||||
|
||||
detection = detect_local_invismark(source)
|
||||
|
||||
assert detection is not None
|
||||
assert detection.watermark_id == _WATERMARK_ID
|
||||
assert detection.payload == b"\x4c" + _WATERMARK_ID.bytes_le + bytes((sum(_WATERMARK_ID.bytes_le) & 0xFF,))
|
||||
assert detection.minimum_support >= 3
|
||||
|
||||
|
||||
def test_rejects_clean_and_malformed_payloads(tmp_path: Path) -> None:
|
||||
clean = tmp_path / "clean.png"
|
||||
malformed = tmp_path / "malformed.png"
|
||||
Image.fromarray(
|
||||
np.random.default_rng(20260913).integers(24, 232, size=(192, 192, 3), dtype=np.uint8),
|
||||
"RGB",
|
||||
).save(clean)
|
||||
_write_carrier(malformed, valid_checksum=False)
|
||||
|
||||
assert detect_local_invismark(clean) is None
|
||||
assert detect_local_invismark(malformed) is None
|
||||
|
||||
|
||||
def test_disruption_requires_expected_guid_and_invalidates_payload(tmp_path: Path) -> None:
|
||||
source = tmp_path / "paint.png"
|
||||
wrong_output = tmp_path / "wrong.png"
|
||||
output = tmp_path / "clean.png"
|
||||
original = _write_carrier(source)
|
||||
|
||||
assert disrupt_local_invismark(source, wrong_output, expected_watermark_id=uuid.uuid4()) is None
|
||||
assert not wrong_output.exists()
|
||||
|
||||
result = disrupt_local_invismark(source, output, expected_watermark_id=_WATERMARK_ID)
|
||||
|
||||
assert result is not None
|
||||
assert result.watermark_id == _WATERMARK_ID
|
||||
assert result.changed_blocks >= 432
|
||||
assert output.exists()
|
||||
assert detect_local_invismark(output) is None
|
||||
cleaned = np.asarray(Image.open(output).convert("RGB"))
|
||||
assert np.mean(np.abs(cleaned.astype(np.int16) - original.astype(np.int16))) < 2.0
|
||||
|
||||
|
||||
def test_cloud_image_creator_fixture_is_not_the_local_dll_format() -> None:
|
||||
fixture = Path(__file__).parents[1] / "data/fixtures/provenance/microsoft-paint-invismark.png"
|
||||
|
||||
assert detect_local_invismark(fixture) is None
|
||||
|
||||
|
||||
def test_c2pa_uuid_requires_exact_algorithm_and_one_valid_value() -> None:
|
||||
info = {
|
||||
"soft_binding_algorithm": "com.microsoft.invismark.1",
|
||||
"soft_binding_value": str(_WATERMARK_ID),
|
||||
}
|
||||
|
||||
assert invismark_id_from_c2pa_info(info) == _WATERMARK_ID
|
||||
assert invismark_id_from_c2pa_info({**info, "soft_binding_algorithm": "io.iscc.v0"}) is None
|
||||
assert invismark_id_from_c2pa_info({**info, "soft_binding_value": f"{_WATERMARK_ID}, {uuid.uuid4()}"}) is None
|
||||
@@ -240,6 +240,7 @@ class TestResolveStrength:
|
||||
)
|
||||
assert resolve_strength(None, None, "qwen-zimage", size=(600, 500)) == pytest.approx(0.084)
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(600, 500)) == QWEN_ZIMAGE_GOOGLE_STRENGTH
|
||||
assert QWEN_ZIMAGE_GOOGLE_STRENGTH == 0.35
|
||||
# The floor holds at every size, not only above the curve's top rung.
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == QWEN_ZIMAGE_GOOGLE_STRENGTH
|
||||
|
||||
@@ -278,7 +279,7 @@ class TestResolveStrength:
|
||||
def test_sdxl_zimage_uses_its_flat_vendor_ladder(self):
|
||||
|
||||
assert SDXL_ZIMAGE_OPENAI_STRENGTH == 0.15
|
||||
assert SDXL_ZIMAGE_GEMINI_STRENGTH == 0.25
|
||||
assert SDXL_ZIMAGE_GEMINI_STRENGTH == 0.50
|
||||
assert SDXL_ZIMAGE_UNKNOWN_STRENGTH == SDXL_ZIMAGE_GEMINI_STRENGTH
|
||||
assert resolve_strength(None, "openai", "sdxl-zimage") == SDXL_ZIMAGE_OPENAI_STRENGTH
|
||||
assert resolve_strength(None, "google", "sdxl-zimage") == SDXL_ZIMAGE_GEMINI_STRENGTH
|
||||
|
||||
@@ -996,6 +996,70 @@ def test_sdxl_zimage_inherits_the_face_stage_rather_than_copying_it():
|
||||
assert SdxlZImagePipeline._load_global is not QwenZImagePipeline._load_global
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("pipeline_module", "pipeline_class", "grid"),
|
||||
[
|
||||
("sdxl_zimage_pipeline", "SdxlZImagePipeline", 8),
|
||||
("chroma_zimage_pipeline", "ChromaZImagePipeline", 16),
|
||||
],
|
||||
)
|
||||
def test_diffusers_vae_roundtrip_uses_deterministic_mode_and_restores_native_size(
|
||||
monkeypatch, pipeline_module, pipeline_class, grid
|
||||
):
|
||||
"""Diffusers donors pad without distorting geometry and never sample."""
|
||||
import importlib
|
||||
|
||||
import torch
|
||||
|
||||
seen: dict[str, object] = {}
|
||||
padded_width = 40 if grid == 8 else 48
|
||||
padded_height = 24 if grid == 8 else 32
|
||||
latent = torch.ones(1, 4, padded_height // 8, padded_width // 8)
|
||||
|
||||
class LatentDistribution:
|
||||
def mode(self):
|
||||
seen["mode_calls"] = int(seen.get("mode_calls", 0)) + 1
|
||||
return latent
|
||||
|
||||
def sample(self, *_args, **_kwargs):
|
||||
raise AssertionError("A verified-text donor must not sample VAE latents")
|
||||
|
||||
class FakeVae:
|
||||
@staticmethod
|
||||
def encode(tensor):
|
||||
seen["encoded_shape"] = tuple(tensor.shape)
|
||||
return type("Encoded", (), {"latent_dist": LatentDistribution()})()
|
||||
|
||||
@staticmethod
|
||||
def decode(received, *, return_dict):
|
||||
assert received is latent
|
||||
assert return_dict is False
|
||||
return (torch.zeros(1, 3, padded_height, padded_width),)
|
||||
|
||||
class FakeProcessor:
|
||||
@staticmethod
|
||||
def preprocess(image, *, height, width):
|
||||
seen["preprocess"] = (image.size, height, width)
|
||||
return torch.zeros(1, 3, height, width)
|
||||
|
||||
@staticmethod
|
||||
def postprocess(decoded, *, output_type):
|
||||
assert output_type == "pil"
|
||||
return [Image.new("RGB", (decoded.shape[3], decoded.shape[2]))]
|
||||
|
||||
pipe = type("FakePipe", (), {"vae": FakeVae(), "image_processor": FakeProcessor()})()
|
||||
module = importlib.import_module(f"remove_ai_watermarks._internal.{pipeline_module}")
|
||||
pipeline = getattr(module, pipeline_class)(device="cpu", torch_dtype=torch.float32)
|
||||
monkeypatch.setattr(pipeline, "_load_global", lambda: pipe)
|
||||
|
||||
result = pipeline._vae_roundtrip(Image.new("RGB", (35, 19)))
|
||||
|
||||
assert seen["preprocess"] == ((padded_width, padded_height), padded_height, padded_width)
|
||||
assert seen["encoded_shape"] == (1, 3, padded_height, padded_width)
|
||||
assert seen["mode_calls"] == 1
|
||||
assert result.size == (35, 19)
|
||||
|
||||
|
||||
def test_chroma_zimage_strength_uses_measured_flat_floors():
|
||||
"""The chroma floors come from the four-cohort oracle calibration; they must not
|
||||
drift from their derivation in watermark_profiles."""
|
||||
@@ -1015,7 +1079,7 @@ def test_chroma_zimage_strength_uses_measured_flat_floors():
|
||||
assert resolve_strength(None, "microsoft", "chroma-zimage") == pytest.approx(CHROMA_ZIMAGE_MICROSOFT_STRENGTH)
|
||||
assert pytest.approx(0.125) == CHROMA_ZIMAGE_MICROSOFT_STRENGTH
|
||||
assert resolve_strength(None, "google", "chroma-zimage") == pytest.approx(CHROMA_ZIMAGE_GOOGLE_STRENGTH)
|
||||
assert pytest.approx(0.40) == CHROMA_ZIMAGE_GOOGLE_STRENGTH
|
||||
assert pytest.approx(0.50) == CHROMA_ZIMAGE_GOOGLE_STRENGTH
|
||||
assert resolve_strength(None, "meta", "chroma-zimage") == pytest.approx(CHROMA_ZIMAGE_META_STRENGTH)
|
||||
assert pytest.approx(0.17) == CHROMA_ZIMAGE_META_STRENGTH
|
||||
# Unknown follows the strictest measured cohort, as with the other profiles.
|
||||
@@ -1163,8 +1227,9 @@ def test_chroma_zimage_floors_to_its_own_latent_grid():
|
||||
assert chroma_target_size(3, 3) == (16, 16)
|
||||
|
||||
|
||||
def test_watermark_remover_accepts_text_manifest_for_chroma_but_not_sdxl(tmp_path, monkeypatch):
|
||||
"""Chroma owns a VAE donor; SDXL still fails at the public boundary."""
|
||||
@pytest.mark.parametrize("profile", ["chroma-zimage", "sdxl-zimage"])
|
||||
def test_watermark_remover_accepts_text_manifest_for_every_diffusers_profile(tmp_path, monkeypatch, profile):
|
||||
"""Both Diffusers profiles forward reviewed text to their VAE donor."""
|
||||
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
|
||||
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
|
||||
@@ -1173,22 +1238,18 @@ def test_watermark_remover_accepts_text_manifest_for_chroma_but_not_sdxl(tmp_pat
|
||||
Image.new("RGB", (96, 80)).save(source)
|
||||
manifest = VerifiedTextManifest("0" * 64, 96, 80, (VerifiedTextLine((4, 4, 20, 16), "x", "alphabetic"),))
|
||||
|
||||
chroma = WatermarkRemover(device="cuda", pipeline="chroma-zimage")
|
||||
chroma_runtime = MagicMock()
|
||||
chroma_runtime.run.return_value = Image.new("RGB", (96, 80))
|
||||
monkeypatch.setattr(chroma, "_load_qwen_zimage_pipeline", lambda: chroma_runtime)
|
||||
remover = WatermarkRemover(device="cuda", pipeline=profile)
|
||||
runtime = MagicMock()
|
||||
runtime.run.return_value = Image.new("RGB", (96, 80))
|
||||
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
|
||||
|
||||
chroma.remove_watermark(source, text_manifest=manifest)
|
||||
remover.remove_watermark(source, text_manifest=manifest)
|
||||
|
||||
assert chroma_runtime.run.call_count == 1
|
||||
assert chroma_runtime.run.call_args.kwargs["text_manifest"] is manifest
|
||||
assert runtime.run.call_count == 1
|
||||
assert runtime.run.call_args.kwargs["text_manifest"] is manifest
|
||||
with pytest.raises(ValueError, match=r"fidelity anchor.*qwen-zimage"):
|
||||
chroma.remove_watermark(source, text_manifest=manifest, fidelity_anchor=True)
|
||||
assert chroma_runtime.run.call_count == 1
|
||||
|
||||
sdxl = WatermarkRemover(device="cuda", pipeline="sdxl-zimage")
|
||||
with pytest.raises(ValueError, match="not supported by the sdxl-zimage profile"):
|
||||
sdxl.remove_watermark(source, text_manifest=manifest)
|
||||
remover.remove_watermark(source, text_manifest=manifest, fidelity_anchor=True)
|
||||
assert runtime.run.call_count == 1
|
||||
|
||||
|
||||
def test_watermark_remover_dispatches_to_chroma_pipeline(monkeypatch):
|
||||
@@ -1229,95 +1290,32 @@ def test_chroma_adaptive_polish_defaults_off():
|
||||
assert resolve_adaptive_polish(True, "chroma-zimage") is True
|
||||
|
||||
|
||||
def test_chroma_vae_roundtrip_uses_deterministic_mode_and_restores_native_size(monkeypatch):
|
||||
"""The Chroma donor pads without distorting geometry and never samples."""
|
||||
import torch
|
||||
def test_chroma_google_uses_one_content_agnostic_floor():
|
||||
"""Face detection alone cannot safely select a lower Google floor.
|
||||
|
||||
from remove_ai_watermarks._internal.chroma_zimage_pipeline import ChromaZImagePipeline
|
||||
|
||||
seen: dict[str, object] = {}
|
||||
latent = torch.ones(1, 4, 4, 6)
|
||||
|
||||
class LatentDistribution:
|
||||
def mode(self):
|
||||
seen["mode_calls"] = int(seen.get("mode_calls", 0)) + 1
|
||||
return latent
|
||||
|
||||
def sample(self, *_args, **_kwargs):
|
||||
raise AssertionError("A verified-text donor must not sample VAE latents")
|
||||
|
||||
class FakeVae:
|
||||
@staticmethod
|
||||
def encode(tensor):
|
||||
seen["encoded_shape"] = tuple(tensor.shape)
|
||||
return type("Encoded", (), {"latent_dist": LatentDistribution()})()
|
||||
|
||||
@staticmethod
|
||||
def decode(received, *, return_dict):
|
||||
assert received is latent
|
||||
assert return_dict is False
|
||||
return (torch.zeros(1, 3, 32, 48),)
|
||||
|
||||
class FakeProcessor:
|
||||
@staticmethod
|
||||
def preprocess(image, *, height, width):
|
||||
seen["preprocess"] = (image.size, height, width)
|
||||
return torch.zeros(1, 3, height, width)
|
||||
|
||||
@staticmethod
|
||||
def postprocess(decoded, *, output_type):
|
||||
assert output_type == "pil"
|
||||
return [Image.new("RGB", (decoded.shape[3], decoded.shape[2]))]
|
||||
|
||||
pipe = type("FakePipe", (), {"vae": FakeVae(), "image_processor": FakeProcessor()})()
|
||||
pipeline = ChromaZImagePipeline(device="cpu", torch_dtype=torch.float32)
|
||||
monkeypatch.setattr(pipeline, "_load_global", lambda: pipe)
|
||||
|
||||
result = pipeline._vae_roundtrip(Image.new("RGB", (35, 19)))
|
||||
|
||||
assert seen["preprocess"] == ((48, 32), 32, 48)
|
||||
assert seen["encoded_shape"] == (1, 3, 32, 48)
|
||||
assert seen["mode_calls"] == 1
|
||||
assert result.size == (35, 19)
|
||||
|
||||
|
||||
def test_chroma_google_face_content_gets_the_lower_adaptive_floor():
|
||||
"""Face content on Google SynthID clears at 0.125, not 0.40.
|
||||
|
||||
The four-fixture calibration showed a clean face-count split: both zero-face
|
||||
text cards need 0.25, both face fixtures clear at 0.12. The adaptive arm
|
||||
saves face identity (0.611 vs 0.279 cosine) and 6.4 dB PSNR on face content.
|
||||
Fresh face-plus-text carriers required more denoise than the old face-only
|
||||
fixtures, and the runtime has no measured text classifier. Keep every Google
|
||||
carrier on the replicated global floor until a richer content gate exists.
|
||||
"""
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH,
|
||||
CHROMA_ZIMAGE_GOOGLE_STRENGTH,
|
||||
resolve_strength,
|
||||
)
|
||||
|
||||
assert pytest.approx(0.125) == CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH
|
||||
# Face content gets the lower adaptive floor.
|
||||
assert resolve_strength(None, "google", "chroma-zimage", face_count=17) == pytest.approx(0.125)
|
||||
assert resolve_strength(None, "google", "chroma-zimage", face_count=1) == pytest.approx(0.125)
|
||||
# Zero-face content gets the flat floor (text/graphic cohort).
|
||||
assert resolve_strength(None, "google", "chroma-zimage", face_count=0) == pytest.approx(0.40)
|
||||
assert pytest.approx(0.40) == CHROMA_ZIMAGE_GOOGLE_STRENGTH
|
||||
# No face_count hint: conservative (flat floor).
|
||||
assert resolve_strength(None, "google", "chroma-zimage") == pytest.approx(0.40)
|
||||
# Other vendors ignore face_count: the split is Google-SynthID-specific.
|
||||
assert resolve_strength(None, "openai", "chroma-zimage", face_count=5) == pytest.approx(0.20)
|
||||
assert resolve_strength(None, "meta", "chroma-zimage", face_count=5) == pytest.approx(0.17)
|
||||
# An explicit strength still wins over the adaptive arm.
|
||||
assert resolve_strength(0.20, "google", "chroma-zimage", face_count=17) == pytest.approx(0.20)
|
||||
# Other profiles are untouched by the face_count parameter.
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850), face_count=17) == pytest.approx(0.27)
|
||||
assert pytest.approx(0.50) == CHROMA_ZIMAGE_GOOGLE_STRENGTH
|
||||
assert resolve_strength(None, "google", "chroma-zimage") == pytest.approx(0.50)
|
||||
assert resolve_strength(None, "openai", "chroma-zimage") == pytest.approx(0.20)
|
||||
assert resolve_strength(None, "meta", "chroma-zimage") == pytest.approx(0.17)
|
||||
# An explicit strength still wins over the default.
|
||||
assert resolve_strength(0.20, "google", "chroma-zimage") == pytest.approx(0.20)
|
||||
|
||||
|
||||
def test_auto_profile_routes_to_the_measured_engine_per_vendor():
|
||||
"""--pipeline auto picks the engine the four-cohort calibration supports:
|
||||
chroma-zimage for Microsoft, qwen-zimage for OpenAI/Google/Meta/unknown."""
|
||||
"""--pipeline auto picks the best quality-qualified measured engine."""
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
CHROMA_ZIMAGE_PROFILE,
|
||||
QWEN_ZIMAGE_PROFILE,
|
||||
SDXL_ZIMAGE_PROFILE,
|
||||
normalize_profile,
|
||||
resolve_auto_profile,
|
||||
)
|
||||
@@ -1325,12 +1323,21 @@ def test_auto_profile_routes_to_the_measured_engine_per_vendor():
|
||||
assert normalize_profile("auto") == "auto"
|
||||
assert resolve_auto_profile("openai") == QWEN_ZIMAGE_PROFILE
|
||||
assert resolve_auto_profile("microsoft") == CHROMA_ZIMAGE_PROFILE
|
||||
assert resolve_auto_profile("google") == QWEN_ZIMAGE_PROFILE
|
||||
assert resolve_auto_profile("google") == SDXL_ZIMAGE_PROFILE
|
||||
assert resolve_auto_profile("meta") == QWEN_ZIMAGE_PROFILE
|
||||
assert resolve_auto_profile(None) == QWEN_ZIMAGE_PROFILE
|
||||
assert resolve_auto_profile("unknown") == QWEN_ZIMAGE_PROFILE
|
||||
|
||||
|
||||
def test_auto_strength_uses_the_effective_vendor_profile():
|
||||
"""Displayed and executed auto strengths must use the same engine policy."""
|
||||
from remove_ai_watermarks._internal.watermark_profiles import resolve_strength
|
||||
|
||||
assert resolve_strength(None, "google", "auto", size=(64, 48)) == pytest.approx(0.50)
|
||||
assert resolve_strength(None, "microsoft", "auto", size=(64, 48)) == pytest.approx(0.125)
|
||||
assert resolve_strength(None, "openai", "auto", size=(64, 48)) == pytest.approx(0.07675)
|
||||
|
||||
|
||||
def test_auto_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeypatch):
|
||||
"""Auto resolves OpenAI to Qwen and uses its oracle-verified 0.07675 floor."""
|
||||
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
@@ -1340,7 +1347,8 @@ def test_auto_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeyp
|
||||
Image.new("RGB", (64, 48)).save(source)
|
||||
|
||||
remover = WatermarkRemover(device="cuda", pipeline="auto")
|
||||
assert remover.model_profile == "auto"
|
||||
assert remover.configured_profile == "auto"
|
||||
assert remover.model_profile == "qwen-zimage"
|
||||
|
||||
runtime = MagicMock()
|
||||
runtime.run.return_value = Image.new("RGB", (64, 48), (50, 60, 70))
|
||||
@@ -1353,6 +1361,27 @@ def test_auto_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeyp
|
||||
assert kwargs["strength"] == pytest.approx(0.07675)
|
||||
|
||||
|
||||
def test_auto_google_switches_to_the_sdxl_floor_and_dtype(tmp_path, monkeypatch):
|
||||
"""Auto must switch both policy and weights dtype when Google selects SDXL."""
|
||||
from remove_ai_watermarks._internal import watermark_remover
|
||||
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
|
||||
_mock_watermark_runtime_deps(monkeypatch)
|
||||
source = tmp_path / "source.png"
|
||||
Image.new("RGB", (64, 48)).save(source)
|
||||
|
||||
remover = WatermarkRemover(device="cuda", pipeline="auto")
|
||||
runtime = MagicMock()
|
||||
runtime.run.return_value = Image.new("RGB", (64, 48), (50, 60, 70))
|
||||
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
|
||||
|
||||
remover.remove_watermark(source, vendor="google")
|
||||
|
||||
assert remover.model_profile == "sdxl-zimage"
|
||||
assert remover.torch_dtype == watermark_remover.torch.float16
|
||||
assert runtime.run.call_args.kwargs["strength"] == pytest.approx(0.50)
|
||||
|
||||
|
||||
def test_auto_profile_text_manifest_uses_the_measured_qwen_engine_once(tmp_path, monkeypatch):
|
||||
"""A manifest does not override the researched OpenAI-to-Qwen decision."""
|
||||
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
|
||||
@@ -1373,3 +1402,30 @@ def test_auto_profile_text_manifest_uses_the_measured_qwen_engine_once(tmp_path,
|
||||
assert remover.model_profile == "qwen-zimage"
|
||||
assert runtime.run.call_count == 1
|
||||
assert runtime.run.call_args.kwargs["text_manifest"] is manifest
|
||||
|
||||
|
||||
def test_auto_profile_is_resolved_fresh_for_each_image(tmp_path, monkeypatch):
|
||||
"""A Google image must not pin the next OpenAI image to SDXL."""
|
||||
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
|
||||
_mock_watermark_runtime_deps(monkeypatch)
|
||||
source = tmp_path / "source.png"
|
||||
Image.new("RGB", (64, 48)).save(source)
|
||||
remover = WatermarkRemover(device="cuda", pipeline="auto")
|
||||
runtimes = []
|
||||
|
||||
def load_runtime():
|
||||
runtime = MagicMock()
|
||||
runtime.run.return_value = Image.new("RGB", (64, 48))
|
||||
runtimes.append(runtime)
|
||||
return runtime
|
||||
|
||||
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", load_runtime)
|
||||
|
||||
remover.remove_watermark(source, tmp_path / "google.png", vendor="google")
|
||||
assert remover.model_profile == "sdxl-zimage"
|
||||
assert runtimes[-1].run.call_args.kwargs["strength"] == pytest.approx(0.50)
|
||||
|
||||
remover.remove_watermark(source, tmp_path / "openai.png", vendor="openai")
|
||||
assert remover.model_profile == "qwen-zimage"
|
||||
assert runtimes[-1].run.call_args.kwargs["strength"] == pytest.approx(0.07675)
|
||||
@@ -12,6 +12,8 @@ sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import corpus_gap_scan as gap
|
||||
import sidecar_regression as regression
|
||||
import visible_eval
|
||||
import visible_positives as visible
|
||||
|
||||
|
||||
def _report(**overrides):
|
||||
@@ -70,22 +72,113 @@ def test_gap_checkpoint_keeps_complete_rows_and_ignores_a_torn_tail(tmp_path):
|
||||
assert gap._read_checkpoint(checkpoint) == {row["path"]: row, second["path"]: second}
|
||||
|
||||
|
||||
def test_visible_checkpoint_is_repaired_before_resume(tmp_path):
|
||||
checkpoint = tmp_path / "visible.jsonl"
|
||||
first = {"path": "/corpus/2026-09-10/first.png", "keys": [], "status": "ok"}
|
||||
checkpoint.write_text(json.dumps(first) + '\n{"path":"torn', encoding="utf-8")
|
||||
|
||||
visible._repair_checkpoint(checkpoint)
|
||||
with checkpoint.open("a", encoding="utf-8") as stream:
|
||||
stream.write(json.dumps({"path": "/corpus/2026-09-10/second.png", "keys": [], "status": "ok"}) + "\n")
|
||||
|
||||
assert [row["path"] for row in visible._read_records(checkpoint)] == [
|
||||
"/corpus/2026-09-10/first.png",
|
||||
"/corpus/2026-09-10/second.png",
|
||||
]
|
||||
|
||||
|
||||
def test_visible_since_filter_uses_corpus_date_directories(tmp_path):
|
||||
old = tmp_path / "2026-09-01" / "old.png"
|
||||
current = tmp_path / "2026-09-10" / "current.png"
|
||||
nested = tmp_path / "2026-09-10" / "nested" / "current.webp"
|
||||
undated = tmp_path / "loose.png"
|
||||
for path in (old, current, nested, undated):
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.touch()
|
||||
|
||||
assert visible._files(tmp_path, date(2026, 9, 10)) == [current, nested]
|
||||
|
||||
|
||||
def test_visible_discovery_candidates_include_unmapped_provenance_and_unbiased_quiet_rows():
|
||||
rows = [
|
||||
{"path": "known.png", "keys": ["qwen"], "status": "ok", "uscc": "unmapped-a"},
|
||||
{"path": "a.png", "keys": [], "status": "ok", "uscc": "unmapped-a"},
|
||||
{"path": "b.png", "keys": [], "status": "ok", "platform": "Google Gemini"},
|
||||
{"path": "c.png", "keys": [], "status": "ok"},
|
||||
{"path": "bad.png", "keys": [], "status": "unreadable", "uscc": "unmapped-b"},
|
||||
]
|
||||
|
||||
selected = visible._discovery_candidates(rows, per_cohort=2, random_quiet=10, seed=7)
|
||||
|
||||
assert {row["path"] for row in selected} == {"a.png", "b.png", "c.png"}
|
||||
assert next(row for row in selected if row["path"] == "a.png")["discovery_strata"] == [
|
||||
"tc260:unmapped-a",
|
||||
"unbiased-quiet",
|
||||
]
|
||||
|
||||
|
||||
def test_visible_evaluation_inventory_follows_the_registry():
|
||||
from remove_ai_watermarks.watermark_registry import mark_keys
|
||||
|
||||
assert tuple(mark_keys()) == visible_eval.MARKS
|
||||
|
||||
|
||||
def test_gap_worker_error_is_a_complete_resumable_row(monkeypatch, tmp_path):
|
||||
path = tmp_path / "bad.bin"
|
||||
path.write_bytes(b"not an image")
|
||||
calls = []
|
||||
|
||||
def fail(_path, **kwargs):
|
||||
calls.append(kwargs)
|
||||
def fail(_path):
|
||||
calls.append(_path)
|
||||
raise ValueError("broken\ninput")
|
||||
|
||||
monkeypatch.setattr(gap, "identify", fail)
|
||||
monkeypatch.setattr(gap, "extract_provenance_evidence", fail)
|
||||
row = gap._scan_one((str(path), "bad.bin", "1.2.3"))
|
||||
|
||||
assert set(row) == set(gap.REPORT_FIELDS)
|
||||
assert row["candidate_classes"] == "identify_error"
|
||||
assert row["error"] == "ValueError: broken input"
|
||||
assert calls == [{"check_visible": False, "check_invisible": False}]
|
||||
assert calls == [path]
|
||||
|
||||
|
||||
def test_gap_worker_records_c2pa_review_fields(monkeypatch, tmp_path):
|
||||
path = tmp_path / "signed.png"
|
||||
path.touch()
|
||||
evidence = SimpleNamespace(
|
||||
c2pa_info={
|
||||
"issuer": "Example signer",
|
||||
"claim_generator": "Example generator",
|
||||
"actions": "created",
|
||||
"source_type": "trainedAlgorithmicMedia (AI-generated)",
|
||||
"c2pa_validation_state": "Valid",
|
||||
},
|
||||
scan=b"c2pa",
|
||||
)
|
||||
monkeypatch.setattr(gap, "extract_provenance_evidence", lambda _path: evidence)
|
||||
monkeypatch.setattr(
|
||||
gap,
|
||||
"identify_from_evidence",
|
||||
lambda actual: (
|
||||
SimpleNamespace(
|
||||
is_ai_generated=True,
|
||||
platform="Example",
|
||||
confidence="high",
|
||||
watermarks=["C2PA"],
|
||||
signals=[SimpleNamespace(name="c2pa")],
|
||||
integrity_clashes=[],
|
||||
)
|
||||
if actual is evidence
|
||||
else None
|
||||
),
|
||||
)
|
||||
|
||||
row = gap._scan_one((str(path), "signed.png", "1.2.3"))
|
||||
|
||||
assert row["c2pa_issuer"] == "Example signer"
|
||||
assert row["c2pa_claim_generator"] == "Example generator"
|
||||
assert row["c2pa_actions"] == "created"
|
||||
assert row["c2pa_source_type"] == "trainedAlgorithmicMedia (AI-generated)"
|
||||
assert row["c2pa_validation"] == "Valid"
|
||||
|
||||
|
||||
def test_gap_summary_does_not_report_a_negative_hidden_count(capsys):
|
||||
|
||||
@@ -0,0 +1,119 @@
|
||||
"""Tests for the abstaining source-pipeline classifier research tool."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import source_pipeline_classifier as classifier
|
||||
|
||||
|
||||
def test_command_help_exits_cleanly() -> None:
|
||||
result = CliRunner().invoke(classifier.main, ["--help"])
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Usage:" in result.output
|
||||
assert "train" in result.output
|
||||
assert "predict" in result.output
|
||||
|
||||
|
||||
def _training_rows() -> tuple[np.ndarray, np.ndarray]:
|
||||
rng = np.random.default_rng(20260915)
|
||||
rows = np.vstack(
|
||||
(
|
||||
rng.normal((-3.0, 0.0, 0.0), 0.1, size=(12, 3)),
|
||||
rng.normal((3.0, 0.0, 0.0), 0.1, size=(12, 3)),
|
||||
rng.normal((0.0, 3.0, 0.0), 0.1, size=(12, 3)),
|
||||
)
|
||||
)
|
||||
labels = np.repeat(np.arange(3), 12)
|
||||
return rows, labels
|
||||
|
||||
|
||||
def test_fit_and_abstention_separate_provider_rows() -> None:
|
||||
features, labels = _training_rows()
|
||||
model = classifier.fit_ridge(features, labels, penalty=1.0)
|
||||
raw_scores = classifier.scores(features, model)
|
||||
margin = classifier.calibrate_margin(raw_scores, labels, unknown_fpr=0.01)
|
||||
predictions = classifier.predict_scores(raw_scores, margin)
|
||||
|
||||
assert np.array_equal(predictions[:24], labels[:24])
|
||||
assert np.all(predictions[24:] == classifier.UNKNOWN_INDEX)
|
||||
|
||||
|
||||
def test_model_round_trip_is_pickle_free_and_strict(tmp_path: Path) -> None:
|
||||
grid = 4
|
||||
feature_count = classifier.FEATURE_WIDTH + 3 * grid * grid
|
||||
model = classifier.RidgeModel(
|
||||
mean=np.arange(feature_count, dtype=np.float64),
|
||||
scale=np.full(feature_count, 2.0),
|
||||
weights=np.arange(feature_count * 3, dtype=np.float64).reshape(feature_count, 3),
|
||||
margin=0.25,
|
||||
grid=grid,
|
||||
penalty=100.0,
|
||||
)
|
||||
path = tmp_path / "model.npz"
|
||||
|
||||
classifier.save_model(path, model)
|
||||
loaded = classifier.load_model(path)
|
||||
|
||||
assert loaded.grid == grid
|
||||
assert loaded.margin == model.margin
|
||||
assert np.array_equal(loaded.mean, model.mean)
|
||||
assert np.array_equal(loaded.scale, model.scale)
|
||||
assert np.array_equal(loaded.weights, model.weights)
|
||||
|
||||
with np.load(path, allow_pickle=False) as payload:
|
||||
assert payload.files
|
||||
|
||||
|
||||
def test_model_loader_rejects_extra_fields(tmp_path: Path) -> None:
|
||||
path = tmp_path / "model.npz"
|
||||
np.savez(
|
||||
path,
|
||||
classes=np.asarray(classifier.CLASSES),
|
||||
mean=np.zeros(136),
|
||||
scale=np.ones(136),
|
||||
weights=np.zeros((136, 3)),
|
||||
margin=np.asarray(0.1),
|
||||
spectral_grid=np.asarray(2),
|
||||
penalty=np.asarray(100.0),
|
||||
unexpected=np.asarray(1),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="model fields must be exactly"):
|
||||
classifier.load_model(path)
|
||||
|
||||
|
||||
def test_model_loader_rejects_nonfinite_margin(tmp_path: Path) -> None:
|
||||
grid = 2
|
||||
feature_count = classifier.FEATURE_WIDTH + 3 * grid * grid
|
||||
path = tmp_path / "model.npz"
|
||||
np.savez(
|
||||
path,
|
||||
classes=np.asarray(classifier.CLASSES),
|
||||
mean=np.zeros(feature_count),
|
||||
scale=np.ones(feature_count),
|
||||
weights=np.zeros((feature_count, 3)),
|
||||
margin=np.asarray(np.nan),
|
||||
spectral_grid=np.asarray(grid),
|
||||
penalty=np.asarray(100.0),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="margin must be finite"):
|
||||
classifier.load_model(path)
|
||||
|
||||
|
||||
def test_calibration_requires_unknown_rows() -> None:
|
||||
with pytest.raises(ValueError, match="must contain unknown"):
|
||||
classifier.calibrate_margin(
|
||||
np.zeros((2, 3)),
|
||||
np.asarray([0, 1]),
|
||||
unknown_fpr=0.01,
|
||||
)
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Tests for the phase-free spectral pipeline research harness."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import spectral_pipeline_probe as probe
|
||||
|
||||
|
||||
def test_log_spectrum_discards_translation_phase() -> None:
|
||||
rng = np.random.default_rng(17)
|
||||
channel = rng.normal(size=(32, 32))
|
||||
|
||||
original = probe.phase_free_log_spectrum(channel)
|
||||
translated = probe.phase_free_log_spectrum(np.roll(channel, shift=(5, -7), axis=(0, 1)))
|
||||
|
||||
assert np.allclose(original, translated, rtol=1e-12, atol=1e-12)
|
||||
|
||||
|
||||
def test_spectral_feature_is_finite_and_has_declared_shape() -> None:
|
||||
geometry = probe.make_geometry(size=32, grid=4)
|
||||
yy, xx = np.mgrid[:32, :32]
|
||||
carrier = np.cos(2 * np.pi * (5 * xx + 3 * yy) / 32)
|
||||
pixels = np.stack((carrier, np.roll(carrier, 1, axis=0), np.roll(carrier, 2, axis=1)), axis=2)
|
||||
|
||||
feature = probe.spectral_feature(pixels, geometry)
|
||||
|
||||
assert feature.shape == (3 * 4 * 4,)
|
||||
assert np.all(np.isfinite(feature))
|
||||
assert np.linalg.norm(feature) > 0
|
||||
|
||||
|
||||
def test_discovery_keeps_only_stems_shared_by_every_class(tmp_path: Path) -> None:
|
||||
for class_name in ("first", "second"):
|
||||
directory = tmp_path / class_name
|
||||
directory.mkdir()
|
||||
for stem in ("001", "002"):
|
||||
Image.new("RGB", (8, 8), "white").save(directory / f"{stem}.png")
|
||||
Image.new("RGB", (8, 8), "black").save(tmp_path / "first" / "unpaired.png")
|
||||
|
||||
paths, labels, groups, names = probe.discover_grouped_paths(tmp_path, ("first", "second"))
|
||||
|
||||
assert names == ["001", "002"]
|
||||
assert [path.stem for path in paths] == ["001", "002", "001", "002"]
|
||||
assert labels.tolist() == [0, 0, 1, 1]
|
||||
assert groups.tolist() == [0, 1, 0, 1]
|
||||
|
||||
|
||||
def test_grouped_ridge_never_trains_on_the_test_group() -> None:
|
||||
groups = np.tile(np.arange(14), 3)
|
||||
labels = np.repeat(np.arange(3), 14)
|
||||
class_centers = np.eye(3)[labels] * 8.0
|
||||
group_nuisance = np.column_stack((groups % 2, groups % 3, groups % 5)) * 0.01
|
||||
features = class_centers + group_nuisance
|
||||
|
||||
models = probe.fit_grouped_models(features, labels, groups, class_count=3, folds=5, ridge=1.0)
|
||||
predictions = probe.predict_grouped(models, features)
|
||||
|
||||
assert np.array_equal(predictions, labels)
|
||||
fold_assignment = np.full(len(groups), -1)
|
||||
for fold, model in enumerate(models):
|
||||
assert not np.any(fold_assignment[model.test_rows] >= 0)
|
||||
fold_assignment[model.test_rows] = fold
|
||||
assert np.all(fold_assignment >= 0)
|
||||
for group in np.unique(groups):
|
||||
assert len(np.unique(fold_assignment[groups == group])) == 1
|
||||
|
||||
|
||||
def test_confusion_matrix_uses_actual_rows_and_predicted_columns() -> None:
|
||||
labels = np.asarray([0, 0, 1, 1, 2])
|
||||
predictions = np.asarray([0, 1, 1, 2, 0])
|
||||
|
||||
matrix = probe.confusion_matrix(labels, predictions, class_count=3)
|
||||
|
||||
assert matrix.tolist() == [[1, 1, 0], [0, 1, 1], [1, 0, 0]]
|
||||
Reference in new issue
Block a user