Skip to content
Open
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
192 changes: 192 additions & 0 deletions docs/swagger.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down Expand Up @@ -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:
Expand Down
83 changes: 79 additions & 4 deletions lib/aruco_logger.py
Original file line number Diff line number Diff line change
@@ -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:
Expand All @@ -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: (
Expand All @@ -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
Expand All @@ -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"]
Loading
Loading