mapillary-tools 0.13.3__py3-none-any.whl → 0.14.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.
Files changed (87) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +198 -55
  3. mapillary_tools/authenticate.py +326 -64
  4. mapillary_tools/blackvue_parser.py +195 -0
  5. mapillary_tools/camm/camm_builder.py +55 -97
  6. mapillary_tools/camm/camm_parser.py +429 -181
  7. mapillary_tools/commands/__main__.py +10 -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 +18 -5
  11. mapillary_tools/commands/sample_video.py +2 -3
  12. mapillary_tools/commands/upload.py +44 -13
  13. mapillary_tools/commands/video_process_and_upload.py +19 -5
  14. mapillary_tools/config.py +65 -26
  15. mapillary_tools/constants.py +141 -18
  16. mapillary_tools/exceptions.py +37 -34
  17. mapillary_tools/exif_read.py +221 -116
  18. mapillary_tools/exif_write.py +10 -8
  19. mapillary_tools/exiftool_read.py +33 -42
  20. mapillary_tools/exiftool_read_video.py +97 -47
  21. mapillary_tools/exiftool_runner.py +57 -0
  22. mapillary_tools/ffmpeg.py +417 -242
  23. mapillary_tools/geo.py +158 -118
  24. mapillary_tools/geotag/__init__.py +0 -1
  25. mapillary_tools/geotag/base.py +147 -0
  26. mapillary_tools/geotag/factory.py +307 -0
  27. mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
  28. mapillary_tools/geotag/geotag_images_from_exiftool.py +136 -85
  29. mapillary_tools/geotag/geotag_images_from_gpx.py +60 -124
  30. mapillary_tools/geotag/geotag_images_from_gpx_file.py +13 -126
  31. mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -5
  32. mapillary_tools/geotag/geotag_images_from_video.py +88 -51
  33. mapillary_tools/geotag/geotag_videos_from_exiftool.py +123 -0
  34. mapillary_tools/geotag/geotag_videos_from_gpx.py +52 -0
  35. mapillary_tools/geotag/geotag_videos_from_video.py +20 -185
  36. mapillary_tools/geotag/image_extractors/base.py +18 -0
  37. mapillary_tools/geotag/image_extractors/exif.py +60 -0
  38. mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
  39. mapillary_tools/geotag/options.py +182 -0
  40. mapillary_tools/geotag/utils.py +52 -16
  41. mapillary_tools/geotag/video_extractors/base.py +18 -0
  42. mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
  43. mapillary_tools/geotag/video_extractors/gpx.py +116 -0
  44. mapillary_tools/geotag/video_extractors/native.py +160 -0
  45. mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
  46. mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
  47. mapillary_tools/history.py +134 -20
  48. mapillary_tools/mp4/construct_mp4_parser.py +17 -10
  49. mapillary_tools/mp4/io_utils.py +0 -1
  50. mapillary_tools/mp4/mp4_sample_parser.py +36 -28
  51. mapillary_tools/mp4/simple_mp4_builder.py +10 -9
  52. mapillary_tools/mp4/simple_mp4_parser.py +13 -22
  53. mapillary_tools/process_geotag_properties.py +184 -414
  54. mapillary_tools/process_sequence_properties.py +594 -225
  55. mapillary_tools/sample_video.py +20 -26
  56. mapillary_tools/serializer/description.py +587 -0
  57. mapillary_tools/serializer/gpx.py +132 -0
  58. mapillary_tools/telemetry.py +26 -13
  59. mapillary_tools/types.py +98 -611
  60. mapillary_tools/upload.py +411 -387
  61. mapillary_tools/upload_api_v4.py +167 -142
  62. mapillary_tools/uploader.py +804 -284
  63. mapillary_tools/utils.py +49 -18
  64. {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/METADATA +93 -35
  65. mapillary_tools-0.14.0.dist-info/RECORD +75 -0
  66. {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/WHEEL +1 -1
  67. mapillary_tools/geotag/blackvue_parser.py +0 -118
  68. mapillary_tools/geotag/geotag_from_generic.py +0 -22
  69. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
  70. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
  71. mapillary_tools/video_data_extraction/cli_options.py +0 -22
  72. mapillary_tools/video_data_extraction/extract_video_data.py +0 -176
  73. mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
  74. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
  75. mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
  76. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -71
  77. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -53
  78. mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
  79. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
  80. mapillary_tools/video_data_extraction/extractors/gpx_parser.py +0 -108
  81. mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
  82. mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
  83. mapillary_tools-0.13.3.dist-info/RECORD +0 -75
  84. /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
  85. {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/entry_points.txt +0 -0
  86. {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info/licenses}/LICENSE +0 -0
  87. {mapillary_tools-0.13.3.dist-info → mapillary_tools-0.14.0.dist-info}/top_level.txt +0 -0
@@ -1,27 +1,29 @@
1
1
  # pyre-ignore-all-errors[5, 11, 16, 21, 24, 58]
2
+ from __future__ import annotations
2
3
 
4
+ import abc
3
5
  import dataclasses
4
6
  import io
5
7
  import logging
6
- import pathlib
7
8
  import typing as T
8
9
  from enum import Enum
9
10
 
10
11
  import construct as C
12
+ from typing_extensions import TypeIs
11
13
 
12
14
  from .. import geo, telemetry
13
- from ..mp4 import simple_mp4_parser as sparser
14
15
  from ..mp4.mp4_sample_parser import MovieBoxParser, Sample, TrackBoxParser
15
16
 
16
17
 
17
18
  LOG = logging.getLogger(__name__)
19
+ # All fields are little-endian
20
+ _Float = C.Float32l
21
+ _Double = C.Float64l
18
22
 
19
23
 
20
24
  TelemetryMeasurement = T.Union[
21
25
  geo.Point,
22
- telemetry.AccelerationData,
23
- telemetry.GyroscopeData,
24
- telemetry.MagnetometerData,
26
+ telemetry.TimestampedMeasurement,
25
27
  ]
26
28
 
27
29
 
@@ -37,104 +39,404 @@ class CAMMType(Enum):
37
39
  MAGNETIC_FIELD = 7
38
40
 
39
41
 
40
- # All fields are little-endian
41
- Float = C.Float32l
42
- Double = C.Float64l
42
+ TTelemetry = T.TypeVar("TTelemetry", bound=TelemetryMeasurement)
43
43
 
44
- _SWITCH: T.Dict[int, C.Struct] = {
45
- # angle_axis
46
- CAMMType.ANGLE_AXIS.value: Float[3],
47
- CAMMType.EXPOSURE_TIME.value: C.Struct(
48
- "pixel_exposure_time" / C.Int32sl,
49
- "rolling_shutter_skew_time" / C.Int32sl,
50
- ),
51
- # gyro
52
- CAMMType.GYRO.value: Float[3],
53
- # acceleration
54
- CAMMType.ACCELERATION.value: Float[3],
55
- # position
56
- CAMMType.POSITION.value: Float[3],
57
- # lat, lon, alt
58
- CAMMType.MIN_GPS.value: Double[3],
59
- CAMMType.GPS.value: C.Struct(
60
- "time_gps_epoch" / Double,
61
- "gps_fix_type" / C.Int32sl,
62
- "latitude" / Double,
63
- "longitude" / Double,
64
- "altitude" / Float,
65
- "horizontal_accuracy" / Float,
66
- "vertical_accuracy" / Float,
67
- "velocity_east" / Float,
68
- "velocity_north" / Float,
69
- "velocity_up" / Float,
70
- "speed_accuracy" / Float,
71
- ),
72
- # magnetic_field
73
- CAMMType.MAGNETIC_FIELD.value: Float[3],
74
- }
75
44
 
76
- CAMMSampleData = C.Struct(
77
- C.Padding(2),
78
- "type" / C.Int16ul,
79
- "data"
80
- / C.Switch(
81
- C.this.type,
82
- _SWITCH,
83
- ),
84
- )
45
+ @dataclasses.dataclass
46
+ class CAMMInfo:
47
+ # None indicates the data has been extracted,
48
+ # while [] indicates extracetd but no data point found
49
+ mini_gps: list[geo.Point] | None = None
50
+ gps: list[telemetry.CAMMGPSPoint] | None = None
51
+ accl: list[telemetry.AccelerationData] | None = None
52
+ gyro: list[telemetry.GyroscopeData] | None = None
53
+ magn: list[telemetry.MagnetometerData] | None = None
54
+ make: str = ""
55
+ model: str = ""
85
56
 
86
57
 
87
- def _parse_telemetry_from_sample(
88
- fp: T.BinaryIO, sample: Sample
89
- ) -> T.Optional[TelemetryMeasurement]:
90
- fp.seek(sample.raw_sample.offset, io.SEEK_SET)
91
- data = fp.read(sample.raw_sample.size)
92
- box = CAMMSampleData.parse(data)
93
- if box.type == CAMMType.MIN_GPS.value:
58
+ def extract_camm_info(fp: T.BinaryIO, telemetry_only: bool = False) -> CAMMInfo | None:
59
+ moov = MovieBoxParser.parse_stream(fp)
60
+
61
+ make, model = "", ""
62
+ if not telemetry_only:
63
+ udta_boxdata = moov.extract_udta_boxdata()
64
+ if udta_boxdata is not None:
65
+ make, model = _extract_camera_make_and_model_from_utda_boxdata(udta_boxdata)
66
+
67
+ gps_only_construct = _construct_with_selected_camm_types(
68
+ [CAMMType.MIN_GPS, CAMMType.GPS]
69
+ )
70
+ # Optimization: skip parsing sample data smaller than 16 bytes
71
+ # because we are only interested in MIN_GPS and GPS which are larger than 16 bytes
72
+ MIN_GPS_SAMPLE_SIZE = 17
73
+
74
+ for track in moov.extract_tracks():
75
+ if _contains_camm_description(track):
76
+ if telemetry_only:
77
+ maybe_measurements = (
78
+ _parse_telemetry_from_sample(fp, sample)
79
+ for sample in track.extract_samples()
80
+ if _is_camm_description(sample.description)
81
+ )
82
+ measurements = _filter_telemetry_by_track_elst(
83
+ moov, track, (m for m in maybe_measurements if m is not None)
84
+ )
85
+
86
+ accl: list[telemetry.AccelerationData] = []
87
+ gyro: list[telemetry.GyroscopeData] = []
88
+ magn: list[telemetry.MagnetometerData] = []
89
+
90
+ for measurement in measurements:
91
+ if isinstance(measurement, telemetry.AccelerationData):
92
+ accl.append(measurement)
93
+ elif isinstance(measurement, telemetry.GyroscopeData):
94
+ gyro.append(measurement)
95
+ elif isinstance(measurement, telemetry.MagnetometerData):
96
+ magn.append(measurement)
97
+
98
+ return CAMMInfo(accl=accl, gyro=gyro, magn=magn)
99
+ else:
100
+ maybe_measurements = (
101
+ _parse_telemetry_from_sample(fp, sample, gps_only_construct)
102
+ for sample in track.extract_samples()
103
+ if _is_camm_description(sample.description)
104
+ and sample.raw_sample.size >= MIN_GPS_SAMPLE_SIZE
105
+ )
106
+ measurements = _filter_telemetry_by_track_elst(
107
+ moov, track, (m for m in maybe_measurements if m is not None)
108
+ )
109
+
110
+ mini_gps: list[geo.Point] = []
111
+ gps: list[telemetry.CAMMGPSPoint] = []
112
+
113
+ for measurement in measurements:
114
+ if isinstance(measurement, geo.Point):
115
+ mini_gps.append(measurement)
116
+ elif isinstance(measurement, telemetry.CAMMGPSPoint):
117
+ gps.append(measurement)
118
+
119
+ return CAMMInfo(mini_gps=mini_gps, gps=gps, make=make, model=model)
120
+
121
+ return None
122
+
123
+
124
+ def extract_camera_make_and_model(fp: T.BinaryIO) -> tuple[str, str]:
125
+ moov = MovieBoxParser.parse_stream(fp)
126
+ udta_boxdata = moov.extract_udta_boxdata()
127
+ if udta_boxdata is None:
128
+ return "", ""
129
+ return _extract_camera_make_and_model_from_utda_boxdata(udta_boxdata)
130
+
131
+
132
+ class CAMMSampleEntry(abc.ABC, T.Generic[TTelemetry]):
133
+ serialized_camm_type: CAMMType
134
+
135
+ telemetry_cls_type: T.Type[TTelemetry]
136
+
137
+ construct: C.Struct
138
+
139
+ @classmethod
140
+ def serializable(cls, data: T.Any, throw: bool = False) -> TypeIs[TTelemetry]:
141
+ # Use "is" for exact type match, instead of isinstance
142
+ if type(data) is cls.telemetry_cls_type:
143
+ return True
144
+
145
+ if throw:
146
+ raise TypeError(
147
+ f"{cls} can not serialize {type(data)}: expect {cls.telemetry_cls_type}"
148
+ )
149
+
150
+ return False
151
+
152
+ @classmethod
153
+ @abc.abstractmethod
154
+ def serialize(cls, data: TTelemetry) -> bytes:
155
+ raise NotImplementedError
156
+
157
+ @classmethod
158
+ @abc.abstractmethod
159
+ def deserialize(cls, sample: Sample, data: T.Any) -> TTelemetry:
160
+ raise NotImplementedError
161
+
162
+
163
+ class MinGPSSampleEntry(CAMMSampleEntry):
164
+ serialized_camm_type = CAMMType.MIN_GPS
165
+
166
+ telemetry_cls_type = geo.Point
167
+
168
+ construct = _Double[3] # type: ignore
169
+
170
+ @classmethod
171
+ def deserialize(cls, sample: Sample, data: T.Any) -> geo.Point:
94
172
  return geo.Point(
95
173
  time=sample.exact_time,
96
- lat=box.data[0],
97
- lon=box.data[1],
98
- alt=box.data[2],
174
+ lat=data[0],
175
+ lon=data[1],
176
+ alt=data[2],
99
177
  angle=None,
100
178
  )
101
- elif box.type == CAMMType.GPS.value:
102
- # Not using box.data.time_gps_epoch as the point timestamp
103
- # because it is from another clock
104
- return geo.Point(
179
+
180
+ @classmethod
181
+ def serialize(cls, data: geo.Point) -> bytes:
182
+ cls.serializable(data, throw=True)
183
+
184
+ return CAMMSampleData.build(
185
+ {
186
+ "type": cls.serialized_camm_type.value,
187
+ "data": [
188
+ data.lat,
189
+ data.lon,
190
+ -1.0 if data.alt is None else data.alt,
191
+ ],
192
+ }
193
+ )
194
+
195
+
196
+ class GPSSampleEntry(CAMMSampleEntry):
197
+ serialized_camm_type: CAMMType = CAMMType.GPS
198
+
199
+ telemetry_cls_type = telemetry.CAMMGPSPoint
200
+
201
+ construct = C.Struct(
202
+ "time_gps_epoch" / _Double, # type: ignore
203
+ "gps_fix_type" / C.Int32sl, # type: ignore
204
+ "latitude" / _Double, # type: ignore
205
+ "longitude" / _Double, # type: ignore
206
+ "altitude" / _Float, # type: ignore
207
+ "horizontal_accuracy" / _Float, # type: ignore
208
+ "vertical_accuracy" / _Float, # type: ignore
209
+ "velocity_east" / _Float, # type: ignore
210
+ "velocity_north" / _Float, # type: ignore
211
+ "velocity_up" / _Float, # type: ignore
212
+ "speed_accuracy" / _Float, # type: ignore
213
+ )
214
+
215
+ @classmethod
216
+ def deserialize(cls, sample: Sample, data: T.Any) -> telemetry.CAMMGPSPoint:
217
+ return telemetry.CAMMGPSPoint(
105
218
  time=sample.exact_time,
106
- lat=box.data.latitude,
107
- lon=box.data.longitude,
108
- alt=box.data.altitude,
219
+ lat=data.latitude,
220
+ lon=data.longitude,
221
+ alt=data.altitude,
109
222
  angle=None,
223
+ time_gps_epoch=data.time_gps_epoch,
224
+ gps_fix_type=data.gps_fix_type,
225
+ horizontal_accuracy=data.horizontal_accuracy,
226
+ vertical_accuracy=data.vertical_accuracy,
227
+ velocity_east=data.velocity_east,
228
+ velocity_north=data.velocity_north,
229
+ velocity_up=data.velocity_up,
230
+ speed_accuracy=data.speed_accuracy,
231
+ )
232
+
233
+ @classmethod
234
+ def serialize(cls, data: telemetry.CAMMGPSPoint) -> bytes:
235
+ cls.serializable(data, throw=True)
236
+
237
+ return CAMMSampleData.build(
238
+ {
239
+ "type": cls.serialized_camm_type.value,
240
+ "data": {
241
+ "time_gps_epoch": data.time_gps_epoch,
242
+ "gps_fix_type": data.gps_fix_type,
243
+ "latitude": data.lat,
244
+ "longitude": data.lon,
245
+ "altitude": -1.0 if data.alt is None else data.alt,
246
+ "horizontal_accuracy": data.horizontal_accuracy,
247
+ "vertical_accuracy": data.vertical_accuracy,
248
+ "velocity_east": data.velocity_east,
249
+ "velocity_north": data.velocity_north,
250
+ "velocity_up": data.velocity_up,
251
+ "speed_accuracy": data.speed_accuracy,
252
+ },
253
+ }
110
254
  )
111
- elif box.type == CAMMType.ACCELERATION.value:
255
+
256
+
257
+ class GoProGPSSampleEntry(CAMMSampleEntry):
258
+ serialized_camm_type: CAMMType = CAMMType.MIN_GPS
259
+
260
+ telemetry_cls_type = telemetry.GPSPoint
261
+
262
+ construct = _Double[3] # type: ignore
263
+
264
+ @classmethod
265
+ def deserialize(cls, sample: Sample, data: T.Any) -> telemetry.GPSPoint:
266
+ raise NotImplementedError("Deserializing GoPro GPS Point is not supported")
267
+
268
+ @classmethod
269
+ def serialize(cls, data: telemetry.GPSPoint) -> bytes:
270
+ cls.serializable(data, throw=True)
271
+
272
+ return CAMMSampleData.build(
273
+ {
274
+ "type": cls.serialized_camm_type.value,
275
+ "data": [
276
+ data.lat,
277
+ data.lon,
278
+ -1.0 if data.alt is None else data.alt,
279
+ ],
280
+ }
281
+ )
282
+
283
+
284
+ class AccelerationSampleEntry(CAMMSampleEntry):
285
+ serialized_camm_type: CAMMType = CAMMType.ACCELERATION
286
+
287
+ telemetry_cls_type = telemetry.AccelerationData
288
+
289
+ construct: C.Struct = _Float[3] # type: ignore
290
+
291
+ @classmethod
292
+ def deserialize(cls, sample: Sample, data: T.Any) -> telemetry.AccelerationData:
112
293
  return telemetry.AccelerationData(
113
294
  time=sample.exact_time,
114
- x=box.data[0],
115
- y=box.data[1],
116
- z=box.data[2],
295
+ x=data[0],
296
+ y=data[1],
297
+ z=data[2],
117
298
  )
118
- elif box.type == CAMMType.GYRO.value:
299
+
300
+ @classmethod
301
+ def serialize(cls, data: telemetry.AccelerationData) -> bytes:
302
+ cls.serializable(data, throw=True)
303
+
304
+ return CAMMSampleData.build(
305
+ {
306
+ "type": cls.serialized_camm_type.value,
307
+ "data": [data.x, data.y, data.z],
308
+ }
309
+ )
310
+
311
+
312
+ class GyroscopeSampleEntry(CAMMSampleEntry):
313
+ serialized_camm_type: CAMMType = CAMMType.GYRO
314
+
315
+ telemetry_cls_type = telemetry.GyroscopeData
316
+
317
+ construct: C.Struct = _Float[3] # type: ignore
318
+
319
+ @classmethod
320
+ def deserialize(cls, sample: Sample, data: T.Any) -> telemetry.GyroscopeData:
119
321
  return telemetry.GyroscopeData(
120
322
  time=sample.exact_time,
121
- x=box.data[0],
122
- y=box.data[1],
123
- z=box.data[2],
323
+ x=data[0],
324
+ y=data[1],
325
+ z=data[2],
326
+ )
327
+
328
+ @classmethod
329
+ def serialize(cls, data: telemetry.GyroscopeData) -> bytes:
330
+ cls.serializable(data)
331
+
332
+ return CAMMSampleData.build(
333
+ {
334
+ "type": cls.serialized_camm_type.value,
335
+ "data": [data.x, data.y, data.z],
336
+ }
124
337
  )
125
- elif box.type == CAMMType.MAGNETIC_FIELD.value:
338
+
339
+
340
+ class MagnetometerSampleEntry(CAMMSampleEntry):
341
+ serialized_camm_type: CAMMType = CAMMType.MAGNETIC_FIELD
342
+
343
+ telemetry_cls_type = telemetry.MagnetometerData
344
+
345
+ construct: C.Struct = _Float[3] # type: ignore
346
+
347
+ @classmethod
348
+ def deserialize(cls, sample: Sample, data: T.Any) -> telemetry.MagnetometerData:
126
349
  return telemetry.MagnetometerData(
127
350
  time=sample.exact_time,
128
- x=box.data[0],
129
- y=box.data[1],
130
- z=box.data[2],
351
+ x=data[0],
352
+ y=data[1],
353
+ z=data[2],
131
354
  )
132
- return None
355
+
356
+ @classmethod
357
+ def serialize(cls, data: telemetry.MagnetometerData) -> bytes:
358
+ cls.serializable(data)
359
+
360
+ return CAMMSampleData.build(
361
+ {
362
+ "type": cls.serialized_camm_type.value,
363
+ "data": [data.x, data.y, data.z],
364
+ }
365
+ )
366
+
367
+
368
+ SAMPLE_ENTRY_CLS_BY_CAMM_TYPE = {
369
+ sample_entry_cls.serialized_camm_type: sample_entry_cls
370
+ for sample_entry_cls in CAMMSampleEntry.__subclasses__()
371
+ if sample_entry_cls not in [GoProGPSSampleEntry]
372
+ }
373
+ assert len(SAMPLE_ENTRY_CLS_BY_CAMM_TYPE) == 5, SAMPLE_ENTRY_CLS_BY_CAMM_TYPE.keys()
374
+
375
+
376
+ _SWITCH: dict[int, C.Struct] = {
377
+ # Angle_axis
378
+ CAMMType.ANGLE_AXIS.value: _Float[3], # type: ignore
379
+ # Exposure time
380
+ CAMMType.EXPOSURE_TIME.value: C.Struct(
381
+ "pixel_exposure_time" / C.Int32sl, # type: ignore
382
+ "rolling_shutter_skew_time" / C.Int32sl, # type: ignore
383
+ ),
384
+ # Position
385
+ CAMMType.POSITION.value: _Float[3], # type: ignore
386
+ # Serializable types
387
+ **{t.value: cls.construct for t, cls in SAMPLE_ENTRY_CLS_BY_CAMM_TYPE.items()},
388
+ }
389
+
390
+
391
+ def _construct_with_selected_camm_types(
392
+ selected_camm_types: T.Container[CAMMType] | None = None,
393
+ ) -> C.Struct:
394
+ if selected_camm_types is None:
395
+ switch = _SWITCH
396
+ else:
397
+ switch = {
398
+ k: v for k, v in _SWITCH.items() if CAMMType(k) in selected_camm_types
399
+ }
400
+
401
+ return C.Struct(
402
+ C.Padding(2),
403
+ "type" / C.Int16ul,
404
+ "data" / C.Switch(C.this.type, switch),
405
+ )
406
+
407
+
408
+ CAMMSampleData = _construct_with_selected_camm_types()
409
+
410
+
411
+ def _parse_telemetry_from_sample(
412
+ fp: T.BinaryIO,
413
+ sample: Sample,
414
+ construct: C.Struct | None = None,
415
+ ) -> TelemetryMeasurement | None:
416
+ if construct is None:
417
+ construct = CAMMSampleData
418
+
419
+ fp.seek(sample.raw_sample.offset, io.SEEK_SET)
420
+ data = fp.read(sample.raw_sample.size)
421
+
422
+ box = construct.parse(data)
423
+
424
+ # boxdata=None when the construct is unable to parse the data
425
+ # (CAMM type not in the switch)
426
+ if box.data is None:
427
+ return None
428
+
429
+ camm_type = CAMMType(box.type) # type: ignore
430
+ SampleKlass = SAMPLE_ENTRY_CLS_BY_CAMM_TYPE.get(camm_type)
431
+ if SampleKlass is None:
432
+ return None
433
+
434
+ return SampleKlass.deserialize(sample, box.data)
133
435
 
134
436
 
135
437
  def _filter_telemetry_by_elst_segments(
136
438
  measurements: T.Iterable[TelemetryMeasurement],
137
- elst: T.Sequence[T.Tuple[float, float]],
439
+ elst: T.Sequence[tuple[float, float]],
138
440
  ) -> T.Generator[TelemetryMeasurement, None, None]:
139
441
  empty_elst = [entry for entry in elst if entry[0] == -1]
140
442
  if empty_elst:
@@ -164,8 +466,8 @@ def _filter_telemetry_by_elst_segments(
164
466
 
165
467
 
166
468
  def elst_entry_to_seconds(
167
- entry: T.Dict, movie_timescale: int, media_timescale: int
168
- ) -> T.Tuple[float, float]:
469
+ entry: dict, movie_timescale: int, media_timescale: int
470
+ ) -> tuple[float, float]:
169
471
  assert movie_timescale > 0, "expected positive movie_timescale"
170
472
  assert media_timescale > 0, "expected positive media_timescale"
171
473
  media_time, duration = entry["media_time"], entry["segment_duration"]
@@ -175,7 +477,7 @@ def elst_entry_to_seconds(
175
477
  return (media_time, duration)
176
478
 
177
479
 
178
- def _is_camm_description(description: T.Dict) -> bool:
480
+ def _is_camm_description(description: dict) -> bool:
179
481
  return description["format"] == b"camm"
180
482
 
181
483
 
@@ -188,7 +490,7 @@ def _filter_telemetry_by_track_elst(
188
490
  moov: MovieBoxParser,
189
491
  track: TrackBoxParser,
190
492
  measurements: T.Iterable[TelemetryMeasurement],
191
- ) -> T.List[TelemetryMeasurement]:
493
+ ) -> list[TelemetryMeasurement]:
192
494
  elst_boxdata = track.extract_elst_boxdata()
193
495
 
194
496
  if elst_boxdata is not None:
@@ -216,127 +518,73 @@ def _filter_telemetry_by_track_elst(
216
518
  return list(measurements)
217
519
 
218
520
 
219
- def extract_points(fp: T.BinaryIO) -> T.Optional[T.List[geo.Point]]:
220
- """
221
- Return a list of points (could be empty) if it is a valid CAMM video,
222
- otherwise None
223
- """
224
-
225
- moov = MovieBoxParser.parse_stream(fp)
226
-
227
- for track in moov.extract_tracks():
228
- if _contains_camm_description(track):
229
- maybe_measurements = (
230
- _parse_telemetry_from_sample(fp, sample)
231
- for sample in track.extract_samples()
232
- if _is_camm_description(sample.description)
233
- )
234
- points = [m for m in maybe_measurements if isinstance(m, geo.Point)]
235
-
236
- return T.cast(
237
- T.List[geo.Point], _filter_telemetry_by_track_elst(moov, track, points)
238
- )
239
-
240
- return None
241
-
242
-
243
- def extract_telemetry_data(fp: T.BinaryIO) -> T.Optional[T.List[TelemetryMeasurement]]:
244
- moov = MovieBoxParser.parse_stream(fp)
245
-
246
- for track in moov.extract_tracks():
247
- if _contains_camm_description(track):
248
- maybe_measurements = (
249
- _parse_telemetry_from_sample(fp, sample)
250
- for sample in track.extract_samples()
251
- if _is_camm_description(sample.description)
252
- )
253
- measurements = [m for m in maybe_measurements if m is not None]
254
-
255
- measurements = _filter_telemetry_by_track_elst(moov, track, measurements)
256
-
257
- return measurements
258
-
259
- return None
260
-
261
-
262
- def parse_gpx(path: pathlib.Path) -> T.List[geo.Point]:
263
- with path.open("rb") as fp:
264
- points = extract_points(fp)
265
- if points is None:
266
- return []
267
- return points
268
-
269
-
270
- MakeOrModel = C.Struct(
521
+ _MakeOrModel = C.Struct(
271
522
  "size" / C.Int16ub,
272
523
  C.Padding(2),
273
524
  "data" / C.FixedSized(C.this.size, C.GreedyBytes),
274
525
  )
275
526
 
276
527
 
277
- def _decode_quietly(data: bytes, h: sparser.Header) -> str:
528
+ def _decode_quietly(data: bytes, type: bytes) -> str:
278
529
  try:
279
530
  return data.decode("utf-8")
280
531
  except UnicodeDecodeError:
281
- LOG.warning("Failed to decode %s: %s", h, data[:512])
532
+ LOG.warning("Failed to decode %s: %s", type, data[:512])
282
533
  return ""
283
534
 
284
535
 
285
- def _parse_quietly(data: bytes, h: sparser.Header) -> bytes:
536
+ def _parse_quietly(data: bytes, type: bytes) -> bytes:
286
537
  try:
287
- parsed = MakeOrModel.parse(data)
538
+ parsed = _MakeOrModel.parse(data)
288
539
  except C.ConstructError:
289
- LOG.warning("Failed to parse %s: %s", h, data[:512])
540
+ LOG.warning("Failed to parse %s: %s", type, data[:512])
290
541
  return b""
291
- return parsed["data"]
292
542
 
543
+ if parsed is None:
544
+ return b""
293
545
 
294
- def extract_camera_make_and_model(fp: T.BinaryIO) -> T.Tuple[str, str]:
295
- header_and_stream = sparser.parse_path(
296
- fp,
297
- [
298
- b"moov",
299
- b"udta",
300
- [
301
- # Insta360 Titan
302
- b"\xa9mak",
303
- b"\xa9mod",
304
- # RICHO THETA V
305
- b"@mod",
306
- b"@mak",
307
- # RICHO THETA V
308
- b"manu",
309
- b"modl",
310
- ],
311
- ],
312
- )
546
+ return parsed["data"]
313
547
 
314
- make: T.Optional[str] = None
315
- model: T.Optional[str] = None
316
548
 
317
- try:
318
- for h, s in header_and_stream:
319
- data = s.read(h.maxsize)
320
- if h.type == b"\xa9mak":
321
- make_data = _parse_quietly(data, h)
549
+ def _extract_camera_make_and_model_from_utda_boxdata(
550
+ utda_boxdata: dict,
551
+ ) -> tuple[str, str]:
552
+ make: str = ""
553
+ model: str = ""
554
+
555
+ for box in utda_boxdata:
556
+ # Insta360 Titan
557
+ if box.type == b"\xa9mak":
558
+ if not make:
559
+ make_data = _parse_quietly(box.data, box.type)
322
560
  make_data = make_data.rstrip(b"\x00")
323
- make = _decode_quietly(make_data, h)
324
- elif h.type == b"\xa9mod":
325
- model_data = _parse_quietly(data, h)
561
+ make = _decode_quietly(make_data, box.type)
562
+
563
+ # Insta360 Titan
564
+ elif box.type == b"\xa9mod":
565
+ if not model:
566
+ model_data = _parse_quietly(box.data, box.type)
326
567
  model_data = model_data.rstrip(b"\x00")
327
- model = _decode_quietly(model_data, h)
328
- elif h.type in [b"@mak", b"manu"]:
329
- make = _decode_quietly(data, h)
330
- elif h.type in [b"@mod", b"modl"]:
331
- model = _decode_quietly(data, h)
332
- # quit when both found
333
- if make and model:
334
- break
335
- except sparser.ParsingError:
336
- pass
568
+ model = _decode_quietly(model_data, box.type)
569
+
570
+ # RICHO THETA V
571
+ elif box.type in [b"@mak", b"manu"]:
572
+ if not make:
573
+ make = _decode_quietly(box.data, box.type)
574
+
575
+ # RICHO THETA V
576
+ elif box.type in [b"@mod", b"modl"]:
577
+ if not model:
578
+ model = _decode_quietly(box.data, box.type)
579
+
580
+ # quit when both found
581
+ if make and model:
582
+ break
337
583
 
338
584
  if make:
339
585
  make = make.strip()
586
+
340
587
  if model:
341
588
  model = model.strip()
342
- return make or "", model or ""
589
+
590
+ return make, model