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.
Files changed (64) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +235 -14
  3. mapillary_tools/authenticate.py +325 -64
  4. mapillary_tools/{geotag/blackvue_parser.py → blackvue_parser.py} +74 -54
  5. mapillary_tools/camm/camm_builder.py +55 -97
  6. mapillary_tools/camm/camm_parser.py +425 -177
  7. mapillary_tools/commands/__main__.py +11 -4
  8. mapillary_tools/commands/authenticate.py +8 -1
  9. mapillary_tools/commands/process.py +27 -51
  10. mapillary_tools/commands/process_and_upload.py +19 -5
  11. mapillary_tools/commands/sample_video.py +2 -3
  12. mapillary_tools/commands/upload.py +18 -9
  13. mapillary_tools/commands/video_process_and_upload.py +19 -5
  14. mapillary_tools/config.py +28 -12
  15. mapillary_tools/constants.py +46 -4
  16. mapillary_tools/exceptions.py +34 -35
  17. mapillary_tools/exif_read.py +158 -53
  18. mapillary_tools/exiftool_read.py +19 -5
  19. mapillary_tools/exiftool_read_video.py +12 -1
  20. mapillary_tools/exiftool_runner.py +77 -0
  21. mapillary_tools/geo.py +148 -107
  22. mapillary_tools/geotag/factory.py +298 -0
  23. mapillary_tools/geotag/geotag_from_generic.py +152 -11
  24. mapillary_tools/geotag/geotag_images_from_exif.py +43 -124
  25. mapillary_tools/geotag/geotag_images_from_exiftool.py +66 -70
  26. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +32 -48
  27. mapillary_tools/geotag/geotag_images_from_gpx.py +41 -116
  28. mapillary_tools/geotag/geotag_images_from_gpx_file.py +15 -96
  29. mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -2
  30. mapillary_tools/geotag/geotag_images_from_video.py +46 -46
  31. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +98 -92
  32. mapillary_tools/geotag/geotag_videos_from_gpx.py +140 -0
  33. mapillary_tools/geotag/geotag_videos_from_video.py +149 -181
  34. mapillary_tools/geotag/options.py +159 -0
  35. mapillary_tools/{geotag → gpmf}/gpmf_parser.py +194 -171
  36. mapillary_tools/history.py +3 -11
  37. mapillary_tools/mp4/io_utils.py +0 -1
  38. mapillary_tools/mp4/mp4_sample_parser.py +11 -3
  39. mapillary_tools/mp4/simple_mp4_parser.py +0 -10
  40. mapillary_tools/process_geotag_properties.py +151 -386
  41. mapillary_tools/process_sequence_properties.py +554 -202
  42. mapillary_tools/sample_video.py +8 -15
  43. mapillary_tools/telemetry.py +24 -12
  44. mapillary_tools/types.py +80 -22
  45. mapillary_tools/upload.py +316 -298
  46. mapillary_tools/upload_api_v4.py +55 -122
  47. mapillary_tools/uploader.py +396 -254
  48. mapillary_tools/utils.py +26 -0
  49. mapillary_tools/video_data_extraction/extract_video_data.py +17 -36
  50. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +34 -19
  51. mapillary_tools/video_data_extraction/extractors/camm_parser.py +41 -17
  52. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +4 -1
  53. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +1 -2
  54. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +37 -22
  55. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/METADATA +3 -2
  56. mapillary_tools-0.14.0a1.dist-info/RECORD +78 -0
  57. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/WHEEL +1 -1
  58. mapillary_tools/geotag/utils.py +0 -26
  59. mapillary_tools-0.13.3a1.dist-info/RECORD +0 -75
  60. /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
  61. /mapillary_tools/{geotag → gpmf}/gps_filter.py +0 -0
  62. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/entry_points.txt +0 -0
  63. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info/licenses}/LICENSE +0 -0
  64. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0a1.dist-info}/top_level.txt +0 -0
@@ -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.Sequence[str],
478
- field_type: T.Type[_FIELD_TYPE],
479
- ) -> T.Optional[_FIELD_TYPE]:
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
- self.tags = exifread.process_file(fp, details=True, debug=True)
524
- except Exception:
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=True, debug=True
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
- direction = self._extract_alternative_fields(fields, float)
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(["Image Make", "EXIF LensMake"], str)
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(["Image Model", "EXIF LensModel"], str)
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: T.Type[_FIELD_TYPE],
735
- ) -> T.Optional[_FIELD_TYPE]:
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._xmp = self._extract_xmp()
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
- application_notes = self.extract_application_notes()
784
- if application_notes is None:
785
- return None
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(application_notes)
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
- if self._xmp is None:
905
+ xmp = self._xmp_with_reason("altitude")
906
+ if xmp is None:
797
907
  return None
798
- val = self._xmp.extract_altitude()
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
- if self._xmp is None:
808
- return None
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 = self._xmp.extract_direction()
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
- if self._xmp is None:
929
+ xmp = self._xmp_with_reason("lon_lat")
930
+ if xmp is None:
830
931
  return None
831
- val = self._xmp.extract_lon_lat()
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
- if self._xmp is None:
941
+ xmp = self._xmp_with_reason("make")
942
+ if xmp is None:
841
943
  return None
842
- val = self._xmp.extract_make()
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
- if self._xmp is None:
953
+ xmp = self._xmp_with_reason("model")
954
+ if xmp is None:
852
955
  return None
853
- val = self._xmp.extract_model()
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
- if self._xmp is None:
965
+ xmp = self._xmp_with_reason("width")
966
+ if xmp is None:
863
967
  return None
864
- val = self._xmp.extract_width()
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
- if self._xmp is None:
977
+ xmp = self._xmp_with_reason("width")
978
+ if xmp is None:
874
979
  return None
875
- val = self._xmp.extract_height()
980
+ val = xmp.extract_height()
876
981
  if val is not None:
877
982
  return val
878
983
  return None
@@ -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
- 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
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=None,
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