mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-10-16 06:20:48 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71dae8fe25 | ||
|
|
9fe12c3997 | ||
|
|
183793fbf2 | ||
|
|
7f22852593 | ||
|
|
d9204e2e1f | ||
|
|
2bdaa09e5f | ||
|
|
72f6734b45 | ||
|
|
220803c4d0 |
No files matched your search
@@ -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.
|
||||
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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.15.0"
|
||||
__version__ = "0.15.3"
|
||||
|
||||
__all__ = ["__version__", "remove_visible", "visible_provenance"]
|
||||
|
||||
|
||||
@@ -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
@@ -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
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
|
||||
Reference in new issue
Block a user