python-motion-planning 2.0.dev2__py3-none-any.whl → 2.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (33) hide show
  1. python_motion_planning/__init__.py +1 -1
  2. python_motion_planning/common/env/map/grid.py +568 -147
  3. python_motion_planning/common/utils/geometry.py +21 -31
  4. python_motion_planning/path_planner/graph_search/lazy_theta_star.py +15 -7
  5. python_motion_planning/path_planner/graph_search/theta_star.py +4 -3
  6. python_motion_planning/path_planner/sample_search/rrt.py +6 -6
  7. python_motion_planning/path_planner/sample_search/rrt_connect.py +2 -2
  8. python_motion_planning/path_planner/sample_search/rrt_star.py +31 -11
  9. python_motion_planning/traj_optimizer/__init__.py +2 -0
  10. python_motion_planning/traj_optimizer/base_curve_generator.py +53 -0
  11. python_motion_planning/traj_optimizer/curve_generator/__init__.py +2 -0
  12. python_motion_planning/traj_optimizer/curve_generator/point_based/__init__.py +2 -0
  13. python_motion_planning/traj_optimizer/curve_generator/point_based/bspline.py +256 -0
  14. python_motion_planning/traj_optimizer/curve_generator/point_based/cubic_spline.py +115 -0
  15. python_motion_planning/traj_optimizer/curve_generator/pose_based/__init__.py +4 -0
  16. python_motion_planning/traj_optimizer/curve_generator/pose_based/bezier.py +121 -0
  17. python_motion_planning/traj_optimizer/curve_generator/pose_based/dubins.py +355 -0
  18. python_motion_planning/traj_optimizer/curve_generator/pose_based/polynomial.py +197 -0
  19. python_motion_planning/traj_optimizer/curve_generator/pose_based/reeds_shepp.py +606 -0
  20. {python_motion_planning-2.0.dev2.dist-info → python_motion_planning-2.1.dist-info}/METADATA +23 -16
  21. {python_motion_planning-2.0.dev2.dist-info → python_motion_planning-2.1.dist-info}/RECORD +24 -22
  22. {python_motion_planning-2.0.dev2.dist-info → python_motion_planning-2.1.dist-info}/WHEEL +1 -1
  23. python_motion_planning/curve_generator/__init__.py +0 -9
  24. python_motion_planning/curve_generator/bezier_curve.py +0 -131
  25. python_motion_planning/curve_generator/bspline_curve.py +0 -271
  26. python_motion_planning/curve_generator/cubic_spline.py +0 -128
  27. python_motion_planning/curve_generator/curve.py +0 -64
  28. python_motion_planning/curve_generator/dubins_curve.py +0 -348
  29. python_motion_planning/curve_generator/fem_pos_smooth.py +0 -114
  30. python_motion_planning/curve_generator/polynomial_curve.py +0 -226
  31. python_motion_planning/curve_generator/reeds_shepp.py +0 -736
  32. {python_motion_planning-2.0.dev2.dist-info → python_motion_planning-2.1.dist-info}/licenses/LICENSE +0 -0
  33. {python_motion_planning-2.0.dev2.dist-info → python_motion_planning-2.1.dist-info}/top_level.txt +0 -0
@@ -1,20 +1,358 @@
1
1
  """
2
2
  @file: grid.py
3
3
  @author: Wu Maojia
4
- @update: 2025.12.20
4
+ @update: 2026.9.11
5
5
  """
6
6
  from itertools import product
7
- from typing import Iterable, Union, Tuple, Callable, List, Dict
7
+ from typing import Iterable, Union, Tuple, List, Dict
8
8
  import time
9
9
 
10
10
  import numpy as np
11
11
  from scipy import ndimage
12
12
 
13
+ try:
14
+ from numba import njit as _numba_njit
15
+ except Exception: # pragma: no cover - keeps import compatibility without numba.
16
+ _numba_njit = None
17
+
13
18
  from python_motion_planning.common.env.map.base_map import BaseMap
14
19
  from python_motion_planning.common.env import Node, TYPES
15
20
  from python_motion_planning.common.utils.geometry import Geometry
16
21
 
17
22
 
