mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-15 22:10:45 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
5991894ca5 | ||
|
|
5fa14f9e26 | ||
|
|
f7e0a18e6c | ||
|
|
bc5922451f | ||
|
|
798c94e6ae |
No files matched your search
@@ -245,7 +245,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. All are CUDA only.
|
||||
calibration. `--pipeline auto` picks chroma-zimage for OpenAI and Microsoft
|
||||
provenance and qwen-zimage otherwise. All are CUDA only.
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
|
||||
+209
-12
@@ -35,7 +35,10 @@ Two Flux-family traps found while wiring the prototype:
|
||||
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).
|
||||
below), `scripts/chroma_meta_high_rungs.py` (the Meta rungs above 0.08),
|
||||
`scripts/chroma_muse_expansion.py` (harvest-2 Meta API fixtures),
|
||||
`scripts/chroma_openai_expansion.py` (extra OpenAI SynthID carriers),
|
||||
`scripts/chroma_seed_probes.py` (seed-1/2 at measured first-cleans).
|
||||
Generated outputs stay outside the repository (`out/`, gitignored); the durable
|
||||
verdicts are the tables on this page.
|
||||
|
||||
@@ -239,10 +242,33 @@ With the `chroma-zimage` profile implemented
|
||||
|
||||
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.
|
||||
seed-0 fluke. The Google seed probes (633uuy @ 0.25) delivered a critical
|
||||
finding: **seed 0 was CLEAN but seed 1 was DETECTED** -- the 633uuy
|
||||
boundary is seed-dependent, which validates the flat 0.40 floor's 0.15
|
||||
margin exactly the way the qwen SDXL precedent did (the same image at the
|
||||
same strength passing or failing run-to-run near the threshold). Seed 2
|
||||
of `seedprobe_google_633uuy_s0.25_seed2.png` was checked twice on
|
||||
2026-08-31 after a human drag-drop. Work Gemini (u/2, Pro,
|
||||
`https://gemini.google.com/u/2/app/ebc7c19d22f85bb9`) with the
|
||||
ordinary question "Is there a SynthID watermark in this image?"
|
||||
auto-titled "SynthID Watermark Detection Results" and returned "No
|
||||
reliable signals were detected indicating how the content was created,
|
||||
and you should look for other supporting evidence from trusted
|
||||
sources." Plus `@synthid` (u/0,
|
||||
`https://gemini.google.com/u/0/app/658b1470307811b7`) with the built-in
|
||||
verification question auto-titled "SynthID Image Verification Results"
|
||||
and returned "No digital watermark from Google AI was detected in this
|
||||
image, indicating that it was not created or edited using Google's AI
|
||||
models." Both are CLEAN. An earlier Plus `@synthid` attempt on the
|
||||
same file hung on "Connecting to Verify AI" with auto-title "SynthID
|
||||
Image Verification Failed"; Flash prose abstained because "the tool
|
||||
response does not confirm" Google AI, which is not a tool verdict.
|
||||
Workspace Gemini's More-uploads menu does not list Photos, but a
|
||||
human drag-drop of a local PNG does attach; one Workspace account in
|
||||
the same Chrome has no Gemini app. Seed 0 CLEAN / seed 1 DETECTED /
|
||||
seed 2 CLEAN at 0.25
|
||||
leaves the 633uuy boundary seed-dependent on the Plus `@synthid`
|
||||
path; the 0.40 floor is already established by the seed-1 flip.
|
||||
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
|
||||
@@ -290,19 +316,190 @@ profile path stays clean through the face stage on the shipped Google floor.
|
||||
The profile ships with option 1's floors plus a content-adaptive Google arm
|
||||
(face content resolves to 0.125 instead of 0.40, keyed on YuNet detection).
|
||||
Option 2's full adaptive policy for the remaining cohorts remains the
|
||||
documented follow-up.
|
||||
documented follow-up. The Meta arm was measured on 2026-08-30/31 and
|
||||
does not have a shippable split; see the expansion section below.
|
||||
|
||||
## Meta content-adaptive expansion (2026-08-30/31)
|
||||
|
||||
Goal: test whether `flat_ratio` (fraction of 16x16 blocks with luma std < 8)
|
||||
predicts Chroma1's first-clean Content Seal boundary well enough to ship a
|
||||
Meta arm analogous to Google's face-count split.
|
||||
|
||||
**Harvest 1, from a 495-file spaces dump, was not a diverse expansion.**
|
||||
Seven "unique" files were staged under `out/cohort-calibration/meta/muse-*`
|
||||
(gitignored). After control checks on `meta.ai/identification`:
|
||||
|
||||
| fixture | control | first-clean | note |
|
||||
|---|---|---|---|
|
||||
| muse-1 | CLEAN | -- | seal did not survive the metadata-stripped control |
|
||||
| muse-2 | DETECTED | (0.08, 0.10] | photoreal portrait; 0.06 and 0.08 DETECTED, 0.10 CLEAN |
|
||||
| muse-3 | DETECTED | (0.015, 0.03] | photoreal portrait, different subject |
|
||||
| muse-4 | DETECTED | (0.08, 0.10] | same subject as muse-2/6/7; 0.06 and 0.08 DETECTED, 0.10 CLEAN |
|
||||
| muse-5 | -- | -- | byte-identical to committed `gen_lighthouse_watercolor.webp` |
|
||||
| muse-6 | DETECTED | (0.06, 0.08] | same subject as muse-4; 0.06 DETECTED, 0.08 CLEAN |
|
||||
| muse-7 | DETECTED | (0.045, 0.06] | same subject as muse-2/4/6; 0.045 DETECTED, 0.06 CLEAN |
|
||||
|
||||
Five valid new points, four of them near-duplicate portraits of one
|
||||
subject, with first-clean already spanning 0.06-0.10 **inside that
|
||||
subject**. Google's shipped split had zero overlap between classes and
|
||||
identical boundaries inside a class. This harvest cannot support a
|
||||
predictor.
|
||||
|
||||
`flat_ratio` on the original five plus the new portraits is not monotonic
|
||||
with first-clean either (studio_mug 0.973 / 0.045 easy; lighthouse 0.602 /
|
||||
0.10 hard; muse-4 0.789 / 0.10 hard). OpenAI stays unshippable for the
|
||||
same reason as before: one text fixture vs one face fixture, and the face
|
||||
fixture was the harder one, the opposite of Google's split.
|
||||
|
||||
**Harvest 2** takes six files from the 2026-08-26 Muse Image API corpus
|
||||
(61 independent `muse-image-1.0` generations, no hash overlap with the
|
||||
committed five), chosen to span class and `flat_ratio`:
|
||||
|
||||
| fixture | class | flat_ratio | edge_density |
|
||||
|---|---|---|---|
|
||||
| architecture_mosque | dense architecture | 0.037 | 0.367 |
|
||||
| product_sneaker | product / high-flat | 0.874 | 0.018 |
|
||||
| text_poster_bakery | text | 0.368 | 0.191 |
|
||||
| portrait_weathered_fisherman | photoreal portrait | 0.470 | 0.108 |
|
||||
| illustration_watercolor_botanical | illustration | 0.442 | 0.089 |
|
||||
| scene_tokyo_alley | busy scene | 0.258 | 0.175 |
|
||||
|
||||
Script: `scripts/chroma_muse_expansion.py`. Outputs under
|
||||
`out/cohort-calibration/meta-api/` (gitignored). Chroma1 ladders (seven
|
||||
rungs, seed 0, four effective steps) ran for all six. Oracle
|
||||
2026-08-31, anonymous `playwright-isolated` against
|
||||
`meta.ai/identification`. All six controls DETECTED (valid carriers).
|
||||
|
||||
| fixture | flat_ratio | detected at | clean from |
|
||||
|---|---|---|---|
|
||||
| architecture_mosque | 0.037 | 0.08 | 0.10 |
|
||||
| illustration_watercolor_botanical | 0.442 | 0.08 | 0.10 |
|
||||
| scene_tokyo_alley | 0.258 | 0.06 | 0.08 |
|
||||
| text_poster_bakery | 0.368 | 0.045 | 0.06 |
|
||||
| portrait_weathered_fisherman | 0.470 | 0.045 | 0.06 |
|
||||
| product_sneaker | 0.874 | 0.03 | 0.045 |
|
||||
|
||||
The worst first-clean is still 0.10, the same as the committed
|
||||
lighthouse, so the shipped Meta floor 0.17 does not move.
|
||||
|
||||
**No Meta adaptive arm.** Google's shipped split had zero overlap
|
||||
between classes and identical first-cleans inside a class. Combined
|
||||
with the original five, Meta first-cleans are a continuum from 0.03 to
|
||||
0.10, and `flat_ratio` does not separate them:
|
||||
|
||||
- the 0.10 cluster is mosque 0.037, botanical 0.442, and lighthouse
|
||||
0.602;
|
||||
- the 0.045 cluster is sneaker 0.874 and studio_mug 0.973;
|
||||
- harvest-1 photoreal portraits were high-flat (0.75-0.85) AND hard
|
||||
(0.08-0.10), so a high-flat easy-arm would misroute them.
|
||||
|
||||
A content-class rule fails the same way: two text posters first-clean
|
||||
at 0.03 and 0.06, and two watercolor-like images (lighthouse, botanical)
|
||||
share 0.10 with dense architecture.
|
||||
|
||||
The leftover rungs were finished in a 2026-08-31 evening UTC
|
||||
anonymous isolated session (fresh-navigation, wait for "Upload
|
||||
another file"; all ten `POST /api/ai-detector` calls returned 200):
|
||||
|
||||
| check | verdict |
|
||||
|---|---|
|
||||
| muse-2 @ 0.08 | DETECTED |
|
||||
| muse-3 @ 0.015 / 0.03 / 0.045 | DETECTED / CLEAN / CLEAN |
|
||||
| muse-7 @ 0.045 / 0.06 | DETECTED / CLEAN |
|
||||
| mosque @ 0.10 seeds 1, 2 | CLEAN / CLEAN |
|
||||
| botanical @ 0.10 seeds 1, 2 | DETECTED / DETECTED |
|
||||
|
||||
botanical's seed-0 first-clean of 0.10 is not seed-stable;
|
||||
lighthouse @ 0.10 seeds 1 and 2 were CLEAN. That is the same class
|
||||
of run-to-run flip the 0.17 floor's margin exists for. No
|
||||
Google-style binary split: harvest-1 first-cleans now span 0.03
|
||||
(muse-3) through 0.10 (muse-2/4), and 0.06-0.10 inside one subject
|
||||
(muse-7 / muse-6 / muse-2).
|
||||
|
||||
## OpenAI content-adaptive expansion (2026-08-31)
|
||||
|
||||
Goal: test whether face_count or `flat_ratio` predicts Chroma1's OpenAI
|
||||
SynthID first-clean the way YuNet does for Google.
|
||||
|
||||
Committed three (YuNet + `flat_ratio` on the files, first-cleans from the
|
||||
2026-08-29/30 web oracle):
|
||||
|
||||
| fixture | faces | flat_ratio | first-clean |
|
||||
|---|---|---|---|
|
||||
| typography 02_03_55 | 0 | 0.642 | 0.06 |
|
||||
| quality 02_02_23 | 0 | 0.695 | 0.06 |
|
||||
| 9-face grid 05_30 | 9 | 0.473 | 0.075 |
|
||||
|
||||
Face content is the harder of the two classes, the opposite of Google.
|
||||
Two zero-face cards share a boundary, which is a hint, not a split.
|
||||
|
||||
**Images API is not a carrier source.** `gpt-image-1` and `gpt-image-1.5`
|
||||
generations (medium, 1024) carry C2PA (`detected`) but SynthID
|
||||
`not_detected` on `POST /v1/content_provenance_checks`. Contrastive-pair
|
||||
"openai" recreations are also SynthID-negative. New ChatGPT-UI downloads
|
||||
are not in the tree beyond the three committed files.
|
||||
|
||||
**Spaces harvest (one day of `_full_scan`, 2026-07-24):** 41 files with
|
||||
OpenAI C2PA, of which 7 checked by the provenance API were SynthID
|
||||
`DETECTED` (one `NOT_DETECTED`). Four diverse DETECTED files were staged
|
||||
under `out/cohort-calibration/openai-expand/` (gitignored; user uploads,
|
||||
not committed) and given a Chroma1 ladder (0.04-0.12, seed 0) by
|
||||
`scripts/chroma_openai_expansion.py`:
|
||||
|
||||
| id | faces | flat_ratio |
|
||||
|---|---|---|
|
||||
| dense_ed68 | 0 | 0.118 |
|
||||
| midflat_038c | 0 | 0.323 |
|
||||
| textlike_3b17 | 0 | 0.649 |
|
||||
| faces2_4dab | 2 | 0.279 |
|
||||
|
||||
The provenance API then 429'd. The same checks were finished on
|
||||
`https://openai.com/research/verify/` (the page reports SynthID and C2PA
|
||||
separately under View details). Chroma PNG outputs have no C2PA, so a
|
||||
"No OpenAI signals detected" heading is a pixel-SynthID negative.
|
||||
|
||||
| id | faces | @0.04 | @0.05 | @0.06 |
|
||||
|---|---|---|---|---|
|
||||
| dense_ed68 | 0 | CLEAN | CLEAN | CLEAN |
|
||||
| midflat_038c | 0 | -- | -- | CLEAN |
|
||||
| textlike_3b17 | 0 | -- | -- | CLEAN |
|
||||
| faces2_4dab | 2 | -- | -- | CLEAN |
|
||||
|
||||
9-face grid @ 0.075, seeds 1 and 2: both CLEAN (SynthID not detected).
|
||||
The committed seed-0 first-clean is not a fluke.
|
||||
|
||||
**No OpenAI adaptive arm.** Zero-face extras clear at or below 0.06, the
|
||||
2-face extra also clears at 0.06, and only the 9-face grid needs 0.075.
|
||||
That is not a YuNet split: `face_count > 0` would over-strengthen the
|
||||
2-face file, and a high face-count cutoff would be one fixture. The
|
||||
spread (0.075 - 0.06) is already absorbed by the shipped 0.09 floor.
|
||||
Images API generations remain non-carriers (C2PA only).
|
||||
|
||||
## 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`.
|
||||
SynthID/C2PA outcomes). The `OPENAI_API_KEY` in the main-checkout `.env`
|
||||
returned 401 org-wide, including `/v1/models`, through the 2026-08-30
|
||||
calibration; it was replaced on 2026-08-31 and both `/v1/models` and
|
||||
a provenance check on a committed ChatGPT original then succeeded
|
||||
(DETECTED).
|
||||
- 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.
|
||||
worked. On 2026-08-31 an anonymous isolated session got a real 429 from
|
||||
`POST /api/ai-detector` with page text "You’ve reached the daily limit
|
||||
for identifications. Try again tomorrow" after about six checks in the
|
||||
UTC morning and again after about fifteen in the UTC afternoon, so the
|
||||
daily-limit wording is not always overstated; the afternoon window was
|
||||
large enough to finish harvest 2. An evening isolated session the same
|
||||
day got ten 200s and finished the leftover harvest-1 rungs plus the
|
||||
mosque/botanical seed probes.
|
||||
- The Gemini app oracle requires the user's logged-in session. A human
|
||||
drag-drop of a local PNG works on Work Gemini (u/2, 2026-08-31) and on
|
||||
Plus (u/0 the same day); Playwright cannot drive the OS picker, and
|
||||
the first `input[type=file].accept` is documents/code only. Plus
|
||||
`@synthid` hung once after attach, then returned a tool CLEAN on a
|
||||
second human-drag chat. Do not drive the live Chrome while it is in
|
||||
active use (wrong-tab risk).
|
||||
+3
-1
@@ -410,8 +410,10 @@ 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, with lower OpenAI and Microsoft floors and higher Google and Meta floors |
|
||||
| `auto` | Pick the engine from provenance: chroma-zimage for OpenAI and Microsoft, qwen-zimage for Google, Meta, and unknown |
|
||||
|
||||
**Both are CUDA-only.** There is no CPU or MPS profile for invisible-watermark
|
||||
**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
|
||||
rather than kept as a CPU path: none of them matched this recipe's face preservation,
|
||||
so offering them implied a quality the library no longer delivers. Passing a retired
|
||||
|
||||
@@ -115,7 +115,7 @@ application actually uses:
|
||||
| `diffusion` | Torch and Diffusers runtime; video SynthID regeneration | `pixels`, Torch, Diffusers | Yes |
|
||||
| `migan` | MI-GAN ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
|
||||
| `lama` | big-LaMa ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch |
|
||||
| `qwen-zimage` | Invisible image-watermark removal, both CUDA-only profiles | `diffusion`, DiffSynth | Yes |
|
||||
| `qwen-zimage` | Invisible image-watermark removal, all CUDA-only profiles | `diffusion`, DiffSynth | Yes |
|
||||
| `text-restoration` | Opt-in verified Qwen-VAE glyph restoration | `qwen-zimage`, `lama` | Yes |
|
||||
| `text-draft` | Draft OCR proposals for operator verification | PaddleOCR, PaddlePaddle | Model download, no Torch |
|
||||
| `all` | Every production feature available on the active Python | All compatible rows above | Yes |
|
||||
|
||||
@@ -73,10 +73,12 @@ as SynthID lives in the pixels everywhere, including inside the glyphs, so
|
||||
frozen text regions would keep the watermark
|
||||
([text protection research](text-protection-research.md)).
|
||||
|
||||
`qwen-zimage` is the default profile and `sdxl-zimage` the only alternative.
|
||||
Both are CUDA only and differ only in the global regeneration model: each
|
||||
conditions that stage on a canny edge map, which preserves structure but not
|
||||
identity or exact texture, and each then runs the same face stage.
|
||||
`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
|
||||
OpenAI and Microsoft provenance 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
|
||||
@@ -235,6 +237,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 OpenAI and 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.
|
||||
|
||||
@@ -942,10 +942,12 @@ is the source of truth for:
|
||||
- the SDXL global-stage checkpoint id (`SDXL_MODEL_ID`) and the Canny ControlNet id;
|
||||
- strength resolution for every profile.
|
||||
|
||||
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.
|
||||
The current profiles are `qwen-zimage` (the default), `sdxl-zimage`,
|
||||
`chroma-zimage`, and `auto`, and all four are CUDA-only. `controlnet`, `sdxl`,
|
||||
`qwen` and `default` were removed rather than kept as a CPU path, and are
|
||||
rejected rather than aliased onward. `auto` is a per-cohort engine router:
|
||||
chroma-zimage for OpenAI and Microsoft, qwen-zimage for Google, Meta, and
|
||||
unknown. It is not a content-class split inside one engine.
|
||||
|
||||
`qwen-zimage` normally resolves global denoise from image area for unknown content.
|
||||
Measured provider cohorts bypass that curve with flat operating points. The values,
|
||||
@@ -1184,7 +1186,15 @@ 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.
|
||||
first shipped piece of the content-adaptive policy. 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
|
||||
easy-arm would misroute the harvest-1 portraits. The worst seed-0
|
||||
first-clean is still 0.10; botanical seeds 1 and 2 stay DETECTED at
|
||||
that rung, which is the margin the 0.17 floor already holds. OpenAI stays on the flat 0.09 floor: extra SynthID-positive carriers
|
||||
clear at or below 0.06, including a 2-face file, and only the 9-face
|
||||
grid needs 0.075 (seed-stable on 0, 1, 2 via openai.com/research/verify).
|
||||
|
||||
### SDXL plus Z-Image
|
||||
|
||||
|
||||
+5
-3
@@ -596,7 +596,7 @@ from pathlib import Path
|
||||
from remove_ai_watermarks.invisible_engine import InvisibleEngine
|
||||
|
||||
engine = InvisibleEngine(
|
||||
pipeline="qwen-zimage", # the default; also "sdxl-zimage" or "chroma-zimage"
|
||||
pipeline="qwen-zimage", # the default; also "sdxl-zimage", "chroma-zimage", or "auto"
|
||||
device=None,
|
||||
cpu_offload=False,
|
||||
)
|
||||
@@ -628,8 +628,10 @@ Qwen:
|
||||
engine = InvisibleEngine(pipeline="sdxl-zimage")
|
||||
```
|
||||
|
||||
The `qwen-zimage` extra is required for all three profiles: each runs the same
|
||||
DiffSynth Z-Image face stage.
|
||||
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 OpenAI and Microsoft provenance 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:
|
||||
|
||||
|
||||
@@ -131,10 +131,12 @@ The `invisible` command uses diffusion regeneration. It targets watermark
|
||||
patterns by changing the image rather than decoding and deleting a known
|
||||
payload.
|
||||
|
||||
Current pipeline values, both CUDA-only:
|
||||
Current pipeline values, all CUDA-only:
|
||||
|
||||
- `qwen-zimage`, the default;
|
||||
- `sdxl-zimage`, the same recipe and the same face stage on an SDXL global pass.
|
||||
- `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 OpenAI and Microsoft provenance, 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.
|
||||
|
||||
+3
-3
@@ -461,9 +461,9 @@ conditioning, never by copying original pixels.**
|
||||
|
||||
> **Superseded in 0.24.0.** The `controlnet`, `sdxl`, `qwen` and `default` profiles
|
||||
> were removed, and a retired name is now rejected at parse time rather than routed
|
||||
> onward. `--pipeline` accepts only `qwen-zimage` (the default) and `sdxl-zimage`;
|
||||
> both are CUDA-only, both condition their global stage on a canny edge map, and both
|
||||
> run the same Z-Image face stage. The bullets below are a record of what was measured
|
||||
> onward. Current `--pipeline` values are `qwen-zimage` (the default),
|
||||
> `sdxl-zimage`, `chroma-zimage`, and `auto`; all are CUDA-only and share the
|
||||
> Z-Image face stage. The bullets below are a record of what was measured
|
||||
> on the removed profiles, kept because the oracle verdicts are still the evidence the
|
||||
> current design rests on.
|
||||
|
||||
|
||||
+1
-1
@@ -4,7 +4,7 @@
|
||||
# on it, including the ComfyUI node package. The console script below carries
|
||||
# the same weight, since users have it on PATH.
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.35.1"
|
||||
version = "0.36.0"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
|
||||
@@ -0,0 +1,160 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["modal>=1.5"]
|
||||
# ///
|
||||
"""Chroma1 ladder for a diverse Meta Muse subset (content-adaptive research).
|
||||
|
||||
The first expansion harvested seven files from a spaces dump of 495 Meta
|
||||
candidates. That set was not diverse: one control lost the seal, one was a
|
||||
byte-identical copy of the committed lighthouse fixture, and four of the
|
||||
remaining five were near-duplicate photoreal portraits of one subject.
|
||||
This script instead ladders six API-generated Muse Image files from the
|
||||
2026-08-26 61-image corpus, chosen to span flat_ratio and content class
|
||||
(architecture, product, text, portrait, illustration, busy scene).
|
||||
|
||||
Outputs stay under out/ (gitignored). Oracle verdicts are recorded in
|
||||
docs/chroma1-engine-research.md.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import modal
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
_SRC = _ROOT / "src"
|
||||
if str(_SRC) not in sys.path:
|
||||
sys.path.insert(0, str(_SRC))
|
||||
|
||||
app = modal.App("chroma-muse-expansion")
|
||||
|
||||
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.015, 0.03, 0.045, 0.06, 0.08, 0.10, 0.12)
|
||||
EFFECTIVE_STEPS = 4
|
||||
_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
||||
_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
|
||||
# Six files from the 2026-08-26 Muse Image API corpus. None of them hashes
|
||||
# to a committed contentseal original. Names are corpus stems.
|
||||
FIXTURES = (
|
||||
"architecture_mosque",
|
||||
"product_sneaker",
|
||||
"text_poster_bakery",
|
||||
"portrait_weathered_fisherman",
|
||||
"illustration_watercolor_botanical",
|
||||
"scene_tokyo_alley",
|
||||
)
|
||||
|
||||
_CORPUS_IMAGES = (
|
||||
Path.home() / "Documents/GitHub/remove-ai-watermarks/data/research/corpora/" / "meta-muse-corpus-2026-08-26/images"
|
||||
)
|
||||
|
||||
|
||||
def _fixture_source(name: str) -> Path:
|
||||
matches = sorted(_CORPUS_IMAGES.glob(f"{name}-*.webp"))
|
||||
if not matches:
|
||||
raise FileNotFoundError(f"No corpus image for {name} under {_CORPUS_IMAGES}")
|
||||
return matches[0]
|
||||
|
||||
|
||||
def stage(staged_root: Path) -> None:
|
||||
"""Write lossless PNG originals and metadata-stripped pixel-identical controls."""
|
||||
from PIL import Image
|
||||
|
||||
import remove_ai_watermarks.metadata as metadata
|
||||
|
||||
if Path(metadata.__file__).resolve().parts[: len(_SRC.resolve().parts)] != _SRC.resolve().parts:
|
||||
raise RuntimeError(f"metadata loaded from {metadata.__file__}, not {_SRC}")
|
||||
|
||||
staged_root.mkdir(parents=True, exist_ok=True)
|
||||
for name in FIXTURES:
|
||||
source = _fixture_source(name)
|
||||
dest_dir = staged_root / name
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
original = dest_dir / "original.png"
|
||||
with Image.open(source) as opened:
|
||||
opened.convert("RGB").save(original, format="PNG")
|
||||
control = dest_dir / "control.png"
|
||||
control.write_bytes(original.read_bytes())
|
||||
metadata.remove_ai_metadata(control, control, keep_standard=True)
|
||||
print(f"staged {name} from {source.name}", flush=True)
|
||||
|
||||
|
||||
@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")
|
||||
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"{name}/chroma_s{strength:.4f}"] = {
|
||||
"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(staged: str = "out/cohort-calibration/meta-api") -> None:
|
||||
staged_root = Path(staged)
|
||||
if not all((staged_root / name / "original.png").exists() for name in FIXTURES):
|
||||
stage(staged_root)
|
||||
payload = {name: (staged_root / name / "original.png").read_bytes() for name in FIXTURES}
|
||||
results = run.remote(payload)
|
||||
timing = {}
|
||||
for key, item in results.items():
|
||||
destination = staged_root / f"{key}.png"
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(item["png"])
|
||||
timing[key] = item["seconds"]
|
||||
(staged_root / "timing.json").write_text(json.dumps(timing, indent=2) + "\n")
|
||||
print(json.dumps(timing, indent=2))
|
||||
@@ -0,0 +1,99 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["modal>=1.5"]
|
||||
# ///
|
||||
"""Chroma1 ladder for extra OpenAI SynthID carriers (content-adaptive research).
|
||||
|
||||
The three committed ChatGPT originals are two zero-face text cards (first-clean
|
||||
0.06) and one 9-face grid (0.075). Images API gpt-image-1 / gpt-image-1.5 stamp
|
||||
C2PA but not SynthID, so they are not carriers. These four files are spaces
|
||||
uploads with OpenAI C2PA whose pixel SynthID still reads DETECTED on
|
||||
POST /v1/content_provenance_checks. Outputs stay under out/ (gitignored).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import modal
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
app = modal.App("chroma-openai-expansion")
|
||||
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.04, 0.05, 0.06, 0.075, 0.09, 0.12)
|
||||
_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
||||
_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
FIXTURES = ("dense_ed68", "midflat_038c", "textlike_3b17", "faces2_4dab")
|
||||
|
||||
|
||||
@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")
|
||||
width = max(16, (source.width // 16) * 16)
|
||||
height = max(16, (source.height // 16) * 16)
|
||||
for strength in RUNGS:
|
||||
started = time.perf_counter()
|
||||
image = chroma(
|
||||
prompt=_PROMPT,
|
||||
negative_prompt=_NEGATIVE,
|
||||
image=source,
|
||||
width=width,
|
||||
height=height,
|
||||
strength=strength,
|
||||
num_inference_steps=max(1, math.ceil(4 / strength)),
|
||||
guidance_scale=5.0,
|
||||
generator=torch.Generator(device="cpu").manual_seed(0),
|
||||
).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"{name}/chroma_s{strength:.4f}"] = {
|
||||
"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(staged: str = "out/cohort-calibration/openai-expand") -> None:
|
||||
staged_root = Path(staged)
|
||||
payload = {name: (staged_root / name / "original.png").read_bytes() for name in FIXTURES}
|
||||
results = run.remote(payload)
|
||||
timing = {}
|
||||
for key, item in results.items():
|
||||
destination = staged_root / f"{key}.png"
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(item["png"])
|
||||
timing[key] = item["seconds"]
|
||||
(staged_root / "timing.json").write_text(json.dumps(timing, indent=2) + "\n")
|
||||
print(json.dumps(timing, indent=2))
|
||||
@@ -0,0 +1,101 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["modal>=1.5"]
|
||||
# ///
|
||||
"""Seed-1/2 probes at measured first-clean rungs (research, gitignored out/).
|
||||
|
||||
Mosque and botanical are the new Meta 0.10 worst cases. The OpenAI 9-face
|
||||
grid is the harder OpenAI first-clean (0.075). Lighthouse @ 0.10 seeds 1-2
|
||||
already ran clean in preship validation.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import modal
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
app = modal.App("chroma-seed-probes")
|
||||
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"
|
||||
_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
||||
_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
|
||||
|
||||
@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")
|
||||
jobs = (
|
||||
("mosque", 0.10),
|
||||
("botanical", 0.10),
|
||||
("openai_faces", 0.075),
|
||||
)
|
||||
out: dict = {}
|
||||
for name, strength in jobs:
|
||||
source = Image.open(io.BytesIO(payload[name])).convert("RGB")
|
||||
width = max(16, (source.width // 16) * 16)
|
||||
height = max(16, (source.height // 16) * 16)
|
||||
for seed in (1, 2):
|
||||
started = time.perf_counter()
|
||||
image = chroma(
|
||||
prompt=_PROMPT,
|
||||
negative_prompt=_NEGATIVE,
|
||||
image=source,
|
||||
width=width,
|
||||
height=height,
|
||||
strength=strength,
|
||||
num_inference_steps=max(1, math.ceil(4 / strength)),
|
||||
guidance_scale=5.0,
|
||||
generator=torch.Generator(device="cpu").manual_seed(seed),
|
||||
).images[0]
|
||||
if image.size != source.size:
|
||||
image = image.resize(source.size, Image.Resampling.LANCZOS)
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
key = f"{name}_s{strength:.4f}_seed{seed}"
|
||||
out[key] = buffer.getvalue()
|
||||
print(f"{key} {time.perf_counter() - started:.1f}s", flush=True)
|
||||
cache.commit()
|
||||
return out
|
||||
|
||||
|
||||
@app.local_entrypoint()
|
||||
def main(out: str = "out/preship-chroma/seed-probes") -> None:
|
||||
sources = {
|
||||
"mosque": _ROOT / "out/cohort-calibration/meta-api/architecture_mosque/original.png",
|
||||
"botanical": _ROOT / "out/cohort-calibration/meta-api/illustration_watercolor_botanical/original.png",
|
||||
"openai_faces": _ROOT / "data/synthid/originals/ChatGPT Image May 30, 2026, 10_31_08 AM.png",
|
||||
}
|
||||
missing = [k for k, p in sources.items() if not p.exists()]
|
||||
if missing:
|
||||
raise SystemExit(f"missing sources: {missing}")
|
||||
results = run.remote({k: p.read_bytes() for k, p in sources.items()})
|
||||
dest = Path(out)
|
||||
dest.mkdir(parents=True, exist_ok=True)
|
||||
for key, png in results.items():
|
||||
(dest / f"{key}.png").write_bytes(png)
|
||||
print(json.dumps(sorted(results), indent=2))
|
||||
@@ -32,7 +32,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.35.1"
|
||||
__version__ = "0.36.0"
|
||||
|
||||
__all__ = [
|
||||
"BatchSummary",
|
||||
|
||||
@@ -21,7 +21,7 @@ img2img pass, so that is the calibrated path.
|
||||
|
||||
# Diffusers and torch expose mostly untyped tensor APIs. Keep the relaxation local
|
||||
# to this optional ML boundary.
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportAttributeAccessIssue=false, reportPrivateUsage=false, reportPrivateImportUsage=false
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportAttributeAccessIssue=false, reportPrivateUsage=false, reportPrivateImportUsage=false, reportOptionalMemberAccess=false
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -31,7 +31,12 @@ from typing import Any, ClassVar
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.two_stage_pipeline import TwoStageZImagePipeline
|
||||
from remove_ai_watermarks._internal.two_stage_pipeline import (
|
||||
TwoStageZImagePipeline,
|
||||
_load_prompt_payload,
|
||||
_prompt_cache_path,
|
||||
_store_prompt_payload,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
@@ -50,6 +55,11 @@ CHROMA_GUIDANCE = 5.0
|
||||
CHROMA_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
||||
CHROMA_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
|
||||
# Prompt-embedding cache key for the Chroma1 T5 encoder. The cache lets a warm
|
||||
# container skip the ~2 s T5 inference on every image, and after the first encode
|
||||
# the text encoder is freed from VRAM (~9.5 GiB of the ~29 GiB peak).
|
||||
_CHROMA_PROMPT_CACHE_KEY = (CHROMA_MODEL_ID, ("chroma1-prompt-embeds-v1",), CHROMA_PROMPT)
|
||||
|
||||
# The latent grid the calibration floors were generated on. The source is floored
|
||||
# to this grid, generated, then resized back to the exact input size -- the same
|
||||
# shape the prototype scripts used.
|
||||
@@ -82,6 +92,8 @@ class ChromaZImagePipeline(TwoStageZImagePipeline):
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
self._chroma_pipe: Any = None
|
||||
self._chroma_embeds: dict[str, Any] | None = None
|
||||
self._chroma_t5_freed: bool = False
|
||||
|
||||
def _load_global(self) -> Any:
|
||||
if self._chroma_pipe is not None:
|
||||
@@ -100,6 +112,45 @@ class ChromaZImagePipeline(TwoStageZImagePipeline):
|
||||
token = {"token": self.hf_token} if self.hf_token else {}
|
||||
pipe = ChromaImg2ImgPipeline.from_pretrained(CHROMA_MODEL_ID, torch_dtype=torch.bfloat16, **token)
|
||||
pipe = pipe.to(self.device)
|
||||
|
||||
# Encode and cache the static prompt once; subsequent containers load
|
||||
# from the cache and skip both the T5 inference and the encoder's VRAM.
|
||||
cache_path = _prompt_cache_path(*_CHROMA_PROMPT_CACHE_KEY)
|
||||
if self.cache_prompt_embeddings and cache_path.exists():
|
||||
self._progress("Loading cached Chroma1 prompt embeddings...")
|
||||
payload = _load_prompt_payload(cache_path, self.device, torch.bfloat16)
|
||||
self._chroma_embeds = payload
|
||||
# Free the T5 encoder: setting to None releases the VRAM reference.
|
||||
pipe.text_encoder = None
|
||||
torch.cuda.empty_cache()
|
||||
self._chroma_t5_freed = True
|
||||
elif self.cache_prompt_embeddings:
|
||||
self._progress("Encoding and caching Chroma1 prompt...")
|
||||
prompt_embeds, _text_ids, prompt_mask, negative_embeds, _neg_ids, negative_mask = pipe.encode_prompt(
|
||||
prompt=CHROMA_PROMPT,
|
||||
negative_prompt=CHROMA_NEGATIVE,
|
||||
device=self.device,
|
||||
do_classifier_free_guidance=True,
|
||||
)
|
||||
p_mask = prompt_mask.cpu() if prompt_mask is not None else torch.zeros(1)
|
||||
n_mask = negative_mask.cpu() if negative_mask is not None else torch.zeros(1)
|
||||
payload = {
|
||||
"prompt_embeds": prompt_embeds.cpu(),
|
||||
"negative_prompt_embeds": negative_embeds.cpu(),
|
||||
"prompt_attention_mask": p_mask,
|
||||
"negative_prompt_attention_mask": n_mask,
|
||||
}
|
||||
_store_prompt_payload(cache_path, payload)
|
||||
loaded = _load_prompt_payload(cache_path, self.device, torch.bfloat16)
|
||||
self._chroma_embeds = loaded
|
||||
# Free the T5 encoder: setting to None releases the VRAM reference.
|
||||
pipe.text_encoder = None
|
||||
torch.cuda.empty_cache()
|
||||
self._chroma_t5_freed = True
|
||||
else:
|
||||
self._chroma_embeds = None
|
||||
self._chroma_t5_freed = False
|
||||
|
||||
self._chroma_pipe = pipe
|
||||
return pipe
|
||||
|
||||
@@ -112,16 +163,28 @@ class ChromaZImagePipeline(TwoStageZImagePipeline):
|
||||
steps = requested_steps(CHROMA_STEPS, strength)
|
||||
self._progress(f"Running Chroma1 pass: strength={strength:.4f}, steps={CHROMA_STEPS} of {steps}...")
|
||||
generator = torch.Generator(device=self.device).manual_seed(seed) if seed is not None else None
|
||||
|
||||
# Pass cached embeddings when available to skip the T5 inference.
|
||||
embeds_kwargs: dict[str, Any] = {}
|
||||
if self._chroma_embeds is not None:
|
||||
embeds_kwargs = {
|
||||
"prompt_embeds": self._chroma_embeds["prompt_embeds"],
|
||||
"negative_prompt_embeds": self._chroma_embeds["negative_prompt_embeds"],
|
||||
"prompt_attention_mask": self._chroma_embeds["prompt_attention_mask"],
|
||||
"negative_prompt_attention_mask": self._chroma_embeds["negative_prompt_attention_mask"],
|
||||
}
|
||||
else:
|
||||
embeds_kwargs = {"prompt": CHROMA_PROMPT, "negative_prompt": CHROMA_NEGATIVE}
|
||||
|
||||
result = pipe(
|
||||
prompt=CHROMA_PROMPT,
|
||||
negative_prompt=CHROMA_NEGATIVE,
|
||||
image=prepared,
|
||||
width=target[0],
|
||||
height=target[1],
|
||||
image=prepared,
|
||||
strength=float(strength),
|
||||
num_inference_steps=steps,
|
||||
guidance_scale=CHROMA_GUIDANCE,
|
||||
generator=generator,
|
||||
**embeds_kwargs,
|
||||
).images[0]
|
||||
if result.size != image.size:
|
||||
result = result.resize(image.size, Image.Resampling.LANCZOS)
|
||||
|
||||
@@ -25,8 +25,9 @@ CONTROLNET_CANNY_MODEL = "xinsir/controlnet-canny-sdxl-1.0"
|
||||
QWEN_ZIMAGE_PROFILE = "qwen-zimage"
|
||||
SDXL_ZIMAGE_PROFILE = "sdxl-zimage"
|
||||
CHROMA_ZIMAGE_PROFILE = "chroma-zimage"
|
||||
AUTO_PROFILE = "auto"
|
||||
DEFAULT_PROFILE = QWEN_ZIMAGE_PROFILE
|
||||
PROFILE_CHOICES = (QWEN_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE, CHROMA_ZIMAGE_PROFILE)
|
||||
PROFILE_CHOICES = (QWEN_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE, CHROMA_ZIMAGE_PROFILE, AUTO_PROFILE)
|
||||
|
||||
# The modules a real removal run needs, and the extra that installs them. Both live
|
||||
# here, in the only profile module that imports nothing heavy, because the CLI's
|
||||
@@ -47,10 +48,11 @@ INVISIBLE_EXTRA = "'remove-ai-watermarks[qwen-zimage]'"
|
||||
PROFILE_ADAPTIVE_POLISH = {
|
||||
QWEN_ZIMAGE_PROFILE: False,
|
||||
SDXL_ZIMAGE_PROFILE: True,
|
||||
# Chroma1's output matches the input's detail level, like qwen-zimage: the
|
||||
# matched-strength calibration measured LPIPS/SSIM parity or better, so
|
||||
# polishing would add grain to an already-faithful output.
|
||||
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.
|
||||
AUTO_PROFILE: False,
|
||||
}
|
||||
|
||||
SDXL_LIGHTNING_MODEL_ID = "ByteDance/SDXL-Lightning"
|
||||
@@ -176,8 +178,24 @@ _ALIASES = {
|
||||
"qwen_zimage": QWEN_ZIMAGE_PROFILE,
|
||||
"sdxl_zimage": SDXL_ZIMAGE_PROFILE,
|
||||
"chroma_zimage": CHROMA_ZIMAGE_PROFILE,
|
||||
"auto": AUTO_PROFILE,
|
||||
}
|
||||
|
||||
# The per-cohort engine router: which profile wins on which vendor, from the
|
||||
# 2026-08-29/30 four-cohort calibration (docs/chroma1-engine-research.md).
|
||||
# chroma-zimage has lower floors AND better matched-strength fidelity on OpenAI
|
||||
# and Microsoft; qwen-zimage wins on Google and Meta. Unknown stays on qwen
|
||||
# (the shipped default, conservative).
|
||||
_ENGINE_BY_VENDOR: dict[str, str] = {
|
||||
"openai": CHROMA_ZIMAGE_PROFILE,
|
||||
"microsoft": CHROMA_ZIMAGE_PROFILE,
|
||||
}
|
||||
|
||||
|
||||
def resolve_auto_profile(vendor: str | None) -> str:
|
||||
"""Pick the engine for --pipeline auto from the provenance vendor."""
|
||||
return _ENGINE_BY_VENDOR.get((vendor or "").casefold(), QWEN_ZIMAGE_PROFILE)
|
||||
|
||||
|
||||
def normalize_profile(profile: str) -> str:
|
||||
"""Normalize spelling and resolve the underscore spellings."""
|
||||
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -18,6 +19,7 @@ from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
REMOVAL_MODULES,
|
||||
SDXL_ZIMAGE_PROFILE,
|
||||
normalize_profile,
|
||||
resolve_auto_profile,
|
||||
resolve_seed,
|
||||
resolve_strength,
|
||||
)
|
||||
@@ -110,6 +112,10 @@ class WatermarkRemover:
|
||||
self.model_profile = normalize_profile(pipeline)
|
||||
if self.model_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
|
||||
# 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
|
||||
@@ -132,11 +138,8 @@ class WatermarkRemover:
|
||||
# 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]
|
||||
elif self.model_profile == CHROMA_ZIMAGE_PROFILE:
|
||||
# Chroma1-HD is calibrated bf16 end to end (the floors were measured
|
||||
# with bf16 weights); the inherited face stage is bf16 regardless.
|
||||
self.torch_dtype = torch.bfloat16 # 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]
|
||||
|
||||
self.cpu_offload = cpu_offload
|
||||
@@ -233,6 +236,14 @@ class WatermarkRemover:
|
||||
with Image.open(image_path) as opened:
|
||||
source = opened.convert("RGB")
|
||||
|
||||
# The auto router 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
|
||||
resolved_strength = self._resolve_chroma_strength(strength, vendor, source)
|
||||
if not 0.0 <= resolved_strength <= 1.0:
|
||||
raise ValueError(f"Strength must be between 0.0 and 1.0, got {resolved_strength}")
|
||||
|
||||
@@ -256,7 +256,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 with lower OpenAI and "
|
||||
"Microsoft floors and higher Google/Meta floors (docs/chroma1-engine-research.md). "
|
||||
"Microsoft floors and higher Google/Meta floors; auto = pick the engine from the "
|
||||
"provenance (chroma-zimage for OpenAI/Microsoft, qwen-zimage for Google/Meta/unknown). "
|
||||
"All are CUDA-ONLY -- install the qwen-zimage extra. There is no CPU or MPS profile for "
|
||||
"invisible-watermark removal."
|
||||
)
|
||||
|
||||
@@ -1086,9 +1086,22 @@ def test_chroma_global_stage_calls_diffusers_with_the_calibrated_shape(monkeypat
|
||||
return type("R", (), {"images": [Image.new("RGB", (kwargs["width"], kwargs["height"]))]})()
|
||||
|
||||
class FakeChromaPipe:
|
||||
text_encoder = object() # truthy: the encoder is loaded
|
||||
|
||||
def to(self, _device):
|
||||
return self
|
||||
|
||||
@staticmethod
|
||||
def encode_prompt(*args, **kwargs):
|
||||
import torch
|
||||
|
||||
n = 8 # sequence length stub
|
||||
embed = torch.zeros(1, n, 64) # prompt embeds stub
|
||||
neg_embed = torch.zeros(1, n, 64)
|
||||
mask = torch.ones(1, n, dtype=torch.long)
|
||||
neg_mask = torch.ones(1, n, dtype=torch.long)
|
||||
return embed, None, mask, neg_embed, None, neg_mask
|
||||
|
||||
@staticmethod
|
||||
def __call__(*args, **kwargs):
|
||||
return fake_chroma(*args, **kwargs)
|
||||
@@ -1113,9 +1126,12 @@ def test_chroma_global_stage_calls_diffusers_with_the_calibrated_shape(monkeypat
|
||||
|
||||
assert result.size == (1122, 1402)
|
||||
call = calls["chroma"]
|
||||
# The calibrated prompt, NOT the shared canny-stage prompt.
|
||||
assert call["prompt"] == "high quality, sharp, detailed, faithful to the original"
|
||||
assert call["negative_prompt"] == "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
# The calibrated prompt is cached on first encode; every subsequent call
|
||||
# passes prompt_embeds (not the raw prompt string), skipping the T5 inference.
|
||||
assert "prompt" not in call, "cached path must not send the raw prompt"
|
||||
assert "prompt_embeds" in call, "cached path must pass pre-computed embeddings"
|
||||
assert "negative_prompt_embeds" in call
|
||||
# The negative prompt is baked into the cached embeddings too.
|
||||
assert call["guidance_scale"] == CHROMA_GUIDANCE
|
||||
# The /16 latent grid, not the raw input size.
|
||||
assert call["width"] == 1120
|
||||
@@ -1235,3 +1251,62 @@ def test_chroma_google_face_content_gets_the_lower_adaptive_floor():
|
||||
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)
|
||||
|
||||
|
||||
def test_auto_profile_routes_to_the_measured_engine_per_vendor():
|
||||
"""--pipeline auto picks the engine the four-cohort calibration supports:
|
||||
chroma-zimage for OpenAI/Microsoft, qwen-zimage for Google/Meta/unknown."""
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
CHROMA_ZIMAGE_PROFILE,
|
||||
QWEN_ZIMAGE_PROFILE,
|
||||
normalize_profile,
|
||||
resolve_auto_profile,
|
||||
)
|
||||
|
||||
assert normalize_profile("auto") == "auto"
|
||||
assert resolve_auto_profile("openai") == CHROMA_ZIMAGE_PROFILE
|
||||
assert resolve_auto_profile("microsoft") == CHROMA_ZIMAGE_PROFILE
|
||||
assert resolve_auto_profile("google") == QWEN_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_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeypatch):
|
||||
"""After auto resolves to chroma-zimage for an OpenAI file, the strength
|
||||
must be chroma's 0.09 floor, not qwen's 0.07675."""
|
||||
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")
|
||||
assert remover.model_profile == "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="openai")
|
||||
|
||||
# Auto resolved to chroma-zimage for the OpenAI vendor.
|
||||
assert remover.model_profile == "chroma-zimage"
|
||||
_, kwargs = runtime.run.call_args
|
||||
assert kwargs["strength"] == pytest.approx(0.09)
|
||||
|
||||
|
||||
def test_auto_profile_text_manifest_stays_on_qwen(tmp_path, monkeypatch):
|
||||
"""Auto routes OpenAI to chroma-zimage, which rejects text manifests;
|
||||
the rejection must reach the caller at the boundary."""
|
||||
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
|
||||
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
|
||||
|
||||
_mock_watermark_runtime_deps(monkeypatch)
|
||||
source = tmp_path / "source.png"
|
||||
Image.new("RGB", (96, 80)).save(source)
|
||||
manifest = VerifiedTextManifest("0" * 64, 96, 80, (VerifiedTextLine((4, 4, 20, 16), "x", "alphabetic"),))
|
||||
|
||||
remover = WatermarkRemover(device="cuda", pipeline="auto")
|
||||
with pytest.raises(ValueError, match="only by the qwen-zimage profile"):
|
||||
remover.remove_watermark(source, vendor="openai", text_manifest=manifest)
|
||||
Reference in new issue
Block a user