6.2 KiB
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 viawinget 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
positionblock (falls back to rawgps); thermal has onlygps. 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_SIGNat the top ofsession_map.py. - Tkinter: never create
ttk.Style()before the root window — it spawns a stray default root andStringVars 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