feat(perception): HSV color object segmenter — Issue #274

- Add ColorDetection.msg + ColorDetectionArray.msg to saltybot_scene_msgs
- Add _color_segmenter.py: HsvRange/ColorBlob types, COLOR_RANGES defaults,
  mask_for_color() (dual-band red wrap), find_color_blobs() with morph open,
  contour extraction, area filter and max-blob-per-color limit
- Add color_segment_node.py: subscribes /camera/color/image_raw (BEST_EFFORT),
  publishes /saltybot/color_objects (ColorDetectionArray) per frame;
  active_colors, min_area_px, max_blobs_per_color params
- Add saltybot_scene_msgs exec_depend to saltybot_bringup/package.xml
- Register color_segmenter console_script in setup.py
- 34/34 unit tests pass (no ROS2 required)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
sl-perception 2026-03-02 17:32:39 -05:00
parent 54bc37926b
commit f5093ecd34
8 changed files with 696 additions and 0 deletions

View File

@ -25,6 +25,8 @@
<exec_depend>saltybot_follower</exec_depend>
<exec_depend>saltybot_outdoor</exec_depend>
<exec_depend>saltybot_perception</exec_depend>
<!-- HSV color segmentation messages (Issue #274) -->
<exec_depend>saltybot_scene_msgs</exec_depend>
<exec_depend>saltybot_uwb</exec_depend>
<buildtool_depend>ament_python</buildtool_depend>

View File

@ -0,0 +1,184 @@
"""
_color_segmenter.py HSV color segmentation helpers (no ROS2 deps).
Algorithm
---------
For each requested color:
1. Convert BGR HSV (OpenCV: H[0,180], S[0,255], V[0,255])
2. Build a binary mask via cv2.inRange using the color's HSV bounds.
Red wraps around H=0/180 so two ranges are OR-combined.
3. Morphological open (3×3) to remove noise.
4. Find external contours; filter by min_area_px.
5. Return ColorBlob NamedTuples one per surviving contour.
confidence is the contour area divided by the bounding-rectangle area
(how "filled" the bounding box is), clamped to [0, 1].
Public API
----------
HsvRange(h_lo, h_hi, s_lo, s_hi, v_lo, v_hi)
ColorBlob(color_name, confidence, cx, cy, w, h, area_px, contour_id)
COLOR_RANGES : Dict[str, List[HsvRange]] default per-color HSV ranges
mask_for_color(hsv, color_name) -> np.ndarray uint8 binary mask
find_color_blobs(bgr, active_colors, min_area_px, max_blobs_per_color) -> List[ColorBlob]
"""
from __future__ import annotations
from typing import Dict, List, NamedTuple
import numpy as np
# ── Data types ────────────────────────────────────────────────────────────────
class HsvRange(NamedTuple):
"""Single HSV band (OpenCV: H∈[0,180], S/V∈[0,255])."""
h_lo: int
h_hi: int
s_lo: int
s_hi: int
v_lo: int
v_hi: int
class ColorBlob(NamedTuple):
"""One detected color object in image coordinates."""
color_name: str
confidence: float # contour_area / bbox_area (01)
cx: float # bbox centre x (pixels)
cy: float # bbox centre y (pixels)
w: float # bbox width (pixels)
h: float # bbox height (pixels)
area_px: float # contour area (pixels²)
contour_id: int # 0-based index within this color in this frame
# ── Default per-color HSV ranges ──────────────────────────────────────────────
# Two ranges are used for red (wraps at 0/180).
# S_lo=60, V_lo=50 to ignore desaturated / near-black pixels.
COLOR_RANGES: Dict[str, List[HsvRange]] = {
'red': [
HsvRange(h_lo=0, h_hi=10, s_lo=60, s_hi=255, v_lo=50, v_hi=255),
HsvRange(h_lo=170, h_hi=180, s_lo=60, s_hi=255, v_lo=50, v_hi=255),
],
'green': [
HsvRange(h_lo=35, h_hi=85, s_lo=60, s_hi=255, v_lo=50, v_hi=255),
],
'blue': [
HsvRange(h_lo=90, h_hi=130, s_lo=60, s_hi=255, v_lo=50, v_hi=255),
],
'yellow': [
HsvRange(h_lo=18, h_hi=38, s_lo=60, s_hi=255, v_lo=80, v_hi=255),
],
'orange': [
HsvRange(h_lo=8, h_hi=20, s_lo=80, s_hi=255, v_lo=80, v_hi=255),
],
}
# Structuring element for morphological open (noise removal)
_MORPH_KERNEL = None
def _get_morph_kernel():
import cv2
global _MORPH_KERNEL
if _MORPH_KERNEL is None:
_MORPH_KERNEL = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (3, 3))
return _MORPH_KERNEL
# ── Public helpers ─────────────────────────────────────────────────────────────
def mask_for_color(hsv: np.ndarray, color_name: str) -> np.ndarray:
"""
Return a uint8 binary mask (255=foreground) for *color_name* in the HSV image.
Parameters
----------
hsv : (H, W, 3) uint8 ndarray in OpenCV HSV format (H[0,180])
color_name : one of COLOR_RANGES keys
Returns
-------
(H, W) uint8 ndarray
"""
import cv2
ranges = COLOR_RANGES.get(color_name)
if not ranges:
raise ValueError(f'Unknown color: {color_name!r}. Known: {list(COLOR_RANGES)}')
mask = np.zeros(hsv.shape[:2], dtype=np.uint8)
for r in ranges:
lo = np.array([r.h_lo, r.s_lo, r.v_lo], dtype=np.uint8)
hi = np.array([r.h_hi, r.s_hi, r.v_hi], dtype=np.uint8)
mask |= cv2.inRange(hsv, lo, hi)
return cv2.morphologyEx(mask, cv2.MORPH_OPEN, _get_morph_kernel())
def find_color_blobs(
bgr: np.ndarray,
active_colors: List[str] | None = None,
min_area_px: float = 200.0,
max_blobs_per_color: int = 10,
) -> List[ColorBlob]:
"""
Detect HSV-segmented color blobs in a BGR image.
Parameters
----------
bgr : (H, W, 3) uint8 BGR ndarray
active_colors : color names to detect; None all COLOR_RANGES keys
min_area_px : minimum contour area to report (pixels²)
max_blobs_per_color : keep at most this many blobs per color (largest first)
Returns
-------
List[ColorBlob] may be empty; contour_id is 0-based within each color
"""
import cv2
if active_colors is None:
active_colors = list(COLOR_RANGES.keys())
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
blobs: List[ColorBlob] = []
for color_name in active_colors:
mask = mask_for_color(hsv, color_name)
contours, _ = cv2.findContours(
mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
# Sort largest first so max_blobs_per_color keeps the significant ones
contours = sorted(contours, key=cv2.contourArea, reverse=True)
blob_idx = 0
for cnt in contours:
if blob_idx >= max_blobs_per_color:
break
area = cv2.contourArea(cnt)
if area < min_area_px:
break # already sorted, no need to continue
x, y, bw, bh = cv2.boundingRect(cnt)
bbox_area = float(bw * bh)
confidence = float(area / bbox_area) if bbox_area > 0 else 0.0
confidence = min(1.0, max(0.0, confidence))
blobs.append(ColorBlob(
color_name=color_name,
confidence=confidence,
cx=float(x + bw / 2.0),
cy=float(y + bh / 2.0),
w=float(bw),
h=float(bh),
area_px=float(area),
contour_id=blob_idx,
))
blob_idx += 1
return blobs

View File

@ -0,0 +1,127 @@
"""
color_segment_node.py D435i HSV color object segmenter (Issue #274).
Subscribes to the RealSense colour stream, applies per-color HSV thresholding,
extracts contours, and publishes detected blobs as ColorDetectionArray.
Subscribes (BEST_EFFORT):
/camera/color/image_raw sensor_msgs/Image BGR8 (or rgb8)
Publishes:
/saltybot/color_objects saltybot_scene_msgs/ColorDetectionArray
Parameters
----------
active_colors str "red,green,blue,yellow,orange" Comma-separated list
min_area_px float 200.0 Minimum contour area (pixels²)
max_blobs_per_color int 10 Max detections per color per frame
"""
from __future__ import annotations
import rclpy
from rclpy.node import Node
from rclpy.qos import QoSProfile, ReliabilityPolicy, HistoryPolicy
import numpy as np
from cv_bridge import CvBridge
from sensor_msgs.msg import Image
from std_msgs.msg import Header
from saltybot_scene_msgs.msg import ColorDetection, ColorDetectionArray
from vision_msgs.msg import BoundingBox2D
from geometry_msgs.msg import Pose2D
from ._color_segmenter import find_color_blobs
_SENSOR_QOS = QoSProfile(
reliability=ReliabilityPolicy.BEST_EFFORT,
history=HistoryPolicy.KEEP_LAST,
depth=4,
)
_DEFAULT_COLORS = 'red,green,blue,yellow,orange'
class ColorSegmentNode(Node):
def __init__(self) -> None:
super().__init__('color_segment_node')
self.declare_parameter('active_colors', _DEFAULT_COLORS)
self.declare_parameter('min_area_px', 200.0)
self.declare_parameter('max_blobs_per_color', 10)
colors_str = self.get_parameter('active_colors').value
self._active_colors = [c.strip() for c in colors_str.split(',') if c.strip()]
self._min_area = float(self.get_parameter('min_area_px').value)
self._max_blobs = int(self.get_parameter('max_blobs_per_color').value)
self._bridge = CvBridge()
self._sub = self.create_subscription(
Image, '/camera/color/image_raw', self._on_image, _SENSOR_QOS)
self._pub = self.create_publisher(
ColorDetectionArray, '/saltybot/color_objects', 10)
self.get_logger().info(
f'color_segment_node ready — colors={self._active_colors} '
f'min_area={self._min_area}px² max_blobs={self._max_blobs}'
)
# ── Callback ──────────────────────────────────────────────────────────────
def _on_image(self, msg: Image) -> None:
try:
bgr = self._bridge.imgmsg_to_cv2(msg, desired_encoding='bgr8')
except Exception as exc:
self.get_logger().error(
f'cv_bridge: {exc}', throttle_duration_sec=5.0)
return
blobs = find_color_blobs(
bgr,
active_colors=self._active_colors,
min_area_px=self._min_area,
max_blobs_per_color=self._max_blobs,
)
arr = ColorDetectionArray()
arr.header = msg.header
for blob in blobs:
det = ColorDetection()
det.header = msg.header
det.color_name = blob.color_name
det.confidence = blob.confidence
det.area_px = blob.area_px
det.contour_id = blob.contour_id
bbox = BoundingBox2D()
center = Pose2D()
center.x = blob.cx
center.y = blob.cy
bbox.center = center
bbox.size_x = blob.w
bbox.size_y = blob.h
det.bbox = bbox
arr.detections.append(det)
self._pub.publish(arr)
def main(args=None) -> None:
rclpy.init(args=args)
node = ColorSegmentNode()
try:
rclpy.spin(node)
finally:
node.destroy_node()
rclpy.shutdown()
if __name__ == '__main__':
main()

View File

@ -39,6 +39,8 @@ setup(
'vo_drift_detector = saltybot_bringup.vo_drift_node:main',
# Depth image hole filler (Issue #268)
'depth_hole_fill = saltybot_bringup.depth_hole_fill_node:main',
# HSV color object segmenter (Issue #274)
'color_segmenter = saltybot_bringup.color_segment_node:main',
],
},
)