23
+ def _njit(*args, **kwargs):
24
+ if _numba_njit is None:
25
+ if args and callable(args[0]):
26
+ return args[0]
27
+
28
+ def decorator(func):
29
+ return func
30
+
31
+ return decorator
32
+
33
+ return _numba_njit(*args, **kwargs)
34
+
35
+
36
+ @_njit(cache=True)
37
+ def _grid_flat_index(point: np.ndarray, shape: np.ndarray) -> int:
38
+ idx = 0
39
+ for d in range(shape.size):
40
+ idx = idx * shape[d] + point[d]
41
+ return idx
42
+
43
+
44
+ @_njit(cache=True)
45
+ def _grid_within_bounds(point: np.ndarray, shape: np.ndarray) -> bool:
46
+ for d in range(shape.size):
47
+ if point[d] < 0 or point[d] >= shape[d]:
48
+ return False
49
+ return True
50
+
51
+
52
+ @_njit(cache=True)
53
+ def _grid_is_expandable(
54
+ point: np.ndarray,
55
+ src_point: np.ndarray,
56
+ has_src_point: bool,
57
+ shape: np.ndarray,
58
+ type_map: np.ndarray,
59
+ esdf: np.ndarray,
60
+ obstacle_type: int,
61
+ inflation_type: int,
62
+ strict_collision: bool,
63
+ ) -> bool:
64
+ if not _grid_within_bounds(point, shape):
65
+ return False
66
+
67
+ point_idx = _grid_flat_index(point, shape)
68
+ src_idx = 0
69
+ src_is_inflation = False
70
+ if has_src_point:
71
+ src_idx = _grid_flat_index(src_point, shape)
72
+ src_is_inflation = type_map[src_idx] == inflation_type
73
+
74
+ point_type = type_map[point_idx]
75
+ if point_type == obstacle_type or (
76
+ point_type == inflation_type
77
+ and not (src_is_inflation and esdf[point_idx] >= esdf[src_idx])
78
+ ):
79
+ return False
80
+
81
+ if not has_src_point or not strict_collision:
82
+ return True
83
+
84
+ dim = point.size
85
+ if dim == 2:
86
+ dx = point[0] - src_point[0]
87
+ dy = point[1] - src_point[1]
88
+ if abs(dx) > 1 or abs(dy) > 1 or dx == 0 or dy == 0:
89
+ return True
90
+
91
+ side_idx = point[0] * shape[1] + src_point[1]
92
+ side_type = type_map[side_idx]
93
+ if side_type == obstacle_type or (
94
+ side_type == inflation_type
95
+ and not (src_is_inflation and esdf[side_idx] >= esdf[src_idx])
96
+ ):
97
+ return False
98
+
99
+ side_idx = src_point[0] * shape[1] + point[1]
100
+ side_type = type_map[side_idx]
101
+ return side_type != obstacle_type and (
102
+ side_type != inflation_type
103
+ or (src_is_inflation and esdf[side_idx] >= esdf[src_idx])
104
+ )
105
+
106
+ changed_mask = 0
107
+ for d in range(dim):
108
+ diff = point[d] - src_point[d]
109
+ if abs(diff) > 1:
110
+ return True
111
+ if diff != 0:
112
+ changed_mask |= 1 << d
113
+
114
+ if not changed_mask & (changed_mask - 1):
115
+ return True
116
+
117
+ subset = (changed_mask - 1) & changed_mask
118
+ while subset:
119
+ side_idx = 0
120
+ for d in range(dim):
121
+ side = point[d] if subset & (1 << d) else src_point[d]
122
+ side_idx = side_idx * shape[d] + side
123
+
124
+ side_type = type_map[side_idx]
125
+ if side_type == obstacle_type or (
126
+ side_type == inflation_type
127
+ and not (src_is_inflation and esdf[side_idx] >= esdf[src_idx])
128
+ ):
129
+ return False
130
+ subset = (subset - 1) & changed_mask
131
+
132
+ return True
133
+
134
+
135
+ @_njit(cache=True)
136
+ def _grid_world_to_map_int(
137
+ point: np.ndarray,
138
+ bounds: np.ndarray,
139
+ resolution: float,
140
+ shape: np.ndarray,
141
+ ) -> np.ndarray:
142
+ point_map = np.empty(shape.size, dtype=np.int64)
143
+ inv_resolution = 1.0 / resolution
144
+ for d in range(shape.size):
145
+ value = int(round((point[d] - bounds[d, 0]) * inv_resolution - 0.5))
146
+ if value < 0:
147
+ value = 0
148
+ elif value >= shape[d]:
149
+ value = shape[d] - 1
150
+ point_map[d] = value
151
+ return point_map
152
+
153
+
154
+ @_njit(cache=True)
155
+ def _grid_line_of_sight(p1: np.ndarray, p2: np.ndarray) -> np.ndarray:
156
+ dim = p1.size
157
+ delta = np.empty(dim, dtype=np.int64)
158
+ abs_delta = np.empty(dim, dtype=np.int64)
159
+ delta2 = np.empty(dim, dtype=np.int64)
160
+
161
+ primary_axis = 0
162
+ max_delta = 0
163
+ for d in range(dim):
164
+ delta[d] = p2[d] - p1[d]
165
+ abs_delta[d] = abs(delta[d])
166
+ delta2[d] = 2 * abs_delta[d]
167
+ if abs_delta[d] > max_delta:
168
+ max_delta = abs_delta[d]
169
+ primary_axis = d
170
+
171
+ primary_step = 1 if delta[primary_axis] > 0 else -1
172
+ steps = abs_delta[primary_axis]
173
+ result = np.empty((steps + 1, dim), dtype=np.int64)
174
+ current = p1.copy()
175
+
176
+ for d in range(dim):
177
+ result[0, d] = current[d]
178
+
179
+ error = np.zeros(dim, dtype=np.int64)
180
+ for i in range(1, steps + 1):
181
+ current[primary_axis] += primary_step
182
+
183
+ for d in range(dim):
184
+ if d == primary_axis:
185
+ continue
186
+
187
+ error[d] += delta2[d]
188
+ # Reverse the tie-break when traversing the primary axis backwards.
189
+ if error[d] > abs_delta[primary_axis] or (
190
+ error[d] == abs_delta[primary_axis] and primary_step < 0
191
+ ):
192
+ current[d] += 1 if delta[d] > 0 else -1
193
+ error[d] -= delta2[primary_axis]
194
+
195
+ for d in range(dim):
196
+ result[i, d] = current[d]
197
+
198
+ return result
199
+
200
+
201
+ @_njit(cache=True)
202
+ def _grid_in_collision(
203
+ p1: np.ndarray,
204
+ p2: np.ndarray,
205
+ shape: np.ndarray,
206
+ type_map: np.ndarray,
207
+ esdf: np.ndarray,
208
+ obstacle_type: int,
209
+ inflation_type: int,
210
+ strict_collision: bool,
211
+ ) -> bool:
212
+ if not _grid_is_expandable(p1, p1, False, shape, type_map, esdf, obstacle_type, inflation_type, False):
213
+ return True
214
+ if not _grid_is_expandable(p2, p1, True, shape, type_map, esdf, obstacle_type, inflation_type, False):
215
+ return True
216
+
217
+ dim = p1.size
218
+ same_point = True
219
+ for d in range(dim):
220
+ if p1[d] != p2[d]:
221
+ same_point = False
222
+ break
223
+ if same_point:
224
+ return False
225
+
226
+ delta = np.empty(dim, dtype=np.int64)
227
+ abs_delta = np.empty(dim, dtype=np.int64)
228
+ delta2 = np.empty(dim, dtype=np.int64)
229
+
230
+ primary_axis = 0
231
+ max_delta = 0
232
+ for d in range(dim):
233
+ delta[d] = p2[d] - p1[d]
234
+ abs_delta[d] = abs(delta[d])
235
+ delta2[d] = 2 * abs_delta[d]
236
+ if abs_delta[d] > max_delta:
237
+ max_delta = abs_delta[d]
238
+ primary_axis = d
239
+
240
+ primary_step = 1 if delta[primary_axis] > 0 else -1
241
+ steps = abs_delta[primary_axis]
242
+ current = p1.copy()
243
+ last_point = np.empty(dim, dtype=np.int64)
244
+ error = np.zeros(dim, dtype=np.int64)
245
+
246
+ for _ in range(steps):
247
+ for d in range(dim):
248
+ last_point[d] = current[d]
249
+
250
+ current[primary_axis] += primary_step
251
+
252
+ for d in range(dim):
253
+ if d == primary_axis:
254
+ continue
255
+
256
+ error[d] += delta2[d]
257
+ # Keep the same cells as _grid_line_of_sight in both directions.
258
+ if error[d] > abs_delta[primary_axis] or (
259
+ error[d] == abs_delta[primary_axis] and primary_step < 0
260
+ ):
261
+ current[d] += 1 if delta[d] > 0 else -1
262
+ error[d] -= delta2[primary_axis]
263
+
264
+ if not _grid_is_expandable(
265
+ current,
266
+ last_point,
267
+ True,
268
+ shape,
269
+ type_map,
270
+ esdf,
271
+ obstacle_type,
272
+ inflation_type,
273
+ strict_collision,
274
+ ):
275
+ return True
276
+
277
+ return False
278
+
279
+
280
+ @_njit(cache=True)
281
+ def _grid_neighbor_positions_and_mask(
282
+ current: np.ndarray,
283
+ offsets: np.ndarray,
284
+ shape: np.ndarray,
285
+ type_map: np.ndarray,
286
+ esdf: np.ndarray,
287
+ obstacle_type: int,
288
+ inflation_type: int,
289
+ strict_collision: bool,
290
+ ) -> Tuple[np.ndarray, np.ndarray]:
291
+ node_num = offsets.shape[0]
292
+ dim = offsets.shape[1]
293
+ positions = np.empty((node_num, dim), dtype=np.int64)
294
+ mask = np.zeros(node_num, dtype=np.bool_)
295
+ neighbor = np.empty(dim, dtype=np.int64)
296
+
297
+ for i in range(node_num):
298
+ for d in range(dim):
299
+ neighbor[d] = current[d] + offsets[i, d]
300
+ positions[i, d] = neighbor[d]
301
+
302
+ mask[i] = _grid_is_expandable(
303
+ neighbor,
304
+ current,
305
+ True,
306
+ shape,
307
+ type_map,
308
+ esdf,
309
+ obstacle_type,
310
+ inflation_type,
311
+ strict_collision,
312
+ )
313
+
314
+ return positions, mask
315
+
316
+
317
+ @_njit(cache=True)
318
+ def _grid_path_map_to_world(points: np.ndarray, bounds: np.ndarray, resolution: float) -> np.ndarray:
319
+ path_world = np.empty((points.shape[0], points.shape[1]), dtype=np.float64)
320
+ for i in range(points.shape[0]):
321
+ for d in range(points.shape[1]):
322
+ path_world[i, d] = (points[i, d] + 0.5) * resolution + bounds[d, 0]
323
+ return path_world
324
+
325
+
326
+ @_njit(cache=True)
327
+ def _grid_path_world_to_map_float(points: np.ndarray, bounds: np.ndarray, resolution: float) -> np.ndarray:
328
+ path_map = np.empty((points.shape[0], points.shape[1]), dtype=np.float64)
329
+ inv_resolution = 1.0 / resolution
330
+ for i in range(points.shape[0]):
331
+ for d in range(points.shape[1]):
332
+ path_map[i, d] = (points[i, d] - bounds[d, 0]) * inv_resolution - 0.5
333
+ return path_map
334
+
335
+
336
+ @_njit(cache=True)
337
+ def _grid_path_world_to_map_int(
338
+ points: np.ndarray,
339
+ bounds: np.ndarray,
340
+ resolution: float,
341
+ shape: np.ndarray,
342
+ ) -> np.ndarray:
343
+ path_map = np.empty((points.shape[0], shape.size), dtype=np.int64)
344
+ inv_resolution = 1.0 / resolution
345
+ for i in range(points.shape[0]):
346
+ for d in range(shape.size):
347
+ value = int(round((points[i, d] - bounds[d, 0]) * inv_resolution - 0.5))
348
+ if value < 0:
349
+ value = 0
350
+ elif value >= shape[d]:
351
+ value = shape[d] - 1
352
+ path_map[i, d] = value
353
+ return path_map
354
+
355
+
18
356
  class GridTypeMap:
