diff --git a/CLAUDE.md b/CLAUDE.md index 4223a78..197c726 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -57,7 +57,7 @@ Compact map. The full per-module detail (design decisions, tuned thresholds, cal - `noai/c2pa.py` — C2PA reading. `extract_c2pa_info(path)` uses the official **c2pa-python `Reader`** first (core dep, any container; `read_manifest_store_json` returns the WHOLE store JSON — active + ingredient manifests — so an AI marker on a parent manifest is seen), and falls back to the hand-rolled caBX/CBOR parser (`has_c2pa_metadata` / `extract_c2pa_chunk` / `_extract_c2pa_info_png`) for synthetic/partial blobs the validator rejects or a broken/absent wheel. The registry scan (issuer / source-type / SynthID / soft-binding) is shared by both paths via `_populate_registry_fields`, so the return-dict shape is identical. Do not reimplement chunk parsing; chunk reads are clamped to the remaining file size by design. `extract_c2pa_chunk`/`inject_c2pa_chunk` stay PNG-only (raw caBX bytes, test/extractor use). - `noai/constants.py` — the single `C2PA_AI_VENDORS` registry (+ `C2PA_SOFT_BINDINGS`) from which `C2PA_ISSUERS` / `SYNTHID_C2PA_ISSUERS` / `C2PA_IDENTITY_AI_ORGS` / `identify._ISSUER_PLATFORM` are all derived. Add a new vendor as one registry entry; never edit the derived dicts and never add inline. A vendor's `asserts_ai=True` flag means its mere presence asserts AI generation even without a `trainedAlgorithmicMedia` digital-source-type (a pure-generator brand with a distinctive issuer/generator string, e.g. **Dreamina** — ByteDance's international Jimeng brand, signed as "Bytedance Pte. Ltd." with a "Dreamina/x.y" claim generator and no source-type); NEVER set it for common-word issuers (Adobe/Google/OpenAI/Microsoft) that appear incidentally in unrelated bytes — those stay source-type-gated in `identify._attribute_platform`. - `metadata.py` — `scan_head(path)` is the shared (memoized) input for every C2PA/AIGC/IPTC byte scan; use it instead of `open().read(1MB)` for any new marker scan. Also home to `synthid_source`, `xai_signature`, `iptc_ai_system`, `aigc_label`, `huggingface_job`, `samsung_genai`, and `remove_ai_metadata` (fail-safe `strip_c2pa_boxes`). **A caller that REPORTS an outcome must use `strip_and_verify`, not `remove_ai_metadata` directly** -- the stripper is deliberately fail-safe (a file PIL cannot decode is copied through UNCHANGED rather than crashing), so its return value cannot distinguish a no-op from a real strip. `strip_and_verify` re-scans the output and, when metadata survived but `image_io` can still decode the raster, normalizes the container and scans again; that recovery preserves pixels but drops standard metadata. A truly undecodable file keeps the surviving-marker result. `metadata --remove` and `batch --mode metadata|all` use this verified path. **`remove_ai_metadata` is the SINGLE metadata stripper** (the legacy PIL-re-encoding `noai/cleaner` was deleted; the diffusion core and the public `noai.remove_ai_metadata` re-export now point here). It strips **losslessly** per container: ISOBMFF (HEIC/AVIF/MP4) blanks tokens / strips boxes in place; **JPEG uses `_strip_jpeg_metadata_lossless`** — a marker-segment walk that drops the AI-bearing APP segments (C2PA APP11; XMP APP1 carrying C2PA, a China-AIGC token, OR an IPTC `digitalSourceType` / 2025.1 AI-disclosure marker; IPTC-IIM APP13) and scrubs AI EXIF tags via piexif, copying the entropy-coded scan verbatim so **the pixels are bit-identical** (no DCT re-encode). **Detection<->removal parity across every marker placement is load-bearing** — anything a scanner flags, the strip must reach, or a re-served file still reads as AI: (a) the APP1-XMP branch of `_jpeg_app_carries_ai` checks the IPTC marker sets too, not only C2PA/AIGC (the Instagram/MidJourney/Meta "Made with AI" `digitalSourceType` lives in XMP, not the APP13 IIM record); (b) a bare `AIGC{...}` / `{"AIGC":{...}}` block in ANY JPEG APP segment — the specific C2PA(APP11)/XMP(APP1)/IPTC(APP13) checks FALL THROUGH to a generic `_is_aigc_exif_value` drop, so a bare AIGC in APP11 (the supported placement, NOT a C2PA manifest) is caught, not swallowed by the C2PA-only 0xEB branch — plus the same AIGC block in a STANDARD **PNG text chunk** value (e.g. `Description`, which `_is_ai_key` keeps) is dropped on the value; (c) the China TC260 `{"AIGC":{...}}` block in EXIF `UserComment`/`ImageDescription` is scrubbed by `_scrub_ai_exif` (Doubao producer + Tencent service-provider schemas); (d) the Samsung Galaxy AI `PhotoEditor_Re_Edit_Data` trailer past the JPEG EOI is truncated by `_strip_samsung_trailer` (and `samsung_genai` reads the file tail so a multi-MB photo's trailer past the 512 KB quick-scan window is still DETECTED). Pixels stay bit-identical throughout, so a `--strip-metadata` on a q100 removal output does NOT crush it back to q75; PNG/WebP re-saves are pixel-lossless (WebP written at cv2 lossless mode, quality 101 — quality 1-100 is lossy). **The PIL-fallback save format is chosen by the source's CONTENT, not its file extension** (`_sniff_image_format`, and the JPEG-lossless gate is content-gated too): inputs can be misnamed (a PNG served as `.jpg` is the common one), and routing on the extension re-encoded a lossless PNG/WebP into a real JPEG — a silent degradation that broke "work with originals". A **misnamed** lossless source (source-extension format != content) is preserved in its true format; a **correctly-named** source still honors a deliberate output-extension conversion (e.g. `source.png -> output.jpg`). Not yet handled: a 16-bit PNG is downconverted to 8-bit on the PIL re-save (rare; would need a byte-level PNG chunk stripper). Regression: `tests/test_metadata.py::TestHasAiMetadata::test_strip_preserves_lossless_content_with_mismatched_extension`. **`remove_ai_metadata` is fail-safe on an undecodable image:** a truncated/corrupt file (PIL raises `OSError` decoding it; some inputs) is copied through UNCHANGED rather than crashing a direct library caller (a web worker would 500 on a partial upload), mirroring `strip_c2pa_boxes` — we cannot strip what we cannot parse, but we never raise. Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_remove_ai_metadata_failsafe_on_truncated_png,test_strip_and_verify_normalizes_decodable_copy_through,test_strip_and_verify_reports_markers_in_undecodable_copy_through}`. Regression: `tests/test_metadata.py::TestHasAiMetadata::{test_jpeg_metadata_strip_is_pixel_lossless, test_jpeg_strip_removes_iptc_marker_in_xmp}`, `TestSamsungGenai::{test_remove_strips_post_eoi_trailer, test_detects_trailer_past_scan_window}`, the AIGC-EXIF/bare-APP removal tests, and `tests/test_noai.py::TestISOBMFF::{test_blank_aigc_block_in_exif, test_blank_xai_signature_pair_in_exif}`. `exif_generator` matches a VALUE against `AI_GENERATOR_TOKENS` across EXIF `Software`/`Make`/`Artist`/`ImageDescription`, XMP `CreatorTool`, AND PNG `tEXt` chunks (`Software`/`Source`/`Title`/`Description` — NovelAI stamps there, not EXIF). **Detection and removal must stay in parity:** a generator that stamps an AI-shaped VALUE under a non-AI KEY (NovelAI's `Title`/`Source`) is dropped on removal by `_is_ai_value` (value-token match, mirrors `exif_generator`), NOT by `_is_ai_key` alone — else the cleaned file still reads as that generator. Add a new no-C2PA generator = one `AI_GENERATOR_TOKENS` entry (use a distinctive token, e.g. `reve.com` not bare `reve`); detection and removal then both follow. Regression: `tests/test_metadata.py::TestExifGenerator::{test_novelai_png_text_chunk_detected,test_novelai_removal_parity}`. -- `identify.py` — aggregates every locally-readable signal into one `ProvenanceReport`; `is_ai_generated` is True or None, never asserted False. `ProvenanceReport.ai_source_kind` exposes the C2PA digital-source-type split — `"generated"` (trainedAlgorithmicMedia, fully AI) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region), else None — so a caller branches full-frame scrub vs region-targeted clean (see `noai/tiling.feather_region_composite` + `WatermarkRemover.remove_watermark(region=...)`). The sparkle provenance threshold is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (imported, not a private copy) so the provenance "is there a sparkle" verdict and the removal "take the sparkle" decision can never drift. `import identify` is deliberately light (lazy `noai/__init__`, fits a 512 MB host) — keep heavy imports out (the `watermark_registry` constant import stays light: engines are lazy there). Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM` only when verified against a real C2PA file; editing-app/AI-device signer tokens go to `_SIGNER_C2PA_PLATFORM`; generator/issuer platforms to `C2PA_AI_VENDORS` in `constants.py`. The IPTC `digitalSourceType` **`algorithmicMedia`** (bare) is PROCEDURAL (an algorithm not trained on sampled data), NOT AI/ML generation, so it is deliberately absent from `IPTC_AI_MARKERS` — flagging it made `identify` assert AI + `has_invisible_target` True, scrubbing clean procedural content (it is a distinct token from `trainedAlgorithmicMedia`, so real "Made with AI" labels are unaffected; regression `test_metadata.py::...test_bare_algorithmic_media_not_flagged_ai`). Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence). `_vendor_of` normalizes ByteDance/Canva/ElevenLabs/Black Forest Labs (as well as OpenAI/Google/... ) so their C2PA claims participate in the clash check; the generic **China TC260 AIGC label names no specific vendor**, so when a TC260-applying vendor (ByteDance, `_TC260_VENDORS`) is co-attributed the label is attributed to it (a legit Doubao image carrying its own TC260 label must NOT clash), while a NON-TC260 vendor next to a TC260 label still clashes as a laundering tell. The vendor normalization must not introduce clashes on clean compatibility samples. +- `identify.py` — separates file-backed metadata extraction (`extract_provenance_evidence`) from metadata-only verdict logic (`identify_from_evidence`); the compatible `identify` wrapper adds optional pixel-backed visible and invisible checks. Detection from `ProvenanceEvidence` must never reopen the source. All paths produce one `ProvenanceReport`; `is_ai_generated` is True or None, never asserted False. `ProvenanceReport.ai_source_kind` exposes the C2PA digital-source-type split — `"generated"` (trainedAlgorithmicMedia, fully AI) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region), else None — so a caller branches full-frame scrub vs region-targeted clean (see `noai/tiling.feather_region_composite` + `WatermarkRemover.remove_watermark(region=...)`). The sparkle provenance threshold is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (imported, not a private copy) so the provenance "is there a sparkle" verdict and the removal "take the sparkle" decision can never drift. `import identify` is deliberately light (lazy `noai/__init__`, fits a 512 MB host) — keep heavy imports out (the `watermark_registry` constant import stays light: engines are lazy there). Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM` only when verified against a real C2PA file; editing-app/AI-device signer tokens go to `_SIGNER_C2PA_PLATFORM`; generator/issuer platforms to `C2PA_AI_VENDORS` in `constants.py`. The IPTC `digitalSourceType` **`algorithmicMedia`** (bare) is PROCEDURAL (an algorithm not trained on sampled data), NOT AI/ML generation, so it is deliberately absent from `IPTC_AI_MARKERS` — flagging it made `identify` assert AI + `has_invisible_target` True, scrubbing clean procedural content (it is a distinct token from `trainedAlgorithmicMedia`, so real "Made with AI" labels are unaffected; regression `test_metadata.py::...test_bare_algorithmic_media_not_flagged_ai`). Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence). `_vendor_of` normalizes ByteDance/Canva/ElevenLabs/Black Forest Labs (as well as OpenAI/Google/... ) so their C2PA claims participate in the clash check; the generic **China TC260 AIGC label names no specific vendor**, so when a TC260-applying vendor (ByteDance, `_TC260_VENDORS`) is co-attributed the label is attributed to it (a legit Doubao image carrying its own TC260 label must NOT clash), while a NON-TC260 vendor next to a TC260 label still clashes as a laundering tell. The vendor normalization must not introduce clashes on clean compatibility samples. - `watermark_registry.py` — the single catalog of known visible watermarks (gemini / doubao / jimeng / qwen / kling / samsung / runninghub / baidu / liblib / jimeng_pill). **Removal is LOCALIZE -> FILL for every mark:** each mark is localized to a binary full-frame footprint mask (a `Localization`), then ONE shared, swappable fill inpaints that mask via `fill(image, mask, backend=...)` (delegates to `region_eraser.erase`). Reverse-alpha (the old `original = (wm - a*logo)/(1-a)` inversion of a captured alpha map + thin residual inpaint) is GONE for ALL marks; why it was dropped is recorded in `docs/module-internals.md`. Backends: `cv2` (classical inpaint, no deps, the floor), `migan` (MI-GAN ONNX, light, the memory-tight pick where LaMa will not fit), `lama` (big-LaMa ONNX, best quality, heavier, auto-preferred when a learned backend is available); `auto` = LaMa > MI-GAN > cv2, best available. The captured alpha maps (`scripts/visible_alpha_solve.py`) are still used to DETECT the marks and to shape the mask, but NOT for pixel recovery. **`--mark auto` removes EVERY detected mark in one pass** via `remove_auto_marks(image, *, sensitivity="auto", provenance=frozenset(), backend="auto")` (marks coexist -- a Jimeng-basic image has the top-left pill AND the bottom-right wordmark; a single-strongest pick would leave one). **Three orthogonal axes:** `backend` (the fill), `sensitivity` (how hard to trust a borderline mark: `auto`/`strict`, see the `Sensitivity` literal), and `provenance` (vendor keys metadata confirms -- the evidence that drives `auto`). **Perception / decision / action are separated:** `_build_candidates(image)` runs every detector at BOTH trust levels (strict + relaxed) and packages raw verdicts + features into `Candidate`s (no policy); the pure arbiter `decide(candidates, Context(sensitivity, provenance)) -> [Decision]` makes every keep/drop call (per-mark `resolve_trust` + the assumed-trust floor + the pill gate) with no image/IO, so it is unit-testable in isolation; then each winner is localized -> filled. Do NOT put policy back into the engines (the one exception, the Gemini FP gate, stays in `gemini_engine` because `identify` shares that confidence). `detect_marks(..., provenance=frozenset())` stays strict (identify verdict, precision over recall); `KnownMark.remove/detect/localize(..., provenance: bool)` take the already-resolved boolean. **How `auto` decides (this is metadata-INDEPENDENT for recall):** the visual detectors are pixel-based and need no metadata; the recall gain comes from RELAXING the false-positive gate, not from metadata. `strict` never relaxes (clean images untouched); `auto` relaxes a mark only on same-product evidence -- metadata provenance for that vendor OR a confidently detected sibling mark of the SAME product (`_PRODUCT_OF`; Doubao and Jimeng are both bottom-right ByteDance but distinct products, so they do NOT cross-relax). **`resolve_trust` resolves TWO levels:** `confirmed` bypasses the engine's false-positive gate, and only `confirmed` has evidence naming THAT vendor, which is exactly what the bypass is contracted to require (`GeminiEngine.detect_watermark`'s `trust_provenance` docstring: "external metadata already proves this is a Google generation"). **Historical, kept as the reason the third level is gone:** a removed `assumed` level let `assume_ai` bypass the gate on the bare assertion an image is AI. It produced unacceptable false positives on clean camera images and was removed. A wrong relaxation only fills a small corner near-losslessly (the localize -> fill benign failure mode), which is what made a SMALL false-fire rate arguable; it was never a licence for a 60% one. Metadata provenance mapping (feeds `auto`, read by `cli._visible_provenance`): Google/Gemini C2PA issuer -> gemini; China-AIGC (TC260) label -> doubao/jimeng; `samsung_genai` -> samsung. **The `jimeng_pill` is capture-less.** It uses a synthetic silhouette and a fixed top-left footprint. `_keep_pill` requires either a confirmed sibling wordmark or Jimeng provenance with a flat footprint. Do not loosen the flatness guard. **`assume_ai` was REMOVED (2026-07-19); `--sensitivity` is now `auto`/`strict` only.** It relaxed every mark's FP gate on the bare assertion an image is AI -- which names no vendor and no location, exactly what the bypass requires -- and had no place in the model (detector finds -> remove; finds nothing -> leave alone; user SEES a mark -> act on that). It took `_ASSUMED_CONF_FLOOR` / `assumed_floor_ok` / the `assumed` trust level with it, collapsing the ladder to `strict`/`confirmed`, and `_keep_pill` lost its `sensitivity` arg. Recall/precision on the unbiased sample are unchanged, so nothing on the default path moved. **Replacement advice is per mark:** `erase --region` is sound by construction; `--mark --no-detect` is reasonable (forced mask = the real glyph blob, non-empty 13/13); **`--mark gemini --no-detect` is NOT** -- it falls back to a fixed slot that covered the true sparkle on only **31% of 97** missed sparkles, so 69% fill a clean corner AND report a removal that did not happen. `cli._no_visible_mark_exit` follows that order and no longer suggests the removed mode. Migration raises loudly (`validate_sensitivity`, called from `api.remove_visible` and `Context.__post_init__`) because a `Literal` is unenforced at runtime and would silently downgrade a 0.15 caller to `auto`. **Detection and removal-mask extraction must use the same front-end.** The continuous `tophat` path can detect a mark whose binary glyph blob is empty, so its fallback mask uses the detector's own best-match box. Keep the textured regression fixture when changing this path. **Detector thresholds and geometry are calibrated per mark; do not port them between vendors without a fresh evaluation.** **New mark assets must be synthetic.** Font-render the mark, calibrate it on local evaluation inputs, and commit only the synthetic silhouette. Never derive a committed asset from source images. - `gemini_engine.py` — visible Gemini-sparkle detector + localizer (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (`_SELECT_TOPK`), corner-promote, false-positive gate (the provenance prior relaxes the gate + lowers the trust threshold when a Google/Gemini C2PA issuer confirms the vendor). **White-core rescue:** the FP gate demotes a low-gradient match (soft edges), but a real FAINT sparkle also has soft edges -- so the gate keeps a low-grad match that is a strong (conf ≥ `_SPARKLE_KEEP_CONF` 0.52), bright (margin), near-WHITE-core sparkle (`_core_saturation` ≤ `_SPARKLE_WHITE_SAT` 0.20): a real sparkle core is white, a clean bright corner that shape-matches (sky/sun) is colored. This recovers ~14/20 metadata-stripped faint sparkles under the DEFAULT strict/auto (no flag, no metadata) at ~1.25% clean false-fire (baseline 0.55%); the ~0.51-scoring bright-bg FPs stay demoted (below 0.52). A learned classifier on the SAME features was measured WORSE than the tuned gate (2026-07 tier-1: MLP 86.7% recall vs 90.8% at equal FP), so the heuristic stays; a patch-CNN with richer features is the only lever left (roadmapped P2, low expected value -- the wall is fundamental). Detection scores the top-K size-weighted matches by full fusion (spatial+gradient+variance) and keeps the highest — NOT the raw-NCC argmax, which re-admits the tiny-patch FPs the size weight suppresses (the osachub 2026-06-12 sub-0.85 corner-sparkle regression; see `docs/module-internals.md`). Keep the 0.85 corner-promote NCC gate; a margin/chroma-gated lower promote was measured and REJECTED 2026-06-11 (~33% FP on non-Google content). Removal is localize -> fill: `footprint_mask` returns the sparkle footprint (the captured alpha thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin), and the shared `watermark_registry.fill` inpaints it. The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery. `detect_sparkle_confidence` reuses a process-wide `_shared_engine()` singleton (lru_cache) — the engine holds only constant assets (captures, alpha maps, a precomputed 16..118 template ladder) and takes the image as an arg, so do NOT reconstruct `GeminiEngine()` per call: that reloaded assets + recomputed alpha maps + rebuilt the template cache on every one of ~34k `identify` calls (−24% on the sparkle path once made a singleton, output byte-identical). `detect_watermark`/`footprint_mask` guard `image.size == 0` before `to_bgr`, and return an empty (detected=False) result when no template scale fits (short side < 16 px), rather than dereferencing an empty candidate list. - `_text_mark_engine.py` — shared base for the text-mark engines (extracted 2026-06-09); the per-engine modules are config-only subclasses. Detection still matches the glyph silhouette (NCC, keys on glyph shape). The removal mask is TEMPLATE-FREE: it is the bounding box of the top-hat glyph blob (`extract_mask`), filled solid + dilated, so the shared fill inpaints the whole wordmark rectangle. This drops the fixed alpha-template placement, so a re-rendered or differently-placed mark is still masked; the captured alpha maps are now used only for the detection silhouette, not for removal. New text mark = a `TextMarkConfig` + a thin subclass + one registry row. Gemini stays a separate engine (different model). The corner anchor is `corner` = `br`/`bl`/`tl`/`bc` (tl added 2026-07-22 for runninghub, bc for liblib's centered wordmark); `detect_frontend` is `binary`/`tophat`/`gray` (`gray` = raw-grayscale NCC for the faint mid-gray runninghub mark, added 2026-07-22; contrast-DEPENDENT, so its gates never port). The detection scale ladder is per-mark (`TextMarkConfig.ladder`, default `(0.8, 1.0, 1.25)` -- added 2026-07-21 for qwen's two size modes; the shared default is unchanged for every other mark, and densifying the SHARED ladder was measured and rejected, see `docs/verification-plan.md` B2). diff --git a/docs/module-internals.md b/docs/module-internals.md index 8432062..ce02f2b 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -131,9 +131,14 @@ Regression coverage: ### Provenance report -[`identify.py`](../src/remove_ai_watermarks/identify.py) combines metadata, -registered visible marks, and optional open invisible-watermark decoders into a -`ProvenanceReport`. +[`identify.py`](../src/remove_ai_watermarks/identify.py) separates file-backed +metadata extraction from verdict logic: + +- `extract_provenance_evidence` reads the supported metadata signals into + `ProvenanceEvidence`. +- `identify_from_evidence` evaluates that evidence without reopening the source. +- `identify` preserves the path-based API and adds the optional registered + visible-mark and open invisible-watermark decoders after extraction. `is_ai_generated` is `True` or `None`; absence of evidence is not reported as a human-made verdict. `ai_source_kind` distinguishes fully generated content from diff --git a/docs/python-api.md b/docs/python-api.md index 4bc2af6..758ee27 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -88,8 +88,8 @@ print(report.platform) print(report.signals) ``` -Use `check_visible=False` and `check_invisible=False` for metadata only -inspection: +Use `check_visible=False` and `check_invisible=False` for metadata-only +inspection through the compatible path-based API: ```python report = identify( @@ -99,6 +99,24 @@ report = identify( ) ``` +Extraction and detection are also available as separate steps. This is useful +when a file-reading worker collects the metadata once and another component +evaluates the resulting evidence: + +```python +from remove_ai_watermarks.identify import ( + extract_provenance_evidence, + identify_from_evidence, +) + +evidence = extract_provenance_evidence(Path("input.png")) +report = identify_from_evidence(evidence) +``` + +`identify_from_evidence` does not reopen the source file. It evaluates metadata +only; registered visible marks and pixel-backed invisible watermarks remain in +the path-based `identify` call. + ## Strip metadata ```python diff --git a/pyproject.toml b/pyproject.toml index dd846ca..350dfcd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remove-ai-watermarks" -version = "0.20.2" +version = "0.21.0" description = "AI watermark remover: strip visible and invisible AI watermarks (Gemini / Nano Banana sparkle, SynthID) and provenance metadata (C2PA, EXIF) from images" readme = "README.md" requires-python = ">=3.10.1" diff --git a/src/remove_ai_watermarks/__init__.py b/src/remove_ai_watermarks/__init__.py index b7cceda..0e20249 100644 --- a/src/remove_ai_watermarks/__init__.py +++ b/src/remove_ai_watermarks/__init__.py @@ -25,7 +25,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error") _warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*") -__version__ = "0.20.2" +__version__ = "0.21.0" __all__ = ["__version__", "remove_visible", "visible_provenance"] diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index ab554a9..3c1d200 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -132,6 +132,28 @@ class Signal: confidence: str # "high" | "medium" +@dataclass(frozen=True) +class ProvenanceEvidence: + """Extracted metadata evidence used by provenance detection. + + Extraction is intentionally separate from verdict logic so a caller can + collect the file-backed evidence once and evaluate it without reopening the + source. Pixel-backed visible and invisible watermark checks remain part of + :func:`identify`. + """ + + path: Path + c2pa_info: dict[str, Any] + ai_metadata: dict[str, str] + scan: bytes + iptc_ai_system: str | None + aigc_label: dict[str, str] | None + exif_generator: str | None + xai_signature: bool + huggingface_job: str | None + samsung_genai: int | None + + @dataclass class ProvenanceReport: """Aggregated provenance verdict for one image.""" @@ -166,6 +188,22 @@ class ProvenanceReport: integrity_clashes: list[str] = field(default_factory=list[str]) +def extract_provenance_evidence(image_path: Path) -> ProvenanceEvidence: + """Read all file-backed metadata needed by provenance verdict logic once.""" + return ProvenanceEvidence( + path=image_path, + c2pa_info=extract_c2pa_info(image_path), + ai_metadata=get_ai_metadata(image_path), + scan=scan_head(image_path, _SCAN_BYTES), + iptc_ai_system=iptc_ai_system(image_path), + aigc_label=aigc_label(image_path), + exif_generator=exif_generator(image_path), + xai_signature=xai_signature(image_path), + huggingface_job=huggingface_job(image_path), + samsung_genai=samsung_genai(image_path), + ) + + def _issuers_in(data: bytes) -> list[str]: """C2PA issuer names whose signature byte appears in ``data`` (binary scan).""" return sorted({name for sig, name in C2PA_ISSUERS.items() if sig in data}) @@ -549,29 +587,25 @@ def _collect_visible_signals( return platform -def identify(image_path: Path, *, check_visible: bool = True, check_invisible: bool = True) -> ProvenanceReport: - """Identify an image's origin platform and watermark inventory. +def _identify_from_evidence( + evidence: ProvenanceEvidence, + *, + image_path: Path | None = None, + check_visible: bool = False, + check_invisible: bool = False, +) -> ProvenanceReport: + """Build a provenance verdict from extracted evidence. - Args: - image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container). - check_visible: Also run the registered visible-mark detectors through cv2. - Set False for a pure-metadata, dependency-light scan. - check_invisible: Also decode open invisible watermarks (SD/SDXL/FLUX) via - the optional imwatermark library. No-op when it is not installed. - - Returns: - A :class:`ProvenanceReport`. ``is_ai_generated`` is True when any AI - signal is found and None (unknown) when none is -- it is never asserted - False, because stripped metadata leaves no local proof of a clean origin. + ``image_path`` is supplied only by :func:`identify` for optional pixel + detectors. Metadata-only callers leave it unset and never reopen the source. """ - info = extract_c2pa_info(image_path) # PNG-structured; {} for other formats - meta = get_ai_metadata(image_path) # PNG text + EXIF + C2PA fields + synthid + if (check_visible or check_invisible) and image_path is None: + raise ValueError("Pixel-backed checks require image_path") + pixel_path = image_path - # First MB covers C2PA (PNG caBX, JPEG APP11, AVIF/HEIF/JXL uuid box) and - # IPTC markers for the non-PNG path where extract_c2pa_info returns {}. - # scan_head also seeks out late ISOBMFF provenance boxes (manifest after a - # large mdat in a streaming MP4) that a fixed first-MB read would miss. - head = scan_head(image_path, _SCAN_BYTES) + info = evidence.c2pa_info + meta = evidence.ai_metadata + head = evidence.scan signals: list[Signal] = [] watermarks: list[str] = [] @@ -688,7 +722,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b # ── IPTC 2025.1 AI-disclosure fields (Iptc4xmpExt:AISystemUsed etc.) ─ iptc_ai = any(m in head for m in IPTC_AI_FIELD_MARKERS) if iptc_ai: - system = iptc_ai_system(image_path) + system = evidence.iptc_ai_system named = bool(system) and system != "fields present" signals.append( Signal("iptc_ai_system", f"IPTC AI disclosure ({system})" if named else "IPTC AI disclosure fields", "high") @@ -704,7 +738,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b # URL, present in XMP and as a laundering tell even when the JSON payload is # truncated) OR the parsed label, which additionally catches the raw-JSON # PNG ``AIGC`` tEXt chunk that carries no namespaced marker at all. - aigc_data = aigc_label(image_path) + aigc_data = evidence.aigc_label aigc = aigc_data is not None or any(m in head for m in AIGC_MARKERS) if aigc: producer = (aigc_data or {}).get("ContentProducer", "") @@ -725,7 +759,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b # ── EXIF Software / XMP CreatorTool / PNG-text generator (cross-format) ─ # Catches a generator tag (incl. inside AVIF/HEIF/JXL and PNG text chunks) # when there is no C2PA. - if generator_tag := exif_generator(image_path): + if generator_tag := evidence.exif_generator: signals.append(Signal("exif_generator", f"Embedded generator tag: {generator_tag}", "high")) watermarks.append(f"Embedded generator tag: {generator_tag}") if platform is None: @@ -737,7 +771,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b # Grok's only provenance signal: EXIF ImageDescription "Signature: " # + a UUID Artist. Distinct from exif_generator (which matches generator # tokens); verified stable across 3 generations. See CLAUDE.md. - if xai_signature(image_path): + if evidence.xai_signature: signals.append(Signal("xai_signature", "EXIF Signature blob + UUID Artist", "high")) watermarks.append("xAI/Grok EXIF signature") if platform is None: @@ -748,7 +782,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b # Marks the hosting job, not a model -- medium confidence (commonly diffusion # output). Like the visible sparkle, it lifts an otherwise-Unknown verdict to # a tentative AI, but never overrides a high-confidence metadata signal. - hf_job = huggingface_job(image_path) + hf_job = evidence.huggingface_job if hf_job: signals.append(Signal("hf_job", f"HuggingFace job {hf_job}", "medium")) watermarks.append("HuggingFace-hosted job (hf-job-id)") @@ -764,7 +798,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b # verdict, but the field is undocumented, so it never overrides a high- # confidence signal. The platform is usually already "Samsung Galaxy" via the # signer-token scan; the fallback covers a future file without the cert org. - samsung_genai_type = samsung_genai(image_path) + samsung_genai_type = evidence.samsung_genai if samsung_genai_type is not None: signals.append(Signal("samsung_genai", f"Samsung genAIType={samsung_genai_type}", "medium")) watermarks.append("Samsung Galaxy AI editing marker (genAIType)") @@ -774,7 +808,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b # ── Open invisible watermark (SD / SDXL / FLUX, dwtDct) ────────── # Public decoder, no key -- a definitive embedded signal on pristine files. - if check_invisible and (scheme := _invisible_watermark(image_path)) is not None: + if check_invisible and pixel_path is not None and (scheme := _invisible_watermark(pixel_path)) is not None: signals.append(Signal("invisible_watermark", scheme, "high")) watermarks.append(f"Open invisible watermark: {scheme}") caveats.append(_INVISIBLE_WM_CAVEAT) @@ -785,7 +819,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b # The watermark behind Adobe Durable Content Credentials. Decoded locally, # but it binds provenance for human-authored content too, so it enriches the # watermark inventory without by itself asserting AI origin. - if check_invisible and (tm_scheme := _trustmark(image_path)) is not None: + if check_invisible and pixel_path is not None and (tm_scheme := _trustmark(pixel_path)) is not None: signals.append(Signal("trustmark", tm_scheme, "high")) watermarks.append(f"Adobe TrustMark invisible watermark ({tm_scheme})") if platform is None: @@ -806,8 +840,8 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b or xai_sig ) - if check_visible: - platform = _collect_visible_signals(image_path, signals, watermarks, platform) + if check_visible and pixel_path is not None: + platform = _collect_visible_signals(pixel_path, signals, watermarks, platform) visible_only = any(s.name.startswith("visible_") for s in signals) and not ai_from_metadata hf_only = bool(hf_job) and not ai_from_metadata @@ -831,7 +865,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b caveats = list(dict.fromkeys(caveats)) return ProvenanceReport( - path=image_path, + path=evidence.path, is_ai_generated=is_ai, platform=platform, confidence=confidence, @@ -846,6 +880,45 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b ) +def identify_from_evidence(evidence: ProvenanceEvidence) -> ProvenanceReport: + """Build a metadata-only provenance verdict without reopening the source.""" + return _identify_from_evidence(evidence) + + +def identify( + image_path: Path, + *, + check_visible: bool = True, + check_invisible: bool = True, +) -> ProvenanceReport: + """Identify an image's origin platform and watermark inventory. + + Args: + image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container). + check_visible: Also run the registered visible-mark detectors through cv2. + Set False for a metadata-only, dependency-light scan. + check_invisible: Also decode open invisible watermarks (SD/SDXL/FLUX) via + the optional imwatermark library. No-op when it is not installed. + + File-backed metadata extraction runs first. The extracted evidence is then + evaluated independently, followed by the optional pixel-backed visible and + invisible watermark checks. + + Returns: + A :class:`ProvenanceReport`. ``is_ai_generated`` is True when any AI + signal is found and None (unknown) when none is found. It is never + asserted False because stripped metadata leaves no local proof of a + clean origin. + """ + evidence = extract_provenance_evidence(image_path) + return _identify_from_evidence( + evidence, + image_path=image_path, + check_visible=check_visible, + check_invisible=check_invisible, + ) + + def has_invisible_target(image_path: Path) -> bool: """True when a locally-detectable invisible/metadata AI signal is present. diff --git a/tests/test_identify.py b/tests/test_identify.py index 833d30b..caaaca8 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -16,14 +16,17 @@ from unittest.mock import patch import pytest from remove_ai_watermarks.identify import ( + ProvenanceEvidence, ProvenanceReport, _ai_tools_in, _attribute_platform, _integrity_clashes, _issuers_in, _vendor_of, + extract_provenance_evidence, has_invisible_target, identify, + identify_from_evidence, ) from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF @@ -33,6 +36,56 @@ _SPARKLE_TARGET = "remove_ai_watermarks.gemini_engine.detect_sparkle_confidence" SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance" +class TestProvenanceEvidence: + @pytest.mark.parametrize( + "filename", + [ + "chatgpt-1.png", + "chatgpt-2.png", + "doubao-1.png", + "firefly-1.png", + "flux-1.jpg", + "flux-1.png", + "grok-1.jpg", + "mj-1.png", + ], + ) + def test_metadata_only_identify_matches_extracted_evidence(self, filename: str): + path = SAMPLES_DIR / filename + + direct = identify(path, check_visible=False, check_invisible=False) + evidence = extract_provenance_evidence(path) + extracted = identify_from_evidence(evidence) + + assert isinstance(evidence, ProvenanceEvidence) + assert extracted == direct + + def test_identify_from_evidence_does_not_read_the_source(self, monkeypatch, tmp_path: Path): + path = tmp_path / "generated.jpg" + path.write_bytes(b"\xff\xd8\xff\xe1jumbc2paOpenAI DALL-E trainedAlgorithmicMedia\xff\xd9") + evidence = extract_provenance_evidence(path) + + def fail_if_called(*args, **kwargs): + raise AssertionError("identify_from_evidence must not read the source file") + + monkeypatch.setattr("remove_ai_watermarks.identify.extract_c2pa_info", fail_if_called) + monkeypatch.setattr("remove_ai_watermarks.identify.get_ai_metadata", fail_if_called) + monkeypatch.setattr("remove_ai_watermarks.identify.scan_head", fail_if_called) + monkeypatch.setattr("remove_ai_watermarks.identify.iptc_ai_system", fail_if_called) + monkeypatch.setattr("remove_ai_watermarks.identify.aigc_label", fail_if_called) + monkeypatch.setattr("remove_ai_watermarks.identify.exif_generator", fail_if_called) + monkeypatch.setattr("remove_ai_watermarks.identify.xai_signature", fail_if_called) + monkeypatch.setattr("remove_ai_watermarks.identify.huggingface_job", fail_if_called) + monkeypatch.setattr("remove_ai_watermarks.identify.samsung_genai", fail_if_called) + monkeypatch.setattr("builtins.open", fail_if_called) + monkeypatch.setattr(Path, "open", fail_if_called) + + report = identify_from_evidence(evidence) + + assert report.is_ai_generated is True + assert any(signal.name == "c2pa" for signal in report.signals) + + # ── Pure attribution logic (no file IO) ───────────────────────────── diff --git a/uv.lock b/uv.lock index 62924fd..edf9f42 100644 --- a/uv.lock +++ b/uv.lock @@ -3187,7 +3187,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.20.2" +version = "0.21.0" source = { editable = "." } dependencies = [ { name = "c2pa-python" },