copick-utils 0.6.1__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.
- copick_utils/__init__.py +1 -1
- copick_utils/cli/__init__.py +33 -0
- copick_utils/cli/clipmesh.py +161 -0
- copick_utils/cli/clippicks.py +154 -0
- copick_utils/cli/clipseg.py +163 -0
- copick_utils/cli/conversion_commands.py +32 -0
- copick_utils/cli/enclosed.py +191 -0
- copick_utils/cli/filter_components.py +166 -0
- copick_utils/cli/fit_spline.py +191 -0
- copick_utils/cli/hull.py +138 -0
- copick_utils/cli/input_output_selection.py +76 -0
- copick_utils/cli/logical_commands.py +29 -0
- copick_utils/cli/mesh2picks.py +170 -0
- copick_utils/cli/mesh2seg.py +167 -0
- copick_utils/cli/meshop.py +262 -0
- copick_utils/cli/picks2ellipsoid.py +171 -0
- copick_utils/cli/picks2mesh.py +181 -0
- copick_utils/cli/picks2plane.py +156 -0
- copick_utils/cli/picks2seg.py +134 -0
- copick_utils/cli/picks2sphere.py +170 -0
- copick_utils/cli/picks2surface.py +164 -0
- copick_utils/cli/picksin.py +146 -0
- copick_utils/cli/picksout.py +148 -0
- copick_utils/cli/processing_commands.py +18 -0
- copick_utils/cli/seg2mesh.py +135 -0
- copick_utils/cli/seg2picks.py +128 -0
- copick_utils/cli/segop.py +248 -0
- copick_utils/cli/separate_components.py +155 -0
- copick_utils/cli/skeletonize.py +164 -0
- copick_utils/cli/util.py +580 -0
- copick_utils/cli/validbox.py +155 -0
- copick_utils/converters/__init__.py +35 -0
- copick_utils/converters/converter_common.py +543 -0
- copick_utils/converters/ellipsoid_from_picks.py +335 -0
- copick_utils/converters/lazy_converter.py +576 -0
- copick_utils/converters/mesh_from_picks.py +209 -0
- copick_utils/converters/mesh_from_segmentation.py +119 -0
- copick_utils/converters/picks_from_mesh.py +542 -0
- copick_utils/converters/picks_from_segmentation.py +168 -0
- copick_utils/converters/plane_from_picks.py +251 -0
- copick_utils/converters/segmentation_from_mesh.py +291 -0
- copick_utils/{segmentation → converters}/segmentation_from_picks.py +123 -13
- copick_utils/converters/sphere_from_picks.py +306 -0
- copick_utils/converters/surface_from_picks.py +337 -0
- copick_utils/logical/__init__.py +43 -0
- copick_utils/logical/distance_operations.py +604 -0
- copick_utils/logical/enclosed_operations.py +222 -0
- copick_utils/logical/mesh_operations.py +443 -0
- copick_utils/logical/point_operations.py +303 -0
- copick_utils/logical/segmentation_operations.py +399 -0
- copick_utils/process/__init__.py +47 -0
- copick_utils/process/connected_components.py +360 -0
- copick_utils/process/filter_components.py +306 -0
- copick_utils/process/hull.py +106 -0
- copick_utils/process/skeletonize.py +326 -0
- copick_utils/process/spline_fitting.py +648 -0
- copick_utils/process/validbox.py +333 -0
- copick_utils/util/__init__.py +6 -0
- copick_utils/util/config_models.py +614 -0
- {copick_utils-0.6.1.dist-info → copick_utils-1.0.0.dist-info}/METADATA +15 -2
- copick_utils-1.0.0.dist-info/RECORD +71 -0
- copick_utils-1.0.0.dist-info/entry_points.txt +29 -0
- copick_utils/segmentation/picks_from_segmentation.py +0 -81
- copick_utils-0.6.1.dist-info/RECORD +0 -14
- /copick_utils/{segmentation → io}/__init__.py +0 -0
- {copick_utils-0.6.1.dist-info → copick_utils-1.0.0.dist-info}/WHEEL +0 -0
- {copick_utils-0.6.1.dist-info → 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
|
+
)
|