Compare commits

...
10 Commits
Author SHA1 Message Date
Victor KuznetsovandClaude Opus 4.8 e7c57e3892 chore(release): v0.8.1 — exclude data/ from sdist
The 0.8.0 PyPI publish uploaded the wheel but the sdist was rejected
(400 File too large): hatchling's default sdist bundled the committed
data/ test corpora (synthid_corpus images + the new visible-mark
captures), pushing it past PyPI's per-project file-size limit. Add a
sdist target that excludes /data, dropping it ~85 MB -> 9.8 MB. The
wheel already ships only src/ and is unaffected.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:57:46 -07:00
Victor KuznetsovandClaude Opus 4.8 315320056b chore(release): v0.8.0
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:25:02 -07:00
Victor KuznetsovandClaude Opus 4.8 e572767555 feat(visible): add Jimeng remover, fix Doubao outline defect, reproducible mask build
Visible-watermark work across all three corner-mark engines plus a committed,
reproducible alpha-build pipeline (scripts/visible_alpha_solve.py) fed by committed
solid black/gray/white captures.

- jimeng: new "即梦AI" wordmark remover (reverse-alpha + thin residual inpaint,
  always NCC-aligned -- the mark re-rasterizes/jitters per image). Detect via glyph
  silhouette NCC (0.45 threshold; does not cross-fire with Doubao). Registered in the
  visible-mark catalog; `visible --mark jimeng` / `--mark auto`.
- doubao: fix a real production defect -- the shipped remover left a READABLE
  "豆包AI生成" outline on real samples while detect() returned conf 0.0 (fooled by a
  thin outline), so the test passed and the "56/56 clean" claim was detector-measured,
  not visual. Root cause: under-estimated alpha + fixed-geometry-no-inpaint + tight
  locate box. Rebuilt alpha (careful gray-self solve), always-align, thin inpaint,
  widened locate box -> readable outline becomes faint texture-level traces.
- gemini: rebuild gemini_bg_{96,48} from our own controlled captures (validated NCC
  0.9998 vs the prior third-party asset); removal re-verified clean, no behaviour change.
- tests: add textured-shift regression to both engines (guards the align-on-shift path
  the Doubao defect exposed; lesson: a detector-only removal test is insufficient,
  assert visual residual).
- docs: CLAUDE.md, README, capture READMEs and docstrings synced; stale
  "exact/pixel-exact/56-clean" claims removed.

Also includes a SynthID label-wording clarification in identify.py/cli.py
("SynthID pixel watermark" -> "SynthID watermark, inferred from C2PA metadata").

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-31 12:20:19 -07:00
Victor KuznetsovandClaude Opus 4.8 5d0e6c3a65 fix: harden metadata parsers and engines; sync docs (full-repo review)
Apply fixes from a full-repo review (code, tests, docs).

Security / correctness:
- Clamp attacker-controlled PNG/caBX chunk lengths to the remaining file
  size in metadata.py and noai/c2pa.py (a malformed length no longer drives
  a multi-GB read); skipped chunks seek instead of read.
- noai/isobmff.strip_c2pa_boxes is now fail-safe on a malformed box: return
  the original bytes with a warning instead of silently truncating the tail,
  so metadata --remove can no longer emit a corrupt file.
- doubao_engine._fixed_alpha_map clamps the glyph box to the image (no crash
  on degenerate width-vs-height).
- watermark_remover._run_region_hires gates the phaseCorrelate offset on
  response and magnitude (a spurious shift no longer garbles text) and drops
  the generator after a CPU fallback (no MPS/CPU device mismatch).

Robustness:
- gemini_engine, doubao_engine, region_eraser normalize grayscale and RGBA
  inputs to BGR at the engine entry points.
- image_io.imwrite returns False on an unwritable path (matches cv2).
- invisible_engine guards a None imread result before use.
- trustmark_detector._decoder uses a double-checked threading lock.
- ctrlregen.tiling.tile_positions raises on overlap >= tile.
- humanizer chromatic shift no longer wraps opposite-edge pixels.
- identify OpenAI caveat keyed on the normalized vendor, not a substring.
- Remove the dead "visible --detect-threshold" CLI option.
- publish.yml verifies the release tag matches the package version.

Docs:
- README strength 0.05 to 0.10; .env.example HF_TOKEN marked optional;
  doubao_capture README updated to reverse-alpha-only; CLAUDE.md synced with
  the new behaviors and the batch command.

Tests: new test_security_clamp.py for the read clamp and isobmff fail-safe;
erase CLI coverage; integrity-clash rule 2 end-to-end; multi-tag EXIF
survival and cross-format strip guards; channel/size, tiling, humanizer, and
imwrite regressions. Full suite 493 passed, 2 skipped; ruff and pyright src/
clean.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 18:00:39 -07:00
Victor KuznetsovandClaude Opus 4.8 5298dcc6a3 chore(release): v0.7.2
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:35:04 -07:00
Victor KuznetsovandClaude Opus 4.8 d88b87ca4e Fix #29 black output: use fp16-fixed SDXL VAE on fp16 GPUs
The stock SDXL VAE overflows to NaN in fp16, so the plain img2img path decodes
to an all-black image on a CUDA/XPU fp16 backend. This is the raiw.cc black
result HitaoLin reported (a 1086x1448 input came back uniformly black). cpu/mps
run fp32 and never hit it, and the differential / region-hires pipeline already
upcasts the VAE itself, so only the plain path on a fp16 GPU was exposed.

`_load_pipeline` now loads `madebyollin/sdxl-vae-fp16-fix` for the default SDXL
checkpoint when running fp16, gated by the pure helper `_needs_fp16_vae_fix`. A
custom non-SDXL model keeps its own VAE.

