# 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). Carries metadata into each output: from the JSON sidecar if present, otherwise copied from the source image's own EXIF/XMP (so stretching already-embedded PNGs keeps GPS/orientation). | `/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.) - **Disk space:** exiftool writes metadata via a temp copy, so a (near-)full disk causes partial/failed writes — symptom is an image with most tags but **no GPS**. The embed and stretch steps now check exiftool's exit code, verify GPS landed, retry once, and raise a clear "full disk" error instead of silently producing GPS-less files. - **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 ```