Write camera attitude for Metashape: XMP-Camera YPR + reference CSV

Photogrammetry software (Agisoft Metashape, Pix4D) could not read the
camera orientation, and the pitch convention was wrong.

- Emit attitude as XMP-Camera:Yaw/Pitch/Roll (Pix4D namespace Metashape
  reads) instead of the ignored XMP-GPano:Pose* tags, via a bundled
  firemapper.ExifTool_config (stock exiftool can't write that namespace).
- Add session_map.metashape_ypr(): pitch 0 = nadir, +90 = forward, so
  thermal -> 0, cam25 -> ~25, cam45 -> ~45, with aircraft attitude and the
  platform sweep folded in (decomposed from the shared camera geometry).
- Write a Metashape/Pix4D reference CSV (GPS + YPR per frame) on every
  Embed run, as a robust alternative to the embedded tags.
- Fix camera detection to key off each image's immediate parent dir, so
  embedding a parent folder that groups several sessions no longer mis-tags
  every RGB frame as nadir (dropping the off-nadir mounting angle).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
NoahC 2026-06-26 17:06:41 +02:00
parent 90a9059544
commit 80f2e92d24
6 changed files with 641 additions and 31 deletions

View File

@ -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 * maps the structured fields to real EXIF/XMP tags so the images are
georeferenced and usable in mapping / photogrammetry software: georeferenced and usable in mapping / photogrammetry software:
- GPS lat/lon/alt -> EXIF GPS* tags - GPS lat/lon/alt -> EXIF GPS* tags
- platform yaw -> EXIF GPSImgDirection + XMP-Camera:PoseHeadingDegrees - optical-axis azimuth -> EXIF GPSImgDirection
- pitch / roll -> XMP-Camera:PosePitch/PoseRollDegrees - camera attitude -> XMP-Camera:Yaw/Pitch/Roll (Pix4D/Metashape
convention: pitch 0 = nadir, +90 = forward)
- GPS week/tow -> DateTimeOriginal + GPSDateStamp/GPSTimeStamp (UTC) - GPS week/tow -> DateTimeOriginal + GPSDateStamp/GPSTimeStamp (UTC)
- lens / exposure / camera -> FocalLength, ExposureTime, Make/Model/Serial - lens / exposure / camera -> FocalLength, ExposureTime, Make/Model/Serial
* stores the COMPLETE original JSON in EXIF:UserComment so nothing is lost. * stores the COMPLETE original JSON in EXIF:UserComment so nothing is lost.
@ -32,6 +33,7 @@ Usage:
from __future__ import annotations from __future__ import annotations
import argparse import argparse
import csv
import datetime as dt import datetime as dt
import json import json
import os 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 GPS_UTC_LEAP_SECONDS = 18 # GPS-UTC offset as of 2017-2026
WORKER_CAP = 16 # default upper bound on parallel workers 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 # 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 = scratch_dir / f"_exif_args_{k}.txt"
argfile.write_text("\n".join(arg_lines) + "\n", encoding="utf-8") argfile.write_text("\n".join(arg_lines) + "\n", encoding="utf-8")
proc = subprocess.Popen( 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) stdout=subprocess.PIPE, stderr=subprocess.STDOUT, text=True)
for line in proc.stdout: # type: ignore[union-attr] for line in proc.stdout: # type: ignore[union-attr]
line = line.rstrip() 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. # not the bare IMU. Computed by the shared geometry so EXIF matches the map.
imu = data.get("imu") or {} imu = data.get("imu") or {}
plat = data.get("platform") or {} plat = data.get("platform") or {}
ori = None geo = None
if None not in (imu.get("yaw"), imu.get("pitch"), imu.get("roll")): 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", "kind": "cam" if cam_id else "thermal",
"off_nadir": smap.off_nadir_from_name(cam_id) if cam_id else 0.0, "off_nadir": smap.off_nadir_from_name(cam_id) if cam_id else 0.0,
"yaw": imu["yaw"], "pitch": imu["pitch"], "roll": imu["roll"], "yaw": imu["yaw"], "pitch": imu["pitch"], "roll": imu["roll"],
"platform_angle": plat.get("platform_angle_deg"), "platform_angle": plat.get("platform_angle_deg"),
}) }
if ori: if geo:
heading, pitch, roll = ori # Compass azimuth of the optical axis -> standard GPSImgDirection.
tags += [f"-GPSImgDirection={heading % 360:.4f}", "-GPSImgDirectionRef=T", ori = smap.camera_orientation(geo)
f"-XMP-GPano:PoseHeadingDegrees={heading % 360:.4f}", if ori:
f"-XMP-GPano:PosePitchDegrees={pitch:.4f}", heading = ori[0] % 360
f"-XMP-GPano:PoseRollDegrees={roll:.4f}"] 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) ------------------------ # --- timestamp (UTC, derived from GPS week/tow) ------------------------
g = data.get("gps") or {} 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 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) # 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 return pairs
def cam_id_for(img: Path) -> str | None:
"""The cam<NN> 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: def load_manifest_cams(session: Path) -> dict:
"""camera id -> manifest entry (model / serial), if a manifest exists.""" """camera id -> manifest entry (model / serial), if a manifest exists."""
cams: dict = {} cams: dict = {}
@ -293,15 +405,23 @@ def embed_session(session, out_root=None, gps_source="position", exiftool=None,
if not pairs: if not pairs:
raise RuntimeError(f"No image+json pairs found under {session}") raise RuntimeError(f"No image+json pairs found under {session}")
cams = load_manifest_cams(session) cams = load_manifest_cams(session)
et = exiftool or ensure_exiftool(None, Path(__file__).resolve().parent / "tools")
total = len(pairs) total = len(pairs)
log(f"Session : {session}") log(f"Session : {session}")
log(f"Output : {out_root}") log(f"Output : {out_root}")
log(f"GPS source : {gps_source} (cam frames; thermal always uses raw gps)") log(f"GPS source : {gps_source} (cam frames; thermal always uses raw gps)")
log(f"exiftool : {et}")
log(f"Workers : {workers}") 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) # 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}: 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.") raise RuntimeError("Cancelled by user.")
img, sidecar = pair img, sidecar = pair
rel = img.relative_to(session) 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: try:
data = json.loads(sidecar.read_text(encoding="utf-8")) data = json.loads(sidecar.read_text(encoding="utf-8"))
except Exception as e: # noqa: BLE001 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.") "(metadata is written via a temp copy) - free up space and re-run.")
progress(1.0, "Done") progress(1.0, "Done")
log(f"\nDone. {updated} files tagged.\nOutput: {out_root}") log(f"\nDone. {updated} files tagged.\nOutput: {out_root}"
return {"out_root": out_root, "pairs": total, "updated": updated} 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: if not pairs:
sys.exit(f"No image+json pairs found under {session}") sys.exit(f"No image+json pairs found under {session}")
img, sidecar = pairs[0] img, sidecar = pairs[0]
cam_id = img.relative_to(session).parts[0] cam_id = cam_id_for(img)
cam_id = cam_id if cam_id.startswith("cam") else None
data = json.loads(sidecar.read_text(encoding="utf-8")) data = json.loads(sidecar.read_text(encoding="utf-8"))
print(f"DRY RUN: {len(pairs)} pairs under {session}") print(f"DRY RUN: {len(pairs)} pairs under {session}")
print("\n--- sample tags (first frame) ---") print("\n--- sample tags (first frame) ---")

View File

@ -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

View File

@ -43,6 +43,7 @@ sys.path.insert(0, str(APP_DIR))
import embed_metadata as embed # noqa: E402 import embed_metadata as embed # noqa: E402
import stretch_thermal as stretch # noqa: E402 import stretch_thermal as stretch # noqa: E402
import session_map as smap # noqa: E402 import session_map as smap # noqa: E402
import map_export # noqa: E402
try: try:
from tkintermapview import TkinterMapView # noqa: E402 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 " "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 " "JSON record in the comment field. The tagged copies are compatible with "
"photogrammetry and GIS applications (Pix4D, Metashape, QGIS), which read the " "photogrammetry and GIS applications (Pix4D, Metashape, QGIS), which read the "
"embedded GPS to position each image. Requires exiftool, which is located " "embedded GPS and camera orientation to position each image. It also writes a "
"automatically and installed on first use if it is not already present." "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)) ).pack(fill="x", pady=(4, 10))
self.embed_session = tk.StringVar(self, value=autodetect_session()) 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") 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 = 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.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.") self.map_status = tk.StringVar(self, value="Idle.")
ttk.Label(left, textvariable=self.map_status, wraplength=230, ttk.Label(left, textvariable=self.map_status, wraplength=230,
foreground="#555").pack(anchor="w") foreground="#555").pack(anchor="w")
@ -389,29 +403,37 @@ class FireMapperGUI(tk.Tk):
ttk.Label(self.session_list_frame, text="(no sessions found)", ttk.Label(self.session_list_frame, text="(no sessions found)",
foreground="#777").pack(anchor="w") foreground="#777").pack(anchor="w")
def _start_plot_map(self): def _read_map_inputs(self):
if self.running: """Parse the shared map options, or None (after warning) if invalid."""
return
sel = [s for s, v in self.session_vars.items() if v.get()] sel = [s for s, v in self.session_vars.items() if v.get()]
if not sel: if not sel:
messagebox.showwarning("No sessions", "Select at least one session to display.") messagebox.showwarning("No sessions", "Select at least one session to display.")
return return None
try: try:
ground = float(self.map_ground.get()) ground = float(self.map_ground.get())
except ValueError: except ValueError:
messagebox.showwarning("Ground elevation", "Ground elevation must be a number (m).") messagebox.showwarning("Ground elevation", "Ground elevation must be a number (m).")
return return None
tfov = None tfov = None
if self.map_tfov_h.get().strip() and self.map_tfov_v.get().strip(): if self.map_tfov_h.get().strip() and self.map_tfov_v.get().strip():
try: try:
tfov = (float(self.map_tfov_h.get()), float(self.map_tfov_v.get())) tfov = (float(self.map_tfov_h.get()), float(self.map_tfov_v.get()))
except ValueError: except ValueError:
messagebox.showwarning("Thermal FOV", "Thermal FOV must be numbers (degrees).") messagebox.showwarning("Thermal FOV", "Thermal FOV must be numbers (degrees).")
return return None
try: try:
toff = float(self.map_toff.get() or 0) toff = float(self.map_toff.get() or 0)
except ValueError: except ValueError:
toff = 0.0 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, opts = dict(sessions=sel, ground=ground, tfov=tfov, toff=toff,
step_raw=self.map_step.get().strip().lower(), step_raw=self.map_step.get().strip().lower(),
markers=self.map_markers.get(), foot=self.map_foot.get(), markers=self.map_markers.get(), foot=self.map_foot.get(),
@ -449,6 +471,39 @@ class FireMapperGUI(tk.Tk):
self.q.put(("maperror", str(e))) self.q.put(("maperror", str(e)))
threading.Thread(target=work, daemon=True).start() 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 @staticmethod
def _dot(color, d=9): def _dot(color, d=9):
"""A small round marker icon (PhotoImage) of the given colour.""" """A small round marker icon (PhotoImage) of the given colour."""
@ -598,6 +653,12 @@ class FireMapperGUI(tk.Tk):
self._show_preview(msg[1]) self._show_preview(msg[1])
elif kind == "map": elif kind == "map":
self._render_map(msg[1]) 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": elif kind == "maperror":
self._set_running(False) self._set_running(False)
self.map_status.set("Error.") self.map_status.set("Error.")

View File

@ -163,6 +163,12 @@ new folder.</p>
<kbd>Refresh</kbd> rescans the folder). Several flight strips may be shown together.</li> <kbd>Refresh</kbd> rescans the folder). Several flight strips may be shown together.</li>
<li>Set the <b>options</b> (described below), then click <b>Show on map</b>. The map <li>Set the <b>options</b> (described below), then click <b>Show on map</b>. The map
zooms to fit the data.</li> zooms to fit the data.</li>
<li>For a complete view, click <b>Open full map in browser</b>. This renders
<b>every</b> 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.</li>
</ol> </ol>
<h3>Options</h3> <h3>Options</h3>
<table> <table>
@ -171,8 +177,9 @@ new folder.</p>
<tr><th>Thermal FOV H/V</th><td>Field of view of the thermal camera, required for its <tr><th>Thermal FOV H/V</th><td>Field of view of the thermal camera, required for its
footprints. Pre-filled for the FLIR A65 25° lens (25° × 20°).</td></tr> footprints. Pre-filled for the FLIR A65 25° lens (25° × 20°).</td></tr>
<tr><th>Thermal off-nadir</th><td>Mounting tilt of the thermal camera (0 = straight down).</td></tr> <tr><th>Thermal off-nadir</th><td>Mounting tilt of the thermal camera (0 = straight down).</td></tr>
<tr><th>Plot every Nth</th><td><i>auto</i> reduces very large surveys so the map remains <tr><th>Plot every Nth</th><td>Applies to the in-app <b>Show on map</b> only: <i>auto</i>
responsive; enter a number to override.</td></tr> reduces very large surveys so the embedded map remains responsive; enter a number to
override. <b>Open full map in browser</b> ignores this and shows everything.</td></tr>
<tr><th>Trigger points / Footprints / Cameras / Thermal</th><td>Control which layers are drawn.</td></tr> <tr><th>Trigger points / Footprints / Cameras / Thermal</th><td>Control which layers are drawn.</td></tr>
</table> </table>
<div class="note"><b>Needs an internet connection</b> for the map tiles.</div> <div class="note"><b>Needs an internet connection</b> for the map tiles.</div>

350
map_export.py Normal file
View File

@ -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<NN> 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<NN>'."""
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"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>__TITLE__</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"/>
<style>
html, body { height: 100%; margin: 0; }
#map { height: 100%; width: 100%; background: #e8e8e8; }
.fm-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid #fff;
box-sizing: border-box; }
.fm-pop { font: 12px/1.4 "Segoe UI", system-ui, sans-serif; border-collapse: collapse; }
.fm-pop td { padding: 1px 6px 1px 0; vertical-align: top; }
.fm-pop td:first-child { color: #666; font-weight: 600; white-space: nowrap; }
.fm-panel {
font: 13px/1.35 "Segoe UI", system-ui, sans-serif; color: #222;
background: rgba(255,255,255,0.96); padding: 8px 10px; border-radius: 6px;
box-shadow: 0 1px 5px rgba(0,0,0,0.4); max-height: 80vh; overflow-y: auto;
min-width: 180px;
}
.fm-panel h4 { margin: 0 0 4px; font-size: 13px; }
.fm-panel .sub { color: #777; margin: 6px 0 2px; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.04em; }
.fm-panel label { display: block; cursor: pointer; white-space: nowrap; }
.fm-panel .mini { font-size: 11px; }
.fm-panel a { color: #1565c0; cursor: pointer; text-decoration: none; }
.fm-panel a:hover { text-decoration: underline; }
.fm-sw { display: inline-block; width: 10px; height: 10px; border-radius: 50%;
border: 1px solid #fff; vertical-align: middle; margin: 0 4px; }
.fm-count { color: #555; margin-top: 6px; font-size: 11px; }
.fm-sessions { max-height: 34vh; overflow-y: auto; margin-top: 2px;
border-top: 1px solid #eee; padding-top: 3px; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<script>
const DATA = __DATA__;
const COLORS = DATA.colors;
const map = L.map('map', { preferCanvas: true });
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
// Per-session layers: footprint featureGroups keyed by camera, plus a marker cluster.
const layers = {}; // session -> { foot: {kind: featureGroup}, cluster }
function ensure(s) {
if (!layers[s]) {
layers[s] = {
foot: {},
cluster: L.markerClusterGroup({ chunkedLoading: true, maxClusterRadius: 50 })
};
}
return layers[s];
}
function ensureFoot(s, k) {
const o = ensure(s);
if (!o.foot[k]) o.foot[k] = L.featureGroup();
return o.foot[k];
}
function popupHtml(p) {
let h = '<table class="fm-pop">';
for (const k in p) h += '<tr><td>' + k + '</td><td>' + p[k] + '</td></tr>';
return h + '</table>';
}
const allLatLngs = [];
for (const f of DATA.foots) {
const poly = L.polygon(f.c, {
color: COLORS[f.k] || '#333', weight: 1, opacity: 0.85,
fill: true, fillOpacity: 0.06
}).bindPopup(popupHtml(f.p));
ensureFoot(f.s, f.k).addLayer(poly);
for (const ll of f.c) allLatLngs.push(ll);
}
for (const m of DATA.markers) {
const mk = L.marker(m.ll, {
icon: L.divIcon({
className: '', iconSize: [10, 10], iconAnchor: [5, 5],
html: '<div class="fm-dot" style="background:' + (COLORS[m.k] || '#333') + '"></div>'
})
}).bindPopup(popupHtml(m.p));
ensure(m.s).cluster.addLayer(mk);
allLatLngs.push(m.ll);
}
// ----- visibility state -------------------------------------------------- //
const state = { footKinds: {}, trig: true, sessions: {} };
for (const k of DATA.layers) state.footKinds[k] = true;
for (const s of DATA.sessions) state.sessions[s] = true;
function setLayer(layer, show) {
if (show) { if (!map.hasLayer(layer)) map.addLayer(layer); }
else { if (map.hasLayer(layer)) map.removeLayer(layer); }
}
function refresh() {
for (const s in layers) {
const on = state.sessions[s];
for (const k in layers[s].foot) setLayer(layers[s].foot[k], on && state.footKinds[k]);
setLayer(layers[s].cluster, on && state.trig);
}
}
// ----- control panel ----------------------------------------------------- //
const panel = L.control({ position: 'topright' });
panel.onAdd = function () {
const div = L.DomUtil.create('div', 'fm-panel');
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
let html = '<h4>Layers</h4>';
html += '<div class="sub">Footprints (per camera)</div>';
for (const k of DATA.layers) {
html += '<label><input type="checkbox" class="fm-fk" value="' + k + '" checked>'
+ '<span class="fm-sw" style="background:' + COLORS[k] + '"></span>' + k + '</label>';
}
html += '<div class="sub">Triggers</div>';
html += '<label><input type="checkbox" id="fm-trig" checked> Trigger points (all)</label>';
html += '<div class="sub">Sessions &nbsp;'
+ '<a id="fm-all">all</a> / <a id="fm-none">none</a></div>';
html += '<div class="fm-sessions" id="fm-sess">';
for (const s of DATA.sessions) {
html += '<label class="mini"><input type="checkbox" class="fm-s" value="'
+ s + '" checked> ' + s + '</label>';
}
html += '</div>';
html += '<div class="fm-count"><a id="fm-fit">Fit to all</a> &nbsp;|&nbsp; '
+ DATA.markers.length + ' points, ' + DATA.foots.length + ' footprints</div>';
div.innerHTML = html;
return div;
};
panel.addTo(map);
for (const cb of document.querySelectorAll('.fm-fk')) {
cb.onchange = e => { state.footKinds[e.target.value] = e.target.checked; refresh(); };
}
document.getElementById('fm-trig').onchange = e => { state.trig = e.target.checked; refresh(); };
for (const cb of document.querySelectorAll('.fm-s')) {
cb.onchange = e => { state.sessions[e.target.value] = e.target.checked; refresh(); };
}
function setAllSessions(v) {
for (const cb of document.querySelectorAll('.fm-s')) {
cb.checked = v; state.sessions[cb.value] = v;
}
refresh();
}
document.getElementById('fm-all').onclick = () => setAllSessions(true);
document.getElementById('fm-none').onclick = () => setAllSessions(false);
function fitAll() {
if (allLatLngs.length) map.fitBounds(L.latLngBounds(allLatLngs).pad(0.05));
else map.setView([49.32, 8.43], 12);
}
document.getElementById('fm-fit').onclick = fitAll;
refresh();
fitAll();
</script>
</body>
</html>
"""
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:]))

View File

@ -205,6 +205,38 @@ def camera_orientation(frame: dict):
return heading, pitch, roll 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<NN> 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, def footprint(frame: dict, ground_elev: float,
thermal_fov_deg: tuple[float, float] | None = None) -> list[tuple[float, float]] | None: 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.""" """Four (lat, lon) ground corners of the image, or None if not projectable."""