ossify 0.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.
ossify/base.py ADDED
@@ -0,0 +1,1445 @@
1
+ import contextlib
2
+ import copy
3
+ from functools import partial
4
+ from typing import Any, Callable, Generator, List, Optional, Self, Union
5
+
6
+ import numpy as np
7
+ import pandas as pd
8
+
9
+ from . import utils
10
+ from .data_layers import (
11
+ GRAPH_LAYER_NAME,
12
+ MESH_LAYER_NAME,
13
+ SKEL_LAYER_NAME,
14
+ GraphLayer,
15
+ MeshLayer,
16
+ PointCloudLayer,
17
+ SkeletonLayer,
18
+ )
19
+ from .sync_classes import *
20
+
21
+ __all__ = [
22
+ "Cell",
23
+ "GraphLayer",
24
+ "MeshLayer",
25
+ "SkeletonLayer",
26
+ "PointCloudLayer",
27
+ "Link",
28
+ "LayerManager",
29
+ "AnnotationManager",
30
+ ]
31
+
32
+
33
+ class LayerManager:
34
+ """Unified manager for both morphological layers and annotations with flexible validation."""
35
+
36
+ def __init__(
37
+ self,
38
+ managed_layers: Optional[dict] = None,
39
+ validation: Union[str, Callable] = "any",
40
+ context: str = "layer",
41
+ initial_layers: Optional[list] = None,
42
+ ):
43
+ """Initialize the unified layer manager.
44
+
45
+ Parameters
46
+ ----------
47
+ managed_layers : dict, optional
48
+ Dictionary to store layers in. If None, creates new dict.
49
+ validation : str or callable, default 'any'
50
+ Type validation mode:
51
+ - 'any': Accept any layer type
52
+ - 'point_cloud_only': Only accept PointCloudLayer
53
+ - callable: Custom validation function
54
+ context : str, default 'layer'
55
+ Context name for error messages ('layer', 'annotation', etc.)
56
+ initial_layers : list, optional
57
+ Initial layers to add (with validation)
58
+ """
59
+ self._layers = managed_layers if managed_layers is not None else {}
60
+ self._validation = validation
61
+ self._context = context
62
+
63
+ # Add initial layers if provided
64
+ if initial_layers is not None:
65
+ for layer in initial_layers:
66
+ self._add(layer)
67
+
68
+ def _validate_layer(self, layer) -> None:
69
+ """Validate layer type based on validation mode."""
70
+ if self._validation == "any":
71
+ # Accept any layer type
72
+ return
73
+ elif self._validation == "point_cloud_only":
74
+ if not isinstance(layer, PointCloudLayer):
75
+ raise ValueError(
76
+ f"{self._context.capitalize()} layers must be PointCloudLayer instances."
77
+ )
78
+ elif callable(self._validation):
79
+ if not self._validation(layer):
80
+ raise ValueError(
81
+ f"Layer validation failed for {self._context}: {layer}"
82
+ )
83
+ else:
84
+ raise ValueError(f"Unknown validation mode: {self._validation}")
85
+
86
+ @property
87
+ def names(self) -> list:
88
+ """Return a list of managed layer names."""
89
+ return list(self._layers.keys())
90
+
91
+ def _add(
92
+ self, layer: Union[SkeletonLayer, GraphLayer, MeshLayer, PointCloudLayer]
93
+ ) -> None:
94
+ """Add a new layer to the manager. Should only be used by the Cell object."""
95
+ self._validate_layer(layer)
96
+ self._layers[layer.name] = layer
97
+
98
+ def get(self, name: str, default: Any = None):
99
+ """Get a layer by name with optional default."""
100
+ return self._layers.get(name, default)
101
+
102
+ def __getitem__(self, name: str):
103
+ return self._layers[name]
104
+
105
+ def __getattr__(self, name: str):
106
+ if name in self._layers:
107
+ return self._layers[name]
108
+ else:
109
+ raise AttributeError(
110
+ f'{self._context.capitalize()} "{name}" does not exist.'
111
+ )
112
+
113
+ def __dir__(self):
114
+ return super().__dir__() + list(self._layers.keys())
115
+
116
+ def __contains__(self, name: str) -> bool:
117
+ """Check if a layer exists by name."""
118
+ return name in self._layers
119
+
120
+ def __iter__(self):
121
+ """Iterate over managed layers in order."""
122
+ return iter(self._layers.values())
123
+
124
+ def __len__(self):
125
+ return len(self._layers)
126
+
127
+ def _remove(self, name: str) -> None:
128
+ """Remove a layer from the manager. Should only be used internally."""
129
+ if name not in self._layers:
130
+ raise ValueError(f'{self._context.capitalize()} "{name}" does not exist.')
131
+ del self._layers[name]
132
+
133
+ def describe(self) -> None:
134
+ """Generate a detailed description of all managed layers.
135
+
136
+ Shows each layer with its metrics, features, and links - same level of
137
+ detail as individual layer describe() methods.
138
+
139
+ Returns
140
+ -------
141
+ None
142
+ Always returns None (prints formatted text)
143
+ """
144
+ print(self._describe_text())
145
+
146
+ def _describe_text(self) -> str:
147
+ """Generate text-based description of all managed layers."""
148
+ lines = []
149
+
150
+ # Header with context and count
151
+ context_title = self._context.capitalize() + "s"
152
+ layer_count = len(self._layers)
153
+ lines.append(f"# {context_title} ({layer_count})")
154
+
155
+ if layer_count == 0:
156
+ lines.append("└── No layers present")
157
+ return "\n".join(lines)
158
+
159
+ # List each layer with details
160
+ layer_items = list(self._layers.items())
161
+ for i, (name, layer) in enumerate(layer_items):
162
+ is_last = i == len(layer_items) - 1
163
+ prefix = "└──" if is_last else "├──"
164
+ continuation = " " if is_last else "│ "
165
+
166
+ # Layer name and type
167
+ lines.append(f"{prefix} {name} ({layer.__class__.__name__})")
168
+
169
+ # Get metrics
170
+ metrics = layer._get_layer_metrics()
171
+ lines.append(f"{continuation}├── {metrics}")
172
+
173
+ # features
174
+ if len(layer.feature_names) > 0:
175
+ feature_list = ", ".join(layer.feature_names)
176
+ feature_line = f"{continuation}├── features: [{feature_list}]"
177
+ lines.extend(self._wrap_line(feature_line))
178
+ else:
179
+ lines.append(f"{continuation}├── features: []")
180
+
181
+ # Links
182
+ link_display = layer._get_link_display()
183
+ if link_display:
184
+ link_line = f"{continuation}└── Links: {link_display}"
185
+ lines.extend(self._wrap_line(link_line))
186
+ else:
187
+ lines.append(f"{continuation}└── Links: []")
188
+
189
+ return "\n".join(lines)
190
+
191
+ def _wrap_line(self, line: str, max_width: int = 120) -> List[str]:
192
+ """Wrap a long line to max_width characters with proper tree indentation."""
193
+ if len(line) <= max_width:
194
+ return [line]
195
+
196
+ # Find where the actual content starts (after tree characters and whitespace)
197
+ content_start = 0
198
+ for i, char in enumerate(line):
199
+ if char not in "├└─│ ":
200
+ content_start = i
201
+ break
202
+
203
+ prefix = line[:content_start]
204
+ content = line[content_start:]
205
+
206
+ # Calculate available width for content
207
+ available_width = max_width - len(prefix)
208
+
209
+ if len(content) <= available_width:
210
+ return [line]
211
+
212
+ # Split the content, preserving brackets and commas
213
+ wrapped_lines = []
214
+ current_line = prefix + content
215
+
216
+ while len(current_line) > max_width:
217
+ # Find the best place to break (prefer after comma + space)
218
+ break_pos = max_width
219
+
220
+ # Look for comma + space within reasonable range
221
+ search_start = max(max_width - 30, len(prefix))
222
+ for i in range(min(max_width, len(current_line) - 1), search_start, -1):
223
+ if current_line[i : i + 2] == ", ":
224
+ break_pos = i + 2
225
+ break
226
+
227
+ # If no good break found, break at max_width
228
+ if break_pos == max_width and break_pos < len(current_line):
229
+ # Make sure we don't break in the middle of a word
230
+ while break_pos > len(prefix) and current_line[break_pos] not in ", ":
231
+ break_pos -= 1
232
+ if break_pos <= len(prefix):
233
+ break_pos = max_width
234
+
235
+ wrapped_lines.append(current_line[:break_pos])
236
+ # Use appropriate continuation indentation based on prefix
237
+ if "└──" in prefix:
238
+ continuation_indent = " " + " " * max(0, (len(prefix) - 4))
239
+ else:
240
+ continuation_indent = "│ " + " " * max(0, (len(prefix) - 4))
241
+ current_line = continuation_indent + current_line[break_pos:].lstrip()
242
+
243
+ if current_line.strip():
244
+ wrapped_lines.append(current_line)
245
+
246
+ return wrapped_lines
247
+
248
+ def __repr__(self) -> str:
249
+ return f"LayerManager({self._context}s={list(self._layers.keys())})"
250
+
251
+
252
+ AnnotationManager = partial(
253
+ LayerManager, validation="point_cloud_only", context="annotation"
254
+ )
255
+
256
+
257
+ class Cell:
258
+ SKEL_LN = SKEL_LAYER_NAME
259
+ GRAPH_LN = GRAPH_LAYER_NAME
260
+ MESH_LN = MESH_LAYER_NAME
261
+
262
+ def __init__(
263
+ self,
264
+ name: Optional[Union[int, str]] = None,
265
+ morphsync: Optional[MorphSync] = None,
266
+ meta: Optional[dict] = None,
267
+ annotation_layers: Optional[list] = None,
268
+ ):
269
+ if morphsync is None:
270
+ self._morphsync = MorphSync()
271
+ else:
272
+ self._morphsync = copy.deepcopy(morphsync)
273
+ self._name = name
274
+ if meta is None:
275
+ self._meta = dict()
276
+ else:
277
+ self._meta = copy.copy(meta)
278
+ self._managed_layers = {}
279
+ self._layers = LayerManager(
280
+ managed_layers=self._managed_layers, validation="any", context="layer"
281
+ )
282
+ self._annotations = LayerManager(
283
+ managed_layers=None, # Separate dict for annotations
284
+ validation="point_cloud_only",
285
+ context="annotation",
286
+ initial_layers=annotation_layers,
287
+ )
288
+
289
+ @property
290
+ def name(self) -> str:
291
+ "Get the name of the cell (typically a segment id)"
292
+ return self._name
293
+
294
+ @property
295
+ def meta(self) -> dict:
296
+ "Get the metadata associated with the cell."
297
+ return self._meta
298
+
299
+ @property
300
+ def layers(self) -> LayerManager:
301
+ "Get the non-annotation layers of the cell."
302
+ return self._layers
303
+
304
+ def _get_layer(self, layer_name: str):
305
+ "Get a managed layer by name."
306
+ return self._managed_layers.get(layer_name)
307
+
308
+ @property
309
+ def skeleton(self) -> Optional[SkeletonLayer]:
310
+ "Skeleton layer for the cell, if present. Otherwise, None."
311
+ if self.SKEL_LN not in self._managed_layers:
312
+ return None
313
+ return self._managed_layers[self.SKEL_LN]
314
+
315
+ @property
316
+ def graph(self) -> Optional[GraphLayer]:
317
+ "Graph layer for the cell, if present. Otherwise, None."
318
+ if self.GRAPH_LN not in self._managed_layers:
319
+ return None
320
+ return self._managed_layers[self.GRAPH_LN]
321
+
322
+ @property
323
+ def mesh(self) -> Optional[MeshLayer]:
324
+ "Mesh layer for the cell, if present. Otherwise, None."
325
+ if self.MESH_LN not in self._managed_layers:
326
+ return None
327
+ return self._managed_layers[self.MESH_LN]
328
+
329
+ @property
330
+ def annotations(self) -> LayerManager:
331
+ "Annotation Manager for the cell, holding all annotation layers."
332
+ return self._annotations
333
+
334
+ @property
335
+ def s(self) -> Optional[SkeletonLayer]:
336
+ "Alias for skeleton."
337
+ return self.skeleton
338
+
339
+ @property
340
+ def g(self) -> Optional[GraphLayer]:
341
+ "Alias for graph."
342
+ return self.graph
343
+
344
+ @property
345
+ def m(self) -> Optional[MeshLayer]:
346
+ "Alias for mesh."
347
+ return self.mesh
348
+
349
+ @property
350
+ def a(self) -> LayerManager:
351
+ "Alias for annotations."
352
+ return self.annotations
353
+
354
+ @property
355
+ def l(self) -> LayerManager:
356
+ "Alias for layers."
357
+ return self.layers
358
+
359
+ @property
360
+ def _all_objects(self) -> dict:
361
+ "All morphological layers and annotation layers in a single dictionary."
362
+ return {**self._managed_layers, **self._annotations._layers}
363
+
364
+ def add_layer(
365
+ self,
366
+ layer: Union[PointCloudLayer, GraphLayer, SkeletonLayer, MeshLayer],
367
+ ) -> Self:
368
+ """Add a initialized layer to the MorphSync.
369
+
370
+ Parameters
371
+ ----------
372
+ layer : Union[PointCloudLayer, GraphLayer, SkeletonLayer, MeshLayer]
373
+ The layer to add.
374
+
375
+ Raises
376
+ ------
377
+ ValueError
378
+ If the layer already exists or if the layer type is incorrect.
379
+ """
380
+ name = layer.name
381
+ if name in self._managed_layers:
382
+ raise ValueError(f'Layer "{name}" already exists!')
383
+ if name == self.MESH_LN and not issubclass(type(layer), MeshLayer):
384
+ raise ValueError(f'Layer "{name}" must be a MeshLayer!')
385
+ if name == self.SKEL_LN and not issubclass(type(layer), SkeletonLayer):
386
+ raise ValueError(f'Layer "{name}" must be a SkeletonLayer!')
387
+ if name == self.GRAPH_LN and not issubclass(type(layer), GraphLayer):
388
+ raise ValueError(f'Layer "{name}" must be a GraphLayer!')
389
+ if self._morphsync != layer._morphsync:
390
+ raise ValueError("Incompatible MorphSync objects.")
391
+ self._layers._add(layer)
392
+ return self
393
+
394
+ def add_mesh(
395
+ self,
396
+ vertices: Union[np.ndarray, pd.DataFrame, MeshLayer],
397
+ faces: Optional[Union[np.ndarray, pd.DataFrame]] = None,
398
+ features: Optional[Union[dict, pd.DataFrame]] = None,
399
+ *,
400
+ vertex_index: Optional[Union[str, np.ndarray]] = None,
401
+ linkage: Optional[Link] = None,
402
+ spatial_columns: Optional[list] = None,
403
+ ) -> Self:
404
+ """Add a mesh layer to the MorphSync.
405
+
406
+ Parameters
407
+ ----------
408
+ vertices : Union[np.ndarray, pd.DataFrame, MeshLayer]
409
+ The vertices of the mesh, or a MeshLayer object.
410
+ faces : Union[np.ndarray, pd.DataFrame]
411
+ The faces of the mesh. If faces are provided as a dataframe, faces should be in dataframe indices.
412
+ features : Optional[Union[dict, pd.DataFrame]]
413
+ Additional features for the mesh. If passed as dictionary, the key is the feature name and the values are an array of feature values.
414
+ vertex_index : Optional[Union[str, np.ndarray]]
415
+ The column to use as a vertex index for the mesh, if vertices are a dataframe.
416
+ linkage : Optional[Link]
417
+ The linkage information for the mesh.
418
+ spatial_columns: Optional[list] = None
419
+ The spatial columns for the mesh, if vertices are a dataframe.
420
+
421
+ Returns
422
+ -------
423
+ Self
424
+ """
425
+ if self.mesh is not None:
426
+ raise ValueError('"Mesh already exists!')
427
+
428
+ if isinstance(spatial_columns, str):
429
+ spatial_columns = utils.process_spatial_columns(col_names=spatial_columns)
430
+ if isinstance(vertices, MeshLayer):
431
+ self.add_layer(vertices)
432
+ else:
433
+ self._layers._add(
434
+ MeshLayer(
435
+ name=self.MESH_LN,
436
+ vertices=vertices,
437
+ faces=faces,
438
+ features=features,
439
+ morphsync=self._morphsync,
440
+ spatial_columns=spatial_columns,
441
+ linkage=linkage,
442
+ vertex_index=vertex_index,
443
+ )
444
+ )
445
+ self._managed_layers[self.MESH_LN]._register_cell(self)
446
+ return self
447
+
448
+ def add_skeleton(
449
+ self,
450
+ vertices: Union[np.ndarray, pd.DataFrame, SkeletonLayer],
451
+ edges: Optional[Union[np.ndarray, pd.DataFrame]] = None,
452
+ features: Optional[Union[dict, pd.DataFrame]] = None,
453
+ root: Optional[int] = None,
454
+ *,
455
+ vertex_index: Optional[Union[str, np.ndarray]] = None,
456
+ linkage: Optional[Link] = None,
457
+ spatial_columns: Optional[list] = None,
458
+ inherited_properties: Optional[dict] = None,
459
+ ) -> Self:
460
+ """
461
+ Add a skeleton layer to the MorphSync.
462
+
463
+ Parameters
464
+ ----------
465
+ vertices : Union[np.ndarray, pd.DataFrame, SkeletonLayer]
466
+ The vertices of the skeleton, or a SkeletonLayer object.
467
+ edges : Union[np.ndarray, pd.DataFrame]
468
+ The edges of the skeleton.
469
+ features : Optional[Union[dict, pd.DataFrame]]
470
+ The features for the skeleton.
471
+ root : Optional[int]
472
+ The root vertex for the skeleton, required of the edges are not already consistent with a single root.
473
+ vertex_index : Optional[Union[str, np.ndarray]]
474
+ The vertex index for the skeleton.
475
+ linkage : Optional[Link]
476
+ The linkage information for the skeleton. Typically, you will define the source vertices for the skeleton if using a graph-to-skeleton mapping.
477
+ spatial_columns: Optional[list] = None
478
+ The spatial columns for the skeleton, if vertices are a dataframe.
479
+
480
+ Returns
481
+ -------
482
+ Self
483
+ """
484
+ if self.skeleton is not None:
485
+ raise ValueError('"Skeleton already exists!')
486
+
487
+ if isinstance(spatial_columns, str):
488
+ spatial_columns = utils.process_spatial_columns(col_names=spatial_columns)
489
+
490
+ if isinstance(vertices, SkeletonLayer):
491
+ self.add_layer(vertices)
492
+ else:
493
+ self._layers._add(
494
+ SkeletonLayer(
495
+ name=self.SKEL_LN,
496
+ vertices=vertices,
497
+ edges=edges,
498
+ features=features,
499
+ root=root,
500
+ morphsync=self._morphsync,
501
+ spatial_columns=spatial_columns,
502
+ linkage=linkage,
503
+ vertex_index=vertex_index,
504
+ inherited_properties=inherited_properties,
505
+ )
506
+ )
507
+ self._managed_layers[self.SKEL_LN]._register_cell(self)
508
+ return self
509
+
510
+ def add_graph(
511
+ self,
512
+ vertices: Union[np.ndarray, pd.DataFrame, SkeletonLayer],
513
+ edges: Optional[Union[np.ndarray, pd.DataFrame]] = None,
514
+ features: Optional[Union[dict, pd.DataFrame]] = None,
515
+ *,
516
+ vertex_index: Optional[Union[str, np.ndarray]] = None,
517
+ spatial_columns: Optional[list] = None,
518
+ linkage: Optional[Link] = None,
519
+ ) -> Self:
520
+ """
521
+ Add the core graph layer to a MeshWork object.
522
+ Additional graph layers can be used, but they must be added separately and with unique names.
523
+
524
+ Parameters
525
+ ----------
526
+ vertices : Union[np.ndarray, pd.DataFrame, SkeletonLayer]
527
+ The vertices of the graph.
528
+ edges : Union[np.ndarray, pd.DataFrame]
529
+ The edges of the graph.
530
+ features : Optional[Union[dict, pd.DataFrame]]
531
+ The features for the graph.
532
+ vertex_index : Optional[Union[str, np.ndarray]]
533
+ The vertex index for the graph.
534
+ spatial_columns: Optional[list] = None
535
+ The spatial columns for the graph, if vertices are a dataframe.
536
+
537
+ Returns
538
+ -------
539
+ Self
540
+ """
541
+ if self.graph is not None:
542
+ raise ValueError('"Graph already exists!')
543
+ if isinstance(spatial_columns, str):
544
+ spatial_columns = utils.process_spatial_columns(col_names=spatial_columns)
545
+ if isinstance(vertices, GraphLayer):
546
+ self.add_layer(
547
+ vertices,
548
+ )
549
+ else:
550
+ self._layers._add(
551
+ GraphLayer(
552
+ name=self.GRAPH_LN,
553
+ vertices=vertices,
554
+ edges=edges,
555
+ features=features,
556
+ morphsync=self._morphsync,
557
+ spatial_columns=spatial_columns,
558
+ linkage=linkage,
559
+ vertex_index=vertex_index,
560
+ )
561
+ )
562
+ self._managed_layers[self.GRAPH_LN]._register_cell(self)
563
+ return self
564
+
565
+ def add_point_annotations(
566
+ self,
567
+ name: str,
568
+ vertices: Optional[Union[np.ndarray, pd.DataFrame]] = None,
569
+ spatial_columns: Optional[list] = None,
570
+ *,
571
+ vertex_index: Optional[Union[str, np.ndarray]] = None,
572
+ features: Optional[Union[dict, pd.DataFrame]] = None,
573
+ linkage: Optional[Link] = None,
574
+ vertices_from_linkage: bool = False,
575
+ ) -> Self:
576
+ """
577
+ Add point annotations to the MeshWork object. This is intended for annotations which are typically sparse and represent specific features, unlike general point clouds that represent the morphology of the cell.
578
+
579
+ Parameters
580
+ ----------
581
+ name : str
582
+ The name of the annotation layer.
583
+ vertices : Union[np.ndarray, pd.DataFrame]
584
+ The vertices of the annotation layer.
585
+ spatial_columns : Optional[list]
586
+ The spatial columns for the annotation layer.
587
+ vertex_index : Optional[Union[str, np.ndarray]]
588
+ The vertex index for the annotation layer.
589
+ features : Optional[Union[dict, pd.DataFrame]]
590
+ The features for the annotation layer.
591
+ linkage : Optional[Link]
592
+ The linkage information for the annotation layer. Typically, you will define the target vertices for annotations.
593
+ vertices_from_linkage : bool
594
+ If True, the vertices will be inferred from the linkage mapping rather than the provided vertices. This is useful if you want to create an annotation layer that directly maps to another layer without providing separate vertex coordinates.
595
+
596
+ Returns
597
+ -------
598
+ Self
599
+ """
600
+
601
+ if name in self._managed_layers:
602
+ raise ValueError(f"Layer '{name}' already exists.")
603
+
604
+ if isinstance(spatial_columns, str) or spatial_columns is None:
605
+ spatial_columns = utils.process_spatial_columns(col_names=spatial_columns)
606
+
607
+ if vertices is None and not vertices_from_linkage:
608
+ raise ValueError(
609
+ "Either vertices or vertices_from_linkage must be provided."
610
+ )
611
+
612
+ if isinstance(vertices, PointCloudLayer):
613
+ anno = PointCloudLayer(
614
+ name=name,
615
+ vertices=vertices.vertices,
616
+ spatial_columns=vertices.spatial_columns,
617
+ morphsync=self._morphsync,
618
+ linkage=linkage,
619
+ )
620
+ else:
621
+ if vertices_from_linkage:
622
+ if not isinstance(vertices, pd.DataFrame):
623
+ raise ValueError(
624
+ "Vertices must be a DataFrame when using vertices_from_linkage."
625
+ )
626
+ if linkage.map_value_is_index:
627
+ sp_verts = (
628
+ self._all_objects[linkage.target]
629
+ .vertex_df.loc[linkage.mapping]
630
+ .values
631
+ )
632
+ else:
633
+ sp_verts = self._all_objects[linkage.target].vertices[
634
+ linkage.mapping
635
+ ]
636
+ for ii, col in enumerate(spatial_columns):
637
+ vertices[col] = sp_verts[:, ii]
638
+ anno = PointCloudLayer(
639
+ name=name,
640
+ vertices=vertices,
641
+ spatial_columns=spatial_columns,
642
+ vertex_index=vertex_index,
643
+ features=features,
644
+ morphsync=self._morphsync,
645
+ linkage=linkage,
646
+ )
647
+ anno._register_cell(self)
648
+ self._annotations._add(anno)
649
+ return self
650
+
651
+ def add_point_layer(
652
+ self,
653
+ name: str,
654
+ vertices: Union[np.ndarray, pd.DataFrame],
655
+ spatial_columns: Optional[list] = None,
656
+ *,
657
+ vertex_index: Optional[Union[str, np.ndarray]] = None,
658
+ features: Optional[Union[dict, pd.DataFrame]] = None,
659
+ linkage: Optional[Link] = None,
660
+ ) -> Self:
661
+ """
662
+ Add point layer to the MeshWork object. This is intended for general point clouds that represent the morphology of the cell, unlike annotations which are typically sparse and represent specific features.
663
+
664
+ Parameters
665
+ ----------
666
+ name : str
667
+ The name of the point layer.
668
+ vertices : Union[np.ndarray, pd.DataFrame]
669
+ The vertices of the point layer.
670
+ spatial_columns : Optional[list]
671
+ The spatial columns for the point layer.
672
+ vertex_index : Optional[Union[str, np.ndarray]]
673
+ The vertex index for the annotation layer.
674
+ features : Optional[Union[dict, pd.DataFrame]]
675
+ The features for the annotation layer.
676
+ linkage : Optional[Link]
677
+ The linkage information for the annotation layer. Typically, you will define the target vertices for annotations.
678
+
679
+ Returns
680
+ -------
681
+ Self
682
+ """
683
+
684
+ if name in self._managed_layers:
685
+ raise ValueError(f"Layer '{name}' already exists.")
686
+
687
+ if isinstance(spatial_columns, str):
688
+ spatial_columns = utils.process_spatial_columns(col_names=spatial_columns)
689
+
690
+ if isinstance(vertices, PointCloudLayer):
691
+ layer = PointCloudLayer(
692
+ name=name,
693
+ vertices=vertices.vertices,
694
+ spatial_columns=vertices.spatial_columns,
695
+ morphsync=self._morphsync,
696
+ linkage=linkage,
697
+ )
698
+ else:
699
+ layer = PointCloudLayer(
700
+ name=name,
701
+ vertices=vertices,
702
+ spatial_columns=spatial_columns,
703
+ vertex_index=vertex_index,
704
+ features=features,
705
+ morphsync=self._morphsync,
706
+ linkage=linkage,
707
+ )
708
+ layer._register_cell(self)
709
+ self._layers._add(layer)
710
+ return self
711
+
712
+ def apply_mask(
713
+ self, layer: str, mask: np.ndarray, as_positional: bool = False
714
+ ) -> Self:
715
+ """Create a new Cell with vertices masked out.
716
+
717
+ Parameters
718
+ ----------
719
+ layer : str
720
+ The layer name that the mask is based on.
721
+ mask : np.ndarray
722
+ The mask to apply. Values that are True are preserved, while values that are False are discarded.
723
+ Can be a boolean array or an array of vertices.
724
+ as_positional : bool
725
+ If mask is an array of vertices, this sets whether indices are in dataframe indices or as_positional indices.
726
+
727
+ Returns
728
+ -------
729
+ Self
730
+ """
731
+ new_morphsync = self.layers[layer]._mask_morphsync(
732
+ mask, as_positional=as_positional
733
+ )
734
+ return self.__class__._from_existing(new_morphsync, self)
735
+
736
+ def __repr__(self) -> str:
737
+ layers = self.layers.names
738
+ annos = self.annotations.names
739
+ return f"Cell(name={self.name}, layers={sorted(layers)}, annotations={annos})"
740
+
741
+ @classmethod
742
+ def _from_existing(cls, new_morphsync: MorphSync, old_obj: Self) -> Self:
743
+ """Build a new Cell from existing data and a new morphsync."""
744
+ new_obj = cls(
745
+ name=old_obj.name,
746
+ morphsync=new_morphsync,
747
+ meta=old_obj.meta,
748
+ )
749
+ for old_layer in old_obj.layers:
750
+ new_layer = old_layer.__class__._from_existing(new_morphsync, old_layer)
751
+ new_layer._register_cell(new_obj)
752
+ new_obj._layers._add(new_layer)
753
+ for old_anno in old_obj.annotations:
754
+ new_anno = old_anno.__class__._from_existing(new_morphsync, old_anno)
755
+ new_anno._register_cell(new_obj)
756
+ new_obj._annotations._add(new_anno)
757
+ return new_obj
758
+
759
+ def copy(self) -> Self:
760
+ """Create a deep copy of the Cell."""
761
+ return self.__class__._from_existing(copy.deepcopy(self._morphsync), self)
762
+
763
+ def transform(
764
+ self, transform: Union[np.ndarray, callable], inplace: bool = False
765
+ ) -> Self:
766
+ """Apply a spatial transformation to all spatial layers in the Cell.
767
+
768
+ Parameters
769
+ ----------
770
+ transform : Union[np.ndarray, callable]
771
+ If an array, must be the same shape as the vertices of the layer(s).
772
+ If a callable, must take in a (N, 3) array and return a (N, 3) array.
773
+ inplace : bool
774
+ If True, modify the current Cell. If False, return a new Cell.
775
+
776
+ Returns
777
+ -------
778
+ Self
779
+ The transformed Cell.
780
+ """
781
+ if not inplace:
782
+ target = self.copy()
783
+ else:
784
+ target = self
785
+ for layer in target._all_objects.values():
786
+ layer.transform(transform, inplace=True)
787
+ return target
788
+
789
+ @property
790
+ def features(self) -> pd.DataFrame:
791
+ """Return a DataFrame listing all feature columns across all layers. Each feature is a row, with the layer name and feature name as columns."""
792
+ all_features = []
793
+ for layer in self.layers:
794
+ all_features.append(
795
+ pd.DataFrame({"layer": layer.name, "features": layer.features.columns})
796
+ )
797
+ return pd.concat(all_features)
798
+
799
+ def get_features(
800
+ self,
801
+ features: Union[str, list],
802
+ target_layer: str,
803
+ source_layers: Optional[Union[str, list]] = None,
804
+ agg: Union[str, dict] = "median",
805
+ ) -> pd.DataFrame:
806
+ """Map feature columns from various sources to a target layer.
807
+
808
+ Parameters
809
+ ----------
810
+ features : Union[str, list]
811
+ The features to map from the source layer.
812
+ target_layer : str
813
+ The target layer to map all features to.
814
+ source_layers : Optional[Union[str, list]]
815
+ The source layers to map the features from. Unnecessary if features are unique.
816
+ agg : Union[str, dict]
817
+ The aggregation method to use when mapping the features.
818
+ Anything pandas `groupby.agg` takes, as well as "majority" which will is a majority vote across the mapped indices via the stats.mode function.
819
+
820
+ Returns
821
+ -------
822
+ pd.DataFrame
823
+ The mapped features for the target layer.
824
+ """
825
+ if isinstance(features, str):
826
+ features = [features]
827
+ if isinstance(source_layers, str):
828
+ source_layers = [source_layers]
829
+ elif source_layers is None:
830
+ source_layers = [None] * len(features)
831
+ remap_features = []
832
+ for feature, source_layer in zip(features, source_layers):
833
+ feature_row = self.features.query("features == @feature")
834
+ if feature_row.shape[0] == 0:
835
+ raise ValueError(f'feature "{feature}" not found in any layer.')
836
+ if feature_row.shape[0] > 1 and source_layer is None:
837
+ raise ValueError(
838
+ f'feature "{feature}" found in multiple layers, please specify a source layer.'
839
+ )
840
+ if source_layer is None:
841
+ source_layer = feature_row.iloc[0]["layer"]
842
+ remap_features.append(
843
+ self.layers[source_layer].map_features_to_layer(
844
+ features=feature,
845
+ layer=target_layer,
846
+ agg=agg,
847
+ )
848
+ )
849
+ return pd.concat(remap_features, axis=1)
850
+
851
+ @contextlib.contextmanager
852
+ def mask_context(
853
+ self,
854
+ layer: str,
855
+ mask: np.ndarray,
856
+ ) -> Generator[Self, None, None]:
857
+ """Create a masked version of the MeshWork object in a context state.
858
+
859
+ Parameters
860
+ ----------
861
+ layer: str
862
+ The name of the layer to which the mask applies.
863
+ mask : array or None
864
+ A boolean array with the same number of elements as mesh vertices. True elements are kept, False are masked out.
865
+
866
+ Example
867
+ -------
868
+ >>> with mesh.mask_context("layer_name", mask) as masked_mesh:
869
+ >>> result = my_favorite_function(masked_mesh)
870
+ """
871
+ nrn_out = self.apply_mask(layer, mask)
872
+ try:
873
+ yield nrn_out
874
+ finally:
875
+ pass
876
+
877
+ def _cleanup_links(self, layer_name: str) -> None:
878
+ """Remove all links involving the specified layer from MorphSync."""
879
+ links_to_remove = []
880
+ for link_key in self._morphsync.links.keys():
881
+ if layer_name in link_key:
882
+ links_to_remove.append(link_key)
883
+
884
+ for link_key in links_to_remove:
885
+ del self._morphsync.links[link_key]
886
+
887
+ def remove_layer(self, name: str) -> Self:
888
+ """Remove a morphological layer from the Cell.
889
+
890
+ Parameters
891
+ ----------
892
+ name : str
893
+ The name of the layer to remove
894
+
895
+ Returns
896
+ -------
897
+ Self
898
+ The Cell object for method chaining
899
+
900
+ Raises
901
+ ------
902
+ ValueError
903
+ If the layer does not exist or is a core layer that cannot be removed
904
+ """
905
+ # Check if layer exists
906
+ if name not in self._managed_layers:
907
+ raise ValueError(f'Layer "{name}" does not exist.')
908
+
909
+ # Prevent removal of core layers if they have specific restrictions
910
+ # (This could be extended based on business logic requirements)
911
+
912
+ # Remove from layer manager
913
+ self._layers._remove(name)
914
+
915
+ # Remove from MorphSync layers
916
+ if name in self._morphsync.layers:
917
+ del self._morphsync.layers[name]
918
+
919
+ # Clean up all related links
920
+ self._cleanup_links(name)
921
+
922
+ return self
923
+
924
+ def remove_annotation(self, name: str) -> Self:
925
+ """Remove an annotation layer from the Cell.
926
+
927
+ Parameters
928
+ ----------
929
+ name : str
930
+ The name of the annotation to remove
931
+
932
+ Returns
933
+ -------
934
+ Self
935
+ The Cell object for method chaining
936
+
937
+ Raises
938
+ ------
939
+ ValueError
940
+ If the annotation does not exist
941
+ """
942
+ # Check if annotation exists
943
+ if name not in self._annotations:
944
+ raise ValueError(f'Annotation "{name}" does not exist.')
945
+
946
+ # Remove from annotation manager
947
+ self._annotations._remove(name)
948
+
949
+ # Remove from MorphSync layers
950
+ if name in self._morphsync.layers:
951
+ del self._morphsync.layers[name]
952
+
953
+ # Clean up all related links
954
+ self._cleanup_links(name)
955
+
956
+ return self
957
+
958
+ def describe(self, html: bool = False) -> None:
959
+ """Generate a hierarchical summary description of the cell and its layers.
960
+
961
+ Provides a tree-like overview including:
962
+ - Cell name and basic info
963
+ - Layers section with expandable details
964
+ - Annotations section with expandable details
965
+
966
+ Parameters
967
+ ----------
968
+ html : bool, optional
969
+ If True, create expandable HTML widgets in Jupyter.
970
+ If False, print formatted string (default).
971
+
972
+ Returns
973
+ -------
974
+ None
975
+ Always returns None (prints text or displays HTML widgets)
976
+
977
+ Examples
978
+ --------
979
+ >>> cell.describe() # Prints formatted string
980
+ >>> cell.describe(html=True) # Shows HTML widgets in Jupyter
981
+ """
982
+ if html:
983
+ self._describe_html()
984
+ else:
985
+ print(self._describe_text())
986
+
987
+ def _describe_text(self) -> str:
988
+ """Generate text-based hierarchical description."""
989
+ lines = []
990
+
991
+ # Cell header
992
+ cell_name = str(self.name) if self.name is not None else "Unnamed"
993
+
994
+ # Count layers, annotations, and potential linkages
995
+ num_layers = self._count_total_layers()
996
+ num_annotations = len(self.annotations)
997
+ num_linkages = (
998
+ len(self._morphsync.links) if hasattr(self._morphsync, "links") else 0
999
+ )
1000
+
1001
+ lines.append(f"# Cell: {cell_name}")
1002
+
1003
+ # Layers section with count
1004
+ lines.append(f"├── Layers ({num_layers})")
1005
+
1006
+ # Always show skeleton, mesh, graph in that order
1007
+ core_layers = [
1008
+ ("skeleton", self.skeleton),
1009
+ ("mesh", self.mesh),
1010
+ ("graph", self.graph),
1011
+ ]
1012
+
1013
+ # Additional layers (non-core layers)
1014
+ core_layer_names = {self.MESH_LN, self.SKEL_LN, self.GRAPH_LN}
1015
+ additional_layers = [
1016
+ layer for layer in self.layers if layer.name not in core_layer_names
1017
+ ]
1018
+
1019
+ all_layers = core_layers + [(layer.name, layer) for layer in additional_layers]
1020
+
1021
+ for i, (layer_name, layer_obj) in enumerate(all_layers):
1022
+ is_last_layer = (
1023
+ i == len(all_layers) - 1 and num_annotations == 0 and num_linkages == 0
1024
+ )
1025
+
1026
+ if is_last_layer:
1027
+ prefix = "└──"
1028
+ else:
1029
+ prefix = "├──"
1030
+
1031
+ if layer_obj is not None:
1032
+ vertex_count = len(layer_obj.vertices)
1033
+
1034
+ if hasattr(layer_obj, "faces"): # Mesh layers
1035
+ face_count = (
1036
+ len(layer_obj.faces) if len(layer_obj.faces.shape) > 1 else 0
1037
+ )
1038
+ lines.append(
1039
+ f"│ {prefix} {layer_name}: {vertex_count} vertices, {face_count} faces"
1040
+ )
1041
+ elif hasattr(layer_obj, "edges"): # Graph/Skeleton layers
1042
+ edge_count = (
1043
+ len(layer_obj.edges) if len(layer_obj.edges.shape) > 1 else 0
1044
+ )
1045
+ lines.append(
1046
+ f"│ {prefix} {layer_name}: {vertex_count} vertices, {edge_count} edges"
1047
+ )
1048
+ else:
1049
+ lines.append(f"│ {prefix} {layer_name}: {vertex_count} vertices")
1050
+ else:
1051
+ lines.append(f"│ {prefix} {layer_name}: not present")
1052
+
1053
+ # Annotations section
1054
+ if num_annotations > 0:
1055
+ if num_linkages > 0:
1056
+ lines.append(f"├── Annotations ({num_annotations})")
1057
+ else:
1058
+ lines.append(f"└── Annotations ({num_annotations})")
1059
+
1060
+ for i, annotation in enumerate(self.annotations):
1061
+ is_last_annotation = i == len(self.annotations) - 1
1062
+
1063
+ if is_last_annotation:
1064
+ prefix = "└──"
1065
+ else:
1066
+ prefix = "├──"
1067
+
1068
+ if num_linkages == 0:
1069
+ continuation = " " if is_last_annotation else "│ "
1070
+ else:
1071
+ continuation = "│ "
1072
+
1073
+ vertex_count = len(annotation.vertices)
1074
+ lines.append(
1075
+ f"{continuation}{prefix} {annotation.name}: {vertex_count} points"
1076
+ )
1077
+
1078
+ # Linkage section (if there are any links)
1079
+ if num_linkages > 0:
1080
+ # Process links to detect bidirectional connections
1081
+ processed_pairs = set()
1082
+ link_display_items = []
1083
+
1084
+ for link_key in self._morphsync.links.keys():
1085
+ source, target = link_key
1086
+ reverse_key = (target, source)
1087
+
1088
+ # Skip if we've already processed this pair in reverse
1089
+ if reverse_key in processed_pairs:
1090
+ continue
1091
+
1092
+ # Check if bidirectional link exists
1093
+ if reverse_key in self._morphsync.links:
1094
+ # Bidirectional link
1095
+ link_display_items.append(f"{source} <-> {target}")
1096
+ processed_pairs.add(link_key)
1097
+ processed_pairs.add(reverse_key)
1098
+ else:
1099
+ # Unidirectional link
1100
+ link_display_items.append(f"{source} → {target}")
1101
+ processed_pairs.add(link_key)
1102
+
1103
+ # Show the actual number of connection pairs being displayed
1104
+ num_connections = len(link_display_items)
1105
+ lines.append(f"└── Linkage ({num_connections} connections)")
1106
+
1107
+ for i, link_display in enumerate(link_display_items):
1108
+ is_last_link = i == len(link_display_items) - 1
1109
+ prefix = "└──" if is_last_link else "├──"
1110
+ lines.append(f" {prefix} {link_display}")
1111
+
1112
+ return "\n".join(lines)
1113
+
1114
+ def _wrap_line(
1115
+ self, line: str, max_width: int, continuation_indent: str
1116
+ ) -> List[str]:
1117
+ """Wrap a long line to max_width characters with proper indentation."""
1118
+ if len(line) <= max_width:
1119
+ return [line]
1120
+
1121
+ # Find where the actual content starts (after tree characters and whitespace)
1122
+ content_start = 0
1123
+ for i, char in enumerate(line):
1124
+ if char not in "├└─│ ":
1125
+ content_start = i
1126
+ break
1127
+
1128
+ prefix = line[:content_start]
1129
+ content = line[content_start:]
1130
+
1131
+ # Calculate available width for content
1132
+ available_width = max_width - len(prefix)
1133
+
1134
+ if len(content) <= available_width:
1135
+ return [line]
1136
+
1137
+ # Split the content, preserving brackets and commas
1138
+ wrapped_lines = []
1139
+ current_line = prefix + content
1140
+
1141
+ while len(current_line) > max_width:
1142
+ # Find the best place to break (prefer after comma + space)
1143
+ break_pos = max_width
1144
+
1145
+ # Look for comma + space within reasonable range
1146
+ search_start = max(max_width - 30, len(prefix))
1147
+ for i in range(min(max_width, len(current_line) - 1), search_start, -1):
1148
+ if current_line[i : i + 2] == ", ":
1149
+ break_pos = i + 2
1150
+ break
1151
+
1152
+ # If no good break found, break at max_width
1153
+ if break_pos == max_width and break_pos < len(current_line):
1154
+ # Make sure we don't break in the middle of a word
1155
+ while break_pos > len(prefix) and current_line[break_pos] not in ", ":
1156
+ break_pos -= 1
1157
+ if break_pos <= len(prefix):
1158
+ break_pos = max_width
1159
+
1160
+ wrapped_lines.append(current_line[:break_pos])
1161
+ current_line = continuation_indent + current_line[break_pos:].lstrip()
1162
+
1163
+ if current_line.strip():
1164
+ wrapped_lines.append(current_line)
1165
+
1166
+ return wrapped_lines
1167
+
1168
+ def _describe_html(self) -> None:
1169
+ """Generate HTML widgets with expandable sections for Jupyter."""
1170
+ try:
1171
+ import uuid
1172
+
1173
+ from IPython.display import HTML, display
1174
+
1175
+ # Generate unique IDs for this cell description
1176
+ cell_id = str(uuid.uuid4())[:8]
1177
+
1178
+ # Cell name
1179
+ cell_name = str(self.name) if self.name is not None else "Unnamed"
1180
+
1181
+ # Build layers section
1182
+ layers_html = self._build_layers_html()
1183
+
1184
+ # Build annotations section
1185
+ annotations_html = self._build_annotations_html()
1186
+
1187
+ # Complete HTML with styling
1188
+ html_content = f"""
1189
+ <style>
1190
+ .cell-describe-{{
1191
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
1192
+ margin: 10px 0;
1193
+ border-left: 3px solid #007acc;
1194
+ padding-left: 10px;
1195
+ }}
1196
+ .cell-title {{
1197
+ font-size: 18px;
1198
+ font-weight: bold;
1199
+ color: var(--jp-content-font-color1, var(--vscode-foreground, #000000));
1200
+ margin-bottom: 10px;
1201
+ background: var(--jp-layout-color0, var(--vscode-editor-background, transparent));
1202
+ padding: 4px 8px;
1203
+ border-radius: 4px;
1204
+ border: 1px solid var(--jp-border-color1, var(--vscode-panel-border, #007acc));
1205
+ }}
1206
+ .section-header {{
1207
+ background: #f8f9fa;
1208
+ border: 1px solid #dee2e6;
1209
+ padding: 8px 12px;
1210
+ cursor: pointer;
1211
+ user-select: none;
1212
+ margin: 5px 0;
1213
+ border-radius: 4px;
1214
+ transition: background-color 0.2s;
1215
+ color: #000000;
1216
+ font-weight: 500;
1217
+ }}
1218
+ .section-header:hover {{
1219
+ background: #e9ecef;
1220
+ }}
1221
+ .section-header::before {{
1222
+ content: '▶ ';
1223
+ transition: transform 0.2s;
1224
+ display: inline-block;
1225
+ }}
1226
+ .section-header.expanded::before {{
1227
+ transform: rotate(90deg);
1228
+ }}
1229
+ .section-content {{
1230
+ display: none;
1231
+ padding: 10px 15px;
1232
+ margin-left: 20px;
1233
+ border-left: 2px solid #dee2e6;
1234
+ background: #fafbfc;
1235
+ }}
1236
+ .section-content.expanded {{
1237
+ display: block;
1238
+ }}
1239
+ .layer-item {{
1240
+ margin: 8px 0;
1241
+ padding: 6px;
1242
+ background: white;
1243
+ border-radius: 3px;
1244
+ border-left: 3px solid #28a745;
1245
+ }}
1246
+ .layer-item.not-present {{
1247
+ border-left-color: #dc3545;
1248
+ opacity: 0.7;
1249
+ }}
1250
+ .layer-name {{
1251
+ font-weight: bold;
1252
+ color: #495057;
1253
+ }}
1254
+ .layer-details {{
1255
+ font-size: 0.9em;
1256
+ color: #6c757d;
1257
+ margin-top: 4px;
1258
+ }}
1259
+ .annotation-item {{
1260
+ margin: 8px 0;
1261
+ padding: 6px;
1262
+ background: white;
1263
+ border-radius: 3px;
1264
+ border-left: 3px solid #17a2b8;
1265
+ }}
1266
+ </style>
1267
+
1268
+ <div class="cell-describe">
1269
+ <div class="cell-title">Cell: {cell_name}</div>
1270
+
1271
+ <div class="section-header" onclick="toggleSection('{cell_id}_layers')">
1272
+ Layers ({self._count_present_layers()}/{self._count_total_layers()})
1273
+ </div>
1274
+ <div class="section-content" id="{cell_id}_layers">
1275
+ {layers_html}
1276
+ </div>
1277
+
1278
+ <div class="section-header" onclick="toggleSection('{cell_id}_annotations')">
1279
+ Annotations ({len(self.annotations)})
1280
+ </div>
1281
+ <div class="section-content" id="{cell_id}_annotations">
1282
+ {annotations_html}
1283
+ </div>
1284
+ </div>
1285
+
1286
+ <script>
1287
+ function toggleSection(sectionId) {{
1288
+ const content = document.getElementById(sectionId);
1289
+ const header = content.previousElementSibling;
1290
+
1291
+ if (content.classList.contains('expanded')) {{
1292
+ content.classList.remove('expanded');
1293
+ header.classList.remove('expanded');
1294
+ }} else {{
1295
+ content.classList.add('expanded');
1296
+ header.classList.add('expanded');
1297
+ }}
1298
+ }}
1299
+ </script>
1300
+ """
1301
+
1302
+ display(HTML(html_content))
1303
+ return None
1304
+
1305
+ except ImportError:
1306
+ # Fallback to text version if not in Jupyter
1307
+ return self._describe_text()
1308
+
1309
+ def _count_present_layers(self) -> int:
1310
+ """Count how many core layers are present."""
1311
+ count = 0
1312
+ if self.mesh is not None:
1313
+ count += 1
1314
+ if self.skeleton is not None:
1315
+ count += 1
1316
+ if self.graph is not None:
1317
+ count += 1
1318
+ # Add additional layers
1319
+ core_layer_names = {self.MESH_LN, self.SKEL_LN, self.GRAPH_LN}
1320
+ count += len([l for l in self.layers if l.name not in core_layer_names])
1321
+ return count
1322
+
1323
+ def _count_total_layers(self) -> int:
1324
+ """Count total possible layers (3 core + additional)."""
1325
+ core_layer_names = {self.MESH_LN, self.SKEL_LN, self.GRAPH_LN}
1326
+ additional_count = len(
1327
+ [l for l in self.layers if l.name not in core_layer_names]
1328
+ )
1329
+ return 3 + additional_count
1330
+
1331
+ def _build_layers_html(self) -> str:
1332
+ """Build HTML for layers section."""
1333
+ html_parts = []
1334
+
1335
+ # Core layers
1336
+ core_layers = [
1337
+ (self.MESH_LN, self.mesh, "mesh"),
1338
+ (self.SKEL_LN, self.skeleton, "skeleton"),
1339
+ (self.GRAPH_LN, self.graph, "graph"),
1340
+ ]
1341
+
1342
+ for layer_name, layer_obj, layer_type in core_layers:
1343
+ if layer_obj is not None:
1344
+ vertex_count = len(layer_obj.vertices)
1345
+
1346
+ if hasattr(layer_obj, "faces"):
1347
+ face_count = (
1348
+ len(layer_obj.faces) if len(layer_obj.faces.shape) > 1 else 0
1349
+ )
1350
+ details = f"vertices: {vertex_count:,} | faces: {face_count:,}"
1351
+ elif hasattr(layer_obj, "edges"):
1352
+ edge_count = (
1353
+ len(layer_obj.edges) if len(layer_obj.edges.shape) > 1 else 0
1354
+ )
1355
+ details = f"vertices: {vertex_count:,} | edges: {edge_count:,}"
1356
+ else:
1357
+ details = f"vertices: {vertex_count:,}"
1358
+
1359
+ # Add features info
1360
+ if (
1361
+ hasattr(layer_obj, "feature_names")
1362
+ and len(layer_obj.feature_names) > 0
1363
+ ):
1364
+ features_str = ", ".join(layer_obj.feature_names)
1365
+ details += f" | features: [{features_str}]"
1366
+ else:
1367
+ details += " | features: []"
1368
+
1369
+ html_parts.append(f"""
1370
+ <div class="layer-item">
1371
+ <div class="layer-name">{layer_name} ({layer_type})</div>
1372
+ <div class="layer-details">{details}</div>
1373
+ </div>
1374
+ """)
1375
+ else:
1376
+ html_parts.append(f"""
1377
+ <div class="layer-item not-present">
1378
+ <div class="layer-name">{layer_name} ({layer_type})</div>
1379
+ <div class="layer-details">not present</div>
1380
+ </div>
1381
+ """)
1382
+
1383
+ # Additional layers
1384
+ core_layer_names = {self.MESH_LN, self.SKEL_LN, self.GRAPH_LN}
1385
+ for layer in self.layers:
1386
+ if layer.name not in core_layer_names:
1387
+ vertex_count = len(layer.vertices)
1388
+
1389
+ if hasattr(layer, "faces"):
1390
+ face_count = len(layer.faces) if len(layer.faces.shape) > 1 else 0
1391
+ details = f"vertices: {vertex_count:,} | faces: {face_count:,}"
1392
+ elif hasattr(layer, "edges"):
1393
+ edge_count = len(layer.edges) if len(layer.edges.shape) > 1 else 0
1394
+ details = f"vertices: {vertex_count:,} | edges: {edge_count:,}"
1395
+ else:
1396
+ details = f"vertices: {vertex_count:,}"
1397
+
1398
+ # Add features info
1399
+ if hasattr(layer, "feature_names") and len(layer.feature_names) > 0:
1400
+ features_str = ", ".join(layer.feature_names)
1401
+ details += f" | features: [{features_str}]"
1402
+ else:
1403
+ details += " | features: []"
1404
+
1405
+ html_parts.append(f"""
1406
+ <div class="layer-item">
1407
+ <div class="layer-name">{layer.name} ({layer.layer_type})</div>
1408
+ <div class="layer-details">{details}</div>
1409
+ </div>
1410
+ """)
1411
+
1412
+ return (
1413
+ "\n".join(html_parts)
1414
+ if html_parts
1415
+ else '<div style="color: #6c757d; font-style: italic;">No layers present</div>'
1416
+ )
1417
+
1418
+ def _build_annotations_html(self) -> str:
1419
+ """Build HTML for annotations section."""
1420
+ if len(self.annotations) == 0:
1421
+ return '<div style="color: #6c757d; font-style: italic;">No annotations present</div>'
1422
+
1423
+ html_parts = []
1424
+ for annotation in self.annotations:
1425
+ vertex_count = len(annotation.vertices)
1426
+ details = f"vertices: {vertex_count:,}"
1427
+
1428
+ # Add features info
1429
+ if (
1430
+ hasattr(annotation, "feature_names")
1431
+ and len(annotation.feature_names) > 0
1432
+ ):
1433
+ features_str = ", ".join(annotation.feature_names)
1434
+ details += f" | features: [{features_str}]"
1435
+ else:
1436
+ details += " | features: []"
1437
+
1438
+ html_parts.append(f"""
1439
+ <div class="annotation-item">
1440
+ <div class="layer-name">{annotation.name}</div>
1441
+ <div class="layer-details">{details}</div>
1442
+ </div>
1443
+ """)
1444
+
1445
+ return "\n".join(html_parts)