diff --git a/.gitignore b/.gitignore index 523ddb7..4abc924 100644 --- a/.gitignore +++ b/.gitignore @@ -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 diff --git a/CLAUDE.md b/CLAUDE.md index c2aebf5..d08cde5 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -5,7 +5,7 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r ## How to run - `uv run remove-ai-watermarks all -o ` -- `uv run remove-ai-watermarks visible -o ` — 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 -o ` — 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 --region x,y,w,h -o ` — 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 ` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip the cv2 sparkle detector - `uv run remove-ai-watermarks metadata --check` — inspect AI metadata (C2PA, EXIF, PNG chunks) @@ -35,9 +35,10 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r - `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: ` + 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 is `is_ai`-gated** (`_attribute_platform(issuers, is_ai=c2pa_is_ai)`): a specific AI-generator platform is named only when the digital-source-type is `trainedAlgorithmicMedia`; on a non-AI source an issuer substring is treated as incidental (an "Adobe XMP" toolkit string in an *unmapped* Canon/Sony capture would otherwise mislabel it "Adobe Firefly"), so it degrades to the neutral "C2PA signer: X" label. Real Firefly/OpenAI/Google output carries the AI source-type, so it is unaffected (verified: chatgpt-1.png→OpenAI, firefly-1.png→Adobe Firefly still attribute). `_attribute_platform` defaults `is_ai=True` so the mapping stays unit-testable in isolation. Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM`, editing-app/AI-device signer tokens to `_SIGNER_C2PA_PLATFORM`, generator/issuer platforms to `_ISSUER_PLATFORM`, not inline. For non-PNG containers (JPEG/WebP/AVIF/HEIF/JXL) the caBX parser returns nothing, so issuer (`_issuers_in`) and generator (`_ai_tools_in`, reusing `C2PA_AI_TOOLS`) are recovered by binary-scanning the first MB. EXIF `Software` / `Make` / `Artist` / `ImageDescription` and XMP `CreatorTool` generator tags are read by `metadata.exif_generator` (PIL+piexif for any format PIL opens incl. AVIF, plus a container-agnostic XMP raw-byte scan that also covers HEIF/JXL), matched against `AI_GENERATOR_TOKENS` so ordinary editors (plain "Adobe Photoshop") and real-camera `Make` ("Apple"/"Canon") are not flagged. **Ideogram tags its output with EXIF `Make="Ideogram AI"`** (verified on a real download 2026-05-24) — that's why `Make` is read. **Integrity-clash detection** (`_integrity_clashes`, surfaced as `ProvenanceReport.integrity_clashes`, printed in red by `identify` and serialized to `--json`): contradictions between independent generator stamps are a laundering/spoofing tell. Two rules: (1) two or more distinct AI-origin vendors named by independent signals (e.g. C2PA OpenAI + EXIF `Make="Ideogram AI"`), and (2) a camera-capture C2PA device (`_DEVICE_C2PA_PLATFORM`) coexisting with any AI-generation marker. Vendor normalization is `_vendor_of` over `_AI_VENDOR_TOKENS` (so a C2PA "Google (Gemini)" issuer and a SynthID-Google proxy agree, while different vendors clash). **High-precision by design:** only hard generator stamps feed it (C2PA-issuer when source is AI, SynthID, EXIF/XMP generator, IPTC `AISystemUsed`, xAI, AIGC); the fuzzy visible sparkle and the open invisible watermark are **excluded** (the latter can be a by-product of our own SDXL removal pass). The c2pa vendor is classified from the issuer attribution / generator, NOT the resolved `platform` (a camera label like "Google Pixel" would mis-normalize to "Google"). All real single-origin fixtures (chatgpt/firefly/doubao/grok/mj) verified to produce **zero** clashes (false-positive guard in `test_identify.py::TestRealSamplesHaveNoClash`). -- `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 ` → 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`. 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. -- `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-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 **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` clamps the glyph box to the image bounds (no crash on a degenerate width-vs-height) and 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`). +- `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 ` → 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 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. @@ -48,7 +49,7 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r ### 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.** @@ -81,6 +82,6 @@ Who embeds what, and whether it is locally detectable (so we know which gaps are - `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 detector gives content 0.518 ≈ negatives 0.504 (no separation; a faint +0.24 single-image lean is likely a brightness confound). Net: the spectral/phase approach is a real *controlled-fill* characterizer, NOT an arbitrary-real-content detector, and is brittle to model version. Metadata proxy + visible sparkle + online oracles remain the ceiling for real content. +- **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" 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 detector gives content 0.518 ≈ negatives 0.504 (no separation; a faint +0.24 single-image lean is likely a brightness confound). Net: the spectral/phase approach is a real *controlled-fill* characterizer, NOT an arbitrary-real-content detector, and is brittle to model version. Metadata proxy + visible sparkle + online oracles remain the ceiling for real content. - **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:** **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). 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 +- **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 diff --git a/README.md b/README.md index 0553d73..19ca6bd 100644 --- a/README.md +++ b/README.md @@ -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 (`` 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 (`` 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 ` 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 @@ -104,8 +114,12 @@ image → encode to latent space (VAE) at native resolution - 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.) @@ -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. diff --git a/data/doubao_capture/README.md b/data/doubao_capture/README.md index e55adbb..b17d7d8 100644 --- a/data/doubao_capture/README.md +++ b/data/doubao_capture/README.md @@ -1,10 +1,11 @@ # Doubao visible watermark capture -> **Status (completed 2026-05-29):** the capture described below was carried out (black + gray -> Doubao captures) and the exact alpha map was solved. Removal is now **reverse-alpha only**: at the -> captured native width recovery is pixel-exact and inpaint is OFF; a residual inpaint runs off-native -> only. See the `doubao_engine.py` notes in the root `CLAUDE.md`. The text below is kept as the -> historical capture plan. +> **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 diff --git a/data/doubao_capture/captures/doubao_black_1x1_1.png b/data/doubao_capture/captures/doubao_black_1x1_1.png new file mode 100644 index 0000000..44ed258 Binary files /dev/null and b/data/doubao_capture/captures/doubao_black_1x1_1.png differ diff --git a/data/doubao_capture/captures/doubao_gray_1x1_1.png b/data/doubao_capture/captures/doubao_gray_1x1_1.png new file mode 100644 index 0000000..c59b7f8 Binary files /dev/null and b/data/doubao_capture/captures/doubao_gray_1x1_1.png differ diff --git a/data/doubao_capture/captures/doubao_white_1x1_1.png b/data/doubao_capture/captures/doubao_white_1x1_1.png new file mode 100644 index 0000000..e1f6abc Binary files /dev/null and b/data/doubao_capture/captures/doubao_white_1x1_1.png differ diff --git a/data/gemini_capture/README.md b/data/gemini_capture/README.md new file mode 100644 index 0000000..26c1e83 --- /dev/null +++ b/data/gemini_capture/README.md @@ -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 +``` diff --git a/data/gemini_capture/captures/gemini_black_2048.png b/data/gemini_capture/captures/gemini_black_2048.png new file mode 100644 index 0000000..8661674 Binary files /dev/null and b/data/gemini_capture/captures/gemini_black_2048.png differ diff --git a/data/gemini_capture/captures/gemini_gray_2048.png b/data/gemini_capture/captures/gemini_gray_2048.png new file mode 100644 index 0000000..2c2db4a Binary files /dev/null and b/data/gemini_capture/captures/gemini_gray_2048.png differ diff --git a/data/gemini_capture/captures/gemini_white_2048.png b/data/gemini_capture/captures/gemini_white_2048.png new file mode 100644 index 0000000..79018cd Binary files /dev/null and b/data/gemini_capture/captures/gemini_white_2048.png differ diff --git a/data/jimeng_capture/README.md b/data/jimeng_capture/README.md new file mode 100644 index 0000000..1ca6cfa --- /dev/null +++ b/data/jimeng_capture/README.md @@ -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 +``` diff --git a/data/jimeng_capture/captures/jimeng_cap_A.png b/data/jimeng_capture/captures/jimeng_cap_A.png new file mode 100644 index 0000000..7ef335d Binary files /dev/null and b/data/jimeng_capture/captures/jimeng_cap_A.png differ diff --git a/data/jimeng_capture/captures/jimeng_cap_B.png b/data/jimeng_capture/captures/jimeng_cap_B.png new file mode 100644 index 0000000..25d1232 Binary files /dev/null and b/data/jimeng_capture/captures/jimeng_cap_B.png differ diff --git a/data/jimeng_capture/captures/jimeng_cap_C.png b/data/jimeng_capture/captures/jimeng_cap_C.png new file mode 100644 index 0000000..7a825ab Binary files /dev/null and b/data/jimeng_capture/captures/jimeng_cap_C.png differ diff --git a/scripts/visible_alpha_solve.py b/scripts/visible_alpha_solve.py new file mode 100644 index 0000000..e8b4daf --- /dev/null +++ b/scripts/visible_alpha_solve.py @@ -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/_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/_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() diff --git a/src/remove_ai_watermarks/assets/doubao_alpha.png b/src/remove_ai_watermarks/assets/doubao_alpha.png index 53ffc33..36d0d21 100644 Binary files a/src/remove_ai_watermarks/assets/doubao_alpha.png and b/src/remove_ai_watermarks/assets/doubao_alpha.png differ diff --git a/src/remove_ai_watermarks/assets/gemini_bg_48.png b/src/remove_ai_watermarks/assets/gemini_bg_48.png index ed11f16..c95f953 100644 Binary files a/src/remove_ai_watermarks/assets/gemini_bg_48.png and b/src/remove_ai_watermarks/assets/gemini_bg_48.png differ diff --git a/src/remove_ai_watermarks/assets/gemini_bg_96.png b/src/remove_ai_watermarks/assets/gemini_bg_96.png index 6a73b58..2220ace 100644 Binary files a/src/remove_ai_watermarks/assets/gemini_bg_96.png and b/src/remove_ai_watermarks/assets/gemini_bg_96.png differ diff --git a/src/remove_ai_watermarks/assets/jimeng_alpha.png b/src/remove_ai_watermarks/assets/jimeng_alpha.png new file mode 100644 index 0000000..1323400 Binary files /dev/null and b/src/remove_ai_watermarks/assets/jimeng_alpha.png differ diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index f32ac6c..3652f79 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -502,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") diff --git a/src/remove_ai_watermarks/doubao_engine.py b/src/remove_ai_watermarks/doubao_engine.py index e50cb91..97fa214 100644 --- a/src/remove_ai_watermarks/doubao_engine.py +++ b/src/remove_ai_watermarks/doubao_engine.py @@ -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 @@ -348,21 +353,17 @@ 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. """ # Normalize to 3-channel BGR so a 2D grayscale or 4-channel BGRA input @@ -371,10 +372,6 @@ class DoubaoEngine: image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) elif image.shape[2] == 4: image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) - 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() 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() @@ -389,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 diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 81fa929..1a2b2a1 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -429,7 +429,7 @@ 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 _vendor_of(synthid) == "OpenAI": caveats.append(_OPENAI_CAVEAT) diff --git a/src/remove_ai_watermarks/jimeng_engine.py b/src/remove_ai_watermarks/jimeng_engine.py new file mode 100644 index 0000000..641c496 --- /dev/null +++ b/src/remove_ai_watermarks/jimeng_engine.py @@ -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 diff --git a/src/remove_ai_watermarks/watermark_registry.py b/src/remove_ai_watermarks/watermark_registry.py index a7cb6ce..9c08fb6 100644 --- a/src/remove_ai_watermarks/watermark_registry.py +++ b/src/remove_ai_watermarks/watermark_registry.py @@ -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 + ), ) diff --git a/tests/test_doubao_engine.py b/tests/test_doubao_engine.py index f55c2fa..d4d1787 100644 --- a/tests/test_doubao_engine.py +++ b/tests/test_doubao_engine.py @@ -133,35 +133,60 @@ 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.""" diff --git a/tests/test_identify.py b/tests/test_identify.py index 6807026..8c0e41d 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -429,13 +429,13 @@ class TestOpenAiCaveatVendorScoped: self._png_chunk(b"tEXt", b"note\x00signed via OpenAI trust chain"), ) r = identify(png, check_visible=False, check_invisible=False) - assert any("SynthID pixel watermark (likely present (Google" in w for w in r.watermarks) + 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 pixel watermark (likely present (OpenAI" in w for w in r.watermarks) + 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) diff --git a/tests/test_jimeng_engine.py b/tests/test_jimeng_engine.py new file mode 100644 index 0000000..587f9c1 --- /dev/null +++ b/tests/test_jimeng_engine.py @@ -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) diff --git a/tests/test_watermark_registry.py b/tests/test_watermark_registry.py index 884c873..6f12d4e 100644 --- a/tests/test_watermark_registry.py +++ b/tests/test_watermark_registry.py @@ -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