diff --git a/perception/__init__.py b/perception/__init__.py index a755b8d..81ec825 100644 --- a/perception/__init__.py +++ b/perception/__init__.py @@ -1,8 +1,8 @@ import perception.vis.TestAlgo as TestAlgo -import perception.tasks.gate.GateCenterAlgo as GateSeg -import perception.tasks.gate.GateSegmentationAlgoA as GateSegA -import perception.tasks.gate.GateSegmentationAlgoB as GateSegB -import perception.tasks.gate.GateSegmentationAlgoC as GateSegC +import perception.tasks.gate.classical.GateCenterAlgo as GateSeg +import perception.tasks.gate.classical.GateSegmentationAlgoA as GateSegA +import perception.tasks.gate.classical.GateSegmentationAlgoB as GateSegB +import perception.tasks.gate.classical.GateSegmentationAlgoC as GateSegC import perception.tasks.segmentation.saliency_detection.MBD as MBD from perception.tasks.segmentation.COMB_SAL_BG import COMB_SAL_BG import perception.vis.TestTasks.BackgroundRemoval as BackgroundRemoval diff --git a/perception/tasks/gate/GateCenterAlgo.py b/perception/tasks/gate/classical/GateCenterAlgo.py similarity index 97% rename from perception/tasks/gate/GateCenterAlgo.py rename to perception/tasks/gate/classical/GateCenterAlgo.py index 5c3e6f6..4e24aa3 100644 --- a/perception/tasks/gate/GateCenterAlgo.py +++ b/perception/tasks/gate/classical/GateCenterAlgo.py @@ -1,4 +1,6 @@ -from perception.tasks.gate.GateSegmentationAlgoA import GateSegmentationAlgoA +from perception.tasks.gate.classical.GateSegmentationAlgoA import ( + GateSegmentationAlgoA, +) from perception.tasks.TaskPerceiver import TaskPerceiver from collections import namedtuple @@ -96,4 +98,4 @@ def get_center(self, rect1, rect2, frame): if __name__ == '__main__': from perception.vis.vis import run - run(['..\..\..\data\GOPR1142.MP4'], GateCenterAlgo(), False) \ No newline at end of file + run(['..\..\..\data\GOPR1142.MP4'], GateCenterAlgo(), False) diff --git a/perception/tasks/gate/GateSegmentationAlgoA.py b/perception/tasks/gate/classical/GateSegmentationAlgoA.py similarity index 100% rename from perception/tasks/gate/GateSegmentationAlgoA.py rename to perception/tasks/gate/classical/GateSegmentationAlgoA.py diff --git a/perception/tasks/gate/GateSegmentationAlgoB.py b/perception/tasks/gate/classical/GateSegmentationAlgoB.py similarity index 100% rename from perception/tasks/gate/GateSegmentationAlgoB.py rename to perception/tasks/gate/classical/GateSegmentationAlgoB.py diff --git a/perception/tasks/gate/GateSegmentationAlgoC.py b/perception/tasks/gate/classical/GateSegmentationAlgoC.py similarity index 100% rename from perception/tasks/gate/GateSegmentationAlgoC.py rename to perception/tasks/gate/classical/GateSegmentationAlgoC.py diff --git a/perception/tasks/gate/classical/__init__.py b/perception/tasks/gate/classical/__init__.py new file mode 100644 index 0000000..a913242 --- /dev/null +++ b/perception/tasks/gate/classical/__init__.py @@ -0,0 +1 @@ +"""Classical computer-vision algorithms for gate detection.""" diff --git a/perception/tasks/gate/archive/detectGate.py b/perception/tasks/gate/classical/archive/detectGate.py similarity index 100% rename from perception/tasks/gate/archive/detectGate.py rename to perception/tasks/gate/classical/archive/detectGate.py diff --git a/perception/tasks/gate/archive/threshTest.py b/perception/tasks/gate/classical/archive/threshTest.py similarity index 100% rename from perception/tasks/gate/archive/threshTest.py rename to perception/tasks/gate/classical/archive/threshTest.py diff --git a/perception/tasks/gate/orientation/__init__.py b/perception/tasks/gate/orientation/__init__.py new file mode 100644 index 0000000..06d3a51 --- /dev/null +++ b/perception/tasks/gate/orientation/__init__.py @@ -0,0 +1 @@ +"""Gate orientation algorithms.""" diff --git a/perception/tasks/gate/orientation/classical_orientation.py b/perception/tasks/gate/orientation/classical_orientation.py new file mode 100644 index 0000000..be8d64a --- /dev/null +++ b/perception/tasks/gate/orientation/classical_orientation.py @@ -0,0 +1,643 @@ +"""Classical gate-orientation prototype ported from raymondt31/UR-B-Perception.""" + +import sys +import argparse +import cv2 +import numpy as np +import matplotlib.pyplot as plt + + +# ══════════════════════════════════════════════════════════════════════════════ +# Config +# ══════════════════════════════════════════════════════════════════════════════ + +L_THRESHOLD = 60 +A_THRESHOLD = 135 + +MIN_BLOB_AREA = 50 # noise filter +TOLERANCE = 0.04 + +COLOR_LEFT = (50, 220, 50) +COLOR_RIGHT = (50, 50, 220) +COLOR_GATE = (0, 165, 255) +COLOR_DASH_BG = (10, 10, 10) + +# POST_ASPECT_MIN = 0.15 -- might use these later +# POST_ASPECT_MAX = 4.0 + +# ══════════════════════════════════════════════════════════════════════════════ +# Underwater enhancement +# ══════════════════════════════════════════════════════════════════════════════ + +def enhance_underwater(img: np.ndarray) -> np.ndarray: + result = img.copy().astype(np.float32) + + # -- white balancing => scale each channel so the darkest pixel = 0 and brightest = 255 -- + for i in range(3): + ch = result[:, :, i] + lo, hi = ch.min(), ch.max() + if hi > lo: + + # Linearly stretch channel to 0-255 range + result[:, :, i] = (ch - lo) / (hi - lo) * 255 + + # --------- CLAHE ------------- + result = result.astype(np.uint8) + lab = cv2.cvtColor(result, cv2.COLOR_BGR2LAB) + l, a, b = cv2.split(lab) + clahe = cv2.createCLAHE(clipLimit=2.5, tileGridSize=(8, 8)) + + # CLAHE l channel only => brightens pixels without distorting color + lab = cv2.merge((clahe.apply(l), a, b)) + return cv2.cvtColor(lab, cv2.COLOR_LAB2BGR) + +# ══════════════════════════════════════════════════════════════════════════════ +# Interactive gate box drawing -> Replaced by YOLO +# ══════════════════════════════════════════════════════════════════════════════ + +# class _DrawState: +# def __init__(self): +# self.drawing = False +# self.start = (0, 0) +# self.rect = None + + +# def interactive_gate_draw(image: np.ndarray) -> tuple: +# """ +# Draw ONE bounding box around the full gate. +# Returns (x, y, w, h) — same format YOLO outputs. + +# ┌─ SWAP POINT ──────────────────────────────────────────────────────────┐ +# │ Replace with YOLO detection: │ +# │ results = yolo_model(image) │ +# │ x, y, w, h = results[0].boxes.xywh[gate_class_idx] │ +# │ return (int(x), int(y), int(w), int(h)) │ +# └───────────────────────────────────────────────────────────────────────┘ +# """ +# state = _DrawState() + +# def mouse_cb(event, x, y, flags, _param): +# if event == cv2.EVENT_LBUTTONDOWN: +# state.drawing = True +# state.start = (x, y) +# elif event == cv2.EVENT_MOUSEMOVE and state.drawing: +# state.rect = (*state.start, x, y) +# elif event == cv2.EVENT_LBUTTONUP: +# state.drawing = False +# state.rect = (*state.start, x, y) + +# win = "Gate Box — drag around full gate, SPACE/ENTER to confirm | Q=quit" +# cv2.namedWindow(win, cv2.WINDOW_NORMAL) +# cv2.setMouseCallback(win, mouse_cb) + +# print("\n Draw a box around the ENTIRE gate (both posts + crossbar)") +# print(" Left-drag = draw | SPACE/ENTER = confirm | Q = quit\n") + +# while True: +# display = image.copy() +# if state.rect: +# x1, y1, x2, y2 = state.rect +# cv2.rectangle(display, (x1, y1), (x2, y2), COLOR_GATE, 2) +# cv2.putText(display, "GATE", (x1+4, y1-6), +# cv2.FONT_HERSHEY_SIMPLEX, 0.65, COLOR_GATE, 2) +# cv2.putText(display, +# "Draw box around FULL GATE (SPACE=confirm Q=quit)", +# (10, 26), cv2.FONT_HERSHEY_SIMPLEX, 0.65, (255, 255, 0), 2) +# cv2.imshow(win, display) + +# key = cv2.waitKey(16) & 0xFF +# if key in (13, 32) and state.rect: +# x1, y1, x2, y2 = state.rect +# x1, x2 = sorted([x1, x2]) +# y1, y2 = sorted([y1, y2]) +# roi = (x1, y1, max(1, x2-x1), max(1, y2-y1)) +# print(f" → Gate box confirmed: x={x1} y={y1} w={x2-x1} h={y2-y1}") +# cv2.destroyWindow(win) +# return roi +# elif key == ord('q'): +# cv2.destroyAllWindows() +# sys.exit(0) + +# ══════════════════════════════════════════════════════════════════════════════ +# Segmentation +# ══════════════════════════════════════════════════════════════════════════════ + +def segment_black_lab(crop: np.ndarray, + use_percentile: bool = True, # False for raw value thresholding + percentile: float = 15, + l_threshold: int = L_THRESHOLD) -> np.ndarray: + lab = cv2.cvtColor(crop, cv2.COLOR_BGR2LAB) + l_ch = lab[:, :, 0] + + # Percentile-based thresholding (set as default) + if use_percentile: + dynamic_threshold = np.percentile(l_ch, percentile) + _, mask = cv2.threshold(l_ch, dynamic_threshold, 255, cv2.THRESH_BINARY_INV) + else: + # Fixed threshold fallback — use --l-threshold to tune + _, mask = cv2.threshold(l_ch, l_threshold, 255, cv2.THRESH_BINARY_INV) + + # Clean up noise + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1) + return mask + +def segment_red_lab(crop: np.ndarray, + a_threshold: int = A_THRESHOLD) -> np.ndarray: + lab = cv2.cvtColor(crop, cv2.COLOR_BGR2LAB) + l, a, b = cv2.split(lab) + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + lab = cv2.merge((clahe.apply(l), a, b)) + a_ch = lab[:, :, 1] + _, mask = cv2.threshold(a_ch, a_threshold, 255, cv2.THRESH_BINARY) + kernel = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) + mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel, iterations=2) + mask = cv2.morphologyEx(mask, cv2.MORPH_OPEN, kernel, iterations=1) + return mask + +# ══════════════════════════════════════════════════════════════════════════════ +# Blob utilities +# ══════════════════════════════════════════════════════════════════════════════ + +# Standard contour detection +def largest_blob(mask: np.ndarray, min_area: int = MIN_BLOB_AREA): + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, + cv2.CHAIN_APPROX_SIMPLE) + if not contours: + return None, None, None + contours = [c for c in contours if cv2.contourArea(c) >= min_area] + if not contours: + return None, None, None + + best = max(contours, key=cv2.contourArea) + x, y, w, h = cv2.boundingRect(best) + blob_mask = np.zeros_like(mask) + cv2.drawContours(blob_mask, [best], -1, 255, -1) + + # return the mask, bounding box, and some helpful stats + return blob_mask, (x, y, w, h), dict( + area = cv2.contourArea(best), # area of contour + cx = x + w / 2.0, # x-center of bounding box + cy = y + h / 2.0, # y-center of bounding box + x=x, y=y, w=w, h=h, + ) + +# Same idea as above, but also accounting for aspect ratio +def largest_blob_by_aspect(mask, min_area=MIN_BLOB_AREA, + min_aspect=0.5, max_aspect=5.0): + contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, + cv2.CHAIN_APPROX_SIMPLE) + if not contours: + return None, None, None + + valid = [] + for c in contours: + # 1) Filter by area + if cv2.contourArea(c) < min_area: + continue + x, y, w, h = cv2.boundingRect(c) + aspect = w / max(h, 1) + # 2) Filter by aspect ratio (width/height) using predefined bounds + if min_aspect <= aspect <= max_aspect: + valid.append(c) + + if not valid: + return None, None, None + + best = max(valid, key=cv2.contourArea) + x, y, w, h = cv2.boundingRect(best) + blob_mask = np.zeros_like(mask) + cv2.drawContours(blob_mask, [best], -1, 255, -1) + + return blob_mask, (x, y, w, h), dict( + area=cv2.contourArea(best), + cx=x + w / 2.0, cy=y + h / 2.0, + x=x, y=y, w=w, h=h, + ) + +# ══════════════════════════════════════════════════════════════════════════════ +# Post detection — BLACK as primary signal instead of RED +# ══════════════════════════════════════════════════════════════════════════════ + +def detect_posts(image: np.ndarray, + gate_roi: tuple, + l_threshold: int = L_THRESHOLD, + a_threshold: int = A_THRESHOLD, + percentile: float = 15.0): + gx, gy, gw, gh = gate_roi + + # slice the gate to isolate the black half + crop = image[gy:gy+gh, gx:gx+gw] + + black_mask = segment_black_lab(crop) + red_mask = np.zeros_like(black_mask) + + divider_y = gh // 2 + mid_x = gw // 2 + + # LEFT post: black panel is on TOP → search upper-left quadrant + left_zone = np.zeros_like(black_mask) + left_zone[:divider_y, :mid_x] = 255 + + # RIGHT post: black panel is on BOTTOM → search lower-right quadrant + right_zone = np.zeros_like(black_mask) + right_zone[divider_y:, mid_x:] = 255 + + # Find largest black blob in each zone + _, _, left_stats = largest_blob(cv2.bitwise_and(black_mask, left_zone)) + _, _, right_stats = largest_blob(cv2.bitwise_and(black_mask, right_zone)) + + # ── Translate to full-image coordinates ─────────────────────────────────── + def translate(s): + return {**s, + "img_cx": s["cx"] + gx, + "img_cy": s["cy"] + gy, + "img_x": s["x"] + gx, + "img_y": s["y"] + gy, + "divider_y": divider_y + gy} + + if left_stats: + left_stats = translate(left_stats) + if right_stats: + right_stats = translate(right_stats) + + return crop, black_mask, red_mask, left_stats, right_stats, divider_y + +# ══════════════════════════════════════════════════════════════════════════════ +# Alignment math +# ══════════════════════════════════════════════════════════════════════════════ + +def compute_alignment(left_stats, right_stats, image_width, + gate_roi=None, tolerance: float = TOLERANCE): + eps = 1e-6 + cx = image_width / 2.0 + + # ── Yaw ─────────────────────────────────────────────────────────────────── + if left_stats and right_stats: + W_L = max(left_stats["w"], eps) + W_R = max(right_stats["w"], eps) + + # W_R > W_L → right black panel wider → AUV angled right → ROTATE LEFT + # W_L > W_R → left black panel wider → AUV angled left → ROTATE RIGHT + width_ratio = W_R / W_L + yaw_signal = width_ratio - 1.0 + + if abs(yaw_signal) <= tolerance: + cmd_yaw = "HEAD-ON (YAW OK)" + elif yaw_signal > 0: + cmd_yaw = "ROTATE LEFT" + else: + cmd_yaw = "ROTATE RIGHT" + else: + width_ratio = yaw_signal = None + cmd_yaw = "ROTATE LEFT" if right_stats is None else "ROTATE RIGHT" + + # Lateral: Compare the center of the gate post to the center of the frame + if gate_roi: + gx, gy, gw, gh = gate_roi + gate_mid_x = gx + gw / 2.0 + elif left_stats and right_stats: + gate_mid_x = (left_stats["img_cx"] + right_stats["img_cx"]) / 2.0 + elif left_stats: # approximate if only the left post is found + gate_mid_x = left_stats["img_cx"] + elif right_stats: # approximate if only the right post is found + gate_mid_x = right_stats["img_cx"] + else: + gate_mid_x = cx + + lateral = gate_mid_x - cx + lat_thresh = cx * tolerance + + if abs(lateral) <= lat_thresh: + cmd_strafe = "CENTERED" + elif lateral > 0: + cmd_strafe = "STRAFE RIGHT" + else: + cmd_strafe = "STRAFE LEFT" + + both_ok = (yaw_signal is not None + and abs(yaw_signal) <= tolerance + and abs(lateral) <= lat_thresh) + status = "HEAD-ON ✓" if both_ok else "ALIGNING..." + + return dict( + yaw_signal = yaw_signal, + width_ratio = width_ratio, + lateral = lateral, + gate_mid_x = gate_mid_x, + cmd_yaw = cmd_yaw, + cmd_strafe = cmd_strafe, + status = status, + ) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Visualisation Stuff +# ══════════════════════════════════════════════════════════════════════════════ + +def annotate_image(canvas, gate_roi, left_stats, right_stats, + aln, divider_y, black_mask, red_mask): + """ + Draw gate box, divider line, zone boundaries, and post detections. + Thin boxes = raw zone blob (always shown for diagnostics). + Thick boxes with ✓ = confirmed detections used for alignment. + """ + out = canvas.copy() + gx, gy, gw, gh = gate_roi + mid_x = gw // 2 + + # ── Gate box ────────────────────────────────────────────────────────────── + cv2.rectangle(out, (gx, gy), (gx+gw, gy+gh), COLOR_GATE, 2) + cv2.putText(out, "GATE (YOLO)", (gx+4, gy-8), + cv2.FONT_HERSHEY_SIMPLEX, 0.55, COLOR_GATE, 1, cv2.LINE_AA) + + # ── Divider line ────────────────────────────────────────────────────────── + dy = divider_y + gy + cv2.line(out, (gx, dy), (gx+gw, dy), (0, 255, 255), 2) + cv2.putText(out, "divider", (gx+4, dy-4), + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 255, 255), 1) + + # ── Vertical midpoint ───────────────────────────────────────────────────── + cv2.line(out, (gx+mid_x, gy), (gx+mid_x, gy+gh), (60, 60, 60), 1) + + # ── Zone labels ─────────────────────────────────────────────────────────── + cv2.putText(out, "L-black zone", (gx+4, gy+16), + cv2.FONT_HERSHEY_SIMPLEX, 0.4, COLOR_LEFT, 1, cv2.LINE_AA) + cv2.putText(out, "R-black zone", (gx+mid_x+4, dy+14), + cv2.FONT_HERSHEY_SIMPLEX, 0.4, COLOR_RIGHT, 1, cv2.LINE_AA) + + # ── Raw diagnostic blobs (thin box — always drawn) ──────────────────────── + left_zone_mask = np.zeros((gh, gw), np.uint8) + left_zone_mask[:divider_y, :mid_x] = 255 + right_zone_mask = np.zeros((gh, gw), np.uint8) + right_zone_mask[divider_y:, mid_x:] = 255 + + _, _, raw_left = largest_blob(cv2.bitwise_and(black_mask, left_zone_mask)) + _, _, raw_right = largest_blob(cv2.bitwise_and(black_mask, right_zone_mask)) + + for raw, color, label in [ + (raw_left, COLOR_LEFT, "L-blk?"), + (raw_right, COLOR_RIGHT, "R-blk?"), + ]: + if raw is None: + continue + rx = raw["x"] + gx + ry = raw["y"] + gy + cv2.rectangle(out, (rx, ry), (rx+raw["w"], ry+raw["h"]), color, 1) + cv2.putText(out, f"{label} {raw['w']}×{raw['h']}px", + (rx+2, ry-4), cv2.FONT_HERSHEY_SIMPLEX, + 0.38, color, 1, cv2.LINE_AA) + + # ── Confirmed post boxes (thick) ────────────────────────────────────────── + for stats, color, label in [ + (left_stats, COLOR_LEFT, "L-POST ✓"), + (right_stats, COLOR_RIGHT, "R-POST ✓"), + ]: + if stats is None: + continue + ix, iy, iw, ih = (stats["img_x"], stats["img_y"], + stats["w"], stats["h"]) + cv2.rectangle(out, (ix, iy), (ix+iw, iy+ih), color, 3) + cv2.putText(out, f"{label} {iw}×{ih}px", + (ix+2, iy-8), cv2.FONT_HERSHEY_SIMPLEX, + 0.55, color, 2, cv2.LINE_AA) + cv2.drawMarker(out, (int(stats["img_cx"]), int(stats["img_cy"])), + color, cv2.MARKER_CROSS, 16, 2, cv2.LINE_AA) + + # ── Image center vs gate center ─────────────────────────────────────────── + img_cx = canvas.shape[1] // 2 + cv2.line(out, (img_cx, 0), (img_cx, canvas.shape[0]), (40, 40, 40), 1) + gm = int(aln["gate_mid_x"]) + cv2.line(out, (gm, gy), (gm, gy+gh), (0, 200, 255), 2) + cv2.putText(out, "gate mid", (gm+4, gy+20), + cv2.FONT_HERSHEY_SIMPLEX, 0.4, (0, 200, 255), 1) + + return out + +def draw_dashboard(canvas: np.ndarray, aln: dict) -> None: + """Semi-transparent navigation dashboard stamped onto canvas in-place.""" + h, w = canvas.shape[:2] + panel_h = 170 + y0 = h - panel_h + + overlay = canvas.copy() + cv2.rectangle(overlay, (0, y0), (w, h), COLOR_DASH_BG, -1) + cv2.addWeighted(overlay, 0.75, canvas, 0.25, 0, canvas) + + ok_col = (50, 220, 50) + warn_col = (0, 120, 255) + info_col = (0, 200, 255) + dim_col = (160, 160, 160) + + def put(text, y, color, scale=0.65, thickness=1): + cv2.putText(canvas, text, (16, y), cv2.FONT_HERSHEY_SIMPLEX, + scale, color, thickness, cv2.LINE_AA) + + status_col = ok_col if "✓" in aln["status"] else warn_col + put(f"STATUS: {aln['status']}", y0+28, status_col, 0.72, 2) + put(f"CMD YAW: {aln['cmd_yaw']}", y0+56, info_col) + put(f"CMD STRAFE: {aln['cmd_strafe']}", y0+82, info_col) + + if aln["yaw_signal"] is not None: + put(f"yaw_signal={aln['yaw_signal']:+.3f} " + f"W_ratio={aln['width_ratio']:.3f} " + f"(R_blk/L_blk)", y0+108, dim_col, 0.47) + else: + put("yaw_signal=N/A (one or both posts not detected)", + y0+108, dim_col, 0.47) + + put(f"lateral={aln['lateral']:+.1f}px " + f"gate_mid={aln['gate_mid_x']:.1f}px", y0+130, dim_col, 0.47) + + # Lateral bar + bar_y = y0 + 28 + bar_x0 = w - 220 + bar_x1 = w - 30 + bar_mid = (bar_x0 + bar_x1) // 2 + cv2.line(canvas, (bar_x0, bar_y), (bar_x1, bar_y), (70, 70, 70), 3) + cv2.line(canvas, (bar_mid, bar_y-8), (bar_mid, bar_y+8), (100,100,100), 1) + norm = np.clip(aln["lateral"] / (w / 2.0), -1.0, 1.0) + ind_x = int(bar_mid + norm * (bar_x1 - bar_mid)) + cv2.circle(canvas, (ind_x, bar_y), 9, info_col, -1, cv2.LINE_AA) + put("◄ LATERAL ►", y0+14, dim_col, 0.37) + + +# ══════════════════════════════════════════════════════════════════════════════ +# Optional Threshold Sliders for Calibration and Debugging Purposes +# ══════════════════════════════════════════════════════════════════════════════ + +def calibrate_thresholds(image: np.ndarray, gate_roi: tuple): + gx, gy, gw, gh = gate_roi + crop = image[gy:gy+gh, gx:gx+gw] + + win = "Calibrate — L=black (primary) A=red (divider) | SPACE=confirm Q=quit" + cv2.namedWindow(win, cv2.WINDOW_NORMAL) + cv2.createTrackbar("L (black)", win, L_THRESHOLD, 255, lambda x: None) + cv2.createTrackbar("A (red)", win, A_THRESHOLD, 255, lambda x: None) + + print("\n[CALIBRATE] L (black): drag up until ONLY the dark gate panels are blue") + print("[CALIBRATE] A (red): drag up until ONLY the red panels/divider are red") + print(" SPACE/ENTER to confirm | Q to quit\n") + + while True: + l_thresh = cv2.getTrackbarPos("L (black)", win) + a_thresh = cv2.getTrackbarPos("A (red)", win) + + lab = cv2.cvtColor(crop, cv2.COLOR_BGR2LAB) + l_ch = lab[:, :, 0] + a_ch = lab[:, :, 1] + + _, black_mask = cv2.threshold(l_ch, l_thresh, 255, cv2.THRESH_BINARY_INV) + _, red_mask = cv2.threshold(a_ch, a_thresh, 255, cv2.THRESH_BINARY) + + overlay = crop.copy() + overlay[black_mask == 255] = (180, 30, 30) # dark blue = black pixels + overlay[red_mask == 255] = (0, 80, 255) # bright red = red pixels + + cv2.putText(overlay, f"L={l_thresh} (black) A={a_thresh} (red)", + (10, 24), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255,255,0), 2) + cv2.imshow(win, overlay) + + key = cv2.waitKey(16) & 0xFF + if key in (13, 32): + cv2.destroyWindow(win) + print(f" → L_THRESHOLD={l_thresh} A_THRESHOLD={a_thresh}") + return l_thresh, a_thresh + elif key == ord('q'): + cv2.destroyAllWindows() + sys.exit(0) + +# ══════════════════════════════════════════════════════════════════════════════ +# Entry point +# ══════════════════════════════════════════════════════════════════════════════ + +def parse_args(): + p = argparse.ArgumentParser( + description="Gate alignment — LAB black panel primary segmentation") + + # Required Arguments + p.add_argument("--img", required=True, + help="Path to input image") + p.add_argument("--box", type=int, nargs=4, required=True, + metavar=("X", "Y", "W", "H"), + help="YOLO gate bounding box as top-left x y w h") + + # Optional Arguments for Tuning + p.add_argument("--percentile", type=float, default=15.0, + help="Darkest N percent of pixels treated as black (default 15.0)") + p.add_argument("--calibrate", action="store_true", + help="Open dual threshold calibration window first") + p.add_argument("--l-threshold", type=int, default=L_THRESHOLD, + help=f"LAB L threshold for black (default {L_THRESHOLD})") + p.add_argument("--a-threshold", type=int, default=A_THRESHOLD, + help=f"LAB A threshold for red (default {A_THRESHOLD})") + + return p.parse_args() + +if __name__ == "__main__": + args = parse_args() + + # ── Load & enhance ──────────────────────────────────────────────────────── + raw = cv2.imread(args.img) + if raw is None: + sys.exit(f"[ERROR] Cannot load image: {args.img}") + img = enhance_underwater(raw) + + # ── Step 1: Draw gate box ───────────────────────────────────────────────── + # print("\n" + "="*54) + # print(" STEP 1 — Draw the YOLO gate bounding box") + # print("="*54) + # gate_roi = interactive_gate_draw(img) + gate_roi = tuple(args.box) + + # ── Step 2: Optional calibration ────────────────────────────────────────── + l_thresh = args.l_threshold + a_thresh = args.a_threshold + if args.calibrate: + print("\n" + "="*54) + print(" STEP 2 — Calibrate thresholds") + print("="*54) + l_thresh, a_thresh = calibrate_thresholds(img, gate_roi) + + # ── Step 3: Detect posts ────────────────────────────────────────────────── + print("\nDetecting posts via BLACK panel segmentation (primary)...") + crop, black_mask, red_mask, left_stats, right_stats, divider_y = \ + detect_posts(img, gate_roi, l_thresh, a_thresh, percentile=args.percentile) + + if left_stats is None: + print(" [WARN] Left post not detected (no black blob in upper-left zone)") + else: + print(f" Left post: {left_stats['w']}×{left_stats['h']}px " + f"area={left_stats['area']:.0f} " + f"center=({left_stats['img_cx']:.0f},{left_stats['img_cy']:.0f})") + + if right_stats is None: + print(" [WARN] Right post not detected (no black blob in lower-right zone)") + else: + print(f" Right post: {right_stats['w']}×{right_stats['h']}px " + f"area={right_stats['area']:.0f} " + f"center=({right_stats['img_cx']:.0f},{right_stats['img_cy']:.0f})") + + # ── Step 4: Alignment math ──────────────────────────────────────────────── + aln = compute_alignment(left_stats, right_stats, img.shape[1], + gate_roi=gate_roi) + + print("\n" + "="*54) + print(" GATE ALIGNMENT") + print("="*54) + print(f" STATUS : {aln['status']}") + print(f" CMD YAW : {aln['cmd_yaw']}") + print(f" CMD STRAFE : {aln['cmd_strafe']}") + if aln["yaw_signal"] is not None: + print(f" yaw_signal : {aln['yaw_signal']:+.4f} (tol ± {TOLERANCE})") + print(f" width_ratio : {aln['width_ratio']:.4f} (R/L black panel width)") + print(f" lateral : {aln['lateral']:+.1f} px (+= gate right of centre)") + print("="*54 + "\n") + + # ── Step 5: Visualise ───────────────────────────────────────────────────── + canvas = annotate_image(img, gate_roi, left_stats, right_stats, + aln, divider_y, black_mask, red_mask) + draw_dashboard(canvas, aln) + + # Color-coded masks for display + gx, gy, gw, gh = gate_roi + mid_x = gw // 2 + + black_color = cv2.cvtColor(black_mask, cv2.COLOR_GRAY2BGR) + red_color = cv2.cvtColor(red_mask, cv2.COLOR_GRAY2BGR) + + # Left zone = green, right zone = blue + black_color[:divider_y, :mid_x][black_mask[:divider_y, :mid_x] == 255] = COLOR_LEFT + black_color[divider_y:, mid_x:][black_mask[divider_y:, mid_x:] == 255] = COLOR_RIGHT + # Remaining black pixels (outside zones) shown as grey + grey_mask = black_mask.copy() + grey_mask[:divider_y, :mid_x] = 0 + grey_mask[divider_y:, mid_x:] = 0 + black_color[grey_mask == 255] = (100, 100, 100) + + fig, axes = plt.subplots(1, 2, figsize=(26, 7)) + + axes[0].imshow(cv2.cvtColor(canvas, cv2.COLOR_BGR2RGB)) + axes[0].set_title( + f"Annotated | YAW: {aln['cmd_yaw']} STRAFE: {aln['cmd_strafe']}", + fontsize=10) + axes[0].axis("off") + + axes[1].imshow(cv2.cvtColor(black_color, cv2.COLOR_BGR2RGB)) + axes[1].set_title("BLACK mask (L channel) — PRIMARY\n" + "Green=left zone Blue=right zone Grey=outside zones") + axes[1].axvline(x=mid_x, color="white", linewidth=1, linestyle="--") + axes[1].axhline(y=divider_y, color="cyan", linewidth=1, linestyle="--") + axes[1].axis("off") + + plt.suptitle( + f"STATUS: {aln['status']} | " + f"yaw_signal = {aln['yaw_signal']:+.3f} " + f"lateral = {aln['lateral']:+.1f} px" + if aln["yaw_signal"] is not None else + f"STATUS: {aln['status']} | lateral = {aln['lateral']:+.1f} px", + fontsize=13, fontweight="bold", + ) + plt.tight_layout() + plt.show() diff --git a/perception/tasks/gate/orientation/modules/LICENSE-XFEAT b/perception/tasks/gate/orientation/modules/LICENSE-XFEAT new file mode 100644 index 0000000..261eeb9 --- /dev/null +++ b/perception/tasks/gate/orientation/modules/LICENSE-XFEAT @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright [yyyy] [name of copyright owner] + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/perception/tasks/gate/orientation/modules/__init__.py b/perception/tasks/gate/orientation/modules/__init__.py new file mode 100644 index 0000000..a730bea --- /dev/null +++ b/perception/tasks/gate/orientation/modules/__init__.py @@ -0,0 +1,4 @@ +""" + "XFeat: Accelerated Features for Lightweight Image Matching, CVPR 2024." + https://www.verlab.dcc.ufmg.br/descriptors/xfeat_cvpr24/ +""" diff --git a/perception/tasks/gate/orientation/modules/interpolator.py b/perception/tasks/gate/orientation/modules/interpolator.py new file mode 100644 index 0000000..7d33990 --- /dev/null +++ b/perception/tasks/gate/orientation/modules/interpolator.py @@ -0,0 +1,33 @@ +""" + "XFeat: Accelerated Features for Lightweight Image Matching, CVPR 2024." + https://www.verlab.dcc.ufmg.br/descriptors/xfeat_cvpr24/ +""" + +import torch +import torch.nn as nn +import torch.nn.functional as F + +class InterpolateSparse2d(nn.Module): + """ Efficiently interpolate tensor at given sparse 2D positions. """ + def __init__(self, mode = 'bicubic', align_corners = False): + super().__init__() + self.mode = mode + self.align_corners = align_corners + + def normgrid(self, x, H, W): + """ Normalize coords to [-1,1]. """ + return 2. * (x/(torch.tensor([W-1, H-1], device = x.device, dtype = x.dtype))) - 1. + + def forward(self, x, pos, H, W): + """ + Input + x: [B, C, H, W] feature tensor + pos: [B, N, 2] tensor of positions + H, W: int, original resolution of input 2d positions -- used in normalization [-1,1] + + Returns + [B, N, C] sampled channels at 2d positions + """ + grid = self.normgrid(pos, H, W).unsqueeze(-2).to(x.dtype) + x = F.grid_sample(x, grid, mode = self.mode , align_corners = False) + return x.permute(0,2,3,1).squeeze(-2) diff --git a/perception/tasks/gate/orientation/modules/lighterglue.py b/perception/tasks/gate/orientation/modules/lighterglue.py new file mode 100644 index 0000000..67b3196 --- /dev/null +++ b/perception/tasks/gate/orientation/modules/lighterglue.py @@ -0,0 +1,57 @@ + +from kornia.feature.lightglue import LightGlue +from torch import nn +import torch +import os + +class LighterGlue(nn.Module): + """ + Lighter version of LightGlue :) + """ + + default_conf_xfeat = { + "name": "xfeat", # just for interfacing + "input_dim": 64, # input descriptor dimension (autoselected from weights) + "descriptor_dim": 96, + "add_scale_ori": False, + "add_laf": False, # for KeyNetAffNetHardNet + "scale_coef": 1.0, # to compensate for the SIFT scale bigger than KeyNet + "n_layers": 6, + "num_heads": 1, + "flash": True, # enable FlashAttention if available. + "mp": False, # enable mixed precision + "depth_confidence": -1, # early stopping, disable with -1 + "width_confidence": 0.95, # point pruning, disable with -1 + "filter_threshold": 0.1, # match threshold + "weights": None, + } + + def __init__(self, weights = os.path.abspath(os.path.dirname(__file__)) + '/../weights/xfeat-lighterglue.pt'): + super().__init__() + LightGlue.default_conf = self.default_conf_xfeat + self.net = LightGlue(None) + self.dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + + if os.path.exists(weights): + state_dict = torch.load(weights, map_location=self.dev) + else: + state_dict = torch.hub.load_state_dict_from_url("https://github.com/verlab/accelerated_features/raw/main/weights/xfeat-lighterglue.pt") + + # rename old state dict entries + for i in range(self.net.conf.n_layers): + pattern = f"self_attn.{i}", f"transformers.{i}.self_attn" + state_dict = {k.replace(*pattern): v for k, v in state_dict.items()} + pattern = f"cross_attn.{i}", f"transformers.{i}.cross_attn" + state_dict = {k.replace(*pattern): v for k, v in state_dict.items()} + state_dict = {k.replace('matcher.', ''): v for k, v in state_dict.items()} + + self.net.load_state_dict(state_dict, strict=False) + self.net.to(self.dev) + + @torch.inference_mode() + def forward(self, data, min_conf = 0.1): + self.net.conf.filter_threshold = min_conf + result = self.net( { 'image0': {'keypoints': data['keypoints0'], 'descriptors': data['descriptors0'], 'image_size': data['image_size0']}, + 'image1': {'keypoints': data['keypoints1'], 'descriptors': data['descriptors1'], 'image_size': data['image_size1']} + } ) + return result diff --git a/perception/tasks/gate/orientation/modules/model.py b/perception/tasks/gate/orientation/modules/model.py new file mode 100644 index 0000000..57539fd --- /dev/null +++ b/perception/tasks/gate/orientation/modules/model.py @@ -0,0 +1,154 @@ +""" + "XFeat: Accelerated Features for Lightweight Image Matching, CVPR 2024." + https://www.verlab.dcc.ufmg.br/descriptors/xfeat_cvpr24/ +""" + + +import torch +import torch.nn as nn +import torch.nn.functional as F +import time + +class BasicLayer(nn.Module): + """ + Basic Convolutional Layer: Conv2d -> BatchNorm -> ReLU + """ + def __init__(self, in_channels, out_channels, kernel_size=3, stride=1, padding=1, dilation=1, bias=False): + super().__init__() + self.layer = nn.Sequential( + nn.Conv2d( in_channels, out_channels, kernel_size, padding = padding, stride=stride, dilation=dilation, bias = bias), + nn.BatchNorm2d(out_channels, affine=False), + nn.ReLU(inplace = True), + ) + + def forward(self, x): + return self.layer(x) + +class XFeatModel(nn.Module): + """ + Implementation of architecture described in + "XFeat: Accelerated Features for Lightweight Image Matching, CVPR 2024." + """ + + def __init__(self): + super().__init__() + self.norm = nn.InstanceNorm2d(1) + + + ########### ⬇️ CNN Backbone & Heads ⬇️ ########### + + self.skip1 = nn.Sequential( nn.AvgPool2d(4, stride = 4), + nn.Conv2d (1, 24, 1, stride = 1, padding=0) ) + + self.block1 = nn.Sequential( + BasicLayer( 1, 4, stride=1), + BasicLayer( 4, 8, stride=2), + BasicLayer( 8, 8, stride=1), + BasicLayer( 8, 24, stride=2), + ) + + self.block2 = nn.Sequential( + BasicLayer(24, 24, stride=1), + BasicLayer(24, 24, stride=1), + ) + + self.block3 = nn.Sequential( + BasicLayer(24, 64, stride=2), + BasicLayer(64, 64, stride=1), + BasicLayer(64, 64, 1, padding=0), + ) + self.block4 = nn.Sequential( + BasicLayer(64, 64, stride=2), + BasicLayer(64, 64, stride=1), + BasicLayer(64, 64, stride=1), + ) + + self.block5 = nn.Sequential( + BasicLayer( 64, 128, stride=2), + BasicLayer(128, 128, stride=1), + BasicLayer(128, 128, stride=1), + BasicLayer(128, 64, 1, padding=0), + ) + + self.block_fusion = nn.Sequential( + BasicLayer(64, 64, stride=1), + BasicLayer(64, 64, stride=1), + nn.Conv2d (64, 64, 1, padding=0) + ) + + self.heatmap_head = nn.Sequential( + BasicLayer(64, 64, 1, padding=0), + BasicLayer(64, 64, 1, padding=0), + nn.Conv2d (64, 1, 1), + nn.Sigmoid() + ) + + + self.keypoint_head = nn.Sequential( + BasicLayer(64, 64, 1, padding=0), + BasicLayer(64, 64, 1, padding=0), + BasicLayer(64, 64, 1, padding=0), + nn.Conv2d (64, 65, 1), + ) + + + ########### ⬇️ Fine Matcher MLP ⬇️ ########### + + self.fine_matcher = nn.Sequential( + nn.Linear(128, 512), + nn.BatchNorm1d(512, affine=False), + nn.ReLU(inplace = True), + nn.Linear(512, 512), + nn.BatchNorm1d(512, affine=False), + nn.ReLU(inplace = True), + nn.Linear(512, 512), + nn.BatchNorm1d(512, affine=False), + nn.ReLU(inplace = True), + nn.Linear(512, 512), + nn.BatchNorm1d(512, affine=False), + nn.ReLU(inplace = True), + nn.Linear(512, 64), + ) + + def _unfold2d(self, x, ws = 2): + """ + Unfolds tensor in 2D with desired ws (window size) and concat the channels + """ + B, C, H, W = x.shape + x = x.unfold(2, ws , ws).unfold(3, ws,ws) \ + .reshape(B, C, H//ws, W//ws, ws**2) + return x.permute(0, 1, 4, 2, 3).reshape(B, -1, H//ws, W//ws) + + + def forward(self, x): + """ + input: + x -> torch.Tensor(B, C, H, W) grayscale or rgb images + return: + feats -> torch.Tensor(B, 64, H/8, W/8) dense local features + keypoints -> torch.Tensor(B, 65, H/8, W/8) keypoint logit map + heatmap -> torch.Tensor(B, 1, H/8, W/8) reliability map + + """ + #dont backprop through normalization + with torch.no_grad(): + x = x.mean(dim=1, keepdim = True) + x = self.norm(x) + + #main backbone + x1 = self.block1(x) + x2 = self.block2(x1 + self.skip1(x)) + x3 = self.block3(x2) + x4 = self.block4(x3) + x5 = self.block5(x4) + + #pyramid fusion + x4 = F.interpolate(x4, (x3.shape[-2], x3.shape[-1]), mode='bilinear') + x5 = F.interpolate(x5, (x3.shape[-2], x3.shape[-1]), mode='bilinear') + feats = self.block_fusion( x3 + x4 + x5 ) + + #heads + heatmap = self.heatmap_head(feats) # Reliability map + keypoints = self.keypoint_head(self._unfold2d(x, ws=8)) #Keypoint map logits + + return feats, keypoints, heatmap diff --git a/perception/tasks/gate/orientation/modules/xfeat.py b/perception/tasks/gate/orientation/modules/xfeat.py new file mode 100644 index 0000000..9d43a33 --- /dev/null +++ b/perception/tasks/gate/orientation/modules/xfeat.py @@ -0,0 +1,405 @@ + +""" + "XFeat: Accelerated Features for Lightweight Image Matching, CVPR 2024." + https://www.verlab.dcc.ufmg.br/descriptors/xfeat_cvpr24/ + + Modified for package-relative imports in Berkeley AUV perception. +""" + +import numpy as np +import os +import torch +import torch.nn.functional as F + +import tqdm + +from .model import * +from .interpolator import InterpolateSparse2d + +class XFeat(nn.Module): + """ + Implements the inference module for XFeat. + It supports inference for both sparse and semi-dense feature extraction & matching. + """ + + def __init__(self, weights = os.path.abspath(os.path.dirname(__file__)) + '/../weights/xfeat.pt', top_k = 4096, detection_threshold=0.05): + super().__init__() + self.dev = torch.device('cuda' if torch.cuda.is_available() else 'cpu') + self.net = XFeatModel().to(self.dev).eval() + self.top_k = top_k + self.detection_threshold = detection_threshold + + if weights is not None: + if isinstance(weights, str): + print('loading weights from: ' + weights) + self.net.load_state_dict(torch.load(weights, map_location=self.dev)) + else: + self.net.load_state_dict(weights) + + self.interpolator = InterpolateSparse2d('bicubic') + + #Try to import LightGlue from Kornia + self.kornia_available = False + self.lighterglue = None + try: + import kornia + self.kornia_available=True + except: + pass + + + @torch.inference_mode() + def detectAndCompute(self, x, top_k = None, detection_threshold = None): + """ + Compute sparse keypoints & descriptors. Supports batched mode. + + input: + x -> torch.Tensor(B, C, H, W): grayscale or rgb image + top_k -> int: keep best k features + return: + List[Dict]: + 'keypoints' -> torch.Tensor(N, 2): keypoints (x,y) + 'scores' -> torch.Tensor(N,): keypoint scores + 'descriptors' -> torch.Tensor(N, 64): local features + """ + if top_k is None: top_k = self.top_k + if detection_threshold is None: detection_threshold = self.detection_threshold + x, rh1, rw1 = self.preprocess_tensor(x) + + B, _, _H1, _W1 = x.shape + + M1, K1, H1 = self.net(x) + M1 = F.normalize(M1, dim=1) + + #Convert logits to heatmap and extract kpts + K1h = self.get_kpts_heatmap(K1) + mkpts = self.NMS(K1h, threshold=detection_threshold, kernel_size=5) + + #Compute reliability scores + _nearest = InterpolateSparse2d('nearest') + _bilinear = InterpolateSparse2d('bilinear') + scores = (_nearest(K1h, mkpts, _H1, _W1) * _bilinear(H1, mkpts, _H1, _W1)).squeeze(-1) + scores[torch.all(mkpts == 0, dim=-1)] = -1 + + #Select top-k features + idxs = torch.argsort(-scores) + mkpts_x = torch.gather(mkpts[...,0], -1, idxs)[:, :top_k] + mkpts_y = torch.gather(mkpts[...,1], -1, idxs)[:, :top_k] + mkpts = torch.cat([mkpts_x[...,None], mkpts_y[...,None]], dim=-1) + scores = torch.gather(scores, -1, idxs)[:, :top_k] + + #Interpolate descriptors at kpts positions + feats = self.interpolator(M1, mkpts, H = _H1, W = _W1) + + #L2-Normalize + feats = F.normalize(feats, dim=-1) + + #Correct kpt scale + mkpts = mkpts * torch.tensor([rw1,rh1], device=mkpts.device).view(1, 1, -1) + + valid = scores > 0 + return [ + {'keypoints': mkpts[b][valid[b]], + 'scores': scores[b][valid[b]], + 'descriptors': feats[b][valid[b]]} for b in range(B) + ] + + @torch.inference_mode() + def detectAndComputeDense(self, x, top_k = None, multiscale = True): + """ + Compute dense *and coarse* descriptors. Supports batched mode. + + input: + x -> torch.Tensor(B, C, H, W): grayscale or rgb image + top_k -> int: keep best k features + return: features sorted by their reliability score -- from most to least + List[Dict]: + 'keypoints' -> torch.Tensor(top_k, 2): coarse keypoints + 'scales' -> torch.Tensor(top_k,): extraction scale + 'descriptors' -> torch.Tensor(top_k, 64): coarse local features + """ + if top_k is None: top_k = self.top_k + if multiscale: + mkpts, sc, feats = self.extract_dualscale(x, top_k) + else: + mkpts, feats = self.extractDense(x, top_k) + sc = torch.ones(mkpts.shape[:2], device=mkpts.device) + + return {'keypoints': mkpts, + 'descriptors': feats, + 'scales': sc } + + + @torch.inference_mode() + def match_lighterglue(self, d0, d1, min_conf = 0.1): + """ + Match XFeat sparse features with LightGlue (smaller version) -- currently does NOT support batched inference because of padding, but its possible to implement easily. + input: + d0, d1: Dict('keypoints', 'scores, 'descriptors', 'image_size (Width, Height)') + output: + mkpts_0, mkpts_1 -> np.ndarray (N,2) xy coordinate matches from image1 to image2 + idx -> np.ndarray (N,2) the indices of the matching features + + """ + if not self.kornia_available: + raise RuntimeError('We rely on kornia for LightGlue. Install with: pip install kornia') + elif self.lighterglue is None: + from .lighterglue import LighterGlue + self.lighterglue = LighterGlue() + + data = { + 'keypoints0': d0['keypoints'][None, ...], + 'keypoints1': d1['keypoints'][None, ...], + 'descriptors0': d0['descriptors'][None, ...], + 'descriptors1': d1['descriptors'][None, ...], + 'image_size0': torch.tensor(d0['image_size']).to(self.dev)[None, ...], + 'image_size1': torch.tensor(d1['image_size']).to(self.dev)[None, ...] + } + + #Dict -> log_assignment: [B x M+1 x N+1] matches0: [B x M] matching_scores0: [B x M] matches1: [B x N] matching_scores1: [B x N] matches: List[[Si x 2]], scores: List[[Si]] + out = self.lighterglue(data, min_conf = min_conf) + + idxs = out['matches'][0] + + return d0['keypoints'][idxs[:, 0]].cpu().numpy(), d1['keypoints'][idxs[:, 1]].cpu().numpy(), out['matches'][0].cpu().numpy() + + + @torch.inference_mode() + def match_xfeat(self, img1, img2, top_k = None, min_cossim = -1): + """ + Simple extractor and MNN matcher. + For simplicity it does not support batched mode due to possibly different number of kpts. + input: + img1 -> torch.Tensor (1,C,H,W) or np.ndarray (H,W,C): grayscale or rgb image. + img2 -> torch.Tensor (1,C,H,W) or np.ndarray (H,W,C): grayscale or rgb image. + top_k -> int: keep best k features + returns: + mkpts_0, mkpts_1 -> np.ndarray (N,2) xy coordinate matches from image1 to image2 + """ + if top_k is None: top_k = self.top_k + img1 = self.parse_input(img1) + img2 = self.parse_input(img2) + + out1 = self.detectAndCompute(img1, top_k=top_k)[0] + out2 = self.detectAndCompute(img2, top_k=top_k)[0] + + idxs0, idxs1 = self.match(out1['descriptors'], out2['descriptors'], min_cossim=min_cossim ) + + return out1['keypoints'][idxs0].cpu().numpy(), out2['keypoints'][idxs1].cpu().numpy() + + @torch.inference_mode() + def match_xfeat_star(self, im_set1, im_set2, top_k = None): + """ + Extracts coarse feats, then match pairs and finally refine matches, currently supports batched mode. + input: + im_set1 -> torch.Tensor(B, C, H, W) or np.ndarray (H,W,C): grayscale or rgb images. + im_set2 -> torch.Tensor(B, C, H, W) or np.ndarray (H,W,C): grayscale or rgb images. + top_k -> int: keep best k features + returns: + matches -> List[torch.Tensor(N, 4)]: List of size B containing tensor of pairwise matches (x1,y1,x2,y2) + """ + if top_k is None: top_k = self.top_k + im_set1 = self.parse_input(im_set1) + im_set2 = self.parse_input(im_set2) + + #Compute coarse feats + out1 = self.detectAndComputeDense(im_set1, top_k=top_k) + out2 = self.detectAndComputeDense(im_set2, top_k=top_k) + + #Match batches of pairs + idxs_list = self.batch_match(out1['descriptors'], out2['descriptors'] ) + B = len(im_set1) + + #Refine coarse matches + #this part is harder to batch, currently iterate + matches = [] + for b in range(B): + matches.append(self.refine_matches(out1, out2, matches = idxs_list, batch_idx=b)) + + return matches if B > 1 else (matches[0][:, :2].cpu().numpy(), matches[0][:, 2:].cpu().numpy()) + + def preprocess_tensor(self, x): + """ Guarantee that image is divisible by 32 to avoid aliasing artifacts. """ + if isinstance(x, np.ndarray): + if len(x.shape) == 3: + x = torch.tensor(x).permute(2,0,1)[None] + elif len(x.shape) == 2: + x = torch.tensor(x[..., None]).permute(2,0,1)[None] + else: + raise RuntimeError('For numpy arrays, only (H,W) or (H,W,C) format is supported.') + + + if len(x.shape) != 4: + raise RuntimeError('Input tensor needs to be in (B,C,H,W) format') + + x = x.to(self.dev).float() + + H, W = x.shape[-2:] + _H, _W = (H//32) * 32, (W//32) * 32 + rh, rw = H/_H, W/_W + + x = F.interpolate(x, (_H, _W), mode='bilinear', align_corners=False) + return x, rh, rw + + def get_kpts_heatmap(self, kpts, softmax_temp = 1.0): + scores = F.softmax(kpts*softmax_temp, 1)[:, :64] + B, _, H, W = scores.shape + heatmap = scores.permute(0, 2, 3, 1).reshape(B, H, W, 8, 8) + heatmap = heatmap.permute(0, 1, 3, 2, 4).reshape(B, 1, H*8, W*8) + return heatmap + + def NMS(self, x, threshold = 0.05, kernel_size = 5): + B, _, H, W = x.shape + pad=kernel_size//2 + local_max = nn.MaxPool2d(kernel_size=kernel_size, stride=1, padding=pad)(x) + pos = (x == local_max) & (x > threshold) + pos_batched = [k.nonzero()[..., 1:].flip(-1) for k in pos] + + pad_val = max([len(x) for x in pos_batched]) + pos = torch.zeros((B, pad_val, 2), dtype=torch.long, device=x.device) + + #Pad kpts and build (B, N, 2) tensor + for b in range(len(pos_batched)): + pos[b, :len(pos_batched[b]), :] = pos_batched[b] + + return pos + + @torch.inference_mode() + def batch_match(self, feats1, feats2, min_cossim = -1): + B = len(feats1) + cossim = torch.bmm(feats1, feats2.permute(0,2,1)) + match12 = torch.argmax(cossim, dim=-1) + match21 = torch.argmax(cossim.permute(0,2,1), dim=-1) + + idx0 = torch.arange(len(match12[0]), device=match12.device) + + batched_matches = [] + + for b in range(B): + mutual = match21[b][match12[b]] == idx0 + + if min_cossim > 0: + cossim_max, _ = cossim[b].max(dim=1) + good = cossim_max > min_cossim + idx0_b = idx0[mutual & good] + idx1_b = match12[b][mutual & good] + else: + idx0_b = idx0[mutual] + idx1_b = match12[b][mutual] + + batched_matches.append((idx0_b, idx1_b)) + + return batched_matches + + def subpix_softmax2d(self, heatmaps, temp = 3): + N, H, W = heatmaps.shape + heatmaps = torch.softmax(temp * heatmaps.view(-1, H*W), -1).view(-1, H, W) + x, y = torch.meshgrid(torch.arange(W, device = heatmaps.device ), torch.arange(H, device = heatmaps.device ), indexing = 'xy') + x = x - (W//2) + y = y - (H//2) + + coords_x = (x[None, ...] * heatmaps) + coords_y = (y[None, ...] * heatmaps) + coords = torch.cat([coords_x[..., None], coords_y[..., None]], -1).view(N, H*W, 2) + coords = coords.sum(1) + + return coords + + def refine_matches(self, d0, d1, matches, batch_idx, fine_conf = 0.25): + idx0, idx1 = matches[batch_idx] + feats1 = d0['descriptors'][batch_idx][idx0] + feats2 = d1['descriptors'][batch_idx][idx1] + mkpts_0 = d0['keypoints'][batch_idx][idx0] + mkpts_1 = d1['keypoints'][batch_idx][idx1] + sc0 = d0['scales'][batch_idx][idx0] + + #Compute fine offsets + offsets = self.net.fine_matcher(torch.cat([feats1, feats2],dim=-1)) + conf = F.softmax(offsets*3, dim=-1).max(dim=-1)[0] + offsets = self.subpix_softmax2d(offsets.view(-1,8,8)) + + mkpts_0 += offsets* (sc0[:,None]) #*0.9 #* (sc0[:,None]) + + mask_good = conf > fine_conf + mkpts_0 = mkpts_0[mask_good] + mkpts_1 = mkpts_1[mask_good] + + return torch.cat([mkpts_0, mkpts_1], dim=-1) + + @torch.inference_mode() + def match(self, feats1, feats2, min_cossim = 0.82): + + cossim = feats1 @ feats2.t() + cossim_t = feats2 @ feats1.t() + + _, match12 = cossim.max(dim=1) + _, match21 = cossim_t.max(dim=1) + + idx0 = torch.arange(len(match12), device=match12.device) + mutual = match21[match12] == idx0 + + if min_cossim > 0: + cossim, _ = cossim.max(dim=1) + good = cossim > min_cossim + idx0 = idx0[mutual & good] + idx1 = match12[mutual & good] + else: + idx0 = idx0[mutual] + idx1 = match12[mutual] + + return idx0, idx1 + + def create_xy(self, h, w, dev): + y, x = torch.meshgrid(torch.arange(h, device = dev), + torch.arange(w, device = dev), indexing='ij') + xy = torch.cat([x[..., None],y[..., None]], -1).reshape(-1,2) + return xy + + def extractDense(self, x, top_k = 8_000): + if top_k < 1: + top_k = 100_000_000 + + x, rh1, rw1 = self.preprocess_tensor(x) + + M1, K1, H1 = self.net(x) + + B, C, _H1, _W1 = M1.shape + + xy1 = (self.create_xy(_H1, _W1, M1.device) * 8).expand(B,-1,-1) + + M1 = M1.permute(0,2,3,1).reshape(B, -1, C) + H1 = H1.permute(0,2,3,1).reshape(B, -1) + + _, top_k = torch.topk(H1, k = min(len(H1[0]), top_k), dim=-1) + + feats = torch.gather( M1, 1, top_k[...,None].expand(-1, -1, 64)) + mkpts = torch.gather(xy1, 1, top_k[...,None].expand(-1, -1, 2)) + mkpts = mkpts * torch.tensor([rw1, rh1], device=mkpts.device).view(1,-1) + + return mkpts, feats + + def extract_dualscale(self, x, top_k, s1 = 0.6, s2 = 1.3): + x1 = F.interpolate(x, scale_factor=s1, align_corners=False, mode='bilinear') + x2 = F.interpolate(x, scale_factor=s2, align_corners=False, mode='bilinear') + + B, _, _, _ = x.shape + + mkpts_1, feats_1 = self.extractDense(x1, int(top_k*0.20)) + mkpts_2, feats_2 = self.extractDense(x2, int(top_k*0.80)) + + mkpts = torch.cat([mkpts_1/s1, mkpts_2/s2], dim=1) + sc1 = torch.ones(mkpts_1.shape[:2], device=mkpts_1.device) * (1/s1) + sc2 = torch.ones(mkpts_2.shape[:2], device=mkpts_2.device) * (1/s2) + sc = torch.cat([sc1, sc2],dim=1) + feats = torch.cat([feats_1, feats_2], dim=1) + + return mkpts, sc, feats + + def parse_input(self, x): + if len(x.shape) == 3: + x = x[None, ...] + + if isinstance(x, np.ndarray): + x = torch.tensor(x).permute(0,3,1,2)/255 + + return x diff --git a/perception/tasks/gate/orientation/tests/__init__.py b/perception/tasks/gate/orientation/tests/__init__.py new file mode 100644 index 0000000..37be2eb --- /dev/null +++ b/perception/tasks/gate/orientation/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for gate orientation algorithms.""" diff --git a/perception/tasks/gate/orientation/xfeat_orientation.py b/perception/tasks/gate/orientation/xfeat_orientation.py new file mode 100644 index 0000000..20a765c --- /dev/null +++ b/perception/tasks/gate/orientation/xfeat_orientation.py @@ -0,0 +1,184 @@ +"""XFeat gate-orientation prototype ported from raymondt31/UR-B-Perception.""" + +import sys +import time +import cv2 +import torch +import threading +import numpy as np +from .modules.xfeat import XFeat +import argparse + +# ══════════════════════════════════════════════════════════════════════════════ +# Threaded Video Ingestion (Unchanged) +# ══════════════════════════════════════════════════════════════════════════════ +class FrameGrabber(threading.Thread): + def __init__(self, index, w, h): + super().__init__() + self.cap = cv2.VideoCapture(index) + self.cap.set(cv2.CAP_PROP_FRAME_WIDTH, w) + self.cap.set(cv2.CAP_PROP_FRAME_HEIGHT, h) + self.cap.set(cv2.CAP_PROP_FPS, 30) + + ret, self.frame = self.cap.read() + if not ret or self.frame is None: + sys.exit(f"[ERROR] Unable to access camera device index: {index}") + self._lock = threading.Lock() + self.running = False + + def run(self): + self.running = True + while self.running: + ret, frame = self.cap.read() + if ret and frame is not None: + with self._lock: + self.frame = frame + time.sleep(0.005) + + def get_frame(self): + with self._lock: + return self.frame.copy() if self.frame is not None else None + + def stop(self): + self.running = False + if self.cap.isOpened(): + self.cap.release() + +# ══════════════════════════════════════════════════════════════════════════════ +# Perception Subsystem Class +# ══════════════════════════════════════════════════════════════════════════════ +class GateDetector: + + def __init__(self, ref_image_path, width=640, height=480): + self.WIDTH = width + self.HEIGHT = height + self.MAX_KPTS = 2048 + self.TOLERANCE = 0.08 + self.RANSAC_THR = 4.0 + self.MIN_INLIERS = 30 + + # Load and prep reference image + ref_frame = cv2.imread(ref_image_path) + if ref_frame is None: + raise FileNotFoundError(f"[ERROR] Static reference path empty: {ref_image_path}") + + ref_frame = cv2.resize(ref_frame, (self.WIDTH, self.HEIGHT)) + self.ref_enh = self.enhance_underwater(ref_frame) + self.ref_h, self.ref_w = self.ref_enh.shape[:2] + + print("[INFO] Initializing PyTorch XFeat Backend Context...") + self.xfeat = XFeat(top_k=self.MAX_KPTS) + + print("[INFO] Pre-computing static reference structural maps...") + with torch.no_grad(): + self.ref_precomp = self.xfeat.detectAndCompute(self.ref_enh)[0] + + def enhance_underwater(self, img): + lab = cv2.cvtColor(img, cv2.COLOR_BGR2LAB) + l, a, b = cv2.split(lab) + clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) + lab = cv2.merge((clahe.apply(l), a, b)) + return cv2.cvtColor(lab, cv2.COLOR_LAB2BGR) + + def process_frame(self, live_raw): + """ + Takes a raw frame, processes it, and RETURNS the alignment data for Controls. + """ + live_enh = self.enhance_underwater(live_raw) + + with torch.no_grad(): + live_features = self.xfeat.detectAndCompute(live_enh)[0] + idx0, idx1 = self.xfeat.match(self.ref_precomp['descriptors'], live_features['descriptors'], 0.82) + points_ref = self.ref_precomp['keypoints'][idx0].cpu().numpy() + points_live = live_features['keypoints'][idx1].cpu().numpy() + + # Default empty state if detection fails + alignment_data = { + "valid": False, + "yaw_signal": 0.0, + "lateral_shift": 0.0, + "warped_corners": None, + "error_msg": "CRITICAL DETECTOR FAULT: NO KEY CORRELATIONS" + } + + if len(points_ref) >= 10: + H, inlier_mask = cv2.findHomography( + points_ref, points_live, + cv2.USAC_MAGSAC, self.RANSAC_THR, maxIters=800, confidence=0.99 + ) + + if H is not None: + inlier_mask = inlier_mask.flatten() > 0 + if inlier_mask.sum() >= self.MIN_INLIERS: + + #Calculate the metrics + corners_ref = np.array([[0, 0], [self.ref_w, 0], [self.ref_w, self.ref_h], [0, self.ref_h]], dtype=np.float32).reshape(-1, 1, 2) + warped_corners = cv2.perspectiveTransform(corners_ref, H) + + tl, tr, br, bl = warped_corners[:, 0, :] + h_left = np.linalg.norm(tl - bl) + h_right = np.linalg.norm(tr - br) + yaw_signal = np.log(h_left / max(h_right, 1e-6)) + + gate_mid = np.mean(warped_corners[:, 0, 0]) + lateral_shift = gate_mid - (self.WIDTH / 2.0) + + # Populate the successful data packet to return + alignment_data = { + "valid": True, + "yaw_signal": yaw_signal, + "lateral_shift": lateral_shift, + "warped_corners": warped_corners, + "valid_points": points_live[inlier_mask], + "error_msg": "" + } + else: + alignment_data["error_msg"] = "LOW INLIER CONSENSUS COUNTS" + else: + alignment_data["error_msg"] = "HOMOGRAPHY MATRIX DIED" + + # WE NOW RETURN THIS DICTIONARY TO WHATEVER SCRIPT CALLED THIS FUNCTION! + return alignment_data + +# ══════════════════════════════════════════════════════════════════════════════ +# Standalone Testing Module (Runs only if executed directly) +# ══════════════════════════════════════════════════════════════════════════════ +if __name__ == "__main__": + p = argparse.ArgumentParser() + p.add_argument("--ref", help="Path to reference gate image") + args = p.parse_args() + + detector = GateDetector(ref_image_path=args.ref) + + grabber = FrameGrabber(index=0, w=detector.WIDTH, h=detector.HEIGHT) + grabber.start() + time.sleep(0.5) + + cv2.namedWindow("RoboSub Alignment Dashboard", cv2.WINDOW_AUTOSIZE) + + while True: + live_raw = grabber.get_frame() + if live_raw is None: continue + + canvas = live_raw.copy() + + # ACTUALLY CALL THE FUNCTION AND GET THE RETURNED DATA + data = detector.process_frame(live_raw) + + if data["valid"]: + # Draw points and poly + for pt in data["valid_points"]: + cv2.circle(canvas, (int(pt[0]), int(pt[1])), 2, (0, 255, 255), -1) + cv2.polylines(canvas, [np.int32(data["warped_corners"])], True, (0, 165, 255), 3, cv2.LINE_AA) + + # Print the data to terminal => what controls will see + print(f"Controls Data -> Yaw: {data['yaw_signal']:.3f} | Strafe: {data['lateral_shift']:.1f}") + else: + cv2.putText(canvas, data["error_msg"], (15, 40), cv2.FONT_HERSHEY_SIMPLEX, 0.75, (0, 120, 255), 2) + + cv2.imshow("RoboSub Alignment Dashboard", canvas) + if cv2.waitKey(1) & 0xFF == ord('q'): + break + + grabber.stop() + cv2.destroyAllWindows() diff --git a/perception/tasks/gate/tests/__init__.py b/perception/tasks/gate/tests/__init__.py new file mode 100644 index 0000000..b1628fd --- /dev/null +++ b/perception/tasks/gate/tests/__init__.py @@ -0,0 +1 @@ +"""Tests for gate detection algorithms.""" diff --git a/perception/tasks/gate/yolo/__init__.py b/perception/tasks/gate/yolo/__init__.py new file mode 100644 index 0000000..60b458c --- /dev/null +++ b/perception/tasks/gate/yolo/__init__.py @@ -0,0 +1 @@ +"""Gate YOLO algorithms, pending confirmation of the model integration.""" diff --git a/requirements-torch.txt b/requirements-torch.txt index 4e69935..00b0507 100644 --- a/requirements-torch.txt +++ b/requirements-torch.txt @@ -2,3 +2,4 @@ torch==2.13.0 torchvision==0.28.0 ultralytics==8.4.104 +tqdm