mapillary-tools 0.14.0a2__py3-none-any.whl → 0.14.0b1__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 +1 -0
- mapillary_tools/authenticate.py +9 -9
- mapillary_tools/blackvue_parser.py +79 -22
- mapillary_tools/config.py +38 -17
- mapillary_tools/constants.py +2 -0
- mapillary_tools/exiftool_read_video.py +52 -15
- mapillary_tools/exiftool_runner.py +4 -24
- mapillary_tools/ffmpeg.py +406 -232
- mapillary_tools/geotag/__init__.py +0 -0
- mapillary_tools/geotag/base.py +2 -2
- mapillary_tools/geotag/factory.py +97 -88
- mapillary_tools/geotag/geotag_images_from_exiftool.py +26 -19
- mapillary_tools/geotag/geotag_images_from_gpx.py +13 -6
- mapillary_tools/geotag/geotag_images_from_video.py +35 -0
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +39 -13
- mapillary_tools/geotag/geotag_videos_from_gpx.py +22 -9
- mapillary_tools/geotag/options.py +25 -3
- mapillary_tools/geotag/video_extractors/base.py +1 -1
- mapillary_tools/geotag/video_extractors/exiftool.py +1 -1
- mapillary_tools/geotag/video_extractors/gpx.py +60 -70
- mapillary_tools/geotag/video_extractors/native.py +9 -31
- mapillary_tools/history.py +4 -1
- mapillary_tools/process_geotag_properties.py +16 -8
- mapillary_tools/process_sequence_properties.py +9 -11
- mapillary_tools/sample_video.py +7 -6
- mapillary_tools/serializer/description.py +587 -0
- mapillary_tools/serializer/gpx.py +132 -0
- mapillary_tools/types.py +44 -610
- mapillary_tools/upload.py +176 -197
- mapillary_tools/upload_api_v4.py +94 -51
- mapillary_tools/uploader.py +284 -138
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.0b1.dist-info}/METADATA +87 -31
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.0b1.dist-info}/RECORD +38 -35
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.0b1.dist-info}/WHEEL +1 -1
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.0b1.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.0b1.dist-info}/licenses/LICENSE +0 -0
- {mapillary_tools-0.14.0a2.dist-info → mapillary_tools-0.14.0b1.dist-info}/top_level.txt +0 -0
mapillary_tools/ffmpeg.py
CHANGED
|
@@ -13,8 +13,7 @@ import typing as T
|
|
|
13
13
|
from pathlib import Path
|
|
14
14
|
|
|
15
15
|
LOG = logging.getLogger(__name__)
|
|
16
|
-
|
|
17
|
-
NA_STREAM_IDX = "NA"
|
|
16
|
+
_MAX_STDERR_LENGTH = 2048
|
|
18
17
|
|
|
19
18
|
|
|
20
19
|
class StreamTag(T.TypedDict):
|
|
@@ -31,6 +30,9 @@ class Stream(T.TypedDict):
|
|
|
31
30
|
index: int
|
|
32
31
|
tags: StreamTag
|
|
33
32
|
width: int
|
|
33
|
+
r_frame_rate: str
|
|
34
|
+
avg_frame_rate: str
|
|
35
|
+
nb_frames: str
|
|
34
36
|
|
|
35
37
|
|
|
36
38
|
class ProbeOutput(T.TypedDict):
|
|
@@ -41,9 +43,6 @@ class FFmpegNotFoundError(Exception):
|
|
|
41
43
|
pass
|
|
42
44
|
|
|
43
45
|
|
|
44
|
-
_MAX_STDERR_LENGTH = 2048
|
|
45
|
-
|
|
46
|
-
|
|
47
46
|
def _truncate_begin(s: str) -> str:
|
|
48
47
|
if _MAX_STDERR_LENGTH < len(s):
|
|
49
48
|
return "..." + s[-_MAX_STDERR_LENGTH:]
|
|
@@ -74,6 +73,8 @@ class FFmpegCalledProcessError(Exception):
|
|
|
74
73
|
|
|
75
74
|
|
|
76
75
|
class FFMPEG:
|
|
76
|
+
FRAME_EXT = ".jpg"
|
|
77
|
+
|
|
77
78
|
def __init__(
|
|
78
79
|
self,
|
|
79
80
|
ffmpeg_path: str = "ffmpeg",
|
|
@@ -81,71 +82,33 @@ class FFMPEG:
|
|
|
81
82
|
stderr: int | None = None,
|
|
82
83
|
) -> None:
|
|
83
84
|
"""
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
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
|
|
87
92
|
"""
|
|
88
93
|
self.ffmpeg_path = ffmpeg_path
|
|
89
94
|
self.ffprobe_path = ffprobe_path
|
|
90
95
|
self.stderr = stderr
|
|
91
96
|
|
|
92
|
-
def
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
try:
|
|
96
|
-
completed = subprocess.run(
|
|
97
|
-
full_cmd,
|
|
98
|
-
check=True,
|
|
99
|
-
stdout=subprocess.PIPE,
|
|
100
|
-
stderr=self.stderr,
|
|
101
|
-
)
|
|
102
|
-
except FileNotFoundError:
|
|
103
|
-
raise FFmpegNotFoundError(
|
|
104
|
-
f'The ffprobe command "{self.ffprobe_path}" not found'
|
|
105
|
-
)
|
|
106
|
-
except subprocess.CalledProcessError as ex:
|
|
107
|
-
raise FFmpegCalledProcessError(ex) from ex
|
|
108
|
-
|
|
109
|
-
try:
|
|
110
|
-
stdout = completed.stdout.decode("utf-8")
|
|
111
|
-
except UnicodeDecodeError:
|
|
112
|
-
raise RuntimeError(
|
|
113
|
-
f"Error decoding ffprobe output as unicode: {_truncate_end(str(completed.stdout))}"
|
|
114
|
-
)
|
|
115
|
-
|
|
116
|
-
try:
|
|
117
|
-
output = json.loads(stdout)
|
|
118
|
-
except json.JSONDecodeError:
|
|
119
|
-
raise RuntimeError(
|
|
120
|
-
f"Error JSON decoding ffprobe output: {_truncate_end(stdout)}"
|
|
121
|
-
)
|
|
97
|
+
def probe_format_and_streams(self, video_path: Path) -> ProbeOutput:
|
|
98
|
+
"""
|
|
99
|
+
Probe video file to extract format and stream information using ffprobe.
|
|
122
100
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
# you will get exit code == 0 with the following stdout and stderr:
|
|
126
|
-
# {
|
|
127
|
-
# }
|
|
128
|
-
# not_exists: No such file or directory
|
|
129
|
-
if not output:
|
|
130
|
-
raise RuntimeError(
|
|
131
|
-
f"Empty JSON ffprobe output with STDERR: {_truncate_begin(str(completed.stderr))}"
|
|
132
|
-
)
|
|
101
|
+
Args:
|
|
102
|
+
video_path: Path to the video file to probe
|
|
133
103
|
|
|
134
|
-
|
|
104
|
+
Returns:
|
|
105
|
+
Dictionary containing streams and format information from ffprobe
|
|
135
106
|
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
except FileNotFoundError:
|
|
142
|
-
raise FFmpegNotFoundError(
|
|
143
|
-
f'The ffmpeg command "{self.ffmpeg_path}" not found'
|
|
144
|
-
)
|
|
145
|
-
except subprocess.CalledProcessError as ex:
|
|
146
|
-
raise FFmpegCalledProcessError(ex) from ex
|
|
147
|
-
|
|
148
|
-
def probe_format_and_streams(self, video_path: Path) -> ProbeOutput:
|
|
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
|
+
"""
|
|
149
112
|
cmd: list[str] = [
|
|
150
113
|
"-hide_banner",
|
|
151
114
|
"-show_format",
|
|
@@ -154,49 +117,79 @@ class FFMPEG:
|
|
|
154
117
|
]
|
|
155
118
|
return T.cast(ProbeOutput, self._run_ffprobe_json(cmd))
|
|
156
119
|
|
|
157
|
-
def
|
|
120
|
+
def extract_frames_by_interval(
|
|
158
121
|
self,
|
|
159
122
|
video_path: Path,
|
|
160
123
|
sample_dir: Path,
|
|
161
124
|
sample_interval: float,
|
|
162
|
-
|
|
125
|
+
stream_specifier: int | str = "v",
|
|
163
126
|
) -> None:
|
|
164
127
|
"""
|
|
165
|
-
Extract frames
|
|
166
|
-
|
|
167
|
-
|
|
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
|
|
168
141
|
"""
|
|
142
|
+
self._validate_stream_specifier(stream_specifier)
|
|
143
|
+
|
|
169
144
|
sample_prefix = sample_dir.joinpath(video_path.stem)
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
ouput_template = f"{sample_prefix}_{stream_idx}_%06d{FRAME_EXT}"
|
|
173
|
-
stream_specifier = f"{stream_idx}"
|
|
174
|
-
else:
|
|
175
|
-
stream_selector = []
|
|
176
|
-
ouput_template = f"{sample_prefix}_{NA_STREAM_IDX}_%06d{FRAME_EXT}"
|
|
177
|
-
stream_specifier = "v"
|
|
145
|
+
stream_selector = ["-map", f"0:{stream_specifier}"]
|
|
146
|
+
output_template = f"{sample_prefix}_{stream_specifier}_%06d{self.FRAME_EXT}"
|
|
178
147
|
|
|
179
148
|
cmd: list[str] = [
|
|
180
|
-
#
|
|
181
|
-
*["-hide_banner"
|
|
182
|
-
#
|
|
149
|
+
# Global options should be specified first
|
|
150
|
+
*["-hide_banner"],
|
|
151
|
+
# Input 0
|
|
183
152
|
*["-i", str(video_path)],
|
|
184
|
-
#
|
|
153
|
+
# Select stream
|
|
185
154
|
*stream_selector,
|
|
186
|
-
#
|
|
155
|
+
# Filter videos
|
|
187
156
|
*["-vf", f"fps=1/{sample_interval}"],
|
|
188
|
-
#
|
|
189
|
-
*[f"-qscale:{stream_specifier}", "2"],
|
|
157
|
+
# Video quality level (or the alias -q:v)
|
|
190
158
|
# -q:v=1 is the best quality but larger image sizes
|
|
191
159
|
# see https://stackoverflow.com/a/10234065
|
|
192
160
|
# *["-qscale:v", "1", "-qmin", "1"],
|
|
193
|
-
|
|
194
|
-
|
|
161
|
+
*["-qscale:v", "2"],
|
|
162
|
+
# Output
|
|
163
|
+
output_template,
|
|
195
164
|
]
|
|
196
165
|
|
|
197
|
-
self.
|
|
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
|
+
"""
|
|
198
192
|
|
|
199
|
-
def generate_binary_search(self, sorted_frame_indices: list[int]) -> str:
|
|
200
193
|
length = len(sorted_frame_indices)
|
|
201
194
|
|
|
202
195
|
if length == 0:
|
|
@@ -205,39 +198,50 @@ class FFMPEG:
|
|
|
205
198
|
if length == 1:
|
|
206
199
|
return f"eq(n\\,{sorted_frame_indices[0]})"
|
|
207
200
|
|
|
208
|
-
|
|
209
|
-
|
|
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})"
|
|
210
206
|
|
|
211
207
|
def extract_specified_frames(
|
|
212
208
|
self,
|
|
213
209
|
video_path: Path,
|
|
214
210
|
sample_dir: Path,
|
|
215
211
|
frame_indices: set[int],
|
|
216
|
-
|
|
212
|
+
stream_specifier: int | str = "v",
|
|
217
213
|
) -> None:
|
|
218
214
|
"""
|
|
219
|
-
Extract
|
|
220
|
-
|
|
221
|
-
|
|
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.
|
|
222
235
|
"""
|
|
223
236
|
|
|
237
|
+
self._validate_stream_specifier(stream_specifier)
|
|
238
|
+
|
|
224
239
|
if not frame_indices:
|
|
225
240
|
return
|
|
226
241
|
|
|
227
242
|
sample_prefix = sample_dir.joinpath(video_path.stem)
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
ouput_template = f"{sample_prefix}_{stream_idx}_%06d{FRAME_EXT}"
|
|
231
|
-
stream_specifier = f"{stream_idx}"
|
|
232
|
-
else:
|
|
233
|
-
stream_selector = []
|
|
234
|
-
ouput_template = f"{sample_prefix}_{NA_STREAM_IDX}_%06d{FRAME_EXT}"
|
|
235
|
-
stream_specifier = "v"
|
|
236
|
-
|
|
237
|
-
# Write the select filter to a temp file because:
|
|
238
|
-
# The select filter could be large and
|
|
239
|
-
# the maximum command line length for the CreateProcess function is 32767 characters
|
|
240
|
-
# 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}"
|
|
241
245
|
|
|
242
246
|
eqs = self.generate_binary_search(sorted(frame_indices))
|
|
243
247
|
|
|
@@ -247,6 +251,10 @@ class FFMPEG:
|
|
|
247
251
|
else:
|
|
248
252
|
delete = True
|
|
249
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
|
|
250
258
|
with tempfile.NamedTemporaryFile(mode="w+", delete=delete) as select_file:
|
|
251
259
|
try:
|
|
252
260
|
select_file.write(f"select={eqs}")
|
|
@@ -255,13 +263,13 @@ class FFMPEG:
|
|
|
255
263
|
if not delete:
|
|
256
264
|
select_file.close()
|
|
257
265
|
cmd: list[str] = [
|
|
258
|
-
#
|
|
259
|
-
*["-hide_banner"
|
|
260
|
-
#
|
|
266
|
+
# Global options should be specified first
|
|
267
|
+
*["-hide_banner"],
|
|
268
|
+
# Input 0
|
|
261
269
|
*["-i", str(video_path)],
|
|
262
|
-
#
|
|
270
|
+
# Select stream
|
|
263
271
|
*stream_selector,
|
|
264
|
-
#
|
|
272
|
+
# Filter videos
|
|
265
273
|
*[
|
|
266
274
|
*["-filter_script:v", select_file.name],
|
|
267
275
|
# Each frame is passed with its timestamp from the demuxer to the muxer
|
|
@@ -269,8 +277,8 @@ class FFMPEG:
|
|
|
269
277
|
# vsync is deprecated by fps_mode,
|
|
270
278
|
# but fps_mode is not avaliable on some older versions ;(
|
|
271
279
|
# *[f"-fps_mode:{stream_specifier}", "passthrough"],
|
|
272
|
-
# Set the number of video frames to output
|
|
273
|
-
*[
|
|
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))],
|
|
274
282
|
# Disabled because it doesn't always name the sample images as expected
|
|
275
283
|
# For example "select(n\,1)" we expected the first sample to be IMG_001.JPG
|
|
276
284
|
# but it could be IMG_005.JPG
|
|
@@ -278,15 +286,15 @@ class FFMPEG:
|
|
|
278
286
|
# If set to 1, expand the filename with pts from pkt->pts. Default value is 0.
|
|
279
287
|
# *["-frame_pts", "1"],
|
|
280
288
|
],
|
|
281
|
-
#
|
|
282
|
-
*[f"-qscale:{stream_specifier}", "2"],
|
|
289
|
+
# Video quality level (or the alias -q:v)
|
|
283
290
|
# -q:v=1 is the best quality but larger image sizes
|
|
284
291
|
# see https://stackoverflow.com/a/10234065
|
|
285
292
|
# *["-qscale:v", "1", "-qmin", "1"],
|
|
293
|
+
*["-qscale:v", "2"],
|
|
286
294
|
# output
|
|
287
|
-
|
|
295
|
+
output_template,
|
|
288
296
|
]
|
|
289
|
-
self.
|
|
297
|
+
self.run_ffmpeg_non_interactive(cmd)
|
|
290
298
|
finally:
|
|
291
299
|
if not delete:
|
|
292
300
|
try:
|
|
@@ -294,45 +302,286 @@ class FFMPEG:
|
|
|
294
302
|
except FileNotFoundError:
|
|
295
303
|
pass
|
|
296
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
|
+
|
|
297
515
|
|
|
298
516
|
class Probe:
|
|
299
|
-
|
|
517
|
+
probe_output: ProbeOutput
|
|
518
|
+
|
|
519
|
+
def __init__(self, probe_output: ProbeOutput) -> None:
|
|
520
|
+
"""
|
|
521
|
+
Initialize Probe with ffprobe output data.
|
|
300
522
|
|
|
301
|
-
|
|
302
|
-
|
|
523
|
+
Args:
|
|
524
|
+
probe_output: Dictionary containing streams and format information from ffprobe
|
|
525
|
+
"""
|
|
526
|
+
self.probe_output = probe_output
|
|
303
527
|
|
|
304
528
|
def probe_video_start_time(self) -> datetime.datetime | None:
|
|
305
529
|
"""
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
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
|
|
535
|
+
|
|
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.
|
|
309
541
|
"""
|
|
310
|
-
streams = self.
|
|
542
|
+
streams = self.probe_output.get("streams", [])
|
|
311
543
|
|
|
312
|
-
#
|
|
544
|
+
# Search start time from video streams
|
|
313
545
|
video_streams = self.probe_video_streams()
|
|
314
546
|
video_streams.sort(
|
|
315
547
|
key=lambda s: s.get("width", 0) * s.get("height", 0), reverse=True
|
|
316
548
|
)
|
|
317
549
|
for stream in video_streams:
|
|
318
|
-
start_time = extract_stream_start_time(stream)
|
|
550
|
+
start_time = self.extract_stream_start_time(stream)
|
|
319
551
|
if start_time is not None:
|
|
320
552
|
return start_time
|
|
321
553
|
|
|
322
|
-
#
|
|
554
|
+
# Search start time from the other streams
|
|
323
555
|
for stream in streams:
|
|
324
556
|
if stream.get("codec_type") != "video":
|
|
325
|
-
start_time = extract_stream_start_time(stream)
|
|
557
|
+
start_time = self.extract_stream_start_time(stream)
|
|
326
558
|
if start_time is not None:
|
|
327
559
|
return start_time
|
|
328
560
|
|
|
329
561
|
return None
|
|
330
562
|
|
|
331
563
|
def probe_video_streams(self) -> list[Stream]:
|
|
332
|
-
|
|
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", [])
|
|
333
572
|
return [stream for stream in streams if stream.get("codec_type") == "video"]
|
|
334
573
|
|
|
335
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
|
+
"""
|
|
336
585
|
video_streams = self.probe_video_streams()
|
|
337
586
|
video_streams.sort(
|
|
338
587
|
key=lambda s: s.get("width", 0) * s.get("height", 0), reverse=True
|
|
@@ -341,112 +590,37 @@ class Probe:
|
|
|
341
590
|
return None
|
|
342
591
|
return video_streams[0]
|
|
343
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.
|
|
344
597
|
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
Find the start time of the given stream.
|
|
348
|
-
Start time is the creation time of the stream minus the duration of the stream.
|
|
349
|
-
"""
|
|
350
|
-
duration_str = stream.get("duration")
|
|
351
|
-
LOG.debug("Extracted video duration: %s", duration_str)
|
|
352
|
-
if duration_str is None:
|
|
353
|
-
return None
|
|
354
|
-
duration = float(duration_str)
|
|
598
|
+
Determines start time by subtracting stream duration from creation time:
|
|
599
|
+
start_time = creation_time - duration
|
|
355
600
|
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
if creation_time_str is None:
|
|
359
|
-
return None
|
|
360
|
-
try:
|
|
361
|
-
creation_time = datetime.datetime.fromisoformat(creation_time_str)
|
|
362
|
-
except ValueError:
|
|
363
|
-
creation_time = datetime.datetime.strptime(
|
|
364
|
-
creation_time_str, "%Y-%m-%dT%H:%M:%S.%f%z"
|
|
365
|
-
)
|
|
366
|
-
return creation_time - datetime.timedelta(seconds=duration)
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def _extract_stream_frame_idx(
|
|
370
|
-
sample_basename: str,
|
|
371
|
-
sample_basename_pattern: T.Pattern[str],
|
|
372
|
-
) -> tuple[int | None, int] | None:
|
|
373
|
-
"""
|
|
374
|
-
extract stream id and frame index from sample basename
|
|
375
|
-
e.g. basename GX010001_NA_000000.jpg will extract (None, 0)
|
|
376
|
-
e.g. basename GX010001_1_000002.jpg will extract (1, 2)
|
|
377
|
-
If returning None, it means the basename does not match the pattern
|
|
378
|
-
"""
|
|
379
|
-
image_no_ext, ext = os.path.splitext(sample_basename)
|
|
380
|
-
if ext.lower() != FRAME_EXT.lower():
|
|
381
|
-
return None
|
|
601
|
+
Args:
|
|
602
|
+
stream: Stream dictionary containing metadata including tags and duration
|
|
382
603
|
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
return None
|
|
604
|
+
Returns:
|
|
605
|
+
Stream start time as datetime object, or None if required metadata is missing
|
|
386
606
|
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
# convert 0-padded numbers to int
|
|
397
|
-
# e.g. 000000 -> 0
|
|
398
|
-
# e.g. 000001 -> 1
|
|
399
|
-
g2 = match.group("frame_idx")
|
|
400
|
-
g2 = g2.lstrip("0") or "0"
|
|
401
|
-
|
|
402
|
-
try:
|
|
403
|
-
frame_idx = int(g2)
|
|
404
|
-
except ValueError:
|
|
405
|
-
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)
|
|
406
615
|
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
sample_basename_pattern = re.compile(
|
|
419
|
-
rf"^{re.escape(video_path.stem)}_(?P<stream_idx>\d+|{re.escape(NA_STREAM_IDX)})_(?P<frame_idx>\d+)$"
|
|
420
|
-
)
|
|
421
|
-
for sample_path in sample_dir.iterdir():
|
|
422
|
-
stream_frame_idx = _extract_stream_frame_idx(
|
|
423
|
-
sample_path.name,
|
|
424
|
-
sample_basename_pattern,
|
|
425
|
-
)
|
|
426
|
-
if stream_frame_idx is not None:
|
|
427
|
-
stream_idx, frame_idx = stream_frame_idx
|
|
428
|
-
yield (stream_idx, frame_idx, sample_path)
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
def sort_selected_samples(
|
|
432
|
-
sample_dir: Path, video_path: Path, selected_stream_indices: list[int | None]
|
|
433
|
-
) -> list[tuple[int, list[Path | None]]]:
|
|
434
|
-
"""
|
|
435
|
-
Group frames by frame index, so that
|
|
436
|
-
the Nth group contains all the frames from the selected streams at frame index N.
|
|
437
|
-
"""
|
|
438
|
-
stream_samples: dict[int, list[tuple[int | None, Path]]] = {}
|
|
439
|
-
for stream_idx, frame_idx, sample_path in iterate_samples(sample_dir, video_path):
|
|
440
|
-
stream_samples.setdefault(frame_idx, []).append((stream_idx, sample_path))
|
|
441
|
-
|
|
442
|
-
selected: list[tuple[int, list[Path | None]]] = []
|
|
443
|
-
for frame_idx in sorted(stream_samples.keys()):
|
|
444
|
-
indexed = {
|
|
445
|
-
stream_idx: sample_path
|
|
446
|
-
for stream_idx, sample_path in stream_samples[frame_idx]
|
|
447
|
-
}
|
|
448
|
-
selected_sample_paths = [
|
|
449
|
-
indexed.get(stream_idx) for stream_idx in selected_stream_indices
|
|
450
|
-
]
|
|
451
|
-
selected.append((frame_idx, selected_sample_paths))
|
|
452
|
-
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)
|