mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-15 05:50:45 +02:00
Compare commits
No files matched your search
@@ -34,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/
|
||||
@@ -14,8 +14,10 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
|
||||
## 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.
|
||||
- **Strict pyright is clean across `src/` (0 errors).** The cv2/torch/diffusers boundary files (`gemini_engine`, `region_eraser`, `doubao_engine`, `face_protector`, `humanizer`, `invisible_engine`, `noai/watermark_remover`, and the whole `noai/ctrlregen/` subpackage) carry a documented per-file `# pyright:` relax pragma (or, for `ctrlregen`, a `tool.pyright.executionEnvironments` entry) that turns off only the unknown-type / untyped-third-party rules — those libs ship no usable types, so strict typing there fights the ecosystem. Pure-logic files stay fully strict; `typings/piexif/__init__.pyi` is a local stub so `metadata.py`/`extractor.py` resolve piexif. Public ndarray-returning signatures on the relaxed engines are still annotated `NDArray[Any]` so strict consumers (`cli.py`) stay clean. When touching a relaxed file, prefer fixing real issues over widening the pragma; keep the pragma scoped to genuinely-untyped boundaries. (`uv-secure` is clean since idna was bumped 3.11 -> 3.16, fixing GHSA-65pc-fj4g-8rjx.)
|
||||
- **Full-project `uv run pyright` (no path) OOMs/crashes node on this ML-heavy repo** (emits a `libnode` stack frame, no summary) — a known environment limit, not a code error. Gate with `uv run --extra dev --extra gpu pyright src/` (completes, authoritative) or scope to changed files; also run `uv run ruff check` and `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.
|
||||
- To add a dev tool (pytest/ruff/pyright) into the env, use `uv sync --frozen --extra dev --extra gpu`, **never `uv pip install`** — `uv pip install` re-resolves and rewrites `uv.lock`, which silently bumped `transformers` to a build incompatible with the pinned `diffusers` (`cannot import name 'Qwen3VLForConditionalGeneration'`) and broke every `identify`/metadata import. Recovery: `git checkout uv.lock && uv sync --frozen --extra gpu --extra dev`. The `gpu` extra holds `diffusers`/`transformers`/`torch`, so a bare `uv sync` (no extras) removes them and `noai/__init__` (eager pipeline import) then fails. `maintain.sh`'s `uv sync --all-extras` also pulls the heavy `trustmark`/`lama` wheels (pytorch-lightning, onnxruntime) — fine on a good connection, but on flaky DNS sync only `--extra gpu --extra dev` and run the lint/test steps by hand.
|
||||
- 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.
|
||||
|
||||
@@ -29,14 +31,16 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
|
||||
|
||||
- `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). 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.
|
||||
- `metadata.py` — `scan_head(path, size=1MB)` is the shared input for every C2PA/AIGC/IPTC byte scan: first `size` bytes plus, for ISOBMFF, the late provenance-box payloads from `isobmff.scan_c2pa_region` (catches a manifest after a large `mdat`); behavior-neutral (`f.read(size)`) for non-ISOBMFF. Use it instead of `open().read(1MB)` for any new marker scan. `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`, the China TC260 AIGC label via `metadata.aigc_label`, the HuggingFace `hf-job-id` job marker via `metadata.huggingface_job`, 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). The `hf_job` and visible-sparkle signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only`, parallel branches) but is excluded from the high-confidence `ai_from_metadata` set, so neither overrides a hard metadata signal. 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. **Integrity-clash detection** (`_integrity_clashes`, surfaced as `ProvenanceReport.integrLine truncated
|
||||
- `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.
|
||||
- `text_protector.py` — text-region protection for the `invisible` SDXL img2img pass (issue #21: CJK/small text deforms at watermark-removal strengths). `is_available()` gates on `cv2.dnn.TextDetectionModel_DB`; `TextProtector.detect_text_boxes(bgr)` runs the **PP-OCRv3 CN** ONNX detector (~2.4 MB, Apache-2.0, opencv_zoo, CJK-native, returns rotated quad polygons) — downloaded+cached to `~/.cache/remove-ai-watermarks` on first use via atomic temp-rename, never bundled, **no torch (cv2.dnn only)**. `build_change_map(boxes, h, w, preserve=0.9, feather=15)` paints a Differential-Diffusion change map. **Polarity (verified empirically):** white(1.0)=PRESERVE original pixels, black(0.0)=MAX change; map is black bg + `preserve` inside text polygons, Gaussian-feathered edges, clipped to [0,1]. `preserve` stays below a hard 1.0 freeze by default so text still scrubs lightly (SynthID survives cropping). Wired into `watermark_remover._run_differential` via the community `pipeline_stable_diffusion_xl_differential_img2img` (loaded with `custom_revision="0.38.0"` — HF resolves the **PyPI** version string, not the `v0.38.0` git tag); gated to the SDXL `DEFAULT_MODEL_ID` only (`_can_protect_text`), falls back to plain img2img otherwise. **Autonomous by default** (`protect_text=True` in `invisible_engine`/`watermark_remover`, mirroring `protect_faces`): the detector runs per image and `_run_differential` falls back to plain img2img when **no boxes** are found, so text-free inputs pay only the cheap cv2 detection (no differential-pipeline load). CLI exposes a single off-switch `--no-protect-text` on `invisible`/`all` (passed as `protect_text=not no_protect_text`); the unavailable-model case logs at debug, not warning, since it is now the default path. The diff pipeline upcasts the VAE to fp32 internally, so do **not** add `upcast_vae()`/`enable_attention_slicing` (both produced NaN/black on fp16 MPS). `build_change_map` is unit-tested without any model download (`tests/test_text_protector.py`).
|
||||
- `face_protector.py` — YOLO detect + soft-blend pattern; mirror this for any "protect region during diffusion" features
|
||||
- `image_io.py` — Unicode-safe cv2 IO (issue #17). `imread(path, flags=None)` / `imwrite(path, img)` wrap `np.fromfile`+`cv2.imdecode` / `cv2.imencode`+`tofile` so non-ASCII paths work on Windows -- bare `cv2.imread`/`cv2.imwrite` use the platform ANSI code-page API there and fail (empty decode + `can't open/read file`) on Chinese/Cyrillic/accented filenames. `imread` keeps `cv2.imread` semantics (defaults to `IMREAD_COLOR`, returns `None` on missing/empty/undecodable). **Every cv2 file read/write in the package routes through here; do not call `cv2.imread`/`cv2.imwrite` directly.** macOS/Linux already accept UTF-8 paths, so it is behavior-neutral there (the bug only reproduces on Windows). cv2/numpy are imported lazily inside the functions, so the module is cheap to import in a bare env.
|
||||
|
||||
### Doubao clean-reverse-alpha distillation (researched 2026-05-26, NOT yet shipped)
|
||||
|
||||
@@ -49,19 +53,21 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are
|
||||
- **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.
|
||||
- **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` reads **two serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `<TC260:AIGC>` block (container-agnostic raw-byte scan, any JSON object accepted) **and** a raw-JSON PNG `AIGC` tEXt chunk — Doubao also writes the label this way, with no namespaced marker at all (confirmed on the corpus 2026-05-28, `ContentProducer="doubao"`). The PNG-chunk path is gated on at least one TC260 field (`_TC260_FIELDS`) so a generic `AIGC` key cannot false-positive. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated).
|
||||
- **HuggingFace-hosted job (caught by `metadata.huggingface_job`, surfaced by `identify` as the `hf_job` signal, MEDIUM confidence):** HuggingFace Jobs / Spaces stamp generated PNGs with an `hf-job-id` tEXt chunk holding the job UUID (3 on the corpus 2026-05-28, no other signal). It marks the *hosting job*, not a model — most commonly diffusion output — so it lifts an Unknown verdict to a tentative AI via `hf_only` (parallel to the visible sparkle) but never overrides a hard metadata signal; `_HF_JOB_CAVEAT` states the limit (job, not model; not proof of AI pixels). Stripped on removal (the PNG save whitelist keeps only `STANDARD_METADATA_KEYS`, so `hf-job-id` and the `AIGC` chunk are both dropped). The exact writer is not authoritatively documented (HF Jobs are generic GPU jobs), hence medium not high.
|
||||
- **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.
|
||||
- **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). **Box detection window — now handled (v0.6.8):** detection no longer relies on a fixed first-MB read. `metadata.scan_head(path, size)` reads the first `size` bytes and, for ISOBMFF, appends the payloads of late provenance boxes found by `isobmff.scan_c2pa_region` (a file-seeking top-level box walker that skips past `mdat` by size without reading it), so a C2PA/AIGC/IPTC manifest placed AFTER a large `mdat` in a streaming/non-faststart MP4 is now caught. Every C2PA/marker byte scan (`has_ai_metadata`, `aigc_label`, `iptc_ai_system`, `synthid_source`, `exif_generator` XMP, `get_ai_metadata` soft-binding, and `identify`) goes through `scan_head`; it is behavior-neutral for non-ISOBMFF inputs (exactly `f.read(size)`). **Meta-box XMP removal — now handled (v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (HEIF/AVIF; out of reach of the top-level box stripper) is blanked in place by `isobmff.blank_ai_xmp_packets` — it locates the packet by its `<?xpacket begin … end?>` delimiters and, if it carries an AI marker (`_AI_LABEL_MARKERS`), overwrites it with spaces of the SAME length, so box sizes / `iloc` offsets stay valid and the coded image is untouched (selective: plain non-AI XMP is left alone, mirroring the top-level uuid logic). Wired into `remove_ai_metadata`'s ISOBMFF branch after `strip_c2pa_boxes`. The remaining gap is an `Exif` meta-box *item* (rare; the AI labels are XMP) — still needs `iinf`/`iloc` surgery or exiftool.
|
||||
- **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 **22 January 2026** (verified via Kim & Chang + FPF/Korea Times; 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 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. **Metadata boundary (researched 2026-05-26, deliberately NOT built — low yield / high risk):** 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; WebM/Matroska is EBML (different container, would need a new parser); MP3 ID3 / WAV RIFF audio tags are low-yield (audio provenance is overwhelmingly oracle-only — SynthID/ElevenLabs/Resemble — or unmarked like Suno/Udio). For these, `remove_ai_metadata` raises a clear "not supported" error (`_UNSUPPORTED_CONTAINER_EXTS`) rather than crashing; detection via the `identify` byte scan still fires when the marker is in the first MB. (These are candidates for future work, not a hard ceiling — revisit when there's demand.)
|
||||
- **rich `console.print` parses `[word]` as a style tag and silently drops unknown ones.** A literal bracketed token in a print string disappears: `pip install 'remove-ai-watermarks[gpu]'` rendered as `...remove-ai-watermarks'` (the `[gpu]` extra eaten), which sent users a broken install command (surfaced via #19). Escape the literal bracket as `\[gpu]` (in a normal Python string that is `"\\[gpu]"`) in any rich string carrying user-facing brackets. Regression-guarded by `tests/test_cli.py::TestGpuHintMarkup`.
|
||||
- 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). **Meta-box XMP now handled (`isobmff.blank_ai_xmp_packets`, v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (AVIF/HEIF) is blanked in place (overwritten with spaces of the same length, so `iloc` offsets and the coded image stay valid). **Still NOT built:** an `Exif` *item* inside the `meta` box (rare -- AI labels are XMP) needs full `iinf`/`iloc` surgery (offset rewrite) with corruption risk -- exiftool (R/W/C for HEIC/AVIF EXIF+XMP, verified on exiftool.org 2026-05-27) 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
|
||||
@@ -12,17 +12,20 @@ Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and vi
|
||||
|
||||
If this tool saves you time, consider [sponsoring its development](https://github.com/sponsors/wiltodelta).
|
||||
|
||||
> **Intended for lawful use only.** Publishing and running this software is lawful; responsibility for any downstream use, and for compliance with local law, rests entirely with the user. Some jurisdictions restrict removing an AI label as such (see [Legal](#legal)). The authors do not condone use for deception, fraud, or any unlawful activity.
|
||||
|
||||
## Features
|
||||
|
||||
- **Visible watermark removal** — Gemini / Nano Banana sparkle logo via reverse alpha blending (fast, offline, deterministic)
|
||||
- **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
|
||||
- **AI metadata stripping** — EXIF, PNG text chunks, C2PA provenance manifests (PNG / JPEG / AVIF / HEIF / JPEG-XL, **MP4 / MOV / M4V / M4A** at the container level, and **WebM / MP3 / WAV / FLAC / OGG** losslessly via ffmpeg), XMP DigitalSourceType
|
||||
- **"Made with AI" label removal** — removes the AI-disclosure metadata that platforms read to apply automatic labels (useful for clearing a false-positive label from a human-edited photograph)
|
||||
- **Analog Humanizer** — optional film grain and chromatic aberration post-processing
|
||||
- **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
|
||||
- **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)
|
||||
- **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 China TC260 AIGC label (XMP or PNG chunk), the HuggingFace `hf-job-id` job marker, 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
|
||||
|
||||
@@ -45,13 +48,13 @@ If this tool saves you time, consider [sponsoring its development](https://githu
|
||||
| **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 | — | — | ✅ TC260 `<TC260:AIGC>` XMP label (China's mandatory AI labeling) | Metadata strip |
|
||||
| **Doubao** (ByteDance) / China AIGC generators | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label — `<TC260:AIGC>` XMP **or** `AIGC` PNG chunk (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.
|
||||
> **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 (XMP or PNG chunk), the HuggingFace `hf-job-id` job marker, 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
|
||||
|
||||
@@ -73,6 +76,16 @@ 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.
|
||||
@@ -94,6 +107,8 @@ SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 P
|
||||
|
||||
**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.)
|
||||
|
||||
**Text Protection** (automatic): SDXL img2img regenerates every pixel, so small text and CJK glyphs get deformed at the strengths that defeat SynthID. The SDXL pipeline guards against this by default: a CJK-native PP-OCRv3 text detector (a 2.4 MB ONNX model run on CPU via OpenCV's DNN module, downloaded and cached on first use) locates text regions, and if any are found the pass switches to Differential Diffusion so a per-pixel change map keeps the text regions largely intact while the background is regenerated normally. Text-free images run the standard pass at no extra cost. Pass `--no-protect-text` to turn it off. SDXL default pipeline only.
|
||||
|
||||
### Stripping C2PA, EXIF, and "Made with AI" metadata
|
||||
|
||||
AI tools embed generation metadata that social platforms use to show "Made with AI" labels:
|
||||
@@ -221,13 +236,20 @@ remove-ai-watermarks batch ./images/ --mode all
|
||||
# of a clean origin. Add --json for machine-readable output.
|
||||
remove-ai-watermarks identify image.png
|
||||
|
||||
# Visible watermark only (Gemini / Nano Banana sparkle) — fast, offline
|
||||
# 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
|
||||
# Text / CJK glyphs are preserved automatically; disable with --no-protect-text
|
||||
|
||||
# Check / strip AI metadata (C2PA, EXIF, "Made with AI" labels)
|
||||
# --check also flags SynthID-bearing sources: a C2PA manifest signed by
|
||||
@@ -304,8 +326,10 @@ Tracked but not yet implemented:
|
||||
- **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).
|
||||
- **Maintenance debt**. Strict pyright is now clean across `src/` (0 errors): pure-logic files are fully typed, the cv2 / torch / diffusers boundary files carry a documented per-file relax pragma, and a local `typings/piexif` stub covers piexif. Remaining: full-project `pyright` (no path) still OOMs node on this ML-heavy repo, so it must be scoped to `src/`; narrowing the boundary pragmas back toward full strict (as upstream stubs improve) is the long tail. (`uv-secure` is already clean since `idna` was bumped to 3.16.)
|
||||
- **AVIF / HEIF `Exif` item inside the `meta` box**. An AI-label *XMP* packet in a `meta`-box item is now blanked in place (v0.6.9), but EXIF stored as a `meta`-box `Exif` *item* is still not removed — it needs full `iinf`/`iloc` surgery (offset rewrite, corruption risk) or `exiftool` (a non-bundled binary dependency). Low priority: the AI labels we target are XMP, not EXIF, so an EXIF-only meta-box case is rare.
|
||||
- **More C2PA device signers**. Leica, Nikon, Google Pixel, Sony, and Truepic are mapped (each verified against a real signed file). Canon and Samsung Galaxy (AI-edit) are deferred until a real signed sample surfaces — no public direct-download C2PA file exists for them today (upload-to-verify / news-agency-licensed only).
|
||||
- **Resemble PerTh audio detection** — evaluated, not feasible with the public API: `get_watermark()` returns a raw bit array with no presence/confidence flag, so watermarked vs. clean audio can't be reliably separated without Resemble's fixed payload or a confidence service. Same wall as the SynthID pixel detector.
|
||||
- **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.
|
||||
|
||||
Won't fix:
|
||||
@@ -319,10 +343,10 @@ Watermarking and provenance for AI-generated content is now regulated in several
|
||||
| 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 (federal) | COPIED Act (S. 1396, 119th Cong.) | **Reintroduced April 2025; not enacted** (referred to Senate Commerce Committee). | 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 (2019), similar | TX SB 751 (2019) in force; **CA AB 2655 struck down** by a federal court (E.D. Cal., Aug 2025, *Kohls v. Bonta*) as preempted by **Section 230**; the court did not reach the First Amendment (the companion law AB 2839 was separately enjoined on First Amendment grounds). | 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. |
|
||||
| South Korea | AI Framework Act (Basic Act on AI), Article 31 | In force since **22 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. |
|
||||
|
||||
@@ -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).
|
||||
+42
-2
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.6.3"
|
||||
version = "0.6.12"
|
||||
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
@@ -19,7 +19,17 @@ dependencies = [
|
||||
gpu = [
|
||||
"torch>=2.0.0",
|
||||
"diffusers>=0.38.0",
|
||||
"transformers>=4.35.0",
|
||||
# diffusers 0.38's auto-pipeline registry imports ``Qwen3VLForConditional
|
||||
# Generation`` (its ``nucleusmoe_image`` pipeline), which only exists in
|
||||
# transformers 5.x -- so ``from diffusers import AutoPipelineForImage2Image``
|
||||
# fails on transformers 4.x. The real SDXL-loading break was NOT transformers
|
||||
# 5.x but the tokenizers *release candidate* (0.23.0rc0) that the global
|
||||
# ``prerelease = "allow"`` drags in: its CLIP tokenizer raises
|
||||
# ``RobertaProcessing.__new__() got an unexpected keyword argument 'cls'``.
|
||||
# Cap tokenizers to the stable 0.22 line (transformers 5.x accepts
|
||||
# >=0.22,<=0.23.0) so the rc is excluded while SDXL still loads.
|
||||
"transformers>=5,<6",
|
||||
"tokenizers>=0.22,<0.23",
|
||||
"accelerate>=0.25.0",
|
||||
"controlnet-aux>=0.0.9",
|
||||
"safetensors",
|
||||
@@ -123,3 +133,33 @@ reportUnknownMemberType = false
|
||||
reportUnknownArgumentType = false
|
||||
reportUnknownVariableType = false
|
||||
reportMissingTypeArgument = false
|
||||
|
||||
# CtrlRegen is a torch/diffusers/controlnet-aux boundary: those libs ship no
|
||||
# usable types, so strict pyright cannot know the tensor element types. Relax the
|
||||
# unknown-type rules for this subpackage only (mirrors the per-file pragmas used
|
||||
# for the cv2 engines); the rest of the codebase stays strict.
|
||||
[[tool.pyright.executionEnvironments]]
|
||||
root = "src/remove_ai_watermarks/noai/ctrlregen"
|
||||
reportUnknownMemberType = false
|
||||
reportUnknownArgumentType = false
|
||||
reportUnknownVariableType = false
|
||||
reportUnknownParameterType = false
|
||||
reportMissingTypeArgument = false
|
||||
reportMissingTypeStubs = false
|
||||
reportMissingImports = false
|
||||
reportArgumentType = false
|
||||
reportAssignmentType = false
|
||||
reportReturnType = false
|
||||
reportCallIssue = false
|
||||
reportIndexIssue = false
|
||||
reportOperatorIssue = false
|
||||
reportOptionalMemberAccess = false
|
||||
reportOptionalCall = false
|
||||
reportOptionalSubscript = false
|
||||
reportOptionalOperand = false
|
||||
reportAttributeAccessIssue = false
|
||||
reportPrivateImportUsage = false
|
||||
reportPrivateUsage = false
|
||||
reportInvalidTypeForm = false
|
||||
reportConstantRedefinition = false
|
||||
reportUnnecessaryComparison = false
|
||||
@@ -1,3 +1,3 @@
|
||||
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
|
||||
|
||||
__version__ = "0.6.3"
|
||||
__version__ = "0.6.12"
|
||||
+222
-17
@@ -12,7 +12,7 @@ import json
|
||||
import logging
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import click
|
||||
from rich.console import Console
|
||||
@@ -23,9 +23,9 @@ from rich.table import Table
|
||||
from remove_ai_watermarks import __version__
|
||||
|
||||
if TYPE_CHECKING:
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
from remove_ai_watermarks.gemini_engine import DetectionResult
|
||||
from remove_ai_watermarks.gemini_engine import DetectionResult, GeminiEngine
|
||||
|
||||
console = Console()
|
||||
|
||||
@@ -76,7 +76,7 @@ def _watermark_region(det: DetectionResult, width: int, height: int) -> tuple[in
|
||||
return (px, py, config.logo_size, config.logo_size)
|
||||
|
||||
|
||||
def _read_bgr_and_alpha(path: Path) -> tuple[np.ndarray | None, np.ndarray | None]:
|
||||
def _read_bgr_and_alpha(path: Path) -> tuple[NDArray[Any] | None, NDArray[Any] | None]:
|
||||
"""Read an image preserving its alpha channel separately.
|
||||
|
||||
Returns ``(bgr, alpha)`` where ``alpha`` is a single-channel ndarray when the
|
||||
@@ -85,7 +85,9 @@ def _read_bgr_and_alpha(path: Path) -> tuple[np.ndarray | None, np.ndarray | Non
|
||||
"""
|
||||
import cv2
|
||||
|
||||
image = cv2.imread(str(path), cv2.IMREAD_UNCHANGED)
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
image = image_io.imread(path, cv2.IMREAD_UNCHANGED)
|
||||
if image is None:
|
||||
return None, None
|
||||
if image.ndim == 2:
|
||||
@@ -97,8 +99,8 @@ def _read_bgr_and_alpha(path: Path) -> tuple[np.ndarray | None, np.ndarray | Non
|
||||
|
||||
def _write_bgr_with_alpha(
|
||||
path: Path,
|
||||
bgr: np.ndarray,
|
||||
alpha: np.ndarray | None,
|
||||
bgr: NDArray[Any],
|
||||
alpha: NDArray[Any] | None,
|
||||
clear_region: tuple[int, int, int, int] | None = None,
|
||||
pad: int = 6,
|
||||
) -> None:
|
||||
@@ -109,11 +111,12 @@ def _write_bgr_with_alpha(
|
||||
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
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
if alpha is None or path.suffix.lower() not in _ALPHA_FORMATS:
|
||||
cv2.imwrite(str(path), bgr)
|
||||
image_io.imwrite(path, bgr)
|
||||
return
|
||||
|
||||
alpha_out = alpha
|
||||
@@ -127,7 +130,73 @@ def _write_bgr_with_alpha(
|
||||
alpha_out[y0:y1, x0:x1] = 0
|
||||
|
||||
bgra = np.dstack([bgr, alpha_out])
|
||||
cv2.imwrite(str(path), bgra)
|
||||
image_io.imwrite(path, bgra)
|
||||
|
||||
|
||||
def _run_doubao_if_selected(
|
||||
ctx: click.Context,
|
||||
image: NDArray[Any],
|
||||
alpha: NDArray[Any] | 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 ───────────────────────────────────────────────────────
|
||||
@@ -167,6 +236,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(
|
||||
@@ -174,15 +249,18 @@ def cmd_visible(
|
||||
source: Path,
|
||||
output: Path | None,
|
||||
inpaint: bool,
|
||||
inpaint_method: str,
|
||||
inpaint_method: Literal["ns", "telea", "gaussian"],
|
||||
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.
|
||||
"""
|
||||
from remove_ai_watermarks.gemini_engine import GeminiEngine
|
||||
|
||||
@@ -203,6 +281,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)
|
||||
@@ -256,6 +340,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: Literal["cv2", "lama"],
|
||||
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 ─────────────────────────────────────
|
||||
|
||||
|
||||
@@ -284,6 +460,12 @@ def cmd_visible(
|
||||
default=0,
|
||||
help="Cap long side (px) before diffusion; 0 = native (best quality, like raiw.cc). Raise only on GPU/MPS OOM.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-protect-text",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Disable automatic text protection (text/CJK is preserved by default on the SDXL pipeline).",
|
||||
)
|
||||
@click.pass_context
|
||||
def cmd_invisible(
|
||||
ctx: click.Context,
|
||||
@@ -297,6 +479,7 @@ def cmd_invisible(
|
||||
hf_token: str | None,
|
||||
humanize: float,
|
||||
max_resolution: int,
|
||||
no_protect_text: bool,
|
||||
) -> None:
|
||||
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
|
||||
|
||||
@@ -308,7 +491,7 @@ def cmd_invisible(
|
||||
if not invisible_available():
|
||||
console.print(
|
||||
"[red]Error:[/] GPU dependencies not installed.\n"
|
||||
" Install them with: [bold]pip install 'remove-ai-watermarks[gpu]'[/]"
|
||||
" Install them with: [bold]pip install 'remove-ai-watermarks\\[gpu]'[/]"
|
||||
)
|
||||
raise SystemExit(1)
|
||||
|
||||
@@ -343,6 +526,7 @@ def cmd_invisible(
|
||||
guidance_scale=None,
|
||||
seed=seed,
|
||||
humanize=humanize,
|
||||
protect_text=not no_protect_text,
|
||||
max_resolution=max_resolution,
|
||||
)
|
||||
elapsed = time.monotonic() - t0
|
||||
@@ -442,6 +626,19 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
console.print(f"\n Verdict: {verdict} [dim](confidence: {report.confidence})[/]")
|
||||
console.print(f" Platform: {report.platform or '[dim]undetermined[/]'}")
|
||||
|
||||
if report.is_ai_generated is None:
|
||||
console.print(
|
||||
" [dim]No locally-readable AI signal found. This is not the same as 'clean': "
|
||||
"metadata is often stripped by re-encoding, screenshots, or upload, and SynthID-class "
|
||||
"pixel watermarks (Gemini / Nano Banana / gpt-image) have no local detector. "
|
||||
"See caveats below.[/]"
|
||||
)
|
||||
|
||||
if report.integrity_clashes:
|
||||
console.print("\n [bold red]⚠ Integrity clash[/] [dim](provenance signals contradict each other)[/]")
|
||||
for clash in report.integrity_clashes:
|
||||
console.print(f" [red]- {clash}[/]")
|
||||
|
||||
if report.watermarks:
|
||||
table = Table(show_header=True, header_style="bold", title="Watermarks / provenance markers")
|
||||
table.add_column("Marker", style="cyan")
|
||||
@@ -490,13 +687,19 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
|
||||
default=0,
|
||||
help="Cap long side (px) before diffusion; 0 = native (best quality, like raiw.cc). Raise only on GPU/MPS OOM.",
|
||||
)
|
||||
@click.option(
|
||||
"--no-protect-text",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help="Disable automatic text protection (text/CJK is preserved by default on the SDXL pipeline).",
|
||||
)
|
||||
@click.pass_context
|
||||
def cmd_all(
|
||||
ctx: click.Context,
|
||||
source: Path,
|
||||
output: Path | None,
|
||||
inpaint: bool,
|
||||
inpaint_method: str,
|
||||
inpaint_method: Literal["ns", "telea", "gaussian"],
|
||||
strength: float,
|
||||
steps: int,
|
||||
pipeline: str,
|
||||
@@ -506,6 +709,7 @@ def cmd_all(
|
||||
hf_token: str | None,
|
||||
humanize: float,
|
||||
max_resolution: int,
|
||||
no_protect_text: bool,
|
||||
) -> None:
|
||||
"""Remove ALL watermarks: visible + invisible + metadata.
|
||||
|
||||
@@ -571,7 +775,7 @@ def cmd_all(
|
||||
if not invisible_available():
|
||||
console.print(
|
||||
" [yellow]⚠[/] Skipped — GPU dependencies not installed.\n"
|
||||
" Install them with: [bold]pip install 'remove-ai-watermarks[gpu]'[/]"
|
||||
" Install them with: [bold]pip install 'remove-ai-watermarks\\[gpu]'[/]"
|
||||
)
|
||||
else:
|
||||
from remove_ai_watermarks.invisible_engine import InvisibleEngine
|
||||
@@ -597,6 +801,7 @@ def cmd_all(
|
||||
num_inference_steps=steps,
|
||||
seed=seed,
|
||||
humanize=humanize,
|
||||
protect_text=not no_protect_text,
|
||||
max_resolution=max_resolution,
|
||||
)
|
||||
console.print(" [green]✓[/] Invisible watermark removed")
|
||||
@@ -659,7 +864,7 @@ def _process_batch_image(
|
||||
Raises:
|
||||
ValueError: If the image cannot be opened.
|
||||
"""
|
||||
saved_alpha: np.ndarray | None = None
|
||||
saved_alpha: NDArray[Any] | None = None
|
||||
saved_region: tuple[int, int, int, int] | None = None
|
||||
|
||||
if mode in ("visible", "all"):
|
||||
|
||||
@@ -0,0 +1,250 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
|
||||
# unknown-type rules for this file only.
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, 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[Any]) -> 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[Any], loc: DoubaoLocation) -> NDArray[Any]:
|
||||
"""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[Any]) -> 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[Any],
|
||||
*,
|
||||
inpaint_method: Literal["telea", "ns"] = "telea",
|
||||
inpaint_radius: int = 6,
|
||||
dilate: int = 3,
|
||||
) -> NDArray[Any]:
|
||||
"""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[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(path, cv2.IMREAD_COLOR)
|
||||
if img is None:
|
||||
raise FileNotFoundError(f"Failed to read image: {path}")
|
||||
return img
|
||||
@@ -1,5 +1,8 @@
|
||||
"""YOLO-based face detection and soft-blend restoration for diffusion pipelines."""
|
||||
|
||||
# cv2/numpy/ultralytics boundary: these libs ship no usable element types; relax
|
||||
# the unknown-type rules for this file only.
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false, reportPossiblyUnboundVariable=false
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@@ -13,13 +13,17 @@ The alpha maps are derived from background captures of the Gemini watermark
|
||||
on pure-black backgrounds (48x48 for small images, 96x96 for large images).
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: cv2 and numpy ship no usable type info for the array ops
|
||||
# below, so strict pyright cannot know their element types. Relax the unknown-type
|
||||
# rules for this file only; the public signatures are still annotated with NDArray[Any].
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -86,7 +90,7 @@ def get_watermark_size(width: int, height: int) -> WatermarkSize:
|
||||
return WatermarkSize.SMALL
|
||||
|
||||
|
||||
def _calculate_alpha_map(bg_capture: NDArray) -> NDArray:
|
||||
def _calculate_alpha_map(bg_capture: NDArray[Any]) -> NDArray[Any]:
|
||||
"""Calculate alpha map from a background capture.
|
||||
|
||||
The alpha map represents how much the watermark affects each pixel.
|
||||
@@ -103,7 +107,7 @@ def _calculate_alpha_map(bg_capture: NDArray) -> NDArray:
|
||||
return gray / 255.0
|
||||
|
||||
|
||||
def _load_embedded_asset(name: str) -> NDArray:
|
||||
def _load_embedded_asset(name: str) -> NDArray[Any]:
|
||||
"""Load an embedded PNG asset and decode it with OpenCV."""
|
||||
asset_path = Path(__file__).parent / "assets" / name
|
||||
if not asset_path.exists():
|
||||
@@ -151,13 +155,13 @@ class GeminiEngine:
|
||||
self._alpha_large.shape,
|
||||
)
|
||||
|
||||
def get_alpha_map(self, size: WatermarkSize) -> NDArray:
|
||||
def get_alpha_map(self, size: WatermarkSize) -> NDArray[Any]:
|
||||
"""Get the base alpha map for a specific standard size."""
|
||||
if size == WatermarkSize.SMALL:
|
||||
return self._alpha_small
|
||||
return self._alpha_large
|
||||
|
||||
def get_interpolated_alpha(self, size_px: int) -> NDArray:
|
||||
def get_interpolated_alpha(self, size_px: int) -> NDArray[Any]:
|
||||
"""Create an interpolated alpha map dynamically scaled from the high-res 96x96 base."""
|
||||
source = self._alpha_large
|
||||
if size_px == source.shape[1]:
|
||||
@@ -170,7 +174,7 @@ class GeminiEngine:
|
||||
|
||||
def detect_watermark(
|
||||
self,
|
||||
image: NDArray,
|
||||
image: NDArray[Any],
|
||||
force_size: WatermarkSize | None = None,
|
||||
) -> DetectionResult:
|
||||
"""Detect Gemini watermark using multi-scale Snap Engine logic (ported from C++ vendor algorithm)."""
|
||||
@@ -304,9 +308,9 @@ class GeminiEngine:
|
||||
|
||||
def remove_watermark(
|
||||
self,
|
||||
image: NDArray,
|
||||
image: NDArray[Any],
|
||||
force_size: WatermarkSize | None = None,
|
||||
) -> NDArray:
|
||||
) -> NDArray[Any]:
|
||||
"""Remove Gemini visible watermark from an image using reverse alpha blending.
|
||||
|
||||
No-op when the detector does not find a watermark: returns an unmodified
|
||||
@@ -359,9 +363,9 @@ class GeminiEngine:
|
||||
|
||||
def remove_watermark_custom(
|
||||
self,
|
||||
image: NDArray,
|
||||
image: NDArray[Any],
|
||||
region: tuple[int, int, int, int],
|
||||
) -> NDArray:
|
||||
) -> NDArray[Any]:
|
||||
"""Remove watermark from a custom region with interpolated alpha map.
|
||||
|
||||
Args:
|
||||
@@ -390,8 +394,8 @@ class GeminiEngine:
|
||||
|
||||
def _reverse_alpha_blend(
|
||||
self,
|
||||
image: NDArray,
|
||||
alpha_map: NDArray,
|
||||
image: NDArray[Any],
|
||||
alpha_map: NDArray[Any],
|
||||
position: tuple[int, int],
|
||||
) -> None:
|
||||
"""Apply reverse alpha blending in-place.
|
||||
@@ -442,13 +446,13 @@ class GeminiEngine:
|
||||
|
||||
def inpaint_residual(
|
||||
self,
|
||||
image: NDArray,
|
||||
image: NDArray[Any],
|
||||
region: tuple[int, int, int, int],
|
||||
strength: float = 0.85,
|
||||
method: Literal["gaussian", "telea", "ns"] = "ns",
|
||||
inpaint_radius: int = 10,
|
||||
padding: int = 32,
|
||||
) -> NDArray:
|
||||
) -> NDArray[Any]:
|
||||
"""Apply inpaint cleanup on residual artifacts after reverse alpha blend.
|
||||
|
||||
Uses a sparse mask derived from alpha map gradient to repair only
|
||||
@@ -556,7 +560,9 @@ def detect_sparkle_confidence(image_path: Path) -> float | None:
|
||||
(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))
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(image_path)
|
||||
if img is None:
|
||||
return None
|
||||
return float(GeminiEngine().detect_watermark(img).confidence)
|
||||
@@ -4,6 +4,9 @@ Simulates analog film imperfections to defeat digital AI perfection
|
||||
classifiers. Ported from NeuralBleach.
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
|
||||
# unknown-type rules for this file only.
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
|
||||
import cv2
|
||||
import numpy as np
|
||||
from numpy.typing import NDArray
|
||||
|
||||
@@ -31,7 +31,9 @@ from remove_ai_watermarks.metadata import (
|
||||
aigc_label,
|
||||
exif_generator,
|
||||
get_ai_metadata,
|
||||
huggingface_job,
|
||||
iptc_ai_system,
|
||||
scan_head,
|
||||
xai_signature,
|
||||
)
|
||||
from remove_ai_watermarks.noai.c2pa import cbor_text_after, extract_c2pa_info, soft_binding_vendors_in
|
||||
@@ -88,6 +90,11 @@ _INVISIBLE_WM_CAVEAT = (
|
||||
"The open invisible watermark is fragile: it does not survive JPEG re-encoding "
|
||||
"or resizing, so it confirms origin only on a pristine (un-re-encoded) file."
|
||||
)
|
||||
_HF_JOB_CAVEAT = (
|
||||
"The hf-job-id tag marks a HuggingFace-hosted job (commonly diffusion "
|
||||
"generation) but names neither the model nor the content type, so it is a "
|
||||
"medium-confidence signal, not proof the pixels are AI-generated."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -110,6 +117,11 @@ class ProvenanceReport:
|
||||
watermarks: list[str] = field(default_factory=list[str])
|
||||
signals: list[Signal] = field(default_factory=list["Signal"])
|
||||
caveats: list[str] = field(default_factory=list[str])
|
||||
# Contradictions between independent provenance signals (e.g. two different
|
||||
# AI vendors both claiming the image, or camera-capture credentials next to
|
||||
# AI-generation markers). Non-empty means the provenance is internally
|
||||
# inconsistent -- a strong tell of spoofed, transplanted, or laundered metadata.
|
||||
integrity_clashes: list[str] = field(default_factory=list[str])
|
||||
|
||||
|
||||
def _issuers_in(data: bytes) -> list[str]:
|
||||
@@ -165,17 +177,111 @@ def _device_platform(head: bytes) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
def _attribute_platform(issuers: list[str]) -> str | None:
|
||||
"""Map a set of C2PA issuer names to a human-readable generating platform."""
|
||||
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)
|
||||
for needle, platform in _ISSUER_PLATFORM:
|
||||
if needle in joined:
|
||||
return platform
|
||||
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
|
||||
|
||||
|
||||
# Coarse origin-vendor normalization for integrity-clash detection. Two signals
|
||||
# that resolve to the SAME key are consistent (a C2PA "Google (Gemini)" issuer
|
||||
# and a SynthID-Google proxy, or Adobe Firefly + its Adobe TrustMark soft
|
||||
# binding); two DIFFERENT keys from independent generator stamps are a
|
||||
# contradiction (a C2PA OpenAI manifest on an image whose EXIF says "Ideogram
|
||||
# AI"). Substring match on the lowercased platform/detail string; first hit wins,
|
||||
# so order specific tokens before brand umbrellas where they overlap.
|
||||
_AI_VENDOR_TOKENS: tuple[tuple[str, str], ...] = (
|
||||
("gpt-image", "OpenAI"),
|
||||
("dall", "OpenAI"),
|
||||
("sora", "OpenAI"),
|
||||
("openai", "OpenAI"),
|
||||
("gemini", "Google"),
|
||||
("imagen", "Google"),
|
||||
("nano banana", "Google"),
|
||||
("google", "Google"),
|
||||
("firefly", "Adobe"),
|
||||
("adobe", "Adobe"),
|
||||
("bing", "Microsoft"),
|
||||
("designer", "Microsoft"),
|
||||
("microsoft", "Microsoft"),
|
||||
("stability", "Stability AI"),
|
||||
("stable diffusion", "Stability AI"),
|
||||
("sdxl", "Stability AI"),
|
||||
("ideogram", "Ideogram"),
|
||||
("grok", "xAI"),
|
||||
("aurora", "xAI"),
|
||||
("xai", "xAI"),
|
||||
)
|
||||
|
||||
|
||||
def _vendor_of(text: str | None) -> str | None:
|
||||
"""Normalize a platform/generator string to a coarse origin-vendor key, or None."""
|
||||
if not text:
|
||||
return None
|
||||
low = text.lower()
|
||||
for token, vendor in _AI_VENDOR_TOKENS:
|
||||
if token in low:
|
||||
return vendor
|
||||
return None
|
||||
|
||||
|
||||
def _integrity_clashes(
|
||||
ai_vendors: dict[str, str], camera_label: str | None, *, camera_has_ai_marker: bool
|
||||
) -> list[str]:
|
||||
"""Surface contradictions between independent provenance signals.
|
||||
|
||||
Args:
|
||||
ai_vendors: family name -> normalized AI-origin vendor, one entry per
|
||||
generator-stamped signal (C2PA issuer when the source is AI, SynthID
|
||||
proxy, EXIF/XMP generator tag, IPTC AISystemUsed, xAI, AIGC label).
|
||||
camera_label: a camera/verified-capture C2PA device platform, if one was
|
||||
identified (Pixel, Leica, Sony, Nikon, Truepic), else None.
|
||||
camera_has_ai_marker: True when an AI-generation stamp coexists with the
|
||||
camera credentials.
|
||||
|
||||
Returns:
|
||||
Human-readable clash descriptions; empty when the signals agree.
|
||||
"""
|
||||
clashes: list[str] = []
|
||||
|
||||
by_vendor: dict[str, list[str]] = {}
|
||||
for family, vendor in ai_vendors.items():
|
||||
by_vendor.setdefault(vendor, []).append(family)
|
||||
if len(by_vendor) >= 2:
|
||||
parts = [f"{vendor} (via {', '.join(sorted(fams))})" for vendor, fams in sorted(by_vendor.items())]
|
||||
clashes.append(
|
||||
"Conflicting AI-origin attributions from independent signals: "
|
||||
+ " vs ".join(parts)
|
||||
+ " -- one provenance set was likely spoofed, transplanted, or laundered."
|
||||
)
|
||||
|
||||
if camera_label and camera_has_ai_marker:
|
||||
vendors = ", ".join(sorted(set(ai_vendors.values()))) or "present"
|
||||
clashes.append(
|
||||
f"Camera-capture C2PA credentials ({camera_label}) coexist with AI-generation markers "
|
||||
f"({vendors}) -- a genuine camera capture is not AI-generated, so the provenance is inconsistent."
|
||||
)
|
||||
|
||||
return clashes
|
||||
|
||||
|
||||
def _visible_sparkle(image_path: Path) -> float | None:
|
||||
"""Visible Gemini-sparkle confidence in [0, 1], or None if unavailable.
|
||||
|
||||
@@ -233,12 +339,20 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
|
||||
# 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)
|
||||
# scan_head also seeks out late ISOBMFF provenance boxes (manifest after a
|
||||
# large mdat in a streaming MP4) that a fixed first-MB read would miss.
|
||||
head = scan_head(image_path, _SCAN_BYTES)
|
||||
|
||||
signals: list[Signal] = []
|
||||
watermarks: list[str] = []
|
||||
caveats: list[str] = []
|
||||
# One normalized origin vendor per generator-stamped signal, for integrity-
|
||||
# clash detection (see _integrity_clashes). Visible sparkle and the open
|
||||
# invisible watermark are deliberately excluded: the former is a fuzzy visual
|
||||
# score, the latter can be a by-product of our own SDXL removal pass, so
|
||||
# neither is a trustworthy "the generator stamped its identity" claim.
|
||||
ai_vendor_claims: dict[str, str] = {}
|
||||
camera_label = _device_platform(head)
|
||||
|
||||
# ── C2PA Content Credentials ────────────────────────────────────
|
||||
has_c2pa = bool(info) or b"c2pa" in head.lower() or C2PA_UUID in head
|
||||
@@ -259,11 +373,17 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
# 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)) if has_c2pa else None
|
||||
platform = (camera_label 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'})")
|
||||
# Record the AI-origin vendor for clash detection only when the source is
|
||||
# actually AI -- classify the issuer attribution / generator, NOT the
|
||||
# resolved `platform` (which may be a camera device token whose label,
|
||||
# e.g. "Google Pixel", would mis-normalize to an AI vendor).
|
||||
if c2pa_is_ai and (v := (_vendor_of(_attribute_platform(issuers, is_ai=True)) or _vendor_of(generator))):
|
||||
ai_vendor_claims["c2pa"] = v
|
||||
|
||||
# ── SynthID metadata proxy ──────────────────────────────────────
|
||||
# get_ai_metadata already sets synthid_watermark for both PNG (caBX parser)
|
||||
@@ -274,6 +394,8 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
caveats.append(_SYNTHID_CAVEAT)
|
||||
if "OpenAI" in (" ".join(issuers) + synthid):
|
||||
caveats.append(_OPENAI_CAVEAT)
|
||||
if v := _vendor_of(synthid):
|
||||
ai_vendor_claims["synthid"] = v
|
||||
|
||||
# ── C2PA soft-binding: a named forensic/third-party watermark vendor ─
|
||||
# (Adobe TrustMark, Digimarc, Imatag, ...). Present in the manifest even when
|
||||
@@ -303,15 +425,23 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
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)"
|
||||
if named and (v := _vendor_of(system)):
|
||||
ai_vendor_claims["iptc_ai_system"] = v
|
||||
|
||||
# ── China TC260 AIGC label (Doubao and other China-served gens) ──
|
||||
aigc = any(m in head for m in AIGC_MARKERS)
|
||||
# Fire on either the namespaced byte marker (``TC260:AIGC`` / the TC260 ns
|
||||
# URL, present in XMP and as a laundering tell even when the JSON payload is
|
||||
# truncated) OR the parsed label, which additionally catches the raw-JSON
|
||||
# PNG ``AIGC`` tEXt chunk that carries no namespaced marker at all.
|
||||
aigc_data = aigc_label(image_path)
|
||||
aigc = aigc_data is not None or any(m in head for m in AIGC_MARKERS)
|
||||
if aigc:
|
||||
producer = (aigc_label(image_path) or {}).get("ContentProducer", "")
|
||||
producer = (aigc_data 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)"
|
||||
ai_vendor_claims["aigc"] = "China AIGC (TC260)"
|
||||
|
||||
# ── Local diffusion parameters (Stable Diffusion / ComfyUI) ──────
|
||||
local_keys = sorted(k for k in meta if k.lower() in _LOCAL_GEN_KEYS)
|
||||
@@ -328,6 +458,8 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
watermarks.append(f"Embedded generator tag: {generator_tag}")
|
||||
if platform is None:
|
||||
platform = f"{generator_tag} (EXIF/XMP generator tag)"
|
||||
if v := _vendor_of(generator_tag):
|
||||
ai_vendor_claims["exif_generator"] = v
|
||||
|
||||
# ── xAI / Grok EXIF signature scheme (no C2PA/SynthID/IPTC) ──────
|
||||
# Grok's only provenance signal: EXIF ImageDescription "Signature: <base64>"
|
||||
@@ -338,6 +470,19 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
watermarks.append("xAI/Grok EXIF signature")
|
||||
if platform is None:
|
||||
platform = "xAI (Grok / Aurora)"
|
||||
ai_vendor_claims["xai"] = "xAI"
|
||||
|
||||
# ── HuggingFace-hosted job marker (hf-job-id PNG text chunk) ─────
|
||||
# Marks the hosting job, not a model -- medium confidence (commonly diffusion
|
||||
# output). Like the visible sparkle, it lifts an otherwise-Unknown verdict to
|
||||
# a tentative AI, but never overrides a high-confidence metadata signal.
|
||||
hf_job = huggingface_job(image_path)
|
||||
if hf_job:
|
||||
signals.append(Signal("hf_job", f"HuggingFace job {hf_job}", "medium"))
|
||||
watermarks.append("HuggingFace-hosted job (hf-job-id)")
|
||||
caveats.append(_HF_JOB_CAVEAT)
|
||||
if platform is None:
|
||||
platform = "HuggingFace-hosted job (model not identified)"
|
||||
|
||||
# ── Open invisible watermark (SD / SDXL / FLUX, dwtDct) ──────────
|
||||
# Public decoder, no key -- a definitive embedded signal on pristine files.
|
||||
@@ -381,17 +526,21 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
platform = "Google Gemini family (visible sparkle detected)"
|
||||
|
||||
visible_only = any(s.name == "visible_sparkle" for s in signals) and not ai_from_metadata
|
||||
hf_only = bool(hf_job) and not ai_from_metadata
|
||||
|
||||
if ai_from_metadata:
|
||||
is_ai: bool | None = True
|
||||
confidence = "high"
|
||||
elif visible_only:
|
||||
elif visible_only or hf_only:
|
||||
is_ai = True
|
||||
confidence = "medium"
|
||||
else:
|
||||
is_ai = None
|
||||
confidence = "none"
|
||||
|
||||
# ── Integrity clashes: contradictions between independent signals ─
|
||||
clashes = _integrity_clashes(ai_vendor_claims, camera_label, camera_has_ai_marker=bool(ai_vendor_claims))
|
||||
|
||||
caveats.append(_STRIP_CAVEAT)
|
||||
# De-duplicate while preserving order.
|
||||
caveats = list(dict.fromkeys(caveats))
|
||||
@@ -404,4 +553,5 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
watermarks=watermarks,
|
||||
signals=signals,
|
||||
caveats=caveats,
|
||||
integrity_clashes=clashes,
|
||||
)
|
||||
@@ -0,0 +1,66 @@
|
||||
"""Unicode-safe cv2 image IO (issue #17).
|
||||
|
||||
``cv2.imread`` / ``cv2.imwrite`` pass the path to the platform C runtime, which
|
||||
on Windows uses the narrow (ANSI) code-page API and therefore fails on paths
|
||||
containing non-ASCII characters (Chinese, Cyrillic, ...). The symptom is a
|
||||
``can't open/read file`` warning and a ``None`` decode even though the file
|
||||
exists.
|
||||
|
||||
These wrappers route through numpy buffers instead: ``np.fromfile`` /
|
||||
``ndarray.tofile`` open the path in Python (full Unicode), and
|
||||
``cv2.imdecode`` / ``cv2.imencode`` do the codec work. The decoded/encoded
|
||||
bytes are byte-for-byte identical to ``imread`` / ``imwrite``. On macOS/Linux
|
||||
cv2 already accepts UTF-8 paths, so the wrappers are behavior-neutral there.
|
||||
|
||||
cv2/numpy are imported lazily inside the functions so importing this module
|
||||
stays cheap in a bare environment (matching the rest of the package).
|
||||
"""
|
||||
|
||||
# cv2 ships no type stubs; mirror the pragma used by the other cv2-using modules.
|
||||
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
|
||||
def imread(path: str | Path, flags: int | None = None) -> NDArray[Any] | None:
|
||||
"""Unicode-safe ``cv2.imread``.
|
||||
|
||||
``flags`` defaults to ``cv2.IMREAD_COLOR`` (same as ``cv2.imread``). Returns
|
||||
``None`` when the file is missing or cannot be decoded, matching
|
||||
``cv2.imread`` semantics so existing ``if img is None`` checks keep working.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
if flags is None:
|
||||
flags = cv2.IMREAD_COLOR
|
||||
try:
|
||||
data = np.fromfile(str(path), dtype=np.uint8)
|
||||
except OSError:
|
||||
return None
|
||||
if data.size == 0:
|
||||
return None
|
||||
return cv2.imdecode(data, flags)
|
||||
|
||||
|
||||
def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
|
||||
"""Unicode-safe ``cv2.imwrite``.
|
||||
|
||||
The output format is taken from the path extension (e.g. ``.png``), exactly
|
||||
like ``cv2.imwrite``. Returns ``True`` on success, ``False`` if the codec
|
||||
rejects the image.
|
||||
"""
|
||||
import cv2
|
||||
|
||||
ext = Path(path).suffix or ".png"
|
||||
ok, buf = cv2.imencode(ext, img)
|
||||
if not ok:
|
||||
return False
|
||||
buf.tofile(str(path))
|
||||
return True
|
||||
@@ -7,6 +7,10 @@ This module requires the 'gpu' extra dependencies:
|
||||
uv pip install 'remove-ai-watermarks[gpu]'
|
||||
"""
|
||||
|
||||
# cv2/torch boundary: this engine wraps cv2 (resize/imwrite/cvtColor), the YOLO
|
||||
# face protector, and the humanizer, none of which carry usable element types;
|
||||
# relax the unknown-type rules for this file only.
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
@@ -33,13 +37,9 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
def is_available() -> bool:
|
||||
"""Check if invisible watermark removal dependencies are installed."""
|
||||
try:
|
||||
import diffusers # noqa: F401
|
||||
import torch # noqa: F401
|
||||
import importlib.util
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
return importlib.util.find_spec("diffusers") is not None and importlib.util.find_spec("torch") is not None
|
||||
|
||||
|
||||
def _target_size(width: int, height: int, max_resolution: int) -> tuple[int, int] | None:
|
||||
@@ -125,6 +125,7 @@ class InvisibleEngine:
|
||||
seed: int | None = None,
|
||||
humanize: float = 0.0,
|
||||
protect_faces: bool = True,
|
||||
protect_text: bool = True,
|
||||
max_resolution: int = 0,
|
||||
) -> Path:
|
||||
"""Remove invisible watermark from an image.
|
||||
@@ -138,6 +139,9 @@ 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.
|
||||
protect_text: Detect text regions and preserve them via Differential
|
||||
Diffusion when any are found, so glyphs (incl. CJK) survive the
|
||||
removal pass. On by default; the detector decides per image.
|
||||
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
|
||||
@@ -210,6 +214,7 @@ class InvisibleEngine:
|
||||
num_inference_steps=num_inference_steps,
|
||||
guidance_scale=guidance_scale,
|
||||
seed=seed,
|
||||
protect_text=protect_text,
|
||||
)
|
||||
|
||||
# Optional: Face restoration & Humanizer (Phase 2 - Post-processing)
|
||||
@@ -217,7 +222,9 @@ class InvisibleEngine:
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
out_cv = cv2.imread(str(out_path), cv2.IMREAD_COLOR)
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
out_cv = image_io.imread(out_path, cv2.IMREAD_COLOR)
|
||||
|
||||
if protect_faces and original_faces:
|
||||
if self._progress_callback:
|
||||
@@ -243,20 +250,22 @@ class InvisibleEngine:
|
||||
# Using INTER_LANCZOS4 for high-quality upscaling back to original
|
||||
out_cv = cv2.resize(out_cv, orig_size, interpolation=cv2.INTER_LANCZOS4)
|
||||
|
||||
cv2.imwrite(str(out_path), out_cv)
|
||||
image_io.imwrite(out_path, out_cv)
|
||||
|
||||
else:
|
||||
# Even if no protect_faces or humanize, we must restore original size if needed
|
||||
import cv2
|
||||
|
||||
out_cv = cv2.imread(str(out_path), cv2.IMREAD_COLOR)
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
out_cv = image_io.imread(out_path, cv2.IMREAD_COLOR)
|
||||
if out_cv is not None and (out_cv.shape[1], out_cv.shape[0]) != orig_size:
|
||||
if self._progress_callback:
|
||||
self._progress_callback(
|
||||
f"Upscaling result back to original resolution {orig_size[0]}x{orig_size[1]}..."
|
||||
)
|
||||
out_cv = cv2.resize(out_cv, orig_size, interpolation=cv2.INTER_LANCZOS4)
|
||||
cv2.imwrite(str(out_path), out_cv)
|
||||
image_io.imwrite(out_path, out_cv)
|
||||
|
||||
return out_path
|
||||
finally:
|
||||
|
||||
@@ -78,10 +78,11 @@ def detect_invisible_watermark(image_path: Path) -> str | None:
|
||||
"""
|
||||
if not is_available():
|
||||
return None
|
||||
import cv2
|
||||
from imwatermark import WatermarkDecoder
|
||||
|
||||
img = cv2.imread(str(image_path))
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
img = image_io.imread(image_path)
|
||||
if img is None:
|
||||
return None
|
||||
|
||||
|
||||
@@ -11,7 +11,7 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -90,10 +90,10 @@ IPTC_AI_FIELD_MARKERS: tuple[bytes, ...] = (
|
||||
# (``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 we can DETECT (binary scan) but not strip at the
|
||||
# container level (EBML / framed / RIFF need re-encoding). remove_ai_metadata
|
||||
# fails clearly on these rather than crashing in the image path.
|
||||
_UNSUPPORTED_CONTAINER_EXTS: frozenset[str] = frozenset(
|
||||
# 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"}
|
||||
)
|
||||
|
||||
@@ -108,6 +108,27 @@ AIGC_MARKERS: tuple[bytes, ...] = (
|
||||
b"TC260:AIGC",
|
||||
)
|
||||
|
||||
# TC260 AIGC-label JSON fields (the standard's labeling object). Doubao writes
|
||||
# the same object as a PNG ``tEXt`` chunk keyed ``AIGC`` (raw JSON, not XMP), so
|
||||
# a JSON object carrying at least one of these is accepted as a valid TC260
|
||||
# label even when the namespaced XMP element is absent.
|
||||
_TC260_FIELDS: frozenset[str] = frozenset(
|
||||
{
|
||||
"Label",
|
||||
"ContentProducer",
|
||||
"ProduceID",
|
||||
"ContentPropagator",
|
||||
"PropagateID",
|
||||
"ReservedCode1",
|
||||
"ReservedCode2",
|
||||
}
|
||||
)
|
||||
|
||||
# HuggingFace-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with this
|
||||
# ``tEXt`` chunk key holding the job UUID. It marks the hosting job, not a
|
||||
# specific model -- a medium-confidence AI signal (commonly diffusion output).
|
||||
_HF_JOB_KEY: str = "hf-job-id"
|
||||
|
||||
STANDARD_METADATA_KEYS: frozenset[str] = frozenset(
|
||||
[
|
||||
"Author",
|
||||
@@ -132,6 +153,28 @@ def _is_ai_key(key: str) -> bool:
|
||||
return any(kw in key_lower for kw in AI_KEYWORDS)
|
||||
|
||||
|
||||
def scan_head(image_path: Path, size: int = 1024 * 1024) -> bytes:
|
||||
"""First ``size`` bytes of the file, plus -- for ISOBMFF containers -- the
|
||||
payloads of any provenance (``uuid`` / ``jumb``) boxes found beyond that
|
||||
window by seeking past large boxes like ``mdat``.
|
||||
|
||||
This is the shared input for every C2PA / AIGC / IPTC byte scan. The
|
||||
ISOBMFF extension catches a manifest placed AFTER the media data in a
|
||||
streaming / non-faststart MP4, which a fixed first-MB read would miss. For
|
||||
non-ISOBMFF inputs it is exactly ``f.read(size)`` -- behavior-neutral.
|
||||
"""
|
||||
with open(image_path, "rb") as f:
|
||||
head = f.read(size)
|
||||
# Lazy import: isobmff imports this module's constants at top level.
|
||||
from remove_ai_watermarks.noai import isobmff
|
||||
|
||||
if isobmff.is_isobmff(head):
|
||||
region = isobmff.scan_c2pa_region(image_path)
|
||||
if region:
|
||||
head += region
|
||||
return head
|
||||
|
||||
|
||||
def has_ai_metadata(image_path: Path) -> bool:
|
||||
"""Check if an image contains AI-generation metadata.
|
||||
|
||||
@@ -150,7 +193,7 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
for key in img.info:
|
||||
if _is_ai_key(key):
|
||||
if isinstance(key, str) and _is_ai_key(key):
|
||||
return True
|
||||
except Exception as exc:
|
||||
logger.debug("PIL could not open %s for metadata scan: %s", image_path, exc)
|
||||
@@ -159,7 +202,8 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
# binary scan that also catches AVIF/HEIF/JPEG-XL containers (PIL doesn't
|
||||
# expose their metadata uniformly).
|
||||
try:
|
||||
from c2pa import has_c2pa_metadata
|
||||
# optional official lib, not a declared dep -> falls back to the binary scan
|
||||
from c2pa import has_c2pa_metadata # pyright: ignore[reportMissingImports, reportUnknownVariableType]
|
||||
|
||||
if has_c2pa_metadata(image_path):
|
||||
return True
|
||||
@@ -167,9 +211,8 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
pass
|
||||
|
||||
# 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)
|
||||
# and IPTC AI markers in XMP. First 512KB (plus late ISOBMFF provenance boxes).
|
||||
data = scan_head(image_path, 512 * 1024)
|
||||
if b"c2pa" in data.lower() or b"C2PA" in data:
|
||||
return True
|
||||
if C2PA_UUID in data:
|
||||
@@ -181,32 +224,90 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
# 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
|
||||
# China TC260 AIGC label as a PNG text chunk (the byte scan above catches
|
||||
# only the XMP form; the raw-JSON tEXt chunk needs the PIL-based parse).
|
||||
if aigc_label(image_path):
|
||||
return True
|
||||
# HuggingFace-hosted job marker (hf-job-id PNG text chunk).
|
||||
if huggingface_job(image_path):
|
||||
return True
|
||||
# xAI / Grok: no C2PA/IPTC/XMP -- only the EXIF Signature + UUID-Artist pair.
|
||||
return xai_signature(image_path)
|
||||
|
||||
|
||||
def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
"""Parse a China TC260 ``<TC260:AIGC>`` AI-labeling block, if present.
|
||||
"""Parse a China TC260 AI-labeling block, if present.
|
||||
|
||||
Two serializations are recognized:
|
||||
|
||||
- a PNG ``tEXt``/``iTXt`` chunk keyed ``AIGC`` carrying the raw JSON object
|
||||
(as written by Doubao / ByteDance), read via PIL; and
|
||||
- an XMP ``<TC260:AIGC>{...}</TC260:AIGC>`` block (HTML-entity encoded text),
|
||||
found by a container-agnostic raw-byte scan (PNG/JPEG/WebP alike).
|
||||
|
||||
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.
|
||||
or None. The PNG-chunk key ``AIGC`` is generic, so a JSON object there is
|
||||
accepted only if it carries at least one known TC260 field (``_TC260_FIELDS``);
|
||||
the namespaced XMP element is unambiguous, so any JSON object is accepted.
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
import re
|
||||
from typing import cast
|
||||
|
||||
with open(image_path, "rb") as f:
|
||||
data = f.read(1024 * 1024)
|
||||
def _parse(text: str, *, require_tc260_field: bool) -> dict[str, str] | None:
|
||||
try:
|
||||
parsed = json.loads(text)
|
||||
except ValueError:
|
||||
return None
|
||||
if not isinstance(parsed, dict):
|
||||
return None
|
||||
fields = {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
|
||||
if require_tc260_field and not (_TC260_FIELDS & fields.keys()):
|
||||
return None
|
||||
return fields
|
||||
|
||||
# PNG tEXt chunk keyed "AIGC" with raw JSON (Doubao and other China gens).
|
||||
# The key is generic, so require a TC260 field to avoid a false positive.
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
value = img.info.get("AIGC")
|
||||
except Exception as exc:
|
||||
logger.debug("PIL could not open %s for AIGC chunk scan: %s", image_path, exc)
|
||||
value = None
|
||||
if isinstance(value, str) and (result := _parse(value, require_tc260_field=True)):
|
||||
return result
|
||||
|
||||
# XMP <TC260:AIGC>{...}</TC260:AIGC> block (namespaced element, unambiguous).
|
||||
data = scan_head(image_path)
|
||||
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"))
|
||||
return _parse(html.unescape(match.group(1).decode("utf-8", "replace")), require_tc260_field=False)
|
||||
|
||||
|
||||
def huggingface_job(image_path: Path) -> str | None:
|
||||
"""Return the HuggingFace job id if the image carries an ``hf-job-id`` PNG
|
||||
text chunk, else None.
|
||||
|
||||
HuggingFace-hosted GPU jobs (Jobs / Spaces) stamp generated PNGs with an
|
||||
``hf-job-id`` ``tEXt`` chunk holding the job's UUID. It identifies the
|
||||
*hosting job*, not a specific model, and is most commonly seen on diffusion-
|
||||
generation output -- a medium-confidence AI signal, not proof of AI pixels
|
||||
on its own.
|
||||
"""
|
||||
try:
|
||||
parsed = json.loads(raw)
|
||||
except ValueError:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
value = img.info.get(_HF_JOB_KEY)
|
||||
except Exception as exc:
|
||||
logger.debug("PIL could not open %s for hf-job-id scan: %s", image_path, exc)
|
||||
return None
|
||||
return {str(k): str(v) for k, v in parsed.items()} if isinstance(parsed, dict) else None
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return None
|
||||
|
||||
|
||||
def iptc_ai_system(image_path: Path) -> str | None:
|
||||
@@ -219,8 +320,7 @@ def iptc_ai_system(image_path: Path) -> str | None:
|
||||
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)
|
||||
data = scan_head(image_path)
|
||||
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)
|
||||
@@ -259,8 +359,7 @@ def synthid_source(image_path: Path) -> str | None:
|
||||
# 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)
|
||||
data = scan_head(image_path)
|
||||
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
|
||||
@@ -311,8 +410,7 @@ def exif_generator(image_path: Path) -> str | None:
|
||||
|
||||
# XMP CreatorTool: text, container-agnostic (covers HEIF/JXL via raw scan).
|
||||
try:
|
||||
with open(image_path, "rb") as f:
|
||||
head = f.read(1024 * 1024)
|
||||
head = scan_head(image_path)
|
||||
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:
|
||||
@@ -336,7 +434,7 @@ def _is_xai_signature_pair(description: str, artist: str) -> bool:
|
||||
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:
|
||||
def _exif_text(ifd: dict[int, Any], 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 ""
|
||||
@@ -372,7 +470,7 @@ def xai_signature(image_path: Path) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _scrub_ai_exif(exif_dict: dict) -> list[str]:
|
||||
def _scrub_ai_exif(exif_dict: dict[str, Any]) -> list[str]:
|
||||
"""Delete AI-provenance tags from a piexif dict's ``0th`` IFD, in place.
|
||||
|
||||
Removes (a) the xAI/Grok signature pair (``ImageDescription`` "Signature: ..."
|
||||
@@ -436,7 +534,7 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
try:
|
||||
with Image.open(image_path) as img:
|
||||
for key, value in img.info.items():
|
||||
if _is_ai_key(key):
|
||||
if isinstance(key, str) and _is_ai_key(key):
|
||||
if isinstance(value, bytes):
|
||||
result[key] = f"<binary {len(value)} bytes>"
|
||||
elif isinstance(value, str) and len(value) > 200:
|
||||
@@ -467,8 +565,7 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
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)
|
||||
head = scan_head(image_path)
|
||||
if vendors := soft_binding_vendors_in(head):
|
||||
result["soft_binding"] = ", ".join(vendors)
|
||||
|
||||
@@ -484,9 +581,54 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
# 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})")
|
||||
|
||||
# HuggingFace-hosted job marker (hf-job-id PNG text chunk).
|
||||
if job := huggingface_job(image_path):
|
||||
result.setdefault("huggingface_job", f"HuggingFace-hosted job ({job})")
|
||||
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,
|
||||
@@ -518,38 +660,55 @@ def remove_ai_metadata(
|
||||
# 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
|
||||
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets, 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()
|
||||
# Top-level uuid/jumb boxes (C2PA + AI-label XMP), then AI-label XMP that
|
||||
# lives inside a meta-box ``mime`` item (HEIF/AVIF) -- blanked in place so
|
||||
# box sizes and iloc offsets stay valid and the coded image is untouched.
|
||||
cleaned, stripped = strip_c2pa_boxes(data)
|
||||
cleaned, blanked = blank_ai_xmp_packets(cleaned)
|
||||
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)
|
||||
logger.info(
|
||||
"Stripped %d AI-provenance box(es), blanked %d meta-box XMP packet(s) → %s",
|
||||
stripped,
|
||||
blanked,
|
||||
output_path,
|
||||
)
|
||||
return output_path
|
||||
|
||||
# Containers we can detect (via identify's byte scan) but cannot strip at the
|
||||
# container level: non-ISOBMFF audio/video (Matroska/WebM are EBML; MP3 is
|
||||
# framed; WAV is RIFF). Re-encoding them is out of scope, so fail clearly
|
||||
# rather than crash in the PIL image path below.
|
||||
if source_path.suffix.lower() in _UNSUPPORTED_CONTAINER_EXTS:
|
||||
raise ValueError(
|
||||
f"container-level metadata removal is not supported for {source_path.suffix} "
|
||||
"(detection via `identify` still works); re-encode it with a media tool to strip metadata"
|
||||
)
|
||||
# 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()
|
||||
fmt = output_path.suffix.lower()
|
||||
|
||||
save_kwargs: dict = {}
|
||||
save_kwargs: dict[str, Any] = {}
|
||||
if fmt in (".jpg", ".jpeg"):
|
||||
save_kwargs["format"] = "JPEG"
|
||||
# JPEG output is unavoidably lossy, so minimize the loss: high quality
|
||||
# and no chroma subsampling (4:4:4). Without these PIL defaults to
|
||||
# quality 75 + 4:2:0, which visibly degrades a re-saved image.
|
||||
save_kwargs["quality"] = 95
|
||||
save_kwargs["subsampling"] = 0
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
elif fmt == ".webp":
|
||||
# Preserve the WebP container losslessly instead of silently rewriting
|
||||
# it as PNG (which changes the format and bloats the file).
|
||||
save_kwargs["format"] = "WEBP"
|
||||
save_kwargs["lossless"] = True
|
||||
if img.mode == "P": # WebP cannot encode palette mode
|
||||
img = img.convert("RGBA" if "transparency" in img.info else "RGB")
|
||||
else:
|
||||
save_kwargs["format"] = "PNG"
|
||||
|
||||
@@ -558,6 +717,8 @@ def remove_ai_metadata(
|
||||
exif_data = None
|
||||
|
||||
for key, value in img.info.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
if _is_ai_key(key):
|
||||
continue
|
||||
if key == "exif":
|
||||
|
||||
@@ -94,6 +94,8 @@ def _extract_non_ai_metadata(source_path: Path, keep_standard: bool) -> dict[str
|
||||
|
||||
# Extract non-AI metadata
|
||||
for key, value in img.info.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
if _is_ai_metadata_key(key):
|
||||
continue
|
||||
|
||||
@@ -127,7 +129,7 @@ def _is_ai_metadata_key(key: str) -> bool:
|
||||
|
||||
def _prepare_clean_png_kwargs(save_kwargs: dict[str, Any], metadata: dict[str, Any]) -> dict[str, Any]:
|
||||
"""Prepare save kwargs for clean PNG."""
|
||||
pnginfo = {}
|
||||
pnginfo: dict[str, Any] = {}
|
||||
exclude_keys = ["exif", "exif_raw", "dpi", "gamma"]
|
||||
|
||||
for key, value in metadata.items():
|
||||
|
||||
@@ -6,7 +6,7 @@ human-readable summary without modifying the source file.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Any
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -46,6 +46,8 @@ def extract_metadata(source_path: Path) -> dict[str, Any]:
|
||||
|
||||
# Extract all other metadata including AI-specific
|
||||
for key, value in img.info.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
if key not in metadata and key not in ["exif"]:
|
||||
metadata[key] = value
|
||||
|
||||
@@ -83,6 +85,8 @@ def extract_ai_metadata(source_path: Path) -> dict[str, Any]:
|
||||
ai_metadata[key] = img.info[key]
|
||||
|
||||
for key, value in img.info.items():
|
||||
if not isinstance(key, str):
|
||||
continue
|
||||
key_lower = key.lower()
|
||||
if key not in ai_metadata and any(kw in key_lower for kw in AI_KEYWORDS):
|
||||
ai_metadata[key] = value
|
||||
@@ -138,7 +142,7 @@ def get_ai_metadata_summary(source_path: Path) -> str:
|
||||
continue
|
||||
if key == "c2pa" and isinstance(value, dict):
|
||||
lines.append("C2PA Metadata:")
|
||||
for ck, cv in value.items():
|
||||
for ck, cv in cast("dict[str, Any]", value).items():
|
||||
lines.append(f" {ck}: {cv}")
|
||||
elif isinstance(value, str) and len(value) > 100:
|
||||
value = value[:100] + "..."
|
||||
|
||||
@@ -121,6 +121,96 @@ def run_img2img_with_mps_fallback(
|
||||
raise
|
||||
|
||||
|
||||
def run_differential(
|
||||
pipeline: Any,
|
||||
image: Image.Image,
|
||||
change_map: Any,
|
||||
strength: float,
|
||||
num_inference_steps: int,
|
||||
guidance_scale: float,
|
||||
generator: Any,
|
||||
device: str,
|
||||
set_progress: Callable[[str], None],
|
||||
) -> Image.Image:
|
||||
"""Run the SDXL Differential-Diffusion pipeline and return the image.
|
||||
|
||||
Unlike standard img2img, the differential pipeline needs pre-processed image
|
||||
tensors plus a per-pixel change map (HxW float32 in [0, 1]); white preserves
|
||||
the original pixels, black regenerates them. Runs without a step callback --
|
||||
the community pipeline's callback signature differs across diffusers
|
||||
versions, and a protect-text pass is short.
|
||||
"""
|
||||
import torch
|
||||
|
||||
image_tensor = pipeline.image_processor.preprocess(image).to(device)
|
||||
map_tensor = torch.from_numpy(change_map)[None].to(device) # pyright: ignore[reportPrivateImportUsage, reportUnknownMemberType]
|
||||
set_progress(f"Running protected regeneration ({device}, strength={strength})...")
|
||||
result = pipeline(
|
||||
prompt="",
|
||||
image=image_tensor,
|
||||
original_image=image_tensor,
|
||||
map=map_tensor,
|
||||
strength=strength,
|
||||
num_inference_steps=num_inference_steps,
|
||||
guidance_scale=guidance_scale,
|
||||
generator=generator,
|
||||
)
|
||||
return result.images[0]
|
||||
|
||||
|
||||
def run_differential_with_mps_fallback(
|
||||
load_pipeline: Callable[[], Any],
|
||||
image: Image.Image,
|
||||
change_map: Any,
|
||||
strength: float,
|
||||
num_inference_steps: int,
|
||||
guidance_scale: float,
|
||||
generator: Any,
|
||||
device: str,
|
||||
set_progress: Callable[[str], None],
|
||||
*,
|
||||
reload_on_cpu: Callable[[], Any],
|
||||
) -> tuple[Image.Image, str]:
|
||||
"""Run differential img2img; on MPS error, fall back to CPU.
|
||||
|
||||
Returns:
|
||||
(result_image, final_device) -- device may change to ``"cpu"`` on fallback.
|
||||
"""
|
||||
pipeline = load_pipeline()
|
||||
try:
|
||||
img = run_differential(
|
||||
pipeline,
|
||||
image,
|
||||
change_map,
|
||||
strength,
|
||||
num_inference_steps,
|
||||
guidance_scale,
|
||||
generator,
|
||||
device,
|
||||
set_progress,
|
||||
)
|
||||
return img, device
|
||||
except RuntimeError as error:
|
||||
if device == "mps" and is_mps_error(error):
|
||||
logger.warning("MPS error detected: %s. Falling back to CPU.", error)
|
||||
set_progress("MPS error! Clearing cache and retrying on CPU...")
|
||||
_try_clear_mps_cache()
|
||||
pipeline = reload_on_cpu()
|
||||
img = run_differential(
|
||||
pipeline,
|
||||
image,
|
||||
change_map,
|
||||
strength,
|
||||
num_inference_steps,
|
||||
guidance_scale,
|
||||
None,
|
||||
"cpu",
|
||||
set_progress,
|
||||
)
|
||||
return img, "cpu"
|
||||
raise
|
||||
|
||||
|
||||
def _call_pipeline(
|
||||
pipeline: Any,
|
||||
image: Image.Image,
|
||||
|
||||
@@ -17,11 +17,13 @@ Reference: ISO/IEC 14496-12 (ISOBMFF) and C2PA 2.1 spec §11.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import struct
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.metadata import (
|
||||
AIGC_MARKERS,
|
||||
@@ -42,6 +44,12 @@ C2PA_BOX_TYPES: frozenset[bytes] = frozenset({b"uuid", b"jumb"})
|
||||
# XMP (copyright, camera info) is kept.
|
||||
_AI_LABEL_MARKERS: tuple[bytes, ...] = AIGC_MARKERS + IPTC_AI_MARKERS + IPTC_AI_FIELD_MARKERS
|
||||
|
||||
# Adobe XMP packet delimiters (XMP spec part 3). In HEIF/AVIF the XMP packet
|
||||
# sits inside a ``meta``-box ``mime`` item whose bytes live in ``mdat`` / ``idat``,
|
||||
# out of reach of the top-level box stripper, so an AI-label packet there is
|
||||
# blanked in place (see ``blank_ai_xmp_packets``).
|
||||
_XMP_PACKET_RE = re.compile(rb"<\?xpacket begin=.*?<\?xpacket end=[^>]*?\?>", re.DOTALL)
|
||||
|
||||
|
||||
def _iter_top_level_boxes(data: bytes) -> Iterator[tuple[int, int, bytes, int]]:
|
||||
"""Yield ``(start, end, type, payload_offset)`` for each top-level box.
|
||||
@@ -78,6 +86,58 @@ def is_isobmff(data: bytes) -> bool:
|
||||
return len(data) >= 8 and data[4:8] == b"ftyp"
|
||||
|
||||
|
||||
def scan_c2pa_region(path: str | Path, *, max_total: int = 4 * 1024 * 1024) -> bytes:
|
||||
"""Concatenated payloads of top-level ``uuid`` / ``jumb`` boxes in an ISOBMFF
|
||||
file, found by seeking past other boxes (``mdat`` etc.) by size.
|
||||
|
||||
C2PA manifests and XMP packets (incl. AI labels) live in top-level ``uuid``
|
||||
boxes; JPEG-XL uses ``jumb``. In a streaming / non-faststart MP4 the manifest
|
||||
sits AFTER a multi-megabyte ``mdat``, so a fixed first-MB read misses it. This
|
||||
walks box headers (8-16 bytes each) and seeks past payloads it does not need,
|
||||
so it never loads ``mdat`` into memory and works on multi-GB files. Returns
|
||||
the relevant box payloads (capped at ``max_total``), or ``b""`` for a
|
||||
non-ISOBMFF file or on any read error.
|
||||
"""
|
||||
collected = bytearray()
|
||||
try:
|
||||
with open(path, "rb") as f:
|
||||
sniff = f.read(8)
|
||||
if len(sniff) < 8 or sniff[4:8] != b"ftyp":
|
||||
return b""
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
pos = 0
|
||||
while pos + 8 <= file_size and len(collected) < max_total:
|
||||
f.seek(pos)
|
||||
header = f.read(8)
|
||||
if len(header) < 8:
|
||||
break
|
||||
size32 = struct.unpack(">I", header[:4])[0]
|
||||
box_type = header[4:8]
|
||||
payload_off = pos + 8
|
||||
if size32 == 1:
|
||||
ext = f.read(8)
|
||||
if len(ext) < 8:
|
||||
break
|
||||
size = struct.unpack(">Q", ext)[0]
|
||||
payload_off = pos + 16
|
||||
elif size32 == 0:
|
||||
size = file_size - pos
|
||||
else:
|
||||
size = size32
|
||||
if size < (payload_off - pos) or pos + size > file_size:
|
||||
break
|
||||
if box_type in C2PA_BOX_TYPES:
|
||||
f.seek(payload_off)
|
||||
to_read = min(pos + size - payload_off, max_total - len(collected))
|
||||
if to_read > 0:
|
||||
collected += f.read(to_read)
|
||||
pos += size
|
||||
except OSError:
|
||||
return b""
|
||||
return bytes(collected)
|
||||
|
||||
|
||||
def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
"""Return ``(cleaned_bytes, stripped_count)`` with AI-provenance boxes removed.
|
||||
|
||||
@@ -92,9 +152,10 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
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.
|
||||
(all ISOBMFF). NOTE: this drops only top-level boxes. An AI-label XMP packet
|
||||
stored as an *item inside the ``meta`` box* (typical for AVIF/HEIF) is handled
|
||||
separately by :func:`blank_ai_xmp_packets`; an ``Exif`` meta-box item is still
|
||||
not removed (would need meta-box surgery) and remains a documented limitation.
|
||||
"""
|
||||
if not is_isobmff(data):
|
||||
return data, 0
|
||||
@@ -114,3 +175,30 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
continue
|
||||
out.extend(data[start:end])
|
||||
return bytes(out), stripped
|
||||
|
||||
|
||||
def blank_ai_xmp_packets(data: bytes) -> tuple[bytes, int]:
|
||||
"""Overwrite (with spaces, in place) any XMP packet carrying an AI-label
|
||||
marker; return ``(data, blanked_count)``.
|
||||
|
||||
HEIF/AVIF store XMP as a ``meta``-box ``mime`` item whose bytes live in
|
||||
``mdat`` / ``idat``, which ``strip_c2pa_boxes`` cannot remove without
|
||||
meta-box surgery (``iinf`` / ``iloc`` rewrite). Instead, the XMP packet is
|
||||
located by its ``<?xpacket begin ... end?>`` delimiters and, when it carries
|
||||
an AI-label marker (TC260 AIGC / IPTC / IPTC-2025.1), overwritten with spaces.
|
||||
Because the replacement is the **same length**, every box size and ``iloc``
|
||||
offset stays valid and the coded image data is untouched -- only the AI label
|
||||
content is destroyed. Packets without an AI marker (plain copyright / camera
|
||||
XMP) are left intact, mirroring the top-level XMP-``uuid`` content match.
|
||||
"""
|
||||
blanked = 0
|
||||
|
||||
def _scrub(match: re.Match[bytes]) -> bytes:
|
||||
nonlocal blanked
|
||||
packet = match.group()
|
||||
if any(marker in packet for marker in _AI_LABEL_MARKERS):
|
||||
blanked += 1
|
||||
return b" " * len(packet)
|
||||
return packet
|
||||
|
||||
return _XMP_PACKET_RE.sub(_scrub, data), blanked
|
||||
@@ -259,7 +259,7 @@ def make_pipeline_progress(
|
||||
label: str = "Denoising",
|
||||
pre_phases: list[tuple[int, str]] | None = None,
|
||||
post_phases: list[tuple[int, str]] | None = None,
|
||||
) -> tuple[Callable, threading.Event, threading.Event, Callable[[], threading.Thread]]:
|
||||
) -> tuple[Callable[..., None], threading.Event, threading.Event, Callable[[], threading.Thread]]:
|
||||
"""Create step callback and background updater for a diffusion pipeline.
|
||||
|
||||
Returns:
|
||||
|
||||
@@ -10,6 +10,9 @@ This module implements a simple regeneration attack that:
|
||||
4. Decodes back to pixel space
|
||||
"""
|
||||
|
||||
# torch/diffusers/cv2 boundary: these libs ship no usable types for the tensor and
|
||||
# array ops below; relax the unknown-type rules for this file only.
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
|
||||
from __future__ import annotations
|
||||
|
||||
import contextlib
|
||||
@@ -229,6 +232,13 @@ def get_device() -> str:
|
||||
# Keep legacy name available for backwards compatibility
|
||||
_detect_model_profile_from_id = detect_model_profile
|
||||
|
||||
# SDXL Differential-Diffusion community pipeline, pinned to the installed
|
||||
# diffusers version so the fetched pipeline code matches the library (see #21).
|
||||
# Diffusers' dynamic-module loader resolves ``custom_revision`` against the
|
||||
# package version string (``0.38.0``), NOT the GitHub git tag (``v0.38.0``).
|
||||
_DIFF_PIPELINE_NAME = "pipeline_stable_diffusion_xl_differential_img2img"
|
||||
_DIFF_PIPELINE_REVISION = "0.38.0"
|
||||
|
||||
|
||||
class WatermarkRemover:
|
||||
"""Remove watermarks from images using diffusion model regeneration.
|
||||
@@ -271,6 +281,7 @@ class WatermarkRemover:
|
||||
self.torch_dtype = torch_dtype
|
||||
|
||||
self._pipeline: AutoImg2ImgPipeline | None = None
|
||||
self._diff_pipeline: Any = None
|
||||
self._ctrlregen_engine: Any = None
|
||||
self._progress_callback = progress_callback
|
||||
self.hf_token: str | None = hf_token or os.environ.get("HF_TOKEN")
|
||||
@@ -379,6 +390,7 @@ class WatermarkRemover:
|
||||
num_inference_steps: int = 50,
|
||||
guidance_scale: float | None = None,
|
||||
seed: int | None = None,
|
||||
protect_text: bool = True,
|
||||
) -> Path:
|
||||
"""Remove watermark from an image using regeneration attack.
|
||||
|
||||
@@ -389,6 +401,10 @@ class WatermarkRemover:
|
||||
num_inference_steps: Number of denoising steps.
|
||||
guidance_scale: Classifier-free guidance scale.
|
||||
seed: Random seed for reproducibility.
|
||||
protect_text: Detect text regions and preserve them via Differential
|
||||
Diffusion when any are found (SDXL default profile only). On by
|
||||
default; the detector decides per image, and text-free inputs run
|
||||
the standard pass at no extra cost.
|
||||
|
||||
Returns:
|
||||
Path to the cleaned image.
|
||||
@@ -437,7 +453,21 @@ class WatermarkRemover:
|
||||
guidance_scale,
|
||||
generator,
|
||||
)
|
||||
elif protect_text and self._can_protect_text():
|
||||
cleaned_image = self._run_differential(
|
||||
init_image,
|
||||
strength,
|
||||
num_inference_steps,
|
||||
guidance_scale,
|
||||
generator,
|
||||
)
|
||||
else:
|
||||
if protect_text:
|
||||
logger.debug(
|
||||
"Text protection unavailable "
|
||||
"(needs the SDXL default model and the cv2 text detector); "
|
||||
"running standard img2img."
|
||||
)
|
||||
cleaned_image = self._run_img2img(
|
||||
init_image,
|
||||
strength,
|
||||
@@ -520,6 +550,99 @@ class WatermarkRemover:
|
||||
self._pipeline = None
|
||||
return self._load_pipeline()
|
||||
|
||||
# ── Text-protected differential runner ───────────────────────────
|
||||
|
||||
def _can_protect_text(self) -> bool:
|
||||
"""True when text protection can run: SDXL default model + cv2 detector."""
|
||||
from remove_ai_watermarks import text_protector
|
||||
|
||||
return self.model_id == self.DEFAULT_MODEL_ID and text_protector.is_available()
|
||||
|
||||
def _load_differential_pipeline(self) -> Any:
|
||||
"""Load the SDXL Differential-Diffusion community pipeline lazily."""
|
||||
if self._diff_pipeline is None:
|
||||
from diffusers import DiffusionPipeline
|
||||
|
||||
self._set_progress("Loading Differential-Diffusion pipeline (protect-text)...")
|
||||
use_fp16 = self.device in {"mps", "cuda"}
|
||||
load_kwargs: dict[str, Any] = {
|
||||
"custom_pipeline": _DIFF_PIPELINE_NAME,
|
||||
"custom_revision": _DIFF_PIPELINE_REVISION,
|
||||
"torch_dtype": torch.float16 if use_fp16 else torch.float32, # type: ignore[attr-defined]
|
||||
"use_safetensors": True,
|
||||
}
|
||||
if use_fp16:
|
||||
load_kwargs["variant"] = "fp16"
|
||||
if self.hf_token:
|
||||
load_kwargs["token"] = self.hf_token
|
||||
|
||||
pipeline = DiffusionPipeline.from_pretrained(self.model_id, **load_kwargs).to(self.device)
|
||||
# The differential pipeline upcasts the SDXL VAE to fp32 internally
|
||||
# (the fp16 VAE decodes to NaN/black otherwise), so we add no extra
|
||||
# VAE handling here. Attention slicing is also left off on MPS: it
|
||||
# produced NaN latents with this pipeline, and the protect-text pass
|
||||
# is short enough not to need it.
|
||||
with contextlib.suppress(Exception):
|
||||
pipeline.set_progress_bar_config(disable=True)
|
||||
self._diff_pipeline = pipeline
|
||||
return self._diff_pipeline
|
||||
|
||||
def _reload_differential_on_cpu(self) -> Any:
|
||||
"""Reload the differential pipeline on CPU after an MPS failure."""
|
||||
self.device = "cpu"
|
||||
self.torch_dtype = torch.float32 # type: ignore[assignment]
|
||||
self._diff_pipeline = None
|
||||
return self._load_differential_pipeline()
|
||||
|
||||
def _run_differential(
|
||||
self,
|
||||
init_image: Image.Image,
|
||||
strength: float,
|
||||
num_inference_steps: int,
|
||||
guidance_scale: float,
|
||||
generator: Any,
|
||||
) -> Image.Image:
|
||||
"""Run differential img2img that preserves detected text regions."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import text_protector
|
||||
|
||||
self._set_progress("Detecting text regions to protect (protect-text)...")
|
||||
bgr = cv2.cvtColor(np.array(init_image), cv2.COLOR_RGB2BGR)
|
||||
try:
|
||||
boxes = text_protector.TextProtector().detect_text_boxes(bgr)
|
||||
except Exception as exc:
|
||||
logger.warning("Text detection failed (%s); running standard img2img.", exc)
|
||||
return self._run_img2img(init_image, strength, num_inference_steps, guidance_scale, generator)
|
||||
|
||||
if not boxes:
|
||||
self._set_progress("No text detected; running standard img2img.")
|
||||
return self._run_img2img(init_image, strength, num_inference_steps, guidance_scale, generator)
|
||||
|
||||
width, height = init_image.size
|
||||
change_map = text_protector.build_change_map(boxes, height, width)
|
||||
self._set_progress(f"Protecting {len(boxes)} text region(s) via Differential Diffusion...")
|
||||
|
||||
from remove_ai_watermarks.noai.img2img_runner import run_differential_with_mps_fallback
|
||||
|
||||
result_image, final_device = run_differential_with_mps_fallback(
|
||||
load_pipeline=self._load_differential_pipeline,
|
||||
image=init_image,
|
||||
change_map=change_map,
|
||||
strength=strength,
|
||||
num_inference_steps=num_inference_steps,
|
||||
guidance_scale=guidance_scale,
|
||||
generator=generator,
|
||||
device=self.device,
|
||||
set_progress=self._set_progress,
|
||||
reload_on_cpu=self._reload_differential_on_cpu,
|
||||
)
|
||||
if final_device != self.device:
|
||||
self.device = final_device
|
||||
self.torch_dtype = torch.float32 # type: ignore[assignment]
|
||||
return result_image
|
||||
|
||||
# ── CtrlRegen runner ─────────────────────────────────────────────
|
||||
|
||||
def _run_ctrlregen(
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"""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.
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: cv2 ships no usable type info, so strict pyright cannot know
|
||||
# its array element types. Relax the unknown-type rules for this file only; the
|
||||
# public signatures are still annotated with NDArray[Any].
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any, 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[Any]:
|
||||
"""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[Any],
|
||||
mask: NDArray[Any],
|
||||
*,
|
||||
method: Literal["telea", "ns"] = "telea",
|
||||
radius: int = 6,
|
||||
) -> NDArray[Any]:
|
||||
"""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)."""
|
||||
import importlib.util
|
||||
|
||||
return importlib.util.find_spec("onnxruntime") is not None
|
||||
|
||||
|
||||
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[Any], mask: NDArray[Any]) -> NDArray[Any]:
|
||||
"""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[Any],
|
||||
*,
|
||||
boxes: list[tuple[int, int, int, int]] | None = None,
|
||||
mask: NDArray[Any] | None = None,
|
||||
backend: Backend = "cv2",
|
||||
dilate: int = 3,
|
||||
cv2_method: Literal["telea", "ns"] = "telea",
|
||||
cv2_radius: int = 6,
|
||||
) -> NDArray[Any]:
|
||||
"""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,167 @@
|
||||
"""Text-region protection for diffusion-based watermark removal.
|
||||
|
||||
SDXL img2img (the ``invisible`` pipeline) regenerates every pixel, so small text
|
||||
and CJK glyphs get deformed at the strengths that defeat SynthID (issue #21).
|
||||
This module detects text regions and builds a per-pixel "change map" for
|
||||
Differential Diffusion: the background is regenerated normally while text
|
||||
regions are largely preserved, so glyphs survive the watermark-removal pass.
|
||||
|
||||
Detection uses only OpenCV's DNN module (no torch): the PP-OCRv3 text detector
|
||||
is a ~2.4 MB ONNX model (Apache-2.0, from opencv_zoo) that is CJK-native and
|
||||
returns rotated quadrilaterals. The model is downloaded and cached on first use;
|
||||
it is never bundled in this repo.
|
||||
|
||||
Change-map polarity (verified empirically against the differential pipeline):
|
||||
white (1.0) = PRESERVE the original pixels, black (0.0) = MAXIMUM change. So the
|
||||
map is black everywhere except the text polygons, which are painted toward
|
||||
white. ``preserve`` stays below a hard 1.0 freeze by default: SynthID is
|
||||
designed to survive cropping, so totally freezing text pixels would leave the
|
||||
watermark intact there. A high-but-partial preserve still scrubs lightly.
|
||||
"""
|
||||
|
||||
# cv2 ships no type stubs; mirror the pragma used by the other cv2-using modules.
|
||||
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportCallIssue=false, reportArgumentType=false, reportReturnType=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import tempfile
|
||||
import urllib.request
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# PP-OCRv3 Chinese text detector (DB head), opencv_zoo, Apache-2.0.
|
||||
_MODEL_URL = (
|
||||
"https://github.com/opencv/opencv_zoo/raw/main/models/text_detection_ppocr/text_detection_cn_ppocrv3_2023may.onnx"
|
||||
)
|
||||
_MODEL_FILENAME = "text_detection_cn_ppocrv3_2023may.onnx"
|
||||
|
||||
# DB detector input: long side scaled to this, rounded to a multiple of 32.
|
||||
_DET_INPUT_LONG_SIDE = 736
|
||||
# ImageNet mean (x255) and 1/255 scale -- the normalization PP-OCRv3 expects.
|
||||
_DET_MEAN = (0.485 * 255, 0.456 * 255, 0.406 * 255)
|
||||
_DET_SCALE = 1 / 255.0
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True when OpenCV's DNN text-detection model is importable."""
|
||||
try:
|
||||
import cv2
|
||||
|
||||
return hasattr(cv2.dnn, "TextDetectionModel_DB")
|
||||
except ImportError:
|
||||
return False
|
||||
|
||||
|
||||
def _cache_dir() -> Path:
|
||||
"""Local cache directory for the detector model (created on demand)."""
|
||||
cache = Path.home() / ".cache" / "remove-ai-watermarks"
|
||||
cache.mkdir(parents=True, exist_ok=True)
|
||||
return cache
|
||||
|
||||
|
||||
def _model_path() -> Path:
|
||||
"""Return the cached detector path, downloading it on first use."""
|
||||
target = _cache_dir() / _MODEL_FILENAME
|
||||
if target.exists() and target.stat().st_size > 0:
|
||||
return target
|
||||
logger.info("Downloading PP-OCRv3 text detector (~2.4 MB) to %s", target)
|
||||
# Download to a temp file in the same dir, then atomically rename so a
|
||||
# partial download never leaves a corrupt model cached.
|
||||
fd, tmp_name = tempfile.mkstemp(dir=str(target.parent), suffix=".onnx.part")
|
||||
tmp_path = Path(tmp_name)
|
||||
try:
|
||||
os.close(fd)
|
||||
with urllib.request.urlopen(_MODEL_URL) as resp: # noqa: S310 (trusted GitHub URL)
|
||||
tmp_path.write_bytes(resp.read())
|
||||
tmp_path.replace(target)
|
||||
finally:
|
||||
if tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
return target
|
||||
|
||||
|
||||
def build_change_map(
|
||||
boxes: list[NDArray[Any]],
|
||||
height: int,
|
||||
width: int,
|
||||
preserve: float = 0.9,
|
||||
feather: int = 15,
|
||||
) -> NDArray[Any]:
|
||||
"""Build a Differential-Diffusion change map from text polygons.
|
||||
|
||||
Args:
|
||||
boxes: Text-region polygons as arrays of (x, y) vertices.
|
||||
height: Output map height in pixels.
|
||||
width: Output map width in pixels.
|
||||
preserve: Map value painted inside text polygons (0..1). White (1.0)
|
||||
fully preserves the original pixels; the default 0.9 preserves
|
||||
strongly while still letting a light scrub through.
|
||||
feather: Gaussian-blur kernel size for soft polygon edges (forced odd).
|
||||
|
||||
Returns:
|
||||
Float32 HxW array in [0, 1]: ~0 in the background (full change),
|
||||
``preserve`` inside text regions, blended at the edges.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
change_map = np.zeros((height, width), np.float32)
|
||||
if boxes:
|
||||
polys = [np.asarray(b, np.int32) for b in boxes]
|
||||
cv2.fillPoly(change_map, polys, float(preserve))
|
||||
if feather > 0:
|
||||
if feather % 2 == 0:
|
||||
feather += 1
|
||||
change_map = cv2.GaussianBlur(change_map, (feather, feather), 0)
|
||||
# GaussianBlur can overshoot the painted value by a float epsilon; keep
|
||||
# the contract that the map stays a valid [0, 1] change map.
|
||||
np.clip(change_map, 0.0, 1.0, out=change_map)
|
||||
return change_map
|
||||
|
||||
|
||||
class TextProtector:
|
||||
"""Detect text regions with PP-OCRv3 for diffusion change-map protection."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
binary_threshold: float = 0.3,
|
||||
polygon_threshold: float = 0.5,
|
||||
max_candidates: int = 200,
|
||||
unclip_ratio: float = 2.0,
|
||||
) -> None:
|
||||
import cv2
|
||||
|
||||
self._detector = cv2.dnn.TextDetectionModel_DB(str(_model_path()))
|
||||
self._detector.setBinaryThreshold(binary_threshold)
|
||||
self._detector.setPolygonThreshold(polygon_threshold)
|
||||
self._detector.setMaxCandidates(max_candidates)
|
||||
self._detector.setUnclipRatio(unclip_ratio)
|
||||
|
||||
def detect_text_boxes(self, bgr_image: NDArray[Any]) -> list[NDArray[Any]]:
|
||||
"""Detect text regions, returning a list of rotated quad polygons.
|
||||
|
||||
Args:
|
||||
bgr_image: Image as an HxWx3 BGR uint8 array (OpenCV convention).
|
||||
|
||||
Returns:
|
||||
One array of four (x, y) vertices per detected text region.
|
||||
"""
|
||||
height, width = bgr_image.shape[:2]
|
||||
scale = _DET_INPUT_LONG_SIDE / max(height, width)
|
||||
in_w = max((round(width * scale) // 32) * 32, 32)
|
||||
in_h = max((round(height * scale) // 32) * 32, 32)
|
||||
self._detector.setInputParams(
|
||||
scale=_DET_SCALE,
|
||||
size=(in_w, in_h),
|
||||
mean=_DET_MEAN,
|
||||
swapRB=True,
|
||||
)
|
||||
boxes, _confidences = self._detector.detect(bgr_image)
|
||||
return list(boxes)
|
||||
+33
-3
@@ -27,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
|
||||
@@ -37,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
|
||||
|
||||
@@ -119,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"
|
||||
@@ -371,6 +375,14 @@ class TestIdentifyCommand:
|
||||
assert result.exit_code == 0
|
||||
assert "unknown" in result.output
|
||||
|
||||
def test_identify_unknown_explains_why(self, runner, tmp_clean_png):
|
||||
# An unknown verdict must explain itself inline (issue #22: users read a bare
|
||||
# "unknown" as the tool being broken) rather than only in the caveats section.
|
||||
result = runner.invoke(main, ["identify", str(tmp_clean_png), "--no-visible"])
|
||||
assert result.exit_code == 0
|
||||
assert "No locally-readable AI signal found" in result.output
|
||||
assert "not the same as 'clean'" 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
|
||||
@@ -505,3 +517,21 @@ class TestBatchCommand:
|
||||
assert result.exit_code == 0
|
||||
expected_dir = tmp_path / "input_clean"
|
||||
assert expected_dir.exists()
|
||||
|
||||
|
||||
class TestGpuHintMarkup:
|
||||
"""The GPU-extra install hint must survive rich markup (the ``[gpu]`` token
|
||||
is otherwise parsed as a style tag and silently dropped)."""
|
||||
|
||||
def test_invisible_install_hint_keeps_gpu_extra(self, runner, sample_png):
|
||||
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
|
||||
result = runner.invoke(main, ["invisible", str(sample_png)])
|
||||
assert result.exit_code != 0
|
||||
assert "remove-ai-watermarks[gpu]" in result.output
|
||||
|
||||
def test_all_install_hint_keeps_gpu_extra(self, runner, sample_png):
|
||||
# The `all` pipeline skips the invisible step with a warning that carries
|
||||
# the same hint; it must keep the [gpu] extra too.
|
||||
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
|
||||
result = runner.invoke(main, ["all", str(sample_png)])
|
||||
assert "remove-ai-watermarks[gpu]" in result.output
|
||||
@@ -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)
|
||||
@@ -17,7 +17,9 @@ from remove_ai_watermarks.identify import (
|
||||
ProvenanceReport,
|
||||
_ai_tools_in,
|
||||
_attribute_platform,
|
||||
_integrity_clashes,
|
||||
_issuers_in,
|
||||
_vendor_of,
|
||||
identify,
|
||||
)
|
||||
|
||||
@@ -199,6 +201,78 @@ class TestIdentifyLocalParams:
|
||||
assert r.signals == []
|
||||
|
||||
|
||||
# ── China TC260 AIGC label as a PNG text chunk (Doubao) ─────────────
|
||||
|
||||
|
||||
class TestIdentifyAigcPngChunk:
|
||||
"""The raw-JSON ``AIGC`` PNG chunk (no namespaced XMP marker) is a high-
|
||||
confidence AI verdict, same as the XMP form."""
|
||||
|
||||
def _aigc_chunk_png(self, tmp_path: Path) -> Path:
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
p = tmp_path / "doubao_chunk.png"
|
||||
pnginfo = PngInfo()
|
||||
pnginfo.add_text("AIGC", json.dumps({"Label": "1", "ContentProducer": "doubao"}))
|
||||
Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
|
||||
return p
|
||||
|
||||
def test_png_chunk_detected_high(self, tmp_path: Path):
|
||||
r = identify(self._aigc_chunk_png(tmp_path), check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "high"
|
||||
assert r.platform is not None
|
||||
assert "AIGC" in r.platform
|
||||
signal = next(s for s in r.signals if s.name == "aigc")
|
||||
assert "doubao" in signal.detail
|
||||
|
||||
|
||||
# ── HuggingFace-hosted job marker (medium confidence) ───────────────
|
||||
|
||||
|
||||
class TestIdentifyHuggingFaceJob:
|
||||
"""The hf-job-id chunk lifts an otherwise-Unknown verdict to a tentative
|
||||
(medium) AI, never overriding a high-confidence metadata signal."""
|
||||
|
||||
def _hf_png(self, tmp_path: Path) -> Path:
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
p = tmp_path / "hfjob.png"
|
||||
pnginfo = PngInfo()
|
||||
pnginfo.add_text("hf-job-id", "ec8380a6-2091-423a-b835-209420f99ee1")
|
||||
Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
|
||||
return p
|
||||
|
||||
def test_hf_job_promotes_to_medium(self, tmp_path: Path):
|
||||
r = identify(self._hf_png(tmp_path), check_visible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "medium"
|
||||
assert r.platform is not None
|
||||
assert "HuggingFace" in r.platform
|
||||
signal = next(s for s in r.signals if s.name == "hf_job")
|
||||
assert signal.confidence == "medium"
|
||||
|
||||
def test_hf_job_caveat_present(self, tmp_path: Path):
|
||||
r = identify(self._hf_png(tmp_path), check_visible=False)
|
||||
assert any("hf-job-id" in c for c in r.caveats)
|
||||
|
||||
def test_metadata_keeps_high_even_with_hf_job(self, tmp_png_with_ai_metadata: Path):
|
||||
# A high-confidence metadata verdict is not downgraded by an hf-job hit.
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
img = Image.open(tmp_png_with_ai_metadata)
|
||||
pnginfo = PngInfo()
|
||||
for k, v in img.text.items():
|
||||
pnginfo.add_text(k, v)
|
||||
pnginfo.add_text("hf-job-id", "ec8380a6-2091-423a-b835-209420f99ee1")
|
||||
img.save(tmp_png_with_ai_metadata, pnginfo=pnginfo)
|
||||
r = identify(tmp_png_with_ai_metadata, check_visible=False)
|
||||
assert r.confidence == "high"
|
||||
|
||||
|
||||
# ── Visible-sparkle fallback (mocked detector) ──────────────────────
|
||||
|
||||
|
||||
@@ -369,6 +443,18 @@ class TestIdentifyC2paDevice:
|
||||
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 ─────────────
|
||||
|
||||
@@ -435,3 +521,101 @@ class TestIdentifyAIGC:
|
||||
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
|
||||
|
||||
|
||||
# ── Integrity clashes (contradictions between independent signals) ──────
|
||||
|
||||
|
||||
class TestVendorOf:
|
||||
def test_openai_variants(self):
|
||||
assert _vendor_of("OpenAI (ChatGPT / gpt-image / DALL-E / Sora)") == "OpenAI"
|
||||
assert _vendor_of("DALL-E 3") == "OpenAI"
|
||||
|
||||
def test_google_variants(self):
|
||||
assert _vendor_of("Google (Gemini / Imagen)") == "Google"
|
||||
assert _vendor_of("Imagen 3") == "Google"
|
||||
|
||||
def test_other_vendors(self):
|
||||
assert _vendor_of("Ideogram AI") == "Ideogram"
|
||||
assert _vendor_of("Adobe Firefly") == "Adobe"
|
||||
assert _vendor_of("Stability AI (Stable Image)") == "Stability AI"
|
||||
|
||||
def test_camera_label_is_not_an_ai_vendor(self):
|
||||
# Camera platform labels must NOT normalize to an AI vendor, or a camera
|
||||
# capture would be mistaken for AI-generation in clash detection.
|
||||
assert _vendor_of("Leica (camera, C2PA capture)") is None
|
||||
|
||||
def test_unknown_is_none(self):
|
||||
assert _vendor_of("a regular photo") is None
|
||||
assert _vendor_of(None) is None
|
||||
|
||||
|
||||
class TestIntegrityClashesHelper:
|
||||
def test_two_ai_vendors_clash(self):
|
||||
clashes = _integrity_clashes({"c2pa": "OpenAI", "exif_generator": "Ideogram"}, None, camera_has_ai_marker=True)
|
||||
assert len(clashes) == 1
|
||||
assert "OpenAI" in clashes[0]
|
||||
assert "Ideogram" in clashes[0]
|
||||
|
||||
def test_same_vendor_two_signals_no_clash(self):
|
||||
# C2PA Google + SynthID-Google proxy is consistent, not a contradiction.
|
||||
assert _integrity_clashes({"c2pa": "Google", "synthid": "Google"}, None, camera_has_ai_marker=True) == []
|
||||
|
||||
def test_single_vendor_no_clash(self):
|
||||
assert _integrity_clashes({"c2pa": "OpenAI"}, None, camera_has_ai_marker=True) == []
|
||||
|
||||
def test_empty_no_clash(self):
|
||||
assert _integrity_clashes({}, None, camera_has_ai_marker=False) == []
|
||||
|
||||
def test_camera_plus_ai_marker_clashes(self):
|
||||
clashes = _integrity_clashes(
|
||||
{"exif_generator": "Ideogram"},
|
||||
"Google Pixel (camera, C2PA capture)",
|
||||
camera_has_ai_marker=True,
|
||||
)
|
||||
assert any("Camera-capture" in c and "Pixel" in c for c in clashes)
|
||||
|
||||
def test_camera_without_ai_marker_no_clash(self):
|
||||
# A clean camera capture (the normal case for our Pixel/Leica/Sony files)
|
||||
# must NOT raise a clash.
|
||||
assert _integrity_clashes({}, "Leica (camera, C2PA capture)", camera_has_ai_marker=False) == []
|
||||
|
||||
|
||||
class TestIntegrityClashEndToEnd:
|
||||
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_two_generator_stamps_clash(self, tmp_path: Path):
|
||||
# An OpenAI C2PA manifest (AI source) on an image that ALSO carries a
|
||||
# China TC260 AIGC label = two independent generator stamps naming
|
||||
# different origins -> a laundering tell.
|
||||
path = self._c2pa_jpeg(tmp_path, b"OpenAI ... trainedAlgorithmicMedia ... TC260:AIGC label")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.integrity_clashes
|
||||
assert any("Conflicting AI-origin" in c for c in r.integrity_clashes)
|
||||
|
||||
def test_single_stamp_no_clash(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"OpenAI ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.integrity_clashes == []
|
||||
|
||||
def test_clash_serializes_to_json(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"OpenAI ... trainedAlgorithmicMedia ... TC260:AIGC label")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
payload = json.loads(json.dumps(asdict(r), default=str))
|
||||
assert payload["integrity_clashes"] == r.integrity_clashes
|
||||
|
||||
|
||||
@pytest.mark.skipif(not SAMPLES_DIR.exists(), reason="data/samples not present")
|
||||
@pytest.mark.parametrize("fixture", ["chatgpt-1.png", "firefly-1.png", "doubao-1.png", "grok-1.jpg", "mj-1.png"])
|
||||
class TestRealSamplesHaveNoClash:
|
||||
"""Every real single-origin fixture must report zero clashes (false-positive guard)."""
|
||||
|
||||
def test_no_false_positive_clash(self, fixture: str):
|
||||
path = SAMPLES_DIR / fixture
|
||||
if not path.exists():
|
||||
pytest.skip(f"{fixture} not present")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.integrity_clashes == []
|
||||
@@ -0,0 +1,74 @@
|
||||
"""Tests for Unicode-safe cv2 image IO (issue #17)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
# Non-ASCII filenames that break cv2.imread/imwrite on Windows (issue #17).
|
||||
_UNICODE_NAMES = [
|
||||
"jimeng-2026-05-27-一面白色的墙.png", # Chinese
|
||||
"тест-изображение.png", # Cyrillic
|
||||
"café-señor.png", # accented Latin
|
||||
]
|
||||
|
||||
|
||||
def _make_bgr() -> np.ndarray:
|
||||
img = np.zeros((8, 8, 3), dtype=np.uint8)
|
||||
img[2:6, 2:6] = (10, 120, 240) # a BGR block so the round-trip is checkable
|
||||
return img
|
||||
|
||||
|
||||
class TestUnicodeRoundTrip:
|
||||
def test_write_then_read_preserves_pixels(self, tmp_path: Path) -> None:
|
||||
for name in _UNICODE_NAMES:
|
||||
path = tmp_path / name
|
||||
src = _make_bgr()
|
||||
assert image_io.imwrite(path, src) is True
|
||||
assert path.exists()
|
||||
out = image_io.imread(path)
|
||||
assert out is not None
|
||||
# PNG is lossless: pixels must match exactly.
|
||||
assert np.array_equal(out, src)
|
||||
|
||||
def test_alpha_round_trip_with_unchanged_flag(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "豆包-alpha.png"
|
||||
bgra = np.zeros((8, 8, 4), dtype=np.uint8)
|
||||
bgra[..., 3] = 128
|
||||
assert image_io.imwrite(path, bgra) is True
|
||||
out = image_io.imread(path, cv2.IMREAD_UNCHANGED)
|
||||
assert out is not None
|
||||
assert out.shape[2] == 4
|
||||
assert np.array_equal(out, bgra)
|
||||
|
||||
def test_reads_file_written_by_raw_cv2(self, tmp_path: Path) -> None:
|
||||
# An ASCII file written by plain cv2 must read back identically through
|
||||
# the wrapper (decode path is byte-compatible with cv2.imread).
|
||||
path = tmp_path / "ascii.png"
|
||||
src = _make_bgr()
|
||||
cv2.imwrite(str(path), src)
|
||||
out = image_io.imread(path)
|
||||
assert out is not None
|
||||
assert np.array_equal(out, src)
|
||||
|
||||
|
||||
class TestFailureSemantics:
|
||||
def test_missing_file_returns_none(self, tmp_path: Path) -> None:
|
||||
assert image_io.imread(tmp_path / "does-not-exist-不存在.png") is None
|
||||
|
||||
def test_empty_file_returns_none(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "empty.png"
|
||||
path.write_bytes(b"")
|
||||
assert image_io.imread(path) is None
|
||||
|
||||
def test_undecodable_file_returns_none(self, tmp_path: Path) -> None:
|
||||
path = tmp_path / "garbage.png"
|
||||
path.write_bytes(b"not an image")
|
||||
assert image_io.imread(path) is None
|
||||
@@ -14,7 +14,11 @@ 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
|
||||
from remove_ai_watermarks.noai.img2img_runner import (
|
||||
run_differential_with_mps_fallback,
|
||||
run_img2img,
|
||||
run_img2img_with_mps_fallback,
|
||||
)
|
||||
|
||||
_MPS_OOM = "MPS backend out of memory (MPS allocated: 17.21 GiB, max allowed: 20.13 GiB)"
|
||||
|
||||
@@ -106,6 +110,80 @@ class TestMpsFallback:
|
||||
reload_on_cpu.assert_not_called()
|
||||
|
||||
|
||||
class TestDifferentialMpsFallback:
|
||||
"""The protect-text (Differential Diffusion) path shares the MPS->CPU
|
||||
fallback contract; mock ``run_differential`` so no torch/model is needed."""
|
||||
|
||||
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_differential", inner)
|
||||
reload_on_cpu = Mock(return_value="cpu_pipe")
|
||||
|
||||
img, device = run_differential_with_mps_fallback(
|
||||
load_pipeline=Mock(return_value="gpu_pipe"),
|
||||
image=object(),
|
||||
change_map=object(),
|
||||
strength=0.05,
|
||||
num_inference_steps=50,
|
||||
guidance_scale=7.5,
|
||||
generator="gen",
|
||||
device="mps",
|
||||
set_progress=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 uses the reloaded CPU pipeline, device "cpu", and drops the MPS
|
||||
# generator (generator=None) for deterministic CPU execution.
|
||||
retry_args = inner.call_args_list[1].args
|
||||
assert retry_args[0] == "cpu_pipe"
|
||||
assert retry_args[6] is None # generator
|
||||
assert retry_args[7] == "cpu" # device
|
||||
|
||||
def test_happy_path_returns_original_device_without_reload(self, monkeypatch: pytest.MonkeyPatch):
|
||||
sentinel = object()
|
||||
monkeypatch.setattr(img2img_runner, "run_differential", Mock(return_value=sentinel))
|
||||
reload_on_cpu = Mock()
|
||||
|
||||
img, device = run_differential_with_mps_fallback(
|
||||
load_pipeline=Mock(return_value="gpu_pipe"),
|
||||
image=object(),
|
||||
change_map=object(),
|
||||
strength=0.05,
|
||||
num_inference_steps=50,
|
||||
guidance_scale=7.5,
|
||||
generator="gen",
|
||||
device="mps",
|
||||
set_progress=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_differential", Mock(side_effect=RuntimeError("CUDA out of memory")))
|
||||
reload_on_cpu = Mock()
|
||||
|
||||
with pytest.raises(RuntimeError, match="CUDA"):
|
||||
run_differential_with_mps_fallback(
|
||||
load_pipeline=Mock(return_value="gpu_pipe"),
|
||||
image=object(),
|
||||
change_map=object(),
|
||||
strength=0.05,
|
||||
num_inference_steps=50,
|
||||
guidance_scale=7.5,
|
||||
generator="gen",
|
||||
device="mps",
|
||||
set_progress=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()
|
||||
|
||||
+279
-3
@@ -2,6 +2,8 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import piexif
|
||||
@@ -320,6 +322,44 @@ class TestRemoveAiMetadata:
|
||||
assert result == jpg_path
|
||||
assert jpg_path.exists()
|
||||
|
||||
def test_jpeg_output_is_high_quality(self, tmp_path):
|
||||
"""JPEG output uses high quality + 4:4:4 (no chroma subsampling), not the
|
||||
lossy PIL defaults (quality 75, 4:2:0) that visibly degrade the image."""
|
||||
from PIL.JpegImagePlugin import get_sampling
|
||||
|
||||
img = Image.new("RGB", (64, 64), color=(100, 150, 200))
|
||||
png_path = tmp_path / "source.png"
|
||||
img.save(png_path)
|
||||
|
||||
jpg_path = tmp_path / "output.jpg"
|
||||
remove_ai_metadata(png_path, jpg_path)
|
||||
|
||||
with Image.open(jpg_path) as out:
|
||||
assert get_sampling(out) == 0 # 4:4:4, no chroma subsampling
|
||||
# quality 95 quantization tables stay well below the q75 defaults
|
||||
# (whose max quant value is ~40+); q95 tops out around 12.
|
||||
assert max(max(t) for t in out.quantization.values()) <= 15
|
||||
|
||||
def test_webp_output_preserves_format_losslessly(self, tmp_path):
|
||||
"""A .webp output keeps the WebP format (not silently rewritten to PNG)
|
||||
and is pixel-identical to the source (lossless)."""
|
||||
import numpy as np
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
arr = rng.integers(0, 255, (48, 48, 3), dtype=np.uint8)
|
||||
src = Image.fromarray(arr, "RGB")
|
||||
pnginfo = PngInfo()
|
||||
pnginfo.add_text("parameters", "ai stuff")
|
||||
png_path = tmp_path / "source.png"
|
||||
src.save(png_path, pnginfo=pnginfo)
|
||||
|
||||
webp_path = tmp_path / "output.webp"
|
||||
remove_ai_metadata(png_path, webp_path)
|
||||
|
||||
with Image.open(webp_path) as out:
|
||||
assert out.format == "WEBP"
|
||||
assert np.array_equal(np.asarray(out.convert("RGB")), arr)
|
||||
|
||||
def test_creates_parent_directories(self, tmp_path):
|
||||
img = Image.new("RGB", (32, 32))
|
||||
pnginfo = PngInfo()
|
||||
@@ -552,6 +592,88 @@ class TestAIGCLabel:
|
||||
assert "aigc_label" in meta
|
||||
assert "TC260" in meta["aigc_label"]
|
||||
|
||||
def _aigc_chunk_png(self, tmp_path: Path, producer: str = "doubao") -> Path:
|
||||
"""Doubao writes the TC260 object as a PNG ``tEXt`` chunk keyed ``AIGC``
|
||||
with raw JSON (no XMP, no namespaced marker)."""
|
||||
import json
|
||||
|
||||
p = tmp_path / "doubao_chunk.png"
|
||||
pnginfo = PngInfo()
|
||||
pnginfo.add_text(
|
||||
"AIGC",
|
||||
json.dumps({"Label": "1", "ContentProducer": producer, "ProduceID": "abc123"}),
|
||||
)
|
||||
Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
|
||||
return p
|
||||
|
||||
def test_parses_png_text_chunk_form(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
info = aigc_label(self._aigc_chunk_png(tmp_path))
|
||||
assert info is not None
|
||||
assert info["Label"] == "1"
|
||||
assert info["ContentProducer"] == "doubao"
|
||||
|
||||
def test_png_chunk_without_tc260_field_ignored(self, tmp_path: Path):
|
||||
"""A generic ``AIGC`` chunk with no TC260 field must not false-positive."""
|
||||
import json
|
||||
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
p = tmp_path / "unrelated.png"
|
||||
pnginfo = PngInfo()
|
||||
pnginfo.add_text("AIGC", json.dumps({"unrelated": "value"}))
|
||||
Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
|
||||
assert aigc_label(p) is None
|
||||
|
||||
def test_has_ai_metadata_detects_png_chunk_form(self, tmp_path: Path):
|
||||
assert has_ai_metadata(self._aigc_chunk_png(tmp_path))
|
||||
|
||||
def test_remove_strips_png_chunk_form(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata
|
||||
|
||||
out = tmp_path / "clean.png"
|
||||
remove_ai_metadata(self._aigc_chunk_png(tmp_path), out)
|
||||
assert aigc_label(out) is None
|
||||
assert not has_ai_metadata(out)
|
||||
|
||||
|
||||
class TestHuggingFaceJob:
|
||||
"""HuggingFace-hosted job marker (``hf-job-id`` PNG text chunk)."""
|
||||
|
||||
def _hf_png(self, tmp_path: Path, job_id: str = "ec8380a6-2091-423a-b835-209420f99ee1") -> Path:
|
||||
p = tmp_path / "hfjob.png"
|
||||
pnginfo = PngInfo()
|
||||
pnginfo.add_text("hf-job-id", job_id)
|
||||
Image.new("RGB", (32, 32)).save(p, pnginfo=pnginfo)
|
||||
return p
|
||||
|
||||
def test_returns_job_id(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import huggingface_job
|
||||
|
||||
assert huggingface_job(self._hf_png(tmp_path)) == "ec8380a6-2091-423a-b835-209420f99ee1"
|
||||
|
||||
def test_none_when_absent(self, tmp_clean_png):
|
||||
from remove_ai_watermarks.metadata import huggingface_job
|
||||
|
||||
assert huggingface_job(tmp_clean_png) is None
|
||||
|
||||
def test_has_ai_metadata_detects_hf_job(self, tmp_path: Path):
|
||||
assert has_ai_metadata(self._hf_png(tmp_path))
|
||||
|
||||
def test_get_ai_metadata_surfaces_hf_job(self, tmp_path: Path):
|
||||
meta = get_ai_metadata(self._hf_png(tmp_path))
|
||||
assert "huggingface_job" in meta
|
||||
assert "ec8380a6" in meta["huggingface_job"]
|
||||
|
||||
def test_remove_strips_hf_job(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import huggingface_job, remove_ai_metadata
|
||||
|
||||
out = tmp_path / "clean.png"
|
||||
remove_ai_metadata(self._hf_png(tmp_path), out)
|
||||
assert huggingface_job(out) is None
|
||||
assert not has_ai_metadata(out)
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (SAMPLES_DIR / "doubao-1.png").exists(), reason="doubao sample not present")
|
||||
class TestAIGCRealSample:
|
||||
@@ -629,6 +751,11 @@ _MP4_FTYP = b"\x00\x00\x00\x18ftypmp42\x00\x00\x00\x00mp42isom"
|
||||
_MP4_MDAT = b"\x00\x00\x00\x10mdat" + b"videodat"
|
||||
|
||||
|
||||
def _box(box_type: bytes, payload: bytes) -> bytes:
|
||||
"""Build a 32-bit-size ISOBMFF box: [size:4][type:4][payload]."""
|
||||
return (8 + len(payload)).to_bytes(4, "big") + box_type + payload
|
||||
|
||||
|
||||
class TestVideoC2pa:
|
||||
"""C2PA in MP4 (ISOBMFF) -- detect + strip, reusing the image box walker."""
|
||||
|
||||
@@ -652,6 +779,121 @@ class TestVideoC2pa:
|
||||
assert has_ai_metadata(out) is False
|
||||
|
||||
|
||||
class TestLateProvenanceBox:
|
||||
"""A C2PA / provenance box placed AFTER a large mdat (streaming / non-faststart
|
||||
MP4) must still be detected -- the fixed first-MB scan would miss it."""
|
||||
|
||||
def _mp4_late_c2pa(self, tmp_path: Path, gap: int = 1_500_000) -> Path:
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
|
||||
big_mdat = _box(b"mdat", b"\x00" * gap) # > 1 MB pushes the manifest past the scan window
|
||||
manifest = C2PA_UUID + b"OpenAI jumbf c2pa ... trainedAlgorithmicMedia ..."
|
||||
p = tmp_path / "stream.mp4"
|
||||
p.write_bytes(_MP4_FTYP + big_mdat + _box(b"uuid", manifest))
|
||||
return p
|
||||
|
||||
def test_scan_c2pa_region_finds_late_box(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
from remove_ai_watermarks.noai.isobmff import scan_c2pa_region
|
||||
|
||||
region = scan_c2pa_region(self._mp4_late_c2pa(tmp_path))
|
||||
assert C2PA_UUID in region
|
||||
assert b"trainedAlgorithmicMedia" in region
|
||||
|
||||
def test_fixed_window_would_have_missed_it(self, tmp_path: Path):
|
||||
# Documents the regression the box walk fixes: the manifest is beyond 1 MB.
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
|
||||
p = self._mp4_late_c2pa(tmp_path)
|
||||
assert C2PA_UUID not in p.read_bytes()[: 1024 * 1024]
|
||||
|
||||
def test_scan_head_includes_late_box(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID, scan_head
|
||||
|
||||
assert C2PA_UUID in scan_head(self._mp4_late_c2pa(tmp_path))
|
||||
|
||||
def test_has_ai_metadata_detects_late_manifest(self, tmp_path: Path):
|
||||
assert has_ai_metadata(self._mp4_late_c2pa(tmp_path)) is True
|
||||
|
||||
def test_scan_c2pa_region_non_isobmff_is_empty(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.noai.isobmff import scan_c2pa_region
|
||||
|
||||
p = tmp_path / "not.bin"
|
||||
p.write_bytes(b"\x89PNG\r\n\x1a\n not an isobmff file")
|
||||
assert scan_c2pa_region(p) == b""
|
||||
|
||||
def test_front_placed_manifest_still_detected(self, tmp_path: Path):
|
||||
# Regression: a faststart MP4 (manifest before mdat) is unaffected.
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
|
||||
manifest = C2PA_UUID + b"OpenAI ... trainedAlgorithmicMedia ..."
|
||||
p = tmp_path / "front.mp4"
|
||||
p.write_bytes(_MP4_FTYP + _box(b"uuid", manifest) + _box(b"mdat", b"\x00" * 100))
|
||||
assert has_ai_metadata(p) is True
|
||||
|
||||
|
||||
_AI_XMP = (
|
||||
b'<?xpacket begin="\xef\xbb\xbf" id="W5M0MpCehiHzreSzNTczkc9d"?>'
|
||||
b'<x:xmpmeta><TC260:AIGC>{"Label":"1"}</TC260:AIGC></x:xmpmeta>'
|
||||
b'<?xpacket end="w"?>'
|
||||
)
|
||||
_PLAIN_XMP = (
|
||||
b'<?xpacket begin="" id="W5M0MpCehiHzreSzNTczkc9d"?>'
|
||||
b"<x:xmpmeta><dc:rights>(c) me</dc:rights></x:xmpmeta>"
|
||||
b'<?xpacket end="w"?>'
|
||||
)
|
||||
|
||||
|
||||
class TestMetaBoxXmpBlanking:
|
||||
"""HEIF/AVIF store XMP as a meta-box ``mime`` item (bytes in mdat/idat), out of
|
||||
reach of the top-level box stripper. An AI-label XMP packet there is blanked
|
||||
in place (same length -> iloc offsets and image data stay intact)."""
|
||||
|
||||
def test_blanks_ai_packet_only(self):
|
||||
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
|
||||
|
||||
before, after = b"IMG_BEFORE" * 4, b"IMG_AFTER" * 4
|
||||
data = before + _AI_XMP + after + _PLAIN_XMP
|
||||
out, n = blank_ai_xmp_packets(data)
|
||||
assert n == 1
|
||||
assert len(out) == len(data) # same length -> no offset shifts
|
||||
assert b"TC260:AIGC" not in out # AI label destroyed
|
||||
assert before in out # surrounding (image) bytes intact
|
||||
assert after in out
|
||||
assert b"dc:rights" in out # plain XMP left alone
|
||||
|
||||
def test_no_packet_is_noop(self):
|
||||
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
|
||||
|
||||
data = b"just some mdat bytes, no xmp here"
|
||||
assert blank_ai_xmp_packets(data) == (data, 0)
|
||||
|
||||
def test_plain_xmp_untouched(self):
|
||||
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
|
||||
|
||||
out, n = blank_ai_xmp_packets(_PLAIN_XMP)
|
||||
assert n == 0
|
||||
assert out == _PLAIN_XMP
|
||||
|
||||
def test_remove_ai_metadata_blanks_meta_box_xmp(self, tmp_path: Path):
|
||||
# End-to-end: a HEIF with an AI XMP packet inside mdat is cleaned without
|
||||
# touching the surrounding (coded image) bytes or the file length.
|
||||
heic_ftyp = b"\x00\x00\x00\x18ftypheic\x00\x00\x00\x00heicmif1"
|
||||
img = b"CODEDIMAGE" * 8
|
||||
mdat = _box(b"mdat", img + _AI_XMP + img)
|
||||
src = tmp_path / "ai.heic"
|
||||
src.write_bytes(heic_ftyp + mdat)
|
||||
assert has_ai_metadata(src) is True
|
||||
|
||||
out = tmp_path / "clean.heic"
|
||||
remove_ai_metadata(src, out)
|
||||
res = out.read_bytes()
|
||||
assert len(res) == src.stat().st_size # length preserved
|
||||
assert b"TC260:AIGC" not in res
|
||||
assert img in res # coded image bytes intact
|
||||
assert has_ai_metadata(out) is False
|
||||
|
||||
|
||||
class TestIsobmffMetadataRemoval:
|
||||
"""Container-level AI-provenance stripping across ISOBMFF image/video/audio."""
|
||||
|
||||
@@ -699,9 +941,43 @@ class TestIsobmffMetadataRemoval:
|
||||
remove_ai_metadata(src, out)
|
||||
assert out.read_bytes() == _MP4_FTYP + _MP4_MDAT
|
||||
|
||||
def test_unsupported_container_raises(self, tmp_path: Path):
|
||||
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 fake mp3 frames")
|
||||
src.write_bytes(b"ID3\x04\x00\x00\x00\x00\x00\x00 not real mp3 frames")
|
||||
out = tmp_path / "out.mp3"
|
||||
with pytest.raises(ValueError, match="not supported"):
|
||||
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
|
||||
@@ -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,59 @@
|
||||
"""Unit tests for the text-protection change-map helper (no model download).
|
||||
|
||||
``build_change_map`` is the pure cv2/numpy part of ``text_protector``: it turns
|
||||
detected text polygons into a Differential-Diffusion change map. The polarity is
|
||||
load-bearing and was verified empirically (white = preserve, black = change), so
|
||||
a regression here would either freeze the whole image or fail to protect text.
|
||||
The PP-OCRv3 detector itself needs a model download and is not exercised here.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.text_protector import build_change_map
|
||||
|
||||
|
||||
class TestBuildChangeMap:
|
||||
def test_no_boxes_is_all_change(self):
|
||||
m = build_change_map([], 32, 48)
|
||||
assert m.shape == (32, 48)
|
||||
assert m.dtype == np.float32
|
||||
assert float(m.max()) == 0.0
|
||||
|
||||
def test_text_region_is_preserved_background_is_change(self):
|
||||
# A 20x20 box centered in a 64x64 map, no feather for a crisp check.
|
||||
box = np.array([[22, 22], [42, 22], [42, 42], [22, 42]])
|
||||
m = build_change_map([box], 64, 64, preserve=0.9, feather=0)
|
||||
# Inside the polygon: painted to preserve value.
|
||||
assert m[32, 32] == np.float32(0.9)
|
||||
# Far background: untouched -> full change (0.0).
|
||||
assert m[2, 2] == 0.0
|
||||
# Polarity: text preserved more than background.
|
||||
assert m[32, 32] > m[2, 2]
|
||||
|
||||
def test_preserve_value_is_respected(self):
|
||||
box = np.array([[10, 10], [30, 10], [30, 30], [10, 30]])
|
||||
m = build_change_map([box], 40, 40, preserve=0.5, feather=0)
|
||||
assert m[20, 20] == np.float32(0.5)
|
||||
|
||||
def test_feather_creates_soft_edge_gradient(self):
|
||||
box = np.array([[20, 20], [44, 20], [44, 44], [20, 44]])
|
||||
m = build_change_map([box], 64, 64, preserve=1.0, feather=15)
|
||||
center = m[32, 32]
|
||||
# An edge pixel just outside the polygon should be partially blended:
|
||||
# strictly between full-change (0) and the preserved center.
|
||||
edge = m[32, 47]
|
||||
assert 0.0 < edge < center
|
||||
assert center <= 1.0
|
||||
|
||||
def test_even_feather_does_not_crash(self):
|
||||
box = np.array([[10, 10], [30, 10], [30, 30], [10, 30]])
|
||||
m = build_change_map([box], 40, 40, feather=14)
|
||||
assert m.shape == (40, 40)
|
||||
|
||||
def test_values_stay_in_unit_range(self):
|
||||
box = np.array([[5, 5], [35, 5], [35, 35], [5, 35]])
|
||||
m = build_change_map([box], 40, 40, preserve=1.0, feather=9)
|
||||
assert float(m.min()) >= 0.0
|
||||
assert float(m.max()) <= 1.0
|
||||
@@ -0,0 +1,23 @@
|
||||
"""Minimal local type stub for the untyped ``piexif`` package.
|
||||
|
||||
Covers only the API surface used in this repo (``load``/``dump`` plus the
|
||||
``ImageIFD`` tag ids), so strict pyright resolves the otherwise-Unknown values
|
||||
that ``piexif.load`` returns instead of carrying type debt. piexif ships no
|
||||
``py.typed`` marker; extend this stub if new piexif APIs are adopted.
|
||||
"""
|
||||
|
||||
from typing import Any
|
||||
|
||||
class ImageIFD:
|
||||
Software: int
|
||||
Make: int
|
||||
Artist: int
|
||||
ImageDescription: int
|
||||
|
||||
class ExifIFD: ...
|
||||
class GPSIFD: ...
|
||||
|
||||
def load(input_data: bytes | str, key_is_name: bool = ...) -> dict[str, Any]: ...
|
||||
def dump(exif_dict_original: dict[str, Any]) -> bytes: ...
|
||||
def insert(exif: bytes, image: str, new_file: str | None = ...) -> None: ...
|
||||
def remove(src: str, new_file: str | None = ...) -> None: ...
|
||||
@@ -1099,7 +1099,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "huggingface-hub"
|
||||
version = "1.16.4"
|
||||
version = "1.17.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -1113,9 +1113,9 @@ dependencies = [
|
||||
{ name = "typer" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/ab/11/9b6e439cb2417c479c3da108b38363232a1554721de9f8ef4836cb07422b/huggingface_hub-1.16.4.tar.gz", hash = "sha256:023bacd155f837d3fa56379ac8e23dababe6d6d87b04f8dacc258a44a38abe01", size = 792585, upload-time = "2026-05-26T17:19:09.971Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/bd/65/9826515abb600b5722bcf53f8b4a2fb58340b1f8bfcaee19f83561c13a44/huggingface_hub-1.17.0.tar.gz", hash = "sha256:fad842b6763ef70ebc3919665b1b9273645203185400a7d6c5eddc2323cc3435", size = 797082, upload-time = "2026-05-28T15:12:13.347Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/da/86/e05d58ea272089151ba9f6fcc7b44a97aa2533d5a5bce46611220c23c6d6/huggingface_hub-1.16.4-py3-none-any.whl", hash = "sha256:994ec184c3330952d7b5f131ea0b1a6ba1047bd05461f5dec191f8fc1099fbd7", size = 668190, upload-time = "2026-05-26T17:19:08.228Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/02/28/d7cef5e477b855c25d415b8f57e5bc7347c7a90cad3acf1725d0c92ca294/huggingface_hub-1.17.0-py3-none-any.whl", hash = "sha256:3b8156d23118e87f6a587648bfbc04f04a12a757ccb4ed298b35c4ae638bf24c", size = 671546, upload-time = "2026-05-28T15:12:11.441Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2865,7 +2865,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.6.3"
|
||||
version = "0.6.12"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -2892,6 +2892,7 @@ all = [
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
{ name = "safetensors" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "torch" },
|
||||
{ name = "transformers" },
|
||||
{ name = "trustmark" },
|
||||
@@ -2913,6 +2914,7 @@ gpu = [
|
||||
{ name = "controlnet-aux" },
|
||||
{ name = "diffusers" },
|
||||
{ name = "safetensors" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "torch" },
|
||||
{ name = "transformers" },
|
||||
{ name = "ultralytics" },
|
||||
@@ -2949,8 +2951,9 @@ requires-dist = [
|
||||
{ name = "rich", specifier = ">=13.0.0" },
|
||||
{ name = "ruff", marker = "extra == 'dev'", specifier = ">=0.4.0" },
|
||||
{ name = "safetensors", marker = "extra == 'gpu'" },
|
||||
{ name = "tokenizers", marker = "extra == 'gpu'", specifier = ">=0.22,<0.23" },
|
||||
{ name = "torch", marker = "extra == 'gpu'", specifier = ">=2.0.0" },
|
||||
{ name = "transformers", marker = "extra == 'gpu'", specifier = ">=4.35.0" },
|
||||
{ name = "transformers", marker = "extra == 'gpu'", specifier = ">=5,<6" },
|
||||
{ name = "trustmark", marker = "extra == 'trustmark'", specifier = ">=0.8.0" },
|
||||
{ name = "ultralytics", marker = "extra == 'gpu'", specifier = ">=8.0.0" },
|
||||
]
|
||||
@@ -3394,29 +3397,32 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "tokenizers"
|
||||
version = "0.23.0rc0"
|
||||
version = "0.22.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "huggingface-hub" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/0b/dc/2ba78324f6c82284f8d3d03bba16e5771d075aa4d5e9b4ecbd87af846af2/tokenizers-0.23.0rc0.tar.gz", hash = "sha256:685c6d269444451a2cf276d3f2bf655f3d7094be20c6553e413ede86b03c637b", size = 361629, upload-time = "2026-04-24T05:37:42.81Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/73/6f/f80cfef4a312e1fb34baf7d85c72d4411afde10978d4657f8cdd811d3ccc/tokenizers-0.22.2.tar.gz", hash = "sha256:473b83b915e547aa366d1eee11806deaf419e17be16310ac0a14077f1e28f917", size = 372115, upload-time = "2026-01-05T10:45:15.988Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/7f/b9/dda4065e0f4b62e0e5a625cbaeb928a611d847171e059066b3adfdb3866f/tokenizers-0.23.0rc0-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:bed69208ba6f74057e18e3c8ed73d62e681ff44f7be642ddeff747247c8a7a98", size = 3134709, upload-time = "2026-04-24T05:37:31.89Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fa/16/54bd9f9e5c3641fe3d6d0e5b1cee37c58cb7520d22752c2065fc5a83caff/tokenizers-0.23.0rc0-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:951be943c0657d8fd12e104731165a56d995c87533cd7f70a9444ddd7afa7708", size = 3043651, upload-time = "2026-04-24T05:37:30.305Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/86/11/54c1040ee93c8d74a364fbf4e17fd5d88e2eea940cbdba69d48d42a5a0c0/tokenizers-0.23.0rc0-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:704ffd50130f6c85aa76ad16c8218ff0f966b14c6e6cab7d0636e492e487ffa5", size = 3365683, upload-time = "2026-04-24T05:37:18.674Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/14/79/c8a7bdfee971346119349dab62f9918de512a7e5a8177555eaa50d854e1f/tokenizers-0.23.0rc0-cp310-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:bcd2a49117ad88999bc5d18d05addf67ec28e69f53e609ab07733c1f96404583", size = 3228688, upload-time = "2026-04-24T05:37:21.137Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e1/32/a46ab1348d0b573dab69860eee601927b9934323e40f6f6018bb362a6013/tokenizers-0.23.0rc0-cp310-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:c52f927516521a3e1f6b6347f8bacedaf589eadd682e7ac87dac911d832c3a73", size = 3565137, upload-time = "2026-04-24T05:37:27.101Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/9c/f1/1a3b6a30388fe7d4b57b1ea7fcd6192341e479d65e50366ee0ba13d96d14/tokenizers-0.23.0rc0-cp310-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1d6add82746146a6e052295ac429949c2d8e723244aa97ffe30cfee6cd788e98", size = 3826198, upload-time = "2026-04-24T05:37:22.783Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/a4/cb/161e52a424aa7ffb4097e8ce343d8dc2bdc42d590601032d4a9e6e5f7da5/tokenizers-0.23.0rc0-cp310-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:564115d3d6d2560b0a6b833d7dc39330d2328262557fbbd5bb0a14fb09b2b6cb", size = 3449011, upload-time = "2026-04-24T05:37:25.324Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/ff/31/0e4b77ca48b302a5db827584c9784f6cdbb35380c0dd1d7668712d477bb5/tokenizers-0.23.0rc0-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:82167864c62a3d83880ed23dea267aa5760e3fcf16fd73f94d413baf1968b211", size = 3337931, upload-time = "2026-04-24T05:37:28.723Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/50/e4/939249edee0073417b2c9447fd3b06e90c283ef6df72f3124427edae1f96/tokenizers-0.23.0rc0-cp310-abi3-manylinux_2_31_riscv64.whl", hash = "sha256:85f29751c4490bfaefe7e0d4b18ef28cd6d5f84c411e88ca896832eb4f18dd69", size = 3416560, upload-time = "2026-04-24T05:37:24.091Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/48/3a4bd2ba88af778e6fa6d03e271b2bc868f495745c8be91616781bf460d9/tokenizers-0.23.0rc0-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:f82b7578eaad0cbb72765d1fbaa7e7bc04c531337513a21f437b73e4617fcf46", size = 9810112, upload-time = "2026-04-24T05:37:33.679Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/45/8a/70c9919aefc7f514d6e98fb9be379b2850ca071a841d88900278781a07b0/tokenizers-0.23.0rc0-cp310-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:e61dff90a4ad8dc7e7e124d67756d63cf3ae57e32f04fb35bb408af91f47ea70", size = 9631038, upload-time = "2026-04-24T05:37:36.207Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/f9/f6/c15a5514f50bf953b70d3d2b7fd1829aa327ba8c9c519c54623510d6f459/tokenizers-0.23.0rc0-cp310-abi3-musllinux_1_2_i686.whl", hash = "sha256:5835b35d9a4815c8a4097d4dbac79c39b780684ea417fa4a93b9165e12ff1383", size = 9959195, upload-time = "2026-04-24T05:37:38.194Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/11/95/d1a6a0e6d6a9bc81b8124d83beb1fb1230310ee93938095f984a12fa336d/tokenizers-0.23.0rc0-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:33ed7df57a040ffb6f0244639619632a06f4c287ed1e77b5e70febb58f9e9a8b", size = 10106242, upload-time = "2026-04-24T05:37:40.745Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/c4/d9d587b9b32c9fca5ea901225d5c4c616802eb0082b17481d23808941641/tokenizers-0.23.0rc0-cp310-abi3-win32.whl", hash = "sha256:ab264a8ffdea05b5fd71a8bca6572762bde9b7aaadeba16dd25c7352a625fa71", size = 2523576, upload-time = "2026-04-24T05:37:47.173Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d8/9b/34b36f6a47fec0a160887da23f173aa8a1729fa425ee67944c9be27f58de/tokenizers-0.23.0rc0-cp310-abi3-win_amd64.whl", hash = "sha256:27fe690eeb35a3a7e52f47d96c2ce8ffc6f939cc51a4591be86d2c86b9881267", size = 2788929, upload-time = "2026-04-24T05:37:45.81Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/35/ec/920d2b36ddddb5ce819a005d9650dc941935e534a27c48758c93388aaa5b/tokenizers-0.23.0rc0-cp310-abi3-win_arm64.whl", hash = "sha256:0b66c5eab2ddd26e59cfe6aa1945aa8b656ea0a9a715e24171c01b5ab1987630", size = 2655724, upload-time = "2026-04-24T05:37:44.108Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/92/97/5dbfabf04c7e348e655e907ed27913e03db0923abb5dfdd120d7b25630e1/tokenizers-0.22.2-cp39-abi3-macosx_10_12_x86_64.whl", hash = "sha256:544dd704ae7238755d790de45ba8da072e9af3eea688f698b137915ae959281c", size = 3100275, upload-time = "2026-01-05T10:41:02.158Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/47/174dca0502ef88b28f1c9e06b73ce33500eedfac7a7692108aec220464e7/tokenizers-0.22.2-cp39-abi3-macosx_11_0_arm64.whl", hash = "sha256:1e418a55456beedca4621dbab65a318981467a2b188e982a23e117f115ce5001", size = 2981472, upload-time = "2026-01-05T10:41:00.276Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/d6/84/7990e799f1309a8b87af6b948f31edaa12a3ed22d11b352eaf4f4b2e5753/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:2249487018adec45d6e3554c71d46eb39fa8ea67156c640f7513eb26f318cec7", size = 3290736, upload-time = "2026-01-05T10:40:32.165Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/78/59/09d0d9ba94dcd5f4f1368d4858d24546b4bdc0231c2354aa31d6199f0399/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:25b85325d0815e86e0bac263506dd114578953b7b53d7de09a6485e4a160a7dd", size = 3168835, upload-time = "2026-01-05T10:40:38.847Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/47/50/b3ebb4243e7160bda8d34b731e54dd8ab8b133e50775872e7a434e524c28/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:bfb88f22a209ff7b40a576d5324bf8286b519d7358663db21d6246fb17eea2d5", size = 3521673, upload-time = "2026-01-05T10:40:56.614Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/e0/fa/89f4cb9e08df770b57adb96f8cbb7e22695a4cb6c2bd5f0c4f0ebcf33b66/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:1c774b1276f71e1ef716e5486f21e76333464f47bece56bbd554485982a9e03e", size = 3724818, upload-time = "2026-01-05T10:40:44.507Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/64/04/ca2363f0bfbe3b3d36e95bf67e56a4c88c8e3362b658e616d1ac185d47f2/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:df6c4265b289083bf710dff49bc51ef252f9d5be33a45ee2bed151114a56207b", size = 3379195, upload-time = "2026-01-05T10:40:51.139Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/2e/76/932be4b50ef6ccedf9d3c6639b056a967a86258c6d9200643f01269211ca/tokenizers-0.22.2-cp39-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:369cc9fc8cc10cb24143873a0d95438bb8ee257bb80c71989e3ee290e8d72c67", size = 3274982, upload-time = "2026-01-05T10:40:58.331Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/1d/28/5f9f5a4cc211b69e89420980e483831bcc29dade307955cc9dc858a40f01/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:29c30b83d8dcd061078b05ae0cb94d3c710555fbb44861139f9f83dcca3dc3e4", size = 9478245, upload-time = "2026-01-05T10:41:04.053Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6c/fb/66e2da4704d6aadebf8cb39f1d6d1957df667ab24cff2326b77cda0dcb85/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_armv7l.whl", hash = "sha256:37ae80a28c1d3265bb1f22464c856bd23c02a05bb211e56d0c5301a435be6c1a", size = 9560069, upload-time = "2026-01-05T10:45:10.673Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/16/04/fed398b05caa87ce9b1a1bb5166645e38196081b225059a6edaff6440fac/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_i686.whl", hash = "sha256:791135ee325f2336f498590eb2f11dc5c295232f288e75c99a36c5dbce63088a", size = 9899263, upload-time = "2026-01-05T10:45:12.559Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/05/a1/d62dfe7376beaaf1394917e0f8e93ee5f67fea8fcf4107501db35996586b/tokenizers-0.22.2-cp39-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:38337540fbbddff8e999d59970f3c6f35a82de10053206a7562f1ea02d046fa5", size = 10033429, upload-time = "2026-01-05T10:45:14.333Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/fd/18/a545c4ea42af3df6effd7d13d250ba77a0a86fb20393143bbb9a92e434d4/tokenizers-0.22.2-cp39-abi3-win32.whl", hash = "sha256:a6bf3f88c554a2b653af81f3204491c818ae2ac6fbc09e76ef4773351292bc92", size = 2502363, upload-time = "2026-01-05T10:45:20.593Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/65/71/0670843133a43d43070abeb1949abfdef12a86d490bea9cd9e18e37c5ff7/tokenizers-0.22.2-cp39-abi3-win_amd64.whl", hash = "sha256:c9ea31edff2968b44a88f97d784c2f16dc0729b8b143ed004699ebca91f05c48", size = 2747786, upload-time = "2026-01-05T10:45:18.411Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/72/f4/0de46cfa12cdcbcd464cc59fde36912af405696f687e53a091fb432f694c/tokenizers-0.22.2-cp39-abi3-win_arm64.whl", hash = "sha256:9ce725d22864a1e965217204946f830c37876eee3b2ba6fc6255e8e903d5fcbc", size = 2612133, upload-time = "2026-01-05T10:45:17.232Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/84/04/655b79dbcc9b3ac5f1479f18e931a344af67e5b7d3b251d2dcdcd7558592/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:753d47ebd4542742ef9261d9da92cd545b2cacbb48349a1225466745bb866ec4", size = 3282301, upload-time = "2026-01-05T10:40:34.858Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/46/cd/e4851401f3d8f6f45d8480262ab6a5c8cb9c4302a790a35aa14eeed6d2fd/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e10bf9113d209be7cd046d40fbabbaf3278ff6d18eb4da4c500443185dc1896c", size = 3161308, upload-time = "2026-01-05T10:40:40.737Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/6f/6e/55553992a89982cd12d4a66dddb5e02126c58677ea3931efcbe601d419db/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:64d94e84f6660764e64e7e0b22baa72f6cd942279fdbb21d46abd70d179f0195", size = 3718964, upload-time = "2026-01-05T10:40:46.56Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/59/8c/b1c87148aa15e099243ec9f0cf9d0e970cc2234c3257d558c25a2c5304e6/tokenizers-0.22.2-pp310-pypy310_pp73-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f01a9c019878532f98927d2bacb79bbb404b43d3437455522a00a30718cdedb5", size = 3373542, upload-time = "2026-01-05T10:40:52.803Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3675,7 +3681,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "ultralytics"
|
||||
version = "8.4.55"
|
||||
version = "8.4.56"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "matplotlib", version = "3.10.9", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" },
|
||||
@@ -3693,9 +3699,9 @@ dependencies = [
|
||||
{ name = "torchvision" },
|
||||
{ name = "ultralytics-thop" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/e9/0c/ec86cf04f520333fbf6fa4932a36e34c40588ddacb72091a8d352066ba25/ultralytics-8.4.55.tar.gz", hash = "sha256:27f0d5f9e818926ccf69da542cf53be0db896e659878bfc774fcd605f696a26a", size = 1065513, upload-time = "2026-05-26T17:26:05.265Z" }
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/73/440a7aa53aafaed46b106f1633385f390bebd300951cf15daf02a73b2c81/ultralytics-8.4.56.tar.gz", hash = "sha256:403f301cd4556b81fd13f5ca6dfcf2d3d6f31cf13cec0db79e655a086214318f", size = 1065497, upload-time = "2026-05-27T03:56:40.189Z" }
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/1c/59/914cd56ae9660fb1911f20560c505d7d672979912adceece1512506f149e/ultralytics-8.4.55-py3-none-any.whl", hash = "sha256:c2b2af4a531b8c7a93ef2ab66fe020bba5b30a64bd038f46f1fdaa3af8731fba", size = 1266451, upload-time = "2026-05-26T17:26:00.797Z" },
|
||||
{ url = "https://files.pythonhosted.org/packages/55/3f/7c86e841462a5f394c5f446943b5776b298aed418a6fd12386da70672660/ultralytics-8.4.56-py3-none-any.whl", hash = "sha256:84185b7c8ad91a8974bb4c5a42a14aeebb87a8c051cc64a5191758ed7d91fb95", size = 1266431, upload-time = "2026-05-27T03:56:35.698Z" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
Reference in new issue
Block a user