diff options
| author | Paul Buetow <paul@buetow.org> | 2026-06-14 16:55:08 +0300 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2026-06-14 16:55:08 +0300 |
| commit | 194354a3b42de0b59aa74ff8b5a35a1b7f8f51d6 (patch) | |
| tree | 1860f0408fbe8049fc7881249d44dfd67c22f5dd | |
| parent | b2f3a25e8fc3f4edfd0d1d402fa290480e033e5b (diff) | |
om0 add stats aggregation module
Add src/lib/stats.source.sh implementing the aggregation layer for the
stats site feature. collect_photo_exif_stats() iterates the album's
incoming photos, reads each one's cached identify -verbose output via
album.source.sh's cache helper, and fills global STATS_* associative
arrays for the camera leaderboard, per-camera photo lists, temporal,
exposure, dimension, format and enum stats. Builds exactly the v1 stat
set recommended by docs/stats-exif-audit.md (rendering is left to the
sibling tasks pm0/rm0/um0).
Wire stats.source.sh into the Justfile LIB_SOURCES list (after album,
before config) and regenerate bin/shuriken. Add unit tests exercising
synthetic identify fixtures, rational/bucket boundaries, DateTimeOriginal
substring parsing, missing-field tolerance, and the cached-output path.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
| -rw-r--r-- | Justfile | 2 | ||||
| -rwxr-xr-x | bin/shuriken | 514 | ||||
| -rw-r--r-- | src/lib/stats.source.sh | 512 | ||||
| -rwxr-xr-x | tests/cli.sh | 176 |
4 files changed, 1203 insertions, 1 deletions
@@ -7,7 +7,7 @@ PREFIX := env_var_or_default("PREFIX", "/usr") BINDIR := env_var_or_default("BINDIR", PREFIX + "/bin") DATADIR := env_var_or_default("DATADIR", PREFIX + "/share") SYSCONFDIR := env_var_or_default("SYSCONFDIR", "/etc/default") -LIB_SOURCES := "src/lib/bootstrap.source.sh src/lib/imagemagick.source.sh src/lib/process.source.sh src/lib/archive.source.sh src/lib/template.source.sh src/lib/job-pool.source.sh src/lib/image.source.sh src/lib/random.source.sh src/lib/album.source.sh src/lib/config.source.sh src/lib/config.print.source.sh src/lib/config.sync.source.sh src/lib/config.staging.source.sh src/lib/config.validate.source.sh src/lib/config.cli.source.sh src/lib/action.source.sh" +LIB_SOURCES := "src/lib/bootstrap.source.sh src/lib/imagemagick.source.sh src/lib/process.source.sh src/lib/archive.source.sh src/lib/template.source.sh src/lib/job-pool.source.sh src/lib/image.source.sh src/lib/random.source.sh src/lib/album.source.sh src/lib/stats.source.sh src/lib/config.source.sh src/lib/config.print.source.sh src/lib/config.sync.source.sh src/lib/config.staging.source.sh src/lib/config.validate.source.sh src/lib/config.cli.source.sh src/lib/action.source.sh" default: build diff --git a/bin/shuriken b/bin/shuriken index 06514b1..4fd127f 100755 --- a/bin/shuriken +++ b/bin/shuriken @@ -2726,6 +2726,520 @@ print_dry_run_plan() { fi } +# Inlined from src/lib/stats.source.sh +# Stats aggregation for the album stats page (feature plan: +# /home/paul/.pi/plans/shuriken-stats-site.md, field audit: +# docs/stats-exif-audit.md). This module turns the per-photo +# `identify -verbose` output that album.source.sh already caches into +# aggregated, photographer-friendly counters. +# +# Scope: aggregation only. Rendering (stats.tmpl / camera.tmpl) lives in the +# sibling tasks pm0/rm0/um0; this module just fills the data structures they +# read. +# +# ---------------------------------------------------------------------------- +# Public API / handoff contract +# ---------------------------------------------------------------------------- +# collect_photo_exif_stats iterates the album's photos and fills these globals +# (all declared with `declare -gA` so the render tasks can read them after the +# call without namerefs): +# +# STATS_CAMERAS[<camera label>] = count (camera leaderboard) +# STATS_CAMERA_SLUGS[<camera label>] = slug (camera-<slug>.html) +# STATS_CAMERA_PHOTOS[<slug>] = newline-separated photo filenames +# STATS_LENSES[<lens model>] = count (sparse; may be empty) +# STATS_YEARS[<YYYY>] = count +# STATS_MONTHS[<01..12>] = count +# STATS_APERTURE[<bucket>] = count (e.g. "f/2.8") +# STATS_SHUTTER[<bucket>] = count (e.g. "1/250s") +# STATS_ISO[<bucket>] = count (e.g. "400") +# STATS_FOCAL[<bucket>] = count (e.g. "35-70mm") +# STATS_MEGAPIXELS[<bucket>] = count (e.g. "10-20MP") +# STATS_ASPECT[<bucket>] = count (e.g. "3:2") +# STATS_ORIENTATION[<bucket>] = count (Landscape/Portrait/Square) +# STATS_FORMAT[<bucket>] = count (JPEG/PNG/WEBP/GIF) +# STATS_EXPOSURE_PROGRAM[<label>] = count (decoded enum) +# STATS_METERING[<label>] = count (decoded enum) +# STATS_WHITE_BALANCE[<label>] = count (Auto/Manual) +# STATS_FLASH[<label>] = count (Fired/Did not fire; sparse) +# STATS_TOTALS[photos] = number of photos seen +# +# The render tasks should treat every per-category array as possibly empty +# (sparse data) and only render a section when it has entries. STATS_TOTALS +# gives the denominator for percentages. + +# Reset every stats global to an empty associative array. Called at the start of +# collect_photo_exif_stats so repeated invocations (e.g. tests, --refresh) do +# not accumulate stale counts. +reset_photo_exif_stats() { + declare -gA STATS_CAMERAS=() + declare -gA STATS_CAMERA_SLUGS=() + declare -gA STATS_CAMERA_PHOTOS=() + declare -gA STATS_LENSES=() + declare -gA STATS_YEARS=() + declare -gA STATS_MONTHS=() + declare -gA STATS_APERTURE=() + declare -gA STATS_SHUTTER=() + declare -gA STATS_ISO=() + declare -gA STATS_FOCAL=() + declare -gA STATS_MEGAPIXELS=() + declare -gA STATS_ASPECT=() + declare -gA STATS_ORIENTATION=() + declare -gA STATS_FORMAT=() + declare -gA STATS_EXPOSURE_PROGRAM=() + declare -gA STATS_METERING=() + declare -gA STATS_WHITE_BALANCE=() + declare -gA STATS_FLASH=() + declare -gA STATS_TOTALS=() + STATS_TOTALS[photos]=0 +} + +# Decode an EXIF rational ("num/den") to a decimal with `scale` digits. +# Guards den == 0 and tolerates plain decimals (some tools write "0.5"). Prints +# nothing for unparseable input so callers can skip it. The audit lists this as +# shared work for FNumber/FocalLength/ExposureTime. +_stats_rational_to_decimal() { + local -r value="$1"; shift + local -r scale="${1:-2}" + + if [[ "$value" =~ ^([0-9]+)/([0-9]+)$ ]]; then + local -r num="${BASH_REMATCH[1]}" + local -r den="${BASH_REMATCH[2]}" + if (( den == 0 )); then + return + fi + awk -v n="$num" -v d="$den" -v s="$scale" \ + 'BEGIN { printf "%.*f", s, n / d }' + return + fi + # Already a bare decimal/integer: echo it back so buckets can use it. + if [[ "$value" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + printf '%s' "$value" + fi +} + +# Bucket an f-number (decimal) to the nearest standard aperture stop, matching +# the boundaries in the plan (≤f/1.8 ... ≥f/22). The decimal is compared with +# awk so we avoid bash integer-only math on fractional stops. +_stats_aperture_bucket() { + local -r fnum="$1"; shift + + awk -v f="$fnum" 'BEGIN { + if (f <= 1.8) { print "f/1.8 or wider"; } + else if (f <= 2.2) { print "f/2"; } + else if (f <= 3.2) { print "f/2.8"; } + else if (f <= 4.5) { print "f/4"; } + else if (f <= 6.7) { print "f/5.6"; } + else if (f <= 9.5) { print "f/8"; } + else if (f <= 13) { print "f/11"; } + else if (f <= 19) { print "f/16"; } + else { print "f/22 or narrower"; } + }' +} + +# Bucket a shutter speed (already normalized to seconds) into the plan's ranges. +# Faster shutters are smaller numbers, so the ladder runs from short to long. +_stats_shutter_bucket() { + local -r seconds="$1"; shift + + awk -v s="$seconds" 'BEGIN { + if (s <= 1/4000) { print "1/4000s or faster"; } + else if (s <= 1/2000) { print "1/2000s"; } + else if (s <= 1/1000) { print "1/1000s"; } + else if (s <= 1/500) { print "1/500s"; } + else if (s <= 1/250) { print "1/250s"; } + else if (s <= 1/125) { print "1/125s"; } + else if (s <= 1/60) { print "1/60s"; } + else if (s <= 1/30) { print "1/30s"; } + else if (s <= 1/15) { print "1/15s"; } + else if (s <= 1/8) { print "1/8s"; } + else if (s <= 1/4) { print "1/4s"; } + else if (s <= 1/2) { print "1/2s"; } + else if (s <= 1) { print "1s"; } + else { print "longer than 1s"; } + }' +} + +# Bucket an integer ISO to the next standard value at or above it. Plain integer +# per the audit; uses bash arithmetic since ISO is always an integer. +_stats_iso_bucket() { + local -ri iso="$1" + + if (( iso <= 50 )); then printf '50' + elif (( iso <= 100 )); then printf '100' + elif (( iso <= 200 )); then printf '200' + elif (( iso <= 400 )); then printf '400' + elif (( iso <= 800 )); then printf '800' + elif (( iso <= 1600 )); then printf '1600' + elif (( iso <= 3200 )); then printf '3200' + elif (( iso <= 6400 )); then printf '6400' + elif (( iso <= 12800 )); then printf '12800' + elif (( iso <= 25600 )); then printf '25600' + else printf 'over 25600' + fi +} + +# Bucket a focal length (decimal mm) by the plan's lens ranges. Labelled as +# physical focal length, not 35mm-equivalent, per the audit. +_stats_focal_bucket() { + local -r mm="$1"; shift + + awk -v m="$mm" 'BEGIN { + if (m < 24) { print "under 24mm"; } + else if (m < 35) { print "24-35mm"; } + else if (m < 70) { print "35-70mm"; } + else if (m < 135) { print "70-135mm"; } + else if (m <= 200) { print "135-200mm"; } + else { print "over 200mm"; } + }' +} + +# Bucket megapixels (W*H/1e6) by the plan's ranges. +_stats_megapixels_bucket() { + local -r mp="$1"; shift + + awk -v p="$mp" 'BEGIN { + if (p < 2) { print "under 2MP"; } + else if (p < 5) { print "2-5MP"; } + else if (p < 10) { print "5-10MP"; } + else if (p < 20) { print "10-20MP"; } + else if (p < 40) { print "20-40MP"; } + else if (p <= 80) { print "40-80MP"; } + else { print "over 80MP"; } + }' +} + +# Reduce W:H by GCD and match common photographic aspect ratios. The audit +# recommends deriving this from Geometry rather than EXIF. +_stats_aspect_bucket() { + local -ri width="$1"; shift + local -ri height="$1"; shift + local -i a="$width" + local -i b="$height" + local -i t + + if (( width <= 0 || height <= 0 )); then + return + fi + while (( b != 0 )); do + t=$b + b=$(( a % b )) + a=$t + done + case "$(( width / a )):$(( height / a ))" in + 3:2|2:3) printf '3:2' ;; + 4:3|3:4) printf '4:3' ;; + 16:9|9:16) printf '16:9' ;; + 1:1) printf '1:1' ;; + 5:4|4:5) printf '5:4' ;; + *) printf 'other' ;; + esac +} + +# Orientation from width vs height. The audit prefers this over the native +# Orientation rotation flag, which is frequently absent or already baked in. +_stats_orientation_bucket() { + local -ri width="$1"; shift + local -ri height="$1"; shift + + if (( width > height )); then printf 'Landscape' + elif (( height > width )); then printf 'Portrait' + else printf 'Square' + fi +} + +# Decode the ExposureProgram enum (0-8) to a friendly label. ImageMagick emits +# the bare integer; the audit supplies this map. +_stats_exposure_program_label() { + case "$1" in + 1) printf 'Manual' ;; + 2) printf 'Program AE' ;; + 3) printf 'Aperture priority' ;; + 4) printf 'Shutter priority' ;; + 5) printf 'Creative' ;; + 6) printf 'Action' ;; + 7) printf 'Portrait' ;; + 8) printf 'Landscape' ;; + *) printf 'Not defined' ;; + esac +} + +# Decode the MeteringMode enum to a friendly label per the audit's map. +_stats_metering_label() { + case "$1" in + 1) printf 'Average' ;; + 2) printf 'Center-weighted' ;; + 3) printf 'Spot' ;; + 4) printf 'Multi-spot' ;; + 5) printf 'Multi-segment' ;; + 6) printf 'Partial' ;; + 255) printf 'Other' ;; + *) printf 'Unknown' ;; + esac +} + +# Standard EXIF WhiteBalance is only a 2-value enum (0 Auto, 1 Manual); the +# richer presets live in MakerNotes and are not exposed by identify (audit). +_stats_white_balance_label() { + case "$1" in + 0) printf 'Auto' ;; + 1) printf 'Manual' ;; + *) return ;; + esac +} + +# Decode the EXIF Flash bitmask: bit 0 indicates the flash fired. The audit +# warns this tag is frequently missing, so callers must tolerate absence. +_stats_flash_label() { + local -ri flash="$1" + + if (( flash & 1 )); then printf 'Flash fired' + else printf 'No flash' + fi +} + +# Build a filename-safe slug for camera-<slug>.html. Lowercase, non-alphanumeric +# runs collapsed to a single dash, leading/trailing dashes trimmed. +_stats_slug() { + local slug="${1,,}" + + slug="${slug//[^a-z0-9]/-}" + while [[ "$slug" == *--* ]]; do + slug="${slug//--/-}" + done + slug="${slug#-}" + slug="${slug%-}" + printf '%s' "$slug" +} + +# Join Make + Model into a single camera label, reusing the same dedup logic as +# album.source.sh's tooltip builder (handles "Model already includes Make"). +_stats_camera_label() { + local -r make="$1"; shift + local -r model="$1"; shift + + if [ -z "$model" ]; then + printf '%s' "$make" + return + fi + if [ -z "$make" ]; then + printf '%s' "$model" + return + fi + case "$model" in + "$make"|"$make "*) printf '%s' "$model" ;; + *) printf '%s %s' "$make" "$model" ;; + esac +} + +# Increment STATS_CAMERAS for a photo and record it on the per-camera list so +# um0 can render camera-<slug>.html. Skips photos with no Make/Model at all. +_stats_record_camera() { + local -n values_ref="$1"; shift + local -r photo="$1"; shift + local label + local slug + + label=$(_stats_camera_label "${values_ref[Make]:-}" "${values_ref[Model]:-}") + if [ -z "$label" ]; then + return + fi + slug=$(_stats_slug "$label") + STATS_CAMERAS["$label"]=$(( ${STATS_CAMERAS["$label"]:-0} + 1 )) + STATS_CAMERA_SLUGS["$label"]="$slug" + if [ -n "${STATS_CAMERA_PHOTOS["$slug"]:-}" ]; then + STATS_CAMERA_PHOTOS["$slug"]+=$'\n'"$photo" + else + STATS_CAMERA_PHOTOS["$slug"]="$photo" + fi + if [ -n "${values_ref[LensModel]:-}" ]; then + STATS_LENSES["${values_ref[LensModel]}"]=$(( + ${STATS_LENSES["${values_ref[LensModel]}"]:-0} + 1 )) + fi +} + +# Record year and month counters from DateTimeOriginal ("YYYY:MM:DD HH:MM:SS"). +# Parsed by substring per the audit: the colons in the date part are not +# standard, so this must not be fed to `date -d`. Falls back to the digitized / +# plain DateTime tags. +_stats_record_datetime() { + local -n values_ref="$1"; shift + local raw='' + local key + + for key in DateTimeOriginal DateTimeDigitized DateTime; do + if [ -n "${values_ref[$key]:-}" ]; then + raw="${values_ref[$key]}" + break + fi + done + if [[ ! "$raw" =~ ^([0-9]{4}):([0-9]{2}): ]]; then + return + fi + local -r year="${BASH_REMATCH[1]}" + local -r month="${BASH_REMATCH[2]}" + STATS_YEARS["$year"]=$(( ${STATS_YEARS["$year"]:-0} + 1 )) + STATS_MONTHS["$month"]=$(( ${STATS_MONTHS["$month"]:-0} + 1 )) +} + +# Record the aperture, shutter, ISO and focal-length histograms. Each uses the +# fallback tag order the audit recommends and the rational decoder for the +# fiddly fields. Missing/unparseable values are simply skipped. +_stats_record_exposure() { + local -n values_ref="$1"; shift + local decimal + local raw + + raw="${values_ref[FNumber]:-}" + decimal=$(_stats_rational_to_decimal "$raw") + if [ -n "$decimal" ]; then + _stats_bump STATS_APERTURE "$(_stats_aperture_bucket "$decimal")" + fi + + raw="${values_ref[ExposureTime]:-}" + decimal=$(_stats_rational_to_decimal "$raw" 6) + if [ -n "$decimal" ]; then + _stats_bump STATS_SHUTTER "$(_stats_shutter_bucket "$decimal")" + fi + + raw="${values_ref[PhotographicSensitivity]:-${values_ref[ISOSpeedRatings]:-${values_ref[ISO]:-}}}" + if [[ "$raw" =~ ^[0-9]+$ ]]; then + _stats_bump STATS_ISO "$(_stats_iso_bucket "$raw")" + fi + + raw="${values_ref[FocalLength]:-}" + decimal=$(_stats_rational_to_decimal "$raw") + if [ -n "$decimal" ]; then + _stats_bump STATS_FOCAL "$(_stats_focal_bucket "$decimal")" + fi +} + +# Record the decoded enum stats (exposure program, metering, white balance, +# flash). Each is skipped when the tag is absent or decodes to nothing. +_stats_record_enums() { + local -n values_ref="$1"; shift + local label + + if [ -n "${values_ref[ExposureProgram]:-}" ]; then + label=$(_stats_exposure_program_label "${values_ref[ExposureProgram]}") + _stats_bump STATS_EXPOSURE_PROGRAM "$label" + fi + if [ -n "${values_ref[MeteringMode]:-}" ]; then + label=$(_stats_metering_label "${values_ref[MeteringMode]}") + _stats_bump STATS_METERING "$label" + fi + if [ -n "${values_ref[WhiteBalance]:-}" ]; then + label=$(_stats_white_balance_label "${values_ref[WhiteBalance]}") + if [ -n "$label" ]; then + _stats_bump STATS_WHITE_BALANCE "$label" + fi + fi + if [[ "${values_ref[Flash]:-}" =~ ^[0-9]+$ ]]; then + _stats_bump STATS_FLASH "$(_stats_flash_label "${values_ref[Flash]}")" + fi +} + +# Record dimension stats (megapixels, aspect ratio, orientation) from the native +# Geometry field. Geometry is "WxH+x+y"; the leading WxH is what we need. These +# are native fields, not exif: lines, so they come from the separate native +# parser path in _stats_parse_identify_stream. +_stats_record_dimensions() { + local -n values_ref="$1"; shift + local mp + + if [[ ! "${values_ref[__geometry]:-}" =~ ^([0-9]+)x([0-9]+) ]]; then + return + fi + local -ri width="${BASH_REMATCH[1]}" + local -ri height="${BASH_REMATCH[2]}" + + mp=$(awk -v w="$width" -v h="$height" 'BEGIN { printf "%.4f", w * h / 1000000 }') + _stats_bump STATS_MEGAPIXELS "$(_stats_megapixels_bucket "$mp")" + _stats_bump STATS_ASPECT "$(_stats_aspect_bucket "$width" "$height")" + _stats_bump STATS_ORIENTATION "$(_stats_orientation_bucket "$width" "$height")" +} + +# Record the file-format breakdown. The audit's cheapest path keys off the file +# extension (no identify parsing), so this maps the extension to a format label. +_stats_record_format() { + local -r photo="$1"; shift + local extension="${photo##*.}" + + if [[ "$photo" != *.* ]]; then + return + fi + case "${extension,,}" in + jpg|jpeg) _stats_bump STATS_FORMAT 'JPEG' ;; + png) _stats_bump STATS_FORMAT 'PNG' ;; + webp) _stats_bump STATS_FORMAT 'WEBP' ;; + gif) _stats_bump STATS_FORMAT 'GIF' ;; + *) _stats_bump STATS_FORMAT 'other' ;; + esac +} + +# Increment a counter in the named global associative array. Centralizes the +# "create-or-add-one" idiom and skips empty keys so unparseable buckets do not +# create blank entries. +_stats_bump() { + local -n array_ref="$1"; shift + local -r key="$1"; shift + + if [ -z "$key" ]; then + return + fi + array_ref["$key"]=$(( ${array_ref["$key"]:-0} + 1 )) +} + +# Parse one photo's `identify -verbose` stream into an associative array. The +# current album.source.sh regex only captures exif: lines, but the audit needs +# the native Geometry field for dimensions, so this adds a second match path +# storing it under the synthetic key __geometry. +_stats_parse_identify_stream() { + local -n values_ref="$1"; shift + local line + + values_ref=() + while IFS= read -r line; do + if [[ "$line" =~ ^[[:space:]]*exif:([^:]+):[[:space:]]*(.*)$ ]]; then + values_ref["${BASH_REMATCH[1]}"]="${BASH_REMATCH[2]}" + elif [[ "$line" =~ ^[[:space:]]*Geometry:[[:space:]]*(.*)$ ]]; then + values_ref[__geometry]="${BASH_REMATCH[1]}" + fi + done +} + +# Aggregate a single photo: parse its identify stream (from stdin) and update +# every counter. Split out from collect_photo_exif_stats so tests can feed a +# synthetic fixture without stubbing the cache layer. +accumulate_photo_stats() { + local -r photo="$1"; shift + # exif_values is filled and read through the nameref helpers below. + # shellcheck disable=SC2034 + local -A exif_values=() + + _stats_parse_identify_stream exif_values + STATS_TOTALS[photos]=$(( STATS_TOTALS[photos] + 1 )) + _stats_record_camera exif_values "$photo" + _stats_record_datetime exif_values + _stats_record_exposure exif_values + _stats_record_enums exif_values + _stats_record_dimensions exif_values + _stats_record_format "$photo" +} + +# Iterate the album's incoming photos, read each one's cached identify output via +# album.source.sh's cache helper, and aggregate it into the STATS_* globals. +# This is the entry point the render tasks call before reading the counters. +collect_photo_exif_stats() { + local photo + + reset_photo_exif_stats + while IFS= read -r photo; do + accumulate_photo_stats "$photo" \ + < <(cached_photo_identify_output "$photo" "$INCOMING_DIR/$photo") + done < <(incoming_image_files) +} + # Inlined from src/lib/config.source.sh existing_parent_dir() { local -r path="$1"; shift diff --git a/src/lib/stats.source.sh b/src/lib/stats.source.sh new file mode 100644 index 0000000..2ac2928 --- /dev/null +++ b/src/lib/stats.source.sh @@ -0,0 +1,512 @@ +# Stats aggregation for the album stats page (feature plan: +# /home/paul/.pi/plans/shuriken-stats-site.md, field audit: +# docs/stats-exif-audit.md). This module turns the per-photo +# `identify -verbose` output that album.source.sh already caches into +# aggregated, photographer-friendly counters. +# +# Scope: aggregation only. Rendering (stats.tmpl / camera.tmpl) lives in the +# sibling tasks pm0/rm0/um0; this module just fills the data structures they +# read. +# +# ---------------------------------------------------------------------------- +# Public API / handoff contract +# ---------------------------------------------------------------------------- +# collect_photo_exif_stats iterates the album's photos and fills these globals +# (all declared with `declare -gA` so the render tasks can read them after the +# call without namerefs): +# +# STATS_CAMERAS[<camera label>] = count (camera leaderboard) +# STATS_CAMERA_SLUGS[<camera label>] = slug (camera-<slug>.html) +# STATS_CAMERA_PHOTOS[<slug>] = newline-separated photo filenames +# STATS_LENSES[<lens model>] = count (sparse; may be empty) +# STATS_YEARS[<YYYY>] = count +# STATS_MONTHS[<01..12>] = count +# STATS_APERTURE[<bucket>] = count (e.g. "f/2.8") +# STATS_SHUTTER[<bucket>] = count (e.g. "1/250s") +# STATS_ISO[<bucket>] = count (e.g. "400") +# STATS_FOCAL[<bucket>] = count (e.g. "35-70mm") +# STATS_MEGAPIXELS[<bucket>] = count (e.g. "10-20MP") +# STATS_ASPECT[<bucket>] = count (e.g. "3:2") +# STATS_ORIENTATION[<bucket>] = count (Landscape/Portrait/Square) +# STATS_FORMAT[<bucket>] = count (JPEG/PNG/WEBP/GIF) +# STATS_EXPOSURE_PROGRAM[<label>] = count (decoded enum) +# STATS_METERING[<label>] = count (decoded enum) +# STATS_WHITE_BALANCE[<label>] = count (Auto/Manual) +# STATS_FLASH[<label>] = count (Fired/Did not fire; sparse) +# STATS_TOTALS[photos] = number of photos seen +# +# The render tasks should treat every per-category array as possibly empty +# (sparse data) and only render a section when it has entries. STATS_TOTALS +# gives the denominator for percentages. + +# Reset every stats global to an empty associative array. Called at the start of +# collect_photo_exif_stats so repeated invocations (e.g. tests, --refresh) do +# not accumulate stale counts. +reset_photo_exif_stats() { + declare -gA STATS_CAMERAS=() + declare -gA STATS_CAMERA_SLUGS=() + declare -gA STATS_CAMERA_PHOTOS=() + declare -gA STATS_LENSES=() + declare -gA STATS_YEARS=() + declare -gA STATS_MONTHS=() + declare -gA STATS_APERTURE=() + declare -gA STATS_SHUTTER=() + declare -gA STATS_ISO=() + declare -gA STATS_FOCAL=() + declare -gA STATS_MEGAPIXELS=() + declare -gA STATS_ASPECT=() + declare -gA STATS_ORIENTATION=() + declare -gA STATS_FORMAT=() + declare -gA STATS_EXPOSURE_PROGRAM=() + declare -gA STATS_METERING=() + declare -gA STATS_WHITE_BALANCE=() + declare -gA STATS_FLASH=() + declare -gA STATS_TOTALS=() + STATS_TOTALS[photos]=0 +} + +# Decode an EXIF rational ("num/den") to a decimal with `scale` digits. +# Guards den == 0 and tolerates plain decimals (some tools write "0.5"). Prints +# nothing for unparseable input so callers can skip it. The audit lists this as +# shared work for FNumber/FocalLength/ExposureTime. +_stats_rational_to_decimal() { + local -r value="$1"; shift + local -r scale="${1:-2}" + + if [[ "$value" =~ ^([0-9]+)/([0-9]+)$ ]]; then + local -r num="${BASH_REMATCH[1]}" + local -r den="${BASH_REMATCH[2]}" + if (( den == 0 )); then + return + fi + awk -v n="$num" -v d="$den" -v s="$scale" \ + 'BEGIN { printf "%.*f", s, n / d }' + return + fi + # Already a bare decimal/integer: echo it back so buckets can use it. + if [[ "$value" =~ ^[0-9]+([.][0-9]+)?$ ]]; then + printf '%s' "$value" + fi +} + +# Bucket an f-number (decimal) to the nearest standard aperture stop, matching +# the boundaries in the plan (≤f/1.8 ... ≥f/22). The decimal is compared with +# awk so we avoid bash integer-only math on fractional stops. +_stats_aperture_bucket() { + local -r fnum="$1"; shift + + awk -v f="$fnum" 'BEGIN { + if (f <= 1.8) { print "f/1.8 or wider"; } + else if (f <= 2.2) { print "f/2"; } + else if (f <= 3.2) { print "f/2.8"; } + else if (f <= 4.5) { print "f/4"; } + else if (f <= 6.7) { print "f/5.6"; } + else if (f <= 9.5) { print "f/8"; } + else if (f <= 13) { print "f/11"; } + else if (f <= 19) { print "f/16"; } + else { print "f/22 or narrower"; } + }' +} + +# Bucket a shutter speed (already normalized to seconds) into the plan's ranges. +# Faster shutters are smaller numbers, so the ladder runs from short to long. +_stats_shutter_bucket() { + local -r seconds="$1"; shift + + awk -v s="$seconds" 'BEGIN { + if (s <= 1/4000) { print "1/4000s or faster"; } + else if (s <= 1/2000) { print "1/2000s"; } + else if (s <= 1/1000) { print "1/1000s"; } + else if (s <= 1/500) { print "1/500s"; } + else if (s <= 1/250) { print "1/250s"; } + else if (s <= 1/125) { print "1/125s"; } + else if (s <= 1/60) { print "1/60s"; } + else if (s <= 1/30) { print "1/30s"; } + else if (s <= 1/15) { print "1/15s"; } + else if (s <= 1/8) { print "1/8s"; } + else if (s <= 1/4) { print "1/4s"; } + else if (s <= 1/2) { print "1/2s"; } + else if (s <= 1) { print "1s"; } + else { print "longer than 1s"; } + }' +} + +# Bucket an integer ISO to the next standard value at or above it. Plain integer +# per the audit; uses bash arithmetic since ISO is always an integer. +_stats_iso_bucket() { + local -ri iso="$1" + + if (( iso <= 50 )); then printf '50' + elif (( iso <= 100 )); then printf '100' + elif (( iso <= 200 )); then printf '200' + elif (( iso <= 400 )); then printf '400' + elif (( iso <= 800 )); then printf '800' + elif (( iso <= 1600 )); then printf '1600' + elif (( iso <= 3200 )); then printf '3200' + elif (( iso <= 6400 )); then printf '6400' + elif (( iso <= 12800 )); then printf '12800' + elif (( iso <= 25600 )); then printf '25600' + else printf 'over 25600' + fi +} + +# Bucket a focal length (decimal mm) by the plan's lens ranges. Labelled as +# physical focal length, not 35mm-equivalent, per the audit. +_stats_focal_bucket() { + local -r mm="$1"; shift + + awk -v m="$mm" 'BEGIN { + if (m < 24) { print "under 24mm"; } + else if (m < 35) { print "24-35mm"; } + else if (m < 70) { print "35-70mm"; } + else if (m < 135) { print "70-135mm"; } + else if (m <= 200) { print "135-200mm"; } + else { print "over 200mm"; } + }' +} + +# Bucket megapixels (W*H/1e6) by the plan's ranges. +_stats_megapixels_bucket() { + local -r mp="$1"; shift + + awk -v p="$mp" 'BEGIN { + if (p < 2) { print "under 2MP"; } + else if (p < 5) { print "2-5MP"; } + else if (p < 10) { print "5-10MP"; } + else if (p < 20) { print "10-20MP"; } + else if (p < 40) { print "20-40MP"; } + else if (p <= 80) { print "40-80MP"; } + else { print "over 80MP"; } + }' +} + +# Reduce W:H by GCD and match common photographic aspect ratios. The audit +# recommends deriving this from Geometry rather than EXIF. +_stats_aspect_bucket() { + local -ri width="$1"; shift + local -ri height="$1"; shift + local -i a="$width" + local -i b="$height" + local -i t + + if (( width <= 0 || height <= 0 )); then + return + fi + while (( b != 0 )); do + t=$b + b=$(( a % b )) + a=$t + done + case "$(( width / a )):$(( height / a ))" in + 3:2|2:3) printf '3:2' ;; + 4:3|3:4) printf '4:3' ;; + 16:9|9:16) printf '16:9' ;; + 1:1) printf '1:1' ;; + 5:4|4:5) printf '5:4' ;; + *) printf 'other' ;; + esac +} + +# Orientation from width vs height. The audit prefers this over the native +# Orientation rotation flag, which is frequently absent or already baked in. +_stats_orientation_bucket() { + local -ri width="$1"; shift + local -ri height="$1"; shift + + if (( width > height )); then printf 'Landscape' + elif (( height > width )); then printf 'Portrait' + else printf 'Square' + fi +} + +# Decode the ExposureProgram enum (0-8) to a friendly label. ImageMagick emits +# the bare integer; the audit supplies this map. +_stats_exposure_program_label() { + case "$1" in + 1) printf 'Manual' ;; + 2) printf 'Program AE' ;; + 3) printf 'Aperture priority' ;; + 4) printf 'Shutter priority' ;; + 5) printf 'Creative' ;; + 6) printf 'Action' ;; + 7) printf 'Portrait' ;; + 8) printf 'Landscape' ;; + *) printf 'Not defined' ;; + esac +} + +# Decode the MeteringMode enum to a friendly label per the audit's map. +_stats_metering_label() { + case "$1" in + 1) printf 'Average' ;; + 2) printf 'Center-weighted' ;; + 3) printf 'Spot' ;; + 4) printf 'Multi-spot' ;; + 5) printf 'Multi-segment' ;; + 6) printf 'Partial' ;; + 255) printf 'Other' ;; + *) printf 'Unknown' ;; + esac +} + +# Standard EXIF WhiteBalance is only a 2-value enum (0 Auto, 1 Manual); the +# richer presets live in MakerNotes and are not exposed by identify (audit). +_stats_white_balance_label() { + case "$1" in + 0) printf 'Auto' ;; + 1) printf 'Manual' ;; + *) return ;; + esac +} + +# Decode the EXIF Flash bitmask: bit 0 indicates the flash fired. The audit +# warns this tag is frequently missing, so callers must tolerate absence. +_stats_flash_label() { + local -ri flash="$1" + + if (( flash & 1 )); then printf 'Flash fired' + else printf 'No flash' + fi +} + +# Build a filename-safe slug for camera-<slug>.html. Lowercase, non-alphanumeric +# runs collapsed to a single dash, leading/trailing dashes trimmed. +_stats_slug() { + local slug="${1,,}" + + slug="${slug//[^a-z0-9]/-}" + while [[ "$slug" == *--* ]]; do + slug="${slug//--/-}" + done + slug="${slug#-}" + slug="${slug%-}" + printf '%s' "$slug" +} + +# Join Make + Model into a single camera label, reusing the same dedup logic as +# album.source.sh's tooltip builder (handles "Model already includes Make"). +_stats_camera_label() { + local -r make="$1"; shift + local -r model="$1"; shift + + if [ -z "$model" ]; then + printf '%s' "$make" + return + fi + if [ -z "$make" ]; then + printf '%s' "$model" + return + fi + case "$model" in + "$make"|"$make "*) printf '%s' "$model" ;; + *) printf '%s %s' "$make" "$model" ;; + esac +} + +# Increment STATS_CAMERAS for a photo and record it on the per-camera list so +# um0 can render camera-<slug>.html. Skips photos with no Make/Model at all. +_stats_record_camera() { + local -n values_ref="$1"; shift + local -r photo="$1"; shift + local label + local slug + + label=$(_stats_camera_label "${values_ref[Make]:-}" "${values_ref[Model]:-}") + if [ -z "$label" ]; then + return + fi + slug=$(_stats_slug "$label") + STATS_CAMERAS["$label"]=$(( ${STATS_CAMERAS["$label"]:-0} + 1 )) + STATS_CAMERA_SLUGS["$label"]="$slug" + if [ -n "${STATS_CAMERA_PHOTOS["$slug"]:-}" ]; then + STATS_CAMERA_PHOTOS["$slug"]+=$'\n'"$photo" + else + STATS_CAMERA_PHOTOS["$slug"]="$photo" + fi + if [ -n "${values_ref[LensModel]:-}" ]; then + STATS_LENSES["${values_ref[LensModel]}"]=$(( + ${STATS_LENSES["${values_ref[LensModel]}"]:-0} + 1 )) + fi +} + +# Record year and month counters from DateTimeOriginal ("YYYY:MM:DD HH:MM:SS"). +# Parsed by substring per the audit: the colons in the date part are not +# standard, so this must not be fed to `date -d`. Falls back to the digitized / +# plain DateTime tags. +_stats_record_datetime() { + local -n values_ref="$1"; shift + local raw='' + local key + + for key in DateTimeOriginal DateTimeDigitized DateTime; do + if [ -n "${values_ref[$key]:-}" ]; then + raw="${values_ref[$key]}" + break + fi + done + if [[ ! "$raw" =~ ^([0-9]{4}):([0-9]{2}): ]]; then + return + fi + local -r year="${BASH_REMATCH[1]}" + local -r month="${BASH_REMATCH[2]}" + STATS_YEARS["$year"]=$(( ${STATS_YEARS["$year"]:-0} + 1 )) + STATS_MONTHS["$month"]=$(( ${STATS_MONTHS["$month"]:-0} + 1 )) +} + +# Record the aperture, shutter, ISO and focal-length histograms. Each uses the +# fallback tag order the audit recommends and the rational decoder for the +# fiddly fields. Missing/unparseable values are simply skipped. +_stats_record_exposure() { + local -n values_ref="$1"; shift + local decimal + local raw + + raw="${values_ref[FNumber]:-}" + decimal=$(_stats_rational_to_decimal "$raw") + if [ -n "$decimal" ]; then + _stats_bump STATS_APERTURE "$(_stats_aperture_bucket "$decimal")" + fi + + raw="${values_ref[ExposureTime]:-}" + decimal=$(_stats_rational_to_decimal "$raw" 6) + if [ -n "$decimal" ]; then + _stats_bump STATS_SHUTTER "$(_stats_shutter_bucket "$decimal")" + fi + + raw="${values_ref[PhotographicSensitivity]:-${values_ref[ISOSpeedRatings]:-${values_ref[ISO]:-}}}" + if [[ "$raw" =~ ^[0-9]+$ ]]; then + _stats_bump STATS_ISO "$(_stats_iso_bucket "$raw")" + fi + + raw="${values_ref[FocalLength]:-}" + decimal=$(_stats_rational_to_decimal "$raw") + if [ -n "$decimal" ]; then + _stats_bump STATS_FOCAL "$(_stats_focal_bucket "$decimal")" + fi +} + +# Record the decoded enum stats (exposure program, metering, white balance, +# flash). Each is skipped when the tag is absent or decodes to nothing. +_stats_record_enums() { + local -n values_ref="$1"; shift + local label + + if [ -n "${values_ref[ExposureProgram]:-}" ]; then + label=$(_stats_exposure_program_label "${values_ref[ExposureProgram]}") + _stats_bump STATS_EXPOSURE_PROGRAM "$label" + fi + if [ -n "${values_ref[MeteringMode]:-}" ]; then + label=$(_stats_metering_label "${values_ref[MeteringMode]}") + _stats_bump STATS_METERING "$label" + fi + if [ -n "${values_ref[WhiteBalance]:-}" ]; then + label=$(_stats_white_balance_label "${values_ref[WhiteBalance]}") + if [ -n "$label" ]; then + _stats_bump STATS_WHITE_BALANCE "$label" + fi + fi + if [[ "${values_ref[Flash]:-}" =~ ^[0-9]+$ ]]; then + _stats_bump STATS_FLASH "$(_stats_flash_label "${values_ref[Flash]}")" + fi +} + +# Record dimension stats (megapixels, aspect ratio, orientation) from the native +# Geometry field. Geometry is "WxH+x+y"; the leading WxH is what we need. These< |
