FireMapper_Postprocess/CLAUDE.md

6.3 KiB
Raw Blame History

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). 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). <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.)
  • 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 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 StringVars 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