Add measured auto engine selection

This commit is contained in:
Victor Kuznetsov committed 2026-09-01 10:19:59 -07:00
1 parent 8f30f8bdb3
commit 84ea8ede2e
63 files changed
+1144 -74

No files matched your search

+5
View File
@@ -17,6 +17,8 @@ data/
full-pipeline-quality.csv
Reusable full-pipeline evaluation selection
evaluations/
engine-selection/
Prompt-matched AI content matrix for deterministic auto-engine research
fidelity/ Evaluation instructions and hand-verified ground truth
video-synthid-oracle.csv
Reproducible full-clip Gemini SynthID verdicts
@@ -34,6 +36,9 @@ data/
commands, hashes, and oracle verdicts instead of committing another corpus
copy. A small curated before-and-after example may live in `docs/images/`
when it is part of the public documentation.
`evaluations/engine-selection/` is the bounded exception for ORIGINAL study
inputs: its manifest tracks a prompt-matched OpenAI/Meta content matrix, not
generated model outputs or watermark-oracle evidence.
6. Keep third-party fixture license notices in `licenses/`, outside directories
that tests enumerate as media inputs.
7. Runtime detector assets belong in `src/remove_ai_watermarks/assets/`.
@@ -0,0 +1,61 @@
# Engine-selection fixture set
This directory is the tracked, content-balanced input set for researching a
deterministic `auto` engine choice. It is not a training corpus and it is not a
watermark-oracle corpus.
## Content matrix
`content-manifest.csv` contains 19 prompt-matched pairs. Each pair uses the
same prompt and generation index across OpenAI `gpt-image-1-mini` and Meta
`muse-image-1.0`, covering the content classes that materially stress image
regeneration:
- natural and urban scenes;
- single-person portraits and people in context;
- animals, food, products, interiors, architecture, macro detail, and action;
- flat, isometric, watercolor, and surreal illustration styles.
The files are exact copies of generation outputs collected for the
`docs/arxiv-paper-review` research branch. The manifest records the SHA-256 of
the committed bytes. The old OpenAI collection manifest stored the API payload
hash under `sha256`; `source_payload_sha256` preserves that value while
`sha256` always means the actual file bytes here. Meta's two hashes are the
same.
One generation per prompt and provider is enough for the first paired
discovery pass. Any policy inferred from it must be challenged against unused
generation indices from the local research archive before shipping. Generated
outputs belong outside this directory.
`carrier-manifest.csv` adds the canonical, already tracked signal carriers to
the study without copying their bytes. Together the two manifests cover
ordinary content, text, single faces, face grids, and mixed face/text scenes.
## Signal carriers
The content matrix was re-encoded by its collection pipeline and has no local
provenance signal. It must not be used to set or certify watermark-removal
strengths. The tracked oracle/carrier inputs remain in their canonical homes:
- OpenAI and Google SynthID: `data/synthid/originals/` with
`data/synthid/manifest.csv`;
- Meta Content Seal: `data/contentseal/originals/` with
`data/contentseal/manifest.csv`.
There is no tracked, publication-cleared Microsoft InvisMark carrier yet. The
three Paint sources used by the earlier calibration are user uploads and remain
outside git. A Microsoft row may be added only from a newly generated or
otherwise publication-cleared source, with a pixel-identical metadata-stripped
control that remains positive in Microsoft's detector.
## Integrity check
Run:
```bash
uv run python scripts/verify_engine_selection_fixtures.py
```
The check validates paths, byte hashes, dimensions, prompt pairing, and the
declared provider/content matrix without loading any ML model.
@@ -0,0 +1,13 @@
provider,file,content_stratum,signal,verified_via,sha256,verified_text_geometry,verified_text_ground_truth
openai,"../../synthid/originals/ChatGPT Image May 30, 2026, 10_31_08 AM.png",face_grid,OpenAI SynthID,openai-verify,28ff8732b037f98a4ef5bc277bbcdaa32e5eb9ccbd00b6c8c616e46ef68ae8a0,no,no
openai,"../../synthid/originals/ChatGPT Image May 31, 2026, 02_02_23 PM.png",multilingual_typography,OpenAI SynthID,c2pa-metadata,228e54413a3ef62b313176dcdba774a56a58c36b54227d1440bc26cbe0aefd82,yes,yes
openai,"../../synthid/originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png",multilingual_typography,OpenAI SynthID,openai-verify,05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e,yes,yes
google,../../synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png,cjk_typography_scene,Google SynthID,gemini-app,4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236,yes,yes
google,../../synthid/originals/Gemini_Generated_Image_akdbeiakdbeiakdb.png,cyrillic_typography_scene,Google SynthID,gemini-app,8c1a6fb03ef3d45a1f958fb3401e4264e409ff88c2a793061db7f29023454d0e,no,no
google,../../synthid/originals/Gemini_Generated_Image_y48j3cy48j3cy48j.png,crowd_faces,Google SynthID,gemini-app,45d79a683134fcba1b147b2aedb669783d474e1fb8a4df329729a0904fd1b46b,no,no
google,../../synthid/originals/Gemini_Generated_Image_3mc4t93mc4t93mc4.png,faces_and_typography,Google SynthID,gemini-app,2c33e75a2db614ce74c83cc0a6ac6c3ac735aca83ab88c9c9345843b124f7856,no,no
meta,../../contentseal/originals/gen_fox_forest.webp,photoreal_texture,Meta Content Seal,meta-oracle,6d7cecf322a5ac4b2547e2f32c767b3cb3e8a0e49f274cfd00aa31be6697eed4,no,no
meta,../../contentseal/originals/gen_text_poster.webp,typography,Meta Content Seal,meta-oracle,873927662d8198e9c38b4ae71a2919ba5d417dfcd714649db1b0ae231e8cbf9b,no,no
meta,../../contentseal/originals/gen_lighthouse_watercolor.webp,watercolor,Meta Content Seal,meta-oracle,69bee7c376608568361085602c5c7406900885849bd94c5279c3924cf793b27c,no,no
meta,../../contentseal/originals/gen_night_city.webp,urban_night,Meta Content Seal,meta-oracle,345adf6b9f777b15ef7ce7bbdfb3b355a1d9e882740809e966e9f6caafd94d48,no,no
meta,../../contentseal/originals/gen_studio_mug.webp,product_still_life,Meta Content Seal,meta-oracle,2b7bc7fd56fa2250c66ef74f8b3e49d9666fe147d894d5baf9461b5e36077a07,no,no
1 provider file content_stratum signal verified_via sha256 verified_text_geometry verified_text_ground_truth
2 openai ../../synthid/originals/ChatGPT Image May 30, 2026, 10_31_08 AM.png face_grid OpenAI SynthID openai-verify 28ff8732b037f98a4ef5bc277bbcdaa32e5eb9ccbd00b6c8c616e46ef68ae8a0 no no
3 openai ../../synthid/originals/ChatGPT Image May 31, 2026, 02_02_23 PM.png multilingual_typography OpenAI SynthID c2pa-metadata 228e54413a3ef62b313176dcdba774a56a58c36b54227d1440bc26cbe0aefd82 yes yes
4 openai ../../synthid/originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png multilingual_typography OpenAI SynthID openai-verify 05b836ecfe40fd689177fda74384ae4fdcc446505bbc4281cd3cbb6523eb669e yes yes
5 google ../../synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png cjk_typography_scene Google SynthID gemini-app 4affd7f27767a445db6abf741355743ba8d95108ad922c9fff045feed8492236 yes yes
6 google ../../synthid/originals/Gemini_Generated_Image_akdbeiakdbeiakdb.png cyrillic_typography_scene Google SynthID gemini-app 8c1a6fb03ef3d45a1f958fb3401e4264e409ff88c2a793061db7f29023454d0e no no
7 google ../../synthid/originals/Gemini_Generated_Image_y48j3cy48j3cy48j.png crowd_faces Google SynthID gemini-app 45d79a683134fcba1b147b2aedb669783d474e1fb8a4df329729a0904fd1b46b no no
8 google ../../synthid/originals/Gemini_Generated_Image_3mc4t93mc4t93mc4.png faces_and_typography Google SynthID gemini-app 2c33e75a2db614ce74c83cc0a6ac6c3ac735aca83ab88c9c9345843b124f7856 no no
9 meta ../../contentseal/originals/gen_fox_forest.webp photoreal_texture Meta Content Seal meta-oracle 6d7cecf322a5ac4b2547e2f32c767b3cb3e8a0e49f274cfd00aa31be6697eed4 no no
10 meta ../../contentseal/originals/gen_text_poster.webp typography Meta Content Seal meta-oracle 873927662d8198e9c38b4ae71a2919ba5d417dfcd714649db1b0ae231e8cbf9b no no
11 meta ../../contentseal/originals/gen_lighthouse_watercolor.webp watercolor Meta Content Seal meta-oracle 69bee7c376608568361085602c5c7406900885849bd94c5279c3924cf793b27c no no
12 meta ../../contentseal/originals/gen_night_city.webp urban_night Meta Content Seal meta-oracle 345adf6b9f777b15ef7ce7bbdfb3b355a1d9e882740809e966e9f6caafd94d48 no no
13 meta ../../contentseal/originals/gen_studio_mug.webp product_still_life Meta Content Seal meta-oracle 2b7bc7fd56fa2250c66ef74f8b3e49d9666fe147d894d5baf9461b5e36077a07 no no
@@ -0,0 +1,39 @@
pair_id,provider,model,file,width,height,content_stratum,sha256,source_payload_sha256,prompt
00,openai,gpt-image-1-mini,originals/openai/00.png,1024,1024,natural_landscape,698896455e0f1de57a227339227bf068143e690873b9c8c9672b31578de93ba0,1345f59fc7b51a1edf837745245bb90811feefd9ba7f9ba6f12dc89846aeba18,"a stunning photograph of a mountain lake at sunrise, highly detailed, professional photography"
00,meta,muse-image-1.0,originals/meta/00.png,1600,1600,natural_landscape,83362c189b79cd0e700296b975e9ef8e7f2b2fe89813e407bc795299400ca5e0,83362c189b79cd0e700296b975e9ef8e7f2b2fe89813e407bc795299400ca5e0,"a stunning photograph of a mountain lake at sunrise, highly detailed, professional photography"
01,openai,gpt-image-1-mini,originals/openai/01.png,1024,1024,street_scene,b885d8cb1490e4fbbe580b87750008780671390097d28e71ff18b50cf320ec85,772518e4686940d2c68df114298fb007a97a5c42548245092e989b6ca9bd5b90,"a vibrant street market in Bangkok with colorful fruits and spices, candid photo"
01,meta,muse-image-1.0,originals/meta/01.png,1600,1600,street_scene,5ba08cf9ab07bf00f64f39fe93f1766a64f737817b00086783476566c5f3317f,5ba08cf9ab07bf00f64f39fe93f1766a64f737817b00086783476566c5f3317f,"a vibrant street market in Bangkok with colorful fruits and spices, candid photo"
02,openai,gpt-image-1-mini,originals/openai/02.png,1024,1024,single_portrait,97618d1a5c39ddeba708df3a9448b27d1d26583ee68b490ba79f7f3e1eb2248d,6bace9d2adae4d630e1830449f0945a30d7ddb3635e3005bc7aad80206a57176,"a close-up portrait of a woman with freckles and green eyes, studio lighting, 85mm lens"
02,meta,muse-image-1.0,originals/meta/02.png,1600,1600,single_portrait,4daf07fc8bdfe007b96a70b00ec0cfde87f180eb8c2d5501723e218ed29e7589,4daf07fc8bdfe007b96a70b00ec0cfde87f180eb8c2d5501723e218ed29e7589,"a close-up portrait of a woman with freckles and green eyes, studio lighting, 85mm lens"
03,openai,gpt-image-1-mini,originals/openai/03.png,1024,1024,animal_action,eb6920aab03bbe6935a01756c8f2d524d383e307369cdfaad3fe44926fbd9e48,2012abb15be639ebb3c596d2210f6d4c50cce0e11dd6cfc88aeb29de1c82de18,"a golden retriever running through a field of sunflowers, action photography"
03,meta,muse-image-1.0,originals/meta/03.png,1600,1600,animal_action,93dce3a5049f773adb882ef22243a84e4822b9c43ad30774741c40f3844ef405,93dce3a5049f773adb882ef22243a84e4822b9c43ad30774741c40f3844ef405,"a golden retriever running through a field of sunflowers, action photography"
04,openai,gpt-image-1-mini,originals/openai/04.png,1024,1024,reflective_architecture,275d68de881041de989261ced7570dd460028e0406346642d880d0d1664bb9ae,1808d95c69080aafced4234e4c3ad7714121bad00f3f5dc84a744288d9757245,"a modern architecture building with glass facade reflecting sunset clouds"
04,meta,muse-image-1.0,originals/meta/04.png,1600,1600,reflective_architecture,209c71f0b737a9ef60b4d620c66f518b34dbc42af4532b2b8e0b3086183c90ec,209c71f0b737a9ef60b4d620c66f518b34dbc42af4532b2b8e0b3086183c90ec,"a modern architecture building with glass facade reflecting sunset clouds"
05,openai,gpt-image-1-mini,originals/openai/05.png,1024,1024,food_closeup,316a743ccf29f025904d1bcd047a6996f987715e4f93e8a0d9cf85b624349b07,a8bac088c350ef849bbf78a85a1e44aa1665d1054a0cdfae29461c1cdca56e4a,"a bowl of ramen with steam rising, moody food photography, shallow depth of field"
05,meta,muse-image-1.0,originals/meta/05.png,1600,1600,food_closeup,4a50ca3e33ddacd2897b926cdc54615a389416fcd2edf4d8803ad882b85b33af,4a50ca3e33ddacd2897b926cdc54615a389416fcd2edf4d8803ad882b85b33af,"a bowl of ramen with steam rising, moody food photography, shallow depth of field"
06,openai,gpt-image-1-mini,originals/openai/06.png,1024,1024,sports_action,390d87d1f59eb4fa38590c0c751b2848383c77ffcf549571a0bf00db0f783763,6cc24eeb0fce95a86741b9ac0c055002793a586ba37f0cf4d64201a5c5e73513,"a surfer riding a barrel wave at golden hour, backlit spray"
06,meta,muse-image-1.0,originals/meta/06.png,1600,1600,sports_action,1d9fcbf0f471f733ae96ca94dff892afd9f5852767000e379d3486dc94e30b78,1d9fcbf0f471f733ae96ca94dff892afd9f5852767000e379d3486dc94e30b78,"a surfer riding a barrel wave at golden hour, backlit spray"
07,openai,gpt-image-1-mini,originals/openai/07.png,1024,1024,interior_low_light,4c77c2814216bdcde3417d2d228c5b6f275e33777f13cbea7911241bc9bc376b,9e858b95a4f842c917cbf47afeb4b7e0e24e04c4df0b19da4afe00b7316a13a9,"a cozy reading nook with warm lamp light and rain on the window"
07,meta,muse-image-1.0,originals/meta/07.png,1600,1600,interior_low_light,ecec76c086a7b33beec1b3cb787f62cad68c09cb362a8888a8af734e9d4f3425,ecec76c086a7b33beec1b3cb787f62cad68c09cb362a8888a8af734e9d4f3425,"a cozy reading nook with warm lamp light and rain on the window"
08,openai,gpt-image-1-mini,originals/openai/08.png,1024,1024,isometric_illustration,d688f6da74d4a7b2bd29ef59ba64864d0915134433febd8c90eb5ce1dc80781f,37a801f66b7c1bf63e613a7a5717dbc00397120a7a32ec45414661e03137a180,"an isometric illustration of a cozy coffee shop interior, warm colors, detailed"
08,meta,muse-image-1.0,originals/meta/08.png,1600,1600,isometric_illustration,d4683d16a55c5f358f5b3b876b89fbc810ae1e3332e74399395ae20c9f646ef5,d4683d16a55c5f358f5b3b876b89fbc810ae1e3332e74399395ae20c9f646ef5,"an isometric illustration of a cozy coffee shop interior, warm colors, detailed"
09,openai,gpt-image-1-mini,originals/openai/09.png,1024,1024,watercolor,d0beefb80ee00a0937a851932a2ed6dab2c670f10104ffe94c5ff22869a9595d,b52401235efc16641d3cb8f9c7c6d20a75c36751ada9409b65ceefa0e57901da,"a watercolor painting of Venice canals at sunset, loose brushstrokes"
09,meta,muse-image-1.0,originals/meta/09.png,1600,1600,watercolor,c76ffbca59fbcb2dba9b66c94598f0c0062b0ac4679e348cdf1e18a06d3eb64d,c76ffbca59fbcb2dba9b66c94598f0c0062b0ac4679e348cdf1e18a06d3eb64d,"a watercolor painting of Venice canals at sunset, loose brushstrokes"
10,openai,gpt-image-1-mini,originals/openai/10.png,1024,1024,macro_detail,59d9c6dce5ce4c2b77427144ccbafc8a4b7ab87564673677119859c31598b0d4,3740f9de304f050fc7306447c6668dcaa8156a888709f759f05715e7987a321c,"a macro photograph of a dewdrop on a spider web at dawn, bokeh background"
10,meta,muse-image-1.0,originals/meta/10.png,1600,1600,macro_detail,c86fd9c3a59aa0bec709c5bce7c2ef1be4ecb1d780f3a3f35460b73cd97d1a12,c86fd9c3a59aa0bec709c5bce7c2ef1be4ecb1d780f3a3f35460b73cd97d1a12,"a macro photograph of a dewdrop on a spider web at dawn, bokeh background"
11,openai,gpt-image-1-mini,originals/openai/11.png,1024,1024,urban_night,e4f6f806ed8559973374eae1e4d379807e45046749b7d491edbbc8ad9647728e,06bfc68289ece1b85b169df4cedd5e1cf12d0efa30631bdecd0e3edce4d8c82e,"a cinematic wide shot of Tokyo at night with neon reflections on wet streets"
11,meta,muse-image-1.0,originals/meta/11.png,1600,1600,urban_night,7877cf0c13b07fbc6651178d838db4ab253369896292af1cf3f85a957a559db8,7877cf0c13b07fbc6651178d838db4ab253369896292af1cf3f85a957a559db8,"a cinematic wide shot of Tokyo at night with neon reflections on wet streets"
12,openai,gpt-image-1-mini,originals/openai/12.png,1024,1024,flat_illustration,6bf230aa0b6f40d7ff0d23b22b8aac2e9b66288eb85c02eebdf43b71a1597f5b,65567d5cecb34241ac755ac4db5eb5a7b271d9a00fa27d826c961d2a2ef6ac3d,"a flat-design poster of Santorini with bold shapes, screen print style"
12,meta,muse-image-1.0,originals/meta/12.png,1600,1600,flat_illustration,1b71bd94aa18d737c8a1c319a33dd0f0e9e15c2e93210c9c3aa98242b9a67955,1b71bd94aa18d737c8a1c319a33dd0f0e9e15c2e93210c9c3aa98242b9a67955,"a flat-design poster of Santorini with bold shapes, screen print style"
13,openai,gpt-image-1-mini,originals/openai/13.png,1024,1024,storm_landscape,d07b05eef633fcb5b6b54268cc2cdd787dc19f8050198872e462f69e383a054d,a692a8a0daebad375cd57d7c671be6e16ad3ef700f0b9214daacbe80b6148c90,"a dramatic storm cloud over a lonely lighthouse, moody, high contrast photography"
13,meta,muse-image-1.0,originals/meta/13.png,1600,1600,storm_landscape,e669128fe2d3b59dc83ae0b216c5378fe5930e77fa3a692219da238dbf55d5f6,e669128fe2d3b59dc83ae0b216c5378fe5930e77fa3a692219da238dbf55d5f6,"a dramatic storm cloud over a lonely lighthouse, moody, high contrast photography"
14,openai,gpt-image-1-mini,originals/openai/14.png,1024,1024,monochrome_portrait,9fb1153f4d0041932b35bcd1408d774b642c68f81d7bb403d22f50f8c891ede7,826acf13df78378499e57c7fe1a8309947ab26810f46482be3a64d3328de1ff6,"a black and white portrait of an elderly craftsman in his workshop"
14,meta,muse-image-1.0,originals/meta/14.png,1600,1600,monochrome_portrait,8339475f0585491ea08992d29890f3c759f24db2184212c6bf4f7efc0932e177,8339475f0585491ea08992d29890f3c759f24db2184212c6bf4f7efc0932e177,"a black and white portrait of an elderly craftsman in his workshop"
15,openai,gpt-image-1-mini,originals/openai/15.png,1024,1024,surreal_composite,8bf02f9028fd049ebfafd3410d2eb9d618819be1c14b4e05d53a490e22ba109c,e71de5a9a2f6579857d2e24b8e7a1516031f19ee824906cece158101d13fe001,"a surreal double exposure of a forest and a human profile, conceptual art"
15,meta,muse-image-1.0,originals/meta/15.png,1600,1600,surreal_composite,20628541cc924e623984604337807c7afd453aa8a7652f087add742a13828f6a,20628541cc924e623984604337807c7afd453aa8a7652f087add742a13828f6a,"a surreal double exposure of a forest and a human profile, conceptual art"
16,openai,gpt-image-1-mini,originals/openai/16.png,1024,1024,brutalist_architecture,e91ca5128ce8644510c5c4def6bba085e45e1716105b3b6a2761cc18d42018b3,0aa8a1819f160ce26dfc9e59b996f969698fdf4eba3402fe999af598fcad80d4,"a low-angle shot of a brutalist building against dramatic clouds"
16,meta,muse-image-1.0,originals/meta/16.png,1600,1600,brutalist_architecture,d22702d26343b6575e89751c95d865696c6085bed053f6008f989434a96bac02,d22702d26343b6575e89751c95d865696c6085bed053f6008f989434a96bac02,"a low-angle shot of a brutalist building against dramatic clouds"
17,openai,gpt-image-1-mini,originals/openai/17.png,1024,1024,product_still_life,4f41f4a46b80c0ded61a9a3ce1273412b1728476a0734519d1af34a1797013b0,1f60c25c59138c14bacb0dd485475037e7d6222bb45daa66ed85f9dd91c83e4e,"a minimalist product photo of a ceramic mug on a marble surface, clean commercial look"
17,meta,muse-image-1.0,originals/meta/17.png,1600,1600,product_still_life,67bde86edfd3db08b88c0dbc5572958408875e188dac6fa8ed64f821b7e7e994,67bde86edfd3db08b88c0dbc5572958408875e188dac6fa8ed64f821b7e7e994,"a minimalist product photo of a ceramic mug on a marble surface, clean commercial look"
18,openai,gpt-image-1-mini,originals/openai/18.png,1024,1024,person_in_context,9b049d3112a654a87022d591b93a01087693c0664be5af560d3c0ec490291963,ba873bc7989720245821f5298f12e36bd7b6051909694b72569645c29cdd6bd0,"a lifestyle photo of a person working on a laptop in a bright cafe"
18,meta,muse-image-1.0,originals/meta/18.png,1600,1600,person_in_context,cc4b2bcfc4e80d7dfe6646beb12529168137aef04d2b6765c6382b830ce19ed7,cc4b2bcfc4e80d7dfe6646beb12529168137aef04d2b6765c6382b830ce19ed7,"a lifestyle photo of a person working on a laptop in a bright cafe"
1 pair_id provider model file width height content_stratum sha256 source_payload_sha256 prompt
2 00 openai gpt-image-1-mini originals/openai/00.png 1024 1024 natural_landscape 698896455e0f1de57a227339227bf068143e690873b9c8c9672b31578de93ba0 1345f59fc7b51a1edf837745245bb90811feefd9ba7f9ba6f12dc89846aeba18 a stunning photograph of a mountain lake at sunrise, highly detailed, professional photography
3 00 meta muse-image-1.0 originals/meta/00.png 1600 1600 natural_landscape 83362c189b79cd0e700296b975e9ef8e7f2b2fe89813e407bc795299400ca5e0 83362c189b79cd0e700296b975e9ef8e7f2b2fe89813e407bc795299400ca5e0 a stunning photograph of a mountain lake at sunrise, highly detailed, professional photography
4 01 openai gpt-image-1-mini originals/openai/01.png 1024 1024 street_scene b885d8cb1490e4fbbe580b87750008780671390097d28e71ff18b50cf320ec85 772518e4686940d2c68df114298fb007a97a5c42548245092e989b6ca9bd5b90 a vibrant street market in Bangkok with colorful fruits and spices, candid photo
5 01 meta muse-image-1.0 originals/meta/01.png 1600 1600 street_scene 5ba08cf9ab07bf00f64f39fe93f1766a64f737817b00086783476566c5f3317f 5ba08cf9ab07bf00f64f39fe93f1766a64f737817b00086783476566c5f3317f a vibrant street market in Bangkok with colorful fruits and spices, candid photo
6 02 openai gpt-image-1-mini originals/openai/02.png 1024 1024 single_portrait 97618d1a5c39ddeba708df3a9448b27d1d26583ee68b490ba79f7f3e1eb2248d 6bace9d2adae4d630e1830449f0945a30d7ddb3635e3005bc7aad80206a57176 a close-up portrait of a woman with freckles and green eyes, studio lighting, 85mm lens
7 02 meta muse-image-1.0 originals/meta/02.png 1600 1600 single_portrait 4daf07fc8bdfe007b96a70b00ec0cfde87f180eb8c2d5501723e218ed29e7589 4daf07fc8bdfe007b96a70b00ec0cfde87f180eb8c2d5501723e218ed29e7589 a close-up portrait of a woman with freckles and green eyes, studio lighting, 85mm lens
8 03 openai gpt-image-1-mini originals/openai/03.png 1024 1024 animal_action eb6920aab03bbe6935a01756c8f2d524d383e307369cdfaad3fe44926fbd9e48 2012abb15be639ebb3c596d2210f6d4c50cce0e11dd6cfc88aeb29de1c82de18 a golden retriever running through a field of sunflowers, action photography
9 03 meta muse-image-1.0 originals/meta/03.png 1600 1600 animal_action 93dce3a5049f773adb882ef22243a84e4822b9c43ad30774741c40f3844ef405 93dce3a5049f773adb882ef22243a84e4822b9c43ad30774741c40f3844ef405 a golden retriever running through a field of sunflowers, action photography
10 04 openai gpt-image-1-mini originals/openai/04.png 1024 1024 reflective_architecture 275d68de881041de989261ced7570dd460028e0406346642d880d0d1664bb9ae 1808d95c69080aafced4234e4c3ad7714121bad00f3f5dc84a744288d9757245 a modern architecture building with glass facade reflecting sunset clouds
11 04 meta muse-image-1.0 originals/meta/04.png 1600 1600 reflective_architecture 209c71f0b737a9ef60b4d620c66f518b34dbc42af4532b2b8e0b3086183c90ec 209c71f0b737a9ef60b4d620c66f518b34dbc42af4532b2b8e0b3086183c90ec a modern architecture building with glass facade reflecting sunset clouds
12 05 openai gpt-image-1-mini originals/openai/05.png 1024 1024 food_closeup 316a743ccf29f025904d1bcd047a6996f987715e4f93e8a0d9cf85b624349b07 a8bac088c350ef849bbf78a85a1e44aa1665d1054a0cdfae29461c1cdca56e4a a bowl of ramen with steam rising, moody food photography, shallow depth of field
13 05 meta muse-image-1.0 originals/meta/05.png 1600 1600 food_closeup 4a50ca3e33ddacd2897b926cdc54615a389416fcd2edf4d8803ad882b85b33af 4a50ca3e33ddacd2897b926cdc54615a389416fcd2edf4d8803ad882b85b33af a bowl of ramen with steam rising, moody food photography, shallow depth of field
14 06 openai gpt-image-1-mini originals/openai/06.png 1024 1024 sports_action 390d87d1f59eb4fa38590c0c751b2848383c77ffcf549571a0bf00db0f783763 6cc24eeb0fce95a86741b9ac0c055002793a586ba37f0cf4d64201a5c5e73513 a surfer riding a barrel wave at golden hour, backlit spray
15 06 meta muse-image-1.0 originals/meta/06.png 1600 1600 sports_action 1d9fcbf0f471f733ae96ca94dff892afd9f5852767000e379d3486dc94e30b78 1d9fcbf0f471f733ae96ca94dff892afd9f5852767000e379d3486dc94e30b78 a surfer riding a barrel wave at golden hour, backlit spray
16 07 openai gpt-image-1-mini originals/openai/07.png 1024 1024 interior_low_light 4c77c2814216bdcde3417d2d228c5b6f275e33777f13cbea7911241bc9bc376b 9e858b95a4f842c917cbf47afeb4b7e0e24e04c4df0b19da4afe00b7316a13a9 a cozy reading nook with warm lamp light and rain on the window
17 07 meta muse-image-1.0 originals/meta/07.png 1600 1600 interior_low_light ecec76c086a7b33beec1b3cb787f62cad68c09cb362a8888a8af734e9d4f3425 ecec76c086a7b33beec1b3cb787f62cad68c09cb362a8888a8af734e9d4f3425 a cozy reading nook with warm lamp light and rain on the window
18 08 openai gpt-image-1-mini originals/openai/08.png 1024 1024 isometric_illustration d688f6da74d4a7b2bd29ef59ba64864d0915134433febd8c90eb5ce1dc80781f 37a801f66b7c1bf63e613a7a5717dbc00397120a7a32ec45414661e03137a180 an isometric illustration of a cozy coffee shop interior, warm colors, detailed
19 08 meta muse-image-1.0 originals/meta/08.png 1600 1600 isometric_illustration d4683d16a55c5f358f5b3b876b89fbc810ae1e3332e74399395ae20c9f646ef5 d4683d16a55c5f358f5b3b876b89fbc810ae1e3332e74399395ae20c9f646ef5 an isometric illustration of a cozy coffee shop interior, warm colors, detailed
20 09 openai gpt-image-1-mini originals/openai/09.png 1024 1024 watercolor d0beefb80ee00a0937a851932a2ed6dab2c670f10104ffe94c5ff22869a9595d b52401235efc16641d3cb8f9c7c6d20a75c36751ada9409b65ceefa0e57901da a watercolor painting of Venice canals at sunset, loose brushstrokes
21 09 meta muse-image-1.0 originals/meta/09.png 1600 1600 watercolor c76ffbca59fbcb2dba9b66c94598f0c0062b0ac4679e348cdf1e18a06d3eb64d c76ffbca59fbcb2dba9b66c94598f0c0062b0ac4679e348cdf1e18a06d3eb64d a watercolor painting of Venice canals at sunset, loose brushstrokes
22 10 openai gpt-image-1-mini originals/openai/10.png 1024 1024 macro_detail 59d9c6dce5ce4c2b77427144ccbafc8a4b7ab87564673677119859c31598b0d4 3740f9de304f050fc7306447c6668dcaa8156a888709f759f05715e7987a321c a macro photograph of a dewdrop on a spider web at dawn, bokeh background
23 10 meta muse-image-1.0 originals/meta/10.png 1600 1600 macro_detail c86fd9c3a59aa0bec709c5bce7c2ef1be4ecb1d780f3a3f35460b73cd97d1a12 c86fd9c3a59aa0bec709c5bce7c2ef1be4ecb1d780f3a3f35460b73cd97d1a12 a macro photograph of a dewdrop on a spider web at dawn, bokeh background
24 11 openai gpt-image-1-mini originals/openai/11.png 1024 1024 urban_night e4f6f806ed8559973374eae1e4d379807e45046749b7d491edbbc8ad9647728e 06bfc68289ece1b85b169df4cedd5e1cf12d0efa30631bdecd0e3edce4d8c82e a cinematic wide shot of Tokyo at night with neon reflections on wet streets
25 11 meta muse-image-1.0 originals/meta/11.png 1600 1600 urban_night 7877cf0c13b07fbc6651178d838db4ab253369896292af1cf3f85a957a559db8 7877cf0c13b07fbc6651178d838db4ab253369896292af1cf3f85a957a559db8 a cinematic wide shot of Tokyo at night with neon reflections on wet streets
26 12 openai gpt-image-1-mini originals/openai/12.png 1024 1024 flat_illustration 6bf230aa0b6f40d7ff0d23b22b8aac2e9b66288eb85c02eebdf43b71a1597f5b 65567d5cecb34241ac755ac4db5eb5a7b271d9a00fa27d826c961d2a2ef6ac3d a flat-design poster of Santorini with bold shapes, screen print style
27 12 meta muse-image-1.0 originals/meta/12.png 1600 1600 flat_illustration 1b71bd94aa18d737c8a1c319a33dd0f0e9e15c2e93210c9c3aa98242b9a67955 1b71bd94aa18d737c8a1c319a33dd0f0e9e15c2e93210c9c3aa98242b9a67955 a flat-design poster of Santorini with bold shapes, screen print style
28 13 openai gpt-image-1-mini originals/openai/13.png 1024 1024 storm_landscape d07b05eef633fcb5b6b54268cc2cdd787dc19f8050198872e462f69e383a054d a692a8a0daebad375cd57d7c671be6e16ad3ef700f0b9214daacbe80b6148c90 a dramatic storm cloud over a lonely lighthouse, moody, high contrast photography
29 13 meta muse-image-1.0 originals/meta/13.png 1600 1600 storm_landscape e669128fe2d3b59dc83ae0b216c5378fe5930e77fa3a692219da238dbf55d5f6 e669128fe2d3b59dc83ae0b216c5378fe5930e77fa3a692219da238dbf55d5f6 a dramatic storm cloud over a lonely lighthouse, moody, high contrast photography
30 14 openai gpt-image-1-mini originals/openai/14.png 1024 1024 monochrome_portrait 9fb1153f4d0041932b35bcd1408d774b642c68f81d7bb403d22f50f8c891ede7 826acf13df78378499e57c7fe1a8309947ab26810f46482be3a64d3328de1ff6 a black and white portrait of an elderly craftsman in his workshop
31 14 meta muse-image-1.0 originals/meta/14.png 1600 1600 monochrome_portrait 8339475f0585491ea08992d29890f3c759f24db2184212c6bf4f7efc0932e177 8339475f0585491ea08992d29890f3c759f24db2184212c6bf4f7efc0932e177 a black and white portrait of an elderly craftsman in his workshop
32 15 openai gpt-image-1-mini originals/openai/15.png 1024 1024 surreal_composite 8bf02f9028fd049ebfafd3410d2eb9d618819be1c14b4e05d53a490e22ba109c e71de5a9a2f6579857d2e24b8e7a1516031f19ee824906cece158101d13fe001 a surreal double exposure of a forest and a human profile, conceptual art
33 15 meta muse-image-1.0 originals/meta/15.png 1600 1600 surreal_composite 20628541cc924e623984604337807c7afd453aa8a7652f087add742a13828f6a 20628541cc924e623984604337807c7afd453aa8a7652f087add742a13828f6a a surreal double exposure of a forest and a human profile, conceptual art
34 16 openai gpt-image-1-mini originals/openai/16.png 1024 1024 brutalist_architecture e91ca5128ce8644510c5c4def6bba085e45e1716105b3b6a2761cc18d42018b3 0aa8a1819f160ce26dfc9e59b996f969698fdf4eba3402fe999af598fcad80d4 a low-angle shot of a brutalist building against dramatic clouds
35 16 meta muse-image-1.0 originals/meta/16.png 1600 1600 brutalist_architecture d22702d26343b6575e89751c95d865696c6085bed053f6008f989434a96bac02 d22702d26343b6575e89751c95d865696c6085bed053f6008f989434a96bac02 a low-angle shot of a brutalist building against dramatic clouds
36 17 openai gpt-image-1-mini originals/openai/17.png 1024 1024 product_still_life 4f41f4a46b80c0ded61a9a3ce1273412b1728476a0734519d1af34a1797013b0 1f60c25c59138c14bacb0dd485475037e7d6222bb45daa66ed85f9dd91c83e4e a minimalist product photo of a ceramic mug on a marble surface, clean commercial look
37 17 meta muse-image-1.0 originals/meta/17.png 1600 1600 product_still_life 67bde86edfd3db08b88c0dbc5572958408875e188dac6fa8ed64f821b7e7e994 67bde86edfd3db08b88c0dbc5572958408875e188dac6fa8ed64f821b7e7e994 a minimalist product photo of a ceramic mug on a marble surface, clean commercial look
38 18 openai gpt-image-1-mini originals/openai/18.png 1024 1024 person_in_context 9b049d3112a654a87022d591b93a01087693c0664be5af560d3c0ec490291963 ba873bc7989720245821f5298f12e36bd7b6051909694b72569645c29cdd6bd0 a lifestyle photo of a person working on a laptop in a bright cafe
39 18 meta muse-image-1.0 originals/meta/18.png 1600 1600 person_in_context cc4b2bcfc4e80d7dfe6646beb12529168137aef04d2b6765c6382b830ce19ed7 cc4b2bcfc4e80d7dfe6646beb12529168137aef04d2b6765c6382b830ce19ed7 a lifestyle photo of a person working on a laptop in a bright cafe
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 854 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.5 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

+45
View File
@@ -475,6 +475,51 @@ That is not a YuNet split: `face_count > 0` would over-strengthen the
spread (0.075 - 0.06) is already absorbed by the shipped 0.09 floor.
Images API generations remain non-carriers (C2PA only).
## Content-balanced engine selection check (2026-08-31)
The earlier tables were intentionally carrier-focused and therefore small. A
separate discovery pass tested whether ordinary image content contradicts the
cohort choice at the shipped floors. The tracked matrix contains 19 prompts,
matched across OpenAI `gpt-image-1-mini` and Meta `muse-image-1.0`, spanning
photoreal scenes, faces, action, text-like flat art, products, architecture,
watercolor, and surreal illustration. These files were re-encoded by collection
and are not signal carriers, so this pass measures fidelity only; removal remains
established by the provider-oracle calibration above.
Qwen and Chroma ran in separate sequential H100 invocations, once per source,
with the production provider floor and fixed seed. The comparison isolated the
global stage because both profiles inherit the same source-based Z-Image face
repair. Per-image differences, rather than differences between aggregate means,
were evaluated with exact two-sided sign tests:
| provider | metric | Chroma wins | Qwen wins | two-sided p |
|---|---|---:|---:|---:|
| OpenAI | SSIM | 19 | 0 | 0.0000038 |
| OpenAI | PSNR | 19 | 0 | 0.0000038 |
| OpenAI | MAE | 18 | 1 | 0.000076 |
| OpenAI | edge F1 | 15 | 4 | 0.019 |
| OpenAI | LPIPS | 12 | 7 | 0.359 |
| Meta | LPIPS | 1 | 18 | 0.000076 |
| Meta | edge F1 | 1 | 18 | 0.000076 |
| Meta | SSIM | 5 | 14 | 0.064 |
Meta's remaining pixel metrics disagree: Chroma wins PSNR on 16/19 and MAE on
14/19, while losing both perceptual distance and edge preservation on 18/19 at
its higher removal floor. This is not evidence for a genre classifier. The
direction instead follows the already measured provenance cohort: Chroma for
OpenAI, Qwen for Meta. On OpenAI, the seven LPIPS exceptions include unrelated
classes (landscape, sport, low-light interior, watercolor, monochrome portrait,
surreal art, and brutalism), while Chroma still wins SSIM and PSNR on every one;
there is no coherent input feature that justifies overriding the cohort choice.
Inputs and integrity manifests are under
`data/evaluations/engine-selection/`. Raw generated outputs remain gitignored
under `out/engine-selection-study/`; `scripts/engine_selection_study.py`
reproduces the run and `scripts/analyze_engine_selection_study.py` reproduces
the paired statistics. One generation per prompt is a discovery set, not a
license to fit thresholds. Any future content override must first predict a
rule here and then survive unused generation indices without changing it.
## Oracle session notes
- openai.com/verify throttles anonymous sessions through Cloudflare Turnstile
+11 -8
View File
@@ -468,17 +468,18 @@ user can act on rather than after a model load.
### Restore operator-verified text
`--text-manifest` enables the experimental `vae-glyphs` post-pass. It reconstructs
the source with the Qwen VAE, erases the annotated candidate glyphs with LaMa, and
composites only the reconstructed glyph cores through source-derived silhouettes. It
does not run OCR or choose which strings are correct. The optional `--fidelity-anchor`
described below additionally blends 15% of the reconstruction across the full frame.
the source with the selected profile's VAE, erases the annotated candidate glyphs
with LaMa, and composites only the reconstructed glyph cores through source-derived
silhouettes. It does not run OCR or choose which strings are correct. The optional
Qwen-only `--fidelity-anchor` described below additionally blends 15% of the
reconstruction across the full frame.
Install the combined extra and run only with an operator-verified manifest:
```bash
uv tool install --force "remove-ai-watermarks[text-restoration]"
remove-ai-watermarks invisible image.png -o clean.png \
--pipeline qwen-zimage --text-manifest verified-lines.json --force
--pipeline auto --text-manifest verified-lines.json --force
```
``verified: true`` may also be set by an automated operator that attests
@@ -486,7 +487,8 @@ machine-verified geometry: stability-gated detector boxes inside sane caps. Such
operators should use the geometry-only schema 2, which carries no transcription
or script metadata.
Since 0.27.1 the global 15% Qwen-VAE fidelity-anchor blend is off by default: it
Since 0.27.1 the global 15% Qwen-VAE fidelity-anchor blend is off by default and
remains available only with `qwen-zimage`: it
was measured to return detector-visible OpenAI SynthID on poster-scale manifests
(official Content Provenance API, 2026-08-19). `--fidelity-anchor` restores the
0.27.0 research behavior; text-box fidelity lost by the default is well under one
@@ -503,8 +505,9 @@ container changes remain valid while a resized or edited source fails closed. Th
experimental helper
`remove_ai_watermarks._internal.text_restoration.source_pixel_sha256` computes it.
This mode is supported only by `qwen-zimage` at native geometry with
`humanize=0`, `unsharp=0`, and adaptive polish disabled. `all` also accepts the flag,
This mode is supported by `qwen-zimage`, `chroma-zimage`, and `auto` at native
geometry with `humanize=0`, `unsharp=0`, and adaptive polish disabled. The legacy
`sdxl-zimage` profile does not expose a verified-text VAE donor. `all` also accepts the flag,
but its manifest must match the pixels entering the invisible stage; if visible-mark
removal changes those pixels, the hash check rejects the run. One oracle verdict does
not certify another manifest, seed, model/runtime version, or output hash.
+1 -1
View File
@@ -117,7 +117,7 @@ application actually uses:
| `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, all CUDA-only profiles | `diffusion`, DiffSynth | Yes |
| `text-restoration` | Opt-in verified Qwen-VAE glyph restoration | `qwen-zimage`, `lama` | Yes |
| `text-restoration` | Opt-in verified profile-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 |
| `dev` | Tests, linting, typing, and upstream parity checks | `video`, `detect`, upstream invisible-watermark | Yes, for parity tests |
+53 -6
View File
@@ -976,9 +976,10 @@ is the source of truth for:
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:
rejected rather than aliased onward. `auto` is a deterministic per-cohort
selection policy:
chroma-zimage for OpenAI and Microsoft, qwen-zimage for Google, Meta, and
unknown. It is not a content-class split inside one engine.
unknown. It does not run a learned router or classify the image's genre.
`qwen-zimage` normally resolves global denoise from image area for unknown content.
Measured provider cohorts bypass that curve with flat operating points. The values,
@@ -1136,13 +1137,14 @@ geometry alone in schema 2, plus a SHA-256 over decoded RGB width, height, and p
Validation happens before model loading. The library never treats OCR confidence as
verification, and geometry-only operators do not need to invent text or script fields.
When enabled, `QwenZImagePipeline` reconstructs the source once through its already
loaded Qwen VAE, runs the ordinary global and face stages, and calls the shared
restoration compositor. The optional fidelity anchor first blends 15% of the VAE
When enabled, the one profile selected before model loading reconstructs the source
once through its already loaded VAE, runs the ordinary global and face stages, and
calls the shared restoration compositor. Qwen and Chroma implement this donor hook;
SDXL does not. The optional Qwen-only fidelity anchor first blends 15% of the VAE
reconstruction into the clean result; it is off by default because the blend returned
detector-visible OpenAI SynthID in the measured poster fixtures. The compositor derives
binary source and candidate silhouettes, groups nearby lines, uses LaMa for the initial
and residual-glyph erase passes, paints fresh silhouette edges, then copies the Qwen-VAE
and residual-glyph erase passes, paints fresh silhouette edges, then copies the profile-VAE
core with a 0.5-pixel feather. The evaluation script
imports these same mask and compositing helpers so the two implementations cannot
silently drift. Silhouette crops start 12% of line height beyond each horizontal
@@ -1158,6 +1160,21 @@ geometry or final pixels after the verified layer and have no measured oracle re
It remains opt-in because annotations are manual and provider verdicts apply only to
the exact tested output hashes, not to the mechanism in general.
The first full-path H100 check on 2026-08-31 ran each generative engine exactly
once per source and used that SAME engine's VAE donor; it did not compose Qwen and
Chroma in one production result. On the two verified multilingual OpenAI posters,
restoration reduced PaddleOCR CER from 0.303 to 0.160 and 0.279 to 0.136 for Qwen,
and from 0.213 to 0.130 and 0.204 to 0.108 for Chroma. PSNR improved on all four
arms (Qwen 26.1 to 29.9 dB and 27.7 to 31.6 dB; Chroma 29.6 to 33.1 dB and 29.7
to 32.9 dB). The verified CJK Google fixture is the counterexample that prevents
an unconditional quality claim: Qwen CER stayed 0.074, while Chroma moved from
0.037 to 0.074 despite better LPIPS, SSIM, PSNR, and text-box pixel error. The
stage is therefore implemented and fidelity-positive on both OpenAI sources, but
not assumed to improve every OCR reading. This is a fidelity check, not a provider
oracle certification; exact output hashes and per-box metrics are produced by
`scripts/chroma_text_restoration_study.py` under the gitignored
`out/text-restoration-engine-study/` directory.
A matched stage-isolation check on the 18-face Gemini portrait grid confirms the
division of responsibility. The visible-cleaned, metadata-stripped control and the
Z-Image face-only output were both SynthID-positive; Qwen global-only and the full
@@ -1196,6 +1213,18 @@ strength-controlled img2img in that family; the full research record,
including why FLUX.2 itself is not integrable this way, is
[`chroma1-engine-research.md`](chroma1-engine-research.md).
The profile also implements the shared verified-text donor hook through its already
loaded Chroma VAE. It edge-pads native geometry to the /16 latent grid, takes the
deterministic mode of the encoder distribution rather than sampling it, decodes with
Diffusers' own image processor, and crops back to the exact source dimensions. Thus
`auto` still loads and runs only the profile selected by the measured vendor table;
the presence of a manifest does not introduce a second generative model or override
that decision. A CPU smoke run against the official Chroma1-HD `AutoencoderKL`
weights on 2026-08-31 verified the real encode/decode contract and exact restoration
of a non-grid-aligned source size; model-free tests pin mode-not-sample and padding.
Chroma-specific full-pipeline provider-oracle certification remains an output-hash
measurement, not something either check can establish.
The profile swaps only the global stage (same inheritance invariant as
sdxl-zimage, asserted by the same test shape). Three things are bound to the
calibration and must not drift: the NEUTRAL prompt (`high quality, sharp,
@@ -1227,6 +1256,24 @@ that rung, which is the margin the 0.17 floor already holds. OpenAI stays on the
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).
A separate content-balanced check on 2026-08-31 tested the exact production
OpenAI and Meta floors over 19 prompt-matched image strata per provider. Each
engine ran in its own sequential H100 invocation, and the comparison isolated
the global stage because the shared face repair does not change with this choice.
On OpenAI content, Chroma won SSIM and PSNR on 19/19 pairs, MAE on 18/19, edge
F1 on 15/19, and LPIPS on 12/19. On Meta content, Qwen won LPIPS and edge F1 on
18/19 pairs; Chroma's better PSNR on 16/19 did not overcome its systematically
worse perceptual and edge preservation at the higher required floor. Exact
two-sided sign-test p-values were 3.8e-6 for the OpenAI SSIM and PSNR directions
and 7.6e-5 for the Meta LPIPS and edge-F1 directions. The tracked discovery
inputs and manifests are in
[`data/evaluations/engine-selection/`](../data/evaluations/engine-selection/),
the run is reproduced by `scripts/engine_selection_study.py`, and paired analysis
by `scripts/analyze_engine_selection_study.py`. These content files were locally
re-encoded and are not watermark oracles; they validate fidelity at already
oracle-calibrated floors, not removal by themselves. No content-stratum override
is supported by this pass, so `auto` keeps the measured provenance-cohort table.
### SDXL plus Z-Image
[`_internal/sdxl_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/sdxl_zimage_pipeline.py)
+9 -6
View File
@@ -672,11 +672,13 @@ engine.remove_watermark(
Install `remove-ai-watermarks[text-restoration]`. The manifest schema and safety
constraints are documented in the CLI guide. The engine verifies its decoded RGB
hash before loading the diffusion models and rejects SDXL, downscaling, and
postprocessing combinations that were not evaluated. Tiling is also rejected because
the combined tiled-restoration path has no provider-oracle calibration. `InvisibleOptions`
exposes the same field for `remove_all`; after a visible-stage edit, the manifest must
be built against the staged pixels rather than the pristine source.
hash before loading the diffusion models. Qwen and Chroma reconstruct the donor with
the VAE already loaded for the one profile selected by `auto`; no second generative
profile runs. The engine rejects SDXL, downscaling, and postprocessing combinations
that were not evaluated. Tiling is also rejected because the combined
tiled-restoration path has no provider-oracle calibration. `InvisibleOptions` exposes
the same field for `remove_all`; after a visible-stage edit, the manifest must be built
against the staged pixels rather than the pristine source.
Use manifest schema 1 for manually reviewed text plus script metadata. Automated
operators that verify only text-region geometry should emit schema 2 lines with a
@@ -687,7 +689,8 @@ default** (`fidelity_anchor=False`): that whole-frame blend was measured to
return detector-visible OpenAI SynthID on poster-scale manifests (official
Content Provenance API, 2026-08-19 - detected x6 with the anchor, clean x6
without it, controls and base outputs validated in the same sessions). Pass
`fidelity_anchor=True` to reproduce the 0.27.0 research behavior.
`fidelity_anchor=True` with `qwen-zimage` to reproduce the 0.27.0 research
behavior. Chroma rejects that Qwen-specific reproduction flag.
### Drafting manifest lines
+4
View File
@@ -45,13 +45,17 @@ explicitly names a tracked canonical result.
| `visible_sheets.py` | Build blinded contact sheets for relaxation candidates. |
| `retrain_photo_classify.py` | CPU-retrain the 2026-08-31 photo heads from a sha256-keyed cache pack, no images. |
| `publish_photo_classify_hf.py` | Upload the photo-classify card and freeze weights to Hugging Face `wiltodelta/raiw-models`. Manual; the Action `publish-photo-classify-hf.yml` is the write-token path. |
| `verify_engine_selection_fixtures.py` | Verify hashes, dimensions, and prompt pairing in the tracked auto-engine content matrix. |
## Research and diagnostic prototypes
| Script | Purpose |
| --- | --- |
| `chroma_text_restoration_study.py` | Compare verified-text restoration for Qwen and Chroma on H100 without running both engines in production. |
| `cjk_tail_probe.py` | Test a generic template for otherwise uncovered CJK labels. |
| `controlnet_sweep.py` | Sweep the historical ControlNet removal prototype. |
| `engine_selection_study.py` | Run one global engine at a time over the tracked prompt-matched content matrix. |
| `analyze_engine_selection_study.py` | Reduce engine-study output to paired deltas and exact sign tests. |
| `infer_text_lines.py` | Draft stable source-text lines without modifying pixels. |
| `qwen_scrub_prototype.py` | Probe low-strength Qwen regeneration on a GPU. |
| `selective_text_restoration.py` | Evaluate text restoration over a scrubbed image. |
+121
View File
@@ -0,0 +1,121 @@
#!/usr/bin/env python3
"""Summarize paired fidelity results from the engine-selection study.
Run from the repository root after ``engine_selection_study.py``:
uv run scripts/analyze_engine_selection_study.py
The report uses per-image paired differences and exact two-sided sign tests.
It does not turn the discovery set into a production routing rule.
"""
from __future__ import annotations
import argparse
import json
import logging
import math
import statistics
from collections import defaultdict
from pathlib import Path
from typing import TYPE_CHECKING, Any
if TYPE_CHECKING:
from collections.abc import Callable
log = logging.getLogger(__name__)
_METRICS: dict[str, tuple[Callable[[float], float], str]] = {
"lpips_512": (lambda value: value, "lower"),
"ssim": (lambda value: -value, "higher"),
"psnr_db": (lambda value: -value, "higher"),
"mae": (lambda value: value, "lower"),
"edge_f1": (lambda value: -value, "higher"),
"laplacian_ratio": (lambda value: abs(math.log(max(value, 1e-12))), "closer_to_one"),
}
def _sign_test_two_sided(wins: int, losses: int) -> float:
"""Return the exact two-sided sign-test p-value, excluding ties."""
trials = wins + losses
if trials == 0:
return 1.0
tail = sum(math.comb(trials, index) for index in range(min(wins, losses) + 1))
return min(1.0, 2.0 * tail / (2**trials))
def _index(rows: list[dict[str, Any]]) -> dict[tuple[str, str], dict[str, Any]]:
return {(row["provider"], row["pair_id"]): row for row in rows}
def analyze(report: dict[str, Any]) -> dict[str, Any]:
qwen = _index(report["qwen-zimage"]["rows"])
chroma = _index(report["chroma-zimage"]["rows"])
if qwen.keys() != chroma.keys():
raise ValueError("Qwen and Chroma result keys differ")
by_provider: dict[str, list[tuple[dict[str, Any], dict[str, Any]]]] = defaultdict(list)
for key in sorted(qwen):
by_provider[key[0]].append((qwen[key], chroma[key]))
providers: dict[str, Any] = {}
for provider, pairs in sorted(by_provider.items()):
metrics: dict[str, Any] = {}
for metric, (loss_transform, direction) in _METRICS.items():
deltas: list[float] = []
chroma_wins = 0
qwen_wins = 0
ties = 0
for qwen_row, chroma_row in pairs:
qwen_value = float(qwen_row["fidelity"][metric])
chroma_value = float(chroma_row["fidelity"][metric])
delta = loss_transform(chroma_value) - loss_transform(qwen_value)
deltas.append(delta)
if math.isclose(delta, 0.0, abs_tol=1e-12):
ties += 1
elif delta < 0:
chroma_wins += 1
else:
qwen_wins += 1
metrics[metric] = {
"preferred_direction": direction,
"delta_definition": "chroma_loss_minus_qwen_loss; negative favors Chroma",
"chroma_wins": chroma_wins,
"qwen_wins": qwen_wins,
"ties": ties,
"mean_delta": statistics.fmean(deltas),
"median_delta": statistics.median(deltas),
"sign_test_two_sided_p": _sign_test_two_sided(chroma_wins, qwen_wins),
}
providers[provider] = {"pairs": len(pairs), "metrics": metrics}
return {
"method": "paired per-image differences with exact two-sided sign tests; ties excluded",
"warning": "Discovery-set evidence only; challenge any proposed rule on unused generations.",
"providers": providers,
}
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument(
"--input",
type=Path,
default=Path("out/engine-selection-study/summary.json"),
)
parser.add_argument(
"--output",
type=Path,
default=Path("out/engine-selection-study/analysis.json"),
)
args = parser.parse_args()
report = json.loads(args.input.read_text(encoding="utf-8"))
analysis = analyze(report)
args.output.parent.mkdir(parents=True, exist_ok=True)
args.output.write_text(json.dumps(analysis, indent=2) + "\n", encoding="utf-8")
log.info("Wrote %s", args.output)
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
main()
+232
View File
@@ -0,0 +1,232 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["modal>=1.5"]
# ///
"""Paired offline study of verified-text restoration on Qwen and Chroma.
Each generative profile runs exactly once per fixture. Its already loaded VAE
produces the restoration donor, then the shared production compositor creates
the restored candidate from that one base output. The experiment therefore
measures four outputs (Qwen/Chroma x base/restored) without accidentally timing
or evaluating a second generation for either restoration arm.
Run from the repository root:
uvx modal run scripts/chroma_text_restoration_study.py
Outputs stay under ``out/text-restoration-engine-study``. Submit exact output
hashes to the matching provider oracle before using fidelity metrics to choose
between engines; fidelity only ranks candidates after the scrub verdict passes.
"""
from __future__ import annotations
import hashlib
import json
from pathlib import Path
from typing import Any
import modal
_ROOT = Path(__file__).resolve().parent.parent
_GPU = "H100"
app = modal.App("chroma-text-restoration-study")
image = (
modal.Image.debian_slim(python_version="3.12")
.pip_install(
"torch",
"torchvision>=0.20.0",
"diffusers>=0.38,<1",
"diffsynth>=2.0.17,<3",
"transformers>=4.53",
"accelerate",
"sentencepiece",
"protobuf",
"pillow",
"numpy",
"opencv-python-headless<5",
"onnxruntime>=1.24.0",
"huggingface-hub>=0.20.0",
)
.env({"HF_HOME": "/cache"})
.add_local_dir(_ROOT / "src" / "remove_ai_watermarks", remote_path="/pkg/remove_ai_watermarks")
)
cache = modal.Volume.from_name("chroma-hf-cache", create_if_missing=True)
def _png_bytes(image: Any) -> bytes:
import io
buffer = io.BytesIO()
image.save(buffer, format="PNG")
return buffer.getvalue()
def _metric_inputs(source: Any, boxes: list[tuple[int, int, int, int]]) -> tuple[Any, Any]:
import numpy as np
source_rgb = np.asarray(source.convert("RGB"), dtype=np.uint8)
mask = np.zeros(source_rgb.shape[:2], dtype=bool)
for x1, y1, x2, y2 in boxes:
mask[y1:y2, x1:x2] = True
return source_rgb, mask
def _metrics(source_rgb: Any, candidate: Any, mask: Any) -> dict[str, float]:
import math
import numpy as np
candidate_rgb = np.asarray(candidate.convert("RGB"), dtype=np.uint8)
signed_delta = source_rgb.astype(np.float32) - candidate_rgb
absolute_delta = np.abs(signed_delta)
mse = float((signed_delta**2).mean())
return {
"whole_mae": round(float(absolute_delta.mean()), 6),
"text_box_mae": round(float(absolute_delta[mask].mean()), 6),
"outside_box_mae": round(float(absolute_delta[~mask].mean()), 6),
"psnr_db": round(99.0 if mse <= 1e-12 else 10.0 * math.log10(255.0**2 / mse), 6),
}
@app.function(image=image, gpu=_GPU, volumes={"/cache": cache}, timeout=3600)
def run(payload: dict[str, dict[str, Any]]) -> dict[str, dict[str, Any]]:
import gc
import io
import sys
import time
import torch
from PIL import Image
sys.path.insert(0, "/pkg")
from remove_ai_watermarks._internal.chroma_zimage_pipeline import ChromaZImagePipeline
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, restore_verified_text
from remove_ai_watermarks._internal.two_stage_pipeline import detect_faces
from remove_ai_watermarks._internal.watermark_profiles import resolve_strength
sources: dict[str, tuple[Image.Image, str, tuple[VerifiedTextLine, ...]]] = {}
for name, item in payload.items():
source = Image.open(io.BytesIO(item["source"])).convert("RGB")
lines = tuple(
VerifiedTextLine(
tuple(line["box"]),
line.get("text"),
line.get("script"),
float(line.get("angle", 0.0)),
)
for line in item["lines"]
)
sources[name] = source, str(item["vendor"]), lines
results: dict[str, dict[str, Any]] = {
name: {"vendor": vendor, "engines": {}} for name, (_, vendor, _) in sources.items()
}
metric_inputs = {
name: _metric_inputs(source, [line.box for line in lines]) for name, (source, _vendor, lines) in sources.items()
}
for profile, pipeline_type in (
("qwen-zimage", QwenZImagePipeline),
("chroma-zimage", ChromaZImagePipeline),
):
print(f"[{profile}] loading...", flush=True)
pipeline = pipeline_type(
device="cuda",
torch_dtype=torch.bfloat16,
keep_global_models_on_device=True,
keep_face_models_on_device=True,
progress_callback=lambda message, p=profile: print(f"[{p}] {message}", flush=True),
)
for name, (source, vendor, lines) in sources.items():
face_count = len(detect_faces(source)) if profile == "chroma-zimage" else 0
strength = resolve_strength(
None,
vendor,
profile,
size=source.size,
face_count=face_count,
)
print(f"[{profile}] {name}: strength={strength:.6f}, faces={face_count}", flush=True)
started = time.perf_counter()
donor = pipeline._vae_roundtrip(source)
donor_seconds = time.perf_counter() - started
started = time.perf_counter()
base = pipeline.run(source, strength=strength, seed=0)
generation_seconds = time.perf_counter() - started
started = time.perf_counter()
restored = restore_verified_text(source, base, donor, lines)
restoration_seconds = time.perf_counter() - started
source_rgb, text_mask = metric_inputs[name]
base_png = _png_bytes(base)
restored_png = _png_bytes(restored)
donor_png = _png_bytes(donor)
results[name]["engines"][profile] = {
"strength": strength,
"face_count": face_count,
"donor_seconds": round(donor_seconds, 3),
"generation_seconds": round(generation_seconds, 3),
"restoration_seconds": round(restoration_seconds, 3),
"base_sha256": hashlib.sha256(base_png).hexdigest(),
"restored_sha256": hashlib.sha256(restored_png).hexdigest(),
"donor_sha256": hashlib.sha256(donor_png).hexdigest(),
"base_metrics": _metrics(source_rgb, base, text_mask),
"restored_metrics": _metrics(source_rgb, restored, text_mask),
"base_png": base_png,
"restored_png": restored_png,
"donor_png": donor_png,
}
del pipeline
gc.collect()
torch.cuda.empty_cache()
cache.commit()
return results
@app.local_entrypoint()
def main(out: str = "out/text-restoration-engine-study") -> None:
annotations = json.loads((_ROOT / "data/evaluations/fidelity/text-lines.json").read_text(encoding="utf-8"))
vendors = {
"ChatGPT Image May 31, 2026, 02_02_23 PM.png": "openai",
"ChatGPT Image May 31, 2026, 02_03_55 PM.png": "openai",
"Gemini_Generated_Image_633uuy633uuy633u.png": "google",
}
source_root = _ROOT / "data/synthid/originals"
payload = {
name: {
"source": (source_root / name).read_bytes(),
"vendor": vendor,
"lines": annotations[name],
}
for name, vendor in vendors.items()
}
results = run.remote(payload)
out_root = Path(out)
summary: dict[str, Any] = {"gpu": _GPU, "fixtures": {}}
for name, fixture in results.items():
fixture_dir = out_root / Path(name).stem
fixture_dir.mkdir(parents=True, exist_ok=True)
summary["fixtures"][name] = {"vendor": fixture["vendor"], "engines": {}}
for profile, result in fixture["engines"].items():
for variant in ("base", "restored", "donor"):
(fixture_dir / f"{profile}_{variant}.png").write_bytes(result[f"{variant}_png"])
summary["fixtures"][name]["engines"][profile] = {
key: value for key, value in result.items() if not key.endswith("_png")
}
out_root.mkdir(parents=True, exist_ok=True)
(out_root / "summary.json").write_text(json.dumps(summary, ensure_ascii=False, indent=2), encoding="utf-8")
print(json.dumps(summary, ensure_ascii=False, indent=2))
print(f"outputs in {out_root}")
+221
View File
@@ -0,0 +1,221 @@
# /// script
# requires-python = ">=3.11"
# dependencies = ["modal>=1.5"]
# ///
"""Compare one engine at a time on the tracked content-selection matrix.
This is an offline research harness, not a production router. Each Modal call
loads exactly one global engine and runs all 38 fixed inputs at that engine's
shipped provider floor. The shared Z-Image face repair is deliberately omitted:
the model choice changes only the global stage, while the face stage regenerates
the same original crops for both profiles.
Run from the repository root:
uvx modal run scripts/engine_selection_study.py
The combined metrics land in the gitignored
``out/engine-selection-study/summary.json``.
"""
from __future__ import annotations
import json
import logging
from pathlib import Path
from typing import Any
import modal
log = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_FIXTURE_ROOT = _ROOT / "data" / "evaluations" / "engine-selection"
_GPU = "H100"
app = modal.App("engine-selection-study")
image = (
modal.Image.debian_slim(python_version="3.12")
.pip_install(
"torch",
"torchvision>=0.20.0",
"diffusers>=0.38,<1",
"diffsynth>=2.0.17,<3",
"transformers>=4.53",
"accelerate",
"sentencepiece",
"protobuf",
"pillow",
"numpy",
"opencv-python-headless",
"scikit-image",
"lpips",
)
.env({"HF_HOME": "/cache", "TORCH_HOME": "/cache/torch"})
.add_local_dir(
_ROOT / "src" / "remove_ai_watermarks",
remote_path="/pkg/remove_ai_watermarks",
)
.add_local_dir(_FIXTURE_ROOT, remote_path="/fixtures")
)
cache = modal.Volume.from_name("chroma-hf-cache", create_if_missing=True)
_STRENGTHS = {
"qwen-zimage": {"openai": 0.07675, "meta": 0.10},
"chroma-zimage": {"openai": 0.09, "meta": 0.17},
}
@app.function(image=image, gpu=_GPU, volumes={"/cache": cache}, timeout=3600)
def run_engine(engine: str) -> dict[str, Any]:
import csv
import hashlib
import sys
import time
import cv2
import lpips
import numpy as np
import torch
from PIL import Image
from skimage.metrics import peak_signal_noise_ratio, structural_similarity
sys.path.insert(0, "/pkg")
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
if engine == "qwen-zimage":
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
pipeline_type: Any = QwenZImagePipeline
elif engine == "chroma-zimage":
from remove_ai_watermarks._internal.chroma_zimage_pipeline import ChromaZImagePipeline
pipeline_type = ChromaZImagePipeline
else:
raise ValueError(f"unknown engine: {engine}")
pipeline = pipeline_type(
device="cuda",
torch_dtype=torch.bfloat16,
keep_global_models_on_device=True,
keep_face_models_on_device=False,
)
perceptual = lpips.LPIPS(net="alex", verbose=False).to("cuda").eval()
def rgb_array(pil_image: Image.Image) -> np.ndarray:
return np.asarray(pil_image.convert("RGB"), dtype=np.uint8)
def gray(rgb: np.ndarray) -> np.ndarray:
return cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
def lpips_distance(source: np.ndarray, candidate: np.ndarray) -> float:
height, width = source.shape[:2]
scale = min(1.0, 512.0 / max(height, width))
size = (max(1, round(width * scale)), max(1, round(height * scale)))
def tensor(array: np.ndarray) -> torch.Tensor:
resized = cv2.resize(array, size, interpolation=cv2.INTER_AREA)
normalized = resized.astype(np.float32) / 127.5 - 1.0
return torch.from_numpy(normalized).permute(2, 0, 1).unsqueeze(0).to("cuda")
with torch.inference_mode():
return float(perceptual(tensor(source), tensor(candidate)).item())
def edge_f1(source_edges: np.ndarray, candidate_gray: np.ndarray) -> float:
candidate_edges = cv2.Canny(candidate_gray, 100, 200) > 0
kernel = np.ones((3, 3), np.uint8)
source_near = cv2.dilate(source_edges.astype(np.uint8), kernel) > 0
candidate_near = cv2.dilate(candidate_edges.astype(np.uint8), kernel) > 0
precision = float((candidate_edges & source_near).sum()) / max(1, int(candidate_edges.sum()))
recall = float((source_edges & candidate_near).sum()) / max(1, int(source_edges.sum()))
return 2.0 * precision * recall / max(1e-9, precision + recall)
def source_features(
source: np.ndarray,
source_gray: np.ndarray,
source_edges: np.ndarray,
source_lapvar: float,
) -> dict[str, float]:
histogram = np.bincount(source_gray.reshape(-1), minlength=256).astype(np.float64)
probabilities = histogram[histogram > 0] / histogram.sum()
hsv = cv2.cvtColor(source, cv2.COLOR_RGB2HSV)
return {
"edge_density": round(float(source_edges.mean()), 6),
"laplacian_variance": round(source_lapvar, 6),
"entropy_bits": round(float(-(probabilities * np.log2(probabilities)).sum()), 6),
"mean_saturation": round(float(hsv[..., 1].mean() / 255.0), 6),
}
def fidelity(
source: np.ndarray,
candidate: np.ndarray,
source_gray: np.ndarray,
source_edges: np.ndarray,
source_lapvar: float,
) -> dict[str, float]:
candidate_gray = gray(candidate)
candidate_lapvar = float(cv2.Laplacian(candidate_gray, cv2.CV_64F).var())
return {
"lpips_512": round(lpips_distance(source, candidate), 6),
"ssim": round(float(structural_similarity(source_gray, candidate_gray)), 6),
"psnr_db": round(float(peak_signal_noise_ratio(source, candidate)), 6),
"mae": round(float(np.abs(source.astype(np.float32) - candidate).mean()), 6),
"edge_f1": round(edge_f1(source_edges, candidate_gray), 6),
"laplacian_ratio": round(candidate_lapvar / max(source_lapvar, 1e-9), 6),
}
with Path("/fixtures/content-manifest.csv").open(newline="", encoding="utf-8") as stream:
rows = list(csv.DictReader(stream))
results: list[dict[str, Any]] = []
for index, row in enumerate(rows, start=1):
source_path = Path("/fixtures") / row["file"]
source_image = Image.open(source_path).convert("RGB")
strength = _STRENGTHS[engine][row["provider"]]
started = time.perf_counter()
output = pipeline._run_global(source_image, strength, 0)
elapsed = time.perf_counter() - started
source = rgb_array(source_image)
candidate = rgb_array(output)
source_gray = gray(source)
source_edges = cv2.Canny(source_gray, 100, 200) > 0
source_lapvar = float(cv2.Laplacian(source_gray, cv2.CV_64F).var())
result = {
"pair_id": row["pair_id"],
"provider": row["provider"],
"content_stratum": row["content_stratum"],
"strength": strength,
"seconds": round(elapsed, 3),
"output_rgb_sha256": hashlib.sha256(output.tobytes()).hexdigest(),
"source_features": source_features(source, source_gray, source_edges, source_lapvar),
"fidelity": fidelity(source, candidate, source_gray, source_edges, source_lapvar),
}
results.append(result)
log.info(
"%s %s/%s (%s/%s)",
engine,
index,
len(rows),
row["provider"],
row["content_stratum"],
)
cache.commit()
return {"engine": engine, "gpu": _GPU, "rows": results}
@app.local_entrypoint()
def main(out: str = "out/engine-selection-study") -> None:
qwen = run_engine.remote("qwen-zimage")
chroma = run_engine.remote("chroma-zimage")
report = {
"protocol": "one engine per sequential H100 invocation; global-stage factorial ablation",
"qwen-zimage": qwen,
"chroma-zimage": chroma,
}
destination = Path(out) / "summary.json"
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_text(json.dumps(report, indent=2), encoding="utf-8")
log.info("Wrote %s", destination)
+128
View File
@@ -0,0 +1,128 @@
#!/usr/bin/env python3
"""Verify the tracked content matrix used by the auto-engine study."""
from __future__ import annotations
import csv
import hashlib
import logging
from collections import defaultdict
from pathlib import Path
from PIL import Image
log = logging.getLogger(__name__)
_ROOT = Path(__file__).resolve().parent.parent
_FIXTURE_ROOT = _ROOT / "data" / "evaluations" / "engine-selection"
_MANIFEST = _FIXTURE_ROOT / "content-manifest.csv"
_CARRIER_MANIFEST = _FIXTURE_ROOT / "carrier-manifest.csv"
_EXPECTED_PROVIDERS = {"meta", "openai"}
def _sha256(path: Path) -> str:
digest = hashlib.sha256()
with path.open("rb") as stream:
for chunk in iter(lambda: stream.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def main() -> int:
errors: list[str] = []
pairs: dict[str, list[dict[str, str]]] = defaultdict(list)
paths: set[Path] = set()
hashes: set[str] = set()
with _MANIFEST.open(newline="", encoding="utf-8") as stream:
rows = list(csv.DictReader(stream))
with _CARRIER_MANIFEST.open(newline="", encoding="utf-8") as stream:
carrier_rows = list(csv.DictReader(stream))
for row in rows:
pair_id = row["pair_id"]
relative_path = Path(row["file"])
path = _FIXTURE_ROOT / relative_path
pairs[pair_id].append(row)
if relative_path in paths:
errors.append(f"duplicate path: {relative_path}")
paths.add(relative_path)
expected_hash = row["sha256"]
if expected_hash in hashes:
errors.append(f"duplicate file hash: {expected_hash}")
hashes.add(expected_hash)
if not path.is_file():
errors.append(f"missing file: {relative_path}")
continue
actual_hash = _sha256(path)
if actual_hash != expected_hash:
errors.append(f"hash mismatch for {relative_path}: expected {expected_hash}, got {actual_hash}")
with Image.open(path) as image:
actual_size = image.size
actual_format = image.format
expected_size = (int(row["width"]), int(row["height"]))
if actual_size != expected_size:
errors.append(f"size mismatch for {relative_path}: expected {expected_size}, got {actual_size}")
if actual_format != "PNG":
errors.append(f"format mismatch for {relative_path}: expected PNG, got {actual_format}")
for pair_id, pair_rows in sorted(pairs.items()):
providers = {row["provider"] for row in pair_rows}
prompts = {row["prompt"] for row in pair_rows}
strata = {row["content_stratum"] for row in pair_rows}
if providers != _EXPECTED_PROVIDERS:
errors.append(f"pair {pair_id} providers: expected {_EXPECTED_PROVIDERS}, got {providers}")
if len(prompts) != 1:
errors.append(f"pair {pair_id} has mismatched prompts")
if len(strata) != 1:
errors.append(f"pair {pair_id} has mismatched content strata")
expected_pair_ids = {f"{index:02d}" for index in range(19)}
if set(pairs) != expected_pair_ids:
errors.append(f"pair ids: expected {sorted(expected_pair_ids)}, got {sorted(pairs)}")
committed_files = {
path.relative_to(_FIXTURE_ROOT) for path in (_FIXTURE_ROOT / "originals").glob("**/*") if path.is_file()
}
unlisted = committed_files - paths
if unlisted:
errors.append(f"unlisted files: {sorted(str(path) for path in unlisted)}")
carrier_providers: set[str] = set()
for row in carrier_rows:
relative_path = Path(row["file"])
path = (_FIXTURE_ROOT / relative_path).resolve()
carrier_providers.add(row["provider"])
if not path.is_relative_to(_ROOT / "data"):
errors.append(f"carrier outside data/: {relative_path}")
continue
if not path.is_file():
errors.append(f"missing carrier: {relative_path}")
continue
actual_hash = _sha256(path)
if actual_hash != row["sha256"]:
errors.append(f"carrier hash mismatch for {relative_path}: expected {row['sha256']}, got {actual_hash}")
if carrier_providers != {"google", "meta", "openai"}:
errors.append(f"carrier providers: expected google/meta/openai; got {sorted(carrier_providers)}")
if errors:
for error in errors:
log.error("%s", error)
return 1
log.info(
"Verified %s content files in %s matched pairs and %s canonical carriers",
len(rows),
len(pairs),
len(carrier_rows),
)
return 0
if __name__ == "__main__":
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
raise SystemExit(main())
@@ -36,6 +36,7 @@ from remove_ai_watermarks._internal.two_stage_pipeline import (
_load_prompt_payload,
_prompt_cache_path,
_store_prompt_payload,
edge_pad_to_grid,
)
log = logging.getLogger(__name__)
@@ -189,3 +190,31 @@ class ChromaZImagePipeline(TwoStageZImagePipeline):
if result.size != image.size:
result = result.resize(image.size, Image.Resampling.LANCZOS)
return result.convert("RGB")
def _vae_roundtrip(self, image: Image.Image) -> Image.Image:
"""Reconstruct source pixels through the already loaded Chroma VAE."""
import torch
pipe = self._load_global()
source_width, source_height = image.size
padded = edge_pad_to_grid(image, _LATENT_GRID)
tensor = pipe.image_processor.preprocess(
padded,
height=padded.height,
width=padded.width,
).to(device=self.device, dtype=self.torch_dtype)
with torch.inference_mode():
encoded = pipe.vae.encode(tensor)
if hasattr(encoded, "latent_dist"):
# A restoration donor must be deterministic. Diffusers samples this
# distribution for img2img noise initialization; its mode is the
# faithful VAE reconstruction needed here.
latents = encoded.latent_dist.mode()
elif hasattr(encoded, "latents"):
latents = encoded.latents
else:
raise AttributeError("Chroma VAE encoder output contains no latents")
decoded = pipe.vae.decode(latents, return_dict=False)[0]
reconstructed = pipe.image_processor.postprocess(decoded, output_type="pil")[0]
return reconstructed.crop((0, 0, source_width, source_height)).convert("RGB")
@@ -18,7 +18,6 @@ import os
from dataclasses import dataclass
from typing import Any, ClassVar
import numpy as np
from PIL import Image
from remove_ai_watermarks._internal.two_stage_pipeline import (
@@ -28,6 +27,7 @@ from remove_ai_watermarks._internal.two_stage_pipeline import (
_cache_static_prompt_embeddings,
_resize_to_target,
build_canny_control_image,
edge_pad_to_grid,
)
log = logging.getLogger(__name__)
@@ -236,17 +236,7 @@ class QwenZImagePipeline(TwoStageZImagePipeline):
pipe, _controlnet_input_cls = self._load_global()
source_width, source_height = image.size
pad_width = (-source_width) % 8
pad_height = (-source_height) % 8
padded = image.convert("RGB")
if pad_width or pad_height:
padded = Image.fromarray(
np.pad(
np.asarray(padded),
((0, pad_height), (0, pad_width), (0, 0)),
mode="edge",
)
)
padded = edge_pad_to_grid(image, 8)
pipe.load_models_to_device(["vae"])
tensor = pipe.preprocess_image(padded).to(device=self.device, dtype=self.torch_dtype)
with torch.inference_mode():
@@ -1,4 +1,4 @@
"""Opt-in restoration of verified text from a Qwen VAE reconstruction."""
"""Opt-in restoration of verified text from a profile VAE reconstruction."""
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false
from __future__ import annotations
@@ -151,7 +151,7 @@ def restore_verified_text(
donor: Image.Image,
lines: tuple[VerifiedTextLine, ...],
) -> Image.Image:
"""Erase candidate glyphs, then composite verified Qwen-VAE glyph cores."""
"""Erase candidate glyphs, then composite verified profile-VAE glyph cores."""
from remove_ai_watermarks import region_eraser
if not region_eraser.lama_available():
@@ -162,7 +162,7 @@ def restore_verified_text(
candidate_rgb = np.asarray(candidate.convert("RGB"))
donor_rgb = np.asarray(donor.convert("RGB"))
if source_rgb.shape != candidate_rgb.shape or source_rgb.shape != donor_rgb.shape:
raise ValueError("Source, candidate, and Qwen-VAE donor dimensions must match")
raise ValueError("Source, candidate, and profile-VAE donor dimensions must match")
source_masks = [source_silhouette_mask(source_rgb, line.box, line.angle) for line in lines]
for index, mask in enumerate(source_masks):
@@ -78,6 +78,23 @@ _FACE_PROMPT = ""
_FACE_NEGATIVE = "blurry, ugly, bad quality,"
def edge_pad_to_grid(image: Image.Image, grid: int) -> Image.Image:
"""Pad RGB pixels at the lower/right edges to a latent-grid multiple."""
width, height = image.size
pad_width = (-width) % grid
pad_height = (-height) % grid
rgb = image.convert("RGB")
if not (pad_width or pad_height):
return rgb
return Image.fromarray(
np.pad(
np.asarray(rgb),
((0, pad_height), (0, pad_width), (0, 0)),
mode="edge",
)
)
def resolve_face_model_residency(
requested: bool | None,
*,
@@ -181,7 +181,7 @@ _ALIASES = {
"auto": AUTO_PROFILE,
}
# The per-cohort engine router: which profile wins on which vendor, from the
# The deterministic per-cohort selection policy: which profile wins on which vendor, from the
# 2026-08-29/30 four-cohort calibration (docs/chroma1-engine-research.md).
# chroma-zimage has lower floors AND better matched-strength fidelity on OpenAI
# and Microsoft; qwen-zimage wins on Google and Meta. Unknown stays on qwen
@@ -16,6 +16,7 @@ from remove_ai_watermarks._internal.watermark_profiles import (
DEFAULT_PROFILE,
INVISIBLE_EXTRA,
PROFILE_CHOICES,
QWEN_ZIMAGE_PROFILE,
REMOVAL_MODULES,
SDXL_ZIMAGE_PROFILE,
normalize_profile,
@@ -236,7 +237,7 @@ class WatermarkRemover:
with Image.open(image_path) as opened:
source = opened.convert("RGB")
# The auto router resolves per-image once the provenance vendor is
# The auto policy resolves per-image once the provenance vendor is
# known, BEFORE the strength resolution so the right engine's floors
# are used. If the vendor changed the engine, reset the cached pipeline.
if self._auto:
@@ -244,13 +245,15 @@ class WatermarkRemover:
if resolved != self.model_profile:
self.model_profile = resolved
self._qwen_zimage_pipeline = None
if text_manifest is not None and self.model_profile == SDXL_ZIMAGE_PROFILE:
raise ValueError("Verified text restoration is not supported by the sdxl-zimage profile")
if text_manifest is not None and tile:
raise ValueError("Verified text restoration is not calibrated with tiled diffusion")
if fidelity_anchor and self.model_profile != QWEN_ZIMAGE_PROFILE:
raise ValueError("The fidelity anchor is supported only by the qwen-zimage profile")
resolved_strength = self._resolve_chroma_strength(strength, vendor, source)
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):
raise ValueError("Verified text restoration is supported only by the qwen-zimage profile")
if text_manifest is not None and tile:
raise ValueError("Verified text restoration is not calibrated with tiled diffusion")
result = self._load_qwen_zimage_pipeline().run(
source,
+3 -2
View File
@@ -345,8 +345,9 @@ _text_manifest_option = click.option(
type=click.Path(exists=True, dir_okay=False, path_type=Path),
default=None,
help=(
"Experimental verified-text restoration manifest. Requires qwen-zimage, "
"the text-restoration extra, native untiled geometry, and no postprocessing."
"Experimental verified-text restoration manifest. Requires qwen-zimage or "
"chroma-zimage, the text-restoration extra, native untiled geometry, and no "
"postprocessing."
),
)
+10 -9
View File
@@ -18,8 +18,8 @@ from typing import TYPE_CHECKING
from ._internal.watermark_profiles import (
DEFAULT_PROFILE,
QWEN_ZIMAGE_PROFILE,
REMOVAL_MODULES,
SDXL_ZIMAGE_PROFILE,
resolve_adaptive_polish,
resolve_seed,
)
@@ -186,18 +186,19 @@ class InvisibleEngine:
tile_size: Tile dimension in px (default 1024).
tile_overlap: Overlap between adjacent tiles in px (default 128).
text_manifest: Operator-verified text lines bound to the decoded source
pixels. Enables the experimental Qwen-VAE ``vae-glyphs`` post-pass.
Requires the ``text-restoration`` extra and the ``qwen-zimage``
profile. Incompatible with downscaling, humanize, unsharp, and
adaptive polish. Tiling is rejected because that combination has
no provider-oracle calibration.
pixels. Enables the experimental profile-VAE ``vae-glyphs`` post-pass.
Requires the ``text-restoration`` extra and either ``qwen-zimage``
or ``chroma-zimage``. Incompatible with downscaling, humanize,
unsharp, and adaptive polish. Tiling is rejected because that
combination has no provider-oracle calibration.
fidelity_anchor: Blend 15% of the Qwen-VAE donor across the whole frame
before glyph restoration. OFF by default since 0.27.1: that global
blend was measured to return detector-visible OpenAI SynthID on
poster-scale manifests (detected x6 with the anchor vs clean x6
without it, base clean; official Content Provenance API,
2026-08-19 - docs/text-protection-research.md). ``True`` reproduces
the 0.27.0 research behavior. Requires ``text_manifest``.
the 0.27.0 research behavior. Requires ``text_manifest`` and the
``qwen-zimage`` profile.
Returns:
Path to the cleaned image.
@@ -210,8 +211,8 @@ class InvisibleEngine:
if fidelity_anchor and text_manifest is None:
raise ValueError("fidelity_anchor requires a text manifest")
if text_manifest is not None:
if self._remover.model_profile != QWEN_ZIMAGE_PROFILE:
raise ValueError("--text-manifest is supported only by the qwen-zimage profile")
if self._remover.model_profile == SDXL_ZIMAGE_PROFILE:
raise ValueError("--text-manifest is not supported by the sdxl-zimage profile")
if tile:
raise ValueError("--text-manifest is not calibrated with --tile")
if max_resolution != 0:
+49
View File
@@ -0,0 +1,49 @@
from __future__ import annotations
import importlib.util
from pathlib import Path
from typing import Any
import pytest
SCRIPT = Path(__file__).resolve().parents[1] / "scripts" / "analyze_engine_selection_study.py"
SPEC = importlib.util.spec_from_file_location("analyze_engine_selection_study", SCRIPT)
assert SPEC is not None
assert SPEC.loader is not None
MODULE = importlib.util.module_from_spec(SPEC)
SPEC.loader.exec_module(MODULE)
def _row(*, ssim: float, lpips: float) -> dict[str, Any]:
common = {
"psnr_db": 30.0,
"mae": 2.0,
"edge_f1": 0.8,
"laplacian_ratio": 0.9,
}
return {
"pair_id": "00",
"provider": "openai",
"content_stratum": "test",
"fidelity": {**common, "ssim": ssim, "lpips_512": lpips},
}
def test_exact_sign_test_two_sided() -> None:
assert MODULE._sign_test_two_sided(19, 0) == pytest.approx(2 / 2**19)
assert MODULE._sign_test_two_sided(12, 7) == pytest.approx(0.359283447265625)
assert MODULE._sign_test_two_sided(0, 0) == 1.0
def test_analysis_respects_each_metrics_preferred_direction() -> None:
report = {
"qwen-zimage": {"rows": [_row(ssim=0.8, lpips=0.1)]},
"chroma-zimage": {"rows": [_row(ssim=0.9, lpips=0.2)]},
}
metrics = MODULE.analyze(report)["providers"]["openai"]["metrics"]
assert metrics["ssim"]["chroma_wins"] == 1
assert metrics["ssim"]["qwen_wins"] == 0
assert metrics["lpips_512"]["chroma_wins"] == 0
assert metrics["lpips_512"]["qwen_wins"] == 1
+4 -2
View File
@@ -59,7 +59,7 @@ class TestVerifiedTextMode:
manifest = tmp_path / "manifest.json"
manifest.write_text("{}", encoding="utf-8")
cases = (
("sdxl-zimage", {}, "qwen-zimage"),
("sdxl-zimage", {}, "not supported by the sdxl-zimage profile"),
("qwen-zimage", {"tile": True}, "not calibrated with --tile"),
("qwen-zimage", {"max_resolution": 1024}, "max-resolution 0"),
("qwen-zimage", {"humanize": 1.0}, "humanize=0"),
@@ -113,7 +113,9 @@ class TestVerifiedTextMode:
Image.open(kwargs["image_path"]).save(kwargs["output_path"])
return kwargs["output_path"]
engine = self._engine()
# The public engine must not reject auto before WatermarkRemover resolves
# the measured per-vendor profile.
engine = self._engine("auto")
engine._remover.remove_watermark = fake_remove
monkeypatch.setattr(region_eraser, "lama_available", lambda: True)
+75 -19
View File
@@ -1154,9 +1154,8 @@ def test_chroma_zimage_floors_to_its_own_latent_grid():
assert chroma_target_size(3, 3) == (16, 16)
def test_watermark_remover_rejects_text_manifest_for_chroma(tmp_path, monkeypatch):
"""Verified text restoration needs the Qwen VAE donor; chroma-zimage and
sdxl-zimage must both be rejected at the boundary, not deep inside."""
def test_watermark_remover_accepts_text_manifest_for_chroma_but_not_sdxl(tmp_path, monkeypatch):
"""Chroma owns a VAE donor; SDXL still fails at the public boundary."""
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
@@ -1165,10 +1164,22 @@ def test_watermark_remover_rejects_text_manifest_for_chroma(tmp_path, monkeypatc
Image.new("RGB", (96, 80)).save(source)
manifest = VerifiedTextManifest("0" * 64, 96, 80, (VerifiedTextLine((4, 4, 20, 16), "x", "alphabetic"),))
for profile in ("chroma-zimage", "sdxl-zimage"):
remover = WatermarkRemover(device="cuda", pipeline=profile)
with pytest.raises(ValueError, match="only by the qwen-zimage profile"):
remover.remove_watermark(source, text_manifest=manifest)
chroma = WatermarkRemover(device="cuda", pipeline="chroma-zimage")
chroma_runtime = MagicMock()
chroma_runtime.run.return_value = Image.new("RGB", (96, 80))
monkeypatch.setattr(chroma, "_load_qwen_zimage_pipeline", lambda: chroma_runtime)
chroma.remove_watermark(source, text_manifest=manifest)
assert chroma_runtime.run.call_count == 1
assert chroma_runtime.run.call_args.kwargs["text_manifest"] is manifest
with pytest.raises(ValueError, match=r"fidelity anchor.*qwen-zimage"):
chroma.remove_watermark(source, text_manifest=manifest, fidelity_anchor=True)
assert chroma_runtime.run.call_count == 1
sdxl = WatermarkRemover(device="cuda", pipeline="sdxl-zimage")
with pytest.raises(ValueError, match="not supported by the sdxl-zimage profile"):
sdxl.remove_watermark(source, text_manifest=manifest)
def test_watermark_remover_dispatches_to_chroma_pipeline(monkeypatch):
@@ -1209,17 +1220,56 @@ def test_chroma_adaptive_polish_defaults_off():
assert resolve_adaptive_polish(True, "chroma-zimage") is True
def test_chroma_vae_roundtrip_raises_not_implemented():
"""The base class provides no VAE donor for chroma-zimage; the failure must
be the explicit NotImplementedError, not an opaque crash from a missing
Qwen stack."""
def test_chroma_vae_roundtrip_uses_deterministic_mode_and_restores_native_size(monkeypatch):
"""The Chroma donor pads without distorting geometry and never samples."""
import torch
from remove_ai_watermarks._internal.chroma_zimage_pipeline import ChromaZImagePipeline
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)))
seen: dict[str, object] = {}
latent = torch.ones(1, 4, 4, 6)
class LatentDistribution:
def mode(self):
seen["mode_calls"] = int(seen.get("mode_calls", 0)) + 1
return latent
def sample(self, *_args, **_kwargs):
raise AssertionError("A verified-text donor must not sample VAE latents")
class FakeVae:
@staticmethod
def encode(tensor):
seen["encoded_shape"] = tuple(tensor.shape)
return type("Encoded", (), {"latent_dist": LatentDistribution()})()
@staticmethod
def decode(received, *, return_dict):
assert received is latent
assert return_dict is False
return (torch.zeros(1, 3, 32, 48),)
class FakeProcessor:
@staticmethod
def preprocess(image, *, height, width):
seen["preprocess"] = (image.size, height, width)
return torch.zeros(1, 3, height, width)
@staticmethod
def postprocess(decoded, *, output_type):
assert output_type == "pil"
return [Image.new("RGB", (decoded.shape[3], decoded.shape[2]))]
pipe = type("FakePipe", (), {"vae": FakeVae(), "image_processor": FakeProcessor()})()
pipeline = ChromaZImagePipeline(device="cpu", torch_dtype=torch.float32)
monkeypatch.setattr(pipeline, "_load_global", lambda: pipe)
result = pipeline._vae_roundtrip(Image.new("RGB", (35, 19)))
assert seen["preprocess"] == ((48, 32), 32, 48)
assert seen["encoded_shape"] == (1, 3, 32, 48)
assert seen["mode_calls"] == 1
assert result.size == (35, 19)
def test_chroma_google_face_content_gets_the_lower_adaptive_floor():
@@ -1296,9 +1346,8 @@ def test_auto_profile_strength_uses_the_resolved_engine_floors(tmp_path, monkeyp
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."""
def test_auto_profile_text_manifest_uses_the_measured_chroma_engine_once(tmp_path, monkeypatch):
"""A manifest does not override the researched OpenAI-to-Chroma decision."""
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover
@@ -1308,5 +1357,12 @@ def test_auto_profile_text_manifest_stays_on_qwen(tmp_path, monkeypatch):
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)
runtime = MagicMock()
runtime.run.return_value = Image.new("RGB", (96, 80))
monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime)
remover.remove_watermark(source, vendor="openai", text_manifest=manifest)
assert remover.model_profile == "chroma-zimage"
assert runtime.run.call_count == 1
assert runtime.run.call_args.kwargs["text_manifest"] is manifest