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,109 +1,160 @@
1
- import io
1
+ from __future__ import annotations
2
+
2
3
  import logging
4
+ import sys
3
5
  import typing as T
4
6
  import xml.etree.ElementTree as ET
5
- from multiprocessing import Pool
6
7
  from pathlib import Path
7
8
 
8
- from tqdm import tqdm
9
+ if sys.version_info >= (3, 12):
10
+ from typing import override
11
+ else:
12
+ from typing_extensions import override
9
13
 
10
- from .. import exceptions, exiftool_read, types
11
- from .geotag_from_generic import GeotagImagesFromGeneric
12
- from .geotag_images_from_exif import GeotagImagesFromEXIF, verify_image_exif_write
14
+ from .. import constants, exceptions, exiftool_read, types, utils
15
+ from ..exiftool_runner import ExiftoolRunner
16
+ from . import options
17
+ from .base import GeotagImagesFromGeneric
18
+ from .geotag_images_from_video import GeotagImagesFromVideo
19
+ from .geotag_videos_from_exiftool import GeotagVideosFromExifToolXML
20
+ from .image_extractors.exiftool import ImageExifToolExtractor
13
21
 
14
22
  LOG = logging.getLogger(__name__)
15
23
 
16
24
 
17
- class GeotagImagesFromExifTool(GeotagImagesFromGeneric):
25
+ class GeotagImagesFromExifToolXML(GeotagImagesFromGeneric):
18
26
  def __init__(
19
- self,
20
- image_paths: T.Sequence[Path],
21
- xml_path: Path,
22
- num_processes: T.Optional[int] = None,
27
+ self, source_path: options.SourcePathOption, num_processes: int | None = None
23
28
  ):
24
- self.image_paths = image_paths
25
- self.xml_path = xml_path
26
- self.num_processes = num_processes
27
- super().__init__()
28
-
29
- @staticmethod
30
- def geotag_image(element: ET.Element) -> types.ImageMetadataOrError:
31
- image_path = exiftool_read.find_rdf_description_path(element)
32
- assert image_path is not None, "must find the path from the element"
29
+ self.source_path = source_path
30
+ super().__init__(num_processes=num_processes)
33
31
 
34
- try:
35
- exif = exiftool_read.ExifToolRead(ET.ElementTree(element))
36
- image_metadata = GeotagImagesFromEXIF.build_image_metadata(
37
- image_path, exif, skip_lonlat_error=False
38
- )
39
- # load the image bytes into memory to avoid reading it multiple times
40
- with image_path.open("rb") as fp:
41
- image_bytesio = io.BytesIO(fp.read())
42
- image_bytesio.seek(0, io.SEEK_SET)
43
- verify_image_exif_write(
44
- image_metadata,
45
- image_bytes=image_bytesio.read(),
46
- )
47
- except Exception as ex:
48
- return types.describe_error_metadata(
49
- ex, image_path, filetype=types.FileType.IMAGE
50
- )
51
-
52
- image_bytesio.seek(0, io.SEEK_SET)
53
- image_metadata.update_md5sum(image_bytesio)
54
-
55
- return image_metadata
56
-
57
- def to_description(self) -> T.List[types.ImageMetadataOrError]:
58
- rdf_description_by_path = exiftool_read.index_rdf_description_by_path(
59
- [self.xml_path]
60
- )
32
+ @classmethod
33
+ def build_image_extractors(
34
+ cls, rdf_by_path: dict[str, ET.Element], image_paths: T.Iterable[Path]
35
+ ) -> list[ImageExifToolExtractor | types.ErrorMetadata]:
36
+ results: list[ImageExifToolExtractor | types.ErrorMetadata] = []
61
37
 
