mapillary-tools 0.14.0a2__py3-none-any.whl → 0.14.0b1__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 (38) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +1 -0
  3. mapillary_tools/authenticate.py +9 -9
  4. mapillary_tools/blackvue_parser.py +79 -22
  5. mapillary_tools/config.py +38 -17
  6. mapillary_tools/constants.py +2 -0
  7. mapillary_tools/exiftool_read_video.py +52 -15
  8. mapillary_tools/exiftool_runner.py +4 -24
  9. mapillary_tools/ffmpeg.py +406 -232
  10. mapillary_tools/geotag/__init__.py +0 -0
  11. mapillary_tools/geotag/base.py +2 -2
  12. mapillary_tools/geotag/factory.py +97 -88
  13. mapillary_tools/geotag/geotag_images_from_exiftool.py +26 -19
  14. mapillary_tools/geotag/geotag_images_from_gpx.py +13 -6
  15. mapillary_tools/geotag/geotag_images_from_video.py +35 -0
  16. mapillary_tools/geotag/geotag_videos_from_exiftool.py +39 -13
  17. mapillary_tools/geotag/geotag_videos_from_gpx.py +22 -9
  18. mapillary_tools/geotag/options.py +25 -3
  19. mapillary_tools/geotag/video_extractors/base.py +1 -1
  20. mapillary_tools/geotag/video_extractors/exiftool.py +1 -1
  21. mapillary_tools/geotag/video_extractors/gpx.py +60 -70
  22. mapillary_tools/geotag/video_extractors/native.py +9 -31
  23. mapillary_tools/history.py +4 -1
  24. mapillary_tools/process_geotag_properties.py +16 -8
  25. mapillary_tools/process_sequence_properties.py +9 -11
  26. mapillary_tools/sample_video.py +7 -6
  27. mapillary_tools/serializer/description.py +587 -0
  28. mapillary_tools/serializer/gpx.py +132 -0
  29. mapillary_tools/types.py +44 -610
  30. mapillary_tools/upload.py +176 -197
  31. mapillary_tools/upload_api_v4.py +94 -51
  32. mapillary_tools/uploader.py +284 -138
  33. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.0b1.dist-info}/METADATA +87 -31
  34. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.0b1.dist-info}/RECORD +38 -35
  35. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.0b1.dist-info}/WHEEL +1 -1
  36. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.0b1.dist-info}/entry_points.txt +0 -0
  37. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.0b1.dist-info}/licenses/LICENSE +0 -0
  38. {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.0b1.dist-info}/top_level.txt +0 -0
File without changes
@@ -51,7 +51,7 @@ class GeotagImagesFromGeneric(abc.ABC, T.Generic[TImageExtractor]):
51
51
  )
52
52
  )
53
53
 
54
- return results + error_metadatas
54
+ return T.cast(list[types.ImageMetadataOrError], results + error_metadatas)
55
55
 
56
56
  # This method is passed to multiprocessing
57
57
  # so it has to be classmethod or staticmethod to avoid pickling the instance
@@ -117,7 +117,7 @@ class GeotagVideosFromGeneric(abc.ABC, T.Generic[TVideoExtractor]):
117
117
  )
118
118
  )
119
119
 
120
- return results + error_metadatas
120
+ return T.cast(list[types.VideoMetadataOrError], results + error_metadatas)
121
121
 
122
122
  # This method is passed to multiprocessing
123
123
  # so it has to be classmethod or staticmethod to avoid pickling the instance
@@ -6,7 +6,6 @@ import typing as T
6
6
  from pathlib import Path
7
7
 
8
8
  from .. import exceptions, types, utils
