|
1 | 1 | import copy |
2 | 2 | import json |
| 3 | +import logging |
3 | 4 | import re |
| 5 | +import shutil |
4 | 6 | from pathlib import Path |
5 | 7 | from typing import Literal |
6 | 8 |
|
7 | 9 | import sleap_io as sio |
8 | 10 | from sleap_io.io import coco |
| 11 | +from sleap_io.io.cli import _get_video_encoding_info, _is_ffmpeg_available |
9 | 12 | from sleap_io.io.dlc import is_dlc_file |
10 | 13 |
|
11 | 14 | _EMPTY_LABELS_ERROR_MSG = { |
|
24 | 27 |
|
25 | 28 | POSEINTERFACE_FRAME_REGEXP = r"frame-(\d+)" |
26 | 29 |
|
| 30 | +# We support sleap's MediaVideo files |
| 31 | +EXPECTED_SUFFIX = ".mp4" |
| 32 | +EXPECTED_ENCODING = { |
| 33 | + "pixelformat": "yuv420p", |
| 34 | + "codec": "h264", # codec name |
| 35 | +} |
| 36 | +REENCODING_PARAMS = { |
| 37 | + **EXPECTED_ENCODING, |
| 38 | + "codec": "libx264", # overwrite with encoder to use |
| 39 | + "crf": 25, |
| 40 | + "preset": "superfast", |
| 41 | +} |
27 | 42 |
|
28 | | -def annotations_to_coco( |
| 43 | + |
| 44 | +def annotations_to_poseinterface( |
29 | 45 | input_path: Path, |
30 | 46 | output_json_path: Path, |
31 | 47 | *, |
@@ -246,3 +262,102 @@ def _pad_integers_to_same_width(input: list[int]) -> list[str]: |
246 | 262 | width = len(str(max(input))) |
247 | 263 | padded_numbers = [str(number).zfill(width) for number in input] |
248 | 264 | return padded_numbers |
| 265 | + |
| 266 | + |
| 267 | +def video_to_poseinterface( |
| 268 | + input_video: Path | str, |
| 269 | + output_video_dir: Path | str, |
| 270 | + *, |
| 271 | + sub_id: str, |
| 272 | + ses_id: str, |
| 273 | + cam_id: str, |
| 274 | +) -> Path: |
| 275 | + """Reencode and rename video.""" |
| 276 | + # Check if ffmpeg is available |
| 277 | + _check_ffmpeg() |
| 278 | + |
| 279 | + # Compute output_video_path |
| 280 | + output_video = ( |
| 281 | + Path(output_video_dir) / f"sub-{sub_id}_ses-{ses_id}_cam-{cam_id}.mp4" |
| 282 | + ) |
| 283 | + # Ensure parent directories exist |
| 284 | + Path(output_video_dir).mkdir(parents=True, exist_ok=True) |
| 285 | + |
| 286 | + # Check if reencoding is required |
| 287 | + if not _needs_reencoding(input_video): |
| 288 | + # If not, copy file and rename |
| 289 | + shutil.copy(input_video, output_video) |
| 290 | + else: |
| 291 | + # Else, reencode video and rename |
| 292 | + _reencode_video(input_video, output_video) |
| 293 | + |
| 294 | + return output_video |
| 295 | + |
| 296 | + |
| 297 | +def _check_ffmpeg(): |
| 298 | + "Check FFMPEG availability" |
| 299 | + sio.set_default_video_plugin("ffmpeg") |
| 300 | + if not _is_ffmpeg_available(): |
| 301 | + raise RuntimeError("ffmpeg is required but not found") |
| 302 | + |
| 303 | + |
| 304 | +def _needs_reencoding(input_video_path: str | Path) -> bool: |
| 305 | + """Check if reencoding is required.""" |
| 306 | + input_video_path = Path(input_video_path) |
| 307 | + logging.info(f"Input video: {input_video_path}") |
| 308 | + |
| 309 | + # Check if suffix is mp4 |
| 310 | + if input_video_path.suffix.lower() != EXPECTED_SUFFIX: |
| 311 | + return True |
| 312 | + |
| 313 | + # Check codec and pixelformat |
| 314 | + encoding = _get_codec_pixelformat(input_video_path) |
| 315 | + if encoding != EXPECTED_ENCODING: |
| 316 | + logging.warning( |
| 317 | + f"Video encoding {encoding} does not match " |
| 318 | + f"expected {EXPECTED_ENCODING}. Please reencode " |
| 319 | + "using the `reencode_video()` function." |
| 320 | + ) |
| 321 | + return True |
| 322 | + return False |
| 323 | + |
| 324 | + |
| 325 | +def _get_codec_pixelformat(input_video_path: str | Path) -> dict: |
| 326 | + """Get video encoding parameters as dictionary. |
| 327 | +
|
| 328 | + It wraps sleap-io's _get_video_encoding_info, which |
| 329 | + uses `ffmpeg -i` to extract metadata without requiring ffprobe in PATH. |
| 330 | +
|
| 331 | + `_get_video_encoding_info` returns a VideoEncodingInfo object |
| 332 | + with attributes: |
| 333 | + codec: Video codec name (e.g., "h264", "hevc"). |
| 334 | + codec_profile: Codec profile (e.g., "Main", "High"). |
| 335 | + pixel_format: Pixel format (e.g., "yuv420p"). |
| 336 | + bitrate_kbps: Bitrate in kilobits per second. |
| 337 | + fps: Frames per second. |
| 338 | + gop_size: Group of pictures size (keyframe interval). |
| 339 | + container: Container format (e.g., "mov", "avi"). |
| 340 | +
|
| 341 | + """ |
| 342 | + info = _get_video_encoding_info(input_video_path) |
| 343 | + return { |
| 344 | + "codec": info.codec, |
| 345 | + "pixelformat": info.pixel_format, |
| 346 | + } |
| 347 | + |
| 348 | + |
| 349 | +def _reencode_video( |
| 350 | + input_video_path: str | Path, |
| 351 | + output_video_path: str | Path, |
| 352 | +) -> Path: |
| 353 | + """Reencode video to default format.""" |
| 354 | + # Read and save reencoded video |
| 355 | + video = sio.load_video(Path(input_video_path)) |
| 356 | + reencoded_video_path = sio.save_video( |
| 357 | + video, |
| 358 | + filename=output_video_path, |
| 359 | + fps=video.fps, |
| 360 | + **REENCODING_PARAMS, |
| 361 | + ) |
| 362 | + logging.info(f"Re-encoded video saved to {reencoded_video_path}") |
| 363 | + return reencoded_video_path |
0 commit comments