diff --git a/CLAUDE.md b/CLAUDE.md
index 048f387..219d6ea 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -1,11 +1,12 @@
# 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.
+Tools that convert raw FireMapper capture sessions into mapping-ready imagery:
+they embed GPS/orientation metadata into the images, stretch the radiometric
+thermal frames for visualisation, and plot trigger points and 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.
+This repository contains **only the Python tools** — the capture data (session
+folders, `*_exif/`, `*_stretched/`) is large (tens of GB) and is git-ignored.
## Hardware / data
@@ -92,7 +93,7 @@ Functions:
`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
+## Conventions & caveats
- **exiftool** is required by the embed step. Resolved from PATH, a local
`tools/exiftool.exe`, the OliverBetz user install (`%LOCALAPPDATA%\Programs\ExifTool`),
@@ -108,8 +109,14 @@ writable in stock exiftool). It uses this geometry — **not** the raw IMU.
(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`.
+- **Parallelism:** both steps fan out across a configurable worker pool
+ (`embed.default_workers()` = `min(cpu, 16)`; CLI `--workers N`, GUI "Parallel workers").
+ `embed.run_exiftool_parallel()` splits the per-file argument segments round-robin across
+ several concurrent exiftool processes and is shared by both tools; copy/render run on a
+ `ThreadPoolExecutor`. Tk variables are read on the main thread and captured before the
+ worker thread is spawned. Designed for SSD/NVMe storage, where concurrent I/O helps.
+- **Sweep side:** if footprints or headings appear mirrored relative to 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.
diff --git a/firemapper_gui.py b/firemapper_gui.py
index ad70cc3..9b5ae0a 100644
--- a/firemapper_gui.py
+++ b/firemapper_gui.py
@@ -1,23 +1,26 @@
#!/usr/bin/env python3
"""
-FireMapper - Session Post-Processing (GUI)
-==========================================
+FireMapper - Session Post-Processing (graphical interface)
+==========================================================
-A friendly front-end for the two FireMapper post-processing steps:
+Graphical front-end for the FireMapper post-processing workflow:
- 1. Embed GPS & metadata - writes each frame's JSON sidecar (GPS, heading,
- time, lens, ...) into EXIF/XMP of tagged image copies, ready for mapping
- software. (wraps embed_metadata.py)
+ 1. Embed GPS & metadata - writes each frame's JSON sidecar (GPS, orientation,
+ capture time, lens) into the EXIF/XMP of tagged image copies for use in
+ mapping software. (wraps embed_metadata.py)
- 2. Thermal stretch - rescales the whole session's 16-bit radiometric
- thermal frames into one shared brightness window and saves viewable
- 8-bit images in a heat-map palette. (wraps stretch_thermal.py)
+ 2. Thermal stretch - rescales the session's 16-bit radiometric thermal frames
+ to a single shared brightness window and saves 8-bit images in the chosen
+ palette. (wraps stretch_thermal.py)
-Just run: python firemapper_gui.py
+ 3. Map - plots trigger points and image footprints on OpenStreetMap.
+ (wraps session_map.py)
-No command line needed - pick a session folder, read the on-screen
-explanation, and click the button. Long jobs run in the background with a
-progress bar; your original files are never modified.
+Launch with: python firemapper_gui.py
+
+Select a session folder, review the description on each tab, set the options,
+and start the operation. Long-running tasks execute on a background thread with
+a progress indicator. Original files are never modified.
"""
from __future__ import annotations
@@ -106,9 +109,9 @@ class FireMapperGUI(tk.Tk):
ttk.Label(head, text="FireMapper - Session Post-Processing",
font=("Segoe UI Semibold", 15)).pack(anchor="w")
ttk.Label(head, foreground="#555", wraplength=900, justify="left",
- text="Turn a raw capture session into mapping-ready imagery. "
- "Pick a session folder, then run either step. Originals are "
- "never changed - results are written to new folders.").pack(anchor="w")
+ text="Convert a raw capture session into mapping-ready imagery. Select a "
+ "session folder and run any step. Original files are never modified; "
+ "all results are written to separate output folders.").pack(anchor="w")
ttk.Separator(self).pack(fill="x", padx=PAD, pady=(6, 8))
@staticmethod
@@ -142,7 +145,8 @@ class FireMapperGUI(tk.Tk):
ttk.Spinbox(row, from_=1, to=(os.cpu_count() or 32), increment=1, width=6,
textvariable=self.workers).pack(side="left")
ttk.Label(row, foreground="#777",
- text=f" threads / exiftool processes (you have {os.cpu_count()} cores)"
+ text=f" concurrent workers for copying, rendering and exiftool "
+ f"({os.cpu_count()} logical cores available)"
).pack(side="left", padx=6)
return row
@@ -165,15 +169,15 @@ class FireMapperGUI(tk.Tk):
tab = ttk.Frame(nb, padding=PAD)
nb.add(tab, text=" 1. Embed GPS & Metadata ")
- ttk.Label(tab, text="Embed GPS & metadata into images", font=HEADING).pack(anchor="w")
+ ttk.Label(tab, text="Embed GPS & Metadata", font=HEADING).pack(anchor="w")
self._explain(tab,
- "Copies every image in the session and writes its matching JSON data into "
- "the copy's EXIF/XMP tags: GPS position, camera heading / pitch / roll, UTC "
- "capture time, lens & exposure, plus the complete JSON in the comment field. "
- "The tagged copies drop straight into mapping / photogrammetry tools "
- "(Pix4D, Metashape, QGIS, ...), which read the embedded GPS to place each "
- "photo on the map. Requires exiftool - it is found automatically, and "
- "installed for you the first time if missing."
+ "Creates a tagged copy of every image in the session and writes the "
+ "corresponding JSON values into its EXIF/XMP metadata: GPS position, camera "
+ "heading, pitch and roll, UTC capture time, lens and exposure, and the complete "
+ "JSON record in the comment field. The tagged copies are compatible with "
+ "photogrammetry and GIS applications (Pix4D, Metashape, QGIS), which read the "
+ "embedded GPS to position each image. Requires exiftool, which is located "
+ "automatically and installed on first use if it is not already present."
).pack(fill="x", pady=(4, 10))
self.embed_session = tk.StringVar(self, value=autodetect_session())
@@ -183,15 +187,15 @@ class FireMapperGUI(tk.Tk):
self._folder_row(tab, "Session folder", self.embed_session).pack(fill="x", pady=3)
self._folder_row(tab, "Output folder", self.embed_out).pack(fill="x", pady=3)
- ttk.Label(tab, text="Leave output blank to use _exif next to the session.",
+ ttk.Label(tab, text="If left blank, output is written to _exif beside the session.",
foreground="#777").pack(anchor="w", padx=(16, 0))
gps = ttk.LabelFrame(tab, text="GPS source for camera frames", padding=8)
gps.pack(fill="x", pady=(10, 4))
ttk.Radiobutton(gps, variable=self.embed_gps, value="position",
- text="Fused INS (position) - most accurate [recommended]").pack(anchor="w")
+ text="Fused INS (position) - highest accuracy (recommended)").pack(anchor="w")
ttk.Radiobutton(gps, variable=self.embed_gps, value="gps",
- text="Raw GNSS (gps) - bare satellite fix").pack(anchor="w")
+ text="Raw GNSS (gps) - uncorrected satellite fix").pack(anchor="w")
ttk.Label(gps, foreground="#777",
text="Thermal frames have no INS solution and always use raw GNSS.").pack(anchor="w")
@@ -199,7 +203,7 @@ class FireMapperGUI(tk.Tk):
adv.pack(fill="x", pady=(8, 2))
ttk.Label(adv, text="exiftool path", width=16).pack(side="left")
ttk.Entry(adv, textvariable=self.embed_exif).pack(side="left", fill="x", expand=True)
- ttk.Label(adv, text="(optional - auto)", foreground="#777").pack(side="left", padx=6)
+ ttk.Label(adv, text="(optional; auto-detected)", foreground="#777").pack(side="left", padx=6)
self._workers_row(tab).pack(fill="x", pady=(8, 2))
@@ -214,15 +218,16 @@ class FireMapperGUI(tk.Tk):
tab = ttk.Frame(nb, padding=PAD)
nb.add(tab, text=" 2. Thermal Stretch ")
- ttk.Label(tab, text="Stretch thermal frames for viewing", font=HEADING).pack(anchor="w")
+ ttk.Label(tab, text="Stretch Thermal Frames for Visualisation", font=HEADING).pack(anchor="w")
self._explain(tab,
- "The thermal camera records 16-bit radiometric frames whose values fill only "
- "a tiny part of the range, so raw files look almost black and flicker between "
- "frames. This finds ONE brightness window shared by the whole session and "
- "stretches every frame into it, saving easy-to-view 8-bit images. Because all "
- "frames share the window, hot and cold areas stay consistent across the flight. "
- "Values are radiometric signal (proportional to temperature), not calibrated "
- "degrees. Tip: click 'Preview palettes' to compare the look before processing."
+ "The thermal camera records 16-bit radiometric frames that occupy only a small "
+ "portion of the available range; consequently the raw files appear nearly black "
+ "and vary in brightness from frame to frame. This step determines a single "
+ "brightness window for the entire session and rescales every frame into it, "
+ "producing viewable 8-bit images. Because all frames share one window, warm and "
+ "cool areas remain consistent across the flight. Pixel values represent "
+ "radiometric signal (proportional to temperature), not calibrated degrees. Use "
+ "'Preview palettes' to compare the available palettes before processing."
).pack(fill="x", pady=(4, 10))
self.th_session = tk.StringVar(self, value=autodetect_session())
@@ -234,7 +239,7 @@ class FireMapperGUI(tk.Tk):
self._folder_row(tab, "Session folder", self.th_session).pack(fill="x", pady=3)
self._folder_row(tab, "Output folder", self.th_out).pack(fill="x", pady=3)
- ttk.Label(tab, text="Leave output blank to use /thermal_stretched.",
+ ttk.Label(tab, text="If left blank, output is written to /thermal_stretched.",
foreground="#777").pack(anchor="w", padx=(16, 0))
opts = ttk.Frame(tab)
@@ -252,8 +257,8 @@ class FireMapperGUI(tk.Tk):
self.th_embed = tk.BooleanVar(self, value=True)
ttk.Checkbutton(tab, variable=self.th_embed,
- text="Embed GPS / orientation metadata into the stretched PNGs "
- "(needs exiftool)").pack(anchor="w", pady=(6, 0))
+ text="Embed GPS and orientation metadata into the stretched PNGs "
+ "(requires exiftool)").pack(anchor="w", pady=(6, 0))
self._workers_row(tab).pack(fill="x", pady=(6, 2))
@@ -279,21 +284,21 @@ class FireMapperGUI(tk.Tk):
if TkinterMapView is None:
ttk.Label(tab, foreground="#a00000", wraplength=600, justify="left",
- text="The map needs the 'tkintermapview' package.\n\n"
- "Install it from a terminal with:\n"
+ text="The map requires the 'tkintermapview' package.\n\n"
+ "Install it from a terminal:\n"
" pip install tkintermapview\n\n"
- "then reopen this program.").pack(anchor="w", pady=20)
+ "then restart the application.").pack(anchor="w", pady=20)
return
- ttk.Label(tab, text="Trigger points & image footprints on OpenStreetMap",
+ ttk.Label(tab, text="Trigger Points & Image Footprints on OpenStreetMap",
font=HEADING).pack(anchor="w")
self._explain(tab,
- "Tick the sessions to plot, then click 'Show on map'. Each image becomes a "
- "trigger point (its GPS position) and, optionally, an oblique footprint - the "
- "ground patch the photo covers, projected from the camera off-nadir angle "
+ "Select the sessions to display and click 'Show on map'. Each image is shown as "
+ "a trigger point (its GPS position) and, optionally, as an oblique footprint: "
+ "the ground area the image covers, projected from the camera off-nadir angle "
"(cam25 = 25 deg, cam45 = 45 deg), the cross-track scan angle, the aircraft "
- "attitude and the height above ground. Footprints assume flat ground at the "
- "elevation set below. Needs an internet connection for the map tiles."
+ "attitude and the height above ground. Footprints assume flat terrain at the "
+ "elevation specified below. An internet connection is required for the map tiles."
).pack(fill="x", pady=(4, 8))
body = ttk.Frame(tab)
@@ -341,7 +346,7 @@ class FireMapperGUI(tk.Tk):
field("Thermal off-nadir", self.map_toff)
field("Plot every Nth", self.map_step)
ttk.Label(opt, foreground="#777",
- text="Thermal footprints need the FOV above.").pack(anchor="w", pady=(2, 4))
+ text="Thermal footprints require the field-of-view values above.").pack(anchor="w", pady=(2, 4))
ttk.Checkbutton(opt, text="Trigger points", variable=self.map_markers).pack(anchor="w")
ttk.Checkbutton(opt, text="Footprints", variable=self.map_foot).pack(anchor="w")
ttk.Checkbutton(opt, text="Cameras (blue)", variable=self.map_cam).pack(anchor="w")
@@ -389,7 +394,7 @@ class FireMapperGUI(tk.Tk):
return
sel = [s for s, v in self.session_vars.items() if v.get()]
if not sel:
- messagebox.showwarning("No sessions", "Tick at least one session to plot.")
+ messagebox.showwarning("No sessions", "Select at least one session to display.")
return
try:
ground = float(self.map_ground.get())
diff --git a/manual.html b/manual.html
index 6dda591..2b8d2ed 100644
--- a/manual.html
+++ b/manual.html
@@ -47,20 +47,20 @@
FireMapper — Session Post-Processing
-
User manual for turning a raw capture session into mapping-ready imagery.
+
User manual for converting a raw capture session into mapping-ready imagery.
-
FireMapper records, for every photo, a small .json file next to it
-containing GPS position, platform/IMU attitude, lens and timing. These tools read those
-files and:
+
For every image it captures, FireMapper writes a small .json file alongside
+it containing the GPS position, platform/IMU attitude, lens parameters and timing. These
+tools read those files and:
-
Embed the data into the photos' EXIF/XMP so mapping software can place each
- image on the map;
-
Stretch the dark 16-bit thermal frames into easy-to-view colour images;
+
Embed the data into each image's EXIF/XMP so that mapping software can position
+ it geographically;
+
Stretch the low-contrast 16-bit thermal frames into clear colour images;
Map the trigger points and image footprints on OpenStreetMap.
-
Your originals are never changed. Every step writes results into a
+
Original files are never modified. Every step writes its results to a
new folder.
Getting started
-
Make sure Python 3 is installed, plus the packages:
+
Ensure Python 3 is installed, together with the required packages:
pip install Pillow numpy tkintermapview requests
-
Put the four scripts (firemapper_gui.py, embed_metadata.py,
- stretch_thermal.py, session_map.py) in one folder.
- Placing them next to your session folders (or next to a parent folder that groups
- several flight strips) lets the program find your data automatically.
-
Start the program:
+
Place the four scripts (firemapper_gui.py, embed_metadata.py,
+ stretch_thermal.py, session_map.py) in a single folder.
+ Locating them alongside your session folders (or alongside a parent folder that groups
+ several flight strips) allows the program to detect your data automatically.
+
Start the application:
python firemapper_gui.py
- A window opens with three tabs. Pick a tab, read the on-screen description, fill in
- the options, and click the button.
+ A window opens with three tabs. Select a tab, review the on-screen description, set the
+ options, and start the operation.
-
The program finds session folders automatically — any folder that
- contains a thermal/, a cam…/ folder or a
- manifest.json counts as a session, and it also looks one level inside
- grouping folders (e.g. Streifen/).
+
The program detects session folders automatically: any folder containing a
+ thermal/ directory, a cam…/ directory, or a
+ manifest.json file is treated as a session. Grouping folders (for example
+ Streifen/) are also scanned one level deep.
Tab 1 Embed GPS & Metadata
-
Writes each photo's JSON data into a tagged copy of the image: GPS position,
- true camera pointing direction, capture time (UTC), lens & exposure, and the full
- JSON in the comment field. The copies drop straight into Pix4D, Metashape, QGIS, etc.
+
Writes each image's JSON data into a tagged copy of the file: GPS position,
+ true camera pointing direction, capture time (UTC), lens and exposure, and the complete
+ JSON record in the comment field. The copies are compatible with Pix4D, Metashape, QGIS
+ and similar applications.
How to use
-
Session folder — auto-filled with the newest session; or click
+
Session folder — pre-filled with the most recent session, or selected via
Browse….
-
Output folder — leave blank to write to <session>_exif
- next to the session.
-
GPS source — Fused INS (position) is the most accurate (recommended);
- Raw GNSS (gps) uses the bare satellite fix. Thermal frames always use raw GNSS.
-
Click Embed metadata. Progress and a log appear; when done you can open the
- output folder.
+
Output folder — if left blank, results are written to
+ <session>_exif beside the session.
+
GPS source — Fused INS (position) provides the highest accuracy
+ (recommended); Raw GNSS (gps) uses the uncorrected satellite fix. Thermal
+ frames always use raw GNSS.
+
Parallel workers — the number of images copied and tagged concurrently
+ (default: derived from the available CPU cores). Higher values are faster on
+ SSD/NVMe storage.
+
Click Embed metadata. A progress bar and log are displayed; on completion the
+ output folder can be opened.
-
Requires exiftool. It is found automatically, and installed for
- you the first time if missing (Windows). Leave the “exiftool path” field blank.
+
Requires exiftool, which is located automatically and installed on
+ first use if it is not already present (Windows). Leave the “exiftool path” field blank.
Tab 2 Thermal Stretch
-
The thermal camera records 16-bit radiometric frames whose values fill only a tiny
- part of the range, so the raw files look almost black and flicker between frames. This
- finds one brightness window shared by the whole session and stretches every frame
- into it, saving easy-to-view 8-bit images. Because all frames use the same window, hot
- and cold areas stay consistent across the flight.
-
Values are radiometric signal (proportional to temperature),
- not calibrated degrees.
+
The thermal camera records 16-bit radiometric frames that occupy only a small portion
+ of the available range; consequently the raw files appear nearly black and vary in
+ brightness from frame to frame. This step determines a single brightness window for the
+ entire session and rescales every frame into it, producing viewable 8-bit images.
+ Because all frames share one window, warm and cool areas remain consistent across the
+ flight.
+
Pixel values represent radiometric signal (proportional to
+ temperature), not calibrated degrees.
The brightness window, as percentiles pooled over
- the whole session (default 1–99 %). Lower the high % / raise the low % for more
- contrast; tick Absolute min/max to use the true extremes.
-
Embed metadata
When ticked (default), the stretched PNGs also get GPS
- and orientation EXIF written in, so they are self-contained.
+
Window low % / high %
The brightness window, expressed as percentiles
+ pooled across the whole session (default 1–99 %). Decrease the high % or increase the
+ low % for greater contrast; enable Absolute min/max to use the true extremes.
+
Embed metadata
When enabled (default), the stretched PNGs also receive
+ GPS and orientation EXIF, making them self-contained.
+
Parallel workers
The number of frames rendered and tagged concurrently
+ (default: derived from the available CPU cores). Higher values are faster on SSD/NVMe
+ storage.
How to use
-
Pick the session folder and palette.
-
Click Preview palettes to compare gray / inferno / ironbow on a sample
- frame before committing.
-
Click Stretch all frames. Output goes to
- <session>/thermal_stretched unless you set an output folder.
+
Select the session folder and palette.
+
Click Preview palettes to compare gray, inferno and ironbow on a sample
+ frame before processing.
+
Click Stretch all frames. Output is written to
+ <session>/thermal_stretched unless an output folder is specified.
Tab 3 Map
-
Plots each photo as a trigger point (its GPS position) and, optionally, an
- oblique footprint — the patch of ground the photo covers — on OpenStreetMap.
- Cameras are blue, thermal is
+
Plots each image as a trigger point (its GPS position) and, optionally, as an
+ oblique footprint — the area of ground the image covers — on OpenStreetMap.
+ Cameras are shown in blue, thermal in
orange.
How to use
-
Tick the sessions to plot (use All/None; Refresh
- rescans the folder). Several flight strips can be shown together.
-
Set the options (see below), then click Show on map. The map zooms to
- fit the data.
+
Select the sessions to display (use All/None;
+ Refresh rescans the folder). Several flight strips may be shown together.
+
Set the options (described below), then click Show on map. The map
+ zooms to fit the data.
Options
Ground elev (m)
Terrain height used to project the footprints (flat-ground
- assumption). Default 110 m — set this to your site's elevation.
-
Thermal FOV H/V
Field of view of the thermal camera, needed for its
+ assumption). Default 110 m; set this to the elevation of your site.
+
Thermal FOV H/V
Field of view of the thermal camera, required for its
footprints. Pre-filled for the FLIR A65 25° lens (25° × 20°).
Thermal off-nadir
Mounting tilt of the thermal camera (0 = straight down).
-
Plot every Nth
auto thins very large surveys so the map stays
- responsive; set a number to override.
-
Trigger points / Footprints / Cameras / Thermal
Toggle what is drawn.
+
Plot every Nth
auto reduces very large surveys so the map remains
+ responsive; enter a number to override.
+
Trigger points / Footprints / Cameras / Thermal
Control which layers are drawn.
Needs an internet connection for the map tiles.
How footprints are computed
@@ -173,20 +182,23 @@ new folder.
each camera's fixed mounting — thermal looks straight down (mounted vertical to the
flight line); the RGB cameras look forward-and-down by their off-nadir angle
(cam25 = 25°, cam45 = 45°) and are mounted landscape across the flight line.
-
The footprints assume flat ground at the elevation you set. If the whole
- swath appears on the wrong side of the flight line compared to reality, ask your
- developer to flip the SCAN_SIGN setting in session_map.py.
+
The footprints assume flat ground at the elevation you specify. If the
+ entire swath appears on the wrong side of the flight line relative to reality, flip
+ the SCAN_SIGN setting at the top of session_map.py.
Command line (optional)
-
The embed and stretch steps also run without the GUI:
If SESSION is omitted, the most recent session found nearby is
+ used. --workers sets the number of parallel workers (default: derived from the
+ CPU core count).
Install it once: winget install OliverBetz.ExifTool (or
- choco install exiftool), then reopen the program.
+ choco install exiftool), then restart the application.
+
Stretched or embedded images have no GPS
+
exiftool writes metadata via a temporary copy, so a full (or nearly full) disk can
+ drop the GPS tag. Free up disk space and run the step again. The tools verify that GPS
+ was written and report a clear error rather than producing files without it.
Map is blank / tiles don't load
The map needs internet access for OpenStreetMap tiles.