9
- from ..types import FileType
10
9
  from . import (
11
10
  base,
12
11
  geotag_images_from_exif,
@@ -70,8 +69,25 @@ def process(
70
69
  for idx, option in enumerate(options):
71
70
  LOG.debug("Processing %d files with %s", len(reprocessable_paths), option)
72
71
 
73
- image_metadata_or_errors = _geotag_images(reprocessable_paths, option)
74
- video_metadata_or_errors = _geotag_videos(reprocessable_paths, option)
72
+ image_videos, video_paths = _filter_images_and_videos(
73
+ reprocessable_paths, option.filetypes
74
+ )
75
+
76
+ if image_videos:
77
+ image_geotag = _build_image_geotag(option)
78
+ image_metadata_or_errors = (
79
+ image_geotag.to_description(image_videos) if image_geotag else []
80
+ )
81
+ else:
82
+ image_metadata_or_errors = []
83
+
84
+ if video_paths:
85
+ video_geotag = _build_video_geotag(option)
86
+ video_metadata_or_errors = (
87
+ video_geotag.to_description(video_paths) if video_geotag else []
88
+ )
89
+ else:
90
+ video_metadata_or_errors = []
75
91
 
76
92
  more_option = idx < len(options) - 1
77
93
 
@@ -97,6 +113,7 @@ def _is_reprocessable(metadata: types.MetadataOrError) -> bool:
97
113
  (
98
114
  exceptions.MapillaryGeoTaggingError,
99
115
  exceptions.MapillaryVideoGPSNotFoundError,
116
+ exceptions.MapillaryExiftoolNotFoundError,
100
117
  ),
101
118
  ):
102
119
  return True
@@ -140,152 +157,144 @@ def _ensure_source_path(option: SourceOption) -> Path:
140
157
  return option.source_path.source_path
141
158
 
142
159
 
143
- def _geotag_images(
144
- paths: T.Iterable[Path], option: SourceOption
145
- ) -> list[types.ImageMetadataOrError]:
146
- image_paths, _ = _filter_images_and_videos(paths, option.filetypes)
147
-
148
- if not image_paths:
149
- return []
150
-
160
+ def _build_image_geotag(option: SourceOption) -> base.GeotagImagesFromGeneric | None:
161
+ """
162
+ Build a GeotagImagesFromGeneric object based on the provided option.
163
+ """
151
164
  if option.interpolation is None:
152
165
  interpolation = InterpolationOption()
153
166
  else:
154
167
  interpolation = option.interpolation
155
168
 
156
- geotag: base.GeotagImagesFromGeneric
157
-
158
- if option.source is SourceType.NATIVE:
159
- geotag = geotag_images_from_exif.GeotagImagesFromEXIF(
169
+ if option.source in [SourceType.EXIF, SourceType.NATIVE]:
170
+ return geotag_images_from_exif.GeotagImagesFromEXIF(
160
171
  num_processes=option.num_processes
161
172
  )
162
- return geotag.to_description(image_paths)
163
173
 
164
174
  if option.source is SourceType.EXIFTOOL_RUNTIME:
165
- geotag = geotag_images_from_exiftool.GeotagImagesFromExifToolRunner(
175
+ return geotag_images_from_exiftool.GeotagImagesFromExifToolRunner(
166
176
  num_processes=option.num_processes
167
177
  )
168
- try:
169
- return geotag.to_description(image_paths)
170
- except exceptions.MapillaryExiftoolNotFoundError as ex:
171
- LOG.warning('Skip "%s" because: %s', option.source.value, ex)
172
- return []
173
178
 
174
179
  elif option.source is SourceType.EXIFTOOL_XML:
175
180
  # This is to ensure 'video_process --geotag={"source": "exiftool_xml", "source_path": "/tmp/xml_path"}'
176
181
  # to work
177
- geotag = geotag_images_from_exiftool.GeotagImagesFromExifToolWithSamples(
178
- xml_path=_ensure_source_path(option),
182
+ if option.source_path is None:
183
+ raise exceptions.MapillaryBadParameterError(
184
+ "source_path must be provided for EXIFTOOL_XML source"
185
+ )
186
+ return geotag_images_from_exiftool.GeotagImagesFromExifToolWithSamples(
187
+ source_path=option.source_path,
179
188
  num_processes=option.num_processes,
180
189
  )
181
- return geotag.to_description(image_paths)
182
190
 
183
191
  elif option.source is SourceType.GPX:
184
- geotag = geotag_images_from_gpx_file.GeotagImagesFromGPXFile(
192
+ return geotag_images_from_gpx_file.GeotagImagesFromGPXFile(
185
193
  source_path=_ensure_source_path(option),
186
194
  use_gpx_start_time=interpolation.use_gpx_start_time,
187
195
  offset_time=interpolation.offset_time,
188
196
  num_processes=option.num_processes,
189
197
  )
190
- return geotag.to_description(image_paths)
191
198
 
192
199
  elif option.source is SourceType.NMEA:
193
- geotag = geotag_images_from_nmea_file.GeotagImagesFromNMEAFile(
200
+ return geotag_images_from_nmea_file.GeotagImagesFromNMEAFile(
194
201
  source_path=_ensure_source_path(option),
195
202
  use_gpx_start_time=interpolation.use_gpx_start_time,
196
203
  offset_time=interpolation.offset_time,
197
204
  num_processes=option.num_processes,
198
205
  )
199
206
 
200
- return geotag.to_description(image_paths)
201
-
202
- elif option.source is SourceType.EXIF:
203
- geotag = geotag_images_from_exif.GeotagImagesFromEXIF(
204
- num_processes=option.num_processes
205
- )
206
- return geotag.to_description(image_paths)
207
-
208
- elif option.source in [
209
- SourceType.GOPRO,
210
- SourceType.BLACKVUE,
211
- SourceType.CAMM,
212
- ]:
213
- map_geotag_source_to_filetype: dict[SourceType, FileType] = {
214
- SourceType.GOPRO: FileType.GOPRO,
215
- SourceType.BLACKVUE: FileType.BLACKVUE,
216
- SourceType.CAMM: FileType.CAMM,
217
- }
218
- video_paths = utils.find_videos([_ensure_source_path(option)])
219
- image_samples_by_video_path = utils.find_all_image_samples(
220
- image_paths, video_paths
221
- )
222
- video_paths_with_image_samples = list(image_samples_by_video_path.keys())
223
- video_metadatas = geotag_videos_from_video.GeotagVideosFromVideo(
224
- filetypes={map_geotag_source_to_filetype[option.source]},
225
- num_processes=option.num_processes,
226
- ).to_description(video_paths_with_image_samples)
227
- geotag = geotag_images_from_video.GeotagImagesFromVideo(
228
- video_metadatas,
207
+ elif option.source in [SourceType.GOPRO, SourceType.BLACKVUE, SourceType.CAMM]:
208
+ return geotag_images_from_video.GeotagImageSamplesFromVideo(
209
+ _ensure_source_path(option),
229
210
  offset_time=interpolation.offset_time,
230
211
  num_processes=option.num_processes,
231
212
  )
232
- return geotag.to_description(image_paths)
233
213
 
234
214
  else:
235
215
  raise ValueError(f"Invalid geotag source {option.source}")
236
216
 
237
217
 
238
- def _geotag_videos(
239
- paths: T.Iterable[Path], option: SourceOption
240
- ) -> list[types.VideoMetadataOrError]:
241
- _, video_paths = _filter_images_and_videos(paths, option.filetypes)
242
-
243
- if not video_paths:
244
- return []
245
-
246
- geotag: base.GeotagVideosFromGeneric
218
+ def _build_video_geotag(option: SourceOption) -> base.GeotagVideosFromGeneric | None:
219
+ """
220
+ Build a GeotagVideosFromGeneric object based on the provided option.
247
221
 
222
+ Examples:
223
+ >>> from pathlib import Path
224
+ >>> from mapillary_tools.geotag.options import SourceOption, SourceType
225
+ >>> opt = SourceOption(SourceType.NATIVE)
226
+ >>> geotagger = _build_video_geotag(opt)
227
+ >>> geotagger.__class__.__name__
228
+ 'GeotagVideosFromVideo'
229
+
230
+ >>> opt = SourceOption(SourceType.EXIFTOOL_RUNTIME)
231
+ >>> geotagger = _build_video_geotag(opt)
232
+ >>> geotagger.__class__.__name__
233
+ 'GeotagVideosFromExifToolRunner'
234
+
235
+ >>> opt = SourceOption(SourceType.EXIFTOOL_XML, source_path=Path("/tmp/test.xml"))
236
+ >>> geotagger = _build_video_geotag(opt)
237
+ >>> geotagger.__class__.__name__
238
+ 'GeotagVideosFromExifToolXML'
239
+
240
+ >>> opt = SourceOption(SourceType.GPX, source_path=Path("/tmp/test.gpx"))
241
+ >>> geotagger = _build_video_geotag(opt)
242
+ >>> geotagger.__class__.__name__
243
+ 'GeotagVideosFromGPX'
244
+
245
+ >>> opt = SourceOption(SourceType.NMEA, source_path=Path("/tmp/test.nmea"))
246
+ >>> _build_video_geotag(opt) is None
247
+ True
248
+
249
+ >>> opt = SourceOption(SourceType.EXIF)
250
+ >>> _build_video_geotag(opt) is None
251
+ True
252
+
253
+ >>> opt = SourceOption(SourceType.GOPRO)
254
+ >>> _build_video_geotag(opt) is None
255
+ True
256
+
257
+ >>> try:
258
+ ... _build_video_geotag(SourceOption("invalid"))
259
+ ... except ValueError as e:
260
+ ... "Invalid geotag source" in str(e)
261
+ True
262
+ """
248
263
  if option.source is SourceType.NATIVE:
249
- geotag = geotag_videos_from_video.GeotagVideosFromVideo(
264
+ return geotag_videos_from_video.GeotagVideosFromVideo(
250
265
  num_processes=option.num_processes, filetypes=option.filetypes
251
266
  )
252
- return geotag.to_description(video_paths)
253
267
 
254
268
  if option.source is SourceType.EXIFTOOL_RUNTIME:
255
- geotag = geotag_videos_from_exiftool.GeotagVideosFromExifToolRunner(
269
+ return geotag_videos_from_exiftool.GeotagVideosFromExifToolRunner(
256
270
  num_processes=option.num_processes
257
271
  )
258
- try:
259
- return geotag.to_description(video_paths)
260
- except exceptions.MapillaryExiftoolNotFoundError as ex:
261
- LOG.warning('Skip "%s" because: %s', option.source.value, ex)
262
- return []
263
272
 
264
273
  elif option.source is SourceType.EXIFTOOL_XML:
265
- geotag = geotag_videos_from_exiftool.GeotagVideosFromExifToolXML(
266
- xml_path=_ensure_source_path(option),
274
+ if option.source_path is None:
275
+ raise exceptions.MapillaryBadParameterError(
276
+ "source_path must be provided for EXIFTOOL_XML source"
277
+ )
278
+ return geotag_videos_from_exiftool.GeotagVideosFromExifToolXML(
279
+ source_path=option.source_path,
267
280
  )
268
- return geotag.to_description(video_paths)
269
281
 
270
282
  elif option.source is SourceType.GPX:
271
- geotag = geotag_videos_from_gpx.GeotagVideosFromGPX()
272
- return geotag.to_description(video_paths)
283
+ return geotag_videos_from_gpx.GeotagVideosFromGPX(
284
+ source_path=option.source_path, num_processes=option.num_processes
285
+ )
273
286
 
274
287
  elif option.source is SourceType.NMEA:
275
288
  # TODO: geotag videos from NMEA
276
- return []
289
+ return None
277
290
 
278
291
  elif option.source is SourceType.EXIF:
279
292
  # Legacy image-specific geotag types
280
- return []
293
+ return None
281
294
 
282
- elif option.source in [
283
- SourceType.GOPRO,
284
- SourceType.BLACKVUE,
285
- SourceType.CAMM,
286
- ]:
295
+ elif option.source in [SourceType.GOPRO, SourceType.BLACKVUE, SourceType.CAMM]:
287
296
  # Legacy image-specific geotag types
288
- return []
297
+ return None
289
298
 
290
299
  else:
291
300
  raise ValueError(f"Invalid geotag source {option.source}")
@@ -13,29 +13,25 @@ else:
13
13
 
14
14
  from .. import constants, exceptions, exiftool_read, types, utils
15
15
  from ..exiftool_runner import ExiftoolRunner
16
+ from . import options
16
17
  from .base import GeotagImagesFromGeneric
17
18
  from .geotag_images_from_video import GeotagImagesFromVideo
18
19
  from .geotag_videos_from_exiftool import GeotagVideosFromExifToolXML
19
20
  from .image_extractors.exiftool import ImageExifToolExtractor
20
- from .utils import index_rdf_description_by_path
21
21
 
22
22
  LOG = logging.getLogger(__name__)
23
23
 
24
24
 
25
25
  class GeotagImagesFromExifToolXML(GeotagImagesFromGeneric):
26
26
  def __init__(
27
- self,
28
- xml_path: Path,
29
- num_processes: int | None = None,
27
+ self, source_path: options.SourcePathOption, num_processes: int | None = None
30
28
  ):
31
- self.xml_path = xml_path
29
+ self.source_path = source_path
32
30
  super().__init__(num_processes=num_processes)
33
31
 
34
32
  @classmethod
35
33
  def build_image_extractors(
36
- cls,
37
- rdf_by_path: dict[str, ET.Element],
38
- image_paths: T.Iterable[Path],
34
+ cls, rdf_by_path: dict[str, ET.Element], image_paths: T.Iterable[Path]
39
35
  ) -> list[ImageExifToolExtractor | types.ErrorMetadata]:
40
36
  results: list[ImageExifToolExtractor | types.ErrorMetadata] = []
41
37
 
@@ -59,7 +55,9 @@ class GeotagImagesFromExifToolXML(GeotagImagesFromGeneric):
59
55
  def _generate_image_extractors(
60
56
  self, image_paths: T.Sequence[Path]
61
57
  ) -> T.Sequence[ImageExifToolExtractor | types.ErrorMetadata]:
62
- rdf_by_path = index_rdf_description_by_path([self.xml_path])
58
+ rdf_by_path = GeotagVideosFromExifToolXML.find_rdf_by_path(
59
+ self.source_path, image_paths
60
+ )
63
61
  return self.build_image_extractors(rdf_by_path, image_paths)
64
62
 
65
63
 
@@ -68,7 +66,10 @@ class GeotagImagesFromExifToolRunner(GeotagImagesFromGeneric):
68
66
  def _generate_image_extractors(
69
67
  self, image_paths: T.Sequence[Path]
70
68
  ) -> T.Sequence[ImageExifToolExtractor | types.ErrorMetadata]:
71
- runner = ExiftoolRunner(constants.EXIFTOOL_PATH)
69
+ if constants.EXIFTOOL_PATH is None:
70
+ runner = ExiftoolRunner()
71
+ else:
72
+ runner = ExiftoolRunner(constants.EXIFTOOL_PATH)
72
73
 
73
74
  LOG.debug(
74
75
  "Extracting XML from %d images with ExifTool command: %s",
@@ -78,7 +79,13 @@ class GeotagImagesFromExifToolRunner(GeotagImagesFromGeneric):
78
79
  try:
79
80
  xml = runner.extract_xml(image_paths)
80
81
  except FileNotFoundError as ex:
81
- raise exceptions.MapillaryExiftoolNotFoundError(ex) from ex
82
+ exiftool_ex = exceptions.MapillaryExiftoolNotFoundError(ex)
83
+ return [
84
+ types.describe_error_metadata(
85
+ exiftool_ex, image_path, filetype=types.FileType.IMAGE
86
+ )
87
+ for image_path in image_paths
88
+ ]
82
89
 
83
90
  try:
84
91
  xml_element = ET.fromstring(xml)
@@ -102,29 +109,30 @@ class GeotagImagesFromExifToolRunner(GeotagImagesFromGeneric):
102
109
  class GeotagImagesFromExifToolWithSamples(GeotagImagesFromGeneric):
103
110
  def __init__(
104
111
  self,
105
- xml_path: Path,
112
+ source_path: options.SourcePathOption,
106
113
  offset_time: float = 0.0,
107
114
  num_processes: int | None = None,
108
115
  ):
109
116
  super().__init__(num_processes=num_processes)
110
- self.xml_path = xml_path
117
+ self.source_path = source_path
111
118
  self.offset_time = offset_time
112
119
 
113
120
  def geotag_samples(
114
121
  self, image_paths: T.Sequence[Path]
115
122
  ) -> list[types.ImageMetadataOrError]:
116
123
  # Find all video paths in self.xml_path
117
- rdf_by_path = index_rdf_description_by_path([self.xml_path])
124
+ rdf_by_path = GeotagVideosFromExifToolXML.find_rdf_by_path(
125
+ self.source_path, image_paths
126
+ )
118
127
  video_paths = utils.find_videos(
119
- [Path(pathstr) for pathstr in rdf_by_path.keys()],
128
+ [Path(canonical_path) for canonical_path in rdf_by_path.keys()],
120
129
  skip_subfolders=True,
121
130
  )
122
131
  # Find all video paths that have sample images
123
132
  samples_by_video = utils.find_all_image_samples(image_paths, video_paths)
124
133
 
125
134
  video_metadata_or_errors = GeotagVideosFromExifToolXML(
126
- self.xml_path,
127
- num_processes=self.num_processes,
135
+ self.source_path, num_processes=self.num_processes
128
136
  ).to_description(list(samples_by_video.keys()))
129
137
  sample_paths = sum(samples_by_video.values(), [])
130
138
  sample_metadata_or_errors = GeotagImagesFromVideo(
@@ -146,8 +154,7 @@ class GeotagImagesFromExifToolWithSamples(GeotagImagesFromGeneric):
146
154
  non_sample_paths = [path for path in image_paths if path not in sample_paths]
147
155
 
148
156
  non_sample_metadata_or_errors = GeotagImagesFromExifToolXML(
149
- self.xml_path,
150
- num_processes=self.num_processes,
157
+ self.source_path, num_processes=self.num_processes
151
158
  ).to_description(non_sample_paths)
152
159
 
153
160
  return sample_metadata_or_errors + non_sample_metadata_or_errors
@@ -12,6 +12,7 @@ else:
12
12
  from typing_extensions import override
13
13
 
14
14
  from .. import exceptions, geo, types
15
+ from ..serializer.description import build_capture_time
15
16
  from .base import GeotagImagesFromGeneric
16
17
  from .geotag_images_from_exif import ImageEXIFExtractor
17
18
 
@@ -19,6 +20,12 @@ from .geotag_images_from_exif import ImageEXIFExtractor
19
20
  LOG = logging.getLogger(__name__)
20
21
 
21
22
 
23
+ class SyncMode:
24
+ SYNC = "sync"
25
+ STRICT_SYNC = "strict_sync"
26
+ RESET = "reset"
27
+
28
+
22
29
  class GeotagImagesFromGPX(GeotagImagesFromGeneric):
23
30
  def __init__(
24
31
  self,
@@ -43,26 +50,26 @@ class GeotagImagesFromGPX(GeotagImagesFromGeneric):
43
50
 
44
51
  if image_metadata.time < sorted_points[0].time:
45
52
  delta = sorted_points[0].time - image_metadata.time
46
- gpx_start_time = types.datetime_to_map_capture_time(sorted_points[0].time)
47
- gpx_end_time = types.datetime_to_map_capture_time(sorted_points[-1].time)
53
+ gpx_start_time = build_capture_time(sorted_points[0].time)
54
+ gpx_end_time = build_capture_time(sorted_points[-1].time)
48
55
  # with the tolerance of 1ms
49
56
  if 0.001 < delta:
50
57
  raise exceptions.MapillaryOutsideGPXTrackError(
51
58
  f"The image date time is {round(delta, 3)} seconds behind the GPX start point",
52
- image_time=types.datetime_to_map_capture_time(image_metadata.time),
59
+ image_time=build_capture_time(image_metadata.time),
53
60
  gpx_start_time=gpx_start_time,
54
61
  gpx_end_time=gpx_end_time,
55
62
  )
56
63
 
57
64
  if sorted_points[-1].time < image_metadata.time:
58
65
  delta = image_metadata.time - sorted_points[-1].time
59
- gpx_start_time = types.datetime_to_map_capture_time(sorted_points[0].time)
60
- gpx_end_time = types.datetime_to_map_capture_time(sorted_points[-1].time)
66
+ gpx_start_time = build_capture_time(sorted_points[0].time)
67
+ gpx_end_time = build_capture_time(sorted_points[-1].time)
61
68
  # with the tolerance of 1ms
62
69
  if 0.001 < delta:
63
70
  raise exceptions.MapillaryOutsideGPXTrackError(
64
71
  f"The image time is {round(delta, 3)} seconds beyond the GPX end point",
65
- image_time=types.datetime_to_map_capture_time(image_metadata.time),
72
+ image_time=build_capture_time(image_metadata.time),
66
73
  gpx_start_time=gpx_start_time,
67
74
  gpx_end_time=gpx_end_time,
68
75
  )
@@ -13,6 +13,7 @@ else:
13
13
  from .. import types, utils
14
14
  from .base import GeotagImagesFromGeneric
15
15
  from .geotag_images_from_gpx import GeotagImagesFromGPX
16
+ from .geotag_videos_from_video import GeotagVideosFromVideo
16
17
 
17
18
 
18
19
  LOG = logging.getLogger(__name__)
@@ -90,3 +91,37 @@ class GeotagImagesFromVideo(GeotagImagesFromGeneric):
90
91
  assert len(final_image_metadatas) <= len(image_paths)
91
92
 
92
93
  return final_image_metadatas
94
+
95
+
96
+ class GeotagImageSamplesFromVideo(GeotagImagesFromGeneric):
97
+ def __init__(
98
+ self,
99
+ source_path: Path,
100
+ filetypes: set[types.FileType] | None = None,
101
+ offset_time: float = 0.0,
102
+ num_processes: int | None = None,
103
+ ):
104
+ super().__init__(num_processes=num_processes)
105
+ self.source_path = source_path
106
+ self.filetypes = filetypes
107
+ self.offset_time = offset_time
108
+
109
+ @override
110
+ def to_description(
111
+ self, image_paths: T.Sequence[Path]
112
+ ) -> list[types.ImageMetadataOrError]:
113
+ video_paths = utils.find_videos([self.source_path])
114
+ image_samples_by_video_path = utils.find_all_image_samples(
115
+ image_paths, video_paths
116
+ )
117
+ video_paths_with_image_samples = list(image_samples_by_video_path.keys())
118
+ video_metadatas = GeotagVideosFromVideo(
119
+ filetypes=self.filetypes,
120
+ num_processes=self.num_processes,
121
+ ).to_description(video_paths_with_image_samples)
122
+ geotag = GeotagImagesFromVideo(
123
+ video_metadatas,
124
+ offset_time=self.offset_time,
125
+ num_processes=self.num_processes,
126
+ )
127
+ return geotag.to_description(image_paths)
@@ -13,6 +13,7 @@ else:
13
13
 
14
14
  from .. import constants, exceptions, exiftool_read, types
15
15
  from ..exiftool_runner import ExiftoolRunner
16
+ from . import options
16
17
  from .base import GeotagVideosFromGeneric
17
18
  from .utils import index_rdf_description_by_path
18
19
  from .video_extractors.exiftool import VideoExifToolExtractor
@@ -22,18 +23,14 @@ LOG = logging.getLogger(__name__)
22
23
 
23
24
  class GeotagVideosFromExifToolXML(GeotagVideosFromGeneric):
24
25
  def __init__(
25
- self,
26
- xml_path: Path,
27
- num_processes: int | None = None,
26
+ self, source_path: options.SourcePathOption, num_processes: int | None = None
28
27
  ):
29
28
  super().__init__(num_processes=num_processes)
30
- self.xml_path = xml_path
29
+ self.source_path = source_path
31
30
 
32
31
  @classmethod
33
- def build_image_extractors(
34
- cls,
35
- rdf_by_path: dict[str, ET.Element],
36
- video_paths: T.Iterable[Path],
32
+ def build_video_extractors_from_etree(
33
+ cls, rdf_by_path: dict[str, ET.Element], video_paths: T.Iterable[Path]
37
34
  ) -> list[VideoExifToolExtractor | types.ErrorMetadata]:
38
35
  results: list[VideoExifToolExtractor | types.ErrorMetadata] = []
39
36
 
@@ -57,8 +54,28 @@ class GeotagVideosFromExifToolXML(GeotagVideosFromGeneric):
57
54
  def _generate_video_extractors(
58
55
  self, video_paths: T.Sequence[Path]
59
56
  ) -> T.Sequence[VideoExifToolExtractor | types.ErrorMetadata]:
60
- rdf_by_path = index_rdf_description_by_path([self.xml_path])
61
- return self.build_image_extractors(rdf_by_path, video_paths)
57
+ rdf_by_path = self.find_rdf_by_path(self.source_path, video_paths)
58
+ return self.build_video_extractors_from_etree(rdf_by_path, video_paths)
59
+
60
+ @classmethod
61
+ def find_rdf_by_path(
62
+ cls, option: options.SourcePathOption, paths: T.Iterable[Path]
63
+ ) -> dict[str, ET.Element]:
64
+ if option.source_path is not None:
65
+ return index_rdf_description_by_path([option.source_path])
66
+
67
+ elif option.pattern is not None:
68
+ rdf_by_path = {}
69
+ for path in paths:
70
+ source_path = option.resolve(path)
71
+ r = index_rdf_description_by_path([source_path])
72
+ rdfs = list(r.values())
73
+ if rdfs:
74
+ rdf_by_path[exiftool_read.canonical_path(path)] = rdfs[0]
75
+ return rdf_by_path
76
+
77
+ else:
78
+ assert False, "Either source_path or pattern must be provided"
62
79
 
63
80
 
64
81
  class GeotagVideosFromExifToolRunner(GeotagVideosFromGeneric):
@@ -66,7 +83,10 @@ class GeotagVideosFromExifToolRunner(GeotagVideosFromGeneric):
66
83
  def _generate_video_extractors(
67
84
  self, video_paths: T.Sequence[Path]
68
85
  ) -> T.Sequence[VideoExifToolExtractor | types.ErrorMetadata]:
69
- runner = ExiftoolRunner(constants.EXIFTOOL_PATH)
86
+ if constants.EXIFTOOL_PATH is None:
87
+ runner = ExiftoolRunner()
88
+ else:
89
+ runner = ExiftoolRunner(constants.EXIFTOOL_PATH)
70
90
 
71
91
  LOG.debug(
72
92
  "Extracting XML from %d videos with ExifTool command: %s",
@@ -76,7 +96,13 @@ class GeotagVideosFromExifToolRunner(GeotagVideosFromGeneric):
76
96
  try:
77
97
  xml = runner.extract_xml(video_paths)
78
98
  except FileNotFoundError as ex:
79
- raise exceptions.MapillaryExiftoolNotFoundError(ex) from ex
99
+ exiftool_ex = exceptions.MapillaryExiftoolNotFoundError(ex)
100
+ return [
101
+ types.describe_error_metadata(
102
+ exiftool_ex, video_path, filetype=types.FileType.VIDEO
103
+ )
104
+ for video_path in video_paths
105
+ ]
80
106
 
81
107
  try:
82
108
  xml_element = ET.fromstring(xml)
@@ -92,6 +118,6 @@ class GeotagVideosFromExifToolRunner(GeotagVideosFromGeneric):
92
118
  xml_element
93
119
  )
94
120
 
95
- return GeotagVideosFromExifToolXML.build_image_extractors(
121
+ return GeotagVideosFromExifToolXML.build_video_extractors_from_etree(
96
122
  rdf_by_path, video_paths
97
123
  )
@@ -10,6 +10,7 @@ if sys.version_info >= (3, 12):
10
10
  else:
11
11
  from typing_extensions import override
12
12
 
13
+ from .. import exceptions, types
13
14
  from . import options
14
15
  from .base import GeotagVideosFromGeneric
15
16
  from .video_extractors.gpx import GPXVideoExtractor
@@ -21,19 +22,31 @@ LOG = logging.getLogger(__name__)
21
22
  class GeotagVideosFromGPX(GeotagVideosFromGeneric):
22
23
  def __init__(
23
24
  self,
24
- option: options.SourcePathOption | None = None,
25
+ source_path: options.SourcePathOption | None = None,
25
26
  num_processes: int | None = None,
26
27
  ):
27
28
  super().__init__(num_processes=num_processes)
28
- if option is None:
29
- option = options.SourcePathOption(pattern="%f.gpx")
30
- self.option = option
29
+ if source_path is None:
30
+ source_path = options.SourcePathOption(pattern="%g.gpx")
31
+ self.source_path = source_path
31
32
 
32
33
  @override
33
34
  def _generate_video_extractors(
34
35
  self, video_paths: T.Sequence[Path]
35
- ) -> T.Sequence[GPXVideoExtractor]:
36
- return [
37
- GPXVideoExtractor(video_path, self.option.resolve(video_path))
38
- for video_path in video_paths
39
- ]
36
+ ) -> T.Sequence[GPXVideoExtractor | types.ErrorMetadata]:
37
+ results: list[GPXVideoExtractor | types.ErrorMetadata] = []
38
+ for video_path in video_paths:
39
+ source_path = self.source_path.resolve(video_path)
40
+ if source_path.is_file():
41
+ results.append(GPXVideoExtractor(video_path, source_path))
42
+ else:
43
+ results.append(
44
+ types.describe_error_metadata(
45
+ exceptions.MapillaryVideoGPSNotFoundError(
46
+ "GPX file not found for video"
47
+ ),
48
+ filename=video_path,
49
+ filetype=types.FileType.VIDEO,
50
+ )
51
+ )
52
+ return results