Skip to content
Merged
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
14 changes: 13 additions & 1 deletion app.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@
jpeg_quality=ip_cam_jpeg_quality,
flip_180=ip_cam_flip_180,
)

# Initialize default local camera for the legacy Camera 1 feed.
# Opening and reconnecting happen in the receiver thread so app startup can continue.
default_cam_device = int(os.getenv("DEFAULT_CAMERA_DEVICE", "0"))
default_cam_jpeg_quality = int(os.getenv("DEFAULT_CAMERA_JPEG_QUALITY", "70"))
app.config["DEFAULT_CAMERA"] = init_camera(
device_index=default_cam_device,
jpeg_quality=default_cam_jpeg_quality,
)

# Start background resource monitor receiver (UDP port 12346)
app.config["RESOURCE"] = init_resource_receiver(port=12346)
app.config["BITMASK"].set_resource_monitor(app.config["RESOURCE"])
Expand All @@ -99,7 +109,6 @@
app.config["SYSTEM_CONTROL"] = SystemControlClient()

register_routes(app)
camera = init_camera()


def _shutdown():
Expand All @@ -118,6 +127,9 @@ def _shutdown():
ip_cam = app.config.get("IP_CAMERA")
if ip_cam:
ip_cam.stop()
default_cam = app.config.get("DEFAULT_CAMERA")
if default_cam:
default_cam.stop()
resource = app.config.get("RESOURCE")
if resource:
resource.stop()
Expand Down
178 changes: 163 additions & 15 deletions lib/camera.py
Original file line number Diff line number Diff line change
Expand Up @@ -19,29 +19,177 @@ def draw_detected_markers(self, frame, corners, ids):
return frame


def init_camera():
"""Initialize and return the default webcam."""
camera = cv2.VideoCapture(0)
return camera
class DefaultCameraReceiver:
"""Reads the default local camera in the background and exposes latest JPEG frame."""

RECONNECT_DELAY = 3.0

def generate_frames(camera):
camera_matrix = np.array([[900, 0, 640], [0, 900, 360], [0, 0, 1]], dtype=np.float32)
dist_coeffs = np.zeros((5, 1), dtype=np.float32)
def __init__(self, device_index=0, jpeg_quality=70):
self.device_index = int(device_index)
self.jpeg_quality = min(95, max(40, int(jpeg_quality)))
self.is_connected = False
self.is_listening = False
self.last_error = None

self._stop_event = threading.Event()
self._thread = None
self._cap = None

self._frame_lock = threading.Lock()
self._frame_cond = threading.Condition(self._frame_lock)
self._latest_jpeg = None
self._frame_seq = 0
self._last_frame_ts = 0.0
self._placeholder_jpeg = self._build_placeholder_jpeg()
camera_matrix = np.array([[900, 0, 640], [0, 900, 360], [0, 0, 1]], dtype=np.float32)
dist_coeffs = np.zeros((5, 1), dtype=np.float32)
self._detector = DummyArUcoMarkerDetector(camera_matrix=camera_matrix, dist_coeffs=dist_coeffs)

def start(self):
if self._thread and self._thread.is_alive():
return
self._stop_event.clear()
self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._thread.start()

def stop(self):
self._stop_event.set()
if self._thread and self._thread.is_alive():
self._thread.join(timeout=3.0)
self._release_cap()

def get_latest_jpeg(self):
with self._frame_lock:
return self._latest_jpeg

def get_latest_jpeg_and_seq(self):
with self._frame_lock:
return self._latest_jpeg, self._frame_seq

def wait_for_next_frame(self, last_seq, timeout=0.25):
with self._frame_cond:
if self._frame_seq == last_seq:
self._frame_cond.wait(timeout=timeout)
return self._latest_jpeg, self._frame_seq

def get_placeholder_jpeg(self):
return self._placeholder_jpeg

def get_status(self):
age_ms = None
if self._last_frame_ts > 0:
age_ms = int((time.monotonic() - self._last_frame_ts) * 1000)
return {
"connected": bool(self.is_connected),
"listening": bool(self.is_listening),
"device_index": self.device_index,
"jpeg_quality": self.jpeg_quality,
"last_frame_age_ms": age_ms,
"last_error": self.last_error,
}

def _set_frame(self, frame):
corners, ids, _rejected = self._detector.detect_markers(frame)
frame = self._detector.draw_detected_markers(frame, corners, ids)
ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), self.jpeg_quality])
if not ok:
return
with self._frame_cond:
self._latest_jpeg = buf.tobytes()
self._frame_seq += 1
self._frame_cond.notify_all()
self._last_frame_ts = time.monotonic()
self.is_connected = True

def _build_placeholder_jpeg(self):
blank = np.zeros((720, 1280, 3), dtype=np.uint8)
cv2.putText(
blank,
"WAITING FOR DEFAULT CAMERA",
(330, 360),
cv2.FONT_HERSHEY_SIMPLEX,
1.0,
(0, 222, 255),
2,
cv2.LINE_AA,
)
ok, buf = cv2.imencode(".jpg", blank, [int(cv2.IMWRITE_JPEG_QUALITY), 75])
return buf.tobytes() if ok else b""

def _open_camera(self):
cap = cv2.VideoCapture(self.device_index)
if not cap.isOpened():
cap.release()
return False
self._cap = cap
return True

def _release_cap(self):
self.is_connected = False
self.is_listening = False
if self._cap is not None:
try:
self._cap.release()
except Exception:
pass
self._cap = None

def _run_loop(self):
while not self._stop_event.is_set():
print(f"[Default Camera] Opening device {self.device_index} ...")
self.is_listening = True
if not self._open_camera():
self.last_error = f"Failed to open camera device {self.device_index}"
print(f"[Default Camera] {self.last_error}")
self._release_cap()
if self._stop_event.wait(self.RECONNECT_DELAY):
break
continue

detector = DummyArUcoMarkerDetector(camera_matrix=camera_matrix, dist_coeffs=dist_coeffs)
print("[Default Camera] Stream connected")
self.last_error = None
had_frame = False

while not self._stop_event.is_set():
ok, frame = self._cap.read()
if not ok or frame is None:
self.last_error = "Camera read failed"
print("[Default Camera] Lost connection, will reconnect ...")
self._release_cap()
break

if not had_frame:
print("[Default Camera] Receiving frames")
had_frame = True
self._set_frame(frame)

if not self._stop_event.is_set():
self._stop_event.wait(self.RECONNECT_DELAY)

self._release_cap()


def generate_frames(camera):
"""Flask MJPEG generator for the default camera receiver."""
last_seq = -1
while True:
success, frame = camera.read()
if not success:
break
corners, ids, rejected = detector.detect_markers(frame)
frame = detector.draw_detected_markers(frame, corners, ids)
ret, buffer = cv2.imencode(".jpg", frame)
frame = buffer.tobytes()
frame, seq = camera.wait_for_next_frame(last_seq, timeout=0.25)
if frame is None:
frame = camera.get_placeholder_jpeg()
elif seq == last_seq:
continue

last_seq = seq
yield (b"--frame\r\nContent-Type: image/jpeg\r\n\r\n" + frame + b"\r\n")


def init_camera(device_index=0, jpeg_quality=70):
"""Initialize and start the default local camera receiver."""
receiver = DefaultCameraReceiver(device_index=device_index, jpeg_quality=jpeg_quality)
receiver.start()
return receiver


class RPiCameraReceiver:
"""Receives RTP/H264 stream from Raspberry Pi and exposes latest JPEG frame."""

Expand Down
23 changes: 19 additions & 4 deletions routes.py
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
from flask import Response, current_app, jsonify, render_template, request

from lib.axis_config_sender import send_axis_config
from lib.camera import generate_frames, generate_ip_camera_frames, generate_rpi_frames, init_camera
from lib.camera import generate_frames, generate_ip_camera_frames, generate_rpi_frames
from lib.json_data_handler import JSONDataHandler
from lib.pid_config_client import AXES as PID_AXES
from lib.pid_config_client import request_pid_gains, send_pid_gains
Expand All @@ -29,7 +29,6 @@ def _save_pid_configs(configs):
# Initialize required components
data_handler = JSONDataHandler()
config_handler = JSONDataHandler(file_path=data_path("config.json"))
camera = init_camera()

# Defaults for axis configs
_DEFAULT_IMU_AXES = {"yaw": "+yaw", "pitch": "+pitch", "roll": "+roll"}
Expand Down Expand Up @@ -129,10 +128,26 @@ def rpi_camera_status():
@app.route("/video_feed")
def video_feed():
"""Return a streaming MJPEG response from the camera."""
return Response(
generate_frames(camera),
default_cam = current_app.config.get("DEFAULT_CAMERA")
if default_cam is None:
return "Default camera not initialized", 503
resp = Response(
generate_frames(default_cam),
mimetype="multipart/x-mixed-replace; boundary=frame",
)
resp.headers["Cache-Control"] = "no-store, no-cache, must-revalidate, max-age=0"
resp.headers["Pragma"] = "no-cache"
resp.headers["Expires"] = "0"
resp.headers["X-Accel-Buffering"] = "no"
return resp

@app.route("/api/camera/status")
def camera_status():
"""Return default camera connection status."""
default_cam = current_app.config.get("DEFAULT_CAMERA")
if default_cam:
return jsonify(default_cam.get_status())
return jsonify({"connected": False, "listening": False})

@app.route("/ip_video_feed")
def ip_video_feed():
Expand Down
42 changes: 42 additions & 0 deletions tests/test_camera.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import threading
import time

import cv2

from lib.camera import DefaultCameraReceiver


class FakeClosedCapture:
def isOpened(self):
return False

def release(self):
pass


def test_default_camera_start_returns_before_device_opens(monkeypatch):
opened = threading.Event()
release = threading.Event()

def fake_capture(device_index):
opened.set()
release.wait(timeout=1.0)
return FakeClosedCapture()

monkeypatch.setattr(cv2, "VideoCapture", fake_capture)

camera = DefaultCameraReceiver(device_index=7)
started_at = time.monotonic()
camera.start()
elapsed = time.monotonic() - started_at
try:
assert elapsed < 0.2
assert camera.get_status()["device_index"] == 7
assert opened.wait(timeout=1.0)
finally:
release.set()
camera.stop()

status = camera.get_status()
assert status["connected"] is False
assert status["listening"] is False
Loading