diff --git a/.claude/rules/development.md b/.claude/rules/development.md index 25f5b8e..2df450c 100644 --- a/.claude/rules/development.md +++ b/.claude/rules/development.md @@ -25,6 +25,16 @@ Boundary modules for cv2, Torch, and Diffusers may carry narrow per-file relaxat From a worktree, `uv run` imports the package from the MAIN checkout -- that is where the editable install points. A script measuring a worktree's edit must insert that worktree's `src` at `sys.path[0]` and assert `module.__file__` resolves inside it, or it silently compares unmodified code against itself. +## Visible-mark example gallery + +Every registered mark carries a committed example: `data/fixtures/visible//example.png` +(image marks) and `example.mp4` (video marks), regenerated by +`scripts/render_visible_examples.py`. `tests/test_visible_examples.py` is the canary: it +fails when a mark is registered without an example and when an engine stops detecting its +own example. The examples are synthetic composites of the committed silhouettes -- user +uploads never enter the repository. When the generator fails after a geometry or gate +change, fix the generator (and the engine) together; do not hand-edit the binaries. + ## Model-adjacent tests Do not classify an entire module as untestable because its main path downloads a model. Keep pure behavior covered without downloads, including: diff --git a/.github/workflows/distribute.yml b/.github/workflows/distribute.yml index 430def4..d9125db 100644 --- a/.github/workflows/distribute.yml +++ b/.github/workflows/distribute.yml @@ -4,7 +4,7 @@ name: Distribute on release # PyPI is handled by publish.yml. This workflow event-drives the three channels # that would otherwise be manual: # - Homebrew tap: rewrite the formula's url + sha256 to the new sdist. -# - HF Space: factory-rebuild so it reinstalls the latest sdist from PyPI. +# - HF Space: factory-rebuild the version already pinned in the Space repo. # - ComfyUI: sync, test, and publish the node against the exact library release. # All wait for the freshly published sdist to appear on PyPI first, since the # Release event fires in parallel with publish.yml's upload. diff --git a/README.md b/README.md index b09abd3..ccc2543 100644 --- a/README.md +++ b/README.md @@ -2,18 +2,19 @@ Remove AI provenance marks from images and video you generated yourself: -- known visible labels such as the Gemini sparkle and vendor text marks; +- known visible labels such as the Google Gemini sparkle watermark and vendor text marks; - invisible pixel watermarks through diffusion regeneration; - C2PA, EXIF, XMP, IPTC, and related AI metadata. Video support covers provenance identification, complete visible-plus-metadata -cleaning, directory batches, visible Sora, Veo, Seedance, Dola, Hailuo, and -Kling mark removal, and oracle-certified VAE regeneration for video SynthID +cleaning, directory batches, visible Sora, Veo, Seedance, Dola, Hailuo AI, and +Kling AI mark removal, and oracle-certified VAE regeneration for video SynthID removal. > [raiw.cc](https://raiw.cc) runs this library as a hosted service, with the GPU -> included and nothing to install. Visible mark and metadata removal are free -> there; invisible watermark removal is paid. +> included and nothing to install. Visible mark and metadata removal at Standard +> output up to 12 MP are free there; original resolution above 12 MP and invisible +> watermark removal are paid. [![PyPI](https://img.shields.io/pypi/v/remove-ai-watermarks?logo=pypi&logoColor=white)](https://pypi.org/project/remove-ai-watermarks/) [![Python](https://img.shields.io/pypi/pyversions/remove-ai-watermarks?logo=python&logoColor=white)](https://pypi.org/project/remove-ai-watermarks/) @@ -188,8 +189,8 @@ the specificity order shown below. Pass an explicit mark to restrict detection to one provider. Sora covers the moving Sora 2 mascot and wordmark. Veo covers both the current four-point diamond and the legacy `Veo` text. Seedance covers the fixed boxed -`AI` label, Dola covers the fixed `Dola AI` text, Hailuo covers the composite -`MINIMAX | hailuo AI` label, and Kling covers the bottom-right `KLING AI` +`AI` label, Dola covers the fixed `Dola AI` text, Hailuo AI covers the composite +`MINIMAX | hailuo AI` label, and Kling AI covers the bottom-right `KLING AI` label with its version suffix. A completed encode is published atomically. No output is written when no stable mark is found. HDR, PQ/HLG, and greater-than-8-bit inputs are rejected before encoding rather @@ -250,6 +251,13 @@ features, and development setup. | --- | --- | | ![Image with a visible Gemini watermark](demo_banana_before.png) | ![Image after visible watermark removal](demo_banana_after.png) | +The `after` raster is generated from the tracked `before` raster by the public path: + +```bash +uv run remove-ai-watermarks visible demo_banana_before.png \ + --backend cv2 -o demo_banana_after.png +``` + ### High quality invisible removal `qwen-zimage` is the default profile: a Qwen-Image-2512 Lightning pass under Canny @@ -331,8 +339,9 @@ remove-ai-watermarks batch ./images --mode all Visible mark support includes: -- Google Gemini and Nano Banana sparkle; -- Doubao, Jimeng, Qwen, Kling, Yuanbao, Baidu, LibLibAI, and RunningHub labels; +- Google Gemini and Nano Banana visible sparkle watermark; +- Doubao, Jimeng, Qwen, Kling AI, Yuanbao, Baidu, LiblibAI, and RunningHub labels; +- one calibrated Microsoft top-right white AI-badge variant; - one calibrated Samsung Galaxy AI label variant. Metadata and provenance inspection covers C2PA, EXIF, XMP, IPTC, common @@ -419,7 +428,7 @@ invisible removal. detail. - Visible video removal recognizes the moving Sora 2 wordmark, the current Veo diamond plus legacy `Veo` text, the Seedance boxed `AI` label, and the fixed - Dola, Hailuo, and Kling labels. It does not recognize the older Sora Turbo + Dola, Hailuo AI, and Kling AI labels. It does not recognize the older Sora Turbo corner swirl or unregistered layouts from those providers. The classical OpenCV backend can smear structured backgrounds; use MI-GAN or LaMa when recovery quality matters. diff --git a/data/README.md b/data/README.md index de5d994..67d14db 100644 --- a/data/README.md +++ b/data/README.md @@ -7,6 +7,8 @@ data/ fixtures/ provenance/ Real format and provenance fixtures used by tests (source records live in fixtures/README.md) + visible/ Synthetic per-mark example gallery (one committed example per + registered visible mark; see fixtures/visible/README.md) calibration/ / Minimal controlled inputs needed to rebuild detector assets synthid/ @@ -54,16 +56,17 @@ runtime assets. video removal profile works, so it is also the source of truth for three shipped defaults: `tests/test_video_invisible.py` asserts that `noise_std`, `long_side`, and `fps` together match a row this manifest records as certified. Changing one -of those three without adding the row that certifies it fails the suite. `vae` is -deliberately outside that check because neither tracked row records one; add it -to the assertion in the same commit as the first row that does. +of those three without adding the row that certifies it fails the suite. Both +historical rows mark `vae` as `unrecorded`: the repository must not infer a model +identity from the current default. Add it to the assertion in the same commit as +the first oracle row that records and verifies one. | Column | Meaning | | --- | --- | | `date`, `source_url`, `source_sha256` | Identify the carrier. | | `source_width`, `source_height`, `source_fps` | Carrier geometry. Without it the actual downscale factor of a row cannot be recovered later. | | `duration_seconds`, `source_verdict` | Clip length submitted and the verifier's reading of the untouched carrier. | -| `vae`, `noise_std`, `long_side`, `fps`, `seed` | The full run configuration. | +| `vae`, `noise_std`, `long_side`, `fps`, `seed` | The run configuration. `unrecorded` means the historical run did not preserve the VAE identity. | | `output_sha256` | Identifies the exact submitted file. | | `output_verdict` | One of `detected`, `not_detected`, `indeterminate`, `refused`. | | `output_verdict_text` | The verifier's wording, verbatim. | diff --git a/data/contentseal/README.md b/data/contentseal/README.md new file mode 100644 index 0000000..3797fb5 --- /dev/null +++ b/data/contentseal/README.md @@ -0,0 +1,101 @@ +# 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. +- Three checked center crops lost the seal: 50% and 33% linear crops of the fox + and the 50% crop of the text poster returned "No AI signatures from Meta were + found". The text poster's 33% crop was not checked because the daily oracle + limit was reached, so its empty verdict is not evidence either way. The checked + results are consistent with the Reuters 2026-07-11 analysis (55% missed after + cropping). +- API outputs carry XMP `iptcExt:DigitalSourceType = + trainedAlgorithmicMedia`, so local `identify` flags them via the existing + Made-with-AI path. Metadata-stripping transforms fall back to unknown, and + 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/` with the + raw file bytes, `x-entity-type`, `x-entity-length`, `ai_detector_upload: true`, + and an anonymous `authorization: OAuth ecto1:` session token minted by + the page. +2. `POST https://meta.ai/api/ai-detector` with `Bearer ecto1:` 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 had no floor before this +calibration; these rows measure one by the same methodology: independent +generations, each one's first-clean boundary, floor = worst boundary plus the +observed cross-source spread. + +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**. +- Shipped as `QWEN_ZIMAGE_META_STRENGTH`: auto mode routes standalone-AI-IPTC + files onto the cohort, and `--vendor meta` / `InvisibleOptions.vendor` + names it explicitly on stripped files (implying the scrub runs). +## Regeneration + +The eight deterministic crop, resize, and JPEG variants can be reproduced and +hash-checked from the tracked originals: + +```bash +uv run python scripts/contentseal_transforms.py /tmp/contentseal-derived +``` + +The Meta API generations and remote GPU outputs are not reproducible from this +repository alone. Their prompts, exact output hashes, model/profile settings, +and oracle results are recorded in `manifest.csv`, but the generation API is +stochastic and the private worker environment is not tracked. `MUSE_API_KEY` and +the anonymous detector session are deliberately absent. A new calibration must +therefore create new manifest rows rather than claiming to recreate these bytes. diff --git a/data/contentseal/manifest.csv b/data/contentseal/manifest.csv new file mode 100644 index 0000000..3c66a7e --- /dev/null +++ b/data/contentseal/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] diff --git a/data/contentseal/originals/gen_fox_forest.webp b/data/contentseal/originals/gen_fox_forest.webp new file mode 100644 index 0000000..b8c0374 Binary files /dev/null and b/data/contentseal/originals/gen_fox_forest.webp differ diff --git a/data/contentseal/originals/gen_lighthouse_watercolor.webp b/data/contentseal/originals/gen_lighthouse_watercolor.webp new file mode 100644 index 0000000..99dcc53 Binary files /dev/null and b/data/contentseal/originals/gen_lighthouse_watercolor.webp differ diff --git a/data/contentseal/originals/gen_night_city.webp b/data/contentseal/originals/gen_night_city.webp new file mode 100644 index 0000000..cc5b92a Binary files /dev/null and b/data/contentseal/originals/gen_night_city.webp differ diff --git a/data/contentseal/originals/gen_studio_mug.webp b/data/contentseal/originals/gen_studio_mug.webp new file mode 100644 index 0000000..73f48e3 Binary files /dev/null and b/data/contentseal/originals/gen_studio_mug.webp differ diff --git a/data/contentseal/originals/gen_text_poster.webp b/data/contentseal/originals/gen_text_poster.webp new file mode 100644 index 0000000..e3ee0aa Binary files /dev/null and b/data/contentseal/originals/gen_text_poster.webp differ diff --git a/data/evaluations/fidelity/anytext2-restoration-2026-08-13.csv b/data/evaluations/fidelity/anytext2-restoration-2026-08-13.csv index 43df6e1..8eced92 100644 --- a/data/evaluations/fidelity/anytext2-restoration-2026-08-13.csv +++ b/data/evaluations/fidelity/anytext2-restoration-2026-08-13.csv @@ -1,4 +1,4 @@ date,source_commit,fixture,variant,model,model_revision,model_sha256,ocr_route,text_cer,img_lpips,ssim,psnr_db,output_sha256,provider_oracle,notes -2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage,,,,"Paddle ch",0.074,0.289,0.696,23.3,4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621,not_run,Exact Qwen baseline reused from text-restoration benchmark +2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage,,,,"Paddle ch",0.074,0.289,0.696,23.3,4b05489c7a79f993d35d32528de792baad5a3e33ee950afa147caf5d3e0621a6,not_run,Exact Qwen baseline reused from text-restoration benchmark 2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage-anytext2-default,AnyText2-SD1.5,b06c583a583818f3679665ef67b51363f107853c,7d5d593928d842500e0da101ab6a1ae28a8d21403441ca62c813c4f92529ace8,"Paddle ch",0.185,0.338,0.642,18.2,87729fe7e0482d2cead6d3b7c7dd386ecc75ab3d8658131c54e67528b8f9feeb,not_run,Fidelity gate failed; padded crop recognition CER 0.074 still shows two substituted characters; locally composited three-line edit; 20 DDIM steps; seed 0 2026-08-13,2d03a00,Gemini_Generated_Image_633uuy633uuy633u.png,qwen-zimage-anytext2-mimic,AnyText2-SD1.5,b06c583a583818f3679665ef67b51363f107853c,7d5d593928d842500e0da101ab6a1ae28a8d21403441ca62c813c4f92529ace8,"Paddle ch",0.222,0.345,0.644,17.9,f5198d8f2365b79880aba8ea6b0c3885142b9de21c9f7f4f6712ac4a0db8dd85,not_run,Fidelity gate failed; source glyph masks supplied as font hints; 20 DDIM steps; seed 0 diff --git a/data/evaluations/video-synthid-oracle.csv b/data/evaluations/video-synthid-oracle.csv index a740419..89dd3e4 100644 --- a/data/evaluations/video-synthid-oracle.csv +++ b/data/evaluations/video-synthid-oracle.csv @@ -1,3 +1,3 @@ date,source_url,source_sha256,source_width,source_height,source_fps,duration_seconds,source_verdict,vae,noise_std,long_side,fps,seed,output_sha256,output_verdict,output_verdict_text,output_detected_range,track,session_id,stratum,psnr_db,temporal_residual_ratio -2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,1920,1080,24,8,detected_all_frames,,0.10,512,12,0,079165105d4c56e1612091987c08c2627049423025f74c0d4e245fb47c2ff0e3,detected,,,,,,26.2932,1.0072 -2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,1920,1080,24,8,detected_all_frames,,0.15,512,12,0,1c4046bcfdead138353b4e2a73339ba227bb5e544878d80c5bc6cd8427c7b00e,not_detected,,,,,,25.3911,1.0578 +2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,1920,1080,24,8,detected_all_frames,unrecorded,0.10,512,12,0,079165105d4c56e1612091987c08c2627049423025f74c0d4e245fb47c2ff0e3,detected,,,,,,26.2932,1.0072 +2026-07-31,https://storage.googleapis.com/gdm-deepmind-com-prod-public/media/media/veo__veo-3__off-road.mp4,79a552b9406a079682440c31f14d33a10ba8e1b8b2e96425f5de70f63350299d,1920,1080,24,8,detected_all_frames,unrecorded,0.15,512,12,0,1c4046bcfdead138353b4e2a73339ba227bb5e544878d80c5bc6cd8427c7b00e,not_detected,,,,,,25.3911,1.0578 diff --git a/data/fixtures/visible/README.md b/data/fixtures/visible/README.md new file mode 100644 index 0000000..cb2978c --- /dev/null +++ b/data/fixtures/visible/README.md @@ -0,0 +1,41 @@ +# Visible-mark example gallery + +One committed example per registered visible mark, so the repository carries a +working sample of everything it supports. `tests/test_visible_examples.py` holds +both sides to it: a mark registered without an example fails the suite, and so +does an engine that stops detecting its own example. + +## What these files are + +Every example is SYNTHETIC: `scripts/render_visible_examples.py` composites the +mark's committed silhouette (the same font-rendered asset the detector matches) +onto a deterministic generated base photo at the engine's measured geometry. +No user upload and no vendor asset enters the repository: corpus files under +`data/spaces/` are user content and stay out of git by policy, and the +silhouettes themselves are our own renders (`scripts/render_vendor_silhouettes.py`). + +The examples demonstrate DETECTION geometry and house style, not vendor raster +fidelity; real-world variants (fonts, opacities, sizes) are covered by the +engines' calibration cohorts, which are local-only. + +## Regeneration + + uv run python scripts/render_visible_examples.py + +The generator self-verifies: it fails (exit 1) if any registered mark does not +detect on its own example, so regeneration is the fix point for drift. + +## Layout + + /example.png 1536x2048..2048x2048 PNG, one per image mark + /example.mp4 960x540 90-frame clip, one per video mark + (kling carries both: it is registered in both registries) + +Special cases: `gemini` composites the sparkle alpha map at the provider's +configured position; `jimeng_pill` is the capture-less pill at the measured +3:4 portrait geometry; `microsoft` is the opaque white pill with dark text +holes (the discriminator its detector keys on). Video examples composite the +detector's own synthetic template on every frame; where two marks share a +shape family the example carries the discriminative variant (`veo` the legacy +text form, `kling` the logo-plus-wordmark pair flush to the edge), because the +temporal selection resolves cross-template ties by table order. diff --git a/data/fixtures/visible/baidu/example.png b/data/fixtures/visible/baidu/example.png new file mode 100644 index 0000000..ed7aa40 Binary files /dev/null and b/data/fixtures/visible/baidu/example.png differ diff --git a/data/fixtures/visible/dola/example.mp4 b/data/fixtures/visible/dola/example.mp4 new file mode 100644 index 0000000..2752ca2 Binary files /dev/null and b/data/fixtures/visible/dola/example.mp4 differ diff --git a/data/fixtures/visible/doubao/example.png b/data/fixtures/visible/doubao/example.png new file mode 100644 index 0000000..f7c9a65 Binary files /dev/null and b/data/fixtures/visible/doubao/example.png differ diff --git a/data/fixtures/visible/gemini/example.png b/data/fixtures/visible/gemini/example.png new file mode 100644 index 0000000..b3c915c Binary files /dev/null and b/data/fixtures/visible/gemini/example.png differ diff --git a/data/fixtures/visible/hailuo/example.mp4 b/data/fixtures/visible/hailuo/example.mp4 new file mode 100644 index 0000000..4129fd3 Binary files /dev/null and b/data/fixtures/visible/hailuo/example.mp4 differ diff --git a/data/fixtures/visible/jimeng/example.png b/data/fixtures/visible/jimeng/example.png new file mode 100644 index 0000000..eea71cd Binary files /dev/null and b/data/fixtures/visible/jimeng/example.png differ diff --git a/data/fixtures/visible/jimeng_pill/example.png b/data/fixtures/visible/jimeng_pill/example.png new file mode 100644 index 0000000..130fdde Binary files /dev/null and b/data/fixtures/visible/jimeng_pill/example.png differ diff --git a/data/fixtures/visible/kling/example.mp4 b/data/fixtures/visible/kling/example.mp4 new file mode 100644 index 0000000..630af26 Binary files /dev/null and b/data/fixtures/visible/kling/example.mp4 differ diff --git a/data/fixtures/visible/kling/example.png b/data/fixtures/visible/kling/example.png new file mode 100644 index 0000000..6074cb2 Binary files /dev/null and b/data/fixtures/visible/kling/example.png differ diff --git a/data/fixtures/visible/liblib/example.png b/data/fixtures/visible/liblib/example.png new file mode 100644 index 0000000..852fdb2 Binary files /dev/null and b/data/fixtures/visible/liblib/example.png differ diff --git a/data/fixtures/visible/microsoft/example.png b/data/fixtures/visible/microsoft/example.png new file mode 100644 index 0000000..9cc21b0 Binary files /dev/null and b/data/fixtures/visible/microsoft/example.png differ diff --git a/data/fixtures/visible/qwen/example.png b/data/fixtures/visible/qwen/example.png new file mode 100644 index 0000000..425e639 Binary files /dev/null and b/data/fixtures/visible/qwen/example.png differ diff --git a/data/fixtures/visible/runninghub/example.png b/data/fixtures/visible/runninghub/example.png new file mode 100644 index 0000000..0460ef8 Binary files /dev/null and b/data/fixtures/visible/runninghub/example.png differ diff --git a/data/fixtures/visible/samsung/example.png b/data/fixtures/visible/samsung/example.png new file mode 100644 index 0000000..b301d1a Binary files /dev/null and b/data/fixtures/visible/samsung/example.png differ diff --git a/data/fixtures/visible/seedance/example.mp4 b/data/fixtures/visible/seedance/example.mp4 new file mode 100644 index 0000000..fbab17d Binary files /dev/null and b/data/fixtures/visible/seedance/example.mp4 differ diff --git a/data/fixtures/visible/sora/example.mp4 b/data/fixtures/visible/sora/example.mp4 new file mode 100644 index 0000000..ce7cabd Binary files /dev/null and b/data/fixtures/visible/sora/example.mp4 differ diff --git a/data/fixtures/visible/veo/example.mp4 b/data/fixtures/visible/veo/example.mp4 new file mode 100644 index 0000000..03d92fa Binary files /dev/null and b/data/fixtures/visible/veo/example.mp4 differ diff --git a/data/fixtures/visible/yuanbao/example.png b/data/fixtures/visible/yuanbao/example.png new file mode 100644 index 0000000..c68e957 Binary files /dev/null and b/data/fixtures/visible/yuanbao/example.png differ diff --git a/data/synthid/README.md b/data/synthid/README.md index 96c01fe..1a11286 100644 --- a/data/synthid/README.md +++ b/data/synthid/README.md @@ -163,7 +163,7 @@ sparkle; the SynthID *pixel* itself has no local detector (oracle only). Key takeaways: - The same model differs by *surface*: Gemini app wraps C2PA, AI Studio (API/playground) emits none -- only the pixel + sparkle survive. -- Microsoft Designer's DALL-E backend inherits OpenAI's C2PA+SynthID (issuer "OpenAI, Microsoft"); Bing now runs Microsoft's own **MAI-Image** and signs C2PA as "Microsoft" (not OpenAI/DALL-E). +- Microsoft Designer's DALL·E backend inherits OpenAI's C2PA+SynthID (issuer "OpenAI, Microsoft"); Bing now runs Microsoft's own **MAI-Image** and signs C2PA as "Microsoft" (not OpenAI/DALL·E). - Meta uses the IPTC `digitalSourceType` marker, not C2PA or SynthID. - The open imwatermark fires only on *pristine* output from a pipeline that runs the encoder (diffusers default, official BFL) -- not from re-hosts (Krea, Stability hosted SDXL) or re-encoded design exports (Recraft, Canva). Ideogram's only signal is the EXIF `Make` tag. - Bing and Grok web UIs are uncooperative for autonomous capture (no document_idle for screenshots; blob downloads intermittently no-op; low-res in-chat previews). Use their native download button manually if a full-res sample is needed. diff --git a/demo_banana_after.png b/demo_banana_after.png index 771c3ec..0743a4b 100644 Binary files a/demo_banana_after.png and b/demo_banana_after.png differ diff --git a/docs/cli.md b/docs/cli.md index 3221ec3..9ef65f4 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -354,7 +354,7 @@ remove-ai-watermarks video visible kling.mp4 --mark kling -o kling_clean.mp4 The command supports the moving Sora mascot and wordmark, two Veo corner variants, the Seedance boxed `AI` label, the `Dola AI` text label, the -composite `MINIMAX | hailuo AI` label, and the bottom-right Kling label. Sora +composite `MINIMAX | hailuo AI` label, and the bottom-right Kling AI label. Sora searches the whole frame at multiple scales. The other detectors search bounded lower-frame regions with separate synthetic silhouettes. Kling additionally requires its bright low-saturation label near the frame edge. Every mark @@ -365,7 +365,7 @@ provenance-aware marks; metadata alone never creates a detection. `--mark auto` is the default. It evaluates all providers in one decode pass and selects the first stable match in specificity order: Sora, Veo, Seedance, Dola, -Hailuo, then Kling. Their confidence scores are independently calibrated and +Hailuo AI, then Kling AI. Their confidence scores are independently calibrated and are not compared across providers. Pass an explicit `--mark` to scan only that provider. @@ -424,6 +424,23 @@ detected. Use `--force` when you know the image should be processed: remove-ai-watermarks invisible image.png -o clean.png --force ``` +### Choose a strength cohort + +`--vendor` selects the cohort the default strength resolves from. `auto` +(the default) derives it from provenance: the C2PA issuer for +OpenAI/Google/Microsoft, and the standalone AI IPTC tag for Meta Content Seal +(Muse output carries no C2PA; the tag is a standard code, so C2PA evidence +always wins first). An explicit value both names the cohort for stripped files +and implies the scrub runs -- naming the cohort asserts the pixel watermark is +present -- exactly like `--force` plus a measured floor: + +```bash +remove-ai-watermarks invisible muse_output.webp -o clean.png --vendor meta +``` + +The same option exists on `all` and `batch`, and as +`InvisibleOptions(vendor="meta")` in the Python API. + ### Choose a pipeline | Pipeline | When to use it | @@ -454,10 +471,10 @@ user can act on rather than after a model load. ### Restore operator-verified text `--text-manifest` enables the experimental `vae-glyphs` post-pass. It reconstructs -the source with the Qwen VAE, blends 15% of that reconstruction into the normal -`qwen-zimage` result, erases the annotated candidate glyphs with LaMa, and composites -only the reconstructed glyph cores through source-derived silhouettes. It does not -run OCR or choose which strings are correct. +the source with the Qwen VAE, erases the annotated candidate glyphs with LaMa, and +composites only the reconstructed glyph cores through source-derived silhouettes. It +does not run OCR or choose which strings are correct. The optional `--fidelity-anchor` +described below additionally blends 15% of the reconstruction across the full frame. Install the combined extra and run only with an operator-verified manifest: diff --git a/docs/controlnet-removal-pipeline-research.md b/docs/controlnet-removal-pipeline-research.md index 4d5ec10..41259ab 100644 --- a/docs/controlnet-removal-pipeline-research.md +++ b/docs/controlnet-removal-pipeline-research.md @@ -644,7 +644,7 @@ photo only). **Sources.** https://arxiv.org/abs/2206.11253 (CodeFormer) · https://github.com/sczhou/CodeFormer · https://arxiv.org/pdf/2401.07519 (InstantID) · -https://openaccess.thecvf.com/content/WACV2024/papers/Suin_Diffuse_and_Restore... (region-adaptive) · +https://openaccess.thecvf.com/content/WACV2024/html/Suin_Diffuse_and_Restore_A_Region-Adaptive_Diffusion_Model_for_Identity-Preserving_Blind_WACV_2024_paper.html (region-adaptive) · https://arxiv.org/pdf/2504.12809 (saliency-aware watermark removal) ## Provenance diff --git a/docs/development.md b/docs/development.md index e102eba..378160f 100644 --- a/docs/development.md +++ b/docs/development.md @@ -1,5 +1,9 @@ # Development +Standalone evaluation, calibration, and release utilities are catalogued in +[`scripts/README.md`](../scripts/README.md). They are maintainer tools rather than +installed commands, and their local data inputs remain outside the repository. + Read this reference for environment setup, dependency recovery, CI behavior, and fixture policy. The always-loaded invariants remain in [`.claude/rules/development.md`](../.claude/rules/development.md). ## Local environment diff --git a/docs/installation.md b/docs/installation.md index c87d6c4..7dea494 100644 --- a/docs/installation.md +++ b/docs/installation.md @@ -115,6 +115,7 @@ application actually uses: | `lama` | big-LaMa ONNX fill backend | `visible`, ONNX Runtime | Model download, no Torch | | `qwen-zimage` | Invisible image-watermark removal, both CUDA-only profiles | `diffusion`, DiffSynth | Yes | | `text-restoration` | Opt-in verified Qwen-VAE glyph restoration | `qwen-zimage`, `lama` | Yes | +| `text-draft` | Draft OCR proposals for operator verification | PaddleOCR, PaddlePaddle | Model download, no Torch | | `all` | Every production feature available on the active Python | All compatible rows above | Yes | | `dev` | Tests, linting, typing, and upstream parity checks | `video`, `detect`, upstream invisible-watermark | Yes, for parity tests | @@ -131,13 +132,16 @@ flowchart LR qwen["qwen-zimage"] --> diffusion text["text-restoration"] --> qwen text --> lama + draft["text-draft"] heif trustmark verify ``` -`heif`, `trustmark`, and `verify` are independent branches. Combine them explicitly with -another feature when required. TrustMark requires NumPy 1.x, which has no +`heif`, `trustmark`, and `text-draft` are independent branches. Combine them +explicitly with another feature when required. `text-draft` is excluded from +`all` because it proposes unverified OCR annotations and is not a production +removal path. TrustMark requires NumPy 1.x, which has no CPython 3.13 or 3.14 wheels, so that branch is available only on Python 3.11-3.12. The `all` bundle contains every production branch compatible with the active Python and never includes `dev`. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 0199966..fd09282 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -31,11 +31,14 @@ an unsupported locale, a different position, or a crop may be missed. Known examples: +- The Microsoft detector covers one calibrated top-right white pill. Microsoft's + [documented feature](https://support.microsoft.com/en-us/topic/include-a-watermark-when-content-from-microsoft-365-is-ai-generated-b00a656e-ae61-4692-8086-67d004421030) + can instead use a Copilot icon, `AI-Generated` text, or another position. - Samsung detection is calibrated for the Italian `Contenuti generati dall'AI` text variant. - The Jimeng top-left pill has a weak visual detector and is intentionally subject to additional product and background checks. -- Kling support covers the calibrated variants rather than every Kling label. +- Kling AI support covers the calibrated variants rather than every Kling AI label. Use `erase --region` when you can see and select an unsupported or missed mark. @@ -113,6 +116,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 @@ -304,11 +315,11 @@ supported AI provenance metadata without transcoding streams. `video visible` and `remove_video_visible` additionally support the moving Sora 2 mascot and wordmark, the current Veo four-point diamond, the legacy -`Veo` text, the Seedance boxed `AI` label, the fixed `Dola AI` text, the Hailuo -MINIMAX/Hailuo composite label, and the bottom-right Kling label with its +`Veo` text, the Seedance boxed `AI` label, the fixed `Dola AI` text, the Hailuo AI +MINIMAX/Hailuo AI composite label, and the bottom-right Kling AI label with its version suffix. Detection requires a recurring visual candidate across adjacent frames. Fixed-mark candidates must remain anchored rather than -drifting with a scene object. Kling also requires a bright low-saturation +drifting with a scene object. Kling AI also requires a bright low-saturation candidate near the expected frame edge. Provider provenance can recover low-contrast runs only after visual evidence exists for the marks that define a provenance prior, so metadata alone does not erase a clean API export. @@ -319,7 +330,7 @@ policies and selects the first stable result in specificity order. Use an explicit mark when the provider is already known. Historical Sora Turbo exports use a small OpenAI swirl in the corner rather than the moving mascot-and-wordmark design; that earlier variant is not -detected by the `sora` video mark. Hailuo and Kling coverage is specific to the +detected by the `sora` video mark. Hailuo AI and Kling AI coverage is specific to the verified lower-edge layouts; a new provider layout needs a separate calibrated silhouette. Other provider video labels are not supported yet. Google video SynthID has an oracle-certified VAE removal path, while other proprietary @@ -410,7 +421,7 @@ remain identical because isolated decoder hits can otherwise be content noise. It accepts Variant P schemas 0-2. Variant Q requires a different model, and schema 3 is rejected at the measured precision threshold. Its NumPy 1.x runtime limits the extra to Python 3.11-3.12; the rest of the package remains supported -through Python 3.14. Through 0.29.0 the extra also resolves lightning 2.6.5 +through Python 3.14. The current TrustMark dependency line also resolves lightning 2.6.5 (PYSEC-2026-3624, no fixed release yet); the vulnerable `load_from_checkpoint` path is unreachable here because TrustMark loads its checksummed checkpoints with plain `torch.load`. Bump lightning and cut a patch release when a fix diff --git a/docs/module-internals.md b/docs/module-internals.md index 53f7b96..74d3400 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -296,13 +296,13 @@ from the gate. The full-clip oracle floor is detected while `0.15` did not. [`video_visible.py`](../src/remove_ai_watermarks/video_visible.py) implements -the first pixel stages for Sora, Veo, Seedance, Dola, Hailuo, and Kling. The +the first pixel stages for Sora, Veo, Seedance, Dola, Hailuo AI, and Kling AI. The Sora detector searches a normalized frame with a fully synthetic mascot-and-text silhouette at several scales. The Veo detector uses separate synthetic silhouettes for the current four-point diamond and legacy `Veo` text. Seedance uses a synthetic rounded boxed-`AI` silhouette, while Dola uses -an OpenCV-font `Dola AI` silhouette. Hailuo uses a synthetic waveform, -MINIMAX/Hailuo text, separator, and ring. Kling combines synthetic font +an OpenCV-font `Dola AI` silhouette. Hailuo AI uses a synthetic waveform, +MINIMAX/Hailuo AI text, separator, and ring. Kling AI combines synthetic font variants with a ring approximation of its swirl; the logo path rescues wordmarks whose version or font differs, while the edge and white-label gates reject recurring scene texture. All fixed-mark searches are bounded to the @@ -323,23 +323,23 @@ Every per-frame result is untrusted. Each provider's floors, minimum-run policy, fill padding and mask style are one row in `VISIBLE_MARK_POLICIES`, and every mark enters the same `stabilize_localizations` entry point; the recurrence implementation underneath knows nothing about providers. That policy row also -carries `accepts_provenance`, which forces `provenance=False` for Hailuo and Kling +carries `accepts_provenance`, which forces `provenance=False` for Hailuo AI and Kling AI — they have no metadata that could confirm them, and the guarantee used to be structural (their wrappers took no `provenance` parameter at all). Provenance can relax a low-contrast run only after recurring visual evidence exists. Sora transition frames follow the nearest confirmed moving position only with Sora provenance. Seedance, Dola, -Hailuo, and Kling additionally require candidates to remain anchored to the +Hailuo AI, and Kling AI additionally require candidates to remain anchored to the start of a run. This rejects slowly drifting scene details that still have -high frame-to-frame overlap. Hailuo and Kling do not infer provenance from +high frame-to-frame overlap. Hailuo AI and Kling AI do not infer provenance from technical encoder tags; their confirmed public samples carried no provider metadata. Removal runs in a second decode pass. Sora, legacy Veo text, Dola text, -Seedance, Hailuo, and Kling use box masks. Seedance deliberately fills the +Seedance, Hailuo AI, and Kling AI use box masks. Seedance deliberately fills the complete localized box: a synthetic outline mask passed repeat detection but left part of the real translucent border visible during visual end-to-end -review. Hailuo expands beyond the matched core to cover both provider icons. -Kling expands around the wordmark or swirl to include the version and optional +review. Hailuo AI expands beyond the matched core to cover both provider icons. +Kling AI expands around the wordmark or swirl to include the version and optional `PRO` suffix. The square Veo diamond uses a synthetic shape mask so transparent corners do not erase unrelated pixels. Every mask goes through the shared `watermark_registry.fill` backends. ffmpeg encodes the changed video stream and @@ -451,7 +451,9 @@ For an AI C2PA claim, a recognized product in `claim_generator` takes precedence over the certificate issuer: an application can sign through an upstream model provider without becoming that provider's product. Only exact product mappings receive this precedence; an unknown claim generator still falls back to issuer -attribution. +attribution. An unmapped issuer org reads as unknown-signer C2PA with no platform; +that is how Ideogram was surfaced (4 corpus uploads signed "Ideogram, Inc", +2026-08-08) before its vendor row was added on 2026-08-27. ### Metadata scanning and stripping @@ -1121,9 +1123,9 @@ capture-less pill is too weak to attribute. The set of marks that veto the pill is DERIVED from the registry rows: every mark under the same label regime (`tc260`) belonging to a different product. It used to -be a hand-written list of keys, and that list drifted -- LibLibAI was registered -alongside RunningHub and Baidu, both of which were added to it, and LibLibAI was -not, so a confident LibLibAI detection did not suppress the pill the way its two +be a hand-written list of keys, and that list drifted -- LiblibAI was registered +alongside RunningHub and Baidu, both of which were added to it, and LiblibAI was +not, so a confident LiblibAI detection did not suppress the pill the way its two siblings did. Marks outside the TC260 regime (Gemini, Samsung) are deliberately not vetoers: neither can put `jimeng` into `provenance`, so neither can enable the arm it would be vetoing. @@ -1175,6 +1177,33 @@ be represented by the shared base: - [`runninghub_engine.py`](../src/remove_ai_watermarks/runninghub_engine.py) - [`baidu_engine.py`](../src/remove_ai_watermarks/baidu_engine.py) - [`liblib_engine.py`](../src/remove_ai_watermarks/liblib_engine.py) +- [`microsoft_engine.py`](../src/remove_ai_watermarks/microsoft_engine.py) + +The measured Microsoft badge variant (2026-08-27 registration) is the first +`tr`-corner mark and the first `long`-side scale basis: the pill tracks the +render dimension, so a +1024x1536 portrait carries the same pill as 1536x1024, and a width basis +undersized the template by the aspect ratio (portrait carriers fell to +0.15-0.32 NCC until the basis was measured). The silhouette is a white pill with +its synthetic internal shapes knocked out - the holes are what separate it from +any other bright rounded corner element (a plain white pill scores below the gate +in the tests). It does not claim coverage of Microsoft's other documented icon, +wording, or position variants. + +The 2026-08-27 rerun used the registered engine through +`scripts/registered_mark_calibrate.py`, rather than a copied detector +configuration. The manifest kept three evidence classes separate: 17 visually +confirmed carriers, 343 Microsoft-provenance files without a visual adjudication, +and 1200 non-overlapping no-signal controls. At the strict 0.38 gate, 15/17 +confirmed carriers fired (min 0.249, p50 0.519, p90 0.578, max 0.579), while +0/1200 controls fired (p99 0.200, max 0.293). The provenance cohort produced +78/343 fires, but that is not a recall measurement because provenance identifies +the provider, not the presence of this visible layout. That cohort was later +labeled by an OCR badge census (2026-08-28: 86 badge carriers, 257 badge-less, +badge-less max 0.251 / p99 0.213), which is what enabled the shipped provenance +relaxation 0.7: the relaxed band [0.251, 0.38) holds three faint badges and +zero false fills, re-verified as 3 band detections with 0/257 badge-less fires +on the provenance path. The detector and removal mask must use compatible geometry. A detector that fires while producing an empty or misplaced mask is a removal failure even if @@ -1193,7 +1222,7 @@ were briefly skipped there for exactly that reason. A mark whose removable footprint differs from what the detector localizes overrides `_footprint_rect` (which policy) and `_extend_match_box` (how far the box grows), not the whole `footprint_mask`. Baidu extends right to the corner tag -and LibLibAI extends left to the triangle logo; both inherit every guard around +and LiblibAI extends left to the triangle logo; both inherit every guard around that arithmetic. Yuanbao uses the polarity-independent `contrast` front end because its standard @@ -1395,11 +1424,13 @@ Validation happens before model loading. The library never treats OCR confidence verification, and geometry-only operators do not need to invent text or script fields. When enabled, `QwenZImagePipeline` reconstructs the source once through its already -loaded Qwen VAE, runs the ordinary global and face stages, blends 15% of the VAE -reconstruction into that clean result, and calls the shared restoration compositor. -The compositor derives binary source and candidate silhouettes, groups nearby lines, -uses LaMa for the initial and residual-glyph erase passes, paints fresh silhouette -edges, then copies the Qwen-VAE core with a 0.5-pixel feather. The evaluation script +loaded Qwen VAE, runs the ordinary global and face stages, and calls the shared +restoration compositor. The optional fidelity anchor first blends 15% of the VAE +reconstruction into the clean result; it is off by default because the blend returned +detector-visible OpenAI SynthID in the measured poster fixtures. The compositor derives +binary source and candidate silhouettes, groups nearby lines, uses LaMa for the initial +and residual-glyph erase passes, paints fresh silhouette edges, then copies the Qwen-VAE +core with a 0.5-pixel feather. The evaluation script imports these same mask and compositing helpers so the two implementations cannot silently drift. Silhouette crops start 12% of line height beyond each horizontal side, then expand each side independently while a foreground component anchored @@ -1524,6 +1555,44 @@ 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**. Shipped as +`QWEN_ZIMAGE_META_STRENGTH` with two routing paths: auto mode routes a file +whose only provenance is the standalone AI IPTC tag onto the cohort +(`vendor_for_strength` checks C2PA issuers first, so Google/OpenAI/Microsoft +evidence always wins, and the tag's other users ship no invisible watermark +this profile targets), and `--vendor meta` / `InvisibleOptions.vendor` names the +cohort explicitly on stripped files, implying the scrub runs (naming the cohort +asserts the watermark is present). sdxl-zimage has no measured Meta rung and an +explicit meta vendor there falls to the conservative unknown 0.25. The default +resolution-adaptive curve (~0.1305 at 2.56 MP) also clears every measured +source, so default behavior needed no change. Oracle verdicts carry a +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 diff --git a/docs/python-api.md b/docs/python-api.md index e92be04..f449975 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -129,6 +129,18 @@ raiw.remove_all( ) ``` +`vendor="meta"` names a strength cohort explicitly (the measured Content Seal +floor) for a stripped file whose provenance no longer carries the AI IPTC tag, +and implies the scrub runs: + +```python +raiw.remove_all( + "muse_output.webp", + "clean.png", + invisible=InvisibleOptions(vendor="meta"), +) +``` + `InvisibleOptions` carries only what `InvisibleEngine` itself takes, and uses the engine's own parameter names and defaults. `force`, which decides whether the engine runs at all, is a parameter of `remove_all` and `remove_batch` alongside @@ -568,8 +580,8 @@ independently and are not compared across detectors. Pass one of those explicit values to restrict the scan to a single provider. The Veo detector recognizes the current four-point diamond and the legacy `Veo` text. Seedance recognizes the boxed `AI` label, Dola recognizes -its compact text label, Hailuo recognizes the composite MINIMAX/Hailuo label, -and Kling recognizes its bottom-right logo, wordmark, and version suffix. Each +its compact text label, Hailuo AI recognizes the composite MINIMAX/Hailuo AI label, +and Kling AI recognizes its bottom-right logo, wordmark, and version suffix. Each variant has an independent synthetic silhouette and calibrated temporal policy. After each accepted frame is filled, `temporal_consistency=True` motion-aligns the preceding accepted fill and blends it only when the warped prior mask @@ -649,10 +661,10 @@ engine.remove_watermark( Install `remove-ai-watermarks[text-restoration]`. The manifest schema and safety constraints are documented in the CLI guide. The engine verifies its decoded RGB hash before loading the diffusion models and rejects SDXL, downscaling, and -postprocessing combinations that were not evaluated. Tiling is allowed: the VAE -donor uses the same overlapping tiles as the global pass. `InvisibleOptions` exposes the -same field for `remove_all`; after a visible-stage edit, the manifest must be built -against the staged pixels rather than the pristine source. +postprocessing combinations that were not evaluated. Tiling is also rejected because +the combined tiled-restoration path has no provider-oracle calibration. `InvisibleOptions` +exposes the same field for `remove_all`; after a visible-stage edit, the manifest must +be built against the staged pixels rather than the pristine source. Use manifest schema 1 for manually reviewed text plus script metadata. Automated operators that verify only text-region geometry should emit schema 2 lines with a diff --git a/docs/release-and-distribution.md b/docs/release-and-distribution.md index 71cea0f..3087ba8 100644 --- a/docs/release-and-distribution.md +++ b/docs/release-and-distribution.md @@ -29,6 +29,10 @@ The package version appears in: Update the first two, then refresh the lock file with uv. Do not edit a line-number-specific location in `uv.lock`; its package order changes. +Before choosing a version, verify it is absent from git tags, GitHub Releases, +and PyPI. An unpublished remote tag still reserves that version; never move it, +publish the next version instead. + ## Publish flow PyPI publishing is triggered by a published GitHub Release, not by a tag push diff --git a/docs/supported-signals.md b/docs/supported-signals.md index 84c2dc9..8e25b2a 100644 --- a/docs/supported-signals.md +++ b/docs/supported-signals.md @@ -10,7 +10,7 @@ The `visible` command registers these mark keys: | Key | Mark | Expected area | Important limit | | --- | --- | --- | --- | -| `gemini` | Google Gemini sparkle | Usually bottom right | Detection includes a false positive gate. | +| `gemini` | Google Gemini visible watermark (sparkle) | Usually bottom right | Detection includes a false positive gate. | | `doubao` | `豆包AI生成` | Bottom right | Vendor specific text detector. | | `jimeng` | `★ 即梦AI` | Bottom right | Vendor specific text detector. | | `qwen` | `千问AI生成` | Bottom right | Strict visual gate. | @@ -19,7 +19,8 @@ The `visible` command registers these mark keys: | `samsung` | `✦ Contenuti generati dall'AI` | Bottom left | Calibrated for the Italian text variant. | | `runninghub` | `RunningHub AI生成` | Top left | Strict visual and position gates. | | `baidu` | `百度 AI生成` | Bottom right | Detector and extended removal footprint. | -| `liblib` | `LibLibAI` | Bottom center | Includes a minimum image size gate. | +| `liblib` | `LiblibAI` | Bottom center | Includes a minimum image size gate. | +| `microsoft` | One Microsoft white AI-badge variant | Top right | Strict gate; other documented icon, text, and position variants are not covered. | | `jimeng_pill` | `AI生成` pill | Top left | Weak detector with additional product and background gates. | `--mark auto` evaluates all registered marks and removes every selected match. @@ -38,7 +39,7 @@ when you can select the affected area yourself. | `seedance` | Boxed `AI` label | Fixed bottom-right corner | Requires an anchored recurring match; the full localized box is filled because a thinner synthetic shape mask leaves the real translucent rim behind. | | `dola` | `Dola AI` text | Fixed bottom-right corner | Requires an anchored recurring match; ByteDance or BytePlus provenance can relax only an existing visual run. | | `hailuo` | `MINIMAX \| hailuo AI` composite label | Fixed lower edge | Uses a synthetic waveform, text, separator, and ring silhouette; the complete recurring label box is filled. A TC260 label naming MiniMax as producer can relax only an existing stable run. | -| `kling` | Kling swirl, `KLING AI`, version, and optional `PRO` suffix | Fixed bottom-right edge | Combines a synthetic logo rescue with font variants, an edge gate, a white-label gate, and anchored temporal recurrence. | +| `kling` | Kling AI swirl, `KLING AI`, version, and optional `PRO` suffix | Fixed bottom-right edge | Combines a synthetic logo rescue with font variants, an edge gate, a white-label gate, and anchored temporal recurrence. | `video identify`, `video visible`, and `video all` share this registry and the same temporal arbiter. It is separate from the image registry because selection @@ -163,7 +164,47 @@ 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. +verification path. The API needs Azure credentials; the page a human can check +without an account is . Its verdict is +weaker than the API's: it collapses watermark and C2PA into one rendered result +and tops out at `Inconclusive` rather than a watermark-negative, so treat +`Inconclusive` on a processed file as "not confirmed", not as "detected still". + +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. Since +0.33.0 a standalone AI digital-source tag (no C2PA manifest) additionally emits +the additive `content_seal` signal - the strength router's Meta bet as evidence, +medium confidence, with the same caveat - so clients select pixel removal from +the signal list exactly the way InvisMark is additive over `soft_binding`. It +is an attribution, never a decode. The +external oracle is `https://meta.ai/identification`: anonymous, no login, +accepts image, video, and audio, enforces an unspecified daily identification +limit, and answers with model attribution (`Muse Image 1 - Meta`) plus a +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). +The derived Meta floor (0.1 by the standard spread method) ships as a measured +cohort: auto mode routes a file whose only provenance is the standalone AI IPTC +tag to it (the tag is not Meta-exclusive; other tag users ship no invisible +watermark this profile targets, and Google/OpenAI/Microsoft C2PA evidence +always wins first), and `invisible --vendor meta` (also `all` and `batch`, and +`InvisibleOptions.vendor` in the API) names the cohort explicitly on stripped +files. An explicit vendor implies the scrub runs: naming the cohort asserts the +pixel watermark is present. +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 @@ -184,8 +225,10 @@ not a universal clean verdict. | --- | --- | --- | --- | | Google Gemini | Sparkle | Diffusion regeneration | 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 | Official remote pixel verifier; diffusion regeneration for supported invisible signals | C2PA and generator provenance | +| OpenAI image generators | None registered | Diffusion regeneration for supported invisible signals | C2PA and generator provenance | +| Meta Muse Image | None on Muse output (legacy `Imagined with AI` unregistered) | Diffusion regeneration for Content Seal, oracle-verified on the default profile | XMP IPTC `trainedAlgorithmicMedia` companion tag; no local Content Seal decoder | | Microsoft Paint and Photos | None registered | External Microsoft oracle for InvisMark; no validated local decoder | Paint C2PA soft-binding algorithm and identifier | +| Microsoft image outputs (measured variant) | One top-right white AI-badge variant | No registered pixel decoder | C2PA attribution | | Stable Diffusion and SDXL | None registered | Diffusion regeneration; optional open decoder | Embedded parameters and text metadata | | FLUX | None registered | Diffusion regeneration; optional open decoder | C2PA for supported sources | | Adobe Firefly | None registered | Optional TrustMark Variant P decoder | C2PA | @@ -193,10 +236,10 @@ not a universal clean verdict. | Luma AI | None registered | No registered pixel decoder | PNG text generator tags (Uni-1) | | ByteDance generators | Doubao and Jimeng marks | No registered pixel decoder | TC260 AIGC, supported C2PA, and exact app-export AIGC disclosures | | Qwen | Qwen mark | No registered pixel decoder | TC260 AIGC | -| Kling | Kling image and video marks | No registered pixel decoder | TC260 AIGC | -| Hailuo / MiniMax video | Hailuo composite video label | No registered pixel decoder | TC260 AIGC where present | +| Kling AI | Kling AI image and video marks | No registered pixel decoder | TC260 AIGC | +| Hailuo AI / MiniMax video | Hailuo AI composite video label | No registered pixel decoder | TC260 AIGC where present | | Baidu | Baidu mark | No registered pixel decoder | TC260 AIGC | -| LibLibAI | LibLibAI mark | No registered pixel decoder | TC260 AIGC | +| LiblibAI | LiblibAI mark | No registered pixel decoder | TC260 AIGC | | RunningHub | RunningHub mark | No registered pixel decoder | TC260 AIGC | | Samsung Galaxy AI | One locale specific mark | No registered pixel decoder | C2PA and Samsung markers | diff --git a/docs/verification-plan.md b/docs/verification-plan.md index d61f8f8..1053daa 100644 --- a/docs/verification-plan.md +++ b/docs/verification-plan.md @@ -24,7 +24,7 @@ Performance depends on format, enabled extras, and hardware. Measure it locally ## Tier A -- self-evident oracles (full corpus, unattended) -Properties that are true or false without anyone labelling anything. These are the +Properties that are true or false without anyone labeling anything. These are the backbone: they scale to large local datasets and catch regressions with zero human cost. ### A1. Recorded-verdict regression @@ -128,7 +128,7 @@ which read like twelve broken features. It was one bad harness parameter (`--ste sitting on top of one real bug. An error that is IDENTICAL across unrelated rows is evidence of a common cause, not of many faults -- check the shared input first. -## Tier B -- constructed ground truth (automatable, no labelling) +## Tier B -- constructed ground truth (automatable, no labeling) Where reality gives no answer key, build one. This is the tier that closes the two biggest holes: fill quality and detector response at the edge of the operating range. @@ -169,7 +169,7 @@ Peak RSS and wall time per backend x input size, up to 25 MP. The memory-constra tier is a real constraint (MI-GAN must stay ~0.6-0.9 GB by cropping around the mask); a regression here is invisible today and would only surface under load. -## Tier C -- human-labelled accuracy (bounded by labelling effort) +## Tier C -- human-labeled accuracy (bounded by labeling effort) The machinery exists: `visible_recall_sample.py` -> `visible_sheets.py` -> `visible_groundtruth.py` -> `visible_eval.py`. @@ -298,8 +298,8 @@ on one file. The bar is never "handles it" but **never raises and never silently 4. **B2 detector curves** -- cheap, and directly guards the geometry class of bug. 5. **A5 contract sweep over a representative local set**. 6. **B4 resource ceilings**, **E robustness**. -7. **C recall expansion** -- gated by labelling appetite. -8. **D oracles** -- authorized and provider-specific, per release. +7. **C recall expansion** -- gated by labeling appetite. +8. **D oracles** -- manual, per release. Every tier writes a versioned snapshot so runs are comparable over time; a run that cannot be diffed against the last one is a one-off, not a regression suite. @@ -333,7 +333,7 @@ Per mark, what actually goes away when metadata is stripped: ### Measure before improving -Use Tier B2 detector-response curves before tuning a detector with sparse labelled +Use Tier B2 detector-response curves before tuning a detector with sparse labeled examples. Sweep size, contrast, aspect, and background texture so geometry regressions are visible without exposing private evaluation statistics. diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index eeafcbb..11f5fa2 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.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 (Copilot + Designer; Bing Image Creator collected 2026-05-24 still signs as "Microsoft" and now runs **MAI-Image**, NOT OpenAI/DALL-E), **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`), and **Canva** (Magic Media signs C2PA as "Canva" + `trainedAlgorithmicMedia` with a generic `c2pa-rs` claim generator, no SynthID — issuer `b"Canva"` → "Canva (Magic Media)"; verified samples disproved the earlier assumption that Canva downloads always strip C2PA). Still unsampled: Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag). +- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Copilot + Designer; Bing Image Creator collected 2026-05-24 still signs as "Microsoft" and now runs **MAI-Image**, NOT OpenAI/DALL·E), **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`), and **Canva** (Magic Media signs C2PA as "Canva" + `trainedAlgorithmicMedia` with a generic `c2pa-rs` claim generator, no SynthID — issuer `b"Canva"` → "Canva (Magic Media)"; verified samples disproved the earlier assumption that Canva downloads always strip C2PA). Still unsampled: Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag). **Samsung Galaxy AI** signs supported edits with C2PA and may carry the proprietary `genAIType` marker. The registered visible detector covers the @@ -28,7 +28,7 @@ take precedence over issuer attribution. Supported mappings include Higgsfield AI, Topaz Labs Image API, and TikTok Ad Creative Toolbox; an unknown claim generator still falls back to the certificate issuer. -**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the same content as **"Byteplus Pte. Ltd."**. The bare `volcengine` needle missed it, so BytePlus output was mis-attributed to "Adobe Firefly" through an incidental "Adobe XMP" toolkit string. Issuer `b"Byteplus"` now maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance (Dreamina)" with **`asserts_ai=True`**. Registering the broader **issuer** `b"Bytedance Pte"` was deliberately avoided because that same entity also signs non-AI CapCut edits; keying on the `Dreamina` generator token is precise. +**ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance Volcano Engine"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. ByteDance's **international brand (BytePlus / Seedream / Seededit)** signs the same content as **"Byteplus Pte. Ltd."**. The bare `volcengine` needle missed it, so BytePlus output was mis-attributed to "Adobe Firefly" through an incidental "Adobe XMP" toolkit string. Issuer `b"Byteplus"` maps directly to "BytePlus (ByteDance)". ByteDance's consumer app **Dreamina** (the international Jimeng brand) signs as **"Bytedance Pte. Ltd."** with a `Dreamina/x.y` claim generator but, unlike the Volcano Engine surface, ships **no `trainedAlgorithmicMedia`**. Issuer `b"Dreamina"` maps to "ByteDance Dreamina" with **`asserts_ai=True`**. Registering the broader **issuer** `b"Bytedance Pte"` was deliberately avoided because that same entity also signs non-AI CapCut edits; keying on the `Dreamina` generator token is precise. - **EXIF/XMP/PNG-text generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal). Additional verified generator stamps include **NovelAI** (`Software`, `Source`, and `Title` PNG text chunks), **Reve** (`Software` or XMP `CreatorTool` = `reve.com`), and **Aphrodite AI** (`Make` or `Software` = `Aphrodite AI`). - **App-export provenance and AIGC JSON:** supported ByteDance-family exports can place a JSON object in EXIF `ImageDescription` or `UserComment`, independently of C2PA or TC260. Exact `product` values for Doubao, Xinghui, and Dreamina are removable product provenance, but do not alone prove that the pixels were generated. Dreamina additionally requires `exportType=generation` for that verdict. A nested Aweme `aigc_type=1` or private ByteDance `aigc_label_type=1` / `2` is an AIGC disclosure; `0` is inconclusive and can occur on a Dreamina generation export. Plain Aweme, retouch, and `lv` exports are preserved. The lower-case private field is deliberately not interpreted as the normative TC260 `Label`, whose values `1` / `2` / `3` mean generated / possibly generated / suspected generated under [GB 45438-2025](https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf). - **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).** @@ -58,7 +58,7 @@ defines two more native serializations. AVI stores an `AIGC` child in bounded RIFF and FLV readers validate the JSON field set and skip media payloads. Removal remuxes either container through ffmpeg with stream copy. -- **HuggingFace-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** HuggingFace Jobs / Spaces can stamp generated PNGs with an `hf-job-id` tEXt chunk holding the job UUID. It marks the *hosting job*, not a model, so it lifts an Unknown verdict to a tentative AI via `hf_only` but never overrides a hard metadata signal. `_HF_JOB_CAVEAT` states the limit. Removal drops the chunk through the PNG metadata whitelist. +- **Hugging Face-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** Hugging Face Jobs / Spaces can stamp generated PNGs with an `hf-job-id` tEXt chunk holding the job UUID. It marks the *hosting job*, not a model, so it lifts an Unknown verdict to a tentative AI via `hf_only` but never overrides a hard metadata signal. `_HF_JOB_CAVEAT` states the limit. Removal drops the chunk through the PNG metadata whitelist. - **No detectable signal on some downloads:** Recraft exports and some hosted FLUX surfaces can arrive without a supported local signal. Midjourney samples may carry IPTC metadata but no registered C2PA or pixel watermark. The open @@ -66,11 +66,11 @@ payloads. Removal remuxes either container through ffmpeg with stream copy. 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, 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 +- **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. The no-account web page for the same check is (its `Inconclusive` verdict is weaker than the API's separate watermark result). 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 - visible Sora, Veo, Seedance, Dola, Hailuo, and Kling removal. Other visible + visible Sora, Veo, Seedance, Dola, Hailuo AI, and Kling AI removal. Other visible video logos and proprietary audio-watermark detection remain outside the package. Metadata stripping for supported audio containers is a separate implemented @@ -116,12 +116,12 @@ every frame, then requires an anchored temporal run. This extra anchor check was necessary because a moving clean scene detail could retain enough adjacent overlap to pass a recurrence-only gate. -**Hailuo and Kling use larger fixed composite labels.** Verified Hailuo exports -carry a lower-edge waveform, `MINIMAX`, separator, Hailuo ring, and -`hailuo AI` text. Verified Kling exports carry a bottom-right swirl, +**Hailuo AI and Kling AI use larger fixed composite labels.** Verified Hailuo AI exports +carry a lower-edge waveform, `MINIMAX`, separator, Hailuo AI ring, and +`hailuo AI` text. Verified Kling AI exports carry a bottom-right swirl, `KLING AI`, a changing version suffix, and sometimes `PRO`. The detectors use -only synthetic primitives and fonts. Hailuo expands the matched core to cover -the complete composite. Kling combines a version-independent text core with a +only synthetic primitives and fonts. Hailuo AI expands the matched core to cover +the complete composite. Kling AI combines a version-independent text core with a synthetic ring rescue, then requires the recurring candidate to reach the expected frame edge and contain enough bright low-saturation pixels. Those extra gates were added after clean Luma and PixVerse scene details passed shape @@ -131,17 +131,17 @@ instead uses Florence-2 to identify arbitrary watermarks before LaMa inpainting. That is broader, but it carries a much heavier model and a less auditable detection boundary than the provider-specific synthetic path here. -**The faint-visible-mark precision/recall wall is fundamental, not a heuristic artifact.** The visible-watermark-detection literature has moved to LEARNED segmentation / object-detection (WDNet WACV'21 arXiv:2012.07616; SLBR ACM MM'21, open code+weights; the PRCV'18 large-scale detector; Su et al. survey 2025), but three verified findings bound what a learned detector actually buys: (1) a claim that a confidence threshold "cleanly separates" true from false matches even with a learned CNN front-end was **REFUTED** in verification (arXiv:1705.08593) -- the precision/recall wall persists even with learned features. (2) Learned detectors need a LARGE, pattern-diverse labeled dataset trained on synthetic composites (PRCV'18: 60k images / 80 watermark classes; CLWD: 60k / 160 marks), and off-distribution degradation is a documented real axis (models trained on limited-pattern LVW transfer worse; diversity of training patterns drives generalization). (3) Inference is cheap (WDNet ~8 ms at 256x256) -- the cost is the data pipeline, not runtime. Net: a learned detector shifts the frontier but does NOT remove the wall; for a SINGLE mark the cheapest next step is a small patch classifier (real-sparkle vs false-positive) on top of the existing NCC localizer, not a full segmentation model. SLBR is a ready baseline. The current NCC + false-positive gate (core-ring brightness margin + gradient-NCC crispness + white-core saturation) is a sound operating point, and the residual miss is the information-theoretic wall the literature confirms. +**Learned detectors change the visible-mark precision/recall tradeoff; they do not establish a universal separator.** The visible-watermark-detection literature has moved to learned segmentation and object detection (WDNet WACV'21 arXiv:2012.07616; SLBR ACM MM'21, open code and weights; the PRCV'18 large-scale detector; Su et al. survey 2025). The cited arXiv:1705.08593 paper reports a method that significantly reduces false matches and eliminates them after rejecting a small fraction of matches on its electron-microscopy task; it does not prove an information-theoretic limit for watermark detection. Learned watermark detectors also rely on large, pattern-diverse labeled datasets built with synthetic composites (PRCV'18: 60k images / 80 watermark classes; CLWD: 60k / 160 marks), and transfer depends on the diversity represented in training. Inference can be cheap (WDNet reports roughly 8 ms at 256x256), while the labeled-data and evaluation pipeline remain the larger project cost. A learned detector is therefore a possible future operating point, not evidence that the current NCC gate is theoretically optimal. -**Visible-mark landscape beyond the registry.** 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 belongs to the pre-2026 Meta AI / "Imagine" pipeline and remains unregistered (string verified, position not). For third-party images Meta relies on C2PA / IPTC, not a visible mark. The Samsung detector covers only the measured Italian text wordmark; no separate icon-only variant has been verified. Visible overlays and embedded metadata can usually be removed without changing the underlying generation model, which is the tool's premise. **Regulatory driver -- China GB 45438-2025 is the strongest VISIBLE-mark mandate.** The CAC / TC260 "Measures for Labeling AI-Generated Synthesized Content" (issued March 2025, **effective 2025-09-01**, technical standard **GB 45438-2025**, building on the TC260 Aug-2023 practice guide) MANDATE a **visible** label for AI images -- a visible textual mark whose height must be **>= 5% of the image's shortest side** -- plus the metadata (implicit) label. Several such CJK text marks are now registered; see [supported signals](supported-signals.md) for the current list. By contrast EU AI Act Article 50 mandates only the MACHINE-READABLE mark (enforceable 2026-08-02, grace to 2026-12-02); a visible label is proposed and modality-specific (visible for images) but is NOT a hard "fixed icon" mandate -- a claim that Art 50 requires a clearly-visible fixed icon for images was refuted in verification. Primary-source dates verified against the article/standard text, not search summaries. ## Uncovered visible marks: implementation specs (deep-research 2026-07-18) -Compatibility testing showed that TC260-labelled images can still produce no visible-mark +Compatibility testing showed that TC260-labeled images can still produce no visible-mark detection. The main causes were a fixed Doubao localization defect and genuinely -uncovered vendors. Verification status is labelled per claim; treat (b)/(c) as leads, +uncovered vendors. Verification status is labeled per claim; treat (b)/(c) as leads, not ground truth. **GB 45438-2025 clause 5.2, the binding constraint for every Chinese mark (VERIFIED (a) -- full standard text extracted from the TC260-hosted PDF).** Verbatim requirements for an image's explicit label: @@ -153,7 +153,7 @@ not ground truth. Two consequences we can exploit: (1) the 5% floor is a **scale prior** -- a compliant CN mark's glyphs are large (>= 51 px on a 1024² image), so a CN silhouette ladder can be anchored at ~5-10% of the short side instead of swept broadly, which should cut false fires; (2) every compliant string shares the tail `AI生成` / `AI合成`, so a shared suffix silhouette plus a per-vendor prefix may beat five independent templates. NOT established: whether 文字高度 means cap height, em box, or rendered bounding box (a ~1.3x spread in template scale). Sources: `https://www.tc260.org.cn/upload/2025-03-15/1742009439794081593.pdf`, parent CAC measure `https://www.cac.gov.cn/2025-03/14/c_1743654684782215.htm` (the CAC text itself specifies no size or corner). -**Alibaba Qwen -- two surfaces that differ (API tier VERIFIED (a)).** Model Studio docs state verbatim that the API adds a `Qwen-Image` watermark 在图像右下角 and 默认值为 false -- so API output is **unwatermarked by default**, and when enabled the mark is a LATIN wordmark, not CJK, and not GB-compliant in wording. The consumer app's `千问AI生成` (bottom-right) is (b) secondary only -- no Alibaba primary page states it. So Qwen needs TWO templates, and its absence is never evidence of a clean image. Source: `https://help.aliyun.com/zh/model-studio/qwen-image-api`. +**Alibaba Cloud Qwen -- two surfaces that differ (API tier VERIFIED (a)).** Model Studio docs state verbatim that the API adds a `Qwen-Image` watermark 在图像右下角 and 默认值为 false -- so API output is **unwatermarked by default**, and when enabled the mark is a LATIN wordmark, not CJK, and not GB-compliant in wording. The consumer app's `千问AI生成` (bottom-right) is (b) secondary only -- no Alibaba primary page states it. So Qwen needs TWO templates, and its absence is never evidence of a clean image. Source: `https://help.aliyun.com/zh/model-studio/qwen-image-api`. **星绘 is ByteDance (VERIFIED (a): Baidu Baike + App Store listing, now branded 豆包旗下, team folded into Doubao April 2025).** So `星绘AI生成` is very likely the Doubao house style -- same typeface, same corner, possibly the same top-left `AI生成` pill. Starting from the Doubao `TextMarkConfig` and swapping the two lead glyphs is the cheap path. String/position themselves are (c) inferred. @@ -161,9 +161,11 @@ 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 `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/`. +**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). Removal is fully supported and oracle-verified on the default profile, with a measured Meta strength floor (0.1) shipped behind auto IPTC routing and an explicit `--vendor meta` override; see [supported signals](supported-signals.md) and [module internals](module-internals.md). Legacy paragraph retained below. -**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. +**Meta `Imagined with AI` (string VERIFIED (a) from Meta's own newsroom; POSITION NOT VERIFIED).** Sources conflict on placement. Do not encode a corner without a verified sample. A dedicated 2026-08-27 sample hunt failed to obtain one: the Feb-2024 newsroom images are UI mockups whose photos carry no in-pixel mark; community posts (Threads `C8_rS_MuEId` titled "lower left corner", a Facebook share) corroborate bottom-LEFT verbally but their files defeat pixel verification; press screenshots predate the mark (Dec 2023); `imagine.meta.com` is dead (redirects to meta.ai) and its Wayback captures are broken SPA error pages. The mark's generator no longer exists (Muse output has no visible mark), so no fresh sample can be made. Removal stays on the generic `erase --region` path until a legacy capture surfaces. Source: `https://about.fb.com/news/2024/02/labeling-ai-generated-images-on-facebook-instagram-and-threads/`. + +**Samsung English/other locales: still not established.** Samsung's own support page says only that "A Galaxy AI watermark will appear on AI-generated images" -- no string, no corner. Every community thread carrying the exact English string returned HTTP 403 to WebFetch, so the search paraphrase (bottom-left) is deliberately NOT recorded as fact. Feature-tier detail (b): the mark is applied by Generative Edit / sketch-to-image but reportedly NOT by Object Eraser, so Samsung absence is feature-dependent. No separate icon-only variant was established. **The one document that would settle ByteDance placement is BLOCKED.** Douyin's 《抖音关于人工智能生成内容标识的水印与元数据规范》 aims to give AI tools a unified watermark style and position, which would cover Doubao / Jimeng / 星绘 at once. Both mirrors return HTTP 403 to WebFetch; a secondary report (b, unconfirmed) says the watermark is `AI生成` + tool name + company name placed **top-left** -- which would explain the Jimeng pill's top-left position but contradicts the GB annex's bottom-right example. Worth one retry through Chrome MCP with a real browser session. diff --git a/pyproject.toml b/pyproject.toml index fcba7f1..82d6707 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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.31.1" +version = "0.34.1" description = "AI watermark remover for visible, invisible, and provenance marks in images and video" readme = "README.md" requires-python = ">=3.11,<3.15" diff --git a/scripts/README.md b/scripts/README.md new file mode 100644 index 0000000..58f2a8b --- /dev/null +++ b/scripts/README.md @@ -0,0 +1,69 @@ +# Maintainer scripts + +These scripts are development and evaluation tools, not installed CLI commands. Run +them from the repository root with `uv run python scripts/.py --help`. Inputs +under `.local-eval/` and generated reports remain untracked unless a data README +explicitly names a tracked canonical result. + +## Audits and release checks + +| Script | Purpose | +| --- | --- | +| `corpus_gap_scan.py` | Compare a local image corpus with the library's `identify` results. | +| `detection_timing.py` | Record per-method metadata and verdict timings. | +| `detection_timing_report.py` | Aggregate timing records by method and segment. | +| `fidelity_metrics.py` | Compute objective image-fidelity metrics for paired outputs. | +| `invisible_quality_audit.py` | Pair originals and invisible-removal outputs for quality review. | +| `metadata_removal_audit.py` | Check metadata detection/removal parity over a corpus. | +| `pill_gate_audit.py` | Measure the Jimeng pill detector on the product path. | +| `real_examples_e2e.py` | Run end-to-end confidence checks over local examples. | +| `record_parity_audit.py` | Compare record-based and file-based identification. | +| `resource_ceilings.py` | Measure peak RSS and runtime for fill backends. | +| `robustness_suite.py` | Exercise CLI failures on adversarial and degenerate inputs. | +| `sidecar_regression.py` | Compare current identification with recorded sidecars. | +| `smoke_matrix.py` | Exercise CLI parameter choices on real local data. | +| `video_fidelity_probe.py` | Compare delivered video fidelity with its source. | +| `visible_eval.py` | Benchmark registered visible-mark detectors. | +| `visible_removal_audit.py` | Audit visible-removal results over a local corpus. | + +## Calibration and corpus preparation + +| Script | Purpose | +| --- | --- | +| `contentseal_transforms.py` | Reproduce and hash-check deterministic Content Seal variants. | +| `detector_response.py` | Measure detector response over mark size, contrast, background, and aspect. | +| `fill_quality.py` | Measure visible-fill quality against constructed ground truth. | +| `ladder_headroom.py` | Measure recall cost from the coarse scale ladder. | +| `registered_mark_calibrate.py` | Measure a registered detector without conflating visual positives, metadata cohorts, adjudicated negatives, and unlabeled controls. | +| `synthid_corpus.py` | Ingest and inspect the local SynthID reference corpus. | +| `vendor_cohort_harvest.py` | Partition TC260 carriers by producer code. | +| `vendor_mark_calibrate.py` | Calibrate a candidate vendor text detector. | +| `visible_alpha_solve.py` | Rebuild visible-watermark alpha assets from controlled captures. | +| `visible_groundtruth.py` | Consolidate blinded contact-sheet labels into ground truth. | +| `visible_positives.py` | List corpus images carrying a registered visible mark. | +| `visible_recall_sample.py` | Build an unbiased blinded sample for recall measurement. | +| `visible_sheets.py` | Build blinded contact sheets for relaxation candidates. | + +## Research and diagnostic prototypes + +| Script | Purpose | +| --- | --- | +| `cjk_tail_probe.py` | Test a generic template for otherwise uncovered CJK labels. | +| `controlnet_sweep.py` | Sweep the historical ControlNet removal prototype. | +| `infer_text_lines.py` | Draft stable source-text lines without modifying pixels. | +| `qwen_scrub_prototype.py` | Probe low-strength Qwen regeneration on a GPU. | +| `selective_text_restoration.py` | Evaluate text restoration over a scrubbed image. | +| `synthid_pixel_probe.py` | Run the experimental local SynthID carrier probe. | +| `video_synthid_sweep.py` | Build oracle-gated video regeneration candidates. | + +## Generated assets + +| Script | Purpose | +| --- | --- | +| `render_pill_silhouette.py` | Render the synthetic Jimeng pill silhouette. | +| `render_vendor_silhouettes.py` | Render synthetic vendor text-mark silhouettes. | + +## Shared helpers + +`_plain_console.py` provides plain-text fallbacks for Rich output, and +`_text_eval.py` contains normalization helpers shared by text-evaluation scripts. diff --git a/scripts/assets/visible-mark-candidates/README.md b/scripts/assets/visible-mark-candidates/README.md new file mode 100644 index 0000000..94a5fd7 --- /dev/null +++ b/scripts/assets/visible-mark-candidates/README.md @@ -0,0 +1,33 @@ +# Unregistered visible-mark candidates + +Synthetic detection silhouettes for marks that are measured but NOT registered: +none of these separates its positives from clean negatives yet, so shipping them +would attribute and fill corners on content that carries no mark (a false fill +is the worse error). Assets are regenerated by +`scripts/render_vendor_silhouettes.py`; a candidate is deleted from here on the +day its mark registers (the asset moves to the package `assets/`). + +Measurements below are local calibration snapshots. Candidate pools are +provenance cohorts, not automatically visible-mark positives; comparison controls +are independently selected no-signal images and are not adjudicated negatives. +Each needs capture-solved alphas or vendor-accurate font work before it can +ship; `scripts/vendor_mark_calibrate.py` is the candidate-detector harness. + +| Asset | Mark | Evidence | Result | +| --- | --- | --- | --- | +| `samsung_en_alpha.png` | Samsung Galaxy AI label, English locale ("AI-generated content", bottom-left) | 5 corpus files | POS 0.11-0.30 vs NEG max 0.40 (binary); tophat/gray tried, no separation. The registered Italian engine scores 0.18-0.31 on the same files -- same layout class, wrong glyph template. | +| `gemini_text_alpha.png` | "Generated with Gemini" text label (bottom-right; the registered gemini mark is the sparkle icon) | 3 corpus files | POS 0.07-0.22 vs NEG max 0.32; coverage gate finds the blob (0.29-0.35) but the Arial silhouette misses Google's letterforms. | +| `notebooklm_alpha.png` | NotebookLM wordmark (bottom-right) | 12 corpus files | locate geometry not yet fitted; POS max 0.12. | +| `dola_alpha.png` | DolaAI on images (the video mark is registered) | 12 corpus files | POS 0.11-0.21 vs NEG max 0.30. | +| `mindvideo_alpha.png` | MindVideo.AI (top-right) | 11 corpus files | POS 0.29-0.32 vs NEG max 0.30 -- borderline overlap, not shippable. | +| `higgsfield_alpha.png` | HIGGSFIELD AI wordmark (bottom-right; the boxed `AI` variant shares the cohort) | 5 wordmark files (16 in the boxed-AI OCR cluster) | POS max 0.26 vs NEG max 0.22 -- no separation; the mark may be two-part (wordmark + boxed AI) and needs a composed template. | +| `jianying_alpha.png` | 剪映AI (CapCut's CN sibling, bottom-right) | 2 corpus files | POS 0.29 vs NEG max 0.35. | +| `capcut_alpha.png` | CapCut AI pill (top-left; likely pill class, not plain text) | 3 corpus files | POS max 0.15 vs NEG max 0.33 -- locate geometry not yet fitted for the pill form. | +| `zsky_alpha.png` | MADE WITH zsky.ai (bottom-right) | 2 corpus files | POS 0.10 vs NEG max 0.27. | +| `chromastudio_alpha.png` | ChromaStudio.ai (bottom-right) | 2 corpus files | POS 0.11 vs NEG max 0.29. | +| `digenai_alpha.png` | DIGENAI (bottom-right) | 3 corpus files (one 2026-07-24 batch) | POS 0.16 vs NEG max 0.31. | +| `gendo_alpha.png` | GendoAI (bottom-left) | 3 corpus files | POS 0.08 vs NEG max 0.32. | +| `xinghui_alpha.png` | 星绘AI生成 (parked before this set) | -- | prior parking, unchanged. | +| `qingyan_alpha.png` | 清言·AI生成 (parked before this set) | -- | prior parking, unchanged. | +| `hailuo_alpha.png` | Hailuo AI image wordmark (parked before this set; the VIDEO label is registered) | -- | prior parking, unchanged. | +| `catlogo_alpha.png` | outline cat-head + AI生成 (parked before this set) | -- | prior parking, unchanged. | diff --git a/scripts/assets/visible-mark-candidates/capcut_alpha.png b/scripts/assets/visible-mark-candidates/capcut_alpha.png new file mode 100644 index 0000000..6a56fae Binary files /dev/null and b/scripts/assets/visible-mark-candidates/capcut_alpha.png differ diff --git a/scripts/assets/visible-mark-candidates/chromastudio_alpha.png b/scripts/assets/visible-mark-candidates/chromastudio_alpha.png new file mode 100644 index 0000000..5854119 Binary files /dev/null and b/scripts/assets/visible-mark-candidates/chromastudio_alpha.png differ diff --git a/scripts/assets/visible-mark-candidates/digenai_alpha.png b/scripts/assets/visible-mark-candidates/digenai_alpha.png new file mode 100644 index 0000000..7309343 Binary files /dev/null and b/scripts/assets/visible-mark-candidates/digenai_alpha.png differ diff --git a/scripts/assets/visible-mark-candidates/dola_alpha.png b/scripts/assets/visible-mark-candidates/dola_alpha.png new file mode 100644 index 0000000..374e960 Binary files /dev/null and b/scripts/assets/visible-mark-candidates/dola_alpha.png differ diff --git a/scripts/assets/visible-mark-candidates/gemini_text_alpha.png b/scripts/assets/visible-mark-candidates/gemini_text_alpha.png new file mode 100644 index 0000000..4036701 Binary files /dev/null and b/scripts/assets/visible-mark-candidates/gemini_text_alpha.png differ diff --git a/scripts/assets/visible-mark-candidates/gendo_alpha.png b/scripts/assets/visible-mark-candidates/gendo_alpha.png new file mode 100644 index 0000000..950f6b2 Binary files /dev/null and b/scripts/assets/visible-mark-candidates/gendo_alpha.png differ diff --git a/scripts/assets/visible-mark-candidates/higgsfield_alpha.png b/scripts/assets/visible-mark-candidates/higgsfield_alpha.png new file mode 100644 index 0000000..a2629d0 Binary files /dev/null and b/scripts/assets/visible-mark-candidates/higgsfield_alpha.png differ diff --git a/scripts/assets/visible-mark-candidates/jianying_alpha.png b/scripts/assets/visible-mark-candidates/jianying_alpha.png new file mode 100644 index 0000000..6321036 Binary files /dev/null and b/scripts/assets/visible-mark-candidates/jianying_alpha.png differ diff --git a/scripts/assets/visible-mark-candidates/mindvideo_alpha.png b/scripts/assets/visible-mark-candidates/mindvideo_alpha.png new file mode 100644 index 0000000..e381f50 Binary files /dev/null and b/scripts/assets/visible-mark-candidates/mindvideo_alpha.png differ diff --git a/scripts/assets/visible-mark-candidates/notebooklm_alpha.png b/scripts/assets/visible-mark-candidates/notebooklm_alpha.png new file mode 100644 index 0000000..305672d Binary files /dev/null and b/scripts/assets/visible-mark-candidates/notebooklm_alpha.png differ diff --git a/scripts/assets/visible-mark-candidates/samsung_en_alpha.png b/scripts/assets/visible-mark-candidates/samsung_en_alpha.png new file mode 100644 index 0000000..ed4882f Binary files /dev/null and b/scripts/assets/visible-mark-candidates/samsung_en_alpha.png differ diff --git a/scripts/assets/visible-mark-candidates/zsky_alpha.png b/scripts/assets/visible-mark-candidates/zsky_alpha.png new file mode 100644 index 0000000..7cf052c Binary files /dev/null and b/scripts/assets/visible-mark-candidates/zsky_alpha.png differ diff --git a/scripts/cjk_tail_probe.py b/scripts/cjk_tail_probe.py index 38bc342..97b43d9 100644 --- a/scripts/cjk_tail_probe.py +++ b/scripts/cjk_tail_probe.py @@ -1,7 +1,7 @@ """Can one GENERIC template cover the CJK AI labels no per-vendor detector fires on? THE OPPORTUNITY - Corpus inspection of doubao-provenance misses turned up `千问AI生成` (Alibaba Qwen) and + Corpus inspection of doubao-provenance misses turned up `千问AI生成` (Alibaba Cloud Qwen) and `百度 AI生成` (Baidu) sitting in the same bottom-right corner as the marks we do cover, bold and plainly legible, with no detector able to fire on either. `docs/...landscape` puts uncovered vendors at ~6% of sampled images -- larger than any tuning gain left in diff --git a/scripts/contentseal_transforms.py b/scripts/contentseal_transforms.py new file mode 100644 index 0000000..7c97ddf --- /dev/null +++ b/scripts/contentseal_transforms.py @@ -0,0 +1,82 @@ +#!/usr/bin/env python3 +"""Reproduce the deterministic Content Seal crop, resize, and JPEG variants.""" + +from __future__ import annotations + +import argparse +import csv +import hashlib +import logging +from pathlib import Path + +from PIL import Image + +log = logging.getLogger(__name__) + +ROOT = Path(__file__).resolve().parents[1] +CORPUS = ROOT / "data" / "contentseal" +MANIFEST = CORPUS / "manifest.csv" + + +def _rows() -> dict[str, dict[str, str]]: + with MANIFEST.open(newline="", encoding="utf-8") as stream: + return {row["name"]: row for row in csv.DictReader(stream)} + + +def _write_and_verify(image: Image.Image, path: Path, row: dict[str, str], *, format: str, quality: int) -> None: + image.save(path, format=format, quality=quality) + digest = hashlib.sha256(path.read_bytes()).hexdigest() + if digest != row["sha256"]: + raise RuntimeError(f"{row['name']} hash mismatch: expected {row['sha256']}, got {digest}") + log.info("Verified %s", path) + + +def reproduce_transforms(output_dir: Path) -> list[Path]: + """Write and hash-check the eight deterministic manifest variants.""" + rows = _rows() + output_dir.mkdir(parents=True, exist_ok=True) + outputs: list[Path] = [] + + for prefix, source_name in (("fox", "gen_fox_forest"), ("text", "gen_text_poster")): + with Image.open(CORPUS / rows[source_name]["file"]) as opened: + source = opened.convert("RGB") + + for fraction in (0.5, 0.33): + width = int(source.width * fraction) + height = int(source.height * fraction) + left = (source.width - width) // 2 + top = (source.height - height) // 2 + name = f"{prefix}_crop{int(fraction * 100)}" + path = output_dir / f"{name}.webp" + crop = source.crop((left, top, left + width, top + height)) + _write_and_verify(crop, path, rows[name], format="WEBP", quality=95) + outputs.append(path) + + scale = 512 / max(source.size) + resized = source.resize( + (round(source.width * scale), round(source.height * scale)), + Image.Resampling.LANCZOS, + ) + name = f"{prefix}_res512" + path = output_dir / f"{name}.webp" + _write_and_verify(resized, path, rows[name], format="WEBP", quality=95) + outputs.append(path) + + name = f"{prefix}_jpeg85" + path = output_dir / f"{name}.jpg" + _write_and_verify(source, path, rows[name], format="JPEG", quality=85) + outputs.append(path) + + return outputs + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("output_dir", type=Path, help="Directory for regenerated variants") + args = parser.parse_args() + logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s") + reproduce_transforms(args.output_dir) + + +if __name__ == "__main__": + main() diff --git a/scripts/detection_timing_report.py b/scripts/detection_timing_report.py index bb7ca86..f5893cf 100644 --- a/scripts/detection_timing_report.py +++ b/scripts/detection_timing_report.py @@ -39,7 +39,7 @@ sys.path.insert(0, str(Path(__file__).resolve().parent)) from detection_timing import COMPONENTS as _TIMED # Taken from the script that WROTE the rows, in its order, so a probe added, removed -# or reordered there cannot silently leave a column missing or mislabelled here. +# or reordered there cannot silently leave a column missing or mislabeled here. COMPONENTS = tuple(name for name, _ in _TIMED) METHODS = ( ("cold_extract_evidence_ms", "extract_provenance_evidence (cold)"), diff --git a/scripts/real_examples_e2e.py b/scripts/real_examples_e2e.py index 424c01b..19c6ac0 100644 --- a/scripts/real_examples_e2e.py +++ b/scripts/real_examples_e2e.py @@ -81,7 +81,7 @@ def find_visible_positive(mark: str) -> Path | None: class Results: - """Collects one row per checked behaviour. + """Collects one row per checked behavior. A FAIL keeps the command's OUTPUT. That is not cosmetic: the first run of this harness discarded it, an `all` invocation failed once with exit 1, and because the output was @@ -166,7 +166,7 @@ def check_visible(res: Results, tmp: Path) -> None: """Real marked images -> the PRODUCT'S DECISION is honoured, and a removed mark clears. The success criterion is not "the mark is always gone" -- it is "the product did what it - decided, and the decision is right". Two designed behaviours make a blind re-detect + decided, and the decision is right". Two designed behaviors make a blind re-detect misleading: * The pill is GATED (`_keep_pill`): a low-confidence pill with no corroboration is deliberately NOT removed, so `visible` correctly writes nothing and exits 2. That is diff --git a/scripts/registered_mark_calibrate.py b/scripts/registered_mark_calibrate.py new file mode 100644 index 0000000..0bed36b --- /dev/null +++ b/scripts/registered_mark_calibrate.py @@ -0,0 +1,175 @@ +"""Measure a registered visible-mark detector on independently labeled arms. + +The input is a JSONL manifest with exactly two fields per row: + +``path`` + Absolute path, or a path relative to the manifest. +``arm`` + ``positive`` for a visually confirmed carrier, ``metadata`` for a + provenance-only cohort, ``negative`` for an independently adjudicated + no-mark image, or ``control`` for an unlabeled comparison image with no + known local signal. + +The arms stay separate because metadata names a provider, not the presence of a +visible mark, and missing local signals do not make an image a true negative. The +script imports the registered engine and reads its shipped gate, so calibration +cannot silently use a copied configuration. + +Input images and manifests are read-only. Keep private inputs and generated +manifests outside the repository or in a gitignored evaluation directory. + + uv run python scripts/registered_mark_calibrate.py microsoft manifest.jsonl +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +from collections import Counter +from pathlib import Path +from typing import Any, Literal, TypedDict, cast + +REPO = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(REPO / "src")) + +from remove_ai_watermarks import watermark_registry # noqa: E402 +from remove_ai_watermarks.image_io import imread # noqa: E402 + +Arm = Literal["positive", "metadata", "negative", "control"] +_ARMS: tuple[Arm, ...] = ("positive", "metadata", "negative", "control") + + +class ManifestRow(TypedDict): + path: str + arm: Arm + + +class ArmSummary(TypedDict): + n: int + unreadable: int + min: float | None + p50: float | None + p90: float | None + p99: float | None + max: float | None + fires: int + + +def _percentile(ordered: list[float], fraction: float) -> float | None: + """Return a nearest-rank percentile from values sorted in ascending order.""" + if not ordered: + return None + return ordered[max(0, math.ceil(fraction * len(ordered)) - 1)] + + +def summarize(values: list[float], *, unreadable: int, fires: int) -> ArmSummary: + """Summarize one independently defined arm without inferring its label.""" + ordered = sorted(values) + return { + "n": len(values), + "unreadable": unreadable, + "min": _percentile(ordered, 0.0), + "p50": _percentile(ordered, 0.5), + "p90": _percentile(ordered, 0.9), + "p99": _percentile(ordered, 0.99), + "max": _percentile(ordered, 1.0), + "fires": fires, + } + + +def load_manifest(path: Path) -> list[ManifestRow]: + """Load and validate a manifest, resolving relative paths beside it.""" + rows: list[ManifestRow] = [] + seen: set[Path] = set() + with path.open(encoding="utf-8") as manifest: + for line_number, line in enumerate(manifest, 1): + if not line.strip(): + continue + raw = json.loads(line) + if not isinstance(raw, dict) or set(raw) != {"path", "arm"}: + raise ValueError(f"{path}:{line_number}: expected exactly path and arm") + raw_path = raw["path"] + raw_arm = raw["arm"] + if not isinstance(raw_path, str) or not raw_path: + raise ValueError(f"{path}:{line_number}: path must be a non-empty string") + if raw_arm not in _ARMS: + raise ValueError(f"{path}:{line_number}: arm must be one of {', '.join(_ARMS)}") + image_path = Path(raw_path) + if not image_path.is_absolute(): + image_path = path.parent / image_path + image_path = image_path.resolve() + if image_path in seen: + raise ValueError(f"{path}:{line_number}: duplicate image path {image_path}") + seen.add(image_path) + rows.append({"path": str(image_path), "arm": cast("Arm", raw_arm)}) + if not rows: + raise ValueError(f"{path}: manifest is empty") + return rows + + +def measure(mark: str, rows: list[ManifestRow]) -> tuple[float, dict[Arm, ArmSummary]]: + """Score every row with the registered engine and its current shipped gate.""" + registered_mark = watermark_registry.get_mark(mark) + # Calibration intentionally resolves the registry's concrete engine so it + # cannot drift onto a copied configuration. + engine = watermark_registry._engine(mark) + config = getattr(engine, "config", None) + gate = getattr(config, "detect_ncc_threshold", None) + if not isinstance(gate, int | float): + raise ValueError(f"registered mark {mark!r} does not expose a text-detector NCC gate") + + scores: dict[Arm, list[float]] = {arm: [] for arm in _ARMS} + unreadable: Counter[Arm] = Counter() + fires: Counter[Arm] = Counter() + for row in rows: + image = imread(row["path"]) + if image is None: + unreadable[row["arm"]] += 1 + continue + detection: Any = registered_mark.detect(image) + scores[row["arm"]].append(float(detection.confidence)) + fires[row["arm"]] += bool(detection.detected) + + threshold = float(gate) + return threshold, {arm: summarize(scores[arm], unreadable=unreadable[arm], fires=fires[arm]) for arm in _ARMS} + + +def _format_stat(value: float | None) -> str: + return "-" if value is None else f"{value:.3f}" + + +def print_report(mark: str, gate: float, summaries: dict[Arm, ArmSummary]) -> None: + """Print a compact human-readable calibration report.""" + print(f"registered mark: {mark} gate: {gate:.3f}") + print( + f"{'arm':10s} {'n':>5s} {'bad':>5s} {'min':>7s} {'p50':>7s} {'p90':>7s} {'p99':>7s} {'max':>7s} {'fires':>7s}" + ) + for arm in _ARMS: + row = summaries[arm] + print( + f"{arm:10s} {row['n']:5d} {row['unreadable']:5d} " + f"{_format_stat(row['min']):>7s} {_format_stat(row['p50']):>7s} " + f"{_format_stat(row['p90']):>7s} {_format_stat(row['p99']):>7s} " + f"{_format_stat(row['max']):>7s} {row['fires']:7d}" + ) + + +def main() -> None: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("mark", choices=watermark_registry.mark_keys()) + parser.add_argument("manifest", type=Path) + parser.add_argument("--json", action="store_true", help="emit machine-readable JSON") + args = parser.parse_args() + + rows = load_manifest(args.manifest) + gate, summaries = measure(args.mark, rows) + if args.json: + print(json.dumps({"mark": args.mark, "gate": gate, "arms": summaries}, sort_keys=True)) + else: + print_report(args.mark, gate, summaries) + + +if __name__ == "__main__": + main() diff --git a/scripts/render_vendor_silhouettes.py b/scripts/render_vendor_silhouettes.py index 237e09d..c6bf68a 100644 --- a/scripts/render_vendor_silhouettes.py +++ b/scripts/render_vendor_silhouettes.py @@ -12,11 +12,14 @@ from __future__ import annotations import sys from pathlib import Path -from typing import Any +from typing import TYPE_CHECKING, Any import numpy as np from PIL import Image, ImageDraw, ImageFont +if TYPE_CHECKING: + from collections.abc import Callable + _ROOT = Path(__file__).resolve().parents[1] sys.path.insert(0, str(_ROOT / "src")) @@ -42,7 +45,7 @@ MARKS = { "kling_alpha.png": "可灵AI 3.0", # The "cat-logo" candidate stamps an outline cat-head plus bold "AI生成", # bottom-right. It remains unregistered pending sufficient calibration coverage. - "catlogo_alpha.png": "CATLOGO", # sentinel: drawn by draw_catlogo(), not font-rendered + "catlogo_alpha.png": "cat logo + AI生成", # RunningHub top-left text mark. "runninghub_alpha.png": "RunningHub AI生成", # LibLibAI bottom-center wordmark. @@ -53,6 +56,28 @@ MARKS = { "hailuo_alpha.png": "Hailuo AI", # Baidu bottom-right text run. "baidu_alpha.png": "百度", + # Measured Microsoft top-right white AI-badge variant. Sentinel: drawn by + # draw_msbadge(), not font-rendered. + "microsoft_alpha.png": "Made with AI", + # Samsung Galaxy AI label, English locale (the registered samsung_alpha.png is + # the Italian "Contenuti generati dall'AI" silhouette; EN is the literal + # translation with the same leading sparkle). Sentinel: draw_samsung_en(). + "samsung_en_alpha.png": "AI-generated content", + # Gemini text-form label (the registered gemini mark is the sparkle icon). + "gemini_text_alpha.png": "Generated with Gemini", + # Candidate wordmarks measured on a local evaluation corpus (unregistered). + "notebooklm_alpha.png": "NotebookLM", + "dola_alpha.png": "DolaAI", + "mindvideo_alpha.png": "MindVideo.AI", + "higgsfield_alpha.png": "HIGGSFIELD AI", + "capcut_alpha.png": "CapCut AI", + "zsky_alpha.png": "MADE WITH zsky.ai", + "chromastudio_alpha.png": "ChromaStudio.ai", + "digenai_alpha.png": "DIGENAI", + "gendo_alpha.png": "GendoAI", + # CapCut's Chinese sibling, JianYing, stamps 剪映AI bottom-right (the + # international CapCut pill sits top-left). + "jianying_alpha.png": "剪映AI", } _REGISTERED = {f"{key}_alpha.png" for key in mark_keys()} & MARKS.keys() @@ -74,6 +99,24 @@ MARK_OPTS: dict[str, dict[str, Any]] = { } +def _fit_font( + font_path: str, + reaches_target: Callable[[ImageFont.FreeTypeFont], bool], + *, + index: int = 0, +) -> ImageFont.FreeTypeFont: + """Return the smallest 8-200 px font that reaches a render target.""" + low, high = 8, 200 + while low < high: + size = (low + high) // 2 + font = ImageFont.truetype(font_path, size, index=index) + if reaches_target(font): + high = size + else: + low = size + 1 + return ImageFont.truetype(font_path, low, index=index) + + def render(text: str, width: int = 335, opts: dict[str, Any] | None = None) -> np.ndarray: """Binary glyph silhouette (255 = glyph), sized to the doubao asset's convention. @@ -91,13 +134,11 @@ def render(text: str, width: int = 335, opts: dict[str, Any] | None = None) -> n probe = Image.new("L", (10, 10)) d0 = ImageDraw.Draw(probe) lines = text.split("\n") - size = 8 - while size < 200: # grow until the LONGEST line fills the target width - f = ImageFont.truetype(font_path, size, index=font_index) - if max(d0.textbbox((0, 0), ln, font=f)[2] for ln in lines) >= width * 0.98: - break - size += 1 - font = ImageFont.truetype(font_path, size, index=font_index) + font = _fit_font( + font_path, + lambda f: max(d0.textbbox((0, 0), ln, font=f)[2] for ln in lines) >= width * 0.98, + index=font_index, + ) boxes = [d0.textbbox((0, 0), ln, font=font) for ln in lines] line_h = max(bb[3] - bb[1] for bb in boxes) gap = max(1, int(line_h * gap_frac)) @@ -138,13 +179,7 @@ def draw_catlogo(width: int = 335) -> np.ndarray: probe = Image.new("L", (10, 10)) d0 = ImageDraw.Draw(probe) text = "AI生成" - size = 8 - while size < 200: - f = ImageFont.truetype(_FONT, size) - if d0.textbbox((0, 0), text, font=f)[2] >= width * 0.60: - break - size += 1 - font = ImageFont.truetype(_FONT, size) + font = _fit_font(_FONT, lambda f: d0.textbbox((0, 0), text, font=f)[2] >= width * 0.60) bb = d0.textbbox((0, 0), text, font=font) tw, th = bb[2] - bb[0], bb[3] - bb[1] cs = int(th * 1.08) @@ -182,10 +217,90 @@ def draw_catlogo(width: int = 335) -> np.ndarray: return np.array(im) +def _star_pts(cx: float, cy: float, r: float, waist: float) -> list[tuple[float, float]]: + return [ + (cx, cy - r), + (cx + r * waist, cy - r * waist), + (cx + r, cy), + (cx + r * waist, cy + r * waist), + (cx, cy + r), + (cx - r * waist, cy + r * waist), + (cx - r, cy), + (cx - r * waist, cy - r * waist), + ] + + +def _sparkle(draw: ImageDraw.ImageDraw, cx: float, cy: float, r: float) -> None: + """Draw the four-point cutout used by synthetic candidate silhouettes.""" + draw.polygon(_star_pts(cx, cy, r, 0.22), fill=0) + + +def draw_msbadge(width: int = 335) -> np.ndarray: + """Synthetic silhouette for the measured Microsoft top-right white pill. + + The top-hat front-end sees the bright pill with dark-text holes, so the template + carries the same holes -- that is what discriminates this pill from any other + white rounded element in the top-right corner. The text and four-point cutout + approximate the measured internal shape; they do not assert one universal + Microsoft icon or wording. Geometry measured on 17 visually confirmed carriers + on 2026-08-27: pill 0.152W x 0.040W, margins ~0.010W right / ~0.007W top, + glyph height ~0.39 of pill height. + """ + h = round(width / 3.78) + im = Image.new("L", (width, h), 0) + d = ImageDraw.Draw(im) + d.rounded_rectangle([0, 0, width - 1, h - 1], radius=h // 2, fill=255) + font_path = "/System/Library/Fonts/Supplemental/Arial Bold.ttf" + text = "Made with AI" + probe = Image.new("L", (10, 10)) + d0 = ImageDraw.Draw(probe) + font = _fit_font(font_path, lambda f: d0.textbbox((0, 0), text, font=f)[3] >= h * 0.39) + bb = d0.textbbox((0, 0), text, font=font) + th = bb[3] - bb[1] + r = h * 0.20 # sparkle radius, ~half the text height + pad_l = h * 0.22 + cx = pad_l + r + cy = h / 2 - 1 + tx = int(pad_l + 2 * r + h * 0.22) + d.text((tx - bb[0], (h - th) // 2 - bb[1]), text, font=font, fill=0) + _sparkle(d, cx, cy, r) + return np.array(im) + + +def draw_samsung_en(width: int = 335) -> np.ndarray: + """Samsung Galaxy AI English label: "AI-generated content" with the leading + 4-point sparkle, light-gray glyphs (same class as the registered Italian asset).""" + text = "AI-generated content" + font_path = "/System/Library/Fonts/Supplemental/Arial.ttf" + probe = Image.new("L", (10, 10)) + d0 = ImageDraw.Draw(probe) + font = _fit_font(font_path, lambda f: d0.textbbox((0, 0), text, font=f)[2] >= width * 0.80) + bb = d0.textbbox((0, 0), text, font=font) + tw, th = bb[2] - bb[0], bb[3] - bb[1] + r = th * 0.55 + gap = th * 0.45 + im = Image.new("L", (int(tw + gap + 2 * r + 8), th + 8), 0) + d = ImageDraw.Draw(im) + # sparkle as bright glyph (this silhouette is light-glyph class, not a pill) + d.polygon(_star_pts(4 + r, 4 + th / 2, r, 0.22), fill=255) + d.text((4 + 2 * r + gap - bb[0], 4 - bb[1]), text, font=font, fill=255) + arr = np.array(im) + ys, xs = np.where(arr > 0) + return arr[ys.min() : ys.max() + 1, xs.min() : xs.max() + 1] + + +_CUSTOM_RENDERERS = { + "catlogo_alpha.png": draw_catlogo, + "microsoft_alpha.png": draw_msbadge, + "samsung_en_alpha.png": draw_samsung_en, +} + + def main() -> None: try: for name, text in MARKS.items(): - sil = draw_catlogo() if text == "CATLOGO" else render(text, opts=MARK_OPTS.get(name)) + renderer = _CUSTOM_RENDERERS.get(name) + sil = renderer() if renderer is not None else render(text, opts=MARK_OPTS.get(name)) output_dir = _PACKAGE_ASSETS if name in _REGISTERED else _CANDIDATE_ASSETS output_dir.mkdir(parents=True, exist_ok=True) output = output_dir / name diff --git a/scripts/render_visible_examples.py b/scripts/render_visible_examples.py new file mode 100644 index 0000000..85fe547 --- /dev/null +++ b/scripts/render_visible_examples.py @@ -0,0 +1,275 @@ +"""Render the visible-mark example gallery under data/fixtures/visible/. + +One committed example per registered image mark, so the repo carries a working +sample of everything it supports and a canary test can hold both sides to it +(mark registered without example; engine regressed on its canonical example). + +Every example is SYNTHETIC: a deterministic generated base photo with the mark's +own committed silhouette composited at the engine's measured geometry. User +uploads never enter the repository (data/spaces stays out of git), and no vendor +asset is copied -- the silhouettes are the same font-rendered templates the +detectors match against. + +Regenerate with: + uv run python scripts/render_visible_examples.py +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from typing import Any + +import cv2 +import numpy as np + +_ROOT = Path(__file__).resolve().parents[1] +sys.path.insert(0, str(_ROOT / "src")) + +from remove_ai_watermarks import watermark_registry as wr # noqa: E402 +from remove_ai_watermarks.image_io import imread # noqa: E402 + +ASSETS = _ROOT / "src" / "remove_ai_watermarks" / "assets" +OUT = _ROOT / "data" / "fixtures" / "visible" + +# Composite strength per key: glyph target luma for the light-overlay class. +# Kling is a thin light-gray run (not near-white); Samsung is a faint overlay +# expressed by SCALING ITS ALPHA to 0.38 toward full white (see below); everything +# else is the bold near-white house style. +_STRENGTH: dict[str, int] = { + "kling": 208, +} +_DEFAULT_STRENGTH = 238 + +# Base geometry: one canonical size per mark where the engine's size modes +# matter (qwen's big mode), otherwise a plain 3:2 landscape frame. +# Samsung keeps the larger base: its overlay is faint (peak alpha ~0.38) and +# the real marks live on ~2958px phone photos -- at 1536 the example falls to 0.39, +# just under the engine's 0.40 gate. +_SIZE: dict[str, tuple[int, int]] = {"qwen": (1536, 1536), "liblib": (1152, 1536), "samsung": (2048, 1536)} + + +def base_photo(w: int, h: int, seed: int = 7) -> np.ndarray: + """A deterministic synthetic 'photo': gradient sky, soft blobs, mild noise.""" + rng = np.random.default_rng(seed) + top, bottom = 96, 168 + grad = np.linspace(top, bottom, h, dtype=np.float32)[:, None] + img = np.repeat(grad[:, :, None], w, axis=1) # (h, w, 1) + for _ in range(5): + cx, cy = rng.uniform(0, w), rng.uniform(0, h) + r = rng.uniform(w * 0.12, w * 0.35) + blob = rng.uniform(-52, 52) + yy, xx = np.ogrid[:h, :w] + gauss = np.exp(-(((xx - cx) ** 2 + (yy - cy) ** 2) / (2 * (r * 0.55) ** 2))).astype(np.float32) + img = img + (blob * gauss)[:, :, None] + img = img + rng.normal(0, 1.6, img.shape).astype(np.float32) + return cv2.merge([np.clip(img, 0, 255).astype(np.uint8)] * 3) + + +def _glyph_asset(name: str) -> np.ndarray: + at = imread(str(ASSETS / name), cv2.IMREAD_GRAYSCALE) + if at is None: + raise RuntimeError(f"missing silhouette asset: {name}") + return at.astype(np.float32) / 255.0 + + +def _composite_light(base: np.ndarray, alpha: np.ndarray, x: int, y: int, strength: int) -> np.ndarray: + out = base.copy() + h, w = alpha.shape[:2] + roi = out[y : y + h, x : x + w].astype(np.float32) + a3 = alpha[:, :, None] if alpha.ndim == 2 else alpha + out[y : y + h, x : x + w] = np.clip(roi * (1 - a3) + strength * a3, 0, 255).astype(np.uint8) + return out + + +def _text_mark_example(key: str) -> np.ndarray: + engine = wr._engine(key) # the generator drives the engine's own config + cfg = engine.config + w, h = _SIZE.get(key, (1536, 1152)) + base = base_photo(w, h) + if key == "microsoft": + # Opaque white pill with dark text/sparkle holes, at the measured inset. + at = _glyph_asset("microsoft_alpha.png") + long_side = max(w, h) + pw = int(0.152 * long_side) + ph = max(4, int(pw / (at.shape[1] / at.shape[0]))) + pad = int(0.010 * long_side) + pill = cv2.resize(at, (pw, ph)) + x, y = w - pad - pw, pad + roi = base[y : y + ph, x : x + pw].astype(np.float32) + bright = (pill > 0.6)[:, :, None] + roi = np.where(bright, 245.0, 46.0) + base[y : y + ph, x : x + pw] = roi.astype(np.uint8) + return base + base_dim = {"short": min(w, h), "width": w, "long": max(w, h)}[cfg.scale_basis] + # Size the glyph ON a ladder rung: the continuous front ends sweep only the + # configured rungs, and a glyph sized between rungs collapses the NCC (the + # comb-collapse qwen's own two-rung ladder exists to avoid). + rung = max(cfg.ladder) if cfg.detect_frontend != "binary" else 1.0 + gw = int(cfg.alpha_width_frac * base_dim * rung) + gh = max(4, int(cfg.alpha_height_frac * base_dim * rung)) + loc = engine.locate(base) + # Corner-hugging placement: the yuanbao/runninghub anchor gates demote a match + # that does not hug the corner, and the real marks sit flush on the box's + # corner side (never centered). + if cfg.corner in ("br", "tr"): + x = loc.x + loc.w - gw + elif cfg.corner == "bc": + x = loc.x + (loc.w - gw) // 2 + else: # bl, tl: flush left + x = loc.x + y = loc.y if cfg.corner in ("tl", "tr") else loc.y + loc.h - gh + x, y = max(0, x), max(0, y) + at = _glyph_asset(f"{key}_alpha.png") + alpha = cv2.resize(at, (gw, gh)) + if key == "samsung": # faint overlay: peak alpha 0.38 toward FULL white + alpha = alpha * 0.38 + return _composite_light(base, alpha, x, y, _STRENGTH.get(key, _DEFAULT_STRENGTH)) + + +def _gemini_example() -> np.ndarray: + from remove_ai_watermarks.gemini_engine import GeminiEngine, get_watermark_config, get_watermark_size + + w, h = 1536, 1152 + base = base_photo(w, h) + eng = GeminiEngine() + size = get_watermark_size(w, h) + alpha = eng.get_alpha_map(size) + cfg = get_watermark_config(w, h) + x, y = cfg.get_position(w, h) + return _composite_light(base, alpha.astype(np.float32), x, y, 255) + + +def _pill_example() -> np.ndarray: + w, h = 1152, 1536 # the measured pill cohort is 3:4 portrait + base = base_photo(w, h) + at = _glyph_asset("jimeng_pill.png") + pw = max(24, int(0.161 * w)) + ph = max(8, int(pw * at.shape[0] / at.shape[1])) + x, y = int(0.03 * w), int(0.03 * h) + alpha = cv2.resize(at, (pw, ph)) + return _composite_light(base, alpha, x, y, 232) + + +_BUILDERS: dict[str, Any] = {"gemini": _gemini_example, "jimeng_pill": _pill_example} + + +def build(key: str) -> np.ndarray: + if key in _BUILDERS: + return _BUILDERS[key]() + return _text_mark_example(key) + + +# ── Video mark examples ────────────────────────────────────────────────────── +# One short clip per registered video mark: the detector's own synthetic +# template composited at a scale inside its calibrated search profile, on every +# frame of a generated base. The canary asserts the SHIPPED selection accepts +# the clip (identify_video -> visible_mark), not just the per-frame detector. + +_VIDEO_FRAMES = 90 +_VIDEO_FPS = 30 + + +def _video_mark_frame(key: str, w: int, h: int) -> np.ndarray: + from remove_ai_watermarks.video_visible import _template_sources + + base = base_photo(w, h, seed=11) + templates = _template_sources() + short = min(w, h) + if key == "sora": + tmpl, scale, x, y = templates["sora-icon"], 0.10, int(w * 0.72), int(h * 0.90) + elif key == "veo": + # The legacy "Veo" TEXT form: a perfect synthetic diamond also matches the + # Sora icon template (both are 4-point stars) and table order hands the + # tie to Sora, so the gallery carries the discriminative text variant. + tmpl = templates["veo-text"] + th = max(6, round(14 * short / 720)) + tw = max(1, round(tmpl.shape[1] * th / tmpl.shape[0])) + x, y = w - tw - int(0.045 * w), h - th - int(0.045 * h) + return _composite_light(base, cv2.resize(tmpl, (tw, th)).astype(np.float32) / 255.0, x, y, 250) + elif key == "seedance": + tmpl, scale, x, y = templates["seedance"], 0.095, int(w * 0.74), int(h * 0.80) + elif key == "dola": + tmpl, scale, x, y = templates["dola"], 0.036, int(w * 0.70), int(h * 0.88) + elif key == "hailuo": + tmpl, scale, x, y = templates["hailuo"], 0.052, int(w * 0.34), int(h * 0.82) + elif key == "kling": + # The FULL mark: swirl logo left of the text run, flush against the + # bottom-right EDGE. The font arm is edge-gated (region must reach + # >=0.96W / >=0.94H), and a text-only composite away from the edge both + # fails that gate and cross-fires the Seedance detector. + tmpl = templates["kling-1"] + th = max(8, round(short * 0.040)) + tw = max(1, round(tmpl.shape[1] * th / tmpl.shape[0])) + tx, ty = w - tw - 6, h - th - 6 + out = _composite_light(base, cv2.resize(tmpl, (tw, th)).astype(np.float32) / 255.0, tx, ty, 250) + logo = templates["kling-logo"] + lh = max(6, round(short * 0.046)) + lw = max(1, round(logo.shape[1] * lh / logo.shape[0])) + lx, ly = tx - lw - round(th * 0.5), h - lh - 6 + return _composite_light(out, cv2.resize(logo, (lw, lh)).astype(np.float32) / 255.0, lx, ly, 250) + else: + raise ValueError(key) + th = max(8, round(short * scale)) + tw = max(1, round(tmpl.shape[1] * th / tmpl.shape[0])) + alpha = cv2.resize(tmpl, (tw, th)).astype(np.float32) / 255.0 + return _composite_light(base, alpha, x, y, 250) + + +def build_video(key: str) -> None: + w, h = 960, 540 + out_dir = OUT / key + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / "example.mp4" + writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), _VIDEO_FPS, (w, h)) + if not writer.isOpened(): + raise RuntimeError("mp4v writer unavailable") + frame = _video_mark_frame(key, w, h) + for _ in range(_VIDEO_FRAMES): + writer.write(frame) + writer.release() + + +def verify_video(key: str) -> tuple[float, str | None, int]: + from remove_ai_watermarks.video import identify_video + + rep = identify_video(OUT / key / "example.mp4", check_visible=True) + return float(rep.visible_detected_frames or 0), rep.visible_mark, rep.total_frames + + +def render_videos() -> list[str]: + from remove_ai_watermarks.video import VIDEO_VISIBLE_MARKS + + failures: list[str] = [] + for key in VIDEO_VISIBLE_MARKS: + build_video(key) + frames, mark, total = verify_video(key) + status = "OK " if mark == key else "MISS" + print(f"{status} {key:10s} video: {mark} on {frames}/{total} frames -> data/fixtures/visible/{key}/example.mp4") + if mark != key: + failures.append(key) + return failures + + +def main() -> None: + failures: list[str] = [] + for mark in wr.known_marks(): + key = mark.key + img = build(key) + out_dir = OUT / key + out_dir.mkdir(parents=True, exist_ok=True) + path = out_dir / "example.png" + cv2.imwrite(str(path), img) + det = wr.get_mark(key).detect(imread(str(path)), provenance=False) + status = "OK " if det.detected else "MISS" + print(f"{status} {key:12s} conf={det.confidence:.3f} -> {path.relative_to(_ROOT)}") + if not det.detected: + failures.append(key) + failures += render_videos() + if failures: + print(f"\nNOT DETECTED on their own examples: {failures}", file=sys.stderr) + raise SystemExit(1) + + +if __name__ == "__main__": + main() diff --git a/scripts/vendor_cohort_harvest.py b/scripts/vendor_cohort_harvest.py index 3ec2561..f61226d 100644 --- a/scripts/vendor_cohort_harvest.py +++ b/scripts/vendor_cohort_harvest.py @@ -15,7 +15,7 @@ THE KEY ``001191110102MACQD9K64010000`` -> USCC ``91110102MACQD9K640``, which names a specific legal entity. So the metadata partitions carriers into per-ENTITY cohorts without looking at a single pixel. A cohort is a LABEL: once one frame - in it is eyeballed, every frame in it is a labelled example of that vendor's + in it is eyeballed, every frame in it is a labeled example of that vendor's mark. That is what turns "one confirmed positive" into "30+ per vendor". CLAUDE.md's "the generic TC260 label names no specific vendor" is about the diff --git a/scripts/vendor_mark_calibrate.py b/scripts/vendor_mark_calibrate.py index ebe1bdc..ecdd12d 100644 --- a/scripts/vendor_mark_calibrate.py +++ b/scripts/vendor_mark_calibrate.py @@ -14,7 +14,7 @@ WHERE THE POSITIVES COME FROM absence) visible directly. NEGATIVES - The 432 frames hand-labelled `present: []` in the 2026-07-18 round -- already-adjudicated + The 432 frames hand-labeled `present: []` in the 2026-07-18 round -- already-adjudicated no-visible-mark images, so the false-fire arm rests on human labels rather than on the absence of a detection. @@ -115,7 +115,7 @@ def load_sets(cohort: str) -> tuple[list[str], list[str]]: # The 2026-07-18 labels are in the vocabulary of the REGISTERED marks only # (gemini/doubao/jimeng/jimeng_pill): `present: []` means "no registered mark", NOT # "no mark at all" -- 146 of the 432 sit in a TC260 cohort, and qwen-cohort frames - # visibly carrying 千问AI生成 are labelled `present: []` there (measured 2026-07-21: + # visibly carrying 千问AI生成 are labeled `present: []` there (measured 2026-07-21: # they made up the clean arm's whole top tail, clean p99 0.37 -> 0.69). A gate read # off that arm is meaningless, so the clean arm excludes every frame in ANY TC260 # cohort -- cohort membership is the cheap proxy for "may carry a CJK AI label". @@ -168,7 +168,7 @@ def report(pos: list[dict[str, Any]], neg: list[dict[str, Any]], name: str) -> N p = np.array([r["score"] for r in pos]) n = np.array([r["score"] for r in neg]) - print(f"\n{'=' * 78}\n{name}: candidate-cohort vs hand-labelled clean\n{'=' * 78}") + print(f"\n{'=' * 78}\n{name}: candidate-cohort vs hand-labeled clean\n{'=' * 78}") print(f"\n{'arm':10s} {'n':>5s} {'p10':>7s} {'p50':>7s} {'p90':>7s} {'p95':>7s} {'p99':>7s} {'max':>7s}") for label, arr in (("cohort", p), ("clean", n)): if not len(arr): @@ -602,7 +602,7 @@ def main() -> None: crossfire(pools, cand, a.workers, a.gate) return - print(f"cohort {a.cohort}: {len(pos_paths)} candidates | clean: {len(neg_paths)} hand-labelled") + print(f"cohort {a.cohort}: {len(pos_paths)} candidates | clean: {len(neg_paths)} hand-labeled") print(f"scale_basis={a.scale_basis} overrides={overrides}") pos = run(pos_paths, a.asset, name, a.workers, a.scale_basis, overrides) neg = run(neg_paths, a.asset, name, a.workers, a.scale_basis, overrides) diff --git a/scripts/visible_eval.py b/scripts/visible_eval.py index a1ec918..bbb1b42 100644 --- a/scripts/visible_eval.py +++ b/scripts/visible_eval.py @@ -1,7 +1,7 @@ """Benchmark harness for the visible-mark detectors. Run this before AND after any detector change. It re-runs perception over the -hand-labelled ground truth and reports, per mark, how often a fire is correct -- +hand-labeled ground truth and reports, per mark, how often a fire is correct -- with Wilson intervals, so a change inside the noise is visible as such. uv run python scripts/visible_eval.py # score current code @@ -10,10 +10,10 @@ with Wilson intervals, so a change inside the noise is visible as such. WHAT THIS SET CAN AND CANNOT MEASURE -- read before quoting a number: - * PRECISION: sound. Every labelled crop is centred on the region a detector + * PRECISION: sound. Every labeled crop is centered on the region a detector pointed at, so "the detector fired mark K here, was K actually there" is exactly the question the labels answer. - * RECALL: NOT measurable here, and the harness refuses to print it. The labelled + * RECALL: NOT measurable here, and the harness refuses to print it. The labeled images were SAMPLED WHERE DETECTORS FIRED (relaxation additions plus controls), so images carrying a mark that every detector missed are absent by construction. Computing recall on this set would divide by a denominator that excludes exactly @@ -133,7 +133,7 @@ def report(res: dict, prev: dict | None = None) -> None: conf = {k: v for k, v in res["confusion"][m].items() if k != m} if conf: print(f" {m:12s} {dict(sorted(conf.items(), key=lambda kv: -kv[1]))}") - print("\n'scope' = images whose crop could rule on that mark; 'missed' = labelled marks it did not fire on.") + print("\n'scope' = images whose crop could rule on that mark; 'missed' = labeled marks it did not fire on.") print("NOTE: 'missed' is NOT recall -- this set was sampled where detectors fired, so images") print(" every detector missed are absent by construction. Use it only to catch a change") print(" LOSING marks it used to find; an unbiased random sample is needed for true recall.") diff --git a/scripts/visible_groundtruth.py b/scripts/visible_groundtruth.py index 4372945..1e62533 100644 --- a/scripts/visible_groundtruth.py +++ b/scripts/visible_groundtruth.py @@ -1,6 +1,6 @@ -"""Consolidate the hand-labelled contact-sheet rounds into ONE ground-truth file. +"""Consolidate the hand-labeled contact-sheet rounds into ONE ground-truth file. -Ground truth is `uid -> the set of visible marks actually present`, hand-labelled +Ground truth is `uid -> the set of visible marks actually present`, hand-labeled blind against contact sheets with a two-sided control in every round. Rounds so far: 2026-07-18 text-mark/pill round : 423 cells (doubao / jimeng / jimeng_pill arms) @@ -10,20 +10,20 @@ DATA SAFETY: treat the input dataset as sensitive. This script reads a gitignore dataset and writes a gitignored ground-truth file. Neither the images nor this output may be committed; only the harness is. See the repo CLAUDE.md. -The labels record what the LABELLER SAW in the crop, one of: +The labels record what the labeler saw in the crop, one of: doubao | jimeng | pill | sparkle | other_ai_label | none | uncertain `other_ai_label` is a real visible AI label from a vendor we do NOT have a mark for (千问 / 百度 / 星绘 / 抖音); it is NOT a positive for any registered mark, but it is also not "clean" -- it is exactly what the relaxed jimeng detector confuses. -`uncertain` rows are EXCLUDED from scoring rather than coerced, so a labeller's +`uncertain` rows are EXCLUDED from scoring rather than coerced, so a labeler's honest doubt never becomes a fabricated data point. """ from __future__ import annotations +import argparse import csv import json -import sys from pathlib import Path SEEN_TO_MARK = { @@ -32,7 +32,7 @@ SEEN_TO_MARK = { "pill": "jimeng_pill", "sparkle": "gemini", } -# Which marks a crop centred on `key` lets the labeller rule on (same corner = visible +# Which marks a crop centered on `key` lets the labeler rule on (same corner = visible # in the same crop). Doubao and Jimeng share the bottom-right corner. _ADJUDICATES = { "doubao": ("doubao", "jimeng"), @@ -46,6 +46,7 @@ ROUNDS = [ ("textmark", "labels.csv", "manifest.csv"), ("gemini", "gemini_labels.csv", "gemini_manifest.csv"), ] +_HELP_DESCRIPTION = "Consolidate hand-labeled contact-sheet rounds into one ground-truth file." def metadata_provenance(path: str) -> list[str]: @@ -77,7 +78,15 @@ def metadata_provenance(path: str) -> list[str]: def main() -> None: - root = Path(sys.argv[1] if len(sys.argv) > 1 else ".local-eval/textmark-relaxation") + parser = argparse.ArgumentParser(description=_HELP_DESCRIPTION) + parser.add_argument( + "root", + type=Path, + nargs="?", + default=Path(".local-eval/textmark-relaxation"), + help="Directory containing the blinded labeling rounds", + ) + root = parser.parse_args().root out = root / "groundtruth.jsonl" rows: dict[str, dict] = {} stats: dict[str, int] = {} @@ -97,8 +106,8 @@ def main() -> None: m["uid"], {"uid": m["uid"], "path": m["path"], "present": [], "seen": [], "rounds": [], "adjudicated": []}, ) - # ADJUDICATION SCOPE -- load-bearing. A crop centred on one mark only lets - # the labeller rule on marks visible IN THAT CROP. A pill crop (top-left) + # ADJUDICATION SCOPE -- load-bearing. A crop centered on one mark only lets + # the labeler rule on marks visible IN THAT CROP. A pill crop (top-left) # says nothing about a bottom-right wordmark, so scoring jimeng against a # pill-round image would book real detections as false fires (~61% of pills # carry a wordmark). Bottom-right marks co-adjudicate each other: one crop diff --git a/scripts/visible_recall_sample.py b/scripts/visible_recall_sample.py index 5be5941..21547f2 100644 --- a/scripts/visible_recall_sample.py +++ b/scripts/visible_recall_sample.py @@ -1,8 +1,8 @@ """Build an UNBIASED random sample for measuring visible-mark RECALL. -Every earlier labelling round sampled where detectors FIRED, so images that every +Every earlier labeling round sampled where detectors FIRED, so images that every detector missed were absent by construction and recall was unmeasurable. This round -samples at random within a provenance class and shows the labeller the corners where +samples at random within a provenance class and shows the labeler the corners where a mark can physically be, so a MISSED mark is visible as such. Design decisions that matter: @@ -13,19 +13,19 @@ Design decisions that matter: recall over all uploads would mostly measure how often each vendor appears. * NATIVE RESOLUTION crops, never a downscaled whole image: a 220px preview destroys a faint mark (measured in an earlier round), which would inflate the miss count with - the labeller's own blindness rather than the detector's. + the labeler's own blindness rather than the detector's. * BOTH corners per image (top-left pill, bottom-right wordmark/strip/sparkle), so one pass adjudicates every registered mark instead of one mark per crop. * The detector's verdict is NOT shown and is not in the sheet order -- the manifest - holds it and must not be opened until labelling ends. + holds it and must not be opened until labeling ends. """ from __future__ import annotations +import argparse import csv import json import random -import sys from pathlib import Path from typing import TYPE_CHECKING, Any @@ -35,6 +35,8 @@ import numpy as np if TYPE_CHECKING: from numpy.typing import NDArray +import sys + sys.path.insert(0, str(Path(__file__).parent.parent / "src")) from remove_ai_watermarks.image_io import imread @@ -63,10 +65,16 @@ def corner_strip(img: NDArray[Any]) -> NDArray[Any] | None: def main() -> None: - scan = Path(sys.argv[1]) - out = Path(sys.argv[2]) - n_tc260 = int(sys.argv[3]) if len(sys.argv) > 3 else 160 - n_google = int(sys.argv[4]) if len(sys.argv) > 4 else 80 + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("scan", type=Path, help="JSONL corpus scan produced by the visible evaluation harness") + parser.add_argument("output", type=Path, help="Directory for contact sheets and the blinded manifest") + parser.add_argument("--tc260", type=int, default=160, help="Number of TC260 carriers to sample") + parser.add_argument("--google", type=int, default=80, help="Number of Google-provenance carriers to sample") + args = parser.parse_args() + scan = args.scan + out = args.output + n_tc260 = args.tc260 + n_google = args.google out.mkdir(parents=True, exist_ok=True) recs = [json.loads(line) for line in scan.open() if '"marks"' in line] diff --git a/scripts/visible_sheets.py b/scripts/visible_sheets.py index 2c70cef..a3fecc1 100644 --- a/scripts/visible_sheets.py +++ b/scripts/visible_sheets.py @@ -1,17 +1,18 @@ -"""Build BLIND contact sheets for hand-labelling relaxation additions. +"""Build BLIND contact sheets for hand-labeling relaxation additions. Crops are centered on the DETECTED REGION (not the corner), padded by ~0.9x the mark size, and resized to 240px with INTER_NEAREST -- a downscaled preview destroys a faint mark, so nothing here may smooth. The manifest is written to a separate file that must -NOT be read until labelling is finished. +NOT be read until labeling is finished. Each sheet mixes three strata in shuffled order: add - the relaxation additions whose precision we are measuring - pos - strict-consistent detections (a mark is really there): labeller sensitivity - clean - verified-clean negatives (no mark can be there): labeller specificity + pos - strict-consistent detections (a mark is really there): labeler sensitivity + clean - verified-clean negatives (no mark can be there): labeler specificity The two control strata are what make a low measured precision trustworthy. """ +import argparse import csv import json import random @@ -62,9 +63,13 @@ def crop(path: str, region: tuple[int, int, int, int] | None, pad_factor: float def main() -> None: - with open(sys.argv[1]) as fh: + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("input", type=Path, help="JSON candidate list") + parser.add_argument("output", type=Path, help="Directory for blinded contact sheets") + args = parser.parse_args() + with args.input.open() as fh: items = json.load(fh) # [{uid,path,key,stratum,conf}] - outdir = Path(sys.argv[2]) + outdir = args.output outdir.mkdir(parents=True, exist_ok=True) random.Random(1234).shuffle(items) # noqa: S311 -- sheet ordering, not cryptography diff --git a/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index d524f3e..72baacc 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -33,7 +33,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") _warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*") -__version__ = "0.31.1" +__version__ = "0.34.1" __all__ = [ "BatchSummary", diff --git a/src/remove_ai_watermarks/_internal/c2pa.py b/src/remove_ai_watermarks/_internal/c2pa.py index cee3da3..b933b3a 100644 --- a/src/remove_ai_watermarks/_internal/c2pa.py +++ b/src/remove_ai_watermarks/_internal/c2pa.py @@ -426,6 +426,11 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: source_types: list[str] = [] soft_binding_algorithms: list[str] = [] soft_binding_values: list[str] = [] + # Raw signer/generator identity strings, used to scope SynthID evidence to + # the vendor that actually asserted the manifest. A vendor token appearing + # anywhere else in the chain (e.g. Microsoft Designer's "Azure OpenAI + # ImageGen" softwareAgent) is a service name, not that vendor's provenance. + identity_strings: list[str] = [] claim_generator_asserts_ai = False def add_tool_matches(value: str, *, asserts_ai: bool = False) -> None: @@ -443,9 +448,11 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: value = signature.get(key) if isinstance(value, str): issuers.extend(_ordered_matches(value.encode(), C2PA_ISSUERS)) + identity_strings.append(value) direct_generator = manifest.get("claim_generator") if isinstance(direct_generator, str): + identity_strings.append(direct_generator) add_tool_matches(direct_generator, asserts_ai=True) candidates = manifest.get("claim_generator_info") @@ -456,6 +463,7 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: name = cast("dict[object, object]", candidate_value).get("name") if isinstance(name, str): add_tool_matches(name, asserts_ai=True) + identity_strings.append(name) assertions = manifest.get("assertions") if not isinstance(assertions, list): @@ -528,9 +536,14 @@ def _structured_manifest_fields(store: dict[str, Any]) -> dict[str, Any]: has_watermark_action = any(action.startswith("watermarked") for action in actions) if has_watermark_action: info["watermarked"] = True - if info.get("ai_source_kind"): - selected_bytes = json.dumps(chain, ensure_ascii=False).encode() - synthid = synthid_evidence_vendors_in(selected_bytes, has_watermark_action=has_watermark_action) + if info.get("ai_source_kind") and not soft_binding_algorithms: + # Evidence scope: only the signer/generator identity strings above, never + # the whole chain - a vendor named inside another vendor's manifest (the + # Designer case) must not turn into that vendor's SynthID provenance. A + # manifest that names its own forensic soft-binding algorithm carries + # that vendor's mark and is excluded from the generic inference entirely. + identity_bytes = json.dumps(identity_strings, ensure_ascii=False).encode() + synthid = synthid_evidence_vendors_in(identity_bytes, has_watermark_action=has_watermark_action) if synthid: info["synthid_vendors"] = synthid info["synthid_watermark"] = synthid_verdict(", ".join(synthid)) @@ -611,12 +624,16 @@ def _populate_registry_fields(buffer: bytes, info: dict[str, Any]) -> bool: if b"c2pa.watermarked" in buffer: info["watermarked"] = True - synthid = synthid_evidence_vendors_in(buffer, has_watermark_action=info.get("watermarked", False)) + soft_bindings = soft_binding_vendors_in(buffer) + synthid = ( + [] + if soft_bindings + else synthid_evidence_vendors_in(buffer, has_watermark_action=info.get("watermarked", False)) + ) if ai_source and synthid: info["synthid_vendors"] = synthid info["synthid_watermark"] = synthid_verdict(", ".join(synthid)) - soft_bindings = soft_binding_vendors_in(buffer) if soft_bindings: info["soft_binding_vendors"] = soft_bindings info["soft_binding"] = ", ".join(soft_bindings) diff --git a/src/remove_ai_watermarks/_internal/constants.py b/src/remove_ai_watermarks/_internal/constants.py index 7f7f416..1cd7a65 100644 --- a/src/remove_ai_watermarks/_internal/constants.py +++ b/src/remove_ai_watermarks/_internal/constants.py @@ -70,7 +70,7 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = ( _vendor( b"OpenAI", "OpenAI", - "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)", + "OpenAI (ChatGPT / GPT Image / DALL·E / Sora)", "OpenAI", synthid=True, synthid_requires_watermark_action=True, @@ -81,29 +81,32 @@ C2PA_AI_VENDORS: tuple[C2paAiVendor, ...] = ( _vendor( b"volcengine", "ByteDance (Volcano Engine)", - "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)", - "ByteDance", + "ByteDance Volcano Engine", + "Volcano Engine", ), _vendor( "北京火山引擎科技有限公司", "ByteDance (Volcano Engine)", - "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)", - "ByteDance", - ), - _vendor( - b"Byteplus", "BytePlus (ByteDance)", "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)", "ByteDance" + "ByteDance Volcano Engine", + "Volcano Engine", ), + _vendor(b"Byteplus", "BytePlus (ByteDance)", "BytePlus (ByteDance)", "BytePlus"), _vendor( b"Dreamina", "ByteDance (Dreamina)", - "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)", - "ByteDance", + "ByteDance Dreamina", + "Dreamina", asserts_ai=True, ), _vendor(b"Canva", "Canva", "Canva (Magic Media)", "Canva"), _vendor(b"Eleven Labs", "ElevenLabs", "ElevenLabs", "ElevenLabs"), _vendor(b"fal-ai", "fal.ai", "fal.ai", "fal.ai", asserts_ai=True), _vendor(b"Bria", "Bria Artificial Intelligence", "Bria AI", "Bria", asserts_ai=True), + # Ideogram signs its downloads' Content Credentials with "Ideogram, Inc"; the + # issuer token is the org prefix (same substring-match class as "Bria" in + # "Bria Artificial Intelligence"). Found as an unmapped signer on 4 corpus + # uploads 2026-08-08 that identify reported as unknown-signer C2PA. + _vendor(b"Ideogram", "Ideogram", "Ideogram", "Ideogram", asserts_ai=True), _vendor(b"Truepic", "Truepic", None, None), ) @@ -116,7 +119,7 @@ C2PA_IDENTITY_AI_ORGS = frozenset(vendor.org for vendor in C2PA_AI_VENDORS if ve C2PA_CLAIM_GENERATOR_PLATFORMS: tuple[tuple[str, str], ...] = ( ("adobe_firefly", "Adobe Firefly"), ("firefly", "Adobe Firefly"), - ("dreamina", "ByteDance (Doubao / Jimeng / Dreamina / Volcano Engine)"), + ("dreamina", "ByteDance Dreamina"), ("higgsfield ai", "Higgsfield AI"), ("topaz labs image api", "Topaz Labs"), ("tiktok ad creative toolbox", "TikTok Ad Creative Toolbox"), @@ -128,8 +131,8 @@ C2PA_AI_TOOLS = { ("GPT-4o", "GPT-4o"), ("ChatGPT", "ChatGPT"), ("Sora", "Sora"), - ("DALL-E", "DALL-E"), - ("DALL", "DALL-E"), + ("DALL-E", "DALL·E"), + ("DALL", "DALL·E"), ("Imagen", "Imagen"), ("Firefly", "Firefly"), ("Dreamina", "Dreamina"), @@ -138,16 +141,18 @@ C2PA_AI_TOOLS = { C2PA_SOFT_BINDINGS = { b"com.adobe.trustmark": "Adobe TrustMark", - b"com.adobe.icn": "Adobe (content fingerprint)", - b"com.digimarc": "Digimarc", + b"com.adobe.icn": "Adobe Image Comparator Network", + b"com.digimarc": "Digimarc Validate", b"com.imatag.lamark": "Imatag (Lamark)", b"ai.steg": "Steg.AI", b"com.microsoft.invismark": "Microsoft InvisMark", b"com.microsoft.wavmark": "Microsoft WavMark", b"com.verimatrix": "Verimatrix", b"com.nagra.nexguard": "NAGRA NexGuard", - b"com.aiwatermark": "AIWatermark (Meta PixelSeal)", - b"ai.trufo": "Trufo", + b"com.aiwatermark.pixelseal": "AIWatermark PixelSeal", + b"com.aiwatermark.videoseal": "AIWatermark VideoSeal", + b"com.aiwatermark.audioseal": "AIWatermark AudioSeal", + b"ai.trufo": "Trufo PawPrint", b"app.overlai": "Overlai", b"com.markany": "MarkAny", b"com.mentaport": "Mentaport", @@ -199,7 +204,7 @@ C2PA_ACTIONS = {f"c2pa.{action}".encode(): action for action in _C2PA_ACTION_NAM # what stops a newly registered TC260 vendor from silently falling back to ByteDance. # # What a TC260 label confirms when its producer is absent or unmapped. Historical -# behaviour, kept as the fallback so an unrecognized producer never regresses to no +# behavior, kept as the fallback so an unrecognized producer never regresses to no # relaxation at all: ByteDance's two products are the ones the relaxed band was # calibrated on (see _text_mark_engine._DEFAULT_PROVENANCE_NCC_FACTOR). TC260_FALLBACK_VENDORS: frozenset[str] = frozenset({"doubao", "jimeng"}) diff --git a/src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py b/src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py index 9cda34e..57d6eac 100644 --- a/src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py +++ b/src/remove_ai_watermarks/_internal/qwen_zimage_pipeline.py @@ -1074,29 +1074,14 @@ class QwenZImagePipeline: fidelity_anchor: bool = False, ) -> Image.Image: """Execute global regeneration and masked face repair.""" + if text_manifest is not None and tile: + raise ValueError("Verified text restoration is not calibrated with tiled diffusion") self._require_cuda() seed = resolve_seed(seed) donor = None if text_manifest is not None: self._progress("Reconstructing the verified text donor with the Qwen VAE...") - 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) + donor = self._qwen_vae_roundtrip(image) global_strength = ( resolution_adaptive_denoise(image.width, image.height) if strength is None else float(strength) ) diff --git a/src/remove_ai_watermarks/_internal/watermark_profiles.py b/src/remove_ai_watermarks/_internal/watermark_profiles.py index 1f0ddb9..c27bf2d 100644 --- a/src/remove_ai_watermarks/_internal/watermark_profiles.py +++ b/src/remove_ai_watermarks/_internal/watermark_profiles.py @@ -79,17 +79,34 @@ QWEN_ZIMAGE_GOOGLE_STRENGTH = 0.27 # 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, +# Microsoft's public detector (https://ai.azure.com/nextgen/validate) 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 +# Meta Muse Image stamps every output with Content Seal, but no provenance signal +# survives to route it: the outputs carry no C2PA, and their IPTC +# trainedAlgorithmicMedia companion tag is a standard code many platforms use, so +# it cannot key this cohort the way an issuer keys the others. The floor is +# therefore selected by an explicit --vendor meta override, never by detection. +# Derivation (oracle meta.ai/identification, 2026-08-26/27, corpus in +# data/contentseal/): five independent 2.56 MP generations bracketed at +# lighthouse (0.0525, 0.06], fox (0.03, 0.0375], night_city (0.03, 0.0375], +# mug <= 0.03, text <= 0.015. Worst clean boundary plus one full observed +# cross-source spread: 0.06 + (0.0525 - 0.015) = 0.0975, rounded up to 0.1. +# sdxl-zimage has no measured Meta floor; its vendor map stays without a meta +# entry so an explicit --vendor meta there falls to the unknown 0.25, which is +# above this floor and therefore conservative. +QWEN_ZIMAGE_META_STRENGTH = 0.1 + _QWEN_ZIMAGE_FLAT_STRENGTH_BY_VENDOR: dict[str, float] = { "google": QWEN_ZIMAGE_GOOGLE_STRENGTH, "openai": QWEN_ZIMAGE_OPENAI_STRENGTH, "microsoft": QWEN_ZIMAGE_MICROSOFT_STRENGTH, + "meta": QWEN_ZIMAGE_META_STRENGTH, } @@ -129,7 +146,8 @@ def strength_default_help() -> str: return ( "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"Microsoft InvisMark {QWEN_ZIMAGE_MICROSOFT_STRENGTH} / Meta Content Seal " + f"{QWEN_ZIMAGE_META_STRENGTH} floors; sdxl-zimage " f"uses OpenAI {SDXL_ZIMAGE_OPENAI_STRENGTH} / Google {SDXL_ZIMAGE_GEMINI_STRENGTH} / " f"unknown {SDXL_ZIMAGE_UNKNOWN_STRENGTH}, from the C2PA issuer)" ) @@ -167,8 +185,18 @@ def resolve_strength( return resolution_adaptive_denoise(*size) -def vendor_for_strength(image_path: Path) -> Literal["openai", "google", "microsoft"] | None: - """Select the strength cohort from non-invalid pixel-watermark provenance.""" +def vendor_for_strength(image_path: Path) -> Literal["openai", "google", "microsoft", "meta"] | None: + """Select the strength cohort from non-invalid pixel-watermark provenance. + + OpenAI / Google / Microsoft come from their C2PA issuers. Meta is the + fallback cohort: Muse Image carries no C2PA at all, and its only readable + companion is the IPTC ``trainedAlgorithmicMedia`` XMP tag -- a standard code + other platforms also use. Attributing that tag to Meta is a measured bet, + not an identification: the other tag users in this project's model (ByteDance + products, X) ship no invisible pixel watermark this profile targets, so the + worst misroute spends the Meta floor (0.1) where the resolution curve would + have spent a similar amount, and Google/OpenAI files never reach this arm + because their C2PA matched first.""" try: from remove_ai_watermarks._internal.c2pa import ( c2pa_info_has_invalid_credential, @@ -187,4 +215,25 @@ def vendor_for_strength(image_path: Path) -> Literal["openai", "google", "micros return "openai" if not c2pa_info_has_invalid_credential(info) and c2pa_info_has_invismark(info): return "microsoft" + if _standalone_iptc_ai_tag(image_path): + return "meta" return None + + +def _standalone_iptc_ai_tag(image_path: Path) -> bool: + """True when the file carries an AI IPTC marker with no C2PA around it. + + Mirrors identify's ``standalone_iptc`` condition (the tag is only + trustworthy as platform evidence when no manifest supersedes it) without + importing the heavy identify module: the shared chunk-aware + :func:`metadata.scan_head` window -- Muse WebP outputs place their XMP + packet in a tail chunk up to hundreds of KB past a plain head read, which + is exactly what scan_head's extensions exist to catch. + """ + try: + from remove_ai_watermarks.metadata import IPTC_AI_MARKERS, c2pa_marker_in, scan_head + + scan = scan_head(image_path) + except Exception: + return False + return any(marker in scan for marker in IPTC_AI_MARKERS) and not c2pa_marker_in(scan) diff --git a/src/remove_ai_watermarks/_text_mark_engine.py b/src/remove_ai_watermarks/_text_mark_engine.py index c9dd02a..da3542d 100644 --- a/src/remove_ai_watermarks/_text_mark_engine.py +++ b/src/remove_ai_watermarks/_text_mark_engine.py @@ -52,8 +52,8 @@ _MIN_DETECT_SHORT_SIDE = 200 # This used to be ONE shared 0.7 for every text mark. Measured 2026-07-18 on the # `auto` path (the default -- no flag, driven by TC260 metadata), it turned out to # mean two completely different things per mark. Blind hand-label of the ADDITIONS -# (accepted with provenance, rejected without) over a labelled TC260 evaluation set, -# two-sided control (labeller sensitivity 100%/96%, specificity 100%/100%): +# (accepted with provenance, rejected without) over a labeled TC260 evaluation set, +# two-sided control (labeler sensitivity 100%/96%, specificity 100%/100%): # # mark band precision 95% CI n # doubao whole arm 76% 61-87% 42 @@ -89,8 +89,8 @@ class TextMarkConfig: name: str # short label for log lines (e.g. "Doubao") asset_name: str # bundled alpha PNG under assets/ (e.g. "doubao_alpha.png") corner: Literal[ - "br", "bl", "tl", "bc" - ] # bottom-right (Doubao/Jimeng), bottom-left (Samsung), top-left (RunningHub), bottom-center (LibLibAI) + "br", "bl", "tl", "tr", "bc" + ] # br (Doubao/Jimeng), bl (Samsung), tl (RunningHub), tr (Microsoft), bc (LiblibAI) margin_floor: int # min margin in px for locate (4 for br marks, 2 for Samsung) # locate geometry (fraction of scale_base -- see scale_base()) width_frac: float @@ -131,7 +131,7 @@ class TextMarkConfig: template_blur: float = 0.0 # Which image dimension the mark's size and margins scale with. VENDOR-SPECIFIC, # measured, not assumed -- see TextMarkEngine.scale_base. "short" = min(h, w), "width" = w. - scale_basis: Literal["short", "width"] = "width" + scale_basis: Literal["short", "width", "long"] = "width" # Scale rungs ``_ladder_best`` sweeps (the detection comb). PER-MARK: a vendor # whose stamp sizes do not land on the shared 3-rung comb carries its own ladder # (measured for 千问, whose marks sit in two size modes ~1.6x apart -- one fraction @@ -309,7 +309,7 @@ class TextMarkEngine: provenance relaxation it stopped trying because many Jimeng false additions were actually Doubao marks. - Measured separability on hand-labelled examples, scoring BOTH templates + Measured separability on hand-labeled examples, scoring BOTH templates against the same glyph blob: feature separability (0.5 = useless, 1.0 = perfect) @@ -480,10 +480,19 @@ class TextMarkEngine: China's GB 45438-2025 clause 5.2(e) mandates glyph height >= 5% of "the shortest side" for CN marks, which is why a short-side basis is the natural - prior -- but Jimeng's measured behaviour overrides the prior, and measurement + prior -- but Jimeng's measured behavior overrides the prior, and measurement wins over the standard's wording. + + "long" (max of the two sides) is the Microsoft badge's measured basis: the + pill tracks the RENDER dimension, so on a 1024x1536 portrait it scales with + the 1536 (a width basis undersized the template by the aspect ratio and the + portrait carriers fell to 0.15-0.32 NCC; measured 2026-08-27). """ - return min(image.shape[:2]) if self.config.scale_basis == "short" else image.shape[1] + if self.config.scale_basis == "short": + return min(image.shape[:2]) + if self.config.scale_basis == "long": + return max(image.shape[:2]) + return image.shape[1] def locate(self, image: NDArray[Any]) -> TextMarkLocation: """Anchor the watermark box in the configured corner, scaled by ``scale_basis``. @@ -499,14 +508,14 @@ class TextMarkEngine: wm_h = max(16, int(base * c.height_frac)) margin_x = max(c.margin_floor, int(base * c.margin_x_frac)) margin_b = max(c.margin_floor, int(base * c.margin_bottom_frac)) - if c.corner == "br": + if c.corner == "br" or c.corner == "tr": x = max(0, w - margin_x - wm_w) elif c.corner == "bc": # bottom-center: horizontally centered, margin_x unused x = max(0, (w - wm_w) // 2) else: x = min(margin_x, max(0, w - wm_w)) - # "tl" anchors at the top instead: margin_bottom_frac is then the TOP margin. - y = min(margin_b, max(0, h - wm_h)) if c.corner == "tl" else max(0, h - margin_b - wm_h) + # "tl"/"tr" anchor at the top instead: margin_bottom_frac is then the TOP margin. + y = min(margin_b, max(0, h - wm_h)) if c.corner in ("tl", "tr") else max(0, h - margin_b - wm_h) wm_w = min(wm_w, w - x) wm_h = min(wm_h, h - y) return TextMarkLocation(x=x, y=y, w=wm_w, h=wm_h) @@ -695,7 +704,7 @@ class TextMarkEngine: OVERRIDABLE, and the override contract is specifically the DETECTOR'S MATCH BOX: a mark whose removable footprint reaches beyond what the NCC localizes -- Baidu's - flat white tag right of the text run, LibLibAI's triangle logo left of the + flat white tag right of the text run, LiblibAI's triangle logo left of the wordmark -- supplies its own extension here and inherits the rest of the footprint path. The blob-bbox branch never routes through an override. """ @@ -771,7 +780,7 @@ class TextMarkEngine: """Footprint policy for a mark whose fill must be bounded by the DETECTOR's match box and never by the binary glyph blob. - Baidu's white tag has a flat interior a top-hat cannot answer, and LibLibAI's + Baidu's white tag has a flat interior a top-hat cannot answer, and LiblibAI's blob bleeds up into background structure; in both cases the blob bbox is measurably wrong and the NCC match box is right. ``force`` takes priority here, unlike the default policy: a ``--no-detect`` caller named the mark, so the whole diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index ae80ea9..7e20f13 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -73,7 +73,7 @@ def _tc260_vendors(path: Path) -> frozenset[str]: An absent, unreadable or unmapped producer falls back to the historical pair rather than to nothing: the caller has already established that the AIGC signal fired, so - the image IS China-AIGC labelled, and dropping to no relaxation would lose the + the image IS China-AIGC labeled, and dropping to no relaxation would lose the detections the fallback recovers today. The re-read is deliberately isolated -- a failure here must narrow the answer, never discard the rest of the provenance. """ @@ -156,9 +156,10 @@ def remove_visible( ) -> tuple[NDArray[Any], list[str]]: """Remove every detected known visible AI mark through localize then fill. - The registry currently covers the Gemini sparkle; Doubao, Jimeng, Qwen, Kling, - Yuanbao, Samsung, RunningHub, Baidu, and LibLibAI text marks; and the Jimeng - pill. Returns ``(result_bgr, [labels removed])``. + The registry currently covers the Gemini visible watermark; Doubao, Jimeng, + Qwen, Kling AI, Yuanbao, Samsung, RunningHub, Baidu, and LiblibAI text marks; + one Microsoft top-right AI-badge variant; and the Jimeng pill. Returns + ``(result_bgr, [labels removed])``. ``source`` is a file path OR a BGR ndarray. For a PATH, metadata provenance is read automatically (so ``sensitivity="auto"`` recovers a moved/faint mark whenever the @@ -184,7 +185,7 @@ def remove_visible( from remove_ai_watermarks import watermark_registry # Reject a removed sensitivity loudly; `Sensitivity` is a Literal and not enforced - # at runtime, so a 0.15 caller would otherwise get `auto` behaviour in silence. + # at runtime, so a 0.15 caller would otherwise get `auto` behavior in silence. watermark_registry.validate_sensitivity(sensitivity) loaded = _load_visible_input(source) result, removed = watermark_registry.remove_auto_marks( @@ -232,6 +233,7 @@ class InvisibleOptions: strength: float | None = None pipeline: str = "qwen-zimage" + vendor: str | None = None seed: int | None = None hf_token: str | None = None humanize: float = 0.0 @@ -487,13 +489,17 @@ def _run_invisible( if not is_available(): say("invisible", "unavailable") return "unavailable" - if not (force or evidence.has_invisible_target()): + if not (force or opts.vendor is not None or evidence.has_invisible_target()): say("invisible", "no-signal") return "no-signal" from remove_ai_watermarks._internal.watermark_profiles import resolve_strength, vendor_for_strength - vendor = vendor_for_strength(vendor_source) + # An explicit vendor override wins over detection and implies the scrub runs: + # naming the cohort (e.g. "meta" for Muse Image Content Seal, which carries no + # provenance to detect) asserts the pixel watermark is present, so the no-signal + # gate must not skip it. + vendor = opts.vendor or vendor_for_strength(vendor_source) # Report the strength the engine will actually execute, resolved the same way it # resolves it, so the reported value cannot drift from the executed one. with suppress(Exception): diff --git a/src/remove_ai_watermarks/assets/microsoft_alpha.png b/src/remove_ai_watermarks/assets/microsoft_alpha.png new file mode 100644 index 0000000..fc53335 Binary files /dev/null and b/src/remove_ai_watermarks/assets/microsoft_alpha.png differ diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index 3282a7c..75857d6 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -274,6 +274,32 @@ _strength_option = click.option( default=None, help=f"Denoising strength (0.0-1.0). Default: {strength_default_help()}.", ) +# Explicit strength-cohort override. Auto-detection reads the C2PA issuer, so it +# covers OpenAI / Google / Microsoft; Meta Content Seal has no provenance signal +# (no C2PA; the IPTC tag is a standard code), and an unknown or stripped manifest +# also leaves the resolution-adaptive curve in charge -- this flag is the way to +# name the cohort when the user knows what the file does not say. +_vendor_option = click.option( + "--vendor", + type=click.Choice(["auto", "openai", "google", "microsoft", "meta"]), + default="auto", + help=( + "Strength cohort for the invisible-removal default, and it implies the scrub " + "runs even without a local signal: naming the cohort asserts the pixel " + "watermark is present. auto: derive from C2PA provenance, else " + "resolution-adaptive. Set explicitly when the source is known but unreadable " + "(e.g. meta for Muse Image Content Seal, which never carries C2PA)." + ), +) + + +def _explicit_vendor(vendor: str | None) -> str | None: + """Normalize --vendor's ``auto`` default to None for the engine/API seam. + + One helper so the three diffusion commands cannot drift on the spelling.""" + return None if vendor in (None, "auto") else vendor + + _seed_option = click.option( "--seed", type=int, @@ -281,7 +307,7 @@ _seed_option = click.option( help="Random seed for reproducibility. Default 0: both profiles are certified " "at a fixed seed, because SynthID removal near the strength floor is seed-dependent.", ) -_hf_token_option = click.option("--hf-token", type=str, default=None, help="HuggingFace API token.") +_hf_token_option = click.option("--hf-token", type=str, default=None, help="Hugging Face API token.") _humanize_option = click.option( "--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)." ) @@ -515,7 +541,7 @@ def _should_skip_invisible_scrub(force: bool, image_path: Path) -> bool: @click.option("-v", "--verbose", is_flag=True, help="Enable verbose logging.") @click.pass_context def main(ctx: click.Context, verbose: bool) -> None: - """Remove visible and invisible AI watermarks from images, plus provenance metadata from video.""" + """Remove visible and invisible AI watermarks, plus metadata provenance marks, from images and video.""" from dotenv import load_dotenv load_dotenv() # Load .env (e.g. HF_TOKEN) @@ -796,6 +822,7 @@ def cmd_erase( @click.argument("source", type=click.Path(exists=True, dir_okay=False, path_type=Path)) @_output_option @_strength_option +@_vendor_option @_pipeline_option @_seed_option @_hf_token_option @@ -815,6 +842,7 @@ def cmd_invisible( source: Path, output: Path | None, strength: float | None, + vendor: str | None, pipeline: str, seed: int | None, hf_token: str | None, @@ -831,7 +859,7 @@ def cmd_invisible( text_manifest: Path | None, fidelity_anchor: bool, ) -> None: - """Remove invisible AI watermarks (SynthID, StableSignature, TreeRing). + """Attempt to disrupt invisible AI watermarks through pixel regeneration. Regenerates the pixels with the two-stage diffusion profile. CUDA-only: pip install 'remove-ai-watermarks[qwen-zimage]' @@ -851,11 +879,17 @@ def cmd_invisible( if output is None: output = source.with_stem(source.stem + "_clean") + # An explicit --vendor wins over detection (see the option help) and implies the + # scrub runs: naming the cohort asserts the pixel watermark is present, so the + # no-signal gate must not skip it. Resolved BEFORE the gate for the same reason. + resolved_vendor = _explicit_vendor(vendor) + # Gate BEFORE building the engine: skip the destructive regeneration when no # invisible AI watermark is locally detectable (it would only degrade a clean # image -- dominant paid score-0 cause), so the common skip path pays nothing for - # engine construction. A skip never claims the image is clean; --force overrides. - if _should_skip_invisible_scrub(force, source): + # engine construction. A skip never claims the image is clean; --force and an + # explicit --vendor override. + if _should_skip_invisible_scrub(force or resolved_vendor is not None, source): _no_invisible_signal_exit(source) def progress_cb(msg: str) -> None: @@ -870,11 +904,18 @@ def cmd_invisible( ) # Detect the SynthID vendor from the ORIGINAL (before processing strips C2PA) so the - # displayed and executed strength agree on the vendor-adaptive default. - vendor = vendor_for_strength(source) + # displayed and executed strength agree on the vendor-adaptive default. An explicit + # --vendor override wins over detection: it names a cohort the file cannot prove + # (Meta Content Seal never carries C2PA; a stripped manifest proves nothing). + detected_vendor = vendor_for_strength(source) if resolved_vendor is None else None + vendor_label = resolved_vendor or detected_vendor + vendor_note = " (override)" if resolved_vendor else "" console.print(f" Input: {source.name}") console.print(f" Pipeline: {pipeline}") - console.print(f" Strength: {_resolved_strength_for_display(source, strength, vendor, pipeline)}") + console.print( + f" Strength: {_resolved_strength_for_display(source, strength, vendor_label, pipeline)}" + + (f" [vendor: {vendor_label}{vendor_note}]" if vendor_label else "") + ) t0 = time.monotonic() try: @@ -887,7 +928,7 @@ def cmd_invisible( unsharp=unsharp, adaptive_polish=adaptive_polish, max_resolution=max_resolution, - vendor=vendor, + vendor=vendor_label, tile=tile, tile_size=tile_size, tile_overlap=tile_overlap, @@ -960,8 +1001,8 @@ def cmd_metadata( Strips EXIF AI tags, PNG text chunks, C2PA provenance manifests, and the China TC260 AIGC label. Beyond images (PNG/JPEG/WebP/AVIF/HEIF/JXL) it also strips provenance metadata from MP4/MOV/M4V/M4A containers and, via ffmpeg, - from WebM/MKV/AVI/FLV/MP3/WAV/FLAC/OGG. The coded image, audio, and video - data are left untouched. + from WebM/MKV/MKA/AVI/FLV/MP3/WAV/FLAC/OGG/OGA/Opus/AAC. The coded image, + audio, and video data are left untouched. """ from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, strip_and_verify @@ -1493,6 +1534,7 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo @_visible_backend_option @_visible_sensitivity_option @_strength_option +@_vendor_option @_pipeline_option @_seed_option @_hf_token_option @@ -1514,6 +1556,7 @@ def cmd_all( backend: str, sensitivity: str, strength: float | None, + vendor: str | None, pipeline: str, seed: int | None, hf_token: str | None, @@ -1594,6 +1637,7 @@ def cmd_all( sensitivity=_parse_sensitivity(sensitivity), invisible=InvisibleOptions( strength=strength, + vendor=_explicit_vendor(vendor), pipeline=pipeline, seed=seed, hf_token=hf_token, @@ -1688,6 +1732,7 @@ def _batch_engine(mode: str, options: InvisibleOptions) -> object | None: @_visible_backend_option @_visible_sensitivity_option @_humanize_option +@_vendor_option @_pipeline_option @_seed_option @_hf_token_option @@ -1705,6 +1750,7 @@ def cmd_batch( mode: str, output_dir: Path | None, strength: float | None, + vendor: str | None, pipeline: str, seed: int | None, hf_token: str | None, @@ -1742,6 +1788,7 @@ def cmd_batch( invisible_options = InvisibleOptions( strength=strength, + vendor=_explicit_vendor(vendor), pipeline=pipeline, seed=seed, hf_token=hf_token, diff --git a/src/remove_ai_watermarks/dwt_dct.py b/src/remove_ai_watermarks/dwt_dct.py index ba6fe13..f557879 100644 --- a/src/remove_ai_watermarks/dwt_dct.py +++ b/src/remove_ai_watermarks/dwt_dct.py @@ -97,7 +97,7 @@ class _DecodeMaxDct: return decoded def _plane_bits(self, trimmed: NDArray[Any], channel: int, scale: int) -> NDArray[Any]: - """Block bits for one colour plane, a strip of block-rows at a time. + """Block bits for one color plane, a strip of block-rows at a time. ``dwt2`` is ``dwtn``: it transforms along axis 0, then along axis 1 over both halves, and three of the four bands it returns are discarded here. diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index c5c6bd3..6198f4e 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -135,12 +135,17 @@ _C2PA_INVALID_CAVEAT = ( "are retained only as removal hints, not as verified provenance." ) _IPTC_ONLY_CAVEAT = "The IPTC 'Made with AI' tag flags AI provenance but does not identify the specific platform." +_CONTENT_SEAL_CAVEAT = ( + "Meta Muse Image outputs carry the invisible Content Seal pixel watermark, which has no " + "local decoder; `invisible` removes it (auto when this tag is present, or `--vendor meta` " + "on stripped files) and meta.ai/identification verifies it." +) _INVISIBLE_WM_CAVEAT = ( "The open invisible watermark is fragile: it does not survive JPEG re-encoding " "or resizing, so it confirms origin only on a pristine (un-re-encoded) file." ) _HF_JOB_CAVEAT = ( - "The hf-job-id tag marks a HuggingFace-hosted job (commonly diffusion " + "The hf-job-id tag marks a Hugging Face-hosted job (commonly diffusion " "generation) but names neither the model nor the content type, so it is a " "medium-confidence signal, not proof the pixels are AI-generated." ) @@ -440,7 +445,7 @@ def evidence_from_metadata_record( if iptc_system: ai_metadata.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({iptc_system})") if hf_job: - ai_metadata.setdefault("huggingface_job", f"HuggingFace-hosted job ({hf_job})") + ai_metadata.setdefault("huggingface_job", f"Hugging Face-hosted job ({hf_job})") if samsung is not None: ai_metadata.setdefault("samsung_genai", f"Samsung Galaxy AI editing marker (genAIType={samsung})") @@ -948,7 +953,7 @@ def _visible_sparkle(image_path: Path, *, image: NDArray[Any] | None = None) -> # metadata label); the per-engine detection thresholds live in the registry. # Text mark -> the platform sentence this report prints when that mark is the strongest # evidence, DERIVED from the registry rows so registering a mark is one edit. It was a -# hand-maintained copy, and that class of copy is how LibLibAI ended up registered but +# hand-maintained copy, and that class of copy is how LiblibAI ended up registered but # missing from the pill veto. Insertion order is the registry's, which is what fixes the # scan order below. The Gemini sparkle and the capture-less pill carry no platform of # their own (`KnownMark.platform is None`) and are excluded here: the sparkle has its @@ -1100,7 +1105,7 @@ def _collect_visible_signals( sparkle_conf = _visible_sparkle(image_path, image=image) if sparkle_conf is not None and sparkle_conf >= _SPARKLE_THRESHOLD: signals.append(Signal("visible_sparkle", f"NCC confidence {sparkle_conf:.2f}", "medium")) - watermarks.append(f"Visible Gemini sparkle (confidence {sparkle_conf:.2f})") + watermarks.append(f"Google Gemini visible watermark (sparkle; confidence {sparkle_conf:.2f})") if platform is None: platform = "Google Gemini family (visible sparkle detected)" @@ -1268,7 +1273,19 @@ def _identify_from_evidence( # reusing the derived `has_c2pa` / `source_kind` above, which are broader: # the file path's answer must not move. trained_source = b"trainedAlgorithmicMedia" in head or b"TrainedAlgorithmicMedia" in head - if not synthid and trained_source and c2pa_marker_in(head) and (vendors := synthid_evidence_vendors_in(region)): + # Same suppression as every other inference site: bytes that name their own + # forensic soft-binding algorithm carry that vendor's mark, and the generic + # vendor-token inference must not add a second, differently-attributed + # invisible watermark (Microsoft Designer: "Azure OpenAI ImageGen" agent + + # the InvisMark watermarked action read as "SynthID per OpenAI"). + soft_binding_vendors = soft_binding_vendors_in(region) + if ( + not synthid + and trained_source + and c2pa_marker_in(head) + and not soft_binding_vendors + and (vendors := synthid_evidence_vendors_in(region)) + ): synthid = synthid_verdict(", ".join(vendors)) if synthid: watermarks.append( @@ -1283,7 +1300,7 @@ def _identify_from_evidence( # ── C2PA soft-binding: a named forensic/third-party watermark vendor ─ # (Adobe TrustMark, Digimarc, Imatag, ...). Present in the manifest even when # the watermark itself can't be decoded; names whose watermark stamped the pixels. - soft_binding = meta.get("soft_binding") or (", ".join(v) if (v := soft_binding_vendors_in(region)) else None) + soft_binding = meta.get("soft_binding") or (", ".join(soft_binding_vendors) if soft_binding_vendors else None) if soft_binding: soft_binding_algorithm = meta.get("soft_binding_algorithm") or info.get("soft_binding_algorithm") soft_binding_value = meta.get("soft_binding_value") or info.get("soft_binding_value") @@ -1317,17 +1334,38 @@ def _identify_from_evidence( if standalone_iptc: signals.append(Signal("iptc", "digitalSourceType (Made with AI)", "high")) watermarks.append("IPTC digitalSourceType (Made with AI)") + # Muse Image stamps every output with the invisible Content Seal, and this + # tag is the only provenance such a file carries - the same measured bet + # the strength router makes (vendor_for_strength -> "meta"). Emit the seal + # as its own stable signal, the way InvisMark is additive over + # soft_binding, so clients select pixel removal from the signal list + # instead of parsing caveats. It is an attribution, not a decode: no + # public Content Seal decoder exists, hence "medium". + signals.append( + Signal( + "content_seal", + "Meta Muse Content Seal pixel watermark (attributed by the standalone AI digital-source tag)", + "medium", + ) + ) + watermarks.append("Invisible Content Seal watermark (Meta Muse attribution)") caveats.append(_IPTC_ONLY_CAVEAT) + caveats.append(_CONTENT_SEAL_CAVEAT) if platform is None: # Apple Photos Clean Up (Apple Intelligence object removal) marks # the edit with photoshop:Credit / IPTC "Apple Photos Clean Up" # next to compositeWithTrainedAlgorithmicMedia. It was detected but # previously never attributed. - platform = ( - "Apple Photos (Clean Up AI edit)" - if b"Apple Photos Clean Up" in head - else "Made-with-AI tag (e.g. Meta AI); platform not specified" - ) + if b"Apple Photos Clean Up" in head: + platform = "Apple Photos (Clean Up AI edit)" + else: + # The platform line follows the same measured bet the seal signal + # and the strength router make: Muse Image is the tag writer whose + # outputs this profile targets, so a hedged Muse attribution is + # more useful than "platform not specified" while the panel below + # already prices the Content Seal removal. The hedge stays in the + # wording - it names the attribution basis, not a detection. + platform = "Meta Muse Image (attributed by the standalone AI digital-source tag)" # ── IPTC 2025.1 AI-disclosure fields (Iptc4xmpExt:AISystemUsed etc.) ─ iptc_ai = any(m in head for m in IPTC_AI_FIELD_MARKERS) @@ -1388,17 +1426,17 @@ def _identify_from_evidence( platform = "xAI (Grok / Aurora)" ai_vendor_claims["xai"] = "xAI" - # ── HuggingFace-hosted job marker (hf-job-id PNG text chunk) ───── + # ── Hugging Face-hosted job marker (hf-job-id PNG text chunk) ───── # Marks the hosting job, not a model -- medium confidence (commonly diffusion # output). Like the visible sparkle, it lifts an otherwise-Unknown verdict to # a tentative AI, but never overrides a high-confidence metadata signal. hf_job = evidence.huggingface_job if hf_job: - signals.append(Signal("hf_job", f"HuggingFace job {hf_job}", "medium")) - watermarks.append("HuggingFace-hosted job (hf-job-id)") + signals.append(Signal("hf_job", f"Hugging Face job {hf_job}", "medium")) + watermarks.append("Hugging Face-hosted job (hf-job-id)") caveats.append(_HF_JOB_CAVEAT) if platform is None: - platform = "HuggingFace-hosted job (model not identified)" + platform = "Hugging Face-hosted job (model not identified)" # ── Samsung Galaxy AI editing marker (genAIType) ───────────────── # Galaxy AI tools stamp a proprietary genAIType in PhotoEditor_Re_Edit_Data. diff --git a/src/remove_ai_watermarks/invisible_engine.py b/src/remove_ai_watermarks/invisible_engine.py index cdf9c1e..e2ea1c4 100644 --- a/src/remove_ai_watermarks/invisible_engine.py +++ b/src/remove_ai_watermarks/invisible_engine.py @@ -33,7 +33,7 @@ warnings.filterwarnings("ignore", category=UserWarning, module="huggingface_hub" warnings.filterwarnings("ignore", category=UserWarning, module="diffusers") warnings.filterwarnings("ignore", module="transformers") -# Suppress HuggingFace internal logging +# Suppress Hugging Face internal logging os.environ["TRANSFORMERS_VERBOSITY"] = "error" os.environ["DIFFUSERS_VERBOSITY"] = "error" @@ -104,7 +104,7 @@ class InvisibleEngine: global pass, vendor-adaptive strength because an SDXL global stage needs more of it). BOTH ARE CUDA-ONLY -- there is no CPU or MPS path for invisible-watermark removal. - hf_token: HuggingFace API token. + hf_token: Hugging Face API token. progress_callback: Optional callback for progress messages. controlnet_conditioning_scale: Canny ControlNet structure-preservation strength on the global stage of both profiles. @@ -187,9 +187,8 @@ class InvisibleEngine: pixels. Enables the experimental Qwen-VAE ``vae-glyphs`` post-pass. Requires the ``text-restoration`` extra and the ``qwen-zimage`` profile. Incompatible with downscaling, humanize, unsharp, and - adaptive polish. Tiling is supported: the VAE donor uses the same - overlapping tiles as the global pass, then glyph restore runs on - the blended full frame. + adaptive polish. Tiling is rejected because that combination has + no provider-oracle calibration. fidelity_anchor: Blend 15% of the Qwen-VAE donor across the whole frame before glyph restoration. OFF by default since 0.27.1: that global blend was measured to return detector-visible OpenAI SynthID on @@ -211,6 +210,8 @@ class InvisibleEngine: if text_manifest is not None: if self._remover.model_profile != QWEN_ZIMAGE_PROFILE: raise ValueError("--text-manifest is supported only by the qwen-zimage profile") + if tile: + raise ValueError("--text-manifest is not calibrated with --tile") if max_resolution != 0: raise ValueError("--text-manifest requires --max-resolution 0") if humanize > 0.0 or unsharp > 0.0 or adaptive_polish: diff --git a/src/remove_ai_watermarks/invisible_watermark.py b/src/remove_ai_watermarks/invisible_watermark.py index d693f00..3a73eea 100644 --- a/src/remove_ai_watermarks/invisible_watermark.py +++ b/src/remove_ai_watermarks/invisible_watermark.py @@ -99,7 +99,7 @@ def detect_invisible_watermark(image_path: Path, *, image: NDArray[Any] | None = # ``image`` lets a caller that has already decoded these pixels hand them in # (mirrors gemini_engine.detect_sparkle_confidence). The decoder only reads the - # array -- it converts colour spaces into fresh buffers -- so no copy is needed. + # array -- it converts color spaces into fresh buffers -- so no copy is needed. img = image if image is not None else image_io.imread(image_path) if img is None: return None diff --git a/src/remove_ai_watermarks/kling_engine.py b/src/remove_ai_watermarks/kling_engine.py index 757458b..e57c6c5 100644 --- a/src/remove_ai_watermarks/kling_engine.py +++ b/src/remove_ai_watermarks/kling_engine.py @@ -1,6 +1,6 @@ -"""Kling (可灵, Kuaishou) visible watermark detector/localizer. +"""Kling AI (可灵AI, Kuaishou) visible watermark detector/localizer. -Kling stamps its generations with a thin, light-gray "可灵AI 3.0" text strip in the +Kling AI stamps its generations with a thin, light-gray "可灵AI 3.0" text strip in the bottom-right corner, preceded by the vendor's spiral logo (not part of the detection silhouette -- logos vary between releases, the text run is what discriminates). Known variants: an "Omni" suffix release, a latin "KlingAI 3.0" release, and a @@ -9,7 +9,7 @@ suffix variants are only caught when the core run is bold enough (measured below Detection matches the bundled glyph silhouette against the corner; removal is the shared **localize -> fill** (the glyph-bbox :meth:`footprint_mask` feeds -``region_eraser``), NOT reverse-alpha. This module supplies only Kling's tuned +``region_eraser``), NOT reverse-alpha. This module supplies only Kling AI's tuned :class:`TextMarkConfig` (``assets/kling_alpha.png`` -- a font-rendered synthetic silhouette from ``scripts/render_vendor_silhouettes.py``, never cut from an upload). It also feeds ``identify`` as the medium-confidence ``visible_kling`` @@ -26,7 +26,7 @@ producer USCC 91110108335469089C names the entity, 2026-07-21; harness * ``alpha_height_frac`` comes from the silhouette aspect (0.239) at the fitted width, matching the aspect the fit converged on (0.25). * Gate 0.35, one step above the clean arm's max: on the cohort-vs-clean run - (cohort-contamination-guarded, 286 hand-labelled clean frames) the clean arm + (cohort-contamination-guarded, 286 hand-labeled clean frames) the clean arm scored p99 0.304 / max 0.320, and every cohort frame >= 0.35 carries a visible 可灵AI 3.0 mark (9 of ~19 eyeballed visible marks fire = ~47% recall of visible marks; the misses are the faint "Omni"-suffix release, the latin "KlingAI" @@ -38,7 +38,7 @@ producer USCC 91110108335469089C names the entity, 2026-07-21; harness provenance relaxation exists for this mark. * No rival margin: at the shipped gate the template fires on 1 of 400 Doubao-marked frames (0.2%, a 豆包 frame sitting INSIDE the Kling cohort, still - below the gate), 0 of 298 Jimeng-marked frames and 0 of 286 hand-labelled clean + below the gate), 0 of 298 Jimeng-marked frames and 0 of 286 hand-labeled clean frames, and a 0.10 rival margin costs zero genuine Kling detections -- so it is simply unnecessary (same conclusion shape as Qwen). """ @@ -74,7 +74,7 @@ LOGO_MIN_LUMA = 150 TOPHAT_DELTA = 12 DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity) -# Calibrated 2026-07-21 on the vendor cohort vs 286 hand-labelled clean frames +# Calibrated 2026-07-21 on the vendor cohort vs 286 hand-labeled clean frames # (cohort-contamination-guarded): clean p99 0.304 / max 0.320, and every cohort # frame scoring >= 0.35 carries a visible 可灵AI 3.0 mark. 0.35 was picked over # 0.33 (also zero clean fires) for margin against unseen clean content at a cost @@ -87,7 +87,7 @@ _ALPHA_WIDTH_FRAC = 0.12 _ALPHA_HEIGHT_FRAC = 0.0287 _CONFIG = TextMarkConfig( - name="Kling", + name="Kling AI", asset_name="kling_alpha.png", corner="br", margin_floor=4, @@ -114,7 +114,7 @@ _CONFIG = TextMarkConfig( def _alpha_template() -> NDArray[Any] | None: - """The bundled Kling alpha template (float [0,1]), or None.""" + """The bundled Kling AI alpha template (float [0,1]), or None.""" return _text_mark_engine.load_alpha_template(_CONFIG.asset_name) @@ -124,7 +124,7 @@ def _glyph_silhouette() -> NDArray[Any] | None: class KlingEngine(TextMarkEngine): - """Detect/localize the visible Kling "可灵AI 3.0" watermark (locate -> mask; mask feeds the fill).""" + """Detect/localize the visible Kling AI "可灵AI 3.0" watermark (locate -> mask; mask feeds the fill).""" def __init__(self) -> None: super().__init__(_CONFIG) diff --git a/src/remove_ai_watermarks/liblib_engine.py b/src/remove_ai_watermarks/liblib_engine.py index af6c4be..0eee574 100644 --- a/src/remove_ai_watermarks/liblib_engine.py +++ b/src/remove_ai_watermarks/liblib_engine.py @@ -1,13 +1,13 @@ -"""LibLibAI visible watermark detector/localizer. +"""LiblibAI visible watermark detector/localizer. -LibLibAI (哩布哩布AI, USCC 91110105MACJ6K1C8A) stamps its generations with a -white triangle logo + "LibLibAI" latin wordmark at **bottom-center** (not a +LiblibAI (哩布哩布AI, USCC 91110105MACJ6K1C8A) stamps its generations with a +white triangle logo + "LiblibAI" Latin wordmark at **bottom-center** (not a corner -- the locate box is horizontally centered). Detection matches the -bundled font-rendered "LibLibAI" silhouette (the triangle logo is NOT rendered +bundled font-rendered "LiblibAI" silhouette (the triangle logo is NOT rendered -- logos vary, the wordmark discriminates); removal is the shared **localize -> fill** (the glyph blob covers logo + wordmark, both bright). -This module supplies only LibLibAI's tuned :class:`TextMarkConfig` +This module supplies only LiblibAI's tuned :class:`TextMarkConfig` (``assets/liblib_alpha.png`` from ``scripts/render_vendor_silhouettes.py``, never cut from an upload). @@ -64,7 +64,7 @@ _ALPHA_HEIGHT_FRAC = 0.026 _LADDER = (0.9, 1.0, 1.1) _CONFIG = TextMarkConfig( - name="LibLibAI", + name="LiblibAI", asset_name="liblib_alpha.png", corner="bc", margin_floor=4, @@ -90,12 +90,12 @@ _CONFIG = TextMarkConfig( def _alpha_template() -> NDArray[Any] | None: - """The bundled LibLibAI alpha template (float [0,1]), or None.""" + """The bundled LiblibAI alpha template (float [0,1]), or None.""" return _text_mark_engine.load_alpha_template(_CONFIG.asset_name) class LibLibEngine(TextMarkEngine): - """Detect/localize the visible LibLibAI wordmark (bottom-center; localize -> fill).""" + """Detect/localize the visible LiblibAI wordmark (bottom-center; localize -> fill).""" # Per-mark size floor prevents small generic icons from matching the wordmark. _MIN_SHORT_SIDE = 480 diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index 60e4f1a..78914f2 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -199,7 +199,7 @@ def parse_tc260_aigc_json(value: bytes) -> dict[str, str] | None: return fields if TC260_AIGC_FIELDS & fields.keys() else None -# HuggingFace-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with this +# Hugging Face-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with this # ``tEXt`` chunk key holding the job UUID. It marks the hosting job, not a # specific model -- a medium-confidence AI signal (commonly diffusion output). _HF_JOB_KEY: str = "hf-job-id" @@ -413,7 +413,7 @@ def _scan_head_impl(image_path: Path, size: int) -> bytes: # packet larger than this is not a provenance label. _DECODED_TEXT_LIMIT = 512 * 1024 # Decoder values that are binary payloads with their own readers, not metadata text. -# An ICC profile is colour data and can run to hundreds of kilobytes; appending it +# An ICC profile is color data and can run to hundreds of kilobytes; appending it # would bloat the buffer every later detector re-scans, for no signal. _DECODER_BINARY_KEYS = frozenset({"icc_profile"}) @@ -510,7 +510,7 @@ def has_ai_metadata(image_path: Path) -> bool: # only the XMP form; the raw-JSON tEXt chunk needs the PIL-based parse). if aigc_label(image_path) is not None: return True - # HuggingFace-hosted job marker (hf-job-id PNG text chunk). + # Hugging Face-hosted job marker (hf-job-id PNG text chunk). if huggingface_job(image_path): return True # xAI / Grok: no C2PA/IPTC/XMP -- only the EXIF Signature + UUID-Artist pair. @@ -682,10 +682,10 @@ def c2pa_cloud_manifest(image_path: Path) -> str | None: def _huggingface_job_impl(image_path: Path) -> str | None: - """Return the HuggingFace job id if the image carries an ``hf-job-id`` PNG + """Return the Hugging Face job id if the image carries an ``hf-job-id`` PNG text chunk, else None. - HuggingFace-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with an + Hugging Face-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with an ``hf-job-id`` ``tEXt`` chunk holding the job's UUID. It identifies the *hosting job*, not a specific model, and is most commonly seen on diffusion- generation output -- a medium-confidence AI signal, not proof of AI pixels @@ -840,6 +840,13 @@ def synthid_source(image_path: Path, *, c2pa_info: dict[str, Any] | None = None) ai_source = b"trainedAlgorithmicMedia" in data or b"TrainedAlgorithmicMedia" in data if not (has_c2pa and ai_source): return None + from remove_ai_watermarks._internal.c2pa import soft_binding_vendors_in + + # A scan that names its own forensic soft-binding algorithm carries that + # vendor's mark; the generic vendor-token inference must not add a second, + # differently-attributed invisible watermark from the same bytes. + if soft_binding_vendors_in(data): + return None matched = synthid_evidence_vendors_in(data) return ", ".join(matched) if matched else None @@ -1210,9 +1217,9 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]: if system := iptc_ai_system(image_path): result.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({system})") - # HuggingFace-hosted job marker (hf-job-id PNG text chunk). + # Hugging Face-hosted job marker (hf-job-id PNG text chunk). if job := huggingface_job(image_path): - result.setdefault("huggingface_job", f"HuggingFace-hosted job ({job})") + result.setdefault("huggingface_job", f"Hugging Face-hosted job ({job})") # Samsung Galaxy AI editing marker (genAIType in PhotoEditor_Re_Edit_Data). if (genai := samsung_genai(image_path)) is not None: result.setdefault("samsung_genai", f"Samsung Galaxy AI editing marker (genAIType={genai})") diff --git a/src/remove_ai_watermarks/microsoft_engine.py b/src/remove_ai_watermarks/microsoft_engine.py new file mode 100644 index 0000000..39802cc --- /dev/null +++ b/src/remove_ai_watermarks/microsoft_engine.py @@ -0,0 +1,108 @@ +"""Microsoft top-right AI-badge detector/localizer. + +This engine covers one measured Microsoft output variant: a white pill with dark +internal shapes in the top-right corner. The evaluated files used both "Made with +AI" and "AI-Generated" wording. This is narrower than Microsoft's documented +watermark feature, which can use a Copilot icon or text and can place the mark in +other positions. A Microsoft provenance signal therefore does not establish that +this exact visible variant is present. + +Detection matches a synthetic pill silhouette (white pill with the sparkle and +text KNOCKED OUT) against the top-hat blob of the located box: the holes are what +discriminate this pill from any other bright rounded element in the corner. +Removal is the shared **localize -> fill**; the glyph-bbox :meth:`footprint_mask` +covers the whole pill including its text. + +The tuned numbers below were remeasured on 2026-08-27 with the registered engine +and ``scripts/registered_mark_calibrate.py``. The arms were kept distinct: 17 +visually confirmed carriers, 343 Microsoft-provenance files whose visible-mark +status was not adjudicated, and 1200 non-overlapping no-signal controls: + + * Geometry is single-mode and tight: pill 0.152 x 0.040 of the LONG side + (aspect 3.73-3.89 over 720..1536 px), margins ~0.010/0.007 of the same basis. One size + mode, so the shared 3-rung ladder is untouched and the locate box simply + wraps the pill with NCC slack. + * Provenance relaxation 0.7 (relaxed gate 0.266), enabled 2026-08-28 when the + cohort the strict-only note was waiting for became available: an OCR badge + census split the 343 Microsoft-C2PA uploads into 86 badge carriers and 257 + true badge-less files (the watermark is a per-user opt-in, so 75% of MS + uploads carry none). Badge-less max 0.251 / p99 0.213, so the relaxed band + [0.251, 0.38) holds three genuine faint badges and zero false fills + (measured 3/3; doubao ships 0.7 on a 58%-precision band). Strict controls + max 0.293 / p99 0.200 vs the 0.38 gate. + * Front-end "binary": the pill is a bold opaque overlay; the tophat blob is + solid with dark-text holes, exactly the template's shape. +""" + +# pyright: reportUnusedFunction=false + +from __future__ import annotations + +from typing import TYPE_CHECKING, Any + +from remove_ai_watermarks import _text_mark_engine +from remove_ai_watermarks._text_mark_engine import TextMarkConfig, TextMarkEngine + +if TYPE_CHECKING: + from numpy.typing import NDArray + +# Locate geometry as a fraction of the image LONG side (measured; scale_basis="long": +# on 1024x1536 portraits the pill tracks the 1536, and a width basis undersized +# the template until the portrait carriers fell to 0.15-0.32 NCC). +# The box wraps the measured pill rect (0.152W x 0.040W) with NCC slack; margins +# sit inside the pill's own ~0.010W-right / ~0.007W-top insets. +WM_WIDTH_FRAC = 0.170 +WM_HEIGHT_FRAC = 0.055 +MARGIN_RIGHT_FRAC = 0.004 +MARGIN_TOP_FRAC = 0.003 + +# Glyph appearance: a bright near-white pill (luma ~245), gray-scale (sat < 60). +MAX_SATURATION = 60 +LOGO_MIN_LUMA = 170 +TOPHAT_DELTA = 10 + +# Calibrated 2026-08-27: non-overlapping no-signal controls (n=1200) max 0.293 / +# p99 0.200; visually confirmed carriers (n=17) p50 0.519 / p90 0.578 / max +# 0.579, with 15/17 above the 0.38 gate. The two misses score 0.249 and 0.315. +DETECT_MIN_COVERAGE = 0.30 # the pill fills most of its box; content corners do not +DETECT_NCC_THRESHOLD = 0.38 + +# Pill silhouette geometry (fraction of width): 0.152W x 0.040W, aspect ~3.78. +_ALPHA_NATIVE_WIDTH = 335 +_ALPHA_WIDTH_FRAC = 0.152 +_ALPHA_HEIGHT_FRAC = 0.040 + +_CONFIG = TextMarkConfig( + name="Microsoft top-right AI badge", + asset_name="microsoft_alpha.png", + corner="tr", + margin_floor=2, + width_frac=WM_WIDTH_FRAC, + height_frac=WM_HEIGHT_FRAC, + margin_x_frac=MARGIN_RIGHT_FRAC, + margin_bottom_frac=MARGIN_TOP_FRAC, + max_saturation=MAX_SATURATION, + logo_min_luma=LOGO_MIN_LUMA, + tophat_delta=TOPHAT_DELTA, + morph_open_size=5, + detect_min_coverage=DETECT_MIN_COVERAGE, + detect_ncc_threshold=DETECT_NCC_THRESHOLD, + alpha_width_frac=_ALPHA_WIDTH_FRAC, + alpha_height_frac=_ALPHA_HEIGHT_FRAC, + min_gw=24, + detect_frontend="binary", + scale_basis="long", + provenance_ncc_factor=0.7, +) + + +def _alpha_template() -> NDArray[Any] | None: + """The bundled Microsoft pill template (float [0,1]), or None.""" + return _text_mark_engine.load_alpha_template(_CONFIG.asset_name) + + +class MicrosoftEngine(TextMarkEngine): + """Detect/localize the measured Microsoft top-right AI badge.""" + + def __init__(self) -> None: + super().__init__(_CONFIG) diff --git a/src/remove_ai_watermarks/pill_engine.py b/src/remove_ai_watermarks/pill_engine.py index 5125a45..a6fd6bb 100644 --- a/src/remove_ai_watermarks/pill_engine.py +++ b/src/remove_ai_watermarks/pill_engine.py @@ -64,7 +64,7 @@ _MASK_W, _MASK_H = 0.205, 0.115 # width of W, height of W # # Measured through the PRODUCT path (the `_keep_pill` gate), not the raw detector, by # ``scripts/pill_gate_audit.py`` -- the raw path bypasses the gate and reads as a -# disaster that the shipped behaviour does not have. Re-run it when the gate changes. +# disaster that the shipped behavior does not have. Re-run it when the gate changes. _FLAT_TEXTURE_MAX = 6.0 _silhouette: NDArray[Any] | None = None diff --git a/src/remove_ai_watermarks/qwen_engine.py b/src/remove_ai_watermarks/qwen_engine.py index 641f460..1847cc1 100644 --- a/src/remove_ai_watermarks/qwen_engine.py +++ b/src/remove_ai_watermarks/qwen_engine.py @@ -23,7 +23,7 @@ whose producer USCC 91440101MA9Y9T4H7A names the entity, 2026-07-21; harness ratio 1.64 -- wider than the shared 3-rung ladder's 1.5625 span), so a single fraction on the shared ladder covers ~75% of marks and the rest land in the comb's collapse zone. Qwen therefore carries its OWN 2-rung ladder - (``TextMarkConfig.ladder``), one rung centred on each mode; the shared + (``TextMarkConfig.ladder``), one rung centered on each mode; the shared default is untouched for every other mark. * The mark also sits FARTHER off the corner than Doubao's box assumes (right margin ~0.025 vs 0.004 of the short side), so Doubao's locate box clipped the @@ -38,7 +38,7 @@ whose producer USCC 91440101MA9Y9T4H7A names the entity, 2026-07-21; harness arm would be mostly false fills. No provenance relaxation exists for this mark. * No rival margin: at the shipped gate the template fires on 0 of 400 - Doubao-marked frames, 0 of 298 Jimeng-marked frames and 0 of 286 hand-labelled + Doubao-marked frames, 0 of 298 Jimeng-marked frames and 0 of 286 hand-labeled clean frames (the shared tail correlates at ~0.22, far below the gate), while a 0.10 rival margin would have suppressed ~10% of genuine Qwen detections. """ @@ -74,7 +74,7 @@ LOGO_MIN_LUMA = 150 TOPHAT_DELTA = 12 DETECT_MIN_COVERAGE = 0.04 # unused by the tophat front-end (kept for config parity) -# Calibrated 2026-07-21 on the vendor cohort vs 286 hand-labelled clean frames +# Calibrated 2026-07-21 on the vendor cohort vs 286 hand-labeled clean frames # (cohort-contamination-guarded): clean p99 0.301 / max 0.316, and every cohort # frame scoring >= 0.45 carries a visible 千问AI生成 mark (86% of the eyeballed # visible marks fire, the misses being white-on-near-white contrast losses). diff --git a/src/remove_ai_watermarks/runninghub_engine.py b/src/remove_ai_watermarks/runninghub_engine.py index 103232a..f93f793 100644 --- a/src/remove_ai_watermarks/runninghub_engine.py +++ b/src/remove_ai_watermarks/runninghub_engine.py @@ -32,7 +32,7 @@ whose producer USCC names the entity, harvested 2026-07-22 by * STRICT ONLY (``provenance_ncc_factor`` 1.0): raw gray NCC is contrast-DEPENDENT and the sub-gate band of a corner-anchored gray match is unmeasured beyond the clean arm, so no provenance relaxation exists. - * Gate 0.34: on 283 hand-labelled clean frames (cohort-contamination-guarded) + * Gate 0.34: on 283 hand-labeled clean frames (cohort-contamination-guarded) corner-anchored gray NCC p99 is 0.264 / max 0.304, while the 4 positives score 0.38-0.54. 0.34 sits above the clean max with a small margin; the positives are few, so the margin is deliberately thin on the recall side. @@ -75,14 +75,14 @@ LOGO_MIN_LUMA = 150 TOPHAT_DELTA = 12 DETECT_MIN_COVERAGE = 0.04 # unused by the gray front-end (kept for config parity) -# Calibrated 2026-07-22 on the vendor cohort vs 283 hand-labelled clean frames: +# Calibrated 2026-07-22 on the vendor cohort vs 283 hand-labeled clean frames: # corner-anchored gray NCC, clean p99 0.264 / max 0.304; positives 0.38-0.54. DETECT_NCC_THRESHOLD = 0.34 # Detection-silhouette geometry (fraction of the image width), measured on the # positives: mark width is ~0.320 of width on all three frame sizes (266px at 832, # 345px at 1080, 491px at 1536), and the NCC is razor-sharp in size (0.537 on-size, -# 0.223 at +5.6% -- the same comb behaviour Qwen measured), so the nominal sits +# 0.223 at +5.6% -- the same comb behavior Qwen measured), so the nominal sits # exactly on the measured size with a TIGHT ladder around it, not the shared 3 rungs # (whose nearest rung landed 5.6% off and collapsed the match to 0.22). _ALPHA_WIDTH_FRAC = 0.32 diff --git a/src/remove_ai_watermarks/video.py b/src/remove_ai_watermarks/video.py index 1b73018..aac4900 100644 --- a/src/remove_ai_watermarks/video.py +++ b/src/remove_ai_watermarks/video.py @@ -1,8 +1,8 @@ """High-level video processing API. The product path covers provenance identification, container-level AI metadata -removal, temporally stabilized visible Sora, Veo, Seedance, Dola, Hailuo, and -Kling removal, and an oracle-certified opt-in VAE profile for video SynthID. +removal, temporally stabilized visible Sora, Veo, Seedance, Dola, Hailuo AI, and +Kling AI removal, and an oracle-certified opt-in VAE profile for video SynthID. The visible pixel path reuses the image package's shared fill backends. """ @@ -179,8 +179,8 @@ _VISIBLE_PLATFORM = { "veo": "Google Veo", "seedance": "ByteDance Seedance", "dola": "ByteDance Dola", - "hailuo": "MiniMax Hailuo", - "kling": "Kuaishou Kling", + "hailuo": "MiniMax Hailuo AI", + "kling": "Kuaishou Kling AI", } diff --git a/src/remove_ai_watermarks/video_visible.py b/src/remove_ai_watermarks/video_visible.py index 01947ed..0ce442b 100644 --- a/src/remove_ai_watermarks/video_visible.py +++ b/src/remove_ai_watermarks/video_visible.py @@ -4,8 +4,8 @@ Supported marks use fully synthetic silhouettes made from geometric primitives, OpenCV's built-in font, and Pillow's bundled font. Sora detection searches the full frame because the wordmark moves. Veo detection covers both the current four-point diamond and legacy ``Veo`` text. Seedance detects the boxed ``AI`` -label, Dola detects its compact text label, Hailuo detects the composite -MINIMAX/Hailuo label, and Kling detects its version-independent wordmark core. +label, Dola detects its compact text label, Hailuo AI detects the composite +MINIMAX/Hailuo AI label, and Kling AI detects its version-independent wordmark core. A single frame is never enough to authorize removal: the temporal arbiter requires the candidate to recur at the same location across adjacent frames. This keeps isolated lookalikes in clean videos from becoming removal masks. diff --git a/src/remove_ai_watermarks/watermark_registry.py b/src/remove_ai_watermarks/watermark_registry.py index d116d45..669b509 100644 --- a/src/remove_ai_watermarks/watermark_registry.py +++ b/src/remove_ai_watermarks/watermark_registry.py @@ -17,17 +17,18 @@ localizer stays cheap (cv2/numpy, CPU) so a memory-tight caller can run it on a small worker; the heavy fill (MI-GAN / LaMa) is opt-in and chosen by the caller. Entries: - - ``gemini`` -- Google Gemini / Nano Banana sparkle, bottom-right. + - ``gemini`` -- Google Gemini / Nano Banana visible watermark (sparkle), bottom-right. - ``doubao`` -- ByteDance Doubao "豆包AI生成" text strip, bottom-right. - ``jimeng`` -- ByteDance Jimeng / Dreamina "★ 即梦AI" wordmark, bottom-right. - - ``qwen`` -- Alibaba Qwen "千问AI生成" text strip, bottom-right. - - ``kling`` -- Kuaishou Kling "可灵AI 3.0" text strip, bottom-right. + - ``qwen`` -- Alibaba Cloud Qwen "千问AI生成" text strip, bottom-right. + - ``kling`` -- Kuaishou Kling AI "可灵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. - ``jimeng_pill`` -- Jimeng-basic "AI生成" pill, top-left (capture-less). - ``runninghub`` -- RunningHub "RunningHub AI生成" text, top-left (gray front-end). - ``baidu`` -- Baidu "百度 AI生成" text + white tag, bottom-right. - - ``liblib`` -- LibLibAI "LibLibAI" wordmark, bottom-center. + - ``liblib`` -- LiblibAI "LiblibAI" wordmark, bottom-center. + - ``microsoft`` -- one measured Microsoft white AI-badge variant, top-right. """ from __future__ import annotations @@ -147,7 +148,7 @@ _REMOVED_SENSITIVITIES = { "vendor made it or where the mark is. If you can see a mark the detector missed, " "act on what you see: erase(image, region=(x, y, w, h)), or the CLI " "`--mark --no-detect` for a known text mark. Use sensitivity='auto' for " - "the default evidence-driven behaviour." + "the default evidence-driven behavior." ) } @@ -156,7 +157,7 @@ def validate_sensitivity(value: str) -> Sensitivity: """Reject a removed sensitivity LOUDLY instead of silently falling back to ``auto``. ``Sensitivity`` is a ``Literal``, which is not enforced at runtime, so a caller - upgrading from 0.15 would pass ``"assume_ai"`` and quietly get ``auto`` behaviour -- + upgrading from 0.15 would pass ``"assume_ai"`` and quietly get ``auto`` behavior -- a silent semantic change on the one release where they most need to be told. """ if value in _REMOVED_SENSITIVITIES: @@ -368,7 +369,7 @@ _GEMINI_AUTO_MIN_CONF = GEMINI_SPARKLE_TRUST_CONF # that never had a mark rather than on moved ones. # # Measured blind on 954 unique Google-metadata uploads (detector never saw the -# metadata), hand-labelled against a two-sided control (labeller sensitivity ~88%, +# metadata), hand-labeled against a two-sided control (labeler sensitivity ~88%, # specificity 100%). "Additions" = accepted with provenance but not without: # # band precision 95% CI population @@ -409,6 +410,7 @@ _ENGINE_CLASS: dict[str, tuple[str, str]] = { "runninghub": ("runninghub_engine", "RunningHubEngine"), "baidu": ("baidu_engine", "BaiduEngine"), "liblib": ("liblib_engine", "LibLibEngine"), + "microsoft": ("microsoft_engine", "MicrosoftEngine"), } @@ -485,7 +487,14 @@ def fill(image: NDArray[Any], mask: NDArray[Any], *, backend: Backend = "auto") def _gemini_wrap(d: Any, *, provenance: bool) -> MarkDetection: gate = _GEMINI_PROVENANCE_MIN_CONF if provenance else _GEMINI_AUTO_MIN_CONF detected = bool(d.detected) and d.confidence >= gate - return MarkDetection("gemini", "Google Gemini sparkle", "bottom-right", detected, d.confidence, d.region) + return MarkDetection( + "gemini", + "Google Gemini visible watermark (sparkle)", + "bottom-right", + detected, + d.confidence, + d.region, + ) def _gemini_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetection: @@ -561,12 +570,14 @@ def _text_mark( label_regime: str | None = "tc260", provenance_signals: tuple[str, ...] = ("aigc",), tc260_producer_codes: tuple[str, ...] = (), + provenance_platform_tokens: tuple[str, ...] = (), ) -> KnownMark: """Build a text-mark registry row from its shared detector and mask adapters. ``product`` defaults to the key (one mark, one product); pass it only when two marks share a product. ``label_regime`` and ``provenance_signals`` default to the - China-AIGC label because every text mark registered so far except Samsung uses it. + China-AIGC label because every text mark registered so far except Samsung and + Microsoft uses it. """ return KnownMark( key, @@ -580,6 +591,7 @@ def _text_mark( _text_mark_mask(key), provenance_signals=provenance_signals, tc260_producer_codes=tc260_producer_codes, + provenance_platform_tokens=provenance_platform_tokens, _detect_both=_text_mark_detect_both(key, label, location), ) @@ -616,11 +628,11 @@ def _pill_features(image: NDArray[Any]) -> dict[str, float]: _REGISTRY: tuple[KnownMark, ...] = ( - # Gemini is a Google C2PA/SynthID product, not a China-AIGC labeller: label_regime + # Gemini is a Google C2PA/SynthID product, not a China-AIGC labeler: label_regime # is None so it can never act as a TC260 sibling in _keep_pill. KnownMark( "gemini", - "Google Gemini sparkle", + "Google Gemini visible watermark (sparkle)", "bottom-right", True, "gemini", @@ -651,14 +663,14 @@ _REGISTRY: tuple[KnownMark, ...] = ( "qwen", "Qwen 千问AI生成 text", "bottom-right", - platform="Alibaba Qwen (visible 千问AI生成 mark detected)", + platform="Alibaba Cloud Qwen (visible 千问AI生成 mark detected)", tc260_producer_codes=("91440101MA9Y9T4H7A",), ), _text_mark( "kling", - "Kling 可灵AI 3.0 text", + "Kling AI 可灵AI 3.0 text", "bottom-right", - platform="Kuaishou Kling (visible 可灵AI 3.0 mark detected)", + platform="Kuaishou Kling AI (visible 可灵AI 3.0 mark detected)", tc260_producer_codes=("91110108335469089C",), ), _text_mark( @@ -693,11 +705,23 @@ _REGISTRY: tuple[KnownMark, ...] = ( ), _text_mark( "liblib", - "LibLibAI wordmark", + "LiblibAI wordmark", "bottom-center", - platform="LibLibAI (visible LibLibAI mark detected)", + platform="LiblibAI (visible LiblibAI mark detected)", tc260_producer_codes=("91110105MACJ6K1C8A",), ), + # One measured Microsoft visible-mark variant: a white top-right pill with + # dark internal shapes. Microsoft's documented feature also permits other + # icon, text, and placement variants, which this detector does not cover. + _text_mark( + "microsoft", + "Microsoft top-right AI badge", + "top-right", + label_regime=None, + provenance_signals=(), + platform="Microsoft (visible top-right AI badge detected)", + provenance_platform_tokens=("microsoft",), + ), # Same product as the Jimeng wordmark -- the one pair that cross-relaxes. KnownMark( "jimeng_pill", @@ -794,7 +818,7 @@ def tc260_producer_vendors() -> dict[str, str]: def _pill_suppressors() -> set[str]: """Marks whose detection vetoes the capture-less pill: same label regime as the pill, different product. Derived so a newly registered TC260 mark cannot be - forgotten here -- which is exactly how LibLibAI ended up missing.""" + forgotten here -- which is exactly how LiblibAI ended up missing.""" pill = get_mark("jimeng_pill") return { m.key @@ -822,9 +846,9 @@ def _keep_pill(keys: set[str], *, provenance: frozenset[str], footprint_flat: bo No confirmation at all -> never remove (blocks false fires on non-Jimeng content). The suppressor set is DERIVED from the registry (same label regime, different - product), not hand-listed. The hand-written list had drifted: LibLibAI was + product), not hand-listed. The hand-written list had drifted: LiblibAI was registered alongside RunningHub and Baidu but never added to it, so a confident - LibLibAI detection did not veto the pill the way its two siblings did. Marks + LiblibAI detection did not veto the pill the way its two siblings did. Marks outside the TC260 regime (Gemini, Samsung) are deliberately NOT suppressors -- neither can put ``"jimeng"`` into ``provenance``, so neither can enable the arm they would be vetoing.""" diff --git a/tests/test_cli.py b/tests/test_cli.py index e779baa..ad1f00a 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -228,8 +228,8 @@ class TestVisibleCommand: # The transparent corners must remain transparent. assert out[0, 0, 3] == 0 assert out[199, 199, 3] == 0 - # The opaque centre remains opaque (the watermark region default is bottom-right, - # which doesn't overlap the centre square at 200x200). + # The opaque center remains opaque (the watermark region default is bottom-right, + # which doesn't overlap the center square at 200x200). assert out[100, 100, 3] == 255 def test_visible_keeps_alpha_opaque_in_watermark_region(self, runner, tmp_path): @@ -426,6 +426,39 @@ class TestInvisibleCommand: assert result.exit_code == 0, result.output mock_engine.remove_watermark.assert_called_once() + def test_invisible_explicit_vendor_implies_force_and_sets_cohort(self, runner, sample_png, tmp_path): + """--vendor meta names a cohort the file cannot prove (Content Seal carries + no C2PA), so it must both bypass the no-signal skip and arrive at the engine + as the vendor, where it resolves to the measured Meta floor (not the + area-curve value the same size would otherwise get).""" + mock_cls, mock_engine = _mock_invisible_engine() + output = tmp_path / "clean.png" + with ( + patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True), + patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True), + patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls), + ): + result = runner.invoke(main, ["invisible", str(sample_png), "-o", str(output), "--vendor", "meta"]) + assert result.exit_code == 0, result.output + kwargs = mock_engine.remove_watermark.call_args.kwargs + assert kwargs["vendor"] == "meta" + assert "0.1" in result.output # the resolved Meta floor, printed + assert "0.094" not in result.output # not the sample's area-curve value (200x200 -> ~0.0944) + + def test_invisible_vendor_auto_keeps_detection_semantics(self, runner, sample_png, tmp_path): + """--vendor auto is the default spelled out: detection still runs and a + no-signal file still skips.""" + mock_cls, mock_engine = _mock_invisible_engine() + output = tmp_path / "clean.png" + with ( + patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True), + patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True), + patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls), + ): + result = runner.invoke(main, ["invisible", str(sample_png), "-o", str(output), "--vendor", "auto"]) + assert result.exit_code == 2, result.output + mock_engine.remove_watermark.assert_not_called() + def test_invisible_runs_without_force_when_signal_present(self, runner, tmp_path): """An image carrying an AI metadata signal IS a scrub target, so the run proceeds with no --force needed.""" diff --git a/tests/test_contentseal_corpus.py b/tests/test_contentseal_corpus.py new file mode 100644 index 0000000..7ee107f --- /dev/null +++ b/tests/test_contentseal_corpus.py @@ -0,0 +1,69 @@ +"""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 + + +def test_deterministic_transforms_reproduce_recorded_hashes(tmp_path: Path) -> None: + from scripts.contentseal_transforms import reproduce_transforms + + outputs = reproduce_transforms(tmp_path) + + assert len(outputs) == 8 + assert all(path.is_file() for path in outputs) diff --git a/tests/test_designer_synthid_suppression.py b/tests/test_designer_synthid_suppression.py new file mode 100644 index 0000000..f1f7b22 --- /dev/null +++ b/tests/test_designer_synthid_suppression.py @@ -0,0 +1,88 @@ +"""Regression: a manifest that names its own forensic soft binding must not +also report a SynthID watermark from the generic vendor-token inference. + +Microsoft Designer manifests sign as Microsoft, carry the InvisMark +``c2pa.watermarked`` action, and name their generation agent +"Azure OpenAI ImageGen". The OpenAI issuer token inside that agent name plus +the watermarked action used to satisfy the OpenAI SynthID-evidence rule, +double-counting one forensic mark as two pixel watermarks. +""" + +from __future__ import annotations + +from remove_ai_watermarks._internal.c2pa import c2pa_info_from_manifest_store + +DESIGNER_STORE = { + "active_manifest": "designer", + "manifests": { + "designer": { + "signature_info": {"issuer": "Microsoft Corporation", "common_name": "Microsoft Corporation"}, + "claim_generator_info": [{"name": "Microsoft Responsible AI Provenance", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "softwareAgent": {"name": "Azure OpenAI ImageGen"}, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", + }, + {"action": "c2pa.watermarked"}, + ] + }, + }, + { + "label": "c2pa.soft-binding", + "data": { + "alg": "com.microsoft.invismark.1", + "blocks": [{"value": "bf7a2993-cc1f-47e1-b1f0-cd8839aabb22"}], + }, + }, + ], + } + }, +} + + +def test_named_soft_binding_suppresses_generic_synthid_evidence() -> None: + info = c2pa_info_from_manifest_store(DESIGNER_STORE) + assert info["ai_source_kind"] == "generated" + assert info["soft_binding_algorithm"] == "com.microsoft.invismark.1" + assert info.get("synthid_watermark") is None + assert info.get("synthid_vendors") is None + + +def test_vendor_agent_name_alone_is_not_the_vendors_provenance() -> None: + """The identity-scoped inference must not fire on a service name either. + + Same manifest without the soft binding: the "Azure OpenAI ImageGen" agent + is not an OpenAI signature or claim generator, so no OpenAI SynthID + evidence may be derived from it. + """ + store = { + "active_manifest": "designer", + "manifests": { + "designer": { + "signature_info": {"issuer": "Microsoft Corporation", "common_name": "Microsoft Corporation"}, + "claim_generator_info": [{"name": "Microsoft Responsible AI Provenance", "version": "1.0"}], + "assertions": [ + { + "label": "c2pa.actions", + "data": { + "actions": [ + { + "action": "c2pa.created", + "softwareAgent": {"name": "Azure OpenAI ImageGen"}, + "digitalSourceType": "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia", + }, + {"action": "c2pa.watermarked"}, + ] + }, + } + ], + } + }, + } + info = c2pa_info_from_manifest_store(store) + assert info.get("synthid_watermark") is None diff --git a/tests/test_evaluation_data.py b/tests/test_evaluation_data.py new file mode 100644 index 0000000..b0559da --- /dev/null +++ b/tests/test_evaluation_data.py @@ -0,0 +1,23 @@ +"""Cross-corpus integrity checks for tracked evaluation tables.""" + +from __future__ import annotations + +import csv +import re +from pathlib import Path + +ROOT = Path(__file__).resolve().parents[1] +EVALUATIONS = ROOT / "data" / "evaluations" +SHA256 = re.compile(r"^[0-9a-f]{64}$") + + +def test_every_recorded_evaluation_sha256_is_well_formed() -> None: + checked = 0 + for path in sorted(EVALUATIONS.rglob("*.csv")): + with path.open(newline="", encoding="utf-8") as stream: + for line_number, row in enumerate(csv.DictReader(stream), start=2): + for field, value in row.items(): + if field is not None and field.endswith("sha256") and value: + assert SHA256.fullmatch(value), f"{path.relative_to(ROOT)}:{line_number} {field}={value!r}" + checked += 1 + assert checked > 0 diff --git a/tests/test_identify.py b/tests/test_identify.py index c7d77bc..4b1d002 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -17,7 +17,11 @@ 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._internal.constants import ( + C2PA_AI_VENDORS, + C2PA_CLAIM_GENERATOR_PLATFORMS, + C2PA_IDENTITY_AI_ORGS, +) from remove_ai_watermarks.identify import ( ProvenanceEvidence, ProvenanceReport, @@ -179,7 +183,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 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): @@ -244,7 +248,7 @@ class TestProvenanceEvidence: report = identify_from_evidence(evidence_from_metadata_record(record, path=path)) assert report.is_ai_generated is True - assert report.platform == "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)" + assert report.platform == "OpenAI (ChatGPT / GPT Image / DALL·E / Sora)" assert [signal.name for signal in report.signals] == ["c2pa"] def test_external_generator_bytes_are_normalized(self, tmp_path: Path): @@ -374,13 +378,12 @@ class TestAttributePlatform: assert platform assert "Canva" in platform - def test_byteplus_attributes_to_bytedance(self): + def test_byteplus_keeps_its_product_name(self): # ByteDance's intl brand signs as "Byteplus Pte. Ltd."; the registry maps - # it to the ByteDance platform (was mis-read as Adobe via an incidental + # it to the ByteDance family (was mis-read as Adobe via an incidental # "Adobe XMP" file string before the entry existed). platform = _attribute_platform(["BytePlus (ByteDance)"]) - assert platform - assert "ByteDance" in platform + assert platform == "BytePlus (ByteDance)" def test_empty_is_none(self): assert _attribute_platform([]) is None @@ -444,7 +447,7 @@ class TestIdentifyNonPng: path = self._c2pa_jpeg(tmp_path, b"certificate_center@volcengine.com ... trainedAlgorithmicMedia") r = identify(path, check_visible=False, check_invisible=False) assert r.is_ai_generated is True - assert "ByteDance" in (r.platform or "") + assert r.platform == "ByteDance Volcano Engine" def test_bytedance_chinese_legal_name_attributed(self, tmp_path: Path): # Some Volcano Engine certs name the signer with the Chinese legal entity @@ -454,7 +457,7 @@ class TestIdentifyNonPng: path = self._c2pa_jpeg(tmp_path, blob) r = identify(path, check_visible=False, check_invisible=False) assert r.is_ai_generated is True - assert "ByteDance" in (r.platform or "") + assert r.platform == "ByteDance Volcano Engine" @pytest.mark.parametrize( ("claim_generator", "platform"), @@ -498,7 +501,7 @@ class TestIdentifyNonPng: path = self._c2pa_jpeg(tmp_path, b"Bytedance Pte. Ltd. Dreamina/7.5.0 c2pa.created") r = identify(path, check_visible=False, check_invisible=False) assert r.is_ai_generated is True - assert "ByteDance" in (r.platform or "") + assert r.platform == "ByteDance Dreamina" def test_elevenlabs_attributed(self, tmp_path: Path): path = self._c2pa_jpeg(tmp_path, b"Eleven Labs Inc. ... trainedAlgorithmicMedia") @@ -666,6 +669,57 @@ class TestIdentifyRealSamples: assert r.is_ai_generated is True assert r.ai_source_kind == "enhanced" + def test_standalone_ai_tag_attributes_the_content_seal(self, tmp_path: Path): + """A standalone AI digital-source tag emits the seal as its own signal. + + Muse Image outputs carry no C2PA; this tag is their only provenance, and + Muse stamps every output with the invisible Content Seal. The signal is + the strength router's Meta bet as evidence - an attribution, not a decode + (no public decoder exists), so its confidence is medium and the caveat + still points at the oracle. + """ + p = tmp_path / "muse-tag.jpg" + p.write_bytes( + b'\xff\xd8\xff\xe1\xff\xd9' + ) + + r = identify(p, check_visible=False, check_invisible=False) + + names = [s.name for s in r.signals] + assert "iptc" in names + assert "content_seal" in names + seal = next(s for s in r.signals if s.name == "content_seal") + assert seal.confidence == "medium" + assert "Invisible Content Seal watermark (Meta Muse attribution)" in r.watermarks + assert any("meta.ai/identification" in c for c in r.caveats) + + def test_seal_platform_attribution_follows_the_signal(self, tmp_path: Path): + """The Likely-source line follows the same bet the seal signal makes. + + Apple keeps its own attribution; every other standalone-tag file gets the + hedged Muse attribution instead of "platform not specified", so the panel + that prices the Content Seal and the source line agree. + """ + muse = tmp_path / "muse.jpg" + muse.write_bytes( + b'\xff\xd8\xff\xe1\xff\xd9' + ) + apple = tmp_path / "apple.jpg" + apple.write_bytes( + b'\xff\xd8\xff\xe1\xff\xd9' + ) + + assert identify(muse, check_visible=False, check_invisible=False).platform == ( + "Meta Muse Image (attributed by the standalone AI digital-source tag)" + ) + assert identify(apple, check_visible=False, check_invisible=False).platform == "Apple Photos (Clean Up AI edit)" + + def test_c2pa_backed_file_gets_no_content_seal_attribution(self): + """C2PA issuers win first: a manifest-backed file is not Meta-routed.""" + r = identify(SAMPLES_DIR / "flux-1.png", check_visible=False, check_invisible=False) + assert "content_seal" not in [s.name for s in r.signals] + def test_flux_bfl_c2pa_png(self): # flux-1.png: real Black Forest Labs FLUX.2 Playground output (signed C2PA). r = identify(SAMPLES_DIR / "flux-1.png", check_visible=False) @@ -844,7 +898,7 @@ class TestIdentifyAigcPngChunk: assert "doubao" in signal.detail -# ── HuggingFace-hosted job marker (medium confidence) ─────────────── +# ── Hugging Face-hosted job marker (medium confidence) ───────────── class TestIdentifyHuggingFaceJob: @@ -866,7 +920,7 @@ class TestIdentifyHuggingFaceJob: assert r.is_ai_generated is True assert r.confidence == "medium" assert r.platform is not None - assert "HuggingFace" in r.platform + assert "Hugging Face" in r.platform signal = next(s for s in r.signals if s.name == "hf_job") assert signal.confidence == "medium" @@ -1170,7 +1224,7 @@ class TestSynthIDProvenanceEvidence: png = self._png(tmp_path, "dreamina.png", self._png_chunk(b"caBX", b"jumbc2pa Dreamina/7.5.0 c2pa.created")) r = identify(png, check_visible=False, check_invisible=False) assert r.is_ai_generated is True - assert "ByteDance" in (r.platform or "") + assert r.platform == "ByteDance Dreamina" class TestReportSerializable: @@ -1401,7 +1455,7 @@ class TestIdentifyAIGC: class TestVendorOf: def test_openai_variants(self): - assert _vendor_of("OpenAI (ChatGPT / gpt-image / DALL-E / Sora)") == "OpenAI" + assert _vendor_of("OpenAI (ChatGPT / GPT Image / DALL·E / Sora)") == "OpenAI" assert _vendor_of("DALL-E 3") == "OpenAI" def test_google_variants(self): @@ -1427,17 +1481,32 @@ class TestVendorOf: # entered clash detection (a coverage hole). They now normalize to one origin. 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("ByteDance Volcano Engine") == "ByteDance" + assert _vendor_of("BytePlus (ByteDance)") == "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" + assert _vendor_of("Ideogram") == "Ideogram" - 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 + def test_ideogram_issuer_attributed(self): + # Corpus evidence 2026-08-08: four uploads signed "Ideogram, Inc" read as + # unknown-signer C2PA with no platform. The issuer token is the org prefix. + platforms = {v.issuer: v.platform for v in C2PA_AI_VENDORS} + assert platforms[b"Ideogram"] == "Ideogram" + assert "Ideogram" in C2PA_IDENTITY_AI_ORGS + assert _issuers_in(b"...CN=Ideogram, Inc...trainedAlgorithmicMedia") == ["Ideogram"] + + def test_bytedance_issuers_keep_the_source_product(self): + platforms = { + vendor.issuer: vendor.platform + for vendor in C2PA_AI_VENDORS + if vendor.org.startswith("ByteDance") or vendor.org.startswith("BytePlus") + } + assert platforms[b"volcengine"] == "ByteDance Volcano Engine" + assert platforms[b"Byteplus"] == "BytePlus (ByteDance)" + assert platforms[b"Dreamina"] == "ByteDance Dreamina" + assert ("dreamina", "ByteDance Dreamina") in C2PA_CLAIM_GENERATOR_PLATFORMS class TestIntegrityClashesHelper: diff --git a/tests/test_invisible_engine.py b/tests/test_invisible_engine.py index b7fc46b..e98ead6 100644 --- a/tests/test_invisible_engine.py +++ b/tests/test_invisible_engine.py @@ -60,6 +60,7 @@ class TestVerifiedTextMode: manifest.write_text("{}", encoding="utf-8") cases = ( ("sdxl-zimage", {}, "qwen-zimage"), + ("qwen-zimage", {"tile": True}, "not calibrated with --tile"), ("qwen-zimage", {"max_resolution": 1024}, "max-resolution 0"), ("qwen-zimage", {"humanize": 1.0}, "humanize=0"), ("qwen-zimage", {"adaptive_polish": True}, "polish disabled"), @@ -128,10 +129,6 @@ class TestVerifiedTextMode: assert seen["fidelity_anchor"] is True - engine.remove_watermark(source, output, text_manifest=manifest, tile=True) - - assert seen["tile"] is True - class TestNativeOutputSize: """Model-side latent-grid rounding must not change the public output size.""" @@ -248,7 +245,7 @@ class TestEngineResolvesThePolishPerProfile: This is the change that stopped a library caller and a CLI caller on one profile from producing different pixels, and it had no test: rebinding - ``resolve_adaptive_polish`` to ``bool(value)`` -- exactly the pre-commit behaviour -- + ``resolve_adaptive_polish`` to ``bool(value)`` -- exactly the pre-commit behavior -- left the whole suite green. """ diff --git a/tests/test_kling_engine.py b/tests/test_kling_engine.py index 65566ce..c98d8f4 100644 --- a/tests/test_kling_engine.py +++ b/tests/test_kling_engine.py @@ -72,7 +72,7 @@ class TestConfig: assert KlingEngine().config.provenance_ncc_factor == 1.0 def test_gate_above_clean_arm_max(self): - # Clean arm scored p99 0.304 / max 0.320 on 286 hand-labelled frames; the + # Clean arm scored p99 0.304 / max 0.320 on 286 hand-labeled frames; the # gate must sit above that with margin. assert KlingEngine().config.detect_ncc_threshold > 0.32 diff --git a/tests/test_liblib_engine.py b/tests/test_liblib_engine.py index 76ccff1..227040b 100644 --- a/tests/test_liblib_engine.py +++ b/tests/test_liblib_engine.py @@ -1,4 +1,4 @@ -"""Tests for the LibLibAI ("LibLibAI" wordmark) visible-watermark engine. +"""Tests for the LiblibAI ("LiblibAI" wordmark) visible-watermark engine. Every tuned constant in ``liblib_engine`` was measured on the 15-frame vendor cohort (2026-07-22); these tests pin the load-bearing ones: the bottom-CENTER @@ -24,7 +24,7 @@ _MARK_FRAC = 0.10 # measured wordmark width, fraction of the frame WIDTH def _compose(w: int, h: int, bg: float = 100.0): - """Composite a triangle logo + the LibLibAI wordmark, bottom-center.""" + """Composite a triangle logo + the LiblibAI wordmark, bottom-center.""" img = np.full((h, w, 3), bg, np.float32) at = _alpha_template() gw = int(_MARK_FRAC * w) @@ -112,8 +112,8 @@ class TestDetectAndMask: assert eng.footprint_mask(img) is None def test_confident_liblib_detection_suppresses_the_jimeng_pill(self): - # A LibLibAI image is TC260 too but is not Jimeng-basic: like Doubao/Qwen/ - # Kling/RunningHub/Baidu, a confident LibLibAI detection must veto the pill. + # A LiblibAI image is TC260 too but is not Jimeng-basic: like Doubao/Qwen/ + # Kling/RunningHub/Baidu, a confident LiblibAI detection must veto the pill. # It was the one mark the hand-written veto list in ``_keep_pill`` missed. from remove_ai_watermarks.watermark_registry import _keep_pill diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 9f1093a..54830bd 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1562,7 +1562,7 @@ class TestAIGCLabel: class TestHuggingFaceJob: - """HuggingFace-hosted job marker (``hf-job-id`` PNG text chunk).""" + """Hugging Face-hosted job marker (``hf-job-id`` PNG text chunk).""" def _hf_png(self, tmp_path: Path, job_id: str = "ec8380a6-2091-423a-b835-209420f99ee1") -> Path: p = tmp_path / "hfjob.png" @@ -1622,10 +1622,13 @@ class TestSoftBinding: from remove_ai_watermarks._internal.c2pa import soft_binding_vendors_in assert soft_binding_vendors_in(b"...alg...com.adobe.trustmark.P...") == ["Adobe TrustMark"] - assert soft_binding_vendors_in(b"com.digimarc.validate.1") == ["Digimarc"] + assert soft_binding_vendors_in(b"com.digimarc.validate.1") == ["Digimarc Validate"] assert soft_binding_vendors_in(b"ai.steg.api blah") == ["Steg.AI"] # Registry-verified vendors added in v0.6.x. - assert soft_binding_vendors_in(b"ai.trufo.gen1.image") == ["Trufo"] + assert soft_binding_vendors_in(b"ai.trufo.pawprint.watermark") == ["Trufo PawPrint"] + assert soft_binding_vendors_in(b"com.aiwatermark.pixelseal.1") == ["AIWatermark PixelSeal"] + assert soft_binding_vendors_in(b"com.aiwatermark.videoseal.1") == ["AIWatermark VideoSeal"] + assert soft_binding_vendors_in(b"com.aiwatermark.audioseal.1") == ["AIWatermark AudioSeal"] assert soft_binding_vendors_in(b"io.iscc.v0") == ["ISCC (content code)"] def test_vendors_in_empty_when_absent(self): diff --git a/tests/test_metadata_internals.py b/tests/test_metadata_internals.py index e3f6acc..752053c 100644 --- a/tests/test_metadata_internals.py +++ b/tests/test_metadata_internals.py @@ -266,8 +266,8 @@ class TestC2PA: def test_content_fingerprint_does_not_trigger_invisible_removal(self): info = { - "soft_binding": "Adobe (content fingerprint)", - "soft_binding_vendors": ["Adobe (content fingerprint)"], + "soft_binding": "Adobe Image Comparator Network", + "soft_binding_vendors": ["Adobe Image Comparator Network"], } assert c2pa_info_has_removal_hint(info) is False @@ -1007,7 +1007,7 @@ class TestTc260ContainerRouting: def _riff_chunk(chunk_id: bytes, payload: bytes) -> bytes: return chunk_id + len(payload).to_bytes(4, "little") + payload + (b"\x00" if len(payload) & 1 else b"") - def _labelled_avi(self) -> bytes: + def _labeled_avi(self) -> bytes: info = self._riff_chunk(b"AIGC", _TC260_AIGC_VALUE) body = b"AVI " + self._riff_chunk(b"LIST", b"INFO" + info) return b"RIFF" + len(body).to_bytes(4, "little") + body @@ -1016,7 +1016,7 @@ class TestTc260ContainerRouting: from remove_ai_watermarks.metadata import aigc_label target = tmp_path / "clip.bin" # correct AVI bytes, wrong suffix - target.write_bytes(self._labelled_avi()) + target.write_bytes(self._labeled_avi()) label = aigc_label(target) assert label is not None assert label["Label"] == "1" @@ -1025,7 +1025,7 @@ class TestTc260ContainerRouting: from remove_ai_watermarks.metadata import aigc_label target = tmp_path / "clip.avi" - target.write_bytes(self._labelled_avi()) + target.write_bytes(self._labeled_avi()) assert aigc_label(target) is not None def test_webp_yields_nothing_from_the_riff_reader(self, tmp_path: Path): diff --git a/tests/test_microsoft_engine.py b/tests/test_microsoft_engine.py new file mode 100644 index 0000000..f0d21e0 --- /dev/null +++ b/tests/test_microsoft_engine.py @@ -0,0 +1,166 @@ +"""Tests for the measured Microsoft top-right AI-badge engine. + +The covered variant is a white top-right pill with dark internal shapes. The +2026-08-27 calibration kept visually confirmed carriers, provenance-only files, +and no-signal controls separate. These tests pin the load-bearing constants -- +especially the long-side scale basis and the internal holes as the discriminator. +""" + +from __future__ import annotations + +import cv2 +import numpy as np +import pytest + +from remove_ai_watermarks import watermark_registry as registry +from remove_ai_watermarks.microsoft_engine import ( + _ALPHA_HEIGHT_FRAC, + _ALPHA_WIDTH_FRAC, + MicrosoftEngine, + _alpha_template, +) + +_INSET = 0.010 # measured pill inset from the top/right edges (long-side fraction) + + +def _pill_geometry(w: int, h: int) -> tuple[int, int, int, int]: + long_side = max(w, h) + pw = int(_ALPHA_WIDTH_FRAC * long_side) + ph = max(4, int(_ALPHA_HEIGHT_FRAC * long_side)) + pad = int(_INSET * long_side) + return w - pad - pw, pad, pw, ph + + +def _compose(w: int, h: int, bg: float = 110.0): + """Composite the synthetic pill at its measured size onto a flat background.""" + img = np.full((h, w, 3), bg, np.uint8) + at = _alpha_template() + x0, y0, pw, ph = _pill_geometry(w, h) + pill = cv2.resize(at, (pw, ph)) + region = img[y0 : y0 + ph, x0 : x0 + pw] + bright = pill > 0.6 + region[bright] = 245 + # Internal holes are dark ink inside the pill, not background. + region[~bright] = 45 + return img, (x0, y0, pw, ph) + + +def _plain_pill(w: int, h: int, text: str | None = None) -> np.ndarray: + """Return a white rounded pill without the expected holes, or with foreign text.""" + img = np.full((h, w, 3), 110.0, np.uint8) + x0, y0, pw, ph = _pill_geometry(w, h) + cv2.rectangle(img, (x0, y0), (x0 + pw, y0 + ph), (245, 245, 245), -1) + cv2.circle(img, (x0 + ph // 2, y0 + ph // 2), ph // 3, (110, 110, 110), -1) + if text: + cv2.putText(img, text, (x0 + ph, y0 + ph // 2 + ph // 6), cv2.FONT_HERSHEY_SIMPLEX, ph / 90.0, (45, 45, 45), 1) + return img + + +class TestLocate: + def test_box_anchored_top_right(self): + eng = MicrosoftEngine() + loc = eng.locate(np.zeros((1024, 1024, 3), np.uint8)) + assert loc.x + loc.w == pytest.approx(1024 - int(0.004 * 1024), abs=2) + assert loc.y == pytest.approx(int(0.003 * 1024), abs=2) + + def test_box_scales_with_long_side_not_width(self): + # Measured: the pill tracks the render dimension, so a 1024x1536 portrait + # carries the SAME pill size as 1536x1024. A width basis undersized the + # template by the aspect ratio and dropped every portrait carrier. + eng = MicrosoftEngine() + portrait = eng.locate(np.zeros((1536, 1024, 3), np.uint8)) + landscape = eng.locate(np.zeros((1024, 1536, 3), np.uint8)) + assert portrait.w == landscape.w + small = eng.locate(np.zeros((720, 480, 3), np.uint8)) + assert small.w < portrait.w + + +class TestConfig: + def test_provenance_relaxation_is_the_measured_07(self): + # The relaxed band was measured on the OCR-censused MS cohort: 257 + # badge-less files max 0.251, so the 0.266 relaxed gate admits the three + # faint badges in [0.251, 0.38) with zero measured false fills. Do not + # move the factor without re-censusing the badge-less cohort. + assert MicrosoftEngine().config.provenance_ncc_factor == 0.7 + + def test_long_scale_basis(self): + assert MicrosoftEngine().config.scale_basis == "long" + + def test_threshold_and_geometry_pins(self): + from remove_ai_watermarks.microsoft_engine import ( + DETECT_NCC_THRESHOLD, + MARGIN_RIGHT_FRAC, + WM_WIDTH_FRAC, + ) + + assert pytest.approx(0.38) == DETECT_NCC_THRESHOLD # controls max 0.293; carriers max 0.579 + assert pytest.approx(0.170) == WM_WIDTH_FRAC + assert pytest.approx(0.004) == MARGIN_RIGHT_FRAC + + def test_registry_row(self): + mark = registry.get_mark("microsoft") + assert mark.location == "top-right" + assert mark.label == "Microsoft top-right AI badge" + assert mark.in_auto + assert mark.provenance_platform_tokens == ("microsoft",) + assert mark.label_regime is None # not a China-TC260 mark + + +class TestDetect: + @pytest.mark.parametrize(("w", "h"), [(1024, 1024), (1536, 1024), (1024, 1536), (720, 480), (1206, 1194)]) + def test_composites_detected_across_sizes(self, w, h): + eng = MicrosoftEngine() + img, _box = _compose(w, h) + det = eng.detect(img) + assert det.detected, f"{w}x{h}: conf={det.confidence:.3f}" + assert det.confidence >= 0.38 + + def test_portrait_composite_region_covers_pill(self): + eng = MicrosoftEngine() + img, (x, y, pw, ph) = _compose(1024, 1536) + det = eng.detect(img) + assert det.detected + rx, ry, rw, _rh = det.region + assert abs((rx + rw) - (x + pw)) < 0.08 * pw + assert abs(ry - y) < 0.4 * ph + + def test_clean_gradient_not_detected(self): + eng = MicrosoftEngine() + ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1)) + img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR) + assert not eng.detect(img).detected + + def test_plain_white_pill_not_detected(self): + # The expected internal holes are the discriminator: any other bright rounded + # element in the corner must not attribute Microsoft. + eng = MicrosoftEngine() + assert not eng.detect(_plain_pill(1024, 1024)).detected + + def test_foreign_text_pill_not_detected(self): + eng = MicrosoftEngine() + assert not eng.detect(_plain_pill(1024, 1024, text="Sample Text")).detected + + def test_busy_content_corner_not_detected(self): + # A photo-like textured corner must stay under the gate. + eng = MicrosoftEngine() + rng = np.random.default_rng(7) + img = rng.integers(0, 255, (1024, 1024, 3), dtype=np.uint8) + img = cv2.GaussianBlur(img, (0, 0), 3) + assert not eng.detect(img).detected + + +class TestMask: + def test_footprint_covers_the_pill(self): + eng = MicrosoftEngine() + img, (x, y, pw, ph) = _compose(1536, 1024) + det = eng.detect(img) + assert det.detected + mask = eng.footprint_mask(img, detection=det) + assert mask.shape[:2] == img.shape[:2] + ys, xs = np.where(mask > 0) + assert xs.min() >= x - 0.15 * pw + assert xs.max() <= x + pw + 0.15 * pw + assert ys.min() >= y - 0.3 * ph + assert ys.max() <= y + ph + 0.3 * ph + # the fill must cover the pill area, not just the text glyphs + assert float(mask[y : y + ph, x : x + pw].mean()) > 0.4 diff --git a/tests/test_platform.py b/tests/test_platform.py index ceeea88..5747d0e 100644 --- a/tests/test_platform.py +++ b/tests/test_platform.py @@ -6,6 +6,7 @@ answer and a clean refusal rather than a fallback ladder. from __future__ import annotations +import struct from pathlib import Path from unittest.mock import MagicMock, patch @@ -15,6 +16,7 @@ from remove_ai_watermarks._internal.utils import get_image_format, is_supported_ from remove_ai_watermarks._internal.watermark_profiles import ( PROFILE_CHOICES, QWEN_ZIMAGE_GOOGLE_STRENGTH, + QWEN_ZIMAGE_META_STRENGTH, QWEN_ZIMAGE_OPENAI_STRENGTH, REMOVAL_MODULES, SDXL_ZIMAGE_GEMINI_STRENGTH, @@ -198,6 +200,30 @@ class TestNoReembeddedWatermark: assert "add_watermarker" not in calls["controlnet"] +# Minimal WebP stub whose XMP chunk carries the IPTC trainedAlgorithmicMedia +# tag exactly as Muse outputs place it (built inline so the test has no binary +# fixture dependency). +_XMP_PAYLOAD = ( + b'' + b'' + b"" +) + + +def _webp_stub(xmp: bytes | None) -> bytes: + def chunk(cid: bytes, data: bytes) -> bytes: + return cid + struct.pack(" 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) + with pytest.raises(ValueError, match="not calibrated with tiled diffusion"): + pipeline.run( + source, + strength=0.1, + seed=0, + tile=True, + tile_size=64, + tile_overlap=16, + text_manifest=manifest, + ) def test_watermark_remover_dispatches_to_full_pipeline(tmp_path, monkeypatch): @@ -757,6 +743,29 @@ def test_watermark_remover_dispatches_qwen_tiling_to_full_pipeline(tmp_path, mon assert output.exists() +def test_watermark_remover_rejects_tiled_verified_text_before_pipeline(tmp_path, monkeypatch): + from remove_ai_watermarks._internal.text_restoration import VerifiedTextLine, VerifiedTextManifest + from remove_ai_watermarks._internal.watermark_remover import WatermarkRemover + + _mock_watermark_runtime_deps(monkeypatch) + source = tmp_path / "source.png" + Image.new("RGB", (96, 80), (20, 30, 40)).save(source) + manifest = VerifiedTextManifest( + "0" * 64, + 96, + 80, + (VerifiedTextLine((4, 4, 20, 16), "Exact", "alphabetic"),), + ) + remover = WatermarkRemover(device="cuda", pipeline="qwen-zimage") + runtime = MagicMock() + monkeypatch.setattr(remover, "_load_qwen_zimage_pipeline", lambda: runtime) + + with pytest.raises(ValueError, match="not calibrated with tiled diffusion"): + remover.remove_watermark(source, text_manifest=manifest, tile=True) + + runtime.run.assert_not_called() + + def test_qwen_tiling_runs_global_tiles_then_one_full_frame_face_stage(monkeypatch): from remove_ai_watermarks._internal.qwen_zimage_pipeline import ( QwenZImagePipeline, diff --git a/tests/test_registered_mark_calibrate.py b/tests/test_registered_mark_calibrate.py new file mode 100644 index 0000000..01df9c3 --- /dev/null +++ b/tests/test_registered_mark_calibrate.py @@ -0,0 +1,59 @@ +"""Contracts for the registered visible-mark calibration harness.""" + +from __future__ import annotations + +import importlib.util +import json +from pathlib import Path + +import pytest + +SCRIPT = Path(__file__).parents[1] / "scripts" / "registered_mark_calibrate.py" +SPEC = importlib.util.spec_from_file_location("registered_mark_calibrate", SCRIPT) +assert SPEC +assert SPEC.loader +module = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(module) + + +def test_manifest_preserves_evidence_arms(tmp_path: Path) -> None: + manifest = tmp_path / "manifest.jsonl" + manifest.write_text( + "\n".join( + [ + json.dumps({"path": "carrier.png", "arm": "positive"}), + json.dumps({"path": "provider.png", "arm": "metadata"}), + json.dumps({"path": "comparison.png", "arm": "control"}), + ] + ), + encoding="utf-8", + ) + + rows = module.load_manifest(manifest) + + assert [row["arm"] for row in rows] == ["positive", "metadata", "control"] + assert all(Path(row["path"]).is_absolute() for row in rows) + + +def test_manifest_rejects_one_file_in_multiple_arms(tmp_path: Path) -> None: + manifest = tmp_path / "manifest.jsonl" + manifest.write_text( + "\n".join( + [ + json.dumps({"path": "same.png", "arm": "positive"}), + json.dumps({"path": "same.png", "arm": "control"}), + ] + ), + encoding="utf-8", + ) + + with pytest.raises(ValueError, match="duplicate image path"): + module.load_manifest(manifest) + + +def test_summary_never_relabels_a_control() -> None: + summary = module.summarize([0.1, 0.4], unreadable=1, fires=1) + + assert summary["n"] == 2 + assert summary["unreadable"] == 1 + assert summary["fires"] == 1 diff --git a/tests/test_runninghub_engine.py b/tests/test_runninghub_engine.py index 83936c3..28c36df 100644 --- a/tests/test_runninghub_engine.py +++ b/tests/test_runninghub_engine.py @@ -71,7 +71,7 @@ class TestConfig: assert RunningHubEngine().config.provenance_ncc_factor == 1.0 def test_gate_above_clean_arm_max(self): - # Clean arm scored p99 0.273 / max 0.295 on 286 hand-labelled frames. + # Clean arm scored p99 0.273 / max 0.295 on 286 hand-labeled frames. assert RunningHubEngine().config.detect_ncc_threshold > 0.295 def test_registry_row(self): diff --git a/tests/test_script_help.py b/tests/test_script_help.py new file mode 100644 index 0000000..731b508 --- /dev/null +++ b/tests/test_script_help.py @@ -0,0 +1,50 @@ +"""Basic command-line contracts for standalone maintainer scripts.""" + +from __future__ import annotations + +import subprocess +import sys +from os import environ +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[1] + + +@pytest.mark.parametrize( + "script", + [ + "visible_groundtruth.py", + "visible_recall_sample.py", + "visible_sheets.py", + "registered_mark_calibrate.py", + ], +) +def test_script_help_exits_cleanly(script: str) -> None: + result = subprocess.run( # noqa: S603 -- fixed interpreter and repository-owned script path + [sys.executable, str(ROOT / "scripts" / script), "--help"], + cwd=ROOT, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "usage:" in result.stdout + + +def test_visible_groundtruth_help_is_cp1252_safe() -> None: + env = environ.copy() + env["PYTHONIOENCODING"] = "cp1252" + result = subprocess.run( # noqa: S603 -- fixed interpreter and repository-owned script path + [sys.executable, str(ROOT / "scripts" / "visible_groundtruth.py"), "--help"], + cwd=ROOT, + env=env, + capture_output=True, + text=True, + check=False, + ) + + assert result.returncode == 0, result.stderr + assert "usage:" in result.stdout diff --git a/tests/test_text_mark_engine.py b/tests/test_text_mark_engine.py index 019e4fd..c715283 100644 --- a/tests/test_text_mark_engine.py +++ b/tests/test_text_mark_engine.py @@ -1,6 +1,6 @@ """Policy-level tests for the shared text-mark engine config. -These assert calibrated TUNING, not algorithm behaviour -- +These assert calibrated TUNING, not algorithm behavior -- they exist so a future edit cannot silently revert a calibrated constant back to a value that was measured to be wrong. The measurements themselves live in `docs/module-internals.md` and in the comment at @@ -74,7 +74,7 @@ class TestScaleBasis: assert doubao_engine._CONFIG.scale_basis == "short" def test_jimeng_scales_with_width(self): - """Measured, not an oversight: the short-side basis took jimeng's labelled + """Measured, not an oversight: the short-side basis took jimeng's labeled landscape positives from 13/13 to 0/13.""" from remove_ai_watermarks import jimeng_engine diff --git a/tests/test_text_mark_faint_mask.py b/tests/test_text_mark_faint_mask.py index c94f35f..1ee3ec3 100644 --- a/tests/test_text_mark_faint_mask.py +++ b/tests/test_text_mark_faint_mask.py @@ -118,7 +118,7 @@ class TestFaintMaskStaysTight: loc = eng.locate(img) roi = loc.w * loc.h # The mark's own glyph box is ~40% of the corner ROI and the mask pads it, so a - # correct mask lands near 60%. The pre-fix behaviour measured 120.9% (the whole + # correct mask lands near 60%. The pre-fix behavior measured 120.9% (the whole # ROI plus padding), which this bound excludes. assert area < 0.85 * roi, f"mask covers {100 * area / roi:.0f}% of the corner box" diff --git a/tests/test_tiling.py b/tests/test_tiling.py index c44f6dc..60171a0 100644 --- a/tests/test_tiling.py +++ b/tests/test_tiling.py @@ -100,7 +100,7 @@ class TestRunTiled: def test_identity_generate_reconstructs_image(self): # A blend of identical (unchanged) tiles must reproduce the input exactly, # regardless of overlap -- the feather weights are a partition-of-unity once - # normalised. This is the seam-free guarantee. + # normalized. This is the seam-free guarantee. rng = np.random.default_rng(0) arr = rng.integers(0, 256, size=(1500, 1300, 3), dtype=np.uint8) image = Image.fromarray(arr) diff --git a/tests/test_trustmark_detector.py b/tests/test_trustmark_detector.py index 9c6a106..4083f26 100644 --- a/tests/test_trustmark_detector.py +++ b/tests/test_trustmark_detector.py @@ -3,7 +3,7 @@ TrustMark is an optional dependency (extra ``trustmark``) that downloads model weights on first use, so the decode path is only exercised when it is installed (mirrors the imwatermark handling). The always-on test pins the graceful -absent/error behaviour: detect must return None, never raise. +absent/error behavior: detect must return None, never raise. """ from __future__ import annotations diff --git a/tests/test_video.py b/tests/test_video.py index 9600fb3..d210de6 100644 --- a/tests/test_video.py +++ b/tests/test_video.py @@ -860,6 +860,12 @@ class TestVideoMetadataApi: class TestVideoProvenanceApi: + def test_c2pa_platform_keeps_the_bytedance_surface_name(self): + from remove_ai_watermarks.video import _platform_from_video_metadata + + assert _platform_from_video_metadata({"issuer": "BytePlus (ByteDance)"}) == "BytePlus (ByteDance)" + assert _platform_from_video_metadata({"issuer": "ByteDance (Volcano Engine)"}) == "ByteDance Volcano Engine" + def test_identifies_metadata_without_pixel_scan(self, tmp_path: Path): from remove_ai_watermarks.video import identify_video @@ -870,7 +876,7 @@ class TestVideoProvenanceApi: assert report.source == source assert report.is_ai_generated is True assert report.confidence == "high" - assert report.platform == "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)" + assert report.platform == "OpenAI (ChatGPT / GPT Image / DALL·E / Sora)" assert report.visible_mark is None assert report.total_frames is None assert report.has_ai_metadata is True diff --git a/tests/test_video_fidelity_probe.py b/tests/test_video_fidelity_probe.py index a976e6c..a23039b 100644 --- a/tests/test_video_fidelity_probe.py +++ b/tests/test_video_fidelity_probe.py @@ -142,7 +142,7 @@ def test_pairing_follows_the_engine_sampling_rule_not_just_the_frame_count( in the suite that constrains the sampler's phase at all. The clips encode losslessly, so the aligned pairing is exact and clears any - floor; the misaligned one compares frames a full colour step apart and lands + floor; the misaligned one compares frames a full color step apart and lands near 9 dB, which is what leaves the 25 dB ceiling a wide moat rather than a tuned threshold. """ diff --git a/tests/test_video_invisible.py b/tests/test_video_invisible.py index 0f35223..235aca5 100644 --- a/tests/test_video_invisible.py +++ b/tests/test_video_invisible.py @@ -3,12 +3,14 @@ from __future__ import annotations import csv +import math import sys import threading from pathlib import Path from types import SimpleNamespace -from typing import TYPE_CHECKING, cast +from typing import TYPE_CHECKING, Any, cast +import numpy as np import pytest from remove_ai_watermarks import optional_deps, video_encoding, video_invisible @@ -92,6 +94,50 @@ def test_regeneration_rejects_noise_outside_unit_interval(tmp_path: Path) -> Non ) +@pytest.mark.parametrize( + ("kwargs", "message"), + [ + ({"fps": 0.0}, "fps must be at least 1"), + ({"batch_size": 0}, "batch_size must be at least 1"), + ({"duration": 0.0}, "duration must be positive"), + ({"device": "tpu"}, "device must be auto, cuda, mps, or cpu"), + ], +) +def test_regeneration_rejects_invalid_controls_before_probing( + tmp_path: Path, + kwargs: dict[str, Any], + message: str, +) -> None: + with pytest.raises(ValueError, match=message): + video_invisible.regenerate_video_candidate( + tmp_path / "source.mp4", + tmp_path / "candidate.mp4", + **kwargs, + ) + + +def test_fit_size_and_paired_psnr_validate_geometry() -> None: + assert video_invisible._fit_size(1920, 1080, 512) == (512, 288) + with pytest.raises(ValueError, match="positive"): + video_invisible._fit_size(0, 1080, 512) + with pytest.raises(ValueError, match="at least"): + video_invisible._fit_size(1920, 1080, 4) + + frame = np.zeros((2, 2, 3), dtype=np.uint8) + assert video_invisible.paired_psnr(frame, frame) == math.inf + with pytest.raises(ValueError, match="matching shapes"): + video_invisible.paired_psnr(frame, frame[:1]) + + +def test_load_runtime_rejects_invalid_device_and_missing_extra(monkeypatch: pytest.MonkeyPatch) -> None: + with pytest.raises(ValueError, match="device must be"): + video_invisible.load_video_vae_runtime(device="tpu") + + monkeypatch.setattr(video_invisible, "is_available", lambda: False) + with pytest.raises(RuntimeError, match="diffusion extra"): + video_invisible.load_video_vae_runtime() + + def test_encoder_and_mux_commands_separate_streaming_frames_from_source_audio( tmp_path: Path, monkeypatch: pytest.MonkeyPatch, @@ -298,8 +344,9 @@ def test_shipped_defaults_match_a_certified_manifest_row() -> None: rate now fails here until a ``not_detected`` row exists for that exact triple. The tuple stops at three fields because the model is a fourth thing the oracle - was shown and neither tracked row records it. That omission is data-driven: add - ``vae`` here in the same commit as the first row that records one. + was shown and neither historical row recorded it. The manifest says + ``unrecorded`` rather than leaving an ambiguous empty field. Add ``vae`` here in + the same commit as the first row that records one. """ with ORACLE_MANIFEST.open(newline="", encoding="utf-8") as stream: certified = { @@ -320,6 +367,15 @@ def test_shipped_defaults_match_a_certified_manifest_row() -> None: ) +def test_oracle_manifest_marks_missing_vae_identity_explicitly() -> None: + with ORACLE_MANIFEST.open(newline="", encoding="utf-8") as stream: + rows = list(csv.DictReader(stream)) + + assert rows + assert all(row["vae"] for row in rows) + assert all(row["vae"] == "unrecorded" or "/" in row["vae"] for row in rows) + + def test_stream_batches_consumes_only_one_batch_ahead() -> None: consumed: list[int] = [] diff --git a/tests/test_visible_examples.py b/tests/test_visible_examples.py new file mode 100644 index 0000000..115ea09 --- /dev/null +++ b/tests/test_visible_examples.py @@ -0,0 +1,57 @@ +"""The visible-mark example gallery is complete and self-consistent. + +Two failures this suite exists to catch: + * a mark registered without a committed example (the gallery lags the registry); + * an engine that no longer detects its own canonical example (the gallery is + generated from the engines' measured geometry, so this is a regression tripwire). + +The examples are SYNTHETIC (``scripts/render_visible_examples.py`` composites the +committed silhouettes onto a generated base). User uploads never enter the repo. +""" + +from __future__ import annotations + +from pathlib import Path + +import pytest + +from remove_ai_watermarks import watermark_registry as wr +from remove_ai_watermarks.image_io import imread +from remove_ai_watermarks.video import VIDEO_VISIBLE_MARKS, identify_video + +_ROOT = Path(__file__).resolve().parents[1] +_GALLERY = _ROOT / "data" / "fixtures" / "visible" + +_IMAGE_KEYS = [m.key for m in wr.known_marks()] + + +class TestGallery: + def test_every_registered_mark_has_an_example(self) -> None: + missing = [key for key in _IMAGE_KEYS if not (_GALLERY / key / "example.png").is_file()] + assert missing == [], f"registered without an example: {missing}; run scripts/render_visible_examples.py" + + @pytest.mark.parametrize("key", _IMAGE_KEYS) + def test_engine_detects_its_own_example(self, key: str) -> None: + img = imread(str(_GALLERY / key / "example.png")) + assert img is not None, key + det = wr.get_mark(key).detect(img, provenance=False) + assert det.detected, f"{key}: confidence {det.confidence:.3f} on its own example" + + def test_gallery_has_no_stray_directories(self) -> None: + known = set(_IMAGE_KEYS) | set(VIDEO_VISIBLE_MARKS) | {"README.md"} + extra = sorted(p.name for p in _GALLERY.iterdir() if p.name not in known) + assert extra == [], f"gallery holds unregistered examples: {extra}; remove or register them" + + +class TestVideoGallery: + def test_every_registered_video_mark_has_an_example(self) -> None: + missing = [key for key in VIDEO_VISIBLE_MARKS if not (_GALLERY / key / "example.mp4").is_file()] + assert missing == [], f"video mark without an example: {missing}; run scripts/render_visible_examples.py" + + def test_selection_accepts_each_example(self) -> None: + # The shipped temporal selection (not just the per-frame detector) must + # accept the clip: table order resolves cross-template ties, so the example + # must carry the discriminative variant of its mark. + for key in VIDEO_VISIBLE_MARKS: + rep = identify_video(_GALLERY / key / "example.mp4", check_visible=True) + assert rep.visible_mark == key, f"{key}: selection returned {rep.visible_mark!r}" diff --git a/tests/test_watermark_registry.py b/tests/test_watermark_registry.py index 53eddd3..67ce8c4 100644 --- a/tests/test_watermark_registry.py +++ b/tests/test_watermark_registry.py @@ -25,6 +25,7 @@ class TestCatalog: "runninghub", "baidu", "liblib", + "microsoft", "jimeng_pill", ] @@ -113,6 +114,7 @@ class TestScan: "runninghub", "baidu", "liblib", + "microsoft", "jimeng_pill", } @@ -219,7 +221,7 @@ class TestProvenanceGate: # 0.38 is inside the measured 13%-precision band and above the engine's own # 0.35 floor, so the engine reports `detected` and only the registry gate can # reject it. Hardcoded on purpose: if the gate is ever lowered back under this - # value, this test must fail on the BEHAVIOUR below, not on its own arithmetic. + # value, this test must fail on the behavior below, not on its own arithmetic. self._stub(monkeypatch, 0.38) img = np.zeros((256, 256, 3), np.uint8) assert reg.get_mark("gemini").detect(img).detected is False @@ -529,7 +531,7 @@ class TestMarkKnowledgeIsOnTheRow: Product family, label regime, the platform sentence and the metadata signals that confirm the vendor all used to live in separate hand-maintained tables across - ``watermark_registry``, ``identify`` and ``api``. That is how LibLibAI ended up + ``watermark_registry``, ``identify`` and ``api``. That is how LiblibAI ended up registered but absent from the pill veto. """ @@ -555,15 +557,18 @@ class TestMarkKnowledgeIsOnTheRow: if mark.label_regime == "tc260" and mark.key != "jimeng_pill": assert "aigc" in mark.provenance_signals, mark.key - def test_only_gemini_claims_platform_tokens(self): + def test_platform_token_marks_are_the_c2pa_attributed_ones(self): + # Gemini (Google C2PA) and Microsoft (issuer "Microsoft") are the marks whose + # vendor a C2PA platform string can confirm; every other mark reaches its + # provenance through TC260 codes or product signals instead. by_token = {m.key for m in reg.known_marks() if m.provenance_platform_tokens} - assert by_token == {"gemini"} + assert by_token == {"gemini", "microsoft"} class TestPillSuppressors: """The pill veto is derived from the registry, not hand-listed. - The hand-written list drifted: LibLibAI was registered in the same commit as + The hand-written list drifted: LiblibAI was registered in the same commit as RunningHub and Baidu, both of which were added to the veto, and it was not. A derived set cannot be forgotten by the next registration. """ @@ -581,7 +586,7 @@ class TestPillSuppressors: assert not reg._keep_pill({"liblib"}, provenance=frozenset({"jimeng"}), footprint_flat=1.0) def test_pill_dropped_on_liblib_even_with_the_jimeng_wordmark(self): - """The veto precedes the wordmark arm, so a co-firing LibLibAI wins. + """The veto precedes the wordmark arm, so a co-firing LiblibAI wins. This is the broader half of the change: it needs neither TC260 provenance nor a flat footprint, so it is reachable on more inputs than the metadata arm. @@ -589,7 +594,7 @@ class TestPillSuppressors: assert not reg._keep_pill({"liblib", "jimeng"}, provenance=frozenset(), footprint_flat=1.0) def test_pill_survives_gemini_and_samsung(self): - """Neither is a TC260 labeller, and neither can put "jimeng" into provenance, + """Neither is a TC260 labeler, and neither can put "jimeng" into provenance, so neither may veto the arm it could not have enabled.""" assert reg._keep_pill({"gemini", "jimeng"}, provenance=frozenset(), footprint_flat=1.0) assert reg._keep_pill({"samsung", "jimeng"}, provenance=frozenset(), footprint_flat=1.0) @@ -632,4 +637,4 @@ class TestProvenanceMaskThreading: ) monkeypatch.setattr(eng, "footprint_mask", lambda image, *, force=False, region=None, dilate=None: None) _, removed = reg.remove_auto_marks(np.zeros((256, 256, 3), np.uint8), sensitivity="strict", backend="cv2") - assert "Google Gemini sparkle" not in removed + assert "Google Gemini visible watermark (sparkle)" not in removed diff --git a/uv.lock b/uv.lock index 5f1b531..0b4d4d3 100644 --- a/uv.lock +++ b/uv.lock @@ -288,7 +288,7 @@ wheels = [ [[package]] name = "c2pa-python" -version = "0.37.7" +version = "0.37.8" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cryptography" }, @@ -298,15 +298,15 @@ dependencies = [ { name = "toml" }, { name = "wheel" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/58/74/291b19f26fe628ca6bf001be741dfe634f8689756cf8495499e02ab2f631/c2pa_python-0.37.7.tar.gz", hash = "sha256:66114c3bc6b073249cb33043e491cf292e052ff770c1aefaed66a5ca2d8aaa0c", size = 112987, upload-time = "2026-08-13T21:25:27.464Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1f/32/e93959d17db5af4078532f587b9638e87b6429ca0b51348a8ac6ad99e471/c2pa_python-0.37.8.tar.gz", hash = "sha256:c52eb07244558a5997fe61b6ed8d879b4f46f38642dd11956bcedf888c58649c", size = 113008, upload-time = "2026-08-27T20:40:06.081Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/ee/be1b567bd85563ee72f7826df23c1fa13886f086b58714dda9bb2f9491a7/c2pa_python-0.37.7-py3-none-macosx_10_9_universal2.whl", hash = "sha256:fde655c31e31015b1474a06f85728e878258e1fe7f2fff6cf4924e4985479da9", size = 16187849, upload-time = "2026-08-13T21:25:06.779Z" }, - { url = "https://files.pythonhosted.org/packages/51/69/50dea7bcbfd68ce7f6734735a1fb9e80ac15498520946a2ff1598d7fef5b/c2pa_python-0.37.7-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d2ee0682f00c3a361d6401a6d2ae249bf953c2375c6945e5ebcf3cbd8022e8e4", size = 14228094, upload-time = "2026-08-13T21:25:09.687Z" }, - { url = "https://files.pythonhosted.org/packages/c0/c2/2c8a58a88488b30f9f96454eee26f815622cfca00b965d60b7a639c7d94d/c2pa_python-0.37.7-py3-none-macosx_11_0_arm64.whl", hash = "sha256:dae0e1177919a2b89b8d951d525421b35d67b21bc38049d544dbcfd881555a44", size = 13869736, upload-time = "2026-08-13T21:25:12.051Z" }, - { url = "https://files.pythonhosted.org/packages/dc/a6/9a4eafdcc836694542eaa3bade9e994248fa777ae4c0eac2bc4f1f199121/c2pa_python-0.37.7-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:c6b2e2b4cb7571e4b8ee76b6f9c472c44aa5650702207db06057d03a14ca2fd2", size = 14085896, upload-time = "2026-08-13T21:25:14.349Z" }, - { url = "https://files.pythonhosted.org/packages/76/c2/983bcdc449e2c4384f0a7299859ec578365c54a2c3ca42030724ab21751e/c2pa_python-0.37.7-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:36bb8eff8904869d8fdf00f6aabe7ba41389d3dbffba02bae324c690450019c2", size = 14714332, upload-time = "2026-08-13T21:25:16.718Z" }, - { url = "https://files.pythonhosted.org/packages/3a/0e/86d47b5dba0a960bac3225e16f7aaa202ea894efed683801a9b99fc33092/c2pa_python-0.37.7-py3-none-win_amd64.whl", hash = "sha256:8054383a53d50dd86197f391bb2e91871284eaecf94410e6d3050803d02fd5e3", size = 86460712, upload-time = "2026-08-13T21:25:20.269Z" }, - { url = "https://files.pythonhosted.org/packages/be/30/4e95f1a9be67bb66690cde0b6143f8761d294d67919cf99016850d7963d7/c2pa_python-0.37.7-py3-none-win_arm64.whl", hash = "sha256:a0980f7d94459a2569c0338af9cdd5b7301ded6578eab2701ed61f8590d3fca4", size = 83941581, upload-time = "2026-08-13T21:25:24.573Z" }, + { url = "https://files.pythonhosted.org/packages/67/6a/252d8d072ac4192df8da28771be64a54cb4ea73f08f3ca1f2b7fafa823a5/c2pa_python-0.37.8-py3-none-macosx_10_9_universal2.whl", hash = "sha256:8dae7d198dd583de3963529b60665e611e36ddca677f85700147d3bdbc885b27", size = 16186835, upload-time = "2026-08-27T20:39:35.421Z" }, + { url = "https://files.pythonhosted.org/packages/c7/b7/3519bbb3735adce56eb0836d25fcf4e00c36c4e5cd480ee5277aadba18ed/c2pa_python-0.37.8-py3-none-macosx_10_9_x86_64.whl", hash = "sha256:d3ff8da9be41c6f74218872f8ebf0b0020dbc00202bb30fda751422418f679c5", size = 14230208, upload-time = "2026-08-27T20:39:38.587Z" }, + { url = "https://files.pythonhosted.org/packages/24/5c/ed3000ca3de522c17797482a90cd64f57cdd2fb9b21a49910c04327954bf/c2pa_python-0.37.8-py3-none-macosx_11_0_arm64.whl", hash = "sha256:ee4fbbe3b98dd4349cc9c60db3c4fb1c349c65b5c72fb89a0de403ca778d3d0d", size = 13865982, upload-time = "2026-08-27T20:39:41.837Z" }, + { url = "https://files.pythonhosted.org/packages/9a/bd/934002a34aa76e5136508b524734dc3c4719f6d87b5a98cf93cbf4917d05/c2pa_python-0.37.8-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:e8c04f4220194a3392dda290be96181ffc94ee5008e87e1fa96c390ec4f9c53c", size = 14077063, upload-time = "2026-08-27T20:39:45.069Z" }, + { url = "https://files.pythonhosted.org/packages/8c/e8/ca03c21e253e95a3aaab472697f1c0c766af852cb643879b4928f6c57298/c2pa_python-0.37.8-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:20742bd702d09476390dc73a375883990291ea458af8f3e61ef48fa9928f2313", size = 14715089, upload-time = "2026-08-27T20:39:48.275Z" }, + { url = "https://files.pythonhosted.org/packages/44/cd/376d90d3073ad975d38a42bdbcb42cec071bff3b6831dd5a6b807526a1df/c2pa_python-0.37.8-py3-none-win_amd64.whl", hash = "sha256:5cf1dadd1c8de7da298db9c62c432180d7185a31e81a96dfae7bce7944b21ddd", size = 86478089, upload-time = "2026-08-27T20:39:55.511Z" }, + { url = "https://files.pythonhosted.org/packages/e1/df/3e59b7813be65678d28f88a7a0c7a13ba8c983fe2a1ecf406959dc7b88df/c2pa_python-0.37.8-py3-none-win_arm64.whl", hash = "sha256:af827698ead013ca20e829e3c9fe1846b548ae77d1ab84762118fbde340e3f0a", size = 83936189, upload-time = "2026-08-27T20:40:03.148Z" }, ] [[package]] @@ -546,14 +546,11 @@ wheels = [ [[package]] name = "click" -version = "8.4.2" +version = "8.5.0" source = { registry = "https://pypi.org/simple" } -dependencies = [ - { name = "colorama", marker = "sys_platform == 'win32'" }, -] -sdist = { url = "https://files.pythonhosted.org/packages/76/d4/81420972a676e8ffea40450d8c8c92943e7218a78fe9b64359836cc9876b/click-8.4.2.tar.gz", hash = "sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6", size = 338000, upload-time = "2026-06-24T17:45:15.148Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c7/0e/7fa0ef50764b67090eca4114772a2abf8b6148198475e54c660b97caeee6/click-8.5.0.tar.gz", hash = "sha256:ba0d2089de75ea0310e2dde03160e6ca10009947fb95a182f9b54021bb272e34", size = 382235, upload-time = "2026-08-26T13:33:14.56Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/fb/e2/79c688af8b210d232694e31e59da9f6ec747bae31c3f5946e4e9b98860d5/click-8.4.2-py3-none-any.whl", hash = "sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76", size = 119243, upload-time = "2026-06-24T17:45:13.73Z" }, + { url = "https://files.pythonhosted.org/packages/58/50/6c0d534c5f134586a8e1ba4e330569e32f057e33372ae556463212fb4cd3/click-8.5.0-py3-none-any.whl", hash = "sha256:255bc9599cf7748b4b1a446ccc735421bd08a2ae529a8b88597d3de5664ee360", size = 125251, upload-time = "2026-08-26T13:33:12.928Z" }, ] [[package]] @@ -919,7 +916,7 @@ wheels = [ [[package]] name = "diffusers" -version = "0.39.0" +version = "0.40.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, @@ -933,9 +930,9 @@ dependencies = [ { name = "requests" }, { name = "safetensors" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/1a/81/6095237b86a3116c4789f28c4435d5296c00c0fc74ffde99008fd6b3a36c/diffusers-0.39.0.tar.gz", hash = "sha256:14bb1d98c85a0e463d734c99aaa73b480a7bc9bad22af30fbf730ef8f09c1d67", size = 4651240, upload-time = "2026-07-03T08:48:47.904Z" } +sdist = { url = "https://files.pythonhosted.org/packages/59/32/b9c6a198812e2a7d2c2828a850dba384306f9af285eac23e35e89d5b008a/diffusers-0.40.0.tar.gz", hash = "sha256:49f112ce52ff6d332ab68afec01f79a53e6489b42cb69104444c98b4a2f64af8", size = 4886420, upload-time = "2026-08-20T14:30:40.923Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3f/3f/7469c46e9d22307ea686bab687d70e6bf328722952f9d10339f5e913e608/diffusers-0.39.0-py3-none-any.whl", hash = "sha256:912aca51b5787365110806e984d5555735bf8a461073bb8459029d0bca7870ef", size = 5631176, upload-time = "2026-07-03T08:48:45.337Z" }, + { url = "https://files.pythonhosted.org/packages/db/df/ffb593ebed2a068d2d6be44261283f39a6b809c0fcdfdcafbd448cbeec77/diffusers-0.40.0-py3-none-any.whl", hash = "sha256:5b5da7c3ddb62152fa4afc577f02e050af688c797375c34fb2d01006da3f3541", size = 5911654, upload-time = "2026-08-20T14:30:38.422Z" }, ] [[package]] @@ -1224,7 +1221,7 @@ wheels = [ [[package]] name = "huggingface-hub" -version = "1.27.0" +version = "1.28.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "click" }, @@ -1237,9 +1234,9 @@ dependencies = [ { name = "tqdm" }, { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/3e/9b/ddf3d02a8681f1b9ce52fda03d755dad6b74c4f8172304c4c8d2975450f9/huggingface_hub-1.27.0.tar.gz", hash = "sha256:c1fed40ea82a6b41b477f5243546549b792ae0a93abcea608cff66089bf8f8df", size = 942668, upload-time = "2026-08-07T12:48:05.161Z" } +sdist = { url = "https://files.pythonhosted.org/packages/c6/ae/222a91937ebee7f62c0ca8f5ee0afd97577caf24c0abb927d1f5c7e9f6d2/huggingface_hub-1.28.0.tar.gz", hash = "sha256:46a2e950c09234de54093d587d1675382f0d08dbd600d9fb599b5932f5b2c6cb", size = 959609, upload-time = "2026-08-18T12:27:15.101Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/de/d8/95b735e183957c1f26d94c52977f09d466d55119cbbc1558ea4975e4c216/huggingface_hub-1.27.0-py3-none-any.whl", hash = "sha256:7df6827c2f956c60fbaa64646e979e566db76f619dd0a9729dfb8c5a3eb4f68d", size = 784926, upload-time = "2026-08-07T12:48:02.905Z" }, + { url = "https://files.pythonhosted.org/packages/51/0e/eafef18f1a75e125e68395db21131db0cf868a128ecd2fce69b4df6c584b/huggingface_hub-1.28.0-py3-none-any.whl", hash = "sha256:58a8bacb03072edfc38067065e9dc24bbb34805410fcd36a1632de0b329660bb", size = 793202, upload-time = "2026-08-18T12:27:12.719Z" }, ] [[package]] @@ -2050,7 +2047,7 @@ wheels = [ [[package]] name = "onnxruntime" -version = "1.28.0" +version = "1.29.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "flatbuffers" }, @@ -2060,30 +2057,30 @@ dependencies = [ { name = "protobuf" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/17/4d/5014667e2a3a77d6e1b74cc3d88948d06163b8e0a33a84c85073322b5dec/onnxruntime-1.28.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:f5c5daabd28aad610f83fdcf32acec8fb57e6adc6c6a39fe2a3c755db957b410", size = 19130506, upload-time = "2026-07-25T01:22:34.489Z" }, - { url = "https://files.pythonhosted.org/packages/ea/97/b7ce1bc8bb6048b5fe9129f55d6506dc19499068ef2e0a0af1ae3c8aa4e7/onnxruntime-1.28.0-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:8d66f9ceb29909c70839e4e4fb3435c7b490050d8f162bd5f3aba4ca01ee517f", size = 17039880, upload-time = "2026-07-25T01:21:37.538Z" }, - { url = "https://files.pythonhosted.org/packages/f3/17/4e5ecd8764f87573c495d834ce79e61ecca47f7a01d1e444a606e570edcb/onnxruntime-1.28.0-cp311-cp311-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:a166b78ee04f3a37fa1ef82034b6a3ce96d9684e582d4d30b296de83e9998bb5", size = 19193162, upload-time = "2026-07-25T01:21:59.151Z" }, - { url = "https://files.pythonhosted.org/packages/9f/10/3d946d5d5f2cdcc3c8da36cae63190c516d16349edaffd944bda60ca4c3e/onnxruntime-1.28.0-cp311-cp311-win_amd64.whl", hash = "sha256:0d650aeee29368414367b65529e90afe4bf1bab76254789063b8b2f7ea3013c8", size = 13752539, upload-time = "2026-07-25T01:22:24.524Z" }, - { url = "https://files.pythonhosted.org/packages/8f/74/1c440be7af1e026280b139caa1be5d11bd4dc368011ddbe8f5362b58e12f/onnxruntime-1.28.0-cp311-cp311-win_arm64.whl", hash = "sha256:0faf85fb447a663c9cdadc39bd6b19bdf7bedded6699e45731b9b36c46fd993d", size = 13449940, upload-time = "2026-07-25T01:22:14.97Z" }, - { url = "https://files.pythonhosted.org/packages/98/f8/dcbe7700dca82fa540035abd3c868fe5ad0f86af00b9a3db7c2e27d15c7d/onnxruntime-1.28.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:26ff0fdd06efb6c155bae95387a09db1a2be89c7a03e4d0bffd5a171cc2826da", size = 19141362, upload-time = "2026-07-25T01:22:36.965Z" }, - { url = "https://files.pythonhosted.org/packages/28/5b/1d77e62097fdbe07e2dc827f389b1c4c0c275f6fab0369a8f46d2461af27/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4e81a23df16e7acb9d51b06d30cc098e49315ef9180f97bc2221d167b4b04d9c", size = 17050628, upload-time = "2026-07-25T01:21:40.481Z" }, - { url = "https://files.pythonhosted.org/packages/95/df/5486ab03e9be288d5268867054c8b04bebcf95bfd12e801c05cc67703dab/onnxruntime-1.28.0-cp312-cp312-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:0a83bdb70d143cede762b677789bf2a7acca54b3fb82565601d5c30695aa933c", size = 19214257, upload-time = "2026-07-25T01:22:01.695Z" }, - { url = "https://files.pythonhosted.org/packages/3e/3b/986ca67c274932ba9ac5332fb10de56f643dfd433c74e33f8ae8f847cf24/onnxruntime-1.28.0-cp312-cp312-win_amd64.whl", hash = "sha256:c35064f9b3c43c81c5d5d282091401d0f1ff22796d93ccade4ea2ece5e137ab8", size = 13755036, upload-time = "2026-07-25T01:22:26.89Z" }, - { url = "https://files.pythonhosted.org/packages/1d/46/059dba81d46c6ba88e0c2d1c64321ac8098847d678423300a183d42ecbd6/onnxruntime-1.28.0-cp312-cp312-win_arm64.whl", hash = "sha256:e02feeb0165c5f13b4cc954738078d59b90128516ac12b671ee24a530242bf02", size = 13454462, upload-time = "2026-07-25T01:22:17.38Z" }, - { url = "https://files.pythonhosted.org/packages/9c/12/3807e2b17d9eb71d3cb78ed2ba76869b05c637c9b9d6112e636098b0c97a/onnxruntime-1.28.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:31410f544674f534c2f27348af52ef81682ca9c8719154bf4d48f0ef23823b1e", size = 19141759, upload-time = "2026-07-25T01:21:53.765Z" }, - { url = "https://files.pythonhosted.org/packages/c0/23/b46045c3bf67a9cf54c12f5df0f018a422c65fbb9d6072b10071bebfaae2/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:f649dd6f6452d12a8059888aa489fe519e062e18793dac72b9efa0f9fdb64135", size = 17049339, upload-time = "2026-07-25T01:21:43.005Z" }, - { url = "https://files.pythonhosted.org/packages/78/b6/8c5396e7894e77c5a7d1e026f3acb9dd39c4b5644e412e37a0055eaa3bc5/onnxruntime-1.28.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:54fa221d669282bd8f582708ce4c96010a7e9fb0661f9006b37fe2fedafb73fe", size = 19214329, upload-time = "2026-07-25T01:22:04.133Z" }, - { url = "https://files.pythonhosted.org/packages/56/f1/51225c202edba4dfc94e1ea03f3d78f1aaf307da75fd792c0ce1946b2514/onnxruntime-1.28.0-cp313-cp313-win_amd64.whl", hash = "sha256:1a1a19175464665c9b8d50bc916f216cc0b569110045b7bbca8f9f290b186f58", size = 13755033, upload-time = "2026-07-25T01:22:29.302Z" }, - { url = "https://files.pythonhosted.org/packages/f4/db/f59f715edfdd96a051f32b5ef0e680a20a8755d4ecd75f63090e960e347a/onnxruntime-1.28.0-cp313-cp313-win_arm64.whl", hash = "sha256:cfab507abe09d6ffeb817eee07944d452fdc0b00fdcef34cab4db10a45e378c7", size = 13454175, upload-time = "2026-07-25T01:22:19.912Z" }, - { url = "https://files.pythonhosted.org/packages/47/28/810314fa88647af9f4cdaf438a30ad1cfebebb53ded55499232d7a0094e6/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ac301f53b1930402fc46c368e268acfed02f3207272aaff05070d7e09f96f031", size = 17057307, upload-time = "2026-07-25T01:21:45.492Z" }, - { url = "https://files.pythonhosted.org/packages/3d/cc/9e9f193cc0f29f263a8f09ec08487aed6c96ee856d5fd77da32a425c1949/onnxruntime-1.28.0-cp313-cp313t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:f7f022a1103cae591c75fc4565589a515f2ddd14a6ac8e8a05812dfeda142e28", size = 19222954, upload-time = "2026-07-25T01:22:06.952Z" }, - { url = "https://files.pythonhosted.org/packages/4e/eb/952314c451d9463e5c9aed9978eec76cf32930d407d9ab8700dd0f4ea1ea/onnxruntime-1.28.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:8adff67a3f28257b37cfe945a7e952e4122666aa8c91a0380862e9fd4c2ed19f", size = 19143748, upload-time = "2026-07-25T01:21:56.297Z" }, - { url = "https://files.pythonhosted.org/packages/3f/e9/139180b4dd810329aaa42c238b4e6383c906202d98609ae29d66eb7c32b1/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:bc2565e487b4896fb988d6383577d875d958e071fc5f6c3550bd5d02ae98264b", size = 17051950, upload-time = "2026-07-25T01:21:48.606Z" }, - { url = "https://files.pythonhosted.org/packages/03/88/9432428273356ad3c8aa01f52c1b3e7f53c4c0192748f41ad983872b436b/onnxruntime-1.28.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6afdc83f1317c136e92fc29f5ee9f058de59d87c0b22cee3fdbfbaa0ccc2098a", size = 19214924, upload-time = "2026-07-25T01:22:09.727Z" }, - { url = "https://files.pythonhosted.org/packages/bb/e2/6feb3a43517aaf2b1bf7e46897ba5eb81a29717f7d7901420614d5ee4653/onnxruntime-1.28.0-cp314-cp314-win_amd64.whl", hash = "sha256:f2a3b9e30ce880d4ca54999cb313569e36da4f62eefe25f87be18f43e9a3a4d5", size = 14093738, upload-time = "2026-07-25T01:22:31.629Z" }, - { url = "https://files.pythonhosted.org/packages/fc/8f/83974a1e201dc2e58e5e7111bcaeb1ca2413e9c41f505d26419ee9e3dddf/onnxruntime-1.28.0-cp314-cp314-win_arm64.whl", hash = "sha256:07fb3cbe990d6bf0ab3c22bfbbfb0e314151266046ea6edb4a07f556b4258c5f", size = 13821117, upload-time = "2026-07-25T01:22:22.387Z" }, - { url = "https://files.pythonhosted.org/packages/0d/83/00e606bc25c756d76a267370c39b7516ad52f9cf134d7ff2bff8b6108bc4/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:e562d6e36a749f6764481c0ddb0f2af3d0b5a3c164291361d08803c557f369af", size = 17055518, upload-time = "2026-07-25T01:21:51.08Z" }, - { url = "https://files.pythonhosted.org/packages/94/a9/68707e1ce345cbdbcd4df65932ebc82a673e917d63eda0007ebcff948691/onnxruntime-1.28.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:4f6e92367ddce1e4d33cf295024f40192be6c6171a09208f515ba169ced06c8e", size = 19222976, upload-time = "2026-07-25T01:22:12.474Z" }, + { url = "https://files.pythonhosted.org/packages/7c/a8/0520890321b8ff40b908cf165a93eb58fbc8f85c14db637277ea866c9544/onnxruntime-1.29.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:07c5907474dec4a2792fd7626b753dc66707808385a6d9eecf993db0066a9d0f", size = 21420890, upload-time = "2026-08-17T22:53:33.429Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/8bd3e0008ff8d386305351109a7329ea57e51a3ab57bc92340f29c4a5b5d/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:16925ef8497e2c07e4b5ae15b504079b3ab3f65e22c58efd10dde0f3caea969a", size = 20803602, upload-time = "2026-08-17T22:53:36.47Z" }, + { url = "https://files.pythonhosted.org/packages/3b/91/a66cd77f28379ede419672edda3184f1eb286db215dce1e7b976fae2d63b/onnxruntime-1.29.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:85f8e8406c52658735fe5c7fbfd3ebaa1ed340768324f6252e4274e374580a23", size = 23113193, upload-time = "2026-08-17T22:53:39.732Z" }, + { url = "https://files.pythonhosted.org/packages/1c/82/2da968405c42340f03de0bcdb63be09ae1004f820b2295590d48951b5cf2/onnxruntime-1.29.0-cp311-cp311-win_amd64.whl", hash = "sha256:0d4f427afac434b0070fe992b540ddf20a7aff2265f760f314d91331935b6b98", size = 13999253, upload-time = "2026-08-17T22:53:43.184Z" }, + { url = "https://files.pythonhosted.org/packages/95/7a/70c9c893bf732ee66124c2d8de6a21fc9361ec62cf378f857043efcbf0eb/onnxruntime-1.29.0-cp311-cp311-win_arm64.whl", hash = "sha256:4eae472cf7dc3107dec1bb53cd6d142d1964616d08aae48654cd4254b2363c4b", size = 13741410, upload-time = "2026-08-17T22:53:45.521Z" }, + { url = "https://files.pythonhosted.org/packages/d4/80/381c1e9efed9cc32d00aa7cab0547dc84116cec906c3ffe3613686d6963a/onnxruntime-1.29.0-cp312-cp312-macosx_14_0_arm64.whl", hash = "sha256:3a3814c041251d6a77fdf513fb282056538ee826d2f1178a0df3c549d3fff6ba", size = 21430049, upload-time = "2026-08-17T22:53:48.286Z" }, + { url = "https://files.pythonhosted.org/packages/30/12/4be0e345d38fe707a701ca07e8f63c05b152a2e6285d1e43a7faf63fedd2/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:d2fb19e848f7c33ed8d3182b52504aaa11c5e8da438bbb47296f85b133cbcf6b", size = 20816870, upload-time = "2026-08-17T22:53:51.169Z" }, + { url = "https://files.pythonhosted.org/packages/96/eb/e6968f5e41aac3125f2ff5708855f09cb0b70d85ed3115b625b0b58305ba/onnxruntime-1.29.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:2b80d8c7ec2cc7438e4da3760b88c24568cba72c9ace96d668800a6c79419acb", size = 23136745, upload-time = "2026-08-17T22:53:53.92Z" }, + { url = "https://files.pythonhosted.org/packages/b4/80/5b28f1f1111210fc4a336ddbc6950f468ebf9a6a265420568f4f43fa33ce/onnxruntime-1.29.0-cp312-cp312-win_amd64.whl", hash = "sha256:4acf2b4948b7ede87221ca6332344b8facdc8059d6ac751a7d367d04532b02dd", size = 14001407, upload-time = "2026-08-17T22:53:56.486Z" }, + { url = "https://files.pythonhosted.org/packages/6f/d6/6883f89ea4b044e6e8447ebfaf9bcecdf457b7d80a683635e130b25498e0/onnxruntime-1.29.0-cp312-cp312-win_arm64.whl", hash = "sha256:dc61a79cb39afd66ab3f01fd2c23591a7f01de89c1668e1fb6315067fc279164", size = 13746981, upload-time = "2026-08-17T22:53:58.977Z" }, + { url = "https://files.pythonhosted.org/packages/41/f8/d375facf60edaf41f5732f9f689c98a800fcc52df5cf6ddfb406703eb5a1/onnxruntime-1.29.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:be0f8ed688cfb1d4d5765a137193b7bfab0c8ea214eed99260b380bb525a3a7f", size = 21429708, upload-time = "2026-08-17T22:54:01.44Z" }, + { url = "https://files.pythonhosted.org/packages/c9/17/b9ad04051a8c4f504852ce0e8e10f9a6b2f1a331eedcdcc503df776dd0ea/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d67673c5367727860922c5262d724472f1b5539fb7ccf4c81a638f9b71719803", size = 20816263, upload-time = "2026-08-17T22:54:04.088Z" }, + { url = "https://files.pythonhosted.org/packages/83/2c/d8eb945d2a372149df9705a8d5c8d7c6c46c987c5446dbcea9e1ea7f6556/onnxruntime-1.29.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:e2128f31f449e922c62dbe5d8b6b7b079f0bcaf2d56a102fa203cb6e5bb5ab19", size = 23136817, upload-time = "2026-08-17T22:54:06.714Z" }, + { url = "https://files.pythonhosted.org/packages/e1/3b/66b424c63fa92dfaa48d1719efaae66fc8c256b9426a832eda51d8dfe1e9/onnxruntime-1.29.0-cp313-cp313-win_amd64.whl", hash = "sha256:2945e1f82f81f27e88decea88c7861f45baea23818950d467bf3909aa303119e", size = 14001310, upload-time = "2026-08-17T22:54:09.13Z" }, + { url = "https://files.pythonhosted.org/packages/83/22/d6a700e3a6322fa3d56fbe7cee9ffc53f35e77ffcd6b7e97f4b7722a27ab/onnxruntime-1.29.0-cp313-cp313-win_arm64.whl", hash = "sha256:4b940b0d777590c7e20bf298f5c16af1ea6ad1b400a1c822a6be192f64f4d954", size = 13747112, upload-time = "2026-08-17T22:54:11.608Z" }, + { url = "https://files.pythonhosted.org/packages/4a/89/c4af146de3d60a32c89fea48d5d34bfd044faaf8957270043a03bd1b462b/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:533f8370ce124304e5cb08ab961836cf755631e3dd77adc5f3bbdab70c2b7d99", size = 20826136, upload-time = "2026-08-17T22:54:14.315Z" }, + { url = "https://files.pythonhosted.org/packages/9d/f2/e6bbacd11dfe8d070613261a758795ea128b9fc9bea391a2a7da2e4c7a08/onnxruntime-1.29.0-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:c1ad3f437153fe77f9d01a08fbaac0beb030e09b8a80ace1603bcf69b6c95481", size = 23138951, upload-time = "2026-08-17T22:54:17.154Z" }, + { url = "https://files.pythonhosted.org/packages/ff/a3/718e1b83096a1bc7b0fc8014c23d4cf795559fe666961cfac4fc038a4871/onnxruntime-1.29.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e74b278af1d949876f5d91d1268fd6c680e79f2bac194967394eaba9fdf69e7e", size = 21431104, upload-time = "2026-08-17T22:54:20.118Z" }, + { url = "https://files.pythonhosted.org/packages/4e/17/c75e78ddc1fe69b6ebaef7fe88ac83f29bfe10955e3a0d2436d93473c91c/onnxruntime-1.29.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:939e5d65f332e6d399774b2bd0d3559fd8fa629c1e77833db29d968d2384f23d", size = 20818488, upload-time = "2026-08-17T22:54:23.147Z" }, + { url = "https://files.pythonhosted.org/packages/65/54/9f197c578d3d3d7bea16971e233e5483981228eec73748585cf7b5933403/onnxruntime-1.29.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:6c0c37b92f67ed68dd36221ce0403e1d9bd4f7efce724439978a2597848530e5", size = 23136994, upload-time = "2026-08-17T22:54:26.321Z" }, + { url = "https://files.pythonhosted.org/packages/24/53/4616a55d2495679cfd0195f968feb3d74fe30e26467d168ee243ac97c089/onnxruntime-1.29.0-cp314-cp314-win_amd64.whl", hash = "sha256:4a3129ae56e70d2618ff773920166916310370a7e3cacb60b9e0e8910092725f", size = 14350643, upload-time = "2026-08-17T22:54:28.794Z" }, + { url = "https://files.pythonhosted.org/packages/0a/0f/c338cb5500a522c7e671a3bb1276f4562404fbecce8a0e274565aa968484/onnxruntime-1.29.0-cp314-cp314-win_arm64.whl", hash = "sha256:e417ef8628dcce310d2d53023e750ea298ec14d4341ae6dc3a572bfd9bc7fa97", size = 14124294, upload-time = "2026-08-17T22:54:31.015Z" }, + { url = "https://files.pythonhosted.org/packages/b8/e7/61064289a9a1301b25c1f0f574fe98aba31c2d388db3c1dbec664f78621f/onnxruntime-1.29.0-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:11264bb58f7b7cf6af835ab10d36838d73680580820fd6f51d90124a1ca8f449", size = 20826174, upload-time = "2026-08-17T22:54:34.283Z" }, + { url = "https://files.pythonhosted.org/packages/60/21/d0c04b561b46e9bff89b5f500fb7415b8ca0669f7902204f76ab06bb0c7e/onnxruntime-1.29.0-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:1ea91cef3b971506e51ae9c37c16d027774ec64994a524ec1bdfb027d68a9832", size = 23138547, upload-time = "2026-08-17T22:54:37.491Z" }, ] [[package]] @@ -3314,7 +3311,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.31.1" +version = "0.34.1" source = { editable = "." } dependencies = [ { name = "c2pa-python" }, @@ -3539,27 +3536,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.16.3" +version = "0.16.4" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/61/b3/3213589383f8f1b3938781bd1278713f6d18621a14992b3e81fefb8a5ef9/ruff-0.16.3.tar.gz", hash = "sha256:e76d33a347661a84b5be6d043d0347fdc745dfdcf825a8f4fed64b5e26eebdf2", size = 4891904, upload-time = "2026-08-13T15:17:13.381Z" } +sdist = { url = "https://files.pythonhosted.org/packages/00/8f/d8074b1f25e003164087a8bfe79a0f1a3945135764dbb6aaab04103dcaf9/ruff-0.16.4.tar.gz", hash = "sha256:13171aa9d9af2240ee3504e639de73122c67e74036de5ba2e1d01422cd17e3dc", size = 4899731, upload-time = "2026-08-20T17:43:59.196Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/bf/96/493770daebd68c0a67f1549fdf519f53be51fc435186c0585bcc272fd76c/ruff-0.16.3-py3-none-linux_armv6l.whl", hash = "sha256:0c5710e247a58a4521e66e124ba9a74655b414f61ba3a2e9e3811e11098f48f7", size = 10902799, upload-time = "2026-08-13T15:16:27.382Z" }, - { url = "https://files.pythonhosted.org/packages/5e/e6/2becf3942fddc29a29b8df47691d456fb1085391a694f74d84513251418c/ruff-0.16.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:fe155130631a2471fd2e14a7a664a4dfbd7194b8229c3d7b2a40b21178639081", size = 11135539, upload-time = "2026-08-13T15:16:30.87Z" }, - { url = "https://files.pythonhosted.org/packages/3e/1e/4b8b72f0d006dbf19326aa99f9ca0ee2ff374187c4d301cf529a51aa06fe/ruff-0.16.3-py3-none-macosx_11_0_arm64.whl", hash = "sha256:e2ed719e14aa64d895c2ee922594a90a43c861a93f0575a95ff8c47cdbd13eb9", size = 10475095, upload-time = "2026-08-13T15:16:33.259Z" }, - { url = "https://files.pythonhosted.org/packages/92/32/2201fa49ba1f6c101ee321e83f051ac7a4b8d07b0ef6b4d3f2772b302275/ruff-0.16.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:9e0b1da805eb043654645d74d5de1e5ce2edc686e40790d2b86f56d71cc06a84", size = 10668771, upload-time = "2026-08-13T15:16:35.65Z" }, - { url = "https://files.pythonhosted.org/packages/c3/66/4afc5c8363bd04d45effce1b7c8713ca037d7a6740b7451a2403a6e3a972/ruff-0.16.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a37bdea0bbe21780f590bf437d6412c8c4e1b6cd010f91a65c2c40c5e5f5f870", size = 10699568, upload-time = "2026-08-13T15:16:38.195Z" }, - { url = "https://files.pythonhosted.org/packages/53/fd/c67d246bf36bf1698551c56de39e95cd07f70e64433e0098e6267d77061b/ruff-0.16.3-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:09571e6d1288ed9be475207a3ac04ada404f1cd898104be0f6ab8d7df438575b", size = 11499365, upload-time = "2026-08-13T15:16:40.623Z" }, - { url = "https://files.pythonhosted.org/packages/67/0b/00ecbceb99a263af7b12f6f05ac3c92bc47b905e91adc3f207a836e3bc01/ruff-0.16.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:2c18c5a101eb540010638cc1ff3c84944d3adb3df62b8d98ca8f22ba484d3413", size = 12311728, upload-time = "2026-08-13T15:16:43.564Z" }, - { url = "https://files.pythonhosted.org/packages/54/b2/b7b3bb54f4d3f7db504e476ad4ab8de530dceebe2c061384b2757ee419e8/ruff-0.16.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8457c44f15033c85ddbb77b15d451df9e24e4bd03b628396dd3610cedc3b8f82", size = 11699896, upload-time = "2026-08-13T15:16:46.209Z" }, - { url = "https://files.pythonhosted.org/packages/c7/30/4c468429ac195addc5ee1b717b6ab1b66632786737ca3b2ed3443fb0c26a/ruff-0.16.3-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:294b95c4ae0cda9388525c2047778aa758d6b8d4bb876fd4e9eaa3ebc92343eb", size = 11058736, upload-time = "2026-08-13T15:16:48.823Z" }, - { url = "https://files.pythonhosted.org/packages/43/67/7a113cdaddf24b64d7f75b1242a99d04c82fcef4f6921fdbb832beaffb5f/ruff-0.16.3-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:3d0c7c40c87c2a820509c31ba007968da6e1306468c067b2d82fbfdbcd0e8474", size = 11586911, upload-time = "2026-08-13T15:16:51.913Z" }, - { url = "https://files.pythonhosted.org/packages/f1/c1/2e66f24c0f3ead25a5e660111778685e505e5da353c82802bf49f0cbe7b9/ruff-0.16.3-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:9f738c0fdfa8eed0b2ce7fb27ee7258208a92a68d7949e62aa15164bc7b389da", size = 10954265, upload-time = "2026-08-13T15:16:54.763Z" }, - { url = "https://files.pythonhosted.org/packages/c2/ba/4cee23bf52cba9a058d3726de623624daf50ef9638868edd86f4126157f6/ruff-0.16.3-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:fb785f0be25abe69d320415cd4f833b59e17ba7613d9ba6a958023b6bceb0a50", size = 10709886, upload-time = "2026-08-13T15:16:57.339Z" }, - { url = "https://files.pythonhosted.org/packages/82/df/7da7194fa5d9dc0a285f7e6fa5a4722e7c63faac0b45b614ded9314363a1/ruff-0.16.3-py3-none-musllinux_1_2_i686.whl", hash = "sha256:c5536e3acfbf9563085aa2be7b13c629c3077e902afc5b941ac44024dbb9f506", size = 11210392, upload-time = "2026-08-13T15:17:00.171Z" }, - { url = "https://files.pythonhosted.org/packages/35/85/7795f6e817af050e7517bf3e7aa9b061cce70ef33d280aad902c956c1ecf/ruff-0.16.3-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:a2d85c02f9b8e165d85e6779184d38c4132de12603dab59c51c28e22584f9e4d", size = 11626910, upload-time = "2026-08-13T15:17:03.299Z" }, - { url = "https://files.pythonhosted.org/packages/78/9b/475b927cf27a5cbbda3c7bafb69ed6ff77e1d7923d5d85f17c2749d7ae32/ruff-0.16.3-py3-none-win32.whl", hash = "sha256:388cdf2166642bd9b13d52b5932d3170f34f8abed7e8d9a855f1d84b83645a0a", size = 10931415, upload-time = "2026-08-13T15:17:05.726Z" }, - { url = "https://files.pythonhosted.org/packages/b2/99/e2a2bfc4fbf0a1e8a916bc9ebe6fe6c58cc34c28e0ffc6ce281d572d1c2e/ruff-0.16.3-py3-none-win_amd64.whl", hash = "sha256:e80a7d69ca2a6d1c4d352ec91458cdca6e56c83cdbcabd93e4abe1e53591d948", size = 11445993, upload-time = "2026-08-13T15:17:08.353Z" }, - { url = "https://files.pythonhosted.org/packages/69/3e/4132e539aed78c148854d4997a2685b0ed4dc4e87110b59ce528564e184e/ruff-0.16.3-py3-none-win_arm64.whl", hash = "sha256:b8ca152da82c1acc1fa8d5874b15951935f0eef46f10e6954c83859011b6178a", size = 11399302, upload-time = "2026-08-13T15:17:10.908Z" }, + { url = "https://files.pythonhosted.org/packages/ff/80/779895ef584e089d22f2c6df0d0e99a65ec2df0805f1fffd439415b8c1f0/ruff-0.16.4-py3-none-linux_armv6l.whl", hash = "sha256:df4075f71ddac40b9934af60c3ec8a53047dd5a5fdc43224e6e4e8e9a27cb6f7", size = 10006909, upload-time = "2026-08-20T17:43:16.888Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e6/f553199b5e8927a05cb5c422d921fd0656b29ab976e91c44802107c6b0da/ruff-0.16.4-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:0c95538517af68004306b0fb3214ff2f2af67a65092aee77cd9eb86db6656604", size = 10240201, upload-time = "2026-08-20T17:43:19.337Z" }, + { url = "https://files.pythonhosted.org/packages/1c/70/4a6dc4bb34da4dee35e30f09bbd1bfbdd26f33b62fb9b8df31f08a199cd2/ruff-0.16.4-py3-none-macosx_11_0_arm64.whl", hash = "sha256:963f83df8e69e575b64d67dd447ebbc917db41a14bf38d4593a4183e7aaa8255", size = 9835122, upload-time = "2026-08-20T17:43:21.708Z" }, + { url = "https://files.pythonhosted.org/packages/24/12/c6e22d686372c15bcb7af99831f1a1be96df696491babf4f24e4f942c527/ruff-0.16.4-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:32a5057c7ff3f6e6480a48fccfb3a412a690f48a3d03ac5cf08177d6c2da3ade", size = 9977162, upload-time = "2026-08-20T17:43:24.236Z" }, + { url = "https://files.pythonhosted.org/packages/46/49/72b10ec912f5ab5854992eaf7aa7cd36729b6937d9dc4e0fb41b3bf428ec/ruff-0.16.4-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:b3dce8d9b0c57c265b91885a66a567d8ea1372e8eb4e250fa8e5e3f579e99cff", size = 9829789, upload-time = "2026-08-20T17:43:26.966Z" }, + { url = "https://files.pythonhosted.org/packages/fa/80/0f30e32e7f6ee26edc39075502db9d368d788a44a79b55f763eb4ab03796/ruff-0.16.4-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:7dc651db49283c69f8e72c834eec4fe5573e4c646856aebece0ce385dceb2a80", size = 10527949, upload-time = "2026-08-20T17:43:29.384Z" }, + { url = "https://files.pythonhosted.org/packages/52/3d/86e8ad3542169e56cac3859a343afdb9df2ad54d35a59ce1e67baee83421/ruff-0.16.4-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:3817b87dbcabc92f13b05019257c5b89b5b4d51b5fb20f56fb5235ceb723cd07", size = 11333695, upload-time = "2026-08-20T17:43:31.872Z" }, + { url = "https://files.pythonhosted.org/packages/d0/16/481c29b380c20a0054a8261066665e1b3488e23636c49d0a43e75975b9bb/ruff-0.16.4-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:e9fce1499134b2c8c68e5166f95705a5812062bb93aacc5f9873bb1a27084bc7", size = 10727741, upload-time = "2026-08-20T17:43:34.596Z" }, + { url = "https://files.pythonhosted.org/packages/5e/b6/56bc0b8cf45b54b28b3a5e6381c8945d51b5b18adf659454c32295209a31/ruff-0.16.4-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f2d812e482f5a7e02eee26cd73d2a37ebbdf47d795ea63ba1b89110ae93e9fb3", size = 10286522, upload-time = "2026-08-20T17:43:37.288Z" }, + { url = "https://files.pythonhosted.org/packages/e8/8b/b345b4fb110f2fbe2bd31eabd271e5e8b3b7e4ee6c0e02f2dc6be78db000/ruff-0.16.4-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:6baaf984aa7976edf93d3b627fe2d1d22ee94bbca05fa6f90fc76d73924e3454", size = 10584182, upload-time = "2026-08-20T17:43:39.984Z" }, + { url = "https://files.pythonhosted.org/packages/29/e5/827b34041c35f58774a9681a4213994c164fc987800f4dddabcf451da0bf/ruff-0.16.4-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:bdfcf0b28662eb890372d50f92c283bb94e67e7635ed93c7fd533970acff7b2b", size = 10134195, upload-time = "2026-08-20T17:43:42.351Z" }, + { url = "https://files.pythonhosted.org/packages/0f/10/d0bffcdd6729b87afc82ba0ef377173356a7dc8e972f5179968cf2fdf98c/ruff-0.16.4-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:b66b02cb9b04f537643cadf5768e5f98dc461890d530cb67113d71c8c76e605d", size = 9825821, upload-time = "2026-08-20T17:43:44.532Z" }, + { url = "https://files.pythonhosted.org/packages/f5/32/0db2a863b796ca62d83e92a07a3ccf00921b14db02059347576a2fda3d4b/ruff-0.16.4-py3-none-musllinux_1_2_i686.whl", hash = "sha256:8528bf9a4b291a60bf02ea453511e8ce6215bd2b982ee80405b66b008b6c30a0", size = 10267658, upload-time = "2026-08-20T17:43:46.989Z" }, + { url = "https://files.pythonhosted.org/packages/b2/a0/fbdeb59e48c6261f523e56c8f12e9c08fbe693786595cc7e3959207a9232/ruff-0.16.4-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:fbd85d2875fdd67e833213a651f613bbf25303abf6aa822a5121f4531195678d", size = 10697071, upload-time = "2026-08-20T17:43:49.891Z" }, + { url = "https://files.pythonhosted.org/packages/aa/28/0c6dd865859c6d17bc8ccc34cb72b0e02d6c7eb25e8a1e22b5bea681e2c0/ruff-0.16.4-py3-none-win32.whl", hash = "sha256:312769988007aaeb8e189b443ccdd03c0e6374489e053467be6d96518ebff76e", size = 10021687, upload-time = "2026-08-20T17:43:52.281Z" }, + { url = "https://files.pythonhosted.org/packages/a3/03/e724450f621698117f9aa6dd241c94d0274ae96781378dc86745ae29f0e7/ruff-0.16.4-py3-none-win_amd64.whl", hash = "sha256:05d9d27a18c4bcbefada602480ec9e01e0bc949d432e0ced5df77edac195919c", size = 10567657, upload-time = "2026-08-20T17:43:54.78Z" }, + { url = "https://files.pythonhosted.org/packages/0e/fe/da8b9e1347696bb22120b77280ec5ce25d500ca5cb39d5ad6e5c18de19c1/ruff-0.16.4-py3-none-win_arm64.whl", hash = "sha256:a3a61621c9b6f6a89573e938a080e648f1695baa3f58570a3a707bc51ff65a21", size = 10451579, upload-time = "2026-08-20T17:43:57.135Z" }, ] [[package]] @@ -3938,7 +3935,7 @@ wheels = [ [[package]] name = "transformers" -version = "5.15.0" +version = "5.15.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "huggingface-hub" }, @@ -3952,9 +3949,9 @@ dependencies = [ { name = "tqdm" }, { name = "typer" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/a6/3f/d89353267d511e18f137dfd7769d07837350c11b88408ce1dfe2e93e56c7/transformers-5.15.0.tar.gz", hash = "sha256:bbf98f57b2ddd7c4ecbccfa2c0069017aa6fd01cc204bd50cbc0eeadcf2a13b8", size = 9377983, upload-time = "2026-08-10T10:27:23.261Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2a/92/c50c61da7046bbb59a4d011291aeadcfb4d7980ab36fdb31e93823a3fb93/transformers-5.15.1.tar.gz", hash = "sha256:27c996bd9075ddc82d40f8590dfdc81ea45f611bfca477e0db5d7fd257a482f7", size = 9378434, upload-time = "2026-08-19T11:28:20.33Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/43/81355710a4c84e9420e11a86d41a5364deb561f2ef36dfdf254a07371bbb/transformers-5.15.0-py3-none-any.whl", hash = "sha256:d7f007736f67749ae9490c4f8cb5d30b452ae2d68c8675e50ba8d63ea7feb107", size = 11749280, upload-time = "2026-08-10T10:27:20.416Z" }, + { url = "https://files.pythonhosted.org/packages/41/c4/a12e1d9b387fb0c40a57116db82b457e8c771cb419163cda29204d74a595/transformers-5.15.1-py3-none-any.whl", hash = "sha256:b7cdf238ff583e3a58dbc7fa34da1aaf091ce063141f65a30538160bd5afe93f", size = 11749582, upload-time = "2026-08-19T11:28:16.726Z" }, ] [[package]]