124 lines
6.4 KiB
Markdown
124 lines
6.4 KiB
Markdown
# 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]
|
||
[--workers N] [--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] [--workers N] [--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 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
|
||
```
|