mirror of
https://github.com/wiltodelta/remove-ai-watermarks.git
synced 2026-08-06 22:18:36 +02:00
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.
1100 lines
43 KiB
Python
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()
|