gps-video-sync 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gps_video_sync/__init__.py +63 -0
- gps_video_sync/__main__.py +3 -0
- gps_video_sync/cli.py +140 -0
- gps_video_sync/gopro.py +243 -0
- gps_video_sync/gpmf.py +124 -0
- gps_video_sync/igc.py +57 -0
- gps_video_sync/models.py +78 -0
- gps_video_sync/mp4.py +403 -0
- gps_video_sync/npz.py +37 -0
- gps_video_sync/plugins.py +37 -0
- gps_video_sync/sync.py +483 -0
- gps_video_sync/verify.py +185 -0
- gps_video_sync-0.1.0.dist-info/METADATA +241 -0
- gps_video_sync-0.1.0.dist-info/RECORD +17 -0
- gps_video_sync-0.1.0.dist-info/WHEEL +4 -0
- gps_video_sync-0.1.0.dist-info/entry_points.txt +2 -0
- gps_video_sync-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
from .models import Track, Telemetry, SyncResult
|
|
2
|
+
from .plugins import (TrackReader, VideoSource, register_track, register_video,
|
|
3
|
+
read_track, read_video)
|
|
4
|
+
from .igc import IGCReader
|
|
5
|
+
|
|
6
|
+
__version__ = "0.1.0"
|
|
7
|
+
register_track("igc", IGCReader())
|
|
8
|
+
from .gopro import GoProSource
|
|
9
|
+
register_video("gopro", GoProSource())
|
|
10
|
+
from .npz import NPZSource
|
|
11
|
+
register_video("npz", NPZSource())
|
|
12
|
+
|
|
13
|
+
def synchronize(track: Track, telemetry: Telemetry) -> SyncResult:
|
|
14
|
+
from .sync import synchronize as run
|
|
15
|
+
return run(track, telemetry)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def match_videos(track: Track, paths, *, source: str = "gopro", hz: float = 10,
|
|
19
|
+
cache_dir=None) -> list[SyncResult]:
|
|
20
|
+
"""Match each recording independently, retaining input order and failures."""
|
|
21
|
+
import math
|
|
22
|
+
from pathlib import Path
|
|
23
|
+
import zipfile
|
|
24
|
+
from .plugins import VIDEO_SOURCES
|
|
25
|
+
from .sync import validate_track, validate_telemetry, _synchronize
|
|
26
|
+
|
|
27
|
+
validate_track(track)
|
|
28
|
+
if source not in VIDEO_SOURCES:
|
|
29
|
+
raise ValueError(f"Unknown or unavailable video source: {source}")
|
|
30
|
+
if not math.isfinite(hz) or hz <= 0:
|
|
31
|
+
raise ValueError("hz must be finite and positive")
|
|
32
|
+
if isinstance(paths, (str, Path)):
|
|
33
|
+
raise ValueError("Supply a nonempty sequence of video paths")
|
|
34
|
+
paths = list(paths)
|
|
35
|
+
if not paths or any(not isinstance(p, (str, Path)) for p in paths):
|
|
36
|
+
raise ValueError("Supply a nonempty sequence of video paths")
|
|
37
|
+
results = []
|
|
38
|
+
seen_paths = set()
|
|
39
|
+
accepted_timelines = set()
|
|
40
|
+
for path in paths:
|
|
41
|
+
identity = str(path)
|
|
42
|
+
try:
|
|
43
|
+
try:
|
|
44
|
+
identity = str(Path(path).expanduser().resolve())
|
|
45
|
+
except RuntimeError as exc:
|
|
46
|
+
raise ValueError(str(exc)) from exc
|
|
47
|
+
if identity in seen_paths:
|
|
48
|
+
raise ValueError("Duplicate input path")
|
|
49
|
+
seen_paths.add(identity)
|
|
50
|
+
telemetry = read_video(path, source=source, hz=hz, cache_dir=cache_dir)
|
|
51
|
+
validate_telemetry(telemetry)
|
|
52
|
+
timeline = (telemetry.video, telemetry.media_offset_seconds, telemetry.duration)
|
|
53
|
+
if timeline in accepted_timelines:
|
|
54
|
+
raise ValueError("Duplicate recording timeline")
|
|
55
|
+
except (OSError, ValueError, EOFError, zipfile.BadZipFile) as exc:
|
|
56
|
+
result = SyncResult("failed", video=identity, message=str(exc), date=track.date)
|
|
57
|
+
else:
|
|
58
|
+
result = _synchronize(track, telemetry)
|
|
59
|
+
if result.status != "failed":
|
|
60
|
+
accepted_timelines.add(timeline)
|
|
61
|
+
result.input_file = identity
|
|
62
|
+
results.append(result)
|
|
63
|
+
return results
|
gps_video_sync/cli.py
ADDED
|
@@ -0,0 +1,140 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import argparse
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
import subprocess
|
|
7
|
+
import sys
|
|
8
|
+
import zipfile
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from . import __version__, read_track, read_video, synchronize, match_videos
|
|
13
|
+
from .sync import validate_telemetry
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _json_default(value):
|
|
17
|
+
if isinstance(value, np.ndarray):
|
|
18
|
+
return value.tolist()
|
|
19
|
+
if isinstance(value, np.generic):
|
|
20
|
+
return value.item()
|
|
21
|
+
raise TypeError(f"Cannot encode {type(value).__name__}")
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def write_json(path, value):
|
|
25
|
+
Path(path).write_text(json.dumps(value, indent=2, default=_json_default,
|
|
26
|
+
allow_nan=False) + "\n")
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _telemetry(args):
|
|
30
|
+
return read_video(args.videos[0], source=args.source, hz=args.hz, cache_dir=args.cache_dir)
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _export(telemetry, path):
|
|
34
|
+
validate_telemetry(telemetry)
|
|
35
|
+
recording = dict(video=telemetry.video, duration=telemetry.duration,
|
|
36
|
+
media_offset_seconds=telemetry.media_offset_seconds)
|
|
37
|
+
if Path(path).suffix.lower() == ".npz":
|
|
38
|
+
reserved = {"time", "gps_time", "gps_utc", "recording", "chapters", "schema_version"}
|
|
39
|
+
if reserved.intersection(telemetry.channels):
|
|
40
|
+
raise ValueError("Telemetry channel name conflicts with NPZ metadata")
|
|
41
|
+
np.savez_compressed(path, time=telemetry.time, **telemetry.channels,
|
|
42
|
+
gps_time=telemetry.gps_time, gps_utc=telemetry.gps_utc,
|
|
43
|
+
recording=json.dumps(recording, default=_json_default),
|
|
44
|
+
schema_version="2.0.0")
|
|
45
|
+
elif Path(path).suffix.lower() == ".json":
|
|
46
|
+
def nullable(values):
|
|
47
|
+
values = np.asarray(values)
|
|
48
|
+
return np.where(np.isfinite(values), values, None).tolist()
|
|
49
|
+
write_json(path, dict(schema_version="2.0.0", time=nullable(telemetry.time),
|
|
50
|
+
channels={k: nullable(v) for k, v in telemetry.channels.items()},
|
|
51
|
+
recording=recording, gps_time=nullable(telemetry.gps_time),
|
|
52
|
+
gps_utc=nullable(telemetry.gps_utc)))
|
|
53
|
+
else:
|
|
54
|
+
raise ValueError("--out must end in .npz or .json")
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _Parser(argparse.ArgumentParser):
|
|
58
|
+
def error(self, message):
|
|
59
|
+
self.print_usage(sys.stderr)
|
|
60
|
+
self.exit(1, f"{self.prog}: error: {message}\n")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def _parser():
|
|
64
|
+
parser = _Parser(description="Synchronize video telemetry with a GPS track")
|
|
65
|
+
parser.add_argument("--version", action="version", version=__version__)
|
|
66
|
+
commands = parser.add_subparsers(dest="command", required=True)
|
|
67
|
+
for command in ("sync", "telemetry", "verify", "info"):
|
|
68
|
+
sub = commands.add_parser(command)
|
|
69
|
+
sub.add_argument("videos", metavar="VIDEO", nargs="+" if command == "sync" else 1)
|
|
70
|
+
sub.add_argument("--source", default="gopro", help="video source plugin, or npz")
|
|
71
|
+
sub.add_argument("--cache-dir", type=Path)
|
|
72
|
+
sub.add_argument("--hz", type=float, default=10, help="telemetry sample rate (default: 10)")
|
|
73
|
+
if command in {"sync", "verify"}:
|
|
74
|
+
sub.add_argument("--track", required=True, type=Path)
|
|
75
|
+
sub.add_argument("--track-format", default="igc")
|
|
76
|
+
sub.add_argument("--altitude", choices=("auto", "gps", "pressure"), default="auto")
|
|
77
|
+
if command == "sync":
|
|
78
|
+
sub.add_argument("--json", type=Path, help="write result here instead of stdout")
|
|
79
|
+
elif command == "telemetry":
|
|
80
|
+
sub.add_argument("--out", required=True, type=Path)
|
|
81
|
+
elif command == "verify":
|
|
82
|
+
sub.add_argument("--output", required=True, type=Path)
|
|
83
|
+
sub.add_argument("--result", type=Path)
|
|
84
|
+
sub.add_argument("--frames", nargs="+", type=float, metavar="VIDEO_SECONDS")
|
|
85
|
+
return parser
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def main(argv=None):
|
|
89
|
+
args = _parser().parse_args(argv)
|
|
90
|
+
try:
|
|
91
|
+
if args.command in {"telemetry", "info"}:
|
|
92
|
+
telemetry = _telemetry(args)
|
|
93
|
+
if args.command == "telemetry":
|
|
94
|
+
_export(telemetry, args.out)
|
|
95
|
+
else:
|
|
96
|
+
print(json.dumps(dict(schema_version="2.0.0", video=telemetry.video,
|
|
97
|
+
input_file=str(Path(args.videos[0]).expanduser().resolve()),
|
|
98
|
+
duration=telemetry.duration, samples=len(telemetry.time),
|
|
99
|
+
start_seconds=float(telemetry.time[0]) if len(telemetry.time) else None,
|
|
100
|
+
end_seconds=float(telemetry.time[-1]) if len(telemetry.time) else None,
|
|
101
|
+
channels={k: list(v.shape) for k, v in telemetry.channels.items()},
|
|
102
|
+
gps_samples=len(telemetry.gps_time)),
|
|
103
|
+
indent=2, default=_json_default, allow_nan=False))
|
|
104
|
+
return 0
|
|
105
|
+
try:
|
|
106
|
+
track = read_track(args.track, format=args.track_format, altitude=args.altitude)
|
|
107
|
+
except KeyError as exc:
|
|
108
|
+
if exc.args == (args.track_format,):
|
|
109
|
+
raise ValueError(f"Unknown track format: {args.track_format}") from exc
|
|
110
|
+
raise
|
|
111
|
+
if args.command == "sync":
|
|
112
|
+
results = [r.to_dict() for r in match_videos(
|
|
113
|
+
track, args.videos, source=args.source, hz=args.hz, cache_dir=args.cache_dir)]
|
|
114
|
+
document = dict(schema_version="2.0.0", videos=results)
|
|
115
|
+
if args.json:
|
|
116
|
+
write_json(args.json, document)
|
|
117
|
+
else:
|
|
118
|
+
print(json.dumps(document, indent=2, default=_json_default, allow_nan=False))
|
|
119
|
+
else:
|
|
120
|
+
from .verify import plot_verification, select_result
|
|
121
|
+
telemetry = _telemetry(args)
|
|
122
|
+
result = (select_result(json.loads(args.result.read_text()), telemetry)
|
|
123
|
+
if args.result else synchronize(track, telemetry).to_dict())
|
|
124
|
+
results = [result]
|
|
125
|
+
if result["status"] == "ok":
|
|
126
|
+
plot_verification(track, telemetry, result, args.output, args.frames or [])
|
|
127
|
+
for result in results:
|
|
128
|
+
if result["status"] != "ok":
|
|
129
|
+
print(f"{result.get('input_file') or result.get('video')}: {result['status']}: "
|
|
130
|
+
+ (result.get("message") or "No unique alignment; inspect confidence."), file=sys.stderr)
|
|
131
|
+
if any(r["status"] == "failed" for r in results):
|
|
132
|
+
return 1
|
|
133
|
+
return 2 if any(r["status"] == "ambiguous" for r in results) else 0
|
|
134
|
+
except (OSError, ValueError, KeyError, RuntimeError, ImportError, EOFError,
|
|
135
|
+
zipfile.BadZipFile, subprocess.SubprocessError) as exc:
|
|
136
|
+
detail = str(exc)
|
|
137
|
+
if isinstance(exc, subprocess.CalledProcessError) and exc.stderr:
|
|
138
|
+
detail += ": " + (exc.stderr.decode(errors="replace") if isinstance(exc.stderr, bytes) else exc.stderr).strip()
|
|
139
|
+
print(f"error: {detail}", file=sys.stderr)
|
|
140
|
+
return 1
|
gps_video_sync/gopro.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
"""Motion retains native camera axes. GPS times are Unix UTC seconds."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
import hashlib
|
|
6
|
+
import json
|
|
7
|
+
import math
|
|
8
|
+
import os
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
import tempfile
|
|
11
|
+
from dataclasses import dataclass
|
|
12
|
+
import zipfile
|
|
13
|
+
|
|
14
|
+
import numpy as np
|
|
15
|
+
from scipy.ndimage import uniform_filter1d
|
|
16
|
+
|
|
17
|
+
from . import mp4
|
|
18
|
+
from .gpmf import number, scaled, streams
|
|
19
|
+
from .models import Telemetry
|
|
20
|
+
from .sync import validate_telemetry
|
|
21
|
+
|
|
22
|
+
KEYS = ("ACCL", "GYRO", "GRAV", "CORI", "IORI")
|
|
23
|
+
QUATERNIONS = ("CORI", "IORI")
|
|
24
|
+
CACHE_VERSION = "2.0.0"
|
|
25
|
+
MAX_GRID_SAMPLES = 1_000_000
|
|
26
|
+
GPS_EPOCH = datetime(2000, 1, 1, tzinfo=timezone.utc).timestamp()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _gps(fields: dict, times: np.ndarray, key: str) -> tuple[np.ndarray, np.ndarray]:
|
|
30
|
+
values = scaled(fields[key], fields)
|
|
31
|
+
if key == "GPS9":
|
|
32
|
+
if values.shape[1] != 9:
|
|
33
|
+
raise ValueError("GPS9 requires nine columns")
|
|
34
|
+
utc = GPS_EPOCH + values[:, 5] * 86400 + values[:, 6]
|
|
35
|
+
good = ((values[:, 8] >= 3) & (values[:, 5] >= 0)
|
|
36
|
+
& (values[:, 6] >= 0) & (values[:, 6] < 86401))
|
|
37
|
+
else:
|
|
38
|
+
if values.shape[1] != 5:
|
|
39
|
+
raise ValueError("GPS5 requires five columns")
|
|
40
|
+
if "GPSU" not in fields or "GPSF" not in fields:
|
|
41
|
+
return np.empty(0), np.empty(0)
|
|
42
|
+
try:
|
|
43
|
+
stamp = fields["GPSU"].data.decode("ascii").rstrip("\0")
|
|
44
|
+
fmt = "%y%m%d%H%M%S.%f" if "." in stamp else "%y%m%d%H%M%S"
|
|
45
|
+
epoch = datetime.strptime(stamp, fmt).replace(tzinfo=timezone.utc).timestamp()
|
|
46
|
+
except (UnicodeDecodeError, ValueError):
|
|
47
|
+
return np.empty(0), np.empty(0)
|
|
48
|
+
utc = epoch + (times - times[0])
|
|
49
|
+
good = np.ones(len(values), dtype=bool)
|
|
50
|
+
# GPSF, when present, must also report a 3D fix. GPS9 carries its own fix.
|
|
51
|
+
if "GPSF" in fields:
|
|
52
|
+
fix = number(fields["GPSF"]).ravel()
|
|
53
|
+
if len(fix) not in (1, len(values)):
|
|
54
|
+
raise ValueError("GPSF does not match GPS samples")
|
|
55
|
+
good &= fix >= 3
|
|
56
|
+
good &= np.isfinite(values).all(axis=1) & np.isfinite(utc)
|
|
57
|
+
good &= (np.abs(values[:, 0]) <= 90) & (np.abs(values[:, 1]) <= 180)
|
|
58
|
+
return times[good], utc[good]
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class Recording:
|
|
63
|
+
duration: float
|
|
64
|
+
packets: int
|
|
65
|
+
motion: dict
|
|
66
|
+
gps: list
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _recording(path: Path) -> Recording:
|
|
70
|
+
timeline = mp4.read_timeline(path)
|
|
71
|
+
duration = timeline.duration
|
|
72
|
+
data = {key: [] for key in KEYS}
|
|
73
|
+
gps5, gps9 = [], []
|
|
74
|
+
with path.open("rb") as handle:
|
|
75
|
+
for sample in timeline.samples:
|
|
76
|
+
pts, interval = sample.time, sample.duration
|
|
77
|
+
handle.seek(sample.offset)
|
|
78
|
+
buf = handle.read(sample.size)
|
|
79
|
+
if len(buf) != sample.size:
|
|
80
|
+
raise ValueError("Truncated GPMF packet in video file")
|
|
81
|
+
packet_gps5, packet_gps9 = [], []
|
|
82
|
+
has_gps9 = False
|
|
83
|
+
for stream in streams(buf):
|
|
84
|
+
fields = {r.key: r for r in stream}
|
|
85
|
+
for key in KEYS:
|
|
86
|
+
if key not in fields:
|
|
87
|
+
continue
|
|
88
|
+
values = scaled(fields[key], fields)
|
|
89
|
+
expected = 4 if key in QUATERNIONS else 3
|
|
90
|
+
if values.shape[1] != expected:
|
|
91
|
+
raise ValueError(f"{key} requires {expected} columns")
|
|
92
|
+
if not len(values):
|
|
93
|
+
continue
|
|
94
|
+
t = pts + np.arange(len(values)) * interval / len(values)
|
|
95
|
+
mask = (t >= 0) & (t < duration)
|
|
96
|
+
data[key].append(np.column_stack((t[mask], values[mask])))
|
|
97
|
+
for key, target in (("GPS9", packet_gps9), ("GPS5", packet_gps5)):
|
|
98
|
+
if key not in fields:
|
|
99
|
+
continue
|
|
100
|
+
has_gps9 |= key == "GPS9"
|
|
101
|
+
count = fields[key].count
|
|
102
|
+
if not count:
|
|
103
|
+
continue
|
|
104
|
+
t = pts + np.arange(count) * interval / count
|
|
105
|
+
gt, utc = _gps(fields, t, key)
|
|
106
|
+
mask = (gt >= 0) & (gt < duration)
|
|
107
|
+
if mask.any():
|
|
108
|
+
target.append((gt[mask], utc[mask]))
|
|
109
|
+
# Do not replace rejected GPS9 fixes with a second GPS5 observation.
|
|
110
|
+
if has_gps9:
|
|
111
|
+
gps9.extend(packet_gps9)
|
|
112
|
+
else:
|
|
113
|
+
gps5.extend(packet_gps5)
|
|
114
|
+
return Recording(duration, len(timeline.samples), data, gps5 + gps9)
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def _resample(blocks: list, grid: np.ndarray, key: str, hz: float) -> np.ndarray | None:
|
|
118
|
+
if not blocks or not len(grid):
|
|
119
|
+
return None
|
|
120
|
+
raw = np.concatenate(blocks)
|
|
121
|
+
raw = raw[np.isfinite(raw).all(axis=1)]
|
|
122
|
+
if key in QUATERNIONS:
|
|
123
|
+
raw = raw[np.linalg.norm(raw[:, 1:], axis=1) > 0]
|
|
124
|
+
if not len(raw):
|
|
125
|
+
return None
|
|
126
|
+
raw = raw[np.argsort(raw[:, 0], kind="stable")]
|
|
127
|
+
raw = raw[np.r_[np.diff(raw[:, 0]) > 0, True]]
|
|
128
|
+
if key in QUATERNIONS:
|
|
129
|
+
values = raw[:, 1:]
|
|
130
|
+
values /= np.linalg.norm(values, axis=1)[:, None]
|
|
131
|
+
flip = np.r_[1, np.cumprod(np.where(np.sum(values[1:] * values[:-1], axis=1) < 0, -1, 1))]
|
|
132
|
+
raw[:, 1:] = values * flip[:, None]
|
|
133
|
+
elif len(raw) > 1:
|
|
134
|
+
width = max(1, min(len(raw), round(1 / np.median(np.diff(raw[:, 0])) / hz)))
|
|
135
|
+
raw[:, 1:] = uniform_filter1d(raw[:, 1:], width, axis=0, mode="nearest")
|
|
136
|
+
result = np.column_stack([np.interp(grid, raw[:, 0], raw[:, i]) for i in range(1, raw.shape[1])])
|
|
137
|
+
if key in QUATERNIONS:
|
|
138
|
+
result /= np.linalg.norm(result, axis=1)[:, None]
|
|
139
|
+
if len(raw) > 1:
|
|
140
|
+
step = float(np.median(np.diff(raw[:, 0])))
|
|
141
|
+
right = np.searchsorted(raw[:, 0], grid).clip(1, len(raw)-1)
|
|
142
|
+
gap = (raw[right, 0]-raw[right-1, 0]) > max(.5, 3*step)
|
|
143
|
+
outside = (grid < raw[0, 0]) | (grid > raw[-1, 0]+step)
|
|
144
|
+
result[gap | outside] = np.nan
|
|
145
|
+
return result
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _cache_path(path: Path, hz: float, directory: Path) -> Path:
|
|
149
|
+
stat = path.stat()
|
|
150
|
+
identity = {"version": CACHE_VERSION, "hz": hz, "file":
|
|
151
|
+
[str(path), stat.st_size, stat.st_mtime_ns]}
|
|
152
|
+
digest = hashlib.sha256(json.dumps(identity, sort_keys=True).encode()).hexdigest()
|
|
153
|
+
return directory / f"gopro-{digest}.npz"
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _grid_samples(duration: float, hz: float) -> int:
|
|
157
|
+
try:
|
|
158
|
+
samples = duration * hz
|
|
159
|
+
except OverflowError:
|
|
160
|
+
samples = math.inf
|
|
161
|
+
if not math.isfinite(samples) or samples > MAX_GRID_SAMPLES:
|
|
162
|
+
raise ValueError(f"GoPro resampling exceeds {MAX_GRID_SAMPLES:,} samples; lower hz")
|
|
163
|
+
return math.ceil(samples)
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
def _load_cache(path: Path, hz: float) -> Telemetry | None:
|
|
167
|
+
try:
|
|
168
|
+
with np.load(path, allow_pickle=False) as saved:
|
|
169
|
+
meta = json.loads(str(saved["metadata"].item()))
|
|
170
|
+
if meta["version"] != CACHE_VERSION or any(k not in KEYS for k in meta["channels"]):
|
|
171
|
+
return None
|
|
172
|
+
_grid_samples(meta["duration"], hz)
|
|
173
|
+
result = Telemetry(saved["time"], {k: saved[k] for k in meta["channels"]},
|
|
174
|
+
meta["video"], meta["duration"], saved["gps_time"], saved["gps_utc"])
|
|
175
|
+
if result.time.ndim != 1 or result.gps_time.ndim != 1 or result.gps_utc.shape != result.gps_time.shape:
|
|
176
|
+
return None
|
|
177
|
+
if any(v.shape != (len(result.time), 4 if k in QUATERNIONS else 3)
|
|
178
|
+
for k, v in result.channels.items()):
|
|
179
|
+
return None
|
|
180
|
+
validate_telemetry(result)
|
|
181
|
+
return result
|
|
182
|
+
except (OSError, ValueError, KeyError, TypeError, EOFError, zipfile.BadZipFile):
|
|
183
|
+
return None
|
|
184
|
+
|
|
185
|
+
|
|
186
|
+
def _save_cache(path: Path, result: Telemetry) -> None:
|
|
187
|
+
temporary = None
|
|
188
|
+
try:
|
|
189
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
190
|
+
with tempfile.NamedTemporaryFile(dir=path.parent, suffix=".npz", delete=False) as handle:
|
|
191
|
+
temporary = handle.name
|
|
192
|
+
metadata = {"version": CACHE_VERSION, "channels": list(result.channels),
|
|
193
|
+
"video": result.video, "duration": result.duration}
|
|
194
|
+
np.savez_compressed(handle, metadata=json.dumps(metadata), time=result.time,
|
|
195
|
+
gps_time=result.gps_time, gps_utc=result.gps_utc, **result.channels)
|
|
196
|
+
os.replace(temporary, path)
|
|
197
|
+
except OSError:
|
|
198
|
+
# A read-only cache must not make extraction fail.
|
|
199
|
+
pass
|
|
200
|
+
finally:
|
|
201
|
+
if temporary is not None:
|
|
202
|
+
try:
|
|
203
|
+
os.unlink(temporary)
|
|
204
|
+
except FileNotFoundError:
|
|
205
|
+
pass
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class GoProSource:
|
|
209
|
+
"""Extract one MP4 recording with file-local sample times."""
|
|
210
|
+
|
|
211
|
+
def read(self, path: str | Path, *, hz: float = 10,
|
|
212
|
+
cache_dir: str | Path | None = None) -> Telemetry:
|
|
213
|
+
hz = float(hz)
|
|
214
|
+
if not math.isfinite(hz) or hz <= 0:
|
|
215
|
+
raise ValueError("hz must be finite and positive")
|
|
216
|
+
if not isinstance(path, (str, Path)):
|
|
217
|
+
raise ValueError("Supply one video path")
|
|
218
|
+
path = Path(path).expanduser().resolve()
|
|
219
|
+
directory = Path(cache_dir).expanduser() if cache_dir is not None else Path.home() / ".cache" / "gps-video-sync"
|
|
220
|
+
cache = _cache_path(path, hz, directory)
|
|
221
|
+
saved = _load_cache(cache, hz)
|
|
222
|
+
if saved is not None and saved.video == str(path) and saved.duration is not None:
|
|
223
|
+
return saved
|
|
224
|
+
local = _recording(path)
|
|
225
|
+
if not local.packets:
|
|
226
|
+
raise ValueError(f"No GoPro telemetry (gpmd) track found in: {path.name}")
|
|
227
|
+
grid = np.arange(_grid_samples(local.duration, hz)) / hz
|
|
228
|
+
grid = grid[grid < local.duration]
|
|
229
|
+
channels = {}
|
|
230
|
+
for key in KEYS:
|
|
231
|
+
values = _resample(local.motion[key], grid, key, hz)
|
|
232
|
+
if values is not None:
|
|
233
|
+
channels[key] = values
|
|
234
|
+
if local.gps:
|
|
235
|
+
pairs = np.concatenate([np.column_stack(pair) for pair in local.gps])
|
|
236
|
+
pairs = pairs[np.argsort(pairs[:, 0], kind="stable")]
|
|
237
|
+
pairs = pairs[np.r_[True, np.diff(pairs[:, 0]) > 0]]
|
|
238
|
+
gps_time, gps_utc = pairs.T
|
|
239
|
+
else:
|
|
240
|
+
gps_time, gps_utc = np.empty(0), np.empty(0)
|
|
241
|
+
result = Telemetry(grid, channels, str(path), local.duration, gps_time, gps_utc)
|
|
242
|
+
_save_cache(cache, result)
|
|
243
|
+
return result
|
gps_video_sync/gpmf.py
ADDED
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import re
|
|
4
|
+
import struct
|
|
5
|
+
from typing import Iterator, NamedTuple
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Record(NamedTuple):
|
|
11
|
+
key: str
|
|
12
|
+
typ: str
|
|
13
|
+
size: int
|
|
14
|
+
count: int
|
|
15
|
+
data: bytes
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
DTYPES = {
|
|
19
|
+
"b": ">i1", "B": ">u1", "s": ">i2", "S": ">u2",
|
|
20
|
+
"l": ">i4", "L": ">u4", "j": ">i8", "J": ">u8",
|
|
21
|
+
"f": ">f4", "d": ">f8", "q": ">i4", "Q": ">i8",
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
MAX_DEPTH = 8
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def records(buf: bytes) -> Iterator[Record]:
|
|
29
|
+
for record in _records(memoryview(buf)):
|
|
30
|
+
yield record._replace(data=bytes(record.data))
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def _records(buf: memoryview) -> Iterator[Record]:
|
|
34
|
+
offset = 0
|
|
35
|
+
while offset < len(buf):
|
|
36
|
+
if len(buf) - offset < 8:
|
|
37
|
+
raise ValueError("Truncated GPMF header")
|
|
38
|
+
key, typ, size, count = struct.unpack_from(">4sBBH", buf, offset)
|
|
39
|
+
length = size * count
|
|
40
|
+
end = offset + 8 + length
|
|
41
|
+
padded_end = offset + 8 + ((length + 3) // 4) * 4
|
|
42
|
+
if padded_end > len(buf):
|
|
43
|
+
raise ValueError("Truncated GPMF payload or padding")
|
|
44
|
+
try:
|
|
45
|
+
name = key.decode("ascii")
|
|
46
|
+
except UnicodeDecodeError as exc:
|
|
47
|
+
raise ValueError("Invalid GPMF key") from exc
|
|
48
|
+
yield Record(name, chr(typ), size, count, buf[offset + 8:end])
|
|
49
|
+
offset = padded_end
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def streams(buf: bytes, _depth: int = 0) -> Iterator[list[Record]]:
|
|
53
|
+
"""Yield STRM records. Nesting is limited to MAX_DEPTH containers, and
|
|
54
|
+
payload bytes are not copied between levels, so work is linear in size."""
|
|
55
|
+
if _depth > MAX_DEPTH:
|
|
56
|
+
raise ValueError("GPMF container nesting is too deep")
|
|
57
|
+
view = buf if isinstance(buf, memoryview) else memoryview(buf)
|
|
58
|
+
for record in _records(view):
|
|
59
|
+
if record.key == "STRM":
|
|
60
|
+
yield [r._replace(data=bytes(r.data)) for r in _records(record.data)]
|
|
61
|
+
elif record.typ == "\0":
|
|
62
|
+
yield from streams(record.data, _depth + 1)
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _types(description: str, size: int) -> list[str]:
|
|
66
|
+
types: list[str] = []
|
|
67
|
+
offset = 0
|
|
68
|
+
while offset < len(description):
|
|
69
|
+
match = re.match(r"([bBsSlLjJfdqQ])(?:\[(\d+)\])?", description[offset:])
|
|
70
|
+
if match is None:
|
|
71
|
+
raise ValueError(f"Unsupported GPMF TYPE: {description!r}")
|
|
72
|
+
count = int(match[2]) if match[2] else 1
|
|
73
|
+
if count < 1 or count > size:
|
|
74
|
+
raise ValueError("Invalid GPMF TYPE array size")
|
|
75
|
+
types.extend([match[1]] * count)
|
|
76
|
+
offset += len(match[0])
|
|
77
|
+
if len(types) > size:
|
|
78
|
+
raise ValueError("GPMF TYPE exceeds row size")
|
|
79
|
+
if not types or sum(np.dtype(DTYPES[t]).itemsize for t in types) != size:
|
|
80
|
+
raise ValueError("GPMF TYPE does not match row size")
|
|
81
|
+
return types
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def number(record: Record, type_hint: str | Record | None = None) -> np.ndarray:
|
|
85
|
+
_, typ, size, count, data = record
|
|
86
|
+
if len(data) != size * count or size == 0:
|
|
87
|
+
raise ValueError("Invalid GPMF numeric payload size")
|
|
88
|
+
if typ == "?":
|
|
89
|
+
if isinstance(type_hint, Record):
|
|
90
|
+
try:
|
|
91
|
+
type_hint = type_hint.data.decode("ascii").rstrip("\0")
|
|
92
|
+
except UnicodeDecodeError as exc:
|
|
93
|
+
raise ValueError("Invalid GPMF TYPE text") from exc
|
|
94
|
+
if not isinstance(type_hint, str):
|
|
95
|
+
raise ValueError("Complex GPMF data requires TYPE")
|
|
96
|
+
types = _types(type_hint, size)
|
|
97
|
+
dtype = np.dtype([(f"f{i}", DTYPES[t]) for i, t in enumerate(types)])
|
|
98
|
+
rows = np.frombuffer(data, dtype=dtype, count=count)
|
|
99
|
+
values = np.column_stack([rows[f"f{i}"] for i in range(len(types))]).astype(float)
|
|
100
|
+
for i, t in enumerate(types):
|
|
101
|
+
if t in ("q", "Q"):
|
|
102
|
+
values[:, i] /= 2 ** (16 if t == "q" else 32)
|
|
103
|
+
return values
|
|
104
|
+
if typ not in DTYPES:
|
|
105
|
+
raise ValueError(f"Unsupported GPMF numeric type: {typ!r}")
|
|
106
|
+
dtype = np.dtype(DTYPES[typ])
|
|
107
|
+
if size % dtype.itemsize:
|
|
108
|
+
raise ValueError("GPMF row size is not a multiple of its numeric type")
|
|
109
|
+
values = np.frombuffer(data, dtype=dtype).astype(float).reshape(count, size // dtype.itemsize)
|
|
110
|
+
if typ in ("q", "Q"):
|
|
111
|
+
values /= 2 ** (16 if typ == "q" else 32)
|
|
112
|
+
return values
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def scaled(record: Record, fields: dict[str, Record]) -> np.ndarray:
|
|
116
|
+
values = number(record, fields.get("TYPE"))
|
|
117
|
+
if "SCAL" in fields:
|
|
118
|
+
scale = number(fields["SCAL"]).ravel()
|
|
119
|
+
if len(scale) not in (1, values.shape[1]):
|
|
120
|
+
raise ValueError("GPMF SCAL does not match data columns")
|
|
121
|
+
if not np.all(np.isfinite(scale)) or np.any(scale == 0):
|
|
122
|
+
raise ValueError("GPMF SCAL must be finite and nonzero")
|
|
123
|
+
values /= scale
|
|
124
|
+
return values
|
gps_video_sync/igc.py
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
from datetime import datetime
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import re
|
|
4
|
+
import numpy as np
|
|
5
|
+
from .models import Track
|
|
6
|
+
|
|
7
|
+
class IGCReader:
|
|
8
|
+
def read(self, path: str | Path, *, altitude: str = "auto") -> Track:
|
|
9
|
+
if altitude not in {"auto", "gps", "pressure"}:
|
|
10
|
+
raise ValueError("altitude must be auto, gps, or pressure")
|
|
11
|
+
rows = []
|
|
12
|
+
day = None
|
|
13
|
+
last_clock = None
|
|
14
|
+
rollover = 0
|
|
15
|
+
for line in Path(path).read_text(errors="replace").splitlines():
|
|
16
|
+
match = re.match(r"H[FO]DTE\s*(?:DATE:)?\s*(\d{6})", line)
|
|
17
|
+
if match and day is None:
|
|
18
|
+
try:
|
|
19
|
+
day = datetime.strptime(match[1], "%d%m%y").date()
|
|
20
|
+
except ValueError:
|
|
21
|
+
pass
|
|
22
|
+
if not line.startswith("B") or len(line) < 35:
|
|
23
|
+
continue
|
|
24
|
+
if not (line[1:14].isascii() and line[1:14].isdigit()
|
|
25
|
+
and line[15:23].isascii() and line[15:23].isdigit()):
|
|
26
|
+
continue
|
|
27
|
+
try:
|
|
28
|
+
h, m, s = int(line[1:3]), int(line[3:5]), int(line[5:7])
|
|
29
|
+
if h > 23 or m > 59 or s > 59:
|
|
30
|
+
continue
|
|
31
|
+
clock = h * 3600 + m * 60 + s
|
|
32
|
+
if line[24] != "A" or line[14] not in "NS" or line[23] not in "EW":
|
|
33
|
+
continue
|
|
34
|
+
lat = (int(line[7:9]) + int(line[9:14]) / 60000) * (1 if line[14] == "N" else -1)
|
|
35
|
+
lon = (int(line[15:18]) + int(line[18:23]) / 60000) * (1 if line[23] == "E" else -1)
|
|
36
|
+
pressure, gps = int(line[25:30]), int(line[30:35])
|
|
37
|
+
if abs(lat) > 90 or abs(lon) > 180:
|
|
38
|
+
continue
|
|
39
|
+
if last_clock is not None and rollover and clock > last_clock + 43200:
|
|
40
|
+
continue
|
|
41
|
+
t = clock + rollover
|
|
42
|
+
if last_clock is not None and clock < last_clock - 43200:
|
|
43
|
+
t += 86400
|
|
44
|
+
if rows and t <= rows[-1][0]:
|
|
45
|
+
continue
|
|
46
|
+
rollover = t - clock
|
|
47
|
+
last_clock = clock
|
|
48
|
+
rows.append((t, lat, lon, pressure, gps))
|
|
49
|
+
except ValueError:
|
|
50
|
+
continue
|
|
51
|
+
if len(rows) < 2:
|
|
52
|
+
raise ValueError("IGC needs at least two valid increasing fixes")
|
|
53
|
+
a = np.asarray(rows, dtype=float)
|
|
54
|
+
choice = altitude
|
|
55
|
+
if choice == "auto":
|
|
56
|
+
choice = "pressure" if np.ptp(a[:, 3]) > 0 and np.any(a[:, 3] != 0) else "gps"
|
|
57
|
+
return Track(a[:, 0], a[:, 1], a[:, 2], a[:, 3 if choice == "pressure" else 4], day, choice)
|