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.
@@ -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
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
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
@@ -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)