19
357
  """
20
358
  Class for Grid Type Map. It is like a np.ndarray, except that its shape and dtype are fixed.
@@ -47,6 +385,7 @@ class GridTypeMap:
47
385
  self._data = np.asarray(type_map)
48
386
  self._shape = self._data.shape
49
387
  self._dtype = self._data.dtype
388
+ self._lazy_flags_ = 3
50
389
 
51
390
  self._dtype_options = [np.int8, np.int16, np.int32, np.int64]
52
391
  if self._dtype not in self._dtype_options:
@@ -63,6 +402,7 @@ class GridTypeMap:
63
402
 
64
403
  def __setitem__(self, idx, value):
65
404
  self._data[idx] = value
405
+ self._lazy_flags_ = 3
66
406
 
67
407
  @property
68
408
  def data(self) -> np.ndarray:
@@ -89,6 +429,7 @@ class Grid(BaseMap):
89
429
  resolution: resolution of the grid map
90
430
  type_map: initial type map of the grid map (its shape must be the same as the converted grid map shape, and its dtype must be int)
91
431
  inflation_radius: radius of the inflation
432
+ strict_collision: whether diagonal steps beside obstacles or inflation are collisions (default: True)
92
433
 
93
434
  Examples:
94
435
  >>> grid_map = Grid(bounds=[[0, 51], [0, 31]], resolution=0.5)
@@ -137,7 +478,7 @@ class Grid(BaseMap):
137
478
 
138
479
  >>> grid_map[1, 0] = TYPES.OBSTACLE # place an obstacle
139
480
  >>> grid_map.get_neighbors(Node((0, 0))) # limited within the bounds
140
- [Node((0, 1), (0, 0), 0, 0), Node((1, 1), (0, 0), 0, 0)]
481
+ [Node((0, 1), (0, 0), 0, 0)]
141
482
 
142
483
  >>> grid_map.get_neighbors(Node((grid_map.shape[0] - 1, grid_map.shape[1] - 1)), diagonal=False) # limited within the boundss
143
484
  [Node((100, 61), (101, 61), 0, 0), Node((101, 60), (101, 61), 0, 0)]
@@ -152,15 +493,31 @@ class Grid(BaseMap):
152
493
  False
153
494
 
154
495
  >>> grid_map[1, 3] = TYPES.OBSTACLE
155
- >>> grid_map.update_esdf()
156
496
  >>> grid_map.in_collision((1, 2), (3, 6))
157
497
  True
498
+
499
+ >>> grid_map = Grid(bounds=[[0, 3], [0, 3]])
500
+ >>> grid_map[1, 0] = TYPES.OBSTACLE
501
+ >>> grid_map.in_collision((0, 0), (1, 1))
502
+ True
503
+ >>> grid_map.strict_collision = False
504
+ >>> grid_map.in_collision((0, 0), (1, 1))
505
+ False
506
+
507
+ >>> grid_map = Grid(bounds=[[0, 4], [0, 4]])
508
+ >>> grid_map[2, :] = TYPES.OBSTACLE
509
+ >>> grid_map.is_connected((0, 0), (3, 3))
510
+ False
511
+ >>> grid_map[2, 1] = TYPES.FREE
512
+ >>> grid_map.is_connected((0, 0), (3, 3))
513
+ True
158
514
  """
