|
| 1 | +"""KML 2.2 LineString (+ gx:Track) export for Google Earth.""" |
| 2 | + |
| 3 | +from __future__ import annotations |
| 4 | + |
| 5 | +import xml.etree.ElementTree as ET |
| 6 | +from pathlib import Path |
| 7 | +from typing import List, Union |
| 8 | +from xml.dom import minidom |
| 9 | + |
| 10 | +from .models import TelemetryFrame |
| 11 | + |
| 12 | +PathLike = Union[str, Path] |
| 13 | + |
| 14 | +KML_NS = "http://www.opengis.net/kml/2.2" |
| 15 | +GX_NS = "http://www.google.com/kml/ext/2.2" |
| 16 | + |
| 17 | + |
| 18 | +def _iso_time(frame: TelemetryFrame) -> str: |
| 19 | + if frame.datetime_utc is not None: |
| 20 | + return frame.datetime_utc.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" |
| 21 | + from datetime import datetime, timedelta, timezone |
| 22 | + |
| 23 | + t = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta( |
| 24 | + milliseconds=frame.start_ms |
| 25 | + ) |
| 26 | + return t.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z" |
| 27 | + |
| 28 | + |
| 29 | +def _altitude_mode(frames: List[TelemetryFrame]) -> str: |
| 30 | + """Match altitudeMode to the dominant altitude source.""" |
| 31 | + if not frames: |
| 32 | + return "absolute" |
| 33 | + abs_n = sum(1 for f in frames if f.altitude_source == "absolute") |
| 34 | + return "absolute" if abs_n >= len(frames) / 2 else "relativeToGround" |
| 35 | + |
| 36 | + |
| 37 | +def frames_to_kml(frames: List[TelemetryFrame], name: str = "DJI flight track") -> str: |
| 38 | + """Build KML 2.2 with a LineString path and a gx:Track when times exist.""" |
| 39 | + ET.register_namespace("", KML_NS) |
| 40 | + ET.register_namespace("gx", GX_NS) |
| 41 | + |
| 42 | + kml = ET.Element(f"{{{KML_NS}}}kml") |
| 43 | + doc = ET.SubElement(kml, f"{{{KML_NS}}}Document") |
| 44 | + ET.SubElement(doc, f"{{{KML_NS}}}name").text = name |
| 45 | + ET.SubElement(doc, f"{{{KML_NS}}}description").text = ( |
| 46 | + "Flight track exported from DJI SRT telemetry for Google Earth. " |
| 47 | + "Coordinates and altitudes come from video subtitle metadata only; " |
| 48 | + "this is not a flight-controller log. Speed may be recorded or " |
| 49 | + "estimated from successive GPS points." |
| 50 | + ) |
| 51 | + |
| 52 | + alt_mode = _altitude_mode(frames) |
| 53 | + |
| 54 | + # LineString placemark (always) |
| 55 | + pm = ET.SubElement(doc, f"{{{KML_NS}}}Placemark") |
| 56 | + ET.SubElement(pm, f"{{{KML_NS}}}name").text = f"{name} (path)" |
| 57 | + ET.SubElement(pm, f"{{{KML_NS}}}description").text = ( |
| 58 | + f"{len(frames)} points; altitudeMode={alt_mode}" |
| 59 | + ) |
| 60 | + line = ET.SubElement(pm, f"{{{KML_NS}}}LineString") |
| 61 | + ET.SubElement(line, f"{{{KML_NS}}}altitudeMode").text = alt_mode |
| 62 | + ET.SubElement(line, f"{{{KML_NS}}}tessellate").text = "1" |
| 63 | + coords = " ".join( |
| 64 | + f"{f.longitude:.7f},{f.latitude:.7f},{f.altitude_m:.3f}" for f in frames |
| 65 | + ) |
| 66 | + ET.SubElement(line, f"{{{KML_NS}}}coordinates").text = coords |
| 67 | + |
| 68 | + # gx:Track with when/gx:coord |
| 69 | + track_pm = ET.SubElement(doc, f"{{{KML_NS}}}Placemark") |
| 70 | + ET.SubElement(track_pm, f"{{{KML_NS}}}name").text = f"{name} (track)" |
| 71 | + track = ET.SubElement(track_pm, f"{{{GX_NS}}}Track") |
| 72 | + ET.SubElement(track, f"{{{KML_NS}}}altitudeMode").text = alt_mode |
| 73 | + for fr in frames: |
| 74 | + ET.SubElement(track, f"{{{KML_NS}}}when").text = _iso_time(fr) |
| 75 | + for fr in frames: |
| 76 | + # gx:coord is lon lat alt (space-separated) |
| 77 | + ET.SubElement(track, f"{{{GX_NS}}}coord").text = ( |
| 78 | + f"{fr.longitude:.7f} {fr.latitude:.7f} {fr.altitude_m:.3f}" |
| 79 | + ) |
| 80 | + |
| 81 | + rough = ET.tostring(kml, encoding="utf-8") |
| 82 | + parsed = minidom.parseString(rough) |
| 83 | + return parsed.toprettyxml(indent=" ", encoding="utf-8").decode("utf-8") |
| 84 | + |
| 85 | + |
| 86 | +def write_kml(frames: List[TelemetryFrame], path: PathLike, name: str = "DJI flight track") -> Path: |
| 87 | + out = Path(path) |
| 88 | + out.write_text(frames_to_kml(frames, name=name), encoding="utf-8") |
| 89 | + return out |
0 commit comments