Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b684423fc4 | ||
|
|
16bc9879e9 | ||
|
|
343da7f87d | ||
|
|
6f2d50cde8 | ||
|
|
95f467b4ad | ||
|
|
34505e5cc2 | ||
|
|
01539f5f58 | ||
|
|
23696f42a8 | ||
|
|
08a4a8d299 | ||
|
|
86ce55ced6 | ||
|
|
ee561308ef | ||
|
|
a8d4bce14a | ||
|
|
17408b958e | ||
|
|
834342db07 | ||
|
|
dcc1bf5e07 |
No files matched your search
@@ -25,6 +25,21 @@ Boundary modules for cv2, Torch, and Diffusers may carry narrow per-file relaxat
|
||||
|
||||
From a worktree, `uv run` imports the package from the MAIN checkout -- that is where the editable install points. A script measuring a worktree's edit must insert that worktree's `src` at `sys.path[0]` and assert `module.__file__` resolves inside it, or it silently compares unmodified code against itself.
|
||||
|
||||
## Visible-mark example gallery
|
||||
|
||||
Every registered mark carries a committed example: `data/fixtures/visible/<key>/example.png`
|
||||
(image marks) and `example.mp4` (video marks), regenerated by
|
||||
`scripts/render_visible_examples.py`. `tests/test_visible_examples.py` is the canary: it
|
||||
fails when a mark is registered without an example and when an engine stops detecting its
|
||||
own example. The examples are deterministic synthetic composites of the committed
|
||||
silhouettes. When the generator fails after a geometry or gate change, fix the generator
|
||||
(and the engine) together; do not hand-edit the binaries.
|
||||
|
||||
Visible-mark content must distinguish the registry key, the provider's canonical
|
||||
product name, and the literal text rendered in the mark whenever those differ. Verify
|
||||
external provider names against the provider's official source rather than inferring
|
||||
them from an asset filename or OCR text.
|
||||
|
||||
## Model-adjacent tests
|
||||
|
||||
Do not classify an entire module as untestable because its main path downloads a model. Keep pure behavior covered without downloads, including:
|
||||
|
||||
@@ -4,7 +4,7 @@ name: Distribute on release
|
||||
# PyPI is handled by publish.yml. This workflow event-drives the three channels
|
||||
# that would otherwise be manual:
|
||||
# - Homebrew tap: rewrite the formula's url + sha256 to the new sdist.
|
||||
# - HF Space: factory-rebuild so it reinstalls the latest sdist from PyPI.
|
||||
# - HF Space: factory-rebuild the version already pinned in the Space repo.
|
||||
# - ComfyUI: sync, test, and publish the node against the exact library release.
|
||||
# All wait for the freshly published sdist to appear on PyPI first, since the
|
||||
# Release event fires in parallel with publish.yml's upload.
|
||||
|
||||
@@ -30,6 +30,10 @@ _refs/
|
||||
yolov8n.pt
|
||||
.coverage
|
||||
|
||||
# Local prototype and evaluation outputs (scripts/chroma_scrub_prototype.py and
|
||||
# friends); the durable record of an oracle verdict lives under data/evaluations/
|
||||
out/
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
@@ -2,18 +2,19 @@
|
||||
|
||||
Remove AI provenance marks from images and video you generated yourself:
|
||||
|
||||
- known visible labels such as the Gemini sparkle and vendor text marks;
|
||||
- known visible labels such as the Google Gemini sparkle watermark and vendor text marks;
|
||||
- invisible pixel watermarks through diffusion regeneration;
|
||||
- C2PA, EXIF, XMP, IPTC, and related AI metadata.
|
||||
|
||||
Video support covers provenance identification, complete visible-plus-metadata
|
||||
cleaning, directory batches, visible Sora, Veo, Seedance, Dola, Hailuo, and
|
||||
Kling mark removal, and oracle-certified VAE regeneration for video SynthID
|
||||
cleaning, directory batches, visible Sora, Veo, Seedance, Dola, Hailuo AI, and
|
||||
Kling AI mark removal, and oracle-certified VAE regeneration for video SynthID
|
||||
removal.
|
||||
|
||||
> [raiw.cc](https://raiw.cc) runs this library as a hosted service, with the GPU
|
||||
> included and nothing to install. Visible mark and metadata removal are free
|
||||
> there; invisible watermark removal is paid.
|
||||
> included and nothing to install. Visible mark and metadata removal at Standard
|
||||
> output up to 12 MP are free there; original resolution above 12 MP and invisible
|
||||
> watermark removal are paid.
|
||||
|
||||
[](https://pypi.org/project/remove-ai-watermarks/)
|
||||
[](https://pypi.org/project/remove-ai-watermarks/)
|
||||
@@ -168,8 +169,8 @@ the specificity order shown below. Pass an explicit mark to restrict detection
|
||||
to one provider.
|
||||
Sora covers the moving Sora 2 mascot and wordmark. Veo covers both the current
|
||||
four-point diamond and the legacy `Veo` text. Seedance covers the fixed boxed
|
||||
`AI` label, Dola covers the fixed `Dola AI` text, Hailuo covers the composite
|
||||
`MINIMAX | hailuo AI` label, and Kling covers the bottom-right `KLING AI`
|
||||
`AI` label, Dola covers the fixed `Dola AI` text, Hailuo AI covers the composite
|
||||
`MINIMAX | hailuo AI` label, and Kling AI covers the bottom-right `KLING AI`
|
||||
label with its version suffix. A completed encode is published atomically. No
|
||||
output is written when no stable mark is found.
|
||||
HDR, PQ/HLG, and greater-than-8-bit inputs are rejected before encoding rather
|
||||
@@ -191,7 +192,7 @@ remains useful for unusually important files or after provider changes, but it
|
||||
is not a product result state.
|
||||
|
||||
For invisible watermark removal, install the `qwen-zimage` extra. **An NVIDIA GPU
|
||||
is required**: both profiles are CUDA-only, and there is no CPU or MPS fallback.
|
||||
is required**: all profiles are CUDA-only, and there is no CPU or MPS fallback.
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
@@ -230,12 +231,21 @@ features, and development setup.
|
||||
| --- | --- |
|
||||
|  |  |
|
||||
|
||||
The `after` raster is generated from the tracked `before` raster by the public path:
|
||||
|
||||
```bash
|
||||
uv run remove-ai-watermarks visible demo_banana_before.png \
|
||||
--backend cv2 -o demo_banana_after.png
|
||||
```
|
||||
|
||||
### High quality invisible removal
|
||||
|
||||
`qwen-zimage` is the default profile: a Qwen-Image-2512 Lightning pass under Canny
|
||||
ControlNet, followed by SAM-masked Z-Image repair of any detected face. The
|
||||
alternative, `sdxl-zimage`, swaps the global stage for SDXL and keeps the same face
|
||||
stage. Both are CUDA only.
|
||||
stage. A third profile, `chroma-zimage`, uses the Apache-2.0 Chroma1 global pass
|
||||
with its own flat vendor floors; see `docs/chroma1-engine-research.md` for the
|
||||
calibration. All are CUDA only.
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
@@ -311,8 +321,10 @@ remove-ai-watermarks batch ./images --mode all
|
||||
|
||||
Visible mark support includes:
|
||||
|
||||
- Google Gemini and Nano Banana sparkle;
|
||||
- Doubao, Jimeng, Qwen, Kling, Yuanbao, Baidu, LibLibAI, and RunningHub labels;
|
||||
- Google Gemini and Nano Banana visible sparkle watermark;
|
||||
- Doubao, the Jimeng wordmark and top-left `AI生成` pill, Qwen, Kling AI,
|
||||
Yuanbao, Baidu, LiblibAI, and RunningHub labels;
|
||||
- one calibrated Microsoft top-right white AI-badge variant;
|
||||
- one calibrated Samsung Galaxy AI label variant.
|
||||
|
||||
Metadata and provenance inspection covers C2PA, EXIF, XMP, IPTC, common
|
||||
@@ -321,7 +333,9 @@ signals. Optional decoders add support for open DWT-DCT watermarks and Adobe
|
||||
TrustMark.
|
||||
|
||||
The exact support matrix, including important locale and detector limits, lives
|
||||
in [supported signals](docs/supported-signals.md).
|
||||
in [supported signals](docs/supported-signals.md). The
|
||||
[synthetic example gallery](data/fixtures/visible/README.md) carries one canonical
|
||||
sample for every registered image and video mark.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -396,7 +410,7 @@ invisible removal.
|
||||
detail.
|
||||
- Visible video removal recognizes the moving Sora 2 wordmark, the current Veo
|
||||
diamond plus legacy `Veo` text, the Seedance boxed `AI` label, and the fixed
|
||||
Dola, Hailuo, and Kling labels. It does not recognize the older Sora Turbo
|
||||
Dola, Hailuo AI, and Kling AI labels. It does not recognize the older Sora Turbo
|
||||
corner swirl or unregistered layouts from those providers.
|
||||
The classical OpenCV backend can smear structured backgrounds; use MI-GAN or
|
||||
LaMa when recovery quality matters.
|
||||
@@ -404,7 +418,7 @@ invisible removal.
|
||||
The shipped profile is oracle-certified, but no public local decoder can
|
||||
certify an arbitrary output at runtime. Recheck unusually important outputs
|
||||
after provider changes.
|
||||
- Invisible-watermark removal requires CUDA. Both profiles refuse any other
|
||||
- Invisible-watermark removal requires CUDA. All profiles refuse any other
|
||||
device at construction rather than falling back to one that cannot run them.
|
||||
Visible removal, metadata stripping and `identify` still run anywhere.
|
||||
- Provider watermark systems can change. Validate important outputs with the
|
||||
|
||||
@@ -7,6 +7,8 @@ data/
|
||||
fixtures/
|
||||
provenance/ Real format and provenance fixtures used by tests
|
||||
(source records live in fixtures/README.md)
|
||||
visible/ Synthetic per-mark example gallery (one committed example per
|
||||
registered visible mark; see fixtures/visible/README.md)
|
||||
calibration/
|
||||
<vendor>/ Minimal controlled inputs needed to rebuild detector assets
|
||||
synthid/
|
||||
@@ -48,16 +50,17 @@ runtime assets.
|
||||
video removal profile works, so it is also the source of truth for three shipped
|
||||
defaults: `tests/test_video_invisible.py` asserts that `noise_std`, `long_side`,
|
||||
and `fps` together match a row this manifest records as certified. Changing one
|
||||
of those three without adding the row that certifies it fails the suite. `vae` is
|
||||
deliberately outside that check because neither tracked row records one; add it
|
||||
to the assertion in the same commit as the first row that does.
|
||||
of those three without adding the row that certifies it fails the suite. Both
|
||||
historical rows mark `vae` as `unrecorded`: the repository must not infer a model
|
||||
identity from the current default. Add it to the assertion in the same commit as
|
||||
the first oracle row that records and verifies one.
|
||||
|
||||
| Column | Meaning |
|
||||
| --- | --- |
|
||||
| `date`, `source_url`, `source_sha256` | Identify the carrier. |
|
||||
| `source_width`, `source_height`, `source_fps` | Carrier geometry. Without it the actual downscale factor of a row cannot be recovered later. |
|
||||
| `duration_seconds`, `source_verdict` | Clip length submitted and the verifier's reading of the untouched carrier. |
|
||||
| `vae`, `noise_std`, `long_side`, `fps`, `seed` | The full run configuration. |
|
||||
| `vae`, `noise_std`, `long_side`, `fps`, `seed` | The run configuration. `unrecorded` means the historical run did not preserve the VAE identity. |
|
||||
| `output_sha256` | Identifies the exact submitted file. |
|
||||
| `output_verdict` | One of `detected`, `not_detected`, `indeterminate`, `refused`. |
|
||||
| `output_verdict_text` | The verifier's wording, verbatim. |
|
||||
|
||||
@@ -16,9 +16,12 @@ plus hash and is not stored.
|
||||
embedded in the watermark payload. Both survived a 512 px LANCZOS resize and
|
||||
a full-size JPEG q85 re-encode (same ID returned), so the payload is more
|
||||
robust than the detection threshold.
|
||||
- Center crops lose the seal: 50% and 33% linear center crops of two different
|
||||
images all returned "No AI signatures from Meta were found", consistent with
|
||||
the Reuters 2026-07-11 analysis (55% missed after cropping).
|
||||
- Three checked center crops lost the seal: 50% and 33% linear crops of the fox
|
||||
and the 50% crop of the text poster returned "No AI signatures from Meta were
|
||||
found". The text poster's 33% crop was not checked because the daily oracle
|
||||
limit was reached, so its empty verdict is not evidence either way. The checked
|
||||
results are consistent with the Reuters 2026-07-11 analysis (55% missed after
|
||||
cropping).
|
||||
- API outputs carry XMP `iptcExt:DigitalSourceType =
|
||||
trainedAlgorithmicMedia`, so local `identify` flags them via the existing
|
||||
Made-with-AI path. Metadata-stripping transforms fall back to unknown, and
|
||||
@@ -62,8 +65,8 @@ the calibration rows below is the race-free variant.
|
||||
|
||||
The library resolves strength per vendor with measured floors (OpenAI
|
||||
0.07675 / Google 0.27 / Microsoft InvisMark 0.15 in
|
||||
`_internal/watermark_profiles.py`). Meta Content Seal has no floor yet; the
|
||||
goal of these rows is to measure one by that same methodology: independent
|
||||
`_internal/watermark_profiles.py`). Meta Content Seal had no floor before this
|
||||
calibration; these rows measure one by the same methodology: independent
|
||||
generations, each one's first-clean boundary, floor = worst boundary plus the
|
||||
observed cross-source spread.
|
||||
|
||||
@@ -83,7 +86,16 @@ Measured (2026-08-26/27, oracle `meta.ai/identification`):
|
||||
names it explicitly on stripped files (implying the scrub runs).
|
||||
## Regeneration
|
||||
|
||||
API key is not stored in this repository. Regenerate with the script pattern
|
||||
from the session (env `MUSE_API_KEY`, endpoint
|
||||
`https://api.meta.ai/v1/images/generations`, model `muse-image-1.0`); prompts
|
||||
are recorded per file in `manifest.csv`.
|
||||
The eight deterministic crop, resize, and JPEG variants can be reproduced and
|
||||
hash-checked from the tracked originals:
|
||||
|
||||
```bash
|
||||
uv run python scripts/contentseal_transforms.py /tmp/contentseal-derived
|
||||
```
|
||||
|
||||
The Meta API generations and remote GPU outputs are not reproducible from this
|
||||
repository alone. Their prompts, exact output hashes, model/profile settings,
|
||||
and oracle results are recorded in `manifest.csv`, but the generation API is
|
||||
stochastic and the private worker environment is not tracked. `MUSE_API_KEY` and
|
||||
the anonymous detector session are deliberately absent. A new calibration must
|
||||
therefore create new manifest rows rather than claiming to recreate these bytes.
|
||||
@@ -1,4 +1,4 @@
|
||||
date,source_commit,fixture,variant,model,model_revision,model_sha256,ocr_route,text_cer,img_lpips,ssim,psnr_db,output_sha256,provider_oracle,notes
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage,,,,"Paddle ch",0.074,0.289,0.696,23.3,4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621,not_run,Exact Qwen baseline reused from text-restoration benchmark
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage,,,,"Paddle ch",0.074,0.289,0.696,23.3,4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621a6,not_run,Exact Qwen baseline reused from text-restoration benchmark
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage-anytext2-default,AnyText2-SD1.5,b06c583a583818f3679665ef67b51363f107853c,7d5d593928d842500e0da101ab6a1ae28a8d21403441ca62c813c4f92529ace8,"Paddle ch",0.185,0.338,0.642,18.2,87729fe7e0482d2cead6d3b7c7dd386ecc75ab3d8658131c54e67528b8f9feeb,not_run,Fidelity gate failed; padded crop recognition CER 0.074 still shows two substituted characters; locally composited three-line edit; 20 DDIM steps; seed 0
|
||||
2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage-anytext2-mimic,AnyText2-SD1.5,b06c583a583818f3679665ef67b51363f107853c,7d5d593928d842500e0da101ab6a1ae28a8d21403441ca62c813c4f92529ace8,"Paddle ch",0.222,0.345,0.644,17.9,f5198d8f2365b79880aba8ea6b0c3885142b9de21c9f7f4f6712ac4a0db8dd85,not_run,Fidelity gate failed; source glyph masks supplied as font hints; 20 DDIM steps; seed 0
|
||||
|
@@ -1,3 +1,3 @@
|
||||
date,source_url,source_sha256,source_width,source_height,source_fps,duration_seconds,source_verdict,vae,noise_std,long_side,fps,seed,output_sha256,output_verdict,output_verdict_text,output_detected_range,track,session_id,stratum,psnr_db,temporal_residual_ratio
|
||||
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,1920,1080,24,8,detected_all_frames,,0.10,512,12,0,079165105d4c56e1612091987c08c2627049423025f74c0d4e245fb47c2ff0e3,detected,,,,,,26.2932,1.0072
|
||||
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,1920,1080,24,8,detected_all_frames,,0.15,512,12,0,1c4046bcfdead138353b4e2a73339ba227bb5e544878d80c5bc6cd8427c7b00e,not_detected,,,,,,25.3911,1.0578
|
||||
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,1920,1080,24,8,detected_all_frames,unrecorded,0.10,512,12,0,079165105d4c56e1612091987c08c2627049423025f74c0d4e245fb47c2ff0e3,detected,,,,,,26.2932,1.0072
|
||||
2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,1920,1080,24,8,detected_all_frames,unrecorded,0.15,512,12,0,1c4046bcfdead138353b4e2a73339ba227bb5e544878d80c5bc6cd8427c7b00e,not_detected,,,,,,25.3911,1.0578
|
||||
|
@@ -13,3 +13,23 @@ example from [`adobe/trustmark`](https://github.com/adobe/trustmark) at commit
|
||||
- SHA-256: `e58c5825ed7e5d9fb04710ea541b61bd55879cad65554c0d46260aa24b3d0755`
|
||||
- Expected signal: TrustMark Variant P, schema 1
|
||||
- License: MIT, reproduced in `../licenses/adobe-trustmark-MIT.txt`
|
||||
|
||||
|
||||
## Ideogram C2PA attribution
|
||||
|
||||
`ideogram-c2pa.png` is a SYNTHETIC signed manifest, not a vendor download: a
|
||||
64x64 flat PNG carrying a C2PA claim (`trainedAlgorithmicMedia`,
|
||||
`claim_generator "Ideogram/3.0"`) signed by a throwaway test CA whose leaf
|
||||
certificate subject is `O=Ideogram, Inc, CN=Ideogram, Inc`. It exercises the
|
||||
issuer-to-platform ATTRIBUTION path end to end through the real reader; the
|
||||
signer is deliberately untrusted (no trust anchors ship), which is also the
|
||||
fixture's second assertion: attribution and signer trust are separate
|
||||
dimensions.
|
||||
|
||||
- Expected signal: C2PA, platform `Ideogram`, `ai_from_metadata` True
|
||||
- Expected signer: `untrusted` (test CA; attribution must not imply trust)
|
||||
- Recipe (no keys are committed): create a CA and an ES256 leaf with
|
||||
`digitalSignature` key usage and subject `CN=Ideogram, Inc`, then
|
||||
`c2pa.Builder.from_json(...).sign_file(png, out, Signer.from_info(
|
||||
C2paSignerInfo(b"es256", leaf_pem + ca_pem, pkcs8_key, None)))`.
|
||||
- No user upload and no Ideogram artifact contributes pixels or bytes.
|
||||
|
After Width: | Height: | Size: 43 KiB |
@@ -0,0 +1,41 @@
|
||||
# Visible-mark example gallery
|
||||
|
||||
One committed example per registered visible mark, so the repository carries a
|
||||
working sample of everything it supports. `tests/test_visible_examples.py` holds
|
||||
both sides to it: a mark registered without an example fails the suite, and so
|
||||
does an engine that stops detecting its own example.
|
||||
|
||||
## What these files are
|
||||
|
||||
Every example is SYNTHETIC: `scripts/render_visible_examples.py` composites the
|
||||
mark's committed silhouette (the same font-rendered asset the detector matches)
|
||||
onto a deterministic generated base photo at the engine's measured geometry.
|
||||
The silhouettes are repository-owned font renders generated by
|
||||
`scripts/render_vendor_silhouettes.py`; the gallery does not copy vendor raster
|
||||
assets.
|
||||
|
||||
The examples demonstrate DETECTION geometry and house style, not vendor raster
|
||||
fidelity; real-world variants (fonts, opacities, sizes) are covered by the
|
||||
engines' recorded calibration evidence.
|
||||
|
||||
## Regeneration
|
||||
|
||||
uv run python scripts/render_visible_examples.py
|
||||
|
||||
The generator self-verifies: it fails (exit 1) if any registered mark does not
|
||||
detect on its own example, so regeneration is the fix point for drift.
|
||||
|
||||
## Layout
|
||||
|
||||
<mark-key>/example.png 1536x2048..2048x2048 PNG, one per image mark
|
||||
<mark-key>/example.mp4 960x540 90-frame clip, one per video mark
|
||||
(kling carries both: it is registered in both registries)
|
||||
|
||||
Special cases: `gemini` composites the sparkle alpha map at the provider's
|
||||
configured position; `jimeng_pill` is the capture-less pill at the measured
|
||||
3:4 portrait geometry; `microsoft` is the opaque white pill with dark text
|
||||
holes (the discriminator its detector keys on). Video examples composite the
|
||||
detector's own synthetic template on every frame; where two marks share a
|
||||
shape family the example carries the discriminative variant (`veo` the legacy
|
||||
text form, `kling` the logo-plus-wordmark pair flush to the edge), because the
|
||||
temporal selection resolves cross-template ties by table order.
|
||||
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 2.6 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
|
After Width: | Height: | Size: 3.5 MiB |
|
After Width: | Height: | Size: 2.0 MiB |
@@ -160,7 +160,7 @@ sparkle; the SynthID *pixel* itself has no local detector (oracle only).
|
||||
|
||||
Key takeaways:
|
||||
- The same model differs by *surface*: Gemini app wraps C2PA, AI Studio (API/playground) emits none -- only the pixel + sparkle survive.
|
||||
- Microsoft Designer's DALL-E backend inherits OpenAI's C2PA+SynthID (issuer "OpenAI, Microsoft"); Bing now runs Microsoft's own **MAI-Image** and signs C2PA as "Microsoft" (not OpenAI/DALL-E).
|
||||
- Microsoft Designer's DALL·E backend inherits OpenAI's C2PA+SynthID (issuer "OpenAI, Microsoft"); Bing now runs Microsoft's own **MAI-Image** and signs C2PA as "Microsoft" (not OpenAI/DALL·E).
|
||||
- Meta uses the IPTC `digitalSourceType` marker, not C2PA or SynthID.
|
||||
- The open imwatermark fires only on *pristine* output from a pipeline that runs the encoder (diffusers default, official BFL) -- not from re-hosts (Krea, Stability hosted SDXL) or re-encoded design exports (Recraft, Canva). Ideogram's only signal is the EXIF `Make` tag.
|
||||
- Bing and Grok web UIs are uncooperative for autonomous capture (no document_idle for screenshots; blob downloads intermittently no-op; low-res in-chat previews). Use their native download button manually if a full-res sample is needed.
|
||||
|
Before Width: | Height: | Size: 3.6 MiB After Width: | Height: | Size: 5.8 MiB |
@@ -0,0 +1,306 @@
|
||||
# Chroma1 engine research (2026-08-29/30)
|
||||
|
||||
> Research archive. This page records experiments and decisions from the dates
|
||||
> above. Current package behavior is defined by the
|
||||
> [supported signals](supported-signals.md), [known limitations](known-limitations.md),
|
||||
> and [module internals](module-internals.md). Nothing here is shipped.
|
||||
|
||||
Cited experiment behind issue #88 ("Please integrate Flux 2"): FLUX.2 has no
|
||||
native strength img2img anywhere (BFL reference code or diffusers; its image
|
||||
inputs are reference conditioning and outputs start from pure noise), so the
|
||||
requestable scrub lane in the FLUX family is **Chroma1** -- the FLUX.1
|
||||
architecture re-trained on open data, Apache-2.0 on weights, with a native
|
||||
`ChromaImg2ImgPipeline` strength parameter. This page records what was measured
|
||||
before any integration decision.
|
||||
|
||||
## Setup
|
||||
|
||||
All Chroma1 runs: `lodestones/Chroma1-HD` bf16 on Modal H100, seed 0, the
|
||||
neutral scrub prompt (`high quality, sharp, detailed, faithful to the original`
|
||||
/ `blurry, lowres, distorted text, garbled text, artifacts`), guidance 5.0, and
|
||||
**step compensation**: Diffusers truncates the step COUNT (`int(steps *
|
||||
strength)`), so the requested count is scaled to always spend 4 effective
|
||||
denoising steps, the same semantics as `sdxl_zimage_pipeline.requested_steps`.
|
||||
Without it, strength 0.05 runs a single step and the floor reads artificially
|
||||
high (the first, uncompensated sweep put the OpenAI boundary at (0.05, 0.10];
|
||||
compensated it is (0.05, 0.06]).
|
||||
|
||||
Two Flux-family traps found while wiring the prototype:
|
||||
|
||||
- without explicit `width`/`height`, the img2img pipeline silently generates
|
||||
1024x1024 regardless of input aspect;
|
||||
- dimensions floor to the /16 latent-patch grid, so the result must be resized
|
||||
back to the input size for pixel-exact comparison.
|
||||
|
||||
Scripts: `scripts/chroma_scrub_prototype.py` (first sweep),
|
||||
`scripts/engine_quality_price_probe.py` (head-to-head with the production
|
||||
qwen-zimage path), `scripts/engine_cohort_calibration.py` (the cohort ladders
|
||||
below), `scripts/chroma_meta_high_rungs.py` (the Meta rungs above 0.08).
|
||||
Generated outputs stay outside the repository (`out/`, gitignored); the durable
|
||||
verdicts are the tables on this page.
|
||||
|
||||
## Measured floors
|
||||
|
||||
Oracle per cohort: OpenAI via openai.com/verify (SynthID read separately from
|
||||
C2PA); Meta via the anonymous meta.ai/identification oracle. Each fixture's
|
||||
rungs were checked ascending and stopped at the first clean verdict.
|
||||
|
||||
| cohort | fixture | detected at | clean from |
|
||||
|---|---|---|---|
|
||||
| OpenAI | typography card 1122x1402 | 0.05 | 0.06 |
|
||||
| OpenAI | 9-face grid 1448x1086 | 0.06 | 0.075 |
|
||||
| OpenAI | full-pipeline quality fixture 1122x1402 | 0.05 | 0.06 |
|
||||
| Meta | gen_lighthouse_watercolor 1600x1600 | 0.08 | 0.10 |
|
||||
| Meta | gen_fox_forest 1600x1600 | 0.06 | 0.08 |
|
||||
| Meta | gen_night_city 1920x1280 | 0.045 | 0.06 |
|
||||
| Meta | gen_studio_mug 1600x1600 | 0.03 | 0.045 |
|
||||
| Meta | gen_text_poster 1600x1600 | -- | <=0.03 |
|
||||
|
||||
Derived operating points by the shipped rule (worst first-clean boundary plus
|
||||
one observed cross-source spread):
|
||||
|
||||
- **OpenAI: 0.09** (0.075 + (0.075 - 0.06)). Against qwen-zimage 0.07675 and
|
||||
sdxl-zimage 0.15. The typography boundary (0.05, 0.06] is seed-stable across
|
||||
seeds 0, 1, 2.
|
||||
- **Meta: 0.17** (0.10 + (0.10 - 0.03)), oracle-verified clean on the worst
|
||||
fixture at 0.17. Against qwen-zimage 0.1. Chroma needs ~1.7x qwen's strength
|
||||
on Content Seal.
|
||||
- **Google: 0.40, measured 2026-08-30 across all four fixtures.** A
|
||||
metadata-stripped original verified as pixel-DETECTED through the @synthid
|
||||
Verify AI agent (the C2PA-carrying original answers about C2PA instead,
|
||||
which is not a pixel verdict).
|
||||
|
||||
| fixture | detected at | clean from |
|
||||
|---|---|---|
|
||||
| Gemini_Generated_Image_633uuy | 0.20 | 0.25 |
|
||||
| Gemini_Generated_Image_akdbei | 0.20 | 0.25 |
|
||||
| Gemini_Generated_Image_y48j3c | 0.08 | 0.12 |
|
||||
| Gemini_Generated_Image_3mc4t9 | 0.08 | 0.12 |
|
||||
|
||||
Derived by the shipped rule: worst first-clean 0.25 + spread (0.25 - 0.12
|
||||
= 0.13) = 0.38, rounded up to the measured rung **0.40**, then
|
||||
oracle-verified clean on the worst fixture. Against qwen-zimage 0.27:
|
||||
Chroma needs ~1.5x the strength on Google SynthID. At 0.15-0.20 the tool
|
||||
still reads positive while its wording degrades from "most or all" to
|
||||
"part of", so the boundary band is real signal decay, not noise.
|
||||
|
||||
Oracle mechanics learned while bracketing: the quota is per ACCOUNT and
|
||||
additionally dedupes SIMILAR images ("quota for checking images similar to
|
||||
this one"), roughly three checks per account window; switching accounts
|
||||
inside one browser works. A fresh Playwright context with the same Google
|
||||
session cookies (exported browser-wide via CDP `Storage.getCookies`, never
|
||||
through the transcript) carries working sign-in and its own check budget.
|
||||
The agent's own instructions leak into the answer on some renders and
|
||||
include an abstention rule for text-dominated images - always read the TOOL
|
||||
outcome, never the prose.
|
||||
- **Microsoft: 0.125**, measured 2026-08-30 on the SAME three valid Paint
|
||||
carriers behind the qwen floor (staged under
|
||||
`out/cohort-calibration/microsoft/` with pixel-identical metadata-stripped
|
||||
controls; they are real raiw-corpus user uploads and are never committed).
|
||||
Controls re-verified `Microsoft AI detected` on the day of the run. Against
|
||||
qwen-zimage 0.15: Chroma needs LESS strength on InvisMark.
|
||||
|
||||
| source (Paint) | detected at | clean from | qwen first-clean |
|
||||
|---|---|---|---|
|
||||
| paint-1 (47d87e99, 1536x1024) | 0.06 | 0.08 | 0.055 |
|
||||
| paint-2 (a579fb70, 1536x1024) | -- | <=0.04 | 0.04125 |
|
||||
| paint-3 (883191b5, worst qwen source) | 0.06 | 0.08 | 0.095 |
|
||||
|
||||
Derived by the shipped rule: 0.08 + (0.08 - 0.04) = 0.12, rounded to the
|
||||
measured rung **0.125**, which was then oracle-verified clean on both worst
|
||||
sources. Oracle: the public `ai.azure.com/nextgen/validate` page; its
|
||||
`Inconclusive` verdict is the site's watermark-negative, weaker than an
|
||||
API-level `Watermark: false`, the same caveat the qwen cohort carries.
|
||||
|
||||
## Fidelity at the floors (scripts/fidelity_metrics.py)
|
||||
|
||||
qwen-zimage rows ran through the package's own `QwenZImagePipeline` on the same
|
||||
H100 (production path, both stacks resident, prompt cache warm).
|
||||
|
||||
OpenAI typography (chroma 0.06 vs qwen 0.07675):
|
||||
|
||||
| | CER | img LPIPS | SSIM | PSNR |
|
||||
|---|---|---|---|---|
|
||||
| qwen-zimage | 0.241 | 0.083 | 0.834 | 27.7 |
|
||||
| chroma1 | **0.138** | **0.049** | **0.881** | **31.2** |
|
||||
|
||||
OpenAI full-pipeline fixture (chroma 0.09 vs qwen 0.07675): chroma wins every
|
||||
metric (CER 0.155 vs 0.259, LPIPS 0.056 vs 0.094, PSNR 29.6 vs 26.1).
|
||||
|
||||
OpenAI 9-face grid (chroma 0.075 global-only vs qwen 0.07675 with its Z-Image
|
||||
face stage):
|
||||
|
||||
| | ID cos | face LPIPS | img LPIPS | SSIM | PSNR |
|
||||
|---|---|---|---|---|---|
|
||||
| qwen-zimage | **0.857** | **0.051** | 0.065 | 0.865 | 31.1 |
|
||||
| chroma1 (no face stage) | 0.788 | 0.067 | **0.060** | **0.890** | **32.4** |
|
||||
|
||||
The face stage regenerates from the ORIGINAL crops and is engine-independent,
|
||||
so an integrated `chroma-zimage` would composite the same faces over the
|
||||
chroma global; whether that closes the identity gap is an integration-time
|
||||
measurement, not a given.
|
||||
|
||||
Meta at the respective floors (chroma 0.17 vs qwen 0.1): qwen wins perceptual
|
||||
fidelity on textured content (LPIPS: fox 0.163 vs 0.211, lighthouse 0.173 vs
|
||||
0.248, night city 0.187 vs 0.277), chroma wins the text poster decisively
|
||||
(LPIPS 0.008 vs 0.012, CER 0.000 vs 0.021) and the studio mug is a split. The
|
||||
extra 0.07 of strength costs real fidelity on photoreal content.
|
||||
|
||||
Google at the respective floors (chroma 0.40 vs qwen 0.27, qwen row includes
|
||||
its Z-Image face stage):
|
||||
|
||||
| fixture | qwen LPIPS/SSIM/ID cos | chroma LPIPS/SSIM/ID cos |
|
||||
|---|---|---|
|
||||
| 633uuy | **0.405 / 0.577** / - | 0.499 / 0.576 / - (CER 1.000 vs 0.333) |
|
||||
| akdbei | **0.440 / 0.552** / - | 0.540 / 0.557 / - (CER 0.796 vs 0.367) |
|
||||
| y48j3c (18 faces) | **0.436 / 0.608 / 0.801** | 0.560 / 0.548 / 0.279 |
|
||||
| 3mc4t9 (5 faces) | **0.422 / 0.529 / 0.921** | 0.562 / 0.461 / 0.143 |
|
||||
|
||||
qwen wins every axis that matters on every fixture; at 0.40 the Chroma global
|
||||
regeneration itself collapses face identity (0.14-0.28 cosine) and destroys
|
||||
dense text (CER up to 1.0). The extra 0.13 of strength is simply too much
|
||||
regeneration. This is the Meta pattern amplified.
|
||||
|
||||
Microsoft at the respective floors (chroma 0.125 vs qwen 0.15): chroma wins
|
||||
EVERY metric on EVERY source while scrubbing at lower strength --
|
||||
|
||||
| source | CER q/ch | img LPIPS q/ch | SSIM q/ch | PSNR q/ch |
|
||||
|---|---|---|---|---|
|
||||
| paint-1 | 0.741 / 0.241 | 0.164 / 0.106 | 0.742 / 0.847 | 24.8 / 29.3 |
|
||||
| paint-2 | 0.826 / 0.391 | 0.253 / 0.108 | 0.536 / 0.633 | 22.5 / 25.5 |
|
||||
| paint-3 | 0.000 / 0.000 | 0.038 / 0.020 | 0.762 / 0.953 | 36.3 / 42.2 |
|
||||
|
||||
The Microsoft cohort therefore behaves like the OpenAI cohort, not like Meta:
|
||||
for InvisMark carriers Chroma1 is the better engine at a lower operating point.
|
||||
|
||||
## Price (warm H100 container)
|
||||
|
||||
| | qwen-zimage | chroma1 |
|
||||
|---|---|---|
|
||||
| seconds per image | 2.44 | 3.62 |
|
||||
| VRAM peak | 31.9 GiB | 29.2 GiB |
|
||||
| USD per 1000 images ($3.95/h) | $2.68 | $3.97 |
|
||||
| weights in cache | ~70 GB | ~28 GB |
|
||||
|
||||
The chroma number includes a T5 encode per call; the shipped qwen path caches
|
||||
prompt embeddings, and the same treatment would put chroma near 1.5-2 s/image.
|
||||
Both stacks fit an 80 GB card; chroma's smaller footprint likely fits smaller
|
||||
cards untested.
|
||||
|
||||
## Matched-strength comparison: the floors hide the engine (2026-08-30 addendum)
|
||||
|
||||
The floor-based tables above compare chroma at its DERIVED floor (worst case
|
||||
+ spread) against qwen at its shipped floor - different perturbation levels.
|
||||
At the strength where each fixture ACTUALLY turns clean (chroma first-clean
|
||||
rungs, all oracle-verified above), the picture reverses:
|
||||
|
||||
Google (chroma@first-clean vs qwen@0.27):
|
||||
|
||||
| fixture | chroma rung | LPIPS q/ch | SSIM q/ch | PSNR q/ch | CER q/ch | ID q/ch |
|
||||
|---|---|---|---|---|---|---|
|
||||
| 633uuy | 0.25 | 0.405/**0.375** | 0.577/**0.673** | 18.8/**22.3** | **0.333**/0.667 | - |
|
||||
| akdbei | 0.25 | 0.440/**0.409** | 0.552/**0.650** | 18.6/**22.5** | 0.367/**0.163** | - |
|
||||
| y48j3c | 0.12 | 0.436/**0.329** | 0.608/**0.700** | 21.0/**25.8** | 1.0/1.0 | **0.801**/0.611 |
|
||||
| 3mc4t9 | 0.12 | 0.422/**0.318** | 0.529/**0.644** | 17.7/**21.3** | 0.443/**0.229** | **0.921**/0.410 |
|
||||
|
||||
Meta (chroma@first-clean vs qwen@0.1):
|
||||
|
||||
| fixture | chroma rung | LPIPS q/ch | SSIM q/ch | PSNR q/ch | CER q/ch |
|
||||
|---|---|---|---|---|---|
|
||||
| lighthouse | 0.10 (equal) | 0.173/**0.097** | 0.783/**0.886** | 25.6/**30.8** | - |
|
||||
| fox | 0.08 | 0.163/**0.083** | 0.778/**0.907** | 25.6/**31.4** | - |
|
||||
| night_city | 0.06 | 0.187/**0.092** | 0.748/**0.893** | 23.9/**29.6** | 0.850/**0.250** |
|
||||
| studio_mug | 0.045 | 0.024/**0.009** | 0.984/**0.992** | **39.0**/37.8 | - |
|
||||
| text_poster | 0.03 | 0.012/**0.002** | 0.984/**0.994** | 34.0/**41.2** | 0.021/**0.007** |
|
||||
|
||||
At effective-clean strength Chroma1 wins whole-image fidelity on essentially
|
||||
every fixture (often by 4-6 dB) and text on five of six; qwen's remaining
|
||||
edge is face identity, which its Z-Image face stage buys (0.80-0.92 vs
|
||||
0.41-0.61 cosine) and a chroma-zimage profile would inherit on top of its own
|
||||
global pass.
|
||||
|
||||
**Why the floors still matter:** a shipped strength must clear the worst
|
||||
unseen content, and Chroma1's per-fixture boundaries scatter much wider
|
||||
(Google 0.12-0.25, Meta 0.03-0.10) than the spread the flat floors were built
|
||||
to absorb - so its derived floors (0.40 / 0.17) pay a large margin penalty
|
||||
and at those floors the extra strength genuinely destroys quality. The
|
||||
engines differ less in "quality per strength" than in how predictable their
|
||||
boundary is. The integration lever this exposes: a content-adaptive strength
|
||||
policy for chroma-zimage (the project already ships resolution-adaptive
|
||||
denoise for qwen) could capture chroma's low per-content boundaries; the two
|
||||
hard Google fixtures are both dense-text cards and the easy ones are
|
||||
photoreal, so a content-class signal may predict the rung.
|
||||
|
||||
## Pre-ship validation (2026-08-30)
|
||||
|
||||
With the `chroma-zimage` profile implemented
|
||||
(`_internal/chroma_zimage_pipeline.py` on `TwoStageZImagePipeline`, floors in
|
||||
`watermark_profiles`), two ship-gate checks were run:
|
||||
|
||||
1. **Seed stability at the worst Meta boundary.** lighthouse @ 0.10 with
|
||||
seeds 1 and 2: both CLEAN on the meta.ai oracle. The Meta boundary is not a
|
||||
seed-0 fluke. The Google seed probes (633uuy @ 0.25, seeds 1-2) were
|
||||
generated but the oracle's per-image-class quota blocked them on every
|
||||
available account; they remain queued, and the shipped 0.40 Google floor
|
||||
carries 0.15 of margin over that boundary regardless.
|
||||
2. **Full-path face-stage interaction.** The complete profile run (Chroma1
|
||||
global at the shipped Google floor 0.40, then the inherited
|
||||
YuNet/SAM/Z-Image face stage) on the 5-face fixture 3mc4t9: CLEAN on the
|
||||
@synthid oracle. The composited face regions do not re-introduce a
|
||||
detectable signal on top of the floor.
|
||||
|
||||
Generation script: `scripts/chroma_preship_validation.py`; outputs under
|
||||
`out/preship-chroma/` (gitignored).
|
||||
|
||||
## Verdict
|
||||
|
||||
Engine quality is cohort-dependent, and after measuring all four cohorts the
|
||||
split is even and sharp:
|
||||
|
||||
- On OpenAI-provenance content Chroma1 is better at every measured fidelity
|
||||
axis at its floor (0.09 vs 0.07675), for comparable money.
|
||||
- On Microsoft InvisMark content Chroma1 is better at every measured fidelity
|
||||
axis at a LOWER floor than qwen (0.125 vs 0.15).
|
||||
- On Meta Content Seal Chroma1 needs 1.7x the strength (0.17 vs 0.1) and loses
|
||||
LPIPS on textured content at its floor.
|
||||
- On Google SynthID Chroma1 needs ~1.5x the strength (0.40 vs 0.27) and loses
|
||||
every fidelity axis on every fixture, with face identity collapsing at 0.40
|
||||
(0.14-0.28 cosine vs qwen's 0.80-0.92).
|
||||
|
||||
A single-engine `chroma-zimage` default at a flat per-vendor floor is NOT
|
||||
supported: Google and Meta floors land too high and the quality at those
|
||||
floors is worse than qwen's. But the matched-strength addendum changes what
|
||||
IS supported: at the strength each image actually needs, Chroma1 regenerates
|
||||
better than qwen almost everywhere except face identity (which the shared
|
||||
face stage would supply). The two honest integration options:
|
||||
|
||||
1. Per-cohort engine router at flat floors: chroma for OpenAI/Microsoft,
|
||||
qwen for Google/Meta - conservative, ships on the measurements as they
|
||||
stand.
|
||||
2. `chroma-zimage` with a content-adaptive strength policy that predicts the
|
||||
per-image rung - potentially better everywhere, but the predictor is new
|
||||
research (the measured signal so far: dense-text content needs the high
|
||||
rungs, photoreal the low ones) and needs its own oracle validation before
|
||||
any ship.
|
||||
|
||||
Both need the seed-sensitivity and face-stage composition checks called out
|
||||
above before shipping. Both checks have now been run (Pre-ship validation
|
||||
above): the Meta boundary is seed-stable across seeds 0-2 and the full
|
||||
profile path stays clean through the face stage on the shipped Google floor.
|
||||
The profile ships with option 1's floors; option 2's adaptive policy remains
|
||||
the documented follow-up.
|
||||
|
||||
## Oracle session notes
|
||||
|
||||
- openai.com/verify throttles anonymous sessions through Cloudflare Turnstile
|
||||
after ~15 rapid checks; a fresh browser context clears it. The programmatic
|
||||
`POST /v1/content_provenance_checks` API is the better oracle (separate
|
||||
SynthID/C2PA outcomes) but the checked `OPENAI_API_KEY` returned 401
|
||||
org-wide, including `/v1/models`.
|
||||
- meta.ai/identification enforces a sliding per-IP window: a burst of ~15
|
||||
checks exhausted it twice; it reopens minutes later, and the page sometimes
|
||||
overstates this as a daily limit. Pacing of roughly 2-3 checks per window
|
||||
worked.
|
||||
- The Gemini app oracle requires the user's logged-in session; driving the
|
||||
user's live Chrome while it is in active use was refused (wrong-tab risk),
|
||||
so those 20 checks remain queued.
|
||||
@@ -317,7 +317,7 @@ remove-ai-watermarks video visible kling.mp4 --mark kling -o kling_clean.mp4
|
||||
|
||||
The command supports the moving Sora mascot and wordmark, two Veo
|
||||
corner variants, the Seedance boxed `AI` label, the `Dola AI` text label, the
|
||||
composite `MINIMAX | hailuo AI` label, and the bottom-right Kling label. Sora
|
||||
composite `MINIMAX | hailuo AI` label, and the bottom-right Kling AI label. Sora
|
||||
searches the whole frame at multiple scales. The other detectors search bounded
|
||||
lower-frame regions with separate synthetic silhouettes. Kling additionally
|
||||
requires its bright low-saturation label near the frame edge. Every mark
|
||||
@@ -327,8 +327,8 @@ provider provenance may relax the visual score only for registered
|
||||
provenance-aware marks; metadata alone never creates a detection.
|
||||
|
||||
`--mark auto` is the default. It evaluates all providers in one decode pass and
|
||||
selects the first stable match in specificity order: Sora, Veo, Seedance, Dola,
|
||||
Hailuo, then Kling. Their confidence scores are independently calibrated and
|
||||
selects the first stable match in specificity order: Sora, Veo, Seedance,
|
||||
Doubao, Dola, Hailuo AI, then Kling AI. Their confidence scores are independently calibrated and
|
||||
are not compared across providers. Pass an explicit `--mark` to scan only that
|
||||
provider.
|
||||
|
||||
@@ -367,7 +367,7 @@ failed encode does not overwrite an existing result.
|
||||
|
||||
## Remove invisible watermarks
|
||||
|
||||
Install the removal dependencies first. Both profiles are CUDA-only and both
|
||||
Install the removal dependencies first. All profiles are CUDA-only and all
|
||||
run the DiffSynth Z-Image face stage, so this is the extra either one needs:
|
||||
|
||||
```bash
|
||||
@@ -434,10 +434,10 @@ 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, blends 15% of that reconstruction into the normal
|
||||
`qwen-zimage` result, 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 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.
|
||||
|
||||
Install the combined extra and run only with an operator-verified manifest:
|
||||
|
||||
|
||||
@@ -641,7 +641,7 @@ photo only).
|
||||
|
||||
**Sources.** https://arxiv.org/abs/2206.11253 (CodeFormer) · https://github.com/sczhou/CodeFormer ·
|
||||
https://arxiv.org/pdf/2401.07519 (InstantID) ·
|
||||
https://openaccess.thecvf.com/content/WACV2024/papers/Suin_Diffuse_and_Restore... (region-adaptive) ·
|
||||
https://openaccess.thecvf.com/content/WACV2024/html/Suin_Diffuse_and_Restore_A_Region-Adaptive_Diffusion_Model_for_Identity-Preserving_Blind_WACV_2024_paper.html (region-adaptive) ·
|
||||
https://arxiv.org/pdf/2504.12809 (saliency-aware watermark removal)
|
||||
|
||||
## Provenance
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
# Development
|
||||
|
||||
Standalone evaluation, calibration, and release utilities are catalogued in
|
||||
[`scripts/README.md`](../scripts/README.md). They are maintainer tools rather than
|
||||
installed commands, and their local data inputs remain outside the repository.
|
||||
|
||||
Read this reference for environment setup, dependency recovery, CI behavior, and fixture policy. The always-loaded invariants remain in [`.claude/rules/development.md`](../.claude/rules/development.md).
|
||||
|
||||
## Local environment
|
||||
|
||||
@@ -39,3 +39,4 @@ The current behavior is defined by the code, tests, README, and user guides.
|
||||
- [SynthID identity follow-up](synthid-robust-identity-research-2026-06-08.md)
|
||||
- [Video SynthID quality research](video-synthid-quality-research.md)
|
||||
- [Text protection research](text-protection-research.md)
|
||||
- [Chroma1 engine research](chroma1-engine-research.md)
|
||||
@@ -75,11 +75,14 @@ uv tool install --force "remove-ai-watermarks[qwen-zimage]"
|
||||
```
|
||||
|
||||
Both remaining profiles run a Z-Image face stage on the DiffSynth runtime, so
|
||||
both need this extra. It includes the `diffusion` dependencies; `diffusion` on its
|
||||
own covers the torch and diffusers imports but not the face stage, so it is not
|
||||
enough to run a removal.
|
||||
both need this extra. The third profile, `chroma-zimage`, uses diffusers'
|
||||
ChromaImg2ImgPipeline instead of DiffSynth for its global stage but inherits
|
||||
the same face stage, so it needs this extra too. It includes the `diffusion`
|
||||
dependencies; `diffusion` on its own covers the torch and diffusers imports
|
||||
but not the face stage, so it is not enough to run a removal.
|
||||
|
||||
**An NVIDIA GPU is required.** `qwen-zimage` and `sdxl-zimage` are CUDA-only, and
|
||||
**An NVIDIA GPU is required.** `qwen-zimage`, `sdxl-zimage` and `chroma-zimage`
|
||||
are CUDA-only, and
|
||||
construction refuses any other device rather than falling back to a slow or broken
|
||||
one. There is no CPU, MPS or XPU path for invisible-watermark removal. Visible-mark
|
||||
removal, metadata stripping and every `identify` command still run anywhere.
|
||||
@@ -114,6 +117,7 @@ application actually uses:
|
||||
| `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 |
|
||||
| `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 |
|
||||
| `dev` | Tests, linting, typing, and upstream parity checks | `video`, `detect`, upstream invisible-watermark | Yes, for parity tests |
|
||||
|
||||
@@ -130,12 +134,15 @@ flowchart LR
|
||||
qwen["qwen-zimage"] --> diffusion
|
||||
text["text-restoration"] --> qwen
|
||||
text --> lama
|
||||
draft["text-draft"]
|
||||
heif
|
||||
trustmark
|
||||
```
|
||||
|
||||
`heif` and `trustmark` are independent branches. Combine them explicitly with
|
||||
another feature when required. TrustMark requires NumPy 1.x, which has no
|
||||
`heif`, `trustmark`, and `text-draft` are independent branches. Combine them
|
||||
explicitly with another feature when required. `text-draft` is excluded from
|
||||
`all` because it proposes unverified OCR annotations and is not a production
|
||||
removal path. TrustMark requires NumPy 1.x, which has no
|
||||
CPython 3.13 or 3.14 wheels, so that branch is available only on Python
|
||||
3.11-3.12. The `all` bundle contains every production branch compatible with
|
||||
the active Python and never includes `dev`.
|
||||
@@ -237,5 +244,5 @@ image came from a relevant generator, use `--force`.
|
||||
|
||||
If the CLI reports that the removal dependencies are unavailable, install the
|
||||
`qwen-zimage` extra. `diffusion` alone covers Torch and Diffusers but not the
|
||||
DiffSynth face stage that both profiles run. Video SynthID removal is a separate
|
||||
DiffSynth face stage that all profiles run. Video SynthID removal is a separate
|
||||
path and needs `video` and `diffusion`.
|
||||
@@ -31,11 +31,14 @@ an unsupported locale, a different position, or a crop may be missed.
|
||||
|
||||
Known examples:
|
||||
|
||||
- The Microsoft detector covers one calibrated top-right white pill. Microsoft's
|
||||
[documented feature](https://support.microsoft.com/en-us/topic/include-a-watermark-when-content-from-microsoft-365-is-ai-generated-b00a656e-ae61-4692-8086-67d004421030)
|
||||
can instead use a Copilot icon, `AI-Generated` text, or another position.
|
||||
- Samsung detection is calibrated for the Italian
|
||||
`Contenuti generati dall'AI` text variant.
|
||||
- The Jimeng top-left pill has a weak visual detector and is intentionally
|
||||
subject to additional product and background checks.
|
||||
- Kling support covers the calibrated variants rather than every Kling label.
|
||||
- Kling AI support covers the calibrated variants rather than every Kling AI label.
|
||||
|
||||
Use `erase --region` when you can see and select an unsupported or missed mark.
|
||||
|
||||
@@ -182,7 +185,7 @@ path has not yet answered.
|
||||
|
||||
### Strength is content and seed dependent
|
||||
|
||||
The two profiles resolve an unset strength differently, because different things
|
||||
The profiles resolve an unset strength differently, because different things
|
||||
were measured for each.
|
||||
|
||||
`qwen-zimage` reads unknown content from the resolution-adaptive denoise curve.
|
||||
@@ -205,10 +208,23 @@ values are flat rather than a curve because flat values are what was measured: e
|
||||
verdict came from a fixed strength at one size, and no size dependence has been
|
||||
established for that stage.
|
||||
|
||||
An explicit `--strength` overrides both. The defaults are operating points, not
|
||||
universal guarantees. Near a removal threshold, different content or a different
|
||||
random seed may change the verifier result, which is why both profiles are
|
||||
certified at a fixed seed. The live resolver is
|
||||
`chroma-zimage` also reads a flat ladder from the provenance cohort, measured
|
||||
2026-08-29/30 across the four vendor oracles (full record:
|
||||
[`chroma1-engine-research.md`](chroma1-engine-research.md)):
|
||||
|
||||
- OpenAI: `0.09` (below qwen's `0.07675` is false economy neither way -- the
|
||||
matched-strength fidelity favors Chroma1 on this cohort);
|
||||
- Microsoft InvisMark: `0.125` (below qwen's `0.15`);
|
||||
- Google: `0.40` (above qwen's `0.27`; at this floor the regeneration destroys
|
||||
dense text and collapses face identity -- the tradeoff that motivates the
|
||||
documented content-adaptive follow-up);
|
||||
- Meta Content Seal: `0.17` (above qwen's `0.1`);
|
||||
- unknown: `0.40`, following the strictest measured cohort.
|
||||
|
||||
An explicit `--strength` overrides all three. The defaults are operating points,
|
||||
not universal guarantees. Near a removal threshold, different content or a
|
||||
different random seed may change the verifier result, which is why the profiles
|
||||
are certified at a fixed seed. The live resolver is
|
||||
[`watermark_profiles.py`](../src/remove_ai_watermarks/_internal/watermark_profiles.py).
|
||||
|
||||
### Pipelines have different quality tradeoffs
|
||||
@@ -269,7 +285,7 @@ device and re-reads every parameter from disk on each stage transition.
|
||||
|
||||
Invisible-watermark removal refuses any device but CUDA at construction. There is
|
||||
no MPS out-of-memory fallback that continues on CPU, and no lighter profile to
|
||||
drop to: both remaining profiles need an NVIDIA GPU.
|
||||
drop to: all three profiles need an NVIDIA GPU.
|
||||
|
||||
When a run does not fit, the levers are `--tile` (native geometry, tiled
|
||||
diffusion), `--max-resolution` (an explicit downscale), and `--cpu-offload`.
|
||||
@@ -310,11 +326,11 @@ supported AI provenance metadata without transcoding streams.
|
||||
|
||||
`video visible` and `remove_video_visible` additionally support the moving
|
||||
Sora 2 mascot and wordmark, the current Veo four-point diamond, the legacy
|
||||
`Veo` text, the Seedance boxed `AI` label, the fixed `Dola AI` text, the Hailuo
|
||||
MINIMAX/Hailuo composite label, and the bottom-right Kling label with its
|
||||
`Veo` text, the Seedance boxed `AI` label, the fixed `Dola AI` text, the Hailuo AI
|
||||
MINIMAX/Hailuo AI composite label, and the bottom-right Kling AI label with its
|
||||
version suffix. Detection requires a recurring visual candidate across
|
||||
adjacent frames. Fixed-mark candidates must remain anchored rather than
|
||||
drifting with a scene object. Kling also requires a bright low-saturation
|
||||
drifting with a scene object. Kling AI also requires a bright low-saturation
|
||||
candidate near the expected frame edge. Provider provenance can recover
|
||||
low-contrast runs only after visual evidence exists for the marks that define a
|
||||
provenance prior, so metadata alone does not erase a clean API export.
|
||||
@@ -325,7 +341,7 @@ policies and selects the first stable result in specificity order. Use an
|
||||
explicit mark when the provider is already known.
|
||||
Historical Sora Turbo exports use a small OpenAI swirl in the corner rather
|
||||
than the moving mascot-and-wordmark design; that earlier variant is not
|
||||
detected by the `sora` video mark. Hailuo and Kling coverage is specific to the
|
||||
detected by the `sora` video mark. Hailuo AI and Kling AI coverage is specific to the
|
||||
verified lower-edge layouts; a new provider layout needs a separate calibrated
|
||||
silhouette. Other provider video labels are not supported yet. Google video
|
||||
SynthID has an oracle-certified VAE removal path, while other proprietary
|
||||
@@ -416,7 +432,7 @@ remain identical because isolated decoder hits can otherwise be content noise.
|
||||
It accepts Variant P schemas 0-2. Variant Q requires a different model, and
|
||||
schema 3 is rejected at the measured precision threshold. Its NumPy 1.x runtime
|
||||
limits the extra to Python 3.11-3.12; the rest of the package remains supported
|
||||
through Python 3.14. Through 0.29.0 the extra also resolves lightning 2.6.5
|
||||
through Python 3.14. The current TrustMark dependency line also resolves lightning 2.6.5
|
||||
(PYSEC-2026-3624, no fixed release yet); the vulnerable `load_from_checkpoint`
|
||||
path is unreachable here because TrustMark loads its checksummed checkpoints
|
||||
with plain `torch.load`. Bump lightning and cut a patch release when a fix
|
||||
|
||||
@@ -296,13 +296,13 @@ from the gate. The full-clip oracle floor is
|
||||
detected while `0.15` did not.
|
||||
|
||||
[`video_visible.py`](../src/remove_ai_watermarks/video_visible.py) implements
|
||||
the first pixel stages for Sora, Veo, Seedance, Dola, Hailuo, and Kling. The
|
||||
the first pixel stages for Sora, Veo, Seedance, Doubao, Dola, Hailuo AI, and Kling AI. The
|
||||
Sora detector searches a normalized frame with a fully synthetic
|
||||
mascot-and-text silhouette at several scales. The Veo detector uses separate
|
||||
synthetic silhouettes for the current four-point diamond and legacy `Veo`
|
||||
text. Seedance uses a synthetic rounded boxed-`AI` silhouette, while Dola uses
|
||||
an OpenCV-font `Dola AI` silhouette. Hailuo uses a synthetic waveform,
|
||||
MINIMAX/Hailuo text, separator, and ring. Kling combines synthetic font
|
||||
an OpenCV-font `Dola AI` silhouette. Hailuo AI uses a synthetic waveform,
|
||||
MINIMAX/Hailuo AI text, separator, and ring. Kling AI combines synthetic font
|
||||
variants with a ring approximation of its swirl; the logo path rescues
|
||||
wordmarks whose version or font differs, while the edge and white-label gates
|
||||
reject recurring scene texture. All fixed-mark searches are bounded to the
|
||||
@@ -323,23 +323,23 @@ Every per-frame result is untrusted. Each provider's floors, minimum-run policy,
|
||||
fill padding and mask style are one row in `VISIBLE_MARK_POLICIES`, and every mark
|
||||
enters the same `stabilize_localizations` entry point; the recurrence
|
||||
implementation underneath knows nothing about providers. That policy row also
|
||||
carries `accepts_provenance`, which forces `provenance=False` for Hailuo and Kling
|
||||
carries `accepts_provenance`, which forces `provenance=False` for Hailuo AI and Kling AI
|
||||
— they have no metadata that could confirm them, and the guarantee used to be
|
||||
structural (their wrappers took no `provenance` parameter at all). Provenance can relax a low-contrast run only
|
||||
after recurring visual evidence exists. Sora transition frames follow the
|
||||
nearest confirmed moving position only with Sora provenance. Seedance, Dola,
|
||||
Hailuo, and Kling additionally require candidates to remain anchored to the
|
||||
nearest confirmed moving position only with Sora provenance. Seedance, Doubao,
|
||||
Dola, Hailuo AI, and Kling AI additionally require candidates to remain anchored to the
|
||||
start of a run. This rejects slowly drifting scene details that still have
|
||||
high frame-to-frame overlap. Hailuo and Kling do not infer provenance from
|
||||
high frame-to-frame overlap. Hailuo AI and Kling AI do not infer provenance from
|
||||
technical encoder tags; their confirmed public samples carried no provider
|
||||
metadata.
|
||||
|
||||
Removal runs in a second decode pass. Sora, legacy Veo text, Dola text,
|
||||
Seedance, Hailuo, and Kling use box masks. Seedance deliberately fills the
|
||||
Seedance, Hailuo AI, and Kling AI use box masks. Seedance deliberately fills the
|
||||
complete localized box: a synthetic outline mask passed repeat detection but
|
||||
left part of the real translucent border visible during visual end-to-end
|
||||
review. Hailuo expands beyond the matched core to cover both provider icons.
|
||||
Kling expands around the wordmark or swirl to include the version and optional
|
||||
review. Hailuo AI expands beyond the matched core to cover both provider icons.
|
||||
Kling AI expands around the wordmark or swirl to include the version and optional
|
||||
`PRO` suffix. The square Veo diamond uses a synthetic shape mask so transparent
|
||||
corners do not erase unrelated pixels. Every mask goes through the shared
|
||||
`watermark_registry.fill` backends. ffmpeg encodes the changed video stream and
|
||||
@@ -451,7 +451,9 @@ For an AI C2PA claim, a recognized product in `claim_generator` takes precedence
|
||||
over the certificate issuer: an application can sign through an upstream model
|
||||
provider without becoming that provider's product. Only exact product mappings
|
||||
receive this precedence; an unknown claim generator still falls back to issuer
|
||||
attribution.
|
||||
attribution. An unmapped issuer org reads as unknown-signer C2PA with no platform;
|
||||
that is how Ideogram was surfaced (4 corpus uploads signed "Ideogram, Inc",
|
||||
2026-08-08) before its vendor row was added on 2026-08-27.
|
||||
|
||||
### Metadata scanning and stripping
|
||||
|
||||
@@ -746,9 +748,9 @@ capture-less pill is too weak to attribute.
|
||||
|
||||
The set of marks that veto the pill is DERIVED from the registry rows: every mark
|
||||
under the same label regime (`tc260`) belonging to a different product. It used to
|
||||
be a hand-written list of keys, and that list drifted -- LibLibAI was registered
|
||||
alongside RunningHub and Baidu, both of which were added to it, and LibLibAI was
|
||||
not, so a confident LibLibAI detection did not suppress the pill the way its two
|
||||
be a hand-written list of keys, and that list drifted -- LiblibAI was registered
|
||||
alongside RunningHub and Baidu, both of which were added to it, and LiblibAI was
|
||||
not, so a confident LiblibAI detection did not suppress the pill the way its two
|
||||
siblings did. Marks outside the TC260 regime (Gemini, Samsung) are deliberately
|
||||
not vetoers: neither can put `jimeng` into `provenance`, so neither can enable the
|
||||
arm it would be vetoing.
|
||||
@@ -800,6 +802,33 @@ be represented by the shared base:
|
||||
- [`runninghub_engine.py`](../src/remove_ai_watermarks/runninghub_engine.py)
|
||||
- [`baidu_engine.py`](../src/remove_ai_watermarks/baidu_engine.py)
|
||||
- [`liblib_engine.py`](../src/remove_ai_watermarks/liblib_engine.py)
|
||||
- [`microsoft_engine.py`](../src/remove_ai_watermarks/microsoft_engine.py)
|
||||
|
||||
The measured Microsoft badge variant (2026-08-27 registration) is the first
|
||||
`tr`-corner mark and the first `long`-side scale basis: the pill tracks the
|
||||
render dimension, so a
|
||||
1024x1536 portrait carries the same pill as 1536x1024, and a width basis
|
||||
undersized the template by the aspect ratio (portrait carriers fell to
|
||||
0.15-0.32 NCC until the basis was measured). The silhouette is a white pill with
|
||||
its synthetic internal shapes knocked out - the holes are what separate it from
|
||||
any other bright rounded corner element (a plain white pill scores below the gate
|
||||
in the tests). It does not claim coverage of Microsoft's other documented icon,
|
||||
wording, or position variants.
|
||||
|
||||
The 2026-08-27 rerun used the registered engine through
|
||||
`scripts/registered_mark_calibrate.py`, rather than a copied detector
|
||||
configuration. The manifest kept three evidence classes separate: 17 visually
|
||||
confirmed carriers, 343 Microsoft-provenance files without a visual adjudication,
|
||||
and 1200 non-overlapping no-signal controls. At the strict 0.38 gate, 15/17
|
||||
confirmed carriers fired (min 0.249, p50 0.519, p90 0.578, max 0.579), while
|
||||
0/1200 controls fired (p99 0.200, max 0.293). The provenance cohort produced
|
||||
78/343 fires, but that is not a recall measurement because provenance identifies
|
||||
the provider, not the presence of this visible layout. That cohort was later
|
||||
labeled by an OCR badge census (2026-08-28: 86 badge carriers, 257 badge-less,
|
||||
badge-less max 0.251 / p99 0.213), which is what enabled the shipped provenance
|
||||
relaxation 0.7: the relaxed band [0.251, 0.38) holds three faint badges and
|
||||
zero false fills, re-verified as 3 band detections with 0/257 badge-less fires
|
||||
on the provenance path.
|
||||
|
||||
The detector and removal mask must use compatible geometry. A detector that
|
||||
fires while producing an empty or misplaced mask is a removal failure even if
|
||||
@@ -818,7 +847,7 @@ were briefly skipped there for exactly that reason.
|
||||
A mark whose removable footprint differs from what the detector localizes
|
||||
overrides `_footprint_rect` (which policy) and `_extend_match_box` (how far the
|
||||
box grows), not the whole `footprint_mask`. Baidu extends right to the corner tag
|
||||
and LibLibAI extends left to the triangle logo; both inherit every guard around
|
||||
and LiblibAI extends left to the triangle logo; both inherit every guard around
|
||||
that arithmetic.
|
||||
|
||||
Yuanbao uses the polarity-independent `contrast` front end because its standard
|
||||
@@ -826,6 +855,22 @@ two-line mark can be light on dark scenes or dark on light scenes. Its detector
|
||||
and footprint both use the same best-match box. The separate one-line overlay
|
||||
variant is not covered.
|
||||
|
||||
#### Bare-upload pill arm: measured impossible with the current detector (2026-08-28)
|
||||
|
||||
`_keep_pill` never removes a metadata-bare pill, and the question "can a bare arm
|
||||
(score + footprint-flatness) be opened" was measured to a closed NO over the local
|
||||
spaces corpus: 68 OCR-confirmed bare pills (an independent pixel-level label: the
|
||||
band sweep read a flush-left `AI生成`) score p50 0.181 / max 0.317, while 799 clean
|
||||
no-signal negatives reach 0.353 and 18 of them already pass the shipped 0.22 raw
|
||||
gate. The true-pill and clean distributions overlap completely -- there is no
|
||||
threshold -- and the engine's own high scores (0.34-0.48 on the flagged cohort)
|
||||
correlate with textured corners, not with pills: the edge-NCC keys on "text-like
|
||||
structure top-left". As with the Jimeng wordmark's silhouette, no threshold repairs
|
||||
this; a bare arm needs a detector that keys on the pill's rounded-rectangle
|
||||
geometry, and until one exists the bare-arm question is settled by this
|
||||
measurement, not by gate tuning. Harness: `data/spaces/_pill_bare_measure.py`
|
||||
(cohorts A/B/C over the no-signal pool).
|
||||
|
||||
The capture-less Jimeng pill lives in
|
||||
[`pill_engine.py`](../src/remove_ai_watermarks/pill_engine.py). It uses a
|
||||
synthetic silhouette for detection and a fixed top-left footprint.
|
||||
@@ -837,6 +882,32 @@ Shared behavior is covered by:
|
||||
- [`test_text_mark_faint_mask.py`](../tests/test_text_mark_faint_mask.py)
|
||||
- [`test_text_mark_memory.py`](../tests/test_text_mark_memory.py)
|
||||
|
||||
#### Doubao video mark (2026-08-28 registration)
|
||||
|
||||
`detect_doubao_frame` reuses the image engine's synthetic `doubao_alpha.png` as
|
||||
its video template (same `豆包AI生成` run, bottom-right) through
|
||||
`_asset_template`, so image and video cannot drift apart on the glyph. Search
|
||||
profile: heights 3.2-6.0 percent of the short side from origin (0.60, 0.76),
|
||||
measured on the corpus. Policy is seedance-style single-floor: weak 0.35 /
|
||||
strong 0.55 / anchor IoU 0.80, calibrated by sequential decode over 39
|
||||
TC260-confirmed Doubao videos and 25 negatives -- a 12-frame stable run at 0.35
|
||||
accepts 33/39 positives and 0/25 negatives, while 0.30 already accepted one
|
||||
negative, so the bar stays at 0.35. Through the shipped arbiter
|
||||
(`identify_video`): 32/39 selected as doubao, 0/25 false; the remainder split
|
||||
between no stable run (5) and cross-template ties handed to earlier table
|
||||
entries (sora 1, kling 1) -- the registry's known order-decides behavior, not a
|
||||
doubao-specific defect. Harness: `data/spaces/_doubao_seq_measure.py`.
|
||||
|
||||
Two further video candidates were measured and PARKED the same day (2026-08-28,
|
||||
`data/spaces/_rh_jm_video_calibrate.py`): the Jimeng CN wordmark does not
|
||||
separate on video (1/5 positives vs 2/25 negatives hold a 12-frame 0.35 run --
|
||||
the same silhouette-generality the image jimeng engine documents), and
|
||||
RunningHub video separates (1/2 positives, 0/25 negatives) but a two-positive
|
||||
cohort is below the registration bar; both await corpus growth. The top-left
|
||||
"AI生成" pill on video is parked by analysis, not measurement: its image-engine
|
||||
false-fire mode is a STATIC textured corner, which is temporally stable, so the
|
||||
recurrence arbiter cannot screen it.
|
||||
|
||||
### Fill backends and region erasing
|
||||
|
||||
[`region_eraser.py`](../src/remove_ai_watermarks/region_eraser.py) implements the
|
||||
@@ -869,12 +940,12 @@ is the source of truth for:
|
||||
- profile names and their underscore spellings;
|
||||
- the fixed seed;
|
||||
- the SDXL global-stage checkpoint id (`SDXL_MODEL_ID`) and the Canny ControlNet id;
|
||||
- strength resolution for both profiles.
|
||||
- strength resolution for every profile.
|
||||
|
||||
The current profiles are `qwen-zimage` (the default) and `sdxl-zimage`, and both
|
||||
are CUDA-only. `controlnet`, `sdxl`, `qwen` and `default` were removed rather than
|
||||
kept as a CPU path, and are rejected rather than aliased onward. There is no
|
||||
content-dependent automatic router.
|
||||
The current profiles are `qwen-zimage` (the default), `sdxl-zimage`, and
|
||||
`chroma-zimage`, and all three are CUDA-only. `controlnet`, `sdxl`, `qwen` and
|
||||
`default` were removed rather than kept as a CPU path, and are rejected rather
|
||||
than aliased onward. There is no content-dependent automatic router.
|
||||
|
||||
`qwen-zimage` normally resolves global denoise from image area for unknown content.
|
||||
Measured provider cohorts bypass that curve with flat operating points. The values,
|
||||
@@ -892,8 +963,10 @@ by the profile, so none of them appears in `WatermarkRemover.__init__`,
|
||||
then rejected several frames down; a signature that refuses the argument outright
|
||||
fails where the caller can act on it, and stops a wrapper from threading a value
|
||||
that would silently do nothing. The step count and CFG live with the stage that
|
||||
runs them (`GLOBAL_STEPS`, `FACE_STEPS`, `GLOBAL_CFG`, `FACE_CFG` in
|
||||
`qwen_zimage_pipeline.py`). The dtype is likewise profile-owned: see "Face-stage
|
||||
runs them: `GLOBAL_STEPS`/`GLOBAL_CFG` with the Qwen global stage
|
||||
(`qwen_zimage_pipeline.py`), `SDXL_STEPS` with the SDXL one
|
||||
(`sdxl_zimage_pipeline.py`), and `FACE_STEPS`/`FACE_CFG` with the shared face
|
||||
stage (`two_stage_pipeline.py`). The dtype is likewise profile-owned: see "Face-stage
|
||||
dtype" for what an override cost the last time one existed.
|
||||
|
||||
`device` is the exception and remains a library parameter: `None` or `"auto"`
|
||||
@@ -920,7 +993,7 @@ same pixels. They diverged before, in opposite directions, for exactly this knob
|
||||
|
||||
The global and face prompts are calibrated model inputs, and the Canny edge map
|
||||
uses fixed thresholds of `_CANNY_LOW = 13` / `_CANNY_HIGH = 64`
|
||||
(`qwen_zimage_pipeline.py`). Treat those values as behavioral compatibility
|
||||
(`two_stage_pipeline.py`). Treat those values as behavioral compatibility
|
||||
contracts: a refactor must preserve them, and any deliberate change requires
|
||||
image-quality evaluation rather than only a unit-test pass. The prompt and
|
||||
edge-map regression guards are
|
||||
@@ -968,8 +1041,19 @@ Regression coverage:
|
||||
|
||||
### Qwen plus Z-Image
|
||||
|
||||
The profiles are siblings of one shared recipe rather than a chain:
|
||||
[`_internal/two_stage_pipeline.py`](../src/remove_ai_watermarks/_internal/two_stage_pipeline.py)
|
||||
holds `TwoStageZImagePipeline`, the base that owns everything profile-independent
|
||||
(the Z-Image face stage with YuNet detection and SAM masks, sizing, compositing,
|
||||
the prompt cache, and the `run`/`preload` orchestration). A profile subclass
|
||||
implements `_load_global` and `_run_global` for its regeneration model and may
|
||||
implement `_vae_roundtrip` to expose that stack's VAE as a verified-text donor;
|
||||
nothing else differs, and a test asserts the shared methods are the same objects
|
||||
on every subclass. Adding a third global stage means implementing those hooks,
|
||||
not inheriting from another profile's pipeline.
|
||||
|
||||
[`_internal/qwen_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py)
|
||||
implements the fixed CUDA-only two-stage profile:
|
||||
implements the fixed CUDA-only two-stage profile on that base:
|
||||
|
||||
1. Qwen Image with Canny conditioning regenerates the frame.
|
||||
2. YuNet locates faces, SAM builds masks, and Z-Image regenerates the selected
|
||||
@@ -1020,11 +1104,13 @@ Validation happens before model loading. The library never treats OCR confidence
|
||||
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, blends 15% of the VAE
|
||||
reconstruction into that clean result, and calls the shared restoration compositor.
|
||||
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 core with a 0.5-pixel feather. The evaluation script
|
||||
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
|
||||
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
|
||||
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
|
||||
side, then expand each side independently while a foreground component anchored
|
||||
@@ -1067,13 +1153,43 @@ oracle outcomes, and the DiffSynth/GGUF, scheduler, detector, and seed caveats a
|
||||
recorded in
|
||||
[`data/evaluations/fidelity/upstream-v2-reproduction-2026-08-13.csv`](../data/evaluations/fidelity/upstream-v2-reproduction-2026-08-13.csv).
|
||||
|
||||
### Chroma1 plus Z-Image
|
||||
|
||||
[`_internal/chroma_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/chroma_zimage_pipeline.py)
|
||||
runs the same two-stage recipe on a Chroma1-HD (`lodestones/Chroma1-HD`,
|
||||
Apache-2.0) global pass through diffusers' `ChromaImg2ImgPipeline`. It is the
|
||||
answer to issue #88's FLUX.2 request through the model that actually exposes
|
||||
strength-controlled img2img in that family; the full research record,
|
||||
including why FLUX.2 itself is not integrable this way, is
|
||||
[`chroma1-engine-research.md`](chroma1-engine-research.md).
|
||||
|
||||
The profile swaps only the global stage (same inheritance invariant as
|
||||
sdxl-zimage, asserted by the same test shape). Three things are bound to the
|
||||
calibration and must not drift: the NEUTRAL prompt (`high quality, sharp,
|
||||
detailed, faithful to the original` -- deliberately NOT the shared canny-stage
|
||||
prompt, which was never calibrated against Chroma1), guidance 5.0, and the
|
||||
four-EFFECTIVE-step schedule with `requested_steps` compensation (the same
|
||||
Diffusers truncation trap sdxl-zimage documents). There is no Canny
|
||||
conditioning: the floors were measured on a plain strength pass.
|
||||
|
||||
The flat vendor floors (`CHROMA_ZIMAGE_*_STRENGTH` in watermark_profiles):
|
||||
OpenAI 0.09 and Microsoft 0.125 (both BELOW qwen's, with better
|
||||
matched-strength fidelity), Google 0.40 and Meta 0.17 (both ABOVE qwen's,
|
||||
because Chroma1's per-fixture boundaries scatter wider). The
|
||||
matched-strength addendum in the research doc is the honest read: at the
|
||||
strength each image actually needs, Chroma1 regenerates better almost
|
||||
everywhere except face identity (which the inherited Z-Image face stage
|
||||
supplies); at the flat worst-case floors it destroys dense text and face
|
||||
identity. A content-adaptive strength policy is the documented follow-up.
|
||||
|
||||
### SDXL plus Z-Image
|
||||
|
||||
[`_internal/sdxl_zimage_pipeline.py`](../src/remove_ai_watermarks/_internal/sdxl_zimage_pipeline.py)
|
||||
runs the same two-stage recipe on an SDXL global pass. `SdxlZImagePipeline` subclasses
|
||||
`QwenZImagePipeline` and overrides only `_run_global` and `preload`, so the face stage
|
||||
is inherited rather than copied and cannot drift between the profiles; a test asserts
|
||||
the shared methods are the same objects.
|
||||
runs the same two-stage recipe on an SDXL global pass. `SdxlZImagePipeline`
|
||||
subclasses `TwoStageZImagePipeline` and implements only the global stage
|
||||
(`_load_global`, `_run_global`), so the face stage is inherited rather than copied
|
||||
and cannot drift between the profiles; a test asserts the shared methods are the
|
||||
same objects.
|
||||
|
||||
Four things are architecture-bound and swap with the model: the ControlNet
|
||||
(`xinsir/controlnet-canny-sdxl-1.0`), the four-step distillation LoRA
|
||||
@@ -1111,7 +1227,7 @@ avoid model downloads, so nothing exercised the loader. The lesson is narrower t
|
||||
was a dtype the subclass silently changed out from under it.
|
||||
|
||||
Note what the seam is, because it decides where the fix belongs.
|
||||
`SdxlZImagePipeline._load_sdxl` hardcodes fp16 for its own ControlNet, VAE and
|
||||
`SdxlZImagePipeline._load_global` hardcodes fp16 for its own ControlNet, VAE and
|
||||
pipeline, so `self.torch_dtype` was never actually the global stage's dtype on this
|
||||
profile -- its only remaining readers were face-stage code. SAM was the second one:
|
||||
it never crashed, because it casts its own inputs and leaves through `.float()`, but it
|
||||
@@ -1198,7 +1314,7 @@ within one pipeline.
|
||||
`_cache_static_prompt_embeddings` therefore persists what the text encoder produced
|
||||
under `_model_cache_dir()/prompt-embeddings`, keyed by cache version, model id,
|
||||
pipeline output params, and the exact prompt string. Once that file exists,
|
||||
`_load_qwen` and `_load_zimage` drop the text-encoder `ModelConfig` from the model
|
||||
`_load_global` and `_load_zimage` drop the text-encoder `ModelConfig` from the model
|
||||
stack entirely and serve the stored tensors instead. Measured on an H100 volume in
|
||||
August 2026, that removes **15.45 GiB** (Qwen2.5-VL) and **7.49 GiB** (Z-Image) of a
|
||||
**87.6 GiB** per-request read, worth a median **11.76 s** and **4.10 s** of load time
|
||||
|
||||
@@ -563,8 +563,8 @@ independently and are not compared across detectors. Pass one of those explicit
|
||||
values to restrict the scan to a single provider. The Veo detector recognizes
|
||||
the current four-point diamond and the
|
||||
legacy `Veo` text. Seedance recognizes the boxed `AI` label, Dola recognizes
|
||||
its compact text label, Hailuo recognizes the composite MINIMAX/Hailuo label,
|
||||
and Kling recognizes its bottom-right logo, wordmark, and version suffix. Each
|
||||
its compact text label, Hailuo AI recognizes the composite MINIMAX/Hailuo AI label,
|
||||
and Kling AI recognizes its bottom-right logo, wordmark, and version suffix. Each
|
||||
variant has an independent synthetic silhouette and calibrated temporal policy.
|
||||
After each accepted frame is filled, `temporal_consistency=True` motion-aligns
|
||||
the preceding accepted fill and blends it only when the warped prior mask
|
||||
@@ -587,7 +587,7 @@ to 8-bit SDR.
|
||||
|
||||
## Remove invisible watermarks
|
||||
|
||||
Install `remove-ai-watermarks[qwen-zimage]`. Both profiles need it, and both
|
||||
Install `remove-ai-watermarks[qwen-zimage]`. All three profiles need it, and all
|
||||
need an NVIDIA GPU.
|
||||
|
||||
```python
|
||||
@@ -596,7 +596,7 @@ from pathlib import Path
|
||||
from remove_ai_watermarks.invisible_engine import InvisibleEngine
|
||||
|
||||
engine = InvisibleEngine(
|
||||
pipeline="qwen-zimage", # the default; the only other value is "sdxl-zimage"
|
||||
pipeline="qwen-zimage", # the default; also "sdxl-zimage" or "chroma-zimage"
|
||||
device=None,
|
||||
cpu_offload=False,
|
||||
)
|
||||
@@ -620,7 +620,7 @@ engine = InvisibleEngine(
|
||||
)
|
||||
```
|
||||
|
||||
Both profiles are CUDA-only, so on a machine without an NVIDIA GPU `device=None`
|
||||
All profiles are CUDA-only, so on a machine without an NVIDIA GPU `device=None`
|
||||
resolves to `cpu` and construction raises. For the SDXL global stage instead of
|
||||
Qwen:
|
||||
|
||||
@@ -628,7 +628,7 @@ Qwen:
|
||||
engine = InvisibleEngine(pipeline="sdxl-zimage")
|
||||
```
|
||||
|
||||
The `qwen-zimage` extra is required for both profiles: each runs the same
|
||||
The `qwen-zimage` extra is required for all three profiles: each runs the same
|
||||
DiffSynth Z-Image face stage.
|
||||
|
||||
The opt-in verified-text stage uses the same `text_manifest` argument as the CLI:
|
||||
@@ -644,10 +644,10 @@ 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 allowed: the VAE
|
||||
donor uses the same overlapping tiles as the global pass. `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.
|
||||
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
|
||||
|
||||
@@ -29,6 +29,10 @@ The package version appears in:
|
||||
Update the first two, then refresh the lock file with uv. Do not edit a
|
||||
line-number-specific location in `uv.lock`; its package order changes.
|
||||
|
||||
Before choosing a version, verify it is absent from git tags, GitHub Releases,
|
||||
and PyPI. An unpublished remote tag still reserves that version; never move it,
|
||||
publish the next version instead.
|
||||
|
||||
## Publish flow
|
||||
|
||||
PyPI publishing is triggered by a published GitHub Release, not by a tag push
|
||||
|
||||
@@ -10,7 +10,7 @@ The `visible` command registers these mark keys:
|
||||
|
||||
| Key | Mark | Expected area | Important limit |
|
||||
| --- | --- | --- | --- |
|
||||
| `gemini` | Google Gemini sparkle | Usually bottom right | Detection includes a false positive gate. |
|
||||
| `gemini` | Google Gemini visible watermark (sparkle) | Usually bottom right | Detection includes a false positive gate. |
|
||||
| `doubao` | `豆包AI生成` | Bottom right | Vendor specific text detector. |
|
||||
| `jimeng` | `★ 即梦AI` | Bottom right | Vendor specific text detector. |
|
||||
| `qwen` | `千问AI生成` | Bottom right | Strict visual gate. |
|
||||
@@ -19,7 +19,8 @@ The `visible` command registers these mark keys:
|
||||
| `samsung` | `✦ Contenuti generati dall'AI` | Bottom left | Calibrated for the Italian text variant. |
|
||||
| `runninghub` | `RunningHub AI生成` | Top left | Strict visual and position gates. |
|
||||
| `baidu` | `百度 AI生成` | Bottom right | Detector and extended removal footprint. |
|
||||
| `liblib` | `LibLibAI` | Bottom center | Includes a minimum image size gate. |
|
||||
| `liblib` | `LiblibAI` | Bottom center | Includes a minimum image size gate. |
|
||||
| `microsoft` | One Microsoft white AI-badge variant | Top right | Strict uses the visual gate; auto can use Microsoft provenance for the measured [relaxed gate](module-internals.md#visible-mark-removal). Other documented icon, text, and position variants are not covered. |
|
||||
| `jimeng_pill` | `AI生成` pill | Top left | Weak detector with additional product and background gates. |
|
||||
|
||||
`--mark auto` evaluates all registered marks and removes every selected match.
|
||||
@@ -29,16 +30,21 @@ the masked area.
|
||||
Marks from other vendors are not detected automatically. Use `erase --region`
|
||||
when you can select the affected area yourself.
|
||||
|
||||
Synthetic examples for every registered image and video mark live in the
|
||||
[visible-mark gallery](../data/fixtures/visible/README.md). They demonstrate the
|
||||
detectors' canonical geometry and house style, not vendor raster fidelity.
|
||||
|
||||
### Visible video marks
|
||||
|
||||
| Key | Mark | Motion | Important limit |
|
||||
| --- | --- | --- | --- |
|
||||
| `sora` | Sora 2 mascot and wordmark | Moves among frame positions | Requires a temporally recurring visual match; the older Sora Turbo corner swirl is a different unsupported mark. |
|
||||
| `veo` | Current four-point diamond and legacy `Veo` text | Fixed bottom-right corner | Uses separate silhouettes and requires a recurring match; learned fill is preferable on structured backgrounds. |
|
||||
| `doubao` | `豆包AI生成` text run | Fixed bottom-right corner | Reuses the image engine's synthetic alpha as the template; a stable 12-frame run above 0.35 is required. |
|
||||
| `seedance` | Boxed `AI` label | Fixed bottom-right corner | Requires an anchored recurring match; the full localized box is filled because a thinner synthetic shape mask leaves the real translucent rim behind. |
|
||||
| `dola` | `Dola AI` text | Fixed bottom-right corner | Requires an anchored recurring match; ByteDance or BytePlus provenance can relax only an existing visual run. |
|
||||
| `hailuo` | `MINIMAX \| hailuo AI` composite label | Fixed lower edge | Uses a synthetic waveform, text, separator, and ring silhouette; the complete recurring label box is filled. A TC260 label naming MiniMax as producer can relax only an existing stable run. |
|
||||
| `kling` | Kling swirl, `KLING AI`, version, and optional `PRO` suffix | Fixed bottom-right edge | Combines a synthetic logo rescue with font variants, an edge gate, a white-label gate, and anchored temporal recurrence. |
|
||||
| `kling` | Kling AI swirl, `KLING AI`, version, and optional `PRO` suffix | Fixed bottom-right edge | Combines a synthetic logo rescue with font variants, an edge gate, a white-label gate, and anchored temporal recurrence. |
|
||||
|
||||
`video identify`, `video visible`, and `video all` share this registry and the
|
||||
same temporal arbiter. It is separate from the image registry because selection
|
||||
@@ -165,7 +171,12 @@ This project has no local Content Seal decoder. Meta Model API outputs and
|
||||
Meta CDN copies carry an XMP `iptcExt:DigitalSourceType =
|
||||
trainedAlgorithmicMedia` companion tag, which `identify` reports through the
|
||||
existing Made-with-AI path; that IPTC code is a standard, not a Meta-exclusive
|
||||
signal, so it cannot key a strength cohort the way the C2PA issuer does. The
|
||||
signal, so it cannot key a strength cohort the way the C2PA issuer does. Since
|
||||
0.33.0 a standalone AI digital-source tag (no C2PA manifest) additionally emits
|
||||
the additive `content_seal` signal - the strength router's Meta bet as evidence,
|
||||
medium confidence, with the same caveat - so clients select pixel removal from
|
||||
the signal list exactly the way InvisMark is additive over `soft_binding`. It
|
||||
is an attribution, never a decode. The
|
||||
external oracle is `https://meta.ai/identification`: anonymous, no login,
|
||||
accepts image, video, and audio, enforces an unspecified daily identification
|
||||
limit, and answers with model attribution (`Muse Image 1 - Meta`) plus a
|
||||
@@ -211,6 +222,7 @@ not a universal clean verdict.
|
||||
| OpenAI image generators | None registered | Diffusion regeneration for supported invisible signals | C2PA and generator provenance |
|
||||
| Meta Muse Image | None on Muse output (legacy `Imagined with AI` unregistered) | Diffusion regeneration for Content Seal, oracle-verified on the default profile | XMP IPTC `trainedAlgorithmicMedia` companion tag; no local Content Seal decoder |
|
||||
| Microsoft Paint and Photos | None registered | External Microsoft oracle for InvisMark; no validated local decoder | Paint C2PA soft-binding algorithm and identifier |
|
||||
| Microsoft image outputs (measured variant) | One top-right white AI-badge variant | No registered pixel decoder | C2PA attribution |
|
||||
| Stable Diffusion and SDXL | None registered | Diffusion regeneration; optional open decoder | Embedded parameters and text metadata |
|
||||
| FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources |
|
||||
| Adobe Firefly | None registered | Optional TrustMark Variant P decoder | C2PA |
|
||||
@@ -218,10 +230,10 @@ not a universal clean verdict.
|
||||
| Luma AI | None registered | No registered pixel decoder | PNG text generator tags (Uni-1) |
|
||||
| ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC, supported C2PA, and exact app-export AIGC disclosures |
|
||||
| Qwen | Qwen mark | No registered pixel decoder | TC260 AIGC |
|
||||
| Kling | Kling image and video marks | No registered pixel decoder | TC260 AIGC |
|
||||
| Hailuo / MiniMax video | Hailuo composite video label | No registered pixel decoder | TC260 AIGC where present |
|
||||
| Kling AI | Kling AI image and video marks | No registered pixel decoder | TC260 AIGC |
|
||||
| Hailuo AI / MiniMax video | Hailuo AI composite video label | No registered pixel decoder | TC260 AIGC where present |
|
||||
| Baidu | Baidu mark | No registered pixel decoder | TC260 AIGC |
|
||||
| LibLibAI | LibLibAI mark | No registered pixel decoder | TC260 AIGC |
|
||||
| LiblibAI | LiblibAI mark | No registered pixel decoder | TC260 AIGC |
|
||||
| RunningHub | RunningHub mark | No registered pixel decoder | TC260 AIGC |
|
||||
| Samsung Galaxy AI | One locale specific mark | No registered pixel decoder | C2PA and Samsung markers |
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ from the test set + this doc).
|
||||
watermark. Stop and reconsider.
|
||||
2. **PhotoMaker-V1 prototype** in the existing `controlnet` pipeline:
|
||||
- Mirror the `_load_controlnet_pipeline` path (removed in 0.24.0 with the
|
||||
controlnet profile; the equivalent seam is now `SdxlZImagePipeline._load_sdxl`):
|
||||
controlnet profile; the equivalent seam is now `SdxlZImagePipeline._load_global`):
|
||||
add a PhotoMaker variant that
|
||||
loads SDXL + canny ControlNet + PhotoMaker adapter on the same engine.
|
||||
- Extract the OpenCLIP face embedding from the watermarked face crops (use
|
||||
|
||||
@@ -24,7 +24,7 @@ Performance depends on format, enabled extras, and hardware. Measure it locally
|
||||
|
||||
## Tier A -- self-evident oracles (full corpus, unattended)
|
||||
|
||||
Properties that are true or false without anyone labelling anything. These are the
|
||||
Properties that are true or false without anyone labeling anything. These are the
|
||||
backbone: they scale to large local datasets and catch regressions with zero human cost.
|
||||
|
||||
### A1. Recorded-verdict regression
|
||||
@@ -128,7 +128,7 @@ which read like twelve broken features. It was one bad harness parameter (`--ste
|
||||
sitting on top of one real bug. An error that is IDENTICAL across unrelated rows is
|
||||
evidence of a common cause, not of many faults -- check the shared input first.
|
||||
|
||||
## Tier B -- constructed ground truth (automatable, no labelling)
|
||||
## Tier B -- constructed ground truth (automatable, no labeling)
|
||||
|
||||
Where reality gives no answer key, build one. This is the tier that closes the two biggest
|
||||
holes: fill quality and detector response at the edge of the operating range.
|
||||
@@ -169,7 +169,7 @@ Peak RSS and wall time per backend x input size, up to 25 MP. The memory-constra
|
||||
tier is a real constraint (MI-GAN must stay ~0.6-0.9 GB by cropping around the mask); a
|
||||
regression here is invisible today and would only surface under load.
|
||||
|
||||
## Tier C -- human-labelled accuracy (bounded by labelling effort)
|
||||
## Tier C -- human-labeled accuracy (bounded by labeling effort)
|
||||
|
||||
The machinery exists: `visible_recall_sample.py` -> `visible_sheets.py` ->
|
||||
`visible_groundtruth.py` -> `visible_eval.py`.
|
||||
@@ -291,7 +291,7 @@ on one file. The bar is never "handles it" but **never raises and never silently
|
||||
4. **B2 detector curves** -- cheap, and directly guards the geometry class of bug.
|
||||
5. **A5 contract sweep over a representative local set**.
|
||||
6. **B4 resource ceilings**, **E robustness**.
|
||||
7. **C recall expansion** -- gated by labelling appetite.
|
||||
7. **C recall expansion** -- gated by labeling appetite.
|
||||
8. **D oracles** -- manual, per release.
|
||||
|
||||
Every tier writes a versioned snapshot so runs are comparable over time; a run that cannot
|
||||
@@ -326,7 +326,7 @@ Per mark, what actually goes away when metadata is stripped:
|
||||
|
||||
### Measure before improving
|
||||
|
||||
Use Tier B2 detector-response curves before tuning a detector with sparse labelled
|
||||
Use Tier B2 detector-response curves before tuning a detector with sparse labeled
|
||||
examples. Sweep size, contrast, aspect, and background texture so geometry regressions
|
||||
are visible without exposing private evaluation statistics.
|
||||
|
||||
|
||||
@@ -10,7 +10,7 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are
|
||||
**Root cause and external confirmation (deep-research 2026-06-19, adversarially verified).** This is the SCHEME's ceiling, not our usage — there is no better decoder to adopt. The imwatermark maintainers state verbatim (both the ShieldMnt and Stability-AI READMEs) that the algorithm "cannot guarantee to decode the original watermarks 100% accurately even though we don't apply any attack." Independent measurement (WMAdapter, arXiv:2406.08337 Table 2) puts dwtDct at only **~0.79 bit accuracy on CLEAN images (~38/48 bits — already below our 44 gate)**, collapsing to ~0.50 (chance) under crop/JPEG. Two code-verified + locally-reproduced mechanisms drive the content-dependent failures: (1) the decoder reads each bit as the **highest-magnitude DCT coefficient per block**, so any content coefficient exceeding the encoded target flips the bit; (2) the default embed is in the **YUV chroma channel, which 8-bit-clamps on white/bright pixels** (a +36 chroma delta survives a white-fill round-trip as only +4, ~89% loss) — this is the mechanism behind the bright-flat / minimalist failures and the all-ones degenerate decode. No maintained fork or detector decodes this scheme reliably: the WAVES benchmark (arXiv:2401.08573) relegates DWT-DCT to supplementary appendix G.5 and targets Stable Signature / Tree-Ring / StegaStamp instead; learned encoder/decoder schemes reach ~0.98-0.99 clean but are a DIFFERENT watermark class (not what SDXL/FLUX stamp). `dwtDctSvd` does not help (SDXL embeds `dwtDct`; dwtDctSvd cannot decode it, and its clean accuracy ~0.72 is lower). **Authoritative conclusion: the open DWT-DCT mark cannot be turned from positive-only into a reliable real-world detector; keep it positive-only and rely on C2PA.** (Refuted along the way: that the library is unmaintained, and that it is robust to JPEG but only fails on geometric attacks — both did not survive verification.)
|
||||
|
||||
Consequence for the FLUX hosted-output question (BFL Playground, FLUX.2 [pro] + FLUX.1 [dev], 2026-06-19): all samples carry the signed C2PA manifest (issuer "Black Forest Labs"); the open DWT-DCT decode returned `None`, but every available FLUX carrier (textured fox AND a minimalist-flat generation) failed the positive control (28/48), so the detector is blind on them and **whether BFL hosted output embeds the open pixel watermark is UNRESOLVED** (an earlier note here wrongly asserted it absent — overstated; a later note blamed "high texture" — also wrong, flat carriers fail too). What IS established: C2PA is the reliable FLUX identifier; the `_BITS_48` pattern is correct (round-trips on chatgpt/firefly/random). Resolving the hosted question needs a hosted FLUX carrier that first passes a >=44/48 positive control, which neither a textured nor a flat prompt produced — low priority (the open mark is only a stripped-metadata fallback).
|
||||
- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Copilot + Designer; Bing Image Creator collected 2026-05-24 still signs as "Microsoft" and now runs **MAI-Image**, NOT OpenAI/DALL-E), **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`), and **Canva** (Magic Media signs C2PA as "Canva" + `trainedAlgorithmicMedia` with a generic `c2pa-rs` claim generator, no SynthID — issuer `b"Canva"` → "Canva (Magic Media)"; verified samples disproved the earlier assumption that Canva downloads always strip C2PA). Still unsampled: Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag).
|
||||
- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Copilot + Designer; Bing Image Creator collected 2026-05-24 still signs as "Microsoft" and now runs **MAI-Image**, NOT OpenAI/DALL·E), **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`), and **Canva** (Magic Media signs C2PA as "Canva" + `trainedAlgorithmicMedia` with a generic `c2pa-rs` claim generator, no SynthID — issuer `b"Canva"` → "Canva (Magic Media)"; verified samples disproved the earlier assumption that Canva downloads always strip C2PA). Still unsampled: Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag).
|
||||
|
||||
**Samsung Galaxy AI** signs supported edits with C2PA and may carry the
|
||||
proprietary `genAIType` marker. The registered visible detector covers the
|
||||
@@ -28,7 +28,7 @@ take precedence over issuer attribution. Supported mappings include Higgsfield
|
||||
AI, Topaz Labs Image API, and TikTok Ad Creative Toolbox; an unknown claim
|
||||
generator still falls back to the certificate issuer.
|
||||
|
||||
**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the same content as **"Byteplus Pte. Ltd."**. The bare `volcengine` needle missed it, so BytePlus output was mis-attributed to "Adobe Firefly" through an incidental "Adobe XMP" toolkit string. Issuer `b"Byteplus"` now maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance (Dreamina)" with **`asserts_ai=True`**. Registering the broader **issuer** `b"Bytedance Pte"` was deliberately avoided because that same entity also signs non-AI CapCut edits; keying on the `Dreamina` generator token is precise.
|
||||
**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance Volcano Engine"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the same content as **"Byteplus Pte. Ltd."**. The bare `volcengine` needle missed it, so BytePlus output was mis-attributed to "Adobe Firefly" through an incidental "Adobe XMP" toolkit string. Issuer `b"Byteplus"` maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance Dreamina" with **`asserts_ai=True`**. Registering the broader **issuer** `b"Bytedance Pte"` was deliberately avoided because that same entity also signs non-AI CapCut edits; keying on the `Dreamina` generator token is precise.
|
||||
- **EXIF/XMP/PNG-text generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal). Additional verified generator stamps include **NovelAI** (`Software`, `Source`, and `Title` PNG text chunks), **Reve** (`Software` or XMP `CreatorTool` = `reve.com`), and **Aphrodite AI** (`Make` or `Software` = `Aphrodite AI`).
|
||||
- **App-export provenance and AIGC JSON:** supported ByteDance-family exports can place a JSON object in EXIF `ImageDescription` or `UserComment`, independently of C2PA or TC260. Exact `product` values for Doubao, Xinghui, and Dreamina are removable product provenance, but do not alone prove that the pixels were generated. Dreamina additionally requires `exportType=generation` for that verdict. A nested Aweme `aigc_type=1` or private ByteDance `aigc_label_type=1` / `2` is an AIGC disclosure; `0` is inconclusive and can occur on a Dreamina generation export. Plain Aweme, retouch, and `lv` exports are preserved. The lower-case private field is deliberately not interpreted as the normative TC260 `Label`, whose values `1` / `2` / `3` mean generated / possibly generated / suspected generated under [GB 45438-2025](https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf).
|
||||
- **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).**
|
||||
@@ -58,7 +58,7 @@ defines two more native serializations. AVI stores an `AIGC` child in
|
||||
bounded RIFF and FLV readers validate the JSON field set and skip media
|
||||
payloads. Removal remuxes either container through ffmpeg with stream copy.
|
||||
|
||||
- **HuggingFace-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** HuggingFace Jobs / Spaces can stamp generated PNGs with an `hf-job-id` tEXt chunk holding the job UUID. It marks the *hosting job*, not a model, so it lifts an Unknown verdict to a tentative AI via `hf_only` but never overrides a hard metadata signal. `_HF_JOB_CAVEAT` states the limit. Removal drops the chunk through the PNG metadata whitelist.
|
||||
- **Hugging Face-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** Hugging Face Jobs / Spaces can stamp generated PNGs with an `hf-job-id` tEXt chunk holding the job UUID. It marks the *hosting job*, not a model, so it lifts an Unknown verdict to a tentative AI via `hf_only` but never overrides a hard metadata signal. `_HF_JOB_CAVEAT` states the limit. Removal drops the chunk through the PNG metadata whitelist.
|
||||
- **No detectable signal on some downloads:** Recraft exports and some hosted
|
||||
FLUX surfaces can arrive without a supported local signal. Midjourney samples
|
||||
may carry IPTC metadata but no registered C2PA or pixel watermark. The open
|
||||
@@ -70,7 +70,7 @@ payloads. Removal remuxes either container through ffmpeg with stream copy.
|
||||
- **Built in the dated batch:** soft-binding vendor detection, IPTC Photo
|
||||
Metadata AI-disclosure fields, C2PA detection and stripping for supported
|
||||
ISOBMFF video, the optional Adobe TrustMark decoder, and temporally stabilized
|
||||
visible Sora, Veo, Seedance, Dola, Hailuo, and Kling removal. Other visible
|
||||
visible Sora, Veo, Seedance, Dola, Hailuo AI, and Kling AI removal. Other visible
|
||||
video logos and proprietary audio-watermark detection remain outside the
|
||||
package.
|
||||
Metadata stripping for supported audio containers is a separate implemented
|
||||
@@ -116,12 +116,12 @@ every frame, then requires an anchored temporal run. This extra anchor check
|
||||
was necessary because a moving clean scene detail could retain enough adjacent
|
||||
overlap to pass a recurrence-only gate.
|
||||
|
||||
**Hailuo and Kling use larger fixed composite labels.** Verified Hailuo exports
|
||||
carry a lower-edge waveform, `MINIMAX`, separator, Hailuo ring, and
|
||||
`hailuo AI` text. Verified Kling exports carry a bottom-right swirl,
|
||||
**Hailuo AI and Kling AI use larger fixed composite labels.** Verified Hailuo AI exports
|
||||
carry a lower-edge waveform, `MINIMAX`, separator, Hailuo AI ring, and
|
||||
`hailuo AI` text. Verified Kling AI exports carry a bottom-right swirl,
|
||||
`KLING AI`, a changing version suffix, and sometimes `PRO`. The detectors use
|
||||
only synthetic primitives and fonts. Hailuo expands the matched core to cover
|
||||
the complete composite. Kling combines a version-independent text core with a
|
||||
only synthetic primitives and fonts. Hailuo AI expands the matched core to cover
|
||||
the complete composite. Kling AI combines a version-independent text core with a
|
||||
synthetic ring rescue, then requires the recurring candidate to reach the
|
||||
expected frame edge and contain enough bright low-saturation pixels. Those
|
||||
extra gates were added after clean Luma and PixVerse scene details passed shape
|
||||
@@ -131,17 +131,17 @@ instead uses Florence-2 to identify arbitrary watermarks before LaMa
|
||||
inpainting. That is broader, but it carries a much heavier model and a less
|
||||
auditable detection boundary than the provider-specific synthetic path here.
|
||||
|
||||
**The faint-visible-mark precision/recall wall is fundamental, not a heuristic artifact.** The visible-watermark-detection literature has moved to LEARNED segmentation / object-detection (WDNet WACV'21 arXiv:2012.07616; SLBR ACM MM'21, open code+weights; the PRCV'18 large-scale detector; Su et al. survey 2025), but three verified findings bound what a learned detector actually buys: (1) a claim that a confidence threshold "cleanly separates" true from false matches even with a learned CNN front-end was **REFUTED** in verification (arXiv:1705.08593) -- the precision/recall wall persists even with learned features. (2) Learned detectors need a LARGE, pattern-diverse labeled dataset trained on synthetic composites (PRCV'18: 60k images / 80 watermark classes; CLWD: 60k / 160 marks), and off-distribution degradation is a documented real axis (models trained on limited-pattern LVW transfer worse; diversity of training patterns drives generalization). (3) Inference is cheap (WDNet ~8 ms at 256x256) -- the cost is the data pipeline, not runtime. Net: a learned detector shifts the frontier but does NOT remove the wall; for a SINGLE mark the cheapest next step is a small patch classifier (real-sparkle vs false-positive) on top of the existing NCC localizer, not a full segmentation model. SLBR is a ready baseline. The current NCC + false-positive gate (core-ring brightness margin + gradient-NCC crispness + white-core saturation) is a sound operating point, and the residual miss is the information-theoretic wall the literature confirms.
|
||||
**Learned detectors change the visible-mark precision/recall tradeoff; they do not establish a universal separator.** The visible-watermark-detection literature has moved to learned segmentation and object detection (WDNet WACV'21 arXiv:2012.07616; SLBR ACM MM'21, open code and weights; the PRCV'18 large-scale detector; Su et al. survey 2025). The cited arXiv:1705.08593 paper reports a method that significantly reduces false matches and eliminates them after rejecting a small fraction of matches on its electron-microscopy task; it does not prove an information-theoretic limit for watermark detection. Learned watermark detectors also rely on large, pattern-diverse labeled datasets built with synthetic composites (PRCV'18: 60k images / 80 watermark classes; CLWD: 60k / 160 marks), and transfer depends on the diversity represented in training. Inference can be cheap (WDNet reports roughly 8 ms at 256x256), while the labeled-data and evaluation pipeline remain the larger project cost. A learned detector is therefore a possible future operating point, not evidence that the current NCC gate is theoretically optimal.
|
||||
|
||||
**Visible-mark landscape beyond the registry.** Since Muse Image (2026-07) Meta's own generation output carries NO visible mark, only the invisible proprietary Content Seal; the legacy visible "Imagined with AI" mark (bottom-LEFT, a small symbol) belongs to the pre-2026 Meta AI / "Imagine" pipeline and remains unregistered (string verified, position not). For third-party images Meta relies on C2PA / IPTC, not a visible mark. Samsung Galaxy AI additionally uses a **four-star icon** variant in a corner alongside the localized text wordmark `samsung_engine` calibrates (only the Italian text variant is covered) -- the icon is a distinct, uncovered variant. Every source agrees visible + metadata marks are trivially removable (crop / screenshot, ~2 s), which is the tool's premise.
|
||||
**Visible-mark landscape beyond the registry.** Since Muse Image (2026-07) Meta's own generation output carries no visible mark, only the invisible proprietary Content Seal; the legacy visible "Imagined with AI" mark belongs to the pre-2026 Meta AI / "Imagine" pipeline and remains unregistered (string verified, position not). For third-party images Meta relies on C2PA / IPTC, not a visible mark. The Samsung detector covers only the measured Italian text wordmark; no separate icon-only variant has been verified. Visible overlays and embedded metadata can usually be removed without changing the underlying generation model, which is the tool's premise.
|
||||
|
||||
**Regulatory driver -- China GB 45438-2025 is the strongest VISIBLE-mark mandate.** The CAC / TC260 "Measures for Labeling AI-Generated Synthesized Content" (issued March 2025, **effective 2025-09-01**, technical standard **GB 45438-2025**, building on the TC260 Aug-2023 practice guide) MANDATE a **visible** label for AI images -- a visible textual mark whose height must be **>= 5% of the image's shortest side** -- plus the metadata (implicit) label. Several such CJK text marks are now registered; see [supported signals](supported-signals.md) for the current list. By contrast EU AI Act Article 50 mandates only the MACHINE-READABLE mark (enforceable 2026-08-02, grace to 2026-12-02); a visible label is proposed and modality-specific (visible for images) but is NOT a hard "fixed icon" mandate -- a claim that Art 50 requires a clearly-visible fixed icon for images was refuted in verification. Primary-source dates verified against the article/standard text, not search summaries.
|
||||
|
||||
## Uncovered visible marks: implementation specs (deep-research 2026-07-18)
|
||||
|
||||
Compatibility testing showed that TC260-labelled images can still produce no visible-mark
|
||||
Compatibility testing showed that TC260-labeled images can still produce no visible-mark
|
||||
detection. The main causes were a fixed Doubao localization defect and genuinely
|
||||
uncovered vendors. Verification status is labelled per claim; treat (b)/(c) as leads,
|
||||
uncovered vendors. Verification status is labeled per claim; treat (b)/(c) as leads,
|
||||
not ground truth.
|
||||
|
||||
**GB 45438-2025 clause 5.2, the binding constraint for every Chinese mark (VERIFIED (a) -- full standard text extracted from the TC260-hosted PDF).** Verbatim requirements for an image's explicit label:
|
||||
@@ -153,7 +153,7 @@ not ground truth.
|
||||
|
||||
Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a compliant CN mark's glyphs are large (>= 51 px on a 1024² image), so a CN silhouette ladder can be anchored at ~5-10% of the short side instead of swept broadly, which should cut false fires; (2) every compliant string shares the tail `AI生成` / `AI合成`, so a shared suffix silhouette plus a per-vendor prefix may beat five independent templates. NOT established: whether 文字高度 means cap height, em box, or rendered bounding box (a ~1.3x spread in template scale). Sources: `https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf`, parent CAC measure `https://www.cac.gov.cn/2025-03/14/c_1743654684782215.htm` (the CAC text itself specifies no size or corner).
|
||||
|
||||
**Alibaba Qwen -- two surfaces that differ (API tier VERIFIED (a)).** Model Studio docs state verbatim that the API adds a `Qwen-Image` watermark 在图像右下角 and 默认值为 false -- so API output is **unwatermarked by default**, and when enabled the mark is a LATIN wordmark, not CJK, and not GB-compliant in wording. The consumer app's `千问AI生成` (bottom-right) is (b) secondary only -- no Alibaba primary page states it. So Qwen needs TWO templates, and its absence is never evidence of a clean image. Source: `https://help.aliyun.com/zh/model-studio/qwen-image-api`.
|
||||
**Alibaba Cloud Qwen -- two surfaces that differ (API tier VERIFIED (a)).** Model Studio docs state verbatim that the API adds a `Qwen-Image` watermark 在图像右下角 and 默认值为 false -- so API output is **unwatermarked by default**, and when enabled the mark is a LATIN wordmark, not CJK, and not GB-compliant in wording. The consumer app's `千问AI生成` (bottom-right) is (b) secondary only -- no Alibaba primary page states it. So Qwen needs TWO templates, and its absence is never evidence of a clean image. Source: `https://help.aliyun.com/zh/model-studio/qwen-image-api`.
|
||||
|
||||
**星绘 is ByteDance (VERIFIED (a): Baidu Baike + App Store listing, now branded 豆包旗下, team folded into Doubao April 2025).** So `星绘AI生成` is very likely the Doubao house style -- same typeface, same corner, possibly the same top-left `AI生成` pill. Starting from the Doubao `TextMarkConfig` and swapping the two lead glyphs is the cheap path. String/position themselves are (c) inferred.
|
||||
|
||||
@@ -165,7 +165,7 @@ Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a comp
|
||||
|
||||
**Meta `Imagined with AI` (string VERIFIED (a) from Meta's own newsroom; POSITION NOT VERIFIED).** Sources conflict on placement. Do not encode a corner without a verified sample. A dedicated 2026-08-27 sample hunt failed to obtain one: the Feb-2024 newsroom images are UI mockups whose photos carry no in-pixel mark; community posts (Threads `C8_rS_MuEId` titled "lower left corner", a Facebook share) corroborate bottom-LEFT verbally but their files defeat pixel verification; press screenshots predate the mark (Dec 2023); `imagine.meta.com` is dead (redirects to meta.ai) and its Wayback captures are broken SPA error pages. The mark's generator no longer exists (Muse output has no visible mark), so no fresh sample can be made. Removal stays on the generic `erase --region` path until a legacy capture surfaces. Source: `https://about.fb.com/news/2024/02/labeling-ai-generated-images-on-facebook-instagram-and-threads/`.
|
||||
|
||||
**Samsung English/other locales: still not established.** Samsung's own support page says only that "A Galaxy AI watermark will appear on AI-generated images" -- no string, no corner. Every community thread carrying the exact English string returned HTTP 403 to WebFetch, so the search paraphrase (bottom-left) is deliberately NOT recorded as fact. Feature-tier detail (b): the mark is applied by Generative Edit / sketch-to-image but reportedly NOT by Object Eraser, so Samsung absence is feature-dependent. The four-star icon variant: nothing found.
|
||||
**Samsung English/other locales: still not established.** Samsung's own support page says only that "A Galaxy AI watermark will appear on AI-generated images" -- no string, no corner. Every community thread carrying the exact English string returned HTTP 403 to WebFetch, so the search paraphrase (bottom-left) is deliberately NOT recorded as fact. Feature-tier detail (b): the mark is applied by Generative Edit / sketch-to-image but reportedly NOT by Object Eraser, so Samsung absence is feature-dependent. No separate icon-only variant was established.
|
||||
|
||||
**The one document that would settle ByteDance placement is BLOCKED.** Douyin's 《抖音关于人工智能生成内容标识的水印与元数据规范》 aims to give AI tools a unified watermark style and position, which would cover Doubao / Jimeng / 星绘 at once. Both mirrors return HTTP 403 to WebFetch; a secondary report (b, unconfirmed) says the watermark is `AI生成` + tool name + company name placed **top-left** -- which would explain the Jimeng pill's top-left position but contradicts the GB annex's bottom-right example. Worth one retry through Chrome MCP with a real browser session.
|
||||
|
||||
|
||||
@@ -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.32.1"
|
||||
version = "0.35.0"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
|
||||
@@ -0,0 +1,69 @@
|
||||
# Maintainer scripts
|
||||
|
||||
These scripts are development and evaluation tools, not installed CLI commands. Run
|
||||
them from the repository root with `uv run python scripts/<name>.py --help`. Inputs
|
||||
under `.local-eval/` and generated reports remain untracked unless a data README
|
||||
explicitly names a tracked canonical result.
|
||||
|
||||
## Audits and release checks
|
||||
|
||||
| Script | Purpose |
|
||||
| --- | --- |
|
||||
| `corpus_gap_scan.py` | Compare a local image corpus with the library's `identify` results. |
|
||||
| `detection_timing.py` | Record per-method metadata and verdict timings. |
|
||||
| `detection_timing_report.py` | Aggregate timing records by method and segment. |
|
||||
| `fidelity_metrics.py` | Compute objective image-fidelity metrics for paired outputs. |
|
||||
| `invisible_quality_audit.py` | Pair originals and invisible-removal outputs for quality review. |
|
||||
| `metadata_removal_audit.py` | Check metadata detection/removal parity over a corpus. |
|
||||
| `pill_gate_audit.py` | Measure the Jimeng pill detector on the product path. |
|
||||
| `real_examples_e2e.py` | Run end-to-end confidence checks over local examples. |
|
||||
| `record_parity_audit.py` | Compare record-based and file-based identification. |
|
||||
| `resource_ceilings.py` | Measure peak RSS and runtime for fill backends. |
|
||||
| `robustness_suite.py` | Exercise CLI failures on adversarial and degenerate inputs. |
|
||||
| `sidecar_regression.py` | Compare current identification with recorded sidecars. |
|
||||
| `smoke_matrix.py` | Exercise CLI parameter choices on real local data. |
|
||||
| `video_fidelity_probe.py` | Compare delivered video fidelity with its source. |
|
||||
| `visible_eval.py` | Benchmark registered visible-mark detectors. |
|
||||
| `visible_removal_audit.py` | Audit visible-removal results over a local corpus. |
|
||||
|
||||
## Calibration and corpus preparation
|
||||
|
||||
| Script | Purpose |
|
||||
| --- | --- |
|
||||
| `contentseal_transforms.py` | Reproduce and hash-check deterministic Content Seal variants. |
|
||||
| `detector_response.py` | Measure detector response over mark size, contrast, background, and aspect. |
|
||||
| `fill_quality.py` | Measure visible-fill quality against constructed ground truth. |
|
||||
| `ladder_headroom.py` | Measure recall cost from the coarse scale ladder. |
|
||||
| `registered_mark_calibrate.py` | Measure a registered detector without conflating visual positives, metadata cohorts, adjudicated negatives, and unlabeled controls. |
|
||||
| `synthid_corpus.py` | Ingest and inspect the local SynthID reference corpus. |
|
||||
| `vendor_cohort_harvest.py` | Partition TC260 carriers by producer code. |
|
||||
| `vendor_mark_calibrate.py` | Calibrate a candidate vendor text detector. |
|
||||
| `visible_alpha_solve.py` | Rebuild visible-watermark alpha assets from controlled captures. |
|
||||
| `visible_groundtruth.py` | Consolidate blinded contact-sheet labels into ground truth. |
|
||||
| `visible_positives.py` | List corpus images carrying a registered visible mark. |
|
||||
| `visible_recall_sample.py` | Build an unbiased blinded sample for recall measurement. |
|
||||
| `visible_sheets.py` | Build blinded contact sheets for relaxation candidates. |
|
||||
|
||||
## Research and diagnostic prototypes
|
||||
|
||||
| Script | Purpose |
|
||||
| --- | --- |
|
||||
| `cjk_tail_probe.py` | Test a generic template for otherwise uncovered CJK labels. |
|
||||
| `controlnet_sweep.py` | Sweep the historical ControlNet removal prototype. |
|
||||
| `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. |
|
||||
| `synthid_pixel_probe.py` | Run the experimental local SynthID carrier probe. |
|
||||
| `video_synthid_sweep.py` | Build oracle-gated video regeneration candidates. |
|
||||
|
||||
## Generated assets
|
||||
|
||||
| Script | Purpose |
|
||||
| --- | --- |
|
||||
| `render_pill_silhouette.py` | Render the synthetic Jimeng pill silhouette. |
|
||||
| `render_vendor_silhouettes.py` | Render synthetic vendor text-mark silhouettes. |
|
||||
|
||||
## Shared helpers
|
||||
|
||||
`_plain_console.py` provides plain-text fallbacks for Rich output, and
|
||||
`_text_eval.py` contains normalization helpers shared by text-evaluation scripts.
|
||||
@@ -0,0 +1,38 @@
|
||||
# Unregistered visible-mark candidates
|
||||
|
||||
Synthetic detection silhouettes for marks that are measured but NOT registered:
|
||||
none of these separates its positives from clean negatives yet, so shipping them
|
||||
would attribute and fill corners on content that carries no mark (a false fill
|
||||
is the worse error). Assets are regenerated by
|
||||
`scripts/render_vendor_silhouettes.py`; a candidate is deleted from here on the
|
||||
day its mark registers (the asset moves to the package `assets/`).
|
||||
|
||||
Measurements below are local calibration snapshots. Candidate pools are
|
||||
provenance cohorts, not automatically visible-mark positives; comparison controls
|
||||
are independently selected no-signal images and are not adjudicated negatives.
|
||||
Each needs capture-solved alphas or vendor-accurate font work before it can
|
||||
ship; `scripts/vendor_mark_calibrate.py` is the candidate-detector harness.
|
||||
Rows through `mindvideo_alpha.png` and the parked candidates were recorded on
|
||||
2026-08-27; rows from `higgsfield_alpha.png` through `gendo_alpha.png` were
|
||||
recorded on 2026-08-28. These are repository record dates, not inferred capture
|
||||
dates. The Mark column quotes the candidate raster text; where that text differs
|
||||
from the provider's canonical name, the provider is named separately.
|
||||
|
||||
| Asset | Mark | Evidence | Result |
|
||||
| --- | --- | --- | --- |
|
||||
| `samsung_en_alpha.png` | Samsung Galaxy AI label, English locale ("AI-generated content", bottom-left) | 5 corpus files | POS 0.11-0.30 vs NEG max 0.40 (binary); tophat/gray tried, no separation. The registered Italian engine scores 0.18-0.31 on the same files -- same layout class, wrong glyph template. |
|
||||
| `gemini_text_alpha.png` | "Generated with Gemini" text label (bottom-right; the registered gemini mark is the sparkle icon) | 3 corpus files | POS 0.07-0.22 vs NEG max 0.32; coverage gate finds the blob (0.29-0.35) but the Arial silhouette misses Google's letterforms. |
|
||||
| `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right) | 12 corpus files | locate geometry not yet fitted; POS max 0.12. |
|
||||
| `dola_alpha.png` | DolaAI on images (the video mark is registered) | 12 corpus files | POS 0.11-0.21 vs NEG max 0.30. |
|
||||
| `mindvideo_alpha.png` | MindVideo.AI (top-right) | 11 corpus files | POS 0.29-0.32 vs NEG max 0.30 -- borderline overlap, not shippable. |
|
||||
| `higgsfield_alpha.png` | `HIGGSFIELD AI` wordmark (Higgsfield AI, bottom-right; the boxed `AI` variant shares the cohort) | 5 wordmark files (16 in the boxed-AI OCR cluster) | POS max 0.26 vs NEG max 0.22 -- no separation; the mark may be two-part (wordmark + boxed AI) and needs a composed template. |
|
||||
| `jianying_alpha.png` | `剪映AI` wordmark (Jianying / 剪映, the China product in the CapCut family; bottom-right) | 2 corpus files | POS 0.29 vs NEG max 0.35. |
|
||||
| `capcut_alpha.png` | CapCut AI pill (top-left; likely pill class, not plain text) | 3 corpus files | POS max 0.15 vs NEG max 0.33 -- locate geometry not yet fitted for the pill form. |
|
||||
| `zsky_alpha.png` | `MADE WITH zsky.ai` wordmark (ZSky AI, bottom-right) | 2 corpus files | POS 0.10 vs NEG max 0.27. |
|
||||
| `chromastudio_alpha.png` | `ChromaStudio.ai` wordmark (ChromaStudio, bottom-right) | 2 corpus files | POS 0.11 vs NEG max 0.29. |
|
||||
| `digenai_alpha.png` | `DIGENAI` wordmark (Digen AI, bottom-right) | 3 corpus files (one 2026-07-24 batch) | POS 0.16 vs NEG max 0.31. |
|
||||
| `gendo_alpha.png` | `GendoAI` wordmark (Gendo, bottom-left) | 3 corpus files | POS 0.08 vs NEG max 0.32. |
|
||||
| `xinghui_alpha.png` | 星绘AI生成 (parked before this set) | -- | prior parking, unchanged. |
|
||||
| `qingyan_alpha.png` | 清言·AI生成 (parked before this set) | -- | prior parking, unchanged. |
|
||||
| `hailuo_alpha.png` | Hailuo AI image wordmark (parked before this set; the VIDEO label is registered) | -- | prior parking, unchanged. |
|
||||
| `catlogo_alpha.png` | outline cat-head + AI生成 (parked before this set) | -- | prior parking, unchanged. |
|
||||
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 2.6 KiB |
|
After Width: | Height: | Size: 2.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.4 KiB |
|
After Width: | Height: | Size: 1.9 KiB |
|
After Width: | Height: | Size: 3.9 KiB |
|
After Width: | Height: | Size: 3.0 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
|
After Width: | Height: | Size: 1.7 KiB |
|
After Width: | Height: | Size: 2.7 KiB |
@@ -0,0 +1,114 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["modal>=1.5"]
|
||||
# ///
|
||||
"""Extra high Chroma1 rungs (0.25-0.40) for the Google cohort fixtures.
|
||||
|
||||
The 0.08-0.20 ladder came back detected end-to-end on the first fixture, so
|
||||
this appends the high rungs for every Google fixture without touching the
|
||||
already-oracled lower outputs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import modal
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
app = modal.App("chroma-google-high-rungs")
|
||||
|
||||
image = (
|
||||
modal.Image.debian_slim(python_version="3.12")
|
||||
.pip_install(
|
||||
"torch",
|
||||
"diffusers>=0.38,<1",
|
||||
"transformers>=4.53",
|
||||
"accelerate",
|
||||
"sentencepiece",
|
||||
"protobuf",
|
||||
"pillow",
|
||||
"numpy",
|
||||
)
|
||||
.env({"HF_HOME": "/cache"})
|
||||
)
|
||||
|
||||
cache = modal.Volume.from_name("chroma-hf-cache", create_if_missing=True)
|
||||
|
||||
CHROMA_MODEL_ID = "lodestones/Chroma1-HD"
|
||||
RUNGS = (0.25, 0.30, 0.35, 0.40)
|
||||
EFFECTIVE_STEPS = 4
|
||||
_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
||||
_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
|
||||
FIXTURES = (
|
||||
"Gemini_Generated_Image_633uuy633uuy633u.png",
|
||||
"Gemini_Generated_Image_akdbeiakdbeiakdb.png",
|
||||
"Gemini_Generated_Image_y48j3cy48j3cy48j.png",
|
||||
"Gemini_Generated_Image_3mc4t93mc4t93mc4.png",
|
||||
)
|
||||
MAX_SIDE = 1536
|
||||
|
||||
|
||||
@app.function(image=image, gpu="H100", volumes={"/cache": cache}, timeout=2400)
|
||||
def run(payload: dict[str, bytes]) -> dict:
|
||||
import io
|
||||
import time
|
||||
|
||||
import torch
|
||||
from diffusers import ChromaImg2ImgPipeline
|
||||
from PIL import Image
|
||||
|
||||
chroma = ChromaImg2ImgPipeline.from_pretrained(CHROMA_MODEL_ID, torch_dtype=torch.bfloat16).to("cuda")
|
||||
out: dict = {}
|
||||
for name, data in payload.items():
|
||||
source = Image.open(io.BytesIO(data)).convert("RGB")
|
||||
if MAX_SIDE and max(source.size) > MAX_SIDE:
|
||||
scale = MAX_SIDE / max(source.size)
|
||||
source = source.resize(
|
||||
(round(source.width * scale), round(source.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
width = max(16, (source.width // 16) * 16)
|
||||
height = max(16, (source.height // 16) * 16)
|
||||
for strength in RUNGS:
|
||||
started = time.perf_counter()
|
||||
generator = torch.Generator(device="cpu").manual_seed(0)
|
||||
image = chroma(
|
||||
prompt=_PROMPT,
|
||||
negative_prompt=_NEGATIVE,
|
||||
image=source,
|
||||
width=width,
|
||||
height=height,
|
||||
strength=strength,
|
||||
num_inference_steps=max(1, math.ceil(EFFECTIVE_STEPS / strength)),
|
||||
guidance_scale=5.0,
|
||||
generator=generator,
|
||||
).images[0]
|
||||
if image.size != source.size:
|
||||
image = image.resize(source.size, Image.Resampling.LANCZOS)
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
out[f"{Path(name).stem}/chroma_s{strength:.2f}"] = {
|
||||
"png": buffer.getvalue(),
|
||||
"seconds": round(time.perf_counter() - started, 2),
|
||||
}
|
||||
print(f"{name} s={strength} done", flush=True)
|
||||
cache.commit()
|
||||
return out
|
||||
|
||||
|
||||
@app.local_entrypoint()
|
||||
def main(out: str = "out/cohort-calibration/google") -> None:
|
||||
payload = {name: (_ROOT / "data" / "synthid" / "originals" / name).read_bytes() for name in FIXTURES}
|
||||
results = run.remote(payload)
|
||||
timing = {}
|
||||
for key, item in results.items():
|
||||
destination = Path(out) / f"{key}.png"
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(item["png"])
|
||||
timing[key] = item["seconds"]
|
||||
print(json.dumps(timing, indent=2))
|
||||
@@ -0,0 +1,109 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["modal>=1.5"]
|
||||
# ///
|
||||
"""Extra high Chroma1 rungs for the Meta Content Seal cohort.
|
||||
|
||||
The first calibration ladder topped out at 0.08 and the worst fixture
|
||||
(gen_lighthouse_watercolor) was still detected there, so this appends 0.10,
|
||||
0.12 and 0.15 for every Meta fixture without touching the already-oracled
|
||||
lower rungs.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import modal
|
||||
|
||||
app = modal.App("chroma-meta-high-rungs")
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
image = (
|
||||
modal.Image.debian_slim(python_version="3.12")
|
||||
.pip_install(
|
||||
"torch",
|
||||
"diffusers>=0.38,<1",
|
||||
"transformers>=4.53",
|
||||
"accelerate",
|
||||
"sentencepiece",
|
||||
"protobuf",
|
||||
"pillow",
|
||||
"numpy",
|
||||
)
|
||||
.env({"HF_HOME": "/cache"})
|
||||
)
|
||||
|
||||
cache = modal.Volume.from_name("chroma-hf-cache", create_if_missing=True)
|
||||
|
||||
CHROMA_MODEL_ID = "lodestones/Chroma1-HD"
|
||||
RUNGS = (0.17,)
|
||||
EFFECTIVE_STEPS = 4
|
||||
_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
||||
_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
|
||||
META_FIXTURES = (
|
||||
"gen_fox_forest.webp",
|
||||
"gen_text_poster.webp",
|
||||
"gen_lighthouse_watercolor.webp",
|
||||
"gen_night_city.webp",
|
||||
"gen_studio_mug.webp",
|
||||
)
|
||||
|
||||
|
||||
@app.function(image=image, gpu="H100", volumes={"/cache": cache}, timeout=1800)
|
||||
def run(payload: dict[str, bytes]) -> dict:
|
||||
import io
|
||||
import time
|
||||
|
||||
import torch
|
||||
from diffusers import ChromaImg2ImgPipeline
|
||||
from PIL import Image
|
||||
|
||||
chroma = ChromaImg2ImgPipeline.from_pretrained(CHROMA_MODEL_ID, torch_dtype=torch.bfloat16).to("cuda")
|
||||
out: dict = {}
|
||||
for name, data in payload.items():
|
||||
source = Image.open(io.BytesIO(data)).convert("RGB")
|
||||
width = max(16, (source.width // 16) * 16)
|
||||
height = max(16, (source.height // 16) * 16)
|
||||
for strength in RUNGS:
|
||||
started = time.perf_counter()
|
||||
generator = torch.Generator(device="cpu").manual_seed(0)
|
||||
image = chroma(
|
||||
prompt=_PROMPT,
|
||||
negative_prompt=_NEGATIVE,
|
||||
image=source,
|
||||
width=width,
|
||||
height=height,
|
||||
strength=strength,
|
||||
num_inference_steps=max(1, math.ceil(EFFECTIVE_STEPS / strength)),
|
||||
guidance_scale=5.0,
|
||||
generator=generator,
|
||||
).images[0]
|
||||
if image.size != source.size:
|
||||
image = image.resize(source.size, Image.Resampling.LANCZOS)
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
out[f"{Path(name).stem}/chroma_s{strength:.3f}"] = {
|
||||
"png": buffer.getvalue(),
|
||||
"seconds": round(time.perf_counter() - started, 2),
|
||||
}
|
||||
print(f"{name} s={strength} done", flush=True)
|
||||
cache.commit()
|
||||
return out
|
||||
|
||||
|
||||
@app.local_entrypoint()
|
||||
def main(out: str = "out/cohort-calibration/meta") -> None:
|
||||
payload = {name: (_ROOT / "data" / "contentseal" / "originals" / name).read_bytes() for name in META_FIXTURES}
|
||||
results = run.remote(payload)
|
||||
timing = {}
|
||||
for key, item in results.items():
|
||||
destination = Path(out) / f"{key}.png"
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(item["png"])
|
||||
timing[key] = item["seconds"]
|
||||
print(json.dumps(timing, indent=2))
|
||||
@@ -0,0 +1,142 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["modal>=1.5"]
|
||||
# ///
|
||||
"""Chroma1 ladder for the Microsoft InvisMark cohort (three valid Paint sources)
|
||||
plus the matching qwen-zimage production runs at the shipped 0.15 floor.
|
||||
|
||||
The sources are the same three Paint carriers behind the qwen cohort floor
|
||||
(staged under out/cohort-calibration/microsoft/<label>/original.png with
|
||||
pixel-identical metadata-stripped controls). They are real user uploads from
|
||||
the raiw corpus and must never be committed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
import modal
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
app = modal.App("chroma-microsoft-ladder")
|
||||
|
||||
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",
|
||||
)
|
||||
.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)
|
||||
|
||||
CHROMA_MODEL_ID = "lodestones/Chroma1-HD"
|
||||
CHROMA_RUNGS = (0.04, 0.06, 0.08, 0.10, 0.125, 0.15, 0.20)
|
||||
QWEN_FLOOR = 0.15
|
||||
EFFECTIVE_STEPS = 4
|
||||
_SOURCES = ("paint-1", "paint-2", "paint-3")
|
||||
_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=2400)
|
||||
def run(payload: dict[str, bytes]) -> dict:
|
||||
import io
|
||||
import sys
|
||||
import time
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, "/pkg")
|
||||
|
||||
def chroma_call(chroma: object, source: Image.Image, strength: float, seed: int) -> Image.Image:
|
||||
width = max(16, (source.width // 16) * 16)
|
||||
height = max(16, (source.height // 16) * 16)
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
out = 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]
|
||||
return out if out.size == source.size else out.resize(source.size, Image.Resampling.LANCZOS)
|
||||
|
||||
def png_bytes(image: Image.Image) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
from diffusers import ChromaImg2ImgPipeline
|
||||
|
||||
chroma = ChromaImg2ImgPipeline.from_pretrained(CHROMA_MODEL_ID, torch_dtype=torch.bfloat16).to("cuda")
|
||||
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
||||
|
||||
qwen = QwenZImagePipeline(
|
||||
device="cuda",
|
||||
torch_dtype=torch.bfloat16,
|
||||
keep_global_models_on_device=True,
|
||||
keep_face_models_on_device=True,
|
||||
)
|
||||
|
||||
out: dict = {}
|
||||
for label in _SOURCES:
|
||||
source = Image.open(io.BytesIO(payload[label])).convert("RGB")
|
||||
rungs: dict = {}
|
||||
for strength in CHROMA_RUNGS:
|
||||
started = time.perf_counter()
|
||||
image = chroma_call(chroma, source, strength, 0)
|
||||
rungs[f"{strength:.4f}"] = {
|
||||
"png": png_bytes(image),
|
||||
"seconds": round(time.perf_counter() - started, 2),
|
||||
}
|
||||
print(f"{label} chroma s={strength} done", flush=True)
|
||||
started = time.perf_counter()
|
||||
qwen_out = qwen.run(source, strength=QWEN_FLOOR, seed=0)
|
||||
out[label] = {
|
||||
"chroma": rungs,
|
||||
"qwen_seconds": round(time.perf_counter() - started, 2),
|
||||
"qwen_png": png_bytes(qwen_out),
|
||||
}
|
||||
print(f"{label} qwen s={QWEN_FLOOR} done", flush=True)
|
||||
|
||||
cache.commit()
|
||||
return out
|
||||
|
||||
|
||||
@app.local_entrypoint()
|
||||
def main(staged: str = "out/cohort-calibration/microsoft") -> None:
|
||||
staged_root = Path(staged)
|
||||
payload = {label: (staged_root / label / "original.png").read_bytes() for label in _SOURCES}
|
||||
results = run.remote(payload)
|
||||
summary: dict = {}
|
||||
for label, entry in results.items():
|
||||
target = staged_root / label
|
||||
for strength, item in entry["chroma"].items():
|
||||
(target / f"chroma_s{float(strength):.4f}.png").write_bytes(item["png"])
|
||||
(target / "qwen_floor.png").write_bytes(entry["qwen_png"])
|
||||
summary[label] = {
|
||||
"chroma_seconds": {k: v["seconds"] for k, v in entry["chroma"].items()},
|
||||
"qwen_seconds": entry["qwen_seconds"],
|
||||
}
|
||||
print(json.dumps(summary, indent=2))
|
||||
@@ -0,0 +1,152 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["modal>=1.5"]
|
||||
# ///
|
||||
"""Pre-ship validation outputs for the chroma-zimage profile.
|
||||
|
||||
1. Seed sensitivity of the measured boundaries: chroma at the worst-fixture
|
||||
first-clean rungs (google 633uuy @ 0.25, meta lighthouse @ 0.10) with
|
||||
seeds 1 and 2, to check the boundary is not a seed-0 fluke.
|
||||
2. Face-stage interaction: the FULL chroma-zimage pipeline (Chroma1 global at
|
||||
the shipped Google floor 0.40, then the inherited YuNet/SAM/Z-Image face
|
||||
stage) on the 5-face fixture 3mc4t9, to verify the composited face regions
|
||||
do not re-introduce a detectable signal on top of the floor.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import modal
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
app = modal.App("chroma-preship-validation")
|
||||
|
||||
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",
|
||||
)
|
||||
.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)
|
||||
|
||||
CHROMA_MODEL_ID = "lodestones/Chroma1-HD"
|
||||
_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
||||
_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
GOOGLE_FLOOR = 0.40
|
||||
MAX_SIDE = 1536
|
||||
|
||||
|
||||
@app.function(image=image, gpu="H100", volumes={"/cache": cache}, timeout=2400)
|
||||
def run(payload: dict[str, bytes]) -> dict:
|
||||
import io
|
||||
import time
|
||||
|
||||
import torch
|
||||
from diffusers import ChromaImg2ImgPipeline
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, "/pkg")
|
||||
|
||||
def chroma_call(pipe: object, source: Image.Image, strength: float, seed: int) -> Image.Image:
|
||||
width = max(16, (source.width // 16) * 16)
|
||||
height = max(16, (source.height // 16) * 16)
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
out = pipe(
|
||||
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=generator,
|
||||
).images[0]
|
||||
return out if out.size == source.size else out.resize(source.size, Image.Resampling.LANCZOS)
|
||||
|
||||
def png_bytes(image: Image.Image) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
out: dict = {}
|
||||
|
||||
chroma = ChromaImg2ImgPipeline.from_pretrained(CHROMA_MODEL_ID, torch_dtype=torch.bfloat16).to("cuda")
|
||||
|
||||
# Seed sensitivity at the worst-fixture boundaries.
|
||||
for key, strength in (("google_633uuy", 0.25), ("meta_lighthouse", 0.10)):
|
||||
source = Image.open(io.BytesIO(payload[key])).convert("RGB")
|
||||
if MAX_SIDE and max(source.size) > MAX_SIDE:
|
||||
scale = MAX_SIDE / max(source.size)
|
||||
source = source.resize(
|
||||
(round(source.width * scale), round(source.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
for seed in (1, 2):
|
||||
started = time.perf_counter()
|
||||
image = chroma_call(chroma, source, strength, seed)
|
||||
out[f"seedprobe_{key}_s{strength}_seed{seed}"] = png_bytes(image)
|
||||
print(f"{key} s={strength} seed={seed} ({time.perf_counter() - started:.1f}s)", flush=True)
|
||||
|
||||
del chroma
|
||||
import gc
|
||||
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
# Full chroma-zimage pipeline on the 5-face fixture at the shipped floor.
|
||||
from remove_ai_watermarks._internal.chroma_zimage_pipeline import ChromaZImagePipeline
|
||||
|
||||
full = ChromaZImagePipeline(
|
||||
device="cuda",
|
||||
torch_dtype=torch.bfloat16,
|
||||
keep_global_models_on_device=True,
|
||||
keep_face_models_on_device=True,
|
||||
)
|
||||
source = Image.open(io.BytesIO(payload["google_3mc4t9"])).convert("RGB")
|
||||
if MAX_SIDE and max(source.size) > MAX_SIDE:
|
||||
scale = MAX_SIDE / max(source.size)
|
||||
source = source.resize(
|
||||
(round(source.width * scale), round(source.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
started = time.perf_counter()
|
||||
result = full.run(source, strength=GOOGLE_FLOOR, seed=0)
|
||||
out["fullpath_3mc4t9_s0.40"] = png_bytes(result)
|
||||
print(f"full pipeline 3mc4t9 @ {GOOGLE_FLOOR} ({time.perf_counter() - started:.1f}s)", flush=True)
|
||||
|
||||
cache.commit()
|
||||
return out
|
||||
|
||||
|
||||
@app.local_entrypoint()
|
||||
def main(out: str = "out/preship-chroma") -> None:
|
||||
sources = {
|
||||
"google_633uuy": _ROOT / "data/synthid/originals/Gemini_Generated_Image_633uuy633uuy633u.png",
|
||||
"meta_lighthouse": _ROOT / "data/contentseal/originals/gen_lighthouse_watercolor.webp",
|
||||
"google_3mc4t9": _ROOT / "data/synthid/originals/Gemini_Generated_Image_3mc4t93mc4t93mc4.png",
|
||||
}
|
||||
results = run.remote({k: p.read_bytes() for k, p in sources.items()})
|
||||
root = Path(out)
|
||||
root.mkdir(parents=True, exist_ok=True)
|
||||
for key, png in results.items():
|
||||
(root / f"{key}.png").write_bytes(png)
|
||||
print(json.dumps(sorted(results), indent=2))
|
||||
@@ -0,0 +1,188 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["modal>=1.5"]
|
||||
# ///
|
||||
"""Isolated Modal GPU prototype: does a low-strength Chroma1 img2img pass scrub
|
||||
the invisible watermark while keeping text/faces faithful?
|
||||
|
||||
Chroma1 (lodestones, Apache-2.0) is the FLUX.1 architecture re-trained on open
|
||||
data, and diffusers ships it a native strength img2img pipeline, which is exactly
|
||||
what FLUX.2 lacks (see docs/synthid.md and issue #88). This script is the same
|
||||
shape as qwen_scrub_prototype.py: standalone, not imported by the package and
|
||||
not in uv.lock, so the locked environment never moves for an experiment.
|
||||
|
||||
Run from the repository root (weights ~34 GB download once into a Modal volume,
|
||||
then every later run starts warm):
|
||||
|
||||
uvx modal run scripts/chroma_scrub_prototype.py \
|
||||
--source data/synthid/originals/'ChatGPT Image May 31, 2026, 02_03_55 PM.png' \
|
||||
--out out/chroma --strengths 0.05,0.1,0.15,0.2
|
||||
|
||||
A different GPU (default H100, ~$3.95/h; the 34 GB bf16 stack also fits
|
||||
A100-80GB at ~$2.50/h):
|
||||
|
||||
CHROMA_GPU=A100-80GB uvx modal run scripts/chroma_scrub_prototype.py ...
|
||||
|
||||
What to look for (same protocol as the Qwen study, docs/synthid.md 2.2):
|
||||
|
||||
* SCRUB: the oracle no longer reports the watermark at some strength
|
||||
(openai.com/verify for OpenAI carriers, the Gemini app for Google ones).
|
||||
* FIDELITY: text and faces stay faithful at that same strength; score with
|
||||
scripts/fidelity_metrics.py after the oracle pass.
|
||||
The smallest strength that clears the oracle while keeping fidelity is the
|
||||
number to compare against the qwen-zimage floors (OpenAI 0.07675 / Google 0.25).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import modal
|
||||
|
||||
_GPU = os.environ.get("CHROMA_GPU", "H100")
|
||||
|
||||
app = modal.App("chroma-scrub-proto")
|
||||
|
||||
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"})
|
||||
)
|
||||
|
||||
# Model weights persist here across runs, so only the first container downloads.
|
||||
cache = modal.Volume.from_name("chroma-hf-cache", create_if_missing=True)
|
||||
|
||||
CHROMA_MODEL_ID = "lodestones/Chroma1-HD"
|
||||
|
||||
# A neutral, faithful-regeneration prompt (scrub, not restyle); the same intent
|
||||
# as the Qwen prototype and the shipped global prompts.
|
||||
_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
||||
_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
|
||||
_pipe = None
|
||||
|
||||
|
||||
def _load_pipe() -> Any:
|
||||
global _pipe
|
||||
if _pipe is not None:
|
||||
return _pipe
|
||||
import torch
|
||||
from diffusers import ChromaImg2ImgPipeline
|
||||
|
||||
print(f"loading {CHROMA_MODEL_ID} (bf16)...", flush=True)
|
||||
_pipe = ChromaImg2ImgPipeline.from_pretrained(CHROMA_MODEL_ID, torch_dtype=torch.bfloat16)
|
||||
_pipe = _pipe.to("cuda")
|
||||
return _pipe
|
||||
|
||||
|
||||
def _psnr(a: Any, b: Any) -> float:
|
||||
import numpy as np
|
||||
|
||||
x = np.asarray(a.convert("RGB"), dtype=np.float64)
|
||||
y = np.asarray(b.convert("RGB"), dtype=np.float64)
|
||||
mse = float(((x - y) ** 2).mean())
|
||||
return 99.0 if mse <= 1e-12 else 10.0 * float(np.log10(255.0**2 / mse))
|
||||
|
||||
|
||||
@app.function(
|
||||
image=image,
|
||||
gpu=_GPU,
|
||||
volumes={"/cache": cache},
|
||||
timeout=2400,
|
||||
)
|
||||
def scrub(
|
||||
source_bytes: bytes,
|
||||
strengths: list[float],
|
||||
steps: int,
|
||||
guidance: float,
|
||||
seed: int,
|
||||
max_side: int,
|
||||
) -> list[tuple[str, bytes, float]]:
|
||||
"""Sweep one fixture over the strengths; return name, PNG bytes, and PSNR."""
|
||||
import io
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
pipe = _load_pipe()
|
||||
source = Image.open(io.BytesIO(source_bytes)).convert("RGB")
|
||||
if max_side and max(source.size) > max_side:
|
||||
scale = max_side / max(source.size)
|
||||
source = source.resize(
|
||||
(round(source.width * scale), round(source.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
# The Flux-family img2img pipelines default to 1024x1024 when height/width
|
||||
# are not passed, silently discarding the input aspect. Floor to the /16
|
||||
# latent-patch grid, the same rule the shipped profiles use.
|
||||
width = max(16, (source.width // 16) * 16)
|
||||
height = max(16, (source.height // 16) * 16)
|
||||
|
||||
results: list[tuple[str, bytes, float]] = []
|
||||
for strength in strengths:
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
print(f"strength={strength:.2f} on {width}x{height}...", flush=True)
|
||||
output = pipe(
|
||||
prompt=_PROMPT,
|
||||
negative_prompt=_NEGATIVE,
|
||||
image=source,
|
||||
width=width,
|
||||
height=height,
|
||||
strength=strength,
|
||||
num_inference_steps=steps,
|
||||
guidance_scale=guidance,
|
||||
generator=generator,
|
||||
).images[0]
|
||||
if output.size != source.size:
|
||||
output = output.resize(source.size, Image.Resampling.LANCZOS)
|
||||
buffer = io.BytesIO()
|
||||
output.save(buffer, format="PNG")
|
||||
results.append((f"s{strength:.2f}", buffer.getvalue(), _psnr(source, output)))
|
||||
print(f" psnr vs source: {results[-1][2]:.2f} dB", flush=True)
|
||||
cache.commit()
|
||||
return results
|
||||
|
||||
|
||||
@app.local_entrypoint()
|
||||
def main(
|
||||
source: str,
|
||||
strengths: str = "0.05,0.1,0.15,0.2",
|
||||
out: str = "out/chroma",
|
||||
steps: int = 35,
|
||||
guidance: float = 5.0,
|
||||
seed: int = 0,
|
||||
max_side: int = 1536,
|
||||
) -> None:
|
||||
"""Sweep Chroma1 img2img strength over SOURCE and save one output per strength."""
|
||||
source_path = Path(source)
|
||||
if not source_path.exists():
|
||||
raise SystemExit(f"source not found: {source_path}")
|
||||
values = [float(s) for s in strengths.split(",") if s.strip()]
|
||||
stem = source_path.stem
|
||||
|
||||
print(f"gpu={_GPU} model={CHROMA_MODEL_ID} steps={steps} guidance={guidance} seed={seed}")
|
||||
outputs = scrub.remote(source_path.read_bytes(), values, steps, guidance, seed, max_side)
|
||||
|
||||
out_dir = Path(out)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for name, payload, psnr in outputs:
|
||||
destination = out_dir / f"{stem}_chroma_{name}.png"
|
||||
destination.write_bytes(payload)
|
||||
print(f"saved {destination} (psnr {psnr:.2f} dB vs source)")
|
||||
|
||||
print(
|
||||
"\nDone. Submit each output to the matching oracle (openai.com/verify for "
|
||||
"OpenAI carriers, the Gemini app for Google ones). The smallest strength "
|
||||
"that clears the oracle while keeping fidelity is the Chroma1 floor."
|
||||
)
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Can one GENERIC template cover the CJK AI labels no per-vendor detector fires on?
|
||||
|
||||
THE OPPORTUNITY
|
||||
Corpus inspection of doubao-provenance misses turned up `千问AI生成` (Alibaba Qwen) and
|
||||
Corpus inspection of doubao-provenance misses turned up `千问AI生成` (Alibaba Cloud Qwen) and
|
||||
`百度 AI生成` (Baidu) sitting in the same bottom-right corner as the marks we do cover,
|
||||
bold and plainly legible, with no detector able to fire on either. `docs/...landscape`
|
||||
puts uncovered vendors at ~6% of sampled images -- larger than any tuning gain left in
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Reproduce the deterministic Content Seal crop, resize, and JPEG variants."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import hashlib
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
CORPUS = ROOT / "data" / "contentseal"
|
||||
MANIFEST = CORPUS / "manifest.csv"
|
||||
|
||||
|
||||
def _rows() -> dict[str, dict[str, str]]:
|
||||
with MANIFEST.open(newline="", encoding="utf-8") as stream:
|
||||
return {row["name"]: row for row in csv.DictReader(stream)}
|
||||
|
||||
|
||||
def _write_and_verify(image: Image.Image, path: Path, row: dict[str, str], *, format: str, quality: int) -> None:
|
||||
image.save(path, format=format, quality=quality)
|
||||
digest = hashlib.sha256(path.read_bytes()).hexdigest()
|
||||
if digest != row["sha256"]:
|
||||
raise RuntimeError(f"{row['name']} hash mismatch: expected {row['sha256']}, got {digest}")
|
||||
log.info("Verified %s", path)
|
||||
|
||||
|
||||
def reproduce_transforms(output_dir: Path) -> list[Path]:
|
||||
"""Write and hash-check the eight deterministic manifest variants."""
|
||||
rows = _rows()
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
outputs: list[Path] = []
|
||||
|
||||
for prefix, source_name in (("fox", "gen_fox_forest"), ("text", "gen_text_poster")):
|
||||
with Image.open(CORPUS / rows[source_name]["file"]) as opened:
|
||||
source = opened.convert("RGB")
|
||||
|
||||
for fraction in (0.5, 0.33):
|
||||
width = int(source.width * fraction)
|
||||
height = int(source.height * fraction)
|
||||
left = (source.width - width) // 2
|
||||
top = (source.height - height) // 2
|
||||
name = f"{prefix}_crop{int(fraction * 100)}"
|
||||
path = output_dir / f"{name}.webp"
|
||||
crop = source.crop((left, top, left + width, top + height))
|
||||
_write_and_verify(crop, path, rows[name], format="WEBP", quality=95)
|
||||
outputs.append(path)
|
||||
|
||||
scale = 512 / max(source.size)
|
||||
resized = source.resize(
|
||||
(round(source.width * scale), round(source.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
name = f"{prefix}_res512"
|
||||
path = output_dir / f"{name}.webp"
|
||||
_write_and_verify(resized, path, rows[name], format="WEBP", quality=95)
|
||||
outputs.append(path)
|
||||
|
||||
name = f"{prefix}_jpeg85"
|
||||
path = output_dir / f"{name}.jpg"
|
||||
_write_and_verify(source, path, rows[name], format="JPEG", quality=85)
|
||||
outputs.append(path)
|
||||
|
||||
return outputs
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("output_dir", type=Path, help="Directory for regenerated variants")
|
||||
args = parser.parse_args()
|
||||
logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
|
||||
reproduce_transforms(args.output_dir)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -39,7 +39,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from detection_timing import COMPONENTS as _TIMED
|
||||
|
||||
# Taken from the script that WROTE the rows, in its order, so a probe added, removed
|
||||
# or reordered there cannot silently leave a column missing or mislabelled here.
|
||||
# or reordered there cannot silently leave a column missing or mislabeled here.
|
||||
COMPONENTS = tuple(name for name, _ in _TIMED)
|
||||
METHODS = (
|
||||
("cold_extract_evidence_ms", "extract_provenance_evidence (cold)"),
|
||||
|
||||
@@ -0,0 +1,272 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["modal>=1.5"]
|
||||
# ///
|
||||
"""Chroma1 floor calibration across the committed vendor cohorts, plus the
|
||||
matching qwen-zimage production runs at each cohort's shipped floor, for the
|
||||
engine quality/price comparison.
|
||||
|
||||
Every Chroma1 run spends a fixed 4 effective denoising steps (the requested
|
||||
count is scaled up, because Diffusers truncates the step COUNT), matching the
|
||||
semantics the shipped profiles use. Google fixtures are capped to a 1536 px
|
||||
long side, the same practical path the qwen floors were certified on; Meta
|
||||
sources run at native size like their cohort measurement.
|
||||
|
||||
The Microsoft InvisMark cohort is NOT here: its carrier fixtures are not
|
||||
committed to this repository (they were private sources), so no Chroma1 floor
|
||||
can be measured for it until carriers exist. Until then a chroma profile would
|
||||
treat Microsoft like sdxl-zimage treats Meta today: fall back to the
|
||||
conservative unknown floor.
|
||||
|
||||
Run from the repository root (both stacks are warm in the shared volume):
|
||||
|
||||
uvx modal run scripts/engine_cohort_calibration.py
|
||||
|
||||
Outputs land in out/cohort-calibration/<vendor>/<fixture>/ . Oracle each
|
||||
fixture's rungs in ASCENDING strength order and stop at the first clean one:
|
||||
that pair brackets the floor with the fewest checks.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import modal
|
||||
|
||||
_GPU = "H100"
|
||||
|
||||
app = modal.App("engine-cohort-calibration")
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
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",
|
||||
)
|
||||
.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)
|
||||
|
||||
CHROMA_MODEL_ID = "lodestones/Chroma1-HD"
|
||||
QWEN_OPENAI_FLOOR = 0.07675
|
||||
QWEN_GOOGLE_FLOOR = 0.27
|
||||
QWEN_META_FLOOR = 0.1
|
||||
CHROMA_EFFECTIVE_STEPS = 4
|
||||
|
||||
_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
||||
_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
|
||||
# vendor -> fixture -> {chroma rungs, qwen floor strength, max side}
|
||||
FIXTURES: dict[str, dict[str, dict]] = {
|
||||
"openai": {
|
||||
"ChatGPT Image May 30, 2026, 10_31_08 AM.png": { # June study 9-face grid, photoreal
|
||||
"chroma": (0.04, 0.05, 0.06, 0.075, 0.09, 0.12),
|
||||
"qwen": QWEN_OPENAI_FLOOR,
|
||||
"max_side": 1536,
|
||||
},
|
||||
"ChatGPT Image May 31, 2026, 02_02_23 PM.png": { # full-pipeline quality fixture
|
||||
"chroma": (0.04, 0.05, 0.06, 0.075, 0.09),
|
||||
"qwen": QWEN_OPENAI_FLOOR,
|
||||
"max_side": 1536,
|
||||
},
|
||||
},
|
||||
"google": {
|
||||
"Gemini_Generated_Image_633uuy633uuy633u.png": {
|
||||
"chroma": (0.08, 0.10, 0.12, 0.15, 0.20),
|
||||
"qwen": QWEN_GOOGLE_FLOOR,
|
||||
"max_side": 1536,
|
||||
},
|
||||
"Gemini_Generated_Image_akdbeiakdbeiakdb.png": {
|
||||
"chroma": (0.08, 0.10, 0.12, 0.15, 0.20),
|
||||
"qwen": QWEN_GOOGLE_FLOOR,
|
||||
"max_side": 1536,
|
||||
},
|
||||
"Gemini_Generated_Image_y48j3cy48j3cy48j.png": {
|
||||
"chroma": (0.08, 0.10, 0.12, 0.15, 0.20),
|
||||
"qwen": QWEN_GOOGLE_FLOOR,
|
||||
"max_side": 1536,
|
||||
},
|
||||
"Gemini_Generated_Image_3mc4t93mc4t93mc4.png": { # the 18-face portrait grid
|
||||
"chroma": (0.08, 0.10, 0.12, 0.15, 0.20),
|
||||
"qwen": QWEN_GOOGLE_FLOOR,
|
||||
"max_side": 1536,
|
||||
},
|
||||
},
|
||||
"meta": {
|
||||
name: {"chroma": (0.03, 0.045, 0.06, 0.08), "qwen": QWEN_META_FLOOR, "max_side": 0}
|
||||
for name in (
|
||||
"gen_fox_forest.webp",
|
||||
"gen_text_poster.webp",
|
||||
"gen_lighthouse_watercolor.webp",
|
||||
"gen_night_city.webp",
|
||||
"gen_studio_mug.webp",
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
# Seed sensitivity of the already-bracketed OpenAI typography fixture: the
|
||||
# boundary was (0.05, 0.06] at seed 0; probe the same rungs at two more seeds.
|
||||
SEED_PROBE = {
|
||||
"source": "data/synthid/originals/ChatGPT Image May 31, 2026, 02_03_55 PM.png",
|
||||
"rungs": (0.05, 0.06),
|
||||
"seeds": (1, 2),
|
||||
"max_side": 1536,
|
||||
}
|
||||
|
||||
|
||||
def requested_steps(effective_steps: int, strength: float) -> int:
|
||||
return max(1, math.ceil(effective_steps / max(float(strength), 1e-6)))
|
||||
|
||||
|
||||
@app.function(image=image, gpu=_GPU, volumes={"/cache": cache}, timeout=3000)
|
||||
def calibrate(payload: dict[str, bytes]) -> dict:
|
||||
"""Run the chroma ladders, the qwen floors, and the seed probe."""
|
||||
import io
|
||||
import time
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, "/pkg")
|
||||
report: dict = {}
|
||||
|
||||
def load(path_key: str, max_side: int) -> Image.Image:
|
||||
source = Image.open(io.BytesIO(payload[path_key])).convert("RGB")
|
||||
if max_side and max(source.size) > max_side:
|
||||
scale = max_side / max(source.size)
|
||||
source = source.resize(
|
||||
(round(source.width * scale), round(source.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
return source
|
||||
|
||||
def chroma_call(chroma: Any, source: Image.Image, strength: float, seed: int) -> Image.Image:
|
||||
width = max(16, (source.width // 16) * 16)
|
||||
height = max(16, (source.height // 16) * 16)
|
||||
generator = torch.Generator(device="cpu").manual_seed(seed)
|
||||
out = chroma(
|
||||
prompt=_PROMPT,
|
||||
negative_prompt=_NEGATIVE,
|
||||
image=source,
|
||||
width=width,
|
||||
height=height,
|
||||
strength=strength,
|
||||
num_inference_steps=requested_steps(CHROMA_EFFECTIVE_STEPS, strength),
|
||||
guidance_scale=5.0,
|
||||
generator=generator,
|
||||
).images[0]
|
||||
return out if out.size == source.size else out.resize(source.size, Image.Resampling.LANCZOS)
|
||||
|
||||
def png_bytes(image: Image.Image) -> bytes:
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
from diffusers import ChromaImg2ImgPipeline
|
||||
|
||||
print(f"[chroma] loading {CHROMA_MODEL_ID}...", flush=True)
|
||||
chroma = ChromaImg2ImgPipeline.from_pretrained(CHROMA_MODEL_ID, torch_dtype=torch.bfloat16).to("cuda")
|
||||
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
||||
|
||||
print("[qwen] loading the production two-stage pipeline...", flush=True)
|
||||
qwen = QwenZImagePipeline(
|
||||
device="cuda",
|
||||
torch_dtype=torch.bfloat16,
|
||||
keep_global_models_on_device=True,
|
||||
keep_face_models_on_device=True,
|
||||
)
|
||||
|
||||
for vendor, fixtures in FIXTURES.items():
|
||||
report[vendor] = {}
|
||||
for name, spec in fixtures.items():
|
||||
print(f"[{vendor}] {name}", flush=True)
|
||||
source = load(name, spec["max_side"])
|
||||
entry: dict = {"chroma": {}, "qwen_seconds": None}
|
||||
for strength in spec["chroma"]:
|
||||
started = time.perf_counter()
|
||||
out = chroma_call(chroma, source, strength, 0)
|
||||
seconds = time.perf_counter() - started
|
||||
entry["chroma"][f"{strength:.4f}"] = {
|
||||
"png": png_bytes(out),
|
||||
"seconds": round(seconds, 2),
|
||||
}
|
||||
print(f" chroma s={strength:.3f} {seconds:.1f}s", flush=True)
|
||||
started = time.perf_counter()
|
||||
qwen_out = qwen.run(source, strength=spec["qwen"], seed=0)
|
||||
entry["qwen_seconds"] = round(time.perf_counter() - started, 2)
|
||||
entry["qwen_png"] = png_bytes(qwen_out)
|
||||
print(f" qwen s={spec['qwen']} {entry['qwen_seconds']}s", flush=True)
|
||||
report[vendor][name] = entry
|
||||
|
||||
print("[seeds] openai typography boundary probe...", flush=True)
|
||||
seed_source = load(SEED_PROBE["source"], SEED_PROBE["max_side"])
|
||||
report["seed_probe"] = {}
|
||||
for seed in SEED_PROBE["seeds"]:
|
||||
for strength in SEED_PROBE["rungs"]:
|
||||
out = chroma_call(chroma, seed_source, strength, seed)
|
||||
report["seed_probe"][f"s{strength:.2f}_seed{seed}"] = png_bytes(out)
|
||||
print(f" seed={seed} s={strength:.2f}", flush=True)
|
||||
|
||||
cache.commit()
|
||||
return report
|
||||
|
||||
|
||||
@app.local_entrypoint()
|
||||
def main(out: str = "out/cohort-calibration") -> None:
|
||||
payload: dict[str, bytes] = {}
|
||||
for vendor, fixtures in FIXTURES.items():
|
||||
for name in fixtures:
|
||||
if vendor == "meta":
|
||||
path = _ROOT / "data" / "contentseal" / "originals" / name
|
||||
else:
|
||||
path = _ROOT / "data" / "synthid" / "originals" / name
|
||||
if not path.exists():
|
||||
raise SystemExit(f"fixture not found: {path}")
|
||||
payload[name] = path.read_bytes()
|
||||
payload[SEED_PROBE["source"]] = (_ROOT / SEED_PROBE["source"]).read_bytes()
|
||||
|
||||
report = calibrate.remote(payload)
|
||||
|
||||
out_root = Path(out)
|
||||
summary: dict = {}
|
||||
for vendor, fixtures in report.items():
|
||||
if vendor == "seed_probe":
|
||||
for key, png in fixtures.items():
|
||||
destination = out_root / "seed-probe" / f"chroma_{key}.png"
|
||||
destination.parent.mkdir(parents=True, exist_ok=True)
|
||||
destination.write_bytes(png)
|
||||
continue
|
||||
summary[vendor] = {}
|
||||
for name, entry in fixtures.items():
|
||||
fixture_dir = out_root / vendor / Path(name).stem
|
||||
fixture_dir.mkdir(parents=True, exist_ok=True)
|
||||
(fixture_dir / "qwen_floor.png").write_bytes(entry["qwen_png"])
|
||||
rungs = {}
|
||||
for strength, run in entry["chroma"].items():
|
||||
(fixture_dir / f"chroma_s{float(strength):.3f}.png").write_bytes(run["png"])
|
||||
rungs[strength] = run["seconds"]
|
||||
summary[vendor][name] = {
|
||||
"chroma_rungs_seconds": rungs,
|
||||
"qwen_seconds": entry["qwen_seconds"],
|
||||
}
|
||||
(out_root / "timing.json").write_text(json.dumps(summary, indent=2))
|
||||
print(json.dumps(summary, indent=2))
|
||||
print(f"\noutputs in {out_root}. Oracle each fixture ascending, stop at first clean.")
|
||||
@@ -0,0 +1,249 @@
|
||||
# /// script
|
||||
# requires-python = ">=3.11"
|
||||
# dependencies = ["modal>=1.5"]
|
||||
# ///
|
||||
"""Head-to-head probe: qwen-zimage (the shipped production path) vs Chroma1
|
||||
img2img on one fixture -- warm per-image wall time, VRAM peak, and outputs for
|
||||
the local fidelity metrics, so quality and price can be compared on the same
|
||||
pixels.
|
||||
|
||||
qwen-zimage runs through the package's own ``QwenZImagePipeline`` (DiffSynth,
|
||||
Lightning LoRA, Canny ControlNet, face stage) at its certified OpenAI operating
|
||||
point (0.07675) with both stacks resident, exactly as the deployed worker does.
|
||||
Chroma1 runs the same neutral prompt with the SDXL-style step compensation
|
||||
(``requested_steps`` semantics: always spend 4 effective denoising steps), so a
|
||||
low strength is not silently 1 step of 35.
|
||||
|
||||
Standalone like the other prototypes: not imported by the package, not in
|
||||
uv.lock. The package source is mounted at runtime, never installed.
|
||||
|
||||
Run from the repository root (first run downloads both stacks, ~100 GB total,
|
||||
into the shared volume; later runs start warm):
|
||||
|
||||
uvx modal run scripts/engine_quality_price_probe.py \
|
||||
--source data/synthid/originals/'ChatGPT Image May 31, 2026, 02_03_55 PM.png'
|
||||
|
||||
Oracle the Chroma ladder outputs afterwards (openai.com/verify) and score both
|
||||
engines' floor outputs with scripts/fidelity_metrics.py locally.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import modal
|
||||
|
||||
_GPU = "H100"
|
||||
# Verified 2026-08-29 from modal.com/pricing.
|
||||
_H100_PER_HOUR = 3.95
|
||||
_A100_80_PER_HOUR = 2.50
|
||||
|
||||
app = modal.App("engine-quality-price-probe")
|
||||
|
||||
_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
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",
|
||||
)
|
||||
.env({"HF_HOME": "/cache"})
|
||||
.add_local_dir(_ROOT / "src" / "remove_ai_watermarks", remote_path="/pkg/remove_ai_watermarks")
|
||||
)
|
||||
|
||||
# Carries both engines' weights; named for the first prototype that filled it.
|
||||
cache = modal.Volume.from_name("chroma-hf-cache", create_if_missing=True)
|
||||
|
||||
QWEN_OPENAI_FLOOR = 0.07675 # watermark_profiles.QWEN_ZIMAGE_OPENAI_STRENGTH
|
||||
CHROMA_MODEL_ID = "lodestones/Chroma1-HD"
|
||||
CHROMA_STRENGTHS = (0.05, 0.06, 0.075, 0.09, 0.10)
|
||||
CHROMA_EFFECTIVE_STEPS = 4
|
||||
|
||||
_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
||||
_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
|
||||
|
||||
def requested_steps(effective_steps: int, strength: float) -> int:
|
||||
"""Diffusers truncates the step COUNT; spend effective_steps regardless."""
|
||||
import math
|
||||
|
||||
return max(1, math.ceil(effective_steps / max(float(strength), 1e-6)))
|
||||
|
||||
|
||||
def _psnr(a: Any, b: Any) -> float:
|
||||
import numpy as np
|
||||
|
||||
x = np.asarray(a.convert("RGB"), dtype=np.float64)
|
||||
y = np.asarray(b.convert("RGB"), dtype=np.float64)
|
||||
mse = float(((x - y) ** 2).mean())
|
||||
return 99.0 if mse <= 1e-12 else 10.0 * float(np.log10(255.0**2 / mse))
|
||||
|
||||
|
||||
def _png(image: Any) -> bytes:
|
||||
import io
|
||||
|
||||
buffer = io.BytesIO()
|
||||
image.save(buffer, format="PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
@app.function(image=image, gpu=_GPU, volumes={"/cache": cache}, timeout=3000)
|
||||
def probe(source_bytes: bytes, max_side: int) -> dict:
|
||||
"""Run both engines; return outputs, timings, and VRAM peaks."""
|
||||
import gc
|
||||
import io
|
||||
import time
|
||||
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, "/pkg")
|
||||
|
||||
source = Image.open(io.BytesIO(source_bytes)).convert("RGB")
|
||||
if max_side and max(source.size) > max_side:
|
||||
scale = max_side / max(source.size)
|
||||
source = source.resize(
|
||||
(round(source.width * scale), round(source.height * scale)),
|
||||
Image.Resampling.LANCZOS,
|
||||
)
|
||||
|
||||
report: dict = {"source_size": source.size, "engines": {}}
|
||||
|
||||
# --- qwen-zimage, the exact production path ---------------------------------
|
||||
print("[qwen] loading the production two-stage pipeline (resident stacks)...", flush=True)
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
||||
|
||||
qwen = QwenZImagePipeline(
|
||||
device="cuda",
|
||||
torch_dtype=torch.bfloat16,
|
||||
keep_global_models_on_device=True,
|
||||
keep_face_models_on_device=True,
|
||||
)
|
||||
print(f"[qwen] warmup pass at strength={QWEN_OPENAI_FLOOR}...", flush=True)
|
||||
qwen.run(source, strength=QWEN_OPENAI_FLOOR, seed=0)
|
||||
|
||||
torch.cuda.reset_peak_memory_stats()
|
||||
started = time.perf_counter()
|
||||
qwen_out = qwen.run(source, strength=QWEN_OPENAI_FLOOR, seed=0)
|
||||
qwen_seconds = time.perf_counter() - started
|
||||
qwen_vram_gib = torch.cuda.max_memory_allocated() / (1024**3)
|
||||
report["engines"]["qwen-zimage"] = {
|
||||
"strength": QWEN_OPENAI_FLOOR,
|
||||
"warm_seconds": round(qwen_seconds, 2),
|
||||
"vram_peak_gib": round(qwen_vram_gib, 1),
|
||||
"psnr": round(_psnr(source, qwen_out), 2),
|
||||
"png": _png(qwen_out),
|
||||
}
|
||||
qwen_report = report["engines"]["qwen-zimage"]
|
||||
print(
|
||||
f"[qwen] warm {qwen_seconds:.2f}s, vram peak {qwen_vram_gib:.1f} GiB, psnr {qwen_report['psnr']:.2f} dB",
|
||||
flush=True,
|
||||
)
|
||||
|
||||
del qwen
|
||||
gc.collect()
|
||||
torch.cuda.empty_cache()
|
||||
|
||||
# --- Chroma1, compensated-step ladder ---------------------------------------
|
||||
from diffusers import ChromaImg2ImgPipeline
|
||||
|
||||
print(f"[chroma] loading {CHROMA_MODEL_ID} (bf16)...", flush=True)
|
||||
chroma = ChromaImg2ImgPipeline.from_pretrained(CHROMA_MODEL_ID, torch_dtype=torch.bfloat16).to("cuda")
|
||||
|
||||
width = max(16, (source.width // 16) * 16)
|
||||
height = max(16, (source.height // 16) * 16)
|
||||
ladder: list[dict] = []
|
||||
for strength in CHROMA_STRENGTHS:
|
||||
steps = requested_steps(CHROMA_EFFECTIVE_STEPS, strength)
|
||||
torch.cuda.reset_peak_memory_stats()
|
||||
started = time.perf_counter()
|
||||
generator = torch.Generator(device="cpu").manual_seed(0)
|
||||
out = chroma(
|
||||
prompt=_PROMPT,
|
||||
negative_prompt=_NEGATIVE,
|
||||
image=source,
|
||||
width=width,
|
||||
height=height,
|
||||
strength=strength,
|
||||
num_inference_steps=steps,
|
||||
guidance_scale=5.0,
|
||||
generator=generator,
|
||||
).images[0]
|
||||
seconds = time.perf_counter() - started
|
||||
vram_gib = torch.cuda.max_memory_allocated() / (1024**3)
|
||||
if out.size != source.size:
|
||||
out = out.resize(source.size, Image.Resampling.LANCZOS)
|
||||
ladder.append(
|
||||
{
|
||||
"strength": strength,
|
||||
"requested_steps": steps,
|
||||
"warm_seconds": round(seconds, 2),
|
||||
"vram_peak_gib": round(vram_gib, 1),
|
||||
"psnr": round(_psnr(source, out), 2),
|
||||
"png": _png(out),
|
||||
}
|
||||
)
|
||||
print(
|
||||
f"[chroma] s={strength:.3f} steps={steps}/{steps} warm {seconds:.2f}s "
|
||||
f"vram {vram_gib:.1f} GiB psnr {ladder[-1]['psnr']:.2f} dB",
|
||||
flush=True,
|
||||
)
|
||||
report["engines"]["chroma1"] = {"ladder": ladder}
|
||||
|
||||
cache.commit()
|
||||
return report
|
||||
|
||||
|
||||
@app.local_entrypoint()
|
||||
def main(
|
||||
source: str,
|
||||
out: str = "out/engine-comparison",
|
||||
max_side: int = 1536,
|
||||
) -> None:
|
||||
source_path = Path(source)
|
||||
if not source_path.exists():
|
||||
raise SystemExit(f"source not found: {source_path}")
|
||||
|
||||
report = probe.remote(source_path.read_bytes(), max_side)
|
||||
|
||||
out_dir = Path(out) / source_path.stem
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
for engine, payload in report["engines"].items():
|
||||
if engine == "chroma1":
|
||||
for run in payload["ladder"]:
|
||||
(out_dir / f"chroma_s{run['strength']:.3f}.png").write_bytes(run["png"])
|
||||
else:
|
||||
(out_dir / f"{engine}_s{payload['strength']}.png").write_bytes(payload["png"])
|
||||
|
||||
summary = {
|
||||
"source": str(source_path),
|
||||
"source_size": report["source_size"],
|
||||
"gpu": _GPU,
|
||||
"usd_per_hour": {"H100": _H100_PER_HOUR, "A100-80GB": _A100_80_PER_HOUR},
|
||||
"engines": {
|
||||
engine: (
|
||||
{k: v for k, v in payload.items() if k != "png"}
|
||||
if engine != "chroma1"
|
||||
else {"ladder": [{k: v for k, v in run.items() if k != "png"} for run in payload["ladder"]]}
|
||||
)
|
||||
for engine, payload in report["engines"].items()
|
||||
},
|
||||
}
|
||||
(out_dir / "timing.json").write_text(json.dumps(summary, indent=2))
|
||||
print(json.dumps(summary, indent=2))
|
||||
print(f"\noutputs in {out_dir}; oracle the chroma ladder, then score locally with:")
|
||||
print(f" uv run scripts/fidelity_metrics.py compare --original '{source}' \\")
|
||||
print(f" --variant qwen={out_dir / 'qwen-zimage_s0.0768.png'} --variant chroma={out_dir}/chroma_sXXX.png")
|
||||
@@ -0,0 +1,76 @@
|
||||
#!/usr/bin/env python3
|
||||
"""One-shot OpenAI Content Provenance oracle check per file.
|
||||
|
||||
Usage: python3 scripts/openai_provenance_check.py FILE [FILE...]
|
||||
|
||||
Prints ``basename<TAB>synthid_outcome`` per file (DETECTED / NOT_DETECTED).
|
||||
Reads OPENAI_API_KEY from the environment or the main checkout's .env.
|
||||
|
||||
This is the documented programmatic twin of openai.com/verify and reports the
|
||||
SynthID and C2PA outcomes separately, which the web page collapses.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import pathlib
|
||||
import sys
|
||||
import urllib.request
|
||||
import uuid
|
||||
|
||||
API_URL = "https://api.openai.com/v1/content_provenance_checks"
|
||||
|
||||
|
||||
def _api_key() -> str:
|
||||
key = os.environ.get("OPENAI_API_KEY")
|
||||
if key:
|
||||
return key
|
||||
env_file = pathlib.Path.home() / "Documents/GitHub/remove-ai-watermarks/.env"
|
||||
if env_file.exists():
|
||||
for line in env_file.read_text().splitlines():
|
||||
if line.startswith("OPENAI_API_KEY="):
|
||||
return line.split("=", 1)[1].strip()
|
||||
raise SystemExit("no OPENAI_API_KEY in env and no main-checkout .env")
|
||||
|
||||
|
||||
def check(path: pathlib.Path) -> str:
|
||||
boundary = uuid.uuid4().hex
|
||||
payload = path.read_bytes()
|
||||
body = b"".join(
|
||||
(
|
||||
f"--{boundary}\r\n"
|
||||
f'Content-Disposition: form-data; name="file"; filename="{path.name}"\r\n'
|
||||
"Content-Type: image/png\r\n\r\n".encode(),
|
||||
payload,
|
||||
f"\r\n--{boundary}--\r\n".encode(),
|
||||
)
|
||||
)
|
||||
request = urllib.request.Request(
|
||||
API_URL,
|
||||
data=body,
|
||||
headers={
|
||||
"Authorization": f"Bearer {_api_key()}",
|
||||
"Content-Type": f"multipart/form-data; boundary={boundary}",
|
||||
},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=120) as response:
|
||||
data = json.load(response)
|
||||
for entry in data.get("results", []):
|
||||
if entry.get("type") == "synthid":
|
||||
return str(entry.get("outcome", "?")).upper()
|
||||
return "NO_SYNTHID_ENTRY"
|
||||
|
||||
|
||||
def main() -> None:
|
||||
for name in sys.argv[1:]:
|
||||
path = pathlib.Path(name)
|
||||
try:
|
||||
outcome = check(path)
|
||||
except Exception as exc:
|
||||
outcome = f"ERROR:{exc}"
|
||||
print(f"{path.name}\t{outcome}", flush=True)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -81,7 +81,7 @@ def find_visible_positive(mark: str) -> Path | None:
|
||||
|
||||
|
||||
class Results:
|
||||
"""Collects one row per checked behaviour.
|
||||
"""Collects one row per checked behavior.
|
||||
|
||||
A FAIL keeps the command's OUTPUT. That is not cosmetic: the first run of this harness
|
||||
discarded it, an `all` invocation failed once with exit 1, and because the output was
|
||||
@@ -166,7 +166,7 @@ def check_visible(res: Results, tmp: Path) -> None:
|
||||
"""Real marked images -> the PRODUCT'S DECISION is honoured, and a removed mark clears.
|
||||
|
||||
The success criterion is not "the mark is always gone" -- it is "the product did what it
|
||||
decided, and the decision is right". Two designed behaviours make a blind re-detect
|
||||
decided, and the decision is right". Two designed behaviors make a blind re-detect
|
||||
misleading:
|
||||
* The pill is GATED (`_keep_pill`): a low-confidence pill with no corroboration is
|
||||
deliberately NOT removed, so `visible` correctly writes nothing and exits 2. That is
|
||||
|
||||
@@ -34,7 +34,7 @@ DATA SAFETY
|
||||
Read-only over a local dataset. Writes only the given output path, which belongs
|
||||
outside the repository. Resumable: rerunning skips files already recorded.
|
||||
|
||||
uv run python scripts/record_parity_audit.py data/spaces/originals .local-eval/parity.jsonl
|
||||
uv run python scripts/record_parity_audit.py /path/to/dataset .local-eval/parity.jsonl
|
||||
uv run python scripts/record_parity_audit.py <dataset> <out> --baseline .local-eval/previous.jsonl
|
||||
"""
|
||||
|
||||
|
||||
@@ -0,0 +1,175 @@
|
||||
"""Measure a registered visible-mark detector on independently labeled arms.
|
||||
|
||||
The input is a JSONL manifest with exactly two fields per row:
|
||||
|
||||
``path``
|
||||
Absolute path, or a path relative to the manifest.
|
||||
``arm``
|
||||
``positive`` for a visually confirmed carrier, ``metadata`` for a
|
||||
provenance-only cohort, ``negative`` for an independently adjudicated
|
||||
no-mark image, or ``control`` for an unlabeled comparison image with no
|
||||
known local signal.
|
||||
|
||||
The arms stay separate because metadata names a provider, not the presence of a
|
||||
visible mark, and missing local signals do not make an image a true negative. The
|
||||
script imports the registered engine and reads its shipped gate, so calibration
|
||||
cannot silently use a copied configuration.
|
||||
|
||||
Input images and manifests are read-only. Keep private inputs and generated
|
||||
manifests outside the repository or in a gitignored evaluation directory.
|
||||
|
||||
uv run python scripts/registered_mark_calibrate.py microsoft manifest.jsonl
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import math
|
||||
import sys
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
from typing import Any, Literal, TypedDict, cast
|
||||
|
||||
REPO = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(REPO / "src"))
|
||||
|
||||
from remove_ai_watermarks import watermark_registry # noqa: E402
|
||||
from remove_ai_watermarks.image_io import imread # noqa: E402
|
||||
|
||||
Arm = Literal["positive", "metadata", "negative", "control"]
|
||||
_ARMS: tuple[Arm, ...] = ("positive", "metadata", "negative", "control")
|
||||
|
||||
|
||||
class ManifestRow(TypedDict):
|
||||
path: str
|
||||
arm: Arm
|
||||
|
||||
|
||||
class ArmSummary(TypedDict):
|
||||
n: int
|
||||
unreadable: int
|
||||
min: float | None
|
||||
p50: float | None
|
||||
p90: float | None
|
||||
p99: float | None
|
||||
max: float | None
|
||||
fires: int
|
||||
|
||||
|
||||
def _percentile(ordered: list[float], fraction: float) -> float | None:
|
||||
"""Return a nearest-rank percentile from values sorted in ascending order."""
|
||||
if not ordered:
|
||||
return None
|
||||
return ordered[max(0, math.ceil(fraction * len(ordered)) - 1)]
|
||||
|
||||
|
||||
def summarize(values: list[float], *, unreadable: int, fires: int) -> ArmSummary:
|
||||
"""Summarize one independently defined arm without inferring its label."""
|
||||
ordered = sorted(values)
|
||||
return {
|
||||
"n": len(values),
|
||||
"unreadable": unreadable,
|
||||
"min": _percentile(ordered, 0.0),
|
||||
"p50": _percentile(ordered, 0.5),
|
||||
"p90": _percentile(ordered, 0.9),
|
||||
"p99": _percentile(ordered, 0.99),
|
||||
"max": _percentile(ordered, 1.0),
|
||||
"fires": fires,
|
||||
}
|
||||
|
||||
|
||||
def load_manifest(path: Path) -> list[ManifestRow]:
|
||||
"""Load and validate a manifest, resolving relative paths beside it."""
|
||||
rows: list[ManifestRow] = []
|
||||
seen: set[Path] = set()
|
||||
with path.open(encoding="utf-8") as manifest:
|
||||
for line_number, line in enumerate(manifest, 1):
|
||||
if not line.strip():
|
||||
continue
|
||||
raw = json.loads(line)
|
||||
if not isinstance(raw, dict) or set(raw) != {"path", "arm"}:
|
||||
raise ValueError(f"{path}:{line_number}: expected exactly path and arm")
|
||||
raw_path = raw["path"]
|
||||
raw_arm = raw["arm"]
|
||||
if not isinstance(raw_path, str) or not raw_path:
|
||||
raise ValueError(f"{path}:{line_number}: path must be a non-empty string")
|
||||
if raw_arm not in _ARMS:
|
||||
raise ValueError(f"{path}:{line_number}: arm must be one of {', '.join(_ARMS)}")
|
||||
image_path = Path(raw_path)
|
||||
if not image_path.is_absolute():
|
||||
image_path = path.parent / image_path
|
||||
image_path = image_path.resolve()
|
||||
if image_path in seen:
|
||||
raise ValueError(f"{path}:{line_number}: duplicate image path {image_path}")
|
||||
seen.add(image_path)
|
||||
rows.append({"path": str(image_path), "arm": cast("Arm", raw_arm)})
|
||||
if not rows:
|
||||
raise ValueError(f"{path}: manifest is empty")
|
||||
return rows
|
||||
|
||||
|
||||
def measure(mark: str, rows: list[ManifestRow]) -> tuple[float, dict[Arm, ArmSummary]]:
|
||||
"""Score every row with the registered engine and its current shipped gate."""
|
||||
registered_mark = watermark_registry.get_mark(mark)
|
||||
# Calibration intentionally resolves the registry's concrete engine so it
|
||||
# cannot drift onto a copied configuration.
|
||||
engine = watermark_registry._engine(mark)
|
||||
config = getattr(engine, "config", None)
|
||||
gate = getattr(config, "detect_ncc_threshold", None)
|
||||
if not isinstance(gate, int | float):
|
||||
raise ValueError(f"registered mark {mark!r} does not expose a text-detector NCC gate")
|
||||
|
||||
scores: dict[Arm, list[float]] = {arm: [] for arm in _ARMS}
|
||||
unreadable: Counter[Arm] = Counter()
|
||||
fires: Counter[Arm] = Counter()
|
||||
for row in rows:
|
||||
image = imread(row["path"])
|
||||
if image is None:
|
||||
unreadable[row["arm"]] += 1
|
||||
continue
|
||||
detection: Any = registered_mark.detect(image)
|
||||
scores[row["arm"]].append(float(detection.confidence))
|
||||
fires[row["arm"]] += bool(detection.detected)
|
||||
|
||||
threshold = float(gate)
|
||||
return threshold, {arm: summarize(scores[arm], unreadable=unreadable[arm], fires=fires[arm]) for arm in _ARMS}
|
||||
|
||||
|
||||
def _format_stat(value: float | None) -> str:
|
||||
return "-" if value is None else f"{value:.3f}"
|
||||
|
||||
|
||||
def print_report(mark: str, gate: float, summaries: dict[Arm, ArmSummary]) -> None:
|
||||
"""Print a compact human-readable calibration report."""
|
||||
print(f"registered mark: {mark} gate: {gate:.3f}")
|
||||
print(
|
||||
f"{'arm':10s} {'n':>5s} {'bad':>5s} {'min':>7s} {'p50':>7s} {'p90':>7s} {'p99':>7s} {'max':>7s} {'fires':>7s}"
|
||||
)
|
||||
for arm in _ARMS:
|
||||
row = summaries[arm]
|
||||
print(
|
||||
f"{arm:10s} {row['n']:5d} {row['unreadable']:5d} "
|
||||
f"{_format_stat(row['min']):>7s} {_format_stat(row['p50']):>7s} "
|
||||
f"{_format_stat(row['p90']):>7s} {_format_stat(row['p99']):>7s} "
|
||||
f"{_format_stat(row['max']):>7s} {row['fires']:7d}"
|
||||
)
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("mark", choices=watermark_registry.mark_keys())
|
||||
parser.add_argument("manifest", type=Path)
|
||||
parser.add_argument("--json", action="store_true", help="emit machine-readable JSON")
|
||||
args = parser.parse_args()
|
||||
|
||||
rows = load_manifest(args.manifest)
|
||||
gate, summaries = measure(args.mark, rows)
|
||||
if args.json:
|
||||
print(json.dumps({"mark": args.mark, "gate": gate, "arms": summaries}, sort_keys=True))
|
||||
else:
|
||||
print_report(args.mark, gate, summaries)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -6,7 +6,7 @@ the committed asset is a font-rendered binary SILHOUETTE (mark shape only, zero
|
||||
content), used ONLY to (a) detect the pill by edge-NCC in the top-left corner and
|
||||
(b) build the inpaint mask. It is NOT an alpha map -- removal quality comes from the
|
||||
inpaint backend (MI-GAN/cv2), so the silhouette need not be pixel-accurate, and the
|
||||
synthetic render keeps corpus/user content out of the tracked repo (data-safety).
|
||||
synthetic render keeps the asset deterministic and reproducible.
|
||||
|
||||
The calibrated edge-NCC threshold is 0.22 in the top-left ROI. Re-run to regenerate
|
||||
the asset: uv run python scripts/render_pill_silhouette.py
|
||||
|
||||
@@ -12,11 +12,14 @@ from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(_ROOT / "src"))
|
||||
|
||||
@@ -42,7 +45,7 @@ MARKS = {
|
||||
"kling_alpha.png": "可灵AI 3.0",
|
||||
# The "cat-logo" candidate stamps an outline cat-head plus bold "AI生成",
|
||||
# bottom-right. It remains unregistered pending sufficient calibration coverage.
|
||||
"catlogo_alpha.png": "CATLOGO", # sentinel: drawn by draw_catlogo(), not font-rendered
|
||||
"catlogo_alpha.png": "cat logo + AI生成",
|
||||
# RunningHub top-left text mark.
|
||||
"runninghub_alpha.png": "RunningHub AI生成",
|
||||
# LibLibAI bottom-center wordmark.
|
||||
@@ -53,6 +56,28 @@ MARKS = {
|
||||
"hailuo_alpha.png": "Hailuo AI",
|
||||
# Baidu bottom-right text run.
|
||||
"baidu_alpha.png": "百度",
|
||||
# Measured Microsoft top-right white AI-badge variant. Sentinel: drawn by
|
||||
# draw_msbadge(), not font-rendered.
|
||||
"microsoft_alpha.png": "Made with AI",
|
||||
# Samsung Galaxy AI label, English locale (the registered samsung_alpha.png is
|
||||
# the Italian "Contenuti generati dall'AI" silhouette; EN is the literal
|
||||
# translation with the same leading sparkle). Sentinel: draw_samsung_en().
|
||||
"samsung_en_alpha.png": "AI-generated content",
|
||||
# Gemini text-form label (the registered gemini mark is the sparkle icon).
|
||||
"gemini_text_alpha.png": "Generated with Gemini",
|
||||
# Candidate wordmarks measured on a local evaluation corpus (unregistered).
|
||||
"notebooklm_alpha.png": "NotebookLM",
|
||||
"dola_alpha.png": "DolaAI",
|
||||
"mindvideo_alpha.png": "MindVideo.AI",
|
||||
"higgsfield_alpha.png": "HIGGSFIELD AI",
|
||||
"capcut_alpha.png": "CapCut AI",
|
||||
"zsky_alpha.png": "MADE WITH zsky.ai",
|
||||
"chromastudio_alpha.png": "ChromaStudio.ai",
|
||||
"digenai_alpha.png": "DIGENAI",
|
||||
"gendo_alpha.png": "GendoAI",
|
||||
# Jianying / 剪映, the China product in the CapCut family, stamps 剪映AI
|
||||
# bottom-right (the international CapCut pill sits top-left).
|
||||
"jianying_alpha.png": "剪映AI",
|
||||
}
|
||||
_REGISTERED = {f"{key}_alpha.png" for key in mark_keys()} & MARKS.keys()
|
||||
|
||||
@@ -74,6 +99,24 @@ MARK_OPTS: dict[str, dict[str, Any]] = {
|
||||
}
|
||||
|
||||
|
||||
def _fit_font(
|
||||
font_path: str,
|
||||
reaches_target: Callable[[ImageFont.FreeTypeFont], bool],
|
||||
*,
|
||||
index: int = 0,
|
||||
) -> ImageFont.FreeTypeFont:
|
||||
"""Return the smallest 8-200 px font that reaches a render target."""
|
||||
low, high = 8, 200
|
||||
while low < high:
|
||||
size = (low + high) // 2
|
||||
font = ImageFont.truetype(font_path, size, index=index)
|
||||
if reaches_target(font):
|
||||
high = size
|
||||
else:
|
||||
low = size + 1
|
||||
return ImageFont.truetype(font_path, low, index=index)
|
||||
|
||||
|
||||
def render(text: str, width: int = 335, opts: dict[str, Any] | None = None) -> np.ndarray:
|
||||
"""Binary glyph silhouette (255 = glyph), sized to the doubao asset's convention.
|
||||
|
||||
@@ -91,13 +134,11 @@ def render(text: str, width: int = 335, opts: dict[str, Any] | None = None) -> n
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
lines = text.split("\n")
|
||||
size = 8
|
||||
while size < 200: # grow until the LONGEST line fills the target width
|
||||
f = ImageFont.truetype(font_path, size, index=font_index)
|
||||
if max(d0.textbbox((0, 0), ln, font=f)[2] for ln in lines) >= width * 0.98:
|
||||
break
|
||||
size += 1
|
||||
font = ImageFont.truetype(font_path, size, index=font_index)
|
||||
font = _fit_font(
|
||||
font_path,
|
||||
lambda f: max(d0.textbbox((0, 0), ln, font=f)[2] for ln in lines) >= width * 0.98,
|
||||
index=font_index,
|
||||
)
|
||||
boxes = [d0.textbbox((0, 0), ln, font=font) for ln in lines]
|
||||
line_h = max(bb[3] - bb[1] for bb in boxes)
|
||||
gap = max(1, int(line_h * gap_frac))
|
||||
@@ -138,13 +179,7 @@ def draw_catlogo(width: int = 335) -> np.ndarray:
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
text = "AI生成"
|
||||
size = 8
|
||||
while size < 200:
|
||||
f = ImageFont.truetype(_FONT, size)
|
||||
if d0.textbbox((0, 0), text, font=f)[2] >= width * 0.60:
|
||||
break
|
||||
size += 1
|
||||
font = ImageFont.truetype(_FONT, size)
|
||||
font = _fit_font(_FONT, lambda f: d0.textbbox((0, 0), text, font=f)[2] >= width * 0.60)
|
||||
bb = d0.textbbox((0, 0), text, font=font)
|
||||
tw, th = bb[2] - bb[0], bb[3] - bb[1]
|
||||
cs = int(th * 1.08)
|
||||
@@ -182,10 +217,90 @@ def draw_catlogo(width: int = 335) -> np.ndarray:
|
||||
return np.array(im)
|
||||
|
||||
|
||||
def _star_pts(cx: float, cy: float, r: float, waist: float) -> list[tuple[float, float]]:
|
||||
return [
|
||||
(cx, cy - r),
|
||||
(cx + r * waist, cy - r * waist),
|
||||
(cx + r, cy),
|
||||
(cx + r * waist, cy + r * waist),
|
||||
(cx, cy + r),
|
||||
(cx - r * waist, cy + r * waist),
|
||||
(cx - r, cy),
|
||||
(cx - r * waist, cy - r * waist),
|
||||
]
|
||||
|
||||
|
||||
def _sparkle(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None:
|
||||
"""Draw the four-point cutout used by synthetic candidate silhouettes."""
|
||||
draw.polygon(_star_pts(cx, cy, r, 0.22), fill=0)
|
||||
|
||||
|
||||
def draw_msbadge(width: int = 335) -> np.ndarray:
|
||||
"""Synthetic silhouette for the measured Microsoft top-right white pill.
|
||||
|
||||
The top-hat front-end sees the bright pill with dark-text holes, so the template
|
||||
carries the same holes -- that is what discriminates this pill from any other
|
||||
white rounded element in the top-right corner. The text and four-point cutout
|
||||
approximate the measured internal shape; they do not assert one universal
|
||||
Microsoft icon or wording. Geometry measured on 17 visually confirmed carriers
|
||||
on 2026-08-27: pill 0.152W x 0.040W, margins ~0.010W right / ~0.007W top,
|
||||
glyph height ~0.39 of pill height.
|
||||
"""
|
||||
h = round(width / 3.78)
|
||||
im = Image.new("L", (width, h), 0)
|
||||
d = ImageDraw.Draw(im)
|
||||
d.rounded_rectangle([0, 0, width - 1, h - 1], radius=h // 2, fill=255)
|
||||
font_path = "/System/Library/Fonts/Supplemental/Arial Bold.ttf"
|
||||
text = "Made with AI"
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
font = _fit_font(font_path, lambda f: d0.textbbox((0, 0), text, font=f)[3] >= h * 0.39)
|
||||
bb = d0.textbbox((0, 0), text, font=font)
|
||||
th = bb[3] - bb[1]
|
||||
r = h * 0.20 # sparkle radius, ~half the text height
|
||||
pad_l = h * 0.22
|
||||
cx = pad_l + r
|
||||
cy = h / 2 - 1
|
||||
tx = int(pad_l + 2 * r + h * 0.22)
|
||||
d.text((tx - bb[0], (h - th) // 2 - bb[1]), text, font=font, fill=0)
|
||||
_sparkle(d, cx, cy, r)
|
||||
return np.array(im)
|
||||
|
||||
|
||||
def draw_samsung_en(width: int = 335) -> np.ndarray:
|
||||
"""Samsung Galaxy AI English label: "AI-generated content" with the leading
|
||||
4-point sparkle, light-gray glyphs (same class as the registered Italian asset)."""
|
||||
text = "AI-generated content"
|
||||
font_path = "/System/Library/Fonts/Supplemental/Arial.ttf"
|
||||
probe = Image.new("L", (10, 10))
|
||||
d0 = ImageDraw.Draw(probe)
|
||||
font = _fit_font(font_path, lambda f: d0.textbbox((0, 0), text, font=f)[2] >= width * 0.80)
|
||||
bb = d0.textbbox((0, 0), text, font=font)
|
||||
tw, th = bb[2] - bb[0], bb[3] - bb[1]
|
||||
r = th * 0.55
|
||||
gap = th * 0.45
|
||||
im = Image.new("L", (int(tw + gap + 2 * r + 8), th + 8), 0)
|
||||
d = ImageDraw.Draw(im)
|
||||
# sparkle as bright glyph (this silhouette is light-glyph class, not a pill)
|
||||
d.polygon(_star_pts(4 + r, 4 + th / 2, r, 0.22), fill=255)
|
||||
d.text((4 + 2 * r + gap - bb[0], 4 - bb[1]), text, font=font, fill=255)
|
||||
arr = np.array(im)
|
||||
ys, xs = np.where(arr > 0)
|
||||
return arr[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1]
|
||||
|
||||
|
||||
_CUSTOM_RENDERERS = {
|
||||
"catlogo_alpha.png": draw_catlogo,
|
||||
"microsoft_alpha.png": draw_msbadge,
|
||||
"samsung_en_alpha.png": draw_samsung_en,
|
||||
}
|
||||
|
||||
|
||||
def main() -> None:
|
||||
try:
|
||||
for name, text in MARKS.items():
|
||||
sil = draw_catlogo() if text == "CATLOGO" else render(text, opts=MARK_OPTS.get(name))
|
||||
renderer = _CUSTOM_RENDERERS.get(name)
|
||||
sil = renderer() if renderer is not None else render(text, opts=MARK_OPTS.get(name))
|
||||
output_dir = _PACKAGE_ASSETS if name in _REGISTERED else _CANDIDATE_ASSETS
|
||||
output_dir.mkdir(parents=True, exist_ok=True)
|
||||
output = output_dir / name
|
||||
|
||||
@@ -0,0 +1,275 @@
|
||||
"""Render the visible-mark example gallery under data/fixtures/visible/.
|
||||
|
||||
One committed example per registered image mark, so the repo carries a working
|
||||
sample of everything it supports and a canary test can hold both sides to it
|
||||
(mark registered without example; engine regressed on its canonical example).
|
||||
|
||||
Every example is SYNTHETIC: a deterministic generated base photo with the mark's
|
||||
own committed silhouette composited at the engine's measured geometry. The
|
||||
silhouettes are repository-owned font renders, not copied vendor raster assets.
|
||||
|
||||
Regenerate with:
|
||||
uv run python scripts/render_visible_examples.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(_ROOT / "src"))
|
||||
|
||||
from remove_ai_watermarks import watermark_registry as wr # noqa: E402
|
||||
from remove_ai_watermarks.image_io import imread # noqa: E402
|
||||
|
||||
ASSETS = _ROOT / "src" / "remove_ai_watermarks" / "assets"
|
||||
OUT = _ROOT / "data" / "fixtures" / "visible"
|
||||
|
||||
# Composite strength per key: glyph target luma for the light-overlay class.
|
||||
# Kling is a thin light-gray run (not near-white); Samsung is a faint overlay
|
||||
# expressed by SCALING ITS ALPHA to 0.38 toward full white (see below); everything
|
||||
# else is the bold near-white house style.
|
||||
_STRENGTH: dict[str, int] = {
|
||||
"kling": 208,
|
||||
}
|
||||
_DEFAULT_STRENGTH = 238
|
||||
|
||||
# Base geometry: one canonical size per mark where the engine's size modes
|
||||
# matter (qwen's big mode), otherwise a plain 3:2 landscape frame.
|
||||
# Samsung keeps the larger base: its overlay is faint (peak alpha ~0.38) and
|
||||
# the real marks live on ~2958px phone photos -- at 1536 the example falls to 0.39,
|
||||
# just under the engine's 0.40 gate.
|
||||
_SIZE: dict[str, tuple[int, int]] = {"qwen": (1536, 1536), "liblib": (1152, 1536), "samsung": (2048, 1536)}
|
||||
|
||||
|
||||
def base_photo(w: int, h: int, seed: int = 7) -> np.ndarray:
|
||||
"""A deterministic synthetic 'photo': gradient sky, soft blobs, mild noise."""
|
||||
rng = np.random.default_rng(seed)
|
||||
top, bottom = 96, 168
|
||||
grad = np.linspace(top, bottom, h, dtype=np.float32)[:, None]
|
||||
img = np.repeat(grad[:, :, None], w, axis=1) # (h, w, 1)
|
||||
for _ in range(5):
|
||||
cx, cy = rng.uniform(0, w), rng.uniform(0, h)
|
||||
r = rng.uniform(w * 0.12, w * 0.35)
|
||||
blob = rng.uniform(-52, 52)
|
||||
yy, xx = np.ogrid[:h, :w]
|
||||
gauss = np.exp(-(((xx - cx) ** 2 + (yy - cy) ** 2) / (2 * (r * 0.55) ** 2))).astype(np.float32)
|
||||
img = img + (blob * gauss)[:, :, None]
|
||||
img = img + rng.normal(0, 1.6, img.shape).astype(np.float32)
|
||||
return cv2.merge([np.clip(img, 0, 255).astype(np.uint8)] * 3)
|
||||
|
||||
|
||||
def _glyph_asset(name: str) -> np.ndarray:
|
||||
at = imread(str(ASSETS / name), cv2.IMREAD_GRAYSCALE)
|
||||
if at is None:
|
||||
raise RuntimeError(f"missing silhouette asset: {name}")
|
||||
return at.astype(np.float32) / 255.0
|
||||
|
||||
|
||||
def _composite_light(base: np.ndarray, alpha: np.ndarray, x: int, y: int, strength: int) -> np.ndarray:
|
||||
out = base.copy()
|
||||
h, w = alpha.shape[:2]
|
||||
roi = out[y : y + h, x : x + w].astype(np.float32)
|
||||
a3 = alpha[:, :, None] if alpha.ndim == 2 else alpha
|
||||
out[y : y + h, x : x + w] = np.clip(roi * (1 - a3) + strength * a3, 0, 255).astype(np.uint8)
|
||||
return out
|
||||
|
||||
|
||||
def _text_mark_example(key: str) -> np.ndarray:
|
||||
engine = wr._engine(key) # the generator drives the engine's own config
|
||||
cfg = engine.config
|
||||
w, h = _SIZE.get(key, (1536, 1152))
|
||||
base = base_photo(w, h)
|
||||
if key == "microsoft":
|
||||
# Opaque white pill with dark text/sparkle holes, at the measured inset.
|
||||
at = _glyph_asset("microsoft_alpha.png")
|
||||
long_side = max(w, h)
|
||||
pw = int(0.152 * long_side)
|
||||
ph = max(4, int(pw / (at.shape[1] / at.shape[0])))
|
||||
pad = int(0.010 * long_side)
|
||||
pill = cv2.resize(at, (pw, ph))
|
||||
x, y = w - pad - pw, pad
|
||||
roi = base[y : y + ph, x : x + pw].astype(np.float32)
|
||||
bright = (pill > 0.6)[:, :, None]
|
||||
roi = np.where(bright, 245.0, 46.0)
|
||||
base[y : y + ph, x : x + pw] = roi.astype(np.uint8)
|
||||
return base
|
||||
base_dim = {"short": min(w, h), "width": w, "long": max(w, h)}[cfg.scale_basis]
|
||||
# Size the glyph ON a ladder rung: the continuous front ends sweep only the
|
||||
# configured rungs, and a glyph sized between rungs collapses the NCC (the
|
||||
# comb-collapse qwen's own two-rung ladder exists to avoid).
|
||||
rung = max(cfg.ladder) if cfg.detect_frontend != "binary" else 1.0
|
||||
gw = int(cfg.alpha_width_frac * base_dim * rung)
|
||||
gh = max(4, int(cfg.alpha_height_frac * base_dim * rung))
|
||||
loc = engine.locate(base)
|
||||
# Corner-hugging placement: the yuanbao/runninghub anchor gates demote a match
|
||||
# that does not hug the corner, and the real marks sit flush on the box's
|
||||
# corner side (never centered).
|
||||
if cfg.corner in ("br", "tr"):
|
||||
x = loc.x + loc.w - gw
|
||||
elif cfg.corner == "bc":
|
||||
x = loc.x + (loc.w - gw) // 2
|
||||
else: # bl, tl: flush left
|
||||
x = loc.x
|
||||
y = loc.y if cfg.corner in ("tl", "tr") else loc.y + loc.h - gh
|
||||
x, y = max(0, x), max(0, y)
|
||||
at = _glyph_asset(f"{key}_alpha.png")
|
||||
alpha = cv2.resize(at, (gw, gh))
|
||||
if key == "samsung": # faint overlay: peak alpha 0.38 toward FULL white
|
||||
alpha = alpha * 0.38
|
||||
return _composite_light(base, alpha, x, y, _STRENGTH.get(key, _DEFAULT_STRENGTH))
|
||||
|
||||
|
||||
def _gemini_example() -> np.ndarray:
|
||||
from remove_ai_watermarks.gemini_engine import GeminiEngine, get_watermark_config, get_watermark_size
|
||||
|
||||
w, h = 1536, 1152
|
||||
base = base_photo(w, h)
|
||||
eng = GeminiEngine()
|
||||
size = get_watermark_size(w, h)
|
||||
alpha = eng.get_alpha_map(size)
|
||||
cfg = get_watermark_config(w, h)
|
||||
x, y = cfg.get_position(w, h)
|
||||
return _composite_light(base, alpha.astype(np.float32), x, y, 255)
|
||||
|
||||
|
||||
def _pill_example() -> np.ndarray:
|
||||
w, h = 1152, 1536 # the measured pill cohort is 3:4 portrait
|
||||
base = base_photo(w, h)
|
||||
at = _glyph_asset("jimeng_pill.png")
|
||||
pw = max(24, int(0.161 * w))
|
||||
ph = max(8, int(pw * at.shape[0] / at.shape[1]))
|
||||
x, y = int(0.03 * w), int(0.03 * h)
|
||||
alpha = cv2.resize(at, (pw, ph))
|
||||
return _composite_light(base, alpha, x, y, 232)
|
||||
|
||||
|
||||
_BUILDERS: dict[str, Any] = {"gemini": _gemini_example, "jimeng_pill": _pill_example}
|
||||
|
||||
|
||||
def build(key: str) -> np.ndarray:
|
||||
if key in _BUILDERS:
|
||||
return _BUILDERS[key]()
|
||||
return _text_mark_example(key)
|
||||
|
||||
|
||||
# ── Video mark examples ──────────────────────────────────────────────────────
|
||||
# One short clip per registered video mark: the detector's own synthetic
|
||||
# template composited at a scale inside its calibrated search profile, on every
|
||||
# frame of a generated base. The canary asserts the SHIPPED selection accepts
|
||||
# the clip (identify_video -> visible_mark), not just the per-frame detector.
|
||||
|
||||
_VIDEO_FRAMES = 90
|
||||
_VIDEO_FPS = 30
|
||||
|
||||
|
||||
def _video_mark_frame(key: str, w: int, h: int) -> np.ndarray:
|
||||
from remove_ai_watermarks.video_visible import _template_sources
|
||||
|
||||
base = base_photo(w, h, seed=11)
|
||||
templates = _template_sources()
|
||||
short = min(w, h)
|
||||
if key == "sora":
|
||||
tmpl, scale, x, y = templates["sora-icon"], 0.10, int(w * 0.72), int(h * 0.90)
|
||||
elif key == "veo":
|
||||
# The legacy "Veo" TEXT form: a perfect synthetic diamond also matches the
|
||||
# Sora icon template (both are 4-point stars) and table order hands the
|
||||
# tie to Sora, so the gallery carries the discriminative text variant.
|
||||
tmpl = templates["veo-text"]
|
||||
th = max(6, round(14 * short / 720))
|
||||
tw = max(1, round(tmpl.shape[1] * th / tmpl.shape[0]))
|
||||
x, y = w - tw - int(0.045 * w), h - th - int(0.045 * h)
|
||||
return _composite_light(base, cv2.resize(tmpl, (tw, th)).astype(np.float32) / 255.0, x, y, 250)
|
||||
elif key == "seedance":
|
||||
tmpl, scale, x, y = templates["seedance"], 0.095, int(w * 0.74), int(h * 0.80)
|
||||
elif key == "doubao":
|
||||
tmpl, scale, x, y = templates["doubao"], 0.048, int(w * 0.72), int(h * 0.82)
|
||||
elif key == "dola":
|
||||
tmpl, scale, x, y = templates["dola"], 0.036, int(w * 0.70), int(h * 0.88)
|
||||
elif key == "hailuo":
|
||||
tmpl, scale, x, y = templates["hailuo"], 0.052, int(w * 0.34), int(h * 0.82)
|
||||
elif key == "kling":
|
||||
# The FULL mark: swirl logo left of the text run, flush against the
|
||||
# bottom-right EDGE. The font arm is edge-gated (region must reach
|
||||
# >=0.96W / >=0.94H), and a text-only composite away from the edge both
|
||||
# fails that gate and cross-fires the Seedance detector.
|
||||
tmpl = templates["kling-1"]
|
||||
th = max(8, round(short * 0.040))
|
||||
tw = max(1, round(tmpl.shape[1] * th / tmpl.shape[0]))
|
||||
tx, ty = w - tw - 6, h - th - 6
|
||||
out = _composite_light(base, cv2.resize(tmpl, (tw, th)).astype(np.float32) / 255.0, tx, ty, 250)
|
||||
logo = templates["kling-logo"]
|
||||
lh = max(6, round(short * 0.046))
|
||||
lw = max(1, round(logo.shape[1] * lh / logo.shape[0]))
|
||||
lx, ly = tx - lw - round(th * 0.5), h - lh - 6
|
||||
return _composite_light(out, cv2.resize(logo, (lw, lh)).astype(np.float32) / 255.0, lx, ly, 250)
|
||||
else:
|
||||
raise ValueError(key)
|
||||
th = max(8, round(short * scale))
|
||||
tw = max(1, round(tmpl.shape[1] * th / tmpl.shape[0]))
|
||||
alpha = cv2.resize(tmpl, (tw, th)).astype(np.float32) / 255.0
|
||||
return _composite_light(base, alpha, x, y, 250)
|
||||
|
||||
|
||||
def build_video(key: str) -> None:
|
||||
w, h = 960, 540
|
||||
out_dir = OUT / key
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / "example.mp4"
|
||||
writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), _VIDEO_FPS, (w, h))
|
||||
if not writer.isOpened():
|
||||
raise RuntimeError("mp4v writer unavailable")
|
||||
frame = _video_mark_frame(key, w, h)
|
||||
for _ in range(_VIDEO_FRAMES):
|
||||
writer.write(frame)
|
||||
writer.release()
|
||||
|
||||
|
||||
def verify_video(key: str) -> tuple[float, str | None, int]:
|
||||
from remove_ai_watermarks.video import identify_video
|
||||
|
||||
rep = identify_video(OUT / key / "example.mp4", check_visible=True)
|
||||
return float(rep.visible_detected_frames or 0), rep.visible_mark, rep.total_frames
|
||||
|
||||
|
||||
def render_videos() -> list[str]:
|
||||
from remove_ai_watermarks.video import VIDEO_VISIBLE_MARKS
|
||||
|
||||
failures: list[str] = []
|
||||
for key in VIDEO_VISIBLE_MARKS:
|
||||
build_video(key)
|
||||
frames, mark, total = verify_video(key)
|
||||
status = "OK " if mark == key else "MISS"
|
||||
print(f"{status} {key:10s} video: {mark} on {frames}/{total} frames -> data/fixtures/visible/{key}/example.mp4")
|
||||
if mark != key:
|
||||
failures.append(key)
|
||||
return failures
|
||||
|
||||
|
||||
def main() -> None:
|
||||
failures: list[str] = []
|
||||
for mark in wr.known_marks():
|
||||
key = mark.key
|
||||
img = build(key)
|
||||
out_dir = OUT / key
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
path = out_dir / "example.png"
|
||||
cv2.imwrite(str(path), img)
|
||||
det = wr.get_mark(key).detect(imread(str(path)), provenance=False)
|
||||
status = "OK " if det.detected else "MISS"
|
||||
print(f"{status} {key:12s} conf={det.confidence:.3f} -> {path.relative_to(_ROOT)}")
|
||||
if not det.detected:
|
||||
failures.append(key)
|
||||
failures += render_videos()
|
||||
if failures:
|
||||
print(f"\nNOT DETECTED on their own examples: {failures}", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -15,7 +15,7 @@ THE KEY
|
||||
``001191110102MACQD9K64010000`` -> USCC ``91110102MACQD9K640``, which names a
|
||||
specific legal entity. So the metadata partitions carriers into per-ENTITY
|
||||
cohorts without looking at a single pixel. A cohort is a LABEL: once one frame
|
||||
in it is eyeballed, every frame in it is a labelled example of that vendor's
|
||||
in it is eyeballed, every frame in it is a labeled example of that vendor's
|
||||
mark. That is what turns "one confirmed positive" into "30+ per vendor".
|
||||
|
||||
CLAUDE.md's "the generic TC260 label names no specific vendor" is about the
|
||||
|
||||
@@ -14,7 +14,7 @@ WHERE THE POSITIVES COME FROM
|
||||
absence) visible directly.
|
||||
|
||||
NEGATIVES
|
||||
The 432 frames hand-labelled `present: []` in the 2026-07-18 round -- already-adjudicated
|
||||
The 432 frames hand-labeled `present: []` in the 2026-07-18 round -- already-adjudicated
|
||||
no-visible-mark images, so the false-fire arm rests on human labels rather than on the
|
||||
absence of a detection.
|
||||
|
||||
@@ -115,7 +115,7 @@ def load_sets(cohort: str) -> tuple[list[str], list[str]]:
|
||||
# The 2026-07-18 labels are in the vocabulary of the REGISTERED marks only
|
||||
# (gemini/doubao/jimeng/jimeng_pill): `present: []` means "no registered mark", NOT
|
||||
# "no mark at all" -- 146 of the 432 sit in a TC260 cohort, and qwen-cohort frames
|
||||
# visibly carrying 千问AI生成 are labelled `present: []` there (measured 2026-07-21:
|
||||
# visibly carrying 千问AI生成 are labeled `present: []` there (measured 2026-07-21:
|
||||
# they made up the clean arm's whole top tail, clean p99 0.37 -> 0.69). A gate read
|
||||
# off that arm is meaningless, so the clean arm excludes every frame in ANY TC260
|
||||
# cohort -- cohort membership is the cheap proxy for "may carry a CJK AI label".
|
||||
@@ -168,7 +168,7 @@ def report(pos: list[dict[str, Any]], neg: list[dict[str, Any]], name: str) -> N
|
||||
|
||||
p = np.array([r["score"] for r in pos])
|
||||
n = np.array([r["score"] for r in neg])
|
||||
print(f"\n{'=' * 78}\n{name}: candidate-cohort vs hand-labelled clean\n{'=' * 78}")
|
||||
print(f"\n{'=' * 78}\n{name}: candidate-cohort vs hand-labeled clean\n{'=' * 78}")
|
||||
print(f"\n{'arm':10s} {'n':>5s} {'p10':>7s} {'p50':>7s} {'p90':>7s} {'p95':>7s} {'p99':>7s} {'max':>7s}")
|
||||
for label, arr in (("cohort", p), ("clean", n)):
|
||||
if not len(arr):
|
||||
@@ -602,7 +602,7 @@ def main() -> None:
|
||||
crossfire(pools, cand, a.workers, a.gate)
|
||||
return
|
||||
|
||||
print(f"cohort {a.cohort}: {len(pos_paths)} candidates | clean: {len(neg_paths)} hand-labelled")
|
||||
print(f"cohort {a.cohort}: {len(pos_paths)} candidates | clean: {len(neg_paths)} hand-labeled")
|
||||
print(f"scale_basis={a.scale_basis} overrides={overrides}")
|
||||
pos = run(pos_paths, a.asset, name, a.workers, a.scale_basis, overrides)
|
||||
neg = run(neg_paths, a.asset, name, a.workers, a.scale_basis, overrides)
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Benchmark harness for the visible-mark detectors.
|
||||
|
||||
Run this before AND after any detector change. It re-runs perception over the
|
||||
hand-labelled ground truth and reports, per mark, how often a fire is correct --
|
||||
hand-labeled ground truth and reports, per mark, how often a fire is correct --
|
||||
with Wilson intervals, so a change inside the noise is visible as such.
|
||||
|
||||
uv run python scripts/visible_eval.py # score current code
|
||||
@@ -10,10 +10,10 @@ with Wilson intervals, so a change inside the noise is visible as such.
|
||||
|
||||
WHAT THIS SET CAN AND CANNOT MEASURE -- read before quoting a number:
|
||||
|
||||
* PRECISION: sound. Every labelled crop is centred on the region a detector
|
||||
* PRECISION: sound. Every labeled crop is centered on the region a detector
|
||||
pointed at, so "the detector fired mark K here, was K actually there" is
|
||||
exactly the question the labels answer.
|
||||
* RECALL: NOT measurable here, and the harness refuses to print it. The labelled
|
||||
* RECALL: NOT measurable here, and the harness refuses to print it. The labeled
|
||||
images were SAMPLED WHERE DETECTORS FIRED (relaxation additions plus controls),
|
||||
so images carrying a mark that every detector missed are absent by construction.
|
||||
Computing recall on this set would divide by a denominator that excludes exactly
|
||||
@@ -133,7 +133,7 @@ def report(res: dict, prev: dict | None = None) -> None:
|
||||
conf = {k: v for k, v in res["confusion"][m].items() if k != m}
|
||||
if conf:
|
||||
print(f" {m:12s} {dict(sorted(conf.items(), key=lambda kv: -kv[1]))}")
|
||||
print("\n'scope' = images whose crop could rule on that mark; 'missed' = labelled marks it did not fire on.")
|
||||
print("\n'scope' = images whose crop could rule on that mark; 'missed' = labeled marks it did not fire on.")
|
||||
print("NOTE: 'missed' is NOT recall -- this set was sampled where detectors fired, so images")
|
||||
print(" every detector missed are absent by construction. Use it only to catch a change")
|
||||
print(" LOSING marks it used to find; an unbiased random sample is needed for true recall.")
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Consolidate the hand-labelled contact-sheet rounds into ONE ground-truth file.
|
||||
"""Consolidate the hand-labeled contact-sheet rounds into ONE ground-truth file.
|
||||
|
||||
Ground truth is `uid -> the set of visible marks actually present`, hand-labelled
|
||||
Ground truth is `uid -> the set of visible marks actually present`, hand-labeled
|
||||
blind against contact sheets with a two-sided control in every round. Rounds so far:
|
||||
|
||||
2026-07-18 text-mark/pill round : 423 cells (doubao / jimeng / jimeng_pill arms)
|
||||
@@ -10,20 +10,20 @@ DATA SAFETY: treat the input dataset as sensitive. This script reads a gitignore
|
||||
dataset and writes a gitignored ground-truth file. Neither the images nor this
|
||||
output may be committed; only the harness is. See the repo CLAUDE.md.
|
||||
|
||||
The labels record what the LABELLER SAW in the crop, one of:
|
||||
The labels record what the labeler saw in the crop, one of:
|
||||
doubao | jimeng | pill | sparkle | other_ai_label | none | uncertain
|
||||
`other_ai_label` is a real visible AI label from a vendor we do NOT have a mark for
|
||||
(千问 / 百度 / 星绘 / 抖音); it is NOT a positive for any registered mark, but it is
|
||||
also not "clean" -- it is exactly what the relaxed jimeng detector confuses.
|
||||
`uncertain` rows are EXCLUDED from scoring rather than coerced, so a labeller's
|
||||
`uncertain` rows are EXCLUDED from scoring rather than coerced, so a labeler's
|
||||
honest doubt never becomes a fabricated data point.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
SEEN_TO_MARK = {
|
||||
@@ -32,7 +32,7 @@ SEEN_TO_MARK = {
|
||||
"pill": "jimeng_pill",
|
||||
"sparkle": "gemini",
|
||||
}
|
||||
# Which marks a crop centred on `key` lets the labeller rule on (same corner = visible
|
||||
# Which marks a crop centered on `key` lets the labeler rule on (same corner = visible
|
||||
# in the same crop). Doubao and Jimeng share the bottom-right corner.
|
||||
_ADJUDICATES = {
|
||||
"doubao": ("doubao", "jimeng"),
|
||||
@@ -46,6 +46,7 @@ ROUNDS = [
|
||||
("textmark", "labels.csv", "manifest.csv"),
|
||||
("gemini", "gemini_labels.csv", "gemini_manifest.csv"),
|
||||
]
|
||||
_HELP_DESCRIPTION = "Consolidate hand-labeled contact-sheet rounds into one ground-truth file."
|
||||
|
||||
|
||||
def metadata_provenance(path: str) -> list[str]:
|
||||
@@ -77,7 +78,15 @@ def metadata_provenance(path: str) -> list[str]:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
root = Path(sys.argv[1] if len(sys.argv) > 1 else ".local-eval/textmark-relaxation")
|
||||
parser = argparse.ArgumentParser(description=_HELP_DESCRIPTION)
|
||||
parser.add_argument(
|
||||
"root",
|
||||
type=Path,
|
||||
nargs="?",
|
||||
default=Path(".local-eval/textmark-relaxation"),
|
||||
help="Directory containing the blinded labeling rounds",
|
||||
)
|
||||
root = parser.parse_args().root
|
||||
out = root / "groundtruth.jsonl"
|
||||
rows: dict[str, dict] = {}
|
||||
stats: dict[str, int] = {}
|
||||
@@ -97,8 +106,8 @@ def main() -> None:
|
||||
m["uid"],
|
||||
{"uid": m["uid"], "path": m["path"], "present": [], "seen": [], "rounds": [], "adjudicated": []},
|
||||
)
|
||||
# ADJUDICATION SCOPE -- load-bearing. A crop centred on one mark only lets
|
||||
# the labeller rule on marks visible IN THAT CROP. A pill crop (top-left)
|
||||
# ADJUDICATION SCOPE -- load-bearing. A crop centered on one mark only lets
|
||||
# the labeler rule on marks visible IN THAT CROP. A pill crop (top-left)
|
||||
# says nothing about a bottom-right wordmark, so scoring jimeng against a
|
||||
# pill-round image would book real detections as false fires (~61% of pills
|
||||
# carry a wordmark). Bottom-right marks co-adjudicate each other: one crop
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
"""Build an UNBIASED random sample for measuring visible-mark RECALL.
|
||||
|
||||
Every earlier labelling round sampled where detectors FIRED, so images that every
|
||||
Every earlier labeling round sampled where detectors FIRED, so images that every
|
||||
detector missed were absent by construction and recall was unmeasurable. This round
|
||||
samples at random within a provenance class and shows the labeller the corners where
|
||||
samples at random within a provenance class and shows the labeler the corners where
|
||||
a mark can physically be, so a MISSED mark is visible as such.
|
||||
|
||||
Design decisions that matter:
|
||||
@@ -13,19 +13,19 @@ Design decisions that matter:
|
||||
recall over all uploads would mostly measure how often each vendor appears.
|
||||
* NATIVE RESOLUTION crops, never a downscaled whole image: a 220px preview destroys a
|
||||
faint mark (measured in an earlier round), which would inflate the miss count with
|
||||
the labeller's own blindness rather than the detector's.
|
||||
the labeler's own blindness rather than the detector's.
|
||||
* BOTH corners per image (top-left pill, bottom-right wordmark/strip/sparkle), so one
|
||||
pass adjudicates every registered mark instead of one mark per crop.
|
||||
* The detector's verdict is NOT shown and is not in the sheet order -- the manifest
|
||||
holds it and must not be opened until labelling ends.
|
||||
holds it and must not be opened until labeling ends.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import random
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
@@ -35,6 +35,8 @@ import numpy as np
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
import sys
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent / "src"))
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
@@ -63,10 +65,16 @@ def corner_strip(img: NDArray[Any]) -> NDArray[Any] | None:
|
||||
|
||||
|
||||
def main() -> None:
|
||||
scan = Path(sys.argv[1])
|
||||
out = Path(sys.argv[2])
|
||||
n_tc260 = int(sys.argv[3]) if len(sys.argv) > 3 else 160
|
||||
n_google = int(sys.argv[4]) if len(sys.argv) > 4 else 80
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("scan", type=Path, help="JSONL corpus scan produced by the visible evaluation harness")
|
||||
parser.add_argument("output", type=Path, help="Directory for contact sheets and the blinded manifest")
|
||||
parser.add_argument("--tc260", type=int, default=160, help="Number of TC260 carriers to sample")
|
||||
parser.add_argument("--google", type=int, default=80, help="Number of Google-provenance carriers to sample")
|
||||
args = parser.parse_args()
|
||||
scan = args.scan
|
||||
out = args.output
|
||||
n_tc260 = args.tc260
|
||||
n_google = args.google
|
||||
out.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
recs = [json.loads(line) for line in scan.open() if '"marks"' in line]
|
||||
|
||||
@@ -1,17 +1,18 @@
|
||||
"""Build BLIND contact sheets for hand-labelling relaxation additions.
|
||||
"""Build BLIND contact sheets for hand-labeling relaxation additions.
|
||||
|
||||
Crops are centered on the DETECTED REGION (not the corner), padded by ~0.9x the mark
|
||||
size, and resized to 240px with INTER_NEAREST -- a downscaled preview destroys a faint
|
||||
mark, so nothing here may smooth. The manifest is written to a separate file that must
|
||||
NOT be read until labelling is finished.
|
||||
NOT be read until labeling is finished.
|
||||
|
||||
Each sheet mixes three strata in shuffled order:
|
||||
add - the relaxation additions whose precision we are measuring
|
||||
pos - strict-consistent detections (a mark is really there): labeller sensitivity
|
||||
clean - verified-clean negatives (no mark can be there): labeller specificity
|
||||
pos - strict-consistent detections (a mark is really there): labeler sensitivity
|
||||
clean - verified-clean negatives (no mark can be there): labeler specificity
|
||||
The two control strata are what make a low measured precision trustworthy.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import csv
|
||||
import json
|
||||
import random
|
||||
@@ -62,9 +63,13 @@ def crop(path: str, region: tuple[int, int, int, int] | None, pad_factor: float
|
||||
|
||||
|
||||
def main() -> None:
|
||||
with open(sys.argv[1]) as fh:
|
||||
parser = argparse.ArgumentParser(description=__doc__)
|
||||
parser.add_argument("input", type=Path, help="JSON candidate list")
|
||||
parser.add_argument("output", type=Path, help="Directory for blinded contact sheets")
|
||||
args = parser.parse_args()
|
||||
with args.input.open() as fh:
|
||||
items = json.load(fh) # [{uid,path,key,stratum,conf}]
|
||||
outdir = Path(sys.argv[2])
|
||||
outdir = args.output
|
||||
outdir.mkdir(parents=True, exist_ok=True)
|
||||
random.Random(1234).shuffle(items) # noqa: S311 -- sheet ordering, not cryptography
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.32.1"
|
||||
__version__ = "0.35.0"
|
||||
|
||||
__all__ = [
|
||||
"BatchSummary",
|
||||
|
||||
@@ -624,16 +624,16 @@ def _populate_registry_fields(buffer: bytes, info: dict[str, Any]) -> bool:
|
||||
|
||||
if b"c2pa.watermarked" in buffer:
|
||||
info["watermarked"] = True
|
||||
soft_bindings = soft_binding_vendors_in(buffer)
|
||||
synthid = (
|
||||
[]
|
||||
if soft_binding_vendors_in(buffer)
|
||||
if soft_bindings
|
||||
else synthid_evidence_vendors_in(buffer, has_watermark_action=info.get("watermarked", False))
|
||||
)
|
||||
if ai_source and synthid:
|
||||
info["synthid_vendors"] = synthid
|
||||
info["synthid_watermark"] = synthid_verdict(", ".join(synthid))
|
||||
|
||||
soft_bindings = soft_binding_vendors_in(buffer)
|
||||
if soft_bindings:
|
||||
info["soft_binding_vendors"] = soft_bindings
|
||||
info["soft_binding"] = ", ".join(soft_bindings)
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
"""The chroma-zimage recipe with a Chroma1 global stage.
|
||||
|
||||
Only the global regeneration model changes. The face stage is inherited verbatim
|
||||
from :class:`TwoStageZImagePipeline` -- same YuNet detection, same SAM masks, same
|
||||
Z-Image Turbo repair of the original crops, same feathered compositing -- so a
|
||||
change there cannot silently diverge between the profiles.
|
||||
|
||||
Pieces bound to this architecture: the diffusers ``ChromaImg2ImgPipeline``
|
||||
(no DiffSynth here), the neutral faithful-regeneration prompt the floors were
|
||||
calibrated with, guidance 5.0, and the step-count compensation -- diffusers
|
||||
truncates the step COUNT (``int(steps * strength)``), so the requested count is
|
||||
scaled to always spend four effective denoising steps, the same semantics as
|
||||
``sdxl_zimage_pipeline.requested_steps``. Strength is bound to it too: the flat
|
||||
vendor floors in ``watermark_profiles`` come from the 2026-08-29/30 oracle
|
||||
calibration (docs/chroma1-engine-research.md) and do not transfer to another
|
||||
prompt, guidance, or effective step count.
|
||||
|
||||
No Canny conditioning: the floors were measured on a plain strength-controlled
|
||||
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
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.two_stage_pipeline import TwoStageZImagePipeline
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
CHROMA_MODEL_ID = "lodestones/Chroma1-HD"
|
||||
|
||||
# The Chroma1 global stage spends four effective denoising steps at every
|
||||
# strength. The calibration ladder (docs/chroma1-engine-research.md) held this
|
||||
# fixed; a different count is a different engine.
|
||||
CHROMA_STEPS = 4
|
||||
CHROMA_GUIDANCE = 5.0
|
||||
|
||||
# The neutral faithful-regeneration prompt the floors were measured with. It is
|
||||
# deliberately NOT the canny-stage prompt the Qwen/SDXL profiles use: those were
|
||||
# never calibrated against Chroma1, and swapping prompts silently moves the
|
||||
# oracle boundaries.
|
||||
CHROMA_PROMPT = "high quality, sharp, detailed, faithful to the original"
|
||||
CHROMA_NEGATIVE = "blurry, lowres, distorted text, garbled text, artifacts"
|
||||
|
||||
# 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.
|
||||
_LATENT_GRID = 16
|
||||
|
||||
|
||||
def requested_steps(effective_steps: int, strength: float) -> int:
|
||||
"""Spend ``effective_steps`` regardless of Diffusers' step-count truncation.
|
||||
|
||||
Diffusers img2img truncates the step COUNT (``init_timestep = int(steps *
|
||||
strength)``), so at the floors this profile uses a naive request would run
|
||||
zero or one steps. Scale the request so the effective count is exact.
|
||||
"""
|
||||
return max(1, math.ceil(effective_steps / max(float(strength), 1e-6)))
|
||||
|
||||
|
||||
def chroma_target_size(width: int, height: int) -> tuple[int, int]:
|
||||
"""Floor dimensions to the latent grid without changing aspect."""
|
||||
return max(_LATENT_GRID, (width // _LATENT_GRID) * _LATENT_GRID), max(
|
||||
_LATENT_GRID, (height // _LATENT_GRID) * _LATENT_GRID
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ChromaZImagePipeline(TwoStageZImagePipeline):
|
||||
"""Lazy runtime for the Chroma1 global stage plus the inherited face stage."""
|
||||
|
||||
profile_name: ClassVar[str] = "chroma-zimage"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
self._chroma_pipe: Any = None
|
||||
|
||||
def _load_global(self) -> Any:
|
||||
if self._chroma_pipe is not None:
|
||||
return self._chroma_pipe
|
||||
self._require_cuda()
|
||||
try:
|
||||
import torch
|
||||
from diffusers import ChromaImg2ImgPipeline
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"The chroma-zimage pipeline needs the optional dependency group. "
|
||||
"Install: pip install 'remove-ai-watermarks[qwen-zimage]'"
|
||||
) from exc
|
||||
|
||||
self._progress("Loading Chroma1-HD (bf16)...")
|
||||
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)
|
||||
self._chroma_pipe = pipe
|
||||
return pipe
|
||||
|
||||
def _run_global(self, image: Image.Image, strength: float, seed: int | None) -> Image.Image:
|
||||
import torch
|
||||
|
||||
pipe = self._load_global()
|
||||
target = chroma_target_size(image.width, image.height)
|
||||
prepared = image if image.size == target else image.resize(target, Image.Resampling.LANCZOS)
|
||||
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
|
||||
result = pipe(
|
||||
prompt=CHROMA_PROMPT,
|
||||
negative_prompt=CHROMA_NEGATIVE,
|
||||
image=prepared,
|
||||
width=target[0],
|
||||
height=target[1],
|
||||
strength=float(strength),
|
||||
num_inference_steps=steps,
|
||||
guidance_scale=CHROMA_GUIDANCE,
|
||||
generator=generator,
|
||||
).images[0]
|
||||
if result.size != image.size:
|
||||
result = result.resize(image.size, Image.Resampling.LANCZOS)
|
||||
return result.convert("RGB")
|
||||
@@ -70,7 +70,7 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
_vendor(
|
||||
b"OpenAI",
|
||||
"OpenAI",
|
||||
"OpenAI (ChatGPT / gpt-image / DALL-E / Sora)",
|
||||
"OpenAI (ChatGPT / GPT Image / DALL·E / Sora)",
|
||||
"OpenAI",
|
||||
synthid=True,
|
||||
synthid_requires_watermark_action=True,
|
||||
@@ -81,29 +81,32 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
_vendor(
|
||||
b"volcengine",
|
||||
"ByteDance (Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)",
|
||||
"ByteDance",
|
||||
"ByteDance Volcano Engine",
|
||||
"Volcano Engine",
|
||||
),
|
||||
_vendor(
|
||||
"北京火山引擎科技有限公司",
|
||||
"ByteDance (Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)",
|
||||
"ByteDance",
|
||||
),
|
||||
_vendor(
|
||||
b"Byteplus", "BytePlus (ByteDance)", "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)", "ByteDance"
|
||||
"ByteDance Volcano Engine",
|
||||
"Volcano Engine",
|
||||
),
|
||||
_vendor(b"Byteplus", "BytePlus (ByteDance)", "BytePlus (ByteDance)", "BytePlus"),
|
||||
_vendor(
|
||||
b"Dreamina",
|
||||
"ByteDance (Dreamina)",
|
||||
"ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)",
|
||||
"ByteDance",
|
||||
"ByteDance Dreamina",
|
||||
"Dreamina",
|
||||
asserts_ai=True,
|
||||
),
|
||||
_vendor(b"Canva", "Canva", "Canva (Magic Media)", "Canva"),
|
||||
_vendor(b"Eleven Labs", "ElevenLabs", "ElevenLabs", "ElevenLabs"),
|
||||
_vendor(b"fal-ai", "fal.ai", "fal.ai", "fal.ai", asserts_ai=True),
|
||||
_vendor(b"Bria", "Bria Artificial Intelligence", "Bria AI", "Bria", asserts_ai=True),
|
||||
# Ideogram signs its downloads' Content Credentials with "Ideogram, Inc"; the
|
||||
# issuer token is the org prefix (same substring-match class as "Bria" in
|
||||
# "Bria Artificial Intelligence"). Found as an unmapped signer on 4 corpus
|
||||
# uploads 2026-08-08 that identify reported as unknown-signer C2PA.
|
||||
_vendor(b"Ideogram", "Ideogram", "Ideogram", "Ideogram", asserts_ai=True),
|
||||
_vendor(b"Truepic", "Truepic", None, None),
|
||||
)
|
||||
|
||||
@@ -116,7 +119,7 @@ C2PA_IDENTITY_AI_ORGS = frozenset(vendor.org for vendor in C2PA_AI_VENDORS if ve
|
||||
C2PA_CLAIM_GENERATOR_PLATFORMS: tuple[tuple[str, str], ...] = (
|
||||
("adobe_firefly", "Adobe Firefly"),
|
||||
("firefly", "Adobe Firefly"),
|
||||
("dreamina", "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)"),
|
||||
("dreamina", "ByteDance Dreamina"),
|
||||
("higgsfield ai", "Higgsfield AI"),
|
||||
("topaz labs image api", "Topaz Labs"),
|
||||
("tiktok ad creative toolbox", "TikTok Ad Creative Toolbox"),
|
||||
@@ -128,8 +131,8 @@ C2PA_AI_TOOLS = {
|
||||
("GPT-4o", "GPT-4o"),
|
||||
("ChatGPT", "ChatGPT"),
|
||||
("Sora", "Sora"),
|
||||
("DALL-E", "DALL-E"),
|
||||
("DALL", "DALL-E"),
|
||||
("DALL-E", "DALL·E"),
|
||||
("DALL", "DALL·E"),
|
||||
("Imagen", "Imagen"),
|
||||
("Firefly", "Firefly"),
|
||||
("Dreamina", "Dreamina"),
|
||||
@@ -138,16 +141,18 @@ C2PA_AI_TOOLS = {
|
||||
|
||||
C2PA_SOFT_BINDINGS = {
|
||||
b"com.adobe.trustmark": "Adobe TrustMark",
|
||||
b"com.adobe.icn": "Adobe (content fingerprint)",
|
||||
b"com.digimarc": "Digimarc",
|
||||
b"com.adobe.icn": "Adobe Image Comparator Network",
|
||||
b"com.digimarc": "Digimarc Validate",
|
||||
b"com.imatag.lamark": "Imatag (Lamark)",
|
||||
b"ai.steg": "Steg.AI",
|
||||
b"com.microsoft.invismark": "Microsoft InvisMark",
|
||||
b"com.microsoft.wavmark": "Microsoft WavMark",
|
||||
b"com.verimatrix": "Verimatrix",
|
||||
b"com.nagra.nexguard": "NAGRA NexGuard",
|
||||
b"com.aiwatermark": "AIWatermark (Meta PixelSeal)",
|
||||
b"ai.trufo": "Trufo",
|
||||
b"com.aiwatermark.pixelseal": "AIWatermark PixelSeal",
|
||||
b"com.aiwatermark.videoseal": "AIWatermark VideoSeal",
|
||||
b"com.aiwatermark.audioseal": "AIWatermark AudioSeal",
|
||||
b"ai.trufo": "Trufo PawPrint",
|
||||
b"app.overlai": "Overlai",
|
||||
b"com.markany": "MarkAny",
|
||||
b"com.mentaport": "Mentaport",
|
||||
@@ -199,7 +204,7 @@ C2PA_ACTIONS = {f"c2pa.{action}".encode(): action for action in _C2PA_ACTION_NAM
|
||||
# what stops a newly registered TC260 vendor from silently falling back to ByteDance.
|
||||
#
|
||||
# What a TC260 label confirms when its producer is absent or unmapped. Historical
|
||||
# behaviour, kept as the fallback so an unrecognized producer never regresses to no
|
||||
# behavior, kept as the fallback so an unrecognized producer never regresses to no
|
||||
# relaxation at all: ByteDance's two products are the ones the relaxed band was
|
||||
# calibrated on (see _text_mark_engine._DEFAULT_PROVENANCE_NCC_FACTOR).
|
||||
TC260_FALLBACK_VENDORS: frozenset[str] = frozenset({"doubao", "jimeng"})
|
||||
@@ -1,9 +1,9 @@
|
||||
"""The qwen-zimage recipe with an SDXL global stage.
|
||||
"""The sdxl-zimage recipe with an SDXL global stage.
|
||||
|
||||
Only the global regeneration model changes. The face stage is inherited verbatim
|
||||
from :class:`QwenZImagePipeline` -- same YuNet detection, same SAM masks, same
|
||||
from :class:`TwoStageZImagePipeline` -- same YuNet detection, same SAM masks, same
|
||||
Z-Image Turbo repair of the original crops, same feathered compositing -- so a
|
||||
change there cannot silently diverge between the two profiles.
|
||||
change there cannot silently diverge between the profiles.
|
||||
|
||||
Three pieces cannot be shared, because they are bound to the architecture: the
|
||||
ControlNet, the four-step distillation LoRA, and the sampler. Strength is bound to
|
||||
@@ -19,13 +19,14 @@ from __future__ import annotations
|
||||
import logging
|
||||
import math
|
||||
from dataclasses import dataclass
|
||||
from typing import Any
|
||||
from typing import Any, ClassVar
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import (
|
||||
GLOBAL_STEPS,
|
||||
QwenZImagePipeline,
|
||||
from remove_ai_watermarks._internal.two_stage_pipeline import (
|
||||
_GLOBAL_NEGATIVE,
|
||||
_GLOBAL_PROMPT,
|
||||
TwoStageZImagePipeline,
|
||||
build_canny_control_image,
|
||||
)
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
@@ -40,6 +41,10 @@ log = logging.getLogger(__name__)
|
||||
SDXL_VAE_MODEL_ID = "madebyollin/sdxl-vae-fp16-fix"
|
||||
# SDXL aligns to an 8-pixel latent grid, against Qwen's 16.
|
||||
_LATENT_GRID = 8
|
||||
# SDXL-Lightning's own four-step distillation, at the strength its authors document.
|
||||
# The reference graph loads the Qwen LoRA at 0.8; carrying that number to a different
|
||||
# LoRA on a different architecture would be imitation, not parity.
|
||||
SDXL_STEPS = 4
|
||||
|
||||
|
||||
def sdxl_target_size(width: int, height: int) -> tuple[int, int]:
|
||||
@@ -63,14 +68,16 @@ def requested_steps(effective_steps: int, strength: float) -> int:
|
||||
|
||||
|
||||
@dataclass
|
||||
class SdxlZImagePipeline(QwenZImagePipeline):
|
||||
class SdxlZImagePipeline(TwoStageZImagePipeline):
|
||||
"""Lazy runtime for the SDXL global stage plus the inherited face stage."""
|
||||
|
||||
profile_name: ClassVar[str] = "sdxl-zimage"
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
super().__post_init__()
|
||||
self._sdxl_pipe: Any = None
|
||||
|
||||
def _load_sdxl(self) -> Any:
|
||||
def _load_global(self) -> Any:
|
||||
if self._sdxl_pipe is not None:
|
||||
return self._sdxl_pipe
|
||||
self._require_cuda()
|
||||
@@ -96,9 +103,6 @@ class SdxlZImagePipeline(QwenZImagePipeline):
|
||||
add_watermarker=False,
|
||||
**token,
|
||||
).to(self.device)
|
||||
# SDXL's own four-step distillation, at the strength its authors document.
|
||||
# The reference graph loads the Qwen LoRA at 0.8; carrying that number to a
|
||||
# different LoRA on a different architecture would be imitation, not parity.
|
||||
pipe.load_lora_weights(hf_hub_download(SDXL_LIGHTNING_MODEL_ID, SDXL_LIGHTNING_PATTERN, **token))
|
||||
pipe.fuse_lora()
|
||||
# SDXL-Lightning is distilled against trailing timestep spacing.
|
||||
@@ -106,29 +110,19 @@ class SdxlZImagePipeline(QwenZImagePipeline):
|
||||
self._sdxl_pipe = pipe
|
||||
return pipe
|
||||
|
||||
def preload(self, *, global_only: bool = False) -> None:
|
||||
"""Eagerly load the mandatory stage and, by default, the face stack."""
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import _yunet_model_path
|
||||
|
||||
self._load_sdxl()
|
||||
_yunet_model_path()
|
||||
if not global_only:
|
||||
self._load_zimage()
|
||||
self._load_sam()
|
||||
|
||||
def _run_global(self, image: Image.Image, strength: float, seed: int | None) -> Image.Image:
|
||||
import torch
|
||||
|
||||
pipe = self._load_sdxl()
|
||||
pipe = self._load_global()
|
||||
target = sdxl_target_size(image.width, image.height)
|
||||
prepared = image if image.size == target else image.resize(target, Image.Resampling.LANCZOS)
|
||||
control = build_canny_control_image(prepared)
|
||||
steps = requested_steps(GLOBAL_STEPS, strength)
|
||||
self._progress(f"Running SDXL Canny pass: strength={strength:.4f}, steps={GLOBAL_STEPS} of {steps}...")
|
||||
steps = requested_steps(SDXL_STEPS, strength)
|
||||
self._progress(f"Running SDXL Canny pass: strength={strength:.4f}, steps={SDXL_STEPS} of {steps}...")
|
||||
generator = torch.Generator(device=self.device).manual_seed(seed) if seed is not None else None
|
||||
result = pipe(
|
||||
prompt=self._global_prompt(),
|
||||
negative_prompt=self._global_negative(),
|
||||
prompt=_GLOBAL_PROMPT,
|
||||
negative_prompt=_GLOBAL_NEGATIVE,
|
||||
image=prepared,
|
||||
control_image=control,
|
||||
controlnet_conditioning_scale=float(self.controlnet_conditioning_scale),
|
||||
@@ -140,15 +134,3 @@ class SdxlZImagePipeline(QwenZImagePipeline):
|
||||
if result.size != image.size:
|
||||
result = result.resize(image.size, Image.Resampling.LANCZOS)
|
||||
return result.convert("RGB")
|
||||
|
||||
@staticmethod
|
||||
def _global_prompt() -> str:
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import _GLOBAL_PROMPT
|
||||
|
||||
return _GLOBAL_PROMPT
|
||||
|
||||
@staticmethod
|
||||
def _global_negative() -> str:
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import _GLOBAL_NEGATIVE
|
||||
|
||||
return _GLOBAL_NEGATIVE
|
||||
@@ -0,0 +1,955 @@
|
||||
"""The shared two-stage regeneration recipe: a profile global stage plus the Z-Image face stage.
|
||||
|
||||
Every removal profile implements this base rather than another profile:
|
||||
``qwen-zimage``, ``sdxl-zimage`` and ``chroma-zimage`` each own one global regeneration stage, and a
|
||||
future profile (a Flux 2 pass, for instance) plugs in the same way. Everything
|
||||
here is shared verbatim, so the face stage, sizing, compositing, the prompt
|
||||
cache, and the run orchestration cannot silently diverge between profiles.
|
||||
"""
|
||||
|
||||
# DiffSynth, torch, transformers, and cv2 expose mostly untyped tensor/array 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, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportUnnecessaryComparison=false
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import hashlib
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any, ClassVar
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.watermark_profiles import resolve_seed
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from remove_ai_watermarks._internal.text_restoration import VerifiedTextManifest
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ZIMAGE_TURBO_MODEL_ID = "Tongyi-MAI/Z-Image-Turbo"
|
||||
SAM_MODEL_ID = "facebook/sam-vit-base"
|
||||
|
||||
YUNET_MODEL_URL = (
|
||||
"https://media.githubusercontent.com/media/opencv/opencv_zoo/main/"
|
||||
"models/face_detection_yunet/face_detection_yunet_2023mar.onnx"
|
||||
)
|
||||
YUNET_MODEL_NAME = "face_detection_yunet_2023mar.onnx"
|
||||
YUNET_MODEL_SHA256 = "8f2383e4dd3cfbb4553ea8718107fc0423210dc964f9f4280604804ed2552fa4"
|
||||
# This threshold retained the faces in the public validation set without admitting
|
||||
# decorative background regions as faces.
|
||||
YUNET_SCORE_THRESHOLD = 0.5
|
||||
|
||||
FACE_STEPS = 8
|
||||
FACE_CFG = 1.0
|
||||
RESIDENT_FACE_MODEL_MIN_VRAM_GIB = 64.0
|
||||
FACE_DENOISE_SCALE = 0.5
|
||||
GLOBAL_CONTROLNET_SCALE = 1.0
|
||||
|
||||
# Both stages prompt with compile-time constants, and at CFG 1.0 DiffSynth reuses the
|
||||
# positive embedding for the negative side instead of encoding it, so exactly one
|
||||
# embedding per stage is ever needed. Persisting it lets the next container load the
|
||||
# stack without its text encoder at all: 15.45 GiB for Qwen and 7.49 GiB for Z-Image
|
||||
# of a measured 87.6 GiB read per request. The stored tensors are what the encoder
|
||||
# itself produced, so the output stays byte-identical. Bump the version when the
|
||||
# stored shape changes; the model id and prompt are already part of the key, so a
|
||||
# model or prompt change invalidates itself.
|
||||
_PROMPT_CACHE_VERSION = 1
|
||||
_PROMPT_CACHE_DIRNAME = "prompt-embeddings"
|
||||
# DiffSynth identifies a pipeline unit by what it produces, so these tuples are how
|
||||
# the prompt stage is located inside each pipeline and how its cache file is keyed.
|
||||
_ZIMAGE_PROMPT_OUTPUTS = ("prompt_embeds",)
|
||||
|
||||
_CANNY_LOW = 13
|
||||
_CANNY_HIGH = 64
|
||||
|
||||
# These short model inputs are retained as calibrated compatibility parameters.
|
||||
# Changing them requires the same provider-oracle and identity evaluation as a model change.
|
||||
_GLOBAL_PROMPT = "ultra clear and smoothe skin, spotless skin"
|
||||
_GLOBAL_NEGATIVE = "moles, freckes, high detail skin"
|
||||
_FACE_PROMPT = ""
|
||||
_FACE_NEGATIVE = "blurry, ugly, bad quality,"
|
||||
|
||||
|
||||
def resolve_face_model_residency(
|
||||
requested: bool | None,
|
||||
*,
|
||||
total_memory_gib: float,
|
||||
) -> bool:
|
||||
"""Keep the Z-Image stack resident when explicitly requested or safely sized."""
|
||||
if requested is not None:
|
||||
return requested
|
||||
return total_memory_gib >= RESIDENT_FACE_MODEL_MIN_VRAM_GIB
|
||||
|
||||
|
||||
def _pin_vram_managed_models(pipe: Any) -> None:
|
||||
"""Move the managed Z-Image stack to CUDA once and make offload a no-op."""
|
||||
model_names = ["text_encoder", "dit", "vae_encoder", "vae_decoder"]
|
||||
for name in model_names:
|
||||
model = getattr(pipe, name, None)
|
||||
if model is None:
|
||||
continue
|
||||
for module in model.modules():
|
||||
if not all(
|
||||
hasattr(module, attribute)
|
||||
for attribute in (
|
||||
"offload_dtype",
|
||||
"offload_device",
|
||||
"onload_dtype",
|
||||
"onload_device",
|
||||
"preparing_dtype",
|
||||
"preparing_device",
|
||||
"computation_dtype",
|
||||
"computation_device",
|
||||
)
|
||||
):
|
||||
continue
|
||||
module.offload_dtype = module.computation_dtype
|
||||
module.offload_device = module.computation_device
|
||||
module.onload_dtype = module.computation_dtype
|
||||
module.onload_device = module.computation_device
|
||||
module.preparing_dtype = module.computation_dtype
|
||||
module.preparing_device = module.computation_device
|
||||
pipe.load_models_to_device(model_names)
|
||||
|
||||
|
||||
def _cast_prompt_payload(payload: Any, device: Any, dtype: Any) -> Any:
|
||||
"""Move a stored embedding payload onto the runtime device.
|
||||
|
||||
Only floating tensors take the pipeline dtype. The attention masks travel in the
|
||||
same payload and are integer, so casting them would corrupt the prompt.
|
||||
"""
|
||||
import torch
|
||||
|
||||
if isinstance(payload, torch.Tensor):
|
||||
if dtype is not None and payload.is_floating_point():
|
||||
return payload.to(device=device, dtype=dtype)
|
||||
return payload.to(device=device)
|
||||
if isinstance(payload, dict):
|
||||
return {key: _cast_prompt_payload(value, device, dtype) for key, value in payload.items()}
|
||||
if isinstance(payload, (list, tuple)):
|
||||
return type(payload)(_cast_prompt_payload(value, device, dtype) for value in payload)
|
||||
return payload
|
||||
|
||||
|
||||
def _prompt_cache_path(model_id: str, output_params: tuple[str, ...], prompt: str) -> Path:
|
||||
"""Locate the stored embedding for one model and one exact prompt string."""
|
||||
key = "\x1f".join((str(_PROMPT_CACHE_VERSION), model_id, *output_params, prompt))
|
||||
digest = hashlib.sha256(key.encode("utf-8")).hexdigest()[:32]
|
||||
return _model_cache_dir() / _PROMPT_CACHE_DIRNAME / f"{digest}.pt"
|
||||
|
||||
|
||||
def _store_prompt_payload(path: Path, payload: Any) -> None:
|
||||
"""Write the embedding atomically so a torn write can never read back as a hit."""
|
||||
import torch
|
||||
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".pt", delete=False) as handle:
|
||||
torch.save(_cast_prompt_payload(payload, "cpu", None), handle)
|
||||
temporary = Path(handle.name)
|
||||
temporary.replace(path)
|
||||
|
||||
|
||||
def _load_prompt_payload(path: Path, device: Any, dtype: Any) -> Any:
|
||||
import torch
|
||||
|
||||
return _cast_prompt_payload(torch.load(path, map_location="cpu", weights_only=True), device, dtype)
|
||||
|
||||
|
||||
def _cached_prompt_process(
|
||||
original_process: Any,
|
||||
*,
|
||||
model_id: str | None,
|
||||
output_params: tuple[str, ...],
|
||||
require_cache: bool,
|
||||
) -> Any:
|
||||
cache: dict[str, dict[str, Any]] = {}
|
||||
|
||||
def cached_process(
|
||||
runtime_pipe: Any,
|
||||
prompt: str,
|
||||
edit_image: Any = None,
|
||||
) -> dict[str, Any]:
|
||||
if edit_image is not None:
|
||||
return original_process(runtime_pipe, prompt, edit_image=edit_image)
|
||||
if prompt in cache:
|
||||
return cache[prompt]
|
||||
path = None if model_id is None else _prompt_cache_path(model_id, output_params, prompt)
|
||||
if path is not None and path.exists():
|
||||
try:
|
||||
cache[prompt] = _load_prompt_payload(path, runtime_pipe.device, runtime_pipe.torch_dtype)
|
||||
except Exception:
|
||||
log.warning("Discarding unreadable prompt-embedding cache %s", path, exc_info=True)
|
||||
else:
|
||||
return cache[prompt]
|
||||
if require_cache:
|
||||
# The text encoder was left out of the model stack on the strength of
|
||||
# this file, so there is nothing left to fall back to.
|
||||
raise RuntimeError(
|
||||
f"The cached prompt embedding {path} disappeared after the stack was loaded without its text encoder."
|
||||
)
|
||||
produced = original_process(runtime_pipe, prompt, edit_image=None)
|
||||
if path is not None:
|
||||
try:
|
||||
_store_prompt_payload(path, produced)
|
||||
except OSError:
|
||||
log.warning("Could not persist the prompt embedding to %s", path, exc_info=True)
|
||||
cache[prompt] = produced
|
||||
return cache[prompt]
|
||||
|
||||
return cached_process
|
||||
|
||||
|
||||
def _cache_static_prompt_embeddings(
|
||||
pipe: Any,
|
||||
output_params: tuple[str, ...],
|
||||
*,
|
||||
model_id: str | None = None,
|
||||
require_cache: bool = False,
|
||||
) -> bool:
|
||||
"""Memoize a prompt unit when its embedding depends only on static text.
|
||||
|
||||
With ``model_id`` the memo is also persisted, which is what lets the next
|
||||
container skip the text encoder entirely; without it the memo lives only as long
|
||||
as the pipeline. ``require_cache`` says the stack was already built without a
|
||||
text encoder, so a miss must fail loudly rather than call a model that is absent.
|
||||
"""
|
||||
for unit in pipe.units:
|
||||
if tuple(getattr(unit, "output_params", ())) == output_params:
|
||||
unit.process = _cached_prompt_process(
|
||||
unit.process,
|
||||
model_id=model_id,
|
||||
output_params=output_params,
|
||||
require_cache=require_cache,
|
||||
)
|
||||
return True
|
||||
return False
|
||||
|
||||
|
||||
def _clamp(value: float, minimum: float, maximum: float) -> float:
|
||||
if maximum < minimum:
|
||||
minimum, maximum = maximum, minimum
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
|
||||
def resolution_adaptive_denoise(
|
||||
width: int,
|
||||
height: int,
|
||||
*,
|
||||
adaptive_level: int = 6,
|
||||
denoise_min: float = 0.08,
|
||||
denoise_max: float = 0.15,
|
||||
) -> float:
|
||||
"""Choose the calibrated global strength from image area and operator level."""
|
||||
low, high = sorted((float(denoise_min), float(denoise_max)))
|
||||
megapixels = max(1.0, float(width) * float(height)) * 1e-6
|
||||
area_fraction = _clamp((megapixels - 0.30) / 3.40, 0.0, 1.0)
|
||||
strength_range = high - low
|
||||
strength = low + strength_range * area_fraction
|
||||
level_delta = float(int(adaptive_level)) - 5.0
|
||||
if level_delta >= 0.0:
|
||||
strength += level_delta * strength_range * (0.285714 / 5.0)
|
||||
else:
|
||||
strength += level_delta * strength_range * (0.257143 / 4.0)
|
||||
return _clamp(strength, 0.0001, 1.0)
|
||||
|
||||
|
||||
def largest_face_denoise(
|
||||
boxes: list[tuple[int, int, int, int]],
|
||||
image_size: tuple[int, int],
|
||||
*,
|
||||
base_denoise: float = 0.10,
|
||||
adaptive_ratio: float = 0.03,
|
||||
denoise_min: float = 0.05,
|
||||
denoise_max: float = 0.28,
|
||||
) -> float:
|
||||
"""Choose the calibrated face strength from the largest detected face area."""
|
||||
width, height = image_size
|
||||
image_area = max(1.0, float(width) * float(height))
|
||||
largest_ratio = 0.0
|
||||
for x1, y1, x2, y2 in boxes:
|
||||
box_area = max(0.0, float(x2 - x1)) * max(0.0, float(y2 - y1))
|
||||
largest_ratio = max(largest_ratio, box_area / image_area)
|
||||
if largest_ratio <= 0.0:
|
||||
return _clamp(base_denoise, denoise_min, denoise_max)
|
||||
scaled = float(base_denoise) * largest_ratio / max(1e-6, float(adaptive_ratio))
|
||||
return _clamp(scaled, denoise_min, denoise_max)
|
||||
|
||||
|
||||
def _target_size(width: int, height: int) -> tuple[int, int]:
|
||||
"""Floor image dimensions to the /16 latent grid without changing aspect."""
|
||||
return max(16, (width // 16) * 16), max(16, (height // 16) * 16)
|
||||
|
||||
|
||||
def _resize_to_target(image: Image.Image) -> Image.Image:
|
||||
"""Resize pixels to the exact latent-grid dimensions passed to DiffSynth."""
|
||||
target = _target_size(image.width, image.height)
|
||||
if image.size == target:
|
||||
return image
|
||||
return image.resize(target, Image.Resampling.LANCZOS)
|
||||
|
||||
|
||||
def build_canny_control_image(image: Image.Image) -> Image.Image:
|
||||
"""Build the calibrated three-channel Canny conditioning map."""
|
||||
import cv2
|
||||
|
||||
rgb = np.asarray(image.convert("RGB"))
|
||||
gray = cv2.cvtColor(rgb, cv2.COLOR_RGB2GRAY)
|
||||
edges = cv2.Canny(gray, _CANNY_LOW, _CANNY_HIGH)
|
||||
return Image.fromarray(np.repeat(edges[:, :, None], 3, axis=2))
|
||||
|
||||
|
||||
def build_face_kwargs(crop: Image.Image, *, strength: float, seed: int | None) -> dict[str, Any]:
|
||||
"""Build the DiffSynth Z-Image face-detail call shape."""
|
||||
input_image = _resize_to_target(crop)
|
||||
width, height = input_image.size
|
||||
return {
|
||||
"prompt": _FACE_PROMPT,
|
||||
"negative_prompt": _FACE_NEGATIVE,
|
||||
"cfg_scale": FACE_CFG,
|
||||
"input_image": input_image,
|
||||
"denoising_strength": float(strength),
|
||||
"height": height,
|
||||
"width": width,
|
||||
"seed": seed,
|
||||
"rand_device": "cpu",
|
||||
"num_inference_steps": FACE_STEPS,
|
||||
}
|
||||
|
||||
|
||||
def composite_face(base: np.ndarray, detail: np.ndarray, mask: np.ndarray, *, feather: int = 10) -> np.ndarray:
|
||||
"""Feather ``detail`` into ``base`` while preserving every zero-mask pixel."""
|
||||
import cv2
|
||||
|
||||
if base.shape != detail.shape:
|
||||
raise ValueError("base and detail must have identical shapes")
|
||||
if mask.shape != base.shape[:2]:
|
||||
raise ValueError("mask must match the image height and width")
|
||||
|
||||
alpha = mask.astype(np.float32) / 255.0
|
||||
if feather > 0:
|
||||
sigma = max(0.1, float(feather) / 3.0)
|
||||
alpha = cv2.GaussianBlur(alpha, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
||||
# Blurring may introduce tiny values far outside the intended mask. Keep an
|
||||
# explicit support dilation so pixels beyond the feather radius stay exact.
|
||||
support = cv2.dilate((mask > 0).astype(np.uint8), np.ones((2 * feather + 1, 2 * feather + 1), np.uint8))
|
||||
alpha *= support
|
||||
alpha = np.clip(alpha, 0.0, 1.0)[:, :, None]
|
||||
merged = base.astype(np.float32) * (1.0 - alpha) + detail.astype(np.float32) * alpha
|
||||
return np.clip(np.rint(merged), 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def _expanded_box(
|
||||
box: tuple[int, int, int, int],
|
||||
image_size: tuple[int, int],
|
||||
*,
|
||||
factor: float = 2.5,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""Expand a face box around its center to include local lighting context."""
|
||||
x1, y1, x2, y2 = box
|
||||
image_width, image_height = image_size
|
||||
center_x = (x1 + x2) / 2.0
|
||||
center_y = (y1 + y2) / 2.0
|
||||
width = max(1.0, (x2 - x1) * factor)
|
||||
height = max(1.0, (y2 - y1) * factor)
|
||||
return (
|
||||
max(0, round(center_x - width / 2.0)),
|
||||
max(0, round(center_y - height / 2.0)),
|
||||
min(image_width, round(center_x + width / 2.0)),
|
||||
min(image_height, round(center_y + height / 2.0)),
|
||||
)
|
||||
|
||||
|
||||
def _model_cache_dir() -> Path:
|
||||
"""Where this library persists downloaded and derived model assets.
|
||||
|
||||
``HF_HOME`` comes first because on a scale-to-zero runner it is the one path
|
||||
mounted persistently; anything under a container-local cache is re-derived on
|
||||
every request, which defeats both the YuNet download and the prompt cache.
|
||||
"""
|
||||
root = os.environ.get("HF_HOME") or os.environ.get("XDG_CACHE_HOME")
|
||||
base = Path(root) if root else Path.home() / ".cache"
|
||||
return base / "remove-ai-watermarks"
|
||||
|
||||
|
||||
def _yunet_model_path() -> Path:
|
||||
"""Download the small MIT-licensed YuNet ONNX model on first use."""
|
||||
model_path = _model_cache_dir() / YUNET_MODEL_NAME
|
||||
if model_path.exists() and hashlib.sha256(model_path.read_bytes()).hexdigest() == YUNET_MODEL_SHA256:
|
||||
return model_path
|
||||
|
||||
model_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
log.info("Downloading YuNet face detector: %s", YUNET_MODEL_URL)
|
||||
request = urllib.request.Request(
|
||||
YUNET_MODEL_URL,
|
||||
headers={"User-Agent": "remove-ai-watermarks"},
|
||||
)
|
||||
with urllib.request.urlopen(request, timeout=60) as response: # noqa: S310 - fixed HTTPS source
|
||||
payload = response.read()
|
||||
log.info(
|
||||
"YuNet download response: status=%s content_length=%s",
|
||||
getattr(response, "status", None),
|
||||
len(payload),
|
||||
)
|
||||
digest = hashlib.sha256(payload).hexdigest()
|
||||
if digest != YUNET_MODEL_SHA256:
|
||||
raise OSError(
|
||||
"YuNet download failed integrity verification: "
|
||||
f"expected {YUNET_MODEL_SHA256}, got {digest} ({len(payload)} bytes)"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(dir=model_path.parent, suffix=".onnx", delete=False) as handle:
|
||||
handle.write(payload)
|
||||
temporary = Path(handle.name)
|
||||
temporary.replace(model_path)
|
||||
return model_path
|
||||
|
||||
|
||||
def _nms_boxes(
|
||||
boxes: list[tuple[int, int, int, int]],
|
||||
scores: list[float],
|
||||
*,
|
||||
threshold: float = 0.3,
|
||||
) -> list[tuple[int, int, int, int]]:
|
||||
"""Apply OpenCV NMS to detections collected at multiple image scales."""
|
||||
if not boxes:
|
||||
return []
|
||||
import cv2
|
||||
|
||||
xywh = [[x1, y1, x2 - x1, y2 - y1] for x1, y1, x2, y2 in boxes]
|
||||
indices = cv2.dnn.NMSBoxes(xywh, scores, score_threshold=0.2, nms_threshold=threshold)
|
||||
if len(indices) == 0:
|
||||
return []
|
||||
return [boxes[int(index)] for index in np.asarray(indices).reshape(-1)]
|
||||
|
||||
|
||||
def detect_faces(image: Image.Image) -> list[tuple[int, int, int, int]]:
|
||||
"""Detect face boxes with YuNet at two scales for large and small faces."""
|
||||
import cv2
|
||||
|
||||
rgb = np.asarray(image.convert("RGB"))
|
||||
original_height, original_width = rgb.shape[:2]
|
||||
detections: list[tuple[int, int, int, int]] = []
|
||||
scores: list[float] = []
|
||||
model_path = _yunet_model_path()
|
||||
|
||||
for long_side in (640, 1280):
|
||||
scale = min(1.0, long_side / max(original_width, original_height))
|
||||
width = max(1, round(original_width * scale))
|
||||
height = max(1, round(original_height * scale))
|
||||
resized = cv2.resize(rgb, (width, height), interpolation=cv2.INTER_AREA) if scale < 1.0 else rgb
|
||||
bgr = cv2.cvtColor(resized, cv2.COLOR_RGB2BGR)
|
||||
detector = cv2.FaceDetectorYN.create(
|
||||
str(model_path),
|
||||
"",
|
||||
(width, height),
|
||||
YUNET_SCORE_THRESHOLD,
|
||||
0.3,
|
||||
5000,
|
||||
)
|
||||
_, rows = detector.detect(bgr)
|
||||
if rows is None:
|
||||
continue
|
||||
inverse = 1.0 / scale
|
||||
for row in rows:
|
||||
x, y, box_width, box_height = (float(value) for value in row[:4])
|
||||
x1 = max(0, round(x * inverse))
|
||||
y1 = max(0, round(y * inverse))
|
||||
x2 = min(original_width, round((x + box_width) * inverse))
|
||||
y2 = min(original_height, round((y + box_height) * inverse))
|
||||
if x2 <= x1 or y2 <= y1:
|
||||
continue
|
||||
detections.append((x1, y1, x2, y2))
|
||||
scores.append(float(row[-1]))
|
||||
if scale == 1.0:
|
||||
break
|
||||
return _nms_boxes(detections, scores)
|
||||
|
||||
|
||||
def _ellipse_masks(
|
||||
boxes: list[tuple[int, int, int, int]],
|
||||
image_size: tuple[int, int],
|
||||
) -> list[np.ndarray]:
|
||||
"""Safe fallback masks when SAM is unavailable."""
|
||||
import cv2
|
||||
|
||||
width, height = image_size
|
||||
masks: list[np.ndarray] = []
|
||||
for x1, y1, x2, y2 in boxes:
|
||||
mask = np.zeros((height, width), dtype=np.uint8)
|
||||
center = ((x1 + x2) // 2, (y1 + y2) // 2)
|
||||
axes = (max(1, int((x2 - x1) * 0.55)), max(1, int((y2 - y1) * 0.62)))
|
||||
cv2.ellipse(mask, center, axes, 0, 0, 360, 255, -1)
|
||||
masks.append(mask)
|
||||
return masks
|
||||
|
||||
|
||||
def _prepare_sam_inputs(inputs: Any, device: str, dtype: Any) -> Any:
|
||||
"""Move SAM inputs to the target device without casting geometric prompts."""
|
||||
prepared = inputs.to(device)
|
||||
if "pixel_values" in prepared:
|
||||
prepared["pixel_values"] = prepared["pixel_values"].to(dtype=dtype)
|
||||
return prepared
|
||||
|
||||
|
||||
def _sam_point_prompts(
|
||||
boxes: list[tuple[int, int, int, int]],
|
||||
) -> tuple[list[list[list[list[float]]]], list[list[list[int]]]]:
|
||||
"""Build Impact Pack's center-1 positive prompt for every face box."""
|
||||
points = [[[[(x1 + x2) / 2.0, (y1 + y2) / 2.0]] for x1, y1, x2, y2 in boxes]]
|
||||
labels = [[[1] for _box in boxes]]
|
||||
return points, labels
|
||||
|
||||
|
||||
def _clip_sam_masks_to_boxes(
|
||||
masks: list[np.ndarray],
|
||||
boxes: list[tuple[int, int, int, int]],
|
||||
image_size: tuple[int, int],
|
||||
) -> list[np.ndarray]:
|
||||
"""Match Impact Pack by intersecting each SAM mask with its detector box."""
|
||||
# Same rectangle primitive the shared fill uses, rather than a private zeros/fill
|
||||
# copy. `dilate=0` because this box CLIPS a SAM mask -- growing it would admit the
|
||||
# pixels the clip exists to exclude. The function-local import keeps region_eraser's
|
||||
# module-scope cv2 off this module's import path.
|
||||
from remove_ai_watermarks.region_eraser import boxes_to_mask
|
||||
|
||||
width, height = image_size
|
||||
clipped: list[np.ndarray] = []
|
||||
for mask, (x1, y1, x2, y2) in zip(masks, boxes, strict=True):
|
||||
# (x, y, w, h) with w = x2 - x1 keeps x + w == x2, so a negative origin clamps
|
||||
# to the same span the explicit max/min pair produced.
|
||||
box_mask = boxes_to_mask((height, width), [(x1, y1, x2 - x1, y2 - y1)], dilate=0)
|
||||
clipped.append(np.bitwise_and(mask.astype(np.uint8), box_mask))
|
||||
return clipped
|
||||
|
||||
|
||||
def _select_sam_masks(
|
||||
masks: np.ndarray,
|
||||
scores: np.ndarray,
|
||||
*,
|
||||
threshold: float = 0.93,
|
||||
) -> list[np.ndarray]:
|
||||
"""Select and combine SAM proposals like Impact Pack's ``sub_threshold``."""
|
||||
mask_array = np.asarray(masks)
|
||||
score_array = np.asarray(scores)
|
||||
if mask_array.ndim == 5 and mask_array.shape[0] == 1:
|
||||
mask_array = mask_array[0]
|
||||
if score_array.ndim == 3 and score_array.shape[0] == 1:
|
||||
score_array = score_array[0]
|
||||
if mask_array.ndim == 3:
|
||||
mask_array = mask_array[:, None, :, :]
|
||||
if score_array.ndim == 1:
|
||||
score_array = score_array[:, None]
|
||||
if mask_array.ndim != 4 or score_array.ndim != 2:
|
||||
raise ValueError("SAM masks and scores have unexpected dimensions")
|
||||
if mask_array.shape[:2] != score_array.shape:
|
||||
raise ValueError("SAM mask proposals and IoU scores do not align")
|
||||
|
||||
selected_masks: list[np.ndarray] = []
|
||||
for candidates, candidate_scores in zip(mask_array, score_array, strict=True):
|
||||
selected = np.flatnonzero(candidate_scores >= threshold)
|
||||
if selected.size == 0:
|
||||
selected = np.asarray([int(np.argmax(candidate_scores))])
|
||||
combined = np.any(candidates[selected] > 0, axis=0)
|
||||
selected_masks.append(combined.astype(np.uint8) * 255)
|
||||
return selected_masks
|
||||
|
||||
|
||||
def _sam_outputs_to_numpy(masks: Any, scores: Any) -> tuple[np.ndarray, np.ndarray]:
|
||||
"""Convert SAM tensors through float32 because NumPy rejects bfloat16."""
|
||||
mask_array = masks.detach().float().cpu().numpy()
|
||||
score_array = scores.detach().float().cpu().numpy()
|
||||
return mask_array, score_array
|
||||
|
||||
|
||||
@dataclass
|
||||
class TwoStageZImagePipeline:
|
||||
"""Lazy runtime for the shared two-stage regeneration recipe.
|
||||
|
||||
A profile subclass owns the global stage: ``_load_global`` loads and caches its
|
||||
model stack, ``_run_global`` regenerates a frame, and ``_vae_roundtrip``
|
||||
optionally exposes that stack's VAE as a verified-text donor. The Z-Image face
|
||||
stage with YuNet detection and SAM masks, the prompt cache, sizing, and the run
|
||||
orchestration live here and are inherited verbatim by every profile.
|
||||
"""
|
||||
|
||||
# Names the CUDA-only refusal after the concrete profile rather than after this
|
||||
# base, so a directly constructed subclass reports itself.
|
||||
profile_name: ClassVar[str] = ""
|
||||
|
||||
device: str
|
||||
torch_dtype: Any
|
||||
hf_token: str | None = None
|
||||
progress_callback: Callable[[str], None] | None = None
|
||||
controlnet_conditioning_scale: float = GLOBAL_CONTROLNET_SCALE
|
||||
keep_face_models_on_device: bool | None = None
|
||||
keep_global_models_on_device: bool | None = None
|
||||
cache_prompt_embeddings: bool = True
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
self._zimage_pipe: Any = None
|
||||
self._sam_model: Any = None
|
||||
self._sam_processor: Any = None
|
||||
|
||||
def _progress(self, message: str) -> None:
|
||||
if self.progress_callback is not None:
|
||||
with contextlib.suppress(Exception):
|
||||
self.progress_callback(message)
|
||||
|
||||
def _require_cuda(self) -> None:
|
||||
if self.device != "cuda":
|
||||
name = self.profile_name or type(self).__name__
|
||||
raise RuntimeError(
|
||||
f"The {name} pipeline is CUDA-only; there is no CPU or MPS path "
|
||||
"for it. WatermarkRemover already refuses a non-CUDA device, so "
|
||||
"reaching this guard means the pipeline was constructed directly."
|
||||
)
|
||||
|
||||
def _vram_limit(self) -> float | None:
|
||||
import torch
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
return max(1.0, torch.cuda.mem_get_info("cuda")[1] / (1024**3) - 0.5)
|
||||
return None
|
||||
|
||||
def _total_vram_gib(self) -> float:
|
||||
"""Card capacity for the residency gates, 0.0 when it cannot be read.
|
||||
|
||||
Separate from ``_vram_limit``, which answers a different question (the budget
|
||||
handed to DiffSynth) and must stay ``None`` rather than 0.0 when unknown, so
|
||||
an unreadable device means "no limit" there and "assume small" here.
|
||||
"""
|
||||
import torch
|
||||
|
||||
with contextlib.suppress(Exception):
|
||||
return torch.cuda.get_device_properties("cuda").total_memory / (1024**3)
|
||||
return 0.0
|
||||
|
||||
def _keep_face_models_resident(self) -> bool:
|
||||
return resolve_face_model_residency(
|
||||
self.keep_face_models_on_device,
|
||||
total_memory_gib=self._total_vram_gib(),
|
||||
)
|
||||
|
||||
def _prompt_is_cached(self, model_id: str, output_params: tuple[str, ...], prompt: str) -> bool:
|
||||
"""Whether this stack can be built without its text encoder at all."""
|
||||
return self.cache_prompt_embeddings and _prompt_cache_path(model_id, output_params, prompt).exists()
|
||||
|
||||
@classmethod
|
||||
def _face_stage_dtype(cls) -> Any:
|
||||
"""The dtype every face-stage model loads and computes in.
|
||||
|
||||
Deliberately independent of ``self.torch_dtype``, which belongs to the global
|
||||
stage. ``sdxl-zimage`` runs its global model in fp16, and inheriting that here
|
||||
built the Z-Image modules bf16 (per the VRAM config below) while handing them
|
||||
fp16 latents -- a Half/BFloat16 conv mismatch that crashed every face image
|
||||
while zero-face inputs passed, so no unit test could see it.
|
||||
|
||||
Read by Z-Image and by SAM alike, so the whole stage moves together. SAM never
|
||||
crashed, because it casts its own inputs and leaves through ``.float()``, but it
|
||||
was reading the same wrong field and would re-land the bug for the next profile
|
||||
that changes its global dtype.
|
||||
"""
|
||||
return cls._zimage_vram_config()["computation_dtype"]
|
||||
|
||||
@staticmethod
|
||||
def _zimage_vram_config() -> dict[str, Any]:
|
||||
import torch
|
||||
|
||||
return {
|
||||
"offload_dtype": torch.bfloat16,
|
||||
"offload_device": "cpu",
|
||||
"onload_dtype": torch.bfloat16,
|
||||
"onload_device": "cpu",
|
||||
"preparing_dtype": torch.bfloat16,
|
||||
"preparing_device": "cuda",
|
||||
"computation_dtype": torch.bfloat16,
|
||||
"computation_device": "cuda",
|
||||
}
|
||||
|
||||
def _load_zimage(self) -> Any:
|
||||
if self._zimage_pipe is not None:
|
||||
return self._zimage_pipe
|
||||
self._require_cuda()
|
||||
os.environ.setdefault("DIFFSYNTH_DOWNLOAD_SOURCE", "huggingface")
|
||||
if self.hf_token:
|
||||
os.environ.setdefault("HF_TOKEN", self.hf_token)
|
||||
try:
|
||||
from diffsynth.pipelines.z_image import ModelConfig, ZImagePipeline
|
||||
except ImportError as exc:
|
||||
raise ImportError(
|
||||
"The two-stage pipelines need the optional dependency group. "
|
||||
"Install: pip install 'remove-ai-watermarks[qwen-zimage]'"
|
||||
) from exc
|
||||
|
||||
self._progress("Loading Z-Image Turbo face-detail model...")
|
||||
keep_on_device = self._keep_face_models_resident()
|
||||
config = self._zimage_vram_config()
|
||||
text_encoder_config = ModelConfig(
|
||||
model_id=ZIMAGE_TURBO_MODEL_ID,
|
||||
origin_file_pattern="text_encoder/*.safetensors",
|
||||
**config,
|
||||
)
|
||||
model_configs = [
|
||||
ModelConfig(
|
||||
model_id=ZIMAGE_TURBO_MODEL_ID,
|
||||
origin_file_pattern="transformer/*.safetensors",
|
||||
**config,
|
||||
),
|
||||
text_encoder_config,
|
||||
ModelConfig(
|
||||
model_id=ZIMAGE_TURBO_MODEL_ID,
|
||||
origin_file_pattern="vae/diffusion_pytorch_model.safetensors",
|
||||
**config,
|
||||
),
|
||||
]
|
||||
prompt_cached = self._prompt_is_cached(ZIMAGE_TURBO_MODEL_ID, _ZIMAGE_PROMPT_OUTPUTS, _FACE_PROMPT)
|
||||
if prompt_cached:
|
||||
model_configs.remove(text_encoder_config)
|
||||
log.info("Z-Image prompt embedding is cached; loading the stack without its text encoder")
|
||||
pipe = ZImagePipeline.from_pretrained(
|
||||
torch_dtype=self._face_stage_dtype(),
|
||||
device=self.device,
|
||||
model_configs=model_configs,
|
||||
tokenizer_config=ModelConfig(
|
||||
model_id=ZIMAGE_TURBO_MODEL_ID,
|
||||
origin_file_pattern="tokenizer/",
|
||||
),
|
||||
vram_limit=self._vram_limit(),
|
||||
)
|
||||
if keep_on_device:
|
||||
_pin_vram_managed_models(pipe)
|
||||
if self.cache_prompt_embeddings:
|
||||
_cache_static_prompt_embeddings(
|
||||
pipe,
|
||||
_ZIMAGE_PROMPT_OUTPUTS,
|
||||
model_id=ZIMAGE_TURBO_MODEL_ID,
|
||||
require_cache=prompt_cached,
|
||||
)
|
||||
self._zimage_pipe = pipe
|
||||
return pipe
|
||||
|
||||
def _load_sam(self) -> tuple[Any, Any]:
|
||||
if self._sam_model is not None and self._sam_processor is not None:
|
||||
return self._sam_model, self._sam_processor
|
||||
self._progress("Loading SAM face-mask model...")
|
||||
try:
|
||||
from transformers import AutoModelForMaskGeneration, AutoProcessor
|
||||
except ImportError as exc:
|
||||
raise ImportError("SAM needs transformers and torchvision from the qwen-zimage extra.") from exc
|
||||
kwargs: dict[str, Any] = {}
|
||||
if self.hf_token:
|
||||
kwargs["token"] = self.hf_token
|
||||
processor = AutoProcessor.from_pretrained(SAM_MODEL_ID, **kwargs)
|
||||
model = AutoModelForMaskGeneration.from_pretrained(
|
||||
SAM_MODEL_ID,
|
||||
torch_dtype=self._face_stage_dtype(),
|
||||
**kwargs,
|
||||
).to(self.device)
|
||||
model.eval()
|
||||
self._sam_model = model
|
||||
self._sam_processor = processor
|
||||
return model, processor
|
||||
|
||||
def _sam_masks(
|
||||
self,
|
||||
image: Image.Image,
|
||||
boxes: list[tuple[int, int, int, int]],
|
||||
) -> list[np.ndarray]:
|
||||
if not boxes:
|
||||
return []
|
||||
import torch
|
||||
|
||||
try:
|
||||
model, processor = self._load_sam()
|
||||
input_points, input_labels = _sam_point_prompts(boxes)
|
||||
inputs = processor(
|
||||
images=image,
|
||||
input_boxes=[[list(box) for box in boxes]],
|
||||
input_points=input_points,
|
||||
input_labels=input_labels,
|
||||
return_tensors="pt",
|
||||
)
|
||||
original_sizes = inputs["original_sizes"].clone()
|
||||
reshaped_sizes = inputs["reshaped_input_sizes"].clone()
|
||||
inputs = _prepare_sam_inputs(inputs, self.device, self._face_stage_dtype())
|
||||
with torch.inference_mode():
|
||||
outputs = model(**inputs, multimask_output=True)
|
||||
processed = processor.post_process_masks(
|
||||
outputs.pred_masks.detach().cpu(),
|
||||
original_sizes,
|
||||
reshaped_sizes,
|
||||
)[0]
|
||||
mask_array, score_array = _sam_outputs_to_numpy(processed, outputs.iou_scores)
|
||||
binary_masks = _select_sam_masks(
|
||||
mask_array,
|
||||
score_array,
|
||||
)
|
||||
return _clip_sam_masks_to_boxes(binary_masks, boxes, image.size)
|
||||
except Exception as exc:
|
||||
log.warning("SAM face-mask refinement failed (%s); using box-derived ellipse masks", exc)
|
||||
return _ellipse_masks(boxes, image.size)
|
||||
|
||||
def preload(self, *, global_only: bool = False) -> None:
|
||||
"""Eagerly load the mandatory stage and, by default, the face stack."""
|
||||
self._load_global()
|
||||
_yunet_model_path()
|
||||
if not global_only:
|
||||
self._load_zimage()
|
||||
self._load_sam()
|
||||
|
||||
def _load_global(self) -> Any:
|
||||
"""Load and cache the profile's global model stack."""
|
||||
raise NotImplementedError("A profile pipeline must implement the global stage loader")
|
||||
|
||||
def _run_global(self, image: Image.Image, strength: float, seed: int | None) -> Image.Image:
|
||||
"""Regenerate one frame with the profile's global stage."""
|
||||
raise NotImplementedError("A profile pipeline must implement the global stage")
|
||||
|
||||
def _vae_roundtrip(self, image: Image.Image) -> Image.Image:
|
||||
"""Reconstruct source pixels through the profile's already loaded VAE."""
|
||||
raise NotImplementedError(f"{type(self).__name__} does not expose a VAE donor for verified text restoration")
|
||||
|
||||
@staticmethod
|
||||
def _detail_size(
|
||||
crop_size: tuple[int, int],
|
||||
face_size: tuple[int, int],
|
||||
) -> tuple[int, int]:
|
||||
"""Scale a crop toward a 768px face guide while capping it at 1024px."""
|
||||
crop_width, crop_height = crop_size
|
||||
face_width, face_height = face_size
|
||||
scale_for_face = 768.0 / max(1, max(face_width, face_height))
|
||||
scale_for_crop = 1024.0 / max(1, max(crop_width, crop_height))
|
||||
scale = min(scale_for_face, scale_for_crop)
|
||||
# Never shrink below the crop's current size unless the cap requires it.
|
||||
if max(crop_width, crop_height) <= 1024:
|
||||
scale = max(1.0, scale)
|
||||
width = max(16, round(crop_width * scale / 16.0) * 16)
|
||||
height = max(16, round(crop_height * scale / 16.0) * 16)
|
||||
return width, height
|
||||
|
||||
def _run_faces(
|
||||
self,
|
||||
original: Image.Image,
|
||||
global_result: Image.Image,
|
||||
boxes: list[tuple[int, int, int, int]],
|
||||
masks: list[np.ndarray],
|
||||
*,
|
||||
strength: float,
|
||||
seed: int | None,
|
||||
) -> Image.Image:
|
||||
if not boxes:
|
||||
return global_result
|
||||
pipe = self._load_zimage()
|
||||
base = np.asarray(global_result.convert("RGB")).copy()
|
||||
source = np.asarray(original.convert("RGB"))
|
||||
detail_seed = None if seed is None else seed + 1
|
||||
|
||||
for index, (box, mask) in enumerate(zip(boxes, masks, strict=True), start=1):
|
||||
crop_box = _expanded_box(box, original.size)
|
||||
cx1, cy1, cx2, cy2 = crop_box
|
||||
crop_source = source[cy1:cy2, cx1:cx2]
|
||||
crop_mask = mask[cy1:cy2, cx1:cx2]
|
||||
if crop_source.size == 0 or not np.any(crop_mask):
|
||||
continue
|
||||
|
||||
face_width = box[2] - box[0]
|
||||
face_height = box[3] - box[1]
|
||||
process_size = self._detail_size((cx2 - cx1, cy2 - cy1), (face_width, face_height))
|
||||
crop_image = Image.fromarray(crop_source).resize(process_size, Image.Resampling.LANCZOS)
|
||||
self._progress(
|
||||
f"Regenerating face {index}/{len(boxes)} with Z-Image: strength={strength:.4f}, steps={FACE_STEPS}..."
|
||||
)
|
||||
detailed = pipe(**build_face_kwargs(crop_image, strength=strength, seed=detail_seed))
|
||||
detailed = detailed.convert("RGB").resize((cx2 - cx1, cy2 - cy1), Image.Resampling.LANCZOS)
|
||||
|
||||
base_crop = base[cy1:cy2, cx1:cx2]
|
||||
base[cy1:cy2, cx1:cx2] = composite_face(
|
||||
base_crop,
|
||||
np.asarray(detailed),
|
||||
crop_mask,
|
||||
feather=10,
|
||||
)
|
||||
return Image.fromarray(base)
|
||||
|
||||
def run(
|
||||
self,
|
||||
image: Image.Image,
|
||||
*,
|
||||
strength: float | None,
|
||||
seed: int | None,
|
||||
tile: bool = False,
|
||||
tile_size: int = 1024,
|
||||
tile_overlap: int = 128,
|
||||
text_manifest: VerifiedTextManifest | None = None,
|
||||
fidelity_anchor: bool = False,
|
||||
) -> Image.Image:
|
||||
"""Execute global regeneration and masked face repair."""
|
||||
if text_manifest is not None and tile:
|
||||
raise ValueError("Verified text restoration is not calibrated with tiled diffusion")
|
||||
self._require_cuda()
|
||||
seed = resolve_seed(seed)
|
||||
donor = None
|
||||
if text_manifest is not None:
|
||||
self._progress("Reconstructing the verified text donor...")
|
||||
donor = self._vae_roundtrip(image)
|
||||
global_strength = (
|
||||
resolution_adaptive_denoise(image.width, image.height) if strength is None else float(strength)
|
||||
)
|
||||
if tile and max(image.size) > tile_size:
|
||||
from remove_ai_watermarks._internal.tiling import run_tiled
|
||||
|
||||
global_result = run_tiled(
|
||||
lambda tile_image: self._run_global(tile_image, global_strength, seed),
|
||||
image,
|
||||
tile_size,
|
||||
tile_overlap,
|
||||
self._progress,
|
||||
)
|
||||
else:
|
||||
global_result = self._run_global(image, global_strength, seed)
|
||||
|
||||
self._progress("Detecting faces on the original image...")
|
||||
boxes = detect_faces(image)
|
||||
if not boxes:
|
||||
self._progress("No faces detected; keeping the global result.")
|
||||
result = global_result
|
||||
else:
|
||||
masks = self._sam_masks(image, boxes)
|
||||
face_strength = largest_face_denoise(boxes, image.size) * FACE_DENOISE_SCALE
|
||||
result = self._run_faces(
|
||||
image,
|
||||
global_result,
|
||||
boxes,
|
||||
masks,
|
||||
strength=face_strength,
|
||||
seed=seed,
|
||||
)
|
||||
if text_manifest is None:
|
||||
return result
|
||||
if donor is None:
|
||||
raise RuntimeError("Verified text restoration requires a VAE donor")
|
||||
from remove_ai_watermarks._internal.text_restoration import (
|
||||
blend_fidelity_anchor,
|
||||
restore_verified_text,
|
||||
)
|
||||
|
||||
# The anchor is OFF by default since 0.27.1: blending 15% of the Qwen-VAE
|
||||
# donor ACROSS THE WHOLE FRAME returned detector-visible OpenAI SynthID on
|
||||
# poster-scale manifests (official Content Provenance API, 2026-08-19:
|
||||
# detected x6 with the anchor, clean x6 without it, base clean throughout;
|
||||
# see docs/text-protection-research.md). ``fidelity_anchor=True`` keeps the
|
||||
# 0.27.0 research behavior for reproduction.
|
||||
if fidelity_anchor:
|
||||
self._progress("Blending the Qwen-VAE fidelity anchor...")
|
||||
anchor = blend_fidelity_anchor(result, donor)
|
||||
else:
|
||||
anchor = result
|
||||
self._progress(f"Restoring {len(text_manifest.lines)} verified text lines...")
|
||||
return restore_verified_text(image, anchor, donor, text_manifest.lines)
|
||||
@@ -1,6 +1,6 @@
|
||||
"""Project-owned configuration for invisible-watermark regeneration profiles.
|
||||
|
||||
Two profiles remain, and both are CUDA-only: ``qwen-zimage`` (the default) and
|
||||
Three profiles remain, and all are CUDA-only: ``qwen-zimage`` (the default),
|
||||
``sdxl-zimage``. The older ``controlnet``, ``sdxl``, ``qwen`` and ``default`` profiles
|
||||
were removed rather than kept as a CPU path, because none of them matched the two-stage
|
||||
recipe's face preservation and keeping them implied a quality this library no longer
|
||||
@@ -18,14 +18,15 @@ if TYPE_CHECKING:
|
||||
# SDXL base is no longer a profile of its own, but it is still the global stage of
|
||||
# sdxl-zimage, so the checkpoint id stays. Named for what it is rather than
|
||||
# ``DEFAULT_MODEL_ID``: there is no user-selectable model any more, so "default"
|
||||
# implied an override that both profiles reject.
|
||||
# implied an override that every profile rejects.
|
||||
SDXL_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
CONTROLNET_CANNY_MODEL = "xinsir/controlnet-canny-sdxl-1.0"
|
||||
|
||||
QWEN_ZIMAGE_PROFILE = "qwen-zimage"
|
||||
SDXL_ZIMAGE_PROFILE = "sdxl-zimage"
|
||||
CHROMA_ZIMAGE_PROFILE = "chroma-zimage"
|
||||
DEFAULT_PROFILE = QWEN_ZIMAGE_PROFILE
|
||||
PROFILE_CHOICES = (QWEN_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE)
|
||||
PROFILE_CHOICES = (QWEN_ZIMAGE_PROFILE, SDXL_ZIMAGE_PROFILE, CHROMA_ZIMAGE_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
|
||||
@@ -43,12 +44,19 @@ INVISIBLE_EXTRA = "'remove-ai-watermarks[qwen-zimage]'"
|
||||
# no-op at best. sdxl-zimage's global pass leaves the softer output the polish exists
|
||||
# for. This is per-profile data, not a CLI concern: the flag defaults to None so that
|
||||
# "the user did not choose" stays a value rather than an inference from Click state.
|
||||
PROFILE_ADAPTIVE_POLISH = {QWEN_ZIMAGE_PROFILE: False, SDXL_ZIMAGE_PROFILE: True}
|
||||
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,
|
||||
}
|
||||
|
||||
SDXL_LIGHTNING_MODEL_ID = "ByteDance/SDXL-Lightning"
|
||||
SDXL_LIGHTNING_PATTERN = "sdxl_lightning_4step_lora.safetensors"
|
||||
|
||||
# Both profiles are certified at a fixed seed because SynthID removal near the
|
||||
# All profiles are certified at a fixed seed because SynthID removal near the
|
||||
# strength floor is seed-dependent. The step count and CFG are not settable at all --
|
||||
# each stage owns them (``GLOBAL_STEPS`` / ``FACE_STEPS`` in qwen_zimage_pipeline).
|
||||
PROFILE_SEED = 0
|
||||
@@ -117,9 +125,44 @@ _SDXL_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
|
||||
"openai": SDXL_ZIMAGE_OPENAI_STRENGTH,
|
||||
"google": SDXL_ZIMAGE_GEMINI_STRENGTH,
|
||||
}
|
||||
|
||||
# chroma-zimage floors, from the 2026-08-29/30 four-cohort oracle calibration
|
||||
# (docs/chroma1-engine-research.md; ChromaImg2ImgPipeline, neutral prompt,
|
||||
# guidance 5.0, four effective steps, seed 0). Flat per-vendor values derived
|
||||
# by the same worst-clean-plus-one-spread rule as the other profiles:
|
||||
#
|
||||
# - OpenAI: first-clean boundaries 0.06 / 0.06 / 0.075 on three fixtures ->
|
||||
# 0.075 + (0.075 - 0.06) = 0.09. Chroma1's OpenAI floor is BELOW qwen's
|
||||
# 0.07675 while its matched-strength fidelity beats qwen on every metric.
|
||||
# - Microsoft InvisMark: paint-1 (0.06, 0.08], paint-2 <= 0.04, paint-3
|
||||
# (0.06, 0.08] -> 0.08 + (0.08 - 0.04) = 0.12, rounded up to the measured
|
||||
# rung 0.125, oracle-verified clean on both worst sources. BELOW qwen's 0.15.
|
||||
# - Google: 633uuy (0.20, 0.25], akdbei (0.20, 0.25], y48j3c (0.08, 0.12],
|
||||
# 3mc4t9 (0.08, 0.12] -> 0.25 + (0.25 - 0.12) = 0.38, rounded up to 0.40,
|
||||
# oracle-verified clean on the worst fixture. ABOVE qwen's 0.27; at 0.40 the
|
||||
# regeneration destroys dense text and collapses face identity, which is why
|
||||
# the matched-strength addendum recommends the adaptive-strength follow-up.
|
||||
# - Meta Content Seal: lighthouse (0.08, 0.10], fox (0.06, 0.08], night_city
|
||||
# (0.045, 0.06], studio_mug (0.03, 0.045], text_poster <= 0.03 ->
|
||||
# 0.10 + (0.10 - 0.03) = 0.17. ABOVE qwen's 0.1; the floors exist because
|
||||
# Chroma1's boundaries scatter wider than qwen's, not because the engine is
|
||||
# worse per unit strength.
|
||||
CHROMA_ZIMAGE_OPENAI_STRENGTH = 0.09
|
||||
CHROMA_ZIMAGE_MICROSOFT_STRENGTH = 0.125
|
||||
CHROMA_ZIMAGE_GOOGLE_STRENGTH = 0.40
|
||||
CHROMA_ZIMAGE_META_STRENGTH = 0.17
|
||||
CHROMA_ZIMAGE_UNKNOWN_STRENGTH = CHROMA_ZIMAGE_GOOGLE_STRENGTH
|
||||
|
||||
_CHROMA_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
|
||||
"openai": CHROMA_ZIMAGE_OPENAI_STRENGTH,
|
||||
"google": CHROMA_ZIMAGE_GOOGLE_STRENGTH,
|
||||
"microsoft": CHROMA_ZIMAGE_MICROSOFT_STRENGTH,
|
||||
"meta": CHROMA_ZIMAGE_META_STRENGTH,
|
||||
}
|
||||
_ALIASES = {
|
||||
"qwen_zimage": QWEN_ZIMAGE_PROFILE,
|
||||
"sdxl_zimage": SDXL_ZIMAGE_PROFILE,
|
||||
"chroma_zimage": CHROMA_ZIMAGE_PROFILE,
|
||||
}
|
||||
|
||||
|
||||
@@ -130,7 +173,7 @@ def normalize_profile(profile: str) -> str:
|
||||
|
||||
|
||||
def resolve_seed(seed: int | None) -> int:
|
||||
"""Keep both profiles reproducible by default."""
|
||||
"""Keep every profile reproducible by default."""
|
||||
return PROFILE_SEED if seed is None else seed
|
||||
|
||||
|
||||
@@ -149,7 +192,9 @@ def strength_default_help() -> str:
|
||||
f"Microsoft InvisMark {QWEN_ZIMAGE_MICROSOFT_STRENGTH} / Meta Content Seal "
|
||||
f"{QWEN_ZIMAGE_META_STRENGTH} floors; sdxl-zimage "
|
||||
f"uses OpenAI {SDXL_ZIMAGE_OPENAI_STRENGTH} / Google {SDXL_ZIMAGE_GEMINI_STRENGTH} / "
|
||||
f"unknown {SDXL_ZIMAGE_UNKNOWN_STRENGTH}, from the C2PA issuer)"
|
||||
f"unknown {SDXL_ZIMAGE_UNKNOWN_STRENGTH}; chroma-zimage uses OpenAI "
|
||||
f"{CHROMA_ZIMAGE_OPENAI_STRENGTH} / Microsoft {CHROMA_ZIMAGE_MICROSOFT_STRENGTH} / "
|
||||
f"Google {CHROMA_ZIMAGE_GOOGLE_STRENGTH} / Meta {CHROMA_ZIMAGE_META_STRENGTH}, from the C2PA issuer)"
|
||||
)
|
||||
|
||||
|
||||
@@ -173,14 +218,17 @@ def resolve_strength(
|
||||
"""
|
||||
if strength is not None:
|
||||
return strength
|
||||
if normalize_profile(pipeline or "") == SDXL_ZIMAGE_PROFILE:
|
||||
normalized = normalize_profile(pipeline or "")
|
||||
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)
|
||||
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())
|
||||
if vendor_strength is not None:
|
||||
return vendor_strength
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import resolution_adaptive_denoise
|
||||
from remove_ai_watermarks._internal.two_stage_pipeline import resolution_adaptive_denoise
|
||||
|
||||
return resolution_adaptive_denoise(*size)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ from typing import TYPE_CHECKING, Any
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
CHROMA_ZIMAGE_PROFILE,
|
||||
DEFAULT_PROFILE,
|
||||
INVISIBLE_EXTRA,
|
||||
PROFILE_CHOICES,
|
||||
@@ -80,7 +81,7 @@ def _cuda_works() -> bool:
|
||||
def get_device() -> str:
|
||||
"""Return ``"cuda"`` when a usable CUDA backend is present, else ``"cpu"``.
|
||||
|
||||
Deliberately binary. Both profiles are CUDA-only, so an XPU or MPS answer would
|
||||
Deliberately binary. All profiles are CUDA-only, so an XPU or MPS answer would
|
||||
only travel one frame further to the same refusal in :class:`WatermarkRemover`,
|
||||
while costing a probe on each. ``"cpu"`` here means "no CUDA", which is exactly
|
||||
what that refusal reports.
|
||||
@@ -117,13 +118,13 @@ class WatermarkRemover:
|
||||
_ensure_watermark_deps()
|
||||
selected_device = (device or get_device()).casefold()
|
||||
self.device = get_device() if selected_device == "auto" else selected_device
|
||||
# CUDA is a precondition of the object, not of the run. Both profiles raise on
|
||||
# CUDA is a precondition of the object, not of the run. All profiles raise on
|
||||
# any other device, so accepting one here only defers a guaranteed failure to
|
||||
# model-load time, several layers down and under the wrong profile's name.
|
||||
if self.device != "cuda":
|
||||
raise ValueError(
|
||||
f"Invisible-watermark removal is CUDA-only, so '{self.device}' cannot run it. "
|
||||
"Both remaining profiles need an NVIDIA GPU. Visible-mark removal and "
|
||||
"All profiles need an NVIDIA GPU. Visible-mark removal and "
|
||||
"every identify command still run on CPU."
|
||||
)
|
||||
|
||||
@@ -131,6 +132,10 @@ 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:
|
||||
self.torch_dtype = torch.bfloat16 # type: ignore[union-attr]
|
||||
|
||||
@@ -150,6 +155,10 @@ class WatermarkRemover:
|
||||
from remove_ai_watermarks._internal.sdxl_zimage_pipeline import (
|
||||
SdxlZImagePipeline as _Pipeline,
|
||||
)
|
||||
elif self.model_profile == CHROMA_ZIMAGE_PROFILE:
|
||||
from remove_ai_watermarks._internal.chroma_zimage_pipeline import (
|
||||
ChromaZImagePipeline as _Pipeline,
|
||||
)
|
||||
else:
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import (
|
||||
QwenZImagePipeline as _Pipeline,
|
||||
@@ -194,7 +203,7 @@ class WatermarkRemover:
|
||||
) -> Path:
|
||||
"""Regenerate image pixels and write the result without AI metadata.
|
||||
|
||||
Step count and CFG are not parameters. Each stage of both profiles is a
|
||||
Step count and CFG are not parameters. Each stage of every profile is a
|
||||
distilled schedule that owns its own, so the only thing a caller-supplied
|
||||
value could do is break the run or be rejected.
|
||||
"""
|
||||
@@ -207,7 +216,7 @@ class WatermarkRemover:
|
||||
resolved_strength = resolve_strength(strength, vendor, self.model_profile, size=source.size)
|
||||
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 == SDXL_ZIMAGE_PROFILE:
|
||||
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")
|
||||
|
||||
@@ -52,8 +52,8 @@ _MIN_DETECT_SHORT_SIDE = 200
|
||||
# This used to be ONE shared 0.7 for every text mark. Measured 2026-07-18 on the
|
||||
# `auto` path (the default -- no flag, driven by TC260 metadata), it turned out to
|
||||
# mean two completely different things per mark. Blind hand-label of the ADDITIONS
|
||||
# (accepted with provenance, rejected without) over a labelled TC260 evaluation set,
|
||||
# two-sided control (labeller sensitivity 100%/96%, specificity 100%/100%):
|
||||
# (accepted with provenance, rejected without) over a labeled TC260 evaluation set,
|
||||
# two-sided control (labeler sensitivity 100%/96%, specificity 100%/100%):
|
||||
#
|
||||
# mark band precision 95% CI n
|
||||
# doubao whole arm 76% 61-87% 42
|
||||
@@ -89,8 +89,8 @@ class TextMarkConfig:
|
||||
name: str # short label for log lines (e.g. "Doubao")
|
||||
asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png")
|
||||
corner: Literal[
|
||||
"br", "bl", "tl", "bc"
|
||||
] # bottom-right (Doubao/Jimeng), bottom-left (Samsung), top-left (RunningHub), bottom-center (LibLibAI)
|
||||
"br", "bl", "tl", "tr", "bc"
|
||||
] # br (Doubao/Jimeng), bl (Samsung), tl (RunningHub), tr (Microsoft), bc (LiblibAI)
|
||||
margin_floor: int # min margin in px for locate (4 for br marks, 2 for Samsung)
|
||||
# locate geometry (fraction of scale_base -- see scale_base())
|
||||
width_frac: float
|
||||
@@ -131,7 +131,7 @@ class TextMarkConfig:
|
||||
template_blur: float = 0.0
|
||||
# Which image dimension the mark's size and margins scale with. VENDOR-SPECIFIC,
|
||||
# measured, not assumed -- see TextMarkEngine.scale_base. "short" = min(h, w), "width" = w.
|
||||
scale_basis: Literal["short", "width"] = "width"
|
||||
scale_basis: Literal["short", "width", "long"] = "width"
|
||||
# Scale rungs ``_ladder_best`` sweeps (the detection comb). PER-MARK: a vendor
|
||||
# whose stamp sizes do not land on the shared 3-rung comb carries its own ladder
|
||||
# (measured for 千问, whose marks sit in two size modes ~1.6x apart -- one fraction
|
||||
@@ -309,7 +309,7 @@ class TextMarkEngine:
|
||||
provenance relaxation it stopped trying because many Jimeng false additions
|
||||
were actually Doubao marks.
|
||||
|
||||
Measured separability on hand-labelled examples, scoring BOTH templates
|
||||
Measured separability on hand-labeled examples, scoring BOTH templates
|
||||
against the same glyph blob:
|
||||
|
||||
feature separability (0.5 = useless, 1.0 = perfect)
|
||||
@@ -480,10 +480,19 @@ class TextMarkEngine:
|
||||
|
||||
China's GB 45438-2025 clause 5.2(e) mandates glyph height >= 5% of "the
|
||||
shortest side" for CN marks, which is why a short-side basis is the natural
|
||||
prior -- but Jimeng's measured behaviour overrides the prior, and measurement
|
||||
prior -- but Jimeng's measured behavior overrides the prior, and measurement
|
||||
wins over the standard's wording.
|
||||
|
||||
"long" (max of the two sides) is the Microsoft badge's measured basis: the
|
||||
pill tracks the RENDER dimension, so on a 1024x1536 portrait it scales with
|
||||
the 1536 (a width basis undersized the template by the aspect ratio and the
|
||||
portrait carriers fell to 0.15-0.32 NCC; measured 2026-08-27).
|
||||
"""
|
||||
return min(image.shape[:2]) if self.config.scale_basis == "short" else image.shape[1]
|
||||
if self.config.scale_basis == "short":
|
||||
return min(image.shape[:2])
|
||||
if self.config.scale_basis == "long":
|
||||
return max(image.shape[:2])
|
||||
return image.shape[1]
|
||||
|
||||
def locate(self, image: NDArray[Any]) -> TextMarkLocation:
|
||||
"""Anchor the watermark box in the configured corner, scaled by ``scale_basis``.
|
||||
@@ -499,14 +508,14 @@ class TextMarkEngine:
|
||||
wm_h = max(16, int(base * c.height_frac))
|
||||
margin_x = max(c.margin_floor, int(base * c.margin_x_frac))
|
||||
margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac))
|
||||
if c.corner == "br":
|
||||
if c.corner == "br" or c.corner == "tr":
|
||||
x = max(0, w - margin_x - wm_w)
|
||||
elif c.corner == "bc": # bottom-center: horizontally centered, margin_x unused
|
||||
x = max(0, (w - wm_w) // 2)
|
||||
else:
|
||||
x = min(margin_x, max(0, w - wm_w))
|
||||
# "tl" anchors at the top instead: margin_bottom_frac is then the TOP margin.
|
||||
y = min(margin_b, max(0, h - wm_h)) if c.corner == "tl" else max(0, h - margin_b - wm_h)
|
||||
# "tl"/"tr" anchor at the top instead: margin_bottom_frac is then the TOP margin.
|
||||
y = min(margin_b, max(0, h - wm_h)) if c.corner in ("tl", "tr") else max(0, h - margin_b - wm_h)
|
||||
wm_w = min(wm_w, w - x)
|
||||
wm_h = min(wm_h, h - y)
|
||||
return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h)
|
||||
@@ -695,7 +704,7 @@ class TextMarkEngine:
|
||||
|
||||
OVERRIDABLE, and the override contract is specifically the DETECTOR'S MATCH BOX:
|
||||
a mark whose removable footprint reaches beyond what the NCC localizes -- Baidu's
|
||||
flat white tag right of the text run, LibLibAI's triangle logo left of the
|
||||
flat white tag right of the text run, LiblibAI's triangle logo left of the
|
||||
wordmark -- supplies its own extension here and inherits the rest of the
|
||||
footprint path. The blob-bbox branch never routes through an override.
|
||||
"""
|
||||
@@ -771,7 +780,7 @@ class TextMarkEngine:
|
||||
"""Footprint policy for a mark whose fill must be bounded by the DETECTOR's match
|
||||
box and never by the binary glyph blob.
|
||||
|
||||
Baidu's white tag has a flat interior a top-hat cannot answer, and LibLibAI's
|
||||
Baidu's white tag has a flat interior a top-hat cannot answer, and LiblibAI's
|
||||
blob bleeds up into background structure; in both cases the blob bbox is
|
||||
measurably wrong and the NCC match box is right. ``force`` takes priority here,
|
||||
unlike the default policy: a ``--no-detect`` caller named the mark, so the whole
|
||||
|
||||
@@ -73,7 +73,7 @@ def _tc260_vendors(path: Path) -> frozenset[str]:
|
||||
|
||||
An absent, unreadable or unmapped producer falls back to the historical pair rather
|
||||
than to nothing: the caller has already established that the AIGC signal fired, so
|
||||
the image IS China-AIGC labelled, and dropping to no relaxation would lose the
|
||||
the image IS China-AIGC labeled, and dropping to no relaxation would lose the
|
||||
detections the fallback recovers today. The re-read is deliberately isolated -- a
|
||||
failure here must narrow the answer, never discard the rest of the provenance.
|
||||
"""
|
||||
@@ -156,9 +156,10 @@ def remove_visible(
|
||||
) -> tuple[NDArray[Any], list[str]]:
|
||||
"""Remove every detected known visible AI mark through localize then fill.
|
||||
|
||||
The registry currently covers the Gemini sparkle; Doubao, Jimeng, Qwen, Kling,
|
||||
Yuanbao, Samsung, RunningHub, Baidu, and LibLibAI text marks; and the Jimeng
|
||||
pill. Returns ``(result_bgr, [labels removed])``.
|
||||
The registry currently covers the Gemini visible watermark; Doubao, Jimeng,
|
||||
Qwen, Kling AI, Yuanbao, Samsung, RunningHub, Baidu, and LiblibAI text marks;
|
||||
one Microsoft top-right AI-badge variant; and the Jimeng pill. Returns
|
||||
``(result_bgr, [labels removed])``.
|
||||
|
||||
``source`` is a file path OR a BGR ndarray. For a PATH, metadata provenance is read
|
||||
automatically (so ``sensitivity="auto"`` recovers a moved/faint mark whenever the
|
||||
@@ -184,7 +185,7 @@ def remove_visible(
|
||||
from remove_ai_watermarks import watermark_registry
|
||||
|
||||
# Reject a removed sensitivity loudly; `Sensitivity` is a Literal and not enforced
|
||||
# at runtime, so a 0.15 caller would otherwise get `auto` behaviour in silence.
|
||||
# at runtime, so a 0.15 caller would otherwise get `auto` behavior in silence.
|
||||
watermark_registry.validate_sensitivity(sensitivity)
|
||||
loaded = _load_visible_input(source)
|
||||
result, removed = watermark_registry.remove_auto_marks(
|
||||
|
||||
|
After Width: | Height: | Size: 2.4 KiB |
@@ -162,7 +162,7 @@ def _resolved_strength_for_display(
|
||||
) -> float:
|
||||
"""Resolve the same profile-specific strength the engine will execute.
|
||||
|
||||
One call for both profiles, so the printed value cannot drift from the executed
|
||||
One call for every profile, so the printed value cannot drift from the executed
|
||||
one; the size is what qwen-zimage derives its strength from.
|
||||
"""
|
||||
from PIL import Image
|
||||
@@ -254,8 +254,10 @@ _PIPELINE_CHOICES = list(PROFILE_CHOICES)
|
||||
_PIPELINE_HELP = (
|
||||
"Pipeline profile. qwen-zimage (DEFAULT) = Qwen-Image-2512 + Lightning + Canny, "
|
||||
"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. Both are "
|
||||
"CUDA-ONLY -- install the qwen-zimage extra. There is no CPU or MPS profile for "
|
||||
"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). "
|
||||
"All are CUDA-ONLY -- install the qwen-zimage extra. There is no CPU or MPS profile for "
|
||||
"invisible-watermark removal."
|
||||
)
|
||||
|
||||
@@ -304,10 +306,10 @@ _seed_option = click.option(
|
||||
"--seed",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Random seed for reproducibility. Default 0: both profiles are certified "
|
||||
help="Random seed for reproducibility. Default 0: all profiles are certified "
|
||||
"at a fixed seed, because SynthID removal near the strength floor is seed-dependent.",
|
||||
)
|
||||
_hf_token_option = click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
|
||||
_hf_token_option = click.option("--hf-token", type=str, default=None, help="Hugging Face API token.")
|
||||
_humanize_option = click.option(
|
||||
"--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
|
||||
)
|
||||
@@ -541,7 +543,7 @@ def _should_skip_invisible_scrub(force: bool, image_path: Path) -> bool:
|
||||
@click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging.")
|
||||
@click.pass_context
|
||||
def main(ctx: click.Context, verbose: bool) -> None:
|
||||
"""Remove visible and invisible AI watermarks from images, plus provenance metadata from video."""
|
||||
"""Remove visible and invisible AI watermarks, plus metadata provenance marks, from images and video."""
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv() # Load .env (e.g. HF_TOKEN)
|
||||
@@ -859,7 +861,7 @@ def cmd_invisible(
|
||||
text_manifest: Path | None,
|
||||
fidelity_anchor: bool,
|
||||
) -> None:
|
||||
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
|
||||
"""Attempt to disrupt invisible AI watermarks through pixel regeneration.
|
||||
|
||||
Regenerates the pixels with the two-stage diffusion profile. CUDA-only:
|
||||
pip install 'remove-ai-watermarks[qwen-zimage]'
|
||||
@@ -1001,8 +1003,8 @@ def cmd_metadata(
|
||||
Strips EXIF AI tags, PNG text chunks, C2PA provenance manifests, and the
|
||||
China TC260 AIGC label. Beyond images (PNG/JPEG/WebP/AVIF/HEIF/JXL) it also
|
||||
strips provenance metadata from MP4/MOV/M4V/M4A containers and, via ffmpeg,
|
||||
from WebM/MKV/AVI/FLV/MP3/WAV/FLAC/OGG. The coded image, audio, and video
|
||||
data are left untouched.
|
||||
from WebM/MKV/MKA/AVI/FLV/MP3/WAV/FLAC/OGG/OGA/Opus/AAC. The coded image,
|
||||
audio, and video data are left untouched.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, strip_and_verify
|
||||
|
||||
|
||||
@@ -97,7 +97,7 @@ class _DecodeMaxDct:
|
||||
return decoded
|
||||
|
||||
def _plane_bits(self, trimmed: NDArray[Any], channel: int, scale: int) -> NDArray[Any]:
|
||||
"""Block bits for one colour plane, a strip of block-rows at a time.
|
||||
"""Block bits for one color plane, a strip of block-rows at a time.
|
||||
|
||||
``dwt2`` is ``dwtn``: it transforms along axis 0, then along axis 1 over
|
||||
both halves, and three of the four bands it returns are discarded here.
|
||||
|
||||
@@ -145,7 +145,7 @@ _INVISIBLE_WM_CAVEAT = (
|
||||
"or resizing, so it confirms origin only on a pristine (un-re-encoded) file."
|
||||
)
|
||||
_HF_JOB_CAVEAT = (
|
||||
"The hf-job-id tag marks a HuggingFace-hosted job (commonly diffusion "
|
||||
"The hf-job-id tag marks a Hugging Face-hosted job (commonly diffusion "
|
||||
"generation) but names neither the model nor the content type, so it is a "
|
||||
"medium-confidence signal, not proof the pixels are AI-generated."
|
||||
)
|
||||
@@ -445,7 +445,7 @@ def evidence_from_metadata_record(
|
||||
if iptc_system:
|
||||
ai_metadata.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({iptc_system})")
|
||||
if hf_job:
|
||||
ai_metadata.setdefault("huggingface_job", f"HuggingFace-hosted job ({hf_job})")
|
||||
ai_metadata.setdefault("huggingface_job", f"Hugging Face-hosted job ({hf_job})")
|
||||
if samsung is not None:
|
||||
ai_metadata.setdefault("samsung_genai", f"Samsung Galaxy AI editing marker (genAIType={samsung})")
|
||||
|
||||
@@ -953,7 +953,7 @@ def _visible_sparkle(image_path: Path, *, image: NDArray[Any] | None = None) ->
|
||||
# metadata label); the per-engine detection thresholds live in the registry.
|
||||
# Text mark -> the platform sentence this report prints when that mark is the strongest
|
||||
# evidence, DERIVED from the registry rows so registering a mark is one edit. It was a
|
||||
# hand-maintained copy, and that class of copy is how LibLibAI ended up registered but
|
||||
# hand-maintained copy, and that class of copy is how LiblibAI ended up registered but
|
||||
# missing from the pill veto. Insertion order is the registry's, which is what fixes the
|
||||
# scan order below. The Gemini sparkle and the capture-less pill carry no platform of
|
||||
# their own (`KnownMark.platform is None`) and are excluded here: the sparkle has its
|
||||
@@ -1105,7 +1105,7 @@ def _collect_visible_signals(
|
||||
sparkle_conf = _visible_sparkle(image_path, image=image)
|
||||
if sparkle_conf is not None and sparkle_conf >= _SPARKLE_THRESHOLD:
|
||||
signals.append(Signal("visible_sparkle", f"NCC confidence {sparkle_conf:.2f}", "medium"))
|
||||
watermarks.append(f"Visible Gemini sparkle (confidence {sparkle_conf:.2f})")
|
||||
watermarks.append(f"Google Gemini visible watermark (sparkle; confidence {sparkle_conf:.2f})")
|
||||
if platform is None:
|
||||
platform = "Google Gemini family (visible sparkle detected)"
|
||||
|
||||
@@ -1278,12 +1278,12 @@ def _identify_from_evidence(
|
||||
# vendor-token inference must not add a second, differently-attributed
|
||||
# invisible watermark (Microsoft Designer: "Azure OpenAI ImageGen" agent +
|
||||
# the InvisMark watermarked action read as "SynthID per OpenAI").
|
||||
|
||||
soft_binding_vendors = soft_binding_vendors_in(region)
|
||||
if (
|
||||
not synthid
|
||||
and trained_source
|
||||
and c2pa_marker_in(head)
|
||||
and not soft_binding_vendors_in(region)
|
||||
and not soft_binding_vendors
|
||||
and (vendors := synthid_evidence_vendors_in(region))
|
||||
):
|
||||
synthid = synthid_verdict(", ".join(vendors))
|
||||
@@ -1300,7 +1300,7 @@ def _identify_from_evidence(
|
||||
# ── C2PA soft-binding: a named forensic/third-party watermark vendor ─
|
||||
# (Adobe TrustMark, Digimarc, Imatag, ...). Present in the manifest even when
|
||||
# the watermark itself can't be decoded; names whose watermark stamped the pixels.
|
||||
soft_binding = meta.get("soft_binding") or (", ".join(v) if (v := soft_binding_vendors_in(region)) else None)
|
||||
soft_binding = meta.get("soft_binding") or (", ".join(soft_binding_vendors) if soft_binding_vendors else None)
|
||||
if soft_binding:
|
||||
soft_binding_algorithm = meta.get("soft_binding_algorithm") or info.get("soft_binding_algorithm")
|
||||
soft_binding_value = meta.get("soft_binding_value") or info.get("soft_binding_value")
|
||||
@@ -1334,6 +1334,21 @@ def _identify_from_evidence(
|
||||
if standalone_iptc:
|
||||
signals.append(Signal("iptc", "digitalSourceType (Made with AI)", "high"))
|
||||
watermarks.append("IPTC digitalSourceType (Made with AI)")
|
||||
# Muse Image stamps every output with the invisible Content Seal, and this
|
||||
# tag is the only provenance such a file carries - the same measured bet
|
||||
# the strength router makes (vendor_for_strength -> "meta"). Emit the seal
|
||||
# as its own stable signal, the way InvisMark is additive over
|
||||
# soft_binding, so clients select pixel removal from the signal list
|
||||
# instead of parsing caveats. It is an attribution, not a decode: no
|
||||
# public Content Seal decoder exists, hence "medium".
|
||||
signals.append(
|
||||
Signal(
|
||||
"content_seal",
|
||||
"Meta Muse Content Seal pixel watermark (attributed by the standalone AI digital-source tag)",
|
||||
"medium",
|
||||
)
|
||||
)
|
||||
watermarks.append("Invisible Content Seal watermark (Meta Muse attribution)")
|
||||
caveats.append(_IPTC_ONLY_CAVEAT)
|
||||
caveats.append(_CONTENT_SEAL_CAVEAT)
|
||||
if platform is None:
|
||||
@@ -1341,11 +1356,16 @@ def _identify_from_evidence(
|
||||
# the edit with photoshop:Credit / IPTC "Apple Photos Clean Up"
|
||||
# next to compositeWithTrainedAlgorithmicMedia. It was detected but
|
||||
# previously never attributed.
|
||||
platform = (
|
||||
"Apple Photos (Clean Up AI edit)"
|
||||
if b"Apple Photos Clean Up" in head
|
||||
else "Made-with-AI tag (e.g. Meta AI); platform not specified"
|
||||
)
|
||||
if b"Apple Photos Clean Up" in head:
|
||||
platform = "Apple Photos (Clean Up AI edit)"
|
||||
else:
|
||||
# The platform line follows the same measured bet the seal signal
|
||||
# and the strength router make: Muse Image is the tag writer whose
|
||||
# outputs this profile targets, so a hedged Muse attribution is
|
||||
# more useful than "platform not specified" while the panel below
|
||||
# already prices the Content Seal removal. The hedge stays in the
|
||||
# wording - it names the attribution basis, not a detection.
|
||||
platform = "Meta Muse Image (attributed by the standalone AI digital-source tag)"
|
||||
|
||||
# ── IPTC 2025.1 AI-disclosure fields (Iptc4xmpExt:AISystemUsed etc.) ─
|
||||
iptc_ai = any(m in head for m in IPTC_AI_FIELD_MARKERS)
|
||||
@@ -1406,17 +1426,17 @@ def _identify_from_evidence(
|
||||
platform = "xAI (Grok / Aurora)"
|
||||
ai_vendor_claims["xai"] = "xAI"
|
||||
|
||||
# ── HuggingFace-hosted job marker (hf-job-id PNG text chunk) ─────
|
||||
# ── Hugging Face-hosted job marker (hf-job-id PNG text chunk) ─────
|
||||
# Marks the hosting job, not a model -- medium confidence (commonly diffusion
|
||||
# output). Like the visible sparkle, it lifts an otherwise-Unknown verdict to
|
||||
# a tentative AI, but never overrides a high-confidence metadata signal.
|
||||
hf_job = evidence.huggingface_job
|
||||
if hf_job:
|
||||
signals.append(Signal("hf_job", f"HuggingFace job {hf_job}", "medium"))
|
||||
watermarks.append("HuggingFace-hosted job (hf-job-id)")
|
||||
signals.append(Signal("hf_job", f"Hugging Face job {hf_job}", "medium"))
|
||||
watermarks.append("Hugging Face-hosted job (hf-job-id)")
|
||||
caveats.append(_HF_JOB_CAVEAT)
|
||||
if platform is None:
|
||||
platform = "HuggingFace-hosted job (model not identified)"
|
||||
platform = "Hugging Face-hosted job (model not identified)"
|
||||
|
||||
# ── Samsung Galaxy AI editing marker (genAIType) ─────────────────
|
||||
# Galaxy AI tools stamp a proprietary genAIType in PhotoEditor_Re_Edit_Data.
|
||||
|
||||
@@ -33,7 +33,7 @@ warnings.filterwarnings("ignore", category=UserWarning, module="huggingface_hub"
|
||||
warnings.filterwarnings("ignore", category=UserWarning, module="diffusers")
|
||||
warnings.filterwarnings("ignore", module="transformers")
|
||||
|
||||
# Suppress HuggingFace internal logging
|
||||
# Suppress Hugging Face internal logging
|
||||
os.environ["TRANSFORMERS_VERBOSITY"] = "error"
|
||||
os.environ["DIFFUSERS_VERBOSITY"] = "error"
|
||||
|
||||
@@ -95,19 +95,21 @@ class InvisibleEngine:
|
||||
"""Initialize the invisible watermark removal engine.
|
||||
|
||||
Args:
|
||||
device: Device for inference. Both profiles are CUDA-only, so the
|
||||
device: Device for inference. All profiles are CUDA-only, so the
|
||||
usable values are "cuda" and None/"auto" (which detects it);
|
||||
anything else raises rather than falling back.
|
||||
pipeline: Pipeline profile, one of "qwen-zimage" (DEFAULT;
|
||||
Qwen-Image-2512 Lightning + Canny, then SAM-masked Z-Image face repair)
|
||||
or "sdxl-zimage" (the same recipe and the same face stage on an SDXL
|
||||
global pass, vendor-adaptive strength because an SDXL global stage
|
||||
needs more of it). BOTH ARE CUDA-ONLY -- there is no CPU or MPS path
|
||||
for invisible-watermark removal.
|
||||
hf_token: HuggingFace API token.
|
||||
needs more of it) or "chroma-zimage" (the same face stage on an
|
||||
Apache-2.0 Chroma1 global pass with its own flat vendor floors;
|
||||
see docs/chroma1-engine-research.md). ALL ARE CUDA-ONLY -- there is
|
||||
no CPU or MPS path for invisible-watermark removal.
|
||||
hf_token: Hugging Face API token.
|
||||
progress_callback: Optional callback for progress messages.
|
||||
controlnet_conditioning_scale: Canny ControlNet structure-preservation
|
||||
strength on the global stage of both profiles.
|
||||
strength on the global stage (not used by chroma-zimage).
|
||||
cpu_offload: Offload model components to CPU between CUDA calls instead
|
||||
of keeping the whole pipeline in VRAM, at the cost of speed. For
|
||||
qwen-zimage, force the face stack to offload instead of using automatic
|
||||
@@ -158,9 +160,9 @@ class InvisibleEngine:
|
||||
image_path: Path to the watermarked image.
|
||||
output_path: Output path (None = overwrite source).
|
||||
strength: Denoising strength (0.0-1.0). None -> the profile's calibrated
|
||||
default (resolution-adaptive for qwen-zimage, vendor-adaptive for
|
||||
sdxl-zimage).
|
||||
seed: Random seed for reproducibility. None resolves to 0, because both
|
||||
default (resolution-adaptive for qwen-zimage, vendor-adaptive flat
|
||||
floors for sdxl-zimage and chroma-zimage).
|
||||
seed: Random seed for reproducibility. None resolves to 0, because all
|
||||
profiles are certified at a fixed seed.
|
||||
humanize: Intensity of Analog Humanizer film grain (0 = off).
|
||||
unsharp: Final unsharp-mask sharpening strength (0 = off, default).
|
||||
@@ -187,9 +189,8 @@ class InvisibleEngine:
|
||||
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 supported: the VAE donor uses the same
|
||||
overlapping tiles as the global pass, then glyph restore runs on
|
||||
the blended full frame.
|
||||
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
|
||||
@@ -211,6 +212,8 @@ class InvisibleEngine:
|
||||
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 tile:
|
||||
raise ValueError("--text-manifest is not calibrated with --tile")
|
||||
if max_resolution != 0:
|
||||
raise ValueError("--text-manifest requires --max-resolution 0")
|
||||
if humanize > 0.0 or unsharp > 0.0 or adaptive_polish:
|
||||
@@ -245,7 +248,7 @@ class InvisibleEngine:
|
||||
|
||||
verified_text = load_verified_text_manifest(text_manifest, reference_pil)
|
||||
|
||||
# Both profiles run at the input's native geometry, so only the explicit max
|
||||
# All profiles run at the input's native geometry, so only the explicit max
|
||||
# cap can move it, and it can only ever scale down.
|
||||
target = _target_size(image.width, image.height, max_resolution)
|
||||
if target is not None:
|
||||
|
||||