1616Reactive LiDAR contour follower: trace an obstacle's boundary at a fixed standoff.
1717
1818The proactive, any-shape generalization of the bumper-based wall_clean. Off the
19- LiDAR it finds the NEAREST boundary point in a forward-biased sector on the follow
20- side (default right) and servos two errors -- standoff distance and the point's
21- bearing (want it abeam, -90 deg). That one law handles straight walls and CONCAVE
22- inside corners. CONVEX outside corners get an explicit recovery: when the near
19+ LiDAR it isolates the followed surface in a forward-biased sector on the follow
20+ side (default right), FITS A LINE to it, and servos two errors -- the fitted
21+ perpendicular distance and the bearing to it (want it abeam, -90 deg). That one
22+ law handles straight walls and CONCAVE inside corners. CONVEX outside corners
23+ get an explicit recovery: when the near
2324boundary vanishes (range jumps, or nothing left in the sector) the follower stops
2425trusting the far reading and ARCS toward the follow side at ~standoff radius until
2526it re-acquires -- "lose the wall, curve toward it". Left-follow is the mirror
6869 'sector_min_deg' : - 170.0 , # follow-side + forward window (right-follow)
6970 'sector_max_deg' : 20.0 ,
7071 'max_follow_range_m' : 1.0 , # ignore boundaries farther than this
72+ 'fit_gap_m' : 0.10 , # max step between adjacent points on one surface
73+ 'min_fit_points' : 6 , # below this, fall back to the nearest beam
7174 'bearing_ref_deg' : - 90.0 , # want the nearest point abeam (right)
7275 'k_approach' : 2.0 , # rad of approach angle per m of standoff error
7376 'alpha_max_deg' : 40.0 , # cap on the approach angle (far-wall approach)
@@ -111,6 +114,7 @@ def __init__(self) -> None:
111114 self ._dbg_d = None # last nearest pick, for debug markers
112115 self ._dbg_b = None
113116 self ._t_log = None # last diagnostic log time
117+ self ._dbg_fit = None # fitted segment endpoints, for markers
114118
115119 latched = QoSProfile (
116120 depth = 1 , history = QoSHistoryPolicy .KEEP_LAST ,
@@ -166,22 +170,79 @@ def _on_enable(self, msg: Bool) -> None:
166170 self .arc_swept = 0.0
167171 self ._set_state ('ALIGN' )
168172
169- def _nearest (self , msg , smin , smax , max_r ):
170- """Nearest valid boundary (d, bearing) in [smin, smax], follow-side frame."""
171- best_d = None
172- best_b = None
173+ def _boundary (self , msg , smin , smax , max_r ):
174+ """
175+ Fit the followed surface; return (perpendicular distance, bearing, n).
176+
177+ Seeds on the nearest beam in the sector, grows the contiguous surface
178+ around it, then total-least-squares fits a line to those points and
179+ reports the perpendicular distance to that line and the bearing to it.
180+
181+ Fitting rather than just taking the nearest beam matters. Near the
182+ perpendicular the range is almost flat -- at 0.2 m, swinging 20 deg
183+ changes it by 1.3 cm, while the beam-to-beam scatter is around 2 cm -- so
184+ the ARG-min (which beam is closest) is essentially random over a wide arc,
185+ and min() over noisy beams is a biased distance. The fit uses every point
186+ on the surface, so the noise averages down and the wall angle falls out
187+ directly instead of being inferred from a single beam.
188+ """
189+ count = len (msg .ranges )
190+ pts = [None ] * count
191+ seed = None
192+ seed_r = None
173193 for i , r in enumerate (msg .ranges ):
174194 if not math .isfinite (r ) or r < msg .range_min or r > max_r :
175195 continue
176196 b = self .side * math .remainder (
177197 msg .angle_min + i * msg .angle_increment , TWO_PI )
178- if b < smin or b > smax or ( best_d is not None and r >= best_d ) :
198+ if b < smin or b > smax :
179199 continue
180- best_d = r
181- best_b = b
182- self ._dbg_d = best_d
183- self ._dbg_b = best_b
184- return best_d , best_b
200+ pts [i ] = (r * math .cos (b ), r * math .sin (b ), r , b )
201+ if seed_r is None or r < seed_r :
202+ seed , seed_r = i , r
203+ if seed is None :
204+ self ._dbg_d = self ._dbg_b = self ._dbg_fit = None
205+ return None , None , 0
206+
207+ # grow the contiguous surface either way from the seed
208+ gap = self ._p ('fit_gap_m' )
209+ keep = [seed ]
210+ for step in (1 , - 1 ):
211+ j = seed
212+ while True :
213+ k = (j + step ) % count
214+ if k == seed or pts [k ] is None :
215+ break
216+ if math .hypot (pts [k ][0 ] - pts [j ][0 ],
217+ pts [k ][1 ] - pts [j ][1 ]) > gap :
218+ break
219+ keep .append (k )
220+ j = k
221+ sel = [pts [k ] for k in keep ]
222+
223+ if len (sel ) < int (self ._p ('min_fit_points' )):
224+ self ._dbg_d , self ._dbg_b = seed_r , pts [seed ][3 ]
225+ self ._dbg_fit = None
226+ return seed_r , pts [seed ][3 ], len (sel )
227+
228+ m = float (len (sel ))
229+ cx = sum (p [0 ] for p in sel ) / m
230+ cy = sum (p [1 ] for p in sel ) / m
231+ sxx = sum ((p [0 ] - cx ) ** 2 for p in sel ) / m
232+ syy = sum ((p [1 ] - cy ) ** 2 for p in sel ) / m
233+ sxy = sum ((p [0 ] - cx ) * (p [1 ] - cy ) for p in sel ) / m
234+ theta = 0.5 * math .atan2 (2.0 * sxy , sxx - syy ) # line direction
235+ nx , ny = - math .sin (theta ), math .cos (theta ) # unit normal
236+ dist = cx * nx + cy * ny # signed, origin to line
237+ if dist < 0.0 :
238+ nx , ny , dist = - nx , - ny , - dist
239+
240+ ct , st = math .cos (theta ), math .sin (theta )
241+ ts = [(p [0 ] - cx ) * ct + (p [1 ] - cy ) * st for p in sel ]
242+ self ._dbg_fit = ((cx + min (ts ) * ct , cy + min (ts ) * st ),
243+ (cx + max (ts ) * ct , cy + max (ts ) * st ))
244+ self ._dbg_d , self ._dbg_b = dist , math .atan2 (ny , nx )
245+ return dist , math .atan2 (ny , nx ), len (sel )
185246
186247 def _on_scan (self , msg : LaserScan ) -> None :
187248 t = msg .header .stamp .sec + msg .header .stamp .nanosec * 1e-9
@@ -208,7 +269,7 @@ def _step(self, msg, b_ref, smin, smax, max_r, dt) -> None:
208269 return
209270
210271 if self .state == 'FOLLOW' :
211- d , b = self ._nearest (msg , smin , smax , max_r )
272+ d , b , _ = self ._boundary (msg , smin , smax , max_r )
212273 jumped = (self .prev_d is not None and d is not None
213274 and (d - self .prev_d ) > self ._p ('convex_jump_m' ))
214275 if d is None or jumped :
@@ -247,7 +308,7 @@ def _arc(self, msg, smin, smax, max_r, b_ref, dt) -> None:
247308 v = max (self ._p ('v_min' ), 0.5 * self ._p ('v_nominal' ))
248309 omega = - v / max (self ._p ('convex_arc_radius_m' ), 1e-3 ) # toward follow side
249310 self .arc_swept += abs (omega ) * dt
250- d , b = self ._nearest (msg , smin , smax , max_r )
311+ d , b , _ = self ._boundary (msg , smin , smax , max_r )
251312 if d is not None and d <= self ._p ('standoff_m' ) + self ._p ('reacquire_margin_m' ):
252313 self .prev_d = d
253314 self ._set_state ('FOLLOW' )
@@ -262,7 +323,7 @@ def _arc(self, msg, smin, smax, max_r, b_ref, dt) -> None:
262323
263324 def _align (self , msg , b_ref , max_r ) -> None :
264325 # rotate in place to bring the nearest obstacle abeam on the follow side
265- d , b = self ._nearest (msg , - math .pi , math .pi , max_r )
326+ d , b , _ = self ._boundary (msg , - math .pi , math .pi , max_r )
266327 if d is None :
267328 self ._set_cmd (0.0 , self .side * self ._p ('align_omega' )) # search
268329 return
@@ -328,6 +389,17 @@ def _pub_markers(self, b_ref, smin, smax) -> None:
328389 tgt .color .a = 0.9
329390 tgt .pose .position = self ._pt (self ._p ('standoff_m' ), s * b_ref )
330391 arr .markers .append (tgt )
392+ if self ._dbg_fit is not None :
393+ fit = self ._mk (5 , Marker .LINE_LIST , stamp )
394+ fit .scale .x = 0.012
395+ fit .color .r = 1.0
396+ fit .color .b = 1.0
397+ fit .color .a = 0.9
398+ for px , py in self ._dbg_fit :
399+ p = Point ()
400+ p .x , p .y , p .z = px , s * py , 0.0
401+ fit .points .append (p )
402+ arr .markers .append (fit )
331403 sec = self ._mk (3 , Marker .LINE_LIST , stamp )
332404 sec .scale .x = 0.006
333405 sec .color .r = 1.0
0 commit comments