159
515
  def __init__(self,
160
516
  bounds: Iterable = [[0, 30], [0, 40]],
161
517
  resolution: float = 1.0,
162
518
  type_map: Union[GridTypeMap, np.ndarray] = None,
163
519
  inflation_radius: float = 0.0,
520
+ strict_collision: bool = True,
164
521
  ) -> None:
165
522
  super().__init__(bounds)
166
523
 
@@ -168,22 +525,24 @@ class Grid(BaseMap):
168
525
  shape = tuple([int((self.bounds[i, 1] - self.bounds[i, 0]) / self.resolution) for i in range(self.dim)])
169
526
 
170
527
  if type_map is None:
171
- self.type_map = GridTypeMap(np.zeros(shape, dtype=np.int8))
528
+ self._type_map = GridTypeMap(np.zeros(shape, dtype=np.int8))
172
529
  else:
173
530
  if type_map.shape != shape:
174
531
  raise ValueError("Shape must be {} instead of {} with given bounds={} and resolution={}".format(shape, type_map.shape, self.bounds, self.resolution))
175
532
 
176
533
  if isinstance(type_map, GridTypeMap):
177
- self.type_map = type_map
534
+ self._type_map = type_map
178
535
  elif isinstance(type_map, np.ndarray):
179
- self.type_map = GridTypeMap(type_map)
536
+ self._type_map = GridTypeMap(type_map)
180
537
  else:
181
538
  raise ValueError("Type map must be GridTypeMap or numpy.ndarray instead of {}".format(type(type_map)))
182
539
 
540
+ self._shape_array = np.asarray(self.shape, dtype=np.int64)
183
541
  self._precompute_offsets()
184
542
 
185
543
  self._esdf = np.zeros(self.shape, dtype=np.float32)
186
- # self.update_esdf() # updated in self.inflate_obstacles()
544
+ self._connectivity_map = np.zeros(self.shape, dtype=np.int32)
545
+ self.strict_collision = strict_collision
187
546
 
188
547
  self.inflation_radius = inflation_radius
189
548
  if self.inflation_radius >= 1:
@@ -198,28 +557,79 @@ class Grid(BaseMap):
198
557
  @property
199
558
  def resolution(self) -> float:
200
559
  return self._resolution
560
+
561
+ @property
562
+ def type_map(self) -> GridTypeMap:
563
+ return self._type_map
201
564
 
202
565
  @property
203
566
  def shape(self) -> tuple:
204
- return self.type_map.shape
567
+ return self._type_map.shape
205
568
 
206
569
  @property
207
570
  def dtype(self) -> np.dtype:
208
- return self.type_map.dtype
571
+ return self._type_map.dtype
209
572
 
210
573
  @property
211
574
  def esdf(self) -> np.ndarray:
575
+ if self._esdf_lazy_flag_:
576
+ self.update_esdf()
577
+ self._esdf_lazy_flag_ = False
212
578
  return self._esdf
579
+
580
+ @property
581
+ def connectivity_map(self) -> np.ndarray:
582
+ if self._connectivity_lazy_flag_:
583
+ self.update_connectivity()
584
+ self._connectivity_lazy_flag_ = False
585
+ return self._connectivity_map
213
586
 
214
587
  @property
215
588
  def data(self) -> np.ndarray:
216
- return self.type_map.data
589
+ return self._type_map.data
217
590
 
218
591
  def __getitem__(self, idx):
219
- return self.type_map[idx]
592
+ return self._type_map[idx]
220
593
 
221
594
  def __setitem__(self, idx, value):
222
- self.type_map[idx] = value
595
+ self._type_map[idx] = value
596
+
597
+ @property
598
+ def _esdf_lazy_flag_(self) -> bool:
599
+ return bool(self._type_map._lazy_flags_ & 1)
600
+
601
+ @_esdf_lazy_flag_.setter
602
+ def _esdf_lazy_flag_(self, value: bool) -> None:
603
+ if value:
604
+ self._type_map._lazy_flags_ |= 1
605
+ else:
606
+ self._type_map._lazy_flags_ &= ~1
607
+
608
+ @property
609
+ def _connectivity_lazy_flag_(self) -> bool:
610
+ return bool(self._type_map._lazy_flags_ & 2)
611
+
612
+ @_connectivity_lazy_flag_.setter
613
+ def _connectivity_lazy_flag_(self, value: bool) -> None:
614
+ if value:
615
+ self._type_map._lazy_flags_ |= 2
616
+ else:
617
+ self._type_map._lazy_flags_ &= ~2
618
+
619
+ @property
620
+ def strict_collision(self) -> bool:
621
+ return self._strict_collision
622
+
623
+ @strict_collision.setter
624
+ def strict_collision(self, value: bool) -> None:
625
+ self._strict_collision = value
626
+ self._connectivity_lazy_flag_ = True
627
+
628
+ def _type_map_flat(self) -> np.ndarray:
629
+ return np.ravel(self._type_map.data)
630
+
631
+ def _esdf_flat(self) -> np.ndarray:
632
+ return np.ravel(self.esdf)
223
633
 
224
634
  def map_to_world(self, point: tuple) -> Tuple[float, ...]:
225
635
  """
@@ -234,7 +644,9 @@ class Grid(BaseMap):
234
644
  if len(point) != self.dim:
235
645
  raise ValueError("Point dimension does not match map dimension.")
236
646
 
237
- return tuple((x + 0.5) * self.resolution + float(self.bounds[i, 0]) for i, x in enumerate(point))
647
+ bounds = self.bounds
648
+ resolution = self.resolution
649
+ return tuple(float((point[d] + 0.5) * resolution + bounds[d, 0]) for d in range(self.dim))
238
650
 
239
651
  def world_to_map(self, point: Tuple[float, ...], discrete: bool = True) -> tuple:
240
652
  """
@@ -250,10 +662,13 @@ class Grid(BaseMap):
250
662
  if len(point) != self.dim:
251
663
  raise ValueError("Point dimension does not match map dimension.")
252
664
 
253
- point_map = tuple((x - float(self.bounds[i, 0])) * (1.0 / self.resolution) - 0.5 for i, x in enumerate(point))
254
665
  if discrete:
255
- point_map = self.point_float_to_int(point_map)
256
- return point_map
666
+ point_map = _grid_world_to_map_int(np.asarray(point, dtype=np.float64), self.bounds, self.resolution, self._shape_array)
667
+ return tuple(int(x) for x in point_map)
668
+ else:
669
+ inv_resolution = 1.0 / self.resolution
670
+ bounds = self.bounds
671
+ return tuple(float((point[d] - bounds[d, 0]) * inv_resolution - 0.5) for d in range(self.dim))
257
672
 
258
673
  def get_distance(self, p1: Tuple[int, int], p2: Tuple[int, int]) -> float:
259
674
  """
@@ -278,18 +693,32 @@ class Grid(BaseMap):
278
693
  Returns:
279
694
  bool: True if the point is within the bounds of the map, False otherwise.
280
695
  """
281
- # if point.dim != self.dim:
282
- # raise ValueError("Point dimension does not match map dimension.")
283
-
284
- # return all(0 <= point[i] < self.shape[i] for i in range(self.dim))
285
- dim = self.dim
696
+ if len(point) != self.dim:
697
+ return False
286
698
  shape = self.shape
287
-
288
- for i in range(dim):
289
- if not (0 <= point[i] < shape[i]):
699
+ for d in range(self.dim):
700
+ if point[d] < 0 or point[d] >= shape[d]:
290
701
  return False
