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

208
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
speed = round(self.playback_speed, 2)
if speed >= 1.0:
# Speed >= 1: maximum FPS (no delay)
return 1
else:
# 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) 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)
else:
return int(self.playback_speed * 2)
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,43 +228,17 @@ 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
# 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: try:
self.current_cap = cv2.VideoCapture(str(file_path), backend) # Use Cv2BufferedCap for better frame handling
if self.current_cap.isOpened(): self.current_cap = Cv2BufferedCap(file_path)
# Optimize buffer settings for better performance self.total_frames = self.current_cap.total_frames
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
self.total_frames = int(self.current_cap.get(cv2.CAP_PROP_FRAME_COUNT))
self.current_frame = 0 self.current_frame = 0
# Get codec information for debugging print(f"Loaded: {file_path.name} | Frames: {self.total_frames} | FPS: {self.current_cap.fps:.2f}")
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}") except Exception as e:
print(f"Warning: Could not open video file {file_path.name}: {e}")
return False
else: else:
self.current_cap = None self.current_cap = None
@@ -235,11 +259,12 @@ 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
except Exception as e:
print(f"Failed to load frame {self.current_frame}: {e}")
return False return False
else: else:
frame = cv2.imread(str(self.media_files[self.current_index])) frame = cv2.imread(str(self.media_files[self.current_index]))
@@ -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,28 +1182,38 @@ 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
delay_ms = self.calculate_frame_delay()
else: else:
delay = self.calculate_frame_delay() # Use minimal delay for immediate responsiveness when not playing
else: delay_ms = 1
delay = self.IMAGE_DISPLAY_DELAY_MS
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"):
# Speed control only for videos
if self.is_video(current_file):
self.playback_speed = max( self.playback_speed = max(
self.MIN_PLAYBACK_SPEED, self.MIN_PLAYBACK_SPEED,
self.playback_speed - self.SPEED_INCREMENT, self.playback_speed - self.SPEED_INCREMENT,
) )
elif key == ord("w"): elif key == ord("w"):
# Speed control only for videos
if self.is_video(current_file):
self.playback_speed = min( self.playback_speed = min(
self.MAX_PLAYBACK_SPEED, self.MAX_PLAYBACK_SPEED,
self.playback_speed + self.SPEED_INCREMENT, self.playback_speed + self.SPEED_INCREMENT,
@@ -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