Merge main into video watermark pipeline

This commit is contained in:
Victor Kuznetsov
2026-07-31 11:13:59 -07:00
42 changed files with 1430 additions and 435 deletions
+1 -1
View File
@@ -32,7 +32,7 @@ _os.environ.setdefault("TRANSFORMERS_VERBOSITY", "error")
_warnings.filterwarnings("ignore", message=r".*ImageProcessorFast.*")
__version__ = "0.20.2"
__version__ = "0.22.0"
__all__ = [
"__version__",
+9 -8
View File
@@ -192,7 +192,7 @@ _upscaler_option = click.option(
"--upscaler",
type=click.Choice(["lanczos", "esrgan"]),
default="lanczos",
help="How to upscale a small input to the --min-resolution floor: lanczos (default, cv2, no deps) or "
help="How to upscale a small input to the --min-resolution floor: lanczos (default, cv2, no model) or "
"esrgan (Real-ESRGAN via the 'esrgan' extra; better detail, slower on CPU). Best for photo/texture "
"content -- as a generic GAN with no face/glyph prior it can degrade faces (diffusion mitigates) and "
"thin text, so lanczos stays the default. Falls back to lanczos if the extra is absent. Only when upscaling.",
@@ -340,7 +340,7 @@ _visible_backend_option = click.option(
default="auto",
help="Fill backend for visible-mark removal (localize -> fill). auto: best available, "
"LaMa > MI-GAN > cv2 (a learned backend needs the 'lama' or 'migan' extra; else cv2, "
"with a warning). cv2: classical inpaint (no deps, smears texture). migan: MI-GAN ONNX "
"with a warning). cv2: classical inpaint (no model download, smears texture). migan: MI-GAN ONNX "
"(light, ~1 GB, the memory-tight pick). lama: big-LaMa ONNX (best quality, ~4.7 GB).",
)
@@ -809,7 +809,7 @@ def _parse_region(spec: str) -> tuple[int, int, int, int]:
"--backend",
type=click.Choice(["cv2", "migan", "lama"]),
default="cv2",
help="Inpaint backend. cv2: instant, no deps. migan: light ONNX MI-GAN, ~1 GB RAM, "
help="Inpaint backend. cv2: instant, no model download. migan: light ONNX MI-GAN, ~1 GB RAM, "
"near-LaMa quality (extra 'migan'). lama: big-LaMa, best quality but ~4.7 GB RAM (extra 'lama').",
)
@click.option("--inpaint-method", type=click.Choice(["telea", "ns"]), default="telea", help="cv2 inpaint method.")
@@ -950,13 +950,14 @@ def cmd_invisible(
"""Remove invisible AI watermarks (SynthID, StableSignature, TreeRing).
Uses diffusion-based regeneration. Requires GPU for reasonable speed.
Requires the [gpu] extra: pip install 'remove-ai-watermarks[gpu]'
Requires the [diffusion] extra: pip install 'remove-ai-watermarks[diffusion]'
"""
from remove_ai_watermarks.invisible_engine import is_available as invisible_available
if not invisible_available():
console.print(
"Error: GPU dependencies not installed.\n Install them with: pip install 'remove-ai-watermarks[gpu]'"
"Error: Diffusion dependencies not installed.\n"
" Install them with: pip install 'remove-ai-watermarks[diffusion]'"
)
raise SystemExit(1)
@@ -1707,7 +1708,7 @@ def cmd_all(
synthid_skipped = True
console.print(
" Warning: Skipped - GPU dependencies not installed.\n"
" Install them with: pip install 'remove-ai-watermarks[gpu]'"
" Install them with: pip install 'remove-ai-watermarks[diffusion]'"
)
elif _should_skip_invisible_scrub(force, source):
# No locally-detectable invisible watermark -> skip the destructive
@@ -1813,7 +1814,7 @@ def cmd_all(
" visible mark and metadata were stripped.\n"
"\n"
" Install the extra and rerun to remove it:\n"
" pip install 'remove-ai-watermarks[gpu]'\n"
" pip install 'remove-ai-watermarks[diffusion]'\n"
" ====================================================================="
)
raise SystemExit(1)
@@ -2175,7 +2176,7 @@ def cmd_batch(
f"\n WARNING: the invisible (SynthID) watermark was NOT removed on "
f"{synthid_skipped_count} image(s) -- the GPU dependencies are not installed, "
f"so those outputs still carry the invisible watermark.\n"
f" Install the extra and rerun: pip install 'remove-ai-watermarks[gpu]'"
f" Install the extra and rerun: pip install 'remove-ai-watermarks[diffusion]'"
)
# Non-zero exit so a wrapping service detects an incomplete/failed run (batch used
+97
View File
@@ -0,0 +1,97 @@
"""DWT-DCT decoder compatible with invisible-watermark's ``dwtDct`` path.
Derived from ShieldMnt/invisible-watermark ``imwatermark/maxDct.py`` (MIT),
trimmed to the matrix path used by Stable Diffusion, SDXL, and FLUX.
Copyright (c) 2021 ShieldMnt
The complete upstream license is distributed in
``licenses/invisible-watermark-MIT.txt``.
"""
# pyright: reportUnknownMemberType=false, reportUnknownArgumentType=false, reportUnknownVariableType=false, reportMissingTypeStubs=false
from __future__ import annotations
from typing import TYPE_CHECKING, Any
import cv2
import numpy as np
import pywt
if TYPE_CHECKING:
from numpy.typing import NDArray
_DEFAULT_SCALES = (0, 36, 36)
_DEFAULT_BLOCK = 4
class _DecodeMaxDct:
"""Extract frequency-domain bits using the upstream matrix algorithm."""
def __init__(
self,
wm_lengths: tuple[int, ...],
scales: tuple[int, int, int] = _DEFAULT_SCALES,
block: int = _DEFAULT_BLOCK,
) -> None:
self._wm_lengths = wm_lengths
self._scales = scales
self._block = block
def decode(self, bgr: NDArray[Any]) -> dict[int, NDArray[Any]]:
row, col, _channels = bgr.shape
yuv = cv2.cvtColor(bgr, cv2.COLOR_BGR2YUV)
scores_by_length = {wm_len: ([0] * wm_len, [0] * wm_len) for wm_len in self._wm_lengths}
for channel in range(2):
if self._scales[channel] <= 0:
continue
ca1, _detail = pywt.dwt2(yuv[: row // 4 * 4, : col // 4 * 4, channel], "haar")
self._decode_frame(ca1, self._scales[channel], scores_by_length)
return {
wm_len: np.asarray(sums) * 255 > np.asarray(counts) * 127
for wm_len, (sums, counts) in scores_by_length.items()
}
def _decode_frame(
self,
frame: NDArray[Any],
scale: int,
scores_by_length: dict[int, tuple[list[int], list[int]]],
) -> None:
row, col = frame.shape
bit_index = 0
for i in range(row // self._block):
for j in range(col // self._block):
block = frame[
i * self._block : i * self._block + self._block,
j * self._block : j * self._block + self._block,
]
inferred = self._infer_bit(block, scale)
for wm_len, (sums, counts) in scores_by_length.items():
bucket = bit_index % wm_len
sums[bucket] += inferred
counts[bucket] += 1
bit_index += 1
def _infer_bit(self, block: NDArray[Any], scale: int) -> int:
position = int(np.argmax(np.abs(block.flatten()[1:]))) + 1
i, j = position // self._block, position % self._block
value = abs(float(block[i][j]))
return int((value % scale) > 0.5 * scale)
def decode_dwt_dct(bgr: NDArray[Any], wm_len: int) -> NDArray[Any]:
"""Extract ``wm_len`` watermark bits from a BGR image."""
return decode_dwt_dct_lengths(bgr, (wm_len,))[wm_len]
def decode_dwt_dct_lengths(bgr: NDArray[Any], wm_lengths: tuple[int, ...]) -> dict[int, NDArray[Any]]:
"""Extract several watermark lengths with one DWT and block scan."""
if bgr.size == 0 or min(bgr.shape[:2]) * max(bgr.shape[:2]) < 256 * 256:
raise RuntimeError("image too small, should be larger than 256x256")
if not wm_lengths or any(wm_len <= 0 for wm_len in wm_lengths):
raise ValueError("watermark lengths must be positive")
return _DecodeMaxDct(wm_lengths=tuple(dict.fromkeys(wm_lengths))).decode(bgr)
+307 -36
View File
@@ -19,10 +19,11 @@ never as "clean". See CLAUDE.md "SynthID detection is metadata-only".
from __future__ import annotations
import base64
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 +31,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 +62,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 +142,213 @@ 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]
try:
parts.append(base64.b64decode(encoded, validate=True))
continue
except (ValueError, TypeError):
pass
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):
if item.startswith("hex:"):
try:
parts.append(bytes.fromhex(item[4:]))
return
except ValueError:
pass
parts.append(item.encode("utf-8", "replace"))
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 = [
_external_text(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 +383,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})
@@ -494,8 +727,8 @@ def _visible_text_marks(image_path: Path, *, image: NDArray[Any] | None = None)
def _invisible_watermark(image_path: Path) -> str | None:
"""Open invisible-watermark scheme name (SD/SDXL/FLUX) or None.
Optional: needs the imwatermark decoder (extra ``detect``). Returns None if
it is not installed or no known watermark decodes.
Optional: needs the torch-free DWT-DCT decoder (extra ``detect``). Returns
None if it is not installed or no known watermark decodes.
"""
from remove_ai_watermarks.invisible_watermark import detect_invisible_watermark
@@ -533,6 +766,9 @@ def _collect_visible_signals(
image = imread(image_path)
except Exception as exc: # cv2 missing - detectors fall back / no-op
logger.debug("visible-mark decode unavailable: %s", exc)
return platform
if image is None:
return platform
sparkle_conf = _visible_sparkle(image_path, image=image)
if sparkle_conf is not None and sparkle_conf >= _SPARKLE_THRESHOLD:
@@ -549,29 +785,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 +920,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 +936,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 +957,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 +969,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 +980,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 +996,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 +1006,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 +1017,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 +1038,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 +1063,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 +1078,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 optional open invisible watermarks
(SD/SDXL/FLUX). No-op when the decoder extra 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.
+2 -2
View File
@@ -4,7 +4,7 @@ Wraps the vendored noai-watermark code for removing invisible AI watermarks
(SynthID, StableSignature, TreeRing) via diffusion-based regeneration.
This module requires the 'gpu' extra dependencies:
uv pip install 'remove-ai-watermarks[gpu]'
uv pip install 'remove-ai-watermarks[diffusion]'
"""
# cv2/torch boundary: this engine wraps cv2 (resize/imwrite/cvtColor) and the
@@ -226,7 +226,7 @@ class InvisibleEngine:
input size, so this is a transparent quality boost; it adds time
and memory on small inputs. Ignored on a min > max misconfig.
upscaler: How to upscale a small input to the ``min_resolution`` floor:
``"lanczos"`` (default, cv2, no deps) or ``"esrgan"`` (Real-ESRGAN
``"lanczos"`` (default, cv2, no model download) or ``"esrgan"`` (Real-ESRGAN
via the ``esrgan`` extra). Only applies when UPscaling (the floor
case); a ``max_resolution`` downscale always uses Lanczos. Falls back
to Lanczos if the extra is absent.
+33 -26
View File
@@ -14,21 +14,20 @@ source:
The watermark is fragile: it does NOT survive JPEG re-encoding or resizing
(verified -- gone after JPEG q90), so detection works only on pristine PNG
originals. Absence is never proof. Requires the optional ``invisible-watermark``
package (extra: ``detect``); ``detect_invisible_watermark`` returns None when it
is not installed.
originals. Absence is never proof. Requires the optional ``detect`` extra;
``detect_invisible_watermark`` returns None when it is not installed.
"""
# imwatermark ships no type stubs (like cv2); its decoder returns are Unknown.
# Relax the untyped-library diagnostics for this thin wrapper module only.
# The optional numeric libraries do not provide complete types for this path.
# pyright: reportMissingTypeStubs=false, reportUnknownMemberType=false, reportUnknownVariableType=false, reportUnknownArgumentType=false
from __future__ import annotations
import logging
from typing import TYPE_CHECKING, cast
from typing import TYPE_CHECKING
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -49,10 +48,10 @@ _MATCH_SD1_FRAC = 0.92 # fraction of the 136 string bits that must match
def is_available() -> bool:
"""True if the optional imwatermark decoder is installed."""
"""True when all dependencies for the optional DWT-DCT decoder exist."""
from .optional_deps import module_available
return module_available("imwatermark")
return module_available("cv2", "numpy", "pywt")
def _bits_match(value: int, ref: int, width: int = 48) -> int:
@@ -68,6 +67,20 @@ def _bytes_match_frac(a: bytes, b: bytes) -> float:
return 1.0 - diff / (8 * len(b))
def _bits_to_int(bits: Iterable[object]) -> int:
value = 0
for bit in bits:
value = (value << 1) | int(bool(bit))
return value
def _bits_to_bytes(bits: Iterable[object], nbytes: int) -> bytes:
import numpy as np
packed = np.packbits([int(bool(bit)) for bit in bits])
return bytes(int(value) for value in packed[:nbytes])
def detect_invisible_watermark(image_path: Path) -> str | None:
"""Return the embedding scheme name if a known open watermark is decoded.
@@ -78,32 +91,26 @@ def detect_invisible_watermark(image_path: Path) -> str | None:
"""
if not is_available():
return None
from imwatermark import WatermarkDecoder
from remove_ai_watermarks import image_io
from remove_ai_watermarks.dwt_dct import decode_dwt_dct_lengths
img = image_io.imread(image_path)
if img is None:
return None
# 48-bit fixed-message watermarks (SDXL, FLUX.2).
try:
bits = WatermarkDecoder("bits", 48).decode(img, "dwtDct")
value = 0
for bit in bits:
value = (value << 1) | (1 if bit else 0)
for name, ref in _BITS_48.items():
if _bits_match(value, ref) >= _MATCH_48:
return name
decoded = decode_dwt_dct_lengths(img, (48, 8 * len(_SD1_STRING)))
except Exception as exc: # decode can fail on tiny images
logger.debug("48-bit watermark decode failed for %s: %s", image_path, exc)
logger.debug("watermark decode failed for %s: %s", image_path, exc)
return None
# 136-bit default string watermark (SD 1.x / 2.x).
try:
raw = cast("bytes", WatermarkDecoder("bytes", 8 * len(_SD1_STRING)).decode(img, "dwtDct"))
if _bytes_match_frac(raw, _SD1_STRING) >= _MATCH_SD1_FRAC:
return "Stable Diffusion 1.x / 2.x"
except Exception as exc:
logger.debug("string watermark decode failed for %s: %s", image_path, exc)
value = _bits_to_int(decoded[48])
for name, ref in _BITS_48.items():
if _bits_match(value, ref) >= _MATCH_48:
return name
raw = _bits_to_bytes(decoded[8 * len(_SD1_STRING)], len(_SD1_STRING))
if _bytes_match_frac(raw, _SD1_STRING) >= _MATCH_SD1_FRAC:
return "Stable Diffusion 1.x / 2.x"
return None
@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2021 ShieldMnt
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
+102 -94
View File
@@ -10,6 +10,7 @@ from __future__ import annotations
import contextlib
import functools
import itertools
import json
import logging
import re
@@ -17,6 +18,7 @@ import struct
from typing import TYPE_CHECKING, Any, cast
if TYPE_CHECKING:
from collections.abc import Iterable
from pathlib import Path
logger = logging.getLogger(__name__)
@@ -374,6 +376,53 @@ 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:
if require_tc260_field:
return parse_tc260_aigc_json(text.encode("utf-8"))
try:
parsed = json.loads(text)
except ValueError:
return None
if not isinstance(parsed, dict):
return None
return {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
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.
@@ -383,6 +432,7 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
(as written by Doubao / ByteDance), read via PIL;
- a native MP4/MOV ``AIGC`` key in ``moov.udta.meta.keys`` whose matching
``ilst`` item carries the raw JSON object;
- a native MKV/WebM ``AIGC`` simple tag carrying the raw JSON object;
- a native AVI ``LIST/INFO/AIGC`` chunk or FLV
``script.onMetaData.AIGC`` string carrying the raw JSON object;
- an XMP ``<TC260:AIGC>{...}</TC260:AIGC>`` block (HTML-entity encoded text),
@@ -400,21 +450,6 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
if they carry at least one known TC260 field (``TC260_AIGC_FIELDS``); the
namespaced XMP element is unambiguous, so any JSON object is accepted.
"""
import html
def _parse(text: str, *, require_tc260_field: bool) -> dict[str, str] | None:
if require_tc260_field:
return parse_tc260_aigc_json(text.encode("utf-8"))
try:
parsed = json.loads(text)
except ValueError:
return None
if not isinstance(parsed, dict):
return None
return {str(k): str(v) for k, v in cast("dict[object, object]", parsed).items()}
# 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
@@ -423,7 +458,8 @@ 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)):
if isinstance(value, str) and (result := aigc_label_from_metadata(b"", (value,))):
return result
# Native MP4/MOV TC260 metadata (TC260-PG-20257A): the ``AIGC`` key lives
@@ -432,18 +468,18 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
# ``mdat`` is found without loading or scanning the media payload.
from remove_ai_watermarks.noai.isobmff import tc260_aigc_payloads
for payload in tc260_aigc_payloads(image_path):
if result := _parse(payload.decode("utf-8", "replace"), require_tc260_field=True):
return result
isobmff_candidates = tuple(payload.decode("utf-8", "replace") for payload in tc260_aigc_payloads(image_path))
if result := aigc_label_from_metadata(b"", isobmff_candidates):
return result
# Native MKV/WebM TC260 metadata: ``Segment.Tags.Tag.SimpleTag`` carries
# ``TagName=AIGC`` and the raw JSON in ``TagString``. The EBML walker seeks
# over clusters and reads only bounded metadata values.
from remove_ai_watermarks.noai.ebml import tc260_aigc_payloads as ebml_tc260_aigc_payloads
for payload in ebml_tc260_aigc_payloads(image_path):
if result := _parse(payload.decode("utf-8", "replace"), require_tc260_field=True):
return result
ebml_candidates = tuple(payload.decode("utf-8", "replace") for payload in ebml_tc260_aigc_payloads(image_path))
if result := aigc_label_from_metadata(b"", ebml_candidates):
return result
# Native AVI and FLV TC260 metadata. Both readers walk their container
# structures and skip media payloads instead of relying on a raw substring
@@ -457,51 +493,12 @@ def aigc_label(image_path: Path) -> dict[str, str] | None:
from remove_ai_watermarks.noai.flv import tc260_aigc_payloads as flv_tc260_aigc_payloads
legacy_payloads = flv_tc260_aigc_payloads(image_path)
for payload in legacy_payloads:
if result := _parse(payload.decode("utf-8", "replace"), require_tc260_field=True):
return result
legacy_candidates = tuple(payload.decode("utf-8", "replace") for payload in legacy_payloads)
if result := aigc_label_from_metadata(b"", legacy_candidates):
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
return aigc_label_from_metadata(data)
# C2PA "Durable Content Credentials" manifest repositories (C2PA 2.4). When the
@@ -595,6 +592,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.
@@ -619,12 +626,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:
@@ -637,13 +649,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:
@@ -686,6 +692,20 @@ def synthid_source(image_path: Path) -> str | None:
return ", ".join(matched) if matched else None
def generator_from_metadata(candidates: Iterable[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
creator_tools = (
match.group(1).decode("latin1", "replace")
for match in re.finditer(rb"CreatorTool[>\"'=\s]{1,4}([^<\"']{1,80})", scan)
)
for value in itertools.chain(candidates, creator_tools):
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
@@ -698,10 +718,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,
@@ -736,18 +752,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:"
@@ -757,7 +767,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
@@ -793,7 +803,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)
)
@@ -848,9 +858,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.
+1 -1
View File
@@ -7,7 +7,7 @@ is exposed **lazily** via PEP 562 ``__getattr__``: importing a light submodule
(e.g. ``noai.c2pa`` / ``noai.constants`` from ``identify``) must NOT eagerly pull
``watermark_remover``, which imports torch + diffusers at module top. Keeping this
lazy is what lets ``import remove_ai_watermarks.identify`` stay cheap (~36 MB, no
torch) even in a full install where the ``gpu``/``detect`` extras are present --
torch) even in a full install where the ``diffusion`` extra is present --
otherwise the mere presence of torch in the env inflated identify to ~420 MB and
risked OOM on a 512 MB host.
"""
+41 -11
View File
@@ -43,7 +43,7 @@ from remove_ai_watermarks.noai.constants import (
logger = logging.getLogger(__name__)
# Official C2PA reader (c2pa-python, a core dependency). It is the primary,
# Official C2PA reader (c2pa-python, a default dependency). It is the primary,
# spec-tracking manifest parser; the hand-rolled caBX/CBOR scanner below stays as
# a fallback for synthetic/partial blobs the validator rejects. The import is
# guarded so a partially-broken install degrades to the byte-scan rather than
@@ -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.
+1 -1
View File
@@ -7,7 +7,7 @@ so adding a new AI tool or metadata key requires updating only this file.
from typing import NamedTuple
# Supported image formats for the pixel/removal path (CLI input validation + batch
# discovery). PNG/JPEG/WebP decode+encode via cv2; HEIC/HEIF/AVIF via the core
# discovery). PNG/JPEG/WebP decode+encode via cv2; HEIC/HEIF/AVIF via the optional
# pillow-heif dep (image_io.imread Pillow fallback + imwrite _pil_write), so batch
# now picks them up and the CLI no longer warns on an iPhone HEIC. JPEG-XL is left
# out on purpose -- it is metadata/strip-only (no pixel decoder without pillow-jxl).
@@ -476,8 +476,9 @@ class WatermarkRemover:
"""Turn off the diffusers default invisible watermarker on an SDXL pipeline.
diffusers embeds an open "Stable Diffusion XL" DWT-DCT invisible watermark on
EVERY SDXL output whenever ``invisible-watermark`` is installed (the ``detect``
extra). A watermark REMOVER must not re-stamp a detectable AI watermark, or the
EVERY SDXL output whenever ``invisible-watermark`` is installed (kept as a
development parity dependency). A watermark REMOVER must not re-stamp a
detectable AI watermark, or the
cleaned output re-reads as AI (``identify`` -> "Open invisible watermark: Stable
Diffusion XL"). Shared by both SDXL loaders; the ``ControlNetModel`` sub-model
and the Qwen loader never call it (only the pipeline accepts the kwarg).
+1 -1
View File
@@ -4,7 +4,7 @@ Mirrors ``region_eraser``'s optional-backend pattern: ``is_available()`` guards
``spandrel`` import, a lazy singleton (double-checked lock) holds the loaded model, and
the weights download on first use (cached by ``torch.hub``) -- they are never bundled.
The DEFAULT upscaler stays Lanczos (cv2, no deps); this is opt-in via the ``esrgan``
The DEFAULT upscaler stays Lanczos (cv2, no model download); this is opt-in via the ``esrgan``
extra and feeds the ``--upscaler esrgan`` path. ``spandrel`` is a pure model-loader
(MIT) with NO basicsr dependency -- it pulls only torch/torchvision/safetensors/numpy/
einops -- so it sidesteps the basicsr / ``torchvision.transforms.functional_tensor``
+19 -2
View File
@@ -34,6 +34,14 @@ _REGENERATED_VIDEO_EXTENSIONS: frozenset[str] = _ISOBMFF_VIDEO_EXTENSIONS
_EBML_MAGIC = b"\x1aE\xdf\xa3"
def _require_video_runtime() -> None:
"""Raise with the public install command when a video runtime is absent."""
from remove_ai_watermarks.optional_deps import module_available
if not module_available("cv2", "numpy", "av"):
raise RuntimeError("Video pixel processing requires remove-ai-watermarks[video]")
@dataclass(frozen=True)
class VideoMetadataReport:
"""AI metadata found in one supported video container."""
@@ -345,7 +353,6 @@ def identify_video(
watermarks such as video SynthID have no public local decoder.
"""
from remove_ai_watermarks.metadata import get_ai_metadata
from remove_ai_watermarks.video_visible import scan_video_marks
source_path = _video_source(source)
markers = get_ai_metadata(source_path)
@@ -354,6 +361,9 @@ def identify_video(
total_frames: int | None = None
if check_visible:
_require_video_runtime()
from remove_ai_watermarks.video_visible import scan_video_marks
scans = scan_video_marks(
source_path,
VIDEO_VISIBLE_MARKS,
@@ -438,6 +448,8 @@ def remove_video_visible(
published atomically. When no stable mark is found, no output is written
and ``output`` in the result is ``None``.
"""
_require_video_runtime()
from remove_ai_watermarks.metadata import get_ai_metadata
from remove_ai_watermarks.video_visible import encode_clean_video, scan_video_marks
from remove_ai_watermarks.watermark_registry import resolve_backend
@@ -526,6 +538,7 @@ def remove_video_all(
if include_invisible and source_path.suffix.lower() not in _REGENERATED_VIDEO_EXTENSIONS:
supported = ", ".join(sorted(_REGENERATED_VIDEO_EXTENSIONS))
raise ValueError(f"Video SynthID regeneration requires one of: {supported}")
_require_video_runtime()
detected_metadata = get_ai_metadata(source_path)
with TemporaryDirectory(prefix=f".{source_path.stem}-video-all-", dir=source_path.parent) as temp_dir:
@@ -619,6 +632,8 @@ def remove_video_batch(
raise ValueError("Unsupported fill backend; expected auto, cv2, migan, or lama")
if include_invisible and mode != "all":
raise ValueError("The invisible video stage is available only in all mode")
if mode != "metadata":
_require_video_runtime()
output_path = (
Path(output_directory)
@@ -768,12 +783,14 @@ def remove_video_invisible(
rechecked with Google's verifier when the caller needs a per-file verdict.
"""
from remove_ai_watermarks.metadata import get_ai_metadata
from remove_ai_watermarks.video_invisible import regenerate_video_candidate
source_path = _video_source(source)
if source_path.suffix.lower() not in _REGENERATED_VIDEO_EXTENSIONS:
supported = ", ".join(sorted(_REGENERATED_VIDEO_EXTENSIONS))
raise ValueError(f"Video SynthID regeneration requires one of: {supported}")
_require_video_runtime()
from remove_ai_watermarks.video_invisible import regenerate_video_candidate
clean_output = Path(output) if output is not None else source_path.with_stem(source_path.stem + "_clean")
output_path = _video_output(
source_path,
+1 -1
View File
@@ -120,7 +120,7 @@ def load_video_vae_runtime(
if device not in {"auto", "cuda", "mps", "cpu"}:
raise ValueError("device must be auto, cuda, mps, or cpu")
if not is_available():
raise RuntimeError("Video SynthID regeneration requires the gpu extra")
raise RuntimeError("Video SynthID regeneration requires the diffusion extra")
import torch
from diffusers import AutoencoderKL