diff --git a/CLAUDE.md b/CLAUDE.md index 197c726..84c177a 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` — 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. +- `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. External metadata ingestion (`evidence_from_metadata_record`) must reuse the same pure parser primitives in `metadata.py` as file-backed extraction; never duplicate signal regexes or field registries. 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 ce02f2b..550c834 100644 --- a/docs/module-internals.md +++ b/docs/module-internals.md @@ -136,6 +136,8 @@ metadata extraction from verdict logic: - `extract_provenance_evidence` reads the supported metadata signals into `ProvenanceEvidence`. +- `evidence_from_metadata_record` normalizes an externally collected nested + metadata record into the same evidence type without file access. - `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. diff --git a/docs/python-api.md b/docs/python-api.md index 758ee27..24ac64e 100644 --- a/docs/python-api.md +++ b/docs/python-api.md @@ -113,6 +113,28 @@ evidence = extract_provenance_evidence(Path("input.png")) report = identify_from_evidence(evidence) ``` +If metadata was collected by another component, normalize its nested record +without reopening the original file: + +```python +from remove_ai_watermarks.identify import ( + evidence_from_metadata_record, + identify_from_evidence, +) + +record = { + "pil": {"info:parameters": "Steps: 20, Sampler: Euler"}, + "exif": {"0th": {"Software": "Stable Diffusion"}}, +} +evidence = evidence_from_metadata_record(record, path=Path("input.png")) +report = identify_from_evidence(evidence) +``` + +The normalizer recursively preserves text and byte values. It also decodes +strings prefixed with `hex:` and fields named `base64` or ending in +`_base64`. Pass a C2PA manifest-store dictionary in `record["c2pa_store"]`, or +through the explicit `c2pa_manifest_store` argument. + `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. diff --git a/docs/watermarking-landscape.md b/docs/watermarking-landscape.md index 49f7729..a7afa30 100644 --- a/docs/watermarking-landscape.md +++ b/docs/watermarking-landscape.md @@ -33,7 +33,7 @@ Grok JPEG downloads (Aurora model) carry **no C2PA, no XMP, no SynthID, no IPTC* **Stripped on removal too:** `remove_ai_metadata` calls `_scrub_ai_exif` on JPEG EXIF, which deletes the xAI Signature and UUID Artist pair plus supported AI generator values while retaining unrelated camera and editor EXIF. The -shared `_is_xai_signature_pair` helper is the single source of truth for the +shared `xai_signature_pair` helper is the single source of truth for the pair. On the ISOBMFF path, `blank_ai_exif_tokens` provides the corresponding in-place scrub for supported EXIF values, TC260 AIGC blocks, and the xAI pair. - **China TC260 AIGC label (caught by `AIGC_MARKERS` / `metadata.aigc_label`, surfaced by `identify` as the `aigc` signal):** China-served generators embed an XMP `{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`). diff --git a/pyproject.toml b/pyproject.toml index 350dfcd..1103cfd 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [project] name = "remove-ai-watermarks" -version = "0.21.0" +version = "0.21.1" 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 0e20249..a22d32b 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.21.0" +__version__ = "0.21.1" __all__ = ["__version__", "remove_visible", "visible_provenance"] diff --git a/src/remove_ai_watermarks/identify.py b/src/remove_ai_watermarks/identify.py index 3c1d200..1b83fcc 100644 --- a/src/remove_ai_watermarks/identify.py +++ b/src/remove_ai_watermarks/identify.py @@ -19,10 +19,12 @@ never as "clean". See CLAUDE.md "SynthID detection is metadata-only". from __future__ import annotations +import base64 +import contextlib import itertools import logging from dataclasses import dataclass, field -from typing import TYPE_CHECKING +from typing import TYPE_CHECKING, Any, cast from remove_ai_watermarks.metadata import ( AI_METADATA_KEYS, @@ -30,17 +32,27 @@ from remove_ai_watermarks.metadata import ( IPTC_AI_FIELD_MARKERS, IPTC_AI_MARKERS, aigc_label, + aigc_label_from_metadata, c2pa_cloud_manifest_in, c2pa_marker_in, exif_generator, + generator_from_metadata, get_ai_metadata, huggingface_job, iptc_ai_system, + iptc_ai_system_in, samsung_genai, + samsung_genai_in, scan_head, xai_signature, + xai_signature_pair, +) +from remove_ai_watermarks.noai.c2pa import ( + c2pa_info_from_manifest_store, + cbor_text_after, + extract_c2pa_info, + soft_binding_vendors_in, ) -from remove_ai_watermarks.noai.c2pa import cbor_text_after, extract_c2pa_info, soft_binding_vendors_in from remove_ai_watermarks.noai.constants import ( C2PA_AI_TOOLS, C2PA_AI_VENDORS, @@ -51,7 +63,6 @@ from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF if TYPE_CHECKING: from pathlib import Path - from typing import Any from numpy.typing import NDArray @@ -154,6 +165,182 @@ class ProvenanceEvidence: samsung_genai: int | None +def _external_metadata(value: Any) -> tuple[list[tuple[str, Any]], bytes]: + """Index nested metadata and recover common encoded binary values in one pass.""" + pairs: list[tuple[str, Any]] = [] + parts: list[bytes] = [] + + def visit(item: Any) -> None: + if isinstance(item, dict): + mapping = cast("dict[object, Any]", item) + for key, nested in mapping.items(): + key_text = str(key) + pairs.append((key_text, nested)) + parts.append(key_text.encode("utf-8", "replace")) + if isinstance(nested, str) and (key_text == "base64" or key_text.endswith("_base64")): + encoded = nested.split("...TRUNCATED", 1)[0] + with contextlib.suppress(ValueError, TypeError): + parts.append(base64.b64decode(encoded, validate=True)) + visit(nested) + elif isinstance(item, (list, tuple)): + sequence = cast("list[Any] | tuple[Any, ...]", item) + for nested in sequence: + visit(nested) + elif isinstance(item, bytes): + parts.append(item) + elif isinstance(item, str): + parts.append(item.encode("utf-8", "replace")) + if item.startswith("hex:"): + with contextlib.suppress(ValueError): + parts.append(bytes.fromhex(item[4:])) + elif item is not None: + parts.append(str(item).encode("utf-8", "replace")) + + visit(value) + return pairs, b"\n".join(parts) + + +def _external_text(value: Any) -> str: + if isinstance(value, bytes): + return value.decode("latin-1", "replace").strip() + if not isinstance(value, str): + return str(value).strip() + if value.startswith("hex:"): + try: + return bytes.fromhex(value[4:]).decode("latin-1", "replace").strip() + except ValueError: + pass + return value.strip() + + +def _external_exif_generator(pairs: list[tuple[str, Any]], scan: bytes) -> str | None: + candidate_keys = { + "software", + "make", + "artist", + "imagedescription", + "source", + "title", + "description", + "creatortool", + } + candidates = [ + str(value) + for key, value in pairs + if key.lower().removeprefix("info:") in candidate_keys and isinstance(value, (str, bytes)) + ] + return generator_from_metadata(candidates, scan) + + +def evidence_from_metadata_record( + record: dict[str, Any], *, path: Path, c2pa_manifest_store: str | dict[str, Any] | None = None +) -> ProvenanceEvidence: + """Normalize an externally collected metadata record into provenance evidence. + + The record may contain arbitrary nested dictionaries and lists. Text, bytes, + hexadecimal values prefixed with ``hex:``, and fields named ``base64`` or + ending in ``_base64`` are included in the shared byte scan. No source file is + opened. + """ + pairs, scan = _external_metadata(record) + store = c2pa_manifest_store + if store is None: + candidate = record.get("c2pa_store") + store = ( + cast("dict[str, Any]", candidate) + if isinstance(candidate, dict) + else candidate + if isinstance(candidate, str) + else None + ) + c2pa_info = c2pa_info_from_manifest_store(store) if store is not None else {} + + ai_metadata: dict[str, str] = {} + pil_info = record.get("pil") + pil_pairs = cast("dict[str, Any]", pil_info).items() if isinstance(pil_info, dict) else () + for key, value in pil_pairs: + normalized_key = key.lower().removeprefix("info:") + if normalized_key not in AI_METADATA_KEYS or isinstance(value, (dict, list, tuple)): + continue + text = value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value) + ai_metadata.setdefault(normalized_key, text[:200] + ("…" if len(text) > 200 else "")) + for key, value in pairs: + if key != "text" or not isinstance(value, str) or "\x00" not in value: + continue + metadata_key, metadata_value = value.split("\x00", 1) + normalized_key = metadata_key.lower() + if normalized_key in AI_METADATA_KEYS: + ai_metadata.setdefault( + normalized_key, + metadata_value[:200] + ("…" if len(metadata_value) > 200 else ""), + ) + for key in ( + "c2pa_manifest", + "claim_generator", + "c2pa_spec", + "issuer", + "source_type", + "actions", + "synthid_watermark", + "soft_binding", + ): + if key in c2pa_info: + ai_metadata.setdefault(key, str(c2pa_info[key])) + + iptc_system = iptc_ai_system_in(scan) + + values_by_key: dict[str, str] = {} + for key, value in pairs: + if isinstance(value, (bytes, str)): + values_by_key.setdefault(key.lower(), _external_text(value)) + description = values_by_key.get("imagedescription", "") + artist = values_by_key.get("artist", "") + xai = xai_signature_pair(description, artist) + + hf_job = next( + ( + str(value).strip() + for key, value in pairs + if key.lower().removeprefix("info:") == "hf-job-id" and str(value).strip() + ), + None, + ) + samsung = samsung_genai_in(scan) + + aigc_candidates = tuple( + value for key, value in pairs if key.lower().removeprefix("info:") == "aigc" and isinstance(value, str) + ) + aigc = aigc_label_from_metadata(scan, aigc_candidates) + exif_gen = _external_exif_generator(pairs, scan) + if aigc is not None: + producer = aigc.get("ContentProducer", "") + ai_metadata.setdefault( + "aigc_label", + f"China AIGC label (TC260){f'; producer {producer}' if producer else ''}", + ) + if xai: + ai_metadata.setdefault("xai_signature", "xAI/Grok EXIF signature (Artist UUID + Signature blob)") + if iptc_system: + ai_metadata.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({iptc_system})") + if hf_job: + ai_metadata.setdefault("huggingface_job", f"HuggingFace-hosted job ({hf_job})") + if samsung is not None: + ai_metadata.setdefault("samsung_genai", f"Samsung Galaxy AI editing marker (genAIType={samsung})") + + return ProvenanceEvidence( + path=path, + c2pa_info=c2pa_info, + ai_metadata=ai_metadata, + scan=scan, + iptc_ai_system=iptc_system, + aigc_label=aigc, + exif_generator=exif_gen, + xai_signature=xai, + huggingface_job=hf_job, + samsung_genai=samsung, + ) + + @dataclass class ProvenanceReport: """Aggregated provenance verdict for one image.""" diff --git a/src/remove_ai_watermarks/metadata.py b/src/remove_ai_watermarks/metadata.py index 9de9459..33bd24b 100644 --- a/src/remove_ai_watermarks/metadata.py +++ b/src/remove_ai_watermarks/metadata.py @@ -356,6 +356,54 @@ def has_ai_metadata(image_path: Path) -> bool: return xai_signature(image_path) +def aigc_label_from_metadata(data: bytes, candidates: tuple[str, ...] = ()) -> dict[str, str] | None: + """Parse a China TC260 AI-labeling block from already collected metadata.""" + import html + import json + from typing import cast + + def _parse(text: str, *, require_tc260_field: bool) -> dict[str, str] | None: + try: + parsed = json.loads(text) + except ValueError: + return None + if not isinstance(parsed, dict): + return None + fields = {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()} + if require_tc260_field and not (_TC260_FIELDS & fields.keys()): + return None + return fields + + for candidate in candidates: + if result := _parse(candidate, require_tc260_field=True): + return result + + match = re.search( + rb'(.*?)|TC260:AIGC\s*=\s*"(.*?)"', + data, + re.DOTALL, + ) + if match: + body = match.group(1) if match.group(1) is not None else match.group(2) + return _parse(html.unescape(body.decode("utf-8", "replace")), require_tc260_field=False) + + text = data.decode("latin-1") + for needle in ('"AIGC"', "AIGC{"): + start = text.find(needle) + if start == -1: + continue + brace = text.find("{", start) + if brace == -1: + continue + try: + _, end = json.JSONDecoder().raw_decode(text, brace) + except ValueError: + continue + if result := _parse(text[brace:end], require_tc260_field=True): + return result + return None + + def aigc_label(image_path: Path) -> dict[str, str] | None: """Parse a China TC260 AI-labeling block, if present. @@ -378,24 +426,6 @@ def aigc_label(image_path: Path) -> dict[str, str] | None: if they carry at least one known TC260 field (``_TC260_FIELDS``); the namespaced XMP element is unambiguous, so any JSON object is accepted. """ - import html - import json - from typing import cast - - def _parse(text: str, *, require_tc260_field: bool) -> dict[str, str] | None: - try: - parsed = json.loads(text) - except ValueError: - return None - if not isinstance(parsed, dict): - return None - fields = {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()} - if require_tc260_field and not (_TC260_FIELDS & fields.keys()): - return None - return fields - - # PNG tEXt chunk keyed "AIGC" with raw JSON (Doubao and other China gens). - # The key is generic, so require a TC260 field to avoid a false positive. try: from PIL import Image @@ -404,50 +434,9 @@ def aigc_label(image_path: Path) -> dict[str, str] | None: except Exception as exc: logger.debug("PIL could not open %s for AIGC chunk scan: %s", image_path, exc) value = None - if isinstance(value, str) and (result := _parse(value, require_tc260_field=True)): - return result - - # XMP TC260:AIGC, namespaced (unambiguous) in either serialization RDF allows: - # an element {...} or an attribute TC260:AIGC="{...}" - # (the attribute form is what PicWish writes). Both are HTML-entity encoded. data = scan_head(image_path) - match = re.search( - rb'(.*?)|TC260:AIGC\s*=\s*"(.*?)"', - data, - re.DOTALL, - ) - if match: - body = match.group(1) if match.group(1) is not None else match.group(2) - return _parse(html.unescape(body.decode("utf-8", "replace")), require_tc260_field=False) - - # Generic raw-JSON forms the PNG-chunk and XMP paths above both miss, each - # gated on a TC260 field: the ``"AIGC":{...}`` key wrapper (as written into - # JPEG EXIF UserComment) and the bare ``AIGC{...}`` blob (the label glued - # straight to its JSON, no key wrapper, in a JPEG APP segment near the JFIF - # header). `raw_decode` brace-matches the inner object (respecting nested - # braces / quoted strings); `_parse` applies the same dict coercion + TC260 - # gate as the PNG-chunk path. A non-matching hit (no TC260 field, or an - # undecodable brace) must FALL THROUGH to the next form, never short-circuit: - # a quoted ``"AIGC"`` can appear later in an XMP packet while the real label - # is a bare ``AIGC{...}`` blob earlier in the file, so an unconditional return - # on the quoted form would shadow the bare form. - text = data.decode("latin-1") - for needle in ('"AIGC"', "AIGC{"): - start = text.find(needle) - if start == -1: - continue - # First brace at/after the needle: the object brace for ``"AIGC":{`` and - # the glued brace (at start+4) for the bare ``AIGC{`` -- one search covers both. - brace = text.find("{", start) - if brace == -1: - continue - try: - _, end = json.JSONDecoder().raw_decode(text, brace) - except ValueError: - continue - if result := _parse(text[brace:end], require_tc260_field=True): - return result - return None + candidates = (value,) if isinstance(value, str) else () + return aigc_label_from_metadata(data, candidates) # C2PA "Durable Content Credentials" manifest repositories (C2PA 2.4). When the @@ -541,6 +530,16 @@ def _read_file_tail(image_path: Path, size: int) -> bytes: return b"" +def samsung_genai_in(data: bytes) -> int | None: + """Return Samsung's non-zero ``genAIType`` from collected metadata bytes.""" + if _SAMSUNG_EDITOR_MARKER not in data: + return None + match = _SAMSUNG_GENAI_RE.search(data) + if match is None: + return None + return int(match.group(1)) or None + + def samsung_genai(image_path: Path) -> int | None: """Return Samsung's non-zero ``genAIType`` value if the image carries the Galaxy AI editing marker, else None. @@ -565,12 +564,17 @@ def samsung_genai(image_path: Path) -> int | None: oversize = False if oversize: data = _read_file_tail(image_path, _QUICK_SCAN_BYTES) - if _SAMSUNG_EDITOR_MARKER not in data: + return samsung_genai_in(data) + + +def iptc_ai_system_in(data: bytes) -> str | None: + """Return an IPTC 2025.1 AI-disclosure note from collected metadata bytes.""" + if not any(marker in data for marker in IPTC_AI_FIELD_MARKERS): return None - m = _SAMSUNG_GENAI_RE.search(data) - if m is None: - return None - return int(m.group(1)) or None + match = re.search(rb"AISystemUsed[=:\s]*[\"'>]\s*([^<\"']{1,120})", data) + if match and (value := match.group(1).decode("utf-8", "replace").strip()): + return value + return "fields present" def iptc_ai_system(image_path: Path) -> str | None: @@ -583,13 +587,7 @@ def iptc_ai_system(image_path: Path) -> str | None: extractable, otherwise the literal ``"fields present"``. Container-agnostic raw-byte scan; handles both XMP element and attribute serializations. """ - data = scan_head(image_path) - if not any(marker in data for marker in IPTC_AI_FIELD_MARKERS): - return None - match = re.search(rb"AISystemUsed[=:\s]*[\"'>]\s*([^<\"']{1,120})", data) - if match and (value := match.group(1).decode("utf-8", "replace").strip()): - return value - return "fields present" + return iptc_ai_system_in(scan_head(image_path)) def synthid_source(image_path: Path) -> str | None: @@ -632,6 +630,20 @@ def synthid_source(image_path: Path) -> str | None: return ", ".join(matched) if matched else None +def generator_from_metadata(candidates: list[str], scan: bytes = b"") -> str | None: + """Return a known AI generator from collected EXIF, PNG, or XMP values.""" + from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS + + candidates.extend( + match.group(1).decode("latin1", "replace") + for match in re.finditer(rb"CreatorTool[>\"'=\s]{1,4}([^<\"']{1,80})", scan) + ) + for value in candidates: + if any(token in value.lower() for token in AI_GENERATOR_TOKENS): + return value.strip() + return None + + def exif_generator(image_path: Path) -> str | None: """Return an AI-generator name from the EXIF ``Software`` / XMP ``CreatorTool`` field (or a PNG text chunk), if it matches a known generator (see @@ -644,10 +656,6 @@ def exif_generator(image_path: Path) -> str | None: chunks rather than EXIF. Only AI tokens match, so ordinary editors (plain "Adobe Photoshop", "GIMP") are not flagged. """ - import re - - from remove_ai_watermarks.noai.constants import AI_GENERATOR_TOKENS - candidates: list[str] = [] # EXIF Software / Artist / ImageDescription (0th IFD) via PIL exif bytes, @@ -682,18 +690,12 @@ def exif_generator(image_path: Path) -> str | None: except Exception as exc: # unopenable format / malformed EXIF logger.debug("EXIF generator read failed for %s: %s", image_path, exc) - # XMP CreatorTool: text, container-agnostic (covers HEIF/JXL via raw scan). try: head = scan_head(image_path) - for match in re.finditer(rb"CreatorTool[>\"'=\s]{1,4}([^<\"']{1,80})", head): - candidates.append(match.group(1).decode("latin1", "replace")) except Exception as exc: logger.debug("XMP CreatorTool scan failed for %s: %s", image_path, exc) - - for value in candidates: - if any(token in value.lower() for token in AI_GENERATOR_TOKENS): - return value.strip() - return None + head = b"" + return generator_from_metadata(candidates, head) # xAI / Grok EXIF signature scheme. A 64+ char base64 blob after "Signature:" @@ -703,7 +705,7 @@ _XAI_SIGNATURE_RE = re.compile(r"Signature:\s*[A-Za-z0-9+/=]{64,}") _UUID_RE = re.compile(r"[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}", re.IGNORECASE) -def _is_xai_signature_pair(description: str, artist: str) -> bool: +def xai_signature_pair(description: str, artist: str) -> bool: """True if an EXIF (ImageDescription, Artist) pair is xAI/Grok's scheme.""" return _XAI_SIGNATURE_RE.match(description) is not None and _UUID_RE.fullmatch(artist) is not None @@ -739,7 +741,7 @@ def xai_signature(image_path: Path) -> bool: logger.debug("xAI-signature EXIF read failed for %s: %s", image_path, exc) return False - return _is_xai_signature_pair( + return xai_signature_pair( _exif_text(tags, piexif.ImageIFD.ImageDescription), _exif_text(tags, piexif.ImageIFD.Artist) ) @@ -794,9 +796,7 @@ def _ai_exif_targets(loaded: dict[str, Any]) -> list[tuple[str, int, bytes, str] targets.append((ifd_key, tag, value, name)) # (a) xAI / Grok: the Signature blob and the UUID Artist go together. - if _is_xai_signature_pair( - _exif_text(ifd0, piexif.ImageIFD.ImageDescription), _exif_text(ifd0, piexif.ImageIFD.Artist) - ): + if xai_signature_pair(_exif_text(ifd0, piexif.ImageIFD.ImageDescription), _exif_text(ifd0, piexif.ImageIFD.Artist)): add("0th", ifd0, piexif.ImageIFD.ImageDescription, "ImageDescription") add("0th", ifd0, piexif.ImageIFD.Artist, "Artist") # (b) known AI generator token in a 0th text tag. diff --git a/src/remove_ai_watermarks/noai/c2pa.py b/src/remove_ai_watermarks/noai/c2pa.py index 310318c..6005476 100644 --- a/src/remove_ai_watermarks/noai/c2pa.py +++ b/src/remove_ai_watermarks/noai/c2pa.py @@ -189,9 +189,8 @@ def _active_manifest(store: dict[str, Any]) -> dict[str, Any]: return cast("dict[str, Any]", active) if isinstance(active, dict) else {} -def _info_from_store_json(store_json: str) -> dict[str, Any]: - """Build the C2PA info dict from a c2pa-python manifest-store JSON string.""" - store_bytes = store_json.encode("utf-8") +def _info_from_store(store: dict[str, Any], store_bytes: bytes) -> dict[str, Any]: + """Build normalized C2PA info from one parsed manifest store.""" c2pa_info: dict[str, Any] = { "has_c2pa": True, "type": "C2PA (Coalition for Content Provenance and Authenticity)", @@ -202,13 +201,6 @@ def _info_from_store_json(store_json: str) -> dict[str, Any]: # registry scan that runs on the raw caBX chunk applies unchanged here. _populate_registry_fields(store_bytes, c2pa_info) - try: - parsed: Any = json.loads(store_json) - except (ValueError, TypeError): - return c2pa_info - if not isinstance(parsed, dict): - return c2pa_info - store = cast("dict[str, Any]", parsed) if generator := _claim_generator_from_store(store): c2pa_info["claim_generator"] = generator sig: Any = _active_manifest(store).get("signature_info") @@ -217,6 +209,44 @@ def _info_from_store_json(store_json: str) -> dict[str, Any]: return c2pa_info +def _info_from_store_json(store_json: str) -> dict[str, Any]: + """Build the C2PA info dict from a c2pa-python manifest-store JSON string.""" + store_bytes = store_json.encode("utf-8") + try: + parsed: Any = json.loads(store_json) + except (ValueError, TypeError): + parsed = {} + store = cast("dict[str, Any]", parsed) if isinstance(parsed, dict) else {} + return _info_from_store(store, store_bytes) + + +def c2pa_info_from_manifest_store(store: str | dict[str, Any]) -> dict[str, Any]: + """Build normalized C2PA evidence from an externally collected manifest store. + + ``store`` may be the JSON string returned by ``c2pa.Reader.json()`` or its + decoded dictionary form. This is the non-file-backed counterpart to + :func:`extract_c2pa_info`. + """ + if isinstance(store, dict): + parsed = store + try: + store_json = json.dumps(store, ensure_ascii=False) + except (TypeError, ValueError): + return {} + else: + store_json = store + try: + decoded: Any = json.loads(store_json) + except (TypeError, ValueError): + return {} + if not isinstance(decoded, dict): + return {} + parsed = cast("dict[str, Any]", decoded) + if not store_json or not parsed or parsed.get("error"): + return {} + return _info_from_store(parsed, store_json.encode("utf-8")) + + def extract_c2pa_info(image_path: Path) -> dict[str, Any]: """ Extract C2PA metadata information from an image. diff --git a/tests/test_identify.py b/tests/test_identify.py index caaaca8..9e7f3d8 100644 --- a/tests/test_identify.py +++ b/tests/test_identify.py @@ -23,6 +23,7 @@ from remove_ai_watermarks.identify import ( _integrity_clashes, _issuers_in, _vendor_of, + evidence_from_metadata_record, extract_provenance_evidence, has_invisible_target, identify, @@ -37,6 +38,29 @@ SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "pr class TestProvenanceEvidence: + def test_external_metadata_record_builds_equivalent_evidence(self, tmp_path: Path): + path = tmp_path / "external.jpg" + signature = "A" * 64 + artist = "c8045292-06d2-4c7d-b4f0-4f93b94e4801" + record = { + "pil": {"info:parameters": "Steps: 20, Sampler: Euler"}, + "exif": { + "0th": { + "ImageDescription": f"Signature: {signature}", + "Artist": artist, + } + }, + } + + evidence = evidence_from_metadata_record(record, path=path) + report = identify_from_evidence(evidence) + + assert evidence.path == path + assert evidence.ai_metadata["parameters"] == "Steps: 20, Sampler: Euler" + assert evidence.xai_signature is True + assert report.is_ai_generated is True + assert {signal.name for signal in report.signals} >= {"gen_params", "xai_signature"} + @pytest.mark.parametrize( "filename", [ diff --git a/uv.lock b/uv.lock index edf9f42..9d600cf 100644 --- a/uv.lock +++ b/uv.lock @@ -660,7 +660,7 @@ name = "cuda-bindings" version = "13.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "cuda-pathfinder", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "cuda-pathfinder" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" }, @@ -695,43 +695,43 @@ wheels = [ [package.optional-dependencies] cublas = [ - { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cudart = [ - { name = "nvidia-cuda-runtime", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cufft = [ - { name = "nvidia-cufft", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cufile = [ - { name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cupti = [ - { name = "nvidia-cuda-cupti", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] curand = [ - { name = "nvidia-curand", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cusolver = [ - { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cusolver", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] cusparse = [ - { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvjitlink = [ - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvrtc = [ - { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] nvtx = [ - { name = "nvidia-nvtx", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" }, + { name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" }, ] [[package]] @@ -842,8 +842,8 @@ name = "email-validator" version = "2.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "dnspython", marker = "python_full_version >= '3.12'" }, - { name = "idna", marker = "python_full_version >= '3.12'" }, + { name = "dnspython" }, + { name = "idna" }, ] sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" } wheels = [ @@ -855,7 +855,7 @@ name = "exceptiongroup" version = "1.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version < '3.11'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" } wheels = [ @@ -1192,8 +1192,8 @@ name = "inflect" version = "7.5.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "more-itertools", marker = "python_full_version >= '3.12'" }, - { name = "typeguard", marker = "python_full_version >= '3.12'" }, + { name = "more-itertools" }, + { name = "typeguard" }, ] sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" } wheels = [ @@ -1674,7 +1674,7 @@ name = "nvidia-cublas" version = "13.1.1.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cuda-nvrtc" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" }, @@ -1713,7 +1713,7 @@ name = "nvidia-cudnn-cu13" version = "9.20.0.48" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cublas" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" }, @@ -1725,7 +1725,7 @@ name = "nvidia-cufft" version = "12.0.0.61" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" }, @@ -1755,9 +1755,9 @@ name = "nvidia-cusolver" version = "12.0.4.66" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, - { name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-cublas" }, + { name = "nvidia-cusparse" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" }, @@ -1769,7 +1769,7 @@ name = "nvidia-cusparse" version = "12.6.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" }, + { name = "nvidia-nvjitlink" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" }, @@ -1844,11 +1844,11 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "flatbuffers", marker = "python_full_version < '3.11'" }, - { name = "numpy", marker = "python_full_version < '3.11'" }, - { name = "packaging", marker = "python_full_version < '3.11'" }, - { name = "protobuf", marker = "python_full_version < '3.11'" }, - { name = "sympy", marker = "python_full_version < '3.11'" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, + { name = "sympy" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/15/41/3253db975a90c3ce1d475e2a230773a21cd7998537f0657947df6fb79861/onnxruntime-1.24.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3e6456801c66b095c5cd68e690ca25db970ea5202bd0c5b84a2c3ef7731c5a3c", size = 17332766, upload-time = "2026-03-05T17:18:59.714Z" }, @@ -1899,10 +1899,10 @@ resolution-markers = [ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ - { name = "flatbuffers", marker = "python_full_version >= '3.11'" }, - { name = "numpy", marker = "python_full_version >= '3.11'" }, - { name = "packaging", marker = "python_full_version >= '3.11'" }, - { name = "protobuf", marker = "python_full_version >= '3.11'" }, + { name = "flatbuffers" }, + { name = "numpy" }, + { name = "packaging" }, + { name = "protobuf" }, ] wheels = [ { url = "https://files.pythonhosted.org/packages/d4/e4/5353d7e09ced4a8f473f843223fc75d726b2b5519dcefc12f22a6c92852d/onnxruntime-1.27.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8ba14a38c570087f3cdb8cfba33f7a38a1e826c1e5b29e17c28ceda0cc910016", size = 18416484, upload-time = "2026-06-15T22:43:43.894Z" }, @@ -2070,10 +2070,10 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "numpy", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "python-dateutil", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "pytz", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, - { name = "tzdata", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" }, + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "pytz" }, + { name = "tzdata" }, ] sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" } wheels = [ @@ -2143,9 +2143,9 @@ resolution-markers = [ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, - { name = "python-dateutil", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" }, - { name = "tzdata", marker = "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32')" }, + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, ] sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" } wheels = [ @@ -2624,10 +2624,10 @@ name = "pydantic" version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "annotated-types", marker = "python_full_version >= '3.12'" }, - { name = "pydantic-core", marker = "python_full_version >= '3.12'" }, - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, - { name = "typing-inspection", marker = "python_full_version >= '3.12'" }, + { name = "annotated-types" }, + { name = "pydantic-core" }, + { name = "typing-extensions" }, + { name = "typing-inspection" }, ] sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ @@ -2636,7 +2636,7 @@ wheels = [ [package.optional-dependencies] email = [ - { name = "email-validator", marker = "python_full_version >= '3.12'" }, + { name = "email-validator" }, ] [[package]] @@ -2644,7 +2644,7 @@ name = "pydantic-core" version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } wheels = [ @@ -2881,7 +2881,7 @@ resolution-markers = [ "(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')", ] dependencies = [ - { name = "numpy", marker = "python_full_version < '3.11'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/48/45/bfaaab38545a33a9f06c61211fc3bea2e23e8a8e00fedeb8e57feda722ff/pywavelets-1.8.0.tar.gz", hash = "sha256:f3800245754840adc143cbc29534a1b8fc4b8cff6e9d403326bd52b7bb5c35aa", size = 3935274, upload-time = "2024-12-04T19:54:20.593Z" } wheels = [ @@ -2946,7 +2946,7 @@ resolution-markers = [ "(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')", ] dependencies = [ - { name = "numpy", marker = "python_full_version >= '3.11'" }, + { name = "numpy" }, ] sdist = { url = "https://files.pythonhosted.org/packages/5a/75/50581633d199812205ea8cdd0f6d52f12a624886b74bf1486335b67f01ff/pywavelets-1.9.0.tar.gz", hash = "sha256:148d12203377772bea452a59211d98649c8ee4a05eff019a9021853a36babdc8", size = 3938340, upload-time = "2025-08-04T16:20:04.978Z" } wheels = [ @@ -3187,7 +3187,7 @@ wheels = [ [[package]] name = "remove-ai-watermarks" -version = "0.21.0" +version = "0.21.1" source = { editable = "." } dependencies = [ { name = "c2pa-python" }, @@ -3490,7 +3490,7 @@ name = "stamina" version = "26.1.0" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "tenacity", marker = "python_full_version >= '3.12'" }, + { name = "tenacity" }, ] sdist = { url = "https://files.pythonhosted.org/packages/80/bd/b2f71ae14368a066f103d182f25bbc6c3bf4aa695889f3ed3cba026d6f36/stamina-26.1.0.tar.gz", hash = "sha256:0214d05fdf5102c518194a4aac7520ce53cf660550ae3b940701aad88cf50c17", size = 568171, upload-time = "2026-04-13T17:44:31.012Z" } wheels = [ @@ -3790,7 +3790,7 @@ name = "typeguard" version = "4.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240, upload-time = "2026-05-14T12:59:40.857Z" } wheels = [ @@ -3826,7 +3826,7 @@ name = "typing-inspection" version = "0.4.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "typing-extensions", marker = "python_full_version >= '3.12'" }, + { name = "typing-extensions" }, ] sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" } wheels = [ @@ -3856,10 +3856,10 @@ name = "uv-outdated" version = "1.0.4" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pydantic", marker = "python_full_version >= '3.12'" }, - { name = "rich", marker = "python_full_version >= '3.12'" }, - { name = "typer", marker = "python_full_version >= '3.12'" }, + { name = "packaging" }, + { name = "pydantic" }, + { name = "rich" }, + { name = "typer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/38/84/78736b81c0e6ebefd3810b04a3bc6cb82bf7ea63474821b02d5cd9040439/uv_outdated-1.0.4.tar.gz", hash = "sha256:126745028823d8d452a82faaf53ea1d4ab5cdea7bba3159fc2ce7e5d0443146c", size = 19176, upload-time = "2025-12-25T10:54:22.77Z" } wheels = [ @@ -3871,18 +3871,18 @@ name = "uv-secure" version = "0.17.2" source = { registry = "https://pypi.org/simple" } dependencies = [ - { name = "anyio", marker = "python_full_version >= '3.12'" }, - { name = "cvss", marker = "python_full_version >= '3.12'" }, - { name = "httpx", marker = "python_full_version >= '3.12'" }, - { name = "humanize", marker = "python_full_version >= '3.12'" }, - { name = "inflect", marker = "python_full_version >= '3.12'" }, - { name = "orjson", marker = "python_full_version >= '3.12'" }, - { name = "packaging", marker = "python_full_version >= '3.12'" }, - { name = "pydantic", extra = ["email"], marker = "python_full_version >= '3.12'" }, - { name = "rich", marker = "python_full_version >= '3.12'" }, - { name = "stamina", marker = "python_full_version >= '3.12'" }, - { name = "tomlkit", marker = "python_full_version >= '3.12'" }, - { name = "typer", marker = "python_full_version >= '3.12'" }, + { name = "anyio" }, + { name = "cvss" }, + { name = "httpx" }, + { name = "humanize" }, + { name = "inflect" }, + { name = "orjson" }, + { name = "packaging" }, + { name = "pydantic", extra = ["email"] }, + { name = "rich" }, + { name = "stamina" }, + { name = "tomlkit" }, + { name = "typer" }, ] sdist = { url = "https://files.pythonhosted.org/packages/71/99/29318cedfc5583cf2d503f0eedb9c4e96829541c356ce5d2aacfe09ef67f/uv_secure-0.17.2.tar.gz", hash = "sha256:e394939e0872df392d8f650d15ac1571b9267fc2f3671a183aa73c0977f0f402", size = 47240, upload-time = "2026-04-18T08:45:38.185Z" } wheels = [