Files
remove-ai-watermarks/scripts/scan_dataset.py
T
Victor Kuznetsov dfe69772cd Polish the scanner after review: shared b64 helper, tri-state pixel mode
Route the pixel layer's base64 through the capped _b64 helper, replace
the pixels/pixels_full booleans with one pixel_mode parameter, record a
pixel skipped marker on oversized files, read EXIF from the already-read
bytes instead of re-reading the file, chunk the noise residual
convolution and replace the mgrid with 1D broadcast in the FFT features
(bit-identical values, ~300 MB less peak memory at 2048px), and fix the
raw-only docstring to account for the derived pixel layer.
2026-07-23 11:54:08 -07:00

1100 lines
43 KiB
Python

"""Collect all raw metadata over a dataset, for later offline analysis.
Read-only; analysis is NOT this script's job. 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, sha256, mtime/birthtime
- raw metadata, so nothing has to be re-scanned later:
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 structure), JPEG encoder
structure (quant tables, progressive scan script, Huffman tables,
subsampling, JFIF/Adobe markers), 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.
By default everything collected is raw bytes or a mechanical container
decode; no verdicts, no estimates, no edit-trail interpretation. The
optional pixel modes add a clearly-separated derived layer (aggregate
statistics only, still no verdicts).
Usage:
python scan_dataset.py /path/to/dataset out_prefix [--pixels|--pixels-full]
Writes out_prefix.jsonl (one record per file) and out_prefix.csv (flat
summary 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).
--pixels adds aggregate pixel forensics to each record (DCT histograms,
noise/FFT/ELA/gradient/color statistics) from which the image CANNOT be
reconstructed, plus per-section timing_ms for pipeline latency planning.
--pixels-full additionally stores the privacy-lifting artifacts (phash,
128px thumbnail, coarse ELA/noise/FFT-phase maps). Pixel modes require
numpy; the metadata-only mode needs no numpy.
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
Rerunning with the same prefix resumes: files already present in the
output are skipped, new files are appended.
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.
Dependencies: pip install pillow piexif c2pa-python pillow-heif
(pillow-heif is only needed for HEIC/AVIF inputs).
"""
from __future__ import annotations
import base64
import contextlib
import csv
import io
import json
import struct
import sys
import time
from pathlib import Path
from typing import Any
from PIL import Image
from PIL.IptcImagePlugin import getiptcinfo
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, data: 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. ``data`` is the
already-read file bytes so piexif does not re-read the file."""
import piexif
try:
exif = piexif.load(data) if data is not None else 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 via the official c2pa-python Reader."""
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[:6] in (b"GIF87a", b"GIF89a"):
return "gif"
if head.startswith(b"BM"):
return "bmp"
if head.startswith((b"II*\x00", b"MM\x00*")):
return "tiff"
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 ---
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
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 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, *, pixel_mode: str | None = None) -> 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 (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
record["pil"], record["iptc"], exif_blob = read_pil_info(path)
record["exif"], thumbnail = read_full_exif(path, exif_blob, data)
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)
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
if pixel_mode is not None:
if oversized:
# symmetric with the metadata oversized marker: distinguish
# "skipped because oversized" from "decode failed" downstream
record["pixel"] = {"skipped": "oversized"}
else:
record.update(scan_pixels_of(path, full=pixel_mode == "full"))
return record
def scan_pixels_of(path: Path, *, full: bool) -> dict[str, Any]:
"""The optional pixel layer for one file (see the pixel-layer section
below). Returns {"pixel_error": ...} when numpy is unavailable."""
if np is None:
return {"pixel_error": "numpy not installed"}
t0 = time.perf_counter()
timing: dict[str, float] = {}
record: dict[str, Any] = {}
t = time.perf_counter()
gray, rgb, info = read_gray(path)
record["pixel"] = info
timing["decode"] = time.perf_counter() - t
if gray is not None:
# maps shared between the scalar features and the --pixels-full
# artifacts, computed once (the JPEG re-save and the sliding-window
# conv are the two most expensive features in the scan)
t = time.perf_counter()
residual = noise_residual_map(gray)
timing["noise"] = time.perf_counter() - t
if residual is not None:
record["noise"] = noise_features(residual)
t = time.perf_counter()
spectrum = fft_decompose(gray)
timing["fft"] = time.perf_counter() - t
mag = phase = None
if spectrum is not None:
mag, phase = spectrum
record["fft"] = fft_features(mag)
t = time.perf_counter()
ela = ela_map(rgb)
timing["ela"] = time.perf_counter() - t
if ela is not None:
record["ela"] = ela_features(ela)
for name, fn, arg in (
("dct", dct_features, gray),
("gradient", gradient_features, gray),
("color", color_features, rgb),
):
t = time.perf_counter()
try:
result = fn(arg)
except Exception as exc:
result = {"error": _safe_str(exc)}
timing[name] = time.perf_counter() - t
if result:
record[name] = result
if full:
t = time.perf_counter()
try:
record["full"] = full_artifacts(gray, rgb, ela=ela, residual=residual, phase=phase)
except Exception as exc:
record["full"] = {"error": _safe_str(exc)}
timing["full_artifacts"] = time.perf_counter() - t
timing["total"] = time.perf_counter() - t0
record["timing_ms"] = {k: round(v * 1000, 1) for k, v in timing.items()}
return record
def summary_row(record: dict[str, Any]) -> dict[str, Any]:
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")),
}
def main() -> None:
flags, positional = set(), []
for arg in sys.argv[1:]:
(flags.add if arg.startswith("--") else positional.append)(arg)
if len(positional) != 2 or flags - {"--pixels", "--pixels-full"}:
print(__doc__)
sys.exit(2)
pixel_mode = "full" if "--pixels-full" in flags else ("basic" if "--pixels" in flags else None)
if pixel_mode is not None and np is None:
print("pixel modes require numpy: pip install numpy")
sys.exit(2)
root, prefix = Path(positional[0]), positional[1]
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), pixel_mode=pixel_mode)
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}")
# --- optional pixel layer (--pixels / --pixels-full), requires numpy ---
try:
import numpy as np
from numpy.lib.stride_tricks import sliding_window_view
except ImportError:
np = None # type: ignore[assignment]
_MAX_SIDE = 2048 # downscale before analysis; stats are scale-robust
_DCT_BINS = np.linspace(-20.5, 20.5, 22) if np is not None else None
_AC_POSITIONS = [(0, 1), (1, 0), (1, 1), (0, 2), (2, 0), (2, 1), (1, 2), (0, 3)]
_FFT_BANDS = 8
_BAYER_OFFSETS = [(1, 1), (1, -1)] # CFA diagonal periodicity candidates
def _arr_b64(arr: np.ndarray) -> dict[str, Any]:
"""Compact array payload for the --full spatial maps."""
return {
"shape": list(arr.shape),
"dtype": str(arr.dtype),
"base64": _b64(arr.tobytes()),
}
def _coarse(arr: np.ndarray, side: int = 64) -> np.ndarray:
"""Downscale a 2D map to at most `side` on the long edge."""
h, w = arr.shape
if max(h, w) <= side:
return arr
img = Image.fromarray(arr.astype(np.float32), mode="F")
img.thumbnail((side, side), Image.BILINEAR)
return np.asarray(img)
def phash(gray: np.ndarray) -> str:
"""64-bit DCT perceptual hash (invertible to a rough layout; --full only)."""
img = Image.fromarray(gray.astype(np.float32), mode="F").resize((32, 32), Image.LANCZOS)
small = np.asarray(img)
m = _dct_matrix(32)
coeff = m @ small @ m.T
low = coeff[:8, :8].ravel()[1:] # drop DC
bits = low > np.median(low)
return f"{int(''.join('1' if b else '0' for b in bits), 2):016x}"
def full_artifacts(
gray: np.ndarray,
rgb: np.ndarray,
*,
ela: np.ndarray | None,
residual: np.ndarray | None,
phase: np.ndarray | None,
) -> dict[str, Any]:
"""The privacy-lifting set: phash, thumbnail, ELA map, noise residual,
FFT phase. Maps are computed once by the caller and shared with the
scalar feature paths."""
out: dict[str, Any] = {}
out["phash"] = phash(gray)
img = Image.fromarray(rgb.astype(np.uint8))
img.thumbnail((128, 128), Image.LANCZOS)
buf = io.BytesIO()
img.save(buf, "JPEG", quality=70)
out["thumbnail_jpeg_b64"] = _b64(buf.getvalue())
if ela is not None:
out["ela_map"] = _arr_b64(_coarse(ela))
if residual is not None:
clipped = np.clip(residual / 4.0, -1, 1)
out["noise_residual"] = _arr_b64(_coarse((clipped * 127).astype(np.int8)))
if phase is not None:
out["fft_phase"] = _arr_b64(_coarse(phase.astype(np.float32), 32))
return out
def read_gray(path: Path) -> tuple[np.ndarray | None, np.ndarray | None, dict[str, Any]]:
"""Decode to float32 grayscale (and RGB for color stats), downscaled."""
try:
with Image.open(path) as img:
info: dict[str, Any] = {"width": img.width, "height": img.height}
if max(img.size) > _MAX_SIDE:
img.thumbnail((_MAX_SIDE, _MAX_SIDE), Image.LANCZOS)
rgb = np.asarray(img.convert("RGB"), dtype=np.float32)
gray = np.asarray(img.convert("L"), dtype=np.float32)
return gray, rgb, info
except Exception as exc:
return None, None, {"error": _safe_str(exc)}
def _dct_matrix(n: int = 8) -> np.ndarray:
"""Orthonormal n x n DCT-II basis: M[i, j] = cos(pi (2j + 1) i / 2n)."""
i = np.arange(n)[:, None]
j = np.arange(n)[None, :]
m = np.cos(np.pi * (2 * j + 1) * i / (2 * n))
m[0, :] *= 1 / np.sqrt(2)
return m * np.sqrt(2 / n)
_DCT_M = _dct_matrix() if np is not None else None
def dct_features(gray: np.ndarray) -> dict[str, Any]:
"""AC coefficient histograms over 8x8 block DCT + Benford deviation."""
h, w = gray.shape
h8, w8 = h // 8 * 8, w // 8 * 8
if h8 < 8 or w8 < 8:
return {}
blocks = gray[:h8, :w8].reshape(h8 // 8, 8, w8 // 8, 8).swapaxes(1, 2)
coeff = np.einsum("ij,abjk,lk->abil", _DCT_M, blocks, _DCT_M)
hists = []
lead_vals: list[np.ndarray] = []
for dy, dx in _AC_POSITIONS:
vals = coeff[:, :, dy, dx].ravel()
hists.append(np.histogram(vals, bins=_DCT_BINS)[0].tolist())
lead_vals.append(np.abs(vals))
out: dict[str, Any] = {"dct_ac_hist": hists}
flat = np.abs(np.concatenate(lead_vals))
flat = flat[flat >= 1]
if flat.size > 100:
leading = (flat / 10 ** np.floor(np.log10(flat))).astype(int)
leading = leading[(leading >= 1) & (leading <= 9)]
if leading.size > 100:
obs = np.bincount(leading, minlength=10)[1:10] / leading.size
ben = np.log10(1 + 1 / np.arange(1, 10))
out["benford_mad"] = float(np.abs(obs - ben).mean())
return out
def noise_residual_map(gray: np.ndarray) -> np.ndarray | None:
"""High-pass residual. The map itself is spatial (shows edges), so it
is only stored under --full; the default mode keeps scalar stats."""
if gray.shape[0] < 3 or gray.shape[1] < 3:
return None
k = np.array([[-1.0, -1.0, -1.0], [-1.0, 8.0, -1.0], [-1.0, -1.0, -1.0]])
h, w = gray.shape
# k is float64, so the residual is float64 like the unchunked form
out = np.empty((h - 2, w - 2), dtype=np.float64)
# row-chunked: the (win * k) temp is ~150 MB at 2048px if materialized
# whole; per-element 9-tap sums are computed in the same order, so the
# result is bit-identical to the unchunked form
for y0 in range(0, h - 2, 256):
y1 = min(y0 + 256, h - 2)
win = sliding_window_view(gray[y0 : y1 + 2], (3, 3))
out[y0:y1] = (win * k).sum(axis=(-1, -2))
return out
def noise_features(residual: np.ndarray) -> dict[str, Any]:
"""High-pass residual std/kurtosis; the residual map is NOT stored."""
flat = residual.ravel()
std = float(flat.std())
if std < 1e-9:
return {"noise_std": 0.0, "noise_kurtosis": 0.0}
z = (flat - flat.mean()) / std
return {"noise_std": std, "noise_kurtosis": float((z**4).mean() - 3.0)}
def fft_decompose(gray: np.ndarray) -> tuple[np.ndarray, np.ndarray] | None:
"""Log-magnitude (fftshifted) and phase of the image spectrum."""
if min(gray.shape) < 32:
return None
spectrum = np.fft.fftshift(np.fft.fft2(gray - gray.mean()))
return np.log1p(np.abs(spectrum)), np.angle(spectrum)
def fft_features(mag: np.ndarray) -> dict[str, Any]:
"""Radial magnitude band energies (no phase) + CFA periodicity peaks."""
h, w = mag.shape
cy, cx = h // 2, w // 2
# 1D broadcast instead of an mgrid: saves ~160 MB of int64 temporaries
# at 2048px; the squares are exact in float64 (values < 2^53), so band
# means are identical to the mgrid form
r2y = (np.arange(h, dtype=np.float64) - cy) ** 2
r2x = (np.arange(w, dtype=np.float64) - cx) ** 2
r = np.sqrt(r2y[:, None] + r2x[None, :])
r_max = r.max()
bands = []
for i in range(_FFT_BANDS):
mask = (r >= r_max * i / _FFT_BANDS) & (r < r_max * (i + 1) / _FFT_BANDS)
bands.append(float(mag[mask].mean()) if mask.any() else 0.0)
# Bayer CFA shows as symmetric peaks at half the Nyquist on diagonals
peaks = []
for dy, dx in _BAYER_OFFSETS:
y, x = cy + dy * (h // 4), cx + dx * (w // 4)
neighborhood = mag[y - 2 : y + 3, x - 2 : x + 3]
peaks.append(float(neighborhood.max() - mag.mean()))
return {"fft_band_energy": bands, "cfa_peaks": peaks, "cfa_peak": max(peaks)}
def ela_map(rgb: np.ndarray) -> np.ndarray | None:
"""Absolute per-pixel error after a q90 JPEG re-save."""
try:
img = Image.fromarray(rgb.astype(np.uint8))
buf = io.BytesIO()
img.save(buf, "JPEG", quality=90)
buf.seek(0)
resaved = np.asarray(Image.open(buf).convert("RGB"), dtype=np.float32)
except Exception:
return None
if resaved.shape != rgb.shape:
return None
return np.abs(rgb - resaved).mean(axis=-1)
def ela_features(err: np.ndarray) -> dict[str, Any]:
"""Error-level stats after a q90 JPEG re-save; global only, no map."""
return {"ela_mean": float(err.mean()), "ela_p95": float(np.percentile(err, 95))}
def gradient_features(gray: np.ndarray) -> dict[str, Any]:
gy, gx = np.gradient(gray)
mag = np.sqrt(gx**2 + gy**2)
hist = np.histogram(mag, bins=10, range=(0, 255))[0].tolist()
lap = np.gradient(gy, axis=0) + np.gradient(gx, axis=1)
return {"gradient_hist": hist, "laplacian_var": float(lap.var())}
def color_features(rgb: np.ndarray) -> dict[str, Any]:
small = rgb[::4, ::4] # decimate; histogram is position-blind anyway
bins = (small / 256 * 4).astype(int).clip(0, 3)
idx = bins[..., 0] * 16 + bins[..., 1] * 4 + bins[..., 2]
hist = np.bincount(idx.ravel(), minlength=64).tolist()
mx = small.max(axis=-1)
mn = small.min(axis=-1)
sat = np.where(mx > 0, (mx - mn) / np.maximum(mx, 1e-6), 0)
return {
"color_hist_4x4x4": hist,
"saturation_mean": float(sat.mean()),
"value_mean": float(mx.mean() / 255),
}
if __name__ == "__main__":
main()