From 16bc9879e9f5454bf4ff34cb60c387e1f67d174d Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Sun, 30 Aug 2026 16:37:20 -0700 Subject: [PATCH] Ship chroma-zimage: Apache-2.0 Chroma1 global stage with measured four-cohort oracle floors Extract TwoStageZImagePipeline shared base from QwenZImagePipeline so all three profiles are siblings, not a chain. Add ChromaZImagePipeline on that base with diffusers ChromaImg2ImgPipeline bf16, neutral calibrated prompt, guidance 5.0, four effective steps with Diffusers truncation compensation, no Canny. Flat vendor floors from the 2026-08-29/30 oracle calibration across OpenAI (0.09), Microsoft InvisMark (0.125), Google (0.40), Meta Content Seal (0.17), seed-stability verified on Meta, full-path face-stage interaction clean through the Gemini oracle. Research record in docs/chroma1-engine-research.md with the matched-strength addendum showing Chroma1 regenerates better per unit strength but its boundaries scatter wider than qwen's. Fix text-manifest guard to reject chroma-zimage at the boundary, update all profile references from 'both' to 'all three' across src, docs, README, and python-api. --- .gitignore | 4 + README.md | 8 +- docs/chroma1-engine-research.md | 306 ++++++ docs/cli.md | 2 +- docs/index.md | 1 + docs/installation.md | 13 +- docs/known-limitations.md | 25 +- docs/module-internals.md | 73 +- docs/python-api.md | 8 +- docs/synthid-robust-identity-research.md | 2 +- pyproject.toml | 2 +- scripts/chroma_google_high_rungs.py | 114 +++ scripts/chroma_meta_high_rungs.py | 109 ++ scripts/chroma_microsoft_ladder.py | 142 +++ scripts/chroma_preship_validation.py | 152 +++ scripts/chroma_scrub_prototype.py | 188 ++++ scripts/engine_cohort_calibration.py | 272 +++++ scripts/engine_quality_price_probe.py | 249 +++++ scripts/openai_provenance_check.py | 76 ++ src/remove_ai_watermarks/__init__.py | 2 +- .../_internal/chroma_zimage_pipeline.py | 128 +++ .../_internal/qwen_zimage_pipeline.py | 927 +---------------- .../_internal/sdxl_zimage_pipeline.py | 60 +- .../_internal/two_stage_pipeline.py | 955 ++++++++++++++++++ .../_internal/watermark_profiles.py | 66 +- .../_internal/watermark_remover.py | 19 +- src/remove_ai_watermarks/cli.py | 10 +- src/remove_ai_watermarks/invisible_engine.py | 18 +- tests/test_platform.py | 2 +- tests/test_qwen_zimage_pipeline.py | 335 ++++-- uv.lock | 2 +- 31 files changed, 3204 insertions(+), 1066 deletions(-) create mode 100644 docs/chroma1-engine-research.md create mode 100755 scripts/chroma_google_high_rungs.py create mode 100755 scripts/chroma_meta_high_rungs.py create mode 100755 scripts/chroma_microsoft_ladder.py create mode 100755 scripts/chroma_preship_validation.py create mode 100755 scripts/chroma_scrub_prototype.py create mode 100755 scripts/engine_cohort_calibration.py create mode 100755 scripts/engine_quality_price_probe.py create mode 100755 scripts/openai_provenance_check.py create mode 100644 src/remove_ai_watermarks/_internal/chroma_zimage_pipeline.py create mode 100644 src/remove_ai_watermarks/_internal/two_stage_pipeline.py diff --git a/.gitignore b/.gitignore index 50375b9..e28c284 100644 --- a/.gitignore +++ b/.gitignore @@ -30,6 +30,10 @@ _refs/ yolov8n.pt .coverage +# Local prototype and evaluation outputs (scripts/chroma_scrub_prototype.py and +# friends); the durable record of an oracle verdict lives under data/evaluations/ +out/ + # Claude Code local settings .claude/settings.local.json .claude/scheduled_tasks.lock diff --git a/README.md b/README.md index 49cdf1a..781af94 100644 --- a/README.md +++ b/README.md @@ -192,7 +192,7 @@ remains useful for unusually important files or after provider changes, but it is not a product result state. For invisible watermark removal, install the `qwen-zimage` extra. **An NVIDIA GPU -is required**: both profiles are CUDA-only, and there is no CPU or MPS fallback. +is required**: all profiles are CUDA-only, and there is no CPU or MPS fallback. ```bash uv tool install --force "remove-ai-watermarks[qwen-zimage]" @@ -243,7 +243,9 @@ uv run remove-ai-watermarks visible demo_banana_before.png \ `qwen-zimage` is the default profile: a Qwen-Image-2512 Lightning pass under Canny ControlNet, followed by SAM-masked Z-Image repair of any detected face. The alternative, `sdxl-zimage`, swaps the global stage for SDXL and keeps the same face -stage. Both are CUDA only. +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. All are CUDA only. ```bash uv tool install --force "remove-ai-watermarks[qwen-zimage]" @@ -416,7 +418,7 @@ invisible removal. The shipped profile is oracle-certified, but no public local decoder can certify an arbitrary output at runtime. Recheck unusually important outputs after provider changes. -- Invisible-watermark removal requires CUDA. Both profiles refuse any other +- Invisible-watermark removal requires CUDA. All profiles refuse any other device at construction rather than falling back to one that cannot run them. Visible removal, metadata stripping and `identify` still run anywhere. - Provider watermark systems can change. Validate important outputs with the diff --git a/docs/chroma1-engine-research.md b/docs/chroma1-engine-research.md new file mode 100644 index 0000000..ccf114a --- /dev/null +++ b/docs/chroma1-engine-research.md @@ -0,0 +1,306 @@ +# Chroma1 engine research (2026-08-29/30) + +> Research archive. This page records experiments and decisions from the dates +> above. Current package behavior is defined by the +> [supported signals](supported-signals.md), [known limitations](known-limitations.md), +> and [module internals](module-internals.md). Nothing here is shipped. + +Cited experiment behind issue #88 ("Please integrate Flux 2"): FLUX.2 has no +native strength img2img anywhere (BFL reference code or diffusers; its image +inputs are reference conditioning and outputs start from pure noise), so the +requestable scrub lane in the FLUX family is **Chroma1** -- the FLUX.1 +architecture re-trained on open data, Apache-2.0 on weights, with a native +`ChromaImg2ImgPipeline` strength parameter. This page records what was measured +before any integration decision. + +## Setup + +All Chroma1 runs: `lodestones/Chroma1-HD` bf16 on Modal H100, seed 0, the +neutral scrub prompt (`high quality, sharp, detailed, faithful to the original` +/ `blurry, lowres, distorted text, garbled text, artifacts`), guidance 5.0, and +**step compensation**: Diffusers truncates the step COUNT (`int(steps * +strength)`), so the requested count is scaled to always spend 4 effective +denoising steps, the same semantics as `sdxl_zimage_pipeline.requested_steps`. +Without it, strength 0.05 runs a single step and the floor reads artificially +high (the first, uncompensated sweep put the OpenAI boundary at (0.05, 0.10]; +compensated it is (0.05, 0.06]). + +Two Flux-family traps found while wiring the prototype: + +- without explicit `width`/`height`, the img2img pipeline silently generates + 1024x1024 regardless of input aspect; +- dimensions floor to the /16 latent-patch grid, so the result must be resized + back to the input size for pixel-exact comparison. + +Scripts: `scripts/chroma_scrub_prototype.py` (first sweep), +`scripts/engine_quality_price_probe.py` (head-to-head with the production +qwen-zimage path), `scripts/engine_cohort_calibration.py` (the cohort ladders +below), `scripts/chroma_meta_high_rungs.py` (the Meta rungs above 0.08). +Generated outputs stay outside the repository (`out/`, gitignored); the durable +verdicts are the tables on this page. + +## Measured floors + +Oracle per cohort: OpenAI via openai.com/verify (SynthID read separately from +C2PA); Meta via the anonymous meta.ai/identification oracle. Each fixture's +rungs were checked ascending and stopped at the first clean verdict. + +| cohort | fixture | detected at | clean from | +|---|---|---|---| +| OpenAI | typography card 1122x1402 | 0.05 | 0.06 | +| OpenAI | 9-face grid 1448x1086 | 0.06 | 0.075 | +| OpenAI | full-pipeline quality fixture 1122x1402 | 0.05 | 0.06 | +| Meta | gen_lighthouse_watercolor 1600x1600 | 0.08 | 0.10 | +| Meta | gen_fox_forest 1600x1600 | 0.06 | 0.08 | +| Meta | gen_night_city 1920x1280 | 0.045 | 0.06 | +| Meta | gen_studio_mug 1600x1600 | 0.03 | 0.045 | +| Meta | gen_text_poster 1600x1600 | -- | <=0.03 | + +Derived operating points by the shipped rule (worst first-clean boundary plus +one observed cross-source spread): + +- **OpenAI: 0.09** (0.075 + (0.075 - 0.06)). Against qwen-zimage 0.07675 and + sdxl-zimage 0.15. The typography boundary (0.05, 0.06] is seed-stable across + seeds 0, 1, 2. +- **Meta: 0.17** (0.10 + (0.10 - 0.03)), oracle-verified clean on the worst + fixture at 0.17. Against qwen-zimage 0.1. Chroma needs ~1.7x qwen's strength + on Content Seal. +- **Google: 0.40, measured 2026-08-30 across all four fixtures.** A + metadata-stripped original verified as pixel-DETECTED through the @synthid + Verify AI agent (the C2PA-carrying original answers about C2PA instead, + which is not a pixel verdict). + + | fixture | detected at | clean from | + |---|---|---| + | Gemini_Generated_Image_633uuy | 0.20 | 0.25 | + | Gemini_Generated_Image_akdbei | 0.20 | 0.25 | + | Gemini_Generated_Image_y48j3c | 0.08 | 0.12 | + | Gemini_Generated_Image_3mc4t9 | 0.08 | 0.12 | + + Derived by the shipped rule: worst first-clean 0.25 + spread (0.25 - 0.12 + = 0.13) = 0.38, rounded up to the measured rung **0.40**, then + oracle-verified clean on the worst fixture. Against qwen-zimage 0.27: + Chroma needs ~1.5x the strength on Google SynthID. At 0.15-0.20 the tool + still reads positive while its wording degrades from "most or all" to + "part of", so the boundary band is real signal decay, not noise. + + Oracle mechanics learned while bracketing: the quota is per ACCOUNT and + additionally dedupes SIMILAR images ("quota for checking images similar to + this one"), roughly three checks per account window; switching accounts + inside one browser works. A fresh Playwright context with the same Google + session cookies (exported browser-wide via CDP `Storage.getCookies`, never + through the transcript) carries working sign-in and its own check budget. + The agent's own instructions leak into the answer on some renders and + include an abstention rule for text-dominated images - always read the TOOL + outcome, never the prose. +- **Microsoft: 0.125**, measured 2026-08-30 on the SAME three valid Paint + carriers behind the qwen floor (staged under + `out/cohort-calibration/microsoft/` with pixel-identical metadata-stripped + controls; they are real raiw-corpus user uploads and are never committed). + Controls re-verified `Microsoft AI detected` on the day of the run. Against + qwen-zimage 0.15: Chroma needs LESS strength on InvisMark. + + | source (Paint) | detected at | clean from | qwen first-clean | + |---|---|---|---| + | paint-1 (47d87e99, 1536x1024) | 0.06 | 0.08 | 0.055 | + | paint-2 (a579fb70, 1536x1024) | -- | <=0.04 | 0.04125 | + | paint-3 (883191b5, worst qwen source) | 0.06 | 0.08 | 0.095 | + + Derived by the shipped rule: 0.08 + (0.08 - 0.04) = 0.12, rounded to the + measured rung **0.125**, which was then oracle-verified clean on both worst + sources. Oracle: the public `ai.azure.com/nextgen/validate` page; its + `Inconclusive` verdict is the site's watermark-negative, weaker than an + API-level `Watermark: false`, the same caveat the qwen cohort carries. + +## Fidelity at the floors (scripts/fidelity_metrics.py) + +qwen-zimage rows ran through the package's own `QwenZImagePipeline` on the same +H100 (production path, both stacks resident, prompt cache warm). + +OpenAI typography (chroma 0.06 vs qwen 0.07675): + +| | CER | img LPIPS | SSIM | PSNR | +|---|---|---|---|---| +| qwen-zimage | 0.241 | 0.083 | 0.834 | 27.7 | +| chroma1 | **0.138** | **0.049** | **0.881** | **31.2** | + +OpenAI full-pipeline fixture (chroma 0.09 vs qwen 0.07675): chroma wins every +metric (CER 0.155 vs 0.259, LPIPS 0.056 vs 0.094, PSNR 29.6 vs 26.1). + +OpenAI 9-face grid (chroma 0.075 global-only vs qwen 0.07675 with its Z-Image +face stage): + +| | ID cos | face LPIPS | img LPIPS | SSIM | PSNR | +|---|---|---|---|---|---| +| qwen-zimage | **0.857** | **0.051** | 0.065 | 0.865 | 31.1 | +| chroma1 (no face stage) | 0.788 | 0.067 | **0.060** | **0.890** | **32.4** | + +The face stage regenerates from the ORIGINAL crops and is engine-independent, +so an integrated `chroma-zimage` would composite the same faces over the +chroma global; whether that closes the identity gap is an integration-time +measurement, not a given. + +Meta at the respective floors (chroma 0.17 vs qwen 0.1): qwen wins perceptual +fidelity on textured content (LPIPS: fox 0.163 vs 0.211, lighthouse 0.173 vs +0.248, night city 0.187 vs 0.277), chroma wins the text poster decisively +(LPIPS 0.008 vs 0.012, CER 0.000 vs 0.021) and the studio mug is a split. The +extra 0.07 of strength costs real fidelity on photoreal content. + +Google at the respective floors (chroma 0.40 vs qwen 0.27, qwen row includes +its Z-Image face stage): + +| fixture | qwen LPIPS/SSIM/ID cos | chroma LPIPS/SSIM/ID cos | +|---|---|---| +| 633uuy | **0.405 / 0.577** / - | 0.499 / 0.576 / - (CER 1.000 vs 0.333) | +| akdbei | **0.440 / 0.552** / - | 0.540 / 0.557 / - (CER 0.796 vs 0.367) | +| y48j3c (18 faces) | **0.436 / 0.608 / 0.801** | 0.560 / 0.548 / 0.279 | +| 3mc4t9 (5 faces) | **0.422 / 0.529 / 0.921** | 0.562 / 0.461 / 0.143 | + +qwen wins every axis that matters on every fixture; at 0.40 the Chroma global +regeneration itself collapses face identity (0.14-0.28 cosine) and destroys +dense text (CER up to 1.0). The extra 0.13 of strength is simply too much +regeneration. This is the Meta pattern amplified. + +Microsoft at the respective floors (chroma 0.125 vs qwen 0.15): chroma wins +EVERY metric on EVERY source while scrubbing at lower strength -- + +| source | CER q/ch | img LPIPS q/ch | SSIM q/ch | PSNR q/ch | +|---|---|---|---|---| +| paint-1 | 0.741 / 0.241 | 0.164 / 0.106 | 0.742 / 0.847 | 24.8 / 29.3 | +| paint-2 | 0.826 / 0.391 | 0.253 / 0.108 | 0.536 / 0.633 | 22.5 / 25.5 | +| paint-3 | 0.000 / 0.000 | 0.038 / 0.020 | 0.762 / 0.953 | 36.3 / 42.2 | + +The Microsoft cohort therefore behaves like the OpenAI cohort, not like Meta: +for InvisMark carriers Chroma1 is the better engine at a lower operating point. + +## Price (warm H100 container) + +| | qwen-zimage | chroma1 | +|---|---|---| +| seconds per image | 2.44 | 3.62 | +| VRAM peak | 31.9 GiB | 29.2 GiB | +| USD per 1000 images ($3.95/h) | $2.68 | $3.97 | +| weights in cache | ~70 GB | ~28 GB | + +The chroma number includes a T5 encode per call; the shipped qwen path caches +prompt embeddings, and the same treatment would put chroma near 1.5-2 s/image. +Both stacks fit an 80 GB card; chroma's smaller footprint likely fits smaller +cards untested. + +## Matched-strength comparison: the floors hide the engine (2026-08-30 addendum) + +The floor-based tables above compare chroma at its DERIVED floor (worst case ++ spread) against qwen at its shipped floor - different perturbation levels. +At the strength where each fixture ACTUALLY turns clean (chroma first-clean +rungs, all oracle-verified above), the picture reverses: + +Google (chroma@first-clean vs qwen@0.27): + +| fixture | chroma rung | LPIPS q/ch | SSIM q/ch | PSNR q/ch | CER q/ch | ID q/ch | +|---|---|---|---|---|---|---| +| 633uuy | 0.25 | 0.405/**0.375** | 0.577/**0.673** | 18.8/**22.3** | **0.333**/0.667 | - | +| akdbei | 0.25 | 0.440/**0.409** | 0.552/**0.650** | 18.6/**22.5** | 0.367/**0.163** | - | +| y48j3c | 0.12 | 0.436/**0.329** | 0.608/**0.700** | 21.0/**25.8** | 1.0/1.0 | **0.801**/0.611 | +| 3mc4t9 | 0.12 | 0.422/**0.318** | 0.529/**0.644** | 17.7/**21.3** | 0.443/**0.229** | **0.921**/0.410 | + +Meta (chroma@first-clean vs qwen@0.1): + +| fixture | chroma rung | LPIPS q/ch | SSIM q/ch | PSNR q/ch | CER q/ch | +|---|---|---|---|---|---| +| lighthouse | 0.10 (equal) | 0.173/**0.097** | 0.783/**0.886** | 25.6/**30.8** | - | +| fox | 0.08 | 0.163/**0.083** | 0.778/**0.907** | 25.6/**31.4** | - | +| night_city | 0.06 | 0.187/**0.092** | 0.748/**0.893** | 23.9/**29.6** | 0.850/**0.250** | +| studio_mug | 0.045 | 0.024/**0.009** | 0.984/**0.992** | **39.0**/37.8 | - | +| text_poster | 0.03 | 0.012/**0.002** | 0.984/**0.994** | 34.0/**41.2** | 0.021/**0.007** | + +At effective-clean strength Chroma1 wins whole-image fidelity on essentially +every fixture (often by 4-6 dB) and text on five of six; qwen's remaining +edge is face identity, which its Z-Image face stage buys (0.80-0.92 vs +0.41-0.61 cosine) and a chroma-zimage profile would inherit on top of its own +global pass. + +**Why the floors still matter:** a shipped strength must clear the worst +unseen content, and Chroma1's per-fixture boundaries scatter much wider +(Google 0.12-0.25, Meta 0.03-0.10) than the spread the flat floors were built +to absorb - so its derived floors (0.40 / 0.17) pay a large margin penalty +and at those floors the extra strength genuinely destroys quality. The +engines differ less in "quality per strength" than in how predictable their +boundary is. The integration lever this exposes: a content-adaptive strength +policy for chroma-zimage (the project already ships resolution-adaptive +denoise for qwen) could capture chroma's low per-content boundaries; the two +hard Google fixtures are both dense-text cards and the easy ones are +photoreal, so a content-class signal may predict the rung. + +## Pre-ship validation (2026-08-30) + +With the `chroma-zimage` profile implemented +(`_internal/chroma_zimage_pipeline.py` on `TwoStageZImagePipeline`, floors in +`watermark_profiles`), two ship-gate checks were run: + +1. **Seed stability at the worst Meta boundary.** lighthouse @ 0.10 with + seeds 1 and 2: both CLEAN on the meta.ai oracle. The Meta boundary is not a + seed-0 fluke. The Google seed probes (633uuy @ 0.25, seeds 1-2) were + generated but the oracle's per-image-class quota blocked them on every + available account; they remain queued, and the shipped 0.40 Google floor + carries 0.15 of margin over that boundary regardless. +2. **Full-path face-stage interaction.** The complete profile run (Chroma1 + global at the shipped Google floor 0.40, then the inherited + YuNet/SAM/Z-Image face stage) on the 5-face fixture 3mc4t9: CLEAN on the + @synthid oracle. The composited face regions do not re-introduce a + detectable signal on top of the floor. + +Generation script: `scripts/chroma_preship_validation.py`; outputs under +`out/preship-chroma/` (gitignored). + +## Verdict + +Engine quality is cohort-dependent, and after measuring all four cohorts the +split is even and sharp: + +- On OpenAI-provenance content Chroma1 is better at every measured fidelity + axis at its floor (0.09 vs 0.07675), for comparable money. +- On Microsoft InvisMark content Chroma1 is better at every measured fidelity + axis at a LOWER floor than qwen (0.125 vs 0.15). +- On Meta Content Seal Chroma1 needs 1.7x the strength (0.17 vs 0.1) and loses + LPIPS on textured content at its floor. +- On Google SynthID Chroma1 needs ~1.5x the strength (0.40 vs 0.27) and loses + every fidelity axis on every fixture, with face identity collapsing at 0.40 + (0.14-0.28 cosine vs qwen's 0.80-0.92). + +A single-engine `chroma-zimage` default at a flat per-vendor floor is NOT +supported: Google and Meta floors land too high and the quality at those +floors is worse than qwen's. But the matched-strength addendum changes what +IS supported: at the strength each image actually needs, Chroma1 regenerates +better than qwen almost everywhere except face identity (which the shared +face stage would supply). The two honest integration options: + +1. Per-cohort engine router at flat floors: chroma for OpenAI/Microsoft, + qwen for Google/Meta - conservative, ships on the measurements as they + stand. +2. `chroma-zimage` with a content-adaptive strength policy that predicts the + per-image rung - potentially better everywhere, but the predictor is new + research (the measured signal so far: dense-text content needs the high + rungs, photoreal the low ones) and needs its own oracle validation before + any ship. + +Both need the seed-sensitivity and face-stage composition checks called out +above before shipping. Both checks have now been run (Pre-ship validation +above): the Meta boundary is seed-stable across seeds 0-2 and the full +profile path stays clean through the face stage on the shipped Google floor. +The profile ships with option 1's floors; option 2's adaptive policy remains +the documented follow-up. + +## Oracle session notes + +- openai.com/verify throttles anonymous sessions through Cloudflare Turnstile + after ~15 rapid checks; a fresh browser context clears it. The programmatic + `POST /v1/content_provenance_checks` API is the better oracle (separate + SynthID/C2PA outcomes) but the checked `OPENAI_API_KEY` returned 401 + org-wide, including `/v1/models`. +- meta.ai/identification enforces a sliding per-IP window: a burst of ~15 + checks exhausted it twice; it reopens minutes later, and the page sometimes + overstates this as a daily limit. Pacing of roughly 2-3 checks per window + worked. +- The Gemini app oracle requires the user's logged-in session; driving the + user's live Chrome while it is in active use was refused (wrong-tab risk), + so those 20 checks remain queued. diff --git a/docs/cli.md b/docs/cli.md index ff99e4d..63d725f 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -367,7 +367,7 @@ failed encode does not overwrite an existing result. ## Remove invisible watermarks -Install the removal dependencies first. Both profiles are CUDA-only and both +Install the removal dependencies first. All profiles are CUDA-only and all run the DiffSynth Z-Image face stage, so this is the extra either one needs: ```bash diff --git a/docs/index.md b/docs/index.md index 5fed2f8..a537dac 100644 --- a/docs/index.md +++ b/docs/index.md @@ -39,3 +39,4 @@ The current behavior is defined by the code, tests, README, and user guides. - [SynthID identity follow-up](synthid-robust-identity-research-2026-06-08.md) - [Video SynthID quality research](video-synthid-quality-research.md) - [Text protection research](text-protection-research.md) +- [Chroma1 engine research](chroma1-engine-research.md) diff --git a/docs/installation.md b/docs/installation.md index 529d409..bd8cf8c 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -75,11 +75,14 @@ uv tool install --force "remove-ai-watermarks[qwen-zimage]" ``` Both remaining profiles run a Z-Image face stage on the DiffSynth runtime, so -both need this extra. It includes the `diffusion` dependencies; `diffusion` on its -own covers the torch and diffusers imports but not the face stage, so it is not -enough to run a removal. +both need this extra. The third profile, `chroma-zimage`, uses diffusers' +ChromaImg2ImgPipeline instead of DiffSynth for its global stage but inherits +the same face stage, so it needs this extra too. It includes the `diffusion` +dependencies; `diffusion` on its own covers the torch and diffusers imports +but not the face stage, so it is not enough to run a removal. -**An NVIDIA GPU is required.** `qwen-zimage` and `sdxl-zimage` are CUDA-only, and +**An NVIDIA GPU is required.** `qwen-zimage`, `sdxl-zimage` and `chroma-zimage` +are CUDA-only, and construction refuses any other device rather than falling back to a slow or broken one. There is no CPU, MPS or XPU path for invisible-watermark removal. Visible-mark removal, metadata stripping and every `identify` command still run anywhere. @@ -241,5 +244,5 @@ image came from a relevant generator, use `--force`. If the CLI reports that the removal dependencies are unavailable, install the `qwen-zimage` extra. `diffusion` alone covers Torch and Diffusers but not the -DiffSynth face stage that both profiles run. Video SynthID removal is a separate +DiffSynth face stage that all profiles run. Video SynthID removal is a separate path and needs `video` and `diffusion`. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index d155801..67ec198 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -185,7 +185,7 @@ path has not yet answered. ### Strength is content and seed dependent -The two profiles resolve an unset strength differently, because different things +The profiles resolve an unset strength differently, because different things were measured for each. `qwen-zimage` reads unknown content from the resolution-adaptive denoise curve. @@ -208,10 +208,23 @@ values are flat rather than a curve because flat values are what was measured: e verdict came from a fixed strength at one size, and no size dependence has been established for that stage. -An explicit `--strength` overrides both. The defaults are operating points, not -universal guarantees. Near a removal threshold, different content or a different -random seed may change the verifier result, which is why both profiles are -certified at a fixed seed. The live resolver is +`chroma-zimage` also reads a flat ladder from the provenance cohort, measured +2026-08-29/30 across the four vendor oracles (full record: +[`chroma1-engine-research.md`](chroma1-engine-research.md)): + +- OpenAI: `0.09` (below qwen's `0.07675` is false economy neither way -- the + matched-strength fidelity favors Chroma1 on this cohort); +- Microsoft InvisMark: `0.125` (below qwen's `0.15`); +- Google: `0.40` (above qwen's `0.27`; at this floor the regeneration destroys + dense text and collapses face identity -- the tradeoff that motivates the + documented content-adaptive follow-up); +- Meta Content Seal: `0.17` (above qwen's `0.1`); +- unknown: `0.40`, 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 +different random seed may change the verifier result, which is why the profiles +are certified at a fixed seed. The live resolver is [`watermark_profiles.py`](../src/remove_ai_watermarks/_internal/watermark_profiles.py). ### Pipelines have different quality tradeoffs @@ -272,7 +285,7 @@ device and re-reads every parameter from disk on each stage transition. Invisible-watermark removal refuses any device but CUDA at construction. There is no MPS out-of-memory fallback that continues on CPU, and no lighter profile to -drop to: both remaining profiles need an NVIDIA GPU. +drop to: all three profiles need an NVIDIA GPU. When a run does not fit, the levers are `--tile` (native geometry, tiled diffusion), `--max-resolution` (an explicit downscale), and `--cpu-offload`. diff --git a/docs/module-internals.md b/docs/module-internals.md index 8d52f4f..3df9779 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -940,12 +940,12 @@ is the source of truth for: - profile names and their underscore spellings; - the fixed seed; - the SDXL global-stage checkpoint id (`SDXL_MODEL_ID`) and the Canny ControlNet id; -- strength resolution for both profiles. +- strength resolution for every profile. -The current profiles are `qwen-zimage` (the default) and `sdxl-zimage`, and both -are CUDA-only. `controlnet`, `sdxl`, `qwen` and `default` were removed rather than -kept as a CPU path, and are rejected rather than aliased onward. There is no -content-dependent automatic router. +The current profiles are `qwen-zimage` (the default), `sdxl-zimage`, and +`chroma-zimage`, and all three are CUDA-only. `controlnet`, `sdxl`, `qwen` and +`default` were removed rather than kept as a CPU path, and are rejected rather +than aliased onward. There is no content-dependent automatic router. `qwen-zimage` normally resolves global denoise from image area for unknown content. Measured provider cohorts bypass that curve with flat operating points. The values, @@ -963,8 +963,10 @@ by the profile, so none of them appears in `WatermarkRemover.__init__`, then rejected several frames down; a signature that refuses the argument outright fails where the caller can act on it, and stops a wrapper from threading a value that would silently do nothing. The step count and CFG live with the stage that -runs them (`GLOBAL_STEPS`, `FACE_STEPS`, `GLOBAL_CFG`, `FACE_CFG` in -`qwen_zimage_pipeline.py`). The dtype is likewise profile-owned: see "Face-stage +runs them: `GLOBAL_STEPS`/`GLOBAL_CFG` with the Qwen global stage +(`qwen_zimage_pipeline.py`), `SDXL_STEPS` with the SDXL one +(`sdxl_zimage_pipeline.py`), and `FACE_STEPS`/`FACE_CFG` with the shared face +stage (`two_stage_pipeline.py`). The dtype is likewise profile-owned: see "Face-stage dtype" for what an override cost the last time one existed. `device` is the exception and remains a library parameter: `None` or `"auto"` @@ -991,7 +993,7 @@ same pixels. They diverged before, in opposite directions, for exactly this knob The global and face prompts are calibrated model inputs, and the Canny edge map uses fixed thresholds of `_CANNY_LOW = 13` / `_CANNY_HIGH = 64` -(`qwen_zimage_pipeline.py`). Treat those values as behavioral compatibility +(`two_stage_pipeline.py`). Treat those values as behavioral compatibility contracts: a refactor must preserve them, and any deliberate change requires image-quality evaluation rather than only a unit-test pass. The prompt and edge-map regression guards are @@ -1039,8 +1041,19 @@ Regression coverage: ### Qwen plus Z-Image +The profiles are siblings of one shared recipe rather than a chain: +[`_internal/two_stage_pipeline.py`](../src/remove_ai_watermarks/_internal/two_stage_pipeline.py) +holds `TwoStageZImagePipeline`, the base that owns everything profile-independent +(the Z-Image face stage with YuNet detection and SAM masks, sizing, compositing, +the prompt cache, and the `run`/`preload` orchestration). A profile subclass +implements `_load_global` and `_run_global` for its regeneration model and may +implement `_vae_roundtrip` to expose that stack's VAE as a verified-text donor; +nothing else differs, and a test asserts the shared methods are the same objects +on every subclass. Adding a third global stage means implementing those hooks, +not inheriting from another profile's pipeline. + [`_internal/qwen_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py) -implements the fixed CUDA-only two-stage profile: +implements the fixed CUDA-only two-stage profile on that base: 1. Qwen Image with Canny conditioning regenerates the frame. 2. YuNet locates faces, SAM builds masks, and Z-Image regenerates the selected @@ -1140,13 +1153,43 @@ oracle outcomes, and the DiffSynth/GGUF, scheduler, detector, and seed caveats a recorded in [`data/evaluations/fidelity/upstream-v2-reproduction-2026-08-13.csv`](../data/evaluations/fidelity/upstream-v2-reproduction-2026-08-13.csv). +### Chroma1 plus Z-Image + +[`_internal/chroma_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/chroma_zimage_pipeline.py) +runs the same two-stage recipe on a Chroma1-HD (`lodestones/Chroma1-HD`, +Apache-2.0) global pass through diffusers' `ChromaImg2ImgPipeline`. It is the +answer to issue #88's FLUX.2 request through the model that actually exposes +strength-controlled img2img in that family; the full research record, +including why FLUX.2 itself is not integrable this way, is +[`chroma1-engine-research.md`](chroma1-engine-research.md). + +The profile swaps only the global stage (same inheritance invariant as +sdxl-zimage, asserted by the same test shape). Three things are bound to the +calibration and must not drift: the NEUTRAL prompt (`high quality, sharp, +detailed, faithful to the original` -- deliberately NOT the shared canny-stage +prompt, which was never calibrated against Chroma1), guidance 5.0, and the +four-EFFECTIVE-step schedule with `requested_steps` compensation (the same +Diffusers truncation trap sdxl-zimage documents). 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.09 and Microsoft 0.125 (both BELOW qwen's, with better +matched-strength fidelity), Google 0.40 and Meta 0.17 (both 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. A content-adaptive strength policy is the documented follow-up. + ### SDXL plus Z-Image [`_internal/sdxl_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/sdxl_zimage_pipeline.py) -runs the same two-stage recipe on an SDXL global pass. `SdxlZImagePipeline` subclasses -`QwenZImagePipeline` and overrides only `_run_global` and `preload`, so the face stage -is inherited rather than copied and cannot drift between the profiles; a test asserts -the shared methods are the same objects. +runs the same two-stage recipe on an SDXL global pass. `SdxlZImagePipeline` +subclasses `TwoStageZImagePipeline` and implements only the global stage +(`_load_global`, `_run_global`), so the face stage is inherited rather than copied +and cannot drift between the profiles; a test asserts the shared methods are the +same objects. Four things are architecture-bound and swap with the model: the ControlNet (`xinsir/controlnet-canny-sdxl-1.0`), the four-step distillation LoRA @@ -1184,7 +1227,7 @@ avoid model downloads, so nothing exercised the loader. The lesson is narrower t was a dtype the subclass silently changed out from under it. Note what the seam is, because it decides where the fix belongs. -`SdxlZImagePipeline._load_sdxl` hardcodes fp16 for its own ControlNet, VAE and +`SdxlZImagePipeline._load_global` hardcodes fp16 for its own ControlNet, VAE and pipeline, so `self.torch_dtype` was never actually the global stage's dtype on this profile -- its only remaining readers were face-stage code. SAM was the second one: it never crashed, because it casts its own inputs and leaves through `.float()`, but it @@ -1271,7 +1314,7 @@ within one pipeline. `_cache_static_prompt_embeddings` therefore persists what the text encoder produced under `_model_cache_dir()/prompt-embeddings`, keyed by cache version, model id, pipeline output params, and the exact prompt string. Once that file exists, -`_load_qwen` and `_load_zimage` drop the text-encoder `ModelConfig` from the model +`_load_global` and `_load_zimage` drop the text-encoder `ModelConfig` from the model stack entirely and serve the stored tensors instead. Measured on an H100 volume in August 2026, that removes **15.45 GiB** (Qwen2.5-VL) and **7.49 GiB** (Z-Image) of a **87.6 GiB** per-request read, worth a median **11.76 s** and **4.10 s** of load time diff --git a/docs/python-api.md b/docs/python-api.md index 4d93f11..59e6955 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -587,7 +587,7 @@ to 8-bit SDR. ## Remove invisible watermarks -Install `remove-ai-watermarks[qwen-zimage]`. Both profiles need it, and both +Install `remove-ai-watermarks[qwen-zimage]`. All three profiles need it, and all need an NVIDIA GPU. ```python @@ -596,7 +596,7 @@ from pathlib import Path from remove_ai_watermarks.invisible_engine import InvisibleEngine engine = InvisibleEngine( - pipeline="qwen-zimage", # the default; the only other value is "sdxl-zimage" + pipeline="qwen-zimage", # the default; also "sdxl-zimage" or "chroma-zimage" device=None, cpu_offload=False, ) @@ -620,7 +620,7 @@ engine = InvisibleEngine( ) ``` -Both profiles are CUDA-only, so on a machine without an NVIDIA GPU `device=None` +All profiles are CUDA-only, so on a machine without an NVIDIA GPU `device=None` resolves to `cpu` and construction raises. For the SDXL global stage instead of Qwen: @@ -628,7 +628,7 @@ Qwen: engine = InvisibleEngine(pipeline="sdxl-zimage") ``` -The `qwen-zimage` extra is required for both profiles: each runs the same +The `qwen-zimage` extra is required for all three profiles: each runs the same DiffSynth Z-Image face stage. The opt-in verified-text stage uses the same `text_manifest` argument as the CLI: diff --git a/docs/synthid-robust-identity-research.md b/docs/synthid-robust-identity-research.md index 8fdf568..36965ff 100644 --- a/docs/synthid-robust-identity-research.md +++ b/docs/synthid-robust-identity-research.md @@ -248,7 +248,7 @@ from the test set + this doc). watermark. Stop and reconsider. 2. **PhotoMaker-V1 prototype** in the existing `controlnet` pipeline: - Mirror the `_load_controlnet_pipeline` path (removed in 0.24.0 with the - controlnet profile; the equivalent seam is now `SdxlZImagePipeline._load_sdxl`): + controlnet profile; the equivalent seam is now `SdxlZImagePipeline._load_global`): add a PhotoMaker variant that loads SDXL + canny ControlNet + PhotoMaker adapter on the same engine. - Extract the OpenCLIP face embedding from the watermarked face crops (use diff --git a/pyproject.toml b/pyproject.toml index fa3f175..084ccb5 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ # on it, including the ComfyUI node package. The console script below carries # the same weight, since users have it on PATH. name = "remove-ai-watermarks" -version = "0.34.1" +version = "0.35.0" description = "AI watermark remover for visible, invisible, and provenance marks in images and video" readme = "README.md" requires-python = ">=3.11,<3.15" diff --git a/scripts/chroma_google_high_rungs.py b/scripts/chroma_google_high_rungs.py new file mode 100755 index 0000000..4f97849 --- /dev/null +++ b/scripts/chroma_google_high_rungs.py @@ -0,0 +1,114 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["modal>=1.5"] +# /// +"""Extra high Chroma1 rungs (0.25-0.40) for the Google cohort fixtures. + +The 0.08-0.20 ladder came back detected end-to-end on the first fixture, so +this appends the high rungs for every Google fixture without touching the +already-oracled lower outputs. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +import modal + +_ROOT = Path(__file__).resolve().parent.parent + +app = modal.App("chroma-google-high-rungs") + +image = ( + modal.Image.debian_slim(python_version="3.12") + .pip_install( + "torch", + "diffusers>=0.38,<1", + "transformers>=4.53", + "accelerate", + "sentencepiece", + "protobuf", + "pillow", + "numpy", + ) + .env({"HF_HOME": "/cache"}) +) + +cache = modal.Volume.from_name("chroma-hf-cache", create_if_missing=True) + +CHROMA_MODEL_ID = "lodestones/Chroma1-HD" +RUNGS = (0.25, 0.30, 0.35, 0.40) +EFFECTIVE_STEPS = 4 +_PROMPT = "high quality, sharp, detailed, faithful to the original" +_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts" + +FIXTURES = ( + "Gemini_Generated_Image_633uuy633uuy633u.png", + "Gemini_Generated_Image_akdbeiakdbeiakdb.png", + "Gemini_Generated_Image_y48j3cy48j3cy48j.png", + "Gemini_Generated_Image_3mc4t93mc4t93mc4.png", +) +MAX_SIDE = 1536 + + +@app.function(image=image, gpu="H100", volumes={"/cache": cache}, timeout=2400) +def run(payload: dict[str, bytes]) -> dict: + import io + import time + + import torch + from diffusers import ChromaImg2ImgPipeline + from PIL import Image + + chroma = ChromaImg2ImgPipeline.from_pretrained(CHROMA_MODEL_ID, torch_dtype=torch.bfloat16).to("cuda") + out: dict = {} + for name, data in payload.items(): + source = Image.open(io.BytesIO(data)).convert("RGB") + if MAX_SIDE and max(source.size) > MAX_SIDE: + scale = MAX_SIDE / max(source.size) + source = source.resize( + (round(source.width * scale), round(source.height * scale)), + Image.Resampling.LANCZOS, + ) + width = max(16, (source.width // 16) * 16) + height = max(16, (source.height // 16) * 16) + for strength in RUNGS: + started = time.perf_counter() + generator = torch.Generator(device="cpu").manual_seed(0) + image = chroma( + prompt=_PROMPT, + negative_prompt=_NEGATIVE, + image=source, + width=width, + height=height, + strength=strength, + num_inference_steps=max(1, math.ceil(EFFECTIVE_STEPS / strength)), + guidance_scale=5.0, + generator=generator, + ).images[0] + if image.size != source.size: + image = image.resize(source.size, Image.Resampling.LANCZOS) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + out[f"{Path(name).stem}/chroma_s{strength:.2f}"] = { + "png": buffer.getvalue(), + "seconds": round(time.perf_counter() - started, 2), + } + print(f"{name} s={strength} done", flush=True) + cache.commit() + return out + + +@app.local_entrypoint() +def main(out: str = "out/cohort-calibration/google") -> None: + payload = {name: (_ROOT / "data" / "synthid" / "originals" / name).read_bytes() for name in FIXTURES} + results = run.remote(payload) + timing = {} + for key, item in results.items(): + destination = Path(out) / f"{key}.png" + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(item["png"]) + timing[key] = item["seconds"] + print(json.dumps(timing, indent=2)) diff --git a/scripts/chroma_meta_high_rungs.py b/scripts/chroma_meta_high_rungs.py new file mode 100755 index 0000000..39a7bf7 --- /dev/null +++ b/scripts/chroma_meta_high_rungs.py @@ -0,0 +1,109 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["modal>=1.5"] +# /// +"""Extra high Chroma1 rungs for the Meta Content Seal cohort. + +The first calibration ladder topped out at 0.08 and the worst fixture +(gen_lighthouse_watercolor) was still detected there, so this appends 0.10, +0.12 and 0.15 for every Meta fixture without touching the already-oracled +lower rungs. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +import modal + +app = modal.App("chroma-meta-high-rungs") + +_ROOT = Path(__file__).resolve().parent.parent + +image = ( + modal.Image.debian_slim(python_version="3.12") + .pip_install( + "torch", + "diffusers>=0.38,<1", + "transformers>=4.53", + "accelerate", + "sentencepiece", + "protobuf", + "pillow", + "numpy", + ) + .env({"HF_HOME": "/cache"}) +) + +cache = modal.Volume.from_name("chroma-hf-cache", create_if_missing=True) + +CHROMA_MODEL_ID = "lodestones/Chroma1-HD" +RUNGS = (0.17,) +EFFECTIVE_STEPS = 4 +_PROMPT = "high quality, sharp, detailed, faithful to the original" +_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts" + +META_FIXTURES = ( + "gen_fox_forest.webp", + "gen_text_poster.webp", + "gen_lighthouse_watercolor.webp", + "gen_night_city.webp", + "gen_studio_mug.webp", +) + + +@app.function(image=image, gpu="H100", volumes={"/cache": cache}, timeout=1800) +def run(payload: dict[str, bytes]) -> dict: + import io + import time + + import torch + from diffusers import ChromaImg2ImgPipeline + from PIL import Image + + chroma = ChromaImg2ImgPipeline.from_pretrained(CHROMA_MODEL_ID, torch_dtype=torch.bfloat16).to("cuda") + out: dict = {} + for name, data in payload.items(): + source = Image.open(io.BytesIO(data)).convert("RGB") + width = max(16, (source.width // 16) * 16) + height = max(16, (source.height // 16) * 16) + for strength in RUNGS: + started = time.perf_counter() + generator = torch.Generator(device="cpu").manual_seed(0) + image = chroma( + prompt=_PROMPT, + negative_prompt=_NEGATIVE, + image=source, + width=width, + height=height, + strength=strength, + num_inference_steps=max(1, math.ceil(EFFECTIVE_STEPS / strength)), + guidance_scale=5.0, + generator=generator, + ).images[0] + if image.size != source.size: + image = image.resize(source.size, Image.Resampling.LANCZOS) + buffer = io.BytesIO() + image.save(buffer, format="PNG") + out[f"{Path(name).stem}/chroma_s{strength:.3f}"] = { + "png": buffer.getvalue(), + "seconds": round(time.perf_counter() - started, 2), + } + print(f"{name} s={strength} done", flush=True) + cache.commit() + return out + + +@app.local_entrypoint() +def main(out: str = "out/cohort-calibration/meta") -> None: + payload = {name: (_ROOT / "data" / "contentseal" / "originals" / name).read_bytes() for name in META_FIXTURES} + results = run.remote(payload) + timing = {} + for key, item in results.items(): + destination = Path(out) / f"{key}.png" + destination.parent.mkdir(parents=True, exist_ok=True) + destination.write_bytes(item["png"]) + timing[key] = item["seconds"] + print(json.dumps(timing, indent=2)) diff --git a/scripts/chroma_microsoft_ladder.py b/scripts/chroma_microsoft_ladder.py new file mode 100755 index 0000000..e895cce --- /dev/null +++ b/scripts/chroma_microsoft_ladder.py @@ -0,0 +1,142 @@ +# /// script +# requires-python = ">=3.11" +# dependencies = ["modal>=1.5"] +# /// +"""Chroma1 ladder for the Microsoft InvisMark cohort (three valid Paint sources) +plus the matching qwen-zimage production runs at the shipped 0.15 floor. + +The sources are the same three Paint carriers behind the qwen cohort floor +(staged under out/cohort-calibration/microsoft/