diff --git a/embed_metadata.py b/embed_metadata.py
index 9dd10cd..64a81ca 100644
--- a/embed_metadata.py
+++ b/embed_metadata.py
@@ -8,8 +8,9 @@ For every image in a capture session that has a same-basename `.json` sidecar
* maps the structured fields to real EXIF/XMP tags so the images are
georeferenced and usable in mapping / photogrammetry software:
- GPS lat/lon/alt -> EXIF GPS* tags
- - platform yaw -> EXIF GPSImgDirection + XMP-Camera:PoseHeadingDegrees
- - pitch / roll -> XMP-Camera:PosePitch/PoseRollDegrees
+ - optical-axis azimuth -> EXIF GPSImgDirection
+ - camera attitude -> XMP-Camera:Yaw/Pitch/Roll (Pix4D/Metashape
+ convention: pitch 0 = nadir, +90 = forward)
- GPS week/tow -> DateTimeOriginal + GPSDateStamp/GPSTimeStamp (UTC)
- lens / exposure / camera -> FocalLength, ExposureTime, Make/Model/Serial
* stores the COMPLETE original JSON in EXIF:UserComment so nothing is lost.
@@ -32,6 +33,7 @@ Usage:
from __future__ import annotations
import argparse
+import csv
import datetime as dt
import json
import os
@@ -50,6 +52,17 @@ GPS_EPOCH = dt.datetime(1980, 1, 6, tzinfo=dt.timezone.utc)
GPS_UTC_LEAP_SECONDS = 18 # GPS-UTC offset as of 2017-2026
WORKER_CAP = 16 # default upper bound on parallel workers
+# ExifTool config that makes the Pix4D/Metashape XMP-Camera:Yaw/Pitch/Roll tags
+# writable (stock exiftool doesn't know them). Passed via `exiftool -config`.
+EXIFTOOL_CONFIG = Path(__file__).resolve().parent / "firemapper.ExifTool_config"
+
+
+def exiftool_base_cmd(et: str) -> list[str]:
+ """exiftool invocation prefix, including our -config when it's present."""
+ if EXIFTOOL_CONFIG.exists():
+ return [et, "-config", str(EXIFTOOL_CONFIG)]
+ return [et]
+
# --------------------------------------------------------------------------- #
# parallelism
@@ -85,7 +98,8 @@ def run_exiftool_parallel(et, segments, scratch_dir: Path, workers: int, total:
argfile = scratch_dir / f"_exif_args_{k}.txt"
argfile.write_text("\n".join(arg_lines) + "\n", encoding="utf-8")
proc = subprocess.Popen(
- [et, "-m", "-charset", "UTF8", "-charset", "filename=UTF8", "-@", str(argfile)],
+ [*exiftool_base_cmd(et), "-m", "-charset", "UTF8", "-charset", "filename=UTF8",
+ "-@", str(argfile)],
stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
for line in proc.stdout: # type: ignore[union-attr]
line = line.rstrip()
@@ -191,20 +205,29 @@ def build_tags(data: dict, manifest_cams: dict, cam_id: str | None, gps_source:
# not the bare IMU. Computed by the shared geometry so EXIF matches the map.
imu = data.get("imu") or {}
plat = data.get("platform") or {}
- ori = None
+ geo = None
if None not in (imu.get("yaw"), imu.get("pitch"), imu.get("roll")):
- ori = smap.camera_orientation({
+ geo = {
"kind": "cam" if cam_id else "thermal",
"off_nadir": smap.off_nadir_from_name(cam_id) if cam_id else 0.0,
"yaw": imu["yaw"], "pitch": imu["pitch"], "roll": imu["roll"],
"platform_angle": plat.get("platform_angle_deg"),
- })
- if ori:
- heading, pitch, roll = ori
- tags += [f"-GPSImgDirection={heading % 360:.4f}", "-GPSImgDirectionRef=T",
- f"-XMP-GPano:PoseHeadingDegrees={heading % 360:.4f}",
- f"-XMP-GPano:PosePitchDegrees={pitch:.4f}",
- f"-XMP-GPano:PoseRollDegrees={roll:.4f}"]
+ }
+ if geo:
+ # Compass azimuth of the optical axis -> standard GPSImgDirection.
+ ori = smap.camera_orientation(geo)
+ if ori:
+ heading = ori[0] % 360
+ tags += [f"-GPSImgDirection={heading:.4f}", "-GPSImgDirectionRef=T"]
+ # Camera attitude in the Pix4D/Metashape convention (pitch 0 = nadir,
+ # +90 = forward) -> XMP-Camera:Yaw/Pitch/Roll, the tags photogrammetry
+ # software (Agisoft Metashape, Pix4D) actually reads. Needs EXIFTOOL_CONFIG.
+ ypr = smap.metashape_ypr(geo)
+ if ypr:
+ yaw, pitch, roll = ypr
+ tags += [f"-XMP-Camera:Yaw={yaw:.4f}",
+ f"-XMP-Camera:Pitch={pitch:.4f}",
+ f"-XMP-Camera:Roll={roll:.4f}"]
# --- timestamp (UTC, derived from GPS week/tow) ------------------------
g = data.get("gps") or {}
@@ -238,6 +261,85 @@ def build_tags(data: dict, manifest_cams: dict, cam_id: str | None, gps_source:
return tags
+# --------------------------------------------------------------------------- #
+# Metashape / Pix4D reference CSV
+# --------------------------------------------------------------------------- #
+REFERENCE_CSV_NAME = "metashape_reference.csv"
+
+
+def frame_reference(data: dict, cam_id: str | None, gps_source: str) -> dict | None:
+ """Position + Pix4D/Metashape camera attitude for one frame, or None if no GPS.
+
+ Uses the same GPS-source rule and shared geometry as build_tags(), so the CSV
+ matches the embedded EXIF exactly: lon/lat/alt plus yaw/pitch/roll where
+ pitch 0 = nadir and +90 = looking forward (None for each angle if attitude
+ is missing).
+ """
+ pos = None
+ if cam_id and gps_source == "position":
+ pos = data.get("position")
+ if not pos:
+ pos = data.get("gps")
+ if not (pos and pos.get("lat") is not None and pos.get("lon") is not None):
+ return None
+ imu = data.get("imu") or {}
+ plat = data.get("platform") or {}
+ ypr = (None, None, None)
+ if None not in (imu.get("yaw"), imu.get("pitch"), imu.get("roll")):
+ ypr = smap.metashape_ypr({
+ "kind": "cam" if cam_id else "thermal",
+ "off_nadir": smap.off_nadir_from_name(cam_id) if cam_id else 0.0,
+ "yaw": imu["yaw"], "pitch": imu["pitch"], "roll": imu["roll"],
+ "platform_angle": plat.get("platform_angle_deg"),
+ }) or (None, None, None)
+ return {"lon": pos["lon"], "lat": pos["lat"], "alt": pos.get("alt"),
+ "yaw": ypr[0], "pitch": ypr[1], "roll": ypr[2]}
+
+
+def write_reference_csv(pairs, gps_source: str, out_path: Path, log=None) -> Path:
+ """Write a Metashape-importable reference CSV for every frame that has GPS.
+
+ Columns: Label, Longitude, Latitude, Altitude, Yaw, Pitch, Roll (WGS84;
+ angles in the Metashape/Pix4D convention). Label is the image filename without
+ extension, which is Metashape's default camera label. Import via
+ Reference pane -> Import Reference (delimiter: comma, first row = header).
+ """
+ log = log or (lambda *_: None)
+
+ def fmt(v, nd):
+ return f"{v:.{nd}f}" if v is not None else ""
+
+ rows, labels, n_oriented = [], {}, 0
+ for img, sidecar in pairs:
+ cam_id = cam_id_for(img)
+ try:
+ data = json.loads(sidecar.read_text(encoding="utf-8"))
+ except Exception: # noqa: BLE001
+ continue
+ ref = frame_reference(data, cam_id, gps_source)
+ if not ref:
+ continue
+ labels[img.stem] = labels.get(img.stem, 0) + 1
+ if ref["yaw"] is not None:
+ n_oriented += 1
+ rows.append([img.stem, fmt(ref["lon"], 8), fmt(ref["lat"], 8), fmt(ref["alt"], 3),
+ fmt(ref["yaw"], 4), fmt(ref["pitch"], 4), fmt(ref["roll"], 4)])
+
+ out_path = Path(out_path)
+ out_path.parent.mkdir(parents=True, exist_ok=True)
+ with out_path.open("w", newline="", encoding="utf-8") as f:
+ w = csv.writer(f)
+ w.writerow(["Label", "Longitude", "Latitude", "Altitude", "Yaw", "Pitch", "Roll"])
+ w.writerows(rows)
+
+ log(f"Metashape reference CSV: {out_path} ({len(rows)} frames, {n_oriented} with attitude)")
+ dups = sum(1 for c in labels.values() if c > 1)
+ if dups:
+ log(f" ! {dups} image name(s) occur in more than one camera folder; Metashape matches "
+ "reference rows by label, so duplicate labels would be ambiguous.")
+ return out_path
+
+
# --------------------------------------------------------------------------- #
# core (importable - used by both the CLI and the GUI)
# --------------------------------------------------------------------------- #
@@ -259,6 +361,16 @@ def collect_pairs(session: Path) -> list[tuple[Path, Path]]:
return pairs
+def cam_id_for(img: Path) -> str | None:
+ """The cam folder the image sits in (its immediate parent dir), or None for
+ thermal / non-cam frames. Keyed off the parent dir, so it stays correct when the
+ embedded path is a parent folder grouping several sessions (the top path part is
+ then the session name, not the camera) - which would otherwise drop the off-nadir
+ mounting angle and mis-tag every RGB frame as nadir."""
+ name = img.parent.name
+ return name if name.startswith("cam") else None
+
+
def load_manifest_cams(session: Path) -> dict:
"""camera id -> manifest entry (model / serial), if a manifest exists."""
cams: dict = {}
@@ -293,15 +405,23 @@ def embed_session(session, out_root=None, gps_source="position", exiftool=None,
if not pairs:
raise RuntimeError(f"No image+json pairs found under {session}")
cams = load_manifest_cams(session)
- et = exiftool or ensure_exiftool(None, Path(__file__).resolve().parent / "tools")
total = len(pairs)
log(f"Session : {session}")
log(f"Output : {out_root}")
log(f"GPS source : {gps_source} (cam frames; thermal always uses raw gps)")
- log(f"exiftool : {et}")
log(f"Workers : {workers}")
- log(f"Found {total} image + JSON pairs.\nCopying originals into the output folder ...")
+ log(f"Found {total} image + JSON pairs.")
+
+ # Metashape/Pix4D reference CSV (GPS + attitude) - written first so it is always
+ # produced, independent of exiftool and of whether the later tagging succeeds.
+ out_root.mkdir(parents=True, exist_ok=True)
+ csv_path = write_reference_csv(pairs, gps_source,
+ out_root / REFERENCE_CSV_NAME, log=log)
+
+ et = exiftool or ensure_exiftool(None, Path(__file__).resolve().parent / "tools")
+ log(f"exiftool : {et}")
+ log("Copying originals into the output folder ...")
# pre-create the output directory tree (avoids mkdir races between threads)
for d in {(out_root / img.relative_to(session)).parent for img, _ in pairs}:
@@ -315,7 +435,7 @@ def embed_session(session, out_root=None, gps_source="position", exiftool=None,
raise RuntimeError("Cancelled by user.")
img, sidecar = pair
rel = img.relative_to(session)
- cam_id = rel.parts[0] if rel.parts[0].startswith("cam") else None
+ cam_id = cam_id_for(img)
try:
data = json.loads(sidecar.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001
@@ -346,8 +466,9 @@ def embed_session(session, out_root=None, gps_source="position", exiftool=None,
"(metadata is written via a temp copy) - free up space and re-run.")
progress(1.0, "Done")
- log(f"\nDone. {updated} files tagged.\nOutput: {out_root}")
- return {"out_root": out_root, "pairs": total, "updated": updated}
+ log(f"\nDone. {updated} files tagged.\nOutput: {out_root}"
+ f"\nMetashape reference CSV: {csv_path}")
+ return {"out_root": out_root, "pairs": total, "updated": updated, "csv": csv_path}
# --------------------------------------------------------------------------- #
@@ -375,8 +496,7 @@ def main() -> int:
if not pairs:
sys.exit(f"No image+json pairs found under {session}")
img, sidecar = pairs[0]
- cam_id = img.relative_to(session).parts[0]
- cam_id = cam_id if cam_id.startswith("cam") else None
+ cam_id = cam_id_for(img)
data = json.loads(sidecar.read_text(encoding="utf-8"))
print(f"DRY RUN: {len(pairs)} pairs under {session}")
print("\n--- sample tags (first frame) ---")
diff --git a/firemapper.ExifTool_config b/firemapper.ExifTool_config
new file mode 100644
index 0000000..493266b
--- /dev/null
+++ b/firemapper.ExifTool_config
@@ -0,0 +1,40 @@
+#------------------------------------------------------------------------------
+# FireMapper ExifTool config - defines the "Camera" XMP namespace.
+#
+# Stock ExifTool cannot write XMP-Camera:Yaw/Pitch/Roll. These are the de-facto
+# standard camera-attitude tags (Pix4D / MicaSense / senseFly schema,
+# namespace http://pix4d.com/camera/1.0/) that Agisoft Metashape and Pix4D read
+# directly to seed each photo's orientation. embed_metadata.py / stretch_thermal.py
+# pass this file via `exiftool -config` so those tags become writable.
+#
+# Do NOT confuse with XMP-GPano:Pose* (a different convention Metashape ignores)
+# or the read-only XMP-Camera:Pose* table built into ExifTool.
+#------------------------------------------------------------------------------
+
+%Image::ExifTool::UserDefined = (
+ 'Image::ExifTool::XMP::Main' => {
+ Camera => {
+ SubDirectory => {
+ TagTable => 'Image::ExifTool::UserDefined::Camera',
+ },
+ },
+ },
+);
+
+%Image::ExifTool::UserDefined::Camera = (
+ GROUPS => { 0 => 'XMP', 1 => 'XMP-Camera', 2 => 'Camera' },
+ NAMESPACE => { 'Camera' => 'http://pix4d.com/camera/1.0/' },
+ WRITABLE => 'string',
+ # Camera attitude, degrees. Pix4D/Metashape convention:
+ # Pitch = 0 -> nadir (looking straight down), +90 -> looking forward.
+ # Yaw = azimuth of the image-top direction (0 = north).
+ # Roll = rotation about the optical axis.
+ Yaw => { Writable => 'real' },
+ Pitch => { Writable => 'real' },
+ Roll => { Writable => 'real' },
+ # Optional reported accuracies (metres), harmless if unused.
+ GPSXYAccuracy => { Writable => 'real' },
+ GPSZAccuracy => { Writable => 'real' },
+);
+
+1; #end
diff --git a/firemapper_gui.py b/firemapper_gui.py
index 9b5ae0a..4f62068 100644
--- a/firemapper_gui.py
+++ b/firemapper_gui.py
@@ -43,6 +43,7 @@ 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
+import map_export # noqa: E402
try:
from tkintermapview import TkinterMapView # noqa: E402
@@ -176,8 +177,11 @@ class FireMapperGUI(tk.Tk):
"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."
+ "embedded GPS and camera orientation to position each image. It also writes a "
+ "metashape_reference.csv (GPS + yaw/pitch/roll for every frame) into the output "
+ "folder, which you can load directly via Metashape's Import Reference if you "
+ "prefer a CSV over the embedded tags. Requires exiftool, which is located "
+ "automatically and installed on first use if not already present."
).pack(fill="x", pady=(4, 10))
self.embed_session = tk.StringVar(self, value=autodetect_session())
@@ -353,8 +357,18 @@ class FireMapperGUI(tk.Tk):
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.map_btn.pack(fill="x", pady=(8, 2))
self.action_buttons.append(self.map_btn)
+
+ self.map_export_btn = ttk.Button(
+ left, text="Open full map in browser", command=self._start_export_map)
+ self.map_export_btn.pack(fill="x")
+ self.action_buttons.append(self.map_export_btn)
+ ttk.Label(left, foreground="#777", wraplength=230, justify="left",
+ text="The in-app map above thins dense flights for speed. The browser map "
+ "renders every trigger and footprint, with per-session and per-kind "
+ "toggles and clustering. Needs internet.").pack(anchor="w", pady=(2, 6))
+
self.map_status = tk.StringVar(self, value="Idle.")
ttk.Label(left, textvariable=self.map_status, wraplength=230,
foreground="#555").pack(anchor="w")
@@ -389,29 +403,37 @@ class FireMapperGUI(tk.Tk):
ttk.Label(self.session_list_frame, text="(no sessions found)",
foreground="#777").pack(anchor="w")
- def _start_plot_map(self):
- if self.running:
- return
+ def _read_map_inputs(self):
+ """Parse the shared map options, or None (after warning) if invalid."""
sel = [s for s, v in self.session_vars.items() if v.get()]
if not sel:
messagebox.showwarning("No sessions", "Select at least one session to display.")
- return
+ return None
try:
ground = float(self.map_ground.get())
except ValueError:
messagebox.showwarning("Ground elevation", "Ground elevation must be a number (m).")
- return
+ return None
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
+ return None
try:
toff = float(self.map_toff.get() or 0)
except ValueError:
toff = 0.0
+ return sel, ground, tfov, toff
+
+ def _start_plot_map(self):
+ if self.running:
+ return
+ parsed = self._read_map_inputs()
+ if parsed is None:
+ return
+ sel, ground, tfov, toff = parsed
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(),
@@ -449,6 +471,39 @@ class FireMapperGUI(tk.Tk):
self.q.put(("maperror", str(e)))
threading.Thread(target=work, daemon=True).start()
+ def _start_export_map(self):
+ """Render *every* trigger/footprint to a standalone Leaflet page and open it."""
+ if self.running:
+ return
+ parsed = self._read_map_inputs()
+ if parsed is None:
+ return
+ sel, ground, tfov, toff = parsed
+ opts = dict(sessions=sel, ground=ground, tfov=tfov, toff=toff,
+ inc_cam=self.map_cam.get(), inc_thermal=self.map_thermal.get(),
+ markers=self.map_markers.get(), foot=self.map_foot.get())
+ self._set_running(True)
+ self.map_status.set("Building full map (all frames) ...")
+
+ 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
+ res = map_export.export(
+ frames, ground_elev=opts["ground"], thermal_fov_deg=opts["tfov"],
+ include_markers=opts["markers"], include_footprints=opts["foot"],
+ title="FireMapper - " + ", ".join(Path(s).name for s in opts["sessions"]))
+ self.q.put(("mapexported", res))
+ 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."""
@@ -598,6 +653,12 @@ class FireMapperGUI(tk.Tk):
self._show_preview(msg[1])
elif kind == "map":
self._render_map(msg[1])
+ elif kind == "mapexported":
+ self._set_running(False)
+ res = msg[1]
+ self.map_status.set(
+ f"Opened browser map: {res['markers']} points, "
+ f"{res['footprints']} footprints.\n{res['path']}")
elif kind == "maperror":
self._set_running(False)
self.map_status.set("Error.")
diff --git a/manual.html b/manual.html
index 2b8d2ed..98fd914 100644
--- a/manual.html
+++ b/manual.html
@@ -163,6 +163,12 @@ new folder.
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.
+
For a complete view, click Open full map in browser. This renders
+ every trigger point and footprint (the in-app map thins dense flights for
+ speed) on an interactive page in your web browser, with marker clustering and a
+ panel to toggle individual sessions on and off and to switch each camera's footprints
+ separately (cam25, cam45 and thermal each have their own colour). Click any point or
+ footprint to see its metadata.
Options
@@ -171,8 +177,9 @@ new folder.
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 reduces very large surveys so the map remains
- responsive; enter a number to override.
+
Plot every Nth
Applies to the in-app Show on map only: auto
+ reduces very large surveys so the embedded map remains responsive; enter a number to
+ override. Open full map in browser ignores this and shows everything.
Trigger points / Footprints / Cameras / Thermal
Control which layers are drawn.
Needs an internet connection for the map tiles.
diff --git a/map_export.py b/map_export.py
new file mode 100644
index 0000000..707b358
--- /dev/null
+++ b/map_export.py
@@ -0,0 +1,350 @@
+#!/usr/bin/env python3
+"""
+map_export.py - export a FireMapper session map as a standalone interactive
+Leaflet HTML page.
+
+Unlike the in-app tkintermapview view (which subsamples to a few hundred frames
+to stay responsive), this renders *every* trigger point and footprint at once.
+It stays legible at that scale by:
+ * clustering the trigger markers (leaflet.markercluster),
+ * drawing footprints on a canvas renderer (fast with thousands of polygons),
+ * giving per-session and per-camera (cam25 / cam45 / thermal / ...) toggles,
+ * showing each frame's metadata in a click popup.
+
+Each camera is its own layer (keyed by its cam off-nadir angle) with its own
+colour, so its footprints can be switched on and off independently. Leaflet + the
+markercluster plugin are loaded from a CDN, so an internet connection is required
+- the OpenStreetMap tiles need one anyway.
+
+Used by firemapper_gui.py ("Open full map in browser"); also runnable directly:
+
+ python map_export.py [SESSION ...]
+"""
+
+from __future__ import annotations
+
+import json
+import tempfile
+import webbrowser
+from pathlib import Path
+
+import session_map as smap
+
+THERMAL_COLOR = "#e65100" # thermal keeps the established orange
+# distinct colours handed to the cameras in ascending off-nadir order
+_CAM_PALETTE = ["#1565c0", "#2e7d32", "#6a1b9a", "#c62828",
+ "#00838f", "#9e9d24", "#4527a0"]
+
+
+# --------------------------------------------------------------------------- #
+# layers & colours
+# --------------------------------------------------------------------------- #
+def _layer_key(frame: dict) -> str:
+ """The toggle layer a frame belongs to: 'thermal' or 'cam'."""
+ if frame["kind"] == "thermal":
+ return "thermal"
+ return f"cam{int(frame['off_nadir'])}"
+
+
+def _ordered_layers(keys: set[str]) -> list[str]:
+ """Cameras first (ascending off-nadir), thermal last."""
+ cams = sorted((k for k in keys if k != "thermal"), key=lambda k: int(k[3:]))
+ return cams + (["thermal"] if "thermal" in keys else [])
+
+
+def _assign_colors(ordered: list[str]) -> dict[str, str]:
+ colors: dict[str, str] = {}
+ ci = 0
+ for lk in ordered:
+ if lk == "thermal":
+ colors[lk] = THERMAL_COLOR
+ else:
+ colors[lk] = _CAM_PALETTE[ci % len(_CAM_PALETTE)]
+ ci += 1
+ return colors
+
+
+# --------------------------------------------------------------------------- #
+# feature building
+# --------------------------------------------------------------------------- #
+def _feature_props(frame: dict) -> dict:
+ """Human-readable metadata for a frame's click popup (ordered)."""
+ kind = frame["kind"]
+ label = f"cam{int(frame['off_nadir'])}" if kind == "cam" else kind
+ props: dict[str, str] = {
+ "Session": frame["session"],
+ "Kind": label,
+ "Lat": f"{frame['lat']:.6f}",
+ "Lon": f"{frame['lon']:.6f}",
+ }
+ if frame.get("alt") is not None:
+ props["Alt (m)"] = f"{frame['alt']:.1f}"
+ ori = smap.camera_orientation(frame)
+ if ori:
+ props["Heading"] = f"{ori[0]:.1f} deg"
+ props["Pitch"] = f"{ori[1]:.1f} deg"
+ props["Roll"] = f"{ori[2]:.1f} deg"
+ if frame.get("platform_angle") is not None:
+ props["Scan angle"] = f"{frame['platform_angle']:.1f} deg"
+ return props
+
+
+def build_payload(frames: list[dict], *, ground_elev: float,
+ thermal_fov_deg: tuple[float, float] | None,
+ include_markers: bool, include_footprints: bool) -> dict:
+ """Turn frames into the compact JSON payload the HTML page consumes."""
+ sessions: list[str] = []
+ seen_s: set[str] = set()
+ seen_k: set[str] = set()
+ markers: list[dict] = []
+ foots: list[dict] = []
+ for f in frames:
+ s = f["session"]
+ if s not in seen_s:
+ seen_s.add(s)
+ sessions.append(s)
+ lk = _layer_key(f)
+ seen_k.add(lk)
+ props = _feature_props(f)
+ if include_markers:
+ markers.append({"s": s, "k": lk, "ll": [f["lat"], f["lon"]], "p": props})
+ if include_footprints:
+ corners = smap.footprint(f, ground_elev, thermal_fov_deg=thermal_fov_deg)
+ if corners:
+ foots.append({"s": s, "k": lk, "c": corners, "p": props})
+ layers = _ordered_layers(seen_k)
+ return {"sessions": sorted(sessions), "markers": markers, "foots": foots,
+ "layers": layers, "colors": _assign_colors(layers)}
+
+
+# --------------------------------------------------------------------------- #
+# HTML template
+# --------------------------------------------------------------------------- #
+_TEMPLATE = r"""
+
+
+
+
+__TITLE__
+
+
+
+
+
+
+
+
+
+
+
+
+"""
+
+
+def build_html(payload: dict, *, title: str = "FireMapper map") -> str:
+ return (_TEMPLATE
+ .replace("__TITLE__", title)
+ .replace("__DATA__", json.dumps(payload, separators=(",", ":"))))
+
+
+def export(frames: list[dict], *, ground_elev: float = 110.0,
+ thermal_fov_deg: tuple[float, float] | None = smap.THERMAL_FOV_DEFAULT,
+ include_markers: bool = True, include_footprints: bool = True,
+ out_path: Path | None = None, title: str = "FireMapper map",
+ open_browser: bool = True) -> dict:
+ """Build the HTML map for `frames`, write it, and (optionally) open it.
+
+ Returns {"path", "markers", "footprints"}.
+ """
+ payload = build_payload(frames, ground_elev=ground_elev,
+ thermal_fov_deg=thermal_fov_deg,
+ include_markers=include_markers,
+ include_footprints=include_footprints)
+ html = build_html(payload, title=title)
+ if out_path is None:
+ out_path = Path(tempfile.gettempdir()) / "firemapper_map.html"
+ out_path = Path(out_path)
+ out_path.write_text(html, encoding="utf-8")
+ if open_browser:
+ webbrowser.open(out_path.as_uri())
+ return {"path": str(out_path),
+ "markers": len(payload["markers"]),
+ "footprints": len(payload["foots"])}
+
+
+# --------------------------------------------------------------------------- #
+def _main(argv: list[str]) -> int:
+ args = [a for a in argv if not a.startswith("-")]
+ if args:
+ sessions = [Path(a) for a in args]
+ else:
+ here = Path(__file__).resolve().parent
+ sessions = smap.list_sessions(here)
+ if not sessions:
+ print("No sessions found.")
+ return 1
+ frames: list[dict] = []
+ for s in sessions:
+ frames += smap.iter_session_frames(s)
+ if not frames:
+ print("No frames with GPS in the selected sessions.")
+ return 1
+ res = export(frames)
+ print(f"Wrote {res['path']} ({res['markers']} points, "
+ f"{res['footprints']} footprints)")
+ return 0
+
+
+if __name__ == "__main__":
+ import sys
+ raise SystemExit(_main(sys.argv[1:]))
diff --git a/session_map.py b/session_map.py
index 39d2446..dfcec10 100644
--- a/session_map.py
+++ b/session_map.py
@@ -205,6 +205,38 @@ def camera_orientation(frame: dict):
return heading, pitch, roll
+def metashape_ypr(frame: dict):
+ """
+ (yaw, pitch, roll) of the camera in the Pix4D / Agisoft Metashape convention,
+ or None. This is the attitude photogrammetry software expects in the XMP
+ ``Camera:Yaw/Pitch/Roll`` tags, and it differs from ``camera_orientation``:
+
+ * pitch = 0 means the optical axis looks straight DOWN (nadir); pitch grows
+ toward +90 as the camera tilts to look FORWARD. So our nadir thermal -> 0,
+ cam25 -> ~25, cam45 -> ~45 (the cam off-nadir angle), as Metashape wants.
+ * yaw = azimuth of the image-TOP direction (0 = top points north), 0-360.
+ * roll = rotation about the optical axis.
+
+ These are the angles psi/theta/phi of the body->NED rotation
+ C^n_b = Rz(yaw) Ry(pitch) Rx(roll) (NED nav frame; right-hand rule), recovered
+ from the true camera axes so aircraft attitude and the platform sweep are all
+ folded in. Pix4D's default "image top = flight direction, camera looks down"
+ body frame maps to our axes as: x_b (front) = image-up = -down_im,
+ y_b (right) = image-right, z_b (bottom) = optical axis.
+ Ref: Pix4D "Yaw, Pitch, Roll and Omega, Phi, Kappa angles".
+ """
+ if any(frame.get(k) is None for k in ("yaw", "pitch", "roll")):
+ return None
+ right, down_im, optical = camera_axes_ned(frame, sensor=True) # true stored-image axes
+ xb = -down_im # body front (image top), col 0 of C^n_b
+ # C^n_b = [xb | right | optical]; extract from Rz(psi)Ry(theta)Rx(phi):
+ # xb[2] = -sin(theta); yaw = atan2(xb[1], xb[0]); roll = atan2(right[2], optical[2])
+ pitch = math.degrees(math.asin(max(-1.0, min(1.0, -float(xb[2])))))
+ yaw = math.degrees(math.atan2(float(xb[1]), float(xb[0]))) % 360.0
+ roll = math.degrees(math.atan2(float(right[2]), float(optical[2])))
+ return yaw, pitch, roll
+
+
def footprint(frame: dict, ground_elev: float,
thermal_fov_deg: tuple[float, float] | None = None) -> list[tuple[float, float]] | None:
"""Four (lat, lon) ground corners of the image, or None if not projectable."""