@@ -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+
7285def 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 ):
0 commit comments