Skip to content

Commit a0fadd1

Browse files
committed
Add FlightTelemetryTools GPX and KML exporters.
1 parent 22b2ced commit a0fadd1

2 files changed

Lines changed: 163 additions & 0 deletions

File tree

Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
"""GPX 1.1 track export."""
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+
GPX_NS = "http://www.topografix.com/GPX/1/1"
15+
XSI_NS = "http://www.w3.org/2001/XMLSchema-instance"
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+
# Fall back to cue start as offset from epoch-less video time — use a
22+
# synthetic timeline anchored at 1970-01-01 so GPX always has <time>.
23+
from datetime import datetime, timedelta, timezone
24+
25+
t = datetime(1970, 1, 1, tzinfo=timezone.utc) + timedelta(
26+
milliseconds=frame.start_ms
27+
)
28+
return t.strftime("%Y-%m-%dT%H:%M:%S.%f")[:-3] + "Z"
29+
30+
31+
def frames_to_gpx(frames: List[TelemetryFrame], name: str = "DJI flight track") -> str:
32+
"""Build a GPX 1.1 document string with a single trk/trkseg."""
33+
ET.register_namespace("", GPX_NS)
34+
ET.register_namespace("xsi", XSI_NS)
35+
36+
gpx = ET.Element(
37+
f"{{{GPX_NS}}}gpx",
38+
{
39+
"version": "1.1",
40+
"creator": "FlightTelemetryTools",
41+
f"{{{XSI_NS}}}schemaLocation": (
42+
f"{GPX_NS} http://www.topografix.com/GPX/1/1/gpx.xsd"
43+
),
44+
},
45+
)
46+
metadata = ET.SubElement(gpx, f"{{{GPX_NS}}}metadata")
47+
ET.SubElement(metadata, f"{{{GPX_NS}}}name").text = name
48+
ET.SubElement(metadata, f"{{{GPX_NS}}}desc").text = (
49+
"Flight track exported from DJI SRT telemetry. "
50+
"GPS from subtitles only; not a flight controller log."
51+
)
52+
53+
trk = ET.SubElement(gpx, f"{{{GPX_NS}}}trk")
54+
ET.SubElement(trk, f"{{{GPX_NS}}}name").text = name
55+
seg = ET.SubElement(trk, f"{{{GPX_NS}}}trkseg")
56+
57+
for fr in frames:
58+
pt = ET.SubElement(
59+
seg,
60+
f"{{{GPX_NS}}}trkpt",
61+
{"lat": f"{fr.latitude:.7f}", "lon": f"{fr.longitude:.7f}"},
62+
)
63+
ET.SubElement(pt, f"{{{GPX_NS}}}ele").text = f"{fr.altitude_m:.3f}"
64+
ET.SubElement(pt, f"{{{GPX_NS}}}time").text = _iso_time(fr)
65+
66+
rough = ET.tostring(gpx, encoding="utf-8")
67+
parsed = minidom.parseString(rough)
68+
return parsed.toprettyxml(indent=" ", encoding="utf-8").decode("utf-8")
69+
70+
71+
def write_gpx(frames: List[TelemetryFrame], path: PathLike, name: str = "DJI flight track") -> Path:
72+
out = Path(path)
73+
out.write_text(frames_to_gpx(frames, name=name), encoding="utf-8")
74+
return out
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
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

Comments
 (0)