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 @@
+ +
+
+ +
+
+
+ AREA + 70% +
+
VISIBLE
--
+
OUTSIDE
+
--
ORDER
    diff --git a/tests/test_aruco_logger.py b/tests/test_aruco_logger.py index 3241ee2..c30fa18 100644 --- a/tests/test_aruco_logger.py +++ b/tests/test_aruco_logger.py @@ -50,3 +50,53 @@ def test_aruco_logger_clear_resets_logged_ids(): logger.start() snapshot = logger.record_visible([{"id": 8, "center": (0, 0)}]) assert [entry["id"] for entry in snapshot["entries"]] == [8] + + +def test_aruco_logger_exports_entries_as_csv(): + logger = ArucoPipelineLogger() + logger.start() + logger.record_visible([{"id": 12, "center": (0, 0)}]) + + lines = logger.to_csv().splitlines() + + assert lines[0] == "order,id,seen_at" + assert lines[1].startswith("1,12,") + + +def test_aruco_logger_filters_logging_to_center_region(): + logger = ArucoPipelineLogger(region_scale=0.5) + logger.start() + + snapshot = logger.record_visible( + [ + {"id": 1, "center": (50, 50)}, + {"id": 2, "center": (5, 50)}, + ], + frame_shape=(100, 100), + ) + + assert snapshot["visible_ids"] == [1] + assert snapshot["outside_ids"] == [2] + assert [entry["id"] for entry in snapshot["entries"]] == [1] + + +def test_aruco_logger_clamps_region_scale(): + logger = ArucoPipelineLogger() + + snapshot = logger.set_region_scale(2.0) + assert snapshot["region_scale"] == 1.0 + + snapshot = logger.set_region_scale(0.01) + assert snapshot["region_scale"] == 0.2 + + +def test_aruco_logger_marker_overlay_defaults_on_and_toggles(): + logger = ArucoPipelineLogger() + + assert logger.snapshot()["marker_overlay_enabled"] is True + assert logger.marker_overlay_enabled() is True + + snapshot = logger.set_marker_overlay_enabled(False) + + assert snapshot["marker_overlay_enabled"] is False + assert logger.marker_overlay_enabled() is False diff --git a/tests/test_aruco_routes.py b/tests/test_aruco_routes.py index fe34bfa..e5b83e5 100644 --- a/tests/test_aruco_routes.py +++ b/tests/test_aruco_routes.py @@ -40,3 +40,69 @@ def test_aruco_log_routes_report_missing_logger(): assert res.status_code == 503 assert res.get_json()["ok"] is False + + +def test_aruco_log_csv_export_downloads_snapshot(): + logger = ArucoPipelineLogger() + logger.start() + logger.record_visible([{"id": 5, "center": (10, 10)}]) + client = _client_with_logger(logger) + + res = client.get("/api/aruco-log/export.csv") + + assert res.status_code == 200 + assert res.mimetype == "text/csv" + assert "attachment" in res.headers["Content-Disposition"] + assert res.get_data(as_text=True).splitlines()[0] == "order,id,seen_at" + assert res.get_data(as_text=True).splitlines()[1].startswith("1,5,") + + +def test_aruco_log_csv_export_reports_missing_logger(): + client = _client_with_logger() + + res = client.get("/api/aruco-log/export.csv") + + assert res.status_code == 503 + assert res.get_json()["ok"] is False + + +def test_aruco_log_region_route_updates_scale(): + logger = ArucoPipelineLogger() + client = _client_with_logger(logger) + + res = client.post("/api/aruco-log/region", json={"scale": 0.45}) + + assert res.status_code == 200 + data = res.get_json() + assert data["ok"] is True + assert data["log"]["region_scale"] == 0.45 + + +def test_aruco_log_region_route_reports_missing_logger(): + client = _client_with_logger() + + res = client.post("/api/aruco-log/region", json={"scale": 0.45}) + + assert res.status_code == 503 + assert res.get_json()["ok"] is False + + +def test_aruco_log_marker_overlay_route_updates_state(): + logger = ArucoPipelineLogger() + client = _client_with_logger(logger) + + res = client.post("/api/aruco-log/marker-overlay", json={"enabled": False}) + + assert res.status_code == 200 + data = res.get_json() + assert data["ok"] is True + assert data["log"]["marker_overlay_enabled"] is False + + +def test_aruco_log_marker_overlay_route_reports_missing_logger(): + client = _client_with_logger() + + res = client.post("/api/aruco-log/marker-overlay", json={"enabled": False}) + + assert res.status_code == 503 + assert res.get_json()["ok"] is False diff --git a/tests/test_camera.py b/tests/test_camera.py index 5cf2ac4..23f2508 100644 --- a/tests/test_camera.py +++ b/tests/test_camera.py @@ -5,7 +5,7 @@ import numpy as np from lib.aruco_logger import ArucoPipelineLogger -from lib.camera import DefaultCameraReceiver, IPCameraReceiver +from lib.camera import DefaultCameraReceiver, IPCameraReceiver, _process_aruco_jpeg class FakeClosedCapture: @@ -45,28 +45,68 @@ def fake_capture(device_index): class FakeArucoDetector: + def __init__(self): + self.draw_calls = 0 + self.frame_shapes = [] + def detect_markers(self, frame): + self.frame_shapes.append(frame.shape[:2]) return [], None, [] def marker_detections(self, corners, ids): return [ - {"id": 3, "center": (300, 100)}, - {"id": 2, "center": (200, 100)}, + {"id": 3, "center": (12, 10)}, + {"id": 2, "center": (8, 10)}, ] def draw_detected_markers(self, frame, corners, ids): + self.draw_calls += 1 return frame -def test_ip_camera_frame_updates_aruco_logger(): +def test_ip_camera_frame_updates_aruco_logger_before_display_resize(): logger = ArucoPipelineLogger() logger.start() - camera = IPCameraReceiver("rtsp://example.invalid/stream", marker_logger=logger) - camera._detector = FakeArucoDetector() + camera = IPCameraReceiver("rtsp://example.invalid/stream", out_width=10, out_height=10, marker_logger=logger) + detector = FakeArucoDetector() + camera._detector = detector camera._set_frame(np.zeros((20, 20, 3), dtype=np.uint8)) snapshot = logger.snapshot() assert [entry["id"] for entry in snapshot["entries"]] == [2, 3] assert snapshot["visible_ids"] == [2, 3] + assert detector.frame_shapes == [(20, 20)] assert camera.get_latest_jpeg() is not None + + +def test_ip_camera_marker_overlay_can_be_disabled(): + logger = ArucoPipelineLogger() + logger.set_marker_overlay_enabled(False) + camera = IPCameraReceiver("rtsp://example.invalid/stream", marker_logger=logger) + detector = FakeArucoDetector() + camera._detector = detector + + camera._set_frame(np.zeros((20, 20, 3), dtype=np.uint8)) + + assert detector.draw_calls == 0 + + logger.set_marker_overlay_enabled(True) + camera._set_frame(np.zeros((20, 20, 3), dtype=np.uint8)) + + assert detector.draw_calls == 1 + + +def test_aruco_jpeg_processing_updates_logger(): + logger = ArucoPipelineLogger() + logger.start() + detector = FakeArucoDetector() + ok, buf = cv2.imencode(".jpg", np.zeros((20, 20, 3), dtype=np.uint8)) + assert ok + + jpg = _process_aruco_jpeg(buf.tobytes(), detector, logger, jpeg_quality=70) + + snapshot = logger.snapshot() + assert [entry["id"] for entry in snapshot["entries"]] == [2, 3] + assert detector.draw_calls == 1 + assert jpg.startswith(b"\xff\xd8")