Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/web-server-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@ jobs:
restore-keys: |
${{ runner.os }}-pip-

- name: Install system dependencies
run: sudo apt-get update && sudo apt-get install -y libcap-dev

- name: Install dependencies
working-directory: Software/web-server
run: |
Expand Down
37 changes: 35 additions & 2 deletions Software/web-server/calibration_manager.py
Original file line number Diff line number Diff line change
Expand Up @@ -27,15 +27,17 @@
class CalibrationManager:
"""Manages calibration processes for PiTrac cameras"""

def __init__(self, config_manager, pitrac_binary: str = "/usr/lib/pitrac/pitrac_lm"):
def __init__(self, config_manager, camera_stream_manager=None, pitrac_binary: str = "/usr/lib/pitrac/pitrac_lm"):
"""
Initialize calibration manager

Args:
config_manager: Configuration manager instance
camera_stream_manager: Optional camera stream manager to stop streams before calibration
pitrac_binary: Path to pitrac_lm binary
"""
self.config_manager = config_manager
self.camera_stream_manager = camera_stream_manager
self.pitrac_binary = pitrac_binary
self.current_processes: Dict[str, asyncio.subprocess.Process] = {}
self._process_lock = asyncio.Lock()
Expand Down Expand Up @@ -309,8 +311,13 @@ async def check_ball_location(self, camera: str = "camera1") -> Dict[str, Any]:
camera: Which camera to use ("camera1" or "camera2")

Returns:
Dict with status and ball location info
Dict with status, ball location info, and image path for display
"""
# Stop any active camera stream before running calibration
if self.camera_stream_manager and self.camera_stream_manager.is_streaming():
logger.info(f"Stopping camera stream before ball location check for {camera}")
self.camera_stream_manager.stop_stream()

logger.info(f"Starting ball location check for {camera}")

self.calibration_status[camera] = {
Expand Down Expand Up @@ -348,13 +355,21 @@ async def check_ball_location(self, camera: str = "camera1") -> Dict[str, Any]:
if camera_gain is None:
camera_gain = 6.0

# Create output filename for the captured image
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
output_file = f"ball_location_{camera}_{timestamp}.png"
images_dir = Path.home() / "LM_Shares" / "Images"
images_dir.mkdir(parents=True, exist_ok=True)
output_path = images_dir / output_file

cmd.extend(
[
f"--search_center_x={search_x}",
f"--search_center_y={search_y}",
f"--logging_level={logging_level}",
"--artifact_save_level=all",
f"--camera_gain={camera_gain}",
f"--output_filename={output_path}",
]
)
cmd.extend(self._build_cli_args_from_metadata(camera))
Expand All @@ -368,10 +383,19 @@ async def check_ball_location(self, camera: str = "camera1") -> Dict[str, Any]:
self.calibration_status[camera]["message"] = "Ball detected" if ball_info else "Ball not found"
self.calibration_status[camera]["progress"] = 100

image_url = None
if output_path.exists():
image_url = f"/api/images/{output_file}"
logger.info(f"Ball location image saved: {output_path}")
else:
logger.warning(f"Ball location image not found at: {output_path}")

return {
"status": "success",
"ball_found": bool(ball_info),
"ball_info": ball_info,
"image_url": image_url,
"image_path": str(output_path) if output_path.exists() else None,
"output": result.get("output", ""),
}

Expand All @@ -392,6 +416,10 @@ async def run_auto_calibration(self, camera: str = "camera1") -> Dict[str, Any]:
Dict with calibration results

"""
# Stop any active camera stream before running calibration
if self.camera_stream_manager and self.camera_stream_manager.is_streaming():
logger.info(f"Stopping camera stream before auto calibration for {camera}")
self.camera_stream_manager.stop_stream()

