567 lines
19 KiB
Python
Executable File
567 lines
19 KiB
Python
Executable File
#!/usr/bin/env python3
|
||
"""
|
||
extract-exif.py — Build-time EXIF sidecar generator for photography.
|
||
|
||
Walks content/photography/**/*.{jpg,jpeg,png} and writes a
|
||
{photo}.exif.yaml sidecar alongside each image. The Hakyll context in
|
||
build/Contexts.hs reads these sidecars and merges their fields into
|
||
the photographyCtx so authors don't have to hand-write camera / lens /
|
||
exposure / captured-date in frontmatter for digital photos.
|
||
|
||
Frontmatter always wins. The sidecar is a strict fallback — present
|
||
to populate fields the author chose not to write. Film scans typically
|
||
have no EXIF; the sidecar is still written but with an empty body, and
|
||
the author hand-writes the relevant fields in frontmatter.
|
||
|
||
Strategy:
|
||
|
||
1. Prefer exiftool when available — Perl-based, ships in distro
|
||
repos, handles every camera vendor's tag dialect (incl. RAW).
|
||
2. Fall back to Pillow's EXIF reader — pure Python, narrower
|
||
coverage, but always available via the project's .venv.
|
||
|
||
Staleness check: skips an image whose sidecar mtime > image mtime.
|
||
This means re-running the tool is idempotent and cheap.
|
||
|
||
GPS coordinates are written to the sidecar at full precision; the
|
||
geo-precision rounding (`exact | km | city | hidden`) is applied in
|
||
Hakyll at the consuming end, against each photo's frontmatter
|
||
`geo-precision:` value. The sidecar is the source of truth; the
|
||
consumer is the privacy gate.
|
||
|
||
Called by `make build` when .venv exists. Failures on individual
|
||
images are logged and the rest of the walk continues.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import os
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from fractions import Fraction
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
import yaml
|
||
|
||
REPO_ROOT = Path(__file__).parent.parent
|
||
CONTENT_DIR = REPO_ROOT / "content" / "photography"
|
||
TOOL = "extract-exif"
|
||
|
||
IMAGE_EXTS = {".jpg", ".jpeg", ".png"}
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Field normalisation
|
||
# ---------------------------------------------------------------------------
|
||
|
||
# Mapping from EXIF field names (as exposed by exiftool / Pillow) to the
|
||
# sidecar keys consumed by Hakyll. Hakyll's fields are deliberately
|
||
# lowercase-with-hyphens, matching the photographyCtx convention.
|
||
SIDECAR_KEYS = [
|
||
"camera",
|
||
"lens",
|
||
"exposure",
|
||
"shutter",
|
||
"aperture",
|
||
"iso",
|
||
"focal-length",
|
||
"captured",
|
||
"geo",
|
||
# Pixel dimensions of the delivered (resized, EXIF-stripped) JPEG.
|
||
# Threaded through to the Hakyll photographyCtx and emitted as
|
||
# width / height attrs on every <img> tag — prevents cumulative
|
||
# layout shift while photos load.
|
||
"width",
|
||
"height",
|
||
]
|
||
|
||
|
||
def _format_shutter(speed: float) -> str:
|
||
"""Render shutter speed as "1/125" or "0.5s" depending on magnitude."""
|
||
if speed <= 0:
|
||
return ""
|
||
if speed >= 1.0:
|
||
return f"{speed:g}s"
|
||
denom = round(1.0 / speed)
|
||
return f"1/{denom}"
|
||
|
||
|
||
def _format_aperture(value: float) -> str:
|
||
if value <= 0:
|
||
return ""
|
||
# Common aperture values display with at most one decimal place.
|
||
if abs(value - round(value)) < 0.05:
|
||
return f"f/{int(round(value))}"
|
||
return f"f/{value:.1f}"
|
||
|
||
|
||
def _format_focal(value: float) -> str:
|
||
if value <= 0:
|
||
return ""
|
||
return f"{int(round(value))}mm"
|
||
|
||
|
||
def _build_exposure_string(
|
||
shutter: str | None,
|
||
aperture: str | None,
|
||
iso: int | None,
|
||
) -> str | None:
|
||
"""Compose "1/125 f/8 ISO 400" from individual fields when present."""
|
||
parts: list[str] = []
|
||
if shutter:
|
||
parts.append(shutter)
|
||
if aperture:
|
||
parts.append(aperture)
|
||
if iso:
|
||
parts.append(f"ISO {iso}")
|
||
return " ".join(parts) if parts else None
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# exiftool path
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _exiftool_available() -> bool:
|
||
return shutil.which("exiftool") is not None
|
||
|
||
|
||
# The tag list, shared by the single-image and batch paths so they can never
|
||
# disagree about what is being read.
|
||
EXIFTOOL_ARGS = [
|
||
"-Make",
|
||
"-Model",
|
||
"-LensModel",
|
||
"-Lens",
|
||
"-LensID",
|
||
"-LensSpec",
|
||
"-LensInfo",
|
||
# Numeric form is requested per-tag with the `#` suffix rather
|
||
# than globally with `-n`. A global -n also flattens the string
|
||
# tags: Composite:Lens becomes the raw LensInfo quad, so an
|
||
# 18–55 kit zoom arrived as "18 55 3.5 5.6" instead of
|
||
# "18-55mm f/3.5-5.6". These are the only fields parsed as
|
||
# numbers below, so they are the only ones forced.
|
||
"-ExposureTime#",
|
||
"-FNumber#",
|
||
"-ISO#",
|
||
"-FocalLength#",
|
||
"-FocalLengthIn35mmFormat#",
|
||
"-DateTimeOriginal",
|
||
"-CreateDate",
|
||
"-GPSLatitude#",
|
||
"-GPSLongitude#",
|
||
"-GPSLatitudeRef",
|
||
"-GPSLongitudeRef",
|
||
"-ImageWidth#",
|
||
"-ImageHeight#",
|
||
]
|
||
|
||
# exiftool is Perl and pays roughly 0.3 s of startup per invocation — twenty
|
||
# images cost 1.47 s as twenty calls and 0.12 s as one. Importing a large
|
||
# batch therefore prefetches every file in a single call and answers from
|
||
# this cache. Keyed by resolved path, since exiftool echoes SourceFile back
|
||
# in whatever form it was handed.
|
||
_RAW_CACHE: dict[str, dict] = {}
|
||
|
||
|
||
def _exiftool_json(images: list[Path]) -> list[dict]:
|
||
args = []
|
||
for image in images:
|
||
a = str(image)
|
||
# exiftool does not reliably support `--` as an end-of-options
|
||
# marker, so make the path argument non-option-shaped instead: a
|
||
# relative path is prefixed with ./ so it can never start with `-`.
|
||
if not os.path.isabs(a):
|
||
a = os.path.join(os.curdir, a)
|
||
args.append(a)
|
||
result = subprocess.run(
|
||
["exiftool", "-json", *EXIFTOOL_ARGS, *args],
|
||
capture_output=True, text=True, check=False,
|
||
)
|
||
if result.returncode != 0:
|
||
return []
|
||
try:
|
||
data = json.loads(result.stdout)
|
||
except json.JSONDecodeError:
|
||
return []
|
||
return data or []
|
||
|
||
|
||
def prefetch_exiftool(images: list[Path]) -> None:
|
||
"""Read every image's tags in one exiftool call, into _RAW_CACHE."""
|
||
if not images:
|
||
return
|
||
for entry in _exiftool_json(images):
|
||
src = entry.get("SourceFile")
|
||
if src:
|
||
_RAW_CACHE[str(Path(src).resolve())] = entry
|
||
|
||
|
||
def _read_exif_via_exiftool(image: Path) -> dict[str, Any]:
|
||
"""Invoke exiftool and return a dict of normalised sidecar keys.
|
||
|
||
exiftool's `-json` output is a list of objects; we parse the first
|
||
entry. Numeric values come through as numbers; text values as
|
||
strings. We accept missing keys silently.
|
||
"""
|
||
key = str(image.resolve())
|
||
raw = _RAW_CACHE.get(key)
|
||
if raw is None:
|
||
found = _exiftool_json([image])
|
||
raw = found[0] if found else {}
|
||
if not raw:
|
||
return {}
|
||
|
||
out: dict[str, Any] = {}
|
||
|
||
make = (raw.get("Make") or "").strip()
|
||
model = (raw.get("Model") or "").strip()
|
||
# Manufacturers repeat the brand in both fields, but rarely identically:
|
||
# Nikon writes Make "NIKON CORPORATION" and Model "NIKON D3100", so a
|
||
# startswith() test against the whole Make string fails and the result
|
||
# reads "NIKON CORPORATION NIKON D3100". Comparing the leading word
|
||
# instead collapses that to "NIKON D3100", which is how every photo tool
|
||
# displays it, while leaving genuinely distinct pairs untouched.
|
||
if make and model:
|
||
if model.lower().split()[:1] == make.lower().split()[:1]:
|
||
out["camera"] = model
|
||
elif model.lower().startswith(make.lower()):
|
||
out["camera"] = model
|
||
else:
|
||
out["camera"] = f"{make} {model}".strip()
|
||
elif model:
|
||
out["camera"] = model
|
||
elif make:
|
||
out["camera"] = make
|
||
|
||
# Preference order is "most human-readable first". LensModel is the
|
||
# standard EXIF field and is already clean when present. `Lens` and
|
||
# `LensID` are exiftool compositions that many bodies (Nikon among them)
|
||
# populate when LensModel is absent. LensSpec/LensInfo are last because
|
||
# LensInfo is a raw numeric quad — it is what produced the unreadable
|
||
# "18 55 3.5 5.6 14" before this preference existed.
|
||
lens = (
|
||
raw.get("LensModel")
|
||
or raw.get("Lens")
|
||
or raw.get("LensID")
|
||
or raw.get("LensSpec")
|
||
or raw.get("LensInfo")
|
||
or ""
|
||
).strip()
|
||
|
||
# A phone's "lens" is not a lens. An iPhone reports "iPhone 15 Pro back
|
||
# triple camera 6.765mm f/1.78" — a product description that repeats the
|
||
# body named on the line above and describes a module nobody chose or can
|
||
# remove. Where the lens field only restates the camera, drop it: the
|
||
# camera row already says what took the photograph, and the exposure row
|
||
# already carries the aperture.
|
||
names_the_body = bool(lens) and bool(model) and model.lower() in lens.lower()
|
||
if lens and not names_the_body:
|
||
out["lens"] = lens
|
||
|
||
shutter_secs = raw.get("ExposureTime")
|
||
if isinstance(shutter_secs, (int, float)) and shutter_secs > 0:
|
||
out["shutter"] = _format_shutter(float(shutter_secs))
|
||
|
||
aperture = raw.get("FNumber")
|
||
if isinstance(aperture, (int, float)) and aperture > 0:
|
||
out["aperture"] = _format_aperture(float(aperture))
|
||
|
||
iso = raw.get("ISO")
|
||
if isinstance(iso, int) and iso > 0:
|
||
out["iso"] = iso
|
||
|
||
# Focal length in the terms the photographer means.
|
||
#
|
||
# A camera that names its lens is describing a barrel with numbers printed
|
||
# on it: an 18-55 at its wide end is 18mm, and rendering that as its 27mm
|
||
# equivalent would contradict the object in the hand. A body that names no
|
||
# lens distinct from itself has no such barrel — 6.8mm is a fact about a
|
||
# sensor, not about a photograph — so the 35mm equivalent is the only
|
||
# figure that means anything, and it is what every phone reports a photo
|
||
# as having been taken at.
|
||
focal = raw.get("FocalLength")
|
||
focal35 = raw.get("FocalLengthIn35mmFormat")
|
||
if "lens" not in out and isinstance(focal35, (int, float)) and focal35 > 0:
|
||
out["focal-length"] = _format_focal(float(focal35))
|
||
elif isinstance(focal, (int, float)) and focal > 0:
|
||
out["focal-length"] = _format_focal(float(focal))
|
||
|
||
|
||
captured_raw = raw.get("DateTimeOriginal") or raw.get("CreateDate")
|
||
if isinstance(captured_raw, str) and captured_raw:
|
||
# exiftool format is "YYYY:MM:DD HH:MM:SS"; we want ISO date only.
|
||
date_part = captured_raw.split(" ", 1)[0].replace(":", "-")
|
||
if len(date_part) == 10:
|
||
out["captured"] = date_part
|
||
|
||
lat = raw.get("GPSLatitude")
|
||
lon = raw.get("GPSLongitude")
|
||
if isinstance(lat, (int, float)) and isinstance(lon, (int, float)):
|
||
# exiftool with -n returns signed decimals already.
|
||
out["geo"] = [round(float(lat), 6), round(float(lon), 6)]
|
||
|
||
width = raw.get("ImageWidth")
|
||
height = raw.get("ImageHeight")
|
||
if isinstance(width, int) and width > 0:
|
||
out["width"] = width
|
||
if isinstance(height, int) and height > 0:
|
||
out["height"] = height
|
||
|
||
exposure = _build_exposure_string(
|
||
out.get("shutter"), out.get("aperture"), out.get("iso")
|
||
)
|
||
if exposure:
|
||
out["exposure"] = exposure
|
||
|
||
return out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Pillow fallback path
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _pillow_rational(value: Any) -> float | None:
|
||
"""Pillow can return EXIF rationals as IFDRational, tuples, or floats."""
|
||
if value is None:
|
||
return None
|
||
try:
|
||
if isinstance(value, tuple) and len(value) == 2:
|
||
num, den = value
|
||
return float(num) / float(den) if den else None
|
||
return float(Fraction(value).limit_denominator())
|
||
except (TypeError, ValueError, ZeroDivisionError):
|
||
try:
|
||
return float(value)
|
||
except (TypeError, ValueError):
|
||
return None
|
||
|
||
|
||
def _gps_to_decimal(coord: Any, ref: Any) -> float | None:
|
||
"""Pillow GPS coords come as ((deg_n, deg_d), (min_n, min_d), (sec_n, sec_d))."""
|
||
if not coord:
|
||
return None
|
||
try:
|
||
deg = float(coord[0])
|
||
minutes = float(coord[1])
|
||
seconds = float(coord[2])
|
||
decimal = deg + minutes / 60.0 + seconds / 3600.0
|
||
if isinstance(ref, str) and ref in ("S", "W"):
|
||
decimal = -decimal
|
||
return decimal
|
||
except (TypeError, ValueError, IndexError):
|
||
return None
|
||
|
||
|
||
def _read_exif_via_pillow(image: Path) -> dict[str, Any]:
|
||
from PIL import Image, ExifTags
|
||
|
||
out: dict[str, Any] = {}
|
||
|
||
# Pixel dimensions are extracted unconditionally (separate from
|
||
# EXIF) — every readable raster file has them, even synthetic
|
||
# placeholders or photos that have had their EXIF stripped.
|
||
try:
|
||
with Image.open(image) as img:
|
||
width, height = img.size
|
||
if isinstance(width, int) and width > 0:
|
||
out["width"] = width
|
||
if isinstance(height, int) and height > 0:
|
||
out["height"] = height
|
||
exif = img.getexif() or {}
|
||
except Exception: # noqa: BLE001 — corrupt EXIF should not abort the walk
|
||
return out
|
||
|
||
if not exif:
|
||
return out
|
||
|
||
tag_name = {v: k for k, v in ExifTags.TAGS.items()}
|
||
gps_name = {v: k for k, v in ExifTags.GPSTAGS.items()}
|
||
|
||
def _g(name: str) -> Any:
|
||
return exif.get(tag_name.get(name, -1))
|
||
|
||
make = (_g("Make") or "").strip()
|
||
model = (_g("Model") or "").strip()
|
||
if make and model and not model.lower().startswith(make.lower()):
|
||
out["camera"] = f"{make} {model}".strip()
|
||
elif model:
|
||
out["camera"] = model
|
||
elif make:
|
||
out["camera"] = make
|
||
|
||
lens = (_g("LensModel") or _g("LensMake") or "").strip()
|
||
if lens:
|
||
out["lens"] = lens
|
||
|
||
shutter_secs = _pillow_rational(_g("ExposureTime"))
|
||
if shutter_secs and shutter_secs > 0:
|
||
out["shutter"] = _format_shutter(shutter_secs)
|
||
|
||
aperture = _pillow_rational(_g("FNumber"))
|
||
if aperture and aperture > 0:
|
||
out["aperture"] = _format_aperture(aperture)
|
||
|
||
iso_raw = _g("ISOSpeedRatings") or _g("PhotographicSensitivity")
|
||
if isinstance(iso_raw, int) and iso_raw > 0:
|
||
out["iso"] = iso_raw
|
||
elif isinstance(iso_raw, tuple) and iso_raw and isinstance(iso_raw[0], int):
|
||
out["iso"] = iso_raw[0]
|
||
|
||
focal = _pillow_rational(_g("FocalLength"))
|
||
if focal and focal > 0:
|
||
out["focal-length"] = _format_focal(focal)
|
||
|
||
captured_raw = _g("DateTimeOriginal") or _g("DateTime")
|
||
if isinstance(captured_raw, str) and captured_raw:
|
||
date_part = captured_raw.split(" ", 1)[0].replace(":", "-")
|
||
if len(date_part) == 10:
|
||
out["captured"] = date_part
|
||
|
||
gps_idx = tag_name.get("GPSInfo", -1)
|
||
gps_info = exif.get(gps_idx) or {}
|
||
if isinstance(gps_info, dict) and gps_info:
|
||
# Pillow exposes GPSInfo by integer-keyed dict; remap.
|
||
named = {gps_name.get(k, str(k)): v for k, v in gps_info.items()}
|
||
lat = _gps_to_decimal(named.get("GPSLatitude"), named.get("GPSLatitudeRef"))
|
||
lon = _gps_to_decimal(named.get("GPSLongitude"), named.get("GPSLongitudeRef"))
|
||
if lat is not None and lon is not None:
|
||
out["geo"] = [round(lat, 6), round(lon, 6)]
|
||
|
||
exposure = _build_exposure_string(
|
||
out.get("shutter"), out.get("aperture"), out.get("iso")
|
||
)
|
||
if exposure:
|
||
out["exposure"] = exposure
|
||
|
||
return out
|
||
|
||
|
||
# ---------------------------------------------------------------------------
|
||
# Walk + write
|
||
# ---------------------------------------------------------------------------
|
||
|
||
|
||
def _sidecar_path(image: Path) -> Path:
|
||
return image.with_suffix(image.suffix + ".exif.yaml")
|
||
|
||
|
||
def _is_stale(image: Path, sidecar: Path) -> bool:
|
||
if not sidecar.exists():
|
||
return True
|
||
return image.stat().st_mtime > sidecar.stat().st_mtime
|
||
|
||
|
||
def _atomic_write_yaml(path: Path, data: dict[str, Any]) -> None:
|
||
# PID-unique temp (concurrent runs can't share it), removed on
|
||
# failure. No fsync: sidecars are regenerated from the photo on the
|
||
# next build, so a lost rename costs one re-extraction, not data.
|
||
tmp = path.with_suffix(path.suffix + f".tmp.{os.getpid()}")
|
||
try:
|
||
with tmp.open("w", encoding="utf-8") as f:
|
||
# Preserve the SIDECAR_KEYS order so a manual diff is easy to read.
|
||
ordered = {k: data[k] for k in SIDECAR_KEYS if k in data}
|
||
yaml.safe_dump(ordered, f, sort_keys=False, allow_unicode=True)
|
||
tmp.replace(path)
|
||
except BaseException:
|
||
tmp.unlink(missing_ok=True)
|
||
raise
|
||
|
||
|
||
def _read_one(image: Path) -> dict[str, Any]:
|
||
if _exiftool_available():
|
||
data = _read_exif_via_exiftool(image)
|
||
if data:
|
||
return data
|
||
return _read_exif_via_pillow(image)
|
||
|
||
|
||
def _candidates(argv: list[str]) -> list[Path]:
|
||
"""Images to consider: the ones named, or the whole section.
|
||
|
||
`make build` calls this with no arguments and wants the full walk.
|
||
import-photo.sh names the single file it just wrote, which keeps a bulk
|
||
import linear instead of quadratic.
|
||
"""
|
||
if argv:
|
||
out = []
|
||
for a in argv:
|
||
p = Path(a)
|
||
if not p.is_absolute():
|
||
p = REPO_ROOT / p
|
||
if p.exists():
|
||
out.append(p)
|
||
else:
|
||
print(f"{TOOL}: no such file: {p}", file=sys.stderr)
|
||
return out
|
||
return sorted(CONTENT_DIR.rglob("*"))
|
||
|
||
|
||
def main() -> int:
|
||
if not CONTENT_DIR.exists():
|
||
print(f"extract-exif: {CONTENT_DIR} does not exist — skipping.", file=sys.stderr)
|
||
return 0
|
||
|
||
using_exiftool = _exiftool_available()
|
||
print(
|
||
"extract-exif: source ="
|
||
f" {'exiftool' if using_exiftool else 'Pillow (exiftool not installed)'}",
|
||
file=sys.stderr,
|
||
)
|
||
|
||
written = 0
|
||
skipped = 0
|
||
failed = 0
|
||
|
||
candidates = [
|
||
i for i in _candidates(sys.argv[1:])
|
||
if i.suffix.lower() in IMAGE_EXTS
|
||
and not i.name.startswith(".")
|
||
and not i.name.endswith(".tmp")
|
||
]
|
||
if using_exiftool:
|
||
prefetch_exiftool([i for i in candidates if _is_stale(i, _sidecar_path(i))])
|
||
|
||
for image in candidates:
|
||
if image.suffix.lower() not in IMAGE_EXTS:
|
||
continue
|
||
# Skip the WebP companions (extension wouldn't match anyway, but
|
||
# be explicit) and any tmp / hidden files.
|
||
if image.name.startswith(".") or image.name.endswith(".tmp"):
|
||
continue
|
||
|
||
sidecar = _sidecar_path(image)
|
||
if not _is_stale(image, sidecar):
|
||
skipped += 1
|
||
continue
|
||
|
||
try:
|
||
data = _read_one(image)
|
||
except Exception as e: # noqa: BLE001 — keep walking
|
||
import traceback
|
||
print(f"extract-exif: {image}: {e}", file=sys.stderr)
|
||
traceback.print_exc(file=sys.stderr)
|
||
failed += 1
|
||
continue
|
||
|
||
# Always write a sidecar — even if it's empty — so the consumer
|
||
# doesn't need to branch on existence. An empty sidecar is the
|
||
# explicit signal that "we tried; nothing to extract" (typical
|
||
# for film scans).
|
||
_atomic_write_yaml(sidecar, data)
|
||
written += 1
|
||
|
||
print(
|
||
f"extract-exif: {written} written, {skipped} skipped, {failed} failed",
|
||
file=sys.stderr,
|
||
)
|
||
return 0
|
||
|
||
|
||
if __name__ == "__main__":
|
||
sys.exit(main())
|