mapillary-tools 0.13.3a1__py3-none-any.whl → 0.14.0a1__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 +235 -14
- 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 +425 -177
- mapillary_tools/commands/__main__.py +11 -4
- 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 +28 -12
- mapillary_tools/constants.py +46 -4
- mapillary_tools/exceptions.py +34 -35
- mapillary_tools/exif_read.py +158 -53
- mapillary_tools/exiftool_read.py +19 -5
- mapillary_tools/exiftool_read_video.py +12 -1
- mapillary_tools/exiftool_runner.py +77 -0
- mapillary_tools/geo.py +148 -107
- mapillary_tools/geotag/factory.py +298 -0
- mapillary_tools/geotag/geotag_from_generic.py +152 -11
- mapillary_tools/geotag/geotag_images_from_exif.py +43 -124
- mapillary_tools/geotag/geotag_images_from_exiftool.py +66 -70
- mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +32 -48
- mapillary_tools/geotag/geotag_images_from_gpx.py +41 -116
- mapillary_tools/geotag/geotag_images_from_gpx_file.py +15 -96
- mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -2
- mapillary_tools/geotag/geotag_images_from_video.py +46 -46
- mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +98 -92
- mapillary_tools/geotag/geotag_videos_from_gpx.py +140 -0
- mapillary_tools/geotag/geotag_videos_from_video.py +149 -181
- mapillary_tools/geotag/options.py +159 -0
- mapillary_tools/{geotag → gpmf}/gpmf_parser.py +194 -171
- mapillary_tools/history.py +3 -11
- mapillary_tools/mp4/io_utils.py +0 -1
- mapillary_tools/mp4/mp4_sample_parser.py +11 -3
- mapillary_tools/mp4/simple_mp4_parser.py +0 -10
- mapillary_tools/process_geotag_properties.py +151 -386
- mapillary_tools/process_sequence_properties.py +554 -202
- mapillary_tools/sample_video.py +8 -15
- mapillary_tools/telemetry.py +24 -12
- mapillary_tools/types.py +80 -22
- mapillary_tools/upload.py +316 -298
- mapillary_tools/upload_api_v4.py +55 -122
- mapillary_tools/uploader.py +396 -254
- mapillary_tools/utils.py +26 -0
- mapillary_tools/video_data_extraction/extract_video_data.py +17 -36
- mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +34 -19
- mapillary_tools/video_data_extraction/extractors/camm_parser.py +41 -17
- mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +4 -1
- mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +1 -2
- mapillary_tools/video_data_extraction/extractors/gopro_parser.py +37 -22
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/METADATA +3 -2
- mapillary_tools-0.14.0a1.dist-info/RECORD +78 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/WHEEL +1 -1
- mapillary_tools/geotag/utils.py +0 -26
- mapillary_tools-0.13.3a1.dist-info/RECORD +0 -75
- /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
- /mapillary_tools/{geotag → gpmf}/gps_filter.py +0 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info/licenses}/LICENSE +0 -0
- {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/top_level.txt +0 -0
mapillary_tools/exif_read.py
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import abc
|
|
2
4
|
import datetime
|
|
5
|
+
import io
|
|
3
6
|
import logging
|
|
4
7
|
import re
|
|
8
|
+
import struct
|
|
5
9
|
import typing as T
|
|
6
10
|
import xml.etree.ElementTree as et
|
|
7
11
|
from fractions import Fraction
|
|
@@ -11,6 +15,7 @@ import exifread
|
|
|
11
15
|
from exifread.utils import Ratio
|
|
12
16
|
|
|
13
17
|
|
|
18
|
+
LOG = logging.getLogger(__name__)
|
|
14
19
|
XMP_NAMESPACES = {
|
|
15
20
|
"exif": "http://ns.adobe.com/exif/1.0/",
|
|
16
21
|
"tiff": "http://ns.adobe.com/tiff/1.0/",
|
|
@@ -474,9 +479,9 @@ class ExifReadFromXMP(ExifReadABC):
|
|
|
474
479
|
|
|
475
480
|
def _extract_alternative_fields(
|
|
476
481
|
self,
|
|
477
|
-
fields: T.
|
|
478
|
-
field_type:
|
|
479
|
-
) ->
|
|
482
|
+
fields: T.Iterable[str],
|
|
483
|
+
field_type: type[_FIELD_TYPE],
|
|
484
|
+
) -> _FIELD_TYPE | None:
|
|
480
485
|
"""
|
|
481
486
|
Extract a value for a list of ordered fields.
|
|
482
487
|
Return the value of the first existed field in the list
|
|
@@ -508,6 +513,86 @@ class ExifReadFromXMP(ExifReadABC):
|
|
|
508
513
|
return None
|
|
509
514
|
|
|
510
515
|
|
|
516
|
+
def extract_xmp_efficiently(fp) -> T.Optional[str]:
|
|
517
|
+
"""
|
|
518
|
+
Extract XMP metadata from a JPEG file efficiently by reading only necessary chunks.
|
|
519
|
+
|
|
520
|
+
Args:
|
|
521
|
+
image_path (str): Path to the JPEG image file
|
|
522
|
+
|
|
523
|
+
Returns:
|
|
524
|
+
str: Formatted XML string containing XMP metadata, or None if no XMP data found
|
|
525
|
+
"""
|
|
526
|
+
# JPEG markers
|
|
527
|
+
SOI_MARKER = b"\xff\xd8" # Start of Image
|
|
528
|
+
APP1_MARKER = b"\xff\xe1" # Application Segment 1 (where XMP usually lives)
|
|
529
|
+
XMP_IDENTIFIER = b"http://ns.adobe.com/xap/1.0/\x00"
|
|
530
|
+
XMP_META_TAG_BEGIN = b"<x:xmpmeta"
|
|
531
|
+
XMP_META_TAG_END = b"</x:xmpmeta>"
|
|
532
|
+
|
|
533
|
+
# Check for JPEG signature (SOI marker)
|
|
534
|
+
if fp.read(2) != SOI_MARKER:
|
|
535
|
+
return None
|
|
536
|
+
|
|
537
|
+
while True:
|
|
538
|
+
# Read marker
|
|
539
|
+
marker_bytes = fp.read(2)
|
|
540
|
+
if len(marker_bytes) < 2:
|
|
541
|
+
# End of file
|
|
542
|
+
break
|
|
543
|
+
|
|
544
|
+
# If not APP1, skip this segment
|
|
545
|
+
if marker_bytes != APP1_MARKER:
|
|
546
|
+
# Read length field (includes the length bytes themselves)
|
|
547
|
+
length_bytes = fp.read(2)
|
|
548
|
+
if len(length_bytes) < 2:
|
|
549
|
+
break
|
|
550
|
+
|
|
551
|
+
length = struct.unpack(">H", length_bytes)[0]
|
|
552
|
+
# Skip the rest of this segment (-2 because length includes length bytes)
|
|
553
|
+
fp.seek(length - 2, io.SEEK_CUR)
|
|
554
|
+
continue
|
|
555
|
+
|
|
556
|
+
# It's an APP1 segment - read length
|
|
557
|
+
length_bytes = fp.read(2)
|
|
558
|
+
if len(length_bytes) < 2:
|
|
559
|
+
break
|
|
560
|
+
|
|
561
|
+
length = struct.unpack(">H", length_bytes)[0]
|
|
562
|
+
segment_data_length = length - 2 # Subtract length field size
|
|
563
|
+
|
|
564
|
+
# Read enough bytes to check for XMP identifier
|
|
565
|
+
identifier_check = fp.read(len(XMP_IDENTIFIER))
|
|
566
|
+
if len(identifier_check) < len(XMP_IDENTIFIER):
|
|
567
|
+
break
|
|
568
|
+
|
|
569
|
+
# Check if this APP1 contains XMP data
|
|
570
|
+
if identifier_check == XMP_IDENTIFIER:
|
|
571
|
+
# We found XMP data - read the rest of the segment
|
|
572
|
+
remaining_length = segment_data_length - len(XMP_IDENTIFIER)
|
|
573
|
+
if remaining_length > 128 * 1024 * 1024:
|
|
574
|
+
raise ValueError("XMP data too large")
|
|
575
|
+
xmp_data = fp.read(remaining_length)
|
|
576
|
+
|
|
577
|
+
# Process the XMP data
|
|
578
|
+
begin_idx = xmp_data.find(XMP_META_TAG_BEGIN)
|
|
579
|
+
if begin_idx >= 0:
|
|
580
|
+
end_idx = xmp_data.rfind(XMP_META_TAG_END, begin_idx)
|
|
581
|
+
if end_idx >= 0:
|
|
582
|
+
xmp_data = xmp_data[begin_idx : end_idx + len(XMP_META_TAG_END)]
|
|
583
|
+
else:
|
|
584
|
+
xmp_data = xmp_data[begin_idx:]
|
|
585
|
+
|
|
586
|
+
return xmp_data.decode("utf-8")
|
|
587
|
+
else:
|
|
588
|
+
# Not XMP data - skip the rest of this APP1 segment
|
|
589
|
+
# We already read the identifier_check bytes, so subtract that
|
|
590
|
+
fp.seek(segment_data_length - len(identifier_check), io.SEEK_CUR)
|
|
591
|
+
|
|
592
|
+
# If we reach here, no XMP data was found
|
|
593
|
+
return None
|
|
594
|
+
|
|
595
|
+
|
|
511
596
|
class ExifReadFromEXIF(ExifReadABC):
|
|
512
597
|
"""
|
|
513
598
|
EXIF class for reading exif from an image
|
|
@@ -520,16 +605,20 @@ class ExifReadFromEXIF(ExifReadABC):
|
|
|
520
605
|
if isinstance(path_or_stream, Path):
|
|
521
606
|
with path_or_stream.open("rb") as fp:
|
|
522
607
|
try:
|
|
523
|
-
|
|
524
|
-
|
|
608
|
+
# Turn off details and debug for performance reasons
|
|
609
|
+
self.tags = exifread.process_file(fp, details=False, debug=False)
|
|
610
|
+
except Exception as ex:
|
|
611
|
+
LOG.warning("Error reading EXIF from %s: %s", path_or_stream, ex)
|
|
525
612
|
self.tags = {}
|
|
526
613
|
|
|
527
614
|
else:
|
|
528
615
|
try:
|
|
616
|
+
# Turn off details and debug for performance reasons
|
|
529
617
|
self.tags = exifread.process_file(
|
|
530
|
-
path_or_stream, details=
|
|
618
|
+
path_or_stream, details=False, debug=False
|
|
531
619
|
)
|
|
532
|
-
except Exception:
|
|
620
|
+
except Exception as ex:
|
|
621
|
+
LOG.warning("Error reading EXIF: %s", ex)
|
|
533
622
|
self.tags = {}
|
|
534
623
|
|
|
535
624
|
def extract_altitude(self) -> T.Optional[float]:
|
|
@@ -649,17 +738,7 @@ class ExifReadFromEXIF(ExifReadABC):
|
|
|
649
738
|
"GPS GPSImgDirection",
|
|
650
739
|
"GPS GPSTrack",
|
|
651
740
|
]
|
|
652
|
-
|
|
653
|
-
if direction is not None:
|
|
654
|
-
if direction > 360:
|
|
655
|
-
# fix negative value wrongly parsed in exifread
|
|
656
|
-
# -360 degree -> 4294966935 when converting from hex
|
|
657
|
-
bearing1 = bin(int(direction))[2:]
|
|
658
|
-
bearing2 = "".join([str(int(int(a) == 0)) for a in bearing1])
|
|
659
|
-
direction = -float(int(bearing2, 2))
|
|
660
|
-
direction %= 360
|
|
661
|
-
|
|
662
|
-
return direction
|
|
741
|
+
return self._extract_alternative_fields(fields, float)
|
|
663
742
|
|
|
664
743
|
def extract_lon_lat(self) -> T.Optional[T.Tuple[float, float]]:
|
|
665
744
|
lat_tag = self.tags.get("GPS GPSLatitude")
|
|
@@ -687,7 +766,9 @@ class ExifReadFromEXIF(ExifReadABC):
|
|
|
687
766
|
"""
|
|
688
767
|
Extract camera make
|
|
689
768
|
"""
|
|
690
|
-
make = self._extract_alternative_fields(
|
|
769
|
+
make = self._extract_alternative_fields(
|
|
770
|
+
["Image Make", "EXIF Make", "EXIF LensMake"], str
|
|
771
|
+
)
|
|
691
772
|
if make is None:
|
|
692
773
|
return None
|
|
693
774
|
return make.strip()
|
|
@@ -696,7 +777,9 @@ class ExifReadFromEXIF(ExifReadABC):
|
|
|
696
777
|
"""
|
|
697
778
|
Extract camera model
|
|
698
779
|
"""
|
|
699
|
-
model = self._extract_alternative_fields(
|
|
780
|
+
model = self._extract_alternative_fields(
|
|
781
|
+
["Image Model", "EXIF Model", "EXIF LensModel"], str
|
|
782
|
+
)
|
|
700
783
|
if model is None:
|
|
701
784
|
return None
|
|
702
785
|
return model.strip()
|
|
@@ -731,8 +814,8 @@ class ExifReadFromEXIF(ExifReadABC):
|
|
|
731
814
|
def _extract_alternative_fields(
|
|
732
815
|
self,
|
|
733
816
|
fields: T.Sequence[str],
|
|
734
|
-
field_type:
|
|
735
|
-
) ->
|
|
817
|
+
field_type: type[_FIELD_TYPE],
|
|
818
|
+
) -> _FIELD_TYPE | None:
|
|
736
819
|
"""
|
|
737
820
|
Extract a value for a list of ordered fields.
|
|
738
821
|
Return the value of the first existed field in the list
|
|
@@ -775,27 +858,54 @@ class ExifReadFromEXIF(ExifReadABC):
|
|
|
775
858
|
|
|
776
859
|
|
|
777
860
|
class ExifRead(ExifReadFromEXIF):
|
|
861
|
+
"""
|
|
862
|
+
Extract properties from EXIF first and then XMP
|
|
863
|
+
NOTE: For performance reasons, XMP is only extracted if EXIF does not contain the required fields
|
|
864
|
+
"""
|
|
865
|
+
|
|
778
866
|
def __init__(self, path_or_stream: T.Union[Path, T.BinaryIO]) -> None:
|
|
779
867
|
super().__init__(path_or_stream)
|
|
780
|
-
self.
|
|
868
|
+
self._path_or_stream = path_or_stream
|
|
869
|
+
self._xml_extracted: bool = False
|
|
870
|
+
self._cached_xml: T.Optional[ExifReadFromXMP] = None
|
|
871
|
+
|
|
872
|
+
def _xmp_with_reason(self, reason: str) -> T.Optional[ExifReadFromXMP]:
|
|
873
|
+
if not self._xml_extracted:
|
|
874
|
+
LOG.debug('Extracting XMP for "%s"', reason)
|
|
875
|
+
self._cached_xml = self._extract_xmp()
|
|
876
|
+
self._xml_extracted = True
|
|
877
|
+
|
|
878
|
+
return self._cached_xml
|
|
781
879
|
|
|
782
880
|
def _extract_xmp(self) -> T.Optional[ExifReadFromXMP]:
|
|
783
|
-
|
|
784
|
-
if
|
|
785
|
-
|
|
881
|
+
xml_str = self.extract_application_notes()
|
|
882
|
+
if xml_str is None:
|
|
883
|
+
if isinstance(self._path_or_stream, Path):
|
|
884
|
+
with self._path_or_stream.open("rb") as fp:
|
|
885
|
+
xml_str = extract_xmp_efficiently(fp)
|
|
886
|
+
else:
|
|
887
|
+
self._path_or_stream.seek(0, io.SEEK_SET)
|
|
888
|
+
xml_str = extract_xmp_efficiently(self._path_or_stream)
|
|
889
|
+
|
|
890
|
+
if xml_str is None:
|
|
891
|
+
return None
|
|
892
|
+
|
|
786
893
|
try:
|
|
787
|
-
e = et.fromstring(
|
|
788
|
-
except et.ParseError:
|
|
894
|
+
e = et.fromstring(xml_str)
|
|
895
|
+
except et.ParseError as ex:
|
|
896
|
+
LOG.warning("Error parsing XMP XML: %s: %s", ex, xml_str)
|
|
789
897
|
return None
|
|
898
|
+
|
|
790
899
|
return ExifReadFromXMP(et.ElementTree(e))
|
|
791
900
|
|
|
792
901
|
def extract_altitude(self) -> T.Optional[float]:
|
|
793
902
|
val = super().extract_altitude()
|
|
794
903
|
if val is not None:
|
|
795
904
|
return val
|
|
796
|
-
|
|
905
|
+
xmp = self._xmp_with_reason("altitude")
|
|
906
|
+
if xmp is None:
|
|
797
907
|
return None
|
|
798
|
-
val =
|
|
908
|
+
val = xmp.extract_altitude()
|
|
799
909
|
if val is not None:
|
|
800
910
|
return val
|
|
801
911
|
return None
|
|
@@ -804,20 +914,10 @@ class ExifRead(ExifReadFromEXIF):
|
|
|
804
914
|
val = super().extract_capture_time()
|
|
805
915
|
if val is not None:
|
|
806
916
|
return val
|
|
807
|
-
|
|
808
|
-
|
|
809
|
-
val = self._xmp.extract_capture_time()
|
|
810
|
-
if val is not None:
|
|
811
|
-
return val
|
|
812
|
-
return None
|
|
813
|
-
|
|
814
|
-
def extract_direction(self) -> T.Optional[float]:
|
|
815
|
-
val = super().extract_direction()
|
|
816
|
-
if val is not None:
|
|
817
|
-
return val
|
|
818
|
-
if self._xmp is None:
|
|
917
|
+
xmp = self._xmp_with_reason("capture_time")
|
|
918
|
+
if xmp is None:
|
|
819
919
|
return None
|
|
820
|
-
val =
|
|
920
|
+
val = xmp.extract_capture_time()
|
|
821
921
|
if val is not None:
|
|
822
922
|
return val
|
|
823
923
|
return None
|
|
@@ -826,9 +926,10 @@ class ExifRead(ExifReadFromEXIF):
|
|
|
826
926
|
val = super().extract_lon_lat()
|
|
827
927
|
if val is not None:
|
|
828
928
|
return val
|
|
829
|
-
|
|
929
|
+
xmp = self._xmp_with_reason("lon_lat")
|
|
930
|
+
if xmp is None:
|
|
830
931
|
return None
|
|
831
|
-
val =
|
|
932
|
+
val = xmp.extract_lon_lat()
|
|
832
933
|
if val is not None:
|
|
833
934
|
return val
|
|
834
935
|
return None
|
|
@@ -837,9 +938,10 @@ class ExifRead(ExifReadFromEXIF):
|
|
|
837
938
|
val = super().extract_make()
|
|
838
939
|
if val is not None:
|
|
839
940
|
return val
|
|
840
|
-
|
|
941
|
+
xmp = self._xmp_with_reason("make")
|
|
942
|
+
if xmp is None:
|
|
841
943
|
return None
|
|
842
|
-
val =
|
|
944
|
+
val = xmp.extract_make()
|
|
843
945
|
if val is not None:
|
|
844
946
|
return val
|
|
845
947
|
return None
|
|
@@ -848,9 +950,10 @@ class ExifRead(ExifReadFromEXIF):
|
|
|
848
950
|
val = super().extract_model()
|
|
849
951
|
if val is not None:
|
|
850
952
|
return val
|
|
851
|
-
|
|
953
|
+
xmp = self._xmp_with_reason("model")
|
|
954
|
+
if xmp is None:
|
|
852
955
|
return None
|
|
853
|
-
val =
|
|
956
|
+
val = xmp.extract_model()
|
|
854
957
|
if val is not None:
|
|
855
958
|
return val
|
|
856
959
|
return None
|
|
@@ -859,9 +962,10 @@ class ExifRead(ExifReadFromEXIF):
|
|
|
859
962
|
val = super().extract_width()
|
|
860
963
|
if val is not None:
|
|
861
964
|
return val
|
|
862
|
-
|
|
965
|
+
xmp = self._xmp_with_reason("width")
|
|
966
|
+
if xmp is None:
|
|
863
967
|
return None
|
|
864
|
-
val =
|
|
968
|
+
val = xmp.extract_width()
|
|
865
969
|
if val is not None:
|
|
866
970
|
return val
|
|
867
971
|
return None
|
|
@@ -870,9 +974,10 @@ class ExifRead(ExifReadFromEXIF):
|
|
|
870
974
|
val = super().extract_height()
|
|
871
975
|
if val is not None:
|
|
872
976
|
return val
|
|
873
|
-
|
|
977
|
+
xmp = self._xmp_with_reason("width")
|
|
978
|
+
if xmp is None:
|
|
874
979
|
return None
|
|
875
|
-
val =
|
|
980
|
+
val = xmp.extract_height()
|
|
876
981
|
if val is not None:
|
|
877
982
|
return val
|
|
878
983
|
return None
|
mapillary_tools/exiftool_read.py
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
1
3
|
import datetime
|
|
2
4
|
import logging
|
|
3
5
|
import typing as T
|
|
@@ -93,11 +95,23 @@ def index_rdf_description_by_path(
|
|
|
93
95
|
LOG.warning(f"Failed to parse {xml_path}: {ex}", exc_info=verbose)
|
|
94
96
|
continue
|
|
95
97
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
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
|
|
101
115
|
|
|
102
116
|
return rdf_description_by_path
|
|
103
117
|
|
|
@@ -86,6 +86,7 @@ def _aggregate_gps_track(
|
|
|
86
86
|
lon_tag: str,
|
|
87
87
|
lat_tag: str,
|
|
88
88
|
alt_tag: T.Optional[str] = None,
|
|
89
|
+
gps_time_tag: T.Optional[str] = None,
|
|
89
90
|
direction_tag: T.Optional[str] = None,
|
|
90
91
|
ground_speed_tag: T.Optional[str] = None,
|
|
91
92
|
) -> T.List[GPSPoint]:
|
|
@@ -161,6 +162,15 @@ def _aggregate_gps_track(
|
|
|
161
162
|
# aggregate speeds (optional)
|
|
162
163
|
ground_speeds = _aggregate_float_values_same_length(ground_speed_tag)
|
|
163
164
|
|
|
165
|
+
# GPS timestamp (optional)
|
|
166
|
+
epoch_time = None
|
|
167
|
+
if gps_time_tag is not None:
|
|
168
|
+
gps_time_text = _extract_alternative_fields(texts_by_tag, [gps_time_tag], str)
|
|
169
|
+
if gps_time_text is not None:
|
|
170
|
+
dt = exif_read.parse_gps_datetime(gps_time_text)
|
|
171
|
+
if dt is not None:
|
|
172
|
+
epoch_time = geo.as_unix_time(dt)
|
|
173
|
+
|
|
164
174
|
# build track
|
|
165
175
|
track = []
|
|
166
176
|
for timestamp, lon, lat, alt, direction, ground_speed in zip(
|
|
@@ -180,7 +190,7 @@ def _aggregate_gps_track(
|
|
|
180
190
|
lat=lat,
|
|
181
191
|
alt=alt,
|
|
182
192
|
angle=direction,
|
|
183
|
-
epoch_time=
|
|
193
|
+
epoch_time=epoch_time,
|
|
184
194
|
fix=None,
|
|
185
195
|
precision=None,
|
|
186
196
|
ground_speed=ground_speed,
|
|
@@ -228,6 +238,7 @@ def _aggregate_gps_track_by_sample_time(
|
|
|
228
238
|
lon_tag: str,
|
|
229
239
|
lat_tag: str,
|
|
230
240
|
alt_tag: T.Optional[str] = None,
|
|
241
|
+
gps_time_tag: T.Optional[str] = None,
|
|
231
242
|
direction_tag: T.Optional[str] = None,
|
|
232
243
|
ground_speed_tag: T.Optional[str] = None,
|
|
233
244
|
gps_fix_tag: T.Optional[str] = None,
|
|
@@ -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
|