Compare commits
No files matched your search
@@ -0,0 +1,2 @@
|
||||
# Enables the "Sponsor" button on the repository page.
|
||||
github: wiltodelta
|
||||
@@ -13,9 +13,9 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
environment: pypi
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/checkout@v6
|
||||
|
||||
- uses: astral-sh/setup-uv@v6
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
@@ -21,6 +21,10 @@ Thumbs.db
|
||||
# Test results
|
||||
data/results/
|
||||
|
||||
# SynthID corpus reference fills (synthetic black/white calibration tiles,
|
||||
# regenerable; the labeled pos/neg/cleaned images ARE tracked, see README)
|
||||
data/synthid_corpus/refs/
|
||||
|
||||
# Reference materials
|
||||
_refs/
|
||||
|
||||
@@ -30,3 +34,10 @@ yolov8n.pt
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
|
||||
# Doubao watermark calibration (local only; ship only the derived alpha-map asset).
|
||||
# Synthetic seeds + raw Doubao captures are regenerable and not committed.
|
||||
# Non-ours reference artifacts go in any _refs/ dir (already ignored above): usable
|
||||
# locally for bootstrap/validation, never redistributed in the repo.
|
||||
data/doubao_capture/seeds/
|
||||
data/doubao_capture/captures/
|
||||
@@ -5,21 +5,63 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
|
||||
## How to run
|
||||
|
||||
- `uv run remove-ai-watermarks all <image.png> -o <output.png>`
|
||||
- `uv run remove-ai-watermarks visible <image.png> -o <out.png>` — visible-mark removal, CPU, no GPU. `--mark auto` (default) routes between the Gemini sparkle and the Doubao "豆包AI生成" text strip by detector confidence; `--mark gemini` / `--mark doubao` force one.
|
||||
- `uv run remove-ai-watermarks erase <image.png> --region x,y,w,h -o <out.png>` — universal region eraser (any logo/object, any position). `--backend cv2` (default, no deps) or `--backend lama` (big-LaMa via onnxruntime, extra `lama`); `--region` is repeatable.
|
||||
- `uv run remove-ai-watermarks identify <image>` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip the cv2 sparkle detector
|
||||
- `uv run remove-ai-watermarks metadata <image.png> --check` — inspect AI metadata (C2PA, EXIF, PNG chunks)
|
||||
- `uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>` — strip all AI metadata
|
||||
|
||||
## Test and lint
|
||||
|
||||
- `bash maintain.sh` — uv-outdated, uv-secure, ruff check/fix, ruff format, pyright, pytest -n auto
|
||||
- `maintain.sh` may not finish fully green (pre-existing, not per-change): strict pyright carries debt in `remove_ai_metadata` / `cli.py` (untyped piexif/PIL/click/rich). (`uv-secure` is clean since idna was bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx.) To gate a change, run `uv run ruff check`, `uv run pyright <changed files>`, `uv run pytest` directly.
|
||||
- Run `uv run` from the repo root — from another cwd it falls back to a bare env without numpy/cv2/torch.
|
||||
- Metadata/C2PA tests assert against real committed fixtures in `data/samples/` (`chatgpt-*.png` = OpenAI C2PA, `firefly-1.png` = Adobe, `mj-*` = Midjourney IPTC, `doubao-1.png` = ByteDance Doubao with the China TC260 `<TC260:AIGC>` XMP label **and** a visible "豆包AI生成" text mark bottom-right; `grok-1.jpg` = xAI Grok with its EXIF-only `Signature:` blob + UUID `Artist` and no C2PA/SynthID/IPTC); synthetic byte blobs cover the JPEG/ISOBMFF format paths. The "non-AI / clean photo" control is no longer in `data/samples/` -- the `clean_photo` conftest fixture serves a verified-negative image from the corpus `neg/` set (skips if the corpus is absent).
|
||||
- SynthID reference corpus: `scripts/synthid_corpus.py` ingests labeled images into `data/synthid_corpus/`. The labeled `images/` (`pos/` `neg/` `cleaned/`) are **committed** (public repo -- review every image for private content before adding; `manifest.csv` is kept in sync with the files on disk, one row per tracked image); only the synthetic `refs/` calibration fills are gitignored. See its README for the collection protocol and verification oracles.
|
||||
|
||||
## Configuration
|
||||
|
||||
- GPU/ML modules (invisible_engine, ctrlregen, watermark_remover) are optional — guard imports with `is_available()` checks
|
||||
- Tests for ML modules are limited to availability checks (require multi-GB downloads)
|
||||
- Optional detection extras: `detect` (imwatermark — open SD/SDXL/FLUX watermark) and `trustmark` (Adobe TrustMark decoder; pulls torch + downloads weights). Both are guarded by `is_available()` and skipped by `identify` when absent.
|
||||
- Tests for the *model-running* paths are limited to availability checks (multi-GB downloads). But the **pure helpers inside ML-adjacent modules are unit-tested without any download** and must stay that way: `_target_size` (native-vs-downscale, `test_invisible_engine.py`), the MPS->CPU fallback control flow via mocked pipelines (`test_img2img_runner.py`, 100% cover), and the tiling math `tile_positions`/`make_blend_weight`/`resize_center_crop` (`test_tiling.py`; `pytest.importorskip("torch")` since `tiling.py` imports torch at module top). Don't skip these as "ML, needs a model" — only `run_tiled`/`remove_watermark`/the diffusion bodies do.
|
||||
|
||||
## Key modules
|
||||
|
||||
- `noai/c2pa.py` — PNG chunk parser; use `extract_c2pa_chunk(path)` to get raw caBX payload, `has_c2pa_metadata(path)` to detect. Do not reimplement chunk parsing.
|
||||
- `noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES constants
|
||||
- `noai/c2pa.py` — PNG chunk parser; use `extract_c2pa_chunk(path)` to get raw caBX payload, `has_c2pa_metadata(path)` to detect. Do not reimplement chunk parsing. `extract_c2pa_info(path)` sets `synthid_watermark`/`synthid_vendors` when the manifest is signed by a SynthID-using vendor, and `soft_binding`/`soft_binding_vendors` when a `c2pa.soft-binding` `alg` names a forensic-watermark vendor (`soft_binding_vendors_in(buffer)` is the shared byte-scan, used by both the PNG parser and the non-PNG binary path).
|
||||
- `noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES, C2PA_ISSUERS, `SYNTHID_C2PA_ISSUERS` (issuers that pair SynthID with C2PA: Google, OpenAI), and `C2PA_SOFT_BINDINGS` (soft-binding `alg` prefix → forensic-watermark vendor: Adobe TrustMark, Digimarc, Imatag, Steg.AI, Microsoft, ...). Add a new issuer/binding here, not inline.
|
||||
- `metadata.py` — `synthid_source(path)` returns the vendor name(s) if the C2PA manifest implies a SynthID pixel watermark, else None. Format-agnostic: PNG via the caBX parser, JPEG/WebP/AVIF/HEIF/JXL via a binary scan (C2PA marker + SynthID issuer + AI-source marker). `get_ai_metadata` surfaces the verdict, and `metadata --check` prints it as a callout. Both `get_ai_metadata` and `has_ai_metadata` guard the PIL open with `except Exception` (HEIC/unknown formats raise non-OSError) and fall through to the binary scan. `xai_signature(path)` detects xAI/Grok's EXIF-only scheme (`ImageDescription` = `Signature: <base64>` + UUID `Artist`); it feeds `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify`. `iptc_ai_system(path)` detects the IPTC Photo Metadata 2025.1 AI-disclosure XMP properties (`IPTC_AI_FIELD_MARKERS` = `AISystemUsed`/`AISystemVersionUsed`/`AIPromptInformation`/`AIPromptWriterName`) and returns the `AISystemUsed` generator name (or `"fields present"`). `remove_ai_metadata` routes **ISOBMFF video** (`.mp4`/`.mov`/`.m4v`) through the same `isobmff.strip_c2pa_boxes` as AVIF/HEIF (MP4 is ISOBMFF), and `_scrub_ai_exif` removes the xAI signature + AI-generator EXIF tags on JPEG output.
|
||||
- `identify.py` — `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, IPTC "Made with AI" + IPTC 2025.1 `AISystemUsed`, embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, visible Gemini sparkle, open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). Visible-sparkle is promoted only at confidence ≥ `_SPARKLE_THRESHOLD` (0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49). The cv2 dependency lives in `gemini_engine.detect_sparkle_confidence`, not here. **C2PA platform attribution is device-token-first, issuer-scan fallback** (`_device_platform` scans manifest bytes for `_DEVICE_C2PA_PLATFORM` tokens, then `_attribute_platform`/`_ISSUER_PLATFORM`). **Why, verified on real signed files 2026-05-26:** the old issuer-only byte-scan matched ANY issuer substring anywhere, so multi-entity manifests mis-attributed -- Leica→"Truepic" (a signing authority in the trust chain), Nikon→"Adobe Firefly" (XMP-toolkit "Adobe" + the sample's "Adobe_MAX" name), Pixel→"Google (Gemini)" ("Google LLC" cert org), Truepic→"Google". A distinctive device token wins instead. **Token distinctiveness is load-bearing:** bare `b"Truepic"` mis-fires (it appears in unrelated trust chains -- it mis-attributed the OpenAI `chatgpt-1.png` fixture), so the token is the specific `b"Truepic_Lens"` from the Lens SDK claim generator; likewise `b"Pixel Camera"` (cert CN) not bare `b"Pixel"`. `_DEVICE_C2PA_PLATFORM` lists ONLY tokens **verified against a real C2PA file**: Leica (`lc_c2pa`/`Leica Camera`), Nikon (`NIKON`), Pixel (`Pixel Camera` -- from a real Pixel 10 Pro file attached to c2pa-rs issue #1609/#1554), Sony (`sony.sig`/`sony.cert` -- Sony's own C2PA assertion namespace, verified on a real Sony PXW-Z300 file; NOT bare "Sony" which is a common EXIF Make), Truepic (`Truepic_Lens`). Canon/Samsung/Bria have **no public direct-download C2PA sample** (checked exhaustively: GitHub issue/PR attachments, contentcredentials gallery, HF datasets -- all upload-to-verify or token-gated; Canon's only public file was a self-signed hobbyist CR3, not factory), so they stay unmapped until a real file is captured (same fixture discipline as Grok/Doubao). The Sony sample is video (MP4) -- our ISOBMFF C2PA path detects it; Sony Alpha stills likely share the `sony.*` namespace but are not separately verified. Camera C2PA marks capture authenticity, not AI (Pixel carries `computationalCapture`, not `trainedAlgorithmicMedia`), so these never set `is_ai` -- that stays driven by digital-source-type. `c2pa.cbor_text_after` (now public) is best-effort for the `generator` detail string only and can be None when the manifest keys it `claim_generator_info` (Pixel). **Issuer→generator mapping is `is_ai`-gated** (`_attribute_platform(issuers, is_ai=c2pa_is_ai)`): a specific AI-generator platform is named only when the digital-source-type is `trainedAlgorithmicMedia`; on a non-AI source an issuer substring is treated as incidental (an "Adobe XMP" toolkit string in an *unmapped* Canon/Sony capture would otherwise mislabel it "Adobe Firefly"), so it degrades to the neutral "C2PA signer: X" label. Real Firefly/OpenAI/Google output carries the AI source-type, so it is unaffected (verified: chatgpt-1.png→OpenAI, firefly-1.png→Adobe Firefly still attribute). `_attribute_platform` defaults `is_ai=True` so the mapping stays unit-testable in isolation. Add device tokens to `_DEVICE_C2PA_PLATFORM`, generator/issuer platforms to `_ISSUER_PLATFORM`, not inline. For non-PNG containers (JPEG/WebP/AVIF/HEIF/JXL) the caBX parser returns nothing, so issuer (`_issuers_in`) and generator (`_ai_tools_in`, reusing `C2PA_AI_TOOLS`) are recovered by binary-scanning the first MB. EXIF `Software` / `Make` / `Artist` / `ImageDescription` and XMP `CreatorTool` generator tags are read by `metadata.exif_generator` (PIL+piexif for any format PIL opens incl. AVIF, plus a container-agnostic XMP raw-byte scan that also covers HEIF/JXL), matched against `AI_GENERATOR_TOKENS` so ordinary editors (plain "Adobe Photoshop") and real-camera `Make` ("Apple"/"Canon") are not flagged. **Ideogram tags its output with EXIF `Make="Ideogram AI"`** (verified on a real download 2026-05-24) — that's why `Make` is read.
|
||||
- `gemini_engine.py` — visible Gemini-sparkle remover/detector (cv2/numpy, no GPU). `detect_sparkle_confidence(path)` is the file-level entry point used by `identify.py`.
|
||||
- `doubao_engine.py` — visible Doubao "豆包AI生成" remover/detector (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH, fractions in module constants; no bundled template), `extract_mask` pulls the light low-saturation glyphs with a **polarity-aware white top-hat** (brighter-than-blurred-local-bg, so white-paper documents are left untouched instead of smeared), `detect` thresholds glyph coverage (`DETECT_MIN_COVERAGE` 0.16 separates real marks ≥0.20 from corner noise, which stays ≤0.06 on large images but can spike to ~0.15 on tiny ones), `remove_watermark` inpaints (cv2 Telea/NS) and **bails when coverage > `MAX_INPAINT_COVERAGE` 0.50** (dense-text background → would smear). Wired into `visible --mark` via `cli._run_doubao_if_selected`. **Logo is near-white (~253), not the gray some third-party tools assume.** Best on photo/illustration backgrounds; high-contrast edges leave faint residue (cv2-inpaint limit). Clean per-pixel reverse-alpha (Gemini-style) is the future upgrade but needs a captured/distilled alpha map — see below.
|
||||
- `region_eraser.py` — universal region eraser (`erase` CLI). `erase(image, boxes=|mask=, backend=)`: `boxes_to_mask` → `cv2.inpaint` (`cv2` backend, default, no deps) or big-LaMa via onnxruntime (`lama` backend, extra `lama`, `Carve/LaMa-ONNX` Apache-2.0 model downloaded on first use, never bundled). `erase_lama` crops a padded region around the mask, runs LaMa at its fixed 512² input, pastes only masked pixels back (untouched areas stay pixel-exact). Lazy `_get_lama_session` singleton; `lama_available()` guards the optional import. **LaMa-ONNX costs ~3.5-4 GB peak RAM and ~5-6 s/call on CPU** (FFC working set, not arena — `enable_cpu_mem_arena=False` does not help), so it does NOT fit a minimal droplet; the cv2 backend (tens of MB, ~30 ms) does. LaMa quality at low RAM = serverless/GPU, mirroring how raiw.cc offloads SDXL to fal.
|
||||
- `invisible_watermark.py` — `detect_invisible_watermark(path)` decodes the OPEN DWT-DCT watermarks (public decoder, no key) embedded by Stable Diffusion / SDXL / FLUX via the `imwatermark` library. Known fixed patterns (verified against upstream source) live in `_BITS_48` (SDXL 48-bit, FLUX.2 48-bit) and `_SD1_STRING` ("StableDiffusionV1", SD 1.x/2.x). Optional dep (extra `detect`); returns None when absent. The `detect` extra pulls **torch** transitively (invisible-watermark declares torch a hard dep, and `WatermarkDecoder` eagerly imports `rivaGan` -> `torch` at import time), so detection needs torch present even though dwtDct runs CPU-only on cv2/numpy/pywavelets — no GPU and no separate `gpu` extra required. **Unlike SynthID this is locally detectable**, but the watermark is fragile (does not survive JPEG re-encode/resize — verified gone after JPEG q90), so it confirms origin only on pristine files. Add new known patterns here. The file carries a top-of-module pyright pragma because imwatermark/cv2 ship no type stubs.
|
||||
- `trustmark_detector.py` — `detect_trustmark(path)` decodes the OPEN, keyless **Adobe TrustMark** watermark (the soft binding behind Adobe Durable Content Credentials, `alg` `com.adobe.trustmark.P`) via the optional `trustmark` package (extra `trustmark`; pulls torch, downloads model weights on first use). Mirrors `invisible_watermark.py` (lazy singleton, top-of-module pyright pragma, returns None when absent). It detects *provenance*, not AI origin as such (TrustMark also marks human-authored content), so `identify` lists it as a watermark without setting `is_ai_generated`. Other soft-binding vendors (Digimarc/Imatag/Steg.AI/...) have no public decoder — they are only *named* via the `C2PA_SOFT_BINDINGS` scan, not decoded.
|
||||
- `face_protector.py` — YOLO detect + soft-blend pattern; mirror this for any "protect region during diffusion" features
|
||||
|
||||
### Doubao clean-reverse-alpha distillation (researched 2026-05-26, NOT yet shipped)
|
||||
|
||||
Plan to get Gemini-style pixel-accurate reverse-alpha for Doubao at cv2 cost (so it runs on a minimal CPU droplet): use LaMa **offline** to reconstruct the clean background `O`, then per-pixel solve `I=(1-α)O+αL` for the alpha map + logo colour (this is Google's CVPR-2017 multi-image idea, with LaMa standing in for the multi-image optimisation). Validated in principle (glyph error drops as alignment improves) but **blocked on data**, confirmed empirically over 6 attempts: a clean canonical alpha map needs **several PRISTINE images at ONE resolution** with varied backgrounds. The findable public Doubao images (ayaya, Tools_CleanMark, ours) are all different resolutions AND lossy (jpg/webp), and Doubao's mark position drifts ~±50 px even at the same resolution, so cross-image registration (ECC / phase-correlation) fails (1/11 templates aligned) — error floor ~64/255 on glyph pixels, not shippable. To finish: ~5-8 pristine same-resolution Doubao originals → per-image α via LaMa → align the clean α templates → median → ship the tiny map + an NCC localiser (Doubao's mark drifts, so runtime needs the same locate-then-reverse-alpha the Gemini engine already does). Don't retry on lossy/mixed-resolution scraped images — proven insufficient.
|
||||
|
||||
## Watermarking landscape (research 2026-05-24)
|
||||
|
||||
Who embeds what, and whether it is locally detectable (so we know which gaps are fillable). See `identify.py` for what we read.
|
||||
- **Locally detectable (open decoder, no key/API):** Stable Diffusion / SDXL / FLUX via `imwatermark` DWT-DCT (now covered by `invisible_watermark.py`). FLUX uses the same library (`black-forest-labs/flux2` `src/flux2/watermark.py`, 48-bit `0b001010101111111010000111100111001111010100101110`); SDXL is the diffusers `WATERMARK_MESSAGE` (`0b101100111110110010010000011110111011000110011110`). Caveat: fragile to re-encoding.
|
||||
- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Designer + **Bing Image Creator** — collected 2026-05-24; Bing now runs Microsoft's own **MAI-Image** model, signs C2PA as "Microsoft", NOT OpenAI/DALL-E), and **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`). Still unsampled: Canva (its downloads are re-encoded design *exports* that strip C2PA, so a Canva "positive" is inconclusive — skipped), Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag).
|
||||
- **EXIF/XMP 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).
|
||||
- **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).** Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC** — only EXIF `Artist` = a UUID and EXIF `ImageDescription` = `Signature: <base64>` (a crypto signature, unverifiable locally without xAI's public key). This empirically kills the earlier unverified "xAI signs C2PA as xAI" lead — xAI is not even a C2PA member. `exif_generator` misses it (neither field holds an `AI_GENERATOR_TOKENS` token), so a dedicated detector `xai_signature(path)` matches the pair (`ImageDescription ~ ^Signature: [A-Za-z0-9+/=]{64,}` AND UUID `Artist`); wired into `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify` (signal `xai_signature`, platform "xAI (Grok / Aurora)"). **Format confirmed stable across n=3 genuine generations:** exactly three EXIF tags (`Artist`, `ExifOffset`, `ImageDescription`), `Signature:` prefix constant, base64 payload 300-1004 chars. Two capture facts: (a) the `Artist` UUID **equals the public image id** in the asset URL (`https://imagine-public.x.ai/imagine-public/images/<uuid>.jpg`), so it is NOT a private per-user secret — only the `Signature` blob is; (b) the Grok web-UI image is a re-encoded **WebP with no signature** — the EXIF survives only in the *original* JPEG (download button or that public tokenless URL), which is why screenshots / re-encodes are metadata-stripped. A real fixture `data/samples/grok-1.jpg` plus **synthetic** JPEG fixtures (fake UUID + fake `Signature:` blob) cover the detector; never add a real Grok image carrying private content (the repo is public). **Stripped on removal too:** `remove_ai_metadata` now calls `_scrub_ai_exif` on the JPEG EXIF, which deletes the xAI Signature+UUID-Artist pair **and** any `Software`/`Make`/`Artist`/`ImageDescription` tag holding an `AI_GENERATOR_TOKENS` token (so Ideogram's `Make="Ideogram AI"` is scrubbed too), while keeping genuine camera/editor EXIF. The shared `_is_xai_signature_pair` helper (module-level compiled regexes) is the single source of truth for the pattern, used by both `xai_signature` and `_scrub_ai_exif`. (AVIF/HEIF/JXL still strip only C2PA boxes via `isobmff`, not EXIF — unchanged.)
|
||||
- **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `<TC260:AIGC>{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). **Doubao** (ByteDance) uses it (verified on the real #13 sample 2026-05-25; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` json-decodes the (HTML-entity-encoded) block; container-agnostic raw-byte scan.
|
||||
- **No detectable signal on download (correctly reported `unknown`):** **Recraft** (PNG export is a re-encoded design export — strips everything), **Krea hosting FLUX 2** (no imwatermark despite FLUX — the host omits the encoder, same as Stability's hosted SDXL), and Midjourney (embeds nothing). Lesson: the imwatermark detector only fires on *pristine* output from a pipeline that runs the encoder (diffusers default, official BFL), not from re-hosts (Krea/Stability) or re-encoded exports (Recraft/Canva).
|
||||
- **Invisible but NOT locally detectable (proprietary, API/oracle only — same wall as SynthID):** Amazon Titan Image Generator + Nova Canvas (Bedrock `DetectGeneratedContent` API), Kakao (new SynthID image adopter, May 2026), NVIDIA Cosmos (SynthID video). No local detector possible; treat like SynthID.
|
||||
- **C2PA 2.4 "Durable Content Credentials" (April 2026; verified against the spec) raise the bar for metadata stripping.** 2.4 defines soft bindings (an invisible watermark or a content fingerprint) plus a server-side manifest repository and a new `c2pa.repository-receipt` assertion. Per the spec: "if a C2PA manifest is removed from an asset, but a copy of that manifest remains in a provenance store elsewhere, the manifest and asset may be matched using available soft bindings." So our local `metadata --remove` deletes the *embedded* manifest, but a fingerprint/watermark soft binding can still re-link the image to its manifest in a repository server-side. Stripping the file is becoming necessary-but-not-sufficient against durable provenance. (Our parsers target the stable embedded-manifest format documented in C2PA 2.1 §11; that format is unchanged in 2.4 -- the new pieces are repository/soft-binding infra, not the on-file box layout, so no parser change is implied.) Spec: https://spec.c2pa.org/specifications/specifications/2.4/specs/C2PA_Specification.html We now READ the soft-binding `alg` (`C2PA_SOFT_BINDINGS` / `soft_binding_vendors_in`) to name the forensic-watermark vendor, and locally DECODE the one open scheme, Adobe TrustMark (`trustmark_detector`); the rest (Digimarc/Imatag/Steg.AI/...) stay name-only (proprietary decoders).
|
||||
- **Built 2026-05-26 (this batch):** soft-binding `alg` vendor detection; IPTC Photo Metadata 2025.1 AI-disclosure fields (`AISystemUsed` etc.); **video C2PA metadata** detect + strip for MP4/MOV/M4V (free — `isobmff.py` is format-agnostic, MP4 is ISOBMFF); Adobe TrustMark open decoder. NOT done (out of cheap reach, per the feasibility review): visible video-logo removal (needs a video frame pipeline) and audio (SynthID/ElevenLabs/Resemble/Suno all oracle-only or unmarked). The soft-binding **box detection window**: non-PNG/video detection scans the first 1 MB, so a C2PA box placed after a large `mdat` in a streaming MP4 can be missed — front-placed manifests (the common case) are caught.
|
||||
- **Regulatory driver (context, not a code change):** AI-content labeling mandates are expanding, which pushes more generators toward exactly the C2PA + watermark signals we read. The full per-jurisdiction table lives in README "## Legal" -- keep it there, not duplicated here. Newly added + primary-source verified 2026-05-26: **EU AI Act Article 50** machine-readable marking applicable **2026-08-02** (verified against the article text); **South Korea AI Framework Act Art. 31(3)** in force since Jan 2026 (verified via Kim & Chang; Enforcement Decree accepts an invisible-watermark label); **California AB 853** (amends the CA AI Transparency Act) latent-disclosure duty operative **2026-08-02**, requiring a disclosure "permanent or extraordinarily difficult to remove" (verified against the leginfo bill text -- this is the exact disclosure our tool strips); **India IT Amendment Rules 2026** in force **2026-02-20** (verified via Chambers), which prominently-label + permanent-provenance-id all synthetic media AND **expressly prohibit removing/suppressing the label or metadata** -- the first major all-content removal ban outside China. **Removal liability (README "## Legal" disclaimer):** the tool is lawful general-purpose software; liability sits with the remover and is intent-gated -- downstream acts (fraud/deception/IP), plus US DMCA 17 USC 1202 (removing copyright-management info to conceal infringement), plus the removal-as-such bans in China + India. When extending the README table, verify each date/article against the statute/bill text before committing, not against search summaries.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- `invisible` pipeline downscales to 768 px before diffusion → degrades fine text in infographics. Tracked; fix is tile-based or skip-downscale approach.
|
||||
- Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs)
|
||||
- `invisible` pipeline processes at **native resolution by default** (`max_resolution=0`), matching the hosted raiw.cc backend (fal fast-sdxl, no pre-downscale). The old forced downscale-to-1024 -> upscale-back round-trip was the main quality loss (issue #10) and is gone; at strength ~0.05 SDXL img2img does not need the ~1024 downscale. `--max-resolution N` re-introduces an opt-in long-side cap purely to bound GPU/MPS memory on very large inputs (it reintroduces the lossy round-trip). For huge images that OOM at native, tile-based diffusion is still the proper long-term fix. **Concrete MPS data point (verified 2026-05-25 on a 1254x1254 gpt-image SDXL run, fp32, 20 GB MPS ceiling):** native res OOMs at the *UNet* step (peak ~17 GiB), not only the VAE decode, and the auto-fallback in `img2img_runner` reloads on CPU and finishes (slow, ~13 min) -- the output is still weight-identical and defeats SynthID, so "looks hung/crashed" on Mac is usually this CPU fallback, not a pipeline error. Adding `enable_vae_tiling()` alone does NOT prevent it (the peak is the UNet, not the VAE). The fast Mac workarounds are fp16 on MPS (roughly halves memory) or `--max-resolution` to cap the long side; neither is wired as the default. The native-vs-downscale decision lives in the pure helper `invisible_engine._target_size(w, h, max_resolution)` (returns `None` for native, a clamped target tuple otherwise) so it is unit-tested (`tests/test_invisible_engine.py::TestTargetSize`, the #10/#15 regression guard) without loading the model -- keep that logic in the helper, don't re-inline it.
|
||||
- Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs); full-project `uv run pyright` can stall for many minutes — scope it to changed files.
|
||||
- `ultralytics` monkey-patches `PIL.Image.open` and tries to autoload `pi_heif`. When `pi_heif` is missing, opening files raises `ModuleNotFoundError`, not `UnidentifiedImageError`. Code that opens user-supplied or unknown-format files should `except Exception`, not just `OSError`/`UnidentifiedImageError`.
|
||||
- Metadata detection for AVIF/HEIF/JPEG-XL relies on a binary scan for `C2PA_UUID` + `IPTC_AI_MARKERS`, plus EXIF `Software` / XMP `CreatorTool` generator tags via `metadata.exif_generator` (validated with synthesized AVIF/JPEG fixtures + an XMP raw-scan fixture). C2PA removal in those containers is implemented via `noai/isobmff.py` (top-level ``uuid`` / ``jumb`` box stripper, no re-encoding), which now also drops a top-level XMP ``uuid`` box that carries an AI label (matched by AI-marker content, not by the XMP UUID, so byte-order-robust) and covers MP4/MOV/M4V/M4A by content sniff. **Non-ISOBMFF audio/video removal is via ffmpeg** (`_FFMPEG_STRIP_EXTS` -> `_strip_with_ffmpeg`): WebM/Matroska (EBML), MP3 (ID3), WAV/FLAC/OGG (RIFF/Vorbis) are stripped losslessly with `ffmpeg -map_metadata -1 -map_chapters -1 -c copy` (codec data untouched). Requires ffmpeg on PATH; raises `RuntimeError` if absent or if ffmpeg can't parse the file. Verified end-to-end (a real ffmpeg-made WAV/MP3 with a `title=Suno AI` tag -> tag gone, audio bytes preserved). **Still NOT built (deliberate):** EXIF/XMP stored as *items inside the ``meta`` box* (typical for AVIF/HEIF images) needs meta-box surgery (iinf/iloc edit + mdat splice) with corruption risk -- exiftool would do it but is a non-installed binary dep, so it stays a documented gap. **Audio watermark DETECTION (Resemble PerTh) was evaluated and NOT built (2026-05-26):** `resemble-perth`'s `PerthImplicitWatermarker.get_watermark()` returns a raw bit-array with **no presence/confidence flag** (clean audio decodes to arbitrary bits too), so reliably distinguishing watermarked-from-clean needs either Resemble's fixed payload or a confidence API -- neither is public, and there's no real Resemble sample to calibrate against. Same wall-class as the SynthID pixel detector: the decode exists, reliable presence-detection does not. (perth's top-level `PerthImplicitWatermarker` is also gated to None unless `librosa` is importable.)
|
||||
- **SynthID detection is metadata-only.** There is no reliable *local* detector of the SynthID *pixel* watermark — Google's decoder is proprietary, no public spec or API (only a waitlisted portal). Authoritative confirmation: Google DeepMind's own paper "SynthID-Image: Image watermarking at internet scale" (Gowal et al., arXiv:2510.09263) states the verification service is restricted to "trusted testers" and does not release detector weights or a reproducible algorithm — so a local pixel detector is infeasible by design, not just unbuilt. https://arxiv.org/abs/2510.09263 We detect SynthID by its C2PA companion (`synthid_source` / `SYNTHID_C2PA_ISSUERS`), which is reliable while the manifest is intact but says nothing once C2PA is stripped. **Surface-dependent blind spot (verified 2026-05-24):** the same Google model emits different metadata per surface -- the Gemini *app* wraps outputs in Google C2PA, but the *API/playground* (AI Studio, Nano Banana / gemini-2.5-flash-image) emits the SynthID *pixel* watermark (confirmed via the Gemini-app oracle) + the visible sparkle but **no C2PA/IPTC at all**, so `synthid_source` returns None despite SynthID being present. Only the pixel oracle or the visible-sparkle detector catches those. (Meta AI is another surface mismatch: it writes the IPTC `digitalSourceType=trainedAlgorithmicMedia` marker, not C2PA and not SynthID.) Google→SynthID is long-standing; OpenAI→SynthID is confirmed by OpenAI's Help Center (ChatGPT/Codex/API "include both C2PA metadata and SynthID watermarks", updated 2026-05-21) but time-gated (pre-rollout OpenAI images carry C2PA without SynthID), so the OpenAI verdict is hedged "likely". Oracles: Gemini app "Verify with SynthID" (Google), openai.com/verify (OpenAI). The spectral phase-coherence approach from `github.com/aloshdenny/reverse-SynthID` was evaluated (May 2026) and **does not work for real-content detection**: on its own shipped codebook + validation set, watermarked and cleaned images were indistinguishable (conf within noise, cleaned often higher); it only fires on pure-black 1024x1024 reference images at exact resolution (the controlled case it was calibrated on). The README's "90% / conf=0.91" reproduces only in that lab condition. Do not build a production detector on it; if revisited, it is experimental/diagnostic only and needs a per-resolution, per-model reference corpus. A from-scratch gpt-image pilot (2026-05-24) confirmed this independently: 5 independent solid-black gpt-image outputs share a near-identical fixed signature (pairwise residual correlation **0.92**, avg-template retains 97% energy), so the watermark/carrier IS strongly present and consistent on flat content — but the carrier frequencies extracted from it do NOT discriminate real content (carrier-to-random ratio: cleaned 1.86 > watermarked 1.53; a non-gpt-image image scored highest at 3.67). The signature drowns in content texture. Net: a perfectly consistent solid-color signature still yields no real-content pixel detector with magnitude/carrier methods. A corpus discrimination test (2026-05-24, `scripts/synthid_pixel_probe.py`, raw zero-mean residual NCC) independently re-confirms this: at matched resolution, SynthID positives do NOT cluster apart from negatives (within-Gemini 0.07; at 1024 px pos-vs-neg >= pos-vs-pos). The only high correlations were near-duplicate *content* (5 ChatGPT renders of one prompt at ~0.92, while a distinct ChatGPT image scored ~0 against them) — content, not a carrier. The probe is solid-fills-only and EXPERIMENTAL/DIAGNOSTIC; do not use it on real content. **Correction (deeper re-examination 2026-05-25):** the carrier IS real on solid fills — the earlier "no carrier" was a *method* artifact of using spatial / FFT-magnitude NCC, which can't see it. The carrier is a fixed *phase* at specific low frequencies, so the right metric is **per-bin phase coherence**. On 8 white `gemini-2.5-flash-image` fills (generated via the reverse-SynthID trick: identity-edit prompt "Recreate this image exactly as it is" on a synthetic pure-white PNG — this bypasses the recitation block that rejects text prompts for pure colors), phase coherence at the white carriers `(0,±7..±12,±20..±23)` = **0.86** vs **0.31** random; single-image leave-one-out phase-match **+0.83** vs real photos **-0.24**. (Black `2.5-flash` fills clip to std≈0 — SynthID can't push values below 0, so no carrier in black; the repo's dark carriers come from nano-banana-pro.) **But it does not generalize:** (a) carriers are model-version + resolution + color specific — the repo's v4 codebook (built for `gemini-3.1-flash-image-preview` + `nano-banana-pro-preview`) scores ~0.527 on my 2.5-flash white fills, indistinguishable from negatives (~0.50), i.e. carriers shift across model versions and need a per-model codebook; (b) on real content (30 `2.5-flash` images) the carrier collapses — set phase coherence at carriers 0.37 ≈ random 0.42, and the repo's v4 deteLine truncated
|
||||
- **External AI-vs-real classifier models are out of scope (decided 2026-05-24).** Generic HuggingFace detectors (`Organika/sdxl-detector` Swin Transformer, `umm-maybe/AI-image-detector`, and fine-tunes) exist and report ~0.98 on their *own* SDXL-vs-real validation sets, but they are per-generator and the model cards themselves note degraded accuracy off-distribution; they are untested on gpt-image / Gemini Nano Banana (the metadata-stripped surfaces we care about), and our own light SDXL pass would likely defeat them the same way it defeats SynthID. Detection here stays local + signal-based (metadata + visible sparkle); do not add a bundled classifier dependency.
|
||||
- **SynthID v2 vs default pipeline:** the SDXL-based default profile (since May 2026) defeats SynthID v2. **Verified end-to-end (May 2026):** local SDXL run on a Gemini 3 Pro output, checked via the Gemini app's "Verify with SynthID" feature, returned "no SynthID watermark detected". Also confirmed against **OpenAI's** SynthID (2026-05-23): a fresh ChatGPT/gpt-image output read "SynthID detected" on openai.com/verify before the local SDXL run and "SynthID not detected" after (corpus regression chain: pos `4ef377bd` -> cleaned `47188e88`). The same configuration is used in raiw-app production (`fal-ai/fast-sdxl/image-to-image`, strength 0.05, steps 50, guidance 7.5, no pre-downscale). fal's own `llms.txt` for `fast-sdxl` names the base checkpoint as `stabilityai/stable-diffusion-xl-base-1.0` (verified 2026-05-25) -- the exact checkpoint the local CLI defaults to (`DEFAULT_MODEL_ID`). So the local `invisible` default is weight-for-weight identical to prod; "fast-sdxl" is fal's optimized serving, not different weights. After the native-resolution fix the local pipeline matches prod on weights + strength + steps + guidance + resolution. SD-1.5 dreamshaper at 768 px was previously the default and does NOT defeat v2 — verified empirically against the same feature (strength 0.04, 0.10, and elastic warp α∈{5,8} all flagged positive). That SD-1.5 path was removed; only `default` (SDXL) and `ctrlregen` profiles remain. **Scope of the claim: defeating the SynthID verifier is NOT the same as forensic invisibility.** "Removing the Watermark Is Not Enough: Forensic Stealth in Generative-AI Watermark Removal" (arXiv:2605.09203, 2026-05) shows that six removal attacks across four families (UnMarker, CtrlRegen+, WatermarkAttacker, etc.) all leave forensic traces: independent detectors flag *removal-processed* images vs genuinely-clean ones at **>98% TPR at 1% FPR**. So our SDXL pass makes the oracle read "SynthID not detected," but the output can still be classifiable as "an image that went through a removal pipeline." Do not over-claim "indistinguishable from a real photo." https://arxiv.org/abs/2605.09203
|
||||
@@ -4,18 +4,26 @@ Remove **visible** and **invisible** AI watermarks from images generated by Goog
|
||||
|
||||
Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and visible sparkle overlays — all in one command.
|
||||
|
||||
> ## Try it online: [raiw.cc](https://raiw.cc)
|
||||
>
|
||||
> No Python, no GPU, no setup. Visible-watermark and metadata removal are **free**. Invisible-watermark removal (SynthID / SDXL regeneration) normally needs a local GPU and ~2 GB of models. On **[raiw.cc](https://raiw.cc)** it runs on cloud GPUs in one click for a small per-image fee.
|
||||
|
||||
[](https://github.com/sponsors/wiltodelta)
|
||||
|
||||
If this tool saves you time, consider [sponsoring its development](https://github.com/sponsors/wiltodelta).
|
||||
|
||||
## Features
|
||||
|
||||
- **Visible watermark removal** — Gemini / Nano Banana sparkle logo via reverse alpha blending (fast, offline, deterministic)
|
||||
- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration
|
||||
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests, XMP DigitalSourceType
|
||||
- **Visible watermark removal** — Gemini / Nano Banana sparkle logo (reverse alpha blending) and the Doubao "豆包AI生成" text strip (locate + mask + inpaint); fast, offline, deterministic, no GPU. `visible --mark auto` picks the right one
|
||||
- **Universal region eraser (`erase`)** — remove any logo / watermark / object inside boxes you specify, regardless of position or colour. Default cv2 inpainting (CPU, instant); optional big-LaMa via onnxruntime (`lama` extra) for higher quality
|
||||
- **Invisible watermark removal** — SynthID, StableSignature, TreeRing via diffusion-based regeneration (needs a local GPU, or run it with no setup on [raiw.cc](https://raiw.cc))
|
||||
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests (PNG / JPEG / AVIF / HEIF / JPEG-XL, and **MP4 / MOV / M4V video** at the container level), XMP DigitalSourceType
|
||||
- **"Made with AI" label removal** — removes the metadata that triggers AI labels on Instagram, Facebook, X (Twitter)
|
||||
- **Analog Humanizer** — film grain and chromatic aberration to bypass AI image classifiers
|
||||
- **Smart Face Protection** — automatic extraction and blending of human faces to prevent AI distortion
|
||||
- **Batch processing** — process entire directories
|
||||
- **Detection** — three-stage NCC watermark detection with confidence scoring
|
||||
|
||||
> **Try it online** — don't want to install anything? Use [raiw.cc](https://raiw.cc), a free web service powered by this library.
|
||||
- **Provenance detection (`identify`)** — aggregate C2PA issuer, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the visible sparkle, the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output)
|
||||
|
||||
## Examples
|
||||
|
||||
@@ -27,16 +35,24 @@ Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and vi
|
||||
|
||||
| AI model | Visible watermark | Invisible watermark | Metadata | Our approach |
|
||||
| --- | --- | --- | --- | --- |
|
||||
| **Google Gemini / Nano Banana** | ✅ Sparkle logo | ✅ SynthID | ✅ C2PA + EXIF | Alpha reversal + diffusion + metadata strip |
|
||||
| **Google Gemini / Nano Banana / Gemini 3 Pro** | ✅ Sparkle logo | ✅ SynthID v1 + v2 (default SDXL pipeline, native resolution) | ✅ C2PA + EXIF | Alpha reversal + diffusion + metadata strip |
|
||||
| **OpenAI DALL-E 3 / ChatGPT** | — | — | ✅ C2PA manifest | Metadata strip |
|
||||
| **OpenAI ChatGPT Images 2.0** (gpt-image-2) | — | ⚠️ imperceptible pixel watermark (no public detector yet) | ✅ C2PA manifest (verified) | Diffusion regeneration + metadata strip |
|
||||
| **Stable Diffusion (AUTOMATIC1111, ComfyUI)** | — | ✅ DWT / steganographic | ✅ PNG text chunks | Diffusion regeneration + metadata strip |
|
||||
| **OpenAI ChatGPT Images 2.0** (gpt-image-2) | — | ✅ SynthID + content-specific pixel watermark (since May 2026; no local decoder, openai.com/verify oracle) | ✅ C2PA manifest (verified) | Diffusion regeneration + metadata strip |
|
||||
| **Stable Diffusion / SDXL (AUTOMATIC1111, ComfyUI)** | — | ✅ DWT-DCT (imwatermark — locally detectable) | ✅ PNG text chunks | Diffusion regeneration + metadata strip |
|
||||
| **Black Forest Labs FLUX** | — | ✅ DWT-DCT (imwatermark — locally detectable) | ✅ C2PA (FLUX.2 Pro) | Diffusion regeneration + metadata strip |
|
||||
| **Adobe Firefly** | — | — | ✅ Content Credentials (C2PA) | Metadata strip |
|
||||
| **Stability AI** (DreamStudio / Stable Image) | — | — | ✅ C2PA ("Stability AI Ltd") | Metadata strip |
|
||||
| **Microsoft Designer / Bing Image Creator** | — | ✅ SynthID via DALL-E backend (Designer) | ✅ C2PA (Bing runs MAI-Image, signed "Microsoft") | Metadata strip |
|
||||
| **xAI Grok (Aurora)** | — | — | ✅ EXIF signature scheme (no C2PA): `Signature:` blob + UUID `Artist` | Detected (`identify`); metadata strip |
|
||||
| **Midjourney** | — | — | ✅ EXIF + XMP (prompt, model, seed) | Metadata strip |
|
||||
| **Meta AI** | — | — | ✅ IPTC "Made with AI" (digitalSourceType) | Metadata strip (removes the label) |
|
||||
| **Doubao** (ByteDance) / China AIGC generators | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 `<TC260:AIGC>` XMP label (China's mandatory AI labeling) | Locate + mask + inpaint (cv2, CPU) + metadata strip |
|
||||
| **StableSignature** (Meta) | — | ✅ In-model watermark | — | Diffusion regeneration |
|
||||
| **TreeRing** | — | ✅ Latent space watermark | — | Diffusion regeneration |
|
||||
|
||||
> Visible watermarks (logo overlays) are currently used only by Google Gemini / Nano Banana. Other services rely on invisible watermarks and/or metadata. Our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain.
|
||||
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo) and by Doubao / China AIGC generators (the mandated "...AI生成" corner text). Both are removed deterministically on CPU. Other services rely on invisible watermarks and/or metadata; our diffusion-based regeneration works against any invisible watermark in pixel or frequency domain. For a visible mark from any other source (any position, any colour), use the universal `erase --region` command.
|
||||
|
||||
> **Detection:** `remove-ai-watermarks identify <image>` reports the origin platform and watermark inventory for all the signals above — C2PA issuer, the C2PA soft-binding forensic-watermark vendor (TrustMark / Digimarc / Imatag / ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, the China TC260 AIGC label, embedded generation params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the SynthID metadata proxy, the visible sparkle, and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only.
|
||||
|
||||
## How it works
|
||||
|
||||
@@ -58,24 +74,36 @@ A three-stage NCC (Normalized Cross-Correlation) detector finds the watermark po
|
||||
|
||||
**Speed**: ~0.05s per image. No GPU needed.
|
||||
|
||||
### Removing the Doubao "豆包AI生成" text watermark
|
||||
|
||||
Doubao (ByteDance) stamps every output with a light, semi-transparent "豆包AI生成" text strip in the bottom-right corner — the visible AIGC label mandated by China's TC260 standard. Unlike the fixed-size Gemini sparkle, it is a text strip that scales with image width, so we anchor a generous bottom-right box by geometry, extract the light low-saturation glyph pixels with a polarity-aware white top-hat mask, and inpaint them (cv2 Telea/NS). The mask is background-relative, so it leaves white-paper documents untouched instead of smearing their text. On dense-text backgrounds where the mask would explode, removal is skipped rather than guessed.
|
||||
|
||||
**Speed**: ~0.03s per image. No GPU needed. Best on photo / illustration backgrounds; on high-contrast edges a faint residue can remain (use `erase --backend lama` for neural-quality fill).
|
||||
|
||||
### Universal region eraser
|
||||
|
||||
For any visible mark the dedicated engines do not cover — a logo anywhere, any colour — `erase --region x,y,w,h` inpaints the box you specify. The default `cv2` backend is instant and dependency-free; the optional `lama` backend (big-LaMa via onnxruntime, `lama` extra, ~200 MB model downloaded on first use) gives much cleaner fills on textured regions at the cost of ~3-4 GB RAM per call.
|
||||
|
||||
### Removing SynthID and other invisible watermarks
|
||||
|
||||
Google embeds **SynthID** into every image generated by Gemini / Nano Banana. Other AI services use StableSignature, TreeRing, and similar schemes. These imperceptible frequency-domain patterns survive cropping, resizing, and JPEG compression.
|
||||
|
||||
The removal pipeline:
|
||||
The removal pipeline (default profile, SDXL):
|
||||
|
||||
```text
|
||||
image → downscale to 768px → encode to latent space (VAE)
|
||||
image → encode to latent space (VAE) at native resolution
|
||||
→ add controlled noise (forward diffusion)
|
||||
→ denoise (reverse diffusion, ~2 steps at strength 0.02)
|
||||
→ decode back to pixels (VAE) → upscale to original resolution
|
||||
→ denoise (reverse diffusion, ~50 steps at strength 0.05)
|
||||
→ decode back to pixels (VAE)
|
||||
```
|
||||
|
||||
The key insight: even minimal noise injection (strength 0.02 = 2% perturbation) breaks the watermark signal while preserving visual quality. The diffusion model acts as a learned image prior — it reconstructs the image faithfully while destroying the watermark pattern.
|
||||
By default the image is processed at its **native resolution** with no pre-downscale, matching the hosted raiw.cc backend (fal `fast-sdxl`, which is `stabilityai/stable-diffusion-xl-base-1.0` — the same checkpoint the CLI defaults to). At strength ~0.05 SDXL img2img does not need the input shrunk, and the old forced downscale-to-1024 then upscale-back round-trip was the main quality loss. Pass `--max-resolution N` to cap the long side only when a very large image runs out of GPU/MPS memory (it reintroduces that lossy round-trip).
|
||||
|
||||
SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 Pro outputs, where the older SD-1.5 pipeline at 768 px did not. The SD-1.5 path was removed once it was verified not to handle v2. Note the scope: this defeats the SynthID *verifier*, which is not the same as being forensically indistinguishable from a real photo. Recent work ([arXiv:2605.09203](https://arxiv.org/abs/2605.09203)) shows watermark-removal pipelines leave detectable traces, so a separate "this image was processed" classifier can still flag the output.
|
||||
|
||||
**Face Protection**: before diffusion, YOLO detects people in the image and extracts them. After diffusion, the original faces are blended back with a soft elliptical mask to prevent AI distortion of facial features.
|
||||
|
||||
**Analog Humanizer**: optional film grain and chromatic aberration injection that makes the output indistinguishable from a photo of a screen, defeating AI-generated image classifiers.
|
||||
**Analog Humanizer**: optional film grain and chromatic aberration injection that mimics a photo of a screen, raising the bar for AI-generated image classifiers. (It frustrates generic classifiers but does not guarantee forensic invisibility — see the [arXiv:2605.09203](https://arxiv.org/abs/2605.09203) note above.)
|
||||
|
||||
### Stripping C2PA, EXIF, and "Made with AI" metadata
|
||||
|
||||
@@ -135,6 +163,21 @@ After installation the `remove-ai-watermarks` command is available system-wide.
|
||||
> ```bash
|
||||
> pip install -e ".[gpu]" # or: uv pip install -e ".[gpu]"
|
||||
> ```
|
||||
>
|
||||
> To let `identify` decode the open Stable Diffusion / SDXL / FLUX invisible
|
||||
> watermarks, install the `detect` extra (adds the `invisible-watermark` decoder):
|
||||
>
|
||||
> ```bash
|
||||
> pip install -e ".[detect]" # or: uv pip install -e ".[detect]"
|
||||
> ```
|
||||
>
|
||||
> To also decode the open **Adobe TrustMark** watermark (behind Adobe Durable
|
||||
> Content Credentials), install the `trustmark` extra (pulls torch and downloads
|
||||
> model weights on first use):
|
||||
>
|
||||
> ```bash
|
||||
> pip install -e ".[trustmark]" # or: uv pip install -e ".[trustmark]"
|
||||
> ```
|
||||
|
||||
#### Invisible watermark removal
|
||||
|
||||
@@ -180,13 +223,34 @@ remove-ai-watermarks batch ./images/ --mode all
|
||||
#### Individual commands
|
||||
|
||||
```bash
|
||||
# Visible watermark only (Gemini / Nano Banana sparkle) — fast, offline
|
||||
# Identify provenance: where an image was made + its watermark inventory.
|
||||
# Aggregates C2PA, IPTC "Made with AI", embedded SD/ComfyUI params, EXIF/XMP
|
||||
# generator tags (incl. inside AVIF/HEIF), the SynthID proxy, the visible Gemini
|
||||
# sparkle, and (with the [detect] extra) the open SD/SDXL/FLUX invisible
|
||||
# watermark into one verdict. Reports "unknown"
|
||||
# (never "clean") when no signal is found, since stripped metadata is not proof
|
||||
# of a clean origin. Add --json for machine-readable output.
|
||||
remove-ai-watermarks identify image.png
|
||||
|
||||
# Visible watermark only — fast, offline, CPU. --mark auto (default) picks
|
||||
# between the Gemini sparkle and the Doubao "豆包AI生成" text strip; force one
|
||||
# with --mark gemini / --mark doubao.
|
||||
remove-ai-watermarks visible image.png -o clean.png
|
||||
|
||||
# Erase arbitrary region(s) — universal, any logo/watermark/object, any position.
|
||||
# Default cv2 inpainting (CPU). --backend lama uses big-LaMa (extra 'lama').
|
||||
remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png
|
||||
|
||||
# Invisible watermark only (SynthID etc.) — requires GPU
|
||||
remove-ai-watermarks invisible image.png -o clean.png --humanize 4.0
|
||||
# Runs at native resolution by default. On a very large image that OOMs the
|
||||
# GPU/MPS, cap the long side: --max-resolution 2048
|
||||
|
||||
# Check / strip AI metadata (C2PA, EXIF, "Made with AI" labels)
|
||||
# --check also flags SynthID-bearing sources: a C2PA manifest signed by
|
||||
# Google or OpenAI implies an invisible SynthID watermark in the pixels
|
||||
# (both vendors pair the two). Adobe Firefly / Microsoft sign C2PA without
|
||||
# SynthID, so they are reported as C2PA only.
|
||||
remove-ai-watermarks metadata image.png --check
|
||||
remove-ai-watermarks metadata image.png --remove
|
||||
|
||||
@@ -249,17 +313,65 @@ pip install certifi
|
||||
- [CtrlRegen](https://github.com/yepengliu/CtrlRegen) by Liu et al. (ICLR 2025) — controllable regeneration pipeline
|
||||
- NeuralBleach (MIT) — analog humanizer technique
|
||||
|
||||
## ⚠️ Disclaimer
|
||||
## Roadmap
|
||||
|
||||
This tool is provided for **educational and research purposes only**.
|
||||
Tracked but not yet implemented:
|
||||
|
||||
Removing AI watermarks to misrepresent AI-generated content as human-created
|
||||
may violate applicable laws, including the U.S. Digital Millennium Copyright Act
|
||||
(DMCA) and the COPIED Act. Users are solely responsible for ensuring their use
|
||||
complies with all applicable laws and platform terms of service.
|
||||
- **SynthID-Image v2 automated regression test**. The default SDXL profile defeats v2 per manual checks against the [Gemini app](https://support.google.com/gemini/answer/16722517)'s "Verify with SynthID" feature on a Gemini 3 Pro output (May 2026). An automated end-to-end test would need either programmatic access to the [SynthID Detector portal](https://blog.google/innovation-and-ai/products/google-synthid-ai-content-detector/) (waitlist for media professionals and researchers) or an offline surrogate detector. The spectral phase-coherence surrogate from [reverse-SynthID](https://github.com/aloshdenny/reverse-SynthID) was evaluated and does not separate watermarked from cleaned real-content images (it only fires on controlled solid-color references at exact resolution), so it is not a usable oracle. Open.
|
||||
- **Local SynthID *pixel* detector**. Not feasible today: Google's decoder is proprietary, and magnitude/carrier spectral methods do not separate real content (confirmed by three independent evaluations, including a from-scratch gpt-image pilot; see CLAUDE.md). Blocked on either (a) a programmatic generation path (OpenAI / Gemini API) to build a per-(model, resolution) labeled corpus at scale, or (b) a raw watermarked-output dataset. If data arrives, the next approach to try is a learned classifier on diverse content rather than a fixed carrier codebook.
|
||||
- **Grow the SynthID reference corpus** (`data/synthid_corpus/`) with oracle-labeled samples per model and resolution (Gemini app for Google, openai.com/verify for OpenAI). Prerequisite for any pixel-detector attempt and for an automated removal-regression set.
|
||||
- **Real non-PNG C2PA fixtures**. SynthID-source detection for JPEG / WebP / AVIF is currently covered only by synthetic byte blobs; replace with real vendor-emitted files to ground the binary-scan path.
|
||||
- **Maintenance debt**. Clear strict-pyright debt in `remove_ai_metadata` / `cli.py` (untyped piexif / PIL / click / rich) so `maintain.sh` can finish green. (`uv-secure` is already clean since `idna` was bumped to 3.16.)
|
||||
- **AVIF / HEIF / JPEG-XL detection limits**. Removal strips top-level C2PA `uuid` and JUMBF `jumb` boxes. EXIF/XMP boxes inside these containers are not yet scrubbed (PNG and JPEG are fully covered).
|
||||
- **Video pipeline (`noai-video`)**: per-frame inpainting and tracking for Sora 2 dynamic logo, Veo 3.1 badge, Kling, Runway. Separate package, not folded into this repo.
|
||||
|
||||
The authors do not condone the use of this tool for deception, fraud,
|
||||
or any activity that violates applicable laws or regulations.
|
||||
Won't fix:
|
||||
|
||||
- **Nightshade / Glaze / PhotoGuard removal**. These are defensive perturbations used by artists to protect their work from being scraped into AI training sets. Removing them attacks artists, not AI provenance. Out of scope.
|
||||
|
||||
## Legal
|
||||
|
||||
Watermarking and provenance for AI-generated content is now regulated in several jurisdictions. The table below summarises the May 2026 status. None of this is legal advice.
|
||||
|
||||
| Jurisdiction | Instrument | Status (May 2026) | Relevance |
|
||||
| --- | --- | --- | --- |
|
||||
| EU | AI Act, Article 50 | Transparency duties apply from **2 August 2026**. Legacy generative systems (placed on the market before that date) get a grandfathering period to **2 December 2026** for the Article 50(2) marking duty, under the Digital Omnibus (Commission proposal Nov 2025; co-legislator political agreement 7 May 2026). Article 50 guidelines and a marking Code of Practice are being finalised through 2026. | Removing mandated provenance markers with intent to deceive may be sanctioned under national implementations. |
|
||||
| US (federal) | COPIED Act | **Reintroduced April 2025; not enacted** (pending in the Senate). | If passed, would set NIST provenance standards and prohibit tampering with / removing provenance information. The tool itself is lawful; usage may not be. |
|
||||
| US (state) | CA AB 2655, TX SB 751, similar | TX SB 751 in force; **CA AB 2655 struck down** by a federal court (Aug 2025, Section 230 / First Amendment). | Content-specific (election deepfakes, sexual deepfakes). Not tool-specific. |
|
||||
| US (state) | CA AB 853 (amends the California AI Transparency Act) | Core provider duties operative **2 August 2026** (delayed from 1 January 2026); large platforms 1 January 2027; capture devices 1 January 2028. | Covered providers (1M+ monthly users) must embed a latent disclosure that is "permanent or extraordinarily difficult to remove" and offer a free detection tool. Removing that disclosure is what this tool does. |
|
||||
| South Korea | AI Framework Act (Basic Act on AI), Article 31 | In force since **January 2026** (one-year transition after promulgation). | Art. 31(3): AI output "difficult to distinguish from reality" must be labeled so users "clearly recognize" it; the draft Enforcement Decree accepts a machine-readable (invisible-watermark) label. Artistic/creative works get a presentation exception. |
|
||||
| China | Measures for Labeling AI-Generated Content (+ GB 45438-2025) | In force since **1 September 2025**. | Mandatory explicit (visible) + implicit (metadata) labels across image / audio / video; tampering with, forging, or removing labels is prohibited. |
|
||||
| India | IT (Intermediary Guidelines and Digital Media Ethics Code) Amendment Rules, 2026 | In force since **20 February 2026** (notified 10 February 2026). | All "synthetically generated information" must be **prominently labelled** and carry **permanent metadata / a provenance identifier**; the rules expressly **prohibit modifying, suppressing, or removing** that label or metadata. Covers image, audio, and audio-visual content. |
|
||||
| UK | Online Safety Act 2023 / Ofcom guidance | In force, but **no statutory AI-provenance or watermarking obligation**. | Ofcom encourages watermarking / provenance metadata as voluntary "attribution measures"; platform duties, not user obligations. |
|
||||
|
||||
## Threat model
|
||||
|
||||
This tool defends already-distributed AI imagery against automatic detection systems (social-platform "Made with AI" labels, third-party classifiers, content-policy filters). It does **not** retroactively anonymise generation.
|
||||
|
||||
In particular, **SynthID** (Google DeepMind) is embedded across Google's generative media stack — Imagen (images), Veo (video), Lyria (audio) — and Gemini app image outputs (Nano Banana / Gemini 3 Pro, which we verified positive via the Gemini app's SynthID oracle); Google reported over 10 billion items watermarked by December 2025. It carries a **multi-bit payload** — the research paper's SynthID-O variant encodes 136-bit payloads in 512x512 images ([arxiv 2510.09263](https://arxiv.org/abs/2510.09263)). The payload is believed to encode a user / session identifier. If the original watermarked file ever passed through a system controlled by the prompt originator (a saved Gemini account history, a screenshot uploaded to a Google product, a backup), Google retains the ability to link that original to the generating account. Stripping the watermark from a copy you possess does not erase Google's server-side record.
|
||||
|
||||
Use cases where the threat model fits:
|
||||
- You generated the image yourself, want to publish it as your own work, and accept the consequences if Google ever publishes their detector logs.
|
||||
- You are running a security / robustness evaluation.
|
||||
- You are preserving art or historical record against false-positive "AI-generated" labels.
|
||||
|
||||
Use cases where the threat model **does not** fit:
|
||||
- Generating an image, expecting that removing the watermark anonymises you to Google. It doesn't.
|
||||
- Distributing AI-generated content while claiming human authorship. The watermark is one of several traceability layers.
|
||||
|
||||
This tool is intended for legitimate purposes such as:
|
||||
|
||||
- Privacy protection (removing metadata that leaks user account identifiers).
|
||||
- Art preservation and fair-use research.
|
||||
- Removing false-positive "Made with AI" labels from human-edited photographs.
|
||||
- Security research and watermark robustness study.
|
||||
|
||||
**Who bears the liability.** This is general-purpose software and is itself lawful to publish and run; legal responsibility attaches to the person who removes a marker and to how the result is then used, and the hinge is intent. Removing AI provenance to pass AI-generated content off as human-made, to commit fraud, to produce non-consensual deepfakes, or to conceal copyright infringement can expose the remover to liability. Two kinds of exposure are worth knowing:
|
||||
|
||||
- **The downstream act.** Deception, fraud, defamation, IP infringement, or breaking a platform's terms — judged by intent and harm, not by the act of editing metadata itself. In the US, the **DMCA (17 U.S.C. § 1202)** specifically bars removing "copyright management information" *with intent to conceal or enable infringement*.
|
||||
- **The removal itself.** Some jurisdictions penalise tampering with the label/metadata as such, regardless of downstream use — notably **China** (Labeling Measures) and **India** (IT Amendment Rules 2026), which expressly prohibit removing or suppressing the AI label and provenance metadata. The US **COPIED Act** would do the same if enacted.
|
||||
|
||||
Legitimate uses — publishing your own work, privacy (stripping metadata that leaks an account identifier), security / robustness research, or removing a false-positive "Made with AI" label from a human-edited photograph — are generally lawful. Users are solely responsible for ensuring their use complies with all applicable laws. The authors do not condone use of this tool for deception, fraud, or any activity that violates applicable laws or regulations. None of this is legal advice.
|
||||
|
||||
## License
|
||||
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
# Doubao visible watermark capture
|
||||
|
||||
Goal: capture the Doubao "豆包AI生成" visible watermark over known flat backgrounds so we can
|
||||
build a per-pixel alpha map and a reverse-alpha-blend remover, the same way the Gemini sparkle
|
||||
engine works (`src/remove_ai_watermarks/gemini_engine.py`).
|
||||
|
||||
## What we already know (verified from prior art, 2026-05-26)
|
||||
|
||||
- Blend model: **alpha compositing with a white logo** `watermarked = a*logo + (1-a)*original`,
|
||||
`logo = (255,255,255)`. Inversion: `original = (watermarked - a*logo) / (1-a)`.
|
||||
Confirmed by two independent sources (an open-source remover's algorithm doc + aiwatermarkremover.dev,
|
||||
both say "alpha map"). One commercial blog (pixelcleanai) claims "screen blend" instead; the gray
|
||||
capture below settles it empirically.
|
||||
- Position: **bottom-right corner**, small margins (right ~8-20px, bottom ~5px), scales with image size.
|
||||
Confirmed by our sample `data/samples/doubao-1.png` (2048x2048) plus three sources.
|
||||
- Size **scales with resolution**. Third-party numbers (~90x18 at <=1024, ~180x40 at >1024) are
|
||||
approximate and calibrated for ~1024-1280 outputs; at 2048 the strip is much larger. A shipped
|
||||
third-party alpha map is only 120x20, too small for our 2K/4K target -> capture fresh.
|
||||
- In practice clean inversion leaves residue on textured backgrounds, so the remover pairs the alpha
|
||||
map with inpainting (our Gemini engine already does gradient-masked inpainting for residual edges).
|
||||
|
||||
## Use doubao.com specifically
|
||||
|
||||
The "豆包AI生成" mark is Doubao's. Jimeng / Dreamina use a different mark. Generate on doubao.com so
|
||||
the captured template matches our target.
|
||||
|
||||
## How to capture (image-edit path, most reliable)
|
||||
|
||||
For each seed in `seeds/`:
|
||||
|
||||
1. Open Doubao image generation, use the image-edit / reference mode, upload the seed.
|
||||
2. Prompt (Chinese preferred):
|
||||
`请完全按照原图重新生成这张图片,保持完全一致,不要添加或修改任何内容`
|
||||
(English: `Recreate this image exactly as it is, keep it identical, do not add or change anything`)
|
||||
3. Download the ORIGINAL output file (not a screenshot). Do not crop / edit / re-save.
|
||||
|
||||
Prior art confirms uploading a pure-black image and letting Doubao stamp it works.
|
||||
|
||||
If edit mode is unavailable and text-to-image refuses a solid color, fall back to generating 10-12
|
||||
normal-content images at one fixed resolution; the mark is the only constant across them and can be
|
||||
extracted by per-pixel min/median.
|
||||
|
||||
## What to capture (priority top to bottom)
|
||||
|
||||
| Aspect | black | white | gray128 | why |
|
||||
|--------|-------|-------|---------|-----|
|
||||
| 1:1 | 3 | 1 | 1 | primary alpha map + confirm the stamp is pixel-identical across runs + settle blend mode |
|
||||
| 16:9 | 2 | 1 | 1 | anchor rule in landscape |
|
||||
| 9:16 | 2 | 1 | 1 | anchor rule in portrait |
|
||||
| 4:3, 3:4 | 1 each | - | - | optional, refines anchor rule |
|
||||
|
||||
- 3 blacks on 1:1: if the first two are byte-identical in the watermark region, the third is optional.
|
||||
- gray128 is the blend-mode test: predict the gray result from the black capture under alpha vs screen;
|
||||
whichever matches the real gray output is the true blend.
|
||||
- If the UI offers multiple output resolutions (1K / 2K / 4K), capture one black per resolution on 1:1 -
|
||||
needed to learn how the watermark scales.
|
||||
- Also grab 3-5 normal-content images on 1:1 for end-to-end removal validation.
|
||||
|
||||
## Hygiene
|
||||
|
||||
- Original download, never a screenshot. PNG preferred; if Doubao only gives JPEG, note it.
|
||||
- No crop / edit / re-save. Default settings, watermark left ON.
|
||||
|
||||
## Naming, drop into `captures/`
|
||||
|
||||
```
|
||||
doubao_black_1x1_1.png
|
||||
doubao_white_1x1_1.png
|
||||
doubao_gray128_1x1_1.png
|
||||
doubao_black_16x9_1.png
|
||||
doubao_content_1x1_1.png
|
||||
```
|
||||
|
||||
## Also report back
|
||||
|
||||
1. Which resolutions and aspect ratios the Doubao UI actually offers.
|
||||
2. Whether there is a watermark on/off toggle in the UI.
|
||||
3. Download format (PNG or JPEG).
|
||||
|
After Width: | Height: | Size: 5.1 MiB |
|
After Width: | Height: | Size: 356 KiB |
|
Before Width: | Height: | Size: 4.2 MiB |
|
Before Width: | Height: | Size: 5.6 MiB |
|
Before Width: | Height: | Size: 553 KiB |
@@ -26,7 +26,7 @@ The `trainedAlgorithmicMedia` tag is what triggers "Made with AI" labels on Inst
|
||||
|
||||
### Invisible pixel-level watermark
|
||||
|
||||
OpenAI's system card for Images 2.0 states the model embeds an "imperceptible, robust, and content-specific" pixel-level watermark alongside C2PA. No public detector exists, so bypass cannot be verified empirically for this sample.
|
||||
OpenAI's system card for Images 2.0 states the model embeds an "imperceptible, robust, and content-specific" pixel-level watermark alongside C2PA. This sample was downloaded 2026-04-22, before OpenAI's 19 May 2026 rollout of Google's SynthID watermark across ChatGPT / Codex / the API, so it likely predates SynthID and carries only the original content-specific watermark. Since that rollout, the openai.com/verify tool (in preview) is the public oracle for both signals; there is still no local decoder, so bypass cannot be verified empirically here without the oracle.
|
||||
|
||||
## Reproducing the removal
|
||||
|
||||
@@ -38,4 +38,4 @@ remove-ai-watermarks metadata amur-leopard.png --remove -o amur-leopard.clean.pn
|
||||
remove-ai-watermarks all amur-leopard.png -o amur-leopard.all.png --device mps
|
||||
```
|
||||
|
||||
Note: the diffusion step downscales to 768 px, which degrades fine text on text-heavy infographics like this one. Tracked as a known limitation; see project TODO.
|
||||
Note: the diffusion step runs at native resolution by default (no pre-downscale), so fine text on a text-heavy infographic like this one is preserved. Pass `--max-resolution N` only if a very large image OOMs the GPU/MPS (that reintroduces a lossy downscale then upscale round-trip).
|
||||
@@ -0,0 +1,152 @@
|
||||
# SynthID reference corpus
|
||||
|
||||
A locally-collected, labeled image corpus for SynthID work. Two downstream uses:
|
||||
|
||||
1. **Per-resolution spectral codebook** for an experimental SynthID detector
|
||||
(carrier frequencies are resolution-dependent, so labels must record the
|
||||
exact native resolution).
|
||||
2. **Removal regression set** — verify that our pipeline turns a SynthID-positive
|
||||
image into a negative one.
|
||||
|
||||
There is no reliable local detector of the SynthID pixel watermark (Google's
|
||||
decoder is proprietary). The ground-truth label therefore comes from an
|
||||
external oracle, recorded per image in `verified_via` (see below).
|
||||
|
||||
## Layout
|
||||
|
||||
```
|
||||
data/synthid_corpus/
|
||||
README.md # this protocol (committed)
|
||||
manifest.csv # labels + provenance (committed; one row per tracked image)
|
||||
images/ # the labeled corpus (committed)
|
||||
pos/ # SynthID present
|
||||
neg/ # SynthID absent (incl. reviewed real photos)
|
||||
cleaned/ # our pipeline output from a pos image
|
||||
refs/ # synthetic black/white calibration fills (gitignored, regenerable)
|
||||
```
|
||||
|
||||
The labeled images are committed so the corpus is reproducible and the removal
|
||||
regression set runs in CI. `manifest.csv` is kept in sync with the files on
|
||||
disk (one row per tracked image; dangling rows are pruned when files are
|
||||
removed). Before adding any image, confirm it carries no private or
|
||||
identifiable content you would not publish -- this is a public repo and git
|
||||
history is permanent. The synthetic `refs/` fills stay gitignored (regenerable,
|
||||
not part of the labeled set).
|
||||
|
||||
## Verification levels (`verified_via`)
|
||||
|
||||
Ground-truth quality, strongest first:
|
||||
|
||||
- `gemini-app` — checked via the Gemini app "Verify with SynthID" feature. Gold standard for the pixel watermark (Google models).
|
||||
- `openai-verify` — checked via openai.com/verify (gold standard for OpenAI ChatGPT/Codex/API images).
|
||||
- `synthid-portal` — checked via Google's SynthID Detector portal.
|
||||
- `c2pa-metadata` — issuer-only proxy (Google/OpenAI C2PA manifest present). Weaker: the C2PA can be stripped while the pixel watermark remains.
|
||||
- `third-party` — label asserted by an external dataset, not independently verified.
|
||||
- `none` — unverified.
|
||||
|
||||
Prefer `gemini-app` for any image that will train the codebook or gate a test.
|
||||
|
||||
## What to collect
|
||||
|
||||
For the **codebook** (per target resolution, e.g. 1024x1024, 1024x1536, 1536x2816):
|
||||
|
||||
- 30-50+ SynthID-positive outputs per resolution (more is better; ~150-200 per
|
||||
resolution materially improves carrier discovery).
|
||||
- At each target resolution, also a batch of **pure-black (#000000)** and
|
||||
**pure-white (#FFFFFF)** fills generated by the SynthID model — these isolate
|
||||
the content-independent carrier (the watermark is most of the signal there).
|
||||
|
||||
For the **regression set**:
|
||||
|
||||
- A handful of `pos` images, their `cleaned` counterparts (run through our
|
||||
pipeline), and the cleaned re-verified via `gemini-app` (should read negative).
|
||||
- `neg` controls: non-AI photos and outputs from non-SynthID models (SD,
|
||||
Midjourney, Firefly) verified negative.
|
||||
|
||||
The corpus is committed to a public repo: review every image before adding it
|
||||
and keep out anything private or identifiable you would not publish.
|
||||
|
||||
## Ingesting
|
||||
|
||||
Use `scripts/synthid_corpus.py` — it copies a file in, records its sha256,
|
||||
resolution, format, and C2PA issuer (via our own detector), and appends a row
|
||||
to `manifest.csv`:
|
||||
|
||||
```bash
|
||||
uv run python scripts/synthid_corpus.py ingest path/to/*.png \
|
||||
--label pos --source "Gemini app" --model gemini-3-pro \
|
||||
--verified-via gemini-app --notes "1024x1024 batch"
|
||||
|
||||
uv run python scripts/synthid_corpus.py status # counts by label / resolution / verification
|
||||
```
|
||||
|
||||
## Autonomous collection via Chrome MCP
|
||||
|
||||
Generation can be driven through the browser (the account must be logged in):
|
||||
|
||||
- **Gemini** (`gemini.google.com`): type `Create an image: <prompt>`, wait, hover the
|
||||
result, click the download icon (top-right). Single, reliable click. Outputs
|
||||
carry Google C2PA + SynthID. Occasionally the composer stalls in a
|
||||
"generating" state -> start a New chat to reset.
|
||||
- **ChatGPT** (`chatgpt.com`): the UI download is flaky (the fullscreen viewer
|
||||
races and can grab the previous image; the share-modal path works but is
|
||||
multi-step). Reliable path is an in-page fetch of the rendered image, which
|
||||
preserves the original bytes (C2PA intact, unlike a canvas re-encode):
|
||||
|
||||
```js
|
||||
// run in the ChatGPT tab via the browser MCP javascript tool
|
||||
(async () => {
|
||||
const imgs = [...document.querySelectorAll('img')].filter(i => i.naturalWidth >= 400);
|
||||
const img = imgs[imgs.length - 1]; // newest large image
|
||||
const b = await (await fetch(img.currentSrc || img.src)).blob();
|
||||
const a = document.createElement('a');
|
||||
a.href = URL.createObjectURL(b); a.download = 'dl.png';
|
||||
document.body.appendChild(a); a.click(); a.remove();
|
||||
return 'size=' + b.size; // do NOT return the src (privacy guard blocks query strings)
|
||||
})()
|
||||
```
|
||||
|
||||
Gotcha: confirm the returned `size` differs from the previous image before
|
||||
ingesting -- if the new image has not finished rendering, the script grabs the
|
||||
prior one (the corpus dedups by sha256, but the notes would mislabel it).
|
||||
ChatGPT also shows an A/B "which is better?" picker; click Skip first.
|
||||
|
||||
**Originals, not previews.** Some platforms render a low-res preview in the chat
|
||||
(Grok serves a ~20KB 1024px JPEG/PNG; the in-page `<img>` fetch grabs *that*, not
|
||||
the original). Previews are re-encoded and **strip metadata**, so a "clean"
|
||||
preview is not proof the original is clean. Always pull the original via the
|
||||
platform's native Download / lightbox button and sanity-check the file size (a
|
||||
20KB "1024x1024" is a preview). ChatGPT's in-chat `<img>` *is* the full-res
|
||||
oaiusercontent original, so fetch+blob is fine there; Grok needs the lightbox
|
||||
Download; Leonardo serves the original JPEG in-chat (download button matches).
|
||||
|
||||
## Per-platform watermark map (observed, May 2026)
|
||||
|
||||
What each platform actually embeds, verified by byte-scan (and Gemini-app oracle
|
||||
where noted). The detector's coverage is complementary: metadata catches C2PA /
|
||||
IPTC; `exif_generator` catches EXIF `Make`/`Software` + XMP `CreatorTool`;
|
||||
`invisible_watermark.py` (imwatermark) catches the open SD/SDXL/FLUX DWT-DCT
|
||||
watermark on pristine files; the visible detector catches the Gemini-family
|
||||
sparkle; the SynthID *pixel* itself has no local detector (oracle only).
|
||||
|
||||
| Platform | C2PA issuer | SynthID pixel | IPTC "Made with AI" | Visible sparkle | imwatermark | Corpus label |
|
||||
|---|---|---|---|---|---|---|
|
||||
| Gemini app | Google | yes | - | yes | - | pos |
|
||||
| ChatGPT / gpt-image | OpenAI | yes | - | - | - | pos |
|
||||
| Microsoft Designer | OpenAI + Microsoft | yes (via OpenAI) | - | - | - | pos |
|
||||
| Bing Image Creator | Microsoft (MAI-Image) | no | - | - | - | pos (C2PA "Microsoft", not OpenAI) |
|
||||
| Google AI Studio (Nano Banana) | **none** | yes (oracle-confirmed) | - | yes | - | pos (metadata blind spot) |
|
||||
| Stability AI (Brand Studio) | Stability AI Ltd | no | - | - | no | pos (C2PA only) |
|
||||
| Ideogram | none | no | - | - | no | pos (EXIF `Make="Ideogram AI"` only) |
|
||||
| Meta AI | none | no | **yes** | - | - | neg (for SynthID) |
|
||||
| Leonardo.ai | none | no | no | - | no | neg |
|
||||
| Recraft | none (export strips) | no | no | - | no | neg (re-encoded export, no signal) |
|
||||
| Krea (FLUX 2 host) | none | no | no | - | no | neg (host omits the imwatermark encoder) |
|
||||
| Grok (xAI) | none (non-adopter) | no | no | - | no | neg (captured: clean low-res preview) |
|
||||
|
||||
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).
|
||||
- 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.
|
||||
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 1.8 MiB |
|
After Width: | Height: | Size: 2.7 MiB |
|
After Width: | Height: | Size: 2.2 MiB |
|
After Width: | Height: | Size: 3.1 MiB |
|
After Width: | Height: | Size: 3.0 MiB |
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 8.3 MiB |
|
After Width: | Height: | Size: 7.8 MiB |
|
After Width: | Height: | Size: 8.6 MiB |
@@ -0,0 +1,26 @@
|
||||
sha256,filename,label,source,model,width,height,format,c2pa_issuer,synthid_metadata,verified_via,added,notes
|
||||
4ef377bde1a1d4eff141972841938643b173f5052992a018b9a21b31ac31731e,"4ef377bd-ChatGPT Image May 23, 2026, 02_43_02 PM.png",pos,ChatGPT,gpt-image,1254,1254,png,OpenAI,yes,openai-verify,2026-05-23T21:48:12Z,fresh post-rollout 2026-05-23; openai.com/verify: SynthID+C2PA detected
|
||||
d09f84c0e4c6d8b336bf4a9a7277314e940dcb5052ae7051e785cbb3bb42d656,d09f84c0-Gemini_Generated_Image_vq7wkwvq7wkwvq7w.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-23T21:52:40Z,"user: latest Gemini, SynthID v2"
|
||||
7b650522d42db09568e249c04d683c469fb3e280a2c53fcd1031cb9df27c619a,"7b650522-ChatGPT Image May 24, 2026, 12_19_54 PM.png",pos,ChatGPT,gpt-image,1602,982,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:20:25Z,content: misty pine forest at dawn
|
||||
fb28dba2a82cc101a92fdee5714867b32610d0564f37737fe4bb70782b8ecf32,fb28dba2-Gemini_Generated_Image_dsjlnsdsjlnsdsjl.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-24T19:30:25Z,content: elderly fisherman portrait
|
||||
d20d4cc936dbdfe909c52502039a9e84ba93d97b42b24a0acee5b7d6c71930ae,d20d4cc9-Gemini_Generated_Image_ug6kdpug6kdpug6k.png,pos,Gemini app,gemini,2816,1536,png,Google LLC,yes,c2pa-metadata,2026-05-24T19:33:15Z,content: red coffee mug product shot
|
||||
28f323345f6496d936c3f1a72f671ddf59d0f81565c24a63bf3286860f633afe,28f32334-chatgpt_fisherman.png,pos,ChatGPT,gpt-image,1023,1537,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:39:20Z,content: elderly fisherman portrait (fetch+blob dl)
|
||||
88e61a384c2e0b12d97bc66046e4a10542b2987448ba89c4b49e66311e969c84,88e61a38-chatgpt_tokyo.png,pos,ChatGPT,gpt-image,1023,1537,png,OpenAI,yes,c2pa-metadata,2026-05-24T19:42:02Z,content: tokyo street night (fetch+blob dl)
|
||||
1fa5f77f710c11a4cc69fed60195450def734401ed57c2600a84ce191f985440,1fa5f77f-IMG_0786.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
2ea228ed270cd169e9d38bc3f1a162de7edbf54b6e5ad3c701a3c90010ec7067,2ea228ed-IMG_1791.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
0bb4c176d83fbcbf4e628de7587d6183b9134c4f3fa85f55b96e94185273c7f6,0bb4c176-IMG_1790.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:47Z,post photo; no C2PA/SynthID (verified)
|
||||
3fc0c4253f86427777904f41355eacbdcc1a29f6897ec694c7fc68b6e7f70846,3fc0c425-IMG_1450.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
2b148db2b1a314a87647a03828b3d235e7c4c939252f448b572b7658c7bb9723,2b148db2-IMG_1832.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
2d02fed0b6d60ce1142eadac9837a83896569dddbdad7cc5cfdec018f0506d36,2d02fed0-IMG_1520.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
8f170c06f843c2bcf4cf6e249dd76365287fdb3a322637ba93c570f50cb19772,8f170c06-IMG_1078.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
3fc4c8316012abc462df5534d4d995e5af84d35076a2927331107ff994293d9e,3fc4c831-IMG_2566.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
5fe59521d579b536340253d2bcaa7c28ddd2485fa964fc27a9b9e5118ad0cdd1,5fe59521-IMG_1496.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
4ead7918a5aeea7fba78b36af44358fea7ed1db7f46f4bc675152b9d04e68c38,4ead7918-IMG_1300.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:48Z,post photo; no C2PA/SynthID (verified)
|
||||
4c795238b89178cf52a3674e721ed7f4cd5068028385491d12171a3c62545c35,4c795238-IMG_3034.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
5abfaccb37c549de67c7f3a751a528a423e24a82c643fdb29094be8debbb206d,5abfaccb-IMG_3018.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
5fed1923d513c1e9ffcba2f240e617fa9344fb39d35144169570ece8b0bd0f33,5fed1923-IMG_0272.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
06b04d8fe8e1cd6bee9a973f93bfda37586924cbfec7d372f59d52aa9196160b,06b04d8f-IMG_1474.HEIC,neg,iPhone (me/post_photos),,5712,4284,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
8fdb574a94e65e14ac29017cf2d5a2ede18a8c4e3f12e04c64292b0d38570062,8fdb574a-IMG_3557.HEIC,neg,iPhone (me/post_photos),,4032,3024,heic,,,none,2026-05-24T20:58:49Z,post photo; no C2PA/SynthID (verified)
|
||||
c86973424817f62510e2a312b85c52e05adf47ace87a8e717fd442607596f501,c8697342-aistudio_lake.png,pos,Google AI Studio (Nano Banana),gemini-2.5-flash-image,1024,1024,png,,,gemini-app,2026-05-24T21:39:09Z,"API/playground: SynthID pixel CONFIRMED (Gemini-app oracle) + visible sparkle, but NO C2PA/IPTC -> synthid_source blind spot"
|
||||
1f81827c06d67cf6f6c7f5d53ec8f9738183942a6d1d2717b161fea0fdcc540a,1f81827c-Designer.png,pos,Microsoft Designer,dall-e (Designer),1024,1024,png,"OpenAI, Microsoft",yes,c2pa-metadata,2026-05-24T22:18:40Z,C2PA issuer OpenAI+Microsoft; synthid_source=OpenAI (DALL-E surface inherits OpenAI SynthID+C2PA)
|
||||
f6dd47a5ffd319aea21bf10dcf9877097666420b02c2620080bac12b03976e7e,f6dd47a5-4ef377bd-gpt-image-2-cleaned.png,cleaned,"our pipeline (invisible/SDXL, native-res default)",stabilityai/stable-diffusion-xl-base-1.0,1254,1254,png,,,openai-verify,2026-05-25T20:50:38Z,"cleaned from 4ef377bd via v0.5.3 'all' at native 1254x1254 (prod-equivalent); openai.com/verify: SynthID NOT detected. Re-confirms #10 native-res default defeats OpenAI SynthID (closes #15 root cause). Note: native res OOMs on 20GB MPS, auto-fell back to CPU."
|
||||
|
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.3.4"
|
||||
version = "0.6.5"
|
||||
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -18,7 +18,7 @@ dependencies = [
|
||||
[project.optional-dependencies]
|
||||
gpu = [
|
||||
"torch>=2.0.0",
|
||||
"diffusers>=0.25.0",
|
||||
"diffusers>=0.38.0",
|
||||
"transformers>=4.35.0",
|
||||
"accelerate>=0.25.0",
|
||||
"controlnet-aux>=0.0.9",
|
||||
@@ -26,13 +26,46 @@ gpu = [
|
||||
"ultralytics>=8.0.0",
|
||||
"color-matcher>=0.5.0",
|
||||
]
|
||||
# Open invisible-watermark (imwatermark) decoder for detecting the DWT-DCT
|
||||
# watermarks embedded by Stable Diffusion / SDXL / FLUX. Optional because it
|
||||
# pulls non-headless opencv AND torch (invisible-watermark declares torch a hard
|
||||
# dependency, and WatermarkDecoder eagerly imports rivaGan -> torch at import
|
||||
# time, so the dwtDct-only detect path still needs torch present even though it
|
||||
# never runs on GPU). So `detect` alone pulls torch -- no need to add `gpu` for
|
||||
# detection. identify() guards the import and skips the signal when absent.
|
||||
detect = [
|
||||
"invisible-watermark>=0.2.0",
|
||||
]
|
||||
# Adobe TrustMark decoder -- the open, keyless watermark behind Adobe Durable
|
||||
# Content Credentials (soft-binding alg ``com.adobe.trustmark.P``). Optional
|
||||
# because it pulls torch and downloads model weights on first use. identify()
|
||||
# guards the import and skips the TrustMark signal when absent.
|
||||
trustmark = [
|
||||
"trustmark>=0.8.0",
|
||||
]
|
||||
# Universal region eraser backend -- big-LaMa via onnxruntime (Carve/LaMa-ONNX,
|
||||
# Apache-2.0). CPU, no torch. Model (~200 MB) is downloaded on first use and
|
||||
# cached by huggingface_hub; it is never bundled in this repo. The default cv2
|
||||
# eraser backend needs none of this.
|
||||
lama = [
|
||||
"onnxruntime>=1.16.0",
|
||||
"huggingface-hub>=0.20.0",
|
||||
]
|
||||
dev = [
|
||||
"pytest>=8.0.0",
|
||||
"pytest-cov>=4.1.0",
|
||||
"ruff>=0.4.0",
|
||||
"pyright>=1.1.0",
|
||||
"invisible-watermark>=0.2.0",
|
||||
]
|
||||
all = ["remove-ai-watermarks[gpu,dev]"]
|
||||
all = ["remove-ai-watermarks[gpu,detect,trustmark,lama,dev]"]
|
||||
|
||||
# diffusers 0.38.0 (security fix for GHSA-98h9-4798-4q5v) declares a dependency
|
||||
# on safetensors>=0.8.0rc0 — a pre-release. Allow pre-releases globally so the
|
||||
# resolver can satisfy that. Drop once diffusers publishes a release with a
|
||||
# stable safetensors pin (or once safetensors 0.8.0 stable is out).
|
||||
[tool.uv]
|
||||
prerelease = "allow"
|
||||
|
||||
[project.scripts]
|
||||
remove-ai-watermarks = "remove_ai_watermarks.cli:main"
|
||||
|
||||
@@ -0,0 +1,240 @@
|
||||
"""Ingest and inspect the local SynthID reference corpus.
|
||||
|
||||
Copies images into ``data/synthid_corpus/images/<label>/`` and records one row
|
||||
per image in ``manifest.csv`` (sha256, resolution, format, C2PA issuer, and the
|
||||
external verification level). Dogfoods the project's own C2PA detector so the
|
||||
recorded metadata matches what the library sees.
|
||||
|
||||
See ``data/synthid_corpus/README.md`` for the collection protocol.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/synthid_corpus.py ingest IMAGES... --label pos \\
|
||||
--source "Gemini app" --model gemini-3-pro --verified-via gemini-app
|
||||
uv run python scripts/synthid_corpus.py status
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import logging
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from collections import Counter
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from PIL import Image
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
console = Console()
|
||||
|
||||
DEFAULT_ROOT = Path(__file__).resolve().parent.parent / "data" / "synthid_corpus"
|
||||
LABELS = ("pos", "neg", "cleaned")
|
||||
VERIFIED_VIA = ("gemini-app", "openai-verify", "synthid-portal", "c2pa-metadata", "third-party", "none")
|
||||
FIELDNAMES = [
|
||||
"sha256",
|
||||
"filename",
|
||||
"label",
|
||||
"source",
|
||||
"model",
|
||||
"width",
|
||||
"height",
|
||||
"format",
|
||||
"c2pa_issuer",
|
||||
"synthid_metadata",
|
||||
"verified_via",
|
||||
"added",
|
||||
"notes",
|
||||
]
|
||||
|
||||
|
||||
def _manifest_path(root: Path) -> Path:
|
||||
return root / "manifest.csv"
|
||||
|
||||
|
||||
def _read_manifest(root: Path) -> list[dict[str, str]]:
|
||||
path = _manifest_path(root)
|
||||
if not path.exists():
|
||||
return []
|
||||
with open(path, newline="") as f:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
|
||||
def _sha256(path: Path) -> str:
|
||||
h = hashlib.sha256()
|
||||
with open(path, "rb") as f:
|
||||
for chunk in iter(lambda: f.read(1 << 20), b""):
|
||||
h.update(chunk)
|
||||
return h.hexdigest()
|
||||
|
||||
|
||||
def _dims_via_sips(path: Path) -> tuple[int, int]:
|
||||
"""Read pixel dimensions via macOS ``sips`` (handles HEIC, which PIL can't
|
||||
open without pillow-heif). Returns (0, 0) off macOS or on failure."""
|
||||
sips = shutil.which("sips")
|
||||
if sys.platform != "darwin" or not sips:
|
||||
return 0, 0
|
||||
try:
|
||||
# Fixed command; the only variable is a local file path passed to ingest.
|
||||
out = subprocess.run( # noqa: S603
|
||||
[sips, "-g", "pixelWidth", "-g", "pixelHeight", str(path)],
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=15,
|
||||
).stdout
|
||||
dims = {"pixelWidth": 0, "pixelHeight": 0}
|
||||
for line in out.splitlines():
|
||||
for key in dims:
|
||||
if f"{key}:" in line:
|
||||
dims[key] = int(line.split(":")[1].strip())
|
||||
return dims["pixelWidth"], dims["pixelHeight"]
|
||||
except Exception: # sips missing/odd output; dims stay unknown
|
||||
return 0, 0
|
||||
|
||||
|
||||
def _probe(path: Path) -> tuple[int, int, str, str, bool]:
|
||||
"""Return (width, height, format, c2pa_issuer, synthid_metadata)."""
|
||||
width = height = 0
|
||||
fmt = path.suffix.lstrip(".").lower()
|
||||
try:
|
||||
with Image.open(path) as img:
|
||||
width, height = img.size
|
||||
fmt = (img.format or fmt).lower()
|
||||
except Exception as exc: # unknown/user formats can raise non-OSError; see CLAUDE.md
|
||||
log.debug("PIL could not open %s: %s", path, exc)
|
||||
|
||||
if width == 0: # e.g. HEIC from iPhone — fall back to macOS sips
|
||||
width, height = _dims_via_sips(path)
|
||||
|
||||
info = extract_c2pa_info(path)
|
||||
issuer = str(info.get("issuer", ""))
|
||||
synthid = "synthid_watermark" in info
|
||||
return width, height, fmt, issuer, synthid
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli() -> None:
|
||||
"""Manage the local SynthID reference corpus."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True, path_type=Path))
|
||||
@click.option("--label", required=True, type=click.Choice(LABELS), help="SynthID label.")
|
||||
@click.option("--source", default="", help="Where the image came from (free text).")
|
||||
@click.option("--model", default="", help="Generating model, e.g. gemini-3-pro.")
|
||||
@click.option(
|
||||
"--verified-via", "verified_via", default="none", type=click.Choice(VERIFIED_VIA), help="Ground-truth oracle."
|
||||
)
|
||||
@click.option("--notes", default="", help="Free-text notes (e.g. resolution batch).")
|
||||
@click.option("--root", type=click.Path(path_type=Path), default=DEFAULT_ROOT, help="Corpus root.")
|
||||
def ingest(
|
||||
images: tuple[Path, ...],
|
||||
label: str,
|
||||
source: str,
|
||||
model: str,
|
||||
verified_via: str,
|
||||
notes: str,
|
||||
root: Path,
|
||||
) -> None:
|
||||
"""Copy IMAGES into the corpus and append rows to the manifest."""
|
||||
dest_dir = root / "images" / label
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
rows = _read_manifest(root)
|
||||
seen = {r["sha256"] for r in rows}
|
||||
added = 0
|
||||
new_rows: list[dict[str, str]] = []
|
||||
|
||||
for src in images:
|
||||
digest = _sha256(src)
|
||||
if digest in seen:
|
||||
console.print(f" [dim]skip (duplicate sha256): {src.name}[/]")
|
||||
continue
|
||||
seen.add(digest)
|
||||
|
||||
width, height, fmt, issuer, synthid = _probe(src)
|
||||
stored_name = f"{digest[:8]}-{src.name}"
|
||||
shutil.copy2(src, dest_dir / stored_name)
|
||||
|
||||
new_rows.append(
|
||||
{
|
||||
"sha256": digest,
|
||||
"filename": stored_name,
|
||||
"label": label,
|
||||
"source": source,
|
||||
"model": model,
|
||||
"width": str(width),
|
||||
"height": str(height),
|
||||
"format": fmt,
|
||||
"c2pa_issuer": issuer,
|
||||
"synthid_metadata": "yes" if synthid else "",
|
||||
"verified_via": verified_via,
|
||||
"added": datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
|
||||
"notes": notes,
|
||||
}
|
||||
)
|
||||
added += 1
|
||||
flag = " [yellow](C2PA-SynthID)[/]" if synthid else ""
|
||||
console.print(f" [green]+[/] {label}/{stored_name} {width}x{height} {fmt}{flag}")
|
||||
|
||||
if new_rows:
|
||||
path = _manifest_path(root)
|
||||
write_header = not path.exists()
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with open(path, "a", newline="") as f:
|
||||
writer = csv.DictWriter(f, fieldnames=FIELDNAMES)
|
||||
if write_header:
|
||||
writer.writeheader()
|
||||
writer.writerows(new_rows)
|
||||
|
||||
console.print(f"\n Ingested [bold]{added}[/] image(s); skipped {len(images) - added} duplicate(s).")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--root", type=click.Path(path_type=Path), default=DEFAULT_ROOT, help="Corpus root.")
|
||||
def status(root: Path) -> None:
|
||||
"""Print corpus counts by label, resolution, and verification level."""
|
||||
rows = _read_manifest(root)
|
||||
if not rows:
|
||||
console.print(" [dim]Corpus is empty.[/]")
|
||||
return
|
||||
|
||||
by_label = Counter(r["label"] for r in rows)
|
||||
by_verified = Counter(r["verified_via"] for r in rows)
|
||||
by_res = Counter(f"{r['label']} {r['width']}x{r['height']}" for r in rows)
|
||||
|
||||
console.print(f"\n [bold]{len(rows)}[/] image(s) in {root}")
|
||||
|
||||
t = Table(title="By label", show_header=True, header_style="bold")
|
||||
t.add_column("Label")
|
||||
t.add_column("Count", justify="right")
|
||||
for k in LABELS:
|
||||
if by_label.get(k):
|
||||
t.add_row(k, str(by_label[k]))
|
||||
console.print(t)
|
||||
|
||||
t = Table(title="By label x resolution", show_header=True, header_style="bold")
|
||||
t.add_column("Label / resolution")
|
||||
t.add_column("Count", justify="right")
|
||||
for k, v in sorted(by_res.items()):
|
||||
t.add_row(k, str(v))
|
||||
console.print(t)
|
||||
|
||||
t = Table(title="By verification", show_header=True, header_style="bold")
|
||||
t.add_column("verified_via")
|
||||
t.add_column("Count", justify="right")
|
||||
for k, v in by_verified.most_common():
|
||||
t.add_row(k, str(v))
|
||||
console.print(t)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -0,0 +1,149 @@
|
||||
"""SynthID pixel-carrier probe -- EXPERIMENTAL / DIAGNOSTIC ONLY.
|
||||
|
||||
There is no local detector of the SynthID pixel watermark on real content: the
|
||||
carrier drowns in scene texture (see CLAUDE.md, confirmed repeatedly). This
|
||||
probe is meaningful ONLY on **solid-color fills**, where the per-pixel deviation
|
||||
from the image mean is essentially the watermark carrier (almost all the
|
||||
variance). It answers two controlled questions, neither of which is a
|
||||
real-content detector:
|
||||
|
||||
consistency IMAGES...
|
||||
Mean pairwise normalized cross-correlation (NCC) of the carriers across
|
||||
independent solid fills from one model, vs a random baseline. Genuine
|
||||
SynthID positives share a fixed carrier, so they correlate well above
|
||||
random (the pilot saw ~0.92 on gpt-image black fills); clean fills don't.
|
||||
|
||||
removal --pos P... --cleaned C...
|
||||
Build a carrier template from the positive fills, then compare how the
|
||||
positives and the pipeline-cleaned fills correlate to it. If removal
|
||||
worked, the cleaned correlation collapses toward the random baseline --
|
||||
pixel-domain evidence that the pipeline destroys the carrier, not just the
|
||||
C2PA metadata.
|
||||
|
||||
Do NOT run this on real-content images; the numbers are uninformative there.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from rich.console import Console
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
console = Console()
|
||||
|
||||
|
||||
def load_gray(path: str) -> NDArray[np.float64]:
|
||||
"""Load an image as a float64 grayscale array (mean of RGB channels)."""
|
||||
with Image.open(path) as img:
|
||||
return np.asarray(img.convert("RGB"), dtype=np.float64).mean(axis=2)
|
||||
|
||||
|
||||
def carrier(gray: NDArray[np.float64]) -> NDArray[np.float64]:
|
||||
"""Zero-mean, unit-norm residual of a solid-fill image -- its carrier.
|
||||
|
||||
Returns a flattened unit-norm vector for NCC comparison. A perfectly flat
|
||||
image (std 0, e.g. a synthetic #000000 reference) has no carrier and yields
|
||||
an all-zero vector, which correlates to 0 with everything.
|
||||
"""
|
||||
residual = gray - float(gray.mean())
|
||||
norm = float(np.linalg.norm(residual))
|
||||
if norm == 0.0:
|
||||
return residual.ravel()
|
||||
return (residual / norm).ravel()
|
||||
|
||||
|
||||
def ncc(a: NDArray[np.float64], b: NDArray[np.float64]) -> float:
|
||||
"""Normalized cross-correlation of two carriers (unit-norm zero-mean vectors)."""
|
||||
if a.shape != b.shape or a.size == 0:
|
||||
return 0.0
|
||||
return float(np.dot(a, b))
|
||||
|
||||
|
||||
def mean_pairwise_ncc(carriers: list[NDArray[np.float64]]) -> float:
|
||||
"""Average NCC over all distinct carrier pairs; 0.0 if fewer than two."""
|
||||
scores = [ncc(carriers[i], carriers[j]) for i in range(len(carriers)) for j in range(i + 1, len(carriers))]
|
||||
return float(np.mean(scores)) if scores else 0.0
|
||||
|
||||
|
||||
def template(carriers: list[NDArray[np.float64]]) -> NDArray[np.float64]:
|
||||
"""Average carrier, renormalized to unit norm (the shared-pattern estimate)."""
|
||||
avg = np.mean(carriers, axis=0)
|
||||
norm = float(np.linalg.norm(avg))
|
||||
return avg / norm if norm else avg
|
||||
|
||||
|
||||
def random_baseline(shape: tuple[int, ...], n: int, *, seed: int = 0) -> float:
|
||||
"""Mean pairwise NCC of ``n`` random-noise carriers of ``shape`` (~0)."""
|
||||
rng = np.random.default_rng(seed)
|
||||
noise = [carrier(rng.standard_normal(shape)) for _ in range(max(n, 2))]
|
||||
return mean_pairwise_ncc(noise)
|
||||
|
||||
|
||||
def _load_carriers(paths: tuple[str, ...]) -> list[NDArray[np.float64]]:
|
||||
"""Load carriers for same-shaped images; warn and skip mismatched shapes."""
|
||||
grays = [(p, load_gray(p)) for p in paths]
|
||||
shape = grays[0][1].shape
|
||||
carriers: list[NDArray[np.float64]] = []
|
||||
for p, g in grays:
|
||||
if g.shape != shape:
|
||||
console.print(f" [yellow]skip[/] {p}: shape {g.shape} != {shape}")
|
||||
continue
|
||||
carriers.append(carrier(g))
|
||||
return carriers
|
||||
|
||||
|
||||
@click.group()
|
||||
def cli() -> None:
|
||||
"""SynthID pixel-carrier probe (solid-color fills only)."""
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True))
|
||||
def consistency(images: tuple[str, ...]) -> None:
|
||||
"""Mean pairwise carrier NCC across solid fills, vs the random baseline."""
|
||||
carriers = _load_carriers(images)
|
||||
if len(carriers) < 2:
|
||||
console.print("[red]Need at least two same-shaped images.[/]")
|
||||
raise SystemExit(1)
|
||||
observed = mean_pairwise_ncc(carriers)
|
||||
baseline = random_baseline(carriers[0].shape, len(carriers))
|
||||
console.print(f" carriers: {len(carriers)}")
|
||||
console.print(f" mean pairwise NCC: [bold]{observed:.3f}[/]")
|
||||
console.print(f" random baseline: {baseline:.3f}")
|
||||
verdict = "shared carrier present" if observed > 0.3 else "no shared carrier (within noise)"
|
||||
console.print(f" verdict: [bold]{verdict}[/]")
|
||||
|
||||
|
||||
@cli.command()
|
||||
@click.option("--pos", "pos", multiple=True, required=True, type=click.Path(exists=True), help="Positive solid fills.")
|
||||
@click.option(
|
||||
"--cleaned", "cleaned", multiple=True, required=True, type=click.Path(exists=True), help="Pipeline-cleaned fills."
|
||||
)
|
||||
def removal(pos: tuple[str, ...], cleaned: tuple[str, ...]) -> None:
|
||||
"""Does the pipeline drop the carrier correlation toward the random baseline?"""
|
||||
pos_carriers = _load_carriers(pos)
|
||||
cleaned_carriers = _load_carriers(cleaned)
|
||||
if not pos_carriers or not cleaned_carriers:
|
||||
console.print("[red]Need at least one positive and one cleaned fill of matching shape.[/]")
|
||||
raise SystemExit(1)
|
||||
tmpl = template(pos_carriers)
|
||||
pos_corr = float(np.mean([ncc(c, tmpl) for c in pos_carriers]))
|
||||
cleaned_corr = float(np.mean([ncc(c, tmpl) for c in cleaned_carriers]))
|
||||
baseline = random_baseline(tmpl.shape, max(len(cleaned_carriers), 2))
|
||||
console.print(f" positive->template NCC: [bold]{pos_corr:.3f}[/]")
|
||||
console.print(f" cleaned->template NCC: [bold]{cleaned_corr:.3f}[/]")
|
||||
console.print(f" random baseline: {baseline:.3f}")
|
||||
effective = cleaned_corr < pos_corr / 2
|
||||
console.print(f" verdict: [bold]{'carrier attenuated' if effective else 'carrier survives'}[/]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
cli()
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
|
||||
|
||||
__version__ = "0.3.4"
|
||||
__version__ = "0.6.5"
|
||||
@@ -8,9 +8,11 @@ Provides commands for:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
@@ -20,6 +22,11 @@ from rich.table import Table
|
||||
|
||||
from remove_ai_watermarks import __version__
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.gemini_engine import DetectionResult, GeminiEngine
|
||||
|
||||
console = Console()
|
||||
|
||||
SUPPORTED_FORMATS = {".png", ".jpg", ".jpeg", ".webp"}
|
||||
@@ -55,6 +62,140 @@ def _validate_image(path: Path) -> Path:
|
||||
return path
|
||||
|
||||
|
||||
_ALPHA_FORMATS = {".png", ".webp"}
|
||||
|
||||
|
||||
def _watermark_region(det: DetectionResult, width: int, height: int) -> tuple[int, int, int, int]:
|
||||
"""Pick a watermark bbox: detector's region if confident, else the default config slot."""
|
||||
if det.confidence > 0.15:
|
||||
return det.region
|
||||
from remove_ai_watermarks.gemini_engine import get_watermark_config
|
||||
|
||||
config = get_watermark_config(width, height)
|
||||
px, py = config.get_position(width, height)
|
||||
return (px, py, config.logo_size, config.logo_size)
|
||||
|
||||
|
||||
def _read_bgr_and_alpha(path: Path) -> tuple[np.ndarray | None, np.ndarray | None]:
|
||||
"""Read an image preserving its alpha channel separately.
|
||||
|
||||
Returns ``(bgr, alpha)`` where ``alpha`` is a single-channel ndarray when the
|
||||
source has transparency, else ``None``. Greyscale inputs are promoted to BGR.
|
||||
Returns ``(None, None)`` if the image cannot be decoded.
|
||||
"""
|
||||
import cv2
|
||||
|
||||
image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
|
||||
if image is None:
|
||||
return None, None
|
||||
if image.ndim == 2:
|
||||
return cv2.cvtColor(image, cv2.COLOR_GRAY2BGR), None
|
||||
if image.shape[2] == 4:
|
||||
return image[:, :, :3].copy(), image[:, :, 3].copy()
|
||||
return image, None
|
||||
|
||||
|
||||
def _write_bgr_with_alpha(
|
||||
path: Path,
|
||||
bgr: np.ndarray,
|
||||
alpha: np.ndarray | None,
|
||||
clear_region: tuple[int, int, int, int] | None = None,
|
||||
pad: int = 6,
|
||||
) -> None:
|
||||
"""Write BGR (with optional alpha) to ``path``.
|
||||
|
||||
When ``alpha`` is provided and the output extension supports it, writes a
|
||||
4-channel image. If ``clear_region`` is given as ``(x, y, w, h)``, alpha is
|
||||
forced to 0 inside that bbox (expanded by ``pad`` px) so the watermark area
|
||||
becomes fully transparent in the saved file.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if alpha is None or path.suffix.lower() not in _ALPHA_FORMATS:
|
||||
cv2.imwrite(str(path), bgr)
|
||||
return
|
||||
|
||||
alpha_out = alpha
|
||||
if clear_region is not None:
|
||||
alpha_out = alpha.copy()
|
||||
x, y, w, h = clear_region
|
||||
height, width = alpha.shape[:2]
|
||||
x0, y0 = max(0, x - pad), max(0, y - pad)
|
||||
x1, y1 = min(width, x + w + pad), min(height, y + h + pad)
|
||||
if x1 > x0 and y1 > y0:
|
||||
alpha_out[y0:y1, x0:x1] = 0
|
||||
|
||||
bgra = np.dstack([bgr, alpha_out])
|
||||
cv2.imwrite(str(path), bgra)
|
||||
|
||||
|
||||
def _run_doubao_if_selected(
|
||||
ctx: click.Context,
|
||||
image: np.ndarray,
|
||||
alpha: np.ndarray | None,
|
||||
output: Path,
|
||||
mark: str,
|
||||
gemini_engine: GeminiEngine,
|
||||
detect: bool,
|
||||
detect_threshold: float,
|
||||
inpaint_method: str,
|
||||
strip_metadata: bool,
|
||||
) -> bool:
|
||||
"""Run the Doubao text-strip removal path when it is the selected mark.
|
||||
|
||||
Returns True when this path handled the image (caller should stop). In
|
||||
``auto`` mode the Doubao detector competes with the Gemini detector and wins
|
||||
only when it is both positive and at least as confident.
|
||||
"""
|
||||
from remove_ai_watermarks.doubao_engine import DoubaoEngine
|
||||
|
||||
doubao = DoubaoEngine()
|
||||
d_det = doubao.detect(image)
|
||||
|
||||
if mark == "auto":
|
||||
g_det = gemini_engine.detect_watermark(image)
|
||||
use_doubao = d_det.detected and d_det.confidence >= g_det.confidence
|
||||
console.print(
|
||||
f" [dim]Mark auto:[/] gemini={g_det.confidence:.2f} doubao={d_det.confidence:.2f} "
|
||||
f"-> {'doubao' if use_doubao else 'gemini'}"
|
||||
)
|
||||
else:
|
||||
use_doubao = mark == "doubao"
|
||||
|
||||
if not use_doubao:
|
||||
return False
|
||||
|
||||
if detect and not d_det.detected and d_det.confidence < detect_threshold:
|
||||
console.print(
|
||||
f" [yellow]⚠[/] Doubao mark not detected [dim](coverage {d_det.coverage:.1%}). "
|
||||
f"Use --no-detect to force.[/]"
|
||||
)
|
||||
raise SystemExit(0)
|
||||
|
||||
method: Literal["telea", "ns"] = "ns" if inpaint_method == "ns" else "telea"
|
||||
t0 = time.monotonic()
|
||||
with console.status("[cyan]Removing Doubao watermark…[/]"):
|
||||
result = doubao.remove_watermark(image, inpaint_method=method)
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
_write_bgr_with_alpha(output, result, alpha, clear_region=d_det.region)
|
||||
|
||||
if strip_metadata:
|
||||
try:
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
remove_ai_metadata(output, output)
|
||||
except Exception as e:
|
||||
if ctx.obj.get("verbose"):
|
||||
console.print(f" [yellow]⚠[/] Failed to strip metadata: {e}")
|
||||
|
||||
size_kb = output.stat().st_size / 1024
|
||||
console.print(f" [green]✓[/] Doubao mark removed → {output} [dim]({size_kb:.0f} KB, {elapsed:.2f}s)[/]")
|
||||
return True
|
||||
|
||||
|
||||
# ── Main group ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -92,6 +233,12 @@ def main(ctx: click.Context, verbose: bool) -> None:
|
||||
@click.option("--inpaint-strength", type=float, default=0.85, help="Inpainting blend strength (0.0-1.0).")
|
||||
@click.option("--detect/--no-detect", default=True, help="Detect watermark before removal.")
|
||||
@click.option("--detect-threshold", type=float, default=0.25, help="Detection confidence threshold.")
|
||||
@click.option(
|
||||
"--mark",
|
||||
type=click.Choice(["auto", "gemini", "doubao"]),
|
||||
default="auto",
|
||||
help="Which visible mark to target. auto picks the stronger of the two detectors.",
|
||||
)
|
||||
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
|
||||
@click.pass_context
|
||||
def cmd_visible(
|
||||
@@ -103,14 +250,15 @@ def cmd_visible(
|
||||
inpaint_strength: float,
|
||||
detect: bool,
|
||||
detect_threshold: float,
|
||||
mark: str,
|
||||
strip_metadata: bool,
|
||||
) -> None:
|
||||
"""Remove visible Gemini watermark (sparkle logo) from an image.
|
||||
"""Remove a visible AI watermark from an image.
|
||||
|
||||
Uses reverse alpha blending — fast, deterministic, offline.
|
||||
Targets the Gemini sparkle logo (reverse alpha blending) or the Doubao
|
||||
"豆包AI生成" text strip (locate -> mask -> inpaint). Fast, deterministic,
|
||||
offline. ``--mark auto`` picks whichever detector fires stronger.
|
||||
"""
|
||||
import cv2
|
||||
|
||||
from remove_ai_watermarks.gemini_engine import GeminiEngine
|
||||
|
||||
_banner()
|
||||
@@ -121,8 +269,8 @@ def cmd_visible(
|
||||
|
||||
engine = GeminiEngine()
|
||||
|
||||
# Load image
|
||||
image = cv2.imread(str(source), cv2.IMREAD_COLOR)
|
||||
# Load image (preserving any alpha channel separately)
|
||||
image, alpha = _read_bgr_and_alpha(source)
|
||||
if image is None:
|
||||
console.print(f"[red]Error:[/] Failed to read image: {source}")
|
||||
raise SystemExit(1)
|
||||
@@ -130,6 +278,12 @@ def cmd_visible(
|
||||
h, w = image.shape[:2]
|
||||
console.print(f" [dim]Input:[/] {source.name} ({w}x{h})")
|
||||
|
||||
# Resolve which visible mark to target, then run the Doubao path if chosen.
|
||||
if _run_doubao_if_selected(
|
||||
ctx, image, alpha, output, mark, engine, detect, detect_threshold, inpaint_method, strip_metadata
|
||||
):
|
||||
return
|
||||
|
||||
# Detection (we always detect softly, to find dynamic region for inpainting)
|
||||
with console.status("[cyan]Detecting watermark…[/]"):
|
||||
det = engine.detect_watermark(image)
|
||||
@@ -150,18 +304,12 @@ def cmd_visible(
|
||||
|
||||
# Removal
|
||||
t0 = time.monotonic()
|
||||
region: tuple[int, int, int, int] | None = None
|
||||
with console.status("[cyan]Removing watermark…[/]"):
|
||||
result = engine.remove_watermark(image)
|
||||
|
||||
if inpaint:
|
||||
if det.confidence > 0.15:
|
||||
region = det.region
|
||||
else:
|
||||
from remove_ai_watermarks.gemini_engine import get_watermark_config
|
||||
|
||||
config = get_watermark_config(w, h)
|
||||
pos = config.get_position(w, h)
|
||||
region = (pos[0], pos[1], config.logo_size, config.logo_size)
|
||||
region = _watermark_region(det, w, h)
|
||||
result = engine.inpaint_residual(
|
||||
result,
|
||||
region,
|
||||
@@ -171,9 +319,9 @@ def cmd_visible(
|
||||
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
# Save
|
||||
# Save (preserves transparency by clearing alpha in the watermark region)
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
cv2.imwrite(str(output), result)
|
||||
_write_bgr_with_alpha(output, result, alpha, clear_region=region)
|
||||
|
||||
# Strip metadata
|
||||
if strip_metadata:
|
||||
@@ -189,6 +337,98 @@ def cmd_visible(
|
||||
console.print(f" [green]✓[/] Saved: {output} [dim]({size_kb:.0f} KB, {elapsed:.2f}s)[/]")
|
||||
|
||||
|
||||
# ── Universal region eraser ─────────────────────────────────────────
|
||||
|
||||
|
||||
def _parse_region(spec: str) -> tuple[int, int, int, int]:
|
||||
"""Parse an ``x,y,w,h`` region string into a 4-int tuple."""
|
||||
parts = spec.replace(" ", "").split(",")
|
||||
if len(parts) != 4:
|
||||
raise click.BadParameter(f"region must be 'x,y,w,h', got: {spec!r}")
|
||||
try:
|
||||
x, y, w, h = (int(p) for p in parts)
|
||||
except ValueError as e:
|
||||
raise click.BadParameter(f"region values must be integers: {spec!r}") from e
|
||||
if w <= 0 or h <= 0:
|
||||
raise click.BadParameter(f"region width/height must be positive: {spec!r}")
|
||||
return x, y, w, h
|
||||
|
||||
|
||||
@main.command("erase")
|
||||
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
||||
@click.option("--region", "regions", multiple=True, required=True, help="x,y,w,h box to erase (repeatable).")
|
||||
@click.option(
|
||||
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
|
||||
)
|
||||
@click.option(
|
||||
"--backend",
|
||||
type=click.Choice(["cv2", "lama"]),
|
||||
default="cv2",
|
||||
help="Inpaint backend. cv2: instant, no deps. lama: onnxruntime big-LaMa, better quality (extra 'lama').",
|
||||
)
|
||||
@click.option("--inpaint-method", type=click.Choice(["telea", "ns"]), default="telea", help="cv2 inpaint method.")
|
||||
@click.option("--dilate", type=int, default=3, help="Grow the box by this many px before inpainting.")
|
||||
@click.option("--strip-metadata/--keep-metadata", default=True, help="Strip AI metadata from output.")
|
||||
@click.pass_context
|
||||
def cmd_erase(
|
||||
ctx: click.Context,
|
||||
source: Path,
|
||||
regions: tuple[str, ...],
|
||||
output: Path | None,
|
||||
backend: str,
|
||||
inpaint_method: str,
|
||||
dilate: int,
|
||||
strip_metadata: bool,
|
||||
) -> None:
|
||||
"""Erase arbitrary region(s) from an image via inpainting.
|
||||
|
||||
Universal and position-agnostic: removes any logo / watermark / object inside
|
||||
the boxes you pass, regardless of colour or location. Runs on CPU. Use this
|
||||
for marks the dedicated ``visible`` engines (Gemini, Doubao) do not cover.
|
||||
"""
|
||||
from remove_ai_watermarks.region_eraser import erase
|
||||
|
||||
_banner()
|
||||
source = _validate_image(source)
|
||||
if output is None:
|
||||
output = source.with_stem(source.stem + "_clean")
|
||||
|
||||
boxes = [_parse_region(r) for r in regions]
|
||||
|
||||
image, alpha = _read_bgr_and_alpha(source)
|
||||
if image is None:
|
||||
console.print(f"[red]Error:[/] Failed to read image: {source}")
|
||||
raise SystemExit(1)
|
||||
h, w = image.shape[:2]
|
||||
console.print(f" [dim]Input:[/] {source.name} ({w}x{h}) [dim]{len(boxes)} region(s), backend={backend}[/]")
|
||||
|
||||
t0 = time.monotonic()
|
||||
method: Literal["telea", "ns"] = "ns" if inpaint_method == "ns" else "telea"
|
||||
try:
|
||||
with console.status(f"[cyan]Erasing ({backend})…[/]"):
|
||||
result = erase(image, boxes=boxes, backend=backend, dilate=dilate, cv2_method=method)
|
||||
except RuntimeError as e:
|
||||
console.print(f" [red]Error:[/] {e}")
|
||||
raise SystemExit(1) from e
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
clear = boxes[0] if len(boxes) == 1 else None
|
||||
_write_bgr_with_alpha(output, result, alpha, clear_region=clear)
|
||||
|
||||
if strip_metadata:
|
||||
try:
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
remove_ai_metadata(output, output)
|
||||
except Exception as e:
|
||||
if ctx.obj.get("verbose"):
|
||||
console.print(f" [yellow]⚠[/] Failed to strip metadata: {e}")
|
||||
|
||||
size_kb = output.stat().st_size / 1024
|
||||
console.print(f" [green]✓[/] Erased {len(boxes)} region(s) → {output} [dim]({size_kb:.0f} KB, {elapsed:.2f}s)[/]")
|
||||
|
||||
|
||||
# ── Invisible watermark removal ─────────────────────────────────────
|
||||
|
||||
|
||||
@@ -197,15 +437,26 @@ def cmd_visible(
|
||||
@click.option(
|
||||
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
|
||||
)
|
||||
@click.option("--strength", type=float, default=0.02, help="Denoising strength (0.0-1.0). Default: 0.02.")
|
||||
@click.option("--steps", type=int, default=100, help="Number of denoising steps. Default: 100.")
|
||||
@click.option("--pipeline", type=click.Choice(["default", "ctrlregen"]), default="default", help="Pipeline profile.")
|
||||
@click.option("--strength", type=float, default=0.05, help="Denoising strength (0.0-1.0). Default: 0.05.")
|
||||
@click.option("--steps", type=int, default=50, help="Number of denoising steps. Default: 50.")
|
||||
@click.option(
|
||||
"--pipeline",
|
||||
type=click.Choice(["default", "ctrlregen"]),
|
||||
default="default",
|
||||
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).",
|
||||
)
|
||||
@click.option("--device", type=click.Choice(["auto", "cpu", "mps", "cuda"]), default="auto", help="Inference device.")
|
||||
@click.option("--seed", type=int, default=None, help="Random seed for reproducibility.")
|
||||
@click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
|
||||
@click.option(
|
||||
"--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
|
||||
)
|
||||
@click.option(
|
||||
"--max-resolution",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Cap long side (px) before diffusion; 0 = native (best quality, like raiw.cc). Raise only on GPU/MPS OOM.",
|
||||
)
|
||||
@click.pass_context
|
||||
def cmd_invisible(
|
||||
ctx: click.Context,
|
||||
@@ -218,6 +469,7 @@ def cmd_invisible(
|
||||
seed: int | None,
|
||||
hf_token: str | None,
|
||||
humanize: float,
|
||||
max_resolution: int,
|
||||
) -> None:
|
||||
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
|
||||
|
||||
@@ -264,6 +516,7 @@ def cmd_invisible(
|
||||
guidance_scale=None,
|
||||
seed=seed,
|
||||
humanize=humanize,
|
||||
max_resolution=max_resolution,
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
|
||||
@@ -305,6 +558,8 @@ def cmd_metadata(
|
||||
if has_ai:
|
||||
console.print(f" [yellow]⚠[/] AI metadata detected in {source.name}:")
|
||||
meta = get_ai_metadata(source)
|
||||
if synthid := meta.get("synthid_watermark"):
|
||||
console.print(f" [bold yellow]⚠ SynthID pixel watermark {synthid}[/]")
|
||||
table = Table(show_header=True, header_style="bold")
|
||||
table.add_column("Key", style="cyan")
|
||||
table.add_column("Value")
|
||||
@@ -322,6 +577,59 @@ def cmd_metadata(
|
||||
console.print(f" [green]✓[/] AI metadata stripped → {out}")
|
||||
|
||||
|
||||
# ── Provenance identification ───────────────────────────────────────
|
||||
|
||||
|
||||
@main.command("identify")
|
||||
@click.argument("source", type=click.Path(exists=True, path_type=Path))
|
||||
@click.option(
|
||||
"--no-visible",
|
||||
is_flag=True,
|
||||
help="Skip pixel-domain detectors (visible sparkle + invisible watermark); metadata-only.",
|
||||
)
|
||||
@click.option("--json", "as_json", is_flag=True, help="Emit the report as JSON instead of a table.")
|
||||
@click.pass_context
|
||||
def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bool) -> None:
|
||||
"""Identify where an image was made and what watermarks it carries.
|
||||
|
||||
Aggregates C2PA Content Credentials, IPTC "Made with AI" tags, embedded
|
||||
generation parameters, the SynthID metadata proxy, and the visible Gemini
|
||||
sparkle into a single provenance verdict. Absence of signals is reported as
|
||||
"unknown", never as "clean" (stripped metadata leaves no local proof).
|
||||
"""
|
||||
from dataclasses import asdict
|
||||
|
||||
from remove_ai_watermarks.identify import identify
|
||||
|
||||
source = _validate_image(source)
|
||||
report = identify(source, check_visible=not no_visible, check_invisible=not no_visible)
|
||||
|
||||
if as_json:
|
||||
click.echo(json.dumps(asdict(report), default=str, indent=2))
|
||||
return
|
||||
|
||||
_banner()
|
||||
verdict = {True: "[yellow]AI-generated[/]", False: "[green]not AI[/]", None: "[dim]unknown[/]"}[
|
||||
report.is_ai_generated
|
||||
]
|
||||
console.print(f"\n Verdict: {verdict} [dim](confidence: {report.confidence})[/]")
|
||||
console.print(f" Platform: {report.platform or '[dim]undetermined[/]'}")
|
||||
|
||||
if report.watermarks:
|
||||
table = Table(show_header=True, header_style="bold", title="Watermarks / provenance markers")
|
||||
table.add_column("Marker", style="cyan")
|
||||
for wm in report.watermarks:
|
||||
table.add_row(wm)
|
||||
console.print(table)
|
||||
else:
|
||||
console.print(" [dim]No watermarks or provenance markers found.[/]")
|
||||
|
||||
if report.caveats:
|
||||
console.print("\n [dim]Caveats:[/]")
|
||||
for c in report.caveats:
|
||||
console.print(f" [dim]- {c}[/]")
|
||||
|
||||
|
||||
# ── Combined "all" mode ──────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -334,13 +642,13 @@ def cmd_metadata(
|
||||
@click.option(
|
||||
"--inpaint-method", type=click.Choice(["ns", "telea", "gaussian"]), default="ns", help="Inpainting method."
|
||||
)
|
||||
@click.option("--strength", type=float, default=0.02, help="Invisible watermark denoising strength (0.0-1.0).")
|
||||
@click.option("--steps", type=int, default=100, help="Number of denoising steps for invisible removal.")
|
||||
@click.option("--strength", type=float, default=0.05, help="Invisible watermark denoising strength (0.0-1.0).")
|
||||
@click.option("--steps", type=int, default=50, help="Number of denoising steps for invisible removal.")
|
||||
@click.option(
|
||||
"--pipeline",
|
||||
type=click.Choice(["default", "ctrlregen"]),
|
||||
default="default",
|
||||
help="Pipeline profile for invisible removal.",
|
||||
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).",
|
||||
)
|
||||
@click.option("--model", type=str, default=None, help="HuggingFace model ID for invisible removal.")
|
||||
@click.option("--device", type=click.Choice(["auto", "cpu", "mps", "cuda"]), default="auto", help="Inference device.")
|
||||
@@ -349,6 +657,12 @@ def cmd_metadata(
|
||||
@click.option(
|
||||
"--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
|
||||
)
|
||||
@click.option(
|
||||
"--max-resolution",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Cap long side (px) before diffusion; 0 = native (best quality, like raiw.cc). Raise only on GPU/MPS OOM.",
|
||||
)
|
||||
@click.pass_context
|
||||
def cmd_all(
|
||||
ctx: click.Context,
|
||||
@@ -364,6 +678,7 @@ def cmd_all(
|
||||
seed: int | None,
|
||||
hf_token: str | None,
|
||||
humanize: float,
|
||||
max_resolution: int,
|
||||
) -> None:
|
||||
"""Remove ALL watermarks: visible + invisible + metadata.
|
||||
|
||||
@@ -374,9 +689,7 @@ def cmd_all(
|
||||
|
||||
If invisible watermark deps are not installed, skips step 2 with a warning.
|
||||
"""
|
||||
import cv2
|
||||
|
||||
from remove_ai_watermarks.gemini_engine import GeminiEngine, get_watermark_config
|
||||
from remove_ai_watermarks.gemini_engine import GeminiEngine
|
||||
|
||||
_banner()
|
||||
source = _validate_image(source)
|
||||
@@ -400,7 +713,7 @@ def cmd_all(
|
||||
# ── Step 1: Visible watermark ────────────────────────────────
|
||||
console.print("\n [bold cyan]① Visible watermark removal[/]")
|
||||
engine = GeminiEngine()
|
||||
image = cv2.imread(str(source), cv2.IMREAD_COLOR)
|
||||
image, alpha = _read_bgr_and_alpha(source)
|
||||
if image is None:
|
||||
console.print(f"[red]Error:[/] Failed to read image: {source}")
|
||||
raise SystemExit(1)
|
||||
@@ -408,25 +721,21 @@ def cmd_all(
|
||||
h, w = image.shape[:2]
|
||||
console.print(f" [dim]Input:[/] {source.name} ({w}x{h})")
|
||||
|
||||
region: tuple[int, int, int, int] | None = None
|
||||
with console.status("[cyan]Removing visible watermark…[/]"):
|
||||
det = engine.detect_watermark(image)
|
||||
if det.detected:
|
||||
result = engine.remove_watermark(image)
|
||||
if inpaint:
|
||||
if det.confidence > 0.15:
|
||||
region = det.region
|
||||
else:
|
||||
config = get_watermark_config(w, h)
|
||||
pos = config.get_position(w, h)
|
||||
region = (pos[0], pos[1], config.logo_size, config.logo_size)
|
||||
region = _watermark_region(det, w, h)
|
||||
result = engine.inpaint_residual(result, region, method=inpaint_method)
|
||||
console.print(" [green]✓[/] Visible watermark removed")
|
||||
else:
|
||||
result = image.copy()
|
||||
console.print(" [dim]Skipped (no visible watermark detected)[/]")
|
||||
|
||||
# Save to temp file for invisible engine input
|
||||
cv2.imwrite(str(tmp_path), result)
|
||||
# Save to temp file for invisible engine input (preserve alpha if present)
|
||||
_write_bgr_with_alpha(tmp_path, result, alpha, clear_region=region)
|
||||
|
||||
# ── Step 2: Invisible watermark ──────────────────────────────
|
||||
console.print("\n [bold cyan]② Invisible watermark removal[/]")
|
||||
@@ -461,6 +770,7 @@ def cmd_all(
|
||||
num_inference_steps=steps,
|
||||
seed=seed,
|
||||
humanize=humanize,
|
||||
max_resolution=max_resolution,
|
||||
)
|
||||
console.print(" [green]✓[/] Invisible watermark removed")
|
||||
|
||||
@@ -475,10 +785,15 @@ def cmd_all(
|
||||
console.print(f" [yellow]⚠[/] Metadata strip failed: {e}")
|
||||
|
||||
# ── Write final result ────────────────────────────────────────
|
||||
import shutil
|
||||
|
||||
# The invisible step (and downstream cv2.IMREAD_COLOR paths) drops alpha,
|
||||
# so re-attach the original alpha (with the watermark region cleared)
|
||||
# when writing the final output for transparent formats.
|
||||
output.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(tmp_path), str(output))
|
||||
final_bgr, _ = _read_bgr_and_alpha(tmp_path)
|
||||
if final_bgr is None:
|
||||
console.print(f"[red]Error:[/] Failed to read intermediate file: {tmp_path}")
|
||||
raise SystemExit(1)
|
||||
_write_bgr_with_alpha(output, final_bgr, alpha, clear_region=region)
|
||||
|
||||
finally:
|
||||
# Clean up temp file if it still exists
|
||||
@@ -507,6 +822,7 @@ def _process_batch_image(
|
||||
seed: int | None,
|
||||
hf_token: str | None,
|
||||
humanize: float,
|
||||
max_resolution: int = 0,
|
||||
) -> None:
|
||||
"""Process a single image for batch mode.
|
||||
|
||||
@@ -516,13 +832,11 @@ def _process_batch_image(
|
||||
Raises:
|
||||
ValueError: If the image cannot be opened.
|
||||
"""
|
||||
if mode in ("visible", "all"):
|
||||
import cv2
|
||||
saved_alpha: np.ndarray | None = None
|
||||
saved_region: tuple[int, int, int, int] | None = None
|
||||
|
||||
from remove_ai_watermarks.gemini_engine import (
|
||||
GeminiEngine,
|
||||
get_watermark_config,
|
||||
)
|
||||
if mode in ("visible", "all"):
|
||||
from remove_ai_watermarks.gemini_engine import GeminiEngine
|
||||
|
||||
if "_vis_engine" not in ctx.obj:
|
||||
ctx.obj["_vis_engine"] = GeminiEngine()
|
||||
@@ -530,27 +844,24 @@ def _process_batch_image(
|
||||
read_path = img_path
|
||||
if mode == "all" and out_path.exists():
|
||||
read_path = out_path
|
||||
image = cv2.imread(str(read_path), cv2.IMREAD_COLOR)
|
||||
image, alpha = _read_bgr_and_alpha(read_path)
|
||||
if image is None:
|
||||
raise ValueError("Failed to read image")
|
||||
|
||||
region: tuple[int, int, int, int] | None = None
|
||||
det = engine.detect_watermark(image)
|
||||
if det.detected:
|
||||
result = engine.remove_watermark(image)
|
||||
if inpaint:
|
||||
if det.confidence > 0.15:
|
||||
region = det.region
|
||||
else:
|
||||
h, w = image.shape[:2]
|
||||
config = get_watermark_config(w, h)
|
||||
pos = config.get_position(w, h)
|
||||
region = (pos[0], pos[1], config.logo_size, config.logo_size)
|
||||
|
||||
h, w = image.shape[:2]
|
||||
region = _watermark_region(det, w, h)
|
||||
result = engine.inpaint_residual(result, region)
|
||||
else:
|
||||
result = image.copy()
|
||||
|
||||
cv2.imwrite(str(out_path), result)
|
||||
_write_bgr_with_alpha(out_path, result, alpha, clear_region=region)
|
||||
saved_alpha = alpha
|
||||
saved_region = region
|
||||
|
||||
if mode in ("invisible", "all"):
|
||||
from remove_ai_watermarks.invisible_engine import (
|
||||
@@ -574,6 +885,7 @@ def _process_batch_image(
|
||||
num_inference_steps=steps,
|
||||
seed=seed,
|
||||
humanize=humanize,
|
||||
max_resolution=max_resolution,
|
||||
)
|
||||
|
||||
if mode in ("metadata", "all"):
|
||||
@@ -581,6 +893,13 @@ def _process_batch_image(
|
||||
|
||||
remove_ai_metadata(img_path if mode == "metadata" else out_path, out_path)
|
||||
|
||||
# In "all" mode, the invisible step (color-only OpenCV paths) drops alpha,
|
||||
# so re-attach the cached alpha when the input had transparency.
|
||||
if mode == "all" and saved_alpha is not None:
|
||||
final_bgr, _ = _read_bgr_and_alpha(out_path)
|
||||
if final_bgr is not None:
|
||||
_write_bgr_with_alpha(out_path, final_bgr, saved_alpha, clear_region=saved_region)
|
||||
|
||||
|
||||
@main.command("batch")
|
||||
@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
||||
@@ -600,10 +919,21 @@ def _process_batch_image(
|
||||
@click.option(
|
||||
"--humanize", type=float, default=0.0, help="Analog Humanizer film grain intensity (0 = off, typical: 2.0-6.0)."
|
||||
)
|
||||
@click.option("--pipeline", type=click.Choice(["default", "ctrlregen"]), default="default", help="Pipeline profile.")
|
||||
@click.option(
|
||||
"--pipeline",
|
||||
type=click.Choice(["default", "ctrlregen"]),
|
||||
default="default",
|
||||
help="Pipeline profile (default=SDXL, ctrlregen=CtrlRegen).",
|
||||
)
|
||||
@click.option("--device", type=click.Choice(["auto", "cpu", "mps", "cuda"]), default="auto", help="Inference device.")
|
||||
@click.option("--seed", type=int, default=None, help="Random seed for reproducibility.")
|
||||
@click.option("--hf-token", type=str, default=None, help="HuggingFace API token.")
|
||||
@click.option(
|
||||
"--max-resolution",
|
||||
type=int,
|
||||
default=0,
|
||||
help="Cap long side (px) before diffusion; 0 = native (best quality, like raiw.cc). Raise only on GPU/MPS OOM.",
|
||||
)
|
||||
@click.pass_context
|
||||
def cmd_batch(
|
||||
ctx: click.Context,
|
||||
@@ -618,6 +948,7 @@ def cmd_batch(
|
||||
hf_token: str | None,
|
||||
inpaint: bool,
|
||||
humanize: float,
|
||||
max_resolution: int,
|
||||
) -> None:
|
||||
"""Process all images in a directory."""
|
||||
_banner()
|
||||
@@ -667,6 +998,7 @@ def cmd_batch(
|
||||
seed=seed,
|
||||
hf_token=hf_token,
|
||||
humanize=humanize,
|
||||
max_resolution=max_resolution,
|
||||
)
|
||||
processed += 1
|
||||
|
||||
|
||||
@@ -0,0 +1,245 @@
|
||||
"""Doubao visible watermark removal engine.
|
||||
|
||||
Doubao (ByteDance) stamps every generated image with a visible "豆包AI生成"
|
||||
(Doubao AI generated) text strip in the bottom-right corner. This is the
|
||||
explicit AIGC label mandated by China's TC260 standard, rendered as a
|
||||
near-white / light-gray, low-saturation text overlay.
|
||||
|
||||
Unlike the Gemini sparkle (a fixed square logo removed by reverse alpha
|
||||
blending against a captured alpha map), the Doubao mark is a text strip whose
|
||||
exact alpha map we do not yet have. This engine therefore removes it by:
|
||||
|
||||
locate -> mask -> inpaint
|
||||
|
||||
1. Locate: the mark scales with image WIDTH and sits in the bottom-right at a
|
||||
fixed margin, so we anchor a generous box there (geometry only -- no bundled
|
||||
template). Constants below are derived from measured Doubao output.
|
||||
2. Mask: within the box, extract the light, low-saturation glyph pixels with a
|
||||
polarity-aware rule (the mark is brighter than dark backgrounds and a
|
||||
distinct off-white gray against light backgrounds).
|
||||
3. Inpaint: cv2 inpainting (TELEA / NS) reconstructs the covered pixels.
|
||||
|
||||
This is fast, offline, deterministic, and needs no GPU. A future upgrade path
|
||||
is per-pixel reverse alpha blending once a Doubao alpha map is captured on a
|
||||
controlled black background (see data/doubao_capture/), which would recover the
|
||||
true pixels instead of hallucinating them -- the same approach as the Gemini
|
||||
engine.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# Geometry as a fraction of image WIDTH. The Doubao mark scales with width and
|
||||
# is anchored bottom-right. The box is intentionally generous (the glyph mask
|
||||
# tightens it); values cover measured outputs across resolutions and aspect
|
||||
# ratios (square 2048, portrait, ultra-wide). Margins are width-relative too.
|
||||
WM_WIDTH_FRAC = 0.185
|
||||
WM_HEIGHT_FRAC = 0.065
|
||||
MARGIN_RIGHT_FRAC = 0.012
|
||||
MARGIN_BOTTOM_FRAC = 0.014
|
||||
|
||||
# Glyph appearance: the label is a low-saturation light gray, rendered brighter
|
||||
# than the surrounding content (the common case: a generated photo/illustration).
|
||||
# We detect it as a local bright feature (white top-hat: brighter than a blurred
|
||||
# local background) intersected with the grayish + minimum-brightness tests.
|
||||
# This is polarity-correct for bright-on-darker backgrounds and, crucially,
|
||||
# leaves white-paper documents untouched (there the mark is not brighter than
|
||||
# its surroundings, so nothing is masked rather than damaging the document text).
|
||||
MAX_SATURATION = 55 # max channel spread to count a pixel as "grayish"
|
||||
LOGO_MIN_LUMA = 150 # glyphs are at least this bright in absolute terms
|
||||
TOPHAT_DELTA = 12 # glyph must exceed the local background by this many levels
|
||||
|
||||
# Detection: a genuine label fills a meaningful fraction of the box. Measured
|
||||
# coverage is >=0.20 on real Doubao outputs; random/textured corners stay <=0.06
|
||||
# on large images but can spike to ~0.15 on tiny ones (small box -> high variance),
|
||||
# so the threshold sits above that spike and below the real-mark floor.
|
||||
DETECT_MIN_COVERAGE = 0.16
|
||||
|
||||
# Safety: a text strip fills a modest slice of the (generous) box. When the box
|
||||
# is over a dense-text / document background the mask explodes and cv2 inpainting
|
||||
# would smear the real content. Above this coverage we refuse to inpaint and
|
||||
# leave the image untouched -- that hard case needs the neural path, not a guess.
|
||||
MAX_INPAINT_COVERAGE = 0.50
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DoubaoLocation:
|
||||
"""Located watermark box (bottom-right), in absolute pixel coordinates."""
|
||||
|
||||
x: int
|
||||
y: int
|
||||
w: int
|
||||
h: int
|
||||
is_fallback: bool = True # geometry anchor (no template match) -> always True for now
|
||||
|
||||
@property
|
||||
def bbox(self) -> tuple[int, int, int, int]:
|
||||
return self.x, self.y, self.w, self.h
|
||||
|
||||
|
||||
@dataclass
|
||||
class DoubaoDetection:
|
||||
"""Result of visible Doubao watermark detection."""
|
||||
|
||||
detected: bool = False
|
||||
confidence: float = 0.0
|
||||
region: tuple[int, int, int, int] = (0, 0, 0, 0)
|
||||
coverage: float = 0.0 # fraction of the box occupied by glyph pixels
|
||||
|
||||
|
||||
class DoubaoEngine:
|
||||
"""Remove the visible Doubao "豆包AI生成" watermark (locate -> mask -> inpaint)."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
*,
|
||||
width_frac: float = WM_WIDTH_FRAC,
|
||||
height_frac: float = WM_HEIGHT_FRAC,
|
||||
margin_right_frac: float = MARGIN_RIGHT_FRAC,
|
||||
margin_bottom_frac: float = MARGIN_BOTTOM_FRAC,
|
||||
) -> None:
|
||||
self.width_frac = width_frac
|
||||
self.height_frac = height_frac
|
||||
self.margin_right_frac = margin_right_frac
|
||||
self.margin_bottom_frac = margin_bottom_frac
|
||||
|
||||
# ── Locate ────────────────────────────────────────────────────────
|
||||
|
||||
def locate(self, image: NDArray) -> DoubaoLocation:
|
||||
"""Anchor the watermark box in the bottom-right corner by geometry."""
|
||||
h, w = image.shape[:2]
|
||||
wm_w = max(40, int(w * self.width_frac))
|
||||
wm_h = max(16, int(w * self.height_frac))
|
||||
margin_r = max(4, int(w * self.margin_right_frac))
|
||||
margin_b = max(4, int(w * self.margin_bottom_frac))
|
||||
x = max(0, w - margin_r - wm_w)
|
||||
y = max(0, h - margin_b - wm_h)
|
||||
wm_w = min(wm_w, w - x)
|
||||
wm_h = min(wm_h, h - y)
|
||||
return DoubaoLocation(x=x, y=y, w=wm_w, h=wm_h, is_fallback=True)
|
||||
|
||||
# ── Mask ──────────────────────────────────────────────────────────
|
||||
|
||||
def extract_mask(self, image: NDArray, loc: DoubaoLocation) -> NDArray:
|
||||
"""Build a full-image uint8 mask (255 = watermark glyph) for the box.
|
||||
|
||||
Polarity-aware: the mark is a light, low-saturation gray. On a dark
|
||||
background it is the bright region; on a light background it is the
|
||||
off-white gray below paper-white. Both cases are captured by the logo
|
||||
luminance band intersected with the grayish constraint, plus a
|
||||
brighter-than-local-background test on dark backgrounds.
|
||||
"""
|
||||
h, w = image.shape[:2]
|
||||
x, y, bw, bh = loc.bbox
|
||||
roi = image[y : y + bh, x : x + bw].astype(np.float32)
|
||||
|
||||
luma = roi.mean(axis=2)
|
||||
sat = roi.max(axis=2) - roi.min(axis=2)
|
||||
grayish = sat < MAX_SATURATION
|
||||
|
||||
# Local background model: a strong Gaussian blur (sigma ~ box height)
|
||||
# approximates the content under the glyphs. The white top-hat
|
||||
# (luma - local_bg) lights up bright thin strokes regardless of the
|
||||
# absolute background level.
|
||||
sigma = max(4.0, bh * 0.4)
|
||||
local_bg = cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma)
|
||||
tophat = luma - local_bg
|
||||
|
||||
cand = grayish & (tophat > TOPHAT_DELTA) & (luma > LOGO_MIN_LUMA)
|
||||
glyph = cand.astype(np.uint8) * 255
|
||||
# Connect glyph parts, then drop isolated specks (5x5 open clears the
|
||||
# scattered grayish pixels that random/textured corners produce).
|
||||
glyph = cv2.morphologyEx(glyph, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8))
|
||||
glyph = cv2.morphologyEx(glyph, cv2.MORPH_OPEN, np.ones((5, 5), np.uint8))
|
||||
|
||||
mask = np.zeros((h, w), np.uint8)
|
||||
mask[y : y + bh, x : x + bw] = glyph
|
||||
return mask
|
||||
|
||||
# ── Detect ────────────────────────────────────────────────────────
|
||||
|
||||
def detect(self, image: NDArray) -> DoubaoDetection:
|
||||
"""Detect the visible Doubao mark by glyph coverage in the corner box.
|
||||
|
||||
Heuristic: a genuine label fills a meaningful fraction of the box with
|
||||
text-like glyph pixels. Coverage maps to a confidence score.
|
||||
"""
|
||||
det = DoubaoDetection()
|
||||
if image is None or image.size == 0:
|
||||
return det
|
||||
loc = self.locate(image)
|
||||
mask = self.extract_mask(image, loc)
|
||||
x, y, bw, bh = loc.bbox
|
||||
box = mask[y : y + bh, x : x + bw]
|
||||
coverage = float((box > 0).sum()) / float(max(1, bw * bh))
|
||||
det.region = loc.bbox
|
||||
det.coverage = coverage
|
||||
# Map coverage to a 0-1 confidence: ~0.06 (noise floor) -> 0, ~0.26 -> 1.
|
||||
det.confidence = float(max(0.0, min(1.0, (coverage - 0.06) / 0.20)))
|
||||
det.detected = coverage >= DETECT_MIN_COVERAGE
|
||||
logger.debug("Doubao detect: coverage=%.3f conf=%.3f", coverage, det.confidence)
|
||||
return det
|
||||
|
||||
# ── Remove ────────────────────────────────────────────────────────
|
||||
|
||||
def remove_watermark(
|
||||
self,
|
||||
image: NDArray,
|
||||
*,
|
||||
inpaint_method: Literal["telea", "ns"] = "telea",
|
||||
inpaint_radius: int = 6,
|
||||
dilate: int = 3,
|
||||
) -> NDArray:
|
||||
"""Remove the visible Doubao watermark by inpainting the glyph mask.
|
||||
|
||||
Returns an unmodified copy when no glyph pixels are found (so we never
|
||||
smear a clean corner). ``dilate`` grows the mask to cover anti-aliased
|
||||
glyph edges before inpainting.
|
||||
"""
|
||||
if image is None or image.size == 0:
|
||||
return image
|
||||
loc = self.locate(image)
|
||||
mask = self.extract_mask(image, loc)
|
||||
if not mask.any():
|
||||
logger.debug("Doubao remove: no glyph pixels found; returning copy")
|
||||
return image.copy()
|
||||
|
||||
x, y, bw, bh = loc.bbox
|
||||
coverage = float((mask[y : y + bh, x : x + bw] > 0).sum()) / float(max(1, bw * bh))
|
||||
if coverage > MAX_INPAINT_COVERAGE:
|
||||
logger.warning(
|
||||
"Doubao remove: box coverage %.2f exceeds %.2f (dense-text/document "
|
||||
"background); leaving image untouched to avoid smearing content",
|
||||
coverage,
|
||||
MAX_INPAINT_COVERAGE,
|
||||
)
|
||||
return image.copy()
|
||||
|
||||
if dilate > 0:
|
||||
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilate + 1, 2 * dilate + 1))
|
||||
mask = cv2.dilate(mask, k)
|
||||
|
||||
flag = cv2.INPAINT_TELEA if inpaint_method == "telea" else cv2.INPAINT_NS
|
||||
return cv2.inpaint(image, mask, inpaint_radius, flag)
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
img = cv2.imread(str(path), cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -309,12 +309,19 @@ class GeminiEngine:
|
||||
) -> NDArray:
|
||||
"""Remove Gemini visible watermark from an image using reverse alpha blending.
|
||||
|
||||
No-op when the detector does not find a watermark: returns an unmodified
|
||||
copy. Reverse alpha blending applied where no sparkle exists creates a
|
||||
visible inverse artifact, so we refuse to touch pixels without a positive
|
||||
detection. To bypass detection (e.g. you know the exact region), use
|
||||
``remove_watermark_custom``.
|
||||
|
||||
Args:
|
||||
image: BGR image as numpy array (will NOT be modified in-place).
|
||||
force_size: Force a specific watermark size (auto-detect if None).
|
||||
|
||||
Returns:
|
||||
Cleaned BGR image as numpy array.
|
||||
Cleaned BGR image as numpy array, or an unmodified copy when no
|
||||
watermark is detected.
|
||||
"""
|
||||
result = image.copy()
|
||||
|
||||
@@ -324,35 +331,28 @@ class GeminiEngine:
|
||||
elif result.shape[2] == 1:
|
||||
result = cv2.cvtColor(result, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
h, w = result.shape[:2]
|
||||
size = force_size or get_watermark_size(w, h)
|
||||
size = force_size or get_watermark_size(result.shape[1], result.shape[0])
|
||||
|
||||
# Detect dynamic position & size
|
||||
detection = self.detect_watermark(image, force_size=size)
|
||||
|
||||
# If the confidence is really high (>0.15), use the dynamically detected position and size.
|
||||
if detection.confidence > 0.15:
|
||||
pos = (detection.region[0], detection.region[1])
|
||||
alpha_map = self.get_interpolated_alpha(detection.region[2])
|
||||
if not detection.detected:
|
||||
logger.debug(
|
||||
"Using dynamic watermark position (%d, %d) at size %dx%d [conf=%.3f]",
|
||||
pos[0],
|
||||
pos[1],
|
||||
detection.region[2],
|
||||
detection.region[3],
|
||||
"No watermark detected (conf=%.3f); returning image unchanged.",
|
||||
detection.confidence,
|
||||
)
|
||||
else:
|
||||
config = get_watermark_config(w, h)
|
||||
pos = config.get_position(w, h)
|
||||
alpha_map = self.get_alpha_map(size)
|
||||
logger.debug(
|
||||
"Dynamic search failed. Using fallback default position (%d, %d) with %dx%d alpha map.",
|
||||
pos[0],
|
||||
pos[1],
|
||||
alpha_map.shape[1],
|
||||
alpha_map.shape[0],
|
||||
)
|
||||
return result
|
||||
|
||||
pos = (detection.region[0], detection.region[1])
|
||||
alpha_map = self.get_interpolated_alpha(detection.region[2])
|
||||
logger.debug(
|
||||
"Removing watermark at (%d, %d) size %dx%d [conf=%.3f]",
|
||||
pos[0],
|
||||
pos[1],
|
||||
detection.region[2],
|
||||
detection.region[3],
|
||||
detection.confidence,
|
||||
)
|
||||
|
||||
self._reverse_alpha_blend(result, alpha_map, pos)
|
||||
return result
|
||||
@@ -546,3 +546,17 @@ class GeminiEngine:
|
||||
result[py1:py2, px1:px2] = blended.astype(np.uint8)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def detect_sparkle_confidence(image_path: Path) -> float | None:
|
||||
"""Visible-sparkle detection confidence for a file, for provenance use.
|
||||
|
||||
Loads the image with cv2 and runs :meth:`GeminiEngine.detect_watermark`.
|
||||
Returns the NCC confidence in [0, 1], or None if the image cannot be read
|
||||
(cv2 returns None for unsupported containers such as HEIC). Kept here so the
|
||||
cv2 dependency stays in this module; callers apply their own threshold.
|
||||
"""
|
||||
img = cv2.imread(str(image_path))
|
||||
if img is None:
|
||||
return None
|
||||
return float(GeminiEngine().detect_watermark(img).confidence)
|
||||
@@ -0,0 +1,419 @@
|
||||
"""Image provenance: identify where an image was made and what watermarks it carries.
|
||||
|
||||
Aggregates every locally-readable signal into a single :class:`ProvenanceReport`:
|
||||
|
||||
- **C2PA Content Credentials** (issuer, claim generator, digital source type) ->
|
||||
the signing platform (OpenAI, Google, Adobe, Microsoft).
|
||||
- **IPTC ``digitalSourceType``** "Made with AI" marker (Meta, X, others).
|
||||
- **PNG text / EXIF generation parameters** (Stable Diffusion, ComfyUI, InvokeAI).
|
||||
- **SynthID metadata proxy** -- a C2PA companion from a SynthID-using vendor
|
||||
(Google / OpenAI) implies the invisible pixel watermark.
|
||||
- **Visible Gemini sparkle** (optional; needs cv2/numpy, no GPU).
|
||||
|
||||
Hard limit: a stripped image (re-encoded, screenshotted, social-media upload)
|
||||
loses all metadata, and the SynthID *pixel* watermark is not locally decodable
|
||||
(proprietary decoder). Absence of signals is therefore reported as ``Unknown``,
|
||||
never as "clean". See CLAUDE.md "SynthID detection is metadata-only".
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from remove_ai_watermarks.metadata import (
|
||||
AI_METADATA_KEYS,
|
||||
AIGC_MARKERS,
|
||||
C2PA_UUID,
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
aigc_label,
|
||||
exif_generator,
|
||||
get_ai_metadata,
|
||||
iptc_ai_system,
|
||||
xai_signature,
|
||||
)
|
||||
from remove_ai_watermarks.noai.c2pa import cbor_text_after, extract_c2pa_info, soft_binding_vendors_in
|
||||
from remove_ai_watermarks.noai.constants import C2PA_AI_TOOLS, C2PA_ISSUERS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# How much of a non-PNG container to binary-scan for the C2PA issuer.
|
||||
_SCAN_BYTES = 1024 * 1024
|
||||
|
||||
# Visible-sparkle confidence above which the signal is trusted as provenance.
|
||||
# Stricter than the removal default (0.25): on the corpus, Gemini-family
|
||||
# sparkles score >= 0.56 while non-sparkle images top out at 0.49, so 0.5
|
||||
# cleanly separates them and avoids false positives when sparkle is the only
|
||||
# signal (e.g. an OpenAI image scored 0.37 -- below threshold, correctly dropped).
|
||||
_SPARKLE_THRESHOLD = 0.5
|
||||
|
||||
# Issuer (C2PA signer) -> human-readable generating platform. Ordered: when a
|
||||
# manifest names several issuers (Microsoft Designer signs as "OpenAI,
|
||||
# Microsoft"), the first match wins so the product, not the backend, is named.
|
||||
_ISSUER_PLATFORM: tuple[tuple[str, str], ...] = (
|
||||
# Microsoft signs both Designer and Bing Image Creator; Bing now runs its
|
||||
# own MAI-Image model (not DALL-E), so the label stays model-neutral.
|
||||
("Microsoft", "Microsoft (Bing Image Creator / Designer)"),
|
||||
("Adobe", "Adobe Firefly"),
|
||||
("OpenAI", "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)"),
|
||||
("Google", "Google (Gemini / Imagen)"),
|
||||
("Stability AI", "Stability AI (Stable Image / DreamStudio)"),
|
||||
)
|
||||
|
||||
# PNG-text / EXIF keys that indicate a local diffusion pipeline (vs. a hosted
|
||||
# platform's C2PA). Subset of AI_METADATA_KEYS; excludes the C2PA/Software keys.
|
||||
_LOCAL_GEN_KEYS = frozenset(
|
||||
AI_METADATA_KEYS & {"parameters", "prompt", "negative_prompt", "workflow", "comfyui", "invokeai_metadata", "dream"}
|
||||
)
|
||||
|
||||
_STRIP_CAVEAT = (
|
||||
"Absence of metadata is not proof the image is clean: C2PA, EXIF, and PNG "
|
||||
"text chunks are stripped by re-encoding, screenshots, or social-media upload."
|
||||
)
|
||||
_SYNTHID_CAVEAT = (
|
||||
"SynthID is a metadata proxy here; the pixel watermark is not locally "
|
||||
"verifiable (proprietary decoder). Confirm via the Gemini app or openai.com/verify."
|
||||
)
|
||||
_OPENAI_CAVEAT = (
|
||||
"OpenAI began pairing SynthID with C2PA around 2026-05; OpenAI images from "
|
||||
"before the rollout carry C2PA without SynthID, so the SynthID verdict is 'likely'."
|
||||
)
|
||||
_IPTC_ONLY_CAVEAT = "The IPTC 'Made with AI' tag flags AI provenance but does not identify the specific platform."
|
||||
_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."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class Signal:
|
||||
"""A single provenance signal that was found (or affirmatively absent)."""
|
||||
|
||||
name: str
|
||||
detail: str
|
||||
confidence: str # "high" | "medium"
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProvenanceReport:
|
||||
"""Aggregated provenance verdict for one image."""
|
||||
|
||||
path: Path
|
||||
is_ai_generated: bool | None # True / False is never asserted; None = unknown
|
||||
platform: str | None
|
||||
confidence: str # "high" | "medium" | "none"
|
||||
watermarks: list[str] = field(default_factory=list[str])
|
||||
signals: list[Signal] = field(default_factory=list["Signal"])
|
||||
caveats: list[str] = field(default_factory=list[str])
|
||||
|
||||
|
||||
def _issuers_in(data: bytes) -> list[str]:
|
||||
"""C2PA issuer names whose signature byte appears in ``data`` (binary scan)."""
|
||||
return sorted({name for sig, name in C2PA_ISSUERS.items() if sig in data})
|
||||
|
||||
|
||||
def _ai_tools_in(data: bytes) -> list[str]:
|
||||
"""Known C2PA AI-tool / generator names appearing in ``data`` (binary scan).
|
||||
|
||||
PNG has a structured claim_generator; for JPEG/WebP/AVIF/HEIF/JXL the
|
||||
generator lives in a JUMBF/EXIF/XMP blob the PNG parser can't reach, so a
|
||||
byte scan recovers the same attribution (e.g. "Imagen", "DALL-E").
|
||||
"""
|
||||
return sorted({name for sig, name in C2PA_AI_TOOLS.items() if sig in data})
|
||||
|
||||
|
||||
# Distinctive C2PA device/camera tokens (cert CN, cert org, or claim-generator
|
||||
# substrings) scanned in the manifest bytes -> platform. This is more reliable
|
||||
# than mapping an issuer name (which also matches incidental mentions: a
|
||||
# timestamp authority like "Truepic" in a Leica chain, an XMP-toolkit "Adobe"
|
||||
# string in a Nikon file, or "Google" in a Pixel camera's cert -- all verified
|
||||
# on real samples), and more robust than parsing the claim generator (which
|
||||
# lives under varying CBOR keys, e.g. `claim_generator` vs `claim_generator_info`,
|
||||
# and is absent on the Pixel sample where only the cert CN "Pixel Camera"
|
||||
# identifies it). Camera C2PA marks CAPTURE authenticity, not AI, so these never
|
||||
# assert is_ai on their own (the verdict still comes from the digital-source-type:
|
||||
# the Pixel sample carries `computationalCapture`, not `trainedAlgorithmicMedia`).
|
||||
# Only tokens verified against a real signed file are listed (Leica, Nikon,
|
||||
# Truepic, Google Pixel); add Sony/Canon/Samsung/Bria as real samples are captured.
|
||||
_DEVICE_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
|
||||
(b"lc_c2pa", "Leica (camera, C2PA capture)"),
|
||||
(b"Leica Camera", "Leica (camera, C2PA capture)"),
|
||||
(b"NIKON", "Nikon (camera, C2PA capture)"),
|
||||
(b"Pixel Camera", "Google Pixel (camera, C2PA capture)"),
|
||||
# Sony uses its own ``sony.*`` C2PA assertion namespace (sony.sig / sony.cert);
|
||||
# match that, NOT bare "Sony" (which is an EXIF Make on countless photos).
|
||||
# Verified on a real Sony-signed file (Sony PXW-Z300, signer "Sony Corporation").
|
||||
(b"sony.sig", "Sony (camera, C2PA capture)"),
|
||||
(b"sony.cert", "Sony (camera, C2PA capture)"),
|
||||
# "Truepic_Lens" (from the Lens SDK claim generator), NOT bare "Truepic" --
|
||||
# Truepic is a C2PA signing authority whose name appears in the trust chain
|
||||
# of unrelated manifests (e.g. OpenAI), so the bare token mis-attributes.
|
||||
(b"Truepic_Lens", "Truepic Lens (verified capture)"),
|
||||
)
|
||||
|
||||
|
||||
def _device_platform(head: bytes) -> str | None:
|
||||
"""Map a distinctive C2PA device/camera token in the manifest bytes to a platform."""
|
||||
for token, platform in _DEVICE_C2PA_PLATFORM:
|
||||
if token in head:
|
||||
return platform
|
||||
return None
|
||||
|
||||
|
||||
def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None:
|
||||
"""Map a set of C2PA issuer names to a human-readable generating platform.
|
||||
|
||||
A specific AI-generator platform (Adobe Firefly, OpenAI, ...) is named only
|
||||
when the content is actually AI (``is_ai``, i.e. digital-source-type
|
||||
``trainedAlgorithmicMedia``). Otherwise an issuer-name byte match is likely
|
||||
incidental -- e.g. an "Adobe XMP" toolkit string in a Canon/Sony camera
|
||||
capture, or a "Google" cert org -- so we fall back to a neutral signer label
|
||||
rather than mislabel a camera photo as "Adobe Firefly". Real Firefly/OpenAI/
|
||||
Google AI output carries the AI source-type, so it is unaffected. ``is_ai``
|
||||
defaults True so the issuer->platform mapping can still be unit-tested in
|
||||
isolation; ``identify`` passes the file's actual ``c2pa_is_ai``.
|
||||
"""
|
||||
joined = " ".join(issuers)
|
||||
if is_ai:
|
||||
for needle, platform in _ISSUER_PLATFORM:
|
||||
if needle in joined:
|
||||
return platform
|
||||
if issuers: # e.g. Truepic alone -- a signing authority, not a generator
|
||||
return f"C2PA signer: {', '.join(issuers)} (no known AI generator named)"
|
||||
return None
|
||||
|
||||
|
||||
def _visible_sparkle(image_path: Path) -> float | None:
|
||||
"""Visible Gemini-sparkle confidence in [0, 1], or None if unavailable.
|
||||
|
||||
Optional: needs cv2/numpy (no GPU). The cv2 work lives in gemini_engine so
|
||||
this module stays dependency-light; returns None if cv2 or the engine
|
||||
assets are missing, or the image can't be read.
|
||||
"""
|
||||
try:
|
||||
from remove_ai_watermarks.gemini_engine import detect_sparkle_confidence
|
||||
except Exception as exc: # cv2/engine assets missing
|
||||
log.debug("visible-sparkle detector unavailable: %s", exc)
|
||||
return None
|
||||
return detect_sparkle_confidence(image_path)
|
||||
|
||||
|
||||
def _invisible_watermark(image_path: Path) -> str | None:
|
||||
"""Open invisible-watermark scheme name (SD/SDXL/FLUX) or None.
|
||||
|
||||
Optional: needs the imwatermark decoder (extra ``detect``). Returns None if
|
||||
it is not installed or no known watermark decodes.
|
||||
"""
|
||||
from remove_ai_watermarks.invisible_watermark import detect_invisible_watermark
|
||||
|
||||
return detect_invisible_watermark(image_path)
|
||||
|
||||
|
||||
def _trustmark(image_path: Path) -> str | None:
|
||||
"""Adobe TrustMark scheme name or None.
|
||||
|
||||
Optional: needs the ``trustmark`` decoder (extra ``trustmark``). Returns None
|
||||
if it is not installed or no TrustMark watermark decodes.
|
||||
"""
|
||||
from remove_ai_watermarks.trustmark_detector import detect_trustmark
|
||||
|
||||
return detect_trustmark(image_path)
|
||||
|
||||
|
||||
def identify(image_path: Path, *, check_visible: bool = True, check_invisible: bool = True) -> ProvenanceReport:
|
||||
"""Identify an image's origin platform and watermark inventory.
|
||||
|
||||
Args:
|
||||
image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container).
|
||||
check_visible: Also run the visible Gemini-sparkle detector (cv2). Set
|
||||
False for a pure-metadata, dependency-light scan.
|
||||
check_invisible: Also decode open invisible watermarks (SD/SDXL/FLUX) via
|
||||
the optional imwatermark library. No-op when it is not installed.
|
||||
|
||||
Returns:
|
||||
A :class:`ProvenanceReport`. ``is_ai_generated`` is True when any AI
|
||||
signal is found and None (unknown) when none is -- it is never asserted
|
||||
False, because stripped metadata leaves no local proof of a clean origin.
|
||||
"""
|
||||
info = extract_c2pa_info(image_path) # PNG-structured; {} for other formats
|
||||
meta = get_ai_metadata(image_path) # PNG text + EXIF + C2PA fields + synthid
|
||||
|
||||
# First MB covers C2PA (PNG caBX, JPEG APP11, AVIF/HEIF/JXL uuid box) and
|
||||
# IPTC markers for the non-PNG path where extract_c2pa_info returns {}.
|
||||
with open(image_path, "rb") as f:
|
||||
head = f.read(_SCAN_BYTES)
|
||||
|
||||
signals: list[Signal] = []
|
||||
watermarks: list[str] = []
|
||||
caveats: list[str] = []
|
||||
|
||||
# ── C2PA Content Credentials ────────────────────────────────────
|
||||
has_c2pa = bool(info) or b"c2pa" in head.lower() or C2PA_UUID in head
|
||||
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(head)
|
||||
c2pa_is_ai = "trainedAlgorithmicMedia" in info.get("source_type", "") or any(
|
||||
m in head for m in (b"trainedAlgorithmicMedia", b"compositeWithTrainedAlgorithmicMedia")
|
||||
)
|
||||
# Generator string (for the signal detail): structured for PNG, CBOR-scanned
|
||||
# for other containers. Best-effort -- some manifests key it as
|
||||
# `claim_generator_info` (Pixel), so this can be None even when a device is
|
||||
# identified by `_device_platform`.
|
||||
generator = (
|
||||
info.get("claim_generator")
|
||||
or cbor_text_after(head, b"claim_generator")
|
||||
or (", ".join(tools) if (tools := _ai_tools_in(head)) else None)
|
||||
)
|
||||
# Platform: a distinctive device/camera token in the manifest wins (it is the
|
||||
# signer/producer), with the issuer byte-scan only as fallback. The issuer
|
||||
# scan alone mis-attributed real samples (Leica->Truepic timestamp authority,
|
||||
# Nikon->Adobe namespace, Pixel->Google Gemini) -- the device scan fixes that.
|
||||
platform = (_device_platform(head) or _attribute_platform(issuers, is_ai=c2pa_is_ai)) if has_c2pa else None
|
||||
if has_c2pa:
|
||||
detail = ", ".join(filter(None, [", ".join(issuers), generator, info.get("source_type")]))
|
||||
signals.append(Signal("c2pa", detail or "C2PA manifest present", "high"))
|
||||
watermarks.append(f"C2PA Content Credentials ({', '.join(issuers) or 'unknown signer'})")
|
||||
|
||||
# ── SynthID metadata proxy ──────────────────────────────────────
|
||||
# get_ai_metadata already sets synthid_watermark for both PNG (caBX parser)
|
||||
# and non-PNG (its own synthid_source fallback), so no extra scan is needed.
|
||||
synthid = meta.get("synthid_watermark")
|
||||
if synthid:
|
||||
watermarks.append(f"SynthID pixel watermark ({synthid})")
|
||||
caveats.append(_SYNTHID_CAVEAT)
|
||||
if "OpenAI" in (" ".join(issuers) + synthid):
|
||||
caveats.append(_OPENAI_CAVEAT)
|
||||
|
||||
# ── 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(head)) else None)
|
||||
if soft_binding:
|
||||
signals.append(Signal("soft_binding", f"C2PA soft binding: {soft_binding}", "high"))
|
||||
watermarks.append(f"Forensic watermark soft binding ({soft_binding})")
|
||||
|
||||
# ── IPTC "Made with AI" (Meta etc.), only meaningful without C2PA ─
|
||||
iptc = any(m in head for m in IPTC_AI_MARKERS)
|
||||
if iptc and not has_c2pa:
|
||||
signals.append(Signal("iptc", "digitalSourceType (Made with AI)", "high"))
|
||||
watermarks.append("IPTC digitalSourceType (Made with AI)")
|
||||
caveats.append(_IPTC_ONLY_CAVEAT)
|
||||
if platform is None:
|
||||
platform = "Made-with-AI tag (e.g. Meta AI); platform not specified"
|
||||
|
||||
# ── IPTC 2025.1 AI-disclosure fields (Iptc4xmpExt:AISystemUsed etc.) ─
|
||||
iptc_ai = any(m in head for m in IPTC_AI_FIELD_MARKERS)
|
||||
if iptc_ai:
|
||||
system = iptc_ai_system(image_path)
|
||||
named = bool(system) and system != "fields present"
|
||||
signals.append(
|
||||
Signal("iptc_ai_system", f"IPTC AI disclosure ({system})" if named else "IPTC AI disclosure fields", "high")
|
||||
)
|
||||
watermarks.append(f"IPTC 2025.1 AI disclosure ({system})" if named else "IPTC 2025.1 AI disclosure fields")
|
||||
if platform is None and named:
|
||||
platform = f"{system} (IPTC AISystemUsed)"
|
||||
|
||||
# ── China TC260 AIGC label (Doubao and other China-served gens) ──
|
||||
aigc = any(m in head for m in AIGC_MARKERS)
|
||||
if aigc:
|
||||
producer = (aigc_label(image_path) or {}).get("ContentProducer", "")
|
||||
signals.append(Signal("aigc", f"TC260 AIGC label{f' (producer {producer})' if producer else ''}", "high"))
|
||||
watermarks.append("China AIGC label (TC260 standard)")
|
||||
if platform is None:
|
||||
platform = "China AIGC-labeled generator (TC260; e.g. Doubao)"
|
||||
|
||||
# ── Local diffusion parameters (Stable Diffusion / ComfyUI) ──────
|
||||
local_keys = sorted(k for k in meta if k.lower() in _LOCAL_GEN_KEYS)
|
||||
if local_keys:
|
||||
signals.append(Signal("gen_params", f"embedded keys: {', '.join(local_keys)}", "high"))
|
||||
watermarks.append("Embedded generation parameters (Stable Diffusion / ComfyUI)")
|
||||
if platform is None:
|
||||
platform = "Stable Diffusion / local pipeline (Automatic1111, ComfyUI, InvokeAI)"
|
||||
|
||||
# ── EXIF Software / XMP CreatorTool generator (cross-format) ─────
|
||||
# Catches a generator tag (incl. inside AVIF/HEIF/JXL) when there is no C2PA.
|
||||
if generator_tag := exif_generator(image_path):
|
||||
signals.append(Signal("exif_generator", f"EXIF/XMP generator: {generator_tag}", "high"))
|
||||
watermarks.append(f"Embedded generator tag: {generator_tag}")
|
||||
if platform is None:
|
||||
platform = f"{generator_tag} (EXIF/XMP generator tag)"
|
||||
|
||||
# ── xAI / Grok EXIF signature scheme (no C2PA/SynthID/IPTC) ──────
|
||||
# Grok's only provenance signal: EXIF ImageDescription "Signature: <base64>"
|
||||
# + a UUID Artist. Distinct from exif_generator (which matches generator
|
||||
# tokens); verified stable across 3 generations. See CLAUDE.md.
|
||||
if xai_signature(image_path):
|
||||
signals.append(Signal("xai_signature", "EXIF Signature blob + UUID Artist", "high"))
|
||||
watermarks.append("xAI/Grok EXIF signature")
|
||||
if platform is None:
|
||||
platform = "xAI (Grok / Aurora)"
|
||||
|
||||
# ── Open invisible watermark (SD / SDXL / FLUX, dwtDct) ──────────
|
||||
# Public decoder, no key -- a definitive embedded signal on pristine files.
|
||||
if check_invisible and (scheme := _invisible_watermark(image_path)) is not None:
|
||||
signals.append(Signal("invisible_watermark", scheme, "high"))
|
||||
watermarks.append(f"Open invisible watermark: {scheme}")
|
||||
caveats.append(_INVISIBLE_WM_CAVEAT)
|
||||
if platform is None:
|
||||
platform = f"{scheme} (open DWT-DCT watermark)"
|
||||
|
||||
# ── Adobe TrustMark invisible watermark (open decoder, no key) ───
|
||||
# The watermark behind Adobe Durable Content Credentials. Decoded locally,
|
||||
# but it binds provenance for human-authored content too, so it enriches the
|
||||
# watermark inventory without by itself asserting AI origin.
|
||||
if check_invisible and (tm_scheme := _trustmark(image_path)) is not None:
|
||||
signals.append(Signal("trustmark", tm_scheme, "high"))
|
||||
watermarks.append(f"Adobe TrustMark invisible watermark ({tm_scheme})")
|
||||
if platform is None:
|
||||
platform = "Adobe (TrustMark / Content Credentials)"
|
||||
|
||||
# ── Verdict so far (metadata + embedded watermark) ──────────────
|
||||
invisible_wm = any(s.name == "invisible_watermark" for s in signals)
|
||||
exif_gen = any(s.name == "exif_generator" for s in signals)
|
||||
xai_sig = any(s.name == "xai_signature" for s in signals)
|
||||
ai_from_metadata = bool(
|
||||
(has_c2pa and (c2pa_is_ai or synthid))
|
||||
or iptc
|
||||
or iptc_ai
|
||||
or aigc
|
||||
or local_keys
|
||||
or invisible_wm
|
||||
or exif_gen
|
||||
or xai_sig
|
||||
)
|
||||
|
||||
# ── Visible Gemini sparkle (fallback for stripped-metadata case) ─
|
||||
if check_visible and (conf := _visible_sparkle(image_path)) is not None and conf >= _SPARKLE_THRESHOLD:
|
||||
signals.append(Signal("visible_sparkle", f"NCC confidence {conf:.2f}", "medium"))
|
||||
watermarks.append(f"Visible Gemini sparkle (confidence {conf:.2f})")
|
||||
if platform is None:
|
||||
platform = "Google Gemini family (visible sparkle detected)"
|
||||
|
||||
visible_only = any(s.name == "visible_sparkle" for s in signals) and not ai_from_metadata
|
||||
|
||||
if ai_from_metadata:
|
||||
is_ai: bool | None = True
|
||||
confidence = "high"
|
||||
elif visible_only:
|
||||
is_ai = True
|
||||
confidence = "medium"
|
||||
else:
|
||||
is_ai = None
|
||||
confidence = "none"
|
||||
|
||||
caveats.append(_STRIP_CAVEAT)
|
||||
# De-duplicate while preserving order.
|
||||
caveats = list(dict.fromkeys(caveats))
|
||||
|
||||
return ProvenanceReport(
|
||||
path=image_path,
|
||||
is_ai_generated=is_ai,
|
||||
platform=platform,
|
||||
confidence=confidence,
|
||||
watermarks=watermarks,
|
||||
signals=signals,
|
||||
caveats=caveats,
|
||||
)
|
||||
@@ -42,6 +42,24 @@ def is_available() -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _target_size(width: int, height: int, max_resolution: int) -> tuple[int, int] | None:
|
||||
"""Compute the downscaled (width, height) for a long-side cap, or None for native.
|
||||
|
||||
Returns None when no pre-downscale is needed: ``max_resolution <= 0`` (native
|
||||
resolution, the default that matches the raiw.cc backend -- see issue #10) or
|
||||
the long side already fits the cap. Otherwise scales the long side down to
|
||||
``max_resolution`` preserving aspect ratio (integer-truncated, matching the
|
||||
PIL ``resize`` call site). Pure function so the native-vs-downscale decision
|
||||
is unit-testable without loading the diffusion model.
|
||||
"""
|
||||
if max_resolution > 0 and max(width, height) > max_resolution:
|
||||
ratio = max_resolution / max(width, height)
|
||||
# Clamp the short side to >=1: extreme aspect ratios (e.g. 5000x3 capped
|
||||
# at 1024) would otherwise truncate it to 0 and crash image.resize().
|
||||
return (max(1, int(width * ratio)), max(1, int(height * ratio)))
|
||||
return None
|
||||
|
||||
|
||||
class InvisibleEngine:
|
||||
"""Remove invisible AI watermarks using diffusion model regeneration.
|
||||
|
||||
@@ -52,7 +70,10 @@ class InvisibleEngine:
|
||||
to break watermark patterns, and reconstructs via reverse diffusion.
|
||||
"""
|
||||
|
||||
DEFAULT_MODEL_ID = "Lykon/dreamshaper-8"
|
||||
# SDXL base is the default since May 2026: empirically defeats SynthID v2
|
||||
# at strength=0.05 / steps=50 / native ~1024px. See CLAUDE.md "Known
|
||||
# limitations" for the regression evidence ruling out SD-1.5 pipelines.
|
||||
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
|
||||
|
||||
def __init__(
|
||||
@@ -68,7 +89,8 @@ class InvisibleEngine:
|
||||
Args:
|
||||
model_id: HuggingFace model ID. None = use default for pipeline.
|
||||
device: Device for inference (auto/cpu/mps/cuda). None = auto.
|
||||
pipeline: Pipeline profile ("default" or "ctrlregen").
|
||||
pipeline: Pipeline profile. "default" (SDXL base, defeats SynthID
|
||||
v2) or "ctrlregen" (CtrlRegen).
|
||||
hf_token: HuggingFace API token.
|
||||
progress_callback: Optional callback for progress messages.
|
||||
"""
|
||||
@@ -103,6 +125,7 @@ class InvisibleEngine:
|
||||
seed: int | None = None,
|
||||
humanize: float = 0.0,
|
||||
protect_faces: bool = True,
|
||||
max_resolution: int = 0,
|
||||
) -> Path:
|
||||
"""Remove invisible watermark from an image.
|
||||
|
||||
@@ -115,6 +138,11 @@ class InvisibleEngine:
|
||||
seed: Random seed for reproducibility.
|
||||
humanize: Intensity of Analog Humanizer film grain (0 = off).
|
||||
protect_faces: Boolean to extract and restore faces intact.
|
||||
max_resolution: Cap the long side (px) before diffusion. 0 (default)
|
||||
= native resolution, no pre-downscale -- matches the hosted
|
||||
raiw.cc backend. Set a positive value only to bound GPU/MPS
|
||||
memory on very large inputs (it reintroduces a lossy
|
||||
downscale->upscale round-trip).
|
||||
|
||||
Returns:
|
||||
Path to the cleaned image.
|
||||
@@ -123,41 +151,35 @@ class InvisibleEngine:
|
||||
|
||||
from PIL import Image, ImageOps
|
||||
|
||||
max_dimension = 768
|
||||
# Process at native resolution by default (max_resolution=0). The hosted
|
||||
# raiw.cc backend (fal fast-sdxl) does NO pre-downscale either, and at
|
||||
# strength ~0.05 SDXL img2img does not need the input shrunk to ~1024 --
|
||||
# the old forced downscale->upscale round-trip was the main quality loss
|
||||
# (see issue #10). A positive max_resolution caps the long side only to
|
||||
# bound GPU/MPS memory on very large inputs.
|
||||
image = Image.open(image_path)
|
||||
image = ImageOps.exif_transpose(image)
|
||||
orig_size = image.size # (width, height)
|
||||
_tmp_path = None
|
||||
|
||||
if max(image.width, image.height) > max_dimension:
|
||||
ratio = max_dimension / max(image.width, image.height)
|
||||
new_size = (int(image.width * ratio), int(image.height * ratio))
|
||||
# Optional long-side downscale; native resolution by default (issue #10).
|
||||
target = _target_size(image.width, image.height, max_resolution)
|
||||
if target is not None:
|
||||
if self._progress_callback:
|
||||
self._progress_callback(
|
||||
f"Downscaling {image.width}x{image.height} "
|
||||
f"to {new_size[0]}x{new_size[1]} "
|
||||
f"(model trained at {max_dimension}px)..."
|
||||
f"to {target[0]}x{target[1]} "
|
||||
f"(max-resolution cap {max_resolution}px)..."
|
||||
)
|
||||
image = image.resize(new_size, Image.Resampling.LANCZOS)
|
||||
image = image.resize(target, Image.Resampling.LANCZOS)
|
||||
|
||||
# Save to a temp file instead of overwriting the original
|
||||
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=image_path.suffix)
|
||||
_tmp_path = Path(_tmp_str)
|
||||
image.save(_tmp_path)
|
||||
import os as _os
|
||||
|
||||
_os.close(_tmp_fd)
|
||||
image_path = _tmp_path
|
||||
else:
|
||||
# We must save the transposed image back to a tmp file if it was rotated
|
||||
# otherwise WatermarkRemover will reload it without EXIF rotation!
|
||||
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=image_path.suffix)
|
||||
_tmp_path = Path(_tmp_str)
|
||||
image.save(_tmp_path)
|
||||
import os as _os
|
||||
|
||||
_os.close(_tmp_fd)
|
||||
image_path = _tmp_path
|
||||
# Always persist to a temp file, even without downscaling: WatermarkRemover
|
||||
# reloads by path, so the EXIF-transposed pixels must be saved or rotation
|
||||
# is lost. Cleaned up in the finally block via _tmp_path.
|
||||
_tmp_fd, _tmp_str = tempfile.mkstemp(suffix=image_path.suffix)
|
||||
_tmp_path = Path(_tmp_str)
|
||||
image.save(_tmp_path)
|
||||
os.close(_tmp_fd)
|
||||
image_path = _tmp_path
|
||||
|
||||
try:
|
||||
# Optional: Face protection (Phase 1 - Extraction)
|
||||
@@ -238,7 +260,8 @@ class InvisibleEngine:
|
||||
|
||||
return out_path
|
||||
finally:
|
||||
if _tmp_path is not None and _tmp_path.exists():
|
||||
# _tmp_path is always set above (we persist the image unconditionally).
|
||||
if _tmp_path.exists():
|
||||
_tmp_path.unlink()
|
||||
|
||||
def remove_watermark_batch(
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""Detect open invisible watermarks embedded by the ``invisible-watermark``
|
||||
(imwatermark) library -- used by Stable Diffusion, SDXL, and FLUX.
|
||||
|
||||
Unlike SynthID (proprietary, no local decoder), these are DWT-DCT watermarks
|
||||
with a PUBLIC decoder and no secret key, so a fresh, un-re-encoded output can be
|
||||
identified locally. The known fixed patterns were verified against upstream
|
||||
source:
|
||||
|
||||
- **Stable Diffusion XL** -- diffusers ``StableDiffusionXLWatermarker``
|
||||
``WATERMARK_MESSAGE`` (48-bit).
|
||||
- **FLUX.2** -- ``black-forest-labs/flux2`` ``src/flux2/watermark.py`` (48-bit).
|
||||
- **Stable Diffusion 1.x / 2.x** -- the library's default ``"StableDiffusionV1"``
|
||||
string (136-bit).
|
||||
|
||||
The watermark is fragile: it does NOT survive JPEG re-encoding or resizing
|
||||
(verified -- gone after JPEG q90), so detection works only on pristine PNG
|
||||
originals. Absence is never proof. Requires the optional ``invisible-watermark``
|
||||
package (extra: ``detect``); ``detect_invisible_watermark`` returns None when it
|
||||
is not installed.
|
||||
"""
|
||||
|
||||
# imwatermark ships no type stubs (like cv2); its decoder returns are Unknown.
|
||||
# Relax the untyped-library diagnostics for this thin wrapper module only.
|
||||
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Known 48-bit ``bits`` watermarks (dwtDct, no key), name -> message integer.
|
||||
_BITS_48: dict[str, int] = {
|
||||
"Stable Diffusion XL": 0b101100111110110010010000011110111011000110011110,
|
||||
"FLUX.2 (Black Forest Labs)": 0b001010101111111010000111100111001111010100101110,
|
||||
}
|
||||
# The invisible-watermark default string watermark (SD 1.x / 2.x).
|
||||
_SD1_STRING = b"StableDiffusionV1"
|
||||
|
||||
# Decoded bits/bytes never match a 48-bit pattern by chance: random decode lands
|
||||
# near 24/48, an exact embed at 48/48 (measured). 44 (<=4 bit errors) is a safe
|
||||
# floor that tolerates light perturbation without risking a false positive.
|
||||
_MATCH_48 = 44
|
||||
_MATCH_SD1_FRAC = 0.92 # fraction of the 136 string bits that must match
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True if the optional imwatermark decoder is installed."""
|
||||
import importlib.util
|
||||
|
||||
return importlib.util.find_spec("imwatermark") is not None
|
||||
|
||||
|
||||
def _bits_match(value: int, ref: int, width: int = 48) -> int:
|
||||
"""Number of matching bits between two ``width``-bit integers."""
|
||||
return width - bin(value ^ ref).count("1")
|
||||
|
||||
|
||||
def _bytes_match_frac(a: bytes, b: bytes) -> float:
|
||||
"""Fraction of matching bits between two equal-length byte strings."""
|
||||
if len(a) != len(b) or not a:
|
||||
return 0.0
|
||||
diff = sum(bin(x ^ y).count("1") for x, y in zip(a, b, strict=True))
|
||||
return 1.0 - diff / (8 * len(b))
|
||||
|
||||
|
||||
def detect_invisible_watermark(image_path: Path) -> str | None:
|
||||
"""Return the embedding scheme name if a known open watermark is decoded.
|
||||
|
||||
Returns e.g. ``"Stable Diffusion XL"`` / ``"FLUX.2 (Black Forest Labs)"`` /
|
||||
``"Stable Diffusion 1.x / 2.x"``, or None if none matches, the decoder is
|
||||
unavailable, or the image can't be read. Meaningful only on pristine
|
||||
(un-re-encoded) images.
|
||||
"""
|
||||
if not is_available():
|
||||
return None
|
||||
import cv2
|
||||
from imwatermark import WatermarkDecoder
|
||||
|
||||
img = cv2.imread(str(image_path))
|
||||
if img is None:
|
||||
return None
|
||||
|
||||
# 48-bit fixed-message watermarks (SDXL, FLUX.2).
|
||||
try:
|
||||
bits = WatermarkDecoder("bits", 48).decode(img, "dwtDct")
|
||||
value = 0
|
||||
for bit in bits:
|
||||
value = (value << 1) | (1 if bit else 0)
|
||||
for name, ref in _BITS_48.items():
|
||||
if _bits_match(value, ref) >= _MATCH_48:
|
||||
return name
|
||||
except Exception as exc: # decode can fail on tiny images
|
||||
log.debug("48-bit watermark decode failed for %s: %s", image_path, exc)
|
||||
|
||||
# 136-bit default string watermark (SD 1.x / 2.x).
|
||||
try:
|
||||
raw = cast("bytes", WatermarkDecoder("bytes", 8 * len(_SD1_STRING)).decode(img, "dwtDct"))
|
||||
if _bytes_match_frac(raw, _SD1_STRING) >= _MATCH_SD1_FRAC:
|
||||
return "Stable Diffusion 1.x / 2.x"
|
||||
except Exception as exc:
|
||||
log.debug("string watermark decode failed for %s: %s", image_path, exc)
|
||||
|
||||
return None
|
||||
@@ -10,6 +10,7 @@ from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -60,6 +61,53 @@ AI_KEYWORDS: tuple[str, ...] = (
|
||||
"c2pa",
|
||||
)
|
||||
|
||||
# C2PA UUID used in ISOBMFF (AVIF, HEIF, MP4) ``uuid`` boxes.
|
||||
# Reference: https://spec.c2pa.org/specifications/specifications/2.1/specs/C2PA_Specification.html
|
||||
C2PA_UUID: bytes = bytes.fromhex("d8fec3d61b0e483c92975828877ec481")
|
||||
|
||||
# IPTC ``digitalSourceType`` values (IPTC 2025.1) that flag AI provenance.
|
||||
# Used by Instagram, Facebook, X (Twitter) to show "Made with AI" labels.
|
||||
IPTC_AI_MARKERS: tuple[bytes, ...] = (
|
||||
b"trainedAlgorithmicMedia",
|
||||
b"compositeSynthetic",
|
||||
b"algorithmicMedia",
|
||||
b"compositeWithTrainedAlgorithmicMedia",
|
||||
)
|
||||
|
||||
# IPTC Photo Metadata 2025.1 (published 2025-11-27) added explicit AI-disclosure
|
||||
# XMP properties in the Iptc4xmpExt namespace. Their mere presence is an AI
|
||||
# signal; ``AISystemUsed`` additionally carries the generator name. Property
|
||||
# tokens verified against the IPTC 2025.1 specification.
|
||||
IPTC_AI_FIELD_MARKERS: tuple[bytes, ...] = (
|
||||
b"AISystemUsed",
|
||||
b"AISystemVersionUsed",
|
||||
b"AIPromptInformation",
|
||||
b"AIPromptWriterName",
|
||||
)
|
||||
|
||||
# ISOBMFF containers whose AI-provenance boxes ``remove_ai_metadata`` strips at
|
||||
# the container level (image, video, audio -- all ISOBMFF). A content sniff
|
||||
# (``ftyp``) is also accepted, so this is a fast-path hint, not the sole gate.
|
||||
_ISOBMFF_EXTS: frozenset[str] = frozenset({".avif", ".heif", ".heic", ".jxl", ".mp4", ".mov", ".m4v", ".m4a"})
|
||||
|
||||
# Non-ISOBMFF audio/video the ISOBMFF box walker can't reach (EBML / framed /
|
||||
# RIFF / Vorbis). remove_ai_metadata strips their container metadata losslessly
|
||||
# via ffmpeg (`-c copy`), so it needs ffmpeg on PATH for these.
|
||||
_FFMPEG_STRIP_EXTS: frozenset[str] = frozenset(
|
||||
{".webm", ".mkv", ".mka", ".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".aac"}
|
||||
)
|
||||
|
||||
# China's mandatory AI-content labeling (TC260, the national cybersecurity
|
||||
# standards committee). AI generators serving China embed an XMP block in the
|
||||
# TC260 namespace -- ``<TC260:AIGC>{"Label":"1",...}``. Doubao (ByteDance) uses
|
||||
# this; the same standard is mandatory for Jimeng, Kling, Qwen, Ernie, etc.,
|
||||
# so the marker covers the whole China-AIGC-labeled ecosystem. Container-
|
||||
# agnostic (XMP is text), so a raw-byte scan catches it in PNG/JPEG/etc.
|
||||
AIGC_MARKERS: tuple[bytes, ...] = (
|
||||
b"tc260.org.cn/ns/AIGC",
|
||||
b"TC260:AIGC",
|
||||
)
|
||||
|
||||
STANDARD_METADATA_KEYS: frozenset[str] = frozenset(
|
||||
[
|
||||
"Author",
|
||||
@@ -95,101 +143,276 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
for key in img.info:
|
||||
if _is_ai_key(key):
|
||||
return True
|
||||
# PIL may not handle AVIF/HEIF/JPEG-XL without the optional plugins
|
||||
# (ultralytics also monkey-patches Image.open in a way that can raise
|
||||
# ModuleNotFoundError when pi_heif autoload fails), so any open failure
|
||||
# falls through to the binary scan.
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
for key in img.info:
|
||||
if _is_ai_key(key):
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("PIL could not open %s for metadata scan: %s", image_path, exc)
|
||||
|
||||
# Check C2PA
|
||||
# Check C2PA — via the official ``c2pa`` lib if available, otherwise via a
|
||||
# binary scan that also catches AVIF/HEIF/JPEG-XL containers (PIL doesn't
|
||||
# expose their metadata uniformly).
|
||||
try:
|
||||
from c2pa import has_c2pa_metadata
|
||||
|
||||
if has_c2pa_metadata(image_path):
|
||||
return True
|
||||
except ImportError:
|
||||
# Try simple binary scan (read only first 512KB to avoid OOM on huge files)
|
||||
with open(image_path, "rb") as f:
|
||||
data = f.read(512 * 1024)
|
||||
if b"c2pa" in data.lower() or b"C2PA" in data:
|
||||
return True
|
||||
pass
|
||||
|
||||
return False
|
||||
# Binary scan covers C2PA (PNG caBX, JPEG APP11, AVIF/HEIF/JXL uuid boxes)
|
||||
# and IPTC AI markers in XMP. Read only the first 512KB to bound memory.
|
||||
with open(image_path, "rb") as f:
|
||||
data = f.read(512 * 1024)
|
||||
if b"c2pa" in data.lower() or b"C2PA" in data:
|
||||
return True
|
||||
if C2PA_UUID in data:
|
||||
return True
|
||||
if any(marker in data for marker in AIGC_MARKERS):
|
||||
return True
|
||||
if any(marker in data for marker in IPTC_AI_MARKERS):
|
||||
return True
|
||||
# IPTC 2025.1 AI-disclosure XMP properties (their presence flags AI content).
|
||||
if any(marker in data for marker in IPTC_AI_FIELD_MARKERS):
|
||||
return True
|
||||
# xAI / Grok: no C2PA/IPTC/XMP -- only the EXIF Signature + UUID-Artist pair.
|
||||
return xai_signature(image_path)
|
||||
|
||||
|
||||
def _scan_png_c2pa_chunk(image_path: Path) -> dict[str, str]:
|
||||
"""Extract a human-readable summary of the C2PA manifest in a PNG file.
|
||||
def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
"""Parse a China TC260 ``<TC260:AIGC>`` AI-labeling block, if present.
|
||||
|
||||
PIL does not expose the caBX JUMBF box via ``img.info``, so we delegate
|
||||
chunk extraction to the existing ``extract_c2pa_chunk`` helper and pull
|
||||
key fields from the JUMBF payload without a full CBOR parser.
|
||||
Returns the decoded JSON (e.g. ``{"Label": "1", "ContentProducer": ...}``)
|
||||
or None. The block is XMP text (HTML-entity encoded), so it is found by a
|
||||
container-agnostic raw-byte scan and works for PNG/JPEG/WebP alike.
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
|
||||
with open(image_path, "rb") as f:
|
||||
data = f.read(1024 * 1024)
|
||||
match = re.search(rb"<TC260:AIGC>(.*?)</TC260:AIGC>", data, re.DOTALL)
|
||||
if not match:
|
||||
return None
|
||||
raw = html.unescape(match.group(1).decode("utf-8", "replace"))
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except ValueError:
|
||||
return None
|
||||
return {str(k): str(v) for k, v in parsed.items()} if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def iptc_ai_system(image_path: Path) -> str | None:
|
||||
"""Return an IPTC 2025.1 AI-disclosure note if the file carries those XMP
|
||||
properties, else None.
|
||||
|
||||
IPTC Photo Metadata 2025.1 added ``Iptc4xmpExt`` AI-disclosure properties
|
||||
(see ``IPTC_AI_FIELD_MARKERS``); their presence alone flags AI content, and
|
||||
``AISystemUsed`` names the generator. Returns the ``AISystemUsed`` value when
|
||||
extractable, otherwise the literal ``"fields present"``. Container-agnostic
|
||||
raw-byte scan; handles both XMP element and attribute serializations.
|
||||
"""
|
||||
with open(image_path, "rb") as f:
|
||||
data = f.read(1024 * 1024)
|
||||
if not any(marker in data for marker in IPTC_AI_FIELD_MARKERS):
|
||||
return None
|
||||
match = re.search(rb"AISystemUsed[=:\s]*[\"'>]\s*([^<\"']{1,120})", data)
|
||||
if match and (value := match.group(1).decode("utf-8", "replace").strip()):
|
||||
return value
|
||||
return "fields present"
|
||||
|
||||
|
||||
def synthid_source(image_path: Path) -> str | None:
|
||||
"""Return the vendor name(s) if the image carries a SynthID pixel watermark.
|
||||
|
||||
This is a *metadata-based* proxy: Google (Imagen/Gemini) and OpenAI
|
||||
(ChatGPT/DALL-E/gpt-image) embed an invisible SynthID watermark alongside
|
||||
a C2PA manifest, so a C2PA manifest signed by one of them on AI-generated
|
||||
content implies SynthID in the pixels. Adobe Firefly / Microsoft Designer
|
||||
sign C2PA but do not use SynthID, so they return None.
|
||||
|
||||
The verdict is reliable only while the C2PA manifest is intact -- absence
|
||||
is not proof, because C2PA can be stripped while the pixel watermark
|
||||
survives, and the pixel watermark itself is not locally detectable
|
||||
(proprietary decoder).
|
||||
|
||||
Args:
|
||||
image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container).
|
||||
|
||||
Returns:
|
||||
Comma-joined vendor name(s) (e.g. ``"OpenAI"``) or None.
|
||||
"""
|
||||
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, synthid_vendors_in
|
||||
|
||||
# PNG: the caBX chunk parser gives a clean, structured issuer.
|
||||
vendors = extract_c2pa_info(image_path).get("synthid_vendors")
|
||||
if vendors:
|
||||
return ", ".join(vendors)
|
||||
|
||||
# Non-PNG containers (JPEG APP11, WebP, AVIF/HEIF/JXL uuid box) keep the
|
||||
# C2PA manifest where the PNG parser can't reach it. Binary-scan for the
|
||||
# same signal: a C2PA manifest from a SynthID-using issuer on AI content.
|
||||
with open(image_path, "rb") as f:
|
||||
data = f.read(1024 * 1024)
|
||||
has_c2pa = b"c2pa" in data.lower() or C2PA_UUID in data
|
||||
# Matches both "trainedAlgorithmicMedia" and "compositeWithTrainedAlgorithmicMedia".
|
||||
ai_source = b"trainedAlgorithmicMedia" in data or b"TrainedAlgorithmicMedia" in data
|
||||
if not (has_c2pa and ai_source):
|
||||
return None
|
||||
matched = synthid_vendors_in(data)
|
||||
return ", ".join(matched) if matched else None
|
||||
|
||||
|
||||
def exif_generator(image_path: Path) -> str | None:
|
||||
"""Return an AI-generator name from the EXIF ``Software`` / XMP ``CreatorTool``
|
||||
field, if it matches a known generator (see ``AI_GENERATOR_TOKENS``), else None.
|
||||
|
||||
Cross-format: EXIF is read via PIL + piexif for any container PIL can open
|
||||
(JPEG/WebP/AVIF/PNG); an XMP ``CreatorTool`` raw-byte scan additionally covers
|
||||
HEIF/JPEG-XL that PIL can't open without plugins. Only AI tokens match, so
|
||||
ordinary editors (plain "Adobe Photoshop", "GIMP") are not flagged.
|
||||
"""
|
||||
import re
|
||||
|
||||
from remove_ai_watermarks.noai.c2pa import extract_c2pa_chunk
|
||||
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
|
||||
|
||||
raw = extract_c2pa_chunk(image_path)
|
||||
if raw is None:
|
||||
return {}
|
||||
candidates: list[str] = []
|
||||
|
||||
# extract_c2pa_chunk returns chunk_header (8 bytes) + data + crc (4 bytes).
|
||||
payload = raw[8:-4]
|
||||
result: dict[str, str] = {"c2pa_manifest": f"C2PA manifest ({len(payload)} bytes)"}
|
||||
# EXIF Software / Artist / ImageDescription (0th IFD) via PIL exif bytes.
|
||||
try:
|
||||
import piexif
|
||||
from PIL import Image
|
||||
|
||||
def _cbor_text_after(key: bytes) -> str | None:
|
||||
"""Return the CBOR text-string immediately following ``key``.
|
||||
with Image.open(image_path) as img:
|
||||
exif_bytes = img.info.get("exif")
|
||||
if exif_bytes:
|
||||
tags = piexif.load(exif_bytes).get("0th", {})
|
||||
# Make catches camera-style tags AI tools reuse (Ideogram writes
|
||||
# Make="Ideogram AI"); real cameras put "Apple"/"Canon" there, which
|
||||
# carry no AI token, so this stays low-false-positive.
|
||||
for tag in (
|
||||
piexif.ImageIFD.Software,
|
||||
piexif.ImageIFD.Make,
|
||||
piexif.ImageIFD.Artist,
|
||||
piexif.ImageIFD.ImageDescription,
|
||||
):
|
||||
value = tags.get(tag)
|
||||
if isinstance(value, bytes):
|
||||
candidates.append(value.decode("latin1", "replace"))
|
||||
except Exception as exc: # unopenable format / malformed EXIF
|
||||
logger.debug("EXIF generator read failed for %s: %s", image_path, exc)
|
||||
|
||||
Handles CBOR major-type 3 length prefixes: direct (0x60-0x77),
|
||||
1-byte (0x78 NN), and 2-byte (0x79 NN NN).
|
||||
"""
|
||||
idx = payload.find(key)
|
||||
if idx < 0:
|
||||
return None
|
||||
p = idx + len(key)
|
||||
if p >= len(payload):
|
||||
return None
|
||||
head = payload[p]
|
||||
if 0x60 <= head <= 0x77:
|
||||
length, start = head - 0x60, p + 1
|
||||
elif head == 0x78 and p + 1 < len(payload):
|
||||
length, start = payload[p + 1], p + 2
|
||||
elif head == 0x79 and p + 2 < len(payload):
|
||||
length, start = (payload[p + 1] << 8) | payload[p + 2], p + 3
|
||||
else:
|
||||
return None
|
||||
raw_str = payload[start : start + length]
|
||||
try:
|
||||
return raw_str.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return raw_str.decode("latin1", errors="replace")
|
||||
# XMP CreatorTool: text, container-agnostic (covers HEIF/JXL via raw scan).
|
||||
try:
|
||||
with open(image_path, "rb") as f:
|
||||
head = f.read(1024 * 1024)
|
||||
for match in re.finditer(rb"CreatorTool[>\"'=\s]{1,4}([^<\"']{1,80})", head):
|
||||
candidates.append(match.group(1).decode("latin1", "replace"))
|
||||
except Exception as exc:
|
||||
logger.debug("XMP CreatorTool scan failed for %s: %s", image_path, exc)
|
||||
|
||||
if generator := _cbor_text_after(b"name"):
|
||||
result["claim_generator"] = generator
|
||||
for value in candidates:
|
||||
if any(token in value.lower() for token in AI_GENERATOR_TOKENS):
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
if spec := _cbor_text_after(b"specVersion"):
|
||||
result["c2pa_spec"] = spec
|
||||
|
||||
dst_match = re.search(
|
||||
rb"(http://cv\.iptc\.org/newscodes/digitalsourcetype/[A-Za-z0-9_-]+)",
|
||||
payload,
|
||||
# xAI / Grok EXIF signature scheme. A 64+ char base64 blob after "Signature:"
|
||||
# is far beyond any incidental description text, and the UUID Artist makes the
|
||||
# pair xAI-specific -- both required keeps the false-positive rate near zero.
|
||||
_XAI_SIGNATURE_RE = re.compile(r"Signature:\s*[A-Za-z0-9+/=]{64,}")
|
||||
_UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE)
|
||||
|
||||
|
||||
def _is_xai_signature_pair(description: str, artist: str) -> bool:
|
||||
"""True if an EXIF (ImageDescription, Artist) pair is xAI/Grok's scheme."""
|
||||
return _XAI_SIGNATURE_RE.match(description) is not None and _UUID_RE.fullmatch(artist) is not None
|
||||
|
||||
|
||||
def _exif_text(ifd: dict, tag: int) -> str:
|
||||
"""Decode a piexif 0th-IFD byte tag to a stripped string ('' if absent)."""
|
||||
value = ifd.get(tag)
|
||||
return value.decode("latin1", "replace").strip() if isinstance(value, bytes) else ""
|
||||
|
||||
|
||||
def xai_signature(image_path: Path) -> bool:
|
||||
"""Detect xAI / Grok's EXIF provenance signature scheme.
|
||||
|
||||
Grok image downloads (Aurora model) carry no C2PA, XMP, SynthID, or IPTC --
|
||||
their only provenance signal is a private EXIF pair: ``ImageDescription`` =
|
||||
``"Signature: <base64>"`` together with ``Artist`` = the image UUID. Verified
|
||||
stable across three independent generations (2026-05-26; see CLAUDE.md). The
|
||||
signature is xAI's and is not locally verifiable (no public key); detection
|
||||
keys on this distinctive, low-false-positive shape, not on the signature's
|
||||
validity. It survives only on the *original* JPEG download -- the web-UI
|
||||
image is a re-encoded WebP that drops EXIF.
|
||||
"""
|
||||
try:
|
||||
import piexif
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
exif_bytes = img.info.get("exif")
|
||||
if not exif_bytes:
|
||||
return False
|
||||
tags = piexif.load(exif_bytes).get("0th", {})
|
||||
except Exception as exc: # unopenable format / malformed EXIF
|
||||
logger.debug("xAI-signature EXIF read failed for %s: %s", image_path, exc)
|
||||
return False
|
||||
|
||||
return _is_xai_signature_pair(
|
||||
_exif_text(tags, piexif.ImageIFD.ImageDescription), _exif_text(tags, piexif.ImageIFD.Artist)
|
||||
)
|
||||
if dst_match:
|
||||
result["digital_source_type"] = dst_match.group(1).decode("latin1")
|
||||
|
||||
actions = sorted(
|
||||
{m.decode("latin1") for m in re.findall(rb"c2pa\.(created|converted|edited|opened|placed)", payload)}
|
||||
)
|
||||
if actions:
|
||||
result["c2pa_actions"] = ", ".join(actions)
|
||||
|
||||
# Scan cert DN printable strings for the signer org name.
|
||||
signer_match = re.search(
|
||||
rb"([A-Za-z][A-Za-z0-9 .,&'()\-]{2,48}OpenAI[A-Za-z0-9 .,&'()\-]{0,48})",
|
||||
payload,
|
||||
)
|
||||
if signer_match:
|
||||
result["signer"] = signer_match.group(1).decode("latin1").strip()
|
||||
def _scrub_ai_exif(exif_dict: dict) -> list[str]:
|
||||
"""Delete AI-provenance tags from a piexif dict's ``0th`` IFD, in place.
|
||||
|
||||
return result
|
||||
Removes (a) the xAI/Grok signature pair (``ImageDescription`` "Signature: ..."
|
||||
+ UUID ``Artist``) and (b) any ``Software`` / ``Make`` / ``Artist`` /
|
||||
``ImageDescription`` tag whose value carries an ``AI_GENERATOR_TOKENS`` token
|
||||
(Ideogram's ``Make``, Firefly's ``Software``, etc.). Mirrors the detection in
|
||||
``xai_signature`` / ``exif_generator`` so removal scrubs exactly what
|
||||
``identify`` flags, while leaving genuine camera/editor EXIF intact. Returns
|
||||
the names of the removed tags (for logging).
|
||||
"""
|
||||
import piexif
|
||||
|
||||
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
|
||||
|
||||
ifd = exif_dict.get("0th")
|
||||
if not ifd:
|
||||
return []
|
||||
|
||||
drop: dict[int, str] = {}
|
||||
|
||||
# (a) xAI / Grok: the Signature blob and the UUID Artist go together.
|
||||
if _is_xai_signature_pair(
|
||||
_exif_text(ifd, piexif.ImageIFD.ImageDescription), _exif_text(ifd, piexif.ImageIFD.Artist)
|
||||
):
|
||||
drop[piexif.ImageIFD.ImageDescription] = "ImageDescription"
|
||||
drop[piexif.ImageIFD.Artist] = "Artist"
|
||||
|
||||
# (b) Known AI generator token in any of the text tags.
|
||||
for tag, name in (
|
||||
(piexif.ImageIFD.Software, "Software"),
|
||||
(piexif.ImageIFD.Make, "Make"),
|
||||
(piexif.ImageIFD.Artist, "Artist"),
|
||||
(piexif.ImageIFD.ImageDescription, "ImageDescription"),
|
||||
):
|
||||
if any(token in _exif_text(ifd, tag).lower() for token in AI_GENERATOR_TOKENS):
|
||||
drop[tag] = name
|
||||
|
||||
for tag in drop:
|
||||
ifd.pop(tag, None)
|
||||
return list(drop.values())
|
||||
|
||||
|
||||
def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
@@ -203,22 +426,100 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, soft_binding_vendors_in, synthid_verdict
|
||||
|
||||
result: dict[str, str] = {}
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
for key, value in img.info.items():
|
||||
if _is_ai_key(key):
|
||||
if isinstance(value, bytes):
|
||||
result[key] = f"<binary {len(value)} bytes>"
|
||||
elif isinstance(value, str) and len(value) > 200:
|
||||
result[key] = value[:200] + "…"
|
||||
else:
|
||||
result[key] = str(value)
|
||||
# PIL may not open AVIF/HEIF/JPEG-XL without optional plugins (and
|
||||
# ultralytics' Image.open patch can raise ModuleNotFoundError); fall through
|
||||
# to the C2PA/binary path on any open failure. See CLAUDE.md.
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
for key, value in img.info.items():
|
||||
if _is_ai_key(key):
|
||||
if isinstance(value, bytes):
|
||||
result[key] = f"<binary {len(value)} bytes>"
|
||||
elif isinstance(value, str) and len(value) > 200:
|
||||
result[key] = value[:200] + "…"
|
||||
else:
|
||||
result[key] = str(value)
|
||||
except Exception as exc:
|
||||
logger.debug("PIL could not open %s for AI-metadata scan: %s", image_path, exc)
|
||||
|
||||
result.update(_scan_png_c2pa_chunk(image_path))
|
||||
# C2PA manifest fields from the single canonical parser (noai/c2pa.py).
|
||||
c2pa = extract_c2pa_info(image_path)
|
||||
for key in (
|
||||
"c2pa_manifest",
|
||||
"claim_generator",
|
||||
"c2pa_spec",
|
||||
"issuer",
|
||||
"source_type",
|
||||
"actions",
|
||||
"synthid_watermark",
|
||||
"soft_binding",
|
||||
):
|
||||
if key in c2pa:
|
||||
result.setdefault(key, str(c2pa[key]))
|
||||
|
||||
# Non-PNG containers (JPEG/WebP/AVIF/MP4): extract_c2pa_info is PNG-only, so
|
||||
# fall back to the format-agnostic source check for the SynthID verdict and
|
||||
# the soft-binding (forensic-watermark vendor) scan.
|
||||
if "synthid_watermark" not in result and (vendor := synthid_source(image_path)):
|
||||
result.setdefault("synthid_watermark", synthid_verdict(vendor))
|
||||
if "soft_binding" not in result:
|
||||
with open(image_path, "rb") as f:
|
||||
head = f.read(1024 * 1024)
|
||||
if vendors := soft_binding_vendors_in(head):
|
||||
result["soft_binding"] = ", ".join(vendors)
|
||||
|
||||
# China TC260 AI-content label (Doubao and other China-served generators).
|
||||
if aigc := aigc_label(image_path):
|
||||
producer = aigc.get("ContentProducer", "")
|
||||
result["aigc_label"] = f"China AIGC label (TC260){f'; producer {producer}' if producer else ''}"
|
||||
|
||||
# xAI / Grok EXIF signature scheme (its only provenance signal).
|
||||
if xai_signature(image_path):
|
||||
result.setdefault("xai_signature", "xAI/Grok EXIF signature (Artist UUID + Signature blob)")
|
||||
|
||||
# IPTC 2025.1 AI-disclosure XMP fields (Iptc4xmpExt:AISystemUsed etc.).
|
||||
if system := iptc_ai_system(image_path):
|
||||
result.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({system})")
|
||||
return result
|
||||
|
||||
|
||||
def _strip_with_ffmpeg(source_path: Path, output_path: Path) -> Path:
|
||||
"""Strip container metadata from a non-ISOBMFF audio/video file via ffmpeg.
|
||||
|
||||
Uses a lossless stream copy (``-c copy``), so codec data is untouched and only
|
||||
container-level tags/chapters are dropped -- the metadata strip for WebM /
|
||||
Matroska (EBML), MP3 (ID3), WAV / FLAC / OGG (RIFF / Vorbis comments) that the
|
||||
ISOBMFF box walker cannot reach. Requires ffmpeg on PATH (raises if absent).
|
||||
The output extension should match the source so ``-c copy`` can re-mux.
|
||||
"""
|
||||
import shutil
|
||||
import subprocess
|
||||
|
||||
ffmpeg = shutil.which("ffmpeg")
|
||||
if ffmpeg is None:
|
||||
raise RuntimeError(
|
||||
f"ffmpeg is required to strip metadata from {source_path.suffix} files but was not found on "
|
||||
"PATH; install ffmpeg (e.g. `brew install ffmpeg`) or re-encode the file with another tool"
|
||||
)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cmd = [
|
||||
ffmpeg, "-y", "-loglevel", "error",
|
||||
"-i", str(source_path),
|
||||
"-map_metadata", "-1", "-map_chapters", "-1",
|
||||
"-c", "copy",
|
||||
str(output_path),
|
||||
]
|
||||
result = subprocess.run(cmd, capture_output=True, text=True, check=False) # noqa: S603
|
||||
if result.returncode != 0:
|
||||
raise RuntimeError(f"ffmpeg failed to strip metadata from {source_path}: {result.stderr.strip()[:300]}")
|
||||
logger.info("Stripped container metadata via ffmpeg -> %s", output_path)
|
||||
return output_path
|
||||
|
||||
|
||||
def remove_ai_metadata(
|
||||
source_path: Path,
|
||||
output_path: Path | None = None,
|
||||
@@ -244,6 +545,30 @@ def remove_ai_metadata(
|
||||
if output_path is None:
|
||||
output_path = source_path
|
||||
|
||||
# ISOBMFF containers (AVIF/HEIF/JPEG-XL images, MP4/MOV/M4V video, M4A audio):
|
||||
# strip C2PA + AI-label boxes at the container level without re-encoding.
|
||||
# Avoids needing PIL plugins (pillow-heif / pillow-jxl) and preserves the
|
||||
# codestream bit-for-bit. MP4/MOV/M4A are ISOBMFF too, so the same top-level
|
||||
# uuid/jumb box walker applies. Route by suffix OR by an ``ftyp`` content
|
||||
# sniff, so a correctly-shaped container is handled whatever its extension.
|
||||
from remove_ai_watermarks.noai.isobmff import is_isobmff, strip_c2pa_boxes
|
||||
|
||||
with open(source_path, "rb") as f:
|
||||
head = f.read(12)
|
||||
if source_path.suffix.lower() in _ISOBMFF_EXTS or is_isobmff(head):
|
||||
data = source_path.read_bytes()
|
||||
cleaned, stripped = strip_c2pa_boxes(data)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(cleaned)
|
||||
logger.info("Stripped %d AI-provenance box(es) → %s", stripped, output_path)
|
||||
return output_path
|
||||
|
||||
# Non-ISOBMFF audio/video (WebM/Matroska EBML, MP3 ID3, WAV/FLAC/OGG): the
|
||||
# box walker can't reach these, so strip container metadata losslessly via
|
||||
# ffmpeg (-c copy -- codec data untouched, only tags/chapters dropped).
|
||||
if source_path.suffix.lower() in _FFMPEG_STRIP_EXTS:
|
||||
return _strip_with_ffmpeg(source_path, output_path)
|
||||
|
||||
# Read image and filter metadata
|
||||
with Image.open(source_path) as img:
|
||||
img = img.copy()
|
||||
@@ -282,6 +607,10 @@ def remove_ai_metadata(
|
||||
save_kwargs["pnginfo"] = pnginfo
|
||||
|
||||
if exif_data and save_kwargs["format"] == "JPEG":
|
||||
# Scrub AI-provenance EXIF tags (xAI/Grok signature, generator tokens)
|
||||
# while keeping genuine camera/editor EXIF; PNG output drops EXIF entirely.
|
||||
if removed := _scrub_ai_exif(exif_data):
|
||||
logger.info("Scrubbed AI EXIF tag(s): %s", ", ".join(removed))
|
||||
with contextlib.suppress(Exception):
|
||||
save_kwargs["exif"] = piexif.dump(exif_data)
|
||||
|
||||
|
||||
@@ -28,7 +28,9 @@ from remove_ai_watermarks.noai.constants import (
|
||||
C2PA_CHUNK_TYPE,
|
||||
C2PA_ISSUERS,
|
||||
C2PA_SIGNATURES,
|
||||
C2PA_SOFT_BINDINGS,
|
||||
PNG_SIGNATURE,
|
||||
SYNTHID_C2PA_ISSUERS,
|
||||
)
|
||||
|
||||
|
||||
@@ -129,10 +131,68 @@ def extract_c2pa_info(image_path: Path) -> dict[str, Any]:
|
||||
return c2pa_info
|
||||
|
||||
|
||||
def cbor_text_after(payload: bytes, key: bytes) -> str | None:
|
||||
"""Return the CBOR text-string immediately following ``key`` in ``payload``.
|
||||
|
||||
Handles CBOR major-type 3 length prefixes: direct (0x60-0x77), 1-byte
|
||||
(0x78 NN), and 2-byte (0x79 NN NN). This reads the actual encoded value, so
|
||||
it avoids the byte-grabbing artifacts a loose regex produces (e.g. the
|
||||
leading length byte showing up as ``fGPT-4o``).
|
||||
"""
|
||||
idx = payload.find(key)
|
||||
if idx < 0:
|
||||
return None
|
||||
p = idx + len(key)
|
||||
if p >= len(payload):
|
||||
return None
|
||||
head = payload[p]
|
||||
if 0x60 <= head <= 0x77:
|
||||
length, start = head - 0x60, p + 1
|
||||
elif head == 0x78 and p + 1 < len(payload):
|
||||
length, start = payload[p + 1], p + 2
|
||||
elif head == 0x79 and p + 2 < len(payload):
|
||||
length, start = (payload[p + 1] << 8) | payload[p + 2], p + 3
|
||||
else:
|
||||
return None
|
||||
raw_str = payload[start : start + length]
|
||||
try:
|
||||
return raw_str.decode("utf-8")
|
||||
except UnicodeDecodeError:
|
||||
return raw_str.decode("latin1", errors="replace")
|
||||
|
||||
|
||||
def synthid_verdict(vendors: str) -> str:
|
||||
"""Human-readable SynthID-source verdict, shared by all callers."""
|
||||
return f"likely present ({vendors} embeds SynthID with C2PA)"
|
||||
|
||||
|
||||
def synthid_vendors_in(buffer: bytes) -> list[str]:
|
||||
"""Return SynthID-using C2PA issuer names whose signature appears in ``buffer``.
|
||||
|
||||
Shared by the PNG caBX parser and the format-agnostic binary scan so both
|
||||
apply the same SYNTHID_C2PA_ISSUERS rule against their respective bytes.
|
||||
"""
|
||||
return sorted({name for sig, name in C2PA_ISSUERS.items() if sig in buffer and sig in SYNTHID_C2PA_ISSUERS})
|
||||
|
||||
|
||||
def soft_binding_vendors_in(buffer: bytes) -> list[str]:
|
||||
"""Return forensic-watermark vendor names whose C2PA soft-binding ``alg``
|
||||
identifier appears in ``buffer``.
|
||||
|
||||
A ``c2pa.soft-binding`` assertion names the watermark scheme that stamped the
|
||||
pixels (Adobe TrustMark, Digimarc, Imatag, Steg.AI, ...). Shared by the PNG
|
||||
caBX parser and the format-agnostic binary scan so both apply the same
|
||||
C2PA_SOFT_BINDINGS rule against their respective bytes.
|
||||
"""
|
||||
return sorted({name for sig, name in C2PA_SOFT_BINDINGS.items() if sig in buffer})
|
||||
|
||||
|
||||
def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
|
||||
"""Parse C2PA chunk data and populate info dictionary."""
|
||||
c2pa_info["c2pa_manifest"] = f"C2PA manifest ({len(chunk_data)} bytes)"
|
||||
|
||||
# Find issuers
|
||||
issuers = []
|
||||
issuers: list[str] = []
|
||||
for sig, name in C2PA_ISSUERS.items():
|
||||
if sig in chunk_data:
|
||||
issuers.append(name)
|
||||
@@ -140,44 +200,25 @@ def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
|
||||
c2pa_info["issuer"] = ", ".join(set(issuers))
|
||||
|
||||
# Find AI tools
|
||||
ai_tools = []
|
||||
ai_tools: list[str] = []
|
||||
for sig, name in C2PA_AI_TOOLS.items():
|
||||
if sig in chunk_data:
|
||||
ai_tools.append(name)
|
||||
if ai_tools:
|
||||
c2pa_info["ai_tool"] = ", ".join(set(ai_tools))
|
||||
|
||||
# Extract software agent (multiple patterns)
|
||||
patterns = [
|
||||
rb"softwareAgent.*?dname([^\x00]+?)(?:q|l|m|n)",
|
||||
rb"software_agent[^\x00]*?([A-Za-z0-9_\-\.]+)",
|
||||
rb"Software[^\x00]*?([A-Za-z0-9_\-\. ]+)",
|
||||
]
|
||||
for pattern in patterns:
|
||||
match = re.search(pattern, chunk_data, re.DOTALL | re.IGNORECASE)
|
||||
if match:
|
||||
agent = match.group(1).decode("utf-8", errors="ignore").strip()
|
||||
if agent and len(agent) < 100:
|
||||
c2pa_info["software_agent"] = agent
|
||||
break
|
||||
|
||||
# Extract claim generator (multiple patterns)
|
||||
claim_patterns = [
|
||||
rb"claim_generator[^\x00]*?([A-Za-z0-9_\-\.\/\:]+)",
|
||||
rb"claimGenerator[^\x00]*?([A-Za-z0-9_\-\.\/\:]+)",
|
||||
rb"dname([^\x00]{3,50})(?:q|l|m|n|i)",
|
||||
]
|
||||
for pattern in claim_patterns:
|
||||
match = re.search(pattern, chunk_data, re.DOTALL | re.IGNORECASE)
|
||||
if match:
|
||||
gen_name = match.group(1).decode("utf-8", errors="ignore").strip()
|
||||
# Filter out common false positives
|
||||
if gen_name and len(gen_name) < 100 and not gen_name.startswith(("\\x", "\\\\x")):
|
||||
c2pa_info["claim_generator"] = gen_name
|
||||
break
|
||||
# Claim generator and spec version: read the CBOR text-string values
|
||||
# directly (regex byte-grabbing produced artifacts like ``fGPT-4o``).
|
||||
# Guard with isprintable(): on some manifests (e.g. Microsoft Designer) the
|
||||
# first ``name`` key precedes a binary field (a hash), not the generator
|
||||
# string, which would otherwise surface as control-char garbage.
|
||||
if (generator := cbor_text_after(chunk_data, b"name")) and generator.isprintable():
|
||||
c2pa_info["claim_generator"] = generator
|
||||
if (spec := cbor_text_after(chunk_data, b"specVersion")) and spec.isprintable():
|
||||
c2pa_info["c2pa_spec"] = spec
|
||||
|
||||
# Find actions
|
||||
actions = []
|
||||
actions: list[str] = []
|
||||
for sig, name in C2PA_ACTIONS.items():
|
||||
if sig in chunk_data:
|
||||
actions.append(name)
|
||||
@@ -192,12 +233,30 @@ def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
|
||||
c2pa_info["timestamps"] = [t.decode("utf-8") for t in timestamp_matches[:3]]
|
||||
|
||||
# Find digital source type
|
||||
ai_source = False
|
||||
if b"trainedAlgorithmicMedia" in chunk_data:
|
||||
c2pa_info["source_type"] = "trainedAlgorithmicMedia (AI-generated)"
|
||||
ai_source = True
|
||||
elif b"algorithmicMedia" in chunk_data:
|
||||
c2pa_info["source_type"] = "algorithmicMedia"
|
||||
elif b"compositeWithTrainedAlgorithmicMedia" in chunk_data:
|
||||
c2pa_info["source_type"] = "compositeWithTrainedAlgorithmicMedia (AI-enhanced)"
|
||||
ai_source = True
|
||||
|
||||
# SynthID pixel-watermark proxy: a C2PA manifest from a SynthID-using
|
||||
# vendor (Google/OpenAI) on AI-generated content implies an invisible
|
||||
# SynthID watermark in the pixels (see SYNTHID_C2PA_ISSUERS).
|
||||
synthid_vendors = synthid_vendors_in(chunk_data)
|
||||
if synthid_vendors and ai_source:
|
||||
c2pa_info["synthid_vendors"] = synthid_vendors
|
||||
c2pa_info["synthid_watermark"] = synthid_verdict(", ".join(synthid_vendors))
|
||||
|
||||
# Soft-binding: a forensic/third-party watermark vendor named in the
|
||||
# manifest (Adobe TrustMark, Digimarc, ...), independent of the issuer.
|
||||
soft_binding_vendors = soft_binding_vendors_in(chunk_data)
|
||||
if soft_binding_vendors:
|
||||
c2pa_info["soft_binding_vendors"] = soft_binding_vendors
|
||||
c2pa_info["soft_binding"] = ", ".join(soft_binding_vendors)
|
||||
|
||||
|
||||
def extract_c2pa_chunk(image_path: Path) -> bytes | None:
|
||||
|
||||
@@ -85,8 +85,32 @@ C2PA_ISSUERS = {
|
||||
b"Microsoft": "Microsoft",
|
||||
b"OpenAI": "OpenAI",
|
||||
b"Truepic": "Truepic",
|
||||
# Stability AI signs C2PA as "Stability AI" (cert org "Stability AI Ltd").
|
||||
# Verified on a live Brand Studio (DreamStudio successor) output, 2026-05-24.
|
||||
b"Stability AI": "Stability AI",
|
||||
}
|
||||
|
||||
# C2PA issuers whose signed outputs also carry an invisible SynthID pixel
|
||||
# watermark -- a metadata proxy for "SynthID is in the pixels":
|
||||
# - Google (Imagen/Gemini): embeds SynthID, long-standing (DeepMind docs).
|
||||
# - OpenAI (ChatGPT/Codex/API): pairs SynthID with C2PA since ~2026-05-20.
|
||||
# Confirmed by OpenAI's Help Center ("C2PA and SynthID in OpenAI-generated
|
||||
# images", updated 2026-05-21): "Images generated with ChatGPT, Codex, and
|
||||
# our API include both C2PA metadata and SynthID watermarks." OpenAI also
|
||||
# notes a signal may be absent if "the image was created before these
|
||||
# signals were available" -- so OpenAI images from BEFORE the rollout carry
|
||||
# C2PA WITHOUT SynthID (e.g. data/samples/openai-images-2/amur-leopard.png,
|
||||
# C2PA timestamp 2026-04-22). For OpenAI the proxy is therefore "likely",
|
||||
# not certain; the verdict string is hedged accordingly. OpenAI's own oracle
|
||||
# is openai.com/verify (Google's is the Gemini app "Verify with SynthID").
|
||||
# The issuer byte ("OpenAI"/"Google") is verified locally against data/samples;
|
||||
# the SynthID pairing is documented behavior (Google: DeepMind; OpenAI: above).
|
||||
# Adobe Firefly and Microsoft Designer sign C2PA but do NOT use SynthID, so a
|
||||
# C2PA manifest alone is not a SynthID signal -- the issuer is. The pixel
|
||||
# watermark is not locally detectable (proprietary decoder); the C2PA companion
|
||||
# is the proxy, and only while the manifest is intact.
|
||||
SYNTHID_C2PA_ISSUERS = frozenset({b"Google", b"OpenAI"})
|
||||
|
||||
# C2PA known AI tools
|
||||
C2PA_AI_TOOLS = {
|
||||
b"GPT-4o": "GPT-4o",
|
||||
@@ -98,6 +122,61 @@ C2PA_AI_TOOLS = {
|
||||
b"Firefly": "Firefly",
|
||||
}
|
||||
|
||||
# C2PA ``c2pa.soft-binding`` algorithm identifiers -> the forensic-watermark
|
||||
# vendor that stamped the pixels. The manifest's ``alg`` field names the
|
||||
# watermark scheme even when the watermark itself cannot be decoded locally, so
|
||||
# a byte-scan for these (keyed on a distinctive prefix to catch all variants)
|
||||
# tells us a third-party forensic watermark is present and whose. Verified
|
||||
# against the official C2PA registry (github.com/c2pa-org/softbinding-algorithm-list).
|
||||
# Adobe TrustMark is additionally decodable locally (see ``trustmark_detector``);
|
||||
# the rest (Digimarc, Imatag, Steg.AI, etc.) are proprietary oracle-only decoders.
|
||||
C2PA_SOFT_BINDINGS = {
|
||||
b"com.adobe.trustmark": "Adobe TrustMark",
|
||||
b"com.adobe.icn": "Adobe (content fingerprint)",
|
||||
b"com.digimarc": "Digimarc",
|
||||
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"app.overlai": "Overlai",
|
||||
b"com.markany": "MarkAny",
|
||||
b"com.mentaport": "Mentaport",
|
||||
b"es.lumatrace": "LumaTrace",
|
||||
b"ai.verda": "VerdaAI",
|
||||
b"ai.contentlens": "ContentLens",
|
||||
b"io.iscc": "ISCC (content code)",
|
||||
}
|
||||
|
||||
# Lowercased substrings that mark an AI generator when found in an EXIF
|
||||
# ``Software`` / XMP ``CreatorTool`` value. Conservative on purpose: plain
|
||||
# editors like "Adobe Photoshop" or "GIMP" must NOT match (no AI token), so only
|
||||
# generator names land here. Add new generators here, not inline.
|
||||
AI_GENERATOR_TOKENS: frozenset[str] = frozenset(
|
||||
{
|
||||
"firefly",
|
||||
"dall-e",
|
||||
"dalle",
|
||||
"midjourney",
|
||||
"stable diffusion",
|
||||
"stable-diffusion",
|
||||
"stablediffusion",
|
||||
"comfyui",
|
||||
"automatic1111",
|
||||
"invokeai",
|
||||
"imagen",
|
||||
"gpt-image",
|
||||
"nightcafe",
|
||||
"ideogram",
|
||||
"leonardo",
|
||||
"flux",
|
||||
"dreamstudio",
|
||||
}
|
||||
)
|
||||
|
||||
# C2PA action types
|
||||
C2PA_ACTIONS = {
|
||||
b"c2pa.created": "created",
|
||||
@@ -106,6 +185,8 @@ C2PA_ACTIONS = {
|
||||
b"c2pa.filtered": "filtered",
|
||||
b"c2pa.cropped": "cropped",
|
||||
b"c2pa.resized": "resized",
|
||||
b"c2pa.opened": "opened",
|
||||
b"c2pa.placed": "placed",
|
||||
}
|
||||
|
||||
# PNG signature
|
||||
|
||||
@@ -36,9 +36,10 @@ _HAS_COLOR_MATCHER = False
|
||||
_HAS_DIFFUSERS = False
|
||||
|
||||
try:
|
||||
from ctrlregen.pipeline import CustomCtrlRegenPipeline
|
||||
from diffusers import AutoencoderKL, ControlNetModel, UniPCMultistepScheduler
|
||||
|
||||
from remove_ai_watermarks.noai.ctrlregen.pipeline import CustomCtrlRegenPipeline
|
||||
|
||||
_HAS_DIFFUSERS = True
|
||||
except ImportError:
|
||||
AutoencoderKL = None # type: ignore[assignment,misc]
|
||||
@@ -54,7 +55,7 @@ except ImportError:
|
||||
CannyDetector = None # type: ignore[assignment,misc]
|
||||
|
||||
try:
|
||||
from ctrlregen.color import color_match
|
||||
from remove_ai_watermarks.noai.ctrlregen.color import color_match
|
||||
|
||||
_HAS_COLOR_MATCHER = True
|
||||
except ImportError:
|
||||
@@ -239,7 +240,7 @@ class CtrlRegenEngine:
|
||||
needs_tiling = orig_w > TILE_SIZE or orig_h > TILE_SIZE
|
||||
|
||||
if needs_tiling:
|
||||
from ctrlregen.tiling import resize_center_crop, run_tiled
|
||||
from remove_ai_watermarks.noai.ctrlregen.tiling import resize_center_crop, run_tiled
|
||||
|
||||
aligned_w = orig_w // 8 * 8
|
||||
aligned_h = orig_h // 8 * 8
|
||||
@@ -264,7 +265,7 @@ class CtrlRegenEngine:
|
||||
ip_adapter_image=orig_image,
|
||||
)
|
||||
else:
|
||||
from ctrlregen.tiling import resize_center_crop
|
||||
from remove_ai_watermarks.noai.ctrlregen.tiling import resize_center_crop
|
||||
|
||||
proc_image = resize_center_crop(image, PROCESS_SIZE)
|
||||
self._set_progress(f"Preprocessed {orig_w}x{orig_h}px → {proc_image.size[0]}x{proc_image.size[1]}px")
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
"""Minimal ISOBMFF box walker for stripping C2PA from AVIF / HEIF / MP4 / JPEG-XL.
|
||||
|
||||
The ISO Base Media File Format wraps content in nested ``[size:4][type:4][...]``
|
||||
boxes. C2PA stores its manifest in a top-level ``uuid`` box keyed by the
|
||||
C2PA UUID; JPEG-XL uses a ``jumb`` box (JUMBF) instead. To strip provenance
|
||||
without re-encoding the image, we walk the top-level box list, drop boxes that
|
||||
carry C2PA, and emit the rest verbatim. The codestream (``mdat`` for ISOBMFF,
|
||||
``jxlc`` / ``jxlp`` for JPEG-XL) is untouched, so pixel data is preserved
|
||||
bit-for-bit.
|
||||
|
||||
This file intentionally avoids dependencies on format-specific libraries
|
||||
(pillow-heif, pillow-jxl, pymp4) so it works on systems where they aren't
|
||||
installed.
|
||||
|
||||
Reference: ISO/IEC 14496-12 (ISOBMFF) and C2PA 2.1 spec §11.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from remove_ai_watermarks.metadata import (
|
||||
AIGC_MARKERS,
|
||||
C2PA_UUID,
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
)
|
||||
|
||||
# Top-level box types that may carry AI provenance. ``uuid`` boxes are checked
|
||||
# against ``C2PA_UUID`` / AI-label markers before being stripped; ``jumb`` boxes
|
||||
# are always stripped (JPEG-XL uses them exclusively for JUMBF).
|
||||
C2PA_BOX_TYPES: frozenset[bytes] = frozenset({b"uuid", b"jumb"})
|
||||
|
||||
# AI-label byte markers (TC260 AIGC, IPTC "Made with AI", IPTC 2025.1 AI fields)
|
||||
# whose presence inside an XMP ``uuid`` box means the box carries an AI label.
|
||||
# Matching the payload rather than a fixed XMP UUID avoids the XMP-box UUID
|
||||
# byte-order ambiguity and stays surgical: only AI-bearing XMP is dropped, plain
|
||||
# XMP (copyright, camera info) is kept.
|
||||
_AI_LABEL_MARKERS: tuple[bytes, ...] = AIGC_MARKERS + IPTC_AI_MARKERS + IPTC_AI_FIELD_MARKERS
|
||||
|
||||
|
||||
def _iter_top_level_boxes(data: bytes) -> Iterator[tuple[int, int, bytes, int]]:
|
||||
"""Yield ``(start, end, type, payload_offset)`` for each top-level box.
|
||||
|
||||
Handles all three ISOBMFF box-size encodings:
|
||||
- ``size > 1``: 32-bit size field is the total box length.
|
||||
- ``size == 1``: 64-bit ``largesize`` follows after the type field.
|
||||
- ``size == 0``: box runs to end of file.
|
||||
"""
|
||||
pos = 0
|
||||
n = len(data)
|
||||
while pos + 8 <= n:
|
||||
size32 = struct.unpack_from(">I", data, pos)[0]
|
||||
box_type = data[pos + 4 : pos + 8]
|
||||
if size32 == 1:
|
||||
if pos + 16 > n:
|
||||
return
|
||||
size = struct.unpack_from(">Q", data, pos + 8)[0]
|
||||
payload_off = pos + 16
|
||||
elif size32 == 0:
|
||||
size = n - pos
|
||||
payload_off = pos + 8
|
||||
else:
|
||||
size = size32
|
||||
payload_off = pos + 8
|
||||
if size < (payload_off - pos) or pos + size > n:
|
||||
return
|
||||
yield pos, pos + size, box_type, payload_off
|
||||
pos += size
|
||||
|
||||
|
||||
def is_isobmff(data: bytes) -> bool:
|
||||
"""Cheap sniff: ISOBMFF files start with an ``ftyp`` box."""
|
||||
return len(data) >= 8 and data[4:8] == b"ftyp"
|
||||
|
||||
|
||||
def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
"""Return ``(cleaned_bytes, stripped_count)`` with AI-provenance boxes removed.
|
||||
|
||||
Walks top-level boxes and drops:
|
||||
- any ``uuid`` box whose UUID equals ``C2PA_UUID`` (a C2PA manifest);
|
||||
- any ``uuid`` box whose payload carries an AI-label marker (an XMP packet
|
||||
with a TC260 / IPTC / IPTC-2025.1 AI field -- caught by content, not by the
|
||||
XMP UUID, so it works regardless of the UUID's byte order, and leaves plain
|
||||
non-AI XMP intact);
|
||||
- any ``jumb`` box (JPEG-XL JUMBF container).
|
||||
|
||||
All other boxes (incl. ``mdat`` / codestream) are emitted verbatim, so pixel
|
||||
and audio data is preserved bit-for-bit. Non-ISOBMFF input is returned
|
||||
unchanged. Despite the name this also covers MP4/MOV/M4A video and audio
|
||||
(all ISOBMFF). NOTE: EXIF/XMP stored as *items inside the ``meta`` box*
|
||||
(typical for AVIF/HEIF images) is not removed -- that needs meta-box surgery
|
||||
and is a documented limitation.
|
||||
"""
|
||||
if not is_isobmff(data):
|
||||
return data, 0
|
||||
|
||||
out = bytearray()
|
||||
stripped = 0
|
||||
for start, end, box_type, payload_off in _iter_top_level_boxes(data):
|
||||
if box_type == b"uuid":
|
||||
# uuid boxes carry the 16-byte UUID immediately after the type.
|
||||
is_c2pa = payload_off + 16 <= end and data[payload_off : payload_off + 16] == C2PA_UUID
|
||||
has_ai_label = any(marker in data[payload_off:end] for marker in _AI_LABEL_MARKERS)
|
||||
if is_c2pa or has_ai_label:
|
||||
stripped += 1
|
||||
continue
|
||||
elif box_type == b"jumb":
|
||||
stripped += 1
|
||||
continue
|
||||
out.extend(data[start:end])
|
||||
return bytes(out), stripped
|
||||
@@ -5,7 +5,7 @@ Pure configuration and lookup functions with no ML dependencies.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
DEFAULT_MODEL_ID = "Lykon/dreamshaper-8"
|
||||
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
|
||||
|
||||
LOW_STRENGTH = 0.04
|
||||
|
||||
@@ -176,8 +176,11 @@ def _reinstall_torch_cuda_and_restart() -> None:
|
||||
return
|
||||
|
||||
os.environ[_CUDA_FIX_ENV_KEY] = "1"
|
||||
restart_code = f"import sys; sys.argv = {sys.argv!r}; from remove_ai_watermarks.cli import main; sys.exit(main())"
|
||||
os.execl(sys.executable, sys.executable, "-c", restart_code)
|
||||
# Re-exec via ``-m`` rather than building a ``-c`` string from repr(sys.argv).
|
||||
# ``-m`` makes Python set argv[0] to the module path, so forward only the
|
||||
# actual args (sys.argv[1:]); passing the full argv would re-inject the
|
||||
# program name as a spurious first argument to Click.
|
||||
os.execv(sys.executable, [sys.executable, "-m", "remove_ai_watermarks.cli", *sys.argv[1:]])
|
||||
|
||||
|
||||
def _ensure_watermark_deps() -> None:
|
||||
|
||||
@@ -0,0 +1,179 @@
|
||||
"""Universal region eraser: remove anything inside user-given boxes via inpainting.
|
||||
|
||||
Position- and content-agnostic. You supply the rectangle(s); the eraser inpaints
|
||||
whatever is inside, so it removes any visible logo / watermark / object regardless
|
||||
of colour, style, or location. Localisation is the user's responsibility (pass the
|
||||
box); restoration runs on CPU. This is the universal fallback for marks the
|
||||
deterministic per-generator engines (Gemini sparkle, Doubao) do not cover.
|
||||
|
||||
Backends:
|
||||
- ``cv2`` (default): ``cv2.inpaint`` (Telea / Navier-Stokes). Instant, no extra
|
||||
dependencies, lower quality on large or textured regions.
|
||||
- ``lama`` (optional, extra ``lama``): big-LaMa via onnxruntime
|
||||
(``Carve/LaMa-ONNX``, Apache-2.0). CPU, resolution-robust, much better on
|
||||
texture. The model (~200 MB) is downloaded on first use and cached by
|
||||
huggingface_hub; it is never bundled in this repo.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
Backend = Literal["cv2", "lama"]
|
||||
|
||||
_LAMA_REPO = "Carve/LaMa-ONNX"
|
||||
_LAMA_FILE = "lama_fp32.onnx"
|
||||
|
||||
# Cached onnxruntime session (loading is expensive; reuse across calls).
|
||||
_lama_session: object | None = None
|
||||
|
||||
|
||||
def boxes_to_mask(
|
||||
shape: tuple[int, int],
|
||||
boxes: list[tuple[int, int, int, int]],
|
||||
dilate: int = 3,
|
||||
) -> NDArray:
|
||||
"""Build a uint8 mask (255 inside boxes) from ``(x, y, w, h)`` rectangles."""
|
||||
h, w = shape
|
||||
mask = np.zeros((h, w), np.uint8)
|
||||
for x, y, bw, bh in boxes:
|
||||
x0, y0 = max(0, x), max(0, y)
|
||||
x1, y1 = min(w, x + bw), min(h, y + bh)
|
||||
if x1 > x0 and y1 > y0:
|
||||
mask[y0:y1, x0:x1] = 255
|
||||
if dilate > 0 and mask.any():
|
||||
k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * dilate + 1, 2 * dilate + 1))
|
||||
mask = cv2.dilate(mask, k)
|
||||
return mask
|
||||
|
||||
|
||||
def erase_cv2(
|
||||
image_bgr: NDArray,
|
||||
mask: NDArray,
|
||||
*,
|
||||
method: Literal["telea", "ns"] = "telea",
|
||||
radius: int = 6,
|
||||
) -> NDArray:
|
||||
"""Inpaint ``mask`` with classical cv2 inpainting (CPU, no extra deps)."""
|
||||
flag = cv2.INPAINT_TELEA if method == "telea" else cv2.INPAINT_NS
|
||||
return cv2.inpaint(image_bgr, mask, radius, flag)
|
||||
|
||||
|
||||
def lama_available() -> bool:
|
||||
"""True when the optional LaMa-ONNX backend can run (onnxruntime installed)."""
|
||||
try:
|
||||
import onnxruntime # noqa: F401
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _get_lama_session() -> object:
|
||||
"""Load (once) the big-LaMa ONNX session, downloading the model on first use."""
|
||||
global _lama_session
|
||||
if _lama_session is not None:
|
||||
return _lama_session
|
||||
|
||||
import onnxruntime as ort
|
||||
from huggingface_hub import hf_hub_download
|
||||
|
||||
model_path = hf_hub_download(repo_id=_LAMA_REPO, filename=_LAMA_FILE)
|
||||
logger.info("Loading LaMa-ONNX model: %s", model_path)
|
||||
_lama_session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"])
|
||||
return _lama_session
|
||||
|
||||
|
||||
def erase_lama(image_bgr: NDArray, mask: NDArray) -> NDArray:
|
||||
"""Inpaint ``mask`` with big-LaMa via onnxruntime (CPU).
|
||||
|
||||
LaMa runs at a fixed square input size. To preserve full-image resolution we
|
||||
crop a padded region around the mask, inpaint that crop at the model size,
|
||||
and paste only the masked pixels back -- so untouched areas stay pixel-exact.
|
||||
"""
|
||||
session = _get_lama_session()
|
||||
inp = session.get_inputs() # type: ignore[attr-defined]
|
||||
img_name = inp[0].name
|
||||
mask_name = inp[1].name
|
||||
# Model declares a fixed square spatial size (e.g. 512); fall back to 512.
|
||||
dims = inp[0].shape
|
||||
size = next((d for d in reversed(dims) if isinstance(d, int) and d > 1), 512)
|
||||
|
||||
h, w = image_bgr.shape[:2]
|
||||
ys, xs = np.where(mask > 0)
|
||||
if len(xs) == 0:
|
||||
return image_bgr.copy()
|
||||
|
||||
# Padded crop around the mask (context for the inpainter).
|
||||
pad = max(16, int(0.4 * max(xs.max() - xs.min() + 1, ys.max() - ys.min() + 1)))
|
||||
cx0, cy0 = max(0, int(xs.min()) - pad), max(0, int(ys.min()) - pad)
|
||||
cx1, cy1 = min(w, int(xs.max()) + 1 + pad), min(h, int(ys.max()) + 1 + pad)
|
||||
crop = image_bgr[cy0:cy1, cx0:cx1]
|
||||
crop_mask = mask[cy0:cy1, cx0:cx1]
|
||||
ch, cw = crop.shape[:2]
|
||||
|
||||
# Resize crop + mask to the model size, normalise to [0,1] RGB CHW.
|
||||
crop_rs = cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA)
|
||||
mask_rs = cv2.resize(crop_mask, (size, size), interpolation=cv2.INTER_NEAREST)
|
||||
img_in = cv2.cvtColor(crop_rs, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
|
||||
img_in = np.transpose(img_in, (2, 0, 1))[None] # (1,3,size,size)
|
||||
mask_in = (mask_rs > 127).astype(np.float32)[None, None] # (1,1,size,size), 1=hole
|
||||
|
||||
out = session.run(None, {img_name: img_in, mask_name: mask_in})[0] # type: ignore[attr-defined]
|
||||
out = np.asarray(out)[0] # (3,size,size)
|
||||
out = np.transpose(out, (1, 2, 0))
|
||||
if float(out.max()) <= 1.5: # model emits [0,1]; otherwise already [0,255]
|
||||
out = out * 255.0
|
||||
out = np.clip(out, 0, 255).astype(np.uint8)
|
||||
out_bgr = cv2.cvtColor(out, cv2.COLOR_RGB2BGR)
|
||||
|
||||
# Resize back to crop size and paste only the masked pixels.
|
||||
out_crop = cv2.resize(out_bgr, (cw, ch), interpolation=cv2.INTER_LINEAR)
|
||||
result = image_bgr.copy()
|
||||
region = result[cy0:cy1, cx0:cx1]
|
||||
paste = crop_mask > 127
|
||||
region[paste] = out_crop[paste]
|
||||
result[cy0:cy1, cx0:cx1] = region
|
||||
return result
|
||||
|
||||
|
||||
def erase(
|
||||
image_bgr: NDArray,
|
||||
*,
|
||||
boxes: list[tuple[int, int, int, int]] | None = None,
|
||||
mask: NDArray | None = None,
|
||||
backend: Backend = "cv2",
|
||||
dilate: int = 3,
|
||||
cv2_method: Literal["telea", "ns"] = "telea",
|
||||
cv2_radius: int = 6,
|
||||
) -> NDArray:
|
||||
"""Erase the given boxes (or mask) via the chosen inpainting backend.
|
||||
|
||||
Provide either ``boxes`` (list of ``(x, y, w, h)``) or a precomputed ``mask``
|
||||
(uint8, 255 = erase). Returns an unmodified copy when nothing is selected.
|
||||
"""
|
||||
if image_bgr is None or image_bgr.size == 0:
|
||||
return image_bgr
|
||||
if mask is None:
|
||||
if not boxes:
|
||||
return image_bgr.copy()
|
||||
mask = boxes_to_mask(image_bgr.shape[:2], boxes, dilate=dilate)
|
||||
if not mask.any():
|
||||
return image_bgr.copy()
|
||||
|
||||
if backend == "lama":
|
||||
if not lama_available():
|
||||
raise RuntimeError(
|
||||
"LaMa backend requires onnxruntime. Install the extra: pip install 'remove-ai-watermarks[lama]'"
|
||||
)
|
||||
return erase_lama(image_bgr, mask)
|
||||
return erase_cv2(image_bgr, mask, method=cv2_method, radius=cv2_radius)
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Detect Adobe TrustMark invisible watermarks.
|
||||
|
||||
TrustMark (github.com/adobe/trustmark, MIT) is the open, keyless image watermark
|
||||
behind Adobe "Durable Content Credentials": when a C2PA manifest is stripped, a
|
||||
TrustMark soft binding can still re-link the asset to its manifest in a
|
||||
repository. Unlike SynthID it has a PUBLIC decoder with no secret key, so a
|
||||
TrustMark-stamped image can be identified locally. Adobe's shipping products use
|
||||
Variant P (the ``com.adobe.trustmark.P`` soft-binding ``alg``); this wrapper
|
||||
loads that model.
|
||||
|
||||
Optional dependency (extra: ``trustmark``); the model weights download on first
|
||||
use. ``detect_trustmark`` returns None when the package is absent. This detects
|
||||
provenance (Adobe Content Credentials), NOT AI generation as such -- TrustMark
|
||||
also marks human-authored content -- so callers should treat it as a watermark
|
||||
signal, not proof of AI origin.
|
||||
"""
|
||||
|
||||
# trustmark ships no type stubs; relax untyped-library diagnostics for this thin
|
||||
# wrapper module only.
|
||||
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingImports=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Adobe ships Variant P in production (com.adobe.trustmark.P).
|
||||
_MODEL_TYPE = "P"
|
||||
# Lazily constructed singleton -- model load + first-use download is expensive.
|
||||
_tm: Any = None
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True if the optional ``trustmark`` package is installed."""
|
||||
import importlib.util
|
||||
|
||||
return importlib.util.find_spec("trustmark") is not None
|
||||
|
||||
|
||||
def _decoder() -> Any:
|
||||
global _tm
|
||||
if _tm is None:
|
||||
from trustmark import TrustMark
|
||||
|
||||
_tm = TrustMark(verbose=False, model_type=_MODEL_TYPE)
|
||||
return _tm
|
||||
|
||||
|
||||
def detect_trustmark(image_path: Path) -> str | None:
|
||||
"""Return a TrustMark scheme note if a TrustMark watermark is decoded, else None.
|
||||
|
||||
Returns e.g. ``"Adobe TrustMark (variant P, schema 0)"`` when the decoder
|
||||
reports the watermark present, or None if it is absent, the optional
|
||||
``trustmark`` package is not installed, or the image cannot be read/decoded.
|
||||
"""
|
||||
if not is_available():
|
||||
return None
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
cover = img.convert("RGB")
|
||||
_wm_secret, wm_present, wm_schema = _decoder().decode(cover)
|
||||
except Exception as exc: # model download / decode failure / unreadable image
|
||||
log.debug("TrustMark decode failed for %s: %s", image_path, exc)
|
||||
return None
|
||||
return f"Adobe TrustMark (variant {_MODEL_TYPE}, schema {wm_schema})" if wm_present else None
|
||||
@@ -2,10 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -13,6 +10,22 @@ import pytest
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
CORPUS_NEG_DIR = Path(__file__).resolve().parent.parent / "data" / "synthid_corpus" / "images" / "neg"
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def clean_photo() -> Path:
|
||||
"""A verified-negative real photo from the corpus neg/ set.
|
||||
|
||||
Used by the "non-AI image" assertions (no SynthID, verdict unknown). These
|
||||
are real photos with no AI provenance, the ground truth for "must not false-
|
||||
positive". Skips if the corpus is not checked out.
|
||||
"""
|
||||
files = sorted(CORPUS_NEG_DIR.glob("*")) if CORPUS_NEG_DIR.exists() else []
|
||||
if not files:
|
||||
pytest.skip("no corpus neg/ images present")
|
||||
return files[0]
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def tmp_image_path(tmp_path: Path) -> Path:
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import TYPE_CHECKING
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
@@ -26,7 +27,9 @@ def runner():
|
||||
@pytest.fixture
|
||||
def sample_png(tmp_path: Path) -> Path:
|
||||
"""Create a sample PNG for CLI testing."""
|
||||
img = np.random.randint(0, 255, (200, 200, 3), dtype=np.uint8)
|
||||
# Seeded: an unseeded random corner can occasionally trip the Doubao
|
||||
# visible-mark detector, making `visible --mark auto` flaky.
|
||||
img = np.random.default_rng(0).integers(0, 255, (200, 200, 3), dtype=np.uint8)
|
||||
path = tmp_path / "input.png"
|
||||
cv2.imwrite(str(path), img)
|
||||
return path
|
||||
@@ -36,8 +39,9 @@ def _make_batch_dir(tmp_path: Path, count: int = 3) -> Path:
|
||||
"""Create a directory with test images for batch testing."""
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
rng = np.random.default_rng(0)
|
||||
for i in range(count):
|
||||
img = np.random.randint(0, 255, (100, 100, 3), dtype=np.uint8)
|
||||
img = rng.integers(0, 255, (100, 100, 3), dtype=np.uint8)
|
||||
cv2.imwrite(str(input_dir / f"img_{i}.png"), img)
|
||||
return input_dir
|
||||
|
||||
@@ -71,6 +75,27 @@ def _mock_invisible_engine():
|
||||
return mock_cls, mock_engine
|
||||
|
||||
|
||||
def _mock_invisible_engine_drops_alpha():
|
||||
"""Mock InvisibleEngine that mimics the real engine's BGR-only output path.
|
||||
|
||||
The real diffusion-based engine reads with cv2.IMREAD_COLOR and writes a
|
||||
3-channel result. This mock simulates that so we can regression-test alpha
|
||||
preservation across the ``all`` pipeline.
|
||||
"""
|
||||
|
||||
def _mock_remove_watermark(image_path, output_path=None, **kwargs):
|
||||
out = output_path or image_path.with_stem(image_path.stem + "_clean")
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
bgr = cv2.imread(str(image_path), cv2.IMREAD_COLOR)
|
||||
cv2.imwrite(str(out), bgr)
|
||||
return out
|
||||
|
||||
mock_engine = MagicMock()
|
||||
mock_engine.remove_watermark.side_effect = _mock_remove_watermark
|
||||
mock_cls = MagicMock(return_value=mock_engine)
|
||||
return mock_cls, mock_engine
|
||||
|
||||
|
||||
class TestMainGroup:
|
||||
"""Tests for the top-level CLI group."""
|
||||
|
||||
@@ -97,7 +122,8 @@ class TestVisibleCommand:
|
||||
def test_visible_help(self, runner):
|
||||
result = runner.invoke(main, ["visible", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "Gemini watermark" in result.output
|
||||
assert "visible AI watermark" in result.output
|
||||
assert "--mark" in result.output
|
||||
|
||||
def test_visible_basic(self, runner, sample_png, tmp_path):
|
||||
output = tmp_path / "clean.png"
|
||||
@@ -143,6 +169,73 @@ class TestVisibleCommand:
|
||||
result = runner.invoke(main, ["visible", "/nonexistent/file.png"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_visible_preserves_rgba_transparency(self, runner, tmp_path):
|
||||
"""Visible removal on an RGBA PNG must keep the alpha channel,
|
||||
not silently flatten the image onto an opaque background.
|
||||
"""
|
||||
rgba = np.zeros((200, 200, 4), dtype=np.uint8)
|
||||
rgba[:, :, :3] = 200 # light grey foreground
|
||||
rgba[50:150, 50:150, 3] = 255 # opaque square in the middle, rest transparent
|
||||
src = tmp_path / "rgba_in.png"
|
||||
cv2.imwrite(str(src), rgba)
|
||||
|
||||
output = tmp_path / "rgba_out.png"
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["visible", str(src), "-o", str(output), "--no-detect"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert output.exists()
|
||||
|
||||
out = cv2.imread(str(output), cv2.IMREAD_UNCHANGED)
|
||||
assert out.ndim == 3, f"output is not 3D: shape={out.shape}"
|
||||
assert out.shape[2] == 4, f"output is not RGBA: shape={out.shape}"
|
||||
# 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).
|
||||
assert out[100, 100, 3] == 255
|
||||
|
||||
def test_visible_clears_alpha_in_watermark_region(self, runner, tmp_path):
|
||||
"""When inpainting an RGBA image, the watermark region must be cleared
|
||||
in the alpha channel so the sparkle area becomes transparent, not opaque-black.
|
||||
"""
|
||||
rgba = np.full((200, 200, 4), 255, dtype=np.uint8) # fully opaque white
|
||||
src = tmp_path / "rgba_full.png"
|
||||
cv2.imwrite(str(src), rgba)
|
||||
|
||||
output = tmp_path / "rgba_cleared.png"
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["visible", str(src), "-o", str(output), "--no-detect"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
out = cv2.imread(str(output), cv2.IMREAD_UNCHANGED)
|
||||
assert out.shape[2] == 4
|
||||
# Default sparkle position is in the bottom-right; alpha there must be 0.
|
||||
from remove_ai_watermarks.gemini_engine import get_watermark_config
|
||||
|
||||
cfg = get_watermark_config(200, 200)
|
||||
px, py = cfg.get_position(200, 200)
|
||||
size = cfg.logo_size
|
||||
assert out[py + size // 2, px + size // 2, 3] == 0, "alpha in the watermark region was not cleared"
|
||||
|
||||
def test_visible_rgb_input_stays_rgb(self, runner, sample_png, tmp_path):
|
||||
"""Regression: a plain RGB PNG must NOT gain a spurious alpha channel."""
|
||||
output = tmp_path / "rgb_out.png"
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["visible", str(sample_png), "-o", str(output), "--no-detect"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
out = cv2.imread(str(output), cv2.IMREAD_UNCHANGED)
|
||||
assert out.ndim == 3, f"output is not 3D: shape={out.shape}"
|
||||
assert out.shape[2] == 3, f"RGB input produced non-RGB output: shape={out.shape}"
|
||||
|
||||
|
||||
class TestInvisibleCommand:
|
||||
"""Tests for the 'invisible' subcommand."""
|
||||
@@ -209,6 +302,33 @@ class TestAllCommand:
|
||||
result = runner.invoke(main, ["all", "/nonexistent/file.png"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_all_preserves_rgba_across_invisible_step(self, runner, tmp_path):
|
||||
"""Regression: ``all`` must keep transparency even when the invisible
|
||||
step writes a 3-channel result (as the real diffusion engine does).
|
||||
"""
|
||||
rgba = np.zeros((200, 200, 4), dtype=np.uint8)
|
||||
rgba[:, :, :3] = 200
|
||||
rgba[50:150, 50:150, 3] = 255 # opaque square; corners transparent
|
||||
src = tmp_path / "rgba_in.png"
|
||||
cv2.imwrite(str(src), rgba)
|
||||
|
||||
output = tmp_path / "rgba_out.png"
|
||||
mock_cls, _engine = _mock_invisible_engine_drops_alpha()
|
||||
with (
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
patch("remove_ai_watermarks.cli.invisible_available", return_value=True, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
):
|
||||
result = runner.invoke(main, ["all", str(src), "-o", str(output)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
out = cv2.imread(str(output), cv2.IMREAD_UNCHANGED)
|
||||
assert out.ndim == 3, f"output not 3D: shape={out.shape}"
|
||||
assert out.shape[2] == 4, f"output is not RGBA: shape={out.shape}"
|
||||
assert out[0, 0, 3] == 0
|
||||
assert out[100, 100, 3] == 255
|
||||
|
||||
|
||||
class TestMetadataCommand:
|
||||
"""Tests for the 'metadata' subcommand."""
|
||||
@@ -243,6 +363,36 @@ class TestMetadataCommand:
|
||||
assert "stripped" in result.output
|
||||
|
||||
|
||||
class TestIdentifyCommand:
|
||||
"""Tests for the 'identify' subcommand."""
|
||||
|
||||
def test_identify_help(self, runner):
|
||||
result = runner.invoke(main, ["identify", "--help"])
|
||||
assert result.exit_code == 0
|
||||
|
||||
def test_identify_clean_png(self, runner, tmp_clean_png):
|
||||
result = runner.invoke(main, ["identify", str(tmp_clean_png), "--no-visible"])
|
||||
assert result.exit_code == 0
|
||||
assert "unknown" in result.output
|
||||
|
||||
def test_identify_ai_png_reports_platform(self, runner, tmp_png_with_ai_metadata):
|
||||
result = runner.invoke(main, ["identify", str(tmp_png_with_ai_metadata), "--no-visible"])
|
||||
assert result.exit_code == 0
|
||||
assert "AI-generated" in result.output
|
||||
assert "Stable Diffusion" in result.output
|
||||
|
||||
def test_identify_json_is_valid(self, runner, tmp_png_with_ai_metadata):
|
||||
result = runner.invoke(main, ["identify", str(tmp_png_with_ai_metadata), "--no-visible", "--json"])
|
||||
assert result.exit_code == 0
|
||||
payload = json.loads(result.output)
|
||||
assert payload["is_ai_generated"] is True
|
||||
assert payload["confidence"] == "high"
|
||||
|
||||
def test_identify_nonexistent_file(self, runner):
|
||||
result = runner.invoke(main, ["identify", "/nonexistent/file.png"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
|
||||
class TestBatchCommand:
|
||||
"""Tests for the 'batch' subcommand."""
|
||||
|
||||
@@ -319,6 +469,37 @@ class TestBatchCommand:
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "3 processed" in result.output
|
||||
|
||||
def test_batch_all_mode_preserves_rgba(self, runner, tmp_path):
|
||||
"""Regression: batch ``all`` must keep transparency across the
|
||||
alpha-dropping invisible step (mirrors test_all_preserves_rgba_...).
|
||||
"""
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
rgba = np.zeros((200, 200, 4), dtype=np.uint8)
|
||||
rgba[:, :, :3] = 200
|
||||
rgba[50:150, 50:150, 3] = 255
|
||||
cv2.imwrite(str(input_dir / "rgba.png"), rgba)
|
||||
|
||||
output_dir = tmp_path / "output"
|
||||
mock_cls, _engine = _mock_invisible_engine_drops_alpha()
|
||||
with (
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
patch("remove_ai_watermarks.cli.invisible_available", return_value=True, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["batch", str(input_dir), "-o", str(output_dir), "--mode", "all"],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
out = cv2.imread(str(output_dir / "rgba.png"), cv2.IMREAD_UNCHANGED)
|
||||
assert out.ndim == 3, f"output not 3D: shape={out.shape}"
|
||||
assert out.shape[2] == 4, f"output is not RGBA: shape={out.shape}"
|
||||
assert out[0, 0, 3] == 0
|
||||
assert out[100, 100, 3] == 255
|
||||
|
||||
def test_batch_default_output_dir(self, runner, tmp_path):
|
||||
input_dir = _make_batch_dir(tmp_path)
|
||||
result = runner.invoke(
|
||||
|
||||
@@ -0,0 +1,98 @@
|
||||
"""Tests for the Doubao visible-watermark engine."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.doubao_engine import DoubaoEngine, load_image_bgr
|
||||
|
||||
SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doubao-1.png"
|
||||
|
||||
|
||||
# ── Locate ──────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestLocate:
|
||||
def test_box_anchored_bottom_right(self):
|
||||
eng = DoubaoEngine()
|
||||
img = np.zeros((2048, 2048, 3), np.uint8)
|
||||
loc = eng.locate(img)
|
||||
# right and bottom edges sit close to the image corner (within margins)
|
||||
assert 2048 - (loc.x + loc.w) < int(2048 * 0.03)
|
||||
assert 2048 - (loc.y + loc.h) < int(2048 * 0.03)
|
||||
assert loc.is_fallback # geometry anchor, no bundled template yet
|
||||
|
||||
def test_box_scales_with_width(self):
|
||||
eng = DoubaoEngine()
|
||||
small = eng.locate(np.zeros((1024, 1024, 3), np.uint8))
|
||||
large = eng.locate(np.zeros((2048, 2048, 3), np.uint8))
|
||||
# width-relative geometry: 2x wider image -> ~2x wider box
|
||||
assert large.w == pytest.approx(small.w * 2, rel=0.1)
|
||||
|
||||
|
||||
# ── Detect + remove on the real sample ──────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLE.exists(), reason="sample image not present")
|
||||
class TestRealSample:
|
||||
def test_detects_watermark(self):
|
||||
eng = DoubaoEngine()
|
||||
det = eng.detect(load_image_bgr(SAMPLE))
|
||||
assert det.detected
|
||||
assert det.confidence > 0.0
|
||||
assert det.coverage > 0.04
|
||||
|
||||
def test_remove_reduces_glyph_coverage(self):
|
||||
eng = DoubaoEngine()
|
||||
img = load_image_bgr(SAMPLE)
|
||||
before = eng.detect(img).coverage
|
||||
out = eng.remove_watermark(img)
|
||||
after = eng.detect(out).coverage
|
||||
# the inpaint should clear most glyph pixels from the corner box
|
||||
assert after < before * 0.5
|
||||
|
||||
def test_pixels_outside_box_untouched(self):
|
||||
eng = DoubaoEngine()
|
||||
img = load_image_bgr(SAMPLE)
|
||||
out = eng.remove_watermark(img)
|
||||
# top-left quadrant is far from the bottom-right mark: must be identical
|
||||
h, w = img.shape[:2]
|
||||
assert np.array_equal(img[: h // 2, : w // 2], out[: h // 2, : w // 2])
|
||||
|
||||
|
||||
# ── Negative + safety guard ─────────────────────────────────────────
|
||||
|
||||
|
||||
class TestNegativeAndGuard:
|
||||
def test_clean_image_not_detected(self):
|
||||
eng = DoubaoEngine()
|
||||
# smooth gradient, no watermark
|
||||
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
|
||||
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
|
||||
det = eng.detect(img)
|
||||
assert not det.detected
|
||||
|
||||
def test_clean_image_returned_unchanged(self):
|
||||
eng = DoubaoEngine()
|
||||
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
|
||||
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
|
||||
out = eng.remove_watermark(img)
|
||||
assert np.array_equal(img, out)
|
||||
|
||||
def test_document_background_guard(self):
|
||||
"""A dense high-frequency corner (document-like) trips the coverage
|
||||
guard, so the image is left untouched rather than smeared."""
|
||||
eng = DoubaoEngine()
|
||||
rng = np.random.default_rng(0)
|
||||
img = np.full((1024, 1024, 3), 255, np.uint8)
|
||||
# fill the bottom-right box area with random grayish text-like noise
|
||||
loc = eng.locate(img)
|
||||
x, y, bw, bh = loc.bbox
|
||||
noise = rng.integers(150, 246, size=(bh, bw), dtype=np.uint8)
|
||||
img[y : y + bh, x : x + bw] = noise[:, :, None]
|
||||
out = eng.remove_watermark(img)
|
||||
assert np.array_equal(img, out)
|
||||
@@ -12,6 +12,7 @@ from remove_ai_watermarks.gemini_engine import (
|
||||
WatermarkPosition,
|
||||
WatermarkSize,
|
||||
_calculate_alpha_map,
|
||||
detect_sparkle_confidence,
|
||||
get_watermark_config,
|
||||
get_watermark_size,
|
||||
)
|
||||
@@ -174,6 +175,21 @@ class TestDetection:
|
||||
assert isinstance(result.gradient_score, float)
|
||||
|
||||
|
||||
class TestDetectSparkleConfidence:
|
||||
"""File-level entry point used by identify.py."""
|
||||
|
||||
def test_returns_float_in_range_for_real_image(self, tmp_image_path):
|
||||
conf = detect_sparkle_confidence(tmp_image_path)
|
||||
assert conf is not None
|
||||
assert 0.0 <= conf <= 1.0
|
||||
|
||||
def test_returns_none_for_unreadable_file(self, tmp_path):
|
||||
# cv2.imread returns None for a non-image; the helper must not raise.
|
||||
bogus = tmp_path / "not_an_image.png"
|
||||
bogus.write_bytes(b"this is not a PNG")
|
||||
assert detect_sparkle_confidence(bogus) is None
|
||||
|
||||
|
||||
# ── Inpainting ──────────────────────────────────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,449 @@
|
||||
"""Tests for the provenance identifier (identify.py).
|
||||
|
||||
Pure attribution logic is unit-tested directly; end-to-end verdicts assert
|
||||
against the real committed C2PA / IPTC fixtures in data/samples/.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.identify import (
|
||||
ProvenanceReport,
|
||||
_ai_tools_in,
|
||||
_attribute_platform,
|
||||
_issuers_in,
|
||||
identify,
|
||||
)
|
||||
|
||||
# Where the lazy import inside identify._visible_sparkle resolves the detector.
|
||||
_SPARKLE_TARGET = "remove_ai_watermarks.gemini_engine.detect_sparkle_confidence"
|
||||
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "samples"
|
||||
|
||||
|
||||
# ── Pure attribution logic (no file IO) ─────────────────────────────
|
||||
|
||||
|
||||
class TestAttributePlatform:
|
||||
def test_openai(self):
|
||||
assert "OpenAI" in (_attribute_platform(["OpenAI"]) or "")
|
||||
|
||||
def test_designer_wins_over_openai_backend(self):
|
||||
# Microsoft Designer signs as "OpenAI, Microsoft"; name the product.
|
||||
platform = _attribute_platform(["OpenAI", "Microsoft"])
|
||||
assert platform
|
||||
assert "Designer" in platform
|
||||
|
||||
def test_adobe(self):
|
||||
assert _attribute_platform(["Adobe"]) == "Adobe Firefly"
|
||||
|
||||
def test_google(self):
|
||||
assert "Google" in (_attribute_platform(["Google LLC"]) or "")
|
||||
|
||||
def test_truepic_is_signer_not_generator(self):
|
||||
platform = _attribute_platform(["Truepic"])
|
||||
assert platform
|
||||
assert "signer" in platform.lower()
|
||||
|
||||
def test_microsoft_label_is_model_neutral(self):
|
||||
# Bing now runs MAI-Image, not DALL-E; the label must not claim DALL-E.
|
||||
platform = _attribute_platform(["Microsoft"])
|
||||
assert platform
|
||||
assert "DALL-E" not in platform
|
||||
|
||||
def test_stability(self):
|
||||
platform = _attribute_platform(["Stability AI"])
|
||||
assert platform
|
||||
assert "Stability AI" in platform
|
||||
|
||||
def test_empty_is_none(self):
|
||||
assert _attribute_platform([]) is None
|
||||
|
||||
|
||||
class TestIssuersIn:
|
||||
def test_finds_openai(self):
|
||||
assert _issuers_in(b"...OpenAI...trainedAlgorithmicMedia") == ["OpenAI"]
|
||||
|
||||
def test_finds_multiple_sorted(self):
|
||||
assert _issuers_in(b"Microsoft and OpenAI") == ["Microsoft", "OpenAI"]
|
||||
|
||||
def test_none_present(self):
|
||||
assert _issuers_in(b"just some bytes") == []
|
||||
|
||||
|
||||
class TestAiToolsIn:
|
||||
def test_finds_generator(self):
|
||||
assert _ai_tools_in(b"...claim_generator Imagen 3...") == ["Imagen"]
|
||||
|
||||
def test_none_present(self):
|
||||
assert _ai_tools_in(b"a regular photo, no tools") == []
|
||||
|
||||
|
||||
class TestIdentifyNonPng:
|
||||
"""Non-PNG containers (JPEG/WebP/AVIF) carry C2PA where the caBX parser can't
|
||||
reach; identify recovers issuer + generator via the binary scan. Synthetic
|
||||
byte blobs mirror tests/test_metadata.py::TestSynthIDSourceNonPng.
|
||||
"""
|
||||
|
||||
def _c2pa_jpeg(self, tmp_path: Path, blob: bytes) -> Path:
|
||||
path = tmp_path / "img.jpg"
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9")
|
||||
return path
|
||||
|
||||
def test_google_imagen_jpeg(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"Google Imagen ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform is not None
|
||||
assert "Google" in r.platform
|
||||
# Generator recovered from the non-PNG blob shows up in the c2pa signal.
|
||||
c2pa_signal = next(s for s in r.signals if s.name == "c2pa")
|
||||
assert "Imagen" in c2pa_signal.detail
|
||||
|
||||
def test_openai_jpeg_has_synthid(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"OpenAI DALL-E ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False)
|
||||
assert any("SynthID" in w for w in r.watermarks)
|
||||
|
||||
def test_stability_ai_issuer_attributed_no_synthid(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"Stability AI ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform is not None
|
||||
assert "Stability AI" in r.platform
|
||||
assert not any("SynthID" in w for w in r.watermarks) # Stability does not use SynthID
|
||||
|
||||
def test_c2pa_without_ai_marker_is_unknown(self, tmp_path: Path):
|
||||
# Adobe signs C2PA on plain Photoshop edits too. Without an AI digital-
|
||||
# source marker, the honest verdict is unknown -- the C2PA watermark is
|
||||
# still listed, but is_ai_generated is not asserted True.
|
||||
path = self._c2pa_jpeg(tmp_path, b"Adobe ... no ai marker here")
|
||||
r = identify(path, check_visible=False)
|
||||
assert r.is_ai_generated is None
|
||||
assert any("C2PA" in w for w in r.watermarks)
|
||||
assert not any("SynthID" in w for w in r.watermarks)
|
||||
|
||||
|
||||
# ── End-to-end verdicts on real fixtures ────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
class TestIdentifyRealSamples:
|
||||
def test_openai_chatgpt(self):
|
||||
r = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "high"
|
||||
assert r.platform
|
||||
assert "OpenAI" in r.platform
|
||||
assert any("C2PA" in w for w in r.watermarks)
|
||||
assert any("SynthID" in w for w in r.watermarks)
|
||||
|
||||
def test_adobe_firefly_has_no_synthid(self):
|
||||
r = identify(SAMPLES_DIR / "firefly-1.png", check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Adobe Firefly"
|
||||
assert not any("SynthID" in w for w in r.watermarks)
|
||||
|
||||
def test_iptc_made_with_ai(self):
|
||||
# mj-1.png carries the IPTC digitalSourceType "Made with AI" marker.
|
||||
r = identify(SAMPLES_DIR / "mj-1.png", check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert any("IPTC" in w for w in r.watermarks)
|
||||
|
||||
def test_clean_photo_is_unknown_not_clean(self, clean_photo: Path):
|
||||
r = identify(clean_photo, check_visible=False)
|
||||
assert r.is_ai_generated is None # never asserted False
|
||||
assert r.platform is None
|
||||
assert r.confidence == "none"
|
||||
assert r.watermarks == []
|
||||
|
||||
def test_strip_caveat_always_present(self, clean_photo: Path):
|
||||
r = identify(clean_photo, check_visible=False)
|
||||
assert any("not proof" in c for c in r.caveats)
|
||||
|
||||
def test_returns_report_dataclass(self):
|
||||
assert isinstance(identify(SAMPLES_DIR / "firefly-1.png", check_visible=False), ProvenanceReport)
|
||||
|
||||
|
||||
# ── Local diffusion parameters (Stable Diffusion / ComfyUI) ─────────
|
||||
|
||||
|
||||
class TestIdentifyLocalParams:
|
||||
"""A PNG carrying SD-style generation params is attributed to a local pipeline."""
|
||||
|
||||
def test_sd_params_attributed_to_local_pipeline(self, tmp_png_with_ai_metadata: Path):
|
||||
r = identify(tmp_png_with_ai_metadata, check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "high"
|
||||
assert r.platform is not None
|
||||
assert "Stable Diffusion" in r.platform
|
||||
assert any("generation parameters" in w for w in r.watermarks)
|
||||
|
||||
def test_gen_params_signal_lists_keys(self, tmp_png_with_ai_metadata: Path):
|
||||
r = identify(tmp_png_with_ai_metadata, check_visible=False)
|
||||
signal = next(s for s in r.signals if s.name == "gen_params")
|
||||
assert "parameters" in signal.detail
|
||||
assert signal.confidence == "high"
|
||||
|
||||
def test_clean_png_is_unknown(self, tmp_clean_png: Path):
|
||||
r = identify(tmp_clean_png, check_visible=False)
|
||||
assert r.is_ai_generated is None
|
||||
assert r.platform is None
|
||||
assert r.confidence == "none"
|
||||
assert r.signals == []
|
||||
|
||||
|
||||
# ── Visible-sparkle fallback (mocked detector) ──────────────────────
|
||||
|
||||
|
||||
class TestIdentifyVisibleSparkle:
|
||||
"""The visible-sparkle signal gates on the corpus-tuned threshold (0.5)."""
|
||||
|
||||
def test_above_threshold_promotes_to_medium(self, tmp_clean_png: Path):
|
||||
with patch(_SPARKLE_TARGET, return_value=0.7):
|
||||
r = identify(tmp_clean_png, check_visible=True)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "medium"
|
||||
assert r.platform is not None
|
||||
assert "Gemini" in r.platform
|
||||
signal = next(s for s in r.signals if s.name == "visible_sparkle")
|
||||
assert signal.confidence == "medium"
|
||||
|
||||
def test_below_threshold_not_promoted(self, tmp_clean_png: Path):
|
||||
with patch(_SPARKLE_TARGET, return_value=0.4):
|
||||
r = identify(tmp_clean_png, check_visible=True)
|
||||
assert r.is_ai_generated is None
|
||||
assert not any(s.name == "visible_sparkle" for s in r.signals)
|
||||
|
||||
def test_detector_unavailable_does_not_crash(self, tmp_clean_png: Path):
|
||||
with patch(_SPARKLE_TARGET, return_value=None):
|
||||
r = identify(tmp_clean_png, check_visible=True)
|
||||
assert r.is_ai_generated is None
|
||||
assert not any(s.name == "visible_sparkle" for s in r.signals)
|
||||
|
||||
def test_check_visible_false_skips_detector(self, tmp_clean_png: Path):
|
||||
# Even a strong detection is ignored when the caller opts out.
|
||||
with patch(_SPARKLE_TARGET, return_value=0.99) as mock_detect:
|
||||
r = identify(tmp_clean_png, check_visible=False)
|
||||
mock_detect.assert_not_called()
|
||||
assert not any(s.name == "visible_sparkle" for s in r.signals)
|
||||
|
||||
def test_metadata_keeps_high_even_with_sparkle(self, tmp_png_with_ai_metadata: Path):
|
||||
# Metadata verdict (high) is not downgraded by an additional sparkle hit.
|
||||
with patch(_SPARKLE_TARGET, return_value=0.7):
|
||||
r = identify(tmp_png_with_ai_metadata, check_visible=True)
|
||||
assert r.confidence == "high"
|
||||
|
||||
|
||||
# ── Caveats and serialization ───────────────────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
class TestIdentifyCaveats:
|
||||
def test_openai_hedge_caveat_present(self):
|
||||
r = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False)
|
||||
assert any("before the rollout" in c for c in r.caveats)
|
||||
|
||||
def test_synthid_proxy_caveat_present(self):
|
||||
r = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False)
|
||||
assert any("not locally" in c for c in r.caveats)
|
||||
|
||||
def test_caveats_are_deduplicated(self):
|
||||
r = identify(SAMPLES_DIR / "chatgpt-1.png", check_visible=False)
|
||||
assert len(r.caveats) == len(set(r.caveats))
|
||||
|
||||
|
||||
class TestReportSerializable:
|
||||
def test_report_is_json_serializable(self, tmp_png_with_ai_metadata: Path):
|
||||
# The CLI --json path relies on asdict + json.dumps(default=str).
|
||||
report = identify(tmp_png_with_ai_metadata, check_visible=False)
|
||||
dumped = json.dumps(asdict(report), default=str)
|
||||
assert "is_ai_generated" in dumped
|
||||
|
||||
|
||||
class TestIdentifyExifGenerator:
|
||||
"""An AI generator tag in EXIF/XMP (incl. AVIF) drives attribution."""
|
||||
|
||||
def test_avif_firefly_software_attributed(self, tmp_path: Path):
|
||||
import piexif
|
||||
from PIL import Image
|
||||
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Software: b"Adobe Firefly"}, "Exif": {}, "GPS": {}, "1st": {}})
|
||||
path = tmp_path / "firefly.avif"
|
||||
Image.new("RGB", (64, 64), (90, 80, 70)).save(path, exif=exif)
|
||||
r = identify(path, check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform is not None
|
||||
assert "Firefly" in r.platform
|
||||
assert any("generator tag" in w for w in r.watermarks)
|
||||
|
||||
|
||||
class TestIdentifyXaiSignature:
|
||||
"""xAI / Grok's EXIF Signature + UUID-Artist drives an xAI verdict."""
|
||||
|
||||
def test_grok_signature_attributed(self, tmp_path: Path):
|
||||
import piexif
|
||||
from PIL import Image
|
||||
|
||||
exif = piexif.dump(
|
||||
{
|
||||
"0th": {
|
||||
piexif.ImageIFD.ImageDescription: b"Signature: " + b"A" * 120,
|
||||
piexif.ImageIFD.Artist: b"12345678-1234-1234-1234-123456789abc",
|
||||
},
|
||||
"Exif": {},
|
||||
"GPS": {},
|
||||
"1st": {},
|
||||
}
|
||||
)
|
||||
path = tmp_path / "grok.jpg"
|
||||
Image.new("RGB", (64, 64), (70, 80, 90)).save(path, exif=exif)
|
||||
r = identify(path, check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform is not None
|
||||
assert "xAI" in r.platform
|
||||
assert any("xAI/Grok" in w for w in r.watermarks)
|
||||
|
||||
|
||||
class TestIdentifySoftBinding:
|
||||
"""A C2PA soft-binding alg names a forensic-watermark vendor in the inventory."""
|
||||
|
||||
def test_soft_binding_vendor_listed(self, tmp_path: Path):
|
||||
p = tmp_path / "sb.jpg"
|
||||
p.write_bytes(b"\xff\xd8\xff\xe1 c2pa jumb com.digimarc.validate.1 \xff\xd9")
|
||||
r = identify(p, check_visible=False, check_invisible=False)
|
||||
assert any("Digimarc" in w for w in r.watermarks)
|
||||
assert any(s.name == "soft_binding" for s in r.signals)
|
||||
|
||||
|
||||
class TestIdentifyIptcAi:
|
||||
"""IPTC 2025.1 AISystemUsed drives an AI verdict + platform attribution."""
|
||||
|
||||
def test_iptc_ai_system_attributed(self, tmp_path: Path):
|
||||
p = tmp_path / "iptc.jpg"
|
||||
p.write_bytes(
|
||||
b"\xff\xd8\xff\xe1<x:xmpmeta><Iptc4xmpExt:AISystemUsed>Google Gemini"
|
||||
b"</Iptc4xmpExt:AISystemUsed></x:xmpmeta>\xff\xd9"
|
||||
)
|
||||
r = identify(p, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform is not None
|
||||
assert "Gemini" in r.platform
|
||||
|
||||
|
||||
class TestIdentifyC2paDevice:
|
||||
"""A distinctive C2PA device token wins platform attribution over incidental
|
||||
issuer-name mentions (regression guard for real-sample mis-attribution:
|
||||
Leica->Truepic, Nikon->Adobe, Pixel->Google Gemini)."""
|
||||
|
||||
def test_leica_token_beats_incidental_tokens(self, tmp_path: Path):
|
||||
# "Adobe"/"Google"/"Truepic" appear incidentally; Leica's lc_c2pa wins.
|
||||
blob = b"\xff\xd8\xff\xe1 c2pa.claim jumbf Adobe Google Truepic lc_c2pa \xff\xd9"
|
||||
p = tmp_path / "leica_like.jpg"
|
||||
p.write_bytes(blob)
|
||||
r = identify(p, check_visible=False, check_invisible=False)
|
||||
assert r.platform == "Leica (camera, C2PA capture)"
|
||||
|
||||
def test_pixel_camera_cert_beats_incidental_google(self, tmp_path: Path):
|
||||
# Pixel's cert CN is "Pixel Camera"; "Google LLC" appears as the cert org
|
||||
# but must NOT yield "Google (Gemini / Imagen)" -- it is a camera capture.
|
||||
blob = b"\xff\xd8\xff\xe1 c2pa.claim jumbf Google LLC Adobe Pixel Camera \xff\xd9"
|
||||
p = tmp_path / "pixel_like.jpg"
|
||||
p.write_bytes(blob)
|
||||
r = identify(p, check_visible=False, check_invisible=False)
|
||||
assert r.platform == "Google Pixel (camera, C2PA capture)"
|
||||
assert r.is_ai_generated is None # camera capture, not AI
|
||||
|
||||
def test_sony_namespace_beats_bare_make(self, tmp_path: Path):
|
||||
# Sony's own C2PA assertion namespace (sony.sig), not the bare "Sony"
|
||||
# EXIF Make that appears on ordinary photos.
|
||||
blob = b"\xff\xd8\xff\xe1 c2pa.claim jumbf Adobe Sony sony.sig.v1_1 \xff\xd9"
|
||||
p = tmp_path / "sony_like.jpg"
|
||||
p.write_bytes(blob)
|
||||
r = identify(p, check_visible=False, check_invisible=False)
|
||||
assert r.platform == "Sony (camera, C2PA capture)"
|
||||
|
||||
def test_unmapped_device_not_mislabeled_via_incidental_issuer(self, tmp_path: Path):
|
||||
# An unmapped camera (Canon) whose manifest incidentally contains the
|
||||
# "Adobe" XMP-toolkit string, with NO AI source type, must NOT be labeled
|
||||
# "Adobe Firefly". The issuer->generator mapping only applies to AI content.
|
||||
blob = b"\xff\xd8\xff\xe1 c2pa.claim jumbf Canon EOS Adobe XMP Core \xff\xd9"
|
||||
p = tmp_path / "canon_like.jpg"
|
||||
p.write_bytes(blob)
|
||||
r = identify(p, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is None # camera capture, not AI
|
||||
assert r.platform is not None
|
||||
assert "Firefly" not in r.platform # not mislabeled as an AI generator
|
||||
|
||||
|
||||
# ── Open invisible watermark (SD/SDXL/FLUX) integration ─────────────
|
||||
|
||||
from remove_ai_watermarks.invisible_watermark import is_available as _wm_available # noqa: E402
|
||||
|
||||
|
||||
@pytest.mark.skipif(not _wm_available(), reason="invisible-watermark not installed")
|
||||
class TestIdentifyInvisibleWatermark:
|
||||
def _sdxl_watermarked(self, tmp_path: Path) -> Path:
|
||||
import cv2
|
||||
import numpy as np
|
||||
from imwatermark import WatermarkEncoder
|
||||
|
||||
from remove_ai_watermarks.invisible_watermark import _BITS_48
|
||||
|
||||
bits = [int(b) for b in format(_BITS_48["Stable Diffusion XL"], "048b")]
|
||||
enc = WatermarkEncoder()
|
||||
enc.set_watermark("bits", bits)
|
||||
img = np.random.default_rng(0).integers(0, 255, (512, 512, 3), dtype=np.uint8)
|
||||
path = tmp_path / "sdxl.png"
|
||||
cv2.imwrite(str(path), enc.encode(img, "dwtDct"))
|
||||
return path
|
||||
|
||||
def test_sdxl_watermark_identified(self, tmp_path: Path):
|
||||
r = identify(self._sdxl_watermarked(tmp_path), check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "high"
|
||||
assert r.platform is not None
|
||||
assert "Stable Diffusion XL" in r.platform
|
||||
assert any("invisible watermark" in w.lower() for w in r.watermarks)
|
||||
|
||||
def test_check_invisible_false_skips(self, tmp_path: Path):
|
||||
r = identify(self._sdxl_watermarked(tmp_path), check_visible=False, check_invisible=False)
|
||||
assert not any(s.name == "invisible_watermark" for s in r.signals)
|
||||
|
||||
|
||||
class TestIdentifyAIGC:
|
||||
"""China TC260 AIGC label is detected and attributed (e.g. Doubao)."""
|
||||
|
||||
def _aigc_png(self, tmp_path: Path) -> Path:
|
||||
from PIL import Image
|
||||
|
||||
p = tmp_path / "doubao.png"
|
||||
Image.new("RGB", (32, 32)).save(p)
|
||||
xmp = (
|
||||
'<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF '
|
||||
'xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
|
||||
'<rdf:Description xmlns:TC260="http://www.tc260.org.cn/ns/AIGC/1.0/">'
|
||||
"<TC260:AIGC>{"Label":"1","ContentProducer":"BYTEDANCE001"}"
|
||||
"</TC260:AIGC></rdf:Description></rdf:RDF></x:xmpmeta>"
|
||||
)
|
||||
with open(p, "ab") as f:
|
||||
f.write(xmp.encode())
|
||||
return p
|
||||
|
||||
def test_aigc_detected(self, tmp_path: Path):
|
||||
r = identify(self._aigc_png(tmp_path), check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform is not None
|
||||
assert "AIGC" in r.platform or "TC260" in r.platform
|
||||
assert any("AIGC" in w for w in r.watermarks)
|
||||
|
||||
def test_aigc_signal_carries_producer(self, tmp_path: Path):
|
||||
r = identify(self._aigc_png(tmp_path), check_visible=False)
|
||||
sig = next(s for s in r.signals if s.name == "aigc")
|
||||
assert "BYTEDANCE001" in sig.detail
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Unit tests for the MPS->CPU fallback orchestration (no GPU/model required).
|
||||
|
||||
``img2img_runner`` has no torch import at module top -- the pipeline is
|
||||
injected as a plain callable -- so the fallback control flow is fully
|
||||
mockable. This guards the exact behavior hit in production on Apple Silicon:
|
||||
a native-resolution SDXL run that OOMs on MPS must transparently retry on CPU,
|
||||
while any non-MPS error must propagate unchanged.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import Mock
|
||||
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.noai import img2img_runner
|
||||
from remove_ai_watermarks.noai.img2img_runner import run_img2img, run_img2img_with_mps_fallback
|
||||
|
||||
_MPS_OOM = "MPS backend out of memory (MPS allocated: 17.21 GiB, max allowed: 20.13 GiB)"
|
||||
|
||||
|
||||
def _result(image: object) -> Mock:
|
||||
"""A stand-in for a diffusers pipeline output object (has .images)."""
|
||||
out = Mock()
|
||||
out.images = [image]
|
||||
return out
|
||||
|
||||
|
||||
class TestMpsFallback:
|
||||
def test_mps_error_reloads_on_cpu_and_retries(self, monkeypatch: pytest.MonkeyPatch):
|
||||
sentinel = object()
|
||||
inner = Mock(side_effect=[RuntimeError(_MPS_OOM), sentinel])
|
||||
monkeypatch.setattr(img2img_runner, "run_img2img", inner)
|
||||
load_pipeline = Mock(return_value="gpu_pipe")
|
||||
reload_on_cpu = Mock(return_value="cpu_pipe")
|
||||
|
||||
img, device = run_img2img_with_mps_fallback(
|
||||
load_pipeline, object(), 0.05, 50, 7.5, "gen", "mps", lambda _m: None, reload_on_cpu=reload_on_cpu
|
||||
)
|
||||
|
||||
assert (img, device) == (sentinel, "cpu")
|
||||
reload_on_cpu.assert_called_once()
|
||||
assert inner.call_count == 2
|
||||
# Retry must use the reloaded CPU pipeline, device "cpu", and drop the
|
||||
# MPS generator (generator=None) so CPU runs deterministically.
|
||||
retry_args = inner.call_args_list[1].args
|
||||
assert retry_args[0] == "cpu_pipe"
|
||||
assert retry_args[5] is None # generator
|
||||
assert retry_args[6] == "cpu" # device
|
||||
|
||||
def test_happy_path_returns_original_device_without_reload(self, monkeypatch: pytest.MonkeyPatch):
|
||||
sentinel = object()
|
||||
monkeypatch.setattr(img2img_runner, "run_img2img", Mock(return_value=sentinel))
|
||||
reload_on_cpu = Mock()
|
||||
|
||||
img, device = run_img2img_with_mps_fallback(
|
||||
Mock(return_value="gpu_pipe"),
|
||||
object(),
|
||||
0.05,
|
||||
50,
|
||||
7.5,
|
||||
"gen",
|
||||
"mps",
|
||||
lambda _m: None,
|
||||
reload_on_cpu=reload_on_cpu,
|
||||
)
|
||||
|
||||
assert (img, device) == (sentinel, "mps")
|
||||
reload_on_cpu.assert_not_called()
|
||||
|
||||
def test_non_mps_runtime_error_propagates(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(img2img_runner, "run_img2img", Mock(side_effect=RuntimeError("CUDA out of memory")))
|
||||
reload_on_cpu = Mock()
|
||||
|
||||
with pytest.raises(RuntimeError, match="CUDA"):
|
||||
run_img2img_with_mps_fallback(
|
||||
Mock(return_value="gpu_pipe"),
|
||||
object(),
|
||||
0.05,
|
||||
50,
|
||||
7.5,
|
||||
"gen",
|
||||
"mps",
|
||||
lambda _m: None,
|
||||
reload_on_cpu=reload_on_cpu,
|
||||
)
|
||||
reload_on_cpu.assert_not_called()
|
||||
|
||||
def test_mps_error_on_non_mps_device_propagates(self, monkeypatch: pytest.MonkeyPatch):
|
||||
# An "mps"-worded error while running on cpu must NOT trigger the reload.
|
||||
monkeypatch.setattr(img2img_runner, "run_img2img", Mock(side_effect=RuntimeError(_MPS_OOM)))
|
||||
reload_on_cpu = Mock()
|
||||
|
||||
with pytest.raises(RuntimeError, match="MPS backend"):
|
||||
run_img2img_with_mps_fallback(
|
||||
Mock(return_value="cpu_pipe"),
|
||||
object(),
|
||||
0.05,
|
||||
50,
|
||||
7.5,
|
||||
None,
|
||||
"cpu",
|
||||
lambda _m: None,
|
||||
reload_on_cpu=reload_on_cpu,
|
||||
)
|
||||
reload_on_cpu.assert_not_called()
|
||||
|
||||
|
||||
class TestRunImg2Img:
|
||||
def test_returns_first_image_from_pipeline_result(self):
|
||||
sentinel = object()
|
||||
pipeline = Mock(return_value=_result(sentinel))
|
||||
|
||||
out = run_img2img(pipeline, object(), 0.05, 50, 7.5, None, "cpu", lambda _m: None)
|
||||
|
||||
assert out is sentinel
|
||||
|
||||
def test_typeerror_on_callback_retries_without_callback(self):
|
||||
# Older diffusers reject the progress callback kwarg with TypeError;
|
||||
# run_img2img must retry once without it rather than fail.
|
||||
sentinel = object()
|
||||
pipeline = Mock(side_effect=[TypeError("unexpected keyword 'callback'"), _result(sentinel)])
|
||||
|
||||
out = run_img2img(pipeline, object(), 0.05, 50, 7.5, None, "cpu", lambda _m: None)
|
||||
|
||||
assert out is sentinel
|
||||
assert pipeline.call_count == 2
|
||||
# First attempt passes the progress callback; the retry omits it.
|
||||
assert "callback" in pipeline.call_args_list[0].kwargs
|
||||
assert "callback" not in pipeline.call_args_list[1].kwargs
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from remove_ai_watermarks.invisible_engine import InvisibleEngine, is_available
|
||||
from remove_ai_watermarks.invisible_engine import InvisibleEngine, _target_size, is_available
|
||||
|
||||
|
||||
class TestIsAvailable:
|
||||
@@ -21,7 +21,50 @@ class TestInvisibleEngineInit:
|
||||
"""Tests for InvisibleEngine construction (no GPU required)."""
|
||||
|
||||
def test_default_model_id(self):
|
||||
assert InvisibleEngine.DEFAULT_MODEL_ID == "Lykon/dreamshaper-8"
|
||||
# SDXL base became the default in May 2026 (defeats SynthID v2).
|
||||
assert InvisibleEngine.DEFAULT_MODEL_ID == "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
|
||||
def test_ctrlregen_model_id(self):
|
||||
assert InvisibleEngine.CTRLREGEN_MODEL_ID == "yepengliu/ctrlregen"
|
||||
|
||||
|
||||
class TestTargetSize:
|
||||
"""Regression guard for the native-resolution decision (issues #10 / #15).
|
||||
|
||||
max_resolution=0 must NOT downscale -- the forced downscale->upscale
|
||||
round-trip was the quality loss in #10, and downscaling at all let SynthID
|
||||
survive in #15 (the native SDXL pass at strength ~0.05 is what defeats it).
|
||||
"""
|
||||
|
||||
def test_native_default_no_downscale(self):
|
||||
# The default (0) means native resolution: no resize, regardless of size.
|
||||
assert _target_size(4096, 4096, 0) is None
|
||||
assert _target_size(123, 456, 0) is None
|
||||
|
||||
def test_negative_cap_treated_as_native(self):
|
||||
assert _target_size(4096, 4096, -1) is None
|
||||
|
||||
def test_cap_below_long_side_downscales(self):
|
||||
# 2000x1000, cap 1024 -> long side scaled to 1024, aspect preserved.
|
||||
assert _target_size(2000, 1000, 1024) == (1024, 512)
|
||||
|
||||
def test_cap_uses_long_side_for_portrait(self):
|
||||
# Portrait: height is the long side, so it drives the ratio.
|
||||
assert _target_size(1000, 2000, 1024) == (512, 1024)
|
||||
|
||||
def test_cap_at_or_above_long_side_no_downscale(self):
|
||||
# Already within the cap (and exactly equal) -> no resize.
|
||||
assert _target_size(800, 600, 1024) is None
|
||||
assert _target_size(1024, 768, 1024) is None
|
||||
|
||||
def test_integer_truncation_matches_pil_call_site(self):
|
||||
# 1254x1254 (the gpt-image sample) capped at 1000: int(1254*1000/1254)=1000.
|
||||
assert _target_size(1254, 1254, 1000) == (1000, 1000)
|
||||
# Non-divisible ratio truncates toward zero like int() at the call site.
|
||||
assert _target_size(1000, 333, 500) == (500, 166)
|
||||
|
||||
def test_extreme_aspect_ratio_clamps_short_side_to_one(self):
|
||||
# 5000x3 capped at 1024: int(3 * 1024/5000) = 0 would crash resize();
|
||||
# the short side must clamp to 1, never 0.
|
||||
assert _target_size(5000, 3, 1024) == (1024, 1)
|
||||
assert _target_size(3, 5000, 1024) == (1, 1024)
|
||||
@@ -0,0 +1,90 @@
|
||||
"""Tests for open invisible-watermark (imwatermark) detection.
|
||||
|
||||
Each known scheme is round-tripped: embed its exact upstream pattern with the
|
||||
encoder, then assert the detector names it. Skipped entirely if the optional
|
||||
``invisible-watermark`` package is not installed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.invisible_watermark import (
|
||||
_BITS_48,
|
||||
_SD1_STRING,
|
||||
_bits_match,
|
||||
_bytes_match_frac,
|
||||
detect_invisible_watermark,
|
||||
is_available,
|
||||
)
|
||||
|
||||
pytestmark = pytest.mark.skipif(not is_available(), reason="invisible-watermark not installed")
|
||||
|
||||
|
||||
def _base_image() -> np.ndarray:
|
||||
# imwatermark needs enough DWT coefficients; use a 512x512 textured image.
|
||||
rng = np.random.default_rng(0)
|
||||
return rng.integers(0, 255, (512, 512, 3), dtype=np.uint8)
|
||||
|
||||
|
||||
def _write_bits_watermark(tmp_path: Path, message: int) -> Path:
|
||||
from imwatermark import WatermarkEncoder
|
||||
|
||||
bits = [int(b) for b in format(message, "048b")]
|
||||
enc = WatermarkEncoder()
|
||||
enc.set_watermark("bits", bits)
|
||||
wm = enc.encode(_base_image(), "dwtDct")
|
||||
path = tmp_path / "wm.png"
|
||||
cv2.imwrite(str(path), wm)
|
||||
return path
|
||||
|
||||
|
||||
class TestHelpers:
|
||||
def test_bits_match_exact(self):
|
||||
assert _bits_match(0b1010, 0b1010, width=4) == 4
|
||||
|
||||
def test_bits_match_one_off(self):
|
||||
assert _bits_match(0b1010, 0b1011, width=4) == 3
|
||||
|
||||
def test_bytes_match_identical(self):
|
||||
assert _bytes_match_frac(_SD1_STRING, _SD1_STRING) == 1.0
|
||||
|
||||
def test_bytes_match_length_mismatch_is_zero(self):
|
||||
assert _bytes_match_frac(b"abc", b"abcd") == 0.0
|
||||
|
||||
|
||||
class TestDetect:
|
||||
def test_detects_sdxl(self, tmp_path: Path):
|
||||
path = _write_bits_watermark(tmp_path, _BITS_48["Stable Diffusion XL"])
|
||||
assert detect_invisible_watermark(path) == "Stable Diffusion XL"
|
||||
|
||||
def test_detects_flux(self, tmp_path: Path):
|
||||
path = _write_bits_watermark(tmp_path, _BITS_48["FLUX.2 (Black Forest Labs)"])
|
||||
assert detect_invisible_watermark(path) == "FLUX.2 (Black Forest Labs)"
|
||||
|
||||
def test_detects_sd1_string(self, tmp_path: Path):
|
||||
from imwatermark import WatermarkEncoder
|
||||
|
||||
enc = WatermarkEncoder()
|
||||
enc.set_watermark("bytes", _SD1_STRING)
|
||||
wm = enc.encode(_base_image(), "dwtDct")
|
||||
path = tmp_path / "sd1.png"
|
||||
cv2.imwrite(str(path), wm)
|
||||
assert detect_invisible_watermark(path) == "Stable Diffusion 1.x / 2.x"
|
||||
|
||||
def test_clean_image_is_none(self, tmp_path: Path):
|
||||
path = tmp_path / "clean.png"
|
||||
cv2.imwrite(str(path), _base_image())
|
||||
assert detect_invisible_watermark(path) is None
|
||||
|
||||
def test_unreadable_file_is_none(self, tmp_path: Path):
|
||||
path = tmp_path / "not_image.png"
|
||||
path.write_bytes(b"not a png")
|
||||
assert detect_invisible_watermark(path) is None
|
||||
@@ -2,18 +2,29 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import piexif
|
||||
import pytest
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from remove_ai_watermarks.metadata import (
|
||||
_is_ai_key,
|
||||
exif_generator,
|
||||
get_ai_metadata,
|
||||
has_ai_metadata,
|
||||
iptc_ai_system,
|
||||
remove_ai_metadata,
|
||||
synthid_source,
|
||||
xai_signature,
|
||||
)
|
||||
|
||||
# Real, committed C2PA sample images used to ground the SynthID-source tests.
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "samples"
|
||||
|
||||
# ── Key detection ───────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -54,6 +65,75 @@ class TestHasAiMetadata:
|
||||
def test_clean_image_no_ai(self, tmp_clean_png):
|
||||
assert not has_ai_metadata(tmp_clean_png)
|
||||
|
||||
def test_detects_c2pa_uuid_in_isobmff_container(self, tmp_path: Path):
|
||||
"""C2PA in AVIF/HEIF/MP4 lives in a ``uuid`` box identified by a fixed UUID.
|
||||
|
||||
Real AVIF/HEIF fixtures aren't shipped, so simulate the container by
|
||||
prepending an ISOBMFF-shaped ftyp box and the C2PA UUID bytes.
|
||||
"""
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
|
||||
path = tmp_path / "fake.avif"
|
||||
# ftyp box: size(4) + 'ftyp' + 'avif' + minor_version(4) + 'avif'
|
||||
ftyp = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avifmif1"
|
||||
# uuid box: size(4) + 'uuid' + 16-byte UUID + minimal payload
|
||||
uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"jumb-payload"
|
||||
path.write_bytes(ftyp + uuid_box + b"\x00" * 64)
|
||||
assert has_ai_metadata(path)
|
||||
|
||||
def test_strip_c2pa_boxes_removes_uuid_box(self, tmp_path: Path):
|
||||
"""ISOBMFF strip should drop the C2PA uuid box and keep everything else."""
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
from remove_ai_watermarks.noai.isobmff import strip_c2pa_boxes
|
||||
|
||||
ftyp = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avifmif1"
|
||||
# uuid box: size(4) + 'uuid' + 16-byte UUID + minimal payload (8 bytes -> total 32)
|
||||
uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"payload!"
|
||||
mdat = b"\x00\x00\x00\x10mdat" + b"pixeldat"
|
||||
cleaned, stripped = strip_c2pa_boxes(ftyp + uuid_box + mdat)
|
||||
assert stripped == 1
|
||||
assert cleaned == ftyp + mdat
|
||||
|
||||
def test_strip_c2pa_boxes_passthrough_for_non_isobmff(self):
|
||||
"""Non-ISOBMFF input must be returned unchanged."""
|
||||
from remove_ai_watermarks.noai.isobmff import strip_c2pa_boxes
|
||||
|
||||
data = b"\x89PNG\r\n\x1a\n\x00\x00\x00\rIHDR" + b"\x00" * 100
|
||||
cleaned, stripped = strip_c2pa_boxes(data)
|
||||
assert stripped == 0
|
||||
assert cleaned == data
|
||||
|
||||
def test_remove_ai_metadata_strips_c2pa_in_avif(self, tmp_path: Path):
|
||||
"""End-to-end: ``remove_ai_metadata`` on a fake .avif drops the C2PA box."""
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID, remove_ai_metadata
|
||||
|
||||
src = tmp_path / "in.avif"
|
||||
ftyp = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avifmif1"
|
||||
uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"payload!"
|
||||
mdat = b"\x00\x00\x00\x10mdat" + b"pixeldat"
|
||||
src.write_bytes(ftyp + uuid_box + mdat)
|
||||
|
||||
out = tmp_path / "out.avif"
|
||||
result = remove_ai_metadata(src, out)
|
||||
assert result == out
|
||||
assert out.read_bytes() == ftyp + mdat
|
||||
# And after stripping, detection must no longer flag the cleaned file.
|
||||
from remove_ai_watermarks.metadata import has_ai_metadata
|
||||
|
||||
assert not has_ai_metadata(out)
|
||||
|
||||
def test_detects_iptc_trained_algorithmic_media_marker(self, tmp_path: Path):
|
||||
"""Some pipelines embed only the IPTC AI marker in XMP, no C2PA manifest."""
|
||||
path = tmp_path / "fake.jpg"
|
||||
# Minimal JPEG-ish bytes containing the IPTC AI marker in an XMP-like blob.
|
||||
xmp = (
|
||||
b"<x:xmpmeta><Iptc4xmpExt:DigitalSourceType>"
|
||||
b"trainedAlgorithmicMedia"
|
||||
b"</Iptc4xmpExt:DigitalSourceType></x:xmpmeta>"
|
||||
)
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1" + xmp + b"\xff\xd9")
|
||||
assert has_ai_metadata(path)
|
||||
|
||||
|
||||
class TestGetAiMetadata:
|
||||
"""Tests for extracting AI metadata."""
|
||||
@@ -75,6 +155,116 @@ class TestGetAiMetadata:
|
||||
meta = get_ai_metadata(tmp_clean_png)
|
||||
assert meta == {}
|
||||
|
||||
def test_long_value_is_truncated(self, tmp_path: Path):
|
||||
img = Image.new("RGB", (32, 32))
|
||||
pnginfo = PngInfo()
|
||||
pnginfo.add_text("parameters", "x" * 300)
|
||||
path = tmp_path / "long.png"
|
||||
img.save(path, pnginfo=pnginfo)
|
||||
meta = get_ai_metadata(path)
|
||||
assert meta["parameters"].endswith("…")
|
||||
assert len(meta["parameters"]) <= 205
|
||||
|
||||
def test_unopenable_file_does_not_raise(self, tmp_path: Path):
|
||||
# PIL can't open HEIC without pillow-heif; get_ai_metadata must fall
|
||||
# through to the binary scan, not propagate UnidentifiedImageError.
|
||||
path = tmp_path / "iphone.heic"
|
||||
path.write_bytes(b"\x00\x00\x00\x18ftypheic" + b"\x00" * 64)
|
||||
assert get_ai_metadata(path) == {}
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
class TestGetAiMetadataRealSample:
|
||||
"""get_ai_metadata surfaces the consolidated C2PA fields on real images."""
|
||||
|
||||
def test_openai_sample_fields(self):
|
||||
meta = get_ai_metadata(SAMPLES_DIR / "chatgpt-1.png")
|
||||
assert "claim_generator" in meta
|
||||
assert "OpenAI" in meta["issuer"]
|
||||
assert "OpenAI" in meta["synthid_watermark"]
|
||||
assert "trainedAlgorithmicMedia" in meta["source_type"]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"marker",
|
||||
[
|
||||
b"trainedAlgorithmicMedia",
|
||||
b"compositeSynthetic",
|
||||
b"algorithmicMedia",
|
||||
b"compositeWithTrainedAlgorithmicMedia",
|
||||
],
|
||||
)
|
||||
def test_has_ai_metadata_detects_each_iptc_marker(tmp_path: Path, marker: bytes):
|
||||
"""Each IPTC digitalSourceType AI marker in XMP triggers detection."""
|
||||
path = tmp_path / "iptc.jpg"
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1<x:xmpmeta>" + marker + b"</x:xmpmeta>\xff\xd9")
|
||||
assert has_ai_metadata(path)
|
||||
|
||||
|
||||
# ── SynthID-source detection (metadata proxy) ────────────────────────
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
class TestSynthIDSource:
|
||||
"""SynthID detection via the C2PA companion manifest.
|
||||
|
||||
Google (Imagen/Gemini) and OpenAI (ChatGPT/DALL-E/gpt-image) pair an
|
||||
invisible SynthID pixel watermark with a C2PA manifest. Adobe Firefly and
|
||||
Microsoft Designer sign C2PA Content Credentials but do NOT use SynthID,
|
||||
so the discriminating signal is the C2PA *issuer*, not the mere presence
|
||||
of a manifest. These tests run against real, committed sample images.
|
||||
"""
|
||||
|
||||
def test_openai_chatgpt_is_synthid_source(self):
|
||||
assert synthid_source(SAMPLES_DIR / "chatgpt-1.png") == "OpenAI"
|
||||
|
||||
def test_openai_verdict_in_get_ai_metadata(self):
|
||||
meta = get_ai_metadata(SAMPLES_DIR / "chatgpt-1.png")
|
||||
assert "synthid_watermark" in meta
|
||||
assert "OpenAI" in meta["synthid_watermark"]
|
||||
|
||||
def test_adobe_firefly_is_not_synthid_source(self):
|
||||
# Adobe signs C2PA (trainedAlgorithmicMedia) but embeds no SynthID.
|
||||
assert synthid_source(SAMPLES_DIR / "firefly-1.png") is None
|
||||
assert "synthid_watermark" not in get_ai_metadata(SAMPLES_DIR / "firefly-1.png")
|
||||
|
||||
def test_non_ai_image_is_not_synthid_source(self, clean_photo: Path):
|
||||
assert synthid_source(clean_photo) is None
|
||||
|
||||
|
||||
class TestSynthIDSourceNonPng:
|
||||
"""SynthID-source detection must work beyond PNG.
|
||||
|
||||
ChatGPT/Gemini images saved as JPEG/WebP/AVIF carry their C2PA manifest in
|
||||
a non-PNG container (JPEG APP11, ISOBMFF uuid box), so the PNG caBX parser
|
||||
misses them. These use synthetic byte blobs (real fixtures aren't shipped).
|
||||
"""
|
||||
|
||||
def _c2pa_jpeg(self, tmp_path: Path, name: str, issuer: bytes, marker: bytes = b"trainedAlgorithmicMedia") -> Path:
|
||||
path = tmp_path / name
|
||||
# Minimal JPEG shell with an embedded C2PA-ish blob.
|
||||
blob = b"jumbc2pa" + issuer + b"..." + marker
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1" + blob + b"\xff\xd9")
|
||||
return path
|
||||
|
||||
def test_openai_c2pa_in_jpeg(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, "chatgpt.jpg", b"OpenAI")
|
||||
assert synthid_source(path) == "OpenAI"
|
||||
|
||||
def test_google_c2pa_in_jpeg(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, "gemini.jpg", b"Google")
|
||||
assert synthid_source(path) == "Google LLC"
|
||||
|
||||
def test_adobe_c2pa_in_jpeg_is_none(self, tmp_path: Path):
|
||||
# Adobe signs C2PA but embeds no SynthID.
|
||||
path = self._c2pa_jpeg(tmp_path, "firefly.jpg", b"Adobe")
|
||||
assert synthid_source(path) is None
|
||||
|
||||
def test_openai_without_ai_marker_is_none(self, tmp_path: Path):
|
||||
# Issuer present but no AI digital-source marker -> not a SynthID source.
|
||||
path = self._c2pa_jpeg(tmp_path, "edited.jpg", b"OpenAI", marker=b"")
|
||||
assert synthid_source(path) is None
|
||||
|
||||
|
||||
# ── remove_ai_metadata ──────────────────────────────────────────────
|
||||
|
||||
@@ -148,3 +338,398 @@ class TestRemoveAiMetadata:
|
||||
result = remove_ai_metadata(tmp_clean_png, output)
|
||||
assert isinstance(result, Path)
|
||||
assert result == output
|
||||
|
||||
|
||||
def _img_with_software(tmp_path: Path, fmt: str, software: str) -> Path:
|
||||
"""Write a tiny image carrying an EXIF Software tag."""
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Software: software.encode()}, "Exif": {}, "GPS": {}, "1st": {}})
|
||||
path = tmp_path / f"img.{fmt}"
|
||||
Image.new("RGB", (64, 64), (100, 90, 80)).save(path, exif=exif)
|
||||
return path
|
||||
|
||||
|
||||
class TestExifGenerator:
|
||||
"""exif_generator extracts AI-tool names from EXIF/XMP across formats."""
|
||||
|
||||
def test_avif_software_ai_tool_detected(self, tmp_path: Path):
|
||||
path = _img_with_software(tmp_path, "avif", "Adobe Firefly")
|
||||
assert exif_generator(path) == "Adobe Firefly"
|
||||
|
||||
def test_jpeg_software_ai_tool_detected(self, tmp_path: Path):
|
||||
path = _img_with_software(tmp_path, "jpg", "ComfyUI v1.2")
|
||||
result = exif_generator(path)
|
||||
assert result is not None
|
||||
assert "ComfyUI" in result
|
||||
|
||||
def test_plain_editor_not_flagged(self, tmp_path: Path):
|
||||
# An ordinary editor tag carries no AI token and must not be flagged.
|
||||
path = _img_with_software(tmp_path, "jpg", "Adobe Photoshop 25.0")
|
||||
assert exif_generator(path) is None
|
||||
|
||||
def test_make_tag_ai_tool_detected(self, tmp_path: Path):
|
||||
# Ideogram tags its output with EXIF Make="Ideogram AI" (verified on a
|
||||
# real download), so the Make tag must be read too.
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Ideogram AI"}, "Exif": {}, "GPS": {}, "1st": {}})
|
||||
path = tmp_path / "ideogram.jpg"
|
||||
Image.new("RGB", (64, 64)).save(path, exif=exif)
|
||||
assert exif_generator(path) == "Ideogram AI"
|
||||
|
||||
def test_camera_make_not_flagged(self, tmp_path: Path):
|
||||
# A real camera Make ("Apple") carries no AI token -> not flagged.
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Apple"}, "Exif": {}, "GPS": {}, "1st": {}})
|
||||
path = tmp_path / "iphone.jpg"
|
||||
Image.new("RGB", (64, 64)).save(path, exif=exif)
|
||||
assert exif_generator(path) is None
|
||||
|
||||
def test_artist_tag_ai_tool_detected(self, tmp_path: Path):
|
||||
# exif_generator also reads the EXIF Artist field for an AI token.
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Artist: b"Midjourney"}, "Exif": {}, "GPS": {}, "1st": {}})
|
||||
path = tmp_path / "artist.jpg"
|
||||
Image.new("RGB", (64, 64)).save(path, exif=exif)
|
||||
assert exif_generator(path) == "Midjourney"
|
||||
|
||||
def test_imagedescription_tag_ai_tool_detected(self, tmp_path: Path):
|
||||
# ...and the EXIF ImageDescription field.
|
||||
exif = piexif.dump(
|
||||
{"0th": {piexif.ImageIFD.ImageDescription: b"Made with Stable Diffusion"}, "Exif": {}, "GPS": {}, "1st": {}}
|
||||
)
|
||||
path = tmp_path / "desc.jpg"
|
||||
Image.new("RGB", (64, 64)).save(path, exif=exif)
|
||||
result = exif_generator(path)
|
||||
assert result is not None
|
||||
assert "Stable Diffusion" in result
|
||||
|
||||
def test_xmp_creatortool_scan_covers_unopenable(self, tmp_path: Path):
|
||||
# PIL can't open this fake HEIF; the raw XMP CreatorTool scan still works.
|
||||
path = tmp_path / "fake.heic"
|
||||
path.write_bytes(
|
||||
b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00"
|
||||
b"<x:xmpmeta><xmp:CreatorTool>Midjourney v7</xmp:CreatorTool></x:xmpmeta>"
|
||||
)
|
||||
result = exif_generator(path)
|
||||
assert result is not None
|
||||
assert "Midjourney" in result
|
||||
|
||||
def test_clean_image_is_none(self, tmp_clean_png: Path):
|
||||
assert exif_generator(tmp_clean_png) is None
|
||||
|
||||
|
||||
_FAKE_SIG = "A" * 120 # 64+ base64 chars; real Grok payloads are 300-1004
|
||||
_FAKE_UUID = "12345678-1234-1234-1234-123456789abc"
|
||||
|
||||
|
||||
def _grok_jpeg(tmp_path: Path, *, signature: str = _FAKE_SIG, artist: str = _FAKE_UUID) -> Path:
|
||||
"""Write a synthetic Grok-style JPEG: EXIF ImageDescription "Signature: ..."
|
||||
+ a UUID Artist. Synthetic on purpose -- never commit a real Grok image
|
||||
(its Artist UUID + signature are user/session data; this is a public repo)."""
|
||||
exif = piexif.dump(
|
||||
{
|
||||
"0th": {
|
||||
piexif.ImageIFD.ImageDescription: f"Signature: {signature}".encode("latin1"),
|
||||
piexif.ImageIFD.Artist: artist.encode("latin1"),
|
||||
},
|
||||
"Exif": {},
|
||||
"GPS": {},
|
||||
"1st": {},
|
||||
}
|
||||
)
|
||||
path = tmp_path / "grok.jpg"
|
||||
Image.new("RGB", (64, 64), (70, 80, 90)).save(path, exif=exif)
|
||||
return path
|
||||
|
||||
|
||||
class TestXaiSignature:
|
||||
"""xAI / Grok's EXIF Signature + UUID-Artist provenance scheme."""
|
||||
|
||||
def test_signature_plus_uuid_detected(self, tmp_path: Path):
|
||||
assert xai_signature(_grok_jpeg(tmp_path)) is True
|
||||
|
||||
def test_real_grok_sample_detected(self):
|
||||
# Real committed Grok download (data/samples/grok-1.jpg); the EXIF
|
||||
# Signature + UUID-Artist pair is the only AI signal it carries.
|
||||
assert xai_signature(SAMPLES_DIR / "grok-1.jpg") is True
|
||||
|
||||
def test_signature_without_uuid_artist_not_flagged(self, tmp_path: Path):
|
||||
# A "Signature:" blob but a non-UUID Artist is not the Grok pair.
|
||||
assert xai_signature(_grok_jpeg(tmp_path, artist="John Doe")) is False
|
||||
|
||||
def test_bare_uuid_artist_not_flagged(self, tmp_path: Path):
|
||||
# A UUID Artist alone (no Signature blob) must not false-positive.
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Artist: _FAKE_UUID.encode()}, "Exif": {}, "GPS": {}, "1st": {}})
|
||||
path = tmp_path / "uuid_only.jpg"
|
||||
Image.new("RGB", (64, 64)).save(path, exif=exif)
|
||||
assert xai_signature(path) is False
|
||||
|
||||
def test_short_signature_text_not_flagged(self, tmp_path: Path):
|
||||
# Incidental short "Signature: ..." text is below the 64-char base64 bar.
|
||||
assert xai_signature(_grok_jpeg(tmp_path, signature="ok")) is False
|
||||
|
||||
def test_clean_image_is_false(self, tmp_clean_png: Path):
|
||||
assert xai_signature(tmp_clean_png) is False
|
||||
|
||||
def test_surfaced_in_get_ai_metadata(self, tmp_path: Path):
|
||||
assert "xai_signature" in get_ai_metadata(_grok_jpeg(tmp_path))
|
||||
|
||||
def test_has_ai_metadata_true(self, tmp_path: Path):
|
||||
assert has_ai_metadata(_grok_jpeg(tmp_path)) is True
|
||||
|
||||
|
||||
class TestRemoveAiExif:
|
||||
"""remove_ai_metadata scrubs AI-provenance EXIF tags but keeps genuine EXIF."""
|
||||
|
||||
def test_grok_signature_stripped_on_jpeg_output(self, tmp_path: Path):
|
||||
src = _grok_jpeg(tmp_path)
|
||||
assert xai_signature(src) is True
|
||||
out = tmp_path / "clean.jpg"
|
||||
remove_ai_metadata(src, out)
|
||||
assert xai_signature(out) is False
|
||||
assert has_ai_metadata(out) is False
|
||||
|
||||
def test_generator_make_token_stripped(self, tmp_path: Path):
|
||||
# Ideogram's EXIF Make="Ideogram AI" must be scrubbed on removal.
|
||||
exif = piexif.dump({"0th": {piexif.ImageIFD.Make: b"Ideogram AI"}, "Exif": {}, "GPS": {}, "1st": {}})
|
||||
src = tmp_path / "ideogram.jpg"
|
||||
Image.new("RGB", (64, 64)).save(src, exif=exif)
|
||||
out = tmp_path / "clean.jpg"
|
||||
remove_ai_metadata(src, out)
|
||||
assert exif_generator(out) is None
|
||||
|
||||
def test_real_camera_exif_preserved(self, tmp_path: Path):
|
||||
# A real-camera Make ("Apple") carries no AI token and must survive.
|
||||
exif = piexif.dump(
|
||||
{
|
||||
"0th": {piexif.ImageIFD.Make: b"Apple", piexif.ImageIFD.Model: b"iPhone 15"},
|
||||
"Exif": {},
|
||||
"GPS": {},
|
||||
"1st": {},
|
||||
}
|
||||
)
|
||||
src = tmp_path / "photo.jpg"
|
||||
Image.new("RGB", (64, 64)).save(src, exif=exif)
|
||||
out = tmp_path / "out.jpg"
|
||||
remove_ai_metadata(src, out)
|
||||
kept = piexif.load(Image.open(out).info["exif"])["0th"]
|
||||
assert kept.get(piexif.ImageIFD.Make) == b"Apple"
|
||||
|
||||
|
||||
class TestAIGCLabel:
|
||||
"""China TC260 AIGC labeling (Doubao and other China-served generators)."""
|
||||
|
||||
def _aigc_png(self, tmp_path: Path, label: str = "1", producer: str = "TESTPRODUCER001") -> Path:
|
||||
from remove_ai_watermarks.metadata import aigc_label # noqa: F401 (import-time guard)
|
||||
|
||||
p = tmp_path / "doubao.png"
|
||||
Image.new("RGB", (32, 32)).save(p)
|
||||
# XMP is HTML-entity encoded in real files; aigc_label must unescape it.
|
||||
xmp = (
|
||||
'<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF '
|
||||
'xmlns:rdf="http://www.w3.org/1999/02/22-rdf-syntax-ns#">'
|
||||
'<rdf:Description rdf:about="" '
|
||||
'xmlns:TC260="http://www.tc260.org.cn/ns/AIGC/1.0/"><TC260:AIGC>'
|
||||
f"{{"Label":"{label}","ContentProducer":"{producer}"}}"
|
||||
"</TC260:AIGC></rdf:Description></rdf:RDF></x:xmpmeta>"
|
||||
)
|
||||
with open(p, "ab") as f:
|
||||
f.write(xmp.encode())
|
||||
return p
|
||||
|
||||
def test_parses_label_and_producer(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
info = aigc_label(self._aigc_png(tmp_path))
|
||||
assert info is not None
|
||||
assert info["Label"] == "1"
|
||||
assert info["ContentProducer"] == "TESTPRODUCER001"
|
||||
|
||||
def test_none_when_absent(self, tmp_clean_png):
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
assert aigc_label(tmp_clean_png) is None
|
||||
|
||||
def test_has_ai_metadata_detects_aigc(self, tmp_path: Path):
|
||||
assert has_ai_metadata(self._aigc_png(tmp_path))
|
||||
|
||||
def test_get_ai_metadata_surfaces_aigc(self, tmp_path: Path):
|
||||
meta = get_ai_metadata(self._aigc_png(tmp_path))
|
||||
assert "aigc_label" in meta
|
||||
assert "TC260" in meta["aigc_label"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (SAMPLES_DIR / "doubao-1.png").exists(), reason="doubao sample not present")
|
||||
class TestAIGCRealSample:
|
||||
"""Real Doubao (ByteDance) sample carries the China TC260 AIGC XMP label."""
|
||||
|
||||
def test_doubao_aigc_label(self):
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
info = aigc_label(SAMPLES_DIR / "doubao-1.png")
|
||||
assert info is not None
|
||||
assert info["Label"] == "1"
|
||||
assert info["ContentProducer"] # ByteDance producer code present
|
||||
|
||||
def test_doubao_detected_as_ai(self):
|
||||
assert has_ai_metadata(SAMPLES_DIR / "doubao-1.png")
|
||||
assert "aigc_label" in get_ai_metadata(SAMPLES_DIR / "doubao-1.png")
|
||||
|
||||
|
||||
class TestSoftBinding:
|
||||
"""C2PA soft-binding alg identifier -> forensic-watermark vendor name."""
|
||||
|
||||
def test_vendors_in_recognizes_known_algs(self):
|
||||
from remove_ai_watermarks.noai.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"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"io.iscc.v0") == ["ISCC (content code)"]
|
||||
|
||||
def test_vendors_in_empty_when_absent(self):
|
||||
from remove_ai_watermarks.noai.c2pa import soft_binding_vendors_in
|
||||
|
||||
assert soft_binding_vendors_in(b"no soft binding here") == []
|
||||
|
||||
def test_get_ai_metadata_surfaces_soft_binding(self, tmp_path: Path):
|
||||
# Non-PNG binary-scan path: a manifest naming a soft-binding vendor.
|
||||
p = tmp_path / "fake.jpg"
|
||||
p.write_bytes(b"\xff\xd8\xff\xe1 c2pa jumb com.adobe.trustmark.P \xff\xd9")
|
||||
assert get_ai_metadata(p).get("soft_binding") == "Adobe TrustMark"
|
||||
|
||||
|
||||
class TestIptcAiFields:
|
||||
"""IPTC 2025.1 AI-disclosure XMP properties (Iptc4xmpExt:AISystemUsed etc.)."""
|
||||
|
||||
def test_detects_ai_system_used_element_form(self, tmp_path: Path):
|
||||
p = tmp_path / "iptc_ai.jpg"
|
||||
p.write_bytes(
|
||||
b"\xff\xd8\xff\xe1<x:xmpmeta><Iptc4xmpExt:AISystemUsed>ChatGPT DALL-E"
|
||||
b"</Iptc4xmpExt:AISystemUsed></x:xmpmeta>\xff\xd9"
|
||||
)
|
||||
assert has_ai_metadata(p) is True
|
||||
assert iptc_ai_system(p) == "ChatGPT DALL-E"
|
||||
assert "ChatGPT DALL-E" in get_ai_metadata(p)["ai_system"]
|
||||
|
||||
def test_attribute_serialization(self, tmp_path: Path):
|
||||
p = tmp_path / "attr.jpg"
|
||||
p.write_bytes(b'\xff\xd8\xff\xe1 Iptc4xmpExt:AISystemUsed="Google Gemini" \xff\xd9')
|
||||
assert iptc_ai_system(p) == "Google Gemini"
|
||||
|
||||
def test_present_without_value(self, tmp_path: Path):
|
||||
# A disclosure field with no extractable value still flags presence.
|
||||
p = tmp_path / "novalue.jpg"
|
||||
p.write_bytes(b"\xff\xd8\xff\xe1 Iptc4xmpExt:AIPromptWriterName \xff\xd9")
|
||||
assert iptc_ai_system(p) == "fields present"
|
||||
assert has_ai_metadata(p) is True
|
||||
|
||||
def test_clean_image_none(self, tmp_clean_png: Path):
|
||||
assert iptc_ai_system(tmp_clean_png) is None
|
||||
|
||||
|
||||
# Synthetic MP4 (ISOBMFF): ftyp + C2PA uuid box + mdat. Same box format as AVIF.
|
||||
_MP4_FTYP = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom"
|
||||
_MP4_MDAT = b"\x00\x00\x00\x10mdat" + b"videodat"
|
||||
|
||||
|
||||
class TestVideoC2pa:
|
||||
"""C2PA in MP4 (ISOBMFF) -- detect + strip, reusing the image box walker."""
|
||||
|
||||
def test_detects_c2pa_in_mp4(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
|
||||
uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"manifest"
|
||||
p = tmp_path / "ai.mp4"
|
||||
p.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
|
||||
assert has_ai_metadata(p) is True
|
||||
|
||||
def test_strips_c2pa_in_mp4(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
|
||||
uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"manifest"
|
||||
src = tmp_path / "in.mp4"
|
||||
src.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
|
||||
out = tmp_path / "out.mp4"
|
||||
remove_ai_metadata(src, out)
|
||||
assert out.read_bytes() == _MP4_FTYP + _MP4_MDAT
|
||||
assert has_ai_metadata(out) is False
|
||||
|
||||
|
||||
class TestIsobmffMetadataRemoval:
|
||||
"""Container-level AI-provenance stripping across ISOBMFF image/video/audio."""
|
||||
|
||||
def test_strips_ai_xmp_uuid_box(self):
|
||||
# A uuid box carrying a TC260 AIGC label is dropped by content match,
|
||||
# regardless of the (non-C2PA) XMP UUID's byte order.
|
||||
from remove_ai_watermarks.noai.isobmff import strip_c2pa_boxes
|
||||
|
||||
xmp_uuid = bytes(range(16)) # arbitrary, not the C2PA UUID
|
||||
payload = b'<x:xmpmeta><TC260:AIGC>{"Label":"1"}</TC260:AIGC></x:xmpmeta>'
|
||||
box = (24 + len(payload)).to_bytes(4, "big") + b"uuid" + xmp_uuid + payload
|
||||
cleaned, stripped = strip_c2pa_boxes(_MP4_FTYP + box + _MP4_MDAT)
|
||||
assert stripped == 1
|
||||
assert cleaned == _MP4_FTYP + _MP4_MDAT
|
||||
|
||||
def test_keeps_plain_non_ai_xmp(self):
|
||||
# A uuid box with ordinary (non-AI) XMP must be preserved.
|
||||
from remove_ai_watermarks.noai.isobmff import strip_c2pa_boxes
|
||||
|
||||
xmp_uuid = bytes(range(16))
|
||||
payload = b"<x:xmpmeta><dc:rights>(c) me</dc:rights></x:xmpmeta>"
|
||||
box = (24 + len(payload)).to_bytes(4, "big") + b"uuid" + xmp_uuid + payload
|
||||
cleaned, stripped = strip_c2pa_boxes(_MP4_FTYP + box + _MP4_MDAT)
|
||||
assert stripped == 0
|
||||
assert cleaned == _MP4_FTYP + box + _MP4_MDAT
|
||||
|
||||
def test_m4a_c2pa_stripped(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
|
||||
uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"manifest"
|
||||
src = tmp_path / "voice.m4a"
|
||||
src.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
|
||||
out = tmp_path / "clean.m4a"
|
||||
remove_ai_metadata(src, out)
|
||||
assert out.read_bytes() == _MP4_FTYP + _MP4_MDAT
|
||||
|
||||
def test_content_sniff_routes_unknown_suffix(self, tmp_path: Path):
|
||||
# An ISOBMFF file with a non-standard extension is still box-stripped.
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
|
||||
uuid_box = b"\x00\x00\x00\x20uuid" + C2PA_UUID + b"manifest"
|
||||
src = tmp_path / "mystery.bin"
|
||||
src.write_bytes(_MP4_FTYP + uuid_box + _MP4_MDAT)
|
||||
out = tmp_path / "out.bin"
|
||||
remove_ai_metadata(src, out)
|
||||
assert out.read_bytes() == _MP4_FTYP + _MP4_MDAT
|
||||
|
||||
def test_unparseable_audio_raises(self, tmp_path: Path):
|
||||
# Garbage that ffmpeg can't parse must raise a clear error, not crash in
|
||||
# the image path. (When ffmpeg is absent this still raises RuntimeError.)
|
||||
src = tmp_path / "audio.mp3"
|
||||
src.write_bytes(b"ID3\x04\x00\x00\x00\x00\x00\x00 not real mp3 frames")
|
||||
out = tmp_path / "out.mp3"
|
||||
with pytest.raises(RuntimeError):
|
||||
remove_ai_metadata(src, out)
|
||||
|
||||
|
||||
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
|
||||
class TestFfmpegMetadataStrip:
|
||||
"""Lossless container-metadata strip for non-ISOBMFF audio/video via ffmpeg."""
|
||||
|
||||
def _wav_with_tag(self, path: Path, tag: str = "Suno AI") -> None:
|
||||
subprocess.run( # noqa: S603
|
||||
[
|
||||
shutil.which("ffmpeg"), "-y", "-loglevel", "error",
|
||||
"-f", "lavfi", "-i", "sine=frequency=440:duration=0.1",
|
||||
"-metadata", f"title={tag}", str(path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
def test_strips_wav_title_metadata(self, tmp_path: Path):
|
||||
src = tmp_path / "in.wav"
|
||||
self._wav_with_tag(src, "Suno AI generated")
|
||||
assert b"Suno AI generated" in src.read_bytes() # tag is present pre-strip
|
||||
out = tmp_path / "clean.wav"
|
||||
remove_ai_metadata(src, out)
|
||||
assert out.exists()
|
||||
assert b"Suno AI generated" not in out.read_bytes() # tag stripped, audio kept
|
||||
@@ -2,10 +2,19 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.noai.c2pa import (
|
||||
_parse_c2pa_chunk,
|
||||
cbor_text_after,
|
||||
extract_c2pa_chunk,
|
||||
extract_c2pa_info,
|
||||
has_c2pa_metadata,
|
||||
inject_c2pa_chunk,
|
||||
synthid_verdict,
|
||||
)
|
||||
from remove_ai_watermarks.noai.cleaner import (
|
||||
has_ai_content,
|
||||
@@ -26,6 +35,10 @@ from remove_ai_watermarks.noai.extractor import (
|
||||
get_ai_metadata_summary,
|
||||
has_ai_metadata,
|
||||
)
|
||||
from remove_ai_watermarks.noai.isobmff import (
|
||||
is_isobmff,
|
||||
strip_c2pa_boxes,
|
||||
)
|
||||
|
||||
# ── Constants ───────────────────────────────────────────────────────
|
||||
|
||||
@@ -128,3 +141,207 @@ class TestC2PA:
|
||||
|
||||
def test_c2pa_returns_false_for_non_png(self, tmp_jpeg_path):
|
||||
assert not has_c2pa_metadata(tmp_jpeg_path)
|
||||
|
||||
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "samples"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
class TestC2PARealSamples:
|
||||
"""Parser behavior on real committed C2PA images."""
|
||||
|
||||
def test_detects_c2pa_in_openai_png(self):
|
||||
assert has_c2pa_metadata(SAMPLES_DIR / "chatgpt-1.png")
|
||||
|
||||
def test_extract_info_openai_fields(self):
|
||||
info = extract_c2pa_info(SAMPLES_DIR / "chatgpt-1.png")
|
||||
assert info["has_c2pa"] is True
|
||||
assert "OpenAI" in info["issuer"]
|
||||
assert "c2pa_manifest" in info # "C2PA manifest (N bytes)"
|
||||
assert "trainedAlgorithmicMedia" in info["source_type"]
|
||||
# CBOR-clean claim generator, no regex artifacts (e.g. "fGPT-4o").
|
||||
assert info["claim_generator"]
|
||||
assert not info["claim_generator"].startswith("f")
|
||||
assert "synthid_watermark" in info
|
||||
|
||||
def test_extract_info_adobe_has_no_synthid(self):
|
||||
info = extract_c2pa_info(SAMPLES_DIR / "firefly-1.png")
|
||||
assert "Adobe" in info["issuer"]
|
||||
assert "synthid_watermark" not in info
|
||||
|
||||
def test_extract_chunk_returns_bytes(self):
|
||||
chunk = extract_c2pa_chunk(SAMPLES_DIR / "chatgpt-1.png")
|
||||
assert chunk is not None
|
||||
assert chunk[4:8] == b"caBX" # chunk type in the 8-byte header
|
||||
|
||||
def test_inject_round_trip(self, tmp_clean_png, tmp_path):
|
||||
"""Extract a real C2PA chunk, inject into a clean PNG, re-detect."""
|
||||
chunk = extract_c2pa_chunk(SAMPLES_DIR / "chatgpt-1.png")
|
||||
out = tmp_path / "injected.png"
|
||||
inject_c2pa_chunk(tmp_clean_png, out, chunk)
|
||||
assert has_c2pa_metadata(out)
|
||||
assert "OpenAI" in extract_c2pa_info(out)["issuer"]
|
||||
|
||||
|
||||
class TestC2PAInjectValidation:
|
||||
def test_inject_rejects_non_png(self, tmp_path):
|
||||
with pytest.raises(ValueError, match="only supported for PNG"):
|
||||
inject_c2pa_chunk(tmp_path / "in.jpg", tmp_path / "out.png", b"")
|
||||
|
||||
|
||||
# ── CBOR text extraction (parser internals) ─────────────────────────
|
||||
|
||||
|
||||
class TestCborTextAfter:
|
||||
"""cbor_text_after handles the three CBOR text-string length prefixes."""
|
||||
|
||||
def test_direct_length(self):
|
||||
# major-type 3, direct length (0x60 + len). "abc" -> 0x63.
|
||||
payload = b"name" + bytes([0x63]) + b"abc"
|
||||
assert cbor_text_after(payload, b"name") == "abc"
|
||||
|
||||
def test_one_byte_length(self):
|
||||
s = b"x" * 30
|
||||
payload = b"name" + bytes([0x78, 30]) + s
|
||||
assert cbor_text_after(payload, b"name") == "x" * 30
|
||||
|
||||
def test_two_byte_length(self):
|
||||
s = b"y" * 300
|
||||
payload = b"name" + bytes([0x79]) + struct.pack(">H", 300) + s
|
||||
assert cbor_text_after(payload, b"name") == "y" * 300
|
||||
|
||||
def test_key_not_found_returns_none(self):
|
||||
assert cbor_text_after(b"nothing here", b"name") is None
|
||||
|
||||
def test_key_at_end_returns_none(self):
|
||||
assert cbor_text_after(b"prefixname", b"name") is None
|
||||
|
||||
def test_invalid_head_returns_none(self):
|
||||
# 0x00 is not a text-string head.
|
||||
assert cbor_text_after(b"name" + bytes([0x00]) + b"abc", b"name") is None
|
||||
|
||||
def test_latin1_fallback_on_invalid_utf8(self):
|
||||
payload = b"name" + bytes([0x61]) + b"\xff" # len 1, invalid utf-8
|
||||
assert cbor_text_after(payload, b"name") is not None
|
||||
|
||||
|
||||
class TestSynthIDVerdict:
|
||||
def test_format(self):
|
||||
assert synthid_verdict("OpenAI") == "likely present (OpenAI embeds SynthID with C2PA)"
|
||||
|
||||
def test_multiple_vendors(self):
|
||||
assert "Google LLC, OpenAI" in synthid_verdict("Google LLC, OpenAI")
|
||||
|
||||
|
||||
class TestParseChunkGuards:
|
||||
"""_parse_c2pa_chunk rejects non-printable claim_generator garbage.
|
||||
|
||||
On some manifests (observed: Microsoft Designer) the first ``name`` key
|
||||
precedes a binary hash field, not the generator string. The clean issuer +
|
||||
SynthID verdict must still come through.
|
||||
"""
|
||||
|
||||
def test_clean_generator_kept(self):
|
||||
# "name" + CBOR text-string (head 0x69 = 0x60+9) "gpt-image"
|
||||
chunk = b"...name" + bytes([0x69]) + b"gpt-image" + b"OpenAI trainedAlgorithmicMedia"
|
||||
info: dict = {}
|
||||
_parse_c2pa_chunk(chunk, info)
|
||||
assert info["claim_generator"] == "gpt-image"
|
||||
assert "OpenAI" in info["issuer"]
|
||||
assert "synthid_watermark" in info # OpenAI + trainedAlgorithmicMedia
|
||||
|
||||
def test_nonprintable_generator_dropped(self):
|
||||
# "name" + CBOR string (head 0x64 = len 4) with a control byte -> garbage
|
||||
chunk = b"...name" + bytes([0x64]) + b"\x81abc" + b"OpenAI trainedAlgorithmicMedia"
|
||||
info: dict = {}
|
||||
_parse_c2pa_chunk(chunk, info)
|
||||
assert "claim_generator" not in info # control-char garbage rejected
|
||||
assert "OpenAI" in info["issuer"] # issuer byte-search still robust
|
||||
|
||||
|
||||
class TestC2PADigitalSourceType:
|
||||
"""The three IPTC digitalSourceType variants drive the AI verdict.
|
||||
|
||||
Only *trained* and *composite-with-trained* mean AI-generated (and so imply
|
||||
a SynthID proxy for a SynthID vendor); plain ``algorithmicMedia`` is
|
||||
procedural (not trained) and must NOT be flagged as AI.
|
||||
"""
|
||||
|
||||
def test_plain_algorithmic_media_not_flagged_ai(self):
|
||||
chunk = b"...name" + bytes([0x69]) + b"some-tool" + b" OpenAI algorithmicMedia"
|
||||
info: dict = {}
|
||||
_parse_c2pa_chunk(chunk, info)
|
||||
assert info["source_type"] == "algorithmicMedia"
|
||||
assert "synthid_watermark" not in info # procedural, not AI-generated
|
||||
|
||||
def test_composite_with_trained_is_ai_and_synthid(self):
|
||||
chunk = b"...name" + bytes([0x69]) + b"some-tool" + b" OpenAI compositeWithTrainedAlgorithmicMedia"
|
||||
info: dict = {}
|
||||
_parse_c2pa_chunk(chunk, info)
|
||||
assert "compositeWithTrainedAlgorithmicMedia" in info["source_type"]
|
||||
assert "synthid_watermark" in info # AI-enhanced + OpenAI issuer
|
||||
|
||||
|
||||
# ── ISOBMFF (AVIF / HEIF / JPEG-XL container stripping) ──────────────
|
||||
|
||||
FTYP = b"\x00\x00\x00\x18ftypavif\x00\x00\x00\x00avifmif1" # 24-byte ftyp box
|
||||
|
||||
|
||||
class TestISOBMFF:
|
||||
def test_is_isobmff_true(self):
|
||||
assert is_isobmff(FTYP)
|
||||
|
||||
def test_is_isobmff_false_for_png(self):
|
||||
assert not is_isobmff(b"\x89PNG\r\n\x1a\n\x00\x00")
|
||||
|
||||
def test_is_isobmff_false_for_short(self):
|
||||
assert not is_isobmff(b"abc")
|
||||
|
||||
def test_strips_jpegxl_jumb_box(self):
|
||||
"""JPEG-XL stores JUMBF in a ``jumb`` box, always stripped."""
|
||||
jumb = struct.pack(">I", 8 + 5) + b"jumb" + b"hello"
|
||||
cleaned, stripped = strip_c2pa_boxes(FTYP + jumb)
|
||||
assert stripped == 1
|
||||
assert cleaned == FTYP
|
||||
|
||||
def test_keeps_non_c2pa_box_with_64bit_size(self):
|
||||
"""size==1 means a 64-bit largesize follows; non-C2PA box is kept."""
|
||||
payload = b"\x00" * 8
|
||||
box = b"\x00\x00\x00\x01" + b"free" + struct.pack(">Q", 16 + len(payload)) + payload
|
||||
cleaned, stripped = strip_c2pa_boxes(FTYP + box)
|
||||
assert stripped == 0
|
||||
assert cleaned == FTYP + box
|
||||
|
||||
def test_malformed_box_does_not_crash(self):
|
||||
# A box claiming size 4 (< 8-byte header) must terminate iteration safely.
|
||||
cleaned, stripped = strip_c2pa_boxes(FTYP + b"\x00\x00\x00\x04XXXX")
|
||||
assert stripped == 0
|
||||
assert cleaned.startswith(FTYP)
|
||||
|
||||
def test_size_zero_box_runs_to_eof(self):
|
||||
# size32==0 means the box extends to EOF; a non-C2PA box round-trips.
|
||||
box = struct.pack(">I", 0) + b"free" + b"\x00\x00\x00\x00"
|
||||
cleaned, stripped = strip_c2pa_boxes(FTYP + box)
|
||||
assert stripped == 0
|
||||
assert cleaned == FTYP + box
|
||||
|
||||
def test_truncated_largesize_terminates_safely(self):
|
||||
# size32==1 promises a 64-bit largesize, but the box ends after 8 bytes;
|
||||
# iteration must stop rather than read the missing largesize past EOF.
|
||||
cleaned, stripped = strip_c2pa_boxes(FTYP + b"\x00\x00\x00\x01uuid")
|
||||
assert stripped == 0
|
||||
assert cleaned == FTYP
|
||||
|
||||
|
||||
class TestC2PAInvalidSignature:
|
||||
"""A .png file that is not actually PNG-signed must read as clean, not crash."""
|
||||
|
||||
def test_has_c2pa_false_for_non_png_bytes(self, tmp_path: Path):
|
||||
fake = tmp_path / "fake.png"
|
||||
fake.write_bytes(b"\xff\xd8\xff\xe0 not a png at all, just garbage bytes")
|
||||
assert has_c2pa_metadata(fake) is False
|
||||
|
||||
def test_extract_chunk_none_for_non_png_bytes(self, tmp_path: Path):
|
||||
fake = tmp_path / "fake.png"
|
||||
fake.write_bytes(b"\xff\xd8\xff\xe0 not a png at all, just garbage bytes")
|
||||
assert extract_c2pa_chunk(fake) is None
|
||||
@@ -63,7 +63,7 @@ class TestModelProfiles:
|
||||
"""Tests for watermark_profiles.py."""
|
||||
|
||||
def test_default_profile(self):
|
||||
assert get_model_id_for_profile("default") == "Lykon/dreamshaper-8"
|
||||
assert get_model_id_for_profile("default") == "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
|
||||
def test_ctrlregen_profile(self):
|
||||
assert get_model_id_for_profile("ctrlregen") == "yepengliu/ctrlregen"
|
||||
@@ -73,7 +73,7 @@ class TestModelProfiles:
|
||||
get_model_id_for_profile("nonexistent")
|
||||
|
||||
def test_detect_default(self):
|
||||
assert detect_model_profile("Lykon/dreamshaper-8") == "default"
|
||||
assert detect_model_profile("stabilityai/stable-diffusion-xl-base-1.0") == "default"
|
||||
|
||||
def test_detect_ctrlregen(self):
|
||||
assert detect_model_profile("yepengliu/ctrlregen") == "ctrlregen"
|
||||
@@ -87,6 +87,24 @@ class TestModelProfiles:
|
||||
def test_recommended_strength_medium(self):
|
||||
assert get_recommended_strength("unknown_type") == 0.35
|
||||
|
||||
@pytest.mark.parametrize("wm_type", ["stegastamp", "stegasamp", "treering", "ringid"])
|
||||
def test_high_perturbation_watermark_types(self, wm_type):
|
||||
"""Robust spatial watermarks need aggressive (0.7) regeneration."""
|
||||
assert get_recommended_strength(wm_type) == 0.7
|
||||
|
||||
@pytest.mark.parametrize("wm_type", ["stablesignature", "dwtectsvd", "rivagan", "ssl", "hidden"])
|
||||
def test_low_perturbation_watermark_types(self, wm_type):
|
||||
"""Fragile frequency/latent watermarks break at low (0.04) strength."""
|
||||
assert get_recommended_strength(wm_type) == 0.04
|
||||
|
||||
def test_strength_match_is_case_insensitive(self):
|
||||
assert get_recommended_strength("TreeRing") == 0.7
|
||||
assert get_recommended_strength("StableSignature") == 0.04
|
||||
|
||||
def test_strength_matches_substring_in_descriptive_name(self):
|
||||
# e.g. a CLI passing "treering_v2" or "synthid-stegastamp" still maps.
|
||||
assert get_recommended_strength("treering_v2") == 0.7
|
||||
|
||||
|
||||
# ── Format utilities ────────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Tests for the universal region eraser."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.region_eraser import boxes_to_mask, erase, lama_available
|
||||
|
||||
|
||||
class TestBoxesToMask:
|
||||
def test_mask_set_inside_box(self):
|
||||
mask = boxes_to_mask((100, 100), [(10, 20, 30, 40)], dilate=0)
|
||||
assert mask[25, 15] == 255 # inside
|
||||
assert mask[0, 0] == 0 # outside
|
||||
assert mask.shape == (100, 100)
|
||||
|
||||
def test_multiple_boxes(self):
|
||||
mask = boxes_to_mask((100, 100), [(0, 0, 10, 10), (90, 90, 10, 10)], dilate=0)
|
||||
assert mask[5, 5] == 255
|
||||
assert mask[95, 95] == 255
|
||||
assert mask[50, 50] == 0
|
||||
|
||||
def test_dilate_grows_mask(self):
|
||||
m0 = boxes_to_mask((100, 100), [(40, 40, 10, 10)], dilate=0)
|
||||
m5 = boxes_to_mask((100, 100), [(40, 40, 10, 10)], dilate=5)
|
||||
assert m5.sum() > m0.sum()
|
||||
|
||||
def test_box_clipped_to_bounds(self):
|
||||
# box partly outside the image must not raise and stays in-bounds
|
||||
mask = boxes_to_mask((50, 50), [(40, 40, 100, 100)], dilate=0)
|
||||
assert mask[45, 45] == 255
|
||||
|
||||
|
||||
class TestEraseCv2:
|
||||
def _image_with_logo(self) -> tuple[np.ndarray, tuple[int, int, int, int]]:
|
||||
img = np.full((200, 200, 3), 120, np.uint8) # flat gray background
|
||||
box = (140, 160, 50, 30)
|
||||
x, y, w, h = box
|
||||
img[y : y + h, x : x + w] = (255, 255, 255) # bright "logo"
|
||||
return img, box
|
||||
|
||||
def test_erase_changes_region(self):
|
||||
img, box = self._image_with_logo()
|
||||
out = erase(img, boxes=[box], backend="cv2")
|
||||
x, y, w, h = box
|
||||
# on a flat background the logo region should be repainted near gray
|
||||
region = out[y : y + h, x : x + w]
|
||||
assert abs(float(region.mean()) - 120) < 20
|
||||
assert not np.array_equal(out, img)
|
||||
|
||||
def test_pixels_outside_box_untouched(self):
|
||||
img, box = self._image_with_logo()
|
||||
out = erase(img, boxes=[box], backend="cv2", dilate=0)
|
||||
# a far corner must be identical
|
||||
assert np.array_equal(img[:50, :50], out[:50, :50])
|
||||
|
||||
def test_no_boxes_returns_copy(self):
|
||||
img = np.full((100, 100, 3), 50, np.uint8)
|
||||
out = erase(img, boxes=[], backend="cv2")
|
||||
assert np.array_equal(img, out)
|
||||
|
||||
def test_empty_mask_returns_copy(self):
|
||||
img = np.full((100, 100, 3), 50, np.uint8)
|
||||
out = erase(img, mask=np.zeros((100, 100), np.uint8), backend="cv2")
|
||||
assert np.array_equal(img, out)
|
||||
|
||||
|
||||
class TestLamaBackend:
|
||||
def test_lama_raises_when_unavailable(self):
|
||||
img = np.full((100, 100, 3), 50, np.uint8)
|
||||
if lama_available():
|
||||
pytest.skip("onnxruntime installed; cannot test the unavailable path")
|
||||
with pytest.raises(RuntimeError, match="onnxruntime"):
|
||||
erase(img, boxes=[(10, 10, 20, 20)], backend="lama")
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Tests for the SynthID corpus ingestion script."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from click.testing import CliRunner
|
||||
|
||||
# scripts/ is not an installed package; add it to the path for import.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import synthid_corpus
|
||||
|
||||
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "samples"
|
||||
|
||||
|
||||
def _manifest_rows(root: Path) -> list[dict[str, str]]:
|
||||
with open(root / "manifest.csv", newline="") as f:
|
||||
return list(csv.DictReader(f))
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
class TestIngest:
|
||||
def test_ingest_openai_flags_synthid_metadata(self, tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
synthid_corpus.cli,
|
||||
["ingest", str(SAMPLES_DIR / "chatgpt-1.png"), "--label", "pos", "--root", str(tmp_path)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
rows = _manifest_rows(tmp_path)
|
||||
assert len(rows) == 1
|
||||
row = rows[0]
|
||||
assert row["label"] == "pos"
|
||||
assert row["synthid_metadata"] == "yes"
|
||||
assert int(row["width"]) > 0
|
||||
assert int(row["height"]) > 0
|
||||
# The copied file lands under images/pos/ with a sha-prefixed name.
|
||||
assert (tmp_path / "images" / "pos" / row["filename"]).exists()
|
||||
|
||||
def test_ingest_firefly_not_flagged(self, tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
runner.invoke(
|
||||
synthid_corpus.cli,
|
||||
["ingest", str(SAMPLES_DIR / "firefly-1.png"), "--label", "neg", "--root", str(tmp_path)],
|
||||
)
|
||||
rows = _manifest_rows(tmp_path)
|
||||
assert len(rows) == 1
|
||||
assert rows[0]["synthid_metadata"] == "" # Adobe signs C2PA but not SynthID
|
||||
|
||||
def test_ingest_dedupes_by_sha256(self, tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
args = ["ingest", str(SAMPLES_DIR / "chatgpt-1.png"), "--label", "pos", "--root", str(tmp_path)]
|
||||
runner.invoke(synthid_corpus.cli, args)
|
||||
runner.invoke(synthid_corpus.cli, args) # second time: duplicate
|
||||
assert len(_manifest_rows(tmp_path)) == 1
|
||||
|
||||
def test_status_on_empty_corpus(self, tmp_path: Path):
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(synthid_corpus.cli, ["status", "--root", str(tmp_path)])
|
||||
assert result.exit_code == 0
|
||||
assert "empty" in result.output.lower()
|
||||
@@ -0,0 +1,104 @@
|
||||
"""Tests for the SynthID pixel-carrier probe.
|
||||
|
||||
The probe's logic is validated synthetically: a fixed carrier injected into
|
||||
uniform fills must correlate strongly, random noise must not, and simulated
|
||||
removal (dropping the carrier) must collapse the correlation. No real SynthID
|
||||
fills or GPU are needed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
from click.testing import CliRunner
|
||||
from PIL import Image
|
||||
|
||||
# scripts/ is not an installed package; add it to the path for import.
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent.parent / "scripts"))
|
||||
|
||||
import synthid_pixel_probe as probe
|
||||
|
||||
_SHAPE = (64, 64)
|
||||
|
||||
|
||||
def _fixed_carrier(seed: int = 7) -> np.ndarray:
|
||||
"""A reproducible 2-D carrier pattern (the stand-in for the SynthID signal)."""
|
||||
rng = np.random.default_rng(seed)
|
||||
return rng.standard_normal(_SHAPE)
|
||||
|
||||
|
||||
def _fill_with_carrier(base: float, pattern: np.ndarray, noise_seed: int, noise: float = 0.05) -> np.ndarray:
|
||||
"""A solid fill at brightness ``base`` carrying ``pattern`` plus light noise."""
|
||||
rng = np.random.default_rng(noise_seed)
|
||||
return base + pattern + noise * rng.standard_normal(_SHAPE)
|
||||
|
||||
|
||||
class TestCarrier:
|
||||
def test_flat_image_has_zero_carrier(self):
|
||||
# A perfectly uniform fill (std 0, like the synthetic refs) carries nothing.
|
||||
flat = np.zeros(_SHAPE)
|
||||
assert not np.any(probe.carrier(flat))
|
||||
|
||||
def test_carrier_is_unit_norm(self):
|
||||
c = probe.carrier(_fixed_carrier() + 100.0)
|
||||
assert np.isclose(np.linalg.norm(c), 1.0)
|
||||
|
||||
def test_ncc_self_is_one(self):
|
||||
c = probe.carrier(_fixed_carrier())
|
||||
assert np.isclose(probe.ncc(c, c), 1.0)
|
||||
|
||||
def test_ncc_mismatched_shape_is_zero(self):
|
||||
a = probe.carrier(np.random.default_rng(1).standard_normal((8, 8)))
|
||||
b = probe.carrier(np.random.default_rng(2).standard_normal((16, 16)))
|
||||
assert probe.ncc(a, b) == 0.0
|
||||
|
||||
|
||||
class TestConsistency:
|
||||
def test_shared_carrier_correlates_high(self):
|
||||
pattern = _fixed_carrier()
|
||||
carriers = [probe.carrier(_fill_with_carrier(b, pattern, s)) for s, b in enumerate((10, 60, 120, 200))]
|
||||
assert probe.mean_pairwise_ncc(carriers) > 0.8
|
||||
|
||||
def test_random_fills_near_zero(self):
|
||||
rng = np.random.default_rng(0)
|
||||
carriers = [probe.carrier(rng.standard_normal(_SHAPE)) for _ in range(5)]
|
||||
assert abs(probe.mean_pairwise_ncc(carriers)) < 0.2
|
||||
|
||||
def test_random_baseline_near_zero(self):
|
||||
assert abs(probe.random_baseline(_SHAPE, 6)) < 0.2
|
||||
|
||||
|
||||
class TestRemoval:
|
||||
def test_removed_carrier_collapses_correlation(self):
|
||||
pattern = _fixed_carrier()
|
||||
pos = [probe.carrier(_fill_with_carrier(b, pattern, s)) for s, b in enumerate((20, 90, 160))]
|
||||
tmpl = probe.template(pos)
|
||||
# "Cleaned" fills keep the base + noise but lose the shared pattern.
|
||||
rng = np.random.default_rng(99)
|
||||
cleaned = [probe.carrier(b + 0.05 * rng.standard_normal(_SHAPE)) for b in (20, 90, 160)]
|
||||
pos_corr = float(np.mean([probe.ncc(c, tmpl) for c in pos]))
|
||||
cleaned_corr = float(np.mean([probe.ncc(c, tmpl) for c in cleaned]))
|
||||
assert pos_corr > 0.8
|
||||
assert cleaned_corr < 0.2
|
||||
|
||||
|
||||
class TestCli:
|
||||
def _solid_fill_png(self, tmp_path: Path, name: str, base: int, pattern: np.ndarray, seed: int) -> Path:
|
||||
arr = np.clip(_fill_with_carrier(base, pattern, seed), 0, 255).astype(np.uint8)
|
||||
path = tmp_path / name
|
||||
Image.fromarray(np.stack([arr] * 3, axis=2)).save(path)
|
||||
return path
|
||||
|
||||
def test_consistency_command_runs(self, tmp_path: Path):
|
||||
pattern = _fixed_carrier()
|
||||
paths = [str(self._solid_fill_png(tmp_path, f"f{i}.png", b, pattern, i)) for i, b in enumerate((40, 120, 200))]
|
||||
result = CliRunner().invoke(probe.cli, ["consistency", *paths])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "mean pairwise NCC" in result.output
|
||||
|
||||
def test_consistency_needs_two_images(self, tmp_path: Path):
|
||||
path = str(self._solid_fill_png(tmp_path, "only.png", 100, _fixed_carrier(), 0))
|
||||
result = CliRunner().invoke(probe.cli, ["consistency", path])
|
||||
assert result.exit_code != 0
|
||||
@@ -0,0 +1,76 @@
|
||||
"""Unit tests for the pure tiling helpers (no GPU/model required).
|
||||
|
||||
``tiling.py`` imports torch at module top, so skip cleanly when torch is
|
||||
absent. The helpers themselves are pure numpy/PIL/math -- they decide how a
|
||||
large image is split into overlapping tiles and blended back, so a regression
|
||||
here would seam or crop the CtrlRegen output wrongly.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
pytest.importorskip("torch")
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks.noai.ctrlregen.tiling import (
|
||||
make_blend_weight,
|
||||
resize_center_crop,
|
||||
tile_positions,
|
||||
)
|
||||
|
||||
|
||||
class TestTilePositions:
|
||||
def test_image_smaller_than_tile_single_position(self):
|
||||
assert tile_positions(500, 512, 64) == [0]
|
||||
|
||||
def test_image_equal_to_tile_single_position(self):
|
||||
assert tile_positions(512, 512, 64) == [0]
|
||||
|
||||
def test_first_is_zero_last_is_total_minus_tile(self):
|
||||
# The tiles must fully cover the span: first starts at 0, last ends at
|
||||
# the far edge (start == total - tile), or the image's edge is missed.
|
||||
pos = tile_positions(2000, 512, 64)
|
||||
assert pos[0] == 0
|
||||
assert pos[-1] == 2000 - 512
|
||||
|
||||
def test_overlap_positions_are_monotonic_and_exact(self):
|
||||
assert tile_positions(1000, 512, 64) == [0, 244, 488]
|
||||
|
||||
def test_zero_overlap_tiles_are_contiguous(self):
|
||||
# 1024 wide, 512 tile, no overlap -> two tiles butting at 512.
|
||||
assert tile_positions(1024, 512, 0) == [0, 512]
|
||||
|
||||
|
||||
class TestMakeBlendWeight:
|
||||
def test_zero_overlap_is_all_ones(self):
|
||||
w = make_blend_weight(8, 8, 0)
|
||||
assert w.shape == (8, 8)
|
||||
assert w.dtype == np.float64
|
||||
assert np.all(w == 1.0)
|
||||
|
||||
def test_overlap_ramps_corners_to_zero_center_to_one(self):
|
||||
w = make_blend_weight(16, 16, 4)
|
||||
assert w[0, 0] == 0.0 # cosine ramp starts at 0
|
||||
assert w[8, 8] == 1.0 # center is unweighted
|
||||
assert w.max() == 1.0
|
||||
assert w.min() == 0.0
|
||||
|
||||
def test_weight_is_point_symmetric(self):
|
||||
# Symmetric ramps on both edges -> mask equals its 180-degree rotation,
|
||||
# so opposite tile seams blend identically.
|
||||
w = make_blend_weight(16, 16, 4)
|
||||
assert np.allclose(w, w[::-1, ::-1])
|
||||
|
||||
|
||||
class TestResizeCenterCrop:
|
||||
@pytest.mark.parametrize(("width", "height"), [(400, 800), (800, 400), (300, 300), (1000, 1001)])
|
||||
def test_output_is_always_square_of_requested_size(self, width: int, height: int):
|
||||
out = resize_center_crop(Image.new("RGB", (width, height)), 256)
|
||||
assert out.size == (256, 256)
|
||||
|
||||
def test_default_size_is_512(self):
|
||||
out = resize_center_crop(Image.new("RGB", (640, 480)))
|
||||
assert out.size == (512, 512)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Tests for the optional Adobe TrustMark detector.
|
||||
|
||||
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.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.trustmark_detector import detect_trustmark, is_available
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def test_detect_never_raises(tmp_clean_png: Path):
|
||||
# Whether or not trustmark is installed, a clean image must yield None
|
||||
# (no watermark) without raising. When absent, the import guard returns None.
|
||||
assert detect_trustmark(tmp_clean_png) is None
|
||||
|
||||
|
||||
def test_unreadable_file_returns_none(tmp_path: Path):
|
||||
bad = tmp_path / "not_an_image.txt"
|
||||
bad.write_bytes(b"not an image")
|
||||
assert detect_trustmark(bad) is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(not is_available(), reason="trustmark not installed")
|
||||
def test_clean_image_reports_no_watermark(tmp_clean_png: Path):
|
||||
# With the decoder present, an un-watermarked image must report absent.
|
||||
assert detect_trustmark(tmp_clean_png) is None
|
||||