mapillary-tools 0.13.3a1__py3-none-any.whl → 0.14.0__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 (87) hide show
  1. mapillary_tools/__init__.py +1 -1
  2. mapillary_tools/api_v4.py +287 -22
  3. mapillary_tools/authenticate.py +326 -64
  4. mapillary_tools/blackvue_parser.py +195 -0
  5. mapillary_tools/camm/camm_builder.py +55 -97
  6. mapillary_tools/camm/camm_parser.py +429 -181
  7. mapillary_tools/commands/__main__.py +17 -8
  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 +44 -13
  13. mapillary_tools/commands/video_process_and_upload.py +19 -5
  14. mapillary_tools/config.py +65 -26
  15. mapillary_tools/constants.py +141 -18
  16. mapillary_tools/exceptions.py +37 -34
  17. mapillary_tools/exif_read.py +221 -116
  18. mapillary_tools/exif_write.py +10 -8
  19. mapillary_tools/exiftool_read.py +33 -42
  20. mapillary_tools/exiftool_read_video.py +97 -47
  21. mapillary_tools/exiftool_runner.py +57 -0
  22. mapillary_tools/ffmpeg.py +417 -242
  23. mapillary_tools/geo.py +158 -118
  24. mapillary_tools/geotag/__init__.py +0 -1
  25. mapillary_tools/geotag/base.py +147 -0
  26. mapillary_tools/geotag/factory.py +307 -0
  27. mapillary_tools/geotag/geotag_images_from_exif.py +14 -131
  28. mapillary_tools/geotag/geotag_images_from_exiftool.py +136 -85
  29. mapillary_tools/geotag/geotag_images_from_gpx.py +60 -124
  30. mapillary_tools/geotag/geotag_images_from_gpx_file.py +13 -126
  31. mapillary_tools/geotag/geotag_images_from_nmea_file.py +4 -5
  32. mapillary_tools/geotag/geotag_images_from_video.py +88 -51
  33. mapillary_tools/geotag/geotag_videos_from_exiftool.py +123 -0
  34. mapillary_tools/geotag/geotag_videos_from_gpx.py +52 -0
  35. mapillary_tools/geotag/geotag_videos_from_video.py +20 -185
  36. mapillary_tools/geotag/image_extractors/base.py +18 -0
  37. mapillary_tools/geotag/image_extractors/exif.py +60 -0
  38. mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
  39. mapillary_tools/geotag/options.py +182 -0
  40. mapillary_tools/geotag/utils.py +52 -16
  41. mapillary_tools/geotag/video_extractors/base.py +18 -0
  42. mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
  43. mapillary_tools/geotag/video_extractors/gpx.py +116 -0
  44. mapillary_tools/geotag/video_extractors/native.py +160 -0
  45. mapillary_tools/{geotag → gpmf}/gpmf_parser.py +205 -182
  46. mapillary_tools/{geotag → gpmf}/gps_filter.py +5 -3
  47. mapillary_tools/history.py +134 -20
  48. mapillary_tools/mp4/construct_mp4_parser.py +17 -10
  49. mapillary_tools/mp4/io_utils.py +0 -1
  50. mapillary_tools/mp4/mp4_sample_parser.py +36 -28
  51. mapillary_tools/mp4/simple_mp4_builder.py +10 -9
  52. mapillary_tools/mp4/simple_mp4_parser.py +13 -22
  53. mapillary_tools/process_geotag_properties.py +184 -414
  54. mapillary_tools/process_sequence_properties.py +594 -225
  55. mapillary_tools/sample_video.py +20 -26
  56. mapillary_tools/serializer/description.py +587 -0
  57. mapillary_tools/serializer/gpx.py +132 -0
  58. mapillary_tools/telemetry.py +26 -13
  59. mapillary_tools/types.py +98 -611
  60. mapillary_tools/upload.py +408 -416
  61. mapillary_tools/upload_api_v4.py +172 -174
  62. mapillary_tools/uploader.py +804 -284
  63. mapillary_tools/utils.py +49 -18
  64. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/METADATA +93 -35
  65. mapillary_tools-0.14.0.dist-info/RECORD +75 -0
  66. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/WHEEL +1 -1
  67. mapillary_tools/geotag/blackvue_parser.py +0 -118
  68. mapillary_tools/geotag/geotag_from_generic.py +0 -22
  69. mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -93
  70. mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -145
  71. mapillary_tools/video_data_extraction/cli_options.py +0 -22
  72. mapillary_tools/video_data_extraction/extract_video_data.py +0 -176
  73. mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
  74. mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -34
  75. mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -38
  76. mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -71
  77. mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -53
  78. mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
  79. mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -43
  80. mapillary_tools/video_data_extraction/extractors/gpx_parser.py +0 -108
  81. mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
  82. mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
  83. mapillary_tools-0.13.3a1.dist-info/RECORD +0 -75
  84. /mapillary_tools/{geotag → gpmf}/gpmf_gps_filter.py +0 -0
  85. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/entry_points.txt +0 -0
  86. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info/licenses}/LICENSE +0 -0
  87. {mapillary_tools-0.13.3a1.dist-info → mapillary_tools-0.14.0.dist-info}/top_level.txt +0 -0
