fix(visible): safe-inpaint pill gate, cut metadata-only false fires

Verified 0.13.0 pill removal on a 32k real-upload corpus. The metadata-OR-wordmark
gate was only ~1/3 precise: TC260 metadata confirms Jimeng-class provenance, not pill
presence, so the weak edge-NCC detector's false fires (textured ceilings/walls, where
inpaint visibly smears) were admitted whenever metadata was present.

Split into two arms (_keep_pill): the reliable bottom-right wordmark (~94% precise,
survives metadata stripping) removes the pill unrestricted; the metadata-only arm
removes it ONLY when the top-left footprint is flat enough for an invisible inpaint
(PillEngine.footprint_is_flat, median-Sobel <= _FLAT_TEXTURE_MAX). Keeps real
flat-scene pills and harmless flat false fires; leaves the damaging textured false
fires untouched. Corpus: 270 -> 118 removals, ~90 true preserved, damaging FP -> ~0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Victor Kuznetsov
2026-07-07 10:57:08 +03:00
parent 0e5a4cbc54
commit a8fd02a8f7
6 changed files with 128 additions and 26 deletions
+2 -2
View File
@@ -56,10 +56,10 @@ 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`). `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). **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; `best_auto_mark` single-strongest would leave one). **The `jimeng_pill` is CAPTURE-LESS** (`has_capture=False`, `pill_engine.py`): the top-left "AI生成" label has no alpha map, so it is detect-by-synthetic-silhouette + inpaint-only (every method resolves to inpaint). Its weak edge-NCC detector (~7% raw false-fire) is gated in `remove_auto_marks`: the pill is kept only when the image is CONFIRMED Jimeng -- **TC260 metadata present (CLI `_aigc_metadata_present` → `pill_metadata`) OR the bottom-right "★ 即梦AI" wordmark fired** -- AND the **Doubao mark did NOT fire** (a Doubao image is also TC260 but not Jimeng-basic). The wordmark arm is what keeps recall on **metadata-STRIPPED uploads** (screenshots / re-saved files, ~61% of pills carry a detectable wordmark) that a metadata-only gate would miss; do NOT drop it. **Two removal methods, selected by `KnownMark.remove(method=...)` / CLI `--method`:** `reverse-alpha` recovers exact pixels from the captured alpha map (lighter, better on structured backgrounds), `inpaint` erases the mark footprint (`footprint_mask` → MI-GAN, or cv2 without the `migan` extra). `auto` (default) = `resolve_removal_method`: **reverse-alpha for capture marks, inpaint for capture-less** (deterministic, model-independent). Reverse-alpha is the default where a capture exists because it recovers the true pixels and is measured CLEANER than MI-GAN inpaint on the capture marks (doubao/gemini/jimeng — MI-GAN adds a rainbow/smudge artifact on structure) AND lighter (no model/RAM); inpaint is reserved for the capture-less pill. The inpaint backend is chosen by `preferred_inpaint_backend`: **MI-GAN when its extra is installed, else cv2** — big-LaMa is NOT auto-selected (both run on onnxruntime, so availability cannot express intent; big-LaMa stays an explicit `erase --backend lama` opt-in). The inpaint mask MUST be the full NCC-aligned alpha silhouette (`footprint_mask`), NOT the per-image `extract_mask` signature — the signature under-segments and leaves glyph residue (corpus-validated 2026-07). Add a new **reverse-alpha** text mark = one `_text_mark(...)` row + a `TextMarkConfig` with a captured alpha map; do not re-add per-mark `if` branches. A **capture-less inpaint mark** (no alpha map, like `jimeng_pill`) is instead a `has_capture=False` `KnownMark` + a small engine exposing `detect`/`footprint_mask` (geometry + synthetic silhouette) + a metadata/cross-mark gate for its detector -- this path does NOT need a flat capture (removal quality comes from the inpaint backend, so a font-rendered silhouette is data-safe and sufficient). `cli._write_bgr_with_alpha` must NOT zero alpha in the watermark bbox (issue #30 white-box regression). **A new REVERSE-ALPHA visible mark is HARD-BLOCKED on real controlled flat captures** — the alpha map is solved by `scripts/visible_alpha_solve.py` from solid black + gray (+white) captures of the mark produced by the actual app/device at native resolution, committed under `data/<engine>_capture/captures/`. Do NOT synthesize the alpha by font-rendering the wordmark — the user rejected synthetic reconstruction as below the quality bar (2026-06-22), and per-image alpha falloff/variation needs the real capture. Corpus images are NOT a substitute (data-safety: no corpus-derived committed assets; and reverse-alpha needs the flat capture, not real content). 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). **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; `best_auto_mark` single-strongest would leave one). **The `jimeng_pill` is CAPTURE-LESS** (`has_capture=False`, `pill_engine.py`): the top-left "AI生成" label has no alpha map, so it is detect-by-synthetic-silhouette + inpaint-only (every method resolves to inpaint). 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 **two confirmation arms** because metadata 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 only** (CLI `_aigc_metadata_present``pill_metadata`, no wordmark) — only ~27% precise, its false fires are **textured ceilings/walls that inpaint visibly SMEARS**, so remove **only when the top-left footprint is flat enough for an invisible inpaint** (`pill_engine.footprint_is_flat`, median-Sobel texture ≤ `_FLAT_TEXTURE_MAX`). 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 metadata arm back to a bare metadata-OR gate. **Two removal methods, selected by `KnownMark.remove(method=...)` / CLI `--method`:** `reverse-alpha` recovers exact pixels from the captured alpha map (lighter, better on structured backgrounds), `inpaint` erases the mark footprint (`footprint_mask` → MI-GAN, or cv2 without the `migan` extra). `auto` (default) = `resolve_removal_method`: **reverse-alpha for capture marks, inpaint for capture-less** (deterministic, model-independent). Reverse-alpha is the default where a capture exists because it recovers the true pixels and is measured CLEANER than MI-GAN inpaint on the capture marks (doubao/gemini/jimeng — MI-GAN adds a rainbow/smudge artifact on structure) AND lighter (no model/RAM); inpaint is reserved for the capture-less pill. The inpaint backend is chosen by `preferred_inpaint_backend`: **MI-GAN when its extra is installed, else cv2** — big-LaMa is NOT auto-selected (both run on onnxruntime, so availability cannot express intent; big-LaMa stays an explicit `erase --backend lama` opt-in). The inpaint mask MUST be the full NCC-aligned alpha silhouette (`footprint_mask`), NOT the per-image `extract_mask` signature — the signature under-segments and leaves glyph residue (corpus-validated 2026-07). Add a new **reverse-alpha** text mark = one `_text_mark(...)` row + a `TextMarkConfig` with a captured alpha map; do not re-add per-mark `if` branches. A **capture-less inpaint mark** (no alpha map, like `jimeng_pill`) is instead a `has_capture=False` `KnownMark` + a small engine exposing `detect`/`footprint_mask` (geometry + synthetic silhouette) + a metadata/cross-mark gate for its detector -- this path does NOT need a flat capture (removal quality comes from the inpaint backend, so a font-rendered silhouette is data-safe and sufficient). `cli._write_bgr_with_alpha` must NOT zero alpha in the watermark bbox (issue #30 white-box regression). **A new REVERSE-ALPHA visible mark is HARD-BLOCKED on real controlled flat captures** — the alpha map is solved by `scripts/visible_alpha_solve.py` from solid black + gray (+white) captures of the mark produced by the actual app/device at native resolution, committed under `data/<engine>_capture/captures/`. Do NOT synthesize the alpha by font-rendering the wordmark — the user rejected synthetic reconstruction as below the quality bar (2026-06-22), and per-image alpha falloff/variation needs the real capture. Corpus images are NOT a substitute (data-safety: no corpus-derived committed assets; and reverse-alpha needs the flat capture, not real content). 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 remover/detector (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (`_SELECT_TOPK`), corner-promote, over/under-subtraction guards, false-positive gate, self-verify repair. 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). Gate any removal candidate on a physical brightness check, not the detector alone.
- `_text_mark_engine.py` — shared base for the three reverse-alpha text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. 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 removes cleanly while a match box leaves outline residue). Removal is inpaint-only (MI-GAN/cv2). Detector precision is weak (~7% raw false-fire), so it is registry-gated in `remove_auto_marks`: kept only when Jimeng is confirmed (TC260 metadata OR the bottom-right wordmark fired -- the wordmark survives metadata-STRIPPED uploads) AND Doubao did not fire; do NOT loosen those gates.
- `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 removes 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-inpaint gate. Removal is inpaint-only (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 inpaint smears). Do NOT loosen those gates.
- `doubao_engine.py` / `jimeng_engine.py` / `samsung_engine.py` — thin `TextMarkEngine` subclasses: Doubao "豆包AI生成" (bottom-right), Jimeng "★ 即梦AI" (bottom-right), Samsung Galaxy AI "✦ Contenuti generati dall'AI" (bottom-LEFT, locale-specific — Italian variant calibrated). Removal = reverse-alpha (always-align) + thin residual inpaint, **with an over-subtraction guard ported from `gemini_engine` (2026-06-20)**: `_reverse_alpha_oversubtracts` predicts the reverse-alpha output PER PIXEL over the glyph body from the INPUT, and when the recovered body lands more than `_OVERSUB_DARK_MARGIN` (25) gray levels below the local ring it abandons the reverse-alpha pixels and inpaints the footprint from the original surroundings (`_inpaint_footprint`) — fixing the dark-pit ghost on dark/mid-tone backgrounds (roadmap P0#8). Predicting per-pixel from the input (not the produced output) keeps a clean full-strength mark byte-identical (no false trip). A detector-only removal test is insufficient — assert visual residual (the textured-shift tests + `tests/test_text_mark_oversubtraction.py`).
- `region_eraser.py` — universal region eraser (`erase` CLI) and the inpaint backend for the visible-mark fallback. Three backends: `cv2` (default, no deps), `migan` (MI-GAN ONNX, extra `migan`, MIT, ~28 MB / ~0.95 GB peak / ~0.19 s — the droplet-friendly tier, **the preferred backend for the auto inpaint fallback** via `watermark_registry.preferred_inpaint_backend`), `lama` (big-LaMa ONNX, extra `lama`, ~200 MB / ~4.7 GB peak — best quality, does not fit a minimal droplet, explicit opt-in only). **MI-GAN mask polarity is INVERTED** (0=hole/255=known) vs this package's 255-erase convention; `erase_migan` inverts before feeding the model (feeding 255=hole regenerates the whole frame into stripes — corpus-validated). Both ONNX models download on first use, never bundled.
- `invisible_watermark.py` — decodes the OPEN DWT-DCT watermarks (SD / SDXL / FLUX) via `imwatermark` (extra `detect`, pulls torch). Fragile two ways: (1) does not survive JPEG re-encode/resize; (2) **carrier-fragile on a broad class of pristine images** -- a clean encode->decode round-trip recovers 48/48 on chatgpt/firefly/random but FAILS (28-39/48, below the `_MATCH_48`=44 gate) on the FLUX fox, doubao, a flat FLUX generation, AND a clean synthetic flat fill with no watermark. The failure does NOT track texture; it goes with a degenerate **all-ones decode that is a CARRIER ARTIFACT, not a watermark** (synthetic clean image reproduces it). So `detect_invisible_watermark` is **positive-only**: trust a hit; a `None` is inconclusive unless a same-carrier positive-control embed first recovers >=44. Verified 2026-06-19; full caveat in `docs/watermarking-landscape.md`.
+1 -1
View File
@@ -258,7 +258,7 @@ Diffusion SynthID removal. The `--tile/--no-tile` knob is the *lossless* alterna
### `visible`
Known-visible-mark removal. Two methods via `--method` (default `auto`): `reverse-alpha` inverts the captured alpha map to recover exact pixels (CPU, no GPU, better on structured backgrounds); `inpaint` erases the mark footprint (`footprint_mask` → MI-GAN when the `migan` extra is installed, else cv2; see `preferred_inpaint_backend` — big-LaMa is NOT auto-selected). `auto` = `registry.resolve_removal_method`: reverse-alpha for capture marks, inpaint for capture-less (deterministic, model-independent -- reverse-alpha is measured cleaner + lighter than MI-GAN on the capture marks, so inpaint is reserved for the pill); the resolved method is echoed in the status line, and `--method` is shared across `visible`/`all`/`batch`. The inpaint mask is the full NCC-aligned alpha silhouette (`footprint_mask`), NOT the per-image `extract_mask` signature — the signature under-segments and leaves glyph residue (corpus-validated 2026-07; the mask is dilated to absorb alpha-alignment slop). `--mark auto` (default) removes EVERY detected mark in one pass (`registry.remove_auto_marks`, not the single strongest -- a Jimeng-basic image carries both the top-left pill and the bottom-right wordmark) from: the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, Italian locale), and the capture-less Jimeng "AI生成" pill (top-left, `pill_engine`, inpaint-only). The pill's weak edge-NCC detector is gated in `remove_auto_marks`: kept only when the image is confirmed Jimeng -- TC260 metadata present (CLI `_aigc_metadata_present``pill_metadata`) OR the bottom-right wordmark fired -- AND Doubao did not fire. The wordmark arm keeps recall on metadata-STRIPPED uploads (screenshots / re-saves) that a metadata-only gate would miss. `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one (choices come from the registry). Under `reverse-alpha`, 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`. **When `--mark auto` finds no known mark (the common case — ~74% of real uploads carry no registered visible mark), the command does NOT silently re-serve the input as a finished result.** It runs a cheap metadata-only `identify`, prints actionable guidance (if the image carries an invisible/metadata mark, e.g. an OpenAI/Gemini C2PA image, it points to `all`; otherwise it does NOT imply the image is clean -- it warns that an invisible pixel watermark like SynthID cannot be detected once the metadata proxy is gone and routes to both `all` and `erase --region`), writes NO output file, and exits **`EXIT_NO_VISIBLE_MARK` (2)** — distinct from success (0) and a hard error (1) so a wrapping service (raiw.cc) can surface the message instead of treating the unchanged image as done (the production "it didn't work" / score-0 trap). Same handling for an explicit `--mark <name>` that is not detected. Helper `cli._no_visible_mark_exit`; regression-guarded by `tests/test_cli.py::TestVisibleCommand::test_visible_auto_no_mark_exits_two_with_eraser_hint` and `test_visible_auto_no_mark_routes_to_all_when_metadata`. `--no-detect` still forces the gemini fallback and proceeds (exit 0).
Known-visible-mark removal. Two methods via `--method` (default `auto`): `reverse-alpha` inverts the captured alpha map to recover exact pixels (CPU, no GPU, better on structured backgrounds); `inpaint` erases the mark footprint (`footprint_mask` → MI-GAN when the `migan` extra is installed, else cv2; see `preferred_inpaint_backend` — big-LaMa is NOT auto-selected). `auto` = `registry.resolve_removal_method`: reverse-alpha for capture marks, inpaint for capture-less (deterministic, model-independent -- reverse-alpha is measured cleaner + lighter than MI-GAN on the capture marks, so inpaint is reserved for the pill); the resolved method is echoed in the status line, and `--method` is shared across `visible`/`all`/`batch`. The inpaint mask is the full NCC-aligned alpha silhouette (`footprint_mask`), NOT the per-image `extract_mask` signature — the signature under-segments and leaves glyph residue (corpus-validated 2026-07; the mask is dilated to absorb alpha-alignment slop). `--mark auto` (default) removes EVERY detected mark in one pass (`registry.remove_auto_marks`, not the single strongest -- a Jimeng-basic image carries both the top-left pill and the bottom-right wordmark) from: the Gemini sparkle, the Doubao "豆包AI生成" text strip, the Jimeng "★ 即梦AI" wordmark, the Samsung Galaxy AI "✦ Contenuti generati dall'AI" strip (bottom-LEFT, Italian locale), and the capture-less Jimeng "AI生成" pill (top-left, `pill_engine`, inpaint-only). The pill's weak edge-NCC detector is gated in `remove_auto_marks` via `_keep_pill` (32k real-upload corpus validation 2026-07): never on Doubao, and two confirmation arms since metadata confirms the platform, not pill presence. (1) The bottom-right wordmark fired — ~94% precise and survives metadata-STRIPPED uploads (screenshots / re-saves) — removes the pill unrestricted. (2) TC260 metadata only (CLI `_aigc_metadata_present``pill_metadata`, no wordmark) — ~27% precise, its false fires are textured ceilings/walls that inpaint visibly SMEARS — removes the pill ONLY when the top-left footprint is flat enough for an invisible inpaint (`pill_engine.footprint_is_flat`, median-Sobel ≤ `_FLAT_TEXTURE_MAX`). No confirmation → never removed. `--mark gemini|doubao|jimeng|samsung|jimeng_pill` forces one (choices come from the registry). Under `reverse-alpha`, 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`. **When `--mark auto` finds no known mark (the common case — ~74% of real uploads carry no registered visible mark), the command does NOT silently re-serve the input as a finished result.** It runs a cheap metadata-only `identify`, prints actionable guidance (if the image carries an invisible/metadata mark, e.g. an OpenAI/Gemini C2PA image, it points to `all`; otherwise it does NOT imply the image is clean -- it warns that an invisible pixel watermark like SynthID cannot be detected once the metadata proxy is gone and routes to both `all` and `erase --region`), writes NO output file, and exits **`EXIT_NO_VISIBLE_MARK` (2)** — distinct from success (0) and a hard error (1) so a wrapping service (raiw.cc) can surface the message instead of treating the unchanged image as done (the production "it didn't work" / score-0 trap). Same handling for an explicit `--mark <name>` that is not detected. Helper `cli._no_visible_mark_exit`; regression-guarded by `tests/test_cli.py::TestVisibleCommand::test_visible_auto_no_mark_exits_two_with_eraser_hint` and `test_visible_auto_no_mark_routes_to_all_when_metadata`. `--no-detect` still forces the gemini fallback and proceeds (exit 0).
### `batch`
+6 -4
View File
@@ -340,10 +340,12 @@ def _warn_if_esrgan_unavailable(upscaler: str) -> None:
def _aigc_metadata_present(path: Path) -> bool:
"""True when the file carries a China-AIGC (TC260) metadata label. Used to gate
the weak-detector 'AI生成' pill: metadata confirms Jimeng-class provenance. NB
this is only ONE of two confirmations -- ``remove_auto_marks`` also accepts the
bottom-right wordmark, so a metadata-STRIPPED upload can still be handled."""
"""True when the file carries a China-AIGC (TC260) metadata label. Feeds the
weak-detector 'AI生成' pill gate (``remove_auto_marks`` ``_keep_pill``): metadata
confirms Jimeng-class provenance but not pill presence, so the metadata-only arm
removes the pill ONLY on a flat, safe-to-inpaint footprint. The reliable
bottom-right wordmark is the other, unrestricted confirmation arm (and it survives
a metadata-STRIPPED upload)."""
with contextlib.suppress(Exception):
from remove_ai_watermarks import metadata
+49 -4
View File
@@ -51,6 +51,19 @@ _DETECT_THRESHOLD = 0.22 # edge-NCC gate, corpus-calibrated
_MASK_X0, _MASK_Y0 = 0.012, 0.006 # x0 of W, y0 of H
_MASK_W, _MASK_H = 0.205, 0.115 # width of W, height of W
# Background-flatness gate for the metadata-only pill arm (see remove_auto_marks).
# The pill detector is weak (~7% raw false-fire); metadata confirms the platform,
# not pill presence, so its false fires are real Jimeng-class content WITHOUT a pill.
# Those false fires cluster on TEXTURED top-left corners (ceiling fixtures, structure)
# where inpaint visibly SMEARS, while real pills and harmless false fires sit on FLAT
# corners (sky / wall / solid) where inpaint is invisible. So the metadata-only arm
# removes the pill only when the footprint background is flat enough for a safe,
# invisible inpaint. Threshold = median Sobel magnitude over the footprint box at a
# normalized width; corpus-validated on 32k real uploads 2026-07 (real pills median
# ~3.2, textured-ceiling false fires median ~8+). The reliable bottom-right wordmark
# arm is NOT texture-gated -- a wordmark-confirmed pill is removed regardless.
_FLAT_TEXTURE_MAX = 6.0
_silhouette: NDArray[Any] | None = None
@@ -104,6 +117,38 @@ class PillEngine:
score, box = m
return PillDetection(score >= _DETECT_THRESHOLD, score, box)
def _footprint_box(self, image: NDArray[Any]) -> tuple[int, int, int, int] | None:
h, w = image.shape[:2]
x0, y0 = int(_MASK_X0 * w), int(_MASK_Y0 * h)
x1, y1 = min(w, x0 + int(_MASK_W * w)), min(h, y0 + int(_MASK_H * w))
if x1 <= x0 or y1 <= y0:
return None
return x0, y0, x1, y1
def footprint_texture(self, image: NDArray[Any]) -> float:
"""Median gradient magnitude over the fixed top-left footprint box at a
normalized width. A robust flatness proxy: low = flat (sky / wall / solid,
inpaint invisible), high = textured (ceiling fixtures / structure, inpaint
smears). Median (not mean) so the pill's own edges -- a minority of the box --
do not inflate it. Backs the metadata-only arm's safe-inpaint gate."""
if image is None or image.size == 0:
return 0.0
box = self._footprint_box(image)
if box is None:
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
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)
gy = cv2.Sobel(gray, cv2.CV_32F, 0, 1, ksize=3)
return float(np.median(cv2.magnitude(gx, gy)))
def footprint_is_flat(self, image: NDArray[Any], *, thresh: float = _FLAT_TEXTURE_MAX) -> bool:
"""True when the top-left footprint is flat enough for an invisible inpaint."""
return self.footprint_texture(image) <= thresh
def footprint_mask(self, image: NDArray[Any], *, force: bool = False) -> NDArray[Any] | None:
"""Full-frame uint8 mask (255 = pill) over the pill's known top-left region.
@@ -116,11 +161,11 @@ class PillEngine:
fixed regardless)."""
if image is None or image.size == 0:
return None
h, w = image.shape[:2]
x0, y0 = int(_MASK_X0 * w), int(_MASK_Y0 * h)
x1, y1 = min(w, x0 + int(_MASK_W * w)), min(h, y0 + int(_MASK_H * w))
if x1 <= x0 or y1 <= y0:
box = self._footprint_box(image)
if box is None:
return None
x0, y0, x1, y1 = box
h, w = image.shape[:2]
mask = np.zeros((h, w), np.uint8)
mask[y0:y1, x0:x1] = 255
return mask
+29 -8
View File
@@ -344,6 +344,31 @@ def best_auto_mark(image: NDArray[Any]) -> MarkDetection | None:
return max(fired, key=lambda d: d.confidence) if fired else None
def _keep_pill(image: NDArray[Any], keys: set[str], *, pill_metadata: bool) -> bool:
"""Whether to auto-remove the capture-less 'AI生成' pill given the fired marks.
The pill detector is weak (~7% raw false-fire) and metadata confirms the platform,
not pill presence, so a naive metadata-OR-wordmark gate over-fires: on a 32k
real-upload corpus (2026-07) the metadata-only arm was only ~27% precise and its
false fires were textured ceilings/walls that inpaint visibly SMEARS. Two arms:
* bottom-right "★ 即梦AI" wordmark fired -> ~94% precise, and it survives
metadata-STRIPPED uploads: remove the pill unrestricted;
* metadata only (TC260 AIGC, no wordmark) -> remove ONLY when the top-left
footprint is flat enough for an invisible inpaint (``footprint_is_flat``),
so real flat-scene pills (and harmless flat false fires) are cleaned while
the damaging textured false fires are left untouched.
A Doubao image is TC260 too but is not Jimeng-basic, so the pill never rides on a
Doubao detection. No confirmation at all -> never remove (blocks false fires on
non-Jimeng content)."""
if "doubao" in keys:
return False
if "jimeng" in keys:
return True
if pill_metadata:
return bool(_engine("jimeng_pill").footprint_is_flat(image))
return False
def remove_auto_marks(
image: NDArray[Any],
*,
@@ -362,16 +387,12 @@ def remove_auto_marks(
its own corner on the progressively-cleaned image, so order does not matter.
The capture-less ``jimeng_pill`` has a weak edge-NCC detector (~7% raw
false-fire), so it is kept only when the image is CONFIRMED Jimeng and NOT
Doubao. Confirmation is ``pill_metadata`` (the China-AIGC / TC260 metadata
signal, supplied by the caller) OR the reliable bottom-right wordmark firing --
the wordmark keeps recall on metadata-STRIPPED uploads (screenshots / re-saved
files) that the metadata gate alone would miss. Returns ``(result, [labels
removed])``; an empty list means nothing fired."""
false-fire), so its removal is gated by ``_keep_pill`` (Jimeng-class confirmation
+ a safe-inpaint check on the metadata-only arm; see that helper). Returns
``(result, [labels removed])``; an empty list means nothing fired."""
fired = [d for d in detect_marks(image, include_explicit=False) if d.detected]
keys = {d.key for d in fired}
jimeng_confirmed = pill_metadata or "jimeng" in keys
if "jimeng_pill" in keys and (not jimeng_confirmed or "doubao" in keys):
if "jimeng_pill" in keys and not _keep_pill(image, keys, pill_metadata=pill_metadata):
fired = [d for d in fired if d.key != "jimeng_pill"]
result = image
for det in fired:
+41 -7
View File
@@ -56,6 +56,17 @@ class TestPillDetect:
assert not PillEngine().detect(np.full((40, 40, 3), 150, np.uint8)).detected
def _textured_frame(w: int = 300, h: int = 400, bg: int = 150) -> np.ndarray:
"""Flat frame with a high-frequency checkerboard over the top-left footprint,
so the pill footprint reads as TEXTURED (an inpaint there would smear)."""
img = np.full((h, w, 3), bg, np.uint8)
fx, fy, fw, fh = int(0.012 * w), int(0.006 * h), int(0.205 * w), int(0.115 * w)
yy, xx = np.mgrid[0:fh, 0:fw]
checker = (((xx // 3) + (yy // 3)) % 2 * 255).astype(np.uint8)
img[fy : fy + fh, fx : fx + fw] = checker[:, :, None]
return img
class TestPillMask:
def test_footprint_mask_top_left_geometry(self) -> None:
mask = PillEngine().footprint_mask(np.full((1600, 1200, 3), 150, np.uint8))
@@ -68,6 +79,19 @@ class TestPillMask:
assert xs.mean() < 600
class TestFootprintFlatness:
"""The metadata-only pill arm removes only on a flat footprint (safe inpaint)."""
def test_flat_frame_is_flat(self) -> None:
assert PillEngine().footprint_is_flat(np.full((1600, 1200, 3), 150, np.uint8))
def test_textured_frame_is_not_flat(self) -> None:
eng = PillEngine()
assert not eng.footprint_is_flat(_textured_frame(1200, 1600))
# median-Sobel texture is well above the flat threshold on the checkerboard
assert eng.footprint_texture(_textured_frame(1200, 1600)) > 6.0
class TestPillRegistry:
def test_pill_is_capture_less(self) -> None:
m = registry.get_mark("jimeng_pill")
@@ -81,9 +105,11 @@ class TestPillRegistry:
class TestPillGate:
"""The pill is kept only when the image is CONFIRMED Jimeng (TC260 metadata OR
the bottom-right wordmark fired) and NOT Doubao. Fakes detect_marks so no image
content is needed; cv2 backend so nothing downloads."""
"""Pill removal is gated (``_keep_pill``): the reliable bottom-right wordmark
removes it unrestricted, the metadata-only arm removes it ONLY on a flat footprint
(safe inpaint), Doubao/no-confirmation never remove it. Fakes detect_marks so no
image content is needed; cv2 backend so nothing downloads. Frame flatness matters
now, so tests pass a flat or a textured frame explicitly."""
@staticmethod
def _fakes(monkeypatch: pytest.MonkeyPatch, keys: set[str]) -> None:
@@ -103,15 +129,23 @@ class TestPillGate:
],
)
def test_pill_kept_with_metadata(self, monkeypatch: pytest.MonkeyPatch) -> None:
def test_pill_kept_with_metadata_on_flat_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# metadata-only + flat background -> safe inpaint, remove
self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), pill_metadata=True)
assert "Jimeng AI生成 pill" in removed
def test_pill_kept_via_wordmark_without_metadata(self, monkeypatch: pytest.MonkeyPatch) -> None:
# metadata-stripped upload: the bottom-right wordmark confirms Jimeng
def test_pill_dropped_with_metadata_on_textured_footprint(self, monkeypatch: pytest.MonkeyPatch) -> None:
# metadata-only + textured background (ceiling-like) -> inpaint would smear, skip
self._fakes(monkeypatch, {"jimeng_pill"})
_, removed = registry.remove_auto_marks(_textured_frame(), pill_metadata=True)
assert "Jimeng AI生成 pill" not in removed
def test_pill_kept_via_wordmark_ignores_texture(self, monkeypatch: pytest.MonkeyPatch) -> None:
# wordmark confirmation (~94% precise, survives metadata stripping) is NOT
# texture-gated: a wordmark-confirmed pill is removed even on a textured frame
self._fakes(monkeypatch, {"jimeng", "jimeng_pill"})
_, removed = registry.remove_auto_marks(np.full((400, 300, 3), 150, np.uint8), pill_metadata=False)
_, removed = registry.remove_auto_marks(_textured_frame(), pill_metadata=False)
assert "Jimeng AI生成 pill" in removed
def test_pill_dropped_without_metadata_or_wordmark(self, monkeypatch: pytest.MonkeyPatch) -> None: