scripts: add append_signals.py for frame sequence to video conversion
Add Python script to concatenate frame sequences into videos using ffmpeg.
Features:
- Handles non-sequential frames with gaps in numbering
- Automatic output naming: {folder_name}_{unix_timestamp}.mp4
- Configurable FPS, width, quality (CRF), and frame limit
- Uses uv inline dependencies for easy execution
- Supports .jpeg, .jpg, and .png files
Example usage:
uv run scripts\append_signals.py results\20251122\bumpy-filter
uv run scripts\append_signals.py results\20251122\bumpy-filter --max-frames 100 --fps 60
This commit is contained in:
248
scripts/append_signals.py
Normal file
248
scripts/append_signals.py
Normal file
@@ -0,0 +1,248 @@
|
||||
#!/usr/bin/env -S uv run
|
||||
# /// script
|
||||
# requires-python = ">=3.10"
|
||||
# dependencies = []
|
||||
# ///
|
||||
|
||||
"""
|
||||
Concatenate a sequence of frames into a video using ffmpeg.
|
||||
Scales to 1920 width and allows control of video speed via fps.
|
||||
"""
|
||||
|
||||
import argparse
|
||||
import subprocess
|
||||
import sys
|
||||
import time
|
||||
from pathlib import Path
|
||||
from typing import List
|
||||
|
||||
|
||||
def find_frames(directory: Path, pattern: str = "*.jpeg") -> List[Path]:
|
||||
"""Find all frame files matching the pattern, sorted by name."""
|
||||
frames = sorted(directory.glob(pattern))
|
||||
if not frames:
|
||||
frames = sorted(directory.glob("*.jpg"))
|
||||
if not frames:
|
||||
frames = sorted(directory.glob("*.png"))
|
||||
return frames
|
||||
|
||||
|
||||
def create_video_ffmpeg(
|
||||
input_dir: Path,
|
||||
output_file: Path,
|
||||
fps: int = 30,
|
||||
width: int = 1920,
|
||||
crf: int = 18,
|
||||
pattern: str = "*.jpeg",
|
||||
max_frames: int = None
|
||||
) -> None:
|
||||
"""
|
||||
Create video from image sequence using ffmpeg.
|
||||
|
||||
Args:
|
||||
input_dir: Directory containing the frames
|
||||
output_file: Output video file path
|
||||
fps: Frames per second (controls playback speed)
|
||||
width: Output video width in pixels (height auto-scales)
|
||||
crf: Constant Rate Factor for quality (lower = better, 18-23 recommended)
|
||||
pattern: File pattern to match frame files
|
||||
"""
|
||||
frames = find_frames(input_dir, pattern)
|
||||
|
||||
if not frames:
|
||||
print(f"Error: No frames found in {input_dir} matching pattern {pattern}")
|
||||
sys.exit(1)
|
||||
|
||||
total_frames = len(frames)
|
||||
|
||||
# Limit frames if max_frames is specified
|
||||
if max_frames is not None and max_frames > 0:
|
||||
frames = frames[:max_frames]
|
||||
print(f"Found {total_frames} frames in {input_dir}, processing first {len(frames)} frames")
|
||||
else:
|
||||
print(f"Found {len(frames)} frames in {input_dir}")
|
||||
print(f"First frame: {frames[0].name}")
|
||||
print(f"Last frame: {frames[-1].name}")
|
||||
|
||||
# Check if frames are sequential (no gaps)
|
||||
first_frame = frames[0]
|
||||
is_sequential = True
|
||||
|
||||
# Extract frame numbers and check for gaps
|
||||
name_parts = first_frame.stem.rsplit('_', 1)
|
||||
if len(name_parts) == 2 and name_parts[1].isdigit():
|
||||
frame_numbers = []
|
||||
for frame in frames:
|
||||
parts = frame.stem.rsplit('_', 1)
|
||||
if len(parts) == 2 and parts[1].isdigit():
|
||||
frame_numbers.append(int(parts[1]))
|
||||
else:
|
||||
is_sequential = False
|
||||
break
|
||||
|
||||
# Check if there are gaps in the sequence
|
||||
if is_sequential and frame_numbers:
|
||||
expected_count = frame_numbers[-1] - frame_numbers[0] + 1
|
||||
if len(frame_numbers) != expected_count:
|
||||
is_sequential = False
|
||||
print(f"Warning: Detected gaps in frame sequence (have {len(frame_numbers)} frames, expected {expected_count})")
|
||||
else:
|
||||
is_sequential = False
|
||||
|
||||
# Use concat method for reliability (works with gaps, non-sequential naming, etc.)
|
||||
# Create concat file
|
||||
concat_file = input_dir / "concat_list.txt"
|
||||
with open(concat_file, 'w') as f:
|
||||
for frame in frames:
|
||||
# Use absolute paths to avoid issues
|
||||
f.write(f"file '{frame.absolute()}'\n")
|
||||
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-y",
|
||||
"-f", "concat",
|
||||
"-safe", "0",
|
||||
"-i", str(concat_file),
|
||||
"-vf", f"scale={width}:-2,fps={fps}",
|
||||
"-c:v", "libx264",
|
||||
"-crf", str(crf),
|
||||
"-pix_fmt", "yuv420p",
|
||||
"-movflags", "+faststart",
|
||||
str(output_file)
|
||||
]
|
||||
|
||||
print(f"\nCreating video with:")
|
||||
print(f" FPS: {fps}")
|
||||
print(f" Width: {width}px")
|
||||
print(f" CRF (quality): {crf}")
|
||||
print(f" Output: {output_file}")
|
||||
print(f"\nRunning: {' '.join(cmd)}\n")
|
||||
|
||||
try:
|
||||
result = subprocess.run(cmd, check=True)
|
||||
print(f"\n✓ Video created successfully: {output_file}")
|
||||
|
||||
# Show file size
|
||||
size_mb = output_file.stat().st_size / (1024 * 1024)
|
||||
print(f" File size: {size_mb:.2f} MB")
|
||||
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error: ffmpeg command failed with return code {e.returncode}")
|
||||
sys.exit(1)
|
||||
except FileNotFoundError:
|
||||
print("Error: ffmpeg not found. Please install ffmpeg and ensure it's in your PATH.")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(
|
||||
description="Concatenate frame sequences into a video using ffmpeg",
|
||||
formatter_class=argparse.RawDescriptionHelpFormatter,
|
||||
epilog="""
|
||||
Examples:
|
||||
# Basic usage with default settings (30 fps, 1920 width)
|
||||
python append_signals.py C:\\dev\\gst-plugins-vision\\results\\20251122\\bumpy-filter
|
||||
|
||||
# Slow motion (10 fps)
|
||||
python append_signals.py C:\\dev\\gst-plugins-vision\\results\\20251122\\bumpy-filter --fps 10
|
||||
|
||||
# Fast playback (60 fps) with custom output name
|
||||
python append_signals.py results/20251122/bumpy-filter --fps 60 -o timelapse.mp4
|
||||
|
||||
# Higher quality (lower CRF)
|
||||
python append_signals.py results/20251122/bumpy-filter --crf 15
|
||||
|
||||
# Custom width
|
||||
python append_signals.py results/20251122/bumpy-filter --width 3840
|
||||
|
||||
# Process only first 100 frames for testing
|
||||
python append_signals.py results/20251122/bumpy-filter --max-frames 100
|
||||
"""
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"input_dir",
|
||||
type=Path,
|
||||
help="Directory containing the frame sequence"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"-o", "--output",
|
||||
type=Path,
|
||||
help="Output video file (default: output.mp4 in input directory)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--fps",
|
||||
type=int,
|
||||
default=30,
|
||||
help="Output video framerate/playback speed (default: 30)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--width",
|
||||
type=int,
|
||||
default=1920,
|
||||
help="Output video width in pixels (default: 1920)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--crf",
|
||||
type=int,
|
||||
default=18,
|
||||
help="Constant Rate Factor for quality, lower=better (default: 18, range: 0-51)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--pattern",
|
||||
type=str,
|
||||
default="*.jpeg",
|
||||
help="File pattern to match frames (default: *.jpeg)"
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--max-frames",
|
||||
type=int,
|
||||
default=None,
|
||||
help="Maximum number of frames to process (default: all frames)"
|
||||
)
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
# Validate input directory
|
||||
if not args.input_dir.exists():
|
||||
print(f"Error: Input directory does not exist: {args.input_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
if not args.input_dir.is_dir():
|
||||
print(f"Error: Input path is not a directory: {args.input_dir}")
|
||||
sys.exit(1)
|
||||
|
||||
# Set output file
|
||||
if args.output is None:
|
||||
# Generate filename: input_folder_name_unixtime.mp4
|
||||
unix_time = int(time.time())
|
||||
folder_name = args.input_dir.name
|
||||
output_filename = f"{folder_name}_{unix_time}.mp4"
|
||||
output_file = args.input_dir / output_filename
|
||||
else:
|
||||
output_file = args.output
|
||||
|
||||
# Validate CRF range
|
||||
if not 0 <= args.crf <= 51:
|
||||
print("Error: CRF must be between 0 and 51")
|
||||
sys.exit(1)
|
||||
|
||||
create_video_ffmpeg(
|
||||
input_dir=args.input_dir,
|
||||
output_file=output_file,
|
||||
fps=args.fps,
|
||||
width=args.width,
|
||||
crf=args.crf,
|
||||
pattern=args.pattern,
|
||||
max_frames=args.max_frames
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
Reference in New Issue
Block a user