"""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(" 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()