mapillary-tools 0.13.3a1__py3-none-any.whl → 0.14.0a2__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.
Files changed (83) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +237 -16
  3. mapillary_tools/authenticate.py +325 -64
  4. mapillary_tools/{geotag/blackvue_parser.py → blackvue_parser.py} +74 -54
  5. mapillary_tools/camm/camm_builder.py +55 -97
  6. mapillary_tools/camm/camm_parser.py +429 -181
  7. mapillary_tools/commands/__main__.py +12 -6
  8. mapillary_tools/commands/authenticate.py +8 -1
  9. mapillary_tools/commands/process.py +27 -51
  10. mapillary_tools/commands/process_and_upload.py +19 -5
  11. mapillary_tools/commands/sample_video.py +2 -3
  12. mapillary_tools/commands/upload.py +18 -9
  13. mapillary_tools/commands/video_process_and_upload.py +19 -5
  14. mapillary_tools/config.py +31 -13
  15. mapillary_tools/constants.py +47 -6
  16. mapillary_tools/exceptions.py +34 -35
  17. mapillary_tools/exif_read.py +221 -116
  18. mapillary_tools/exif_write.py +7 -7
  19. mapillary_tools/exiftool_read.py +33 -42
  20. mapillary_tools/exiftool_read_video.py +46 -33
  21. mapillary_tools/exiftool_runner.py +77 -0
  22. mapillary_tools/ffmpeg.py +24 -23
  23. mapillary_tools/geo.py +144 -120
  24. mapillary_tools/geotag/base.py +147 -0
  25. mapillary_tools/geotag/factory.py +291 -0
  26. mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
  27. mapillary_tools/geotag/geotag_images_from_exiftool.py +126 -82
  28. mapillary_tools/geotag/geotag_images_from_gpx.py +53 -118
  29. mapillary_tools/geotag/geotag_images_from_gpx_file.py +13 -126
  30. mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -5
  31. mapillary_tools/geotag/geotag_images_from_video.py +53 -51
  32. mapillary_tools/geotag/geotag_videos_from_exiftool.py +97 -0
  33. mapillary_tools/geotag/geotag_videos_from_gpx.py +39 -0
  34. mapillary_tools/geotag/geotag_videos_from_video.py +20 -185
  35. mapillary_tools/geotag/image_extractors/base.py +18 -0
  36. mapillary_tools/geotag/image_extractors/exif.py +60 -0
  37. mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
  38. mapillary_tools/geotag/options.py +160 -0
  39. mapillary_tools/geotag/utils.py +52 -16
  40. mapillary_tools/geotag/video_extractors/base.py +18 -0
  41. mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
  42. mapillary_tools/{video_data_extraction/extractors/gpx_parser.py → geotag/video_extractors/gpx.py} +57 -39
  43. mapillary_tools/geotag/video_extractors/native.py +157 -0
  44. mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
  45. mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
  46. mapillary_tools/history.py +7 -13
  47. mapillary_tools/mp4/construct_mp4_parser.py +9 -8
  48. mapillary_tools/mp4/io_utils.py +0 -1
  49. mapillary_tools/mp4/mp4_sample_parser.py +36 -28
  50. mapillary_tools/mp4/simple_mp4_builder.py +10 -9
  51. mapillary_tools/mp4/simple_mp4_parser.py +13 -22
  52. mapillary_tools/process_geotag_properties.py +155 -392
  53. mapillary_tools/process_sequence_properties.py +562 -208
  54. mapillary_tools/sample_video.py +13 -20
  55. mapillary_tools/telemetry.py +26 -13
  56. mapillary_tools/types.py +111 -58
  57. mapillary_tools/upload.py +316 -298
  58. mapillary_tools/upload_api_v4.py +55 -122
  59. mapillary_tools/uploader.py +396 -254
  60. mapillary_tools/utils.py +42 -18
  61. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/METADATA +3 -2
  62. mapillary_tools-0.14.0a2.dist-info/RECORD +72 -0
  63. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/WHEEL +1 -1
  64. mapillary_tools/geotag/__init__.py +0 -1
  65. mapillary_tools/geotag/geotag_from_generic.py +0 -22
  66. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
  67. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
  68. mapillary_tools/video_data_extraction/cli_options.py +0 -22
  69. mapillary_tools/video_data_extraction/extract_video_data.py +0 -176
  70. mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
  71. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
  72. mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
  73. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -71
  74. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -53
  75. mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
  76. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
  77. mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
  78. mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
  79. mapillary_tools-0.13.3a1.dist-info/RECORD +0 -75
  80. /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
  81. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/entry_points.txt +0 -0
  82. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info/licenses}/LICENSE +0 -0
  83. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/top_level.txt +0 -0
