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
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import abc
|
|
4
|
+
import logging
|
|
5
|
+
import typing as T
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from tqdm import tqdm
|
|
9
|
+
|
|
10
|
+
from .. import exceptions, types, utils
|
|
11
|
+
from .image_extractors.base import BaseImageExtractor
|
|
12
|
+
from .video_extractors.base import BaseVideoExtractor
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
LOG = logging.getLogger(__name__)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
TImageExtractor = T.TypeVar("TImageExtractor", bound=BaseImageExtractor)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GeotagImagesFromGeneric(abc.ABC, T.Generic[TImageExtractor]):
|
|
22
|
+
"""
|
|
23
|
+
Extracts metadata from a list of image files with multiprocessing.
|
|
24
|
+
"""
|
|
25
|
+
|
|
26
|
+
def __init__(self, num_processes: int | None = None) -> None:
|
|
27
|
+
self.num_processes = num_processes
|
|
28
|
+
|
|
29
|
+
def to_description(
|
|
30
|
+
self, image_paths: T.Sequence[Path]
|
|
31
|
+
) -> list[types.ImageMetadataOrError]:
|
|
32
|
+
extractor_or_errors = self._generate_image_extractors(image_paths)
|
|
33
|
+
|
|
34
|
+
assert len(extractor_or_errors) == len(image_paths)
|
|
35
|
+
|
|
36
|
+
extractors, error_metadatas = types.separate_errors(extractor_or_errors)
|
|
37
|
+
|
|
38
|
+
map_results = utils.mp_map_maybe(
|
|
39
|
+
self.run_extraction,
|
|
40
|
+
extractors,
|
|
41
|
+
num_processes=self.num_processes,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
results = list(
|
|
45
|
+
tqdm(
|
|
46
|
+
map_results,
|
|
47
|
+
desc="Extracting images",
|
|
48
|
+
unit="images",
|
|
49
|
+
disable=LOG.getEffectiveLevel() <= logging.DEBUG,
|
|
50
|
+
total=len(extractors),
|
|
51
|
+
)
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
return results + error_metadatas
|
|
55
|
+
|
|
56
|
+
# This method is passed to multiprocessing
|
|
57
|
+
# so it has to be classmethod or staticmethod to avoid pickling the instance
|
|
58
|
+
@classmethod
|
|
59
|
+
def run_extraction(cls, extractor: TImageExtractor) -> types.ImageMetadataOrError:
|
|
60
|
+
image_path = extractor.image_path
|
|
61
|
+
|
|
62
|
+
try:
|
|
63
|
+
return extractor.extract()
|
|
64
|
+
except exceptions.MapillaryDescriptionError as ex:
|
|
65
|
+
return types.describe_error_metadata(
|
|
66
|
+
ex, image_path, filetype=types.FileType.IMAGE
|
|
67
|
+
)
|
|
68
|
+
except exceptions.MapillaryUserError as ex:
|
|
69
|
+
# Considered as fatal error if not MapillaryDescriptionError
|
|
70
|
+
raise ex
|
|
71
|
+
except Exception as ex:
|
|
72
|
+
# TODO: hide details if not verbose mode
|
|
73
|
+
LOG.exception("Unexpected error extracting metadata from %s", image_path)
|
|
74
|
+
return types.describe_error_metadata(
|
|
75
|
+
ex, image_path, filetype=types.FileType.IMAGE
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
def _generate_image_extractors(
|
|
79
|
+
self, image_paths: T.Sequence[Path]
|
|
80
|
+
) -> T.Sequence[TImageExtractor | types.ErrorMetadata]:
|
|
81
|
+
raise NotImplementedError
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
TVideoExtractor = T.TypeVar("TVideoExtractor", bound=BaseVideoExtractor)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
class GeotagVideosFromGeneric(abc.ABC, T.Generic[TVideoExtractor]):
|
|
88
|
+
"""
|
|
89
|
+
Extracts metadata from a list of video files with multiprocessing.
|
|
90
|
+
"""
|
|
91
|
+
|
|
92
|
+
def __init__(self, num_processes: int | None = None) -> None:
|
|
93
|
+
self.num_processes = num_processes
|
|
94
|
+
|
|
95
|
+
def to_description(
|
|
96
|
+
self, video_paths: T.Sequence[Path]
|
|
97
|
+
) -> list[types.VideoMetadataOrError]:
|
|
98
|
+
extractor_or_errors = self._generate_video_extractors(video_paths)
|
|
99
|
+
|
|
100
|
+
assert len(extractor_or_errors) == len(video_paths)
|
|
101
|
+
|
|
102
|
+
extractors, error_metadatas = types.separate_errors(extractor_or_errors)
|
|
103
|
+
|
|
104
|
+
map_results = utils.mp_map_maybe(
|
|
105
|
+
self.run_extraction,
|
|
106
|
+
extractors,
|
|
107
|
+
num_processes=self.num_processes,
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
results = list(
|
|
111
|
+
tqdm(
|
|
112
|
+
map_results,
|
|
113
|
+
desc="Extracting videos",
|
|
114
|
+
unit="videos",
|
|
115
|
+
disable=LOG.getEffectiveLevel() <= logging.DEBUG,
|
|
116
|
+
total=len(extractors),
|
|
117
|
+
)
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
return results + error_metadatas
|
|
121
|
+
|
|
122
|
+
# This method is passed to multiprocessing
|
|
123
|
+
# so it has to be classmethod or staticmethod to avoid pickling the instance
|
|
124
|
+
@classmethod
|
|
125
|
+
def run_extraction(cls, extractor: TVideoExtractor) -> types.VideoMetadataOrError:
|
|
126
|
+
video_path = extractor.video_path
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
return extractor.extract()
|
|
130
|
+
except exceptions.MapillaryDescriptionError as ex:
|
|
131
|
+
return types.describe_error_metadata(
|
|
132
|
+
ex, video_path, filetype=types.FileType.VIDEO
|
|
133
|
+
)
|
|
134
|
+
except exceptions.MapillaryUserError as ex:
|
|
135
|
+
# Considered as fatal error if not MapillaryDescriptionError
|
|
136
|
+
raise ex
|
|
137
|
+
except Exception as ex:
|
|
138
|
+
# TODO: hide details if not verbose mode
|
|
139
|
+
LOG.exception("Unexpected error extracting metadata from %s", video_path)
|
|
140
|
+
return types.describe_error_metadata(
|
|
141
|
+
ex, video_path, filetype=types.FileType.VIDEO
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
def _generate_video_extractors(
|
|
145
|
+
self, video_paths: T.Sequence[Path]
|
|
146
|
+
) -> T.Sequence[TVideoExtractor | types.ErrorMetadata]:
|
|
147
|
+
raise NotImplementedError
|
|
@@ -0,0 +1,291 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import typing as T
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .. import exceptions, types, utils
|
|
9
|
+
from ..types import FileType
|
|
10
|
+
from . import (
|
|
11
|
+
base,
|
|
12
|
+
geotag_images_from_exif,
|
|
13
|
+
geotag_images_from_exiftool,
|
|
14
|
+
geotag_images_from_gpx_file,
|
|
15
|
+
geotag_images_from_nmea_file,
|
|
16
|
+
geotag_images_from_video,
|
|
17
|
+
geotag_videos_from_exiftool,
|
|
18
|
+
geotag_videos_from_gpx,
|
|
19
|
+
geotag_videos_from_video,
|
|
20
|
+
)
|
|
21
|
+
from .options import InterpolationOption, SOURCE_TYPE_ALIAS, SourceOption, SourceType
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
LOG = logging.getLogger(__name__)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def parse_source_option(source: str) -> list[SourceOption]:
|
|
28
|
+
"""
|
|
29
|
+
Given a source string, parse it into a list of GeotagOptions objects.
|
|
30
|
+
|
|
31
|
+
Examples:
|
|
32
|
+
"native" -> [SourceOption(SourceType.NATIVE)]
|
|
33
|
+
"gpx,exif" -> [SourceOption(SourceType.GPX), SourceOption(SourceType.EXIF)]
|
|
34
|
+
"exif,gpx" -> [SourceOption(SourceType.EXIF), SourceOption(SourceType.GPX)]
|
|
35
|
+
'{"source": "gpx"}' -> [SourceOption(SourceType.GPX)]
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
source_type = SourceType(SOURCE_TYPE_ALIAS.get(source, source))
|
|
40
|
+
except ValueError:
|
|
41
|
+
pass
|
|
42
|
+
else:
|
|
43
|
+
return [SourceOption(source_type)]
|
|
44
|
+
|
|
45
|
+
try:
|
|
46
|
+
payload = json.loads(source)
|
|
47
|
+
except json.JSONDecodeError:
|
|
48
|
+
pass
|
|
49
|
+
else:
|
|
50
|
+
return [SourceOption.from_dict(payload)]
|
|
51
|
+
|
|
52
|
+
sources = source.split(",")
|
|
53
|
+
|
|
54
|
+
return [SourceOption(SourceType(SOURCE_TYPE_ALIAS.get(s, s))) for s in sources]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def process(
|
|
58
|
+
# Collection: ABC for sized iterable container classes
|
|
59
|
+
paths: T.Iterable[Path],
|
|
60
|
+
options: T.Collection[SourceOption],
|
|
61
|
+
) -> list[types.MetadataOrError]:
|
|
62
|
+
if not options:
|
|
63
|
+
raise ValueError("No geotag options provided")
|
|
64
|
+
|
|
65
|
+
final_metadatas: list[types.MetadataOrError] = []
|
|
66
|
+
|
|
67
|
+
# Paths (image path or video path) that will be sent to the next geotag process
|
|
68
|
+
reprocessable_paths = set(paths)
|
|
69
|
+
|
|
70
|
+
for idx, option in enumerate(options):
|
|
71
|
+
LOG.debug("Processing %d files with %s", len(reprocessable_paths), option)
|
|
72
|
+
|
|
73
|
+
image_metadata_or_errors = _geotag_images(reprocessable_paths, option)
|
|
74
|
+
video_metadata_or_errors = _geotag_videos(reprocessable_paths, option)
|
|
75
|
+
|
|
76
|
+
more_option = idx < len(options) - 1
|
|
77
|
+
|
|
78
|
+
for metadata in image_metadata_or_errors + video_metadata_or_errors:
|
|
79
|
+
if more_option and _is_reprocessable(metadata):
|
|
80
|
+
# Leave what it is for the next geotag process
|
|
81
|
+
pass
|
|
82
|
+
else:
|
|
83
|
+
final_metadatas.append(metadata)
|
|
84
|
+
reprocessable_paths.remove(metadata.filename)
|
|
85
|
+
|
|
86
|
+
# Quit if no more paths to process
|
|
87
|
+
if not reprocessable_paths:
|
|
88
|
+
break
|
|
89
|
+
|
|
90
|
+
return final_metadatas
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _is_reprocessable(metadata: types.MetadataOrError) -> bool:
|
|
94
|
+
if isinstance(metadata, types.ErrorMetadata):
|
|
95
|
+
if isinstance(
|
|
96
|
+
metadata.error,
|
|
97
|
+
(
|
|
98
|
+
exceptions.MapillaryGeoTaggingError,
|
|
99
|
+
exceptions.MapillaryVideoGPSNotFoundError,
|
|
100
|
+
),
|
|
101
|
+
):
|
|
102
|
+
return True
|
|
103
|
+
|
|
104
|
+
return False
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _filter_images_and_videos(
|
|
108
|
+
paths: T.Iterable[Path],
|
|
109
|
+
filetypes: set[types.FileType] | None = None,
|
|
110
|
+
) -> tuple[list[Path], list[Path]]:
|
|
111
|
+
image_paths = []
|
|
112
|
+
video_paths = []
|
|
113
|
+
|
|
114
|
+
ALL_VIDEO_TYPES = {types.FileType.VIDEO, *types.NATIVE_VIDEO_FILETYPES}
|
|
115
|
+
|
|
116
|
+
if filetypes is None:
|
|
117
|
+
include_images = True
|
|
118
|
+
include_videos = True
|
|
119
|
+
else:
|
|
120
|
+
include_images = types.FileType.IMAGE in filetypes
|
|
121
|
+
include_videos = bool(filetypes & ALL_VIDEO_TYPES)
|
|
122
|
+
|
|
123
|
+
for path in paths:
|
|
124
|
+
if utils.is_image_file(path):
|
|
125
|
+
if include_images:
|
|
126
|
+
image_paths.append(path)
|
|
127
|
+
|
|
128
|
+
elif utils.is_video_file(path):
|
|
129
|
+
if include_videos:
|
|
130
|
+
video_paths.append(path)
|
|
131
|
+
|
|
132
|
+
return image_paths, video_paths
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def _ensure_source_path(option: SourceOption) -> Path:
|
|
136
|
+
if option.source_path is None or option.source_path.source_path is None:
|
|
137
|
+
raise exceptions.MapillaryBadParameterError(
|
|
138
|
+
f"source_path must be provided for {option.source}"
|
|
139
|
+
)
|
|
140
|
+
return option.source_path.source_path
|
|
141
|
+
|
|
142
|
+
|
|
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
|
+
|
|
151
|
+
if option.interpolation is None:
|
|
152
|
+
interpolation = InterpolationOption()
|
|
153
|
+
else:
|
|
154
|
+
interpolation = option.interpolation
|
|
155
|
+
|
|
156
|
+
geotag: base.GeotagImagesFromGeneric
|
|
157
|
+
|
|
158
|
+
if option.source is SourceType.NATIVE:
|
|
159
|
+
geotag = geotag_images_from_exif.GeotagImagesFromEXIF(
|
|
160
|
+
num_processes=option.num_processes
|
|
161
|
+
)
|
|
162
|
+
return geotag.to_description(image_paths)
|
|
163
|
+
|
|
164
|
+
if option.source is SourceType.EXIFTOOL_RUNTIME:
|
|
165
|
+
geotag = geotag_images_from_exiftool.GeotagImagesFromExifToolRunner(
|
|
166
|
+
num_processes=option.num_processes
|
|
167
|
+
)
|
|
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
|
+
|
|
174
|
+
elif option.source is SourceType.EXIFTOOL_XML:
|
|
175
|
+
# This is to ensure 'video_process --geotag={"source": "exiftool_xml", "source_path": "/tmp/xml_path"}'
|
|
176
|
+
# to work
|
|
177
|
+
geotag = geotag_images_from_exiftool.GeotagImagesFromExifToolWithSamples(
|
|
178
|
+
xml_path=_ensure_source_path(option),
|
|
179
|
+
num_processes=option.num_processes,
|
|
180
|
+
)
|
|
181
|
+
return geotag.to_description(image_paths)
|
|
182
|
+
|
|
183
|
+
elif option.source is SourceType.GPX:
|
|
184
|
+
geotag = geotag_images_from_gpx_file.GeotagImagesFromGPXFile(
|
|
185
|
+
source_path=_ensure_source_path(option),
|
|
186
|
+
use_gpx_start_time=interpolation.use_gpx_start_time,
|
|
187
|
+
offset_time=interpolation.offset_time,
|
|
188
|
+
num_processes=option.num_processes,
|
|
189
|
+
)
|
|
190
|
+
return geotag.to_description(image_paths)
|
|
191
|
+
|
|
192
|
+
elif option.source is SourceType.NMEA:
|
|
193
|
+
geotag = geotag_images_from_nmea_file.GeotagImagesFromNMEAFile(
|
|
194
|
+
source_path=_ensure_source_path(option),
|
|
195
|
+
use_gpx_start_time=interpolation.use_gpx_start_time,
|
|
196
|
+
offset_time=interpolation.offset_time,
|
|
197
|
+
num_processes=option.num_processes,
|
|
198
|
+
)
|
|
199
|
+
|
|
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,
|
|
229
|
+
offset_time=interpolation.offset_time,
|
|
230
|
+
num_processes=option.num_processes,
|
|
231
|
+
)
|
|
232
|
+
return geotag.to_description(image_paths)
|
|
233
|
+
|
|
234
|
+
else:
|
|
235
|
+
raise ValueError(f"Invalid geotag source {option.source}")
|
|
236
|
+
|
|
237
|
+
|
|
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
|
|
247
|
+
|
|
248
|
+
if option.source is SourceType.NATIVE:
|
|
249
|
+
geotag = geotag_videos_from_video.GeotagVideosFromVideo(
|
|
250
|
+
num_processes=option.num_processes, filetypes=option.filetypes
|
|
251
|
+
)
|
|
252
|
+
return geotag.to_description(video_paths)
|
|
253
|
+
|
|
254
|
+
if option.source is SourceType.EXIFTOOL_RUNTIME:
|
|
255
|
+
geotag = geotag_videos_from_exiftool.GeotagVideosFromExifToolRunner(
|
|
256
|
+
num_processes=option.num_processes
|
|
257
|
+
)
|
|
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
|
+
|
|
264
|
+
elif option.source is SourceType.EXIFTOOL_XML:
|
|
265
|
+
geotag = geotag_videos_from_exiftool.GeotagVideosFromExifToolXML(
|
|
266
|
+
xml_path=_ensure_source_path(option),
|
|
267
|
+
)
|
|
268
|
+
return geotag.to_description(video_paths)
|
|
269
|
+
|
|
270
|
+
elif option.source is SourceType.GPX:
|
|
271
|
+
geotag = geotag_videos_from_gpx.GeotagVideosFromGPX()
|
|
272
|
+
return geotag.to_description(video_paths)
|
|
273
|
+
|
|
274
|
+
elif option.source is SourceType.NMEA:
|
|
275
|
+
# TODO: geotag videos from NMEA
|
|
276
|
+
return []
|
|
277
|
+
|
|
278
|
+
elif option.source is SourceType.EXIF:
|
|
279
|
+
# Legacy image-specific geotag types
|
|
280
|
+
return []
|
|
281
|
+
|
|
282
|
+
elif option.source in [
|
|
283
|
+
SourceType.GOPRO,
|
|
284
|
+
SourceType.BLACKVUE,
|
|
285
|
+
SourceType.CAMM,
|
|
286
|
+
]:
|
|
287
|
+
# Legacy image-specific geotag types
|
|
288
|
+
return []
|
|
289
|
+
|
|
290
|
+
else:
|
|
291
|
+
raise ValueError(f"Invalid geotag source {option.source}")
|
|
@@ -1,141 +1,24 @@
|
|
|
1
|
-
import
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
2
3
|
import logging
|
|
4
|
+
import sys
|
|
3
5
|
import typing as T
|
|
4
|
-
from multiprocessing import Pool
|
|
5
6
|
from pathlib import Path
|
|
6
7
|
|
|
7
|
-
|
|
8
|
+
if sys.version_info >= (3, 12):
|
|
9
|
+
from typing import override
|
|
10
|
+
else:
|
|
11
|
+
from typing_extensions import override
|
|
8
12
|
|
|
9
|
-
from
|
|
10
|
-
from
|
|
11
|
-
from .geotag_from_generic import GeotagImagesFromGeneric
|
|
13
|
+
from .base import GeotagImagesFromGeneric
|
|
14
|
+
from .image_extractors.exif import ImageEXIFExtractor
|
|
12
15
|
|
|
13
16
|
LOG = logging.getLogger(__name__)
|
|
14
17
|
|
|
15
18
|
|
|
16
|
-
def verify_image_exif_write(
|
|
17
|
-
metadata: types.ImageMetadata,
|
|
18
|
-
image_bytes: T.Optional[bytes] = None,
|
|
19
|
-
) -> None:
|
|
20
|
-
if image_bytes is None:
|
|
21
|
-
edit = exif_write.ExifEdit(metadata.filename)
|
|
22
|
-
else:
|
|
23
|
-
edit = exif_write.ExifEdit(image_bytes)
|
|
24
|
-
|
|
25
|
-
# The cast is to fix the type error in Python3.6:
|
|
26
|
-
# Argument 1 to "add_image_description" of "ExifEdit" has incompatible type "ImageDescription"; expected "Dict[str, Any]"
|
|
27
|
-
edit.add_image_description(
|
|
28
|
-
T.cast(T.Dict, types.desc_file_to_exif(types.as_desc(metadata)))
|
|
29
|
-
)
|
|
30
|
-
|
|
31
|
-
# Possible errors thrown here:
|
|
32
|
-
# - struct.error: 'H' format requires 0 <= number <= 65535
|
|
33
|
-
# - piexif.InvalidImageDataError
|
|
34
|
-
edit.dump_image_bytes()
|
|
35
|
-
|
|
36
|
-
|
|
37
19
|
class GeotagImagesFromEXIF(GeotagImagesFromGeneric):
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
super().__init__()
|
|
44
|
-
|
|
45
|
-
@staticmethod
|
|
46
|
-
def build_image_metadata(
|
|
47
|
-
image_path: Path, exif: ExifReadABC, skip_lonlat_error: bool = False
|
|
48
|
-
) -> types.ImageMetadata:
|
|
49
|
-
lonlat = exif.extract_lon_lat()
|
|
50
|
-
if lonlat is None:
|
|
51
|
-
if not skip_lonlat_error:
|
|
52
|
-
raise exceptions.MapillaryGeoTaggingError(
|
|
53
|
-
"Unable to extract GPS Longitude or GPS Latitude from the image"
|
|
54
|
-
)
|
|
55
|
-
lonlat = (0.0, 0.0)
|
|
56
|
-
lon, lat = lonlat
|
|
57
|
-
|
|
58
|
-
capture_time = exif.extract_capture_time()
|
|
59
|
-
if capture_time is None:
|
|
60
|
-
raise exceptions.MapillaryGeoTaggingError(
|
|
61
|
-
"Unable to extract timestamp from the image"
|
|
62
|
-
)
|
|
63
|
-
|
|
64
|
-
image_metadata = types.ImageMetadata(
|
|
65
|
-
filename=image_path,
|
|
66
|
-
md5sum=None,
|
|
67
|
-
filesize=utils.get_file_size(image_path),
|
|
68
|
-
time=geo.as_unix_time(capture_time),
|
|
69
|
-
lat=lat,
|
|
70
|
-
lon=lon,
|
|
71
|
-
alt=exif.extract_altitude(),
|
|
72
|
-
angle=exif.extract_direction(),
|
|
73
|
-
width=exif.extract_width(),
|
|
74
|
-
height=exif.extract_height(),
|
|
75
|
-
MAPOrientation=exif.extract_orientation(),
|
|
76
|
-
MAPDeviceMake=exif.extract_make(),
|
|
77
|
-
MAPDeviceModel=exif.extract_model(),
|
|
78
|
-
)
|
|
79
|
-
|
|
80
|
-
return image_metadata
|
|
81
|
-
|
|
82
|
-
@staticmethod
|
|
83
|
-
def geotag_image(
|
|
84
|
-
image_path: Path, skip_lonlat_error: bool = False
|
|
85
|
-
) -> types.ImageMetadataOrError:
|
|
86
|
-
try:
|
|
87
|
-
# load the image bytes into memory to avoid reading it multiple times
|
|
88
|
-
with image_path.open("rb") as fp:
|
|
89
|
-
image_bytesio = io.BytesIO(fp.read())
|
|
90
|
-
|
|
91
|
-
image_bytesio.seek(0, io.SEEK_SET)
|
|
92
|
-
exif = ExifRead(image_bytesio)
|
|
93
|
-
|
|
94
|
-
image_metadata = GeotagImagesFromEXIF.build_image_metadata(
|
|
95
|
-
image_path, exif, skip_lonlat_error=skip_lonlat_error
|
|
96
|
-
)
|
|
97
|
-
|
|
98
|
-
image_bytesio.seek(0, io.SEEK_SET)
|
|
99
|
-
verify_image_exif_write(
|
|
100
|
-
image_metadata,
|
|
101
|
-
image_bytes=image_bytesio.read(),
|
|
102
|
-
)
|
|
103
|
-
except Exception as ex:
|
|
104
|
-
return types.describe_error_metadata(
|
|
105
|
-
ex, image_path, filetype=types.FileType.IMAGE
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
image_bytesio.seek(0, io.SEEK_SET)
|
|
109
|
-
image_metadata.update_md5sum(image_bytesio)
|
|
110
|
-
|
|
111
|
-
return image_metadata
|
|
112
|
-
|
|
113
|
-
def to_description(self) -> T.List[types.ImageMetadataOrError]:
|
|
114
|
-
if self.num_processes is None:
|
|
115
|
-
num_processes = self.num_processes
|
|
116
|
-
disable_multiprocessing = False
|
|
117
|
-
else:
|
|
118
|
-
num_processes = max(self.num_processes, 1)
|
|
119
|
-
disable_multiprocessing = self.num_processes <= 0
|
|
120
|
-
|
|
121
|
-
with Pool(processes=num_processes) as pool:
|
|
122
|
-
image_metadatas_iter: T.Iterator[types.ImageMetadataOrError]
|
|
123
|
-
if disable_multiprocessing:
|
|
124
|
-
image_metadatas_iter = map(
|
|
125
|
-
GeotagImagesFromEXIF.geotag_image,
|
|
126
|
-
self.image_paths,
|
|
127
|
-
)
|
|
128
|
-
else:
|
|
129
|
-
image_metadatas_iter = pool.imap(
|
|
130
|
-
GeotagImagesFromEXIF.geotag_image,
|
|
131
|
-
self.image_paths,
|
|
132
|
-
)
|
|
133
|
-
return list(
|
|
134
|
-
tqdm(
|
|
135
|
-
image_metadatas_iter,
|
|
136
|
-
desc="Extracting geotags from images",
|
|
137
|
-
unit="images",
|
|
138
|
-
disable=LOG.getEffectiveLevel() <= logging.DEBUG,
|
|
139
|
-
total=len(self.image_paths),
|
|
140
|
-
)
|
|
141
|
-
)
|
|
20
|
+
@override
|
|
21
|
+
def _generate_image_extractors(
|
|
22
|
+
self, image_paths: T.Sequence[Path]
|
|
23
|
+
) -> T.Sequence[ImageEXIFExtractor]:
|
|
24
|
+
return [ImageEXIFExtractor(path) for path in image_paths]
|