From a385aab4338cc23b93f1afbd4724ee3ef82908e1 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Tue, 15 Sep 2026 16:37:57 -0700 Subject: [PATCH] Calibrate provider-specific invisible removal profiles --- README.md | 4 +- docs/cli.md | 8 +- docs/known-limitations.md | 56 +++-- docs/module-internals.md | 233 ++++++++++++++++-- docs/provider-oracles.md | 135 +++++++++- docs/python-api.md | 11 +- docs/supported-signals.md | 3 +- docs/synthid-detector-research.md | 11 +- .../references/commands.md | 4 +- .../_internal/watermark_profiles.py | 81 +++--- .../_internal/watermark_remover.py | 73 +++--- src/remove_ai_watermarks/cli.py | 4 +- src/remove_ai_watermarks/invisible_engine.py | 6 +- tests/test_audit_image_contracts.py | 2 +- tests/test_cpu_offload.py | 5 +- tests/test_invisible_engine.py | 35 ++- tests/test_platform.py | 3 +- tests/test_qwen_zimage_pipeline.py | 101 ++++++-- 18 files changed, 589 insertions(+), 186 deletions(-) diff --git a/README.md b/README.md index e1a070b..3b625bc 100644 --- a/README.md +++ b/README.md @@ -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]" diff --git a/docs/cli.md b/docs/cli.md index a733106..759e97e 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -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 diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 6363b5b..789210e 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -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. diff --git a/docs/module-internals.md b/docs/module-internals.md index a837a4a..e07f45b 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -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 diff --git a/docs/provider-oracles.md b/docs/provider-oracles.md index 692aaf3..53671f5 100644 --- a/docs/provider-oracles.md +++ b/docs/provider-oracles.md @@ -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`. | diff --git a/docs/python-api.md b/docs/python-api.md index 8b4c149..88b480f 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -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. diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 9d299ad..0ad45da 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -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. diff --git a/docs/synthid-detector-research.md b/docs/synthid-detector-research.md index 4a09570..21d20de 100644 --- a/docs/synthid-detector-research.md +++ b/docs/synthid-detector-research.md @@ -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 diff --git a/skills/remove-ai-watermarks/references/commands.md b/skills/remove-ai-watermarks/references/commands.md index 415eb43..833f81e 100644 --- a/skills/remove-ai-watermarks/references/commands.md +++ b/skills/remove-ai-watermarks/references/commands.md @@ -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. diff --git a/src/remove_ai_watermarks/_internal/watermark_profiles.py b/src/remove_ai_watermarks/_internal/watermark_profiles.py index 58dedaf..4f2569c 100644 --- a/src/remove_ai_watermarks/_internal/watermark_profiles.py +++ b/src/remove_ai_watermarks/_internal/watermark_profiles.py @@ -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") diff --git a/src/remove_ai_watermarks/_internal/watermark_remover.py b/src/remove_ai_watermarks/_internal/watermark_remover.py index 336fa3d..d4fe815 100644 --- a/src/remove_ai_watermarks/_internal/watermark_remover.py +++ b/src/remove_ai_watermarks/_internal/watermark_remover.py @@ -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}") diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 22a183b..98ff66a 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -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." ) diff --git a/src/remove_ai_watermarks/invisible_engine.py b/src/remove_ai_watermarks/invisible_engine.py index 1a92301..993a022 100644 --- a/src/remove_ai_watermarks/invisible_engine.py +++ b/src/remove_ai_watermarks/invisible_engine.py @@ -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") diff --git a/tests/test_audit_image_contracts.py b/tests/test_audit_image_contracts.py index 54ee81e..9d943a8 100644 --- a/tests/test_audit_image_contracts.py +++ b/tests/test_audit_image_contracts.py @@ -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 diff --git a/tests/test_cpu_offload.py b/tests/test_cpu_offload.py index 865522a..6e63cdd 100644 --- a/tests/test_cpu_offload.py +++ b/tests/test_cpu_offload.py @@ -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 diff --git a/tests/test_invisible_engine.py b/tests/test_invisible_engine.py index 2254344..e54e65c 100644 --- a/tests/test_invisible_engine.py +++ b/tests/test_invisible_engine.py @@ -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 diff --git a/tests/test_platform.py b/tests/test_platform.py index 859e98c..774d3e0 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -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 diff --git a/tests/test_qwen_zimage_pipeline.py b/tests/test_qwen_zimage_pipeline.py index 4c1304f..3a6e400 100644 --- a/tests/test_qwen_zimage_pipeline.py +++ b/tests/test_qwen_zimage_pipeline.py @@ -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)