Files
gst-plugin-linescan/scripts/append_signals.py
yair 411310e4f1 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
2025-11-22 16:50:09 +02:00

248 lines
7.3 KiB
Python

#!/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()