copick-utils 0.6.1__py3-none-any.whl → 1.0.1__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 (67) hide show
  1. copick_utils/__init__.py +1 -1
  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 +123 -13
  43. copick_utils/converters/sphere_from_picks.py +306 -0
  44. copick_utils/converters/surface_from_picks.py +337 -0
  45. copick_utils/logical/__init__.py +43 -0
  46. copick_utils/logical/distance_operations.py +604 -0
  47. copick_utils/logical/enclosed_operations.py +222 -0
  48. copick_utils/logical/mesh_operations.py +443 -0
  49. copick_utils/logical/point_operations.py +303 -0
  50. copick_utils/logical/segmentation_operations.py +399 -0
  51. copick_utils/process/__init__.py +47 -0
  52. copick_utils/process/connected_components.py +360 -0
  53. copick_utils/process/filter_components.py +306 -0
  54. copick_utils/process/hull.py +106 -0
  55. copick_utils/process/skeletonize.py +326 -0
  56. copick_utils/process/spline_fitting.py +648 -0
  57. copick_utils/process/validbox.py +333 -0
  58. copick_utils/util/__init__.py +6 -0
  59. copick_utils/util/config_models.py +614 -0
  60. {copick_utils-0.6.1.dist-info → copick_utils-1.0.1.dist-info}/METADATA +15 -2
  61. copick_utils-1.0.1.dist-info/RECORD +71 -0
  62. {copick_utils-0.6.1.dist-info → copick_utils-1.0.1.dist-info}/WHEEL +1 -1
  63. copick_utils-1.0.1.dist-info/entry_points.txt +29 -0
  64. copick_utils/segmentation/picks_from_segmentation.py +0 -81
  65. copick_utils-0.6.1.dist-info/RECORD +0 -14
  66. /copick_utils/{segmentation → io}/__init__.py +0 -0
  67. {copick_utils-0.6.1.dist-info → copick_utils-1.0.1.dist-info}/licenses/LICENSE +0 -0