mapillary_tools/ffmpeg.py CHANGED
@@ -1,4 +1,5 @@
1
1
  # pyre-ignore-all-errors[5, 24]
2
+ from __future__ import annotations
2
3
 
3
4
  import datetime
4
5
  import json
@@ -33,7 +34,7 @@ class Stream(T.TypedDict):
33
34
 
34
35
 
35
36
  class ProbeOutput(T.TypedDict):
36
- streams: T.List[Stream]
37
+ streams: list[Stream]
37
38
 
38
39
 
39
40
  class FFmpegNotFoundError(Exception):
@@ -77,7 +78,7 @@ class FFMPEG:
77
78
  self,
78
79
  ffmpeg_path: str = "ffmpeg",
79
80
  ffprobe_path: str = "ffprobe",
80
- stderr: T.Optional[int] = None,
81
+ stderr: int | None = None,
81
82
  ) -> None:
82
83
  """
83
84
  ffmpeg_path: path to ffmpeg binary
@@ -88,8 +89,8 @@ class FFMPEG:
88
89
  self.ffprobe_path = ffprobe_path
89
90
  self.stderr = stderr
90
91
 
91
- def _run_ffprobe_json(self, cmd: T.List[str]) -> T.Dict:
92
- full_cmd: T.List[str] = [self.ffprobe_path, "-print_format", "json", *cmd]
92
+ def _run_ffprobe_json(self, cmd: list[str]) -> dict:
93
+ full_cmd: list[str] = [self.ffprobe_path, "-print_format", "json", *cmd]
93
94
  LOG.info(f"Extracting video information: {' '.join(full_cmd)}")
94
95
  try:
95
96
  completed = subprocess.run(
@@ -132,8 +133,8 @@ class FFMPEG:
132
133
 
133
134
  return output
134
135
 
135
- def _run_ffmpeg(self, cmd: T.List[str]) -> None:
136
- full_cmd: T.List[str] = [self.ffmpeg_path, *cmd]
136
+ def _run_ffmpeg(self, cmd: list[str]) -> None:
137
+ full_cmd: list[str] = [self.ffmpeg_path, *cmd]
137
138
  LOG.info(f"Extracting frames: {' '.join(full_cmd)}")
138
139
  try:
139
140
  subprocess.run(full_cmd, check=True, stderr=self.stderr)
@@ -145,7 +146,7 @@ class FFMPEG:
145
146
  raise FFmpegCalledProcessError(ex) from ex
146
147
 
147
148
  def probe_format_and_streams(self, video_path: Path) -> ProbeOutput:
148
- cmd: T.List[str] = [
149
+ cmd: list[str] = [
149
150
  "-hide_banner",
150
151
  "-show_format",
151
152
  "-show_streams",
@@ -158,7 +159,7 @@ class FFMPEG:
158
159
  video_path: Path,
159
160
  sample_dir: Path,
160
161
  sample_interval: float,
161
- stream_idx: T.Optional[int] = None,
162
+ stream_idx: int | None = None,
162
163
  ) -> None:
163
164
  """
164
165
  Extract frames by the sample interval from the specified video stream.
@@ -175,7 +176,7 @@ class FFMPEG:
175
176
  ouput_template = f"{sample_prefix}_{NA_STREAM_IDX}_%06d{FRAME_EXT}"
176
177
  stream_specifier = "v"
177
178
 
178
- cmd: T.List[str] = [
179
+ cmd: list[str] = [
179
180
  # global options should be specified first
180
181
  *["-hide_banner", "-nostdin"],
181
182
  # input 0
@@ -195,7 +196,7 @@ class FFMPEG:
195
196
 
196
197
  self._run_ffmpeg(cmd)
197
198
 
198
- def generate_binary_search(self, sorted_frame_indices: T.Sequence[int]) -> str:
199
+ def generate_binary_search(self, sorted_frame_indices: list[int]) -> str:
199
200
  length = len(sorted_frame_indices)
200
201
 
201
202
  if length == 0:
@@ -211,8 +212,8 @@ class FFMPEG:
211
212
  self,
212
213
  video_path: Path,
213
214
  sample_dir: Path,
214
- frame_indices: T.Set[int],
215
- stream_idx: T.Optional[int] = None,
215
+ frame_indices: set[int],
216
+ stream_idx: int | None = None,
216
217
  ) -> None:
217
218
  """
218
219
  Extract specified frames from the specified video stream.
@@ -253,7 +254,7 @@ class FFMPEG:
253
254
  # If not close, error "The process cannot access the file because it is being used by another process"
254
255
  if not delete:
255
256
  select_file.close()
256
- cmd: T.List[str] = [
257
+ cmd: list[str] = [
257
258
  # global options should be specified first
258
259
  *["-hide_banner", "-nostdin"],
259
260
  # input 0
@@ -300,7 +301,7 @@ class Probe:
300
301
  def __init__(self, probe: ProbeOutput) -> None:
301
302
  self.probe = probe
302
303
 
303
- def probe_video_start_time(self) -> T.Optional[datetime.datetime]:
304
+ def probe_video_start_time(self) -> datetime.datetime | None:
304
305
  """
305
306
  Find video start time of the given video.
306
307
  It searches video creation time and duration in video streams first and then the other streams.
@@ -327,11 +328,11 @@ class Probe:
327
328
 
328
329
  return None
329
330
 
330
- def probe_video_streams(self) -> T.List[Stream]:
331
+ def probe_video_streams(self) -> list[Stream]:
331
332
  streams = self.probe.get("streams", [])
332
333
  return [stream for stream in streams if stream.get("codec_type") == "video"]
333
334
 
334
- def probe_video_with_max_resolution(self) -> T.Optional[Stream]:
335
+ def probe_video_with_max_resolution(self) -> Stream | None:
335
336
  video_streams = self.probe_video_streams()
336
337
  video_streams.sort(
337
338
  key=lambda s: s.get("width", 0) * s.get("height", 0), reverse=True
@@ -341,7 +342,7 @@ class Probe:
341
342
  return video_streams[0]
342
343
 
343
344
 
344
- def extract_stream_start_time(stream: Stream) -> T.Optional[datetime.datetime]:
345
+ def extract_stream_start_time(stream: Stream) -> datetime.datetime | None:
345
346
  """
346
347
  Find the start time of the given stream.
347
348
  Start time is the creation time of the stream minus the duration of the stream.
@@ -368,7 +369,7 @@ def extract_stream_start_time(stream: Stream) -> T.Optional[datetime.datetime]:
368
369
  def _extract_stream_frame_idx(
369
370
  sample_basename: str,
370
371
  sample_basename_pattern: T.Pattern[str],
371
- ) -> T.Optional[T.Tuple[T.Optional[int], int]]:
372
+ ) -> tuple[int | None, int] | None:
372
373
  """
373
374
  extract stream id and frame index from sample basename
374
375
  e.g. basename GX010001_NA_000000.jpg will extract (None, 0)
@@ -408,7 +409,7 @@ def _extract_stream_frame_idx(
408
409
 
409
410
  def iterate_samples(
410
411
  sample_dir: Path, video_path: Path
411
- ) -> T.Generator[T.Tuple[T.Optional[int], int, Path], None, None]:
412
+ ) -> T.Generator[tuple[int | None, int, Path], None, None]:
412
413
  """
413
414
  Search all samples in the sample_dir,
414
415
  and return a generator of the tuple: (stream ID, frame index, sample path).
@@ -428,17 +429,17 @@ def iterate_samples(
428
429
 
429
430
 
430
431
  def sort_selected_samples(
431
- sample_dir: Path, video_path: Path, selected_stream_indices: T.List[T.Optional[int]]
432
- ) -> T.List[T.Tuple[int, T.List[T.Optional[Path]]]]:
432
+ sample_dir: Path, video_path: Path, selected_stream_indices: list[int | None]
433
+ ) -> list[tuple[int, list[Path | None]]]:
433
434
  """
434
435
  Group frames by frame index, so that
435
436
  the Nth group contains all the frames from the selected streams at frame index N.
436
437
  """
437
- stream_samples: T.Dict[int, T.List[T.Tuple[T.Optional[int], Path]]] = {}
438
+ stream_samples: dict[int, list[tuple[int | None, Path]]] = {}
438
439
  for stream_idx, frame_idx, sample_path in iterate_samples(sample_dir, video_path):
439
440
  stream_samples.setdefault(frame_idx, []).append((stream_idx, sample_path))
440
441
 
441
- selected: T.List[T.Tuple[int, T.List[T.Optional[Path]]]] = []
442
+ selected: list[tuple[int, list[Path | None]]] = []
442
443
  for frame_idx in sorted(stream_samples.keys()):
443
444
  indexed = {
444
445
  stream_idx: sample_path
mapillary_tools/geo.py CHANGED
@@ -1,10 +1,12 @@
1
1
  # pyre-ignore-all-errors[4]
2
+ from __future__ import annotations
2
3
 
3
4
  import bisect
4
5
  import dataclasses
5
6
  import datetime
6
7
  import itertools
7
8
  import math
9
+ import sys
8
10
  import typing as T
9
11
 
10
12
  WGS84_a = 6378137.0
@@ -27,34 +29,14 @@ class Point:
27
29
  time: float
28
30
  lat: float
29
31
  lon: float
30
- alt: T.Optional[float]
31
- angle: T.Optional[float]
32
+ alt: float | None
33
+ angle: float | None
32
34
 
33
35
 
34
- def _ecef_from_lla2(lat: float, lon: float) -> T.Tuple[float, float, float]:
35
- """
36
- Compute ECEF XYZ from latitude, longitude and altitude.
37
-
38
- All using the WGS94 model.
39
- Altitude is the distance to the WGS94 ellipsoid.
40
- Check results here http://www.oc.nps.edu/oc2902w/coord/llhxyz.htm
41
-
42
- """
43
- lat = math.radians(lat)
44
- lon = math.radians(lon)
45
- cos_lat = math.cos(lat)
46
- sin_lat = math.sin(lat)
47
- L = 1.0 / math.sqrt(WGS84_a_SQ * cos_lat**2 + WGS84_b_SQ * sin_lat**2)
48
- K = WGS84_a_SQ * L * cos_lat
49
- x = K * math.cos(lon)
50
- y = K * math.sin(lon)
51
- z = WGS84_b_SQ * L * sin_lat
52
- return x, y, z
36
+ PointLike = T.TypeVar("PointLike", bound=Point)
53
37
 
54
38
 
55
- def gps_distance(
56
- latlon_1: T.Tuple[float, float], latlon_2: T.Tuple[float, float]
57
- ) -> float:
39
+ def gps_distance(latlon_1: tuple[float, float], latlon_2: tuple[float, float]) -> float:
58
40
  """
59
41
  Distance between two (lat,lon) pairs.
60
42
 
@@ -69,19 +51,9 @@ def gps_distance(
69
51
  return math.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2 + (z1 - z2) ** 2)
70
52
 
71
53
 
72
- def get_max_distance_from_start(latlons: T.List[T.Tuple[float, float]]) -> float:
73
- """
74
- Returns the radius of an entire GPS track. Used to calculate whether or not the entire sequence was just stationary video
75
- Takes a sequence of points as input
76
- """
77
- if not latlons:
78
- return 0
79
- start = latlons[0]
80
- return max(gps_distance(start, latlon) for latlon in latlons)
81
-
82
-
83
54
  def compute_bearing(
84
- start_lat: float, start_lon: float, end_lat: float, end_lon: float
55
+ latlon_1: tuple[float, float],
56
+ latlon_2: tuple[float, float],
85
57
  ) -> float:
86
58
  """
87
59
  Get the compass bearing from start to end.
@@ -89,7 +61,10 @@ def compute_bearing(
89
61
  Formula from
90
62
  http://www.movable-type.co.uk/scripts/latlong.html
91
63
  """
92
- # make sure everything is in radians
64
+ start_lat, start_lon = latlon_1
65
+ end_lat, end_lon = latlon_2
66
+
67
+ # Make sure everything is in radians
93
68
  start_lat = math.radians(start_lat)
94
69
  start_lon = math.radians(start_lon)
95
70
  end_lat = math.radians(end_lat)
@@ -125,14 +100,14 @@ _IT = T.TypeVar("_IT")
125
100
 
126
101
 
127
102
  # http://stackoverflow.com/a/5434936
128
- def pairwise(iterable: T.Iterable[_IT]) -> T.Iterable[T.Tuple[_IT, _IT]]:
103
+ def pairwise(iterable: T.Iterable[_IT]) -> T.Iterable[tuple[_IT, _IT]]:
129
104
  """s -> (s0,s1), (s1,s2), (s2, s3), ..."""
130
105
  a, b = itertools.tee(iterable)
131
106
  next(b, None)
132
107
  return zip(a, b)
133
108
 
134
109
 
135
- def as_unix_time(dt: T.Union[datetime.datetime, int, float]) -> float:
110
+ def as_unix_time(dt: datetime.datetime | int | float) -> float:
136
111
  if isinstance(dt, (int, float)):
137
112
  return dt
138
113
  else:
@@ -148,59 +123,37 @@ def as_unix_time(dt: T.Union[datetime.datetime, int, float]) -> float:
148
123
  return 0.0
149
124
 
150
125
 
151
- def _interpolate_segment(start: Point, end: Point, t: float) -> Point:
152
- if start.time == end.time:
153
- weight = 0.0
154
- else:
155
- weight = (t - start.time) / (end.time - start.time)
126
+ if sys.version_info < (3, 10):
156
127
 
157
- lat = start.lat + (end.lat - start.lat) * weight
158
- lon = start.lon + (end.lon - start.lon) * weight
159
- angle = compute_bearing(start.lat, start.lon, end.lat, end.lon)
160
- alt: T.Optional[float]
161
- if start.alt is not None and end.alt is not None:
162
- alt = start.alt + (end.alt - start.alt) * weight
163
- else:
164
- alt = None
165
-
166
- return Point(time=t, lat=lat, lon=lon, alt=alt, angle=angle)
167
-
168
-
169
- def _interpolate_at_index(points: T.Sequence[Point], t: float, idx: int):
170
- assert points, "expect non-empty points"
171
-
172
- # find the segment (start point, end point)
173
- if len(points) == 1:
174
- start, end = points[0], points[0]
175
- else:
176
- if 0 < idx < len(points):
177
- # interpolating within the range
178
- start, end = points[idx - 1], points[idx]
179
- elif idx <= 0:
180
- # extrapolating behind the range
181
- start, end = points[0], points[1]
182
- else:
183
- # extrapolating beyond the range
184
- assert len(points) <= idx
185
- start, end = points[-2], points[-1]
128
+ def interpolate(points: T.Sequence[Point], t: float, lo: int = 0) -> Point:
129
+ """
130
+ Interpolate or extrapolate the point at time t along the sequence of points (sorted by time).
131
+ """
132
+ if not points:
133
+ raise ValueError("Expect non-empty points")
186
134
 
187
- return _interpolate_segment(start, end, t)
135
+ # Make sure that points are sorted (disabled because the check costs O(N)):
136
+ # for cur, nex in pairwise(points):
137
+ # assert cur.time <= nex.time, "Points not sorted"
188
138
 
139
+ p = Point(time=t, lat=float("-inf"), lon=float("-inf"), alt=None, angle=None)
140
+ idx = bisect.bisect_left(points, p, lo=lo)
141
+ return _interpolate_at_segment_idx(points, t, idx)
142
+ else:
189
143
 
190
- def interpolate(points: T.Sequence[Point], t: float, lo: int = 0) -> Point:
191
- """
192
- Interpolate or extrapolate the point at time t along the sequence of points (sorted by time).
193
- """
194
- if not points:
195
- raise ValueError("Expect non-empty points")
144
+ def interpolate(points: T.Sequence[Point], t: float, lo: int = 0) -> Point:
145
+ """
146
+ Interpolate or extrapolate the point at time t along the sequence of points (sorted by time).
147
+ """
148
+ if not points:
149
+ raise ValueError("Expect non-empty points")
196
150
 
197
- # Make sure that points are sorted (disabled because the check costs O(N)):
198
- # for cur, nex in pairwise(points):
199
- # assert cur.time <= nex.time, "Points not sorted"
151
+ # Make sure that points are sorted (disabled because the check costs O(N)):
152
+ # for cur, nex in pairwise(points):
153
+ # assert cur.time <= nex.time, "Points not sorted"
200
154
 
201
- p = Point(time=t, lat=float("-inf"), lon=float("-inf"), alt=None, angle=None)
202
- idx = bisect.bisect_left(points, p, lo=lo)
203
- return _interpolate_at_index(points, t, idx)
155
+ idx = bisect.bisect_left(points, t, lo=lo, key=lambda x: x.time)
156
+ return _interpolate_at_segment_idx(points, t, idx)
204
157
 
205
158
 
206
159
  class Interpolator:
@@ -212,12 +165,22 @@ class Interpolator:
212
165
  track_idx: int
213
166
  # interpolation starts from the lower bound point index in the current track
214
167
  lo: int
215
- prev_time: T.Optional[float]
168
+ prev_time: float | None
216
169
 
217
170
  def __init__(self, tracks: T.Sequence[T.Sequence[Point]]):
171
+ # Remove empty tracks
218
172
  self.tracks = [track for track in tracks if track]
173
+
219
174
  if not self.tracks:
220
- raise ValueError("Expect non-empty tracks")
175
+ raise ValueError("Expect at least one non-empty track")
176
+
177
+ for track in self.tracks:
178
+ for left, right in pairwise(track):
179
+ if not (left.time <= right.time):
180
+ raise ValueError(
181
+ "Expect points to be sorted by time, but got {left.time} then {right.time}"
182
+ )
183
+
221
184
  self.tracks.sort(key=lambda track: track[0].time)
222
185
  self.track_idx = 0
223
186
  self.lo = 0
@@ -225,7 +188,7 @@ class Interpolator:
225
188
 
226
189
  @staticmethod
227
190
  def _lsearch_left(
228
- track: T.Sequence[Point], t: float, lo: int = 0, hi: T.Optional[int] = None
191
+ track: T.Sequence[Point], t: float, lo: int = 0, hi: int | None = None
229
192
  ) -> int:
230
193
  """
231
194
  similar to bisect.bisect_left, but faster in the incremental search case
@@ -244,39 +207,52 @@ class Interpolator:
244
207
 
245
208
  def interpolate(self, t: float) -> Point:
246
209
  if self.prev_time is not None:
247
- assert self.prev_time <= t, "requires time to be monotonically increasing"
210
+ if not (self.prev_time <= t):
211
+ raise ValueError(
212
+ f"Require times to be monotonically increasing, but got {self.prev_time} then {t}"
213
+ )
214
+
215
+ interpolated: Point | None = None
248
216
 
249
217
  while self.track_idx < len(self.tracks):
250
218
  track = self.tracks[self.track_idx]
219
+ assert track, "expect non-empty track"
220
+
251
221
  if t < track[0].time:
252
- return _interpolate_at_index(track, t, 0)
222
+ interpolated = _interpolate_at_segment_idx(track, t, 0)
223
+ break
224
+
253
225
  elif track[0].time <= t <= track[-1].time:
254
- # similar to bisect.bisect_left(points, p, lo=lo) but faster in this case
226
+ # Similar to bisect.bisect_left(points, p, lo=lo) but faster in this case
255
227
  idx = Interpolator._lsearch_left(track, t, lo=self.lo)
256
- # t must sit between (track[idx - 1], track[idx]]
257
- # set the lower bound to idx - 1
258
- # because the next t can still be interpolated anywhere between (track[idx - 1], track[idx]]
228
+ # Time t must be between (track[idx - 1], track[idx]], so set the lower bound to idx - 1
229
+ # Because the next t can still be interpolated anywhere between (track[idx - 1], track[idx]]
259
230
  self.lo = max(idx - 1, 0)
260
- return _interpolate_at_index(track, t, idx)
231
+ interpolated = _interpolate_at_segment_idx(track, t, idx)
232
+ break
233
+
261
234
  self.track_idx += 1
262
235
  self.lo = 0
263
236
 
264
- interpolated = _interpolate_at_index(self.tracks[-1], t, len(self.tracks[-1]))
237
+ if interpolated is None:
238
+ interpolated = _interpolate_at_segment_idx(
239
+ self.tracks[-1], t, len(self.tracks[-1])
240
+ )
265
241
 
266
242
  self.prev_time = t
267
243
 
268
244
  return interpolated
269
245
 
270
246
 
271
- _PointAbstract = T.TypeVar("_PointAbstract")
247
+ _T = T.TypeVar("_T")
272
248
 
273
249
 
274
250
  def sample_points_by_distance(
275
- samples: T.Iterable[_PointAbstract],
251
+ samples: T.Iterable[_T],
276
252
  min_distance: float,
277
- point_func: T.Callable[[_PointAbstract], Point],
278
- ) -> T.Generator[_PointAbstract, None, None]:
279
- prevp: T.Optional[Point] = None
253
+ point_func: T.Callable[[_T], Point],
254
+ ) -> T.Generator[_T, None, None]:
255
+ prevp: Point | None = None
280
256
  for sample in samples:
281
257
  if prevp is None:
282
258
  yield sample
@@ -288,34 +264,82 @@ def sample_points_by_distance(
288
264
  prevp = p
289
265
 
290
266
 
291
- def interpolate_directions_if_none(sequence: T.Sequence[Point]) -> None:
267
+ def interpolate_directions_if_none(sequence: T.Sequence[PointLike]) -> None:
292
268
  for cur, nex in pairwise(sequence):
293
269
  if cur.angle is None:
294
- cur.angle = compute_bearing(cur.lat, cur.lon, nex.lat, nex.lon)
270
+ cur.angle = compute_bearing((cur.lat, cur.lon), (nex.lat, nex.lon))
295
271
 
296
272
  if len(sequence) == 1:
297
273
  if sequence[-1].angle is None:
298
274
  sequence[-1].angle = 0
299
275
  elif 2 <= len(sequence):
300
276
  if sequence[-1].angle is None:
301
- sequence[-1].angle = sequence[-2].angle
277
+ prev_angle = sequence[-2].angle
278
+ assert prev_angle is not None, (
279
+ "expect the last second point to have an interpolated angle"
280
+ )
281
+ sequence[-1].angle = prev_angle
282
+
283
+
284
+ def _ecef_from_lla2(lat: float, lon: float) -> tuple[float, float, float]:
285
+ """
286
+ Compute ECEF XYZ from latitude and longitude.
287
+
288
+ All using the WGS94 model.
289
+ Altitude is the distance to the WGS94 ellipsoid.
290
+ Check results here http://www.oc.nps.edu/oc2902w/coord/llhxyz.htm
291
+
292
+ """
293
+ lat = math.radians(lat)
294
+ lon = math.radians(lon)
295
+ cos_lat = math.cos(lat)
296
+ sin_lat = math.sin(lat)
297
+ L = 1.0 / math.sqrt(WGS84_a_SQ * cos_lat**2 + WGS84_b_SQ * sin_lat**2)
298
+ K = WGS84_a_SQ * L * cos_lat
299
+ x = K * math.cos(lon)
300
+ y = K * math.sin(lon)
301
+ z = WGS84_b_SQ * L * sin_lat
302
+ return x, y, z
303
+
302
304
 
305
+ def _interpolate_segment(start: Point, end: Point, t: float) -> Point:
306
+ try:
307
+ weight = (t - start.time) / (end.time - start.time)
308
+ except ZeroDivisionError:
309
+ weight = 0.0
310
+
311
+ lat = start.lat + (end.lat - start.lat) * weight
312
+ lon = start.lon + (end.lon - start.lon) * weight
313
+ angle = compute_bearing((start.lat, start.lon), (end.lat, end.lon))
314
+ alt: float | None
315
+ if start.alt is not None and end.alt is not None:
316
+ alt = start.alt + (end.alt - start.alt) * weight
317
+ else:
318
+ alt = None
303
319
 
304
- _PointLike = T.TypeVar("_PointLike", bound=Point)
320
+ return Point(time=t, lat=lat, lon=lon, alt=alt, angle=angle)
305
321
 
306
322
 
307
- def extend_deduplicate_points(
308
- sequence: T.Iterable[_PointLike],
309
- to_extend: T.Optional[T.List[_PointLike]] = None,
310
- ) -> T.List[_PointLike]:
311
- if to_extend is None:
312
- to_extend = []
313
- for point in sequence:
314
- if to_extend:
315
- prev = to_extend[-1].lon, to_extend[-1].lat
316
- cur = (point.lon, point.lat)
317
- if cur != prev:
318
- to_extend.append(point)
323
+ def _interpolate_at_segment_idx(points: T.Sequence[Point], t: float, idx: int) -> Point:
324
+ """
325
+ Interpolate time t along the segment between idx - 1 and idx.
326
+ If idx is out of range, extrapolate it to the nearest segment (first or last).
327
+ """
328
+
329
+ if len(points) == 1:
330
+ start, end = points[0], points[0]
331
+ elif 2 <= len(points):
332
+ if 0 < idx < len(points):
333
+ # Normal interpolation within the range
334
+ start, end = points[idx - 1], points[idx]
335
+ elif idx <= 0:
336
+ # Extrapolating before the first point
337
+ start, end = points[0], points[1]
319
338
  else:
320
- to_extend.append(point)
321
- return to_extend
339
+ # Extrapolating after the last point
340
+ assert len(points) <= idx
341
+ start, end = points[-2], points[-1]
342
+ else:
343
+ assert False, "expect non-empty points"
344
+
345
+ return _interpolate_segment(start, end, t)