View File

@ -0,0 +1,361 @@
"""
test_color_segmenter.py Unit tests for HSV color segmentation helpers (no ROS2 required).
Covers:
HsvRange / ColorBlob:
- NamedTuple fields accessible by name
- confidence clamped to [0,1]
mask_for_color:
- pure red image red mask fully white
- pure red image green mask fully black
- pure green image green mask fully white
- pure blue image blue mask fully white
- pure yellow image yellow mask non-empty
- pure orange image orange mask non-empty
- red hue wrap-around detected from both HSV bands
- unknown color name raises ValueError
- mask is uint8
- mask shape matches input
find_color_blobs output contract:
- returns list
- empty list on blank (no-color) image
- empty list when min_area_px larger than any contour
find_color_blobs detection:
- large red rectangle detected as red blob
- large green rectangle detected as green blob
- large blue rectangle detected as blue blob
- detected blob color_name matches requested color
- contour_id is 0 for first blob
- confidence in [0, 1]
- cx, cy within image bounds
- w, h > 0 for detected blob
- area_px > 0 for detected blob
find_color_blobs filtering:
- active_colors=None detects all colors when present
- only requested colors returned when active_colors restricted
- max_blobs_per_color limits output count
- two separate red blobs both detected when max_blobs=2
- smaller blob filtered when min_area_px high
find_color_blobs multi-color:
- image with red + green regions both detected
"""
import sys
import os
import numpy as np
import pytest
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
from saltybot_bringup._color_segmenter import (
HsvRange,
ColorBlob,
COLOR_RANGES,
mask_for_color,
find_color_blobs,
)
# ── Image factories ───────────────────────────────────────────────────────────
def _solid_bgr(b, g, r, h=64, w=64) -> np.ndarray:
"""Solid BGR image."""
img = np.zeros((h, w, 3), dtype=np.uint8)
img[:, :] = (b, g, r)
return img
def _blank(h=64, w=64) -> np.ndarray:
"""All-black image (nothing to detect)."""
return np.zeros((h, w, 3), dtype=np.uint8)
def _image_with_rect(bg_bgr, rect_bgr, rect_slice_r, rect_slice_c, h=128, w=128) -> np.ndarray:
"""Background colour with a filled rectangle."""
img = np.zeros((h, w, 3), dtype=np.uint8)
img[:, :] = bg_bgr
img[rect_slice_r, rect_slice_c] = rect_bgr
return img
# Canonical solid color BGR values (saturated, in-range for HSV thresholds)
_RED_BGR = (0, 0, 200) # BGR pure red
_GREEN_BGR = (0, 200, 0 ) # BGR pure green
_BLUE_BGR = (200, 0, 0 ) # BGR pure blue
_YELLOW_BGR = (0, 220, 220) # BGR yellow
_ORANGE_BGR = (0, 140, 220) # BGR orange
# ── HsvRange / ColorBlob types ────────────────────────────────────────────────
class TestTypes:
def test_hsv_range_fields(self):
r = HsvRange(0, 10, 60, 255, 50, 255)
assert r.h_lo == 0 and r.h_hi == 10
assert r.s_lo == 60 and r.s_hi == 255
assert r.v_lo == 50 and r.v_hi == 255
def test_color_blob_fields(self):
b = ColorBlob('red', 0.8, 32.0, 32.0, 20.0, 20.0, 300.0, 0)
assert b.color_name == 'red'
assert b.confidence == pytest.approx(0.8)
assert b.contour_id == 0
def test_color_ranges_contains_all_defaults(self):
for color in ('red', 'green', 'blue', 'yellow', 'orange'):
assert color in COLOR_RANGES
assert len(COLOR_RANGES[color]) >= 1
# ── mask_for_color ────────────────────────────────────────────────────────────
class TestMaskForColor:
def test_mask_is_uint8(self):
import cv2
hsv = cv2.cvtColor(_solid_bgr(*_RED_BGR), cv2.COLOR_BGR2HSV)
m = mask_for_color(hsv, 'red')
assert m.dtype == np.uint8
def test_mask_shape_matches_input(self):
import cv2
bgr = _solid_bgr(*_RED_BGR, h=48, w=80)
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
m = mask_for_color(hsv, 'red')
assert m.shape == (48, 80)
def test_pure_red_gives_red_mask_nonzero(self):
import cv2
hsv = cv2.cvtColor(_solid_bgr(*_RED_BGR), cv2.COLOR_BGR2HSV)
m = mask_for_color(hsv, 'red')
assert m.any(), 'red mask should be non-empty for red image'
def test_pure_red_gives_green_mask_empty(self):
import cv2
hsv = cv2.cvtColor(_solid_bgr(*_RED_BGR), cv2.COLOR_BGR2HSV)
m = mask_for_color(hsv, 'green')
assert not m.any(), 'green mask should be empty for red image'
def test_pure_green_gives_green_mask_nonzero(self):
import cv2
hsv = cv2.cvtColor(_solid_bgr(*_GREEN_BGR), cv2.COLOR_BGR2HSV)
m = mask_for_color(hsv, 'green')
assert m.any()
def test_pure_blue_gives_blue_mask_nonzero(self):
import cv2
hsv = cv2.cvtColor(_solid_bgr(*_BLUE_BGR), cv2.COLOR_BGR2HSV)
m = mask_for_color(hsv, 'blue')
assert m.any()
def test_pure_yellow_gives_yellow_mask_nonzero(self):
import cv2
hsv = cv2.cvtColor(_solid_bgr(*_YELLOW_BGR), cv2.COLOR_BGR2HSV)
m = mask_for_color(hsv, 'yellow')
assert m.any()
def test_pure_orange_gives_orange_mask_nonzero(self):
import cv2
hsv = cv2.cvtColor(_solid_bgr(*_ORANGE_BGR), cv2.COLOR_BGR2HSV)
m = mask_for_color(hsv, 'orange')
assert m.any()
def test_unknown_color_raises(self):
import cv2
hsv = cv2.cvtColor(_blank(), cv2.COLOR_BGR2HSV)
with pytest.raises(ValueError, match='Unknown color'):
mask_for_color(hsv, 'purple')
def test_red_detected_in_high_hue_band(self):
"""A near-180-hue red pixel should still trigger the red mask."""
import cv2
# HSV (175, 200, 200) = high-hue red (wrap-around band)
hsv = np.full((32, 32, 3), (175, 200, 200), dtype=np.uint8)
m = mask_for_color(hsv, 'red')
assert m.any(), 'high-hue red not detected'
# ── find_color_blobs — output contract ───────────────────────────────────────
class TestFindColorBlobsContract:
def test_returns_list(self):
result = find_color_blobs(_blank())
assert isinstance(result, list)
def test_blank_image_returns_empty(self):
result = find_color_blobs(_blank())
assert result == []
def test_min_area_filter_removes_all(self):
"""Request a min area larger than the entire image → no blobs."""
bgr = _solid_bgr(*_RED_BGR, h=32, w=32)
result = find_color_blobs(bgr, active_colors=['red'], min_area_px=1e9)
assert result == []
# ── find_color_blobs — detection ─────────────────────────────────────────────
class TestFindColorBlobsDetection:
def _large_rect(self, color_bgr, color_name) -> np.ndarray:
"""100×100 image with a 60×60 solid-color rectangle centred."""
img = _blank(h=100, w=100)
img[20:80, 20:80] = color_bgr
return img
def test_red_rect_detected(self):
blobs = find_color_blobs(self._large_rect(_RED_BGR, 'red'), active_colors=['red'])
assert len(blobs) >= 1
assert blobs[0].color_name == 'red'
def test_green_rect_detected(self):
blobs = find_color_blobs(self._large_rect(_GREEN_BGR, 'green'), active_colors=['green'])
assert len(blobs) >= 1
assert blobs[0].color_name == 'green'
def test_blue_rect_detected(self):
blobs = find_color_blobs(self._large_rect(_BLUE_BGR, 'blue'), active_colors=['blue'])
assert len(blobs) >= 1
assert blobs[0].color_name == 'blue'
def test_first_contour_id_is_zero(self):
img = _blank(h=100, w=100)
img[20:80, 20:80] = _RED_BGR
blobs = find_color_blobs(img, active_colors=['red'])
assert blobs[0].contour_id == 0
def test_confidence_in_range(self):
img = _blank(h=100, w=100)
img[20:80, 20:80] = _GREEN_BGR
blobs = find_color_blobs(img, active_colors=['green'])
assert blobs
assert 0.0 <= blobs[0].confidence <= 1.0
def test_cx_within_image(self):
img = _blank(h=100, w=100)
img[20:80, 20:80] = _BLUE_BGR
blobs = find_color_blobs(img, active_colors=['blue'])
assert blobs
assert 0.0 <= blobs[0].cx <= 100.0
def test_cy_within_image(self):
img = _blank(h=100, w=100)
img[20:80, 20:80] = _BLUE_BGR
blobs = find_color_blobs(img, active_colors=['blue'])
assert blobs
assert 0.0 <= blobs[0].cy <= 100.0
def test_w_positive(self):
img = _blank(h=100, w=100)
img[20:80, 20:80] = _RED_BGR
blobs = find_color_blobs(img, active_colors=['red'])
assert blobs[0].w > 0
def test_h_positive(self):
img = _blank(h=100, w=100)
img[20:80, 20:80] = _RED_BGR
blobs = find_color_blobs(img, active_colors=['red'])
assert blobs[0].h > 0
def test_area_px_positive(self):
img = _blank(h=100, w=100)
img[20:80, 20:80] = _RED_BGR
blobs = find_color_blobs(img, active_colors=['red'])
assert blobs[0].area_px > 0
def test_area_px_reasonable(self):
"""area_px should be roughly within the rectangle we drew."""
img = _blank(h=100, w=100)
img[20:80, 20:80] = _GREEN_BGR # 60×60 = 3600 px
blobs = find_color_blobs(img, active_colors=['green'], min_area_px=100.0)
assert blobs
assert 1000 <= blobs[0].area_px <= 4000
# ── find_color_blobs — filtering ─────────────────────────────────────────────
class TestFindColorBlobsFiltering:
def test_active_colors_none_detects_all(self):
"""Image with red+green patches → both found when active_colors=None."""
img = _blank(h=128, w=128)
img[10:50, 10:50] = _RED_BGR
img[10:50, 70:110] = _GREEN_BGR
blobs = find_color_blobs(img, active_colors=None, min_area_px=100.0)
names = {b.color_name for b in blobs}
assert 'red' in names
assert 'green' in names
def test_restricted_active_colors(self):
"""Only red requested → no green blobs returned."""
img = _blank(h=128, w=128)
img[10:50, 10:50] = _RED_BGR
img[10:50, 70:110] = _GREEN_BGR
blobs = find_color_blobs(img, active_colors=['red'], min_area_px=100.0)
assert all(b.color_name == 'red' for b in blobs)
def test_max_blobs_per_color_limits(self):
"""Four separate red rectangles but max_blobs=2 → at most 2 blobs."""
img = _blank(h=200, w=200)
img[10:40, 10:40] = _RED_BGR
img[10:40, 80:110] = _RED_BGR
img[100:130, 10:40] = _RED_BGR
img[100:130, 80:110] = _RED_BGR
blobs = find_color_blobs(img, active_colors=['red'],
min_area_px=100.0, max_blobs_per_color=2)
red_blobs = [b for b in blobs if b.color_name == 'red']
assert len(red_blobs) <= 2
def test_two_blobs_detected_when_max_allows(self):
"""Two red rectangles detected when max_blobs_per_color >= 2."""
img = _blank(h=200, w=200)
img[10:60, 10:60] = _RED_BGR
img[10:60, 130:180] = _RED_BGR
blobs = find_color_blobs(img, active_colors=['red'],
min_area_px=100.0, max_blobs_per_color=10)
red_blobs = [b for b in blobs if b.color_name == 'red']
assert len(red_blobs) >= 2
def test_small_blob_filtered_by_min_area(self):
"""Small 5×5 red patch filtered by min_area_px=500."""
img = _blank(h=64, w=64)
img[28:33, 28:33] = _RED_BGR # 5×5 = 25 px contour area
blobs = find_color_blobs(img, active_colors=['red'], min_area_px=500.0)
assert blobs == []
# ── find_color_blobs — multi-color ───────────────────────────────────────────
class TestFindColorBlobsMultiColor:
def test_red_and_green_in_same_image(self):
img = _blank(h=128, w=128)
img[10:60, 10:60] = _RED_BGR
img[10:60, 68:118] = _GREEN_BGR
blobs = find_color_blobs(img, active_colors=['red', 'green'], min_area_px=100.0)
names = {b.color_name for b in blobs}
assert 'red' in names, 'red blob should be detected'
assert 'green' in names, 'green blob should be detected'
def test_contour_ids_per_color_start_at_zero(self):
"""contour_id should be 0 for the first (largest) blob of each color."""
img = _blank(h=200, w=200)
img[10:80, 10:80] = _RED_BGR
img[10:80, 110:180] = _BLUE_BGR
blobs = find_color_blobs(img, active_colors=['red', 'blue'], min_area_px=100.0)
for color in ('red', 'blue'):
first = next((b for b in blobs if b.color_name == color), None)
assert first is not None, f'{color} blob not found'
assert first.contour_id == 0, f'{color} first blob contour_id != 0'
if __name__ == '__main__':
pytest.main([__file__, '-v'])

View File

@ -16,6 +16,9 @@ rosidl_generate_interfaces(${PROJECT_NAME}
# Issue #233 QR code reader
"msg/QRDetection.msg"
"msg/QRDetectionArray.msg"
# Issue #274 HSV color segmentation
"msg/ColorDetection.msg"
"msg/ColorDetectionArray.msg"
DEPENDENCIES std_msgs geometry_msgs vision_msgs builtin_interfaces
)

View File

@ -0,0 +1,14 @@
# ColorDetection.msg — single HSV color-segmented object detection (Issue #274)
#
# color_name : target color label ("red", "green", "blue", "yellow", "orange")
# confidence : mask fill ratio inside bbox (contour_area / bbox_area, 01)
# bbox : axis-aligned bounding box in image pixels (center + size)
# area_px : contour area in pixels² (use for size filtering downstream)
# contour_id : 0-based index of this detection within the current frame
#
std_msgs/Header header
string color_name
float32 confidence
vision_msgs/BoundingBox2D bbox
float32 area_px
uint32 contour_id

View File

@ -0,0 +1,3 @@
# ColorDetectionArray.msg — frame-level list of HSV color-segmented objects (Issue #274)
std_msgs/Header header
ColorDetection[] detections