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.
Files changed (45) hide show
  1. nepher/__init__.py +36 -0
  2. nepher/api/__init__.py +6 -0
  3. nepher/api/client.py +384 -0
  4. nepher/api/endpoints.py +97 -0
  5. nepher/auth.py +150 -0
  6. nepher/cli/__init__.py +2 -0
  7. nepher/cli/commands/__init__.py +6 -0
  8. nepher/cli/commands/auth.py +37 -0
  9. nepher/cli/commands/cache.py +85 -0
  10. nepher/cli/commands/config.py +77 -0
  11. nepher/cli/commands/download.py +72 -0
  12. nepher/cli/commands/list.py +75 -0
  13. nepher/cli/commands/upload.py +69 -0
  14. nepher/cli/commands/view.py +310 -0
  15. nepher/cli/main.py +30 -0
  16. nepher/cli/utils.py +28 -0
  17. nepher/config.py +202 -0
  18. nepher/core.py +67 -0
  19. nepher/env_cfgs/__init__.py +7 -0
  20. nepher/env_cfgs/base.py +32 -0
  21. nepher/env_cfgs/manipulation/__init__.py +4 -0
  22. nepher/env_cfgs/navigation/__init__.py +45 -0
  23. nepher/env_cfgs/navigation/abstract_nav_cfg.py +159 -0
  24. nepher/env_cfgs/navigation/preset_nav_cfg.py +590 -0
  25. nepher/env_cfgs/navigation/usd_nav_cfg.py +644 -0
  26. nepher/env_cfgs/registry.py +31 -0
  27. nepher/loader/__init__.py +9 -0
  28. nepher/loader/base.py +27 -0
  29. nepher/loader/category_loaders/__init__.py +2 -0
  30. nepher/loader/preset_loader.py +80 -0
  31. nepher/loader/registry.py +63 -0
  32. nepher/loader/usd_loader.py +49 -0
  33. nepher/storage/__init__.py +8 -0
  34. nepher/storage/bundle.py +78 -0
  35. nepher/storage/cache.py +145 -0
  36. nepher/storage/manifest.py +80 -0
  37. nepher/utils/__init__.py +12 -0
  38. nepher/utils/fast_spawn_sampler.py +334 -0
  39. nepher/utils/free_zone_finder.py +239 -0
  40. nepher-0.1.0.dist-info/METADATA +235 -0
  41. nepher-0.1.0.dist-info/RECORD +45 -0
  42. nepher-0.1.0.dist-info/WHEEL +5 -0
  43. nepher-0.1.0.dist-info/entry_points.txt +2 -0
  44. nepher-0.1.0.dist-info/licenses/LICENSE +97 -0
  45. 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
+