mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-10 08:00:32 +02:00
Merge main into video watermark pipeline
This commit is contained in:
@@ -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__",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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``
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user