mapillary-tools 0.10.2a0__py3-none-any.whl → 0.10.3a1__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/commands/process.py +4 -3
- mapillary_tools/exceptions.py +4 -0
- mapillary_tools/exif_read.py +543 -65
- mapillary_tools/exiftool_read.py +406 -0
- mapillary_tools/exiftool_read_video.py +360 -0
- mapillary_tools/geo.py +10 -2
- mapillary_tools/geotag/geotag_from_generic.py +13 -2
- mapillary_tools/geotag/{geotag_from_exif.py → geotag_images_from_exif.py} +51 -67
- mapillary_tools/geotag/geotag_images_from_exiftool.py +123 -0
- mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +81 -0
- mapillary_tools/geotag/{geotag_from_gpx.py → geotag_images_from_gpx.py} +16 -13
- mapillary_tools/geotag/{geotag_from_gpx_file.py → geotag_images_from_gpx_file.py} +52 -36
- mapillary_tools/geotag/{geotag_from_nmea_file.py → geotag_images_from_nmea_file.py} +4 -5
- mapillary_tools/geotag/geotag_images_from_video.py +87 -0
- mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +105 -0
- mapillary_tools/geotag/geotag_videos_from_video.py +175 -0
- mapillary_tools/process_geotag_properties.py +65 -31
- mapillary_tools/sample_video.py +19 -6
- mapillary_tools/types.py +2 -0
- mapillary_tools/utils.py +24 -2
- {mapillary_tools-0.10.2a0.dist-info → mapillary_tools-0.10.3a1.dist-info}/METADATA +1 -1
- {mapillary_tools-0.10.2a0.dist-info → mapillary_tools-0.10.3a1.dist-info}/RECORD +27 -24
- mapillary_tools/geotag/geotag_from_blackvue.py +0 -93
- mapillary_tools/geotag/geotag_from_camm.py +0 -94
- mapillary_tools/geotag/geotag_from_gopro.py +0 -96
- mapillary_tools/geotag/geotag_from_video.py +0 -145
- {mapillary_tools-0.10.2a0.dist-info → mapillary_tools-0.10.3a1.dist-info}/LICENSE +0 -0
- {mapillary_tools-0.10.2a0.dist-info → mapillary_tools-0.10.3a1.dist-info}/WHEEL +0 -0
- {mapillary_tools-0.10.2a0.dist-info → mapillary_tools-0.10.3a1.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.10.2a0.dist-info → mapillary_tools-0.10.3a1.dist-info}/top_level.txt +0 -0
mapillary_tools/exif_read.py
CHANGED
|
@@ -1,11 +1,28 @@
|
|
|
1
|
+
import abc
|
|
1
2
|
import datetime
|
|
3
|
+
import logging
|
|
2
4
|
import typing as T
|
|
5
|
+
import xml.etree.ElementTree as et
|
|
3
6
|
from pathlib import Path
|
|
4
7
|
|
|
5
8
|
import exifread
|
|
6
9
|
from exifread.utils import Ratio
|
|
7
10
|
|
|
8
11
|
|
|
12
|
+
XMP_NAMESPACES = {
|
|
13
|
+
"exif": "http://ns.adobe.com/exif/1.0/",
|
|
14
|
+
"tiff": "http://ns.adobe.com/tiff/1.0/",
|
|
15
|
+
"exifEX": "http://cipa.jp/exif/1.0/",
|
|
16
|
+
"xmp": "http://ns.adobe.com/xap/1.0/",
|
|
17
|
+
"rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
|
|
18
|
+
"x": "adobe:ns:meta/",
|
|
19
|
+
"GPano": "http://ns.google.com/photos/1.0/panorama/",
|
|
20
|
+
}
|
|
21
|
+
# https://github.com/ianare/exif-py/issues/167
|
|
22
|
+
EXIFREAD_LOG = logging.getLogger("exifread")
|
|
23
|
+
EXIFREAD_LOG.setLevel(logging.ERROR)
|
|
24
|
+
|
|
25
|
+
|
|
9
26
|
def eval_frac(value: Ratio) -> float:
|
|
10
27
|
return float(value.num) / float(value.den)
|
|
11
28
|
|
|
@@ -30,20 +47,35 @@ def gps_to_decimal(values: T.Tuple[Ratio, Ratio, Ratio]) -> T.Optional[float]:
|
|
|
30
47
|
return degrees + minutes / 60 + seconds / 3600
|
|
31
48
|
|
|
32
49
|
|
|
50
|
+
def _parse_iso(dtstr: str) -> T.Optional[datetime.datetime]:
|
|
51
|
+
try:
|
|
52
|
+
return datetime.datetime.fromisoformat(dtstr)
|
|
53
|
+
except ValueError:
|
|
54
|
+
# fromisoformat does not support trailing Z
|
|
55
|
+
return strptime_alternative_formats(
|
|
56
|
+
dtstr, ["%Y-%m-%dT%H:%M:%S.%f%z", "%Y-%m-%dT%H:%M:%S%z"]
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
33
60
|
def strptime_alternative_formats(
|
|
34
61
|
dtstr: str, formats: T.Sequence[str]
|
|
35
62
|
) -> T.Optional[datetime.datetime]:
|
|
36
63
|
for format in formats:
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
64
|
+
if format == "ISO":
|
|
65
|
+
dt = _parse_iso(dtstr)
|
|
66
|
+
if dt is not None:
|
|
67
|
+
return dt
|
|
68
|
+
else:
|
|
69
|
+
try:
|
|
70
|
+
return datetime.datetime.strptime(dtstr, format)
|
|
71
|
+
except ValueError:
|
|
72
|
+
pass
|
|
41
73
|
return None
|
|
42
74
|
|
|
43
75
|
|
|
44
|
-
def
|
|
76
|
+
def parse_timestr_as_timedelta(timestr: str) -> T.Optional[datetime.timedelta]:
|
|
77
|
+
timestr = timestr.strip()
|
|
45
78
|
parts = timestr.strip().split(":")
|
|
46
|
-
|
|
47
79
|
try:
|
|
48
80
|
if len(parts) == 0:
|
|
49
81
|
raise ValueError
|
|
@@ -59,82 +91,410 @@ def parse_timestr(timestr: str) -> T.Optional[datetime.timedelta]:
|
|
|
59
91
|
return datetime.timedelta(hours=h, minutes=m, seconds=s)
|
|
60
92
|
|
|
61
93
|
|
|
62
|
-
def
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
94
|
+
def parse_time_ratios_as_timedelta(
|
|
95
|
+
time_tuple: T.Sequence[Ratio],
|
|
96
|
+
) -> T.Optional[datetime.timedelta]:
|
|
97
|
+
try:
|
|
98
|
+
hours, minutes, seconds, *_ = time_tuple
|
|
99
|
+
except (ValueError, TypeError):
|
|
100
|
+
return None
|
|
101
|
+
if not isinstance(hours, Ratio):
|
|
102
|
+
return None
|
|
103
|
+
if not isinstance(minutes, Ratio):
|
|
104
|
+
return None
|
|
105
|
+
if not isinstance(seconds, Ratio):
|
|
106
|
+
return None
|
|
107
|
+
try:
|
|
108
|
+
h: int = int(eval_frac(hours))
|
|
109
|
+
m: int = int(eval_frac(minutes))
|
|
110
|
+
s: float = eval_frac(seconds)
|
|
111
|
+
except (ValueError, ZeroDivisionError):
|
|
112
|
+
return None
|
|
113
|
+
return datetime.timedelta(hours=h, minutes=m, seconds=s)
|
|
70
114
|
|
|
71
115
|
|
|
72
|
-
|
|
116
|
+
def parse_gps_datetime(
|
|
117
|
+
dtstr: str,
|
|
118
|
+
default_tz: T.Optional[datetime.timezone] = datetime.timezone.utc,
|
|
119
|
+
) -> T.Optional[datetime.datetime]:
|
|
120
|
+
dtstr = dtstr.strip()
|
|
121
|
+
|
|
122
|
+
dt = strptime_alternative_formats(dtstr, ["ISO"])
|
|
123
|
+
if dt is not None:
|
|
124
|
+
if dt.tzinfo is None:
|
|
125
|
+
dt = dt.replace(tzinfo=default_tz)
|
|
126
|
+
return dt
|
|
127
|
+
|
|
128
|
+
date_and_time = dtstr.split(maxsplit=2)
|
|
129
|
+
if len(date_and_time) < 2:
|
|
130
|
+
return None
|
|
131
|
+
datestr, timestr, *_ = date_and_time
|
|
132
|
+
return parse_gps_datetime_separately(datestr, timestr, default_tz=default_tz)
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def parse_gps_datetime_separately(
|
|
136
|
+
datestr: str,
|
|
137
|
+
timestr: str,
|
|
138
|
+
default_tz: T.Optional[datetime.timezone] = datetime.timezone.utc,
|
|
139
|
+
) -> T.Optional[datetime.datetime]:
|
|
140
|
+
"""
|
|
141
|
+
Parse GPSDateStamp and GPSTimeStamp and return the corresponding datetime object in GMT.
|
|
142
|
+
|
|
143
|
+
Valid examples:
|
|
144
|
+
- "2021:08:02" "07:57:06"
|
|
145
|
+
- "2022:06:10" "17:35:52.269367"
|
|
146
|
+
- "2022:06:10" "17:35:52.269367Z"
|
|
147
|
+
"""
|
|
148
|
+
datestr = datestr.strip()
|
|
149
|
+
dt = strptime_alternative_formats(datestr, ["%Y:%m:%d", "%Y-%m-%d"])
|
|
150
|
+
if dt is None:
|
|
151
|
+
return None
|
|
152
|
+
|
|
153
|
+
# split the time part and timezone part
|
|
154
|
+
# examples: 12:22:00.123000+01:00
|
|
155
|
+
# 12:22:00.123000Z
|
|
156
|
+
# 12:22:00
|
|
157
|
+
timestr = timestr.strip()
|
|
158
|
+
if timestr.endswith("Z"):
|
|
159
|
+
timepartstr = timestr[:-1]
|
|
160
|
+
tzinfo = datetime.timezone.utc
|
|
161
|
+
else:
|
|
162
|
+
# find the first + or -
|
|
163
|
+
idx = timestr.find("+")
|
|
164
|
+
if idx < 0:
|
|
165
|
+
idx = timestr.find("-")
|
|
166
|
+
# if found, then parse the offset
|
|
167
|
+
if 0 <= idx:
|
|
168
|
+
timepartstr = timestr[:idx]
|
|
169
|
+
offset_delta = parse_timestr_as_timedelta(timestr[idx + 1 :])
|
|
170
|
+
if offset_delta is not None:
|
|
171
|
+
if timestr[idx] == "-":
|
|
172
|
+
offset_delta = -offset_delta
|
|
173
|
+
tzinfo = datetime.timezone(offset_delta)
|
|
174
|
+
else:
|
|
175
|
+
tzinfo = None
|
|
176
|
+
else:
|
|
177
|
+
timepartstr = timestr
|
|
178
|
+
tzinfo = None
|
|
179
|
+
|
|
180
|
+
delta = parse_timestr_as_timedelta(timepartstr)
|
|
181
|
+
if delta is None:
|
|
182
|
+
return None
|
|
183
|
+
dt = dt + delta
|
|
73
184
|
|
|
185
|
+
if tzinfo is None:
|
|
186
|
+
tzinfo = default_tz
|
|
74
187
|
|
|
75
|
-
|
|
188
|
+
dt = dt.replace(tzinfo=tzinfo)
|
|
189
|
+
|
|
190
|
+
return dt
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def parse_datetimestr_with_subsec_and_offset(
|
|
76
194
|
dtstr: str, subsec: T.Optional[str] = None, tz_offset: T.Optional[str] = None
|
|
77
195
|
) -> T.Optional[datetime.datetime]:
|
|
78
196
|
"""
|
|
79
197
|
Convert dtstr "YYYY:mm:dd HH:MM:SS[.sss]" to a datetime object.
|
|
80
198
|
It handles time "24:00:00" as "00:00:00" of the next day.
|
|
81
|
-
|
|
199
|
+
Subsec "123" will be parsed as seconds 0.123 i.e microseconds 123000 and added to the datetime object.
|
|
82
200
|
"""
|
|
201
|
+
# handle dtstr
|
|
83
202
|
dtstr = dtstr.strip()
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
if d is None:
|
|
90
|
-
return None
|
|
91
|
-
time_delta = parse_timestr(time)
|
|
92
|
-
if time_delta is None:
|
|
93
|
-
# unable to parse HH:MM:SS
|
|
203
|
+
|
|
204
|
+
# example dtstr: <exif:DateTimeOriginal>2021-07-15T15:37:30+10:00</exif:DateTimeOriginal>
|
|
205
|
+
# example dtstr: 'EXIF DateTimeOriginal': (0x9003) ASCII=2021:07:15 15:37:30 @ 1278
|
|
206
|
+
dt = parse_gps_datetime(dtstr, default_tz=None)
|
|
207
|
+
if dt is None:
|
|
94
208
|
return None
|
|
95
|
-
|
|
209
|
+
|
|
210
|
+
# handle subsec
|
|
96
211
|
if subsec is not None:
|
|
212
|
+
subsec = subsec.strip()
|
|
97
213
|
if len(subsec) < 6:
|
|
98
214
|
subsec = subsec + ("0" * 6)
|
|
99
215
|
microseconds = int(subsec[:6])
|
|
100
216
|
# ValueError: microsecond must be in 0..999999
|
|
101
217
|
microseconds = microseconds % int(1e6)
|
|
102
218
|
# always overrides the microseconds
|
|
103
|
-
|
|
219
|
+
dt = dt.replace(microsecond=microseconds)
|
|
220
|
+
|
|
221
|
+
# handle tz_offset
|
|
104
222
|
if tz_offset is not None:
|
|
223
|
+
tz_offset = tz_offset.strip()
|
|
105
224
|
if tz_offset.startswith("+"):
|
|
106
|
-
offset_delta =
|
|
225
|
+
offset_delta = parse_timestr_as_timedelta(tz_offset[1:])
|
|
107
226
|
elif tz_offset.startswith("-"):
|
|
108
|
-
offset_delta =
|
|
227
|
+
offset_delta = parse_timestr_as_timedelta(tz_offset[1:])
|
|
109
228
|
if offset_delta is not None:
|
|
110
229
|
offset_delta = -1 * offset_delta
|
|
111
230
|
else:
|
|
112
|
-
offset_delta =
|
|
231
|
+
offset_delta = parse_timestr_as_timedelta(tz_offset)
|
|
113
232
|
if offset_delta is not None:
|
|
114
233
|
offset_delta = make_valid_timezone_offset(offset_delta)
|
|
115
234
|
tzinfo = datetime.timezone(offset_delta)
|
|
116
|
-
|
|
117
|
-
|
|
235
|
+
dt = dt.replace(tzinfo=tzinfo)
|
|
236
|
+
|
|
237
|
+
return dt
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def make_valid_timezone_offset(delta: datetime.timedelta) -> datetime.timedelta:
|
|
241
|
+
# otherwise: ValueError: offset must be a timedelta strictly between -timedelta(hours=24)
|
|
242
|
+
# and timedelta(hours=24), not datetime.timedelta(days=1)
|
|
243
|
+
h24 = datetime.timedelta(hours=24)
|
|
244
|
+
if h24 <= delta:
|
|
245
|
+
delta = delta % h24
|
|
246
|
+
elif delta <= -h24:
|
|
247
|
+
delta = delta % -h24
|
|
248
|
+
return delta
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
_FIELD_TYPE = T.TypeVar("_FIELD_TYPE", int, float, str)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
class ExifReadABC(abc.ABC):
|
|
255
|
+
@abc.abstractmethod
|
|
256
|
+
def extract_altitude(self) -> T.Optional[float]:
|
|
257
|
+
raise NotImplementedError
|
|
258
|
+
|
|
259
|
+
@abc.abstractmethod
|
|
260
|
+
def extract_capture_time(self) -> T.Optional[datetime.datetime]:
|
|
261
|
+
raise NotImplementedError
|
|
262
|
+
|
|
263
|
+
@abc.abstractmethod
|
|
264
|
+
def extract_direction(self) -> T.Optional[float]:
|
|
265
|
+
raise NotImplementedError
|
|
266
|
+
|
|
267
|
+
@abc.abstractmethod
|
|
268
|
+
def extract_lon_lat(self) -> T.Optional[T.Tuple[float, float]]:
|
|
269
|
+
raise NotImplementedError
|
|
270
|
+
|
|
271
|
+
@abc.abstractmethod
|
|
272
|
+
def extract_make(self) -> T.Optional[str]:
|
|
273
|
+
raise NotImplementedError
|
|
274
|
+
|
|
275
|
+
@abc.abstractmethod
|
|
276
|
+
def extract_model(self) -> T.Optional[str]:
|
|
277
|
+
raise NotImplementedError
|
|
278
|
+
|
|
279
|
+
@abc.abstractmethod
|
|
280
|
+
def extract_width(self) -> T.Optional[int]:
|
|
281
|
+
raise NotImplementedError
|
|
282
|
+
|
|
283
|
+
@abc.abstractmethod
|
|
284
|
+
def extract_height(self) -> T.Optional[int]:
|
|
285
|
+
raise NotImplementedError
|
|
286
|
+
|
|
287
|
+
@abc.abstractmethod
|
|
288
|
+
def extract_orientation(self) -> int:
|
|
289
|
+
raise NotImplementedError
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
class ExifReadFromXMP(ExifReadABC):
|
|
293
|
+
def __init__(self, etree: et.ElementTree):
|
|
294
|
+
self.etree = etree
|
|
295
|
+
self._tags_or_attrs: T.Dict[str, str] = {}
|
|
296
|
+
for description in self.etree.iterfind(
|
|
297
|
+
".//rdf:Description", namespaces=XMP_NAMESPACES
|
|
298
|
+
):
|
|
299
|
+
for k, v in description.items():
|
|
300
|
+
self._tags_or_attrs[k] = v
|
|
301
|
+
for child in description:
|
|
302
|
+
if child.text is not None:
|
|
303
|
+
self._tags_or_attrs[child.tag] = child.text
|
|
304
|
+
|
|
305
|
+
def extract_altitude(self) -> T.Optional[float]:
|
|
306
|
+
return self._extract_alternative_fields(["exif:GPSAltitude"], float)
|
|
307
|
+
|
|
308
|
+
def _extract_exif_datetime(
|
|
309
|
+
self, dt_tag: str, subsec_tag: str, offset_tag: str
|
|
310
|
+
) -> T.Optional[datetime.datetime]:
|
|
311
|
+
dtstr = self._extract_alternative_fields([dt_tag], str)
|
|
312
|
+
if dtstr is None:
|
|
313
|
+
return None
|
|
314
|
+
subsec = self._extract_alternative_fields([subsec_tag], str)
|
|
315
|
+
# See https://github.com/mapillary/mapillary_tools/issues/388#issuecomment-860198046
|
|
316
|
+
# and https://community.gopro.com/t5/Cameras/subsecond-timestamp-bug/m-p/1057505
|
|
317
|
+
if subsec:
|
|
318
|
+
subsec = subsec.replace(" ", "0")
|
|
319
|
+
offset = self._extract_alternative_fields([offset_tag], str)
|
|
320
|
+
dt = parse_datetimestr_with_subsec_and_offset(dtstr, subsec, offset)
|
|
321
|
+
if dt is None:
|
|
322
|
+
return None
|
|
323
|
+
return dt
|
|
118
324
|
|
|
325
|
+
def extract_exif_datetime(self) -> T.Optional[datetime.datetime]:
|
|
326
|
+
dt = self._extract_exif_datetime(
|
|
327
|
+
"exif:DateTimeOriginal",
|
|
328
|
+
"exif:SubsecTimeOriginal",
|
|
329
|
+
"exif:OffsetTimeOriginal",
|
|
330
|
+
)
|
|
331
|
+
if dt is not None:
|
|
332
|
+
return dt
|
|
119
333
|
|
|
120
|
-
|
|
334
|
+
dt = self._extract_exif_datetime(
|
|
335
|
+
"exif:DateTimeDigitized",
|
|
336
|
+
"exif:SubsecTimeDigitized",
|
|
337
|
+
"exif:OffsetTimeDigitized",
|
|
338
|
+
)
|
|
339
|
+
if dt is not None:
|
|
340
|
+
return dt
|
|
341
|
+
|
|
342
|
+
return None
|
|
343
|
+
|
|
344
|
+
def extract_gps_datetime(self) -> T.Optional[datetime.datetime]:
|
|
345
|
+
"""
|
|
346
|
+
Extract timestamp from GPS field.
|
|
347
|
+
"""
|
|
348
|
+
timestr = self._extract_alternative_fields(["exif:GPSTimeStamp"], str)
|
|
349
|
+
if not timestr:
|
|
350
|
+
return None
|
|
351
|
+
|
|
352
|
+
# handle: <exif:GPSTimeStamp>2021-07-15T05:37:30Z</exif:GPSTimeStamp>
|
|
353
|
+
dt = strptime_alternative_formats(timestr, ["ISO"])
|
|
354
|
+
if dt is not None:
|
|
355
|
+
return dt
|
|
356
|
+
|
|
357
|
+
datestr = self._extract_alternative_fields(["exif:GPSDateStamp"], str)
|
|
358
|
+
if datestr is None:
|
|
359
|
+
return None
|
|
360
|
+
|
|
361
|
+
# handle: exif:GPSTimeStamp="17:22:05.999000"
|
|
362
|
+
return parse_gps_datetime_separately(datestr, timestr)
|
|
363
|
+
|
|
364
|
+
def extract_capture_time(self) -> T.Optional[datetime.datetime]:
|
|
365
|
+
dt = self.extract_gps_datetime()
|
|
366
|
+
if dt is not None:
|
|
367
|
+
return dt
|
|
368
|
+
|
|
369
|
+
dt = self.extract_exif_datetime()
|
|
370
|
+
if dt is not None:
|
|
371
|
+
return dt
|
|
372
|
+
|
|
373
|
+
return None
|
|
374
|
+
|
|
375
|
+
def extract_direction(self) -> T.Optional[float]:
|
|
376
|
+
return self._extract_alternative_fields(
|
|
377
|
+
["exif:GPSImgDirection", "exif:GPSTrack"], float
|
|
378
|
+
)
|
|
379
|
+
|
|
380
|
+
def extract_lon_lat(self) -> T.Optional[T.Tuple[float, float]]:
|
|
381
|
+
lat = self._extract_alternative_fields(["exif:GPSLatitude"], float)
|
|
382
|
+
if lat is None:
|
|
383
|
+
return None
|
|
384
|
+
|
|
385
|
+
lon = self._extract_alternative_fields(["exif:GPSLongitude"], float)
|
|
386
|
+
if lon is None:
|
|
387
|
+
return None
|
|
388
|
+
|
|
389
|
+
ref = self._extract_alternative_fields(["exif:GPSLongitudeRef"], str)
|
|
390
|
+
if ref and ref.upper() == "W":
|
|
391
|
+
lon = -1 * lon
|
|
392
|
+
|
|
393
|
+
ref = self._extract_alternative_fields(["exif:GPSLatitudeRef"], str)
|
|
394
|
+
if ref and ref.upper() == "S":
|
|
395
|
+
lat = -1 * lat
|
|
396
|
+
|
|
397
|
+
return lon, lat
|
|
398
|
+
|
|
399
|
+
def extract_make(self) -> T.Optional[str]:
|
|
400
|
+
make = self._extract_alternative_fields(["tiff:Make", "exifEX:LensMake"], str)
|
|
401
|
+
if make is None:
|
|
402
|
+
return None
|
|
403
|
+
return make.strip()
|
|
404
|
+
|
|
405
|
+
def extract_model(self) -> T.Optional[str]:
|
|
406
|
+
model = self._extract_alternative_fields(
|
|
407
|
+
["tiff:Model", "exifEX:LensModel"], str
|
|
408
|
+
)
|
|
409
|
+
if model is None:
|
|
410
|
+
return None
|
|
411
|
+
return model.strip()
|
|
412
|
+
|
|
413
|
+
def extract_width(self) -> T.Optional[int]:
|
|
414
|
+
return self._extract_alternative_fields(
|
|
415
|
+
[
|
|
416
|
+
"exif:PixelXDimension",
|
|
417
|
+
"GPano:FullPanoWidthPixels",
|
|
418
|
+
"GPano:CroppedAreaImageWidthPixels",
|
|
419
|
+
],
|
|
420
|
+
int,
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
def extract_height(self) -> T.Optional[int]:
|
|
424
|
+
return self._extract_alternative_fields(
|
|
425
|
+
[
|
|
426
|
+
"exif:PixelYDimension",
|
|
427
|
+
"GPano:FullPanoHeightPixels",
|
|
428
|
+
"GPano:CroppedAreaImageHeightPixels",
|
|
429
|
+
],
|
|
430
|
+
int,
|
|
431
|
+
)
|
|
432
|
+
|
|
433
|
+
def extract_orientation(self) -> int:
|
|
434
|
+
orientation = self._extract_alternative_fields(["tiff:Orientation"], int)
|
|
435
|
+
if orientation is None or orientation not in range(1, 9):
|
|
436
|
+
return 1
|
|
437
|
+
return orientation
|
|
438
|
+
|
|
439
|
+
def _extract_alternative_fields(
|
|
440
|
+
self,
|
|
441
|
+
fields: T.Sequence[str],
|
|
442
|
+
field_type: T.Type[_FIELD_TYPE],
|
|
443
|
+
) -> T.Optional[_FIELD_TYPE]:
|
|
444
|
+
"""
|
|
445
|
+
Extract a value for a list of ordered fields.
|
|
446
|
+
Return the value of the first existed field in the list
|
|
447
|
+
"""
|
|
448
|
+
for field in fields:
|
|
449
|
+
ns, attr_or_tag = field.split(":")
|
|
450
|
+
value = self._tags_or_attrs.get(
|
|
451
|
+
"{" + XMP_NAMESPACES[ns] + "}" + attr_or_tag
|
|
452
|
+
)
|
|
453
|
+
if value is None:
|
|
454
|
+
continue
|
|
455
|
+
if field_type is int:
|
|
456
|
+
try:
|
|
457
|
+
return T.cast(_FIELD_TYPE, int(value))
|
|
458
|
+
except (ValueError, TypeError):
|
|
459
|
+
pass
|
|
460
|
+
elif field_type is float:
|
|
461
|
+
try:
|
|
462
|
+
return T.cast(_FIELD_TYPE, float(value))
|
|
463
|
+
except (ValueError, TypeError):
|
|
464
|
+
pass
|
|
465
|
+
elif field_type is str:
|
|
466
|
+
try:
|
|
467
|
+
return T.cast(_FIELD_TYPE, str(value))
|
|
468
|
+
except (ValueError, TypeError):
|
|
469
|
+
pass
|
|
470
|
+
else:
|
|
471
|
+
raise ValueError(f"Invalid field type {field_type}")
|
|
472
|
+
return None
|
|
473
|
+
|
|
474
|
+
|
|
475
|
+
class ExifReadFromEXIF(ExifReadABC):
|
|
121
476
|
"""
|
|
122
477
|
EXIF class for reading exif from an image
|
|
123
478
|
"""
|
|
124
479
|
|
|
125
|
-
def __init__(
|
|
126
|
-
self, path_or_stream: T.Union[Path, T.BinaryIO], details: bool = False
|
|
127
|
-
) -> None:
|
|
480
|
+
def __init__(self, path_or_stream: T.Union[Path, T.BinaryIO]) -> None:
|
|
128
481
|
"""
|
|
129
482
|
Initialize EXIF object with FILE as filename or fileobj
|
|
130
483
|
"""
|
|
131
484
|
if isinstance(path_or_stream, Path):
|
|
132
485
|
with path_or_stream.open("rb") as fp:
|
|
133
|
-
|
|
486
|
+
try:
|
|
487
|
+
self.tags = exifread.process_file(fp, details=True, debug=True)
|
|
488
|
+
except Exception:
|
|
489
|
+
self.tags = {}
|
|
490
|
+
|
|
134
491
|
else:
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
492
|
+
try:
|
|
493
|
+
self.tags = exifread.process_file(
|
|
494
|
+
path_or_stream, details=True, debug=True
|
|
495
|
+
)
|
|
496
|
+
except Exception:
|
|
497
|
+
self.tags = {}
|
|
138
498
|
|
|
139
499
|
def extract_altitude(self) -> T.Optional[float]:
|
|
140
500
|
"""
|
|
@@ -156,26 +516,21 @@ class ExifRead:
|
|
|
156
516
|
gpsdate = self._extract_alternative_fields(["GPS GPSDate"], str)
|
|
157
517
|
if gpsdate is None:
|
|
158
518
|
return None
|
|
159
|
-
|
|
519
|
+
|
|
520
|
+
dt = strptime_alternative_formats(gpsdate, ["%Y:%m:%d", "%Y-%m-%d"])
|
|
160
521
|
if dt is None:
|
|
161
522
|
return None
|
|
523
|
+
|
|
162
524
|
gpstimestamp = self.tags.get("GPS GPSTimeStamp")
|
|
163
525
|
if not gpstimestamp:
|
|
164
526
|
return None
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
return None
|
|
169
|
-
if not isinstance(h, Ratio):
|
|
170
|
-
return None
|
|
171
|
-
if not isinstance(m, Ratio):
|
|
172
|
-
return None
|
|
173
|
-
if not isinstance(s, Ratio):
|
|
527
|
+
|
|
528
|
+
delta = parse_time_ratios_as_timedelta(gpstimestamp.values)
|
|
529
|
+
if delta is None:
|
|
174
530
|
return None
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
dt = dt + datetime.timedelta(hours=hour, minutes=minute, seconds=second)
|
|
531
|
+
|
|
532
|
+
dt = dt + delta
|
|
533
|
+
|
|
179
534
|
# GPS timestamps are always GMT
|
|
180
535
|
dt = dt.replace(tzinfo=datetime.timezone.utc)
|
|
181
536
|
return dt
|
|
@@ -192,7 +547,7 @@ class ExifRead:
|
|
|
192
547
|
if subsec:
|
|
193
548
|
subsec = subsec.replace(" ", "0")
|
|
194
549
|
offset = self._extract_alternative_fields([offset_tag], field_type=str)
|
|
195
|
-
dt =
|
|
550
|
+
dt = parse_datetimestr_with_subsec_and_offset(dtstr, subsec, offset)
|
|
196
551
|
if dt is None:
|
|
197
552
|
return None
|
|
198
553
|
return dt
|
|
@@ -296,13 +651,19 @@ class ExifRead:
|
|
|
296
651
|
"""
|
|
297
652
|
Extract camera make
|
|
298
653
|
"""
|
|
299
|
-
|
|
654
|
+
make = self._extract_alternative_fields(["Image Make", "EXIF LensMake"], str)
|
|
655
|
+
if make is None:
|
|
656
|
+
return None
|
|
657
|
+
return make.strip()
|
|
300
658
|
|
|
301
659
|
def extract_model(self) -> T.Optional[str]:
|
|
302
660
|
"""
|
|
303
661
|
Extract camera model
|
|
304
662
|
"""
|
|
305
|
-
|
|
663
|
+
model = self._extract_alternative_fields(["Image Model", "EXIF LensModel"], str)
|
|
664
|
+
if model is None:
|
|
665
|
+
return None
|
|
666
|
+
return model.strip()
|
|
306
667
|
|
|
307
668
|
def extract_width(self) -> T.Optional[int]:
|
|
308
669
|
"""
|
|
@@ -346,13 +707,17 @@ class ExifRead:
|
|
|
346
707
|
continue
|
|
347
708
|
values = tag.values
|
|
348
709
|
if field_type is float:
|
|
349
|
-
if values
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
710
|
+
if values:
|
|
711
|
+
if len(values) == 1 and isinstance(values[0], Ratio):
|
|
712
|
+
try:
|
|
713
|
+
return T.cast(_FIELD_TYPE, eval_frac(values[0]))
|
|
714
|
+
except ZeroDivisionError:
|
|
715
|
+
pass
|
|
354
716
|
elif field_type is str:
|
|
355
|
-
|
|
717
|
+
try:
|
|
718
|
+
return T.cast(_FIELD_TYPE, str(values))
|
|
719
|
+
except (ValueError, TypeError):
|
|
720
|
+
pass
|
|
356
721
|
elif field_type is int:
|
|
357
722
|
if values:
|
|
358
723
|
try:
|
|
@@ -362,3 +727,116 @@ class ExifRead:
|
|
|
362
727
|
else:
|
|
363
728
|
raise ValueError(f"Invalid field type {field_type}")
|
|
364
729
|
return None
|
|
730
|
+
|
|
731
|
+
def extract_application_notes(self) -> T.Optional[str]:
|
|
732
|
+
xmp = self.tags.get("Image ApplicationNotes")
|
|
733
|
+
if xmp is None:
|
|
734
|
+
return None
|
|
735
|
+
try:
|
|
736
|
+
return str(xmp)
|
|
737
|
+
except (ValueError, TypeError):
|
|
738
|
+
return None
|
|
739
|
+
|
|
740
|
+
|
|
741
|
+
class ExifRead(ExifReadFromEXIF):
|
|
742
|
+
def __init__(self, path_or_stream: T.Union[Path, T.BinaryIO]) -> None:
|
|
743
|
+
super().__init__(path_or_stream)
|
|
744
|
+
self._xmp = self._extract_xmp()
|
|
745
|
+
|
|
746
|
+
def _extract_xmp(self) -> T.Optional[ExifReadFromXMP]:
|
|
747
|
+
application_notes = self.extract_application_notes()
|
|
748
|
+
if application_notes is None:
|
|
749
|
+
return None
|
|
750
|
+
try:
|
|
751
|
+
e = et.fromstring(application_notes)
|
|
752
|
+
except et.ParseError:
|
|
753
|
+
return None
|
|
754
|
+
return ExifReadFromXMP(et.ElementTree(e))
|
|
755
|
+
|
|
756
|
+
def extract_altitude(self) -> T.Optional[float]:
|
|
757
|
+
val = super().extract_altitude()
|
|
758
|
+
if val is not None:
|
|
759
|
+
return val
|
|
760
|
+
if self._xmp is None:
|
|
761
|
+
return None
|
|
762
|
+
val = self._xmp.extract_altitude()
|
|
763
|
+
if val is not None:
|
|
764
|
+
return val
|
|
765
|
+
return None
|
|
766
|
+
|
|
767
|
+
def extract_capture_time(self) -> T.Optional[datetime.datetime]:
|
|
768
|
+
val = super().extract_capture_time()
|
|
769
|
+
if val is not None:
|
|
770
|
+
return val
|
|
771
|
+
if self._xmp is None:
|
|
772
|
+
return None
|
|
773
|
+
val = self._xmp.extract_capture_time()
|
|
774
|
+
if val is not None:
|
|
775
|
+
return val
|
|
776
|
+
return None
|
|
777
|
+
|
|
778
|
+
def extract_direction(self) -> T.Optional[float]:
|
|
779
|
+
val = super().extract_direction()
|
|
780
|
+
if val is not None:
|
|
781
|
+
return val
|
|
782
|
+
if self._xmp is None:
|
|
783
|
+
return None
|
|
784
|
+
val = self._xmp.extract_direction()
|
|
785
|
+
if val is not None:
|
|
786
|
+
return val
|
|
787
|
+
return None
|
|
788
|
+
|
|
789
|
+
def extract_lon_lat(self) -> T.Optional[T.Tuple[float, float]]:
|
|
790
|
+
val = super().extract_lon_lat()
|
|
791
|
+
if val is not None:
|
|
792
|
+
return val
|
|
793
|
+
if self._xmp is None:
|
|
794
|
+
return None
|
|
795
|
+
val = self._xmp.extract_lon_lat()
|
|
796
|
+
if val is not None:
|
|
797
|
+
return val
|
|
798
|
+
return None
|
|
799
|
+
|
|
800
|
+
def extract_make(self) -> T.Optional[str]:
|
|
801
|
+
val = super().extract_make()
|
|
802
|
+
if val is not None:
|
|
803
|
+
return val
|
|
804
|
+
if self._xmp is None:
|
|
805
|
+
return None
|
|
806
|
+
val = self._xmp.extract_make()
|
|
807
|
+
if val is not None:
|
|
808
|
+
return val
|
|
809
|
+
return None
|
|
810
|
+
|
|
811
|
+
def extract_model(self) -> T.Optional[str]:
|
|
812
|
+
val = super().extract_model()
|
|
813
|
+
if val is not None:
|
|
814
|
+
return val
|
|
815
|
+
if self._xmp is None:
|
|
816
|
+
return None
|
|
817
|
+
val = self._xmp.extract_model()
|
|
818
|
+
if val is not None:
|
|
819
|
+
return val
|
|
820
|
+
return None
|
|
821
|
+
|
|
822
|
+
def extract_width(self) -> T.Optional[int]:
|
|
823
|
+
val = super().extract_width()
|
|
824
|
+
if val is not None:
|
|
825
|
+
return val
|
|
826
|
+
if self._xmp is None:
|
|
827
|
+
return None
|
|
828
|
+
val = self._xmp.extract_width()
|
|
829
|
+
if val is not None:
|
|
830
|
+
return val
|
|
831
|
+
return None
|
|
832
|
+
|
|
833
|
+
def extract_height(self) -> T.Optional[int]:
|
|
834
|
+
val = super().extract_height()
|
|
835
|
+
if val is not None:
|
|
836
|
+
return val
|
|
837
|
+
if self._xmp is None:
|
|
838
|
+
return None
|
|
839
|
+
val = self._xmp.extract_height()
|
|
840
|
+
if val is not None:
|
|
841
|
+
return val
|
|
842
|
+
return None
|