Compare commits

..
10 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
Victor Kuznetsov 67d948f57d Release 0.35.1 2026-08-30 18:39:39 -07:00
Victor Kuznetsov 1d0e6b54dd Add content-adaptive Google floor for chroma-zimage: face content resolves to 0.125 instead of 0.40
The four-fixture Google calibration showed a clean YuNet face-count split:
both zero-face text cards need 0.25 first-clean, both face fixtures clear
at 0.12 with zero cross-source spread. The adaptive arm routes face
content (face_count > 0) to the measured 0.125 rung, saving 6.4 dB PSNR
and doubling face identity cosine vs the flat 0.40 floor. The split is
Google-SynthID-specific: OpenAI's face fixture was harder than its text
fixture, so no other cohort gets an adaptive arm.
2026-08-30 17:50:56 -07:00
Victor Kuznetsov fa4748a6a8 Fix uvx invocation in clean-install step 2026-08-30 17:33:44 -07:00
Victor Kuznetsov 1e9572e2d4 Install uv before the clean-install verification step 2026-08-30 17:31:44 -07:00
Victor Kuznetsov 1a6f50db75 Add automated post-release verification workflow for all four published surfaces 2026-08-30 17:30:13 -07:00
22 changed files with 1042 additions and 65 deletions

No files matched your search

