From 59823b2eaf2aa4df3f75848af114e906412cb4b2 Mon Sep 17 00:00:00 2001 From: Victor Kuznetsov Date: Tue, 28 Jul 2026 12:12:26 -0700 Subject: [PATCH] Recover malformed metadata containers, release 0.20.2 --- CLAUDE.md | 2 +- docs/known-limitations.md | 5 ++- docs/module-internals.md | 5 ++- docs/python-api.md | 5 ++- pyproject.toml | 2 +- src/remove_ai_watermarks/__init__.py | 2 +- src/remove_ai_watermarks/metadata.py | 23 ++++++++++++ tests/test_metadata.py | 56 ++++++++++++++++++++++++++++ uv.lock | 2 +- 9 files changed, 95 insertions(+), 7 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6cbdd8c..4223a78 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -56,7 +56,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal - `noai/c2pa.py` — C2PA reading. `extract_c2pa_info(path)` uses the official **c2pa-python `Reader`** first (core dep, any container; `read_manifest_store_json` returns the WHOLE store JSON — active + ingredient manifests — so an AI marker on a parent manifest is seen), and falls back to the hand-rolled caBX/CBOR parser (`has_c2pa_metadata` / `extract_c2pa_chunk` / `_extract_c2pa_info_png`) for synthetic/partial blobs the validator rejects or a broken/absent wheel. The registry scan (issuer / source-type / SynthID / soft-binding) is shared by both paths via `_populate_registry_fields`, so the return-dict shape is identical. Do not reimplement chunk parsing; chunk reads are clamped to the remaining file size by design. `extract_c2pa_chunk`/`inject_c2pa_chunk` stay PNG-only (raw caBX bytes, test/extractor use). - `noai/constants.py` — the single `C2PA_AI_VENDORS` registry (+ `C2PA_SOFT_BINDINGS`) from which `C2PA_ISSUERS` / `SYNTHID_C2PA_ISSUERS` / `C2PA_IDENTITY_AI_ORGS` / `identify._ISSUER_PLATFORM` are all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline. A vendor's `asserts_ai=True` flag means its mere presence asserts AI generation even without a `trainedAlgorithmicMedia` digital-source-type (a pure-generator brand with a distinctive issuer/generator string, e.g. **Dreamina** — ByteDance's international Jimeng brand, signed as "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and no source-type); NEVER set it for common-word issuers (Adobe/Google/OpenAI/Microsoft) that appear incidentally in unrelated bytes — those stay source-type-gated in `identify._attribute_platform`. -- `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`). **A caller that REPORTS an outcome must use `strip_and_verify`, not `remove_ai_metadata` directly** -- the stripper is deliberately fail-safe (a file PIL cannot decode is copied through UNCHANGED rather than crashing), so its return value cannot distinguish a no-op from a real strip. `metadata --remove` and `batch --mode metadata|all` both re-scan the OUTPUT through it and fail loudly; verified on Samsung Galaxy S22 C2PA PNGs, where the command printed "stripped" and exited 0 while the output still read as AI (2026-07-19). **`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; XMP APP1 carrying C2PA, a China-AIGC token, OR an IPTC `digitalSourceType` / 2025.1 AI-disclosure marker; IPTC-IIM APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so **the pixels are bit-identical** (no DCT re-encode). **Detection<->removal parity across every marker placement is load-bearing** — anything a scanner flags, the strip must reach, or a re-served file still reads as AI: (a) the APP1-XMP branch of `_jpeg_app_carries_ai` checks the IPTC marker sets too, not only C2PA/AIGC (the Instagram/MidJourney/Meta "Made with AI" `digitalSourceType` lives in XMP, not the APP13 IIM record); (b) a bare `AIGC{...}` / `{"AIGC":{...}}` block in ANY JPEG APP segment — the specific C2PA(APP11)/XMP(APP1)/IPTC(APP13) checks FALL THROUGH to a generic `_is_aigc_exif_value` drop, so a bare AIGC in APP11 (the supported placement, NOT a C2PA manifest) is caught, not swallowed by the C2PA-only 0xEB branch — plus the same AIGC block in a STANDARD **PNG text chunk** value (e.g. `Description`, which `_is_ai_key` keeps) is dropped on the value; (c) the China TC260 `{"AIGC":{...}}` block in EXIF `UserComment`/`ImageDescription` is scrubbed by `_scrub_ai_exif` (Doubao producer + Tencent service-provider schemas); (d) the Samsung Galaxy AI `PhotoEditor_Re_Edit_Data` trailer past the JPEG EOI is truncated by `_strip_samsung_trailer` (and `samsung_genai` reads the file tail so a multi-MB photo's trailer past the 512 KB quick-scan window is still DETECTED). Pixels stay bit-identical throughout, so a `--strip-metadata` on a q100 removal output does NOT crush it back to q75; PNG/WebP re-saves are pixel-lossless (WebP written at cv2 lossless mode, quality 101 — quality 1-100 is lossy). **The PIL-fallback save format is chosen by the source's CONTENT, not its file extension** (`_sniff_image_format`, and the JPEG-lossless gate is content-gated too): inputs can be misnamed (a PNG served as `.jpg` is the common one), and routing on the extension re-encoded a lossless PNG/WebP into a real JPEG — a silent degradation that broke "work with originals". A **misnamed** lossless source (source-extension format != content) is preserved in its true format; a **correctly-named** source still honors a deliberate output-extension conversion (e.g. `source.png -> output.jpg`). Not yet handled: a 16-bit PNG is downconverted to 8-bit on the PIL re-save (rare; would need a byte-level PNG chunk stripper). Regression: `tests/test_metadata.py::TestHasAiMetadata::test_strip_preserves_lossless_content_with_mismatched_extension`. **`remove_ai_metadata` is fail-safe on an undecodable image:** a truncated/corrupt file (PIL raises `OSError` decoding it; some inputs) is copied through UNCHANGED rather than crashing a direct library caller (a web worker would 500 on a partial upload), mirroring `strip_c2pa_boxes` — we cannot strip what we cannot parse, but we never raise. Regression: `tests/test_metadata.py::TestHasAiMetadata::test_remove_ai_metadata_failsafe_on_truncated_png`. Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_jpeg_metadata_strip_is_pixel_lossless, test_jpeg_strip_removes_iptc_marker_in_xmp}`, `TestSamsungGenai::{test_remove_strips_post_eoi_trailer, test_detects_trailer_past_scan_window}`, the AIGC-EXIF/bare-APP removal tests, and `tests/test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`. `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}`. +- `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`). **A caller that REPORTS an outcome must use `strip_and_verify`, not `remove_ai_metadata` directly** -- the stripper is deliberately fail-safe (a file PIL cannot decode is copied through UNCHANGED rather than crashing), so its return value cannot distinguish a no-op from a real strip. `strip_and_verify` re-scans the output and, when metadata survived but `image_io` can still decode the raster, normalizes the container and scans again; that recovery preserves pixels but drops standard metadata. A truly undecodable file keeps the surviving-marker result. `metadata --remove` and `batch --mode metadata|all` use this verified path. **`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; XMP APP1 carrying C2PA, a China-AIGC token, OR an IPTC `digitalSourceType` / 2025.1 AI-disclosure marker; IPTC-IIM APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so **the pixels are bit-identical** (no DCT re-encode). **Detection<->removal parity across every marker placement is load-bearing** — anything a scanner flags, the strip must reach, or a re-served file still reads as AI: (a) the APP1-XMP branch of `_jpeg_app_carries_ai` checks the IPTC marker sets too, not only C2PA/AIGC (the Instagram/MidJourney/Meta "Made with AI" `digitalSourceType` lives in XMP, not the APP13 IIM record); (b) a bare `AIGC{...}` / `{"AIGC":{...}}` block in ANY JPEG APP segment — the specific C2PA(APP11)/XMP(APP1)/IPTC(APP13) checks FALL THROUGH to a generic `_is_aigc_exif_value` drop, so a bare AIGC in APP11 (the supported placement, NOT a C2PA manifest) is caught, not swallowed by the C2PA-only 0xEB branch — plus the same AIGC block in a STANDARD **PNG text chunk** value (e.g. `Description`, which `_is_ai_key` keeps) is dropped on the value; (c) the China TC260 `{"AIGC":{...}}` block in EXIF `UserComment`/`ImageDescription` is scrubbed by `_scrub_ai_exif` (Doubao producer + Tencent service-provider schemas); (d) the Samsung Galaxy AI `PhotoEditor_Re_Edit_Data` trailer past the JPEG EOI is truncated by `_strip_samsung_trailer` (and `samsung_genai` reads the file tail so a multi-MB photo's trailer past the 512 KB quick-scan window is still DETECTED). Pixels stay bit-identical throughout, so a `--strip-metadata` on a q100 removal output does NOT crush it back to q75; PNG/WebP re-saves are pixel-lossless (WebP written at cv2 lossless mode, quality 101 — quality 1-100 is lossy). **The PIL-fallback save format is chosen by the source's CONTENT, not its file extension** (`_sniff_image_format`, and the JPEG-lossless gate is content-gated too): inputs can be misnamed (a PNG served as `.jpg` is the common one), and routing on the extension re-encoded a lossless PNG/WebP into a real JPEG — a silent degradation that broke "work with originals". A **misnamed** lossless source (source-extension format != content) is preserved in its true format; a **correctly-named** source still honors a deliberate output-extension conversion (e.g. `source.png -> output.jpg`). Not yet handled: a 16-bit PNG is downconverted to 8-bit on the PIL re-save (rare; would need a byte-level PNG chunk stripper). Regression: `tests/test_metadata.py::TestHasAiMetadata::test_strip_preserves_lossless_content_with_mismatched_extension`. **`remove_ai_metadata` is fail-safe on an undecodable image:** a truncated/corrupt file (PIL raises `OSError` decoding it; some inputs) is copied through UNCHANGED rather than crashing a direct library caller (a web worker would 500 on a partial upload), mirroring `strip_c2pa_boxes` — we cannot strip what we cannot parse, but we never raise. Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_remove_ai_metadata_failsafe_on_truncated_png,test_strip_and_verify_normalizes_decodable_copy_through,test_strip_and_verify_reports_markers_in_undecodable_copy_through}`. Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_jpeg_metadata_strip_is_pixel_lossless, test_jpeg_strip_removes_iptc_marker_in_xmp}`, `TestSamsungGenai::{test_remove_strips_post_eoi_trailer, test_detects_trailer_past_scan_window}`, the AIGC-EXIF/bare-APP removal tests, and `tests/test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`. `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`. The IPTC `digitalSourceType` **`algorithmicMedia`** (bare) is PROCEDURAL (an algorithm not trained on sampled data), NOT AI/ML generation, so it is deliberately absent from `IPTC_AI_MARKERS` — flagging it made `identify` assert AI + `has_invisible_target` True, scrubbing clean procedural content (it is a distinct token from `trainedAlgorithmicMedia`, so real "Made with AI" labels are unaffected; regression `test_metadata.py::...test_bare_algorithmic_media_not_flagged_ai`). Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence). `_vendor_of` normalizes ByteDance/Canva/ElevenLabs/Black Forest Labs (as well as OpenAI/Google/... ) so their C2PA claims participate in the clash check; the generic **China TC260 AIGC label names no specific vendor**, so when a TC260-applying vendor (ByteDance, `_TC260_VENDORS`) is co-attributed the label is attributed to it (a legit Doubao image carrying its own TC260 label must NOT clash), while a NON-TC260 vendor next to a TC260 label still clashes as a laundering tell. The vendor normalization must not introduce clashes on clean compatibility samples. - `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / qwen / kling / samsung / runninghub / baidu / liblib / 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 memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. 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`, 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_trust` + the assumed-trust floor + 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` decides (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). **`resolve_trust` resolves TWO levels:** `confirmed` bypasses the engine's false-positive gate, and only `confirmed` has evidence naming THAT vendor, which is exactly what the bypass is contracted to require (`GeminiEngine.detect_watermark`'s `trust_provenance` docstring: "external metadata already proves this is a Google generation"). **Historical, kept as the reason the third level is gone:** a removed `assumed` level let `assume_ai` bypass the gate on the bare assertion an image is AI. It produced unacceptable false positives on clean camera images and was removed. A wrong relaxation only fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what made a SMALL false-fire rate arguable; it was never a licence for a 60% one. 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.** It uses a synthetic silhouette and a fixed top-left footprint. `_keep_pill` requires either a confirmed sibling wordmark or Jimeng provenance with a flat footprint. Do not loosen the flatness guard. **`assume_ai` was REMOVED (2026-07-19); `--sensitivity` is now `auto`/`strict` only.** It relaxed every mark's FP gate on the bare assertion an image is AI -- which names no vendor and no location, exactly what the bypass requires -- and had no place in the model (detector finds -> remove; finds nothing -> leave alone; user SEES a mark -> act on that). It took `_ASSUMED_CONF_FLOOR` / `assumed_floor_ok` / the `assumed` trust level with it, collapsing the ladder to `strict`/`confirmed`, and `_keep_pill` lost its `sensitivity` arg. Recall/precision on the unbiased sample are unchanged, so nothing on the default path moved. **Replacement advice is per mark:** `erase --region` is sound by construction; `--mark --no-detect` is reasonable (forced mask = the real glyph blob, non-empty 13/13); **`--mark gemini --no-detect` is NOT** -- it falls back to a fixed slot that covered the true sparkle on only **31% of 97** missed sparkles, so 69% fill a clean corner AND report a removal that did not happen. `cli._no_visible_mark_exit` follows that order and no longer suggests the removed mode. Migration raises loudly (`validate_sensitivity`, called from `api.remove_visible` and `Context.__post_init__`) because a `Literal` is unenforced at runtime and would silently downgrade a 0.15 caller to `auto`. **Detection and removal-mask extraction must use the same front-end.** The continuous `tophat` path can detect a mark whose binary glyph blob is empty, so its fallback mask uses the detector's own best-match box. Keep the textured regression fixture when changing this path. **Detector thresholds and geometry are calibrated per mark; do not port them between vendors without a fresh evaluation.** **New mark assets must be synthetic.** Font-render the mark, calibrate it on local evaluation inputs, and commit only the synthetic silhouette. Never derive a committed asset from source images. - `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). **White-core rescue:** the FP gate demotes a low-gradient match (soft edges), but a real FAINT sparkle also has soft edges -- so the gate keeps a low-grad match that is a strong (conf ≥ `_SPARKLE_KEEP_CONF` 0.52), bright (margin), near-WHITE-core sparkle (`_core_saturation` ≤ `_SPARKLE_WHITE_SAT` 0.20): a real sparkle core is white, a clean bright corner that shape-matches (sky/sun) is colored. This recovers ~14/20 metadata-stripped faint sparkles under the DEFAULT strict/auto (no flag, no metadata) at ~1.25% clean false-fire (baseline 0.55%); the ~0.51-scoring bright-bg FPs stay demoted (below 0.52). A learned classifier on the SAME features was measured WORSE than the tuned gate (2026-07 tier-1: MLP 86.7% recall vs 90.8% at equal FP), so the heuristic stays; a patch-CNN with richer features is the only lever left (roadmapped P2, low expected value -- the wall is fundamental). 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. `detect_sparkle_confidence` reuses a process-wide `_shared_engine()` singleton (lru_cache) — the engine holds only constant assets (captures, alpha maps, a precomputed 16..118 template ladder) and takes the image as an arg, so do NOT reconstruct `GeminiEngine()` per call: that reloaded assets + recomputed alpha maps + rebuilt the template cache on every one of ~34k `identify` calls (−24% on the sparkle path once made a singleton, output byte-identical). `detect_watermark`/`footprint_mask` guard `image.size == 0` before `to_bgr`, and return an empty (detected=False) result when no template scale fits (short side < 16 px), rather than dereferencing an empty candidate list. diff --git a/docs/known-limitations.md b/docs/known-limitations.md index 94ad4fc..72c9e7d 100644 --- a/docs/known-limitations.md +++ b/docs/known-limitations.md @@ -178,7 +178,10 @@ cannot parse the input. `remove_ai_metadata` may copy an undecodable file through unchanged instead of raising. User facing callers must use `strip_and_verify` and inspect its -surviving marker mapping before reporting success. The CLI does this. +surviving marker mapping before reporting success. `strip_and_verify` recovers +when `image_io` can still decode the raster by normalizing the container and +checking again. A truly undecodable file still reports the surviving markers. +The CLI uses this verified path. ### Sixteen bit PNG output is not preserved diff --git a/docs/module-internals.md b/docs/module-internals.md index 3e9d0e4..8432062 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -114,7 +114,10 @@ Key contracts: - The low-level remover is fail-safe and can copy an undecodable file through unchanged. - A caller that reports success must use `strip_and_verify`, which scans the - written output for surviving markers. + written output for surviving markers. If the metadata-preserving decoder + rejected the container but `image_io` can still decode its raster, + `strip_and_verify` normalizes that raster and scans again. A truly undecodable + file keeps the surviving-marker result. Detection and removal must stay in parity. A new marker is incomplete until the scanner can find it, the remover can reach every supported placement, and a diff --git a/docs/python-api.md b/docs/python-api.md index 82879bf..4bc2af6 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -119,7 +119,10 @@ if has_ai_metadata(source): Use `strip_and_verify` when your application reports that stripping succeeded. It checks the written output and returns `(output_path, surviving_markers)`. -Treat a nonempty `surviving_markers` mapping as a failure. +When the first strip leaves markers in a malformed but raster-decodable image, +it normalizes the container through `image_io` and checks again. That recovery +path preserves the pixels but drops standard metadata. Treat a nonempty +`surviving_markers` mapping as a failure. `remove_ai_metadata` is the lower level fail-safe transformer. It may copy an undecodable input through unchanged, so its return alone must not be presented diff --git a/pyproject.toml b/pyproject.toml index 08902b2..dd846ca 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remove-ai-watermarks" -version = "0.20.1" +version = "0.20.2" description = "AI watermark remover: strip visible and invisible AI watermarks (Gemini / Nano Banana sparkle, SynthID) and provenance metadata (C2PA, EXIF) from images" readme = "README.md" requires-python = ">=3.10.1" diff --git a/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index 420d720..b7cceda 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -25,7 +25,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") _warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*") -__version__ = "0.20.1" +__version__ = "0.20.2" __all__ = ["__version__", "remove_visible", "visible_provenance"] diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index d502968..9de9459 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -1091,9 +1091,32 @@ def strip_and_verify( exposed this when `metadata --remove` reported success while the output still read as AI. + If markers survive but OpenCV can still decode the raster, the verified path + normalizes the container through :mod:`image_io` and scans once more. This + intentionally drops standard metadata on that recovery path because the original + container is too malformed for the metadata-preserving decoder. + Returns ``(output_path, surviving_markers)``; an empty mapping means a real strip. """ out = remove_ai_metadata(source_path, output_path, keep_standard=keep_standard) + remaining = get_ai_metadata(out) + if not remaining: + return out, {} + + import cv2 + + from remove_ai_watermarks import image_io + + image = image_io.imread(out, cv2.IMREAD_UNCHANGED) + if image is None: + return out, remaining + logger.warning( + "AI metadata survived stripping; normalizing decodable raster: path=%s fields=%s", + out, + ",".join(sorted(remaining)), + ) + if not image_io.imwrite(out, image): + raise OSError(f"Failed to normalize image after incomplete metadata stripping: {out}") return out, get_ai_metadata(out) diff --git a/tests/test_metadata.py b/tests/test_metadata.py index 2d4c6f5..85555ba 100644 --- a/tests/test_metadata.py +++ b/tests/test_metadata.py @@ -22,6 +22,7 @@ from remove_ai_watermarks.metadata import ( iptc_ai_system, remove_ai_metadata, samsung_genai, + strip_and_verify, synthid_source, xai_signature, ) @@ -29,6 +30,28 @@ from remove_ai_watermarks.metadata import ( # Real, committed C2PA sample images used to ground the SynthID-source tests. SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance" + +def _png_chunk(kind: bytes, payload: bytes, *, corrupt_crc: bool = False) -> bytes: + """Encode one PNG chunk, optionally corrupting its CRC.""" + import zlib + + crc = zlib.crc32(kind + payload) & 0xFFFFFFFF + if corrupt_crc: + crc ^= 1 + return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", crc) + + +def _corrupt_c2pa_png(path: Path) -> Path: + """Write a synthetic PNG that Pillow rejects but OpenCV can decode.""" + Image.new("RGB", (32, 32), (80, 120, 160)).save(path) + png = path.read_bytes() + idat_start = png.index(b"IDAT") - 4 + fake_c2pa = _png_chunk(b"caBX", b"\x00\x00\x00\x10jumbsynthetic-c2pa") + bad_exif = _png_chunk(b"eXIf", b"Exif\x00\x00synthetic", corrupt_crc=True) + path.write_bytes(png[:idat_start] + fake_c2pa + bad_exif + png[idat_start:]) + return path + + # ── Key detection ─────────────────────────────────────────────────── @@ -276,6 +299,39 @@ class TestHasAiMetadata: assert result == out assert out.read_bytes() == truncated.read_bytes() + def test_strip_and_verify_normalizes_decodable_copy_through(self, tmp_path: Path): + """A decodable raster must not retain C2PA after verified stripping.""" + import numpy as np + + from remove_ai_watermarks import image_io + + src = _corrupt_c2pa_png(tmp_path / "source.png") + out = tmp_path / "clean.png" + assert has_ai_metadata(src) + before = image_io.imread(src) + assert before is not None + + result, remaining = strip_and_verify(src, out) + + assert result == out + assert remaining == {} + assert not has_ai_metadata(out) + after = image_io.imread(out) + assert after is not None + assert np.array_equal(after, before) + + def test_strip_and_verify_reports_markers_in_undecodable_copy_through(self, tmp_path: Path): + """A truly undecodable file keeps the established fail-safe result.""" + src = _corrupt_c2pa_png(tmp_path / "source.png") + src.write_bytes(src.read_bytes()[: src.stat().st_size // 2]) + out = tmp_path / "copy.png" + + result, remaining = strip_and_verify(src, out) + + assert result == out + assert "c2pa_manifest" in remaining + assert out.read_bytes() == src.read_bytes() + class TestC2paMarkerIn: """The C2PA presence check requires a JUMBF wrapper or the C2PA uuid box, so diff --git a/uv.lock b/uv.lock index 70bab5b..62924fd 100644 --- a/uv.lock +++ b/uv.lock @@ -3187,7 +3187,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.20.1" +version = "0.20.2" source = { editable = "." } dependencies = [ { name = "c2pa-python" },