copick-utils 0.6.0__py3-none-any.whl → 1.0.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 (72) hide show
  1. copick_utils/__init__.py +1 -0
  2. copick_utils/cli/__init__.py +33 -0
  3. copick_utils/cli/clipmesh.py +161 -0
  4. copick_utils/cli/clippicks.py +154 -0
  5. copick_utils/cli/clipseg.py +163 -0
  6. copick_utils/cli/conversion_commands.py +32 -0
  7. copick_utils/cli/enclosed.py +191 -0
  8. copick_utils/cli/filter_components.py +166 -0
  9. copick_utils/cli/fit_spline.py +191 -0
  10. copick_utils/cli/hull.py +138 -0
  11. copick_utils/cli/input_output_selection.py +76 -0
  12. copick_utils/cli/logical_commands.py +29 -0
  13. copick_utils/cli/mesh2picks.py +170 -0
  14. copick_utils/cli/mesh2seg.py +167 -0
  15. copick_utils/cli/meshop.py +262 -0
  16. copick_utils/cli/picks2ellipsoid.py +171 -0
  17. copick_utils/cli/picks2mesh.py +181 -0
  18. copick_utils/cli/picks2plane.py +156 -0
  19. copick_utils/cli/picks2seg.py +134 -0
  20. copick_utils/cli/picks2sphere.py +170 -0
  21. copick_utils/cli/picks2surface.py +164 -0
  22. copick_utils/cli/picksin.py +146 -0
  23. copick_utils/cli/picksout.py +148 -0
  24. copick_utils/cli/processing_commands.py +18 -0
  25. copick_utils/cli/seg2mesh.py +135 -0
  26. copick_utils/cli/seg2picks.py +128 -0
  27. copick_utils/cli/segop.py +248 -0
  28. copick_utils/cli/separate_components.py +155 -0
  29. copick_utils/cli/skeletonize.py +164 -0
  30. copick_utils/cli/util.py +580 -0
  31. copick_utils/cli/validbox.py +155 -0
  32. copick_utils/converters/__init__.py +35 -0
  33. copick_utils/converters/converter_common.py +543 -0
  34. copick_utils/converters/ellipsoid_from_picks.py +335 -0
  35. copick_utils/converters/lazy_converter.py +576 -0
  36. copick_utils/converters/mesh_from_picks.py +209 -0
  37. copick_utils/converters/mesh_from_segmentation.py +119 -0
  38. copick_utils/converters/picks_from_mesh.py +542 -0
  39. copick_utils/converters/picks_from_segmentation.py +168 -0
  40. copick_utils/converters/plane_from_picks.py +251 -0
  41. copick_utils/converters/segmentation_from_mesh.py +291 -0
  42. copick_utils/{segmentation → converters}/segmentation_from_picks.py +151 -15
  43. copick_utils/converters/sphere_from_picks.py +306 -0
  44. copick_utils/converters/surface_from_picks.py +337 -0
  45. copick_utils/features/skimage.py +33 -13
  46. copick_utils/io/readers.py +62 -59
  47. copick_utils/io/writers.py +9 -14
  48. copick_utils/logical/__init__.py +43 -0
  49. copick_utils/logical/distance_operations.py +604 -0
  50. copick_utils/logical/enclosed_operations.py +222 -0
  51. copick_utils/logical/mesh_operations.py +443 -0
  52. copick_utils/logical/point_operations.py +303 -0
  53. copick_utils/logical/segmentation_operations.py +399 -0
  54. copick_utils/pickers/grid_picker.py +5 -4
  55. copick_utils/process/__init__.py +47 -0
  56. copick_utils/process/connected_components.py +360 -0
  57. copick_utils/process/filter_components.py +306 -0
  58. copick_utils/process/hull.py +106 -0
  59. copick_utils/process/skeletonize.py +326 -0
  60. copick_utils/process/spline_fitting.py +648 -0
  61. copick_utils/process/validbox.py +333 -0
  62. copick_utils/util/__init__.py +6 -0
  63. copick_utils/util/config_models.py +614 -0
  64. {copick_utils-0.6.0.dist-info → copick_utils-1.0.0.dist-info}/METADATA +38 -12
  65. copick_utils-1.0.0.dist-info/RECORD +71 -0
  66. {copick_utils-0.6.0.dist-info → copick_utils-1.0.0.dist-info}/WHEEL +1 -1
  67. copick_utils-1.0.0.dist-info/entry_points.txt +29 -0
  68. copick_utils/__about__.py +0 -4
  69. copick_utils/segmentation/picks_from_segmentation.py +0 -67
  70. copick_utils-0.6.0.dist-info/RECORD +0 -15
  71. /copick_utils/{segmentation → io}/__init__.py +0 -0
  72. /copick_utils-0.6.0.dist-info/LICENSE.txt → /copick_utils-1.0.0.dist-info/licenses/LICENSE +0 -0
