diff --git a/CLAUDE.md b/CLAUDE.md index 26f7f54..efdf0f2 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal - `noai/constants.py` — the single `C2PA_AI_VENDORS` registry (+ `C2PA_SOFT_BINDINGS`) from which `C2PA_ISSUERS` / `SYNTHID_C2PA_ISSUERS` / `identify._ISSUER_PLATFORM` are all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline. - `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`). **`remove_ai_metadata` is the SINGLE metadata stripper** (the legacy PIL-re-encoding `noai/cleaner` was deleted; the diffusion core and the public `noai.remove_ai_metadata` re-export now point here). It strips **losslessly** per container: ISOBMFF (HEIC/AVIF/MP4) blanks tokens / strips boxes in place; **JPEG uses `_strip_jpeg_metadata_lossless`** — a marker-segment walk that drops the AI-bearing APP segments (C2PA APP11, AI XMP APP1, IPTC APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so **the pixels are bit-identical** (no DCT re-encode). This is what lets a `--strip-metadata` on a q100 removal output NOT crush it back to q75. PNG/WebP re-saves are already pixel-lossless. Regression: `tests/test_metadata.py::TestHasAiMetadata::test_jpeg_metadata_strip_is_pixel_lossless` (real grok/flux fixtures). `exif_generator` matches a VALUE against `AI_GENERATOR_TOKENS` across EXIF `Software`/`Make`/`Artist`/`ImageDescription`, XMP `CreatorTool`, AND PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI stamps there, not EXIF). **Detection and removal must stay in parity:** a generator that stamps an AI-shaped VALUE under a non-AI KEY (NovelAI's `Title`/`Source`) is dropped on removal by `_is_ai_value` (value-token match, mirrors `exif_generator`), NOT by `_is_ai_key` alone — else the cleaned file still reads as that generator. Add a new no-C2PA generator = one `AI_GENERATOR_TOKENS` entry (use a distinctive token, e.g. `reve.com` not bare `reve`); detection and removal then both follow. Regression: `tests/test_metadata.py::TestExifGenerator::{test_novelai_png_text_chunk_detected,test_novelai_removal_parity}`. - `identify.py` — aggregates every locally-readable signal into one `ProvenanceReport`; `is_ai_generated` is True or None, never asserted False. `ProvenanceReport.ai_source_kind` exposes the C2PA digital-source-type split — `"generated"` (trainedAlgorithmicMedia, fully AI) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region), else None — so a caller branches full-frame scrub vs region-targeted clean (see `noai/tiling.feather_region_composite` + `WatermarkRemover.remove_watermark(region=...)`). The sparkle provenance threshold is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (imported, not a private copy) so the provenance "is there a sparkle" verdict and the removal "take the sparkle" decision can never drift. `import identify` is deliberately light (lazy `noai/__init__`, fits a 512 MB host) — keep heavy imports out (the `watermark_registry` constant import stays light: engines are lazy there). Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM` only when verified against a real C2PA file; editing-app/AI-device signer tokens go to `_SIGNER_C2PA_PLATFORM`; generator/issuer platforms to `C2PA_AI_VENDORS` in `constants.py`. Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence). -- `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / samsung / jimeng_pill). **Removal is LOCALIZE -> FILL for every mark:** each mark is localized to a binary full-frame footprint mask (a `Localization`), then ONE shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). Reverse-alpha (the old `original = (wm - a*logo)/(1-a)` inversion of a captured alpha map + thin residual inpaint) is GONE for ALL marks; why it was dropped is recorded in `docs/module-internals.md`. Backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the preferred default when the `migan` extra is installed), `lama` (big-LaMa ONNX, best quality, heavier, explicit opt-in); `auto` = MI-GAN if installed, else cv2. The captured alpha maps (`scripts/visible_alpha_solve.py`) are still used to DETECT the marks and to shape the mask, but NOT for pixel recovery. **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; a single-strongest pick would leave one). **Three orthogonal axes:** `backend` (the fill), `sensitivity` (how hard to trust a borderline mark: `auto`/`strict`/`assume_ai`, see the `Sensitivity` literal), and `provenance` (vendor keys metadata confirms -- the evidence that drives `auto`). **Perception / decision / action are separated:** `_build_candidates(image)` runs every detector at BOTH trust levels (strict + relaxed) and packages raw verdicts + features into `Candidate`s (no policy); the pure arbiter `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` makes every keep/drop call (per-mark `_resolve_relax` + the pill gate) with no image/IO, so it is unit-testable in isolation; then each winner is localized -> filled. Do NOT put policy back into the engines (the one exception, the Gemini FP gate, stays in `gemini_engine` because `identify` shares that confidence). `detect_marks(..., provenance=frozenset())` stays strict (identify verdict, precision over recall); `KnownMark.remove/detect/localize(..., provenance: bool)` take the already-resolved boolean. **How auto/assume decide (this is metadata-INDEPENDENT for recall):** the visual detectors are pixel-based and need no metadata; the recall gain comes from RELAXING the false-positive gate, not from metadata. `strict` never relaxes (clean images untouched); `auto` relaxes a mark only on same-product evidence -- metadata provenance for that vendor OR a confidently detected sibling mark of the SAME product (`_PRODUCT_OF`; Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax); `assume_ai` relaxes every mark (the caller asserts AI -- a metadata-stripped screenshot uploaded to a remover). Corpus finding: Gemini sparkle removal on Google-C2PA images is ~46% under `strict`/metadata-free `auto` and ~92% under `assume_ai` (recovering marks the vendor moved or re-rendered); the library CANNOT infer AI from a stripped image, so only the caller's `assume_ai` reaches the high recall there. A wrong relaxation just fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what makes `assume_ai` acceptable. Metadata provenance mapping (feeds `auto`, read by `cli._visible_provenance`): Google/Gemini C2PA issuer -> gemini; China-AIGC (TC260) label -> doubao/jimeng; `samsung_genai` -> samsung. **The `jimeng_pill` is CAPTURE-LESS** (`pill_engine.py`): the top-left "AI生成" label has no captured alpha map, so it is detect-by-synthetic-silhouette; its footprint is a fixed top-left geometry box. Its weak edge-NCC detector (~7% raw false-fire) is gated in `remove_auto_marks` via **`_keep_pill`** (32k real-upload corpus validation 2026-07): the pill never rides on a **Doubao** detection, and has confirmation arms because metadata/intent confirms the platform, not pill presence. **(1) Bottom-right "★ 即梦AI" wordmark fired** — ~94% precise and survives **metadata-STRIPPED uploads** (screenshots / re-saves, ~61% of pills carry a detectable wordmark): remove **unrestricted**. **(2) TC260 metadata confirms Jimeng** (`"jimeng" in provenance`, no wordmark) **OR the caller asserts AI** (`sensitivity == "assume_ai"`) — the metadata-only arm is only ~27% precise and its false fires are **textured ceilings/walls that the fill visibly SMEARS**, so remove **only when the top-left footprint is flat enough for an invisible fill** (`pill_engine.footprint_is_flat`, median-Sobel texture ≤ `_FLAT_TEXTURE_MAX`) — the flatness guard holds even under `assume_ai`. This keeps real flat-scene pills (incl. metadata-only ones the wordmark misses) plus harmless flat false fires, and leaves the damaging textured false fires untouched. Do NOT drop the wordmark arm or loosen the flatness guard. `cli._write_bgr_with_alpha` must NOT zero alpha in the watermark bbox (issue #30 white-box regression). **The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller** (a small worker can use cv2; a GPU/model worker can use MI-GAN/LaMa). Adding a new mark still needs a real detection template: a new alpha-solved silhouette from `scripts/visible_alpha_solve.py` (solid black + gray (+white) captures of the mark produced by the actual app/device at native resolution, committed under `data/_capture/captures/`) for a captured mark, or a synthetic font-rendered silhouette for a capture-less one like the pill. Do NOT synthesize a captured mark's detection template by font-rendering the wordmark (the user rejected synthetic reconstruction as below the quality bar, 2026-06-22), and do NOT derive one from user uploads (data-safety: no corpus-derived committed assets). So if no flat capture exists for a mark, the work is parked — do not propose synthetic, do not derive from user uploads. (Meta AI, more Samsung locales, and any Grok visible mark are all parked on this; Grok additionally needs confirming it even HAS a visible mark — its known signal is EXIF-only `xai_signature`.) +- `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / samsung / jimeng_pill). **Removal is LOCALIZE -> FILL for every mark:** each mark is localized to a binary full-frame footprint mask (a `Localization`), then ONE shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). Reverse-alpha (the old `original = (wm - a*logo)/(1-a)` inversion of a captured alpha map + thin residual inpaint) is GONE for ALL marks; why it was dropped is recorded in `docs/module-internals.md`. Backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the preferred default when the `migan` extra is installed), `lama` (big-LaMa ONNX, best quality, heavier, explicit opt-in); `auto` = MI-GAN if installed, else cv2. The captured alpha maps (`scripts/visible_alpha_solve.py`) are still used to DETECT the marks and to shape the mask, but NOT for pixel recovery. **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; a single-strongest pick would leave one). **Three orthogonal axes:** `backend` (the fill), `sensitivity` (how hard to trust a borderline mark: `auto`/`strict`/`assume_ai`, see the `Sensitivity` literal), and `provenance` (vendor keys metadata confirms -- the evidence that drives `auto`). **Perception / decision / action are separated:** `_build_candidates(image)` runs every detector at BOTH trust levels (strict + relaxed) and packages raw verdicts + features into `Candidate`s (no policy); the pure arbiter `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` makes every keep/drop call (per-mark `resolve_relax` + the pill gate) with no image/IO, so it is unit-testable in isolation; then each winner is localized -> filled. Do NOT put policy back into the engines (the one exception, the Gemini FP gate, stays in `gemini_engine` because `identify` shares that confidence). `detect_marks(..., provenance=frozenset())` stays strict (identify verdict, precision over recall); `KnownMark.remove/detect/localize(..., provenance: bool)` take the already-resolved boolean. **How auto/assume decide (this is metadata-INDEPENDENT for recall):** the visual detectors are pixel-based and need no metadata; the recall gain comes from RELAXING the false-positive gate, not from metadata. `strict` never relaxes (clean images untouched); `auto` relaxes a mark only on same-product evidence -- metadata provenance for that vendor OR a confidently detected sibling mark of the SAME product (`_PRODUCT_OF`; Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax); `assume_ai` relaxes every mark (the caller asserts AI -- a metadata-stripped screenshot uploaded to a remover). Corpus finding: Gemini sparkle removal on Google-C2PA images is ~46% under `strict`/metadata-free `auto` and ~92% under `assume_ai` (recovering marks the vendor moved or re-rendered); the library CANNOT infer AI from a stripped image, so only the caller's `assume_ai` reaches the high recall there. A wrong relaxation just fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what makes `assume_ai` acceptable. Metadata provenance mapping (feeds `auto`, read by `cli._visible_provenance`): Google/Gemini C2PA issuer -> gemini; China-AIGC (TC260) label -> doubao/jimeng; `samsung_genai` -> samsung. **The `jimeng_pill` is CAPTURE-LESS** (`pill_engine.py`): the top-left "AI生成" label has no captured alpha map, so it is detect-by-synthetic-silhouette; its footprint is a fixed top-left geometry box. Its weak edge-NCC detector (~7% raw false-fire) is gated in `remove_auto_marks` via **`_keep_pill`** (32k real-upload corpus validation 2026-07): the pill never rides on a **Doubao** detection, and has confirmation arms because metadata/intent confirms the platform, not pill presence. **(1) Bottom-right "★ 即梦AI" wordmark fired** — ~94% precise and survives **metadata-STRIPPED uploads** (screenshots / re-saves, ~61% of pills carry a detectable wordmark): remove **unrestricted**. **(2) TC260 metadata confirms Jimeng** (`"jimeng" in provenance`, no wordmark) **OR the caller asserts AI** (`sensitivity == "assume_ai"`) — the metadata-only arm is only ~27% precise and its false fires are **textured ceilings/walls that the fill visibly SMEARS**, so remove **only when the top-left footprint is flat enough for an invisible fill** (`pill_engine.footprint_is_flat`, median-Sobel texture ≤ `_FLAT_TEXTURE_MAX`) — the flatness guard holds even under `assume_ai`. This keeps real flat-scene pills (incl. metadata-only ones the wordmark misses) plus harmless flat false fires, and leaves the damaging textured false fires untouched. Do NOT drop the wordmark arm or loosen the flatness guard. `cli._write_bgr_with_alpha` must NOT zero alpha in the watermark bbox (issue #30 white-box regression). **The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller** (a small worker can use cv2; a GPU/model worker can use MI-GAN/LaMa). Adding a new mark still needs a real detection template: a new alpha-solved silhouette from `scripts/visible_alpha_solve.py` (solid black + gray (+white) captures of the mark produced by the actual app/device at native resolution, committed under `data/_capture/captures/`) for a captured mark, or a synthetic font-rendered silhouette for a capture-less one like the pill. Do NOT synthesize a captured mark's detection template by font-rendering the wordmark (the user rejected synthetic reconstruction as below the quality bar, 2026-06-22), and do NOT derive one from user uploads (data-safety: no corpus-derived committed assets). So if no flat capture exists for a mark, the work is parked — do not propose synthetic, do not derive from user uploads. (Meta AI, more Samsung locales, and any Grok visible mark are all parked on this; Grok additionally needs confirming it even HAS a visible mark — its known signal is EXIF-only `xai_signature`.) - `gemini_engine.py` — visible Gemini-sparkle detector + localizer (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (`_SELECT_TOPK`), corner-promote, false-positive gate (the provenance prior relaxes the gate + lowers the trust threshold when a Google/Gemini C2PA issuer confirms the vendor). Detection scores the top-K size-weighted matches by full fusion (spatial+gradient+variance) and keeps the highest — NOT the raw-NCC argmax, which re-admits the tiny-patch FPs the size weight suppresses (the osachub 2026-06-12 sub-0.85 corner-sparkle regression; see `docs/module-internals.md`). Keep the 0.85 corner-promote NCC gate; a margin/chroma-gated lower promote was measured and REJECTED 2026-06-11 (~33% FP on non-Google content). Removal is localize -> fill: `footprint_mask` returns the sparkle footprint (the captured alpha thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin), and the shared `watermark_registry.fill` inpaints it. The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery. - `_text_mark_engine.py` — shared base for the three text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. Detection still matches the glyph silhouette (NCC, keys on glyph shape). The removal mask is TEMPLATE-FREE: it is the bounding box of the top-hat glyph blob (`extract_mask`), filled solid + dilated, so the shared fill inpaints the whole wordmark rectangle. This drops the fixed alpha-template placement, so a re-rendered or differently-placed mark is still masked; the captured alpha maps are now used only for the detection silhouette, not for removal. New text mark = a `TextMarkConfig` + a thin subclass + one registry row. Gemini stays a separate engine (different model). - `pill_engine.py` — the CAPTURE-LESS Jimeng-basic "AI生成" pill (top-left, issue #54). No alpha map: `detect` is edge-NCC of a synthetic font-rendered silhouette (`assets/jimeng_pill.png`, regenerate via `scripts/render_pill_silhouette.py`; committed, data-safe -- corpus stays out of the repo) in the top-left ROI, calibrated on 61 local real positives to threshold 0.22; `footprint_mask` is a generous FIXED top-left geometry box (NOT the NCC match position -- the synthetic silhouette localizes only approximately, the corner is negative space, so a geometry box fills cleanly while a match box leaves outline residue). `footprint_texture`/`footprint_is_flat` (median-Sobel over that box, `_FLAT_TEXTURE_MAX`) back the metadata-only safe-fill gate. Removal is the shared localize -> fill (MI-GAN/cv2). Detector precision is weak (~7% raw false-fire), so it is registry-gated in `remove_auto_marks` via `_keep_pill`: never on Doubao; the bottom-right wordmark removes it unrestricted (~94% precise, survives metadata-STRIPPED uploads); TC260-metadata-only removes it ONLY on a flat footprint (its textured false fires -- ceilings/walls -- are what the fill smears). Do NOT loosen those gates. @@ -69,7 +69,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal - `auto_config.py` + the content-detection layer were REMOVED 2026-06-09; `--auto` is a deprecated no-op (controlnet is the default pipeline and the adaptive polish is ON by default and self-gates to a no-op where there is no detail deficit). - `upscaler.py` — optional Real-ESRGAN pre-diffusion super-resolution for small inputs (extra `esrgan`, spandrel only). Manual opt-in; the default `--upscaler` stays `lanczos` and the engine always falls back to Lanczos on absence/error. ESRGAN can degrade faces and thin text. - `image_io.py` — Unicode-safe cv2 IO (issue #17). Every cv2 file read/write in the package routes through `imread`/`imwrite`; do not call `cv2.imread`/`cv2.imwrite` directly. `to_bgr(image)` is the shared channel normalizer — use it instead of inlining `cvtColor` branches. `read_bgr_and_alpha`/`write_bgr_with_alpha` (+ `ALPHA_FORMATS`) are the alpha-preserving IO helpers shared by the CLI and the library `api` (moved here from cli so both use ONE implementation; the write MUST NOT zero alpha in the mark bbox — issue #30 white box). cv2/numpy import lazily, so importing `image_io` is cheap. **`imread` has a Pillow fallback (`_pil_read`) for HEIC/AVIF**: cv2 can't decode those containers, so when its decode returns None it opens via Pillow (AVIF native; HEIC via the core `pillow-heif` dep, whose libheif also covers AVIF) and converts to the same BGR/BGRA layout the flags imply — so the pixel/removal path reads iPhone HEIC and AVIF, not just the metadata path. Normal PNG/JPEG/WebP never reach the fallback. Corpus-verified: 54/55 HEIC+AVIF now decode (the 1 miss is a truncated upload). **`imwrite` PRESERVES the input format at max quality** ("work with originals"): the removal only touches the mark footprint (cv2 AND MI-GAN fills composite over the original — untouched pixels are bit-exact), so the container re-encode must not degrade the rest. JPEG is written at quality 100 / 4:4:4 (no chroma subsampling) — PSNR ~55 dB vs the old default-95's ~48; HEIC/AVIF write via Pillow (`_pil_write`) since cv2 has NO encoder for them (writing `.heic` via cv2 RAISES — a HEIC input used to crash on save). `imwrite` never raises (catches `cv2.error`). **`api.remove_visible` copies the original bytes verbatim on a no-op** (nothing removed + same output format) rather than a lossy re-encode, so a clean image round-trips byte-identical. `noai/constants.SUPPORTED_FORMATS` now includes `.heic`/`.heif`/`.avif` alongside png/jpg/jpeg/webp (pillow-heif is core, so read+write both work), so `batch` discovers them and the CLI no longer warns on an iPhone HEIC; JPEG-XL stays OUT (metadata/strip-only, no pixel decoder without pillow-jxl). **The invisible/SynthID path is inherently a full-frame diffusion regeneration (every pixel changes by design — you cannot "work with originals" there), but it no longer piles gratuitous re-encodes on top:** `watermark_remover` saves the regenerated output through `image_io.imwrite` (not raw `PIL.save`, which defaults to JPEG q75), `invisible_engine` writes its pre-diffusion temp as lossless PNG (not a re-compressed copy of a JPEG input), and the output metadata strip goes through the byte-level `metadata.remove_ai_metadata` (see its bullet) which for JPEG does NOT re-encode the DCT at all — pixels stay bit-identical. -- `api.py` — the high-level convenience API, re-exported lazily at the package top level via `__init__.__getattr__` (PEP 562, so `import remove_ai_watermarks` stays cheap): `remove_visible(source, output=None, *, sensitivity="auto", backend="auto", strip_metadata=True) -> (result_bgr, [labels])` (source = path OR BGR ndarray; a PATH auto-reads metadata provenance and preserves alpha, an ARRAY does neither) and `visible_provenance(path) -> frozenset[str]` (the single metadata→vendor-keys mapper; `cli._visible_provenance` is a thin None-guarded wrapper over it). **`remove_visible` is the ONE path the CLI and library share** — `cli.cmd_visible`'s `--mark auto` branch delegates entirely to it (read → provenance → `remove_auto_marks` → write → `strip_metadata`), so there is no CLI-vs-library drift; `strip_metadata` defaults True to match `visible --strip-metadata`. This is where a library caller should start — NOT the engines directly (`GeminiEngine`/`TextMarkEngine` have no `remove_watermark` any more; removal is registry `remove_auto_marks`/`KnownMark.remove`; the old single-strongest `best_auto_mark` is gone — removal takes EVERY mark). `identify` is NOT top-level re-exported (it collides with the `identify` submodule); use `from remove_ai_watermarks.identify import identify`. +- `api.py` — the high-level convenience API, re-exported lazily at the package top level via `__init__.__getattr__` (PEP 562, so `import remove_ai_watermarks` stays cheap): `remove_visible(source, output=None, *, sensitivity="auto", backend="auto", strip_metadata=True, write_noop=True) -> (result_bgr, [labels])` (source = path OR BGR ndarray; a PATH auto-reads metadata provenance and preserves alpha, an ARRAY does neither; `write_noop=True` writes a clean passthrough copy when nothing is removed, `False` leaves `output` untouched so a "no mark = produce nothing" caller like the CLI `visible` command does not clobber a pre-existing file there) and `visible_provenance(path) -> frozenset[str]` (the single metadata→vendor-keys mapper; `cli._visible_provenance` is a thin None-guarded wrapper over it). **`remove_visible` is the ONE path the CLI and library share** — `cli.cmd_visible`'s `--mark auto` branch delegates entirely to it (read → provenance → `remove_auto_marks` → write → `strip_metadata`), so there is no CLI-vs-library drift; `strip_metadata` defaults True to match `visible --strip-metadata`. This is where a library caller should start — NOT the engines directly (`GeminiEngine`/`TextMarkEngine` have no `remove_watermark` any more; removal is registry `remove_auto_marks`/`KnownMark.remove`; the old single-strongest `best_auto_mark` is gone — removal takes EVERY mark). `identify` is NOT top-level re-exported (it collides with the `identify` submodule); use `from remove_ai_watermarks.identify import identify`. For the Doubao alpha-distillation history (why content-image reverse-alpha distillation fails by physics and controlled captures were required), see `docs/research-doubao-distillation.md`. diff --git a/docs/module-internals.md b/docs/module-internals.md index d8ca44c..eb43171 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -29,7 +29,7 @@ module. **AI-generated vs AI-enhanced** (`ProvenanceReport.ai_source_kind`, roadmap item): the C2PA digital-source-type is split into `"generated"` (trainedAlgorithmicMedia, fully synthetic) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region) — the two byte strings are unambiguous (`compositeWithTrainedAlgorithmicMedia` capitalizes the inner "Trained", so a lowercase `trainedAlgorithmicMedia` match is standalone full generation; full generation wins when both appear). `ai_source_kind` is set only when the AI verdict actually came from the C2PA source type (a non-C2PA AI signal — IPTC/AIGC/local gen/xAI — leaves it None). It lets a caller branch a full-frame scrub (`generated`) from a region-targeted clean that preserves the real photo (`enhanced`; see `noai/tiling.feather_region_composite`). The CLI verdict line reads "AI-generated (fully synthetic)" vs "AI-enhanced (real content with an AI-composited region)". -**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`, which is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (0.5) — imported, not a private copy, so the provenance detect threshold and the removal `best_auto_mark` / `_gemini_detect` arbitration gate can never drift (the detect-vs-remove desync from roadmap P0#7; regression-guarded by `tests/test_identify.py::TestSparkleDetectRemoveAlignment`, which composites the real demo sparkle at borderline opacities and asserts identify and `best_auto_mark` AGREE on either side of the line). Lowering the gate to recover faint sub-0.5 sparkles was evaluated 2026-06-20 and REJECTED: a real Doubao text mark scores ~0.40-0.42 as a gemini match with a HIGHER core-ring brightness margin than a genuine faint sparkle, so neither confidence nor the brightness gate separates them in the [0.35, 0.5) band — lowering trades a rare miss for false-positive removals on clean images. 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. +**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`, which is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (0.5) — imported, not a private copy, so the provenance detect threshold and the removal `detect_marks` / `_gemini_detect` arbitration gate can never drift (the detect-vs-remove desync from roadmap P0#7; regression-guarded by `tests/test_identify.py::TestSparkleDetectRemoveAlignment`, which composites the real demo sparkle at borderline opacities and asserts identify and `detect_marks` AGREE on either side of the line). Lowering the gate to recover faint sub-0.5 sparkles was evaluated 2026-06-20 and REJECTED: a real Doubao text mark scores ~0.40-0.42 as a gemini match with a HIGHER core-ring brightness margin than a genuine faint sparkle, so neither confidence nor the brightness gate separates them in the [0.35, 0.5) band — lowering trades a rare miss for false-positive removals on clean images. 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** (~26 MB; ~36 MB with cv2 loaded by a visible-mark run, ~106 MB for a full `check_visible` run): it imports the `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. `noai.c2pa` does eagerly import the **c2pa-python** binary (Rust + cryptography, ~+5 MB RSS, no torch) for the primary `Reader` path — light enough to stay on the dependency-light host; a broken/absent wheel degrades to the byte-scan parser (`reader_available()` False). 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. @@ -61,7 +61,7 @@ module. `watermark_registry.py` — **single catalog of known visible watermarks**, the unified "find known marks in their usual places, recognize, remove" entry. -**Localize -> fill by policy (replaced reverse-alpha):** each mark is localized to a binary full-frame footprint mask (a `Localization`), and one shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). This replaced the old reverse-alpha removal (invert a captured alpha map, `original = (wm - a*logo)/(1-a)`, plus a thin residual inpaint) for ALL marks — gemini, doubao, jimeng, samsung, and jimeng_pill. **Why it changed:** reverse-alpha depended on a fixed captured alpha map at a fixed position, so it broke whenever a vendor moved or re-rendered its mark; and it was not colour-lossless even with the right map (it amplifies 8-bit quantization and JPEG-chroma error by `1/(1-a)`), which showed up as "the colour just changed, not removed" reports. Localize -> fill has a benign failure mode: a slightly-off localization just inpaints a small region near-losslessly instead of leaving a colour-shifted smear. The captured alpha maps are still used to DETECT the marks and to shape the mask (gemini's footprint), but NOT for pixel recovery. Fill backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the preferred default when the `migan` extra is installed), `lama` (big-LaMa ONNX, best quality, heavier, explicit opt-in); `auto` = MI-GAN if installed, else cv2. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, a `_detect` callable → uniform `MarkDetection`, a `_mask` callable → full-frame footprint mask}; `KnownMark.remove(image, *, backend="auto", provenance=False, force=False)`. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `_resolve_relax` turns the policy + evidence into the per-mark relaxation boolean — `strict` never relaxes, `assume_ai` always relaxes (the caller asserts AI, e.g. a metadata-stripped screenshot; ~46% -> ~92% Gemini recall, at the cost of a small near-lossless fill on some clean corners), `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`_resolve_relax` cross-mark corroboration + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior is byte-identical to the pre-arbiter two-pass (corpus-verified: strict/auto 46%, assume_ai 92% Gemini recall unchanged). +**Localize -> fill by policy (replaced reverse-alpha):** each mark is localized to a binary full-frame footprint mask (a `Localization`), and one shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). This replaced the old reverse-alpha removal (invert a captured alpha map, `original = (wm - a*logo)/(1-a)`, plus a thin residual inpaint) for ALL marks — gemini, doubao, jimeng, samsung, and jimeng_pill. **Why it changed:** reverse-alpha depended on a fixed captured alpha map at a fixed position, so it broke whenever a vendor moved or re-rendered its mark; and it was not colour-lossless even with the right map (it amplifies 8-bit quantization and JPEG-chroma error by `1/(1-a)`), which showed up as "the colour just changed, not removed" reports. Localize -> fill has a benign failure mode: a slightly-off localization just inpaints a small region near-losslessly instead of leaving a colour-shifted smear. The captured alpha maps are still used to DETECT the marks and to shape the mask (gemini's footprint), but NOT for pixel recovery. Fill backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the preferred default when the `migan` extra is installed), `lama` (big-LaMa ONNX, best quality, heavier, explicit opt-in); `auto` = MI-GAN if installed, else cv2. Each `KnownMark` ties a key to {usual `location`, `in_auto` flag, a `_detect` callable → uniform `MarkDetection`, a `_mask` callable → full-frame footprint mask}; `KnownMark.remove(image, *, backend="auto", provenance=False, force=False)`. Entries today: `gemini` (bottom-right sparkle), `doubao` (bottom-right "豆包AI生成"), `jimeng` (bottom-right "★ 即梦AI"), `samsung` (bottom-**LEFT** "✦ Contenuti generati dall'AI", Samsung Galaxy AI, Italian locale), and the capture-less `jimeng_pill` (top-left "AI生成"). `detect_marks(image, *, provenance=frozenset())` scans all (strict, for the identify verdict); `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` removes every detected mark in one pass. **Sensitivity (`auto`/`strict`/`assume_ai`)** decides how hard a borderline mark is trusted: the visual detectors are pixel-based (no metadata needed) and the recall gain comes from relaxing the false-positive gate, not from metadata. `resolve_relax` turns the policy + evidence into the per-mark relaxation boolean — `strict` never relaxes, `assume_ai` always relaxes (the caller asserts AI, e.g. a metadata-stripped screenshot; ~46% -> ~92% Gemini recall, at the cost of a small near-lossless fill on some clean corners), `auto` relaxes only on same-product evidence (metadata provenance for that vendor, or a confidently strict-detected sibling of the same `_PRODUCT_OF` — Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **Perception / decision / action are separated three ways** (the removal path only; `identify` keeps calling `KnownMark.detect` directly, so its verdict is untouched): `_build_candidates(image)` is PERCEPTION — it runs each detector at both trust levels and packages raw verdicts + the pill's flatness feature into `Candidate`s, no policy; `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` is the pure DECISION arbiter — all keep/drop policy (`resolve_relax` cross-mark corroboration + the pill gate) in one image-free, unit-testable function (`tests/test_watermark_registry.py::TestArbiter`); then `remove_auto_marks` does the ACTION, localizing -> filling each winner. The Gemini FP gate deliberately stays inside `gemini_engine` (not the arbiter) because `identify` reads that same gated confidence — pulling it out would drift the provenance verdict. Behavior is byte-identical to the pre-arbiter two-pass (corpus-verified: strict/auto 46%, assume_ai 92% Gemini recall unchanged). **Provenance prior:** when local metadata already confirms the vendor, the mark's detection trust gate is relaxed (a confirmed vendor means the mark is present with high prior, so a mark the conservative detector would demote as a content false positive is trusted). `detect_marks` / `remove_auto_marks` take a `provenance` frozenset and `KnownMark.remove` a `provenance` flag. Mapping: a Google/Gemini C2PA issuer relaxes gemini (skips its false-positive gate and lowers the trust threshold from 0.5 to 0.35); a China-AIGC (TC260) label relaxes doubao/jimeng; `samsung_genai` relaxes samsung. Corpus finding: on Google-C2PA images, Gemini sparkle recall rose from ~46% (plain detector) to ~90% with the provenance prior (recovering marks the vendor moved or re-rendered). The localizer is cheap CPU (cv2/numpy), so a memory-tight caller runs it anywhere; the heavy MI-GAN/LaMa fill is opt-in and chosen by the caller. diff --git a/src/remove_ai_watermarks/api.py b/src/remove_ai_watermarks/api.py index 35def66..6148f09 100644 --- a/src/remove_ai_watermarks/api.py +++ b/src/remove_ai_watermarks/api.py @@ -58,6 +58,7 @@ def remove_visible( sensitivity: Sensitivity = "auto", backend: Backend = "auto", strip_metadata: bool = True, + write_noop: bool = True, ) -> tuple[NDArray[Any], list[str]]: """Remove every detected known visible AI mark (Gemini sparkle, Doubao/Jimeng/ Samsung text, the Jimeng pill) via localize -> fill, returning ``(result_bgr, @@ -81,6 +82,11 @@ def remove_visible( also strips AI provenance metadata (C2PA/EXIF/XMP/IPTC) from the written output via the lossless :func:`metadata.remove_ai_metadata`, so a library call does exactly what the CLI does. Only applies when ``output`` is given. + + ``write_noop`` (default True) controls whether ``output`` is written when NOTHING was + removed: True writes a clean passthrough copy (an idempotent clean); False leaves the + output path untouched, so a caller that treats "no mark" as "produce nothing" (the CLI + ``visible`` no-mark contract) does not clobber a pre-existing file at that path. """ from remove_ai_watermarks import image_io, watermark_registry @@ -98,19 +104,24 @@ def remove_visible( result, removed = watermark_registry.remove_auto_marks( bgr, sensitivity=sensitivity, provenance=provenance, backend=backend ) - if output is not None: - same_format = isinstance(source, (str, Path)) and Path(source).suffix.lower() == Path(output).suffix.lower() + if output is not None and (removed or write_noop): + out_path = Path(output) + out_path.parent.mkdir(parents=True, exist_ok=True) + same_format = isinstance(source, (str, Path)) and Path(source).suffix.lower() == out_path.suffix.lower() if not removed and same_format: # Nothing was removed: copy the ORIGINAL bytes verbatim instead of a lossy # re-encode of its decode, so the pixels stay bit-identical (the metadata - # strip below is lossless, so it does not disturb them either). - import shutil + # strip below is lossless, so it does not disturb them either). Skip the copy + # for an in-place call (output == source): the bytes are already there, and + # shutil.copyfile would raise SameFileError. + if Path(source).resolve() != out_path.resolve(): # type: ignore[arg-type] + import shutil - shutil.copyfile(source, output) # type: ignore[arg-type] + shutil.copyfile(source, out_path) # type: ignore[arg-type] else: - image_io.write_bgr_with_alpha(output, result, alpha) + image_io.write_bgr_with_alpha(out_path, result, alpha) if strip_metadata: from remove_ai_watermarks import metadata - metadata.remove_ai_metadata(Path(output), Path(output)) + metadata.remove_ai_metadata(out_path, out_path) return result, removed diff --git a/src/remove_ai_watermarks/cli.py b/src/remove_ai_watermarks/cli.py index fe4207a..c169ffc 100644 --- a/src/remove_ai_watermarks/cli.py +++ b/src/remove_ai_watermarks/cli.py @@ -381,7 +381,13 @@ def _remove_visible_auto( bk: watermark_registry.Backend = backend # type: ignore[assignment] sens = _parse_sensitivity(sensitivity) provenance = _visible_provenance(source_path) - result, removed = watermark_registry.remove_auto_marks(image, sensitivity=sens, provenance=provenance, backend=bk) + try: + result, removed = watermark_registry.remove_auto_marks( + image, sensitivity=sens, provenance=provenance, backend=bk + ) + except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent + console.print(f" Error: {e}") + raise SystemExit(1) from e if not removed: return image, None return result, ", ".join(removed) @@ -576,15 +582,25 @@ def cmd_visible( from remove_ai_watermarks import api t0 = time.monotonic() - with console.status("Detecting & removing visible marks..."): - result, removed = api.remove_visible( - str(source), str(output), sensitivity=sens, backend=bk, strip_metadata=strip_metadata - ) + try: + with console.status("Detecting & removing visible marks..."): + result, removed = api.remove_visible( + str(source), + str(output), + sensitivity=sens, + backend=bk, + strip_metadata=strip_metadata, + write_noop=False, + ) + except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent + console.print(f" Error: {e}") + raise SystemExit(1) from e elapsed = time.monotonic() - t0 h, w = result.shape[:2] console.print(f" Input: {source.name} ({w}x{h})") if not removed: - output.unlink(missing_ok=True) # api wrote a no-op copy; the no-mark contract writes nothing + # write_noop=False means nothing was written, so a pre-existing file at the + # output path is left intact (the no-mark contract writes nothing). console.print(" No known visible mark detected (gemini / doubao / jimeng / jimeng-pill / samsung).") _no_visible_mark_exit(source, sensitivity=sens) console.print(f" Removed: {', '.join(removed)}") @@ -603,9 +619,10 @@ def cmd_visible( provenance = _visible_provenance(source) target = "gemini" if mark == "auto" else mark # --no-detect auto: gemini fallback chosen = registry.get_mark(target) - # A single explicit mark has no cross-mark pass, so its relaxation is just the - # sensitivity + provenance decision (no sibling corroboration). - prov = sens == "assume_ai" or (sens == "auto" and chosen.key in provenance) + # A single explicit mark has no cross-mark pass (no sibling corroboration), so use the + # canonical arbiter policy with an empty strict-sibling set instead of re-deriving it + # inline (keeps this in lockstep with `decide`). + prov = registry.resolve_relax(chosen.key, sensitivity=sens, provenance=provenance, strict_keys=set()) det = chosen.detect(image, provenance=prov) if detect and not det.detected: console.print(f" {chosen.label} not detected (conf {det.confidence:.2f}). Use --no-detect to force.") @@ -613,8 +630,12 @@ def cmd_visible( if det.detected: console.print(f" {chosen.label} detected ({chosen.location}, conf {det.confidence:.2f})") t0 = time.monotonic() - with console.status(f"Removing {chosen.label}... ({resolved_backend})"): - result, _ = chosen.remove(image, backend=bk, provenance=prov, force=not detect) + try: + with console.status(f"Removing {chosen.label}... ({resolved_backend})"): + result, _ = chosen.remove(image, backend=bk, provenance=prov, force=not detect) + except RuntimeError as e: # e.g. a selected migan/lama backend whose extra is absent + console.print(f" Error: {e}") + raise SystemExit(1) from e elapsed = time.monotonic() - t0 # Save (rejoins the original alpha plane unchanged) + strip metadata. diff --git a/src/remove_ai_watermarks/gemini_engine.py b/src/remove_ai_watermarks/gemini_engine.py index f0a3512..29a642b 100644 --- a/src/remove_ai_watermarks/gemini_engine.py +++ b/src/remove_ai_watermarks/gemini_engine.py @@ -498,7 +498,12 @@ class GeminiEngine: _MASK_DILATE_FRAC = 0.18 # dilation radius as a fraction of the sparkle scale def footprint_mask( - self, image: NDArray[Any], *, force: bool = False, dilate: int | None = None + self, + image: NDArray[Any], + *, + force: bool = False, + dilate: int | None = None, + region: tuple[int, int, int, int] | None = None, ) -> NDArray[Any] | None: """Full-frame uint8 mask (255 = sparkle) of the sparkle footprint, for the shared fill removal path (cv2 / MI-GAN / LaMa), or None. @@ -506,20 +511,29 @@ class GeminiEngine: The footprint is the interpolated captured alpha at the detected scale, thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin. When ``force`` and nothing is detected, falls back to - the default sparkle slot for the image size (the ``--no-detect`` path). The - caller gates on the trust-confidence detection. + the default sparkle slot for the image size (the ``--no-detect`` path). + + ``region`` is the already-resolved ``(x, y, scale)`` from the caller's detection + (the registry passes the decision's provenance-aware region). When given, the + mask is built from it directly WITHOUT a second internal detect -- otherwise a + provenance/assume-relaxed sparkle would be re-demoted by the strict re-detect and + yield no mask (reported-removed-but-unchanged). Absent ``region``, direct callers + keep the detect-then-force behavior. """ image = image_io.to_bgr(image) h, w = image.shape[:2] - det = self.detect_watermark(image) - if det.detected: - x, y, scale = det.region[0], det.region[1], det.region[2] - elif force: - cfg = get_watermark_config(w, h) - x, y = cfg.get_position(w, h) - scale = cfg.logo_size + if region is not None: + x, y, scale = region[0], region[1], region[2] else: - return None + det = self.detect_watermark(image) + if det.detected: + x, y, scale = det.region[0], det.region[1], det.region[2] + elif force: + cfg = get_watermark_config(w, h) + x, y = cfg.get_position(w, h) + scale = cfg.logo_size + else: + return None alpha = self.get_interpolated_alpha(scale) fp = self._footprint_indices(alpha, (x, y), image.shape) if fp is None: diff --git a/src/remove_ai_watermarks/invisible_engine.py b/src/remove_ai_watermarks/invisible_engine.py index e49fa72..977c5f6 100644 --- a/src/remove_ai_watermarks/invisible_engine.py +++ b/src/remove_ai_watermarks/invisible_engine.py @@ -229,6 +229,12 @@ class InvisibleEngine: # SDXL img2img runs near its ~1024 training size instead of distorting on a # tiny latent (a 381x512 portrait wrecks at native -- issue #36 follow-up). # The output is restored to orig_size below, so the floor is transparent. + # Register the HEIF/AVIF opener so a .heic/.avif input (now a SUPPORTED_FORMAT) + # decodes here too. The --force skip path bypasses image_io.imread, which is + # what would otherwise register it, so a bare Image.open would fail on HEIC. + from remove_ai_watermarks import image_io + + image_io._register_heif() image = Image.open(image_path) image = ImageOps.exif_transpose(image) orig_size = image.size # (width, height) @@ -261,7 +267,9 @@ class InvisibleEngine: # Cleaned up in the finally block via _tmp_path. _tmp_fd, _tmp_str = tempfile.mkstemp(suffix=".png") _tmp_path = Path(_tmp_str) - image.save(_tmp_path) + # Convert to RGB before the PNG temp: the diffusion pass is RGB anyway, and a + # non-RGB source mode (e.g. a CMYK JPEG) cannot be written as PNG and would raise. + image.convert("RGB").save(_tmp_path) os.close(_tmp_fd) image_path = _tmp_path diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index c8b557c..0f6076c 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -895,8 +895,7 @@ def _strip_jpeg_metadata_lossless(source_path: Path, output_path: Path) -> bool: i, n = 2, len(data) while i + 1 < n: if data[i] != 0xFF: - out += data[i:] # malformed stream: copy the remainder verbatim - break + return False # malformed marker boundary: defer to the PIL re-encode fallback marker = data[i + 1] if marker in (0xDA, 0xD9): # SOS / EOI -> the coded scan follows; copy verbatim out += data[i:] @@ -906,13 +905,11 @@ def _strip_jpeg_metadata_lossless(source_path: Path, output_path: Path) -> bool: i += 2 continue if i + 4 > n: - out += data[i:] - break + return False # truncated segment header: defer to the PIL re-encode fallback seg_len = int.from_bytes(data[i + 2 : i + 4], "big") seg_end = i + 2 + seg_len if seg_len < 2 or seg_end > n: - out += data[i:] - break + return False # malformed segment length: defer to the PIL re-encode fallback if not _jpeg_app_carries_ai(marker, data[i + 4 : seg_end]): out += data[i:seg_end] i = seg_end @@ -998,8 +995,14 @@ def remove_ai_metadata( # re-encoded. The PIL open+save path below is lossy for JPEG (a q95 re-encode that # would undo the quality-preserving writes of the removal pipelines); this keeps a # JPEG bit-identical outside its APP metadata segments. Falls through on a - # non-parseable JPEG. - if output_path.suffix.lower() in (".jpg", ".jpeg") and _strip_jpeg_metadata_lossless(source_path, output_path): + # non-parseable JPEG. Only when keep_standard: the lossless walk drops AI segments + # but preserves standard ones, so a keep_standard=False caller (strip EVERYTHING) + # must use the full re-encode path below instead. + if ( + keep_standard + and output_path.suffix.lower() in (".jpg", ".jpeg") + and _strip_jpeg_metadata_lossless(source_path, output_path) + ): return output_path # Read image and filter metadata diff --git a/src/remove_ai_watermarks/pill_engine.py b/src/remove_ai_watermarks/pill_engine.py index ab9d900..38aef69 100644 --- a/src/remove_ai_watermarks/pill_engine.py +++ b/src/remove_ai_watermarks/pill_engine.py @@ -98,7 +98,7 @@ class PillEngine: h, w = image.shape[:2] if h < 64 or w < 64: return None - gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if image.ndim == 3 else image + gray = cv2.cvtColor(image_io.to_bgr(image), cv2.COLOR_BGR2GRAY) rh, rw = int(h * _ROI_H_FRAC), int(w * _ROI_W_FRAC) roi = gray[0:rh, 0:rw] tw = max(24, int(_WIDTH_FRAC * w)) @@ -138,7 +138,7 @@ class PillEngine: return 0.0 x0, y0, x1, y1 = box crop = image[y0:y1, x0:x1] - gray = cv2.cvtColor(crop, cv2.COLOR_BGR2GRAY) if crop.ndim == 3 else crop + gray = cv2.cvtColor(image_io.to_bgr(crop), cv2.COLOR_BGR2GRAY) tw = 220 gray = cv2.resize(gray, (tw, max(1, int(gray.shape[0] * tw / gray.shape[1])))).astype(np.float32) gx = cv2.Sobel(gray, cv2.CV_32F, 1, 0, ksize=3) diff --git a/src/remove_ai_watermarks/watermark_registry.py b/src/remove_ai_watermarks/watermark_registry.py index a90f84c..b172a57 100644 --- a/src/remove_ai_watermarks/watermark_registry.py +++ b/src/remove_ai_watermarks/watermark_registry.py @@ -47,7 +47,7 @@ Backend = Literal["auto", "cv2", "migan", "lama"] # a clean corner). Lowest recall on faint/moved marks. # * ``auto`` (default): relax a mark's gate ONLY when the image carries same-product # evidence the mark is there -- metadata provenance for that vendor, or a confidently -# detected sibling mark of the same product (see ``_resolve_relax``). No evidence -> +# detected sibling mark of the same product (see ``resolve_relax``). No evidence -> # stays strict. Safe: it only escalates where the mark is corroborated. # * ``assume_ai``: relax every mark's gate regardless of evidence -- the caller asserts # the image is AI and wants the mark gone (e.g. a metadata-stripped screenshot uploaded @@ -179,7 +179,10 @@ class KnownMark: det = self.detect(image, provenance=provenance) if not (det.detected or force): return Localization(det.detected, det.confidence, det.region, None) - mask = self._mask(image, force=force) + # Pass the (provenance-aware) detection to the mask builder so it does NOT + # re-detect at a different trust level -- a relaxed sparkle must not be + # re-demoted into a None mask (reported-removed-but-unchanged). + mask = self._mask(image, force=force, detection=det) return Localization(det.detected, det.confidence, det.region, mask) def remove( @@ -315,8 +318,14 @@ def _gemini_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDete return MarkDetection("gemini", "Google Gemini sparkle", "bottom-right", detected, d.confidence, d.region) -def _gemini_mask(image: NDArray[Any], *, force: bool = False) -> NDArray[Any] | None: - return _engine("gemini").footprint_mask(image, force=force) +def _gemini_mask( + image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None +) -> NDArray[Any] | None: + # Reuse the decision's provenance-aware region (skip the strict re-detect that would + # otherwise re-demote a relaxed sparkle to None); None region -> footprint_mask + # falls back to its own detect-then-force path (direct/--no-detect callers). + region = detection.region if (detection is not None and detection.detected) else None + return _engine("gemini").footprint_mask(image, force=force, region=region) # The three text-mark engines (Doubao/Jimeng/Samsung) share the TextMarkEngine @@ -333,7 +342,12 @@ def _text_mark_detect(key: str, label: str, location: str) -> Callable[..., Mark def _text_mark_mask(key: str) -> Callable[..., NDArray[Any] | None]: - def mask(image: NDArray[Any], *, force: bool = False) -> NDArray[Any] | None: + def mask( + image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None + ) -> NDArray[Any] | None: + # Text masks rebuild the glyph blob template-free (no trust gate to re-apply), so + # the detection is not needed here; accepted for the uniform _mask signature. + del detection return _engine(key).footprint_mask(image, force=force) return mask @@ -354,7 +368,12 @@ def _pill_detect(image: NDArray[Any], *, provenance: bool = False) -> MarkDetect return MarkDetection("jimeng_pill", "Jimeng AI生成 pill", "top-left", d.detected, d.confidence, d.region) -def _pill_mask(image: NDArray[Any], *, force: bool = False) -> NDArray[Any] | None: +def _pill_mask( + image: NDArray[Any], *, force: bool = False, detection: MarkDetection | None = None +) -> NDArray[Any] | None: + # The pill mask is a fixed top-left geometry box, independent of the detection; + # accepted for the uniform _mask signature. + del detection return _engine("jimeng_pill").footprint_mask(image, force=force) @@ -406,7 +425,7 @@ def detect_marks( return [m.detect(image, provenance=m.key in provenance) for m in _REGISTRY if include_explicit or m.in_auto] -def _resolve_relax( +def resolve_relax( key: str, *, sensitivity: Sensitivity, @@ -487,14 +506,14 @@ def decide(candidates: list[Candidate], context: Context) -> list[Decision]: """The removal ARBITER: a pure function turning perception + context into the ordered list of marks to remove (and the trust level each was accepted at). - All policy lives here, in one place: per-mark relaxation (:func:`_resolve_relax`, + All policy lives here, in one place: per-mark relaxation (:func:`resolve_relax`, which needs the strict-detected siblings for ``auto`` cross-mark corroboration) and the capture-less pill gate (:func:`_keep_pill`). No image, no I/O -- so it is unit-testable in isolation and the same decision drives every caller.""" strict_keys = {c.key for c in candidates if c.detected_strict} fired: list[Decision] = [] for c in candidates: - relax = _resolve_relax( + relax = resolve_relax( c.key, sensitivity=context.sensitivity, provenance=context.provenance, strict_keys=strict_keys ) if c.detected_relaxed if relax else c.detected_strict: @@ -538,6 +557,10 @@ def remove_auto_marks( result = image labels: list[str] = [] for d in decide(_build_candidates(image), context): - result, _ = get_mark(d.candidate.key).remove(result, backend=backend, provenance=d.relax, force=False) - labels.append(d.candidate.label) + result, region = get_mark(d.candidate.key).remove(result, backend=backend, provenance=d.relax, force=False) + # Only report the mark as removed when a fill actually happened: remove() returns + # a None region when the localized mask came back empty, and reporting it anyway + # would claim a removal that left the pixels unchanged. + if region is not None: + labels.append(d.candidate.label) return result, labels diff --git a/tests/test_api.py b/tests/test_api.py index 388aa58..ed6d1ea 100644 --- a/tests/test_api.py +++ b/tests/test_api.py @@ -103,3 +103,26 @@ class TestVisibleProvenance: def test_unreadable_path_is_empty(self, tmp_path): assert raiw.visible_provenance(tmp_path / "missing.png") == frozenset() + + +class TestRemoveVisibleOutputPath: + """Output-path robustness: in-place clean (#3) and a missing output dir (#4).""" + + def _write_clean(self, p: Path) -> None: + from remove_ai_watermarks import image_io + + image_io.imwrite(str(p), np.full((128, 128, 3), 200, np.uint8)) + + def test_inplace_clean_no_crash(self, tmp_path: Path): + p = tmp_path / "clean.png" + self._write_clean(p) + _, removed = raiw.remove_visible(str(p), str(p), backend="cv2") + assert removed == [] + assert p.exists() + + def test_creates_missing_output_dir(self, tmp_path: Path): + src = tmp_path / "in.png" + self._write_clean(src) + out = tmp_path / "sub" / "out.png" + raiw.remove_visible(str(src), str(out), backend="cv2") + assert out.exists() diff --git a/tests/test_cli.py b/tests/test_cli.py index 5777fb9..9671725 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -866,3 +866,24 @@ class TestEraseCommand: assert result.exit_code != 0 assert "onnxruntime" in result.output.lower() assert not output.exists() + + +def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypatch): + # A backend whose extra is missing raises RuntimeError in region_eraser.erase; + # the visible --mark auto path must surface it cleanly, not as a raw traceback (#9). + from pathlib import Path + + from remove_ai_watermarks import region_eraser + + doubao = Path(__file__).resolve().parent.parent / "data" / "samples" / "doubao-1.png" + if not doubao.exists(): + pytest.skip("doubao sample not present") + + def boom(*_a, **_k): + raise RuntimeError("MI-GAN backend requires onnxruntime. Install the extra: ...") + + monkeypatch.setattr(region_eraser, "erase", boom) + out = tmp_path / "out.png" + result = runner.invoke(main, ["visible", str(doubao), "-o", str(out), "--backend", "migan"]) + assert result.exit_code == 1 + assert not isinstance(result.exception, RuntimeError), "RuntimeError leaked as a traceback" diff --git a/tests/test_metadata.py b/tests/test_metadata.py index a9b412f..8555538 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -1359,3 +1359,28 @@ class TestC2paCloudManifest: assert r.is_ai_generated is None assert any("Durable Content Credentials" in w for w in r.watermarks) assert any(s.name == "c2pa_cloud" for s in r.signals) + + +def test_remove_all_bypasses_lossless_jpeg(tmp_path, monkeypatch): + # keep_standard=False (--remove-all) must NOT take the AI-only lossless JPEG path, + # which leaves standard (non-AI) metadata in place; it must fall through to the full + # re-encode strip (#7). + import remove_ai_watermarks.metadata as md + + calls: list[int] = [] + real = md._strip_jpeg_metadata_lossless + + def spy(s, o): + calls.append(1) + return real(s, o) + + monkeypatch.setattr(md, "_strip_jpeg_metadata_lossless", spy) + src = tmp_path / "x.jpg" + Image.new("RGB", (32, 32), (128, 128, 128)).save(src, "JPEG") + out = tmp_path / "o.jpg" + + md.remove_ai_metadata(src, out, keep_standard=True) + assert calls, "lossless JPEG strip should run when keeping standard metadata" + calls.clear() + md.remove_ai_metadata(src, out, keep_standard=False) + assert not calls, "keep_standard=False must bypass the AI-only lossless JPEG path" diff --git a/tests/test_pill_engine.py b/tests/test_pill_engine.py index 2ebcaee..aa43378 100644 --- a/tests/test_pill_engine.py +++ b/tests/test_pill_engine.py @@ -167,7 +167,20 @@ class TestPillGate: assert "Jimeng AI生成 pill" not in removed def test_pill_dropped_on_doubao_even_with_metadata(self, monkeypatch: pytest.MonkeyPatch) -> None: + # doubao is faked as detected, which drives the pill gate (pill never rides on a + # Doubao detection). The same flat + jimeng-metadata setup WITHOUT doubao keeps the + # pill (test_pill_kept_with_metadata_on_flat_footprint), so doubao is the + # differentiator. Doubao itself is not asserted in `removed` here: this synthetic + # frame is flat with no real glyph, so the text mask has nothing to fill (its real + # removal is covered by TestRealSample on the committed doubao sample). self._fakes(monkeypatch, {"doubao", "jimeng_pill"}) _, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), provenance=frozenset({"jimeng"})) - assert "Doubao 豆包AI生成 text" in removed assert "Jimeng AI生成 pill" not in removed + + +def test_detect_bgra_no_crash() -> None: + # A 4-channel BGRA array must be normalized, not crash cv2.cvtColor(BGR2GRAY) (#10). + bgra = np.zeros((256, 256, 4), np.uint8) + det = PillEngine().detect(bgra) + assert det.detected in (True, False) + assert PillEngine().footprint_texture(bgra) >= 0.0 diff --git a/tests/test_watermark_registry.py b/tests/test_watermark_registry.py index b3bc9e3..7b3f814 100644 --- a/tests/test_watermark_registry.py +++ b/tests/test_watermark_registry.py @@ -130,38 +130,36 @@ class TestLocalizeFill: class TestSensitivity: - """``_resolve_relax`` turns the sensitivity policy + evidence into the per-mark + """``resolve_relax`` turns the sensitivity policy + evidence into the per-mark relaxation boolean the engines consume.""" def test_strict_never_relaxes(self): # even with metadata provenance, strict keeps the conservative gate assert ( - reg._resolve_relax("gemini", sensitivity="strict", provenance=frozenset({"gemini"}), strict_keys=set()) + reg.resolve_relax("gemini", sensitivity="strict", provenance=frozenset({"gemini"}), strict_keys=set()) is False ) def test_assume_ai_always_relaxes(self): - assert reg._resolve_relax("gemini", sensitivity="assume_ai", provenance=frozenset(), strict_keys=set()) is True + assert reg.resolve_relax("gemini", sensitivity="assume_ai", provenance=frozenset(), strict_keys=set()) is True def test_auto_relaxes_on_own_metadata(self): assert ( - reg._resolve_relax("gemini", sensitivity="auto", provenance=frozenset({"gemini"}), strict_keys=set()) - is True + reg.resolve_relax("gemini", sensitivity="auto", provenance=frozenset({"gemini"}), strict_keys=set()) is True ) def test_auto_strict_without_evidence(self): - assert reg._resolve_relax("gemini", sensitivity="auto", provenance=frozenset(), strict_keys=set()) is False + assert reg.resolve_relax("gemini", sensitivity="auto", provenance=frozenset(), strict_keys=set()) is False def test_auto_cross_mark_same_product(self): # a detected Jimeng wordmark relaxes the Jimeng pill (same product, other corner) assert ( - reg._resolve_relax("jimeng_pill", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) - is True + reg.resolve_relax("jimeng_pill", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) is True ) def test_auto_no_cross_mark_across_products(self): # a detected Jimeng wordmark must NOT relax Doubao (distinct products, same corner) - assert reg._resolve_relax("doubao", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) is False + assert reg.resolve_relax("doubao", sensitivity="auto", provenance=frozenset(), strict_keys={"jimeng"}) is False def test_remove_auto_marks_accepts_all_sensitivities(self): blank = np.zeros((256, 256, 3), np.uint8) @@ -225,3 +223,39 @@ class TestArbiter: self._c("jimeng_pill", strict=True, relaxed=True, flat=False), ] assert "jimeng_pill" in self._keys(cands, reg.Context()) + + +class TestProvenanceMaskThreading: + """Regression for the provenance-relaxed Gemini no-op (#1) and the false 'removed' + label (#2). Before the fix, footprint_mask re-detected WITHOUT trust_provenance, the + FP gate demoted the sparkle to detected=False, the mask came back None, yet + remove_auto_marks still reported the mark as removed.""" + + def test_relaxed_sparkle_yields_mask(self, monkeypatch: pytest.MonkeyPatch): + # A sparkle a strict re-detect would demote (detected False) but a + # provenance-relaxed detect accepts must still produce a removal mask. + from remove_ai_watermarks.gemini_engine import DetectionResult + + def fake(image, force_size=None, *, trust_provenance=False): + return DetectionResult( + detected=trust_provenance, confidence=0.42 if trust_provenance else 0.30, region=(400, 400, 60) + ) + + monkeypatch.setattr(reg._engine("gemini"), "detect_watermark", fake) + img = np.full((512, 512, 3), 90, np.uint8) + assert reg.get_mark("gemini").localize(img, provenance=True).mask is not None + assert reg.get_mark("gemini").localize(img, provenance=False).mask is None + + def test_no_label_when_mask_none(self, monkeypatch: pytest.MonkeyPatch): + # A decided mark whose mask comes back None must NOT be reported as removed. + from remove_ai_watermarks.gemini_engine import DetectionResult + + eng = reg._engine("gemini") + monkeypatch.setattr( + eng, + "detect_watermark", + lambda image, force_size=None, *, trust_provenance=False: DetectionResult(True, 0.9, (10, 10, 40)), + ) + monkeypatch.setattr(eng, "footprint_mask", lambda image, *, force=False, region=None, dilate=None: None) + _, removed = reg.remove_auto_marks(np.zeros((256, 256, 3), np.uint8), sensitivity="strict", backend="cv2") + assert "Google Gemini sparkle" not in removed