631 lines
28 KiB
Python
631 lines
28 KiB
Python
#!/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.workers = tk.IntVar(self, value=embed.default_workers())
|
|
|
|
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 _workers_row(self, parent):
|
|
row = ttk.Frame(parent)
|
|
ttk.Label(row, text="Parallel workers", width=16).pack(side="left")
|
|
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)"
|
|
).pack(side="left", padx=6)
|
|
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 <session>_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)
|
|
|
|
self._workers_row(tab).pack(fill="x", pady=(8, 2))
|
|
|
|
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 <session>/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))
|
|
|
|
self._workers_row(tab).pack(fill="x", pady=(6, 2))
|
|
|
|
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
|
|
workers = max(1, int(self.workers.get()))
|
|
progress, log = self._callbacks("embed")
|
|
|
|
def work():
|
|
try:
|
|
res = embed.embed_session(session, out, gps, et, progress=progress, log=log,
|
|
workers=workers)
|
|
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()
|
|
workers = max(1, int(self.workers.get()))
|
|
progress, log = self._callbacks("thermal")
|
|
|
|
def work():
|
|
try:
|
|
pngs = stretch.list_frames(Path(session))
|
|
win = stretch.compute_window(pngs, progress=progress, workers=workers, **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()
|
|
workers = max(1, int(self.workers.get()))
|
|
embed_exif = bool(self.th_embed.get())
|
|
progress, log = self._callbacks("thermal")
|
|
|
|
def work():
|
|
try:
|
|
pngs = stretch.list_frames(Path(session))
|
|
win = stretch.compute_window(pngs, progress=progress, workers=workers, **params)
|
|
log(f"Window: {win['lo']}-{win['hi']} ({win['how']})")
|
|
res = stretch.stretch_session(session, out, cmap, win["lo"], win["hi"],
|
|
embed_exif=embed_exif,
|
|
progress=progress, log=log, workers=workers)
|
|
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()
|