mapillary-tools 0.14.0a1__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 +5 -4
- mapillary_tools/authenticate.py +9 -9
- mapillary_tools/blackvue_parser.py +79 -22
- mapillary_tools/camm/camm_parser.py +5 -5
- mapillary_tools/commands/__main__.py +1 -2
- mapillary_tools/config.py +41 -18
- mapillary_tools/constants.py +3 -2
- mapillary_tools/exceptions.py +1 -1
- mapillary_tools/exif_read.py +65 -65
- mapillary_tools/exif_write.py +7 -7
- mapillary_tools/exiftool_read.py +23 -46
- mapillary_tools/exiftool_read_video.py +88 -49
- mapillary_tools/exiftool_runner.py +4 -24
- mapillary_tools/ffmpeg.py +417 -242
- mapillary_tools/geo.py +4 -21
- mapillary_tools/geotag/__init__.py +0 -1
- mapillary_tools/geotag/{geotag_from_generic.py → base.py} +34 -50
- mapillary_tools/geotag/factory.py +105 -103
- mapillary_tools/geotag/geotag_images_from_exif.py +15 -51
- mapillary_tools/geotag/geotag_images_from_exiftool.py +118 -63
- mapillary_tools/geotag/geotag_images_from_gpx.py +33 -16
- mapillary_tools/geotag/geotag_images_from_gpx_file.py +2 -34
- mapillary_tools/geotag/geotag_images_from_nmea_file.py +0 -3
- mapillary_tools/geotag/geotag_images_from_video.py +51 -14
- mapillary_tools/geotag/geotag_videos_from_exiftool.py +123 -0
- mapillary_tools/geotag/geotag_videos_from_gpx.py +35 -123
- mapillary_tools/geotag/geotag_videos_from_video.py +14 -147
- mapillary_tools/geotag/image_extractors/base.py +18 -0
- mapillary_tools/geotag/image_extractors/exif.py +60 -0
- mapillary_tools/geotag/image_extractors/exiftool.py +18 -0
- mapillary_tools/geotag/options.py +26 -3
- mapillary_tools/geotag/utils.py +62 -0
- mapillary_tools/geotag/video_extractors/base.py +18 -0
- mapillary_tools/geotag/video_extractors/exiftool.py +70 -0
- mapillary_tools/geotag/video_extractors/gpx.py +116 -0
- mapillary_tools/geotag/video_extractors/native.py +135 -0
- mapillary_tools/gpmf/gpmf_parser.py +16 -16
- mapillary_tools/gpmf/gps_filter.py +5 -3
- mapillary_tools/history.py +8 -3
- mapillary_tools/mp4/construct_mp4_parser.py +9 -8
- mapillary_tools/mp4/mp4_sample_parser.py +27 -27
- mapillary_tools/mp4/simple_mp4_builder.py +10 -9
- mapillary_tools/mp4/simple_mp4_parser.py +13 -12
- mapillary_tools/process_geotag_properties.py +21 -15
- mapillary_tools/process_sequence_properties.py +49 -49
- mapillary_tools/sample_video.py +15 -14
- mapillary_tools/serializer/description.py +587 -0
- mapillary_tools/serializer/gpx.py +132 -0
- mapillary_tools/telemetry.py +6 -5
- mapillary_tools/types.py +64 -635
- mapillary_tools/upload.py +176 -197
- mapillary_tools/upload_api_v4.py +94 -51
- mapillary_tools/uploader.py +284 -138
- mapillary_tools/utils.py +16 -18
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/METADATA +87 -31
- mapillary_tools-0.14.0b1.dist-info/RECORD +75 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/WHEEL +1 -1
- mapillary_tools/geotag/geotag_images_from_exiftool_both_image_and_video.py +0 -77
- mapillary_tools/geotag/geotag_videos_from_exiftool_video.py +0 -151
- mapillary_tools/video_data_extraction/cli_options.py +0 -22
- mapillary_tools/video_data_extraction/extract_video_data.py +0 -157
- mapillary_tools/video_data_extraction/extractors/base_parser.py +0 -75
- mapillary_tools/video_data_extraction/extractors/blackvue_parser.py +0 -49
- mapillary_tools/video_data_extraction/extractors/camm_parser.py +0 -62
- mapillary_tools/video_data_extraction/extractors/exiftool_runtime_parser.py +0 -74
- mapillary_tools/video_data_extraction/extractors/exiftool_xml_parser.py +0 -52
- mapillary_tools/video_data_extraction/extractors/generic_video_parser.py +0 -52
- mapillary_tools/video_data_extraction/extractors/gopro_parser.py +0 -58
- mapillary_tools/video_data_extraction/extractors/gpx_parser.py +0 -108
- mapillary_tools/video_data_extraction/extractors/nmea_parser.py +0 -24
- mapillary_tools/video_data_extraction/video_data_parser_factory.py +0 -39
- mapillary_tools-0.14.0a1.dist-info/RECORD +0 -78
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/entry_points.txt +0 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.dist-info}/licenses/LICENSE +0 -0
- {mapillary_tools-0.14.0a1.dist-info → mapillary_tools-0.14.0b1.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
|
-
|
|
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:
|
|
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:
|
|
82
|
+
stderr: int | None = None,
|
|
81
83
|
) -> None:
|
|
82
84
|
"""
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
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
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
101
|
+
Args:
|
|
102
|
+
video_path: Path to the video file to probe
|
|
134
103
|
|
|
135
|
-
|
|
136
|
-
|
|
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
|
-
|
|
148
|
-
|
|
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
|
|
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
|
-
|
|
125
|
+
stream_specifier: int | str = "v",
|
|
162
126
|
) -> None:
|
|
163
127
|
"""
|
|
164
|
-
Extract frames
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
#
|
|
153
|
+
# Select stream
|
|
184
154
|
*stream_selector,
|
|
185
|
-
#
|
|
155
|
+
# Filter videos
|
|
186
156
|
*["-vf", f"fps=1/{sample_interval}"],
|
|
187
|
-
#
|
|
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
|
-
|
|
193
|
-
|
|
161
|
+
*["-qscale:v", "2"],
|
|
162
|
+
# Output
|
|
163
|
+
output_template,
|
|
194
164
|
]
|
|
195
165
|
|
|
196
|
-
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
|
+
"""
|
|
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
|
-
|
|
208
|
-
|
|
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:
|
|
215
|
-
|
|
211
|
+
frame_indices: set[int],
|
|
212
|
+
stream_specifier: int | str = "v",
|
|
216
213
|
) -> None:
|
|
217
214
|
"""
|
|
218
|
-
Extract
|
|
219
|
-
|
|
220
|
-
|
|
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
|
-
|
|
228
|
-
|
|
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:
|
|
257
|
-
#
|
|
258
|
-
*["-hide_banner"
|
|
259
|
-
#
|
|
265
|
+
cmd: list[str] = [
|
|
266
|
+
# Global options should be specified first
|
|
267
|
+
*["-hide_banner"],
|
|
268
|
+
# Input 0
|
|
260
269
|
*["-i", str(video_path)],
|
|
261
|
-
#
|
|
270
|
+
# Select stream
|
|
262
271
|
*stream_selector,
|
|
263
|
-
#
|
|
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
|
-
*[
|
|
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
|
-
#
|
|
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
|
-
|
|
295
|
+
output_template,
|
|
287
296
|
]
|
|
288
|
-
self.
|
|
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
|
-
|
|
517
|
+
probe_output: ProbeOutput
|
|
299
518
|
|
|
300
|
-
def __init__(self,
|
|
301
|
-
|
|
519
|
+
def __init__(self, probe_output: ProbeOutput) -> None:
|
|
520
|
+
"""
|
|
521
|
+
Initialize Probe with ffprobe output data.
|
|
302
522
|
|
|
303
|
-
|
|
523
|
+
Args:
|
|
524
|
+
probe_output: Dictionary containing streams and format information from ffprobe
|
|
304
525
|
"""
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
526
|
+
self.probe_output = probe_output
|
|
527
|
+
|
|
528
|
+
def probe_video_start_time(self) -> datetime.datetime | None:
|
|
308
529
|
"""
|
|
309
|
-
|
|
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
|
-
|
|
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
|
-
#
|
|
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) ->
|
|
331
|
-
|
|
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) ->
|
|
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
|
-
|
|
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
|
-
|
|
356
|
-
|
|
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
|
-
|
|
383
|
-
|
|
384
|
-
return None
|
|
604
|
+
Returns:
|
|
605
|
+
Stream start time as datetime object, or None if required metadata is missing
|
|
385
606
|
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
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
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
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)
|