nepher 0.1.0__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.
- nepher/__init__.py +36 -0
- nepher/api/__init__.py +6 -0
- nepher/api/client.py +384 -0
- nepher/api/endpoints.py +97 -0
- nepher/auth.py +150 -0
- nepher/cli/__init__.py +2 -0
- nepher/cli/commands/__init__.py +6 -0
- nepher/cli/commands/auth.py +37 -0
- nepher/cli/commands/cache.py +85 -0
- nepher/cli/commands/config.py +77 -0
- nepher/cli/commands/download.py +72 -0
- nepher/cli/commands/list.py +75 -0
- nepher/cli/commands/upload.py +69 -0
- nepher/cli/commands/view.py +310 -0
- nepher/cli/main.py +30 -0
- nepher/cli/utils.py +28 -0
- nepher/config.py +202 -0
- nepher/core.py +67 -0
- nepher/env_cfgs/__init__.py +7 -0
- nepher/env_cfgs/base.py +32 -0
- nepher/env_cfgs/manipulation/__init__.py +4 -0
- nepher/env_cfgs/navigation/__init__.py +45 -0
- nepher/env_cfgs/navigation/abstract_nav_cfg.py +159 -0
- nepher/env_cfgs/navigation/preset_nav_cfg.py +590 -0
- nepher/env_cfgs/navigation/usd_nav_cfg.py +644 -0
- nepher/env_cfgs/registry.py +31 -0
- nepher/loader/__init__.py +9 -0
- nepher/loader/base.py +27 -0
- nepher/loader/category_loaders/__init__.py +2 -0
- nepher/loader/preset_loader.py +80 -0
- nepher/loader/registry.py +63 -0
- nepher/loader/usd_loader.py +49 -0
- nepher/storage/__init__.py +8 -0
- nepher/storage/bundle.py +78 -0
- nepher/storage/cache.py +145 -0
- nepher/storage/manifest.py +80 -0
- nepher/utils/__init__.py +12 -0
- nepher/utils/fast_spawn_sampler.py +334 -0
- nepher/utils/free_zone_finder.py +239 -0
- nepher-0.1.0.dist-info/METADATA +235 -0
- nepher-0.1.0.dist-info/RECORD +45 -0
- nepher-0.1.0.dist-info/WHEEL +5 -0
- nepher-0.1.0.dist-info/entry_points.txt +2 -0
- nepher-0.1.0.dist-info/licenses/LICENSE +97 -0
- nepher-0.1.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
# Copyright (c) 2025, Nepher Team
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
+
|
|
6
|
+
"""Fast O(1) spawn sampler using pre-computed valid positions.
|
|
7
|
+
|
|
8
|
+
All expensive work (occupancy map parsing, collision checks) happens at init.
|
|
9
|
+
Runtime sampling is just random index selection - O(1).
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import os
|
|
15
|
+
from dataclasses import dataclass
|
|
16
|
+
|
|
17
|
+
import numpy as np
|
|
18
|
+
import torch
|
|
19
|
+
from PIL import Image
|
|
20
|
+
import yaml
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class OccupancyMapConfig:
|
|
25
|
+
"""Configuration for loading an occupancy map."""
|
|
26
|
+
yaml_path: str
|
|
27
|
+
"""Path to the occupancy map YAML file."""
|
|
28
|
+
safety_margin: float = 0.5
|
|
29
|
+
"""Safety margin in meters around obstacles."""
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class FastSpawnSampler:
|
|
33
|
+
"""Pre-computes valid spawn positions for O(1) runtime sampling.
|
|
34
|
+
|
|
35
|
+
At init: Scans occupancy map / spawn areas, builds tensor of valid (x,y) coords.
|
|
36
|
+
At runtime: torch.randint + index lookup = O(1) per sample.
|
|
37
|
+
|
|
38
|
+
Note on ROS occupancy map conventions:
|
|
39
|
+
- Origin is at the BOTTOM-LEFT of the image in world coordinates
|
|
40
|
+
- +Y in world points UPWARD, but image row 0 is at TOP
|
|
41
|
+
- origin = [x, y, theta] where theta is map rotation (yaw) in radians
|
|
42
|
+
"""
|
|
43
|
+
|
|
44
|
+
def __init__(
|
|
45
|
+
self,
|
|
46
|
+
device: str | torch.device = "cpu",
|
|
47
|
+
omap_config: OccupancyMapConfig | None = None,
|
|
48
|
+
spawn_bounds: tuple[float, float, float, float] | None = None,
|
|
49
|
+
exclusion_rects: list[tuple[float, float, float, float]] | None = None,
|
|
50
|
+
grid_resolution: float = 0.1,
|
|
51
|
+
safety_margin: float = 0.5,
|
|
52
|
+
usd_offset: tuple[float, float, float] | None = None,
|
|
53
|
+
):
|
|
54
|
+
"""Initialize the fast spawn sampler.
|
|
55
|
+
|
|
56
|
+
Args:
|
|
57
|
+
device: Torch device for tensors.
|
|
58
|
+
omap_config: Occupancy map config (YAML path + margin). If provided, uses omap.
|
|
59
|
+
spawn_bounds: (x_min, y_min, x_max, y_max) fallback spawn area if no omap.
|
|
60
|
+
exclusion_rects: List of (x_min, y_min, x_max, y_max) exclusion zones.
|
|
61
|
+
grid_resolution: Grid cell size in meters for discretization.
|
|
62
|
+
safety_margin: Safety margin around obstacles in meters.
|
|
63
|
+
usd_offset: Optional (x, y, yaw) offset to reconcile map frame with USD placement.
|
|
64
|
+
Applied AFTER map-to-world transform. Use when USD scene origin
|
|
65
|
+
doesn't match the map's world origin.
|
|
66
|
+
"""
|
|
67
|
+
self.device = device
|
|
68
|
+
self.resolution = grid_resolution
|
|
69
|
+
self.safety_margin = safety_margin
|
|
70
|
+
self.usd_offset = usd_offset
|
|
71
|
+
self._valid_positions: torch.Tensor | None = None
|
|
72
|
+
self._num_valid: int = 0
|
|
73
|
+
|
|
74
|
+
if omap_config and os.path.exists(omap_config.yaml_path):
|
|
75
|
+
self._init_from_occupancy_map(omap_config)
|
|
76
|
+
elif spawn_bounds:
|
|
77
|
+
self._init_from_bounds(spawn_bounds, exclusion_rects or [])
|
|
78
|
+
else:
|
|
79
|
+
# Fallback: 10x10m area centered at origin
|
|
80
|
+
self._init_from_bounds((-5.0, -5.0, 5.0, 5.0), exclusion_rects or [])
|
|
81
|
+
|
|
82
|
+
def _init_from_occupancy_map(self, config: OccupancyMapConfig) -> None:
|
|
83
|
+
"""Load occupancy map and extract all free cells.
|
|
84
|
+
|
|
85
|
+
Correctly handles ROS occupancy map conventions:
|
|
86
|
+
- origin is at BOTTOM-LEFT of image in world frame
|
|
87
|
+
- image row 0 is at TOP (so must flip Y)
|
|
88
|
+
- origin[2] is yaw rotation in radians
|
|
89
|
+
"""
|
|
90
|
+
with open(config.yaml_path, encoding="utf-8") as f:
|
|
91
|
+
meta = yaml.safe_load(f.read())
|
|
92
|
+
|
|
93
|
+
img_path = os.path.join(os.path.dirname(config.yaml_path), meta["image"])
|
|
94
|
+
img = np.array(Image.open(img_path))
|
|
95
|
+
|
|
96
|
+
resolution = meta["resolution"]
|
|
97
|
+
origin = meta["origin"] # [x, y, theta] in world coords
|
|
98
|
+
origin_x, origin_y = origin[0], origin[1]
|
|
99
|
+
origin_yaw = origin[2] if len(origin) > 2 else 0.0 # rotation in radians
|
|
100
|
+
|
|
101
|
+
# Get image dimensions (height = rows, width = cols)
|
|
102
|
+
img_height = img.shape[0]
|
|
103
|
+
|
|
104
|
+
margin_px = int(np.ceil(config.safety_margin / resolution))
|
|
105
|
+
|
|
106
|
+
# Build occupancy grid (True = occupied)
|
|
107
|
+
# Handle both grayscale and RGB images
|
|
108
|
+
if len(img.shape) == 3:
|
|
109
|
+
pixel_values = img[:, :, 0]
|
|
110
|
+
else:
|
|
111
|
+
pixel_values = img
|
|
112
|
+
|
|
113
|
+
if meta.get("negate", False):
|
|
114
|
+
occupied = (pixel_values / 255.0) > meta["free_thresh"]
|
|
115
|
+
else:
|
|
116
|
+
occupied = ((255 - pixel_values) / 255.0) > meta["free_thresh"]
|
|
117
|
+
|
|
118
|
+
# Dilate obstacles by safety margin using max pooling
|
|
119
|
+
if margin_px > 0:
|
|
120
|
+
occ_tensor = torch.from_numpy(occupied.astype(np.float32)).unsqueeze(0).unsqueeze(0)
|
|
121
|
+
dilated = torch.nn.functional.max_pool2d(
|
|
122
|
+
occ_tensor,
|
|
123
|
+
kernel_size=2 * margin_px + 1,
|
|
124
|
+
stride=1,
|
|
125
|
+
padding=margin_px
|
|
126
|
+
)
|
|
127
|
+
occupied = dilated.squeeze().numpy() > 0.5
|
|
128
|
+
|
|
129
|
+
# Find free cells (not occupied)
|
|
130
|
+
free_rows, free_cols = np.where(~occupied)
|
|
131
|
+
|
|
132
|
+
if len(free_rows) == 0:
|
|
133
|
+
# No free cells - use origin as fallback
|
|
134
|
+
self._valid_positions = torch.tensor([[origin_x, origin_y]], device=self.device)
|
|
135
|
+
self._num_valid = 1
|
|
136
|
+
return
|
|
137
|
+
|
|
138
|
+
# Convert pixel coords to world coords (ROS convention)
|
|
139
|
+
#
|
|
140
|
+
# ROS occupancy map convention:
|
|
141
|
+
# - origin is the real-world pose of the BOTTOM-LEFT corner of the image
|
|
142
|
+
# - In the image, row 0 is TOP, but in world coords that's the TOP of the map
|
|
143
|
+
# - So for a pixel at (row, col):
|
|
144
|
+
# world_x = origin_x + col * resolution
|
|
145
|
+
# world_y = origin_y + (image_height - 1 - row) * resolution
|
|
146
|
+
#
|
|
147
|
+
# The (image_height - 1 - row) flips the Y axis from image coords to world coords
|
|
148
|
+
|
|
149
|
+
world_x = origin_x + free_cols * resolution
|
|
150
|
+
world_y = origin_y + (img_height - 1 - free_rows) * resolution
|
|
151
|
+
|
|
152
|
+
# Apply map rotation (yaw) if non-zero
|
|
153
|
+
# Rotation is around the origin point
|
|
154
|
+
if abs(origin_yaw) > 1e-6:
|
|
155
|
+
cos_yaw = np.cos(origin_yaw)
|
|
156
|
+
sin_yaw = np.sin(origin_yaw)
|
|
157
|
+
|
|
158
|
+
# Translate to origin, rotate, translate back
|
|
159
|
+
dx = world_x - origin_x
|
|
160
|
+
dy = world_y - origin_y
|
|
161
|
+
|
|
162
|
+
world_x = origin_x + dx * cos_yaw - dy * sin_yaw
|
|
163
|
+
world_y = origin_y + dx * sin_yaw + dy * cos_yaw
|
|
164
|
+
|
|
165
|
+
# Apply USD offset if provided (reconcile map frame with USD scene frame)
|
|
166
|
+
if self.usd_offset is not None:
|
|
167
|
+
usd_x, usd_y = self.usd_offset[0], self.usd_offset[1]
|
|
168
|
+
usd_yaw = self.usd_offset[2] if len(self.usd_offset) > 2 else 0.0
|
|
169
|
+
|
|
170
|
+
if abs(usd_yaw) > 1e-6:
|
|
171
|
+
cos_yaw = np.cos(usd_yaw)
|
|
172
|
+
sin_yaw = np.sin(usd_yaw)
|
|
173
|
+
rotated_x = world_x * cos_yaw - world_y * sin_yaw
|
|
174
|
+
rotated_y = world_x * sin_yaw + world_y * cos_yaw
|
|
175
|
+
world_x = rotated_x + usd_x
|
|
176
|
+
world_y = rotated_y + usd_y
|
|
177
|
+
else:
|
|
178
|
+
world_x = world_x + usd_x
|
|
179
|
+
world_y = world_y + usd_y
|
|
180
|
+
|
|
181
|
+
# Stack as (N, 2) tensor
|
|
182
|
+
positions = np.stack([world_x, world_y], axis=1).astype(np.float32)
|
|
183
|
+
self._valid_positions = torch.from_numpy(positions).to(self.device)
|
|
184
|
+
self._num_valid = len(positions)
|
|
185
|
+
|
|
186
|
+
def _init_from_bounds(
|
|
187
|
+
self,
|
|
188
|
+
bounds: tuple[float, float, float, float],
|
|
189
|
+
exclusions: list[tuple[float, float, float, float]],
|
|
190
|
+
) -> None:
|
|
191
|
+
"""Generate valid positions from rectangular bounds, excluding zones."""
|
|
192
|
+
x_min, y_min, x_max, y_max = bounds
|
|
193
|
+
|
|
194
|
+
# Generate grid of candidate positions
|
|
195
|
+
xs = np.arange(x_min + self.safety_margin, x_max - self.safety_margin, self.resolution)
|
|
196
|
+
ys = np.arange(y_min + self.safety_margin, y_max - self.safety_margin, self.resolution)
|
|
197
|
+
|
|
198
|
+
if len(xs) == 0 or len(ys) == 0:
|
|
199
|
+
# Area too small - use center
|
|
200
|
+
cx, cy = (x_min + x_max) / 2, (y_min + y_max) / 2
|
|
201
|
+
self._valid_positions = torch.tensor([[cx, cy]], device=self.device)
|
|
202
|
+
self._num_valid = 1
|
|
203
|
+
return
|
|
204
|
+
|
|
205
|
+
xx, yy = np.meshgrid(xs, ys)
|
|
206
|
+
candidates = np.stack([xx.ravel(), yy.ravel()], axis=1)
|
|
207
|
+
|
|
208
|
+
# Filter out exclusion zones
|
|
209
|
+
valid_mask = np.ones(len(candidates), dtype=bool)
|
|
210
|
+
for ex_xmin, ex_ymin, ex_xmax, ex_ymax in exclusions:
|
|
211
|
+
# Expand exclusion by safety margin
|
|
212
|
+
in_exclusion = (
|
|
213
|
+
(candidates[:, 0] >= ex_xmin - self.safety_margin) &
|
|
214
|
+
(candidates[:, 0] <= ex_xmax + self.safety_margin) &
|
|
215
|
+
(candidates[:, 1] >= ex_ymin - self.safety_margin) &
|
|
216
|
+
(candidates[:, 1] <= ex_ymax + self.safety_margin)
|
|
217
|
+
)
|
|
218
|
+
valid_mask &= ~in_exclusion
|
|
219
|
+
|
|
220
|
+
valid_positions = candidates[valid_mask].astype(np.float32)
|
|
221
|
+
|
|
222
|
+
if len(valid_positions) == 0:
|
|
223
|
+
# All positions excluded - use center of bounds
|
|
224
|
+
cx, cy = (x_min + x_max) / 2, (y_min + y_max) / 2
|
|
225
|
+
self._valid_positions = torch.tensor([[cx, cy]], device=self.device)
|
|
226
|
+
self._num_valid = 1
|
|
227
|
+
else:
|
|
228
|
+
self._valid_positions = torch.from_numpy(valid_positions).to(self.device)
|
|
229
|
+
self._num_valid = len(valid_positions)
|
|
230
|
+
|
|
231
|
+
@property
|
|
232
|
+
def num_valid_positions(self) -> int:
|
|
233
|
+
"""Number of pre-computed valid positions."""
|
|
234
|
+
return self._num_valid
|
|
235
|
+
|
|
236
|
+
@property
|
|
237
|
+
def is_ready(self) -> bool:
|
|
238
|
+
"""Check if sampler has valid positions."""
|
|
239
|
+
return self._valid_positions is not None and self._num_valid > 0
|
|
240
|
+
|
|
241
|
+
def sample(self, n: int) -> torch.Tensor:
|
|
242
|
+
"""Sample n random positions in O(1).
|
|
243
|
+
|
|
244
|
+
Args:
|
|
245
|
+
n: Number of positions to sample.
|
|
246
|
+
|
|
247
|
+
Returns:
|
|
248
|
+
Tensor of shape (n, 2) with (x, y) world coordinates.
|
|
249
|
+
"""
|
|
250
|
+
if self._valid_positions is None or self._num_valid == 0:
|
|
251
|
+
return torch.zeros((n, 2), device=self.device)
|
|
252
|
+
|
|
253
|
+
indices = torch.randint(0, self._num_valid, (n,), device=self.device)
|
|
254
|
+
return self._valid_positions[indices]
|
|
255
|
+
|
|
256
|
+
def sample_with_min_distance(
|
|
257
|
+
self,
|
|
258
|
+
n: int,
|
|
259
|
+
existing_positions: torch.Tensor | None = None,
|
|
260
|
+
min_distance: float = 1.0,
|
|
261
|
+
max_attempts: int = 10,
|
|
262
|
+
) -> torch.Tensor:
|
|
263
|
+
"""Sample n positions with minimum distance constraint.
|
|
264
|
+
|
|
265
|
+
Args:
|
|
266
|
+
n: Number of positions to sample.
|
|
267
|
+
existing_positions: (M, 2) tensor of positions to avoid.
|
|
268
|
+
min_distance: Minimum distance between sampled positions and existing ones.
|
|
269
|
+
max_attempts: Max attempts per sample before accepting any valid position.
|
|
270
|
+
|
|
271
|
+
Returns:
|
|
272
|
+
Tensor of shape (n, 2) with (x, y) world coordinates.
|
|
273
|
+
"""
|
|
274
|
+
valid = self._valid_positions
|
|
275
|
+
if valid is None or self._num_valid == 0:
|
|
276
|
+
return torch.zeros((n, 2), device=self.device)
|
|
277
|
+
|
|
278
|
+
result = torch.zeros((n, 2), device=self.device)
|
|
279
|
+
|
|
280
|
+
for i in range(n):
|
|
281
|
+
for _ in range(max_attempts):
|
|
282
|
+
idx = torch.randint(0, self._num_valid, (1,), device=self.device)
|
|
283
|
+
pos = valid[idx]
|
|
284
|
+
|
|
285
|
+
# Check distance to existing positions
|
|
286
|
+
if existing_positions is not None and len(existing_positions) > 0:
|
|
287
|
+
dists = torch.norm(existing_positions - pos, dim=1)
|
|
288
|
+
if dists.min() < min_distance:
|
|
289
|
+
continue
|
|
290
|
+
|
|
291
|
+
# Check distance to already sampled positions
|
|
292
|
+
if i > 0:
|
|
293
|
+
dists = torch.norm(result[:i] - pos, dim=1)
|
|
294
|
+
if dists.min() < min_distance:
|
|
295
|
+
continue
|
|
296
|
+
|
|
297
|
+
result[i] = pos.squeeze()
|
|
298
|
+
break
|
|
299
|
+
else:
|
|
300
|
+
# Max attempts reached - just pick random
|
|
301
|
+
idx = torch.randint(0, self._num_valid, (1,), device=self.device)
|
|
302
|
+
result[i] = valid[idx].squeeze()
|
|
303
|
+
|
|
304
|
+
return result
|
|
305
|
+
|
|
306
|
+
def validate(self, positions: torch.Tensor) -> torch.Tensor:
|
|
307
|
+
"""Check if positions are in the valid set (approximate).
|
|
308
|
+
|
|
309
|
+
Uses nearest-neighbor check against valid positions.
|
|
310
|
+
|
|
311
|
+
Args:
|
|
312
|
+
positions: (N, 2) tensor of positions to validate.
|
|
313
|
+
|
|
314
|
+
Returns:
|
|
315
|
+
Boolean tensor of shape (N,) - True if position is near a valid cell.
|
|
316
|
+
"""
|
|
317
|
+
valid = self._valid_positions
|
|
318
|
+
if valid is None or self._num_valid == 0 or len(positions) == 0:
|
|
319
|
+
return torch.ones(len(positions), dtype=torch.bool, device=self.device)
|
|
320
|
+
|
|
321
|
+
# Compute distances to nearest valid position
|
|
322
|
+
threshold = self.resolution * 1.5
|
|
323
|
+
|
|
324
|
+
# Batch compute min distances
|
|
325
|
+
# positions: (N, 2), valid: (M, 2)
|
|
326
|
+
pos_exp = positions.unsqueeze(1) # (N, 1, 2)
|
|
327
|
+
valid_exp = valid.unsqueeze(0) # (1, M, 2)
|
|
328
|
+
|
|
329
|
+
# Min distance for each position
|
|
330
|
+
dists = torch.norm(pos_exp - valid_exp, dim=2) # (N, M)
|
|
331
|
+
min_dists = dists.min(dim=1).values # (N,)
|
|
332
|
+
|
|
333
|
+
return min_dists <= threshold
|
|
334
|
+
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
# Copyright (c) 2025, Nepher Team
|
|
2
|
+
# All rights reserved.
|
|
3
|
+
#
|
|
4
|
+
# SPDX-License-Identifier: BSD-3-Clause
|
|
5
|
+
|
|
6
|
+
"""Free zone computation utilities.
|
|
7
|
+
|
|
8
|
+
This module provides functions to compute free zones (obstacle-free rectangular regions)
|
|
9
|
+
within a playground given a set of obstacle rectangles.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
from dataclasses import dataclass
|
|
15
|
+
from typing import NamedTuple
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class FreeZone(NamedTuple):
|
|
19
|
+
"""A rectangular free zone defined by two corner points."""
|
|
20
|
+
x1: float
|
|
21
|
+
y1: float
|
|
22
|
+
x2: float
|
|
23
|
+
y2: float
|
|
24
|
+
|
|
25
|
+
@property
|
|
26
|
+
def width(self) -> float:
|
|
27
|
+
"""Width of the free zone (x-dimension)."""
|
|
28
|
+
return abs(self.x2 - self.x1)
|
|
29
|
+
|
|
30
|
+
@property
|
|
31
|
+
def height(self) -> float:
|
|
32
|
+
"""Height of the free zone (y-dimension)."""
|
|
33
|
+
return abs(self.y2 - self.y1)
|
|
34
|
+
|
|
35
|
+
@property
|
|
36
|
+
def area(self) -> float:
|
|
37
|
+
"""Area of the free zone."""
|
|
38
|
+
return self.width * self.height
|
|
39
|
+
|
|
40
|
+
@property
|
|
41
|
+
def center(self) -> tuple[float, float]:
|
|
42
|
+
"""Center point of the free zone."""
|
|
43
|
+
return ((self.x1 + self.x2) / 2, (self.y1 + self.y2) / 2)
|
|
44
|
+
|
|
45
|
+
def shrink(self, margin: float) -> FreeZone:
|
|
46
|
+
"""Return a new free zone shrunk by the given margin on all sides."""
|
|
47
|
+
return FreeZone(
|
|
48
|
+
x1=self.x1 + margin,
|
|
49
|
+
y1=self.y1 + margin,
|
|
50
|
+
x2=self.x2 - margin,
|
|
51
|
+
y2=self.y2 - margin,
|
|
52
|
+
)
|
|
53
|
+
|
|
54
|
+
def expand(self, margin: float) -> FreeZone:
|
|
55
|
+
"""Return a new free zone expanded by the given margin on all sides."""
|
|
56
|
+
return FreeZone(
|
|
57
|
+
x1=self.x1 - margin,
|
|
58
|
+
y1=self.y1 - margin,
|
|
59
|
+
x2=self.x2 + margin,
|
|
60
|
+
y2=self.y2 + margin,
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
def contains_point(self, x: float, y: float) -> bool:
|
|
64
|
+
"""Check if a point is inside this free zone.
|
|
65
|
+
|
|
66
|
+
Args:
|
|
67
|
+
x: X coordinate of the point.
|
|
68
|
+
y: Y coordinate of the point.
|
|
69
|
+
|
|
70
|
+
Returns:
|
|
71
|
+
True if the point is inside the free zone, False otherwise.
|
|
72
|
+
"""
|
|
73
|
+
x_min = min(self.x1, self.x2)
|
|
74
|
+
x_max = max(self.x1, self.x2)
|
|
75
|
+
y_min = min(self.y1, self.y2)
|
|
76
|
+
y_max = max(self.y1, self.y2)
|
|
77
|
+
return x_min <= x <= x_max and y_min <= y <= y_max
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
@dataclass
|
|
81
|
+
class Rectangle:
|
|
82
|
+
"""Axis-aligned rectangle defined by min/max coordinates."""
|
|
83
|
+
x_min: float
|
|
84
|
+
y_min: float
|
|
85
|
+
x_max: float
|
|
86
|
+
y_max: float
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def width(self) -> float:
|
|
90
|
+
"""Width of the rectangle (x-dimension)."""
|
|
91
|
+
return self.x_max - self.x_min
|
|
92
|
+
|
|
93
|
+
@property
|
|
94
|
+
def height(self) -> float:
|
|
95
|
+
"""Height of the rectangle (y-dimension)."""
|
|
96
|
+
return self.y_max - self.y_min
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def center(self) -> tuple[float, float]:
|
|
100
|
+
"""Center point of the rectangle."""
|
|
101
|
+
return ((self.x_min + self.x_max) / 2, (self.y_min + self.y_max) / 2)
|
|
102
|
+
|
|
103
|
+
def contains_point(self, x: float, y: float) -> bool:
|
|
104
|
+
"""Check if a point is inside this rectangle."""
|
|
105
|
+
return self.x_min <= x <= self.x_max and self.y_min <= y <= self.y_max
|
|
106
|
+
|
|
107
|
+
def intersects(self, other: Rectangle) -> bool:
|
|
108
|
+
"""Check if this rectangle intersects with another."""
|
|
109
|
+
return not (
|
|
110
|
+
self.x_max < other.x_min or self.x_min > other.x_max or
|
|
111
|
+
self.y_max < other.y_min or self.y_min > other.y_max
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
def expand(self, margin: float) -> Rectangle:
|
|
115
|
+
"""Return a new rectangle expanded by the given margin on all sides."""
|
|
116
|
+
return Rectangle(
|
|
117
|
+
x_min=self.x_min - margin,
|
|
118
|
+
y_min=self.y_min - margin,
|
|
119
|
+
x_max=self.x_max + margin,
|
|
120
|
+
y_max=self.y_max + margin,
|
|
121
|
+
)
|
|
122
|
+
|
|
123
|
+
def shrink(self, margin: float) -> Rectangle:
|
|
124
|
+
"""Return a new rectangle shrunk by the given margin on all sides."""
|
|
125
|
+
return Rectangle(
|
|
126
|
+
x_min=self.x_min + margin,
|
|
127
|
+
y_min=self.y_min + margin,
|
|
128
|
+
x_max=self.x_max - margin,
|
|
129
|
+
y_max=self.y_max - margin,
|
|
130
|
+
)
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def compute_bounding_playground(obstacles: list[Rectangle] | None, margin: float = 1.0) -> Rectangle:
|
|
134
|
+
"""Compute a playground that bounds all obstacles with a margin."""
|
|
135
|
+
if not obstacles:
|
|
136
|
+
return Rectangle(x_min=-margin, y_min=-margin, x_max=margin, y_max=margin)
|
|
137
|
+
|
|
138
|
+
return Rectangle(
|
|
139
|
+
x_min=min(obs.x_min for obs in obstacles) - margin,
|
|
140
|
+
y_min=min(obs.y_min for obs in obstacles) - margin,
|
|
141
|
+
x_max=max(obs.x_max for obs in obstacles) + margin,
|
|
142
|
+
y_max=max(obs.y_max for obs in obstacles) + margin,
|
|
143
|
+
)
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _zones_overlap(z1: FreeZone, z2: FreeZone) -> bool:
|
|
147
|
+
"""Check if two zones overlap."""
|
|
148
|
+
return not (z1.x2 <= z2.x1 or z1.x1 >= z2.x2 or z1.y2 <= z2.y1 or z1.y1 >= z2.y2)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _zone_is_obstacle_free(zone: FreeZone, obstacles: list[Rectangle]) -> bool:
|
|
152
|
+
"""Check if a zone is completely free of obstacles."""
|
|
153
|
+
return not any(
|
|
154
|
+
not (zone.x2 <= obs.x_min or zone.x1 >= obs.x_max or zone.y2 <= obs.y_min or zone.y1 >= obs.y_max)
|
|
155
|
+
for obs in obstacles
|
|
156
|
+
)
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
def _remove_overlapping_zones(zones: list[FreeZone]) -> list[FreeZone]:
|
|
160
|
+
"""Remove overlapping zones, keeping larger ones (zones must be sorted by area)."""
|
|
161
|
+
non_overlapping = []
|
|
162
|
+
for zone in zones:
|
|
163
|
+
if not any(_zones_overlap(zone, kept) for kept in non_overlapping):
|
|
164
|
+
non_overlapping.append(zone)
|
|
165
|
+
return non_overlapping
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def find_free_zones(
|
|
169
|
+
obstacle_boxes: list[Rectangle] | None = None,
|
|
170
|
+
playground: Rectangle | None = None,
|
|
171
|
+
playground_margin: float = 1.0,
|
|
172
|
+
min_zone_size: float = 0.5,
|
|
173
|
+
max_zones: int | None = None,
|
|
174
|
+
clearance: float = 0.3,
|
|
175
|
+
) -> tuple[list[FreeZone], Rectangle]:
|
|
176
|
+
"""Find free zones within a playground given obstacle rectangles.
|
|
177
|
+
|
|
178
|
+
A **free zone** is a rectangular region that:
|
|
179
|
+
1. Is entirely within the playground bounds.
|
|
180
|
+
2. Does not overlap with any obstacle rectangle.
|
|
181
|
+
3. Does not overlap with any other free zone.
|
|
182
|
+
4. Has minimum dimensions (width and height) of at least `min_zone_size`.
|
|
183
|
+
|
|
184
|
+
The algorithm uses a boundary-based sweep approach to maximize the number of zones:
|
|
185
|
+
1. Collect all x and y boundaries from obstacles and playground.
|
|
186
|
+
2. Generate candidate zones from all combinations of these boundaries.
|
|
187
|
+
3. Test each candidate zone to ensure it's obstacle-free.
|
|
188
|
+
4. Filter by minimum size and remove overlapping zones.
|
|
189
|
+
|
|
190
|
+
This approach finds many more zones than simple subdivision by exploring all possible
|
|
191
|
+
rectangular zones that can be formed from obstacle boundaries.
|
|
192
|
+
|
|
193
|
+
Args:
|
|
194
|
+
obstacle_boxes: List of Rectangle objects representing obstacles.
|
|
195
|
+
playground: The playground boundary as a Rectangle. If None, computed automatically.
|
|
196
|
+
playground_margin: Margin when auto-computing playground. Default is 1.0m.
|
|
197
|
+
min_zone_size: Minimum dimension (width and height) of a free zone. Default is 0.5m.
|
|
198
|
+
max_zones: Maximum number of free zones to return. Default is None (no limit).
|
|
199
|
+
clearance: Clearance margin to shrink free zones by (for safety). Default is 0.3m.
|
|
200
|
+
|
|
201
|
+
Returns:
|
|
202
|
+
Tuple of (list of FreeZone objects sorted by area descending, playground Rectangle).
|
|
203
|
+
All returned zones are guaranteed to be non-overlapping with obstacles and each other.
|
|
204
|
+
|
|
205
|
+
Example:
|
|
206
|
+
>>> obstacles = [
|
|
207
|
+
... Rectangle(x_min=2.5, y_min=-0.5, x_max=3.5, y_max=0.5),
|
|
208
|
+
... Rectangle(x_min=5.5, y_min=1.5, x_max=6.5, y_max=2.5),
|
|
209
|
+
... ]
|
|
210
|
+
>>> free_zones, playground = find_free_zones(obstacles)
|
|
211
|
+
>>> print(f"Found {len(free_zones)} non-overlapping free zones")
|
|
212
|
+
"""
|
|
213
|
+
obstacle_boxes = obstacle_boxes or []
|
|
214
|
+
playground = playground or compute_bounding_playground(obstacle_boxes, margin=playground_margin)
|
|
215
|
+
|
|
216
|
+
# Collect all boundary coordinates
|
|
217
|
+
x_coords = sorted({playground.x_min, playground.x_max} | {obs.x_min for obs in obstacle_boxes} | {obs.x_max for obs in obstacle_boxes})
|
|
218
|
+
y_coords = sorted({playground.y_min, playground.y_max} | {obs.y_min for obs in obstacle_boxes} | {obs.y_max for obs in obstacle_boxes})
|
|
219
|
+
|
|
220
|
+
# Generate candidate zones from all boundary combinations
|
|
221
|
+
candidate_zones = []
|
|
222
|
+
for i, x1 in enumerate(x_coords):
|
|
223
|
+
for x2 in x_coords[i+1:]:
|
|
224
|
+
for j, y1 in enumerate(y_coords):
|
|
225
|
+
for y2 in y_coords[j+1:]:
|
|
226
|
+
candidate = FreeZone(x1, y1, x2, y2)
|
|
227
|
+
if (candidate.width >= min_zone_size and candidate.height >= min_zone_size and
|
|
228
|
+
candidate.x1 >= playground.x_min and candidate.x2 <= playground.x_max and
|
|
229
|
+
candidate.y1 >= playground.y_min and candidate.y2 <= playground.y_max and
|
|
230
|
+
_zone_is_obstacle_free(candidate, obstacle_boxes)):
|
|
231
|
+
candidate_zones.append(candidate)
|
|
232
|
+
|
|
233
|
+
candidate_zones.sort(key=lambda z: z.area, reverse=True)
|
|
234
|
+
free_zones = _remove_overlapping_zones(candidate_zones)
|
|
235
|
+
|
|
236
|
+
# Shrink zones by clearance for safety margins
|
|
237
|
+
free_zones = [zone.shrink(clearance) for zone in free_zones]
|
|
238
|
+
return (free_zones[:max_zones] if max_zones else free_zones, playground)
|
|
239
|
+
|