mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-07-31 03:27:24 +02:00
feat: detect soft-binding vendors, IPTC 2025.1, video/audio C2PA, TrustMark (v0.6.0)
Broadens metadata provenance coverage at the detection and container-strip level. Detection: - C2PA soft-binding `alg` -> forensic-watermark vendor (Adobe TrustMark, Digimarc, Imatag, Steg.AI, Microsoft, ...) via C2PA_SOFT_BINDINGS + soft_binding_vendors_in(); names the watermark vendor even when the watermark itself can't be decoded. - IPTC Photo Metadata 2025.1 AI-disclosure XMP fields (AISystemUsed etc.) via iptc_ai_system() + IPTC_AI_FIELD_MARKERS. - Adobe TrustMark open keyless decoder (trustmark_detector.py, optional extra `trustmark`) -- the watermark behind Adobe Durable Content Credentials. Detects provenance, not AI origin, so it does not assert is_ai. Removal / containers: - isobmff.strip_c2pa_boxes now also drops a top-level XMP uuid box that carries an AI label (matched by AI-marker content, byte-order-robust; plain XMP kept). - remove_ai_metadata routes MP4/MOV/M4V/M4A (and any ftyp-sniffed ISOBMFF) through the box stripper; raises a clear error for non-ISOBMFF audio/video (WebM/MP3/WAV) instead of crashing in the image path. Tests: soft-binding scan, IPTC element/attribute/presence, MP4 + M4A detect/ strip, ISOBMFF XMP surgical strip, content-sniff, unsupported-container guard, TrustMark absent-safety + identify integration. ruff clean; pyright clean on all new modules. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
ba94de8275
commit
c196a16900
@@ -1,3 +1,3 @@
|
||||
"""Remove-AI-Watermarks: Unified tool for removing visible and invisible AI watermarks."""
|
||||
|
||||
__version__ = "0.5.6"
|
||||
__version__ = "0.6.0"
|
||||
|
||||
@@ -26,13 +26,15 @@ from remove_ai_watermarks.metadata import (
|
||||
AI_METADATA_KEYS,
|
||||
AIGC_MARKERS,
|
||||
C2PA_UUID,
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
aigc_label,
|
||||
exif_generator,
|
||||
get_ai_metadata,
|
||||
iptc_ai_system,
|
||||
xai_signature,
|
||||
)
|
||||
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info
|
||||
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, soft_binding_vendors_in
|
||||
from remove_ai_watermarks.noai.constants import C2PA_AI_TOOLS, C2PA_ISSUERS
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -162,6 +164,17 @@ def _invisible_watermark(image_path: Path) -> str | None:
|
||||
return detect_invisible_watermark(image_path)
|
||||
|
||||
|
||||
def _trustmark(image_path: Path) -> str | None:
|
||||
"""Adobe TrustMark scheme name or None.
|
||||
|
||||
Optional: needs the ``trustmark`` decoder (extra ``trustmark``). Returns None
|
||||
if it is not installed or no TrustMark watermark decodes.
|
||||
"""
|
||||
from remove_ai_watermarks.trustmark_detector import detect_trustmark
|
||||
|
||||
return detect_trustmark(image_path)
|
||||
|
||||
|
||||
def identify(image_path: Path, *, check_visible: bool = True, check_invisible: bool = True) -> ProvenanceReport:
|
||||
"""Identify an image's origin platform and watermark inventory.
|
||||
|
||||
@@ -213,6 +226,14 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
if "OpenAI" in (" ".join(issuers) + synthid):
|
||||
caveats.append(_OPENAI_CAVEAT)
|
||||
|
||||
# ── C2PA soft-binding: a named forensic/third-party watermark vendor ─
|
||||
# (Adobe TrustMark, Digimarc, Imatag, ...). Present in the manifest even when
|
||||
# the watermark itself can't be decoded; names whose watermark stamped the pixels.
|
||||
soft_binding = meta.get("soft_binding") or (", ".join(v) if (v := soft_binding_vendors_in(head)) else None)
|
||||
if soft_binding:
|
||||
signals.append(Signal("soft_binding", f"C2PA soft binding: {soft_binding}", "high"))
|
||||
watermarks.append(f"Forensic watermark soft binding ({soft_binding})")
|
||||
|
||||
# ── IPTC "Made with AI" (Meta etc.), only meaningful without C2PA ─
|
||||
iptc = any(m in head for m in IPTC_AI_MARKERS)
|
||||
if iptc and not has_c2pa:
|
||||
@@ -222,6 +243,18 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
if platform is None:
|
||||
platform = "Made-with-AI tag (e.g. Meta AI); platform not specified"
|
||||
|
||||
# ── 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)
|
||||
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")
|
||||
)
|
||||
watermarks.append(f"IPTC 2025.1 AI disclosure ({system})" if named else "IPTC 2025.1 AI disclosure fields")
|
||||
if platform is None and named:
|
||||
platform = f"{system} (IPTC AISystemUsed)"
|
||||
|
||||
# ── China TC260 AIGC label (Doubao and other China-served gens) ──
|
||||
aigc = any(m in head for m in AIGC_MARKERS)
|
||||
if aigc:
|
||||
@@ -266,12 +299,29 @@ def identify(image_path: Path, *, check_visible: bool = True, check_invisible: b
|
||||
if platform is None:
|
||||
platform = f"{scheme} (open DWT-DCT watermark)"
|
||||
|
||||
# ── Adobe TrustMark invisible watermark (open decoder, no key) ───
|
||||
# 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:
|
||||
signals.append(Signal("trustmark", tm_scheme, "high"))
|
||||
watermarks.append(f"Adobe TrustMark invisible watermark ({tm_scheme})")
|
||||
if platform is None:
|
||||
platform = "Adobe (TrustMark / Content Credentials)"
|
||||
|
||||
# ── Verdict so far (metadata + embedded watermark) ──────────────
|
||||
invisible_wm = any(s.name == "invisible_watermark" for s in signals)
|
||||
exif_gen = any(s.name == "exif_generator" for s in signals)
|
||||
xai_sig = any(s.name == "xai_signature" for s in signals)
|
||||
ai_from_metadata = bool(
|
||||
(has_c2pa and (c2pa_is_ai or synthid)) or iptc or aigc or local_keys or invisible_wm or exif_gen or xai_sig
|
||||
(has_c2pa and (c2pa_is_ai or synthid))
|
||||
or iptc
|
||||
or iptc_ai
|
||||
or aigc
|
||||
or local_keys
|
||||
or invisible_wm
|
||||
or exif_gen
|
||||
or xai_sig
|
||||
)
|
||||
|
||||
# ── Visible Gemini sparkle (fallback for stripped-metadata case) ─
|
||||
|
||||
@@ -74,6 +74,29 @@ IPTC_AI_MARKERS: tuple[bytes, ...] = (
|
||||
b"compositeWithTrainedAlgorithmicMedia",
|
||||
)
|
||||
|
||||
# IPTC Photo Metadata 2025.1 (published 2025-11-27) added explicit AI-disclosure
|
||||
# XMP properties in the Iptc4xmpExt namespace. Their mere presence is an AI
|
||||
# signal; ``AISystemUsed`` additionally carries the generator name. Property
|
||||
# tokens verified against the IPTC 2025.1 specification.
|
||||
IPTC_AI_FIELD_MARKERS: tuple[bytes, ...] = (
|
||||
b"AISystemUsed",
|
||||
b"AISystemVersionUsed",
|
||||
b"AIPromptInformation",
|
||||
b"AIPromptWriterName",
|
||||
)
|
||||
|
||||
# ISOBMFF containers whose AI-provenance boxes ``remove_ai_metadata`` strips at
|
||||
# the container level (image, video, audio -- all ISOBMFF). A content sniff
|
||||
# (``ftyp``) is also accepted, so this is a fast-path hint, not the sole gate.
|
||||
_ISOBMFF_EXTS: frozenset[str] = frozenset({".avif", ".heif", ".heic", ".jxl", ".mp4", ".mov", ".m4v", ".m4a"})
|
||||
|
||||
# Non-ISOBMFF audio/video we can DETECT (binary scan) but not strip at the
|
||||
# container level (EBML / framed / RIFF need re-encoding). remove_ai_metadata
|
||||
# fails clearly on these rather than crashing in the image path.
|
||||
_UNSUPPORTED_CONTAINER_EXTS: frozenset[str] = frozenset(
|
||||
{".webm", ".mkv", ".mka", ".mp3", ".wav", ".flac", ".ogg", ".oga", ".opus", ".aac"}
|
||||
)
|
||||
|
||||
# China's mandatory AI-content labeling (TC260, the national cybersecurity
|
||||
# standards committee). AI generators serving China embed an XMP block in the
|
||||
# TC260 namespace -- ``<TC260:AIGC>{"Label":"1",...}``. Doubao (ByteDance) uses
|
||||
@@ -155,6 +178,9 @@ def has_ai_metadata(image_path: Path) -> bool:
|
||||
return True
|
||||
if any(marker in data for marker in IPTC_AI_MARKERS):
|
||||
return True
|
||||
# IPTC 2025.1 AI-disclosure XMP properties (their presence flags AI content).
|
||||
if any(marker in data for marker in IPTC_AI_FIELD_MARKERS):
|
||||
return True
|
||||
# xAI / Grok: no C2PA/IPTC/XMP -- only the EXIF Signature + UUID-Artist pair.
|
||||
return xai_signature(image_path)
|
||||
|
||||
@@ -183,6 +209,26 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
|
||||
return {str(k): str(v) for k, v in parsed.items()} if isinstance(parsed, dict) else None
|
||||
|
||||
|
||||
def iptc_ai_system(image_path: Path) -> str | None:
|
||||
"""Return an IPTC 2025.1 AI-disclosure note if the file carries those XMP
|
||||
properties, else None.
|
||||
|
||||
IPTC Photo Metadata 2025.1 added ``Iptc4xmpExt`` AI-disclosure properties
|
||||
(see ``IPTC_AI_FIELD_MARKERS``); their presence alone flags AI content, and
|
||||
``AISystemUsed`` names the generator. Returns the ``AISystemUsed`` value when
|
||||
extractable, otherwise the literal ``"fields present"``. Container-agnostic
|
||||
raw-byte scan; handles both XMP element and attribute serializations.
|
||||
"""
|
||||
with open(image_path, "rb") as f:
|
||||
data = f.read(1024 * 1024)
|
||||
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"
|
||||
|
||||
|
||||
def synthid_source(image_path: Path) -> str | None:
|
||||
"""Return the vendor name(s) if the image carries a SynthID pixel watermark.
|
||||
|
||||
@@ -380,7 +426,7 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
"""
|
||||
from PIL import Image
|
||||
|
||||
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, synthid_verdict
|
||||
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, soft_binding_vendors_in, synthid_verdict
|
||||
|
||||
result: dict[str, str] = {}
|
||||
|
||||
@@ -410,14 +456,21 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
"source_type",
|
||||
"actions",
|
||||
"synthid_watermark",
|
||||
"soft_binding",
|
||||
):
|
||||
if key in c2pa:
|
||||
result.setdefault(key, str(c2pa[key]))
|
||||
|
||||
# Non-PNG containers (JPEG/WebP/AVIF): extract_c2pa_info is PNG-only, so
|
||||
# fall back to the format-agnostic source check for the SynthID verdict.
|
||||
# Non-PNG containers (JPEG/WebP/AVIF/MP4): extract_c2pa_info is PNG-only, so
|
||||
# fall back to the format-agnostic source check for the SynthID verdict and
|
||||
# the soft-binding (forensic-watermark vendor) scan.
|
||||
if "synthid_watermark" not in result and (vendor := synthid_source(image_path)):
|
||||
result.setdefault("synthid_watermark", synthid_verdict(vendor))
|
||||
if "soft_binding" not in result:
|
||||
with open(image_path, "rb") as f:
|
||||
head = f.read(1024 * 1024)
|
||||
if vendors := soft_binding_vendors_in(head):
|
||||
result["soft_binding"] = ", ".join(vendors)
|
||||
|
||||
# China TC260 AI-content label (Doubao and other China-served generators).
|
||||
if aigc := aigc_label(image_path):
|
||||
@@ -427,6 +480,10 @@ def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
||||
# xAI / Grok EXIF signature scheme (its only provenance signal).
|
||||
if xai_signature(image_path):
|
||||
result.setdefault("xai_signature", "xAI/Grok EXIF signature (Artist UUID + Signature blob)")
|
||||
|
||||
# IPTC 2025.1 AI-disclosure XMP fields (Iptc4xmpExt:AISystemUsed etc.).
|
||||
if system := iptc_ai_system(image_path):
|
||||
result.setdefault("ai_system", f"IPTC 2025.1 AI disclosure ({system})")
|
||||
return result
|
||||
|
||||
|
||||
@@ -455,19 +512,34 @@ def remove_ai_metadata(
|
||||
if output_path is None:
|
||||
output_path = source_path
|
||||
|
||||
# AVIF/HEIF/JPEG-XL: strip C2PA boxes at the container level without
|
||||
# re-encoding. Avoids needing PIL plugins (pillow-heif / pillow-jxl) and
|
||||
# preserves pixel data bit-for-bit.
|
||||
if source_path.suffix.lower() in (".avif", ".heif", ".heic", ".jxl"):
|
||||
from remove_ai_watermarks.noai.isobmff import strip_c2pa_boxes
|
||||
# ISOBMFF containers (AVIF/HEIF/JPEG-XL images, MP4/MOV/M4V video, M4A audio):
|
||||
# strip C2PA + AI-label boxes at the container level without re-encoding.
|
||||
# Avoids needing PIL plugins (pillow-heif / pillow-jxl) and preserves the
|
||||
# codestream bit-for-bit. MP4/MOV/M4A are ISOBMFF too, so the same top-level
|
||||
# uuid/jumb box walker applies. Route by suffix OR by an ``ftyp`` content
|
||||
# sniff, so a correctly-shaped container is handled whatever its extension.
|
||||
from remove_ai_watermarks.noai.isobmff import is_isobmff, strip_c2pa_boxes
|
||||
|
||||
with open(source_path, "rb") as f:
|
||||
head = f.read(12)
|
||||
if source_path.suffix.lower() in _ISOBMFF_EXTS or is_isobmff(head):
|
||||
data = source_path.read_bytes()
|
||||
cleaned, stripped = strip_c2pa_boxes(data)
|
||||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
output_path.write_bytes(cleaned)
|
||||
logger.info("Stripped %d C2PA box(es) → %s", stripped, output_path)
|
||||
logger.info("Stripped %d AI-provenance box(es) → %s", stripped, output_path)
|
||||
return output_path
|
||||
|
||||
# Containers we can detect (via identify's byte scan) but cannot strip at the
|
||||
# container level: non-ISOBMFF audio/video (Matroska/WebM are EBML; MP3 is
|
||||
# framed; WAV is RIFF). Re-encoding them is out of scope, so fail clearly
|
||||
# rather than crash in the PIL image path below.
|
||||
if source_path.suffix.lower() in _UNSUPPORTED_CONTAINER_EXTS:
|
||||
raise ValueError(
|
||||
f"container-level metadata removal is not supported for {source_path.suffix} "
|
||||
"(detection via `identify` still works); re-encode it with a media tool to strip metadata"
|
||||
)
|
||||
|
||||
# Read image and filter metadata
|
||||
with Image.open(source_path) as img:
|
||||
img = img.copy()
|
||||
|
||||
@@ -28,6 +28,7 @@ from remove_ai_watermarks.noai.constants import (
|
||||
C2PA_CHUNK_TYPE,
|
||||
C2PA_ISSUERS,
|
||||
C2PA_SIGNATURES,
|
||||
C2PA_SOFT_BINDINGS,
|
||||
PNG_SIGNATURE,
|
||||
SYNTHID_C2PA_ISSUERS,
|
||||
)
|
||||
@@ -174,6 +175,18 @@ def synthid_vendors_in(buffer: bytes) -> list[str]:
|
||||
return sorted({name for sig, name in C2PA_ISSUERS.items() if sig in buffer and sig in SYNTHID_C2PA_ISSUERS})
|
||||
|
||||
|
||||
def soft_binding_vendors_in(buffer: bytes) -> list[str]:
|
||||
"""Return forensic-watermark vendor names whose C2PA soft-binding ``alg``
|
||||
identifier appears in ``buffer``.
|
||||
|
||||
A ``c2pa.soft-binding`` assertion names the watermark scheme that stamped the
|
||||
pixels (Adobe TrustMark, Digimarc, Imatag, Steg.AI, ...). Shared by the PNG
|
||||
caBX parser and the format-agnostic binary scan so both apply the same
|
||||
C2PA_SOFT_BINDINGS rule against their respective bytes.
|
||||
"""
|
||||
return sorted({name for sig, name in C2PA_SOFT_BINDINGS.items() if sig in buffer})
|
||||
|
||||
|
||||
def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
|
||||
"""Parse C2PA chunk data and populate info dictionary."""
|
||||
c2pa_info["c2pa_manifest"] = f"C2PA manifest ({len(chunk_data)} bytes)"
|
||||
@@ -238,6 +251,13 @@ def _parse_c2pa_chunk(chunk_data: bytes, c2pa_info: dict[str, Any]) -> None:
|
||||
c2pa_info["synthid_vendors"] = synthid_vendors
|
||||
c2pa_info["synthid_watermark"] = synthid_verdict(", ".join(synthid_vendors))
|
||||
|
||||
# Soft-binding: a forensic/third-party watermark vendor named in the
|
||||
# manifest (Adobe TrustMark, Digimarc, ...), independent of the issuer.
|
||||
soft_binding_vendors = soft_binding_vendors_in(chunk_data)
|
||||
if soft_binding_vendors:
|
||||
c2pa_info["soft_binding_vendors"] = soft_binding_vendors
|
||||
c2pa_info["soft_binding"] = ", ".join(soft_binding_vendors)
|
||||
|
||||
|
||||
def extract_c2pa_chunk(image_path: Path) -> bytes | None:
|
||||
"""
|
||||
|
||||
@@ -122,6 +122,26 @@ C2PA_AI_TOOLS = {
|
||||
b"Firefly": "Firefly",
|
||||
}
|
||||
|
||||
# C2PA ``c2pa.soft-binding`` algorithm identifiers -> the forensic-watermark
|
||||
# vendor that stamped the pixels. The manifest's ``alg`` field names the
|
||||
# watermark scheme even when the watermark itself cannot be decoded locally, so
|
||||
# a byte-scan for these (keyed on a distinctive prefix to catch all variants)
|
||||
# tells us a third-party forensic watermark is present and whose. Verified
|
||||
# against the official C2PA registry (github.com/c2pa-org/softbinding-algorithm-list).
|
||||
# Adobe TrustMark is additionally decodable locally (see ``trustmark_detector``);
|
||||
# the rest (Digimarc, Imatag, Steg.AI, etc.) are proprietary oracle-only decoders.
|
||||
C2PA_SOFT_BINDINGS = {
|
||||
b"com.adobe.trustmark": "Adobe TrustMark",
|
||||
b"com.digimarc": "Digimarc",
|
||||
b"com.imatag.lamark": "Imatag (Lamark)",
|
||||
b"ai.steg": "Steg.AI",
|
||||
b"com.microsoft.invismark": "Microsoft InvisMark",
|
||||
b"com.microsoft.wavmark": "Microsoft WavMark",
|
||||
b"com.verimatrix": "Verimatrix",
|
||||
b"com.nagra.nexguard": "NAGRA NexGuard",
|
||||
b"com.aiwatermark": "AIWatermark",
|
||||
}
|
||||
|
||||
# Lowercased substrings that mark an AI generator when found in an EXIF
|
||||
# ``Software`` / XMP ``CreatorTool`` value. Conservative on purpose: plain
|
||||
# editors like "Adobe Photoshop" or "GIMP" must NOT match (no AI token), so only
|
||||
|
||||
@@ -23,13 +23,25 @@ from typing import TYPE_CHECKING
|
||||
if TYPE_CHECKING:
|
||||
from collections.abc import Iterator
|
||||
|
||||
from remove_ai_watermarks.metadata import C2PA_UUID
|
||||
from remove_ai_watermarks.metadata import (
|
||||
AIGC_MARKERS,
|
||||
C2PA_UUID,
|
||||
IPTC_AI_FIELD_MARKERS,
|
||||
IPTC_AI_MARKERS,
|
||||
)
|
||||
|
||||
# Top-level box types that carry C2PA payload. ``uuid`` boxes are checked
|
||||
# against ``C2PA_UUID`` before being stripped; ``jumb`` boxes are always
|
||||
# stripped (JPEG-XL uses them exclusively for JUMBF).
|
||||
# Top-level box types that may carry AI provenance. ``uuid`` boxes are checked
|
||||
# against ``C2PA_UUID`` / AI-label markers before being stripped; ``jumb`` boxes
|
||||
# are always stripped (JPEG-XL uses them exclusively for JUMBF).
|
||||
C2PA_BOX_TYPES: frozenset[bytes] = frozenset({b"uuid", b"jumb"})
|
||||
|
||||
# AI-label byte markers (TC260 AIGC, IPTC "Made with AI", IPTC 2025.1 AI fields)
|
||||
# whose presence inside an XMP ``uuid`` box means the box carries an AI label.
|
||||
# Matching the payload rather than a fixed XMP UUID avoids the XMP-box UUID
|
||||
# byte-order ambiguity and stays surgical: only AI-bearing XMP is dropped, plain
|
||||
# XMP (copyright, camera info) is kept.
|
||||
_AI_LABEL_MARKERS: tuple[bytes, ...] = AIGC_MARKERS + IPTC_AI_MARKERS + IPTC_AI_FIELD_MARKERS
|
||||
|
||||
|
||||
def _iter_top_level_boxes(data: bytes) -> Iterator[tuple[int, int, bytes, int]]:
|
||||
"""Yield ``(start, end, type, payload_offset)`` for each top-level box.
|
||||
@@ -67,12 +79,22 @@ def is_isobmff(data: bytes) -> bool:
|
||||
|
||||
|
||||
def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
"""Return ``(cleaned_bytes, stripped_count)``.
|
||||
"""Return ``(cleaned_bytes, stripped_count)`` with AI-provenance boxes removed.
|
||||
|
||||
Walks top-level boxes; drops any ``uuid`` box whose UUID equals
|
||||
``C2PA_UUID`` and any ``jumb`` box (JPEG-XL JUMBF container). All other
|
||||
boxes are emitted verbatim. If the input is not ISOBMFF-shaped, returns
|
||||
it unchanged.
|
||||
Walks top-level boxes and drops:
|
||||
- any ``uuid`` box whose UUID equals ``C2PA_UUID`` (a C2PA manifest);
|
||||
- any ``uuid`` box whose payload carries an AI-label marker (an XMP packet
|
||||
with a TC260 / IPTC / IPTC-2025.1 AI field -- caught by content, not by the
|
||||
XMP UUID, so it works regardless of the UUID's byte order, and leaves plain
|
||||
non-AI XMP intact);
|
||||
- any ``jumb`` box (JPEG-XL JUMBF container).
|
||||
|
||||
All other boxes (incl. ``mdat`` / codestream) are emitted verbatim, so pixel
|
||||
and audio data is preserved bit-for-bit. Non-ISOBMFF input is returned
|
||||
unchanged. Despite the name this also covers MP4/MOV/M4A video and audio
|
||||
(all ISOBMFF). NOTE: EXIF/XMP stored as *items inside the ``meta`` box*
|
||||
(typical for AVIF/HEIF images) is not removed -- that needs meta-box surgery
|
||||
and is a documented limitation.
|
||||
"""
|
||||
if not is_isobmff(data):
|
||||
return data, 0
|
||||
@@ -80,14 +102,15 @@ def strip_c2pa_boxes(data: bytes) -> tuple[bytes, int]:
|
||||
out = bytearray()
|
||||
stripped = 0
|
||||
for start, end, box_type, payload_off in _iter_top_level_boxes(data):
|
||||
if box_type in C2PA_BOX_TYPES:
|
||||
if box_type == b"uuid":
|
||||
# uuid boxes carry the 16-byte UUID immediately after the type.
|
||||
if payload_off + 16 <= end and data[payload_off : payload_off + 16] == C2PA_UUID:
|
||||
stripped += 1
|
||||
continue
|
||||
else: # b"jumb"
|
||||
if box_type == b"uuid":
|
||||
# uuid boxes carry the 16-byte UUID immediately after the type.
|
||||
is_c2pa = payload_off + 16 <= end and data[payload_off : payload_off + 16] == C2PA_UUID
|
||||
has_ai_label = any(marker in data[payload_off:end] for marker in _AI_LABEL_MARKERS)
|
||||
if is_c2pa or has_ai_label:
|
||||
stripped += 1
|
||||
continue
|
||||
elif box_type == b"jumb":
|
||||
stripped += 1
|
||||
continue
|
||||
out.extend(data[start:end])
|
||||
return bytes(out), stripped
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
"""Detect Adobe TrustMark invisible watermarks.
|
||||
|
||||
TrustMark (github.com/adobe/trustmark, MIT) is the open, keyless image watermark
|
||||
behind Adobe "Durable Content Credentials": when a C2PA manifest is stripped, a
|
||||
TrustMark soft binding can still re-link the asset to its manifest in a
|
||||
repository. Unlike SynthID it has a PUBLIC decoder with no secret key, so a
|
||||
TrustMark-stamped image can be identified locally. Adobe's shipping products use
|
||||
Variant P (the ``com.adobe.trustmark.P`` soft-binding ``alg``); this wrapper
|
||||
loads that model.
|
||||
|
||||
Optional dependency (extra: ``trustmark``); the model weights download on first
|
||||
use. ``detect_trustmark`` returns None when the package is absent. This detects
|
||||
provenance (Adobe Content Credentials), NOT AI generation as such -- TrustMark
|
||||
also marks human-authored content -- so callers should treat it as a watermark
|
||||
signal, not proof of AI origin.
|
||||
"""
|
||||
|
||||
# trustmark ships no type stubs; relax untyped-library diagnostics for this thin
|
||||
# wrapper module only.
|
||||
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false, reportMissingImports=false
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from typing import TYPE_CHECKING, Any
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from pathlib import Path
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
# Adobe ships Variant P in production (com.adobe.trustmark.P).
|
||||
_MODEL_TYPE = "P"
|
||||
# Lazily constructed singleton -- model load + first-use download is expensive.
|
||||
_tm: Any = None
|
||||
|
||||
|
||||
def is_available() -> bool:
|
||||
"""True if the optional ``trustmark`` package is installed."""
|
||||
import importlib.util
|
||||
|
||||
return importlib.util.find_spec("trustmark") is not None
|
||||
|
||||
|
||||
def _decoder() -> Any:
|
||||
global _tm
|
||||
if _tm is None:
|
||||
from trustmark import TrustMark
|
||||
|
||||
_tm = TrustMark(verbose=False, model_type=_MODEL_TYPE)
|
||||
return _tm
|
||||
|
||||
|
||||
def detect_trustmark(image_path: Path) -> str | None:
|
||||
"""Return a TrustMark scheme note if a TrustMark watermark is decoded, else None.
|
||||
|
||||
Returns e.g. ``"Adobe TrustMark (variant P, schema 0)"`` when the decoder
|
||||
reports the watermark present, or None if it is absent, the optional
|
||||
``trustmark`` package is not installed, or the image cannot be read/decoded.
|
||||
"""
|
||||
if not is_available():
|
||||
return None
|
||||
try:
|
||||
from PIL import Image
|
||||
|
||||
with Image.open(image_path) as img:
|
||||
cover = img.convert("RGB")
|
||||
_wm_secret, wm_present, wm_schema = _decoder().decode(cover)
|
||||
except Exception as exc: # model download / decode failure / unreadable image
|
||||
log.debug("TrustMark decode failed for %s: %s", image_path, exc)
|
||||
return None
|
||||
return f"Adobe TrustMark (variant {_MODEL_TYPE}, schema {wm_schema})" if wm_present else None
|
||||
Reference in New Issue
Block a user