generated_config_path = self.config_manager.generate_golf_sim_config()
logger.info(f"Generated config file at: {generated_config_path}")
Expand Down Expand Up @@ -864,6 +892,11 @@ async def run_manual_calibration(self, camera: str = "camera1") -> Dict[str, Any
Returns:
Dict with calibration results
"""
# Stop any active camera stream before running calibration
if self.camera_stream_manager and self.camera_stream_manager.is_streaming():
logger.info(f"Stopping camera stream before manual calibration for {camera}")
self.camera_stream_manager.stop_stream()

logger.info(f"Starting manual calibration for {camera}")

self.calibration_status[camera] = {
Expand Down
212 changes: 212 additions & 0 deletions Software/web-server/camera_stream_manager.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
"""
Camera Stream Manager for PiTrac Web Server

Manages live camera preview streams using picamera2 for calibration workflow.
Only one camera stream can be active at a time to prevent resource conflicts.
"""

import io
import logging
import os
from threading import Condition
from typing import Dict, Optional, Generator

# Conditional import based on test environment
if os.environ.get("TESTING") == "true":
from tests.utils.mock_picamera import MockPicamera2 as Picamera2
from tests.utils.mock_picamera import MockJpegEncoder as JpegEncoder
from tests.utils.mock_picamera import MockFileOutput as FileOutput
else:
from picamera2 import Picamera2
from picamera2.encoders import JpegEncoder
from picamera2.outputs import FileOutput

logger = logging.getLogger(__name__)


class StreamingOutput(io.BufferedIOBase):
"""Buffer for MJPEG frames with thread-safe access"""

def __init__(self):
self.frame = None
self.condition = Condition()

def write(self, buf):
"""Called by picamera2 encoder with each new frame"""
with self.condition:
self.frame = buf
self.condition.notify_all()


class CameraStreamManager:
"""Manages camera streaming for live preview during calibration

Only allows one camera stream at a time to prevent resource conflicts.
Automatically stops streams when calibration starts or page navigation occurs.
"""

def __init__(self, config_manager):
"""Initialize camera stream manager

Args:
config_manager: Configuration manager instance for camera settings
"""
self.config_manager = config_manager
self.active_camera: Optional[str] = None
self.picam2: Optional[Picamera2] = None
self.output: Optional[StreamingOutput] = None

def start_stream(self, camera: str) -> Dict[str, str]:
"""Start streaming for specified camera

Args:
camera: Camera identifier ("camera1" or "camera2")

Returns:
Dict with status and camera ID

Raises:
ValueError: If camera ID is invalid
RuntimeError: If camera cannot be initialized
"""
if camera not in ["camera1", "camera2"]:
raise ValueError(f"Invalid camera ID: {camera}")

# Stop any existing stream first (only one at a time)
if self.active_camera:
logger.info(f"Stopping existing stream for {self.active_camera} before starting {camera}")
self.stop_stream()

try:
# Map camera to picamera2 index
# Camera1 is typically index 0, Camera2 is index 1
camera_index = 0 if camera == "camera1" else 1

logger.info(f"Starting stream for {camera} (picamera2 index {camera_index})")

# Initialize picamera2
self.picam2 = Picamera2(camera_index)

# Configure for 640x480 streaming (good balance of quality/performance)
config = self.picam2.create_video_configuration(
main={"size": (640, 480), "format": "RGB888"}
)
self.picam2.configure(config)

# Create streaming output buffer
self.output = StreamingOutput()

# Start recording JPEG frames to the output buffer
self.picam2.start_recording(JpegEncoder(), FileOutput(self.output))

self.active_camera = camera
logger.info(f"Successfully started stream for {camera}")

return {"status": "started", "camera": camera}

except Exception as e:
logger.error(f"Failed to start stream for {camera}: {e}", exc_info=True)
# Cleanup on failure
if self.picam2:
try:
self.picam2.close()
except Exception:
pass
self.picam2 = None
self.output = None
self.active_camera = None
raise RuntimeError(f"Failed to start camera stream: {e}")

def stop_stream(self) -> Dict[str, str]:
"""Stop the active camera stream

Returns:
Dict with status and which camera was stopped
"""
if not self.active_camera:
return {"status": "no_stream_active"}

camera = self.active_camera
logger.info(f"Stopping stream for {camera}")

try:
if self.picam2:
self.picam2.stop_recording()
self.picam2.close()
self.picam2 = None

self.output = None
self.active_camera = None

logger.info(f"Successfully stopped stream for {camera}")
return {"status": "stopped", "camera": camera}

except Exception as e:
logger.error(f"Error stopping stream for {camera}: {e}", exc_info=True)
# Force cleanup even on error
self.picam2 = None
self.output = None
self.active_camera = None
return {"status": "error", "camera": camera, "message": str(e)}

def generate_frames(self) -> Generator[bytes, None, None]:
"""Generate MJPEG frames for streaming

Yields:
MJPEG frame boundaries with JPEG data

Raises:
RuntimeError: If no stream is active
"""
if not self.active_camera or not self.output:
raise RuntimeError("No active camera stream")

try:
logger.debug(f"Starting frame generation for {self.active_camera}")
while True:
# Wait for new frame from camera
with self.output.condition:
self.output.condition.wait()
frame = self.output.frame

# Yield MJPEG formatted frame
yield (
b'--FRAME\r\n'
b'Content-Type: image/jpeg\r\n'
b'Content-Length: ' + str(len(frame)).encode() + b'\r\n'
b'\r\n' + frame + b'\r\n'
)

except GeneratorExit:
# Client disconnected - this is normal
logger.debug(f"Client disconnected from {self.active_camera} stream")
pass
except Exception as e:
logger.error(f"Error generating frames: {e}", exc_info=True)
raise

def is_streaming(self, camera: Optional[str] = None) -> bool:
"""Check if a stream is active

Args:
camera: Optional specific camera to check. If None, checks any stream.

Returns:
True if specified camera (or any camera) is streaming
"""
if camera:
return self.active_camera == camera
return self.active_camera is not None

def get_active_camera(self) -> Optional[str]:
"""Get the currently active camera stream

Returns:
Camera ID if streaming, None otherwise
"""
return self.active_camera

def cleanup(self):
"""Cleanup all resources - call on shutdown"""
logger.info("Cleaning up camera stream manager")
self.stop_stream()
1 change: 1 addition & 0 deletions Software/web-server/requirements.txt
Original file line number Diff line number Diff line change
Expand Up @@ -8,3 +8,4 @@ pillow==11.3.0
pyyaml==6.0.3
aiofiles==25.1.0
websockets==15.0.1
picamera2>=0.3.12
Loading