From 9629a07399571edaf7b0c3ffe0eb1286ccb778c1 Mon Sep 17 00:00:00 2001 From: NoahC Date: Fri, 26 Jun 2026 09:39:54 +0200 Subject: [PATCH] Add FireMapper post-processing toolchain EXIF/GPS embedding, session-wide thermal stretch, and an OpenStreetMap view of trigger points and image footprints, wrapped in a Tkinter GUI. - embed_metadata.py: write JSON sidecars into image EXIF/XMP, with true camera orientation (aircraft attitude + platform roll + mounting offset) - stretch_thermal.py: rescale 16-bit thermal frames to a session-wide window (8-bit palettes), embedding GPS/orientation EXIF in the output - session_map.py: shared camera geometry (orientation + oblique footprints) - firemapper_gui.py: GUI with Embed / Thermal Stretch / Map tabs Includes CLAUDE.md and an end-user manual (manual.html). Capture data and derived outputs (tens of GB) are git-ignored. Co-Authored-By: Claude Opus 4.8 --- .gitignore | 27 ++ CLAUDE.md | 118 +++++++++ embed_metadata.py | 327 ++++++++++++++++++++++++ firemapper_gui.py | 610 +++++++++++++++++++++++++++++++++++++++++++++ manual.html | 214 ++++++++++++++++ session_map.py | 246 ++++++++++++++++++ stretch_thermal.py | 257 +++++++++++++++++++ 7 files changed, 1799 insertions(+) create mode 100644 .gitignore create mode 100644 CLAUDE.md create mode 100644 embed_metadata.py create mode 100644 firemapper_gui.py create mode 100644 manual.html create mode 100644 session_map.py create mode 100644 stretch_thermal.py diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2edff97 --- /dev/null +++ b/.gitignore @@ -0,0 +1,27 @@ +# --- Capture data & derived outputs (large — never commit) --- +Streifen/ +session_*/ +*_exif/ +*_stretched/ +thermal_palette_sample.png + +# --- exiftool cache --- +tools/ + +# --- Python --- +__pycache__/ +*.py[cod] + +# --- Raw capture / image data (safety net) --- +*.tif +*.tiff +*.png +*.jpg +*.jpeg +*.jsonl + +# --- Temp files & logs --- +_gui_*.log +_diag* +_exiftool_args.txt +*.log diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..b042ead --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,118 @@ +# FireMapper — Session Post-Processing + +Tools that turn raw FireMapper capture sessions into mapping-ready imagery: +embed GPS/orientation metadata into the photos, stretch the radiometric thermal +frames for viewing, and plot trigger points + image footprints on a map. + +This repo contains **only the Python tools** — the capture data (session folders, +`*_exif/`, `*_stretched/`) is large (tens of GB) and is git-ignored. + +## Hardware / data + +FireMapper (GGS Speyer) is an aerial rig: +- **RGB cameras** — Allied Vision Alvium *1800 U-1620c*, 5328×3040, saved as TIFF in + `cam25/`, `cam45/` (the number is the **off-nadir mounting angle** in degrees). +- **Thermal camera** — FLIR **A65**, 640×512, 16-bit **radiometric** PNG in `thermal/` + (values are signal counts, *not* calibrated °C). 25° lens → ~25°×20° FOV. +- **INS** — VN-200 (fused attitude + position). + +A **capture session** is a folder (typically `session_YYYYMMDD_HHMMSS/`, but any name +works) containing: + +``` +/ + cam25/ _step_NN.tiff + _step_NN.json (same-basename JSON sidecar each) + cam45/ … + thermal/ _step_NN.png + _step_NN.json + steps/ step_NN.json (per-step summaries, NOT per-image — ignored by the tools) + manifest.json (camera ids / models / serials) + imu_log.jsonl +``` + +Sessions may be grouped under a **parent folder** (e.g. `Streifen/` = flight strips). +Session discovery is content-based (a dir that holds `thermal/`, a `cam*/`, or a +`manifest.json`) and looks one level into parent folders — so the tools find sessions +whether you run from the session's parent or from a grouping folder. + +## The tools + +| File | What it does | Output | +|------|--------------|--------| +| `embed_metadata.py` | Copies each image and writes its JSON sidecar into the copy's EXIF/XMP (GPS, true camera orientation, UTC time, lens/exposure, full JSON in `UserComment`). | `_exif/` (mirrors layout; originals untouched) | +| `stretch_thermal.py` | Rescales all thermal frames to one session-wide brightness window → 8-bit, with a palette (inferno/ironbow/gray). Embeds the sidecar EXIF into the output too. | `/thermal_stretched/` | +| `session_map.py` | Shared geometry: trigger points + oblique footprints; also the camera-orientation math used by `embed_metadata`. Not run directly. | — | +| `firemapper_gui.py` | Tkinter GUI wrapping all of the above. Tabs: **1. Embed**, **2. Thermal Stretch**, **3. Map**. | — | + +`embed_metadata.py` and `stretch_thermal.py` are also usable as CLIs (see below). +`stretch_thermal` imports `embed_metadata` (to reuse exiftool + tag-building); +`embed_metadata` and `stretch_thermal` import `session_map` (geometry/discovery). + +## Running + +GUI (recommended for end users): +``` +python firemapper_gui.py +``` + +CLI: +``` +python embed_metadata.py [SESSION] [--out DIR] [--gps-source position|gps] [--dry-run] +python stretch_thermal.py [SESSION] [--colormap inferno|ironbow|gray] + [--lo-pct P --hi-pct P | --absolute | --lo N --hi N] + [--out DIR] [--no-embed-exif] [--sample] +``` +With no `SESSION` argument they auto-pick the newest session near the script. + +## Camera geometry (important — keep map and EXIF consistent) + +All orientation/footprint math lives in `session_map.py` and is shared by the map and +the EXIF writer so they never diverge. The pointing chain is: + +``` +aircraft attitude (yaw/pitch/roll, body→NED) + @ platform roll about the FORWARD axis by platform_angle_deg (cross-track sweep) + @ camera mounting offset +``` + +Mounting (operator-confirmed): +- **Thermal** — optical axis straight **down** (nadir). Mounted **vertical/portrait**: + sensor *width* runs **along** the flight track. +- **RGB** — optical axis tilted toward the **forward** direction by the `cam` + off-nadir angle (cam25 = 25° from straight-down, cam45 = 45°). Mounted **landscape**: + sensor *width* runs **across** the flight track. + +Functions: +- `camera_orientation(frame)` → `(heading, pitch, roll)` of the true optical axis for + EXIF. Roll uses a *canonical landscape* frame, so it is the camera **bank** (≈0 in + level flight), not the 90° sensor portrait/landscape rotation. +- `camera_axes_ned(frame, sensor=True)` → the sensor-accurate image axes for footprints. + +`embed_metadata.build_tags()` writes the orientation as `GPSImgDirection` + +`XMP-GPano:Pose{Heading,Pitch,Roll}Degrees` (the `XMP-Camera:Pose*` namespace is **not** +writable in stock exiftool). It uses this geometry — **not** the raw IMU. + +## Conventions & gotchas + +- **exiftool** is required by the embed step. Resolved from PATH, a local + `tools/exiftool.exe`, the OliverBetz user install (`%LOCALAPPDATA%\Programs\ExifTool`), + else auto-installed via `winget install OliverBetz.ExifTool`. (SourceForge auto-download + is unreliable — GDPR/consent wall.) +- **GPS source:** cam frames use the fused INS `position` block (falls back to raw `gps`); + thermal has only `gps`. UTC time = GPS week/tow − 18 leap seconds. +- **Thermal stretch window:** default = pooled 1–99th percentile across the whole session + (robust to outliers); configurable. Values are radiometric signal, not °C. +- **Footprints** assume **flat ground** at a user-set elevation (default 110 m, the Speyer + area). There is no terrain model. +- **Sweep side:** if footprints/headings come out mirrored vs a known flight, flip + `SCAN_SIGN` at the top of `session_map.py`. +- **Tkinter:** never create `ttk.Style()` before the root window — it spawns a stray + default root and `StringVar`s bind to the wrong one (entries render blank). The style is + created with the app as master inside `__init__`, and all vars are parented to it. + +## Dependencies + +Python 3.13, `Pillow`, `numpy`, `tkintermapview`, `requests` (map tiles need internet), +and `exiftool` (auto-installed). Tkinter ships with the standard Windows Python. +``` +pip install Pillow numpy tkintermapview requests +``` diff --git a/embed_metadata.py b/embed_metadata.py new file mode 100644 index 0000000..90a073c --- /dev/null +++ b/embed_metadata.py @@ -0,0 +1,327 @@ +#!/usr/bin/env python3 +""" +embed_metadata.py - Write FireMapper per-frame JSON sidecars into image EXIF/XMP. + +For every image in a capture session that has a same-basename `.json` sidecar +(cam*/_step_NN.tiff and thermal/_step_NN.png), this: + + * maps the structured fields to real EXIF/XMP tags so the images are + georeferenced and usable in mapping / photogrammetry software: + - GPS lat/lon/alt -> EXIF GPS* tags + - platform yaw -> EXIF GPSImgDirection + XMP-Camera:PoseHeadingDegrees + - pitch / roll -> XMP-Camera:PosePitch/PoseRollDegrees + - GPS week/tow -> DateTimeOriginal + GPSDateStamp/GPSTimeStamp (UTC) + - lens / exposure / camera -> FocalLength, ExposureTime, Make/Model/Serial + * stores the COMPLETE original JSON in EXIF:UserComment so nothing is lost. + +Originals are never modified: tagged copies are written to an output folder +that mirrors the session layout. + +Usage: + python embed_metadata.py [SESSION_DIR] [options] + + SESSION_DIR session folder (default: the single session_* dir here) + --out DIR output folder (default: _exif next to it) + --gps-source {position,gps} + which block feeds GPS coords for cam frames + (default: position = fused INS, falls back to gps) + --exiftool PATH path to exiftool.exe (default: auto-detect / download) + --dry-run report what would be written, touch nothing +""" + +from __future__ import annotations + +import argparse +import datetime as dt +import json +import os +import shutil +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import session_map as smap # noqa: E402 (shared camera geometry + session discovery) + +IMAGE_EXTS = {".tif", ".tiff", ".png", ".jpg", ".jpeg"} +GPS_EPOCH = dt.datetime(1980, 1, 6, tzinfo=dt.timezone.utc) +GPS_UTC_LEAP_SECONDS = 18 # GPS-UTC offset as of 2017-2026 + + +# --------------------------------------------------------------------------- # +# exiftool acquisition +# --------------------------------------------------------------------------- # +def _exiftool_candidates(cache_dir: Path): + """Yield likely exiftool locations in priority order.""" + yield shutil.which("exiftool") + yield str(cache_dir / "exiftool.exe") + local = os.environ.get("LOCALAPPDATA", "") + pf = os.environ.get("ProgramFiles", "") + for base in (local and Path(local) / "Programs" / "ExifTool", pf and Path(pf) / "ExifTool"): + if base: + yield str(base / "ExifTool.exe") + yield str(base / "exiftool.exe") + + +def ensure_exiftool(explicit: str | None, cache_dir: Path) -> str: + """Return a path to a working exiftool, installing it via winget if needed.""" + if explicit: + return explicit + for cand in _exiftool_candidates(cache_dir): + if cand and Path(cand).exists(): + return cand + + # not found anywhere - try a one-time install via winget (Windows) + if shutil.which("winget"): + print("exiftool not found - installing via winget (OliverBetz.ExifTool)...") + subprocess.run( + ["winget", "install", "--id", "OliverBetz.ExifTool", + "--accept-package-agreements", "--accept-source-agreements", "--silent"], + check=False, + ) + for cand in _exiftool_candidates(cache_dir): + if cand and Path(cand).exists(): + return cand + + sys.exit( + "exiftool could not be found or installed automatically.\n" + "Install it (e.g. `winget install OliverBetz.ExifTool` or `choco install exiftool`),\n" + "or pass its path with --exiftool C:\\path\\to\\exiftool.exe" + ) + + +# --------------------------------------------------------------------------- # +# JSON -> tag mapping +# --------------------------------------------------------------------------- # +def gps_time_to_utc(week, tow) -> dt.datetime | None: + if week is None or tow is None: + return None + return GPS_EPOCH + dt.timedelta(seconds=week * 604800 + tow - GPS_UTC_LEAP_SECONDS) + + +def signed_ref(value, pos, neg): + """Return (abs_value, ref_letter) for a signed coordinate.""" + return (abs(value), pos if value >= 0 else neg) + + +def build_tags(data: dict, manifest_cams: dict, cam_id: str | None, gps_source: str) -> list[str]: + """Return a list of '-Tag=value' exiftool arguments for one frame.""" + tags: list[str] = [] + + # --- coordinates: prefer the requested source, fall back to raw gps ---- + pos = None + if cam_id and gps_source == "position": + pos = data.get("position") + if not pos: + pos = data.get("gps") + + if pos and pos.get("lat") is not None and pos.get("lon") is not None: + lat, lat_ref = signed_ref(pos["lat"], "N", "S") + lon, lon_ref = signed_ref(pos["lon"], "E", "W") + tags += [f"-GPSLatitude={lat}", f"-GPSLatitudeRef={lat_ref}", + f"-GPSLongitude={lon}", f"-GPSLongitudeRef={lon_ref}"] + if pos.get("alt") is not None: + alt = pos["alt"] + tags += [f"-GPSAltitude={abs(alt)}", f"-GPSAltitudeRef={0 if alt >= 0 else 1}"] + + # --- orientation: TRUE camera pointing --------------------------------- + # aircraft attitude + platform roll (platform_angle_deg) + camera mounting offset, + # not the bare IMU. Computed by the shared geometry so EXIF matches the map. + imu = data.get("imu") or {} + plat = data.get("platform") or {} + ori = None + if None not in (imu.get("yaw"), imu.get("pitch"), imu.get("roll")): + ori = smap.camera_orientation({ + "kind": "cam" if cam_id else "thermal", + "off_nadir": smap.off_nadir_from_name(cam_id) if cam_id else 0.0, + "yaw": imu["yaw"], "pitch": imu["pitch"], "roll": imu["roll"], + "platform_angle": plat.get("platform_angle_deg"), + }) + if ori: + heading, pitch, roll = ori + tags += [f"-GPSImgDirection={heading % 360:.4f}", "-GPSImgDirectionRef=T", + f"-XMP-GPano:PoseHeadingDegrees={heading % 360:.4f}", + f"-XMP-GPano:PosePitchDegrees={pitch:.4f}", + f"-XMP-GPano:PoseRollDegrees={roll:.4f}"] + + # --- timestamp (UTC, derived from GPS week/tow) ------------------------ + g = data.get("gps") or {} + when = gps_time_to_utc(g.get("week"), g.get("tow")) + if when: + tags += [f"-DateTimeOriginal={when:%Y:%m:%d %H:%M:%S}", + f"-SubSecTimeOriginal={when.microsecond // 1000:03d}", + "-OffsetTimeOriginal=+00:00", + f"-GPSDateStamp={when:%Y:%m:%d}", + f"-GPSTimeStamp={when:%H:%M:%S}"] + + # --- camera / lens ----------------------------------------------------- + lens = data.get("lens") or {} + if lens.get("focal_length_mm") is not None: + tags.append(f"-FocalLength={lens['focal_length_mm']}") + if data.get("exposure_time_us") is not None: + tags.append(f"-ExposureTime={data['exposure_time_us'] / 1_000_000.0:.9f}") + if cam_id and cam_id in manifest_cams: + cam = manifest_cams[cam_id] + tags += ["-Make=Allied Vision", f"-Model={cam.get('model', cam_id)}", + f"-SerialNumber={cam.get('serial', '')}"] + + # --- full fidelity: entire JSON in UserComment + a short description ---- + compact = json.dumps(data, separators=(",", ":"), sort_keys=True) + label_bits = [p for p in (cam_id or ("thermal" if "thermal" in data else None),) if p] + if g.get("week") is not None: + label_bits.append(f"frame {data.get('frame_id', '?')}") + tags += [f"-EXIF:UserComment={compact}", + "-Software=FireMapper embed_metadata.py", + f"-EXIF:ImageDescription={' '.join(label_bits) or 'FireMapper frame'}"] + return tags + + +# --------------------------------------------------------------------------- # +# core (importable - used by both the CLI and the GUI) +# --------------------------------------------------------------------------- # +def find_session(arg: str | None) -> Path: + if arg: + return Path(arg).resolve() + sessions = smap.list_sessions(Path(__file__).resolve().parent) + if not sessions: + sys.exit("No session folder found nearby; pass SESSION_DIR explicitly.") + return max(sessions, key=lambda p: p.stat().st_mtime) + + +def collect_pairs(session: Path) -> list[tuple[Path, Path]]: + """Every image under the session that has a same-basename .json sidecar.""" + pairs = [] + for img in sorted(session.rglob("*")): + if img.suffix.lower() in IMAGE_EXTS and img.with_suffix(".json").exists(): + pairs.append((img, img.with_suffix(".json"))) + return pairs + + +def load_manifest_cams(session: Path) -> dict: + """camera id -> manifest entry (model / serial), if a manifest exists.""" + cams: dict = {} + mpath = session / "manifest.json" + if mpath.exists(): + try: + for cam in json.loads(mpath.read_text(encoding="utf-8")).get("cameras", []): + cams[cam["id"]] = cam + except Exception: # noqa: BLE001 + pass + return cams + + +def embed_session(session, out_root=None, gps_source="position", exiftool=None, + progress=None, log=None, cancel=None) -> dict: + """ + Write the JSON sidecars into EXIF/XMP of tagged copies of every image. + + progress(fraction_0_to_1, message) and log(message) are optional callbacks + so a GUI (or the CLI) can show what is happening; cancel() may return True + to abort. Returns a small summary dict. + """ + session = Path(session) + out_root = Path(out_root) if out_root else session.with_name(session.name + "_exif") + log = log or (lambda *_: None) + progress = progress or (lambda *_: None) + cancel = cancel or (lambda: False) + + pairs = collect_pairs(session) + if not pairs: + raise RuntimeError(f"No image+json pairs found under {session}") + cams = load_manifest_cams(session) + et = exiftool or ensure_exiftool(None, Path(__file__).resolve().parent / "tools") + total = len(pairs) + + log(f"Session : {session}") + log(f"Output : {out_root}") + log(f"GPS source : {gps_source} (cam frames; thermal always uses raw gps)") + log(f"exiftool : {et}") + log(f"Found {total} image + JSON pairs.\nCopying originals into the output folder ...") + + arg_lines: list[str] = [] + copied = 0 + for i, (img, sidecar) in enumerate(pairs, 1): + if cancel(): + raise RuntimeError("Cancelled by user.") + rel = img.relative_to(session) + cam_id = rel.parts[0] if rel.parts[0].startswith("cam") else None + try: + data = json.loads(sidecar.read_text(encoding="utf-8")) + except Exception as e: # noqa: BLE001 + log(f" ! skip {rel}: bad json ({e})") + continue + dst = out_root / rel + dst.parent.mkdir(parents=True, exist_ok=True) + shutil.copy2(img, dst) + copied += 1 + arg_lines += ["-overwrite_original", *build_tags(data, cams, cam_id, gps_source), + str(dst), "-execute"] + progress(0.5 * i / total, f"Copying {i}/{total}") + + argfile = out_root / "_exiftool_args.txt" + argfile.write_text("\n".join(arg_lines) + "\n", encoding="utf-8") + log(f"Copied {copied} images. Writing EXIF/XMP tags with exiftool ...") + + # stream exiftool so progress advances per file + proc = subprocess.Popen( + [et, "-m", "-charset", "UTF8", "-charset", "filename=UTF8", "-@", str(argfile)], + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True, + ) + done = updated = 0 + for line in proc.stdout: # type: ignore[union-attr] + line = line.rstrip() + if "image files" in line and ("updated" in line or "unchanged" in line): + done += 1 + if "updated" in line: + updated += 1 + progress(0.5 + 0.5 * done / total, f"Tagging {done}/{total}") + elif line.strip(): + log(line) + proc.wait() + argfile.unlink(missing_ok=True) + if proc.returncode != 0: + raise RuntimeError(f"exiftool exited with code {proc.returncode}") + + progress(1.0, "Done") + log(f"\nDone. {updated} files tagged.\nOutput: {out_root}") + return {"out_root": out_root, "pairs": total, "updated": updated} + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # +def main() -> int: + ap = argparse.ArgumentParser(description="Embed FireMapper JSON sidecars into image EXIF/XMP.") + ap.add_argument("session", nargs="?", help="session folder (default: newest session_* here)") + ap.add_argument("--out", help="output folder (default: _exif)") + ap.add_argument("--gps-source", choices=["position", "gps"], default="position", + help="GPS source for cam frames (default: position = fused INS)") + ap.add_argument("--exiftool", help="path to exiftool (default: auto-detect/install)") + ap.add_argument("--dry-run", action="store_true", help="report only, write nothing") + args = ap.parse_args() + + session = find_session(args.session) + if not session.is_dir(): + sys.exit(f"Session folder not found: {session}") + + if args.dry_run: + pairs = collect_pairs(session) + cams = load_manifest_cams(session) + if not pairs: + sys.exit(f"No image+json pairs found under {session}") + img, sidecar = pairs[0] + cam_id = img.relative_to(session).parts[0] + cam_id = cam_id if cam_id.startswith("cam") else None + data = json.loads(sidecar.read_text(encoding="utf-8")) + print(f"DRY RUN: {len(pairs)} pairs under {session}") + print("\n--- sample tags (first frame) ---") + print("\n".join(build_tags(data, cams, cam_id, args.gps_source))) + return 0 + + embed_session(session, args.out, args.gps_source, args.exiftool, + progress=lambda f, m: None, log=print) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/firemapper_gui.py b/firemapper_gui.py new file mode 100644 index 0000000..b8d6594 --- /dev/null +++ b/firemapper_gui.py @@ -0,0 +1,610 @@ +#!/usr/bin/env python3 +""" +FireMapper - Session Post-Processing (GUI) +========================================== + +A friendly front-end for the two FireMapper post-processing steps: + + 1. Embed GPS & metadata - writes each frame's JSON sidecar (GPS, heading, + time, lens, ...) into EXIF/XMP of tagged image copies, ready for mapping + software. (wraps embed_metadata.py) + + 2. Thermal stretch - rescales the whole session's 16-bit radiometric + thermal frames into one shared brightness window and saves viewable + 8-bit images in a heat-map palette. (wraps stretch_thermal.py) + +Just run: python firemapper_gui.py + +No command line needed - pick a session folder, read the on-screen +explanation, and click the button. Long jobs run in the background with a +progress bar; your original files are never modified. +""" + +from __future__ import annotations + +import math +import os +import queue +import sys +import threading +from pathlib import Path + +import tkinter as tk +from tkinter import filedialog, messagebox, scrolledtext, ttk + +from PIL import Image, ImageDraw, ImageTk + +# import the worker modules that live next to this file +APP_DIR = Path(__file__).resolve().parent +sys.path.insert(0, str(APP_DIR)) +import embed_metadata as embed # noqa: E402 +import stretch_thermal as stretch # noqa: E402 +import session_map as smap # noqa: E402 + +try: + from tkintermapview import TkinterMapView # noqa: E402 +except Exception: # noqa: BLE001 (optional dependency - the Map tab degrades gracefully) + TkinterMapView = None + +PAD = 10 +HEADING = ("Segoe UI Semibold", 11) +EXPLAIN_WRAP = 720 + + +def autodetect_session() -> str: + """Newest session folder near this script (also one level into parent folders).""" + sessions = smap.list_sessions(APP_DIR) + return str(max(sessions, key=lambda p: p.stat().st_mtime)) if sessions else "" + + +def open_in_explorer(path: Path) -> None: + if hasattr(os, "startfile"): + try: + os.startfile(path) # type: ignore[attr-defined] + except OSError: + pass + + +# --------------------------------------------------------------------------- # +class FireMapperGUI(tk.Tk): + def __init__(self): + super().__init__() + self.title("FireMapper - Session Post-Processing") + self.geometry("960x780") + self.minsize(820, 640) + + # style must be created with THIS root as master - never before it, + # or tkinter spins up a second, stray default root and the StringVars + # bind to the wrong one (entries then render blank). + style = ttk.Style(self) + if "vista" in style.theme_names(): + style.theme_use("vista") + style.configure("Card.TFrame", background="#f5f6f8", relief="solid", borderwidth=1) + + self.q: queue.Queue = queue.Queue() + self.running = False + self.action_buttons: list[ttk.Button] = [] + self.logs: dict[str, scrolledtext.ScrolledText] = {} + self.bars: dict[str, ttk.Progressbar] = {} + self.status_vars: dict[str, tk.StringVar] = {} + self._preview_imgtk = None # keep a reference so Tk doesn't GC it + + self._build_header() + nb = ttk.Notebook(self) + nb.pack(fill="both", expand=True, padx=PAD, pady=(0, PAD)) + self._build_embed_tab(nb) + self._build_thermal_tab(nb) + self._build_map_tab(nb) + + self.after(100, self._poll_queue) + + # ----- shared UI bits ------------------------------------------------- + def _build_header(self): + head = ttk.Frame(self, padding=(PAD, PAD, PAD, 4)) + head.pack(fill="x") + ttk.Label(head, text="FireMapper - Session Post-Processing", + font=("Segoe UI Semibold", 15)).pack(anchor="w") + ttk.Label(head, foreground="#555", wraplength=900, justify="left", + text="Turn a raw capture session into mapping-ready imagery. " + "Pick a session folder, then run either step. Originals are " + "never changed - results are written to new folders.").pack(anchor="w") + ttk.Separator(self).pack(fill="x", padx=PAD, pady=(6, 8)) + + @staticmethod + def _explain(parent, text): + box = ttk.Frame(parent, padding=8) + box.configure(style="Card.TFrame") + ttk.Label(box, text=text, wraplength=EXPLAIN_WRAP, justify="left", + foreground="#333").pack(anchor="w") + return box + + def _folder_row(self, parent, label, var, browse_dir=True): + row = ttk.Frame(parent) + ttk.Label(row, text=label, width=16).pack(side="left") + ttk.Entry(row, textvariable=var).pack(side="left", fill="x", expand=True) + + def browse(): + if browse_dir: + p = filedialog.askdirectory(title=label, + initialdir=var.get() or str(APP_DIR)) + else: + p = filedialog.askopenfilename(title=label, + initialdir=var.get() or str(APP_DIR)) + if p: + var.set(p) + ttk.Button(row, text="Browse...", command=browse).pack(side="left", padx=(6, 0)) + return row + + def _progress_block(self, parent, which): + frame = ttk.Frame(parent) + bar = ttk.Progressbar(frame, mode="determinate", maximum=1000) + bar.pack(fill="x") + sv = tk.StringVar(self, value="Idle.") + ttk.Label(frame, textvariable=sv, foreground="#555").pack(anchor="w", pady=(2, 0)) + log = scrolledtext.ScrolledText(frame, height=11, wrap="word", + font=("Consolas", 9)) + log.pack(fill="both", expand=True, pady=(6, 0)) + self.bars[which] = bar + self.status_vars[which] = sv + self.logs[which] = log + return frame + + # ----- tab 1: embed --------------------------------------------------- + def _build_embed_tab(self, nb): + tab = ttk.Frame(nb, padding=PAD) + nb.add(tab, text=" 1. Embed GPS & Metadata ") + + ttk.Label(tab, text="Embed GPS & metadata into images", font=HEADING).pack(anchor="w") + self._explain(tab, + "Copies every image in the session and writes its matching JSON data into " + "the copy's EXIF/XMP tags: GPS position, camera heading / pitch / roll, UTC " + "capture time, lens & exposure, plus the complete JSON in the comment field. " + "The tagged copies drop straight into mapping / photogrammetry tools " + "(Pix4D, Metashape, QGIS, ...), which read the embedded GPS to place each " + "photo on the map. Requires exiftool - it is found automatically, and " + "installed for you the first time if missing." + ).pack(fill="x", pady=(4, 10)) + + self.embed_session = tk.StringVar(self, value=autodetect_session()) + self.embed_out = tk.StringVar(self, value="") + self.embed_gps = tk.StringVar(self, value="position") + self.embed_exif = tk.StringVar(self, value="") + + self._folder_row(tab, "Session folder", self.embed_session).pack(fill="x", pady=3) + self._folder_row(tab, "Output folder", self.embed_out).pack(fill="x", pady=3) + ttk.Label(tab, text="Leave output blank to use _exif next to the session.", + foreground="#777").pack(anchor="w", padx=(16, 0)) + + gps = ttk.LabelFrame(tab, text="GPS source for camera frames", padding=8) + gps.pack(fill="x", pady=(10, 4)) + ttk.Radiobutton(gps, variable=self.embed_gps, value="position", + text="Fused INS (position) - most accurate [recommended]").pack(anchor="w") + ttk.Radiobutton(gps, variable=self.embed_gps, value="gps", + text="Raw GNSS (gps) - bare satellite fix").pack(anchor="w") + ttk.Label(gps, foreground="#777", + text="Thermal frames have no INS solution and always use raw GNSS.").pack(anchor="w") + + adv = ttk.Frame(tab) + adv.pack(fill="x", pady=(8, 2)) + ttk.Label(adv, text="exiftool path", width=16).pack(side="left") + ttk.Entry(adv, textvariable=self.embed_exif).pack(side="left", fill="x", expand=True) + ttk.Label(adv, text="(optional - auto)", foreground="#777").pack(side="left", padx=6) + + btn = ttk.Button(tab, text="Embed metadata", command=self._start_embed) + btn.pack(anchor="w", pady=10) + self.action_buttons.append(btn) + + self._progress_block(tab, "embed").pack(fill="both", expand=True) + + # ----- tab 2: thermal ------------------------------------------------- + def _build_thermal_tab(self, nb): + tab = ttk.Frame(nb, padding=PAD) + nb.add(tab, text=" 2. Thermal Stretch ") + + ttk.Label(tab, text="Stretch thermal frames for viewing", font=HEADING).pack(anchor="w") + self._explain(tab, + "The thermal camera records 16-bit radiometric frames whose values fill only " + "a tiny part of the range, so raw files look almost black and flicker between " + "frames. This finds ONE brightness window shared by the whole session and " + "stretches every frame into it, saving easy-to-view 8-bit images. Because all " + "frames share the window, hot and cold areas stay consistent across the flight. " + "Values are radiometric signal (proportional to temperature), not calibrated " + "degrees. Tip: click 'Preview palettes' to compare the look before processing." + ).pack(fill="x", pady=(4, 10)) + + self.th_session = tk.StringVar(self, value=autodetect_session()) + self.th_out = tk.StringVar(self, value="") + self.th_cmap = tk.StringVar(self, value="inferno") + self.th_lo = tk.DoubleVar(self, value=1.0) + self.th_hi = tk.DoubleVar(self, value=99.0) + self.th_absolute = tk.BooleanVar(self, value=False) + + self._folder_row(tab, "Session folder", self.th_session).pack(fill="x", pady=3) + self._folder_row(tab, "Output folder", self.th_out).pack(fill="x", pady=3) + ttk.Label(tab, text="Leave output blank to use /thermal_stretched.", + foreground="#777").pack(anchor="w", padx=(16, 0)) + + opts = ttk.Frame(tab) + opts.pack(fill="x", pady=(10, 4)) + ttk.Label(opts, text="Palette", width=16).pack(side="left") + ttk.Combobox(opts, textvariable=self.th_cmap, width=12, state="readonly", + values=["inferno", "ironbow", "gray"]).pack(side="left") + ttk.Label(opts, text=" Window low %").pack(side="left") + ttk.Spinbox(opts, from_=0, to=49, increment=0.5, width=6, + textvariable=self.th_lo).pack(side="left", padx=4) + ttk.Label(opts, text="high %").pack(side="left") + ttk.Spinbox(opts, from_=51, to=100, increment=0.5, width=6, + textvariable=self.th_hi).pack(side="left", padx=4) + ttk.Checkbutton(opts, text="Absolute min/max", variable=self.th_absolute).pack(side="left", padx=10) + + self.th_embed = tk.BooleanVar(self, value=True) + ttk.Checkbutton(tab, variable=self.th_embed, + text="Embed GPS / orientation metadata into the stretched PNGs " + "(needs exiftool)").pack(anchor="w", pady=(6, 0)) + + bar = ttk.Frame(tab) + bar.pack(fill="x", pady=10) + pv = ttk.Button(bar, text="Preview palettes", command=self._start_preview) + pv.pack(side="left") + run = ttk.Button(bar, text="Stretch all frames", command=self._start_stretch) + run.pack(side="left", padx=8) + self.action_buttons += [pv, run] + + ttk.Label(tab, text="Preview (middle frame) gray | inferno | ironbow", + foreground="#777").pack(anchor="w") + self.preview_label = ttk.Label(tab, anchor="center") + self.preview_label.pack(fill="x", pady=(2, 6)) + + self._progress_block(tab, "thermal").pack(fill="both", expand=True) + + # ----- tab 3: map ----------------------------------------------------- + def _build_map_tab(self, nb): + tab = ttk.Frame(nb, padding=PAD) + nb.add(tab, text=" 3. Map ") + + if TkinterMapView is None: + ttk.Label(tab, foreground="#a00000", wraplength=600, justify="left", + text="The map needs the 'tkintermapview' package.\n\n" + "Install it from a terminal with:\n" + " pip install tkintermapview\n\n" + "then reopen this program.").pack(anchor="w", pady=20) + return + + ttk.Label(tab, text="Trigger points & image footprints on OpenStreetMap", + font=HEADING).pack(anchor="w") + self._explain(tab, + "Tick the sessions to plot, then click 'Show on map'. Each image becomes a " + "trigger point (its GPS position) and, optionally, an oblique footprint - the " + "ground patch the photo covers, projected from the camera off-nadir angle " + "(cam25 = 25 deg, cam45 = 45 deg), the cross-track scan angle, the aircraft " + "attitude and the height above ground. Footprints assume flat ground at the " + "elevation set below. Needs an internet connection for the map tiles." + ).pack(fill="x", pady=(4, 8)) + + body = ttk.Frame(tab) + body.pack(fill="both", expand=True) + left = ttk.Frame(body) + left.pack(side="left", fill="y", padx=(0, 8)) + right = ttk.Frame(body) + right.pack(side="left", fill="both", expand=True) + + sess_box = ttk.LabelFrame(left, text="Sessions", padding=6) + sess_box.pack(fill="x") + self.session_list_frame = ttk.Frame(sess_box) + self.session_list_frame.pack(fill="x") + self.session_vars: dict[str, tk.BooleanVar] = {} + btns = ttk.Frame(sess_box) + btns.pack(anchor="w", pady=(4, 0)) + ttk.Button(btns, text="All", width=5, + command=lambda: self._set_all_sessions(True)).pack(side="left") + ttk.Button(btns, text="None", width=5, + command=lambda: self._set_all_sessions(False)).pack(side="left", padx=4) + ttk.Button(btns, text="Refresh", width=8, + command=self._refresh_sessions).pack(side="left") + + opt = ttk.LabelFrame(left, text="Options", padding=6) + opt.pack(fill="x", pady=(8, 0)) + self.map_ground = tk.StringVar(self, value="110") + self.map_tfov_h = tk.StringVar(self, value=str(smap.THERMAL_FOV_DEFAULT[0])) # FLIR A65 25deg + self.map_tfov_v = tk.StringVar(self, value=str(smap.THERMAL_FOV_DEFAULT[1])) + self.map_toff = tk.StringVar(self, value="0") + self.map_step = tk.StringVar(self, value="auto") + self.map_markers = tk.BooleanVar(self, value=True) + self.map_foot = tk.BooleanVar(self, value=True) + self.map_cam = tk.BooleanVar(self, value=True) + self.map_thermal = tk.BooleanVar(self, value=True) + + def field(label, var): + r = ttk.Frame(opt) + r.pack(fill="x", pady=1) + ttk.Label(r, text=label, width=17).pack(side="left") + ttk.Entry(r, textvariable=var, width=8).pack(side="left") + + field("Ground elev (m)", self.map_ground) + field("Thermal FOV H (deg)", self.map_tfov_h) + field("Thermal FOV V (deg)", self.map_tfov_v) + field("Thermal off-nadir", self.map_toff) + field("Plot every Nth", self.map_step) + ttk.Label(opt, foreground="#777", + text="Thermal footprints need the FOV above.").pack(anchor="w", pady=(2, 4)) + ttk.Checkbutton(opt, text="Trigger points", variable=self.map_markers).pack(anchor="w") + ttk.Checkbutton(opt, text="Footprints", variable=self.map_foot).pack(anchor="w") + ttk.Checkbutton(opt, text="Cameras (blue)", variable=self.map_cam).pack(anchor="w") + ttk.Checkbutton(opt, text="Thermal (orange)", variable=self.map_thermal).pack(anchor="w") + + self.map_btn = ttk.Button(left, text="Show on map", command=self._start_plot_map) + self.map_btn.pack(fill="x", pady=8) + self.action_buttons.append(self.map_btn) + self.map_status = tk.StringVar(self, value="Idle.") + ttk.Label(left, textvariable=self.map_status, wraplength=230, + foreground="#555").pack(anchor="w") + + # small dot icons so hundreds of trigger points stay legible (the default + # tkintermapview pins are large and merge into a blob at this density) + self._dot_icons = {"cam": self._dot("#1565c0"), "thermal": self._dot("#e65100")} + + self.map_widget = TkinterMapView(right, corner_radius=0) + self.map_widget.pack(fill="both", expand=True) + self.map_widget.set_tile_server( + "https://tile.openstreetmap.org/{z}/{x}/{y}.png", max_zoom=19) + self.map_widget.set_position(49.32, 8.43) # Speyer area, until data is plotted + self.map_widget.set_zoom(9) + + self._refresh_sessions() + + def _set_all_sessions(self, value: bool): + for v in self.session_vars.values(): + v.set(value) + + def _refresh_sessions(self): + for w in self.session_list_frame.winfo_children(): + w.destroy() + self.session_vars = {} + sessions = smap.list_sessions(APP_DIR) + for s in sessions: + v = tk.BooleanVar(self, value=True) # default: show all strips + self.session_vars[str(s)] = v + ttk.Checkbutton(self.session_list_frame, text=s.name, variable=v).pack(anchor="w") + if not sessions: + ttk.Label(self.session_list_frame, text="(no sessions found)", + foreground="#777").pack(anchor="w") + + def _start_plot_map(self): + if self.running: + return + sel = [s for s, v in self.session_vars.items() if v.get()] + if not sel: + messagebox.showwarning("No sessions", "Tick at least one session to plot.") + return + try: + ground = float(self.map_ground.get()) + except ValueError: + messagebox.showwarning("Ground elevation", "Ground elevation must be a number (m).") + return + tfov = None + if self.map_tfov_h.get().strip() and self.map_tfov_v.get().strip(): + try: + tfov = (float(self.map_tfov_h.get()), float(self.map_tfov_v.get())) + except ValueError: + messagebox.showwarning("Thermal FOV", "Thermal FOV must be numbers (degrees).") + return + try: + toff = float(self.map_toff.get() or 0) + except ValueError: + toff = 0.0 + opts = dict(sessions=sel, ground=ground, tfov=tfov, toff=toff, + step_raw=self.map_step.get().strip().lower(), + markers=self.map_markers.get(), foot=self.map_foot.get(), + inc_cam=self.map_cam.get(), inc_thermal=self.map_thermal.get()) + self._set_running(True) + self.map_status.set("Reading sessions ...") + + def work(): + try: + frames = [] + for s in opts["sessions"]: + frames += smap.iter_session_frames( + s, include_cam=opts["inc_cam"], include_thermal=opts["inc_thermal"], + thermal_off_nadir=opts["toff"]) + if not frames: + self.q.put(("maperror", "No frames with GPS in the selected sessions.")) + return + total = len(frames) + if opts["step_raw"] in ("", "auto", "0"): + step = max(1, math.ceil(total / 600)) + else: + step = max(1, int(float(opts["step_raw"]))) + use = frames[::step] + markers = [(f["lat"], f["lon"], f["kind"]) for f in use] if opts["markers"] else [] + polys = [] + if opts["foot"]: + for f in use: + c = smap.footprint(f, opts["ground"], thermal_fov_deg=opts["tfov"]) + if c: + polys.append((c, f["kind"])) + self.q.put(("map", {"markers": markers, "polys": polys, + "bounds": smap.bounds(use), "total": total, + "shown": len(use), "step": step})) + except Exception as e: # noqa: BLE001 + self.q.put(("maperror", str(e))) + threading.Thread(target=work, daemon=True).start() + + @staticmethod + def _dot(color, d=9): + """A small round marker icon (PhotoImage) of the given colour.""" + img = Image.new("RGBA", (d, d), (0, 0, 0, 0)) + ImageDraw.Draw(img).ellipse([0, 0, d - 1, d - 1], fill=color, outline="#ffffff") + return ImageTk.PhotoImage(img) + + def _render_map(self, data): + self._set_running(False) + mw = self.map_widget + mw.delete_all_marker() + mw.delete_all_polygon() + colors = {"cam": "#1565c0", "thermal": "#e65100"} + for corners, kind in data["polys"]: + mw.set_polygon(corners, outline_color=colors.get(kind, "#333333"), border_width=1) + for lat, lon, kind in data["markers"]: + mw.set_marker(lat, lon, text="", icon=self._dot_icons.get(kind), icon_anchor="center") + mn_lat, mn_lon, mx_lat, mx_lon = data["bounds"] + try: + if mx_lat > mn_lat and mx_lon > mn_lon: + mw.fit_bounding_box((mx_lat, mn_lon), (mn_lat, mx_lon)) + else: + mw.set_position((mn_lat + mx_lat) / 2, (mn_lon + mx_lon) / 2) + mw.set_zoom(14) + except Exception: # noqa: BLE001 + pass + self.map_status.set( + f"Plotted {data['shown']} of {data['total']} frames (every {data['step']}): " + f"{len(data['markers'])} points, {len(data['polys'])} footprints.") + + # ----- run helpers ---------------------------------------------------- + def _set_running(self, busy: bool): + self.running = busy + for b in self.action_buttons: + b.config(state="disabled" if busy else "normal") + + def _callbacks(self, which): + return (lambda frac, msg: self.q.put(("progress", which, frac, msg)), + lambda msg: self.q.put(("log", which, msg))) + + def _guard(self, session: str, which: str) -> bool: + if self.running: + return False + if not session or not Path(session).is_dir(): + messagebox.showwarning("No session", "Please choose a valid session folder.") + return False + self.logs[which].delete("1.0", "end") + self.bars[which]["value"] = 0 + self._set_running(True) + return True + + def _spawn(self, fn): + threading.Thread(target=fn, daemon=True).start() + + def _start_embed(self): + session = self.embed_session.get().strip() + if not self._guard(session, "embed"): + return + out = self.embed_out.get().strip() or None + gps = self.embed_gps.get() + et = self.embed_exif.get().strip() or None + progress, log = self._callbacks("embed") + + def work(): + try: + res = embed.embed_session(session, out, gps, et, progress=progress, log=log) + self.q.put(("done", "embed", res)) + except Exception as e: # noqa: BLE001 + self.q.put(("error", "embed", str(e))) + self._spawn(work) + + def _read_window_params(self): + return dict(lo_pct=float(self.th_lo.get()), hi_pct=float(self.th_hi.get()), + absolute=bool(self.th_absolute.get())) + + def _start_preview(self): + session = self.th_session.get().strip() + if not self._guard(session, "thermal"): + return + params = self._read_window_params() + progress, log = self._callbacks("thermal") + + def work(): + try: + pngs = stretch.list_frames(Path(session)) + win = stretch.compute_window(pngs, progress=progress, **params) + log(f"Frames: {len(pngs)} | absolute signal {win['abs_lo']}-{win['abs_hi']}") + log(f"Window: {win['lo']}-{win['hi']} ({win['how']})") + img = stretch.make_sample_image(pngs, win["lo"], win["hi"]) + self.q.put(("preview", img)) + self.q.put(("done", "thermal", {"kind": "preview", **win})) + except Exception as e: # noqa: BLE001 + self.q.put(("error", "thermal", str(e))) + self._spawn(work) + + def _start_stretch(self): + session = self.th_session.get().strip() + if not self._guard(session, "thermal"): + return + out = self.th_out.get().strip() or None + cmap = self.th_cmap.get() + params = self._read_window_params() + progress, log = self._callbacks("thermal") + + def work(): + try: + pngs = stretch.list_frames(Path(session)) + win = stretch.compute_window(pngs, progress=progress, **params) + log(f"Window: {win['lo']}-{win['hi']} ({win['how']})") + res = stretch.stretch_session(session, out, cmap, win["lo"], win["hi"], + embed_exif=self.th_embed.get(), + progress=progress, log=log) + self.q.put(("done", "thermal", {"kind": "stretch", **res})) + except Exception as e: # noqa: BLE001 + self.q.put(("error", "thermal", str(e))) + self._spawn(work) + + # ----- preview rendering --------------------------------------------- + def _show_preview(self, img: Image.Image): + maxw = max(self.preview_label.winfo_width() - 8, 600) + if img.width > maxw: + h = round(img.height * maxw / img.width) + img = img.resize((maxw, h), Image.LANCZOS) + self._preview_imgtk = ImageTk.PhotoImage(img) + self.preview_label.config(image=self._preview_imgtk) + + # ----- queue pump (runs on the Tk main thread) ------------------------ + def _poll_queue(self): + try: + while True: + msg = self.q.get_nowait() + kind = msg[0] + if kind == "log": + _, which, text = msg + self.logs[which].insert("end", text + "\n") + self.logs[which].see("end") + elif kind == "progress": + _, which, frac, m = msg + self.bars[which]["value"] = max(0, min(1000, int(frac * 1000))) + self.status_vars[which].set(m) + elif kind == "preview": + self._show_preview(msg[1]) + elif kind == "map": + self._render_map(msg[1]) + elif kind == "maperror": + self._set_running(False) + self.map_status.set("Error.") + messagebox.showerror("FireMapper - map", msg[1]) + elif kind == "done": + self._on_done(msg[1], msg[2]) + elif kind == "error": + _, which, m = msg + self._set_running(False) + self.status_vars[which].set("Error.") + messagebox.showerror("FireMapper - error", m) + except queue.Empty: + pass + self.after(100, self._poll_queue) + + def _on_done(self, which, res): + self._set_running(False) + if which == "thermal" and res.get("kind") == "preview": + self.status_vars["thermal"].set( + f"Preview ready - window {res['lo']}-{res['hi']} ({res['how']}).") + return + self.bars[which]["value"] = 1000 + self.status_vars[which].set("Done.") + if which == "embed": + out = Path(res["out_root"]) + text = f"Tagged {res['updated']} of {res['pairs']} images.\n\nOutput:\n{out}" + else: + out = Path(res["out_dir"]) + text = f"Wrote {res['frames']} thermal frames.\n\nOutput:\n{out}" + if messagebox.askyesno("FireMapper - done", text + "\n\nOpen the output folder?"): + open_in_explorer(out) + + +if __name__ == "__main__": + FireMapperGUI().mainloop() diff --git a/manual.html b/manual.html new file mode 100644 index 0000000..6dda591 --- /dev/null +++ b/manual.html @@ -0,0 +1,214 @@ + + + + + +FireMapper — Session Post-Processing — User Manual + + + +
+

FireMapper — Session Post-Processing

+

User manual for turning a raw capture session into mapping-ready imagery.

+
+ +
+

FireMapper records, for every photo, a small .json file next to it +containing GPS position, platform/IMU attitude, lens and timing. These tools read those +files and:

+
    +
  • Embed the data into the photos' EXIF/XMP so mapping software can place each + image on the map;
  • +
  • Stretch the dark 16-bit thermal frames into easy-to-view colour images;
  • +
  • Map the trigger points and image footprints on OpenStreetMap.
  • +
+

Your originals are never changed. Every step writes results into a +new folder.

+ + + +
+

Getting started

+
    +
  1. Make sure Python 3 is installed, plus the packages: +
    pip install Pillow numpy tkintermapview requests
  2. +
  3. Put the four scripts (firemapper_gui.py, embed_metadata.py, + stretch_thermal.py, session_map.py) in one folder. + Placing them next to your session folders (or next to a parent folder that groups + several flight strips) lets the program find your data automatically.
  4. +
  5. Start the program: +
    python firemapper_gui.py
    + A window opens with three tabs. Pick a tab, read the on-screen description, fill in + the options, and click the button.
  6. +
+

The program finds session folders automatically — any folder that + contains a thermal/, a cam…/ folder or a + manifest.json counts as a session, and it also looks one level inside + grouping folders (e.g. Streifen/).

+
+ +
+

Tab 1 Embed GPS & Metadata

+

Writes each photo's JSON data into a tagged copy of the image: GPS position, + true camera pointing direction, capture time (UTC), lens & exposure, and the full + JSON in the comment field. The copies drop straight into Pix4D, Metashape, QGIS, etc.

+

How to use

+
    +
  1. Session folder — auto-filled with the newest session; or click + Browse….
  2. +
  3. Output folder — leave blank to write to <session>_exif + next to the session.
  4. +
  5. GPS sourceFused INS (position) is the most accurate (recommended); + Raw GNSS (gps) uses the bare satellite fix. Thermal frames always use raw GNSS.
  6. +
  7. Click Embed metadata. Progress and a log appear; when done you can open the + output folder.
  8. +
+

Requires exiftool. It is found automatically, and installed for + you the first time if missing (Windows). Leave the “exiftool path” field blank.

+
+ +
+

Tab 2 Thermal Stretch

+

The thermal camera records 16-bit radiometric frames whose values fill only a tiny + part of the range, so the raw files look almost black and flicker between frames. This + finds one brightness window shared by the whole session and stretches every frame + into it, saving easy-to-view 8-bit images. Because all frames use the same window, hot + and cold areas stay consistent across the flight.

+
Values are radiometric signal (proportional to temperature), + not calibrated degrees.
+

