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:
parent
90a9059544
commit
80f2e92d24
|
|
@ -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<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:
|
||||
"""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) ---")
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
@ -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.")
|
||||
|
|
|
|||
11
manual.html
11
manual.html
|
|
@ -163,6 +163,12 @@ new folder.</p>
|
|||
<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
|
||||
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>
|
||||
<h3>Options</h3>
|
||||
<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
|
||||
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>Plot every Nth</th><td><i>auto</i> reduces very large surveys so the map remains
|
||||
responsive; enter a number to override.</td></tr>
|
||||
<tr><th>Plot every Nth</th><td>Applies to the in-app <b>Show on map</b> only: <i>auto</i>
|
||||
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>
|
||||
</table>
|
||||
<div class="note"><b>Needs an internet connection</b> for the map tiles.</div>
|
||||
|
|
|
|||
|
|
@ -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: '© 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 '
|
||||
+ '<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> | '
|
||||
+ 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:]))
|
||||
|
|
@ -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<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,
|
||||
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."""
|
||||
|
|
|
|||
Loading…
Reference in New Issue