From 80f2e92d24d5e7ebf0d579698cf26e8f4b42795c Mon Sep 17 00:00:00 2001 From: NoahC Date: Fri, 26 Jun 2026 17:06:41 +0200 Subject: [PATCH] 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 --- embed_metadata.py | 160 ++++++++++++++--- firemapper.ExifTool_config | 40 +++++ firemapper_gui.py | 79 ++++++++- manual.html | 11 +- map_export.py | 350 +++++++++++++++++++++++++++++++++++++ session_map.py | 32 ++++ 6 files changed, 641 insertions(+), 31 deletions(-) create mode 100644 firemapper.ExifTool_config create mode 100644 map_export.py 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/VField of view of the thermal camera, required for its footprints. Pre-filled for the FLIR A65 25° lens (25° × 20°).
    Thermal off-nadirMounting tilt of the thermal camera (0 = straight down).
    Plot every Nthauto reduces very large surveys so the map remains - responsive; enter a number to override.
    Plot every NthApplies 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 / ThermalControl 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."""