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

230
main.py
View File

@@ -7,23 +7,74 @@ import argparse
import shutil import shutil
import time import time
import threading import threading
import subprocess
import json
from concurrent.futures import ThreadPoolExecutor from concurrent.futures import ThreadPoolExecutor
from pathlib import Path from pathlib import Path
from typing import List 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: 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 KEY_REPEAT_RATE_SEC = 0.5
FAST_SEEK_ACTIVATION_TIME = 2.0 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 FAST_SEEK_MULTIPLIER = 60
IMAGE_DISPLAY_DELAY_MS = 100
MONITOR_WIDTH = 2560 MONITOR_WIDTH = 2560
MONITOR_HEIGHT = 1440 MONITOR_HEIGHT = 1440
@@ -158,19 +209,18 @@ class MediaGrader:
def calculate_frame_delay(self) -> int: def calculate_frame_delay(self) -> int:
"""Calculate frame delay in milliseconds based on playback speed""" """Calculate frame delay in milliseconds based on playback speed"""
delay_ms = int(self.BASE_FRAME_DELAY_MS / self.playback_speed) # Round to 2 decimals to handle floating point precision issues
return max(1, delay_ms) speed = round(self.playback_speed, 2)
if speed >= 1.0:
def calculate_frames_to_skip(self) -> int: # Speed >= 1: maximum FPS (no delay)
"""Calculate how many frames to skip for high-speed playback""" return 1
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)
else: 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: def load_media(self, file_path: Path) -> bool:
"""Load media file for display""" """Load media file for display"""
@@ -178,44 +228,18 @@ class MediaGrader:
self.current_cap.release() self.current_cap.release()
if self.is_video(file_path): if self.is_video(file_path):
# Try different backends for better performance try:
# For video files: FFmpeg is usually best, DirectShow is for cameras # Use Cv2BufferedCap for better frame handling
backends_to_try = [] self.current_cap = Cv2BufferedCap(file_path)
if hasattr(cv2, 'CAP_FFMPEG'): # FFmpeg - best for video files self.total_frames = self.current_cap.total_frames
backends_to_try.append(cv2.CAP_FFMPEG) self.current_frame = 0
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 print(f"Loaded: {file_path.name} | Frames: {self.total_frames} | FPS: {self.current_cap.fps:.2f}")
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(): except Exception as e:
print(f"Warning: Could not open video file {file_path.name} (unsupported codec)") print(f"Warning: Could not open video file {file_path.name}: {e}")
return False return False
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}")
else: else:
self.current_cap = None self.current_cap = None
self.total_frames = 1 self.total_frames = 1
@@ -235,12 +259,13 @@ class MediaGrader:
if not self.current_cap: if not self.current_cap:
return False return False
ret, frame = self.current_cap.read() try:
if ret: # Use Cv2BufferedCap to get frame
self.current_display_frame = frame self.current_display_frame = self.current_cap.get_frame(self.current_frame)
self.current_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES))
return True return True
return False except Exception as e:
print(f"Failed to load frame {self.current_frame}: {e}")
return False
else: else:
frame = cv2.imread(str(self.media_files[self.current_index])) frame = cv2.imread(str(self.media_files[self.current_index]))
if frame is not None: if frame is not None:
@@ -904,38 +929,30 @@ class MediaGrader:
target_frame = max(0, min(target_frame, self.total_frames - 1)) target_frame = max(0, min(target_frame, self.total_frames - 1))
# Seek to target frame # Seek to target frame
self.current_cap.set(cv2.CAP_PROP_POS_FRAMES, target_frame) self.current_frame = target_frame
self.load_current_frame() self.load_current_frame()
def advance_frame(self): def advance_frame(self):
"""Advance to next frame(s) based on playback speed""" """Advance to next frame - handles playback speed and marker looping"""
if ( if not self.is_playing:
not self.is_video(self.media_files[self.current_index]) return True
or not self.is_playing
):
return
if self.multi_segment_mode: if self.multi_segment_mode:
self.update_segment_frames() self.update_segment_frames()
return True return True
else: 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): # Handle looping bounds
ret, frame = self.current_cap.read() if new_frame >= self.total_frames:
if not ret: # Loop to beginning
actual_frame = int(self.current_cap.get(cv2.CAP_PROP_POS_FRAMES)) new_frame = 0
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
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() self.update_watch_tracking()
return self.load_current_frame()
return True
def seek_video(self, frames_delta: int): def seek_video(self, frames_delta: int):
"""Seek video by specified number of frames""" """Seek video by specified number of frames"""
@@ -952,7 +969,7 @@ class MediaGrader:
0, min(self.current_frame + frames_delta, self.total_frames - 1) 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() self.load_current_frame()
def process_seek_key(self, key: int) -> bool: def process_seek_key(self, key: int) -> bool:
@@ -1165,32 +1182,42 @@ class MediaGrader:
cv2.setWindowTitle("Media Grader", window_title) cv2.setWindowTitle("Media Grader", window_title)
while True: while True:
# Update display
self.display_current_frame() self.display_current_frame()
if self.is_video(current_file): # Calculate appropriate delay based on playback state
if self.is_seeking: if self.is_playing and self.is_video(current_file):
delay = self.FRAME_RENDER_TIME_MS # Use calculated frame delay for proper playback speed
else: delay_ms = self.calculate_frame_delay()
delay = self.calculate_frame_delay()
else: else:
delay = self.IMAGE_DISPLAY_DELAY_MS # Use minimal delay for immediate responsiveness when not playing
delay_ms = 1
key = cv2.waitKey(delay) & 0xFF # Auto advance frame when playing (videos only)
if self.is_playing and self.is_video(current_file):
self.advance_frame()
# Key capture with appropriate delay
key = cv2.waitKey(delay_ms) & 0xFF
if key == ord("q") or key == 27: if key == ord("q") or key == 27:
return return
elif key == ord(" "): elif key == ord(" "):
self.is_playing = not self.is_playing self.is_playing = not self.is_playing
elif key == ord("s"): elif key == ord("s"):
self.playback_speed = max( # Speed control only for videos
self.MIN_PLAYBACK_SPEED, if self.is_video(current_file):
self.playback_speed - self.SPEED_INCREMENT, self.playback_speed = max(
) self.MIN_PLAYBACK_SPEED,
self.playback_speed - self.SPEED_INCREMENT,
)
elif key == ord("w"): elif key == ord("w"):
self.playback_speed = min( # Speed control only for videos
self.MAX_PLAYBACK_SPEED, if self.is_video(current_file):
self.playback_speed + self.SPEED_INCREMENT, self.playback_speed = min(
) self.MAX_PLAYBACK_SPEED,
self.playback_speed + self.SPEED_INCREMENT,
)
elif self.process_seek_key(key): elif self.process_seek_key(key):
continue continue
elif key == ord("n"): elif key == ord("n"):
@@ -1229,17 +1256,6 @@ class MediaGrader:
if self.is_seeking and self.current_seek_key is not None: if self.is_seeking and self.current_seek_key is not None:
self.process_seek_key(self.current_seek_key) 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")]: 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) print("Navigating to (pu12345): ", self.current_index)
self.current_index += 1 self.current_index += 1