media.Telemetry decodes the sensor data a camera records next to the video into typed channels: GPS fixes with time, altitude and speed, accelerometer and gyroscope readings, camera settings. This program writes the GPS fixes of a GoPro clip to a GPX file. Telemetry is a preview in 0.6.0-preview.1.
dotnet new console -o GoProGps && cd GoProGps dotnet add package RecursiveAI.OneMedia --version 0.6.0-preview.1 curl -L -o hero6.mp4 https://raw.githubusercontent.com/gopro/gpmf-parser/9a7150632892c7356c91145c889016f07b0ed48d/samples/hero6.mp4
hero6.mp4 is a 23-second clip recorded on a GoPro HERO6 Black, from GoPro’s own gpmf-parser repository, which GoPro publishes under the Apache License 2.0 or the MIT licence (the samples carry no separate terms). Put this in Program.cs and run dotnet run:
using System.Globalization; using System.Xml.Linq; using RecursiveAI.OneMedia; using RecursiveAI.OneMedia.Metadata; using MediaFile media = Media.Open("hero6.mp4"); // Every telemetry stream in the file, decoded to typed samples. foreach (TelemetryChannel channel in media.Telemetry) Console.WriteLine($"{channel.Format} {channel.Kind,-14} {channel.Name}: {channel.Samples.Count} samples"); List<GpsSample> fixes = media.Telemetry .Where(c => c.Kind == TelemetryKind.Gps) .SelectMany(c => c.Samples.OfType<GpsSample>()) .ToList(); // Write them as a GPX track: latitude, longitude, altitude and GPS time. XNamespace gpx = "http://www.topografix.com/GPX/1/1"; string F(double v) => v.ToString("R", CultureInfo.InvariantCulture); var segment = new XElement(gpx + "trkseg", fixes.Select(f => new XElement(gpx + "trkpt", new XAttribute("lat", F(f.Latitude)), new XAttribute("lon", F(f.Longitude)), f.Altitude is double alt ? new XElement(gpx + "ele", F(alt)) : null, f.GpsTime is DateTime t ? new XElement(gpx + "time", t.ToString("yyyy-MM-ddTHH:mm:ss.fffZ", CultureInfo.InvariantCulture)) : null))); var doc = new XElement(gpx + "gpx", new XAttribute("version", "1.1"), new XAttribute("creator", "OneMedia"), new XElement(gpx + "trk", segment)); File.WriteAllText("hero6.gpx", "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + doc); GpsSample first = fixes[0], last = fixes[^1]; Console.WriteLine($"{fixes.Count} fixes written to hero6.gpx"); Console.WriteLine($"first: {first.Latitude}, {first.Longitude}, {first.Altitude} m at {first.GpsTime:O} ({first.Fix})"); Console.WriteLine($"last: {last.Latitude}, {last.Longitude}, {last.Altitude} m at {last.GpsTime:O}"); // Once the telemetry has been read, its first fix also counts as location evidence. foreach (FactCandidate<GeoLocation> c in media.Location.Candidates) Console.WriteLine($"location candidate: {c.Value.Latitude}, {c.Value.Longitude} from {c.Source}");
gpmf Accelerometer ACCL: 4667 samples gpmf Gyroscope GYRO: 4668 samples gpmf Gps GPS5: 417 samples gpmf Scalar FCNM: 344 samples gpmf CameraSettings ISOE: 689 samples gpmf CameraSettings SHUT: 689 samples gpmf CameraSettings WBAL: 689 samples gpmf Scalar WRGB: 689 samples gpmf Scalar FACE: 55 samples 417 fixes written to hero6.gpx first: 33.126515, -117.3271676, -17.228 m at 2018-01-24T19:27:58.6740000Z (Fix3D) last: 33.1266036, -117.3273038, -19.54 m at 2018-01-24T19:28:21.6193889Z location candidate: 33.1265, -117.3272 from udta:©xyz location candidate: 33.126515, -117.3271676 from telemetry:gpmf:GPS5
head -12 hero6.gpx<?xml version="1.0" encoding="utf-8"?> <gpx version="1.1" creator="OneMedia" xmlns="http://www.topografix.com/GPX/1/1"> <trk> <trkseg> <trkpt lat="33.126515" lon="-117.3271676"> <ele>-17.228</ele> <time>2018-01-24T19:27:58.674Z</time> </trkpt> <trkpt lat="33.1265159" lon="-117.3271672"> <ele>-17.231</ele> <time>2018-01-24T19:27:58.732Z</time> </trkpt>
The location fact now has two candidates: the position GoPro writes as text in the user data (©xyz, four decimal places), which ranks first, and the first telemetry fix. GoPro writes GPS at about 18 Hz in 23 one-second payloads; each payload’s samples are spread evenly over its time, and the GPS time is the payload’s GPSU time plus the same spread. media.OpenTelemetry(track) is the pull reader for long files: media.Telemetry reads the whole track into memory.
Checked against ExifTool
ExifTool 13.60 reads the same GPS5 samples with -ee. This compares every point of the GPX file with its output:
exiftool -n -ee -p '$GPSLatitude,$GPSLongitude,$GPSAltitude' hero6.mp4 2>/dev/null > exiftool.csv cat > compare.py <<'EOF' import csv, xml.etree.ElementTree as ET ns = {"g": "http://www.topografix.com/GPX/1/1"} ours = [(float(p.get("lat")), float(p.get("lon")), float(p.find("g:ele", ns).text)) for p in ET.parse("hero6.gpx").iterfind(".//g:trkpt", ns)] theirs = [tuple(map(float, row)) for row in csv.reader(open("exiftool.csv"))] print(f"OneMedia {len(ours)} points, ExifTool {len(theirs)} points") for i, name in enumerate(["latitude", "longitude", "altitude"]): print(f"largest {name} difference: {max(abs(a[i] - b[i]) for a, b in zip(ours, theirs)):.1e}") EOF python3 compare.py
OneMedia 417 points, ExifTool 417 points largest latitude difference: 0.0e+00 largest longitude difference: 0.0e+00 largest altitude difference: 0.0e+00
The same 417 points, equal in latitude, longitude and altitude. (ExifTool prints a Perl warning about a GoPro AACD field on this file; 2>/dev/null hides it.)
Other cameras
The same call reads Google’s CAMM track (phones and 360° cameras), Apple’s QuickTime timed metadata (mebx), DJI’s per-frame text and djmd tracks, and the GPS in AVCHD camcorder data; the telemetry page gives the channel layout of each. They were not run for this page. In the README’s words, telemetry was checked on “10 published GoPro clips (HERO5 to HERO8, Fusion, MAX, Karma) against ExifTool (equal counts, values within 1e-13) and against GoPro’s own gpmfdemo release binary”, with “CAMM and mebx fixtures against ExifTool and ffprobe”; “No real DJI telemetry file was available”, and for CAMM time_gps_epoch “no real CAMM device file was available to settle it”.
Limits
- A preview, not checked on device recordings. The README’s wording for the facts layer applies to telemetry too: “Checked against ExifTool 13.60 and MediaInfo 26.10 on published samples and on files we generate, not on a corpus of device recordings.” Its API may change.
- Not compared value by value: the GoPro HUES, FACE, SCEN, MFGI and VPTS streams; they come out as raw items with their byte ranges.
- Reading only. To remove the GPS from a file, see Remove GPS location and metadata from a video.
- Sidecars. A DJI
.SRTfile next to the video is read withDjiSrt.Read(path), not throughmedia.Telemetry.