Compare commits

...
9 Commits
16 changed files with 860 additions and 231 deletions

No files matched your search

+75 -2
View File
@@ -3,10 +3,11 @@ name: Distribute on release
# Fans a published GitHub Release out to the channels that need a nudge.
# PyPI is handled by publish.yml; the conda-forge channel is handled by its
# autotick bot. This workflow updates the repository recipe and event-drives
# the two channels that would otherwise be manual:
# the three channels that would otherwise be manual:
# - Homebrew tap: rewrite the formula's url + sha256 to the new sdist.
# - HF Space: factory-rebuild so it reinstalls the latest sdist from PyPI.
# Both wait for the freshly published sdist to appear on PyPI first, since the
# - ComfyUI: sync, test, and publish the node against the exact library release.
# All wait for the freshly published sdist to appear on PyPI first, since the
# Release event fires in parallel with publish.yml's upload.
on:
@@ -133,3 +134,75 @@ jobs:
)
print("HF Space factory rebuild triggered")
PY
comfyui:
needs: resolve
runs-on: ubuntu-latest
steps:
- name: Sync and publish the ComfyUI nodes
env:
GH_TOKEN: ${{ secrets.COMFYUI_RELEASE_TOKEN }}
run: |
set -euo pipefail
repo=wiltodelta/ComfyUI-remove-ai-watermarks
workflow=sync-library.yml
dispatched_at=$(date -u +%Y-%m-%dT%H:%M:%SZ)
gh workflow run "$workflow" \
--repo "$repo" \
--ref main \
--raw-field library_version="${{ needs.resolve.outputs.version }}"
run_id=""
for attempt in $(seq 1 30); do
run_id=$(
gh run list \
--repo "$repo" \
--workflow "$workflow" \
--event workflow_dispatch \
--limit 10 \
--json databaseId,createdAt \
| python3 -c '
import json
import sys
dispatched_at = sys.argv[1]
runs = json.load(sys.stdin)
print(next(
(run["databaseId"] for run in runs if run["createdAt"] >= dispatched_at),
"",
))
' "$dispatched_at"
)
if [ -n "$run_id" ]; then
break
fi
echo "ComfyUI sync run not visible yet (attempt $attempt), waiting..."
sleep 2
done
if [ -z "$run_id" ]; then
echo "::error::The dispatched ComfyUI sync run did not appear"
exit 1
fi
for attempt in $(seq 1 180); do
read -r status conclusion <<< "$(
gh run view "$run_id" \
--repo "$repo" \
--json status,conclusion \
--jq '[.status, (.conclusion // "")] | @tsv'
)"
if [ "$status" = "completed" ]; then
if [ "$conclusion" = "success" ]; then
echo "ComfyUI sync run $run_id succeeded"
exit 0
fi
echo "::error::ComfyUI sync run $run_id completed with $conclusion"
exit 1
fi
echo "ComfyUI sync run $run_id is $status (attempt $attempt), waiting..."
sleep 5
done
echo "::error::ComfyUI sync run $run_id did not finish in time"
exit 1
+2 -2
View File
@@ -56,8 +56,8 @@ 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. `metadata --remove` and `batch --mode metadata|all` both re-scan the OUTPUT through it and fail loudly; verified on Samsung Galaxy S22 C2PA PNGs, where the command printed "stripped" and exited 0 while the output still read as AI (2026-07-19). **`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`. 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 Line truncated
- `identify.py` — aggregates every locally-readable signal into one `ProvenanceReport`; `is_ai_generated` is True or None, never asserted False. `ProvenanceReport.ai_source_kind` exposes the C2PA digital-source-type split — `"generated"` (trainedAlgorithmicMedia, fully AI) vs `"enhanced"` (compositeWithTrainedAlgorithmicMedia, a real photo with an AI-composited region), else None — so a caller branches full-frame scrub vs region-targeted clean (see `noai/tiling.feather_region_composite` + `WatermarkRemover.remove_watermark(region=...)`). The sparkle provenance threshold is the SHARED `watermark_registry.GEMINI_SPARKLE_TRUST_CONF` (imported, not a private copy) so the provenance "is there a sparkle" verdict and the removal "take the sparkle" decision can never drift. `import identify` is deliberately light (lazy `noai/__init__`, fits a 512 MB host) — keep heavy imports out (the `watermark_registry` constant import stays light: engines are lazy there). Add capture-camera tokens to `_DEVICE_C2PA_PLATFORM` only when verified against a real C2PA file; editing-app/AI-device signer tokens go to `_SIGNER_C2PA_PLATFORM`; generator/issuer platforms to `C2PA_AI_VENDORS` in `constants.py`. The IPTC `digitalSourceType` **`algorithmicMedia`** (bare) is PROCEDURAL (an algorithm not trained on sampled data), NOT AI/ML generation, so it is deliberately absent from `IPTC_AI_MARKERS` — flagging it made `identify` assert AI + `has_invisible_target` True, scrubbing clean procedural content (it is a distinct token from `trainedAlgorithmicMedia`, so real "Made with AI" labels are unaffected; regression `test_metadata.py::...test_bare_algorithmic_media_not_flagged_ai`). Integrity-clash detection is high-precision by design (only hard generator stamps feed it, source-grouped independence). `_vendor_of` normalizes ByteDance/Canva/ElevenLabs/Black Forest Labs (as well as OpenAI/Google/... ) so their C2PA claims participate in the clash check; the generic **China TC260 AIGC label names no specific vendor**, so when a TC260-applying vendor (ByteDance, `_TC260_VENDORS`) is co-attributed the label is attributed to it (a legit Doubao image carrying its own TC260 label must NOT clash), while a NON-TC260 vendor next to a TC260 label still clashes as a laundering tell. The vendor normalization must not introduce clashes on clean compatibility samples.
- `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` Line truncated
- `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 <text-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 thLine truncated
- `gemini_engine.py` — visible Gemini-sparkle detector + localizer (cv2/numpy, no GPU): top-K size-weighted fusion candidate selection (`_SELECT_TOPK`), corner-promote, false-positive gate (the provenance prior relaxes the gate + lowers the trust threshold when a Google/Gemini C2PA issuer confirms the vendor). **White-core rescue:** the FP gate demotes a low-gradient match (soft edges), but a real FAINT sparkle also has soft edges -- so the gate keeps a low-grad match that is a strong (conf ≥ `_SPARKLE_KEEP_CONF` 0.52), bright (margin), near-WHITE-core sparkle (`_core_saturation` ≤ `_SPARKLE_WHITE_SAT` 0.20): a real sparkle core is white, a clean bright corner that shape-matches (sky/sun) is colored. This recovers ~14/20 metadata-stripped faint sparkles under the DEFAULT strict/auto (no flag, no metadata) at ~1.25% clean false-fire (baseline 0.55%); the ~0.51-scoring bright-bg FPs stay demoted (below 0.52). A learned classifier on the SAME features was measured WORSE than the tuned gate (2026-07 tier-1: MLP 86.7% recall vs 90.8% at equal FP), so the heuristic stays; a patch-CNN with richer features is the only lever left (roadmapped P2, low expected value -- the wall is fundamental). Detection scores the top-K size-weighted matches by full fusion (spatial+gradient+variance) and keeps the highest — NOT the raw-NCC argmax, which re-admits the tiny-patch FPs the size weight suppresses (the osachub 2026-06-12 sub-0.85 corner-sparkle regression; see `docs/module-internals.md`). Keep the 0.85 corner-promote NCC gate; a margin/chroma-gated lower promote was measured and REJECTED 2026-06-11 (~33% FP on non-Google content). Removal is localize -> fill: `footprint_mask` returns the sparkle footprint (the captured alpha thresholded LOW so the faint halo is included, then dilated by a sparkle-relative margin), and the shared `watermark_registry.fill` inpaints it. The captured alpha maps are used only to detect and to shape the mask, not for pixel recovery. `detect_sparkle_confidence` reuses a process-wide `_shared_engine()` singleton (lru_cache) — the engine holds only constant assets (captures, alpha maps, a precomputed 16..118 template ladder) and takes the image as an arg, so do NOT reconstruct `GeminiEngine()` per call: that reloaded assets + recomputed alpha maps + rebuilt the template cache on every one of ~34k `identify` calls (−24% on the sparkle path once made a singleton, output byte-identical). `detect_watermark`/`footprint_mask` guard `image.size == 0` before `to_bgr`, and return an empty (detected=False) result when no template scale fits (short side < 16 px), rather than dereferencing an empty candidate list.
- `_text_mark_engine.py` — shared base for the 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).
+4 -1
View File
@@ -178,7 +178,10 @@ cannot parse the input.
`remove_ai_metadata` may copy an undecodable file through unchanged instead of
raising. User facing callers must use `strip_and_verify` and inspect its
surviving marker mapping before reporting success. The CLI does this.
surviving marker mapping before reporting success. `strip_and_verify` recovers
when `image_io` can still decode the raster by normalizing the container and
checking again. A truly undecodable file still reports the surviving markers.
The CLI uses this verified path.
### Sixteen bit PNG output is not preserved
+16 -4
View File
@@ -114,7 +114,10 @@ Key contracts:
- The low-level remover is fail-safe and can copy an undecodable file through
unchanged.
- A caller that reports success must use `strip_and_verify`, which scans the
written output for surviving markers.
written output for surviving markers. If the metadata-preserving decoder
rejected the container but `image_io` can still decode its raster,
`strip_and_verify` normalizes that raster and scans again. A truly undecodable
file keeps the surviving-marker result.
Detection and removal must stay in parity. A new marker is incomplete until the
scanner can find it, the remover can reach every supported placement, and a
@@ -128,9 +131,18 @@ 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`.
- `evidence_from_metadata_record` normalizes an externally collected nested
metadata record into the same evidence type without file access. Diagnostic
values under `error` and `kind` are excluded from evidence while nested raw
bytes remain available through encoded binary fields.
- `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
+48 -3
View File
@@ -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,48 @@ 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)
```
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`. Diagnostic values under `error` and `kind` are ignored because they
describe the collector rather than the source file. 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.
## Strip metadata
```python
@@ -119,7 +161,10 @@ if has_ai_metadata(source):
Use `strip_and_verify` when your application reports that stripping succeeded.
It checks the written output and returns `(output_path, surviving_markers)`.
Treat a nonempty `surviving_markers` mapping as a failure.
When the first strip leaves markers in a malformed but raster-decodable image,
it normalizes the container through `image_io` and checks again. That recovery
path preserves the pixels but drops standard metadata. Treat a nonempty
`surviving_markers` mapping as a failure.
`remove_ai_metadata` is the lower level fail-safe transformer. It may copy an
undecodable input through unchanged, so its return alone must not be presented
+10 -6
View File
@@ -77,12 +77,15 @@ The package uses hatchling through the unpinned `hatchling` build requirement in
The repository includes a conda recipe under `packaging/conda/recipe.yaml`.
The ComfyUI nodes are maintained and versioned in their own repository. Its
scheduled workflow detects a newer PyPI library release, updates the dependency
floor, runs compatibility tests, bumps the node patch version, and publishes to
the ComfyUI Registry only when those tests pass. The library release event does
not publish the node package directly, so the registry update follows on that
schedule rather than inside `distribute.yml`.
The ComfyUI nodes are maintained and versioned in their own repository. After
the matching source distribution appears on PyPI, `distribute.yml` dispatches
that repository's sync workflow with the exact library version and waits for it
to finish. The sync updates the dependency floor, runs compatibility tests,
bumps the node patch version, and publishes to the ComfyUI Registry only when
those tests pass. Its daily schedule remains as a recovery path if a release
dispatch is interrupted. The `COMFYUI_RELEASE_TOKEN` repository secret is a
fine-grained token limited to the ComfyUI node repository, with Actions read and
write access.
## Release verification
@@ -94,4 +97,5 @@ After publication, verify:
- the distribution workflow completed successfully;
- the repository's conda recipe matches the published version and source
distribution;
- the ComfyUI Registry node requires the new library version;
- a clean install can run `remove-ai-watermarks --version`.
+1 -1
View File
@@ -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 `<TC260:AIGC>{"Label":"1","ContentProducer":...}` block — China's mandatory AI-content labeling (TC260 namespace `tc260.org.cn/ns/AIGC`).
+2 -2
View File
@@ -1,7 +1,7 @@
schema_version: 1
context:
version: "0.20.0"
version: "0.21.0"
python_min: "3.10"
package:
@@ -10,7 +10,7 @@ package:
source:
url: https://pypi.org/packages/source/r/remove-ai-watermarks/remove_ai_watermarks-${{ version }}.tar.gz
sha256: 28eb7aaeace7cfa50fe158ed6da72a4afa526d4be11e9f9c614b90edbaccf0b4
sha256: 69eaa8cbb25eec113916358bad4baf4d067cb41854f92d389d2b5c0f22f94f94
build:
noarch: python
+1 -1
View File
@@ -1,6 +1,6 @@
[project]
name = "remove-ai-watermarks"
version = "0.20.1"
version = "0.21.2"
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"
+1 -1
View File
@@ -25,7 +25,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.20.1"
__version__ = "0.21.2"
__all__ = ["__version__", "remove_visible", "visible_provenance"]
+297 -34
View File
@@ -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
@@ -132,6 +143,207 @@ 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
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] = []
diagnostic_keys = {"error", "kind"}
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 key_text.lower() in diagnostic_keys:
continue
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."""
@@ -166,6 +378,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 +777,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 +912,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 +928,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 +949,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 +961,7 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
# Grok's only provenance signal: EXIF ImageDescription "Signature: <base64>"
# + 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 +972,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 +988,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 +998,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 +1009,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 +1030,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 +1055,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 +1070,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.
+113 -90
View File
@@ -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>(.*?)</TC260:AIGC>|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 <TC260:AIGC>{...}</TC260:AIGC> 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>(.*?)</TC260:AIGC>|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.
@@ -1091,9 +1091,32 @@ def strip_and_verify(
exposed this when `metadata --remove` reported success while the output still read
as AI.
If markers survive but OpenCV can still decode the raster, the verified path
normalizes the container through :mod:`image_io` and scans once more. This
intentionally drops standard metadata on that recovery path because the original
container is too malformed for the metadata-preserving decoder.
Returns ``(output_path, surviving_markers)``; an empty mapping means a real strip.
"""
out = remove_ai_metadata(source_path, output_path, keep_standard=keep_standard)
remaining = get_ai_metadata(out)
if not remaining:
return out, {}
import cv2
from remove_ai_watermarks import image_io
image = image_io.imread(out, cv2.IMREAD_UNCHANGED)
if image is None:
return out, remaining
logger.warning(
"AI metadata survived stripping; normalizing decodable raster: path=%s fields=%s",
out,
",".join(sorted(remaining)),
)
if not image_io.imwrite(out, image):
raise OSError(f"Failed to normalize image after incomplete metadata stripping: {out}")
return out, get_ai_metadata(out)
+40 -10
View File
@@ -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.
+120
View File
@@ -6,6 +6,7 @@ against the real committed C2PA / IPTC fixtures in data/fixtures/provenance/.
from __future__ import annotations
import base64
import json
import subprocess
import sys
@@ -16,14 +17,18 @@ 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,
evidence_from_metadata_record,
extract_provenance_evidence,
has_invisible_target,
identify,
identify_from_evidence,
)
from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
@@ -33,6 +38,121 @@ _SPARKLE_TARGET = "remove_ai_watermarks.gemini_engine.detect_sparkle_confidence"
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
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"}
def test_external_scanner_diagnostics_do_not_create_c2pa_evidence(self, tmp_path: Path):
path = tmp_path / "plain.jpg"
record = {
"c2pa_store": {"error": "ManifestNotFound: no JUMBF data found"},
"jpeg": {
"segments": [
{
"marker": "APP11",
"kind": "c2pa_or_jumbf",
"base64": "AAA=",
}
]
},
}
report = identify_from_evidence(evidence_from_metadata_record(record, path=path))
assert report.is_ai_generated is None
assert report.signals == []
assert report.watermarks == []
def test_external_scanner_raw_bytes_still_create_c2pa_evidence(self, tmp_path: Path):
path = tmp_path / "signed.jpg"
manifest = b"jumb c2pa OpenAI trainedAlgorithmicMedia"
record = {
"jpeg": {
"segments": [
{
"marker": "APP11",
"kind": "c2pa_or_jumbf",
"base64": base64.b64encode(manifest).decode(),
}
]
}
}
report = identify_from_evidence(evidence_from_metadata_record(record, path=path))
assert report.is_ai_generated is True
assert report.platform == "OpenAI (ChatGPT / gpt-image / DALL-E / Sora)"
assert [signal.name for signal in report.signals] == ["c2pa"]
@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) ─────────────────────────────
+56
View File
@@ -22,6 +22,7 @@ from remove_ai_watermarks.metadata import (
iptc_ai_system,
remove_ai_metadata,
samsung_genai,
strip_and_verify,
synthid_source,
xai_signature,
)
@@ -29,6 +30,28 @@ from remove_ai_watermarks.metadata import (
# Real, committed C2PA sample images used to ground the SynthID-source tests.
SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "provenance"
def _png_chunk(kind: bytes, payload: bytes, *, corrupt_crc: bool = False) -> bytes:
"""Encode one PNG chunk, optionally corrupting its CRC."""
import zlib
crc = zlib.crc32(kind + payload) & 0xFFFFFFFF
if corrupt_crc:
crc ^= 1
return struct.pack(">I", len(payload)) + kind + payload + struct.pack(">I", crc)
def _corrupt_c2pa_png(path: Path) -> Path:
"""Write a synthetic PNG that Pillow rejects but OpenCV can decode."""
Image.new("RGB", (32, 32), (80, 120, 160)).save(path)
png = path.read_bytes()
idat_start = png.index(b"IDAT") - 4
fake_c2pa = _png_chunk(b"caBX", b"\x00\x00\x00\x10jumbsynthetic-c2pa")
bad_exif = _png_chunk(b"eXIf", b"Exif\x00\x00synthetic", corrupt_crc=True)
path.write_bytes(png[:idat_start] + fake_c2pa + bad_exif + png[idat_start:])
return path
# ── Key detection ───────────────────────────────────────────────────
@@ -276,6 +299,39 @@ class TestHasAiMetadata:
assert result == out
assert out.read_bytes() == truncated.read_bytes()
def test_strip_and_verify_normalizes_decodable_copy_through(self, tmp_path: Path):
"""A decodable raster must not retain C2PA after verified stripping."""
import numpy as np
from remove_ai_watermarks import image_io
src = _corrupt_c2pa_png(tmp_path / "source.png")
out = tmp_path / "clean.png"
assert has_ai_metadata(src)
before = image_io.imread(src)
assert before is not None
result, remaining = strip_and_verify(src, out)
assert result == out
assert remaining == {}
assert not has_ai_metadata(out)
after = image_io.imread(out)
assert after is not None
assert np.array_equal(after, before)
def test_strip_and_verify_reports_markers_in_undecodable_copy_through(self, tmp_path: Path):
"""A truly undecodable file keeps the established fail-safe result."""
src = _corrupt_c2pa_png(tmp_path / "source.png")
src.write_bytes(src.read_bytes()[: src.stat().st_size // 2])
out = tmp_path / "copy.png"
result, remaining = strip_and_verify(src, out)
assert result == out
assert "c2pa_manifest" in remaining
assert out.read_bytes() == src.read_bytes()
class TestC2paMarkerIn:
"""The C2PA presence check requires a JUMBF wrapper or the C2PA uuid box, so
Generated
+74 -74
View File
@@ -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.20.1"
version = "0.21.2"
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 = [