Skip to content

Commit 2ff9e1e

Browse files
committed
feat: stream clustered poses based on child_frame_ids
1 parent 84d7fc0 commit 2ff9e1e

5 files changed

Lines changed: 157 additions & 42 deletions

File tree

bb_filters/nodes/cluster/cluster_poses_action_node.py

Lines changed: 11 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
ClusterPosesNode,
1010
fill_cluster_result_array,
1111
seconds_to_duration,
12+
validate_stream_frame_ids,
1213
)
1314
from rclpy.action import ActionServer, GoalResponse
1415
from rclpy.action.server import ServerGoalHandle
@@ -117,6 +118,10 @@ def _start_goal(self, goal_handle: ServerGoalHandle) -> None:
117118
feedback.poses_collected_so_far = 0
118119
goal_handle.publish_feedback(feedback)
119120

121+
validate_stream_frame_ids(
122+
list(goal.params.clustered_child_frame_ids),
123+
len(goal.params.pose_stamped_topics),
124+
)
120125
self._start_subscribers(
121126
odom_topic=goal.params.odom_topic,
122127
pose_topics=list(goal.params.pose_stamped_topics),
@@ -154,7 +159,7 @@ def _step_collection(self, goal_handle: ServerGoalHandle) -> None:
154159
feedback = ClusterPosesAction.Feedback()
155160
feedback.current_status = "Collecting synchronized messages"
156161
feedback.collection_progress = min(elapsed.nanoseconds / duration_ns, 1.0)
157-
feedback.poses_collected_so_far = len(self._synchronized_data)
162+
feedback.poses_collected_so_far = self._num_collected()
158163
goal_handle.publish_feedback(feedback)
159164

160165
def _finalize_goal(self, goal_handle: ServerGoalHandle) -> None:
@@ -166,21 +171,18 @@ def _finalize_goal(self, goal_handle: ServerGoalHandle) -> None:
166171
feedback = ClusterPosesAction.Feedback()
167172
feedback.current_status = "Finalizing clustering"
168173
feedback.collection_progress = 1.0
169-
feedback.poses_collected_so_far = len(self._synchronized_data)
174+
feedback.poses_collected_so_far = self._num_collected()
170175
goal_handle.publish_feedback(feedback)
171176

172-
clustered, transformed_poses, total_collected = self._run_clustering(
173-
self._cluster_params(goal)
177+
clustered, total_collected, last_header = self._cluster_streams(
178+
list(params.clustered_child_frame_ids),
179+
self._cluster_params(goal),
180+
publish_tfs=True,
174181
)
175182
if not clustered:
176183
self._finish_action("aborted", self._empty_result(int(params.sort_key)))
177184
return
178185

179-
last_header = transformed_poses[-1].header
180-
self._publish_results(
181-
clustered, transformed_poses, params.clustered_child_frame_id
182-
)
183-
184186
result = ClusterPosesAction.Result()
185187
fill_cluster_result_array(
186188
result.cluster_results,

bb_filters/nodes/cluster/cluster_poses_node.py

Lines changed: 89 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -69,6 +69,19 @@ def build_cluster_pose_result(
6969
return msg
7070

7171

72+
def validate_stream_frame_ids(frame_ids: list[str], num_pose_topics: int) -> None:
73+
"""Reject `clustered_child_frame_ids` lengths the stream layout can't honour.
74+
75+
Empty (single default stream) and 1 (single merged stream) are always
76+
valid; otherwise there must be exactly one frame per pose topic.
77+
"""
78+
if frame_ids and len(frame_ids) not in (1, num_pose_topics):
79+
raise ValueError(
80+
"clustered_child_frame_ids must be empty, have 1 entry, or one per "
81+
f"pose topic ({num_pose_topics}); got {len(frame_ids)}"
82+
)
83+
84+
7285
def fill_cluster_result_array(
7386
msg: ClusterPoseResultArray,
7487
clustered: list[tuple[Pose, ClusterResult]],
@@ -106,7 +119,10 @@ def __init__(self, node_name: str) -> None:
106119
qos_profile=static_qos,
107120
)
108121

109-
self._synchronized_data: list[tuple[Odometry, PoseStamped]] = []
122+
# One accumulation buffer per pose topic, indexed by topic position.
123+
# Populated by `_start_subscribers`; stream layout (merge vs. split) is
124+
# decided later by the output frame IDs, not here.
125+
self._stream_data: list[list[tuple[Odometry, PoseStamped]]] = []
110126
self._camera_to_odom_transforms: dict[str, TransformStamped] = {}
111127
self._odom_subscriber: Subscriber | None = None
112128
self._pose_subscribers: list[Subscriber] = []
@@ -138,8 +154,13 @@ def _handle_tf_static(self, msg: TFMessage) -> None:
138154
for transform in msg.transforms:
139155
self._tf_buffer.set_transform_static(transform, "default_authority")
140156

141-
def _synchronized_callback(self, odom_msg: Odometry, pose_msg: PoseStamped) -> None:
142-
self._synchronized_data.append((odom_msg, pose_msg))
157+
def _collect_pose(
158+
self, odom_msg: Odometry, pose_msg: PoseStamped, stream_index: int
159+
) -> None:
160+
self._stream_data[stream_index].append((odom_msg, pose_msg))
161+
162+
def _num_collected(self) -> int:
163+
return sum(len(stream) for stream in self._stream_data)
143164

144165
def _is_detection_too_old(
145166
self, clustering_time: Time, pose_msg: PoseStamped
@@ -153,7 +174,7 @@ def _is_detection_too_old(
153174
return detection_age_s >= self._max_detection_age_s
154175

155176
def _reset_collection(self) -> None:
156-
self._synchronized_data = []
177+
self._stream_data = []
157178
self._camera_to_odom_transforms = {}
158179

159180
def _ensure_camera_to_odom(
@@ -201,18 +222,25 @@ def _start_subscribers(
201222
sync_queue_size: int | None = None,
202223
max_detection_age_s: float = 0.0,
203224
) -> None:
204-
"""Subscribe to one odom topic and N pose topics, all feeding one buffer."""
225+
"""Subscribe to one odom topic and N pose topics.
226+
227+
Each pose topic accumulates into its own `_stream_data` buffer (indexed
228+
by topic position). Whether those buffers are later merged into one
229+
clustering stream or clustered independently is decided at cluster time
230+
by the output frame IDs, not here.
231+
"""
205232
if not pose_topics:
206233
raise ValueError("pose_topics must contain at least one topic")
207234
self._max_detection_age_s = float(max_detection_age_s)
235+
self._stream_data = [[] for _ in pose_topics]
208236
qsize = int(sync_queue_size or self._sync_queue_size)
209237
self._odom_subscriber = Subscriber(
210238
self,
211239
Odometry,
212240
odom_topic,
213241
qos_profile=qos_profile_sensor_data,
214242
)
215-
for topic in pose_topics:
243+
for stream_index, topic in enumerate(pose_topics):
216244
pose_sub = Subscriber(
217245
self,
218246
PoseStamped,
@@ -225,7 +253,7 @@ def _start_subscribers(
225253
queue_size=qsize,
226254
slop=float(sync_tolerance),
227255
)
228-
time_synchronizer.registerCallback(self._synchronized_callback)
256+
time_synchronizer.registerCallback(self._collect_pose, stream_index)
229257
self._time_synchronizers.append(time_synchronizer)
230258

231259
def _cleanup_subscribers(self) -> None:
@@ -242,35 +270,80 @@ def _cleanup_subscribers(self) -> None:
242270
self._pose_subscribers = []
243271
self._time_synchronizers = []
244272

273+
def _resolve_stream_frames(
274+
self, frame_ids: list[str]
275+
) -> list[tuple[list[tuple[Odometry, PoseStamped]], str]]:
276+
"""Pair collected poses with the output frame for each clustering stream.
277+
278+
The number of `frame_ids` selects the layout (see
279+
`validate_stream_frame_ids`):
280+
- empty or 1 entry: every pose topic is merged into a single stream.
281+
- one per pose topic: each topic is its own independent stream.
282+
"""
283+
frame_ids = list(frame_ids) or ["clustered_object"]
284+
validate_stream_frame_ids(frame_ids, len(self._stream_data))
285+
if len(frame_ids) == 1:
286+
merged = [pair for stream in self._stream_data for pair in stream]
287+
return [(merged, frame_ids[0])]
288+
return list(zip(self._stream_data, frame_ids))
289+
290+
def _cluster_streams(
291+
self,
292+
frame_ids: list[str],
293+
params: ClusterParams,
294+
publish_tfs: bool,
295+
) -> tuple[list[tuple[Pose, ClusterResult]], int, object]:
296+
"""Cluster each output stream independently and aggregate the results.
297+
298+
`params.top_k` applies per stream, so N streams can yield up to
299+
N * top_k results. Returns `(clustered, total_collected, last_header)`;
300+
when `publish_tfs`, each stream's results are TF-broadcast under its
301+
own frame.
302+
"""
303+
aggregated: list[tuple[Pose, ClusterResult]] = []
304+
total_collected = 0
305+
last_header = None
306+
for synchronized_data, frame_id in self._resolve_stream_frames(frame_ids):
307+
clustered, transformed_poses, collected = self._run_clustering(
308+
synchronized_data, params
309+
)
310+
total_collected += collected
311+
if not clustered:
312+
continue
313+
last_header = transformed_poses[-1].header
314+
if publish_tfs:
315+
self._publish_results(clustered, transformed_poses, frame_id)
316+
aggregated.extend(clustered)
317+
return aggregated, total_collected, last_header
318+
245319
def _run_clustering(
246320
self,
321+
synchronized_data: list[tuple[Odometry, PoseStamped]],
247322
params: ClusterParams,
248323
) -> tuple[
249324
list[tuple[Pose, ClusterResult]],
250325
list[PoseStamped],
251326
int,
252327
]:
253-
"""Run the full clustering pipeline against the current buffer.
328+
"""Run the full clustering pipeline against `synchronized_data`.
254329
255330
Returns `(clustered, transformed_poses, total_collected)`
256331
"""
257-
if not self._synchronized_data:
258-
total_collected = 0
332+
if not synchronized_data:
259333
self.get_logger().error(
260334
"Not enough synchronized poses collected. "
261-
f"Got {total_collected}, need {int(params.min_poses)}"
335+
f"Got 0, need {int(params.min_poses)}"
262336
)
263-
return [], [], total_collected
337+
return [], [], 0
264338

265339
# Match the result PoseArray/ClusterPoseResultArray timestamp, which is
266340
# taken from the last transformed pose after this age filter.
267-
clustering_time = Time.from_msg(self._synchronized_data[-1][1].header.stamp)
268-
self._synchronized_data = [
341+
clustering_time = Time.from_msg(synchronized_data[-1][1].header.stamp)
342+
synchronized_data = [
269343
(odom_msg, pose_msg)
270-
for odom_msg, pose_msg in self._synchronized_data
344+
for odom_msg, pose_msg in synchronized_data
271345
if not self._is_detection_too_old(clustering_time, pose_msg)
272346
]
273-
synchronized_data = self._synchronized_data
274347
total_collected = len(synchronized_data)
275348

276349
if total_collected < int(params.min_poses):

bb_filters/nodes/cluster/cluster_poses_service_node.py

Lines changed: 13 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@
99
ClusterParams,
1010
ClusterPosesNode,
1111
fill_cluster_result_array,
12+
validate_stream_frame_ids,
1213
)
1314
from geometry_msgs.msg import Pose
1415
from rclpy.timer import Timer
@@ -54,7 +55,7 @@ def __init__(self) -> None:
5455
self.top_k = 1
5556
self.sort_key = int(ClusterSortKey.NUM_CLUSTER_POSES)
5657
self.cluster_interval = 0.0
57-
self._clustered_child_frame_id = ""
58+
self._clustered_child_frame_ids: list[str] = []
5859
self._cluster_timer: Timer | None = None
5960

6061
self.get_logger().info("Cluster Poses Service Node initialized")
@@ -78,8 +79,11 @@ def _apply_request_config(self, request: ClusterPosesSrv.Request) -> None:
7879
self.top_k = int(params.top_k)
7980
self.sort_key = int(params.sort_key)
8081
self.cluster_interval = float(request.cluster_interval)
81-
self._clustered_child_frame_id = (
82-
params.clustered_child_frame_id or "unknown/clustered"
82+
# Stream layout (merge vs. independent per topic) is derived from the
83+
# number of frame IDs at cluster time; validate the length up front.
84+
self._clustered_child_frame_ids = list(params.clustered_child_frame_ids)
85+
validate_stream_frame_ids(
86+
self._clustered_child_frame_ids, len(self.pose_stamped_topics)
8387
)
8488

8589
def _cluster_params(self) -> ClusterParams:
@@ -152,17 +156,13 @@ def _stop_and_cluster(
152156
response.cluster_results.sort_key = int(self.sort_key)
153157
response.is_enabled = False
154158

155-
clustered, transformed_poses, total_collected = self._run_clustering(
156-
self._cluster_params()
159+
clustered, total_collected, last_header = self._cluster_streams(
160+
self._clustered_child_frame_ids, self._cluster_params(), publish_tfs=True
157161
)
158162
if not clustered:
159163
response.is_cluster_success = False
160164
return response
161165

162-
last_header = transformed_poses[-1].header
163-
self._publish_results(
164-
clustered, transformed_poses, self._clustered_child_frame_id
165-
)
166166
fill_cluster_result_array(
167167
response.cluster_results,
168168
clustered,
@@ -178,20 +178,18 @@ def _stop_and_cluster(
178178
finally:
179179
self._cleanup_cluster_pose_publishers()
180180
self._reset_collection()
181-
self._clustered_child_frame_id = ""
181+
self._clustered_child_frame_ids = []
182182

183183
def _periodic_cluster_tick(self) -> None:
184184
"""Cluster the poses collected so far and publish a ClusterPoseResultArray."""
185185
if not self.enabled:
186186
return
187-
clustered, transformed_poses, total_collected = self._run_clustering(
188-
self._cluster_params()
187+
clustered, total_collected, last_header = self._cluster_streams(
188+
self._clustered_child_frame_ids, self._cluster_params(), publish_tfs=False
189189
)
190190
if not clustered:
191191
return
192-
self._publish_cluster_pose_result_array(
193-
clustered, total_collected, transformed_poses[-1].header
194-
)
192+
self._publish_cluster_pose_result_array(clustered, total_collected, last_header)
195193

196194
def _publish_cluster_pose_result_array(
197195
self,

test/test_cluster_poses_action.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -57,7 +57,7 @@ def test_action_succeeds(rig):
5757
goal = ClusterPosesAction.Goal()
5858
goal.params.odom_topic = ODOM_TOPIC
5959
goal.params.pose_stamped_topics = [POSE_TOPIC]
60-
goal.params.clustered_child_frame_id = "test/clustered_action"
60+
goal.params.clustered_child_frame_ids = ["test/clustered_action"]
6161
goal.params.sync_tolerance = 0.05
6262
goal.params.min_poses = 5
6363
goal.params.min_cluster_size = 3

test/test_cluster_poses_service.py

Lines changed: 43 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -92,7 +92,9 @@ def _base_enable_request(pose_topics, child_frame_id):
9292
req.enabled = True
9393
req.params.odom_topic = ODOM_TOPIC
9494
req.params.pose_stamped_topics = pose_topics
95-
req.params.clustered_child_frame_id = child_frame_id
95+
req.params.clustered_child_frame_ids = (
96+
[child_frame_id] if isinstance(child_frame_id, str) else list(child_frame_id)
97+
)
9698
req.params.sync_queue_size = 100
9799
req.params.sync_tolerance = 0.05
98100
req.params.min_poses = 5
@@ -219,3 +221,43 @@ def test_multiple_pose_topics_feed_same_buffer(multi_rig):
219221
for cluster in final.results:
220222
assert cluster.num_cluster_poses >= 3
221223
assert cluster.num_cluster_poses < total_input
224+
225+
226+
def test_independent_streams_one_frame_per_topic(multi_rig):
227+
"""One frame id per pose topic clusters each topic independently. With
228+
top_k=1 we still get one cluster per topic, but each result's
229+
num_input_poses reflects only its own topic, not the merged total."""
230+
multi_rig["tf_pub"].publish(identity_tf_static())
231+
spin_for(multi_rig["executor"], 0.2)
232+
233+
req = _base_enable_request(
234+
[POSE_TOPIC_A, POSE_TOPIC_B],
235+
["test/clustered_a", "test/clustered_b"],
236+
)
237+
req.params.top_k = 1
238+
239+
enable_response = multi_rig["call_service"](req)
240+
assert enable_response.is_enabled is True
241+
242+
multi_rig["publish_timer"].reset()
243+
spin_for(multi_rig["executor"], 1.5)
244+
multi_rig["publish_timer"].cancel()
245+
246+
disable_req = ClusterPosesSrv.Request()
247+
disable_req.enabled = False
248+
response = multi_rig["call_service"](disable_req)
249+
250+
assert response.is_cluster_success is True
251+
final = response.cluster_results
252+
assert len(final.results) == 2, (
253+
f"expected one cluster per independent stream, got {len(final.results)}"
254+
)
255+
256+
cluster_xs = sorted(r.clustered_pose.position.x for r in final.results)
257+
assert abs(cluster_xs[0] - EXPECTED_CLUSTER_X) < 0.05
258+
assert abs(cluster_xs[1] - SECONDARY_CLUSTER_X) < 0.05
259+
260+
# Each stream sees only its own topic's poses, so a single result's
261+
# num_input_poses is well under the combined two-topic total.
262+
for cluster in final.results:
263+
assert cluster.num_cluster_poses >= 3

0 commit comments

Comments
 (0)