diff --git a/app.py b/app.py index d557ac3..a8568a7 100644 --- a/app.py +++ b/app.py @@ -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"]) @@ -99,7 +109,6 @@ app.config["SYSTEM_CONTROL"] = SystemControlClient() register_routes(app) -camera = init_camera() def _shutdown(): @@ -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() diff --git a/lib/camera.py b/lib/camera.py index e2aa075..56f55ba 100644 --- a/lib/camera.py +++ b/lib/camera.py @@ -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.""" diff --git a/routes.py b/routes.py index 521c87c..5856fe1 100644 --- a/routes.py +++ b/routes.py @@ -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 @@ -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"} @@ -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(): diff --git a/tests/test_camera.py b/tests/test_camera.py new file mode 100644 index 0000000..0fde650 --- /dev/null +++ b/tests/test_camera.py @@ -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