simple-autonomous-car 0.1.2__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 (50) hide show
  1. simple_autonomous_car/__init__.py +96 -0
  2. simple_autonomous_car/alerts/__init__.py +5 -0
  3. simple_autonomous_car/alerts/track_bounds_alert.py +276 -0
  4. simple_autonomous_car/car/__init__.py +5 -0
  5. simple_autonomous_car/car/car.py +234 -0
  6. simple_autonomous_car/constants.py +112 -0
  7. simple_autonomous_car/control/__init__.py +7 -0
  8. simple_autonomous_car/control/base_controller.py +152 -0
  9. simple_autonomous_car/control/controller_viz.py +282 -0
  10. simple_autonomous_car/control/pid_controller.py +153 -0
  11. simple_autonomous_car/control/pure_pursuit_controller.py +578 -0
  12. simple_autonomous_car/costmap/__init__.py +12 -0
  13. simple_autonomous_car/costmap/base_costmap.py +187 -0
  14. simple_autonomous_car/costmap/grid_costmap.py +507 -0
  15. simple_autonomous_car/costmap/inflation.py +126 -0
  16. simple_autonomous_car/detection/__init__.py +5 -0
  17. simple_autonomous_car/detection/error_detector.py +165 -0
  18. simple_autonomous_car/filters/__init__.py +7 -0
  19. simple_autonomous_car/filters/base_filter.py +119 -0
  20. simple_autonomous_car/filters/kalman_filter.py +131 -0
  21. simple_autonomous_car/filters/particle_filter.py +162 -0
  22. simple_autonomous_car/footprint/__init__.py +7 -0
  23. simple_autonomous_car/footprint/base_footprint.py +128 -0
  24. simple_autonomous_car/footprint/circular_footprint.py +73 -0
  25. simple_autonomous_car/footprint/rectangular_footprint.py +123 -0
  26. simple_autonomous_car/frames/__init__.py +21 -0
  27. simple_autonomous_car/frames/frenet.py +267 -0
  28. simple_autonomous_car/maps/__init__.py +9 -0
  29. simple_autonomous_car/maps/frenet_map.py +97 -0
  30. simple_autonomous_car/maps/grid_ground_truth_map.py +83 -0
  31. simple_autonomous_car/maps/grid_map.py +361 -0
  32. simple_autonomous_car/maps/ground_truth_map.py +64 -0
  33. simple_autonomous_car/maps/perceived_map.py +169 -0
  34. simple_autonomous_car/perception/__init__.py +5 -0
  35. simple_autonomous_car/perception/perception.py +107 -0
  36. simple_autonomous_car/planning/__init__.py +7 -0
  37. simple_autonomous_car/planning/base_planner.py +184 -0
  38. simple_autonomous_car/planning/goal_planner.py +261 -0
  39. simple_autonomous_car/planning/track_planner.py +199 -0
  40. simple_autonomous_car/sensors/__init__.py +6 -0
  41. simple_autonomous_car/sensors/base_sensor.py +105 -0
  42. simple_autonomous_car/sensors/lidar_sensor.py +145 -0
  43. simple_autonomous_car/track/__init__.py +5 -0
  44. simple_autonomous_car/track/track.py +463 -0
  45. simple_autonomous_car/visualization/__init__.py +25 -0
  46. simple_autonomous_car/visualization/alert_viz.py +316 -0
  47. simple_autonomous_car/visualization/utils.py +169 -0
  48. simple_autonomous_car-0.1.2.dist-info/METADATA +324 -0
  49. simple_autonomous_car-0.1.2.dist-info/RECORD +50 -0
  50. simple_autonomous_car-0.1.2.dist-info/WHEEL +4 -0
