Implement Cv2BufferedCap for improved video frame handling

This commit introduces the Cv2BufferedCap class, which optimizes video frame loading, seeking, and caching. The MediaGrader class has been updated to utilize this new class, enhancing frame accuracy and playback performance. Additionally, configuration constants have been adjusted for better playback speed control, and redundant backend handling has been removed to streamline video loading. Overall, these changes improve the efficiency and reliability of video playback in the application.
This commit is contained in:
2025-09-19 18:23:15 +02:00
parent ea1a6e58f4
commit 4a1649a568

234
main.py
View File

@@ -7,23 +7,74 @@ import argparse
import shutil
import time
import threading
import subprocess
import json
from concurrent.futures import ThreadPoolExecutor
from pathlib import Path
from typing import List
class Cv2BufferedCap:
"""Buffered wrapper around cv2.VideoCapture that handles frame loading, seeking, and caching correctly"""
def __init__(self, video_path, backend=None):
self.video_path = video_path
self.cap = cv2.VideoCapture(str(video_path), backend)
if not self.cap.isOpened():
raise ValueError(f"Could not open video: {video_path}")
# Video properties
self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT))
self.fps = self.cap.get(cv2.CAP_PROP_FPS)
self.frame_width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH))
self.frame_height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
# Current position tracking
self.current_frame = 0
def get_frame(self, frame_number):
"""Get frame at specific index - always accurate"""
# Clamp frame number to valid range
frame_number = max(0, min(frame_number, self.total_frames - 1))
# Optimize for sequential reading (next frame)
if frame_number == self.current_frame + 1:
ret, frame = self.cap.read()
else:
# Seek for non-sequential access
self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)
ret, frame = self.cap.read()
if ret:
self.current_frame = frame_number
return frame
else:
raise ValueError(f"Failed to read frame {frame_number}")
def advance_frame(self, frames=1):
"""Advance by specified number of frames"""
new_frame = self.current_frame + frames
return self.get_frame(new_frame)
def release(self):
"""Release the video capture"""
if self.cap:
self.cap.release()
def isOpened(self):
"""Check if capture is opened"""
return self.cap and self.cap.isOpened()
class MediaGrader:
BASE_FRAME_DELAY_MS = 16
# Configuration constants - matching croppa implementation
TARGET_FPS = 80 # Target FPS for speed calculations
SPEED_INCREMENT = 0.1
MIN_PLAYBACK_SPEED = 0.05
MAX_PLAYBACK_SPEED = 1.0
# Legacy constants for compatibility
KEY_REPEAT_RATE_SEC = 0.5
FAST_SEEK_ACTIVATION_TIME = 2.0
FRAME_RENDER_TIME_MS = 50
SPEED_INCREMENT = 0.2
MIN_PLAYBACK_SPEED = 0.1
MAX_PLAYBACK_SPEED = 100.0
FAST_SEEK_MULTIPLIER = 60
IMAGE_DISPLAY_DELAY_MS = 100
MONITOR_WIDTH = 2560
MONITOR_HEIGHT = 1440
@@ -158,19 +209,18 @@ class MediaGrader:
def calculate_frame_delay(self) -> int:
"""Calculate frame delay in milliseconds based on playback speed"""
delay_ms = int(self.BASE_FRAME_DELAY_MS / self.playback_speed)
return max(1, delay_ms)
def calculate_frames_to_skip(self) -> int:
"""Calculate how many frames to skip for high-speed playback"""
if self.playback_speed <= 1.0:
return 0
elif self.playback_speed <= 2.0:
return 0
elif self.playback_speed <= 5.0:
return int(self.playback_speed - 1)
# Round to 2 decimals to handle floating point precision issues
speed = round(self.playback_speed, 2)
if speed >= 1.0:
# Speed >= 1: maximum FPS (no delay)
return 1
else:
return int(self.playback_speed * 2)
# Speed < 1: scale FPS based on speed
# Formula: fps = TARGET_FPS * speed, so delay = 1000 / fps
target_fps = self.TARGET_FPS * speed
delay_ms = int(1000 / target_fps)
return max(1, delay_ms)
def load_media(self, file_path: Path) -> bool:
"""Load media file for display"""
@@ -178,43 +228,17 @@ class MediaGrader:
self.current_cap.release()
if self.is_video(file_path):
# Try different backends for better performance
# For video files: FFmpeg is usually best, DirectShow is for cameras
backends_to_try = []
if hasattr(cv2, 'CAP_FFMPEG'): # FFmpeg - best for video files
backends_to_try.append(cv2.CAP_FFMPEG)
if hasattr(cv2, 'CAP_DSHOW'): # DirectShow - usually for cameras, but try as fallback
backends_to_try.append(cv2.CAP_DSHOW)
backends_to_try.append(cv2.CAP_ANY) # Final fallback
self.current_cap = None
for backend in backends_to_try:
try:
self.current_cap = cv2.VideoCapture(str(file_path), backend)
if self.current_cap.isOpened():
# Optimize buffer settings for better performance
self.current_cap.set(cv2.CAP_PROP_BUFFERSIZE, 1) # Minimize buffer to reduce latency
# Try to set hardware acceleration if available
if hasattr(cv2, 'CAP_PROP_HW_ACCELERATION'):
self.current_cap.set(cv2.CAP_PROP_HW_ACCELERATION, cv2.VIDEO_ACCELERATION_ANY)
break
self.current_cap.release()
except:
continue
if not self.current_cap or not self.current_cap.isOpened():
print(f"Warning: Could not open video file {file_path.name} (unsupported codec)")
return False
try:
# Use Cv2BufferedCap for better frame handling
self.current_cap = Cv2BufferedCap(file_path)
self.total_frames = self.current_cap.total_frames
self.current_frame = 0
self.total_frames = int(self.current_cap.get(cv2.CAP_PROP_FRAME_COUNT))
self.current_frame = 0
# Get codec information for debugging
fourcc = int(self.current_cap.get(cv2.CAP_PROP_FOURCC))
codec = "".join([chr((fourcc >> 8 * i) & 0xFF) for i in range(4)])
backend = self.current_cap.getBackendName()
print(f"Loaded: {file_path.name} | Codec: {codec} | Backend: {backend} | Frames: {self.total_frames}")
print(f"Loaded: {file_path.name} | Frames: {self.total_frames} | FPS: {self.current_cap.fps:.2f}")
except Exception as e:
print(f"Warning: Could not open video file {file_path.name}: {e}")
return False
else:
self.current_cap = None
@@ -235,12 +259,13 @@ class MediaGrader:
if not self.current_cap:
return False
ret, frame = self.current_cap.read()
if ret:
self.current_display_frame = frame
self.current_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
try:
# Use Cv2BufferedCap to get frame
self.current_display_frame = self.current_cap.get_frame(self.current_frame)
return True
return False
except Exception as e:
print(f"Failed to load frame {self.current_frame}: {e}")
return False
else:
frame = cv2.imread(str(self.media_files[self.current_index]))
if frame is not None:
@@ -904,38 +929,30 @@ class MediaGrader:
target_frame = max(0, min(target_frame, self.total_frames - 1))
# Seek to target frame
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
self.current_frame = target_frame
self.load_current_frame()
def advance_frame(self):
"""Advance to next frame(s) based on playback speed"""
if (
not self.is_video(self.media_files[self.current_index])
or not self.is_playing
):
return
"""Advance to next frame - handles playback speed and marker looping"""
if not self.is_playing:
return True
if self.multi_segment_mode:
self.update_segment_frames()
return True
else:
frames_to_skip = self.calculate_frames_to_skip()
# Always advance by 1 frame - speed is controlled by delay timing
new_frame = self.current_frame + 1
for _ in range(frames_to_skip + 1):
ret, frame = self.current_cap.read()
if not ret:
actual_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
if actual_frame < self.total_frames - 5:
print(f"Frame count mismatch! Reported: {self.total_frames}, Actual: {actual_frame}")
self.total_frames = actual_frame
return False
# Handle looping bounds
if new_frame >= self.total_frames:
# Loop to beginning
new_frame = 0
self.current_display_frame = frame
self.current_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
# Update current frame and load it
self.current_frame = new_frame
self.update_watch_tracking()
return True
return self.load_current_frame()
def seek_video(self, frames_delta: int):
"""Seek video by specified number of frames"""
@@ -952,7 +969,7 @@ class MediaGrader:
0, min(self.current_frame + frames_delta, self.total_frames - 1)
)
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame)
self.current_frame = target_frame
self.load_current_frame()
def process_seek_key(self, key: int) -> bool:
@@ -1165,32 +1182,42 @@ class MediaGrader:
cv2.setWindowTitle("Media Grader", window_title)
while True:
# Update display
self.display_current_frame()
if self.is_video(current_file):
if self.is_seeking:
delay = self.FRAME_RENDER_TIME_MS
else:
delay = self.calculate_frame_delay()
# Calculate appropriate delay based on playback state
if self.is_playing and self.is_video(current_file):
# Use calculated frame delay for proper playback speed
delay_ms = self.calculate_frame_delay()
else:
delay = self.IMAGE_DISPLAY_DELAY_MS
# Use minimal delay for immediate responsiveness when not playing
delay_ms = 1
# Auto advance frame when playing (videos only)
if self.is_playing and self.is_video(current_file):
self.advance_frame()
key = cv2.waitKey(delay) & 0xFF
# Key capture with appropriate delay
key = cv2.waitKey(delay_ms) & 0xFF
if key == ord("q") or key == 27:
return
elif key == ord(" "):
self.is_playing = not self.is_playing
elif key == ord("s"):
self.playback_speed = max(
self.MIN_PLAYBACK_SPEED,
self.playback_speed - self.SPEED_INCREMENT,
)
# Speed control only for videos
if self.is_video(current_file):
self.playback_speed = max(
self.MIN_PLAYBACK_SPEED,
self.playback_speed - self.SPEED_INCREMENT,
)
elif key == ord("w"):
self.playback_speed = min(
self.MAX_PLAYBACK_SPEED,
self.playback_speed + self.SPEED_INCREMENT,
)
# Speed control only for videos
if self.is_video(current_file):
self.playback_speed = min(
self.MAX_PLAYBACK_SPEED,
self.playback_speed + self.SPEED_INCREMENT,
)
elif self.process_seek_key(key):
continue
elif key == ord("n"):
@@ -1229,17 +1256,6 @@ class MediaGrader:
if self.is_seeking and self.current_seek_key is not None:
self.process_seek_key(self.current_seek_key)
if (
self.is_playing
and self.is_video(current_file)
and not self.is_seeking
):
if not self.advance_frame():
# Video reached the end, restart it instead of navigating
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, 0)
self.current_frame = 0
self.load_current_frame()
if key not in [ord("p"), ord("u"), ord("1"), ord("2"), ord("3"), ord("4"), ord("5")]:
print("Navigating to (pu12345): ", self.current_index)
self.current_index += 1