Skip to content

Commit 7a45e5e

Browse files
authored
improve: geotag with exiftool after native geotagging (#737)
* add exiftool_runner.py * add the test cli for exiftool_runner.py * git add mapillary_tools/geotag/geotag_videos_from_gpx.py * update types * add index_rdf_description_by_path_from_xml_element * handle the case when MAPILLARY_TOOLS_EXIFTOOL_PATH is not set * git add mapillary_tools/geotag/options.py * git add mapillary_tools/geotag/factory.py * update process command to use the latest geotag factory and options * pass the ruff check * fix mypy * alias for source type parsing * refactor: move parse_source_options out * fix failed tests for video_process * fix more test failures * forgot add the fix * fix sample_video.py * fix enum.StrEnum is added in 3.11 * type errors in 3.8 * skip and warning when exiftool executable not found * fix filetype filters
1 parent 2bcbc8b commit 7a45e5e

20 files changed

+1006
-329
lines changed

mapillary_tools/commands/process.py

Lines changed: 17 additions & 45 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,15 @@
1+
from __future__ import annotations
2+
13
import argparse
24
import inspect
3-
import typing as T
45
from pathlib import Path
56

6-
from .. import constants
7+
from .. import constants, types
78
from ..process_geotag_properties import (
8-
FileType,
9-
GeotagSource,
9+
DEFAULT_GEOTAG_SOURCE_OPTIONS,
1010
process_finalize,
1111
process_geotag_properties,
12+
SourceType,
1213
)
1314
from ..process_sequence_properties import process_sequence_properties
1415

@@ -24,24 +25,13 @@ class Command:
2425
help = "process images and videos"
2526

2627
def add_basic_arguments(self, parser: argparse.ArgumentParser):
27-
geotag_sources: T.List[GeotagSource] = [
28-
"blackvue_videos",
29-
"camm",
30-
"exif",
31-
"exiftool",
32-
"gopro_videos",
33-
"gpx",
34-
"nmea",
35-
]
36-
geotag_gpx_based_sources: T.List[GeotagSource] = [
37-
"gpx",
38-
"gopro_videos",
39-
"nmea",
40-
"blackvue_videos",
41-
"camm",
28+
geotag_gpx_based_sources: list[str] = [
29+
SourceType.GPX.value,
30+
SourceType.NMEA.value,
31+
SourceType.GOPRO.value,
32+
SourceType.BLACKVUE.value,
33+
SourceType.CAMM.value,
4234
]
43-
for source in geotag_gpx_based_sources:
44-
assert source in geotag_sources
4535

4636
parser.add_argument(
4737
"--skip_process_errors",
@@ -53,9 +43,9 @@ def add_basic_arguments(self, parser: argparse.ArgumentParser):
5343
parser.add_argument(
5444
"--filetypes",
5545
"--file_types",
56-
help=f"Process files of the specified types only. Supported file types: {','.join(sorted(t.value for t in FileType))} [default: %(default)s]",
57-
type=lambda option: set(FileType(t) for t in option.split(",")),
58-
default=",".join(sorted(t.value for t in FileType)),
46+
help=f"Process files of the specified types only. Supported file types: {','.join(sorted(t.value for t in types.FileType))} [default: %(default)s]",
47+
type=lambda option: set(types.FileType(t) for t in option.split(",")),
48+
default=None,
5949
required=False,
6050
)
6151
group = parser.add_argument_group(bold_text("PROCESS EXIF OPTIONS"))
@@ -122,10 +112,9 @@ def add_basic_arguments(self, parser: argparse.ArgumentParser):
122112
)
123113
group_geotagging.add_argument(
124114
"--geotag_source",
125-
help="Provide the source of date/time and GPS information needed for geotagging. [default: %(default)s]",
126-
action="store",
127-
choices=geotag_sources,
128-
default="exif",
115+
help=f"Provide the source of date/time and GPS information needed for geotagging. Supported source types: {', '.join(g.value for g in SourceType)} [default: {','.join(DEFAULT_GEOTAG_SOURCE_OPTIONS)}]",
116+
action="append",
117+
default=[],
129118
required=False,
130119
)
131120
group_geotagging.add_argument(
@@ -216,24 +205,7 @@ def add_basic_arguments(self, parser: argparse.ArgumentParser):
216205
)
217206

218207
def run(self, vars_args: dict):
219-
if (
220-
"geotag_source" in vars_args
221-
and vars_args["geotag_source"] == "blackvue_videos"
222-
and (
223-
"device_make" not in vars_args
224-
or ("device_make" in vars_args and not vars_args["device_make"])
225-
)
226-
):
227-
vars_args["device_make"] = "Blackvue"
228-
if (
229-
"device_make" in vars_args
230-
and vars_args["device_make"]
231-
and vars_args["device_make"].lower() == "blackvue"
232-
):
233-
vars_args["duplicate_angle"] = 360
234-
235208
metadatas = process_geotag_properties(
236-
vars_args=vars_args,
237209
**(
238210
{
239211
k: v

mapillary_tools/constants.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import os
24
import typing as T
35

@@ -30,7 +32,8 @@ def _yes_or_no(val: str) -> bool:
3032
VIDEO_DURATION_RATIO = float(os.getenv(_ENV_PREFIX + "VIDEO_DURATION_RATIO", 1))
3133
FFPROBE_PATH: str = os.getenv(_ENV_PREFIX + "FFPROBE_PATH", "ffprobe")
3234
FFMPEG_PATH: str = os.getenv(_ENV_PREFIX + "FFMPEG_PATH", "ffmpeg")
33-
EXIFTOOL_PATH: str = os.getenv(_ENV_PREFIX + "EXIFTOOL_PATH", "exiftool")
35+
# When not set, MT will try to check both "exiftool" and "exiftool.exe" from $PATH
36+
EXIFTOOL_PATH: str | None = os.getenv(_ENV_PREFIX + "EXIFTOOL_PATH")
3437
IMAGE_DESCRIPTION_FILENAME = os.getenv(
3538
_ENV_PREFIX + "IMAGE_DESCRIPTION_FILENAME", "mapillary_image_description.json"
3639
)

mapillary_tools/exiftool_read.py

Lines changed: 19 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
import datetime
24
import logging
35
import typing as T
@@ -93,11 +95,23 @@ def index_rdf_description_by_path(
9395
LOG.warning(f"Failed to parse {xml_path}: {ex}", exc_info=verbose)
9496
continue
9597

96-
elements = etree.iterfind(_DESCRIPTION_TAG, namespaces=EXIFTOOL_NAMESPACES)
97-
for element in elements:
98-
path = find_rdf_description_path(element)
99-
if path is not None:
100-
rdf_description_by_path[canonical_path(path)] = element
98+
rdf_description_by_path.update(
99+
index_rdf_description_by_path_from_xml_element(etree.getroot())
100+
)
101+
102+
return rdf_description_by_path
103+
104+
105+
def index_rdf_description_by_path_from_xml_element(
106+
element: ET.Element,
107+
) -> dict[str, ET.Element]:
108+
rdf_description_by_path: dict[str, ET.Element] = {}
109+
110+
elements = element.iterfind(_DESCRIPTION_TAG, namespaces=EXIFTOOL_NAMESPACES)
111+
for element in elements:
112+
path = find_rdf_description_path(element)
113+
if path is not None:
114+
rdf_description_by_path[canonical_path(path)] = element
101115

102116
return rdf_description_by_path
103117

mapillary_tools/exiftool_runner.py

Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
from __future__ import annotations
2+
3+
import platform
4+
import shutil
5+
import subprocess
6+
import typing as T
7+
from pathlib import Path
8+
9+
10+
class ExiftoolRunner:
11+
"""
12+
Wrapper around ExifTool to run it in a subprocess
13+
"""
14+
15+
def __init__(self, exiftool_path: str | None = None, recursive: bool = False):
16+
if exiftool_path is None:
17+
exiftool_path = self._search_preferred_exiftool_path()
18+
self.exiftool_path = exiftool_path
19+
self.recursive = recursive
20+
21+
def _search_preferred_exiftool_path(self) -> str:
22+
system = platform.system()
23+
24+
if system and system.lower() == "windows":
25+
exiftool_paths = ["exiftool.exe", "exiftool"]
26+
else:
27+
exiftool_paths = ["exiftool", "exiftool.exe"]
28+
29+
for path in exiftool_paths:
30+
full_path = shutil.which(path)
31+
if full_path:
32+
return path
33+
34+
# Always return the prefered one, even if it is not found,
35+
# and let the subprocess.run figure out the error later
36+
return exiftool_paths[0]
37+
38+
def _build_args_read_stdin(self) -> list[str]:
39+
args: list[str] = [
40+
self.exiftool_path,
41+
"-q",
42+
"-n", # Disable print conversion
43+
"-X", # XML output
44+
"-ee",
45+
*["-api", "LargeFileSupport=1"],
46+
*["-charset", "filename=utf8"],
47+
*["-@", "-"],
48+
]
49+
50+
if self.recursive:
51+
args.append("-r")
52+
53+
return args
54+
55+
def extract_xml(self, paths: T.Sequence[Path]) -> str:
56+
if not paths:
57+
# ExifTool will show its full manual if no files are provided
58+
raise ValueError("No files provided to exiftool")
59+
60+
# To handle non-latin1 filenames under Windows, we pass the path
61+
# via stdin. See https://exiftool.org/faq.html#Q18
62+
stdin = "\n".join([str(p.resolve()) for p in paths])
63+
64+
args = self._build_args_read_stdin()
65+
66+
# Raise FileNotFoundError here if self.exiftool_path not found
67+
process = subprocess.run(
68+
args,
69+
capture_output=True,
70+
text=True,
71+
input=stdin,
72+
encoding="utf-8",
73+
# Do not check exit status to allow some files not found
74+
# check=True,
75+
)
76+
77+
return process.stdout

0 commit comments

Comments
 (0)