mirror of
https://github.com/hanzoai/live.git
synced 2026-10-30 18:29:41 +00:00
Changes: - Fix calculating fps in frame processor to incorporate the fast that the video can be paused - Move the actual pause from frame processor to video track to avoid delaying pausing if inference takes a long time Signed-off-by: Rafal Leszko <[email protected]> Signed-off-by: Yondon Fu <[email protected]>
367 lines
14 KiB
Python
367 lines
14 KiB
Python
import logging
|
|
import queue
|
|
import threading
|
|
import time
|
|
from collections import deque
|
|
from typing import Any
|
|
|
|
import torch
|
|
from aiortc.mediastreams import VideoFrame
|
|
|
|
from .pipeline_manager import PipelineManager, PipelineNotAvailableException
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Multiply the # of output frames from pipeline by this to get the max size of the output queue
|
|
OUTPUT_QUEUE_MAX_SIZE_FACTOR = 3
|
|
|
|
# FPS calculation constants
|
|
MIN_FPS = 1.0 # Minimum FPS to prevent division by zero
|
|
MAX_FPS = 60.0 # Maximum FPS cap
|
|
DEFAULT_FPS = 30.0 # Default FPS
|
|
SLEEP_TIME = 0.01
|
|
|
|
|
|
class FrameProcessor:
|
|
def __init__(
|
|
self,
|
|
pipeline_manager: PipelineManager,
|
|
max_output_queue_size: int = 8,
|
|
max_parameter_queue_size: int = 8,
|
|
max_buffer_size: int = 30,
|
|
initial_parameters: dict = None,
|
|
notification_callback: callable = None,
|
|
):
|
|
self.pipeline_manager = pipeline_manager
|
|
|
|
self.frame_buffer = deque(maxlen=max_buffer_size)
|
|
self.frame_buffer_lock = threading.Lock()
|
|
self.output_queue = queue.Queue(maxsize=max_output_queue_size)
|
|
|
|
# Current parameters used by processing thread
|
|
self.parameters = initial_parameters or {}
|
|
# Queue for parameter updates from external threads
|
|
self.parameters_queue = queue.Queue(maxsize=max_parameter_queue_size)
|
|
|
|
self.worker_thread: threading.Thread | None = None
|
|
self.shutdown_event = threading.Event()
|
|
self.running = False
|
|
|
|
self.is_prepared = False
|
|
|
|
# Callback to notify when frame processor stops
|
|
self.notification_callback = notification_callback
|
|
|
|
# FPS tracking variables
|
|
self.processing_time_per_frame = deque(
|
|
maxlen=2
|
|
) # Keep last 2 processing_time/num_frames values for averaging
|
|
self.last_fps_update = time.time()
|
|
self.fps_update_interval = 0.5 # Update FPS every 0.5 seconds
|
|
self.min_fps = MIN_FPS
|
|
self.max_fps = MAX_FPS
|
|
self.current_pipeline_fps = DEFAULT_FPS
|
|
self.fps_lock = threading.Lock() # Lock for thread-safe FPS updates
|
|
|
|
self.paused = False
|
|
|
|
def start(self):
|
|
if self.running:
|
|
return
|
|
|
|
self.running = True
|
|
self.shutdown_event.clear()
|
|
self.worker_thread = threading.Thread(target=self.worker_loop, daemon=True)
|
|
self.worker_thread.start()
|
|
|
|
logger.info("FrameProcessor started")
|
|
|
|
def stop(self, error_message: str = None):
|
|
if not self.running:
|
|
return
|
|
|
|
self.running = False
|
|
self.shutdown_event.set()
|
|
|
|
if self.worker_thread and self.worker_thread.is_alive():
|
|
# Don't join if we're calling stop() from within the worker thread
|
|
if threading.current_thread() != self.worker_thread:
|
|
self.worker_thread.join(timeout=5.0)
|
|
|
|
while not self.output_queue.empty():
|
|
try:
|
|
self.output_queue.get_nowait()
|
|
except queue.Empty:
|
|
break
|
|
|
|
with self.frame_buffer_lock:
|
|
self.frame_buffer.clear()
|
|
|
|
logger.info("FrameProcessor stopped")
|
|
|
|
# Notify callback that frame processor has stopped
|
|
if self.notification_callback:
|
|
try:
|
|
message = {"type": "stream_stopped"}
|
|
if error_message:
|
|
message["error_message"] = error_message
|
|
self.notification_callback(message)
|
|
except Exception as e:
|
|
logger.error(f"Error in frame processor stop callback: {e}")
|
|
|
|
def put(self, frame: VideoFrame) -> bool:
|
|
if not self.running:
|
|
return False
|
|
|
|
with self.frame_buffer_lock:
|
|
self.frame_buffer.append(frame)
|
|
return True
|
|
|
|
def get(self) -> torch.Tensor | None:
|
|
if not self.running:
|
|
return None
|
|
|
|
try:
|
|
return self.output_queue.get_nowait()
|
|
except queue.Empty:
|
|
return None
|
|
|
|
def get_current_pipeline_fps(self) -> float:
|
|
"""Get the current dynamically calculated pipeline FPS"""
|
|
with self.fps_lock:
|
|
return self.current_pipeline_fps
|
|
|
|
def _calculate_pipeline_fps(self, start_time: float, num_frames: int):
|
|
"""Calculate FPS based on processing time and number of frames created"""
|
|
processing_time = time.time() - start_time
|
|
if processing_time <= 0 or num_frames <= 0:
|
|
return
|
|
|
|
# Store processing time per frame for averaging
|
|
time_per_frame = processing_time / num_frames
|
|
self.processing_time_per_frame.append(time_per_frame)
|
|
|
|
# Update FPS if enough time has passed
|
|
current_time = time.time()
|
|
if current_time - self.last_fps_update >= self.fps_update_interval:
|
|
if len(self.processing_time_per_frame) >= 1:
|
|
# Calculate average processing time per frame
|
|
avg_time_per_frame = sum(self.processing_time_per_frame) / len(
|
|
self.processing_time_per_frame
|
|
)
|
|
|
|
# Calculate FPS: 1 / average_time_per_frame
|
|
# This gives us the actual frames per second output
|
|
with self.fps_lock:
|
|
current_fps = self.current_pipeline_fps
|
|
estimated_fps = (
|
|
1.0 / avg_time_per_frame if avg_time_per_frame > 0 else current_fps
|
|
)
|
|
|
|
# Clamp to reasonable bounds
|
|
estimated_fps = max(self.min_fps, min(self.max_fps, estimated_fps))
|
|
with self.fps_lock:
|
|
self.current_pipeline_fps = estimated_fps
|
|
|
|
self.last_fps_update = current_time
|
|
|
|
def update_parameters(self, parameters: dict[str, Any]):
|
|
"""Update parameters that will be used in the next pipeline call."""
|
|
# Put new parameters in queue (replace any pending update)
|
|
try:
|
|
# Add new update
|
|
self.parameters_queue.put_nowait(parameters)
|
|
except queue.Full:
|
|
logger.info("Parameter queue full, dropping parameter update")
|
|
return False
|
|
|
|
def worker_loop(self):
|
|
logger.info("Worker thread started")
|
|
|
|
while self.running and not self.shutdown_event.is_set():
|
|
try:
|
|
self.process_chunk()
|
|
|
|
except PipelineNotAvailableException as e:
|
|
logger.debug(f"Pipeline temporarily unavailable: {e}")
|
|
# Flush frame buffer to prevent buildup
|
|
with self.frame_buffer_lock:
|
|
if self.frame_buffer:
|
|
logger.debug(
|
|
f"Flushing {len(self.frame_buffer)} frames due to pipeline unavailability"
|
|
)
|
|
self.frame_buffer.clear()
|
|
continue
|
|
except Exception as e:
|
|
if self._is_recoverable(e):
|
|
logger.error(f"Error in worker loop: {e}")
|
|
continue
|
|
else:
|
|
logger.error(
|
|
f"Non-recoverable error in worker loop: {e}, stopping frame processor"
|
|
)
|
|
self.stop(error_message=str(e))
|
|
break
|
|
logger.info("Worker thread stopped")
|
|
|
|
def process_chunk(self):
|
|
start_time = time.time()
|
|
try:
|
|
# Check if there are new parameters
|
|
new_parameters = self.parameters_queue.get_nowait()
|
|
if new_parameters != self.parameters:
|
|
# Merge new parameters with existing ones to preserve any missing keys
|
|
self.parameters = {**self.parameters, **new_parameters}
|
|
logger.info(f"Updated parameters: {self.parameters}")
|
|
except queue.Empty:
|
|
pass
|
|
|
|
# Get the current pipeline using sync wrapper
|
|
pipeline = self.pipeline_manager.get_pipeline()
|
|
|
|
# Pause or resume the processing
|
|
paused = self.parameters.pop("paused", None)
|
|
if paused is not None and paused != self.paused:
|
|
self.paused = paused
|
|
if self.paused:
|
|
# Sleep briefly to avoid busy waiting
|
|
self.shutdown_event.wait(SLEEP_TIME)
|
|
return
|
|
|
|
# prepare() will handle any required preparation based on parameters internally
|
|
reset_cache = self.parameters.pop("reset_cache", None)
|
|
requirements = pipeline.prepare(
|
|
should_prepare=not self.is_prepared or reset_cache, **self.parameters
|
|
)
|
|
self.is_prepared = True
|
|
input = None
|
|
|
|
if requirements is not None:
|
|
current_chunk_size = requirements.input_size
|
|
with self.frame_buffer_lock:
|
|
if not self.frame_buffer or len(self.frame_buffer) < current_chunk_size:
|
|
# Sleep briefly to avoid busy waiting
|
|
self.shutdown_event.wait(SLEEP_TIME)
|
|
return
|
|
input = self.prepare_chunk(current_chunk_size)
|
|
try:
|
|
# Pass parameters
|
|
output = pipeline(input, **self.parameters)
|
|
|
|
processing_time = time.time() - start_time
|
|
num_frames = output.shape[0]
|
|
logger.debug(
|
|
f"Processed pipeline in {processing_time:.4f}s, {num_frames} frames"
|
|
)
|
|
|
|
# Normalize to [0, 255] and convert to uint8
|
|
output = (
|
|
(output * 255.0)
|
|
.clamp(0, 255)
|
|
.to(dtype=torch.uint8)
|
|
.contiguous()
|
|
.detach()
|
|
.cpu()
|
|
)
|
|
|
|
# Resize output queue to meet target max size
|
|
target_output_queue_max_size = num_frames * OUTPUT_QUEUE_MAX_SIZE_FACTOR
|
|
if self.output_queue.maxsize < target_output_queue_max_size:
|
|
logger.info(
|
|
f"Increasing output queue size to {target_output_queue_max_size}, current size {self.output_queue.maxsize}, num_frames {num_frames}"
|
|
)
|
|
|
|
# Transfer frames from old queue to new queue
|
|
old_queue = self.output_queue
|
|
self.output_queue = queue.Queue(maxsize=target_output_queue_max_size)
|
|
while not old_queue.empty():
|
|
try:
|
|
frame = old_queue.get_nowait()
|
|
self.output_queue.put_nowait(frame)
|
|
except queue.Empty:
|
|
break
|
|
|
|
for frame in output:
|
|
try:
|
|
self.output_queue.put_nowait(frame)
|
|
except queue.Full:
|
|
logger.warning("Output queue full, dropping processed frame")
|
|
# Update FPS calculation based on processing time and frame count
|
|
self._calculate_pipeline_fps(start_time, num_frames)
|
|
continue
|
|
|
|
# Update FPS calculation based on processing time and frame count
|
|
self._calculate_pipeline_fps(start_time, num_frames)
|
|
except Exception as e:
|
|
if self._is_recoverable(e):
|
|
# Handle recoverable errors with full stack trace and continue processing
|
|
logger.error(f"Error processing chunk: {e}", exc_info=True)
|
|
else:
|
|
raise e
|
|
|
|
def prepare_chunk(self, chunk_size: int) -> list[torch.Tensor]:
|
|
"""
|
|
Sample frames uniformly from the buffer, convert them to tensors, and remove processed frames.
|
|
|
|
This function implements uniform sampling across the entire buffer to ensure
|
|
temporal coverage of input frames. It samples frames at evenly distributed
|
|
indices and removes all frames up to the last sampled frame to prevent
|
|
buffer buildup.
|
|
|
|
Note:
|
|
This function must be called with self.frame_buffer_lock held to ensure
|
|
thread safety. The caller is responsible for acquiring the lock.
|
|
|
|
Example:
|
|
With buffer_len=8 and chunk_size=4:
|
|
- step = 8/4 = 2.0
|
|
- indices = [0, 2, 4, 6] (uniformly distributed)
|
|
- Returns frames at positions 0, 2, 4, 6
|
|
- Removes frames 0-6 from buffer (7 frames total)
|
|
|
|
Returns:
|
|
List of processed tensor frames, or empty list if insufficient buffer
|
|
"""
|
|
# Calculate uniform sampling step
|
|
step = len(self.frame_buffer) / chunk_size
|
|
# Generate indices for uniform sampling
|
|
indices = [round(i * step) for i in range(chunk_size)]
|
|
# Extract VideoFrames at sampled indices
|
|
video_frames = [self.frame_buffer[i] for i in indices]
|
|
|
|
# Drop all frames up to and including the last sampled frame
|
|
last_idx = indices[-1]
|
|
for _ in range(last_idx + 1):
|
|
self.frame_buffer.popleft()
|
|
|
|
# Convert VideoFrames to tensors
|
|
tensor_frames = []
|
|
for video_frame in video_frames:
|
|
# Convert VideoFrame into THWC tensor on cpu
|
|
tensor = (
|
|
torch.from_numpy(video_frame.to_ndarray(format="rgb24"))
|
|
.float()
|
|
.unsqueeze(0)
|
|
)
|
|
tensor_frames.append(tensor)
|
|
|
|
return tensor_frames
|
|
|
|
def __enter__(self):
|
|
self.start()
|
|
return self
|
|
|
|
def __exit__(self, exc_type, exc_val, exc_tb):
|
|
self.stop()
|
|
|
|
@staticmethod
|
|
def _is_recoverable(error: Exception) -> bool:
|
|
"""
|
|
Check if an error is recoverable (i.e., processing can continue).
|
|
Non-recoverable errors will cause the stream to stop.
|
|
"""
|
|
if isinstance(error, torch.cuda.OutOfMemoryError):
|
|
return False
|
|
# Add more non-recoverable error types here as needed
|
|
return True
|