FireMapper_Postprocess/map_export.py

351 lines
13 KiB
Python

#!/usr/bin/env python3
"""
map_export.py - export a FireMapper session map as a standalone interactive
Leaflet HTML page.
Unlike the in-app tkintermapview view (which subsamples to a few hundred frames
to stay responsive), this renders *every* trigger point and footprint at once.
It stays legible at that scale by:
* clustering the trigger markers (leaflet.markercluster),
* drawing footprints on a canvas renderer (fast with thousands of polygons),
* giving per-session and per-camera (cam25 / cam45 / thermal / ...) toggles,
* showing each frame's metadata in a click popup.
Each camera is its own layer (keyed by its cam<NN> off-nadir angle) with its own
colour, so its footprints can be switched on and off independently. Leaflet + the
markercluster plugin are loaded from a CDN, so an internet connection is required
- the OpenStreetMap tiles need one anyway.
Used by firemapper_gui.py ("Open full map in browser"); also runnable directly:
python map_export.py [SESSION ...]
"""
from __future__ import annotations
import json
import tempfile
import webbrowser
from pathlib import Path
import session_map as smap
THERMAL_COLOR = "#e65100" # thermal keeps the established orange
# distinct colours handed to the cameras in ascending off-nadir order
_CAM_PALETTE = ["#1565c0", "#2e7d32", "#6a1b9a", "#c62828",
"#00838f", "#9e9d24", "#4527a0"]
# --------------------------------------------------------------------------- #
# layers & colours
# --------------------------------------------------------------------------- #
def _layer_key(frame: dict) -> str:
"""The toggle layer a frame belongs to: 'thermal' or 'cam<NN>'."""
if frame["kind"] == "thermal":
return "thermal"
return f"cam{int(frame['off_nadir'])}"
def _ordered_layers(keys: set[str]) -> list[str]:
"""Cameras first (ascending off-nadir), thermal last."""
cams = sorted((k for k in keys if k != "thermal"), key=lambda k: int(k[3:]))
return cams + (["thermal"] if "thermal" in keys else [])
def _assign_colors(ordered: list[str]) -> dict[str, str]:
colors: dict[str, str] = {}
ci = 0
for lk in ordered:
if lk == "thermal":
colors[lk] = THERMAL_COLOR
else:
colors[lk] = _CAM_PALETTE[ci % len(_CAM_PALETTE)]
ci += 1
return colors
# --------------------------------------------------------------------------- #
# feature building
# --------------------------------------------------------------------------- #
def _feature_props(frame: dict) -> dict:
"""Human-readable metadata for a frame's click popup (ordered)."""
kind = frame["kind"]
label = f"cam{int(frame['off_nadir'])}" if kind == "cam" else kind
props: dict[str, str] = {
"Session": frame["session"],
"Kind": label,
"Lat": f"{frame['lat']:.6f}",
"Lon": f"{frame['lon']:.6f}",
}
if frame.get("alt") is not None:
props["Alt (m)"] = f"{frame['alt']:.1f}"
ori = smap.camera_orientation(frame)
if ori:
props["Heading"] = f"{ori[0]:.1f} deg"
props["Pitch"] = f"{ori[1]:.1f} deg"
props["Roll"] = f"{ori[2]:.1f} deg"
if frame.get("platform_angle") is not None:
props["Scan angle"] = f"{frame['platform_angle']:.1f} deg"
return props
def build_payload(frames: list[dict], *, ground_elev: float,
thermal_fov_deg: tuple[float, float] | None,
include_markers: bool, include_footprints: bool) -> dict:
"""Turn frames into the compact JSON payload the HTML page consumes."""
sessions: list[str] = []
seen_s: set[str] = set()
seen_k: set[str] = set()
markers: list[dict] = []
foots: list[dict] = []
for f in frames:
s = f["session"]
if s not in seen_s:
seen_s.add(s)
sessions.append(s)
lk = _layer_key(f)
seen_k.add(lk)
props = _feature_props(f)
if include_markers:
markers.append({"s": s, "k": lk, "ll": [f["lat"], f["lon"]], "p": props})
if include_footprints:
corners = smap.footprint(f, ground_elev, thermal_fov_deg=thermal_fov_deg)
if corners:
foots.append({"s": s, "k": lk, "c": corners, "p": props})
layers = _ordered_layers(seen_k)
return {"sessions": sorted(sessions), "markers": markers, "foots": foots,
"layers": layers, "colors": _assign_colors(layers)}
# --------------------------------------------------------------------------- #
# HTML template
# --------------------------------------------------------------------------- #
_TEMPLATE = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>__TITLE__</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.css"/>
<link rel="stylesheet" href="https://unpkg.com/leaflet.markercluster@1.5.3/dist/MarkerCluster.Default.css"/>
<style>
html, body { height: 100%; margin: 0; }
#map { height: 100%; width: 100%; background: #e8e8e8; }
.fm-dot { width: 10px; height: 10px; border-radius: 50%; border: 1px solid #fff;
box-sizing: border-box; }
.fm-pop { font: 12px/1.4 "Segoe UI", system-ui, sans-serif; border-collapse: collapse; }
.fm-pop td { padding: 1px 6px 1px 0; vertical-align: top; }
.fm-pop td:first-child { color: #666; font-weight: 600; white-space: nowrap; }
.fm-panel {
font: 13px/1.35 "Segoe UI", system-ui, sans-serif; color: #222;
background: rgba(255,255,255,0.96); padding: 8px 10px; border-radius: 6px;
box-shadow: 0 1px 5px rgba(0,0,0,0.4); max-height: 80vh; overflow-y: auto;
min-width: 180px;
}
.fm-panel h4 { margin: 0 0 4px; font-size: 13px; }
.fm-panel .sub { color: #777; margin: 6px 0 2px; font-size: 11px;
text-transform: uppercase; letter-spacing: 0.04em; }
.fm-panel label { display: block; cursor: pointer; white-space: nowrap; }
.fm-panel .mini { font-size: 11px; }
.fm-panel a { color: #1565c0; cursor: pointer; text-decoration: none; }
.fm-panel a:hover { text-decoration: underline; }
.fm-sw { display: inline-block; width: 10px; height: 10px; border-radius: 50%;
border: 1px solid #fff; vertical-align: middle; margin: 0 4px; }
.fm-count { color: #555; margin-top: 6px; font-size: 11px; }
.fm-sessions { max-height: 34vh; overflow-y: auto; margin-top: 2px;
border-top: 1px solid #eee; padding-top: 3px; }
</style>
</head>
<body>
<div id="map"></div>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<script src="https://unpkg.com/leaflet.markercluster@1.5.3/dist/leaflet.markercluster.js"></script>
<script>
const DATA = __DATA__;
const COLORS = DATA.colors;
const map = L.map('map', { preferCanvas: true });
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', {
maxZoom: 19, attribution: '&copy; OpenStreetMap contributors'
}).addTo(map);
// Per-session layers: footprint featureGroups keyed by camera, plus a marker cluster.
const layers = {}; // session -> { foot: {kind: featureGroup}, cluster }
function ensure(s) {
if (!layers[s]) {
layers[s] = {
foot: {},
cluster: L.markerClusterGroup({ chunkedLoading: true, maxClusterRadius: 50 })
};
}
return layers[s];
}
function ensureFoot(s, k) {
const o = ensure(s);
if (!o.foot[k]) o.foot[k] = L.featureGroup();
return o.foot[k];
}
function popupHtml(p) {
let h = '<table class="fm-pop">';
for (const k in p) h += '<tr><td>' + k + '</td><td>' + p[k] + '</td></tr>';
return h + '</table>';
}
const allLatLngs = [];
for (const f of DATA.foots) {
const poly = L.polygon(f.c, {
color: COLORS[f.k] || '#333', weight: 1, opacity: 0.85,
fill: true, fillOpacity: 0.06
}).bindPopup(popupHtml(f.p));
ensureFoot(f.s, f.k).addLayer(poly);
for (const ll of f.c) allLatLngs.push(ll);
}
for (const m of DATA.markers) {
const mk = L.marker(m.ll, {
icon: L.divIcon({
className: '', iconSize: [10, 10], iconAnchor: [5, 5],
html: '<div class="fm-dot" style="background:' + (COLORS[m.k] || '#333') + '"></div>'
})
}).bindPopup(popupHtml(m.p));
ensure(m.s).cluster.addLayer(mk);
allLatLngs.push(m.ll);
}
// ----- visibility state -------------------------------------------------- //
const state = { footKinds: {}, trig: true, sessions: {} };
for (const k of DATA.layers) state.footKinds[k] = true;
for (const s of DATA.sessions) state.sessions[s] = true;
function setLayer(layer, show) {
if (show) { if (!map.hasLayer(layer)) map.addLayer(layer); }
else { if (map.hasLayer(layer)) map.removeLayer(layer); }
}
function refresh() {
for (const s in layers) {
const on = state.sessions[s];
for (const k in layers[s].foot) setLayer(layers[s].foot[k], on && state.footKinds[k]);
setLayer(layers[s].cluster, on && state.trig);
}
}
// ----- control panel ----------------------------------------------------- //
const panel = L.control({ position: 'topright' });
panel.onAdd = function () {
const div = L.DomUtil.create('div', 'fm-panel');
L.DomEvent.disableClickPropagation(div);
L.DomEvent.disableScrollPropagation(div);
let html = '<h4>Layers</h4>';
html += '<div class="sub">Footprints (per camera)</div>';
for (const k of DATA.layers) {
html += '<label><input type="checkbox" class="fm-fk" value="' + k + '" checked>'
+ '<span class="fm-sw" style="background:' + COLORS[k] + '"></span>' + k + '</label>';
}
html += '<div class="sub">Triggers</div>';
html += '<label><input type="checkbox" id="fm-trig" checked> Trigger points (all)</label>';
html += '<div class="sub">Sessions &nbsp;'
+ '<a id="fm-all">all</a> / <a id="fm-none">none</a></div>';
html += '<div class="fm-sessions" id="fm-sess">';
for (const s of DATA.sessions) {
html += '<label class="mini"><input type="checkbox" class="fm-s" value="'
+ s + '" checked> ' + s + '</label>';
}
html += '</div>';
html += '<div class="fm-count"><a id="fm-fit">Fit to all</a> &nbsp;|&nbsp; '
+ DATA.markers.length + ' points, ' + DATA.foots.length + ' footprints</div>';
div.innerHTML = html;
return div;
};
panel.addTo(map);
for (const cb of document.querySelectorAll('.fm-fk')) {
cb.onchange = e => { state.footKinds[e.target.value] = e.target.checked; refresh(); };
}
document.getElementById('fm-trig').onchange = e => { state.trig = e.target.checked; refresh(); };
for (const cb of document.querySelectorAll('.fm-s')) {
cb.onchange = e => { state.sessions[e.target.value] = e.target.checked; refresh(); };
}
function setAllSessions(v) {
for (const cb of document.querySelectorAll('.fm-s')) {
cb.checked = v; state.sessions[cb.value] = v;
}
refresh();
}
document.getElementById('fm-all').onclick = () => setAllSessions(true);
document.getElementById('fm-none').onclick = () => setAllSessions(false);
function fitAll() {
if (allLatLngs.length) map.fitBounds(L.latLngBounds(allLatLngs).pad(0.05));
else map.setView([49.32, 8.43], 12);
}
document.getElementById('fm-fit').onclick = fitAll;
refresh();
fitAll();
</script>
</body>
</html>
"""
def build_html(payload: dict, *, title: str = "FireMapper map") -> str:
return (_TEMPLATE
.replace("__TITLE__", title)
.replace("__DATA__", json.dumps(payload, separators=(",", ":"))))
def export(frames: list[dict], *, ground_elev: float = 110.0,
thermal_fov_deg: tuple[float, float] | None = smap.THERMAL_FOV_DEFAULT,
include_markers: bool = True, include_footprints: bool = True,
out_path: Path | None = None, title: str = "FireMapper map",
open_browser: bool = True) -> dict:
"""Build the HTML map for `frames`, write it, and (optionally) open it.
Returns {"path", "markers", "footprints"}.
"""
payload = build_payload(frames, ground_elev=ground_elev,
thermal_fov_deg=thermal_fov_deg,
include_markers=include_markers,
include_footprints=include_footprints)
html = build_html(payload, title=title)
if out_path is None:
out_path = Path(tempfile.gettempdir()) / "firemapper_map.html"
out_path = Path(out_path)
out_path.write_text(html, encoding="utf-8")
if open_browser:
webbrowser.open(out_path.as_uri())
return {"path": str(out_path),
"markers": len(payload["markers"]),
"footprints": len(payload["foots"])}
# --------------------------------------------------------------------------- #
def _main(argv: list[str]) -> int:
args = [a for a in argv if not a.startswith("-")]
if args:
sessions = [Path(a) for a in args]
else:
here = Path(__file__).resolve().parent
sessions = smap.list_sessions(here)
if not sessions:
print("No sessions found.")
return 1
frames: list[dict] = []
for s in sessions:
frames += smap.iter_session_frames(s)
if not frames:
print("No frames with GPS in the selected sessions.")
return 1
res = export(frames)
print(f"Wrote {res['path']} ({res['markers']} points, "
f"{res['footprints']} footprints)")
return 0
if __name__ == "__main__":
import sys
raise SystemExit(_main(sys.argv[1:]))