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.
- mapillary_tools/__init__.py +1 -1
- mapillary_tools/api_v4.py +237 -16
- mapillary_tools/authenticate.py +325 -64
- mapillary_tools/{geotag/blackvue_parser.py → blackvue_parser.py} +74 -54
- mapillary_tools/camm/camm_builder.py +55 -97
- mapillary_tools/camm/camm_parser.py +429 -181
- mapillary_tools/commands/__main__.py +12 -6
- mapillary_tools/commands/authenticate.py +8 -1
- mapillary_tools/commands/process.py +27 -51
- mapillary_tools/commands/process_and_upload.py +19 -5
- mapillary_tools/commands/sample_video.py +2 -3
- mapillary_tools/commands/upload.py +18 -9
- mapillary_tools/commands/video_process_and_upload.py +19 -5
- mapillary_tools/config.py +31 -13
- mapillary_tools/constants.py +47 -6
- mapillary_tools/exceptions.py +34 -35
- mapillary_tools/exif_read.py +221 -116
- mapillary_tools/exif_write.py +7 -7
- mapillary_tools/exiftool_read.py +33 -42
- mapillary_tools/exiftool_read_video.py +46 -33
- mapillary_tools/exiftool_runner.py +77 -0
- mapillary_tools/ffmpeg.py +24 -23
- mapillary_tools/geo.py +144 -120
- mapillary_tools/geotag/base.py +147 -0
- mapillary_tools/geotag/factory.py +291 -0
- mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
- mapillary_tools/geotag/geotag_images_from_exiftool.py +126 -82
- mapillary_tools/geotag/geotag_images_from_gpx.py +53 -118
- mapillary_tools/geotag/geotag_images_from_gpx_file.py +13 -126
- mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -5
- mapillary_tools/geotag/geotag_images_from_video.py +53 -51
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +97 -0
- mapillary_tools/geotag/geotag_videos_from_gpx.py +39 -0
- mapillary_tools/geotag/geotag_videos_from_video.py +20 -185
- mapillary_tools/geotag/image_extractors/base.py +18 -0
- mapillary_tools/geotag/image_extractors/exif.py +60 -0
- mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
- mapillary_tools/geotag/options.py +160 -0
- mapillary_tools/geotag/utils.py +52 -16
- mapillary_tools/geotag/video_extractors/base.py +18 -0
- mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
- mapillary_tools/{video_data_extraction/extractors/gpx_parser.py → geotag/video_extractors/gpx.py} +57 -39
- mapillary_tools/geotag/video_extractors/native.py +157 -0
- mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
- mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
- mapillary_tools/history.py +7 -13
- mapillary_tools/mp4/construct_mp4_parser.py +9 -8
- mapillary_tools/mp4/io_utils.py +0 -1
- mapillary_tools/mp4/mp4_sample_parser.py +36 -28
- mapillary_tools/mp4/simple_mp4_builder.py +10 -9
- mapillary_tools/mp4/simple_mp4_parser.py +13 -22
- mapillary_tools/process_geotag_properties.py +155 -392
- mapillary_tools/process_sequence_properties.py +562 -208
- mapillary_tools/sample_video.py +13 -20
- mapillary_tools/telemetry.py +26 -13
- mapillary_tools/types.py +111 -58
- mapillary_tools/upload.py +316 -298
- mapillary_tools/upload_api_v4.py +55 -122
- mapillary_tools/uploader.py +396 -254
- mapillary_tools/utils.py +42 -18
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/METADATA +3 -2
- mapillary_tools-0.14.0a2.dist-info/RECORD +72 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/WHEEL +1 -1
- mapillary_tools/geotag/__init__.py +0 -1
- mapillary_tools/geotag/geotag_from_generic.py +0 -22
- mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
- mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
- mapillary_tools/video_data_extraction/cli_options.py +0 -22
- mapillary_tools/video_data_extraction/extract_video_data.py +0 -176
- mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
- mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
- mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
- mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -71
- mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -53
- mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
- mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
- mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
- mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
- mapillary_tools-0.13.3a1.dist-info/RECORD +0 -75
- /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a2.dist-info/licenses}/LICENSE +0 -0
- {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:
|
|
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:
|
|
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:
|
|
92
|
-
full_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:
|
|
136
|
-
full_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:
|
|
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:
|
|
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:
|
|
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:
|
|
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:
|
|
215
|
-
stream_idx:
|
|
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:
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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) ->
|
|
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
|
-
) ->
|
|
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[
|
|
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:
|
|
432
|
-
) ->
|
|
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:
|
|
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:
|
|
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:
|
|
31
|
-
angle:
|
|
32
|
+
alt: float | None
|
|
33
|
+
angle: float | None
|
|
32
34
|
|
|
33
35
|
|
|
34
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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[
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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
|
-
|
|
198
|
-
|
|
199
|
-
|
|
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
|
-
|
|
202
|
-
|
|
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:
|
|
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
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
222
|
+
interpolated = _interpolate_at_segment_idx(track, t, 0)
|
|
223
|
+
break
|
|
224
|
+
|
|
253
225
|
elif track[0].time <= t <= track[-1].time:
|
|
254
|
-
#
|
|
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
|
|
257
|
-
#
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
247
|
+
_T = T.TypeVar("_T")
|
|
272
248
|
|
|
273
249
|
|
|
274
250
|
def sample_points_by_distance(
|
|
275
|
-
samples: T.Iterable[
|
|
251
|
+
samples: T.Iterable[_T],
|
|
276
252
|
min_distance: float,
|
|
277
|
-
point_func: T.Callable[[
|
|
278
|
-
) -> T.Generator[
|
|
279
|
-
prevp:
|
|
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[
|
|
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
|
-
|
|
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
|
-
|
|
320
|
+
return Point(time=t, lat=lat, lon=lon, alt=alt, angle=angle)
|
|
305
321
|
|
|
306
322
|
|
|
307
|
-
def
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
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
|
-
|
|
321
|
-
|
|
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)
|