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
@@ -1,13 +1,29 @@
1
+ from typing import TYPE_CHECKING, Dict, Optional, Tuple
2
+
1
3
  import numpy as np
2
4
  import zarr
5
+ from copick.util.log import get_logger
3
6
  from scipy.ndimage import zoom
4
- import copick
5
7
 
6
- def from_picks(pick,
7
- seg_volume,
8
- radius: float = 10.0,
9
- label_value: int = 1,
10
- voxel_spacing: float = 10):
8
+ from copick_utils.converters.converter_common import (
9
+ create_batch_converter,
10
+ create_batch_worker,
11
+ )
12
+ from copick_utils.converters.lazy_converter import create_lazy_batch_converter
13
+
14
+ if TYPE_CHECKING:
15
+ from copick.models import CopickObject, CopickPicks, CopickRun, CopickSegmentation
16
+
17
+ logger = get_logger(__name__)
18
+
19
+
20
+ def from_picks(
21
+ pick: "CopickPicks",
22
+ seg_volume: np.ndarray,
23
+ radius: float = 10.0,
24
+ label_value: int = 1,
25
+ voxel_spacing: float = 10,
26
+ ) -> np.ndarray:
11
27
  """
12
28
  Paints picks into a segmentation volume as spheres.
13
29
 
@@ -26,12 +42,13 @@ def from_picks(pick,
26
42
  Returns:
27
43
  --------
28
44
  numpy.ndarray
29
- The modified segmentation volume with spheres inserted at pick locations.
45
+ The modified segmentation volume with spheres inserted at pick locations.
30
46
  """
47
+
31
48
  def create_sphere(shape, center, radius, val):
32
49
  zc, yc, xc = center
33
50
  z, y, x = np.indices(shape)
34
- distance_sq = (x - xc)**2 + (y - yc)**2 + (z - zc)**2
51
+ distance_sq = (x - xc) ** 2 + (y - yc) ** 2 + (z - zc) ** 2
35
52
  sphere = np.zeros(shape, dtype=np.float32)
36
53
  sphere[distance_sq <= radius**2] = val
37
54
  return sphere
