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/__init__.py +6 -0
- ossify/algorithms.py +514 -0
- ossify/base.py +1445 -0
- ossify/data_layers.py +2706 -0
- ossify/file_io.py +958 -0
- ossify/graph_functions.py +905 -0
- ossify/plot.py +1428 -0
- ossify/sync_classes.py +91 -0
- ossify/test.ipynb +472 -0
- ossify/translate.py +238 -0
- ossify/utils.py +252 -0
- ossify-0.0.1.dist-info/METADATA +22 -0
- ossify-0.0.1.dist-info/RECORD +15 -0
- ossify-0.0.1.dist-info/WHEEL +4 -0
- ossify-0.0.1.dist-info/licenses/LICENSE +21 -0
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)
|