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
|
* 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 geo:
|
||||||
|
# Compass azimuth of the optical axis -> standard GPSImgDirection.
|
||||||
|
ori = smap.camera_orientation(geo)
|
||||||
if ori:
|
if ori:
|
||||||
heading, pitch, roll = ori
|
heading = ori[0] % 360
|
||||||
tags += [f"-GPSImgDirection={heading % 360:.4f}", "-GPSImgDirectionRef=T",
|
tags += [f"-GPSImgDirection={heading:.4f}", "-GPSImgDirectionRef=T"]
|
||||||
f"-XMP-GPano:PoseHeadingDegrees={heading % 360:.4f}",
|
# Camera attitude in the Pix4D/Metashape convention (pitch 0 = nadir,
|
||||||
f"-XMP-GPano:PosePitchDegrees={pitch:.4f}",
|
# +90 = forward) -> XMP-Camera:Yaw/Pitch/Roll, the tags photogrammetry
|
||||||
f"-XMP-GPano:PoseRollDegrees={roll:.4f}"]
|
# 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) ---")
|
||||||
|
|
|
||||||
|
|
@ -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 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.")
|
||||||
|
|
|
||||||
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>
|
<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>
|
||||||
|
|
|
||||||
|
|
@ -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
|
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."""
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue