mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
Single-file read-only scanner producing one JSONL record per image: AI verdict via identify (metadata-only, no visible marks), full EXIF/ IPTC/XMP/PNG/JPEG/WebP/ISOBMFF inventories, C2PA store, JPEG encoder forensics (quant tables, IJG quality, scan script, Huffman, subsampling), XMP edit-trail fields, hashes/timestamps, macOS download provenance, Live Photo pairing id. Runs standalone (pillow/piexif/c2pa-python) with the library optional; supports gzip output, resume after interruption, and head-only scanning of oversized files.
1049 lines
38 KiB
Python
1049 lines
38 KiB
Python
"""Inventory AI-provenance signals AND all raw metadata over a dataset.
|
|
|
|
Read-only. For every image it writes one JSONL record containing:
|
|
|
|
- file basics: name, path, size in bytes, container format (content-sniffed),
|
|
pixel dimensions, dpi, color mode, bit depth
|
|
- the library verdict: full `identify` ProvenanceReport (what we detect as AI)
|
|
- raw metadata the library does NOT interpret, so nothing has to be re-run:
|
|
full EXIF (all IFDs, decoded tag names, MakerNote in full hex), all XMP
|
|
packets, all PNG chunks (text chunks decoded and inflated, binary chunks
|
|
as base64), all JPEG APP segments (marker + full base64), WebP RIFF
|
|
chunks, ISOBMFF box/item inventory for HEIC/AVIF/MOV, IPTC-IIM dataset,
|
|
ICC profile (full base64), C2PA manifest store JSON, post-EOI/IEND
|
|
trailers, EXIF thumbnail (bytes + its own JPEG forensics), JPEG encoder
|
|
forensics (quant tables, IJG quality estimate, progressive scan script,
|
|
Huffman tables, subsampling, JFIF/Adobe markers), XMP edit-trail fields
|
|
(creator tool, history agents/actions/timestamps, namespaces, motion
|
|
photo / Ultra HDR / depth-map flags), file hashes and timestamps, macOS
|
|
download provenance xattrs, Live Photo content identifier. Embedded
|
|
binary blobs are base64'd in full with a 1 MB ceiling per blob.
|
|
|
|
Usage:
|
|
python scan_dataset.py /path/to/dataset out_prefix
|
|
|
|
Writes out_prefix.jsonl (one record per file) and out_prefix.csv (flat
|
|
summary of the detection verdicts only, for quick sorting). If the prefix
|
|
ends with .gz the JSONL is gzip-compressed on the fly (3-5x smaller; still
|
|
streamable line by line).
|
|
|
|
For a large dataset, parallelize by sharding the input into folders and
|
|
running one process per shard (a multiprocessing pool hangs on macOS once
|
|
cv2/PIL are loaded; independent processes do not):
|
|
|
|
for d in /dataset/*/; do
|
|
python scan_dataset.py "$d" "out_$(basename "$d")" &
|
|
done
|
|
wait
|
|
cat out_*.jsonl > dataset.jsonl # or just read the shards lazily
|
|
|
|
Reading big results: never load the JSONL whole. Stream it:
|
|
polars.scan_ndjson (lazy), pandas.read_json(lines=True, chunksize=...),
|
|
or a plain line loop. One line = one self-contained JSON record.
|
|
|
|
Standalone use (no remove_ai_watermarks checkout):
|
|
pip install pillow piexif c2pa-python pillow-heif
|
|
pillow-heif is only needed for HEIC/AVIF inputs. Without
|
|
remove_ai_watermarks the record simply carries no AI verdict; everything
|
|
raw is still collected.
|
|
"""
|
|
|
|
import base64
|
|
import contextlib
|
|
import csv
|
|
import json
|
|
import struct
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Any
|
|
|
|
from PIL import Image
|
|
from PIL.IptcImagePlugin import getiptcinfo
|
|
|
|
try:
|
|
from remove_ai_watermarks.identify import identify
|
|
except ImportError: # standalone mode: raw metadata only, no AI verdict
|
|
identify = None # type: ignore[assignment]
|
|
|
|
try: # optional reuse of the repo's cached C2PA reader (standalone-safe)
|
|
from remove_ai_watermarks.noai.c2pa import read_manifest_store_json as _repo_c2pa_reader
|
|
except ImportError:
|
|
_repo_c2pa_reader = None # type: ignore[assignment]
|
|
|
|
SUPPORTED = {
|
|
".png",
|
|
".jpg",
|
|
".jpeg",
|
|
".webp",
|
|
".heic",
|
|
".heif",
|
|
".avif",
|
|
".tif",
|
|
".tiff",
|
|
".bmp",
|
|
".gif",
|
|
# video/px containers: no pixel decode, but C2PA reads them (Sora/Veo
|
|
# carry C2PA manifests) and the byte scans still apply
|
|
".mp4",
|
|
".mov",
|
|
".m4v",
|
|
".jxl",
|
|
}
|
|
|
|
_B64_CAP = 1 << 20 # 1 MB safety ceiling per embedded blob
|
|
|
|
|
|
def _safe_str(v: Any) -> str:
|
|
try:
|
|
return str(v)
|
|
except Exception:
|
|
return repr(v)
|
|
|
|
|
|
def _b64(b: bytes) -> str:
|
|
"""Full base64 of a binary blob, with a 1 MB safety ceiling."""
|
|
if len(b) > _B64_CAP:
|
|
return base64.b64encode(b[:_B64_CAP]).decode("ascii") + f"...TRUNCATED({len(b)} bytes total)"
|
|
return base64.b64encode(b).decode("ascii")
|
|
|
|
|
|
def _decode_exif_value(v: Any) -> Any:
|
|
"""Make a piexif value JSON-safe; bytes are kept in full as hex."""
|
|
if isinstance(v, bytes):
|
|
if len(v) <= 64:
|
|
try:
|
|
return v.decode("utf-8", "strict")
|
|
except (UnicodeDecodeError, ValueError):
|
|
return f"hex:{v.hex()}"
|
|
return f"hex:{v.hex()}"
|
|
if isinstance(v, (tuple, list)):
|
|
return [_decode_exif_value(x) for x in v]
|
|
return v
|
|
|
|
|
|
def read_full_exif(path: Path, exif_blob: bytes | None = None) -> tuple[dict[str, Any], bytes | None]:
|
|
"""All EXIF IFDs with decoded tag names (piexif, no re-encode), plus the
|
|
raw embedded-thumbnail bytes for the caller's own thumbnail forensics.
|
|
|
|
``exif_blob`` is the PIL-exposed EXIF blob (PNG/WebP/HEIC path) so the
|
|
caller's single Image.open is not repeated here."""
|
|
import piexif
|
|
|
|
try:
|
|
exif = piexif.load(str(path))
|
|
except Exception:
|
|
if not exif_blob:
|
|
return {}, None
|
|
try:
|
|
exif = piexif.load(exif_blob)
|
|
except Exception as exc:
|
|
return {"error": _safe_str(exc)}, None
|
|
out: dict[str, Any] = {}
|
|
thumbnail: bytes | None = None
|
|
for ifd, tags in exif.items():
|
|
if ifd == "thumbnail":
|
|
thumbnail = tags if isinstance(tags, bytes) else None
|
|
out["thumbnail"] = f"{len(tags)} bytes" if isinstance(tags, bytes) else None
|
|
continue
|
|
if not isinstance(tags, dict):
|
|
continue
|
|
tag_names = piexif.TAGS.get(ifd, {})
|
|
decoded = {}
|
|
for t, v in tags.items():
|
|
name = tag_names.get(t, {}).get("name", f"tag_{t}")
|
|
if name == "MakerNote" and isinstance(v, bytes):
|
|
# full hex, no cap: measured on real uploads, Apple is ~2 KB
|
|
# but Canon reaches 28 KB and Sony 38 KB (AF data, serials,
|
|
# embedded previews) -- a cap would silently drop exactly the
|
|
# camera-original evidence this scan exists to preserve
|
|
decoded[name] = f"hex:{v.hex()}"
|
|
else:
|
|
decoded[name] = _decode_exif_value(v)
|
|
out[ifd] = decoded
|
|
return out, thumbnail
|
|
|
|
|
|
def _png_text_decode(ctype: str, body: bytes) -> str:
|
|
"""Decode a tEXt/zTXt/iTXt chunk, inflating zlib where used.
|
|
|
|
The compressed forms are where ComfyUI / Automatic1111 hide the
|
|
generation workflow and prompt, so skipping the inflate would drop
|
|
the strongest AI-provenance text a PNG can carry."""
|
|
import zlib
|
|
|
|
if ctype == "tEXt":
|
|
return body.decode("utf-8", "replace")
|
|
if ctype == "zTXt":
|
|
nul = body.find(b"\x00")
|
|
if nul == -1:
|
|
return body.decode("utf-8", "replace")
|
|
keyword = body[:nul].decode("latin-1", "replace")
|
|
# body[nul+1] = compression method (0 = zlib)
|
|
try:
|
|
text = zlib.decompress(body[nul + 2 :]).decode("utf-8", "replace")
|
|
except zlib.error:
|
|
text = body.decode("utf-8", "replace")
|
|
return f"{keyword}\x00{text}"
|
|
# iTXt: keyword\0 compflag(1) compmethod(1) lang\0 translated\0 text
|
|
parts = body.split(b"\x00", 1)
|
|
if len(parts) < 2:
|
|
return body.decode("utf-8", "replace")
|
|
keyword = parts[0].decode("latin-1", "replace")
|
|
rest = parts[1]
|
|
if len(rest) < 2:
|
|
return body.decode("utf-8", "replace")
|
|
compflag = rest[0]
|
|
tail = rest[2:]
|
|
for _ in range(2): # skip language tag and translated keyword
|
|
nul = tail.find(b"\x00")
|
|
if nul == -1:
|
|
return body.decode("utf-8", "replace")
|
|
tail = tail[nul + 1 :]
|
|
if compflag:
|
|
with contextlib.suppress(zlib.error):
|
|
tail = zlib.decompress(tail)
|
|
return f"{keyword}\x00{tail.decode('utf-8', 'replace')}"
|
|
|
|
|
|
def read_png_chunks(data: bytes) -> tuple[list[dict[str, Any]], int]:
|
|
"""Every PNG chunk in order (type, length; text chunks decoded and
|
|
inflated, binary chunks as base64) plus the post-IEND trailer size."""
|
|
chunks: list[dict[str, Any]] = []
|
|
post_iend = 0
|
|
try:
|
|
pos = 8
|
|
while pos + 12 <= len(data):
|
|
length = struct.unpack(">I", data[pos : pos + 4])[0]
|
|
ctype = data[pos + 4 : pos + 8].decode("latin-1")
|
|
body = data[pos + 8 : pos + 8 + length]
|
|
entry: dict[str, Any] = {"type": ctype, "length": length}
|
|
if ctype in ("tEXt", "zTXt", "iTXt"):
|
|
entry["text"] = _png_text_decode(ctype, body)
|
|
if entry["text"].startswith("XML:com.adobe.xmp"):
|
|
entry["kind"] = "xmp"
|
|
elif ctype == "tIME" and length == 7:
|
|
y, mo, d, h, mi, s = struct.unpack(">HBBBBB", body)
|
|
entry["time"] = f"{y:04d}-{mo:02d}-{d:02d}T{h:02d}:{mi:02d}:{s:02d}Z"
|
|
elif ctype == "gAMA" and length == 4:
|
|
entry["gamma"] = struct.unpack(">I", body)[0] / 100000
|
|
elif ctype == "sRGB" and length == 1:
|
|
entry["rendering_intent"] = body[0]
|
|
elif ctype == "iCCP":
|
|
nul = body.find(b"\x00")
|
|
if nul > 0:
|
|
entry["profile_name"] = body[:nul].decode("latin-1", "replace")
|
|
entry["base64"] = _b64(body)
|
|
elif ctype == "iDOT":
|
|
# present in iOS/macOS screenshots
|
|
entry["apple_screenshot_marker"] = True
|
|
elif ctype in ("IHDR", "IDAT"):
|
|
pass # pixel-data / header chunks: length is signal enough
|
|
elif length:
|
|
entry["base64"] = _b64(body)
|
|
chunks.append(entry)
|
|
pos += 12 + length
|
|
if ctype == "IEND":
|
|
post_iend = len(data) - pos
|
|
break
|
|
except Exception as exc:
|
|
chunks.append({"error": _safe_str(exc)})
|
|
return chunks, post_iend
|
|
|
|
|
|
def read_jpeg_segments(data: bytes) -> dict[str, Any]:
|
|
"""Every JPEG APP segment in order, plus post-EOI trailer size.
|
|
|
|
XMP APP1 segments are kept as full text; every other segment body is
|
|
kept as full base64 (1 MB ceiling per segment).
|
|
"""
|
|
result: dict[str, Any] = {"segments": [], "post_eoi_bytes": 0}
|
|
try:
|
|
pos = 2
|
|
while pos + 4 <= len(data):
|
|
if data[pos] != 0xFF:
|
|
break
|
|
marker = data[pos + 1]
|
|
if marker == 0xD9: # EOI
|
|
result["post_eoi_bytes"] = len(data) - (pos + 2)
|
|
break
|
|
if marker == 0xDA: # SOS: entropy-coded data follows
|
|
eoi = data.rfind(b"\xff\xd9")
|
|
if eoi != -1:
|
|
result["post_eoi_bytes"] = len(data) - (eoi + 2)
|
|
break
|
|
if not (0xE0 <= marker <= 0xEF):
|
|
length = struct.unpack(">H", data[pos + 2 : pos + 4])[0]
|
|
pos += 2 + length
|
|
continue
|
|
length = struct.unpack(">H", data[pos + 2 : pos + 4])[0]
|
|
body = data[pos + 4 : pos + 2 + length]
|
|
name = f"APP{marker - 0xE0}"
|
|
entry: dict[str, Any] = {"marker": name, "length": length}
|
|
if body.startswith(b"http://ns.adobe.com/xap/1.0/\x00"):
|
|
entry["kind"] = "xmp"
|
|
entry["text"] = body[29:].decode("utf-8", "replace")
|
|
elif name == "APP2" and body.startswith(b"MPF\x00"):
|
|
# Multi-Picture Format: Ultra HDR gain map, Samsung dual shot
|
|
entry["kind"] = "mpf"
|
|
entry["base64"] = _b64(body)
|
|
elif name == "APP2" and body.startswith(b"ICC_PROFILE"):
|
|
entry["kind"] = "icc"
|
|
entry["base64"] = _b64(body)
|
|
elif name == "APP2" and body.startswith(b"FPXR"):
|
|
entry["kind"] = "flashpix"
|
|
entry["base64"] = _b64(body)
|
|
elif name == "APP11":
|
|
entry["kind"] = "c2pa_or_jumbf"
|
|
# the parsed manifest is in c2pa_store, but the raw JUMBF
|
|
# also carries assertion thumbnails the JSON may omit
|
|
entry["base64"] = _b64(body)
|
|
elif body.startswith(b"Exif\x00\x00"):
|
|
entry["kind"] = "exif"
|
|
entry["base64"] = _b64(body)
|
|
elif body.startswith(b"Photoshop 3.0\x00"):
|
|
entry["kind"] = "iptc_iim"
|
|
entry["base64"] = _b64(body)
|
|
else:
|
|
entry["base64"] = _b64(body)
|
|
result["segments"].append(entry)
|
|
pos += 2 + length
|
|
except Exception as exc:
|
|
result["error"] = _safe_str(exc)
|
|
return result
|
|
|
|
|
|
def read_pil_info(path: Path) -> tuple[dict[str, Any], dict[str, Any], bytes | None]:
|
|
"""One Image.open serving all PIL-derived data: container basics,
|
|
img.info passthrough (XMP, comments), the IPTC-IIM dataset, and the
|
|
raw EXIF blob (for the caller's piexif parse on PNG/WebP/HEIC)."""
|
|
out: dict[str, Any] = {}
|
|
iptc: dict[str, Any] = {}
|
|
exif_blob: bytes | None = None
|
|
try:
|
|
with Image.open(path) as img:
|
|
out["format"] = img.format
|
|
out["mode"] = img.mode
|
|
out["width"], out["height"] = img.size
|
|
out["n_frames"] = getattr(img, "n_frames", 1)
|
|
dpi = img.info.get("dpi")
|
|
if dpi:
|
|
out["dpi"] = [round(float(d), 2) for d in dpi]
|
|
icc = img.info.get("icc_profile")
|
|
if icc:
|
|
out["icc_profile"] = {
|
|
"length": len(icc),
|
|
# header: profile class, color space, PCS (bytes 12-24)
|
|
"header_hex": icc[12:24].hex() if len(icc) >= 24 else "",
|
|
"base64": _b64(icc),
|
|
}
|
|
blob = img.info.get("exif")
|
|
if isinstance(blob, bytes):
|
|
exif_blob = blob
|
|
try:
|
|
info = getiptcinfo(img)
|
|
except Exception:
|
|
info = None
|
|
if info:
|
|
iptc = {f"{k[0]}:{k[1]}": _decode_exif_value(v) for k, v in info.items()}
|
|
for key, value in img.info.items():
|
|
if key in ("icc_profile", "exif", "dpi"):
|
|
continue
|
|
if isinstance(value, bytes):
|
|
try:
|
|
out[f"info:{key}"] = value.decode("utf-8", "strict")
|
|
except (UnicodeDecodeError, ValueError):
|
|
out[f"info:{key}"] = f"base64:{_b64(value)}"
|
|
else:
|
|
out[f"info:{key}"] = _safe_str(value)
|
|
except Exception as exc:
|
|
out["error"] = _safe_str(exc)
|
|
return out, iptc, exif_blob
|
|
|
|
|
|
def read_c2pa_store(path: Path) -> dict[str, Any]:
|
|
"""Full C2PA manifest store JSON. Prefers the repo's cached Reader
|
|
wrapper when the package is installed; falls back to a direct
|
|
c2pa-python Reader call in standalone mode."""
|
|
if _repo_c2pa_reader is not None:
|
|
try:
|
|
store = _repo_c2pa_reader(path)
|
|
return json.loads(store) if store else {}
|
|
except Exception as exc:
|
|
return {"error": _safe_str(exc)}
|
|
try:
|
|
from c2pa import Reader
|
|
|
|
with Reader(str(path)) as reader:
|
|
return json.loads(reader.json())
|
|
except Exception as exc:
|
|
return {"error": _safe_str(exc)}
|
|
|
|
|
|
def sniff_format(head: bytes) -> str:
|
|
if head.startswith(b"\x89PNG"):
|
|
return "png"
|
|
if head.startswith(b"\xff\xd8"):
|
|
return "jpeg"
|
|
if head.startswith(b"RIFF") and head[8:12] == b"WEBP":
|
|
return "webp"
|
|
if head[4:8] == b"ftyp":
|
|
return f"isobmff:{head[8:12].decode('latin-1', 'replace')}"
|
|
return f"unknown:{head[:16].hex()}"
|
|
|
|
|
|
# --- forensic helpers: signals that a file is not an untouched original ---
|
|
|
|
# IJG/libjpeg base quantization tables (quality 50), Annex K
|
|
_IJG_LUM_Q50 = [
|
|
16,
|
|
11,
|
|
10,
|
|
16,
|
|
24,
|
|
40,
|
|
51,
|
|
61,
|
|
12,
|
|
12,
|
|
14,
|
|
19,
|
|
26,
|
|
58,
|
|
60,
|
|
55,
|
|
14,
|
|
13,
|
|
16,
|
|
24,
|
|
40,
|
|
57,
|
|
69,
|
|
56,
|
|
14,
|
|
17,
|
|
22,
|
|
29,
|
|
51,
|
|
87,
|
|
80,
|
|
62,
|
|
18,
|
|
22,
|
|
37,
|
|
56,
|
|
68,
|
|
109,
|
|
103,
|
|
77,
|
|
24,
|
|
35,
|
|
55,
|
|
64,
|
|
81,
|
|
104,
|
|
113,
|
|
92,
|
|
49,
|
|
64,
|
|
78,
|
|
87,
|
|
103,
|
|
121,
|
|
120,
|
|
101,
|
|
72,
|
|
92,
|
|
95,
|
|
98,
|
|
112,
|
|
100,
|
|
103,
|
|
99,
|
|
]
|
|
_IJG_CHR_Q50 = [
|
|
17,
|
|
18,
|
|
24,
|
|
47,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
18,
|
|
21,
|
|
26,
|
|
66,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
24,
|
|
26,
|
|
56,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
47,
|
|
66,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
99,
|
|
]
|
|
|
|
|
|
def _ijg_table(quality: int, base: list[int]) -> list[int]:
|
|
scale = 5000 // quality if quality < 50 else 200 - quality * 2
|
|
return [max(1, min(255, (b * scale + 50) // 100)) for b in base]
|
|
|
|
|
|
def estimate_jpeg_quality(lum_table: list[int]) -> int | None:
|
|
"""Best-fit IJG quality for a luminance quant table, else None if the
|
|
table does not look IJG-derived (custom encoder, camera-specific)."""
|
|
best_q, best_err = None, None
|
|
for q in range(1, 101):
|
|
ref = _ijg_table(q, _IJG_LUM_Q50)
|
|
err = sum(abs(a - b) for a, b in zip(lum_table, ref, strict=True))
|
|
if best_err is None or err < best_err:
|
|
best_q, best_err = q, err
|
|
return best_q if best_err is not None and best_err <= 200 else None
|
|
|
|
|
|
def _jpeg_forensics_bytes(data: bytes) -> dict[str, Any]:
|
|
"""Structure-level JPEG forensics: DQT tables (encoder fingerprint),
|
|
SOF type (baseline/progressive) + chroma subsampling, DHT Huffman
|
|
tables (custom = optimizing encoder), per-scan spectral selection
|
|
(progressive scan script), JFIF/Adobe app markers, COM, DRI."""
|
|
out: dict[str, Any] = {}
|
|
try:
|
|
if not data.startswith(b"\xff\xd8"):
|
|
return out
|
|
pos = 2
|
|
scans: list[dict[str, int]] = []
|
|
dqt: dict[str, list[int]] = {}
|
|
dht: list[str] = []
|
|
comments: list[str] = []
|
|
while pos + 4 <= len(data):
|
|
if data[pos] != 0xFF:
|
|
break
|
|
marker = data[pos + 1]
|
|
if marker in (0xD8, 0x01) or 0xD0 <= marker <= 0xD7:
|
|
pos += 2
|
|
continue
|
|
if marker == 0xD9:
|
|
break
|
|
length = struct.unpack(">H", data[pos + 2 : pos + 4])[0]
|
|
body = data[pos + 4 : pos + 2 + length]
|
|
if marker == 0xDB: # DQT
|
|
off = 0
|
|
while off < len(body):
|
|
tid = body[off] & 0x0F
|
|
prec = body[off] >> 4
|
|
n = 128 if prec else 64
|
|
vals = list(body[off + 1 : off + 1 + n])
|
|
if prec: # 16-bit entries
|
|
vals = [struct.unpack(">H", bytes(vals[i : i + 2]))[0] for i in range(0, len(vals) - 1, 2)]
|
|
dqt[str(tid)] = vals[:64]
|
|
off += 1 + n
|
|
elif marker == 0xC4: # DHT: custom tables mean an optimizing encoder
|
|
dht.append(body.hex())
|
|
elif marker == 0xDD and len(body) >= 2: # DRI
|
|
out["restart_interval"] = struct.unpack(">H", body[:2])[0]
|
|
elif marker == 0xE0 and body.startswith(b"JFIF\x00") and len(body) >= 12:
|
|
out["jfif"] = {
|
|
"version": f"{body[5]}.{body[6]}",
|
|
"density_units": body[7],
|
|
"x_density": struct.unpack(">H", body[8:10])[0],
|
|
"y_density": struct.unpack(">H", body[10:12])[0],
|
|
}
|
|
elif marker == 0xEE and body.startswith(b"Adobe") and len(body) >= 12:
|
|
out["adobe_transform"] = body[11]
|
|
elif marker in (0xC0, 0xC1, 0xC2) and len(body) >= 6:
|
|
out["progressive"] = marker == 0xC2
|
|
out["precision_bits"] = body[0]
|
|
out["sof_height"] = struct.unpack(">H", body[1:3])[0]
|
|
out["sof_width"] = struct.unpack(">H", body[3:5])[0]
|
|
comps = []
|
|
for i in range(body[5]):
|
|
c = body[6 + i * 3 : 9 + i * 3]
|
|
if len(c) == 3:
|
|
comps.append({"h": c[1] >> 4, "v": c[1] & 0x0F, "tq": c[2]})
|
|
if len(comps) >= 3:
|
|
lum = comps[0]
|
|
subs = {1: "4:4:4", 2: "4:2:2"}.get(lum["h"] * lum["v"])
|
|
out["subsampling"] = subs or f"{lum['h']}x{lum['v']}"
|
|
elif marker == 0xFE: # COM
|
|
comments.append(body.decode("utf-8", "replace")[:2000])
|
|
elif marker == 0xDA:
|
|
# SOS spectral selection: the progressive scan script
|
|
# differs across libjpeg / mozjpeg / Photoshop
|
|
if len(body) >= 3:
|
|
ns = body[0]
|
|
tail = body[1 + ns * 2 :]
|
|
if len(tail) >= 3:
|
|
scans.append({"ss": tail[0], "se": tail[1], "ah": tail[2] >> 4, "al": tail[2] & 0x0F})
|
|
# skip entropy-coded data to the next marker
|
|
end = data.find(b"\xff\xd9", pos)
|
|
nxt = data.find(b"\xff", pos + 2)
|
|
while nxt != -1 and nxt + 1 < len(data) and data[nxt + 1] == 0x00:
|
|
nxt = data.find(b"\xff", nxt + 2)
|
|
if nxt == -1 or (end != -1 and nxt >= end):
|
|
break
|
|
pos = nxt
|
|
continue
|
|
pos += 2 + length
|
|
if dqt:
|
|
out["quant_tables"] = dqt
|
|
q = estimate_jpeg_quality(dqt.get("0", []))
|
|
if q is not None:
|
|
out["estimated_ijg_quality"] = q
|
|
else:
|
|
out["ijg_derived"] = False
|
|
if dht:
|
|
out["huffman_tables_hex"] = dht
|
|
if comments:
|
|
out["comments"] = comments
|
|
if scans:
|
|
out["scan_count"] = len(scans)
|
|
out["scan_script"] = scans
|
|
except Exception as exc:
|
|
out["error"] = _safe_str(exc)
|
|
return out
|
|
|
|
|
|
def parse_xmp_forensics(xmp_text: str) -> dict[str, Any]:
|
|
"""Edit-trail fields from an XMP packet: creator tool, history events,
|
|
derived-from. These are the direct 'someone edited this' footprints."""
|
|
import re
|
|
|
|
out: dict[str, Any] = {}
|
|
if not xmp_text:
|
|
return out
|
|
for field, patterns in {
|
|
"creator_tool": [r'xmp:CreatorTool="([^"]*)"', r"<xmp:CreatorTool>([^<]*)"],
|
|
"metadata_date": [r'xmp:MetadataDate="([^"]*)"'],
|
|
"modify_date": [r'xmp:ModifyDate="([^"]*)"'],
|
|
"derived_from": [r'stRef:documentID="([^"]*)"'],
|
|
"original_document_id": [r'xmpMM:OriginalDocumentID="([^"]*)"'],
|
|
}.items():
|
|
for pat in patterns:
|
|
m = re.search(pat, xmp_text)
|
|
if m:
|
|
out[field] = m.group(1)[:500]
|
|
break
|
|
agents = re.findall(r"stEvt:softwareAgent>([^<]*)", xmp_text)
|
|
actions = re.findall(r"stEvt:action>([^<]*)", xmp_text)
|
|
whens = re.findall(r"stEvt:when=\"([^\"]*)\"", xmp_text)
|
|
if agents:
|
|
out["history_software_agents"] = agents[:20]
|
|
if actions:
|
|
out["history_actions"] = actions[:20]
|
|
if whens:
|
|
out["history_when"] = whens[:20]
|
|
# declared namespaces are an editor fingerprint on their own:
|
|
# crs = Lightroom/Camera Raw (with edit settings attached), photoshop,
|
|
# xmpG = GIMP, darktable, apple_photos, hdrgm = Ultra HDR, GDepth/GImage/
|
|
# GCamera = Google computational photography (depth map, motion photo)
|
|
namespaces = sorted(set(re.findall(r"xmlns:([A-Za-z0-9_]+)=", xmp_text)))
|
|
if namespaces:
|
|
out["namespaces"] = namespaces
|
|
if re.search(r"crs:\w+[=>\s]", xmp_text):
|
|
out["camera_raw_edited"] = True
|
|
if "GCamera:MotionPhoto" in xmp_text or "Camera:MotionPhoto" in xmp_text:
|
|
out["motion_photo"] = True
|
|
m = re.search(r"MicroVideoOffset[=>\"\s]+(\d+)", xmp_text)
|
|
if m:
|
|
out["micro_video_offset"] = int(m.group(1))
|
|
if "GDepth:" in xmp_text or "GImage:" in xmp_text:
|
|
out["google_depth_map"] = True
|
|
if "hdrgm:" in xmp_text:
|
|
out["ultra_hdr_xmp"] = True
|
|
return out
|
|
|
|
|
|
def xmp_texts_of(record_parts: dict[str, Any]) -> list[str]:
|
|
"""XMP packets, collected only from slots tagged at extraction (the
|
|
container reader knows it is XMP; no substring guessing)."""
|
|
texts: list[str] = []
|
|
for key in ("info:xmp", "info:XML:com.adobe.xmp"):
|
|
value = record_parts.get("pil", {}).get(key)
|
|
if isinstance(value, str):
|
|
texts.append(value)
|
|
for seg in record_parts.get("jpeg", {}).get("segments", []):
|
|
if seg.get("kind") == "xmp" and seg.get("text"):
|
|
texts.append(seg["text"])
|
|
for slot in ("png_chunks", "webp_chunks"):
|
|
for chunk in record_parts.get(slot, []):
|
|
if chunk.get("kind") == "xmp" and chunk.get("text"):
|
|
texts.append(chunk["text"])
|
|
return texts
|
|
|
|
|
|
def read_webp_chunks(data: bytes) -> list[dict[str, Any]]:
|
|
"""WebP RIFF chunk inventory (VP8X/VP8/VP8L/EXIF/XMP/ICCP/ANIM...)."""
|
|
chunks: list[dict[str, Any]] = []
|
|
try:
|
|
pos = 12
|
|
while pos + 8 <= len(data):
|
|
ctype = data[pos : pos + 4].decode("latin-1")
|
|
length = struct.unpack("<I", data[pos + 4 : pos + 8])[0]
|
|
body = data[pos + 8 : pos + 8 + length]
|
|
entry: dict[str, Any] = {"type": ctype, "length": length}
|
|
if ctype == "XMP ":
|
|
entry["kind"] = "xmp"
|
|
entry["text"] = body.decode("utf-8", "replace")
|
|
elif ctype in ("VP8 ", "VP8L", "ALPH", "ANMF"):
|
|
pass # pixel-data chunks: length is signal enough
|
|
elif length:
|
|
entry["base64"] = _b64(body)
|
|
chunks.append(entry)
|
|
pos += 8 + length + (length & 1) # chunks are 2-byte aligned
|
|
except Exception as exc:
|
|
chunks.append({"error": _safe_str(exc)})
|
|
return chunks
|
|
|
|
|
|
def sha256_of(data: bytes) -> str:
|
|
import hashlib
|
|
|
|
return hashlib.sha256(data).hexdigest()
|
|
|
|
|
|
def xattr_where_from(path: Path) -> list[str]:
|
|
"""macOS download-source URLs (kMDItemWhereFroms), empty elsewhere."""
|
|
import os
|
|
import plistlib
|
|
|
|
try:
|
|
raw = os.getxattr(path, "com.apple.metadata:kMDItemWhereFroms")
|
|
value = plistlib.loads(raw)
|
|
return [str(v) for v in value] if isinstance(value, list) else [str(value)]
|
|
except (AttributeError, OSError, ValueError):
|
|
return []
|
|
|
|
|
|
def xattr_quarantine(path: Path) -> str | None:
|
|
"""macOS quarantine string: flags; timestamp; downloading agent (Safari,
|
|
Telegram, Chrome...). Presence alone means 'came from the internet'."""
|
|
import os
|
|
|
|
try:
|
|
return os.getxattr(path, "com.apple.quarantine").decode("utf-8", "replace")[:500]
|
|
except (AttributeError, OSError):
|
|
return None
|
|
|
|
|
|
def read_isobmff_inventory(data: bytes) -> dict[str, Any]:
|
|
"""HEIC/AVIF/MOV box inventory: top-level boxes plus the meta item
|
|
types (Exif, mime=XMP, auxl depth/gain-map, aae Apple-edits plist,
|
|
irot derived images). Strong phone-provenance signal."""
|
|
out: dict[str, Any] = {}
|
|
try:
|
|
|
|
def boxes(start: int, end: int) -> list[tuple[str, int, int]]:
|
|
result = []
|
|
pos = start
|
|
while pos + 8 <= end:
|
|
size, btype = struct.unpack(">I4s", data[pos : pos + 8])
|
|
t = btype.decode("latin-1")
|
|
header = 8
|
|
if size == 1:
|
|
size = struct.unpack(">Q", data[pos + 8 : pos + 16])[0]
|
|
header = 16
|
|
elif size == 0:
|
|
size = end - pos
|
|
if size < header or pos + size > end:
|
|
break
|
|
result.append((t, pos + header, pos + size))
|
|
pos += size
|
|
return result
|
|
|
|
top = boxes(0, len(data))
|
|
out["boxes"] = [t for t, _, _ in top]
|
|
for t, s, e in top:
|
|
if t == "moov":
|
|
for ct, cs, ce in boxes(s, e):
|
|
if ct == "mvhd" and ce - cs >= 24:
|
|
# full box + creation/modification times (1904 epoch)
|
|
version = data[cs]
|
|
base = cs + 4
|
|
creation = struct.unpack(">I", data[base : base + 4])[0] if version == 0 else None
|
|
if creation:
|
|
out["mvhd_creation_time"] = creation - 2082844800
|
|
elif t == "meta":
|
|
# full box: 4 bytes version/flags, then child boxes
|
|
for ct, cs, ce in boxes(s + 4, e):
|
|
if ct == "iinf":
|
|
# full box + entry count, then infe entries
|
|
count = struct.unpack(">H", data[cs + 4 : cs + 6])[0]
|
|
out["meta_item_count"] = count
|
|
item_types = []
|
|
for it, is_, ie in boxes(cs + 6, ce):
|
|
if it == "infe" and ie - is_ >= 8:
|
|
# infe full box: version(1)+flags(3), then
|
|
# v2: item_ID(2)+protection(2)+item_type(4)
|
|
# v3: item_ID(4)+protection(2)+item_type(4)
|
|
version = data[is_]
|
|
off = is_ + 4 + (4 if version == 3 else 2) + 2
|
|
if off + 4 <= ie:
|
|
item_types.append(data[off : off + 4].decode("latin-1", "replace"))
|
|
if item_types:
|
|
out["meta_item_types"] = sorted(set(item_types))
|
|
elif ct == "iprp":
|
|
out["has_iprp"] = True
|
|
for pt, ps, pe in boxes(cs, ce):
|
|
if pt == "ipco":
|
|
props = [t for t, _, _ in boxes(ps, pe)]
|
|
out["ipco_properties"] = props
|
|
# auxC holds the auxiliary image type URN
|
|
for qt, qs, qe in boxes(ps, pe):
|
|
if qt == "auxC":
|
|
out["auxc_types"] = (
|
|
data[qs + 4 : qe].split(b"\x00")[0].decode("latin-1", "replace")
|
|
)
|
|
elif ct == "iref":
|
|
out["has_iref"] = True
|
|
# QuickTime metadata keys (©mak/©mod/©swr) for the MOV side of
|
|
# Live Photos: tolerant printable-string grab after each atom
|
|
import re
|
|
|
|
qt: dict[str, str] = {}
|
|
for atom, key in ((b"\xa9mak", "make"), (b"\xa9mod", "model"), (b"\xa9swr", "software")):
|
|
idx = data.find(atom)
|
|
if idx != -1:
|
|
m = re.search(rb"[ -~]{4,80}", data[idx + 4 : idx + 200])
|
|
if m:
|
|
qt[key] = m.group(0).decode("ascii", "replace")
|
|
if qt:
|
|
out["quicktime"] = qt
|
|
except Exception as exc:
|
|
out["error"] = _safe_str(exc)
|
|
return out
|
|
|
|
|
|
def apple_live_photo_id(head: bytes) -> str | None:
|
|
"""Apple Live Photo content identifier (links the still to its MOV).
|
|
|
|
The UUID sits in the Apple MakerNote (tag 17) of the still and in the
|
|
MOV metadata; a raw head scan finds it in either container."""
|
|
import re
|
|
|
|
# the UUID string sits next to "content.identifier" in the MOV, but in
|
|
# the STILL it is a bare UUID inside the Apple MakerNote (whose header
|
|
# is "Apple iOS"), so gate on either marker
|
|
if b"content.identifier" not in head and b"com.apple.quicktime" not in head and b"Apple iOS" not in head:
|
|
return None
|
|
m = re.search(
|
|
rb"[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}",
|
|
head,
|
|
)
|
|
return m.group(0).decode("ascii") if m else None
|
|
|
|
|
|
_MAX_FULL_READ = 256 << 20 # files bigger than this are scanned head-only
|
|
_HEAD_READ = 4 << 20
|
|
|
|
|
|
def _sha256_stream(path: Path) -> str:
|
|
import hashlib
|
|
|
|
h = hashlib.sha256()
|
|
with open(path, "rb") as f:
|
|
for block in iter(lambda: f.read(1 << 20), b""):
|
|
h.update(block)
|
|
return h.hexdigest()
|
|
|
|
|
|
def scan_file(path: Path) -> dict[str, Any]:
|
|
stat = path.stat()
|
|
oversized = stat.st_size > _MAX_FULL_READ
|
|
if oversized:
|
|
data = None
|
|
with open(path, "rb") as f:
|
|
head = f.read(_HEAD_READ)
|
|
else:
|
|
data = path.read_bytes()
|
|
head = data
|
|
record: dict[str, Any] = {
|
|
"file": str(path),
|
|
"name": path.name,
|
|
"extension": path.suffix.lower(),
|
|
"size_bytes": stat.st_size,
|
|
"mtime": stat.st_mtime,
|
|
"birthtime": getattr(stat, "st_birthtime", None),
|
|
"sha256": _sha256_stream(path) if data is None else sha256_of(data),
|
|
"content_format": sniff_format(head),
|
|
}
|
|
if oversized:
|
|
# too big to hold in memory: path-based readers (identify, PIL,
|
|
# piexif, c2pa) still run; byte-level walkers are skipped
|
|
record["oversized"] = {"head_scanned_bytes": len(head)}
|
|
where_from = xattr_where_from(path)
|
|
if where_from:
|
|
record["download_source_urls"] = where_from
|
|
quarantine = xattr_quarantine(path)
|
|
if quarantine:
|
|
record["quarantine"] = quarantine
|
|
live_photo_id = apple_live_photo_id(head[: 2 << 20])
|
|
if live_photo_id:
|
|
record["live_photo_content_id"] = live_photo_id
|
|
if identify is None:
|
|
record["verdict"] = {"skipped": "remove_ai_watermarks not installed"}
|
|
else:
|
|
try:
|
|
# visible-mark detectors are cv2-heavy and are the slow part; the
|
|
# dataset scan only cares about metadata/embedded signals
|
|
report = identify(path, check_visible=False)
|
|
record["verdict"] = {
|
|
"is_ai_generated": report.is_ai_generated,
|
|
"platform": report.platform,
|
|
"confidence": report.confidence,
|
|
"ai_source_kind": report.ai_source_kind,
|
|
"ai_from_metadata": report.ai_from_metadata,
|
|
"watermarks": [str(w) for w in report.watermarks],
|
|
"signals": [str(s) for s in report.signals],
|
|
"caveats": [str(c) for c in report.caveats],
|
|
"integrity_clashes": [str(c) for c in report.integrity_clashes],
|
|
}
|
|
except Exception as exc:
|
|
record["verdict"] = {"error": _safe_str(exc)}
|
|
record["pil"], record["iptc"], exif_blob = read_pil_info(path)
|
|
record["exif"], thumbnail = read_full_exif(path, exif_blob)
|
|
record["c2pa_store"] = read_c2pa_store(path)
|
|
if data is not None:
|
|
fmt = record["content_format"]
|
|
if fmt == "png":
|
|
record["png_chunks"], post_iend = read_png_chunks(data)
|
|
if post_iend:
|
|
record["png_post_iend_bytes"] = post_iend
|
|
elif fmt == "jpeg":
|
|
record["jpeg"] = read_jpeg_segments(data)
|
|
record["jpeg_forensics"] = _jpeg_forensics_bytes(data)
|
|
elif fmt == "webp":
|
|
record["webp_chunks"] = read_webp_chunks(data)
|
|
elif fmt.startswith("isobmff"):
|
|
record["isobmff"] = read_isobmff_inventory(data)
|
|
# edit-trail parse over every XMP packet found in any container slot
|
|
xmp: dict[str, Any] = {}
|
|
for text in xmp_texts_of(record):
|
|
for key, value in parse_xmp_forensics(text).items():
|
|
xmp.setdefault(key, value)
|
|
if xmp:
|
|
record["xmp_forensics"] = xmp
|
|
if thumbnail:
|
|
record["has_exif_thumbnail"] = True
|
|
# the embedded thumbnail is its own JPEG; after an edit its encoder
|
|
# forensics commonly MISMATCH the main image (classic tamper tell)
|
|
thumb_forensics = _jpeg_forensics_bytes(thumbnail)
|
|
thumb_forensics["base64"] = _b64(thumbnail)
|
|
record["exif_thumbnail_forensics"] = thumb_forensics
|
|
return record
|
|
|
|
|
|
def summary_row(record: dict[str, Any]) -> dict[str, Any]:
|
|
v = record.get("verdict", {})
|
|
pil = record.get("pil", {})
|
|
return {
|
|
"file": record.get("file"),
|
|
"name": record.get("name"),
|
|
"size_bytes": record.get("size_bytes"),
|
|
"sha256": record.get("sha256"),
|
|
"content_format": record.get("content_format"),
|
|
"width": pil.get("width"),
|
|
"height": pil.get("height"),
|
|
"dpi": json.dumps(pil.get("dpi")),
|
|
"is_ai_generated": v.get("is_ai_generated"),
|
|
"platform": v.get("platform"),
|
|
"confidence": v.get("confidence"),
|
|
"ai_source_kind": v.get("ai_source_kind"),
|
|
"watermarks": "; ".join(v.get("watermarks", [])),
|
|
"signals": "; ".join(v.get("signals", [])),
|
|
"integrity_clashes": "; ".join(v.get("integrity_clashes", [])),
|
|
}
|
|
|
|
|
|
def main() -> None:
|
|
if len(sys.argv) != 3:
|
|
print(__doc__)
|
|
sys.exit(2)
|
|
root, prefix = Path(sys.argv[1]), sys.argv[2]
|
|
files = sorted(str(p) for p in root.rglob("*") if p.is_file() and p.suffix.lower() in SUPPORTED)
|
|
# resume: skip files already present in the output of a previous
|
|
# (interrupted) run with the same prefix
|
|
gz = prefix.endswith(".gz")
|
|
jsonl_name = prefix if gz else f"{prefix}.jsonl"
|
|
csv_name = f"{prefix[:-3] if gz else prefix}.csv"
|
|
import gzip
|
|
|
|
jsonl_path = Path(jsonl_name)
|
|
done: set[str] = set()
|
|
if jsonl_path.exists():
|
|
opener = gzip.open if gz else open
|
|
with opener(jsonl_path, "rt") as existing: # type: ignore[arg-type]
|
|
for line in existing:
|
|
with contextlib.suppress(Exception):
|
|
done.add(json.loads(line).get("file", ""))
|
|
files = [f for f in files if f not in done]
|
|
print(f"scanning {len(files)} files under {root} ({len(done)} already done)")
|
|
n_done = 0
|
|
text_opener = gzip.open if gz else open
|
|
csv_exists = Path(csv_name).exists()
|
|
with (
|
|
text_opener(jsonl_path, "at") as jsonl, # type: ignore[arg-type]
|
|
open(csv_name, "a", newline="") as csvf,
|
|
):
|
|
writer = csv.DictWriter(csvf, fieldnames=list(summary_row({})))
|
|
if not csv_exists:
|
|
writer.writeheader()
|
|
for path_str in files:
|
|
try:
|
|
record = scan_file(Path(path_str))
|
|
except Exception as exc: # one corrupt file must not kill the scan
|
|
record = {"file": path_str, "error": _safe_str(exc)}
|
|
jsonl.write(json.dumps(record, default=str) + "\n")
|
|
writer.writerow(summary_row(record))
|
|
n_done += 1
|
|
if n_done % 500 == 0:
|
|
print(f" {n_done}/{len(files)}", flush=True)
|
|
print(f"done: {jsonl_name}, {csv_name}")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|