mapillary_tools/ffmpeg.py CHANGED
@@ -1,4 +1,5 @@
1
1
  # pyre-ignore-all-errors[5, 24]
2
+ from __future__ import annotations
2
3
 
3
4
  import datetime
4
5
  import json
@@ -12,8 +13,7 @@ import typing as T
12
13
  from pathlib import Path
13
14
 
14
15
  LOG = logging.getLogger(__name__)
15
- FRAME_EXT = ".jpg"
16
- NA_STREAM_IDX = "NA"
16
+ _MAX_STDERR_LENGTH = 2048
17
17
 
18
18
 
19
19
  class StreamTag(T.TypedDict):
@@ -30,19 +30,19 @@ class Stream(T.TypedDict):
30
30
  index: int
31
31
  tags: StreamTag
32
32
  width: int
33
+ r_frame_rate: str
34
+ avg_frame_rate: str
35
+ nb_frames: str
33
36
 
34
37
 
35
38
  class ProbeOutput(T.TypedDict):
36
- streams: T.List[Stream]
39
+ streams: list[Stream]
37
40
 
38
41
 
39
42
  class FFmpegNotFoundError(Exception):
40
43
  pass
41
44
 
42
45
 
43
- _MAX_STDERR_LENGTH = 2048
44
-
45
-
46
46
  def _truncate_begin(s: str) -> str:
47
47
  if _MAX_STDERR_LENGTH < len(s):
48
48
  return "..." + s[-_MAX_STDERR_LENGTH:]
@@ -73,79 +73,43 @@ class FFmpegCalledProcessError(Exception):
73
73
 
74
74
 
75
75
  class FFMPEG:
76
+ FRAME_EXT = ".jpg"
77
+
76
78
  def __init__(
77
79
  self,
78
80
  ffmpeg_path: str = "ffmpeg",
79
81
  ffprobe_path: str = "ffprobe",
80
- stderr: T.Optional[int] = None,
82
+ stderr: int | None = None,
81
83
  ) -> None:
82
84
  """
83
- ffmpeg_path: path to ffmpeg binary
84
- ffprobe_path: path to ffprobe binary
85
- stderr: param passed to subprocess.run to control whether to capture stderr
85
+ Initialize FFMPEG wrapper with paths to ffmpeg and ffprobe binaries.
86
+
87
+ Args:
88
+ ffmpeg_path: Path to ffmpeg binary executable
89
+ ffprobe_path: Path to ffprobe binary executable
90
+ stderr: Parameter passed to subprocess.run to control stderr capture.
91
+ Use subprocess.PIPE to capture stderr, None to inherit from parent
86
92
  """
87
93
  self.ffmpeg_path = ffmpeg_path
88
94
  self.ffprobe_path = ffprobe_path
89
95
  self.stderr = stderr
90
96
 
91
- def _run_ffprobe_json(self, cmd: T.List[str]) -> T.Dict:
92
- full_cmd: T.List[str] = [self.ffprobe_path, "-print_format", "json", *cmd]
93
- LOG.info(f"Extracting video information: {' '.join(full_cmd)}")
94
- try:
95
- completed = subprocess.run(
96
- full_cmd,
97
- check=True,
98
- stdout=subprocess.PIPE,
99
- stderr=self.stderr,
100
- )
101
- except FileNotFoundError:
102
- raise FFmpegNotFoundError(
103
- f'The ffprobe command "{self.ffprobe_path}" not found'
104
- )
105
- except subprocess.CalledProcessError as ex:
106
- raise FFmpegCalledProcessError(ex) from ex
107
-
108
- try:
109
- stdout = completed.stdout.decode("utf-8")
110
- except UnicodeDecodeError:
111
- raise RuntimeError(
112
- f"Error decoding ffprobe output as unicode: {_truncate_end(str(completed.stdout))}"
113
- )
114
-
115
- try:
116
- output = json.loads(stdout)
117
- except json.JSONDecodeError:
118
- raise RuntimeError(
119
- f"Error JSON decoding ffprobe output: {_truncate_end(stdout)}"
120
- )
121
-
122
- # This check is for macOS:
123
- # ffprobe -hide_banner -print_format json not_exists
124
- # you will get exit code == 0 with the following stdout and stderr:
125
- # {
126
- # }
127
- # not_exists: No such file or directory
128
- if not output:
129
- raise RuntimeError(
130
- f"Empty JSON ffprobe output with STDERR: {_truncate_begin(str(completed.stderr))}"
131
- )
97
+ def probe_format_and_streams(self, video_path: Path) -> ProbeOutput:
98
+ """
99
+ Probe video file to extract format and stream information using ffprobe.
132
100
 
133
- return output
101
+ Args:
102
+ video_path: Path to the video file to probe
134
103
 
135
- def _run_ffmpeg(self, cmd: T.List[str]) -> None:
136
- full_cmd: T.List[str] = [self.ffmpeg_path, *cmd]
137
- LOG.info(f"Extracting frames: {' '.join(full_cmd)}")
138
- try:
139
- subprocess.run(full_cmd, check=True, stderr=self.stderr)
140
- except FileNotFoundError:
141
- raise FFmpegNotFoundError(
142
- f'The ffmpeg command "{self.ffmpeg_path}" not found'
143
- )
144
- except subprocess.CalledProcessError as ex:
145
- raise FFmpegCalledProcessError(ex) from ex
104
+ Returns:
105
+ Dictionary containing streams and format information from ffprobe
146
106
 
147
- def probe_format_and_streams(self, video_path: Path) -> ProbeOutput:
148
- cmd: T.List[str] = [
107
+ Raises:
108
+ FFmpegNotFoundError: If ffprobe binary is not found
109
+ FFmpegCalledProcessError: If ffprobe command fails
110
+ RuntimeError: If output cannot be decoded or parsed as JSON
111
+ """
112
+ cmd: list[str] = [
149
113
  "-hide_banner",
150
114
  "-show_format",
151
115
  "-show_streams",
@@ -153,49 +117,79 @@ class FFMPEG:
153
117
  ]