@@ -48,7 +65,11 @@ def from_picks(pick,
48
65
  # Paint each pick as a sphere
49
66
  for point in pick.points:
50
67
  # Convert the pick's location from angstroms to voxel units
51
- cx, cy, cz = point.location.x / voxel_spacing, point.location.y / voxel_spacing, point.location.z / voxel_spacing
68
+ cx, cy, cz = (
69
+ point.location.x / voxel_spacing,
70
+ point.location.y / voxel_spacing,
71
+ point.location.z / voxel_spacing,
72
+ )
52
73
 
53
74
  # Calculate subarray bounds
54
75
  xLow, xHigh = get_relative_target_coordinates(cx, delta, seg_volume.shape[2])
@@ -65,12 +86,15 @@ def from_picks(pick,
65
86
  sphere = create_sphere(subarray_shape, local_center, radius_voxel, label_value)
66
87
 
67
88
  # Assign Sphere to Segmentation Target Volume
68
- seg_volume[zLow:zHigh, yLow:yHigh, xLow:xHigh] = np.maximum(seg_volume[zLow:zHigh, yLow:yHigh, xLow:xHigh], sphere)
89
+ seg_volume[zLow:zHigh, yLow:yHigh, xLow:xHigh] = np.maximum(
90
+ seg_volume[zLow:zHigh, yLow:yHigh, xLow:xHigh],
91
+ sphere,
92
+ )
69
93
 
70
94
  return seg_volume
71
95
 
72
96
 
73
- def downsample_to_exact_shape(array, target_shape):
97
+ def downsample_to_exact_shape(array: np.ndarray, target_shape: tuple) -> np.ndarray:
74
98
  """
75
99
  Downsamples a 3D array to match the target shape using nearest-neighbor interpolation.
76
100
  Ensures that the resulting array has the exact target shape.
@@ -79,7 +103,17 @@ def downsample_to_exact_shape(array, target_shape):
79
103
  return zoom(array, zoom_factors, order=0)
80
104
 
81
105
 
82
- def segmentation_from_picks(radius, painting_segmentation_name, run, voxel_spacing, tomo_type, pickable_object, pick_set, user_id="paintedPicks", session_id="0"):
106
+ def _create_segmentation_from_picks_legacy(
107
+ radius: float,
108
+ painting_segmentation_name: str,
109
+ run: "CopickRun",
110
+ voxel_spacing: float,
111
+ tomo_type: str,
112
+ pickable_object: "CopickObject",
113
+ pick_set: "CopickPicks",
114
+ user_id: str = "paintedPicks",
115
+ session_id: str = "0",
116
+ ) -> "CopickSegmentation":
83
117
  """
84
118
  Paints picks from a run into a multiscale segmentation array, representing them as spheres in 3D space.
85
119
 
@@ -115,7 +149,13 @@ def segmentation_from_picks(radius, painting_segmentation_name, run, voxel_spaci
115
149
  raise ValueError("Tomogram not found for the given parameters.")
116
150
 
117
151
  # Use copick to create a new segmentation if one does not exist
118
- segs = run.get_segmentations(user_id=user_id, session_id=session_id, is_multilabel=True, name=painting_segmentation_name, voxel_size=voxel_spacing)
152
+ segs = run.get_segmentations(
153
+ user_id=user_id,
154
+ session_id=session_id,
155
+ is_multilabel=True,
156
+ name=painting_segmentation_name,
157
+ voxel_size=voxel_spacing,
158
+ )
119
159
  if len(segs) == 0:
120
160
  seg = run.new_segmentation(voxel_spacing, painting_segmentation_name, session_id, True, user_id=user_id)
121
161
  else:
@@ -142,7 +182,7 @@ def segmentation_from_picks(radius, painting_segmentation_name, run, voxel_spaci
142
182
  segmentation_group[highest_res_name][:] = highest_res_seg
143
183
 
144
184
  # Downsample to create lower resolution scales
145
- multiscale_metadata = tomogram_zarr.attrs.get('multiscales', [{}])[0].get('datasets', [])
185
+ multiscale_metadata = tomogram_zarr.attrs.get("multiscales", [{}])[0].get("datasets", [])
146
186
  for level_index, level_metadata in enumerate(multiscale_metadata):
147
187
  if level_index == 0:
148
188
  continue
@@ -154,8 +194,104 @@ def segmentation_from_picks(radius, painting_segmentation_name, run, voxel_spaci
154
194
  scaled_array = downsample_to_exact_shape(highest_res_seg, expected_shape)
155
195
 
156
196
  # Create/overwrite the Zarr array for this level
157
- segmentation_group.create_dataset(level_name, shape=expected_shape, data=scaled_array, dtype=np.uint16, overwrite=True)
197
+ segmentation_group.create_dataset(
198
+ level_name,
199
+ shape=expected_shape,
200
+ data=scaled_array,
201
+ dtype=np.uint16,
202
+ overwrite=True,
203
+ )
158
204
 
159
205
  segmentation_group[level_name][:] = scaled_array
160
206
 
161
207
  return seg
208
+
209
+
210
+ def segmentation_from_picks(
211
+ picks: "CopickPicks",
212
+ run: "CopickRun",
213
+ object_name: str,
214
+ session_id: str,
215
+ user_id: str,
216
+ radius: float,
217
+ voxel_spacing: float,
218
+ tomo_type: str = "wbp",
219
+ ) -> Optional[Tuple["CopickSegmentation", Dict[str, int]]]:
220
+ """
221
+ Convert CopickPicks to a segmentation by painting spheres.
222
+
223
+ Args:
224
+ picks: CopickPicks object to convert
225
+ run: CopickRun object
226
+ object_name: Name for the output segmentation
227
+ session_id: Session ID for the output segmentation
228
+ user_id: User ID for the output segmentation
229
+ radius: Radius of the spheres in physical units
230
+ voxel_spacing: Voxel spacing for the segmentation
231
+ tomo_type: Type of tomogram to use as reference
232
+
233
+ Returns:
234
+ Tuple of (CopickSegmentation object, stats dict) or None if creation failed.
235
+ Stats dict contains 'points_converted' and 'voxels_created'.
236
+ """
237
+ try:
238
+ # Get the pickable object for label information
239
+ root = run.root
240
+ pickable_object = root.get_object(picks.pickable_object_name)
241
+ if not pickable_object:
242
+ logger.error(f"Object '{picks.pickable_object_name}' not found in config")
243
+ return None
244
+
245
+ if not picks.points:
246
+ logger.error("No points found in pick set")
247
+ return None
248
+
249
+ # Create segmentation using the legacy function
250
+ seg = _create_segmentation_from_picks_legacy(
251
+ radius=radius,
252
+ painting_segmentation_name=object_name,
253
+ run=run,
254
+ voxel_spacing=voxel_spacing,
255
+ tomo_type=tomo_type,
256
+ pickable_object=pickable_object,
257
+ pick_set=picks,
258
+ user_id=user_id,
259
+ session_id=session_id,
260
+ )
261
+
262
+ # Calculate statistics
263
+ # For now, we don't have easy access to the actual voxel count, so we estimate
264
+ # based on number of spheres and their volume
265
+ sphere_volume_voxels = (4 / 3) * np.pi * (radius / voxel_spacing) ** 3
266
+ estimated_voxels = int(len(picks.points) * sphere_volume_voxels)
267
+
268
+ stats = {
269
+ "points_converted": len(picks.points),
270
+ "voxels_created": estimated_voxels,
271
+ }
272
+ logger.info(f"Created segmentation from {stats['points_converted']} picks")
273
+ return seg, stats
274
+
275
+ except Exception as e:
276
+ logger.error(f"Error creating segmentation: {e}")
277
+ return None
278
+
279
+
280
+ # Create worker function using common infrastructure
281
+ _segmentation_from_picks_worker = create_batch_worker(segmentation_from_picks, "segmentation", "picks", min_points=1)
282
+
283
+
284
+ # Create batch converter using common infrastructure
285
+ segmentation_from_picks_batch = create_batch_converter(
286
+ segmentation_from_picks,
287
+ "Converting picks to segmentations",
288
+ "segmentation",
289
+ "picks",
290
+ min_points=1,
291
+ )
292
+
293
+ # Lazy batch converter for new architecture
294
+ segmentation_from_picks_lazy_batch = create_lazy_batch_converter(
295
+ converter_func=segmentation_from_picks,
296
+ task_description="Converting picks to segmentations",
297
+ )
@@ -0,0 +1,306 @@
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 scipy.optimize import minimize
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_sphere_to_points(points: np.ndarray) -> Tuple[np.ndarray, float]:
24
+ """Fit a sphere to a set of 3D points using least squares.
25
+
26
+ Args:
27
+ points: Nx3 array of points.
28
+
29
+ Returns:
30
+ Tuple of (center, radius).
31
+ """
32
+ if len(points) < 4:
33
+ raise ValueError("Need at least 4 points to fit a sphere")
34
+
35
+ def sphere_residuals(params, points):
36
+ """Calculate residuals for sphere fitting."""
37
+ cx, cy, cz, r = params
38
+ center = np.array([cx, cy, cz])
39
+ distances = np.linalg.norm(points - center, axis=1)
40
+ return distances - r
41
+
42
+ # Initial guess: center at centroid, radius as average distance to centroid
43
+ centroid = np.mean(points, axis=0)
44
+ distances = np.linalg.norm(points - centroid, axis=1)
45
+ initial_radius = np.mean(distances)
46
+
47
+ initial_params = [centroid[0], centroid[1], centroid[2], initial_radius]
48
+
49
+ # Fit sphere using least squares
50
+ result = minimize(lambda params: np.sum(sphere_residuals(params, points) ** 2), initial_params, method="L-BFGS-B")
51
+
52
+ if result.success:
53
+ cx, cy, cz, r = result.x
54
+ center = np.array([cx, cy, cz])
55
+ radius = abs(r) # Ensure positive radius
56
+ return center, radius
57
+ else:
58
+ # Fallback to simple centroid and average distance
59
+ radius = np.mean(np.linalg.norm(points - centroid, axis=1))
60
+ return centroid, radius
61
+
62
+
63
+ def deduplicate_spheres(
64
+ spheres: List[Tuple[np.ndarray, float]],
65
+ min_distance: float = None,
66
+ ) -> List[Tuple[np.ndarray, float]]:
67
+ """Merge spheres that are too close to each other.
68
+
69
+ Args:
70
+ spheres: List of (center, radius) tuples.
71
+ min_distance: Minimum distance between sphere centers. If None, uses average radius.
72
+
73
+ Returns:
74
+ List of deduplicated (center, radius) tuples.
75
+ """
76
+ if len(spheres) <= 1:
77
+ return spheres
78
+
79
+ if min_distance is None:
80
+ # Use average radius as minimum distance
81
+ avg_radius = np.mean([radius for _, radius in spheres])
82
+ min_distance = avg_radius * 0.5
83
+
84
+ deduplicated = []
85
+ used = set()
86
+
87
+ for i, (center1, radius1) in enumerate(spheres):
88
+ if i in used:
89
+ continue
90
+
91
+ # Find all spheres close to this one
92
+ close_spheres = [(center1, radius1)]
93
+ used.add(i)
94
+
95
+ for j, (center2, radius2) in enumerate(spheres):
96
+ if j in used or i == j:
97
+ continue
98
+
99
+ distance = np.linalg.norm(center1 - center2)
100
+ if distance <= min_distance:
101
+ close_spheres.append((center2, radius2))
102
+ used.add(j)
103
+
104
+ if len(close_spheres) == 1:
105
+ # Single sphere, keep as is
106
+ deduplicated.append((center1, radius1))
107
+ else:
108
+ # Merge multiple close spheres
109
+ centers = np.array([center for center, _ in close_spheres])
110
+ radii = np.array([radius for _, radius in close_spheres])
111
+
112
+ # Use weighted average for center (weight by volume)
113
+ volumes = (4 / 3) * np.pi * radii**3
114
+ weights = volumes / np.sum(volumes)
115
+ merged_center = np.average(centers, axis=0, weights=weights)
116
+
117
+ # Use volume-weighted average for radius
118
+ merged_radius = np.average(radii, weights=weights)
119
+
120
+ deduplicated.append((merged_center, merged_radius))
121
+ logger.info(f"Merged {len(close_spheres)} overlapping spheres into one")
122
+
123
+ return deduplicated
124
+
125
+
126
+ def create_sphere_mesh(center: np.ndarray, radius: float, subdivisions: int = 2) -> tm.Trimesh:
127
+ """Create a sphere mesh with given center and radius.
128
+
129
+ Args:
130
+ center: 3D center point.
131
+ radius: Sphere radius.
132
+ subdivisions: Number of subdivisions for sphere resolution.
133
+
134
+ Returns:
135
+ Trimesh sphere object.
136
+ """
137
+ # Create unit sphere and scale/translate
138
+ sphere = tm.creation.icosphere(subdivisions=subdivisions, radius=radius)
139
+ sphere.apply_translation(center)
140
+ return sphere
141
+
142
+
143
+ def sphere_from_picks(
144
+ points: np.ndarray,
145
+ run: "CopickRun",
146
+ object_name: str,
147
+ session_id: str,
148
+ user_id: str,
149
+ use_clustering: bool = False,
150
+ clustering_method: str = "dbscan",
151
+ clustering_params: Optional[Dict[str, Any]] = None,
152
+ subdivisions: int = 2,
153
+ all_clusters: bool = False,
154
+ deduplicate_spheres_flag: bool = True,
155
+ min_sphere_distance: Optional[float] = None,
156
+ individual_meshes: bool = False,
157
+ session_id_template: Optional[str] = None,
158
+ ) -> Optional[Tuple["CopickMesh", Dict[str, int]]]:
159
+ """Create sphere mesh(es) from pick points.
160
+
161
+ Args:
162
+ points: Nx3 array of pick positions.
163
+ run: Copick run object.
164
+ object_name: Name of the mesh object.
165
+ session_id: Session ID for the mesh.
166
+ user_id: User ID for the mesh.
167
+ use_clustering: Whether to cluster points first.
168
+ clustering_method: Clustering method ('dbscan', 'kmeans').
169
+ clustering_params: Parameters for clustering.
170
+ e.g.
171
+ - {'eps': 5.0, 'min_samples': 3} for DBSCAN
172
+ - {'n_clusters': 3} for KMeans
173
+ subdivisions: Number of subdivisions for sphere resolution.
174
+ all_clusters: If True and clustering is used, use all clusters. If False, use only largest cluster.
175
+ deduplicate_spheres_flag: Whether to merge overlapping spheres.
176
+ min_sphere_distance: Minimum distance between sphere centers for deduplication.
177
+ individual_meshes: If True, create separate mesh objects for each sphere.
178
+ session_id_template: Template for individual mesh session IDs.
179
+
180
+ Returns:
181
+ Tuple of (CopickMesh object, stats dict) or None if creation failed.
182
+ Stats dict contains 'vertices_created' and 'faces_created' totals.
183
+ """
184
+ if not validate_points(points, 4, "sphere"):
185
+ return None
186
+
187
+ if clustering_params is None:
188
+ clustering_params = {}
189
+
190
+ # Handle clustering workflow with special sphere logic
191
+ if use_clustering:
192
+ point_clusters = cluster(points, clustering_method, 4, **clustering_params)
193
+
194
+ if not point_clusters:
195
+ logger.warning("No valid clusters found")
196
+ return None
197
+
198
+ logger.info(f"Found {len(point_clusters)} clusters")
199
+
200
+ if all_clusters and len(point_clusters) > 1:
201
+ # Create sphere parameters from all clusters
202
+ sphere_params = []
203
+ for i, cluster_points in enumerate(point_clusters):
204
+ try:
205
+ center, radius = fit_sphere_to_points(cluster_points)
206
+ sphere_params.append((center, radius))
207
+ logger.info(f"Cluster {i}: sphere at {center} with radius {radius:.2f}")
208
+ except Exception as e:
209
+ logger.critical(f"Failed to fit sphere to cluster {i}: {e}")
210
+ continue
211
+
212
+ if not sphere_params:
213
+ logger.warning("No valid spheres created from clusters")
214
+ return None
215
+
216
+ # Deduplicate overlapping spheres if requested
217
+ if deduplicate_spheres_flag:
218
+ final_spheres = deduplicate_spheres(sphere_params, min_sphere_distance)
219
+ else:
220
+ final_spheres = sphere_params
221
+
222
+ if individual_meshes:
223
+ # Create separate mesh objects for each sphere
224
+ created_meshes = []
225
+ total_vertices = 0
226
+ total_faces = 0
227
+
228
+ for i, (center, radius) in enumerate(final_spheres):
229
+ sphere_mesh = create_sphere_mesh(center, radius, subdivisions)
230
+
231
+ # Generate session ID using template if provided
232
+ if session_id_template:
233
+ sphere_session_id = session_id_template.format(
234
+ base_session_id=session_id,
235
+ instance_id=i,
236
+ )
237
+ else:
238
+ sphere_session_id = f"{session_id}-{i:03d}"
239
+
240
+ try:
241
+ copick_mesh = run.new_mesh(object_name, sphere_session_id, user_id, exist_ok=True)
242
+ copick_mesh.mesh = sphere_mesh
243
+ copick_mesh.store()
244
+ created_meshes.append(copick_mesh)
245
+ total_vertices += len(sphere_mesh.vertices)
246
+ total_faces += len(sphere_mesh.faces)
247
+ logger.info(f"Created individual sphere mesh {i} with {len(sphere_mesh.vertices)} vertices")
248
+ except Exception as e:
249
+ logger.error(f"Failed to create mesh {i}: {e}")
250
+ continue
251
+
252
+ # Return the first mesh and total stats
253
+ if created_meshes:
254
+ stats = {"vertices_created": total_vertices, "faces_created": total_faces}
255
+ return created_meshes[0], stats
256
+ else:
257
+ return None
258
+ else:
259
+ # Create meshes from final spheres and combine them
260
+ all_meshes = []
261
+ for center, radius in final_spheres:
262
+ sphere_mesh = create_sphere_mesh(center, radius, subdivisions)
263
+ all_meshes.append(sphere_mesh)
264
+
265
+ # Combine all meshes
266
+ combined_mesh = tm.util.concatenate(all_meshes)
267
+ else:
268
+ # Use largest cluster
269
+ cluster_sizes = [len(cluster) for cluster in point_clusters]
270
+ largest_cluster_idx = np.argmax(cluster_sizes)
271
+ points_to_use = point_clusters[largest_cluster_idx]
272
+ logger.info(f"Using largest cluster with {len(points_to_use)} points")
273
+
274
+ center, radius = fit_sphere_to_points(points_to_use)
275
+ combined_mesh = create_sphere_mesh(center, radius, subdivisions)
276
+ else:
277
+ # Fit single sphere to all points
278
+ center, radius = fit_sphere_to_points(points)
279
+ combined_mesh = create_sphere_mesh(center, radius, subdivisions)
280
+ logger.info(f"Fitted sphere at {center} with radius {radius:.2f}")
281
+
282
+ # Store mesh and return stats
283
+ try:
284
+ return store_mesh_with_stats(run, combined_mesh, object_name, session_id, user_id, "sphere")
285
+ except Exception as e:
286
+ logger.critical(f"Error creating mesh: {e}")
287
+ return None
288
+
289
+
290
+ # Create worker function using common infrastructure
291
+ _sphere_from_picks_worker = create_batch_worker(sphere_from_picks, "sphere", min_points=4)
292
+
293
+
294
+ # Create batch converter using common infrastructure
295
+ sphere_from_picks_batch = create_batch_converter(
296
+ sphere_from_picks,
297
+ "Converting picks to sphere meshes",
298
+ "sphere",
299
+ min_points=4,
300
+ )
301
+
302
+ # Lazy batch converter for new architecture
303
+ sphere_from_picks_lazy_batch = create_lazy_batch_converter(
304
+ converter_func=sphere_from_picks,
305
+ task_description="Converting picks to sphere meshes",
306
+ )