The decision logic is unit-tested without a download (TestFp16VaeFix). The
black->clean recovery itself needs a CUDA GPU and was not verifiable on this MPS
machine; it must be confirmed on the backend.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:31:51 -07:00
Victor KuznetsovandClaude Opus 4.8 9be66752c5 chore(release): v0.7.1
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 14:21:02 -07:00
Victor KuznetsovandClaude Opus 4.8 69559226d7 Clarify metadata command supports video/audio, drop misfiring format warning (#33)
The `metadata` command handles more than images: `remove_ai_metadata` strips
C2PA / AIGC provenance from MP4/MOV/M4V/M4A and from WebM/MP3/WAV/FLAC/OGG via
ffmpeg. But the help said "from images" and the shared `_validate_image` call
printed "Warning: .mp4 may not be supported" on exactly those supported
containers. The argument's `exists=True` already enforces the file exists, so
the validation call only added the wrong warning here.

Update the docstring to list the real format coverage and drop the
image-only validation from this command. The image commands keep it.

Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
2026-05-30 13:20:04 -07:00
Victor KuznetsovandClaude Opus 4.8 29da3c52b6 Raise default SynthID-removal strength 0.05 → 0.10 (current Google SynthID) (#32)
* Raise default SynthID-removal strength 0.05 -> 0.10 (current Google SynthID)

The old default (0.04/0.05) no longer removes the CURRENT Google SynthID (Nano
Banana / Gemini 3): verified 2026-05-30 via the Gemini 'Verify with SynthID'
oracle on a real image -- 0.05 still detected, 0.10 not detected (OpenAI's was
already cleared at 0.05). Add DEFAULT_STRENGTH=0.10 in watermark_profiles, route
the engine + CLI defaults to it. At 0.10 small text deforms more, which is why
text protection (_run_region_hires) runs by default. CLAUDE.md SynthID note
corrected. CAVEAT: n=1 Google + n=1 OpenAI; broad corpus oracle validation
pending (task tracked).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* Drop unused LOW/MEDIUM/HIGH strength profiles; CLI --strength defaults to DEFAULT_STRENGTH

The fixed strength presets (and get_recommended_strength) were dead -- nothing in
the pipeline used them, only tests. One knob now: DEFAULT_STRENGTH (0.10),
overridable per-call via the CLI --strength flag, which now defaults to that
constant (single source of truth). Removed the WatermarkRemover.LOW/MEDIUM/HIGH
class attrs and the get_recommended_strength tests.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 13:15:58 -07:00
Victor KuznetsovandClaude Opus 4.8 e4f558dccf Add per-region high-resolution text protection (regenerate crisp, scrub everywhere) (#31)
Replace the default text-protection path. Differential Diffusion froze text in
latent space, which left SynthID intact inside text (violating remove-everywhere)
and still softened sub-8px strokes (VAE latent limit). _run_region_hires instead
scrubs the whole image, then re-scrubs each detected text block at high resolution
and feather-composites it back: every pixel is regenerated (watermark removed
everywhere) while small text stays crisp (high-res strokes span >1 latent cell).

merge_text_regions + feather_paste are pure and unit-tested; each re-scrubbed
patch is phase-correlated back to the original crop to null the ~1-2px round-trip
offset. Synthetic 18px multilingual text: text-region SSIM 0.28 -> 0.48, visually
garbled -> readable across Latin/Cyrillic/CJK. Legacy _run_differential /
build_change_map remain but are no longer the default. Prod use still requires
confirming via the SynthID oracle that re-scrubbed text zones read watermark-free.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-30 12:59:29 -07:00
57 changed files with 2032 additions and 204 deletions

No files matched your search

+1 -1
View File
@@ -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=
+10
View File
@@ -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
+9 -5
View File
@@ -36,9 +36,13 @@ yolov8n.pt
.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
+20 -16
View File
@@ -5,15 +5,16 @@ 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>` — known-visible-mark removal, CPU, no GPU. **Reverse-alpha only**: every mark is removed by inverting its captured alpha map (exact pixel recovery, no inpaint). `--mark auto` (default) picks the strongest detected of the Gemini sparkle and the Doubao "豆包AI生成" text strip; `--mark gemini` / `--mark doubao` force one. For arbitrary logos/objects use `erase`.
- `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.
- **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.
- `bash maintain.sh` — uv-outdated, uv-secure, ruff check/fix, ruff format, pyright, pytest -n auto
- **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.
@@ -26,27 +27,29 @@ 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` — `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.
- `identify.py` — `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, IPTC "Made with AI" + IPTC 2025.1 `AISystemUsed`, embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, the China TC260 AIGC label via `metadata.aigc_label`, the HuggingFace `hf-job-id` job marker via `metadata.huggingface_job`, the Samsung Galaxy AI editing marker via `metadata.samsung_genai`, visible Gemini sparkle, open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). The `hf_job`, visible-sparkle, 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) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. Visible-sparkle is promoted only at confidence ≥ `_SPARKLE_THRESHOLD` (0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49). The cv2 dependency lives in `gemini_engine.detect_sparkle_confidence`, not here. **C2PA platform attribution is device-token-first, issuer-scan fallback** (`_device_platform` scans manifest bytes for `_DEVICE_C2PA_PLATFORM` tokens, then `_attribute_platform`/`_ISSUER_PLATFORM`). **Why, verified on real signed files 2026-05-26:** the old issuer-only byte-scan matched ANY issuer substring anywhere, so multi-entity manifests mis-attributed -- Leica→"Truepic" (a signing authority in the trust chain), Nikon→"Adobe Firefly" (XMP-toolkit "Adobe" + the sample's "Adobe_MAX" name), Pixel→"Google (Gemini)" ("Google LLC" cert org), Truepic→"Google". A distinctive device token wins instead. **Token distinctiveness is load-bearing:** bare `b"Truepic"` mis-fires (it appears in unrelated trust chains -- it mis-attributed the OpenAI `chatgpt-1.png` fixture), so the token is the specific `b"Truepic_Lens"` from the Lens SDK claim generator; likewise `b"Pixel Camera"` (cert CN) not bare `b"Pixel"`. `_DEVICE_C2PA_PLATFORM` lists ONLY tokens **verified against a real C2PA file**: Leica (`lc_c2pa`/`Leica Camera`), Nikon (`NIKON`), Pixel (`Pixel Camera` -- from a real Pixel 10 Pro file attached to c2pa-rs issue #1609/#1554), Sony (`sony.sig`/`sony.cert` -- Sony's own C2PA assertion namespace, verified on a real Sony PXW-Z300 file; NOT bare "Sony" which is a common EXIF Make), Truepic (`Truepic_Lens`). Canon/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 BOTH its device cert AND a `trainedAlgorithmicMedia`/genAIType AI marker on a Generative-Edit image, so treating it as a "genuine camera capture" would false-fire integrity-clash rule 2 on every Galaxy AI edit. The signer tokens (`b"Samsung Galaxy"` cert org — distinct from the EXIF `SM-xxxx` model string on ordinary Samsung photos; `b"com.asus.gallery"` claim generator) only resolve the platform label; the AI verdict still comes from the source-type / genAIType. ASUS Gallery is a C2PA-signed edit with no AI marker, so it attributes the platform without asserting `is_ai`. **Samsung's `genAIType` (in the proprietary `PhotoEditor_Re_Edit_Data` JSON) is an undocumented Galaxy-AI editing marker** (`metadata.samsung_genai`, gated on the `PhotoEditor_Re_Edit_Data` container; non-zero value = AI tool used, values {1,5} observed): medium-confidence because the field has no public spec (verified 2026-05-29: absent from C2PA spec + Samsung docs), but it co-occurred with `trainedAlgorithmicMedia` in 3/3 verified files that record a source-type and was the SOLE AI marker on a Galaxy S24 file that omits the source type. 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 Line truncated
- `watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry. **Reverse-alpha only 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)`, exact recovery) — no inpaint/heuristic removal here (arbitrary-region inpainting 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) and `doubao` (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`. `cli.cmd_visible` is registry-driven: `--mark auto` → `best_auto_mark`, `--mark <key>` → that mark; `--mark` choices come from `mark_keys()`. `_doubao_remove` applies reverse-alpha only when the mark is detected AND `reverse_alpha_available` (resolution in the alpha band); 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`.
- `doubao_engine.py` — visible Doubao "豆包AI生成" remover/detector (cv2/numpy, no GPU), **reverse-alpha only**. `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH), `extract_mask` pulls the light low-saturation glyphs (the detection candidate). `detect` is **reverse-alpha-consistent**: it matches the bundled alpha glyph silhouette (`assets/doubao_alpha.png`, the exact shape we invert) 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/structure heuristics) fixed #23: corpus FP fell to 7/1243 (0.6%); old coverage-only fired on ~28%. **Removal is exact reverse-alpha** (`remove_watermark_reverse_alpha`): `original = (wm - a*logo)/(1-a)` from the bundled alpha map + `_ALPHA_LOGO_BGR` (near-white ~253) + `_ALPHA_*_FRAC` geometry. The alpha map + logo were **solved from real black+gray Doubao captures** (`data/doubao_capture/captures/`, gitignored): on black `captured = a*logo`, the black/gray pair solves `a` per-pixel without assuming the logo colour (white capture cross-validates: mark → flat fill). The single captured alpha map (at width 2048) **generalizes to any resolution**: at (near) the captured width (`_ALPHA_NATIVE_BAND` of `_ALPHA_NATIVE_WIDTH`) `_fixed_alpha_map` places it by exact width-relative geometry (pixel-exact recovery, ~0.9 mean error — the whole point of reverse-alpha); off that width it **tries BOTH placements -- fixed geometry AND `_aligned_alpha_map`'s `TM_CCOEFF_NORMED` scale+position search (`_ALPHA_ALIGN_SEARCH`) -- and keeps whichever leaves the least residual mark** (re-`detect` confidence on the bare reverse-alpha). On a faint/busy-background mark the NCC peak wanders a few px and geometry wins; on a clear mark alignment wins -- no magic threshold, it just picks the better removal. Verified **56/56 real detected-Doubao removed clean across all corpus resolutions** (2048 fixed 27/27, 1773 22/22, plus 1185/1187/1535/1672); a single fixed-vs-aligned choice left 2/56 busy-background residuals, try-both fixed them. `reverse_alpha_available` is just "asset present"; the registry still gates removal on `detect` so a clean corner is never touched. **Residual inpaint is off-native-only:** at the captured width the fixed-geometry recovery is exact, so it is returned untouched -- inpainting over exactly-recovered interior pixels only swaps them for a cv2 hallucination (measured worse, native textured-bg error vs true bg **1.6 reverse-alpha-only vs 2.6 with the old always-on full-footprint inpaint**; regression-guarded by `test_native_returns_exact_reverse_alpha_no_inpaint`). Off-native the NCC alignment is only sub-pixel-approximate, so the interior is no longer exact and a residual inpaint over the glyph footprint cleans the seam (costs nothing there and reliably clears the mark). The shipped third-party `_refs/zhengsuanfa_doubao_alpha_120x20.png` is NOT a usable alpha (≈0.85 everywhere → blacks out on inversion; wrong resolution/version), verified 2026-05-29. There is no inpaint-based removal here (removed 2026-05-29; arbitrary-region inpainting is `region_eraser`/`erase`).
- `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`, visible Gemini sparkle, open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). The `hf_job`, visible-sparkle, 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) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. Visible-sparkle is promoted only at confidence ≥ `_SPARKLE_THRESHOLD` (0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49). The cv2 dependency lives in `gemini_engine.detect_sparkle_confidence`, not here. **C2PA platform attribution is device-token-first, issuer-scan fallback** (`_device_platform` scans manifest bytes for `_DEVICE_C2PA_PLATFORM` tokens, then `_attribute_platform`/`_ISSUER_PLATFORM`). **Why, verified on real signed files 2026-05-26:** the old issuer-only byte-scan matched ANY issuer substring anywhere, so multi-entity manifests mis-attributed -- Leica→"Truepic" (a signing authority in the trust chain), Nikon→"Adobe Firefly" (XMP-toolkit "Adobe" + the sample's "Adobe_MAX" name), Pixel→"Google (Gemini)" ("Google LLC" cert org), Truepic→"Google". A distinctive device token wins instead. **Token distinctiveness is load-bearing:** bare `b"Truepic"` mis-fires (it appears in unrelated trust chains -- it mis-attributed the OpenAI `chatgpt-1.png` fixture), so the token is the specific `b"Truepic_Lens"` from the Lens SDK claim generator; likewise `b"Pixel Camera"` (cert CN) not bare `b"Pixel"`. `_DEVICE_C2PA_PLATFORM` lists ONLY tokens **verified against a real C2PA file**: Leica (`lc_c2pa`/`Leica Camera`), Nikon (`NIKON`), Pixel (`Pixel Camera` -- from a real Pixel 10 Pro file attached to c2pa-rs issue #1609/#1554), Sony (`sony.sig`/`sony.cert` -- Sony's own C2PA assertion namespace, verified on a real Sony PXW-Z300 file; NOT bare "Sony" which is a common EXIF Make), Truepic (`Truepic_Lens`). Canon/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 BOTH its device cert AND a `trainedAlgorithmicMedia`/genAIType AI marker on a Generative-Edit image, so treating it as a "genuine camera capture" would false-fire integrity-clash rule 2 on every Galaxy AI edit. The signer tokens (`b"Samsung Galaxy"` cert org — distinct from the EXIF `SM-xxxx` model string on ordinary Samsung photos; `b"com.asus.gallery"` claim generator) only resolve the platform label; the AI verdict still comes from the source-type / genAIType. ASUS Gallery is a C2PA-signed edit with no AI marker, so it attributes the platform without asserting `is_ai`. **Samsung's `genAIType` (in the proprietary `PhotoEditor_Re_Edit_Data` JSON) is an undocumented Galaxy-AI editing marker** (`metadata.samsung_genai`, gated on the `PhotoEditor_Re_Edit_Data` container; non-zero value = AI tool used, values {1,5} observed): medium-confidence because the field has no public spec (verified 2026-05-29: absent from C2PA spec + Samsung docs), but it co-occurred with `trainedAlgorithmicMedia` in 3/3 verified files that record a source-type and was the SOLE AI marker on a Galaxy S24 file that omits the source type. 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 iLine 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 NO inpaint** (`remove_watermark` → `_reverse_alpha_blend`): the sparkle alpha is computed (`alpha = max(R,G,B)/255`) from the bundled sparkle-on-black captures `assets/gemini_bg_{96,48}.png`, which are PURE-BLACK so the alpha is near-exact — re-verified clean on `demo_banana_before.png` 2026-05-31 (the registry's optional `inpaint_residual` is a no-op on a clean 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).** `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. **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). Wired into `watermark_remover._run_differential` via the community `pipeline_stable_diffusion_xl_differential_img2img` (loaded with `custom_revision="0.38.0"` — HF resolves the **PyPI** version string, not the `v0.38.0` git tag); gated to the SDXL `DEFAULT_MODEL_ID` only (`_can_protect_text`), falls back to plain img2img otherwise. **Autonomous by default** (`protect_text=True` in `invisible_engine`/`watermark_remover`, mirroring `protect_faces`): the detector runs per image and `_run_differential` falls back to plain img2img when **no boxes** are found, so text-free inputs pay only the cheap cv2 detection (no differential-pipeline load). CLI exposes a single off-switch `--no-protect-text` on `invisible`/`all` (passed as `protect_text=not no_protect_text`); the unavailable-model case logs at debug, not warning, since it is now the default path. The diff pipeline upcasts the VAE to fp32 internally, so do **not** add `upcast_vae()`/`enable_attention_slicing` (both produced NaN/black on fp16 MPS). `build_change_map` is unit-tested without any model download (`tests/test_text_protector.py`).
- `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 off-switch `--no-protect-text` on `invisible`/`all`. `merge_text_regions` + `feather_paste` are pure, unit-tested without a model (`tests/test_text_protector.py`). **MUST still be confirmed by the SynthID oracle** (openai.com/verify / Gemini app) that a region-rescrubbed text zone reads watermark-free before trusting it in prod. 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
- `image_io.py` — Unicode-safe cv2 IO (issue #17). `imread(path, flags=None)` / `imwrite(path, img)` wrap `np.fromfile`+`cv2.imdecode` / `cv2.imencode`+`tofile` so non-ASCII paths work on Windows -- bare `cv2.imread`/`cv2.imwrite` use the platform ANSI code-page API there and fail (empty decode + `can't open/read file`) on Chinese/Cyrillic/accented filenames. `imread` keeps `cv2.imread` semantics (defaults to `IMREAD_COLOR`, returns `None` on missing/empty/undecodable). **Every cv2 file read/write in the package routes through here; do not call `cv2.imread`/`cv2.imwrite` directly.** macOS/Linux already accept UTF-8 paths, so it is behavior-neutral there (the bug only reproduces on Windows). cv2/numpy are imported lazily inside the functions, so the module is cheap to import in a bare env.
- `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 (re-investigated 2026-05-29)
**RESOLVED 2026-05-29: black+gray Doubao captures were obtained and the exact 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/`, gitignored) confirmed the alpha-composite model: on black `captured = a*logo`, the black/gray pair solves `a` per-pixel (`a_max≈0.65`, logo near-white), and the white capture cross-validates. A single 2048 capture suffices: sub-pixel NCC alignment (`_aligned_alpha_map`) registers the alpha glyph to the real mark, so it works at any resolution (verified on the 1773×2364 3:4 corpus size), not just 2048. The notes below (the failed content-image distillation) are retained as the record of why captures were necessary.
**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.**
@@ -74,10 +77,11 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are
## 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.
- **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`.
- **rich `console.print` parses `[word]` as a style tag and silently drops unknown ones.** A literal bracketed token in a print string disappears: `pip install 'remove-ai-watermarks[gpu]'` rendered as `...remove-ai-watermarks'` (the `[gpu]` extra eaten), which sent users a broken install command (surfaced via #19). Escape the literal bracket as `\[gpu]` (in a normal Python string that is `"\\[gpu]"`) in any rich string carrying user-facing brackets. Regression-guarded by `tests/test_cli.py::TestGpuHintMarkup`.
- Metadata detection for AVIF/HEIF/JPEG-XL relies on a binary scan for `C2PA_UUID` + `IPTC_AI_MARKERS`, plus EXIF `Software` / XMP `CreatorTool` generator tags via `metadata.exif_generator` (validated with synthesized AVIF/JPEG fixtures + an XMP raw-scan fixture). C2PA removal in those containers is implemented via `noai/isobmff.py` (top-level ``uuid`` / ``jumb`` box stripper, no re-encoding), which now also drops a top-level XMP ``uuid`` box that carries an AI label (matched by AI-marker content, not by the XMP UUID, so byte-order-robust) and covers MP4/MOV/M4V/M4A by content sniff. **Non-ISOBMFF audio/video removal is via ffmpeg** (`_FFMPEG_STRIP_EXTS` -> `_strip_with_ffmpeg`): WebM/Matroska (EBML), MP3 (ID3), WAV/FLAC/OGG (RIFF/Vorbis) are stripped losslessly with `ffmpeg -map_metadata -1 -map_chapters -1 -c copy` (codec data untouched). Requires ffmpeg on PATH; raises `RuntimeError` if absent or if ffmpeg can't parse the file. Verified end-to-end (a real ffmpeg-made WAV/MP3 with a `title=Suno AI` tag -> tag gone, audio bytes preserved). **Meta-box XMP now handled (`isobmff.blank_ai_xmp_packets`, v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (AVIF/HEIF) is blanked in place (overwritten with spaces of the same length, so `iloc` offsets and the coded image stay valid). **Still NOT built:** an `Exif` *item* inside the `meta` box (rare -- AI labels are XMP) needs full `iinf`/`iloc` surgery (offset rewrite) with corruption risk -- exiftool (R/W/C for HEIC/AVIF EXIF+XMP, verified on exiftool.org 2026-05-27) would do it but is a non-installed binary dep, so it stays a documented gap. **Audio watermark DETECTION (Resemble PerTh) was evaluated and NOT built (2026-05-26):** `resemble-perth`'s `PerthImplicitWatermarker.get_watermark()` returns a raw bit-array with **no presence/confidence flag** (clean audio decodes to arbitrary bits too), so reliably distinguishing watermarked-from-clean needs either Resemble's fixed payload or a confidence API -- neither is public, and there's no real Resemble sample to calibrate against. Same wall-class as the SynthID pixel detector: the decode exists, reliable presence-detection does not. (perth's top-level `PerthImplicitWatermarker` is also gated to None unless `librosa` is importable.)
- **SynthID detection is metadata-only.** There is no reliable *local* detector of the SynthID *pixel* watermark — Google's decoder is proprietary, no public spec or API (only a waitlisted portal). Authoritative confirmation: Google DeepMind's own paper "SynthID-Image: Image watermarking at internet scale" (Gowal et al., arXiv:2510.09263) states the verification service is restricted to "trusted testers" and does not release detector weights or a reproducible algorithm — so a local pixel detector is infeasible by design, not just unbuilt. https://arxiv.org/abs/2510.09263 We detect SynthID by its C2PA companion (`synthid_source` / `SYNTHID_C2PA_ISSUERS`), which is reliable while the manifest is intact but says nothing once C2PA is stripped. **Surface-dependent blind spot (verified 2026-05-24):** the same Google model emits different metadata per surface -- the Gemini *app* wraps outputs in Google C2PA, but the *API/playground* (AI Studio, Nano Banana / gemini-2.5-flash-image) emits the SynthID *pixel* watermark (confirmed via the Gemini-app oracle) + the visible sparkle but **no C2PA/IPTC at all**, so `synthid_source` returns None despite SynthID being present. Only the pixel oracle or the visible-sparkle detector catches those. (Meta AI is another surface mismatch: it writes the IPTC `digitalSourceType=trainedAlgorithmicMedia` marker, not C2PA and not SynthID.) Google→SynthID is long-standing; OpenAI→SynthID is confirmed by OpenAI's Help Center (ChatGPT/Codex/API "include both C2PA metadata and SynthID watermarks", updated 2026-05-21) but time-gated (pre-rollout OpenAI images carry C2PA without SynthID), so the OpenAI verdict is hedged "likely". Oracles: Gemini app "Verify with SynthID" (Google), openai.com/verify (OpenAI). The spectral phase-coherence approach from `github.com/aloshdenny/reverse-SynthID` was evaluated (May 2026) and **does not work for real-content detection**: on its own shipped codebook + validation set, watermarked and cleaned images were indistinguishable (conf within noise, cleaned often higher); it only fires on pure-black 1024x1024 reference images at exact resolution (the controlled case it was calibrated on). The README's "90% / conf=0.91" reproduces only in that lab condition. Do not build a production detector on it; if revisited, it is experimental/diagnostic only and needs a per-resolution, per-model reference corpus. A from-scratch gpt-image pilot (2026-05-24) confirmed this independently: 5 independent solid-black gpt-image outputs share a near-identical fixed signature (pairwise residual correlation **0.92**, avg-template retains 97% energy), so the watermark/carrier IS strongly present and consistent on flat content — but the carrier frequencies extracted from it do NOT discriminate real content (carrier-to-random ratio: cleaned 1.86 > watermarked 1.53; a non-gpt-image image scored highest at 3.67). The signature drowns in content texture. Net: a perfectly consistent solid-color signature still yields no real-content pixel detector with magnitude/carrier methods. A corpus discrimination test (2026-05-24, `scripts/synthid_pixel_probe.py`, raw zero-mean residual NCC) independently re-confirms this: at matched resolution, SynthID positives do NOT cluster apart from negatives (within-Gemini 0.07; at 1024 px pos-vs-neg >= pos-vs-pos). The only high correlations were near-duplicate *content* (5 ChatGPT renders of one prompt at ~0.92, while a distinct ChatGPT image scored ~0 against them) — content, not a carrier. The probe is solid-fills-only and EXPERIMENTAL/DIAGNOSTIC; do not use it on real content. **Correction (deeper re-examination 2026-05-25):** the carrier IS real on solid fills — the earlier "no carrier" was a *method* artifact of using spatial / FFT-magnitude NCC, which can't see it. The carrier is a fixed *phase* at specific low frequencies, so the right metric is **per-bin phase coherence**. On 8 white `gemini-2.5-flash-image` fills (generated via the reverse-SynthID trick: identity-edit prompt "Recreate this image exactly as it is" on a synthetic pure-white PNG — this bypasses the recitation block that rejects text prompts for pure colors), phase coherence at the white carriers `(0,±7..±12,±20..±23)` = **0.86** vs **0.31** random; single-image leave-one-out phase-match **+0.83** vs real photos **-0.24**. (Black `2.5-flash` fills clip to std≈0 — SynthID can't push values below 0, so no carrier in black; the repo's dark carriers come from nano-banana-pro.) **But it does not generalize:** (a) carriers are model-version + resolution + color specific — the repo's v4 codebook (built for `gemini-3.1-flash-image-preview` + `nano-banana-pro-preview`) scores ~0.527 on my 2.5-flash white fills, indistinguishable from negatives (~0.50), i.e. carriers shift across model versions and need a per-model codebook; (b) on real content (30 `2.5-flash` images) the carrier collapses — set phase coherence at carriers 0.37 ≈ random 0.42, and the repo's v4 deteLine truncated
- **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. 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" 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
- **SynthID v2 vs default pipeline:** **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. **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.05 defeats SynthID v2) held for the specific May-2026 Gemini output tested then but is stale for current Google SynthID. **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
+24 -10
View File
@@ -17,7 +17,7 @@ If this tool saves you time, consider [sponsoring its development](https://githu
## Features
- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle and the Doubao "豆包AI生成" text strip. Each is removed by **exact reverse-alpha blending** against a captured alpha map (`original = (wm − α·logo)/(1−α)`), recovering the true pixels rather than inpainting a guess. Fast, offline, no GPU. `visible --mark auto` finds and removes the strongest detected mark. (For arbitrary logos/objects, see `erase`.)
- **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; 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, **MP4 / MOV / M4V / M4A** at the container level, and **WebM / MP3 / WAV / FLAC / OGG** losslessly via ffmpeg), XMP DigitalSourceType
@@ -49,13 +49,14 @@ 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 AIGC label (`<TC260:AIGC>` XMP, `AIGC` PNG chunk, or EXIF JSON) **+ C2PA** signed by ByteDance Volcano Engine (`volcengine`) | Exact reverse-alpha (captured α map): pixel-exact at native width, NCC-aligned at other resolutions, + 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 (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 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.
@@ -81,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. It is a fixed semi-transparent white overlay, so — like the Gemini sparkle — it is removed by **exact reverse-alpha blending**: `original = (watermarked - α·logo) / (1 - α)`, recovering the true pixels instead of hallucinating them. The α map and logo colour were solved from controlled black + gray captures (on black, `captured = α·logo`; the black/gray pair solves α per-pixel). At the captured width the placement is exact, so the recovery is returned untouched (inpainting over exactly-recovered pixels only degrades them). The single capture generalizes to any resolution: off the captured width an NCC scale-and-position search registers the α template to the actual mark, and a light residual inpaint cleans the sub-pixel seam there. Detection is consistent with removal: it matches the same alpha glyph silhouette against the corner (normalized correlation), so it keys on the actual "豆包AI生成" shape, not on textured corners.
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.05s, no GPU needed. Reverse-alpha at the captured resolution recovers the true background pixels exactly.
**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
@@ -98,19 +108,23 @@ 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 at strength 0.10)
→ 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.
> **If SynthID still verifies after the run, raise `--strength`.** The default `0.10` is the value that clears the watermark today, but SynthID is a moving target: both Google and OpenAI tighten it over time, and a larger image carries a stronger watermark (a 1600x1600 image needs more than a 400x400 one). There is no single permanent number, and there is no local SynthID detector, so the tool cannot self-check and auto-tune. The rule is simple: if the verifier still reads SynthID, step the strength up — try `--strength 0.12`, then `0.15`. Higher strength changes more detail and text, so use the lowest value that comes back clean on the oracle ([openai.com/research/verify](https://openai.com/research/verify/) or the Gemini app's "Verify with SynthID").
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.
> **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.
**Face Protection**: before diffusion, YOLO detects people in the image and extracts them. After diffusion, the original faces are blended back with a soft elliptical mask to prevent AI distortion of facial features.
**Analog Humanizer**: optional film grain and chromatic aberration injection that mimics a photo of a screen, raising the bar for AI-generated image classifiers. (It frustrates generic classifiers but does not guarantee forensic invisibility — see the [arXiv:2605.09203](https://arxiv.org/abs/2605.09203) note above.)
**Text Protection** (automatic): SDXL img2img regenerates every pixel, so small text and glyphs get deformed at the strengths that defeat SynthID. The SDXL pipeline guards against this by default: a PP-OCRv3 text detector (a 2.4 MB ONNX model run on CPU via OpenCV's DNN module, downloaded and cached on first use) locates text regions, and if any are found the pass switches to Differential Diffusion so a per-pixel change map keeps the text regions largely intact while the background is regenerated normally. Detection is **language-agnostic** (it finds text regions, not characters), so Latin, Cyrillic, CJK, Hangul, Arabic, and digits are all protected, and it runs at the image's native resolution so small text on large images is not missed. Text-free images run the standard pass at no extra cost. Pass `--no-protect-text` to turn it off. SDXL default pipeline only.
**Text Protection** (automatic): SDXL img2img regenerates every pixel, so small text and glyphs get deformed at the strengths that defeat SynthID. The SDXL pipeline guards against this by default: a PP-OCRv3 text detector (a 2.4 MB ONNX model run on CPU via OpenCV's DNN module, downloaded and cached on first use) locates text regions, and if any are found the whole image is scrubbed normally and then each text block is **re-scrubbed at high resolution** and composited back. Because the text is regenerated (not copied), the watermark is still removed inside it, while running the text region at higher resolution keeps small strokes crisp through the regeneration. Detection is **language-agnostic** (it finds text regions, not characters), so Latin, Cyrillic, CJK, Hangul, Arabic, and digits are all protected, and it runs at the image's native resolution so small text on large images is not missed. Text-free images run the standard pass at no extra cost. Pass `--no-protect-text` to turn it off. SDXL default pipeline only.
### Stripping C2PA, EXIF, and "Made with AI" metadata
@@ -240,8 +254,8 @@ remove-ai-watermarks batch ./images/ --mode all
remove-ai-watermarks identify image.png
# Visible watermark only — fast, offline, CPU. --mark auto (default) finds the
# strongest known mark (Gemini sparkle / Doubao "豆包AI生成" text); force one
# with --mark gemini / doubao. Removed by exact reverse-alpha (true-pixel recovery).
# 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.
+11 -2
View File
@@ -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
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 980 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 MiB

+45
View File
@@ -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
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.7 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 715 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

+75
View File
@@ -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
```
Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 373 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 MiB

+8 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.7.0"
version = "0.8.1"
description = "Remove visible and invisible AI watermarks from images (Gemini / Nano Banana, ChatGPT, Stable Diffusion)"
readme = "README.md"
requires-python = ">=3.10"
@@ -118,6 +118,13 @@ 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"]
+247
View File
@@ -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 -1
View File
@@ -1,3 +1,3 @@
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
__version__ = "0.7.0"
__version__ = "0.8.1"
Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 9.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.6 KiB

After

Width:  |  Height:  |  Size: 2.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.0 KiB

After

Width:  |  Height:  |  Size: 8.4 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

+15 -8
View File
@@ -21,6 +21,7 @@ from rich.progress import BarColumn, Progress, SpinnerColumn, TextColumn, TimeEl
from rich.table import Table
from remove_ai_watermarks import __version__, watermark_registry
from remove_ai_watermarks.noai.watermark_profiles import DEFAULT_STRENGTH
if TYPE_CHECKING:
from numpy.typing import NDArray
@@ -159,7 +160,6 @@ def main(ctx: click.Context, verbose: bool) -> None:
)
@click.option("--inpaint-strength", type=float, default=0.85, help="Inpainting blend strength (0.0-1.0).")
@click.option("--detect/--no-detect", default=True, help="Detect watermark before removal.")
@click.option("--detect-threshold", type=float, default=0.25, help="Detection confidence threshold.")
@click.option(
"--mark",
type=click.Choice(["auto", *watermark_registry.mark_keys()]),
@@ -177,7 +177,6 @@ def cmd_visible(
inpaint_method: Literal["ns", "telea", "gaussian"],
inpaint_strength: float,
detect: bool,
detect_threshold: float,
mark: str,
strip_metadata: bool,
) -> None:
@@ -362,7 +361,7 @@ def cmd_erase(
@click.option(
"-o", "--output", type=click.Path(path_type=Path), default=None, help="Output path (default: <source>_clean.<ext>)."
)
@click.option("--strength", type=float, default=0.05, help="Denoising strength (0.0-1.0). Default: 0.05.")
@click.option("--strength", type=float, default=DEFAULT_STRENGTH, help="Denoising strength (0.0-1.0). Default: 0.10.")
@click.option("--steps", type=int, default=50, help="Number of denoising steps. Default: 50.")
@click.option(
"--pipeline",
@@ -482,14 +481,20 @@ def cmd_metadata(
output: Path | None,
keep_standard: bool,
) -> None:
"""Check or remove AI-generation metadata from images.
"""Check or remove AI-generation metadata (images, video, and audio).
Strips EXIF AI tags, PNG text chunks, and C2PA provenance manifests.
Strips EXIF AI tags, PNG text chunks, C2PA provenance manifests, and the
China TC260 AIGC label. Beyond images (PNG/JPEG/WebP/AVIF/HEIF/JXL) it also
strips provenance metadata from MP4/MOV/M4V/M4A containers and, via ffmpeg,
from WebM/MP3/WAV/FLAC/OGG. The coded image, audio, and video data are left
untouched.
"""
from remove_ai_watermarks.metadata import get_ai_metadata, has_ai_metadata, remove_ai_metadata
# No _validate_image() here: unlike the image-only commands, metadata also
# accepts video/audio containers, so the image-format warning would misfire.
# click's `exists=True` on the argument already enforces the file exists.
_banner()
source = _validate_image(source)
if check or (not remove):
has_ai = has_ai_metadata(source)
@@ -497,7 +502,7 @@ def cmd_metadata(
console.print(f" [yellow]⚠[/] AI metadata detected in {source.name}:")
meta = get_ai_metadata(source)
if synthid := meta.get("synthid_watermark"):
console.print(f" [bold yellow]⚠ SynthID pixel watermark {synthid}[/]")
console.print(f" [bold yellow]⚠ SynthID watermark (inferred from C2PA metadata) {synthid}[/]")
table = Table(show_header=True, header_style="bold")
table.add_column("Key", style="cyan")
table.add_column("Value")
@@ -593,7 +598,9 @@ def cmd_identify(ctx: click.Context, source: Path, no_visible: bool, as_json: bo
@click.option(
"--inpaint-method", type=click.Choice(["ns", "telea", "gaussian"]), default="ns", help="Inpainting method."
)
@click.option("--strength", type=float, default=0.05, help="Invisible watermark denoising strength (0.0-1.0).")
@click.option(
"--strength", type=float, default=DEFAULT_STRENGTH, help="Invisible watermark denoising strength. Default: 0.10."
)
@click.option("--steps", type=int, default=50, help="Number of denoising steps for invisible removal.")
@click.option(
"--pipeline",
+68 -52
View File
@@ -42,13 +42,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).
@@ -73,34 +75,37 @@ TOPHAT_DELTA = 12 # glyph must exceed the local background by this many levels
DETECT_MIN_COVERAGE = 0.04
DETECT_NCC_THRESHOLD = 0.4
# ── Reverse-alpha (exact recovery, Gemini-style) ─────────────────────
# ── 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 exactly: original = (wm - a*logo)/(1-a).
# The alpha map + logo colour were solved from black+gray Doubao captures on a
# controlled background (data/doubao_capture/): on black, captured = a*logo, and
# the black/gray pair solves a per-pixel WITHOUT assuming the logo colour. The
# bundled asset (assets/doubao_alpha.png) is the alpha template (a*255) at the
# captured width. The mark scales with image WIDTH, but a pure width-scale is
# only sub-pixel-accurate at the captured width and ghosts elsewhere, so removal
# does NOT trust fixed geometry: `_aligned_alpha_map` registers the template to
# the actual mark by a TM_CCOEFF_NORMED scale+position search, which makes the
# single capture work at any resolution (verified clean on 1773x2364). Verified
# 2026-05-29: white-capture cross-check -> mark vanishes to a flat fill; clean on
# doubao-1.png (2048) and the 3:4 portrait corpus size.
# 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] = (252.0, 255.0, 255.0)
_ALPHA_WIDTH_FRAC = 0.1572 # glyph width / image width -- the alignment scale seed
_ALPHA_HEIGHT_FRAC = 0.0347
_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.0166
_ALPHA_MARGIN_BOTTOM_FRAC = 0.0195
_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, 13)
# At (near) the captured width the fixed geometry is pixel-exact, so we use it
# directly there -- NCC alignment is integer-pixel and would land ~1px off,
# degrading the otherwise-exact native recovery. Off this band, alignment wins.
_ALPHA_NATIVE_BAND = 0.03
_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
@@ -222,7 +227,14 @@ class DoubaoEngine:
"""
h, w = image.shape[:2]
x, y, bw, bh = loc.bbox
roi = image[y : y + bh, x : x + bw].astype(np.float32)
# 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)
@@ -290,7 +302,12 @@ class DoubaoEngine:
if at is None:
return None
h, w = image.shape[:2]
gw, gh = max(1, int(_ALPHA_WIDTH_FRAC * w)), max(1, int(_ALPHA_HEIGHT_FRAC * w))
# 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)
@@ -336,27 +353,25 @@ class DoubaoEngine:
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)``.
``original = (wm - a*logo)/(1-a)``, then clear the residual edges with a
thin inpaint over the glyph footprint.
Placement: at (near) the captured width the fixed geometry is pixel-exact,
so the recovery is returned UNTOUCHED -- inpainting over exactly-recovered
interior pixels only swaps them for a cv2 hallucination (measured worse on
textured backgrounds: native error vs true bg 1.6 reverse-alpha-only vs
2.6 with full-footprint inpaint). Off-native, NCC alignment registers the
template to the real mark; the alignment is only sub-pixel-approximate, so
the interior recovery is no longer exact and the seam can re-trip the
detector. There we try BOTH placements and keep whichever leaves the least
residual mark (on a faint/busy-background mark the NCC peak can wander a
few px, where geometry wins; on a clear mark alignment wins) -- no magic
threshold, it just picks the better removal -- then a residual inpaint over
the glyph footprint cleans the seam (the interior is approximate anyway, so
inpaint there costs nothing and reliably clears the mark).
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.
"""
at_native = abs(image.shape[1] / _ALPHA_NATIVE_WIDTH - 1.0) <= _ALPHA_NATIVE_BAND
if at_native:
amap = self._fixed_alpha_map(image)
return self._apply_reverse_alpha(image, amap[0]) if amap is not None else image.copy()
# 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)
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()
@@ -371,8 +386,9 @@ class DoubaoEngine:
if best_out is None or best_amap is None: # pragma: no cover - maps is non-empty
return image.copy()
if residual_inpaint:
rm = cv2.dilate((best_amap > 0.10).astype(np.uint8) * 255, np.ones((3, 3), np.uint8))
best_out = cv2.inpaint(best_out, rm, 3, cv2.INPAINT_TELEA)
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
+9 -4
View File
@@ -329,16 +329,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(
+5 -1
View File
@@ -36,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))
+2 -2
View File
@@ -429,9 +429,9 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
# 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
+6 -2
View File
@@ -54,7 +54,8 @@ def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
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.
rejects the image or the path cannot be written (matching ``cv2.imwrite``,
which returns ``False`` rather than raising on an unwritable path).
"""
import cv2
@@ -62,5 +63,8 @@ def imwrite(path: str | Path, img: NDArray[Any]) -> bool:
ok, buf = cv2.imencode(ext, img)
if not ok:
return False
buf.tofile(str(path))
try:
buf.tofile(str(path))
except OSError:
return False
return True
+7 -3
View File
@@ -19,6 +19,8 @@ import warnings
from pathlib import Path
from typing import TYPE_CHECKING
from remove_ai_watermarks.noai.watermark_profiles import DEFAULT_STRENGTH
if TYPE_CHECKING:
from collections.abc import Callable
@@ -71,7 +73,7 @@ class InvisibleEngine:
"""
# SDXL base is the default since May 2026: empirically defeats SynthID v2
# at strength=0.05 / steps=50 / native ~1024px. See CLAUDE.md "Known
# at strength=0.10 / steps=50 / native ~1024px. See CLAUDE.md "Known
# limitations" for the regression evidence ruling out SD-1.5 pipelines.
DEFAULT_MODEL_ID = "stabilityai/stable-diffusion-xl-base-1.0"
CTRLREGEN_MODEL_ID = "yepengliu/ctrlregen"
@@ -133,7 +135,7 @@ class InvisibleEngine:
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 -> DEFAULT_STRENGTH (0.10).
steps: Number of denoising steps.
guidance_scale: Classifier-free guidance scale.
seed: Random seed for reproducibility.
@@ -225,6 +227,8 @@ class InvisibleEngine:
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:
@@ -277,7 +281,7 @@ class InvisibleEngine:
self,
input_dir: Path,
output_dir: Path,
strength: float = 0.04,
strength: float = DEFAULT_STRENGTH,
steps: int = 50,
) -> list[Path]:
"""Remove invisible watermarks from all images in a directory."""
+401
View File
@@ -0,0 +1,401 @@
"""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
# 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)
# 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
+6 -1
View File
@@ -190,6 +190,8 @@ def _png_late_metadata(image_path: Path, window: int) -> bytes:
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)
@@ -201,9 +203,12 @@ def _png_late_metadata(image_path: Path, window: int) -> bytes:
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(length)
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)
+24 -6
View File
@@ -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
@@ -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)))
+21
View File
@@ -17,6 +17,7 @@ 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
@@ -32,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).
@@ -126,6 +129,8 @@ def scan_c2pa_region(path: str | Path, *, max_total: int = 4 * 1024 * 1024) -> b
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)
@@ -162,7 +167,9 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
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
@@ -174,6 +181,20 @@ 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
@@ -1,4 +1,4 @@
"""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.
"""
@@ -8,12 +8,18 @@ from __future__ import annotations
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
_HIGH_PERTURBATION = ("stegasamp", "stegastamp", "treering", "ringid")
_LOW_PERTURBATION = ("stablesignature", "dwtectsvd", "rivagan", "ssl", "hidden")
# Single default denoising strength for the SDXL img2img scrub, overridable from
# the CLI (`--strength`). Raised from the old 0.04/0.05 because that no longer
# removes the CURRENT Google SynthID (Nano Banana / Gemini 3): verified 2026-05-30
# via the Gemini "Verify with SynthID" oracle on a real image -- 0.05 still
# detected, 0.10 not detected (OpenAI's SynthID was already cleared at 0.05). 0.10
# keeps the visible change modest while removing both. CAVEAT: confirmed on n=1
# Google + n=1 OpenAI image; broad oracle validation across the corpus is pending
# (different images may need a different strength). At this higher strength small
# text deforms more -- which is exactly why text protection (`_run_region_hires`)
# runs by default. (Fixed LOW/MEDIUM/HIGH presets were removed -- unused; the one
# knob is this default plus the per-call `--strength` override.)
DEFAULT_STRENGTH = 0.10
def get_model_id_for_profile(profile: str) -> str:
@@ -31,21 +37,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
@@ -31,9 +31,7 @@ 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,
)
@@ -63,6 +61,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"
@@ -256,6 +272,12 @@ def _make_seed_generator(device: str, seed: int) -> Any:
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
@@ -277,9 +299,7 @@ class WatermarkRemover:
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,
@@ -374,6 +394,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,
@@ -446,7 +474,7 @@ class WatermarkRemover:
if output_path is None:
output_path = image_path
strength = strength or self.LOW_STRENGTH
strength = strength or self.DEFAULT_STRENGTH
if not 0.0 <= strength <= 1.0:
raise ValueError(f"Strength must be between 0.0 and 1.0, got {strength}")
@@ -481,7 +509,7 @@ class WatermarkRemover:
generator,
)
elif protect_text and self._can_protect_text():
cleaned_image = self._run_differential(
cleaned_image = self._run_region_hires(
init_image,
strength,
num_inference_steps,
@@ -621,6 +649,103 @@ class WatermarkRemover:
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,
@@ -769,7 +894,7 @@ class WatermarkRemover:
def remove_watermark(
image_path: Path,
output_path: Path | None = None,
strength: float = 0.04,
strength: float = DEFAULT_STRENGTH,
model_id: str | None = None,
device: str | None = None,
hf_token: str | None = None,
+9 -1
View File
@@ -67,8 +67,16 @@ def erase_cv2(
method: Literal["telea", "ns"] = "telea",
radius: int = 6,
) -> NDArray[Any]:
"""Inpaint ``mask`` with classical cv2 inpainting (CPU, no extra deps)."""
"""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)
@@ -151,6 +151,87 @@ def build_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."""
@@ -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,9 +48,11 @@ 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
+35 -6
View File
@@ -5,18 +5,22 @@ A single catalog that ties each known visible mark to (a) where it usually sits,
registry detects every known mark in its usual place and removes the ones
present.
**Reverse-alpha only.** A known mark is a fixed semi-transparent overlay, so it
**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)``) -- exact recovery of the true pixels, not an
inpaint guess. 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
(``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;
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
@@ -106,6 +110,10 @@ def _engine(key: str) -> Any:
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]
@@ -162,11 +170,32 @@ def _doubao_remove(
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
),
)
+75
View File
@@ -541,3 +541,78 @@ class TestGpuHintMarkup:
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()
+62 -12
View File
@@ -133,31 +133,81 @@ class TestReverseAlpha:
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_native_returns_exact_reverse_alpha_no_inpaint(self):
"""At native width the recovery is exact, so it must be returned untouched
-- inpainting over exactly-recovered interior pixels degrades quality
(regression: native textured error 1.6 reverse-alpha-only vs 2.6 with the
old full-footprint inpaint). The output must equal pure reverse-alpha."""
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()
wm, _mark = self._compose(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH)
assert eng.detect(wm).detected
out = eng.remove_watermark_reverse_alpha(wm)
amap = eng._fixed_alpha_map(wm)
assert amap is not None
expected = eng._apply_reverse_alpha(wm, amap[0])
assert np.array_equal(out, expected) # no inpaint touched the recovery
assert not eng.detect(out).detected
@pytest.mark.parametrize(
("w", "h", "max_err"),
[
(_ALPHA_NATIVE_WIDTH, _ALPHA_NATIVE_WIDTH, 5.0), # native 1:1 -> fixed geometry, ~exact
(_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 native (fixed geometry, exact) AND a
non-native resolution (NCC alignment generalizes the single capture)."""
"""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)
+20
View File
@@ -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
+63
View File
@@ -389,6 +389,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).
@@ -657,6 +707,19 @@ class TestIntegrityClashEndToEnd:
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)
+5
View File
@@ -72,3 +72,8 @@ class TestFailureSemantics:
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
+191
View File
@@ -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)
+60
View File
@@ -445,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."""
@@ -617,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)."""
+5 -2
View File
@@ -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:
+25 -28
View File
@@ -15,7 +15,6 @@ from remove_ai_watermarks.noai.utils import get_image_format, is_supported_forma
from remove_ai_watermarks.noai.watermark_profiles import (
detect_model_profile,
get_model_id_for_profile,
get_recommended_strength,
)
from remove_ai_watermarks.noai.watermark_remover import get_device, is_watermark_removal_available
@@ -121,33 +120,6 @@ 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
def test_recommended_strength_medium(self):
assert get_recommended_strength("unknown_type") == 0.35
@pytest.mark.parametrize("wm_type", ["stegastamp", "stegasamp", "treering", "ringid"])
def test_high_perturbation_watermark_types(self, wm_type):
"""Robust spatial watermarks need aggressive (0.7) regeneration."""
assert get_recommended_strength(wm_type) == 0.7
@pytest.mark.parametrize("wm_type", ["stablesignature", "dwtectsvd", "rivagan", "ssl", "hidden"])
def test_low_perturbation_watermark_types(self, wm_type):
"""Fragile frequency/latent watermarks break at low (0.04) strength."""
assert get_recommended_strength(wm_type) == 0.04
def test_strength_match_is_case_insensitive(self):
assert get_recommended_strength("TreeRing") == 0.7
assert get_recommended_strength("StableSignature") == 0.04
def test_strength_matches_substring_in_descriptive_name(self):
# e.g. a CLI passing "treering_v2" or "synthid-stegastamp" still maps.
assert get_recommended_strength("treering_v2") == 0.7
# ── Format utilities ────────────────────────────────────────────────
@@ -232,3 +204,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
+17
View File
@@ -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)
+130
View File
@@ -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)
+69 -1
View File
@@ -11,7 +11,75 @@ 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
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:
+10
View File
@@ -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):
+3 -2
View File
@@ -14,7 +14,7 @@ DOUBAO_SAMPLE = Path(__file__).resolve().parents[1] / "data" / "samples" / "doub
class TestCatalog:
def test_keys(self):
assert reg.mark_keys() == ["gemini", "doubao"]
assert reg.mark_keys() == ["gemini", "doubao", "jimeng"]
def test_all_in_auto(self):
assert all(m.in_auto for m in reg.known_marks())
@@ -27,6 +27,7 @@ class TestCatalog:
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):
@@ -37,7 +38,7 @@ 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"}
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
Generated
+1 -1
View File
@@ -2865,7 +2865,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.7.0"
version = "0.8.1"
source = { editable = "." }
dependencies = [
{ name = "click" },