#!/usr/bin/env python3 """ FireMapper - Session Post-Processing (GUI) ========================================== A friendly front-end for the two FireMapper post-processing steps: 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) 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) Just run: python firemapper_gui.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. """ from __future__ import annotations import math import os import queue import sys import threading from pathlib import Path import tkinter as tk from tkinter import filedialog, messagebox, scrolledtext, ttk from PIL import Image, ImageDraw, ImageTk # import the worker modules that live next to this file APP_DIR = Path(__file__).resolve().parent sys.path.insert(0, str(APP_DIR)) import embed_metadata as embed # noqa: E402 import stretch_thermal as stretch # noqa: E402 import session_map as smap # noqa: E402 try: from tkintermapview import TkinterMapView # noqa: E402 except Exception: # noqa: BLE001 (optional dependency - the Map tab degrades gracefully) TkinterMapView = None PAD = 10 HEADING = ("Segoe UI Semibold", 11) EXPLAIN_WRAP = 720 def autodetect_session() -> str: """Newest session folder near this script (also one level into parent folders).""" sessions = smap.list_sessions(APP_DIR) return str(max(sessions, key=lambda p: p.stat().st_mtime)) if sessions else "" def open_in_explorer(path: Path) -> None: if hasattr(os, "startfile"): try: os.startfile(path) # type: ignore[attr-defined] except OSError: pass # --------------------------------------------------------------------------- # class FireMapperGUI(tk.Tk): def __init__(self): super().__init__() self.title("FireMapper - Session Post-Processing") self.geometry("960x780") self.minsize(820, 640) # style must be created with THIS root as master - never before it, # or tkinter spins up a second, stray default root and the StringVars # bind to the wrong one (entries then render blank). style = ttk.Style(self) if "vista" in style.theme_names(): style.theme_use("vista") style.configure("Card.TFrame", background="#f5f6f8", relief="solid", borderwidth=1) self.q: queue.Queue = queue.Queue() self.running = False self.action_buttons: list[ttk.Button] = [] self.logs: dict[str, scrolledtext.ScrolledText] = {} self.bars: dict[str, ttk.Progressbar] = {} self.status_vars: dict[str, tk.StringVar] = {} self._preview_imgtk = None # keep a reference so Tk doesn't GC it self._build_header() nb = ttk.Notebook(self) nb.pack(fill="both", expand=True, padx=PAD, pady=(0, PAD)) self._build_embed_tab(nb) self._build_thermal_tab(nb) self._build_map_tab(nb) self.after(100, self._poll_queue) # ----- shared UI bits ------------------------------------------------- def _build_header(self): head = ttk.Frame(self, padding=(PAD, PAD, PAD, 4)) head.pack(fill="x") 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") ttk.Separator(self).pack(fill="x", padx=PAD, pady=(6, 8)) @staticmethod def _explain(parent, text): box = ttk.Frame(parent, padding=8) box.configure(style="Card.TFrame") ttk.Label(box, text=text, wraplength=EXPLAIN_WRAP, justify="left", foreground="#333").pack(anchor="w") return box def _folder_row(self, parent, label, var, browse_dir=True): row = ttk.Frame(parent) ttk.Label(row, text=label, width=16).pack(side="left") ttk.Entry(row, textvariable=var).pack(side="left", fill="x", expand=True) def browse(): if browse_dir: p = filedialog.askdirectory(title=label, initialdir=var.get() or str(APP_DIR)) else: p = filedialog.askopenfilename(title=label, initialdir=var.get() or str(APP_DIR)) if p: var.set(p) ttk.Button(row, text="Browse...", command=browse).pack(side="left", padx=(6, 0)) return row def _progress_block(self, parent, which): frame = ttk.Frame(parent) bar = ttk.Progressbar(frame, mode="determinate", maximum=1000) bar.pack(fill="x") sv = tk.StringVar(self, value="Idle.") ttk.Label(frame, textvariable=sv, foreground="#555").pack(anchor="w", pady=(2, 0)) log = scrolledtext.ScrolledText(frame, height=11, wrap="word", font=("Consolas", 9)) log.pack(fill="both", expand=True, pady=(6, 0)) self.bars[which] = bar self.status_vars[which] = sv self.logs[which] = log return frame # ----- tab 1: embed --------------------------------------------------- def _build_embed_tab(self, nb): 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") 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." ).pack(fill="x", pady=(4, 10)) self.embed_session = tk.StringVar(self, value=autodetect_session()) self.embed_out = tk.StringVar(self, value="") self.embed_gps = tk.StringVar(self, value="position") self.embed_exif = tk.StringVar(self, value="") 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.", 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") ttk.Radiobutton(gps, variable=self.embed_gps, value="gps", text="Raw GNSS (gps) - bare 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") adv = ttk.Frame(tab) 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) btn = ttk.Button(tab, text="Embed metadata", command=self._start_embed) btn.pack(anchor="w", pady=10) self.action_buttons.append(btn) self._progress_block(tab, "embed").pack(fill="both", expand=True) # ----- tab 2: thermal ------------------------------------------------- def _build_thermal_tab(self, nb): 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") 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." ).pack(fill="x", pady=(4, 10)) self.th_session = tk.StringVar(self, value=autodetect_session()) self.th_out = tk.StringVar(self, value="") self.th_cmap = tk.StringVar(self, value="inferno") self.th_lo = tk.DoubleVar(self, value=1.0) self.th_hi = tk.DoubleVar(self, value=99.0) self.th_absolute = tk.BooleanVar(self, value=False) 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.", foreground="#777").pack(anchor="w", padx=(16, 0)) opts = ttk.Frame(tab) opts.pack(fill="x", pady=(10, 4)) ttk.Label(opts, text="Palette", width=16).pack(side="left") ttk.Combobox(opts, textvariable=self.th_cmap, width=12, state="readonly", values=["inferno", "ironbow", "gray"]).pack(side="left") ttk.Label(opts, text=" Window low %").pack(side="left") ttk.Spinbox(opts, from_=0, to=49, increment=0.5, width=6, textvariable=self.th_lo).pack(side="left", padx=4) ttk.Label(opts, text="high %").pack(side="left") ttk.Spinbox(opts, from_=51, to=100, increment=0.5, width=6, textvariable=self.th_hi).pack(side="left", padx=4) ttk.Checkbutton(opts, text="Absolute min/max", variable=self.th_absolute).pack(side="left", padx=10) 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)) bar = ttk.Frame(tab) bar.pack(fill="x", pady=10) pv = ttk.Button(bar, text="Preview palettes", command=self._start_preview) pv.pack(side="left") run = ttk.Button(bar, text="Stretch all frames", command=self._start_stretch) run.pack(side="left", padx=8) self.action_buttons += [pv, run] ttk.Label(tab, text="Preview (middle frame) gray | inferno | ironbow", foreground="#777").pack(anchor="w") self.preview_label = ttk.Label(tab, anchor="center") self.preview_label.pack(fill="x", pady=(2, 6)) self._progress_block(tab, "thermal").pack(fill="both", expand=True) # ----- tab 3: map ----------------------------------------------------- def _build_map_tab(self, nb): tab = ttk.Frame(nb, padding=PAD) nb.add(tab, text=" 3. Map ") 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" " pip install tkintermapview\n\n" "then reopen this program.").pack(anchor="w", pady=20) return 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 " "(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." ).pack(fill="x", pady=(4, 8)) body = ttk.Frame(tab) body.pack(fill="both", expand=True) left = ttk.Frame(body) left.pack(side="left", fill="y", padx=(0, 8)) right = ttk.Frame(body) right.pack(side="left", fill="both", expand=True) sess_box = ttk.LabelFrame(left, text="Sessions", padding=6) sess_box.pack(fill="x") self.session_list_frame = ttk.Frame(sess_box) self.session_list_frame.pack(fill="x") self.session_vars: dict[str, tk.BooleanVar] = {} btns = ttk.Frame(sess_box) btns.pack(anchor="w", pady=(4, 0)) ttk.Button(btns, text="All", width=5, command=lambda: self._set_all_sessions(True)).pack(side="left") ttk.Button(btns, text="None", width=5, command=lambda: self._set_all_sessions(False)).pack(side="left", padx=4) ttk.Button(btns, text="Refresh", width=8, command=self._refresh_sessions).pack(side="left") opt = ttk.LabelFrame(left, text="Options", padding=6) opt.pack(fill="x", pady=(8, 0)) self.map_ground = tk.StringVar(self, value="110") self.map_tfov_h = tk.StringVar(self, value=str(smap.THERMAL_FOV_DEFAULT[0])) # FLIR A65 25deg self.map_tfov_v = tk.StringVar(self, value=str(smap.THERMAL_FOV_DEFAULT[1])) self.map_toff = tk.StringVar(self, value="0") self.map_step = tk.StringVar(self, value="auto") self.map_markers = tk.BooleanVar(self, value=True) self.map_foot = tk.BooleanVar(self, value=True) self.map_cam = tk.BooleanVar(self, value=True) self.map_thermal = tk.BooleanVar(self, value=True) def field(label, var): r = ttk.Frame(opt) r.pack(fill="x", pady=1) ttk.Label(r, text=label, width=17).pack(side="left") ttk.Entry(r, textvariable=var, width=8).pack(side="left") field("Ground elev (m)", self.map_ground) field("Thermal FOV H (deg)", self.map_tfov_h) field("Thermal FOV V (deg)", self.map_tfov_v) 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)) 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") ttk.Checkbutton(opt, text="Thermal (orange)", variable=self.map_thermal).pack(anchor="w") self.map_btn = ttk.Button(left, text="Show on map", command=self._start_plot_map) self.map_btn.pack(fill="x", pady=8) self.action_buttons.append(self.map_btn) self.map_status = tk.StringVar(self, value="Idle.") ttk.Label(left, textvariable=self.map_status, wraplength=230, foreground="#555").pack(anchor="w") # small dot icons so hundreds of trigger points stay legible (the default # tkintermapview pins are large and merge into a blob at this density) self._dot_icons = {"cam": self._dot("#1565c0"), "thermal": self._dot("#e65100")} self.map_widget = TkinterMapView(right, corner_radius=0) self.map_widget.pack(fill="both", expand=True) self.map_widget.set_tile_server( "https://tile.openstreetmap.org/{z}/{x}/{y}.png", max_zoom=19) self.map_widget.set_position(49.32, 8.43) # Speyer area, until data is plotted self.map_widget.set_zoom(9) self._refresh_sessions() def _set_all_sessions(self, value: bool): for v in self.session_vars.values(): v.set(value) def _refresh_sessions(self): for w in self.session_list_frame.winfo_children(): w.destroy() self.session_vars = {} sessions = smap.list_sessions(APP_DIR) for s in sessions: v = tk.BooleanVar(self, value=True) # default: show all strips self.session_vars[str(s)] = v ttk.Checkbutton(self.session_list_frame, text=s.name, variable=v).pack(anchor="w") if not sessions: ttk.Label(self.session_list_frame, text="(no sessions found)", foreground="#777").pack(anchor="w") def _start_plot_map(self): if self.running: 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.") return try: ground = float(self.map_ground.get()) except ValueError: messagebox.showwarning("Ground elevation", "Ground elevation must be a number (m).") return tfov = None if self.map_tfov_h.get().strip() and self.map_tfov_v.get().strip(): try: tfov = (float(self.map_tfov_h.get()), float(self.map_tfov_v.get())) except ValueError: messagebox.showwarning("Thermal FOV", "Thermal FOV must be numbers (degrees).") return try: toff = float(self.map_toff.get() or 0) except ValueError: toff = 0.0 opts = dict(sessions=sel, ground=ground, tfov=tfov, toff=toff, step_raw=self.map_step.get().strip().lower(), markers=self.map_markers.get(), foot=self.map_foot.get(), inc_cam=self.map_cam.get(), inc_thermal=self.map_thermal.get()) self._set_running(True) self.map_status.set("Reading sessions ...") def work(): try: frames = [] for s in opts["sessions"]: frames += smap.iter_session_frames( s, include_cam=opts["inc_cam"], include_thermal=opts["inc_thermal"], thermal_off_nadir=opts["toff"]) if not frames: self.q.put(("maperror", "No frames with GPS in the selected sessions.")) return total = len(frames) if opts["step_raw"] in ("", "auto", "0"): step = max(1, math.ceil(total / 600)) else: step = max(1, int(float(opts["step_raw"]))) use = frames[::step] markers = [(f["lat"], f["lon"], f["kind"]) for f in use] if opts["markers"] else [] polys = [] if opts["foot"]: for f in use: c = smap.footprint(f, opts["ground"], thermal_fov_deg=opts["tfov"]) if c: polys.append((c, f["kind"])) self.q.put(("map", {"markers": markers, "polys": polys, "bounds": smap.bounds(use), "total": total, "shown": len(use), "step": step})) except Exception as e: # noqa: BLE001 self.q.put(("maperror", str(e))) threading.Thread(target=work, daemon=True).start() @staticmethod def _dot(color, d=9): """A small round marker icon (PhotoImage) of the given colour.""" img = Image.new("RGBA", (d, d), (0, 0, 0, 0)) ImageDraw.Draw(img).ellipse([0, 0, d - 1, d - 1], fill=color, outline="#ffffff") return ImageTk.PhotoImage(img) def _render_map(self, data): self._set_running(False) mw = self.map_widget mw.delete_all_marker() mw.delete_all_polygon() colors = {"cam": "#1565c0", "thermal": "#e65100"} for corners, kind in data["polys"]: mw.set_polygon(corners, outline_color=colors.get(kind, "#333333"), border_width=1) for lat, lon, kind in data["markers"]: mw.set_marker(lat, lon, text="", icon=self._dot_icons.get(kind), icon_anchor="center") mn_lat, mn_lon, mx_lat, mx_lon = data["bounds"] try: if mx_lat > mn_lat and mx_lon > mn_lon: mw.fit_bounding_box((mx_lat, mn_lon), (mn_lat, mx_lon)) else: mw.set_position((mn_lat + mx_lat) / 2, (mn_lon + mx_lon) / 2) mw.set_zoom(14) except Exception: # noqa: BLE001 pass self.map_status.set( f"Plotted {data['shown']} of {data['total']} frames (every {data['step']}): " f"{len(data['markers'])} points, {len(data['polys'])} footprints.") # ----- run helpers ---------------------------------------------------- def _set_running(self, busy: bool): self.running = busy for b in self.action_buttons: b.config(state="disabled" if busy else "normal") def _callbacks(self, which): return (lambda frac, msg: self.q.put(("progress", which, frac, msg)), lambda msg: self.q.put(("log", which, msg))) def _guard(self, session: str, which: str) -> bool: if self.running: return False if not session or not Path(session).is_dir(): messagebox.showwarning("No session", "Please choose a valid session folder.") return False self.logs[which].delete("1.0", "end") self.bars[which]["value"] = 0 self._set_running(True) return True def _spawn(self, fn): threading.Thread(target=fn, daemon=True).start() def _start_embed(self): session = self.embed_session.get().strip() if not self._guard(session, "embed"): return out = self.embed_out.get().strip() or None gps = self.embed_gps.get() et = self.embed_exif.get().strip() or None progress, log = self._callbacks("embed") def work(): try: res = embed.embed_session(session, out, gps, et, progress=progress, log=log) self.q.put(("done", "embed", res)) except Exception as e: # noqa: BLE001 self.q.put(("error", "embed", str(e))) self._spawn(work) def _read_window_params(self): return dict(lo_pct=float(self.th_lo.get()), hi_pct=float(self.th_hi.get()), absolute=bool(self.th_absolute.get())) def _start_preview(self): session = self.th_session.get().strip() if not self._guard(session, "thermal"): return params = self._read_window_params() progress, log = self._callbacks("thermal") def work(): try: pngs = stretch.list_frames(Path(session)) win = stretch.compute_window(pngs, progress=progress, **params) log(f"Frames: {len(pngs)} | absolute signal {win['abs_lo']}-{win['abs_hi']}") log(f"Window: {win['lo']}-{win['hi']} ({win['how']})") img = stretch.make_sample_image(pngs, win["lo"], win["hi"]) self.q.put(("preview", img)) self.q.put(("done", "thermal", {"kind": "preview", **win})) except Exception as e: # noqa: BLE001 self.q.put(("error", "thermal", str(e))) self._spawn(work) def _start_stretch(self): session = self.th_session.get().strip() if not self._guard(session, "thermal"): return out = self.th_out.get().strip() or None cmap = self.th_cmap.get() params = self._read_window_params() progress, log = self._callbacks("thermal") def work(): try: pngs = stretch.list_frames(Path(session)) win = stretch.compute_window(pngs, progress=progress, **params) log(f"Window: {win['lo']}-{win['hi']} ({win['how']})") res = stretch.stretch_session(session, out, cmap, win["lo"], win["hi"], embed_exif=self.th_embed.get(), progress=progress, log=log) self.q.put(("done", "thermal", {"kind": "stretch", **res})) except Exception as e: # noqa: BLE001 self.q.put(("error", "thermal", str(e))) self._spawn(work) # ----- preview rendering --------------------------------------------- def _show_preview(self, img: Image.Image): maxw = max(self.preview_label.winfo_width() - 8, 600) if img.width > maxw: h = round(img.height * maxw / img.width) img = img.resize((maxw, h), Image.LANCZOS) self._preview_imgtk = ImageTk.PhotoImage(img) self.preview_label.config(image=self._preview_imgtk) # ----- queue pump (runs on the Tk main thread) ------------------------ def _poll_queue(self): try: while True: msg = self.q.get_nowait() kind = msg[0] if kind == "log": _, which, text = msg self.logs[which].insert("end", text + "\n") self.logs[which].see("end") elif kind == "progress": _, which, frac, m = msg self.bars[which]["value"] = max(0, min(1000, int(frac * 1000))) self.status_vars[which].set(m) elif kind == "preview": self._show_preview(msg[1]) elif kind == "map": self._render_map(msg[1]) elif kind == "maperror": self._set_running(False) self.map_status.set("Error.") messagebox.showerror("FireMapper - map", msg[1]) elif kind == "done": self._on_done(msg[1], msg[2]) elif kind == "error": _, which, m = msg self._set_running(False) self.status_vars[which].set("Error.") messagebox.showerror("FireMapper - error", m) except queue.Empty: pass self.after(100, self._poll_queue) def _on_done(self, which, res): self._set_running(False) if which == "thermal" and res.get("kind") == "preview": self.status_vars["thermal"].set( f"Preview ready - window {res['lo']}-{res['hi']} ({res['how']}).") return self.bars[which]["value"] = 1000 self.status_vars[which].set("Done.") if which == "embed": out = Path(res["out_root"]) text = f"Tagged {res['updated']} of {res['pairs']} images.\n\nOutput:\n{out}" else: out = Path(res["out_dir"]) text = f"Wrote {res['frames']} thermal frames.\n\nOutput:\n{out}" if messagebox.askyesno("FireMapper - done", text + "\n\nOpen the output folder?"): open_in_explorer(out) if __name__ == "__main__": FireMapperGUI().mainloop()