mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
Add external metadata evidence ingestion
This commit is contained in:
@@ -136,6 +136,8 @@ metadata extraction from verdict logic:
|
||||
|
||||
- `extract_provenance_evidence` reads the supported metadata signals into
|
||||
`ProvenanceEvidence`.
|
||||
- `evidence_from_metadata_record` normalizes an externally collected nested
|
||||
metadata record into the same evidence type without file access.
|
||||
- `identify_from_evidence` evaluates that evidence without reopening the source.
|
||||
- `identify` preserves the path-based API and adds the optional registered
|
||||
visible-mark and open invisible-watermark decoders after extraction.
|
||||
|
||||
@@ -113,6 +113,28 @@ evidence = extract_provenance_evidence(Path("input.png"))
|
||||
report = identify_from_evidence(evidence)
|
||||
```
|
||||
|
||||
If metadata was collected by another component, normalize its nested record
|
||||
without reopening the original file:
|
||||
|
||||
```python
|
||||
from remove_ai_watermarks.identify import (
|
||||
evidence_from_metadata_record,
|
||||
identify_from_evidence,
|
||||
)
|
||||
|
||||
record = {
|
||||
"pil": {"info:parameters": "Steps: 20, Sampler: Euler"},
|
||||
"exif": {"0th": {"Software": "Stable Diffusion"}},
|
||||
}
|
||||
evidence = evidence_from_metadata_record(record, path=Path("input.png"))
|
||||
report = identify_from_evidence(evidence)
|
||||
```
|
||||
|
||||
The normalizer recursively preserves text and byte values. It also decodes
|
||||
strings prefixed with `hex:` and fields named `base64` or ending in
|
||||
`_base64`. Pass a C2PA manifest-store dictionary in `record["c2pa_store"]`, or
|
||||
through the explicit `c2pa_manifest_store` argument.
|
||||
|
||||
`identify_from_evidence` does not reopen the source file. It evaluates metadata
|
||||
only; registered visible marks and pixel-backed invisible watermarks remain in
|
||||
the path-based `identify` call.
|
||||
|
||||
@@ -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`).
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.21.0"
|
||||
version = "0.21.1"
|
||||
description = "AI watermark remover: strip visible and invisible AI watermarks (Gemini / Nano Banana sparkle, SynthID) and provenance metadata (C2PA, EXIF) from images"
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.10.1"
|
||||
|
||||
@@ -25,7 +25,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
|
||||
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
|
||||
|
||||
|
||||
__version__ = "0.21.0"
|
||||
__version__ = "0.21.1"
|
||||
|
||||
__all__ = ["__version__", "remove_visible", "visible_provenance"]
|
||||
|
||||
|
||||
@@ -19,10 +19,12 @@ never as "clean". See CLAUDE.md "SynthID detection is metadata-only".
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
import contextlib
|
||||
import itertools
|
||||
import logging
|
||||
from dataclasses import dataclass, field
|
||||
from typing import TYPE_CHECKING
|
||||
from typing import TYPE_CHECKING, Any, cast
|
||||
|
||||
from remove_ai_watermarks.metadata import (
|
||||
AI_METADATA_KEYS,
|
||||
@@ -30,17 +32,27 @@ from remove_ai_watermarks.metadata import (
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
aigc_label,
|
||||
aigc_label_from_metadata,
|
||||
c2pa_cloud_manifest_in,
|
||||
c2pa_marker_in,
|
||||
exif_generator,
|
||||
generator_from_metadata,
|
||||
get_ai_metadata,
|
||||
huggingface_job,
|
||||
iptc_ai_system,
|
||||
iptc_ai_system_in,
|
||||
samsung_genai,
|
||||
samsung_genai_in,
|
||||
scan_head,
|
||||
xai_signature,
|
||||
xai_signature_pair,
|
||||
)
|
||||
from remove_ai_watermarks.noai.c2pa import (
|
||||
c2pa_info_from_manifest_store,
|
||||
cbor_text_after,
|
||||
extract_c2pa_info,
|
||||
soft_binding_vendors_in,
|
||||
)
|
||||
from remove_ai_watermarks.noai.c2pa import cbor_text_after, extract_c2pa_info, soft_binding_vendors_in
|
||||
from remove_ai_watermarks.noai.constants import (
|
||||
C2PA_AI_TOOLS,
|
||||
C2PA_AI_VENDORS,
|
||||
@@ -51,7 +63,6 @@ from remove_ai_watermarks.watermark_registry import GEMINI_SPARKLE_TRUST_CONF
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from numpy.typing import NDArray
|
||||
|
||||
@@ -154,6 +165,182 @@ class ProvenanceEvidence:
|
||||
samsung_genai: int | None
|
||||
|
||||
|
||||
def _external_metadata(value: Any) -> tuple[list[tuple[str, Any]], bytes]:
|
||||
"""Index nested metadata and recover common encoded binary values in one pass."""
|
||||
pairs: list[tuple[str, Any]] = []
|
||||
parts: list[bytes] = []
|
||||
|
||||
def visit(item: Any) -> None:
|
||||
if isinstance(item, dict):
|
||||
mapping = cast("dict[object, Any]", item)
|
||||
for key, nested in mapping.items():
|
||||
key_text = str(key)
|
||||
pairs.append((key_text, nested))
|
||||
parts.append(key_text.encode("utf-8", "replace"))
|
||||
if isinstance(nested, str) and (key_text == "base64" or key_text.endswith("_base64")):
|
||||
encoded = nested.split("...TRUNCATED", 1)[0]
|
||||
with contextlib.suppress(ValueError, TypeError):
|
||||
parts.append(base64.b64decode(encoded, validate=True))
|
||||
visit(nested)
|
||||
elif isinstance(item, (list, tuple)):
|
||||
sequence = cast("list[Any] | tuple[Any, ...]", item)
|
||||
for nested in sequence:
|
||||
visit(nested)
|
||||
elif isinstance(item, bytes):
|
||||
parts.append(item)
|
||||
elif isinstance(item, str):
|
||||
parts.append(item.encode("utf-8", "replace"))
|
||||
if item.startswith("hex:"):
|
||||
with contextlib.suppress(ValueError):
|
||||
parts.append(bytes.fromhex(item[4:]))
|
||||
elif item is not None:
|
||||
parts.append(str(item).encode("utf-8", "replace"))
|
||||
|
||||
visit(value)
|
||||
return pairs, b"\n".join(parts)
|
||||
|
||||
|
||||
def _external_text(value: Any) -> str:
|
||||
if isinstance(value, bytes):
|
||||
return value.decode("latin-1", "replace").strip()
|
||||
if not isinstance(value, str):
|
||||
return str(value).strip()
|
||||
if value.startswith("hex:"):
|
||||
try:
|
||||
return bytes.fromhex(value[4:]).decode("latin-1", "replace").strip()
|
||||
except ValueError:
|
||||
pass
|
||||
return value.strip()
|
||||
|
||||
|
||||
def _external_exif_generator(pairs: list[tuple[str, Any]], scan: bytes) -> str | None:
|
||||
candidate_keys = {
|
||||
"software",
|
||||
"make",
|
||||
"artist",
|
||||
"imagedescription",
|
||||
"source",
|
||||
"title",
|
||||
"description",
|
||||
"creatortool",
|
||||
}
|
||||
candidates = [
|
||||
str(value)
|
||||
for key, value in pairs
|
||||
if key.lower().removeprefix("info:") in candidate_keys and isinstance(value, (str, bytes))
|
||||
]
|
||||
return generator_from_metadata(candidates, scan)
|
||||
|
||||
|
||||
def evidence_from_metadata_record(
|
||||
record: dict[str, Any], *, path: Path, c2pa_manifest_store: str | dict[str, Any] | None = None
|
||||
) -> ProvenanceEvidence:
|
||||
"""Normalize an externally collected metadata record into provenance evidence.
|
||||
|
||||
The record may contain arbitrary nested dictionaries and lists. Text, bytes,
|
||||
hexadecimal values prefixed with ``hex:``, and fields named ``base64`` or
|
||||
ending in ``_base64`` are included in the shared byte scan. No source file is
|
||||
opened.
|
||||
"""
|
||||
pairs, scan = _external_metadata(record)
|
||||
store = c2pa_manifest_store
|
||||
if store is None:
|
||||
candidate = record.get("c2pa_store")
|
||||
store = (
|
||||
cast("dict[str, Any]", candidate)
|
||||
if isinstance(candidate, dict)
|
||||
else candidate
|
||||
if isinstance(candidate, str)
|
||||
else None
|
||||
)
|
||||
c2pa_info = c2pa_info_from_manifest_store(store) if store is not None else {}
|
||||
|
||||
ai_metadata: dict[str, str] = {}
|
||||
pil_info = record.get("pil")
|
||||
pil_pairs = cast("dict[str, Any]", pil_info).items() if isinstance(pil_info, dict) else ()
|
||||
for key, value in pil_pairs:
|
||||
normalized_key = key.lower().removeprefix("info:")
|
||||
if normalized_key not in AI_METADATA_KEYS or isinstance(value, (dict, list, tuple)):
|
||||
continue
|
||||
text = value.decode("utf-8", "replace") if isinstance(value, bytes) else str(value)
|
||||
ai_metadata.setdefault(normalized_key, text[:200] + ("…" if len(text) > 200 else ""))
|
||||
for key, value in pairs:
|
||||
if key != "text" or not isinstance(value, str) or "\x00" not in value:
|
||||
continue
|
||||
metadata_key, metadata_value = value.split("\x00", 1)
|
||||
normalized_key = metadata_key.lower()
|
||||
if normalized_key in AI_METADATA_KEYS:
|
||||
ai_metadata.setdefault(
|
||||
normalized_key,
|
||||
metadata_value[:200] + ("…" if len(metadata_value) > 200 else ""),
|
||||
)
|
||||
for key in (
|
||||
"c2pa_manifest",
|
||||
"claim_generator",
|
||||
"c2pa_spec",
|
||||
"issuer",
|
||||
"source_type",
|
||||
"actions",
|
||||
"synthid_watermark",
|
||||
"soft_binding",
|
||||
):
|
||||
if key in c2pa_info:
|
||||
ai_metadata.setdefault(key, str(c2pa_info[key]))
|
||||
|
||||
iptc_system = iptc_ai_system_in(scan)
|
||||
|
||||
values_by_key: dict[str, str] = {}
|
||||
for key, value in pairs:
|
||||
if isinstance(value, (bytes, str)):
|
||||
values_by_key.setdefault(key.lower(), _external_text(value))
|
||||
description = values_by_key.get("imagedescription", "")
|
||||
artist = values_by_key.get("artist", "")
|
||||
xai = xai_signature_pair(description, artist)
|
||||
|
||||
hf_job = next(
|
||||
(
|
||||
str(value).strip()
|
||||
for key, value in pairs
|
||||
if key.lower().removeprefix("info:") == "hf-job-id" and str(value).strip()
|
||||
),
|
||||
None,
|
||||
)
|
||||
samsung = samsung_genai_in(scan)
|
||||
|
||||
aigc_candidates = tuple(
|
||||
value for key, value in pairs if key.lower().removeprefix("info:") == "aigc" and isinstance(value, str)
|
||||
)
|
||||
aigc = aigc_label_from_metadata(scan, aigc_candidates)
|
||||
exif_gen = _external_exif_generator(pairs, scan)
|
||||
if aigc is not None:
|
||||
producer = aigc.get("ContentProducer", "")
|
||||
ai_metadata.setdefault(
|
||||
"aigc_label",
|
||||
f"China AIGC label (TC260){f'; producer {producer}' if producer else ''}",
|
||||
)
|
||||
if xai:
|
||||
ai_metadata.setdefault("xai_signature", "xAI/Grok EXIF signature (Artist UUID + Signature blob)")
|
||||
if iptc_system:
|
||||
ai_metadata.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({iptc_system})")
|
||||
if hf_job:
|
||||
ai_metadata.setdefault("huggingface_job", f"HuggingFace-hosted job ({hf_job})")
|
||||
if samsung is not None:
|
||||
ai_metadata.setdefault("samsung_genai", f"Samsung Galaxy AI editing marker (genAIType={samsung})")
|
||||
|
||||
return ProvenanceEvidence(
|
||||
path=path,
|
||||
c2pa_info=c2pa_info,
|
||||
ai_metadata=ai_metadata,
|
||||
scan=scan,
|
||||
iptc_ai_system=iptc_system,
|
||||
aigc_label=aigc,
|
||||
exif_generator=exif_gen,
|
||||
xai_signature=xai,
|
||||
huggingface_job=hf_job,
|
||||
samsung_genai=samsung,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ProvenanceReport:
|
||||
"""Aggregated provenance verdict for one image."""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -23,6 +23,7 @@ from remove_ai_watermarks.identify import (
|
||||
_integrity_clashes,
|
||||
_issuers_in,
|
||||
_vendor_of,
|
||||
evidence_from_metadata_record,
|
||||
extract_provenance_evidence,
|
||||
has_invisible_target,
|
||||
identify,
|
||||
@@ -37,6 +38,29 @@ SAMPLES_DIR = Path(__file__).resolve().parent.parent / "data" / "fixtures" / "pr
|
||||
|
||||
|
||||
class TestProvenanceEvidence:
|
||||
def test_external_metadata_record_builds_equivalent_evidence(self, tmp_path: Path):
|
||||
path = tmp_path / "external.jpg"
|
||||
signature = "A" * 64
|
||||
artist = "c8045292-06d2-4c7d-b4f0-4f93b94e4801"
|
||||
record = {
|
||||
"pil": {"info:parameters": "Steps: 20, Sampler: Euler"},
|
||||
"exif": {
|
||||
"0th": {
|
||||
"ImageDescription": f"Signature: {signature}",
|
||||
"Artist": artist,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
evidence = evidence_from_metadata_record(record, path=path)
|
||||
report = identify_from_evidence(evidence)
|
||||
|
||||
assert evidence.path == path
|
||||
assert evidence.ai_metadata["parameters"] == "Steps: 20, Sampler: Euler"
|
||||
assert evidence.xai_signature is True
|
||||
assert report.is_ai_generated is True
|
||||
assert {signal.name for signal in report.signals} >= {"gen_params", "xai_signature"}
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"filename",
|
||||
[
|
||||
|
||||
@@ -660,7 +660,7 @@ name = "cuda-bindings"
|
||||
version = "13.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "cuda-pathfinder", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
||||
{ name = "cuda-pathfinder" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a9/21/8464d133752951c154feafb3b65c297e7d80f301183d220bec4c830f1441/cuda_bindings-13.3.1-cp310-cp310-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:120fcc53d57903df529c3486962c56528cba5b7d6c57c99537320ed9922c8b86", size = 6073403, upload-time = "2026-05-29T23:11:36.22Z" },
|
||||
@@ -695,43 +695,43 @@ wheels = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
cublas = [
|
||||
{ name = "nvidia-cublas", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
]
|
||||
cudart = [
|
||||
{ name = "nvidia-cuda-runtime", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cuda-runtime", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
]
|
||||
cufft = [
|
||||
{ name = "nvidia-cufft", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cufft", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
]
|
||||
cufile = [
|
||||
{ name = "nvidia-cufile", marker = "(platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cufile", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
]
|
||||
cupti = [
|
||||
{ name = "nvidia-cuda-cupti", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cuda-cupti", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
]
|
||||
curand = [
|
||||
{ name = "nvidia-curand", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-curand", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
]
|
||||
cusolver = [
|
||||
{ name = "nvidia-cublas", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cusolver", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cublas", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cusolver", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
]
|
||||
cusparse = [
|
||||
{ name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cusparse", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
]
|
||||
nvjitlink = [
|
||||
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
]
|
||||
nvrtc = [
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
]
|
||||
nvtx = [
|
||||
{ name = "nvidia-nvtx", marker = "(python_full_version < '3.11' and platform_machine == 'AMD64' and sys_platform == 'win32') or (platform_machine == 'aarch64' and sys_platform == 'linux') or (platform_machine == 'x86_64' and sys_platform == 'linux')" },
|
||||
{ name = "nvidia-nvtx", marker = "platform_machine == 'aarch64' or platform_machine == 'x86_64'" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -842,8 +842,8 @@ name = "email-validator"
|
||||
version = "2.3.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "dnspython", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "idna", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "dnspython" },
|
||||
{ name = "idna" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/f5/22/900cb125c76b7aaa450ce02fd727f452243f2e91a61af068b40adba60ea9/email_validator-2.3.0.tar.gz", hash = "sha256:9fc05c37f2f6cf439ff414f8fc46d917929974a82244c20eb10231ba60c54426", size = 51238, upload-time = "2025-08-26T13:09:06.831Z" }
|
||||
wheels = [
|
||||
@@ -855,7 +855,7 @@ name = "exceptiongroup"
|
||||
version = "1.3.1"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version < '3.11'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/50/79/66800aadf48771f6b62f7eb014e352e5d06856655206165d775e675a02c9/exceptiongroup-1.3.1.tar.gz", hash = "sha256:8b412432c6055b0b7d14c310000ae93352ed6754f70fa8f7c34141f91c4e3219", size = 30371, upload-time = "2025-11-21T23:01:54.787Z" }
|
||||
wheels = [
|
||||
@@ -1192,8 +1192,8 @@ name = "inflect"
|
||||
version = "7.5.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "more-itertools", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "typeguard", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "more-itertools" },
|
||||
{ name = "typeguard" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/78/c6/943357d44a21fd995723d07ccaddd78023eace03c1846049a2645d4324a3/inflect-7.5.0.tar.gz", hash = "sha256:faf19801c3742ed5a05a8ce388e0d8fe1a07f8d095c82201eb904f5d27ad571f", size = 73751, upload-time = "2024-12-28T17:11:18.897Z" }
|
||||
wheels = [
|
||||
@@ -1674,7 +1674,7 @@ name = "nvidia-cublas"
|
||||
version = "13.1.1.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cuda-nvrtc", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
||||
{ name = "nvidia-cuda-nvrtc" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/a7/a1/0bd24ee8c8d03adac032fd2909426a00c88f8c57961b1277ded97f91119f/nvidia_cublas-13.1.1.3-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:b7a210458267ac818974c53038fbec2e969d5c99f305ab15c72522fa9f001dd5", size = 542848918, upload-time = "2026-04-08T18:46:22.985Z" },
|
||||
@@ -1713,7 +1713,7 @@ name = "nvidia-cudnn-cu13"
|
||||
version = "9.20.0.48"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
||||
{ name = "nvidia-cublas" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/56/c5/83384d846b2fd17c44bd499b36c75a45ed4f095fbbb2252294e89cea5c5c/nvidia_cudnn_cu13-9.20.0.48-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:e31454ae00094b0c55319d9d15b6fa2fc50a9e1c0f5c8c80fb75258234e731e1", size = 444574296, upload-time = "2026-03-09T19:28:27.751Z" },
|
||||
@@ -1725,7 +1725,7 @@ name = "nvidia-cufft"
|
||||
version = "12.0.0.61"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/8b/ae/f417a75c0259e85c1d2f83ca4e960289a5f814ed0cea74d18c353d3e989d/nvidia_cufft-12.0.0.61-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:2708c852ef8cd89d1d2068bdbece0aa188813a0c934db3779b9b1faa8442e5f5", size = 214053554, upload-time = "2025-09-04T08:31:38.196Z" },
|
||||
@@ -1755,9 +1755,9 @@ name = "nvidia-cusolver"
|
||||
version = "12.0.4.66"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-cublas", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
||||
{ name = "nvidia-cusparse", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
||||
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
||||
{ name = "nvidia-cublas" },
|
||||
{ name = "nvidia-cusparse" },
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/c8/c3/b30c9e935fc01e3da443ec0116ed1b2a009bb867f5324d3f2d7e533e776b/nvidia_cusolver-12.0.4.66-py3-none-manylinux_2_27_aarch64.whl", hash = "sha256:02c2457eaa9e39de20f880f4bd8820e6a1cfb9f9a34f820eb12a155aa5bc92d2", size = 223467760, upload-time = "2025-09-04T08:33:04.222Z" },
|
||||
@@ -1769,7 +1769,7 @@ name = "nvidia-cusparse"
|
||||
version = "12.6.3.3"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "nvidia-nvjitlink", marker = "(python_full_version < '3.11' and sys_platform == 'emscripten') or (python_full_version < '3.11' and sys_platform == 'win32') or (sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'win32')" },
|
||||
{ name = "nvidia-nvjitlink" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/f8/94/5c26f33738ae35276672f12615a64bd008ed5be6d1ebcb23579285d960a9/nvidia_cusparse-12.6.3.3-py3-none-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:80bcc4662f23f1054ee334a15c72b8940402975e0eab63178fc7e670aa59472c", size = 162155568, upload-time = "2025-09-04T08:33:42.864Z" },
|
||||
@@ -1844,11 +1844,11 @@ resolution-markers = [
|
||||
"(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "flatbuffers", marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy", marker = "python_full_version < '3.11'" },
|
||||
{ name = "packaging", marker = "python_full_version < '3.11'" },
|
||||
{ name = "protobuf", marker = "python_full_version < '3.11'" },
|
||||
{ name = "sympy", marker = "python_full_version < '3.11'" },
|
||||
{ name = "flatbuffers" },
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
{ name = "sympy" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/15/41/3253db975a90c3ce1d475e2a230773a21cd7998537f0657947df6fb79861/onnxruntime-1.24.3-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:3e6456801c66b095c5cd68e690ca25db970ea5202bd0c5b84a2c3ef7731c5a3c", size = 17332766, upload-time = "2026-03-05T17:18:59.714Z" },
|
||||
@@ -1899,10 +1899,10 @@ resolution-markers = [
|
||||
"(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "flatbuffers", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "numpy", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "protobuf", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "flatbuffers" },
|
||||
{ name = "numpy" },
|
||||
{ name = "packaging" },
|
||||
{ name = "protobuf" },
|
||||
]
|
||||
wheels = [
|
||||
{ url = "https://files.pythonhosted.org/packages/d4/e4/5353d7e09ced4a8f473f843223fc75d726b2b5519dcefc12f22a6c92852d/onnxruntime-1.27.0-cp311-cp311-macosx_14_0_arm64.whl", hash = "sha256:8ba14a38c570087f3cdb8cfba33f7a38a1e826c1e5b29e17c28ceda0cc910016", size = 18416484, upload-time = "2026-06-15T22:43:43.894Z" },
|
||||
@@ -2070,10 +2070,10 @@ resolution-markers = [
|
||||
"(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" },
|
||||
{ name = "python-dateutil", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" },
|
||||
{ name = "pytz", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" },
|
||||
{ name = "tzdata", marker = "python_full_version < '3.11' or python_full_version >= '3.14'" },
|
||||
{ name = "numpy" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "pytz" },
|
||||
{ name = "tzdata" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/33/01/d40b85317f86cf08d853a4f495195c73815fdf205eef3993821720274518/pandas-2.3.3.tar.gz", hash = "sha256:e05e1af93b977f7eafa636d043f9f94c7ee3ac81af99c13508215942e64c993b", size = 4495223, upload-time = "2025-09-29T23:34:51.853Z" }
|
||||
wheels = [
|
||||
@@ -2143,9 +2143,9 @@ resolution-markers = [
|
||||
"(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
|
||||
{ name = "python-dateutil", marker = "python_full_version >= '3.11' and python_full_version < '3.14'" },
|
||||
{ name = "tzdata", marker = "(python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'emscripten') or (python_full_version >= '3.11' and python_full_version < '3.14' and sys_platform == 'win32')" },
|
||||
{ name = "numpy" },
|
||||
{ name = "python-dateutil" },
|
||||
{ name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/be/4f/5f3422a2afec5ffc46308b79e53291365a93748b498ac2e58bead0197916/pandas-3.0.5.tar.gz", hash = "sha256:dca3734d6ab7c906e6730f0788b0a1dbb9f2467731f9711f77995c8e9d62d712", size = 4658219, upload-time = "2026-07-22T22:19:28.819Z" }
|
||||
wheels = [
|
||||
@@ -2624,10 +2624,10 @@ name = "pydantic"
|
||||
version = "2.13.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "annotated-types", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "pydantic-core", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "typing-inspection", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "annotated-types" },
|
||||
{ name = "pydantic-core" },
|
||||
{ name = "typing-extensions" },
|
||||
{ name = "typing-inspection" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" }
|
||||
wheels = [
|
||||
@@ -2636,7 +2636,7 @@ wheels = [
|
||||
|
||||
[package.optional-dependencies]
|
||||
email = [
|
||||
{ name = "email-validator", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "email-validator" },
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -2644,7 +2644,7 @@ name = "pydantic-core"
|
||||
version = "2.46.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" }
|
||||
wheels = [
|
||||
@@ -2881,7 +2881,7 @@ resolution-markers = [
|
||||
"(python_full_version < '3.11' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version < '3.11' and sys_platform != 'darwin' and sys_platform != 'linux')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "python_full_version < '3.11'" },
|
||||
{ name = "numpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/48/45/bfaaab38545a33a9f06c61211fc3bea2e23e8a8e00fedeb8e57feda722ff/pywavelets-1.8.0.tar.gz", hash = "sha256:f3800245754840adc143cbc29534a1b8fc4b8cff6e9d403326bd52b7bb5c35aa", size = 3935274, upload-time = "2024-12-04T19:54:20.593Z" }
|
||||
wheels = [
|
||||
@@ -2946,7 +2946,7 @@ resolution-markers = [
|
||||
"(python_full_version == '3.11.*' and platform_machine != 'aarch64' and sys_platform == 'linux') or (python_full_version == '3.11.*' and sys_platform != 'darwin' and sys_platform != 'emscripten' and sys_platform != 'linux' and sys_platform != 'win32')",
|
||||
]
|
||||
dependencies = [
|
||||
{ name = "numpy", marker = "python_full_version >= '3.11'" },
|
||||
{ name = "numpy" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/5a/75/50581633d199812205ea8cdd0f6d52f12a624886b74bf1486335b67f01ff/pywavelets-1.9.0.tar.gz", hash = "sha256:148d12203377772bea452a59211d98649c8ee4a05eff019a9021853a36babdc8", size = 3938340, upload-time = "2025-08-04T16:20:04.978Z" }
|
||||
wheels = [
|
||||
@@ -3187,7 +3187,7 @@ wheels = [
|
||||
|
||||
[[package]]
|
||||
name = "remove-ai-watermarks"
|
||||
version = "0.21.0"
|
||||
version = "0.21.1"
|
||||
source = { editable = "." }
|
||||
dependencies = [
|
||||
{ name = "c2pa-python" },
|
||||
@@ -3490,7 +3490,7 @@ name = "stamina"
|
||||
version = "26.1.0"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "tenacity", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "tenacity" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/80/bd/b2f71ae14368a066f103d182f25bbc6c3bf4aa695889f3ed3cba026d6f36/stamina-26.1.0.tar.gz", hash = "sha256:0214d05fdf5102c518194a4aac7520ce53cf660550ae3b940701aad88cf50c17", size = 568171, upload-time = "2026-04-13T17:44:31.012Z" }
|
||||
wheels = [
|
||||
@@ -3790,7 +3790,7 @@ name = "typeguard"
|
||||
version = "4.5.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/67/1c/dfba5c4633cafc4c701f237d2ba63b416805047fd6d96aab4cfc40969f98/typeguard-4.5.2.tar.gz", hash = "sha256:5a16dcac23502039299c97c8941651bc33d7ea8cc4b2f7d6bbb1b528f6eea423", size = 80240, upload-time = "2026-05-14T12:59:40.857Z" }
|
||||
wheels = [
|
||||
@@ -3826,7 +3826,7 @@ name = "typing-inspection"
|
||||
version = "0.4.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "typing-extensions", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "typing-extensions" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/55/e3/70399cb7dd41c10ac53367ae42139cf4b1ca5f36bb3dc6c9d33acdb43655/typing_inspection-0.4.2.tar.gz", hash = "sha256:ba561c48a67c5958007083d386c3295464928b01faa735ab8547c5692e87f464", size = 75949, upload-time = "2025-10-01T02:14:41.687Z" }
|
||||
wheels = [
|
||||
@@ -3856,10 +3856,10 @@ name = "uv-outdated"
|
||||
version = "1.0.4"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "packaging", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "pydantic", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "rich", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "typer", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic" },
|
||||
{ name = "rich" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/38/84/78736b81c0e6ebefd3810b04a3bc6cb82bf7ea63474821b02d5cd9040439/uv_outdated-1.0.4.tar.gz", hash = "sha256:126745028823d8d452a82faaf53ea1d4ab5cdea7bba3159fc2ce7e5d0443146c", size = 19176, upload-time = "2025-12-25T10:54:22.77Z" }
|
||||
wheels = [
|
||||
@@ -3871,18 +3871,18 @@ name = "uv-secure"
|
||||
version = "0.17.2"
|
||||
source = { registry = "https://pypi.org/simple" }
|
||||
dependencies = [
|
||||
{ name = "anyio", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "cvss", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "httpx", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "humanize", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "inflect", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "orjson", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "packaging", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "pydantic", extra = ["email"], marker = "python_full_version >= '3.12'" },
|
||||
{ name = "rich", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "stamina", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "tomlkit", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "typer", marker = "python_full_version >= '3.12'" },
|
||||
{ name = "anyio" },
|
||||
{ name = "cvss" },
|
||||
{ name = "httpx" },
|
||||
{ name = "humanize" },
|
||||
{ name = "inflect" },
|
||||
{ name = "orjson" },
|
||||
{ name = "packaging" },
|
||||
{ name = "pydantic", extra = ["email"] },
|
||||
{ name = "rich" },
|
||||
{ name = "stamina" },
|
||||
{ name = "tomlkit" },
|
||||
{ name = "typer" },
|
||||
]
|
||||
sdist = { url = "https://files.pythonhosted.org/packages/71/99/29318cedfc5583cf2d503f0eedb9c4e96829541c356ce5d2aacfe09ef67f/uv_secure-0.17.2.tar.gz", hash = "sha256:e394939e0872df392d8f650d15ac1571b9267fc2f3671a183aa73c0977f0f402", size = 47240, upload-time = "2026-04-18T08:45:38.185Z" }
|
||||
wheels = [
|
||||
|
||||
Reference in New Issue
Block a user