swcgeom 0.15.0__py3-none-any.whl → 0.18.3__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.

Potentially problematic release.


This version of swcgeom might be problematic. Click here for more details.

Files changed (72) hide show
  1. swcgeom/__init__.py +26 -1
  2. swcgeom/analysis/__init__.py +21 -8
  3. swcgeom/analysis/feature_extractor.py +43 -18
  4. swcgeom/analysis/features.py +250 -0
  5. swcgeom/analysis/lmeasure.py +857 -0
  6. swcgeom/analysis/sholl.py +55 -29
  7. swcgeom/analysis/trunk.py +27 -11
  8. swcgeom/analysis/visualization.py +24 -9
  9. swcgeom/analysis/visualization3d.py +100 -0
  10. swcgeom/analysis/volume.py +19 -4
  11. swcgeom/core/__init__.py +32 -9
  12. swcgeom/core/branch.py +28 -7
  13. swcgeom/core/branch_tree.py +18 -4
  14. swcgeom/core/{segment.py → compartment.py} +31 -10
  15. swcgeom/core/node.py +31 -10
  16. swcgeom/core/path.py +37 -10
  17. swcgeom/core/population.py +103 -34
  18. swcgeom/core/swc.py +26 -10
  19. swcgeom/core/swc_utils/__init__.py +21 -7
  20. swcgeom/core/swc_utils/assembler.py +27 -1
  21. swcgeom/core/swc_utils/base.py +25 -12
  22. swcgeom/core/swc_utils/checker.py +31 -14
  23. swcgeom/core/swc_utils/io.py +24 -7
  24. swcgeom/core/swc_utils/normalizer.py +20 -4
  25. swcgeom/core/swc_utils/subtree.py +17 -2
  26. swcgeom/core/tree.py +85 -72
  27. swcgeom/core/tree_utils.py +31 -16
  28. swcgeom/core/tree_utils_impl.py +18 -3
  29. swcgeom/images/__init__.py +17 -2
  30. swcgeom/images/augmentation.py +24 -4
  31. swcgeom/images/contrast.py +122 -0
  32. swcgeom/images/folder.py +97 -39
  33. swcgeom/images/io.py +108 -121
  34. swcgeom/transforms/__init__.py +28 -10
  35. swcgeom/transforms/base.py +17 -2
  36. swcgeom/transforms/branch.py +74 -8
  37. swcgeom/transforms/branch_tree.py +82 -0
  38. swcgeom/transforms/geometry.py +22 -7
  39. swcgeom/transforms/image_preprocess.py +115 -0
  40. swcgeom/transforms/image_stack.py +37 -13
  41. swcgeom/transforms/images.py +184 -7
  42. swcgeom/transforms/mst.py +20 -5
  43. swcgeom/transforms/neurolucida_asc.py +508 -0
  44. swcgeom/transforms/path.py +15 -0
  45. swcgeom/transforms/population.py +16 -3
  46. swcgeom/transforms/tree.py +89 -31
  47. swcgeom/transforms/tree_assembler.py +23 -7
  48. swcgeom/utils/__init__.py +27 -11
  49. swcgeom/utils/debug.py +15 -0
  50. swcgeom/utils/download.py +59 -21
  51. swcgeom/utils/dsu.py +15 -0
  52. swcgeom/utils/ellipse.py +18 -4
  53. swcgeom/utils/file.py +15 -0
  54. swcgeom/utils/neuromorpho.py +439 -302
  55. swcgeom/utils/numpy_helper.py +29 -4
  56. swcgeom/utils/plotter_2d.py +151 -0
  57. swcgeom/utils/plotter_3d.py +48 -0
  58. swcgeom/utils/renderer.py +49 -145
  59. swcgeom/utils/sdf.py +24 -8
  60. swcgeom/utils/solid_geometry.py +16 -3
  61. swcgeom/utils/transforms.py +17 -4
  62. swcgeom/utils/volumetric_object.py +23 -10
  63. {swcgeom-0.15.0.dist-info → swcgeom-0.18.3.dist-info}/LICENSE +1 -1
  64. {swcgeom-0.15.0.dist-info → swcgeom-0.18.3.dist-info}/METADATA +28 -24
  65. swcgeom-0.18.3.dist-info/RECORD +67 -0
  66. {swcgeom-0.15.0.dist-info → swcgeom-0.18.3.dist-info}/WHEEL +1 -1
  67. swcgeom/_version.py +0 -16
  68. swcgeom/analysis/branch_features.py +0 -67
  69. swcgeom/analysis/node_features.py +0 -121
  70. swcgeom/analysis/path_features.py +0 -37
  71. swcgeom-0.15.0.dist-info/RECORD +0 -62
  72. {swcgeom-0.15.0.dist-info → swcgeom-0.18.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,857 @@
1
+ # Copyright 2022-2025 Zexin Yuan
2
+ #
3
+ # Licensed under the Apache License, Version 2.0 (the "License");
4
+ # you may not use this file except in compliance with the License.
5
+ # You may obtain a copy of the License at
6
+ #
7
+ # http://www.apache.org/licenses/LICENSE-2.0
8
+ #
9
+ # Unless required by applicable law or agreed to in writing, software
10
+ # distributed under the License is distributed on an "AS IS" BASIS,
11
+ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12
+ # See the License for the specific language governing permissions and
13
+ # limitations under the License.
14
+
15
+
16
+ """L-Measure analysis."""
17
+
18
+ import math
19
+ from typing import Literal
20
+
21
+ import numpy as np
22
+ import numpy.typing as npt
23
+ from swcgeom.core import Branch, Compartment, Node, Tree
24
+
25
+ __all__ = ["LMeasure"]
26
+
27
+
28
+ class LMeasure:
29
+ """L-Measure analysis.
30
+
31
+ The L-Measure analysis provide a set of morphometric features for
32
+ multiple levels analysis, as described in the paper [1]_
33
+
34
+ References
35
+ ----------
36
+ .. [1] Scorcioni R, Polavaram S, Ascoli GA. L-Measure: a
37
+ web-accessible tool for the analysis, comparison and search of
38
+ digital reconstructions of neuronal morphologies. Nat Protoc.
39
+ 2008;3(5):866-76. doi: 10.1038/nprot.2008.51. PMID: 18451794;
40
+ PMCID: PMC4340709.
41
+
42
+ See Also
43
+ --------
44
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/index.htm
45
+ """
46
+
47
+ def __init__(self, compartment_point: Literal[0, -1] = -1):
48
+ """
49
+ Parameters
50
+ ----------
51
+ compartment_point : 0 | -1, default=-1
52
+ The point of the compartment to be used for the measurements.
53
+ If 0, the first point of the compartment is used. If -1, the
54
+ last point of the compartment is used.
55
+ """
56
+ super().__init__()
57
+ self.compartment_point = compartment_point
58
+
59
+ # Topological measurements
60
+
61
+ def n_stems(self, tree: Tree) -> int:
62
+ """Number of stems that is connected to soma.
63
+
64
+ This function returns the number of stems attached to the soma.
65
+ When the type of the Compartment changes from type=1 to others
66
+ it is labeled a stem. These stems can also be considered as
67
+ independent subtrees for subtree level analysis.
68
+
69
+ See Also
70
+ --------
71
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/N_stems.htm
72
+ """
73
+ return len(tree.soma().children())
74
+
75
+ def n_bifs(self, tree: Tree) -> int:
76
+ """Number of bifurcations.
77
+
78
+ This function returns the number of bifurcations for the given
79
+ input neuron. A bifurcation point has two daughters.
80
+
81
+ See Also
82
+ --------
83
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/N_bifs.htm
84
+ """
85
+ return len(tree.get_furcations())
86
+
87
+ def n_branch(self, tree: Tree) -> int:
88
+ """Number of branches.
89
+
90
+ This function returns the number of branches in the given input
91
+ neuron. A branch is one or more compartments that lie between
92
+ two branching points or between one branching point and a
93
+ termination point.
94
+
95
+ See Also
96
+ --------
97
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/N_branch.htm
98
+ """
99
+ return len(tree.get_branches())
100
+
101
+ def n_tips(self, tree: Tree) -> int:
102
+ """Number of terminal tips.
103
+
104
+ This function returns the number of terminal tips for the given
105
+ input neuron. This function counts the number of compartments
106
+ that terminate as terminal endpoints.
107
+
108
+ See Also
109
+ --------
110
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/N_tips.htm
111
+ """
112
+ return len(tree.get_tips())
113
+
114
+ def terminal_segment(self) -> int:
115
+ """Terminal Segment.
116
+
117
+ TerminalSegment is the branch that ends as a terminal branch.
118
+ This function returns "1" for all the compartments in the
119
+ terminal branch.
120
+
121
+ See Also
122
+ --------
123
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/TerminalSegment.htm
124
+ """
125
+ raise NotImplementedError()
126
+
127
+ def branch_pathlength(self, branch: Branch) -> float:
128
+ """Length of the branch.
129
+
130
+ This function returns the sum of the length of all compartments
131
+ forming the giveN_branch.
132
+
133
+ See Also
134
+ --------
135
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Branch_pathlength.htm
136
+ """
137
+ # ? as a topological measurement, this should accept a tree
138
+ return branch.length()
139
+
140
+ def contraction(self, branch: Branch) -> float:
141
+ """Contraction of the branch.
142
+
143
+ This function returns the ratio between Euclidean distance of a
144
+ branch and its path length.
145
+
146
+ See Also
147
+ --------
148
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Contraction.htm
149
+ """
150
+ # ? as a topological measurement, this should accept a tree
151
+ euclidean = branch[0].distance(branch[-1])
152
+ return euclidean / branch.length()
153
+
154
+ def fragmentation(self, branch: Branch) -> int:
155
+ """Number of compartments.
156
+
157
+ This function returns the total number of compartments that
158
+ constitute a branch between two bifurcation points or between a
159
+ bifurcation point and a terminal tip.
160
+
161
+ See Also
162
+ --------
163
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Fragmentation.htm
164
+ """
165
+ # ? as a topological measurement, this should accept a tree
166
+ return branch.number_of_edges()
167
+
168
+ def partition_asymmetry(self, n: Tree.Node) -> float:
169
+ """Partition asymmetry.
170
+
171
+ This is computed only on bifurcation. If n1 is the number of
172
+ tips on the left and n2 on the right. Asymmetry return
173
+ abs(n1-n2)/(n1+n2-2).
174
+
175
+ See Also
176
+ --------
177
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Partition_asymmetry.htm
178
+ """
179
+
180
+ children = n.children()
181
+ assert (
182
+ len(children) == 2
183
+ ), "Partition asymmetry is only defined for bifurcations"
184
+ n1 = len(children[0].subtree().get_tips())
185
+ n2 = len(children[1].subtree().get_tips())
186
+ if n1 == n2:
187
+ return 0
188
+ return abs(n1 - n2) / (n1 + n2 - 2)
189
+
190
+ def fractal_dim(self):
191
+ """Fractal dimension.
192
+
193
+ Fractal dimension (D) of neuronal branches is computedas the
194
+ slope of linear fit of regression line obtained from the
195
+ log-log plot of Path distance vs Euclidean distance.
196
+
197
+ This method of measuring the fractal follows the reference
198
+ given below by Marks & Burke, J Comp Neurol. 2007. [1]_
199
+ - When D = 1, the particle moves in a straight line.
200
+ - When D = 2, the motion is a space-filling random walk
201
+ - When D is only slightly larger than 1, the particle
202
+ trajectory resembles a country road or a dendrite branch.
203
+
204
+ References
205
+ ----------
206
+ .. [1] Marks WB, Burke RE. Simulation of motoneuron morphology
207
+ in three dimensions. I. Building individual dendritic trees.
208
+ J Comp Neurol. 2007 Aug 10;503(5):685-700.
209
+ doi: 10.1002/cne.21418. PMID: 17559104.
210
+
211
+ See Also
212
+ --------
213
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Fractal_Dim.htm
214
+ """
215
+ raise NotImplementedError()
216
+
217
+ # Branch level measurements (thickness and taper)
218
+
219
+ def taper_1(self, branch: Branch) -> float:
220
+ """The Burke Taper.
221
+
222
+ This function returns the Burke Taper. This function is
223
+ measured between two bifurcation points.
224
+
225
+ See Also
226
+ --------
227
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Taper_1.htm
228
+ """
229
+ da, db = 2 * branch[0].r, 2 * branch[-1].r
230
+ return (da - db) / branch.length()
231
+
232
+ def taper_2(self, branch: Branch) -> float:
233
+ """The Hillman taper.
234
+
235
+ This function returns the Hillman taper. This is measured
236
+ between two bifurcation points.
237
+
238
+ See Also
239
+ --------
240
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Taper_2.htm
241
+ """
242
+ da, db = 2 * branch[0].r, 2 * branch[-1].r
243
+ return (da - db) / da
244
+
245
+ def daughter_ratio(self):
246
+ """Daughter ratio.
247
+
248
+ The function returns the ratio between the bigger daughter and
249
+ the other one. A daughter is the next immediate compartment
250
+ connected to a bifurcation point.
251
+
252
+ See Also
253
+ --------
254
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Daughter_Ratio.htm
255
+ """
256
+ raise NotImplementedError()
257
+
258
+ def parent_daughter_ratio(self) -> float:
259
+ """Parent daughter ratio.
260
+
261
+ This function returns the ratio between the diameter of a
262
+ daughter and its father. One values for each daughter is
263
+ returned at each bifurcation point.
264
+
265
+ See Also
266
+ --------
267
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Parent_Daughter_Ratio.htm
268
+ """
269
+ raise NotImplementedError()
270
+
271
+ def rall_power(self, bif: Tree.Node) -> float:
272
+ """Rall Power.
273
+
274
+ Rall value is computed as the best value that fits the equation
275
+ (Bif_Dia)^Rall=(Daughter1_dia^Rall+Daughter2_dia^Rall).
276
+ According to Rall’s rule we compute rall’s power by linking the
277
+ diameter of two daughter branches to the diameter of the
278
+ bifurcating parent. We compute the best fit rall’s power within
279
+ the boundary values of [0, 5] at incremental steps of 1000
280
+ compartments. The final rall value is the idealistic n value
281
+ that can propagate the signal transmission without loss from
282
+ the starting point to the terminal point in a cable model
283
+ assumption.
284
+
285
+ See Also
286
+ --------
287
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Rall_Power.htm
288
+ """
289
+
290
+ rall_power, _, _, _ = self._rall_power(bif)
291
+ return rall_power
292
+
293
+ def _rall_power_d(self, bif: Tree.Node) -> tuple[float, float, float]:
294
+ children = bif.children()
295
+ assert len(children) == 2, "Rall Power is only defined for bifurcations"
296
+ parent = bif.parent()
297
+ assert parent is not None, "Rall Power is not defined for root"
298
+
299
+ dp = 2 * parent.r
300
+ da, db = 2 * children[0].r, 2 * children[1].r
301
+ return dp, da, db
302
+
303
+ def _rall_power(self, bif: Tree.Node) -> tuple[float, float, float, float]:
304
+ dp, da, db = self._rall_power_d(bif)
305
+ start, stop, step = 0, 5, 5 / 1000
306
+ xs = np.arange(start, stop, step)
307
+ ys = (da**xs + db**xs) - dp**xs
308
+ return xs[np.argmin(ys)], dp, da, db
309
+
310
+ def pk(self, bif: Tree.Node) -> float:
311
+ """Ratio of rall power increased.
312
+
313
+ After computing the average value for Rall_Power, this function
314
+ returns the ratio of (d1^rall+d2^rall)/(bifurcDiam^rall).
315
+
316
+ See Also
317
+ --------
318
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Pk.htm
319
+ """
320
+
321
+ rall_power, dp, da, db = self._rall_power(bif)
322
+ return (da**rall_power + db**rall_power) / dp**rall_power
323
+
324
+ def pk_classic(self, bif: Tree.Node) -> float:
325
+ """Ratio of rall power increased with fixed rall power 1.5.
326
+
327
+ This function returns the same value as Pk, but with Rall_Power
328
+ sets to 1.5.
329
+
330
+ See Also
331
+ --------
332
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Pk_classic.htm
333
+ """
334
+
335
+ dp, da, db = self._rall_power_d(bif)
336
+ rall_power = 1.5
337
+ return (da**rall_power + db**rall_power) / dp**rall_power
338
+
339
+ def pk_2(self, bif: Tree.Node) -> float:
340
+ """Ratio of rall power increased with fixed rall power 2.
341
+
342
+ This function returns the same value as Pk, but with Rall_Power
343
+ sets to 2 .
344
+
345
+ See Also
346
+ --------
347
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Pk_2.htm
348
+ """
349
+
350
+ dp, da, db = self._rall_power_d(bif)
351
+ rall_power = 2
352
+ return (da**rall_power + db**rall_power) / dp**rall_power
353
+
354
+ def bif_ampl_local(self, bif: Tree.Node) -> float:
355
+ """Bifurcation angle.
356
+
357
+ Given a bifurcation, this function returns the angle between
358
+ the first two compartments (in degree).
359
+
360
+ See Also
361
+ --------
362
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Bif_ampl_local.htm
363
+ """
364
+
365
+ v1, v2 = self._bif_vector_local(bif)
366
+ return np.degrees(angle(v1, v2))
367
+
368
+ def bif_ampl_remote(self, bif: Tree.Node) -> float:
369
+ """Bifurcation angle.
370
+
371
+ This function returns the angle between two bifurcation points
372
+ or between bifurcation point and terminal point or between two
373
+ terminal points.
374
+
375
+ See Also
376
+ --------
377
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Bif_ampl_remote.htm
378
+ """
379
+
380
+ v1, v2 = self._bif_vector_remote(bif)
381
+ return np.degrees(angle(v1, v2))
382
+
383
+ def bif_tilt_local(self, bif: Tree.Node) -> float:
384
+ """Bifuarcation tilt.
385
+
386
+ This function returns the angle between the previous
387
+ compartment of bifurcating father and the two daughter
388
+ compartments of the same bifurcation. The smaller of the two
389
+ angles is returned as the result.
390
+
391
+ Tilt is measured as outer angle between parent and child
392
+ compartments. L-Measure returns smaller angle of the two
393
+ children, but intuitively this can be viewed as the angle of
394
+ deflection of the parent orientation compared to the
395
+ orientation of the mid line of the bifurcation amplitude angle.
396
+
397
+ (i.e.) new tilt(NT) = pi - 1/2 Bif_amp_remote - old tilt(OT)
398
+
399
+ See Also
400
+ --------
401
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Bif_tilt_local.htm
402
+ """
403
+
404
+ parent = bif.parent()
405
+ assert parent is not None, "Bifurcation tilt is not defined for root"
406
+ v = parent.xyz() - bif.xyz()
407
+ v1, v2 = self._bif_vector_local(bif)
408
+
409
+ angle1 = np.degrees(angle(v, v1))
410
+ angle2 = np.degrees(angle(v, v2))
411
+ return min(angle1, angle2)
412
+
413
+ def bif_tilt_remote(self, bif: Tree.Node) -> float:
414
+ """Bifuarcation tilt.
415
+
416
+ This function returns the angle between the previous father
417
+ node of the current bifurcating father and its two daughter
418
+ nodes. A node is a terminating point or a bifurcation point or
419
+ a root point. Smaller of the two angles is returned as the
420
+ result. This angle is not computed for the root node.
421
+
422
+ Tilt is measured as outer angle between parent and child
423
+ compartments. L-Measure returns smaller angle of the two
424
+ children, but intuitively this can be viewed as the angle of
425
+ deflection of the parent orientation compared to the
426
+ orientation of the mid line of the bifurcation amplitude angle.
427
+
428
+ (i.e.) new tilt(NT) = pi - 1/2 Bif_amp_remote - old tilt(OT)
429
+
430
+ See Also
431
+ --------
432
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Bif_tilt_remote.htm
433
+ """
434
+
435
+ parent = bif.parent()
436
+ assert parent is not None, "Bifurcation tilt is not defined for root"
437
+ v = parent.xyz() - bif.xyz()
438
+ v1, v2 = self._bif_vector_remote(bif)
439
+
440
+ angle1 = np.degrees(angle(v, v1))
441
+ angle2 = np.degrees(angle(v, v2))
442
+ return min(angle1, angle2)
443
+
444
+ def bif_torque_local(self, bif: Tree.Node) -> float:
445
+ """Bifurcation torque.
446
+
447
+ This function returns the angle between the plane of previous
448
+ bifurcation and the current bifurcation. Bifurcation plane is
449
+ identified by the two daughter compartments leaving the
450
+ bifurcation.
451
+
452
+ A torque is the inner angle measured between two planes
453
+ (current & parent) of bifurcations. Plane DCE has current
454
+ bifurcation and plane CAB has parent bifurcation. Although LM
455
+ returns the absolute angle between CAB and DCE as the result,
456
+ it must be noted that intuitively what we are measuring is
457
+ relative change in the angle of second plane (DCE) with respect
458
+ to the first plane (CAB). Therefore, angles > 90 degrees should
459
+ be considered as pi - angle.
460
+
461
+ i.e. in Example1 tilt = 30deg. and in Example2 tilt = 180-110 = 70deg.
462
+
463
+ See Also
464
+ --------
465
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Bif_torque_local.htm
466
+ """
467
+
468
+ parent = bif.parent()
469
+ assert parent is not None, "Bifurcation torque is not defined for root"
470
+ idx = parent.branch().origin_id()[0]
471
+ n = parent.branch().attach.node(idx)
472
+
473
+ v1, v2 = self._bif_vector_local(n)
474
+ u1, u2 = self._bif_vector_local(bif)
475
+
476
+ n1, n2 = np.cross(v1, v2), np.cross(u1, u2)
477
+ theta_deg = np.degrees(angle(n1, n2))
478
+ raise theta_deg
479
+
480
+ def bif_torque_remote(self, bif: Tree.Node) -> float:
481
+ """Bifurcation torque.
482
+
483
+ This function returns the angle between, current plane of
484
+ bifurcation and previous plane of bifurcation. This is a
485
+ bifurcation level metric and from the figure, the current
486
+ plane of bifurcation is formed between C D and E where all
487
+ three are bifurcation points and the previous plane is formed
488
+ between A B and C bifurcation points.
489
+
490
+ A torque is the inner angle measured between two planes
491
+ (current & parent) of bifurcations. Plane DCE has current
492
+ bifurcation and plane CAB has parent bifurcation. Although LM
493
+ returns the absolute angle between CAB and DCE as the result,
494
+ it must be noted that intuitively what we are measuring is
495
+ relative change in the angle of second plane (DCE) with respect
496
+ to the first plane (CAB). Therefore, angles > 90 degrees should
497
+ be considered as pi - angle.
498
+
499
+ i.e. in Example1 tilt = 30deg. and in Example2 tilt = 180-110 = 70deg.
500
+
501
+ See Also
502
+ --------
503
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Bif_torque_remote.htm
504
+ """
505
+
506
+ parent = bif.parent()
507
+ assert parent is not None, "Bifurcation torque is not defined for root"
508
+ idx = parent.branch().origin_id()[0]
509
+ n = parent.branch().attach.node(idx)
510
+
511
+ v1, v2 = self._bif_vector_remote(n)
512
+ u1, u2 = self._bif_vector_remote(bif)
513
+
514
+ n1, n2 = np.cross(v1, v2), np.cross(u1, u2)
515
+ theta_deg = np.degrees(angle(n1, n2))
516
+ raise theta_deg
517
+
518
+ def _bif_vector_local(
519
+ self, bif: Tree.Node
520
+ ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32]]:
521
+ children = bif.children()
522
+ assert len(children) == 2, "Only defined for bifurcations"
523
+
524
+ v1 = children[0].xyz() - bif.xyz()
525
+ v2 = children[1].xyz() - bif.xyz()
526
+ return v1, v2
527
+
528
+ def _bif_vector_remote(
529
+ self, bif: Tree.Node
530
+ ) -> tuple[npt.NDArray[np.float32], npt.NDArray[np.float32]]:
531
+ children = bif.children()
532
+ assert len(children) == 2, "Only defined for bifurcations"
533
+
534
+ v1 = children[0].branch()[-1].xyz() - bif.xyz()
535
+ v2 = children[1].branch()[-1].xyz() - bif.xyz()
536
+ return v1, v2
537
+
538
+ def last_parent_diam(self, branch: Branch) -> float:
539
+ """The diameter of last bifurcation before the terminal tips.
540
+
541
+ This function returns the diameter of last bifurcation before
542
+ the terminal tips.
543
+
544
+ See Also
545
+ --------
546
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Last_parent_diam.htm
547
+ """
548
+
549
+ raise NotImplementedError()
550
+
551
+ def diam_threshold(self, branch: Branch) -> float:
552
+ """
553
+
554
+ This function returns Diameter of first compartment after the
555
+ last bifurcation leading to a terminal tip.
556
+
557
+ See Also
558
+ --------
559
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Diam_threshold.htm
560
+ """
561
+
562
+ raise NotImplementedError()
563
+
564
+ def hillman_threshold(self, branch: Branch) -> float:
565
+ """Hillman Threshold.
566
+
567
+ Computes the weighted average between 50% of father and 25% of
568
+ daughter diameters of the terminal bifurcation.
569
+
570
+ See Also
571
+ --------
572
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/HillmanThreshold.htm
573
+ """
574
+
575
+ raise NotImplementedError()
576
+
577
+ # Compartment level geometrical measurements
578
+
579
+ def diameter(self, node: Node) -> float:
580
+ """This function returns diameter of each compartment the neuron.
581
+
582
+ See Also
583
+ --------
584
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Diameter.htm
585
+ """
586
+
587
+ return 2 * node.r
588
+
589
+ def diameter_pow(self, node: Node) -> float:
590
+ """Computes the diameter raised to the power 1.5 for each compartment.
591
+
592
+ See Also
593
+ --------
594
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Diameter_pow.htm
595
+ """
596
+
597
+ return self.diameter(node) ** 1.5
598
+
599
+ def length(self, compartment: Compartment) -> float:
600
+ """Length of the compartment.
601
+
602
+ This function returns the length of compartments by computing
603
+ the distance between the two end points of a compartment.
604
+
605
+ See Also
606
+ --------
607
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Length.htm
608
+ """
609
+ return compartment.length()
610
+
611
+ def surface(self, compartment: Compartment) -> float:
612
+ """This function returns surface of the compartment.
613
+
614
+ See Also
615
+ --------
616
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Surface.htm
617
+ """
618
+ p = compartment[self.compartment_point]
619
+ return cylinder_side_surface_area(p.r, compartment.length())
620
+
621
+ def section_area(self, node: Node) -> float:
622
+ """This function returns the SectionArea of the compartment.
623
+
624
+ See Also
625
+ --------
626
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/SectionArea.htm
627
+ """
628
+ return circle_area(node.r)
629
+
630
+ def volume(self, compartment: Compartment) -> float:
631
+ """This function returns the volume of the compartment.
632
+
633
+ See Also
634
+ --------
635
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Volume.htm
636
+ """
637
+ p = compartment[self.compartment_point]
638
+ return cylinder_volume(p.r, compartment.length())
639
+
640
+ def euc_distance(self, node: Tree.Node) -> float:
641
+ """Euclidean distance from compartment to soma.
642
+
643
+ This function returns the Euclidean distance of a compartment
644
+ with respect to soma.
645
+
646
+ See Also
647
+ --------
648
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/EucDistance.htm
649
+ """
650
+
651
+ soma = node.attach.soma()
652
+ return node.distance(soma)
653
+
654
+ def path_distance(self, node: Tree.Node) -> float:
655
+ """Path distance from compartment to soma.
656
+
657
+ This function returns the PathDistance of a compartment.
658
+
659
+ See Also
660
+ --------
661
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/PathDistance.htm
662
+ """
663
+
664
+ n = node
665
+ length = 0
666
+ while (parent := n.parent()) is not None:
667
+ length += n.distance(parent)
668
+ n = parent
669
+ return length
670
+
671
+ def branch_order(self, node: Tree.Node) -> int:
672
+ """The order of the compartment.
673
+
674
+ This function returns the order of the branch with respect to soma.
675
+
676
+ See Also
677
+ --------
678
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Branch_Order.htm
679
+ """
680
+
681
+ n = node
682
+ order = 0
683
+ while n is not None:
684
+ if n.is_furcation():
685
+ order += 1
686
+ n = n.parent()
687
+ return order
688
+
689
+ def terminal_degree(self, node: Tree.Node) -> int:
690
+ """The number of tips of the comparment.
691
+
692
+ This function gives the total number of tips that each
693
+ compartment will terminate into.
694
+
695
+ See Also
696
+ --------
697
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Terminal_degree.htm
698
+ """
699
+
700
+ return len(node.subtree().get_tips())
701
+
702
+ def helix(self, compartment: Tree.Compartment) -> float:
703
+ """Helix of the compartment.
704
+
705
+ The function computes the helix by choosing the 3 segments at a
706
+ time (or four points at a time) and computes the normal form on
707
+ the 3 vectors to find the 4th vector.
708
+
709
+ See Also
710
+ --------
711
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Helix.htm
712
+ """
713
+
714
+ n1 = compartment.attach.node(compartment.origin_id()[0])
715
+ n2 = compartment.attach.node(compartment.origin_id()[1])
716
+ parent = n1.parent()
717
+ assert parent is not None
718
+ grandparent = parent.parent()
719
+ assert grandparent is not None
720
+
721
+ a = n1.xyz() - parent.xyz()
722
+ b = parent.xyz() - grandparent.xyz()
723
+ c = n2.xyz() - n1.xyz()
724
+ return np.dot(np.cross(a, b), c) / (
725
+ 3 * np.linalg.norm(a) * np.linalg.norm(b) * np.linalg.norm(c)
726
+ )
727
+
728
+ # Whole-arbor measurements
729
+
730
+ def width(self, tree: Tree) -> float:
731
+ """With of the neuron.
732
+
733
+ Width is computed on the x-coordinates and it is the difference
734
+ of minimum and maximum x-values after eliminating the outer
735
+ points on the either ends by using the 95% approximation of the
736
+ x-values of the given input neuron.
737
+
738
+ See Also
739
+ --------
740
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Width.htm
741
+ """
742
+ return max_filtered_difference(tree.x(), 95)
743
+
744
+ def height(self, tree: Tree) -> float:
745
+ """Height of the neuron.
746
+
747
+ Height is computed on the y-coordinates and it is the
748
+ difference of minimum and maximum y-values after eliminating
749
+ the outer points on the either ends by using the 95%
750
+ approximation of the y-values of the given input neuron.
751
+
752
+ See Also
753
+ --------
754
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Height.htm
755
+ """
756
+ return max_filtered_difference(tree.y(), 95)
757
+
758
+ def depth(self, tree: Tree) -> float:
759
+ """Depth of the neuron.
760
+
761
+ Depth is computed on the x-coordinates and it is the difference
762
+ of minimum and maximum x-values after eliminating the outer
763
+ points on the either ends by using the 95% approximation of the
764
+ x-values of the given input neuron.
765
+
766
+ See Also
767
+ --------
768
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Depth.htm
769
+ """
770
+ return max_filtered_difference(tree.z(), 95)
771
+
772
+ # Specific-arbor type measurements
773
+
774
+ def soma_surface(self, tree: Tree) -> float:
775
+ """Soma surface area.
776
+
777
+ This function computes the surface of the soma (Type=1). If the
778
+ soma is composed of just one compartment, then it uses the
779
+ sphere assumption, otherwise it returns the sum of the external
780
+ cylindrical surfaces of compartments forming the soma. There
781
+ can be multiple soma's, one soma or no soma at all.
782
+
783
+ See Also
784
+ --------
785
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Soma_Surface.htm
786
+ """
787
+ return sphere_surface_area(tree.soma().r) # TODO: handle multiple soma
788
+
789
+ def type(self):
790
+ """The type of compartment.
791
+
792
+ This function returns type of compartment. Each compartment of
793
+ the neuron is of a particular type; soma = 1, axon = 2, basal
794
+ dendrites = 3, apical dendrites= 4. The type values are
795
+ assigned directly from the given input neuron.
796
+
797
+ See Also
798
+ --------
799
+ L-Measure: http://cng.gmu.edu:8080/Lm/help/Type.htm
800
+ """
801
+ raise NotImplementedError()
802
+
803
+
804
+ def max_filtered_difference(
805
+ values: npt.NDArray[np.float32], percentile: float = 95
806
+ ) -> float:
807
+ assert 0 < percentile < 100, "Percentile must be between 0 and 100"
808
+ sorted = np.sort(values)
809
+ lower_index = int(len(sorted) * (100 - percentile) / 200)
810
+ upper_index = int(len(sorted) * (1 - (100 - percentile) / 200))
811
+ filtered = sorted[lower_index:upper_index]
812
+ difference = filtered[-1] - filtered[0]
813
+ return difference
814
+
815
+
816
+ def circle_area(r: float) -> float:
817
+ return math.pi * r**2
818
+
819
+
820
+ def sphere_surface_area(r: float):
821
+ return 4 * math.pi * r**2
822
+
823
+
824
+ def cylinder_volume(r: float, h: float) -> float:
825
+ return math.pi * r**2 * h
826
+
827
+
828
+ def cylinder_side_surface_area(r: float, h: float):
829
+ return 2 * math.pi * r * h
830
+
831
+
832
+ def pill_surface_area(ra: float, rb: float, h: float) -> float:
833
+ lateral_area = math.pi * (ra + rb) * math.sqrt((ra - rb) ** 2 + h**2)
834
+ top_hemisphere_area = 2 * math.pi * ra**2
835
+ bottom_hemisphere_area = 2 * math.pi * rb**2
836
+ total_area = lateral_area + top_hemisphere_area + bottom_hemisphere_area
837
+ return total_area
838
+
839
+
840
+ # TODO: move to `utils`
841
+ def angle(a: npt.ArrayLike, b: npt.ArrayLike) -> float:
842
+ """Get the angle of vectors.
843
+
844
+ Returns
845
+ -------
846
+ angle : float
847
+ Angle [0, PI) in radians.
848
+ """
849
+
850
+ a, b = np.array(a), np.array(b)
851
+ if np.linalg.norm(a) == 0 or np.linalg.norm(b) == 0:
852
+ raise ValueError("Input vectors must not be zero vectors.")
853
+
854
+ costheta = np.dot(a, b) / (np.linalg.norm(a) * np.linalg.norm(b))
855
+ costheta = np.clip(costheta, -1, 1) # avoid numerical errors
856
+ theta = np.arccos(costheta)
857
+ return theta