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.
sdf_sampler/sampler.py ADDED
@@ -0,0 +1,439 @@
1
+ # ABOUTME: SDFSampler class for generating training samples from constraints
2
+ # ABOUTME: Converts constraints to survi-compatible training data
3
+
4
+ from pathlib import Path
5
+ from typing import Any
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+ from scipy.spatial import KDTree
10
+
11
+ from sdf_sampler.config import SamplerConfig
12
+ from sdf_sampler.models.constraints import (
13
+ BoxConstraint,
14
+ BrushStrokeConstraint,
15
+ HalfspaceConstraint,
16
+ PocketConstraint,
17
+ RayCarveConstraint,
18
+ SamplePointConstraint,
19
+ SeedPropagationConstraint,
20
+ SignConvention,
21
+ SliceSelectionConstraint,
22
+ SphereConstraint,
23
+ )
24
+ from sdf_sampler.models.samples import SamplingStrategy, TrainingSample
25
+ from sdf_sampler.sampling.box import sample_box, sample_box_inverse_square
26
+ from sdf_sampler.sampling.brush import sample_brush_stroke
27
+ from sdf_sampler.sampling.ray_carve import sample_ray_carve
28
+ from sdf_sampler.sampling.sphere import sample_sphere
29
+
30
+
31
+ class SDFSampler:
32
+ """Generate training samples from constraints for SDF learning.
33
+
34
+ Converts spatial constraints (boxes, spheres, etc.) into training samples
35
+ with position, signed distance, and optional normals.
36
+
37
+ Example:
38
+ >>> sampler = SDFSampler()
39
+ >>> samples = sampler.generate(
40
+ ... xyz=points,
41
+ ... normals=normals,
42
+ ... constraints=result.constraints,
43
+ ... )
44
+ >>> sampler.export_parquet(samples, "training.parquet")
45
+
46
+ Strategies:
47
+ - CONSTANT: Fixed samples per constraint
48
+ - DENSITY: Samples proportional to constraint volume
49
+ - INVERSE_SQUARE: More samples near surface, fewer far away
50
+ """
51
+
52
+ def __init__(self, config: SamplerConfig | None = None):
53
+ """Initialize the sampler.
54
+
55
+ Args:
56
+ config: Optional configuration. Uses defaults if not provided.
57
+ """
58
+ self.config = config or SamplerConfig()
59
+
60
+ def generate(
61
+ self,
62
+ xyz: np.ndarray,
63
+ constraints: list[dict[str, Any]],
64
+ normals: np.ndarray | None = None,
65
+ total_samples: int | None = None,
66
+ strategy: str | SamplingStrategy = SamplingStrategy.INVERSE_SQUARE,
67
+ seed: int | None = None,
68
+ ) -> list[TrainingSample]:
69
+ """Generate training samples from constraints.
70
+
71
+ Args:
72
+ xyz: Point cloud positions (N, 3) for distance computation
73
+ constraints: List of constraint dicts (from analyzer.analyze().constraints)
74
+ normals: Optional point normals (N, 3)
75
+ total_samples: Total samples to generate (default from config)
76
+ strategy: Sampling strategy (CONSTANT, DENSITY, or INVERSE_SQUARE)
77
+ seed: Random seed for reproducibility
78
+
79
+ Returns:
80
+ List of TrainingSample objects
81
+
82
+ Example:
83
+ >>> samples = sampler.generate(
84
+ ... xyz=points,
85
+ ... constraints=result.constraints,
86
+ ... strategy="inverse_square",
87
+ ... total_samples=50000,
88
+ ... )
89
+ """
90
+ xyz = np.asarray(xyz)
91
+ if normals is not None:
92
+ normals = np.asarray(normals)
93
+
94
+ if total_samples is None:
95
+ total_samples = self.config.total_samples
96
+
97
+ if isinstance(strategy, str):
98
+ strategy = SamplingStrategy(strategy)
99
+
100
+ rng = np.random.default_rng(seed if seed is not None else self.config.seed)
101
+
102
+ # Build KD-tree for inverse_square strategy
103
+ surface_tree = None
104
+ if strategy == SamplingStrategy.INVERSE_SQUARE:
105
+ surface_tree = KDTree(xyz)
106
+
107
+ samples: list[TrainingSample] = []
108
+
109
+ for constraint_dict in constraints:
110
+ # Convert dict to typed constraint
111
+ constraint = self._parse_constraint(constraint_dict)
112
+ if constraint is None:
113
+ continue
114
+
115
+ n_samples = self._compute_sample_count(constraint, strategy)
116
+
117
+ if isinstance(constraint, BoxConstraint):
118
+ if strategy == SamplingStrategy.INVERSE_SQUARE and surface_tree is not None:
119
+ samples.extend(
120
+ sample_box_inverse_square(
121
+ constraint,
122
+ rng,
123
+ self.config.near_band,
124
+ n_samples,
125
+ surface_tree,
126
+ self.config.inverse_square_falloff,
127
+ )
128
+ )
129
+ else:
130
+ samples.extend(
131
+ sample_box(constraint, rng, self.config.near_band, n_samples)
132
+ )
133
+ elif isinstance(constraint, SphereConstraint):
134
+ samples.extend(
135
+ sample_sphere(constraint, rng, self.config.near_band, n_samples)
136
+ )
137
+ elif isinstance(constraint, HalfspaceConstraint):
138
+ samples.extend(
139
+ self._sample_halfspace(constraint, xyz, rng, n_samples)
140
+ )
141
+ elif isinstance(constraint, BrushStrokeConstraint):
142
+ samples.extend(
143
+ sample_brush_stroke(constraint, rng, self.config.near_band, n_samples)
144
+ )
145
+ elif isinstance(constraint, SeedPropagationConstraint):
146
+ samples.extend(self._sample_propagated(constraint, xyz, normals))
147
+ elif isinstance(constraint, RayCarveConstraint):
148
+ samples.extend(sample_ray_carve(constraint, rng, n_samples))
149
+ elif isinstance(constraint, PocketConstraint):
150
+ samples.extend(self._sample_pocket(constraint, rng, n_samples))
151
+ elif isinstance(constraint, SliceSelectionConstraint):
152
+ samples.extend(self._sample_slice_selection(constraint, xyz, normals))
153
+ elif isinstance(constraint, SamplePointConstraint):
154
+ samples.extend(self._sample_sample_point(constraint))
155
+
156
+ return samples
157
+
158
+ def to_dataframe(self, samples: list[TrainingSample]) -> pd.DataFrame:
159
+ """Convert samples to pandas DataFrame.
160
+
161
+ Args:
162
+ samples: List of TrainingSample objects
163
+
164
+ Returns:
165
+ DataFrame with columns: x, y, z, phi, nx, ny, nz, weight, source, is_surface, is_free
166
+ """
167
+ return pd.DataFrame([s.to_dict() for s in samples])
168
+
169
+ def export_parquet(
170
+ self,
171
+ samples: list[TrainingSample],
172
+ path: str | Path,
173
+ ) -> Path:
174
+ """Export samples to Parquet file.
175
+
176
+ Args:
177
+ samples: List of TrainingSample objects
178
+ path: Output file path
179
+
180
+ Returns:
181
+ Path to created file
182
+ """
183
+ path = Path(path)
184
+ df = self.to_dataframe(samples)
185
+ df.to_parquet(path)
186
+ return path
187
+
188
+ def _parse_constraint(self, constraint_dict: dict[str, Any]) -> Any:
189
+ """Parse a constraint dict into a typed constraint object."""
190
+ c_type = constraint_dict.get("type")
191
+
192
+ if c_type == "box":
193
+ return BoxConstraint(**constraint_dict)
194
+ elif c_type == "sphere":
195
+ return SphereConstraint(**constraint_dict)
196
+ elif c_type == "halfspace":
197
+ return HalfspaceConstraint(**constraint_dict)
198
+ elif c_type == "brush_stroke":
199
+ return BrushStrokeConstraint(**constraint_dict)
200
+ elif c_type == "seed_propagation":
201
+ return SeedPropagationConstraint(**constraint_dict)
202
+ elif c_type == "ray_carve":
203
+ return RayCarveConstraint(**constraint_dict)
204
+ elif c_type == "pocket":
205
+ return PocketConstraint(**constraint_dict)
206
+ elif c_type == "slice_selection":
207
+ return SliceSelectionConstraint(**constraint_dict)
208
+ elif c_type == "sample_point":
209
+ return SamplePointConstraint(**constraint_dict)
210
+
211
+ return None
212
+
213
+ def _compute_sample_count(
214
+ self,
215
+ constraint: Any,
216
+ strategy: SamplingStrategy,
217
+ ) -> int:
218
+ """Compute number of samples for a constraint based on strategy."""
219
+ if strategy == SamplingStrategy.CONSTANT:
220
+ return self.config.samples_per_primitive
221
+
222
+ elif strategy == SamplingStrategy.DENSITY:
223
+ volume = self._compute_constraint_volume(constraint)
224
+ return max(10, int(volume * self.config.samples_per_cubic_meter))
225
+
226
+ elif strategy == SamplingStrategy.INVERSE_SQUARE:
227
+ return self.config.inverse_square_base_samples
228
+
229
+ return self.config.samples_per_primitive
230
+
231
+ def _compute_constraint_volume(self, constraint: Any) -> float:
232
+ """Compute approximate volume of a constraint in cubic meters."""
233
+ if isinstance(constraint, BoxConstraint):
234
+ half = np.array(constraint.half_extents)
235
+ return float(np.prod(half * 2))
236
+
237
+ elif isinstance(constraint, SphereConstraint):
238
+ return (4 / 3) * np.pi * (constraint.radius**3)
239
+
240
+ elif isinstance(constraint, PocketConstraint):
241
+ voxel_size = 0.01
242
+ return constraint.voxel_count * (voxel_size**3)
243
+
244
+ elif isinstance(constraint, BrushStrokeConstraint):
245
+ n_points = len(constraint.stroke_points)
246
+ sphere_vol = (4 / 3) * np.pi * (constraint.radius**3)
247
+ return n_points * sphere_vol * 0.5
248
+
249
+ return 0.001
250
+
251
+ def _sample_halfspace(
252
+ self,
253
+ constraint: HalfspaceConstraint,
254
+ xyz: np.ndarray,
255
+ rng: np.random.Generator,
256
+ n_samples: int,
257
+ ) -> list[TrainingSample]:
258
+ """Generate samples from a halfspace constraint."""
259
+ samples = []
260
+ point = np.array(constraint.point)
261
+ normal = np.array(constraint.normal)
262
+ normal /= np.linalg.norm(normal)
263
+
264
+ bounds_low = xyz.min(axis=0)
265
+ bounds_high = xyz.max(axis=0)
266
+
267
+ for _ in range(n_samples):
268
+ sample_point = rng.uniform(bounds_low, bounds_high)
269
+ dist = np.dot(sample_point - point, normal)
270
+
271
+ if constraint.sign == SignConvention.EMPTY:
272
+ phi = abs(dist) + self.config.near_band
273
+ else:
274
+ phi = -(abs(dist) + self.config.near_band)
275
+
276
+ samples.append(
277
+ TrainingSample(
278
+ x=float(sample_point[0]),
279
+ y=float(sample_point[1]),
280
+ z=float(sample_point[2]),
281
+ phi=phi,
282
+ nx=float(normal[0]),
283
+ ny=float(normal[1]),
284
+ nz=float(normal[2]),
285
+ weight=constraint.weight,
286
+ source=f"halfspace_{constraint.sign.value}",
287
+ is_surface=False,
288
+ is_free=constraint.sign == SignConvention.EMPTY,
289
+ )
290
+ )
291
+
292
+ return samples
293
+
294
+ def _sample_propagated(
295
+ self,
296
+ constraint: SeedPropagationConstraint,
297
+ xyz: np.ndarray,
298
+ normals: np.ndarray | None,
299
+ ) -> list[TrainingSample]:
300
+ """Generate samples from propagated seed."""
301
+ samples = []
302
+
303
+ for i, idx in enumerate(constraint.propagated_indices):
304
+ if idx >= len(xyz):
305
+ continue
306
+
307
+ point = xyz[idx]
308
+ normal = normals[idx] if normals is not None else [0, 0, 1]
309
+ confidence = constraint.confidences[i] if i < len(constraint.confidences) else 1.0
310
+
311
+ phi = (
312
+ 0.0
313
+ if constraint.sign == SignConvention.SURFACE
314
+ else (-0.01 if constraint.sign == SignConvention.SOLID else 0.01)
315
+ )
316
+
317
+ samples.append(
318
+ TrainingSample(
319
+ x=float(point[0]),
320
+ y=float(point[1]),
321
+ z=float(point[2]),
322
+ phi=phi,
323
+ nx=float(normal[0]),
324
+ ny=float(normal[1]),
325
+ nz=float(normal[2]),
326
+ weight=constraint.weight * confidence,
327
+ source=f"propagated_{constraint.sign.value}",
328
+ is_surface=constraint.sign == SignConvention.SURFACE,
329
+ is_free=constraint.sign == SignConvention.EMPTY,
330
+ )
331
+ )
332
+
333
+ return samples
334
+
335
+ def _sample_pocket(
336
+ self,
337
+ constraint: PocketConstraint,
338
+ rng: np.random.Generator,
339
+ n_samples: int,
340
+ ) -> list[TrainingSample]:
341
+ """Generate samples from a pocket constraint.
342
+
343
+ Note: Without access to voxel data, we sample uniformly within bounds.
344
+ For full pocket sampling, use the original SDF Labeler backend.
345
+ """
346
+ samples = []
347
+
348
+ if constraint.sign == SignConvention.SOLID:
349
+ phi = -0.05
350
+ else:
351
+ phi = 0.05
352
+
353
+ bounds_low = np.array(constraint.bounds_low)
354
+ bounds_high = np.array(constraint.bounds_high)
355
+
356
+ for _ in range(n_samples):
357
+ point = rng.uniform(bounds_low, bounds_high)
358
+
359
+ samples.append(
360
+ TrainingSample(
361
+ x=float(point[0]),
362
+ y=float(point[1]),
363
+ z=float(point[2]),
364
+ phi=phi,
365
+ nx=0.0,
366
+ ny=0.0,
367
+ nz=0.0,
368
+ weight=constraint.weight,
369
+ source=f"pocket_{constraint.sign.value}",
370
+ is_surface=False,
371
+ is_free=constraint.sign == SignConvention.EMPTY,
372
+ )
373
+ )
374
+
375
+ return samples
376
+
377
+ def _sample_slice_selection(
378
+ self,
379
+ constraint: SliceSelectionConstraint,
380
+ xyz: np.ndarray,
381
+ normals: np.ndarray | None,
382
+ ) -> list[TrainingSample]:
383
+ """Generate samples from slice selection constraint."""
384
+ samples = []
385
+
386
+ for idx in constraint.point_indices:
387
+ if idx >= len(xyz):
388
+ continue
389
+
390
+ point = xyz[idx]
391
+ normal = normals[idx] if normals is not None else [0, 0, 1]
392
+
393
+ if constraint.sign == SignConvention.SURFACE:
394
+ phi = 0.0
395
+ elif constraint.sign == SignConvention.SOLID:
396
+ phi = -0.01
397
+ else:
398
+ phi = 0.01
399
+
400
+ samples.append(
401
+ TrainingSample(
402
+ x=float(point[0]),
403
+ y=float(point[1]),
404
+ z=float(point[2]),
405
+ phi=phi,
406
+ nx=float(normal[0]),
407
+ ny=float(normal[1]),
408
+ nz=float(normal[2]),
409
+ weight=constraint.weight,
410
+ source=f"slice_{constraint.sign.value}",
411
+ is_surface=constraint.sign == SignConvention.SURFACE,
412
+ is_free=constraint.sign == SignConvention.EMPTY,
413
+ )
414
+ )
415
+
416
+ return samples
417
+
418
+ def _sample_sample_point(
419
+ self,
420
+ constraint: SamplePointConstraint,
421
+ ) -> list[TrainingSample]:
422
+ """Convert a sample_point constraint directly to a training sample."""
423
+ phi = constraint.distance
424
+
425
+ return [
426
+ TrainingSample(
427
+ x=float(constraint.position[0]),
428
+ y=float(constraint.position[1]),
429
+ z=float(constraint.position[2]),
430
+ phi=phi,
431
+ nx=0.0,
432
+ ny=0.0,
433
+ nz=0.0,
434
+ weight=constraint.weight,
435
+ source=f"idw_{constraint.sign.value}",
436
+ is_surface=constraint.sign == SignConvention.SURFACE,
437
+ is_free=constraint.sign == SignConvention.EMPTY,
438
+ )
439
+ ]
@@ -0,0 +1,15 @@
1
+ # ABOUTME: Sampling module exports
2
+ # ABOUTME: Provides sampling functions for different constraint types
3
+
4
+ from sdf_sampler.sampling.box import sample_box, sample_box_inverse_square
5
+ from sdf_sampler.sampling.brush import sample_brush_stroke
6
+ from sdf_sampler.sampling.ray_carve import sample_ray_carve
7
+ from sdf_sampler.sampling.sphere import sample_sphere
8
+
9
+ __all__ = [
10
+ "sample_box",
11
+ "sample_box_inverse_square",
12
+ "sample_sphere",
13
+ "sample_brush_stroke",
14
+ "sample_ray_carve",
15
+ ]
@@ -0,0 +1,131 @@
1
+ # ABOUTME: Box constraint sampling functions
2
+ # ABOUTME: Generates training samples from axis-aligned box constraints
3
+
4
+ from typing import Any
5
+
6
+ import numpy as np
7
+
8
+ from sdf_sampler.models.constraints import BoxConstraint, SignConvention
9
+ from sdf_sampler.models.samples import TrainingSample
10
+
11
+
12
+ def sample_box(
13
+ constraint: BoxConstraint,
14
+ rng: np.random.Generator,
15
+ near_band: float,
16
+ n_samples: int,
17
+ ) -> list[TrainingSample]:
18
+ """Generate samples from a box constraint.
19
+
20
+ Samples points near the box surfaces with appropriate SDF values.
21
+
22
+ Args:
23
+ constraint: Box constraint to sample
24
+ rng: Random number generator
25
+ near_band: Near-band width for offset
26
+ n_samples: Number of samples to generate
27
+
28
+ Returns:
29
+ List of TrainingSample objects
30
+ """
31
+ samples = []
32
+ center = np.array(constraint.center)
33
+ half = np.array(constraint.half_extents)
34
+
35
+ for _ in range(n_samples):
36
+ # Random point near box surface
37
+ face = rng.integers(0, 6)
38
+ point = center + rng.uniform(-1, 1, 3) * half
39
+
40
+ # Clamp to face
41
+ axis = face // 2
42
+ sign = 1 if face % 2 else -1
43
+ point[axis] = center[axis] + sign * half[axis]
44
+
45
+ # Offset based on sign convention
46
+ offset = near_band if constraint.sign == SignConvention.EMPTY else -near_band
47
+ normal = np.zeros(3)
48
+ normal[axis] = sign
49
+ point = point + offset * normal
50
+
51
+ phi = offset
52
+
53
+ samples.append(
54
+ TrainingSample(
55
+ x=float(point[0]),
56
+ y=float(point[1]),
57
+ z=float(point[2]),
58
+ phi=phi,
59
+ nx=float(normal[0]),
60
+ ny=float(normal[1]),
61
+ nz=float(normal[2]),
62
+ weight=constraint.weight,
63
+ source=f"box_{constraint.sign.value}",
64
+ is_surface=False,
65
+ is_free=constraint.sign == SignConvention.EMPTY,
66
+ )
67
+ )
68
+
69
+ return samples
70
+
71
+
72
+ def sample_box_inverse_square(
73
+ constraint: BoxConstraint,
74
+ rng: np.random.Generator,
75
+ near_band: float,
76
+ n_samples: int,
77
+ surface_tree: Any,
78
+ falloff: float = 2.0,
79
+ ) -> list[TrainingSample]:
80
+ """Generate samples from a box with inverse-square density distribution.
81
+
82
+ Samples more points near the surface (point cloud) and fewer far away.
83
+
84
+ Args:
85
+ constraint: Box constraint to sample
86
+ rng: Random number generator
87
+ near_band: Near-band width for offset
88
+ n_samples: Number of samples to generate
89
+ surface_tree: KDTree of surface points for distance computation
90
+ falloff: Falloff exponent (higher = faster falloff)
91
+
92
+ Returns:
93
+ List of TrainingSample objects
94
+ """
95
+ samples = []
96
+ center = np.array(constraint.center)
97
+ half = np.array(constraint.half_extents)
98
+
99
+ n_candidates = n_samples * 10
100
+
101
+ for _ in range(n_candidates):
102
+ if len(samples) >= n_samples:
103
+ break
104
+
105
+ point = center + rng.uniform(-1, 1, 3) * half
106
+ dist_to_surface, _ = surface_tree.query(point, k=1)
107
+
108
+ min_dist = max(dist_to_surface, near_band * 0.1)
109
+ weight = (near_band / min_dist) ** falloff
110
+
111
+ if rng.random() < min(1.0, weight):
112
+ offset = near_band if constraint.sign == SignConvention.EMPTY else -near_band
113
+ phi = offset
114
+
115
+ samples.append(
116
+ TrainingSample(
117
+ x=float(point[0]),
118
+ y=float(point[1]),
119
+ z=float(point[2]),
120
+ phi=phi,
121
+ nx=0.0,
122
+ ny=0.0,
123
+ nz=0.0,
124
+ weight=constraint.weight,
125
+ source=f"box_{constraint.sign.value}_inv_sq",
126
+ is_surface=False,
127
+ is_free=constraint.sign == SignConvention.EMPTY,
128
+ )
129
+ )
130
+
131
+ return samples
@@ -0,0 +1,63 @@
1
+ # ABOUTME: Brush stroke constraint sampling functions
2
+ # ABOUTME: Generates training samples from volumetric brush strokes
3
+
4
+ import numpy as np
5
+
6
+ from sdf_sampler.models.constraints import BrushStrokeConstraint, SignConvention
7
+ from sdf_sampler.models.samples import TrainingSample
8
+
9
+
10
+ def sample_brush_stroke(
11
+ constraint: BrushStrokeConstraint,
12
+ rng: np.random.Generator,
13
+ near_band: float,
14
+ n_samples_per_point: int,
15
+ ) -> list[TrainingSample]:
16
+ """Generate samples from brush stroke volume.
17
+
18
+ Samples uniformly within the tube-like stroke region.
19
+
20
+ Args:
21
+ constraint: Brush stroke constraint to sample
22
+ rng: Random number generator
23
+ near_band: Near-band width for phi calculation
24
+ n_samples_per_point: Number of samples per stroke point
25
+
26
+ Returns:
27
+ List of TrainingSample objects
28
+ """
29
+ samples = []
30
+ stroke_points = np.array(constraint.stroke_points)
31
+ radius = constraint.radius
32
+
33
+ if constraint.sign == SignConvention.SURFACE:
34
+ phi = 0.0
35
+ elif constraint.sign == SignConvention.SOLID:
36
+ phi = -near_band
37
+ else:
38
+ phi = near_band
39
+
40
+ for center in stroke_points:
41
+ for _ in range(n_samples_per_point):
42
+ direction = rng.standard_normal(3)
43
+ direction /= np.linalg.norm(direction)
44
+ distance = rng.uniform(0, radius)
45
+ point = center + distance * direction
46
+
47
+ samples.append(
48
+ TrainingSample(
49
+ x=float(point[0]),
50
+ y=float(point[1]),
51
+ z=float(point[2]),
52
+ phi=phi,
53
+ nx=0.0,
54
+ ny=0.0,
55
+ nz=0.0,
56
+ weight=constraint.weight,
57
+ source=f"brush_{constraint.sign.value}",
58
+ is_surface=constraint.sign == SignConvention.SURFACE,
59
+ is_free=constraint.sign == SignConvention.EMPTY,
60
+ )
61
+ )
62
+
63
+ return samples