Compare commits

...
5 Commits
Author SHA1 Message Date
Victor Kuznetsov 5991894ca5 Release 0.36.0 2026-08-31 12:26:42 -07:00
Victor Kuznetsov 5fa14f9e26 Record Meta and OpenAI chroma-zimage expansion, no adaptive arms 2026-08-31 12:15:35 -07:00
Victor Kuznetsov f7e0a18e6c Add --pipeline auto: per-cohort engine router from provenance
Routes OpenAI/Microsoft provenance to chroma-zimage (lower floors, better
matched-strength fidelity) and Google/Meta/unknown to qwen-zimage, from
the 2026-08-29/30 four-cohort calibration. The resolution happens in
remove_watermark after the vendor is known and before the strength
resolution, so each engine's own floors apply.
2026-08-30 20:02:14 -07:00
Victor Kuznetsov bc5922451f Add prompt-embedding cache for Chroma1: skip T5 inference and free the 24B encoder after first encode 2026-08-30 19:50:49 -07:00
Victor Kuznetsov 798c94e6ae Record Google seed-sensitivity finding: 633uuy boundary is seed-dependent 2026-08-30 19:34:31 -07:00
20 changed files with 798 additions and 52 deletions

No files matched your search

+2 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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 |
+8 -4
View File
@@ -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.
+15 -5
View File
@@ -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
View File
@@ -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:
+4 -2
View File
@@ -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
View File
@@ -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
View File
@@ -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"
+160
View File
@@ -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))
+99
View File
@@ -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))
+101
View File
@@ -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))
+1 -1
View File
@@ -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}")
+2 -1
View File
@@ -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."
)
+78 -3
View File
@@ -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)
Generated
+1 -1
View File
@@ -3169,7 +3169,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.35.1"
version = "0.36.0"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },