Compare commits
No files matched your search
@@ -1,3 +1,3 @@
|
||||
# HuggingFace token (required for invisible watermark removal)
|
||||
# HuggingFace token (optional; only needed for gated/private models)
|
||||
# Get yours at: https://huggingface.co/settings/tokens
|
||||
HF_TOKEN=
|
||||
@@ -17,6 +17,16 @@ jobs:
|
||||
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
|
||||
- name: Verify release tag matches package version
|
||||
run: |
|
||||
tag="${{ github.event.release.tag_name }}"
|
||||
version="$(grep -m1 '^version = ' pyproject.toml | sed -E 's/^version = "([^"]+)"/\1/')"
|
||||
if [ "${tag#v}" != "$version" ]; then
|
||||
echo "Release tag '$tag' does not match pyproject.toml version '$version'" >&2
|
||||
exit 1
|
||||
fi
|
||||
echo "Release tag '$tag' matches package version '$version'"
|
||||
|
||||
- name: Build package
|
||||
run: uv build
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
name: Tests
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
|
||||
concurrency:
|
||||
group: tests-${{ github.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
jobs:
|
||||
lint:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Ruff lint
|
||||
run: uv run ruff check
|
||||
- name: Ruff format check
|
||||
run: uv run ruff format --check
|
||||
|
||||
test:
|
||||
name: test ${{ matrix.os }} py${{ matrix.python-version }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
# Cross-OS x Python-floor/recent. The default-install surface only
|
||||
# needs the core deps plus dev tooling (no GPU extra): the model-running
|
||||
# paths are availability-gated and skip when torch/diffusers are absent,
|
||||
# so this matrix exercises metadata, identify, visible, and the cv2
|
||||
# eraser on every OS without pulling the heavy ML stack.
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.10", "3.12"]
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: astral-sh/setup-uv@v7
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
- name: Sync dev environment
|
||||
run: uv sync --frozen --extra dev
|
||||
- name: Run tests
|
||||
run: uv run pytest -q
|
||||
@@ -34,10 +34,15 @@ yolov8n.pt
|
||||
|
||||
# Claude Code local settings
|
||||
.claude/settings.local.json
|
||||
.claude/scheduled_tasks.lock
|
||||
|
||||
# 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.
|
||||
# Visible-watermark alpha calibration. The solid black/gray/white CAPTURES are
|
||||
# committed (content-free: a solid colour + the watermark; the source for
|
||||
# scripts/visible_alpha_solve.py so the alpha assets are reproducible). The
|
||||
# synthetic seeds (regenerable) and any real-content validation download (a real
|
||||
# generated scene, kept local for privacy) are NOT committed.
|
||||
data/doubao_capture/seeds/
|
||||
data/doubao_capture/captures/
|
||||
data/jimeng_capture/seeds/
|
||||
data/jimeng_capture/captures/jimeng_content_*.png
|
||||
data/gemini_capture/seeds/
|
||||
data/gemini_capture/captures/gemini_content_*.png
|
||||
@@ -5,17 +5,21 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
|
||||
## How to run
|
||||
|
||||
- `uv run remove-ai-watermarks all <image.png> -o <output.png>`
|
||||
- `uv run remove-ai-watermarks visible <image.png> -o <out.png>` — visible-mark removal, CPU, no GPU. `--mark auto` (default) routes between the Gemini sparkle and the Doubao "豆包AI生成" text strip by detector confidence; `--mark gemini` / `--mark doubao` force one.
|
||||
- `uv run remove-ai-watermarks visible <image.png> -o <out.png>` — known-visible-mark removal, CPU, no GPU. Reverse-alpha based: each mark is removed by inverting its captured alpha map. `--mark auto` (default) picks the strongest detected of the Gemini sparkle, the Doubao "豆包AI生成" text strip, and the Jimeng "★ 即梦AI" wordmark; `--mark gemini` / `--mark doubao` / `--mark jimeng` force one. Gemini/Doubao recover pixels exactly with no inpaint at native; **Jimeng adds an always-on residual inpaint over the glyph footprint** (its mark re-rasterizes per image, so reverse-alpha alone leaves a faint outline). For arbitrary logos/objects use `erase`.
|
||||
- `uv run remove-ai-watermarks erase <image.png> --region x,y,w,h -o <out.png>` — universal region eraser (any logo/object, any position). `--backend cv2` (default, no deps) or `--backend lama` (big-LaMa via onnxruntime, extra `lama`); `--region` is repeatable.
|
||||
- `uv run remove-ai-watermarks identify <image>` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip the cv2 sparkle detector
|
||||
- `uv run remove-ai-watermarks metadata <image.png> --check` — inspect AI metadata (C2PA, EXIF, PNG chunks)
|
||||
- `uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>` — strip all AI metadata
|
||||
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the same `--strength`/`--steps`/`--pipeline`/`--device`/`--max-resolution`/`--seed`/`--hf-token` knobs as `invisible`, `--inpaint/--no-inpaint` for the visible pass, and `--humanize` for the Analog Humanizer
|
||||
|
||||
## Test and lint
|
||||
|
||||
- **CI** (`.github/workflows/test.yml`): runs on push to `main` + every PR. A `lint` job (ubuntu: `ruff check` + `ruff format --check`) plus a `test` matrix (ubuntu/macos/windows x py3.10/3.12) that does `uv sync --frozen --extra dev` then `pytest`. The matrix installs only core + dev (no `gpu` extra), so the GPU/model-running tests skip there and it exercises the metadata/identify/visible/cv2-eraser surface on all three OSes. Keep `uv.lock` valid (don't break `--frozen`) when editing `pyproject.toml`. `publish.yml` stays release-only and now verifies the release tag matches the `pyproject.toml` version (fails the build on a mismatch) before building. **Release flow:** bump the version in `pyproject.toml` + `src/remove_ai_watermarks/__init__.py` + `uv.lock` (the project's own `[[package]]` entry, ~line 2868), commit `chore(release): vX.Y.Z`, `git tag -a vX.Y.Z -m vX.Y.Z` (annotated — `git tag` without `-m` errors here), push `main` + the tag, then `gh release create vX.Y.Z` — **PyPI publish triggers on the GitHub Release `published` event, NOT on the tag push**, so the tag alone does not publish. **Sdist must exclude `data/`** (`[tool.hatch.build.targets.sdist] exclude = ["/data"]`): hatchling's default sdist bundles all VCS-tracked files, so the committed `data/` test corpora (synthid_corpus images ~65 MB + the visible-mark captures) pushed the **0.8.0** sdist past PyPI's per-project file-size limit (400 "File too large") — the wheel uploaded but the sdist was rejected, so 0.8.0 shipped wheel-only and 0.8.1 carried the fix. The wheel only ships `src/` (via `[tool.hatch.build.targets.wheel] packages`), so it was never affected. **A failed PyPI upload of one artifact still leaves the other live and you cannot re-upload the same version** — fix the build and cut the next patch. **Build backend is pinned `hatchling<1.28`** (`[build-system] requires`): hatchling 1.28+ emits **Metadata-Version 2.5** (PEP 639), which the twine bundled in `pypa/gh-action-pypi-publish@release/v1` rejects (`"'2.5' is not a valid Metadata-Version"`) — this **failed the v0.8.3 PyPI upload on 2026-06-01** (tag-match + build passed, the upload step failed; nothing was uploaded, so the version stayed empty on PyPI). 1.27.x emits 2.4, which uploads fine (0.8.2 shipped on it). The pin is unpinned `requires = ["hatchling"]` no longer safe because `uv build` pulls the latest hatchling. Lift the pin only once the publish action's twine is ≥ 6.1.0 (2.5-aware) or the workflow moves to `uv publish`.
|
||||
- `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; `noai/__init__` is now **lazy** (PEP 562 `__getattr__`, so importing `identify`/`metadata` no longer pulls `watermark_remover`/torch), so a bare env breaks only when the removal pipeline is actually invoked, not on import. `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.
|
||||
|
||||
@@ -23,45 +27,65 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r
|
||||
|
||||
- GPU/ML modules (invisible_engine, ctrlregen, watermark_remover) are optional — guard imports with `is_available()` checks
|
||||
- Optional detection extras: `detect` (imwatermark — open SD/SDXL/FLUX watermark) and `trustmark` (Adobe TrustMark decoder; pulls torch + downloads weights). Both are guarded by `is_available()` and skipped by `identify` when absent.
|
||||
- Tests for the *model-running* paths are limited to availability checks (multi-GB downloads). But the **pure helpers inside ML-adjacent modules are unit-tested without any download** and must stay that way: `_target_size` (native-vs-downscale, `test_invisible_engine.py`), the MPS->CPU fallback control flow via mocked pipelines (`test_img2img_runner.py`, 100% cover), and the tiling math `tile_positions`/`make_blend_weight`/`resize_center_crop` (`test_tiling.py`; `pytest.importorskip("torch")` since `tiling.py` imports torch at module top). Don't skip these as "ML, needs a model" — only `run_tiled`/`remove_watermark`/the diffusion bodies do.
|
||||
- Tests for the *model-running* paths are limited to availability checks (multi-GB downloads). But the **pure helpers inside ML-adjacent modules are unit-tested without any download** and must stay that way: `_target_size` (native-vs-downscale, `test_invisible_engine.py`), the MPS->CPU fallback control flow via mocked pipelines (`test_img2img_runner.py`, 100% cover), and the tiling math `tile_positions` (now raises `ValueError` when not `0 <= overlap < tile`)/`make_blend_weight`/`resize_center_crop` (`test_tiling.py`; `pytest.importorskip("torch")` since `tiling.py` imports torch at module top). Don't skip these as "ML, needs a model" — only `run_tiled`/`remove_watermark`/the diffusion bodies do.
|
||||
|
||||
## Key modules
|
||||
|
||||
- `noai/c2pa.py` — PNG chunk parser; use `extract_c2pa_chunk(path)` to get raw caBX payload, `has_c2pa_metadata(path)` to detect. Do not reimplement chunk parsing. `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/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). PNG/caBX chunk reads are clamped to the remaining file size (`safe_length = min(length, remaining)`; skipped chunks use seek) so a malformed huge `length` cannot drive a multi-GB allocation (shared safety discipline matching `isobmff.scan_c2pa_region`).
|
||||
- `noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES, C2PA_ISSUERS, `SYNTHID_C2PA_ISSUERS` (issuers that pair SynthID with C2PA: Google, OpenAI), and `C2PA_SOFT_BINDINGS` (soft-binding `alg` prefix → forensic-watermark vendor: Adobe TrustMark, Digimarc, Imatag, Steg.AI, Microsoft, ...). Add a new issuer/binding here, not inline.
|
||||
- `metadata.py` — `synthid_source(path)` returns the vendor name(s) if the C2PA manifest implies a SynthID pixel watermark, else None. Format-agnostic: PNG via the caBX parser, JPEG/WebP/AVIF/HEIF/JXL via a binary scan (C2PA marker + SynthID issuer + AI-source marker). `get_ai_metadata` surfaces the verdict, and `metadata --check` prints it as a callout. Both `get_ai_metadata` and `has_ai_metadata` guard the PIL open with `except Exception` (HEIC/unknown formats raise non-OSError) and fall through to the binary scan. `xai_signature(path)` detects xAI/Grok's EXIF-only scheme (`ImageDescription` = `Signature: <base64>` + UUID `Artist`); it feeds `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify`. `iptc_ai_system(path)` detects the IPTC Photo Metadata 2025.1 AI-disclosure XMP properties (`IPTC_AI_FIELD_MARKERS` = `AISystemUsed`/`AISystemVersionUsed`/`AIPromptInformation`/`AIPromptWriterName`) and returns the `AISystemUsed` generator name (or `"fields present"`). `remove_ai_metadata` routes **ISOBMFF video** (`.mp4`/`.mov`/`.m4v`) through the same `isobmff.strip_c2pa_boxes` as AVIF/HEIF (MP4 is ISOBMFF), and `_scrub_ai_exif` removes the xAI signature + AI-generator EXIF tags on JPEG output.
|
||||
- `identify.py` — `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, IPTC "Made with AI" + IPTC 2025.1 `AISystemUsed`, embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, visible Gemini sparkle, open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). Visible-sparkle is promoted only at confidence ≥ `_SPARKLE_THRESHOLD` (0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49). The cv2 dependency lives in `gemini_engine.detect_sparkle_confidence`, not here. **C2PA platform attribution is device-token-first, issuer-scan fallback** (`_device_platform` scans manifest bytes for `_DEVICE_C2PA_PLATFORM` tokens, then `_attribute_platform`/`_ISSUER_PLATFORM`). **Why, verified on real signed files 2026-05-26:** the old issuer-only byte-scan matched ANY issuer substring anywhere, so multi-entity manifests mis-attributed -- Leica→"Truepic" (a signing authority in the trust chain), Nikon→"Adobe Firefly" (XMP-toolkit "Adobe" + the sample's "Adobe_MAX" name), Pixel→"Google (Gemini)" ("Google LLC" cert org), Truepic→"Google". A distinctive device token wins instead. **Token distinctiveness is load-bearing:** bare `b"Truepic"` mis-fires (it appears in unrelated trust chains -- it mis-attributed the OpenAI `chatgpt-1.png` fixture), so the token is the specific `b"Truepic_Lens"` from the Lens SDK claim generator; likewise `b"Pixel Camera"` (cert CN) not bare `b"Pixel"`. `_DEVICE_C2PA_PLATFORM` lists ONLY tokens **verified against a real C2PA file**: Leica (`lc_c2pa`/`Leica Camera`), Nikon (`NIKON`), Pixel (`Pixel Camera` -- from a real Pixel 10 Pro file attached to c2pa-rs issue #1609/#1554), Sony (`sony.sig`/`sony.cert` -- Sony's own C2PA assertion namespace, verified on a real Sony PXW-Z300 file; NOT bare "Sony" which is a common EXIF Make), Truepic (`Truepic_Lens`). Canon/Samsung/Bria have **no public direct-download C2PA sample** (checked exhaustively: GitHub issue/PR attachments, contentcredentials gallery, HF datasets -- all upload-to-verify or token-gated; Canon's only public file was a self-signed hobbyist CR3, not factory), so they stay unmapped until a real file is captured (same fixture discipline as Grok/Doubao). The Sony sample is video (MP4) -- our ISOBMFF C2PA path detects it; Sony Alpha stills likely share the `sony.*` namespace but are not separately verified. Camera C2PA marks capture authenticity, not AI (Pixel carries `computationalCapture`, not `trainedAlgorithmicMedia`), so these never set `is_ai` -- that stays driven by digital-source-type. `c2pa.cbor_text_after` (now public) is best-effort for the `generator` detail string only and can be None when the manifest keys it `claim_generator_info` (Pixel). **Issuer→generator mapping is `is_ai`-gated** (`_attribute_platform(issuers, is_ai=c2pa_is_ai)`): a specific AI-generator platform is named only when the digital-source-type is `trainedAlgorithmicMedia`; on a non-AI source an issuer substring is treated as incidental (an "Adobe XMP" toolkit string in an *unmapped* Canon/Sony capture would otherwise mislabel it "Adobe Firefly"), so it degrades to the neutral "C2PA signer: X" label. Real Firefly/OpenAI/Google output carries the AI source-type, so it is unaffected (verified: chatgpt-1.png→OpenAI, firefly-1.png→Adobe Firefly still attribute). `_attribute_platform` defaults `is_ai=True` so the mapping stays unit-testable in isolation. Add device tokens to `_DEVICE_C2PA_PLATFORM`, generator/issuer platforms to `_ISSUER_PLATFORM`, not inline. For non-PNG containers (JPEG/WebP/AVIF/HEIF/JXL) the caBX parser returns nothing, so issuer (`_issuers_in`) and generator (`_ai_tools_in`, reusing `C2PA_AI_TOOLS`) are recovered by binary-scanning the first MB. EXIF `Software` / `Make` / `Artist` / `ImageDescription` and XMP `CreatorTool` generator tags are read by `metadata.exif_generator` (PIL+piexif for any format PIL opens incl. AVIF, plus a container-agnostic XMP raw-byte scan that also covers HEIF/JXL), matched against `AI_GENERATOR_TOKENS` so ordinary editors (plain "Adobe Photoshop") and real-camera `Make` ("Apple"/"Canon") are not flagged. **Ideogram tags its output with EXIF `Make="Ideogram AI"`** (verified on a real download 2026-05-24) — that's why `Make` is read.
|
||||
- `gemini_engine.py` — visible Gemini-sparkle remover/detector (cv2/numpy, no GPU). `detect_sparkle_confidence(path)` is the file-level entry point used by `identify.py`.
|
||||
- `doubao_engine.py` — visible Doubao "豆包AI生成" remover/detector (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH, fractions in module constants; no bundled template), `extract_mask` pulls the light low-saturation glyphs with a **polarity-aware white top-hat** (brighter-than-blurred-local-bg, so white-paper documents are left untouched instead of smeared), `detect` thresholds glyph coverage (`DETECT_MIN_COVERAGE` 0.16 separates real marks ≥0.20 from corner noise, which stays ≤0.06 on large images but can spike to ~0.15 on tiny ones), `remove_watermark` inpaints (cv2 Telea/NS) and **bails when coverage > `MAX_INPAINT_COVERAGE` 0.50** (dense-text background → would smear). Wired into `visible --mark` via `cli._run_doubao_if_selected`. **Logo is near-white (~253), not the gray some third-party tools assume.** Best on photo/illustration backgrounds; high-contrast edges leave faint residue (cv2-inpaint limit). Clean per-pixel reverse-alpha (Gemini-style) is the future upgrade but needs a captured/distilled alpha map — see below.
|
||||
- `region_eraser.py` — universal region eraser (`erase` CLI). `erase(image, boxes=|mask=, backend=)`: `boxes_to_mask` → `cv2.inpaint` (`cv2` backend, default, no deps) or big-LaMa via onnxruntime (`lama` backend, extra `lama`, `Carve/LaMa-ONNX` Apache-2.0 model downloaded on first use, never bundled). `erase_lama` crops a padded region around the mask, runs LaMa at its fixed 512² input, pastes only masked pixels back (untouched areas stay pixel-exact). Lazy `_get_lama_session` singleton; `lama_available()` guards the optional import. **LaMa-ONNX costs ~3.5-4 GB peak RAM and ~5-6 s/call on CPU** (FFC working set, not arena — `enable_cpu_mem_arena=False` does not help), so it does NOT fit a minimal droplet; the cv2 backend (tens of MB, ~30 ms) does. LaMa quality at low RAM = serverless/GPU, mirroring how raiw.cc offloads SDXL to fal.
|
||||
- `metadata.py` — `scan_head(path, size=1MB)` is the shared input for every C2PA/AIGC/IPTC byte scan: first `size` bytes plus the payloads of any provenance metadata found beyond that window — for ISOBMFF, the late provenance boxes from `isobmff.scan_c2pa_region` (catches a manifest after a large `mdat`); for **PNG**, the late `tEXt`/`iTXt`/`zTXt`/`eXIf`/`iCCP` chunks from `_png_late_metadata` (catches an XMP/EXIF packet appended after a large `IDAT`, e.g. a TC260 AIGC label at ~2.7 MB). Behavior-neutral (`f.read(size)`) for non-ISOBMFF inputs and for any file that fits within `size`. 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. `strip_c2pa_boxes` is **fail-safe** on a malformed box: it returns the original bytes unchanged with a logged warning instead of truncating the tail to EOF (detection-only `scan_c2pa_region` still stops at a malformed box). `_png_late_metadata` clamps each late-chunk read to the remaining file size (`safe_length = min(length, remaining)`) so a malformed `length` cannot drive a multi-GB allocation.
|
||||
- `identify.py` — the OpenAI rollout caveat is keyed on `_vendor_of(synthid) == "OpenAI"` (not a raw substring over the issuer + verdict blob). `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`, the Samsung Galaxy AI editing marker via `metadata.samsung_genai`, the visible marks — Gemini sparkle plus the ByteDance Doubao 豆包AI生成 / Jimeng 即梦AI text marks via the `watermark_registry` — 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`, visible-mark, and Samsung `samsung_genai` signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only` / `samsung_only`, parallel branches; `visible_only` fires on any `visible_*` signal) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. **Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD` 0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng reuse the registry detectors (`_visible_text_marks` → `watermark_registry`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.4, Jimeng 0.45). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here. **`import identify` is deliberately light** (~21 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports only the pure `noai.c2pa`/`noai.constants` submodules, and `noai/__init__` is lazy (see "Test and lint"), so torch/diffusers are NOT pulled at import even in a full `gpu`/`detect` install — fits a 512 MB host. The heavy paths are opt-in: `check_invisible=True` needs the `detect`/`trustmark` extras (each pulls **torch**; TrustMark also **downloads weights**), so on a core-only deploy leave `check_invisible` off (it is a no-op there anyway). Before the lazy `__init__`, the mere presence of torch in the env inflated `import identify` to ~420 MB. **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/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. **Samsung Galaxy + ASUS Gallery live in a separate `_SIGNER_C2PA_PLATFORM` (scanned after `_device_platform`, before the issuer fallback), NOT in `_DEVICE_C2PA_PLATFORM`** — verified on real signed files 2026-05-29. Reason: a Galaxy phone stamps BOLine truncated
|
||||
- `watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry. **Reverse-alpha based by policy**: a mark is listed only once a real alpha map has been captured for it, and removal inverts that map (`original = (wm - a*logo)/(1-a)`) — Gemini recovers cleanly with no inpaint (its sparkle alpha comes from a pure-black capture, so it is near-exact), while **Doubao and Jimeng both add an always-on THIN residual inpaint** over the glyph footprint (their text marks re-rasterize + jitter a few px per image, so a single capture cannot pixel-cancel them; the inpaint blends into the reverse-alpha-recovered pixels). Arbitrary-region inpainting still lives in `region_eraser`/`erase`. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, `recovery` (="reverse-alpha"), a `detect` adapter → uniform `MarkDetection`, a `remove` adapter}. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), and `jimeng` (bottom-right "★ 即梦AI"). `detect_marks` scans all; `best_auto_mark` picks the highest-confidence detection. **Cross-engine confidences aren't directly comparable**, so the gemini adapter applies the corpus-validated 0.5 sparkle threshold (`_GEMINI_AUTO_MIN_CONF`) for its `detected` flag — otherwise the gemini engine's loose internal threshold weakly fires (~0.36) on the Doubao text and hijacks `auto`. The shape-keyed Doubao/Jimeng NCC detectors don't cross-fire (jimeng scores ~0.22 on the Doubao strip, well under its 0.45 threshold), so `auto` picks the right one on a Doubao vs Jimeng image. `cli.cmd_visible` is registry-driven: `--mark auto` → `best_auto_mark`, `--mark <key>` → that mark; `--mark` choices come from `mark_keys()`. `_doubao_remove`/`_jimeng_remove` apply reverse-alpha only when the mark is detected AND `reverse_alpha_available`; outside that, removal is **skipped** (not inpainted). Add a new visible mark = one `KnownMark` entry + its engine (with a captured alpha map); do not re-add per-mark `if` branches in the CLI. **Alpha-on-save policy (issue #30):** `cli._write_bgr_with_alpha` rejoins the input's alpha plane **unchanged** — it must NOT zero alpha in the watermark bbox. Reverse-alpha (and `erase` inpaint) recover real pixels there, so zeroing alpha punched a transparent hole that renders as a solid **white box** on any non-transparent viewer (Gemini app exports are opaque RGBA, so every user hit it; regression-guarded by `test_visible_keeps_alpha_opaque_in_watermark_region`). The registry `remove()` still returns its region (used for `inpaint_residual` positioning), but the CLI no longer uses it to clear alpha.
|
||||
- `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`. The public entry points normalize a grayscale (2D) or RGBA (4-channel) input to BGR up front so a non-BGR image does not crash the cv2 pipeline. **Removal is reverse-alpha with an over-subtraction guard** (`remove_watermark` → `_reverse_alpha_blend`, else `_inpaint_footprint`): the sparkle alpha is computed (`alpha = max(R,G,B)/255`) from the bundled sparkle-on-black captures `assets/gemini_bg_{96,48}.png` (the capture max is ~130, NOT 255 — the sparkle is a ~51%-opaque white overlay, so `alpha` maxes at ~0.51, which is CORRECT for the capture, not under-exposed). The alpha is near-exact only when the real mark's effective opacity matches the capture, which holds on bright/flat backgrounds — re-verified clean on `demo_banana_before.png` 2026-05-31. **Issue #30 (dark-background black pit):** on a dark/textured background (e.g. grass, ~73) the real sparkle's effective opacity is LOWER than the captured 0.51, so the fixed-alpha reverse blend OVER-subtracts (`watermarked - a*logo` goes negative) and drives the footprint to black — the white sparkle becomes a black diamond. `remove_watermark` now detects this via `_reverse_alpha_oversubtracts` (fraction of footprint pixels with `alpha >= _FOOTPRINT_ALPHA` 0.1 whose numerator < 0 exceeds `_OVERSUB_FOOTPRINT_FRAC` 0.05) and **inpaints the footprint** (`_inpaint_footprint`, cv2 NS over the dilated alpha mask) from the surrounding pixels instead. **Behavior-neutral on the working case:** a bright background over-subtracts at ~0% so reverse-alpha is used and the output is byte-identical to before (verified: demo_banana 0.0 frac vs issue-#30 grass 0.61 frac; regression-guarded by `test_gemini_engine.py::TestOverSubtractionGuard`, which composites the sparkle at a reduced effective alpha to reproduce the mismatch). The registry's optional `inpaint_residual` (edge cleanup) is a no-op on a clean reverse-alpha removal; an earlier "Gemini smears" read was a misjudged soft-fur original, not an artifact. **The bg assets are now rebuilt from OUR OWN controlled captures** (`data/gemini_capture/captures/`, committed) by `scripts/visible_alpha_solve.py gemini`, which locates the 96px sparkle on the black capture and crops it to the two logo sizes; our capture matched the previously third-party-sourced `gemini_bg_96.png` to **NCC 0.9998**, validating the asset and making it reproducible. Gemini's multi-size fixed-slot model is genuinely different from the Doubao/Jimeng text-strip engines (so it stays a separate engine, not part of the shared-base refactor).
|
||||
- `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), `extract_mask` pulls the light, low-chroma glyphs (the detection candidate) using a per-pixel channel-spread proxy `sat = roi.max(axis=2) - roi.min(axis=2)` (no HSV conversion). `detect` is **shape-consistent**: it matches the bundled alpha glyph silhouette (`assets/doubao_alpha.png`) against the candidate via zero-mean normalized correlation (`_template_match_score`, cv2 `TM_CCOEFF_NORMED`), gated at `DETECT_NCC_THRESHOLD` 0.4 over a small `DETECT_MIN_COVERAGE` floor. Keying on glyph SHAPE (not coverage heuristics) fixed #23 (corpus FP 7/1243). **Removal = reverse-alpha + thin residual inpaint** (`remove_watermark_reverse_alpha`): `original = (wm - a*logo)/(1-a)` from the bundled alpha map + `_ALPHA_LOGO_BGR` (pure white) + `_ALPHA_*_FRAC` geometry, then a deliberately THIN inpaint (`_RESIDUAL_*`, `INPAINT_NS`) over the glyph footprint clears leftover edges without smearing. **Alpha is rebuilt by `scripts/visible_alpha_solve.py` (the careful gray-self solve: cubic background fit, mean over channels, full halo, unblurred), same recipe as Jimeng** — the captures are committed in `data/doubao_capture/captures/`. **Removal aligns ALWAYS** (no `_ALPHA_NATIVE_BAND` fast-path): it tries fixed geometry AND `_aligned_alpha_map`'s `TM_CCOEFF_NORMED` scale+position search and keeps the lower-residual one — the mark is re-rasterized and a few px off per image, so fixed geometry alone leaves a visible outline even at 2048. **The locate box (`WM_*`) is generous (0.22 wide, margins 0.004) and reaches close to the corner** — a tight box (the old 0.185 / margin 0.012) let a corner-ward shift fall OUTSIDE the alignment search, so the align missed and a readable outline survived; regression-guarded by `test_recovers_shifted_mark_on_texture` (composes the alpha shifted on a known texture; old box ~29 vs new ~1 mean residual). **Issue #13 follow-up defect (found 2026-05-31): the SHIPPED Doubao removal left a clearly READABLE "豆包AI生成" outline on the real `doubao-1.png` sample, while `detect` returned conf 0.0 (it is fooled by a thin outline) so `test_reverse_alpha_removes_mark` passed and the old "56/56 clean" claim was detector-measured, not visual.** Root cause: bad alpha (under-estimated, max ~0.65) + fixed-no-inpaint + tight box; the careful rebuild + always-align + thin inpaint + wide box takes it from a readable outline to faint texture-level traces (parity with Jimeng — a single capture cannot pixel-cancel a per-image re-rasterized mark). **Lesson: a detector-only removal test is insufficient; assert visual residual (the textured-shift test).** **`extract_mask` guards a degenerate ROI (`bh < 16 or bw < 16` -> empty mask, skips cv2):** the always-align removal scores each placement with a residual `detect(out)`, and on an extremely wide/short image (e.g. 2048x1, `test_wide_short_does_not_raise`) that fed cv2's GaussianBlur a ~1-px-tall ROI and **faulted natively on Windows py3.12 (access violation, non-deterministic — one CI cell went red while a re-run passed)**; the old at-native path never ran `detect` on degenerate sizes. Real images always clear the guard (the `WM_*` box floors are `max(16, …)` height / `max(40, …)` width), so it only short-circuits slivers. `reverse_alpha_available` is just "asset present"; the registry gates removal on `detect`. The shipped third-party `_refs/zhengsuanfa_doubao_alpha_120x20.png` is NOT a usable alpha (verified 2026-05-29). Arbitrary-region inpainting is `region_eraser`/`erase`.
|
||||
- `jimeng_engine.py` — visible Jimeng / Dreamina "★ 即梦AI" remover/detector (cv2/numpy, no GPU), built 2026-05-30 from issue #13's solid captures (@powersee). Mirrors `doubao_engine`: `locate` anchors a bottom-right box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma), `detect` matches the bundled "即梦AI" glyph silhouette (`assets/jimeng_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.45** cleanly separates real Jimeng marks (>=0.81) from the Doubao strip (0.21) and other AI output (0.0), so the two ByteDance marks don't cross-fire in `--mark auto`. **Logo is pure white (255,255,255)** (`_ALPHA_LOGO_BGR`; the white capture + an L-pair-solve confirm ~254.6); compositing is **sRGB, not linear** (a linear-light solve tripled the cross-residual). **Alpha rebuilt by `scripts/visible_alpha_solve.py` from the GRAY capture** (`data/jimeng_capture/captures/`, the solid captures now committed): `a = (I - B)/(255 - B)`, B a per-capture **cubic** background fit over the non-glyph pixels, **averaged over channels, full halo extent (down to a~0.02), unblurred**. Gray (bg ~132) is the deliberate choice over black: it is the best proxy for real content (the mark sits on bright photo areas, not on black), and the careful build drops the gray self-residual to ~1.3. **The mask quality, not the method, was the earlier limit** — a max-channel / quadratic-bg / blurred / halo-truncated build (and a black-dominated LS) left a visible outline (lesson from issue #13: when reverse-alpha leaves a ghost, suspect the captured alpha map before adding heuristics or switching method). Geometry emitted by the solver at `_ALPHA_NATIVE_WIDTH` 2048: `_ALPHA_WIDTH_FRAC` 0.202, `_ALPHA_HEIGHT_FRAC` 0.058, margins ~0.029. **Removal = reverse-alpha + a deliberately THIN residual inpaint** (`remove_watermark_reverse_alpha`, `_RESIDUAL_DILATE` 5 over the `_RESIDUAL_ALPHA_FLOOR` 0.05 footprint, `_RESIDUAL_INPAINT_RADIUS` 2, `INPAINT_NS`): a single 2048 alpha cannot pixel-cancel the mark re-rasterized at another resolution (alpha maps from independent captures correlate 0.998, not 1.0; off-native reverse-alpha alone only halves the mark), so a tight inpaint clears the residual edges WITHOUT the texture/edge smear a wide full-footprint pass caused. **Placement ALWAYS tries fixed geometry AND `_aligned_alpha_map`'s NCC scale+position search, keeping the lower-residual** — the mark re-rasterizes + jitters a few px per image even at the captured width, so fixed geometry alone misses (there is no `_ALPHA_NATIVE_BAND` fast-path; the scale search `_ALPHA_ALIGN_SEARCH` is fine-stepped, and the `WM_*` locate box is generous so a corner-ward shift stays inside the search — the same widen that fixed Doubao). Verified clean on the solid captures (native 2048; faint self-residual ~1.3 visible only on a dead-flat field, hidden by real texture) and a real 1440-wide Jimeng download (off-native, table edge preserved). `reverse_alpha_available` is just "asset present"; the registry gates on `detect`. **No committed real sample** (the real content download stays gitignored; only the solid calibration captures are committed) — `tests/test_jimeng_engine.py` synthesizes a mark from the bundled alpha asset, and `test_recovers_shifted_mark_on_texture` guards the align-on-shift path that the Doubao defect exposed. Jimeng images are independently caught by the China TC260 AIGC label in `metadata`/`identify`, so this engine is the visible-mark *removal* path, not a new `identify` signal.
|
||||
- `region_eraser.py` — universal region eraser (`erase` CLI). `erase(image, boxes=|mask=, backend=)` normalizes grayscale (2D) and RGBA (4-channel) inputs up front (`erase_cv2` splits off any alpha plane and re-attaches it on the result): `boxes_to_mask` → `cv2.inpaint` (`cv2` backend, default, no deps) or big-LaMa via onnxruntime (`lama` backend, extra `lama`, `Carve/LaMa-ONNX` Apache-2.0 model downloaded on first use, never bundled). `erase_lama` crops a padded region around the mask, runs LaMa at its fixed 512² input, pastes only masked pixels back (untouched areas stay pixel-exact). Lazy `_get_lama_session` singleton; `lama_available()` guards the optional import. **LaMa-ONNX costs ~3.5-4 GB peak RAM and ~5-6 s/call on CPU** (FFC working set, not arena — `enable_cpu_mem_arena=False` does not help), so it does NOT fit a minimal droplet; the cv2 backend (tens of MB, ~30 ms) does. LaMa quality at low RAM = serverless/GPU, mirroring how raiw.cc offloads SDXL to fal.
|
||||
- `invisible_watermark.py` — `detect_invisible_watermark(path)` decodes the OPEN DWT-DCT watermarks (public decoder, no key) embedded by Stable Diffusion / SDXL / FLUX via the `imwatermark` library. Known fixed patterns (verified against upstream source) live in `_BITS_48` (SDXL 48-bit, FLUX.2 48-bit) and `_SD1_STRING` ("StableDiffusionV1", SD 1.x/2.x). Optional dep (extra `detect`); returns None when absent. The `detect` extra pulls **torch** transitively (invisible-watermark declares torch a hard dep, and `WatermarkDecoder` eagerly imports `rivaGan` -> `torch` at import time), so detection needs torch present even though dwtDct runs CPU-only on cv2/numpy/pywavelets — no GPU and no separate `gpu` extra required. **Unlike SynthID this is locally detectable**, but the watermark is fragile (does not survive JPEG re-encode/resize — verified gone after JPEG q90), so it confirms origin only on pristine files. Add new known patterns here. The file carries a top-of-module pyright pragma because imwatermark/cv2 ship no type stubs.
|
||||
- `trustmark_detector.py` — `detect_trustmark(path)` decodes the OPEN, keyless **Adobe TrustMark** watermark (the soft binding behind Adobe Durable Content Credentials, `alg` `com.adobe.trustmark.P`) via the optional `trustmark` package (extra `trustmark`; pulls torch, downloads model weights on first use). Mirrors `invisible_watermark.py` (lazy singleton, top-of-module pyright pragma, returns None when absent). It detects *provenance*, not AI origin as such (TrustMark also marks human-authored content), so `identify` lists it as a watermark without setting `is_ai_generated`. Other soft-binding vendors (Digimarc/Imatag/Steg.AI/...) have no public decoder — they are only *named* via the `C2PA_SOFT_BINDINGS` scan, not decoded.
|
||||
- `face_protector.py` — YOLO detect + soft-blend pattern; mirror this for any "protect region during diffusion" features
|
||||
- `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 guarded by a double-checked `threading.Lock` so concurrent callers do not double-download the weights, 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. **False-positive gate (added 2026-05-29):** TrustMark's `wm_present` is a BCH error-correction validity flag that spuriously validates on a content-correlated fraction of un-watermarked images — AI-generated textures trip it far more than camera photos (verified 2026-05-29 on real files: it fires on Gemini/OpenAI/Doubao output that *cannot* carry Adobe's watermark, with a random-bytes decoded secret, while signal-free camera photos did not trip it). A genuine TrustMark is a *durable* soft binding engineered to survive re-encoding, so `detect_trustmark` re-decodes after a mild JPEG round-trip (`_survives_reencode`, `_REENCODE_QUALITY` 95) and requires the same schema both times; every observed false positive collapsed (none survived even q95), so the gate is the durability property the watermark guarantees. The second decode runs only on the rare initial hit, so the cost is negligible. Do NOT remove the gate to "catch more" — a lone TrustMark hit without it is almost always content noise.
|
||||
- `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 DB** ONNX detector (~2.4 MB, Apache-2.0, opencv_zoo, 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)**. **Detection is script-agnostic** (DB segments text *regions*, not characters), so Latin / Cyrillic / CJK / Hangul / Arabic / digits all detect identically — language was never the recall lever, **resolution was**. `_detection_input_size(h, w)` (pure, unit-tested) detects at the **native long side capped at `_DET_MAX_LONG_SIDE` (1536), never upscaled**: the old fixed 736 downscaled large canvases so small text fell below the detector and was missed (issue #14, e.g. ~16 px text on a 2048 image). `scripts/text_detection_benchmark.py` measures recall across scripts × sizes × canvas: the cap fix lifts overall hit-rate 0.91 → 1.00 (worst cell 2048/16 px: 0.06 → 1.00) at ~100 ms CPU. Very large canvases with tiny text may still need tiling (documented limit, not built). `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). **Default text protection is `watermark_remover._run_region_hires`, NOT the differential change map.** Differential Diffusion froze text in latent space (`preserve`<1.0), so the watermark survived *inside* text — violating the "remove SynthID everywhere" requirement; and the SDXL VAE's 8px latent cell softens sub-8px strokes regardless of `preserve` (architectural limit, confirmed by the DD authors — see `docs/text-protection-research.md`). `_run_region_hires` instead: (1) scrubs the whole image (plain img2img), (2) RE-scrubs each detected text block at HIGH resolution and feather-composites it back. `merge_text_regions(boxes,h,w)` groups boxes into local blocks; each crop is upscaled by `_REGION_HIRES_SCALE` 3.0 (applied as an integer factor via int(...), capped so a region stays under `_REGION_MAX_MEGAPIXELS` 1.3 to avoid OOM; skipped if it can't reach 2x — very large text areas then fall back to the global scrub, tiling is the future fix), img2img-scrubbed, downscaled, **phase-correlated back to the original crop to null the ~1-2px round-trip offset** (the shift is applied only on a confident, small correlation -- `response > 0.3` and `|shift| < 4` -- so a spurious large offset on a flat crop no longer garbles the composite; and after a CPU fallback the generator is dropped before the per-region passes to avoid an MPS-vs-CPU generator device mismatch) (a sub-pixel shift garbles the composite even when text is crisp; integer scale alone did NOT fix it because the diffusion pipeline rounds dims to a multiple of 8), then `feather_paste`d. Every pixel is regenerated, so the watermark is removed everywhere AND small text stays crisp (high-res strokes span >1 latent cell). Validated on synthetic 18px multilingual text: text-region SSIM 0.28 (plain) → 0.48 (region-hires), visually garbled → readable across Latin/Cyrillic/CJK, residual shift ~0.5px. Gated to the SDXL `DEFAULT_MODEL_ID` + detector (`_can_protect_text`); no text → plain global scrub (text-free inputs pay only the cheap cv2 detection). CLI opt-in `--protect-text` on `invisible`/`all` (**OFF by default** — see SynthID bullet). `merge_text_regions` + `feather_paste` are pure, unit-tested without a model (`tests/test_text_protector.py`). **The high-res re-scrub can shield SynthID in text regions** (verified 2026-06-01: same gpt-image, with `--protect-text` → SynthID detected by oracle; without → SynthID removed). The mechanism: the global pass at step 1 removes SynthID everywhere, but the per-region high-res re-scrub at step 2 regenerates those pixels from a higher-resolution crop -- if the per-region strength is insufficient at the effective upscaled resolution, SynthID can reconstitute. Until this is resolved, `protect_text` is EXPERIMENTAL and OFF by default. The legacy `_run_differential` / `build_change_map` / `_load_differential_pipeline` (community `pipeline_stable_diffusion_xl_differential_img2img`, `custom_revision="0.38.0"`) remain in the file but are no longer the default; the diff pipeline upcasts the VAE to fp32 internally, so do **not** add `upcast_vae()`/`enable_attention_slicing` there (NaN/black on fp16 MPS). `build_change_map` is still unit-tested.
|
||||
- `face_protector.py` — YOLO detect + soft-blend pattern; mirror this for any "protect region during diffusion" features. The expensive extract+blend already runs only when a face is found, but the YOLO detector itself always loads+runs to decide; CLI opt-in `--protect-faces` on `invisible`/`all` (**OFF by default**, experimental). **Face protection has an even more direct SynthID-preservation mechanism than text protection:** it extracts face regions from the ORIGINAL (watermarked) image BEFORE the diffusion pass, then blends those original pixels BACK after the global pass. Those restored pixels are the unprocessed originals -- SynthID is guaranteed to survive in face regions (not just possibly, as with text re-scrub). Any image with faces processed with `--protect-faces` will have SynthID intact in the face areas regardless of strength.
|
||||
- `humanizer.py` — optional post-process "humanize" effects (cv2/numpy). The chromatic-shift step replicates the border instead of wrapping opposite-edge pixels, so a shifted channel no longer bleeds the far edge into the near one.
|
||||
- `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.** `imwrite` returns `False` on an unwritable path (`OSError` caught) instead of raising, matching `cv2.imwrite` semantics. 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)
|
||||
### Doubao clean-reverse-alpha distillation (re-investigated 2026-05-29)
|
||||
|
||||
Plan to get Gemini-style pixel-accurate reverse-alpha for Doubao at cv2 cost (so it runs on a minimal CPU droplet): use LaMa **offline** to reconstruct the clean background `O`, then per-pixel solve `I=(1-α)O+αL` for the alpha map + logo colour (this is Google's CVPR-2017 multi-image idea, with LaMa standing in for the multi-image optimisation). Validated in principle (glyph error drops as alignment improves) but **blocked on data**, confirmed empirically over 6 attempts: a clean canonical alpha map needs **several PRISTINE images at ONE resolution** with varied backgrounds. The findable public Doubao images (ayaya, Tools_CleanMark, ours) are all different resolutions AND lossy (jpg/webp), and Doubao's mark position drifts ~±50 px even at the same resolution, so cross-image registration (ECC / phase-correlation) fails (1/11 templates aligned) — error floor ~64/255 on glyph pixels, not shippable. To finish: ~5-8 pristine same-resolution Doubao originals → per-image α via LaMa → align the clean α templates → median → ship the tiny map + an NCC localiser (Doubao's mark drifts, so runtime needs the same locate-then-reverse-alpha the Gemini engine already does). Don't retry on lossy/mixed-resolution scraped images — proven insufficient.
|
||||
**RESOLVED 2026-05-29: black+gray Doubao captures were obtained and a reverse-alpha is built** (`doubao_engine.remove_watermark_reverse_alpha`, `assets/doubao_alpha.png`; see the `doubao_engine.py` bullet above). The captures (`data/doubao_capture/captures/`, now committed) confirmed the alpha-composite model: on black `captured = a*logo`, logo pure white. **UPDATE 2026-05-31 (issue #13 follow-up): the first build was NOT "exact"** — it left a readable "豆包AI生成" outline on the real sample (the detector was fooled, conf 0.0). The alpha is now rebuilt by `scripts/visible_alpha_solve.py` (the careful gray-self solve shared with Jimeng), removal always-aligns + thin-inpaints, and the locate box was widened; see the `doubao_engine.py` bullet. The notes below (the failed content-image distillation) are retained as the record of why controlled captures were necessary.
|
||||
|
||||
**Conclusion (historical): pure reverse-alpha distilled from content images does NOT work, and the blocker is the WRONG kind of data, not too little of it.** The earlier framing ("need ~5-8 PRISTINE same-resolution originals") is obsolete -- `data/spaces/originals/` holds plenty. Curate them with `DoubaoEngine.detect` + an NCC filter against a clean glyph template, keeping only marks at offset ≈ (0,0): that yields e.g. **15 pixel-aligned 2048² marks** (sub-pixel drift, not the ±50 px the old lossy/mixed-res scrapes had), plus 1086x1448 / 1792x2400 clusters. With those, LaMa-clean `O` + weighted-LS (and per-pixel I-on-O regression) for `α` (+ logo colour) was tried end-to-end and **still leaves a persistent ghost outline.**
|
||||
|
||||
Diagnosed why, empirically (cached stacks, `/tmp/doubao_distill`): (1) the mark is a clean white overlay with **no dark halo** -- over glyph pixels ~54% are brighter than the clean bg, only ~4% darker -- so the white-logo model `I=(1-α)O+α·255` is correct; (2) but content backgrounds are almost never dark *under* the mark (median darkest available bg over glyph pixels = **58/255**; only ~13% of mark pixels are ever observed on a bg < 40), so on bright backgrounds the equation is ill-conditioned and `α` is unidentifiable; (3) LaMa's `O` is a plausible **hallucination**, not the true pre-mark background, which compounds the error, and per-pixel regression on ~15 obs overfits into colour noise.
|
||||
|
||||
**Why Gemini's engine is clean (verified in GeminiWatermarkTool `src/core/watermark_engine.cpp`): its alpha map is the watermark stamped on a PURE-BLACK background**, where `watermarked = α·255 + (1-α)·0 = α·255`, so `alpha = capture/255` exactly -- no estimation. (`gemini_bg_*.png` is literally the sparkle in grey on black.) So the real Doubao unlock is the same controlled capture, **not more content images**. Black/white/gray seeds exist (`data/doubao_capture/seeds/seed_*_1x1_2048x2048.png`); a capture run (feed a black seed through doubao.com edit mode, download the *original*) was requested from the #13 reporter 2026-05-29. With ~2-3 black captures we get `α = capture/255` for free, Gemini-quality.
|
||||
|
||||
**Until black captures arrive, the shipped direction is precise canonical glyph mask + inpaint (cv2 default, lama optional), NOT reverse-alpha.** The consensus glyph silhouette across the aligned marks distills cleanly (proto: a tight "豆包AI生成" strip, width ≈ 0.156 × image-width) and is good both as an exact inpaint mask and as an NCC localiser -- the latter also fixes the #23 detector false-positives (match the real glyph shape, not any bright low-saturation corner). Do **not** retry content-image reverse-alpha: it is data-limited by physics (no dark-background observations), not by effort.
|
||||
|
||||
## Watermarking landscape (research 2026-05-24)
|
||||
|
||||
Who embeds what, and whether it is locally detectable (so we know which gaps are fillable). See `identify.py` for what we read.
|
||||
- **Locally detectable (open decoder, no key/API):** Stable Diffusion / SDXL / FLUX via `imwatermark` DWT-DCT (now covered by `invisible_watermark.py`). FLUX uses the same library (`black-forest-labs/flux2` `src/flux2/watermark.py`, 48-bit `0b001010101111111010000111100111001111010100101110`); SDXL is the diffusers `WATERMARK_MESSAGE` (`0b101100111110110010010000011110111011000110011110`). Caveat: fragile to re-encoding.
|
||||
- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Designer + **Bing Image Creator** — collected 2026-05-24; Bing now runs Microsoft's own **MAI-Image** model, signs C2PA as "Microsoft", NOT OpenAI/DALL-E), and **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`). Still unsampled: Canva (its downloads are re-encoded design *exports* that strip C2PA, so a Canva "positive" is inconclusive — skipped), Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag).
|
||||
- **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). **Samsung Galaxy AI** (Generative Edit / Sketch to Image / Portrait Studio on Galaxy S23 FE / S24 / S25, One UI 7+) signs C2PA as "Samsung Galaxy" with the standard `trainedAlgorithmicMedia` source type AND a proprietary `genAIType` marker; verified on real signed files 2026-05-29 (the standard scan catches the source type; `genAIType` additionally catches a Galaxy S24 file that omits it). **ASUS Gallery** also signs edited photos as C2PA (`com.asus.gallery`) but with no AI source type — a signer, not an AI marker. **Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)"). **ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. All three verified on real signed files 2026-05-29.
|
||||
- **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 **three serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `<TC260:AIGC>{...}</TC260:AIGC>` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on the corpus 2026-05-30) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on the corpus 2026-05-28, `ContentProducer="doubao"`), **and** a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on the corpus 2026-05-30, `ContentProducer="001191440300708461136T1308L"`). Both generic forms (the PNG chunk and the bare `{"AIGC":...}` object) are gated on at least one TC260 field (`_TC260_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. 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.
|
||||
- `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 points (the OOM is memory-tier-dependent, NOT a hard MPS limit):** on a ~24 GB unified-memory machine (verified 2026-05-25, 1254x1254 gpt-image SDXL, fp32) 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. On a **32 GB** unified-memory machine the same default SDXL pass runs entirely on MPS with **no CPU fallback** (verified 2026-05-31, 1122x1402 gpt-image, `all`/default, ~155 s end-to-end), so 32 GB clears the native-res UNet peak that 24 GB could not. Adding `enable_vae_tiling()` alone does NOT prevent the 24 GB OOM (the peak is the UNet, not the VAE). The fast Mac workarounds for memory-constrained machines are fp16 on MPS (roughly halves memory) or `--max-resolution` to cap the long side; neither is wired as the default. **ctrlregen is compute-bound, not memory-bound, on MPS:** the clean-noise profile tiles to 512px (e.g. ~12 tiles for a 1122x1402 image) and runs the full step count per tile (strength 1.0 -> ~50 effective steps/tile), so on a base-tier Apple-GPU laptop it is ~25-30 min/image after the one-time DINOv2-giant download, regardless of having 32 GB -- a discrete CUDA GPU (fp16, no tiling) is the right place for ctrlregen, while the default SDXL pass is comfortable on a 32 GB Mac. 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.
|
||||
- **fp16 VAE black-output fix (issue #29, 2026-05-30):** on a **CUDA/XPU fp16** backend the stock SDXL VAE overflows to NaN and the *plain* img2img path decodes to an **all-black** image (reproduced on the raiw.cc result: a 1086x1448 input -> a uniformly black 4.6 KB PNG, mean 0). `watermark_remover._load_pipeline` now swaps in the fp16-fixed SDXL VAE (`madebyollin/sdxl-vae-fp16-fix` = `_SDXL_FP16_VAE_ID`) when `_needs_fp16_vae_fix(model_id, DEFAULT_MODEL_ID, is_fp16)` is true -- only the default SDXL checkpoint on fp16. **cpu/mps run fp32** (the stock VAE is fine there, which is why the bug never reproduces on Mac), and the **differential / region-hires** pipeline already upcasts the VAE itself (see the `text_protector` bullet). A custom non-SDXL `model_id` keeps its own VAE (the fp16-fix VAE is SDXL-architecture-specific). The decision is a pure helper, unit-tested without a download (`tests/test_platform.py::TestFp16VaeFix`); the actual black->clean recovery needs a CUDA GPU and was NOT verifiable on this MPS machine -- confirm on the backend / an NVIDIA box.
|
||||
- Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs); full-project `uv run pyright` can stall for many minutes — scope it to changed files.
|
||||
- `ultralytics` monkey-patches `PIL.Image.open` and tries to autoload `pi_heif`. When `pi_heif` is missing, opening files raises `ModuleNotFoundError`, not `UnidentifiedImageError`. Code that opens user-supplied or unknown-format files should `except Exception`, not just `OSError`/`UnidentifiedImageError`.
|
||||
- Metadata detection for AVIF/HEIF/JPEG-XL relies on a binary scan for `C2PA_UUID` + `IPTC_AI_MARKERS`, plus EXIF `Software` / XMP `CreatorTool` generator tags via `metadata.exif_generator` (validated with synthesized AVIF/JPEG fixtures + an XMP raw-scan fixture). C2PA removal in those containers is implemented via `noai/isobmff.py` (top-level ``uuid`` / ``jumb`` box stripper, no re-encoding), which now also drops a top-level XMP ``uuid`` box that carries an AI label (matched by AI-marker content, not by the XMP UUID, so byte-order-robust) and covers MP4/MOV/M4V/M4A by content sniff. **Non-ISOBMFF audio/video removal is via ffmpeg** (`_FFMPEG_STRIP_EXTS` -> `_strip_with_ffmpeg`): WebM/Matroska (EBML), MP3 (ID3), WAV/FLAC/OGG (RIFF/Vorbis) are stripped losslessly with `ffmpeg -map_metadata -1 -map_chapters -1 -c copy` (codec data untouched). Requires ffmpeg on PATH; raises `RuntimeError` if absent or if ffmpeg can't parse the file. Verified end-to-end (a real ffmpeg-made WAV/MP3 with a `title=Suno AI` tag -> tag gone, audio bytes preserved). **Still NOT built (deliberate):** EXIF/XMP stored as *items inside the ``meta`` box* (typical for AVIF/HEIF images) needs meta-box surgery (iinf/iloc edit + mdat splice) with corruption risk -- exiftool would do it but is a non-installed binary dep, so it stays a documented gap. **Audio watermark DETECTION (Resemble PerTh) was evaluated and NOT built (2026-05-26):** `resemble-perth`'s `PerthImplicitWatermarker.get_watermark()` returns a raw bit-array with **no presence/confidence flag** (clean audio decodes to arbitrary bits too), so reliably distinguishing watermarked-from-clean needs either Resemble's fixed payload or a confidence API -- neither is public, and there's no real Resemble sample to calibrate against. Same wall-class as the SynthID pixel detector: the decode exists, reliable presence-detection does not. (perth's top-level `PerthImplicitWatermarker` is also gated to None unless `librosa` is importable.)
|
||||
- **SynthID detection is metadata-only.** There is no reliable *local* detector of the SynthID *pixel* watermark — Google's decoder is proprietary, no public spec or API (only a waitlisted portal). Authoritative confirmation: Google DeepMind's own paper "SynthID-Image: Image watermarking at internet scale" (Gowal et al., arXiv:2510.09263) states the verification service is restricted to "trusted testers" and does not release detector weights or a reproducible algorithm — so a local pixel detector is infeasible by design, not just unbuilt. https://arxiv.org/abs/2510.09263 We detect SynthID by its C2PA companion (`synthid_source` / `SYNTHID_C2PA_ISSUERS`), which is reliable while the manifest is intact but says nothing once C2PA is stripped. **Surface-dependent blind spot (verified 2026-05-24):** the same Google model emits different metadata per surface -- the Gemini *app* wraps outputs in Google C2PA, but the *API/playground* (AI Studio, Nano Banana / gemini-2.5-flash-image) emits the SynthID *pixel* watermark (confirmed via the Gemini-app oracle) + the visible sparkle but **no C2PA/IPTC at all**, so `synthid_source` returns None despite SynthID being present. Only the pixel oracle or the visible-sparkle detector catches those. (Meta AI is another surface mismatch: it writes the IPTC `digitalSourceType=trainedAlgorithmicMedia` marker, not C2PA and not SynthID.) Google→SynthID is long-standing; OpenAI→SynthID is confirmed by OpenAI's Help Center (ChatGPT/Codex/API "include both C2PA metadata and SynthID watermarks", updated 2026-05-21) but time-gated (pre-rollout OpenAI images carry C2PA without SynthID), so the OpenAI verdict is hedged "likely". Oracles: Gemini app "Verify with SynthID" (Google), openai.com/verify (OpenAI). The spectral phase-coherence approach from `github.com/aloshdenny/reverse-SynthID` was evaluated (May 2026) and **does not work for real-content detection**: on its own shipped codebook + validation set, watermarked and cleaned images were indistinguishable (conf within noise, cleaned often higher); it only fires on pure-black 1024x1024 reference images at exact resolution (the controlled case it was calibrated on). The README's "90% / conf=0.91" reproduces only in that lab condition. Do not build a production detector on it; if revisited, it is experimental/diagnostic only and needs a per-resolution, per-model reference corpus. A from-scratch gpt-image pilot (2026-05-24) confirmed this independently: 5 independent solid-black gpt-image outputs share a near-identical fixed signature (pairwise residual correlation **0.92**, avg-template retains 97% energy), so the watermark/carrier IS strongly present and consistent on flat content — but the carrier frequencies extracted from it do NOT discriminate real content (carrier-to-random ratio: cleaned 1.86 > watermarked 1.53; a non-gpt-image image scored highest at 3.67). The signature drowns in content texture. Net: a perfectly consistent solid-color signature still yields no real-content pixel detector with magnitude/carrier methods. A corpus discrimination test (2026-05-24, `scripts/synthid_pixel_probe.py`, raw zero-mean residual NCC) independently re-confirms this: at matched resolution, SynthID positives do NOT cluster apart from negatives (within-Gemini 0.07; at 1024 px pos-vs-neg >= pos-vs-pos). The only high correlations were near-duplicate *content* (5 ChatGPT renders of one prompt at ~0.92, while a distinct ChatGPT image scored ~0 against them) — content, not a carrier. The probe is solid-fills-only and EXPERIMENTAL/DIAGNOSTIC; do not use it on real content. **Correction (deeper re-examination 2026-05-25):** the carrier IS real on solid fills — the earlier "no carrier" was a *method* artifact of using spatial / FFT-magnitude NCC, which can't see it. The carrier is a fixed *phase* at specific low frequencies, so the right metric is **per-bin phase coherence**. On 8 white `gemini-2.5-flash-image` fills (generated via the reverse-SynthID trick: identity-edit prompt "Recreate this image exactly as it is" on a synthetic pure-white PNG — this bypasses the recitation block that rejects text prompts for pure colors), phase coherence at the white carriers `(0,±7..±12,±20..±23)` = **0.86** vs **0.31** random; single-image leave-one-out phase-match **+0.83** vs real photos **-0.24**. (Black `2.5-flash` fills clip to std≈0 — SynthID can't push values below 0, so no carrier in black; the repo's dark carriers come from nano-banana-pro.) **But it does not generalize:** (a) carriers are model-version + resolution + color specific — the repo's v4 codebook (built for `gemini-3.1-flash-image-preview` + `nano-banana-pro-preview`) scores ~0.527 on my 2.5-flash white fills, indistinguishable from negatives (~0.50), i.e. carriers shift across model versions and need a per-model codebook; (b) on real content (30 `2.5-flash` images) the carrier collapses — set phase coherence at carriers 0.37 ≈ random 0.42, and the repo's v4 deteLine truncated
|
||||
- **rich was dropped (CLI + scripts print plain text via `click.echo`).** `cli.py` renders through small `_Console`/`_Table`/`_Progress` shims; the analysis scripts (`scripts/synthid_corpus.py`, `synthid_pixel_probe.py`, `text_detection_benchmark.py`, `corpus_gap_scan.py`) import `Console`/`Table` from the shared `scripts/_plain_console.py` shim (markup like `[bold]`/`[/]` is stripped, tables render aligned). Consequences: (1) `rich` is NOT a dependency, so anything that imports it breaks a clean `uv sync --frozen` (CI installs core+dev only) — this exact gap red-failed CI after the refactor when those 4 scripts still imported rich; if you add a script, use the `_plain_console` shim, not rich. (2) The old `[gpu]`-bracket-eaten bug (#19) is gone — plain `click.echo` prints `pip install 'remove-ai-watermarks[gpu]'` verbatim, no escaping needed (regression-guarded by `tests/test_cli.py::TestGpuHintMarkup`). (3) No Unicode glyphs / colors / progress bars in CLI output by design.
|
||||
- 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 technical reference: `docs/synthid.md`** — primary-source-cited doc covering mechanism (post-hoc encoder/decoder pair, 136-bit payload at 512x512, pixel-space, model weights NOT modified), robustness numbers (arXiv:2510.09263: ~99.98% TPR@0.1%FPR across 30 transforms including JPEG/crop/resize/color/noise), removal attacks and forensic detectability (arXiv:2605.09203: all 6 attacks detectable at >98% TPR@1%FPR), detectability limits (no public decoder, metadata-proxy only), oracle scope, and adoption landscape. Read that doc first before adding notes here.
|
||||
|
||||
- **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). **Each vendor's oracle detects only its OWN content (verified on the page 2026-05-31):** `openai.com/research/verify` states verbatim "OpenAI generation signals will only be detected if the image was generated with our tools" and "Content could also still be AI-generated by another company's model, which the tool currently does not detect" -- SynthID is shared tech but the verifier is keyed to its own vendor's payload, so a Google-SynthID image reads clean on OpenAI's verifier and vice-versa. **This explains the recurring "oracle says clean but `identify` still flags SynthID" report (#14):** the oracle reads the *pixel* watermark (gone after our SDXL pass), while `identify` reads the *C2PA-metadata proxy* (still present if the manifest survived). Different signals, not a contradiction -- strip the metadata too (`metadata --remove` / `all`) and the proxy goes quiet, but a quiet proxy is not proof the pixel watermark is gone. **SynthID is durable to JPEG re-encode by design, so a GitHub-recompressed issue attachment is still a valid SynthID test subject** (verified 2026-06-01 on issue #14's pic3: the GitHub-served JPEG survived re-encoding and openai.com/verify still detected SynthID). Do NOT dismiss issue-attachment JPEGs as "not faithful originals" when reproducing a SynthID-survival report: the recompression strips the **C2PA metadata** (so `identify` reads Unknown on the attachment) but NOT the **pixel watermark** that openai.com/verify reads. A true byte-original only matters for the metadata/C2PA path, not for the pixel-SynthID-removal test. (Contrast the open imwatermark above, which IS fragile to JPEG.) 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 Line 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
|
||||
- **DEFAULT STRENGTH IS NOW VENDOR-ADAPTIVE (2026-06-01, SUPERSEDES every fixed-default claim in this bullet and the next).** `resolve_strength(strength, profile, vendor)` + `vendor_for_strength(path)` (`watermark_profiles.py`) read the C2PA issuer (`metadata.synthid_source`) on the ORIGINAL input and pick `OPENAI_STRENGTH` **0.10** / `GEMINI_STRENGTH` **0.15** / `UNKNOWN_STRENGTH` **0.15** when `--strength` is unset; explicit `--strength` always wins. The CLI detects the vendor from the pristine source (before the visible pass / metadata-strip removes C2PA from the temp file) and passes it to the engine, so display and execution agree; `cmd_invisible`/`cmd_all`/`batch` + the module-level `remove_watermark` all thread `vendor`. **This replaces the single 0.30 default AND the prior "do NOT build a vendor-adaptive default" policy** -- both came from the now-debunked protect-text-contaminated study. Basis: the oracle-verified June 2026 controlled study (clean v0.8.6, protect OFF): OpenAI clears at 0.05 across 1024-1600 (n=4, resolution-independent); Google needs 0.15 on the capped-1536 path (n=4). `docs/synthid.md` §2.2 (data) + §5.2 (the adaptive default) are authoritative. CAVEAT: Google's 0.15 was validated only on `--max-resolution 1536`; native large Gemini (2816) was not locally measurable (OOM on M-series) and is pending GPU validation on raiw.cc -- if it survives 0.15 native, raise `--strength`. **Everything below in this bullet about a fixed 0.10/0.30 default is HISTORICAL; trust the vendor-adaptive constants + docs/synthid.md.**
|
||||
- **SynthID v2 vs default pipeline (HISTORICAL, see the vendor-adaptive correction above):** **CORRECTION (2026-05-31, oracle-verified GPU study, SUPERSEDES the 0.10 claim below): the current Gemini SynthID survives 0.10/0.15/0.2 and is REMOVED only at strength 0.3** (Modal A100, native res, Gemini-app "Verify with SynthID", n=3 FRESH Gemini images, `protect_text`/`faces` OFF; 0.2 still present, 0.3 removed). `DEFAULT_STRENGTH` was raised **0.10 -> 0.30** to match. The "0.10 removes it" finding below was n=1 and is now stale -- the threshold has climbed 0.05 -> 0.10 -> ~0.3 as Google hardens SynthID, so re-test against fresh Gemini periodically (moving target). 0.3 costs SSIM ~0.97 vs original (modest) but softens dense/fine typography, and is **overkill for non-SynthID sources** (OpenAI/ChatGPT carry C2PA, NOT Google SynthID -- their pixels read negative on the Gemini verifier at every strength, so 0.10 is plenty there; a per-source strength via `identify` was considered and deferred in favour of the simpler single 0.3 default). **`protect_text` is OFF by default (CORRECTED 2026-06-01, supersedes earlier A/B findings):** verified on a gpt-image at 1600x1600 (issue #14, June 2026 oracle study): same image, with `--protect-text` → SynthID detected by openai.com/verify; without → SynthID removed. The 2026-05-31 A/B finding ("protect_text does not block removal") was Gemini-SynthID-only and did not generalize to OpenAI gpt-image at 1600x1600. Mechanism: the global pass removes SynthID everywhere, but the per-region hires re-scrub regenerates those pixels from an upscaled crop -- at that effective resolution the per-region pass may be insufficient to re-destroy the payload. Both `protect_text` and `protect_faces` are now **EXPERIMENTAL, opt-in** (`--protect-text` / `--protect-faces`), OFF by default. **Oracle scope (load-bearing):** the Gemini app "Verify with SynthID" is the ONLY valid SynthID oracle (it detects Google's mark on any image); `openai.com/verify` is scoped to OpenAI provenance (its own C2PA) and is **NOT** a SynthID oracle, so a negative there is meaningless for SynthID and the older "OpenAI cleared at 0.05 on openai.com/verify" notes below are about provenance, not a pixel-SynthID measurement. **CORRECTION (2026-05-30): strength 0.05 does NOT remove the CURRENT Google SynthID (Nano Banana / Gemini 3).** Re-verified via the Gemini "Verify with SynthID" oracle on a real image: at 0.05 SynthID is still detected; at **0.10 it is removed** (OpenAI's SynthID was already cleared at 0.05). So the default strength was raised 0.05 -> **0.10** (`DEFAULT_STRENGTH` in `watermark_profiles.py`; CLI `--strength` defaults to 0.10), and that higher strength is exactly why text protection (`_run_region_hires`) runs by default (text deforms more at 0.10). Caveat: n=1 Google + n=1 OpenAI image so far -- broad oracle validation across the corpus is pending (different images may need a different strength). **Resolution dependence confirmed by a user report (#14, qw1212ss, 2026-05-31):** on 1600x1600 gpt-image outputs checked via openai.com/verify, 0.05 left SynthID detected on **7/8** images, while small images (376x429) cleared at ~100% -- so "OpenAI cleared at 0.05" was a low-resolution result; a larger canvas carries a stronger watermark and needs more strength. **CORRECTION (2026-06-01, oracle-verified controlled study, SUPERSEDES the resolution-dependence claim above): the "1600 needs more strength" result was the protect-text bug, NOT resolution.** qw1212ss ran v0.7.2 with `protect_text` ON by default; on dense-text infographics the hires text re-scrub re-introduced SynthID. Re-running the SAME 1600x1600 gpt-image on clean v0.8.6 (protect OFF) removes SynthID at **0.05**. Controlled June 2026 study (clean v0.8.6, protect OFF, oracle-verified per image; test set in `data/synthid_corpus/`): **OpenAI clears at 0.05 across 1024-1600 (n=4), resolution-independent; Google (Gemini) needs 0.15 on the capped-1536 path (n=4), 0.05/0.10 do NOT clear.** The dominant real factor is **vendor** (~3x: Google harder), not resolution. Native large Gemini (2816) was not locally measured (OOM/CPU on M-series) -- it plausibly needs >= 0.30. **`docs/synthid.md` section 2.2 is the authoritative source for these numbers; the year-over-year "0.05 -> 0.10 -> 0.30" progression in this bullet conflates real Google hardening with the now-debunked protect-text artifact.** **Policy (do NOT chase a single magic number or build resolution/vendor-adaptive defaults): 0.10 is the default because it is what clears the watermark today; if the oracle still reads SynthID, the guidance is simply to raise `--strength` (0.12, then 0.15), using the lowest value that verifies clean.** There is no local SynthID detector, so the tool cannot self-check and auto-tune; both vendors tighten the watermark over time, so any fixed value is a moving target. README "Removing SynthID" documents the strength-ladder guidance for users. The original claim below (0.0Line truncated
|
||||
- **CtrlRegen profile uses a clean-noise default strength, NOT the SDXL 0.10 (fixed 2026-05-31).** **CORRECTION (2026-05-31, same oracle-verified GPU study): ctrlregen at its clean-noise strength DESTROYS real images** -- smooth/background regions fill with hallucinated micro-text garbage; the pipeline is binary (low strength = no-op, high = destroy, no usable middle) and heavy (~8.5 min / ~$0.30 vs ~25 s / ~$0.02 for SDXL). So the literature's "clean-noise is the lever" (detailed below) did NOT survive empirical testing on real content. ctrlregen is now flagged **EXPERIMENTAL** (CLI `--pipeline` help, README, and the `watermark_profiles` comment) and is **NOT for production** -- SDXL img2img at ~0.3 is the shippable path. The clean-noise-default plumbing below is kept (so the profile at least does real work if anyone opts in), but do not recommend ctrlregen. `--pipeline ctrlregen` no longer inherits the SDXL img2img `--strength` default. `resolve_strength(strength, profile, vendor)` (`watermark_profiles.py`, pure + unit-tested in `test_platform.py::TestResolveStrength`) resolves an unset `--strength` to `CTRLREGEN_DEFAULT_STRENGTH` (**1.0**) for ctrlregen and to the **vendor-adaptive** SDXL default (`OPENAI_STRENGTH` 0.10 / `GEMINI_STRENGTH` 0.15 / `UNKNOWN_STRENGTH` 0.15, see the vendor-adaptive bullet above); an explicit `--strength` always wins (including `0.0` -- the resolver checks `is None`, not falsiness, so it does not repeat the old `strength or DEFAULT` bug). CLI `--strength` for `invisible`/`all` defaults to **None** (batch already did); the display (`cli.py`) and the engine (`watermark_remover.remove_watermark`) both route through `resolve_strength` with the SAME detected `vendor` so they never disagree. **Why (deep-research pass 2026-05-31, primary sources):** CtrlRegen's removal power comes from regenerating from (near) clean Gaussian noise, not the light partial-noise img2img the SDXL pass uses. CtrlRegen (ICLR 2025, arXiv:2410.05470) diagnoses verbatim that prior partial-noise regeneration "struggles with high-perturbation watermarks" because a small noise step "retains" watermark info that diffuses back into the output; the fix is a clean-noise start, which with `StableDiffusionControlNetImg2ImgPipeline` maps to strength ~1.0 (image structure held by the canny ControlNet + DINOv2 IP-Adapter, not by the watermarked latent). **Before the fix `--pipeline ctrlregen` ran at 0.10** -- a near-identity pass that loaded ControlNet + DINOv2-giant and then barely changed the image (a removal no-op). **NOT yet oracle-verified** that clean-noise ctrlregen clears the stubborn high-texture gpt-image class that 0.20 SDXL img2img could not (issue #14, qw1212ss: pic3/6/7 survived SynthID through 0.05->0.20); that is the pending controlled test (via openai.com/verify with the IP-country rate-limit bypass). **Forensic-stealth caveat applies harder here:** regeneration-family removal is the MOST detectable as "an image that went through a removal pipeline" (CtrlRegen+ 99.97% TPR@1%FPR, arXiv:2605.09203). **Two #14-investigation hypotheses the literature did NOT confirm:** (1) our "VAE round-trip drives removal, denoising strength does not" framing is only PARTIALLY supported -- arXiv:2510.09263 confirms SynthID was hardened against *weak* VAE re-generation (explaining survival) but does not name the VAE round-trip as the removal vector; (2) our "survival correlates with high-frequency CONTENT texture (Laplacian 466 vs 236)" is unconfirmed by any primary source -- the literature establishes *watermark-perturbation-strength* dependence (a different axis), so the texture correlation stays our own unverified observation, not a literature-backed fact.
|
||||
@@ -8,22 +8,25 @@ Strips SynthID, C2PA Content Credentials, EXIF/XMP "Made with AI" labels, and vi
|
||||
>
|
||||
> No Python, no GPU, no setup. Visible-watermark and metadata removal are **free**. Invisible-watermark removal (SynthID / SDXL regeneration) normally needs a local GPU and ~2 GB of models. On **[raiw.cc](https://raiw.cc)** it runs on cloud GPUs in one click for a small per-image fee.
|
||||
|
||||
[](https://github.com/wiltodelta/remove-ai-watermarks/actions/workflows/test.yml)
|
||||
[](https://github.com/sponsors/wiltodelta)
|
||||
|
||||
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 (reverse alpha blending) and the Doubao "豆包AI生成" text strip (locate + mask + inpaint); fast, offline, deterministic, no GPU. `visible --mark auto` picks the right one
|
||||
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, and the Jimeng "★ 即梦AI" wordmark. Each is removed by **reverse-alpha blending** against a captured alpha map (`original = (wm − α·logo)/(1−α)`), recovering the true pixels rather than inpainting a guess. The Gemini sparkle recovers cleanly on its own on bright backgrounds (on a dark background, where the fixed alpha would over-subtract and leave a dark spot, it automatically inpaints the small sparkle footprint instead); the Doubao and Jimeng text marks re-rasterize slightly per image, so a thin residual inpaint over the glyph footprint clears the leftover edges (the alpha maps are reproducibly rebuilt from controlled captures by `scripts/visible_alpha_solve.py`). Fast, offline, no GPU. `visible --mark auto` finds and removes the strongest detected mark. (For arbitrary logos/objects, see `erase`.)
|
||||
- **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, PNG chunk, or EXIF), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" text marks), 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
|
||||
|
||||
@@ -46,13 +49,16 @@ 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 | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 `<TC260:AIGC>` XMP label (China's mandatory AI labeling) | Locate + mask + inpaint (cv2, CPU) + metadata strip |
|
||||
| **Doubao** (ByteDance) / China AIGC generators | ✅ "豆包AI生成" text strip (bottom-right) | — | ✅ TC260 AIGC label (`<TC260:AIGC>` XMP, `AIGC` PNG chunk, or EXIF JSON) **+ C2PA** signed by ByteDance Volcano Engine (`volcengine`) | Reverse-alpha (captured α map) + thin residual inpaint, NCC-aligned across resolutions, + metadata strip |
|
||||
| **Jimeng / Dreamina** (即梦AI, ByteDance) | ✅ "★ 即梦AI" wordmark (bottom-right) | — | ✅ TC260 AIGC label + C2PA (Volcano Engine) | Reverse-alpha (captured α map) + residual inpaint over the glyph footprint, NCC-aligned across resolutions, + metadata strip |
|
||||
| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | — | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Detected (`identify`) + metadata strip |
|
||||
| **Black Forest Labs** (FLUX API) | — | — | ✅ C2PA (`Black Forest Labs API` + `c2pa.ai_generated_content` + `trainedAlgorithmicMedia`) | Metadata strip |
|
||||
| **StableSignature** (Meta) | — | ✅ In-model watermark | — | Diffusion regeneration |
|
||||
| **TreeRing** | — | ✅ Latent space watermark | — | Diffusion regeneration |
|
||||
|
||||
> 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.
|
||||
> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo) and by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark). All are removed on CPU by reverse-alpha against a captured alpha map (Jimeng adds a residual inpaint over the glyph footprint, since its mark re-rasterizes per image). 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, PNG chunk, or EXIF), 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 marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" text marks), 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
|
||||
|
||||
@@ -76,9 +82,18 @@ A three-stage NCC (Normalized Cross-Correlation) detector finds the watermark po
|
||||
|
||||
### 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.
|
||||
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. It is a fixed semi-transparent white overlay, so it is removed by **reverse-alpha blending**: `original = (watermarked - α·logo) / (1 - α)`, recovering the true pixels instead of hallucinating them. The α map is solved from controlled black/gray captures (rebuildable with `scripts/visible_alpha_solve.py`). Like the Jimeng mark, Doubao re-rasterizes its text slightly per image, so reverse-alpha is followed by a thin residual inpaint over the glyph footprint to clear the leftover edges, and the α template is NCC-aligned to the actual mark (handling per-image scale/position jitter). Detection matches the same glyph silhouette against the corner (normalized correlation), so it keys on the "豆包AI生成" shape, not on textured corners.
|
||||
|
||||
**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).
|
||||
**Speed**: ~0.05s, no GPU needed.
|
||||
|
||||
### Removing the Jimeng "★ 即梦AI" wordmark
|
||||
|
||||
Jimeng / Dreamina (即梦AI, also ByteDance, distinct from Doubao) stamps a "★ 即梦AI" wordmark — a four-point sparkle followed by the 即梦AI characters — in the bottom-right corner. It is a fixed semi-transparent **pure-white** overlay, solved from controlled black / gray / white captures the same way as Doubao. `visible --mark auto` detects and removes it (or force it with `--mark jimeng`). One difference from Doubao: Jimeng re-rasterizes its mark slightly differently per image, so a single alpha map does not cancel it pixel-for-pixel — reverse-alpha knocks the mark down and a residual inpaint over the glyph footprint clears the remaining outline. The two ByteDance marks do not confuse `auto`: detection keys on each mark's own glyph shape (the Jimeng detector scores far below its threshold on a Doubao strip, and vice versa).
|
||||
|
||||
```bash
|
||||
remove-ai-watermarks visible jimeng.png -o clean.png # --mark auto picks Jimeng
|
||||
remove-ai-watermarks visible jimeng.png --mark jimeng -o clean.png
|
||||
```
|
||||
|
||||
### Universal region eraser
|
||||
|
||||
@@ -93,18 +108,31 @@ The removal pipeline (default profile, SDXL):
|
||||
```text
|
||||
image → encode to latent space (VAE) at native resolution
|
||||
→ add controlled noise (forward diffusion)
|
||||
→ denoise (reverse diffusion, ~50 steps at strength 0.05)
|
||||
→ denoise (reverse diffusion, ~50 steps; strength is vendor-adaptive:
|
||||
0.10 OpenAI / 0.15 Google / 0.15 unknown, override with --strength)
|
||||
→ decode back to pixels (VAE)
|
||||
```
|
||||
|
||||
By default the image is processed at its **native resolution** with no pre-downscale, matching the hosted raiw.cc backend (fal `fast-sdxl`, which is `stabilityai/stable-diffusion-xl-base-1.0` — the same checkpoint the CLI defaults to). At strength ~0.05 SDXL img2img does not need the input shrunk, and the old forced downscale-to-1024 then upscale-back round-trip was the main quality loss. Pass `--max-resolution N` to cap the long side only when a very large image runs out of GPU/MPS memory (it reintroduces that lossy round-trip).
|
||||
- Native resolution avoids shrinking the input to 1024 px first; that down-then-up round-trip was the main quality loss (issue #10). Use `--max-resolution N` only to cap GPU/MPS memory on very large inputs.
|
||||
|
||||
> **Default strength is vendor-adaptive (no flag needed).** The tool reads the C2PA issuer to detect which vendor's SynthID is present and picks the strength that clears it with the least quality loss: **OpenAI gpt-image → `0.10`**, **Google Gemini → `0.15`**, **unknown source → `0.15`**. An oracle-verified June 2026 study (clean pipeline, per-image openai.com/verify or Gemini app) found OpenAI's watermark clears at `0.05` across `1024`-`1600` px (resolution-independent) while Google's is ~3x more robust and needs `0.15`. The dominant factor is the vendor, not resolution. There is no local SynthID detector, so if the oracle still reads SynthID, raise `--strength`; if you care more about preserving fine text, lower it. (Caveat: Google's `0.15` was validated on the capped `--max-resolution 1536` path; a very large native Gemini image may need more.)
|
||||
>
|
||||
> **Text and face protection are OFF by default.** The high-resolution text re-scrub can shield SynthID in text regions, leaving the watermark intact there even after the global pass clears it everywhere else (verified June 2026: same image, with `--protect-text` → SynthID detected; without → SynthID removed). Both features are opt-in with `--protect-text` / `--protect-faces` and considered **experimental**. If you enable them, verify the result with the oracle.
|
||||
>
|
||||
> **`--pipeline ctrlregen` is experimental and not recommended.** On paper CtrlRegen ([ICLR 2025](https://github.com/yepengliu/CtrlRegen)) regenerates from near-clean Gaussian noise to defeat robust watermarks, but in testing on real images it **destroys content** — smooth and background regions fill with hallucinated micro-text — and it is heavy (several GB of extra models, minutes per image). It has no usable middle setting (too low removes nothing, high enough to remove wrecks the image), so the shippable path is the default SDXL pipeline at the vendor-adaptive strength. CtrlRegen stays available for experimentation only.
|
||||
|
||||
SDXL is the default since May 2026: empirically defeats SynthID v2 on Gemini 3 Pro outputs, where the older SD-1.5 pipeline at 768 px did not. The SD-1.5 path was removed once it was verified not to handle v2. Note the scope: this defeats the SynthID *verifier*, which is not the same as being forensically indistinguishable from a real photo. Recent work ([arXiv:2605.09203](https://arxiv.org/abs/2605.09203)) shows watermark-removal pipelines leave detectable traces, so a separate "this image was processed" classifier can still flag the output.
|
||||
|
||||
**Face Protection**: before diffusion, YOLO detects people in the image and extracts them. After diffusion, the original faces are blended back with a soft elliptical mask to prevent AI distortion of facial features.
|
||||
> **Oracle vs `identify` can disagree, and that is expected.** An online verifier reads the actual SynthID *pixel* watermark and detects only its own vendor's content — [openai.com/research/verify](https://openai.com/research/verify/) states "OpenAI generation signals will only be detected if the image was generated with our tools". Our `identify` cannot decode the pixel watermark (no vendor ships a local decoder), so it infers SynthID from the **C2PA metadata** instead. So after the SDXL pass the oracle can read "no SynthID" (pixel watermark gone) while `identify` still reports SynthID from a surviving C2PA manifest. They measure different signals. Run `metadata --remove` (or `all`) to also strip the manifest; note that a quiet metadata proxy is not proof the pixel watermark itself is gone.
|
||||
|
||||
> **Technical deep-dive:** see [`docs/synthid.md`](docs/synthid.md) for a primary-source-cited breakdown of how SynthID works mechanically (post-hoc encoder/decoder, 136-bit payload, pixel-space embedding), what it empirically survives (JPEG, crop, resize: ~99.98% TPR at 0.1% FPR from arXiv:2510.09263), what removes it, and the forensic-stealth tradeoff (all known removal attacks are detectable at >98% TPR@1%FPR per arXiv:2605.09203).
|
||||
|
||||
**Face Protection** (experimental, opt-in `--protect-faces`): before diffusion, YOLO detects people in the image and extracts them; after diffusion the original faces are blended back. Off by default — enable only when face fidelity matters more than SynthID removal completeness.
|
||||
|
||||
**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** (experimental, opt-in `--protect-text`): re-scrubs detected text blocks at high resolution after the global pass to keep small glyphs crisp. **Off by default** because the high-resolution re-scrub can preserve SynthID in text regions even after the global pass removes it elsewhere. Enable only when text fidelity matters more than watermark removal completeness, and verify the oracle result. SDXL 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:
|
||||
@@ -232,9 +260,9 @@ 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 — fast, offline, CPU. --mark auto (default) picks
|
||||
# between the Gemini sparkle and the Doubao "豆包AI生成" text strip; force one
|
||||
# with --mark gemini / --mark doubao.
|
||||
# Visible watermark only — fast, offline, CPU. --mark auto (default) finds the
|
||||
# strongest known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI"); force
|
||||
# one with --mark gemini / doubao / jimeng. Removed by reverse-alpha (true-pixel recovery).
|
||||
remove-ai-watermarks visible image.png -o clean.png
|
||||
|
||||
# Erase arbitrary region(s) — universal, any logo/watermark/object, any position.
|
||||
@@ -245,6 +273,9 @@ remove-ai-watermarks erase image.png --region 1640,1930,400,100 -o clean.png
|
||||
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
|
||||
# Strength is vendor-adaptive by default (OpenAI 0.10 / Google 0.15); override
|
||||
# with --strength. Text/face protection is opt-in (--protect-text /
|
||||
# --protect-faces, experimental: they can shield SynthID).
|
||||
|
||||
# Check / strip AI metadata (C2PA, EXIF, "Made with AI" labels)
|
||||
# --check also flags SynthID-bearing sources: a C2PA manifest signed by
|
||||
@@ -321,8 +352,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 capture cameras are mapped (each verified against a real signed file); **Samsung Galaxy AI**, **Black Forest Labs (FLUX)**, and **ByteDance Volcano Engine** (Doubao / Jimeng) are now attributed too (verified on real signed files). Canon is still deferred until a real signed sample surfaces — no public direct-download C2PA file exists for it 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:
|
||||
@@ -336,10 +369,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. |
|
||||
|
||||
@@ -1,5 +1,12 @@
|
||||
# Doubao visible watermark capture
|
||||
|
||||
> **Status (captured 2026-05-29; alpha rebuilt 2026-05-31):** the black/gray/white captures were
|
||||
> taken and are now **committed** in `captures/` (solid colour + watermark, content-free). The alpha
|
||||
> map is rebuilt by `scripts/visible_alpha_solve.py doubao` (the careful gray-self solve shared with
|
||||
> Jimeng). The first build claimed "pixel-exact" but left a readable outline on the real sample (issue
|
||||
> #13 follow-up); removal now reverse-alphas, NCC-aligns, and applies a thin residual inpaint. See the
|
||||
> `doubao_engine.py` notes in the root `CLAUDE.md`. The text below is kept as the historical capture plan.
|
||||
|
||||
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`).
|
||||
@@ -16,8 +23,10 @@ engine works (`src/remove_ai_watermarks/gemini_engine.py`).
|
||||
- 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).
|
||||
- The planning assumption was that clean inversion leaves residue on textured backgrounds, so the
|
||||
remover would pair the alpha map with inpainting. After the capture this turned out unnecessary at
|
||||
the native width (recovery is pixel-exact there and inpaint is off); the shipped remover is
|
||||
reverse-alpha only, with a residual inpaint applied off-native only.
|
||||
|
||||
## Use doubao.com specifically
|
||||
|
||||
|
||||
|
After Width: | Height: | Size: 1.4 MiB |
|
After Width: | Height: | Size: 980 KiB |
|
After Width: | Height: | Size: 1.2 MiB |
@@ -0,0 +1,45 @@
|
||||
# Gemini (Nano Banana) visible sparkle capture
|
||||
|
||||
> **Status (captured 2026-05-31):** black/gray/white captures taken and **committed**
|
||||
> in `captures/` (solid colour + the sparkle, content-free). The sparkle-on-black assets
|
||||
> `gemini_bg_{96,48}.png` are rebuilt by `scripts/visible_alpha_solve.py gemini`.
|
||||
|
||||
Google Gemini (Nano Banana) stamps a four-point sparkle icon in the bottom-right corner
|
||||
via alpha compositing: `watermarked = a*logo + (1-a)*original`. Unlike the Doubao/Jimeng
|
||||
text marks, the sparkle is captured over a **pure-black** background, where
|
||||
`watermarked = a*255` (logo is near-white), so the alpha reads directly off the capture
|
||||
(`alpha = max(R,G,B)/255`) -- no background fit needed. This is the "golden" capture case.
|
||||
|
||||
## What the captures confirmed (2026-05-31)
|
||||
|
||||
- The sparkle at a 2048-wide image is **96x96 px** (width_frac ~0.047), bottom-right,
|
||||
margins ~0.031, alpha max ~0.51 -- matching the engine's existing 96px asset.
|
||||
- Our own controlled capture matches the previously third-party-sourced
|
||||
`gemini_bg_96.png` to **NCC 0.9998**, so the bundled asset is validated and now
|
||||
reproducible from our own capture.
|
||||
|
||||
## How to capture (image-edit path)
|
||||
|
||||
For each solid-colour seed (`seeds/seed_{black,gray,white}_2048.png`, gitignored):
|
||||
|
||||
1. Open Gemini image generation, image-edit / reference mode, upload the seed.
|
||||
2. Prompt: `Recreate this image exactly as it is, keep it identical, do not add or change anything`
|
||||
3. Download the ORIGINAL output (not a screenshot). Do not crop / edit / re-save.
|
||||
|
||||
Black is the key one (sparkle on black -> exact alpha). Gray/white cross-check.
|
||||
|
||||
## Naming, drop into `captures/`
|
||||
|
||||
```
|
||||
gemini_black_2048.png # the key capture (sparkle on black)
|
||||
gemini_gray_2048.png
|
||||
gemini_white_2048.png
|
||||
```
|
||||
|
||||
The solid captures are **committed** (content-free). The synthetic `seeds/` and any
|
||||
real-content `gemini_content_*.png` validation download are gitignored (local-only).
|
||||
Rebuild the assets with:
|
||||
|
||||
```
|
||||
uv run python scripts/visible_alpha_solve.py gemini # or: all
|
||||
```
|
||||
|
After Width: | Height: | Size: 3.7 MiB |
|
After Width: | Height: | Size: 715 KiB |
|
After Width: | Height: | Size: 138 KiB |
@@ -0,0 +1,75 @@
|
||||
# Jimeng (即梦AI) visible watermark capture
|
||||
|
||||
> **Status (completed 2026-05-30):** solid black/gray/white Jimeng captures were
|
||||
> obtained (issue #13, from @powersee) and the alpha map was solved. Removal is
|
||||
> reverse-alpha plus a residual inpaint over the glyph footprint; see the
|
||||
> `jimeng_engine.py` notes in the root `CLAUDE.md`. The text below is kept as the
|
||||
> capture plan.
|
||||
|
||||
Goal: capture the Jimeng / Dreamina "★ 即梦AI" visible wordmark over known flat
|
||||
backgrounds so we can build a per-pixel alpha map and a reverse-alpha remover, the
|
||||
same way the Gemini sparkle and Doubao strip engines work
|
||||
(`src/remove_ai_watermarks/gemini_engine.py`, `doubao_engine.py`).
|
||||
|
||||
## What we learned (verified from the captures, 2026-05-30)
|
||||
|
||||
- Mark: a four-point sparkle icon followed by the "即梦AI" characters, near-white
|
||||
semi-transparent overlay, bottom-right corner.
|
||||
- Blend model: **alpha compositing with a pure-white logo** `watermarked =
|
||||
a*255 + (1-a)*original`, confirmed in sRGB (a linear-light solve made the
|
||||
black/gray cross-residual much worse, so the compositing is plain sRGB). An
|
||||
L-pair-solve (independent of the L assumption) lands at ~254.6, confirming white.
|
||||
- **Alpha is solved from the GRAY capture**, not black: `a = (I - B)/(255 - B)`
|
||||
with B a per-capture CUBIC background fit over the non-glyph pixels, averaged
|
||||
over channels, at FULL halo extent (down to a~0.02) and UNBLURRED. Gray (bg ~132)
|
||||
is the best proxy for real content (the mark sits on bright photo areas, not on
|
||||
black). This careful build drops the gray self-residual to ~1.3; an earlier
|
||||
max-channel / quadratic-bg / blurred / halo-truncated build (and a black-dominated
|
||||
least-squares solve) left a visible outline -- the mask quality, not the method,
|
||||
was the limit.
|
||||
- Geometry (fraction of image WIDTH, at the captured 2048): asset width ~0.211,
|
||||
height ~0.068, right margin ~0.023, bottom margin ~0.023. The mark scales with
|
||||
width; a real 1440-wide download matched width_frac ~0.21.
|
||||
- **Per-image render variation:** the alpha maps solved independently from the
|
||||
black and the gray capture correlate 0.998 but not 1.0 (mean |Δa| ~0.02). Jimeng
|
||||
re-rasterizes the mark per generation AND jitters its position a few px, so a
|
||||
single alpha map does NOT pixel-cancel the mark the way Doubao's deterministic
|
||||
overlay does. Removal therefore: NCC-aligns the alpha to the actual mark (always,
|
||||
not only off-native), reverse-alphas, then clears the residual with a THIN inpaint
|
||||
over the glyph footprint (a wide full-footprint pass smeared the texture/edges).
|
||||
|
||||
## How to capture (image-edit path, most reliable)
|
||||
|
||||
For each solid-color seed:
|
||||
|
||||
1. Open Jimeng image generation, use the image-edit / reference mode, upload the seed.
|
||||
2. Prompt (Chinese preferred):
|
||||
`请完全按照原图重新生成这张图片,保持完全一致,不要添加或修改任何内容`
|
||||
3. Download the ORIGINAL output file (not a screenshot). Do not crop / edit / re-save.
|
||||
|
||||
The black capture is the key one (white logo on black -> `captured ~= a*255`); the
|
||||
gray capture refines the alpha at mid-tones; the white capture confirms the logo is
|
||||
pure white (the mark is nearly invisible on white, as expected).
|
||||
|
||||
## Hygiene
|
||||
|
||||
- Original download, never a screenshot. PNG preferred; if Jimeng only gives JPEG, note it.
|
||||
- No crop / edit / re-save. Default settings, watermark left ON.
|
||||
|
||||
## Naming, drop into `captures/`
|
||||
|
||||
```
|
||||
jimeng_cap_A.png # black seed run through Jimeng
|
||||
jimeng_cap_B.png # white seed
|
||||
jimeng_cap_C.png # gray seed
|
||||
jimeng_content_1.png # a normal-content download, for end-to-end validation
|
||||
```
|
||||
|
||||
The solid `jimeng_cap_{A,B,C}.png` captures are **committed** (content-free: a solid
|
||||
colour + the watermark; the source for `scripts/visible_alpha_solve.py jimeng`). The
|
||||
synthetic `seeds/` and the real-content `jimeng_content_*.png` validation download are
|
||||
gitignored (local-only). Rebuild the alpha asset with:
|
||||
|
||||
```
|
||||
uv run python scripts/visible_alpha_solve.py jimeng # or: all
|
||||
```
|
||||
|
After Width: | Height: | Size: 1.9 MiB |
|
After Width: | Height: | Size: 373 KiB |
|
After Width: | Height: | Size: 1.6 MiB |
@@ -0,0 +1,460 @@
|
||||
# SynthID-Image: technical reference
|
||||
|
||||
This document covers how Google SynthID for images works mechanically, what it
|
||||
survives, what removes it, and the current deployment landscape. It is written
|
||||
for engineers working on watermark detection and removal -- specifically to
|
||||
inform decisions about strength settings, test methodology, and what oracle
|
||||
results mean.
|
||||
|
||||
Primary sources are cited inline. Marketing-only claims are flagged separately
|
||||
from independently-verified results.
|
||||
|
||||
---
|
||||
|
||||
## 1. Mechanism
|
||||
|
||||
### 1.1 Post-hoc, model-independent design
|
||||
|
||||
SynthID-Image is **not** baked into a diffusion model's weights. It is a
|
||||
post-hoc, model-independent system: a separate encoder `f` is applied to an
|
||||
already-generated image, and a separate decoder `g` reads it back.
|
||||
|
||||
> "We deliberately designed SynthID-Image as a post-hoc, model-independent
|
||||
> approach, a choice largely based on deployment considerations."
|
||||
> -- Gowal et al., arXiv:2510.09263
|
||||
|
||||
The formal definition from the paper:
|
||||
|
||||
> "A post-hoc watermarking scheme is a pair f, g consisting of an encoder
|
||||
> function f: X -> X, which adds an identification mark, and a decoder
|
||||
> function g: X -> {+-1}, which tries to detect if the mark is present."
|
||||
|
||||
This is the key architectural fact: **the generative model (Imagen, Gemini's
|
||||
image model) is not modified**. The watermark is stamped onto the pixel output
|
||||
after generation, by a separate neural network. This means:
|
||||
|
||||
- The watermark is in **pixel space**, not in the model's latent activations.
|
||||
- Replacing the generative model does not remove the watermarking capability.
|
||||
- The encoder/decoder pair can be updated independently of the generative model.
|
||||
|
||||
The paper does not disclose the internal architecture of the encoder/decoder
|
||||
networks (layer types, capacity). The external variant SynthID-O is available
|
||||
to partners; the production internal variant is not published.
|
||||
|
||||
### 1.2 How it differs from classical DWT-DCT watermarks
|
||||
|
||||
The open watermarks used by Stable Diffusion / SDXL / FLUX (via the
|
||||
`imwatermark` library) use classical **DWT-DCT** frequency-domain embedding: a
|
||||
fixed bit pattern is added to specific frequency coefficients of the image's
|
||||
wavelet transform. This is fast, key-free, and locally detectable with a public
|
||||
decoder.
|
||||
|
||||
SynthID-Image uses **jointly-trained deep learning models**:
|
||||
|
||||
> "SynthID uses two deep learning models -- for watermarking and identifying --
|
||||
> that have been trained together on a diverse set of images. The combined model
|
||||
> is optimised on a range of objectives, including correctly identifying
|
||||
> watermarked content and improving imperceptibility by visually aligning the
|
||||
> watermark to the original content."
|
||||
> -- Google DeepMind blog, 2023
|
||||
|
||||
The practical difference for robustness: the deep learning encoder learns to
|
||||
spread the signal across the image in a way that is optimized to survive a
|
||||
specific perturbation distribution seen during training. Classical DWT-DCT
|
||||
embeds in fixed, predictable frequency bins, making it brittle to any
|
||||
operation that hits those bins (e.g., JPEG re-quantization wipes it cleanly at
|
||||
quality <= 90).
|
||||
|
||||
### 1.3 Payload capacity
|
||||
|
||||
SynthID-O (the external/partnership variant) encodes:
|
||||
|
||||
- **136 bits** within a **512x512 pixel image**
|
||||
|
||||
For comparison (from the same paper):
|
||||
|
||||
| Method | Bits | Resolution |
|
||||
|-------------|------|------------|
|
||||
| SynthID-O | 136 | 512x512 |
|
||||
| StegaStamp | 100 | 400x400 |
|
||||
| TrustMark | 100 | 256x256 |
|
||||
| WAM | 32 | 256x256 |
|
||||
|
||||
The payload carries an identification mark (not a user-readable secret). The
|
||||
paper separates watermark **detection** (is this watermarked?) from payload
|
||||
**recovery** (what does the payload say?): the detection path is what oracles
|
||||
like the Gemini app's "Verify with SynthID" exercise.
|
||||
|
||||
### 1.4 Where in the pipeline it lives
|
||||
|
||||
```
|
||||
[Diffusion model]
|
||||
|
|
||||
raw pixel output
|
||||
|
|
||||
[SynthID encoder f] <-- separate neural net, stamps the watermark
|
||||
|
|
||||
watermarked image
|
||||
|
|
||||
[served / downloaded]
|
||||
|
|
||||
[SynthID decoder g] <-- separate neural net, run by Google's verifier only
|
||||
|
|
||||
present / not present
|
||||
```
|
||||
|
||||
The VAE decoder of the diffusion model is **not** involved in watermarking.
|
||||
Some in-generation watermark approaches (like the research method "Tree Ring")
|
||||
inject the signal into the initial noise latent so it propagates through the
|
||||
diffusion process and appears in the final image; SynthID-Image does not do
|
||||
this -- it is applied after the VAE has already decoded latents to pixels.
|
||||
|
||||
---
|
||||
|
||||
## 2. Robustness
|
||||
|
||||
### 2.1 What the paper claims it survives (primary-source verified)
|
||||
|
||||
The SynthID-Image paper (arXiv:2510.09263) evaluates SynthID-O against **30
|
||||
image transformations** grouped into 6 categories:
|
||||
|
||||
| Category | Examples |
|
||||
|-------------|-----------------------------------------------|
|
||||
| Color | brightness, contrast, saturation, hue shifts |
|
||||
| Combination | combinations of multiple transforms |
|
||||
| Noise | Gaussian noise, impulse noise, median filter |
|
||||
| Overlay | text overlays, logos, stickers |
|
||||
| Quality | JPEG compression, WebP, format conversion |
|
||||
| Spatial | crop, resize, rotate, flip, padding |
|
||||
|
||||
**TPR at 0.1% FPR -- SynthID-O vs. baselines (resized to 512x512):**
|
||||
|
||||
| Category | SynthID-O | Best baseline (WAM) | Worst baseline (StegaStamp spatial) |
|
||||
|------------------|-----------|---------------------|--------------------------------------|
|
||||
| Identity (none) | 100.00% | 100.00% | 100.00% |
|
||||
| Aggregated | 99.98% | 90.62% | ~70% |
|
||||
| Color | 100.00% | 81.29% | ~75% |
|
||||
| Combination | 99.96% | 96.08% | ~22% |
|
||||
| Noise | 99.98% | 100.00% | ~92% |
|
||||
| Overlay | 100.00% | 100.00% | 100.00% |
|
||||
| Quality | 99.99% | -- | ~89% |
|
||||
| Spatial (worst) | 99.97% | 76.04% | 15.25% |
|
||||
|
||||
The "Spatial worst" row is the hardest case (aggressive crop + resize).
|
||||
SynthID-O retains 99.97% TPR; StegaStamp collapses to 15.25%. This is where
|
||||
the deep-learning approach gains the most over classical methods.
|
||||
|
||||
Google's marketing page states the watermark is:
|
||||
|
||||
> "designed to stand up to modifications like cropping, adding filters, changing
|
||||
> frame rates, or lossy compression."
|
||||
> -- deepmind.google/models/synthid/
|
||||
|
||||
The marketing claim is broadly consistent with the paper's numbers for these
|
||||
specific categories.
|
||||
|
||||
**JPEG and format conversion specifically** fall under the "Quality" category,
|
||||
where SynthID-O achieves 99.99% TPR. This is the empirical basis for the fact
|
||||
that **GitHub-recompressed JPEGs from issue attachments are valid SynthID test
|
||||
subjects**: the re-encoding does not remove the pixel watermark.
|
||||
|
||||
### 2.2 Stated limits (vendor claim, not independently verified)
|
||||
|
||||
> "SynthID isn't foolproof against extreme image manipulations."
|
||||
> -- Google DeepMind blog, 2023
|
||||
|
||||
This is the only public failure-mode statement Google has made. No specific
|
||||
perturbation type, threshold, or quantitative boundary is named. The
|
||||
Limitations section of the paper (Section 10) was not recoverable from the
|
||||
public HTML version of arXiv:2510.09263v1 due to a rendering failure in the
|
||||
conversion (the body text of Section 10 is absent from the HTML).
|
||||
|
||||
**What is known empirically from our own oracle-verified testing.**
|
||||
|
||||
A controlled study (June 2026, clean v0.8.6 with text/face protection OFF,
|
||||
native resolution on this repo's default SDXL pipeline) measured the minimum
|
||||
img2img strength that removes the SynthID pixel watermark, verified per image on
|
||||
the vendor's own oracle (openai.com/verify for OpenAI, the Gemini app "Verify
|
||||
with SynthID" for Google). The test set and per-image results are recorded in
|
||||
`data/synthid_corpus/` (manifest `verified_via` = `openai-verify` / `gemini-app`).
|
||||
|
||||
| Vendor | Images | Resolution(s) | Pipeline | Removed at |
|
||||
|--------|--------|---------------|----------|------------|
|
||||
| OpenAI (gpt-image) | n=4 | 1024x1536 .. 1600x1600 | native | **0.05** |
|
||||
| Google (Gemini) | n=4 | 2816x1536 -> capped 1536 | `--max-resolution 1536` | **0.15** (0.05 and 0.10 do NOT clear) |
|
||||
|
||||
**Two findings, both oracle-verified:**
|
||||
|
||||
1. **Vendor is the dominant factor, not resolution.** Google's SynthID is
|
||||
roughly 3x more robust than OpenAI's: at a comparable (small) working
|
||||
resolution, OpenAI clears at 0.05 while Google needs 0.15. This matches
|
||||
Google having hardened SynthID more aggressively over time.
|
||||
|
||||
2. **OpenAI SynthID removal is resolution-independent in the tested range.**
|
||||
All four OpenAI images (including a 1600x1600) cleared at 0.05.
|
||||
|
||||
**CORRECTION (supersedes the earlier "resolution dependence" claim).** A prior
|
||||
version of this doc and CLAUDE.md stated that strength 0.30 failed to remove
|
||||
SynthID on 1600x1600 gpt-image and that removal was resolution-dependent. That
|
||||
was an **artifact of the text-protection bug** (issue #14): those tests ran a
|
||||
build where `protect_text` was ON by default, and the high-resolution text
|
||||
re-scrub re-introduced SynthID in the dense-text regions of the infographic
|
||||
images tested. Re-running the *same* 1600x1600 image on clean v0.8.6 (protect
|
||||
OFF) removes SynthID at **0.05**. The "large images resist removal" conclusion
|
||||
was false; the resistance was the protect-text shielding, now fixed (v0.8.5).
|
||||
|
||||
**Open / not locally testable:**
|
||||
|
||||
- **Native large Gemini (2816x1536, ~4.3 MP).** The Gemini floor of 0.15 was
|
||||
measured on the *capped* (`--max-resolution 1536`) path, which is the
|
||||
practical local route on Apple-Silicon (native 2816 OOMs / falls back to slow
|
||||
CPU on a 32 GB M-series). Native large Gemini was not measured here; the
|
||||
vendor and resolution effects would stack, so it plausibly needs >= 0.30 or a
|
||||
discrete GPU. Confirm on a CUDA box if needed.
|
||||
- **Heavy JPEG compression** (quality < ~50-60): not oracle-tested; the DL
|
||||
approach is more robust than DWT-DCT but Google acknowledges limits at
|
||||
"extreme" manipulation.
|
||||
|
||||
### 2.3 Removal attacks and forensic detectability
|
||||
|
||||
The paper arXiv:2605.09203 ("Removing the Watermark Is Not Enough",
|
||||
Goonatilake & Ateniese, 2026) evaluates 6 removal attacks against a ResNet-50
|
||||
forensic detector. All attacks defeat the watermark verifier but are detected
|
||||
by the forensic classifier:
|
||||
|
||||
| Attack | Family | AUROC | TPR @ 1% FPR | TPR @ 0.1% FPR |
|
||||
|-----------------|------------------|--------|--------------|----------------|
|
||||
| UnMarker | Distortion | 0.9994 | 99.81% | 98.28% |
|
||||
| WatermarkAttacker| Regeneration | 0.9997 | 99.95% | 99.38% |
|
||||
| CtrlRegen+ | Regeneration | 0.9999 | 99.97% | 99.64% |
|
||||
| NFPA | Inversion/Pert. | 0.9984 | 99.24% | 62.10% |
|
||||
| Boundary Leak. | Inversion/Pert. | 0.9991 | 99.24% | 88.34% |
|
||||
| WiTS | Erosion | 0.9999 | 99.80% | 99.55% |
|
||||
|
||||
The forensic detector is a standard ResNet-50 fine-tuned end-to-end; no exotic
|
||||
architecture needed. The key finding:
|
||||
|
||||
> "These removers do not return images to a clean forensic state. They often
|
||||
> trade an explicit watermark for an implicit watermark: a detectable artifact
|
||||
> introduced by the removal process itself."
|
||||
|
||||
This means: even when our SDXL img2img pass defeats the SynthID pixel
|
||||
watermark (oracle reads negative), the output may still be classifiable as
|
||||
"an image that went through a removal pipeline" by an independent detector --
|
||||
even if that detector is not trained on SynthID specifically. **Defeating the
|
||||
verifier does not restore forensic deniability.**
|
||||
|
||||
CtrlRegen+ is the most detectable removal method (AUROC 0.9999), which is
|
||||
notable because it is also the most powerful removal attack. The paper notes
|
||||
that diffusion regeneration "leaves a strong reconstruction signature from the
|
||||
diffusion prior."
|
||||
|
||||
---
|
||||
|
||||
## 3. Detectability and verifier access
|
||||
|
||||
### 3.1 No public local detector
|
||||
|
||||
The SynthID decoder is proprietary and not released:
|
||||
|
||||
> "SynthID-Image has been used to watermark over ten billion images and video
|
||||
> frames across Google's services and its corresponding verification service is
|
||||
> available to trusted testers."
|
||||
> -- Gowal et al., arXiv:2510.09263
|
||||
|
||||
There is no public API, no released decoder weights, and no reproducible
|
||||
algorithm for local detection. The verification service (SynthID Detector) is:
|
||||
|
||||
> "a verification portal" in early testing with "journalists and media
|
||||
> professionals" on a waitlist
|
||||
> -- deepmind.google/models/synthid/
|
||||
|
||||
The external variant SynthID-O is available "through partnerships" only. Our
|
||||
tool cannot locally detect SynthID presence or absence -- this is by design,
|
||||
not a gap we can fill.
|
||||
|
||||
### 3.2 How our tool detects SynthID (metadata proxy)
|
||||
|
||||
We detect SynthID indirectly: if the image's C2PA manifest is signed by a
|
||||
known SynthID-using issuer (Google, OpenAI), we infer SynthID is present. This
|
||||
is a **metadata proxy**, not a pixel watermark decode. It works while the C2PA
|
||||
manifest is intact, and is silent once the manifest is stripped or the image
|
||||
is re-encoded without C2PA (e.g., a screenshot, a social-media re-upload, or
|
||||
after `metadata --remove`).
|
||||
|
||||
This is why:
|
||||
- `identify` on a GitHub-recompressed issue attachment returns Unknown (C2PA is
|
||||
gone) even though the pixel SynthID is still present and detectable by
|
||||
openai.com/verify.
|
||||
- A quiet `identify` output is not proof that SynthID was removed -- it only
|
||||
means the metadata signal is gone.
|
||||
|
||||
### 3.3 Oracle scope: each vendor detects only their own
|
||||
|
||||
From openai.com/research/verify (verbatim, verified 2026-05-31):
|
||||
|
||||
> "OpenAI generation signals will only be detected if the image was generated
|
||||
> with our tools."
|
||||
> "Content could also still be AI-generated by another company's model, which
|
||||
> the tool currently does not detect."
|
||||
|
||||
SynthID technology is used by multiple vendors, but each verifier is keyed to
|
||||
its own payload:
|
||||
|
||||
| Oracle | Detects | Does NOT detect |
|
||||
|-------------------------------|------------------|-------------------------|
|
||||
| Gemini app "Verify with SynthID" | Google SynthID | OpenAI SynthID |
|
||||
| openai.com/research/verify | OpenAI SynthID | Google SynthID |
|
||||
|
||||
A Google-SynthID image reads clean on openai.com/verify. An OpenAI image reads
|
||||
clean in the Gemini oracle. They are different payloads within the same
|
||||
framework.
|
||||
|
||||
---
|
||||
|
||||
## 4. Adoption and current state (as of June 2026)
|
||||
|
||||
### 4.1 Google products
|
||||
|
||||
Google has watermarked **over 10 billion** images and video frames. The
|
||||
deployment split by surface matters for our tool:
|
||||
|
||||
| Surface | SynthID pixel | C2PA metadata | Visible sparkle |
|
||||
|--------------------------------------|---------------|---------------|-----------------|
|
||||
| Gemini app (generated images) | YES | YES (Google) | YES |
|
||||
| Gemini API / AI Studio / Nano Banana | YES | NO | YES |
|
||||
|
||||
The Gemini API surface is a key blind spot: it embeds the pixel watermark and
|
||||
the visible sparkle but **no C2PA or IPTC at all**. Our `identify` returns
|
||||
Unknown on API-generated images unless the visible sparkle is detected (via
|
||||
`check_visible=True`) or the user runs the Gemini app oracle.
|
||||
|
||||
### 4.2 OpenAI
|
||||
|
||||
OpenAI confirmed SynthID adoption (Help Center, updated 2026-05-21):
|
||||
|
||||
> "ChatGPT images include both C2PA metadata and SynthID watermarks."
|
||||
|
||||
This is time-gated: pre-rollout ChatGPT/gpt-image images carry C2PA without
|
||||
SynthID. Our C2PA proxy therefore over-reports SynthID presence on old images
|
||||
(hence the `_OPENAI_CAVEAT` hedging flag in the codebase).
|
||||
|
||||
### 4.3 Other vendors
|
||||
|
||||
- **Kakao** (South Korea): SynthID adopter as of May 2026 (Google announcement)
|
||||
- **NVIDIA Cosmos**: SynthID for video (not still images; different pipeline)
|
||||
- **Meta AI**: does NOT use SynthID; uses IPTC `digitalSourceType` marker instead
|
||||
|
||||
### 4.4 Version evolution (v1 vs v2 hardening)
|
||||
|
||||
Google has not publicly documented version numbers for the SynthID image
|
||||
watermark in a way that maps to our testing observations. What is known
|
||||
empirically from oracle tests:
|
||||
|
||||
- **Before May 2026 (Gemini)**: strength 0.05 removed the watermark
|
||||
- **May 2026 (Gemini)**: strength 0.05 insufficient; 0.10 required
|
||||
- **Current (Gemini, June 2026)**: on the capped 1536 path, 0.05 and 0.10 do
|
||||
NOT clear; 0.15 clears (n=4, Gemini app oracle). See section 2.2.
|
||||
- **OpenAI (June 2026)**: clears at 0.05 across 1024-1600 (n=4, clean v0.8.6).
|
||||
The earlier "0.30 still detected on 1600x1600" report (issue #14) was the
|
||||
text-protection bug, not a hardening of the watermark -- see the correction in
|
||||
section 2.2.
|
||||
|
||||
Google has hardened SynthID relative to OpenAI's (vendor gap measured at ~3x
|
||||
strength), but the year-over-year "0.05 -> 0.10 -> 0.30" progression above
|
||||
conflates a real hardening trend with the now-debunked protect-text artifact;
|
||||
treat only the section 2.2 controlled numbers as authoritative.
|
||||
|
||||
---
|
||||
|
||||
## 5. Practical implications for this tool
|
||||
|
||||
### 5.1 Text and face protection: OFF by default
|
||||
|
||||
**Text protection (`--protect-text`) can preserve SynthID in text regions.**
|
||||
Verified June 2026 on gpt-image at 1600x1600: same image processed twice --
|
||||
with `--protect-text` the oracle detected SynthID; without it, SynthID was
|
||||
removed. The mechanism: the global img2img pass clears SynthID everywhere, but
|
||||
the text-protection high-resolution re-scrub regenerates those pixels from an
|
||||
upscaled crop. At the effective resolution of the upscaled crop, the per-region
|
||||
pass may be insufficient to re-destroy the payload, reconstituting SynthID in
|
||||
text regions.
|
||||
|
||||
**Face protection (`--protect-faces`) has an even more direct preservation
|
||||
mechanism.** The pipeline extracts face regions from the ORIGINAL (watermarked)
|
||||
image BEFORE the diffusion pass, runs the global pass (which removes SynthID
|
||||
everywhere), then blends the original face pixels BACK onto the result
|
||||
(`invisible_engine.py`: `original_faces = protector.extract_faces(cv_img)`
|
||||
before `remove_watermark`, then `protector.restore_faces(out_cv, original_faces)`
|
||||
after). Those restored pixels are the original watermarked pixels -- SynthID is
|
||||
guaranteed to survive in face regions, not just possibly. The text-protection
|
||||
case is at least re-generating (uncertain); face protection is literally
|
||||
restoring the original SynthID-bearing pixels.
|
||||
|
||||
Both `--protect-text` and `--protect-faces` are therefore **experimental and
|
||||
OFF by default**. Enable only when text/face fidelity matters more than
|
||||
watermark removal completeness, and always verify the result with the oracle.
|
||||
|
||||
### 5.2 Strength setting
|
||||
|
||||
There is no single permanent correct strength, but the controlled June 2026
|
||||
study (section 2.2) gives empirical floors:
|
||||
|
||||
- **OpenAI**: 0.05 clears across 1024-1600 (n=4). 0.30 is large overkill here.
|
||||
- **Google (capped 1536)**: 0.15 (n=4); 0.05 and 0.10 do not clear.
|
||||
- **Google native 2816**: not locally measured; likely needs >= 0.30 (vendor +
|
||||
resolution stack). Use a GPU or `--max-resolution 1536`.
|
||||
|
||||
The default is **vendor-adaptive** (`watermark_profiles.resolve_strength` +
|
||||
`vendor_for_strength`): the tool reads the C2PA issuer on the original input and
|
||||
picks `OPENAI_STRENGTH` 0.10 / `GEMINI_STRENGTH` 0.15 / `UNKNOWN_STRENGTH` 0.15.
|
||||
This uses the vendor signal we DO have locally (the C2PA SynthID proxy) to avoid
|
||||
the overkill of a single high default on OpenAI images, without needing a local
|
||||
pixel detector. An explicit `--strength` always wins. If the watermark still
|
||||
survives (e.g. a large native Gemini beyond the capped-1536 validation), raise
|
||||
toward 0.30 then 0.35-0.40 (0.40 visibly corrupts dense text), using the lowest
|
||||
value that reads clean on the oracle.
|
||||
|
||||
### 5.3 Test methodology
|
||||
|
||||
- **GitHub-recompressed JPEGs from issue attachments are valid SynthID test
|
||||
subjects.** JPEG re-encoding removes C2PA metadata but does NOT remove the
|
||||
SynthID pixel watermark (verified June 2026 on issue #14 pic3). Do not
|
||||
dismiss these as "not faithful originals" for SynthID-removal tests.
|
||||
- **The correct oracle for OpenAI images is openai.com/verify**, not the Gemini
|
||||
app. The two oracles detect different payloads.
|
||||
- **A quiet `identify` output after processing is not proof of removal.** It
|
||||
means the metadata proxy is gone. The pixel watermark state is unknown without
|
||||
an oracle check.
|
||||
- **After removal, the output may carry forensic artifacts** detectable by an
|
||||
independent classifier even if the vendor oracle reads negative. Defeating the
|
||||
verifier is not the same as being forensically indistinguishable from clean
|
||||
content (arXiv:2605.09203).
|
||||
|
||||
### 5.4 ctrlregen and img2img: the tradeoff
|
||||
|
||||
Both the paper and our testing confirm: higher img2img strength removes the
|
||||
watermark but introduces detectable regeneration artifacts. The Goonatilake &
|
||||
Ateniese paper shows CtrlRegen+ (the most powerful remover) is simultaneously
|
||||
the most forensically detectable (AUROC 0.9999). The tradeoff is unavoidable
|
||||
with current diffusion-based approaches.
|
||||
|
||||
---
|
||||
|
||||
## References
|
||||
|
||||
1. Gowal et al. (2025). **SynthID-Image: Image watermarking at internet scale.**
|
||||
arXiv:2510.09263. https://arxiv.org/abs/2510.09263
|
||||
|
||||
2. Google DeepMind. **Identifying AI-generated images with SynthID.** Blog post,
|
||||
2023. https://deepmind.google/blog/identifying-ai-generated-images-with-synthid/
|
||||
|
||||
3. Google DeepMind. **SynthID.** Product page.
|
||||
https://deepmind.google/models/synthid/
|
||||
|
||||
4. Goonatilake & Ateniese (2026). **Removing the Watermark Is Not Enough:
|
||||
Forensic Stealth in Generative-AI Watermark Removal.** arXiv:2605.09203.
|
||||
https://arxiv.org/abs/2605.09203
|
||||
|
||||
5. OpenAI. **Verify tool for AI-generated images.** openai.com/research/verify.
|
||||
Accessed 2026-05-31.
|
||||
@@ -0,0 +1,140 @@
|
||||
# Text protection research: crisp text under a "watermark removed everywhere" constraint
|
||||
|
||||
Date: 2026-05-29. Source: a deep-research run (104 agents, 5 search angles, sources
|
||||
fetched and 3-vote adversarially verified). Not committed automatically — saved as a
|
||||
research note for the next session.
|
||||
|
||||
## The constraint that frames everything
|
||||
|
||||
The invisible watermark (Google SynthID) must be removed **everywhere, including inside
|
||||
text regions**. Therefore any technique that keeps or composites the **original
|
||||
(watermarked) text pixels** is disqualified — the text must be *regenerated / freshly
|
||||
synthesized* enough to scrub the watermark, yet rendered crisply. This single rule is the
|
||||
filter applied to every candidate below.
|
||||
|
||||
## Problem recap
|
||||
|
||||
The `invisible` pipeline is SDXL base 1.0 img2img to defeat SynthID. The default
|
||||
strength has risen over time as Google hardens SynthID (0.05 -> 0.10 -> **~0.30**, the
|
||||
current threshold for fresh Gemini output); higher strength deforms text more, which is
|
||||
exactly why text protection matters. Text is protected via Differential Diffusion with a
|
||||
per-pixel change map (`preserve` ~0.9) driven by the PP-OCRv3 DB detector
|
||||
(`text_protector.py`). Large text survives; **small text (sub ~8 px strokes) softens or
|
||||
garbles** (issue #14, confirmed on real content).
|
||||
|
||||
## Executive summary
|
||||
|
||||
The fine-text softening is an **architectural consequence of latent-space processing, not
|
||||
a tuning problem**: SDXL's 4-channel VAE (~48x compression) discards high-frequency signal
|
||||
on encode, and Differential Diffusion blends in latent space with the change map
|
||||
downsampled by 8x, so any stroke under ~8 px sits inside one latent cell and cannot be
|
||||
preserved or edited cleanly **regardless of `preserve`** (the Differential Diffusion
|
||||
authors state this limit explicitly). Two structurally sound directions keep the
|
||||
"watermark removed everywhere" guarantee because they **synthesize fresh glyph pixels**
|
||||
rather than compositing originals: (1) glyph/text-conditioned diffusion re-render of
|
||||
detected text (AnyText2, EasyText), and (2) a two-stage architecture — global scrub, then
|
||||
a dedicated text-restoration / text-aware super-resolution pass over detected regions
|
||||
(TIGER, TextSR, TeReDiff/TAIR). **EasyText** and **TextSR** are the most promising for this
|
||||
CJK-first pipeline (both multilingual via DiT/ByT5, both regenerate from glyph or
|
||||
character-shape priors). The deepest fix — a 16-channel (SD3/FLUX) VAE — materially reduces
|
||||
the softening but means switching the base model, not a drop-in VAE swap.
|
||||
|
||||
## Constraint reconciliation (important)
|
||||
|
||||
The generic research "quick win: bump `preserve` toward 1.0" is **invalid under our hard
|
||||
constraint**: raising `preserve` freezes the text region, so SynthID there is **not
|
||||
scrubbed**. Likewise, pixel paste-back of the original text is disqualified. The only
|
||||
constraint-compatible quick win is **higher resolution / tiled diffusion** (strokes span
|
||||
more latent cells, less VAE softening, while the text is still fully regenerated and thus
|
||||
scrubbed). The real answer is **regenerate text crisply**, not freeze it.
|
||||
|
||||
## Findings (with confidence and sources)
|
||||
|
||||
### Finding 1 — confidence: high
|
||||
|
||||
**Claim.** The small-text softening is an architectural latent-space limit, not a tuning issue. SDXL's VAE compressively encodes (losing exact color and fine detail on every round-trip), and Differential Diffusion blends in latent space with the change map downsampled to latent resolution (8x), so the method explicitly caps edit/preserve granularity at ~8 px under SD settings. Text strokes below one latent cell cannot be cleanly preserved even at preserve ~0.9.
|
||||
|
||||
**Evidence.** Differential Diffusion's paper states a "cap on the resolution of the change map ... can limit the ability to precisely edit small objects (less than 8 pixels for Stable-Diffusion's settings)"; the official SDXL pipeline downsamples the map by `vae_scale_factor=8` and blends `latents = original*mask + latents*(1-mask)` in latent space. The VAE encode is "compressive ... exact color qualities and exact visual fine-details are lost." arXiv:2512.05198 confirms "resizing the pixel mask to latent resolution discards fine structure ... downsamples by 1/8" and that linear latent blending "cannot be pixel-equivalent." Higher compression = more high-frequency loss (arXiv:2305.02541).
|
||||
|
||||
**Sources.** https://onlinelibrary.wiley.com/doi/10.1111/cgf.70040 · https://differential-diffusion.github.io/ · https://github.com/exx8/differential-diffusion · https://arxiv.org/abs/2512.05198 · https://omriavrahami.com/blended-latent-diffusion-page/ · https://arxiv.org/pdf/2305.02541
|
||||
|
||||
### Finding 2 — confidence: low (do not build on it yet)
|
||||
|
||||
**Claim.** Pixel-space differential / blended-latent variants exist as a research direction, but the specific full-resolution-mask solution (PELC/DecFormer, arXiv:2512.05198) was NOT verified to deliver its claimed seam/edge improvements.
|
||||
|
||||
**Evidence.** arXiv:2512.05198 argues linear latent blending is not pixel-equivalent and proposes decoder-equivariant compositing; PixPerfect (arXiv:2512.03247) does pixel-space refinement of chromatic shifts at edit boundaries. But the specific PELC full-resolution-mask and DecFormer "53% error reduction" claims were **refuted on adversarial vote (0-3 and 1-2)**. Treat pixel-equivalent latent compositing as an emerging idea to watch, not a production fix.
|
||||
|
||||
**Sources.** https://arxiv.org/abs/2512.05198 · https://arxiv.org/abs/2512.03247
|
||||
|
||||
### Finding 3 — confidence: high
|
||||
|
||||
**Claim.** Glyph/text-conditioned diffusion can re-render detected text as freshly synthesized pixels (not copied), which inherently scrubs any watermark in the text region while rendering glyphs crisply. AnyText/AnyText2 inject text-rendering into a pretrained T2I model and support generation AND editing of existing scene images; multilingual including CJK and English.
|
||||
|
||||
**Evidence.** AnyText2 "enables precise control over multilingual text attributes in natural scene image generation and editing" (WriteNet+AttnX); +3.3% (Chinese) / +9.3% (English) accuracy over AnyText v1. AnyText "can be plugged into existing diffusion models ... for rendering or editing text" and synthesizes text latent features through diffusion (fresh pixels), supporting zh/en/ja/ko/ar/bn/hi. **Caveat:** both are SD1.5-based, so NOT a drop-in into the SDXL scrub (separate base model); AnyText's own limitation: "the inpainting manner ... impedes editing quality on small text," and it ranks weak on STRICT (EMNLP 2025) — small-text crispness not guaranteed.
|
||||
|
||||
**Sources.** https://github.com/tyxsspa/AnyText2 · https://arxiv.org/abs/2411.15245 · https://arxiv.org/abs/2311.03054
|
||||
|
||||
### Finding 4 — confidence: high
|
||||
|
||||
**Claim.** EasyText is a strong glyph-conditioned re-render candidate: built on the FLUX-dev DiT framework with LoRA tuning, renders compact per-character glyph patches (64px-high adaptive for alphabetic, 64x64 for logographic) concatenated in latent space, supports 10+ languages including Chinese, Japanese, Korean, Thai, Vietnamese, Greek, and Latin.
|
||||
|
||||
**Evidence.** AAAI 2025 + arXiv:2505.24417: "implemented based on the open-source FLUX-dev framework with LoRA-based parameter-efficient tuning," VAE and text encoder frozen, two-stage 512->1024 training. Glyph conditioning via "64-pixel-high images ... adaptive widths for alphabetic; fixed 64x64 for logographic," VAE-encoded and concatenated with denoised latents, "less than one-tenth the spatial size of layout-matching methods." FLUX-based (16-channel VAE, DiT) also sidesteps the SDXL 4-channel wall. Fresh-pixel generation preserves the watermark-removal guarantee. Cyrillic/Arabic crispness not separately benchmarked.
|
||||
|
||||
**Sources.** https://arxiv.org/html/2505.24417 · https://ojs.aaai.org/index.php/AAAI/article/view/37697
|
||||
|
||||
### Finding 5 — confidence: high
|
||||
|
||||
**Claim.** A two-stage "global watermark scrub then text-restoration pass" architecture is validated by recent literature, and the restoration stage can synthesize glyph pixels from priors (no original-pixel reintroduction). TIGER reconstructs stroke geometry then injects it as guidance into full-image super-resolution; TextSR uses a detector + multilingual OCR to regenerate text from character-shape priors; TeReDiff/TAIR couples a jointly-trained text-spotter with diffusion.
|
||||
|
||||
**Evidence.** TIGER (arXiv:2510.21590): "a diffusion-based local text refiner ... reconstructing fine-grained stroke geometry ... injected as conditional guidance into the subsequent full-image restoration." TextSR (arXiv:2505.23119, Google): "leverages a text detector ... then employs OCR to extract multilingual text," regenerating from "multilingual character-to-shape diffusion priors" that "produce character shapes solely based on text prompts, even without visual input" — fresh pixels. TAIR/TeReDiff (ICLR 2026): standard restoration "frequently generates plausible but incorrect textures"; TeReDiff feeds text-spotter outputs back as prompts. **Caveat:** TIGER orders text-first then global (reverse of scrub-then-text); these target degraded-input super-resolution, not watermark removal, so the SynthID-scrub of the restoration stage must be verified empirically (the stages are themselves diffusion-based, so fresh-pixel = no SynthID is plausible but unproven here).
|
||||
|
||||
**Sources.** https://arxiv.org/html/2510.21590v1 · https://arxiv.org/html/2505.23119v1 · https://cvlab-kaist.github.io/TAIR/ · https://arxiv.org/abs/2506.09993
|
||||
|
||||
### Finding 6 — confidence: high
|
||||
|
||||
**Claim.** Switching to a 16-channel VAE (SD3/FLUX class) materially reduces small-text/latent softening vs SDXL's 4-channel VAE, but it requires switching the base model — not a drop-in latent swap into an SDXL UNet img2img pipeline. RAE approaches are DiT-native and likewise not drop-in.
|
||||
|
||||
**Evidence.** SD3/FLUX moved from 4-channel (48x) to 16-channel (12x) VAEs specifically to preserve fine detail (diffusers Discussion #8713; madebyollin VAE notes; arXiv:2305.02541). RAE (arXiv:2510.11690) "should be the new default for diffusion transformer training" but produces high-dimensional latents needing a DiT wide-DDT head — NOT compatible with an SDXL 4-channel UNet. EasyText shows the practical path: adopt a FLUX-DiT base rather than retrofit SDXL. The VAE upgrade couples to a base-model migration.
|
||||
|
||||
**Sources.** https://arxiv.org/abs/2510.11690 · https://arxiv.org/pdf/2305.02541 · https://arxiv.org/html/2505.24417
|
||||
|
||||
## Recommendation
|
||||
|
||||
Under the hard constraint, the correct architecture is **not "protect text during the
|
||||
scrub" (Differential Diffusion)** but **"scrub everywhere, then restore text crisply by
|
||||
regeneration"**:
|
||||
|
||||
1. Global SDXL scrub with text protection OFF (text region is scrubbed too).
|
||||
2. On detected text regions, a **glyph-conditioned restoration** that re-renders the same
|
||||
glyphs as fresh pixels (no original reused).
|
||||
|
||||
This is the only path that delivers both "watermark everywhere" and crisp text.
|
||||
|
||||
**Top-2 to prototype:**
|
||||
- **TextSR** — detector + multilingual OCR + character-shape diffusion priors; closest to
|
||||
the existing detector-driven pipeline.
|
||||
- **EasyText** — FLUX-DiT glyph re-render, multilingual incl. CJK; also gets the 16-channel
|
||||
VAE for free.
|
||||
|
||||
**Honest costs / unknowns:** this is a re-architecture, not a quick fix. It needs a new
|
||||
**OCR-recognition** step (we currently only detect text; we must know *what* to re-render).
|
||||
Models are FLUX/DiT-class (heavy) -> serverless GPU. Maturity is research-grade; CJK is
|
||||
covered, Cyrillic/Arabic crispness is not separately benchmarked -> a prototype must
|
||||
measure real fidelity. The restoration stage being diffusion-based makes "fresh pixels =
|
||||
no SynthID" plausible but **must be verified empirically** (run the SynthID oracle on the
|
||||
restored output).
|
||||
|
||||
**Constraint-compatible quick win to try first:** run the global scrub at **higher
|
||||
resolution / tiled** so strokes exceed the latent cell — less softening, full scrub, no
|
||||
freezing. Cheap to test; quantify recall/quality vs cost.
|
||||
|
||||
**Do not pursue:** raising `preserve` toward 1.0 or pixel paste-back (both leave original
|
||||
watermarked pixels in text); PELC/DecFormer pixel-equivalent latent compositing (refuted,
|
||||
not production-ready).
|
||||
|
||||
## Provenance
|
||||
|
||||
Deep-research workflow run `wf_118b9a03-3eb` (2026-05-29). Findings adversarially verified
|
||||
(2/3 refutes required to kill a claim). This note records research only; no code change is
|
||||
implied until a prototype validates fidelity and the SynthID-scrub guarantee on the
|
||||
restored output.
|
||||
@@ -1,25 +1,53 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.6.5"
|
||||
version = "0.8.8"
|
||||
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10"
|
||||
license = {text = "MIT"}
|
||||
classifiers = [
|
||||
"License :: OSI Approved :: MIT License",
|
||||
"Operating System :: OS Independent",
|
||||
"Programming Language :: Python :: 3.10",
|
||||
"Programming Language :: Python :: 3.11",
|
||||
"Programming Language :: Python :: 3.12",
|
||||
"Programming Language :: Python :: 3.13",
|
||||
"Topic :: Multimedia :: Graphics",
|
||||
"Topic :: Scientific/Engineering :: Image Processing",
|
||||
]
|
||||
dependencies = [
|
||||
"pillow>=10.0.0",
|
||||
"piexif>=1.1.3",
|
||||
"numpy>=1.24.0",
|
||||
"opencv-python-headless>=4.8.0",
|
||||
"click>=8.0.0",
|
||||
"rich>=13.0.0",
|
||||
"python-dotenv>=1.0.0",
|
||||
]
|
||||
|
||||
[project.optional-dependencies]
|
||||
gpu = [
|
||||
"torch>=2.0.0",
|
||||
# The default PyPI torch wheel is a CPU/CUDA build. To drive an Intel GPU
|
||||
# (Arc / Data Center) via ``--device xpu`` you need an XPU-enabled torch
|
||||
# from PyTorch's XPU wheel index (Linux/Windows only -- there is no macOS
|
||||
# XPU build). Install that build first, then this extra (torch is then
|
||||
# already satisfied and won't be re-pulled):
|
||||
# pip install torch --index-url https://download.pytorch.org/whl/xpu
|
||||
# pip install 'remove-ai-watermarks[gpu]'
|
||||
# uv users can target the ``pytorch-xpu`` index declared under [tool.uv]:
|
||||
# uv pip install torch --index-url https://download.pytorch.org/whl/xpu
|
||||
"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",
|
||||
@@ -67,6 +95,15 @@ all = ["remove-ai-watermarks[gpu,detect,trustmark,lama,dev]"]
|
||||
[tool.uv]
|
||||
prerelease = "allow"
|
||||
|
||||
# PyTorch Intel-GPU (XPU) wheel index. ``explicit = true`` keeps it inert for
|
||||
# the default CPU/CUDA install: uv consults it only when a torch install
|
||||
# explicitly targets it (see the ``gpu`` extra comment), so it does not alter
|
||||
# the locked CPU/CUDA resolution. Linux/Windows only -- no macOS XPU build.
|
||||
[[tool.uv.index]]
|
||||
name = "pytorch-xpu"
|
||||
url = "https://download.pytorch.org/whl/xpu"
|
||||
explicit = true
|
||||
|
||||
[project.scripts]
|
||||
remove-ai-watermarks = "remove_ai_watermarks.cli:main"
|
||||
|
||||
@@ -74,12 +111,24 @@ remove-ai-watermarks = "remove_ai_watermarks.cli:main"
|
||||
Repository = "https://github.com/wiltodelta/remove-ai-watermarks"
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling"]
|
||||
# Pin hatchling < 1.28: 1.28+ emits Metadata-Version 2.5 (PEP 639), which the twine
|
||||
# bundled in pypa/gh-action-pypi-publish@release/v1 rejects ("'2.5' is not a valid
|
||||
# Metadata-Version"), failing the PyPI upload (v0.8.3, 2026-06-01). 1.27.x emits 2.4,
|
||||
# which uploads fine (0.8.2 shipped on it). Lift this pin once the publish action's
|
||||
# twine is upgraded to >= 6.1.0 (2.5-aware) or the workflow moves to `uv publish`.
|
||||
requires = ["hatchling<1.28"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = ["src/remove_ai_watermarks"]
|
||||
|
||||
[tool.hatch.build.targets.sdist]
|
||||
# Keep the source distribution small: ship the package + metadata, not the
|
||||
# committed test corpora / calibration captures under data/ (tens of MB --
|
||||
# synthid_corpus images + the visible-mark captures), which pushed the 0.8.0
|
||||
# sdist past PyPI's per-project file-size limit (the wheel ships only src/).
|
||||
exclude = ["/data"]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
pythonpath = ["src"]
|
||||
@@ -123,3 +172,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
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Minimal plain-text stand-ins for the rich Console/Table API.
|
||||
|
||||
rich was dropped as a project dependency (see the CLI plain-text refactor), but
|
||||
the analysis scripts still printed through it. These shims keep the scripts
|
||||
runnable without rich: ``[bold]``/``[/]``-style markup is stripped and tables
|
||||
render as aligned plain text. Output goes through ``click.echo`` to match the
|
||||
package CLI (no bare ``print`` in tooling).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from typing import Any
|
||||
|
||||
import click
|
||||
|
||||
# Matches rich style tags: the bare close ``[/]`` and named open/close tags such
|
||||
# as ``[yellow]``, ``[bold yellow]``, ``[/green]``. Anchored to lowercase-letter
|
||||
# starts so numeric/data brackets (``[1024]``, ``[file.png]``) are left intact.
|
||||
_MARKUP = re.compile(r"\[(?:/|/?[a-z][a-z ]*)\]")
|
||||
|
||||
|
||||
def _strip(obj: Any) -> str:
|
||||
return _MARKUP.sub("", str(obj))
|
||||
|
||||
|
||||
class Table:
|
||||
"""Drop-in for ``rich.table.Table`` covering add_column/add_row + render."""
|
||||
|
||||
def __init__(self, *args: Any, title: str | None = None, **kwargs: Any) -> None:
|
||||
self.title = title
|
||||
self._headers: list[str] = []
|
||||
self._rows: list[list[str]] = []
|
||||
|
||||
def add_column(self, header: str = "", *args: Any, **kwargs: Any) -> None:
|
||||
self._headers.append(_strip(header))
|
||||
|
||||
def add_row(self, *cells: Any) -> None:
|
||||
self._rows.append([_strip(c) for c in cells])
|
||||
|
||||
def render(self) -> str:
|
||||
all_rows = ([self._headers] if self._headers else []) + self._rows
|
||||
cols = max((len(r) for r in all_rows), default=0)
|
||||
widths = [0] * cols
|
||||
for row in all_rows:
|
||||
for i, cell in enumerate(row):
|
||||
widths[i] = max(widths[i], len(cell))
|
||||
lines: list[str] = []
|
||||
if self.title:
|
||||
lines.append(_strip(self.title))
|
||||
if self._headers:
|
||||
lines.append(" ".join(h.ljust(widths[i]) for i, h in enumerate(self._headers)))
|
||||
lines.extend(" ".join(c.ljust(widths[i]) for i, c in enumerate(row)) for row in self._rows)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
class Console:
|
||||
"""Drop-in for ``rich.console.Console`` covering ``print`` (with Table)."""
|
||||
|
||||
def print(self, *objects: Any, **kwargs: Any) -> None:
|
||||
click.echo(" ".join(o.render() if isinstance(o, Table) else _strip(o) for o in objects))
|
||||
@@ -0,0 +1,214 @@
|
||||
"""Audit a local image corpus against the library's own ``identify`` detector.
|
||||
|
||||
Two jobs in one pass:
|
||||
|
||||
1. **Report** -- run ``identify`` over every image and write one CSV row per file
|
||||
(verdict, platform, confidence, watermarks, signals, integrity clashes).
|
||||
2. **Gap audit** -- for every ``unknown``-verdict file, scan only its *metadata
|
||||
region* (PNG text/eXIf chunks, JPEG APPn segments before SOS, or the file
|
||||
head for other containers) for known provenance markers. A marker found there
|
||||
on a file the detector calls ``unknown`` is a concrete lib gap: a serialization
|
||||
or generator we do not yet parse. Scanning the metadata region -- not the whole
|
||||
file -- is deliberate: short tokens collide randomly inside compressed PNG
|
||||
``IDAT`` / JPEG scan data, which produced false "xAI/Flux/AIGC" hits when the
|
||||
first audit naively scanned the first megabyte.
|
||||
|
||||
This is how new detector gaps get found (it is what surfaced the JPEG-EXIF
|
||||
``{"AIGC":{...}}`` form). Re-run after collecting a fresh corpus batch.
|
||||
|
||||
Usage:
|
||||
uv run python scripts/corpus_gap_scan.py --corpus data/spaces/originals
|
||||
uv run python scripts/corpus_gap_scan.py --corpus data/spaces/originals \\
|
||||
--report data/spaces/detector_report.csv
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import logging
|
||||
from collections import Counter
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from _plain_console import Console, Table
|
||||
|
||||
from remove_ai_watermarks.identify import identify
|
||||
from remove_ai_watermarks.metadata import _png_late_metadata
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
console = Console()
|
||||
|
||||
# Distinctive, multi-byte provenance markers worth flagging when they appear in a
|
||||
# file the detector calls `unknown`. Kept long enough that a random collision in a
|
||||
# (non-scanned) compressed stream is implausible; the metadata-region restriction
|
||||
# below is the primary guard, this list is the second. Group: C2PA/JUMBF infra,
|
||||
# AI source-type / labeling schemes, and distinctive generator name strings.
|
||||
MARKERS: tuple[bytes, ...] = (
|
||||
# C2PA / JUMBF infrastructure and AI source-type / labeling schemes.
|
||||
b"c2pa",
|
||||
b"jumbf",
|
||||
b"contentauth",
|
||||
b"trainedAlgorithmicMedia",
|
||||
b"digitalSourceType",
|
||||
b'"AIGC"',
|
||||
b"<TC260:AIGC>",
|
||||
b"TC260:AIGC",
|
||||
b"tc260.org.cn",
|
||||
b"AISystemUsed",
|
||||
b"SynthID",
|
||||
b"hf-job-id",
|
||||
b"genAIType",
|
||||
b"PhotoEditor_Re_Edit",
|
||||
b"Signature:",
|
||||
# Distinctive multi-word generator strings only. Bare single words (Luma,
|
||||
# Gemini, Sora, ...) are omitted: they collide with unrelated metadata prose
|
||||
# (e.g. "Luma" in Lightroom's EnhanceDenoiseLumaAmount), defeating precision.
|
||||
b"Midjourney",
|
||||
b"Stable Diffusion",
|
||||
b"StableDiffusion",
|
||||
b"ComfyUI",
|
||||
b"Automatic1111",
|
||||
b"DALL-E",
|
||||
b"Ideogram AI",
|
||||
b"Adobe Firefly",
|
||||
b"Black Forest",
|
||||
b"volcengine",
|
||||
b"Doubao",
|
||||
b"\xe8\xb1\x86\xe5\x8c\x85",
|
||||
b"Nano Banana",
|
||||
b"Stability AI",
|
||||
b"Samsung Galaxy",
|
||||
)
|
||||
|
||||
|
||||
def _metadata_region(path: Path) -> bytes:
|
||||
"""Return only the bytes where provenance metadata can live, never the
|
||||
compressed pixel stream (which produces random short-token collisions)."""
|
||||
try:
|
||||
head = path.read_bytes()
|
||||
except OSError:
|
||||
return b""
|
||||
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
# All ancillary metadata chunks (window=0), via the library's own walker.
|
||||
return _png_late_metadata(path, 0)
|
||||
if head[:2] == b"\xff\xd8": # JPEG: APPn segments up to Start-Of-Scan
|
||||
out = bytearray()
|
||||
p = 2
|
||||
n = len(head)
|
||||
while p + 4 <= n and head[p] == 0xFF:
|
||||
marker = head[p + 1]
|
||||
if marker == 0xDA: # SOS -> compressed scan data follows
|
||||
break
|
||||
seg_len = (head[p + 2] << 8) | head[p + 3]
|
||||
out += head[p + 4 : p + 2 + seg_len]
|
||||
p += 2 + seg_len
|
||||
return bytes(out)
|
||||
return head[:65536] # webp/avif/heif/jxl: metadata sits near the head
|
||||
|
||||
|
||||
def _row(rep) -> dict[str, str]: # noqa: ANN001 (ProvenanceReport)
|
||||
return {
|
||||
"path": "", # filled by caller (relative)
|
||||
"is_ai": str(rep.is_ai_generated),
|
||||
"platform": rep.platform or "",
|
||||
"confidence": rep.confidence,
|
||||
"watermarks": "|".join(rep.watermarks),
|
||||
"signals": "|".join(s.name for s in rep.signals),
|
||||
"integrity_clashes": "|".join(rep.integrity_clashes),
|
||||
}
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--corpus",
|
||||
type=click.Path(exists=True, file_okay=False, path_type=Path),
|
||||
default=Path("data/spaces/originals"),
|
||||
show_default=True,
|
||||
help="Directory of images to scan (recursively).",
|
||||
)
|
||||
@click.option(
|
||||
"--report",
|
||||
type=click.Path(path_type=Path),
|
||||
default=None,
|
||||
help="Write the per-file CSV here (default: <corpus>/../detector_report.csv).",
|
||||
)
|
||||
@click.option("--limit", type=int, default=0, help="Scan at most N files (0 = all).")
|
||||
def main(corpus: Path, report: Path | None, limit: int) -> None:
|
||||
logging.basicConfig(level=logging.WARNING, format="%(message)s")
|
||||
report = report or corpus.parent / "detector_report.csv"
|
||||
|
||||
files = sorted(p for p in corpus.rglob("*") if p.is_file())
|
||||
if limit:
|
||||
files = files[:limit]
|
||||
console.print(f"Scanning [bold]{len(files)}[/bold] files under {corpus} ...")
|
||||
|
||||
verdicts: Counter[str] = Counter()
|
||||
platforms: Counter[str] = Counter()
|
||||
gap_tokens: Counter[str] = Counter()
|
||||
gaps: list[tuple[str, list[str]]] = []
|
||||
rows: list[dict[str, str]] = []
|
||||
errors = 0
|
||||
|
||||
with click.progressbar(files, label="identify") as bar:
|
||||
for p in bar:
|
||||
rel = str(p.relative_to(corpus))
|
||||
try:
|
||||
rep = identify(p)
|
||||
except Exception as exc:
|
||||
log.warning("identify failed on %s: %s", rel, exc)
|
||||
errors += 1
|
||||
continue
|
||||
row = _row(rep)
|
||||
row["path"] = rel
|
||||
rows.append(row)
|
||||
if rep.is_ai_generated:
|
||||
verdicts["ai"] += 1
|
||||
platforms[rep.platform or "?"] += 1
|
||||
continue
|
||||
verdicts["unknown"] += 1
|
||||
# A gap candidate is a file identify is *blind* to (no signal at all)
|
||||
# yet whose metadata carries a known marker. A file that produced a
|
||||
# signal but no AI verdict (e.g. an ASUS Gallery C2PA signer, which we
|
||||
# attribute but do not call AI) is handled correctly -- not a gap.
|
||||
if rep.signals:
|
||||
continue
|
||||
region = _metadata_region(p)
|
||||
hits = sorted({m.decode("latin-1", "replace") for m in MARKERS if m in region})
|
||||
if hits:
|
||||
gaps.append((rel, hits))
|
||||
gap_tokens.update(hits)
|
||||
|
||||
with report.open("w", newline="") as f:
|
||||
writer = csv.DictWriter(
|
||||
f,
|
||||
fieldnames=["path", "is_ai", "platform", "confidence", "watermarks", "signals", "integrity_clashes"],
|
||||
)
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
console.print(f"\nWrote [bold]{len(rows)}[/bold] rows -> {report}")
|
||||
|
||||
console.print(f"\n[bold]Verdicts:[/bold] AI {verdicts['ai']} | unknown {verdicts['unknown']} | errors {errors}")
|
||||
plat = Table(title="AI platforms", show_header=False)
|
||||
for name, n in platforms.most_common():
|
||||
plat.add_row(str(n), name)
|
||||
console.print(plat)
|
||||
|
||||
if gaps:
|
||||
console.print(
|
||||
f"\n[bold red]Gap candidates[/bold red]: {len(gaps)} unknown files carry a known "
|
||||
f"marker in their metadata region (potential undetected serialization/generator):"
|
||||
)
|
||||
tok = Table(title="markers seen in unknown files")
|
||||
tok.add_column("count", justify="right")
|
||||
tok.add_column("marker")
|
||||
for name, n in gap_tokens.most_common():
|
||||
tok.add_row(str(n), name)
|
||||
console.print(tok)
|
||||
for rel, hits in gaps:
|
||||
console.print(f" {rel} -> {', '.join(hits)}")
|
||||
else:
|
||||
console.print("\n[green]No gap candidates: every unknown file is metadata-free.[/green]")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -26,9 +26,8 @@ from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
from _plain_console import Console, Table
|
||||
from PIL import Image
|
||||
from rich.console import Console
|
||||
from rich.table import Table
|
||||
|
||||
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info
|
||||
|
||||
|
||||
@@ -30,8 +30,8 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
from _plain_console import Console
|
||||
from PIL import Image
|
||||
from rich.console import Console
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
@@ -0,0 +1,150 @@
|
||||
"""Multilingual recall benchmark for the text-protection detector.
|
||||
|
||||
Measures the core lever of text protection (`text_protector.TextProtector`): if
|
||||
the PP-OCRv3 DB detector misses a text region, that text is NOT preserved during
|
||||
the SDXL watermark-removal pass and gets deformed (issue #14). This renders short
|
||||
text in several scripts at several font sizes on two canvas sizes, runs detection,
|
||||
and reports the fraction of each known text bbox the detector covers.
|
||||
|
||||
Findings (2026-05-29):
|
||||
- Detection is script-agnostic: DB segments text *regions*, not characters, so
|
||||
Latin / Cyrillic / CJK / Hangul / Arabic / digits score identically. Language
|
||||
was never the lever.
|
||||
- The only lever is resolution. A fixed small detector input downscaled large
|
||||
canvases so far that small text was missed. Detecting at the native long side
|
||||
(capped, see ``text_protector._DET_MAX_LONG_SIDE``) lifts overall hit-rate
|
||||
from 0.91 to 1.00 and the worst cell (~16 px text on a 2048 canvas) from
|
||||
0.06 to 1.00.
|
||||
|
||||
This needs the detector model (downloaded on first use) and a font that covers
|
||||
all the scripts (macOS "Arial Unicode"; on Linux install a Noto super-font).
|
||||
No GPU. Run:
|
||||
|
||||
uv run python scripts/text_detection_benchmark.py
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections import defaultdict
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from _plain_console import Console
|
||||
from PIL import Image, ImageDraw, ImageFont
|
||||
|
||||
from remove_ai_watermarks import text_protector as tp
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
console = Console()
|
||||
|
||||
# A single font covering every tested script isolates "language" from "font".
|
||||
_FONT_CANDIDATES = [
|
||||
"/System/Library/Fonts/Supplemental/Arial Unicode.ttf",
|
||||
"/Library/Fonts/Arial Unicode.ttf",
|
||||
"/usr/share/fonts/truetype/noto/NotoSansCJK-Regular.ttc",
|
||||
"/usr/share/fonts/truetype/unifont/unifont.ttf",
|
||||
]
|
||||
SCRIPTS = {
|
||||
"Latin": "Generated by AI",
|
||||
"Cyrillic": "Сгенерировано ИИ",
|
||||
"CJK": "豆包AI生成内容",
|
||||
"Hangul": "AI로 생성됨",
|
||||
"Arabic": "أنشئ بالذكاء",
|
||||
"Digits": "0123456789",
|
||||
}
|
||||
FONT_SIZES = [16, 24, 32, 48, 64]
|
||||
CANVASES = [1024, 2048]
|
||||
PLACEMENTS = [(0.08, 0.15), (0.30, 0.55), (0.10, 0.82)]
|
||||
BG_COLORS = [(35, 40, 60), (210, 205, 200)]
|
||||
|
||||
|
||||
def _find_font() -> str:
|
||||
for path in _FONT_CANDIDATES:
|
||||
if Path(path).exists():
|
||||
return path
|
||||
raise SystemExit(
|
||||
"No multi-script font found. Install one (macOS ships 'Arial Unicode'; "
|
||||
"on Linux: a Noto CJK/super font) and add its path to _FONT_CANDIDATES."
|
||||
)
|
||||
|
||||
|
||||
def _render(
|
||||
font_path: str,
|
||||
canvas: int,
|
||||
text: str,
|
||||
font_size: int,
|
||||
place: tuple[float, float],
|
||||
bg: tuple[int, int, int],
|
||||
) -> tuple[Image.Image, tuple[int, int, int, int]]:
|
||||
img = Image.new("RGB", (canvas, canvas), bg)
|
||||
draw = ImageDraw.Draw(img)
|
||||
font = ImageFont.truetype(font_path, font_size)
|
||||
x, y = int(place[0] * canvas), int(place[1] * canvas)
|
||||
fg = (245, 245, 245) if sum(bg) < 360 else (20, 20, 20)
|
||||
draw.text((x, y), text, font=font, fill=fg)
|
||||
return img, draw.textbbox((x, y), text, font=font)
|
||||
|
||||
|
||||
def _coverage(boxes: list[Any], bbox: tuple[int, int, int, int], h: int, w: int) -> float | None:
|
||||
gt = np.zeros((h, w), np.uint8)
|
||||
cv2.rectangle(gt, (bbox[0], bbox[1]), (bbox[2], bbox[3]), 1, -1)
|
||||
area = int(gt.sum())
|
||||
if area == 0:
|
||||
return None
|
||||
det = np.zeros((h, w), np.uint8)
|
||||
if boxes:
|
||||
cv2.fillPoly(det, [np.asarray(b, np.int32) for b in boxes], 1)
|
||||
return int((gt & det).sum()) / area
|
||||
|
||||
|
||||
def _hitrate(values: list[float], thr: float = 0.5) -> float:
|
||||
return sum(c >= thr for c in values) / len(values) if values else float("nan")
|
||||
|
||||
|
||||
def main() -> int:
|
||||
logging.basicConfig(level=logging.WARNING)
|
||||
if not tp.is_available():
|
||||
raise SystemExit("text detector unavailable (need opencv with cv2.dnn.TextDetectionModel_DB)")
|
||||
font_path = _find_font()
|
||||
detector = tp.TextProtector()
|
||||
|
||||
by_script_size: dict[tuple[str, int], list[float]] = defaultdict(list)
|
||||
by_size_canvas: dict[tuple[int, int], list[float]] = defaultdict(list)
|
||||
by_script: dict[str, list[float]] = defaultdict(list)
|
||||
|
||||
for canvas in CANVASES:
|
||||
for script, text in SCRIPTS.items():
|
||||
for font_size in FONT_SIZES:
|
||||
for idx, place in enumerate(PLACEMENTS):
|
||||
img, bbox = _render(font_path, canvas, text, font_size, place, BG_COLORS[idx % len(BG_COLORS)])
|
||||
bgr = cv2.cvtColor(np.array(img), cv2.COLOR_RGB2BGR)
|
||||
cov = _coverage(detector.detect_text_boxes(bgr), bbox, canvas, canvas)
|
||||
if cov is None:
|
||||
continue
|
||||
by_script_size[(script, font_size)].append(cov)
|
||||
by_size_canvas[(font_size, canvas)].append(cov)
|
||||
by_script[script].append(cov)
|
||||
|
||||
console.print("=== hit-rate (coverage>=0.5) by script x font-size ===")
|
||||
console.print("script".ljust(10) + "".join(f"{fs:>7}" for fs in FONT_SIZES))
|
||||
for script in SCRIPTS:
|
||||
console.print(
|
||||
script.ljust(10) + "".join(f"{_hitrate(by_script_size[(script, fs)]):>7.2f}" for fs in FONT_SIZES)
|
||||
)
|
||||
|
||||
console.print("\n=== hit-rate by font-size x canvas (the downscale effect) ===")
|
||||
console.print("size".ljust(8) + "".join(f"{c:>8}" for c in CANVASES))
|
||||
for fs in FONT_SIZES:
|
||||
console.print(str(fs).ljust(8) + "".join(f"{_hitrate(by_size_canvas[(fs, c)]):>8.2f}" for c in CANVASES))
|
||||
|
||||
overall = _hitrate([c for vals in by_script.values() for c in vals])
|
||||
console.print(f"\nOVERALL hit-rate: {overall:.2f} (detector max long side = {tp._DET_MAX_LONG_SIDE})")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,247 @@
|
||||
"""Rebuild the visible-watermark alpha assets from controlled captures.
|
||||
|
||||
The committed, reproducible build of the bundled visible-mark assets -- the inputs
|
||||
live in ``data/<engine>_capture/captures/`` (committed solid-colour captures run
|
||||
through the generator). Re-run after re-capturing.
|
||||
|
||||
**Doubao "豆包AI生成" strip and Jimeng "★ 即梦AI" wordmark** are fixed
|
||||
semi-transparent white overlays; the asset is their recovered per-pixel alpha map
|
||||
(``assets/<engine>_alpha.png``). The "careful" solve (issue #13) -- a naive build
|
||||
(max-over-channels, coarse background, blur, truncated halo, or a black-dominated
|
||||
least-squares fit) leaves a visible outline because the alpha is wrong at the glyph
|
||||
edges:
|
||||
|
||||
1. Locate the mark on the BLACK capture (bright pixels in the bottom-right).
|
||||
2. Fit a smooth CUBIC background per channel over the GRAY capture's non-glyph
|
||||
pixels (a cubic captures the gentle gradient without bleeding glyph values).
|
||||
3. Solve ``a = (I - B) / (255 - B)`` on the gray capture, AVERAGED over channels,
|
||||
at FULL halo extent (down to a~0.02) and UNBLURRED. Gray (background ~130-200)
|
||||
is the reference because the mark sits on bright photo content in real use, not
|
||||
on black; the white capture only confirms the logo is white.
|
||||
|
||||
**Gemini sparkle** is a different type: a single icon stamped on PURE BLACK, so the
|
||||
engine reads ``alpha = max(R,G,B)/255`` directly (no background fit). Its assets are
|
||||
the sparkle-on-black capture cropped to two fixed logo sizes (``gemini_bg_{96,48}.png``).
|
||||
|
||||
Usage::
|
||||
|
||||
uv run python scripts/visible_alpha_solve.py doubao
|
||||
uv run python scripts/visible_alpha_solve.py jimeng
|
||||
uv run python scripts/visible_alpha_solve.py gemini
|
||||
uv run python scripts/visible_alpha_solve.py all
|
||||
"""
|
||||
|
||||
# cv2/numpy boundary: third-party libs ship no usable element types; relax the
|
||||
# unknown-type rules for this file only (mirrors the engine modules).
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
import click
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from numpy.typing import NDArray
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
_ROOT = Path(__file__).resolve().parents[1]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EngineSpec:
|
||||
"""Per-engine capture inputs and the alpha asset to rebuild."""
|
||||
|
||||
name: str
|
||||
capture_dir: Path
|
||||
black: str
|
||||
gray: str
|
||||
asset: Path
|
||||
native_width: int = 2048
|
||||
|
||||
|
||||
_SPECS: dict[str, EngineSpec] = {
|
||||
"doubao": EngineSpec(
|
||||
"doubao",
|
||||
_ROOT / "data" / "doubao_capture" / "captures",
|
||||
"doubao_black_1x1_1.png",
|
||||
"doubao_gray_1x1_1.png",
|
||||
_ROOT / "src" / "remove_ai_watermarks" / "assets" / "doubao_alpha.png",
|
||||
),
|
||||
"jimeng": EngineSpec(
|
||||
"jimeng",
|
||||
_ROOT / "data" / "jimeng_capture" / "captures",
|
||||
"jimeng_cap_A.png", # black seed
|
||||
"jimeng_cap_C.png", # gray seed
|
||||
_ROOT / "src" / "remove_ai_watermarks" / "assets" / "jimeng_alpha.png",
|
||||
),
|
||||
}
|
||||
|
||||
_CUBIC_BG_PAD = 30 # px of background margin around the mark for the cubic fit
|
||||
_GLYPH_BODY = 0.08 # alpha above this is the solid glyph body (for the bbox)
|
||||
_MIN_PART_AREA = 25 # drop connected glyph-mask blobs smaller than this (cubic-fit specks)
|
||||
_HALO_PAD = 7 # keep this many px of halo around the glyph body in the saved asset
|
||||
|
||||
# Gemini is a different watermark TYPE: a single sparkle icon stamped on a
|
||||
# PURE-BLACK background (so the engine reads alpha = max(R,G,B)/255 directly, no
|
||||
# background fit). Its assets are the sparkle-on-black CAPTURE at two fixed logo
|
||||
# sizes (the engine interpolates between them), not an alpha map.
|
||||
_GEMINI_CAPTURE = _ROOT / "data" / "gemini_capture" / "captures" / "gemini_black_2048.png"
|
||||
_GEMINI_ASSETS: dict[int, Path] = {
|
||||
96: _ROOT / "src" / "remove_ai_watermarks" / "assets" / "gemini_bg_96.png",
|
||||
48: _ROOT / "src" / "remove_ai_watermarks" / "assets" / "gemini_bg_48.png",
|
||||
}
|
||||
|
||||
|
||||
def _union_bbox(mask: NDArray[np.uint8], err: str) -> tuple[int, int, int, int]:
|
||||
"""Union bbox ``(x0, x1, y0, y1)`` of ``mask``'s connected components with area
|
||||
>= ``_MIN_PART_AREA``. The mark is several separate glyphs, so the union spans
|
||||
the whole word while a stray small speck/blotch is dropped by the area filter.
|
||||
Raises ``ValueError(err)`` if nothing qualifies."""
|
||||
n, _labels, stats, _c = cv2.connectedComponentsWithStats(mask, connectivity=8)
|
||||
parts = [i for i in range(1, n) if stats[i, cv2.CC_STAT_AREA] >= _MIN_PART_AREA]
|
||||
if not parts:
|
||||
raise ValueError(err)
|
||||
x0 = min(int(stats[i, cv2.CC_STAT_LEFT]) for i in parts)
|
||||
y0 = min(int(stats[i, cv2.CC_STAT_TOP]) for i in parts)
|
||||
x1 = max(int(stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH]) for i in parts)
|
||||
y1 = max(int(stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT]) for i in parts)
|
||||
return x0, x1, y0, y1
|
||||
|
||||
|
||||
def _locate_on_black(black: NDArray[np.float32]) -> tuple[int, int, int, int]:
|
||||
"""Bounding box of the white mark on the black capture (bottom-right).
|
||||
|
||||
Thresholds well above the blotchy near-black background, then unions the
|
||||
sufficiently-large bright components so the box spans the whole word.
|
||||
"""
|
||||
h, w = black.shape[:2]
|
||||
lum = black.mean(axis=2)
|
||||
br = lum > 40 # comfortably above the ~5-30 background blotches
|
||||
br[: h * 3 // 4, :] = False # bottom quarter only
|
||||
br[:, : w * 3 // 4] = False # right quarter only
|
||||
bright = cv2.morphologyEx(br.astype(np.uint8) * 255, cv2.MORPH_CLOSE, np.ones((9, 9), np.uint8))
|
||||
return _union_bbox(bright, "no mark found on the black capture (bottom-right is empty)")
|
||||
|
||||
|
||||
def _cubic_background(crop: NDArray[np.float32], glyph: NDArray[np.bool_]) -> NDArray[np.float32]:
|
||||
"""Per-channel cubic surface fit over the non-glyph pixels of ``crop``."""
|
||||
h, w = crop.shape[:2]
|
||||
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
yy /= h
|
||||
xx /= w
|
||||
terms = [np.ones_like(xx), xx, yy, xx * xx, xx * yy, yy * yy, xx**3, xx * xx * yy, xx * yy * yy, yy**3]
|
||||
basis = np.stack(terms, axis=-1).reshape(-1, len(terms))
|
||||
keep = (~glyph).reshape(-1)
|
||||
out = np.zeros_like(crop)
|
||||
for ch in range(3):
|
||||
values = crop[..., ch].reshape(-1)
|
||||
coef, *_ = np.linalg.lstsq(basis[keep], values[keep], rcond=None)
|
||||
out[..., ch] = (basis @ coef).reshape(h, w)
|
||||
return out
|
||||
|
||||
|
||||
def solve_alpha(spec: EngineSpec) -> NDArray[np.uint8]:
|
||||
"""Solve the careful gray-self alpha map for one engine (uint8, a*255)."""
|
||||
black = image_io.imread(str(spec.capture_dir / spec.black), cv2.IMREAD_COLOR)
|
||||
gray = image_io.imread(str(spec.capture_dir / spec.gray), cv2.IMREAD_COLOR)
|
||||
if black is None or gray is None:
|
||||
raise FileNotFoundError(f"missing captures in {spec.capture_dir} (expected {spec.black}, {spec.gray})")
|
||||
black_f = black.astype(np.float32)
|
||||
gray_f = gray.astype(np.float32)
|
||||
|
||||
img_h, img_w = black_f.shape[:2]
|
||||
mx0, mx1, my0, my1 = _locate_on_black(black_f)
|
||||
pad = _CUBIC_BG_PAD
|
||||
rx0, rx1 = max(0, mx0 - pad), min(img_w, mx1 + pad)
|
||||
ry0, ry1 = max(0, my0 - pad), min(img_h, my1 + pad)
|
||||
cg = gray_f[ry0:ry1, rx0:rx1]
|
||||
cb = black_f[ry0:ry1, rx0:rx1]
|
||||
|
||||
glyph = cv2.dilate((cb.mean(axis=2) > 8).astype(np.uint8), np.ones((9, 9), np.uint8)) > 0
|
||||
bg = _cubic_background(cg, glyph)
|
||||
alpha = np.clip((cg - bg).mean(axis=2) / np.clip(255.0 - bg.mean(axis=2), 1e-3, None), 0.0, 1.0)
|
||||
|
||||
# Crop to the UNION of the glyph parts (the mark is several disconnected
|
||||
# glyphs), padded by _HALO_PAD -- this keeps the real anti-aliased halo while
|
||||
# dropping the small cubic-fit specks at the crop edges (< _MIN_PART_AREA) that
|
||||
# a bare a>floor box would otherwise inflate the asset with.
|
||||
body = (alpha > _GLYPH_BODY).astype(np.uint8)
|
||||
bx, bex, by, bey = _union_bbox(body, "solved alpha has no glyph body -- check the gray capture background")
|
||||
cx0 = max(0, bx - _HALO_PAD)
|
||||
cy0 = max(0, by - _HALO_PAD)
|
||||
cx1 = min(alpha.shape[1], bex + _HALO_PAD)
|
||||
cy1 = min(alpha.shape[0], bey + _HALO_PAD)
|
||||
tight = alpha[cy0:cy1, cx0:cx1]
|
||||
aw, ah = tight.shape[1], tight.shape[0]
|
||||
# Absolute asset position in the capture, for the engine's geometry constants.
|
||||
abs_x0, abs_y0 = rx0 + cx0, ry0 + cy0
|
||||
log.info(
|
||||
"%s: alpha %dx%d max %.3f | WIDTH_FRAC %.4f HEIGHT_FRAC %.4f "
|
||||
"MARGIN_RIGHT_FRAC %.4f MARGIN_BOTTOM_FRAC %.4f (native_width %d)",
|
||||
spec.name,
|
||||
aw,
|
||||
ah,
|
||||
float(tight.max()),
|
||||
aw / spec.native_width,
|
||||
ah / spec.native_width,
|
||||
(img_w - (abs_x0 + aw)) / spec.native_width,
|
||||
(img_h - (abs_y0 + ah)) / spec.native_width,
|
||||
spec.native_width,
|
||||
)
|
||||
return (np.clip(tight, 0.0, 1.0) * 255.0).astype(np.uint8)
|
||||
|
||||
|
||||
def solve_gemini() -> dict[int, NDArray[np.uint8]]:
|
||||
"""Extract the Gemini sparkle-on-black region from the black capture at each
|
||||
bundled logo size (the bg-capture asset format; the engine derives the alpha).
|
||||
Returns ``{size: bgr_image}``."""
|
||||
black = image_io.imread(str(_GEMINI_CAPTURE), cv2.IMREAD_COLOR)
|
||||
if black is None:
|
||||
raise FileNotFoundError(f"missing Gemini capture {_GEMINI_CAPTURE}")
|
||||
h, w = black.shape[:2]
|
||||
bright = np.zeros((h, w), np.uint8)
|
||||
reg = black.astype(np.float32).mean(axis=2) > 60 # sparkle is ~0.5*255 on black
|
||||
reg[: h * 3 // 4, :] = False
|
||||
reg[:, : w * 3 // 4] = False
|
||||
bright[reg] = 255
|
||||
bright = cv2.morphologyEx(bright, cv2.MORPH_CLOSE, np.ones((7, 7), np.uint8))
|
||||
x0, x1, y0, y1 = _union_bbox(bright, "no sparkle found on the Gemini black capture")
|
||||
crop = black[y0:y1, x0:x1]
|
||||
log.info("gemini: sparkle %dx%d at margin_frac %.4f", x1 - x0, y1 - y0, (w - x1) / w)
|
||||
return {size: cv2.resize(crop, (size, size), interpolation=cv2.INTER_AREA) for size in _GEMINI_ASSETS}
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.argument("engine", type=click.Choice([*_SPECS, "gemini", "all"]))
|
||||
def main(engine: str) -> None:
|
||||
"""Rebuild the alpha asset(s) for ENGINE (doubao / jimeng / gemini / all)."""
|
||||
logging.basicConfig(level=logging.INFO, format="%(message)s")
|
||||
|
||||
def _write(path: Path, img: NDArray[np.uint8], label: str) -> None:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if not image_io.imwrite(str(path), img):
|
||||
raise OSError(f"failed to write {path}")
|
||||
log.info("%s: wrote %s", label, path.relative_to(_ROOT))
|
||||
|
||||
if engine in ("doubao", "jimeng", "all"):
|
||||
specs = list(_SPECS.values()) if engine == "all" else [_SPECS[engine]]
|
||||
for spec in specs:
|
||||
_write(spec.asset, solve_alpha(spec), spec.name)
|
||||
if engine in ("gemini", "all"):
|
||||
for size, img in solve_gemini().items():
|
||||
_write(_GEMINI_ASSETS[size], img, f"gemini-{size}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -1,3 +1,14 @@
|
||||
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
|
||||
|
||||
__version__ = "0.6.5"
|
||||
import os as _os
|
||||
import warnings as _warnings
|
||||
|
||||
# transformers prints a noisy deprecation for the Siglip2ImageProcessorFast
|
||||
# alias when it is imported (by the optional GPU/ML path). Silence it before
|
||||
# any submodule pulls transformers in, so the CLI startup stays quiet. Uses
|
||||
# setdefault so a user-set TRANSFORMERS_VERBOSITY still wins.
|
||||
_os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.8.8"
|
||||
|
After Width: | Height: | Size: 9.2 KiB |
|
Before Width: | Height: | Size: 1.6 KiB After Width: | Height: | Size: 2.7 KiB |
|
Before Width: | Height: | Size: 8.0 KiB After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 19 KiB |
@@ -1,36 +1,38 @@
|
||||
"""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.
|
||||
(Doubao AI generated) text strip in the bottom-right corner -- the explicit AIGC
|
||||
label mandated by China's TC260 standard, a near-white semi-transparent 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:
|
||||
Like the Gemini sparkle and the Jimeng wordmark, it is a fixed overlay, so removal
|
||||
starts from **reverse-alpha blending** against a captured alpha map
|
||||
(``remove_watermark_reverse_alpha``): ``original = (wm - a*logo)/(1-a)``. The alpha
|
||||
map is rebuilt by ``scripts/visible_alpha_solve.py`` from black/gray Doubao captures
|
||||
(the careful gray-self solve; logo is pure white) and bundled as
|
||||
``assets/doubao_alpha.png``. The mark re-rasterizes a few px off per image, so
|
||||
removal ALWAYS NCC-aligns the template to the actual mark and then clears the
|
||||
residual edges with a deliberately THIN inpaint over the glyph footprint (an
|
||||
earlier under-estimated alpha + fixed-no-inpaint left a readable outline that the
|
||||
detector did not flag -- see the reverse-alpha section below).
|
||||
|
||||
locate -> mask -> inpaint
|
||||
Detection (``detect``) is shape-consistent: it matches that same alpha glyph
|
||||
silhouette against the corner via normalized correlation, so it keys on the actual
|
||||
"豆包AI生成" shape rather than coverage/structure heuristics.
|
||||
|
||||
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.
|
||||
``locate`` (geometry box, scales with image WIDTH) and ``extract_mask`` (the
|
||||
candidate glyph mask the detector correlates) mirror the Jimeng engine.
|
||||
Arbitrary-region inpainting still lives in ``region_eraser`` / the ``erase``
|
||||
command. Fast, offline, no GPU.
|
||||
"""
|
||||
|
||||
# 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, Literal
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -44,13 +46,15 @@ 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
|
||||
# is anchored bottom-right. The box must be GENEROUSLY wider than the mark and
|
||||
# reach close to the corner -- the mark is re-rasterized a few px off per image,
|
||||
# and the NCC alignment search only registers within this box, so a tight box
|
||||
# (the old 0.185 / margin 0.012) let a corner-ward shift fall partly outside it
|
||||
# and the alignment missed. The glyph mask tightens the actual removal.
|
||||
WM_WIDTH_FRAC = 0.22
|
||||
WM_HEIGHT_FRAC = 0.075
|
||||
MARGIN_RIGHT_FRAC = 0.004
|
||||
MARGIN_BOTTOM_FRAC = 0.004
|
||||
|
||||
# Glyph appearance: the label is a low-saturation light gray, rendered brighter
|
||||
# than the surrounding content (the common case: a generated photo/illustration).
|
||||
@@ -63,17 +67,66 @@ 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
|
||||
# Detection is reverse-alpha-consistent: the mark is recognized by matching the
|
||||
# bundled alpha-template glyph silhouette (assets/doubao_alpha.png -- the exact
|
||||
# shape we invert) against the extracted candidate mask via zero-mean normalized
|
||||
# correlation (cv2 TM_CCOEFF_NORMED). It keys on the actual "豆包AI生成" glyph
|
||||
# SHAPE, not on coverage/structure heuristics, so a merely-textured corner does
|
||||
# not fire (the old coverage detector false-positived on ~28% of images; #23).
|
||||
# Corpus-tuned: real marks score median ~0.61, arbitrary corners <=0.17 (p99);
|
||||
# threshold 0.4 -> false positives 7/1243 (0.6%). A small coverage floor skips
|
||||
# the template match on a near-empty candidate box.
|
||||
DETECT_MIN_COVERAGE = 0.04
|
||||
DETECT_NCC_THRESHOLD = 0.4
|
||||
|
||||
# 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
|
||||
# ── Reverse-alpha (recovery + thin residual inpaint) ─────────────────
|
||||
# The Doubao mark is a fixed semi-transparent white overlay, so given its alpha
|
||||
# map the original pixels are recovered by inverting the blend: (wm - a*logo)/(1-a).
|
||||
# The alpha map is rebuilt by scripts/visible_alpha_solve.py from the black/gray
|
||||
# Doubao captures (data/doubao_capture/): the CAREFUL solve -- a = (I - B)/(255 - B)
|
||||
# on the gray capture with B a per-channel cubic background fit, mean over channels,
|
||||
# full halo extent, unblurred. The earlier build (a coarser solve) under-estimated
|
||||
# the alpha and left a clearly READABLE "豆包AI生成" outline on real samples
|
||||
# (issue #13 follow-up: the detector was fooled by the outline -- conf 0.0 -- so the
|
||||
# test passed while the result was visibly bad; suspect the captured alpha map, not
|
||||
# the method). The mark is re-rasterized and a few px off per image, so removal
|
||||
# does NOT trust fixed geometry: it ALWAYS tries fixed AND `_aligned_alpha_map`'s
|
||||
# TM_CCOEFF_NORMED scale+position search and keeps the lower-residual placement,
|
||||
# then a deliberately THIN residual inpaint clears the leftover edges without
|
||||
# smearing the recovered texture. Geometry below is emitted by the solver -- keep in
|
||||
# sync when the asset is rebuilt.
|
||||
_ALPHA_NATIVE_WIDTH = 2048
|
||||
_ALPHA_LOGO_BGR: tuple[float, float, float] = (255.0, 255.0, 255.0)
|
||||
_ALPHA_WIDTH_FRAC = 0.1636 # asset width / image width -- the alignment scale seed
|
||||
_ALPHA_HEIGHT_FRAC = 0.0405
|
||||
# Margins (of image WIDTH) of the captured mark -- the geometry record / where to
|
||||
# seed; alignment refines the actual position, so these are not load-bearing.
|
||||
_ALPHA_MARGIN_RIGHT_FRAC = 0.0132
|
||||
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0166
|
||||
# Alignment scale search (np.linspace args) around the width-scaled glyph size.
|
||||
_ALPHA_ALIGN_SEARCH = (0.88, 1.12, 25)
|
||||
# Residual inpaint over the glyph footprint -- thin (NS, small radius) so it clears
|
||||
# the leftover edges without the smear a wide full-footprint pass caused.
|
||||
_RESIDUAL_ALPHA_FLOOR = 0.05
|
||||
_RESIDUAL_DILATE = 5
|
||||
_RESIDUAL_INPAINT_RADIUS = 2
|
||||
_alpha_template_cache: NDArray[Any] | None = None
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""Lazily load the bundled Doubao alpha template (float [0,1]), or None."""
|
||||
global _alpha_template_cache
|
||||
if _alpha_template_cache is None:
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
path = Path(__file__).parent / "assets" / "doubao_alpha.png"
|
||||
img = image_io.imread(str(path), cv2.IMREAD_GRAYSCALE)
|
||||
if img is None:
|
||||
return None
|
||||
_alpha_template_cache = img.astype(np.float32) / 255.0
|
||||
return _alpha_template_cache
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
@@ -101,6 +154,39 @@ class DoubaoDetection:
|
||||
coverage: float = 0.0 # fraction of the box occupied by glyph pixels
|
||||
|
||||
|
||||
_silhouette_cache: NDArray[Any] | None = None
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "豆包AI生成" silhouette (255 = glyph) from the bundled alpha map,
|
||||
used as the detection template. None if the alpha asset is missing."""
|
||||
global _silhouette_cache
|
||||
if _silhouette_cache is None:
|
||||
at = _alpha_template()
|
||||
if at is None:
|
||||
return None
|
||||
_silhouette_cache = (at > 0.15).astype(np.uint8) * 255
|
||||
return _silhouette_cache
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
|
||||
"""Zero-mean normalized correlation of the alpha-template glyph silhouette
|
||||
(scaled to the mark's expected size) against the candidate ``box_mask``.
|
||||
|
||||
TM_CCOEFF_NORMED keys on glyph SHAPE, not coverage, so a dense textured
|
||||
corner does not score highly -- only the actual "豆包AI生成" shape does.
|
||||
"""
|
||||
sil = _glyph_silhouette()
|
||||
if sil is None or box_mask.size == 0:
|
||||
return 0.0
|
||||
gw = min(box_mask.shape[1] - 1, max(8, int(_ALPHA_WIDTH_FRAC * image_width)))
|
||||
gh = min(box_mask.shape[0] - 1, max(4, int(_ALPHA_HEIGHT_FRAC * image_width)))
|
||||
if gw < 8 or gh < 4:
|
||||
return 0.0
|
||||
template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
|
||||
return float(cv2.matchTemplate(box_mask, template, cv2.TM_CCOEFF_NORMED).max())
|
||||
|
||||
|
||||
class DoubaoEngine:
|
||||
"""Remove the visible Doubao "豆包AI生成" watermark (locate -> mask -> inpaint)."""
|
||||
|
||||
@@ -119,7 +205,7 @@ class DoubaoEngine:
|
||||
|
||||
# ── Locate ────────────────────────────────────────────────────────
|
||||
|
||||
def locate(self, image: NDArray) -> DoubaoLocation:
|
||||
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))
|
||||
@@ -134,7 +220,7 @@ class DoubaoEngine:
|
||||
|
||||
# ── Mask ──────────────────────────────────────────────────────────
|
||||
|
||||
def extract_mask(self, image: NDArray, loc: DoubaoLocation) -> NDArray:
|
||||
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
|
||||
@@ -145,7 +231,21 @@ class DoubaoEngine:
|
||||
"""
|
||||
h, w = image.shape[:2]
|
||||
x, y, bw, bh = loc.bbox
|
||||
roi = image[y : y + bh, x : x + bw].astype(np.float32)
|
||||
# A degenerate ROI (a sliver from an extremely wide/short image) cannot hold
|
||||
# the mark and would feed cv2's GaussianBlur/morphology a ~1-px-tall array,
|
||||
# which can fault the native code on some platforms (observed: a Windows
|
||||
# access violation via the always-align removal's residual `detect`). Skip
|
||||
# the cv2 pipeline and return an empty mask there.
|
||||
if bh < 16 or bw < 16:
|
||||
return np.zeros((h, w), np.uint8)
|
||||
# Normalize the ROI to 3-channel BGR: a 2D grayscale or 4-channel BGRA
|
||||
# input would otherwise break the axis=2 channel reductions below.
|
||||
roi = image[y : y + bh, x : x + bw]
|
||||
if roi.ndim == 2:
|
||||
roi = cv2.cvtColor(roi, cv2.COLOR_GRAY2BGR)
|
||||
elif roi.shape[2] == 4:
|
||||
roi = cv2.cvtColor(roi, cv2.COLOR_BGRA2BGR)
|
||||
roi = roi.astype(np.float32)
|
||||
|
||||
luma = roi.mean(axis=2)
|
||||
sat = roi.max(axis=2) - roi.min(axis=2)
|
||||
@@ -172,11 +272,13 @@ class DoubaoEngine:
|
||||
|
||||
# ── Detect ────────────────────────────────────────────────────────
|
||||
|
||||
def detect(self, image: NDArray) -> DoubaoDetection:
|
||||
"""Detect the visible Doubao mark by glyph coverage in the corner box.
|
||||
def detect(self, image: NDArray[Any]) -> DoubaoDetection:
|
||||
"""Detect the visible Doubao mark by matching the alpha-template glyph
|
||||
silhouette against the corner candidate (TM_CCOEFF_NORMED).
|
||||
|
||||
Heuristic: a genuine label fills a meaningful fraction of the box with
|
||||
text-like glyph pixels. Coverage maps to a confidence score.
|
||||
Keys on the "豆包AI生成" SHAPE, not coverage, so a textured corner does
|
||||
not fire. ``confidence`` is the correlation score; ``detected`` is it
|
||||
clearing ``DETECT_NCC_THRESHOLD``.
|
||||
"""
|
||||
det = DoubaoDetection()
|
||||
if image is None or image.size == 0:
|
||||
@@ -188,58 +290,131 @@ class DoubaoEngine:
|
||||
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)
|
||||
if coverage >= DETECT_MIN_COVERAGE:
|
||||
score = _template_match_score(box, image.shape[1])
|
||||
det.confidence = score
|
||||
det.detected = score >= DETECT_NCC_THRESHOLD
|
||||
logger.debug("Doubao detect: coverage=%.3f ncc=%.2f detected=%s", coverage, score, det.detected)
|
||||
return det
|
||||
|
||||
# ── Remove ────────────────────────────────────────────────────────
|
||||
# ── Reverse-alpha (exact recovery) ────────────────────────────────
|
||||
|
||||
def remove_watermark(
|
||||
self,
|
||||
image: NDArray,
|
||||
*,
|
||||
inpaint_method: Literal["telea", "ns"] = "telea",
|
||||
inpaint_radius: int = 6,
|
||||
dilate: int = 3,
|
||||
) -> NDArray:
|
||||
"""Remove the visible Doubao watermark by inpainting the glyph mask.
|
||||
def reverse_alpha_available(self, image: NDArray[Any]) -> bool:
|
||||
"""True if the bundled alpha map is loadable. Sub-pixel NCC alignment
|
||||
(see ``_aligned_alpha_map``) places it on the actual mark at ANY
|
||||
resolution, so there is no width gate -- the caller still gates on
|
||||
``detect`` so a clean corner is never touched."""
|
||||
return image is not None and image.size > 0 and _alpha_template() is not None
|
||||
|
||||
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
|
||||
def _fixed_alpha_map(self, image: NDArray[Any]) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
|
||||
"""Place the template by fixed width-relative geometry -- pixel-exact at
|
||||
the captured width (used there instead of integer-pixel NCC alignment)."""
|
||||
at = _alpha_template()
|
||||
if at is None:
|
||||
return None
|
||||
h, w = image.shape[:2]
|
||||
# Glyph box scales with WIDTH; on a wide/short image the height-from-width
|
||||
# box can exceed the image height. Clamp both dims so the slice assignment
|
||||
# below cannot overflow (a degenerate 2048x1 input otherwise raised
|
||||
# ValueError on the broadcast). Normal images are unaffected.
|
||||
gw = min(w, max(1, int(_ALPHA_WIDTH_FRAC * w)))
|
||||
gh = min(h, max(1, int(_ALPHA_HEIGHT_FRAC * w)))
|
||||
ax = max(0, w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw)
|
||||
ay = max(0, h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh)
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
|
||||
return amap, (ax, ay, gw, gh)
|
||||
|
||||
def _aligned_alpha_map(self, image: NDArray[Any]) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
|
||||
"""Build a full-image alpha map with the captured template registered to
|
||||
the actual mark via a TM_CCOEFF_NORMED scale + position search -- so the
|
||||
single capture works off the captured width (a pure width-scale ghosts).
|
||||
Returns ``(alpha_map, glyph_bbox)`` or None."""
|
||||
at = _alpha_template()
|
||||
sil = _glyph_silhouette()
|
||||
if at is None or sil is None:
|
||||
return None
|
||||
h, w = image.shape[:2]
|
||||
loc = self.locate(image)
|
||||
mask = self.extract_mask(image, loc)
|
||||
if not mask.any():
|
||||
logger.debug("Doubao remove: no glyph pixels found; returning copy")
|
||||
bx, by, bw, bh = loc.bbox
|
||||
box_mask = self.extract_mask(image, loc)[by : by + bh, bx : bx + bw]
|
||||
expected = _ALPHA_WIDTH_FRAC * w
|
||||
best: tuple[float, int, int, int, int] | None = None
|
||||
for scale in np.linspace(*_ALPHA_ALIGN_SEARCH):
|
||||
gw, gh = int(expected * scale), int(_ALPHA_HEIGHT_FRAC * w * scale)
|
||||
if gw < 8 or gh < 4 or gw >= bw or gh >= bh:
|
||||
continue
|
||||
t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
|
||||
_, score, _, top_left = cv2.minMaxLoc(cv2.matchTemplate(box_mask, t, cv2.TM_CCOEFF_NORMED))
|
||||
if best is None or score > best[0]:
|
||||
best = (score, gw, gh, top_left[0], top_left[1])
|
||||
if best is None:
|
||||
return None
|
||||
_, gw, gh, ox, oy = best
|
||||
ax, ay = bx + ox, by + oy
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
|
||||
return amap, (ax, ay, gw, gh)
|
||||
|
||||
def _apply_reverse_alpha(self, image: NDArray[Any], amap: NDArray[Any]) -> NDArray[Any]:
|
||||
"""Invert the alpha blend with ``amap``: ``original = (wm - a*logo)/(1-a)``."""
|
||||
a3 = np.clip(amap, 0.0, 1.0)[:, :, None]
|
||||
logo = np.array(_ALPHA_LOGO_BGR, np.float32)
|
||||
return np.clip((image.astype(np.float32) - a3 * logo) / np.clip(1.0 - a3, 0.25, 1.0), 0, 255).astype(np.uint8)
|
||||
|
||||
def remove_watermark_reverse_alpha(self, image: NDArray[Any], *, residual_inpaint: bool = True) -> NDArray[Any]:
|
||||
"""Recover the original pixels by inverting the alpha blend
|
||||
``original = (wm - a*logo)/(1-a)``, then clear the residual edges with a
|
||||
thin inpaint over the glyph footprint.
|
||||
|
||||
Placement: fixed geometry AND the NCC-aligned placement are always tried and
|
||||
the one leaving the least residual mark (lowest re-``detect`` confidence) is
|
||||
kept -- the mark is re-rasterized and a few px off per image, so fixed
|
||||
geometry alone leaves a visible outline (it did on the doubao-1.png sample).
|
||||
A single capture cannot pixel-cancel the mark on every image, so a
|
||||
deliberately THIN residual inpaint (``_RESIDUAL_*``) follows: reverse-alpha
|
||||
has already recovered the true background under the mark, so the inpaint only
|
||||
finishes the leftover edges instead of smearing the whole footprint.
|
||||
Call only when :meth:`reverse_alpha_available` and the mark is detected.
|
||||
"""
|
||||
# Normalize to 3-channel BGR so a 2D grayscale or 4-channel BGRA input
|
||||
# does not break the reverse-alpha math (which assumes a 3-channel logo).
|
||||
if image.ndim == 2:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||
elif image.shape[2] == 4:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||
# An image too small to hold the mark would make the geometry boxes
|
||||
# degenerate and feed cv2.resize a ~1-px-tall target / GaussianBlur a sliver
|
||||
# ROI, which faults natively on Windows (access violation / "Unknown C++
|
||||
# exception"). No real watermarked image is this small; skip cv2 entirely.
|
||||
h, w = image.shape[:2]
|
||||
if h < 32 or w < 64:
|
||||
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,
|
||||
)
|
||||
maps = [c for c in (self._fixed_alpha_map(image), self._aligned_alpha_map(image)) if c is not None]
|
||||
if not maps:
|
||||
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)
|
||||
best_out: NDArray[Any] | None = None
|
||||
best_amap: NDArray[Any] | None = None
|
||||
best_residual = float("inf")
|
||||
for amap, _region in maps:
|
||||
out = self._apply_reverse_alpha(image, amap)
|
||||
residual = self.detect(out).confidence
|
||||
if residual < best_residual:
|
||||
best_residual, best_out, best_amap = residual, out, amap
|
||||
if best_out is None or best_amap is None: # pragma: no cover - maps is non-empty
|
||||
return image.copy()
|
||||
if residual_inpaint:
|
||||
kernel = np.ones((_RESIDUAL_DILATE, _RESIDUAL_DILATE), np.uint8)
|
||||
rm = cv2.dilate((best_amap > _RESIDUAL_ALPHA_FLOOR).astype(np.uint8) * 255, kernel)
|
||||
best_out = cv2.inpaint(best_out, rm, _RESIDUAL_INPAINT_RADIUS, cv2.INPAINT_NS)
|
||||
return best_out
|
||||
|
||||
|
||||
def load_image_bgr(path: str | Path) -> NDArray:
|
||||
def load_image_bgr(path: str | Path) -> NDArray[Any]:
|
||||
"""Read an image as BGR ndarray (helper for scripts/tests)."""
|
||||
img = cv2.imread(str(path), cv2.IMREAD_COLOR)
|
||||
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():
|
||||
@@ -123,6 +127,17 @@ class GeminiEngine:
|
||||
This is a Python port of the GeminiWatermarkTool C++ engine.
|
||||
"""
|
||||
|
||||
# Footprint pixels with alpha at/above this are the sparkle body; below it the
|
||||
# mark barely affects the pixel, so those are excluded from both the
|
||||
# over-subtraction test and the inpaint mask.
|
||||
_FOOTPRINT_ALPHA = 0.1
|
||||
# If more than this fraction of footprint pixels over-subtract (numerator < 0),
|
||||
# the fixed alpha does not match this image's sparkle and reverse-alpha would
|
||||
# punch a dark pit -- inpaint instead. demo_banana measures 0.0 (reverse-alpha
|
||||
# kept), the issue #30 dark-grass image measures ~0.61 (inpaint), so the 0.05
|
||||
# gate separates them with a wide margin.
|
||||
_OVERSUB_FOOTPRINT_FRAC = 0.05
|
||||
|
||||
def __init__(self, logo_value: float = 255.0) -> None:
|
||||
"""Initialize the engine with embedded alpha maps.
|
||||
|
||||
@@ -151,13 +166,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 +185,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)."""
|
||||
@@ -186,8 +201,10 @@ class GeminiEngine:
|
||||
# Use large alpha template (96x96) as the high-quality source for downscaling
|
||||
source_alpha = self._alpha_large
|
||||
|
||||
# Dynamically search bottom-right corner (search up to 256x256 region)
|
||||
search_size = int(min(min(w, h), 256))
|
||||
# Dynamically search bottom-right corner. 512 covers up to 512px from the
|
||||
# corner -- enough for known Gemini margin variations (standard: 64+96=160px;
|
||||
# observed variants up to ~300px). 256 was too tight and caused misses.
|
||||
search_size = int(min(min(w, h), 512))
|
||||
sx1 = max(0, w - search_size)
|
||||
sy1 = max(0, h - search_size)
|
||||
|
||||
@@ -304,9 +321,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
|
||||
@@ -325,16 +342,21 @@ class GeminiEngine:
|
||||
"""
|
||||
result = image.copy()
|
||||
|
||||
# Handle alpha channel
|
||||
if result.shape[2] == 4:
|
||||
# Normalize to 3-channel BGR up front: 2D grayscale (no channel axis) and
|
||||
# 4-channel BGRA both reach this public entry point and would otherwise
|
||||
# crash on the channel-count checks / downstream 3-channel math.
|
||||
if result.ndim == 2:
|
||||
result = cv2.cvtColor(result, cv2.COLOR_GRAY2BGR)
|
||||
elif result.shape[2] == 4:
|
||||
result = cv2.cvtColor(result, cv2.COLOR_BGRA2BGR)
|
||||
elif result.shape[2] == 1:
|
||||
result = cv2.cvtColor(result, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
size = force_size or get_watermark_size(result.shape[1], result.shape[0])
|
||||
|
||||
# Detect dynamic position & size
|
||||
detection = self.detect_watermark(image, force_size=size)
|
||||
# Detect dynamic position & size (on the normalized 3-channel image so a
|
||||
# grayscale/BGRA input does not crash the detector).
|
||||
detection = self.detect_watermark(result, force_size=size)
|
||||
|
||||
if not detection.detected:
|
||||
logger.debug(
|
||||
@@ -354,14 +376,28 @@ class GeminiEngine:
|
||||
detection.confidence,
|
||||
)
|
||||
|
||||
self._reverse_alpha_blend(result, alpha_map, pos)
|
||||
# The captured alpha map (max ~0.51 = a ~50%-opaque white sparkle) is exact
|
||||
# only when the real mark's effective opacity matches it. On a dark/textured
|
||||
# background the sparkle's effective alpha is lower than the capture, so the
|
||||
# fixed-alpha reverse blend OVER-subtracts and drives the footprint to black --
|
||||
# the "white sparkle turns into a black pit" bug (issue #30). The signature is
|
||||
# a large fraction of footprint pixels whose numerator (watermarked - a*logo)
|
||||
# goes negative, which is physically impossible under a brightening overlay.
|
||||
# In that case inpaint the small footprint from the surrounding pixels instead;
|
||||
# on a bright background no pixel over-subtracts, so reverse-alpha is used and
|
||||
# the result is byte-identical to before (verified on demo_banana: 0% vs 61%).
|
||||
if self._reverse_alpha_oversubtracts(result, alpha_map, pos):
|
||||
logger.debug("Reverse-alpha over-subtracts on this background; inpainting sparkle footprint.")
|
||||
self._inpaint_footprint(result, alpha_map, pos)
|
||||
else:
|
||||
self._reverse_alpha_blend(result, alpha_map, pos)
|
||||
return result
|
||||
|
||||
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:
|
||||
@@ -388,32 +424,98 @@ class GeminiEngine:
|
||||
self._reverse_alpha_blend(result, alpha, (x, y))
|
||||
return result
|
||||
|
||||
def _footprint_indices(
|
||||
self,
|
||||
alpha_map: NDArray[Any],
|
||||
position: tuple[int, int],
|
||||
image_shape: tuple[int, ...],
|
||||
) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
|
||||
"""Return (alpha_roi, (y1, y2, x1, x2)) for the placed footprint, or None.
|
||||
|
||||
Shared by the over-subtraction test and the inpaint mask so both operate on
|
||||
exactly the same clipped, in-bounds region.
|
||||
"""
|
||||
x, y = position
|
||||
ah, aw = alpha_map.shape[:2]
|
||||
ih, iw = image_shape[:2]
|
||||
x1, y1 = max(0, x), max(0, y)
|
||||
x2, y2 = min(iw, x + aw), min(ih, y + ah)
|
||||
if x1 >= x2 or y1 >= y2:
|
||||
return None
|
||||
ax1, ay1 = x1 - x, y1 - y
|
||||
alpha_roi = alpha_map[ay1 : ay1 + (y2 - y1), ax1 : ax1 + (x2 - x1)]
|
||||
return alpha_roi, (y1, y2, x1, x2)
|
||||
|
||||
def _reverse_alpha_oversubtracts(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
alpha_map: NDArray[Any],
|
||||
position: tuple[int, int],
|
||||
) -> bool:
|
||||
"""True when reverse-alpha would drive the footprint dark (issue #30).
|
||||
|
||||
Tests the numerator ``watermarked - alpha*logo`` over the sparkle body: a
|
||||
brightening overlay can never make it negative, so a large negative fraction
|
||||
means the fixed alpha over-estimates this image's opacity.
|
||||
"""
|
||||
placed = self._footprint_indices(alpha_map, position, image.shape)
|
||||
if placed is None:
|
||||
return False
|
||||
alpha_roi, (y1, y2, x1, x2) = placed
|
||||
body = alpha_roi >= self._FOOTPRINT_ALPHA
|
||||
if not bool(body.any()):
|
||||
return False
|
||||
roi = image[y1:y2, x1:x2].astype(np.float32)
|
||||
numerator = roi.mean(axis=2) - np.clip(alpha_roi, 0.0, 0.99) * self.logo_value
|
||||
frac = float((numerator[body] < 0).sum()) / float(body.sum())
|
||||
return frac > self._OVERSUB_FOOTPRINT_FRAC
|
||||
|
||||
def _inpaint_footprint(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
alpha_map: NDArray[Any],
|
||||
position: tuple[int, int],
|
||||
) -> None:
|
||||
"""Inpaint the sparkle body from surrounding pixels, in-place.
|
||||
|
||||
Fallback for backgrounds where reverse-alpha over-subtracts: a small mask of
|
||||
the footprint (alpha >= threshold, dilated) is reconstructed by cv2 NS inpaint
|
||||
from the continuous surroundings, so the sparkle is replaced by plausible
|
||||
background instead of a black pit.
|
||||
"""
|
||||
placed = self._footprint_indices(alpha_map, position, image.shape)
|
||||
if placed is None:
|
||||
return
|
||||
alpha_roi, (y1, y2, x1, x2) = placed
|
||||
# Inpaint only a padded crop around the footprint, not the whole image: the
|
||||
# mask is zero outside a ~96x96 corner, so inpainting the full (multi-MP)
|
||||
# image would be ~hundreds of times more work for an identical result. The
|
||||
# padding gives cv2 enough surrounding context to reconstruct the sparkle.
|
||||
ih, iw = image.shape[:2]
|
||||
pad = 24
|
||||
cy1, cy2 = max(0, y1 - pad), min(ih, y2 + pad)
|
||||
cx1, cx2 = max(0, x1 - pad), min(iw, x2 + pad)
|
||||
crop = image[cy1:cy2, cx1:cx2]
|
||||
mask = np.zeros(crop.shape[:2], dtype=np.uint8)
|
||||
mask[y1 - cy1 : y2 - cy1, x1 - cx1 : x2 - cx1] = (alpha_roi >= self._FOOTPRINT_ALPHA).astype(np.uint8) * 255
|
||||
kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5))
|
||||
mask = cv2.dilate(mask, kernel, iterations=2)
|
||||
image[cy1:cy2, cx1:cx2] = cv2.inpaint(crop, mask, 6, cv2.INPAINT_NS)
|
||||
|
||||
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.
|
||||
|
||||
Formula: original = (watermarked - a * logo) / (1 - a)
|
||||
"""
|
||||
x, y = position
|
||||
ah, aw = alpha_map.shape[:2]
|
||||
ih, iw = image.shape[:2]
|
||||
|
||||
# Clip to bounds
|
||||
x1 = max(0, x)
|
||||
y1 = max(0, y)
|
||||
x2 = min(iw, x + aw)
|
||||
y2 = min(ih, y + ah)
|
||||
|
||||
if x1 >= x2 or y1 >= y2:
|
||||
placed = self._footprint_indices(alpha_map, position, image.shape)
|
||||
if placed is None:
|
||||
return
|
||||
|
||||
# Get ROIs
|
||||
ax1, ay1 = x1 - x, y1 - y
|
||||
alpha_roi = alpha_map[ay1 : ay1 + (y2 - y1), ax1 : ax1 + (x2 - x1)]
|
||||
alpha_roi, (y1, y2, x1, x2) = placed
|
||||
image_roi = image[y1:y2, x1:x2].astype(np.float32)
|
||||
|
||||
alpha_threshold = 0.002
|
||||
@@ -442,13 +544,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 +658,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
|
||||
@@ -33,10 +36,14 @@ def apply_analog_humanizer(image: NDArray, grain_intensity: float = 4.0, chromat
|
||||
b, g, r = cv2.split(image)
|
||||
|
||||
# 1. Chromatic Aberration
|
||||
# Shift R channel left, B channel right
|
||||
# Shift R channel left, B channel right. np.roll is circular, so it wraps
|
||||
# the opposite edge into a thin colored fringe at the L/R borders; replicate
|
||||
# the original edge columns there to keep the intended offset interior-only.
|
||||
if chromatic_shift > 0:
|
||||
r = np.roll(r, -chromatic_shift, axis=1)
|
||||
r[:, -chromatic_shift:] = r[:, -chromatic_shift - 1 : -chromatic_shift]
|
||||
b = np.roll(b, chromatic_shift, axis=1)
|
||||
b[:, :chromatic_shift] = b[:, chromatic_shift : chromatic_shift + 1]
|
||||
|
||||
merged = cv2.merge((b, g, r))
|
||||
|
||||
|
||||
@@ -8,7 +8,8 @@ Aggregates every locally-readable signal into a single :class:`ProvenanceReport`
|
||||
- **PNG text / EXIF generation parameters** (Stable Diffusion, ComfyUI, InvokeAI).
|
||||
- **SynthID metadata proxy** -- a C2PA companion from a SynthID-using vendor
|
||||
(Google / OpenAI) implies the invisible pixel watermark.
|
||||
- **Visible Gemini sparkle** (optional; needs cv2/numpy, no GPU).
|
||||
- **Visible marks** (optional; needs cv2/numpy, no GPU): the Gemini sparkle and
|
||||
the ByteDance Doubao 豆包AI生成 / Jimeng 即梦AI text marks.
|
||||
|
||||
Hard limit: a stripped image (re-encoded, screenshotted, social-media upload)
|
||||
loses all metadata, and the SynthID *pixel* watermark is not locally decodable
|
||||
@@ -25,13 +26,16 @@ from typing import TYPE_CHECKING
|
||||
from remove_ai_watermarks.metadata import (
|
||||
AI_METADATA_KEYS,
|
||||
AIGC_MARKERS,
|
||||
C2PA_UUID,
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
aigc_label,
|
||||
c2pa_marker_in,
|
||||
exif_generator,
|
||||
get_ai_metadata,
|
||||
huggingface_job,
|
||||
iptc_ai_system,
|
||||
samsung_genai,
|
||||
scan_head,
|
||||
xai_signature,
|
||||
)
|
||||
from remove_ai_watermarks.noai.c2pa import cbor_text_after, extract_c2pa_info, soft_binding_vendors_in
|
||||
@@ -40,6 +44,8 @@ from remove_ai_watermarks.noai.constants import C2PA_AI_TOOLS, C2PA_ISSUERS
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks.watermark_registry import MarkDetection
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# How much of a non-PNG container to binary-scan for the C2PA issuer.
|
||||
@@ -63,6 +69,8 @@ _ISSUER_PLATFORM: tuple[tuple[str, str], ...] = (
|
||||
("OpenAI", "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)"),
|
||||
("Google", "Google (Gemini / Imagen)"),
|
||||
("Stability AI", "Stability AI (Stable Image / DreamStudio)"),
|
||||
("Black Forest Labs", "Black Forest Labs (FLUX)"),
|
||||
("ByteDance", "ByteDance (Doubao / Jimeng / Volcano Engine)"),
|
||||
)
|
||||
|
||||
# PNG-text / EXIF keys that indicate a local diffusion pipeline (vs. a hosted
|
||||
@@ -88,6 +96,17 @@ _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."
|
||||
)
|
||||
_SAMSUNG_GENAI_CAVEAT = (
|
||||
"Samsung's genAIType marker shows a Galaxy AI editing tool (Generative Edit, "
|
||||
"Sketch to Image, ...) touched the image; it is an undocumented proprietary "
|
||||
"field, so it is a medium-confidence signal of AI editing, not proof the "
|
||||
"whole image is AI-generated."
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -110,6 +129,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]:
|
||||
@@ -139,7 +163,9 @@ def _ai_tools_in(data: bytes) -> list[str]:
|
||||
# assert is_ai on their own (the verdict still comes from the digital-source-type:
|
||||
# the Pixel sample carries `computationalCapture`, not `trainedAlgorithmicMedia`).
|
||||
# Only tokens verified against a real signed file are listed (Leica, Nikon,
|
||||
# Truepic, Google Pixel); add Sony/Canon/Samsung/Bria as real samples are captured.
|
||||
# Sony, Truepic, Google Pixel); add Canon/Bria as real samples are captured.
|
||||
# Samsung Galaxy is an AI-capable editing device, not a pure-capture camera, so
|
||||
# it lives in `_SIGNER_C2PA_PLATFORM` below (it must not feed the camera clash).
|
||||
_DEVICE_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
|
||||
(b"lc_c2pa", "Leica (camera, C2PA capture)"),
|
||||
(b"Leica Camera", "Leica (camera, C2PA capture)"),
|
||||
@@ -165,6 +191,32 @@ def _device_platform(head: bytes) -> str | None:
|
||||
return None
|
||||
|
||||
|
||||
# C2PA signers that are an editing app or AI-capable device rather than a
|
||||
# verified-capture camera. Unlike `_DEVICE_C2PA_PLATFORM`, these do NOT feed the
|
||||
# camera-vs-AI integrity clash (rule 2 in `_integrity_clashes`): a Galaxy phone
|
||||
# legitimately stamps BOTH its device credentials AND a `trainedAlgorithmicMedia`
|
||||
# source type on a Generative-Edit image, so treating it as a "genuine camera
|
||||
# capture" would false-flag every Galaxy AI edit. They only resolve the platform
|
||||
# label; the AI verdict still comes from the digital-source-type / genAIType.
|
||||
# Tokens verified against real signed files (2026-05-29):
|
||||
# Samsung Galaxy -- cert org on Galaxy S23 FE / S24 / S25 C2PA JPEGs/PNGs
|
||||
# (distinct from the EXIF "SM-xxxx" model string on ordinary Samsung photos).
|
||||
# com.asus.gallery -- ASUS Gallery claim_generator (a C2PA-signed edit, no AI
|
||||
# source type or genAIType on the samples, so it never asserts is_ai).
|
||||
_SIGNER_C2PA_PLATFORM: tuple[tuple[bytes, str], ...] = (
|
||||
(b"Samsung Galaxy", "Samsung Galaxy (C2PA)"),
|
||||
(b"com.asus.gallery", "ASUS Gallery (C2PA signer)"),
|
||||
)
|
||||
|
||||
|
||||
def _signer_platform(head: bytes) -> str | None:
|
||||
"""Map a C2PA editing-app / AI-capable-device signer token to a platform."""
|
||||
for token, platform in _SIGNER_C2PA_PLATFORM:
|
||||
if token in head:
|
||||
return platform
|
||||
return None
|
||||
|
||||
|
||||
def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None:
|
||||
"""Map a set of C2PA issuer names to a human-readable generating platform.
|
||||
|
||||
@@ -188,6 +240,88 @@ def _attribute_platform(issuers: list[str], *, is_ai: bool = True) -> str | None
|
||||
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.
|
||||
|
||||
@@ -203,6 +337,46 @@ def _visible_sparkle(image_path: Path) -> float | None:
|
||||
return detect_sparkle_confidence(image_path)
|
||||
|
||||
|
||||
# Visible text marks (registry keys) -> human-readable platform, mirroring the
|
||||
# Gemini-sparkle phrasing. These are the stripped-metadata visual fallback for
|
||||
# the China-served ByteDance generators (normally also caught by the TC260 AIGC
|
||||
# metadata label); the per-engine detection thresholds live in the registry.
|
||||
_VISIBLE_MARK_PLATFORM = {
|
||||
"doubao": "ByteDance Doubao (visible 豆包AI生成 mark detected)",
|
||||
"jimeng": "ByteDance Jimeng / Dreamina (visible 即梦AI mark detected)",
|
||||
}
|
||||
|
||||
|
||||
def _visible_text_marks(image_path: Path) -> list[MarkDetection]:
|
||||
"""Detected visible Doubao/Jimeng marks (registry ``MarkDetection`` list).
|
||||
|
||||
The Gemini sparkle keeps its own ``_visible_sparkle`` path (file-level
|
||||
confidence); these two text marks reuse the registry detectors, which apply
|
||||
each engine's calibrated NCC threshold via ``MarkDetection.detected``.
|
||||
Optional: needs cv2/numpy; returns ``[]`` if the engines/assets are missing
|
||||
or the image can't be read.
|
||||
"""
|
||||
try:
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
from remove_ai_watermarks.watermark_registry import get_mark
|
||||
except Exception as exc: # cv2/engine assets missing
|
||||
log.debug("visible-mark detectors unavailable: %s", exc)
|
||||
return []
|
||||
image = imread(image_path)
|
||||
if image is None:
|
||||
return []
|
||||
detections: list[MarkDetection] = []
|
||||
for key in _VISIBLE_MARK_PLATFORM:
|
||||
try:
|
||||
det = get_mark(key).detect(image)
|
||||
except Exception as exc: # one engine failing must not break identify
|
||||
log.debug("visible-mark %s detector failed: %s", key, exc)
|
||||
continue
|
||||
if det.detected:
|
||||
detections.append(det)
|
||||
return detections
|
||||
|
||||
|
||||
def _invisible_watermark(image_path: Path) -> str | None:
|
||||
"""Open invisible-watermark scheme name (SD/SDXL/FLUX) or None.
|
||||
|
||||
@@ -230,7 +404,8 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
|
||||
Args:
|
||||
image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container).
|
||||
check_visible: Also run the visible Gemini-sparkle detector (cv2). Set
|
||||
check_visible: Also run the visible-mark detectors (cv2) -- the Gemini
|
||||
sparkle and the Doubao/Jimeng text marks from the registry. Set
|
||||
False for a pure-metadata, dependency-light scan.
|
||||
check_invisible: Also decode open invisible watermarks (SD/SDXL/FLUX) via
|
||||
the optional imwatermark library. No-op when it is not installed.
|
||||
@@ -245,15 +420,24 @@ 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)
|
||||
signer_label = _signer_platform(head)
|
||||
|
||||
# ── C2PA Content Credentials ────────────────────────────────────
|
||||
has_c2pa = bool(info) or b"c2pa" in head.lower() or C2PA_UUID in head
|
||||
has_c2pa = bool(info) or c2pa_marker_in(head)
|
||||
issuers = [info["issuer"]] if info.get("issuer") else _issuers_in(head)
|
||||
c2pa_is_ai = "trainedAlgorithmicMedia" in info.get("source_type", "") or any(
|
||||
m in head for m in (b"trainedAlgorithmicMedia", b"compositeWithTrainedAlgorithmicMedia")
|
||||
@@ -268,24 +452,33 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
or (", ".join(tools) if (tools := _ai_tools_in(head)) else None)
|
||||
)
|
||||
# Platform: a distinctive device/camera token in the manifest wins (it is the
|
||||
# signer/producer), with the issuer byte-scan only as fallback. The issuer
|
||||
# scan alone mis-attributed real samples (Leica->Truepic timestamp authority,
|
||||
# Nikon->Adobe namespace, Pixel->Google Gemini) -- the device scan fixes that.
|
||||
platform = (_device_platform(head) or _attribute_platform(issuers, is_ai=c2pa_is_ai)) if has_c2pa else None
|
||||
# signer/producer), then an editing-app/AI-device signer (Samsung Galaxy,
|
||||
# ASUS Gallery), 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 token scans fix that.
|
||||
platform = (camera_label or signer_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)
|
||||
# and non-PNG (its own synthid_source fallback), so no extra scan is needed.
|
||||
synthid = meta.get("synthid_watermark")
|
||||
if synthid:
|
||||
watermarks.append(f"SynthID pixel watermark ({synthid})")
|
||||
watermarks.append(f"SynthID watermark, inferred from C2PA metadata ({synthid})")
|
||||
caveats.append(_SYNTHID_CAVEAT)
|
||||
if "OpenAI" in (" ".join(issuers) + synthid):
|
||||
if _vendor_of(synthid) == "OpenAI":
|
||||
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
|
||||
@@ -315,15 +508,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)
|
||||
@@ -340,6 +541,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>"
|
||||
@@ -350,6 +553,35 @@ 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)"
|
||||
|
||||
# ── Samsung Galaxy AI editing marker (genAIType) ─────────────────
|
||||
# Galaxy AI tools stamp a proprietary genAIType in PhotoEditor_Re_Edit_Data.
|
||||
# Medium confidence: it co-occurs with the C2PA trainedAlgorithmicMedia type
|
||||
# on Galaxy files that record one, and is the SOLE AI marker on a Galaxy S24
|
||||
# sample that omits the source type -- so it lifts an otherwise-Unknown
|
||||
# verdict, but the field is undocumented, so it never overrides a high-
|
||||
# confidence signal. The platform is usually already "Samsung Galaxy" via the
|
||||
# signer-token scan; the fallback covers a future file without the cert org.
|
||||
samsung_genai_type = samsung_genai(image_path)
|
||||
if samsung_genai_type is not None:
|
||||
signals.append(Signal("samsung_genai", f"Samsung genAIType={samsung_genai_type}", "medium"))
|
||||
watermarks.append("Samsung Galaxy AI editing marker (genAIType)")
|
||||
caveats.append(_SAMSUNG_GENAI_CAVEAT)
|
||||
if platform is None:
|
||||
platform = "Samsung Galaxy (Galaxy AI editing)"
|
||||
|
||||
# ── Open invisible watermark (SD / SDXL / FLUX, dwtDct) ──────────
|
||||
# Public decoder, no key -- a definitive embedded signal on pristine files.
|
||||
@@ -392,18 +624,32 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
if platform is None:
|
||||
platform = "Google Gemini family (visible sparkle detected)"
|
||||
|
||||
visible_only = any(s.name == "visible_sparkle" for s in signals) and not ai_from_metadata
|
||||
# ── Visible Doubao / Jimeng text marks (registry; same stripped-metadata
|
||||
# fallback role as the Gemini sparkle above) ─
|
||||
if check_visible:
|
||||
for det in _visible_text_marks(image_path):
|
||||
signals.append(Signal(f"visible_{det.key}", f"NCC confidence {det.confidence:.2f}", "medium"))
|
||||
watermarks.append(f"Visible {det.label} (confidence {det.confidence:.2f})")
|
||||
if platform is None:
|
||||
platform = _VISIBLE_MARK_PLATFORM[det.key]
|
||||
|
||||
visible_only = any(s.name.startswith("visible_") for s in signals) and not ai_from_metadata
|
||||
hf_only = bool(hf_job) and not ai_from_metadata
|
||||
samsung_only = samsung_genai_type is not None 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 or samsung_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))
|
||||
@@ -416,4 +662,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,70 @@
|
||||
"""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 or the path cannot be written (matching ``cv2.imwrite``,
|
||||
which returns ``False`` rather than raising on an unwritable path).
|
||||
"""
|
||||
import cv2
|
||||
|
||||
ext = Path(path).suffix or ".png"
|
||||
ok, buf = cv2.imencode(ext, img)
|
||||
if not ok:
|
||||
return False
|
||||
try:
|
||||
buf.tofile(str(path))
|
||||
except OSError:
|
||||
return False
|
||||
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:
|
||||
@@ -70,9 +70,10 @@ class InvisibleEngine:
|
||||
to break watermark patterns, and reconstructs via reverse diffusion.
|
||||
"""
|
||||
|
||||
# SDXL base is the default since May 2026: empirically defeats SynthID v2
|
||||
# at strength=0.05 / steps=50 / native ~1024px. See CLAUDE.md "Known
|
||||
# limitations" for the regression evidence ruling out SD-1.5 pipelines.
|
||||
# SDXL base is the default since May 2026; the current Google SynthID is
|
||||
# removed at strength ~0.30 / steps=50 / native res (oracle-verified, n=3 fresh
|
||||
# Gemini -- 0.10/0.15/0.2 still detected). See CLAUDE.md "Known limitations" for
|
||||
# the strength study and the regression evidence ruling out SD-1.5 pipelines.
|
||||
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
|
||||
|
||||
@@ -88,7 +89,7 @@ class InvisibleEngine:
|
||||
|
||||
Args:
|
||||
model_id: HuggingFace model ID. None = use default for pipeline.
|
||||
device: Device for inference (auto/cpu/mps/cuda). None = auto.
|
||||
device: Device for inference (auto/cpu/mps/cuda/xpu). None = auto.
|
||||
pipeline: Pipeline profile. "default" (SDXL base, defeats SynthID
|
||||
v2) or "ctrlregen" (CtrlRegen).
|
||||
hf_token: HuggingFace API token.
|
||||
@@ -124,20 +125,26 @@ class InvisibleEngine:
|
||||
guidance_scale: float | None = None,
|
||||
seed: int | None = None,
|
||||
humanize: float = 0.0,
|
||||
protect_faces: bool = True,
|
||||
protect_faces: bool = False,
|
||||
protect_text: bool = False,
|
||||
max_resolution: int = 0,
|
||||
vendor: str | None = None,
|
||||
) -> Path:
|
||||
"""Remove invisible watermark from an image.
|
||||
|
||||
Args:
|
||||
image_path: Path to the watermarked image.
|
||||
output_path: Output path (None = overwrite source).
|
||||
strength: Denoising strength (0.0-1.0). Default 0.04.
|
||||
strength: Denoising strength (0.0-1.0). None -> profile default
|
||||
(0.10 for SDXL, 1.0 clean-noise for ctrlregen).
|
||||
steps: Number of denoising steps.
|
||||
guidance_scale: Classifier-free guidance scale.
|
||||
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 +217,8 @@ class InvisibleEngine:
|
||||
num_inference_steps=num_inference_steps,
|
||||
guidance_scale=guidance_scale,
|
||||
seed=seed,
|
||||
protect_text=protect_text,
|
||||
vendor=vendor,
|
||||
)
|
||||
|
||||
# Optional: Face restoration & Humanizer (Phase 2 - Post-processing)
|
||||
@@ -217,7 +226,11 @@ 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 out_cv is None:
|
||||
return out_path
|
||||
|
||||
if protect_faces and original_faces:
|
||||
if self._progress_callback:
|
||||
@@ -243,20 +256,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:
|
||||
@@ -268,7 +283,7 @@ class InvisibleEngine:
|
||||
self,
|
||||
input_dir: Path,
|
||||
output_dir: Path,
|
||||
strength: float = 0.04,
|
||||
strength: float | None = None,
|
||||
steps: int = 50,
|
||||
) -> list[Path]:
|
||||
"""Remove invisible watermarks from all images in a directory."""
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -0,0 +1,415 @@
|
||||
"""Jimeng (即梦AI) visible watermark removal engine.
|
||||
|
||||
Jimeng / Dreamina (ByteDance's image generator, distinct from Doubao) stamps a
|
||||
visible "★ 即梦AI" wordmark -- a four-point sparkle icon followed by the 即梦AI
|
||||
characters -- in the bottom-right corner: a near-white semi-transparent overlay,
|
||||
the explicit AIGC label under China's TC260 standard.
|
||||
|
||||
Like the Gemini sparkle and the Doubao strip, it is a fixed overlay, so removal
|
||||
starts from **reverse-alpha blending** against a captured alpha map
|
||||
(``remove_watermark_reverse_alpha``): ``original = (wm - a*logo)/(1-a)``. The logo
|
||||
is pure white (255,255,255); the alpha map was solved from the GRAY Jimeng capture
|
||||
(see data/jimeng_capture/), bundled as ``assets/jimeng_alpha.png`` -- a careful
|
||||
build (cubic-background fit, mean over channels, full halo extent, unblurred) that
|
||||
drops the self-residual to ~1.3. Gray is the chosen background because the mark
|
||||
sits on bright photo content in real use, not on black.
|
||||
|
||||
Unlike the Doubao mark, Jimeng re-rasterizes its mark per generation AND jitters
|
||||
its position a few px (the alpha maps solved from independent captures correlate
|
||||
0.998 but not 1.0), so a single 2048 alpha map does not pixel-cancel the mark on
|
||||
every image/resolution the way Doubao's deterministic overlay does. Removal
|
||||
therefore NCC-aligns the alpha to the actual mark (always, not only off-native),
|
||||
reverse-alphas, then clears the residual with a deliberately THIN inpaint over the
|
||||
glyph footprint. The reverse-alpha pre-step recovers the true background (including
|
||||
edges) under the semi-transparent mark, so the thin inpaint only finishes the
|
||||
residual edges rather than smearing the whole footprint -- a wide full-footprint
|
||||
pass blurred the texture/edges under the mark. Verified clean on the solid captures
|
||||
(native 2048) and on a real 1440-wide Jimeng download (off-native, table edge kept).
|
||||
|
||||
Detection (``detect``) matches the bundled "即梦AI" glyph silhouette against the
|
||||
corner candidate via normalized correlation, so it keys on the actual mark shape
|
||||
(real marks score >=0.81, the Doubao strip 0.21, other AI output 0.0) rather than
|
||||
coverage heuristics, and does not hijack ``--mark auto`` on a Doubao image.
|
||||
|
||||
``locate`` (geometry box, scales with image WIDTH) and ``extract_mask`` (the
|
||||
candidate glyph mask the detector correlates) mirror the Doubao engine. Fast,
|
||||
offline, no GPU. Arbitrary-region inpainting still lives in ``region_eraser`` /
|
||||
the ``erase`` command.
|
||||
"""
|
||||
|
||||
# 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
|
||||
|
||||
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 Jimeng mark scales with width and is
|
||||
# anchored bottom-right. The box is intentionally generous (the glyph mask
|
||||
# tightens it); values cover the measured 2048 captures plus a real 1440 download.
|
||||
WM_WIDTH_FRAC = 0.27
|
||||
WM_HEIGHT_FRAC = 0.092
|
||||
MARGIN_RIGHT_FRAC = 0.008
|
||||
MARGIN_BOTTOM_FRAC = 0.010
|
||||
|
||||
# Glyph appearance: a low-saturation light gray rendered brighter than the
|
||||
# surrounding content (white top-hat: brighter than a blurred local background)
|
||||
# intersected with the grayish + minimum-brightness tests. Same polarity logic as
|
||||
# the Doubao engine: leaves white-paper documents untouched (the mark is not
|
||||
# brighter than its surroundings there, so nothing is masked).
|
||||
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 matches the bundled alpha-template glyph silhouette
|
||||
# (assets/jimeng_alpha.png) against the candidate via zero-mean normalized
|
||||
# correlation (cv2 TM_CCOEFF_NORMED). Real Jimeng marks score >=0.83, the Doubao
|
||||
# strip 0.22, other AI output 0.0 -> threshold 0.45 separates cleanly while
|
||||
# keeping `--mark auto` from confusing Jimeng with Doubao. A small coverage floor
|
||||
# skips the template match on a near-empty candidate box.
|
||||
DETECT_MIN_COVERAGE = 0.02
|
||||
DETECT_NCC_THRESHOLD = 0.45
|
||||
|
||||
# ── Reverse-alpha (recovery, Gemini/Doubao-style) ────────────────────
|
||||
# The Jimeng mark is a fixed semi-transparent white overlay; given its alpha map
|
||||
# the original pixels are recovered by inverting the blend. The logo is pure white
|
||||
# (the white capture confirms L=255 and a pair-solve of L lands at ~254.6). The
|
||||
# alpha map was solved from the GRAY capture: a = (I - B)/(255 - B) with B a
|
||||
# per-capture CUBIC background fit over the non-glyph pixels, averaged over the
|
||||
# three channels, kept at full halo extent (down to a~0.02) and UNBLURRED. Gray
|
||||
# (background ~132, mark contrast ~120) is chosen over black because it is the
|
||||
# best proxy for real content, where the mark sits on bright photo areas, not on
|
||||
# black; the careful build drops the gray self-residual to ~1.3 (the earlier
|
||||
# max-channel / quadratic-bg / blurred / halo-truncated build was visibly worse --
|
||||
# the mask, not the method, was the limit). The bundled asset
|
||||
# (assets/jimeng_alpha.png) is the alpha template (a*255) at the captured width.
|
||||
# The mark scales with image WIDTH; a pure width-scale is only sub-pixel-accurate
|
||||
# at the captured width, so removal also registers the template to the actual mark
|
||||
# via a TM_CCOEFF_NORMED scale+position search (`_aligned_alpha_map`) off it.
|
||||
_ALPHA_NATIVE_WIDTH = 2048
|
||||
_ALPHA_LOGO_BGR: tuple[float, float, float] = (255.0, 255.0, 255.0)
|
||||
# Geometry below is emitted by scripts/visible_alpha_solve.py for the bundled
|
||||
# asset -- keep them in sync when the asset is rebuilt.
|
||||
_ALPHA_WIDTH_FRAC = 0.2021 # asset width / image width -- the alignment scale seed
|
||||
_ALPHA_HEIGHT_FRAC = 0.0576
|
||||
# Margins (of image WIDTH) of the captured mark -- the geometry record / where to
|
||||
# seed; alignment refines the actual position, so these are not load-bearing.
|
||||
_ALPHA_MARGIN_RIGHT_FRAC = 0.0288
|
||||
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0288
|
||||
# Alignment scale search (np.linspace args) around the width-scaled glyph size --
|
||||
# fine enough that a per-image scale/position jitter does not leave a thick
|
||||
# edge-misalignment outline (a coarse step left ~4px slop at the mark ends).
|
||||
_ALPHA_ALIGN_SEARCH = (0.90, 1.12, 23)
|
||||
# Residual inpaint footprint: unlike Doubao, Jimeng's per-image render variation
|
||||
# leaves a faint outline even at native, so the glyph footprint (alpha above this)
|
||||
# is always inpainted after reverse-alpha (dilated by this kernel, INPAINT_NS).
|
||||
# Kept deliberately THIN -- the careful alpha map (cubic-background, mean-channel,
|
||||
# full-halo solve) knocks the mark down far enough that a tight footprint clears
|
||||
# it, so the inpaint does not smear the texture/edges under the mark the way a
|
||||
# wide full-footprint pass did.
|
||||
_RESIDUAL_ALPHA_FLOOR = 0.05
|
||||
_RESIDUAL_DILATE = 5
|
||||
_RESIDUAL_INPAINT_RADIUS = 2
|
||||
_alpha_template_cache: NDArray[Any] | None = None
|
||||
|
||||
|
||||
def _alpha_template() -> NDArray[Any] | None:
|
||||
"""Lazily load the bundled Jimeng alpha template (float [0,1]), or None."""
|
||||
global _alpha_template_cache
|
||||
if _alpha_template_cache is None:
|
||||
from pathlib import Path
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
path = Path(__file__).parent / "assets" / "jimeng_alpha.png"
|
||||
img = image_io.imread(str(path), cv2.IMREAD_GRAYSCALE)
|
||||
if img is None:
|
||||
return None
|
||||
_alpha_template_cache = img.astype(np.float32) / 255.0
|
||||
return _alpha_template_cache
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class JimengLocation:
|
||||
"""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 JimengDetection:
|
||||
"""Result of visible Jimeng 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
|
||||
|
||||
|
||||
_silhouette_cache: NDArray[Any] | None = None
|
||||
|
||||
|
||||
def _glyph_silhouette() -> NDArray[Any] | None:
|
||||
"""Binary "即梦AI" silhouette (255 = glyph) from the bundled alpha map, used
|
||||
as the detection template. None if the alpha asset is missing."""
|
||||
global _silhouette_cache
|
||||
if _silhouette_cache is None:
|
||||
at = _alpha_template()
|
||||
if at is None:
|
||||
return None
|
||||
_silhouette_cache = (at > 0.15).astype(np.uint8) * 255
|
||||
return _silhouette_cache
|
||||
|
||||
|
||||
def _template_match_score(box_mask: NDArray[Any], image_width: int) -> float:
|
||||
"""Zero-mean normalized correlation of the alpha-template glyph silhouette
|
||||
(scaled to the mark's expected size) against the candidate ``box_mask``."""
|
||||
sil = _glyph_silhouette()
|
||||
if sil is None or box_mask.size == 0:
|
||||
return 0.0
|
||||
gw = min(box_mask.shape[1] - 1, max(8, int(_ALPHA_WIDTH_FRAC * image_width)))
|
||||
gh = min(box_mask.shape[0] - 1, max(4, int(_ALPHA_HEIGHT_FRAC * image_width)))
|
||||
if gw < 8 or gh < 4:
|
||||
return 0.0
|
||||
template = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
|
||||
return float(cv2.matchTemplate(box_mask, template, cv2.TM_CCOEFF_NORMED).max())
|
||||
|
||||
|
||||
class JimengEngine:
|
||||
"""Remove the visible Jimeng "即梦AI" watermark (locate -> mask -> reverse-alpha)."""
|
||||
|
||||
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]) -> JimengLocation:
|
||||
"""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 JimengLocation(x=x, y=y, w=wm_w, h=wm_h, is_fallback=True)
|
||||
|
||||
# ── Mask ──────────────────────────────────────────────────────────
|
||||
|
||||
def extract_mask(self, image: NDArray[Any], loc: JimengLocation) -> NDArray[Any]:
|
||||
"""Build a full-image uint8 mask (255 = watermark glyph) for the box.
|
||||
|
||||
Polarity-aware: the mark is a light, low-saturation gray rendered brighter
|
||||
than the local background (white top-hat), so a white-paper document is
|
||||
left untouched (nothing brighter than its surroundings is masked there).
|
||||
"""
|
||||
h, w = image.shape[:2]
|
||||
x, y, bw, bh = loc.bbox
|
||||
# A degenerate ROI (a sliver from an extremely wide/short image) cannot hold
|
||||
# the mark and would feed cv2's GaussianBlur/morphology a ~1-px-tall array,
|
||||
# which can fault the native code on some platforms (observed: a Windows
|
||||
# access violation via the always-align removal's residual `detect`). Skip
|
||||
# the cv2 pipeline and return an empty mask there.
|
||||
if bh < 16 or bw < 16:
|
||||
return np.zeros((h, w), np.uint8)
|
||||
# Normalize the ROI to 3-channel BGR: a 2D grayscale or 4-channel BGRA
|
||||
# input would otherwise break the axis=2 channel reductions below.
|
||||
roi = image[y : y + bh, x : x + bw]
|
||||
if roi.ndim == 2:
|
||||
roi = cv2.cvtColor(roi, cv2.COLOR_GRAY2BGR)
|
||||
elif roi.shape[2] == 4:
|
||||
roi = cv2.cvtColor(roi, cv2.COLOR_BGRA2BGR)
|
||||
roi = roi.astype(np.float32)
|
||||
|
||||
luma = roi.mean(axis=2)
|
||||
sat = roi.max(axis=2) - roi.min(axis=2)
|
||||
grayish = sat < MAX_SATURATION
|
||||
|
||||
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
|
||||
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]) -> JimengDetection:
|
||||
"""Detect the visible Jimeng mark by matching the alpha-template glyph
|
||||
silhouette against the corner candidate (TM_CCOEFF_NORMED)."""
|
||||
det = JimengDetection()
|
||||
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
|
||||
if coverage >= DETECT_MIN_COVERAGE:
|
||||
score = _template_match_score(box, image.shape[1])
|
||||
det.confidence = score
|
||||
det.detected = score >= DETECT_NCC_THRESHOLD
|
||||
logger.debug("Jimeng detect: coverage=%.3f ncc=%.2f detected=%s", coverage, score, det.detected)
|
||||
return det
|
||||
|
||||
# ── Reverse-alpha (recovery + residual inpaint) ───────────────────
|
||||
|
||||
def reverse_alpha_available(self, image: NDArray[Any]) -> bool:
|
||||
"""True if the bundled alpha map is loadable (NCC alignment places it at
|
||||
any resolution; the caller still gates on ``detect``)."""
|
||||
return image is not None and image.size > 0 and _alpha_template() is not None
|
||||
|
||||
def _fixed_alpha_map(self, image: NDArray[Any]) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
|
||||
"""Place the template by fixed width-relative geometry."""
|
||||
at = _alpha_template()
|
||||
if at is None:
|
||||
return None
|
||||
h, w = image.shape[:2]
|
||||
gw = min(w, max(1, int(_ALPHA_WIDTH_FRAC * w)))
|
||||
gh = min(h, max(1, int(_ALPHA_HEIGHT_FRAC * w)))
|
||||
ax = max(0, w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw)
|
||||
ay = max(0, h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh)
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
|
||||
return amap, (ax, ay, gw, gh)
|
||||
|
||||
def _aligned_alpha_map(self, image: NDArray[Any]) -> tuple[NDArray[Any], tuple[int, int, int, int]] | None:
|
||||
"""Register the captured template to the actual mark via a
|
||||
TM_CCOEFF_NORMED scale + position search -- so the single capture works
|
||||
off the captured width. Returns ``(alpha_map, glyph_bbox)`` or None."""
|
||||
at = _alpha_template()
|
||||
sil = _glyph_silhouette()
|
||||
if at is None or sil is None:
|
||||
return None
|
||||
h, w = image.shape[:2]
|
||||
loc = self.locate(image)
|
||||
bx, by, bw, bh = loc.bbox
|
||||
box_mask = self.extract_mask(image, loc)[by : by + bh, bx : bx + bw]
|
||||
expected = _ALPHA_WIDTH_FRAC * w
|
||||
best: tuple[float, int, int, int, int] | None = None
|
||||
for scale in np.linspace(*_ALPHA_ALIGN_SEARCH):
|
||||
gw, gh = int(expected * scale), int(_ALPHA_HEIGHT_FRAC * w * scale)
|
||||
if gw < 8 or gh < 4 or gw >= bw or gh >= bh:
|
||||
continue
|
||||
t = cv2.resize(sil, (gw, gh), interpolation=cv2.INTER_NEAREST)
|
||||
_, score, _, top_left = cv2.minMaxLoc(cv2.matchTemplate(box_mask, t, cv2.TM_CCOEFF_NORMED))
|
||||
if best is None or score > best[0]:
|
||||
best = (score, gw, gh, top_left[0], top_left[1])
|
||||
if best is None:
|
||||
return None
|
||||
_, gw, gh, ox, oy = best
|
||||
ax, ay = bx + ox, by + oy
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh), interpolation=cv2.INTER_LINEAR)
|
||||
return amap, (ax, ay, gw, gh)
|
||||
|
||||
def _apply_reverse_alpha(self, image: NDArray[Any], amap: NDArray[Any]) -> NDArray[Any]:
|
||||
"""Invert the alpha blend with ``amap``: ``original = (wm - a*logo)/(1-a)``."""
|
||||
a3 = np.clip(amap, 0.0, 1.0)[:, :, None]
|
||||
logo = np.array(_ALPHA_LOGO_BGR, np.float32)
|
||||
return np.clip((image.astype(np.float32) - a3 * logo) / np.clip(1.0 - a3, 0.25, 1.0), 0, 255).astype(np.uint8)
|
||||
|
||||
def remove_watermark_reverse_alpha(self, image: NDArray[Any], *, residual_inpaint: bool = True) -> NDArray[Any]:
|
||||
"""Recover the original pixels by inverting the alpha blend, then clear
|
||||
the residual outline with a thin inpaint over the glyph footprint.
|
||||
|
||||
Placement: fixed geometry AND the NCC-aligned placement are always tried
|
||||
and the one leaving the least residual mark (lowest re-``detect``
|
||||
confidence) is kept -- Jimeng jitters the mark a few px per image even at
|
||||
the captured width, so fixed geometry alone is not reliable. A single 2048
|
||||
alpha cannot pixel-cancel the mark re-rasterized at another resolution, so a
|
||||
deliberately THIN residual inpaint (``_RESIDUAL_*``) follows: reverse-alpha
|
||||
has already recovered the true background (edges included) under the mark,
|
||||
so the inpaint only finishes the residual edges instead of smearing the
|
||||
whole footprint. Call only when :meth:`reverse_alpha_available` and the mark
|
||||
is detected.
|
||||
"""
|
||||
# Normalize to 3-channel BGR so a 2D grayscale or 4-channel BGRA input
|
||||
# does not break the reverse-alpha math (which assumes a 3-channel logo).
|
||||
if image.ndim == 2:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||
elif image.shape[2] == 4:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||
# An image too small to hold the mark would make the geometry boxes
|
||||
# degenerate and feed cv2.resize a ~1-px-tall target / GaussianBlur a sliver
|
||||
# ROI, which faults natively on Windows (access violation / "Unknown C++
|
||||
# exception"). No real watermarked image is this small; skip cv2 entirely.
|
||||
h, w = image.shape[:2]
|
||||
if h < 32 or w < 64:
|
||||
return image.copy()
|
||||
# Always try fixed geometry AND the NCC-aligned placement and keep
|
||||
# whichever leaves the least residual mark (re-detect confidence on the
|
||||
# bare reverse-alpha). Unlike Doubao's deterministic overlay, Jimeng jitters
|
||||
# the mark's position a few px PER IMAGE even at the captured width, so
|
||||
# fixed geometry alone misses there too -- the NCC search registers the
|
||||
# template to the actual mark; fixed stays as a fallback if the search has
|
||||
# no saliency to lock onto (a flat/contrastless mark).
|
||||
maps = [c for c in (self._fixed_alpha_map(image), self._aligned_alpha_map(image)) if c is not None]
|
||||
if not maps:
|
||||
return image.copy()
|
||||
best_out: NDArray[Any] | None = None
|
||||
best_amap: NDArray[Any] | None = None
|
||||
best_residual = float("inf")
|
||||
for amap, _region in maps:
|
||||
out = self._apply_reverse_alpha(image, amap)
|
||||
residual = self.detect(out).confidence
|
||||
if residual < best_residual:
|
||||
best_residual, best_out, best_amap = residual, out, amap
|
||||
if best_out is None or best_amap is None: # pragma: no cover - maps is non-empty
|
||||
return image.copy()
|
||||
if residual_inpaint:
|
||||
kernel = np.ones((_RESIDUAL_DILATE, _RESIDUAL_DILATE), np.uint8)
|
||||
rm = cv2.dilate((best_amap > _RESIDUAL_ALPHA_FLOOR).astype(np.uint8) * 255, kernel)
|
||||
best_out = cv2.inpaint(best_out, rm, _RESIDUAL_INPAINT_RADIUS, cv2.INPAINT_NS)
|
||||
return best_out
|
||||
|
||||
|
||||
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
|
||||
@@ -11,7 +11,8 @@ from __future__ import annotations
|
||||
import contextlib
|
||||
import logging
|
||||
import re
|
||||
from typing import TYPE_CHECKING
|
||||
import struct
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
@@ -65,6 +66,22 @@ AI_KEYWORDS: tuple[str, ...] = (
|
||||
# Reference: https://spec.c2pa.org/specifications/specifications/2.1/specs/C2PA_Specification.html
|
||||
C2PA_UUID: bytes = bytes.fromhex("d8fec3d61b0e483c92975828877ec481")
|
||||
|
||||
|
||||
def c2pa_marker_in(data: bytes) -> bool:
|
||||
"""True if ``data`` carries a real C2PA manifest marker, not just an
|
||||
incidental 4-byte ``c2pa`` substring.
|
||||
|
||||
A bare ``c2pa`` byte match false-positives on compressed pixel data -- a
|
||||
recompressed PNG IDAT (or any large binary) can contain the bytes ``c2pa``
|
||||
by chance (verified 2026-05-29: 4 cleaned PNGs re-flagged this way after
|
||||
their manifest was correctly stripped). Every real manifest is JUMBF-wrapped
|
||||
(the ``jumb`` box FourCC accompanies the ``c2pa`` content type) or uses the
|
||||
standalone C2PA ``uuid`` box in ISOBMFF, so we require one of those: the
|
||||
joint ``jumb`` + ``c2pa`` match has negligible random-collision probability.
|
||||
"""
|
||||
return C2PA_UUID in data or (b"jumb" in data and b"c2pa" in data.lower())
|
||||
|
||||
|
||||
# IPTC ``digitalSourceType`` values (IPTC 2025.1) that flag AI provenance.
|
||||
# Used by Instagram, Facebook, X (Twitter) to show "Made with AI" labels.
|
||||
IPTC_AI_MARKERS: tuple[bytes, ...] = (
|
||||
@@ -108,6 +125,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 +170,80 @@ def _is_ai_key(key: str) -> bool:
|
||||
return any(kw in key_lower for kw in AI_KEYWORDS)
|
||||
|
||||
|
||||
# PNG ancillary chunks that can carry provenance metadata (XMP, EXIF, text).
|
||||
# Never IDAT -- that is the compressed pixel stream.
|
||||
_PNG_META_CHUNKS: frozenset[bytes] = frozenset({b"tEXt", b"iTXt", b"zTXt", b"eXIf", b"iCCP"})
|
||||
|
||||
|
||||
def _png_late_metadata(image_path: Path, window: int) -> bytes:
|
||||
"""Payloads of PNG metadata chunks that start *beyond* the first ``window``
|
||||
bytes, found by seeking past the (large) ``IDAT`` pixel stream.
|
||||
|
||||
A PNG encoder may append the XMP/EXIF packet after the image data, so a
|
||||
fixed first-``size`` read misses it (e.g. a TC260 AIGC label in an XMP
|
||||
``iTXt`` chunk at ~2.7 MB). This is the PNG analogue of the ISOBMFF
|
||||
late-box scan in :func:`scan_head`. Returns only chunks past ``window`` so
|
||||
bytes already in the head are not duplicated; empty when there are none.
|
||||
"""
|
||||
out = bytearray()
|
||||
try:
|
||||
with open(image_path, "rb") as f:
|
||||
if f.read(8) != b"\x89PNG\r\n\x1a\n":
|
||||
return b""
|
||||
f.seek(0, 2)
|
||||
file_size = f.tell()
|
||||
pos = 8
|
||||
while True:
|
||||
f.seek(pos)
|
||||
header = f.read(8)
|
||||
if len(header) < 8:
|
||||
break
|
||||
(length,) = struct.unpack(">I", header[:4])
|
||||
chunk_type = header[4:8]
|
||||
if chunk_type == b"IEND":
|
||||
break
|
||||
data_start = pos + 8
|
||||
# Clamp the attacker-controlled 32-bit length to the bytes that
|
||||
# actually remain, so a malformed huge length can't allocate GBs.
|
||||
safe_length = max(0, min(length, file_size - data_start))
|
||||
if chunk_type in _PNG_META_CHUNKS and data_start >= window:
|
||||
f.seek(data_start)
|
||||
out += f.read(safe_length)
|
||||
pos = data_start + length + 4 # data + CRC
|
||||
except OSError as exc:
|
||||
logger.debug("PNG late-metadata scan failed on %s: %s", image_path, exc)
|
||||
return b""
|
||||
return bytes(out)
|
||||
|
||||
|
||||
def scan_head(image_path: Path, size: int = 1024 * 1024) -> bytes:
|
||||
"""First ``size`` bytes of the file, plus the payloads of any provenance
|
||||
metadata found beyond that window: ISOBMFF ``uuid`` / ``jumb`` boxes (seeking
|
||||
past large boxes like ``mdat``) and PNG ``tEXt`` / ``iTXt`` / ``eXIf`` chunks
|
||||
(seeking past ``IDAT``).
|
||||
|
||||
This is the shared input for every C2PA / AIGC / IPTC byte scan. The
|
||||
extensions catch a manifest or XMP packet placed AFTER the media data -- a
|
||||
non-faststart MP4 manifest, or a PNG XMP packet appended after the pixels --
|
||||
which a fixed first-MB read would miss. For other inputs, and for files that
|
||||
fit within ``size``, 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
|
||||
elif head[:8] == b"\x89PNG\r\n\x1a\n" and len(head) == size:
|
||||
# len(head) == size means the file is at least `size` bytes, so metadata
|
||||
# chunks may lie beyond the window; otherwise the whole PNG is in `head`.
|
||||
head += _png_late_metadata(image_path, size)
|
||||
return head
|
||||
|
||||
|
||||
def has_ai_metadata(image_path: Path) -> bool:
|
||||
"""Check if an image contains AI-generation metadata.
|
||||
|
||||
@@ -150,7 +262,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 +271,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,12 +280,9 @@ 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)
|
||||
if b"c2pa" in data.lower() or b"C2PA" in data:
|
||||
return True
|
||||
if C2PA_UUID in data:
|
||||
# and IPTC AI markers in XMP. First 512KB (plus late ISOBMFF provenance boxes).
|
||||
data = scan_head(image_path, 512 * 1024)
|
||||
if c2pa_marker_in(data):
|
||||
return True
|
||||
if any(marker in data for marker in AIGC_MARKERS):
|
||||
return True
|
||||
@@ -181,32 +291,150 @@ 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.
|
||||
|
||||
Three serializations are recognized:
|
||||
|
||||
- a PNG ``tEXt``/``iTXt`` chunk keyed ``AIGC`` carrying the raw JSON object
|
||||
(as written by Doubao / ByteDance), read via PIL;
|
||||
- an XMP ``<TC260:AIGC>{...}</TC260:AIGC>`` block (HTML-entity encoded text),
|
||||
found by a container-agnostic raw-byte scan (PNG/JPEG/WebP alike); and
|
||||
- a raw-JSON ``{"AIGC":{...}}`` block with no namespace, as embedded in JPEG
|
||||
EXIF (UserComment) by some China-served generators, brace-matched from the
|
||||
scan head.
|
||||
|
||||
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 generic forms (the PNG-chunk key ``AIGC`` and the bare
|
||||
``{"AIGC":...}`` object) are accepted only if they carry 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)
|
||||
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"))
|
||||
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:
|
||||
parsed = json.loads(raw)
|
||||
except ValueError:
|
||||
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, namespaced (unambiguous) in either serialization RDF allows:
|
||||
# an element <TC260:AIGC>{...}</TC260:AIGC> or an attribute TC260:AIGC="{...}"
|
||||
# (the attribute form is what PicWish writes). Both are HTML-entity encoded.
|
||||
data = scan_head(image_path)
|
||||
match = re.search(
|
||||
rb'<TC260:AIGC>(.*?)</TC260:AIGC>|TC260:AIGC\s*=\s*"(.*?)"',
|
||||
data,
|
||||
re.DOTALL,
|
||||
)
|
||||
if match:
|
||||
body = match.group(1) if match.group(1) is not None else match.group(2)
|
||||
return _parse(html.unescape(body.decode("utf-8", "replace")), require_tc260_field=False)
|
||||
|
||||
# Raw-JSON {"AIGC":{...}} block (no namespace), as written into JPEG EXIF
|
||||
# (UserComment) by some China-served generators -- the PNG-chunk and XMP
|
||||
# paths above both miss it. The bytes pre-check keeps the common (no-AIGC)
|
||||
# path off the full-buffer decode; raw_decode then brace-matches the inner
|
||||
# object (respecting nested braces / quoted strings) and `_parse` applies the
|
||||
# same dict coercion + TC260-field gate as the generic PNG-chunk path.
|
||||
if b'"AIGC"' in data:
|
||||
text = data.decode("latin-1")
|
||||
brace = text.find("{", text.find('"AIGC"') + len('"AIGC"'))
|
||||
if brace != -1:
|
||||
try:
|
||||
_, end = json.JSONDecoder().raw_decode(text, brace)
|
||||
except ValueError:
|
||||
return None
|
||||
return _parse(text[brace:end], require_tc260_field=True)
|
||||
return None
|
||||
|
||||
|
||||
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:
|
||||
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
|
||||
|
||||
|
||||
# Samsung Galaxy AI editing marker. Galaxy AI tools (Generative Edit, Sketch to
|
||||
# Image, Portrait Studio, Drawing Assist, ...) record their re-edit data as a
|
||||
# proprietary ``PhotoEditor_Re_Edit_Data`` JSON that carries a ``genAIType``
|
||||
# field; a non-zero value flags that a generative-AI tool produced or altered
|
||||
# the pixels. The field is undocumented by Samsung (verified 2026-05-29: absent
|
||||
# from the C2PA spec and Samsung's public docs/forums), so detection is
|
||||
# empirical -- on real Galaxy S23/S24/S25 files it co-occurs with the C2PA
|
||||
# ``trainedAlgorithmicMedia`` source type (3/3 of the verified files that record
|
||||
# that type), and on a Galaxy S24 sample it is the *only* AI marker (the C2PA
|
||||
# source type was absent there). Medium confidence: it signals Galaxy AI editing
|
||||
# without proving the whole image is AI-generated. Scoped to the Samsung editor
|
||||
# container to avoid matching a stray ``genAIType`` token elsewhere.
|
||||
_SAMSUNG_GENAI_RE = re.compile(rb'genAIType"\s*:\s*(-?\d+)')
|
||||
_SAMSUNG_EDITOR_MARKER = b"PhotoEditor_Re_Edit_Data"
|
||||
|
||||
|
||||
def samsung_genai(image_path: Path) -> int | None:
|
||||
"""Return Samsung's non-zero ``genAIType`` value if the image carries the
|
||||
Galaxy AI editing marker, else None.
|
||||
|
||||
See the module note above ``_SAMSUNG_GENAI_RE``: detection is empirical and
|
||||
gated on the ``PhotoEditor_Re_Edit_Data`` container so an incidental
|
||||
``genAIType`` token cannot false-positive.
|
||||
"""
|
||||
head = scan_head(image_path, 512 * 1024)
|
||||
if _SAMSUNG_EDITOR_MARKER not in head:
|
||||
return None
|
||||
m = _SAMSUNG_GENAI_RE.search(head)
|
||||
if m is None:
|
||||
return None
|
||||
return int(m.group(1)) or None
|
||||
|
||||
|
||||
def iptc_ai_system(image_path: Path) -> str | None:
|
||||
@@ -219,8 +447,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,9 +486,8 @@ 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)
|
||||
has_c2pa = b"c2pa" in data.lower() or C2PA_UUID in data
|
||||
data = scan_head(image_path)
|
||||
has_c2pa = c2pa_marker_in(data)
|
||||
# Matches both "trainedAlgorithmicMedia" and "compositeWithTrainedAlgorithmicMedia".
|
||||
ai_source = b"trainedAlgorithmicMedia" in data or b"TrainedAlgorithmicMedia" in data
|
||||
if not (has_c2pa and ai_source):
|
||||
@@ -311,8 +537,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 +561,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 +597,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 +661,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 +692,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,6 +708,13 @@ 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})")
|
||||
# Samsung Galaxy AI editing marker (genAIType in PhotoEditor_Re_Edit_Data).
|
||||
if (genai := samsung_genai(image_path)) is not None:
|
||||
result.setdefault("samsung_genai", f"Samsung Galaxy AI editing marker (genAIType={genai})")
|
||||
return result
|
||||
|
||||
|
||||
@@ -507,10 +738,18 @@ def _strip_with_ffmpeg(source_path: Path, output_path: Path) -> Path:
|
||||
)
|
||||
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",
|
||||
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
|
||||
@@ -551,16 +790,25 @@ 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
|
||||
|
||||
# Non-ISOBMFF audio/video (WebM/Matroska EBML, MP3 ID3, WAV/FLAC/OGG): the
|
||||
@@ -574,11 +822,23 @@ def remove_ai_metadata(
|
||||
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"
|
||||
|
||||
@@ -587,6 +847,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":
|
||||
|
||||
@@ -1,9 +1,36 @@
|
||||
"""Vendored noai-watermark code for invisible watermark removal.
|
||||
|
||||
Original: https://github.com/mertizci/noai-watermark (MIT License)
|
||||
|
||||
The public API (``WatermarkRemover`` / ``remove_watermark`` / ``remove_ai_metadata``)
|
||||
is exposed **lazily** via PEP 562 ``__getattr__``: importing a light submodule
|
||||
(e.g. ``noai.c2pa`` / ``noai.constants`` from ``identify``) must NOT eagerly pull
|
||||
``watermark_remover``, which imports torch + diffusers at module top. Keeping this
|
||||
lazy is what lets ``import remove_ai_watermarks.identify`` stay cheap (~36 MB, no
|
||||
torch) even in a full install where the ``gpu``/``detect`` extras are present --
|
||||
otherwise the mere presence of torch in the env inflated identify to ~420 MB and
|
||||
risked OOM on a 512 MB host.
|
||||
"""
|
||||
|
||||
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover, remove_watermark
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover, remove_watermark
|
||||
|
||||
__all__ = ["WatermarkRemover", "remove_ai_metadata", "remove_watermark"]
|
||||
|
||||
|
||||
def __getattr__(name: str) -> object:
|
||||
"""Resolve the public API on first access (PEP 562), not at package import."""
|
||||
if name == "remove_ai_metadata":
|
||||
from remove_ai_watermarks.noai.cleaner import remove_ai_metadata
|
||||
|
||||
return remove_ai_metadata
|
||||
if name in ("WatermarkRemover", "remove_watermark"):
|
||||
from remove_ai_watermarks.noai import watermark_remover
|
||||
|
||||
return getattr(watermark_remover, name)
|
||||
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
||||
@@ -55,6 +55,9 @@ def has_c2pa_metadata(image_path: Path) -> bool:
|
||||
if signature != PNG_SIGNATURE:
|
||||
return False
|
||||
|
||||
file_size = f.seek(0, 2)
|
||||
f.seek(8)
|
||||
|
||||
while True:
|
||||
chunk_header = f.read(8)
|
||||
if len(chunk_header) < 8:
|
||||
@@ -62,9 +65,12 @@ def has_c2pa_metadata(image_path: Path) -> bool:
|
||||
|
||||
length = struct.unpack(">I", chunk_header[:4])[0]
|
||||
chunk_type = chunk_header[4:8]
|
||||
# Clamp the attacker-controlled 32-bit length to the bytes that
|
||||
# actually remain, so a malformed huge length can't allocate GBs.
|
||||
safe_length = max(0, min(length, file_size - f.tell()))
|
||||
|
||||
if chunk_type == C2PA_CHUNK_TYPE:
|
||||
chunk_data = f.read(length)
|
||||
chunk_data = f.read(safe_length)
|
||||
# Check for any C2PA signature
|
||||
for sig in C2PA_SIGNATURES:
|
||||
if sig in chunk_data:
|
||||
@@ -74,7 +80,7 @@ def has_c2pa_metadata(image_path: Path) -> bool:
|
||||
return True
|
||||
f.read(4)
|
||||
else:
|
||||
f.read(length + 4)
|
||||
f.seek(safe_length + 4, 1)
|
||||
|
||||
if chunk_type == b"IEND":
|
||||
break
|
||||
@@ -108,6 +114,9 @@ def extract_c2pa_info(image_path: Path) -> dict[str, Any]:
|
||||
if signature != PNG_SIGNATURE:
|
||||
return c2pa_info
|
||||
|
||||
file_size = f.seek(0, 2)
|
||||
f.seek(8)
|
||||
|
||||
while True:
|
||||
chunk_header = f.read(8)
|
||||
if len(chunk_header) < 8:
|
||||
@@ -115,13 +124,16 @@ def extract_c2pa_info(image_path: Path) -> dict[str, Any]:
|
||||
|
||||
length = struct.unpack(">I", chunk_header[:4])[0]
|
||||
chunk_type = chunk_header[4:8]
|
||||
# Clamp the attacker-controlled 32-bit length to the bytes that
|
||||
# actually remain, so a malformed huge length can't allocate GBs.
|
||||
safe_length = max(0, min(length, file_size - f.tell()))
|
||||
|
||||
if chunk_type == C2PA_CHUNK_TYPE:
|
||||
chunk_data = f.read(length)
|
||||
chunk_data = f.read(safe_length)
|
||||
_parse_c2pa_chunk(chunk_data, c2pa_info)
|
||||
f.read(4)
|
||||
else:
|
||||
f.read(length + 4)
|
||||
f.seek(safe_length + 4, 1)
|
||||
|
||||
if chunk_type == b"IEND":
|
||||
break
|
||||
@@ -278,6 +290,9 @@ def extract_c2pa_chunk(image_path: Path) -> bytes | None:
|
||||
if signature != PNG_SIGNATURE:
|
||||
return None
|
||||
|
||||
file_size = f.seek(0, 2)
|
||||
f.seek(8)
|
||||
|
||||
while True:
|
||||
chunk_header = f.read(8)
|
||||
if len(chunk_header) < 8:
|
||||
@@ -285,9 +300,12 @@ def extract_c2pa_chunk(image_path: Path) -> bytes | None:
|
||||
|
||||
length = struct.unpack(">I", chunk_header[:4])[0]
|
||||
chunk_type = chunk_header[4:8]
|
||||
# Clamp the attacker-controlled 32-bit length to the bytes that
|
||||
# actually remain, so a malformed huge length can't allocate GBs.
|
||||
safe_length = max(0, min(length, file_size - f.tell()))
|
||||
|
||||
if chunk_type == C2PA_CHUNK_TYPE:
|
||||
chunk_data = f.read(length)
|
||||
chunk_data = f.read(safe_length)
|
||||
crc = f.read(4)
|
||||
|
||||
# Check for any C2PA signature
|
||||
@@ -299,7 +317,7 @@ def extract_c2pa_chunk(image_path: Path) -> bytes | None:
|
||||
if b"jumb" in chunk_data.lower() or b"c2pa" in chunk_data.lower():
|
||||
return chunk_header + chunk_data + crc
|
||||
else:
|
||||
f.read(length + 4)
|
||||
f.seek(safe_length + 4, 1)
|
||||
|
||||
if chunk_type == b"IEND":
|
||||
break
|
||||
|
||||
@@ -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():
|
||||
|
||||
@@ -88,6 +88,14 @@ C2PA_ISSUERS = {
|
||||
# Stability AI signs C2PA as "Stability AI" (cert org "Stability AI Ltd").
|
||||
# Verified on a live Brand Studio (DreamStudio successor) output, 2026-05-24.
|
||||
b"Stability AI": "Stability AI",
|
||||
# Black Forest Labs (FLUX) API output: claim_generator_info "Black Forest
|
||||
# Labs API" + a c2pa.ai_generated_content assertion + trainedAlgorithmicMedia.
|
||||
# Verified on a real signed FLUX JPEG, 2026-05-29.
|
||||
b"Black Forest Labs": "Black Forest Labs",
|
||||
# ByteDance's Volcano Engine (Volcengine) signs its AI image output with a
|
||||
# cert from certificate_center@volcengine.com -- the platform behind Doubao /
|
||||
# Jimeng. Verified on two real signed JPEGs, 2026-05-29.
|
||||
b"volcengine": "ByteDance (Volcano Engine)",
|
||||
}
|
||||
|
||||
# C2PA issuers whose signed outputs also carry an invisible SynthID pixel
|
||||
|
||||
@@ -20,6 +20,8 @@ from PIL import Image
|
||||
|
||||
def tile_positions(total: int, tile: int, overlap: int) -> list[int]:
|
||||
"""Compute evenly-spaced tile start positions covering *total* pixels."""
|
||||
if not (0 <= overlap < tile):
|
||||
raise ValueError(f"overlap must satisfy 0 <= overlap < tile (got overlap={overlap}, tile={tile})")
|
||||
if total <= tile:
|
||||
return [0]
|
||||
n = max(2, math.ceil((total - overlap) / (tile - overlap)))
|
||||
|
||||
@@ -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,14 @@ Reference: ISO/IEC 14496-12 (ISOBMFF) and C2PA 2.1 spec §11.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
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,
|
||||
@@ -30,6 +33,8 @@ from remove_ai_watermarks.metadata import (
|
||||
IPTC_AI_MARKERS,
|
||||
)
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Top-level box types that may carry AI provenance. ``uuid`` boxes are checked
|
||||
# against ``C2PA_UUID`` / AI-label markers before being stripped; ``jumb`` boxes
|
||||
# are always stripped (JPEG-XL uses them exclusively for JUMBF).
|
||||
@@ -42,6 +47,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 +89,60 @@ 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:
|
||||
# Detection-only: a malformed box halts the walk, so a manifest
|
||||
# placed after it is missed (best-effort scan; no resync).
|
||||
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,16 +157,19 @@ 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
|
||||
|
||||
out = bytearray()
|
||||
stripped = 0
|
||||
consumed = 0
|
||||
for start, end, box_type, payload_off in _iter_top_level_boxes(data):
|
||||
consumed = end
|
||||
if box_type == b"uuid":
|
||||
# uuid boxes carry the 16-byte UUID immediately after the type.
|
||||
is_c2pa = payload_off + 16 <= end and data[payload_off : payload_off + 16] == C2PA_UUID
|
||||
@@ -113,4 +181,45 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
stripped += 1
|
||||
continue
|
||||
out.extend(data[start:end])
|
||||
|
||||
# Fail-safe: the walker returns early on a malformed box (bad size, or a box
|
||||
# that runs past EOF), so anything after it was never visited. Emitting `out`
|
||||
# would silently truncate the file from the bad box to EOF -- worse than not
|
||||
# stripping. If the walk did not consume the whole input, return it unchanged.
|
||||
if consumed != len(data):
|
||||
log.warning(
|
||||
"ISOBMFF box walk stopped at offset %d of %d (malformed box); "
|
||||
"returning input unchanged to avoid truncation",
|
||||
consumed,
|
||||
len(data),
|
||||
)
|
||||
return data, 0
|
||||
|
||||
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:
|
||||
|
||||
@@ -1,19 +1,109 @@
|
||||
"""Watermark removal model profiles, strength presets, and profile detection.
|
||||
"""Watermark removal model profiles, the default strength, and profile detection.
|
||||
|
||||
Pure configuration and lookup functions with no ML dependencies.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import TYPE_CHECKING, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
|
||||
|
||||
LOW_STRENGTH = 0.04
|
||||
MEDIUM_STRENGTH = 0.35
|
||||
HIGH_STRENGTH = 0.7
|
||||
# Vendor-adaptive default denoising strength for the SDXL img2img scrub, overridable
|
||||
# from the CLI (`--strength`). The right strength depends on which vendor's SynthID is
|
||||
# present, detected from the C2PA issuer (metadata.synthid_source). Oracle-verified
|
||||
# controlled study (2026-06-01, clean v0.8.6 with protect_text/faces OFF, per-image
|
||||
# openai.com/verify or Gemini-app verdict; see docs/synthid.md section 2.2):
|
||||
# - OpenAI gpt-image: removed at 0.05 across 1024-1600 (n=4), resolution-independent.
|
||||
# OPENAI_STRENGTH 0.10 = the 0.05 floor plus a 2x margin (keeps quality high).
|
||||
# - Google Gemini: removed at 0.15 on the capped-1536 path (n=4); 0.05/0.10 do NOT
|
||||
# clear. GEMINI_STRENGTH 0.15. CAVEAT: 0.15 was validated only on
|
||||
# `--max-resolution 1536`; native 2816 (the default path) was not locally
|
||||
# measurable (OOM on Apple Silicon) and may need more -- pending GPU validation on
|
||||
# the raiw.cc backend. If a native large Gemini still verifies positive at 0.15,
|
||||
# raise `--strength`.
|
||||
# - Unknown vendor (metadata stripped, or non-OpenAI/Google C2PA): UNKNOWN_STRENGTH
|
||||
# 0.15, the safe middle that clears both vendors at the tested resolutions.
|
||||
# The dominant factor is VENDOR, not resolution: Google's SynthID is ~3x more robust
|
||||
# than OpenAI's. The earlier single 0.30 default (and the "resolution dependence" lore)
|
||||
# came from contaminated tests run with protect_text ON -- see docs/synthid.md 2.2.
|
||||
OPENAI_STRENGTH = 0.10
|
||||
GEMINI_STRENGTH = 0.15
|
||||
UNKNOWN_STRENGTH = 0.15
|
||||
# Backwards-compatible alias: the vendor-unknown default (what a caller gets without a
|
||||
# detected vendor). Kept as DEFAULT_STRENGTH for existing references.
|
||||
DEFAULT_STRENGTH = UNKNOWN_STRENGTH
|
||||
|
||||
_HIGH_PERTURBATION = ("stegasamp", "stegastamp", "treering", "ringid")
|
||||
_LOW_PERTURBATION = ("stablesignature", "dwtectsvd", "rivagan", "ssl", "hidden")
|
||||
# Detected-vendor -> default strength. Vendor strings come from `vendor_for_strength`.
|
||||
_VENDOR_STRENGTH = {"openai": OPENAI_STRENGTH, "google": GEMINI_STRENGTH}
|
||||
|
||||
# CtrlRegen removes watermarks by regenerating from (near) clean Gaussian noise,
|
||||
# NOT by the light-touch partial-noise img2img the SDXL default uses. The research
|
||||
# is explicit (CtrlRegen, ICLR 2025, arXiv:2410.05470): partial-noise regeneration
|
||||
# "struggles with high-perturbation watermarks" because a small noise step "retains"
|
||||
# watermark information that diffuses back into the output; the fix is to start from
|
||||
# clean noise. With the StableDiffusionControlNetImg2ImgPipeline that maps to a high
|
||||
# strength (~1.0 = full noise at the first timestep, structure held by the canny
|
||||
# ControlNet + DINOv2 IP-Adapter, not by the watermarked latent). So the ctrlregen
|
||||
# profile must NOT inherit the SDXL default (`DEFAULT_STRENGTH`, a partial-noise
|
||||
# value) -- at that low strength it loads ControlNet + DINOv2-giant and then barely
|
||||
# changes the image (a no-op for removal). Tunable via
|
||||
# `--strength`; lower it to trade removal strength for fidelity (the CtrlRegen+ regime).
|
||||
#
|
||||
# EXPERIMENTAL -- NOT recommended for production. The same GPU study that set the 0.3
|
||||
# SDXL threshold tested ctrlregen at its clean-noise strength and found it DESTROYS
|
||||
# images: smooth/background regions fill with hallucinated micro-text garbage, and it
|
||||
# is heavy (~8.5 min / ~$0.30 vs ~25 s / ~$0.02 for SDXL on a large image). The pipeline
|
||||
# is effectively binary -- low strength = no-op, high strength = destroys -- with no
|
||||
# usable middle, so the literature's "clean-noise is the lever" (arXiv:2410.05470) did
|
||||
# NOT survive empirical testing on real content. SDXL img2img at ~0.3 is the shippable
|
||||
# path; ctrlregen stays opt-in and flagged experimental.
|
||||
CTRLREGEN_DEFAULT_STRENGTH = 1.0
|
||||
|
||||
|
||||
def resolve_strength(strength: float | None, profile: str, vendor: str | None = None) -> float:
|
||||
"""Resolve the denoising strength, applying the profile/vendor default when unset.
|
||||
|
||||
``None`` means "the user did not pass ``--strength``". ``ctrlregen`` resolves to
|
||||
``CTRLREGEN_DEFAULT_STRENGTH`` (clean-noise regeneration). The SDXL default profile
|
||||
resolves **vendor-adaptively**: ``vendor`` (``"openai"`` / ``"google"`` / None, from
|
||||
``vendor_for_strength``) selects ``OPENAI_STRENGTH`` / ``GEMINI_STRENGTH`` /
|
||||
``UNKNOWN_STRENGTH``. An explicit value always wins (including ``0.0`` -- the check is
|
||||
``is None``, not falsiness). Shared by the CLI (for display) and the engine (for
|
||||
execution) so the two never disagree -- both must pass the SAME ``vendor``.
|
||||
"""
|
||||
if strength is not None:
|
||||
return strength
|
||||
if profile == "ctrlregen":
|
||||
return CTRLREGEN_DEFAULT_STRENGTH
|
||||
return _VENDOR_STRENGTH.get(vendor or "", UNKNOWN_STRENGTH)
|
||||
|
||||
|
||||
def vendor_for_strength(image_path: Path) -> Literal["openai", "google"] | None:
|
||||
"""Detect the SynthID vendor for strength selection: ``"openai"`` / ``"google"`` / None.
|
||||
|
||||
Reads the C2PA SynthID proxy (``metadata.synthid_source``) on the ORIGINAL input,
|
||||
so it must run before any pass that strips metadata. When both issuers appear (a
|
||||
rare multi-sign anomaly) Google wins -- the more-robust watermark -> safer (higher)
|
||||
strength. Returns None when metadata is stripped or the issuer is neither vendor,
|
||||
which maps to ``UNKNOWN_STRENGTH``. Lazy-imports ``metadata`` to keep this module
|
||||
dependency-light.
|
||||
"""
|
||||
try:
|
||||
from remove_ai_watermarks.metadata import synthid_source
|
||||
|
||||
src = (synthid_source(image_path) or "").lower()
|
||||
except Exception: # metadata unreadable -> treat as unknown vendor
|
||||
return None
|
||||
if "google" in src:
|
||||
return "google"
|
||||
if "openai" in src:
|
||||
return "openai"
|
||||
return None
|
||||
|
||||
|
||||
def get_model_id_for_profile(profile: str) -> str:
|
||||
@@ -31,21 +121,3 @@ def detect_model_profile(model_id: str) -> str:
|
||||
if "ctrlregen" in model_id.lower():
|
||||
return "ctrlregen"
|
||||
return "default"
|
||||
|
||||
|
||||
def get_recommended_strength(watermark_type: str) -> float:
|
||||
"""Get recommended strength for different watermark types.
|
||||
|
||||
Args:
|
||||
watermark_type: Type of watermark. One of: 'low', 'medium', 'high',
|
||||
or specific names like 'stegastamp', 'treering', etc.
|
||||
|
||||
Returns:
|
||||
Recommended strength value.
|
||||
"""
|
||||
wt = watermark_type.lower()
|
||||
if any(name in wt for name in _HIGH_PERTURBATION):
|
||||
return HIGH_STRENGTH
|
||||
if any(name in wt for name in _LOW_PERTURBATION):
|
||||
return LOW_STRENGTH
|
||||
return MEDIUM_STRENGTH
|
||||
@@ -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
|
||||
@@ -28,10 +31,9 @@ from PIL import Image
|
||||
from remove_ai_watermarks.noai.watermark_profiles import (
|
||||
CTRLREGEN_MODEL_ID,
|
||||
DEFAULT_MODEL_ID,
|
||||
HIGH_STRENGTH,
|
||||
LOW_STRENGTH,
|
||||
MEDIUM_STRENGTH,
|
||||
DEFAULT_STRENGTH,
|
||||
detect_model_profile,
|
||||
resolve_strength,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,6 +62,24 @@ def is_watermark_removal_available() -> bool:
|
||||
return _HAS_TORCH and _HAS_DIFFUSERS
|
||||
|
||||
|
||||
# Drop-in fp16-safe replacement for the SDXL VAE. The stock SDXL VAE overflows
|
||||
# to NaN in fp16 and decodes to an all-black image (issue #29: the raiw.cc black
|
||||
# result on a CUDA fp16 backend). This community VAE is numerically rescaled to
|
||||
# stay in fp16 range. SDXL-architecture only.
|
||||
_SDXL_FP16_VAE_ID = "madebyollin/sdxl-vae-fp16-fix"
|
||||
|
||||
|
||||
def _needs_fp16_vae_fix(model_id: str, default_model_id: str, is_fp16: bool) -> bool:
|
||||
"""Whether the plain img2img pipeline must swap in the fp16-fixed SDXL VAE.
|
||||
|
||||
Gated to the default SDXL checkpoint running in fp16: cpu/mps run fp32 (the
|
||||
stock VAE is fine there) and the differential pipeline upcasts the VAE on its
|
||||
own, so only this path on a fp16 GPU (CUDA/XPU) hits the NaN/black decode.
|
||||
A custom non-SDXL ``model_id`` keeps its own VAE (the fix is SDXL-specific).
|
||||
"""
|
||||
return is_fp16 and model_id == default_model_id
|
||||
|
||||
|
||||
_CUDA_FIX_ENV_KEY = "NOAI_CUDA_FIXED"
|
||||
|
||||
|
||||
@@ -219,6 +239,19 @@ def get_device() -> str:
|
||||
return "cuda"
|
||||
except (AssertionError, RuntimeError):
|
||||
pass
|
||||
# Intel GPU (Arc / Data Center) via the torch XPU backend. The torch.xpu
|
||||
# namespace exists in stock wheels, but is_available() is only True on an
|
||||
# XPU-enabled build (download.pytorch.org/whl/xpu), so this is inert on the
|
||||
# default CPU/CUDA install. Checked before the nvidia-smi path so an Intel
|
||||
# box never triggers the CUDA reinstaller.
|
||||
if hasattr(torch, "xpu") and torch.xpu.is_available(): # type: ignore
|
||||
try:
|
||||
t = torch.tensor([1.0], device="xpu")
|
||||
_ = t + t
|
||||
del t
|
||||
return "xpu"
|
||||
except (AssertionError, RuntimeError):
|
||||
pass
|
||||
if _has_nvidia_gpu() and not os.environ.get(_CUDA_FIX_ENV_KEY):
|
||||
_reinstall_torch_cuda_and_restart()
|
||||
if hasattr(torch.backends, "mps") and torch.backends.mps.is_available():
|
||||
@@ -226,23 +259,48 @@ def get_device() -> str:
|
||||
return "cpu"
|
||||
|
||||
|
||||
def _make_seed_generator(device: str, seed: int) -> Any:
|
||||
"""Build a seeded ``torch.Generator``, falling back to a CPU generator.
|
||||
|
||||
Some backends have no device-side RNG (notably certain torch-xpu builds),
|
||||
so ``torch.Generator(device="xpu")`` can raise. A CPU generator is
|
||||
backend-agnostic and still seeds the pipeline reproducibly, so fall back to
|
||||
it rather than failing the run when ``--seed`` is used on such a device.
|
||||
"""
|
||||
try:
|
||||
return torch.Generator(device=device).manual_seed(seed) # type: ignore
|
||||
except (RuntimeError, TypeError):
|
||||
return torch.Generator().manual_seed(seed) # type: ignore
|
||||
|
||||
|
||||
def _generator_device(generator: Any) -> str:
|
||||
"""Best-effort device type of a ``torch.Generator`` (e.g. ``"cpu"``, ``"mps"``)."""
|
||||
device = getattr(generator, "device", None)
|
||||
return getattr(device, "type", str(device)) if device is not None else "cpu"
|
||||
|
||||
|
||||
# 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.
|
||||
|
||||
Attributes:
|
||||
model_id: HuggingFace model ID for the diffusion model.
|
||||
device: Device to run inference on (cuda, mps, or cpu).
|
||||
device: Device to run inference on (cuda, xpu, mps, or cpu).
|
||||
"""
|
||||
|
||||
DEFAULT_MODEL_ID = DEFAULT_MODEL_ID
|
||||
CTRLREGEN_MODEL_ID = CTRLREGEN_MODEL_ID
|
||||
LOW_STRENGTH = LOW_STRENGTH
|
||||
MEDIUM_STRENGTH = MEDIUM_STRENGTH
|
||||
HIGH_STRENGTH = HIGH_STRENGTH
|
||||
DEFAULT_STRENGTH = DEFAULT_STRENGTH
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -260,8 +318,8 @@ class WatermarkRemover:
|
||||
self.device = (device or get_device()).lower()
|
||||
if self.device == "auto":
|
||||
self.device = get_device()
|
||||
if self.device not in {"cpu", "mps", "cuda"}:
|
||||
raise ValueError(f"Unsupported device '{device}'. Use one of: auto, cpu, mps, cuda.")
|
||||
if self.device not in {"cpu", "mps", "cuda", "xpu"}:
|
||||
raise ValueError(f"Unsupported device '{device}'. Use one of: auto, cpu, mps, cuda, xpu.")
|
||||
if torch_dtype is None:
|
||||
if self.device == "cpu" or self.device == "mps":
|
||||
self.torch_dtype = torch.float32 # type: ignore
|
||||
@@ -271,6 +329,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")
|
||||
@@ -336,6 +395,14 @@ class WatermarkRemover:
|
||||
if self.hf_token:
|
||||
load_kwargs["token"] = self.hf_token
|
||||
|
||||
# Avoid the SDXL fp16 NaN/all-black decode (issue #29) by loading the
|
||||
# fp16-fixed VAE for the default SDXL checkpoint on a fp16 GPU.
|
||||
if _needs_fp16_vae_fix(self.model_id, self.DEFAULT_MODEL_ID, self.torch_dtype == torch.float16):
|
||||
from diffusers import AutoencoderKL
|
||||
|
||||
self._set_progress("Loading fp16-fixed SDXL VAE (avoids black output)...")
|
||||
load_kwargs["vae"] = AutoencoderKL.from_pretrained(_SDXL_FP16_VAE_ID, torch_dtype=torch.float16)
|
||||
|
||||
self._pipeline = AutoImg2ImgPipeline.from_pretrained( # type: ignore
|
||||
self.model_id,
|
||||
**load_kwargs,
|
||||
@@ -379,16 +446,28 @@ class WatermarkRemover:
|
||||
num_inference_steps: int = 50,
|
||||
guidance_scale: float | None = None,
|
||||
seed: int | None = None,
|
||||
protect_text: bool = True,
|
||||
vendor: str | None = None,
|
||||
) -> Path:
|
||||
"""Remove watermark from an image using regeneration attack.
|
||||
|
||||
Args:
|
||||
image_path: Path to the watermarked image.
|
||||
output_path: Path for the cleaned image. If None, modifies in place.
|
||||
strength: Denoising strength (0.0-1.0).
|
||||
strength: Denoising strength (0.0-1.0). None -> the vendor-adaptive
|
||||
default (see ``vendor``).
|
||||
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.
|
||||
vendor: SynthID vendor (``"openai"`` / ``"google"`` / None) used to pick the
|
||||
default strength when ``strength`` is None. Detect it from the ORIGINAL
|
||||
input with ``watermark_profiles.vendor_for_strength`` before processing
|
||||
strips the metadata; the caller passes it down so display and execution
|
||||
agree.
|
||||
|
||||
Returns:
|
||||
Path to the cleaned image.
|
||||
@@ -403,7 +482,7 @@ class WatermarkRemover:
|
||||
if output_path is None:
|
||||
output_path = image_path
|
||||
|
||||
strength = strength or self.LOW_STRENGTH
|
||||
strength = resolve_strength(strength, self.model_profile, vendor)
|
||||
|
||||
if not 0.0 <= strength <= 1.0:
|
||||
raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}")
|
||||
@@ -419,7 +498,7 @@ class WatermarkRemover:
|
||||
generator = None
|
||||
if seed is not None and _HAS_TORCH:
|
||||
self._set_progress(f"Setting reproducible seed: {seed}")
|
||||
generator = torch.Generator(device=self.device).manual_seed(seed) # type: ignore
|
||||
generator = _make_seed_generator(self.device, seed)
|
||||
|
||||
effective_steps = max(1, int(num_inference_steps * strength))
|
||||
self._set_progress(
|
||||
@@ -437,7 +516,21 @@ class WatermarkRemover:
|
||||
guidance_scale,
|
||||
generator,
|
||||
)
|
||||
elif protect_text and self._can_protect_text():
|
||||
cleaned_image = self._run_region_hires(
|
||||
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 +613,196 @@ 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", "xpu"}
|
||||
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()
|
||||
|
||||
# Region high-res text scrub: defaults tuned so each text block is upscaled
|
||||
# enough that strokes exceed the VAE's ~8px latent cell, capped so a single
|
||||
# region never blows past the GPU/MPS memory budget.
|
||||
_REGION_HIRES_SCALE = 3.0
|
||||
_REGION_MAX_MEGAPIXELS = 1.3
|
||||
|
||||
def _run_region_hires(
|
||||
self,
|
||||
init_image: Image.Image,
|
||||
strength: float,
|
||||
num_inference_steps: int,
|
||||
guidance_scale: float,
|
||||
generator: Any,
|
||||
) -> Image.Image:
|
||||
"""Scrub the whole image, then RE-scrub each detected text block at high
|
||||
resolution and composite it back.
|
||||
|
||||
Unlike the Differential-Diffusion path (which freezes text in latent space
|
||||
and so leaves the watermark intact there), every pixel here is regenerated
|
||||
-- the watermark is removed everywhere. Small text survives because each
|
||||
text block is upscaled before its img2img pass, so strokes span more than
|
||||
one VAE latent cell (the ~8px floor that softens text at native scale);
|
||||
the scrubbed crop is downscaled and feather-composited back. Falls back to
|
||||
the plain global scrub when no text is detected.
|
||||
"""
|
||||
import math
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import text_protector
|
||||
|
||||
base = self._run_img2img(init_image, strength, num_inference_steps, guidance_scale, generator)
|
||||
|
||||
# The base pass may have fallen back from MPS to CPU (it flips
|
||||
# self.device). The generator was built for the original device, and
|
||||
# diffusers rejects a device-mismatched generator ("Expected a 'cpu'
|
||||
# device generator but found 'mps'"), so drop it for the per-region
|
||||
# passes -- they then seed from the global RNG, which is fine here.
|
||||
if generator is not None and self.device == "cpu" and _generator_device(generator) != "cpu":
|
||||
generator = None
|
||||
|
||||
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); keeping the global scrub.", exc)
|
||||
return base
|
||||
if not boxes:
|
||||
self._set_progress("No text detected; global scrub only.")
|
||||
return base
|
||||
|
||||
width, height = init_image.size
|
||||
regions = text_protector.merge_text_regions(boxes, height, width)
|
||||
orig_bgr = cv2.cvtColor(np.array(init_image), cv2.COLOR_RGB2BGR)
|
||||
out_bgr = cv2.cvtColor(np.array(base), cv2.COLOR_RGB2BGR)
|
||||
budget = self._REGION_MAX_MEGAPIXELS * 1_000_000
|
||||
|
||||
done = 0
|
||||
for x, y, w, h in regions:
|
||||
area = max(1, w * h)
|
||||
# INTEGER scale so the upscale -> scrub -> downscale round-trip is an
|
||||
# exact dimensional inverse (a fractional factor truncates and shifts
|
||||
# the composited text ~1-2px, which is invisible but tanks alignment).
|
||||
scale = int(min(self._REGION_HIRES_SCALE, math.sqrt(budget / area)))
|
||||
if scale < 2:
|
||||
# Region too large to even double within the budget: upscaling
|
||||
# buys nothing here; the global scrub covers it (documented limit
|
||||
# for very large text areas -- tiling is the future fix).
|
||||
continue
|
||||
crop = orig_bgr[y : y + h, x : x + w]
|
||||
up = cv2.resize(crop, (w * scale, h * scale), interpolation=cv2.INTER_LANCZOS4)
|
||||
up_pil = Image.fromarray(cv2.cvtColor(up, cv2.COLOR_BGR2RGB))
|
||||
scrubbed = self._run_img2img(up_pil, strength, num_inference_steps, guidance_scale, generator)
|
||||
down = cv2.resize(cv2.cvtColor(np.array(scrubbed), cv2.COLOR_RGB2BGR), (w, h), interpolation=cv2.INTER_AREA)
|
||||
# The up -> scrub -> down round-trip can offset the re-rendered text by
|
||||
# a pixel or two (the diffusion pipeline rounds dims to a multiple of
|
||||
# 8, so the inverse resize is not perfectly centered). Phase-correlate
|
||||
# the patch back to the original crop and translate it so the glyphs
|
||||
# land exactly where they were -- otherwise a sub-pixel shift garbles
|
||||
# the composite even though the text is crisp.
|
||||
cg = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY).astype(np.float32)
|
||||
dg = cv2.cvtColor(down, cv2.COLOR_BGR2GRAY).astype(np.float32)
|
||||
(sx, sy), resp = cv2.phaseCorrelate(cg, dg)
|
||||
# Only correct for the real 1-2px round-trip shift. On a near-flat /
|
||||
# low-contrast crop phaseCorrelate returns a spurious large offset at
|
||||
# a tiny response (e.g. (19,19) at resp ~0.005); warping by that
|
||||
# garbles the composite -- the exact failure this was meant to
|
||||
# prevent. Gate on both a confident response and a plausible offset.
|
||||
if resp > 0.3 and abs(sx) < 4 and abs(sy) < 4 and (abs(sx) > 0.1 or abs(sy) > 0.1):
|
||||
m = np.float32([[1, 0, -sx], [0, 1, -sy]])
|
||||
down = cv2.warpAffine(down, m, (w, h), flags=cv2.INTER_LINEAR, borderMode=cv2.BORDER_REPLICATE)
|
||||
out_bgr = text_protector.feather_paste(out_bgr, down, x, y)
|
||||
done += 1
|
||||
self._set_progress(f"Re-scrubbed {done}/{len(regions)} text region(s) at high resolution.")
|
||||
return Image.fromarray(cv2.cvtColor(out_bgr, cv2.COLOR_BGR2RGB))
|
||||
|
||||
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(
|
||||
@@ -619,15 +902,23 @@ class WatermarkRemover:
|
||||
def remove_watermark(
|
||||
image_path: Path,
|
||||
output_path: Path | None = None,
|
||||
strength: float = 0.04,
|
||||
strength: float | None = None,
|
||||
model_id: str | None = None,
|
||||
device: str | None = None,
|
||||
hf_token: str | None = None,
|
||||
) -> Path:
|
||||
"""Convenience function to remove watermark from an image."""
|
||||
"""Convenience function to remove watermark from an image.
|
||||
|
||||
``strength=None`` lets the profile pick its default: vendor-adaptive for SDXL
|
||||
(0.10 OpenAI / 0.15 Google / 0.15 unknown, from the C2PA SynthID proxy on the
|
||||
input), clean-noise 1.0 for ctrlregen. Pass a value to override.
|
||||
"""
|
||||
from remove_ai_watermarks.noai.watermark_profiles import vendor_for_strength
|
||||
|
||||
remover = WatermarkRemover(model_id=model_id, device=device, hf_token=hf_token)
|
||||
return remover.remove_watermark(
|
||||
image_path=image_path,
|
||||
output_path=output_path,
|
||||
strength=strength,
|
||||
vendor=vendor_for_strength(image_path),
|
||||
)
|
||||
@@ -15,10 +15,14 @@ Backends:
|
||||
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, Literal
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
@@ -41,7 +45,7 @@ def boxes_to_mask(
|
||||
shape: tuple[int, int],
|
||||
boxes: list[tuple[int, int, int, int]],
|
||||
dilate: int = 3,
|
||||
) -> NDArray:
|
||||
) -> 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)
|
||||
@@ -57,25 +61,30 @@ def boxes_to_mask(
|
||||
|
||||
|
||||
def erase_cv2(
|
||||
image_bgr: NDArray,
|
||||
mask: NDArray,
|
||||
image_bgr: NDArray[Any],
|
||||
mask: NDArray[Any],
|
||||
*,
|
||||
method: Literal["telea", "ns"] = "telea",
|
||||
radius: int = 6,
|
||||
) -> NDArray:
|
||||
"""Inpaint ``mask`` with classical cv2 inpainting (CPU, no extra deps)."""
|
||||
) -> NDArray[Any]:
|
||||
"""Inpaint ``mask`` with classical cv2 inpainting (CPU, no extra deps).
|
||||
|
||||
Accepts 1-/3-channel BGR (passed straight to ``cv2.inpaint``) and 4-channel
|
||||
BGRA: ``cv2.inpaint`` rejects 4 channels, so the alpha plane is split off,
|
||||
the BGR is inpainted, and alpha is re-attached unchanged.
|
||||
"""
|
||||
flag = cv2.INPAINT_TELEA if method == "telea" else cv2.INPAINT_NS
|
||||
if image_bgr.ndim == 3 and image_bgr.shape[2] == 4:
|
||||
bgr = cv2.inpaint(image_bgr[:, :, :3], mask, radius, flag)
|
||||
return np.dstack([bgr, image_bgr[:, :, 3]])
|
||||
return cv2.inpaint(image_bgr, mask, radius, flag)
|
||||
|
||||
|
||||
def lama_available() -> bool:
|
||||
"""True when the optional LaMa-ONNX backend can run (onnxruntime installed)."""
|
||||
try:
|
||||
import onnxruntime # noqa: F401
|
||||
import importlib.util
|
||||
|
||||
return True
|
||||
except ImportError:
|
||||
return False
|
||||
return importlib.util.find_spec("onnxruntime") is not None
|
||||
|
||||
|
||||
def _get_lama_session() -> object:
|
||||
@@ -93,7 +102,7 @@ def _get_lama_session() -> object:
|
||||
return _lama_session
|
||||
|
||||
|
||||
def erase_lama(image_bgr: NDArray, mask: NDArray) -> NDArray:
|
||||
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
|
||||
@@ -147,15 +156,15 @@ def erase_lama(image_bgr: NDArray, mask: NDArray) -> NDArray:
|
||||
|
||||
|
||||
def erase(
|
||||
image_bgr: NDArray,
|
||||
image_bgr: NDArray[Any],
|
||||
*,
|
||||
boxes: list[tuple[int, int, int, int]] | None = None,
|
||||
mask: NDArray | None = None,
|
||||
mask: NDArray[Any] | None = None,
|
||||
backend: Backend = "cv2",
|
||||
dilate: int = 3,
|
||||
cv2_method: Literal["telea", "ns"] = "telea",
|
||||
cv2_radius: int = 6,
|
||||
) -> NDArray:
|
||||
) -> 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``
|
||||
|
||||
@@ -0,0 +1,271 @@
|
||||
"""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: the image is detected at its NATIVE long side, capped at
|
||||
# this value (rounded to a multiple of 32), never upscaled. A fixed small input
|
||||
# (the old 736) downscaled large images so far that small text fell below the
|
||||
# detector's resolution and was missed -- the cause of the "small text still
|
||||
# distorts" reports (issue #14). Detection is script-agnostic (DB segments text
|
||||
# *regions*, not characters), so this recall fix applies to every language; the
|
||||
# only lever that mattered was resolution. 1536 recovers full recall down to
|
||||
# ~12 px text on a 2048 canvas at ~100 ms on CPU (a fixed 736 missed it); going
|
||||
# higher buys no measured recall at 2x+ the cost. Benchmarked in
|
||||
# scripts/text_detection_benchmark.py. Very large canvases with tiny text may
|
||||
# still need tiling -- a documented limit, not yet built.
|
||||
_DET_MAX_LONG_SIDE = 1536
|
||||
# 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 _detection_input_size(height: int, width: int) -> tuple[int, int]:
|
||||
"""DB-detector input ``(in_w, in_h)`` for an image of the given size.
|
||||
|
||||
Detect at the native long side, capped at ``_DET_MAX_LONG_SIDE`` and never
|
||||
upscaled, each side rounded down to a multiple of 32 (the DB head requires
|
||||
/32 dims), floored at 32. Pure function so the resolution contract (the
|
||||
issue #14 small-text recall fix) is unit-testable without the model.
|
||||
"""
|
||||
long_side = max(height, width)
|
||||
scale = min(_DET_MAX_LONG_SIDE, long_side) / long_side
|
||||
in_w = max((round(width * scale) // 32) * 32, 32)
|
||||
in_h = max((round(height * scale) // 32) * 32, 32)
|
||||
return in_w, in_h
|
||||
|
||||
|
||||
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
|
||||
|
||||
|
||||
def merge_text_regions(
|
||||
boxes: list[NDArray[Any]],
|
||||
height: int,
|
||||
width: int,
|
||||
dilate_frac: float = 0.012,
|
||||
pad_frac: float = 0.02,
|
||||
max_regions: int = 8,
|
||||
) -> list[tuple[int, int, int, int]]:
|
||||
"""Group detected text polygons into a few padded axis-aligned rectangles.
|
||||
|
||||
The DB detector returns one box per word/line; the region-high-res text scrub
|
||||
runs a separate diffusion pass per region, so we coalesce nearby boxes into a
|
||||
handful of *local* blocks (a light dilation merges within a paragraph but not
|
||||
across the whole image, so each block stays small enough to upscale within a
|
||||
memory budget). Returns ``(x, y, w, h)`` rects, largest-area first, clipped to
|
||||
the image and capped at ``max_regions``.
|
||||
"""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
mask = np.zeros((height, width), np.uint8)
|
||||
if not boxes:
|
||||
return []
|
||||
cv2.fillPoly(mask, [np.asarray(b, np.int32) for b in boxes], 1)
|
||||
k = max(1, int(min(height, width) * dilate_frac))
|
||||
mask = cv2.dilate(mask, cv2.getStructuringElement(cv2.MORPH_RECT, (k, k)))
|
||||
n, _labels, stats, _c = cv2.connectedComponentsWithStats(mask, 8)
|
||||
pad = int(min(height, width) * pad_frac)
|
||||
rects: list[tuple[int, int, int, int]] = []
|
||||
for i in range(1, n):
|
||||
x, y, w, h = (
|
||||
int(stats[i, cv2.CC_STAT_LEFT]),
|
||||
int(stats[i, cv2.CC_STAT_TOP]),
|
||||
int(stats[i, cv2.CC_STAT_WIDTH]),
|
||||
int(stats[i, cv2.CC_STAT_HEIGHT]),
|
||||
)
|
||||
x0, y0 = max(0, x - pad), max(0, y - pad)
|
||||
x1, y1 = min(width, x + w + pad), min(height, y + h + pad)
|
||||
rects.append((x0, y0, x1 - x0, y1 - y0))
|
||||
rects.sort(key=lambda r: -(r[2] * r[3]))
|
||||
return rects[:max_regions]
|
||||
|
||||
|
||||
def feather_paste(
|
||||
base: NDArray[Any],
|
||||
patch: NDArray[Any],
|
||||
x: int,
|
||||
y: int,
|
||||
feather: int = 8,
|
||||
) -> NDArray[Any]:
|
||||
"""Alpha-composite ``patch`` into ``base`` at ``(x, y)`` with a feathered edge.
|
||||
|
||||
Used to drop a separately re-scrubbed (high-resolution) text region back into
|
||||
the globally-scrubbed image without a visible seam. Returns a new array;
|
||||
``base`` is not modified. ``patch`` is clipped to ``base`` bounds.
|
||||
"""
|
||||
import numpy as np
|
||||
|
||||
out = base.copy()
|
||||
bh, bw = base.shape[:2]
|
||||
ph, pw = patch.shape[:2]
|
||||
x0, y0 = max(0, x), max(0, y)
|
||||
x1, y1 = min(bw, x + pw), min(bh, y + ph)
|
||||
if x1 <= x0 or y1 <= y0:
|
||||
return out
|
||||
patch_roi = patch[y0 - y : y1 - y, x0 - x : x1 - x].astype(np.float32)
|
||||
base_roi = out[y0:y1, x0:x1].astype(np.float32)
|
||||
rh, rw = base_roi.shape[:2]
|
||||
alpha = np.ones((rh, rw), np.float32)
|
||||
f = max(0, min(feather, rh // 2, rw // 2))
|
||||
if f > 0:
|
||||
ramp = np.linspace(0.0, 1.0, f, dtype=np.float32)
|
||||
alpha[:f, :] *= ramp[:, None]
|
||||
alpha[rh - f :, :] *= ramp[::-1, None]
|
||||
alpha[:, :f] *= ramp[None, :]
|
||||
alpha[:, rw - f :] *= ramp[None, ::-1]
|
||||
a3 = alpha[:, :, None]
|
||||
out[y0:y1, x0:x1] = (patch_roi * a3 + base_roi * (1.0 - a3)).astype(base.dtype)
|
||||
return out
|
||||
|
||||
|
||||
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]
|
||||
in_w, in_h = _detection_input_size(height, width)
|
||||
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)
|
||||
@@ -22,6 +22,7 @@ signal, not proof of AI origin.
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import threading
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -32,7 +33,9 @@ log = logging.getLogger(__name__)
|
||||
# Adobe ships Variant P in production (com.adobe.trustmark.P).
|
||||
_MODEL_TYPE = "P"
|
||||
# Lazily constructed singleton -- model load + first-use download is expensive.
|
||||
# Guarded by a lock so concurrent callers don't double-construct/double-download.
|
||||
_tm: Any = None
|
||||
_tm_lock = threading.Lock()
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
@@ -45,18 +48,39 @@ def is_available() -> bool:
|
||||
def _decoder() -> Any:
|
||||
global _tm
|
||||
if _tm is None:
|
||||
from trustmark import TrustMark
|
||||
with _tm_lock:
|
||||
if _tm is None:
|
||||
from trustmark import TrustMark
|
||||
|
||||
_tm = TrustMark(verbose=False, model_type=_MODEL_TYPE)
|
||||
_tm = TrustMark(verbose=False, model_type=_MODEL_TYPE)
|
||||
return _tm
|
||||
|
||||
|
||||
# JPEG quality for the false-positive durability gate (see detect_trustmark).
|
||||
# Deliberately mild: a genuine TrustMark survives far harsher, while every
|
||||
# observed false positive collapsed even at this quality.
|
||||
_REENCODE_QUALITY = 95
|
||||
|
||||
|
||||
def detect_trustmark(image_path: Path) -> str | None:
|
||||
"""Return a TrustMark scheme note if a TrustMark watermark is decoded, else None.
|
||||
"""Return a TrustMark scheme note if a *durable* TrustMark watermark is
|
||||
decoded, else None.
|
||||
|
||||
Returns e.g. ``"Adobe TrustMark (variant P, schema 0)"`` when the decoder
|
||||
reports the watermark present, or None if it is absent, the optional
|
||||
``trustmark`` package is not installed, or the image cannot be read/decoded.
|
||||
reports the watermark present AND it survives a mild JPEG re-encode, or None
|
||||
if it is absent, the optional ``trustmark`` package is not installed, or the
|
||||
image cannot be read/decoded.
|
||||
|
||||
**False-positive gate.** TrustMark's ``wm_present`` flag is a BCH
|
||||
error-correction validity check, which spuriously validates on a small
|
||||
fraction of un-watermarked images -- content-correlated, so AI-generated
|
||||
textures trip it more often than camera photos (verified 2026-05-29 on real
|
||||
files: the false "detections" were on Gemini / OpenAI / Doubao output that
|
||||
cannot carry Adobe's watermark, and decoded a random-bytes secret). A genuine
|
||||
TrustMark is a *durable* soft binding engineered to survive re-encoding (that
|
||||
is its entire purpose once C2PA is stripped), so we re-decode after a mild
|
||||
JPEG round-trip and require the same schema both times. Every observed false
|
||||
positive collapsed under this gate.
|
||||
"""
|
||||
if not is_available():
|
||||
return None
|
||||
@@ -65,8 +89,30 @@ def detect_trustmark(image_path: Path) -> str | None:
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
cover = img.convert("RGB")
|
||||
_wm_secret, wm_present, wm_schema = _decoder().decode(cover)
|
||||
decoder = _decoder()
|
||||
_wm_secret, wm_present, wm_schema = decoder.decode(cover)
|
||||
if not wm_present:
|
||||
return None
|
||||
if not _survives_reencode(decoder, cover, wm_schema):
|
||||
log.debug("TrustMark decode for %s did not survive re-encode; treating as false positive", image_path)
|
||||
return None
|
||||
except Exception as exc: # model download / decode failure / unreadable image
|
||||
log.debug("TrustMark decode failed for %s: %s", image_path, exc)
|
||||
return None
|
||||
return f"Adobe TrustMark (variant {_MODEL_TYPE}, schema {wm_schema})" if wm_present else None
|
||||
return f"Adobe TrustMark (variant {_MODEL_TYPE}, schema {wm_schema})"
|
||||
|
||||
|
||||
def _survives_reencode(decoder: Any, cover: Any, schema: int) -> bool:
|
||||
"""True if the watermark re-decodes with the same schema after a mild JPEG
|
||||
round-trip -- the durability a genuine TrustMark guarantees, which a BCH
|
||||
false positive (content noise) does not."""
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
buffer = io.BytesIO()
|
||||
cover.save(buffer, "JPEG", quality=_REENCODE_QUALITY)
|
||||
buffer.seek(0)
|
||||
with Image.open(buffer) as reencoded:
|
||||
_secret, present, reencoded_schema = decoder.decode(reencoded.convert("RGB"))
|
||||
return bool(present) and reencoded_schema == schema
|
||||
@@ -0,0 +1,235 @@
|
||||
"""Registry of known visible watermarks.
|
||||
|
||||
A single catalog that ties each known visible mark to (a) where it usually sits,
|
||||
(b) how to recognize it there, and (c) how to remove it. One pass over the
|
||||
registry detects every known mark in its usual place and removes the ones
|
||||
present.
|
||||
|
||||
**Reverse-alpha based.** A known mark is a fixed semi-transparent overlay, so it
|
||||
is removed by inverting the alpha blend against a captured alpha map
|
||||
(``original = (wm - a*logo)/(1-a)``) -- recovering the true pixels rather than
|
||||
inpainting a guess. Gemini and Doubao recover exactly with no inpaint at native on
|
||||
bright/flat backgrounds (Gemini falls back to inpainting the sparkle footprint when
|
||||
reverse-alpha would over-subtract on a dark background -- issue #30, see gemini_engine);
|
||||
Jimeng adds a thin residual inpaint over the glyph footprint to clear the outline
|
||||
its per-image render variation leaves behind (still seeded by the reverse-alpha
|
||||
recovery, not a blind inpaint). Detection is consistent with that: each mark is
|
||||
recognized by matching its known shape/template (the thing we invert), not by
|
||||
heuristics. A mark is therefore listed here only once a real alpha map has been
|
||||
captured for it; everything else (arbitrary logos/objects) is the user-directed
|
||||
``erase --region`` tool, not this catalog.
|
||||
|
||||
Entries:
|
||||
- ``gemini`` -- Google Gemini / Nano Banana sparkle, bottom-right.
|
||||
- ``doubao`` -- ByteDance Doubao "豆包AI生成" text strip, bottom-right.
|
||||
- ``jimeng`` -- ByteDance Jimeng / Dreamina "★ 即梦AI" wordmark, bottom-right.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import TYPE_CHECKING, Any, Literal
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Callable
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
# cv2 method for the Gemini reverse-alpha edge-residual cleanup (not a standalone
|
||||
# remover): "ns" / "telea".
|
||||
InpaintMethod = Literal["telea", "ns"]
|
||||
Region = tuple[int, int, int, int]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class MarkDetection:
|
||||
"""Uniform detection result for a known mark (across heterogeneous engines)."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
location: str
|
||||
detected: bool
|
||||
confidence: float
|
||||
region: Region
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class KnownMark:
|
||||
"""A known visible watermark: where it lives, how to find and remove it."""
|
||||
|
||||
key: str
|
||||
label: str
|
||||
location: str # usual place, human-readable ("bottom-right")
|
||||
in_auto: bool # participate in `--mark auto` scanning
|
||||
recovery: str # removal strategy (all reverse-alpha today)
|
||||
_detect: Callable[[NDArray[Any]], MarkDetection]
|
||||
_remove: Callable[..., tuple[NDArray[Any], Region | None]]
|
||||
|
||||
def detect(self, image: NDArray[Any]) -> MarkDetection:
|
||||
return self._detect(image)
|
||||
|
||||
def remove(
|
||||
self,
|
||||
image: NDArray[Any],
|
||||
*,
|
||||
inpaint_method: InpaintMethod = "ns",
|
||||
inpaint: bool = True,
|
||||
inpaint_strength: float = 0.85,
|
||||
force: bool = False,
|
||||
) -> tuple[NDArray[Any], Region | None]:
|
||||
"""Remove this mark by reverse-alpha; returns ``(result, region)`` where
|
||||
``region`` is the removed mark's bbox (for residual-inpaint positioning),
|
||||
or None if nothing was removed. NB: the CLI does NOT use ``region`` to
|
||||
clear alpha on save -- that zeroing caused the issue-#30 white box.
|
||||
|
||||
``inpaint`` / ``inpaint_strength`` / ``inpaint_method`` tune the Gemini
|
||||
reverse-alpha edge-residual cleanup only. ``force`` removes at the mark's
|
||||
usual location even without a positive detection (the ``--no-detect`` path).
|
||||
"""
|
||||
return self._remove(image, inpaint_method, inpaint, inpaint_strength, force)
|
||||
|
||||
|
||||
# Gemini-sparkle confidence above which the registry treats it as a confident
|
||||
# detection for arbitration. Matches identify's corpus-validated sparkle
|
||||
# threshold (0.5): the gemini engine's own detect flag uses a looser internal
|
||||
# threshold and weakly fires (~0.36) on unrelated bottom-right text (e.g. the
|
||||
# Doubao mark), which would otherwise let it hijack `--mark auto`. 0.5 gives 0
|
||||
# false positives on the corpus.
|
||||
_GEMINI_AUTO_MIN_CONF = 0.5
|
||||
|
||||
# ── Engine adapters (lazy singletons; engines are cv2-only, no model load) ──
|
||||
|
||||
_engines: dict[str, Any] = {}
|
||||
|
||||
|
||||
def _engine(key: str) -> Any:
|
||||
if key not in _engines:
|
||||
if key == "gemini":
|
||||
from remove_ai_watermarks.gemini_engine import GeminiEngine
|
||||
|
||||
_engines[key] = GeminiEngine()
|
||||
elif key == "doubao":
|
||||
from remove_ai_watermarks.doubao_engine import DoubaoEngine
|
||||
|
||||
_engines[key] = DoubaoEngine()
|
||||
elif key == "jimeng":
|
||||
from remove_ai_watermarks.jimeng_engine import JimengEngine
|
||||
|
||||
_engines[key] = JimengEngine()
|
||||
else: # pragma: no cover - guarded by the registry keys
|
||||
raise KeyError(key)
|
||||
return _engines[key]
|
||||
|
||||
|
||||
def _gemini_detect(image: NDArray[Any]) -> MarkDetection:
|
||||
d = _engine("gemini").detect_watermark(image)
|
||||
detected = bool(d.detected) and d.confidence >= _GEMINI_AUTO_MIN_CONF
|
||||
return MarkDetection("gemini", "Google Gemini sparkle", "bottom-right", detected, d.confidence, d.region)
|
||||
|
||||
|
||||
def _gemini_remove(
|
||||
image: NDArray[Any], inpaint_method: InpaintMethod, inpaint: bool, strength: float, force: bool
|
||||
) -> tuple[NDArray[Any], Region | None]:
|
||||
engine = _engine("gemini")
|
||||
det = engine.detect_watermark(image)
|
||||
if not det.detected:
|
||||
if not force:
|
||||
return image.copy(), None
|
||||
# Forced (--no-detect): remove at the default sparkle slot for the size.
|
||||
from remove_ai_watermarks.gemini_engine import get_watermark_config
|
||||
|
||||
h, w = image.shape[:2]
|
||||
cfg = get_watermark_config(w, h)
|
||||
px, py = cfg.get_position(w, h)
|
||||
region = (px, py, cfg.logo_size, cfg.logo_size)
|
||||
result = engine.remove_watermark_custom(image, region)
|
||||
if inpaint:
|
||||
result = engine.inpaint_residual(result, region, strength=strength, method=inpaint_method)
|
||||
return result, region
|
||||
result = engine.remove_watermark(image)
|
||||
# Reverse-alpha leaves a faint residual at the sparkle edge; the engine's
|
||||
# own residual inpaint cleans that seam (part of its reverse-alpha pipeline).
|
||||
if inpaint:
|
||||
result = engine.inpaint_residual(result, det.region, strength=strength, method=inpaint_method)
|
||||
return result, det.region
|
||||
|
||||
|
||||
def _doubao_detect(image: NDArray[Any]) -> MarkDetection:
|
||||
d = _engine("doubao").detect(image)
|
||||
return MarkDetection("doubao", "Doubao 豆包AI生成 text", "bottom-right", d.detected, d.confidence, d.region)
|
||||
|
||||
|
||||
def _doubao_remove(
|
||||
image: NDArray[Any], _inpaint_method: InpaintMethod, _inpaint: bool, _strength: float, force: bool
|
||||
) -> tuple[NDArray[Any], Region | None]:
|
||||
# Reverse-alpha only: apply when the mark is present AND the resolution is in
|
||||
# the alpha map's calibrated band. Outside it we do NOT inpaint (no
|
||||
# hallucination) -- removal is skipped until a capture for that resolution.
|
||||
engine = _engine("doubao")
|
||||
det = engine.detect(image)
|
||||
if (det.detected or force) and engine.reverse_alpha_available(image):
|
||||
return engine.remove_watermark_reverse_alpha(image), (det.region if det.detected else None)
|
||||
return image.copy(), None
|
||||
|
||||
|
||||
def _jimeng_detect(image: NDArray[Any]) -> MarkDetection:
|
||||
d = _engine("jimeng").detect(image)
|
||||
return MarkDetection("jimeng", "Jimeng 即梦AI wordmark", "bottom-right", d.detected, d.confidence, d.region)
|
||||
|
||||
|
||||
def _jimeng_remove(
|
||||
image: NDArray[Any], _inpaint_method: InpaintMethod, _inpaint: bool, _strength: float, force: bool
|
||||
) -> tuple[NDArray[Any], Region | None]:
|
||||
# Reverse-alpha (with an always-on residual inpaint over the glyph footprint,
|
||||
# see the engine): apply when the mark is present and the alpha asset loads.
|
||||
# Skipped otherwise (no hallucination on a clean corner).
|
||||
engine = _engine("jimeng")
|
||||
det = engine.detect(image)
|
||||
if (det.detected or force) and engine.reverse_alpha_available(image):
|
||||
return engine.remove_watermark_reverse_alpha(image), (det.region if det.detected else None)
|
||||
return image.copy(), None
|
||||
|
||||
|
||||
_REGISTRY: tuple[KnownMark, ...] = (
|
||||
KnownMark("gemini", "Google Gemini sparkle", "bottom-right", True, "reverse-alpha", _gemini_detect, _gemini_remove),
|
||||
KnownMark(
|
||||
"doubao", "Doubao 豆包AI生成 text", "bottom-right", True, "reverse-alpha", _doubao_detect, _doubao_remove
|
||||
),
|
||||
KnownMark(
|
||||
"jimeng", "Jimeng 即梦AI wordmark", "bottom-right", True, "reverse-alpha", _jimeng_detect, _jimeng_remove
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
def known_marks() -> tuple[KnownMark, ...]:
|
||||
"""All registered known visible watermarks."""
|
||||
return _REGISTRY
|
||||
|
||||
|
||||
def mark_keys() -> list[str]:
|
||||
"""Keys of all registered marks (for CLI choices)."""
|
||||
return [m.key for m in _REGISTRY]
|
||||
|
||||
|
||||
def get_mark(key: str) -> KnownMark:
|
||||
"""Look up a known mark by key (raises KeyError if unknown)."""
|
||||
for m in _REGISTRY:
|
||||
if m.key == key:
|
||||
return m
|
||||
raise KeyError(key)
|
||||
|
||||
|
||||
def detect_marks(image: NDArray[Any], *, include_explicit: bool = True) -> list[MarkDetection]:
|
||||
"""Detect every known mark in its usual place.
|
||||
|
||||
Returns one MarkDetection per scanned mark (``detected`` flags which fired).
|
||||
``include_explicit=False`` scans only the ``in_auto`` marks -- the set used
|
||||
by ``--mark auto``.
|
||||
"""
|
||||
return [m.detect(image) for m in _REGISTRY if include_explicit or m.in_auto]
|
||||
|
||||
|
||||
def best_auto_mark(image: NDArray[Any]) -> MarkDetection | None:
|
||||
"""The highest-confidence detected ``in_auto`` mark, or None if none fired."""
|
||||
fired = [d for d in detect_marks(image, include_explicit=False) if d.detected]
|
||||
return max(fired, key=lambda d: d.confidence) if fired else None
|
||||
@@ -198,15 +198,17 @@ class TestVisibleCommand:
|
||||
# which doesn't overlap the centre square at 200x200).
|
||||
assert out[100, 100, 3] == 255
|
||||
|
||||
def test_visible_clears_alpha_in_watermark_region(self, runner, tmp_path):
|
||||
"""When inpainting an RGBA image, the watermark region must be cleared
|
||||
in the alpha channel so the sparkle area becomes transparent, not opaque-black.
|
||||
def test_visible_keeps_alpha_opaque_in_watermark_region(self, runner, tmp_path):
|
||||
"""Regression for issue #30 (white box): on an opaque RGBA image, the
|
||||
watermark region must stay OPAQUE. Reverse-alpha recovers real pixels
|
||||
there, so zeroing alpha would punch a transparent hole that renders as a
|
||||
solid white box on any non-transparent viewer.
|
||||
"""
|
||||
rgba = np.full((200, 200, 4), 255, dtype=np.uint8) # fully opaque white
|
||||
src = tmp_path / "rgba_full.png"
|
||||
cv2.imwrite(str(src), rgba)
|
||||
|
||||
output = tmp_path / "rgba_cleared.png"
|
||||
output = tmp_path / "rgba_kept.png"
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["visible", str(src), "-o", str(output), "--no-detect"],
|
||||
@@ -215,13 +217,15 @@ class TestVisibleCommand:
|
||||
assert result.exit_code == 0, result.output
|
||||
out = cv2.imread(str(output), cv2.IMREAD_UNCHANGED)
|
||||
assert out.shape[2] == 4
|
||||
# Default sparkle position is in the bottom-right; alpha there must be 0.
|
||||
# Default sparkle position is in the bottom-right; alpha there must stay 255.
|
||||
from remove_ai_watermarks.gemini_engine import get_watermark_config
|
||||
|
||||
cfg = get_watermark_config(200, 200)
|
||||
px, py = cfg.get_position(200, 200)
|
||||
size = cfg.logo_size
|
||||
assert out[py + size // 2, px + size // 2, 3] == 0, "alpha in the watermark region was not cleared"
|
||||
assert out[py + size // 2, px + size // 2, 3] == 255, "watermark region alpha was zeroed (white-box regression)"
|
||||
# No pixel anywhere should have been forced transparent.
|
||||
assert int((out[:, :, 3] == 0).sum()) == 0, "spurious transparent pixels introduced"
|
||||
|
||||
def test_visible_rgb_input_stays_rgb(self, runner, sample_png, tmp_path):
|
||||
"""Regression: a plain RGB PNG must NOT gain a spurious alpha channel."""
|
||||
@@ -249,6 +253,7 @@ class TestInvisibleCommand:
|
||||
mock_cls, mock_engine = _mock_invisible_engine()
|
||||
output = tmp_path / "clean.png"
|
||||
with (
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
):
|
||||
@@ -263,6 +268,7 @@ class TestInvisibleCommand:
|
||||
def test_invisible_default_output(self, runner, sample_png):
|
||||
mock_cls, _mock_engine = _mock_invisible_engine()
|
||||
with (
|
||||
patch("remove_ai_watermarks.invisible_engine.is_available", return_value=True),
|
||||
patch("remove_ai_watermarks.cli.InvisibleEngine", mock_cls, create=True),
|
||||
patch("remove_ai_watermarks.invisible_engine.InvisibleEngine", mock_cls),
|
||||
):
|
||||
@@ -375,6 +381,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
|
||||
@@ -509,3 +523,96 @@ 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 reach the user with the ``[gpu]`` token
|
||||
intact (plain output prints it verbatim, with no markup parsing)."""
|
||||
|
||||
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
|
||||
|
||||
|
||||
class TestEraseCommand:
|
||||
"""Tests for the 'erase' universal region eraser subcommand."""
|
||||
|
||||
def test_erase_help(self, runner):
|
||||
result = runner.invoke(main, ["erase", "--help"])
|
||||
assert result.exit_code == 0
|
||||
assert "--region" in result.output
|
||||
assert "--backend" in result.output
|
||||
|
||||
def test_erase_single_region(self, runner, sample_png, tmp_path):
|
||||
output = tmp_path / "erased.png"
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["erase", str(sample_png), "--region", "10,10,40,40", "-o", str(output)],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert output.exists()
|
||||
|
||||
def test_erase_two_regions(self, runner, sample_png, tmp_path):
|
||||
output = tmp_path / "erased2.png"
|
||||
result = runner.invoke(
|
||||
main,
|
||||
[
|
||||
"erase",
|
||||
str(sample_png),
|
||||
"--region",
|
||||
"10,10,30,30",
|
||||
"--region",
|
||||
"120,120,30,30",
|
||||
"-o",
|
||||
str(output),
|
||||
],
|
||||
)
|
||||
assert result.exit_code == 0, result.output
|
||||
assert output.exists()
|
||||
# The banner reports the region count it processed.
|
||||
assert "2 region(s)" in result.output
|
||||
|
||||
def test_erase_default_output_name(self, runner, sample_png):
|
||||
result = runner.invoke(main, ["erase", str(sample_png), "--region", "10,10,40,40"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert sample_png.with_stem(sample_png.stem + "_clean").exists()
|
||||
|
||||
def test_erase_malformed_region_exits_nonzero(self, runner, sample_png, tmp_path):
|
||||
output = tmp_path / "x.png"
|
||||
# Only three values: click.BadParameter -> non-zero exit, no output file.
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["erase", str(sample_png), "--region", "1,2,3", "-o", str(output)],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert not output.exists()
|
||||
|
||||
def test_erase_nonexistent_file(self, runner):
|
||||
result = runner.invoke(main, ["erase", "/nonexistent/file.png", "--region", "0,0,10,10"])
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_erase_lama_backend_without_onnxruntime(self, runner, sample_png, tmp_path):
|
||||
# The LaMa backend needs onnxruntime; without it the CLI must surface a
|
||||
# clear error and exit non-zero rather than crash. When onnxruntime IS
|
||||
# installed there is no missing-dep path to exercise, so skip.
|
||||
from remove_ai_watermarks.region_eraser import lama_available
|
||||
|
||||
if lama_available():
|
||||
pytest.skip("onnxruntime installed; missing-dep error path not reachable")
|
||||
output = tmp_path / "y.png"
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["erase", str(sample_png), "--region", "10,10,40,40", "--backend", "lama", "-o", str(output)],
|
||||
)
|
||||
assert result.exit_code != 0
|
||||
assert "onnxruntime" in result.output.lower()
|
||||
assert not output.exists()
|
||||
@@ -1,4 +1,4 @@
|
||||
"""Tests for the Doubao visible-watermark engine."""
|
||||
"""Tests for the Doubao visible-watermark engine (reverse-alpha only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -8,91 +8,206 @@ import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.doubao_engine import DoubaoEngine, load_image_bgr
|
||||
from remove_ai_watermarks.doubao_engine import (
|
||||
_ALPHA_HEIGHT_FRAC,
|
||||
_ALPHA_LOGO_BGR,
|
||||
_ALPHA_MARGIN_BOTTOM_FRAC,
|
||||
_ALPHA_MARGIN_RIGHT_FRAC,
|
||||
_ALPHA_NATIVE_WIDTH,
|
||||
_ALPHA_WIDTH_FRAC,
|
||||
DETECT_NCC_THRESHOLD,
|
||||
DoubaoEngine,
|
||||
_alpha_template,
|
||||
_glyph_silhouette,
|
||||
_template_match_score,
|
||||
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 ──────────────────────────────
|
||||
# ── Detection: alpha-template NCC ───────────────────────────────────
|
||||
|
||||
|
||||
class TestDetect:
|
||||
def test_clean_gradient_not_detected(self):
|
||||
eng = DoubaoEngine()
|
||||
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
|
||||
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_solid_blob_corner_not_detected(self):
|
||||
"""A bright blob is not the glyph shape -> low correlation, not detected."""
|
||||
eng = DoubaoEngine()
|
||||
img = np.zeros((1024, 1024, 3), np.uint8)
|
||||
x, y, bw, bh = eng.locate(img).bbox
|
||||
img[y + bh // 4 : y + bh * 3 // 4, x : x + bw // 2] = 200
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_silhouette_loads(self):
|
||||
sil = _glyph_silhouette()
|
||||
assert sil is not None
|
||||
assert set(np.unique(sil)).issubset({0, 255})
|
||||
|
||||
def test_match_score_shape_sensitive(self):
|
||||
"""The glyph silhouette correlates with itself, not with a filled block."""
|
||||
sil = _glyph_silhouette()
|
||||
h, w = sil.shape
|
||||
# box that contains the silhouette -> high score
|
||||
box = np.zeros((h + 8, int(w / _ALPHA_WIDTH_FRAC * 0.2) + w), np.uint8)
|
||||
box[4 : 4 + h, 4 : 4 + w] = sil
|
||||
assert _template_match_score(box, _ALPHA_NATIVE_WIDTH) >= DETECT_NCC_THRESHOLD
|
||||
# a uniformly filled box has no glyph structure -> low score
|
||||
solid = np.full_like(box, 255)
|
||||
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
|
||||
|
||||
|
||||
@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))
|
||||
det = DoubaoEngine().detect(load_image_bgr(SAMPLE))
|
||||
assert det.detected
|
||||
assert det.confidence > 0.0
|
||||
assert det.coverage > 0.04
|
||||
assert det.confidence >= DETECT_NCC_THRESHOLD
|
||||
|
||||
def test_remove_reduces_glyph_coverage(self):
|
||||
def test_reverse_alpha_removes_mark(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
|
||||
assert eng.reverse_alpha_available(img) # sample is at the captured width
|
||||
out = eng.remove_watermark_reverse_alpha(img)
|
||||
assert not eng.detect(out).detected # mark gone after recovery
|
||||
|
||||
def test_pixels_outside_box_untouched(self):
|
||||
def test_far_region_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
|
||||
out = eng.remove_watermark_reverse_alpha(img)
|
||||
h, w = img.shape[:2]
|
||||
assert np.array_equal(img[: h // 2, : w // 2], out[: h // 2, : w // 2])
|
||||
|
||||
|
||||
# ── Negative + safety guard ─────────────────────────────────────────
|
||||
# ── Reverse-alpha (exact recovery) ──────────────────────────────────
|
||||
|
||||
|
||||
class TestNegativeAndGuard:
|
||||
def test_clean_image_not_detected(self):
|
||||
class TestReverseAlpha:
|
||||
def test_alpha_asset_loads(self):
|
||||
at = _alpha_template()
|
||||
assert at is not None
|
||||
assert at.dtype.kind == "f"
|
||||
assert float(at.min()) >= 0.0
|
||||
assert float(at.max()) <= 1.0
|
||||
|
||||
def test_available_whenever_asset_present(self):
|
||||
# NCC alignment generalizes to any resolution, so availability is just
|
||||
# "asset loadable" (any non-empty image); the caller gates on detect.
|
||||
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
|
||||
assert eng.reverse_alpha_available(np.zeros((1024, 1024, 3), np.uint8))
|
||||
assert eng.reverse_alpha_available(np.zeros((1773, 1535, 3), np.uint8))
|
||||
assert not eng.reverse_alpha_available(np.zeros((0, 0, 3), np.uint8))
|
||||
|
||||
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)
|
||||
@staticmethod
|
||||
def _compose(w: int, h: int, bg: float = 100.0):
|
||||
"""Composite the real alpha (scaled to width ``w``) onto a flat bg.
|
||||
Returns ``(watermarked_uint8, mark_bool_mask)``."""
|
||||
img = np.full((h, w, 3), bg, np.float32)
|
||||
at = _alpha_template()
|
||||
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
|
||||
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw
|
||||
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
a3 = amap[:, :, None]
|
||||
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * img).clip(0, 255).astype(np.uint8)
|
||||
return wm, amap > 0.2
|
||||
|
||||
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."""
|
||||
def test_removes_synthetic_mark(self):
|
||||
"""Reverse-alpha + thin residual inpaint clears a mark composed from the
|
||||
real alpha (re-detect no longer fires)."""
|
||||
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)
|
||||
wm, _mark = self._compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
assert eng.detect(wm).detected
|
||||
out = eng.remove_watermark_reverse_alpha(wm)
|
||||
assert not eng.detect(out).detected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("w", "h", "max_err"),
|
||||
[
|
||||
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, 5.0), # captured width
|
||||
(1773, 2364, 8.0), # 3:4 portrait -> NCC alignment generalizes the single capture
|
||||
],
|
||||
)
|
||||
def test_recovers_flat_background(self, w, h, max_err):
|
||||
"""Recovers the flat background at the captured width AND a non-native
|
||||
resolution (NCC alignment generalizes the single capture)."""
|
||||
eng = DoubaoEngine()
|
||||
wm, mark = self._compose(w, h)
|
||||
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
|
||||
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
|
||||
assert float(np.abs(out[mark] - 100.0).mean()) < max_err
|
||||
|
||||
@staticmethod
|
||||
def _textured_bg(w: int, h: int):
|
||||
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
base = 120 + 40 * np.sin(xx / 90.0) + 30 * np.cos(yy / 70.0)
|
||||
return np.clip(np.stack([base, base * 0.95, base * 1.05], axis=-1), 0, 255)
|
||||
|
||||
def test_recovers_shifted_mark_on_texture(self):
|
||||
"""A real mark is re-rasterized a few px off its fixed slot, so removal
|
||||
must NCC-align to it. Regression guard for the issue-#13 follow-up defect:
|
||||
a too-tight locate box let a corner-ward shift fall outside the alignment
|
||||
search, leaving a readable outline that the detector did not flag. Composes
|
||||
the real alpha SHIFTED on a known texture and asserts the texture is
|
||||
recovered (a misaligned removal would leave the bright glyph outline)."""
|
||||
eng = DoubaoEngine()
|
||||
w = h = _ALPHA_NATIVE_WIDTH
|
||||
at = _alpha_template()
|
||||
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
|
||||
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw + 12 # shift toward the corner
|
||||
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh + 8
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
a3 = amap[:, :, None]
|
||||
bg = self._textured_bg(w, h)
|
||||
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * bg).clip(0, 255).astype(np.uint8)
|
||||
mark = amap > 0.15
|
||||
assert float(np.abs(wm.astype(np.float32)[mark] - bg[mark]).mean()) > 30 # mark clearly visible
|
||||
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
|
||||
assert float(np.abs(out[mark] - bg[mark]).mean()) < 8.0 # texture recovered, no outline
|
||||
|
||||
|
||||
class TestDegenerateAndChannelInputs:
|
||||
"""Removal must not crash on degenerate sizes or non-3-channel inputs."""
|
||||
|
||||
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
|
||||
def test_wide_short_does_not_raise(self, w, h):
|
||||
"""A wide/short image at native width makes the width-derived glyph box
|
||||
taller than the image; the slice assignment must not ValueError."""
|
||||
eng = DoubaoEngine()
|
||||
img = np.zeros((h, w, 3), np.uint8)
|
||||
out = eng.remove_watermark_reverse_alpha(img)
|
||||
assert out.shape == img.shape
|
||||
|
||||
def test_grayscale_2d_does_not_raise(self):
|
||||
eng = DoubaoEngine()
|
||||
gray = np.zeros((2048, 2048), np.uint8)
|
||||
out = eng.remove_watermark_reverse_alpha(gray)
|
||||
assert out.shape == (2048, 2048, 3)
|
||||
|
||||
def test_bgra_4channel_does_not_raise(self):
|
||||
eng = DoubaoEngine()
|
||||
bgra = np.zeros((2048, 2048, 4), np.uint8)
|
||||
out = eng.remove_watermark_reverse_alpha(bgra)
|
||||
assert out.shape == (2048, 2048, 3)
|
||||
@@ -230,3 +230,57 @@ class TestInpainting:
|
||||
original = image.copy()
|
||||
self.engine.inpaint_residual(image, (150, 150, 48, 48))
|
||||
np.testing.assert_array_equal(image, original)
|
||||
|
||||
|
||||
class TestOverSubtractionGuard:
|
||||
"""Issue #30: reverse-alpha must not turn the sparkle into a black pit.
|
||||
|
||||
On a dark background the captured alpha over-estimates the real sparkle opacity,
|
||||
so the fixed-alpha reverse blend over-subtracts and drives the footprint to black.
|
||||
The engine detects this and inpaints the footprint instead.
|
||||
"""
|
||||
|
||||
# Composite the mark at ~60% of the captured opacity: the engine's alpha maxes at
|
||||
# ~0.51, real dark-background sparkles sit nearer ~0.31, so 0.6x reproduces the
|
||||
# capture-over-estimates-reality mismatch that triggers the bug.
|
||||
_REALISTIC_ALPHA_SCALE = 0.6
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _setup_engine(self):
|
||||
self.engine = GeminiEngine()
|
||||
|
||||
def _composite_sparkle(self, bg_value: int, size: int = 1400, alpha_scale: float = _REALISTIC_ALPHA_SCALE):
|
||||
"""Build a flat BGR image of ``bg_value`` with the sparkle composited in.
|
||||
|
||||
The mark is composited at a LOWER effective opacity than the engine's captured
|
||||
alpha map (``alpha_scale`` < 1), reproducing the real-world mismatch behind
|
||||
issue #30: the captured alpha (~0.51) over-estimates a real sparkle whose
|
||||
effective opacity is lower, so the fixed-alpha reverse blend over-subtracts.
|
||||
Placed at the configured large-image position so the detector locates it.
|
||||
"""
|
||||
img = np.full((size, size, 3), bg_value, dtype=np.float32)
|
||||
config = get_watermark_config(size, size)
|
||||
x, y = config.get_position(size, size)
|
||||
alpha = self.engine.get_alpha_map(WatermarkSize.LARGE)
|
||||
ah, aw = alpha.shape[:2]
|
||||
a = (alpha * alpha_scale)[:, :, None]
|
||||
roi = img[y : y + ah, x : x + aw]
|
||||
img[y : y + ah, x : x + aw] = a * 255.0 + (1.0 - a) * roi
|
||||
return np.clip(img, 0, 255).astype(np.uint8), (x, y, aw, ah)
|
||||
|
||||
def test_dark_background_does_not_leave_black_pit(self):
|
||||
image, (x, y, w, h) = self._composite_sparkle(bg_value=60)
|
||||
out = self.engine.remove_watermark(image)
|
||||
footprint = out[y : y + h, x : x + w]
|
||||
# The recovered footprint must read like the dark background, not a black hole.
|
||||
assert footprint.min() > 25, f"black pit: min={footprint.min()}"
|
||||
assert abs(float(footprint.mean()) - 60.0) < 25.0
|
||||
|
||||
def test_bright_background_keeps_reverse_alpha(self):
|
||||
"""A bright background does not over-subtract, so reverse-alpha is used."""
|
||||
bright, pos = self._composite_sparkle(bg_value=230)
|
||||
alpha = self.engine.get_interpolated_alpha(pos[2])
|
||||
assert self.engine._reverse_alpha_oversubtracts(bright, alpha, (pos[0], pos[1])) is False
|
||||
dark, dpos = self._composite_sparkle(bg_value=60)
|
||||
dalpha = self.engine.get_interpolated_alpha(dpos[2])
|
||||
assert self.engine._reverse_alpha_oversubtracts(dark, dalpha, (dpos[0], dpos[1])) is True
|
||||
@@ -50,3 +50,23 @@ def test_invalid_shape():
|
||||
img[0, 0] = 50
|
||||
result = apply_analog_humanizer(img)
|
||||
assert np.array_equal(img, result)
|
||||
|
||||
|
||||
def test_chromatic_shift_does_not_wrap_opposite_edge():
|
||||
# On a horizontal gradient (dark left, bright right), a circular np.roll
|
||||
# would wrap the bright right edge into the R channel's left border and the
|
||||
# dark left edge into the B channel's right border, producing a colored
|
||||
# fringe. After the fix the border columns must replicate their own edge.
|
||||
ramp = np.linspace(0, 255, 64, dtype=np.uint8)
|
||||
gray = np.broadcast_to(ramp, (32, 64))
|
||||
img = np.stack([gray, gray, gray], axis=2).copy() # B, G, R
|
||||
|
||||
shift = 3
|
||||
result = apply_analog_humanizer(img, grain_intensity=0.0, chromatic_shift=shift)
|
||||
|
||||
# B (index 0) rolled right -> its left border must stay dark (near 0),
|
||||
# NOT wrap the bright right edge.
|
||||
assert result[:, :shift, 0].max() < 60
|
||||
# R (index 2) rolled left -> its right border must stay bright (near 255),
|
||||
# NOT wrap the dark left edge.
|
||||
assert result[:, -shift:, 2].min() > 195
|
||||
@@ -7,6 +7,8 @@ against the real committed C2PA / IPTC fixtures in data/samples/.
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import subprocess
|
||||
import sys
|
||||
from dataclasses import asdict
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
@@ -17,7 +19,9 @@ from remove_ai_watermarks.identify import (
|
||||
ProvenanceReport,
|
||||
_ai_tools_in,
|
||||
_attribute_platform,
|
||||
_integrity_clashes,
|
||||
_issuers_in,
|
||||
_vendor_of,
|
||||
identify,
|
||||
)
|
||||
|
||||
@@ -111,6 +115,18 @@ class TestIdentifyNonPng:
|
||||
r = identify(path, check_visible=False)
|
||||
assert any("SynthID" in w for w in r.watermarks)
|
||||
|
||||
def test_black_forest_labs_flux_attributed(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"Black Forest Labs API ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.platform == "Black Forest Labs (FLUX)"
|
||||
|
||||
def test_bytedance_volcengine_attributed(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"certificate_center@volcengine.com ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert "ByteDance" in (r.platform or "")
|
||||
|
||||
def test_stability_ai_issuer_attributed_no_synthid(self, tmp_path: Path):
|
||||
path = self._c2pa_jpeg(tmp_path, b"Stability AI ... trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False)
|
||||
@@ -130,6 +146,50 @@ class TestIdentifyNonPng:
|
||||
assert not any("SynthID" in w for w in r.watermarks)
|
||||
|
||||
|
||||
class TestIdentifySamsungGalaxy:
|
||||
"""Samsung Galaxy / ASUS Gallery C2PA signers (verified on real signed files
|
||||
2026-05-29; synthetic byte blobs here since the originals are private).
|
||||
|
||||
Galaxy AI edits stamp BOTH the device cert AND an AI source-type / genAIType,
|
||||
so the signer attribution must NOT trip the camera-vs-AI integrity clash.
|
||||
"""
|
||||
|
||||
def _jpeg(self, tmp_path: Path, name: str, blob: bytes) -> Path:
|
||||
path = tmp_path / name
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1jumbc2pa" + blob + b"\xff\xd9")
|
||||
return path
|
||||
|
||||
def test_galaxy_trained_source_is_high_ai(self, tmp_path: Path):
|
||||
path = self._jpeg(tmp_path, "s25.jpg", b"Samsung Galaxy Galaxy S25 c2pa-rs trainedAlgorithmicMedia")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "high"
|
||||
assert r.platform == "Samsung Galaxy (C2PA)"
|
||||
assert r.integrity_clashes == [] # device cert + AI source-type is legitimate, not a clash
|
||||
|
||||
def test_galaxy_genai_only_is_medium_ai(self, tmp_path: Path):
|
||||
# The Galaxy S24 case: no trainedAlgorithmicMedia, genAIType is the only
|
||||
# AI marker -- previously missed, now a medium-confidence verdict.
|
||||
path = self._jpeg(
|
||||
tmp_path, "s24.jpg", b'Samsung Galaxy Galaxy S24 c2pa-rs PhotoEditor_Re_Edit_Data{"genAIType":1}'
|
||||
)
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "medium"
|
||||
assert r.platform == "Samsung Galaxy (C2PA)"
|
||||
assert any(s.name == "samsung_genai" for s in r.signals)
|
||||
assert r.integrity_clashes == []
|
||||
|
||||
def test_asus_gallery_signer_not_ai(self, tmp_path: Path):
|
||||
# ASUS Gallery signs edited photos; no AI source-type or genAIType, so the
|
||||
# platform is attributed but the verdict stays unknown.
|
||||
path = self._jpeg(tmp_path, "asus.jpg", b"/com.asus.gallery/3.8.0.98 c2pa-rs no ai marker")
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.is_ai_generated is None
|
||||
assert r.platform == "ASUS Gallery (C2PA signer)"
|
||||
assert any("C2PA" in w for w in r.watermarks)
|
||||
|
||||
|
||||
# ── End-to-end verdicts on real fixtures ────────────────────────────
|
||||
|
||||
|
||||
@@ -199,6 +259,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) ──────────────────────
|
||||
|
||||
|
||||
@@ -241,6 +373,68 @@ class TestIdentifyVisibleSparkle:
|
||||
assert r.confidence == "high"
|
||||
|
||||
|
||||
class TestIdentifyImportIsLight:
|
||||
"""`import identify` must stay torch-free (lazy noai/__init__): the package
|
||||
is deployed on a 512 MB host where eagerly pulling torch/diffusers OOMs."""
|
||||
|
||||
def test_import_identify_does_not_pull_torch(self):
|
||||
# Only meaningful where torch is installed (the gpu/detect extra); on a
|
||||
# core-only CI runner torch can't be in sys.modules anyway.
|
||||
pytest.importorskip("torch")
|
||||
code = "import sys, remove_ai_watermarks.identify; sys.exit(1 if 'torch' in sys.modules else 0)"
|
||||
result = subprocess.run([sys.executable, "-c", code], capture_output=True, check=False) # noqa: S603
|
||||
assert result.returncode == 0, f"import identify pulled torch: {result.stderr.decode()[-500:]}"
|
||||
|
||||
|
||||
# Where the registry-backed Doubao/Jimeng visible detector resolves.
|
||||
_TEXT_MARKS_TARGET = "remove_ai_watermarks.identify._visible_text_marks"
|
||||
|
||||
|
||||
class TestIdentifyVisibleTextMarks:
|
||||
"""The visible Doubao/Jimeng marks are a stripped-metadata visual fallback,
|
||||
parallel to the Gemini sparkle: each lifts an Unknown verdict to medium."""
|
||||
|
||||
@staticmethod
|
||||
def _detection(key: str, label: str, conf: float):
|
||||
from remove_ai_watermarks.watermark_registry import MarkDetection
|
||||
|
||||
return MarkDetection(key, label, "bottom-right", True, conf, (0, 0, 10, 10))
|
||||
|
||||
def test_doubao_promotes_to_medium(self, tmp_clean_png: Path):
|
||||
det = self._detection("doubao", "Doubao 豆包AI生成 text", 0.8)
|
||||
with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]):
|
||||
r = identify(tmp_clean_png, check_visible=True)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "medium"
|
||||
assert r.platform is not None
|
||||
assert "Doubao" in r.platform
|
||||
signal = next(s for s in r.signals if s.name == "visible_doubao")
|
||||
assert signal.confidence == "medium"
|
||||
|
||||
def test_jimeng_promotes_to_medium(self, tmp_clean_png: Path):
|
||||
det = self._detection("jimeng", "Jimeng 即梦AI wordmark", 0.9)
|
||||
with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]):
|
||||
r = identify(tmp_clean_png, check_visible=True)
|
||||
assert r.is_ai_generated is True
|
||||
assert r.confidence == "medium"
|
||||
assert r.platform is not None
|
||||
assert "Jimeng" in r.platform
|
||||
assert any(s.name == "visible_jimeng" for s in r.signals)
|
||||
|
||||
def test_check_visible_false_skips_text_marks(self, tmp_clean_png: Path):
|
||||
det = self._detection("doubao", "Doubao 豆包AI生成 text", 0.99)
|
||||
with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]) as mock:
|
||||
r = identify(tmp_clean_png, check_visible=False)
|
||||
mock.assert_not_called()
|
||||
assert not any(s.name == "visible_doubao" for s in r.signals)
|
||||
|
||||
def test_metadata_keeps_high_even_with_text_mark(self, tmp_png_with_ai_metadata: Path):
|
||||
det = self._detection("doubao", "Doubao 豆包AI生成 text", 0.8)
|
||||
with patch(_SPARKLE_TARGET, return_value=None), patch(_TEXT_MARKS_TARGET, return_value=[det]):
|
||||
r = identify(tmp_png_with_ai_metadata, check_visible=True)
|
||||
assert r.confidence == "high"
|
||||
|
||||
|
||||
# ── Caveats and serialization ───────────────────────────────────────
|
||||
|
||||
|
||||
@@ -259,6 +453,56 @@ class TestIdentifyCaveats:
|
||||
assert len(r.caveats) == len(set(r.caveats))
|
||||
|
||||
|
||||
class TestOpenAiCaveatVendorScoped:
|
||||
"""The OpenAI rollout caveat keys on the normalized SynthID vendor, not a raw
|
||||
"OpenAI" substring over the issuer + verdict blob -- so a Google-SynthID
|
||||
manifest with an incidental "OpenAI" byte elsewhere is not mislabeled, while
|
||||
a genuine OpenAI manifest still gets the hedge.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _png_chunk(ctype: bytes, data: bytes) -> bytes:
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
return struct.pack(">I", len(data)) + ctype + data + struct.pack(">I", zlib.crc32(ctype + data) & 0xFFFFFFFF)
|
||||
|
||||
def _png(self, tmp_path: Path, name: str, *extra: bytes) -> Path:
|
||||
import struct
|
||||
import zlib
|
||||
|
||||
ihdr = struct.pack(">IIBBBBB", 1, 1, 8, 6, 0, 0, 0)
|
||||
body = (
|
||||
b"\x89PNG\r\n\x1a\n"
|
||||
+ self._png_chunk(b"IHDR", ihdr)
|
||||
+ self._png_chunk(b"IDAT", zlib.compress(b"\x00" * 6, 9))
|
||||
+ b"".join(extra)
|
||||
+ self._png_chunk(b"IEND", b"")
|
||||
)
|
||||
path = tmp_path / name
|
||||
path.write_bytes(body)
|
||||
return path
|
||||
|
||||
def test_google_synthid_with_incidental_openai_byte_no_caveat(self, tmp_path: Path):
|
||||
# Google C2PA/SynthID manifest in caBX; the byte "OpenAI" lives in a
|
||||
# separate tEXt chunk (e.g. a trust-chain note), not as a SynthID vendor.
|
||||
png = self._png(
|
||||
tmp_path,
|
||||
"g.png",
|
||||
self._png_chunk(b"caBX", b"jumbc2pa Google ... trainedAlgorithmicMedia"),
|
||||
self._png_chunk(b"tEXt", b"note\x00signed via OpenAI trust chain"),
|
||||
)
|
||||
r = identify(png, check_visible=False, check_invisible=False)
|
||||
assert any("SynthID watermark, inferred from C2PA metadata (likely present (Google" in w for w in r.watermarks)
|
||||
assert not any("before the rollout" in c for c in r.caveats)
|
||||
|
||||
def test_openai_synthid_still_gets_caveat(self, tmp_path: Path):
|
||||
png = self._png(tmp_path, "oa.png", self._png_chunk(b"caBX", b"jumbc2pa OpenAI ... trainedAlgorithmicMedia"))
|
||||
r = identify(png, check_visible=False, check_invisible=False)
|
||||
assert any("SynthID watermark, inferred from C2PA metadata (likely present (OpenAI" in w for w in r.watermarks)
|
||||
assert any("before the rollout" in c for c in r.caveats)
|
||||
|
||||
|
||||
class TestReportSerializable:
|
||||
def test_report_is_json_serializable(self, tmp_png_with_ai_metadata: Path):
|
||||
# The CLI --json path relies on asdict + json.dumps(default=str).
|
||||
@@ -447,3 +691,114 @@ 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_camera_device_plus_ai_marker_clash(self, tmp_path: Path):
|
||||
# Integrity-clash rule #2: a camera-capture C2PA device token (Pixel
|
||||
# Camera) coexisting with an independent AI-generation marker (a China
|
||||
# TC260 AIGC label) -- a genuine camera capture is not AI-generated, so
|
||||
# the provenance is inconsistent (a laundering / spoofing tell).
|
||||
path = self._c2pa_jpeg(
|
||||
tmp_path,
|
||||
b'Pixel Camera ... <TC260:AIGC>{"Label":"1","ContentProducer":"BYTEDANCE001"}</TC260:AIGC>',
|
||||
)
|
||||
r = identify(path, check_visible=False, check_invisible=False)
|
||||
assert r.platform == "Google Pixel (camera, C2PA capture)"
|
||||
assert any("Camera-capture C2PA credentials" in c and "AI-generation markers" in c for c in 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,79 @@
|
||||
"""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
|
||||
|
||||
def test_imwrite_to_missing_directory_returns_false(self, tmp_path: Path) -> None:
|
||||
# An unwritable path must return False (cv2.imwrite contract), not raise.
|
||||
path = tmp_path / "no-such-dir" / "out.png"
|
||||
assert image_io.imwrite(path, _make_bgr()) is False
|
||||
@@ -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()
|
||||
|
||||
@@ -12,9 +12,15 @@ class TestIsAvailable:
|
||||
result = is_available()
|
||||
assert isinstance(result, bool)
|
||||
|
||||
def test_available_when_torch_installed(self):
|
||||
"""torch + diffusers should be installed in dev env."""
|
||||
assert is_available() is True
|
||||
def test_available_reflects_dependencies(self):
|
||||
"""is_available() is True iff torch + diffusers (the gpu extra) import.
|
||||
|
||||
Must not assume the full stack: the core+dev CI env has no diffusers.
|
||||
"""
|
||||
import importlib.util
|
||||
|
||||
expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers"))
|
||||
assert is_available() is expected
|
||||
|
||||
|
||||
class TestInvisibleEngineInit:
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
"""Tests for the Jimeng (即梦AI) visible-watermark engine.
|
||||
|
||||
No real Jimeng sample is committed (the captures are gitignored, repo is public),
|
||||
so detection/removal is exercised against a watermark synthesized from the bundled
|
||||
alpha asset itself -- self-consistent and download-free.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.jimeng_engine import (
|
||||
_ALPHA_HEIGHT_FRAC,
|
||||
_ALPHA_LOGO_BGR,
|
||||
_ALPHA_MARGIN_BOTTOM_FRAC,
|
||||
_ALPHA_MARGIN_RIGHT_FRAC,
|
||||
_ALPHA_NATIVE_WIDTH,
|
||||
_ALPHA_WIDTH_FRAC,
|
||||
DETECT_NCC_THRESHOLD,
|
||||
JimengEngine,
|
||||
_alpha_template,
|
||||
_glyph_silhouette,
|
||||
_template_match_score,
|
||||
)
|
||||
|
||||
|
||||
def _compose(w: int, h: int, bg: float = 100.0):
|
||||
"""Composite the real alpha (scaled to width ``w``) onto a flat bg by the
|
||||
engine's fixed geometry. Returns ``(watermarked_uint8, mark_bool_mask)``."""
|
||||
img = np.full((h, w, 3), bg, np.float32)
|
||||
at = _alpha_template()
|
||||
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
|
||||
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw
|
||||
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
a3 = amap[:, :, None]
|
||||
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * img).clip(0, 255).astype(np.uint8)
|
||||
return wm, amap > 0.2
|
||||
|
||||
|
||||
class TestLocate:
|
||||
def test_box_anchored_bottom_right(self):
|
||||
eng = JimengEngine()
|
||||
img = np.zeros((2048, 2048, 3), np.uint8)
|
||||
loc = eng.locate(img)
|
||||
assert 2048 - (loc.x + loc.w) < int(2048 * 0.03)
|
||||
assert 2048 - (loc.y + loc.h) < int(2048 * 0.03)
|
||||
|
||||
def test_box_scales_with_width(self):
|
||||
eng = JimengEngine()
|
||||
small = eng.locate(np.zeros((1024, 1024, 3), np.uint8))
|
||||
large = eng.locate(np.zeros((2048, 2048, 3), np.uint8))
|
||||
assert large.w == pytest.approx(small.w * 2, rel=0.1)
|
||||
|
||||
|
||||
class TestDetect:
|
||||
def test_clean_gradient_not_detected(self):
|
||||
eng = JimengEngine()
|
||||
ramp = np.tile(np.linspace(0, 255, 1024, dtype=np.uint8), (1024, 1))
|
||||
img = cv2.cvtColor(ramp, cv2.COLOR_GRAY2BGR)
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_solid_blob_corner_not_detected(self):
|
||||
"""A bright blob is not the glyph shape -> low correlation, not detected."""
|
||||
eng = JimengEngine()
|
||||
img = np.zeros((1024, 1024, 3), np.uint8)
|
||||
x, y, bw, bh = eng.locate(img).bbox
|
||||
img[y + bh // 4 : y + bh * 3 // 4, x : x + bw // 2] = 200
|
||||
assert not eng.detect(img).detected
|
||||
|
||||
def test_silhouette_loads(self):
|
||||
sil = _glyph_silhouette()
|
||||
assert sil is not None
|
||||
assert set(np.unique(sil)).issubset({0, 255})
|
||||
|
||||
def test_match_score_shape_sensitive(self):
|
||||
"""The glyph silhouette correlates with itself, not with a filled block."""
|
||||
sil = _glyph_silhouette()
|
||||
h, w = sil.shape
|
||||
box = np.zeros((h + 8, int(w / _ALPHA_WIDTH_FRAC * 0.2) + w), np.uint8)
|
||||
box[4 : 4 + h, 4 : 4 + w] = sil
|
||||
assert _template_match_score(box, _ALPHA_NATIVE_WIDTH) >= DETECT_NCC_THRESHOLD
|
||||
solid = np.full_like(box, 255)
|
||||
assert _template_match_score(solid, _ALPHA_NATIVE_WIDTH) < DETECT_NCC_THRESHOLD
|
||||
|
||||
def test_synthetic_mark_detected(self):
|
||||
"""A watermark composed from the real alpha is detected at its threshold."""
|
||||
eng = JimengEngine()
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
det = eng.detect(wm)
|
||||
assert det.detected
|
||||
assert det.confidence >= DETECT_NCC_THRESHOLD
|
||||
|
||||
|
||||
class TestReverseAlpha:
|
||||
def test_alpha_asset_loads(self):
|
||||
at = _alpha_template()
|
||||
assert at is not None
|
||||
assert at.dtype.kind == "f"
|
||||
assert float(at.min()) >= 0.0
|
||||
assert float(at.max()) <= 1.0
|
||||
|
||||
def test_logo_is_white(self):
|
||||
assert _ALPHA_LOGO_BGR == (255.0, 255.0, 255.0)
|
||||
|
||||
def test_available_whenever_asset_present(self):
|
||||
eng = JimengEngine()
|
||||
assert eng.reverse_alpha_available(np.zeros((1024, 1024, 3), np.uint8))
|
||||
assert eng.reverse_alpha_available(np.zeros((1440, 2560, 3), np.uint8))
|
||||
assert not eng.reverse_alpha_available(np.zeros((0, 0, 3), np.uint8))
|
||||
|
||||
def test_removes_synthetic_mark(self):
|
||||
"""Reverse-alpha + residual inpaint clears the composed mark (re-detect
|
||||
no longer fires)."""
|
||||
eng = JimengEngine()
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
assert eng.detect(wm).detected
|
||||
out = eng.remove_watermark_reverse_alpha(wm)
|
||||
assert not eng.detect(out).detected
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("w", "h", "max_err"),
|
||||
[
|
||||
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, 4.0), # captured width
|
||||
(1440, 2560, 8.0), # off-native -> NCC alignment generalizes the capture
|
||||
],
|
||||
)
|
||||
def test_recovers_flat_background(self, w, h, max_err):
|
||||
eng = JimengEngine()
|
||||
wm, mark = _compose(w, h)
|
||||
assert float(np.abs(wm.astype(np.float32)[mark] - 100.0).mean()) > 15 # mark visible
|
||||
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
|
||||
assert float(np.abs(out[mark] - 100.0).mean()) < max_err
|
||||
|
||||
def test_far_region_untouched(self):
|
||||
"""The residual inpaint only touches the bottom-right footprint; the
|
||||
opposite corner stays pixel-identical."""
|
||||
eng = JimengEngine()
|
||||
wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
|
||||
out = eng.remove_watermark_reverse_alpha(wm)
|
||||
h, w = wm.shape[:2]
|
||||
assert np.array_equal(wm[: h // 2, : w // 2], out[: h // 2, : w // 2])
|
||||
|
||||
def test_recovers_shifted_mark_on_texture(self):
|
||||
"""A real mark is re-rasterized a few px off its fixed slot, so removal
|
||||
must NCC-align to it (a too-tight locate box would let a corner-ward shift
|
||||
escape the search and leave a readable outline). Composes the real alpha
|
||||
SHIFTED on a known texture and asserts the texture is recovered."""
|
||||
eng = JimengEngine()
|
||||
w = h = _ALPHA_NATIVE_WIDTH
|
||||
at = _alpha_template()
|
||||
gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w)
|
||||
ax = w - int(_ALPHA_MARGIN_RIGHT_FRAC * w) - gw + 12 # shift toward the corner
|
||||
ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh + 8
|
||||
amap = np.zeros((h, w), np.float32)
|
||||
amap[ay : ay + gh, ax : ax + gw] = cv2.resize(at, (gw, gh))
|
||||
a3 = amap[:, :, None]
|
||||
yy, xx = np.mgrid[0:h, 0:w].astype(np.float32)
|
||||
base = 120 + 40 * np.sin(xx / 90.0) + 30 * np.cos(yy / 70.0)
|
||||
bg = np.clip(np.stack([base, base * 0.95, base * 1.05], axis=-1), 0, 255)
|
||||
wm = (a3 * np.array(_ALPHA_LOGO_BGR, np.float32) + (1 - a3) * bg).clip(0, 255).astype(np.uint8)
|
||||
mark = amap > 0.15
|
||||
assert float(np.abs(wm.astype(np.float32)[mark] - bg[mark]).mean()) > 30 # mark clearly visible
|
||||
out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32)
|
||||
assert float(np.abs(out[mark] - bg[mark]).mean()) < 8.0 # texture recovered, no outline
|
||||
|
||||
|
||||
class TestDegenerateAndChannelInputs:
|
||||
"""Removal must not crash on degenerate sizes or non-3-channel inputs."""
|
||||
|
||||
@pytest.mark.parametrize(("w", "h"), [(2048, 1), (1, 2048), (2048, 8)])
|
||||
def test_wide_short_does_not_raise(self, w, h):
|
||||
eng = JimengEngine()
|
||||
img = np.zeros((h, w, 3), np.uint8)
|
||||
out = eng.remove_watermark_reverse_alpha(img)
|
||||
assert out.shape == img.shape
|
||||
|
||||
def test_grayscale_2d_does_not_raise(self):
|
||||
eng = JimengEngine()
|
||||
gray = np.zeros((2048, 2048), np.uint8)
|
||||
out = eng.remove_watermark_reverse_alpha(gray)
|
||||
assert out.shape == (2048, 2048, 3)
|
||||
|
||||
def test_bgra_4channel_does_not_raise(self):
|
||||
eng = JimengEngine()
|
||||
bgra = np.zeros((2048, 2048, 4), np.uint8)
|
||||
out = eng.remove_watermark_reverse_alpha(bgra)
|
||||
assert out.shape == (2048, 2048, 3)
|
||||
@@ -12,12 +12,15 @@ from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from remove_ai_watermarks.metadata import (
|
||||
C2PA_UUID,
|
||||
_is_ai_key,
|
||||
c2pa_marker_in,
|
||||
exif_generator,
|
||||
get_ai_metadata,
|
||||
has_ai_metadata,
|
||||
iptc_ai_system,
|
||||
remove_ai_metadata,
|
||||
samsung_genai,
|
||||
synthid_source,
|
||||
xai_signature,
|
||||
)
|
||||
@@ -135,6 +138,71 @@ class TestHasAiMetadata:
|
||||
assert has_ai_metadata(path)
|
||||
|
||||
|
||||
class TestC2paMarkerIn:
|
||||
"""The C2PA presence check requires a JUMBF wrapper or the C2PA uuid box, so
|
||||
a bare 4-byte ``c2pa`` substring (e.g. random compressed pixel data) does not
|
||||
false-positive -- the regression behind 4 cleaned PNGs re-flagging C2PA."""
|
||||
|
||||
def test_jumbf_wrapped_c2pa_detected(self):
|
||||
assert c2pa_marker_in(b"....jumbc2pa....manifest....") is True
|
||||
|
||||
def test_c2pa_uuid_box_detected(self):
|
||||
assert c2pa_marker_in(b"\x00\x00\x00\x18uuid" + C2PA_UUID + b"payload") is True
|
||||
|
||||
def test_bare_c2pa_substring_not_detected(self):
|
||||
# The exact false positive: "c2pa" appears in noise but no JUMBF/uuid box.
|
||||
assert c2pa_marker_in(b"\x9c\xc3\xa7B1\x11c2pa\x80b\x804\xc5\xf9random idat") is False
|
||||
|
||||
def test_jumb_without_c2pa_not_detected(self):
|
||||
assert c2pa_marker_in(b"some jumb box but no manifest label") is False
|
||||
|
||||
def test_empty_not_detected(self):
|
||||
assert c2pa_marker_in(b"") is False
|
||||
|
||||
|
||||
class TestSamsungGenai:
|
||||
"""Samsung Galaxy AI editing marker (genAIType in PhotoEditor_Re_Edit_Data).
|
||||
|
||||
Synthetic byte blobs -- real Galaxy files are user content and not shipped
|
||||
(public repo), same discipline as the Grok/Doubao fixtures.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _samsung_jpeg(tmp_path: Path, name: str, payload: bytes) -> Path:
|
||||
path = tmp_path / name
|
||||
path.write_bytes(b"\xff\xd8\xff\xe1" + payload + b"\xff\xd9")
|
||||
return path
|
||||
|
||||
def test_nonzero_genai_type_detected(self, tmp_path: Path):
|
||||
p = self._samsung_jpeg(
|
||||
tmp_path, "galaxy.jpg", b'PhotoEditor_Re_Edit_Data{"connectorType":"srvg","genAIType":1}'
|
||||
)
|
||||
assert samsung_genai(p) == 1
|
||||
|
||||
def test_other_nonzero_value_detected(self, tmp_path: Path):
|
||||
p = self._samsung_jpeg(tmp_path, "galaxy5.jpg", b'PhotoEditor_Re_Edit_Data{"genAIType":5}')
|
||||
assert samsung_genai(p) == 5
|
||||
|
||||
def test_zero_genai_type_is_none(self, tmp_path: Path):
|
||||
"""genAIType:0 means no generative AI was used -- not a positive signal."""
|
||||
p = self._samsung_jpeg(tmp_path, "edit.jpg", b'PhotoEditor_Re_Edit_Data{"genAIType":0}')
|
||||
assert samsung_genai(p) is None
|
||||
|
||||
def test_genai_without_editor_container_ignored(self, tmp_path: Path):
|
||||
"""An incidental genAIType token outside Samsung's editor JSON is ignored."""
|
||||
p = self._samsung_jpeg(tmp_path, "stray.jpg", b'some other blob "genAIType":1 elsewhere')
|
||||
assert samsung_genai(p) is None
|
||||
|
||||
def test_clean_image_is_none(self, tmp_clean_png):
|
||||
assert samsung_genai(tmp_clean_png) is None
|
||||
|
||||
def test_surfaced_in_get_ai_metadata(self, tmp_path: Path):
|
||||
p = self._samsung_jpeg(tmp_path, "galaxy.jpg", b'PhotoEditor_Re_Edit_Data{"genAIType":1}')
|
||||
meta = get_ai_metadata(p)
|
||||
assert "samsung_genai" in meta
|
||||
assert "genAIType=1" in meta["samsung_genai"]
|
||||
|
||||
|
||||
class TestGetAiMetadata:
|
||||
"""Tests for extracting AI metadata."""
|
||||
|
||||
@@ -322,6 +390,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()
|
||||
@@ -339,6 +445,31 @@ class TestRemoveAiMetadata:
|
||||
assert isinstance(result, Path)
|
||||
assert result == output
|
||||
|
||||
def _sd_png(self, tmp_path: Path) -> Path:
|
||||
img = Image.new("RGB", (32, 32), color=(80, 80, 80))
|
||||
pnginfo = PngInfo()
|
||||
pnginfo.add_text("parameters", "Steps: 20, Sampler: Euler")
|
||||
img.save(tmp_path / "sd.png", pnginfo=pnginfo)
|
||||
return tmp_path / "sd.png"
|
||||
|
||||
def test_png_to_jpeg_strips_ai(self, tmp_path):
|
||||
# Cross-format output: the AI text chunk must not survive the PNG->JPEG
|
||||
# re-encode, by detection AND by raw bytes.
|
||||
out = tmp_path / "clean.jpg"
|
||||
remove_ai_metadata(self._sd_png(tmp_path), out)
|
||||
assert not has_ai_metadata(out)
|
||||
body = out.read_bytes()
|
||||
assert b"parameters" not in body
|
||||
assert b"Steps" not in body
|
||||
|
||||
def test_png_to_webp_strips_ai(self, tmp_path):
|
||||
out = tmp_path / "clean.webp"
|
||||
remove_ai_metadata(self._sd_png(tmp_path), out)
|
||||
assert not has_ai_metadata(out)
|
||||
body = out.read_bytes()
|
||||
assert b"parameters" not in body
|
||||
assert b"Steps" not in body
|
||||
|
||||
|
||||
def _img_with_software(tmp_path: Path, fmt: str, software: str) -> Path:
|
||||
"""Write a tiny image carrying an EXIF Software tag."""
|
||||
@@ -511,6 +642,41 @@ class TestRemoveAiExif:
|
||||
kept = piexif.load(Image.open(out).info["exif"])["0th"]
|
||||
assert kept.get(piexif.ImageIFD.Make) == b"Apple"
|
||||
|
||||
def test_xai_pair_stripped_but_genuine_camera_tags_kept(self, tmp_path: Path):
|
||||
# An image carrying BOTH the xAI Signature pair (ImageDescription =
|
||||
# "Signature: <base64>" + UUID Artist) AND genuine non-AI camera tags.
|
||||
# The scrub must delete only the xAI pair, leaving the camera tags intact.
|
||||
sig = "Signature: " + "A" * 120
|
||||
artist = "12345678-1234-1234-1234-123456789abc"
|
||||
exif = piexif.dump(
|
||||
{
|
||||
"0th": {
|
||||
piexif.ImageIFD.ImageDescription: sig.encode(),
|
||||
piexif.ImageIFD.Artist: artist.encode(),
|
||||
piexif.ImageIFD.Make: b"Canon",
|
||||
piexif.ImageIFD.Model: b"EOS R5",
|
||||
},
|
||||
"Exif": {piexif.ExifIFD.DateTimeOriginal: b"2024:01:01 12:00:00"},
|
||||
"GPS": {piexif.GPSIFD.GPSLatitudeRef: b"N"},
|
||||
"1st": {},
|
||||
}
|
||||
)
|
||||
src = tmp_path / "grok_plus_cam.jpg"
|
||||
Image.new("RGB", (32, 32)).save(src, exif=exif)
|
||||
out = tmp_path / "scrubbed.jpg"
|
||||
remove_ai_metadata(src, out)
|
||||
|
||||
# xAI signature pair is gone (xai_signature returns a bool, not None).
|
||||
assert xai_signature(out) is False
|
||||
kept = piexif.load(Image.open(out).info["exif"])
|
||||
assert kept["0th"].get(piexif.ImageIFD.ImageDescription) is None
|
||||
assert kept["0th"].get(piexif.ImageIFD.Artist) is None
|
||||
# Genuine camera tags are preserved.
|
||||
assert kept["0th"].get(piexif.ImageIFD.Make) == b"Canon"
|
||||
assert kept["0th"].get(piexif.ImageIFD.Model) == b"EOS R5"
|
||||
assert kept["Exif"].get(piexif.ExifIFD.DateTimeOriginal) == b"2024:01:01 12:00:00"
|
||||
assert kept["GPS"].get(piexif.GPSIFD.GPSLatitudeRef) == b"N"
|
||||
|
||||
|
||||
class TestAIGCLabel:
|
||||
"""China TC260 AIGC labeling (Doubao and other China-served generators)."""
|
||||
@@ -554,6 +720,164 @@ 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)
|
||||
|
||||
def _aigc_exif_jpeg(self, tmp_path: Path, producer: str = "001191440300708461136T1308L") -> Path:
|
||||
"""Some China-served generators embed the raw-JSON ``{"AIGC":{...}}``
|
||||
block in JPEG EXIF (UserComment) -- no PNG chunk, no namespaced XMP."""
|
||||
import json
|
||||
|
||||
import piexif
|
||||
|
||||
p = tmp_path / "aigc_exif.jpg"
|
||||
Image.new("RGB", (32, 32)).save(p)
|
||||
payload = json.dumps({"AIGC": {"Label": "1", "ContentProducer": producer, "ProduceID": "abc123"}})
|
||||
exif = {"Exif": {piexif.ExifIFD.UserComment: payload.encode("ascii")}}
|
||||
piexif.insert(piexif.dump(exif), str(p))
|
||||
return p
|
||||
|
||||
def test_parses_raw_json_exif_form(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
info = aigc_label(self._aigc_exif_jpeg(tmp_path))
|
||||
assert info is not None
|
||||
assert info["Label"] == "1"
|
||||
assert info["ContentProducer"] == "001191440300708461136T1308L"
|
||||
|
||||
def test_has_ai_metadata_detects_raw_json_exif_form(self, tmp_path: Path):
|
||||
assert has_ai_metadata(self._aigc_exif_jpeg(tmp_path))
|
||||
|
||||
def test_raw_json_without_tc260_field_ignored(self, tmp_path: Path):
|
||||
"""A bare ``{"AIGC":{...}}`` object with no TC260 field must not fire."""
|
||||
import json
|
||||
|
||||
import piexif
|
||||
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
p = tmp_path / "unrelated.jpg"
|
||||
Image.new("RGB", (32, 32)).save(p)
|
||||
payload = json.dumps({"AIGC": {"unrelated": "value"}})
|
||||
exif = {"Exif": {piexif.ExifIFD.UserComment: payload.encode("ascii")}}
|
||||
piexif.insert(piexif.dump(exif), str(p))
|
||||
assert aigc_label(p) is None
|
||||
|
||||
def _aigc_attr_png(self, tmp_path: Path, producer: str = "picwish") -> Path:
|
||||
"""PicWish writes the TC260 label as an XMP *attribute*
|
||||
(``TC260:AIGC="{...}"``), not the nested element form."""
|
||||
p = tmp_path / "picwish.png"
|
||||
Image.new("RGB", (32, 32)).save(p)
|
||||
xmp = (
|
||||
'<rdf:Description rdf:about="" '
|
||||
'xmlns:TC260="http://www.tc260.org.cn/ns/AIGC/1.0/" '
|
||||
f'TC260:AIGC="{{"Label":"1","ContentProducer":"{producer}"}}"/>'
|
||||
)
|
||||
with open(p, "ab") as f:
|
||||
f.write(xmp.encode())
|
||||
return p
|
||||
|
||||
def test_parses_xmp_attribute_form(self, tmp_path: Path):
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
|
||||
info = aigc_label(self._aigc_attr_png(tmp_path))
|
||||
assert info is not None
|
||||
assert info["ContentProducer"] == "picwish"
|
||||
|
||||
def test_scan_head_collects_png_metadata_past_window(self, tmp_path: Path):
|
||||
"""A PNG metadata chunk beyond the read window is still reachable -- the
|
||||
regression for a TC260 XMP packet appended after a large IDAT."""
|
||||
import json
|
||||
|
||||
from remove_ai_watermarks.metadata import _png_late_metadata, scan_head
|
||||
|
||||
p = tmp_path / "late.png"
|
||||
pnginfo = PngInfo()
|
||||
pnginfo.add_text("AIGC", json.dumps({"Label": "1", "ContentProducer": "doubao"}))
|
||||
Image.new("RGB", (16, 16)).save(p, pnginfo=pnginfo)
|
||||
# window = 8 (just the signature) makes the text chunk "late".
|
||||
assert b"ContentProducer" in _png_late_metadata(p, 8)
|
||||
assert b"ContentProducer" in scan_head(p, 8)
|
||||
|
||||
|
||||
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:
|
||||
@@ -631,6 +955,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."""
|
||||
|
||||
@@ -654,6 +983,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."""
|
||||
|
||||
@@ -718,9 +1162,17 @@ class TestFfmpegMetadataStrip:
|
||||
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),
|
||||
shutil.which("ffmpeg"),
|
||||
"-y",
|
||||
"-loglevel",
|
||||
"error",
|
||||
"-f",
|
||||
"lavfi",
|
||||
"-i",
|
||||
"sine=frequency=440:duration=0.1",
|
||||
"-metadata",
|
||||
f"title={tag}",
|
||||
str(path),
|
||||
],
|
||||
check=True,
|
||||
)
|
||||
|
||||
@@ -328,9 +328,12 @@ class TestISOBMFF:
|
||||
def test_truncated_largesize_terminates_safely(self):
|
||||
# size32==1 promises a 64-bit largesize, but the box ends after 8 bytes;
|
||||
# iteration must stop rather than read the missing largesize past EOF.
|
||||
cleaned, stripped = strip_c2pa_boxes(FTYP + b"\x00\x00\x00\x01uuid")
|
||||
# The walk halts before EOF, so the fail-safe returns the input unchanged
|
||||
# (emitting only FTYP would silently truncate the file).
|
||||
data = FTYP + b"\x00\x00\x00\x01uuid"
|
||||
cleaned, stripped = strip_c2pa_boxes(data)
|
||||
assert stripped == 0
|
||||
assert cleaned == FTYP
|
||||
assert cleaned == data
|
||||
|
||||
|
||||
class TestC2PAInvalidSignature:
|
||||
|
||||
@@ -6,16 +6,22 @@ code paths work correctly on CPU, MPS (macOS), and CUDA (Linux/Windows).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from unittest.mock import patch
|
||||
from pathlib import Path
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.noai.progress import is_mps_error
|
||||
from remove_ai_watermarks.noai.utils import get_image_format, is_supported_format
|
||||
from remove_ai_watermarks.noai.watermark_profiles import (
|
||||
CTRLREGEN_DEFAULT_STRENGTH,
|
||||
DEFAULT_STRENGTH,
|
||||
GEMINI_STRENGTH,
|
||||
OPENAI_STRENGTH,
|
||||
UNKNOWN_STRENGTH,
|
||||
detect_model_profile,
|
||||
get_model_id_for_profile,
|
||||
get_recommended_strength,
|
||||
resolve_strength,
|
||||
)
|
||||
from remove_ai_watermarks.noai.watermark_remover import get_device, is_watermark_removal_available
|
||||
|
||||
@@ -27,7 +33,7 @@ class TestDeviceDetection:
|
||||
|
||||
def test_returns_valid_device(self):
|
||||
device = get_device()
|
||||
assert device in ("cpu", "mps", "cuda")
|
||||
assert device in ("cpu", "mps", "cuda", "xpu")
|
||||
|
||||
def test_cpu_fallback_when_no_gpu(self):
|
||||
"""On CI / machines without GPU, should fall back to cpu or mps."""
|
||||
@@ -39,6 +45,49 @@ class TestDeviceDetection:
|
||||
def test_no_torch_returns_cpu(self):
|
||||
assert get_device() == "cpu"
|
||||
|
||||
def test_xpu_selected_when_available(self):
|
||||
"""An XPU-enabled torch (no CUDA) routes to the Intel GPU backend.
|
||||
|
||||
The whole torch module is mocked so the smoke-test ops succeed without
|
||||
any real device; cuda must read False so the cuda branch is skipped.
|
||||
"""
|
||||
fake_torch = MagicMock()
|
||||
fake_torch.cuda.is_available.return_value = False
|
||||
fake_torch.xpu.is_available.return_value = True
|
||||
with patch("remove_ai_watermarks.noai.watermark_remover.torch", fake_torch):
|
||||
assert get_device() == "xpu"
|
||||
fake_torch.tensor.assert_called_with([1.0], device="xpu")
|
||||
|
||||
def test_init_accepts_xpu_and_selects_fp16(self):
|
||||
"""WatermarkRemover accepts device='xpu' and picks fp16 (not fp32)."""
|
||||
if not is_watermark_removal_available():
|
||||
pytest.skip("torch/diffusers not installed")
|
||||
import torch
|
||||
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
|
||||
|
||||
remover = WatermarkRemover(device="xpu")
|
||||
assert remover.device == "xpu"
|
||||
assert remover.torch_dtype == torch.float16
|
||||
|
||||
def test_seed_generator_falls_back_to_cpu_when_device_rng_unsupported(self):
|
||||
"""A device with no RNG backend (e.g. some torch-xpu builds) falls back
|
||||
to a CPU generator instead of raising when --seed is used."""
|
||||
from remove_ai_watermarks.noai import watermark_remover as wr
|
||||
|
||||
def fake_generator(device="cpu"):
|
||||
if device == "xpu":
|
||||
raise RuntimeError("Device type xpu is not supported for torch.Generator()")
|
||||
gen = MagicMock()
|
||||
gen.manual_seed.return_value = f"gen:{device}"
|
||||
return gen
|
||||
|
||||
fake_torch = MagicMock()
|
||||
fake_torch.Generator.side_effect = fake_generator
|
||||
with patch.object(wr, "torch", fake_torch):
|
||||
assert wr._make_seed_generator("xpu", 123) == "gen:cpu"
|
||||
assert wr._make_seed_generator("cuda", 123) == "gen:cuda"
|
||||
|
||||
|
||||
class TestMpsErrorDetection:
|
||||
"""Tests for MPS error detection helper."""
|
||||
@@ -78,32 +127,80 @@ class TestModelProfiles:
|
||||
def test_detect_ctrlregen(self):
|
||||
assert detect_model_profile("yepengliu/ctrlregen") == "ctrlregen"
|
||||
|
||||
def test_recommended_strength_high(self):
|
||||
assert get_recommended_strength("treering") == 0.7
|
||||
|
||||
def test_recommended_strength_low(self):
|
||||
assert get_recommended_strength("stablesignature") == 0.04
|
||||
class TestResolveStrength:
|
||||
"""resolve_strength applies the profile/vendor default only when strength is unset."""
|
||||
|
||||
def test_recommended_strength_medium(self):
|
||||
assert get_recommended_strength("unknown_type") == 0.35
|
||||
def test_none_default_profile_is_vendor_adaptive(self):
|
||||
# No vendor -> unknown default; OpenAI lower, Google == unknown.
|
||||
assert resolve_strength(None, "default") == UNKNOWN_STRENGTH
|
||||
assert resolve_strength(None, "default", "openai") == OPENAI_STRENGTH
|
||||
assert resolve_strength(None, "default", "google") == GEMINI_STRENGTH
|
||||
assert resolve_strength(None, "default", None) == UNKNOWN_STRENGTH
|
||||
# An unrecognized vendor string falls through to the unknown default.
|
||||
assert resolve_strength(None, "default", "adobe") == UNKNOWN_STRENGTH
|
||||
|
||||
@pytest.mark.parametrize("wm_type", ["stegastamp", "stegasamp", "treering", "ringid"])
|
||||
def test_high_perturbation_watermark_types(self, wm_type):
|
||||
"""Robust spatial watermarks need aggressive (0.7) regeneration."""
|
||||
assert get_recommended_strength(wm_type) == 0.7
|
||||
def test_default_strength_alias_is_unknown_vendor_value(self):
|
||||
assert DEFAULT_STRENGTH == UNKNOWN_STRENGTH
|
||||
assert OPENAI_STRENGTH < UNKNOWN_STRENGTH
|
||||
|
||||
@pytest.mark.parametrize("wm_type", ["stablesignature", "dwtectsvd", "rivagan", "ssl", "hidden"])
|
||||
def test_low_perturbation_watermark_types(self, wm_type):
|
||||
"""Fragile frequency/latent watermarks break at low (0.04) strength."""
|
||||
assert get_recommended_strength(wm_type) == 0.04
|
||||
def test_none_ctrlregen_uses_clean_noise_default(self):
|
||||
# ctrlregen must NOT inherit the SDXL vendor defaults (that makes it a no-op);
|
||||
# clean-noise regeneration is the lever against robust marks. Vendor is ignored.
|
||||
assert resolve_strength(None, "ctrlregen") == CTRLREGEN_DEFAULT_STRENGTH
|
||||
assert resolve_strength(None, "ctrlregen", "openai") == CTRLREGEN_DEFAULT_STRENGTH
|
||||
assert CTRLREGEN_DEFAULT_STRENGTH > DEFAULT_STRENGTH
|
||||
|
||||
def test_strength_match_is_case_insensitive(self):
|
||||
assert get_recommended_strength("TreeRing") == 0.7
|
||||
assert get_recommended_strength("StableSignature") == 0.04
|
||||
def test_explicit_value_overrides_profile_and_vendor(self):
|
||||
assert resolve_strength(0.3, "default") == 0.3
|
||||
assert resolve_strength(0.3, "default", "openai") == 0.3
|
||||
assert resolve_strength(0.3, "ctrlregen") == 0.3
|
||||
|
||||
def test_strength_matches_substring_in_descriptive_name(self):
|
||||
# e.g. a CLI passing "treering_v2" or "synthid-stegastamp" still maps.
|
||||
assert get_recommended_strength("treering_v2") == 0.7
|
||||
def test_explicit_zero_is_respected_not_treated_as_unset(self):
|
||||
# 0.0 is falsy but explicit -- must not fall through to the profile default
|
||||
# (the old `strength or DEFAULT` bug would have). Range validation lives in
|
||||
# remove_watermark, not here.
|
||||
assert resolve_strength(0.0, "ctrlregen") == 0.0
|
||||
assert resolve_strength(0.0, "default", "google") == 0.0
|
||||
|
||||
|
||||
class TestVendorForStrength:
|
||||
"""vendor_for_strength normalizes the C2PA SynthID proxy to openai/google/None."""
|
||||
|
||||
@staticmethod
|
||||
def _patch(value):
|
||||
return patch("remove_ai_watermarks.metadata.synthid_source", return_value=value)
|
||||
|
||||
def test_openai(self):
|
||||
from remove_ai_watermarks.noai.watermark_profiles import vendor_for_strength
|
||||
|
||||
with self._patch("OpenAI"):
|
||||
assert vendor_for_strength(Path("x.png")) == "openai"
|
||||
|
||||
def test_google(self):
|
||||
from remove_ai_watermarks.noai.watermark_profiles import vendor_for_strength
|
||||
|
||||
with self._patch("Google"):
|
||||
assert vendor_for_strength(Path("x.png")) == "google"
|
||||
|
||||
def test_both_issuers_google_wins(self):
|
||||
# The more-robust watermark wins -> safer (higher) strength.
|
||||
from remove_ai_watermarks.noai.watermark_profiles import vendor_for_strength
|
||||
|
||||
with self._patch("OpenAI, Google"):
|
||||
assert vendor_for_strength(Path("x.png")) == "google"
|
||||
|
||||
def test_none_when_no_synthid_source(self):
|
||||
from remove_ai_watermarks.noai.watermark_profiles import vendor_for_strength
|
||||
|
||||
with self._patch(None):
|
||||
assert vendor_for_strength(Path("x.png")) is None
|
||||
|
||||
def test_unreadable_metadata_is_none(self):
|
||||
from remove_ai_watermarks.noai.watermark_profiles import vendor_for_strength
|
||||
|
||||
with patch("remove_ai_watermarks.metadata.synthid_source", side_effect=OSError):
|
||||
assert vendor_for_strength(Path("x.png")) is None
|
||||
|
||||
|
||||
# ── Format utilities ────────────────────────────────────────────────
|
||||
@@ -151,13 +248,21 @@ class TestAvailability:
|
||||
"""Tests for dependency availability checks."""
|
||||
|
||||
def test_watermark_removal_available(self):
|
||||
# In dev env with torch+diffusers installed
|
||||
assert is_watermark_removal_available() is True
|
||||
# Reflects the actual environment: True iff torch + diffusers (the gpu
|
||||
# extra) are importable. The core+dev CI env has no diffusers, so this
|
||||
# must not assume the full stack is present.
|
||||
import importlib.util
|
||||
|
||||
expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers"))
|
||||
assert is_watermark_removal_available() is expected
|
||||
|
||||
def test_invisible_is_available(self):
|
||||
import importlib.util
|
||||
|
||||
from remove_ai_watermarks.invisible_engine import is_available
|
||||
|
||||
assert is_available() is True
|
||||
expected = all(importlib.util.find_spec(m) is not None for m in ("torch", "diffusers"))
|
||||
assert is_available() is expected
|
||||
|
||||
|
||||
# ── Platform-specific path handling ─────────────────────────────────
|
||||
@@ -181,3 +286,28 @@ class TestPlatformPaths:
|
||||
# If we get here without error, asset loading works
|
||||
assert engine._alpha_small.shape == (48, 48)
|
||||
assert engine._alpha_large.shape == (96, 96)
|
||||
|
||||
|
||||
class TestFp16VaeFix:
|
||||
"""The plain SDXL img2img pipeline must swap in the fp16-fixed VAE on fp16
|
||||
GPUs to avoid the NaN/all-black decode (issue #29). Pure decision logic, no
|
||||
torch or model download needed."""
|
||||
|
||||
DEFAULT = "stabilityai/stable-diffusion-xl-base-1.0"
|
||||
|
||||
def test_default_sdxl_on_fp16_needs_fix(self):
|
||||
from remove_ai_watermarks.noai.watermark_remover import _needs_fp16_vae_fix
|
||||
|
||||
assert _needs_fp16_vae_fix(self.DEFAULT, self.DEFAULT, is_fp16=True) is True
|
||||
|
||||
def test_fp32_does_not_need_fix(self):
|
||||
"""cpu/mps run fp32, where the stock SDXL VAE is fine."""
|
||||
from remove_ai_watermarks.noai.watermark_remover import _needs_fp16_vae_fix
|
||||
|
||||
assert _needs_fp16_vae_fix(self.DEFAULT, self.DEFAULT, is_fp16=False) is False
|
||||
|
||||
def test_non_default_model_keeps_own_vae(self):
|
||||
"""A custom (non-SDXL) checkpoint must not get the SDXL-specific VAE."""
|
||||
from remove_ai_watermarks.noai.watermark_remover import _needs_fp16_vae_fix
|
||||
|
||||
assert _needs_fp16_vae_fix("runwayml/stable-diffusion-v1-5", self.DEFAULT, is_fp16=True) is False
|
||||
@@ -66,6 +66,23 @@ class TestEraseCv2:
|
||||
assert np.array_equal(img, out)
|
||||
|
||||
|
||||
class TestNonBgrInputs:
|
||||
"""cv2.inpaint rejects 4-channel BGRA and 2D-only entry points must work."""
|
||||
|
||||
def test_grayscale_2d_does_not_raise(self):
|
||||
gray = np.full((100, 100), 120, np.uint8)
|
||||
out = erase(gray, boxes=[(40, 40, 20, 20)], backend="cv2")
|
||||
assert out.shape == gray.shape
|
||||
|
||||
def test_bgra_preserves_alpha_and_does_not_raise(self):
|
||||
bgra = np.full((100, 100, 4), 120, np.uint8)
|
||||
bgra[..., 3] = 200 # opaque-ish alpha plane
|
||||
out = erase(bgra, boxes=[(40, 40, 20, 20)], backend="cv2", dilate=0)
|
||||
assert out.shape == bgra.shape
|
||||
# alpha plane is carried through unchanged
|
||||
assert np.array_equal(out[..., 3], bgra[..., 3])
|
||||
|
||||
|
||||
class TestLamaBackend:
|
||||
def test_lama_raises_when_unavailable(self):
|
||||
img = np.full((100, 100, 3), 50, np.uint8)
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
"""Regression guards for malformed-length DoS and removal-truncation bugs.
|
||||
|
||||
Three verified bugs are locked in here:
|
||||
|
||||
1. PNG C2PA parsers (``c2pa.has_c2pa_metadata`` / ``extract_c2pa_info`` and
|
||||
``metadata._png_late_metadata`` via ``scan_head``) used the raw 32-bit chunk
|
||||
``length`` field directly in ``f.read(length)``. A crafted file can declare
|
||||
``length = 0x7FFFFFFF`` (~2 GiB) on a 60-byte file, forcing a multi-GB
|
||||
allocation. The fix clamps ``length`` to the bytes actually remaining.
|
||||
|
||||
2. ISOBMFF ``strip_c2pa_boxes`` truncated the file from a malformed box to EOF
|
||||
(the box walker returns early), so ``remove_ai_metadata`` could emit a
|
||||
shorter file and report success. The fix returns the input unchanged when the
|
||||
walk does not reach EOF.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import tracemalloc
|
||||
|
||||
from remove_ai_watermarks import metadata
|
||||
from remove_ai_watermarks.noai import c2pa, isobmff
|
||||
|
||||
PNG_SIG = b"\x89PNG\r\n\x1a\n"
|
||||
_HUGE = 0x7FFFFFFF # ~2 GiB declared length on a tiny file
|
||||
|
||||
|
||||
def _png_with_huge_c2pa_chunk() -> bytes:
|
||||
"""A ~60-byte 'PNG' whose caBX chunk header lies about its length."""
|
||||
header = struct.pack(">I", _HUGE) + c2pa.C2PA_CHUNK_TYPE
|
||||
body = b"jumbc2pa-not-really" # far shorter than the declared length
|
||||
return PNG_SIG + header + body
|
||||
|
||||
|
||||
class TestPngLengthClampNoAlloc:
|
||||
"""Clamping makes the parsers read only the real bytes, not the lie."""
|
||||
|
||||
def test_has_c2pa_metadata_is_bounded(self, tmp_path):
|
||||
path = tmp_path / "evil.png"
|
||||
path.write_bytes(_png_with_huge_c2pa_chunk())
|
||||
|
||||
tracemalloc.start()
|
||||
try:
|
||||
# Must return quickly without allocating gigabytes and without raising.
|
||||
c2pa.has_c2pa_metadata(path)
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
assert peak < 50 * 1024 * 1024 # < 50 MB locks in the clamp
|
||||
|
||||
def test_extract_c2pa_info_is_bounded(self, tmp_path):
|
||||
path = tmp_path / "evil.png"
|
||||
path.write_bytes(_png_with_huge_c2pa_chunk())
|
||||
|
||||
tracemalloc.start()
|
||||
try:
|
||||
c2pa.extract_c2pa_info(path)
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
assert peak < 50 * 1024 * 1024
|
||||
|
||||
def test_extract_c2pa_chunk_is_bounded(self, tmp_path):
|
||||
path = tmp_path / "evil.png"
|
||||
path.write_bytes(_png_with_huge_c2pa_chunk())
|
||||
|
||||
tracemalloc.start()
|
||||
try:
|
||||
c2pa.extract_c2pa_chunk(path)
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
assert peak < 50 * 1024 * 1024
|
||||
|
||||
def test_png_late_metadata_scan_is_bounded(self, tmp_path):
|
||||
# A PNG with a real IDAT pushing the late-scan window past 1 MB, then a
|
||||
# tEXt chunk lying about its length. scan_head() -> _png_late_metadata().
|
||||
idat = b"\x00" * (1024 * 1024 + 16)
|
||||
text_header = struct.pack(">I", _HUGE) + b"tEXt"
|
||||
blob = (
|
||||
PNG_SIG
|
||||
+ struct.pack(">I", len(idat))
|
||||
+ b"IDAT"
|
||||
+ idat
|
||||
+ b"\x00\x00\x00\x00" # fake CRC
|
||||
+ text_header
|
||||
+ b"AIGC short"
|
||||
)
|
||||
path = tmp_path / "evil_late.png"
|
||||
path.write_bytes(blob)
|
||||
|
||||
tracemalloc.start()
|
||||
try:
|
||||
metadata.scan_head(path)
|
||||
_, peak = tracemalloc.get_traced_memory()
|
||||
finally:
|
||||
tracemalloc.stop()
|
||||
# head itself is ~1 MB; the clamp keeps the late read tiny. Generous cap.
|
||||
assert peak < 50 * 1024 * 1024
|
||||
|
||||
|
||||
def _box(box_type: bytes, payload: bytes) -> bytes:
|
||||
return struct.pack(">I", 8 + len(payload)) + box_type + payload
|
||||
|
||||
|
||||
class TestIsobmffStripFailSafe:
|
||||
def test_well_formed_file_still_strips_uuid(self):
|
||||
ftyp = _box(b"ftyp", b"isom\x00\x00\x00\x00mp42")
|
||||
c2pa_box = _box(b"uuid", isobmff.C2PA_UUID + b"manifest-bytes")
|
||||
mdat = _box(b"mdat", b"\x00" * 32)
|
||||
data = ftyp + c2pa_box + mdat
|
||||
|
||||
cleaned, stripped = isobmff.strip_c2pa_boxes(data)
|
||||
assert stripped == 1
|
||||
assert len(cleaned) == len(data) - len(c2pa_box)
|
||||
assert isobmff.C2PA_UUID not in cleaned
|
||||
|
||||
def test_malformed_box_does_not_truncate_tail(self):
|
||||
ftyp = _box(b"ftyp", b"isom\x00\x00\x00\x00mp42")
|
||||
c2pa_box = _box(b"uuid", isobmff.C2PA_UUID + b"manifest-bytes")
|
||||
# A box claiming ~2 GiB before EOF: the walker returns early here.
|
||||
bad_box = struct.pack(">I", _HUGE) + b"free" + b"\x00" * 16
|
||||
data = ftyp + c2pa_box + bad_box
|
||||
|
||||
cleaned, stripped = isobmff.strip_c2pa_boxes(data)
|
||||
# Fail-safe: input returned unchanged, nothing stripped, no truncation.
|
||||
assert stripped == 0
|
||||
assert cleaned == data
|
||||
assert len(cleaned) == len(data)
|
||||
@@ -0,0 +1,170 @@
|
||||
"""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 (
|
||||
_DET_MAX_LONG_SIDE,
|
||||
_detection_input_size,
|
||||
build_change_map,
|
||||
feather_paste,
|
||||
merge_text_regions,
|
||||
)
|
||||
|
||||
|
||||
def _quad(x0, y0, x1, y1):
|
||||
"""An axis-aligned 4-vertex polygon as the detector returns."""
|
||||
return np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1]], np.int32)
|
||||
|
||||
|
||||
class TestMergeTextRegions:
|
||||
def test_empty(self):
|
||||
assert merge_text_regions([], 256, 256) == []
|
||||
|
||||
def test_far_apart_boxes_stay_separate(self):
|
||||
boxes = [_quad(10, 10, 60, 30), _quad(10, 200, 60, 220)]
|
||||
regions = merge_text_regions(boxes, 256, 256, dilate_frac=0.005, pad_frac=0.0)
|
||||
assert len(regions) == 2
|
||||
|
||||
def test_close_boxes_merge(self):
|
||||
# two boxes on the same line, a few px apart -> one block
|
||||
boxes = [_quad(10, 10, 60, 30), _quad(64, 10, 110, 30)]
|
||||
# dilate_frac sized to close the few-px inter-word gap on one line
|
||||
regions = merge_text_regions(boxes, 256, 256, dilate_frac=0.03)
|
||||
assert len(regions) == 1
|
||||
|
||||
def test_rects_in_bounds_and_padded(self):
|
||||
boxes = [_quad(100, 100, 150, 130)]
|
||||
(x, y, w, h) = merge_text_regions(boxes, 256, 256, pad_frac=0.05)[0]
|
||||
assert x >= 0
|
||||
assert y >= 0
|
||||
assert x + w <= 256
|
||||
assert y + h <= 256
|
||||
assert w > 50 # padded beyond the raw 50px box
|
||||
|
||||
def test_caps_region_count(self):
|
||||
boxes = [_quad(20 * i, 0, 20 * i + 8, 8) for i in range(20)]
|
||||
regions = merge_text_regions(boxes, 64, 512, dilate_frac=0.002, pad_frac=0.0, max_regions=5)
|
||||
assert len(regions) <= 5
|
||||
|
||||
|
||||
class TestFeatherPaste:
|
||||
def test_patch_lands_at_location_center(self):
|
||||
base = np.zeros((100, 100, 3), np.uint8)
|
||||
patch = np.full((40, 40, 3), 200, np.uint8)
|
||||
out = feather_paste(base, patch, 30, 30, feather=6)
|
||||
# center of the pasted region is (near) the patch value
|
||||
assert out[50, 50, 0] >= 190
|
||||
# far corner untouched
|
||||
assert out[2, 2, 0] == 0
|
||||
|
||||
def test_does_not_mutate_base(self):
|
||||
base = np.zeros((50, 50, 3), np.uint8)
|
||||
feather_paste(base, np.full((20, 20, 3), 255, np.uint8), 10, 10)
|
||||
assert base.sum() == 0
|
||||
|
||||
def test_shape_preserved(self):
|
||||
base = np.zeros((50, 60, 3), np.uint8)
|
||||
out = feather_paste(base, np.full((10, 10, 3), 100, np.uint8), 5, 5)
|
||||
assert out.shape == base.shape
|
||||
|
||||
def test_partial_out_of_bounds_no_crash(self):
|
||||
base = np.zeros((40, 40, 3), np.uint8)
|
||||
out = feather_paste(base, np.full((30, 30, 3), 150, np.uint8), 25, 25, feather=4)
|
||||
assert out.shape == (40, 40, 3)
|
||||
|
||||
|
||||
class TestDetectionInputSize:
|
||||
"""Resolution contract for the DB detector input (issue #14 recall fix).
|
||||
|
||||
A fixed small input (the old 736) downscaled large canvases so far that small
|
||||
text fell below the detector's resolution and was missed. Detection now runs
|
||||
at the native long side, capped and never upscaled.
|
||||
"""
|
||||
|
||||
def test_large_canvas_not_downscaled_to_old_736(self):
|
||||
# The #14 regression: a 2048 canvas must detect well above the old 736
|
||||
# so ~12-16 px text survives. Capped at the max long side.
|
||||
in_w, in_h = _detection_input_size(2048, 2048)
|
||||
assert in_w == _DET_MAX_LONG_SIDE
|
||||
assert in_h == _DET_MAX_LONG_SIDE
|
||||
assert in_w > 736 # the old fixed input that missed small text
|
||||
|
||||
def test_native_resolution_not_upscaled(self):
|
||||
# A 1024 canvas detects at native 1024 (not upscaled to the cap, not
|
||||
# downscaled to the old 736).
|
||||
assert _detection_input_size(1024, 1024) == (1024, 1024)
|
||||
|
||||
def test_small_image_is_native(self):
|
||||
assert _detection_input_size(512, 512) == (512, 512)
|
||||
|
||||
def test_dims_are_multiples_of_32(self):
|
||||
for h, w in [(2048, 1024), (1234, 567), (4096, 4096), (1000, 1000)]:
|
||||
in_w, in_h = _detection_input_size(h, w)
|
||||
assert in_w % 32 == 0
|
||||
assert in_h % 32 == 0
|
||||
|
||||
def test_aspect_ratio_preserved_when_capped(self):
|
||||
# Portrait 2048x1024: long side capped to the max, short side scaled by
|
||||
# the same factor (so the 2:1 aspect is roughly kept).
|
||||
in_w, in_h = _detection_input_size(2048, 1024)
|
||||
assert in_h == _DET_MAX_LONG_SIDE
|
||||
assert abs((in_w / in_h) - 0.5) < 0.05
|
||||
|
||||
def test_floor_at_32(self):
|
||||
in_w, in_h = _detection_input_size(10, 5)
|
||||
assert in_w >= 32
|
||||
assert in_h >= 32
|
||||
|
||||
|
||||
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
|
||||
@@ -43,6 +43,16 @@ class TestTilePositions:
|
||||
# 1024 wide, 512 tile, no overlap -> two tiles butting at 512.
|
||||
assert tile_positions(1024, 512, 0) == [0, 512]
|
||||
|
||||
def test_overlap_equal_to_tile_raises(self):
|
||||
# overlap == tile makes the stride denominator (tile - overlap) zero;
|
||||
# reject up front instead of dividing by zero.
|
||||
with pytest.raises(ValueError, match="overlap"):
|
||||
tile_positions(2000, 512, 512)
|
||||
|
||||
def test_overlap_greater_than_tile_raises(self):
|
||||
with pytest.raises(ValueError, match="overlap"):
|
||||
tile_positions(2000, 512, 600)
|
||||
|
||||
|
||||
class TestMakeBlendWeight:
|
||||
def test_zero_overlap_is_all_ones(self):
|
||||
|
||||
@@ -12,12 +12,28 @@ from typing import TYPE_CHECKING
|
||||
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks import trustmark_detector
|
||||
from remove_ai_watermarks.trustmark_detector import detect_trustmark, is_available
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
class _FakeDecoder:
|
||||
"""A TrustMark decoder whose successive ``decode`` calls return scripted
|
||||
``(secret, present, schema)`` tuples -- the first for the original image, the
|
||||
second for the re-encoded copy used by the false-positive durability gate."""
|
||||
|
||||
def __init__(self, *results: tuple[bytes, bool, int]):
|
||||
self._results = list(results)
|
||||
self.calls = 0
|
||||
|
||||
def decode(self, _img: object) -> tuple[bytes, bool, int]:
|
||||
result = self._results[min(self.calls, len(self._results) - 1)]
|
||||
self.calls += 1
|
||||
return result
|
||||
|
||||
|
||||
def test_detect_never_raises(tmp_clean_png: Path):
|
||||
# Whether or not trustmark is installed, a clean image must yield None
|
||||
# (no watermark) without raising. When absent, the import guard returns None.
|
||||
@@ -34,3 +50,40 @@ def test_unreadable_file_returns_none(tmp_path: Path):
|
||||
def test_clean_image_reports_no_watermark(tmp_clean_png: Path):
|
||||
# With the decoder present, an un-watermarked image must report absent.
|
||||
assert detect_trustmark(tmp_clean_png) is None
|
||||
|
||||
|
||||
class TestFalsePositiveGate:
|
||||
"""The re-encode durability gate keeps real (durable) TrustMarks and drops
|
||||
BCH false positives that collapse under a mild JPEG round-trip."""
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _force_available(self, monkeypatch: pytest.MonkeyPatch):
|
||||
monkeypatch.setattr(trustmark_detector, "is_available", lambda: True)
|
||||
|
||||
def _patch_decoder(self, monkeypatch: pytest.MonkeyPatch, decoder: _FakeDecoder) -> None:
|
||||
monkeypatch.setattr(trustmark_detector, "_decoder", lambda: decoder)
|
||||
|
||||
def test_durable_watermark_survives_and_is_reported(self, monkeypatch, tmp_clean_png: Path):
|
||||
decoder = _FakeDecoder((b"secret", True, 2), (b"secret", True, 2))
|
||||
self._patch_decoder(monkeypatch, decoder)
|
||||
result = detect_trustmark(tmp_clean_png)
|
||||
assert result == "Adobe TrustMark (variant P, schema 2)"
|
||||
assert decoder.calls == 2 # original + re-encode
|
||||
|
||||
def test_false_positive_collapsing_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
|
||||
# Present on the original, absent after re-encode -> content-noise FP.
|
||||
decoder = _FakeDecoder((b"\x00\x01", True, 3), (b"", False, -1))
|
||||
self._patch_decoder(monkeypatch, decoder)
|
||||
assert detect_trustmark(tmp_clean_png) is None
|
||||
|
||||
def test_schema_drift_on_reencode_is_dropped(self, monkeypatch, tmp_clean_png: Path):
|
||||
# Present both times but the schema changes -> not a stable watermark.
|
||||
decoder = _FakeDecoder((b"\x00", True, 2), (b"\x00", True, 3))
|
||||
self._patch_decoder(monkeypatch, decoder)
|
||||
assert detect_trustmark(tmp_clean_png) is None
|
||||
|
||||
def test_absent_skips_reencode(self, monkeypatch, tmp_clean_png: Path):
|
||||
decoder = _FakeDecoder((b"", False, -1))
|
||||
self._patch_decoder(monkeypatch, decoder)
|
||||
assert detect_trustmark(tmp_clean_png) is None
|
||||
assert decoder.calls == 1 # no second decode when the first is absent
|
||||
@@ -0,0 +1,71 @@
|
||||
"""Tests for the known-visible-watermark registry (reverse-alpha only)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks import watermark_registry as reg
|
||||
|
||||
DOUBAO_SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doubao-1.png"
|
||||
|
||||
|
||||
class TestCatalog:
|
||||
def test_keys(self):
|
||||
assert reg.mark_keys() == ["gemini", "doubao", "jimeng"]
|
||||
|
||||
def test_all_in_auto(self):
|
||||
assert all(m.in_auto for m in reg.known_marks())
|
||||
|
||||
def test_recovery_is_reverse_alpha(self):
|
||||
# Every catalogued mark is removed by exact reverse-alpha (no inpaint).
|
||||
assert all(m.recovery == "reverse-alpha" for m in reg.known_marks())
|
||||
|
||||
def test_locations(self):
|
||||
by_key = {m.key: m for m in reg.known_marks()}
|
||||
assert by_key["gemini"].location == "bottom-right"
|
||||
assert by_key["doubao"].location == "bottom-right"
|
||||
assert by_key["jimeng"].location == "bottom-right"
|
||||
|
||||
def test_get_mark_unknown_raises(self):
|
||||
with pytest.raises(KeyError):
|
||||
reg.get_mark("nope")
|
||||
|
||||
|
||||
class TestScan:
|
||||
def test_detect_marks_scans_all(self):
|
||||
img = np.zeros((256, 256, 3), np.uint8)
|
||||
keys = {d.key for d in reg.detect_marks(img)}
|
||||
assert keys == {"gemini", "doubao", "jimeng"}
|
||||
|
||||
def test_blank_image_no_auto_mark(self):
|
||||
assert reg.best_auto_mark(np.zeros((256, 256, 3), np.uint8)) is None
|
||||
|
||||
|
||||
@pytest.mark.skipif(not DOUBAO_SAMPLE.exists(), reason="doubao sample not present")
|
||||
class TestRealSample:
|
||||
def test_doubao_sample_wins_auto(self):
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
best = reg.best_auto_mark(imread(DOUBAO_SAMPLE))
|
||||
assert best is not None
|
||||
assert best.key == "doubao"
|
||||
|
||||
def test_doubao_remove_returns_region(self):
|
||||
from remove_ai_watermarks.image_io import imread
|
||||
|
||||
img = imread(DOUBAO_SAMPLE) # 2048 wide -> reverse-alpha applies
|
||||
result, region = reg.get_mark("doubao").remove(img)
|
||||
assert region is not None
|
||||
assert result.shape == img.shape
|
||||
|
||||
|
||||
class TestReverseAlphaOnly:
|
||||
def test_doubao_off_resolution_is_skipped(self):
|
||||
# No alpha capture for this width -> no inpaint fallback, image untouched.
|
||||
img = np.zeros((512, 512, 3), np.uint8)
|
||||
result, region = reg.get_mark("doubao").remove(img)
|
||||
assert region is None
|
||||
assert np.array_equal(result, img)
|
||||
@@ -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.5"
|
||||
version = "0.8.8"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "click" },
|
||||
@@ -2874,7 +2874,6 @@ dependencies = [
|
||||
{ name = "piexif" },
|
||||
{ name = "pillow" },
|
||||
{ name = "python-dotenv" },
|
||||
{ name = "rich" },
|
||||
]
|
||||
|
||||
[package.optional-dependencies]
|
||||
@@ -2892,6 +2891,7 @@ all = [
|
||||
{ name = "pytest-cov" },
|
||||
{ name = "ruff" },
|
||||
{ name = "safetensors" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "torch" },
|
||||
{ name = "transformers" },
|
||||
{ name = "trustmark" },
|
||||
@@ -2913,6 +2913,7 @@ gpu = [
|
||||
{ name = "controlnet-aux" },
|
||||
{ name = "diffusers" },
|
||||
{ name = "safetensors" },
|
||||
{ name = "tokenizers" },
|
||||
{ name = "torch" },
|
||||
{ name = "transformers" },
|
||||
{ name = "ultralytics" },
|
||||
@@ -2946,11 +2947,11 @@ requires-dist = [
|
||||
{ name = "pytest-cov", marker = "extra == 'dev'", specifier = ">=4.1.0" },
|
||||
{ name = "python-dotenv", specifier = ">=1.0.0" },
|
||||
{ name = "remove-ai-watermarks", extras = ["gpu", "detect", "trustmark", "lama", "dev"], marker = "extra == 'all'" },
|
||||
{ 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 +3395,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 +3679,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 +3697,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]]
|
||||
|
||||