Skip to content

Commit 156ac3d

Browse files
committed
Implement live preview!
1 parent e775da4 commit 156ac3d

File tree

6 files changed

+472
-18
lines changed

6 files changed

+472
-18
lines changed

Software/web-server/calibration_manager.py

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,15 +27,17 @@
2727
class CalibrationManager:
2828
"""Manages calibration processes for PiTrac cameras"""
2929

30-
def __init__(self, config_manager, pitrac_binary: str = "/usr/lib/pitrac/pitrac_lm"):
30+
def __init__(self, config_manager, camera_stream_manager=None, pitrac_binary: str = "/usr/lib/pitrac/pitrac_lm"):
3131
"""
3232
Initialize calibration manager
3333
3434
Args:
3535
config_manager: Configuration manager instance
36+
camera_stream_manager: Optional camera stream manager to stop streams before calibration
3637
pitrac_binary: Path to pitrac_lm binary
3738
"""
3839
self.config_manager = config_manager
40+
self.camera_stream_manager = camera_stream_manager
3941
self.pitrac_binary = pitrac_binary
4042
self.current_processes: Dict[str, asyncio.subprocess.Process] = {}
4143
self._process_lock = asyncio.Lock()
@@ -311,6 +313,11 @@ async def check_ball_location(self, camera: str = "camera1") -> Dict[str, Any]:
311313
Returns:
312314
Dict with status, ball location info, and image path for display
313315
"""
316+
# Stop any active camera stream before running calibration
317+
if self.camera_stream_manager and self.camera_stream_manager.is_streaming():
318+
logger.info(f"Stopping camera stream before ball location check for {camera}")
319+
self.camera_stream_manager.stop_stream()
320+
314321
logger.info(f"Starting ball location check for {camera}")
315322

316323
self.calibration_status[camera] = {
@@ -408,6 +415,11 @@ async def run_auto_calibration(self, camera: str = "camera1") -> Dict[str, Any]:
408415
Returns:
409416
Dict with calibration results
410417
"""
418+
# Stop any active camera stream before running calibration
419+
if self.camera_stream_manager and self.camera_stream_manager.is_streaming():
420+
logger.info(f"Stopping camera stream before auto calibration for {camera}")
421+
self.camera_stream_manager.stop_stream()
422+
411423
if camera == "camera2":
412424
return await self._run_camera2_auto_calibration()
413425
else:
@@ -876,6 +888,11 @@ async def run_manual_calibration(self, camera: str = "camera1") -> Dict[str, Any
876888
Returns:
877889
Dict with calibration results
878890
"""
891+
# Stop any active camera stream before running calibration
892+
if self.camera_stream_manager and self.camera_stream_manager.is_streaming():
893+
logger.info(f"Stopping camera stream before manual calibration for {camera}")
894+
self.camera_stream_manager.stop_stream()
895+
879896
logger.info(f"Starting manual calibration for {camera}")
880897

881898
self.calibration_status[camera] = {
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
"""
2+
Camera Stream Manager for PiTrac Web Server
3+
4+
Manages live camera preview streams using picamera2 for calibration workflow.
5+
Only one camera stream can be active at a time to prevent resource conflicts.
6+
"""
7+
8+
import io
9+
import logging
10+
from threading import Condition
11+
from typing import Dict, Optional, Generator
12+
13+
from picamera2 import Picamera2
14+
from picamera2.encoders import JpegEncoder
15+
from picamera2.outputs import FileOutput
16+
17+
logger = logging.getLogger(__name__)
18+
19+
20+
class StreamingOutput(io.BufferedIOBase):
21+
"""Buffer for MJPEG frames with thread-safe access"""
22+
23+
def __init__(self):
24+
self.frame = None
25+
self.condition = Condition()
26+
27+
def write(self, buf):
28+
"""Called by picamera2 encoder with each new frame"""
29+
with self.condition:
30+
self.frame = buf
31+
self.condition.notify_all()
32+
33+
34+
class CameraStreamManager:
35+
"""Manages camera streaming for live preview during calibration
36+
37+
Only allows one camera stream at a time to prevent resource conflicts.
38+
Automatically stops streams when calibration starts or page navigation occurs.
39+
"""
40+
41+
def __init__(self, config_manager):
42+
"""Initialize camera stream manager
43+
44+
Args:
45+
config_manager: Configuration manager instance for camera settings
46+
"""
47+
self.config_manager = config_manager
48+
self.active_camera: Optional[str] = None
49+
self.picam2: Optional[Picamera2] = None
50+
self.output: Optional[StreamingOutput] = None
51+
52+
def start_stream(self, camera: str) -> Dict[str, str]:
53+
"""Start streaming for specified camera
54+
55+
Args:
56+
camera: Camera identifier ("camera1" or "camera2")
57+
58+
Returns:
59+
Dict with status and camera ID
60+
61+
Raises:
62+
ValueError: If camera ID is invalid
63+
RuntimeError: If camera cannot be initialized
64+
"""
65+
if camera not in ["camera1", "camera2"]:
66+
raise ValueError(f"Invalid camera ID: {camera}")
67+
68+
# Stop any existing stream first (only one at a time)
69+
if self.active_camera:
70+
logger.info(f"Stopping existing stream for {self.active_camera} before starting {camera}")
71+
self.stop_stream()
72+
73+
try:
74+
# Map camera to picamera2 index
75+
# Camera1 is typically index 0, Camera2 is index 1
76+
camera_index = 0 if camera == "camera1" else 1
77+
78+
logger.info(f"Starting stream for {camera} (picamera2 index {camera_index})")
79+
80+
# Initialize picamera2
81+
self.picam2 = Picamera2(camera_index)
82+
83+
# Configure for 640x480 streaming (good balance of quality/performance)
84+
config = self.picam2.create_video_configuration(
85+
main={"size": (640, 480), "format": "RGB888"}
86+
)
87+
self.picam2.configure(config)
88+
89+
# Create streaming output buffer
90+
self.output = StreamingOutput()
91+
92+
# Start recording JPEG frames to the output buffer
93+
self.picam2.start_recording(JpegEncoder(), FileOutput(self.output))
94+
95+
self.active_camera = camera
96+
logger.info(f"Successfully started stream for {camera}")
97+
98+
return {"status": "started", "camera": camera}
99+
100+
except Exception as e:
101+
logger.error(f"Failed to start stream for {camera}: {e}", exc_info=True)
102+
# Cleanup on failure
103+
if self.picam2:
104+
try:
105+
self.picam2.close()
106+
except Exception:
107+
pass
108+
self.picam2 = None
109+
self.output = None
110+
self.active_camera = None
111+
raise RuntimeError(f"Failed to start camera stream: {e}")
112+
113+
def stop_stream(self) -> Dict[str, str]:
114+
"""Stop the active camera stream
115+
116+
Returns:
117+
Dict with status and which camera was stopped
118+
"""
119+
if not self.active_camera:
120+
return {"status": "no_stream_active"}
121+
122+
camera = self.active_camera
123+
logger.info(f"Stopping stream for {camera}")
124+
125+
try:
126+
if self.picam2:
127+
self.picam2.stop_recording()
128+
self.picam2.close()
129+
self.picam2 = None
130+
131+
self.output = None
132+
self.active_camera = None
133+
134+
logger.info(f"Successfully stopped stream for {camera}")
135+
return {"status": "stopped", "camera": camera}
136+
137+
except Exception as e:
138+
logger.error(f"Error stopping stream for {camera}: {e}", exc_info=True)
139+
# Force cleanup even on error
140+
self.picam2 = None
141+
self.output = None
142+
self.active_camera = None
143+
return {"status": "error", "camera": camera, "message": str(e)}
144+
145+
def generate_frames(self) -> Generator[bytes, None, None]:
146+
"""Generate MJPEG frames for streaming
147+
148+
Yields:
149+
MJPEG frame boundaries with JPEG data
150+
151+
Raises:
152+
RuntimeError: If no stream is active
153+
"""
154+
if not self.active_camera or not self.output:
155+
raise RuntimeError("No active camera stream")
156+
157+
try:
158+
logger.debug(f"Starting frame generation for {self.active_camera}")
159+
while True:
160+
# Wait for new frame from camera
161+
with self.output.condition:
162+
self.output.condition.wait()
163+
frame = self.output.frame
164+
165+
# Yield MJPEG formatted frame
166+
yield (
167+
b'--FRAME\r\n'
168+
b'Content-Type: image/jpeg\r\n'
169+
b'Content-Length: ' + str(len(frame)).encode() + b'\r\n'
170+
b'\r\n' + frame + b'\r\n'
171+
)
172+
173+
except GeneratorExit:
174+
# Client disconnected - this is normal
175+
logger.debug(f"Client disconnected from {self.active_camera} stream")
176+
pass
177+
except Exception as e:
178+
logger.error(f"Error generating frames: {e}", exc_info=True)
179+
raise
180+
181+
def is_streaming(self, camera: Optional[str] = None) -> bool:
182+
"""Check if a stream is active
183+
184+
Args:
185+
camera: Optional specific camera to check. If None, checks any stream.
186+
187+
Returns:
188+
True if specified camera (or any camera) is streaming
189+
"""
190+
if camera:
191+
return self.active_camera == camera
192+
return self.active_camera is not None
193+
194+
def get_active_camera(self) -> Optional[str]:
195+
"""Get the currently active camera stream
196+
197+
Returns:
198+
Camera ID if streaming, None otherwise
199+
"""
200+
return self.active_camera
201+
202+
def cleanup(self):
203+
"""Cleanup all resources - call on shutdown"""
204+
logger.info("Cleaning up camera stream manager")
205+
self.stop_stream()

Software/web-server/requirements.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,3 +8,4 @@ pillow==11.3.0
88
pyyaml==6.0.3
99
aiofiles==25.1.0
1010
websockets==15.0.1
11+
picamera2>=0.3.12

0 commit comments

Comments
 (0)