diff --git a/app.py b/app.py index f61acb7..660b256 100644 --- a/app.py +++ b/app.py @@ -3,6 +3,7 @@ from flask import Flask +from lib.aruco_logger import ArucoPipelineLogger from lib.axis_config_sender import send_axis_config from lib.bitmask import init_bitmask from lib.camera import init_camera, init_ip_camera, init_rpi_camera @@ -31,6 +32,9 @@ # Start background IMU receiver (UDP port 5002) app.config["IMU"] = init_imu_receiver(port=5002) +# Tracks ordered ARUCO markers for the pipeline challenge. +app.config["ARUCO_LOGGER"] = ArucoPipelineLogger() + # Load saved IMU axis mapping from config _config = JSONDataHandler(file_path=data_path("config.json")) _saved_axes = _config.get_section("imu_axes") @@ -68,6 +72,7 @@ out_height=rpi_cam_out_height, jpeg_quality=rpi_cam_jpeg_quality, flip_180=rpi_cam_flip_180, + marker_logger=app.config["ARUCO_LOGGER"], ) # Initialize IP camera (SMTSEC SIP-K327GS) via RTSP @@ -82,6 +87,7 @@ out_height=ip_cam_out_height, jpeg_quality=ip_cam_jpeg_quality, flip_180=ip_cam_flip_180, + marker_logger=app.config["ARUCO_LOGGER"], ) # Initialize default local camera for the legacy Camera 1 feed. @@ -91,6 +97,7 @@ app.config["DEFAULT_CAMERA"] = init_camera( device_index=default_cam_device, jpeg_quality=default_cam_jpeg_quality, + marker_logger=app.config["ARUCO_LOGGER"], ) # Start background resource monitor receiver (UDP port 12346) diff --git a/lib/aruco_logger.py b/lib/aruco_logger.py new file mode 100644 index 0000000..8c303e4 --- /dev/null +++ b/lib/aruco_logger.py @@ -0,0 +1,79 @@ +import threading +import time +from datetime import UTC, datetime + + +class ArucoPipelineLogger: + """Tracks ordered ARUCO sightings for the pipeline challenge.""" + + def __init__(self): + self._lock = threading.Lock() + self._enabled = False + self._entries = [] + self._logged_ids = set() + self._visible_ids = [] + self._duplicate_count = 0 + + def start(self): + with self._lock: + self._enabled = True + return self._snapshot_locked() + + def stop(self): + with self._lock: + self._enabled = False + return self._snapshot_locked() + + def clear(self): + with self._lock: + self._entries = [] + self._logged_ids = set() + self._visible_ids = [] + self._duplicate_count = 0 + return self._snapshot_locked() + + def record_visible(self, detections): + ordered = sorted( + detections, + key=lambda marker: ( + marker.get("center", (float("inf"), float("inf")))[0], + marker.get("id", 0), + ), + ) + now = time.time() + + with self._lock: + self._visible_ids = [marker["id"] for marker in ordered] + if not self._enabled: + return self._snapshot_locked() + + for marker in ordered: + marker_id = marker["id"] + if marker_id in self._logged_ids: + self._duplicate_count += 1 + continue + self._logged_ids.add(marker_id) + self._entries.append( + { + "order": len(self._entries) + 1, + "id": marker_id, + "seen_at": _format_timestamp(now), + } + ) + return self._snapshot_locked() + + def snapshot(self): + with self._lock: + return self._snapshot_locked() + + def _snapshot_locked(self): + return { + "enabled": self._enabled, + "entries": list(self._entries), + "visible_ids": list(self._visible_ids), + "duplicate_count": self._duplicate_count, + } + + +def _format_timestamp(timestamp): + return datetime.fromtimestamp(timestamp, UTC).isoformat().replace("+00:00", "Z") diff --git a/lib/camera.py b/lib/camera.py index ff63dd5..f04ee64 100644 --- a/lib/camera.py +++ b/lib/camera.py @@ -7,29 +7,57 @@ import numpy as np -# Dummy ArUco detector to keep code functional after removing autonomous -class DummyArUcoMarkerDetector: - def __init__(self, camera_matrix=None, dist_coeffs=None): - pass +class ArUcoMarkerDetector: + def __init__(self, dictionary_name="DICT_4X4_50", camera_matrix=None, dist_coeffs=None): + aruco = cv2.aruco + self.dictionary_name = dictionary_name + self.camera_matrix = camera_matrix + self.dist_coeffs = dist_coeffs + self._dictionary = aruco.getPredefinedDictionary(getattr(aruco, dictionary_name)) + self._detector = aruco.ArucoDetector(self._dictionary, aruco.DetectorParameters()) def detect_markers(self, frame): - return [], [], [] + return self._detector.detectMarkers(frame) def draw_detected_markers(self, frame, corners, ids): + if ids is not None and len(ids) > 0: + cv2.aruco.drawDetectedMarkers(frame, corners, ids) return frame + def marker_detections(self, corners, ids): + if ids is None: + return [] + + detections = [] + for marker_id, marker_corners in zip(ids.flatten(), corners, strict=False): + points = np.asarray(marker_corners, dtype=np.float32).reshape(-1, 2) + center = points.mean(axis=0) + detections.append({"id": int(marker_id), "center": (float(center[0]), float(center[1]))}) + return detections + + +def _process_aruco_frame(frame, detector, marker_logger): + if detector is None: + return frame + corners, ids, _rejected = detector.detect_markers(frame) + detections = detector.marker_detections(corners, ids) + if marker_logger is not None: + marker_logger.record_visible(detections) + return detector.draw_detected_markers(frame, corners, ids) + class DefaultCameraReceiver: """Reads the default local camera in the background and exposes latest JPEG frame.""" RECONNECT_DELAY = 3.0 - def __init__(self, device_index=0, jpeg_quality=70): + def __init__(self, device_index=0, jpeg_quality=70, marker_logger=None): 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.marker_logger = marker_logger self._stop_event = threading.Event() self._thread = None @@ -43,7 +71,7 @@ def __init__(self, device_index=0, jpeg_quality=70): 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) + self._detector = ArUcoMarkerDetector(camera_matrix=camera_matrix, dist_coeffs=dist_coeffs) def start(self): if self._thread and self._thread.is_alive(): @@ -89,8 +117,7 @@ def get_status(self): } def _set_frame(self, frame): - corners, ids, _rejected = self._detector.detect_markers(frame) - frame = self._detector.draw_detected_markers(frame, corners, ids) + frame = _process_aruco_frame(frame, self._detector, self.marker_logger) ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), self.jpeg_quality]) if not ok: return @@ -183,9 +210,9 @@ def generate_frames(camera): 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): +def init_camera(device_index=0, jpeg_quality=70, marker_logger=None): """Initialize and start the default local camera receiver.""" - receiver = DefaultCameraReceiver(device_index=device_index, jpeg_quality=jpeg_quality) + receiver = DefaultCameraReceiver(device_index=device_index, jpeg_quality=jpeg_quality, marker_logger=marker_logger) receiver.start() return receiver @@ -202,6 +229,7 @@ def __init__( out_height=540, jpeg_quality=70, flip_180=True, + marker_logger=None, ): self.host = host self.port = int(port) @@ -214,6 +242,7 @@ def __init__( self.is_listening = False self.backend = "none" self.last_error = None + self.marker_logger = marker_logger self._stop_event = threading.Event() self._thread = None @@ -227,6 +256,7 @@ def __init__( self._frame_seq = 0 self._last_frame_ts = 0.0 self._placeholder_jpeg = self._build_placeholder_jpeg() + self._detector = ArUcoMarkerDetector() def start(self): if self._thread and self._thread.is_alive(): @@ -278,6 +308,7 @@ def get_status(self): } def _set_frame(self, frame): + frame = _process_aruco_frame(frame, self._detector, self.marker_logger) ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), 80]) if not ok: return @@ -543,6 +574,7 @@ def init_rpi_camera( out_height=540, jpeg_quality=70, flip_180=False, + marker_logger=None, ): receiver = RPiCameraReceiver( host=host, @@ -552,6 +584,7 @@ def init_rpi_camera( out_height=out_height, jpeg_quality=jpeg_quality, flip_180=flip_180, + marker_logger=marker_logger, ) receiver.start() return receiver @@ -589,6 +622,7 @@ def __init__( out_height=540, jpeg_quality=70, flip_180=False, + marker_logger=None, ): self.url = url self.out_width = max(160, int(out_width)) @@ -597,6 +631,7 @@ def __init__( self.flip_180 = bool(flip_180) self.is_connected = False self.last_error = None + self.marker_logger = marker_logger self._stop_event = threading.Event() self._thread = None @@ -608,6 +643,7 @@ def __init__( self._frame_seq = 0 self._last_frame_ts = 0.0 self._placeholder_jpeg = self._build_placeholder_jpeg() + self._detector = ArUcoMarkerDetector() def start(self): if self._thread and self._thread.is_alive(): @@ -655,6 +691,7 @@ def get_status(self): } def _set_frame(self, frame): + frame = _process_aruco_frame(frame, self._detector, self.marker_logger) ok, buf = cv2.imencode( ".jpg", frame, @@ -751,6 +788,7 @@ def init_ip_camera( out_height=540, jpeg_quality=70, flip_180=False, + marker_logger=None, ): """Initialize and start an IP camera receiver (RTSP/HTTP).""" receiver = IPCameraReceiver( @@ -759,6 +797,7 @@ def init_ip_camera( out_height=out_height, jpeg_quality=jpeg_quality, flip_180=flip_180, + marker_logger=marker_logger, ) receiver.start() return receiver diff --git a/routes.py b/routes.py index 92fda4d..2937a58 100644 --- a/routes.py +++ b/routes.py @@ -239,6 +239,38 @@ def ip_camera_status(): return jsonify(ip_cam.get_status()) return jsonify({"connected": False}) + @app.route("/api/aruco-log", methods=["GET"]) + def aruco_log_status(): + """Return ordered ARUCO marker sightings for the pipeline challenge.""" + logger = current_app.config.get("ARUCO_LOGGER") + if not logger: + return jsonify({"ok": False, "error": "ARUCO logger unavailable"}), 503 + return jsonify({"ok": True, "log": logger.snapshot()}) + + @app.route("/api/aruco-log/start", methods=["POST"]) + def start_aruco_log(): + """Start logging new ARUCO marker IDs.""" + logger = current_app.config.get("ARUCO_LOGGER") + if not logger: + return jsonify({"ok": False, "error": "ARUCO logger unavailable"}), 503 + return jsonify({"ok": True, "log": logger.start()}) + + @app.route("/api/aruco-log/stop", methods=["POST"]) + def stop_aruco_log(): + """Stop logging new ARUCO marker IDs.""" + logger = current_app.config.get("ARUCO_LOGGER") + if not logger: + return jsonify({"ok": False, "error": "ARUCO logger unavailable"}), 503 + return jsonify({"ok": True, "log": logger.stop()}) + + @app.route("/api/aruco-log/clear", methods=["POST"]) + def clear_aruco_log(): + """Clear the ordered ARUCO marker log.""" + logger = current_app.config.get("ARUCO_LOGGER") + if not logger: + return jsonify({"ok": False, "error": "ARUCO logger unavailable"}), 503 + return jsonify({"ok": True, "log": logger.clear()}) + @app.route("/api/thrusters", methods=["GET"]) def get_thrusters(): """API route for thrusters data.""" diff --git a/static/css/pilot.css b/static/css/pilot.css index 81dc4a6..1f41f08 100644 --- a/static/css/pilot.css +++ b/static/css/pilot.css @@ -122,6 +122,12 @@ color: #e0e0e0; } .hud-panel-mt { margin-top: 8px; } +.hud-panel-head { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; +} .hud-label { display: block; @@ -298,6 +304,59 @@ justify-content: center; } .hud-right .hud-panel { min-width: 140px; } +.hud-aruco-panel { + width: 190px; +} +.hud-mode-pill { + border: 1px solid rgba(255, 255, 255, 0.18); + border-radius: 999px; + padding: 1px 7px; + color: #aaa; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.6rem; + line-height: 1.4; +} +.hud-mode-pill.is-on { + border-color: rgba(0, 204, 102, 0.55); + color: #7dffbd; + box-shadow: 0 0 8px rgba(0, 204, 102, 0.25); +} +.hud-aruco-actions { + display: flex; + gap: 6px; + margin: 7px 0 9px; +} +.hud-aruco-actions .pilot-feed-btn { + flex: 1; + padding: 4px 8px; +} +.hud-aruco-visible { + min-height: 1.2rem; + margin: 2px 0 8px; +} +.hud-aruco-log-label { + display: block; + margin-top: 4px; +} +.hud-aruco-log { + display: grid; + gap: 3px; + max-height: 180px; + margin: 4px 0 0; + padding-left: 22px; + overflow-y: auto; + color: #f4fdff; + font-family: 'Consolas', 'Monaco', monospace; + font-size: 0.82rem; +} +.hud-aruco-log:empty::before { + content: "none"; + margin-left: -22px; + color: #777; +} +.hud-aruco-log li::marker { + color: rgba(0, 222, 255, 0.55); +} .hud-telem-grid { display: grid; @@ -433,12 +492,21 @@ } @media (max-width: 768px) { - .hud-left, .hud-right { + .hud-left { display: none; /* hide side panels on small screens */ } .hud-overlay { grid-template-columns: 0 1fr 0; } + .hud-right { + position: absolute; + right: 12px; + top: 58px; + justify-content: flex-start; + } + .hud-aruco-panel { + width: 170px; + } .hud-big-value { font-size: 1.2rem; } } diff --git a/static/js/pilot.js b/static/js/pilot.js index 338d333..fcba66f 100644 --- a/static/js/pilot.js +++ b/static/js/pilot.js @@ -19,6 +19,14 @@ loadedOnce: false, fitContain: false, }; + const aruco = { + enabled: false, + state: null, + toggleBtn: null, + clearBtn: null, + visible: null, + log: null, + }; function setCameraState(state, label) { if (!camera.status || !camera.stateText) return; @@ -272,6 +280,63 @@ } catch (_) { /* silent */ } } + function renderArucoLog(log) { + aruco.enabled = Boolean(log && log.enabled); + if (aruco.state) { + aruco.state.textContent = aruco.enabled ? "ON" : "OFF"; + aruco.state.classList.toggle("is-on", aruco.enabled); + } + if (aruco.toggleBtn) { + aruco.toggleBtn.textContent = aruco.enabled ? "Stop" : "Start"; + } + if (aruco.visible) { + const visibleIds = log && Array.isArray(log.visible_ids) ? log.visible_ids : []; + aruco.visible.textContent = visibleIds.length > 0 ? visibleIds.join(", ") : "--"; + } + if (aruco.log) { + const entries = log && Array.isArray(log.entries) ? log.entries : []; + aruco.log.innerHTML = ""; + entries.forEach((entry) => { + const item = document.createElement("li"); + item.textContent = `ID ${entry.id}`; + aruco.log.appendChild(item); + }); + } + } + + async function fetchArucoLog() { + try { + const res = await fetch("/api/aruco-log"); + const data = await res.json(); + if (data.ok) renderArucoLog(data.log); + } catch (_) { /* silent */ } + } + + async function postArucoAction(action) { + try { + const res = await fetch(`/api/aruco-log/${action}`, { method: "POST" }); + const data = await res.json(); + if (data.ok) renderArucoLog(data.log); + } catch (_) { /* silent */ } + } + + function initArucoControls() { + aruco.state = document.getElementById("hud-aruco-state"); + aruco.toggleBtn = document.getElementById("hud-aruco-toggle"); + aruco.clearBtn = document.getElementById("hud-aruco-clear"); + aruco.visible = document.getElementById("hud-aruco-visible"); + aruco.log = document.getElementById("hud-aruco-log"); + + if (aruco.toggleBtn) { + aruco.toggleBtn.addEventListener("click", () => { + postArucoAction(aruco.enabled ? "stop" : "start"); + }); + } + if (aruco.clearBtn) { + aruco.clearBtn.addEventListener("click", () => postArucoAction("clear")); + } + } + function initCameraFeed() { camera.img = document.getElementById("pilot-camera"); camera.shell = document.getElementById("pilot-feed-shell"); @@ -314,6 +379,7 @@ function init() { buildCompassStrip(); initCameraFeed(); + initArucoControls(); // Hide the page header/footer for immersive view const header = document.querySelector("header.header"); @@ -328,6 +394,7 @@ setInterval(fetchThrusters, 2000); setInterval(fetchLights, 3000); setInterval(fetchCameraStatus, 5000); + setInterval(fetchArucoLog, 1000); setInterval(updateMissionTime, 1000); // Initial fetches @@ -337,6 +404,7 @@ fetchThrusters(); fetchLights(); fetchCameraStatus(); + fetchArucoLog(); } if (document.readyState === "loading") { diff --git a/static/templates/pilot.html b/static/templates/pilot.html index 22e8d28..85cdccd 100644 --- a/static/templates/pilot.html +++ b/static/templates/pilot.html @@ -101,7 +101,25 @@ --> - + +
+
+
+ ARUCO + OFF +
+
+ + +
+
VISIBLE
+
--
+
ORDER
+
    +
    +
    + +