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
@@ -0,0 +1,406 @@
1
+ import datetime
2
+ import typing as T
3
+ from xml.etree.ElementTree import ElementTree
4
+
5
+ from . import exif_read
6
+
7
+
8
+ EXIFTOOL_NAMESPACES = {
9
+ "Adobe": "http://ns.exiftool.org/APP14/Adobe/1.0/",
10
+ "Apple": "http://ns.exiftool.org/MakerNotes/Apple/1.0/",
11
+ "Composite": "http://ns.exiftool.org/Composite/1.0/",
12
+ "ExifIFD": "http://ns.exiftool.org/EXIF/ExifIFD/1.0/",
13
+ "ExifTool": "http://ns.exiftool.org/ExifTool/1.0/",
14
+ "File": "http://ns.exiftool.org/File/1.0/",
15
+ "GPS": "http://ns.exiftool.org/EXIF/GPS/1.0/",
16
+ "GoPro": "http://ns.exiftool.org/APP6/GoPro/1.0/",
17
+ "ICC-chrm": "http://ns.exiftool.org/ICC_Profile/ICC-chrm/1.0/",
18
+ "ICC-header": "http://ns.exiftool.org/ICC_Profile/ICC-header/1.0/",
19
+ "ICC-meas": "http://ns.exiftool.org/ICC_Profile/ICC-meas/1.0/",
20
+ "ICC-view": "http://ns.exiftool.org/ICC_Profile/ICC-view/1.0/",
21
+ "ICC_Profile": "http://ns.exiftool.org/ICC_Profile/ICC_Profile/1.0/",
22
+ "IFD0": "http://ns.exiftool.org/EXIF/IFD0/1.0/",
23
+ "IFD1": "http://ns.exiftool.org/EXIF/IFD1/1.0/",
24
+ "IPTC": "http://ns.exiftool.org/IPTC/IPTC/1.0/",
25
+ "InteropIFD": "http://ns.exiftool.org/EXIF/InteropIFD/1.0/",
26
+ "JFIF": "http://ns.exiftool.org/JFIF/JFIF/1.0/",
27
+ "MPF0": "http://ns.exiftool.org/MPF/MPF0/1.0/",
28
+ "MPImage1": "http://ns.exiftool.org/MPF/MPImage1/1.0/",
29
+ "MPImage2": "http://ns.exiftool.org/MPF/MPImage2/1.0/",
30
+ "Photoshop": "http://ns.exiftool.org/Photoshop/Photoshop/1.0/",
31
+ "Samsung": "http://ns.exiftool.org/MakerNotes/Samsung/1.0/",
32
+ "System": "http://ns.exiftool.org/File/System/1.0/",
33
+ "XMP-GAudio": "http://ns.exiftool.org/XMP/XMP-GAudio/1.0/",
34
+ "XMP-GImage": "http://ns.exiftool.org/XMP/XMP-GImage/1.0/",
35
+ "XMP-GPano": "http://ns.exiftool.org/XMP/XMP-GPano/1.0/",
36
+ "XMP-aux": "http://ns.exiftool.org/XMP/XMP-aux/1.0/",
37
+ "XMP-crs": "http://ns.exiftool.org/XMP/XMP-crs/1.0/",
38
+ "XMP-dc": "http://ns.exiftool.org/XMP/XMP-dc/1.0/",
39
+ "XMP-exif": "http://ns.exiftool.org/XMP/XMP-exif/1.0/",
40
+ "XMP-exifEX": "http://ns.exiftool.org/XMP/XMP-exifEX/1.0/",
41
+ "XMP-photoshop": "http://ns.exiftool.org/XMP/XMP-photoshop/1.0/",
42
+ "XMP-tiff": "http://ns.exiftool.org/XMP/XMP-tiff/1.0/",
43
+ "XMP-x": "http://ns.exiftool.org/XMP/XMP-x/1.0/",
44
+ "XMP-xmp": "http://ns.exiftool.org/XMP/XMP-xmp/1.0/",
45
+ "XMP-xmpMM": "http://ns.exiftool.org/XMP/XMP-xmpMM/1.0/",
46
+ "XMP-xmpNote": "http://ns.exiftool.org/XMP/XMP-xmpNote/1.0/",
47
+ "rdf": "http://www.w3.org/1999/02/22-rdf-syntax-ns#",
48
+ }
49
+
50
+
51
+ _FIELD_TYPE = T.TypeVar("_FIELD_TYPE", int, float, str)
52
+
53
+
54
+ class ExifToolRead(exif_read.ExifReadABC):
55
+ """
56
+ Read exif from ExifTool XML output
57
+ """
58
+
59
+ def __init__(
60
+ self,
61
+ etree: ElementTree,
62
+ ) -> None:
63
+ self.etree = etree
64
+
65
+ def extract_altitude(self) -> T.Optional[float]:
66
+ """
67
+ Extract altitude
68
+ """
69
+ altitude = self._extract_alternative_fields(["GPS:GPSAltitude"], float)
70
+ if altitude is None:
71
+ return None
72
+ # 0 = Above sea level
73
+ # 1 = Below sea level
74
+ ref = self._extract_alternative_fields(["GPS:GPSAltitudeRef"], int)
75
+ if ref == 1:
76
+ altitude = -1 * altitude
77
+ return altitude
78
+
79
+ def _extract_gps_datetime(
80
+ self, date_tags: T.Sequence[str], time_tags: T.Sequence[str]
81
+ ) -> T.Optional[datetime.datetime]:
82
+ """
83
+ Extract timestamp from GPS field.
84
+ """
85
+ gpsdate = self._extract_alternative_fields(date_tags, str)
86
+ if gpsdate is None:
87
+ return None
88
+
89
+ gpstimestamp = self._extract_alternative_fields(time_tags, str)
90
+ if not gpstimestamp:
91
+ return None
92
+
93
+ return exif_read.parse_gps_datetime_separately(gpsdate, gpstimestamp)
94
+
95
+ def extract_gps_datetime(self) -> T.Optional[datetime.datetime]:
96
+ """
97
+ Extract timestamp from GPS field.
98
+ """
99
+ return self._extract_gps_datetime(["GPS:GPSDateStamp"], ["GPS:GPSTimeStamp"])
100
+
101
+ def extract_gps_datetime_from_xmp(self) -> T.Optional[datetime.datetime]:
102
+ """
103
+ Extract timestamp from XMP GPS field.
104
+ """
105
+ # example: <XMP-exif:GPSDateStamp>2021:09:14</XMP-exif:GPSDateStamp>
106
+ # example: <XMP-exif:GPSDateTime>08:23:56.000000</XMP-exif:GPSDateTime>
107
+ return self._extract_gps_datetime(
108
+ ["XMP-exif:GPSDateStamp"],
109
+ # Put both here but I do not see any XMP-exif:GPSTimeStamp in my samples
110
+ ["XMP-exif:GPSDateTime", "XMP-exif:GPSTimeStamp"],
111
+ )
112
+
113
+ def _extract_exif_datetime(
114
+ self,
115
+ dt_tags: T.Sequence[str],
116
+ subsec_tags: T.Sequence[str],
117
+ offset_tags: T.Sequence[str],
118
+ ) -> T.Optional[datetime.datetime]:
119
+ dtstr = self._extract_alternative_fields(dt_tags, str)
120
+ if dtstr is None:
121
+ return None
122
+ subsec = self._extract_alternative_fields(subsec_tags, str)
123
+ # See https://github.com/mapillary/mapillary_tools/issues/388#issuecomment-860198046
124
+ # and https://community.gopro.com/t5/Cameras/subsecond-timestamp-bug/m-p/1057505
125
+ if subsec:
126
+ subsec = subsec.replace(" ", "0")
127
+ offset = self._extract_alternative_fields(offset_tags, str)
128
+ dt = exif_read.parse_datetimestr_with_subsec_and_offset(dtstr, subsec, offset)
129
+ if dt is None:
130
+ return None
131
+ return dt
132
+
133
+ def extract_exif_datetime_from_xmp(self) -> T.Optional[datetime.datetime]:
134
+ # EXIF DateTimeOriginal: 0x9003 (date/time when original image was taken)
135
+ # EXIF SubSecTimeOriginal: 0x9291 (fractional seconds for DateTimeOriginal)
136
+ # EXIF OffsetTimeOriginal: 0x9011 (time zone for DateTimeOriginal)
137
+ dt = self._extract_exif_datetime(
138
+ ["XMP-exif:DateTimeOriginal"],
139
+ # NOTE: it is Subsec instead of SubSec
140
+ ["XMP-exif:SubsecTimeOriginal"],
141
+ ["XMP-exif:OffsetTimeOriginal"],
142
+ )
143
+ if dt is not None:
144
+ return dt
145
+
146
+ # EXIF DateTimeDigitized: 0x9004 CreateDate in ExifTool (called DateTimeDigitized by the EXIF spec.)
147
+ # EXIF SubSecTimeDigitized: 0x9292 (fractional seconds for CreateDate)
148
+ # EXIF OffsetTimeDigitized: 0x9012 (time zone for CreateDate)
149
+ dt = self._extract_exif_datetime(
150
+ ["XMP-exif:DateTimeDigitized", "XMP-xmp:CreateDate"],
151
+ # NOTE: it is Subsec instead of SubSec
152
+ ["XMP-exif:SubsecTimeDigitized"],
153
+ ["XMP-exif:OffsetTimeDigitized"],
154
+ )
155
+ if dt is not None:
156
+ return dt
157
+
158
+ # Image DateTime: 0x0132 ModifyDate in ExifTool (called DateTime by the EXIF spec.)
159
+ # EXIF SubSecTime: 0x9290 (fractional seconds for ModifyDate)
160
+ # EXIF OffsetTime: 0x9010 (time zone for ModifyDate)
161
+ dt = self._extract_exif_datetime(
162
+ ["XMP-exif:ModifyDate"],
163
+ # NOTE: this tag might not exist in XMP
164
+ ["XMP-exif:SubsecTime"],
165
+ ["XMP-exif:OffsetTime"],
166
+ )
167
+ if dt is not None:
168
+ return dt
169
+
170
+ return None
171
+
172
+ def extract_exif_datetime(self) -> T.Optional[datetime.datetime]:
173
+ # EXIF DateTimeOriginal: 0x9003 (date/time when original image was taken)
174
+ # EXIF SubSecTimeOriginal: 0x9291 (fractional seconds for DateTimeOriginal)
175
+ # EXIF OffsetTimeOriginal: 0x9011 (time zone for DateTimeOriginal)
176
+ dt = self._extract_exif_datetime(
177
+ ["ExifIFD:DateTimeOriginal"],
178
+ ["ExifIFD:SubSecTimeOriginal"],
179
+ ["ExifIFD:OffsetTimeOriginal"],
180
+ )
181
+ if dt is not None:
182
+ return dt
183
+
184
+ # EXIF DateTimeDigitized: 0x9004 CreateDate in ExifTool (called DateTimeDigitized by the EXIF spec.)
185
+ # EXIF SubSecTimeDigitized: 0x9292 (fractional seconds for CreateDate)
186
+ # EXIF OffsetTimeDigitized: 0x9012 (time zone for CreateDate)
187
+ dt = self._extract_exif_datetime(
188
+ ["ExifIFD:CreateDate"],
189
+ ["ExifIFD:SubSecTimeDigitized"],
190
+ ["ExifIFD:OffsetTimeDigitized"],
191
+ )
192
+ if dt is not None:
193
+ return dt
194
+
195
+ # Image DateTime: 0x0132 ModifyDate in ExifTool (called DateTime by the EXIF spec.)
196
+ # EXIF SubSecTime: 0x9290 (fractional seconds for ModifyDate)
197
+ # EXIF OffsetTime: 0x9010 (time zone for ModifyDate)
198
+ dt = self._extract_exif_datetime(
199
+ ["ExifIFD:ModifyDate", "IFD0:ModifyDate", "IFD1:ModifyDate"],
200
+ ["ExifIFD:SubSecTime"],
201
+ ["ExifIFD:OffsetTime"],
202
+ )
203
+ if dt is not None:
204
+ return dt
205
+
206
+ return None
207
+
208
+ def extract_capture_time(self) -> T.Optional[datetime.datetime]:
209
+ """
210
+ Extract capture time from EXIF DateTime tags
211
+ """
212
+ # Prefer GPS datetime over EXIF timestamp
213
+ # NOTE: GPS datetime precision is usually 1 second, but this case is handled by the subsecond interpolation
214
+ try:
215
+ dt = self.extract_gps_datetime()
216
+ except (ValueError, TypeError, ZeroDivisionError):
217
+ dt = None
218
+ if dt is not None:
219
+ return dt
220
+
221
+ try:
222
+ dt = self.extract_gps_datetime_from_xmp()
223
+ except (ValueError, TypeError, ZeroDivisionError):
224
+ dt = None
225
+ if dt is not None:
226
+ return dt
227
+
228
+ dt = self.extract_exif_datetime()
229
+ if dt is not None:
230
+ return dt
231
+
232
+ dt = self.extract_exif_datetime_from_xmp()
233
+ if dt is not None:
234
+ return dt
235
+
236
+ return None
237
+
238
+ def extract_direction(self) -> T.Optional[float]:
239
+ """
240
+ Extract image direction (i.e. compass, heading, bearing)
241
+ """
242
+ # https://www.awaresystems.be/imaging/tiff/tifftags/privateifd/gps/gpsimgdirectionref.html
243
+ return self._extract_alternative_fields(
244
+ [
245
+ "GPS:GPSImgDirection",
246
+ "GPS:GPSTrack",
247
+ ],
248
+ float,
249
+ )
250
+
251
+ def extract_lon_lat(self) -> T.Optional[T.Tuple[float, float]]:
252
+ lon_lat = self._extract_lon_lat("GPS:GPSLongitude", "GPS:GPSLatitude")
253
+ if lon_lat is not None:
254
+ return lon_lat
255
+
256
+ lon_lat = self._extract_lon_lat(
257
+ "Composite:GPSLongitude", "Composite:GPSLatitude"
258
+ )
259
+ if lon_lat is not None:
260
+ return lon_lat
261
+
262
+ return None
263
+
264
+ def _extract_lon_lat(
265
+ self, lon_tag: str, lat_tag: str
266
+ ) -> T.Optional[T.Tuple[float, float]]:
267
+ lon = self._extract_alternative_fields(
268
+ [lon_tag],
269
+ float,
270
+ )
271
+ if lon is None:
272
+ return None
273
+ ref = self._extract_alternative_fields([lon_tag + "Ref"], str)
274
+ if ref and ref.upper() in ["WEST", "W"]:
275
+ lon = -1 * lon
276
+
277
+ lat = self._extract_alternative_fields(
278
+ [lat_tag],
279
+ float,
280
+ )
281
+ if lat is None:
282
+ return None
283
+ ref = self._extract_alternative_fields([lat_tag + "Ref"], str)
284
+ if ref and ref.upper() in ["SOUTH", "S"]:
285
+ lat = -1 * lat
286
+
287
+ return lon, lat
288
+
289
+ def extract_make(self) -> T.Optional[str]:
290
+ """
291
+ Extract camera make
292
+ """
293
+ make = self._extract_alternative_fields(
294
+ [
295
+ "IFD0:Make",
296
+ "IFD1:Make",
297
+ "ExifIFD:Make",
298
+ "ExifIFD:LensMake",
299
+ "XMP-exif:Make",
300
+ "XMP-exifEX:LensMake",
301
+ ],
302
+ str,
303
+ )
304
+ if make is None:
305
+ return None
306
+ return make.strip()
307
+
308
+ def extract_model(self) -> T.Optional[str]:
309
+ """
310
+ Extract camera model
311
+ """
312
+ model = self._extract_alternative_fields(
313
+ [
314
+ "IFD0:Model",
315
+ "IFD1:Model",
316
+ "ExifIFD:Model",
317
+ "GoPro:Model",
318
+ "ExifIFD:LensModel",
319
+ "XMP-exif:Model",
320
+ "XMP-exifEX:LensModel",
321
+ ],
322
+ str,
323
+ )
324
+ if model is None:
325
+ return None
326
+ return model.strip()
327
+
328
+ def extract_width(self) -> T.Optional[int]:
329
+ """
330
+ Extract image width in pixels
331
+ """
332
+ return self._extract_alternative_fields(
333
+ [
334
+ "File:ImageWidth",
335
+ "ExifIFD:ExifImageWidth",
336
+ "IFD0:ExifImageWidth",
337
+ "IFD1:ExifImageWidth",
338
+ "XMP-exif:ExifImageWidth",
339
+ ],
340
+ int,
341
+ )
342
+
343
+ def extract_height(self) -> T.Optional[int]:
344
+ """
345
+ Extract image height in pixels
346
+ """
347
+ return self._extract_alternative_fields(
348
+ [
349
+ "File:ImageHeight",
350
+ "ExifIFD:ExifImageHeight",
351
+ "IFD0:ExifImageHeight",
352
+ "IFD1:ExifImageHeight",
353
+ "XMP-exif:ExifImageHeight",
354
+ ],
355
+ int,
356
+ )
357
+
358
+ def extract_orientation(self) -> int:
359
+ """
360
+ Extract image orientation
361
+ """
362
+ orientation = self._extract_alternative_fields(
363
+ [
364
+ "ExifIFD:Orientation",
365
+ "IFD0:Orientation",
366
+ "IFD1:Orientation",
367
+ "XMP-exif:Orientation",
368
+ "XMP-tiff:Orientation",
369
+ ],
370
+ int,
371
+ )
372
+ if orientation is None:
373
+ return 1
374
+ if orientation not in range(1, 9):
375
+ return 1
376
+ return orientation
377
+
378
+ def _extract_alternative_fields(
379
+ self,
380
+ fields: T.Sequence[str],
381
+ field_type: T.Type[_FIELD_TYPE],
382
+ ) -> T.Optional[_FIELD_TYPE]:
383
+ for field in fields:
384
+ value = self.etree.findtext(field, namespaces=EXIFTOOL_NAMESPACES)
385
+ if value is None:
386
+ continue
387
+ if value is None:
388
+ continue
389
+ if field_type is int:
390
+ try:
391
+ return T.cast(_FIELD_TYPE, int(value))
392
+ except (ValueError, TypeError):
393
+ pass
394
+ elif field_type is float:
395
+ try:
396
+ return T.cast(_FIELD_TYPE, float(value))
397
+ except (ValueError, TypeError):
398
+ pass
399
+ elif field_type is str:
400
+ try:
401
+ return T.cast(_FIELD_TYPE, str(value))
402
+ except (ValueError, TypeError):
403
+ pass
404
+ else:
405
+ raise ValueError(f"Invalid field type {field_type}")
406
+ return None