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 <noreply@anthropic.com>
This commit is contained in:
NoahC 2026-06-26 09:39:54 +02:00
commit 9629a07399
7 changed files with 1799 additions and 0 deletions

27
.gitignore vendored Normal file
View File

@ -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

118
CLAUDE.md Normal file
View File

@ -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:
```
<session>/
cam25/ <ts>_step_NN.tiff + <ts>_step_NN.json (same-basename JSON sidecar each)
cam45/ …
thermal/ <ts>_step_NN.png + <ts>_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`). | `<session>_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. | `<session>/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<NN>`
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 199th 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
```

327
embed_metadata.py Normal file
View File

@ -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*/<ts>_step_NN.tiff and thermal/<ts>_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: <session>_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: <session>_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())

610
firemapper_gui.py Normal file
View File

@ -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 <session>_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 <session>/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()

214
manual.html Normal file
View File

@ -0,0 +1,214 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>FireMapper — Session Post-Processing — User Manual</title>
<style>
:root{
--fg:#1c2230; --muted:#5b6473; --bg:#f6f7f9; --card:#ffffff; --line:#e2e6ec;
--accent:#1565c0; --accent2:#e65100; --code:#0f172a; --codebg:#f0f2f5;
}
*{box-sizing:border-box}
body{margin:0;background:var(--bg);color:var(--fg);
font:16px/1.6 "Segoe UI",system-ui,Arial,sans-serif}
header{background:linear-gradient(135deg,#1565c0,#e65100);color:#fff;padding:34px 24px}
header .wrap{max-width:900px;margin:0 auto}
header h1{margin:0 0 6px;font-size:28px}
header p{margin:0;opacity:.92}
main{max-width:900px;margin:0 auto;padding:24px}
nav{background:var(--card);border:1px solid var(--line);border-radius:10px;
padding:14px 18px;margin:22px 0}
nav b{display:block;color:var(--muted);font-size:13px;text-transform:uppercase;
letter-spacing:.04em;margin-bottom:6px}
nav a{color:var(--accent);text-decoration:none;margin-right:16px;white-space:nowrap}
nav a:hover{text-decoration:underline}
section{background:var(--card);border:1px solid var(--line);border-radius:12px;
padding:20px 24px;margin:18px 0}
h2{margin:.2em 0 .6em;font-size:22px;border-bottom:2px solid var(--line);padding-bottom:8px}
h3{margin:1.2em 0 .4em;font-size:17px;color:var(--accent)}
code,kbd{background:var(--codebg);border-radius:4px;padding:1px 6px;font-family:Consolas,monospace;font-size:.92em}
pre{background:var(--code);color:#e6edf3;border-radius:8px;padding:14px 16px;overflow:auto;font-size:13px}
pre code{background:none;color:inherit;padding:0}
ol,ul{padding-left:22px}
li{margin:4px 0}
table{border-collapse:collapse;width:100%;margin:10px 0;font-size:14.5px}
th,td{border:1px solid var(--line);padding:8px 10px;text-align:left;vertical-align:top}
th{background:var(--codebg)}
.pill{display:inline-block;border-radius:999px;padding:1px 10px;font-size:13px;font-weight:600;color:#fff}
.blue{background:var(--accent)} .orange{background:var(--accent2)}
.note{border-left:4px solid var(--accent);background:#eef4fc;padding:10px 14px;border-radius:6px;margin:12px 0}
.warn{border-left:4px solid var(--accent2);background:#fdf0e6;padding:10px 14px;border-radius:6px;margin:12px 0}
.muted{color:var(--muted)}
footer{max-width:900px;margin:0 auto;padding:10px 24px 40px;color:var(--muted);font-size:13px}
.step{counter-reset:none}
</style>
</head>
<body>
<header><div class="wrap">
<h1>FireMapper — Session Post-Processing</h1>
<p>User manual for turning a raw capture session into mapping-ready imagery.</p>
</div></header>
<main>
<p>FireMapper records, for every photo, a small <code>.json</code> file next to it
containing GPS position, platform/IMU attitude, lens and timing. These tools read those
files and:</p>
<ul>
<li><b>Embed</b> the data into the photos' EXIF/XMP so mapping software can place each
image on the map;</li>
<li><b>Stretch</b> the dark 16-bit thermal frames into easy-to-view colour images;</li>
<li><b>Map</b> the trigger points and image footprints on OpenStreetMap.</li>
</ul>
<p class="note"><b>Your originals are never changed.</b> Every step writes results into a
new folder.</p>
<nav>
<b>Contents</b>
<a href="#start">Getting started</a>
<a href="#embed">1 · Embed</a>
<a href="#thermal">2 · Thermal</a>
<a href="#map">3 · Map</a>
<a href="#cli">Command line</a>
<a href="#trouble">Troubleshooting</a>
</nav>
<section id="start">
<h2>Getting started</h2>
<ol>
<li>Make sure Python 3 is installed, plus the packages:
<pre><code>pip install Pillow numpy tkintermapview requests</code></pre></li>
<li>Put the four scripts (<code>firemapper_gui.py</code>, <code>embed_metadata.py</code>,
<code>stretch_thermal.py</code>, <code>session_map.py</code>) 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.</li>
<li>Start the program:
<pre><code>python firemapper_gui.py</code></pre>
A window opens with three tabs. Pick a tab, read the on-screen description, fill in
the options, and click the button.</li>
</ol>
<p class="muted">The program finds session folders automatically — any folder that
contains a <code>thermal/</code>, a <code>cam…/</code> folder or a
<code>manifest.json</code> counts as a session, and it also looks one level inside
grouping folders (e.g. <code>Streifen/</code>).</p>
</section>
<section id="embed">
<h2><span class="pill blue">Tab 1</span> Embed GPS &amp; Metadata</h2>
<p>Writes each photo's JSON data into a tagged <b>copy</b> of the image: GPS position,
true camera pointing direction, capture time (UTC), lens &amp; exposure, and the full
JSON in the comment field. The copies drop straight into Pix4D, Metashape, QGIS, etc.</p>
<h3>How to use</h3>
<ol>
<li><b>Session folder</b> — auto-filled with the newest session; or click
<kbd>Browse…</kbd>.</li>
<li><b>Output folder</b> — leave blank to write to <code>&lt;session&gt;_exif</code>
next to the session.</li>
<li><b>GPS source</b><i>Fused INS (position)</i> is the most accurate (recommended);
<i>Raw GNSS (gps)</i> uses the bare satellite fix. Thermal frames always use raw GNSS.</li>
<li>Click <b>Embed metadata</b>. Progress and a log appear; when done you can open the
output folder.</li>
</ol>
<p class="muted">Requires <b>exiftool</b>. It is found automatically, and installed for
you the first time if missing (Windows). Leave the “exiftool path” field blank.</p>
</section>
<section id="thermal">
<h2><span class="pill orange">Tab 2</span> Thermal Stretch</h2>
<p>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 <b>one brightness window shared by the whole session</b> 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.</p>
<div class="note">Values are radiometric <b>signal</b> (proportional to temperature),
not calibrated degrees.</div>
<h3>Options</h3>
<table>
<tr><th>Palette</th><td><b>inferno</b> / <b>ironbow</b> — heat colour maps (dark = cool,
bright = hot); <b>gray</b> — plain grayscale.</td></tr>
<tr><th>Window low % / high %</th><td>The brightness window, as percentiles pooled over
the whole session (default 199 %). Lower the high % / raise the low % for more
contrast; tick <b>Absolute min/max</b> to use the true extremes.</td></tr>
<tr><th>Embed metadata</th><td>When ticked (default), the stretched PNGs also get GPS
and orientation EXIF written in, so they are self-contained.</td></tr>
</table>
<h3>How to use</h3>
<ol>
<li>Pick the <b>session folder</b> and <b>palette</b>.</li>
<li>Click <b>Preview palettes</b> to compare gray / inferno / ironbow on a sample
frame before committing.</li>
<li>Click <b>Stretch all frames</b>. Output goes to
<code>&lt;session&gt;/thermal_stretched</code> unless you set an output folder.</li>
</ol>
</section>
<section id="map">
<h2><span class="pill blue">Tab 3</span> Map</h2>
<p>Plots each photo as a <b>trigger point</b> (its GPS position) and, optionally, an
<b>oblique footprint</b> — the patch of ground the photo covers — on OpenStreetMap.
Cameras are <span class="pill blue">blue</span>, thermal is
<span class="pill orange">orange</span>.</p>
<h3>How to use</h3>
<ol>
<li><b>Tick the sessions</b> to plot (use <kbd>All</kbd>/<kbd>None</kbd>; <kbd>Refresh</kbd>
rescans the folder). Several flight strips can be shown together.</li>
<li>Set the <b>options</b> (see below), then click <b>Show on map</b>. The map zooms to
fit the data.</li>
</ol>
<h3>Options</h3>
<table>
<tr><th>Ground elev (m)</th><td>Terrain height used to project the footprints (flat-ground
assumption). Default 110 m — set this to your site's elevation.</td></tr>
<tr><th>Thermal FOV H/V</th><td>Field of view of the thermal camera, needed for its
footprints. Pre-filled for the FLIR A65 25° lens (25° × 20°).</td></tr>
<tr><th>Thermal off-nadir</th><td>Mounting tilt of the thermal camera (0 = straight down).</td></tr>
<tr><th>Plot every Nth</th><td><i>auto</i> thins very large surveys so the map stays
responsive; set a number to override.</td></tr>
<tr><th>Trigger points / Footprints / Cameras / Thermal</th><td>Toggle what is drawn.</td></tr>
</table>
<div class="note"><b>Needs an internet connection</b> for the map tiles.</div>
<h3>How footprints are computed</h3>
<p>The footprint is projected from the true camera pointing direction: the aircraft
attitude, plus the platform's left/right swivel (<code>platform_angle_deg</code>), 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.</p>
<div class="warn">The footprints assume flat ground at the elevation you set. If the whole
swath appears on the <b>wrong side</b> of the flight line compared to reality, ask your
developer to flip the <code>SCAN_SIGN</code> setting in <code>session_map.py</code>.</div>
</section>
<section id="cli">
<h2>Command line (optional)</h2>
<p>The embed and stretch steps also run without the GUI:</p>
<pre><code>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]</code></pre>
<p class="muted">With no <code>SESSION</code> they use the newest session found nearby.</p>
</section>
<section id="trouble">
<h2>Troubleshooting</h2>
<table>
<tr><th>“exiftool could not be found”</th>
<td>Install it once: <code>winget install OliverBetz.ExifTool</code> (or
<code>choco install exiftool</code>), then reopen the program.</td></tr>
<tr><th>Map is blank / tiles don't load</th>
<td>The map needs internet access for OpenStreetMap tiles.</td></tr>
<tr><th>No sessions in the Map list</th>
<td>Click <kbd>Refresh</kbd>. Make sure the scripts are in (or next to) the folder
that holds your session folders.</td></tr>
<tr><th>Footprints / headings look mirrored</th>
<td>Flip <code>SCAN_SIGN</code> at the top of <code>session_map.py</code>.</td></tr>
<tr><th>Thermal footprints missing</th>
<td>Fill in the <b>Thermal FOV</b> fields on the Map tab.</td></tr>
</table>
</section>
</main>
<footer>FireMapper — Session Post-Processing · GGS Speyer. Originals are never modified;
all results are written to new folders.</footer>
</body>
</html>

246
session_map.py Normal file
View File

@ -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<NN> 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<NN>
# 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)

257
stretch_thermal.py Normal file
View File

@ -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: <session>/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: <session>/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())