Compare commits

...
8 Commits
Author SHA1 Message Date
Victor KuznetsovandClaude Opus 4.8 71dae8fe25 chore(release): bump version to 0.15.3
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:17:01 +03:00
Victor KuznetsovandClaude Opus 4.8 9fe12c3997 docs: note content-based strip routing + SDXL watermarker fixes
Document both fixes in CLAUDE.md (the metadata.py and watermark_remover.py
bullets). Also add a --backend flag to the visible-removal audit script so a
realistic quality pass can run the production MI-GAN fill instead of cv2
(removal SUCCESS is backend-independent, but only migan/lama reflect the
recovered-region quality a user actually gets).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:16:56 +03:00
Victor KuznetsovandClaude Opus 4.8 183793fbf2 fix(invisible): disable diffusers SDXL watermarker on removal output
The SDXL removal pipelines (sdxl + controlnet) were built without
add_watermarker=False, so diffusers embedded its default open "Stable
Diffusion XL" DWT-DCT invisible watermark on every output whenever
invisible-watermark is installed (the detect extra). A watermark REMOVER was
therefore replacing one detectable AI watermark (SynthID) with another: the
cleaned output re-read as AI (identify -> "Open invisible watermark: Stable
Diffusion XL"), observed on the SynthID validation sample.

Both SDXL loaders now call a shared _disable_sdxl_watermarker helper (mirrors
_maybe_add_fp16_vae; the ControlNetModel sub-model and the Qwen loader never
call it, since only the pipeline accepts the kwarg). Verified end to end: the
affected outputs re-run clean (is_ai=None, no open watermark).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:16:49 +03:00
Victor KuznetsovandClaude Opus 4.8 7f22852593 fix(metadata): route strip output format by content, not extension
remove_ai_metadata chose its save format (and the lossless-JPEG fast path)
from the OUTPUT file extension. On the ~2% of real uploads whose extension
lies about their content (a PNG served as .jpg is the common case, ~0.9% of
the corpus), the default flow -- which inherits the source's own extension --
re-encoded a lossless PNG/WebP into a real JPEG, silently degrading the pixels
and breaking the "work with originals" invariant.

Sniff the actual container from magic bytes (_sniff_image_format, reusing the
12-byte head already read for the ISOBMFF check) and route on content: a
misnamed lossless source (source-extension format != content) is preserved in
its true format, while a correctly-named source still honors a deliberate
output-extension conversion (source.png -> output.jpg). The JPEG-lossless gate
is likewise content-gated.

Found by a new metadata-removal parity audit over the local corpus
(scripts/metadata_removal_audit.py): 18170/18173 carriers strip cleanly, and
this fix takes the 208 pixel-integrity failures (all misnamed PNGs) to 0.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-14 17:16:36 +03:00
Victor Kuznetsov d9204e2e1f chore(release): bump version to 0.15.2 2026-07-13 12:27:06 +03:00
Victor KuznetsovandClaude Opus 4.8 2bdaa09e5f fix(metadata): strip bare AIGC in APP11 and AIGC in a standard PNG text chunk
Full-corpus strip audit surfaced 24 china_aigc survivors on real uploads:
- 19 JPEG carried the bare AIGC{...} blob in APP11 (0xEB). That marker's branch
  in _jpeg_app_carries_ai only tested for a C2PA/JUMBF manifest and RETURNED, so
  a bare AIGC there slipped past the generic AIGC check. The specific
  C2PA(APP11)/XMP(APP1)/IPTC(APP13) checks now fall through to the generic
  _is_aigc_exif_value drop, which runs for every APP marker they did not claim.
- PNG carried the {"AIGC":{...}} block in a STANDARD text chunk (Description).
  _is_ai_key keeps that key, so removal now also drops a text value carrying an
  AIGC block (_is_aigc_exif_value broadened to accept str; wired into the PNG
  re-save value filter), in parity with aigc_label's detection.

Verified on the corpus: decodable china_aigc survivors 27 -> 0; the 3 remaining
are truncated files the strip fail-safe (v0.15.1) copies through by design.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 12:27:05 +03:00
Victor Kuznetsov 72f6734b45 chore(release): bump version to 0.15.1 2026-07-13 11:54:53 +03:00
Victor KuznetsovandClaude Opus 4.8 220803c4d0 fix(metadata): remove_ai_metadata is fail-safe on a truncated/corrupt image
PIL raises OSError decoding a truncated file, which crashed remove_ai_metadata
(the PNG/WebP PIL re-save path) -- a direct library caller like a web worker
500s on a partial upload. ~0.2% of the real upload corpus is truncated. The
strip now probes decodability first and, on failure, copies the input through
unchanged and returns rather than raising (we cannot strip what we cannot parse),
mirroring strip_c2pa_boxes' fail-safe. identify already handled these.

The CLI `metadata --remove` on an unreadable file therefore now exits 0 with the
input passed through, not a clean error (exit 1) -- `visible`, which must decode
to remove a mark, still exits 1. Test updated to the per-command contract.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-13 11:54:33 +03:00
12 changed files with 540 additions and 52 deletions

No files matched your search

+2 -2
View File
@@ -54,7 +54,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal
- `noai/c2pa.py` — C2PA reading. `extract_c2pa_info(path)` uses the official **c2pa-python `Reader`** first (core dep, any container; `read_manifest_store_json` returns the WHOLE store JSON — active + ingredient manifests — so an AI marker on a parent manifest is seen), and falls back to the hand-rolled caBX/CBOR parser (`has_c2pa_metadata` / `extract_c2pa_chunk` / `_extract_c2pa_info_png`) for synthetic/partial blobs the validator rejects or a broken/absent wheel. The registry scan (issuer / source-type / SynthID / soft-binding) is shared by both paths via `_populate_registry_fields`, so the return-dict shape is identical. Do not reimplement chunk parsing; chunk reads are clamped to the remaining file size by design. `extract_c2pa_chunk`/`inject_c2pa_chunk` stay PNG-only (raw caBX bytes, test/extractor use).
- `noai/constants.py` — the single `C2PA_AI_VENDORS` registry (+ `C2PA_SOFT_BINDINGS`) from which `C2PA_ISSUERS` / `SYNTHID_C2PA_ISSUERS` / `C2PA_IDENTITY_AI_ORGS` / `identify._ISSUER_PLATFORM` are all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline. A vendor's `asserts_ai=True` flag means its mere presence asserts AI generation even without a `trainedAlgorithmicMedia` digital-source-type (a pure-generator brand with a distinctive issuer/generator string, e.g. **Dreamina** — ByteDance's international Jimeng brand, signed as "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and no source-type); NEVER set it for common-word issuers (Adobe/Google/OpenAI/Microsoft) that appear incidentally in unrelated bytes — those stay source-type-gated in `identify._attribute_platform`.
- `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`). **`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 a NON-standard APP segment (near JFIF on some China gens) is dropped via `_is_aigc_exif_value`, not only the APP11/APP1/APP13 cases; (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). Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_jpeg_metadata_strip_is_pixel_lossless, test_jpeg_strip_removes_iptc_marker_in_xmp}`, `TestSamsungGenai::{test_remove_strips_post_eoi_trailer, test_detects_trailer_past_scan_window}`, the AIGC-EXIF/bare-APP removal tests, and `tests/test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`. `exif_generator` matches a VALUE against `AI_GENERATOR_TOKENS` across EXIF `Software`/`Make`/`Artist`/`ImageDescription`, XMP `CreatorTool`, AND PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI stamps there, not EXIF). **Detection and removal must stay in parity:** a generator that stamps an AI-shaped VALUE under a non-AI KEY (NovelAI's `Title`/`Source`) is dropped on removal by `_is_ai_value` (value-token match, mirrors `exif_generator`), NOT by `_is_ai_key` alone — else the cleaned file still reads as that generator. Add a new no-C2PA generator = one `AI_GENERATOR_TOKENS` entry (use a distinctive token, e.g. `reve.com` not bare `reve`); detection and removal then both follow. Regression: `tests/test_metadata.py::TestExifGenerator::{test_novelai_png_text_chunk_detected,test_novelai_removal_parity}`.
- `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`). **`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.
@@ -64,7 +64,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal
- `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.
+1 -1
View File
@@ -53,7 +53,7 @@ module.
**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}`.
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.15.0"
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"
+256
View File
@@ -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()
+14 -2
View File
@@ -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)
+1 -1
View File
@@ -25,7 +25,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.15.0"
__version__ = "0.15.3"
__all__ = ["__version__", "remove_visible", "visible_provenance"]
+97 -34
View File
@@ -751,8 +751,11 @@ def _is_aigc_exif_value(raw: object) -> bool:
``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.
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:
@@ -951,25 +954,37 @@ 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 (
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) # digitalSourceType in XMP, not only APP13
or any(m in payload for m in IPTC_AI_FIELD_MARKERS) # IPTC 2025.1 AI-disclosure fields
or any(m in payload for m in IPTC_AI_MARKERS)
or any(m in payload for m in IPTC_AI_FIELD_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)
# A bare / wrapped China TC260 AIGC block (``AIGC{...}`` or ``{"AIGC":{...}}``) that
# some China-served generators glue into a non-standard APP segment near the JFIF
# header. ``aigc_label`` detects it anywhere in the scan head, 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, and the AIGC-in-
# UserComment/ImageDescription placement is handled there, so it must not be dropped
# wholesale here.
if 0xE0 <= marker <= 0xEF and not (marker == 0xE1 and payload.startswith(b"Exif")):
):
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
@@ -1039,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,
@@ -1106,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
@@ -1113,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.
@@ -1135,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] = {}
@@ -1154,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):
@@ -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}")
+14 -7
View File
@@ -949,14 +949,21 @@ def test_visible_backend_runtime_error_exits_cleanly(runner, tmp_path, monkeypat
)
@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
truncated files) must produce a clean 'Error: cannot read/process' + exit 1, NOT a
raw PIL.UnidentifiedImageError / OSError / ValueError traceback. Found by the runtime
mode fuzz across metadata --remove and visible."""
"""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.exit_code == 1, result.output
assert isinstance(result.exception, SystemExit), f"leaked a raw traceback: {result.exception!r}"
assert "Error" in result.output
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
+90 -3
View File
@@ -179,6 +179,37 @@ 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
@@ -226,6 +257,27 @@ class TestHasAiMetadata:
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
@@ -1046,16 +1098,19 @@ class TestAIGCLabel:
assert aigc_label(out) is None
assert not has_ai_metadata(out)
def _aigc_bare_jpeg(self, tmp_path: Path, producer: str = "00119144030008867405X210002") -> Path:
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
@@ -1082,6 +1137,38 @@ class TestAIGCLabel:
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
+48
View File
@@ -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."""
Generated
+1 -1
View File
@@ -2750,7 +2750,7 @@ wheels = [
[[package]]
name = "remove-ai-watermarks"
version = "0.15.0"
version = "0.15.3"
source = { editable = "." }
dependencies = [
{ name = "c2pa-python" },