mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-18 15:31:06 +02:00
Compare commits
10
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf30b4f95a | ||
|
|
8ce8bf14da | ||
|
|
3fb94b220d | ||
|
|
d2c5aa66fc | ||
|
|
380820fa7e | ||
|
|
12e9a87865 | ||
|
|
19fccf27cb | ||
|
|
a385aab433 | ||
|
|
1d7a6270d4 | ||
|
|
b5cd16aef0 |
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": [
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
+264
-23
@@ -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 --
|
||||
@@ -1652,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.
|
||||
@@ -1825,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
|
||||
@@ -1840,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.
|
||||
@@ -1865,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
|
||||
@@ -1923,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
|
||||
@@ -1974,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.
|
||||
|
||||
@@ -1986,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
|
||||
@@ -2042,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 |
|
||||
@@ -2079,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)
|
||||
|
||||
+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.3"
|
||||
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"
|
||||
|
||||
@@ -50,6 +50,8 @@ explicitly names a tracked canonical result.
|
||||
| `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,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()
|
||||
@@ -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.3"
|
||||
__version__ = "0.40.6"
|
||||
|
||||
__all__ = [
|
||||
"BatchItemResult",
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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