62
- error_metadatas: T.List[types.ErrorMetadata] = []
63
- rdf_descriptions: T.List[ET.Element] = []
64
- for path in self.image_paths:
65
- rdf_description = rdf_description_by_path.get(
66
- exiftool_read.canonical_path(path)
67
- )
68
- if rdf_description is None:
69
- exc = exceptions.MapillaryEXIFNotFoundError(
70
- f"The {exiftool_read._DESCRIPTION_TAG} XML element for the image not found"
38
+ for path in image_paths:
39
+ rdf = rdf_by_path.get(exiftool_read.canonical_path(path))
40
+ if rdf is None:
41
+ ex = exceptions.MapillaryExifToolXMLNotFoundError(
42
+ "Cannot find the image in the ExifTool XML"
71
43
  )
72
- error_metadatas.append(
44
+ results.append(
73
45
  types.describe_error_metadata(
74
- exc, path, filetype=types.FileType.IMAGE
46
+ ex, path, filetype=types.FileType.IMAGE
75
47
  )
76
48
  )
77
49
  else:
78
- rdf_descriptions.append(rdf_description)
50
+ results.append(ImageExifToolExtractor(path, rdf))
51
+
52
+ return results
53
+
54
+ @override
55
+ def _generate_image_extractors(
56
+ self, image_paths: T.Sequence[Path]
57
+ ) -> T.Sequence[ImageExifToolExtractor | types.ErrorMetadata]:
58
+ rdf_by_path = GeotagVideosFromExifToolXML.find_rdf_by_path(
59
+ self.source_path, image_paths
60
+ )
61
+ return self.build_image_extractors(rdf_by_path, image_paths)
62
+
79
63
 
80
- if self.num_processes is None:
81
- num_processes = self.num_processes
82
- disable_multiprocessing = False
64
+ class GeotagImagesFromExifToolRunner(GeotagImagesFromGeneric):
65
+ @override
66
+ def _generate_image_extractors(
67
+ self, image_paths: T.Sequence[Path]
68
+ ) -> T.Sequence[ImageExifToolExtractor | types.ErrorMetadata]:
69
+ if constants.EXIFTOOL_PATH is None:
70
+ runner = ExiftoolRunner()
83
71
  else:
84
- num_processes = max(self.num_processes, 1)
85
- disable_multiprocessing = self.num_processes <= 0
86
-
87
- with Pool(processes=num_processes) as pool:
88
- image_metadatas_iter: T.Iterator[types.ImageMetadataOrError]
89
- if disable_multiprocessing:
90
- image_metadatas_iter = map(
91
- GeotagImagesFromExifTool.geotag_image,
92
- rdf_descriptions,
93
- )
94
- else:
95
- image_metadatas_iter = pool.imap(
96
- GeotagImagesFromExifTool.geotag_image,
97
- rdf_descriptions,
98
- )
99
- image_metadata_or_errors = list(
100
- tqdm(
101
- image_metadatas_iter,
102
- desc="Extracting geotags from ExifTool XML",
103
- unit="images",
104
- disable=LOG.getEffectiveLevel() <= logging.DEBUG,
105
- total=len(self.image_paths),
72
+ runner = ExiftoolRunner(constants.EXIFTOOL_PATH)
73
+
74
+ LOG.debug(
75
+ "Extracting XML from %d images with ExifTool command: %s",
76
+ len(image_paths),
77
+ " ".join(runner._build_args_read_stdin()),
78
+ )
79
+ try:
80
+ xml = runner.extract_xml(image_paths)
81
+ except FileNotFoundError as ex:
82
+ exiftool_ex = exceptions.MapillaryExiftoolNotFoundError(ex)
83
+ return [
84
+ types.describe_error_metadata(
85
+ exiftool_ex, image_path, filetype=types.FileType.IMAGE
106
86
  )
87
+ for image_path in image_paths
88
+ ]
89
+
90
+ try:
91
+ xml_element = ET.fromstring(xml)
92
+ except ET.ParseError as ex:
93
+ LOG.warning(
94
+ "Failed to parse ExifTool XML: %s",
95
+ str(ex),
96
+ exc_info=LOG.getEffectiveLevel() <= logging.DEBUG,
107
97
  )
98
+ rdf_by_path = {}
99
+ else:
100
+ rdf_by_path = exiftool_read.index_rdf_description_by_path_from_xml_element(
101
+ xml_element
102
+ )
103
+
104
+ return GeotagImagesFromExifToolXML.build_image_extractors(
105
+ rdf_by_path, image_paths
106
+ )
107
+
108
+
109
+ class GeotagImagesFromExifToolWithSamples(GeotagImagesFromGeneric):
110
+ def __init__(
111
+ self,
112
+ source_path: options.SourcePathOption,
113
+ offset_time: float = 0.0,
114
+ num_processes: int | None = None,
115
+ ):
116
+ super().__init__(num_processes=num_processes)
117
+ self.source_path = source_path
118
+ self.offset_time = offset_time
119
+
120
+ def geotag_samples(
121
+ self, image_paths: T.Sequence[Path]
122
+ ) -> list[types.ImageMetadataOrError]:
123
+ # Find all video paths in self.xml_path
124
+ rdf_by_path = GeotagVideosFromExifToolXML.find_rdf_by_path(
125
+ self.source_path, image_paths
126
+ )
127
+ video_paths = utils.find_videos(
128
+ [Path(canonical_path) for canonical_path in rdf_by_path.keys()],
129
+ skip_subfolders=True,
130
+ )
131
+ # Find all video paths that have sample images
132
+ samples_by_video = utils.find_all_image_samples(image_paths, video_paths)
133
+
134
+ video_metadata_or_errors = GeotagVideosFromExifToolXML(
135
+ self.source_path, num_processes=self.num_processes
136
+ ).to_description(list(samples_by_video.keys()))
137
+ sample_paths = sum(samples_by_video.values(), [])
138
+ sample_metadata_or_errors = GeotagImagesFromVideo(
139
+ video_metadata_or_errors,
140
+ offset_time=self.offset_time,
141
+ num_processes=self.num_processes,
142
+ ).to_description(sample_paths)
143
+
144
+ return sample_metadata_or_errors
145
+
146
+ @override
147
+ def to_description(
148
+ self, image_paths: T.Sequence[Path]
149
+ ) -> list[types.ImageMetadataOrError]:
150
+ sample_metadata_or_errors = self.geotag_samples(image_paths)
151
+
152
+ sample_paths = set(metadata.filename for metadata in sample_metadata_or_errors)
153
+
154
+ non_sample_paths = [path for path in image_paths if path not in sample_paths]
155
+
156
+ non_sample_metadata_or_errors = GeotagImagesFromExifToolXML(
157
+ self.source_path, num_processes=self.num_processes
158
+ ).to_description(non_sample_paths)
108
159
 
109
- return error_metadatas + image_metadata_or_errors
160
+ return sample_metadata_or_errors + non_sample_metadata_or_errors
@@ -1,12 +1,20 @@
1
+ from __future__ import annotations
2
+
1
3
  import dataclasses
2
4
  import logging
5
+ import sys
3
6
  import typing as T
4
- from multiprocessing import Pool
5
7
  from pathlib import Path
6
8
 
9
+ if sys.version_info >= (3, 12):
10
+ from typing import override
11
+ else:
12
+ from typing_extensions import override
13
+
7
14
  from .. import exceptions, geo, types
8
- from .geotag_from_generic import GeotagImagesFromGeneric
9
- from .geotag_images_from_exif import GeotagImagesFromEXIF
15
+ from ..serializer.description import build_capture_time
16
+ from .base import GeotagImagesFromGeneric
17
+ from .geotag_images_from_exif import ImageEXIFExtractor
10
18
 
11
19
 
12
20
  LOG = logging.getLogger(__name__)
@@ -15,79 +23,50 @@ LOG = logging.getLogger(__name__)
15
23
  class GeotagImagesFromGPX(GeotagImagesFromGeneric):
16
24
  def __init__(
17
25
  self,
18
- image_paths: T.Sequence[Path],
19
26
  points: T.Sequence[geo.Point],
20
27
  use_gpx_start_time: bool = False,
21
28
  use_image_start_time: bool = False,
22
29
  offset_time: float = 0.0,
23
- num_processes: T.Optional[int] = None,
30
+ num_processes: int | None = None,
24
31
  ):
25
- super().__init__()
26
- self.image_paths = image_paths
32
+ super().__init__(num_processes=num_processes)
27
33
  self.points = points
28
34
  self.use_gpx_start_time = use_gpx_start_time
29
35
  self.use_image_start_time = use_image_start_time
30
36
  self.offset_time = offset_time
31
- self.num_processes = num_processes
32
-
33
- @staticmethod
34
- def geotag_image(image_path: Path) -> types.ImageMetadataOrError:
35
- return GeotagImagesFromEXIF.geotag_image(image_path, skip_lonlat_error=True)
36
-
37
- def geotag_multiple_images(
38
- self, image_paths: T.Sequence[Path]
39
- ) -> T.List[types.ImageMetadataOrError]:
40
- if self.num_processes is None:
41
- num_processes = self.num_processes
42
- disable_multiprocessing = False
43
- else:
44
- num_processes = max(self.num_processes, 1)
45
- disable_multiprocessing = self.num_processes <= 0
46
-
47
- if disable_multiprocessing:
48
- return list(map(GeotagImagesFromGPX.geotag_image, image_paths))
49
- else:
50
- with Pool(processes=num_processes) as pool:
51
- return pool.map(GeotagImagesFromGPX.geotag_image, image_paths)
52
37
 
53
38
  def _interpolate_image_metadata_along(
54
39
  self,
55
40
  image_metadata: types.ImageMetadata,
56
41
  sorted_points: T.Sequence[geo.Point],
57
- ) -> types.ImageMetadataOrError:
42
+ ) -> types.ImageMetadata:
58
43
  assert sorted_points, "must have at least one point"
59
44
 
60
45
  if image_metadata.time < sorted_points[0].time:
61
46
  delta = sorted_points[0].time - image_metadata.time
62
- gpx_start_time = types.datetime_to_map_capture_time(sorted_points[0].time)
63
- gpx_end_time = types.datetime_to_map_capture_time(sorted_points[-1].time)
47
+ gpx_start_time = build_capture_time(sorted_points[0].time)
48
+ gpx_end_time = build_capture_time(sorted_points[-1].time)
64
49
  # with the tolerance of 1ms
65
50
  if 0.001 < delta:
66
- exc = exceptions.MapillaryOutsideGPXTrackError(
51
+ raise exceptions.MapillaryOutsideGPXTrackError(
67
52
  f"The image date time is {round(delta, 3)} seconds behind the GPX start point",
68
- image_time=types.datetime_to_map_capture_time(image_metadata.time),
53
+ image_time=build_capture_time(image_metadata.time),
69
54
  gpx_start_time=gpx_start_time,
70
55
  gpx_end_time=gpx_end_time,
71
56
  )
72
- return types.describe_error_metadata(
73
- exc, image_metadata.filename, filetype=types.FileType.IMAGE
74
- )
75
57
 
76
58
  if sorted_points[-1].time < image_metadata.time:
77
59
  delta = image_metadata.time - sorted_points[-1].time
78
- gpx_start_time = types.datetime_to_map_capture_time(sorted_points[0].time)
79
- gpx_end_time = types.datetime_to_map_capture_time(sorted_points[-1].time)
60
+ gpx_start_time = build_capture_time(sorted_points[0].time)
61
+ gpx_end_time = build_capture_time(sorted_points[-1].time)
80
62
  # with the tolerance of 1ms
81
63
  if 0.001 < delta:
82
- exc = exceptions.MapillaryOutsideGPXTrackError(
64
+ raise exceptions.MapillaryOutsideGPXTrackError(
83
65
  f"The image time is {round(delta, 3)} seconds beyond the GPX end point",
84
- image_time=types.datetime_to_map_capture_time(image_metadata.time),
66
+ image_time=build_capture_time(image_metadata.time),
85
67
  gpx_start_time=gpx_start_time,
86
68
  gpx_end_time=gpx_end_time,
87
69
  )
88
- return types.describe_error_metadata(
89
- exc, image_metadata.filename, filetype=types.FileType.IMAGE
90
- )
91
70
 
92
71
  interpolated = geo.interpolate(sorted_points, image_metadata.time)
93
72
 
@@ -100,34 +79,30 @@ class GeotagImagesFromGPX(GeotagImagesFromGeneric):
100
79
  time=interpolated.time,
101
80
  )
102
81
 
103
- def to_description(self) -> T.List[types.ImageMetadataOrError]:
104
- metadatas: T.List[types.ImageMetadataOrError] = []
105
-
106
- if not self.points:
107
- exc = exceptions.MapillaryGPXEmptyError(
108
- "Empty GPS extracted from the geotag source"
109
- )
110
- for image_path in self.image_paths:
111
- metadatas.append(
112
- types.describe_error_metadata(
113
- exc, image_path, filetype=types.FileType.IMAGE
114
- ),
115
- )
116
- assert len(self.image_paths) == len(metadatas)
117
- return metadatas
82
+ @override
83
+ def _generate_image_extractors(
84
+ self, image_paths: T.Sequence[Path]
85
+ ) -> T.Sequence[ImageEXIFExtractor]:
86
+ return [
87
+ ImageEXIFExtractor(path, skip_lonlat_error=True) for path in image_paths
88
+ ]
89
+
90
+ @override
91
+ def to_description(
92
+ self, image_paths: T.Sequence[Path]
93
+ ) -> list[types.ImageMetadataOrError]:
94
+ final_metadatas: list[types.ImageMetadataOrError] = []
118
95
 
119
- image_metadata_or_errors = self.geotag_multiple_images(self.image_paths)
96
+ image_metadata_or_errors = super().to_description(image_paths)
120
97
 
121
- image_metadatas = []
122
- for metadata_or_error in image_metadata_or_errors:
123
- if isinstance(metadata_or_error, types.ErrorMetadata):
124
- metadatas.append(metadata_or_error)
125
- else:
126
- image_metadatas.append(metadata_or_error)
98
+ image_metadatas, error_metadatas = types.separate_errors(
99
+ image_metadata_or_errors
100
+ )
101
+ final_metadatas.extend(error_metadatas)
127
102
 
128
103
  if not image_metadatas:
129
- assert len(self.image_paths) == len(metadatas)
130
- return metadatas
104
+ assert len(image_paths) == len(final_metadatas)
105
+ return final_metadatas
131
106
 
132
107
  # Do not use point itself for comparison because point.angle or point.alt could be None
133
108
  # when you compare nonnull value with None, it will throw
@@ -162,64 +137,25 @@ class GeotagImagesFromGPX(GeotagImagesFromGeneric):
162
137
  LOG.debug("GPX start time delta: %s", time_delta)
163
138
  image_time_offset += time_delta
164
139
 
165
- LOG.debug("Final time offset for interpolation: %s", image_time_offset)
140
+ if image_time_offset:
141
+ LOG.debug("Final time offset for interpolation: %s", image_time_offset)
142
+ for image_metadata in sorted_image_metadatas:
143
+ # TODO: this time modification seems to affect final capture times
144
+ image_metadata.time += image_time_offset
166
145
 
167
146
  for image_metadata in sorted_image_metadatas:
168
- image_metadata.time += image_time_offset
169
- metadatas.append(
170
- self._interpolate_image_metadata_along(image_metadata, sorted_points)
171
- )
172
-
173
- assert len(self.image_paths) == len(metadatas)
174
- return metadatas
175
-
176
-
177
- class GeotagImagesFromGPXWithProgress(GeotagImagesFromGPX):
178
- def __init__(
179
- self,
180
- image_paths: T.Sequence[Path],
181
- points: T.Sequence[geo.Point],
182
- use_gpx_start_time: bool = False,
183
- use_image_start_time: bool = False,
184
- offset_time: float = 0.0,
185
- num_processes: T.Optional[int] = None,
186
- progress_bar=None,
187
- ) -> None:
188
- super().__init__(
189
- image_paths,
190
- points,
191
- use_gpx_start_time=use_gpx_start_time,
192
- use_image_start_time=use_image_start_time,
193
- offset_time=offset_time,
194
- num_processes=num_processes,
195
- )
196
- self._progress_bar = progress_bar
197
-
198
- def geotag_multiple_images(
199
- self, image_paths: T.Sequence[Path]
200
- ) -> T.List[types.ImageMetadataOrError]:
201
- if self._progress_bar is None:
202
- return super().geotag_multiple_images(image_paths)
203
-
204
- if self.num_processes is None:
205
- num_processes = self.num_processes
206
- disable_multiprocessing = False
207
- else:
208
- num_processes = max(self.num_processes, 1)
209
- disable_multiprocessing = self.num_processes <= 0
210
-
211
- output = []
212
- with Pool(processes=num_processes) as pool:
213
- image_metadatas_iter: T.Iterator[types.ImageMetadataOrError]
214
- if disable_multiprocessing:
215
- image_metadatas_iter = map(
216
- GeotagImagesFromGPX.geotag_image, image_paths
147
+ try:
148
+ final_metadatas.append(
149
+ self._interpolate_image_metadata_along(
150
+ image_metadata, sorted_points
151
+ )
217
152
  )
218
- else:
219
- image_metadatas_iter = pool.imap(
220
- GeotagImagesFromGPX.geotag_image, image_paths
153
+ except exceptions.MapillaryOutsideGPXTrackError as ex:
154
+ error_metadata = types.describe_error_metadata(
155
+ ex, image_metadata.filename, filetype=types.FileType.IMAGE
221
156
  )
222
- for image_metadata_or_error in image_metadatas_iter:
223
- self._progress_bar.update(1)
224
- output.append(image_metadata_or_error)
225
- return output
157
+ final_metadatas.append(error_metadata)
158
+
159
+ assert len(image_paths) == len(final_metadatas)
160
+
161
+ return final_metadatas
@@ -1,32 +1,25 @@
1
- import dataclasses
1
+ from __future__ import annotations
2
+
2
3
  import logging
3
- import typing as T
4
- from multiprocessing import Pool
5
4
  from pathlib import Path
6
5
 
7
- import gpxpy
8
- from tqdm import tqdm
9
-
10
- from .. import exif_read, geo, types
11
- from .geotag_from_generic import GeotagImagesFromGeneric
12
- from .geotag_images_from_gpx import GeotagImagesFromGPXWithProgress
6
+ from . import utils
7
+ from .geotag_images_from_gpx import GeotagImagesFromGPX
13
8
 
14
9
 
15
10
  LOG = logging.getLogger(__name__)
16
11
 
17
12
 
18
- class GeotagImagesFromGPXFile(GeotagImagesFromGeneric):
13
+ class GeotagImagesFromGPXFile(GeotagImagesFromGPX):
19
14
  def __init__(
20
15
  self,
21
- image_paths: T.Sequence[Path],
22
16
  source_path: Path,
23
17
  use_gpx_start_time: bool = False,
24
18
  offset_time: float = 0.0,
25
- num_processes: T.Optional[int] = None,
19
+ num_processes: int | None = None,
26
20
  ):
27
- super().__init__()
28
21
  try:
29
- tracks = parse_gpx(source_path)
22
+ tracks = utils.parse_gpx(source_path)
30
23
  except Exception as ex:
31
24
  raise RuntimeError(
32
25
  f"Error parsing GPX {source_path}: {ex.__class__.__name__}: {ex}"
@@ -38,116 +31,10 @@ class GeotagImagesFromGPXFile(GeotagImagesFromGeneric):
38
31
  len(tracks),
39
32
  source_path,
40
33
  )
41
- self.points: T.List[geo.Point] = sum(tracks, [])
42
- self.image_paths = image_paths
43
- self.source_path = source_path
44
- self.use_gpx_start_time = use_gpx_start_time
45
- self.offset_time = offset_time
46
- self.num_processes = num_processes
47
-
48
- @staticmethod
49
- def _extract_image_metadata(
50
- image_metadata: types.ImageMetadata,
51
- ) -> types.ImageMetadataOrError:
52
- try:
53
- exif = exif_read.ExifRead(image_metadata.filename)
54
- orientation = exif.extract_orientation()
55
- make = exif.extract_make()
56
- model = exif.extract_model()
57
- except Exception as ex:
58
- return types.describe_error_metadata(
59
- ex, image_metadata.filename, filetype=types.FileType.IMAGE
60
- )
61
-
62
- return dataclasses.replace(
63
- image_metadata,
64
- MAPOrientation=orientation,
65
- MAPDeviceMake=make,
66
- MAPDeviceModel=model,
67
- )
68
-
69
- def to_description(self) -> T.List[types.ImageMetadataOrError]:
70
- with tqdm(
71
- total=len(self.image_paths),
72
- desc="Interpolating",
73
- unit="images",
74
- disable=LOG.getEffectiveLevel() <= logging.DEBUG,
75
- ) as pbar:
76
- geotag = GeotagImagesFromGPXWithProgress(
77
- self.image_paths,
78
- self.points,
79
- use_gpx_start_time=self.use_gpx_start_time,
80
- offset_time=self.offset_time,
81
- progress_bar=pbar,
82
- )
83
- image_metadata_or_errors = geotag.to_description()
84
-
85
- image_metadatas: T.List[types.ImageMetadata] = []
86
- error_metadatas: T.List[types.ErrorMetadata] = []
87
- for metadata in image_metadata_or_errors:
88
- if isinstance(metadata, types.ErrorMetadata):
89
- error_metadatas.append(metadata)
90
- else:
91
- image_metadatas.append(metadata)
92
-
93
- if self.num_processes is None:
94
- num_processes = self.num_processes
95
- disable_multiprocessing = False
96
- else:
97
- num_processes = max(self.num_processes, 1)
98
- disable_multiprocessing = self.num_processes <= 0
99
-
100
- with Pool(processes=num_processes) as pool:
101
- image_metadatas_iter: T.Iterator[types.ImageMetadataOrError]
102
- if disable_multiprocessing:
103
- image_metadatas_iter = map(
104
- GeotagImagesFromGPXFile._extract_image_metadata, image_metadatas
105
- )
106
- else:
107
- # Do not pass error metadatas where the error object can not be pickled for multiprocessing to work
108
- # Otherwise we get:
109
- # TypeError: __init__() missing 3 required positional arguments: 'image_time', 'gpx_start_time', and 'gpx_end_time'
110
- # See https://stackoverflow.com/a/61432070
111
- image_metadatas_iter = pool.imap(
112
- GeotagImagesFromGPXFile._extract_image_metadata, image_metadatas
113
- )
114
- image_metadata_or_errors = list(
115
- tqdm(
116
- image_metadatas_iter,
117
- desc="Processing",
118
- unit="images",
119
- disable=LOG.getEffectiveLevel() <= logging.DEBUG,
120
- )
121
- )
122
-
123
- return (
124
- T.cast(T.List[types.ImageMetadataOrError], error_metadatas)
125
- + image_metadata_or_errors
34
+ points = sum(tracks, [])
35
+ super().__init__(
36
+ points,
37
+ use_gpx_start_time=use_gpx_start_time,
38
+ offset_time=offset_time,
39
+ num_processes=num_processes,
126
40
  )
127
-
128
-
129
- Track = T.List[geo.Point]
130
-
131
-
132
- def parse_gpx(gpx_file: Path) -> T.List[Track]:
133
- with gpx_file.open("r") as f:
134
- gpx = gpxpy.parse(f)
135
-
136
- tracks: T.List[Track] = []
137
-
138
- for track in gpx.tracks:
139
- for segment in track.segments:
140
- tracks.append([])
141
- for point in segment.points:
142
- if point.time is not None:
143
- tracks[-1].append(
144
- geo.Point(
145
- time=geo.as_unix_time(point.time),
146
- lat=point.latitude,
147
- lon=point.longitude,
148
- alt=point.elevation,
149
- angle=None,
150
- )
151
- )
152
-
153
- return tracks