@@ -0,0 +1,187 @@
1
+ """Base costmap class for obstacle representation."""
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+ from simple_autonomous_car.car.car import CarState
9
+
10
+
11
+ class BaseCostmap(ABC):
12
+ """
13
+ Base class for all costmaps.
14
+
15
+ Costmaps represent the cost of traversing different areas in the environment,
16
+ typically derived from perception data (obstacles, track boundaries, etc.).
17
+
18
+ Attributes
19
+ ----------
20
+ resolution : float
21
+ Resolution of the costmap (meters per cell).
22
+ inflation_radius : float
23
+ Inflation radius for obstacles (meters).
24
+ enabled : bool
25
+ Whether the costmap is enabled.
26
+ """
27
+
28
+ def __init__(
29
+ self,
30
+ resolution: float = 0.5,
31
+ inflation_radius: float = 1.0,
32
+ enabled: bool = True,
33
+ ):
34
+ """
35
+ Initialize base costmap.
36
+
37
+ Parameters
38
+ ----------
39
+ resolution : float, default=0.5
40
+ Resolution of the costmap in meters per cell.
41
+ inflation_radius : float, default=1.0
42
+ Inflation radius for obstacles in meters.
43
+ enabled : bool, default=True
44
+ Whether the costmap is enabled.
45
+ """
46
+ self.resolution = resolution
47
+ self.inflation_radius = inflation_radius
48
+ self.enabled = enabled
49
+
50
+ @abstractmethod
51
+ def update(
52
+ self,
53
+ perception_data: dict | None = None,
54
+ car_state: CarState | None = None,
55
+ static_obstacles: np.ndarray | None = None,
56
+ frame: str = "global",
57
+ ) -> None:
58
+ """
59
+ Update costmap from perception data or static obstacles.
60
+
61
+ Parameters
62
+ ----------
63
+ perception_data : dict, optional
64
+ Dictionary of perception data from sensors.
65
+ Keys are sensor names, values are PerceptionPoints.
66
+ car_state : CarState, optional
67
+ Current car state (required for ego frame or perception_data).
68
+ static_obstacles : np.ndarray, optional
69
+ Static obstacles as array of shape (N, 2) with [x, y] positions in global frame.
70
+ Used for static maps when perception_data is not available.
71
+ frame : str, default="global"
72
+ Frame to use for costmap ("global" or "ego").
73
+ """
74
+ pass
75
+
76
+ @abstractmethod
77
+ def get_cost(
78
+ self, position: np.ndarray, frame: str = "global", car_state: CarState | None = None
79
+ ) -> float:
80
+ """
81
+ Get cost at a specific position.
82
+
83
+ Parameters
84
+ ----------
85
+ position : np.ndarray
86
+ Position as [x, y] array.
87
+ frame : str, default="global"
88
+ Frame of the position ("global" or "ego").
89
+ car_state : CarState, optional
90
+ Car state for frame conversion (if needed).
91
+
92
+ Returns
93
+ -------
94
+ float
95
+ Cost value (0.0 = free, 1.0 = occupied, 0.0-1.0 = inflated).
96
+ """
97
+ pass
98
+
99
+ @abstractmethod
100
+ def get_cost_region(
101
+ self,
102
+ center: np.ndarray,
103
+ size: float,
104
+ frame: str = "global",
105
+ ) -> np.ndarray:
106
+ """
107
+ Get cost values in a region.
108
+
109
+ Parameters
110
+ ----------
111
+ center : np.ndarray
112
+ Center position as [x, y].
113
+ size : float
114
+ Size of the region in meters.
115
+ frame : str, default="global"
116
+ Frame of the center position.
117
+
118
+ Returns
119
+ -------
120
+ np.ndarray
121
+ 2D array of cost values.
122
+ """
123
+ pass
124
+
125
+ def is_enabled(self) -> bool:
126
+ """Check if costmap is enabled."""
127
+ return self.enabled
128
+
129
+ def enable(self) -> None:
130
+ """Enable the costmap."""
131
+ self.enabled = True
132
+
133
+ def disable(self) -> None:
134
+ """Disable the costmap."""
135
+ self.enabled = False
136
+
137
+ def get_visualization_data(
138
+ self, car_state: CarState | None = None, **kwargs: Any
139
+ ) -> dict[str, Any]:
140
+ """
141
+ Get visualization data for this costmap.
142
+
143
+ This method should be overridden by subclasses to provide
144
+ costmap-specific visualization data (e.g., costmap array, bounds, origin).
145
+
146
+ Parameters
147
+ ----------
148
+ car_state : CarState, optional
149
+ Current car state (for frame transformations).
150
+ **kwargs
151
+ Additional arguments.
152
+
153
+ Returns
154
+ -------
155
+ Dict
156
+ Dictionary containing visualization data. Default implementation
157
+ returns basic info. Subclasses should override to provide costmap data.
158
+ """
159
+ return {
160
+ "enabled": self.enabled,
161
+ "resolution": self.resolution,
162
+ "inflation_radius": self.inflation_radius,
163
+ }
164
+
165
+ def visualize(
166
+ self, ax: Any, car_state: CarState | None = None, frame: str = "global", **kwargs: Any
167
+ ) -> None:
168
+ """
169
+ Visualize costmap on the given axes.
170
+
171
+ This method should be overridden by subclasses to plot
172
+ costmap-specific visualizations.
173
+
174
+ Parameters
175
+ ----------
176
+ ax : matplotlib.axes.Axes
177
+ Axes to plot on.
178
+ car_state : CarState, optional
179
+ Current car state (for frame transformations).
180
+ frame : str, default="global"
181
+ Frame to plot in: "global" or "ego".
182
+ **kwargs
183
+ Additional visualization arguments (colors, alpha, etc.).
184
+ """
185
+ # Default implementation does nothing
186
+ # Subclasses should override to provide visualization
187
+ pass
@@ -0,0 +1,507 @@
1
+ """Grid-based costmap implementation."""
2
+
3
+ from typing import TYPE_CHECKING, Any, Optional
4
+
5
+ import numpy as np
6
+
7
+ from simple_autonomous_car.car.car import CarState
8
+ from simple_autonomous_car.costmap.base_costmap import BaseCostmap
9
+ from simple_autonomous_car.costmap.inflation import inflate_obstacles
10
+ from simple_autonomous_car.perception.perception import PerceptionPoints
11
+
12
+ if TYPE_CHECKING:
13
+ from simple_autonomous_car.footprint.base_footprint import BaseFootprint
14
+ from simple_autonomous_car.constants import (
15
+ COST_FREE,
16
+ COST_OCCUPIED,
17
+ DEFAULT_COSTMAP_ALPHA,
18
+ DEFAULT_COSTMAP_HEIGHT,
19
+ DEFAULT_COSTMAP_RESOLUTION,
20
+ DEFAULT_COSTMAP_WIDTH,
21
+ DEFAULT_COSTMAP_ZORDER,
22
+ DEFAULT_INFLATION_RADIUS,
23
+ )
24
+
25
+
26
+ class GridCostmap(BaseCostmap):
27
+ """
28
+ Grid-based costmap for obstacle representation.
29
+
30
+ This costmap represents the environment as a 2D grid where each cell
31
+ has a cost value (0.0 = free, 1.0 = occupied, 0.0-1.0 = inflated).
32
+
33
+ Parameters
34
+ ----------
35
+ width : float
36
+ Width of costmap in meters.
37
+ height : float
38
+ Height of costmap in meters.
39
+ resolution : float, default=0.5
40
+ Resolution in meters per cell.
41
+ inflation_radius : float, optional
42
+ Inflation radius for obstacles in meters.
43
+ If None and footprint provided, uses footprint bounding radius + padding.
44
+ If None and no footprint, uses DEFAULT_INFLATION_RADIUS.
45
+ footprint : BaseFootprint, optional
46
+ Vehicle footprint for accurate collision checking.
47
+ If provided, inflation_radius is calculated from footprint.
48
+ footprint_padding : float, default=0.0
49
+ Additional safety padding around footprint in meters.
50
+ origin : np.ndarray, optional
51
+ Origin position [x, y] in global frame. If None, uses car position.
52
+ frame : str, default="ego"
53
+ Frame for costmap: "ego" (car-centered) or "global".
54
+ enabled : bool, default=True
55
+ Whether costmap is enabled.
56
+
57
+ Attributes
58
+ ----------
59
+ costmap : np.ndarray
60
+ 2D array of cost values.
61
+ origin : np.ndarray
62
+ Origin position in global frame.
63
+ width_pixels : int
64
+ Width in pixels.
65
+ height_pixels : int
66
+ Height in pixels.
67
+ """
68
+
69
+ def __init__(
70
+ self,
71
+ width: float = DEFAULT_COSTMAP_WIDTH,
72
+ height: float = DEFAULT_COSTMAP_HEIGHT,
73
+ resolution: float = DEFAULT_COSTMAP_RESOLUTION,
74
+ inflation_radius: float | None = None,
75
+ footprint: Optional["BaseFootprint"] = None,
76
+ footprint_padding: float = 0.0,
77
+ origin: np.ndarray | None = None,
78
+ frame: str = "ego",
79
+ enabled: bool = True,
80
+ ):
81
+ # Determine inflation radius: use footprint if provided, otherwise use provided or default
82
+ self.footprint = footprint
83
+ self.footprint_padding = footprint_padding
84
+
85
+ if inflation_radius is None:
86
+ if footprint is not None:
87
+ # Use footprint bounding radius + padding
88
+ inflation_radius = footprint.get_inflation_radius(padding=footprint_padding)
89
+ else:
90
+ # Use default
91
+ inflation_radius = DEFAULT_INFLATION_RADIUS
92
+
93
+ super().__init__(resolution=resolution, inflation_radius=inflation_radius, enabled=enabled)
94
+ self.width = width
95
+ self.height = height
96
+ self.width_pixels = int(width / resolution)
97
+ self.height_pixels = int(height / resolution)
98
+ self.frame = frame
99
+ self.origin = origin if origin is not None else np.array([0.0, 0.0])
100
+
101
+ # Initialize costmap (0.0 = free space)
102
+ self.costmap = np.zeros((self.height_pixels, self.width_pixels), dtype=np.float32)
103
+
104
+ def _world_to_grid(
105
+ self, position: np.ndarray, car_state: CarState | None = None
106
+ ) -> tuple[int, int]:
107
+ """
108
+ Convert world position to grid coordinates.
109
+
110
+ Parameters
111
+ ----------
112
+ position : np.ndarray
113
+ Position in world frame [x, y].
114
+ car_state : CarState, optional
115
+ Car state for ego frame conversion.
116
+
117
+ Returns
118
+ -------
119
+ Tuple[int, int]
120
+ Grid coordinates (row, col).
121
+ """
122
+ if self.frame == "ego" and car_state is not None:
123
+ # Convert to ego frame
124
+ position_ego = car_state.transform_to_car_frame(position)
125
+ x, y = position_ego[0], position_ego[1]
126
+ else:
127
+ # Global frame
128
+ x = position[0] - self.origin[0]
129
+ y = position[1] - self.origin[1]
130
+
131
+ col = int((x + self.width / 2) / self.resolution)
132
+ row = int((y + self.height / 2) / self.resolution)
133
+
134
+ return row, col
135
+
136
+ def _grid_to_world(self, row: int, col: int, car_state: CarState | None = None) -> np.ndarray:
137
+ """
138
+ Convert grid coordinates to world position.
139
+
140
+ Parameters
141
+ ----------
142
+ row : int
143
+ Grid row.
144
+ col : int
145
+ Grid column.
146
+ car_state : CarState, optional
147
+ Car state for ego frame conversion.
148
+
149
+ Returns
150
+ -------
151
+ np.ndarray
152
+ Position in world frame [x, y].
153
+ """
154
+ x = (col * self.resolution) - (self.width / 2)
155
+ y = (row * self.resolution) - (self.height / 2)
156
+
157
+ if self.frame == "ego" and car_state is not None:
158
+ # Convert from ego to global
159
+ position_ego = np.array([x, y])
160
+ position = car_state.transform_to_world_frame(position_ego)
161
+ else:
162
+ position = np.array([x + self.origin[0], y + self.origin[1]])
163
+
164
+ return position
165
+
166
+ def update(
167
+ self,
168
+ perception_data: dict[str, PerceptionPoints] | None = None,
169
+ car_state: CarState | None = None,
170
+ static_obstacles: np.ndarray | None = None,
171
+ frame: str = "global",
172
+ ) -> None:
173
+ """
174
+ Update costmap from perception data or static obstacles.
175
+
176
+ Parameters
177
+ ----------
178
+ perception_data : dict, optional
179
+ Dictionary of perception data from sensors.
180
+ If None and static_obstacles provided, uses static obstacles.
181
+ car_state : CarState, optional
182
+ Current car state (required if using perception_data or ego frame).
183
+ static_obstacles : np.ndarray, optional
184
+ Static obstacles as array of shape (N, 2) with [x, y] positions in global frame.
185
+ Used when perception_data is None (e.g., for static maps).
186
+ frame : str, default="global"
187
+ Frame to use (not used if costmap has fixed frame).
188
+ """
189
+ if not self.enabled:
190
+ return
191
+
192
+ if car_state is None and (self.frame == "ego" or perception_data is not None):
193
+ raise ValueError("car_state is required for ego frame or perception_data updates")
194
+
195
+ # Update origin if ego frame (but keep grid size fixed)
196
+ if self.frame == "ego" and car_state is not None:
197
+ # Store new origin but grid dimensions stay the same
198
+ # The grid is always centered at origin in ego frame
199
+ self.origin = car_state.position().copy()
200
+
201
+ # Reset costmap (grid size is fixed, only data changes)
202
+ self.costmap.fill(COST_FREE)
203
+
204
+ # Mark obstacles from static obstacles (if provided)
205
+ if static_obstacles is not None and len(static_obstacles) > 0:
206
+ for obstacle_pos in static_obstacles:
207
+ row, col = self._world_to_grid(obstacle_pos, car_state)
208
+ if 0 <= row < self.height_pixels and 0 <= col < self.width_pixels:
209
+ self.costmap[row, col] = COST_OCCUPIED
210
+
211
+ # Mark obstacles from perception data (if provided)
212
+ if perception_data is not None:
213
+ for sensor_name, perception_points in perception_data.items():
214
+ if perception_points is None or len(perception_points.points) == 0:
215
+ continue
216
+
217
+ # Convert to global frame if needed
218
+ if perception_points.frame != "global" and car_state is not None:
219
+ points_global = perception_points.to_global_frame(car_state).points
220
+ else:
221
+ points_global = perception_points.points
222
+
223
+ # Mark obstacles in costmap
224
+ for point in points_global:
225
+ row, col = self._world_to_grid(point, car_state)
226
+ if 0 <= row < self.height_pixels and 0 <= col < self.width_pixels:
227
+ self.costmap[row, col] = COST_OCCUPIED
228
+
229
+ # Apply inflation (in-place to preserve array shape)
230
+ if self.inflation_radius > 0:
231
+ inflated = inflate_obstacles(
232
+ self.costmap, self.inflation_radius, self.resolution, method="linear"
233
+ )
234
+ # Ensure we keep the same array shape (inflation should not change dimensions)
235
+ if inflated.shape == self.costmap.shape:
236
+ self.costmap[:] = inflated
237
+ else:
238
+ # Fallback: if shape changed, resize to original
239
+ self.costmap.fill(0.0)
240
+ min_rows = min(self.costmap.shape[0], inflated.shape[0])
241
+ min_cols = min(self.costmap.shape[1], inflated.shape[1])
242
+ self.costmap[:min_rows, :min_cols] = inflated[:min_rows, :min_cols]
243
+
244
+ def get_cost(
245
+ self, position: np.ndarray, frame: str = "global", car_state: CarState | None = None
246
+ ) -> float:
247
+ """
248
+ Get cost at a specific position.
249
+
250
+ Parameters
251
+ ----------
252
+ position : np.ndarray
253
+ Position as [x, y] array.
254
+ frame : str, default="global"
255
+ Frame of the position.
256
+ car_state : CarState, optional
257
+ Car state for frame conversion.
258
+
259
+ Returns
260
+ -------
261
+ float
262
+ Cost value (0.0 = free, 1.0 = occupied).
263
+ """
264
+ if not self.enabled:
265
+ return COST_FREE
266
+
267
+ # Convert to global if needed
268
+ if frame != "global" and car_state is not None:
269
+ if frame == "ego":
270
+ position = car_state.transform_to_world_frame(position)
271
+
272
+ row, col = self._world_to_grid(position, car_state)
273
+ if 0 <= row < self.height_pixels and 0 <= col < self.width_pixels:
274
+ return float(self.costmap[row, col])
275
+ else:
276
+ return COST_FREE # Outside costmap = free
277
+
278
+ def get_cost_region(
279
+ self,
280
+ center: np.ndarray,
281
+ size: float,
282
+ frame: str = "global",
283
+ car_state: CarState | None = None,
284
+ ) -> np.ndarray:
285
+ """
286
+ Get cost values in a region.
287
+
288
+ Parameters
289
+ ----------
290
+ center : np.ndarray
291
+ Center position as [x, y].
292
+ size : float
293
+ Size of the region in meters.
294
+ frame : str, default="global"
295
+ Frame of the center position.
296
+ car_state : CarState, optional
297
+ Car state for frame conversion.
298
+
299
+ Returns
300
+ -------
301
+ np.ndarray
302
+ 2D array of cost values.
303
+ """
304
+ if not self.enabled:
305
+ return np.zeros((1, 1))
306
+
307
+ # Convert to global if needed
308
+ if frame != "global" and car_state is not None:
309
+ if frame == "ego":
310
+ center = car_state.transform_to_world_frame(center)
311
+
312
+ center_row, center_col = self._world_to_grid(center, car_state)
313
+ half_size_pixels = int(size / (2 * self.resolution))
314
+
315
+ row_start = max(0, center_row - half_size_pixels)
316
+ row_end = min(self.height_pixels, center_row + half_size_pixels)
317
+ col_start = max(0, center_col - half_size_pixels)
318
+ col_end = min(self.width_pixels, center_col + half_size_pixels)
319
+
320
+ return np.array(self.costmap[row_start:row_end, col_start:col_end].copy(), dtype=np.float64)
321
+
322
+ def get_visualization_data(
323
+ self, car_state: CarState | None = None, **kwargs: Any
324
+ ) -> dict[str, Any]:
325
+ """
326
+ Get visualization data for GridCostmap.
327
+
328
+ Returns costmap array, bounds, origin, and frame information.
329
+
330
+ Parameters
331
+ ----------
332
+ car_state : CarState, optional
333
+ Current car state (for frame transformations).
334
+ **kwargs
335
+ Additional arguments.
336
+
337
+ Returns
338
+ -------
339
+ Dict
340
+ Dictionary with keys:
341
+ - "costmap": np.ndarray, 2D costmap array
342
+ - "width": float, width in meters
343
+ - "height": float, height in meters
344
+ - "resolution": float, resolution in meters per cell
345
+ - "origin": np.ndarray, origin position [x, y]
346
+ - "frame": str, frame type ("ego" or "global")
347
+ - "enabled": bool, whether costmap is enabled
348
+ """
349
+ return {
350
+ "costmap": self.get_full_costmap(),
351
+ "width": self.width,
352
+ "height": self.height,
353
+ "resolution": self.resolution,
354
+ "origin": self.origin.copy(),
355
+ "frame": self.frame,
356
+ "enabled": self.enabled,
357
+ "width_pixels": self.width_pixels,
358
+ "height_pixels": self.height_pixels,
359
+ }
360
+
361
+ def get_full_costmap(self) -> np.ndarray:
362
+ """
363
+ Get full costmap array.
364
+
365
+ Returns
366
+ -------
367
+ np.ndarray
368
+ Full 2D costmap array.
369
+ """
370
+ return np.array(self.costmap.copy(), dtype=np.float64)
371
+
372
+ def visualize(
373
+ self, ax: Any, car_state: CarState | None = None, frame: str = "global", **kwargs: Any
374
+ ) -> None:
375
+ """
376
+ Visualize costmap on the given axes.
377
+
378
+ Parameters
379
+ ----------
380
+ ax : matplotlib.axes.Axes
381
+ Axes to plot on.
382
+ car_state : CarState, optional
383
+ Current car state (for frame transformations).
384
+ frame : str, default="global"
385
+ Frame to plot in: "global" or "ego".
386
+ **kwargs
387
+ Additional visualization arguments:
388
+ - alpha: float, transparency of costmap
389
+ - cmap: str, colormap name
390
+ - show_car: bool, whether to show car position
391
+ """
392
+ if not self.enabled:
393
+ return
394
+
395
+ import numpy as np
396
+
397
+ # Extract visualization parameters
398
+ alpha = kwargs.pop("alpha", DEFAULT_COSTMAP_ALPHA)
399
+ cmap = kwargs.pop("cmap", "RdYlGn_r")
400
+ show_car = kwargs.pop("show_car", False)
401
+
402
+ # Get costmap data
403
+ costmap_data = self.get_full_costmap()
404
+
405
+ # Mask out empty space (zero-cost cells) to save computation
406
+ # Only mask if there's actual data to show
407
+ if np.any(costmap_data > COST_FREE):
408
+ costmap_data = np.ma.masked_where(costmap_data == COST_FREE, costmap_data)
409
+ else:
410
+ # Costmap is empty, nothing to visualize
411
+ return
412
+
413
+ # Use imshow for optimal performance
414
+ if frame == "ego" and self.frame == "ego" and car_state is not None:
415
+ # Costmap is in ego frame, plot in ego frame
416
+ origin_x = -self.width / 2
417
+ origin_y = -self.height / 2
418
+ extent = [
419
+ origin_x,
420
+ origin_x + self.width,
421
+ origin_y,
422
+ origin_y + self.height,
423
+ ]
424
+ ax.imshow(
425
+ costmap_data,
426
+ extent=extent,
427
+ origin="lower",
428
+ cmap=cmap,
429
+ vmin=COST_FREE,
430
+ vmax=COST_OCCUPIED,
431
+ interpolation="nearest",
432
+ alpha=alpha,
433
+ zorder=DEFAULT_COSTMAP_ZORDER,
434
+ **kwargs,
435
+ )
436
+ elif self.frame == "ego" and car_state is not None and frame == "global":
437
+ # Costmap is in ego frame, but we want to plot in global frame
438
+ # Use matplotlib transform instead of rotating array data for smoother visualization
439
+ # This avoids jerky artifacts from rotating the array every frame
440
+ car_pos = car_state.position()
441
+ car_heading = car_state.heading
442
+
443
+ from matplotlib.transforms import Affine2D
444
+
445
+ # Use transform to rotate/translate at rendering time (smooth, no array manipulation)
446
+ # This is the key difference: ego frame doesn't rotate array, it uses transforms
447
+ # Transform order: rotate around origin (0,0) first, then translate to car position
448
+ # This correctly transforms ego frame (centered at origin) to global frame
449
+ trans = (
450
+ Affine2D()
451
+ .rotate(car_heading) # Rotate around origin (0,0) in ego frame
452
+ .translate(car_pos[0], car_pos[1]) # Then translate to car position
453
+ + ax.transData
454
+ )
455
+
456
+ # Extent in ego frame (centered at origin)
457
+ extent = [
458
+ -self.width / 2,
459
+ self.width / 2,
460
+ -self.height / 2,
461
+ self.height / 2,
462
+ ]
463
+
464
+ # Plot with transform - matplotlib handles rotation smoothly at render time
465
+ # No array rotation = no jerky artifacts
466
+ ax.imshow(
467
+ costmap_data,
468
+ extent=extent,
469
+ origin="lower",
470
+ cmap=cmap,
471
+ vmin=COST_FREE,
472
+ vmax=COST_OCCUPIED,
473
+ interpolation="nearest",
474
+ alpha=alpha,
475
+ zorder=DEFAULT_COSTMAP_ZORDER,
476
+ transform=trans,
477
+ **kwargs,
478
+ )
479
+ else:
480
+ # Global frame costmap in global frame
481
+ origin = self.origin
482
+ extent = [
483
+ origin[0],
484
+ origin[0] + self.width,
485
+ origin[1],
486
+ origin[1] + self.height,
487
+ ]
488
+ ax.imshow(
489
+ costmap_data,
490
+ extent=extent,
491
+ origin="lower",
492
+ cmap=cmap,
493
+ vmin=COST_FREE,
494
+ vmax=COST_OCCUPIED,
495
+ interpolation="nearest",
496
+ alpha=alpha,
497
+ zorder=DEFAULT_COSTMAP_ZORDER,
498
+ **kwargs,
499
+ )
500
+
501
+ # Show car position if requested
502
+ if show_car and car_state is not None:
503
+ car_pos = car_state.position()
504
+ if frame == "global":
505
+ ax.plot(car_pos[0], car_pos[1], "bo", markersize=8, label="Car")
506
+ else:
507
+ ax.plot(0, 0, "bo", markersize=8, label="Car")