@@ -0,0 +1,604 @@
1
+ """Distance-based limiting operations for meshes, segmentations, and picks."""
2
+
3
+ from typing import TYPE_CHECKING, Dict, Optional, Tuple
4
+
5
+ import numpy as np
6
+ import trimesh as tm
7
+ from copick.util.log import get_logger
8
+
9
+ from copick_utils.converters.converter_common import (
10
+ create_batch_converter,
11
+ create_batch_worker,
12
+ store_mesh_with_stats,
13
+ )
14
+ from copick_utils.converters.lazy_converter import create_lazy_batch_converter
15
+
16
+ if TYPE_CHECKING:
17
+ from copick.models import CopickMesh, CopickPicks, CopickRun, CopickSegmentation
18
+
19
+ logger = get_logger(__name__)
20
+
21
+
22
+ def _create_distance_field_from_segmentation(segmentation_array: np.ndarray, voxel_spacing: float) -> np.ndarray:
23
+ """
24
+ Create Euclidean distance field from reference segmentation using distance transform.
25
+
26
+ Args:
27
+ segmentation_array: Binary reference segmentation array
28
+ voxel_spacing: Voxel spacing of the segmentation
29
+
30
+ Returns:
31
+ Distance field array with exact Euclidean distances in physical units
32
+ """
33
+ from scipy import ndimage
34
+
35
+ # Convert reference to binary
36
+ binary_ref = (segmentation_array > 0).astype(bool)
37
+
38
+ # Compute distance transform (distances to nearest foreground voxel)
39
+ # We want distances FROM the segmentation, so use the inverse
40
+ distance_field_voxels = ndimage.distance_transform_edt(~binary_ref)
41
+
42
+ # Convert from voxel units to physical units
43
+ distance_field = distance_field_voxels * voxel_spacing
44
+
45
+ return distance_field
46
+
47
+
48
+ def _create_distance_field_from_mesh(
49
+ mesh: tm.Trimesh,
50
+ target_shape: tuple,
51
+ target_voxel_spacing: float,
52
+ mesh_voxel_spacing: float = None,
53
+ ) -> np.ndarray:
54
+ """
55
+ Create Euclidean distance field from reference mesh using voxelization and distance transform.
56
+
57
+ Args:
58
+ mesh: Reference trimesh object
59
+ target_shape: Shape of target array
60
+ target_voxel_spacing: Voxel spacing of target
61
+ mesh_voxel_spacing: Voxel spacing for mesh voxelization (defaults to target_voxel_spacing)
62
+
63
+ Returns:
64
+ Distance field array with exact Euclidean distances in physical units
65
+ """
66
+ if mesh_voxel_spacing is None:
67
+ mesh_voxel_spacing = target_voxel_spacing
68
+
69
+ # Calculate voxelization grid size based on target shape and spacing
70
+ physical_size = np.array(target_shape) * target_voxel_spacing
71
+ voxel_grid_shape = np.ceil(physical_size / mesh_voxel_spacing).astype(int)
72
+
73
+ # Voxelize the mesh
74
+ try:
75
+ # Use trimesh's voxelization
76
+ voxel_grid = mesh.voxelized(pitch=mesh_voxel_spacing)
77
+ voxelized_array = voxel_grid.matrix
78
+ except Exception as e:
79
+ logger.warning(f"Trimesh voxelization failed: {e}. Using fallback method.")
80
+ # Fallback: create a simple voxelization by checking mesh bounds
81
+ bounds = mesh.bounds
82
+ origin = bounds[0]
83
+
84
+ # Create coordinate grids
85
+ x_coords = np.arange(voxel_grid_shape[0]) * mesh_voxel_spacing + origin[0]
86
+ y_coords = np.arange(voxel_grid_shape[1]) * mesh_voxel_spacing + origin[1]
87
+ z_coords = np.arange(voxel_grid_shape[2]) * mesh_voxel_spacing + origin[2]
88
+
89
+ xx, yy, zz = np.meshgrid(x_coords, y_coords, z_coords, indexing="ij")
90
+ points = np.column_stack([xx.ravel(), yy.ravel(), zz.ravel()])
91
+
92
+ # Check which points are inside the mesh
93
+ inside = mesh.contains(points)
94
+ voxelized_array = inside.reshape(voxel_grid_shape)
95
+
96
+ # Resample to target resolution if needed
97
+ if mesh_voxel_spacing != target_voxel_spacing:
98
+ from scipy.ndimage import zoom
99
+
100
+ zoom_factor = mesh_voxel_spacing / target_voxel_spacing
101
+ voxelized_array = zoom(voxelized_array.astype(float), zoom_factor, order=0) > 0.5
102
+
103
+ # Ensure shape matches target
104
+ if voxelized_array.shape != target_shape:
105
+ # Crop or pad to match target shape
106
+ result = np.zeros(target_shape, dtype=bool)
107
+
108
+ # Calculate valid regions for copying
109
+ copy_shape = np.minimum(voxelized_array.shape, target_shape)
110
+ slices_src = tuple(slice(0, s) for s in copy_shape)
111
+ slices_dst = tuple(slice(0, s) for s in copy_shape)
112
+
113
+ result[slices_dst] = voxelized_array[slices_src]
114
+ voxelized_array = result
115
+
116
+ # Create distance field using distance transform
117
+ return _create_distance_field_from_segmentation(voxelized_array.astype(np.uint8), target_voxel_spacing)
118
+
119
+
120
+ def limit_mesh_by_distance(
121
+ mesh: "CopickMesh",
122
+ run: "CopickRun",
123
+ output_object_name: str,
124
+ output_session_id: str,
125
+ output_user_id: str,
126
+ reference_mesh: Optional["CopickMesh"] = None,
127
+ reference_segmentation: Optional["CopickSegmentation"] = None,
128
+ max_distance: float = 100.0,
129
+ mesh_voxel_spacing: float = None,
130
+ **kwargs,
131
+ ) -> Optional[Tuple["CopickMesh", Dict[str, int]]]:
132
+ """
133
+ Limit a mesh to vertices within a certain distance of a reference surface.
134
+
135
+ Args:
136
+ mesh: CopickMesh to limit
137
+ reference_mesh: Reference CopickMesh (either this or reference_segmentation must be provided)
138
+ reference_segmentation: Reference CopickSegmentation
139
+ run: CopickRun object
140
+ output_object_name: Name for the output mesh
141
+ output_session_id: Session ID for the output mesh
142
+ output_user_id: User ID for the output mesh
143
+ max_distance: Maximum distance from reference surface
144
+ mesh_voxel_spacing: Voxel spacing for mesh voxelization (defaults to 10.0)
145
+ **kwargs: Additional keyword arguments
146
+
147
+ Returns:
148
+ Tuple of (CopickMesh object, stats dict) or None if operation failed.
149
+ Stats dict contains 'vertices_created' and 'faces_created'.
150
+ """
151
+ try:
152
+ if reference_mesh is None and reference_segmentation is None:
153
+ raise ValueError("Either reference_mesh or reference_segmentation must be provided")
154
+
155
+ # Get the target mesh
156
+ target_mesh = mesh.mesh
157
+ if target_mesh is None:
158
+ logger.error("Could not load target mesh data")
159
+ return None
160
+
161
+ # Handle Scene objects
162
+ if isinstance(target_mesh, tm.Scene):
163
+ if len(target_mesh.geometry) == 0:
164
+ logger.error("Target mesh is empty")
165
+ return None
166
+ target_mesh = tm.util.concatenate(list(target_mesh.geometry.values()))
167
+
168
+ # Create distance field from reference
169
+ # Use mesh bounds to define coordinate space
170
+ mesh_bounds = np.array([target_mesh.vertices.min(axis=0), target_mesh.vertices.max(axis=0)])
171
+
172
+ # Add padding for max_distance
173
+ padding = max_distance * 1.1
174
+ mesh_bounds[0] -= padding
175
+ mesh_bounds[1] += padding
176
+
177
+ field_voxel_spacing = mesh_voxel_spacing if mesh_voxel_spacing is not None else 10.0
178
+ field_size = mesh_bounds[1] - mesh_bounds[0]
179
+ field_shape = np.ceil(field_size / field_voxel_spacing).astype(int)
180
+
181
+ if reference_mesh is not None:
182
+ ref_mesh = reference_mesh.mesh
183
+ if ref_mesh is None:
184
+ logger.error("Could not load reference mesh data")
185
+ return None
186
+
187
+ if isinstance(ref_mesh, tm.Scene):
188
+ if len(ref_mesh.geometry) == 0:
189
+ logger.error("Reference mesh is empty")
190
+ return None
191
+ ref_mesh = tm.util.concatenate(list(ref_mesh.geometry.values()))
192
+
193
+ # Create distance field from mesh
194
+ distance_field = _create_distance_field_from_mesh(
195
+ ref_mesh,
196
+ field_shape,
197
+ field_voxel_spacing,
198
+ mesh_voxel_spacing,
199
+ )
200
+
201
+ else: # reference_segmentation is not None
202
+ ref_seg_array = reference_segmentation.numpy()
203
+ if ref_seg_array is None or ref_seg_array.size == 0:
204
+ logger.error("Could not load reference segmentation data")
205
+ return None
206
+
207
+ # Convert segmentation to field coordinate space
208
+ seg_indices = np.array(np.where(ref_seg_array > 0)).T
209
+ seg_physical = seg_indices * reference_segmentation.voxel_size
210
+ field_coords = np.floor((seg_physical - mesh_bounds[0]) / field_voxel_spacing).astype(int)
211
+
212
+ # Create voxelized reference in field space
213
+ voxelized_ref = np.zeros(field_shape, dtype=bool)
214
+ valid_coords = (field_coords >= 0).all(axis=1) & (field_coords < field_shape).all(axis=1)
215
+ if np.any(valid_coords):
216
+ valid_field_coords = field_coords[valid_coords]
217
+ voxelized_ref[valid_field_coords[:, 0], valid_field_coords[:, 1], valid_field_coords[:, 2]] = True
218
+
219
+ distance_field = _create_distance_field_from_segmentation(
220
+ voxelized_ref.astype(np.uint8),
221
+ field_voxel_spacing,
222
+ )
223
+
224
+ # Convert mesh vertex coordinates to field indices
225
+ vertex_field_coords = (target_mesh.vertices - mesh_bounds[0]) / field_voxel_spacing
226
+ vertex_field_indices = np.floor(vertex_field_coords).astype(int)
227
+
228
+ # Check which vertices are within field bounds
229
+ valid_vertices = (vertex_field_indices >= 0).all(axis=1) & (vertex_field_indices < field_shape).all(axis=1)
230
+
231
+ if not np.any(valid_vertices):
232
+ logger.warning("No mesh vertices within distance field bounds")
233
+ return None
234
+
235
+ # Get distances for valid vertices
236
+ valid_indices = vertex_field_indices[valid_vertices]
237
+ vertex_distances = distance_field[valid_indices[:, 0], valid_indices[:, 1], valid_indices[:, 2]]
238
+
239
+ # Find vertices within distance threshold
240
+ distance_valid = vertex_distances <= max_distance
241
+
242
+ # Combine bounds validity and distance validity
243
+ final_valid = np.zeros(len(target_mesh.vertices), dtype=bool)
244
+ final_valid[valid_vertices] = distance_valid
245
+
246
+ if not np.any(final_valid):
247
+ logger.warning(f"No vertices within {max_distance} units of reference surface")
248
+ return None
249
+
250
+ # Create a new mesh with only valid vertices and their faces
251
+ valid_vertex_indices = np.where(final_valid)[0]
252
+
253
+ # Create a mapping from old vertex indices to new ones
254
+ vertex_mapping = {}
255
+ new_vertices = []
256
+ for new_idx, old_idx in enumerate(valid_vertex_indices):
257
+ vertex_mapping[old_idx] = new_idx
258
+ new_vertices.append(target_mesh.vertices[old_idx])
259
+
260
+ new_vertices = np.array(new_vertices)
261
+
262
+ # Filter faces to only include those with all vertices in the valid set
263
+ valid_faces = []
264
+ for face in target_mesh.faces:
265
+ if all(vertex in vertex_mapping for vertex in face):
266
+ new_face = [vertex_mapping[vertex] for vertex in face]
267
+ valid_faces.append(new_face)
268
+
269
+ if len(valid_faces) == 0:
270
+ logger.warning("No valid faces after distance filtering")
271
+ return None
272
+
273
+ new_faces = np.array(valid_faces)
274
+
275
+ # Create the limited mesh
276
+ limited_mesh = tm.Trimesh(vertices=new_vertices, faces=new_faces)
277
+
278
+ # Store the result
279
+ copick_mesh, stats = store_mesh_with_stats(
280
+ run=run,
281
+ mesh=limited_mesh,
282
+ object_name=output_object_name,
283
+ session_id=output_session_id,
284
+ user_id=output_user_id,
285
+ shape_name="distance-limited mesh",
286
+ )
287
+
288
+ logger.info(f"Limited mesh to {stats['vertices_created']} vertices within {max_distance} units")
289
+ return copick_mesh, stats
290
+
291
+ except Exception as e:
292
+ logger.error(f"Error limiting mesh by distance: {e}")
293
+ return None
294
+
295
+
296
+ def limit_segmentation_by_distance(
297
+ segmentation: "CopickSegmentation",
298
+ run: "CopickRun",
299
+ output_object_name: str,
300
+ output_session_id: str,
301
+ output_user_id: str,
302
+ reference_mesh: Optional["CopickMesh"] = None,
303
+ reference_segmentation: Optional["CopickSegmentation"] = None,
304
+ max_distance: float = 100.0,
305
+ voxel_spacing: float = 10.0,
306
+ mesh_voxel_spacing: float = None,
307
+ is_multilabel: bool = False,
308
+ **kwargs,
309
+ ) -> Optional[Tuple["CopickSegmentation", Dict[str, int]]]:
310
+ """
311
+ Limit a segmentation to voxels within a certain distance of a reference surface.
312
+
313
+ Args:
314
+ segmentation: CopickSegmentation to limit
315
+ reference_mesh: Reference CopickMesh (either this or reference_segmentation must be provided)
316
+ reference_segmentation: Reference CopickSegmentation
317
+ run: CopickRun object
318
+ output_object_name: Name for the output segmentation
319
+ output_session_id: Session ID for the output segmentation
320
+ output_user_id: User ID for the output segmentation
321
+ max_distance: Maximum distance from reference surface
322
+ voxel_spacing: Voxel spacing for the output segmentation
323
+ mesh_voxel_spacing: Voxel spacing for mesh voxelization (defaults to target voxel spacing)
324
+ is_multilabel: Whether the segmentation is multilabel
325
+ **kwargs: Additional keyword arguments
326
+
327
+ Returns:
328
+ Tuple of (CopickSegmentation object, stats dict) or None if operation failed.
329
+ Stats dict contains 'voxels_created'.
330
+ """
331
+ try:
332
+ if reference_mesh is None and reference_segmentation is None:
333
+ raise ValueError("Either reference_mesh or reference_segmentation must be provided")
334
+
335
+ # Load target segmentation
336
+ seg_array = segmentation.numpy()
337
+ if seg_array is None or seg_array.size == 0:
338
+ logger.error("Could not load target segmentation data")
339
+ return None
340
+
341
+ # Create distance field from reference
342
+ if reference_mesh is not None:
343
+ ref_mesh = reference_mesh.mesh
344
+ if ref_mesh is None:
345
+ logger.error("Could not load reference mesh data")
346
+ return None
347
+
348
+ if isinstance(ref_mesh, tm.Scene):
349
+ if len(ref_mesh.geometry) == 0:
350
+ logger.error("Reference mesh is empty")
351
+ return None
352
+ ref_mesh = tm.util.concatenate(list(ref_mesh.geometry.values()))
353
+
354
+ # Create distance field from mesh
355
+ distance_field = _create_distance_field_from_mesh(
356
+ ref_mesh,
357
+ seg_array.shape,
358
+ segmentation.voxel_size,
359
+ mesh_voxel_spacing,
360
+ )
361
+
362
+ else: # reference_segmentation is not None
363
+ ref_seg_array = reference_segmentation.numpy()
364
+ if ref_seg_array is None or ref_seg_array.size == 0:
365
+ logger.error("Could not load reference segmentation data")
366
+ return None
367
+
368
+ # Handle different voxel spacings between reference and target
369
+ if abs(reference_segmentation.voxel_size - segmentation.voxel_size) > 1e-6:
370
+ # Resample reference segmentation to match target
371
+ from scipy.ndimage import zoom
372
+
373
+ zoom_factor = reference_segmentation.voxel_size / segmentation.voxel_size
374
+ ref_seg_array = zoom(ref_seg_array.astype(float), zoom_factor, order=0) > 0.5
375
+
376
+ # Crop or pad to match target shape
377
+ if ref_seg_array.shape != seg_array.shape:
378
+ result = np.zeros(seg_array.shape, dtype=bool)
379
+ copy_shape = np.minimum(ref_seg_array.shape, seg_array.shape)
380
+ slices = tuple(slice(0, s) for s in copy_shape)
381
+ result[slices] = ref_seg_array[slices]
382
+ ref_seg_array = result
383
+
384
+ # Create distance field from segmentation
385
+ distance_field = _create_distance_field_from_segmentation(ref_seg_array, segmentation.voxel_size)
386
+
387
+ # Apply distance threshold to create mask
388
+ distance_mask = distance_field <= max_distance
389
+
390
+ # Apply mask to target segmentation
391
+ output_array = seg_array * distance_mask
392
+
393
+ if np.sum(output_array > 0) == 0:
394
+ logger.warning(f"No voxels within {max_distance} units of reference surface")
395
+ return None
396
+
397
+ # Create output segmentation
398
+ output_seg = run.new_segmentation(
399
+ name=output_object_name,
400
+ user_id=output_user_id,
401
+ session_id=output_session_id,
402
+ is_multilabel=is_multilabel,
403
+ voxel_size=voxel_spacing,
404
+ exist_ok=True,
405
+ )
406
+
407
+ # Store the result
408
+ output_seg.from_numpy(output_array)
409
+
410
+ stats = {"voxels_created": int(np.sum(output_array > 0))}
411
+ logger.info(f"Limited segmentation to {stats['voxels_created']} voxels within {max_distance} units")
412
+ return output_seg, stats
413
+
414
+ except Exception as e:
415
+ logger.error(f"Error limiting segmentation by distance: {e}")
416
+ return None
417
+
418
+
419
+ def limit_picks_by_distance(
420
+ picks: "CopickPicks",
421
+ run: "CopickRun",
422
+ pick_object_name: str,
423
+ pick_session_id: str,
424
+ pick_user_id: str,
425
+ reference_mesh: Optional["CopickMesh"] = None,
426
+ reference_segmentation: Optional["CopickSegmentation"] = None,
427
+ max_distance: float = 100.0,
428
+ mesh_voxel_spacing: float = None,
429
+ **kwargs,
430
+ ) -> Optional[Tuple["CopickPicks", Dict[str, int]]]:
431
+ """
432
+ Limit picks to those within a certain distance of a reference surface.
433
+
434
+ Args:
435
+ picks: CopickPicks to limit
436
+ reference_mesh: Reference CopickMesh (either this or reference_segmentation must be provided)
437
+ reference_segmentation: Reference CopickSegmentation
438
+ run: CopickRun object
439
+ pick_object_name: Name for the output picks
440
+ pick_session_id: Session ID for the output picks
441
+ pick_user_id: User ID for the output picks
442
+ max_distance: Maximum distance from reference surface
443
+ mesh_voxel_spacing: Voxel spacing for mesh voxelization (defaults to 10.0)
444
+ **kwargs: Additional keyword arguments
445
+
446
+ Returns:
447
+ Tuple of (CopickPicks object, stats dict) or None if operation failed.
448
+ Stats dict contains 'points_created'.
449
+ """
450
+ try:
451
+ if reference_mesh is None and reference_segmentation is None:
452
+ raise ValueError("Either reference_mesh or reference_segmentation must be provided")
453
+
454
+ # Load pick data
455
+ points, transforms = picks.numpy()
456
+ if points is None or len(points) == 0:
457
+ logger.error("Could not load pick data")
458
+ return None
459
+
460
+ pick_positions = points[:, :3] # Use only x, y, z coordinates
461
+
462
+ # We need a coordinate space to create the distance field
463
+ # Use the reference segmentation's coordinate space, or create one for mesh references
464
+ if reference_segmentation is not None:
465
+ ref_seg_array = reference_segmentation.numpy()
466
+ if ref_seg_array is None or ref_seg_array.size == 0:
467
+ logger.error("Could not load reference segmentation data")
468
+ return None
469
+
470
+ # Use reference segmentation's coordinate space
471
+ field_voxel_spacing = reference_segmentation.voxel_size
472
+ distance_field = _create_distance_field_from_segmentation(ref_seg_array, field_voxel_spacing)
473
+
474
+ # Convert pick coordinates to voxel indices in reference segmentation space
475
+ pick_voxel_coords = pick_positions / field_voxel_spacing
476
+ pick_voxel_indices = np.floor(pick_voxel_coords).astype(int)
477
+
478
+ else: # reference_mesh is not None
479
+ ref_mesh = reference_mesh.mesh
480
+ if ref_mesh is None:
481
+ logger.error("Could not load reference mesh data")
482
+ return None
483
+
484
+ if isinstance(ref_mesh, tm.Scene):
485
+ if len(ref_mesh.geometry) == 0:
486
+ logger.error("Reference mesh is empty")
487
+ return None
488
+ ref_mesh = tm.util.concatenate(list(ref_mesh.geometry.values()))
489
+
490
+ # Define coordinate space based on mesh bounds and pick positions
491
+ all_coords = np.vstack([ref_mesh.vertices, pick_positions])
492
+ coord_bounds = np.array([all_coords.min(axis=0), all_coords.max(axis=0)])
493
+
494
+ # Add padding for max_distance
495
+ padding = max_distance * 1.1
496
+ coord_bounds[0] -= padding
497
+ coord_bounds[1] += padding
498
+
499
+ field_voxel_spacing = mesh_voxel_spacing if mesh_voxel_spacing is not None else 10.0
500
+ field_size = coord_bounds[1] - coord_bounds[0]
501
+ field_shape = np.ceil(field_size / field_voxel_spacing).astype(int)
502
+
503
+ # Create distance field from mesh in this coordinate space
504
+ distance_field = _create_distance_field_from_mesh(
505
+ ref_mesh,
506
+ field_shape,
507
+ field_voxel_spacing,
508
+ mesh_voxel_spacing,
509
+ )
510
+
511
+ # Convert pick coordinates to voxel indices in this field space
512
+ pick_voxel_coords = (pick_positions - coord_bounds[0]) / field_voxel_spacing
513
+ pick_voxel_indices = np.floor(pick_voxel_coords).astype(int)
514
+
515
+ # Check which picks are within field bounds
516
+ valid_picks = (pick_voxel_indices >= 0).all(axis=1) & (pick_voxel_indices < distance_field.shape).all(axis=1)
517
+
518
+ if not np.any(valid_picks):
519
+ logger.warning("No picks within distance field bounds")
520
+ return None
521
+
522
+ # Get distances for valid picks
523
+ valid_indices = pick_voxel_indices[valid_picks]
524
+ pick_distances = distance_field[valid_indices[:, 0], valid_indices[:, 1], valid_indices[:, 2]]
525
+
526
+ # Find picks within distance threshold
527
+ distance_valid = pick_distances <= max_distance
528
+
529
+ # Combine bounds validity and distance validity
530
+ final_valid = np.zeros(len(points), dtype=bool)
531
+ final_valid[valid_picks] = distance_valid
532
+
533
+ if not np.any(final_valid):
534
+ logger.warning(f"No picks within {max_distance} units of reference surface")
535
+ return None
536
+
537
+ # Filter picks
538
+ valid_points = points[final_valid]
539
+ valid_transforms = transforms[final_valid] if transforms is not None else None
540
+
541
+ # Create output picks
542
+ output_picks = run.new_picks(pick_object_name, pick_session_id, pick_user_id, exist_ok=True)
543
+ output_picks.from_numpy(positions=valid_points, transforms=valid_transforms)
544
+ output_picks.store()
545
+
546
+ stats = {"points_created": len(valid_points)}
547
+ logger.info(f"Limited picks to {stats['points_created']} points within {max_distance} units")
548
+ return output_picks, stats
549
+
550
+ except Exception as e:
551
+ logger.error(f"Error limiting picks by distance: {e}")
552
+ return None
553
+
554
+
555
+ # Create batch workers
556
+ _limit_mesh_by_distance_worker = create_batch_worker(limit_mesh_by_distance, "mesh", "mesh", min_points=0)
557
+ _limit_segmentation_by_distance_worker = create_batch_worker(
558
+ limit_segmentation_by_distance,
559
+ "segmentation",
560
+ "segmentation",
561
+ min_points=0,
562
+ )
563
+ _limit_picks_by_distance_worker = create_batch_worker(limit_picks_by_distance, "picks", "picks", min_points=1)
564
+
565
+ # Create batch converters
566
+ limit_mesh_by_distance_batch = create_batch_converter(
567
+ limit_mesh_by_distance,
568
+ "Limiting meshes by distance",
569
+ "mesh",
570
+ "mesh",
571
+ min_points=0,
572
+ )
573
+
574
+ limit_segmentation_by_distance_batch = create_batch_converter(
575
+ limit_segmentation_by_distance,
576
+ "Limiting segmentations by distance",
577
+ "segmentation",
578
+ "segmentation",
579
+ min_points=0,
580
+ )
581
+
582
+ limit_picks_by_distance_batch = create_batch_converter(
583
+ limit_picks_by_distance,
584
+ "Limiting picks by distance",
585
+ "picks",
586
+ "picks",
587
+ min_points=1,
588
+ )
589
+
590
+ # Lazy batch converters for new architecture
591
+ limit_segmentation_by_distance_lazy_batch = create_lazy_batch_converter(
592
+ converter_func=limit_segmentation_by_distance,
593
+ task_description="Limiting segmentations by distance",
594
+ )
595
+
596
+ limit_picks_by_distance_lazy_batch = create_lazy_batch_converter(
597
+ converter_func=limit_picks_by_distance,
598
+ task_description="Limiting picks by distance",
599
+ )
600
+
601
+ limit_mesh_by_distance_lazy_batch = create_lazy_batch_converter(
602
+ converter_func=limit_mesh_by_distance,
603
+ task_description="Limiting meshes by distance",
604
+ )