From 74b5d33c86c9c1891ab6222cb5e2fa55ae974e3d Mon Sep 17 00:00:00 2001 From: Claude Agent Date: Thu, 16 Apr 2026 07:08:28 +0000 Subject: [PATCH] fix(vision): connected-components for rightmost-dot detection MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Dots on M2D MAPS strip are so close that anti-aliased edge pixels bridge adjacent dots column-counts → the previous walk-left approach merged the entire strip into one cluster and picked its midpoint. Connected components (8-connectivity) treats each dot as a separate blob even when antialiased edges touch. We pick the blob with the largest right-edge, then return its centroid. Robust, O(pixels), one opencv call. Co-Authored-By: Claude Opus 4.6 (1M context) --- src/atm/vision.py | 41 +++++++++++++++++++---------------------- 1 file changed, 19 insertions(+), 22 deletions(-) diff --git a/src/atm/vision.py b/src/atm/vision.py index fa9f378..2a1016c 100644 --- a/src/atm/vision.py +++ b/src/atm/vision.py @@ -101,30 +101,27 @@ def find_rightmost_dot( """ bgr_bg = np.array([bg_rgb[2], bg_rgb[1], bg_rgb[0]], dtype=np.float32) diff = np.linalg.norm(roi_img.astype(np.float32) - bgr_bg, axis=2) - mask = diff > bg_tol # True = non-background - _h, w = mask.shape - col_counts = mask.sum(axis=0) + mask = (diff > bg_tol).astype(np.uint8) - right_col = None - for x in range(w - 1, -1, -1): - if col_counts[x] >= min_cluster_px: - right_col = x - break - if right_col is None: + # Connected components → one component per dot. Anti-aliasing bridges + # between adjacent dots are small enough that 8-connectivity still + # separates them cleanly. + n_labels, _labels, stats, centroids = cv2.connectedComponentsWithStats( + mask, connectivity=8, + ) + best_idx: int | None = None + best_right = -1 + for i in range(1, n_labels): # skip background (label 0) + if int(stats[i, cv2.CC_STAT_AREA]) < min_cluster_px: + continue + right = int(stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH]) + if right > best_right: + best_right = right + best_idx = i + if best_idx is None: return None - - # Walk LEFT until a gap to find the cluster's left edge. - left_col = right_col - for x in range(right_col - 1, -1, -1): - if col_counts[x] < min_cluster_px: - break - left_col = x - - cx = (left_col + right_col) // 2 - cluster = mask[:, left_col:right_col + 1] - ys = np.where(cluster.any(axis=1))[0] - cy = int(ys.mean()) if ys.size > 0 else 0 - return (cx, cy) + cx, cy = centroids[best_idx] + return (int(cx), int(cy)) def pixel_rgb(roi_img: np.ndarray, x: int, y: int, box: int = 3) -> tuple[int, int, int]: