mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-22 00:31:15 +02:00
Calibrate provider-specific invisible removal profiles
This commit is contained in:
18 files changed
+589
-186
No files matched your search
@@ -272,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]"
|
||||
|
||||
+6
-2
@@ -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
|
||||
```
|
||||
|
||||
Use an explicit `qwen-zimage` or `chroma-zimage` profile with this option. For
|
||||
Google provenance, `auto` resolves to SDXL, whose VAE path has not been calibrated
|
||||
for verified-text restoration and is therefore rejected.
|
||||
|
||||
``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
|
||||
|
||||
+34
-22
@@ -115,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:
|
||||
@@ -249,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
|
||||
@@ -276,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
|
||||
@@ -296,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.
|
||||
|
||||
+216
-17
@@ -1665,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.
|
||||
@@ -1936,22 +1937,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
|
||||
@@ -1999,13 +1998,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
|
||||
@@ -2092,7 +2291,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`. |
|
||||
|
||||
+6
-5
@@ -765,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:
|
||||
|
||||
@@ -781,9 +781,10 @@ 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
|
||||
the VAE already loaded for the selected profile; no second generative profile runs.
|
||||
The engine rejects SDXL, including `auto` when Google provenance selects it,
|
||||
downscaling, and postprocessing combinations that were not evaluated. Tiling is
|
||||
also rejected because the combined
|
||||
tiled-restoration path has no provider-oracle calibration. `InvisibleOptions` exposes
|
||||
the same field for `remove_all`; after a visible-stage edit, the manifest must be built
|
||||
against the staged pixels rather than the pristine source.
|
||||
|
||||
@@ -157,7 +157,8 @@ 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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -89,8 +89,8 @@ 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.
|
||||
|
||||
|
||||
@@ -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,21 @@ 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
|
||||
# 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 self.model_profile == SDXL_ZIMAGE_PROFILE:
|
||||
raise ValueError("Verified text restoration is not supported by the sdxl-zimage profile")
|
||||
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."
|
||||
)
|
||||
|
||||
@@ -21,6 +21,7 @@ from ._internal.watermark_profiles import (
|
||||
REMOVAL_MODULES,
|
||||
SDXL_ZIMAGE_PROFILE,
|
||||
resolve_adaptive_polish,
|
||||
resolve_effective_profile,
|
||||
resolve_seed,
|
||||
)
|
||||
|
||||
@@ -267,12 +268,13 @@ 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)
|
||||
adaptive_polish = 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:
|
||||
if effective_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")
|
||||
|
||||
@@ -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):
|
||||
@@ -87,6 +87,19 @@ class TestVerifiedTextMode:
|
||||
fidelity_anchor=True,
|
||||
)
|
||||
|
||||
def test_auto_rejects_google_text_manifest_before_model_loading(self, tmp_path):
|
||||
import pytest
|
||||
|
||||
manifest = tmp_path / "manifest.json"
|
||||
manifest.write_text("{}", encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="not supported by the sdxl-zimage profile"):
|
||||
self._engine("auto").remove_watermark(
|
||||
tmp_path / "unused.png",
|
||||
text_manifest=manifest,
|
||||
vendor="google",
|
||||
)
|
||||
|
||||
def test_loads_and_forwards_verified_manifest(self, tmp_path, monkeypatch):
|
||||
import json
|
||||
|
||||
@@ -149,7 +162,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 +279,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 +295,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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1015,7 +1015,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.
|
||||
@@ -1281,43 +1281,32 @@ def test_chroma_vae_roundtrip_uses_deterministic_mode_and_restores_native_size(m
|
||||
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.
|
||||
def test_chroma_google_uses_one_content_agnostic_floor():
|
||||
"""Face detection alone cannot safely select a lower Google floor.
|
||||
|
||||
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 +1314,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 +1338,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 +1352,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 +1393,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)
|
||||
Reference in new issue
Block a user