FireMapper_Postprocess/CLAUDE.md

123 lines
6.3 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

# 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 `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
```