sdf-sampler 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.
@@ -0,0 +1,339 @@
1
+ # ABOUTME: Voxel grid construction utilities
2
+ # ABOUTME: Shared voxelization and hull computation for analysis algorithms
3
+
4
+ import numpy as np
5
+ from scipy.ndimage import binary_dilation
6
+ from scipy.spatial import ConvexHull, Delaunay, KDTree
7
+
8
+
9
+ def estimate_mean_spacing(xyz: np.ndarray, tree: KDTree | None = None, k: int = 8) -> float:
10
+ """Estimate mean point spacing using k-NN.
11
+
12
+ Args:
13
+ xyz: Point positions (N, 3)
14
+ tree: Optional pre-built KDTree
15
+ k: Number of neighbors to use
16
+
17
+ Returns:
18
+ Mean distance to k nearest neighbors
19
+ """
20
+ if tree is None:
21
+ tree = KDTree(xyz)
22
+
23
+ n_sample = min(1000, len(xyz))
24
+ rng = np.random.default_rng(42)
25
+ sample_indices = rng.choice(len(xyz), n_sample, replace=False)
26
+
27
+ distances = []
28
+ for idx in sample_indices:
29
+ dists, _ = tree.query(xyz[idx], k=k + 1) # +1 for self
30
+ distances.extend(dists[1:]) # Exclude self (distance 0)
31
+
32
+ return float(np.mean(distances))
33
+
34
+
35
+ def build_voxel_grid(
36
+ xyz: np.ndarray,
37
+ min_gap_size: float = 0.10,
38
+ max_dim: int = 200,
39
+ voxel_size: float | None = None,
40
+ z_extension: float | None = None,
41
+ ) -> tuple[np.ndarray, np.ndarray, float, tuple[int, int, int]] | None:
42
+ """Build a voxel grid from point cloud data.
43
+
44
+ Args:
45
+ xyz: Point cloud coordinates (N, 3)
46
+ min_gap_size: Minimum gap size flood fill should traverse
47
+ max_dim: Maximum voxel grid dimension
48
+ voxel_size: Optional voxel size (auto-computed if None)
49
+ z_extension: How much to extend grid above point cloud in +Z
50
+
51
+ Returns:
52
+ Tuple of (occupied_grid, bbox_min, voxel_size, grid_shape) or None if invalid.
53
+ """
54
+ if len(xyz) < 10:
55
+ return None
56
+
57
+ # Determine voxel size based on point cloud density
58
+ tree = KDTree(xyz)
59
+ mean_spacing = estimate_mean_spacing(xyz, tree)
60
+
61
+ if voxel_size is None:
62
+ # Voxel size based on point density, constrained by min_gap_size
63
+ density_based = mean_spacing * 2.0
64
+ gap_based = min_gap_size / 3.0
65
+ voxel_size = min(density_based, gap_based)
66
+
67
+ voxel_size_float: float = float(voxel_size)
68
+ if voxel_size_float <= 0 or not np.isfinite(voxel_size_float):
69
+ return None
70
+
71
+ voxel_size = voxel_size_float
72
+
73
+ # Compute bounding box with padding
74
+ bbox_min = xyz.min(axis=0) - voxel_size
75
+ bbox_max = xyz.max(axis=0) + voxel_size
76
+
77
+ # Extend in +Z direction for sky space (outdoor scenes)
78
+ if z_extension is None:
79
+ z_range = xyz[:, 2].max() - xyz[:, 2].min()
80
+ z_extension = max(z_range * 0.5, voxel_size * 5)
81
+ bbox_max[2] += z_extension
82
+ bbox_min[2] -= voxel_size * 5 # Small extension for underground
83
+ bbox_size = bbox_max - bbox_min
84
+
85
+ if np.any(bbox_size <= 0) or not np.all(np.isfinite(bbox_size)):
86
+ return None
87
+
88
+ grid_shape = np.ceil(bbox_size / voxel_size).astype(int)
89
+
90
+ if np.any(grid_shape <= 0):
91
+ return None
92
+
93
+ # Cap grid size for performance
94
+ vs: float = voxel_size
95
+ if grid_shape.max() > max_dim:
96
+ scale = float(max_dim / grid_shape.max())
97
+ vs = vs / scale
98
+ grid_shape = np.ceil(bbox_size / vs).astype(int)
99
+ grid_shape = np.minimum(grid_shape, max_dim)
100
+
101
+ # Mark occupied voxels
102
+ point_voxel_indices = ((xyz - bbox_min) / vs).astype(int)
103
+ point_voxel_indices = np.clip(point_voxel_indices, 0, grid_shape - 1)
104
+
105
+ occupied = np.zeros(tuple(grid_shape), dtype=bool)
106
+ for idx in point_voxel_indices:
107
+ occupied[tuple(idx)] = True
108
+
109
+ # Dilate to ensure surface blocks flood fill
110
+ structure = np.ones((3, 3, 3), dtype=bool)
111
+ occupied = binary_dilation(occupied, structure, iterations=1)
112
+
113
+ shape_tuple: tuple[int, int, int] = (
114
+ int(grid_shape[0]),
115
+ int(grid_shape[1]),
116
+ int(grid_shape[2]),
117
+ )
118
+ return occupied, bbox_min, vs, shape_tuple
119
+
120
+
121
+ def compute_hull_mask(
122
+ xyz: np.ndarray,
123
+ bbox_min: np.ndarray,
124
+ voxel_size: float,
125
+ grid_shape: tuple[int, int, int],
126
+ ) -> np.ndarray:
127
+ """Compute a 2D mask of which XY voxel positions are inside the convex hull.
128
+
129
+ Args:
130
+ xyz: Point cloud coordinates
131
+ bbox_min: Bounding box minimum
132
+ voxel_size: Size of each voxel
133
+ grid_shape: Shape of the voxel grid
134
+
135
+ Returns:
136
+ 2D boolean array (nx, ny) where True = inside the XY convex hull.
137
+ """
138
+ nx, ny, _nz = grid_shape
139
+
140
+ xy_points = xyz[:, :2]
141
+
142
+ try:
143
+ hull = ConvexHull(xy_points)
144
+ hull_delaunay = Delaunay(xy_points[hull.vertices])
145
+ except Exception:
146
+ return np.ones((nx, ny), dtype=bool)
147
+
148
+ inside_hull = np.zeros((nx, ny), dtype=bool)
149
+ for ix in range(nx):
150
+ for iy in range(ny):
151
+ world_x = bbox_min[0] + (ix + 0.5) * voxel_size
152
+ world_y = bbox_min[1] + (iy + 0.5) * voxel_size
153
+ inside_hull[ix, iy] = hull_delaunay.find_simplex([world_x, world_y]) >= 0
154
+
155
+ return inside_hull
156
+
157
+
158
+ def ray_propagation_with_bounces(
159
+ occupied: np.ndarray,
160
+ grid_shape: tuple[int, int, int],
161
+ inside_hull: np.ndarray,
162
+ cone_angle_degrees: float,
163
+ ) -> tuple[np.ndarray, np.ndarray]:
164
+ """Propagate EMPTY/SOLID using ray model with cone angles and flood fill.
165
+
166
+ Physical model:
167
+ 1. EMPTY rays shine from +Z (sky) in a cone, then flood-fill from seeds
168
+ 2. SOLID rays shine from -Z (underground) in a cone, then flood-fill
169
+ 3. EMPTY has priority - SOLID flood-fill never overwrites EMPTY
170
+
171
+ Args:
172
+ occupied: Boolean grid of occupied voxels
173
+ grid_shape: Grid dimensions
174
+ inside_hull: 2D mask of XY positions inside hull
175
+ cone_angle_degrees: Half-angle of the cone
176
+
177
+ Returns:
178
+ Tuple of (empty_mask, solid_mask) boolean arrays.
179
+ """
180
+ from scipy.ndimage import label
181
+
182
+ nx, ny, nz = grid_shape
183
+ empty = np.zeros(grid_shape, dtype=bool)
184
+ solid = np.zeros(grid_shape, dtype=bool)
185
+
186
+ # Phase 1: Rays from multiple angles within cone
187
+ tan_angle = np.tan(np.radians(cone_angle_degrees))
188
+ diag = tan_angle * 0.707
189
+
190
+ ray_tilts = [
191
+ (0.0, 0.0),
192
+ (tan_angle, 0.0),
193
+ (-tan_angle, 0.0),
194
+ (0.0, tan_angle),
195
+ (0.0, -tan_angle),
196
+ (diag, diag),
197
+ (diag, -diag),
198
+ (-diag, diag),
199
+ (-diag, -diag),
200
+ ]
201
+
202
+ # EMPTY rays from sky (top-down with cone)
203
+ for dx_rate, dy_rate in ray_tilts:
204
+ for start_ix in range(nx):
205
+ for start_iy in range(ny):
206
+ fx, fy = float(start_ix), float(start_iy)
207
+ for iz in range(nz - 1, -1, -1):
208
+ ix, iy = int(round(fx)), int(round(fy))
209
+ if ix < 0 or ix >= nx or iy < 0 or iy >= ny:
210
+ break
211
+ if occupied[ix, iy, iz]:
212
+ break
213
+ empty[ix, iy, iz] = True
214
+ fx += dx_rate
215
+ fy += dy_rate
216
+
217
+ # SOLID rays from underground (bottom-up with cone), only inside hull
218
+ for dx_rate, dy_rate in ray_tilts:
219
+ for start_ix in range(nx):
220
+ for start_iy in range(ny):
221
+ if not inside_hull[start_ix, start_iy]:
222
+ continue
223
+ fx, fy = float(start_ix), float(start_iy)
224
+ for iz in range(nz):
225
+ ix, iy = int(round(fx)), int(round(fy))
226
+ if ix < 0 or ix >= nx or iy < 0 or iy >= ny:
227
+ break
228
+ if occupied[ix, iy, iz]:
229
+ break
230
+ if not empty[ix, iy, iz]:
231
+ solid[ix, iy, iz] = True
232
+ fx += dx_rate
233
+ fy += dy_rate
234
+
235
+ # Phase 2: Full flood fill from seeds
236
+ directions = [
237
+ (1, 0, 0),
238
+ (-1, 0, 0),
239
+ (0, 1, 0),
240
+ (0, -1, 0),
241
+ (0, 0, 1),
242
+ (0, 0, -1),
243
+ ]
244
+
245
+ # Compute per-column floor
246
+ column_floor = np.full((nx, ny), -1, dtype=int)
247
+ for ix in range(nx):
248
+ for iy in range(ny):
249
+ occupied_z = np.where(occupied[ix, iy, :])[0]
250
+ if len(occupied_z) > 0:
251
+ column_floor[ix, iy] = occupied_z.min()
252
+
253
+ # Flood fill EMPTY
254
+ empty_stack = [tuple(coord) for coord in np.argwhere(empty)]
255
+ while empty_stack:
256
+ ix, iy, iz = empty_stack.pop()
257
+ for dx, dy, dz in directions:
258
+ nx_, ny_, nz_ = ix + dx, iy + dy, iz + dz
259
+ if 0 <= nx_ < nx and 0 <= ny_ < ny and 0 <= nz_ < nz:
260
+ floor_z = column_floor[nx_, ny_]
261
+ if floor_z >= 0 and nz_ < floor_z:
262
+ continue
263
+ if not occupied[nx_, ny_, nz_] and not empty[nx_, ny_, nz_]:
264
+ empty[nx_, ny_, nz_] = True
265
+ empty_stack.append((nx_, ny_, nz_))
266
+
267
+ # Filter EMPTY by sky connectivity
268
+ labeled_empty, num_components = label(empty)
269
+ if num_components > 0:
270
+ top_slice = labeled_empty[:, :, -1]
271
+ sky_labels = set(top_slice[top_slice > 0])
272
+ if sky_labels:
273
+ sky_connected = np.isin(labeled_empty, list(sky_labels))
274
+ empty = empty & sky_connected
275
+
276
+ # Remove small isolated EMPTY regions
277
+ labeled_empty, num_components = label(empty)
278
+ if num_components > 0:
279
+ component_sizes = np.bincount(labeled_empty.ravel())
280
+ min_component_voxels = max(10, (nx * ny) // 100)
281
+ large_enough = component_sizes >= min_component_voxels
282
+ large_enough[0] = False
283
+ keep_mask = large_enough[labeled_empty]
284
+ empty = empty & keep_mask
285
+
286
+ # Flood fill SOLID
287
+ solid_stack = [tuple(coord) for coord in np.argwhere(solid)]
288
+ while solid_stack:
289
+ ix, iy, iz = solid_stack.pop()
290
+ for dx, dy, dz in directions:
291
+ nx_, ny_, nz_ = ix + dx, iy + dy, iz + dz
292
+ if 0 <= nx_ < nx and 0 <= ny_ < ny and 0 <= nz_ < nz:
293
+ if (
294
+ not occupied[nx_, ny_, nz_]
295
+ and not empty[nx_, ny_, nz_]
296
+ and not solid[nx_, ny_, nz_]
297
+ ):
298
+ solid[nx_, ny_, nz_] = True
299
+ solid_stack.append((nx_, ny_, nz_))
300
+
301
+ return empty, solid
302
+
303
+
304
+ def greedy_2d_mesh(mask_2d: np.ndarray) -> list[tuple[int, int, int, int]]:
305
+ """Decompose a 2D boolean mask into axis-aligned rectangles.
306
+
307
+ Uses a greedy algorithm: find first True voxel, expand as far as
308
+ possible in X, then Y, record rectangle, clear voxels, repeat.
309
+
310
+ Args:
311
+ mask_2d: 2D boolean array to decompose
312
+
313
+ Returns:
314
+ List of (x_min, x_max, y_min, y_max) rectangles (exclusive max).
315
+ """
316
+ mask = mask_2d.copy()
317
+ boxes: list[tuple[int, int, int, int]] = []
318
+
319
+ while mask.any():
320
+ coords = np.argwhere(mask)
321
+ if len(coords) == 0:
322
+ break
323
+ x, y = coords[0]
324
+
325
+ x_max = x
326
+ while x_max + 1 < mask.shape[0] and mask[x_max + 1, y]:
327
+ x_max += 1
328
+
329
+ y_max = y
330
+ while y_max + 1 < mask.shape[1]:
331
+ if mask[x : x_max + 1, y_max + 1].all():
332
+ y_max += 1
333
+ else:
334
+ break
335
+
336
+ boxes.append((x, x_max + 1, y, y_max + 1))
337
+ mask[x : x_max + 1, y : y_max + 1] = False
338
+
339
+ return boxes
@@ -0,0 +1,80 @@
1
+ # ABOUTME: Voxel region algorithm for SOLID region detection
2
+ # ABOUTME: Uses ray propagation from underground to identify solid material
3
+
4
+ import numpy as np
5
+
6
+ from sdf_sampler.algorithms.flood_fill import (
7
+ _generate_boxes_from_mask,
8
+ _generate_samples_from_mask,
9
+ )
10
+ from sdf_sampler.algorithms.voxel_grid import (
11
+ build_voxel_grid,
12
+ compute_hull_mask,
13
+ ray_propagation_with_bounces,
14
+ )
15
+ from sdf_sampler.config import AutoAnalysisOptions
16
+ from sdf_sampler.models.analysis import AlgorithmType, GeneratedConstraint
17
+ from sdf_sampler.models.constraints import SignConvention
18
+
19
+
20
+ def generate_voxel_region_constraints(
21
+ xyz: np.ndarray,
22
+ normals: np.ndarray | None,
23
+ options: AutoAnalysisOptions,
24
+ ) -> list[GeneratedConstraint]:
25
+ """Generate SOLID constraints for underground regions.
26
+
27
+ Uses directional Z-ray propagation: SOLID propagates up from Z_min
28
+ until hitting the surface. Only voxels inside the 2D convex hull
29
+ are marked SOLID.
30
+
31
+ Args:
32
+ xyz: Point cloud positions (N, 3)
33
+ normals: Point normals (N, 3) or None
34
+ options: Algorithm options
35
+
36
+ Returns:
37
+ List of GeneratedConstraint objects
38
+ """
39
+ constraints: list[GeneratedConstraint] = []
40
+
41
+ grid_result = build_voxel_grid(xyz, options.min_gap_size, options.max_grid_dim)
42
+ if grid_result is None:
43
+ return constraints
44
+
45
+ occupied, bbox_min, voxel_size, grid_shape = grid_result
46
+ _nx, _ny, nz = grid_shape
47
+
48
+ inside_hull = compute_hull_mask(xyz, bbox_min, voxel_size, grid_shape)
49
+
50
+ _, solid_mask = ray_propagation_with_bounces(
51
+ occupied, grid_shape, inside_hull, options.cone_angle
52
+ )
53
+
54
+ output_mode = options.voxel_regions_output.lower()
55
+
56
+ if output_mode in ("samples", "both"):
57
+ sample_constraints = _generate_samples_from_mask(
58
+ solid_mask,
59
+ bbox_min,
60
+ voxel_size,
61
+ xyz,
62
+ options.voxel_regions_sample_count,
63
+ SignConvention.SOLID,
64
+ AlgorithmType.VOXEL_REGIONS,
65
+ )
66
+ constraints.extend(sample_constraints)
67
+
68
+ if output_mode in ("boxes", "both"):
69
+ box_constraints = _generate_boxes_from_mask(
70
+ solid_mask,
71
+ bbox_min,
72
+ voxel_size,
73
+ nz,
74
+ options,
75
+ SignConvention.SOLID,
76
+ AlgorithmType.VOXEL_REGIONS,
77
+ )
78
+ constraints.extend(box_constraints)
79
+
80
+ return constraints