mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-17 15:00:47 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a1811b6221 | ||
|
|
e09104e39f | ||
|
|
29f17105de | ||
|
|
ba5710836b | ||
|
|
c927560614 | ||
|
|
f83da5aee4 | ||
|
|
251ba9ac79 | ||
|
|
c5764661b4 | ||
|
|
97cba11efe | ||
|
|
809d980fb7 | ||
|
|
10da166f3e | ||
|
|
8871eb3f67 | ||
|
|
44ec203c22 | ||
|
|
4beb61463a | ||
|
|
0d11e4099c | ||
|
|
fa83c1444f | ||
|
|
f52e40ae24 | ||
|
|
0aa03c4e89 | ||
|
|
91aa49b03a | ||
|
|
ee865e95e4 | ||
|
|
d84fd1a125 | ||
|
|
e315eacb14 | ||
|
|
3ba4ace7e0 | ||
|
|
13888704f8 | ||
|
|
e938b57f8c | ||
|
|
3cd1e47935 | ||
|
|
2298282b8b | ||
|
|
42d427731f | ||
|
|
50d9f44829 |
No files matched your search
@@ -65,6 +65,15 @@ that is what actually happened.
|
||||
|
||||
Use availability checks only for paths that actually load large models.
|
||||
|
||||
A verdict branch must be reachable with the dependency stack as shipped. High-confidence
|
||||
C2PA attribution required `signingCredential.trusted`, a code the reader emits only when a
|
||||
trust anchor list is loaded, and none ships -- so the branch was dead in production for
|
||||
every vendor while a hand-built info dict stamping that code kept it green from 0.27.0
|
||||
through 0.30.0. Assert reachability against the committed fixtures rather than a synthesized
|
||||
status set; `TestIdentifyRealSamples::test_no_committed_fixture_reports_a_trusted_signer`
|
||||
is that guard. Read a capability the stack does not have as a missing input, never as a
|
||||
negative finding.
|
||||
|
||||
## One measurement, one gate seam
|
||||
|
||||
A detector is split into a trust-level-blind scan and a verdict that applies the
|
||||
@@ -98,6 +107,11 @@ record is byte-identical, and a green test suite does not establish that on its
|
||||
change that is meant to FIX detection is the exception that proves the rule: the diff
|
||||
must then be exactly the files you intended to change, named in advance.
|
||||
|
||||
When the baseline is a published release rather than the previous commit, get it with
|
||||
`uv run --isolated --no-project --with 'remove-ai-watermarks[heif]==<version>' python <script>`:
|
||||
it resolves that release from PyPI without touching the working tree or the editable
|
||||
install. Quote the extra -- bare `pkg[extra]` is a glob in zsh.
|
||||
|
||||
Two corollaries in `dwt_dct.py`, where "close enough" has an exact meaning. The bit test
|
||||
is `peak % 36 > 18.0` and for uint8 input the exact Haar value is a multiple of 0.5, so it
|
||||
lands ON the threshold once per 72 blocks and a 1-ulp difference flips real bits: leave the
|
||||
|
||||
@@ -44,6 +44,10 @@ removal.
|
||||
| Run visible, invisible, and metadata removal | `all` | Recommended |
|
||||
| Process a directory | `batch` | Depends on mode |
|
||||
|
||||
Microsoft Paint and Photos InvisMark declarations are routed automatically to
|
||||
pixel regeneration. The `all` command removes both the hidden pixel watermark and
|
||||
its linked C2PA manifest; metadata stripping alone removes only the manifest.
|
||||
|
||||
## Installation modes
|
||||
|
||||
| Need | Install |
|
||||
@@ -202,8 +206,9 @@ remove-ai-watermarks invisible image.png -o clean.png --force
|
||||
```
|
||||
|
||||
Typography-heavy images can opt into the experimental verified-text post-pass.
|
||||
It requires manually reviewed strings and line boxes; it never trusts OCR as ground
|
||||
truth or runs automatically:
|
||||
It accepts manually reviewed strings and line boxes or an operator-verified
|
||||
geometry-only manifest; it never treats raw OCR output as ground truth or runs
|
||||
automatically:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[text-restoration]"
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
# Content Seal oracle corpus
|
||||
|
||||
Muse Image (`muse-image-1.0`) generations with externally verified Content Seal
|
||||
verdicts, produced through the Meta Model API on 2026-08-26 and checked against
|
||||
the public detector at `https://meta.ai/identification` (anonymous session, no
|
||||
login). Follows the `data/synthid/originals` pattern: binaries live in
|
||||
`originals/`, every derived variant is recorded in `manifest.csv` as a recipe
|
||||
plus hash and is not stored.
|
||||
|
||||
## What this corpus establishes
|
||||
|
||||
- The Meta Model API image endpoint (`POST /v1/images/generations`) stamps the
|
||||
same Content Seal pixel watermark as the consumer Meta AI app: all five
|
||||
generations verified positive with attribution "Muse Image 1 - Meta".
|
||||
- The detector response carries a per-generation ID and creation timestamp
|
||||
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).
|
||||
- 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
|
||||
Content Seal has no local decoder in this project: the oracle is the only
|
||||
reader.
|
||||
- Drift finding: the 512 px resize of `gen_fox_forest` triggers a
|
||||
medium-confidence false positive "Tencent Yuanbao (visible 元宝 / AI生成
|
||||
mark)" in this project's `identify`. Recorded here as a reproducible case.
|
||||
|
||||
## Oracle limits and wire format
|
||||
|
||||
There is no public or documented checking API. Verified against the official
|
||||
developer documentation on 2026-08-26 (`https://dev.meta.ai/docs/`): the full
|
||||
Meta Model API reference lists only Responses, Chat Completions, Messages,
|
||||
Files, Images (`/v1/images/generations`, `/v1/images/edits`), and Models, with
|
||||
no identification, detection, or watermark endpoint, and the image-generation,
|
||||
Muse Image cookbook, and pricing pages never mention watermark, Content Seal,
|
||||
or provenance at all. The API applies the seal (every generation in this corpus
|
||||
carries it) while documenting nothing about it. The web tool drives an internal
|
||||
REST pair, captured from the browser network log on 2026-08-26:
|
||||
|
||||
1. `POST https://rupload.meta.ai/gen_ai_document_gen_ai_tenant/<uuid>` with the
|
||||
raw file bytes, `x-entity-type`, `x-entity-length`, `ai_detector_upload: true`,
|
||||
and an anonymous `authorization: OAuth ecto1:<token>` session token minted by
|
||||
the page.
|
||||
2. `POST https://meta.ai/api/ai-detector` with `Bearer ecto1:<token>` and body
|
||||
`{"media_id": "...", "fileName": "...", "mimeType": "..."}`.
|
||||
|
||||
The rate limit is enforced at that endpoint, server-side, and keyed beyond the
|
||||
browser session: the API itself returns `429 {"errorType": "rate_limited"}`,
|
||||
and clearing cookies and storage changed nothing, so driving the internal pair
|
||||
directly does not bypass it. The Meta Model API (`api.meta.ai/v1`, where the
|
||||
generation key works) has no identification endpoint; plausible paths all
|
||||
return 404. Rows with an empty `oracle_verdict` were transformed but not yet
|
||||
checkable. Read a verdict only from the settled page text after the
|
||||
result-complete state ("Upload another file"): a wait for a verdict string can
|
||||
match the previous upload's text, and the fresh-navigation protocol used for
|
||||
the calibration rows below is the race-free variant.
|
||||
|
||||
## Strength floor calibration (qwen-zimage, seed 0)
|
||||
|
||||
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
|
||||
generations, each one's first-clean boundary, floor = worst boundary plus the
|
||||
observed cross-source spread.
|
||||
|
||||
Measured (2026-08-26/27, oracle `meta.ai/identification`):
|
||||
|
||||
- Default pipeline clears Content Seal: tested samples came back clean at the
|
||||
default resolution-adaptive strength (~0.1305 at 2.56 MP), including the
|
||||
worst source.
|
||||
- Five independent generations bracketed. First-clean boundaries:
|
||||
lighthouse (0.0525, 0.06], fox (0.03, 0.0375], night_city (0.03, 0.0375],
|
||||
mug <= 0.03, text <= 0.015. Cross-source spread is wide (a factor of four
|
||||
between easiest and hardest).
|
||||
- Derived Meta floor by the existing worst-boundary-plus-cross-source-spread
|
||||
method: 0.06 + (0.0525 - 0.015) = 0.0975, rounded to **0.1**.
|
||||
- Not shipped as a constant: no provenance signal routes Muse outputs onto a
|
||||
vendor cohort, so the default curve stays authoritative and 0.1 is recorded
|
||||
as the floor to encode if an explicit Meta override is ever added.
|
||||
|
||||
## 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`.
|
||||
@@ -0,0 +1,44 @@
|
||||
name,file,origin,source_url,prompt,requested_size,delivered_size,sha256,xmp_iptc_trainedAlgorithmicMedia,oracle_verdict,oracle_attribution,oracle_generation_id,oracle_created_utc,checked_at_utc,notes
|
||||
gen_fox_forest,originals/gen_fox_forest.webp,meta-model-api,,A red fox resting on a mossy log in a misty forest at dawn photorealistic shallow depth of field,1024x1024,1600x1600,6d7cecf322a5ac4b2547e2f32c767b3cb3e8a0e49f274cfd00aa31be6697eed4,yes,detected,Muse Image 1 - Meta,AXZpZGVvc2VhbHY0X2NhX3NjbLlNjYhvPAxD,2026-08-26T19:07Z,2026-08-26T19:11Z,checked twice; ID stable across uploads
|
||||
gen_text_poster,originals/gen_text_poster.webp,meta-model-api,,A cheerful flat-design poster that says 'WATERMARK FIELD DAY 2026' in bold rounded letters over a coral and cream striped background,1024x1024,1600x1600,873927662d8198e9c38b4ae71a2919ba5d417dfcd714649db1b0ae231e8cbf9b,yes,detected,Muse Image 1 - Meta,AXZpZGVvc2VhbHY0X2NhX3NjbJuIKXPAYxsd,2026-08-26T19:08Z,2026-08-26T19:08Z,
|
||||
gen_lighthouse_watercolor,originals/gen_lighthouse_watercolor.webp,meta-model-api,,A watercolor illustration of a lighthouse on a cliff at golden hour seagulls in the distance soft loose washes white paper texture,1024x1024,1600x1600,69bee7c376608568361085602c5c7406900885849bd94c5279c3924cf793b27c,yes,detected,Muse Image 1 - Meta,AXZpZGVvc2VhbHY0X2NhX3NjbB5uWbAmWmwp,2026-08-26T19:08Z,2026-08-26T19:08Z,
|
||||
gen_night_city,originals/gen_night_city.webp,meta-model-api,,A night city skyline in the rain neon reflections on wet asphalt cinematic wide shot moody,1536x1024,1920x1280,345adf6b9f777b15ef7ce7bbdfb3b355a1d9e882740809e966e9f6caafd94d48,yes,detected,Muse Image 1 - Meta,AXZpZGVvc2VhbHY0X2NhX3NjbH5uWbAmWmwp,2026-08-26T19:08Z,2026-08-26T19:08Z,
|
||||
gen_studio_mug,originals/gen_studio_mug.webp,meta-model-api,,A minimalist studio photo of a single matte ceramic mug on a pastel blue background soft shadow clean commercial look,1024x1024,1600x1600,2b7bc7fd56fa2250c66ef74f8b3e49d9666fe147d894d5baf9461b5e36077a07,yes,detected,Muse Image 1 - Meta,AXZpZGVvc2VhbHY0X2NhX3NjbAsSozV2XCzw,2026-08-26T19:08Z,2026-08-26T19:08Z,
|
||||
fox_crop50,,derived:gen_fox_forest,,center crop 0.5 linear each side then WEBP q95,,800x800,80b91995560a7c4b08596a26b3401a29afb82691fad193502697a49785eab7be,no_stripped_by_reencode,not_detected,,,,2026-08-26T19:13Z,seal lost; PIL re-encode strips XMP
|
||||
fox_crop33,,derived:gen_fox_forest,,center crop 0.33 linear each side then WEBP q95,,528x528,3b3aded8f4fcb98d0599431abccb73b9581854ae34c6bc869724cd5c86a1e35e,no_stripped_by_reencode,not_detected,,,,2026-08-26T19:13Z,seal lost
|
||||
fox_res512,,derived:gen_fox_forest,,resize long side 512 LANCZOS then WEBP q95,,512x512,19b85f615d56f5e9f55cd97bae0ee343f4f432e6bfa0a6fc7acb07dec2cc1d81,no_stripped_by_reencode,detected,Muse Image 1 - Meta,AXZpZGVvc2VhbHY0X2NhX3NjbLlNjYhvPAxD,2026-08-26T19:07Z,2026-08-26T19:13Z,generation ID and creation time identical to the original generation: watermark payload survives the resize
|
||||
fox_jpeg85,,derived:gen_fox_forest,,full-size JPEG q85 re-encode,,1600x1600,3bc7dc11bf3c16c83814df1e6485cdb6e49e21141511dda56d210cc920a8c5e3,no_stripped_by_reencode,detected,Muse Image 1 - Meta,AXZpZGVvc2VhbHY0X2NhX3NjbLlNjYhvPAxD,2026-08-26T19:07Z,2026-08-26T19:13Z,payload survives JPEG recompression; local identify returns unknown (metadata stripped)
|
||||
text_crop50,,derived:gen_text_poster,,center crop 0.5 linear each side then WEBP q95,,800x800,3f0d4075012ee4fec820209c5c1c32bb739998813325f49b1124f379162f9837,no_stripped_by_reencode,not_detected,,,,2026-08-26T19:13Z,seal lost
|
||||
text_crop33,,derived:gen_text_poster,,center crop 0.33 linear each side then WEBP q95,,528x528,95cef87002961831b21f6f47cd9a0824a67e2ee6f8503356fd5db6a3fcfcc998,no_stripped_by_reencode,,,,,,daily oracle limit reached before this row could be checked
|
||||
text_res512,,derived:gen_text_poster,,resize long side 512 LANCZOS then WEBP q95,,512x512,b319727d02e908f22e59f148050e6b4686cbd1a622bd69db51fe4072a6f66b34,no_stripped_by_reencode,,,,,,daily oracle limit reached; also the fox twin of this transform triggered the Yuanbao false positive below
|
||||
text_jpeg85,,derived:gen_text_poster,,full-size JPEG q85 re-encode,,1600x1600,76f00fcde1e974549afa5d90e1879c705e3c3631ef1f35cab901fc912cd15fa4,no_stripped_by_reencode,,,,,,daily oracle limit reached before this row could be checked
|
||||
blog_qr_manhwa,,meta-blog-cdn,https://lookaside.fbsbx.com/elementpath/media/?media_id=1807972953501959&version=1783318590&transcode_extension=webp,,,1920x1280,8ee7700dff59f21d7de393a619664a0b9360f148d18df289364ecea939b3dea9,yes,detected,Muse Image 1 - Meta,,,2026-08-26T18:59Z,not stored; re-downloadable from source_url
|
||||
blog_fractal_poster,,meta-blog-cdn,https://lookaside.fbsbx.com/elementpath/media/?media_id=1499727434782014&version=1783727678&transcode_extension=webp,,,2096x992,2a96168db026444b47ac37807fd99a88c8b7fa6866e1c93a54ab427fbe581f76,no,detected,Muse Image 1 - Meta,,,2026-08-26T18:59Z,seal survived CDN transcode without any metadata
|
||||
blog_flipbook_punch,,meta-blog-cdn,https://lookaside.fbsbx.com/elementpath/media/?media_id=2521328538303639&version=1783396590&transcode_extension=webp,,,1920x1280,35f28dc2cbdf44aed190bc8efaa0fe7bfa288a2196638712dd83bc941cc294d5,yes,detected,Muse Image 1 - Meta,,,2026-08-26T19:00Z,
|
||||
blog_teen_cat,,meta-blog-cdn,https://lookaside.fbsbx.com/elementpath/media/?media_id=1325391919706734&version=1783319609&transcode_extension=webp,,,1600x1600,c2e7df000ffa6520589d8efc5f0bcbeb759175cdaa0c827189c28a64f75d3dd7,yes,detected,Muse Image 1 - Meta,,,2026-08-26T19:00Z,
|
||||
blog_hero_1,,meta-blog-cdn,https://lookaside.fbsbx.com/elementpath/media/?media_id=2140990336800598&version=1783727678,,,1344x1792,7655639acb9f826cdb864d869530153efc1e1467ff414b5a63e80aa7b39b2fb7,no,detected,Muse Image 1 - Meta,,,2026-08-26T19:00Z,seal survived CDN transcode without any metadata
|
||||
control_synth_gradient,,synthetic-local,,PIL gradient with rectangle JPEG q90; never touched any AI pipeline,,1024x768,25f29c0b993ed1a8e41060ed50de778d875a6d94847ced180aafc5a6ad429f82,no,not_detected,,,,2026-08-26T19:01Z,negative control: No AI signatures from Meta were found
|
||||
fox_nometa,,derived:gen_fox_forest,,metadata --remove only (no pixel change),,1600x1600,685d3a5c5d74e653ec7615fc7a347a0584c277fc7fb59594d388ac619ffe3985,no_stripped,detected,Muse Image 1 - Meta,AXZpZGVvc2VhbHY0X2NhX3NjbLlNjYhvPAxD,2026-08-26T19:07Z,2026-08-26T22:19Z,metadata stripping does NOT remove Content Seal; seal lives in pixels; same generation ID as original
|
||||
fox_modal_invisible,,derived:gen_fox_forest,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=default-adaptive(approx 0.1305 at 1600x1600),,1600x1600,6daa3e9b5dde5cb443d8071c5294262742f001c02b694243764a50345769d8b8,no_stripped,not_detected,,,,2026-08-26T22:21Z,current default pipeline clears Content Seal
|
||||
text_modal_invisible,,derived:gen_text_poster,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=default-adaptive(approx 0.1305 at 1600x1600),,1600x1600,8530a82fb856b0a00651076ac1ad8a7544b5d8873cac9bd5780704fbe10f53f8,no_stripped,not_detected,,,,2026-08-26T22:22Z,current default pipeline clears Content Seal
|
||||
fox_str0.06000,,derived:gen_fox_forest,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.06,,1600x1600,7b5e5ae75ca20f2a3cdfcfa7666104752d9557b08ac236db6f1548434940a1f3,no_stripped,not_detected,,,,2026-08-26T22:28Z,strength calibration ladder
|
||||
fox_str0.04500,,derived:gen_fox_forest,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.045,,1600x1600,26da15694b3096e2f1717154db213e814cdd27f2fc40e5f924db2324213cf960,no_stripped,not_detected,,,,2026-08-27T02:28Z,strength calibration ladder; re-confirmed clean at 0.045
|
||||
fox_str0.03750,,derived:gen_fox_forest,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.0375,,1600x1600,b7eb0616d10042933edf22cfc30aff4df915384828289b409f6cfa4cdf2807d4,no_stripped,not_detected,,,,2026-08-26T22:30Z,fresh-page protocol; fox first-clean boundary is in (0.03 0.0375]
|
||||
fox_str0.03000,,derived:gen_fox_forest,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.03,,1600x1600,cff80d18d9d00000f47e0f97c419a37c13a13831795768aee8727aa9516d1401,no_stripped,detected,Muse Image 1 - Meta,,,2026-08-26T22:31Z,fresh-page protocol; seal survives 0.03
|
||||
fox_str0.02000,,derived:gen_fox_forest,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.02,,1600x1600,b19183ec60b63a5b73d3e1c723e7943584912857d51a4b5f85259c5c77da0e53,no_stripped,detected,Muse Image 1 - Meta,,,2026-08-26T22:28Z,settled-read protocol; monotonic below the 0.03 boundary
|
||||
text_str0.03750,,derived:gen_text_poster,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.0375,,1600x1600,bacfd4eca7b13616502f74497cbe4f02ef6893918870f13f9e76fdb93b43107f,no_stripped,not_detected,,,,2026-08-26T22:33Z,fresh-page protocol
|
||||
text_str0.03000,,derived:gen_text_poster,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.03,,1600x1600,d5c6cffbf471ef8bb74ee8c0d22cc17d9da2c446b83e46c4be67e6ea4cdd09de,no_stripped,not_detected,,,,2026-08-26T22:33Z,fresh-page protocol; text first-clean boundary is at or below 0.03 - cross-source spread vs fox confirmed
|
||||
lh_str0.03750,,derived:gen_lighthouse_watercolor,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.0375,,1600x1600,f5169debc5fd2792399c0ea3fa88cb1d05f523d95004bec240c73db0e9aaee51,no_stripped,detected,Muse Image 1 - Meta,,,2026-08-27T02:28Z,seal survives 0.0375 where fox is already clean
|
||||
lh_str0.03000,,derived:gen_lighthouse_watercolor,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.03,,1600x1600,110d2112d91dd53df5ccd1839e0e2b41c44aa1832adb0090032660e071fb1fd4,no_stripped,detected,Muse Image 1 - Meta,,,2026-08-27T02:28Z,fresh-page protocol; lighthouse holds its seal below the fox boundary: cross-source spread is wide
|
||||
text_str0.02500,,derived:gen_text_poster,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.025,,1600x1600,f46a6c53b4f3d39fccb1d62cca82ced2e163e4424d13c98e6468a8e083c0fa54,no_stripped,not_detected,,,,2026-08-27T02:28Z,mid rung inside the clean zone
|
||||
text_str0.02000,,derived:gen_text_poster,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.02,,1600x1600,a19cbe22313e0f19768c98567ea89f2d9dc30827691fc7ec2971c52f3e7c6cc6,no_stripped,not_detected,,,,2026-08-27T02:28Z,text clean at 0.02: boundary <= 0.02 - the easiest source measured
|
||||
text_str0.01500,,derived:gen_text_poster,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.015,,1600x1600,aa3816c75e38f3ab5afc378d349caafad56f2bb6da859a5ee68fdea57ad00d73,no_stripped,not_detected,,,,2026-08-27T02:41Z,text clean at 0.015: boundary <= 0.015 - the easiest source measured
|
||||
lh_str0.02500,,derived:gen_lighthouse_watercolor,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.025,,1600x1600,989c4a7a804bc8420ec4d86b9445bc3a37382c1834782a7f8842f781d69b05e2,no_stripped,,,,,,below the measured detected boundary (0.03) for this source; not individually checked
|
||||
lh_str0.02000,,derived:gen_lighthouse_watercolor,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.02,,1600x1600,6a4a4ec94292a145e7d73e6900b8ec6d0eede17137f2b91fba7757ec0bdba5ec,no_stripped,,,,,,below the measured detected boundary (0.03) for this source; not individually checked
|
||||
nc_str0.03750,,derived:gen_night_city,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.0375,,1920x1280,46f322335f801ee54427eb39275216465f9a7cfa3d918c698e4d90a2439a0815,no_stripped,not_detected,,,,2026-08-27T02:28Z,boundary (0.03; 0.0375]
|
||||
nc_str0.03000,,derived:gen_night_city,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.03,,1920x1280,3f42632319248ad1a91ef2457c7e0b13ed17e1d2569decbbaab67c2072e9a305,no_stripped,detected,Muse Image 1 - Meta,,,2026-08-27T02:28Z,night_city holds seal at 0.03
|
||||
mug_str0.03750,,derived:gen_studio_mug,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.0375,,1600x1600,3b6109ae7e86b8ea74ce33315e3f2a7eda1cadb724d429f0951f13c3ead34b0f,no_stripped,not_detected,,,,2026-08-27T02:28Z,upper rung for the mug bracket
|
||||
mug_str0.03000,,derived:gen_studio_mug,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.03,,1600x1600,1c24c984677e1528da83003ac40594f6e5ef1fa9351f1368b6d31a0101308583,no_stripped,not_detected,,,,2026-08-27T02:28Z,mug clean at 0.03: boundary <= 0.03
|
||||
lh_str0.05250,,derived:gen_lighthouse_watercolor,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.0525,,1600x1600,8912ba1ff4a26790f69224c49dd649e46835e277c375276ac9604f5339775b2a,no_stripped,detected,Muse Image 1 - Meta,,,2026-08-27T02:41Z,bisection: lighthouse first-clean boundary bracketed to (0.0525; 0.06]
|
||||
lh_str0.04500,,derived:gen_lighthouse_watercolor,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.045,,1600x1600,cfdcf7bbb27c740846a1de4edffee3b25a624c2a8e1aba2362feaa25b682e3ae,no_stripped,detected,Muse Image 1 - Meta,,,2026-08-27T02:28Z,seal survives 0.045: lighthouse is the worst source measured
|
||||
lh_str0.06000,,derived:gen_lighthouse_watercolor,,modal raiw-worker qwen-zimage mode=invisible seed=0 strength=0.06,,1600x1600,4e214be00a89dc9f1f32ec39483c5e9668a1ec7f38b15e5794942479c839fcca,no_stripped,not_detected,,,,2026-08-27T02:28Z,lighthouse first-clean boundary bracketed to (0.045 0.06]
|
||||
|
Binary file not shown.
|
After Width: | Height: | Size: 585 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 510 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 652 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 83 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 192 KiB |
+36
-9
@@ -41,9 +41,14 @@ remove-ai-watermarks identify image.png
|
||||
it also evaluates supported visible and invisible pixel signals. When no signal
|
||||
is found, it reports the origin as unknown. It does not claim the image is
|
||||
clean. For C2PA files, the text report shows asset integrity, claim-signature,
|
||||
signer-trust, and signer-validity results separately. An intact claim from an
|
||||
untrusted or expired signer is reported at medium confidence; a failed asset
|
||||
binding or signature does not confirm the claimed origin.
|
||||
signer-trust, and signer-validity results separately. Confidence follows the
|
||||
binding: an intact asset binding and claim signature is high confidence, and an
|
||||
unanchored or expired signer is reported as a caveat rather than a lower score,
|
||||
because no trust anchor list ships with the reader. A failed asset binding or
|
||||
signature, or a revoked signing credential, does not confirm the claimed origin. When a structured
|
||||
C2PA soft binding is present, the report also names its exact algorithm and
|
||||
signed value; removing the manifest does not remove the referenced pixel
|
||||
watermark or content fingerprint.
|
||||
|
||||
Machine readable output:
|
||||
|
||||
@@ -157,6 +162,11 @@ remove-ai-watermarks metadata image.png --remove -o clean.png
|
||||
When `-o` is omitted, removal overwrites the source. Standard metadata is kept
|
||||
unless you pass `--remove-all`.
|
||||
|
||||
A quiet `--check` or a successful `--remove` is not a clean verdict. The command
|
||||
only inspects and strips embedded AI metadata; a pixel watermark such as SynthID
|
||||
has no local decoder once that metadata proxy is gone. `identify` reports the
|
||||
same limit.
|
||||
|
||||
The command also supports the audio and video containers listed in
|
||||
[supported signals](supported-signals.md). ffmpeg must be available for the
|
||||
non-ISOBMFF audio and video path.
|
||||
@@ -225,6 +235,10 @@ remove-ai-watermarks video metadata input.mp4 --check
|
||||
remove-ai-watermarks video metadata input.mp4 --remove -o clean.mp4
|
||||
```
|
||||
|
||||
As with the generic `metadata` command, a quiet check or a successful strip is
|
||||
not a clean verdict: video SynthID is not decoded locally after the metadata
|
||||
proxy is gone.
|
||||
|
||||
Supported containers are MP4, MOV, M4V, WebM, MKV, AVI, and FLV. The operation
|
||||
delegates to the same verified metadata scanner and stripper as the generic
|
||||
`metadata` command, so detection and removal stay in parity. Video and audio
|
||||
@@ -408,7 +422,7 @@ the source with the Qwen VAE, blends 15% of that reconstruction into the normal
|
||||
only the reconstructed glyph cores through source-derived silhouettes. It does not
|
||||
run OCR or choose which strings are correct.
|
||||
|
||||
Install the combined extra and run only with a manually reviewed manifest:
|
||||
Install the combined extra and run only with an operator-verified manifest:
|
||||
|
||||
```bash
|
||||
uv tool install --force "remove-ai-watermarks[text-restoration]"
|
||||
@@ -416,16 +430,29 @@ remove-ai-watermarks invisible image.png -o clean.png \
|
||||
--pipeline qwen-zimage --text-manifest verified-lines.json --force
|
||||
```
|
||||
|
||||
The manifest is a JSON object with `schema_version: 1`, `verified: true`, decoded
|
||||
RGB dimensions, `source_pixel_sha256`, and a non-empty `lines` array. Each line has
|
||||
an integer `[x1, y1, x2, y2]` box, exact `text`, a non-empty `script`, and an optional
|
||||
angle from -30 to 30 degrees. Lines must be in top-to-bottom, left-to-right order.
|
||||
``verified: true`` may also be set by an automated operator that attests
|
||||
machine-verified geometry: stability-gated detector boxes inside sane caps. Such
|
||||
operators should use the geometry-only schema 2, which carries no transcription
|
||||
or script metadata.
|
||||
|
||||
Since 0.27.1 the global 15% Qwen-VAE fidelity-anchor blend is off by default: it
|
||||
was measured to return detector-visible OpenAI SynthID on poster-scale manifests
|
||||
(official Content Provenance API, 2026-08-19). `--fidelity-anchor` restores the
|
||||
0.27.0 research behavior; text-box fidelity lost by the default is well under one
|
||||
MAE point on the measured fixtures.
|
||||
|
||||
The manifest is a JSON object with `verified: true`, decoded RGB dimensions,
|
||||
`source_pixel_sha256`, and a non-empty `lines` array. Schema 1 is retained for
|
||||
manually reviewed annotations: each line has an integer `[x1, y1, x2, y2]` box,
|
||||
exact `text`, a non-empty `script`, and an optional angle from -30 to 30 degrees.
|
||||
Schema 2 is geometry-only: each line has the box and optional angle, with no
|
||||
required `text` or `script`. Lines must be in top-to-bottom, left-to-right order.
|
||||
The hash binds the annotations to decoded RGB geometry and pixels, so metadata-only
|
||||
container changes remain valid while a resized or edited source fails closed. The
|
||||
experimental helper
|
||||
`remove_ai_watermarks._internal.text_restoration.source_pixel_sha256` computes it.
|
||||
|
||||
This mode is supported only by `qwen-zimage` at native untiled geometry with
|
||||
This mode is supported only by `qwen-zimage` at native geometry with
|
||||
`humanize=0`, `unsharp=0`, and adaptive polish disabled. `all` also accepts the flag,
|
||||
but its manifest must match the pixels entering the invisible stage; if visible-mark
|
||||
removal changes those pixels, the hash check rejects the run. One oracle verdict does
|
||||
|
||||
@@ -521,7 +521,7 @@ held by its masked ArcFace vector -- no original pixel copied.
|
||||
drift, NOT a perfect face swap. Set expectations; PuLID/InstantID are the higher-fidelity (heavier)
|
||||
paths if needed.
|
||||
- **Value scales with strength:** at low strength (OpenAI 0.10) faces barely drift, so FaceID is
|
||||
marginal; at the higher strength a hard vendor (Google 0.30) needs, FaceID earns its keep.
|
||||
marginal; at the higher strength then used for a hard vendor (Google 0.30), FaceID earned its keep.
|
||||
|
||||
### Build plan (staged)
|
||||
|
||||
|
||||
@@ -57,6 +57,19 @@ Invisible removal does not decode and delete a payload. It regenerates the
|
||||
image through a diffusion pipeline. Faces, text, colors, and fine detail can
|
||||
change even when the watermark is successfully disrupted.
|
||||
|
||||
Text, tables, and UI screenshots are the worst case for a forced scrub: the
|
||||
model redraws glyphs as plausible-but-wrong shapes. Do not run the invisible
|
||||
stage on such content unless a locally-detectable invisible signal exists.
|
||||
The default `all`, `invisible`, and `batch` commands already skip it then, and
|
||||
`visible` (which strips AI metadata by default) or `metadata` alone never
|
||||
redraws a glyph outside the filled watermark box. `--force` is for content you
|
||||
know carries a pixel watermark despite no local signal; on a clean screenshot
|
||||
it only buys distortion. Preserving or pasting back the original text pixels
|
||||
during a real scrub is deliberately not offered: an invisible watermark such
|
||||
as SynthID lives in the pixels everywhere, including inside the glyphs, so
|
||||
frozen text regions would keep the watermark
|
||||
([text protection research](text-protection-research.md)).
|
||||
|
||||
`qwen-zimage` is the default profile and `sdxl-zimage` the only alternative.
|
||||
Both are CUDA only and differ only in the global regeneration model: each
|
||||
conditions that stage on a canny edge map, which preserves structure but not
|
||||
@@ -98,6 +111,14 @@ For important outputs:
|
||||
Provider systems can change, so a result verified on one file, seed, or version
|
||||
is not a permanent certification.
|
||||
|
||||
Meta Content Seal (Muse Image) is in the same family: no local decoder, presence
|
||||
recognizable only through the XMP `trainedAlgorithmicMedia` companion tag while
|
||||
that metadata survives, and removal verifiable only through the anonymous
|
||||
`meta.ai/identification` oracle, which rate-limits by IP per day. The oracle's
|
||||
negative is weaker than its positive: Reuters measured it missing 55% of cropped
|
||||
Muse images, so treat a negative on a cropped or heavily edited file as
|
||||
inconclusive rather than clean.
|
||||
|
||||
### Video SynthID removal is lossy and content-dependent
|
||||
|
||||
The `video invisible` command and `remove_video_invisible` API regenerate video
|
||||
@@ -164,9 +185,14 @@ path has not yet answered.
|
||||
The two profiles resolve an unset strength differently, because different things
|
||||
were measured for each.
|
||||
|
||||
`qwen-zimage` reads it from image area, through the resolution-adaptive denoise
|
||||
curve. The vendor is deliberately ignored: the curve, not the issuer, is what was
|
||||
calibrated.
|
||||
`qwen-zimage` reads unknown content from the resolution-adaptive denoise curve.
|
||||
Measured provider cohorts instead take flat operating points: OpenAI `0.07675`,
|
||||
Google `0.27`, and Microsoft InvisMark `0.15`. OpenAI and Microsoft add one full
|
||||
observed cross-source boundary spread to the worst clean source; Microsoft's three
|
||||
first-clean boundaries were `0.04125`, `0.055`, and `0.095`, giving `0.14875` before
|
||||
rounding up. Google's candidate was separately repeated across three valid sources
|
||||
and three accounts. The small corpora make these operating points, not universal
|
||||
thresholds.
|
||||
|
||||
`sdxl-zimage` reads it from the C2PA issuer, on a flat ladder:
|
||||
|
||||
@@ -390,7 +416,11 @@ 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. The calibration history is in
|
||||
through Python 3.14. Through 0.29.0 the extra 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
|
||||
ships. The calibration history is in
|
||||
[module internals](module-internals.md#metadata-and-provenance).
|
||||
|
||||
External AI versus real image classifiers are out of scope. The project
|
||||
|
||||
+105
-11
@@ -42,6 +42,10 @@ Important contracts:
|
||||
- `invisible` writes no output when no supported local signal is found, unless
|
||||
`--force` is supplied.
|
||||
- The two no-signal conditions currently share exit code `2`.
|
||||
- A quiet `metadata --check` and a successful `metadata --remove` (same for
|
||||
`video metadata`) end by repeating the `identify` limit: the pixel channel is
|
||||
untouched and a watermark such as SynthID has no local decoder once its
|
||||
metadata proxy is gone, so neither outcome is a clean verdict.
|
||||
- Hard processing and write failures exit with code `1`.
|
||||
- `all` can still write the completed visible and metadata stages when the
|
||||
diffusion dependencies are unavailable, but exits with code `1` so the
|
||||
@@ -375,23 +379,68 @@ Structured extraction is limited to the active manifest and the ingredient
|
||||
manifests reachable from it. Validation is preserved as separate dimensions:
|
||||
asset binding integrity, claim signature, signer trust, and signer certificate
|
||||
validity. A matching asset hash and valid claim signature do not make an
|
||||
untrusted or expired signer trusted. `identify` therefore assigns high confidence
|
||||
only when all four dimensions validate, medium confidence to an intact but
|
||||
untrusted or unvalidated claim, and no origin verdict to a hash/signature failure.
|
||||
The failed claim remains in the marker inventory and keeps the removal gate
|
||||
fail-safe because a post-signing container edit can invalidate C2PA without
|
||||
untrusted or expired signer trusted, so those stay separate fields and separate
|
||||
caveats -- but they do not lower confidence. `identify` assigns high confidence
|
||||
when the asset binding and the claim signature both validate, medium confidence
|
||||
to a claim that validated nothing (fallback parsing, unknown dimensions), and no
|
||||
origin verdict to a binding failure, a signature failure, or a revoked signing
|
||||
credential. The failed claim remains in the marker inventory and keeps the removal
|
||||
gate fail-safe because a post-signing container edit can invalidate C2PA without
|
||||
removing a declared pixel watermark.
|
||||
|
||||
Revocation reaches the disqualifying branch through `signer_validity`, not through
|
||||
binding or signature. A check that read only the latter two returned a confident AI
|
||||
verdict off a credential the issuer had disowned, with an empty `integrity_clashes`
|
||||
-- quieter than a hash mismatch on the same file. Certificate expiry is deliberately
|
||||
not disqualifying: an expired certificate does not imply the signed bytes changed,
|
||||
and a signature actually made outside validity already arrives as
|
||||
`claimSignature.outsideValidity`.
|
||||
|
||||
One rule, one place. `_validation_fields` maps status codes to the four dimensions
|
||||
and also emits `c2pa_failed_codes`, the subset of failures that actually drove a
|
||||
dimension to invalid; `c2pa_info_has_invalid_credential` maps those dimensions to
|
||||
disqualified, and both the ingredient-reachability walk and the report consume that
|
||||
single path. The displayed reason comes from `c2pa_failed_codes` rather than a
|
||||
substring rescan of the full code list, so what a caller is shown as the cause
|
||||
cannot become a looser rule than the verdict it explains.
|
||||
|
||||
The structured walk also treats an exact known AI product in a reachable
|
||||
`claim_generator` as an AI assertion. This covers update chains where the active
|
||||
manifest names only `c2pa-tool` while a validated ingredient names Dreamina, and
|
||||
Firefly chains that identify `Adobe_Firefly` without repeating a digital source
|
||||
type. Unreachable manifests remain excluded.
|
||||
|
||||
Reachable `c2pa.soft-binding*` assertions retain their exact `alg` and bounded,
|
||||
printable block `value` in addition to the normalized vendor label. A block value
|
||||
without its algorithm is not surfaced because it cannot be attributed to a
|
||||
decoder or registry entry. `com.microsoft.invismark.1` uses that value as the
|
||||
pixel-watermark identifier in Microsoft Paint output. An InvisMark soft binding
|
||||
keeps the invisible-removal gate fail-safe even when the C2PA asset binding has
|
||||
since become invalid, because metadata damage does not prove the pixel carrier
|
||||
disappeared. `identify` retains the generic `soft_binding` signal for schema-1
|
||||
compatibility and adds `invismark` as the stable pixel-removal signal.
|
||||
Content-fingerprint soft bindings do not trigger pixel regeneration.
|
||||
|
||||
The SDK default enables trust verification but supplies no production trust
|
||||
anchors. Consequently, an installation without an explicitly maintained C2PA
|
||||
trust bundle reports otherwise valid signer chains as untrusted and keeps their
|
||||
attribution at medium confidence. Shipping or fetching an official trust bundle
|
||||
trust bundle reports every otherwise valid signer chain as untrusted --
|
||||
`signingCredential.trusted` appears in no default installation, from any vendor.
|
||||
Confidence therefore must not depend on it. It did from 0.27.0 through 0.30.0, which made
|
||||
the high-confidence branch unreachable in production while a hand-built fixture
|
||||
stamping `signingCredential.trusted` kept it green in the suite; measured on the
|
||||
committed provenance fixtures, every C2PA file from OpenAI, Adobe and Black Forest
|
||||
Labs came back untrusted, and an intact manifest scored the same medium as a
|
||||
fallback parse that validated nothing. That collapsed the one distinction the
|
||||
official reader exists to draw.
|
||||
`tests/test_identify.py::TestIdentifyRealSamples::test_no_committed_fixture_reports_a_trusted_signer`
|
||||
is the reachability guard: it asserts the fixtures really are untrusted and still
|
||||
reach high confidence.
|
||||
|
||||
Read `untrusted` here as a missing input, not a finding: nothing was checked,
|
||||
because there was nothing to check against. If a maintained trust bundle is ever
|
||||
configured, that stops being true and the confidence mapping in
|
||||
`_c2pa_credential_level` must be re-read, because only then does a failed trust
|
||||
check mean the signer was rejected. Shipping or fetching an official trust bundle
|
||||
requires a separate update, provenance, and availability policy; do not silently
|
||||
convert `signingCredential.untrusted` into trusted based on a vendor-name match.
|
||||
|
||||
@@ -827,6 +876,11 @@ are CUDA-only. `controlnet`, `sdxl`, `qwen` and `default` were removed rather th
|
||||
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,
|
||||
measurement derivations, and corpus limits are canonical in
|
||||
[`known-limitations.md`](known-limitations.md#strength-is-content-and-seed-dependent).
|
||||
|
||||
For serverless cold starts, `InvisibleEngine.preload(global_only=True)` loads the
|
||||
mandatory global stage and YuNet while leaving the optional Z-Image and SAM face
|
||||
stack lazy until a face is detected. The default `preload()` still loads every
|
||||
@@ -960,9 +1014,10 @@ requires the same provider-oracle and identity evaluation as a model change.
|
||||
|
||||
[`_internal/text_restoration.py`](../src/remove_ai_watermarks/_internal/text_restoration.py)
|
||||
implements the opt-in `vae-glyphs` stage. A versioned manifest carries manually
|
||||
reviewed strings and source-space line boxes, plus a SHA-256 over decoded RGB width,
|
||||
height, and pixels. Validation happens before model loading. The product never treats
|
||||
OCR confidence as verification.
|
||||
reviewed strings and source-space line boxes in schema 1, or verified source-space
|
||||
geometry alone in schema 2, plus a SHA-256 over decoded RGB width, height, and pixels.
|
||||
Validation happens before model loading. The library never treats OCR confidence as
|
||||
verification, and geometry-only operators do not need to invent text or script fields.
|
||||
|
||||
When enabled, `QwenZImagePipeline` reconstructs the source once through its already
|
||||
loaded Qwen VAE, runs the ordinary global and face stages, blends 15% of the VAE
|
||||
@@ -971,7 +1026,12 @@ The compositor derives binary source and candidate silhouettes, groups nearby li
|
||||
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.
|
||||
silently drift. Silhouette crops start 12% of line height beyond each horizontal
|
||||
side, then expand each side independently while a foreground component anchored
|
||||
inside the detector box still reaches that boundary, up to one line height. They
|
||||
extend 8% above and 25% below. The anchored-component gate covers clipped leading
|
||||
flourishes, trailing punctuation, icons, and descenders without walking into
|
||||
disconnected decoration or background texture.
|
||||
|
||||
The stage is deliberately narrower than the engine: it rejects `sdxl-zimage`, tiles,
|
||||
resolution caps, humanize, unsharp, and adaptive polish. Those combinations change
|
||||
@@ -1089,6 +1149,40 @@ sizes the curve's own values verify clean, which is what a resolution-scaled
|
||||
requirement would predict. There is no measured size at which the shipped curve fails,
|
||||
so it is left alone.
|
||||
|
||||
### Meta Content Seal boundaries for qwen-zimage
|
||||
|
||||
Measured 2026-08-26 against the anonymous oracle `meta.ai/identification`
|
||||
(verdicts read from the settled page text; corpus and full row-level provenance in
|
||||
`data/contentseal/manifest.csv`). Muse Image carries no C2PA, so
|
||||
`vendor_for_strength` returns None for it and these images ride the default
|
||||
resolution-adaptive curve; there is deliberately no `meta` entry in the flat vendor
|
||||
map, because no provenance signal can establish that cohort automatically and an
|
||||
unreachable constant would be dead code.
|
||||
|
||||
| source (2.56 MP generation) | detected at | clean from |
|
||||
|---|---|---|
|
||||
| gen_lighthouse_watercolor | 0.0525 | 0.06 |
|
||||
| gen_fox_forest | 0.03 | 0.0375 |
|
||||
| gen_night_city | 0.03 | 0.0375 |
|
||||
| gen_studio_mug | -- | 0.03 |
|
||||
| gen_text_poster | -- | 0.015 |
|
||||
|
||||
Full spread: worst first-clean boundary (0.0525, 0.06] on lighthouse, easiest
|
||||
source already clean at 0.015. Following the same derivation as the OpenAI and
|
||||
Microsoft floors (worst clean boundary plus one full observed cross-source
|
||||
spread): 0.06 + (0.0525 - 0.015) = 0.0975, rounded up to **0.1**. This is a
|
||||
candidate `meta` floor, not yet a shipped constant: Muse outputs carry no C2PA,
|
||||
so `vendor_for_strength` has no signal that could route them onto the flat
|
||||
vendor map automatically. Encoding 0.1 as `QWEN_ZIMAGE_META_STRENGTH` makes
|
||||
sense only once the tool gains an explicit way to opt into a Meta cohort (for
|
||||
example a CLI `--vendor meta` override); the default resolution-adaptive curve
|
||||
(~0.1305 at 2.56 MP) already clears every measured source, so default behavior
|
||||
needs no change. Oracle verdicts carry a per-generation ID and creation timestamp
|
||||
embedded in the watermark payload; both survived the 512 px resize and JPEG q85
|
||||
rows, so payload recovery outlives the detection threshold. Oracle session
|
||||
limits are per-IP, server-side, and sliding-window: clearing cookies and storage
|
||||
does not reset them, and a burst exhausts the window minutes after it reopens.
|
||||
|
||||
### Static prompt embeddings
|
||||
|
||||
Both stages prompt with module constants, and at CFG 1.0 DiffSynth's
|
||||
|
||||
+62
-5
@@ -160,9 +160,26 @@ print(report.c2pa_validation)
|
||||
|
||||
`c2pa_validation`, when present, reports `integrity`, `signature`,
|
||||
`signer_trust`, and `signer_validity` independently, plus the reader status
|
||||
codes. A valid hash and signature with an untrusted or expired signer is a
|
||||
medium-confidence signed claim. A hash or signature failure does not confirm the
|
||||
claimed platform or AI origin. Fallback parsing reports unknown validation
|
||||
codes. A valid hash and signature is a high-confidence signed claim; an
|
||||
unanchored or expired signer appears in `caveats` and in these fields, not as a
|
||||
lower confidence, because the reader ships no trust anchors to check against and
|
||||
`signer_trust` is therefore a missing input rather than a finding. A hash or
|
||||
signature failure, or a revoked signing credential, does not confirm the claimed
|
||||
platform or AI origin.
|
||||
|
||||
A consumer must read `integrity_clashes`. When a credential fails validation,
|
||||
`is_ai_generated` becomes `None`, because a claim that cannot be tied to these
|
||||
bytes cannot establish origin -- the manifest may have been transplanted from a
|
||||
real AI image onto anything -- and the failure is reported in
|
||||
`integrity_clashes` instead. That is a different question from whether an AI
|
||||
watermark is physically present in the pixels, which is what
|
||||
`has_invisible_target` answers, and it stays fail-safe `True` on the same file.
|
||||
Reading only `is_ai_generated` turns a broken vendor manifest into silence.
|
||||
|
||||
`c2pa_validation["state"]` is the reader's own aggregate and is carried for
|
||||
diagnostics only; no verdict is derived from it, because it collapses a
|
||||
transplanted manifest and a merely expired certificate into one `Invalid`, and
|
||||
its `Trusted` level depends on anchors no default installation has. Fallback parsing reports unknown validation
|
||||
dimensions, while a raw marker in an unsupported or malformed container can
|
||||
leave `c2pa_validation` as `None`.
|
||||
|
||||
@@ -233,6 +250,11 @@ the schema 1 serializer remains available for rolling upgrades. Asking a release
|
||||
an unsupported schema raises `ValueError` rather than silently returning another
|
||||
shape.
|
||||
|
||||
Microsoft InvisMark declarations emit both the compatible generic `soft_binding`
|
||||
signal and the additive `invismark` signal. Consumers should use `invismark` to route
|
||||
the image through pixel removal; the generic signal also covers content fingerprints
|
||||
that must not trigger regeneration.
|
||||
|
||||
A record carries metadata regions, not the primary coded-pixel stream: marker
|
||||
segments before the JPEG scan, every PNG chunk except `IDAT`, RIFF chunks except the
|
||||
coded image, the ISOBMFF provenance boxes, the container's trailer, the parsed EXIF tags the
|
||||
@@ -609,11 +631,46 @@ 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, tiling, downscaling, and
|
||||
postprocessing combinations that were not evaluated. `InvisibleOptions` exposes the
|
||||
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.
|
||||
|
||||
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
|
||||
`box` and optional `angle`; no placeholder transcription or script is required.
|
||||
|
||||
Since 0.27.1 the mode's global 15% Qwen-VAE fidelity-anchor blend is **off by
|
||||
default** (`fidelity_anchor=False`): that whole-frame blend was measured to
|
||||
return detector-visible OpenAI SynthID on poster-scale manifests (official
|
||||
Content Provenance API, 2026-08-19 - detected x6 with the anchor, clean x6
|
||||
without it, controls and base outputs validated in the same sessions). Pass
|
||||
`fidelity_anchor=True` to reproduce the 0.27.0 research behavior.
|
||||
|
||||
### Drafting manifest lines
|
||||
|
||||
`remove_ai_watermarks.text_draft` proposes lines for a manifest; it never
|
||||
produces verified ones:
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.text_draft import draft_text_lines
|
||||
|
||||
draft = draft_text_lines(Path("watermarked.png"))
|
||||
for line in draft.accepted:
|
||||
print(line.box, line.script, line.min_score, line.text)
|
||||
```
|
||||
|
||||
Install `remove-ai-watermarks[text-draft]` (CPU, no torch: PaddleOCR detection
|
||||
plus three script-chosen recognition engines). A line lands in `accepted` only
|
||||
when three crop paddings normalize identically and every confidence clears
|
||||
`min_score` (default 0.85); `accepted` means crop-stable, NOT ground-truth-
|
||||
correct - on the reference posters the draft's exact-text precision was 90.0%
|
||||
and 94.4% because high-confidence OCR still lost punctuation. Every accepted
|
||||
line needs a human yes/no before a manifest may claim `verified: true`.
|
||||
`source_pixel_sha256` is re-exported here for building the manifest's
|
||||
pixel-binding hash against the exact source the engine will decode.
|
||||
|
||||
`remove_watermark` takes strength, seed, tiling, resolution, and postprocessing
|
||||
controls. It takes no model id, step count or guidance scale, and neither does the
|
||||
constructor: each profile pins its model stack, its per-stage schedule and CFG
|
||||
|
||||
@@ -11,7 +11,7 @@ A release is complete only after all four published surfaces are verified:
|
||||
|---|---|---|
|
||||
| PyPI | The `remove-ai-watermarks` wheel and source distribution | `publish.yml` |
|
||||
| Homebrew | The `remove-ai-watermarks` formula in `wiltodelta/homebrew-tap` | `distribute.yml` |
|
||||
| Hugging Face Space | A factory rebuild of `wiltodelta/remove-ai-watermarks` against the new PyPI release | `distribute.yml` |
|
||||
| Hugging Face Space | The Space demo deployed on the new release through a requirements-pin bump in `wiltodelta/raiw-hf-space` | `raiw-hf-space` sync workflow; the pin bump is manual, `distribute.yml` only re-installs the pinned version |
|
||||
| ComfyUI Registry | A compatible release of `wiltodelta/ComfyUI-remove-ai-watermarks` with its own node version | `distribute.yml` and the node repository's workflows |
|
||||
|
||||
The GitHub Release is the trigger and release record for this flow. Conda is
|
||||
@@ -58,9 +58,17 @@ PyPI API token from the repository.
|
||||
waits for the matching source distribution to appear on PyPI, then:
|
||||
|
||||
- updates the Homebrew tap formula URL and SHA-256;
|
||||
- triggers a factory rebuild of the Hugging Face Space;
|
||||
- triggers a factory rebuild of the Hugging Face Space: this re-installs the
|
||||
version pinned in the Space repo, it does NOT upgrade the demo;
|
||||
- synchronizes, tests, versions, and publishes the ComfyUI nodes.
|
||||
|
||||
Upgrading the Hugging Face Space is a separate, manual step: bump
|
||||
`remove-ai-watermarks[visible,heif]` in `wiltodelta/raiw-hf-space` (`pyproject.toml`,
|
||||
`uv lock`, and the re-exported `requirements.txt`), then push. That repository's
|
||||
`sync-to-hf.yml` mirrors the files onto the Space, which rebuilds on the new
|
||||
pin. Its callback smoke tests run in that repository's CI on every push; they
|
||||
are the scenario check for the demo surface.
|
||||
|
||||
The workflow can also be started manually with an optional version input.
|
||||
|
||||
If a distribution job fails because a repository or Hugging Face credential is
|
||||
@@ -116,4 +124,20 @@ After publication, verify:
|
||||
- the Homebrew formula points to the new source distribution;
|
||||
- the distribution workflow completed successfully;
|
||||
- the ComfyUI Registry node requires the new library version;
|
||||
- the Hugging Face Space serves the new release: its `requirements.txt` pin
|
||||
matches (raw file via the HF API), the runtime stage is `RUNNING` after the
|
||||
rebuild, and one live `identify` and one live `visible` API call succeed
|
||||
against the running Space;
|
||||
- a clean install can run `remove-ai-watermarks --version`.
|
||||
|
||||
A clean-install check run immediately after publication can fail with "no
|
||||
version of remove-ai-watermarks==X.Y.Z" even though the simple index already
|
||||
lists it: uv serves its cached pre-release view of the index. Re-run with a
|
||||
fresh cache (`UV_CACHE_DIR=$(mktemp -d)`) before treating it as a release
|
||||
failure.
|
||||
|
||||
The ComfyUI sync run can fail the same way on its own: it resolves the version
|
||||
from the PyPI JSON API (which updates first) but installs the test dependency
|
||||
with pip against the simple index, whose CDN edges lag by minutes. Rerun the
|
||||
failed `distribute.yml` comfyui job once the simple index lists the release;
|
||||
nothing about the release itself is wrong.
|
||||
@@ -88,9 +88,11 @@ The inspection and stripping code handles signals in these groups:
|
||||
unknown when evidence is absent. It never treats missing metadata as proof that
|
||||
an image is human made. C2PA presence alone is not a verified identity: the
|
||||
report distinguishes asset binding, claim signature, signer trust, and signer
|
||||
validity. High-confidence C2PA attribution requires all four; intact but
|
||||
untrusted or fallback claims are medium-confidence, while a failed binding or
|
||||
signature contributes no origin verdict.
|
||||
validity. High-confidence C2PA attribution requires an intact asset binding and
|
||||
claim signature; signer trust and certificate expiry are reported as their own
|
||||
dimensions and as caveats, since no trust anchor list ships to evaluate them.
|
||||
Fallback claims, which validate nothing, remain medium-confidence, while a failed
|
||||
binding or signature or a revoked credential contributes no origin verdict.
|
||||
|
||||
## File and container formats
|
||||
|
||||
@@ -138,6 +140,45 @@ all-media watermark policy, and current OpenAI C2PA carrying an explicit
|
||||
assert SynthID. After provenance metadata is removed, a local negative result
|
||||
is still inconclusive.
|
||||
|
||||
Microsoft Paint can name `com.microsoft.invismark.1` in a C2PA soft-binding
|
||||
assertion. Inspection reports both that exact algorithm and its signed `value`,
|
||||
which Paint uses as the identifier carried by the pixel watermark, and emits an
|
||||
additive `invismark` signal so callers can select pixel removal without parsing
|
||||
the generic `soft_binding` detail. Photos uses a parallel local writer path.
|
||||
Metadata stripping removes only the embedded manifest; `invisible` and `all`
|
||||
guarantee the supported InvisMark removal contract by regenerating the pixel
|
||||
layer as well. The project has no validated local InvisMark decoder, so local
|
||||
inspection cannot independently verify the output. Microsoft's official
|
||||
[Content Provenance Detection API](https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection)
|
||||
is the external oracle: it reports pixel `Watermark` and embedded `C2PA` results
|
||||
separately; a control-positive, output-negative pair is the available per-file
|
||||
verification path.
|
||||
|
||||
Meta Muse Image stamps every output with Content Seal, a proprietary invisible
|
||||
pixel watermark, and ships no visible mark (the legacy `Imagined with AI`
|
||||
corner mark belongs to the pre-2026 Imagine pipeline and is not registered).
|
||||
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
|
||||
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
|
||||
per-generation ID and creation timestamp read from the watermark payload. No
|
||||
identification endpoint exists in the Meta Model API itself (the reference at
|
||||
dev.meta.ai documents only generation and edits for images), and the official
|
||||
documentation never mentions the seal. The
|
||||
default `qwen-zimage` profile clears Content Seal at the default
|
||||
resolution-adaptive strength (oracle-verified on 2.56 MP generations); measured
|
||||
strength boundaries are recorded in `data/contentseal/manifest.csv` and
|
||||
[module internals](module-internals.md#meta-content-seal-boundaries-for-qwen-zimage)
|
||||
(derived Meta floor 0.1 by the standard spread method, not shipped as a constant
|
||||
because no provenance signal can route Muse output onto a vendor cohort).
|
||||
The seal survives resizing, JPEG recompression, and metadata stripping; it dies
|
||||
to center crops of a third to a half, matching the Reuters 2026-07-11 finding
|
||||
that Meta's detector missed 55% of cropped Muse images.
|
||||
|
||||
For MP4, MOV, and M4V, `video invisible` or the explicit
|
||||
`video all --invisible` option can regenerate the video through a VAE and strip
|
||||
source metadata. The shipped profile is oracle-certified, but it is not a local
|
||||
@@ -158,6 +199,8 @@ not a universal clean verdict.
|
||||
| Google Gemini | Sparkle | Diffusion regeneration for SynthID | C2PA and related source signals |
|
||||
| Google Veo video | Veo diamond and legacy text | Oracle-certified VAE removal for SynthID | C2PA and related source signals |
|
||||
| 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 |
|
||||
| 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 |
|
||||
|
||||
+17
-5
@@ -537,8 +537,8 @@ study (section 2.2) gives empirical floors:
|
||||
> were removed, and `OPENAI_STRENGTH` / `GEMINI_STRENGTH` / `UNKNOWN_STRENGTH` went
|
||||
> with them. Everything from here to the end of this section is a record of what was
|
||||
> measured on those profiles, kept because the oracle verdicts are still the evidence
|
||||
> base. For the strength policy in force now see `module-internals.md`: `qwen-zimage`
|
||||
> uses `resolution_adaptive_denoise`, `sdxl-zimage` a flat vendor ladder.
|
||||
> base. For the strength policy in force now see
|
||||
> [`known-limitations.md`](known-limitations.md#strength-is-content-and-seed-dependent).
|
||||
|
||||
The default was **vendor-adaptive** (`watermark_profiles.resolve_strength` +
|
||||
`vendor_for_strength`): the tool read the C2PA issuer on the original input and picked
|
||||
@@ -691,9 +691,9 @@ solid at 0.10 but at 0.15 it is near-threshold noise; (2) for reliable removal p
|
||||
a strength with MARGIN above the borderline (controlnet >= 0.20), not exactly on
|
||||
it; (3) **historical engineering conclusion:** this dated run argued for a
|
||||
higher ControlNet strength than the then-current default. That proposal was
|
||||
later superseded. The current resolver intentionally shares the 0.10/0.15
|
||||
ladder between SDXL and ControlNet and uses a separate Qwen ladder; see
|
||||
`_internal/watermark_profiles.py`.
|
||||
later superseded. The resolver of that period shared the 0.10/0.15 ladder between
|
||||
SDXL and ControlNet; the current policies are recorded in
|
||||
[`known-limitations.md`](known-limitations.md#strength-is-content-and-seed-dependent).
|
||||
Source images are private (faces / product shots), not committed; reproduce on any
|
||||
photoreal + flat-graphic gpt-image pair, varying the seed, and re-checking the
|
||||
oracle.
|
||||
@@ -769,3 +769,15 @@ reproducible verification requires a fixed seed.
|
||||
8. Jiang et al. (2025). **VideoMarkBench: Benchmarking Robustness of Video
|
||||
Watermarking.** arXiv:2505.21620.
|
||||
https://arxiv.org/abs/2505.21620
|
||||
|
||||
**Google floor on qwen-zimage (0.27.2, remeasured 2026-08-25).** The bracket above did not
|
||||
survive the full production path: on the 4.33 MP CJK-sign fixture (visible-stage
|
||||
sparkle removal -> qwen-zimage seed 0 at the curve's 0.154 top -> resize-back ->
|
||||
metadata strip), the Gemini verifier detected SynthID x3 with a valid
|
||||
pixel-identical stripped control in the same session. Google-provenance content
|
||||
initially took a flat `0.30` floor instead of the area curve. The later provider
|
||||
remeasurement established a common pass at `0.25` across three valid sources and
|
||||
separately repeated `QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.27` across those sources and
|
||||
three accounts. OpenAI now has its own measured flat operating point; unknown
|
||||
content keeps the resolution curve. An explicit strength still wins. Certification artifacts: raiw-app
|
||||
`data/certification/text-restoration-2026-08-18/` (the failing re-baseline).
|
||||
@@ -20,8 +20,8 @@ filter applied to every candidate below.
|
||||
## Problem recap
|
||||
|
||||
The `invisible` pipeline is SDXL base 1.0 img2img to defeat SynthID. The default
|
||||
strength has risen over time as Google hardens SynthID (0.05 -> 0.10 -> **~0.30**, the
|
||||
current threshold for fresh Gemini output); higher strength deforms text more, which is
|
||||
strength rose over time as Google hardened SynthID (0.05 -> 0.10 -> **~0.30** in
|
||||
this experiment; the later measured operating point is 0.27); higher strength deforms text more, which is
|
||||
exactly why text protection matters. Text is protected via Differential Diffusion with a
|
||||
per-pixel change map (`preserve` ~0.9) driven by the PP-OCRv3 DB detector
|
||||
(`text_protector.py`). Large text survives; **small text (sub ~8 px strokes) softens or
|
||||
@@ -343,3 +343,29 @@ The stable errors were punctuation: one lost English comma and an ideographic
|
||||
comma consistently replaced by ASCII. Thus crop agreement and confidence do
|
||||
not verify literal text. The helper is retained only to seed annotations; every
|
||||
proposal needs manual review before it can enter the restoration compositor.
|
||||
|
||||
## Production-path anchor leak and fix (2026-08-19)
|
||||
|
||||
The engine `text_manifest=` path (unlike the research compositor above) blends a
|
||||
15% Qwen-VAE fidelity anchor across the WHOLE frame before glyph restoration.
|
||||
Certified through the official OpenAI Content Provenance API on the exact
|
||||
production path (qwen-zimage, seed 0, no strength override, untiled, LANCZOS
|
||||
resize-back, metadata strip; library 0.27.0 @ 50d9f448):
|
||||
|
||||
- controls (pixel-identical metadata-stripped sources): `synthid: detected` -
|
||||
the pixel channel was proven live before every session;
|
||||
- base outputs: `clean` x6 (two 20-line posters, 3 attempts each);
|
||||
- text-restored WITH the anchor: `synthid: detected` x6, deterministic;
|
||||
- text-restored with `FIDELITY_BLEND_ALPHA = 0` (runtime ablation, base bytes
|
||||
identical between runs): `clean` x6.
|
||||
|
||||
The anchor was therefore the leak channel: a 15% global donor admixture over a
|
||||
scrubbed frame returns enough OpenAI SynthID for the provider detector. Fidelity
|
||||
cost of dropping it (source-referenced text-box MAE): 11.60->11.72, 7.79->7.86,
|
||||
and 7.57->8.13 across the two posters and the CJK sign - well under one point
|
||||
against base MAEs of 24.7/16.8/10.2. Since 0.27.1 the anchor is off by default
|
||||
(`fidelity_anchor` parameter on `remove_watermark` / `InvisibleOptions` /
|
||||
`--fidelity-anchor`); Google rows were not re-queried because the default
|
||||
strength leaves that fixture's base detected (the vendor-strength lane tracks
|
||||
that separately). Certification artifacts: raiw-app
|
||||
`data/certification/text-restoration-2026-08-19-A1/` (sha256-stamped rows).
|
||||
@@ -186,10 +186,14 @@ report recall from the detector-sampled set.
|
||||
|
||||
## Tier D -- external oracles (manual, not automatable here)
|
||||
|
||||
SynthID removal cannot be verified locally by design -- no public decoder exists. Each
|
||||
vendor has its own oracle and it covers only that vendor's content: `openai.com/verify` for
|
||||
OpenAI (more accessible, the automation candidate), the Gemini app for Google (manual,
|
||||
rate-limited). A quiet metadata proxy is **not** proof the pixel watermark is gone.
|
||||
Proprietary watermark removal cannot be verified locally by design -- no public decoder
|
||||
exists. Each vendor has its own oracle and it covers only that vendor's content:
|
||||
`openai.com/verify` for OpenAI, the Gemini app for Google, and Microsoft's
|
||||
[Content Provenance Detection API](https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection)
|
||||
for InvisMark. The Microsoft API reports `Watermark` and `C2PA` separately. It therefore
|
||||
needs a pixel-identical metadata-stripped control: the control must lose `C2PA` while
|
||||
remaining `Watermark`-positive before a candidate's negative result can be attributed to
|
||||
pixel regeneration. A quiet metadata proxy is **not** proof the pixel watermark is gone.
|
||||
|
||||
Scope honestly: this tier certifies strength floors on a handful of images per vendor, and
|
||||
that is all it can do. See `docs/synthid.md`.
|
||||
|
||||
@@ -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 (Designer + **Bing Image Creator** — collected 2026-05-24; Bing now runs Microsoft's own **MAI-Image** model, signs C2PA as "Microsoft", 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 / 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 (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.
|
||||
- **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).**
|
||||
@@ -65,7 +65,8 @@ payloads. Removal remuxes either container through ffmpeg with stream copy.
|
||||
DWT-DCT decoder only applies when the producing pipeline actually ran its
|
||||
encoder and the carrier remains decodable.
|
||||
- **Invisible but NOT locally detectable (proprietary, API/oracle only — same wall as SynthID):** Amazon Titan Image Generator + Nova Canvas (Bedrock `DetectGeneratedContent` API), Kakao (new SynthID image adopter, May 2026), NVIDIA Cosmos (SynthID video). No local detector possible; treat like SynthID.
|
||||
- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We now READ the soft-binding `alg` (`C2PA_SOFT_BINDINGS` / `soft_binding_vendors_in`) to name the forensic-watermark vendor, and locally DECODE the one open scheme, Adobe TrustMark (`trustmark_detector`); the rest (Digimarc/Imatag/Steg.AI/...) stay name-only (proprietary decoders).
|
||||
- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We now READ the soft-binding `alg` (`C2PA_SOFT_BINDINGS` / `soft_binding_vendors_in`) to name the forensic-watermark vendor, preserve the structured assertion's signed `value`, and locally DECODE the one open scheme, Adobe TrustMark (`trustmark_detector`); the rest stay name-only unless a compatible decoder is independently verified.
|
||||
- **Microsoft Paint and Photos InvisMark (reverse-engineered 2026-08-20):** Paint receives a per-generation GUID from remote prompt moderation, embeds it into locally generated pixels, and records the same value in `c2pa.soft-binding` under `com.microsoft.invismark.1`. The C2PA soft-binding registry independently identifies that algorithm as Microsoft Responsible AI InvisMark for image and video. Paint's 144-bit writer framing does not match the public repository's 100-bit pretrained checkpoint interface, so compatibility is not assumed. The parser reports the signed identifier; there is no validated local pixel decoder. Microsoft's external Content Provenance Detection API is the removal oracle because it reports `Watermark` separately from `C2PA`; a metadata-stripped, pixel-identical control must remain watermark-positive before an output-negative result is attributed to pixel removal. Sources: https://xusheng.dev/posts/reversing/mspaint_invisible_watermark/main/, https://github.com/c2pa-org/softbinding-algorithm-list/blob/main/softbinding-algorithm-list.json, and https://learn.microsoft.com/en-us/azure/ai-services/content-safety/how-to/how-to-provenance-detection
|
||||
- **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
|
||||
@@ -132,7 +133,7 @@ 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.
|
||||
|
||||
**Visible-mark landscape beyond the registry.** Meta stamps a visible "Imagined with AI" mark (bottom-LEFT, a small symbol) on its OWN Meta AI / "Imagine" output; for third-party images it 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 (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.
|
||||
|
||||
**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.
|
||||
|
||||
@@ -160,6 +161,8 @@ Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a comp
|
||||
|
||||
**Tencent Yuanbao: RESOLVED 2026-07-25, registered (`yuanbao_engine.py`).** The standard mark is a compact two-line italic `元宝` over `AI生成` block at bottom-right. It switches between light and dark strokes with the scene, so detection uses polarity-independent local contrast rather than a white top-hat. The separate one-line overlay variant remains evidence-limited to one example.
|
||||
|
||||
**Meta Muse Image / Content Seal: current state VERIFIED 2026-08-26 against live artifacts.** Muse Image (launched 2026-07-07, first image model from Meta Superintelligence Labs) puts no visible mark on output. Every output carries Content Seal, a proprietary invisible pixel watermark; research lineage is open (`github.com/facebookresearch/content-seal`, Pixel Seal / VideoSeal / Watermark Anything), but the deployed implementation is proprietary and unpublished. API outputs and Meta CDN copies also carry XMP `iptcExt:DigitalSourceType = trainedAlgorithmicMedia` (a standard IPTC code, not Meta-exclusive). The only reader is the anonymous web oracle `meta.ai/identification`, which returns model attribution plus a per-generation ID and timestamp embedded in the payload. Verified robustness (our corpus, `data/contentseal/`): seal survives CDN WebP transcode, 512 px resize, JPEG q85, and full metadata stripping; it is lost to center crops of 33-50% linear size (Reuters measured 55% detector misses on cropped images, 2026-07-11). The default `qwen-zimage` profile clears it (oracle-verified). Legacy paragraph retained below.
|
||||
|
||||
**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. `identify` reads the supported IPTC disclosure; it does not decode Meta's proprietary invisible watermark. 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.
|
||||
|
||||
+9
-1
@@ -4,7 +4,7 @@
|
||||
# on it, including the ComfyUI node package. The console script below carries
|
||||
# the same weight, since users have it on PATH.
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.27.0"
|
||||
version = "0.31.2"
|
||||
description = "AI watermark remover for visible, invisible, and provenance marks in images and video"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11,<3.15"
|
||||
@@ -125,6 +125,14 @@ qwen-zimage = [
|
||||
text-restoration = [
|
||||
"remove-ai-watermarks[qwen-zimage,lama]",
|
||||
]
|
||||
# Draft-only OCR proposals for verified-text manifests. CPU, no torch:
|
||||
# PaddleOCR detection plus three script-chosen recognition engines. The models
|
||||
# download on first use and are cached by huggingface_hub; never bundled.
|
||||
text-draft = [
|
||||
"paddleocr>=3.3.3",
|
||||
"paddlepaddle>=3.0.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
# Adobe TrustMark decoder -- the open, keyless watermark behind Adobe Durable
|
||||
# Content Credentials (soft-binding alg ``com.adobe.trustmark.P``). Optional
|
||||
# because it pulls torch and downloads model weights on first use. identify()
|
||||
|
||||
+11
-40
@@ -21,14 +21,11 @@ changes under crop jitter or whose minimum confidence is below the threshold.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
import unicodedata
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
@@ -37,38 +34,15 @@ from PIL import Image
|
||||
log = logging.getLogger(__name__)
|
||||
ROOT = Path(__file__).resolve().parents[1]
|
||||
sys.path.insert(0, str(ROOT))
|
||||
RESTORATION_SCRIPT = ROOT / "scripts/selective_text_restoration.py"
|
||||
|
||||
from scripts._text_eval import normalize_text # noqa: E402
|
||||
|
||||
|
||||
def _load_restoration_module() -> Any:
|
||||
spec = importlib.util.spec_from_file_location("selective_text_restoration_for_inference", RESTORATION_SCRIPT)
|
||||
if spec is None or spec.loader is None:
|
||||
raise RuntimeError(f"unable to load {RESTORATION_SCRIPT}")
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
sys.modules[spec.name] = module
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
def _has_script(text: str, script: str) -> bool:
|
||||
return any(script in unicodedata.name(character, "") for character in text)
|
||||
|
||||
|
||||
def choose_language(probes: dict[str, tuple[str, float]]) -> str:
|
||||
if _has_script(probes["ch"][0], "CJK"):
|
||||
return "ch"
|
||||
if _has_script(probes["ru"][0], "CYRILLIC"):
|
||||
return "ru"
|
||||
return "en"
|
||||
|
||||
|
||||
def stable_recognition(reads: list[tuple[str, float]], min_score: float = 0.85) -> str | None:
|
||||
normalized = {normalize_text(text) for text, _score in reads}
|
||||
if len(normalized) != 1 or min(score for _text, score in reads) < min_score:
|
||||
return None
|
||||
return reads[0][0]
|
||||
# Dogfoods the packaged draft API (remove_ai_watermarks.text_draft); this script
|
||||
# keeps only the CLI wrapper so the package stays the one home for the logic.
|
||||
from remove_ai_watermarks.text_draft import ( # noqa: E402
|
||||
_detect_line_boxes,
|
||||
_recognize,
|
||||
choose_language,
|
||||
stable_recognition,
|
||||
)
|
||||
|
||||
|
||||
@click.command()
|
||||
@@ -81,7 +55,6 @@ def main(source: Path, out: Path, min_score: float) -> None:
|
||||
os.environ["PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK"] = "True"
|
||||
from paddleocr import PaddleOCR, TextRecognition
|
||||
|
||||
restoration = _load_restoration_module()
|
||||
source_rgb = np.asarray(Image.open(source).convert("RGB"))
|
||||
detector = PaddleOCR(
|
||||
lang="ch",
|
||||
@@ -94,19 +67,17 @@ def main(source: Path, out: Path, min_score: float) -> None:
|
||||
"ru": TextRecognition(model_name="eslav_PP-OCRv5_mobile_rec"),
|
||||
"ch": TextRecognition(model_name="PP-OCRv5_server_rec"),
|
||||
}
|
||||
boxes = restoration.detect_line_boxes(detector, source_rgb)
|
||||
boxes = _detect_line_boxes(detector, source_rgb)
|
||||
accepted = []
|
||||
rejected = []
|
||||
for box in boxes:
|
||||
probes = {}
|
||||
for language, engine in engines.items():
|
||||
script = "cjk" if language == "ch" else "alphabetic"
|
||||
line = restoration.TextLine(box, "", script)
|
||||
probes[language] = restoration._recognize(engine, source_rgb, line, 0.1)
|
||||
probes[language] = _recognize(engine, source_rgb, box, script, 0.1)
|
||||
language = choose_language(probes)
|
||||
script = "cjk" if language == "ch" else "alphabetic"
|
||||
line = restoration.TextLine(box, "", script)
|
||||
reads = [restoration._recognize(engines[language], source_rgb, line, ratio) for ratio in (0.08, 0.12, 0.2)]
|
||||
reads = [_recognize(engines[language], source_rgb, box, script, ratio) for ratio in (0.08, 0.12, 0.2)]
|
||||
text = stable_recognition(reads, min_score)
|
||||
result = {
|
||||
"box": box,
|
||||
|
||||
@@ -32,7 +32,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.27.0"
|
||||
__version__ = "0.31.2"
|
||||
|
||||
__all__ = [
|
||||
"BatchSummary",
|
||||
|
||||
@@ -62,6 +62,16 @@ _SIGNATURE_FAILURES = frozenset(
|
||||
"claimSignature.outsideValidity",
|
||||
}
|
||||
)
|
||||
# A credential the issuer itself has disowned, disqualifying like a hash mismatch.
|
||||
# ``signingCredential.expired`` is deliberately absent: an expired certificate does not
|
||||
# imply the signed bytes changed, and a signature actually made outside validity already
|
||||
# arrives as ``claimSignature.outsideValidity`` above.
|
||||
_CREDENTIAL_FAILURES = frozenset(
|
||||
{
|
||||
"signingCredential.invalid",
|
||||
"signingCredential.ocsp.revoked",
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -293,21 +303,35 @@ def _status_codes(store: dict[str, Any]) -> tuple[list[str], list[str], list[str
|
||||
|
||||
|
||||
def _store_has_invalid_credential(store: dict[str, Any]) -> bool:
|
||||
"""Return whether this store's own active credential failed validation."""
|
||||
_, _, failures = _status_codes(store)
|
||||
return any(marker in code for code in failures for marker in _CONTENT_BINDING_FAILURE_MARKERS) or any(
|
||||
code in _SIGNATURE_FAILURES for code in failures
|
||||
)
|
||||
"""Return whether this store's own active credential failed validation.
|
||||
|
||||
Deliberately routed through the same codes -> dimensions -> disqualified path the
|
||||
report uses, rather than re-testing the raw codes here. When this walk classified
|
||||
failures on its own, adding one rule meant editing it and
|
||||
:func:`c2pa_info_has_invalid_credential` in lockstep, and the ingredient
|
||||
reachability walk was free to drift away from the verdict it feeds.
|
||||
"""
|
||||
return c2pa_info_has_invalid_credential(_validation_fields(store))
|
||||
|
||||
|
||||
def c2pa_info_has_invalid_credential(info: dict[str, Any]) -> bool:
|
||||
"""Return whether parsed C2PA info reports a failed binding or signature."""
|
||||
return info.get("c2pa_integrity") == "invalid" or info.get("c2pa_signature") == "invalid"
|
||||
"""Return whether parsed C2PA info reports a failed binding, signature, or credential."""
|
||||
return (
|
||||
info.get("c2pa_integrity") == "invalid"
|
||||
or info.get("c2pa_signature") == "invalid"
|
||||
or info.get("c2pa_signer_validity") == "invalid"
|
||||
)
|
||||
|
||||
|
||||
def c2pa_info_has_invismark(info: dict[str, Any]) -> bool:
|
||||
"""Return whether parsed C2PA info declares Microsoft InvisMark."""
|
||||
soft_bindings = info.get("soft_binding_vendors")
|
||||
return isinstance(soft_bindings, list) and "Microsoft InvisMark" in soft_bindings
|
||||
|
||||
|
||||
def c2pa_info_has_removal_hint(info: dict[str, Any]) -> bool:
|
||||
"""Return whether a C2PA AI or watermark claim should keep removal fail-safe."""
|
||||
return bool(info.get("ai_source_kind") or info.get("synthid_watermark"))
|
||||
return bool(info.get("ai_source_kind") or info.get("synthid_watermark") or c2pa_info_has_invismark(info))
|
||||
|
||||
|
||||
def _has_suffix(codes: list[str], suffixes: tuple[str, ...]) -> bool:
|
||||
@@ -318,9 +342,16 @@ def _validation_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
successes, informationals, failures = _status_codes(store)
|
||||
all_codes = list(dict.fromkeys([*successes, *informationals, *failures]))
|
||||
|
||||
binding_failed = any(marker in code for code in failures for marker in _CONTENT_BINDING_FAILURE_MARKERS)
|
||||
disqualifying = [
|
||||
code
|
||||
for code in failures
|
||||
if any(marker in code for marker in _CONTENT_BINDING_FAILURE_MARKERS)
|
||||
or code in _SIGNATURE_FAILURES
|
||||
or code in _CREDENTIAL_FAILURES
|
||||
]
|
||||
binding_failed = any(marker in code for code in disqualifying for marker in _CONTENT_BINDING_FAILURE_MARKERS)
|
||||
binding_matched = _has_suffix(successes, _CONTENT_BINDING_MATCHES)
|
||||
signature_failed = any(code in _SIGNATURE_FAILURES for code in failures)
|
||||
signature_failed = any(code in _SIGNATURE_FAILURES for code in disqualifying)
|
||||
signature_validated = "claimSignature.validated" in successes
|
||||
|
||||
if binding_failed:
|
||||
@@ -343,7 +374,7 @@ def _validation_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
else:
|
||||
signer_trust = "unknown"
|
||||
|
||||
if any(code in failures for code in ("signingCredential.invalid", "signingCredential.ocsp.revoked")):
|
||||
if any(code in _CREDENTIAL_FAILURES for code in disqualifying):
|
||||
signer_validity = "invalid"
|
||||
elif "signingCredential.expired" in failures:
|
||||
signer_validity = "expired"
|
||||
@@ -361,6 +392,10 @@ def _validation_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
"c2pa_signer_trust": signer_trust,
|
||||
"c2pa_signer_validity": signer_validity,
|
||||
"c2pa_validation_codes": all_codes,
|
||||
# The subset of failures that actually drove a dimension to invalid. Callers
|
||||
# rendering "why" must use this rather than re-classifying the full code list,
|
||||
# or the reason shown and the verdict reached stop being the same rule.
|
||||
"c2pa_failed_codes": disqualifying,
|
||||
}
|
||||
|
||||
|
||||
@@ -390,6 +425,7 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
actions: list[str] = []
|
||||
source_types: list[str] = []
|
||||
soft_binding_algorithms: list[str] = []
|
||||
soft_binding_values: list[str] = []
|
||||
claim_generator_asserts_ai = False
|
||||
|
||||
def add_tool_matches(value: str, *, asserts_ai: bool = False) -> None:
|
||||
@@ -431,9 +467,18 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
label = assertion.get("label")
|
||||
data = assertion.get("data")
|
||||
if isinstance(label, str) and label.startswith("c2pa.soft-binding") and isinstance(data, dict):
|
||||
algorithm = cast("dict[object, object]", data).get("alg")
|
||||
soft_binding = cast("dict[object, object]", data)
|
||||
algorithm = soft_binding.get("alg")
|
||||
if isinstance(algorithm, str):
|
||||
soft_binding_algorithms.append(algorithm)
|
||||
blocks = soft_binding.get("blocks")
|
||||
if isinstance(blocks, list):
|
||||
for block_value in cast("list[object]", blocks):
|
||||
if not isinstance(block_value, dict):
|
||||
continue
|
||||
value = cast("dict[object, object]", block_value).get("value")
|
||||
if isinstance(value, str) and value.isprintable() and len(value) <= 256:
|
||||
soft_binding_values.append(value)
|
||||
if not (isinstance(label, str) and label.startswith("c2pa.actions") and isinstance(data, dict)):
|
||||
continue
|
||||
action_values = cast("dict[object, object]", data).get("actions")
|
||||
@@ -491,11 +536,15 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]:
|
||||
info["synthid_watermark"] = synthid_verdict(", ".join(synthid))
|
||||
|
||||
if soft_binding_algorithms:
|
||||
soft_binding_algorithms = list(dict.fromkeys(soft_binding_algorithms))
|
||||
algorithms = "\n".join(soft_binding_algorithms).encode()
|
||||
soft_bindings = soft_binding_vendors_in(algorithms)
|
||||
if soft_bindings:
|
||||
info["soft_binding_vendors"] = soft_bindings
|
||||
info["soft_binding"] = ", ".join(soft_bindings)
|
||||
info["soft_binding_algorithm"] = ", ".join(soft_binding_algorithms)
|
||||
if soft_binding_values:
|
||||
info["soft_binding_value"] = ", ".join(dict.fromkeys(soft_binding_values))
|
||||
return info
|
||||
|
||||
|
||||
@@ -590,6 +639,7 @@ def _base_info(byte_count: int, *, fallback: bool = False) -> dict[str, Any]:
|
||||
c2pa_signer_trust="unknown",
|
||||
c2pa_signer_validity="unknown",
|
||||
c2pa_validation_codes=[],
|
||||
c2pa_failed_codes=[],
|
||||
)
|
||||
return info
|
||||
|
||||
|
||||
@@ -65,7 +65,7 @@ def _vendor(
|
||||
|
||||
# Order is product priority when a manifest mentions more than one organization.
|
||||
C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
_vendor(b"Microsoft", "Microsoft", "Microsoft (Bing Image Creator / Designer)", "Microsoft"),
|
||||
_vendor(b"Microsoft", "Microsoft", "Microsoft (Copilot / Designer)", "Microsoft"),
|
||||
_vendor(b"Adobe", "Adobe", "Adobe Firefly", "Adobe"),
|
||||
_vendor(
|
||||
b"OpenAI",
|
||||
@@ -78,18 +78,25 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = (
|
||||
_vendor(b"Google", "Google LLC", "Google (Gemini / Imagen)", "Google", synthid=True),
|
||||
_vendor(b"Stability AI", "Stability AI", "Stability AI (Stable Image / DreamStudio)", "Stability AI"),
|
||||
_vendor(b"Black Forest Labs", "Black Forest Labs", "Black Forest Labs (FLUX)", "Black Forest Labs"),
|
||||
_vendor(b"volcengine", "ByteDance (Volcano Engine)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"),
|
||||
_vendor(
|
||||
b"volcengine",
|
||||
"ByteDance (Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)",
|
||||
"ByteDance",
|
||||
),
|
||||
_vendor(
|
||||
"北京火山引擎科技有限公司",
|
||||
"ByteDance (Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)",
|
||||
"ByteDance",
|
||||
),
|
||||
_vendor(b"Byteplus", "BytePlus (ByteDance)", "ByteDance (Doubao / Jimeng / Volcano Engine)", "ByteDance"),
|
||||
_vendor(
|
||||
b"Byteplus", "BytePlus (ByteDance)", "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)", "ByteDance"
|
||||
),
|
||||
_vendor(
|
||||
b"Dreamina",
|
||||
"ByteDance (Dreamina)",
|
||||
"ByteDance (Doubao / Jimeng / Volcano Engine)",
|
||||
"ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)",
|
||||
"ByteDance",
|
||||
asserts_ai=True,
|
||||
),
|
||||
@@ -109,7 +116,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 / Volcano Engine)"),
|
||||
("dreamina", "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)"),
|
||||
("higgsfield ai", "Higgsfield AI"),
|
||||
("topaz labs image api", "Topaz Labs"),
|
||||
("tiktok ad creative toolbox", "TikTok Ad Creative Toolbox"),
|
||||
|
||||
@@ -1071,6 +1071,7 @@ class QwenZImagePipeline:
|
||||
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."""
|
||||
self._require_cuda()
|
||||
@@ -1078,7 +1079,24 @@ class QwenZImagePipeline:
|
||||
donor = None
|
||||
if text_manifest is not None:
|
||||
self._progress("Reconstructing the verified text donor with the Qwen VAE...")
|
||||
donor = self._qwen_vae_roundtrip(image)
|
||||
if tile and max(image.size) > tile_size:
|
||||
from remove_ai_watermarks._internal.tiling import run_tiled
|
||||
|
||||
donor = run_tiled(
|
||||
self._qwen_vae_roundtrip,
|
||||
image,
|
||||
tile_size,
|
||||
tile_overlap,
|
||||
lambda message: self._progress(
|
||||
message.replace(
|
||||
"Tiled diffusion",
|
||||
"Reconstructing the verified text donor",
|
||||
1,
|
||||
)
|
||||
),
|
||||
)
|
||||
else:
|
||||
donor = self._qwen_vae_roundtrip(image)
|
||||
global_strength = (
|
||||
resolution_adaptive_denoise(image.width, image.height) if strength is None else float(strength)
|
||||
)
|
||||
@@ -1120,7 +1138,16 @@ class QwenZImagePipeline:
|
||||
restore_verified_text,
|
||||
)
|
||||
|
||||
self._progress("Blending the Qwen-VAE fidelity anchor...")
|
||||
anchor = blend_fidelity_anchor(result, donor)
|
||||
# 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)
|
||||
@@ -13,30 +13,35 @@ import cv2
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks._internal.schema import require_schema_version
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Sequence
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
TEXT_MANIFEST_SCHEMA = 1
|
||||
TEXT_MANIFEST_SCHEMA = 2
|
||||
_SUPPORTED_TEXT_MANIFEST_SCHEMAS = frozenset({1, TEXT_MANIFEST_SCHEMA})
|
||||
FIDELITY_BLEND_ALPHA = 0.15
|
||||
GLYPH_FEATHER = 0.5
|
||||
GLYPH_SIDE_PAD_RATIO = 0.12
|
||||
GLYPH_SIDE_PAD_LIMIT = 1.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedTextLine:
|
||||
"""One operator-verified source line in source-pixel coordinates."""
|
||||
"""One operator-verified source region in source-pixel coordinates."""
|
||||
|
||||
box: tuple[int, int, int, int]
|
||||
text: str
|
||||
script: str
|
||||
text: str | None = None
|
||||
script: str | None = None
|
||||
angle: float = 0.0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class VerifiedTextManifest:
|
||||
"""Text annotations cryptographically bound to one decoded RGB source."""
|
||||
"""Verified text regions cryptographically bound to one decoded RGB source."""
|
||||
|
||||
source_pixel_sha256: str
|
||||
width: int
|
||||
@@ -55,17 +60,20 @@ def source_pixel_sha256(image: Image.Image) -> str:
|
||||
|
||||
|
||||
def load_verified_text_manifest(path: Path, source: Image.Image) -> VerifiedTextManifest:
|
||||
"""Load and validate a manually verified manifest for exactly ``source``."""
|
||||
"""Load and validate an operator-verified manifest for exactly ``source``."""
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
raise ValueError(f"Cannot read text manifest {path}: {exc}") from exc
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Text manifest must be a JSON object")
|
||||
if payload.get("schema_version") != TEXT_MANIFEST_SCHEMA:
|
||||
raise ValueError(f"Text manifest schema_version must be {TEXT_MANIFEST_SCHEMA}")
|
||||
schema_version = require_schema_version(
|
||||
payload.get("schema_version"),
|
||||
contract="text manifest",
|
||||
supported=_SUPPORTED_TEXT_MANIFEST_SCHEMAS,
|
||||
)
|
||||
if payload.get("verified") is not True:
|
||||
raise ValueError("Text manifest must contain verified=true after manual review")
|
||||
raise ValueError("Text manifest must contain verified=true after operator verification")
|
||||
|
||||
rgb = source.convert("RGB")
|
||||
width = _manifest_integer(payload, "width")
|
||||
@@ -82,7 +90,7 @@ def load_verified_text_manifest(path: Path, source: Image.Image) -> VerifiedText
|
||||
raw_lines = payload.get("lines")
|
||||
if not isinstance(raw_lines, list) or not raw_lines:
|
||||
raise ValueError("Text manifest lines must be a non-empty list")
|
||||
lines = tuple(_load_line(item, width, height, index) for index, item in enumerate(raw_lines))
|
||||
lines = tuple(_load_line(item, width, height, index, schema_version) for index, item in enumerate(raw_lines))
|
||||
if list(lines) != sorted(lines, key=lambda line: (line.box[1], line.box[0])):
|
||||
raise ValueError("Text manifest lines must be in top-to-bottom, left-to-right reading order")
|
||||
return VerifiedTextManifest(actual_hash, width, height, lines)
|
||||
@@ -95,7 +103,7 @@ def _manifest_integer(payload: dict[str, Any], key: str) -> int:
|
||||
return value
|
||||
|
||||
|
||||
def _load_line(item: Any, width: int, height: int, index: int) -> VerifiedTextLine:
|
||||
def _load_line(item: Any, width: int, height: int, index: int, schema_version: int) -> VerifiedTextLine:
|
||||
if not isinstance(item, dict):
|
||||
raise ValueError(f"Text manifest line {index} must be an object")
|
||||
raw_box = item.get("box")
|
||||
@@ -109,12 +117,15 @@ def _load_line(item: Any, width: int, height: int, index: int) -> VerifiedTextLi
|
||||
x1, y1, x2, y2 = box
|
||||
if not (0 <= x1 < x2 <= width and 0 <= y1 < y2 <= height):
|
||||
raise ValueError(f"Text manifest line {index} box is outside the source dimensions")
|
||||
text = item.get("text")
|
||||
script = item.get("script")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError(f"Text manifest line {index} text must be non-empty")
|
||||
if not isinstance(script, str) or not script.strip():
|
||||
raise ValueError(f"Text manifest line {index} script must be non-empty")
|
||||
text: str | None = None
|
||||
script: str | None = None
|
||||
if schema_version == 1:
|
||||
text = item.get("text")
|
||||
script = item.get("script")
|
||||
if not isinstance(text, str) or not text.strip():
|
||||
raise ValueError(f"Text manifest line {index} text must be non-empty")
|
||||
if not isinstance(script, str) or not script.strip():
|
||||
raise ValueError(f"Text manifest line {index} script must be non-empty")
|
||||
angle_value = item.get("angle", 0.0)
|
||||
if isinstance(angle_value, bool) or not isinstance(angle_value, int | float):
|
||||
raise ValueError(f"Text manifest line {index} angle must be numeric")
|
||||
@@ -185,14 +196,41 @@ def restore_verified_text(
|
||||
return Image.fromarray(restored)
|
||||
|
||||
|
||||
def source_silhouette_mask(
|
||||
source_rgb: NDArray[Any],
|
||||
def _glyph_crop_box(
|
||||
box: tuple[int, int, int, int],
|
||||
angle: float = 0.0,
|
||||
width: int,
|
||||
height: int,
|
||||
left_pad: int,
|
||||
right_pad: int,
|
||||
) -> tuple[int, int, int, int]:
|
||||
"""Widen the detector box so glyph edges stay inside the silhouette crop.
|
||||
|
||||
Detector boxes can stop inside a leading flourish or trailing punctuation,
|
||||
and Paddle line boxes sit 2-5 px above the true ink bottom on the poster
|
||||
fixtures. The crop is otherwise the box itself, so those pixels never reach
|
||||
the donor composite. Start 12% sideways, expand each side independently
|
||||
while foreground reaches its boundary, and pad 8% up and 25% down.
|
||||
"""
|
||||
x1, y1, x2, y2 = box
|
||||
line_h = max(1, y2 - y1)
|
||||
pad_top = max(1, round(line_h * 0.08))
|
||||
pad_bot = max(2, round(line_h * 0.25))
|
||||
return (
|
||||
max(0, x1 - left_pad),
|
||||
max(0, y1 - pad_top),
|
||||
min(width, x2 + right_pad),
|
||||
min(height, y2 + pad_bot),
|
||||
)
|
||||
|
||||
|
||||
def _silhouette_crop_mask(
|
||||
source_rgb: NDArray[Any],
|
||||
crop_box: tuple[int, int, int, int],
|
||||
angle: float,
|
||||
) -> NDArray[Any]:
|
||||
"""Recover a thresholded glyph shape without retaining source amplitudes."""
|
||||
"""Threshold one candidate crop so its horizontal boundaries can be tested."""
|
||||
height, width = source_rgb.shape[:2]
|
||||
x1, y1, x2, y2 = _clip_box(box, width, height)
|
||||
x1, y1, x2, y2 = crop_box
|
||||
gray = cv2.cvtColor(source_rgb[y1:y2, x1:x2], cv2.COLOR_RGB2GRAY)
|
||||
support = np.ones(gray.shape, dtype=np.uint8)
|
||||
if angle:
|
||||
@@ -211,7 +249,7 @@ def source_silhouette_mask(
|
||||
background_luma = float(np.median(values))
|
||||
else:
|
||||
ring_pad = max(6, min(20, (y2 - y1) // 4))
|
||||
rx1, ry1, rx2, ry2 = _clip_box((x1, y1, x2, y2), width, height, pad=ring_pad)
|
||||
rx1, ry1, rx2, ry2 = _clip_box(crop_box, width, height, pad=ring_pad)
|
||||
context = cv2.cvtColor(source_rgb[ry1:ry2, rx1:rx2], cv2.COLOR_RGB2GRAY)
|
||||
ring = np.ones(context.shape, dtype=bool)
|
||||
ring[y1 - ry1 : y2 - ry1, x1 - rx1 : x2 - rx1] = False
|
||||
@@ -225,6 +263,61 @@ def source_silhouette_mask(
|
||||
else:
|
||||
crop_mask = (gray.astype(np.float32) <= background_luma - threshold).astype(np.uint8) * 255
|
||||
crop_mask[support == 0] = 0
|
||||
return crop_mask
|
||||
|
||||
|
||||
def _anchored_components_reach_sides(
|
||||
crop_mask: NDArray[Any],
|
||||
anchor_box: tuple[int, int, int, int],
|
||||
) -> tuple[bool, bool]:
|
||||
"""Whether anchored foreground reaches the left and right crop sides."""
|
||||
count, labels, stats, _centroids = cv2.connectedComponentsWithStats(crop_mask, 8)
|
||||
x1, y1, x2, y2 = anchor_box
|
||||
anchor_counts = np.bincount(labels[y1:y2, x1:x2].reshape(-1), minlength=count)
|
||||
anchor_counts[0] = 0
|
||||
|
||||
def reaches(edge_labels: NDArray[Any]) -> bool:
|
||||
return any(
|
||||
label != 0 and anchor_counts[label] >= max(3, round(stats[label, cv2.CC_STAT_AREA] * 0.1))
|
||||
for label in np.unique(edge_labels)
|
||||
)
|
||||
|
||||
return reaches(labels[y1:y2, :2]), reaches(labels[y1:y2, -2:])
|
||||
|
||||
|
||||
def source_silhouette_mask(
|
||||
source_rgb: NDArray[Any],
|
||||
box: tuple[int, int, int, int],
|
||||
angle: float = 0.0,
|
||||
) -> NDArray[Any]:
|
||||
"""Recover a thresholded glyph shape without retaining source amplitudes."""
|
||||
height, width = source_rgb.shape[:2]
|
||||
box_x1, box_y1, box_x2, box_y2 = box
|
||||
line_h = max(1, box_y2 - box_y1)
|
||||
step = max(2, round(line_h * GLYPH_SIDE_PAD_RATIO))
|
||||
limit = max(step, round(line_h * GLYPH_SIDE_PAD_LIMIT))
|
||||
left_pad = right_pad = step
|
||||
while True:
|
||||
x1, y1, x2, y2 = _glyph_crop_box(box, width, height, left_pad, right_pad)
|
||||
crop_mask = _silhouette_crop_mask(source_rgb, (x1, y1, x2, y2), angle)
|
||||
core_y1 = max(0, box_y1 - y1)
|
||||
core_y2 = min(y2 - y1, box_y2 - y1)
|
||||
anchor_box = (max(0, box_x1 - x1), core_y1, min(x2 - x1, box_x2 - x1), core_y2)
|
||||
can_expand_left = x1 > 0 and left_pad < limit
|
||||
can_expand_right = x2 < width and right_pad < limit
|
||||
left_anchored, right_anchored = (
|
||||
_anchored_components_reach_sides(crop_mask, anchor_box)
|
||||
if can_expand_left or can_expand_right
|
||||
else (False, False)
|
||||
)
|
||||
left_touches = can_expand_left and left_anchored
|
||||
right_touches = can_expand_right and right_anchored
|
||||
if not left_touches and not right_touches:
|
||||
break
|
||||
if left_touches:
|
||||
left_pad = min(limit, left_pad + step)
|
||||
if right_touches:
|
||||
right_pad = min(limit, right_pad + step)
|
||||
result = np.zeros((height, width), dtype=np.uint8)
|
||||
result[y1:y2, x1:x2] = crop_mask
|
||||
return result
|
||||
|
||||
@@ -69,9 +69,33 @@ SDXL_ZIMAGE_OPENAI_STRENGTH = 0.15
|
||||
SDXL_ZIMAGE_GEMINI_STRENGTH = 0.25
|
||||
SDXL_ZIMAGE_UNKNOWN_STRENGTH = SDXL_ZIMAGE_GEMINI_STRENGTH
|
||||
|
||||
# qwen-zimage keeps its resolution curve for unknown content, but measured vendor
|
||||
# cohorts bypass it. Google remained detectable through 0.24375; 0.25 cleared all
|
||||
# three valid sources and 0.27 was separately repeated clean across them and three
|
||||
# accounts, so the independently checked 0.27 candidate is the operating floor.
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.27
|
||||
|
||||
# sdxl-zimage picks its strength from the VENDOR (unlike qwen-zimage, which derives it
|
||||
# from image area). An unlisted or unknown vendor falls back to the Gemini value.
|
||||
# The two OpenAI sources first cleared at 0.06225 and 0.0695. Add one full observed
|
||||
# cross-source spread (0.00725) to the worst clean boundary: 0.0695 + 0.00725.
|
||||
QWEN_ZIMAGE_OPENAI_STRENGTH = 0.07675
|
||||
|
||||
# Microsoft's public detector returned Inconclusive rather than an API-level
|
||||
# watermark-negative verdict. Three valid Paint sources first cleared at 0.04125,
|
||||
# 0.055, and 0.095. Add one full observed cross-source spread to the worst clean
|
||||
# boundary: 0.095 + (0.095 - 0.04125) = 0.14875, rounded up to 0.15. This is a
|
||||
# measured corpus margin, not a universal InvisMark threshold.
|
||||
QWEN_ZIMAGE_MICROSOFT_STRENGTH = 0.15
|
||||
|
||||
_QWEN_ZIMAGE_FLAT_STRENGTH_BY_VENDOR: dict[str, float] = {
|
||||
"google": QWEN_ZIMAGE_GOOGLE_STRENGTH,
|
||||
"openai": QWEN_ZIMAGE_OPENAI_STRENGTH,
|
||||
"microsoft": QWEN_ZIMAGE_MICROSOFT_STRENGTH,
|
||||
}
|
||||
|
||||
|
||||
# sdxl-zimage always picks its strength from the vendor. qwen-zimage instead uses
|
||||
# the vendor only for measured cohorts and image area for unknown content. An
|
||||
# unlisted or unknown SDXL vendor falls back to the Gemini value.
|
||||
_SDXL_ZIMAGE_STRENGTH_BY_VENDOR: dict[str, float] = {
|
||||
"openai": SDXL_ZIMAGE_OPENAI_STRENGTH,
|
||||
"google": SDXL_ZIMAGE_GEMINI_STRENGTH,
|
||||
@@ -103,7 +127,9 @@ def resolve_adaptive_polish(adaptive_polish: bool | None, pipeline: str) -> bool
|
||||
def strength_default_help() -> str:
|
||||
"""Describe the live default policy without duplicating its values."""
|
||||
return (
|
||||
"profile-adaptive (qwen-zimage uses resolution-adaptive denoise; sdxl-zimage "
|
||||
"profile-adaptive (qwen-zimage uses resolution-adaptive denoise, with a "
|
||||
f"flat OpenAI {QWEN_ZIMAGE_OPENAI_STRENGTH} / Google {QWEN_ZIMAGE_GOOGLE_STRENGTH} / "
|
||||
f"Microsoft InvisMark {QWEN_ZIMAGE_MICROSOFT_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)"
|
||||
)
|
||||
@@ -118,10 +144,14 @@ def resolve_strength(
|
||||
) -> float:
|
||||
"""Resolve a user override or the calibrated policy for a profile and vendor.
|
||||
|
||||
Total by design. qwen-zimage picks its strength from image area rather than from
|
||||
the vendor, so it needs ``size``; returning ``None`` for it instead would push that
|
||||
Total by design. qwen-zimage picks its strength from image area rather than
|
||||
from the vendor, so it needs ``size``; returning ``None`` for it instead would push that
|
||||
branch onto every caller and move one of the two strength policies outside this
|
||||
module. ``size`` is required for qwen-zimage without an explicit strength.
|
||||
Measured vendor exceptions bypass the area curve: OpenAI and Microsoft use one
|
||||
additional observed cross-source spread over their worst clean boundaries, while
|
||||
Google uses a separately repeated cross-source candidate. See the constants'
|
||||
comments for the exact derivations.
|
||||
"""
|
||||
if strength is not None:
|
||||
return strength
|
||||
@@ -129,21 +159,32 @@ def resolve_strength(
|
||||
return _SDXL_ZIMAGE_STRENGTH_BY_VENDOR.get((vendor or "").casefold(), SDXL_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
|
||||
|
||||
return resolution_adaptive_denoise(*size)
|
||||
|
||||
|
||||
def vendor_for_strength(image_path: Path) -> Literal["openai", "google"] | None:
|
||||
"""Select the strength cohort using the input's SynthID provenance evidence."""
|
||||
def vendor_for_strength(image_path: Path) -> Literal["openai", "google", "microsoft"] | None:
|
||||
"""Select the strength cohort from non-invalid pixel-watermark provenance."""
|
||||
try:
|
||||
from remove_ai_watermarks._internal.c2pa import (
|
||||
c2pa_info_has_invalid_credential,
|
||||
c2pa_info_has_invismark,
|
||||
extract_c2pa_info,
|
||||
)
|
||||
from remove_ai_watermarks.metadata import synthid_source
|
||||
|
||||
evidence = (synthid_source(image_path) or "").casefold()
|
||||
info = extract_c2pa_info(image_path)
|
||||
evidence = (synthid_source(image_path, c2pa_info=info) or "").casefold()
|
||||
except Exception:
|
||||
return None
|
||||
if "google" in evidence:
|
||||
return "google"
|
||||
if "openai" in evidence:
|
||||
return "openai"
|
||||
if not c2pa_info_has_invalid_credential(info) and c2pa_info_has_invismark(info):
|
||||
return "microsoft"
|
||||
return None
|
||||
@@ -190,6 +190,7 @@ class WatermarkRemover:
|
||||
tile_size: int = 1024,
|
||||
tile_overlap: int = 128,
|
||||
text_manifest: VerifiedTextManifest | None = None,
|
||||
fidelity_anchor: bool = False,
|
||||
) -> Path:
|
||||
"""Regenerate image pixels and write the result without AI metadata.
|
||||
|
||||
@@ -219,6 +220,7 @@ class WatermarkRemover:
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
text_manifest=text_manifest,
|
||||
fidelity_anchor=fidelity_anchor,
|
||||
)
|
||||
self._write_output(result, destination)
|
||||
return destination
|
||||
@@ -244,6 +244,7 @@ class InvisibleOptions:
|
||||
tile_size: int = 1024
|
||||
tile_overlap: int = 128
|
||||
text_manifest: Path | None = None
|
||||
fidelity_anchor: bool = False
|
||||
|
||||
|
||||
# What the invisible stage did. "unavailable" is the one outcome the caller must
|
||||
@@ -525,6 +526,7 @@ def _run_invisible(
|
||||
tile_size=opts.tile_size,
|
||||
tile_overlap=opts.tile_overlap,
|
||||
text_manifest=opts.text_manifest,
|
||||
fidelity_anchor=opts.fidelity_anchor,
|
||||
)
|
||||
say("invisible", "removed")
|
||||
return "removed"
|
||||
|
||||
@@ -321,6 +321,16 @@ _text_manifest_option = click.option(
|
||||
),
|
||||
)
|
||||
|
||||
_fidelity_anchor_option = click.option(
|
||||
"--fidelity-anchor/--no-fidelity-anchor",
|
||||
default=False,
|
||||
help=(
|
||||
"With --text-manifest: blend 15% of the Qwen-VAE donor across the whole "
|
||||
"frame. Off by default - the global blend was measured to return "
|
||||
"detector-visible OpenAI SynthID on poster-scale manifests."
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
_visible_backend_option = click.option(
|
||||
"--backend",
|
||||
@@ -798,6 +808,7 @@ def cmd_erase(
|
||||
@_force_option
|
||||
@_cpu_offload_option
|
||||
@_text_manifest_option
|
||||
@_fidelity_anchor_option
|
||||
@click.pass_context
|
||||
def cmd_invisible(
|
||||
ctx: click.Context,
|
||||
@@ -818,6 +829,7 @@ def cmd_invisible(
|
||||
force: bool,
|
||||
cpu_offload: bool,
|
||||
text_manifest: Path | None,
|
||||
fidelity_anchor: bool,
|
||||
) -> None:
|
||||
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
|
||||
|
||||
@@ -880,6 +892,7 @@ def cmd_invisible(
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
text_manifest=text_manifest,
|
||||
fidelity_anchor=fidelity_anchor,
|
||||
)
|
||||
except (OSError, RuntimeError, ValueError) as exc:
|
||||
console.print(f" Error: {exc}")
|
||||
@@ -891,10 +904,27 @@ def cmd_invisible(
|
||||
|
||||
|
||||
# ── Metadata operations ──
|
||||
def _print_metadata_not_a_clean_verdict() -> None:
|
||||
"""Repeat the identify empty-scan limit on metadata check and strip success.
|
||||
|
||||
``metadata --check`` and ``metadata --remove`` answer a narrower question than
|
||||
``identify``: they report embedded AI metadata only. A quiet result used to
|
||||
stop at "No AI metadata found" / "AI metadata stripped", which readers treat
|
||||
as a clean-image verdict. The pixel channel is unchanged, and this project has
|
||||
no local SynthID decoder, so the command must say so in the same words
|
||||
``identify`` already uses.
|
||||
"""
|
||||
console.print(
|
||||
" This is not the same as 'clean': a pixel watermark such as SynthID cannot be\n"
|
||||
" detected here once its metadata proxy is absent."
|
||||
)
|
||||
|
||||
|
||||
def _print_metadata_report(source: Path, has_ai: bool, metadata: dict[str, str]) -> None:
|
||||
"""Render one metadata inspection result for the generic and video commands."""
|
||||
if not has_ai:
|
||||
console.print(f" No AI metadata found in {source.name}")
|
||||
_print_metadata_not_a_clean_verdict()
|
||||
return
|
||||
|
||||
console.print(f" Warning: AI metadata detected in {source.name}:")
|
||||
@@ -961,6 +991,7 @@ def cmd_metadata(
|
||||
console.print(" the file could not be decoded, so it was copied through unchanged")
|
||||
raise SystemExit(1)
|
||||
console.print(f" AI metadata stripped -> {out}")
|
||||
_print_metadata_not_a_clean_verdict()
|
||||
|
||||
|
||||
# ── Video pipeline ──
|
||||
@@ -1102,6 +1133,7 @@ def cmd_video_metadata(
|
||||
console.print(f" still present: {', '.join(sorted(result.remaining))}")
|
||||
raise SystemExit(1)
|
||||
console.print(f" AI metadata stripped -> {result.output}")
|
||||
_print_metadata_not_a_clean_verdict()
|
||||
|
||||
|
||||
@cmd_video.command("invisible")
|
||||
@@ -1428,6 +1460,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
@_force_option
|
||||
@_cpu_offload_option
|
||||
@_text_manifest_option
|
||||
@_fidelity_anchor_option
|
||||
@click.pass_context
|
||||
def cmd_all(
|
||||
ctx: click.Context,
|
||||
@@ -1450,6 +1483,7 @@ def cmd_all(
|
||||
force: bool,
|
||||
cpu_offload: bool,
|
||||
text_manifest: Path | None,
|
||||
fidelity_anchor: bool,
|
||||
) -> None:
|
||||
"""Remove ALL watermarks: visible + invisible + metadata.
|
||||
|
||||
@@ -1528,6 +1562,7 @@ def cmd_all(
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
text_manifest=text_manifest,
|
||||
fidelity_anchor=fidelity_anchor,
|
||||
),
|
||||
force=force,
|
||||
progress=progress,
|
||||
|
||||
@@ -29,6 +29,7 @@ from typing import TYPE_CHECKING, Any, cast
|
||||
from remove_ai_watermarks._internal.c2pa import (
|
||||
c2pa_info_from_manifest_store,
|
||||
c2pa_info_has_invalid_credential,
|
||||
c2pa_info_has_invismark,
|
||||
c2pa_info_has_removal_hint,
|
||||
cbor_text_after,
|
||||
extract_c2pa_info,
|
||||
@@ -117,8 +118,13 @@ _SYNTHID_CAVEAT = (
|
||||
"decoded (proprietary decoder). Confirm via the Gemini app or openai.com/verify."
|
||||
)
|
||||
_C2PA_UNTRUSTED_CAVEAT = (
|
||||
"The C2PA claim signature and asset binding validate, but the signer identity is not anchored "
|
||||
"to a trusted credential here; treat the named platform as a signed claim, not a verified identity."
|
||||
"The C2PA claim signature and asset binding validate, but no trust anchor list is configured here, "
|
||||
"so the signer identity was never checked against one; treat the named platform as a signed claim, "
|
||||
"not a verified identity."
|
||||
)
|
||||
_C2PA_EXPIRED_CAVEAT = (
|
||||
"The C2PA signing certificate has expired and no trusted timestamp establishes when the claim was "
|
||||
"signed. The binding to these bytes still holds; only the signing time is unproven."
|
||||
)
|
||||
_C2PA_UNVALIDATED_CAVEAT = (
|
||||
"The C2PA marker was parsed without cryptographic validation; treat its origin and watermark "
|
||||
@@ -145,6 +151,10 @@ _C2PA_CLOUD_CAVEAT = (
|
||||
"It marks Content Credentials, not AI origin: the cloud manifest may describe a "
|
||||
"human edit, and reading it needs a network fetch this tool does not make."
|
||||
)
|
||||
_SOFT_BINDING_CAVEAT = (
|
||||
"Removing the embedded C2PA manifest does not remove its soft binding: the named "
|
||||
"watermark or fingerprint may remain in the pixels and re-link the asset to provenance."
|
||||
)
|
||||
_SAMSUNG_GENAI_CAVEAT = (
|
||||
"Samsung's genAIType marker shows a Galaxy AI editing tool (Generative Edit, "
|
||||
"Sketch to Image, ...) touched the image; it is an undocumented proprietary "
|
||||
@@ -388,6 +398,8 @@ def evidence_from_metadata_record(
|
||||
"actions",
|
||||
"synthid_watermark",
|
||||
"soft_binding",
|
||||
"soft_binding_algorithm",
|
||||
"soft_binding_value",
|
||||
):
|
||||
if key in c2pa_info:
|
||||
ai_metadata.setdefault(key, str(c2pa_info[key]))
|
||||
@@ -467,17 +479,20 @@ class ProvenanceReport:
|
||||
# an open DWT-DCT decode) -- as opposed to a visible mark, provenance-only
|
||||
# TrustMark, or a weak medium-confidence hint (hf-job, Samsung genAIType). This
|
||||
# is the set of signals an invisible/diffusion scrub targets: a visible-only
|
||||
# or no-signal image has it False. An intact C2PA AI claim from an untrusted
|
||||
# signer is deliberately medium-confidence while this remains True; callers
|
||||
# should gate on intent, not on the confidence string.
|
||||
# or no-signal image has it False. Callers should gate on intent, not on the
|
||||
# confidence string.
|
||||
ai_from_metadata: bool = False
|
||||
watermarks: list[str] = field(default_factory=list[str])
|
||||
signals: list[Signal] = field(default_factory=list["Signal"])
|
||||
caveats: list[str] = field(default_factory=list[str])
|
||||
# Contradictions between independent provenance signals (e.g. two different
|
||||
# AI vendors both claiming the image, or camera-capture credentials next to
|
||||
# AI-generation markers). Non-empty means the provenance is internally
|
||||
# inconsistent -- a strong tell of spoofed, transplanted, or laundered metadata.
|
||||
# AI-generation markers), and credentials that failed validation. Non-empty means
|
||||
# the provenance is internally inconsistent -- a strong tell of spoofed,
|
||||
# transplanted, or laundered metadata. A failed credential sends
|
||||
# ``is_ai_generated`` to None and lands here instead, so a consumer that reads
|
||||
# only the verdict field turns a broken AI manifest into silence; see the
|
||||
# ``integrity_clashes`` note in docs/python-api.md.
|
||||
integrity_clashes: list[str] = field(default_factory=list[str])
|
||||
# Orthogonal C2PA checks. A valid content binding does not make an untrusted
|
||||
# signer identity trusted, and an expired credential does not by itself mean the
|
||||
@@ -540,15 +555,16 @@ def _c2pa_validation(info: dict[str, Any]) -> dict[str, Any] | None:
|
||||
|
||||
|
||||
def _c2pa_credential_level(info: dict[str, Any]) -> str:
|
||||
"""Return invalid, verified, or unverified for provenance attribution."""
|
||||
"""Return invalid, verified, or unverified for provenance attribution.
|
||||
|
||||
``verified`` means the reader tied this manifest to these bytes: the hard binding
|
||||
matched and the claim signature validated. Signer trust is deliberately NOT a
|
||||
condition -- no trust anchors ship, so gating on it made this branch unreachable.
|
||||
Read the trust-anchor paragraph in docs/module-internals.md before changing this.
|
||||
"""
|
||||
if c2pa_info_has_invalid_credential(info):
|
||||
return "invalid"
|
||||
if (
|
||||
info.get("c2pa_integrity") == "valid"
|
||||
and info.get("c2pa_signature") == "valid"
|
||||
and info.get("c2pa_signer_trust") == "trusted"
|
||||
and info.get("c2pa_signer_validity") == "valid"
|
||||
):
|
||||
if info.get("c2pa_integrity") == "valid" and info.get("c2pa_signature") == "valid":
|
||||
return "verified"
|
||||
return "unverified"
|
||||
|
||||
@@ -770,6 +786,7 @@ _AI_VENDOR_TOKENS: tuple[tuple[str, str], ...] = (
|
||||
("google", "Google"),
|
||||
("firefly", "Adobe"),
|
||||
("adobe", "Adobe"),
|
||||
("copilot", "Microsoft"),
|
||||
("bing", "Microsoft"),
|
||||
("designer", "Microsoft"),
|
||||
("microsoft", "Microsoft"),
|
||||
@@ -1140,11 +1157,9 @@ def _identify_from_evidence(
|
||||
c2pa_validation = _c2pa_validation(info)
|
||||
c2pa_level = _c2pa_credential_level(info)
|
||||
c2pa_usable = c2pa_level != "invalid"
|
||||
failed_c2pa_codes = [
|
||||
str(code)
|
||||
for code in cast("list[object]", info.get("c2pa_validation_codes", []))
|
||||
if "mismatch" in str(code) or "invalid" in str(code)
|
||||
]
|
||||
# The reader already named which failures moved a dimension; re-deriving that here
|
||||
# by substring made the displayed reason a second, looser rule than the verdict.
|
||||
failed_c2pa_codes = [str(code) for code in cast("list[object]", info.get("c2pa_failed_codes", []))]
|
||||
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(region)
|
||||
# Full AI generation (trainedAlgorithmicMedia) vs an AI-enhanced real photo
|
||||
# (compositeWithTrainedAlgorithmicMedia). The structured kind is parsed once in
|
||||
@@ -1200,12 +1215,14 @@ def _identify_from_evidence(
|
||||
Signal("c2pa", detail or "C2PA manifest present", "high" if c2pa_level == "verified" else "medium")
|
||||
)
|
||||
watermarks.append(f"C2PA Content Credentials ({', '.join(issuers) or 'unknown signer'})")
|
||||
if c2pa_level == "unverified":
|
||||
caveats.append(
|
||||
_C2PA_UNTRUSTED_CAVEAT
|
||||
if info.get("c2pa_integrity") == "valid" and info.get("c2pa_signature") == "valid"
|
||||
else _C2PA_UNVALIDATED_CAVEAT
|
||||
)
|
||||
if c2pa_level == "verified":
|
||||
# A missing trust bundle is not a signer that failed against one.
|
||||
if info.get("c2pa_signer_trust") != "trusted":
|
||||
caveats.append(_C2PA_UNTRUSTED_CAVEAT)
|
||||
if info.get("c2pa_signer_validity") == "expired":
|
||||
caveats.append(_C2PA_EXPIRED_CAVEAT)
|
||||
else:
|
||||
caveats.append(_C2PA_UNVALIDATED_CAVEAT)
|
||||
# Record the AI-origin vendor for clash detection only when the source is
|
||||
# actually AI -- classify the issuer attribution / generator, NOT the
|
||||
# resolved `platform` (which may be a camera device token whose label,
|
||||
@@ -1268,12 +1285,31 @@ def _identify_from_evidence(
|
||||
# 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)
|
||||
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")
|
||||
soft_binding_details = "; ".join(
|
||||
str(value) for value in (soft_binding, soft_binding_algorithm, soft_binding_value) if value
|
||||
)
|
||||
signals.append(
|
||||
Signal(
|
||||
"soft_binding", f"C2PA soft binding: {soft_binding}", "high" if c2pa_level == "verified" else "medium"
|
||||
"soft_binding",
|
||||
f"C2PA soft binding: {soft_binding_details}",
|
||||
"high" if c2pa_level == "verified" else "medium",
|
||||
)
|
||||
)
|
||||
watermarks.append(f"Forensic watermark soft binding ({soft_binding})")
|
||||
if c2pa_info_has_invismark(info):
|
||||
# Keep the generic soft-binding row for schema-1 compatibility, and add
|
||||
# the pixel-removal target as its own stable signal for clients that need
|
||||
# to select diffusion without parsing human-readable detail.
|
||||
signals.append(
|
||||
Signal(
|
||||
"invismark",
|
||||
f"Microsoft InvisMark pixel watermark: {soft_binding_details}",
|
||||
"high" if c2pa_level == "verified" else "medium",
|
||||
)
|
||||
)
|
||||
watermarks.append(f"Forensic watermark soft binding ({soft_binding_details})")
|
||||
caveats.append(_SOFT_BINDING_CAVEAT)
|
||||
|
||||
# ── IPTC "Made with AI" (Meta etc.), only meaningful without C2PA ─
|
||||
iptc = any(m in head for m in IPTC_AI_MARKERS)
|
||||
|
||||
@@ -150,6 +150,7 @@ class InvisibleEngine:
|
||||
tile_size: int = 1024,
|
||||
tile_overlap: int = 128,
|
||||
text_manifest: Path | None = None,
|
||||
fidelity_anchor: bool = False,
|
||||
) -> Path:
|
||||
"""Remove invisible watermark from an image.
|
||||
|
||||
@@ -185,8 +186,17 @@ class InvisibleEngine:
|
||||
text_manifest: Operator-verified text lines bound to the decoded source
|
||||
pixels. Enables the experimental Qwen-VAE ``vae-glyphs`` post-pass.
|
||||
Requires the ``text-restoration`` extra and the ``qwen-zimage``
|
||||
profile. Incompatible with tiling, downscaling, humanize, unsharp,
|
||||
and adaptive polish because those combinations are not calibrated.
|
||||
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.
|
||||
fidelity_anchor: Blend 15% of the Qwen-VAE donor across the whole frame
|
||||
before glyph restoration. OFF by default since 0.27.1: that global
|
||||
blend was measured to return detector-visible OpenAI SynthID on
|
||||
poster-scale manifests (detected x6 with the anchor vs clean x6
|
||||
without it, base clean; official Content Provenance API,
|
||||
2026-08-19 - docs/text-protection-research.md). ``True`` reproduces
|
||||
the 0.27.0 research behavior. Requires ``text_manifest``.
|
||||
|
||||
Returns:
|
||||
Path to the cleaned image.
|
||||
@@ -196,13 +206,13 @@ class InvisibleEngine:
|
||||
seed = resolve_seed(seed)
|
||||
adaptive_polish = resolve_adaptive_polish(adaptive_polish, self._remover.model_profile)
|
||||
|
||||
if fidelity_anchor and text_manifest is None:
|
||||
raise ValueError("fidelity_anchor requires a text manifest")
|
||||
if text_manifest is not None:
|
||||
if self._remover.model_profile != QWEN_ZIMAGE_PROFILE:
|
||||
raise ValueError("--text-manifest is supported only by the qwen-zimage profile")
|
||||
if max_resolution != 0:
|
||||
raise ValueError("--text-manifest requires --max-resolution 0")
|
||||
if tile:
|
||||
raise ValueError("--text-manifest is not calibrated with --tile")
|
||||
if humanize > 0.0 or unsharp > 0.0 or adaptive_polish:
|
||||
raise ValueError("--text-manifest requires humanize=0, unsharp=0, and adaptive polish disabled")
|
||||
from remove_ai_watermarks import region_eraser
|
||||
@@ -270,6 +280,7 @@ class InvisibleEngine:
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
text_manifest=verified_text,
|
||||
fidelity_anchor=fidelity_anchor,
|
||||
)
|
||||
|
||||
# Post-processing chain: decode the diffusion output ONCE, apply the
|
||||
|
||||
@@ -792,7 +792,7 @@ def _iptc_ai_system_impl(image_path: Path) -> str | None:
|
||||
return iptc_ai_system_in(scan_head(image_path))
|
||||
|
||||
|
||||
def synthid_source(image_path: Path) -> str | None:
|
||||
def synthid_source(image_path: Path, *, c2pa_info: dict[str, Any] | None = None) -> str | None:
|
||||
"""Return the vendor name(s) when provenance establishes SynthID.
|
||||
|
||||
This is provenance-based, not a local pixel decode. Google states that all
|
||||
@@ -808,6 +808,8 @@ def synthid_source(image_path: Path) -> str | None:
|
||||
|
||||
Args:
|
||||
image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container).
|
||||
c2pa_info: Already extracted normalized C2PA info, for callers that also need
|
||||
another claim from the same manifest.
|
||||
|
||||
Returns:
|
||||
Comma-joined vendor name(s) (e.g. ``"OpenAI"``) or None.
|
||||
@@ -821,7 +823,7 @@ def synthid_source(image_path: Path) -> str | None:
|
||||
# Prefer the official reader's structured result. A failed asset binding or
|
||||
# signature cannot establish the claim, and the raw fallback below must not
|
||||
# resurrect it from the same damaged manifest bytes.
|
||||
c2pa = extract_c2pa_info(image_path)
|
||||
c2pa = extract_c2pa_info(image_path) if c2pa_info is None else c2pa_info
|
||||
if c2pa_info_has_invalid_credential(c2pa):
|
||||
return None
|
||||
vendors = c2pa.get("synthid_vendors")
|
||||
@@ -1160,6 +1162,8 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
"actions",
|
||||
"synthid_watermark",
|
||||
"soft_binding",
|
||||
"soft_binding_algorithm",
|
||||
"soft_binding_value",
|
||||
"c2pa_validation_source",
|
||||
"c2pa_validation_state",
|
||||
"c2pa_integrity",
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Qwen (Tongyi Qianwen, Alibaba) visible watermark detector/localizer.
|
||||
"""Qwen (Alibaba) visible watermark detector/localizer.
|
||||
|
||||
Qwen stamps its generations with a visible "千问AI生成" text strip in the
|
||||
bottom-right corner -- the explicit AIGC label mandated by China's GB 45438-2025
|
||||
|
||||
@@ -0,0 +1,299 @@
|
||||
"""Draft operator-verifiable text lines for verified-text manifests.
|
||||
|
||||
Proposal-only OCR: PaddleOCR detection plus three script-chosen recognition
|
||||
engines (Latin / Cyrillic / CJK), accepted only when three crop paddings
|
||||
normalize identically and every confidence clears the floor. ``accepted``
|
||||
means crop-stable, NEVER ground-truth-correct: on the reference posters the
|
||||
draft's exact-text precision was 90.0% and 94.4% because high-confidence OCR
|
||||
still lost punctuation (one English comma dropped, one ideographic comma
|
||||
replaced with ASCII). Every accepted line needs a human yes/no before it may
|
||||
enter a manifest with ``verified: true``.
|
||||
|
||||
Heavy imports (paddle, and the numpy/cv2/PIL pixel stack) stay inside the
|
||||
call so importing this module costs nothing without the ``text-draft`` extra;
|
||||
``draft_available()`` reports whether the extra is installed.
|
||||
"""
|
||||
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import unicodedata
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from remove_ai_watermarks._internal.text_restoration import source_pixel_sha256
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
__all__ = [
|
||||
"DraftLine",
|
||||
"RejectedLine",
|
||||
"TextDraft",
|
||||
"choose_language",
|
||||
"draft_available",
|
||||
"draft_text_lines",
|
||||
"group_word_boxes",
|
||||
"source_pixel_sha256",
|
||||
"stable_recognition",
|
||||
]
|
||||
|
||||
# Crop paddings probed per line: recognition must be invariant across them.
|
||||
JITTER_RATIOS: tuple[float, ...] = (0.08, 0.12, 0.2)
|
||||
DETECT_SCORE_FLOOR = 0.5
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DraftLine:
|
||||
"""One crop-stable proposal. ``accepted`` means stable, not correct."""
|
||||
|
||||
box: tuple[int, int, int, int]
|
||||
text: str
|
||||
script: str
|
||||
language: str
|
||||
min_score: float
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RejectedLine:
|
||||
"""A detected line whose recognition was unstable or low-confidence."""
|
||||
|
||||
box: tuple[int, int, int, int]
|
||||
script: str
|
||||
language: str
|
||||
reads: tuple[tuple[str, float], ...]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TextDraft:
|
||||
"""The full proposal set for one image."""
|
||||
|
||||
accepted: tuple[DraftLine, ...] = ()
|
||||
rejected: tuple[RejectedLine, ...] = ()
|
||||
|
||||
|
||||
def normalize_text(text: str) -> str:
|
||||
"""Normalize text for layout-independent comparison (casefold + no spaces)."""
|
||||
return "".join(unicodedata.normalize("NFC", text).casefold().split())
|
||||
|
||||
|
||||
def _has_script(text: str, script: str) -> bool:
|
||||
return any(script in unicodedata.name(character, "") for character in text)
|
||||
|
||||
|
||||
def choose_language(probes: dict[str, tuple[str, float]]) -> str:
|
||||
"""Pick ``ch``/``ru``/``en`` from what each probe engine actually read."""
|
||||
if _has_script(probes["ch"][0], "CJK"):
|
||||
return "ch"
|
||||
if _has_script(probes["ru"][0], "CYRILLIC"):
|
||||
return "ru"
|
||||
return "en"
|
||||
|
||||
|
||||
def stable_recognition(reads: list[tuple[str, float]], min_score: float = 0.85) -> str | None:
|
||||
"""The proposal text when every read normalizes identically and clears the floor."""
|
||||
normalized = {normalize_text(text) for text, _score in reads}
|
||||
if len(normalized) != 1 or min(score for _text, score in reads) < min_score:
|
||||
return None
|
||||
return reads[0][0]
|
||||
|
||||
|
||||
def _vertical_overlap_ratio(left: tuple[int, int, int, int], right: tuple[int, int, int, int]) -> float:
|
||||
overlap = max(0, min(left[3], right[3]) - max(left[1], right[1]))
|
||||
return overlap / max(1, min(left[3] - left[1], right[3] - right[1]))
|
||||
|
||||
|
||||
def _row_center(box: tuple[int, int, int, int]) -> float:
|
||||
return (box[1] + box[3]) / 2
|
||||
|
||||
|
||||
def group_word_boxes(boxes: list[tuple[int, int, int, int]]) -> list[tuple[int, int, int, int]]:
|
||||
"""Merge word detections into line boxes by vertical overlap and gap."""
|
||||
groups: list[tuple[int, int, int, int]] = []
|
||||
for box in sorted(boxes, key=lambda item: (_row_center(item), item[0])):
|
||||
matches: list[int] = []
|
||||
for index, group in enumerate(groups):
|
||||
if _vertical_overlap_ratio(box, group) < 0.45:
|
||||
continue
|
||||
horizontal_gap = max(0, max(box[0], group[0]) - min(box[2], group[2]))
|
||||
line_height = min(box[3] - box[1], group[3] - group[1])
|
||||
if horizontal_gap <= max(24, line_height * 3):
|
||||
matches.append(index)
|
||||
if not matches:
|
||||
groups.append(box)
|
||||
continue
|
||||
index = max(matches, key=lambda item: _vertical_overlap_ratio(box, groups[item]))
|
||||
x1, y1, x2, y2 = groups[index]
|
||||
groups[index] = min(x1, box[0]), min(y1, box[1]), max(x2, box[2]), max(y2, box[3])
|
||||
return sorted(groups, key=_row_center)
|
||||
|
||||
|
||||
def _recognition_box(
|
||||
box: tuple[int, int, int, int],
|
||||
script: str,
|
||||
width: int,
|
||||
height: int,
|
||||
vertical_pad_ratio: float | None = None,
|
||||
) -> tuple[int, int, int, int]:
|
||||
x1, y1, x2, y2 = box
|
||||
line_height = y2 - y1
|
||||
if script == "cjk":
|
||||
left_pad = max(16, round(line_height * 0.2))
|
||||
right_pad = max(16, round(line_height * 0.6))
|
||||
return max(0, x1 - left_pad), y1, min(width, x2 + right_pad), y2
|
||||
pad_x = max(16, line_height)
|
||||
pad_y = max(8, line_height // 3) if vertical_pad_ratio is None else max(8, round(line_height * vertical_pad_ratio))
|
||||
return max(0, x1 - pad_x), max(0, y1 - pad_y), min(width, x2 + pad_x), min(height, y2 + pad_y)
|
||||
|
||||
|
||||
def _recognize(
|
||||
engine: Any,
|
||||
image: Any,
|
||||
box: tuple[int, int, int, int],
|
||||
script: str,
|
||||
vertical_pad_ratio: float | None = None,
|
||||
) -> tuple[str, float]:
|
||||
import cv2
|
||||
|
||||
height, width = image.shape[:2]
|
||||
x1, y1, x2, y2 = _recognition_box(box, script, width, height, vertical_pad_ratio)
|
||||
crop = image[y1:y2, x1:x2]
|
||||
if crop.shape[0] < 64:
|
||||
scale = 64 / crop.shape[0]
|
||||
crop = cv2.resize(crop, None, fx=scale, fy=scale, interpolation=cv2.INTER_CUBIC)
|
||||
result = next(iter(engine.predict(crop)))
|
||||
return str(result.get("rec_text", "")), float(result.get("rec_score", 0.0))
|
||||
|
||||
|
||||
def _detect_line_boxes(engine: Any, source_rgb: Any) -> list[tuple[int, int, int, int]]:
|
||||
import numpy as np
|
||||
|
||||
boxes: list[tuple[int, int, int, int]] = []
|
||||
for page in engine.predict(source_rgb):
|
||||
detected = page.get("rec_boxes", None)
|
||||
if detected is None or len(detected) == 0:
|
||||
detected = page.get("rec_polys", [])
|
||||
for score, raw_box in zip(page.get("rec_scores", []), detected, strict=False):
|
||||
if float(score) < DETECT_SCORE_FLOOR:
|
||||
continue
|
||||
points = np.asarray(raw_box, dtype=np.float32).reshape(-1)
|
||||
if points.size == 4:
|
||||
x1, y1, x2, y2 = points
|
||||
else:
|
||||
points = points.reshape(-1, 2)
|
||||
x1, y1 = points.min(axis=0)
|
||||
x2, y2 = points.max(axis=0)
|
||||
boxes.append((round(float(x1)), round(float(y1)), round(float(x2)), round(float(y2))))
|
||||
return group_word_boxes(boxes)
|
||||
|
||||
|
||||
def draft_available() -> bool:
|
||||
"""True when the ``text-draft`` extra (paddleocr + paddle) can run."""
|
||||
from remove_ai_watermarks.optional_deps import module_available
|
||||
|
||||
return module_available("paddleocr") and module_available("paddle")
|
||||
|
||||
|
||||
def _build_engines() -> tuple[Any, dict[str, Any]]:
|
||||
from paddleocr import PaddleOCR, TextRecognition
|
||||
|
||||
# enable_mkldnn=False is load-bearing on linux: paddle's oneDNN PIR executor
|
||||
# raises NotImplementedError on the text-detection graph there (seen on the
|
||||
# Modal draft container, 2026-08-19), while the reference CPU path runs. The
|
||||
# eval scripts never hit it because macOS pads take a different path.
|
||||
detector = PaddleOCR(
|
||||
lang="ch",
|
||||
use_doc_orientation_classify=False,
|
||||
use_doc_unwarping=False,
|
||||
use_textline_orientation=False,
|
||||
enable_mkldnn=False,
|
||||
)
|
||||
engines = {
|
||||
"en": TextRecognition(model_name="en_PP-OCRv5_mobile_rec", enable_mkldnn=False),
|
||||
"ru": TextRecognition(model_name="eslav_PP-OCRv5_mobile_rec", enable_mkldnn=False),
|
||||
"ch": TextRecognition(model_name="PP-OCRv5_server_rec", enable_mkldnn=False),
|
||||
}
|
||||
return detector, engines
|
||||
|
||||
|
||||
def draft_text_lines(
|
||||
image: Path,
|
||||
*,
|
||||
min_score: float = 0.85,
|
||||
detector: Any | None = None,
|
||||
engines: dict[str, Any] | None = None,
|
||||
stable: bool = True,
|
||||
) -> TextDraft:
|
||||
"""Propose verified-text manifest lines for ``image``; never verified ones.
|
||||
|
||||
Args:
|
||||
image: path of the source image (draft boxes are in ITS pixel space).
|
||||
min_score: recognition confidence floor. With ``stable=True`` every
|
||||
jittered read must clear it; with ``stable=False`` the single
|
||||
probe for the chosen language must.
|
||||
detector/engines: injectable Paddle objects (tests use fakes); when
|
||||
None they are built from the ``text-draft`` extra, which must be
|
||||
installed (``draft_available()`` reports it).
|
||||
stable: when True (default), accept a line only if three crop paddings
|
||||
normalize identically. When False, take one recognition trio and
|
||||
accept on score alone. Automatic restoration uses box/script only,
|
||||
so the jitter gate is optional there.
|
||||
|
||||
Returns:
|
||||
``TextDraft`` with crop-stable ``accepted`` proposals and unstable
|
||||
``rejected`` lines. Both lists need human review before any manifest
|
||||
may claim ``verified: true``; accepted means crop-stable, NOT
|
||||
ground-truth-correct.
|
||||
"""
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
if detector is None or engines is None:
|
||||
if not draft_available():
|
||||
raise RuntimeError(
|
||||
"Text drafting requires PaddleOCR. Install: pip install 'remove-ai-watermarks[text-draft]'"
|
||||
)
|
||||
os.environ.setdefault("PADDLE_PDX_DISABLE_MODEL_SOURCE_CHECK", "True")
|
||||
detector, engines = _build_engines()
|
||||
|
||||
source_rgb = np.asarray(Image.open(image).convert("RGB"))
|
||||
accepted: list[DraftLine] = []
|
||||
rejected: list[RejectedLine] = []
|
||||
for box in _detect_line_boxes(detector, source_rgb):
|
||||
probes: dict[str, tuple[str, float]] = {}
|
||||
for language, engine in engines.items():
|
||||
script = "cjk" if language == "ch" else "alphabetic"
|
||||
probes[language] = _recognize(engine, source_rgb, box, script, 0.1)
|
||||
language = choose_language(probes)
|
||||
script = "cjk" if language == "ch" else "alphabetic"
|
||||
if stable:
|
||||
reads = [_recognize(engines[language], source_rgb, box, script, ratio) for ratio in JITTER_RATIOS]
|
||||
text = stable_recognition(reads, min_score)
|
||||
else:
|
||||
text, score = probes[language]
|
||||
reads = [(text, score)]
|
||||
if not text.strip() or score < min_score:
|
||||
text = None
|
||||
if text is None:
|
||||
rejected.append(
|
||||
RejectedLine(
|
||||
box=box,
|
||||
script=script,
|
||||
language=language,
|
||||
reads=tuple((value, score) for value, score in reads),
|
||||
)
|
||||
)
|
||||
else:
|
||||
accepted.append(
|
||||
DraftLine(
|
||||
box=box,
|
||||
text=text,
|
||||
script=script,
|
||||
language=language,
|
||||
min_score=min(score for _value, score in reads),
|
||||
)
|
||||
)
|
||||
return TextDraft(accepted=tuple(accepted), rejected=tuple(rejected))
|
||||
@@ -20,7 +20,7 @@ Entries:
|
||||
- ``gemini`` -- Google Gemini / Nano Banana sparkle, bottom-right.
|
||||
- ``doubao`` -- ByteDance Doubao "豆包AI生成" text strip, bottom-right.
|
||||
- ``jimeng`` -- ByteDance Jimeng / Dreamina "★ 即梦AI" wordmark, bottom-right.
|
||||
- ``qwen`` -- Alibaba Tongyi Qianwen "千问AI生成" text strip, bottom-right.
|
||||
- ``qwen`` -- Alibaba Qwen "千问AI生成" text strip, bottom-right.
|
||||
- ``kling`` -- Kuaishou Kling "可灵AI 3.0" text strip, bottom-right.
|
||||
- ``yuanbao`` -- Tencent Yuanbao "元宝 / AI生成" two-line mark, bottom-right.
|
||||
- ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left.
|
||||
@@ -651,7 +651,7 @@ _REGISTRY: tuple[KnownMark, ...] = (
|
||||
"qwen",
|
||||
"Qwen 千问AI生成 text",
|
||||
"bottom-right",
|
||||
platform="Alibaba Tongyi Qianwen (visible 千问AI生成 mark detected)",
|
||||
platform="Alibaba Qwen (visible 千问AI生成 mark detected)",
|
||||
tc260_producer_codes=("91440101MA9Y9T4H7A",),
|
||||
),
|
||||
_text_mark(
|
||||
|
||||
@@ -629,11 +629,13 @@ class TestMetadataCommand:
|
||||
result = runner.invoke(main, ["metadata", str(tmp_clean_png), "--check"])
|
||||
assert result.exit_code == 0
|
||||
assert "No AI metadata" in result.output
|
||||
assert "not the same as 'clean'" in result.output
|
||||
|
||||
def test_metadata_check_ai(self, runner, tmp_png_with_ai_metadata):
|
||||
result = runner.invoke(main, ["metadata", str(tmp_png_with_ai_metadata), "--check"])
|
||||
assert result.exit_code == 0
|
||||
assert "AI metadata detected" in result.output
|
||||
assert "not the same as 'clean'" not in result.output
|
||||
|
||||
def test_metadata_remove(self, runner, tmp_png_with_ai_metadata, tmp_path):
|
||||
output = tmp_path / "stripped.png"
|
||||
@@ -649,6 +651,7 @@ class TestMetadataCommand:
|
||||
)
|
||||
assert result.exit_code == 0
|
||||
assert "stripped" in result.output
|
||||
assert "not the same as 'clean'" in result.output
|
||||
|
||||
def test_metadata_remove_reports_failure_when_the_strip_was_a_no_op(self, runner, tmp_path):
|
||||
"""A file PIL cannot decode is copied through UNCHANGED by the fail-safe.
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
"""Tests for the Content Seal oracle corpus layout.
|
||||
|
||||
Mirrors the synthid corpus guard: the manifest is the source of truth for
|
||||
which binaries exist, and every recorded hash must match the file it names.
|
||||
Derived rows are recipes, not stored files, so only originals are checked
|
||||
against disk.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
CORPUS_DIR = Path(__file__).resolve().parent.parent / "data" / "contentseal"
|
||||
MANIFEST = CORPUS_DIR / "manifest.csv"
|
||||
ORIGINALS = CORPUS_DIR / "originals"
|
||||
|
||||
_SHA256 = re.compile(r"^[0-9a-f]{64}$")
|
||||
_VALID_VERDICTS = {"detected", "not_detected", ""}
|
||||
_VALID_ORIGINS = {"meta-model-api", "derived", "meta-blog-cdn", "synthetic-local"}
|
||||
|
||||
|
||||
def _manifest_rows() -> list[dict[str, str]]:
|
||||
with open(MANIFEST, newline="") as f:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
|
||||
def test_manifest_original_rows_match_binaries_and_hashes() -> None:
|
||||
rows = [r for r in _manifest_rows() if r["file"]]
|
||||
stored = {path.name for path in ORIGINALS.iterdir() if path.is_file()}
|
||||
assert {r["file"].removeprefix("originals/") for r in rows} == stored
|
||||
|
||||
for row in rows:
|
||||
digest = hashlib.sha256((CORPUS_DIR / row["file"]).read_bytes()).hexdigest()
|
||||
assert digest == row["sha256"], row["file"]
|
||||
|
||||
|
||||
def test_manifest_rows_are_well_formed() -> None:
|
||||
rows = _manifest_rows()
|
||||
assert len({row["sha256"] for row in rows}) == len(rows), "duplicate sha256"
|
||||
|
||||
for row in rows:
|
||||
assert _SHA256.match(row["sha256"]), row["name"]
|
||||
assert row["origin"].split(":")[0] in _VALID_ORIGINS, row["name"]
|
||||
assert row["oracle_verdict"] in _VALID_VERDICTS, row["name"]
|
||||
# Every oracle verdict must carry its check timestamp.
|
||||
if row["oracle_verdict"]:
|
||||
assert row["checked_at_utc"], row["name"]
|
||||
# Detection rows must name the oracle attribution.
|
||||
if row["oracle_verdict"] == "detected":
|
||||
assert "Muse Image 1" in row["oracle_attribution"], row["name"]
|
||||
|
||||
|
||||
def test_default_pipeline_clearance_is_recorded() -> None:
|
||||
"""The verified claim that the default profile clears Content Seal must stay."""
|
||||
rows = {row["name"]: row for row in _manifest_rows()}
|
||||
for name in ("fox_modal_invisible", "text_modal_invisible"):
|
||||
assert rows[name]["oracle_verdict"] == "not_detected", name
|
||||
+143
-10
@@ -16,6 +16,8 @@ from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks._internal.c2pa import c2pa_info_from_manifest_store
|
||||
from remove_ai_watermarks._internal.constants import C2PA_AI_VENDORS, C2PA_CLAIM_GENERATOR_PLATFORMS
|
||||
from remove_ai_watermarks.identify import (
|
||||
ProvenanceEvidence,
|
||||
ProvenanceReport,
|
||||
@@ -71,10 +73,77 @@ class TestProvenanceEvidence:
|
||||
report = identify_from_evidence(evidence)
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert report.confidence == "medium"
|
||||
# Intact binding and signature. The signer is not anchored, which is a missing
|
||||
# input here (no trust bundle ships), not a finding against the credential.
|
||||
assert report.confidence == "high"
|
||||
assert report.platform == "Adobe Firefly"
|
||||
|
||||
def test_fully_validated_c2pa_claim_is_high_confidence(self, tmp_path: Path):
|
||||
def test_revoked_signing_credential_is_disqualifying(self, tmp_path: Path):
|
||||
"""A credential the issuer disowned cannot establish origin.
|
||||
|
||||
Revocation arrives on its own dimension, not as a binding or signature failure,
|
||||
so a check that reads only those two returned an AI verdict off a dead cert with
|
||||
an empty ``integrity_clashes`` -- quieter than a hash mismatch on the same file.
|
||||
|
||||
The evidence comes from :func:`c2pa_info_from_manifest_store`, not a hand-written
|
||||
dict of what it is believed to emit, so the assertion follows the producer when
|
||||
its contract changes.
|
||||
"""
|
||||
path = tmp_path / "revoked.png"
|
||||
info = c2pa_info_from_manifest_store(
|
||||
{
|
||||
"active_manifest": "created",
|
||||
"validation_results": {
|
||||
"activeManifest": {
|
||||
"success": [
|
||||
{"code": "assertion.dataHash.match"},
|
||||
{"code": "claimSignature.validated"},
|
||||
],
|
||||
"failure": [{"code": "signingCredential.ocsp.revoked"}],
|
||||
}
|
||||
},
|
||||
"manifests": {
|
||||
"created": {
|
||||
"signature_info": {"issuer": "OpenAI"},
|
||||
"assertions": [
|
||||
{
|
||||
"label": "c2pa.actions.v2",
|
||||
"data": {
|
||||
"actions": [
|
||||
{
|
||||
"action": "c2pa.created",
|
||||
"digitalSourceType": "trainedAlgorithmicMedia",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
],
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
assert info["c2pa_signer_validity"] == "invalid"
|
||||
evidence = ProvenanceEvidence(
|
||||
path=path,
|
||||
c2pa_info=info,
|
||||
ai_metadata={},
|
||||
scan=b"jumb c2pa OpenAI trainedAlgorithmicMedia",
|
||||
iptc_ai_system=None,
|
||||
aigc_label=None,
|
||||
exif_generator=None,
|
||||
xai_signature=False,
|
||||
huggingface_job=None,
|
||||
samsung_genai=None,
|
||||
)
|
||||
|
||||
report = identify_from_evidence(evidence)
|
||||
|
||||
assert report.is_ai_generated is None
|
||||
assert report.platform is None
|
||||
assert report.confidence == "none"
|
||||
assert any("revoked" in clash for clash in report.integrity_clashes)
|
||||
|
||||
def test_anchored_signer_is_also_high_confidence(self, tmp_path: Path):
|
||||
path = tmp_path / "validated.png"
|
||||
info = {
|
||||
"has_c2pa": True,
|
||||
@@ -82,7 +151,7 @@ class TestProvenanceEvidence:
|
||||
"source_type": "trainedAlgorithmicMedia (AI-generated)",
|
||||
"ai_source_kind": "generated",
|
||||
"c2pa_validation_source": "reader",
|
||||
"c2pa_validation_state": "Valid",
|
||||
"c2pa_validation_state": "Trusted",
|
||||
"c2pa_integrity": "valid",
|
||||
"c2pa_signature": "valid",
|
||||
"c2pa_signer_trust": "trusted",
|
||||
@@ -111,6 +180,7 @@ class TestProvenanceEvidence:
|
||||
assert report.is_ai_generated is True
|
||||
assert report.confidence == "high"
|
||||
assert report.platform == "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)"
|
||||
assert not any("not anchored" in caveat for caveat in report.caveats)
|
||||
|
||||
def test_external_metadata_record_builds_equivalent_evidence(self, tmp_path: Path):
|
||||
path = tmp_path / "external.jpg"
|
||||
@@ -552,7 +622,7 @@ class TestIdentifyRealSamples:
|
||||
def test_openai_chatgpt(self):
|
||||
r = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "medium"
|
||||
assert r.confidence == "high"
|
||||
assert r.platform
|
||||
assert "OpenAI" in r.platform
|
||||
assert any("C2PA" in w for w in r.watermarks)
|
||||
@@ -621,15 +691,14 @@ class TestIdentifyRealSamples:
|
||||
# both invisible/metadata targets, so the diffusion scrub should run.
|
||||
assert has_invisible_target(SAMPLES_DIR / "chatgpt-1.png") is True
|
||||
assert has_invisible_target(SAMPLES_DIR / "mj-1.png") is True
|
||||
# ai_from_metadata records scrub intent even when an untrusted signer
|
||||
# makes the provenance verdict medium-confidence.
|
||||
# ai_from_metadata records scrub intent independently of the confidence string.
|
||||
assert identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False).ai_from_metadata is True
|
||||
|
||||
def test_untrusted_but_intact_c2pa_is_medium_confidence(self):
|
||||
def test_untrusted_but_intact_c2pa_is_high_confidence_with_a_caveat(self):
|
||||
report = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False, check_invisible=False)
|
||||
|
||||
assert report.is_ai_generated is True
|
||||
assert report.confidence == "medium"
|
||||
assert report.confidence == "high"
|
||||
assert report.ai_from_metadata is True
|
||||
assert report.c2pa_validation is not None
|
||||
assert report.c2pa_validation["source"] == "reader"
|
||||
@@ -639,7 +708,30 @@ class TestIdentifyRealSamples:
|
||||
assert report.c2pa_validation["signer_trust"] == "untrusted"
|
||||
assert report.c2pa_validation["signer_validity"] == "expired"
|
||||
assert "assertion.dataHash.match" in report.c2pa_validation["codes"]
|
||||
assert any("not anchored" in caveat for caveat in report.caveats)
|
||||
# What was not established is said, not folded into the confidence string.
|
||||
assert any("never checked against one" in caveat for caveat in report.caveats)
|
||||
assert any("only the signing time is unproven" in caveat for caveat in report.caveats)
|
||||
|
||||
def test_no_committed_fixture_reports_a_trusted_signer(self):
|
||||
"""The reachability guard for :func:`_c2pa_credential_level`.
|
||||
|
||||
The SDK ships no production trust anchors, so ``signingCredential.trusted``
|
||||
appears in no default installation. Gating high confidence on it made that branch
|
||||
dead in production for every vendor while a hand-built dict kept it green in the
|
||||
suite. These fixtures are the producer; if one ever comes back trusted, a bundle
|
||||
got configured and the confidence mapping needs re-reading, not this assertion
|
||||
deleted.
|
||||
"""
|
||||
checked = 0
|
||||
for path in sorted(SAMPLES_DIR.iterdir()):
|
||||
report = identify(path, check_visible=False, check_invisible=False)
|
||||
if report.c2pa_validation is None:
|
||||
continue
|
||||
checked += 1
|
||||
assert report.c2pa_validation["signer_trust"] != "trusted"
|
||||
if report.c2pa_validation["integrity"] == "valid" and report.c2pa_validation["signature"] == "valid":
|
||||
assert report.confidence == "high", path.name
|
||||
assert checked >= 3
|
||||
|
||||
def test_hash_mismatch_does_not_confirm_origin_but_keeps_scrub_fail_safe(self, tampered_chatgpt_png: Path):
|
||||
report = identify(tampered_chatgpt_png, check_visible=False, check_invisible=False)
|
||||
@@ -1143,6 +1235,39 @@ class TestIdentifySoftBinding:
|
||||
assert any("Digimarc" in w for w in r.watermarks)
|
||||
assert any(s.name == "soft_binding" for s in r.signals)
|
||||
|
||||
def test_invismark_signal_lists_signed_watermark_id(self, tmp_path: Path):
|
||||
watermark_id = "83424621-03cb-40e3-9808-a9fae837156d"
|
||||
record = {
|
||||
"c2pa_store": {
|
||||
"active_manifest": "paint",
|
||||
"manifests": {
|
||||
"paint": {
|
||||
"assertions": [
|
||||
{
|
||||
"label": "c2pa.soft-binding",
|
||||
"data": {
|
||||
"alg": "com.microsoft.invismark.1",
|
||||
"blocks": [{"scope": "the entire image", "value": watermark_id}],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
evidence = evidence_from_metadata_record(record, path=tmp_path / "paint.png")
|
||||
|
||||
report = identify_from_evidence(evidence)
|
||||
|
||||
assert evidence.ai_metadata["soft_binding_value"] == watermark_id
|
||||
signal = next(signal for signal in report.signals if signal.name == "soft_binding")
|
||||
assert "com.microsoft.invismark.1" in signal.detail
|
||||
assert watermark_id in signal.detail
|
||||
invismark = next(signal for signal in report.signals if signal.name == "invismark")
|
||||
assert "com.microsoft.invismark.1" in invismark.detail
|
||||
assert watermark_id in invismark.detail
|
||||
assert any("may remain in the pixels" in caveat for caveat in report.caveats)
|
||||
|
||||
|
||||
class TestIdentifyIptcAi:
|
||||
"""IPTC 2025.1 AISystemUsed drives an AI verdict + platform attribution."""
|
||||
@@ -1300,12 +1425,20 @@ class TestVendorOf:
|
||||
def test_registered_vendors_normalize(self):
|
||||
# Regression: these registered C2PA vendors returned None, so their claims never
|
||||
# entered clash detection (a coverage hole). They now normalize to one origin.
|
||||
assert _vendor_of("ByteDance (Doubao / Jimeng / Volcano Engine)") == "ByteDance"
|
||||
assert _vendor_of("Microsoft (Copilot / Designer)") == "Microsoft"
|
||||
assert _vendor_of("Copilot") == "Microsoft"
|
||||
assert _vendor_of("ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)") == "ByteDance"
|
||||
assert _vendor_of("Dreamina/1.2") == "ByteDance"
|
||||
assert _vendor_of("Canva (Magic Media)") == "Canva"
|
||||
assert _vendor_of("Black Forest Labs (FLUX)") == "Black Forest Labs"
|
||||
assert _vendor_of("Eleven Labs Inc.") == "ElevenLabs"
|
||||
|
||||
def test_bytedance_issuers_share_one_platform(self):
|
||||
expected = "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)"
|
||||
platforms = {vendor.platform for vendor in C2PA_AI_VENDORS if vendor.needle == "ByteDance"}
|
||||
assert platforms == {expected}
|
||||
assert ("dreamina", expected) in C2PA_CLAIM_GENERATOR_PLATFORMS
|
||||
|
||||
|
||||
class TestIntegrityClashesHelper:
|
||||
def test_two_ai_vendors_clash(self):
|
||||
|
||||
@@ -61,7 +61,6 @@ class TestVerifiedTextMode:
|
||||
cases = (
|
||||
("sdxl-zimage", {}, "qwen-zimage"),
|
||||
("qwen-zimage", {"max_resolution": 1024}, "max-resolution 0"),
|
||||
("qwen-zimage", {"tile": True}, "not calibrated"),
|
||||
("qwen-zimage", {"humanize": 1.0}, "humanize=0"),
|
||||
("qwen-zimage", {"adaptive_polish": True}, "polish disabled"),
|
||||
)
|
||||
@@ -73,6 +72,15 @@ class TestVerifiedTextMode:
|
||||
**kwargs,
|
||||
)
|
||||
|
||||
def test_rejects_fidelity_anchor_without_manifest(self, tmp_path):
|
||||
import pytest
|
||||
|
||||
with pytest.raises(ValueError, match="fidelity_anchor requires a text manifest"):
|
||||
self._engine().remove_watermark(
|
||||
tmp_path / "unused.png",
|
||||
fidelity_anchor=True,
|
||||
)
|
||||
|
||||
def test_loads_and_forwards_verified_manifest(self, tmp_path, monkeypatch):
|
||||
import json
|
||||
|
||||
@@ -111,6 +119,18 @@ class TestVerifiedTextMode:
|
||||
engine.remove_watermark(source, output, text_manifest=manifest)
|
||||
|
||||
assert seen["text_manifest"].lines[0].text == "Exact"
|
||||
# Leak-safe default since 0.27.1: the global 15% donor blend is OFF unless
|
||||
# explicitly requested (measured to return detector-visible OpenAI SynthID
|
||||
# on poster-scale manifests; see docs/text-protection-research.md).
|
||||
assert seen["fidelity_anchor"] is False
|
||||
|
||||
engine.remove_watermark(source, output, text_manifest=manifest, fidelity_anchor=True)
|
||||
|
||||
assert seen["fidelity_anchor"] is True
|
||||
|
||||
engine.remove_watermark(source, output, text_manifest=manifest, tile=True)
|
||||
|
||||
assert seen["tile"] is True
|
||||
|
||||
|
||||
class TestNativeOutputSize:
|
||||
|
||||
@@ -596,6 +596,19 @@ class TestSynthIDSourceNonPng:
|
||||
)
|
||||
assert synthid_source(path) == "OpenAI"
|
||||
|
||||
def test_preextracted_c2pa_is_reused(self, tmp_path: Path, monkeypatch: pytest.MonkeyPatch):
|
||||
"""A caller classifying another claim must not parse the manifest twice."""
|
||||
from remove_ai_watermarks._internal import c2pa
|
||||
|
||||
path = self._c2pa_jpeg(tmp_path, "chatgpt-reused.jpg", b"OpenAI")
|
||||
info = {"synthid_vendors": ["OpenAI"]}
|
||||
|
||||
def unexpected_extract(_path: Path):
|
||||
pytest.fail("synthid_source reparsed pre-extracted C2PA")
|
||||
|
||||
monkeypatch.setattr(c2pa, "extract_c2pa_info", unexpected_extract)
|
||||
assert synthid_source(path, c2pa_info=info) == "OpenAI"
|
||||
|
||||
def test_legacy_openai_c2pa_without_watermark_action_is_none(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, "legacy-chatgpt.jpg", b"OpenAI")
|
||||
assert synthid_source(path) is None
|
||||
|
||||
@@ -13,6 +13,8 @@ from PIL import Image
|
||||
from remove_ai_watermarks._internal.c2pa import (
|
||||
_parse_c2pa_chunk,
|
||||
c2pa_info_from_manifest_store,
|
||||
c2pa_info_has_invismark,
|
||||
c2pa_info_has_removal_hint,
|
||||
cbor_text_after,
|
||||
extract_c2pa_chunk,
|
||||
extract_c2pa_info,
|
||||
@@ -209,7 +211,79 @@ class TestC2PA:
|
||||
assert info["ai_tool"] == "Dreamina"
|
||||
assert info["c2pa_identity_ai"] is True
|
||||
|
||||
def test_invalid_ingredient_does_not_taint_active_validation_or_supply_claims(self):
|
||||
def test_structured_invismark_exposes_algorithm_and_watermark_id(self):
|
||||
watermark_id = "83424621-03cb-40e3-9808-a9fae837156d"
|
||||
store = {
|
||||
"active_manifest": "paint",
|
||||
"manifests": {
|
||||
"paint": {
|
||||
"assertions": [
|
||||
{
|
||||
"label": "c2pa.soft-binding",
|
||||
"data": {
|
||||
"alg": "com.microsoft.invismark.1",
|
||||
"blocks": [
|
||||
{
|
||||
"scope": "the entire image",
|
||||
"value": watermark_id,
|
||||
}
|
||||
],
|
||||
},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
info = c2pa_info_from_manifest_store(store)
|
||||
|
||||
assert info["soft_binding"] == "Microsoft InvisMark"
|
||||
assert info["soft_binding_algorithm"] == "com.microsoft.invismark.1"
|
||||
assert info["soft_binding_value"] == watermark_id
|
||||
|
||||
def test_soft_binding_value_requires_its_algorithm(self):
|
||||
store = {
|
||||
"active_manifest": "broken",
|
||||
"manifests": {
|
||||
"broken": {
|
||||
"assertions": [
|
||||
{
|
||||
"label": "c2pa.soft-binding",
|
||||
"data": {"blocks": [{"value": "not-attributable"}]},
|
||||
}
|
||||
]
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
assert "soft_binding_value" not in c2pa_info_from_manifest_store(store)
|
||||
|
||||
def test_soft_binding_keeps_invisible_removal_fail_safe(self):
|
||||
info = {"soft_binding_vendors": ["Microsoft InvisMark"]}
|
||||
|
||||
assert c2pa_info_has_invismark(info) is True
|
||||
assert c2pa_info_has_removal_hint(info) is True
|
||||
|
||||
def test_content_fingerprint_does_not_trigger_invisible_removal(self):
|
||||
info = {
|
||||
"soft_binding": "Adobe (content fingerprint)",
|
||||
"soft_binding_vendors": ["Adobe (content fingerprint)"],
|
||||
}
|
||||
|
||||
assert c2pa_info_has_removal_hint(info) is False
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ingredient_failure",
|
||||
[
|
||||
# One exclusion rule, reached through two different dimensions: a broken
|
||||
# binding and a credential the issuer disowned. The walk classified only the
|
||||
# first for a while, so a revoked child manifest stayed reachable and kept
|
||||
# donating its claim generator to the parent's attribution.
|
||||
"assertion.dataHash.mismatch",
|
||||
"signingCredential.ocsp.revoked",
|
||||
],
|
||||
)
|
||||
def test_invalid_ingredient_does_not_taint_active_validation_or_supply_claims(self, ingredient_failure: str):
|
||||
store = {
|
||||
"active_manifest": "update",
|
||||
"validation_results": {
|
||||
@@ -220,13 +294,7 @@ class TestC2PA:
|
||||
],
|
||||
"failure": [{"code": "signingCredential.untrusted"}],
|
||||
},
|
||||
"ingredientDeltas": [
|
||||
{
|
||||
"validationDeltas": {
|
||||
"failure": [{"code": "assertion.dataHash.mismatch"}],
|
||||
}
|
||||
}
|
||||
],
|
||||
"ingredientDeltas": [{"validationDeltas": {"failure": [{"code": ingredient_failure}]}}],
|
||||
},
|
||||
"manifests": {
|
||||
"update": {
|
||||
@@ -234,11 +302,7 @@ class TestC2PA:
|
||||
"ingredients": [
|
||||
{
|
||||
"active_manifest": "created",
|
||||
"validation_results": {
|
||||
"activeManifest": {
|
||||
"failure": [{"code": "assertion.dataHash.mismatch"}],
|
||||
}
|
||||
},
|
||||
"validation_results": {"activeManifest": {"failure": [{"code": ingredient_failure}]}},
|
||||
}
|
||||
],
|
||||
"assertions": [],
|
||||
|
||||
+37
-8
@@ -14,6 +14,8 @@ import pytest
|
||||
from remove_ai_watermarks._internal.utils import get_image_format, is_supported_format
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
PROFILE_CHOICES,
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH,
|
||||
QWEN_ZIMAGE_OPENAI_STRENGTH,
|
||||
REMOVAL_MODULES,
|
||||
SDXL_ZIMAGE_GEMINI_STRENGTH,
|
||||
SDXL_ZIMAGE_OPENAI_STRENGTH,
|
||||
@@ -197,23 +199,34 @@ class TestNoReembeddedWatermark:
|
||||
|
||||
|
||||
class TestResolveStrength:
|
||||
"""resolve_strength answers for sdxl-zimage and defers for qwen-zimage."""
|
||||
"""resolve_strength owns the qwen-zimage and sdxl-zimage policies."""
|
||||
|
||||
def test_qwen_zimage_answers_from_the_resolution_curve(self):
|
||||
"""The function is total: it owns both policies rather than returning None.
|
||||
|
||||
qwen-zimage picks strength from image area, so it takes the size. Returning
|
||||
None for it would push that branch onto every caller and leave one of the two
|
||||
strength policies living outside this module. The vendor is ignored here on
|
||||
purpose - the curve, not the issuer, is what was calibrated.
|
||||
qwen-zimage picks unknown content's strength from image area, so it takes the
|
||||
size. Returning None would push that branch onto every caller and leave one of
|
||||
the two strength policies living outside this module. Measured providers take
|
||||
flat corpus-derived operating points instead.
|
||||
"""
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
|
||||
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
|
||||
QWEN_ZIMAGE_OPENAI_STRENGTH
|
||||
)
|
||||
assert resolve_strength(None, None, "qwen-zimage", size=(600, 500)) == pytest.approx(0.084)
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(600, 500)) == QWEN_ZIMAGE_GOOGLE_STRENGTH
|
||||
# The floor holds at every size, not only above the curve's top rung.
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == QWEN_ZIMAGE_GOOGLE_STRENGTH
|
||||
|
||||
def test_qwen_zimage_without_a_size_fails_loudly(self):
|
||||
"""A missing size must not silently fall back to some vendor value."""
|
||||
with pytest.raises(ValueError, match="size is required"):
|
||||
resolve_strength(None, "google", "qwen-zimage")
|
||||
resolve_strength(None, "openai", "qwen-zimage")
|
||||
|
||||
@pytest.mark.parametrize(("vendor", "expected"), [("microsoft", 0.15), ("openai", 0.07675)])
|
||||
def test_qwen_zimage_measured_vendors_use_flat_cross_source_margins(self, vendor, expected):
|
||||
"""Measured providers must not fall below their operating points on small files."""
|
||||
assert resolve_strength(None, vendor, "qwen-zimage", size=(600, 500)) == pytest.approx(expected)
|
||||
assert resolve_strength(None, vendor, "qwen-zimage", size=(4000, 3000)) == pytest.approx(expected)
|
||||
|
||||
def test_sdxl_zimage_uses_its_flat_vendor_ladder(self):
|
||||
|
||||
@@ -247,7 +260,7 @@ class TestResolveStrength:
|
||||
|
||||
|
||||
class TestVendorForStrength:
|
||||
"""vendor_for_strength normalizes SynthID provenance to openai/google/None."""
|
||||
"""Normalize supported invisible-watermark provenance to a removal profile."""
|
||||
|
||||
@staticmethod
|
||||
def _patch(value):
|
||||
@@ -265,6 +278,22 @@ class TestVendorForStrength:
|
||||
with self._patch("Google"):
|
||||
assert vendor_for_strength(Path("x.png")) == "google"
|
||||
|
||||
@pytest.mark.parametrize(("integrity", "expected"), [("valid", "microsoft"), ("invalid", None)])
|
||||
def test_only_valid_microsoft_invismark_selects_the_removal_floor(self, integrity, expected):
|
||||
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
|
||||
|
||||
info = {
|
||||
"soft_binding_vendors": ["Microsoft InvisMark"],
|
||||
"c2pa_integrity": integrity,
|
||||
"c2pa_signature": "valid",
|
||||
"c2pa_signer_validity": "valid",
|
||||
}
|
||||
with (
|
||||
self._patch(None),
|
||||
patch("remove_ai_watermarks._internal.c2pa.extract_c2pa_info", return_value=info),
|
||||
):
|
||||
assert vendor_for_strength(Path("x.png")) == expected
|
||||
|
||||
def test_both_issuers_google_wins(self):
|
||||
# The more-robust watermark wins -> safer (higher) strength.
|
||||
from remove_ai_watermarks._internal.watermark_profiles import vendor_for_strength
|
||||
|
||||
@@ -646,9 +646,58 @@ def test_no_face_path_still_runs_verified_text_restoration(monkeypatch):
|
||||
|
||||
result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest)
|
||||
|
||||
assert result is restored
|
||||
# Off by default since 0.27.1 (leak finding, docs/text-protection-research.md):
|
||||
# no whole-frame donor blend; the raw global result feeds restoration.
|
||||
blend.assert_not_called()
|
||||
restore.assert_called_once_with(source, global_result, donor, manifest.lines)
|
||||
|
||||
result = pipeline.run(source, strength=0.1, seed=0, text_manifest=manifest, fidelity_anchor=True)
|
||||
|
||||
assert result is restored
|
||||
blend.assert_called_once_with(global_result, donor)
|
||||
restore.assert_called_once_with(source, anchor, donor, manifest.lines)
|
||||
restore.assert_called_with(source, anchor, donor, manifest.lines)
|
||||
|
||||
|
||||
def test_tiled_verified_text_runs_vae_donor_per_tile(monkeypatch):
|
||||
from remove_ai_watermarks._internal import qwen_zimage_pipeline, text_restoration
|
||||
from remove_ai_watermarks._internal.qwen_zimage_pipeline import QwenZImagePipeline
|
||||
from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest
|
||||
|
||||
pipeline = object.__new__(QwenZImagePipeline)
|
||||
pipeline.device = "cuda"
|
||||
pipeline.progress_callback = None
|
||||
source = Image.new("RGB", (96, 80), (10, 20, 30))
|
||||
restored = Image.new("RGB", (96, 80), (130, 140, 150))
|
||||
pipeline._qwen_vae_roundtrip = MagicMock(side_effect=lambda tile: tile)
|
||||
pipeline._run_global = MagicMock(side_effect=lambda tile, _strength, _seed: tile)
|
||||
monkeypatch.setattr(qwen_zimage_pipeline, "detect_faces", lambda _image: [])
|
||||
restore = MagicMock(return_value=restored)
|
||||
monkeypatch.setattr(text_restoration, "restore_verified_text", restore)
|
||||
manifest = VerifiedTextManifest(
|
||||
"0" * 64,
|
||||
96,
|
||||
80,
|
||||
(VerifiedTextLine((4, 4, 20, 16), "Exact", "alphabetic"),),
|
||||
)
|
||||
|
||||
result = pipeline.run(
|
||||
source,
|
||||
strength=0.1,
|
||||
seed=0,
|
||||
tile=True,
|
||||
tile_size=64,
|
||||
tile_overlap=16,
|
||||
text_manifest=manifest,
|
||||
)
|
||||
|
||||
assert result is restored
|
||||
assert pipeline._qwen_vae_roundtrip.call_count > 1
|
||||
assert pipeline._run_global.call_count > 1
|
||||
restore.assert_called_once()
|
||||
assert restore.call_args.args[0].size == (96, 80)
|
||||
assert restore.call_args.args[1].size == (96, 80)
|
||||
assert restore.call_args.args[2].size == (96, 80)
|
||||
|
||||
|
||||
def test_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch):
|
||||
@@ -858,6 +907,8 @@ def test_invisible_engine_passes_the_seed_but_never_a_step_count(tmp_image_path,
|
||||
def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone():
|
||||
"""An SDXL global pass needs more strength than Qwen, so it gets its own policy."""
|
||||
from remove_ai_watermarks._internal.watermark_profiles import (
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH,
|
||||
QWEN_ZIMAGE_OPENAI_STRENGTH,
|
||||
SDXL_ZIMAGE_GEMINI_STRENGTH,
|
||||
SDXL_ZIMAGE_OPENAI_STRENGTH,
|
||||
resolve_strength,
|
||||
@@ -867,11 +918,15 @@ def test_sdxl_zimage_strength_is_vendor_adaptive_and_leaves_other_profiles_alone
|
||||
assert resolve_strength(None, "google", "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_GEMINI_STRENGTH)
|
||||
# Unknown provenance takes the stricter of the two.
|
||||
assert resolve_strength(None, None, "sdxl-zimage") == pytest.approx(SDXL_ZIMAGE_GEMINI_STRENGTH)
|
||||
# An explicit value still wins, and qwen-zimage is untouched by this ladder: it
|
||||
# defers to its resolution curve rather than to a vendor value.
|
||||
# An explicit value still wins. qwen-zimage's unknown cohort keeps the curve,
|
||||
# while measured providers take their flat operating points.
|
||||
assert resolve_strength(0.4, "google", "sdxl-zimage") == pytest.approx(0.4)
|
||||
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(0.154)
|
||||
assert resolve_strength(None, "openai", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
|
||||
QWEN_ZIMAGE_OPENAI_STRENGTH
|
||||
)
|
||||
assert resolve_strength(None, "google", "qwen-zimage", size=(2000, 1850)) == pytest.approx(
|
||||
QWEN_ZIMAGE_GOOGLE_STRENGTH
|
||||
)
|
||||
|
||||
|
||||
def test_sdxl_zimage_shares_the_fixed_seed_contract():
|
||||
|
||||
@@ -0,0 +1,158 @@
|
||||
"""text_draft: proposal-only OCR for verified-text manifests (no paddle needed)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks.text_draft import (
|
||||
TextDraft,
|
||||
choose_language,
|
||||
draft_available,
|
||||
draft_text_lines,
|
||||
group_word_boxes,
|
||||
stable_recognition,
|
||||
)
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class TestPureHelpers:
|
||||
def test_choose_language_follows_unicode_script(self):
|
||||
assert choose_language({"ch": ("你好", 0.9), "ru": ("xxx", 0.1), "en": ("yyy", 0.1)}) == "ch"
|
||||
assert choose_language({"ch": ("??", 0.1), "ru": ("привет", 0.9), "en": ("yyy", 0.1)}) == "ru"
|
||||
assert choose_language({"ch": ("??", 0.1), "ru": ("xxx", 0.1), "en": ("hello", 0.9)}) == "en"
|
||||
|
||||
def test_stable_recognition_requires_identical_normalizations(self):
|
||||
# Same text modulo spacing/case (comma kept): stable.
|
||||
assert (
|
||||
stable_recognition([("Hello, World", 0.97), ("hello,world", 0.96), ("HELLO, WORLD", 0.95)])
|
||||
== "Hello, World"
|
||||
)
|
||||
# Different texts: rejected (None).
|
||||
assert stable_recognition([("Hello", 0.97), ("Hallo", 0.96), ("Hello", 0.95)]) is None
|
||||
# Identical text but one read under the floor: rejected.
|
||||
assert stable_recognition([("Hello", 0.97), ("Hello", 0.60), ("Hello", 0.95)]) is None
|
||||
assert stable_recognition([("Hello", 0.97), ("Hello", 0.96), ("Hello", 0.95)], min_score=0.99) is None
|
||||
|
||||
def test_group_word_boxes_merges_a_line_and_keeps_rows_apart(self):
|
||||
# Two words on one baseline merge; a distant lower line stays separate.
|
||||
merged = group_word_boxes([(10, 100, 60, 140), (70, 100, 120, 140)])
|
||||
assert len(merged) == 1
|
||||
assert merged[0] == (10, 100, 120, 140)
|
||||
apart = group_word_boxes([(10, 100, 60, 140), (10, 300, 60, 340)])
|
||||
assert len(apart) == 2
|
||||
|
||||
|
||||
class TestDraftAvailable:
|
||||
def test_returns_a_bool_without_importing_paddle(self):
|
||||
assert isinstance(draft_available(), bool)
|
||||
|
||||
|
||||
class _FakeDetector:
|
||||
"""Emits Paddle-style pages for one synthetic poster."""
|
||||
|
||||
def __init__(self, boxes: list[tuple[int, int, int, int]]) -> None:
|
||||
self._boxes = boxes
|
||||
|
||||
def predict(self, _image: Any) -> Any:
|
||||
import numpy as np
|
||||
|
||||
yield {
|
||||
"rec_boxes": np.asarray(self._boxes, dtype=np.float32),
|
||||
"rec_scores": [0.99] * len(self._boxes),
|
||||
}
|
||||
|
||||
|
||||
class _FakeEngine:
|
||||
"""Returns a fixed text at high score regardless of the crop."""
|
||||
|
||||
def __init__(self, text: str) -> None:
|
||||
self._text = text
|
||||
|
||||
def predict(self, _crop: Any) -> Any:
|
||||
yield {"rec_text": self._text, "rec_score": 0.97}
|
||||
|
||||
|
||||
class _JitterEngine:
|
||||
"""Changes its answer with the crop height - exactly what the gate rejects."""
|
||||
|
||||
def __init__(self, texts: list[str]) -> None:
|
||||
self._texts = texts
|
||||
self._calls = 0
|
||||
|
||||
def predict(self, crop: Any) -> Any:
|
||||
text = self._texts[self._calls % len(self._texts)]
|
||||
self._calls += 1
|
||||
yield {"rec_text": text, "rec_score": 0.97}
|
||||
|
||||
|
||||
class TestDraftTextLines:
|
||||
@staticmethod
|
||||
def _poster(tmp_path: Path) -> Path:
|
||||
path = tmp_path / "poster.png"
|
||||
Image.new("RGB", (400, 300), (255, 255, 255)).save(path)
|
||||
return path
|
||||
|
||||
def test_accepts_crop_stable_and_rejects_jittered(self, tmp_path: Path):
|
||||
path = self._poster(tmp_path)
|
||||
stable = _FakeEngine("Invoice 42")
|
||||
jitter = _JitterEngine(["Hello", "Hallo", "Hullo"])
|
||||
# All three probe engines read the same string, so language = en; the
|
||||
# jitter engine then serves as the en engine and flips across crops.
|
||||
draft = draft_text_lines(
|
||||
path,
|
||||
detector=_FakeDetector([(20, 20, 200, 60), (20, 80, 200, 120)]),
|
||||
engines={"en": jitter, "ru": stable, "ch": stable},
|
||||
)
|
||||
assert isinstance(draft, TextDraft)
|
||||
assert len(draft.rejected) == 2
|
||||
assert all(line.language == "en" for line in draft.rejected)
|
||||
assert draft.accepted == ()
|
||||
|
||||
def test_accepted_line_carries_box_text_script_and_floor(self, tmp_path: Path):
|
||||
path = self._poster(tmp_path)
|
||||
engine = _FakeEngine("Total: 1,234.56")
|
||||
draft = draft_text_lines(
|
||||
path,
|
||||
detector=_FakeDetector([(20, 20, 260, 60)]),
|
||||
engines={"en": engine, "ru": engine, "ch": engine},
|
||||
)
|
||||
(line,) = draft.accepted
|
||||
assert line.box == (20, 20, 260, 60)
|
||||
assert line.text == "Total: 1,234.56"
|
||||
assert line.script == "alphabetic"
|
||||
assert line.language == "en"
|
||||
assert line.min_score >= 0.85
|
||||
|
||||
def test_cjk_probe_switches_script_and_language(self, tmp_path: Path):
|
||||
path = self._poster(tmp_path)
|
||||
cjk = _FakeEngine("每天都是一个新的机会。")
|
||||
latin = _FakeEngine("hello")
|
||||
draft = draft_text_lines(
|
||||
path,
|
||||
detector=_FakeDetector([(30, 30, 300, 90)]),
|
||||
engines={"en": latin, "ru": latin, "ch": cjk},
|
||||
)
|
||||
(line,) = draft.accepted
|
||||
assert line.language == "ch"
|
||||
assert line.script == "cjk"
|
||||
|
||||
def test_unstable_geometry_draft_accepts_a_single_high_score_read(self, tmp_path: Path):
|
||||
path = self._poster(tmp_path)
|
||||
jitter = _JitterEngine(["Hello", "Hallo", "Hullo"])
|
||||
stable = _FakeEngine("Hello")
|
||||
# Probes disagree across engines so language is en; jitter would fail
|
||||
# the stable gate. Geometry mode uses the one en probe and accepts.
|
||||
draft = draft_text_lines(
|
||||
path,
|
||||
min_score=0.75,
|
||||
stable=False,
|
||||
detector=_FakeDetector([(20, 20, 200, 60)]),
|
||||
engines={"en": jitter, "ru": stable, "ch": stable},
|
||||
)
|
||||
(line,) = draft.accepted
|
||||
assert line.text == "Hello"
|
||||
assert line.min_score >= 0.75
|
||||
@@ -15,6 +15,7 @@ from remove_ai_watermarks._internal.text_restoration import (
|
||||
load_verified_text_manifest,
|
||||
restore_verified_text,
|
||||
source_pixel_sha256,
|
||||
source_silhouette_mask,
|
||||
)
|
||||
|
||||
|
||||
@@ -36,6 +37,17 @@ def _manifest(image: Image.Image) -> dict[str, object]:
|
||||
}
|
||||
|
||||
|
||||
def _geometry_manifest(image: Image.Image) -> dict[str, object]:
|
||||
return {
|
||||
"schema_version": 2,
|
||||
"verified": True,
|
||||
"source_pixel_sha256": source_pixel_sha256(image),
|
||||
"width": image.width,
|
||||
"height": image.height,
|
||||
"lines": [{"box": [8, 8, 40, 24], "angle": 0.0}],
|
||||
}
|
||||
|
||||
|
||||
def test_pixel_hash_ignores_container_metadata(tmp_path) -> None:
|
||||
image = Image.new("RGB", (48, 32), (10, 20, 30))
|
||||
plain = tmp_path / "plain.png"
|
||||
@@ -62,6 +74,40 @@ def test_verified_manifest_is_bound_to_source_pixels(tmp_path) -> None:
|
||||
assert loaded.lines == (VerifiedTextLine((8, 8, 40, 24), "Exact text", "alphabetic", 0.0),)
|
||||
|
||||
|
||||
def test_geometry_manifest_needs_no_transcription_or_script(tmp_path) -> None:
|
||||
source = Image.new("RGB", (48, 32), (10, 20, 30))
|
||||
path = tmp_path / "regions.json"
|
||||
path.write_text(json.dumps(_geometry_manifest(source)), encoding="utf-8")
|
||||
|
||||
loaded = load_verified_text_manifest(path, source)
|
||||
|
||||
assert loaded.lines == (VerifiedTextLine((8, 8, 40, 24), angle=0.0),)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["text", "script"])
|
||||
def test_schema_one_still_requires_text_metadata(tmp_path, field) -> None:
|
||||
source = Image.new("RGB", (48, 32), (10, 20, 30))
|
||||
payload = _manifest(source)
|
||||
del payload["lines"][0][field]
|
||||
path = tmp_path / "lines.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match=field):
|
||||
load_verified_text_manifest(path, source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("schema_version", [True, 0, 3])
|
||||
def test_manifest_rejects_unsupported_schema_versions(tmp_path, schema_version) -> None:
|
||||
source = Image.new("RGB", (48, 32), (10, 20, 30))
|
||||
payload = _geometry_manifest(source)
|
||||
payload["schema_version"] = schema_version
|
||||
path = tmp_path / "lines.json"
|
||||
path.write_text(json.dumps(payload), encoding="utf-8")
|
||||
|
||||
with pytest.raises(ValueError, match="Unsupported text manifest schema"):
|
||||
load_verified_text_manifest(path, source)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("mutation", "message"),
|
||||
[
|
||||
@@ -119,10 +165,35 @@ def test_restoration_uses_lama_and_qwen_vae_core(monkeypatch) -> None:
|
||||
Image.fromarray(source),
|
||||
Image.fromarray(candidate),
|
||||
Image.fromarray(donor),
|
||||
(VerifiedTextLine((8, 8, 48, 28), "Exact text", "alphabetic"),),
|
||||
(VerifiedTextLine((8, 8, 48, 28)),),
|
||||
)
|
||||
|
||||
restored = np.asarray(result)
|
||||
assert calls
|
||||
assert np.all(restored[16, 20] == donor[16, 20])
|
||||
assert np.all(restored[0, 0] == candidate[0, 0])
|
||||
|
||||
|
||||
def test_silhouette_includes_descender_below_the_detector_box() -> None:
|
||||
source = np.full((40, 50, 3), 240, dtype=np.uint8)
|
||||
source[10:22, 18:24] = 20
|
||||
source[22:27, 18:22] = 20 # tail of a y / Cyrillic u, under the box
|
||||
|
||||
mask = source_silhouette_mask(source, (10, 10, 40, 22))
|
||||
|
||||
assert mask[24, 20] == 255
|
||||
assert mask[16, 20] == 255
|
||||
|
||||
|
||||
def test_silhouette_includes_glyph_edges_beside_the_detector_box() -> None:
|
||||
source = np.full((40, 70, 3), 240, dtype=np.uint8)
|
||||
source[10:22, 20:50] = 20
|
||||
source[14:18, 10:20] = 20 # leading flourish, left of the detector box
|
||||
source[14:18, 50:58] = 20 # punctuation or icon edge, right of the box
|
||||
source[14:18, 2:5] = 20 # separate decoration must stay outside the crop
|
||||
|
||||
mask = source_silhouette_mask(source, (20, 10, 50, 22))
|
||||
|
||||
assert mask[16, 12] == 255
|
||||
assert mask[16, 56] == 255
|
||||
assert mask[16, 3] == 0
|
||||
@@ -951,6 +951,7 @@ class TestVideoMetadataCli:
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "AI metadata detected" in result.output
|
||||
assert "not the same as 'clean'" not in result.output
|
||||
|
||||
def test_remove_reports_output(self, tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
@@ -961,6 +962,7 @@ class TestVideoMetadataCli:
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "AI metadata stripped" in result.output
|
||||
assert "not the same as 'clean'" in result.output
|
||||
assert C2PA_UUID not in output.read_bytes()
|
||||
|
||||
def test_rejects_image_input(self, tmp_clean_png: Path):
|
||||
|
||||
Reference in new issue
Block a user