mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-15 05:50:45 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71dae8fe25 | ||
|
|
9fe12c3997 | ||
|
|
183793fbf2 | ||
|
|
7f22852593 | ||
|
|
d9204e2e1f | ||
|
|
2bdaa09e5f | ||
|
|
72f6734b45 | ||
|
|
220803c4d0 | ||
|
|
1dc6fee91a | ||
|
|
a4c901ff39 | ||
|
|
190dc89d23 |
No files matched your search
@@ -26,7 +26,7 @@ Per-command exit-code semantics (the no-signal / GPU-missing skip branches), tes
|
||||
- `uv run remove-ai-watermarks identify <image>` — provenance verdict (platform + watermark inventory + confidence); `--json` for machine output, `--no-visible` to skip the cv2 sparkle detector
|
||||
- `uv run remove-ai-watermarks metadata <image.png> --check` — inspect AI metadata (C2PA, EXIF, PNG chunks)
|
||||
- `uv run remove-ai-watermarks metadata <image.png> --remove -o <out.png>` — strip all AI metadata
|
||||
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the full `invisible` knob set above, plus `--backend` and `--sensitivity` for the visible localize -> fill pass. Applies the same no-signal skip per image; see the module doc.
|
||||
- `uv run remove-ai-watermarks batch <directory>` — process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the full `invisible` knob set above, plus `--backend` and `--sensitivity` for the visible localize -> fill pass. Applies the same no-signal skip per image; see the module doc. **Exit code:** non-zero when any image errored OR (mirroring single `all`) a `--mode invisible`/`all` image carried an invisible signal but the GPU extra was absent, so its SynthID scrub was skipped — it emits a loud warning and copies the input through (invisible mode) so the output dir stays complete; a wrapping service can then detect the incomplete run instead of trusting a silent exit 0.
|
||||
|
||||
## Test and lint
|
||||
|
||||
@@ -54,17 +54,17 @@ 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`). **`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 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`/`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 loosenLine truncated
|
||||
- `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.
|
||||
- `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; 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 common real-corpus 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): ~2% of real uploads are 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" (corpus-measured ~0.9% of files). 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; ~0.2% of real uploads) 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::TestExifGLine truncated
|
||||
- `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. Corpus-validated: adding the vendors introduced 0 new clashes on 5000 carriers.
|
||||
- `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 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`/`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 loosenLine truncated
|
||||
- `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.
|
||||
- `_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.
|
||||
- `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). Detection matches the glyph silhouette (NCC); removal localizes the glyph blob to a solid dilated box (`extract_mask`) and hands it to the shared fill. Corpus validation: doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. **Samsung detection is calibrated only for the Italian "Contenuti generati dall'AI" string** (a pre-existing limit, unchanged by the localize -> fill refactor but now surfaced because detection gates removal): non-Italian Samsung locales are not detected, and thus not removed, even though the fill mask itself is locale-independent; other locales need their own captured detection template.
|
||||
- `region_eraser.py` — universal region eraser (`erase` CLI) and the shared fill backend behind `watermark_registry.fill` for the visible localize -> fill removal. Three backends: `cv2` (default, no deps, the floor), `migan` (MI-GAN ONNX, extra `migan`, MIT, ~28 MB / ~0.95 GB peak / ~0.19 s — the droplet-friendly tier, **the preferred default fill** when the extra is installed), `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. The `erase` command keeps its own `--backend`/`--inpaint-method` (unchanged).
|
||||
- `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). Detection matches the glyph silhouette (NCC); removal localizes the glyph blob to a solid dilated box (`extract_mask`) and hands it to the shared fill. Corpus validation: doubao and jimeng localize + remove at ~100% with clean footprints (the filled region blends into its surroundings within a few LAB levels, no color shift, no dark pit); clean images with no vendor signature had 0% false removal. **Samsung detection is calibrated only for the Italian "Contenuti generati dall'AI" string** (a pre-existing limit, unchanged by the localize -> fill refactor but now surfaced because detection gates removal): non-Italian Samsung locales are not detected, and thus not removed, even though the fill mask itself is locale-independent; other locales need their own detection silhouette (the locale string font-rendered + calibrated on real positives), NOT an app capture.
|
||||
- `region_eraser.py` — universal region eraser (`erase` CLI) and the shared fill backend behind `watermark_registry.fill` for the visible localize -> fill removal. Three backends: `cv2` (default, no deps, the floor), `migan` (MI-GAN ONNX, extra `migan`, MIT, ~28 MB / ~0.19 s — the droplet-friendly tier, **the preferred default fill** when the extra is installed), `lama` (big-LaMa ONNX, extra `lama`, ~200 MB / ~4.7 GB peak — best quality, does not fit a minimal droplet, explicit opt-in only). Both `migan` and `lama` **crop a padded region around the mask** before inference and paste only masked pixels back, so peak RAM is bounded by the MARK size, not the image (`migan` ~0.6-0.9 GB regardless of upload size — feeding the whole frame scaled it to ~2.4 GB at 25 MP; `migan` feeds the crop at native resolution, `lama` resizes to its fixed 512²). **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. The `erase` command keeps its own `--backend`/`--inpaint-method` (unchanged).
|
||||
- `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`.
|
||||
- `trustmark_detector.py` — Adobe TrustMark open decoder (extra `trustmark`). Do NOT remove the JPEG re-encode false-positive gate — a lone TrustMark hit without it is almost always content noise.
|
||||
- `noai/watermark_remover.py` — `WatermarkRemover` with three diffusion pipelines selected by the explicit `pipeline` ctor arg, never inferred from `model_id`: `sdxl` (plain SDXL img2img), `controlnet` (SDXL + canny ControlNet, **the DEFAULT since 2026-06-09**), and `qwen` (Qwen-Image 20B MMDiT img2img, Apache-2.0, CUDA/cloud-class — best **text** preservation (incl. CJK); `_load_qwen_pipeline`/`_run_qwen`, bf16, no MPS fallback; call shape in the pure `_build_qwen_kwargs` using `true_cfg_scale`). Removal comes from the img2img `strength`; ControlNet only preserves text/face STRUCTURE — SynthID CAN survive controlnet on photoreal content at low strength. Qwen CERTIFIED oracle floors (2026-06-20): OpenAI **0.10** (seed-robust, clean on seeds 0-4), Gemini **0.25** (seed 0 verified, pin a seed — Gemini oracle rate-limits volume; higher than the controlnet Gemini floor 0.15). `resolve_strength(..., pipeline="qwen")` carries the Qwen ladder (`_QWEN_VENDOR_STRENGTH`), so `--pipeline qwen` gets the 0.25 Gemini floor automatically (the old manual `--strength 0.25` workaround is retired). `_build_qwen_kwargs` passes an explicit `height`/`width` from the input (floored to /16 via `_qwen_target_size`) — without it the pipeline defaults to a 1024x1024 SQUARE and silently squishes non-square inputs (fixed 2026-06-20). **`qwen` is a MANUAL opt-in only — there is NO auto-router.** Measured (`scripts/fidelity_metrics.py`, OCR-CER / ArcFace / LPIPS / Laplacian-var, NOT eyeball): qwen beats controlnet on ONE niche only — **clean body text on a plain background, no faces** (openai_1/2 CER 0.241 vs 0.385). controlnet wins FACES (it always has) AND **display/decorative text in a scene** (abba poster: controlnet CER 0.114 vs qwen 0.379 — canny holds letter shapes, qwen re-renders and garbles them). So a content `--pipeline auto` router and a faces+text **mixed dual-pass** were prototyped and **DROPPED** (2026-06-20): on the canonical faces+text case controlnet wins every metric incl. text, so mixed loses; and "text→qwen" can't be auto-decided (it is body-vs-display text that matters, undetectable cheaply). qwen stays for callers who KNOW their content is clean-text-heavy and face-free. No face-restore extra ships, by validated decision (every restore approach looked MORE AI-generated). `remove_watermark(region=(x,y,w,h), region_feather=...)` runs the regeneration but feather-composites only the AI box back over the original (via `noai/tiling.feather_region_composite`), preserving the real photo elsewhere — the **AI-enhanced composite** path (`identify` `ai_source_kind == "enhanced"`); the box is supplied by the caller (a C2PA composite manifest carries no reliable machine-readable region, so we do not fabricate one).
|
||||
- `noai/watermark_remover.py` — `WatermarkRemover` with three diffusion pipelines selected by the explicit `pipeline` ctor arg, never inferred from `model_id`: `sdxl` (plain SDXL img2img), `controlnet` (SDXL + canny ControlNet, **the DEFAULT since 2026-06-09**), and `qwen` (Qwen-Image 20B MMDiT img2img, Apache-2.0, CUDA/cloud-class — best **text** preservation (incl. CJK); `_load_qwen_pipeline`/`_run_qwen`, bf16, no MPS fallback; call shape in the pure `_build_qwen_kwargs` using `true_cfg_scale`). Removal comes from the img2img `strength`; ControlNet only preserves text/face STRUCTURE — SynthID CAN survive controlnet on photoreal content at low strength. **Both SDXL loaders (`_load_pipeline`, `_load_controlnet_pipeline`) pass `add_watermarker=False`** — diffusers otherwise embeds an open "Stable Diffusion XL" DWT-DCT invisible watermark on EVERY SDXL output whenever `invisible-watermark` is installed (the `detect` extra), so a watermark REMOVER would re-stamp a detectable AI watermark and the cleaned output re-reads as AI (`identify` → "Open invisible watermark: Stable Diffusion XL"; corpus-observed on the SynthID sample before the fix). Only the pipeline accepts the kwarg, not the `ControlNetModel` sub-model; qwen is not SDXL and has no such watermarker. Regression: `tests/test_platform.py::TestNoReembeddedWatermark`. Qwen CERTIFIED oracle floors (2026-06-20): OpenAI **0.10** (seed-robust, clean on seeds 0-4), Gemini **0.25** (seed 0 verified, pin a seed — Gemini oracle rate-limits volume; higher than the controlnet Gemini floor 0.15). `resolve_strength(..., pipeline="qwen")` carries the Qwen ladder (`_QWEN_VENDOR_STRENGTH`), so `--pipeline qwen` gets the 0.25 Gemini floor automatically (the old manual `--strength 0.25` workaround is retired). `_build_qwen_kwargs` passes an explicit `height`/`width` from the input (floored to /16 via `_qwen_target_size`) — without it the pipeline defaults to a 1024x1024 SQUARE and silently squishes non-square inputs (fixed 2026-06-20). **`qwen` is a MANUAL opt-in only — there is NO auto-router.** Measured (`scripts/fidelity_metrics.py`, OCR-CER / ArcFace / LPIPS / Laplacian-var, NOT eyeball): qwen beats controlnet on ONE niche only — **clean body text on a plain background, no faces** (openai_1/2 CER 0.241 vs 0.385). controlnet wins FACES (it always has) AND **display/decorative text in a scene** (abba poster: controlnet CER 0.114 vs qwen 0.379 — canny holds letter shapes, qwen re-renders and garbles them). So a content `--pipeline auto` router and a faces+text **mixed dual-pass** were prototyped and **DROPPED** (2026-06-20): on the canonical faces+text case controlnet wins every metric incl. text, so mixed loses; and "text→qwen" can't be auto-decided (it is body-vs-display text that matters, undetectable cheaply). qwen stays for callers who KNOW their content is clean-text-heavy and face-free. No face-restore extra ships, by validated decision (every restore approach looked MORE AI-generated). `remove_watermark(region=(x,y,w,h), region_feather=...)` runs the regeneration but feather-composites only the AI box back over the original (via `noai/tiling.feather_region_composite`), preserving the real photo elsewhere — the **AI-enhanced composite** path (`identify` `ai_source_kind == "enhanced"`); the box is supplied by the caller (a C2PA composite manifest carries no reliable machine-readable region, so we do not fabricate one).
|
||||
- `noai/tiling.py` — sliding-window tiled diffusion for large inputs (CLI `--tile`). `WatermarkRemover.remove_watermark` branches to `run_tiled` when `tile` is set AND the long side exceeds `tile_size`, refactoring the single-pass `_generate` into a per-tile `_generate_one` (the ControlNet edge map is rebuilt per tile inside it). Pure helpers `plan_tiles` (uniform-size tiles, last one flush to the edge) and `feather_weights` (strictly-positive separable taper -> partition-of-unity blend) are unit-tested without the model. Also home to `feather_region_composite(base, regenerated, box, *, feather)` — the pure region-targeted compositor for **AI-enhanced composites** (`ai_source_kind == "enhanced"`): blends the regenerated AI box back over the original with a feathered seam, leaving the real photo OUTSIDE the box pixel-exact. It backs `WatermarkRemover.remove_watermark(region=...)` (regenerate ONLY the AI region, not the whole frame); the no-model lossless region path stays `region_eraser.erase`. New tile/region-blend tuning goes in these pure helpers; do not inline blend math into the runner.
|
||||
- `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.
|
||||
@@ -87,9 +87,9 @@ Compact list. Full measurements, incident history, and oracle-validation runs li
|
||||
- Pyright first run is slow (2-3 min) due to ML deps (torch/diffusers/transformers stubs); full-project `uv run pyright` can stall for many minutes — scope it to changed files.
|
||||
- A third-party PIL plugin autoload (e.g. an HEIF/AVIF plugin) can raise a non-OSError (`ModuleNotFoundError`), not `UnidentifiedImageError`, when opening a file. Code that opens user-supplied or unknown-format files should `except Exception`, not just `OSError`/`UnidentifiedImageError`.
|
||||
- rich was dropped: the CLI + analysis scripts print plain text (`click.echo` / the `scripts/_plain_console.py` shim). `rich` is NOT a dependency — importing it breaks the core+dev CI sync; new scripts must use the shim. No Unicode glyphs / colors / progress bars in CLI output by design.
|
||||
- HEIC/AVIF are decodable on BOTH paths now: the pixel/removal path via the `image_io.imread` Pillow fallback (+ core `pillow-heif`), and metadata detection via a plugin-free binary scan. C2PA removal in those containers (and MP4/MOV/M4V) is `noai/isobmff.py`; JPEG-XL stays metadata/strip-only (Pillow can't decode it without `pillow-jxl`, not a dep). Non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. An AI-generator token in an `Exif` meta-box *item* (bytes in `mdat`/`idat`) is now blanked **in place** by `isobmff.blank_ai_exif_tokens` (same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — no `iinf`/`iloc` surgery, mirrors `blank_ai_xmp_packets`); it scrubs the AI-token value only, leaving camera/editor EXIF intact. Still NOT built: Resemble PerTh audio detection (no presence/confidence flag exists). non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. An AI-generator token in an `Exif` meta-box *item* (bytes in `mdat`/`idat`) is now blanked **in place** by `isobmff.blank_ai_exif_tokens` (same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — no `iinf`/`iloc` surgery, mirrors `blank_ai_xmp_packets`); it scrubs the AI-token value only, leaving camera/editor EXIF intact. Still NOT built: Resemble PerTh audio detection (no presence/confidence flag exists).
|
||||
- HEIC/AVIF are decodable on BOTH paths now: the pixel/removal path via the `image_io.imread` Pillow fallback (+ core `pillow-heif`), and metadata detection via a plugin-free binary scan. C2PA removal in those containers (and MP4/MOV/M4V) is `noai/isobmff.py`; JPEG-XL stays metadata/strip-only (Pillow can't decode it without `pillow-jxl`, not a dep). Non-ISOBMFF audio/video (WebM/MP3/WAV/FLAC/OGG) strips losslessly via ffmpeg on PATH. On the ISOBMFF path `remove_ai_metadata` routes to the container branch and never runs the JPEG `_scrub_ai_exif`, so `isobmff.blank_ai_exif_tokens` is the ONLY EXIF scrubber there and must stay in PARITY with it: it blanks **in place** (same-length space overwrite, piexif-validated so a coincidental II/MM run in pixels is ignored — no `iinf`/`iloc` surgery, mirrors `blank_ai_xmp_packets`) an AI-generator token in `Software`/`Make`/`Artist`/`ImageDescription`, the China TC260 `{"AIGC":{...}}` block in `ImageDescription`/`UserComment` (via `_is_aigc_exif_value`), AND the xAI/Grok `Signature:` + UUID-`Artist` pair — leaving camera/editor EXIF intact. Still NOT built: Resemble PerTh audio detection (no presence/confidence flag exists).
|
||||
- **SynthID technical reference: `docs/synthid.md`** — primary-source-cited doc covering mechanism (post-hoc encoder/decoder pair, 136-bit payload at 512x512, pixel-space, model weights NOT modified), robustness numbers (arXiv:2510.09263: ~99.98% TPR@0.1%FPR across 30 transforms including JPEG/crop/resize/color/noise), removal attacks and forensic detectability (arXiv:2605.09203: all 6 attacks detectable at >98% TPR@1%FPR), detectability limits (no public decoder, metadata-proxy only), oracle scope, and adoption landscape. Read that doc first before adding notes here.
|
||||
- **SynthID detection is metadata-only.** No local pixel detector is possible by design (Google's decoder is proprietary, trusted-testers only); we read the C2PA companion proxy, which goes quiet once metadata is stripped — a quiet proxy is not proof the pixel watermark is gone. Each vendor has its OWN oracle and it detects only that vendor's content: the Gemini app "Verify with SynthID" for Google, `openai.com/verify` for OpenAI. **Validate the OpenAI arm FIRST** — `openai.com/verify` is more accessible (fewer per-check restrictions) and the strongest automation candidate (Playwright / Chrome MCP); the Gemini flow is more manual. Ordering/throughput choice, not a substitution (see `docs/synthid.md`). SynthID survives JPEG re-encode, so GitHub issue attachments remain valid pixel-watermark test subjects. Every spectral/phase detection approach evaluated (reverse-SynthID, our own probes) works only on controlled solid fills, never on real content.
|
||||
- **External AI-vs-real classifier models are out of scope** (decided 2026-05-24): per-generator, degrade off-distribution, and our own light SDXL pass would likely defeat them. Detection stays local + signal-based.
|
||||
- **Default strength is VENDOR-ADAPTIVE, one ladder for BOTH pipelines** (since 2026-06-09): `resolve_strength(strength, vendor)` picks OpenAI **0.20** / Gemini **0.30** / unknown **0.30** when `--strength` is unset; explicit `--strength` always wins. Removal at low strength is content x pipeline dependent, and near-threshold removal is SEED-NON-DETERMINISTIC — pick a strength with margin and oracle-revalidate per content type. Certified controlnet floors (Modal cert 2026-06-04): OpenAI 0.20 (resolution-independent), Gemini 0.30 (only <= 1536px; native large Gemini needs ~0.35+ or a cap).
|
||||
- **Default strength is VENDOR-ADAPTIVE, one ladder for BOTH pipelines** (since 2026-06-09): `resolve_strength(strength, vendor)` picks OpenAI **0.10** / Gemini **0.15** / unknown **0.15** when `--strength` is unset (the 2026-06-14 lowering from the 2026-06-04 cert floors of 0.20/0.30 — the single source of truth is `watermark_profiles.py`, and the full cert/lowering history is in `docs/known-limitations.md`); explicit `--strength` always wins. Removal at low strength is content x pipeline dependent, and near-threshold removal is SEED-NON-DETERMINISTIC — pick a strength with margin and oracle-revalidate per content type.
|
||||
- **`controlnet` is the default pipeline**; `--pipeline sdxl` is the lighter opt-down. Neither pipeline clears all content at low strength (photoreal survives controlnet, flat graphics survive sdxl — the lever is higher strength). A removal-priority caller MUST oracle-validate strength across content types; prod recipe: controlnet + per-vendor floor + FIXED seed. Forensic-stealth caveat (arXiv:2605.09203): defeating the SynthID verifier is NOT forensic invisibility — removal-processed images are flaggable at >98% TPR@1%FPR.
|
||||
@@ -74,7 +74,7 @@ Metadata detection for AVIF/HEIF/JPEG-XL relies on a binary scan for `C2PA_UUID`
|
||||
|
||||
**Meta-box XMP now handled (`isobmff.blank_ai_xmp_packets`, v0.6.9):** an AI-label XMP packet stored as a meta-box `mime` item (AVIF/HEIF) is blanked in place (overwritten with spaces of the same length, so `iloc` offsets and the coded image stay valid).
|
||||
|
||||
**`Exif` item inside the `meta` box (AVIF/HEIF), now handled in place (2026-06-19):** an AI-generator token in an EXIF item (its TIFF bytes live in `mdat`/`idat`) is blanked by `isobmff.blank_ai_exif_tokens` — it finds EXIF TIFF blocks by their II/MM byte-order header, validates each with **piexif** (a coincidental II/MM run in pixel data won't parse as a TIFF IFD, so it is ignored), and overwrites any `Software`/`Make`/`Artist`/`ImageDescription` value carrying an `AI_GENERATOR_TOKENS` token with spaces of the **same length**. Same-length means every box size and `iloc` offset stays valid and the coded image is untouched — so it avoids the full `iinf`/`iloc` surgery (offset rewrite) that exiftool would need (exiftool is a non-installed binary dep, deliberately not used). It scrubs only the AI-token value; camera/editor EXIF is preserved. Wired into `remove_ai_metadata`'s ISOBMFF path after `blank_ai_xmp_packets`. Limitation: covers the AI-generator-token case (the realistic one); a future xAI-signature-in-meta-box-EXIF (Grok is JPEG-only today) is not separately handled. **Still NOT built:** Resemble PerTh audio detection (no presence/confidence flag exists).
|
||||
**`Exif` item inside the `meta` box (AVIF/HEIF), now handled in place (2026-06-19):** an AI-generator token in an EXIF item (its TIFF bytes live in `mdat`/`idat`) is blanked by `isobmff.blank_ai_exif_tokens` — it finds EXIF TIFF blocks by their II/MM byte-order header, validates each with **piexif** (a coincidental II/MM run in pixel data won't parse as a TIFF IFD, so it is ignored), and overwrites any `Software`/`Make`/`Artist`/`ImageDescription` value carrying an `AI_GENERATOR_TOKENS` token with spaces of the **same length**. Same-length means every box size and `iloc` offset stays valid and the coded image is untouched — so it avoids the full `iinf`/`iloc` surgery (offset rewrite) that exiftool would need (exiftool is a non-installed binary dep, deliberately not used). It scrubs only the AI value; camera/editor EXIF is preserved. Wired into `remove_ai_metadata`'s ISOBMFF path after `blank_ai_xmp_packets`. Because the ISOBMFF branch never runs the JPEG `_scrub_ai_exif`, this is the ONLY EXIF scrubber on that path and must stay in PARITY with it: it now also blanks the China TC260 `{"AIGC":{...}}` block in `ImageDescription`/`UserComment` (via `_is_aigc_exif_value` — Doubao producer + Tencent service-provider schemas) and the xAI/Grok `Signature:` + UUID-`Artist` pair, not just `AI_GENERATOR_TOKENS` in `Software`/`Make`/`Artist`/`ImageDescription` (regression `test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`). **Still NOT built:** Resemble PerTh audio detection (no presence/confidence flag exists).
|
||||
|
||||
**Audio watermark DETECTION (Resemble PerTh) was evaluated and NOT built (2026-05-26):** `resemble-perth`'s `PerthImplicitWatermarker.get_watermark()` returns a raw bit-array with **no presence/confidence flag** (clean audio decodes to arbitrary bits too), so reliably distinguishing watermarked-from-clean needs either Resemble's fixed payload or a confidence API -- neither is public, and there's no real Resemble sample to calibrate against. Same wall-class as the SynthID pixel detector: the decode exists, reliable presence-detection does not. (perth's top-level `PerthImplicitWatermarker` is also gated to None unless `librosa` is importable.)
|
||||
|
||||
|
||||
@@ -41,7 +41,7 @@ module.
|
||||
|
||||
**Samsung Galaxy + ASUS Gallery live in a separate `_SIGNER_C2PA_PLATFORM` (scanned after `_device_platform`, before the issuer fallback), NOT in `_DEVICE_C2PA_PLATFORM`** — verified on real signed files 2026-05-29. Reason: a Galaxy phone stamps BOTH its device cert AND a `trainedAlgorithmicMedia`/genAIType AI marker on a Generative-Edit image, so treating it as a "genuine camera capture" would false-fire integrity-clash rule 2 on every Galaxy AI edit. The signer tokens (`b"Samsung Galaxy"` cert org — distinct from the EXIF `SM-xxxx` model string on ordinary Samsung photos; `b"com.asus.gallery"` claim generator) only resolve the platform label; the AI verdict still comes from the source-type / genAIType. ASUS Gallery is a C2PA-signed edit with no AI marker, so it attributes the platform without asserting `is_ai`.
|
||||
|
||||
**Samsung's `genAIType` (in the proprietary `PhotoEditor_Re_Edit_Data` JSON) is an undocumented Galaxy-AI editing marker** (`metadata.samsung_genai`, gated on the `PhotoEditor_Re_Edit_Data` container; non-zero value = AI tool used, values {1,5} observed): medium-confidence because the field has no public spec (verified 2026-05-29: absent from C2PA spec + Samsung docs), but it co-occurred with `trainedAlgorithmicMedia` in 3/3 verified files that record a source-type and was the SOLE AI marker on a Galaxy S24 file that omits the source type. Camera C2PA marks capture authenticity, not AI (Pixel carries `computationalCapture`, not `trainedAlgorithmicMedia`), so these never set `is_ai` -- that stays driven by digital-source-type. `c2pa.cbor_text_after` (now public) is best-effort for the `generator` detail string only and can be None when the manifest keys it `claim_generator_info` (Pixel).
|
||||
**Samsung's `genAIType` (in the proprietary `PhotoEditor_Re_Edit_Data` JSON) is an undocumented Galaxy-AI editing marker** (`metadata.samsung_genai`, gated on the `PhotoEditor_Re_Edit_Data` container; non-zero value = AI tool used, values {1,5} observed; Galaxy AI appends it as a trailer AFTER the JPEG EOI, so `samsung_genai` reads the file TAIL when the 512 KB quick-scan head misses it — else a multi-MB photo's trailer past the window went undetected while removal, which reads the whole file, would still strip it; removal truncates the post-EOI Samsung trailer via `metadata._strip_samsung_trailer`, pixels bit-identical): medium-confidence because the field has no public spec (verified 2026-05-29: absent from C2PA spec + Samsung docs), but it co-occurred with `trainedAlgorithmicMedia` in 3/3 verified files that record a source-type and was the SOLE AI marker on a Galaxy S24 file that omits the source type. Camera C2PA marks capture authenticity, not AI (Pixel carries `computationalCapture`, not `trainedAlgorithmicMedia`), so these never set `is_ai` -- that stays driven by digital-source-type. `c2pa.cbor_text_after` (now public) is best-effort for the `generator` detail string only and can be None when the manifest keys it `claim_generator_info` (Pixel).
|
||||
|
||||
**Issuer→generator mapping is `is_ai`-gated** (`_attribute_platform(issuers, is_ai=c2pa_is_ai)`): a specific AI-generator platform is named only when the digital-source-type is `trainedAlgorithmicMedia`; on a non-AI source an issuer substring is treated as incidental (an "Adobe XMP" toolkit string in an *unmapped* Canon/Sony capture would otherwise mislabel it "Adobe Firefly"), so it degrades to the neutral "C2PA signer: X" label. **The one exception is an identity-AI issuer** (`c2pa_is_ai = c2pa_source_kind is not None or c2pa_identity_ai`, where `c2pa_identity_ai` is any resolved issuer org in `C2PA_IDENTITY_AI_ORGS`): a vendor flagged `asserts_ai` (today only Dreamina) sets `c2pa_is_ai` True on its own, so its platform resolves even though the manifest carries no `trainedAlgorithmicMedia`. This is safe precisely because the flag is restricted to distinctive brand strings, not the incidental-mention-prone common words. Real Firefly/OpenAI/Google output carries the AI source-type, so it is unaffected (verified: chatgpt-1.png→OpenAI, firefly-1.png→Adobe Firefly still attribute). `_attribute_platform` defaults `is_ai=True` so the mapping stays unit-testable in isolation. Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM`, editing-app/AI-device signer tokens to `_SIGNER_C2PA_PLATFORM`, generator/issuer platforms to the `C2PA_AI_VENDORS` registry in `constants.py` (which derives `_ISSUER_PLATFORM`), not inline. For non-PNG containers (JPEG/WebP/AVIF/HEIF/JXL) the caBX parser returns nothing, so issuer (`_issuers_in`) and generator (`_ai_tools_in`, reusing `C2PA_AI_TOOLS`) are recovered by binary-scanning the first MB. EXIF `Software` / `Make` / `Artist` / `ImageDescription`, XMP `CreatorTool`, and PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI stamps its generator there, not EXIF) are read by `metadata.exif_generator` (PIL+piexif for any format PIL opens incl. AVIF, plus a container-agnostic XMP raw-byte scan that also covers HEIF/JXL), matched against `AI_GENERATOR_TOKENS` so ordinary editors (plain "Adobe Photoshop") and real-camera `Make` ("Apple"/"Canon") are not flagged. Tokens mined from the retained corpus 2026-06-22: `novelai`, `reve.com` (full token, not bare `reve`), `aphrodite ai` — all no-C2PA generator stamps that previously read as no-signal (and under the P0#5 no-signal skip would have skipped the scrub).
|
||||
|
||||
@@ -51,9 +51,9 @@ module.
|
||||
|
||||
**Rule 2's independence gate (added 2026-06-11):** a device that both captures and runs on-device generative AI (Google Pixel Magic Editor / Pixel Studio) records the capture AND the AI edit in ONE C2PA manifest — so the AI vendor is named only from that same manifest (`c2pa` issuer + `synthid` proxy, both `c2pa_manifest` source) — a legitimate edit chain, NOT a clash. Rule 2 therefore fires only when some `ai_vendor_claims` family has a source `!= "c2pa_manifest"` (EXIF/XMP generator, IPTC, TC260 AIGC, a second manifest naming AI on a camera capture — the real laundering tell). This killed a false-positive class on the corpus: 2 real Pixel generative-edit PNGs (`computationalCapture` + `trainedAlgorithmicMedia` + "Applied imperceptible SynthID watermark" in one Google manifest) read as camera-vs-AI clashes before the gate. Pure cameras (Leica/Sony/Nikon/Truepic) that do NOT generate AI still clash on any within-manifest AI marker only if it is independent — they never legitimately carry one, so the gate is behavior-neutral for them while fixing Pixel (regression-guarded by `test_identify.py::TestIntegrityClashesHelper::{test_pixel_generative_edit_same_manifest_no_clash,test_camera_plus_independent_ai_marker_still_clashes}` + `TestIntegrityClashEndToEnd::test_pixel_generative_edit_no_clash`).
|
||||
|
||||
**Independence is source-grouped (`_CLASH_SOURCE`, added 2026-06-02):** the C2PA issuer attribution (`c2pa`) and the SynthID proxy (`synthid`) are NOT independent — the proxy is inferred from the *same* manifest — so they share one source and two vendors named within a single manifest do not clash. This killed a false-positive class found on the spaces corpus: legitimate multi-actor manifests where a product wraps another vendor's engine (Microsoft Designer on OpenAI → `OpenAI, Microsoft`; Microsoft on Google → `Microsoft, Google LLC, Google C2PA Core Generator Library`) or an edit chain re-signs (Adobe over a Gemini original → Adobe c2pa + Google synthid) — 19 such files across the 2026-06-01/02 batches read as clashes before the fix. Rule 1 still fires when a manifest vendor disagrees with a genuinely independent stamp (EXIF/XMP generator, IPTC `AISystemUsed`, AIGC, xAI); each non-`c2pa`/`synthid` family is its own source (`test_identify.py::TestIntegrityClashes::{test_multi_actor_manifest_no_clash,test_manifest_vendor_vs_independent_signal_clashes}`). Vendor normalization is `_vendor_of` over `_AI_VENDOR_TOKENS` (so a C2PA "Google (Gemini)" issuer and a SynthID-Google proxy agree, while different vendors clash).
|
||||
**Independence is source-grouped (`_CLASH_SOURCE`, added 2026-06-02):** the C2PA issuer attribution (`c2pa`) and the SynthID proxy (`synthid`) are NOT independent — the proxy is inferred from the *same* manifest — so they share one source and two vendors named within a single manifest do not clash. This killed a false-positive class found on the spaces corpus: legitimate multi-actor manifests where a product wraps another vendor's engine (Microsoft Designer on OpenAI → `OpenAI, Microsoft`; Microsoft on Google → `Microsoft, Google LLC, Google C2PA Core Generator Library`) or an edit chain re-signs (Adobe over a Gemini original → Adobe c2pa + Google synthid) — 19 such files across the 2026-06-01/02 batches read as clashes before the fix. Rule 1 still fires when a manifest vendor disagrees with a genuinely independent stamp (EXIF/XMP generator, IPTC `AISystemUsed`, AIGC, xAI); each non-`c2pa`/`synthid` family is its own source (`test_identify.py::TestIntegrityClashes::{test_multi_actor_manifest_no_clash,test_manifest_vendor_vs_independent_signal_clashes}`). Vendor normalization is `_vendor_of` over `_AI_VENDOR_TOKENS` (so a C2PA "Google (Gemini)" issuer and a SynthID-Google proxy agree, while different vendors clash). `_AI_VENDOR_TOKENS` covers ByteDance (all brands: bytedance/doubao/jimeng/dreamina/volcengine), Canva, ElevenLabs, and Black Forest Labs in addition to the OpenAI/Google/Adobe/... set — without them a transplanted ByteDance/Canva/BFL C2PA manifest next to an independent conflicting stamp was silently missed. **The generic `China AIGC (TC260)` label names no SPECIFIC vendor** (any Chinese generator applies it), so it cannot vendor-conflict in the spoofing sense: when a Chinese TC260-applying vendor (`_TC260_VENDORS`, today `{ByteDance}`) is co-attributed, Rule 1 attributes the label to that vendor (a legit Doubao image carries BOTH a ByteDance C2PA manifest and its own TC260 label and must not clash); against a NON-TC260 vendor (OpenAI etc.) the label stays generic and still clashes as a laundering tell (`test_bytedance_c2pa_plus_own_aigc_no_clash`, `test_foreign_vendor_plus_aigc_still_clashes`, `test_bytedance_c2pa_plus_foreign_generator_clashes`). Corpus-validated: 0 new clashes on 5000 ByteDance/AIGC/Canva/FLUX carriers.
|
||||
|
||||
**High-precision by design:** only hard generator stamps feed it (C2PA-issuer when source is AI, SynthID, EXIF/XMP generator, IPTC `AISystemUsed`, xAI, AIGC); the fuzzy visible sparkle and the open invisible watermark are **excluded** (the latter can be a by-product of our own SDXL removal pass). The c2pa vendor is classified from the issuer attribution / generator, NOT the resolved `platform` (a camera label like "Google Pixel" would mis-normalize to "Google"). All real single-origin fixtures (chatgpt/firefly/doubao/grok/mj) verified to produce **zero** clashes (false-positive guard in `test_identify.py::TestRealSamplesHaveNoClash`).
|
||||
**High-precision by design:** only hard generator stamps feed it (C2PA-issuer when source is AI, SynthID, EXIF/XMP generator, IPTC `AISystemUsed`, xAI, AIGC); the fuzzy visible sparkle and the open invisible watermark are **excluded** (both are low-precision/positive-only signals; the open watermark was also historically a by-product of our own SDXL removal pass, until `watermark_remover` was fixed to load the SDXL pipelines with `add_watermarker=False` — it stays excluded as a fuzzy signal regardless). The c2pa vendor is classified from the issuer attribution / generator, NOT the resolved `platform` (a camera label like "Google Pixel" would mis-normalize to "Google"). All real single-origin fixtures (chatgpt/firefly/doubao/grok/mj) verified to produce **zero** clashes (false-positive guard in `test_identify.py::TestRealSamplesHaveNoClash`).
|
||||
|
||||
**`ai_from_metadata` field + `has_invisible_target` helper (P0#5, 2026-06-22):** the high-confidence union (everything that sets `confidence == "high"`: C2PA AI-issuer / SynthID proxy, IPTC, AIGC, local gen params, EXIF/xAI, open DWT-DCT / TrustMark — the medium-confidence `hf_only`/`visible_only`/`samsung_only` are excluded) is now surfaced as the public `ProvenanceReport.ai_from_metadata` boolean, so callers gate on intent rather than on the `confidence` string. `has_invisible_target(path)` wraps `identify(path, check_visible=False, check_invisible=True)` and returns that field — it is the decision gate for the diffusion scrub (the CLI `invisible`/`all`/`batch` no-signal skip, `cli._no_invisible_signal_exit`): a visible-only or no-signal image has it False, so regeneration (which would only degrade a clean image) does not run. It fails SAFE — any detector exception returns True so the removal still runs (leaving a watermark on a paid removal is worse than over-regenerating). It does NOT prove a pixel SynthID is absent (SynthID is detectable only via its metadata proxy, gone once stripped), so a False means "no locally-detectable target", never "clean". Guarded by `test_identify.py::{TestIdentifyRealSamples::test_has_invisible_target_*,TestHasInvisibleTargetFailSafe}`.
|
||||
|
||||
@@ -131,7 +131,7 @@ The cost (mislabel ~8-33% of non-Gemini content as Gemini) outweighs the benefit
|
||||
|
||||
**Removal is localize -> fill:** the glyph blob is localized to a solid, dilated footprint mask and the shared `watermark_registry.fill` inpaints it. Verified on a real 2958-wide @f-liva photo: re-detect 0.79→0.00, no readable text or outline on the recovered wooden table — checked **visually**, not just by the detector. The registry gates removal on `detect`.
|
||||
|
||||
**Detection is locale-specific** (the string differs per language); this build detects only the Italian "Contenuti generati dall'AI" variant, so non-Italian Samsung locales are not detected — and, because detection gates removal, not removed — even though the fill mask itself is locale-independent. Other locales need their own captured detection template. This is a pre-existing limit, unchanged by the localize -> fill refactor.
|
||||
**Detection is locale-specific** (the string differs per language); this build detects only the Italian "Contenuti generati dall'AI" variant, so non-Italian Samsung locales are not detected — and, because detection gates removal, not removed — even though the fill mask itself is locale-independent. Other locales need their own detection silhouette — the locale string font-rendered and calibrated on real positives (the pill's `scripts/render_pill_silhouette.py` pattern), NOT an app capture (the solid/gray/white capture workflow retired with reverse-alpha). This is a pre-existing limit, unchanged by the localize -> fill refactor.
|
||||
|
||||
**No committed real sample** (only the flat calibration captures are committed) — `tests/test_samsung_engine.py` synthesizes a mark from the bundled template (bottom-left geometry), with `test_recovers_shifted_mark_on_texture` guarding the localize-on-shift path. Samsung Galaxy AI edits are independently caught by C2PA + the `genAIType` marker in `metadata`/`identify`, so this engine is the visible-mark *removal* path; it also feeds `identify` as the medium-confidence `visible_samsung` signal via the registry (the stripped-metadata fallback).
|
||||
|
||||
@@ -139,7 +139,7 @@ The cost (mislabel ~8-33% of non-Gemini content as Gemini) outweighs the benefit
|
||||
|
||||
`region_eraser.py` — universal region eraser (`erase` CLI) AND the shared fill backend behind `watermark_registry.fill` for the visible localize -> fill removal. `erase(image, boxes=|mask=, backend=)` accepts grayscale (2D) and RGBA (4-channel) inputs on **all** backends (each splits off any alpha plane and re-attaches it unchanged, and promotes grayscale to BGR): `boxes_to_mask` → one of three backends.
|
||||
- `cv2` (default, no deps): `cv2.inpaint`.
|
||||
- `migan` (extra `migan`, `andraniksargsyan/migan` ONNX, MIT, ~28 MB): `erase_migan`. The MI-GAN ONNX crops around the mask bbox and re-composites internally, so the FULL image is fed at native resolution; only masked pixels are pasted back. **Mask polarity is INVERTED** vs this package's 255-erase convention — the shipped ONNX wants 0=hole / 255=known, so `erase_migan` feeds `(mask<=127)*255`; feeding 255=hole regenerates the whole frame into stripes (corpus-validated 2026-07, cost hours to find). ~0.95 GB peak / ~0.19 s. This is the **preferred default fill** for the visible localize -> fill path.
|
||||
- `migan` (extra `migan`, `andraniksargsyan/migan` ONNX, MIT, ~28 MB): `erase_migan`. Like `erase_lama`, it crops a padded region around the mask (`pad = max(256, 2*bbox)`), feeds only that crop to the ONNX model, and pastes only masked pixels back — but since MI-GAN accepts arbitrary dims (unlike LaMa's fixed 512² square) the crop is fed at NATIVE resolution (no resize). This **bounds the ONNX working set by the mark size, not the image**: feeding the whole frame made peak RAM scale with the upload (~0.6 GB at 4 MP up to ~2.4 GB at 25 MP, measured 2026-07); cropping holds it roughly constant (~0.6-0.9 GB), so a memory-tight host (a 1-2 GB web worker) can run MI-GAN on a 25 MP upload. The crop does not degrade the fill — a small mark only needs local context, and on real marks the cropped fill is on par with / sometimes cleaner than the full-frame fill (a tighter view gives the GAN less room to hallucinate large background structure; verified by eye on real Gemini/Doubao marks + a ground-truth reconstruction sweep). **Mask polarity is INVERTED** vs this package's 255-erase convention — the shipped ONNX wants 0=hole / 255=known, so `erase_migan` feeds `(crop_mask<=127)*255`; feeding 255=hole regenerates the whole frame into stripes (corpus-validated 2026-07, cost hours to find). ~0.19 s. This is the **preferred default fill** for the visible localize -> fill path.
|
||||
- `lama` (extra `lama`, `Carve/LaMa-ONNX` Apache-2.0, ~200 MB): `erase_lama` crops a padded region around the mask, runs at LaMa's fixed 512² input, pastes only masked pixels back. Best quality but ~4.7 GB peak — explicit opt-in only, NOT auto-selected.
|
||||
Lazy `_get_{lama,migan}_session` singletons; `{lama,migan}_available()` guard the optional imports (both == onnxruntime present). Note both extras install the same onnxruntime, so the two `*_available()` checks are identical — the fill's `auto` backend therefore resolves to MI-GAN whenever onnxruntime is present, else cv2, and big-LaMa is reachable only by an explicit `lama` backend (`--backend lama` on `erase`, or the shared fill's `backend="lama"`).
|
||||
|
||||
@@ -234,4 +234,4 @@ Known-visible-mark removal by **localize -> fill**: each detected mark is locali
|
||||
|
||||
### `batch`
|
||||
|
||||
Process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the **full `invisible` knob set** (`--strength`/`--steps`/`--guidance-scale`/`--pipeline`/`--controlnet-scale`/`--model`/`--device`/`--max-resolution`/`--min-resolution`/`--upscaler`/`--seed`/`--hf-token`/`--humanize`/`--unsharp`/`--adaptive-polish`/`--tile`/`--tile-size`/`--tile-overlap`/`--force`), plus `--backend` for the visible localize -> fill pass. `--adaptive-polish` is ON by default; `--auto` is deprecated and a no-op that only warns. **No-signal skip (P0#5):** in invisible/all mode each image runs the same `has_invisible_target` gate — a signal-less image is skipped (no diffusion); in `invisible` mode the input is copied through to the output dir so it stays complete, in `all` mode the visible-removed result is kept and metadata is still stripped. `--force` scrubs every image regardless. One engine cached per pipeline; the polish is resolved once before the loop.
|
||||
Process every supported image in a directory (output defaults to `<directory>_clean/`, set with `-o`). `--mode visible|invisible|metadata|all` (default `visible`); the invisible/all path reuses the **full `invisible` knob set** (`--strength`/`--steps`/`--guidance-scale`/`--pipeline`/`--controlnet-scale`/`--model`/`--device`/`--max-resolution`/`--min-resolution`/`--upscaler`/`--seed`/`--hf-token`/`--humanize`/`--unsharp`/`--adaptive-polish`/`--tile`/`--tile-size`/`--tile-overlap`/`--force`), plus `--backend` for the visible localize -> fill pass. `--adaptive-polish` is ON by default; `--auto` is deprecated and a no-op that only warns. **No-signal skip (P0#5):** in invisible/all mode each image runs the same `has_invisible_target` gate — a signal-less image is skipped (no diffusion); in `invisible` mode the input is copied through to the output dir so it stays complete, in `all` mode the visible-removed result is kept and metadata is still stripped. `--force` scrubs every image regardless. One engine cached per pipeline; the polish is resolved once before the loop. **Exit code (`batch` used to always exit 0, hiding failures):** `cmd_batch` raises `SystemExit(1)` when any image errored, OR when a `--mode invisible`/`all` image carried an invisible signal but the GPU extra was absent so its SynthID scrub was skipped — mirroring single `all`, it emits a loud "the invisible watermark was NOT removed on N image(s)" warning and (invisible mode) copies the input through so the output dir stays complete, rather than silently dropping the signal-bearing files that most needed processing. `_process_batch_image` returns that skipped-scrub flag; the loop tallies it. Regression-guarded by `tests/test_cli.py::TestBatchCommand::{test_batch_errors_exit_nonzero, test_batch_invisible_gpu_missing_writes_output_and_exits_nonzero}`.
|
||||
+16
-4
@@ -4,11 +4,23 @@ set -euo pipefail
|
||||
|
||||
uv sync --all-extras
|
||||
# uv-outdated / uv-secure run via uvx (isolated env), NOT `uv run`: resolving them
|
||||
# inside the project env crashes (uv-secure -> "annotated-doc raised exception") and,
|
||||
# with set -e, aborts the whole gate before ruff/pyright/tests. uvx sidesteps the
|
||||
# in-project dependency conflict (see CLAUDE.md "Test and lint").
|
||||
# inside the project env crashes and, with set -e, aborts the whole gate before
|
||||
# ruff/pyright/tests (see CLAUDE.md "Test and lint").
|
||||
uvx uv-outdated
|
||||
uvx uv-secure --ignore-unfixed
|
||||
# uv-secure prints its verdict but can then crash in an internal teardown with a
|
||||
# NON-ZERO exit -- observed as "annotated-doc raised exception" and later "anyio raised
|
||||
# exception"; both are bugs in uv-secure's OWN uvx env, not a project vulnerability. With
|
||||
# set -e that teardown crash aborts the whole gate before ruff/pyright/tests. So gate on
|
||||
# the VERDICT, not the exit code: capture the output, accept the run when uv-secure
|
||||
# reported all-safe (even if it then crashed), but still FAIL on a real finding (no
|
||||
# all-safe line) so a genuine CVE is never masked, and fail loud if it never got a
|
||||
# verdict at all (so a broken run is never silently skipped).
|
||||
secure_out="$(uvx uv-secure --ignore-unfixed 2>&1)" || true
|
||||
printf '%s\n' "$secure_out"
|
||||
if ! grep -qE "No vulnerabilities or maintenance issues detected|All dependencies appear safe" <<<"$secure_out"; then
|
||||
echo "maintain.sh: uv-secure reported a finding or failed before its verdict -- triage before committing." >&2
|
||||
exit 1
|
||||
fi
|
||||
uv run ruff check --fix
|
||||
uv run ruff format
|
||||
# Scoped to src/: a full-project pyright run OOM-crashes node on this ML-heavy
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.14.1"
|
||||
version = "0.15.3"
|
||||
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"
|
||||
|
||||
@@ -0,0 +1,256 @@
|
||||
"""Audit AI-metadata REMOVAL over a local image corpus (detection<->removal parity).
|
||||
|
||||
`corpus_gap_scan.py` proves the DETECTOR sees a marker; this proves the STRIPPER
|
||||
reaches it. For every file that carries an AI-metadata signal, run
|
||||
``remove_ai_metadata`` and re-scan the output with the SAME oracle
|
||||
(``get_ai_metadata``). Any signal that survives is a real parity bug: a re-served
|
||||
file still reads as AI. Also assert the strip is lossless -- decoded pixels
|
||||
(and alpha) bit-identical before/after -- since the removal must only touch
|
||||
metadata, never the coded image.
|
||||
|
||||
A no-op control set (clean images with no AI metadata) verifies the stripper
|
||||
neither ADDS a signal nor corrupts pixels on files it should leave alone.
|
||||
|
||||
Operates on gitignored data only (data/spaces/...); writes nothing tracked.
|
||||
|
||||
uv run python scripts/metadata_removal_audit.py \
|
||||
--corpus data/spaces/originals --identify data/spaces/identify \
|
||||
--out data/spaces/_metadata_removal_audit.csv --jobs 8
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import csv
|
||||
import json
|
||||
import logging
|
||||
import random
|
||||
import tempfile
|
||||
from collections import Counter
|
||||
from concurrent.futures import ProcessPoolExecutor, as_completed
|
||||
from pathlib import Path
|
||||
|
||||
import click
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.noai.constants import SUPPORTED_FORMATS
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# identify-JSON watermark substrings that imply a METADATA-borne signal (as
|
||||
# opposed to a purely visual sparkle/text mark). Used only to pick the candidate
|
||||
# population fast; get_ai_metadata is the per-file ground truth.
|
||||
_META_HINTS = (
|
||||
"C2PA",
|
||||
"Content Credentials",
|
||||
"IPTC",
|
||||
"Made with AI",
|
||||
"AIGC",
|
||||
"TC260",
|
||||
"EXIF",
|
||||
"Signature",
|
||||
"SynthID",
|
||||
"hf-job",
|
||||
"HuggingFace",
|
||||
"Samsung",
|
||||
"soft-binding",
|
||||
"metadata",
|
||||
)
|
||||
|
||||
|
||||
def _pixels(path: Path) -> tuple[np.ndarray | None, np.ndarray | None]:
|
||||
"""Decoded BGR + alpha, for the lossless-strip integrity check."""
|
||||
from remove_ai_watermarks import image_io
|
||||
|
||||
return image_io.read_bgr_and_alpha(path)
|
||||
|
||||
|
||||
def _same_pixels(a: Path, b: Path) -> bool | None:
|
||||
"""True/False if both decode; None if either is undecodable (can't compare)."""
|
||||
try:
|
||||
bgr_a, al_a = _pixels(a)
|
||||
bgr_b, al_b = _pixels(b)
|
||||
except Exception:
|
||||
return None
|
||||
if bgr_a is None or bgr_b is None:
|
||||
return None
|
||||
if bgr_a.shape != bgr_b.shape or not np.array_equal(bgr_a, bgr_b):
|
||||
return False
|
||||
if (al_a is None) != (al_b is None):
|
||||
return False
|
||||
return al_a is None or al_b is None or np.array_equal(al_a, al_b)
|
||||
|
||||
|
||||
def _audit_one(path_str: str) -> dict[str, object]:
|
||||
"""Worker: detect -> strip -> re-detect + pixel-integrity for one file."""
|
||||
from remove_ai_watermarks.metadata import get_ai_metadata, remove_ai_metadata
|
||||
|
||||
path = Path(path_str)
|
||||
row: dict[str, object] = {
|
||||
"path": path.name,
|
||||
"ext": path.suffix.lower(),
|
||||
"carrier": False,
|
||||
"before": "",
|
||||
"after": "",
|
||||
"parity_ok": "",
|
||||
"pixels_identical": "",
|
||||
"status": "ok",
|
||||
}
|
||||
try:
|
||||
before = get_ai_metadata(path)
|
||||
except Exception as exc:
|
||||
row["status"] = f"scan_error:{type(exc).__name__}"
|
||||
return row
|
||||
row["before"] = "|".join(sorted(before))
|
||||
row["carrier"] = bool(before)
|
||||
|
||||
with tempfile.TemporaryDirectory() as td:
|
||||
out = Path(td) / f"clean{path.suffix.lower()}"
|
||||
try:
|
||||
remove_ai_metadata(path, out)
|
||||
except Exception as exc:
|
||||
row["status"] = f"strip_error:{type(exc).__name__}"
|
||||
return row
|
||||
if not out.exists():
|
||||
row["status"] = "no_output"
|
||||
return row
|
||||
try:
|
||||
after = get_ai_metadata(out)
|
||||
except Exception as exc:
|
||||
row["status"] = f"rescan_error:{type(exc).__name__}"
|
||||
return row
|
||||
row["after"] = "|".join(sorted(after))
|
||||
row["parity_ok"] = not after # every AI signal must be gone
|
||||
same = _same_pixels(path, out)
|
||||
row["pixels_identical"] = "" if same is None else same
|
||||
return row
|
||||
|
||||
|
||||
def _candidate_paths(corpus: Path, identify: Path | None, clean_sample: int) -> tuple[list[Path], list[Path]]:
|
||||
"""Return (carriers, clean_controls). Uses identify JSONs when present to pick
|
||||
metadata carriers fast; falls back to scanning every file."""
|
||||
if identify is None or not identify.exists():
|
||||
files = sorted(p for p in corpus.rglob("*") if p.is_file() and p.suffix.lower() in SUPPORTED_FORMATS)
|
||||
return files, []
|
||||
|
||||
carriers: list[Path] = []
|
||||
clean: list[Path] = []
|
||||
for jf in identify.rglob("*.json"):
|
||||
try:
|
||||
d = json.loads(jf.read_text())
|
||||
except Exception: # noqa: S112 -- skip an unreadable identify JSON, not security-relevant
|
||||
continue
|
||||
src = d.get("src")
|
||||
if not src:
|
||||
continue
|
||||
img = corpus / jf.parent.name / src
|
||||
if not img.exists() or img.suffix.lower() not in SUPPORTED_FORMATS:
|
||||
continue
|
||||
wm = " | ".join(d.get("watermarks") or [])
|
||||
if any(h in wm for h in _META_HINTS):
|
||||
carriers.append(img)
|
||||
elif not d.get("is_ai_generated"):
|
||||
clean.append(img)
|
||||
rng = random.Random(0) # noqa: S311 -- deterministic sampling seed, not cryptographic
|
||||
rng.shuffle(clean)
|
||||
return carriers, clean[:clean_sample]
|
||||
|
||||
|
||||
@click.command()
|
||||
@click.option(
|
||||
"--corpus", type=click.Path(exists=True, file_okay=False, path_type=Path), default=Path("data/spaces/originals")
|
||||
)
|
||||
@click.option(
|
||||
"--identify",
|
||||
type=click.Path(path_type=Path),
|
||||
default=Path("data/spaces/identify"),
|
||||
help="identify-JSON dir to pick carriers (skip = scan all).",
|
||||
)
|
||||
@click.option("--out", type=click.Path(path_type=Path), default=Path("data/spaces/_metadata_removal_audit.csv"))
|
||||
@click.option(
|
||||
"--clean-sample", type=int, default=1500, help="No-op control: N clean images to prove the strip is a no-op."
|
||||
)
|
||||
@click.option("--limit", type=int, default=0, help="Cap carriers scanned (0 = all).")
|
||||
@click.option("--jobs", type=int, default=8)
|
||||
def main(corpus: Path, identify: Path | None, out: Path, clean_sample: int, limit: int, jobs: int) -> None:
|
||||
logging.basicConfig(level=logging.ERROR, format="%(message)s")
|
||||
carriers, clean = _candidate_paths(corpus, identify, clean_sample)
|
||||
if limit:
|
||||
carriers = carriers[:limit]
|
||||
tasks = [(p, "carrier") for p in carriers] + [(p, "clean") for p in clean]
|
||||
click.echo(f"Carriers: {len(carriers)} | clean controls: {len(clean)} | jobs {jobs}")
|
||||
|
||||
rows: list[dict[str, object]] = []
|
||||
with ProcessPoolExecutor(max_workers=jobs) as ex:
|
||||
futs = {ex.submit(_audit_one, str(p)): k for p, k in tasks}
|
||||
for done, fut in enumerate(as_completed(futs), 1):
|
||||
row = fut.result()
|
||||
row["kind"] = futs[fut]
|
||||
rows.append(row)
|
||||
if done % 500 == 0:
|
||||
click.echo(f" {done}/{len(tasks)}")
|
||||
|
||||
out.parent.mkdir(parents=True, exist_ok=True)
|
||||
fields = ["kind", "path", "ext", "carrier", "before", "after", "parity_ok", "pixels_identical", "status"]
|
||||
with out.open("w", newline="") as f:
|
||||
w = csv.DictWriter(f, fieldnames=fields)
|
||||
w.writeheader()
|
||||
for r in rows:
|
||||
w.writerow({k: r.get(k, "") for k in fields})
|
||||
|
||||
# ---- Summary ----
|
||||
car = [r for r in rows if r["kind"] == "carrier"]
|
||||
ctl = [r for r in rows if r["kind"] == "clean"]
|
||||
real_carriers = [r for r in car if r["carrier"] and r["status"] == "ok"]
|
||||
parity_fail = [r for r in real_carriers if r["parity_ok"] is False]
|
||||
pixel_fail = [r for r in real_carriers if r["pixels_identical"] is False]
|
||||
errors = [r for r in rows if r["status"] != "ok"]
|
||||
|
||||
click.echo("\n===== METADATA REMOVAL PARITY =====")
|
||||
click.echo(f"Carrier candidates: {len(car)}")
|
||||
click.echo(f"Confirmed carriers: {len(real_carriers)} (get_ai_metadata non-empty)")
|
||||
click.echo(f"Parity FAILS (signal survives strip): {len(parity_fail)}")
|
||||
click.echo(f"Pixel-integrity FAILS (strip altered pixels): {len(pixel_fail)}")
|
||||
click.echo(f"Errors (scan/strip/decode): {len(errors)}")
|
||||
|
||||
# Per-signal parity breakdown.
|
||||
sig_total: Counter[str] = Counter()
|
||||
sig_fail: Counter[str] = Counter()
|
||||
for r in real_carriers:
|
||||
for s in str(r["before"]).split("|"):
|
||||
if s:
|
||||
sig_total[s] += 1
|
||||
if r["parity_ok"] is False:
|
||||
for s in str(r["after"]).split("|"):
|
||||
if s:
|
||||
sig_fail[s] += 1
|
||||
click.echo("\nPer-signal (carriers / surviving-after-strip):")
|
||||
for s, n in sig_total.most_common():
|
||||
click.echo(f" {s:24} {n:6} survived: {sig_fail.get(s, 0)}")
|
||||
|
||||
click.echo("\n===== NO-OP CONTROL (clean images) =====")
|
||||
ctl_ok = [r for r in ctl if r["status"] == "ok"]
|
||||
added = [r for r in ctl_ok if r["after"]] # strip must not ADD a signal
|
||||
corrupted = [r for r in ctl_ok if r["pixels_identical"] is False]
|
||||
click.echo(f"Clean controls scanned: {len(ctl_ok)}")
|
||||
click.echo(f"Strip ADDED a signal: {len(added)}")
|
||||
click.echo(f"Strip corrupted pixels: {len(corrupted)}")
|
||||
|
||||
if parity_fail:
|
||||
click.echo("\n--- Parity failures (first 30) ---")
|
||||
for r in parity_fail[:30]:
|
||||
click.echo(f" {r['ext']:6} survived=[{r['after']}] {r['path']}")
|
||||
if pixel_fail:
|
||||
click.echo("\n--- Pixel-integrity failures (first 30) ---")
|
||||
for r in pixel_fail[:30]:
|
||||
click.echo(f" {r['ext']:6} {r['path']}")
|
||||
if errors:
|
||||
ec: Counter[str] = Counter(str(r["status"]) for r in errors)
|
||||
click.echo("\n--- Errors by kind ---")
|
||||
for k, n in ec.most_common():
|
||||
click.echo(f" {n:5} {k}")
|
||||
|
||||
click.echo(f"\nReport: {out}")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -10,6 +10,11 @@ NOT sufficient (their NCC detector is fooled by a thin residual outline -- see
|
||||
CLAUDE.md). Treat a detector-clean Doubao/Jimeng as "detector passes"; visual
|
||||
residual is a separate check.
|
||||
|
||||
Backend: for a REALISTIC quality audit pass ``--backend migan`` (the production
|
||||
fill), not ``cv2``. Removal SUCCESS (detector-clean) is backend-independent, so cv2
|
||||
is fine for a fast pass/fail sweep, but only migan/lama reflect the recovered-region
|
||||
quality a user actually gets. Run migan when validating the visible pipeline for real.
|
||||
|
||||
Operates on gitignored data only (data/spaces/...); writes nothing tracked.
|
||||
|
||||
uv run python scripts/visible_removal_audit.py \
|
||||
@@ -54,7 +59,14 @@ def _rel(p: Path, corpus: Path) -> str:
|
||||
help="Audit only these paths (one per line), skipping the full rglob.",
|
||||
)
|
||||
@click.option("--limit", type=int, default=0, help="Scan at most N files (0 = all).")
|
||||
def main(corpus: Path, out: Path, dataset_root: Path, paths_file: Path | None, limit: int) -> None:
|
||||
@click.option(
|
||||
"--backend",
|
||||
type=click.Choice(["auto", "cv2", "migan", "lama"]),
|
||||
default="auto",
|
||||
help="Fill backend for removal. Removal SUCCESS (detector-clean) is backend-independent; "
|
||||
"cv2 is fastest for a bulk audit, migan/lama only change recovered-region quality.",
|
||||
)
|
||||
def main(corpus: Path, out: Path, dataset_root: Path, paths_file: Path | None, limit: int, backend: str) -> None:
|
||||
logging.basicConfig(level=logging.WARNING, format="%(message)s")
|
||||
if paths_file is not None:
|
||||
files = [Path(s) for line in paths_file.read_text().splitlines() if (s := line.strip()) and Path(s).is_file()]
|
||||
@@ -86,7 +98,7 @@ def main(corpus: Path, out: Path, dataset_root: Path, paths_file: Path | None, l
|
||||
shutil.copy2(p, ddir / p.name)
|
||||
# Remove, then re-detect with the SAME mark's detector.
|
||||
try:
|
||||
cleaned, _ = mark.remove(img)
|
||||
cleaned, _ = mark.remove(img, backend=backend)
|
||||
after = mark.detect(cleaned)
|
||||
except Exception as exc:
|
||||
log.warning("remove failed on %s (%s): %s", p.name, det.key, exc)
|
||||
|
||||
@@ -25,7 +25,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.14.1"
|
||||
__version__ = "0.15.3"
|
||||
|
||||
__all__ = ["__version__", "remove_visible", "visible_provenance"]
|
||||
|
||||
|
||||
@@ -315,6 +315,8 @@ class TextMarkEngine:
|
||||
With ``force`` and no glyph found, falls back to the whole geometry box (the
|
||||
``--no-detect`` path). The caller gates on detection.
|
||||
"""
|
||||
if image is None or image.size == 0:
|
||||
return None # guard before to_bgr (cvtColor raises on an empty Mat); mirror detect()
|
||||
image = image_io.to_bgr(image)
|
||||
h, w = image.shape[:2]
|
||||
if h < 32 or w < 64:
|
||||
|
||||
@@ -595,6 +595,9 @@ def cmd_visible(
|
||||
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
|
||||
except (ValueError, OSError) as e: # unreadable / truncated / non-image input
|
||||
console.print(f" Error: cannot read image {source.name}: {e}")
|
||||
raise SystemExit(1) from e
|
||||
elapsed = time.monotonic() - t0
|
||||
h, w = result.shape[:2]
|
||||
console.print(f" Input: {source.name} ({w}x{h})")
|
||||
@@ -933,7 +936,11 @@ def cmd_metadata(
|
||||
return
|
||||
|
||||
# Remove
|
||||
out = remove_ai_metadata(source, output, keep_standard=keep_standard)
|
||||
try:
|
||||
out = remove_ai_metadata(source, output, keep_standard=keep_standard)
|
||||
except (OSError, ValueError) as e: # unreadable / truncated / non-image (PIL raises OSError subclasses)
|
||||
console.print(f" Error: cannot process {source.name}: {e}")
|
||||
raise SystemExit(1) from e
|
||||
console.print(f" AI metadata stripped -> {out}")
|
||||
|
||||
|
||||
@@ -1246,6 +1253,14 @@ def cmd_all(
|
||||
|
||||
|
||||
# ── Batch command ──
|
||||
def _passthrough_copy(img_path: Path, out_path: Path) -> None:
|
||||
"""Copy the input's pixels through to ``out_path`` unchanged (the invisible-mode skip
|
||||
paths), so the output dir stays complete without touching the pixels."""
|
||||
src_bgr, src_alpha = image_io.read_bgr_and_alpha(img_path)
|
||||
if src_bgr is not None:
|
||||
image_io.write_bgr_with_alpha(out_path, src_bgr, src_alpha)
|
||||
|
||||
|
||||
def _process_batch_image(
|
||||
ctx: click.Context,
|
||||
img_path: Path,
|
||||
@@ -1272,16 +1287,21 @@ def _process_batch_image(
|
||||
tile_size: int = 1024,
|
||||
tile_overlap: int = 128,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
) -> bool:
|
||||
"""Process a single image for batch mode.
|
||||
|
||||
Applies the requested watermark removal steps (visible, invisible,
|
||||
metadata) to *img_path* and writes the result to *out_path*.
|
||||
|
||||
Returns True if the invisible (SynthID) scrub was skipped because the GPU deps
|
||||
are missing while a signal was present -- so the batch caller can warn + exit
|
||||
non-zero, mirroring the single ``all`` command.
|
||||
|
||||
Raises:
|
||||
ValueError: If the image cannot be opened.
|
||||
"""
|
||||
saved_alpha: NDArray[Any] | None = None
|
||||
synthid_skipped = False
|
||||
|
||||
if mode in ("visible", "all"):
|
||||
# Always read the ORIGINAL source: the visible pass is the first step, so a
|
||||
@@ -1341,13 +1361,22 @@ def _process_batch_image(
|
||||
# visible-processed `out_path` whose C2PA is already gone.
|
||||
vendor=vendor_for_strength(img_path),
|
||||
)
|
||||
elif not invisible_available() and not skip_no_signal:
|
||||
# An invisible signal IS present but the GPU deps are missing, so the
|
||||
# SynthID scrub cannot run. Mirror the single `all` command's loud skip:
|
||||
# flag it for a batch-level warning + non-zero exit (a silently retained
|
||||
# SynthID watermark is the #1 "it didn't work" report). For invisible mode
|
||||
# nothing wrote out_path yet -> copy the input through so the output dir is
|
||||
# complete with the pixels deliberately left intact (without this, a
|
||||
# signal-bearing image in a GPU-less --mode invisible run got NO output).
|
||||
synthid_skipped = True
|
||||
if mode == "invisible" and not out_path.exists():
|
||||
_passthrough_copy(img_path, out_path)
|
||||
elif skip_no_signal and mode == "invisible" and not out_path.exists():
|
||||
# No invisible target and the visible/all pass did not write out_path
|
||||
# (invisible mode): copy the input through so the output dir is complete
|
||||
# with the pixels deliberately left intact.
|
||||
src_bgr, src_alpha = image_io.read_bgr_and_alpha(img_path)
|
||||
if src_bgr is not None:
|
||||
image_io.write_bgr_with_alpha(out_path, src_bgr, src_alpha)
|
||||
_passthrough_copy(img_path, out_path)
|
||||
|
||||
if mode in ("metadata", "all"):
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
@@ -1361,6 +1390,8 @@ def _process_batch_image(
|
||||
if final_bgr is not None:
|
||||
image_io.write_bgr_with_alpha(out_path, final_bgr, saved_alpha)
|
||||
|
||||
return synthid_skipped
|
||||
|
||||
|
||||
@main.command("batch")
|
||||
@click.argument("directory", type=click.Path(exists=True, file_okay=False, path_type=Path))
|
||||
@@ -1457,6 +1488,7 @@ def cmd_batch(
|
||||
|
||||
processed = 0
|
||||
errors = 0
|
||||
synthid_skipped_count = 0
|
||||
|
||||
with Progress(
|
||||
SpinnerColumn(),
|
||||
@@ -1473,7 +1505,7 @@ def cmd_batch(
|
||||
progress.update(task, description=f"{img_path.name}")
|
||||
|
||||
try:
|
||||
_process_batch_image(
|
||||
if _process_batch_image(
|
||||
ctx=ctx,
|
||||
img_path=img_path,
|
||||
out_path=out_path,
|
||||
@@ -1499,7 +1531,8 @@ def cmd_batch(
|
||||
tile_size=tile_size,
|
||||
tile_overlap=tile_overlap,
|
||||
force=force,
|
||||
)
|
||||
):
|
||||
synthid_skipped_count += 1
|
||||
processed += 1
|
||||
|
||||
except Exception as e:
|
||||
@@ -1511,6 +1544,21 @@ def cmd_batch(
|
||||
|
||||
console.print(f"\n {processed} processed" + (f" {errors} errors" if errors else ""))
|
||||
|
||||
if synthid_skipped_count:
|
||||
# Mirror the single `all` command: a silently retained SynthID watermark is the
|
||||
# #1 "it didn't work" report, so make the skipped scrub impossible to miss.
|
||||
console.print(
|
||||
f"\n WARNING: the invisible (SynthID) watermark was NOT removed on "
|
||||
f"{synthid_skipped_count} image(s) -- the GPU dependencies are not installed, "
|
||||
f"so those outputs still carry the invisible watermark.\n"
|
||||
f" Install the extra and rerun: pip install 'remove-ai-watermarks[gpu]'"
|
||||
)
|
||||
|
||||
# Non-zero exit so a wrapping service detects an incomplete/failed run (batch used
|
||||
# to always exit 0, hiding both per-image errors and skipped SynthID scrubs).
|
||||
if errors or synthid_skipped_count:
|
||||
raise SystemExit(1)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -20,6 +20,7 @@ to DETECT and to shape the removal mask -- the old reverse-alpha pixel recovery
|
||||
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportUnknownParameterType=false, reportMissingTypeArgument=false, reportMissingTypeStubs=false, reportMissingImports=false, reportArgumentType=false, reportAssignmentType=false, reportReturnType=false, reportCallIssue=false, reportIndexIssue=false, reportOperatorIssue=false, reportOptionalMemberAccess=false, reportOptionalCall=false, reportOptionalSubscript=false, reportOptionalOperand=false, reportAttributeAccessIssue=false, reportPrivateImportUsage=false, reportPrivateUsage=false, reportInvalidTypeForm=false, reportConstantRedefinition=false, reportUnnecessaryComparison=false
|
||||
from __future__ import annotations
|
||||
|
||||
import functools
|
||||
import logging
|
||||
from dataclasses import dataclass
|
||||
from enum import Enum
|
||||
@@ -126,6 +127,12 @@ def _load_embedded_asset(name: str) -> NDArray[Any]:
|
||||
return img
|
||||
|
||||
|
||||
# Single source of truth for the multi-scale template ladder (aggressively downscaled to
|
||||
# slightly upscaled): the precomputed `_tmpl_cache` and the `_scan_scales` loop must use
|
||||
# the SAME scales or a scan scale would miss the cache and KeyError.
|
||||
_TEMPLATE_SCALES: tuple[int, ...] = tuple(range(16, 120, 2))
|
||||
|
||||
|
||||
class GeminiEngine:
|
||||
"""Detects and localizes the visible Gemini sparkle for the shared fill removal.
|
||||
|
||||
@@ -242,6 +249,16 @@ class GeminiEngine:
|
||||
self._alpha_small = _calculate_alpha_map(bg_small)
|
||||
self._alpha_large = _calculate_alpha_map(bg_large)
|
||||
|
||||
# Per-scale resized templates are constant (``_alpha_large`` never changes),
|
||||
# so precompute the whole fixed 16..118 ladder once: ``_scan_scales`` runs it on
|
||||
# every image (twice -- global + corner), and re-``resize``-ing the 96x96 source
|
||||
# each time is pure repeated work. Prebuilt (not lazy) so the dict is read-only
|
||||
# after construction and safe to share across threads via the module singleton.
|
||||
self._tmpl_cache: dict[int, NDArray[Any]] = {
|
||||
scale: cv2.resize(self._alpha_large, (scale, scale), interpolation=cv2.INTER_AREA)
|
||||
for scale in _TEMPLATE_SCALES
|
||||
}
|
||||
|
||||
logger.debug(
|
||||
"Alpha maps loaded: small=%s, large=%s",
|
||||
self._alpha_small.shape,
|
||||
@@ -275,11 +292,10 @@ class GeminiEngine:
|
||||
``_alpha_large`` is the high-quality source downscaled per scale; the range
|
||||
covers aggressively downscaled to slightly upscaled logos.
|
||||
"""
|
||||
for scale in range(16, 120, 2):
|
||||
for scale in _TEMPLATE_SCALES:
|
||||
if scale > gray.shape[0] or scale > gray.shape[1]:
|
||||
continue
|
||||
tmpl = cv2.resize(self._alpha_large, (scale, scale), interpolation=cv2.INTER_AREA)
|
||||
match_res = cv2.matchTemplate(gray, tmpl, cv2.TM_CCOEFF_NORMED)
|
||||
match_res = cv2.matchTemplate(gray, self._tmpl_cache[scale], cv2.TM_CCOEFF_NORMED)
|
||||
_, max_val, _, max_loc = cv2.minMaxLoc(match_res)
|
||||
yield scale, float(max_val), max_loc
|
||||
|
||||
@@ -361,6 +377,12 @@ class GeminiEngine:
|
||||
if promoted is not None:
|
||||
candidates.append(promoted)
|
||||
|
||||
# No candidate at any scale: the search region is smaller than the 16px template
|
||||
# floor (an image whose short side is < 16px), so nothing is detectable. Return
|
||||
# the empty (detected=False) result rather than dereferencing candidates[0].
|
||||
if not candidates:
|
||||
return result
|
||||
|
||||
# Select the candidate with the highest full-fusion confidence (pre-FP-gate).
|
||||
best_scale, pos_x, pos_y, best_raw_ncc = candidates[0]
|
||||
grad_score, var_score, best_fused = 0.0, 0.0, -1.0
|
||||
@@ -543,6 +565,8 @@ class GeminiEngine:
|
||||
yield no mask (reported-removed-but-unchanged). Absent ``region``, direct callers
|
||||
keep the detect-then-force behavior.
|
||||
"""
|
||||
if image is None or image.size == 0:
|
||||
return None # guard before to_bgr (cvtColor raises on an empty Mat); mirror detect_watermark
|
||||
image = image_io.to_bgr(image)
|
||||
h, w = image.shape[:2]
|
||||
if region is not None:
|
||||
@@ -681,6 +705,18 @@ class GeminiEngine:
|
||||
return float(np.median((hi - lo) / (hi + 1.0)))
|
||||
|
||||
|
||||
@functools.lru_cache(maxsize=1)
|
||||
def _shared_engine() -> GeminiEngine:
|
||||
"""Process-wide default ``GeminiEngine`` singleton.
|
||||
|
||||
The engine holds only constant assets (embedded captures, alpha maps, the
|
||||
precomputed template ladder) and takes the image as a method argument, so one
|
||||
instance is reused across every ``detect_sparkle_confidence`` call instead of
|
||||
reloading assets + recomputing alpha maps + rebuilding the template cache on
|
||||
each of the ~34k images an ``identify`` batch scans. Output is identical."""
|
||||
return GeminiEngine()
|
||||
|
||||
|
||||
def detect_sparkle_confidence(image_path: Path, *, image: NDArray[Any] | None = None) -> float | None:
|
||||
"""Visible-sparkle detection confidence for a file, for provenance use.
|
||||
|
||||
@@ -698,4 +734,4 @@ def detect_sparkle_confidence(image_path: Path, *, image: NDArray[Any] | None =
|
||||
img = image if image is not None else image_io.imread(image_path)
|
||||
if img is None:
|
||||
return None
|
||||
return float(GeminiEngine().detect_watermark(img).confidence)
|
||||
return float(_shared_engine().detect_watermark(img).confidence)
|
||||
@@ -41,11 +41,14 @@ def apply_analog_humanizer(image: NDArray, grain_intensity: float = 4.0, chromat
|
||||
# Shift R channel left, B channel right. np.roll is circular, so it wraps
|
||||
# the opposite edge into a thin colored fringe at the L/R borders; replicate
|
||||
# the original edge columns there to keep the intended offset interior-only.
|
||||
if chromatic_shift > 0:
|
||||
r = np.roll(r, -chromatic_shift, axis=1)
|
||||
r[:, -chromatic_shift:] = r[:, -chromatic_shift - 1 : -chromatic_shift]
|
||||
b = np.roll(b, chromatic_shift, axis=1)
|
||||
b[:, :chromatic_shift] = b[:, chromatic_shift : chromatic_shift + 1]
|
||||
# Clamp so the edge-replication slices below always have a source column: a shift
|
||||
# >= width would leave them empty and crash the broadcast (r[:, -shift:] = (H, 0)).
|
||||
shift = min(chromatic_shift, image.shape[1] - 1)
|
||||
if shift > 0:
|
||||
r = np.roll(r, -shift, axis=1)
|
||||
r[:, -shift:] = r[:, -shift - 1 : -shift]
|
||||
b = np.roll(b, shift, axis=1)
|
||||
b[:, :shift] = b[:, shift : shift + 1]
|
||||
|
||||
merged = cv2.merge((b, g, r))
|
||||
|
||||
|
||||
@@ -299,6 +299,19 @@ _AI_VENDOR_TOKENS: tuple[tuple[str, str], ...] = (
|
||||
("grok", "xAI"),
|
||||
("aurora", "xAI"),
|
||||
("xai", "xAI"),
|
||||
# ByteDance family (all its brands normalize to one origin, mirroring constants.py):
|
||||
# without these a transplanted ByteDance C2PA manifest next to an independent
|
||||
# conflicting stamp went undetected by the clash check.
|
||||
("bytedance", "ByteDance"),
|
||||
("doubao", "ByteDance"),
|
||||
("jimeng", "ByteDance"),
|
||||
("dreamina", "ByteDance"),
|
||||
("volcengine", "ByteDance"),
|
||||
("volcano engine", "ByteDance"),
|
||||
("canva", "Canva"),
|
||||
("elevenlabs", "ElevenLabs"),
|
||||
("eleven labs", "ElevenLabs"),
|
||||
("black forest", "Black Forest Labs"),
|
||||
)
|
||||
|
||||
|
||||
@@ -328,6 +341,17 @@ def _vendor_of(text: str | None) -> str | None:
|
||||
_C2PA_MANIFEST_SOURCE = "c2pa_manifest"
|
||||
_CLASH_SOURCE: dict[str, str] = {"c2pa": _C2PA_MANIFEST_SOURCE, "synthid": _C2PA_MANIFEST_SOURCE}
|
||||
|
||||
# The generic China TC260 AIGC vendor label -- a COUNTRY-LEVEL regulatory "this is AI"
|
||||
# stamp any Chinese generator applies to its own output, naming no specific vendor.
|
||||
_GENERIC_AIGC_VENDOR = "China AIGC (TC260)"
|
||||
# Vendors that apply the TC260 label to their OWN output. When one is co-attributed with
|
||||
# the generic AIGC label, the label is that vendor's own stamp (not an independent
|
||||
# competing origin), so the clash check attributes the AIGC label to it -- else a legit
|
||||
# ByteDance/Doubao image (C2PA "ByteDance" + its own TC260 label) would false-clash once
|
||||
# ByteDance normalizes via _vendor_of. Chinese generators only (Canva/BFL/ElevenLabs,
|
||||
# also added to _vendor_of, are NOT TC260 appliers).
|
||||
_TC260_VENDORS: frozenset[str] = frozenset({"ByteDance"})
|
||||
|
||||
|
||||
def _integrity_clashes(
|
||||
ai_vendors: dict[str, str], camera_label: str | None, *, camera_has_ai_marker: bool
|
||||
@@ -352,6 +376,18 @@ def _integrity_clashes(
|
||||
# families clash only when they belong to different provenance sources (see
|
||||
# _CLASH_SOURCE) AND name different vendors -- so multiple vendors named within
|
||||
# one C2PA manifest (c2pa issuer + synthid proxy) do not flag.
|
||||
# The generic TC260 AIGC label is a Chinese regulatory "this is AI" stamp. When a
|
||||
# Chinese TC260-applying vendor (ByteDance) is ALSO attributed, the label is that
|
||||
# vendor's own stamp on its own output, so attribute it to that vendor -- a legit
|
||||
# Doubao image carries BOTH a ByteDance C2PA manifest and its own TC260 label and
|
||||
# must not clash. Against a NON-TC260 vendor (OpenAI, Google, ...) the label stays
|
||||
# generic and still clashes as a laundering tell (a foreign-vendor image carrying a
|
||||
# Chinese TC260 label names two different origins).
|
||||
if ai_vendors.get("aigc") == _GENERIC_AIGC_VENDOR:
|
||||
own = next((v for f, v in ai_vendors.items() if f != "aigc" and v in _TC260_VENDORS), None)
|
||||
if own:
|
||||
ai_vendors = {**ai_vendors, "aigc": own} # copy co-located with the relabel
|
||||
|
||||
source = {fam: _CLASH_SOURCE.get(fam, fam) for fam in ai_vendors}
|
||||
independent_conflict = any(
|
||||
source[a] != source[b] and ai_vendors[a] != ai_vendors[b] for a, b in itertools.combinations(ai_vendors, 2)
|
||||
@@ -625,7 +661,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
watermarks.append("China AIGC label (TC260 standard)")
|
||||
if platform is None:
|
||||
platform = "China AIGC-labeled generator (TC260; e.g. Doubao)"
|
||||
ai_vendor_claims["aigc"] = "China AIGC (TC260)"
|
||||
ai_vendor_claims["aigc"] = _GENERIC_AIGC_VENDOR
|
||||
|
||||
# ── Local diffusion parameters (Stable Diffusion / ComfyUI) ──────
|
||||
local_keys = sorted(k for k in meta if k.lower() in _LOCAL_GEN_KEYS)
|
||||
|
||||
@@ -145,7 +145,11 @@ def _encode_params(ext: str) -> list[int]:
|
||||
params += [sf, sf444]
|
||||
return params
|
||||
if ext == ".webp":
|
||||
return [cv2.IMWRITE_WEBP_QUALITY, 100]
|
||||
# cv2 WebP: quality 1-100 is LOSSY; a value > 100 selects LOSSLESS mode.
|
||||
# "work with originals" requires lossless so a mark-removal re-encode does not
|
||||
# degrade the untouched pixels the fill composites over (regression: q100
|
||||
# round-tripped a random image at maxdiff ~230, q101 at 0).
|
||||
return [cv2.IMWRITE_WEBP_QUALITY, 101]
|
||||
return []
|
||||
|
||||
|
||||
|
||||
@@ -93,9 +93,17 @@ def c2pa_marker_in(data: bytes) -> bool:
|
||||
IPTC_AI_MARKERS: tuple[bytes, ...] = (
|
||||
b"trainedAlgorithmicMedia",
|
||||
b"compositeSynthetic",
|
||||
b"algorithmicMedia",
|
||||
b"compositeWithTrainedAlgorithmicMedia",
|
||||
)
|
||||
# NOTE: bare ``algorithmicMedia`` is deliberately NOT here. That IPTC digitalSourceType
|
||||
# means "created purely by an algorithm, NOT from sampled training data" (procedural /
|
||||
# generative-code art) -- it is NOT AI/ML generation. Real "Made with AI" labels
|
||||
# (Meta / Instagram / MidJourney) use ``trainedAlgorithmicMedia``. Including the bare
|
||||
# token flagged clean procedural images as AI (is_ai=high + has_invisible_target=True ->
|
||||
# a diffusion scrub of clean content), contradicting the c2pa layer, which sets
|
||||
# source_type without ai_source for it (tests/test_noai.py::test_plain_algorithmic_media_not_flagged_ai).
|
||||
# It is not a substring of the trained/composite tokens, so its removal does not affect
|
||||
# their detection.
|
||||
|
||||
# IPTC Photo Metadata 2025.1 (published 2025-11-27) added explicit AI-disclosure
|
||||
# XMP properties in the Iptc4xmpExt namespace. Their mere presence is an AI
|
||||
@@ -521,18 +529,45 @@ _SAMSUNG_GENAI_RE = re.compile(rb'genAIType"\s*:\s*(-?\d+)')
|
||||
_SAMSUNG_EDITOR_MARKER = b"PhotoEditor_Re_Edit_Data"
|
||||
|
||||
|
||||
def _read_file_tail(image_path: Path, size: int) -> bytes:
|
||||
"""Return the last ``size`` bytes of the file (or the whole file if smaller)."""
|
||||
try:
|
||||
file_size = image_path.stat().st_size
|
||||
with open(image_path, "rb") as f:
|
||||
if file_size > size:
|
||||
f.seek(file_size - size)
|
||||
return f.read()
|
||||
except OSError:
|
||||
return b""
|
||||
|
||||
|
||||
def samsung_genai(image_path: Path) -> int | None:
|
||||
"""Return Samsung's non-zero ``genAIType`` value if the image carries the
|
||||
Galaxy AI editing marker, else None.
|
||||
|
||||
See the module note above ``_SAMSUNG_GENAI_RE``: detection is empirical and
|
||||
gated on the ``PhotoEditor_Re_Edit_Data`` container so an incidental
|
||||
``genAIType`` token cannot false-positive.
|
||||
``genAIType`` token cannot false-positive. Galaxy AI appends the marker as a
|
||||
trailer AFTER the JPEG EOI, so on a multi-MB phone photo it sits past the quick-
|
||||
scan window; when the head misses it, also read the file tail (else detection
|
||||
and removal disagree -- the strip reads the whole file and would drop a marker
|
||||
detection never reported).
|
||||
"""
|
||||
head = scan_head(image_path, _QUICK_SCAN_BYTES)
|
||||
if _SAMSUNG_EDITOR_MARKER not in head:
|
||||
data = scan_head(image_path, _QUICK_SCAN_BYTES)
|
||||
if _SAMSUNG_EDITOR_MARKER not in data:
|
||||
# The marker is a post-EOI trailer, so only a file LARGER than the quick-scan
|
||||
# window can hide it past the head (`scan_head` already read a smaller file
|
||||
# whole). Gate the extra tail read on that — `samsung_genai` is on the identify
|
||||
# hot path, so a redundant 512 KB re-read per small image is not free.
|
||||
try:
|
||||
oversize = image_path.stat().st_size > _QUICK_SCAN_BYTES
|
||||
except OSError:
|
||||
oversize = False
|
||||
if oversize:
|
||||
data = _read_file_tail(image_path, _QUICK_SCAN_BYTES)
|
||||
if _SAMSUNG_EDITOR_MARKER not in data:
|
||||
return None
|
||||
m = _SAMSUNG_GENAI_RE.search(head)
|
||||
m = _SAMSUNG_GENAI_RE.search(data)
|
||||
if m is None:
|
||||
return None
|
||||
return int(m.group(1)) or None
|
||||
@@ -709,47 +744,90 @@ def xai_signature(image_path: Path) -> bool:
|
||||
)
|
||||
|
||||
|
||||
def _scrub_ai_exif(exif_dict: dict[str, Any]) -> list[str]:
|
||||
"""Delete AI-provenance tags from a piexif dict's ``0th`` IFD, in place.
|
||||
def _is_aigc_exif_value(raw: object) -> bool:
|
||||
"""Whether an EXIF tag value carries a China TC260 AIGC producer/service block.
|
||||
|
||||
Removes (a) the xAI/Grok signature pair (``ImageDescription`` "Signature: ..."
|
||||
+ UUID ``Artist``) and (b) any ``Software`` / ``Make`` / ``Artist`` /
|
||||
``ImageDescription`` tag whose value carries an ``AI_GENERATOR_TOKENS`` token
|
||||
(Ideogram's ``Make``, Firefly's ``Software``, etc.). Mirrors the detection in
|
||||
``xai_signature`` / ``exif_generator`` so removal scrubs exactly what
|
||||
``identify`` flags, while leaving genuine camera/editor EXIF intact. Returns
|
||||
the names of the removed tags (for logging).
|
||||
Mirrors ``aigc_label``'s EXIF path: the ``{"AIGC":{...}}`` wrapper embedded in
|
||||
``UserComment`` / ``ImageDescription`` by China-served generators (Doubao's
|
||||
producer schema AND Tencent Cloud's service-provider schema, both keyed under
|
||||
``_TC260_FIELDS``). Gated on both the ``AIGC`` marker and a TC260 field so a
|
||||
coincidental token cannot false-drop a genuine caption/comment. Accepts a ``str``
|
||||
too (a PNG ``tEXt``/``iTXt`` value), not only EXIF bytes.
|
||||
"""
|
||||
if isinstance(raw, str):
|
||||
raw = raw.encode("latin-1", "ignore")
|
||||
if not isinstance(raw, (bytes, bytearray)):
|
||||
return False
|
||||
if b"AIGC" not in raw:
|
||||
return False
|
||||
text = bytes(raw).decode("latin-1", "ignore")
|
||||
return any(field in text for field in _TC260_FIELDS)
|
||||
|
||||
|
||||
def _ai_exif_targets(loaded: dict[str, Any]) -> list[tuple[str, int, bytes, str]]:
|
||||
"""The SINGLE AI-EXIF rule set, as ``(ifd_key, tag, value_bytes, name)`` entries.
|
||||
|
||||
Shared by both EXIF scrubbers so their coverage cannot drift: the JPEG-path
|
||||
:func:`_scrub_ai_exif` pops each tag, and the ISOBMFF-path
|
||||
``isobmff.blank_ai_exif_tokens`` blanks each value's bytes in place. Covers
|
||||
(a) the xAI/Grok ``Signature:`` + UUID-``Artist`` pair, (b) any ``Software`` /
|
||||
``Make`` / ``Artist`` / ``ImageDescription`` tag carrying an ``AI_GENERATOR_TOKENS``
|
||||
token, and (c) the China TC260 ``{"AIGC":{...}}`` block in ``ImageDescription``
|
||||
(0th) or ``UserComment`` (Exif). De-duplicated by ``(ifd_key, tag)`` so a value
|
||||
flagged by two rules is removed and named once. Mirrors the detection in
|
||||
``xai_signature`` / ``exif_generator`` / ``aigc_label``; adding a new AI EXIF
|
||||
placement here reaches BOTH containers.
|
||||
"""
|
||||
import piexif
|
||||
|
||||
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
|
||||
|
||||
ifd = exif_dict.get("0th")
|
||||
if not ifd:
|
||||
return []
|
||||
ifd0: dict[int, Any] = loaded.get("0th") or {}
|
||||
ifde: dict[int, Any] = loaded.get("Exif") or {}
|
||||
seen: set[tuple[str, int]] = set()
|
||||
targets: list[tuple[str, int, bytes, str]] = []
|
||||
|
||||
drop: dict[int, str] = {}
|
||||
def add(ifd_key: str, ifd: dict[int, Any], tag: int, name: str) -> None:
|
||||
value = ifd.get(tag)
|
||||
if isinstance(value, bytes) and (ifd_key, tag) not in seen:
|
||||
seen.add((ifd_key, tag))
|
||||
targets.append((ifd_key, tag, value, name))
|
||||
|
||||
# (a) xAI / Grok: the Signature blob and the UUID Artist go together.
|
||||
if _is_xai_signature_pair(
|
||||
_exif_text(ifd, piexif.ImageIFD.ImageDescription), _exif_text(ifd, piexif.ImageIFD.Artist)
|
||||
_exif_text(ifd0, piexif.ImageIFD.ImageDescription), _exif_text(ifd0, piexif.ImageIFD.Artist)
|
||||
):
|
||||
drop[piexif.ImageIFD.ImageDescription] = "ImageDescription"
|
||||
drop[piexif.ImageIFD.Artist] = "Artist"
|
||||
|
||||
# (b) Known AI generator token in any of the text tags.
|
||||
add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription")
|
||||
add("0th", ifd0, piexif.ImageIFD.Artist, "Artist")
|
||||
# (b) known AI generator token in a 0th text tag.
|
||||
for tag, name in (
|
||||
(piexif.ImageIFD.Software, "Software"),
|
||||
(piexif.ImageIFD.Make, "Make"),
|
||||
(piexif.ImageIFD.Artist, "Artist"),
|
||||
(piexif.ImageIFD.ImageDescription, "ImageDescription"),
|
||||
):
|
||||
if any(token in _exif_text(ifd, tag).lower() for token in AI_GENERATOR_TOKENS):
|
||||
drop[tag] = name
|
||||
if any(token in _exif_text(ifd0, tag).lower() for token in AI_GENERATOR_TOKENS):
|
||||
add("0th", ifd0, tag, name)
|
||||
# (c) TC260 AIGC block in ImageDescription (0th) or UserComment (Exif sub-IFD).
|
||||
if _is_aigc_exif_value(ifd0.get(piexif.ImageIFD.ImageDescription)):
|
||||
add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription")
|
||||
if _is_aigc_exif_value(ifde.get(piexif.ExifIFD.UserComment)):
|
||||
add("Exif", ifde, piexif.ExifIFD.UserComment, "UserComment")
|
||||
|
||||
for tag in drop:
|
||||
ifd.pop(tag, None)
|
||||
return list(drop.values())
|
||||
return targets
|
||||
|
||||
|
||||
def _scrub_ai_exif(exif_dict: dict[str, Any]) -> list[str]:
|
||||
"""Delete the AI-provenance EXIF tags (`_ai_exif_targets`) from a piexif dict's
|
||||
``0th`` / ``Exif`` IFDs in place; return the removed tag names (for logging).
|
||||
Genuine camera/editor EXIF is left intact."""
|
||||
removed: list[str] = []
|
||||
for ifd_key, tag, _value, name in _ai_exif_targets(exif_dict):
|
||||
ifd = exif_dict.get(ifd_key)
|
||||
if ifd is not None:
|
||||
ifd.pop(tag, None)
|
||||
removed.append(name)
|
||||
return removed
|
||||
|
||||
|
||||
def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
@@ -876,20 +954,65 @@ def _jpeg_app_carries_ai(marker: int, payload: bytes) -> bool:
|
||||
APP11, an AI XMP packet in APP1, an IPTC "Made with AI" record in APP13). EXIF
|
||||
(APP1 ``Exif``) is NOT dropped here -- it is scrubbed tag-by-tag via piexif so
|
||||
genuine camera EXIF survives."""
|
||||
if marker == 0xEB: # APP11: C2PA / JUMBF manifest
|
||||
return c2pa_marker_in(payload) or b"jumb" in payload[:256].lower()
|
||||
if marker == 0xE1 and payload.startswith(b"http://ns.adobe.com/xap/"): # APP1 XMP
|
||||
return c2pa_marker_in(payload) or any(m in payload for m in AIGC_MARKERS)
|
||||
if marker == 0xED: # APP13: Photoshop / IPTC
|
||||
return any(m in payload for m in IPTC_AI_MARKERS) or any(m in payload for m in IPTC_AI_FIELD_MARKERS)
|
||||
if not (0xE0 <= marker <= 0xEF): # only APPn segments carry these
|
||||
return False
|
||||
# C2PA / JUMBF manifest (APP11).
|
||||
if marker == 0xEB and (c2pa_marker_in(payload) or b"jumb" in payload[:256].lower()):
|
||||
return True
|
||||
# AI XMP packet (APP1): C2PA, a China-AIGC token, or an IPTC digitalSourceType /
|
||||
# 2025.1 AI-disclosure marker (which live in XMP, not only the APP13 IIM record).
|
||||
if (
|
||||
marker == 0xE1
|
||||
and payload.startswith(b"http://ns.adobe.com/xap/")
|
||||
and (
|
||||
c2pa_marker_in(payload)
|
||||
or any(m in payload for m in AIGC_MARKERS)
|
||||
or any(m in payload for m in IPTC_AI_MARKERS)
|
||||
or any(m in payload for m in IPTC_AI_FIELD_MARKERS)
|
||||
)
|
||||
):
|
||||
return True
|
||||
# IPTC "Made with AI" record (APP13).
|
||||
if marker == 0xED and (
|
||||
any(m in payload for m in IPTC_AI_MARKERS) or any(m in payload for m in IPTC_AI_FIELD_MARKERS)
|
||||
):
|
||||
return True
|
||||
# A bare / wrapped China TC260 AIGC block (``AIGC{...}`` or ``{"AIGC":{...}}``) glued
|
||||
# into ANY APP segment -- some China gens use APP11, APP1, or a near-JFIF APPn. This
|
||||
# runs for every APP marker the specific checks above did NOT already claim, so a bare
|
||||
# AIGC in APP11 (not a C2PA manifest) is no longer missed by the 0xEB C2PA-only check.
|
||||
# ``aigc_label`` detects it anywhere, so removal must drop the carrying segment too
|
||||
# (detection<->removal parity). Skip APP1-EXIF (0xE1 ``Exif``): its camera tags are
|
||||
# scrubbed tag-by-tag via piexif, not dropped wholesale.
|
||||
if not (marker == 0xE1 and payload.startswith(b"Exif")):
|
||||
return _is_aigc_exif_value(payload)
|
||||
return False
|
||||
|
||||
|
||||
def _strip_samsung_trailer(scan_and_tail: bytes) -> bytes:
|
||||
"""Drop a Samsung Galaxy AI editing trailer appended AFTER the JPEG EOI.
|
||||
|
||||
Galaxy AI records its ``PhotoEditor_Re_Edit_Data`` (``genAIType``) blob as a
|
||||
proprietary trailer past the final ``FFD9`` end-of-image, so the verbatim
|
||||
scan copy in :func:`_strip_jpeg_metadata_lossless` would carry it through. If
|
||||
the marker is present in the post-EOI trailer, truncate at EOI (the coded scan
|
||||
is untouched, pixels stay bit-identical). A JPEG with no such trailer -- or a
|
||||
non-Samsung trailer (e.g. an MPF multi-picture block) -- is returned unchanged.
|
||||
"""
|
||||
if _SAMSUNG_EDITOR_MARKER not in scan_and_tail:
|
||||
return scan_and_tail
|
||||
eoi = scan_and_tail.rfind(b"\xff\xd9")
|
||||
if eoi == -1 or _SAMSUNG_EDITOR_MARKER not in scan_and_tail[eoi:]:
|
||||
return scan_and_tail # marker not in the post-EOI trailer; leave the scan alone
|
||||
return scan_and_tail[: eoi + 2]
|
||||
|
||||
|
||||
def _strip_jpeg_metadata_lossless(source_path: Path, output_path: Path) -> bool:
|
||||
"""Remove AI metadata from a JPEG WITHOUT re-encoding the DCT scan, so the pixels
|
||||
stay bit-identical (the point of "work with originals" -- a metadata strip must not
|
||||
degrade the image). Walks the marker segments up to SOS, drops the AI-bearing APP
|
||||
segments (:func:`_jpeg_app_carries_ai`), copies the entropy-coded scan verbatim,
|
||||
segments (:func:`_jpeg_app_carries_ai`), copies the entropy-coded scan verbatim
|
||||
(minus a Samsung Galaxy AI trailer past EOI, via :func:`_strip_samsung_trailer`),
|
||||
then scrubs AI EXIF tags in place via piexif (which rewrites only the APP1 EXIF,
|
||||
leaving genuine camera EXIF and the scan untouched). Returns False if the bytes are
|
||||
not a parseable JPEG, so the caller falls back to the near-lossless PIL re-save."""
|
||||
@@ -905,7 +1028,7 @@ def _strip_jpeg_metadata_lossless(source_path: Path, output_path: Path) -> bool:
|
||||
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:]
|
||||
out += _strip_samsung_trailer(data[i:])
|
||||
break
|
||||
if 0xD0 <= marker <= 0xD7 or marker == 0x01: # standalone markers carry no length
|
||||
out += data[i : i + 2]
|
||||
@@ -931,6 +1054,28 @@ def _strip_jpeg_metadata_lossless(source_path: Path, output_path: Path) -> bool:
|
||||
return True
|
||||
|
||||
|
||||
# Fallback extension -> PIL save format, used only when the content sniff is
|
||||
# inconclusive (never for JPEG re-encode of lossless content).
|
||||
_EXT_TO_PIL_FORMAT = {".jpg": "JPEG", ".jpeg": "JPEG", ".webp": "WEBP", ".png": "PNG"}
|
||||
|
||||
|
||||
def _sniff_image_format(head: bytes) -> str | None:
|
||||
"""Actual raster format from a file's leading magic bytes (>= 12 bytes), as a PIL
|
||||
format name ("JPEG"/"PNG"/"WEBP"), or None when unrecognized. The file EXTENSION is
|
||||
unreliable: ~2% of real uploads carry a mismatched one (a PNG served as ``.jpg`` is
|
||||
common). Choosing the save format by extension re-encodes a lossless PNG/WebP into a
|
||||
real JPEG, silently degrading the pixels -- so the strip routes on content instead.
|
||||
ISOBMFF/GIF are handled before this point or fall through to PNG; only the
|
||||
lossy-vs-lossless distinction that matters here is resolved."""
|
||||
if head[:2] == b"\xff\xd8":
|
||||
return "JPEG"
|
||||
if head[:8] == b"\x89PNG\r\n\x1a\n":
|
||||
return "PNG"
|
||||
if head[:4] == b"RIFF" and head[8:12] == b"WEBP":
|
||||
return "WEBP"
|
||||
return None
|
||||
|
||||
|
||||
def remove_ai_metadata(
|
||||
source_path: Path,
|
||||
output_path: Path | None = None,
|
||||
@@ -998,6 +1143,12 @@ def remove_ai_metadata(
|
||||
if source_path.suffix.lower() in _FFMPEG_STRIP_EXTS:
|
||||
return _strip_with_ffmpeg(source_path, output_path)
|
||||
|
||||
# Route on the ACTUAL content format, not the extension (which lies on ~2% of real
|
||||
# uploads -- a PNG served as .jpg, etc.). Trusting the extension would push a
|
||||
# lossless PNG/WebP through the lossy JPEG re-encode below just because its name
|
||||
# ends .jpg, breaking the "work with originals" invariant.
|
||||
true_fmt = _sniff_image_format(head) # reuse the 12 bytes already read above
|
||||
|
||||
# JPEG: strip AI metadata at the byte level so the DCT scan (the pixels) is NOT
|
||||
# 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
|
||||
@@ -1005,21 +1156,42 @@ def remove_ai_metadata(
|
||||
# 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)
|
||||
):
|
||||
if keep_standard and true_fmt == "JPEG" and _strip_jpeg_metadata_lossless(source_path, output_path):
|
||||
return output_path
|
||||
|
||||
# Fail-safe for a truncated / corrupt image: PIL raises OSError when it decodes a
|
||||
# partial file (`img.copy()` / `img.save()` below), which would crash a direct
|
||||
# library caller (a web worker 500s on a partial upload). Probe decodability first;
|
||||
# if it fails, copy the input through unchanged and return -- we cannot strip what we
|
||||
# cannot parse, but we never raise (mirrors strip_c2pa_boxes' fail-safe).
|
||||
try:
|
||||
with Image.open(source_path) as _probe:
|
||||
_probe.load()
|
||||
except Exception:
|
||||
logger.warning("Could not decode %s to strip metadata (truncated/corrupt); copied through", source_path)
|
||||
if output_path != source_path:
|
||||
import shutil
|
||||
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copyfile(source_path, output_path)
|
||||
return output_path
|
||||
|
||||
# Read image and filter metadata
|
||||
with Image.open(source_path) as img:
|
||||
img = img.copy()
|
||||
fmt = output_path.suffix.lower()
|
||||
# Pick the save format. Honor the caller's output extension (so a deliberate
|
||||
# source.png -> output.jpg conversion still works) UNLESS the SOURCE is misnamed
|
||||
# -- a lossless PNG/WebP whose extension lies (served as .jpg). There the output
|
||||
# extension only inherited the source's wrong name, so re-encoding to JPEG would
|
||||
# silently degrade an original; preserve the true content format instead.
|
||||
source_ext_fmt = _EXT_TO_PIL_FORMAT.get(source_path.suffix.lower())
|
||||
if true_fmt is not None and true_fmt != source_ext_fmt:
|
||||
fmt = true_fmt # misnamed source: never let a lying extension force a re-encode
|
||||
else:
|
||||
fmt = _EXT_TO_PIL_FORMAT.get(output_path.suffix.lower()) or true_fmt or "PNG"
|
||||
|
||||
save_kwargs: dict[str, Any] = {}
|
||||
if fmt in (".jpg", ".jpeg"):
|
||||
save_kwargs["format"] = "JPEG"
|
||||
save_kwargs: dict[str, Any] = {"format": fmt}
|
||||
if fmt == "JPEG":
|
||||
# JPEG output is unavoidably lossy, so minimize the loss: high quality
|
||||
# and no chroma subsampling (4:4:4). Without these PIL defaults to
|
||||
# quality 75 + 4:2:0, which visibly degrades a re-saved image.
|
||||
@@ -1027,15 +1199,12 @@ def remove_ai_metadata(
|
||||
save_kwargs["subsampling"] = 0
|
||||
if img.mode in ("RGBA", "P"):
|
||||
img = img.convert("RGB")
|
||||
elif fmt == ".webp":
|
||||
elif fmt == "WEBP":
|
||||
# Preserve the WebP container losslessly instead of silently rewriting
|
||||
# it as PNG (which changes the format and bloats the file).
|
||||
save_kwargs["format"] = "WEBP"
|
||||
save_kwargs["lossless"] = True
|
||||
if img.mode == "P": # WebP cannot encode palette mode
|
||||
img = img.convert("RGBA" if "transparency" in img.info else "RGB")
|
||||
else:
|
||||
save_kwargs["format"] = "PNG"
|
||||
|
||||
# Collect non-AI metadata
|
||||
kept_meta: dict[str, str] = {}
|
||||
@@ -1046,10 +1215,12 @@ def remove_ai_metadata(
|
||||
continue
|
||||
if _is_ai_key(key):
|
||||
continue
|
||||
# Drop a generic text chunk whose VALUE names an AI generator (NovelAI
|
||||
# writes its stamp into Title/Source under non-AI keys) -- keeps removal
|
||||
# in parity with exif_generator's value-based detection.
|
||||
if isinstance(value, str) and _is_ai_value(value):
|
||||
# Drop a text chunk whose VALUE names an AI generator (NovelAI writes its
|
||||
# stamp into Title/Source under non-AI keys) OR carries a China TC260 AIGC
|
||||
# block (some China gens put `{"AIGC":{...}}` in a STANDARD chunk like
|
||||
# Description, which _is_ai_key would keep) -- keeps removal in parity with
|
||||
# exif_generator / aigc_label's value-based detection.
|
||||
if isinstance(value, str) and (_is_ai_value(value) or _is_aigc_exif_value(value)):
|
||||
continue
|
||||
if key == "exif":
|
||||
with contextlib.suppress(Exception):
|
||||
|
||||
@@ -374,12 +374,15 @@ def _populate_registry_fields(buf: bytes, c2pa_info: dict[str, Any]) -> bool:
|
||||
c2pa_info["source_type"] = "trainedAlgorithmicMedia (AI-generated)"
|
||||
c2pa_info["ai_source_kind"] = "generated"
|
||||
ai_source = True
|
||||
elif b"algorithmicMedia" in buf:
|
||||
c2pa_info["source_type"] = "algorithmicMedia"
|
||||
elif b"compositeWithTrainedAlgorithmicMedia" in buf:
|
||||
# Checked BEFORE bare ``algorithmicMedia``: a manifest can carry both tokens
|
||||
# (an AI-enhanced composite with a procedural ingredient), and the bare-token
|
||||
# branch would otherwise fire first and misclassify the AI composite as non-AI.
|
||||
c2pa_info["source_type"] = "compositeWithTrainedAlgorithmicMedia (AI-enhanced)"
|
||||
c2pa_info["ai_source_kind"] = "enhanced"
|
||||
ai_source = True
|
||||
elif b"algorithmicMedia" in buf:
|
||||
c2pa_info["source_type"] = "algorithmicMedia"
|
||||
|
||||
# SynthID pixel-watermark proxy: a C2PA manifest from a SynthID-using
|
||||
# vendor (Google/OpenAI) on AI-generated content implies an invisible
|
||||
|
||||
@@ -246,42 +246,39 @@ def blank_ai_exif_tokens(data: bytes) -> tuple[bytes, int]:
|
||||
``remove_ai_metadata`` (a documented gap). This locates EXIF TIFF blocks by
|
||||
their byte-order header, **validates each with piexif** (so a coincidental
|
||||
II/MM run in pixel data is ignored -- it will not parse as a TIFF IFD), and
|
||||
overwrites any value carrying an ``AI_GENERATOR_TOKENS`` token with spaces of
|
||||
the SAME length. Because the replacement is same-length, every box size and
|
||||
``iloc`` offset stays valid and the coded image is untouched -- only the AI tag
|
||||
content is destroyed; camera/editor EXIF without an AI token is left intact
|
||||
(mirrors ``metadata._scrub_ai_exif`` and ``blank_ai_xmp_packets``).
|
||||
overwrites any AI value with spaces of the SAME length. Because the replacement
|
||||
is same-length, every box size and ``iloc`` offset stays valid and the coded image
|
||||
is untouched -- only the AI tag content is destroyed; camera/editor EXIF without an
|
||||
AI token is left intact. This mirrors ``metadata._scrub_ai_exif`` in what it removes
|
||||
-- generator tokens (``Software``/``Make``/``Artist``/``ImageDescription``), the
|
||||
China TC260 ``{"AIGC":{...}}`` block (``ImageDescription``/``UserComment``), and the
|
||||
xAI/Grok ``Signature:`` + UUID-``Artist`` pair -- since on the ISOBMFF path this is
|
||||
the ONLY EXIF scrubber (``_scrub_ai_exif`` never runs there), so without parity a
|
||||
HEIC/AVIF AIGC/xAI tag is detected but not removed.
|
||||
"""
|
||||
import piexif
|
||||
|
||||
from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS
|
||||
# The AI-EXIF rule set is defined ONCE in metadata._ai_exif_targets and shared by both
|
||||
# EXIF scrubbers (the JPEG _scrub_ai_exif pops the tag; here we blank the value bytes),
|
||||
# so their coverage cannot drift. Imported lazily to avoid import-order coupling with
|
||||
# metadata (which imports this module); a deliberate cross-module use, not an API leak.
|
||||
from remove_ai_watermarks.metadata import _ai_exif_targets # pyright: ignore[reportPrivateUsage]
|
||||
|
||||
ai_tags = (
|
||||
piexif.ImageIFD.Software,
|
||||
piexif.ImageIFD.Make,
|
||||
piexif.ImageIFD.Artist,
|
||||
piexif.ImageIFD.ImageDescription,
|
||||
)
|
||||
out = bytearray(data)
|
||||
blanked = 0
|
||||
for header in _TIFF_HEADERS:
|
||||
pos = data.find(header)
|
||||
while pos != -1:
|
||||
window = bytes(out[pos : pos + _EXIF_WINDOW])
|
||||
ifd: dict[int, Any] = {}
|
||||
try:
|
||||
ifd = piexif.load(window).get("0th", {})
|
||||
loaded: dict[str, Any] = piexif.load(window)
|
||||
except Exception:
|
||||
ifd = {}
|
||||
for tag in ai_tags:
|
||||
value = ifd.get(tag)
|
||||
if not isinstance(value, bytes):
|
||||
continue
|
||||
if any(token in value.decode("latin1", "replace").lower() for token in AI_GENERATOR_TOKENS):
|
||||
# Blank the value bytes in place, within this EXIF block only.
|
||||
vpos = out.find(value, pos, pos + _EXIF_WINDOW)
|
||||
if vpos != -1:
|
||||
out[vpos : vpos + len(value)] = b" " * len(value)
|
||||
blanked += 1
|
||||
loaded = {}
|
||||
for _ifd_key, _tag, value, _name in _ai_exif_targets(loaded):
|
||||
# Blank the value bytes in place, within this EXIF block only.
|
||||
vpos = out.find(value, pos, pos + _EXIF_WINDOW)
|
||||
if vpos != -1:
|
||||
out[vpos : vpos + len(value)] = b" " * len(value)
|
||||
blanked += 1
|
||||
pos = data.find(header, pos + len(header))
|
||||
return bytes(out), blanked
|
||||
@@ -447,6 +447,19 @@ class WatermarkRemover:
|
||||
self._set_progress("Loading fp16-fixed SDXL VAE (avoids black output)...")
|
||||
load_kwargs["vae"] = AutoencoderKL.from_pretrained(_SDXL_FP16_VAE_ID, torch_dtype=torch.float16)
|
||||
|
||||
@staticmethod
|
||||
def _disable_sdxl_watermarker(load_kwargs: dict[str, Any]) -> None:
|
||||
"""Turn off the diffusers default invisible watermarker on an SDXL pipeline.
|
||||
|
||||
diffusers embeds an open "Stable Diffusion XL" DWT-DCT invisible watermark on
|
||||
EVERY SDXL output whenever ``invisible-watermark`` is installed (the ``detect``
|
||||
extra). A watermark REMOVER must not re-stamp a detectable AI watermark, or the
|
||||
cleaned output re-reads as AI (``identify`` -> "Open invisible watermark: Stable
|
||||
Diffusion XL"). Shared by both SDXL loaders; the ``ControlNetModel`` sub-model
|
||||
and the Qwen loader never call it (only the pipeline accepts the kwarg).
|
||||
"""
|
||||
load_kwargs["add_watermarker"] = False
|
||||
|
||||
def _move_to_device_and_optimize(self, pipeline: Any) -> Any:
|
||||
"""Move a freshly-loaded pipeline to ``self.device`` + enable memory opts.
|
||||
|
||||
@@ -520,6 +533,7 @@ class WatermarkRemover:
|
||||
load_kwargs = self._base_load_kwargs()
|
||||
load_kwargs["safety_checker"] = None
|
||||
load_kwargs["requires_safety_checker"] = False
|
||||
self._disable_sdxl_watermarker(load_kwargs)
|
||||
self._maybe_add_fp16_vae(load_kwargs)
|
||||
|
||||
pipeline = self._load_from_pretrained(AutoImg2ImgPipeline, self.model_id, **load_kwargs) # type: ignore
|
||||
@@ -549,6 +563,7 @@ class WatermarkRemover:
|
||||
|
||||
load_kwargs = self._base_load_kwargs()
|
||||
load_kwargs["controlnet"] = controlnet
|
||||
self._disable_sdxl_watermarker(load_kwargs)
|
||||
self._maybe_add_fp16_vae(load_kwargs)
|
||||
|
||||
self._set_progress(f"Loading model weights: {self.model_id}")
|
||||
@@ -964,8 +979,9 @@ def remove_watermark(
|
||||
"""Convenience function to remove watermark from an image.
|
||||
|
||||
``strength=None`` lets the profile pick its vendor-adaptive default
|
||||
(0.20 OpenAI / 0.30 Google / 0.30 unknown, from the C2PA SynthID proxy on the
|
||||
input; same ladder for the controlnet and sdxl pipelines). Pass a value to override.
|
||||
(0.10 OpenAI / 0.15 Google / 0.15 unknown, from the C2PA SynthID proxy on the
|
||||
input; same ladder for the controlnet and sdxl pipelines -- the single source of
|
||||
truth is ``watermark_profiles.py``). Pass a value to override.
|
||||
|
||||
``region=(x, y, w, h)`` restricts the regeneration to that box and preserves the
|
||||
real photo elsewhere -- for AI-enhanced composites (see
|
||||
|
||||
@@ -10,9 +10,12 @@ Backends:
|
||||
- ``cv2`` (default): ``cv2.inpaint`` (Telea / Navier-Stokes). Instant, no extra
|
||||
dependencies, lower quality on large or textured regions.
|
||||
- ``migan`` (optional, extra ``migan``): MI-GAN via onnxruntime
|
||||
(``andraniksargsyan/migan``, MIT). CPU, ~28 MB model, ~700-950 MB peak RAM,
|
||||
~0.19 s/call -- the droplet-friendly tier: near-big-LaMa quality on small
|
||||
marks at ~5x less RAM and ~8x faster. Model downloaded on first use.
|
||||
(``andraniksargsyan/migan``, MIT). CPU, ~28 MB model, ~0.19 s/call -- the
|
||||
droplet-friendly tier: near-big-LaMa quality on small marks. Model downloaded
|
||||
on first use. Like ``lama`` it crops a padded region around the mask before
|
||||
inference (at native resolution -- MI-GAN takes arbitrary dims), so peak RAM is
|
||||
bounded by the mark size (~0.6-0.9 GB) rather than scaling with the image, which
|
||||
is what lets a memory-tight host run it on a large upload.
|
||||
- ``lama`` (optional, extra ``lama``): big-LaMa via onnxruntime
|
||||
(``Carve/LaMa-ONNX``, Apache-2.0). CPU, resolution-robust, best quality on
|
||||
texture but ~200 MB model and ~4.7 GB peak RAM (too heavy for a small host).
|
||||
@@ -69,6 +72,26 @@ def boxes_to_mask(
|
||||
return mask
|
||||
|
||||
|
||||
def _padded_crop_box(
|
||||
mask: NDArray[Any], h: int, w: int, *, pad_frac: float, pad_min: int
|
||||
) -> tuple[int, int, int, int] | None:
|
||||
"""Bounding box of the set mask pixels, padded and clamped to the image.
|
||||
|
||||
Returns ``(x0, y0, x1, y1)`` or ``None`` when the mask is empty. Both learned
|
||||
backends crop to this box so the ONNX working set is bounded by the mark size
|
||||
rather than the whole image; ``pad_frac``/``pad_min`` tune how much surrounding
|
||||
context the inpainter sees (LaMa then resizes the crop to its fixed square,
|
||||
MI-GAN feeds it at native resolution).
|
||||
"""
|
||||
ys, xs = np.where(mask > 0)
|
||||
if len(xs) == 0:
|
||||
return None
|
||||
pad = max(pad_min, int(pad_frac * max(xs.max() - xs.min() + 1, ys.max() - ys.min() + 1)))
|
||||
x0, y0 = max(0, int(xs.min()) - pad), max(0, int(ys.min()) - pad)
|
||||
x1, y1 = min(w, int(xs.max()) + 1 + pad), min(h, int(ys.max()) + 1 + pad)
|
||||
return x0, y0, x1, y1
|
||||
|
||||
|
||||
def erase_cv2(
|
||||
image_bgr: NDArray[Any],
|
||||
mask: NDArray[Any],
|
||||
@@ -139,14 +162,10 @@ def erase_lama(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
|
||||
size = next((d for d in reversed(dims) if isinstance(d, int) and d > 1), 512)
|
||||
|
||||
h, w = image_bgr.shape[:2]
|
||||
ys, xs = np.where(mask > 0)
|
||||
if len(xs) == 0:
|
||||
box = _padded_crop_box(mask, h, w, pad_frac=0.4, pad_min=16)
|
||||
if box is None:
|
||||
return image_bgr.copy()
|
||||
|
||||
# Padded crop around the mask (context for the inpainter).
|
||||
pad = max(16, int(0.4 * max(xs.max() - xs.min() + 1, ys.max() - ys.min() + 1)))
|
||||
cx0, cy0 = max(0, int(xs.min()) - pad), max(0, int(ys.min()) - pad)
|
||||
cx1, cy1 = min(w, int(xs.max()) + 1 + pad), min(h, int(ys.max()) + 1 + pad)
|
||||
cx0, cy0, cx1, cy1 = box
|
||||
crop = image_bgr[cy0:cy1, cx0:cx1]
|
||||
crop_mask = mask[cy0:cy1, cx0:cx1]
|
||||
ch, cw = crop.shape[:2]
|
||||
@@ -201,16 +220,25 @@ def _get_migan_session() -> object:
|
||||
def erase_migan(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
|
||||
"""Inpaint ``mask`` (255 = erase) with MI-GAN via onnxruntime (CPU).
|
||||
|
||||
The MI-GAN ONNX pipeline crops around the mask bbox internally and re-composites,
|
||||
so the full image is fed at native resolution. Only the masked pixels are pasted
|
||||
back, so untouched areas stay pixel-exact.
|
||||
Like ``erase_lama``, we crop a padded region around the mask, feed only that crop
|
||||
to the ONNX model, and paste only the masked pixels back -- so untouched areas stay
|
||||
pixel-exact and the ONNX working set is bounded by the MARK size, not the whole
|
||||
image. MI-GAN accepts arbitrary spatial dims, so (unlike LaMa's fixed 512 square)
|
||||
the crop is fed at NATIVE resolution -- no resize, so the mark is seen at full
|
||||
scale. Feeding the whole frame instead made peak RAM scale with the image
|
||||
(~0.6 GB at 4 MP up to ~2.4 GB at 25 MP, measured 2026-07); cropping holds the
|
||||
inpaint working set roughly constant, so a memory-tight host (e.g. a 1-2 GB web
|
||||
worker) can run MI-GAN on a 25 MP upload. Cropping does not degrade the fill:
|
||||
a small mark only needs local context, and on real marks the cropped fill is on
|
||||
par with -- sometimes cleaner than -- the full-frame fill (a tighter view gives
|
||||
the GAN less room to hallucinate large background structure).
|
||||
|
||||
Mask polarity: the shipped ``andraniksargsyan/migan`` ONNX expects 0 = hole
|
||||
(inpaint) / 255 = known (keep) -- the INVERSE of this package's 255-erase
|
||||
convention -- so the mask is inverted before feeding the model (corpus-validated
|
||||
2026-07; feeding 255=hole regenerates the whole frame into stripes).
|
||||
|
||||
Like ``erase_lama``, accepts 1-channel (grayscale) and 4-channel (BGRA) input.
|
||||
Accepts 1-channel (grayscale) and 4-channel (BGRA) input.
|
||||
"""
|
||||
if image_bgr.ndim == 2:
|
||||
bgr = erase_migan(cv2.cvtColor(image_bgr, cv2.COLOR_GRAY2BGR), mask)
|
||||
@@ -219,26 +247,40 @@ def erase_migan(image_bgr: NDArray[Any], mask: NDArray[Any]) -> NDArray[Any]:
|
||||
bgr = erase_migan(np.ascontiguousarray(image_bgr[:, :, :3]), mask)
|
||||
return np.dstack([bgr, image_bgr[:, :, 3]])
|
||||
|
||||
h, w = image_bgr.shape[:2]
|
||||
# 2x the mark size (min 256 px) gives ample local context while a small corner
|
||||
# mask keeps the crop small regardless of the full image resolution; the crop is
|
||||
# clamped to the image, so a mark already spanning the frame degrades to the
|
||||
# whole image (the old behavior) rather than erroring.
|
||||
box = _padded_crop_box(mask, h, w, pad_frac=2.0, pad_min=256)
|
||||
if box is None:
|
||||
return image_bgr.copy()
|
||||
cx0, cy0, cx1, cy1 = box
|
||||
crop = np.ascontiguousarray(image_bgr[cy0:cy1, cx0:cx1])
|
||||
crop_mask = mask[cy0:cy1, cx0:cx1]
|
||||
ch, cw = crop.shape[:2]
|
||||
|
||||
session = _get_migan_session()
|
||||
inp = session.get_inputs() # type: ignore[attr-defined]
|
||||
img_name, mask_name = inp[0].name, inp[1].name
|
||||
h, w = image_bgr.shape[:2]
|
||||
|
||||
rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
|
||||
img_in = np.transpose(rgb, (2, 0, 1))[None].astype(np.uint8) # (1,3,H,W)
|
||||
rgb = cv2.cvtColor(crop, cv2.COLOR_BGR2RGB)
|
||||
img_in = np.transpose(rgb, (2, 0, 1))[None].astype(np.uint8) # (1,3,ch,cw)
|
||||
# invert to MI-GAN polarity: 255 where KNOWN (keep), 0 where hole (erase)
|
||||
known = (mask <= 127).astype(np.uint8) * 255
|
||||
mask_in = known[None, None] # (1,1,H,W)
|
||||
known = (crop_mask <= 127).astype(np.uint8) * 255
|
||||
mask_in = known[None, None] # (1,1,ch,cw)
|
||||
|
||||
out = session.run(None, {img_name: img_in, mask_name: mask_in})[0] # type: ignore[attr-defined]
|
||||
res = np.transpose(np.asarray(out)[0], (1, 2, 0)).astype(np.uint8) # (H',W',3) RGB
|
||||
if res.shape[:2] != (h, w):
|
||||
res = cv2.resize(res, (w, h), interpolation=cv2.INTER_LINEAR)
|
||||
res = np.transpose(np.asarray(out)[0], (1, 2, 0)).astype(np.uint8) # (ch',cw',3) RGB
|
||||
if res.shape[:2] != (ch, cw):
|
||||
res = cv2.resize(res, (cw, ch), interpolation=cv2.INTER_LINEAR)
|
||||
out_bgr = cv2.cvtColor(res, cv2.COLOR_RGB2BGR)
|
||||
|
||||
result = image_bgr.copy()
|
||||
hole = mask > 127
|
||||
result[hole] = out_bgr[hole]
|
||||
region = result[cy0:cy1, cx0:cx1]
|
||||
hole = crop_mask > 127
|
||||
region[hole] = out_bgr[hole]
|
||||
result[cy0:cy1, cx0:cx1] = region
|
||||
return result
|
||||
|
||||
|
||||
|
||||
@@ -573,6 +573,16 @@ class TestMetadataCommand:
|
||||
assert result.exit_code == 0
|
||||
assert "stripped" in result.output
|
||||
|
||||
def test_metadata_remove_in_place(self, runner, tmp_png_with_ai_metadata):
|
||||
"""With ``-o`` omitted, the strip overwrites the source in place (default
|
||||
output_path=None). Previously every test passed an explicit ``-o``."""
|
||||
from remove_ai_watermarks.metadata import has_ai_metadata
|
||||
|
||||
assert has_ai_metadata(tmp_png_with_ai_metadata) # precondition
|
||||
result = runner.invoke(main, ["metadata", str(tmp_png_with_ai_metadata), "--remove"])
|
||||
assert result.exit_code == 0, result.output
|
||||
assert not has_ai_metadata(tmp_png_with_ai_metadata) # source overwritten, AI metadata gone
|
||||
|
||||
|
||||
class TestIdentifyCommand:
|
||||
"""Tests for the 'identify' subcommand."""
|
||||
@@ -600,6 +610,18 @@ class TestIdentifyCommand:
|
||||
assert "AI-generated" in result.output
|
||||
assert "Stable Diffusion" in result.output
|
||||
|
||||
def test_identify_reports_generated_source_kind(self, runner):
|
||||
"""The C2PA trainedAlgorithmicMedia source type sharpens the verdict to
|
||||
'AI-generated (fully synthetic)' at the CLI (the ai_source_kind branch)."""
|
||||
from pathlib import Path
|
||||
|
||||
sample = Path(__file__).resolve().parent.parent / "data" / "samples" / "chatgpt-1.png"
|
||||
if not sample.exists():
|
||||
pytest.skip("chatgpt sample not present")
|
||||
result = runner.invoke(main, ["identify", str(sample), "--no-visible"])
|
||||
assert result.exit_code == 0
|
||||
assert "AI-generated (fully synthetic)" in result.output
|
||||
|
||||
def test_identify_json_is_valid(self, runner, tmp_png_with_ai_metadata):
|
||||
result = runner.invoke(main, ["identify", str(tmp_png_with_ai_metadata), "--no-visible", "--json"])
|
||||
assert result.exit_code == 0
|
||||
@@ -774,6 +796,34 @@ class TestBatchCommand:
|
||||
expected_dir = tmp_path / "input_clean"
|
||||
assert expected_dir.exists()
|
||||
|
||||
def test_batch_errors_exit_nonzero(self, runner, tmp_path):
|
||||
"""Regression: batch used to always exit 0 even when every image errored,
|
||||
hiding failure from a wrapping service. A corrupt image must yield a non-zero
|
||||
exit and an error count."""
|
||||
input_dir = tmp_path / "input"
|
||||
input_dir.mkdir()
|
||||
(input_dir / "corrupt.png").write_bytes(b"this is not a PNG at all" * 50)
|
||||
result = runner.invoke(main, ["batch", str(input_dir), "--mode", "visible"])
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "error" in result.output.lower()
|
||||
|
||||
def test_batch_invisible_gpu_missing_writes_output_and_exits_nonzero(self, runner, tmp_path):
|
||||
"""Regression: batch --mode invisible with a signal-bearing image but no GPU
|
||||
deps used to write NO output for that image and still exit 0, silently dropping
|
||||
the files that most needed processing. It must now copy the input through (so the
|
||||
output dir is complete), warn about the retained SynthID watermark, and exit
|
||||
non-zero -- mirroring the single ``all`` command."""
|
||||
input_dir = _make_batch_dir_with_metadata(tmp_path, count=3) # SD params = invisible signal
|
||||
output_dir = tmp_path / "output"
|
||||
with patch("remove_ai_watermarks.invisible_engine.is_available", return_value=False):
|
||||
result = runner.invoke(
|
||||
main,
|
||||
["batch", str(input_dir), "-o", str(output_dir), "--mode", "invisible"],
|
||||
)
|
||||
assert result.exit_code != 0, result.output
|
||||
assert "NOT removed" in result.output
|
||||
assert len(list(output_dir.glob("*.png"))) == 3 # every input copied through, none dropped
|
||||
|
||||
|
||||
class TestGpuHintMarkup:
|
||||
"""The GPU-extra install hint must reach the user with the ``[gpu]`` token
|
||||
@@ -887,3 +937,33 @@ def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypat
|
||||
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"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "content"),
|
||||
[
|
||||
("empty.png", b""),
|
||||
("notimage.jpg", b"plain text, not an image at all " * 20),
|
||||
("truncated.png", b"\x89PNG\r\n\x1a\n" + b"\x00" * 40),
|
||||
],
|
||||
)
|
||||
@pytest.mark.parametrize("cmd", [["metadata", "--remove"], ["visible", "--backend", "cv2"]])
|
||||
def test_unreadable_input_exits_cleanly(runner, tmp_path, name, content, cmd):
|
||||
"""Regression: a corrupt / empty / non-image file (real prod uploads include ~0.2%
|
||||
truncated files) must NEVER leak a raw PIL/OSError/ValueError traceback. `metadata
|
||||
--remove` is fail-safe -- an undecodable file is copied through unchanged (exit 0),
|
||||
a strip that cannot parse the file is a no-op, not a crash; `visible` must decode to
|
||||
remove a mark, so it is a clean error (exit 1). Found by the runtime mode fuzz."""
|
||||
bad = tmp_path / name
|
||||
bad.write_bytes(content)
|
||||
out = tmp_path / "out.png"
|
||||
result = runner.invoke(main, [cmd[0], str(bad), "-o", str(out), *cmd[1:]])
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit), (
|
||||
f"leaked a raw traceback: {result.exception!r}"
|
||||
)
|
||||
if cmd[0] == "metadata":
|
||||
assert result.exit_code == 0, result.output # fail-safe copy-through
|
||||
assert out.read_bytes() == content # input passed through unchanged
|
||||
else:
|
||||
assert result.exit_code == 1, result.output # cannot remove a mark from unreadable input
|
||||
assert "Error" in result.output
|
||||
@@ -195,3 +195,29 @@ class TestDegenerateAndChannelInputs:
|
||||
bgra = np.zeros((2048, 2048, 4), np.uint8)
|
||||
mask = eng.footprint_mask(bgra, force=True)
|
||||
assert mask is None or mask.shape == (2048, 2048)
|
||||
|
||||
def test_template_match_score_guards_return_zero(self):
|
||||
# Guards return 0.0 (never a false positive) for a mask that cannot hold a
|
||||
# glyph: empty, narrower than min_gw, or shorter than the 4-px floor.
|
||||
assert _template_match_score(np.zeros((0, 5), np.uint8), 1000) == 0.0
|
||||
assert _template_match_score(np.zeros((10, 3), np.uint8), 1000) == 0.0 # width-1 < min_gw
|
||||
assert _template_match_score(np.zeros((3, 200), np.uint8), 1000) == 0.0 # height-1 < 4
|
||||
|
||||
@pytest.mark.parametrize("shape", [(20, 20, 3), (10, 400, 3), (400, 10, 3), (1, 1, 3), (2000, 2000, 3)])
|
||||
def test_locate_box_stays_in_bounds(self, shape):
|
||||
"""locate() must clamp its geometry box inside the image for ANY size/aspect --
|
||||
wide-short, tall-narrow, 1x1, huge -- for both bottom corners (br + bl)."""
|
||||
from remove_ai_watermarks._text_mark_engine import TextMarkEngine
|
||||
from remove_ai_watermarks.doubao_engine import _CONFIG as BR_CONFIG
|
||||
from remove_ai_watermarks.samsung_engine import _CONFIG as BL_CONFIG
|
||||
|
||||
h, w = shape[:2]
|
||||
img = np.zeros(shape, np.uint8)
|
||||
for cfg in (BR_CONFIG, BL_CONFIG):
|
||||
loc = TextMarkEngine(cfg).locate(img)
|
||||
assert loc.x >= 0
|
||||
assert loc.y >= 0
|
||||
assert loc.x + loc.w <= w
|
||||
assert loc.y + loc.h <= h
|
||||
assert loc.w > 0
|
||||
assert loc.h > 0
|
||||
@@ -1,4 +1,5 @@
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
from remove_ai_watermarks.humanizer import apply_analog_humanizer, unsharp_mask
|
||||
|
||||
@@ -72,6 +73,15 @@ def test_chromatic_shift_does_not_wrap_opposite_edge():
|
||||
assert result[:, -shift:, 2].min() > 195
|
||||
|
||||
|
||||
@pytest.mark.parametrize(("width", "shift"), [(1, 1), (3, 3), (3, 5), (5, 10)])
|
||||
def test_chromatic_shift_wider_than_image_no_crash(width: int, shift: int):
|
||||
"""Regression: a chromatic_shift >= image width left the edge-replication slices
|
||||
empty and crashed the broadcast (ValueError). The shift must clamp to width-1."""
|
||||
img = np.full((4, width, 3), 120, np.uint8)
|
||||
result = apply_analog_humanizer(img, grain_intensity=0.0, chromatic_shift=shift)
|
||||
assert result.shape == img.shape
|
||||
|
||||
|
||||
def test_unsharp_disabled_returns_unchanged_copy():
|
||||
img = np.full((20, 20, 3), 128, dtype=np.uint8)
|
||||
img[10, 10] = [100, 150, 200]
|
||||
@@ -147,3 +157,19 @@ class TestAdaptivePolish:
|
||||
a = adaptive_polish(soft, reference, seed=7)
|
||||
b = adaptive_polish(soft, reference, seed=7)
|
||||
assert np.array_equal(a, b)
|
||||
|
||||
def test_all_edges_reference_grain_mask_near_zero(self):
|
||||
# An all-high-frequency target: _smooth_grain_mask suppresses edges, so the grain
|
||||
# mask is ~all-zero (grain adds nothing) -- adaptive_polish must still return a
|
||||
# valid same-shape image, not crash on the empty-mask branch.
|
||||
import cv2
|
||||
|
||||
from remove_ai_watermarks.humanizer import _smooth_grain_mask, adaptive_polish
|
||||
|
||||
rng = np.random.default_rng(5)
|
||||
edges = rng.integers(0, 256, (120, 120, 3), dtype=np.uint8) # all high-frequency
|
||||
assert _smooth_grain_mask(edges).mean() < _smooth_grain_mask(np.full((120, 120, 3), 128, np.uint8)).mean()
|
||||
soft = cv2.GaussianBlur(edges, (0, 0), sigmaX=3.0)
|
||||
out = adaptive_polish(soft, edges, seed=0)
|
||||
assert out.shape == soft.shape
|
||||
assert out.dtype == np.uint8
|
||||
@@ -926,6 +926,15 @@ class TestVendorOf:
|
||||
assert _vendor_of("a regular photo") is None
|
||||
assert _vendor_of(None) is None
|
||||
|
||||
def test_registered_vendors_normalize(self):
|
||||
# Regression: these registered C2PA vendors returned None, so their claims never
|
||||
# entered clash detection (a coverage hole). They now normalize to one origin.
|
||||
assert _vendor_of("ByteDance (Doubao / Jimeng / Volcano Engine)") == "ByteDance"
|
||||
assert _vendor_of("Dreamina/1.2") == "ByteDance"
|
||||
assert _vendor_of("Canva (Magic Media)") == "Canva"
|
||||
assert _vendor_of("Black Forest Labs (FLUX)") == "Black Forest Labs"
|
||||
assert _vendor_of("Eleven Labs Inc.") == "ElevenLabs"
|
||||
|
||||
|
||||
class TestIntegrityClashesHelper:
|
||||
def test_two_ai_vendors_clash(self):
|
||||
@@ -949,6 +958,31 @@ class TestIntegrityClashesHelper:
|
||||
== []
|
||||
)
|
||||
|
||||
def test_bytedance_c2pa_plus_own_aigc_no_clash(self):
|
||||
# A legit ByteDance/Doubao image carries BOTH a ByteDance C2PA manifest and its
|
||||
# own China TC260 AIGC label. The label is ByteDance's own regulatory stamp, so
|
||||
# it must be attributed to ByteDance and NOT read as a competing origin.
|
||||
assert (
|
||||
_integrity_clashes({"c2pa": "ByteDance", "aigc": "China AIGC (TC260)"}, None, camera_has_ai_marker=True)
|
||||
== []
|
||||
)
|
||||
|
||||
def test_foreign_vendor_plus_aigc_still_clashes(self):
|
||||
# But a NON-Chinese vendor's C2PA next to a China TC260 label names two different
|
||||
# origins -- a laundering tell that must still fire (the generic label stays generic).
|
||||
clashes = _integrity_clashes({"c2pa": "OpenAI", "aigc": "China AIGC (TC260)"}, None, camera_has_ai_marker=True)
|
||||
assert len(clashes) == 1
|
||||
assert "Conflicting AI-origin" in clashes[0]
|
||||
|
||||
def test_bytedance_c2pa_plus_foreign_generator_clashes(self):
|
||||
# Coverage win: a transplanted ByteDance C2PA manifest next to an independent
|
||||
# foreign generator stamp is a laundering tell that went undetected before
|
||||
# ByteDance was added to _vendor_of.
|
||||
clashes = _integrity_clashes({"c2pa": "ByteDance", "exif_generator": "OpenAI"}, None, camera_has_ai_marker=True)
|
||||
assert len(clashes) == 1
|
||||
assert "ByteDance" in clashes[0]
|
||||
assert "OpenAI" in clashes[0]
|
||||
|
||||
def test_manifest_vendor_vs_independent_signal_clashes(self):
|
||||
# A vendor named only inside the manifest still clashes with a genuinely
|
||||
# independent stamp (here an EXIF/XMP generator tag) naming a third vendor.
|
||||
|
||||
@@ -183,6 +183,17 @@ class TestQualityPreservingWrite:
|
||||
assert back is not None
|
||||
assert float(np.abs(img.astype(int) - back.astype(int)).mean()) < 1.0
|
||||
|
||||
def test_webp_written_lossless(self, tmp_path: Path) -> None:
|
||||
# Regression: cv2 WebP quality 1-100 is LOSSY; lossless needs > 100. A
|
||||
# mark-removal .webp re-encode must NOT degrade the untouched pixels, so
|
||||
# a full-frame round-trip of random data must be bit-identical.
|
||||
img = np.random.default_rng(0).integers(0, 256, (80, 80, 3), dtype=np.uint8)
|
||||
p = tmp_path / "x.webp"
|
||||
assert image_io.imwrite(p, img) is True
|
||||
back = image_io.imread(p)
|
||||
assert back is not None
|
||||
assert np.array_equal(back, img), "WebP re-encode was lossy"
|
||||
|
||||
@pytest.mark.skipif(not _heif_writable("HEIF"), reason="no HEIC encoder in this env")
|
||||
def test_heic_write_roundtrips(self, tmp_path: Path) -> None:
|
||||
# cv2 cannot encode HEIC (used to raise); imwrite must route through Pillow.
|
||||
|
||||
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
from types import SimpleNamespace
|
||||
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks.invisible_engine import InvisibleEngine, _target_size, is_available
|
||||
@@ -195,3 +196,24 @@ class TestEsrganUpscale:
|
||||
out = InvisibleEngine._esrgan_upscale(self._fake_engine(), img, (512, 341))
|
||||
assert out.size == (512, 341)
|
||||
assert np.array_equal(np.asarray(out), np.asarray(img.resize((512, 341), Image.Resampling.LANCZOS)))
|
||||
|
||||
|
||||
class TestCannyControlImage:
|
||||
"""The ControlNet canny conditioning image builder (pure cv2/numpy; behind the gpu
|
||||
extra since it lives on WatermarkRemover). Skips when torch/diffusers are absent."""
|
||||
|
||||
def test_edge_map_is_3channel_rgb(self):
|
||||
if not is_available():
|
||||
pytest.skip("gpu extra (torch/diffusers) not installed")
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
|
||||
|
||||
rng = np.random.default_rng(0)
|
||||
img = Image.fromarray(rng.integers(0, 256, (64, 80, 3), dtype=np.uint8))
|
||||
# The method uses no instance state, so call it unbound with a dummy self.
|
||||
out = WatermarkRemover._build_canny_control_image(None, img) # type: ignore[arg-type]
|
||||
arr = np.array(out)
|
||||
assert out.mode == "RGB"
|
||||
assert arr.shape == (64, 80, 3)
|
||||
assert arr.max() <= 255
|
||||
+250
-4
@@ -179,6 +179,105 @@ class TestHasAiMetadata:
|
||||
assert np.array_equal(before, after), f"{name}: pixels changed (DCT was re-encoded)"
|
||||
assert not has_ai_metadata(out), f"{name}: AI metadata survived the strip"
|
||||
|
||||
def test_strip_preserves_lossless_content_with_mismatched_extension(self, tmp_path: Path):
|
||||
"""F1 regression: the save format is chosen by CONTENT, not the file extension.
|
||||
A PNG served with a .jpg name (common on real uploads -- ~1% of the corpus is a
|
||||
PNG/WebP under a .jpg extension) must be stripped losslessly as PNG, NOT
|
||||
re-encoded into a real JPEG. The extension-driven path silently degraded it,
|
||||
breaking the 'work with originals' invariant."""
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
# Random noise: JPEG (even q95 4:4:4) provably shifts it; PNG stays exact.
|
||||
arr = np.random.default_rng(0).integers(0, 256, (64, 64, 3), dtype=np.uint8)
|
||||
info = PngInfo()
|
||||
info.add_text("parameters", "Stable Diffusion prompt") # an AI-provenance key
|
||||
src = tmp_path / "actually_png.jpg" # PNG bytes under a .jpg name
|
||||
Image.fromarray(arr).save(src, format="PNG", pnginfo=info)
|
||||
assert has_ai_metadata(src)
|
||||
|
||||
out = tmp_path / "cleaned.jpg"
|
||||
remove_ai_metadata(src, out)
|
||||
|
||||
before = image_io.imread(str(src))
|
||||
after = image_io.imread(str(out))
|
||||
assert before is not None
|
||||
assert after is not None
|
||||
assert np.array_equal(before, after), "misnamed PNG was lossily re-encoded to JPEG"
|
||||
assert not has_ai_metadata(out), "AI metadata survived the strip"
|
||||
|
||||
@staticmethod
|
||||
def _xmp_iptc_jpeg(tmp_path: Path, name: str, marker: bytes) -> Path:
|
||||
"""A real (decodable) JPEG carrying the IPTC AI marker in a well-formed APP1
|
||||
XMP segment -- the layout Instagram/Facebook/X and MidJourney/Meta use, where
|
||||
``digitalSourceType`` lives in XMP rather than the APP13 IPTC-IIM record.
|
||||
Synthetic (no corpus): a solid cv2 JPEG with the APP1 spliced in after SOI."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
real = tmp_path / f"real-{name}"
|
||||
cv2.imwrite(str(real), np.full((32, 32, 3), 200, np.uint8), [cv2.IMWRITE_JPEG_QUALITY, 100])
|
||||
data = real.read_bytes()
|
||||
xmp = (
|
||||
b"http://ns.adobe.com/xap/1.0/\x00"
|
||||
b'<x:xmpmeta xmlns:x="adobe:ns:meta/"><rdf:RDF><rdf:Description>'
|
||||
b"<Iptc4xmpExt:DigitalSourceType>http://cv.iptc.org/newscodes/digitalsourcetype/"
|
||||
+ marker
|
||||
+ b"</Iptc4xmpExt:DigitalSourceType></rdf:Description></rdf:RDF></x:xmpmeta>"
|
||||
)
|
||||
seg = b"\xff\xe1" + (len(xmp) + 2).to_bytes(2, "big") + xmp
|
||||
path = tmp_path / name
|
||||
path.write_bytes(data[:2] + seg + data[2:]) # splice APP1 right after SOI
|
||||
return path
|
||||
|
||||
@pytest.mark.parametrize("marker", [b"trainedAlgorithmicMedia", b"AISystemUsed"])
|
||||
def test_jpeg_strip_removes_iptc_marker_in_xmp(self, tmp_path: Path, marker: bytes):
|
||||
"""Regression: the lossless JPEG strip must drop an AI-bearing APP1 XMP packet
|
||||
when the AI signal is an IPTC ``digitalSourceType`` / 2025.1 field, not only a
|
||||
C2PA or China-AIGC token. Before the fix these survived because the APP1 branch
|
||||
of ``_jpeg_app_carries_ai`` checked only c2pa + AIGC markers, leaving the
|
||||
Instagram/MidJourney/Meta 'Made with AI' XMP intact. Pixels stay bit-identical."""
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
src = self._xmp_iptc_jpeg(tmp_path, "iptc-xmp.jpg", marker)
|
||||
assert has_ai_metadata(src) # detected before
|
||||
before = image_io.imread(str(src))
|
||||
out = tmp_path / "clean.jpg"
|
||||
remove_ai_metadata(src, out)
|
||||
after = image_io.imread(str(out))
|
||||
assert before is not None
|
||||
assert after is not None
|
||||
assert np.array_equal(before, after), "pixels changed: the DCT scan was re-encoded"
|
||||
assert not has_ai_metadata(out), "IPTC AI marker in XMP survived the strip"
|
||||
|
||||
def test_remove_ai_metadata_failsafe_on_truncated_png(self, tmp_path: Path):
|
||||
"""Regression: a truncated / corrupt image must NOT crash remove_ai_metadata (a
|
||||
direct library caller like a web worker would 500 on a partial upload). PIL raises
|
||||
OSError decoding a truncated PNG; the strip must fail SAFE -- copy the input through
|
||||
unchanged and return, mirroring strip_c2pa_boxes. Real prod corpus: ~0.2% of
|
||||
uploads are truncated."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
real = tmp_path / "real.png"
|
||||
cv2.imwrite(str(real), np.random.default_rng(0).integers(0, 256, (128, 128, 3), dtype=np.uint8))
|
||||
truncated = tmp_path / "truncated.png"
|
||||
truncated.write_bytes(real.read_bytes()[: real.stat().st_size // 2]) # chop the IDAT stream
|
||||
out = tmp_path / "out.png"
|
||||
# Must not raise; output is the input copied through (undecodable -> nothing to strip).
|
||||
result = remove_ai_metadata(truncated, out)
|
||||
assert result == out
|
||||
assert out.read_bytes() == truncated.read_bytes()
|
||||
|
||||
|
||||
class TestC2paMarkerIn:
|
||||
"""The C2PA presence check requires a JUMBF wrapper or the C2PA uuid box, so
|
||||
@@ -235,6 +334,59 @@ class TestSamsungGenai:
|
||||
p = self._samsung_jpeg(tmp_path, "stray.jpg", b'some other blob "genAIType":1 elsewhere')
|
||||
assert samsung_genai(p) is None
|
||||
|
||||
def test_remove_strips_post_eoi_trailer(self, tmp_path: Path):
|
||||
"""Regression: Galaxy AI appends ``PhotoEditor_Re_Edit_Data`` as a trailer AFTER
|
||||
the JPEG EOI, so the verbatim scan copy in the lossless strip carried it through
|
||||
(survived 0/8 on the corpus). The strip now truncates a Samsung AI trailer at EOI;
|
||||
pixels stay bit-identical and a non-Samsung trailer is preserved."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks import image_io
|
||||
from remove_ai_watermarks.metadata import remove_ai_metadata
|
||||
|
||||
real = tmp_path / "real.jpg"
|
||||
cv2.imwrite(str(real), np.full((32, 32, 3), 180, np.uint8), [cv2.IMWRITE_JPEG_QUALITY, 100])
|
||||
src = tmp_path / "galaxy.jpg"
|
||||
src.write_bytes(real.read_bytes() + b'PhotoEditor_Re_Edit_Data{"genAIType":1}')
|
||||
assert samsung_genai(src) == 1 # detected before
|
||||
before = image_io.imread(str(src))
|
||||
out = tmp_path / "clean.jpg"
|
||||
remove_ai_metadata(src, out)
|
||||
after = image_io.imread(str(out))
|
||||
assert before is not None
|
||||
assert after is not None
|
||||
assert np.array_equal(before, after), "pixels changed: the DCT scan was re-encoded"
|
||||
assert samsung_genai(out) is None, "Samsung genAIType trailer survived the strip"
|
||||
|
||||
def test_non_samsung_trailer_preserved(self, tmp_path: Path):
|
||||
"""A benign post-EOI trailer (e.g. an MPF block) must NOT be truncated."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from remove_ai_watermarks.metadata import _strip_samsung_trailer
|
||||
|
||||
real = tmp_path / "real.jpg"
|
||||
cv2.imwrite(str(real), np.full((16, 16, 3), 90, np.uint8))
|
||||
tail = real.read_bytes() + b"MPF-benign-trailer-bytes"
|
||||
assert _strip_samsung_trailer(tail) == tail
|
||||
|
||||
def test_detects_trailer_past_scan_window(self, tmp_path: Path):
|
||||
"""Regression: the marker is a trailer AFTER the JPEG EOI, so on a multi-MB
|
||||
photo it sits past the 512 KB quick-scan window. Detection must read the file
|
||||
tail too, else it disagrees with removal (which reads the whole file). A random
|
||||
1400x1400 q100 JPEG exceeds 512 KB; the marker is only in its post-EOI tail."""
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
real = tmp_path / "big.jpg"
|
||||
big = np.random.default_rng(0).integers(0, 256, (1400, 1400, 3), dtype=np.uint8)
|
||||
cv2.imwrite(str(real), big, [cv2.IMWRITE_JPEG_QUALITY, 100])
|
||||
assert real.stat().st_size > 512 * 1024 # trailer will be past the quick-scan window
|
||||
p = tmp_path / "galaxy_big.jpg"
|
||||
p.write_bytes(real.read_bytes() + b'PhotoEditor_Re_Edit_Data{"genAIType":1}')
|
||||
assert samsung_genai(p) == 1
|
||||
|
||||
def test_clean_image_is_none(self, tmp_clean_png):
|
||||
assert samsung_genai(tmp_clean_png) is None
|
||||
|
||||
@@ -300,7 +452,6 @@ class TestGetAiMetadataRealSample:
|
||||
[
|
||||
b"trainedAlgorithmicMedia",
|
||||
b"compositeSynthetic",
|
||||
b"algorithmicMedia",
|
||||
b"compositeWithTrainedAlgorithmicMedia",
|
||||
],
|
||||
)
|
||||
@@ -311,6 +462,21 @@ def test_has_ai_metadata_detects_each_iptc_marker(tmp_path: Path, marker: bytes)
|
||||
assert has_ai_metadata(path)
|
||||
|
||||
|
||||
def test_bare_algorithmic_media_not_flagged_ai(tmp_path: Path):
|
||||
"""Regression: the IPTC ``algorithmicMedia`` digitalSourceType is PROCEDURAL (an
|
||||
algorithm not trained on sampled data), NOT AI/ML generation. It must NOT be flagged
|
||||
-- flagging it made identify assert is_ai=high + has_invisible_target=True, which
|
||||
would trigger a diffusion scrub of clean procedural content. It is a distinct token
|
||||
from ``trainedAlgorithmicMedia``, so real 'Made with AI' labels are unaffected."""
|
||||
path = tmp_path / "proc.jpg"
|
||||
path.write_bytes(
|
||||
b"\xff\xd8\xff\xe1<x:xmpmeta><Iptc4xmpExt:DigitalSourceType>"
|
||||
b"http://cv.iptc.org/newscodes/digitalsourcetype/algorithmicMedia"
|
||||
b"</Iptc4xmpExt:DigitalSourceType></x:xmpmeta>\xff\xd9"
|
||||
)
|
||||
assert not has_ai_metadata(path)
|
||||
|
||||
|
||||
# ── SynthID-source detection (metadata proxy) ────────────────────────
|
||||
|
||||
|
||||
@@ -920,16 +1086,31 @@ class TestAIGCLabel:
|
||||
def test_has_ai_metadata_detects_raw_json_exif_form(self, tmp_path: Path):
|
||||
assert has_ai_metadata(self._aigc_exif_jpeg(tmp_path))
|
||||
|
||||
def _aigc_bare_jpeg(self, tmp_path: Path, producer: str = "00119144030008867405X210002") -> Path:
|
||||
def test_remove_strips_raw_json_exif_form(self, tmp_path: Path):
|
||||
"""Regression: the TC260 AIGC ``{"AIGC":{...}}`` block Doubao embeds in EXIF
|
||||
UserComment must be scrubbed on removal. Before the fix it survived because
|
||||
``_scrub_ai_exif`` only touched Software/Make/Artist/ImageDescription in the
|
||||
0th IFD, never UserComment in the Exif sub-IFD."""
|
||||
from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata
|
||||
|
||||
out = tmp_path / "clean.jpg"
|
||||
remove_ai_metadata(self._aigc_exif_jpeg(tmp_path), out)
|
||||
assert aigc_label(out) is None
|
||||
assert not has_ai_metadata(out)
|
||||
|
||||
def _aigc_bare_jpeg(
|
||||
self, tmp_path: Path, producer: str = "00119144030008867405X210002", marker: bytes = b"\xff\xe9"
|
||||
) -> Path:
|
||||
"""Some China-served generators glue the TC260 label straight to its JSON
|
||||
as a bare ``AIGC{...}`` blob inside a JPEG APP segment (no ``"AIGC":``
|
||||
key wrapper, no PNG chunk, no namespaced XMP) -- seen near the JFIF
|
||||
header on real 2026-06 downloads."""
|
||||
header on real 2026-06 downloads. ``marker`` selects the APP segment
|
||||
(default APP9; the real corpus also uses APP11)."""
|
||||
p = tmp_path / "aigc_bare.jpg"
|
||||
Image.new("RGB", (32, 32)).save(p)
|
||||
raw = p.read_bytes()
|
||||
blob = b'AIGC{"Label":"1","ContentProducer":"' + producer.encode() + b'","ProduceID":"8F995586"}'
|
||||
segment = b"\xff\xe9" + (len(blob) + 2).to_bytes(2, "big") + blob # APP9
|
||||
segment = marker + (len(blob) + 2).to_bytes(2, "big") + blob
|
||||
p.write_bytes(raw[:2] + segment + raw[2:]) # splice after SOI
|
||||
return p
|
||||
|
||||
@@ -944,6 +1125,50 @@ class TestAIGCLabel:
|
||||
def test_has_ai_metadata_detects_bare_aigc_jpeg_form(self, tmp_path: Path):
|
||||
assert has_ai_metadata(self._aigc_bare_jpeg(tmp_path))
|
||||
|
||||
def test_remove_strips_bare_aigc_jpeg_form(self, tmp_path: Path):
|
||||
"""Regression: a bare ``AIGC{...}`` blob in a non-standard JPEG APP segment
|
||||
(APP9 here) is detected by aigc_label, so removal must drop that segment too.
|
||||
Before the fix ``_jpeg_app_carries_ai`` only inspected APP11/APP1-XMP/APP13, so
|
||||
the blob survived the lossless strip (detection<->removal parity break)."""
|
||||
from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata
|
||||
|
||||
out = tmp_path / "clean.jpg"
|
||||
remove_ai_metadata(self._aigc_bare_jpeg(tmp_path), out)
|
||||
assert aigc_label(out) is None
|
||||
assert not has_ai_metadata(out)
|
||||
|
||||
def test_remove_strips_bare_aigc_in_app11(self, tmp_path: Path):
|
||||
"""Regression (real corpus, 19/27 survivors): the bare ``AIGC{...}`` blob lives
|
||||
in APP11 (0xEB) on many China gens. That marker's branch in _jpeg_app_carries_ai
|
||||
only checked for a C2PA/JUMBF manifest and RETURNED, so the AIGC blob slipped past
|
||||
the generic check -> survived the strip. The specific checks must fall through to
|
||||
the generic AIGC check."""
|
||||
from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata
|
||||
|
||||
src = self._aigc_bare_jpeg(tmp_path, marker=b"\xff\xeb") # APP11
|
||||
assert aigc_label(src) is not None
|
||||
out = tmp_path / "clean.jpg"
|
||||
remove_ai_metadata(src, out)
|
||||
assert aigc_label(out) is None
|
||||
assert not has_ai_metadata(out)
|
||||
|
||||
def test_remove_strips_aigc_in_png_text_chunk(self, tmp_path: Path):
|
||||
"""Regression (real corpus, 2 survivors): the TC260 ``{"AIGC":{...}}`` block in a
|
||||
STANDARD PNG text chunk (Description) -- _is_ai_key keeps that key, so removal
|
||||
must also drop it on the VALUE carrying an AIGC block."""
|
||||
from PIL.PngImagePlugin import PngInfo
|
||||
|
||||
from remove_ai_watermarks.metadata import aigc_label, remove_ai_metadata
|
||||
|
||||
p = tmp_path / "aigc_desc.png"
|
||||
info = PngInfo()
|
||||
info.add_text("Description", '{"AIGC":{"Label":"1","ContentProducer":"00119144030008867405X210002"}}')
|
||||
Image.new("RGB", (32, 32)).save(p, pnginfo=info)
|
||||
assert aigc_label(p) is not None
|
||||
out = tmp_path / "clean.png"
|
||||
remove_ai_metadata(p, out)
|
||||
assert aigc_label(out) is None
|
||||
|
||||
def test_bare_aigc_without_tc260_field_ignored(self, tmp_path: Path):
|
||||
"""A bare ``AIGC{...}`` blob with no TC260 field must not false-positive."""
|
||||
from remove_ai_watermarks.metadata import aigc_label
|
||||
@@ -1192,6 +1417,27 @@ class TestLateProvenanceBox:
|
||||
p.write_bytes(b"\x89PNG\r\n\x1a\n not an isobmff file")
|
||||
assert scan_c2pa_region(p) == b""
|
||||
|
||||
def test_scan_c2pa_region_reads_largesize_uuid(self, tmp_path: Path):
|
||||
"""A 64-bit largesize (size32 == 1) uuid box must be walked and collected."""
|
||||
import struct
|
||||
|
||||
from remove_ai_watermarks.noai.isobmff import scan_c2pa_region
|
||||
|
||||
payload = b"LARGESIZE-C2PA-MANIFEST"
|
||||
total = 16 + len(payload) # 4 (size32=1) + 4 (type) + 8 (largesize) + payload
|
||||
uuid_box = struct.pack(">I", 1) + b"uuid" + struct.pack(">Q", total) + payload
|
||||
p = tmp_path / "large.mp4"
|
||||
p.write_bytes(_MP4_FTYP + uuid_box)
|
||||
assert payload in scan_c2pa_region(p)
|
||||
|
||||
def test_scan_c2pa_region_caps_at_max_total(self, tmp_path: Path):
|
||||
"""The collected payload is bounded by ``max_total`` (never unbounded)."""
|
||||
from remove_ai_watermarks.noai.isobmff import scan_c2pa_region
|
||||
|
||||
p = tmp_path / "big.mp4"
|
||||
p.write_bytes(_MP4_FTYP + _box(b"uuid", b"A" * 5000))
|
||||
assert len(scan_c2pa_region(p, max_total=1000)) <= 1000
|
||||
|
||||
def test_front_placed_manifest_still_detected(self, tmp_path: Path):
|
||||
# Regression: a faststart MP4 (manifest before mdat) is unaffected.
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
|
||||
@@ -321,6 +321,18 @@ class TestC2PADigitalSourceType:
|
||||
assert "compositeWithTrainedAlgorithmicMedia" in info["source_type"]
|
||||
assert "synthid_watermark" in info # AI-enhanced + OpenAI issuer
|
||||
|
||||
def test_composite_and_bare_algorithmic_cooccur_is_ai(self):
|
||||
"""Regression: a manifest carrying BOTH ``compositeWithTrainedAlgorithmicMedia``
|
||||
(AI-enhanced) and a bare procedural ``algorithmicMedia`` token must classify as
|
||||
AI-enhanced. Before the reorder the bare-token elif fired first and returned
|
||||
non-AI, dropping the composite AI signal (a false negative)."""
|
||||
from remove_ai_watermarks.noai.c2pa import _populate_registry_fields
|
||||
|
||||
info: dict = {}
|
||||
_populate_registry_fields(b"x compositeWithTrainedAlgorithmicMedia x algorithmicMedia x", info)
|
||||
assert info.get("ai_source_kind") == "enhanced"
|
||||
assert "compositeWithTrainedAlgorithmicMedia" in info["source_type"]
|
||||
|
||||
|
||||
# ── ISOBMFF (AVIF / HEIF / JPEG-XL container stripping) ──────────────
|
||||
|
||||
@@ -400,6 +412,33 @@ class TestISOBMFF:
|
||||
assert ifd[piexif.ImageIFD.Software].strip() == b""
|
||||
assert ifd[piexif.ImageIFD.Make] == b"Canon"
|
||||
|
||||
def test_blank_aigc_block_in_exif(self):
|
||||
"""Parity with the JPEG path: the China TC260 ``{"AIGC":{...}}`` block in EXIF
|
||||
ImageDescription must be blanked on the ISOBMFF path too -- ``blank_ai_exif_tokens``
|
||||
is the ONLY EXIF scrubber for HEIC/AVIF (``_scrub_ai_exif`` never runs there)."""
|
||||
import piexif
|
||||
|
||||
aigc = b'{"AIGC":{"Label":"1","ContentProducer":"00119144030008867405X210002","ProduceID":"abc"}}'
|
||||
data = self._avif_with_exif({piexif.ImageIFD.ImageDescription: aigc, piexif.ImageIFD.Make: b"Canon"})
|
||||
out, blanked = blank_ai_exif_tokens(data)
|
||||
assert blanked >= 1
|
||||
assert len(out) == len(data) # same length -> box sizes / iloc stay valid
|
||||
assert b'"AIGC"' not in out # TC260 block destroyed
|
||||
assert b"Canon" in out # camera tag preserved
|
||||
|
||||
def test_blank_xai_signature_pair_in_exif(self):
|
||||
"""Parity: the xAI/Grok ``Signature:`` blob + UUID ``Artist`` pair in EXIF is
|
||||
dropped together on the ISOBMFF path too."""
|
||||
import piexif
|
||||
|
||||
sig = b"Signature: " + b"A" * 80
|
||||
art = b"12345678-1234-1234-1234-123456789012"
|
||||
data = self._avif_with_exif({piexif.ImageIFD.ImageDescription: sig, piexif.ImageIFD.Artist: art})
|
||||
out, blanked = blank_ai_exif_tokens(data)
|
||||
assert blanked == 2 # both the signature and the UUID artist
|
||||
assert len(out) == len(data)
|
||||
assert b"Signature: AAAA" not in out
|
||||
|
||||
def test_blank_leaves_clean_exif_untouched(self):
|
||||
import piexif
|
||||
|
||||
@@ -414,6 +453,105 @@ class TestISOBMFF:
|
||||
assert out == FTYP + b"\x00\x00\x00\x0cmdat" + b"pixels!!"
|
||||
|
||||
|
||||
class TestIterTopLevelBoxes:
|
||||
"""The box walker's three size encodings and its underflow/overflow guards."""
|
||||
|
||||
def test_64bit_largesize(self):
|
||||
from remove_ai_watermarks.noai.isobmff import _iter_top_level_boxes
|
||||
|
||||
# size32 == 1 -> a 64-bit largesize follows the type; total box length = 24.
|
||||
box = struct.pack(">I", 1) + b"uuid" + struct.pack(">Q", 24) + b"payload!"
|
||||
boxes = list(_iter_top_level_boxes(box))
|
||||
assert len(boxes) == 1
|
||||
start, end, btype, payload_off = boxes[0]
|
||||
assert (start, end, btype, payload_off) == (0, 24, b"uuid", 16)
|
||||
|
||||
def test_size0_runs_to_eof(self):
|
||||
from remove_ai_watermarks.noai.isobmff import _iter_top_level_boxes
|
||||
|
||||
box = struct.pack(">I", 0) + b"mdat" + b"tail-to-eof"
|
||||
boxes = list(_iter_top_level_boxes(box))
|
||||
assert len(boxes) == 1
|
||||
start, end, btype, payload_off = boxes[0]
|
||||
assert (start, end, btype, payload_off) == (0, len(box), b"mdat", 8)
|
||||
|
||||
def test_underflow_size_stops_safely(self):
|
||||
from remove_ai_watermarks.noai.isobmff import _iter_top_level_boxes
|
||||
|
||||
# size (4) < the 8-byte header -> the guard returns without yielding a box.
|
||||
assert list(_iter_top_level_boxes(struct.pack(">I", 4) + b"ftyp" + b"more")) == []
|
||||
|
||||
def test_overflow_size_stops_safely(self):
|
||||
from remove_ai_watermarks.noai.isobmff import _iter_top_level_boxes
|
||||
|
||||
# size claims 999 but the buffer is far shorter -> guard returns, no partial box.
|
||||
assert list(_iter_top_level_boxes(struct.pack(">I", 999) + b"uuid" + b"x")) == []
|
||||
|
||||
|
||||
class TestBlankAiXmpPackets:
|
||||
"""XMP-packet blanking: same-length overwrite only for AI-marked packets, and only
|
||||
when the packet is fully delimited."""
|
||||
|
||||
AIMARK = b"trainedAlgorithmicMedia"
|
||||
|
||||
def test_ai_packet_blanked_same_length(self):
|
||||
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
|
||||
|
||||
packet = b'<?xpacket begin="x"?><x:xmpmeta>' + self.AIMARK + b'</x:xmpmeta><?xpacket end="w"?>'
|
||||
data = b"boxhdr" + packet + b"tail"
|
||||
out, n = blank_ai_xmp_packets(data)
|
||||
assert n == 1
|
||||
assert len(out) == len(data) # same length -> iloc offsets stay valid
|
||||
assert self.AIMARK not in out
|
||||
assert b"boxhdr" in out
|
||||
assert b"tail" in out
|
||||
|
||||
def test_clean_packet_left_intact(self):
|
||||
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
|
||||
|
||||
packet = b'<?xpacket begin="x"?><x:xmpmeta>plain copyright</x:xmpmeta><?xpacket end="w"?>'
|
||||
out, n = blank_ai_xmp_packets(packet)
|
||||
assert n == 0
|
||||
assert out == packet
|
||||
|
||||
def test_missing_end_delimiter_not_blanked(self):
|
||||
from remove_ai_watermarks.noai.isobmff import blank_ai_xmp_packets
|
||||
|
||||
# No <?xpacket end?> -> the packet regex cannot match, so it is left unchanged.
|
||||
data = b'<?xpacket begin="x"?><x:xmpmeta>' + self.AIMARK + b"</x:xmpmeta>"
|
||||
out, n = blank_ai_xmp_packets(data)
|
||||
assert n == 0
|
||||
assert out == data
|
||||
|
||||
|
||||
class TestC2paBufferScans:
|
||||
"""The shared buffer-scan helpers (used by both the PNG caBX parser and the
|
||||
format-agnostic binary scan). Data-driven off the registries so they stay valid
|
||||
as vendors are added."""
|
||||
|
||||
def test_soft_binding_vendors_in(self):
|
||||
from remove_ai_watermarks.noai.c2pa import C2PA_SOFT_BINDINGS, soft_binding_vendors_in
|
||||
|
||||
sig, name = next(iter(C2PA_SOFT_BINDINGS.items()))
|
||||
assert name in soft_binding_vendors_in(b"...manifest..." + sig + b"...tail...")
|
||||
assert soft_binding_vendors_in(b"") == []
|
||||
assert soft_binding_vendors_in(b"no soft-binding assertion here") == []
|
||||
|
||||
def test_synthid_vendors_in_requires_synthid_issuer(self):
|
||||
from remove_ai_watermarks.noai.c2pa import C2PA_ISSUERS, SYNTHID_C2PA_ISSUERS, synthid_vendors_in
|
||||
|
||||
syn_sig = next(s for s in C2PA_ISSUERS if s in SYNTHID_C2PA_ISSUERS)
|
||||
non_sig = next(s for s in C2PA_ISSUERS if s not in SYNTHID_C2PA_ISSUERS)
|
||||
assert C2PA_ISSUERS[syn_sig] in synthid_vendors_in(b"x" + syn_sig + b"x")
|
||||
# an issuer that does NOT pair SynthID with C2PA must not be reported as one
|
||||
assert C2PA_ISSUERS[non_sig] not in synthid_vendors_in(b"x" + non_sig + b"x")
|
||||
|
||||
def test_synthid_verdict_format(self):
|
||||
from remove_ai_watermarks.noai.c2pa import synthid_verdict
|
||||
|
||||
assert synthid_verdict("Google LLC") == "likely present (Google LLC embeds SynthID with C2PA)"
|
||||
|
||||
|
||||
class TestC2PAInvalidSignature:
|
||||
"""A .png file that is not actually PNG-signed must read as clean, not crash."""
|
||||
|
||||
|
||||
@@ -178,6 +178,54 @@ class TestFp16WeightVariant:
|
||||
assert "variant" not in cls.from_pretrained.call_args.kwargs
|
||||
|
||||
|
||||
class TestNoReembeddedWatermark:
|
||||
"""F2 regression: the SDXL removal pipelines must disable the diffusers default
|
||||
invisible watermarker (``add_watermarker=False``).
|
||||
|
||||
diffusers stamps an open "Stable Diffusion XL" DWT-DCT watermark onto every SDXL
|
||||
output whenever ``invisible-watermark`` is installed. A watermark REMOVER that left
|
||||
it on would replace one detectable AI watermark (SynthID) with another -- the cleaned
|
||||
output re-reads as AI. The ControlNet sub-model load must NOT receive the kwarg (it
|
||||
is not a pipeline and does not accept it).
|
||||
"""
|
||||
|
||||
def _remover(self, profile: str):
|
||||
if not is_watermark_removal_available():
|
||||
pytest.skip("torch/diffusers not installed")
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
|
||||
|
||||
return WatermarkRemover(device="cpu", pipeline=profile)
|
||||
|
||||
def _capture(self, monkeypatch, remover):
|
||||
from remove_ai_watermarks.noai.watermark_remover import WatermarkRemover
|
||||
|
||||
calls: list[tuple[str, dict]] = []
|
||||
|
||||
def fake_load(self, cls, model_id, **kwargs):
|
||||
calls.append((getattr(cls, "__name__", str(cls)), kwargs))
|
||||
return MagicMock()
|
||||
|
||||
monkeypatch.setattr(WatermarkRemover, "_load_from_pretrained", fake_load)
|
||||
monkeypatch.setattr(WatermarkRemover, "_move_to_device_and_optimize", lambda self, p: p)
|
||||
return calls
|
||||
|
||||
def test_sdxl_pipeline_disables_watermarker(self, monkeypatch: pytest.MonkeyPatch):
|
||||
remover = self._remover("sdxl")
|
||||
calls = self._capture(monkeypatch, remover)
|
||||
remover._load_pipeline()
|
||||
assert any(kw.get("add_watermarker") is False for _, kw in calls), calls
|
||||
|
||||
def test_controlnet_pipeline_disables_watermarker(self, monkeypatch: pytest.MonkeyPatch):
|
||||
remover = self._remover("controlnet")
|
||||
calls = self._capture(monkeypatch, remover)
|
||||
remover._load_controlnet_pipeline()
|
||||
by_cls = dict(calls)
|
||||
# the SDXL pipeline load disables the watermarker...
|
||||
assert by_cls["StableDiffusionXLControlNetImg2ImgPipeline"].get("add_watermarker") is False
|
||||
# ...but the ControlNet sub-model load must not carry the kwarg (it would error).
|
||||
assert "add_watermarker" not in by_cls["ControlNetModel"]
|
||||
|
||||
|
||||
class _StubImage:
|
||||
"""Minimal PIL.Image stand-in: just the ``width``/``height`` the pure helper reads."""
|
||||
|
||||
|
||||
@@ -8,6 +8,40 @@ import pytest
|
||||
from remove_ai_watermarks.region_eraser import boxes_to_mask, erase, lama_available, migan_available
|
||||
|
||||
|
||||
class TestPaddedCropBox:
|
||||
"""The padded bounding box that bounds the learned backends' ONNX working set."""
|
||||
|
||||
def test_empty_mask_returns_none(self):
|
||||
from remove_ai_watermarks.region_eraser import _padded_crop_box
|
||||
|
||||
assert _padded_crop_box(np.zeros((100, 100), np.uint8), 100, 100, pad_frac=0.1, pad_min=8) is None
|
||||
|
||||
def test_pad_min_dominates_and_clamps_at_border(self):
|
||||
from remove_ai_watermarks.region_eraser import _padded_crop_box
|
||||
|
||||
mask = np.zeros((100, 100), np.uint8)
|
||||
mask[0:5, 0:5] = 255 # 5-px mark in the top-left corner
|
||||
# pad = max(8, int(0.1*5)) = 8; x0 clamps to 0 (not -8), x1 = min(100, 4+1+8) = 13.
|
||||
assert _padded_crop_box(mask, 100, 100, pad_frac=0.1, pad_min=8) == (0, 0, 13, 13)
|
||||
|
||||
def test_pad_frac_dominates_for_large_mark(self):
|
||||
from remove_ai_watermarks.region_eraser import _padded_crop_box
|
||||
|
||||
mask = np.zeros((400, 400), np.uint8)
|
||||
mask[100:300, 100:300] = 255 # 200-px span
|
||||
# pad = max(8, int(0.2*200)) = 40; box = (100-40, .., 299+1+40, ..).
|
||||
assert _padded_crop_box(mask, 400, 400, pad_frac=0.2, pad_min=8) == (60, 60, 340, 340)
|
||||
|
||||
def test_clamps_at_far_border(self):
|
||||
from remove_ai_watermarks.region_eraser import _padded_crop_box
|
||||
|
||||
mask = np.zeros((50, 60), np.uint8)
|
||||
mask[45:50, 55:60] = 255 # bottom-right corner
|
||||
_x0, _y0, x1, y1 = _padded_crop_box(mask, 50, 60, pad_frac=0.1, pad_min=8)
|
||||
assert x1 == 60 # clamped to w, no overflow
|
||||
assert y1 == 50 # clamped to h, no overflow
|
||||
|
||||
|
||||
class TestBoxesToMask:
|
||||
def test_mask_set_inside_box(self):
|
||||
mask = boxes_to_mask((100, 100), [(10, 20, 30, 40)], dilate=0)
|
||||
@@ -171,6 +205,7 @@ class TestMiganWrapper:
|
||||
|
||||
def run(self, _outputs, feeds):
|
||||
self.outer.captured["mask"] = feeds["mask"]
|
||||
self.outer.captured["image_shape"] = feeds["image"].shape
|
||||
img = feeds["image"] # (1,3,H,W) RGB
|
||||
red = np.zeros_like(img)
|
||||
red[:, 0] = 255 # pure red in RGB
|
||||
@@ -191,6 +226,21 @@ class TestMiganWrapper:
|
||||
assert m[50, 50] == 0
|
||||
assert m[10, 10] == 255
|
||||
|
||||
@pytest.mark.usefixtures("_fake_migan")
|
||||
def test_crops_around_mask_so_onnx_input_is_bounded(self):
|
||||
# Large frame, small corner mark: the tensor fed to MI-GAN is the padded
|
||||
# CROP (pad = max(256, 2*bbox)), not the full image -- this is what holds the
|
||||
# ONNX working set roughly constant on big uploads instead of scaling with
|
||||
# the image (the memory fix). Untouched pixels stay exact; the mark is filled.
|
||||
img = np.full((2000, 3000, 3), 120, np.uint8)
|
||||
out = erase(img, boxes=[(2900, 1900, 60, 60)], backend="migan", dilate=0)
|
||||
assert out.shape == img.shape
|
||||
_, _, fh, fw = self.captured["image_shape"]
|
||||
assert fh < 700 # crop height, not the 2000px frame
|
||||
assert fw < 700 # crop width, not the 3000px frame
|
||||
assert tuple(int(v) for v in out[1930, 2930]) == (0, 0, 255) # mark -> red fill
|
||||
assert np.array_equal(out[:100, :100], img[:100, :100]) # far corner untouched
|
||||
|
||||
@pytest.mark.usefixtures("_fake_migan")
|
||||
def test_grayscale_2d_does_not_raise(self):
|
||||
gray = np.full((100, 100), 120, np.uint8)
|
||||
|
||||
@@ -49,6 +49,28 @@ class TestScan:
|
||||
dets = reg.detect_marks(np.zeros((256, 256, 3), np.uint8), include_explicit=False)
|
||||
assert not any(d.detected for d in dets)
|
||||
|
||||
@pytest.mark.parametrize("shape", [(1, 1, 3), (8, 8, 3), (15, 15, 3), (12, 300, 3), (300, 10, 3)])
|
||||
def test_tiny_image_no_crash(self, shape):
|
||||
"""Regression: an image whose short side is < 16 px (below the Gemini template
|
||||
floor) must yield no detection, not crash. detect_marks/remove_auto_marks are
|
||||
the public visible/all/batch path; a tiny thumbnail in a batch used to take the
|
||||
whole auto pass down with an IndexError (empty candidate list dereference)."""
|
||||
img = np.full(shape, 100, np.uint8)
|
||||
assert not any(d.detected for d in reg.detect_marks(img, include_explicit=False))
|
||||
result, removed = reg.remove_auto_marks(img, backend="cv2")
|
||||
assert removed == []
|
||||
assert result.shape == img.shape
|
||||
|
||||
@pytest.mark.parametrize("shape", [(0, 5), (5, 0), (0, 5, 4), (0, 0)])
|
||||
def test_forced_remove_on_empty_array_no_crash(self, shape):
|
||||
"""Regression: footprint_mask ran to_bgr (cvtColor) before any size check, so a
|
||||
forced remove on a zero-size ndarray crashed (cv2.error on an empty Mat). detect
|
||||
already guarded this; footprint_mask must too. Covers the text + gemini engines."""
|
||||
empty = np.zeros(shape, np.uint8)
|
||||
for key in ("doubao", "jimeng", "samsung", "gemini"):
|
||||
_result, mask = reg.get_mark(key).remove(empty, force=True)
|
||||
assert mask is None
|
||||
|
||||
|
||||
class TestBackendResolution:
|
||||
def test_auto_resolves_to_available_backend(self):
|
||||
|
||||
@@ -14,7 +14,9 @@ class ImageIFD:
|
||||
Artist: int
|
||||
ImageDescription: int
|
||||
|
||||
class ExifIFD: ...
|
||||
class ExifIFD:
|
||||
UserComment: int
|
||||
|
||||
class GPSIFD: ...
|
||||
|
||||
def load(input_data: bytes | str, key_is_name: bool = ...) -> dict[str, Any]: ...
|
||||
|
||||
Reference in new issue
Block a user