diff --git a/docs/swagger.yml b/docs/swagger.yml index 05bf635..1950d5e 100644 --- a/docs/swagger.yml +++ b/docs/swagger.yml @@ -22,6 +22,8 @@ tags: description: Camera status and MJPEG streams - name: Telemetry description: ROV telemetry, sensors, logs, and live control data + - name: ARUCO + description: Pipeline challenge marker logging - name: ROV Command description: Manual command output and uplink state - name: IMU @@ -272,6 +274,150 @@ paths: items: $ref: "#/definitions/LogEntry" + /api/aruco-log: + get: + tags: [ARUCO] + summary: Get ordered ARUCO marker sightings + responses: + 200: + description: Current ARUCO log state + schema: + type: object + properties: + ok: + type: boolean + example: true + log: + $ref: "#/definitions/ArucoLogState" + 503: + description: ARUCO logger is unavailable + + /api/aruco-log/start: + post: + tags: [ARUCO] + summary: Start ordered ARUCO marker logging + responses: + 200: + description: Updated ARUCO log state + schema: + type: object + properties: + ok: + type: boolean + example: true + log: + $ref: "#/definitions/ArucoLogState" + 503: + description: ARUCO logger is unavailable + + /api/aruco-log/stop: + post: + tags: [ARUCO] + summary: Stop ordered ARUCO marker logging + responses: + 200: + description: Updated ARUCO log state + schema: + type: object + properties: + ok: + type: boolean + example: true + log: + $ref: "#/definitions/ArucoLogState" + 503: + description: ARUCO logger is unavailable + + /api/aruco-log/clear: + post: + tags: [ARUCO] + summary: Clear ordered ARUCO marker sightings + responses: + 200: + description: Cleared ARUCO log state + schema: + type: object + properties: + ok: + type: boolean + example: true + log: + $ref: "#/definitions/ArucoLogState" + 503: + description: ARUCO logger is unavailable + + /api/aruco-log/region: + post: + tags: [ARUCO] + summary: Set the centered ARUCO logging region scale + parameters: + - in: body + name: region + required: true + schema: + type: object + properties: + scale: + type: number + minimum: 0.2 + maximum: 1.0 + example: 0.7 + responses: + 200: + description: Updated ARUCO log state + schema: + type: object + properties: + ok: + type: boolean + example: true + log: + $ref: "#/definitions/ArucoLogState" + 503: + description: ARUCO logger is unavailable + + /api/aruco-log/marker-overlay: + post: + tags: [ARUCO] + summary: Enable or disable ARUCO marker outlines in the camera feed + parameters: + - in: body + name: marker_overlay + required: true + schema: + type: object + properties: + enabled: + type: boolean + example: true + responses: + 200: + description: Updated ARUCO log state + schema: + type: object + properties: + ok: + type: boolean + example: true + log: + $ref: "#/definitions/ArucoLogState" + 503: + description: ARUCO logger is unavailable + + /api/aruco-log/export.csv: + get: + tags: [ARUCO] + summary: Download ordered ARUCO marker sightings as CSV + produces: + - text/csv + responses: + 200: + description: CSV attachment with columns order,id,seen_at + schema: + type: file + 503: + description: ARUCO logger is unavailable + /api/setpoint/status: get: tags: [Debug] @@ -1182,6 +1328,52 @@ definitions: type: string example: control loop started + ArucoLogEntry: + type: object + properties: + order: + type: integer + example: 1 + id: + type: integer + example: 12 + seen_at: + type: string + format: date-time + example: "2026-05-28T13:45:10Z" + + ArucoLogState: + type: object + properties: + enabled: + type: boolean + example: true + entries: + type: array + items: + $ref: "#/definitions/ArucoLogEntry" + visible_ids: + type: array + items: + type: integer + example: [12, 15] + outside_ids: + type: array + items: + type: integer + example: [4] + duplicate_count: + type: integer + example: 3 + region_scale: + type: number + minimum: 0.2 + maximum: 1.0 + example: 0.7 + marker_overlay_enabled: + type: boolean + example: true + SetpointOverrideState: type: object properties: diff --git a/lib/aruco_logger.py b/lib/aruco_logger.py index 8c303e4..80a197d 100644 --- a/lib/aruco_logger.py +++ b/lib/aruco_logger.py @@ -1,18 +1,27 @@ +import csv +import io import threading import time from datetime import UTC, datetime +MIN_REGION_SCALE = 0.2 +MAX_REGION_SCALE = 1.0 +DEFAULT_REGION_SCALE = 0.7 + class ArucoPipelineLogger: """Tracks ordered ARUCO sightings for the pipeline challenge.""" - def __init__(self): + def __init__(self, region_scale=DEFAULT_REGION_SCALE, marker_overlay_enabled=True): self._lock = threading.Lock() self._enabled = False self._entries = [] self._logged_ids = set() self._visible_ids = [] + self._outside_ids = [] self._duplicate_count = 0 + self._region_scale = _coerce_region_scale(region_scale) + self._marker_overlay_enabled = bool(marker_overlay_enabled) def start(self): with self._lock: @@ -29,10 +38,30 @@ def clear(self): self._entries = [] self._logged_ids = set() self._visible_ids = [] + self._outside_ids = [] self._duplicate_count = 0 return self._snapshot_locked() - def record_visible(self, detections): + def set_region_scale(self, scale): + with self._lock: + self._region_scale = _coerce_region_scale(scale) + return self._snapshot_locked() + + def set_marker_overlay_enabled(self, enabled): + with self._lock: + self._marker_overlay_enabled = bool(enabled) + return self._snapshot_locked() + + def marker_overlay_enabled(self): + with self._lock: + return self._marker_overlay_enabled + + def region_for_frame(self, frame_shape): + with self._lock: + scale = self._region_scale + return _region_for_frame(frame_shape, scale) + + def record_visible(self, detections, frame_shape=None): ordered = sorted( detections, key=lambda marker: ( @@ -43,11 +72,15 @@ def record_visible(self, detections): now = time.time() with self._lock: - self._visible_ids = [marker["id"] for marker in ordered] + region = _region_for_frame(frame_shape, self._region_scale) + inside = [marker for marker in ordered if _marker_inside_region(marker, region)] + outside = [marker for marker in ordered if not _marker_inside_region(marker, region)] + self._visible_ids = [marker["id"] for marker in inside] + self._outside_ids = [marker["id"] for marker in outside] if not self._enabled: return self._snapshot_locked() - for marker in ordered: + for marker in inside: marker_id = marker["id"] if marker_id in self._logged_ids: self._duplicate_count += 1 @@ -66,14 +99,56 @@ def snapshot(self): with self._lock: return self._snapshot_locked() + def to_csv(self): + with self._lock: + output = io.StringIO() + writer = csv.DictWriter(output, fieldnames=["order", "id", "seen_at"], lineterminator="\n") + writer.writeheader() + writer.writerows(self._entries) + return output.getvalue() + def _snapshot_locked(self): return { "enabled": self._enabled, "entries": list(self._entries), "visible_ids": list(self._visible_ids), + "outside_ids": list(self._outside_ids), "duplicate_count": self._duplicate_count, + "region_scale": self._region_scale, + "marker_overlay_enabled": self._marker_overlay_enabled, } def _format_timestamp(timestamp): return datetime.fromtimestamp(timestamp, UTC).isoformat().replace("+00:00", "Z") + + +def _coerce_region_scale(scale): + try: + value = float(scale) + except (TypeError, ValueError): + value = DEFAULT_REGION_SCALE + return min(MAX_REGION_SCALE, max(MIN_REGION_SCALE, value)) + + +def _region_for_frame(frame_shape, scale): + if frame_shape is None: + return None + height, width = int(frame_shape[0]), int(frame_shape[1]) + if height <= 0 or width <= 0: + return None + region_width = max(1, int(round(width * scale))) + region_height = max(1, int(round(height * scale))) + x = max(0, (width - region_width) // 2) + y = max(0, (height - region_height) // 2) + return {"x": x, "y": y, "width": region_width, "height": region_height} + + +def _marker_inside_region(marker, region): + if region is None: + return True + center = marker.get("center") + if center is None or len(center) < 2: + return False + x, y = center[0], center[1] + return region["x"] <= x <= region["x"] + region["width"] and region["y"] <= y <= region["y"] + region["height"] diff --git a/lib/camera.py b/lib/camera.py index f04ee64..5ee4334 100644 --- a/lib/camera.py +++ b/lib/camera.py @@ -38,12 +38,88 @@ def marker_detections(self, corners, ids): def _process_aruco_frame(frame, detector, marker_logger): if detector is None: - return frame + return _draw_aruco_region_overlay(frame, marker_logger) + 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) + marker_logger.record_visible(detections, frame.shape[:2]) + if _marker_overlay_enabled(marker_logger): + frame = detector.draw_detected_markers(frame, corners, ids) + return _draw_aruco_region_overlay(frame, marker_logger) + + +def _process_aruco_jpeg(jpg, detector, marker_logger, jpeg_quality=70): + if detector is None and marker_logger is None: + return jpg + + data = np.frombuffer(jpg, dtype=np.uint8) + frame = cv2.imdecode(data, cv2.IMREAD_COLOR) + if frame is None: + return jpg + + frame = _process_aruco_frame(frame, detector, marker_logger) + ok, buf = cv2.imencode(".jpg", frame, [int(cv2.IMWRITE_JPEG_QUALITY), jpeg_quality]) + return buf.tobytes() if ok else jpg + + +def _marker_overlay_enabled(marker_logger): + if marker_logger is None: + return True + return marker_logger.marker_overlay_enabled() + + +def _draw_aruco_region_overlay(frame, marker_logger): + if marker_logger is None: + return frame + + region = marker_logger.region_for_frame(frame.shape[:2]) + if region is None: + return frame + + x = region["x"] + y = region["y"] + width = region["width"] + height = region["height"] + frame_height, frame_width = frame.shape[:2] + x2 = min(frame_width, x + width) + y2 = min(frame_height, y + height) + + overlay = frame.copy() + shade = (0, 0, 0) + cv2.rectangle(overlay, (0, 0), (frame_width, y), shade, -1) + cv2.rectangle(overlay, (0, y2), (frame_width, frame_height), shade, -1) + cv2.rectangle(overlay, (0, y), (x, y2), shade, -1) + cv2.rectangle(overlay, (x2, y), (frame_width, y2), shade, -1) + cv2.addWeighted(overlay, 0.38, frame, 0.62, 0, frame) + + color = (0, 255, 170) + cv2.rectangle(frame, (x, y), (x2, y2), color, 2) + tick = max(12, min(frame_width, frame_height) // 28) + for start, end in ( + ((x, y), (x + tick, y)), + ((x, y), (x, y + tick)), + ((x2, y), (x2 - tick, y)), + ((x2, y), (x2, y + tick)), + ((x, y2), (x + tick, y2)), + ((x, y2), (x, y2 - tick)), + ((x2, y2), (x2 - tick, y2)), + ((x2, y2), (x2, y2 - tick)), + ): + cv2.line(frame, start, end, (0, 222, 255), 3) + + font_scale = max(0.45, min(frame_width, frame_height) / 1200) + cv2.putText( + frame, + "ARUCO LOG AREA", + (x + 8, max(18, y - 8)), + cv2.FONT_HERSHEY_SIMPLEX, + font_scale, + color, + 1, + cv2.LINE_AA, + ) + return frame class DefaultCameraReceiver: @@ -506,7 +582,7 @@ def _run_gst_subprocess(self): if not had_frame: print("[RPi Camera] ✓ Receiving frames") had_frame = True - # JPEG is already encoded by GStreamer; avoid re-decode/re-encode. + jpg = _process_aruco_jpeg(jpg, self._detector, self.marker_logger, self.jpeg_quality) self._set_jpeg_bytes(jpg) if self._is_stream_stale(): @@ -692,6 +768,8 @@ def get_status(self): def _set_frame(self, frame): frame = _process_aruco_frame(frame, self._detector, self.marker_logger) + if frame.shape[1] != self.out_width or frame.shape[0] != self.out_height: + frame = cv2.resize(frame, (self.out_width, self.out_height)) ok, buf = cv2.imencode( ".jpg", frame, @@ -768,8 +846,6 @@ def _run_loop(self): print("[IP Camera] ✓ Receiving frames") had_frame = True - if frame.shape[1] != self.out_width or frame.shape[0] != self.out_height: - frame = cv2.resize(frame, (self.out_width, self.out_height)) if self.flip_180: frame = cv2.rotate(frame, cv2.ROTATE_180) diff --git a/routes.py b/routes.py index 5e33c19..d53181a 100644 --- a/routes.py +++ b/routes.py @@ -1,6 +1,7 @@ import json import math import re +from datetime import UTC, datetime from pathlib import Path from flask import Response, current_app, jsonify, render_template, request, send_from_directory @@ -103,6 +104,10 @@ def _send_full_axis_config(): send_axis_config(imu_axes=imu_axes, accel_axes=accel_axes, offset=offset) +def _download_timestamp(): + return datetime.now(UTC).strftime("%Y%m%d_%H%M%S") + + # Default resource data (used when no telemetry received) DEFAULT_RESOURCES = { "sequence": 0, @@ -271,6 +276,36 @@ def clear_aruco_log(): return jsonify({"ok": False, "error": "ARUCO logger unavailable"}), 503 return jsonify({"ok": True, "log": logger.clear()}) + @app.route("/api/aruco-log/region", methods=["POST"]) + def set_aruco_log_region(): + """Set the centered ARUCO logging region scale.""" + logger = current_app.config.get("ARUCO_LOGGER") + if not logger: + return jsonify({"ok": False, "error": "ARUCO logger unavailable"}), 503 + payload = request.get_json(silent=True) or {} + return jsonify({"ok": True, "log": logger.set_region_scale(payload.get("scale"))}) + + @app.route("/api/aruco-log/marker-overlay", methods=["POST"]) + def set_aruco_marker_overlay(): + """Enable or disable ARUCO marker outlines in the camera feed.""" + logger = current_app.config.get("ARUCO_LOGGER") + if not logger: + return jsonify({"ok": False, "error": "ARUCO logger unavailable"}), 503 + payload = request.get_json(silent=True) or {} + return jsonify({"ok": True, "log": logger.set_marker_overlay_enabled(bool(payload.get("enabled")))}) + + @app.route("/api/aruco-log/export.csv", methods=["GET"]) + def export_aruco_log_csv(): + """Download ordered ARUCO marker sightings as CSV.""" + logger = current_app.config.get("ARUCO_LOGGER") + if not logger: + return jsonify({"ok": False, "error": "ARUCO logger unavailable"}), 503 + filename = f"aruco_log_{_download_timestamp()}.csv" + response = Response(logger.to_csv(), mimetype="text/csv") + response.headers["Content-Disposition"] = f'attachment; filename="{filename}"' + response.headers["Cache-Control"] = "no-store" + return response + @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 1f41f08..e2fcea2 100644 --- a/static/css/pilot.css +++ b/static/css/pilot.css @@ -330,9 +330,46 @@ flex: 1; padding: 4px 8px; } +.hud-aruco-overlay-actions { + display: grid; + margin: -3px 0 8px; +} +.hud-aruco-overlay-actions .pilot-feed-btn { + padding: 4px 8px; +} +.pilot-feed-btn.is-on { + border-color: rgba(0, 204, 102, 0.55); + color: #7dffbd; + box-shadow: 0 0 8px rgba(0, 204, 102, 0.2); +} +.hud-aruco-scale { + margin: 0 0 9px; +} +.hud-aruco-scale-head { + display: flex; + justify-content: space-between; + align-items: center; + gap: 8px; + margin-bottom: 2px; +} +.hud-aruco-scale-head .hud-mono { + font-size: 0.72rem; + color: #d7f8ff; +} +.hud-aruco-scale input[type="range"] { + display: block; + width: 100%; + height: 16px; + accent-color: #00deff; +} .hud-aruco-visible { + min-height: 1.2rem; + margin: 2px 0 6px; +} +.hud-aruco-outside { min-height: 1.2rem; margin: 2px 0 8px; + color: #aaa; } .hud-aruco-log-label { display: block; diff --git a/static/js/pilot.js b/static/js/pilot.js index fcba66f..0711889 100644 --- a/static/js/pilot.js +++ b/static/js/pilot.js @@ -24,8 +24,14 @@ state: null, toggleBtn: null, clearBtn: null, + saveBtn: null, + markerOverlayBtn: null, + scaleInput: null, + scaleValue: null, visible: null, + outside: null, log: null, + scaleTimer: null, }; function setCameraState(state, label) { @@ -289,10 +295,27 @@ if (aruco.toggleBtn) { aruco.toggleBtn.textContent = aruco.enabled ? "Stop" : "Start"; } + if (aruco.markerOverlayBtn && log) { + const markerOverlayEnabled = log.marker_overlay_enabled !== false; + aruco.markerOverlayBtn.textContent = markerOverlayEnabled ? "Markers On" : "Markers Off"; + aruco.markerOverlayBtn.classList.toggle("is-on", markerOverlayEnabled); + aruco.markerOverlayBtn.setAttribute("aria-pressed", markerOverlayEnabled ? "true" : "false"); + } if (aruco.visible) { const visibleIds = log && Array.isArray(log.visible_ids) ? log.visible_ids : []; aruco.visible.textContent = visibleIds.length > 0 ? visibleIds.join(", ") : "--"; } + if (aruco.outside) { + const outsideIds = log && Array.isArray(log.outside_ids) ? log.outside_ids : []; + aruco.outside.textContent = outsideIds.length > 0 ? outsideIds.join(", ") : "--"; + } + if (log && Number.isFinite(Number(log.region_scale))) { + const scalePercent = Math.round(Number(log.region_scale) * 100); + if (aruco.scaleValue) aruco.scaleValue.textContent = `${scalePercent}%`; + if (aruco.scaleInput && document.activeElement !== aruco.scaleInput) { + aruco.scaleInput.value = String(scalePercent); + } + } if (aruco.log) { const entries = log && Array.isArray(log.entries) ? log.entries : []; aruco.log.innerHTML = ""; @@ -320,11 +343,79 @@ } catch (_) { /* silent */ } } + async function postArucoRegionScale(scalePercent) { + try { + const res = await fetch("/api/aruco-log/region", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ scale: Number(scalePercent) / 100 }), + }); + const data = await res.json(); + if (data.ok) renderArucoLog(data.log); + } catch (_) { /* silent */ } + } + + async function postArucoMarkerOverlay(enabled) { + try { + const res = await fetch("/api/aruco-log/marker-overlay", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ enabled }), + }); + const data = await res.json(); + if (data.ok) renderArucoLog(data.log); + } catch (_) { /* silent */ } + } + + function arucoDownloadName() { + const now = new Date(); + const ts = now.getFullYear() + + String(now.getMonth() + 1).padStart(2, "0") + + String(now.getDate()).padStart(2, "0") + "_" + + String(now.getHours()).padStart(2, "0") + + String(now.getMinutes()).padStart(2, "0") + + String(now.getSeconds()).padStart(2, "0"); + return `aruco_log_${ts}.csv`; + } + + function filenameFromDisposition(disposition) { + if (!disposition) return null; + const match = disposition.match(/filename="?([^"]+)"?/i); + return match ? match[1] : null; + } + + async function saveArucoLog() { + if (!aruco.saveBtn) return; + aruco.saveBtn.disabled = true; + try { + const res = await fetch("/api/aruco-log/export.csv"); + if (!res.ok) return; + const blob = await res.blob(); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = filenameFromDisposition(res.headers.get("Content-Disposition")) || arucoDownloadName(); + document.body.appendChild(a); + a.click(); + document.body.removeChild(a); + URL.revokeObjectURL(url); + } catch (_) { + /* silent */ + } finally { + aruco.saveBtn.disabled = false; + } + } + function initArucoControls() { aruco.state = document.getElementById("hud-aruco-state"); aruco.toggleBtn = document.getElementById("hud-aruco-toggle"); aruco.clearBtn = document.getElementById("hud-aruco-clear"); + aruco.saveBtn = document.getElementById("hud-aruco-save"); + aruco.markerOverlayBtn = document.getElementById("hud-aruco-marker-overlay"); + aruco.scaleInput = document.getElementById("hud-aruco-scale"); + aruco.scaleValue = document.getElementById("hud-aruco-scale-value"); aruco.visible = document.getElementById("hud-aruco-visible"); + aruco.outside = document.getElementById("hud-aruco-outside"); aruco.log = document.getElementById("hud-aruco-log"); if (aruco.toggleBtn) { @@ -335,6 +426,25 @@ if (aruco.clearBtn) { aruco.clearBtn.addEventListener("click", () => postArucoAction("clear")); } + if (aruco.saveBtn) { + aruco.saveBtn.addEventListener("click", saveArucoLog); + } + if (aruco.markerOverlayBtn) { + aruco.markerOverlayBtn.addEventListener("click", () => { + postArucoMarkerOverlay(aruco.markerOverlayBtn.getAttribute("aria-pressed") !== "true"); + }); + } + if (aruco.scaleInput) { + aruco.scaleInput.addEventListener("input", () => { + if (aruco.scaleValue) aruco.scaleValue.textContent = `${aruco.scaleInput.value}%`; + clearTimeout(aruco.scaleTimer); + aruco.scaleTimer = setTimeout(() => postArucoRegionScale(aruco.scaleInput.value), 120); + }); + aruco.scaleInput.addEventListener("change", () => { + clearTimeout(aruco.scaleTimer); + postArucoRegionScale(aruco.scaleInput.value); + }); + } } function initCameraFeed() { diff --git a/static/templates/pilot.html b/static/templates/pilot.html index 85cdccd..8921d8a 100644 --- a/static/templates/pilot.html +++ b/static/templates/pilot.html @@ -111,9 +111,22 @@