-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathvideo_pipeline.py
More file actions
164 lines (137 loc) · 5.84 KB
/
Copy pathvideo_pipeline.py
File metadata and controls
164 lines (137 loc) · 5.84 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
import cv2
import asyncio
import threading
import time
from livekit.rtc import VideoSource, LocalVideoTrack, VideoFrame, VideoBufferType
from PyQt6.QtCore import pyqtSignal, QObject
from PyQt6.QtGui import QImage
class CameraThread(threading.Thread):
"""
Separate thread for capturing frames from the webcam using OpenCV.
Handles resizing to target resolution and keeps the latest frame.
"""
def __init__(self, camera_index=0, target_width=1280, target_height=720):
super().__init__(daemon=True)
self.camera_index = camera_index
self.target_width = target_width
self.target_height = target_height
self.latest_frame = None
self.running = False
self.frame_ready_event = threading.Event()
self.cap = None
def run(self):
import platform
if platform.system() == "Windows":
# DirectShow is typically much faster to initialize on Windows
self.cap = cv2.VideoCapture(self.camera_index, cv2.CAP_DSHOW)
if not self.cap.isOpened():
self.cap = cv2.VideoCapture(self.camera_index)
else:
self.cap = cv2.VideoCapture(self.camera_index)
if not self.cap.isOpened():
print(f"Error: Could not open camera {self.camera_index}")
return
self.running = True
while self.running:
ret, frame = self.cap.read()
if not ret:
time.sleep(0.01)
continue
# Resize to target resolution immediately
frame = cv2.resize(frame, (self.target_width, self.target_height))
# Store latest frame (BGR)
self.latest_frame = frame
self.frame_ready_event.set()
self.cap.release()
def stop(self):
self.running = False
class LiveKitCameraSource:
"""
Bridges the CameraThread to a LiveKit VideoSource.
Continuously pushes camera frames into the LiveKit VideoSource
so a LocalVideoTrack can be used with the Decart RealtimeClient.
"""
def __init__(self, camera_thread: CameraThread, fps: int = 24):
self.camera_thread = camera_thread
self.fps = fps
self.width = camera_thread.target_width
self.height = camera_thread.target_height
# Create a LiveKit VideoSource and corresponding LocalVideoTrack
self.video_source = VideoSource(self.width, self.height)
self.track = LocalVideoTrack.create_video_track("camera", self.video_source)
self._running = False
self._task = None
async def start(self):
"""Start the async loop that pushes camera frames to the LiveKit VideoSource."""
self._running = True
self._task = asyncio.create_task(self._push_frames())
async def _push_frames(self):
"""Continuously push camera frames to the LiveKit VideoSource at target FPS."""
frame_interval = 1.0 / self.fps
start_time = time.monotonic()
frame_count = 0
while self._running:
loop_start = time.monotonic()
if self.camera_thread.latest_frame is not None:
frame_bgr = self.camera_thread.latest_frame
# Convert BGR (OpenCV) to RGBA (LiveKit)
frame_rgba = cv2.cvtColor(frame_bgr, cv2.COLOR_BGR2RGBA)
# Create LiveKit VideoFrame from the RGBA data
lk_frame = VideoFrame(
self.width,
self.height,
VideoBufferType.RGBA,
frame_rgba.tobytes(),
)
# Compute timestamp in microseconds
timestamp_us = int((time.monotonic() - start_time) * 1_000_000)
# Push the frame to the VideoSource
self.video_source.capture_frame(lk_frame, timestamp_us=timestamp_us)
frame_count += 1
# Maintain target FPS
elapsed = time.monotonic() - loop_start
sleep_time = max(0.001, frame_interval - elapsed)
await asyncio.sleep(sleep_time)
async def stop(self):
"""Stop the frame push loop."""
self._running = False
if self._task:
self._task.cancel()
try:
await self._task
except asyncio.CancelledError:
pass
self._task = None
class FrameEmitter(QObject):
"""
Thread-safe emitter to send frames from the WebRTC background task to the UI.
"""
frame_received = pyqtSignal(QImage)
def emit_frame(self, av_frame):
"""
Convert an av.VideoFrame or similar frame object to QImage and emit.
Handles both av.VideoFrame (with to_ndarray) and raw numpy arrays.
"""
try:
if hasattr(av_frame, 'to_ndarray'):
# av.VideoFrame from Decart remote stream
img_data = av_frame.to_ndarray(format="rgb24")
elif hasattr(av_frame, 'data') and hasattr(av_frame, 'width'):
# LiveKit VideoFrame — convert from RGBA bytes to numpy
import numpy as np
data = bytes(av_frame.data)
img_data = np.frombuffer(data, dtype=np.uint8).reshape(
av_frame.height, av_frame.width, 4
)
# Convert RGBA to RGB for QImage
img_data = img_data[:, :, :3].copy()
else:
# Assume it's already a numpy array (RGB)
img_data = av_frame
height, width, channel = img_data.shape
bytes_per_line = 3 * width
q_img = QImage(img_data.data, width, height, bytes_per_line, QImage.Format.Format_RGB888)
# We need to copy the image because the underlying data might be reused
self.frame_received.emit(q_img.copy())
except Exception as e:
print(f"Error in FrameEmitter.emit_frame: {e}")