291
702
  return True
292
703
 
704
+ def is_connected(self, p1: Tuple[int, ...], p2: Tuple[int, ...]) -> bool:
705
+ """
706
+ Check whether two points belong to the same free-space component.
707
+
708
+ Args:
709
+ p1: First point.
710
+ p2: Second point.
711
+
712
+ Returns:
713
+ connected: True if the two points are connected, False otherwise.
714
+ """
715
+ if not self.within_bounds(p1) or not self.within_bounds(p2):
716
+ raise ValueError("Points are out of bounds or invalid.")
717
+
718
+ connectivity_map = self.connectivity_map
719
+ component = connectivity_map[p1]
720
+ return component != 0 and component == connectivity_map[p2]
721
+
293
722
  def is_expandable(self, point: Tuple[int, ...], src_point: Tuple[int, ...] = None) -> bool:
294
723
  """
295
724
  Check if a point is expandable.
@@ -301,13 +730,21 @@ class Grid(BaseMap):
301
730
  Returns:
302
731
  expandable: True if the point is expandable, False otherwise.
303
732
  """
304
- if not self.within_bounds(point):
305
- return False
306
- if src_point is not None:
307
- if self.type_map[src_point] == TYPES.INFLATION and self._esdf[point] >= self._esdf[src_point]:
308
- return True
309
-
310
- return not self.type_map[point] == TYPES.OBSTACLE and not self.type_map[point] == TYPES.INFLATION
733
+ point_array = np.asarray(point, dtype=np.int64)
734
+ has_src_point = src_point is not None
735
+ src_array = point_array if src_point is None else np.asarray(src_point, dtype=np.int64)
736
+
737
+ return _grid_is_expandable(
738
+ point_array,
739
+ src_array,
740
+ has_src_point,
741
+ self._shape_array,
742
+ self._type_map_flat(),
743
+ self._esdf_flat(),
744
+ TYPES.OBSTACLE,
745
+ TYPES.INFLATION,
746
+ self.strict_collision,
747
+ )
311
748
 
312
749
  def get_neighbors(self,
313
750
  node: Node,
@@ -326,18 +763,28 @@ class Grid(BaseMap):
326
763
  if node.dim != self.dim:
327
764
  raise ValueError("Node dimension does not match map dimension.")
328
765
 
329
- offsets = self._diagonal_offsets if diagonal else self._orthogonal_offsets
330
-
331
- # Generate all neighbor positions
332
- # neighbor_positions = current_pos + offsets
333
- neighbors = [node + offset for offset in offsets]
334
- filtered_neighbors = []
335
-
336
- for neighbor in neighbors:
337
- if self.is_expandable(neighbor.current, node.current):
338
- filtered_neighbors.append(neighbor)
339
-
340
- return filtered_neighbors
766
+ positions, mask = self._get_neighbor_arrays(node, diagonal)
767
+
768
+ return [
769
+ Node(tuple(positions[i].tolist()), node.current, node.g, node.h)
770
+ for i in range(positions.shape[0])
771
+ if mask[i]
772
+ ]
773
+
774
+ def _get_neighbor_arrays(self, node: Node, diagonal: bool = True) -> Tuple[np.ndarray, np.ndarray]:
775
+ """Get candidate neighbor positions and their expandable mask."""
776
+ offsets = self._diagonal_offsets_array if diagonal else self._orthogonal_offsets_array
777
+ positions, mask = _grid_neighbor_positions_and_mask(
778
+ np.asarray(node.current, dtype=np.int64),
779
+ offsets,
780
+ self._shape_array,
781
+ self._type_map_flat(),
782
+ self._esdf_flat(),
783
+ TYPES.OBSTACLE,
784
+ TYPES.INFLATION,
785
+ self.strict_collision,
786
+ )
787
+ return positions, mask
341
788
 
342
789
  def line_of_sight(self, p1: Tuple[int, ...], p2: Tuple[int, ...]) -> List[Tuple[int, ...]]:
343
790
  """
@@ -350,45 +797,13 @@ class Grid(BaseMap):
350
797
  Returns:
351
798
  points: List of point on the line of sight.
352
799
  """
353
- p1 = np.array(p1)
354
- p2 = np.array(p2)
800
+ p1_array = np.asarray(p1, dtype=np.int64)
801
+ p2_array = np.asarray(p2, dtype=np.int64)
802
+ if p1_array.shape != p2_array.shape:
803
+ p2_array - p1_array
355
804
 
356
- dim = len(p1)
357
- delta = p2 - p1
358
- abs_delta = np.abs(delta)
359
-
360
- # Determine the main direction axis (the dimension with the greatest change)
361
- primary_axis = np.argmax(abs_delta)
362
- primary_step = 1 if delta[primary_axis] > 0 else -1
363
-
364
- # Initialize the error variable
365
- error = np.zeros(dim, dtype=int)
366
- delta2 = 2 * abs_delta
367
-
368
- # Calculate the number of steps and initialize the current point
369
- steps = abs_delta[primary_axis]
370
- current = p1
371
-
372
- # Allocate the result array
373
- result = []
374
- result.append(tuple(int(x) for x in current))
375
-
376
- for i in range(1, steps + 1):
377
- current[primary_axis] += primary_step
378
-
379
- # Update the error for the primary dimension
380
- for d in range(dim):
381
- if d == primary_axis:
382
- continue
383
-
384
- error[d] += delta2[d]
385
- if error[d] > abs_delta[primary_axis]:
386
- current[d] += 1 if delta[d] > 0 else -1
387
- error[d] -= delta2[primary_axis]
388
-
389
- result.append(tuple(int(x) for x in current))
390
-
391
- return result
805
+ points = _grid_line_of_sight(p1_array, p2_array)
806
+ return [tuple(int(x) for x in points[i]) for i in range(points.shape[0])]
392
807
 
393
808
  def in_collision(self, p1: Tuple[int, ...], p2: Tuple[int, ...]) -> bool:
394
809
  """
@@ -401,51 +816,21 @@ class Grid(BaseMap):
401
816
  Returns:
402
817
  in_collision: True if the line of sight is in collision, False otherwise.
403
818
  """
404
- if not self.is_expandable(p1) or not self.is_expandable(p2, p1):
405
- return True
406
-
407
- # Corner Case: Start and end points are the same
408
- if p1 == p2:
409
- return False
410
-
411
- p1 = np.array(p1)
412
- p2 = np.array(p2)
413
-
414
- # Calculate delta and absolute delta
415
- delta = p2 - p1
416
- abs_delta = np.abs(delta)
417
-
418
- # Determine the primary axis (the dimension with the greatest change)
419
- primary_axis = np.argmax(abs_delta)
420
- primary_step = 1 if delta[primary_axis] > 0 else -1
421
-
422
- # Initialize the error variable
423
- error = np.zeros_like(delta, dtype=np.int32)
424
- delta2 = 2 * abs_delta
425
-
426
- # calculate the number of steps and initialize the current point
427
- steps = abs_delta[primary_axis]
428
- current = p1
429
-
430
- for _ in range(steps):
431
- last_point = current.copy()
432
- current[primary_axis] += primary_step
433
-
434
- # Update the error for the primary dimension
435
- for d in range(len(delta)):
436
- if d == primary_axis:
437
- continue
438
-
439
- error[d] += delta2[d]
440
- if error[d] > abs_delta[primary_axis]:
441
- current[d] += 1 if delta[d] > 0 else -1
442
- error[d] -= delta2[primary_axis]
443
-
444
- # Check the current point
445
- if not self.is_expandable(tuple(current), tuple(last_point)):
446
- return True
447
-
448
- return False
819
+ p1_array = np.asarray(p1, dtype=np.int64)
820
+ p2_array = np.asarray(p2, dtype=np.int64)
821
+ if p1_array.shape != p2_array.shape:
822
+ p2_array - p1_array
823
+
824
+ return _grid_in_collision(
825
+ p1_array,
826
+ p2_array,
827
+ self._shape_array,
828
+ self._type_map_flat(),
829
+ self._esdf_flat(),
830
+ TYPES.OBSTACLE,
831
+ TYPES.INFLATION,
832
+ self.strict_collision,
833
+ )
449
834
 
450
835
  def fill_boundary_with_obstacles(self) -> None:
451
836
  """
@@ -456,12 +841,12 @@ class Grid(BaseMap):
456
841
  # First boundary (start index)
457
842
  slices_start = [slice(None)] * self.dim
458
843
  slices_start[d] = 0
459
- self.type_map[tuple(slices_start)] = TYPES.OBSTACLE
844
+ self._type_map[tuple(slices_start)] = TYPES.OBSTACLE
460
845
 
461
846
  # Last boundary (end index)
462
847
  slices_end = [slice(None)] * self.dim
463
848
  slices_end[d] = -1
464
- self.type_map[tuple(slices_end)] = TYPES.OBSTACLE
849
+ self._type_map[tuple(slices_end)] = TYPES.OBSTACLE
465
850
 
466
851
  def inflate_obstacles(self, radius: float = 1.0) -> None:
467
852
  """
@@ -470,9 +855,8 @@ class Grid(BaseMap):
470
855
  Args:
471
856
  radius: Radius of the inflation.
472
857
  """
473
- self.update_esdf()
474
- mask = (self.esdf <= radius) & (self.type_map.data == TYPES.FREE)
475
- self.type_map[mask] = TYPES.INFLATION
858
+ mask = (self.esdf <= radius) & (self._type_map.data == TYPES.FREE)
859
+ self._type_map[mask] = TYPES.INFLATION
476
860
  self.inflation_radius = radius
477
861
 
478
862
  def fill_expands(self, expands: Dict[Tuple[int, ...], Node]) -> None:
@@ -483,9 +867,9 @@ class Grid(BaseMap):
483
867
  expands: List of expands.
484
868
  """
485
869
  for expand in expands.keys():
486
- if self.type_map[expand] != TYPES.FREE:
870
+ if self._type_map[expand] != TYPES.FREE:
487
871
  continue
488
- self.type_map[expand] = TYPES.EXPAND
872
+ self._type_map[expand] = TYPES.EXPAND
489
873
 
490
874
  def update_esdf(self) -> None:
491
875
  """
@@ -493,7 +877,7 @@ class Grid(BaseMap):
493
877
  - Obstacle grid ESDF = 0
494
878
  - Free grid ESDF > 0. The value is the di/stance to the nearest obstacle
495
879
  """
496
- obstacle_mask = (self.type_map.data == TYPES.OBSTACLE)
880
+ obstacle_mask = (self._type_map.data == TYPES.OBSTACLE)
497
881
  free_mask = ~obstacle_mask
498
882
 
499
883
  # distance to obstacles
@@ -503,6 +887,16 @@ class Grid(BaseMap):
503
887
 
504
888
  self._esdf = dist_outside.astype(np.float32)
505
889
  self._esdf[obstacle_mask] = -dist_inside[obstacle_mask]
890
+ self._esdf_lazy_flag_ = False
891
+
892
+ def update_connectivity(self) -> None:
893
+ """Update the free-space connected component map."""
894
+ type_map = self._type_map.data
895
+ free_mask = (type_map != TYPES.OBSTACLE) & (type_map != TYPES.INFLATION)
896
+ connectivity = 1 if self.strict_collision else self.dim
897
+ structure = ndimage.generate_binary_structure(self.dim, connectivity)
898
+ ndimage.label(free_mask, structure=structure, output=self._connectivity_map)
899
+ self._connectivity_lazy_flag_ = False
506
900
 
507
901
  def path_map_to_world(self, path: List[tuple]) -> List[Tuple[float, ...]]:
508
902
  """
@@ -514,7 +908,16 @@ class Grid(BaseMap):
514
908
  Returns:
515
909
  path: a list of world coordinates
516
910
  """
517
- return [self.map_to_world(p) for p in path]
911
+ path = list(path)
912
+ if not path:
913
+ return []
914
+
915
+ points = np.asarray(path, dtype=np.float64)
916
+ if points.ndim != 2 or points.shape[1] != self.dim:
917
+ raise ValueError("Point dimension does not match map dimension.")
918
+
919
+ path_world = _grid_path_map_to_world(points, self.bounds, self.resolution)
920
+ return [tuple(float(x) for x in path_world[i]) for i in range(path_world.shape[0])]
518
921
 
519
922
  def path_world_to_map(self, path: List[Tuple[float, ...]], discrete: bool = True) -> List[tuple]:
520
923
  """
@@ -527,7 +930,20 @@ class Grid(BaseMap):
527
930
  Returns:
528
931
  path: a list of map coordinates
529
932
  """
530
- return [self.world_to_map(p, discrete) for p in path]
933
+ path = list(path)
934
+ if not path:
935
+ return []
936
+
937
+ points = np.asarray(path, dtype=np.float64)
938
+ if points.ndim != 2 or points.shape[1] != self.dim:
939
+ raise ValueError("Point dimension does not match map dimension.")
940
+
941
+ if discrete:
942
+ path_map = _grid_path_world_to_map_int(points, self.bounds, self.resolution, self._shape_array)
943
+ return [tuple(int(x) for x in path_map[i]) for i in range(path_map.shape[0])]
944
+ else:
945
+ path_map = _grid_path_world_to_map_float(points, self.bounds, self.resolution)
946
+ return [tuple(float(x) for x in path_map[i]) for i in range(path_map.shape[0])]
531
947
 
532
948
  def point_float_to_int(self, point: Tuple[float, ...]) -> Tuple[int, ...]:
533
949
  """
@@ -539,24 +955,29 @@ class Grid(BaseMap):
539
955
  Returns:
540
956
  point: a point in integer coordinates
541
957
  """
958
+ shape = self.shape
542
959
  point_int = []
543
960
  for d in range(self.dim):
544
- point_int.append(max(0, min(self.shape[d] - 1, int(round(point[d])))))
545
- point_int = tuple(point_int)
546
- return point_int
961
+ value = round(point[d])
962
+ if value < 0:
963
+ value = 0
964
+ elif value >= shape[d]:
965
+ value = shape[d] - 1
966
+ point_int.append(value)
967
+ return tuple(point_int)
547
968
 
548
969
  def _precompute_offsets(self):
549
970
  # Generate all possible offsets (-1, 0, +1) in each dimension
550
- self._diagonal_offsets = np.array(np.meshgrid(*[[-1, 0, 1]]*self.dim), dtype=self.dtype).T.reshape(-1, self.dim)
971
+ self._diagonal_offsets_array = np.array(np.meshgrid(*[[-1, 0, 1]]*self.dim), dtype=np.int64).T.reshape(-1, self.dim)
551
972
  # Remove the zero offset (current node itself)
552
- self._diagonal_offsets = self._diagonal_offsets[np.any(self._diagonal_offsets != 0, axis=1)]
973
+ self._diagonal_offsets_array = self._diagonal_offsets_array[np.any(self._diagonal_offsets_array != 0, axis=1)]
553
974
  # self._diagonal_offsets = [Node((offset.tolist(), dtype=self.dtype)) for offset in self._diagonal_offsets]
554
- self._diagonal_offsets = [Node(tuple(offset.tolist())) for offset in self._diagonal_offsets]
975
+ self._diagonal_offsets = [Node(tuple(offset.tolist())) for offset in self._diagonal_offsets_array]
555
976
 
556
977
  # Generate only orthogonal offsets (one dimension changes by ±1)
557
- self._orthogonal_offsets = np.zeros((2*self.dim, self.dim), dtype=self.dtype)
978
+ self._orthogonal_offsets_array = np.zeros((2*self.dim, self.dim), dtype=np.int64)
558
979
  for d in range(self.dim):
559
- self._orthogonal_offsets[2*d, d] = 1
560
- self._orthogonal_offsets[2*d+1, d] = -1
980
+ self._orthogonal_offsets_array[2*d, d] = 1
981
+ self._orthogonal_offsets_array[2*d+1, d] = -1
561
982
  # self._orthogonal_offsets = [Node((offset.tolist(), dtype=self.dtype)) for offset in self._orthogonal_offsets]
562
- self._orthogonal_offsets = [Node(tuple(offset.tolist())) for offset in self._orthogonal_offsets]
983
+ self._orthogonal_offsets = [Node(tuple(offset.tolist())) for offset in self._orthogonal_offsets_array]