154
118
  return T.cast(ProbeOutput, self._run_ffprobe_json(cmd))
155
119
 
156
- def extract_frames(
120
+ def extract_frames_by_interval(
157
121
  self,
158
122
  video_path: Path,
159
123
  sample_dir: Path,
160
124
  sample_interval: float,
161
- stream_idx: T.Optional[int] = None,
125
+ stream_specifier: int | str = "v",
162
126
  ) -> None:
163
127
  """
164
- Extract frames by the sample interval from the specified video stream.
165
-
166
- stream_idx: the stream_index specifier to a **video stream**. If it's None, defaults to "v". See http://ffmpeg.org/ffmpeg.html#Stream-specifiers-1
128
+ Extract frames from video at regular time intervals using fps filter.
129
+
130
+ Args:
131
+ video_path: Path to input video file
132
+ sample_dir: Directory where extracted frame images will be saved
133
+ sample_interval: Time interval between extracted frames in seconds
134
+ stream_specifier: Stream specifier to target specific stream(s).
135
+ Can be an integer (stream index) or "v" (all video streams)
136
+ See https://ffmpeg.org/ffmpeg.html#Stream-specifiers-1
137
+
138
+ Raises:
139
+ FFmpegNotFoundError: If ffmpeg binary is not found
140
+ FFmpegCalledProcessError: If ffmpeg command fails
167
141
  """
142
+ self._validate_stream_specifier(stream_specifier)
143
+
168
144
  sample_prefix = sample_dir.joinpath(video_path.stem)
169
- if stream_idx is not None:
170
- stream_selector = ["-map", f"0:{stream_idx}"]
171
- ouput_template = f"{sample_prefix}_{stream_idx}_%06d{FRAME_EXT}"
172
- stream_specifier = f"{stream_idx}"
173
- else:
174
- stream_selector = []
175
- ouput_template = f"{sample_prefix}_{NA_STREAM_IDX}_%06d{FRAME_EXT}"
176
- stream_specifier = "v"
177
-
178
- cmd: T.List[str] = [
179
- # global options should be specified first
180
- *["-hide_banner", "-nostdin"],
181
- # input 0
145
+ stream_selector = ["-map", f"0:{stream_specifier}"]
146
+ output_template = f"{sample_prefix}_{stream_specifier}_%06d{self.FRAME_EXT}"
147
+
148
+ cmd: list[str] = [
149
+ # Global options should be specified first
150
+ *["-hide_banner"],
151
+ # Input 0
182
152
  *["-i", str(video_path)],
183
- # select stream
153
+ # Select stream
184
154
  *stream_selector,
185
- # filter videos
155
+ # Filter videos
186
156
  *["-vf", f"fps=1/{sample_interval}"],
187
- # video quality level (or the alias -q:v)
188
- *[f"-qscale:{stream_specifier}", "2"],
157
+ # Video quality level (or the alias -q:v)
189
158
  # -q:v=1 is the best quality but larger image sizes
190
159
  # see https://stackoverflow.com/a/10234065
191
160
  # *["-qscale:v", "1", "-qmin", "1"],
192
- # output
193
- ouput_template,
161
+ *["-qscale:v", "2"],
162
+ # Output
163
+ output_template,
194
164
  ]
195
165
 
196
- self._run_ffmpeg(cmd)
166
+ self.run_ffmpeg_non_interactive(cmd)
167
+
168
+ @classmethod
169
+ def generate_binary_search(cls, sorted_frame_indices: list[int]) -> str:
170
+ """
171
+ Generate a binary search expression for ffmpeg select filter.
172
+
173
+ Creates an optimized filter expression that uses binary search logic
174
+ to efficiently select specific frame numbers from a video stream.
175
+
176
+ Args:
177
+ sorted_frame_indices: List of frame numbers to select, must be sorted in ascending order
178
+
179
+ Returns:
180
+ FFmpeg filter expression string using binary search logic
181
+
182
+ Examples:
183
+ >>> FFMPEG.generate_binary_search([])
184
+ '0'
185
+ >>> FFMPEG.generate_binary_search([1])
186
+ 'eq(n\\\\,1)'
187
+ >>> FFMPEG.generate_binary_search([1, 2])
188
+ 'if(lt(n\\\\,2)\\\\,eq(n\\\\,1)\\\\,eq(n\\\\,2))'
189
+ >>> FFMPEG.generate_binary_search([1, 2, 3])
190
+ 'if(lt(n\\\\,2)\\\\,eq(n\\\\,1)\\\\,if(lt(n\\\\,3)\\\\,eq(n\\\\,2)\\\\,eq(n\\\\,3)))'
191
+ """
197
192
 
198
- def generate_binary_search(self, sorted_frame_indices: T.Sequence[int]) -> str:
199
193
  length = len(sorted_frame_indices)
200
194
 
201
195
  if length == 0:
@@ -204,39 +198,50 @@ class FFMPEG:
204
198
  if length == 1:
205
199
  return f"eq(n\\,{sorted_frame_indices[0]})"
206
200
 
207
- middle = length // 2
208
- return f"if(lt(n\\,{sorted_frame_indices[middle]})\\,{self.generate_binary_search(sorted_frame_indices[:middle])}\\,{self.generate_binary_search(sorted_frame_indices[middle:])})"
201
+ middle_idx = length // 2
202
+ left = cls.generate_binary_search(sorted_frame_indices[:middle_idx])
203
+ right = cls.generate_binary_search(sorted_frame_indices[middle_idx:])
204
+
205
+ return f"if(lt(n\\,{sorted_frame_indices[middle_idx]})\\,{left}\\,{right})"
209
206
 
210
207
  def extract_specified_frames(
211
208
  self,
212
209
  video_path: Path,
213
210
  sample_dir: Path,
214
- frame_indices: T.Set[int],
215
- stream_idx: T.Optional[int] = None,
211
+ frame_indices: set[int],
212
+ stream_specifier: int | str = "v",
216
213
  ) -> None:
217
214
  """
218
- Extract specified frames from the specified video stream.
219
-
220
- stream_idx: the stream_index specifier to a **video stream**. If it's None, defaults to "v". See http://ffmpeg.org/ffmpeg.html#Stream-specifiers-1
215
+ Extract specific frames from video by frame number using select filter.
216
+
217
+ Uses a binary search filter expression to efficiently select only the
218
+ specified frame numbers from the video stream.
219
+
220
+ Args:
221
+ video_path: Path to input video file
222
+ sample_dir: Directory where extracted frame images will be saved
223
+ frame_indices: Set of specific frame numbers to extract (0-based)
224
+ stream_specifier: Stream specifier to target specific stream(s).
225
+ Can be an integer (stream index) or "v" (all video streams)
226
+ See https://ffmpeg.org/ffmpeg.html#Stream-specifiers-1
227
+
228
+ Raises:
229
+ FFmpegNotFoundError: If ffmpeg binary is not found
230
+ FFmpegCalledProcessError: If ffmpeg command fails
231
+
232
+ Note:
233
+ Frame indices are 0-based but ffmpeg output files are numbered starting from 1.
234
+ Creates temporary filter script file on Windows to avoid command line length limits.
221
235
  """
222
236
 
237
+ self._validate_stream_specifier(stream_specifier)
238
+
223
239
  if not frame_indices:
224
240
  return
225
241
 
226
242
  sample_prefix = sample_dir.joinpath(video_path.stem)
227
- if stream_idx is not None:
228
- stream_selector = ["-map", f"0:{stream_idx}"]
229
- ouput_template = f"{sample_prefix}_{stream_idx}_%06d{FRAME_EXT}"
230
- stream_specifier = f"{stream_idx}"
231
- else:
232
- stream_selector = []
233
- ouput_template = f"{sample_prefix}_{NA_STREAM_IDX}_%06d{FRAME_EXT}"
234
- stream_specifier = "v"
235
-
236
- # Write the select filter to a temp file because:
237
- # The select filter could be large and
238
- # the maximum command line length for the CreateProcess function is 32767 characters
239
- # https://devblogs.microsoft.com/oldnewthing/20031210-00/?p=41553
243
+ stream_selector = ["-map", f"0:{stream_specifier}"]
244
+ output_template = f"{sample_prefix}_{stream_specifier}_%06d{self.FRAME_EXT}"
240
245
 
241
246
  eqs = self.generate_binary_search(sorted(frame_indices))
242
247
 
@@ -246,6 +251,10 @@ class FFMPEG:
246
251
  else:
247
252
  delete = True
248
253
 
254
+ # Write the select filter to a temp file because:
255
+ # The select filter could be large and
256
+ # the maximum command line length for the CreateProcess function is 32767 characters
257
+ # https://devblogs.microsoft.com/oldnewthing/20031210-00/?p=41553
249
258
  with tempfile.NamedTemporaryFile(mode="w+", delete=delete) as select_file:
250
259
  try:
251
260
  select_file.write(f"select={eqs}")
@@ -253,14 +262,14 @@ class FFMPEG:
253
262
  # If not close, error "The process cannot access the file because it is being used by another process"
254
263
  if not delete:
255
264
  select_file.close()
256
- cmd: T.List[str] = [
257
- # global options should be specified first
258
- *["-hide_banner", "-nostdin"],
259
- # input 0
265
+ cmd: list[str] = [
266
+ # Global options should be specified first
267
+ *["-hide_banner"],
268
+ # Input 0
260
269
  *["-i", str(video_path)],
261
- # select stream
270
+ # Select stream
262
271
  *stream_selector,
263
- # filter videos
272
+ # Filter videos
264
273
  *[
265
274
  *["-filter_script:v", select_file.name],
266
275
  # Each frame is passed with its timestamp from the demuxer to the muxer
@@ -268,8 +277,8 @@ class FFMPEG:
268
277
  # vsync is deprecated by fps_mode,
269
278
  # but fps_mode is not avaliable on some older versions ;(
270
279
  # *[f"-fps_mode:{stream_specifier}", "passthrough"],
271
- # Set the number of video frames to output
272
- *[f"-frames:{stream_specifier}", str(len(frame_indices))],
280
+ # Set the number of video frames to output (this is an optimization to let ffmpeg stop early)
281
+ *["-frames:v", str(len(frame_indices))],
273
282
  # Disabled because it doesn't always name the sample images as expected
274
283
  # For example "select(n\,1)" we expected the first sample to be IMG_001.JPG
275
284
  # but it could be IMG_005.JPG
@@ -277,15 +286,15 @@ class FFMPEG:
277
286
  # If set to 1, expand the filename with pts from pkt->pts. Default value is 0.
278
287
  # *["-frame_pts", "1"],
279
288
  ],
280
- # video quality level (or the alias -q:v)
281
- *[f"-qscale:{stream_specifier}", "2"],
289
+ # Video quality level (or the alias -q:v)
282
290
  # -q:v=1 is the best quality but larger image sizes
283
291
  # see https://stackoverflow.com/a/10234065
284
292
  # *["-qscale:v", "1", "-qmin", "1"],
293
+ *["-qscale:v", "2"],
285
294
  # output
286
- ouput_template,
295
+ output_template,
287
296
  ]
288
- self._run_ffmpeg(cmd)
297
+ self.run_ffmpeg_non_interactive(cmd)
289
298
  finally:
290
299
  if not delete:
291
300
  try:
@@ -293,45 +302,286 @@ class FFMPEG:
293
302
  except FileNotFoundError:
294
303
  pass
295
304
 
305
+ @classmethod
306
+ def sort_selected_samples(
307
+ cls,
308
+ sample_dir: Path,
309
+ video_path: Path,
310
+ selected_stream_specifiers: list[int | str] | None = None,
311
+ ) -> list[tuple[int, list[Path | None]]]:
312
+ """
313
+ Group extracted frame samples by frame index across multiple streams.
314
+
315
+ Groups frames so that the Nth group contains all frames from the selected
316
+ streams at frame index N, allowing synchronized access to multi-stream frames.
317
+
318
+ Args:
319
+ sample_dir: Directory containing extracted frame files
320
+ video_path: Original video file path (used to match frame filenames)
321
+ selected_stream_specifiers: List of stream specifiers to include in output.
322
+ Can contain integers (stream indices) or "v" (all video streams).
323
+ If None, defaults to ["v"]
324
+
325
+ Returns:
326
+ List of tuples where each tuple contains:
327
+ - frame_idx (int): The frame index
328
+ - sample_paths (list[Path | None]): Paths to frame files from each selected stream,
329
+ or None if no frame exists for that stream at this index
330
+
331
+ Note:
332
+ Output is sorted by frame index in ascending order.
333
+ """
334
+ if selected_stream_specifiers is None:
335
+ selected_stream_specifiers = ["v"]
336
+
337
+ for stream_specifier in selected_stream_specifiers:
338
+ cls._validate_stream_specifier(stream_specifier)
339
+
340
+ stream_samples: dict[int, list[tuple[str, Path]]] = {}
341
+ for stream_specifier, frame_idx, sample_path in cls.iterate_samples(
342
+ sample_dir, video_path
343
+ ):
344
+ stream_samples.setdefault(frame_idx, []).append(
345
+ (str(stream_specifier), sample_path)
346
+ )
347
+
348
+ selected: list[tuple[int, list[Path | None]]] = []
349
+ for frame_idx in sorted(stream_samples.keys()):
350
+ indexed_by_specifier = {
351
+ specifier: sample_path
352
+ for specifier, sample_path in stream_samples[frame_idx]
353
+ }
354
+ selected_sample_paths = [
355
+ indexed_by_specifier.get(str(specifier))
356
+ for specifier in selected_stream_specifiers
357
+ ]
358
+ selected.append((frame_idx, selected_sample_paths))
359
+ return selected
360
+
361
+ @classmethod
362
+ def iterate_samples(
363
+ cls, sample_dir: Path, video_path: Path
364
+ ) -> T.Generator[tuple[str, int, Path], None, None]:
365
+ """
366
+ Iterate over all extracted frame samples in a directory.
367
+
368
+ Searches for frame files matching the expected naming pattern and yields
369
+ information about each frame including stream specifier, frame index, and file path.
370
+
371
+ Args:
372
+ sample_dir: Directory containing extracted frame files
373
+ video_path: Original video file path (used to match frame filenames)
374
+
375
+ Yields:
376
+ Tuple containing:
377
+ - stream_specifier (str): Stream specifier (number or "v")
378
+ - frame_idx (int): Frame index (0-based or 1-based depending on extraction method)
379
+ - sample_path (Path): Path to the frame image file
380
+
381
+ Note:
382
+ Expected filename pattern: {video_stem}_{stream_specifier}_{frame_idx:06d}.jpg
383
+ where stream_specifier can be a number or "v" for video streams.
384
+ """
385
+ sample_basename_pattern = re.compile(
386
+ rf"""
387
+ ^{re.escape(video_path.stem)} # Match the video stem
388
+ _(?P<stream_specifier>\d+|v) # Stream specifier can be a number or "v"
389
+ _(?P<frame_idx>\d+)$ # Frame index, can be 0-padded
390
+ """,
391
+ re.X,
392
+ )
393
+ for sample_path in sample_dir.iterdir():
394
+ result = cls._extract_stream_frame_idx(
395
+ sample_path.name, sample_basename_pattern
396
+ )
397
+ if result is not None:
398
+ stream_specifier, frame_idx = result
399
+ yield (stream_specifier, frame_idx, sample_path)
400
+
401
+ def run_ffmpeg_non_interactive(self, cmd: list[str]) -> None:
402
+ """
403
+ Execute ffmpeg command in non-interactive mode.
404
+
405
+ Runs ffmpeg with the given command arguments, automatically adding
406
+ the -nostdin flag to prevent interactive prompts.
407
+
408
+ Args:
409
+ cmd: List of command line arguments to pass to ffmpeg
410
+
411
+ Raises:
412
+ FFmpegNotFoundError: If ffmpeg binary is not found
413
+ FFmpegCalledProcessError: If ffmpeg command fails
414
+ """
415
+ full_cmd: list[str] = [self.ffmpeg_path, "-nostdin", *cmd]
416
+ LOG.info(f"Running ffmpeg: {' '.join(full_cmd)}")
417
+ try:
418
+ subprocess.run(full_cmd, check=True, stderr=self.stderr)
419
+ except FileNotFoundError:
420
+ raise FFmpegNotFoundError(
421
+ f'The ffmpeg command "{self.ffmpeg_path}" not found'
422
+ )
423
+ except subprocess.CalledProcessError as ex:
424
+ raise FFmpegCalledProcessError(ex) from ex
425
+
426
+ @classmethod
427
+ def _extract_stream_frame_idx(
428
+ cls, sample_basename: str, pattern: T.Pattern[str]
429
+ ) -> tuple[str, int] | None:
430
+ """
431
+ Extract stream specifier and frame index from sample basename
432
+
433
+ Returns:
434
+ If returning None, it means the basename does not match the pattern
435
+
436
+ Examples:
437
+ * basename GX010001_v_000000.jpg will extract ("v", 0)
438
+ * basename GX010001_1_000002.jpg will extract ("1", 2)
439
+ """
440
+ image_no_ext, ext = os.path.splitext(sample_basename)
441
+ if ext.lower() != cls.FRAME_EXT.lower():
442
+ return None
443
+
444
+ match = pattern.match(image_no_ext)
445
+ if not match:
446
+ return None
447
+
448
+ stream_specifier = match.group("stream_specifier")
449
+
450
+ # Convert 0-padded numbers to int
451
+ # e.g. 000000 -> 0
452
+ # e.g. 000001 -> 1
453
+ frame_idx_str = match.group("frame_idx")
454
+ frame_idx_str = frame_idx_str.lstrip("0") or "0"
455
+
456
+ try:
457
+ frame_idx = int(frame_idx_str)
458
+ except ValueError:
459
+ return None
460
+
461
+ return stream_specifier, frame_idx
462
+
463
+ def _run_ffprobe_json(self, cmd: list[str]) -> dict:
464
+ full_cmd: list[str] = [self.ffprobe_path, "-print_format", "json", *cmd]
465
+ LOG.info(f"Extracting video information: {' '.join(full_cmd)}")
466
+ try:
467
+ completed = subprocess.run(
468
+ full_cmd, check=True, stdout=subprocess.PIPE, stderr=self.stderr
469
+ )
470
+ except FileNotFoundError:
471
+ raise FFmpegNotFoundError(
472
+ f'The ffprobe command "{self.ffprobe_path}" not found'
473
+ )
474
+ except subprocess.CalledProcessError as ex:
475
+ raise FFmpegCalledProcessError(ex) from ex
476
+
477
+ try:
478
+ stdout = completed.stdout.decode("utf-8")
479
+ except UnicodeDecodeError:
480
+ raise RuntimeError(
481
+ f"Error decoding ffprobe output as unicode: {_truncate_end(str(completed.stdout))}"
482
+ )
483
+
484
+ try:
485
+ output = json.loads(stdout)
486
+ except json.JSONDecodeError:
487
+ raise RuntimeError(
488
+ f"Error JSON decoding ffprobe output: {_truncate_end(stdout)}"
489
+ )
490
+
491
+ # This check is for macOS:
492
+ # ffprobe -hide_banner -print_format json not_exists
493
+ # you will get exit code == 0 with the following stdout and stderr:
494
+ # {
495
+ # }
496
+ # not_exists: No such file or directory
497
+ if not output:
498
+ raise RuntimeError(
499
+ f"Empty JSON ffprobe output with STDERR: {_truncate_begin(str(completed.stderr))}"
500
+ )
501
+
502
+ return output
503
+
504
+ @classmethod
505
+ def _validate_stream_specifier(cls, stream_specifier: int | str) -> None:
506
+ if isinstance(stream_specifier, str):
507
+ if stream_specifier in ["v"]:
508
+ pass
509
+ else:
510
+ try:
511
+ int(stream_specifier)
512
+ except ValueError:
513
+ raise ValueError(f"Invalid stream specifier: {stream_specifier}")
514
+
296
515
 
297
516
  class Probe:
298
- probe: ProbeOutput
517
+ probe_output: ProbeOutput
299
518
 
300
- def __init__(self, probe: ProbeOutput) -> None:
301
- self.probe = probe
519
+ def __init__(self, probe_output: ProbeOutput) -> None:
520
+ """
521
+ Initialize Probe with ffprobe output data.
302
522
 
303
- def probe_video_start_time(self) -> T.Optional[datetime.datetime]:
523
+ Args:
524
+ probe_output: Dictionary containing streams and format information from ffprobe
304
525
  """
305
- Find video start time of the given video.
306
- It searches video creation time and duration in video streams first and then the other streams.
307
- Once found, return stream creation time - stream duration as the video start time.
526
+ self.probe_output = probe_output
527
+
528
+ def probe_video_start_time(self) -> datetime.datetime | None:
308
529
  """
309
- streams = self.probe.get("streams", [])
530
+ Determine the start time of the video by analyzing stream metadata.
531
+
532
+ Searches for creation time and duration information in video streams first,
533
+ then falls back to other stream types. Calculates start time as:
534
+ creation_time - duration
310
535
 
311
- # search start time from video streams
536
+ Returns:
537
+ Video start time as datetime object, or None if cannot be determined
538
+
539
+ Note:
540
+ Prioritizes video streams with highest resolution when multiple exist.
541
+ """
542
+ streams = self.probe_output.get("streams", [])
543
+
544
+ # Search start time from video streams
312
545
  video_streams = self.probe_video_streams()
313
546
  video_streams.sort(
314
547
  key=lambda s: s.get("width", 0) * s.get("height", 0), reverse=True
315
548
  )
316
549
  for stream in video_streams:
317
- start_time = extract_stream_start_time(stream)
550
+ start_time = self.extract_stream_start_time(stream)
318
551
  if start_time is not None:
319
552
  return start_time
320
553
 
321
- # search start time from the other streams
554
+ # Search start time from the other streams
322
555
  for stream in streams:
323
556
  if stream.get("codec_type") != "video":
324
- start_time = extract_stream_start_time(stream)
557
+ start_time = self.extract_stream_start_time(stream)
325
558
  if start_time is not None:
326
559
  return start_time
327
560
 
328
561
  return None
329
562
 
330
- def probe_video_streams(self) -> T.List[Stream]:
331
- streams = self.probe.get("streams", [])
563
+ def probe_video_streams(self) -> list[Stream]:
564
+ """
565
+ Extract all video streams from the probe output.
566
+
567
+ Returns:
568
+ List of video stream dictionaries containing metadata like codec,
569
+ dimensions, frame rate, etc.
570
+ """
571
+ streams = self.probe_output.get("streams", [])
332
572
  return [stream for stream in streams if stream.get("codec_type") == "video"]
333
573
 
334
- def probe_video_with_max_resolution(self) -> T.Optional[Stream]:
574
+ def probe_video_with_max_resolution(self) -> Stream | None:
575
+ """
576
+ Find the video stream with the highest resolution.
577
+
578
+ Sorts all video streams by width × height and returns the one with
579
+ the largest resolution.
580
+
581
+ Returns:
582
+ Stream dictionary for the highest resolution video stream,
583
+ or None if no video streams exist
584
+ """
335
585
  video_streams = self.probe_video_streams()
336
586
  video_streams.sort(
337
587
  key=lambda s: s.get("width", 0) * s.get("height", 0), reverse=True
@@ -340,112 +590,37 @@ class Probe:
340
590
  return None
341
591
  return video_streams[0]
342
592
 
593
+ @classmethod
594
+ def extract_stream_start_time(cls, stream: Stream) -> datetime.datetime | None:
595
+ """
596
+ Calculate the start time of a specific stream.
343
597
 
344
- def extract_stream_start_time(stream: Stream) -> T.Optional[datetime.datetime]:
345
- """
346
- Find the start time of the given stream.
347
- Start time is the creation time of the stream minus the duration of the stream.
348
- """
349
- duration_str = stream.get("duration")
350
- LOG.debug("Extracted video duration: %s", duration_str)
351
- if duration_str is None:
352
- return None
353
- duration = float(duration_str)
598
+ Determines start time by subtracting stream duration from creation time:
599
+ start_time = creation_time - duration
354
600
 
355
- creation_time_str = stream.get("tags", {}).get("creation_time")
356
- LOG.debug("Extracted video creation time: %s", creation_time_str)
357
- if creation_time_str is None:
358
- return None
359
- try:
360
- creation_time = datetime.datetime.fromisoformat(creation_time_str)
361
- except ValueError:
362
- creation_time = datetime.datetime.strptime(
363
- creation_time_str, "%Y-%m-%dT%H:%M:%S.%f%z"
364
- )
365
- return creation_time - datetime.timedelta(seconds=duration)
366
-
367
-
368
- def _extract_stream_frame_idx(
369
- sample_basename: str,
370
- sample_basename_pattern: T.Pattern[str],
371
- ) -> T.Optional[T.Tuple[T.Optional[int], int]]:
372
- """
373
- extract stream id and frame index from sample basename
374
- e.g. basename GX010001_NA_000000.jpg will extract (None, 0)
375
- e.g. basename GX010001_1_000002.jpg will extract (1, 2)
376
- If returning None, it means the basename does not match the pattern
377
- """
378
- image_no_ext, ext = os.path.splitext(sample_basename)
379
- if ext.lower() != FRAME_EXT.lower():
380
- return None
601
+ Args:
602
+ stream: Stream dictionary containing metadata including tags and duration
381
603
 
382
- match = sample_basename_pattern.match(image_no_ext)
383
- if not match:
384
- return None
604
+ Returns:
605
+ Stream start time as datetime object, or None if required metadata is missing
385
606
 
386
- g1 = match.group("stream_idx")
387
- try:
388
- if g1 == NA_STREAM_IDX:
389
- stream_idx = None
390
- else:
391
- stream_idx = int(g1)
392
- except ValueError:
393
- return None
394
-
395
- # convert 0-padded numbers to int
396
- # e.g. 000000 -> 0
397
- # e.g. 000001 -> 1
398
- g2 = match.group("frame_idx")
399
- g2 = g2.lstrip("0") or "0"
400
-
401
- try:
402
- frame_idx = int(g2)
403
- except ValueError:
404
- return None
607
+ Note:
608
+ Handles multiple datetime formats including ISO format and custom patterns.
609
+ """
610
+ duration_str = stream.get("duration")
611
+ LOG.debug("Extracted video duration: %s", duration_str)
612
+ if duration_str is None:
613
+ return None
614
+ duration = float(duration_str)
405
615
 
406
- return stream_idx, frame_idx
407
-
408
-
409
- def iterate_samples(
410
- sample_dir: Path, video_path: Path
411
- ) -> T.Generator[T.Tuple[T.Optional[int], int, Path], None, None]:
412
- """
413
- Search all samples in the sample_dir,
414
- and return a generator of the tuple: (stream ID, frame index, sample path).
415
- The frame index could be 0-based or 1-based depending on how it's sampled.
416
- """
417
- sample_basename_pattern = re.compile(
418
- rf"^{re.escape(video_path.stem)}_(?P<stream_idx>\d+|{re.escape(NA_STREAM_IDX)})_(?P<frame_idx>\d+)$"
419
- )
420
- for sample_path in sample_dir.iterdir():
421
- stream_frame_idx = _extract_stream_frame_idx(
422
- sample_path.name,
423
- sample_basename_pattern,
424
- )
425
- if stream_frame_idx is not None:
426
- stream_idx, frame_idx = stream_frame_idx
427
- yield (stream_idx, frame_idx, sample_path)
428
-
429
-
430
- def sort_selected_samples(
431
- sample_dir: Path, video_path: Path, selected_stream_indices: T.List[T.Optional[int]]
432
- ) -> T.List[T.Tuple[int, T.List[T.Optional[Path]]]]:
433
- """
434
- Group frames by frame index, so that
435
- the Nth group contains all the frames from the selected streams at frame index N.
436
- """
437
- stream_samples: T.Dict[int, T.List[T.Tuple[T.Optional[int], Path]]] = {}
438
- for stream_idx, frame_idx, sample_path in iterate_samples(sample_dir, video_path):
439
- stream_samples.setdefault(frame_idx, []).append((stream_idx, sample_path))
440
-
441
- selected: T.List[T.Tuple[int, T.List[T.Optional[Path]]]] = []
442
- for frame_idx in sorted(stream_samples.keys()):
443
- indexed = {
444
- stream_idx: sample_path
445
- for stream_idx, sample_path in stream_samples[frame_idx]
446
- }
447
- selected_sample_paths = [
448
- indexed.get(stream_idx) for stream_idx in selected_stream_indices
449
- ]
450
- selected.append((frame_idx, selected_sample_paths))
451
- return selected
616
+ creation_time_str = stream.get("tags", {}).get("creation_time")
617
+ LOG.debug("Extracted video creation time: %s", creation_time_str)
618
+ if creation_time_str is None:
619
+ return None
620
+ try:
621
+ creation_time = datetime.datetime.fromisoformat(creation_time_str)
622
+ except ValueError:
623
+ creation_time = datetime.datetime.strptime(
624
+ creation_time_str, "%Y-%m-%dT%H:%M:%S.%f%z"
625
+ )
626
+ return creation_time - datetime.timedelta(seconds=duration)