|
| 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() |
0 commit comments