diff --git a/.gitignore b/.gitignore index 34c1457..5a85638 100644 --- a/.gitignore +++ b/.gitignore @@ -46,6 +46,8 @@ data/jimeng_capture/seeds/ data/jimeng_capture/captures/jimeng_content_*.png data/gemini_capture/seeds/ data/gemini_capture/captures/gemini_content_*.png +data/samsung_capture/seeds/ +data/samsung_capture/captures/samsung_content_* # GFPGAN downloads its RetinaFace/parsing weights to a CWD ./gfpgan/weights/ # working dir on first use (the restore extra). Runtime artifact, never committed. diff --git a/CLAUDE.md b/CLAUDE.md index 01ffb89..aa6dc16 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 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 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, the Jimeng "★ 即梦AI" wordmark, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, locale-specific — Italian variant calibrated); `--mark gemini` / `--mark doubao` / `--mark jimeng` / `--mark samsung` force one (choices come from the registry). Gemini/Doubao recover pixels exactly with no inpaint at native; **Jimeng and Samsung add an always-on thin residual inpaint over the glyph footprint** (their marks re-rasterize 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) @@ -36,11 +36,12 @@ You are a **principal Python engineer** maintaining a CLI tool and library for r - `noai/c2pa.py` — PNG chunk parser; use `extract_c2pa_chunk(path)` to get raw caBX payload, `has_c2pa_metadata(path)` to detect. Do not reimplement chunk parsing. `extract_c2pa_info(path)` sets `synthid_watermark`/`synthid_vendors` when the manifest is signed by a SynthID-using vendor, and `soft_binding`/`soft_binding_vendors` when a `c2pa.soft-binding` `alg` names a forensic-watermark vendor (`soft_binding_vendors_in(buffer)` is the shared byte-scan, used by both the PNG parser and the non-PNG binary path). PNG/caBX chunk reads are clamped to the remaining file size (`safe_length = min(length, remaining)`; skipped chunks use seek) so a malformed huge `length` cannot drive a multi-GB allocation (shared safety discipline matching `isobmff.scan_c2pa_region`). - `noai/constants.py` — PNG_SIGNATURE, C2PA_CHUNK_TYPE, C2PA_SIGNATURES, and `C2PA_AI_VENDORS` — the single `C2paAiVendor` registry of C2PA-signing vendors (issuer byte, resolved org name, the `identify` platform label, and a `synthid` flag), from which `C2PA_ISSUERS`, `SYNTHID_C2PA_ISSUERS` (issuers that pair SynthID with C2PA: Google, OpenAI), and `identify._ISSUER_PLATFORM` are all **derived** — plus `C2PA_SOFT_BINDINGS` (soft-binding `alg` prefix → forensic-watermark vendor: Adobe TrustMark, Digimarc, Imatag, Steg.AI, Microsoft, ...). Add a new C2PA vendor as one `C2PA_AI_VENDORS` entry (never edit the derived dicts), a new soft-binding to `C2PA_SOFT_BINDINGS`; 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`, the visible marks — Gemini sparkle plus the ByteDance Doubao 豆包AI生成 / Jimeng 即梦AI text marks via the `watermark_registry` — open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). The `hf_job`, visible-mark, and Samsung `samsung_genai` signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only` / `samsung_only`, parallel branches; `visible_only` fires on any `visible_*` signal) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. **Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD` 0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng reuse the registry detectors (`_visible_text_marks` → `watermark_registry`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.4, Jimeng 0.45). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here. **`import identify` is deliberately light** (~21 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports only the pure `noai.c2pa`/`noai.constants` submodules, and `noai/__init__` is lazy (see "Test and lint"), so torch/diffusers are NOT pulled at import even in a full `gpu`/`detect` install — fits a 512 MB host. The heavy paths are opt-in: `check_invisible=True` needs the `detect`/`trustmark` extras (each pulls **torch**; TrustMark also **downloads weights**), so on a core-only deploy leave `check_invisible` off (it is a no-op there anyway). Before the lazy `__init__`, the mere presence of torch in the env inflated `import identify` to ~420 MB. **C2PA platform attribution is device-token-first, issuer-scan fallback** (`_device_platform` scans manifest bytes for `_DEVICE_C2PA_PLATFORM` tokens, then `_attribute_platform`/`_ISSUER_PLATFORM`). **Why, verified on real signed files 2026-05-26:** the old issuer-only byte-scan matched ANY issuer substring anywhere, so multi-entity manifests mis-attributed -- Leica→"Truepic" (a signing authority in the trust chain), Nikon→"Adobe Firefly" (XMP-toolkit "Adobe" + the sample's "Adobe_MAX" name), Pixel→"Google (Gemini)" ("Google LLC" cert org), Truepic→"Google". A distinctive device token wins instead. **Token distinctiveness is load-bearing:** bare `b"Truepic"` mis-fires (it appears in unrelated trust chains -- it mis-attributed the OpenAI `chatgpt-1.png` fixture), so the token is the specific `b"Truepic_Lens"` from the Lens SDK claim generator; likewise `b"Pixel Camera"` (cert CN) not bare `b"Pixel"`. `_DEVICE_C2PA_PLATFORM` lists ONLY tokens **verified against a real C2PA file**: Leica (`lc_c2pa`/`Leica Camera`), Nikon (`NIKON`), Pixel (`Pixel Camera` -- from a real Pixel 10 Pro file attached to c2pa-rs issue #1609/#1554), Sony (`sony.sig`/`sony.cert` -- Sony's own C2PA assertion namespace, verified on a real Sony PXW-Z300 file; NOT bare "Sony" which is a common EXIF Make), Truepic (`Truepic_Lens`). Canon/Bria have **no public direct-download C2PA sample** (checked exhaustively: GitHub issue/PR attachments, contentcredentials gallery, HF datasets -- all upload-to-verify or token-gated; Canon's only public file was a self-signed hobbyist CR3, not factory), so they stay unmapped until a real file is captured (same fixture discipline as Grok/Doubao). The Sony sample is video (MP4) -- our ISOBMFF C2PA path detects it; Sony Alpha stills likely share the `sony.*` namespace but are not separately verified. **Samsung Galaxy + ASUS Gallery live in a separate `_SIGNER_C2PA_PLATFORM` (scanned after `_device_platform`, before the issuer fallback), NOT in `_DEVICE_C2PA_PLATFORM`** — verified on real signed files 2026-05-29. Reason: a Galaxy phone stamps 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 the `C2PA_AI_VENDORS` registry in `constants.py` (which derives `_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. **Independence is source-grouped (`_CLASH_SOURCE`, added 2026-06-02):** the C2PA issuer attribution (`c2pa`) and the SynthID proxy (`synthid`) are NOT independent — the proxy is inferred from the *same* manifest — so they share one source and two vendors named within a single manifest do not clash. This killed a false-positive class found on the spaces corpus: legitimate multi-actor manifests where a product wraps another vendor's engine (Microsoft Designer on OpenAI → `OpenAI, Microsoft`; Microsoft on Google → `Microsoft, Google LLC, Google C2PA Core Generator Library`) or an edit chain re-signs (Adobe over a Gemini original → Adobe c2pa + Google synthid) — 19 such files across the 2026-06-01/02 batches read as clashes before the fix. Rule 1 still fires when a manifest vendor disagrees with a genuinely independent stamp (EXIF/XMP generator, IPTC `AISystemUsed`, AIGC, xAI); each non-`c2pa`/`synthid` family is its own source (`test_identify.py::TestIntegrityClashes::{test_multi_actor_manifest_no_clash,test_manifest_vendor_vs_independent_signal_clashes}`). 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 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. +- `identify.py` — the OpenAI rollout caveat is keyed on `_vendor_of(synthid) == "OpenAI"` (not a raw substring over the issuer + verdict blob). `identify(path)` aggregates every locally-readable signal (C2PA issuer→platform, C2PA soft-binding forensic-watermark vendor, IPTC "Made with AI" + IPTC 2025.1 `AISystemUsed`, embedded SD/ComfyUI params, SynthID proxy, xAI/Grok EXIF signature via `metadata.xai_signature`, the China TC260 AIGC label via `metadata.aigc_label`, the HuggingFace `hf-job-id` job marker via `metadata.huggingface_job`, the Samsung Galaxy AI editing marker via `metadata.samsung_genai`, the visible marks — Gemini sparkle plus the ByteDance Doubao 豆包AI生成 / Jimeng 即梦AI / Samsung Galaxy AI "Contenuti generati dall'AI" text marks via the `watermark_registry` — open invisible watermark, Adobe TrustMark via `trustmark_detector`) into one `ProvenanceReport`. `is_ai_generated` is True or None (never asserted False — stripped metadata is not proof of clean origin). The `hf_job`, visible-mark, and Samsung `samsung_genai` signals are **medium** confidence: each lifts an otherwise-Unknown verdict to a tentative AI (`hf_only` / `visible_only` / `samsung_only`, parallel branches; `visible_only` fires on any `visible_*` signal) but is excluded from the high-confidence `ai_from_metadata` set, so none overrides a hard metadata signal. **Visible-mark detection** (`check_visible`, signals `visible_sparkle` / `visible_doubao` / `visible_jimeng` / `visible_samsung`): the Gemini sparkle keeps its own file-level path (`_visible_sparkle` → `gemini_engine.detect_sparkle_confidence`, promoted only at confidence ≥ `_SPARKLE_THRESHOLD` 0.5; corpus-tuned to separate Gemini sparkles ≥0.56 from non-sparkle ≤0.49), while Doubao/Jimeng/Samsung reuse the registry detectors (`_visible_text_marks` → `watermark_registry`, iterating `_VISIBLE_MARK_PLATFORM`), each gated by its own engine NCC threshold via `MarkDetection.detected` (Doubao 0.4, Jimeng 0.45, Samsung 0.4). Doubao/Jimeng are normally also caught by the TC260 AIGC metadata label and Samsung by its C2PA + `genAIType` marker, so the visible path is their stripped-metadata fallback. Visible marks set `platform` only when no harder signal already did, and (like the sparkle) are excluded from integrity-clash vendor claims. The cv2 dependency lives in the engines, not here. **`import identify` is deliberately light** (~21 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports only the pure `noai.c2pa`/`noai.constants` submodules, and `noai/__init__` is lazy (see "Test and lint"), so torch/diffusers are NOT pulled at import even in a full `gpu`/`detect` install — fits a 512 MB host. The heavy paths are opt-in: `check_invisible=True` needs the `detect`/`trustmark` extras (each pulls **torch**; TrustMark also **downloads weights**), so on a core-only deploy leave `check_invisible` off (it is a no-op there anyway). Before the lazy `__init__`, the mere presence of torch in the env inflated `import identify` to ~420 MB. **C2PA platform attribution is device-token-first, issuer-scan fallback** (`_device_platform` scans manifest bytes for `_DEVICE_C2PA_PLATFORM` tokens, then `_attribute_platform`/`_ISSUER_PLATFORM`). **Why, verified on real signed files 2026-05-26:** the old issuer-only byte-scan matched ANY issuer substring anywhere, so multi-entity manifests mis-attributed -- Leica→"Truepic" (a signing authority in the trust chain), Nikon→"Adobe Firefly" (XMP-toolkit "Adobe" + the sample's "Adobe_MAX" name), Pixel→"Google (Gemini)" ("Google LLC" cert org), Truepic→"Google". A distinctive device token wins instead. **Token distinctiveness is load-bearing:** bare `b"Truepic"` mis-fires (it appears in unrelated trust chains -- it mis-attributed the OpenAI `chatgpt-1.png` fixture), so the token is the specific `b"Truepic_Lens"` from the Lens SDK claim generator; likewise `b"Pixel Camera"` (cert CN) not bare `b"Pixel"`. `_DEVICE_C2PA_PLATFORM` lists ONLY tokens **verified against a real C2PA file**: Leica (`lc_c2pa`/`Leica Camera`), Nikon (`NIKON`), Pixel (`Pixel Camera` -- from a real Pixel 10 Pro file attached to c2pa-rs issue #1609/#1554), Sony (`sony.sig`/`sony.cert` -- Sony's own C2PA assertion namespace, verified on a real Sony PXW-Z300 file; NOT bare "Sony" which is a common EXIF Make), Truepic (`Truepic_Lens`). Canon/Bria have **no public direct-download C2PA sample** (checked exhaustively: GitHub issue/PR attachments, contentcredentials gallery, HF datasets -- all upload-to-verify or token-gated; Canon's only public file was a self-signed hobbyist CR3, not factory), so they stay unmapped until a real file is captured (same fixture discipline as Grok/Doubao). The Sony sample is video (MP4) -- our ISOBMFF C2PA path detects it; Sony Alpha stills likely share the `sony.*` namespace but are not separately verified. **Samsung Galaxy + ASUS Gallery live in a separate `_SIGNER_C2PA_PLATFORM` (scanned after `_device_platform`, before the issuer fallback), NOT in `_DEVICE_C2PA_PLATFORM`** — verified on real signed files 2026-05-29. Reason: a Galaxy phone stamps 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 the `C2PA_AI_VENDORS` registry in `constants.py` (which derives `_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. **Independence is source-grouped (`_CLASH_SOURCE`, added 2026-06-02):** the C2PA issuer attribution (`c2pa`) and the SynthID proxy (`synthid`) are NOT independent — the proxy is inferred from the *same* manifest — so they share one source and two vendors named within a single manifest do not clash. This killed a false-positive class found on the spaces corpus: legitimate multi-actor manifests where a product wraps another vendor's engine (Microsoft Designer on OpenAI → `OpenAI, Microsoft`; Microsoft on Google → `Microsoft, Google LLC, Google C2PA Core Generator Library`) or an edit chain re-signs (Adobe over a Gemini original → Adobe c2pa + Google synthid) — 19 such files across the 2026-06-01/02 batches read as clashes before the fix. Rule 1 still fires when a manifest vendor disagrees with a genuinely independent stamp (EXIF/XMP generator, IPTC `AISystemUsed`, AIGC, xAI); each non-`c2pa`/`synthid` family is its own source (`test_identify.py::TestIntegrityClashes::{test_multi_actor_manifest_no_clash,test_manifest_vendor_vs_independent_signal_clashes}`). 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 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, Jimeng, and Samsung all 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生成"), `jimeng` (bottom-right "★ 即梦AI"), and `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale). `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/Samsung NCC detectors don't cross-fire (jimeng scores ~0.22 on the Doubao strip, well under its 0.45 threshold; Samsung is bottom-left so it shares no corner with the others, and scored 0.0 on Doubao/Jimeng captures and they 0.0 on a real Samsung photo), so `auto` picks the right one. `cli.cmd_visible` is registry-driven: `--mark auto` → `best_auto_mark`, `--mark ` → that mark; `--mark` choices come from `mark_keys()`. `_doubao_remove`/`_jimeng_remove`/`_samsung_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. **Detection localization (issue #36):** `detect_watermark`'s global multi-scale NCC search applies a size weight (`(scale/96)**0.5`) that suppresses tiny-patch false positives but can let a larger, mediocre match (e.g. a bright collar in a portrait) outrank a small, near-perfect sparkle in the corner — so a faint sparkle on a busy background scored below threshold and read as clean (the regression osachub reported from widening the search window 256px->512px between v0.7.2 and v0.8.8). `_corner_promote` adds a bottom-right-corner raw-NCC pass on top of the global search: a match with raw NCC >= `_CORNER_PROMOTE_NCC` 0.85 that beats the global pick overrides it (it only ever replaces a lower-fidelity pick, so it cannot weaken an existing detection), rescuing the buried sparkle without reverting the wider window. The corner side is **relative-clamped** (`_CORNER_PROMOTE_FRAC` 0.20 of the short side, clamped to `[_CORNER_PROMOTE_MIN` 96, `_CORNER_PROMOTE_MAX` 384`]`): a fixed 256px is a true corner on a large image but covers ~70% of a small portrait, where a real photo raw-matches the star at ~0.81 (relative tightening drops that worst case to ~0.69, while the upper clamp stops the corner ballooning on huge images where a real photo reached ~0.83 at 512px). The 0.85 gate sits midway between the worst real-photo corner match (~0.78 across native + downscaled negatives) and a genuine faint sparkle (~0.93), so promotion adds true detections with zero corpus false positives (Gemini's sparkle sits ~60-160px from the corner at fixed margins, covered by the [96, 384] band at every measured size). Regression-guarded by `test_gemini_engine.py::TestCornerPromotion`. **Removal is reverse-alpha with an over-subtraction guard** (`remove_watermark` → `_reverse_alpha_blend`, else `_inpaint_footprint`): the sparkle alpha is computed (`alpha = max(R,G,B)/255`) from the bundled sparkle-on-black captures `assets/gemini_bg_{96,48}.png` (the capture max is ~130, NOT 255 — the sparkle is a ~51%-opaque white overlay, so `alpha` maxes at ~0.51, which is CORRECT for the capture, not under-exposed). The alpha is near-exact only when the real mark's effective opacity matches the capture, which holds on bright/flat backgrounds — re-verified clean on `demo_banana_before.png` 2026-05-31. **Issue #30 (dark-background black pit):** on a dark/textured background (e.g. grass, ~73) the real sparkle's effective opacity is LOWER than the captured 0.51, so the fixed-alpha reverse blend OVER-subtracts (`watermarked - a*logo` goes negative) and drives the footprint to black — the white sparkle becomes a black diamond. `remove_watermark` now detects this via `_reverse_alpha_oversubtracts` (fraction of footprint pixels with `alpha >= _FOOTPRINT_ALPHA` 0.1 whose numerator < 0 exceeds `_OVERSUB_FOOTPRINT_FRAC` 0.05) and **inpaints the footprint** (`_inpaint_footprint`, cv2 NS over the dilated alpha mask) from the surrounding pixels instead. **Behavior-neutral on the working case:** a bright background over-subtracts at ~0% so reverse-alpha is used and the output is byte-identical to before (verified: demo_banana 0.0 frac vs issue-#30 grass 0.61 frac; regression-guarded by `test_gemini_engine.py::TestOverSubtractionGuard`, which composites the sparkle at a reduced effective alpha to reproduce the mismatch). **Under-subtraction (the symmetric case, fixed 2026-06-03):** some real Gemini sparkles are rendered MORE opaque than the captured ~0.51, so the fixed-alpha reverse blend UNDER-subtracts and leaves a bright sparkle residual the detector still fires on (measured on the spaces corpus: a visible-removal audit through the registry path left a detectable sparkle on a meaningful fraction of marks, all under-removals, NOT a background-brightness class — failures and successes had the same input confidence and the same background-luma distribution; the discriminator was the removal delta itself). `remove_watermark` now estimates a per-image alpha gain (`_estimate_alpha_gain`: effective sparkle opacity at the bright core vs the local background ring, `a_eff/a_cap`, clamped `[1.0, _ALPHA_GAIN_MAX` 1.94`]`) and scales the alpha to match before the over-sub/blend branch. The gain cleanly separates on the corpus (under-removed marks ~1.47, cleanly-removed ~1.00), and a deadband (`_ALPHA_GAIN_DEADBAND` 1.05) keeps a matching sparkle **byte-identical** to the pre-fix output, so the fix is purely additive (0 regressions on the audit set; the over-sub guard still runs on the scaled alpha as the safety net for an over-shooting estimate). Regression-guarded by `test_gemini_engine.py::TestUnderSubtractionGain` (composites a more-opaque-than-capture sparkle; **asserts on footprint pixels, NOT the detector** — the detector's NCC is degenerate on a flat synthetic background, so a re-detect conf is meaningless there; the real corpus removal drops the detector from ~0.80 to ~0.27). **False-positive gate (added 2026-06-03):** `detect_watermark`'s shape-only NCC (`spatial*0.5 + gradient*0.3 + var*0.2`) fires on ornate/flat content (text strips, banners, hatching) that coincidentally matches the diamond shape — a real Gemini sparkle is a bright WHITE overlay, so its core sits above the local background, but the NCC is contrast-invariant and cannot see that. The fusion now **demotes** (caps confidence to 0.30) any match that is BOTH low-confidence (`< _SPARKLE_FP_CONF` 0.65) AND has a low core-ring brightness margin (`_core_ring_margin < _SPARKLE_FP_MARGIN` 5). Real sparkles escape via EITHER high confidence (white-bg sparkles score ≥0.79 despite a low margin — the NCC shape match is strong) OR high margin (dark/mid backgrounds, incl. the #36 faint-corner case, lift well clear), so BOTH must fail to demote. The gate is **monotonic** (only ever removes detections, never adds), so it cannot regress the verified-negative corpus (already 0 FPs). On the spaces corpus it demoted 16/495 flagged sparkles (13 carried no AI metadata = content FPs; the 3 AI-meta were visually FPs / a near-invisible white-on-white sparkle whose AI verdict is held by metadata anyway), and dropped the removal-audit failures 20→15 (post-removal flat footprints the NCC re-fired on). `_core_ring_margin` and `_estimate_alpha_gain` share the `_core_and_bg` helper (core 75th-pct brightness vs background-ring median). Regression-guarded by `test_gemini_engine.py::TestSparkleFalsePositiveGate`. **Self-verify repair (added 2026-06-04):** the gain estimate corrects most under-subtractions, but a tail of strong sparkles still survived reverse-alpha (position jitter, or a gain the `[1.0, 1.94]` clamp could not fully reach). After the reverse blend, `remove_watermark` re-detects via `_verify_and_repair`; when a sparkle at or above `_VERIFY_FALLBACK_CONF` 0.5 (the registry's real fail line) remains, it inpaints the footprint and **keeps that only when it lowers the re-detect confidence** — purely additive (the common clean removal re-detects below 0.5 and is returned untouched, so it can never regress). On the spaces corpus this rescued **4 of the 15 remaining gemini removal-audit failures** (15→11, doubao/jimeng still 0), verified through the registry/CLI path. Costs one extra `detect_watermark` per removal (two when the fallback fires). Regression-guarded by `test_gemini_engine.py::TestVerifyAndRepair` (stubs `detect_watermark` to drive the keep-best control flow, since the NCC is degenerate on flat synthetics). **An offset+scale alignment search was prototyped on the remaining 11 fails and REJECTED (2026-06-04):** an audit "ceiling" test suggested it could rescue 4 more (e.g. a5a9 0.577→0.417), but direct inspection showed those were NCC-gaming, not removal — the lower-scoring placement left the sparkle as bright or BRIGHTER (a5a9: first-pass slot 99.5th-pct ~76 at background level, the "aligned win" slot ~164), it just reshaped the residual so the contrast-invariant shape-NCC scored lower. A slot-brightness sanity gate rejected every one, so alignment contributed 0 genuine rescues and was removed (the footprint inpaint stays because it physically reconstructs the slot from its darker surroundings, so its rescues are real). **Lesson: the visible-audit pass/fail metric (re-detect conf < 0.5) is gameable by reshaping the residual — optimizing it directly finds NCC-gaming placements, not clean removals; gate any removal candidate on a physical brightness check, not the detector alone.** The 11 survivors are near-white ill-conditioning (reverse-alpha divides by `1-a`≈0.02) or detector false positives (before≈after≈0.51) that no reverse-alpha placement fixes. The registry's optional `inpaint_residual` (edge cleanup) is a no-op on a clean reverse-alpha removal (and on the same corpus it lowered the re-detect conf on 3 marks, raised it on 10, no-op on 466 — net-neutral on pass/fail, so the self-verify repair, not it, drives the removal tail); an earlier "Gemini smears" read was a misjudged soft-fur original, not an artifact. **The bg assets are now rebuilt from OUR OWN controlled captures** (`data/gemini_capture/captures/`, committed) by `scripts/visible_alpha_solve.py gemini`, which locates the 96px sparkle on the black capture and crops it to the two logo sizes; our capture matched the previously third-party-sourced `gemini_bg_96.png` to **NCC 0.9998**, validating the asset and making it reproducible. Gemini's multi-size fixed-slot model is genuinely different from the Doubao/Jimeng text-strip engines (so it stays a separate engine, not part of the shared-base refactor). - `doubao_engine.py` — visible Doubao "豆包AI生成" remover/detector (cv2/numpy, no GPU). `DoubaoEngine.locate` anchors a bottom-right box by **geometry** (mark scales with image WIDTH), `extract_mask` pulls the light, low-chroma glyphs (the detection candidate) using a per-pixel channel-spread proxy `sat = roi.max(axis=2) - roi.min(axis=2)` (no HSV conversion). `detect` is **shape-consistent**: it matches the bundled alpha glyph silhouette (`assets/doubao_alpha.png`) against the candidate via zero-mean normalized correlation (`_template_match_score`, cv2 `TM_CCOEFF_NORMED`), gated at `DETECT_NCC_THRESHOLD` 0.4 over a small `DETECT_MIN_COVERAGE` floor. Keying on glyph SHAPE (not coverage heuristics) fixed #23 (corpus FP 7/1243). **Removal = reverse-alpha + thin residual inpaint** (`remove_watermark_reverse_alpha`): `original = (wm - a*logo)/(1-a)` from the bundled alpha map + `_ALPHA_LOGO_BGR` (pure white) + `_ALPHA_*_FRAC` geometry, then a deliberately THIN inpaint (`_RESIDUAL_*`, `INPAINT_NS`) over the glyph footprint clears leftover edges without smearing. **Alpha is rebuilt by `scripts/visible_alpha_solve.py` (the careful gray-self solve: cubic background fit, mean over channels, full halo, unblurred), same recipe as Jimeng** — the captures are committed in `data/doubao_capture/captures/`. **Removal aligns ALWAYS** (no `_ALPHA_NATIVE_BAND` fast-path): it tries fixed geometry AND `_aligned_alpha_map`'s `TM_CCOEFF_NORMED` scale+position search and keeps the lower-residual one — the mark is re-rasterized and a few px off per image, so fixed geometry alone leaves a visible outline even at 2048. **The locate box (`WM_*`) is generous (0.22 wide, margins 0.004) and reaches close to the corner** — a tight box (the old 0.185 / margin 0.012) let a corner-ward shift fall OUTSIDE the alignment search, so the align missed and a readable outline survived; regression-guarded by `test_recovers_shifted_mark_on_texture` (composes the alpha shifted on a known texture; old box ~29 vs new ~1 mean residual). **Issue #13 follow-up defect (found 2026-05-31): the SHIPPED Doubao removal left a clearly READABLE "豆包AI生成" outline on the real `doubao-1.png` sample, while `detect` returned conf 0.0 (it is fooled by a thin outline) so `test_reverse_alpha_removes_mark` passed and the old "56/56 clean" claim was detector-measured, not visual.** Root cause: bad alpha (under-estimated, max ~0.65) + fixed-no-inpaint + tight box; the careful rebuild + always-align + thin inpaint + wide box takes it from a readable outline to faint texture-level traces (parity with Jimeng — a single capture cannot pixel-cancel a per-image re-rasterized mark). **Lesson: a detector-only removal test is insufficient; assert visual residual (the textured-shift test).** **`extract_mask` guards a degenerate ROI (`bh < 16 or bw < 16` -> empty mask, skips cv2):** the always-align removal scores each placement with a residual `detect(out)`, and on an extremely wide/short image (e.g. 2048x1, `test_wide_short_does_not_raise`) that fed cv2's GaussianBlur a ~1-px-tall ROI and **faulted natively on Windows py3.12 (access violation, non-deterministic — one CI cell went red while a re-run passed)**; the old at-native path never ran `detect` on degenerate sizes. Real images always clear the guard (the `WM_*` box floors are `max(16, …)` height / `max(40, …)` width), so it only short-circuits slivers. `reverse_alpha_available` is just "asset present"; the registry gates removal on `detect`. The shipped third-party `_refs/zhengsuanfa_doubao_alpha_120x20.png` is NOT a usable alpha (verified 2026-05-29). Arbitrary-region inpainting is `region_eraser`/`erase`. - `jimeng_engine.py` — visible Jimeng / Dreamina "★ 即梦AI" remover/detector (cv2/numpy, no GPU), built 2026-05-30 from issue #13's solid captures (@powersee). Mirrors `doubao_engine`: `locate` anchors a bottom-right box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma), `detect` matches the bundled "即梦AI" glyph silhouette (`assets/jimeng_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.45** cleanly separates real Jimeng marks (>=0.81) from the Doubao strip (0.21) and other AI output (0.0), so the two ByteDance marks don't cross-fire in `--mark auto`. **Logo is pure white (255,255,255)** (`_ALPHA_LOGO_BGR`; the white capture + an L-pair-solve confirm ~254.6); compositing is **sRGB, not linear** (a linear-light solve tripled the cross-residual). **Alpha rebuilt by `scripts/visible_alpha_solve.py` from the GRAY capture** (`data/jimeng_capture/captures/`, the solid captures now committed): `a = (I - B)/(255 - B)`, B a per-capture **cubic** background fit over the non-glyph pixels, **averaged over channels, full halo extent (down to a~0.02), unblurred**. Gray (bg ~132) is the deliberate choice over black: it is the best proxy for real content (the mark sits on bright photo areas, not on black), and the careful build drops the gray self-residual to ~1.3. **The mask quality, not the method, was the earlier limit** — a max-channel / quadratic-bg / blurred / halo-truncated build (and a black-dominated LS) left a visible outline (lesson from issue #13: when reverse-alpha leaves a ghost, suspect the captured alpha map before adding heuristics or switching method). Geometry emitted by the solver at `_ALPHA_NATIVE_WIDTH` 2048: `_ALPHA_WIDTH_FRAC` 0.202, `_ALPHA_HEIGHT_FRAC` 0.058, margins ~0.029. **Removal = reverse-alpha + a deliberately THIN residual inpaint** (`remove_watermark_reverse_alpha`, `_RESIDUAL_DILATE` 5 over the `_RESIDUAL_ALPHA_FLOOR` 0.05 footprint, `_RESIDUAL_INPAINT_RADIUS` 2, `INPAINT_NS`): a single 2048 alpha cannot pixel-cancel the mark re-rasterized at another resolution (alpha maps from independent captures correlate 0.998, not 1.0; off-native reverse-alpha alone only halves the mark), so a tight inpaint clears the residual edges WITHOUT the texture/edge smear a wide full-footprint pass caused. **Placement ALWAYS tries fixed geometry AND `_aligned_alpha_map`'s NCC scale+position search, keeping the lower-residual** — the mark re-rasterizes + jitters a few px per image even at the captured width, so fixed geometry alone misses (there is no `_ALPHA_NATIVE_BAND` fast-path; the scale search `_ALPHA_ALIGN_SEARCH` is fine-stepped, and the `WM_*` locate box is generous so a corner-ward shift stays inside the search — the same widen that fixed Doubao). Verified clean on the solid captures (native 2048; faint self-residual ~1.3 visible only on a dead-flat field, hidden by real texture) and a real 1440-wide Jimeng download (off-native, table edge preserved). `reverse_alpha_available` is just "asset present"; the registry gates on `detect`. **No committed real sample** (the real content download stays gitignored; only the solid calibration captures are committed) — `tests/test_jimeng_engine.py` synthesizes a mark from the bundled alpha asset, and `test_recovers_shifted_mark_on_texture` guards the align-on-shift path that the Doubao defect exposed. Jimeng images are independently caught by the China TC260 AIGC label in `metadata`/`identify`, so this engine is the visible-mark *removal* path, not a new `identify` signal. +- `samsung_engine.py` — visible Samsung Galaxy AI "✦ Contenuti generati dall'AI" remover/detector (cv2/numpy, no GPU), built 2026-06-05 from issue #37's flat captures (@f-liva). Mirrors `jimeng_engine` but anchored **bottom-LEFT** (Doubao/Jimeng are bottom-right): `locate` anchors a bottom-left box by **geometry** (scales with WIDTH), `extract_mask` pulls the light low-chroma glyphs (white top-hat + grayish + min-luma — `LOGO_MIN_LUMA` is lowered to **110** because the mark is faint, peak alpha ~0.38, so on a mid/dark background its glyph luma is lower than Jimeng's), `detect` matches the bundled glyph silhouette (`assets/samsung_alpha.png`) via `TM_CCOEFF_NORMED` over a coverage floor. Threshold `DETECT_NCC_THRESHOLD` **0.40** (real marks ~0.79 on a real photo, ~0.57/0.71 on the black/gray captures; 0.0 on Doubao/Jimeng captures, and Doubao/Jimeng score 0.0 on a real Samsung photo — no cross-fire, also because the corner differs). **Logo is pure white (255,255,255)** (`_ALPHA_LOGO_BGR`; white capture confirms). **Alpha solved by `scripts/visible_alpha_solve.py samsung` from the GRAY capture** (`data/samsung_capture/captures/`, the flat black/gray/white captures committed; the solver gained a `corner="bl"` mode + left-margin logging for this), same careful recipe as Jimeng (cubic background, mean-channel, full halo, unblurred). Geometry emitted at `_ALPHA_NATIVE_WIDTH` **1086** (the flat-edit capture width): `_ALPHA_WIDTH_FRAC` 0.3195, `_ALPHA_HEIGHT_FRAC` 0.0378, `_ALPHA_MARGIN_LEFT_FRAC` 0.0110, `_ALPHA_MARGIN_BOTTOM_FRAC` 0.0064. **Removal = reverse-alpha + a deliberately THIN residual inpaint** (`remove_watermark_reverse_alpha`, same `_RESIDUAL_*` recipe as Jimeng) with **always-try fixed AND `_aligned_alpha_map` NCC scale+position search, keep the lower-residual** (`_ALPHA_ALIGN_SEARCH` widened to (0.85, 1.18, 23) because the flat captures are far off the real-photo width). **Resolution caveat:** the flat captures arrived at 1086 wide while real photos are ~2958 wide (the mark scales with width, so the captured glyph ~334px is ~2.7x smaller than the ~903px real-photo glyph); width-scale + NCC-align still removes it cleanly (verified on a real 2958-wide @f-liva photo: re-detect 0.79→0.00, no readable text or outline on the recovered wooden table — checked **visually**, not just by the detector, per the Gemini self-verify lesson), but a flat capture at the real photo resolution would make the alpha pixel-sharp instead of upscaled (open quality upgrade, noted in `data/samsung_capture/README.md`). **The mark is locale-specific** (text differs per language); this build is the Italian "Contenuti generati dall'AI" variant — other locales need their own captured template. `reverse_alpha_available` is just "asset present"; the registry gates on `detect`. **No committed real sample** (the real photo stays gitignored; only the flat calibration captures are committed) — `tests/test_samsung_engine.py` synthesizes a mark from the bundled alpha asset (bottom-left geometry), with `test_recovers_shifted_mark_on_texture` guarding the align-on-shift path. Samsung Galaxy AI edits are independently caught by C2PA + the `genAIType` marker in `metadata`/`identify`, so this engine is the visible-mark *removal* path; it also feeds `identify` as the medium-confidence `visible_samsung` signal via the registry (the stripped-metadata fallback). - `region_eraser.py` — universal region eraser (`erase` CLI). `erase(image, boxes=|mask=, backend=)` accepts grayscale (2D) and RGBA (4-channel) inputs on **both** backends (`erase_cv2` and `erase_lama` each split off any alpha plane and re-attach it unchanged, and promote grayscale to BGR for processing — LaMa would otherwise crash on grayscale and drop alpha on BGRA): `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. @@ -66,7 +67,7 @@ Diagnosed why, empirically (cached stacks, `/tmp/doubao_distill`): (1) the mark Who embeds what, and whether it is locally detectable (so we know which gaps are fillable). See `identify.py` for what we read. - **Locally detectable (open decoder, no key/API):** Stable Diffusion / SDXL / FLUX via `imwatermark` DWT-DCT (now covered by `invisible_watermark.py`). FLUX uses the same library (`black-forest-labs/flux2` `src/flux2/watermark.py`, 48-bit `0b001010101111111010000111100111001111010100101110`); SDXL is the diffusers `WATERMARK_MESSAGE` (`0b101100111110110010010000011110111011000110011110`). Caveat: fragile to re-encoding. -- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Designer + **Bing Image Creator** — collected 2026-05-24; Bing now runs Microsoft's own **MAI-Image** model, signs C2PA as "Microsoft", NOT OpenAI/DALL-E), and **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`). Still unsampled: Canva (its downloads are re-encoded design *exports* that strip C2PA, so a Canva "positive" is inconclusive — skipped), Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag). **Samsung Galaxy AI** (Generative Edit / Sketch to Image / Portrait Studio on Galaxy S23 FE / S24 / S25, One UI 7+) signs C2PA as "Samsung Galaxy" with the standard `trainedAlgorithmicMedia` source type AND a proprietary `genAIType` marker; verified on real signed files 2026-05-29 (the standard scan catches the source type; `genAIType` additionally catches a Galaxy S24 file that omits it). **ASUS Gallery** also signs edited photos as C2PA (`com.asus.gallery`) but with no AI source type — a signer, not an AI marker. **Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)"). **ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. All three verified on real signed files 2026-05-29. +- **C2PA / IPTC (covered by the issuer/marker scan):** OpenAI, Google, Adobe Firefly, Microsoft (Designer + **Bing Image Creator** — collected 2026-05-24; Bing now runs Microsoft's own **MAI-Image** model, signs C2PA as "Microsoft", NOT OpenAI/DALL-E), and **Stability AI** (collected from Brand Studio / DreamStudio successor; signs C2PA as "Stability AI Ltd", no SynthID, no imwatermark on its current Stable Image model — issuer added to `C2PA_ISSUERS`). Still unsampled: Canva (its downloads are re-encoded design *exports* that strip C2PA, so a Canva "positive" is inconclusive — skipped), Getty, Shutterstock. Midjourney embeds NO C2PA and no invisible watermark (our `mj-*` sample carried only the IPTC tag). **Samsung Galaxy AI** (Generative Edit / Sketch to Image / Portrait Studio on Galaxy S23 FE / S24 / S25, One UI 7+) signs C2PA as "Samsung Galaxy" with the standard `trainedAlgorithmicMedia` source type AND a proprietary `genAIType` marker; verified on real signed files 2026-05-29 (the standard scan catches the source type; `genAIType` additionally catches a Galaxy S24 file that omits it). It ALSO burns a **visible** localized wordmark into the pixels — a sparkle + "generated with AI" string in the bottom-LEFT corner (issue #37; the Italian "✦ Contenuti generati dall'AI" variant is calibrated) — removed by `samsung_engine.py` / `visible --mark samsung` (reverse-alpha, see the engine bullet); detection feeds `identify` as the medium `visible_samsung` signal. The string is locale-specific, so each locale needs its own captured alpha template. **ASUS Gallery** also signs edited photos as C2PA (`com.asus.gallery`) but with no AI source type — a signer, not an AI marker. **Black Forest Labs (FLUX)** API output signs C2PA: `claim_generator_info "Black Forest Labs API"` + a `c2pa.ai_generated_content` assertion + `trainedAlgorithmicMedia` (issuer `b"Black Forest Labs"` added to `C2PA_ISSUERS`, platform "Black Forest Labs (FLUX)"). **ByteDance Volcano Engine (Volcengine)** — the cloud behind Doubao / Jimeng — signs its AI image output with a cert from `certificate_center@volcengine.com` + `trainedAlgorithmicMedia` (issuer `b"volcengine"` → "ByteDance (Volcano Engine)", platform "ByteDance (Doubao / Jimeng / Volcano Engine)"); note this is the C2PA-signed surface, distinct from the XMP/PNG TC260 `AIGC` label Doubao also uses. All three verified on real signed files 2026-05-29. - **EXIF/XMP generator tag (caught by `exif_generator`):** **Ideogram** writes EXIF `Make="Ideogram AI"` (collected 2026-05-24 — no C2PA, no SynthID, no imwatermark; the Make tag is the only signal). - **xAI / Grok — its own EXIF signature scheme, NOT C2PA (DETECTED by `metadata.xai_signature`, built 2026-05-26).** Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC** — only EXIF `Artist` = a UUID and EXIF `ImageDescription` = `Signature: ` (a crypto signature, unverifiable locally without xAI's public key). This empirically kills the earlier unverified "xAI signs C2PA as xAI" lead — xAI is not even a C2PA member. `exif_generator` misses it (neither field holds an `AI_GENERATOR_TOKENS` token), so a dedicated detector `xai_signature(path)` matches the pair (`ImageDescription ~ ^Signature: [A-Za-z0-9+/=]{64,}` AND UUID `Artist`); wired into `has_ai_metadata`, `get_ai_metadata` (key `xai_signature`), and `identify` (signal `xai_signature`, platform "xAI (Grok / Aurora)"). **Format confirmed stable across n=3 genuine generations:** exactly three EXIF tags (`Artist`, `ExifOffset`, `ImageDescription`), `Signature:` prefix constant, base64 payload 300-1004 chars. Two capture facts: (a) the `Artist` UUID **equals the public image id** in the asset URL (`https://imagine-public.x.ai/imagine-public/images/.jpg`), so it is NOT a private per-user secret — only the `Signature` blob is; (b) the Grok web-UI image is a re-encoded **WebP with no signature** — the EXIF survives only in the *original* JPEG (download button or that public tokenless URL), which is why screenshots / re-encodes are metadata-stripped. A real fixture `data/samples/grok-1.jpg` plus **synthetic** JPEG fixtures (fake UUID + fake `Signature:` blob) cover the detector; never add a real Grok image carrying private content (the repo is public). **Stripped on removal too:** `remove_ai_metadata` now calls `_scrub_ai_exif` on the JPEG EXIF, which deletes the xAI Signature+UUID-Artist pair **and** any `Software`/`Make`/`Artist`/`ImageDescription` tag holding an `AI_GENERATOR_TOKENS` token (so Ideogram's `Make="Ideogram AI"` is scrubbed too), while keeping genuine camera/editor EXIF. The shared `_is_xai_signature_pair` helper (module-level compiled regexes) is the single source of truth for the pattern, used by both `xai_signature` and `_scrub_ai_exif`. (AVIF/HEIF/JXL still strip only C2PA boxes via `isobmff`, not EXIF — unchanged.) - **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). **Doubao** (ByteDance) uses it (verified on the real #13 sample 2026-05-25; `ContentProducer` `001191110102MACQD9K64010000`, no C2PA/SynthID/imwatermark — the XMP block is the only signal; GitHub attachment upload did NOT strip it). The same standard is mandatory for Jimeng/Kling/Qwen/Ernie etc., so the one marker covers the whole China-AIGC-labeled ecosystem. `aigc_label` reads **three serializations** through a shared `_parse` helper: the HTML-entity-encoded XMP `TC260:AIGC` block in **either RDF form** — the nested element `{...}` (Doubao) or the attribute `TC260:AIGC="{...}"` (**PicWish**, `ContentProducer="picwish"`, verified on the corpus 2026-05-30) — via a container-agnostic raw-byte scan (any JSON object accepted), a raw-JSON PNG `AIGC` tEXt chunk (Doubao also writes the label this way, no namespaced marker at all — confirmed on the corpus 2026-05-28, `ContentProducer="doubao"`), **and** a bare raw-JSON `{"AIGC":{...}}` object embedded in **JPEG EXIF (UserComment)** by some China-served generators, brace-matched from the scan head with `json.JSONDecoder().raw_decode` (no namespaced marker, no PNG chunk — confirmed on the corpus 2026-05-30, `ContentProducer="001191440300708461136T1308L"`). Both generic forms (the PNG chunk and the bare `{"AIGC":...}` object) are gated on at least one TC260 field (`_TC260_FIELDS`) so a generic `AIGC` key cannot false-positive; the namespaced XMP element is unambiguous and needs no gate. In `identify`, `aigc` fires on the parsed label **or** the `AIGC_MARKERS` byte scan (the latter preserves the laundering-tell case where the JSON payload is truncated). diff --git a/README.md b/README.md index 49b5992..d7b45b2 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, the Doubao "豆包AI生成" text strip, and the Jimeng "★ 即梦AI" wordmark. Each is removed by **reverse-alpha blending** against a captured alpha map (`original = (wm − α·logo)/(1−α)`), recovering the true pixels rather than inpainting a guess. The Gemini sparkle recovers cleanly on its own on bright backgrounds; it adapts the alpha to each image's sparkle opacity, so a more-opaque-than-captured sparkle is still fully removed (and on a dark background, where the fixed alpha would over-subtract and leave a dark spot, it automatically inpaints the small sparkle footprint instead); the Doubao and Jimeng text marks re-rasterize slightly per image, so a thin residual inpaint over the glyph footprint clears the leftover edges (the alpha maps are reproducibly rebuilt from controlled captures by `scripts/visible_alpha_solve.py`). Fast, offline, no GPU. `visible --mark auto` finds and removes the strongest detected mark. (For arbitrary logos/objects, see `erase`.) +- **Visible watermark removal** — a registry of known marks in their usual places: the Gemini / Nano Banana sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, and the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific). Each is removed by **reverse-alpha blending** against a captured alpha map (`original = (wm − α·logo)/(1−α)`), recovering the true pixels rather than inpainting a guess. The Gemini sparkle recovers cleanly on its own on bright backgrounds; it adapts the alpha to each image's sparkle opacity, so a more-opaque-than-captured sparkle is still fully removed (and on a dark background, where the fixed alpha would over-subtract and leave a dark spot, it automatically inpaints the small sparkle footprint instead); the Doubao, Jimeng, and Samsung 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 @@ -26,7 +26,7 @@ If this tool saves you time, consider [sponsoring its development](https://githu - **Text and face preservation (experimental)** — optional `--pipeline controlnet` adds a canny ControlNet that keeps text and face structure sharp through the removal pass (without copying original pixels, so SynthID is still removed). Canny preserves face *structure*, not *identity* (the regenerated face drifts in likeness); identity is preserved by the `--restore-faces` GFPGAN post-pass (opt-in). Both are experimental and off by default. - **Batch processing** — process entire directories - **Detection** — three-stage NCC watermark detection with confidence scoring -- **Provenance detection (`identify`)** — aggregate C2PA issuer, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the China TC260 AIGC label (XMP, PNG chunk, or EXIF), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" text marks), the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output) +- **Provenance detection (`identify`)** — aggregate C2PA issuer, the C2PA soft-binding forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, ...), IPTC "Made with AI" plus the IPTC 2025.1 `AISystemUsed` field, embedded SD/ComfyUI params, EXIF/XMP generator tags, the xAI/Grok EXIF signature, the China TC260 AIGC label (XMP, PNG chunk, or EXIF), the HuggingFace `hf-job-id` job marker, the SynthID metadata proxy, the visible marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), the open SD/SDXL/FLUX invisible watermark, and (with the `trustmark` extra) the open Adobe TrustMark watermark into one origin-platform + watermark-inventory verdict (`--json` for machine output) ## Examples @@ -51,14 +51,14 @@ If this tool saves you time, consider [sponsoring its development](https://githu | **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`) | 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 | +| **Samsung Galaxy AI** (Generative Edit, Sketch to Image, ...) | ✅ "✦ Contenuti generati dall'AI" strip (bottom-left, locale-specific) | — | ✅ C2PA (signer "Samsung Galaxy") + `trainedAlgorithmicMedia` / proprietary `genAIType` marker | Reverse-alpha (captured α map) + thin residual inpaint, NCC-aligned across resolutions, + 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 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. +> Visible overlays are used by Google Gemini / Nano Banana (sparkle logo), by ByteDance's Doubao ("豆包AI生成" corner text) and Jimeng / Dreamina ("★ 即梦AI" wordmark), and by Samsung Galaxy AI ("✦ Contenuti generati dall'AI" strip, bottom-left, locale-specific). All are removed on CPU by reverse-alpha against a captured alpha map (Jimeng and Samsung add a thin residual inpaint over the glyph footprint, since their marks re-rasterize 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 marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" text marks), and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only. +> **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 marks (Gemini sparkle plus the Doubao "豆包AI生成" / Jimeng "即梦AI" / Samsung Galaxy AI "Contenuti generati dall'AI" text marks), and (with the `[detect]` / `[trustmark]` extras) the open SD/SDXL/FLUX and Adobe TrustMark invisible watermarks. SynthID and the proprietary soft-binding watermarks (Digimarc etc.) have no local decoder, so they are reported by metadata proxy / vendor name only. ## How it works @@ -95,6 +95,15 @@ remove-ai-watermarks visible jimeng.png -o clean.png # --mark auto pi remove-ai-watermarks visible jimeng.png --mark jimeng -o clean.png ``` +### Removing the Samsung Galaxy AI "✦ Contenuti generati dall'AI" mark + +Samsung's on-device Generative AI edits (Generative Edit, Sketch to Image, Portrait Studio) burn a visible sparkle + "generated with AI" string into the **bottom-left** corner — a faint, low-opacity semi-transparent white overlay. It is solved from controlled black / gray / white captures the same way as Jimeng and removed by reverse-alpha plus a thin residual inpaint over the glyph footprint (the mark re-rasterizes per image, and the flat captures are smaller than real photos, so the alpha template is NCC-aligned and width-scaled to the actual mark). `visible --mark auto` detects and removes it (or force it with `--mark samsung`); being bottom-left it never confuses the bottom-right Gemini/Doubao/Jimeng marks. The string is **locale-specific** — this build is calibrated for the Italian "Contenuti generati dall'AI" variant; other locales need their own captured template (open a sample on issue #37). + +```bash +remove-ai-watermarks visible samsung.jpg -o clean.jpg # --mark auto picks Samsung +remove-ai-watermarks visible samsung.jpg --mark samsung -o clean.jpg +``` + ### Universal region eraser For any visible mark the dedicated engines do not cover — a logo anywhere, any colour — `erase --region x,y,w,h` inpaints the box you specify. The default `cv2` backend is instant and dependency-free; the optional `lama` backend (big-LaMa via onnxruntime, `lama` extra, ~200 MB model downloaded on first use) gives much cleaner fills on textured regions at the cost of ~3-4 GB RAM per call. @@ -276,8 +285,9 @@ 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生成" / Jimeng "即梦AI"); force -# one with --mark gemini / doubao / jimeng. Removed by reverse-alpha (true-pixel recovery). +# strongest known mark (Gemini sparkle / Doubao "豆包AI生成" / Jimeng "即梦AI" / +# Samsung Galaxy AI "Contenuti generati dall'AI"); force one with +# --mark gemini / doubao / jimeng / samsung. 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/samsung_capture/README.md b/data/samsung_capture/README.md new file mode 100644 index 0000000..47dcb3c --- /dev/null +++ b/data/samsung_capture/README.md @@ -0,0 +1,66 @@ +# Samsung Galaxy AI visible watermark capture + +> **Status (built 2026-06-05):** flat black/gray/white Samsung Galaxy AI captures +> were obtained (issue #37, from @f-liva) and the alpha map was solved. Removal is +> reverse-alpha plus a thin residual inpaint over the glyph footprint; see the +> `samsung_engine.py` notes in the root `CLAUDE.md`. The text below is the capture +> plan and the open quality follow-up. + +Goal: capture the Samsung Galaxy AI "✦ Contenuti generati dall'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 the Doubao / Jimeng strips work +(`src/remove_ai_watermarks/gemini_engine.py`, `doubao_engine.py`, `jimeng_engine.py`). + +## What we learned (verified from the captures, 2026-06-05) + +- Mark: a sparkle icon followed by the locale string "Contenuti generati dall'AI" + (Italian), a light low-opacity (peak alpha ~0.38) semi-transparent **white** + overlay, anchored **bottom-LEFT** (Doubao/Jimeng are bottom-right). The string is + locale-specific, so the alpha template is per-locale; this build is the Italian + variant. Other locales need their own captured template. +- Blend model: alpha compositing with a pure-white logo, `watermarked = + a*255 + (1-a)*original`, solved from the GRAY capture (same careful recipe as + Doubao/Jimeng: cubic-background fit, mean over channels, full halo extent, + unblurred). The white capture confirms the logo is white; on white the mark is + white-on-white and not detectable (no contrast), which is fine -- there is nothing + to recover there. +- Geometry (fraction of image WIDTH): asset width ~0.32, height ~0.038, left margin + ~0.011, bottom margin ~0.006. The mark scales with width: a 1086-wide flat capture + and a 2958-wide real photo both measure width_frac ~0.31. +- **Resolution caveat (open quality follow-up):** the flat black/gray/white captures + arrived at the phone's flat-edit size (1086 wide and a landscape 1920 set), while + the real photos are ~3000 wide, so the captured glyph (~334 px) is ~2.7x smaller + than on a real photo (~900 px). The alpha is solved at the capture size and + width-scaled + NCC-aligned per image, which removes the mark cleanly (verified on a + real 2958-wide photo: re-detect 0.79 -> 0.00, no readable text or outline), but a + flat capture taken at the real photo resolution (~3000 wide) would let the alpha be + pixel-sharp instead of upscaled. Not a blocker; a quality upgrade if a full-res + flat capture is provided. + +## Capture protocol (to re-capture or add a locale) + +On a Samsung Galaxy AI device (set the UI language to the target locale): + +1. Run the AI edit (Generative Edit / Sketch to Image) on a solid black image, so + the overlay lands on a flat black background. Download the ORIGINAL output file + (not a screenshot, no crop or re-save). +2. Repeat over solid white and solid gray (those pin the exact glyph color). +3. Ideally run all three flat edits at the same resolution as real photos (~3000 + wide) so the alpha map is pixel-sharp rather than upscaled. +4. Plus 3-5 real outputs with the visible mark over normal content for validation. + +## Files + +- `captures/samsung_black_1.png`, `samsung_gray_1.png`, `samsung_white_1.png` -- + portrait flat edits (1086 wide), the primary calibration set. +- `captures/samsung_black_2.png`, `samsung_gray_2.png`, `samsung_white_2.png` -- + a second (landscape 1920) set. +- `captures/samsung_content_*` -- real-photo validation downloads, **gitignored** + (user content, repo is public). +- `seeds/` -- synthetic solid-color inputs, gitignored (regenerable). + +Rebuild the alpha asset with: + +``` +uv run python scripts/visible_alpha_solve.py samsung +``` diff --git a/data/samsung_capture/captures/samsung_black_1.png b/data/samsung_capture/captures/samsung_black_1.png new file mode 100644 index 0000000..2fee517 Binary files /dev/null and b/data/samsung_capture/captures/samsung_black_1.png differ diff --git a/data/samsung_capture/captures/samsung_black_2.png b/data/samsung_capture/captures/samsung_black_2.png new file mode 100644 index 0000000..4d92ccf Binary files /dev/null and b/data/samsung_capture/captures/samsung_black_2.png differ diff --git a/data/samsung_capture/captures/samsung_gray_1.png b/data/samsung_capture/captures/samsung_gray_1.png new file mode 100644 index 0000000..c9a856f Binary files /dev/null and b/data/samsung_capture/captures/samsung_gray_1.png differ diff --git a/data/samsung_capture/captures/samsung_gray_2.png b/data/samsung_capture/captures/samsung_gray_2.png new file mode 100644 index 0000000..55003aa Binary files /dev/null and b/data/samsung_capture/captures/samsung_gray_2.png differ diff --git a/data/samsung_capture/captures/samsung_white_1.png b/data/samsung_capture/captures/samsung_white_1.png new file mode 100644 index 0000000..af041de Binary files /dev/null and b/data/samsung_capture/captures/samsung_white_1.png differ diff --git a/data/samsung_capture/captures/samsung_white_2.png b/data/samsung_capture/captures/samsung_white_2.png new file mode 100644 index 0000000..d3e5eba Binary files /dev/null and b/data/samsung_capture/captures/samsung_white_2.png differ diff --git a/scripts/visible_alpha_solve.py b/scripts/visible_alpha_solve.py index e8b4daf..b59a0a0 100644 --- a/scripts/visible_alpha_solve.py +++ b/scripts/visible_alpha_solve.py @@ -68,6 +68,7 @@ class EngineSpec: gray: str asset: Path native_width: int = 2048 + corner: str = "br" # which corner the mark sits in: "br" (Doubao/Jimeng) or "bl" (Samsung) _SPECS: dict[str, EngineSpec] = { @@ -85,6 +86,18 @@ _SPECS: dict[str, EngineSpec] = { "jimeng_cap_C.png", # gray seed _ROOT / "src" / "remove_ai_watermarks" / "assets" / "jimeng_alpha.png", ), + "samsung": EngineSpec( + "samsung", + _ROOT / "data" / "samsung_capture" / "captures", + "samsung_black_1.png", # black flat edit (mark on true black, bottom-left) + "samsung_gray_1.png", # gray flat edit + _ROOT / "src" / "remove_ai_watermarks" / "assets" / "samsung_alpha.png", + # The flat captures arrive at the phone's flat-edit size (1086 wide); the + # mark is a fixed FRACTION of width (~0.31), consistent with the 2958-wide + # real photos, so geometry is emitted relative to the capture width. + native_width=1086, + corner="bl", + ), } _CUBIC_BG_PAD = 30 # px of background margin around the mark for the cubic fit @@ -119,19 +132,26 @@ def _union_bbox(mask: NDArray[np.uint8], err: str) -> tuple[int, int, int, int]: 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). +def _locate_on_black(black: NDArray[np.float32], corner: str = "br") -> tuple[int, int, int, int]: + """Bounding box of the white mark on the black capture, in the given corner. Thresholds well above the blotchy near-black background, then unions the - sufficiently-large bright components so the box spans the whole word. + sufficiently-large bright components so the box spans the whole word. ``corner`` + is ``"br"`` (bottom-right, Doubao/Jimeng) or ``"bl"`` (bottom-left, Samsung). + The horizontal window is kept generous (the Samsung text strip is ~0.31 of the + width, so a corner *quarter* would clip it) while still excluding any centered + generated content the flat edit hallucinated. """ 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 + if corner == "bl": + br[:, w // 2 :] = False # left half only + else: + 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)") + return _union_bbox(bright, f"no mark found on the black capture ({corner} corner is empty)") def _cubic_background(crop: NDArray[np.float32], glyph: NDArray[np.bool_]) -> NDArray[np.float32]: @@ -161,7 +181,7 @@ def solve_alpha(spec: EngineSpec) -> NDArray[np.uint8]: gray_f = gray.astype(np.float32) img_h, img_w = black_f.shape[:2] - mx0, mx1, my0, my1 = _locate_on_black(black_f) + mx0, mx1, my0, my1 = _locate_on_black(black_f, spec.corner) 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) @@ -186,16 +206,20 @@ def solve_alpha(spec: EngineSpec) -> NDArray[np.uint8]: 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 + # Horizontal margin depends on the anchor corner: left margin for "bl", right + # margin (distance from the right edge) for "br". + h_margin = abs_x0 if spec.corner == "bl" else img_w - (abs_x0 + aw) 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)", + "MARGIN_%s_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, + "LEFT" if spec.corner == "bl" else "RIGHT", + h_margin / spec.native_width, (img_h - (abs_y0 + ah)) / spec.native_width, spec.native_width, ) @@ -234,7 +258,7 @@ def main(engine: str) -> None: raise OSError(f"failed to write {path}") log.info("%s: wrote %s", label, path.relative_to(_ROOT)) - if engine in ("doubao", "jimeng", "all"): + if engine in (*_SPECS, "all"): specs = list(_SPECS.values()) if engine == "all" else [_SPECS[engine]] for spec in specs: _write(spec.asset, solve_alpha(spec), spec.name) diff --git a/src/remove_ai_watermarks/assets/samsung_alpha.png b/src/remove_ai_watermarks/assets/samsung_alpha.png new file mode 100644 index 0000000..8815104 Binary files /dev/null and b/src/remove_ai_watermarks/assets/samsung_alpha.png differ diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 8c39da9..419ec6f 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -358,6 +358,7 @@ def _visible_sparkle(image_path: Path) -> float | None: _VISIBLE_MARK_PLATFORM = { "doubao": "ByteDance Doubao (visible 豆包AI生成 mark detected)", "jimeng": "ByteDance Jimeng / Dreamina (visible 即梦AI mark detected)", + "samsung": "Samsung Galaxy AI (visible 'Contenuti generati dall'AI' mark detected)", } diff --git a/src/remove_ai_watermarks/samsung_engine.py b/src/remove_ai_watermarks/samsung_engine.py new file mode 100644 index 0000000..4f11527 --- /dev/null +++ b/src/remove_ai_watermarks/samsung_engine.py @@ -0,0 +1,394 @@ +"""Samsung Galaxy AI visible watermark removal engine. + +Samsung's on-device Generative AI photo edits (Generative Edit / Sketch to Image / +Portrait Studio on Galaxy phones) stamp a visible localized wordmark -- a sparkle +icon followed by a "generated with AI" string -- in the **bottom-left** corner: a +light, low-opacity semi-transparent white overlay. The string is locale-specific; +this engine is calibrated for the Italian "Contenuti generati dall'AI" variant +(issue #37, captures from @f-liva). Other locales need their own captured alpha +template, but the geometry and removal recipe are shared. + +Like the Gemini sparkle and the Doubao / Jimeng marks 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 Samsung capture +(see ``data/samsung_capture/``), bundled as ``assets/samsung_alpha.png`` -- the same +careful build as Jimeng/Doubao (cubic-background fit, mean over channels, full halo +extent, unblurred). The Samsung mark is faint (peak alpha ~0.38), so the glyph reads +as a soft light-gray strip. + +The mark is anchored bottom-LEFT (Doubao/Jimeng are bottom-right) and scales with +image WIDTH (~0.32 of width). The flat calibration captures arrive at the phone's +flat-edit size (~1086 wide) while real photos are ~3000 wide, so a single alpha map +cannot pixel-cancel the upscaled, per-image re-rasterized mark; removal therefore +NCC-aligns the alpha to the actual mark (always), reverse-alphas, then clears the +residual with a deliberately THIN inpaint over the glyph footprint -- the exact +recipe Jimeng uses. Verified on the flat captures and a real ~2958-wide download. + +Detection (``detect``) matches the bundled glyph silhouette against the corner +candidate via normalized correlation, keying on the actual mark shape rather than +coverage heuristics. Samsung edits also carry C2PA + the Galaxy ``genAIType`` +marker (see ``metadata``/``identify``), so the visible path is the stripped-metadata +fallback / the *removal* path, not a new ``identify`` signal. + +``locate`` (geometry box) and ``extract_mask`` (the candidate glyph mask the +detector correlates) mirror the Doubao/Jimeng engines. 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 Samsung mark scales with width and is +# anchored bottom-LEFT. The box is intentionally generous (the glyph mask tightens +# it and the alignment search refines position); values cover the 1086 flat captures +# and the ~2958 real photos (both measured at width_frac ~0.31). +WM_WIDTH_FRAC = 0.40 +WM_HEIGHT_FRAC = 0.060 +MARGIN_LEFT_FRAC = 0.004 +MARGIN_BOTTOM_FRAC = 0.002 + +# Glyph appearance: a low-saturation light gray rendered brighter than the +# surrounding content (white top-hat), same polarity logic as Doubao/Jimeng so a +# white-paper document is left untouched. LOGO_MIN_LUMA is lower than Jimeng's +# because the Samsung mark is fainter (peak alpha ~0.38), so on a mid/dark +# background the glyph luma is lower; the top-hat + NCC shape gate keep precision. +MAX_SATURATION = 55 # max channel spread to count a pixel as "grayish" +LOGO_MIN_LUMA = 110 # glyphs are at least this bright in absolute terms +TOPHAT_DELTA = 8 # glyph must exceed the local background by this many levels + +# Detection matches the bundled alpha-template glyph silhouette +# (assets/samsung_alpha.png) against the candidate via zero-mean normalized +# correlation (cv2 TM_CCOEFF_NORMED). A small coverage floor skips the template +# match on a near-empty candidate box. The threshold is validated against the real +# capture set and the other visible marks (Doubao/Jimeng/Gemini must not cross-fire). +DETECT_MIN_COVERAGE = 0.01 +DETECT_NCC_THRESHOLD = 0.40 + +# ── Reverse-alpha (recovery, Gemini/Doubao/Jimeng-style) ───────────── +# The Samsung 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 it). The alpha map was solved from the GRAY capture by +# scripts/visible_alpha_solve.py (cubic-background fit, mean over channels, full halo, +# unblurred); the bundled asset (assets/samsung_alpha.png) is that template (a*255) +# at the captured width. The mark scales with image WIDTH, and the flat captures are +# ~2.7x smaller than real photos, so a pure width-scale is only approximate; removal +# also registers the template to the actual mark via a TM_CCOEFF_NORMED scale+position +# search (`_aligned_alpha_map`). +_ALPHA_NATIVE_WIDTH = 1086 +_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.3195 # asset width / image width -- the alignment scale seed +_ALPHA_HEIGHT_FRAC = 0.0378 +# 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_LEFT_FRAC = 0.0110 +_ALPHA_MARGIN_BOTTOM_FRAC = 0.0064 +# Alignment scale search (np.linspace args) around the width-scaled glyph size -- +# wider than Jimeng's because the flat captures are far off the real-photo width, so +# the per-image scale can drift more from the width-scaled seed. +_ALPHA_ALIGN_SEARCH = (0.85, 1.18, 23) +# Residual inpaint footprint: a single capture upscaled to the real-photo width +# cannot pixel-cancel the re-rasterized mark, so the glyph footprint (alpha above +# this) is always inpainted after reverse-alpha (dilated by this kernel, INPAINT_NS). +# Kept deliberately THIN -- reverse-alpha already recovers the true background under +# the semi-transparent mark, so the inpaint only finishes the residual edges. +_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 Samsung 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" / "samsung_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 SamsungLocation: + """Located watermark box (bottom-left), 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 SamsungDetection: + """Result of visible Samsung Galaxy AI 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 glyph silhouette (255 = glyph) from the bundled alpha map, used as the + detection template. None if the alpha asset is missing. The threshold is a + fraction of the (faint) peak alpha so the thin strokes survive.""" + global _silhouette_cache + if _silhouette_cache is None: + at = _alpha_template() + if at is None: + return None + _silhouette_cache = (at > 0.10).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(16, int(_ALPHA_WIDTH_FRAC * image_width))) + gh = min(box_mask.shape[0] - 1, max(4, int(_ALPHA_HEIGHT_FRAC * image_width))) + if gw < 16 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 SamsungEngine: + """Remove the visible Samsung Galaxy AI watermark (locate -> mask -> reverse-alpha).""" + + def __init__( + self, + *, + width_frac: float = WM_WIDTH_FRAC, + height_frac: float = WM_HEIGHT_FRAC, + margin_left_frac: float = MARGIN_LEFT_FRAC, + margin_bottom_frac: float = MARGIN_BOTTOM_FRAC, + ) -> None: + self.width_frac = width_frac + self.height_frac = height_frac + self.margin_left_frac = margin_left_frac + self.margin_bottom_frac = margin_bottom_frac + + # ── Locate ──────────────────────────────────────────────────────── + + def locate(self, image: NDArray[Any]) -> SamsungLocation: + """Anchor the watermark box in the bottom-left 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_l = max(2, int(w * self.margin_left_frac)) + margin_b = max(2, int(w * self.margin_bottom_frac)) + x = min(margin_l, max(0, w - 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 SamsungLocation(x=x, y=y, w=wm_w, h=wm_h, is_fallback=True) + + # ── Mask ────────────────────────────────────────────────────────── + + def extract_mask(self, image: NDArray[Any], loc: SamsungLocation) -> NDArray[Any]: + """Build a full-image uint8 mask (255 = watermark glyph) for the box. + + Polarity-aware: the mark is a light, low-saturation gray rendered brighter + than the local background (white top-hat), so a white-paper document is left + untouched (nothing brighter than its surroundings is masked there). + """ + h, w = image.shape[:2] + x, y, bw, bh = loc.bbox + # A degenerate ROI (a sliver from an extremely wide/short image) cannot hold + # the mark and would feed cv2's GaussianBlur/morphology a ~1-px-tall array, + # which can fault the native code on some platforms (mirrors the Doubao/Jimeng + # guard). Skip the cv2 pipeline and return an empty mask there. + if bh < 16 or bw < 16: + return np.zeros((h, w), np.uint8) + # Normalize the ROI to 3-channel BGR: a 2D grayscale or 4-channel BGRA input + # would otherwise break the axis=2 channel reductions below. + roi = image[y : y + bh, x : x + bw] + if roi.ndim == 2: + roi = cv2.cvtColor(roi, cv2.COLOR_GRAY2BGR) + elif roi.shape[2] == 4: + roi = cv2.cvtColor(roi, cv2.COLOR_BGRA2BGR) + roi = roi.astype(np.float32) + + luma = roi.mean(axis=2) + sat = roi.max(axis=2) - roi.min(axis=2) + grayish = sat < MAX_SATURATION + + sigma = max(4.0, bh * 0.4) + local_bg = cv2.GaussianBlur(luma, (0, 0), sigmaX=sigma, sigmaY=sigma) + tophat = luma - local_bg + + cand = grayish & (tophat > TOPHAT_DELTA) & (luma > LOGO_MIN_LUMA) + glyph = cand.astype(np.uint8) * 255 + glyph = cv2.morphologyEx(glyph, cv2.MORPH_CLOSE, np.ones((5, 5), np.uint8)) + glyph = cv2.morphologyEx(glyph, cv2.MORPH_OPEN, np.ones((3, 3), 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]) -> SamsungDetection: + """Detect the visible Samsung mark by matching the alpha-template glyph + silhouette against the corner candidate (TM_CCOEFF_NORMED).""" + det = SamsungDetection() + 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("Samsung 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 (bottom-left).""" + 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 = min(max(0, int(_ALPHA_MARGIN_LEFT_FRAC * w)), max(0, 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 < 16 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 -- the flat capture is far off the real-photo width and the mark + re-rasterizes per image, so fixed geometry alone is not reliable. A single + capture cannot pixel-cancel the upscaled mark, so a deliberately THIN residual + inpaint (``_RESIDUAL_*``) follows. Call only when + :meth:`reverse_alpha_available` and the mark is detected. + """ + # Normalize to 3-channel BGR so a 2D grayscale or 4-channel BGRA input does + # not break the reverse-alpha math (which assumes a 3-channel logo). + if image.ndim == 2: + image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR) + elif image.shape[2] == 4: + image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) + # An image too small to hold the mark would make the geometry boxes degenerate + # and feed cv2.resize a ~1-px-tall target; skip cv2 entirely (mirrors Jimeng). + h, w = image.shape[:2] + if h < 32 or w < 64: + return 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() + 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 af3d166..6ba2089 100644 --- a/src/remove_ai_watermarks/watermark_registry.py +++ b/src/remove_ai_watermarks/watermark_registry.py @@ -23,6 +23,7 @@ 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. + - ``samsung`` -- Samsung Galaxy AI "Contenuti generati dall'AI" strip, bottom-left. """ from __future__ import annotations @@ -116,6 +117,10 @@ def _engine(key: str) -> Any: from remove_ai_watermarks.jimeng_engine import JimengEngine _engines[key] = JimengEngine() + elif key == "samsung": + from remove_ai_watermarks.samsung_engine import SamsungEngine + + _engines[key] = SamsungEngine() else: # pragma: no cover - guarded by the registry keys raise KeyError(key) return _engines[key] @@ -190,6 +195,24 @@ def _jimeng_remove( return image.copy(), None +def _samsung_detect(image: NDArray[Any]) -> MarkDetection: + d = _engine("samsung").detect(image) + return MarkDetection("samsung", "Samsung Galaxy AI text", "bottom-left", d.detected, d.confidence, d.region) + + +def _samsung_remove( + image: NDArray[Any], _inpaint_method: InpaintMethod, _inpaint: bool, _strength: float, force: bool +) -> tuple[NDArray[Any], Region | None]: + # Reverse-alpha (with an always-on thin 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("samsung") + 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( @@ -198,6 +221,9 @@ _REGISTRY: tuple[KnownMark, ...] = ( KnownMark( "jimeng", "Jimeng 即梦AI wordmark", "bottom-right", True, "reverse-alpha", _jimeng_detect, _jimeng_remove ), + KnownMark( + "samsung", "Samsung Galaxy AI text", "bottom-left", True, "reverse-alpha", _samsung_detect, _samsung_remove + ), ) diff --git a/tests/test_samsung_engine.py b/tests/test_samsung_engine.py new file mode 100644 index 0000000..bebc6ee --- /dev/null +++ b/tests/test_samsung_engine.py @@ -0,0 +1,192 @@ +"""Tests for the Samsung Galaxy AI visible-watermark engine. + +No real Samsung sample is committed (the real-photo 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. The mark is +anchored bottom-LEFT (unlike the bottom-right Doubao/Jimeng marks). +""" + +from __future__ import annotations + +import cv2 +import numpy as np +import pytest + +from remove_ai_watermarks.samsung_engine import ( + _ALPHA_HEIGHT_FRAC, + _ALPHA_LOGO_BGR, + _ALPHA_MARGIN_BOTTOM_FRAC, + _ALPHA_MARGIN_LEFT_FRAC, + _ALPHA_NATIVE_WIDTH, + _ALPHA_WIDTH_FRAC, + DETECT_NCC_THRESHOLD, + SamsungEngine, + _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 bottom-left 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 = int(_ALPHA_MARGIN_LEFT_FRAC * w) + 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.15 + + +class TestLocate: + def test_box_anchored_bottom_left(self): + eng = SamsungEngine() + img = np.zeros((1448, 1086, 3), np.uint8) + loc = eng.locate(img) + assert loc.x < int(1086 * 0.03) # hugs the left edge + assert 1448 - (loc.y + loc.h) < int(1086 * 0.03) # hugs the bottom + + def test_box_scales_with_width(self): + eng = SamsungEngine() + 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 = SamsungEngine() + ramp = np.tile(np.linspace(0, 255, 1086, dtype=np.uint8), (1086, 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 = SamsungEngine() + img = np.zeros((1086, 1086, 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 = SamsungEngine() + wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33)) + 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 = SamsungEngine() + assert eng.reverse_alpha_available(np.zeros((1086, 1086, 3), np.uint8)) + assert eng.reverse_alpha_available(np.zeros((4054, 2958, 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 = SamsungEngine() + wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33)) + 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, int(_ALPHA_NATIVE_WIDTH * 1.33), 5.0), # captured width + (2958, 4054, 10.0), # real-photo width (~2.7x native) -> NCC alignment generalizes + ], + ) + def test_recovers_flat_background(self, w, h, max_err): + eng = SamsungEngine() + 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-left footprint; the + opposite (top-right) corner stays pixel-identical.""" + eng = SamsungEngine() + wm, _mark = _compose(_ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33)) + 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 = SamsungEngine() + w, h = _ALPHA_NATIVE_WIDTH, int(_ALPHA_NATIVE_WIDTH * 1.33) + at = _alpha_template() + gw, gh = int(_ALPHA_WIDTH_FRAC * w), int(_ALPHA_HEIGHT_FRAC * w) + ax = max(0, int(_ALPHA_MARGIN_LEFT_FRAC * w) + 9) # shift right of the fixed slot + ay = h - int(_ALPHA_MARGIN_BOTTOM_FRAC * w) - gh - 7 # shift up + 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()) > 20 # mark clearly visible + out = eng.remove_watermark_reverse_alpha(wm).astype(np.float32) + assert float(np.abs(out[mark] - bg[mark]).mean()) < 10.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 = SamsungEngine() + 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 = SamsungEngine() + gray = np.zeros((1448, 1086), np.uint8) + out = eng.remove_watermark_reverse_alpha(gray) + assert out.shape == (1448, 1086, 3) + + def test_bgra_4channel_does_not_raise(self): + eng = SamsungEngine() + bgra = np.zeros((1448, 1086, 4), np.uint8) + out = eng.remove_watermark_reverse_alpha(bgra) + assert out.shape == (1448, 1086, 3) diff --git a/tests/test_watermark_registry.py b/tests/test_watermark_registry.py index 6f12d4e..a9982c8 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", "jimeng"] + assert reg.mark_keys() == ["gemini", "doubao", "jimeng", "samsung"] def test_all_in_auto(self): assert all(m.in_auto for m in reg.known_marks()) @@ -28,6 +28,7 @@ class TestCatalog: assert by_key["gemini"].location == "bottom-right" assert by_key["doubao"].location == "bottom-right" assert by_key["jimeng"].location == "bottom-right" + assert by_key["samsung"].location == "bottom-left" def test_get_mark_unknown_raises(self): with pytest.raises(KeyError): @@ -38,7 +39,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", "jimeng"} + assert keys == {"gemini", "doubao", "jimeng", "samsung"} def test_blank_image_no_auto_mark(self): assert reg.best_auto_mark(np.zeros((256, 256, 3), np.uint8)) is None