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:
234
main.py
234
main.py
@@ -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
|
||||
|
Reference in New Issue
Block a user