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