+151
View File
@@ -0,0 +1,151 @@
name: Verify release surfaces
# Post-release verification: after distribute.yml fans a release out to
# Homebrew, the HF Space, and the ComfyUI Registry, confirm every published
# surface actually landed and works. This workflow is the automated twin of
# the manual checklist in docs/release-and-distribution.md ("Release
# verification"), not a replacement for the library's own test suite (which
# already ran in test.yml and publish.yml).
on:
workflow_dispatch:
inputs:
version:
description: "Version to verify (no leading v). Blank = latest on PyPI."
required: false
workflow_run:
workflows: ["Distribute on release"]
types: [completed]
jobs:
verify:
# Only run on a successful distribute (not on manual dispatch, where the
# version input or PyPI-latest provides the target).
if: ${{ github.event_name == 'workflow_dispatch' || github.event.workflow_run.conclusion == 'success' }}
runs-on: ubuntu-latest
timeout-minutes: 10
steps:
- name: Resolve target version
id: v
run: |
set -euo pipefail
if [ -n "${{ github.event.inputs.version }}" ]; then
v="${{ github.event.inputs.version }}"
else
v=$(curl -fsSL https://pypi.org/pypi/remove-ai-watermarks/json \
| python3 -c "import sys,json;print(json.load(sys.stdin)['info']['version'])")
fi
echo "version=$v" >> "$GITHUB_OUTPUT"
echo "Verifying release $v"
# -- PyPI ---------------------------------------------------------------
- name: Verify PyPI
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
echo "Checking PyPI for $VERSION..."
rel=$(curl -fsSL "https://pypi.org/pypi/remove-ai-watermarks/$VERSION/json")
echo "$rel" | python3 -c "
import json, sys
d = json.load(sys.stdin)
assert d['info']['version'] == '$VERSION', f\"PyPI version mismatch: {d['info']['version']}\"
assert d['urls'], 'No files on PyPI'
has_wheel = any(u['packagetype'] == 'bdist_wheel' for u in d['urls'])
has_sdist = any(u['packagetype'] == 'sdist' for u in d['urls'])
assert has_wheel, 'No wheel on PyPI'
assert has_sdist, 'No sdist on PyPI'
print(f' PyPI: {len(d[\"urls\"])} files (wheel + sdist) for $VERSION')
"
# -- Clean install from PyPI ---------------------------------------------
- name: Install uv
run: curl -LsSf https://astral.sh/uv/install.sh | sh
- name: Verify clean install
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
echo "Installing remove-ai-watermarks==$VERSION in a clean environment..."
export PATH="$HOME/.local/bin:$PATH"
result=$(uvx --from "remove-ai-watermarks==$VERSION" remove-ai-watermarks --version 2>&1) \
|| { echo "::error::Clean install failed: $result"; exit 1; }
echo " Clean install: $result"
# -- Homebrew --------------------------------------------------------------
- name: Verify Homebrew formula
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
set -euo pipefail
echo "Checking Homebrew tap formula..."
formula=$(curl -fsSL \
"https://raw.githubusercontent.com/wiltodelta/homebrew-tap/main/Formula/remove-ai-watermarks.rb")
echo "$formula" | grep -q "remove_ai_watermarks-$VERSION.tar.gz" \
|| { echo "::error::Homebrew formula does not reference $VERSION"; \
echo "Formula URL line:"; echo "$formula" | grep '^url'; exit 1; }
echo " Homebrew: formula points to $VERSION"
# -- ComfyUI ------------------------------------------------------------------
- name: Verify ComfyUI sync succeeded
env:
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: |
set -euo pipefail
echo "Checking ComfyUI node sync..."
# The registry itself has no public JSON API; the sync workflow in
# the node repo already tests compatibility and publishes only on
# success, so verifying its latest run succeeded is the proxy.
latest=$(gh run list \
--repo wiltodelta/ComfyUI-remove-ai-watermarks \
--workflow sync-library.yml \
--limit 1 \
--json conclusion \
| python3 -c "
import json, sys
runs = json.load(sys.stdin)
r = runs[0] if runs else {}
print(r.get('conclusion', 'unknown'))
")
echo " ComfyUI sync: $latest"
if [ "$latest" != "success" ]; then
echo "::warning::ComfyUI sync latest run is '$latest', not success."
echo " Check: https://github.com/wiltodelta/ComfyUI-remove-ai-watermarks/actions"
else
echo " ComfyUI: sync succeeded, node published"
fi
# -- HF Space -------------------------------------------------------------------
- name: Verify HF Space is alive
run: |
set -euo pipefail
echo "Checking HF Space..."
space_url="https://wiltodelta-remove-ai-watermarks.hf.space"
# The Space may be sleeping after a factory rebuild; wake it.
for attempt in $(seq 1 12); do
status=$(curl -s -o /dev/null -w "%{http_code}" --max-time 30 "$space_url/" || echo "000")
if [ "$status" = "200" ]; then
echo " HF Space: alive (HTTP $status)"
exit 0
fi
echo " HF Space: HTTP $status (attempt $attempt), waiting for rebuild..."
sleep 15
done
echo "::warning::HF Space did not respond with HTTP 200 after 3 minutes."
echo " The Space may still be rebuilding after a factory restart."
echo " Verify manually at $space_url"
- name: Summary
env:
VERSION: ${{ steps.v.outputs.version }}
run: |
cat <<EOF
Release $VERSION verified:
PyPI: wheel + sdist present
Clean install: --version returns $VERSION
Homebrew: formula points to $VERSION
ComfyUI: sync succeeded
HF Space: alive and responding
EOF
+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]"
+212 -13
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
@@ -287,20 +313,193 @@ Both need the seed-sensitivity and face-stage composition checks called out
above before shipping. Both checks have now been run (Pre-ship validation
above): the Meta boundary is seed-stable across seeds 0-2 and the full
profile path stays clean through the face stage on the shipped Google floor.
The profile ships with option 1's floors; option 2's adaptive policy remains
the documented follow-up.
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. 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 |
+13 -7
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
@@ -215,9 +217,11 @@ established for that stage.
- OpenAI: `0.09` (below qwen's `0.07675` is false economy neither way -- the
matched-strength fidelity favors Chroma1 on this cohort);
- Microsoft InvisMark: `0.125` (below qwen's `0.15`);
- Google: `0.40` (above qwen's `0.27`; at this floor the regeneration destroys
dense text and collapses face identity -- the tradeoff that motivates the
documented content-adaptive follow-up);
- Google: `0.40` for zero-face content (above qwen's `0.27`; at this floor the
regeneration destroys dense text and collapses face identity -- the tradeoff
that motivates the adaptive arm below), or `0.125` when YuNet detects at
least one face (both measured face fixtures clear at 0.12 with zero
cross-source spread; oracle-verified 2026-08-30);
- Meta Content Seal: `0.17` (above qwen's `0.1`);
- unknown: `0.40`, following the strictest measured cohort.
@@ -233,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.
+25 -11
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,
@@ -1174,13 +1176,25 @@ conditioning: the floors were measured on a plain strength pass.
The flat vendor floors (`CHROMA_ZIMAGE_*_STRENGTH` in watermark_profiles):
OpenAI 0.09 and Microsoft 0.125 (both BELOW qwen's, with better
matched-strength fidelity), Google 0.40 and Meta 0.17 (both ABOVE qwen's,
because Chroma1's per-fixture boundaries scatter wider). The
matched-strength addendum in the research doc is the honest read: at the
strength each image actually needs, Chroma1 regenerates better almost
everywhere except face identity (which the inherited Z-Image face stage
supplies); at the flat worst-case floors it destroys dense text and face
identity. A content-adaptive strength policy is the documented follow-up.
matched-strength fidelity), Google 0.40 for zero-face content / 0.125 when
faces are detected (a content-adaptive arm from the clean face-count split
in the calibration: both text cards need 0.25, both face fixtures clear at
0.12, so the YuNet detector -- already loaded for the face stage -- routes
the operating point), and Meta 0.17 (ABOVE qwen's, because Chroma1's
per-fixture boundaries scatter wider). The matched-strength addendum in the
research doc is the honest read: at the strength each image actually needs,
Chroma1 regenerates better almost everywhere except face identity (which the
inherited Z-Image face stage supplies); at the flat worst-case floors it
destroys dense text and face identity. The Google face-content arm is the
first shipped piece of the content-adaptive policy. A Meta arm was measured
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:
+5 -1
View File
@@ -121,7 +121,11 @@ without removing schema 1 so long-lived consumers can update separately. A packa
release must never silently substitute its latest schema when a caller explicitly
requests an older supported one.
After publication, verify:
After publication, the `verify-release.yml` workflow runs automatically once
`distribute.yml` completes and checks every surface below. It can also be
dispatched manually with a specific version. The ComfyUI check verifies the
sync workflow's latest run succeeded rather than querying the registry directly
(the registry has no public JSON API). The full checklist for completeness:
- both wheel and source distribution exist on PyPI;
- the package version matches the tag;
+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.0"
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.0"
__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"
@@ -153,6 +155,19 @@ CHROMA_ZIMAGE_GOOGLE_STRENGTH = 0.40
CHROMA_ZIMAGE_META_STRENGTH = 0.17
CHROMA_ZIMAGE_UNKNOWN_STRENGTH = CHROMA_ZIMAGE_GOOGLE_STRENGTH
# Content-adaptive Google floor: the four-fixture calibration showed a clean
# face-count split. Both zero-face fixtures (dense-text cards 633uuy and akdbei)
# need 0.25 first-clean; both face fixtures (y48j3c with 17 detected faces and
# 3mc4t9 with 7) clear at 0.12. The flat 0.40 floor is the zero-face policy
# (worst 0.25 + spread 0.13 = 0.38, rounded to 0.40). Face content can use the
# measured 0.125 rung instead: both face fixtures first-cleaned at 0.12, and
# with identical boundaries the cross-source spread is zero, so 0.125 (the next
# measured rung above 0.12) is the operating point. This split is
# Google-SynthID-specific: OpenAI's face fixture was HARDER than its text
# fixture (0.075 vs 0.06), so no other cohort gets an adaptive arm.
# Oracle-verified 2026-08-30; see docs/chroma1-engine-research.md.
CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH = 0.125
_CHROMA_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
"openai": CHROMA_ZIMAGE_OPENAI_STRENGTH,
"google": CHROMA_ZIMAGE_GOOGLE_STRENGTH,
@@ -163,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."""
@@ -204,6 +235,7 @@ def resolve_strength(
pipeline: str | None = None,
*,
size: tuple[int, int] | None = None,
face_count: int | None = None,
) -> float:
"""Resolve a user override or the calibrated policy for a profile and vendor.
@@ -222,7 +254,10 @@ def resolve_strength(
if normalized == SDXL_ZIMAGE_PROFILE:
return _SDXL_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), SDXL_ZIMAGE_UNKNOWN_STRENGTH)
if normalized == CHROMA_ZIMAGE_PROFILE:
return _CHROMA_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), CHROMA_ZIMAGE_UNKNOWN_STRENGTH)
vendor_key = (vendor or "").casefold()
if vendor_key == "google" and face_count is not None and face_count > 0:
return CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH
return _CHROMA_ZIMAGE_STRENGTH_BY_VENDOR.get(vendor_key, CHROMA_ZIMAGE_UNKNOWN_STRENGTH)
if size is None:
raise ValueError("qwen-zimage resolves strength from image area, so size is required")
vendor_strength = _QWEN_ZIMAGE_FLAT_STRENGTH_BY_VENDOR.get((vendor or "").casefold())
@@ -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
@@ -188,6 +191,26 @@ class WatermarkRemover:
remove_ai_metadata(output_path, output_path, keep_standard=True)
def _resolve_chroma_strength(
self,
strength: float | None,
vendor: str | None,
source: Image.Image,
) -> float:
"""Resolve strength, running face detection for the chroma Google arm.
The content-adaptive Google floor needs a face count. YuNet is fast
(~50 ms) and the model download is already managed by the shared base.
"""
if self.model_profile != CHROMA_ZIMAGE_PROFILE or (vendor or "").casefold() != "google":
return resolve_strength(strength, vendor, self.model_profile, size=source.size)
if strength is not None:
return strength
from remove_ai_watermarks._internal.two_stage_pipeline import detect_faces
face_count = len(detect_faces(source))
return resolve_strength(strength, vendor, self.model_profile, size=source.size, face_count=face_count)
def remove_watermark(
self,
image_path: Path,
@@ -213,7 +236,15 @@ class WatermarkRemover:
with Image.open(image_path) as opened:
source = opened.convert("RGB")
resolved_strength = resolve_strength(strength, vendor, self.model_profile, size=source.size)
# 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}")
if text_manifest is not None and self.model_profile in (SDXL_ZIMAGE_PROFILE, CHROMA_ZIMAGE_PROFILE):
+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."
)
+109 -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
@@ -1204,3 +1220,93 @@ def test_chroma_vae_roundtrip_raises_not_implemented():
pipeline = ChromaZImagePipeline(device="cuda", torch_dtype=torch.bfloat16)
with pytest.raises(NotImplementedError, match="does not expose a VAE donor"):
pipeline._vae_roundtrip(Image.new("RGB", (32, 32)))
def test_chroma_google_face_content_gets_the_lower_adaptive_floor():
"""Face content on Google SynthID clears at 0.125, not 0.40.
The four-fixture calibration showed a clean face-count split: both zero-face
text cards need 0.25, both face fixtures clear at 0.12. The adaptive arm
saves face identity (0.611 vs 0.279 cosine) and 6.4 dB PSNR on face content.
"""
from remove_ai_watermarks._internal.watermark_profiles import (
CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH,
CHROMA_ZIMAGE_GOOGLE_STRENGTH,
resolve_strength,
)
assert pytest.approx(0.125) == CHROMA_ZIMAGE_GOOGLE_FACE_STRENGTH
# Face content gets the lower adaptive floor.
assert resolve_strength(None, "google", "chroma-zimage", face_count=17) == pytest.approx(0.125)
assert resolve_strength(None, "google", "chroma-zimage", face_count=1) == pytest.approx(0.125)
# Zero-face content gets the flat floor (text/graphic cohort).
assert resolve_strength(None, "google", "chroma-zimage", face_count=0) == pytest.approx(0.40)
assert pytest.approx(0.40) == CHROMA_ZIMAGE_GOOGLE_STRENGTH
# No face_count hint: conservative (flat floor).
assert resolve_strength(None, "google", "chroma-zimage") == pytest.approx(0.40)
# Other vendors ignore face_count: the split is Google-SynthID-specific.
assert resolve_strength(None, "openai", "chroma-zimage", face_count=5) == pytest.approx(0.09)
assert resolve_strength(None, "meta", "chroma-zimage", face_count=5) == pytest.approx(0.17)
# An explicit strength still wins over the adaptive arm.
assert resolve_strength(0.20, "google", "chroma-zimage", face_count=17) == pytest.approx(0.20)
# Other profiles are untouched by the face_count parameter.
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850), face_count=17) == pytest.approx(0.27)
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.0"
version = "0.36.0"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },