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.