@@ -0,0 +1,335 @@
1
+ from typing import TYPE_CHECKING, Any, Dict, List, Optional, Tuple
2
+
3
+ import numpy as np
4
+ import trimesh as tm
5
+ from copick.util.log import get_logger
6
+ from sklearn.decomposition import PCA
7
+
8
+ from copick_utils.converters.converter_common import (
9
+ cluster,
10
+ create_batch_converter,
11
+ create_batch_worker,
12
+ store_mesh_with_stats,
13
+ validate_points,
14
+ )
15
+ from copick_utils.converters.lazy_converter import create_lazy_batch_converter
16
+
17
+ if TYPE_CHECKING:
18
+ from copick.models import CopickMesh, CopickRun
19
+
20
+ logger = get_logger(__name__)
21
+
22
+
23
+ def fit_ellipsoid_to_points(points: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]:
24
+ """Fit an ellipsoid to a set of 3D points using PCA and least squares.
25
+
26
+ Args:
27
+ points: Nx3 array of points.
28
+
29
+ Returns:
30
+ Tuple of (center, semi_axes, rotation_matrix).
31
+ """
32
+ if len(points) < 6:
33
+ raise ValueError("Need at least 6 points to fit an ellipsoid")
34
+
35
+ # Center the points
36
+ center = np.mean(points, axis=0)
37
+ centered_points = points - center
38
+
39
+ # Use PCA to find principal axes
40
+ pca = PCA(n_components=3)
41
+ pca.fit(centered_points)
42
+
43
+ # Transform to PCA coordinates
44
+ transformed_points = pca.transform(centered_points)
45
+
46
+ # Estimate semi-axes lengths from the spread in each direction
47
+ semi_axes = np.sqrt(np.var(transformed_points, axis=0)) * 2 # 2 standard deviations
48
+
49
+ # Ensure positive and reasonable semi-axes
50
+ semi_axes = np.maximum(semi_axes, 0.1)
51
+
52
+ # Sort semi-axes in descending order and reorder components
53
+ sorted_indices = np.argsort(semi_axes)[::-1]
54
+ semi_axes = semi_axes[sorted_indices]
55
+ rotation_matrix = pca.components_[sorted_indices]
56
+
57
+ return center, semi_axes, rotation_matrix
58
+
59
+
60
+ def deduplicate_ellipsoids(
61
+ ellipsoids: List[Tuple[np.ndarray, np.ndarray, np.ndarray]],
62
+ min_distance: float = None,
63
+ ) -> List[Tuple[np.ndarray, np.ndarray, np.ndarray]]:
64
+ """Merge ellipsoids that are too close to each other.
65
+
66
+ Args:
67
+ ellipsoids: List of (center, semi_axes, rotation_matrix) tuples.
68
+ min_distance: Minimum distance between ellipsoid centers. If None, uses average of largest semi-axes.
69
+
70
+ Returns:
71
+ List of deduplicated (center, semi_axes, rotation_matrix) tuples.
72
+ """
73
+ if len(ellipsoids) <= 1:
74
+ return ellipsoids
75
+
76
+ if min_distance is None:
77
+ # Use average of largest semi-axes as minimum distance
78
+ avg_major_axis = np.mean([semi_axes[0] for _, semi_axes, _ in ellipsoids])
79
+ min_distance = avg_major_axis * 0.5
80
+
81
+ deduplicated = []
82
+ used = set()
83
+
84
+ for i, (center1, semi_axes1, rotation1) in enumerate(ellipsoids):
85
+ if i in used:
86
+ continue
87
+
88
+ # Find all ellipsoids close to this one
89
+ close_ellipsoids = [(center1, semi_axes1, rotation1)]
90
+ used.add(i)
91
+
92
+ for j, (center2, semi_axes2, rotation2) in enumerate(ellipsoids):
93
+ if j in used or i == j:
94
+ continue
95
+
96
+ distance = np.linalg.norm(center1 - center2)
97
+ if distance <= min_distance:
98
+ close_ellipsoids.append((center2, semi_axes2, rotation2))
99
+ used.add(j)
100
+
101
+ if len(close_ellipsoids) == 1:
102
+ # Single ellipsoid, keep as is
103
+ deduplicated.append((center1, semi_axes1, rotation1))
104
+ else:
105
+ # Merge multiple close ellipsoids
106
+ centers = np.array([center for center, _, _ in close_ellipsoids])
107
+ all_semi_axes = np.array([semi_axes for _, semi_axes, _ in close_ellipsoids])
108
+
109
+ # Use volume-weighted average for center and semi-axes
110
+ volumes = (4 / 3) * np.pi * np.prod(all_semi_axes, axis=1)
111
+ weights = volumes / np.sum(volumes)
112
+ merged_center = np.average(centers, axis=0, weights=weights)
113
+ merged_semi_axes = np.average(all_semi_axes, axis=0, weights=weights)
114
+
115
+ # Use first rotation matrix (could be improved with proper rotation averaging)
116
+ merged_rotation = close_ellipsoids[0][2]
117
+
118
+ deduplicated.append((merged_center, merged_semi_axes, merged_rotation))
119
+ logger.info(f"Merged {len(close_ellipsoids)} overlapping ellipsoids into one")
120
+
121
+ return deduplicated
122
+
123
+
124
+ def create_ellipsoid_mesh(
125
+ center: np.ndarray,
126
+ semi_axes: np.ndarray,
127
+ rotation_matrix: np.ndarray,
128
+ subdivisions: int = 2,
129
+ ) -> tm.Trimesh:
130
+ """Create an ellipsoid mesh with given center, semi-axes, and orientation.
131
+
132
+ Args:
133
+ center: 3D center point.
134
+ semi_axes: Three semi-axis lengths [a, b, c].
135
+ rotation_matrix: 3x3 rotation matrix.
136
+ subdivisions: Number of subdivisions for ellipsoid resolution.
137
+
138
+ Returns:
139
+ Trimesh ellipsoid object.
140
+ """
141
+ # Create unit sphere
142
+ sphere = tm.creation.icosphere(subdivisions=subdivisions, radius=1.0)
143
+
144
+ # Scale by semi-axes to create ellipsoid
145
+ scale_matrix = np.diag([semi_axes[0], semi_axes[1], semi_axes[2]])
146
+
147
+ # Apply scaling
148
+ ellipsoid_vertices = sphere.vertices @ scale_matrix.T
149
+
150
+ # Apply rotation
151
+ ellipsoid_vertices = ellipsoid_vertices @ rotation_matrix
152
+
153
+ # Translate to center
154
+ ellipsoid_vertices += center
155
+
156
+ # Create new mesh
157
+ ellipsoid = tm.Trimesh(vertices=ellipsoid_vertices, faces=sphere.faces)
158
+
159
+ return ellipsoid
160
+
161
+
162
+ def ellipsoid_from_picks(
163
+ points: np.ndarray,
164
+ run: "CopickRun",
165
+ object_name: str,
166
+ session_id: str,
167
+ user_id: str,
168
+ use_clustering: bool = False,
169
+ clustering_method: str = "dbscan",
170
+ clustering_params: Optional[Dict[str, Any]] = None,
171
+ subdivisions: int = 2,
172
+ all_clusters: bool = True,
173
+ deduplicate_ellipsoids_flag: bool = True,
174
+ min_ellipsoid_distance: Optional[float] = None,
175
+ individual_meshes: bool = False,
176
+ session_id_template: Optional[str] = None,
177
+ ) -> Optional[Tuple["CopickMesh", Dict[str, int]]]:
178
+ """Create ellipsoid mesh(es) from pick points.
179
+
180
+ Args:
181
+ points: Nx3 array of pick positions.
182
+ run: Copick run object.
183
+ object_name: Name of the mesh object.
184
+ session_id: Session ID for the mesh.
185
+ user_id: User ID for the mesh.
186
+ use_clustering: Whether to cluster points first.
187
+ clustering_method: Clustering method ('dbscan', 'kmeans').
188
+ clustering_params: Parameters for clustering.
189
+ e.g.
190
+ - {'eps': 5.0, 'min_samples': 3} for DBSCAN
191
+ - {'n_clusters': 3} for KMeans
192
+ subdivisions: Number of subdivisions for ellipsoid resolution.
193
+ all_clusters: If True, use all clusters; if False, use only the largest cluster.
194
+ deduplicate_ellipsoids_flag: Whether to merge overlapping ellipsoids.
195
+ min_ellipsoid_distance: Minimum distance between ellipsoid centers for deduplication.
196
+ individual_meshes: If True, create separate mesh objects for each ellipsoid.
197
+ session_id_template: Template for individual mesh session IDs.
198
+
199
+ Returns:
200
+ Tuple of (CopickMesh object, stats dict) or None if creation failed.
201
+ Stats dict contains 'vertices_created' and 'faces_created' totals.
202
+ """
203
+ if not validate_points(points, 6, "ellipsoid"):
204
+ return None
205
+
206
+ if clustering_params is None:
207
+ clustering_params = {}
208
+
209
+ # Define ellipsoid creation function with special handling
210
+ def create_ellipsoid_from_points(cluster_points):
211
+ center, semi_axes, rotation_matrix = fit_ellipsoid_to_points(cluster_points)
212
+ return create_ellipsoid_mesh(center, semi_axes, rotation_matrix, subdivisions)
213
+
214
+ # Handle clustering workflow with special ellipsoid logic
215
+ if use_clustering:
216
+ point_clusters = cluster(
217
+ points,
218
+ clustering_method,
219
+ min_points_per_cluster=6, # Ellipsoids need at least 6 points
220
+ **clustering_params,
221
+ )
222
+
223
+ if not point_clusters:
224
+ logger.warning("No valid clusters found")
225
+ return None
226
+
227
+ logger.info(f"Found {len(point_clusters)} clusters")
228
+
229
+ if all_clusters and len(point_clusters) > 1:
230
+ # Create ellipsoid parameters from all clusters
231
+ ellipsoid_params = []
232
+ for i, cluster_points in enumerate(point_clusters):
233
+ try:
234
+ center, semi_axes, rotation_matrix = fit_ellipsoid_to_points(cluster_points)
235
+ ellipsoid_params.append((center, semi_axes, rotation_matrix))
236
+ logger.info(f"Cluster {i}: ellipsoid at {center} with semi-axes {semi_axes}")
237
+ except Exception as e:
238
+ logger.critical(f"Failed to fit ellipsoid to cluster {i}: {e}")
239
+ continue
240
+
241
+ if not ellipsoid_params:
242
+ logger.warning("No valid ellipsoids created from clusters")
243
+ return None
244
+
245
+ # Deduplicate overlapping ellipsoids if requested
246
+ if deduplicate_ellipsoids_flag:
247
+ final_ellipsoids = deduplicate_ellipsoids(ellipsoid_params, min_ellipsoid_distance)
248
+ else:
249
+ final_ellipsoids = ellipsoid_params
250
+
251
+ if individual_meshes:
252
+ # Create separate mesh objects for each ellipsoid
253
+ created_meshes = []
254
+ total_vertices = 0
255
+ total_faces = 0
256
+
257
+ for i, (center, semi_axes, rotation_matrix) in enumerate(final_ellipsoids):
258
+ ellipsoid_mesh = create_ellipsoid_mesh(center, semi_axes, rotation_matrix, subdivisions)
259
+
260
+ # Generate session ID using template if provided
261
+ if session_id_template:
262
+ ellipsoid_session_id = session_id_template.format(
263
+ base_session_id=session_id,
264
+ instance_id=i,
265
+ )
266
+ else:
267
+ ellipsoid_session_id = f"{session_id}-{i:03d}"
268
+
269
+ try:
270
+ copick_mesh = run.new_mesh(object_name, ellipsoid_session_id, user_id, exist_ok=True)
271
+ copick_mesh.mesh = ellipsoid_mesh
272
+ copick_mesh.store()
273
+ created_meshes.append(copick_mesh)
274
+ total_vertices += len(ellipsoid_mesh.vertices)
275
+ total_faces += len(ellipsoid_mesh.faces)
276
+ logger.info(
277
+ f"Created individual ellipsoid mesh {i} with {len(ellipsoid_mesh.vertices)} vertices",
278
+ )
279
+ except Exception as e:
280
+ logger.error(f"Failed to create mesh {i}: {e}")
281
+ continue
282
+
283
+ # Return the first mesh and total stats
284
+ if created_meshes:
285
+ stats = {"vertices_created": total_vertices, "faces_created": total_faces}
286
+ return created_meshes[0], stats
287
+ else:
288
+ return None
289
+ else:
290
+ # Create meshes from final ellipsoids and combine them
291
+ all_meshes = []
292
+ for center, semi_axes, rotation_matrix in final_ellipsoids:
293
+ ellipsoid_mesh = create_ellipsoid_mesh(center, semi_axes, rotation_matrix, subdivisions)
294
+ all_meshes.append(ellipsoid_mesh)
295
+
296
+ # Combine all meshes
297
+ combined_mesh = tm.util.concatenate(all_meshes)
298
+ else:
299
+ # Use largest cluster
300
+ cluster_sizes = [len(cluster) for cluster in point_clusters]
301
+ largest_cluster_idx = np.argmax(cluster_sizes)
302
+ points_to_use = point_clusters[largest_cluster_idx]
303
+ logger.info(f"Using largest cluster with {len(points_to_use)} points")
304
+
305
+ combined_mesh = create_ellipsoid_from_points(points_to_use)
306
+ else:
307
+ # Use all points without clustering
308
+ combined_mesh = create_ellipsoid_from_points(points)
309
+
310
+ # Store mesh and return stats
311
+ try:
312
+ return store_mesh_with_stats(run, combined_mesh, object_name, session_id, user_id, "ellipsoid")
313
+ except Exception as e:
314
+ logger.critical(f"Error creating mesh: {e}")
315
+ return None
316
+
317
+
318
+ # Create worker function using common infrastructure
319
+ _ellipsoid_from_picks_worker = create_batch_worker(ellipsoid_from_picks, "ellipsoid", min_points=6)
320
+
321
+
322
+ # Create batch converter using common infrastructure
323
+ ellipsoid_from_picks_batch = create_batch_converter(
324
+ ellipsoid_from_picks,
325
+ "Converting picks to ellipsoid meshes",
326
+ "ellipsoid",
327
+ min_points=6,
328
+ )
329
+
330
+ # Lazy batch converter for new architecture
331
+
332
+ ellipsoid_from_picks_lazy_batch = create_lazy_batch_converter(
333
+ converter_func=ellipsoid_from_picks,
334
+ task_description="Converting picks to ellipsoid meshes",
335
+ )