Options

+ + + + +
Paletteinferno / ironbow — heat colour maps (dark = cool, + bright = hot); gray — plain grayscale.
Window low % / high %The brightness window, as percentiles pooled over + the whole session (default 1–99 %). Lower the high % / raise the low % for more + contrast; tick Absolute min/max to use the true extremes.
Embed metadataWhen ticked (default), the stretched PNGs also get GPS + and orientation EXIF written in, so they are self-contained.
+

How to use

+
    +
  1. Pick the session folder and palette.
  2. +
  3. Click Preview palettes to compare gray / inferno / ironbow on a sample + frame before committing.
  4. +
  5. Click Stretch all frames. Output goes to + <session>/thermal_stretched unless you set an output folder.
  6. +
+
+ +
+

Tab 3 Map

+

Plots each photo as a trigger point (its GPS position) and, optionally, an + oblique footprint — the patch of ground the photo covers — on OpenStreetMap. + Cameras are blue, thermal is + orange.

+

How to use

+
    +
  1. Tick the sessions to plot (use All/None; Refresh + rescans the folder). Several flight strips can be shown together.
  2. +
  3. Set the options (see below), then click Show on map. The map zooms to + fit the data.
  4. +
+

Options

+ + + + + + +
Ground elev (m)Terrain height used to project the footprints (flat-ground + assumption). Default 110 m — set this to your site's elevation.
Thermal FOV H/VField of view of the thermal camera, needed for its + footprints. Pre-filled for the FLIR A65 25° lens (25° × 20°).
Thermal off-nadirMounting tilt of the thermal camera (0 = straight down).
Plot every Nthauto thins very large surveys so the map stays + responsive; set a number to override.
Trigger points / Footprints / Cameras / ThermalToggle what is drawn.
+
Needs an internet connection for the map tiles.
+

How footprints are computed

+

The footprint is projected from the true camera pointing direction: the aircraft + attitude, plus the platform's left/right swivel (platform_angle_deg), plus + each camera's fixed mounting — thermal looks straight down (mounted vertical to the + flight line); the RGB cameras look forward-and-down by their off-nadir angle + (cam25 = 25°, cam45 = 45°) and are mounted landscape across the flight line.

+
The footprints assume flat ground at the elevation you set. If the whole + swath appears on the wrong side of the flight line compared to reality, ask your + developer to flip the SCAN_SIGN setting in session_map.py.
+
+ +
+

Command line (optional)

+

The embed and stretch steps also run without the GUI:

+
python embed_metadata.py [SESSION] [--out DIR] [--gps-source position|gps] [--dry-run]
+
+python stretch_thermal.py [SESSION] [--colormap inferno|ironbow|gray]
+                          [--lo-pct 1 --hi-pct 99 | --absolute | --lo N --hi N]
+                          [--out DIR] [--no-embed-exif] [--sample]
+

With no SESSION they use the newest session found nearby.

+
+ +
+

Troubleshooting

+ + + + + + + + + + + +
“exiftool could not be found”Install it once: winget install OliverBetz.ExifTool (or + choco install exiftool), then reopen the program.
Map is blank / tiles don't loadThe map needs internet access for OpenStreetMap tiles.
No sessions in the Map listClick Refresh. Make sure the scripts are in (or next to) the folder + that holds your session folders.
Footprints / headings look mirroredFlip SCAN_SIGN at the top of session_map.py.
Thermal footprints missingFill in the Thermal FOV fields on the Map tab.
+
+
+ +
FireMapper — Session Post-Processing · GGS Speyer. Originals are never modified; +all results are written to new folders.
+ + diff --git a/session_map.py b/session_map.py new file mode 100644 index 0000000..39d2446 --- /dev/null +++ b/session_map.py @@ -0,0 +1,246 @@ +#!/usr/bin/env python3 +""" +session_map.py - geometry for plotting FireMapper sessions on a map. + +Provides trigger points (GPS position of each frame) and oblique image +footprints projected onto flat ground. + +Camera model (confirmed with the operator): + * Cameras sit on a platform that rolls about the aircraft longitudinal (forward) + axis by platform_angle_deg, sweeping every camera cross-track. + * Mounting on the platform: thermal looks straight down (nadir); the RGB cameras + are tilted toward the forward direction by their cam off-nadir angle + (cam25 -> 25 deg from nadir, cam45 -> 45 deg). + * The full chain is: aircraft attitude (yaw/pitch/roll, body->NED) @ platform roll + @ camera mounting. The image corners are then ray-cast onto flat ground at a + given elevation (footprints); the optical axis gives the true EXIF orientation. + +If footprints / headings come out mirrored, flip SCAN_SIGN below. +""" + +from __future__ import annotations + +import json +import math +from pathlib import Path + +import numpy as np + +M_PER_DEG_LAT = 111320.0 + +SCAN_SIGN = +1.0 # sign of the platform_angle rotation about the aircraft roll (forward) axis + +# Camera mounting on the rotating platform (operator spec): +# thermal (FLIR A65): optical axis straight DOWN (nadir); mounted VERTICAL/portrait, +# so sensor width runs ALONG the flight track. +# RGB cameras: optical axis tilted toward the FORWARD direction by the cam +# off-nadir angle (cam25 = 25 deg from straight-down); mounted +# LANDSCAPE, so sensor width runs ACROSS the flight track. +# The platform then rolls about the forward axis (platform_angle_deg), sweeping every +# camera cross-track, and finally the aircraft attitude (yaw/pitch/roll) is applied. + +# FLIR A65, 640x512, 25 deg lens -> ~25 x 20 deg field of view +THERMAL_FOV_DEFAULT = (25.0, 20.0) + + +# --------------------------------------------------------------------------- # +# rotations +# --------------------------------------------------------------------------- # +def _rot_x(a: float) -> np.ndarray: + c, s = math.cos(a), math.sin(a) + return np.array([[1, 0, 0], [0, c, -s], [0, s, c]]) + + +def _body_to_ned(yaw: float, pitch: float, roll: float) -> np.ndarray: + """Aerospace 3-2-1 (yaw, pitch, roll) body->NED rotation; angles in radians.""" + cy, sy = math.cos(yaw), math.sin(yaw) + cp, sp = math.cos(pitch), math.sin(pitch) + cr, sr = math.cos(roll), math.sin(roll) + return np.array([ + [cy * cp, cy * sp * sr - sy * cr, cy * sp * cr + sy * sr], + [sy * cp, sy * sp * sr + cy * cr, sy * sp * cr - cy * sr], + [-sp, cp * sr, cp * cr], + ]) + + +# --------------------------------------------------------------------------- # +# sessions & frames +# --------------------------------------------------------------------------- # +def is_session_dir(p: Path) -> bool: + """A capture session = a folder holding thermal/ or cam*/ or a manifest.json. + Name-independent (works whether it's 'session_...' or anything else), and + excludes our own derived outputs.""" + if not p.is_dir() or p.name.endswith(("_exif", "_stretched")) or p.name == "__pycache__": + return False + return ((p / "manifest.json").exists() or (p / "thermal").is_dir() + or any(p.glob("cam*"))) + + +def list_sessions(root) -> list[Path]: + """Session folders directly under root, or one level deeper (e.g. grouped in a + parent folder like 'Streifen/'). Sorted by name.""" + root = Path(root) + found: list[Path] = [] + for p in sorted(root.iterdir()): + if not p.is_dir(): + continue + if is_session_dir(p): + found.append(p) + elif not p.name.endswith(("_exif", "_stretched")) and p.name != "__pycache__": + found += [q for q in sorted(p.iterdir()) if is_session_dir(q)] + return found + + +def off_nadir_from_name(folder: str) -> float: + digits = "".join(ch for ch in folder if ch.isdigit()) + return float(digits) if digits else 0.0 + + +def _read_frame(json_path: Path, kind: str, off_nadir: float) -> dict | None: + try: + j = json.loads(json_path.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + return None + pos = (j.get("gps") if kind == "thermal" else (j.get("position") or j.get("gps"))) or {} + if pos.get("lat") is None or pos.get("lon") is None: + return None + imu = j.get("imu") or {} + plat = j.get("platform") or {} + img = j.get("image") or {} + lens = j.get("lens") or {} + return { + "kind": kind, "off_nadir": off_nadir, "session": json_path.parent.parent.name, + "lat": pos["lat"], "lon": pos["lon"], "alt": pos.get("alt"), + "yaw": imu.get("yaw"), "pitch": imu.get("pitch"), "roll": imu.get("roll"), + "platform_angle": plat.get("platform_angle_deg"), + "width": img.get("width"), "height": img.get("height"), + "focal_mm": lens.get("focal_length_mm"), "pixel_um": lens.get("pixel_size_um"), + } + + +def iter_session_frames(session, include_cam=True, include_thermal=True, + thermal_off_nadir=0.0) -> list[dict]: + """All plottable frames in a session (those with GPS), tagged by kind.""" + session = Path(session) + frames: list[dict] = [] + for sub in sorted(p for p in session.iterdir() if p.is_dir()): + if sub.name.startswith("cam") and include_cam: + off = off_nadir_from_name(sub.name) + for jf in sorted(sub.glob("*.json")): + fr = _read_frame(jf, "cam", off) + if fr: + frames.append(fr) + elif sub.name == "thermal" and include_thermal: + for jf in sorted(sub.glob("*.json")): + fr = _read_frame(jf, "thermal", thermal_off_nadir) + if fr: + frames.append(fr) + return frames + + +# --------------------------------------------------------------------------- # +# footprint projection +# --------------------------------------------------------------------------- # +def lens_fov(focal_mm, pixel_um, width, height): + """(hfov, vfov) in radians from focal length / pixel pitch / pixel counts.""" + if not (focal_mm and pixel_um and width and height): + return None + sw = pixel_um / 1000.0 * width # mm + sh = pixel_um / 1000.0 * height + return (2 * math.atan((sw / 2) / focal_mm), 2 * math.atan((sh / 2) / focal_mm)) + + +def camera_axes_ned(frame: dict, sensor: bool = True): + """ + Camera frame as NED unit vectors (image_right, image_down, optical_axis) for the + TRUE camera pointing, built as: + aircraft attitude (body->NED) @ platform roll about forward axis @ mounting + + The optical axis is identical either way. With sensor=True the right/down axes + follow the real sensor mounting (RGB width ALONG track = portrait) - used for + footprints. With sensor=False a canonical landscape frame is used (right = + cross-track) so the orientation 'roll' is the camera bank, not the 90 deg sensor + rotation. + """ + R = _body_to_ned(math.radians(frame["yaw"]), math.radians(frame["pitch"]), + math.radians(frame["roll"])) \ + @ _rot_x(math.radians(SCAN_SIGN * (frame["platform_angle"] or 0.0))) + if frame["kind"] == "thermal": + # nadir, VERTICAL/portrait mount -> sensor width runs ALONG track + optical = np.array([0.0, 0.0, 1.0]) + if sensor: + right = np.array([-1.0, 0.0, 0.0]) # aft (along-track) = width + down_im = np.array([0.0, -1.0, 0.0]) # port (cross-track) = height + else: # canonical landscape (for bank/roll only) + right = np.array([0.0, 1.0, 0.0]) + down_im = np.array([-1.0, 0.0, 0.0]) + else: + # RGB tilted forward by off-nadir; LANDSCAPE mount -> width ACROSS track. + # (landscape == canonical here, so sensor and orientation frames coincide) + a = math.radians(frame["off_nadir"]) + optical = np.array([math.sin(a), 0.0, math.cos(a)]) # forward & down + right = np.array([0.0, 1.0, 0.0]) # cross-track = width + down_im = np.array([-math.cos(a), 0.0, math.sin(a)]) # along-track = height + return R @ right, R @ down_im, R @ optical + + +def camera_orientation(frame: dict): + """ + (heading_deg, pitch_deg, roll_deg) of the true camera optical axis, or None. + heading: compass azimuth of the optical axis (0-360, from true north) + pitch : elevation of the optical axis (0 = horizon, -90 = straight down) + roll : bank about the optical axis (0 = image bottom points to ground-down) + """ + if any(frame.get(k) is None for k in ("yaw", "pitch", "roll")): + return None + right, down_im, optical = camera_axes_ned(frame, sensor=False) # canonical for bank + if math.hypot(optical[0], optical[1]) < 1e-6: # ~nadir: heading from image-up + ref = -down_im + heading = math.degrees(math.atan2(ref[1], ref[0])) % 360.0 + else: + heading = math.degrees(math.atan2(optical[1], optical[0])) % 360.0 + pitch = math.degrees(math.asin(max(-1.0, min(1.0, -float(optical[2]))))) + wd = np.array([0.0, 0.0, 1.0]) # world down + roll = math.degrees(math.atan2(float(wd @ right), float(wd @ down_im))) + return heading, pitch, roll + + +def footprint(frame: dict, ground_elev: float, + thermal_fov_deg: tuple[float, float] | None = None) -> list[tuple[float, float]] | None: + """Four (lat, lon) ground corners of the image, or None if not projectable.""" + if any(frame[k] is None for k in ("alt", "yaw", "pitch", "roll")): + return None + H = frame["alt"] - ground_elev + if H <= 1.0: + return None + + if frame["kind"] == "thermal": + if not thermal_fov_deg: + return None + hfov, vfov = math.radians(thermal_fov_deg[0]), math.radians(thermal_fov_deg[1]) + else: + fov = lens_fov(frame["focal_mm"], frame["pixel_um"], frame["width"], frame["height"]) + if not fov: + return None + hfov, vfov = fov + + right, down_im, optical = camera_axes_ned(frame) # width axis, height axis, boresight + th, tv = math.tan(hfov / 2), math.tan(vfov / 2) + coslat = math.cos(math.radians(frame["lat"])) + corners = [] + for sx, sy in ((-1, -1), (1, -1), (1, 1), (-1, 1)): + d = optical + sx * th * right + sy * tv * down_im # ray toward an image corner + if d[2] <= 1e-3: # ray not pointing at the ground + return None + t = H / d[2] + dlat = (t * d[0]) / M_PER_DEG_LAT + dlon = (t * d[1]) / (M_PER_DEG_LAT * coslat) + corners.append((frame["lat"] + dlat, frame["lon"] + dlon)) + return corners + + +def bounds(frames: list[dict]): + """(min_lat, min_lon, max_lat, max_lon) over frame trigger points.""" + lats = [f["lat"] for f in frames] + lons = [f["lon"] for f in frames] + return min(lats), min(lons), max(lats), max(lons) diff --git a/stretch_thermal.py b/stretch_thermal.py new file mode 100644 index 0000000..214005b --- /dev/null +++ b/stretch_thermal.py @@ -0,0 +1,257 @@ +#!/usr/bin/env python3 +""" +stretch_thermal.py - Contrast-stretch a session's radiometric thermal frames +to one common signal window, for consistent, comparable visualisation. + +The thermal PNGs are 16-bit radiometric (raw signal counts, monotonic with +temperature but not calibrated to degC). Each frame on its own spans only a +narrow part of the 16-bit range and uses a different sub-range, so viewing the +raw files is near-black and frame-to-frame brightness flickers. + +This computes ONE window [lo, hi] pooled across every frame in the session +(default: 1st-99th percentile, robust to outlier pixels), then maps that window +to 8-bit and writes the result with a chosen palette. Because all frames share +the same window, brightness/colour is directly comparable across the session. + +Usage: + python stretch_thermal.py [SESSION_DIR] [options] + + --colormap {inferno,ironbow,gray} palette (default: inferno) + --out DIR output folder (default: /thermal_stretched) + --lo-pct P low percentile (default: 1.0) + --hi-pct P high percentile (default: 99.0) + --lo N / --hi N hard-set the signal window, overriding percentiles + --absolute use the true session min/max instead of percentiles + --sample write one side-by-side comparison of all palettes and exit +""" + +from __future__ import annotations + +import argparse +import json +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +import embed_metadata as embed # noqa: E402 (reuse exiftool + tag building) +import session_map as smap # noqa: E402 (session discovery) + +import numpy as np # noqa: E402 +from PIL import Image # noqa: E402 + +U16_MAX = 65536 + + +# --------------------------------------------------------------------------- # +# palettes - built-in 256-entry LUTs, no matplotlib dependency +# --------------------------------------------------------------------------- # +def _lut_from_anchors(anchors) -> np.ndarray: + """Build a 256x3 uint8 LUT by linear interpolation between (pos, r,g,b) anchors.""" + pos = np.array([a[0] for a in anchors]) + rgb = np.array([a[1:] for a in anchors], dtype=float) + x = np.linspace(0.0, 1.0, 256) + lut = np.stack([np.interp(x, pos, rgb[:, c]) for c in range(3)], axis=1) + return np.clip(lut, 0, 255).astype(np.uint8) + + +INFERNO = _lut_from_anchors([ + (0.00, 0, 0, 4), (0.13, 31, 12, 72), (0.25, 85, 15, 109), (0.38, 136, 34, 106), + (0.50, 186, 54, 85), (0.63, 227, 89, 51), (0.75, 249, 140, 10), + (0.88, 249, 201, 50), (1.00, 252, 255, 164), +]) +IRONBOW = _lut_from_anchors([ + (0.00, 0, 0, 0), (0.12, 0, 0, 70), (0.25, 60, 0, 130), (0.40, 160, 0, 120), + (0.55, 220, 40, 60), (0.70, 250, 110, 0), (0.85, 255, 200, 40), (1.00, 255, 255, 255), +]) +PALETTES = {"inferno": INFERNO, "ironbow": IRONBOW} + + +# --------------------------------------------------------------------------- # +# helpers +# --------------------------------------------------------------------------- # +def find_session(arg: str | None) -> Path: + if arg: + return Path(arg).resolve() + sessions = smap.list_sessions(Path(__file__).resolve().parent) + if not sessions: + sys.exit("No session folder found nearby; pass SESSION_DIR explicitly.") + return max(sessions, key=lambda p: p.stat().st_mtime) + + +def list_frames(session: Path) -> list[Path]: + """Sorted thermal PNG frames in a session (raises if none).""" + thermal_dir = Path(session) / "thermal" + if not thermal_dir.is_dir(): + raise RuntimeError(f"No thermal/ folder in {session}") + pngs = sorted(thermal_dir.glob("*.png")) + if not pngs: + raise RuntimeError(f"No PNG frames in {thermal_dir}") + return pngs + + +def session_histogram(pngs, progress=None) -> np.ndarray: + """Pooled value histogram over all frames (exact for integer signal data).""" + hist = np.zeros(U16_MAX, dtype=np.int64) + n = len(pngs) + for i, p in enumerate(pngs, 1): + arr = np.asarray(Image.open(p)).astype(np.uint16, copy=False) + hist += np.bincount(arr.ravel(), minlength=U16_MAX) + if progress: + progress(0.5 * i / n, f"Analysing frame {i}/{n}") + return hist + + +def percentile_from_hist(hist: np.ndarray, pct: float) -> int: + total = hist.sum() + cdf = np.cumsum(hist) + return int(np.searchsorted(cdf, total * pct / 100.0)) + + +def compute_window(pngs, lo_pct=1.0, hi_pct=99.0, lo=None, hi=None, + absolute=False, progress=None) -> dict: + """Resolve the common [lo, hi] signal window for a set of frames.""" + hist = session_histogram(pngs, progress=progress) + nz = np.nonzero(hist)[0] + abs_lo, abs_hi = int(nz[0]), int(nz[-1]) + if lo is not None or hi is not None: + rlo = lo if lo is not None else abs_lo + rhi = hi if hi is not None else abs_hi + how = "manual" + elif absolute: + rlo, rhi, how = abs_lo, abs_hi, "absolute min/max" + else: + rlo = percentile_from_hist(hist, lo_pct) + rhi = percentile_from_hist(hist, hi_pct) + how = f"{lo_pct:g}-{hi_pct:g} percentile" + if rhi <= rlo: + rhi = rlo + 1 + return {"lo": rlo, "hi": rhi, "abs_lo": abs_lo, "abs_hi": abs_hi, "how": how} + + +def render(arr: np.ndarray, lo: int, hi: int, colormap: str) -> Image.Image: + """Stretch one 16-bit frame through [lo, hi] -> 8-bit, apply palette.""" + span = max(hi - lo, 1) + norm = np.clip((arr.astype(np.float32) - lo) / span, 0.0, 1.0) + idx = (norm * 255.0 + 0.5).astype(np.uint8) + if colormap == "gray": + return Image.fromarray(idx, mode="L") + return Image.fromarray(PALETTES[colormap][idx], mode="RGB") + + +def render_frame(path, lo: int, hi: int, colormap: str) -> Image.Image: + """Open one PNG and render it with the given window/palette (for previews).""" + return render(np.asarray(Image.open(path)).astype(np.uint16), lo, hi, colormap) + + +def make_sample_image(pngs, lo: int, hi: int, gap: int = 8) -> Image.Image: + """Side-by-side comparison of all palettes for the middle frame.""" + mid = np.asarray(Image.open(pngs[len(pngs) // 2])).astype(np.uint16) + h, w = mid.shape + tiles = [render(mid, lo, hi, c).convert("RGB") for c in ("gray", "inferno", "ironbow")] + canvas = Image.new("RGB", (w * 3 + gap * 2, h), (255, 255, 255)) + for i, t in enumerate(tiles): + canvas.paste(t, (i * (w + gap), 0)) + return canvas + + +def _embed_exif(pngs, out_dir: Path, exiftool=None, log=None): + """Write each frame's JSON sidecar into the matching stretched PNG (GPS, true + camera orientation, time, full JSON) so the 8-bit outputs are self-contained.""" + log = log or (lambda *_: None) + try: + et = exiftool or embed.ensure_exiftool(None, Path(__file__).resolve().parent / "tools") + except BaseException as e: # noqa: BLE001 (ensure_exiftool may sys.exit) + log(f" ! skipping EXIF embed - exiftool unavailable ({e})") + return + arg_lines = [] + for p in pngs: + sidecar = p.with_suffix(".json") + if not sidecar.exists(): + continue + try: + data = json.loads(sidecar.read_text(encoding="utf-8")) + except Exception: # noqa: BLE001 + continue + dst = out_dir / (p.stem + ".png") + arg_lines += ["-overwrite_original", *embed.build_tags(data, {}, None, "gps"), + str(dst), "-execute"] + if not arg_lines: + return + argfile = out_dir / "_exif_args.txt" + argfile.write_text("\n".join(arg_lines) + "\n", encoding="utf-8") + log("Embedding GPS / orientation EXIF into the stretched frames ...") + subprocess.run([et, "-m", "-charset", "UTF8", "-charset", "filename=UTF8", + "-@", str(argfile)], capture_output=True, text=True) + argfile.unlink(missing_ok=True) + + +def stretch_session(session, out_dir=None, colormap="inferno", lo=None, hi=None, + embed_exif=True, exiftool=None, progress=None, log=None) -> dict: + """ + Stretch every thermal frame of a session through the [lo, hi] window and + write 8-bit images with the chosen palette. lo/hi must already be resolved + (see compute_window). If embed_exif, the JSON sidecar metadata (GPS + true + camera orientation) is written into each output PNG. Callbacks optional. + """ + session = Path(session) + log = log or (lambda *_: None) + progress = progress or (lambda *_: None) + pngs = list_frames(session) + out_dir = Path(out_dir) if out_dir else session / "thermal_stretched" + out_dir.mkdir(parents=True, exist_ok=True) + + n = len(pngs) + log(f"Writing {n} {colormap} frames to {out_dir} ...") + for i, p in enumerate(pngs, 1): + render_frame(p, lo, hi, colormap).save(out_dir / (p.stem + ".png")) + progress(0.5 + 0.45 * i / n, f"Rendering {i}/{n}") + if embed_exif: + _embed_exif(pngs, out_dir, exiftool=exiftool, log=log) + progress(1.0, "Done") + log(f"Done. {n} frames written.\nOutput: {out_dir}") + return {"out_dir": out_dir, "frames": n} + + +# --------------------------------------------------------------------------- # +# CLI +# --------------------------------------------------------------------------- # +def main() -> int: + ap = argparse.ArgumentParser(description="Stretch session thermal frames to a common window.") + ap.add_argument("session", nargs="?", help="session folder (default: newest session_* here)") + ap.add_argument("--colormap", choices=["inferno", "ironbow", "gray"], default="inferno") + ap.add_argument("--out", help="output folder (default: /thermal_stretched)") + ap.add_argument("--lo-pct", type=float, default=1.0, help="low percentile (default 1.0)") + ap.add_argument("--hi-pct", type=float, default=99.0, help="high percentile (default 99.0)") + ap.add_argument("--lo", type=int, help="hard low signal value (overrides percentile)") + ap.add_argument("--hi", type=int, help="hard high signal value (overrides percentile)") + ap.add_argument("--absolute", action="store_true", help="use true session min/max") + ap.add_argument("--no-embed-exif", action="store_true", + help="do not write GPS/orientation EXIF into the stretched PNGs") + ap.add_argument("--sample", action="store_true", + help="write one side-by-side palette comparison and exit") + args = ap.parse_args() + + session = find_session(args.session) + pngs = list_frames(session) + win = compute_window(pngs, args.lo_pct, args.hi_pct, args.lo, args.hi, args.absolute) + lo, hi = win["lo"], win["hi"] + print(f"Session : {session}") + print(f"Frames : {len(pngs)} | absolute signal range {win['abs_lo']}-{win['abs_hi']}") + print(f"Window : {lo}-{hi} ({win['how']})") + + if args.sample: + out = session / "thermal_palette_sample.png" + make_sample_image(pngs, lo, hi).save(out) + print(f"\nSample written (left->right: gray | inferno | ironbow):\n {out}") + return 0 + + out_dir = Path(args.out).resolve() if args.out else None + res = stretch_session(session, out_dir, args.colormap, lo, hi, + embed_exif=not args.no_embed_exif, log=print) + print(f"\nWrote {res['frames']} {args.colormap} frames to:\n {res['out_dir']}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main())