mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-08 23:16:02 +02:00
get_ai_metadata opened the file with PIL unguarded, so a HEIC (or any format PIL can't open without optional plugins) raised UnidentifiedImageError instead of falling through to the binary scan -- unlike has_ai_metadata, which already guards. Wrap the open in except Exception and continue to the C2PA/IPTC path. Regression test feeds an unopenable .heic shell and asserts no raise. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
322 lines
10 KiB
Python
322 lines
10 KiB
Python
"""AI metadata detection and removal.
|
|
|
|
Wraps the noai-watermark metadata handling for stripping AI-generation
|
|
metadata (EXIF, PNG text chunks, C2PA provenance) from images.
|
|
|
|
For metadata-only operations, the heavy ML dependencies are NOT required.
|
|
"""
|
|
|
|
from __future__ import annotations
|
|
|
|
import contextlib
|
|
import logging
|
|
from typing import TYPE_CHECKING
|
|
|
|
if TYPE_CHECKING:
|
|
from pathlib import Path
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# ── Known AI metadata keys ──────────────────────────────────────────
|
|
|
|
AI_METADATA_KEYS: frozenset[str] = frozenset(
|
|
k.lower()
|
|
for k in [
|
|
"parameters",
|
|
"prompt",
|
|
"negative_prompt",
|
|
"workflow",
|
|
"comfyui",
|
|
"sd-metadata",
|
|
"invokeai_metadata",
|
|
"generation_data",
|
|
"ai_metadata",
|
|
"dream",
|
|
"sd:prompt",
|
|
"sd:negative_prompt",
|
|
"sd:seed",
|
|
"sd:steps",
|
|
"sd:sampler",
|
|
"sd:cfg_scale",
|
|
"sd:model_hash",
|
|
"c2pa",
|
|
"c2pa_chunk",
|
|
"Software",
|
|
]
|
|
)
|
|
|
|
AI_KEYWORDS: tuple[str, ...] = (
|
|
"stable_diffusion",
|
|
"comfyui",
|
|
"automatic1111",
|
|
"invokeai",
|
|
"midjourney",
|
|
"dall-e",
|
|
"dalle",
|
|
"imagen",
|
|
"synthid",
|
|
"google_ai",
|
|
"openai",
|
|
"c2pa",
|
|
)
|
|
|
|
# C2PA UUID used in ISOBMFF (AVIF, HEIF, MP4) ``uuid`` boxes.
|
|
# Reference: https://spec.c2pa.org/specifications/specifications/2.1/specs/C2PA_Specification.html
|
|
C2PA_UUID: bytes = bytes.fromhex("d8fec3d61b0e483c92975828877ec481")
|
|
|
|
# IPTC ``digitalSourceType`` values (IPTC 2025.1) that flag AI provenance.
|
|
# Used by Instagram, Facebook, X (Twitter) to show "Made with AI" labels.
|
|
IPTC_AI_MARKERS: tuple[bytes, ...] = (
|
|
b"trainedAlgorithmicMedia",
|
|
b"compositeSynthetic",
|
|
b"algorithmicMedia",
|
|
b"compositeWithTrainedAlgorithmicMedia",
|
|
)
|
|
|
|
STANDARD_METADATA_KEYS: frozenset[str] = frozenset(
|
|
[
|
|
"Author",
|
|
"Title",
|
|
"Description",
|
|
"Copyright",
|
|
"Creation Time",
|
|
"Software",
|
|
"Comment",
|
|
"Disclaimer",
|
|
"Source",
|
|
"Warning",
|
|
]
|
|
)
|
|
|
|
|
|
def _is_ai_key(key: str) -> bool:
|
|
"""Check if a metadata key is AI-related."""
|
|
key_lower = key.lower()
|
|
if key_lower in AI_METADATA_KEYS:
|
|
return True
|
|
return any(kw in key_lower for kw in AI_KEYWORDS)
|
|
|
|
|
|
def has_ai_metadata(image_path: Path) -> bool:
|
|
"""Check if an image contains AI-generation metadata.
|
|
|
|
Args:
|
|
image_path: Path to the image.
|
|
|
|
Returns:
|
|
True if AI metadata is detected.
|
|
"""
|
|
from PIL import Image
|
|
|
|
# PIL may not handle AVIF/HEIF/JPEG-XL without the optional plugins
|
|
# (ultralytics also monkey-patches Image.open in a way that can raise
|
|
# ModuleNotFoundError when pi_heif autoload fails), so any open failure
|
|
# falls through to the binary scan.
|
|
try:
|
|
with Image.open(image_path) as img:
|
|
for key in img.info:
|
|
if _is_ai_key(key):
|
|
return True
|
|
except Exception as exc:
|
|
logger.debug("PIL could not open %s for metadata scan: %s", image_path, exc)
|
|
|
|
# Check C2PA — via the official ``c2pa`` lib if available, otherwise via a
|
|
# binary scan that also catches AVIF/HEIF/JPEG-XL containers (PIL doesn't
|
|
# expose their metadata uniformly).
|
|
try:
|
|
from c2pa import has_c2pa_metadata
|
|
|
|
if has_c2pa_metadata(image_path):
|
|
return True
|
|
except ImportError:
|
|
pass
|
|
|
|
# Binary scan covers C2PA (PNG caBX, JPEG APP11, AVIF/HEIF/JXL uuid boxes)
|
|
# and IPTC AI markers in XMP. Read only the first 512KB to bound memory.
|
|
with open(image_path, "rb") as f:
|
|
data = f.read(512 * 1024)
|
|
if b"c2pa" in data.lower() or b"C2PA" in data:
|
|
return True
|
|
if C2PA_UUID in data:
|
|
return True
|
|
return any(marker in data for marker in IPTC_AI_MARKERS)
|
|
|
|
|
|
def synthid_source(image_path: Path) -> str | None:
|
|
"""Return the vendor name(s) if the image carries a SynthID pixel watermark.
|
|
|
|
This is a *metadata-based* proxy: Google (Imagen/Gemini) and OpenAI
|
|
(ChatGPT/DALL-E/gpt-image) embed an invisible SynthID watermark alongside
|
|
a C2PA manifest, so a C2PA manifest signed by one of them on AI-generated
|
|
content implies SynthID in the pixels. Adobe Firefly / Microsoft Designer
|
|
sign C2PA but do not use SynthID, so they return None.
|
|
|
|
The verdict is reliable only while the C2PA manifest is intact -- absence
|
|
is not proof, because C2PA can be stripped while the pixel watermark
|
|
survives, and the pixel watermark itself is not locally detectable
|
|
(proprietary decoder).
|
|
|
|
Args:
|
|
image_path: Path to the image (PNG, JPEG, WebP, or ISOBMFF container).
|
|
|
|
Returns:
|
|
Comma-joined vendor name(s) (e.g. ``"OpenAI"``) or None.
|
|
"""
|
|
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, synthid_vendors_in
|
|
|
|
# PNG: the caBX chunk parser gives a clean, structured issuer.
|
|
vendors = extract_c2pa_info(image_path).get("synthid_vendors")
|
|
if vendors:
|
|
return ", ".join(vendors)
|
|
|
|
# Non-PNG containers (JPEG APP11, WebP, AVIF/HEIF/JXL uuid box) keep the
|
|
# C2PA manifest where the PNG parser can't reach it. Binary-scan for the
|
|
# same signal: a C2PA manifest from a SynthID-using issuer on AI content.
|
|
with open(image_path, "rb") as f:
|
|
data = f.read(1024 * 1024)
|
|
has_c2pa = b"c2pa" in data.lower() or C2PA_UUID in data
|
|
# Matches both "trainedAlgorithmicMedia" and "compositeWithTrainedAlgorithmicMedia".
|
|
ai_source = b"trainedAlgorithmicMedia" in data or b"TrainedAlgorithmicMedia" in data
|
|
if not (has_c2pa and ai_source):
|
|
return None
|
|
matched = synthid_vendors_in(data)
|
|
return ", ".join(matched) if matched else None
|
|
|
|
|
|
def get_ai_metadata(image_path: Path) -> dict[str, str]:
|
|
"""Extract AI-related metadata from an image.
|
|
|
|
Args:
|
|
image_path: Path to the image.
|
|
|
|
Returns:
|
|
Dictionary of AI metadata key-value pairs.
|
|
"""
|
|
from PIL import Image
|
|
|
|
from remove_ai_watermarks.noai.c2pa import extract_c2pa_info, synthid_verdict
|
|
|
|
result: dict[str, str] = {}
|
|
|
|
# PIL may not open AVIF/HEIF/JPEG-XL without optional plugins (and
|
|
# ultralytics' Image.open patch can raise ModuleNotFoundError); fall through
|
|
# to the C2PA/binary path on any open failure. See CLAUDE.md.
|
|
try:
|
|
with Image.open(image_path) as img:
|
|
for key, value in img.info.items():
|
|
if _is_ai_key(key):
|
|
if isinstance(value, bytes):
|
|
result[key] = f"<binary {len(value)} bytes>"
|
|
elif isinstance(value, str) and len(value) > 200:
|
|
result[key] = value[:200] + "…"
|
|
else:
|
|
result[key] = str(value)
|
|
except Exception as exc:
|
|
logger.debug("PIL could not open %s for AI-metadata scan: %s", image_path, exc)
|
|
|
|
# C2PA manifest fields from the single canonical parser (noai/c2pa.py).
|
|
c2pa = extract_c2pa_info(image_path)
|
|
for key in (
|
|
"c2pa_manifest",
|
|
"claim_generator",
|
|
"c2pa_spec",
|
|
"issuer",
|
|
"source_type",
|
|
"actions",
|
|
"synthid_watermark",
|
|
):
|
|
if key in c2pa:
|
|
result.setdefault(key, str(c2pa[key]))
|
|
|
|
# Non-PNG containers (JPEG/WebP/AVIF): extract_c2pa_info is PNG-only, so
|
|
# fall back to the format-agnostic source check for the SynthID verdict.
|
|
if "synthid_watermark" not in result and (vendor := synthid_source(image_path)):
|
|
result.setdefault("synthid_watermark", synthid_verdict(vendor))
|
|
return result
|
|
|
|
|
|
def remove_ai_metadata(
|
|
source_path: Path,
|
|
output_path: Path | None = None,
|
|
keep_standard: bool = True,
|
|
) -> Path:
|
|
"""Remove AI-generation metadata from an image.
|
|
|
|
Strips EXIF AI tags, PNG text chunks, and C2PA provenance manifests
|
|
while optionally preserving standard metadata (Author, Title, etc.).
|
|
|
|
Args:
|
|
source_path: Path to the source image.
|
|
output_path: Output path (None = overwrite source).
|
|
keep_standard: If True, preserve standard metadata fields.
|
|
|
|
Returns:
|
|
Path to the cleaned image.
|
|
"""
|
|
import piexif
|
|
from PIL import Image
|
|
from PIL.PngImagePlugin import PngInfo
|
|
|
|
if output_path is None:
|
|
output_path = source_path
|
|
|
|
# AVIF/HEIF/JPEG-XL: strip C2PA boxes at the container level without
|
|
# re-encoding. Avoids needing PIL plugins (pillow-heif / pillow-jxl) and
|
|
# preserves pixel data bit-for-bit.
|
|
if source_path.suffix.lower() in (".avif", ".heif", ".heic", ".jxl"):
|
|
from remove_ai_watermarks.noai.isobmff import strip_c2pa_boxes
|
|
|
|
data = source_path.read_bytes()
|
|
cleaned, stripped = strip_c2pa_boxes(data)
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
output_path.write_bytes(cleaned)
|
|
logger.info("Stripped %d C2PA box(es) → %s", stripped, output_path)
|
|
return output_path
|
|
|
|
# Read image and filter metadata
|
|
with Image.open(source_path) as img:
|
|
img = img.copy()
|
|
fmt = output_path.suffix.lower()
|
|
|
|
save_kwargs: dict = {}
|
|
if fmt in (".jpg", ".jpeg"):
|
|
save_kwargs["format"] = "JPEG"
|
|
if img.mode in ("RGBA", "P"):
|
|
img = img.convert("RGB")
|
|
else:
|
|
save_kwargs["format"] = "PNG"
|
|
|
|
# Collect non-AI metadata
|
|
kept_meta: dict[str, str] = {}
|
|
exif_data = None
|
|
|
|
for key, value in img.info.items():
|
|
if _is_ai_key(key):
|
|
continue
|
|
if key == "exif":
|
|
with contextlib.suppress(Exception):
|
|
exif_data = piexif.load(value)
|
|
continue
|
|
if key in ("dpi", "gamma"):
|
|
save_kwargs[key] = value
|
|
continue
|
|
if keep_standard and key in STANDARD_METADATA_KEYS:
|
|
kept_meta[key] = str(value) if not isinstance(value, str) else value
|
|
|
|
# Apply cleaned metadata
|
|
if save_kwargs["format"] == "PNG" and kept_meta:
|
|
pnginfo = PngInfo()
|
|
for k, v in kept_meta.items():
|
|
pnginfo.add_text(k, v)
|
|
save_kwargs["pnginfo"] = pnginfo
|
|
|
|
if exif_data and save_kwargs["format"] == "JPEG":
|
|
with contextlib.suppress(Exception):
|
|
save_kwargs["exif"] = piexif.dump(exif_data)
|
|
|
|
output_path.parent.mkdir(parents=True, exist_ok=True)
|
|
img.save(output_path, **save_kwargs)
|
|
|
|
logger.info("Stripped AI metadata → %s", output_path)
|
|
return output_path
|