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