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.
Files changed (31) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/commands/process.py +4 -3
  3. mapillary_tools/exceptions.py +4 -0
  4. mapillary_tools/exif_read.py +543 -65
  5. mapillary_tools/exiftool_read.py +406 -0
  6. mapillary_tools/exiftool_read_video.py +360 -0
  7. mapillary_tools/geo.py +10 -2
  8. mapillary_tools/geotag/geotag_from_generic.py +13 -2
  9. mapillary_tools/geotag/{geotag_from_exif.py → geotag_images_from_exif.py} +51 -67
  10. mapillary_tools/geotag/geotag_images_from_exiftool.py +123 -0
  11. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +81 -0
  12. mapillary_tools/geotag/{geotag_from_gpx.py → geotag_images_from_gpx.py} +16 -13
  13. mapillary_tools/geotag/{geotag_from_gpx_file.py → geotag_images_from_gpx_file.py} +52 -36
  14. mapillary_tools/geotag/{geotag_from_nmea_file.py → geotag_images_from_nmea_file.py} +4 -5
  15. mapillary_tools/geotag/geotag_images_from_video.py +87 -0
  16. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +105 -0
  17. mapillary_tools/geotag/geotag_videos_from_video.py +175 -0
  18. mapillary_tools/process_geotag_properties.py +65 -31
  19. mapillary_tools/sample_video.py +19 -6
  20. mapillary_tools/types.py +2 -0
  21. mapillary_tools/utils.py +24 -2
  22. {mapillary_tools-0.10.2a0.dist-info → mapillary_tools-0.10.3a1.dist-info}/METADATA +1 -1
  23. {mapillary_tools-0.10.2a0.dist-info → mapillary_tools-0.10.3a1.dist-info}/RECORD +27 -24
  24. mapillary_tools/geotag/geotag_from_blackvue.py +0 -93
  25. mapillary_tools/geotag/geotag_from_camm.py +0 -94
  26. mapillary_tools/geotag/geotag_from_gopro.py +0 -96
  27. mapillary_tools/geotag/geotag_from_video.py +0 -145
  28. {mapillary_tools-0.10.2a0.dist-info → mapillary_tools-0.10.3a1.dist-info}/LICENSE +0 -0
  29. {mapillary_tools-0.10.2a0.dist-info → mapillary_tools-0.10.3a1.dist-info}/WHEEL +0 -0
  30. {mapillary_tools-0.10.2a0.dist-info → mapillary_tools-0.10.3a1.dist-info}/entry_points.txt +0 -0
  31. {mapillary_tools-0.10.2a0.dist-info → mapillary_tools-0.10.3a1.dist-info}/top_level.txt +0 -0
@@ -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
- try:
38
- return datetime.datetime.strptime(dtstr, format)
39
- except ValueError:
40
- continue
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 parse_timestr(timestr: str) -> T.Optional[datetime.timedelta]:
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 make_valid_timezone_offset(delta: datetime.timedelta) -> datetime.timedelta:
63
- # otherwise: ValueError: offset must be a timedelta strictly between -timedelta(hours=24) and timedelta(hours=24), not datetime.timedelta(days=1)
64
- h24 = datetime.timedelta(hours=24)
65
- if h24 <= delta:
66
- delta = delta % h24
67
- elif delta <= -h24:
68
- delta = delta % -h24
69
- return delta
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
- _FIELD_TYPE = T.TypeVar("_FIELD_TYPE", int, float, str)
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
- def parse_datetimestr(
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
- subsec "123" will be parsed as seconds 0.123 i.e microseconds 123000 and added to the datetime object.
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
- date_and_time = dtstr.split(maxsplit=2)
85
- if len(date_and_time) < 2:
86
- return None
87
- date, time = date_and_time[:2]
88
- d = strptime_alternative_formats(date, ["%Y:%m:%d", "%Y-%m-%d"])
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
- d = d + time_delta
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
- d = d.replace(microsecond=microseconds)
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 = parse_timestr(tz_offset[1:])
225
+ offset_delta = parse_timestr_as_timedelta(tz_offset[1:])
107
226
  elif tz_offset.startswith("-"):
108
- offset_delta = parse_timestr(tz_offset[1:])
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 = parse_timestr(tz_offset)
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
- d = d.replace(tzinfo=tzinfo)
117
- return d
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
- class ExifRead:
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
- self.tags = exifread.process_file(fp, details=details, debug=True)
486
+ try:
487
+ self.tags = exifread.process_file(fp, details=True, debug=True)
488
+ except Exception:
489
+ self.tags = {}
490
+
134
491
  else:
135
- self.tags = exifread.process_file(
136
- path_or_stream, details=details, debug=True
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
- dt = strptime_alternative_formats(gpsdate, ["%Y:%m:%d"])
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
- try:
166
- h, m, s, *_ = gpstimestamp.values
167
- except (ValueError, TypeError):
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
- hour = int(eval_frac(h))
176
- minute = int(eval_frac(m))
177
- second = float(eval_frac(s))
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 = parse_datetimestr(dtstr, subsec, offset)
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
- return self._extract_alternative_fields(["EXIF LensMake", "Image Make"], str)
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
- return self._extract_alternative_fields(["EXIF LensModel", "Image Model"], str)
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 and isinstance(values[0], Ratio):
350
- try:
351
- return T.cast(_FIELD_TYPE, eval_frac(values[0]))
352
- except ZeroDivisionError:
353
- pass
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
- return T.cast(_FIELD_TYPE, str(values))
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