Repository navigation
Expand file tree
/
Copy pathPath.java
More file actions
4353 lines (3883 loc) · 147 KB
/
Copy pathPath.java
File metadata and controls
4353 lines (3883 loc) · 147 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
/*-
* #%L
* Fiji distribution of ImageJ for the life sciences.
* %%
* Copyright (C) 2010 - 2026 Fiji developers.
* %%
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as
* published by the Free Software Foundation, either version 3 of the
* License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public
* License along with this program. If not, see
* <http://www.gnu.org/licenses/gpl-3.0.html>.
* #L%
*/
package sc.fiji.snt;
import ij.ImagePlus;
import ij.measure.Calibration;
import ij3d.Content;
import ij3d.Image3DUniverse;
import ij3d.Pipe;
import ij3d.Utils;
import org.apache.commons.math3.analysis.interpolation.LinearInterpolator;
import org.apache.commons.math3.analysis.polynomials.PolynomialFunction;
import org.apache.commons.math3.analysis.polynomials.PolynomialSplineFunction;
import org.apache.commons.math3.stat.regression.SimpleRegression;
import org.apache.commons.math3.util.MathUtils;
import org.scijava.util.ColorRGB;
import org.scijava.util.ColorRGBA;
import org.jogamp.vecmath.Color3f;
import org.jogamp.vecmath.Point3f;
import org.jogamp.vecmath.Vector3d;
import sc.fiji.snt.analysis.PathProfiler;
import sc.fiji.snt.annotation.BrainAnnotation;
import sc.fiji.snt.gui.GuiUtils;
import sc.fiji.snt.hyperpanes.MultiDThreePanes;
import sc.fiji.snt.util.*;
import java.awt.*;
import java.util.List;
import java.util.function.DoublePredicate;
import java.util.*;
/**
* This class represents a traced segment (i.e., a <i>Path</i>) in a
* reconstruction. It has methods to manipulate its points (nodes) with
* sup-pixel accuracy, including drawing them onto threePane-style canvases,
* etc.
* <p>
* Paths are non-branching sequences of adjacent points (including diagonals) in
* the image. Branches and joins are supported by attributes of paths that
* specify that they begin on (or end on) other paths.
* </p>
* <p>
* In SNT, a Path can exist in two versions or flavors: itself and a fitted
* version generated by {@link PathFitter}. Because fitting may fail around
* certain nodes, the fitted version may have a different number of nodes
* relatively to its non-fitted version
* </p>
**/
public class Path implements Comparable<Path>, Cloneable {
static { net.imagej.patcher.LegacyInjector.preinit(); } // required for _every_ class that imports ij. classes
// https://swc-specification.readthedocs.io/en/latest/
/** SWC type flag specifying {@value #SWC_UNDEFINED_LABEL} */
public static final int SWC_UNDEFINED = 0;
/** SWC type flag specifying {@value #SWC_SOMA_LABEL} */
public static final int SWC_SOMA = 1;
/** SWC type flag specifying {@value #SWC_AXON_LABEL} */
public static final int SWC_AXON = 2;
/** SWC type flag specifying {@value #SWC_DENDRITE_LABEL} */
public static final int SWC_DENDRITE = 3;
/** SWC type flag specifying {@value #SWC_APICAL_DENDRITE_LABEL} */
public static final int SWC_APICAL_DENDRITE = 4;
/** SWC type flag specifying {@value #SWC_CUSTOM_LABEL} */
public static final int SWC_CUSTOM = 5;
/** SWC type flag specifying {@value #SWC_UNSPECIFIED_LABEL} */
public static final int SWC_UNSPECIFIED = 6;
/** SWC type flag specifying {@value #SWC_GLIA_PROCESS_LABEL} */
public static final int SWC_GLIA_PROCESS = 7;
/** SWC type flag specifying {@value #SWC_CUSTOM2_LABEL} */
public static final int SWC_CUSTOM2 = 8;
/** Deprecated. No longer part of the SWC specification */
@Deprecated
public static final int SWC_FORK_POINT = -5;
/** Deprecated. No longer part of the SWC specification */
@Deprecated
public static final int SWC_END_POINT = -6;
/** String representation of {@link Path#SWC_UNDEFINED} */
public static final String SWC_UNDEFINED_LABEL = "undefined";
/** String representation of {@link Path#SWC_SOMA} */
public static final String SWC_SOMA_LABEL = "soma";
/** String representation of {@link Path#SWC_AXON} */
public static final String SWC_AXON_LABEL = "axon";
/** String representation of {@link Path#SWC_DENDRITE} */
public static final String SWC_DENDRITE_LABEL = "(basal) dendrite";
/** String representation of {@link Path#SWC_APICAL_DENDRITE} */
public static final String SWC_APICAL_DENDRITE_LABEL = "apical dendrite";
/** String representation of {@link Path#SWC_CUSTOM} */
public static final String SWC_CUSTOM_LABEL = "custom";
/** String representation of {@link Path#SWC_UNSPECIFIED} */
public static final String SWC_UNSPECIFIED_LABEL = "unspecified neurite";
/** String representation of {@link Path#SWC_GLIA_PROCESS} */
public static final String SWC_GLIA_PROCESS_LABEL = "glia process";
/** String representation of {@link Path#SWC_CUSTOM2} */
public static final String SWC_CUSTOM2_LABEL = "custom (2)";
// Default sampling interval for 3D visualization - can be overridden based on path characteristics
protected static final int DEFAULT_SAMPLING_INTERVAL = 2;
/**
* @deprecated Use {@link #DEFAULT_SAMPLING_INTERVAL} instead
*/
@Deprecated
protected static final int noMoreThanOneEvery = DEFAULT_SAMPLING_INTERVAL;
/**
* Calculates an appropriate sampling interval for 3D visualization based on path characteristics.
* Uses path length and node density to determine optimal sampling.
*
* @return the recommended sampling interval
*/
private int calculateSamplingInterval() {
if (size() <= DEFAULT_SAMPLING_INTERVAL) return 1;
// For short paths, use minimal sampling
if (size() <= 10) return 1;
// For longer paths, calculate based on average inter-node distance
final double avgDistance = getLength() / (size() - 1);
final double minSpacing = getMinimumSeparation();
// If nodes are very close together, increase sampling interval
if (avgDistance < minSpacing * 2) {
return Math.min(size() / 10, 5); // Max interval of 5
}
return DEFAULT_SAMPLING_INTERVAL;
}
/**
* Enhanced PointInImage that stores all node-specific properties.
* This eliminates the need for parallel arrays and provides better
* memory efficiency and data encapsulation.
*/
public static class PathNode extends PointInImage implements Cloneable {
private Color nodeColor;
private double[] tangent; // [x, y, z]
public PathNode(final double x, final double y, final double z) {
super(x, y, z);
}
public PathNode(final SNTPoint point) {
super(point.getX(), point.getY(), point.getZ());
if (point instanceof PointInImage pim) {
this.v = pim.v;
this.setAnnotation(pim.getAnnotation());
this.setHemisphere(pim.getHemisphere());
this.radius = pim.radius;
}
}
// Node color management
public Color getColor() { return nodeColor; }
public boolean hasColor() { return nodeColor != null; }
public void setColor(final Color color) { this.nodeColor = color; }
// Radius management
public double getRadius() { return radius; }
public boolean hasRadius() { return radius > 0d; }
public void setRadius(final double radius) { this.radius = radius; }
// Tangent management
public double[] getTangent() { return tangent; }
public boolean hasTangent() { return tangent != null; }
public void setTangent(final double[] tangent) { this.tangent = tangent; }
public void setTangent(final double tx, final double ty, final double tz) {
this.tangent = new double[]{tx, ty, tz};
}
private Vector3d asVector() {
return new Vector3d(x, y, z);
}
/**
* Creates a deep copy of this PathNode.
* <p>
* This method creates a complete copy of the node including all properties
* such as color, radius, and tangent information. The cloned node will have
* the same spatial coordinates and metadata as the original.
* </p>
*
* @return a new PathNode that is a deep copy of this node
*/
@Override
public PathNode clone() {
try {
// Call super.clone() to get a shallow copy
PathNode cloned = (PathNode) super.clone();
// Deep clone mutable fields
cloned.tangent = this.tangent != null ? this.tangent.clone() : null;
return cloned;
} catch (final Exception e) {
// This should never happen since we implement Cloneable
throw new AssertionError("Clone not supported", e);
}
}
}
/* Path properties */
// Single collection of enhanced nodes (replaces all parallel arrays previous to SNTv5)
private final List<PathNode> nodes;
/*
* Path identifiers: this Path's id is stored in (lower) bits 15-0. Tree id in
* the (upper) bits 31-16. NB: should only be assigned by PathAndFillManager.
* NB: A regular path should have a path ID > 0, because if present, its fitted
* version will have an ID of -path ID. see {@link #setFitted(Path)}
*/
private long id = -1L;
private String treeLabel;
// NB: The legacy 3D viewer requires always a unique name
private String name;
// Path based ordering akin of reverse Horton-Strahler numbers
private int order = 1;
// The SWC-type flag of this path
int swcType = SWC_UNDEFINED;
// is this path selected in the UI?
private boolean selected;
// the node being edited when in 'Analysis mode'
private int editableNodeIndex = -1;
// whether node being edited is locked
private boolean editableNodeLocked;
// the display offset for this Path in a tracing canvas. This allows to overlay the path
// away from the signal without changing its coordinates
protected PointInCanvas canvasOffset = new PointInCanvas(0, 0, 0);
// the channel and frame associated with this Path (1-based indices)
private final int[] ctPosition;
// the number of spines or varicosities associated with this path.
//TODO: This should be move to PathNode so that such count can be assigned to individual nodes
private int spinesOrVaricosities;
/* Spatial calibration definitions */
protected double x_spacing;
protected double y_spacing;
protected double z_spacing;
protected String spacing_units;
/* Branching */
protected Path parentPath;
protected int branchPointIndex = -1;
// This is a symmetrical relationship, showing
// all the other paths this one is joined to...
protected ArrayList<Path> connectedPaths;
// We almost always impose tree structure on the Path graph.
// When this is done, we regenerated this list. This should
// always be a subset of 'somehowJoins'...
protected ArrayList<Path> children;
/* Fitting (Path refinement) */
// If this path has a fitted version, this is it
protected Path fitted;
// Prefer the fitted flavor of this path
private boolean useFitted;
// If this path is a fitted version of another one, this is the original
protected Path fittedVersionOf;
/* General color definitions (in addition to node colors) */
private Color color;
private Color3f realColor;
private boolean hasCustomColor;
private final List<PathChangeListener> changeListeners;
/**
* Instantiates a new path under default settings (isotropic 1um pixel spacing)
*/
public Path() {
this(1, 1, 1, GuiUtils.micrometer());
}
/**
* Instantiates a new path.
*
* @param x_spacing Pixel width in spacing_units
* @param y_spacing Pixel height in spacing_units
* @param z_spacing Pixel depth in spacing_units
* @param spacing_units the length unit in physical world units (typically "um").
*/
public Path(final double x_spacing, final double y_spacing,
final double z_spacing, final String spacing_units) {
this.x_spacing = x_spacing;
this.y_spacing = y_spacing;
this.z_spacing = z_spacing;
this.spacing_units = SNTUtils.getSanitizedUnit(spacing_units);
this.nodes = new ArrayList<>();
connectedPaths = new ArrayList<>();
children = new ArrayList<>();
ctPosition = new int[] {1, 1};
changeListeners = new ArrayList<>();
}
/* (non-Javadoc)
* @see java.lang.Comparable#compareTo(java.lang.Object)
*/
@Override
public int compareTo(final Path o) {
return Long.compare(id, o.id);
}
/**
* Gets the identifier of this Path
*
* @return the identifier
*/
public int getID() {
return (int) id; // (lower) bits 15-0
}
/**
* Gets the identifier of the {@link Tree} associated to this Path (if any).
*
* @return the Tree identifier
*/
public int getTreeID() {
return (int) (id >> 32); // (upper) bits 31-16
}
protected void setIDs(final int pathID, final int treeID) {
final int oldId = getID();
// pathID to fill lower bits, treeID to fill upper bits
id = (((long) treeID) << 32) | (pathID & 0xffffffffL);
changeListeners.forEach(l -> l.pathChanged(
new PathChangeEvent(this, PathChangeEvent.EventType.ID_CHANGED, oldId, getID())));
}
protected void setTreeLabel(final String treeLabel) {
this.treeLabel = treeLabel;
if (getFitted() != null) getFitted().setTreeLabel(treeLabel);
}
public String getTreeLabel() {
if (isFittedVersionOfAnotherPath()) {
return fittedVersionOf.getTreeLabel();
}
return (treeLabel == null) ? String.format("Arbor %d C%dT%d", getTreeID(), getChannel(), getFrame()) : treeLabel;
}
/**
* Specifies a translation offset when rendering this Path in a
* {@link TracerCanvas}. Path coordinates remain unaltered.
*
* @param canvasOffset the x,y,z coordinates (pixel-based) specifying the
* translation offset
*/
public void setCanvasOffset(final PointInCanvas canvasOffset) {
this.canvasOffset = canvasOffset;
if (getFitted() != null) getFitted().setCanvasOffset(canvasOffset);
}
/**
* Returns the translation offset used to render this Path in a
* {@link TracerCanvas}.
*
* @return the rendering offset (in pixel coordinates)
*/
public PointInCanvas getCanvasOffset() {
canvasOffset.onPath = this;
return canvasOffset;
}
/**
* Gets the parent path that this path branches from.
* <p>
* Returns the path from which this path originates as a branch. For primary
* (root) paths, this method returns null since they don't branch from any
* other path. The parent-child relationship is established when a path is
* created as a branch using {@link #setBranchFrom(Path, PointInImage)}.
* </p>
*
* @return the parent path, or null if this is a primary (root) path
*
* @see #setBranchFrom(Path, PointInImage) for establishing parent-child relationships
* @see #isPrimary() for checking if this is a root path
* @see #getBranchPoint() for the specific branching point
*/
public Path getParentPath() {
return parentPath;
}
/**
* @deprecated use {@link #getParentPath()} instead
*/
@Deprecated
public Path getStartJoins() {
return getParentPath();
}
/**
* Gets the branch point (junction) where this path starts, i.e., connects to its parent path.
*
* @return the start junction point, or null if this is a primary path
* @see #getBranchPoints()
*/
public PointInImage getBranchPoint() {
if (parentPath == null || branchPointIndex < 0 || branchPointIndex >= parentPath.size()) {
return null;
}
return parentPath.getNode(branchPointIndex);
}
/**
* Gets the index of the branch point in the parent path.
*
* @return the branch point index, or -1 if this is a primary path
*/
public int getBranchPointIndex() {
return branchPointIndex;
}
/**
* @deprecated Use {@link #getBranchPoint()} instead.
*
* <p>
* NB: Earlier versions of Simple Neurite Tracer allowed paths to connect at either the start
* point (getStartJoinsPoint()) or end-point (getStartJoinsPoint()). Useful for some
* applications but a huge headache to handle.
* </p>
*/
@Deprecated
public PointInImage getStartJoinsPoint() {
return getBranchPoint();
}
/**
* Sets this Path's name. Set it to null or {@code ""}, to reset it to the
* default.
*
* @param newName the new name.
* @see #getName()
*/
public void setName(final String newName) {
final String oldName = getName();
this.name = newName;
getName(); // assign default if newName is null
changeListeners.forEach(l -> l.pathChanged(
new PathChangeEvent(this, PathChangeEvent.EventType.NAME_CHANGED, oldName, getName())));
}
/**
* Gets this Path's name.
*
* @return the name. If no name as been set, the default name is returned.
* @see #setName(String)
*/
public String getName() {
if (name == null || name.isEmpty()) name = "Path " + getID();
return name;
}
protected static String pathsToIDListString(final ArrayList<Path> a) {
final StringBuilder s = new StringBuilder();
final int n = a.size();
for (int i = 0; i < n; ++i) {
s.append(a.get(i).getID());
if (i < n - 1) {
s.append(",");
}
}
return s.toString();
}
protected String somehowJoinsAsString() {
return pathsToIDListString(connectedPaths);
}
protected String childrenAsString() {
return pathsToIDListString(children);
}
protected void setChildren(final Set<Path> pathsLeft) {
// Set the children of this path in a breadth first fashion:
children.clear();
for (final Path c : connectedPaths) {
if (pathsLeft.contains(c)) {
children.add(c);
pathsLeft.remove(c);
}
}
for (final Path c : children)
c.setChildren(pathsLeft);
}
/**
* Returns the ratio between the "Euclidean distance" of this path and its
* length. The Euclidean distance of this path is defined as the distance
* between this Path's start and end point.
*
* @return the Contraction of this Path, or {@code NaN} if this Path has no length
*/
public double getContraction() {
final double length = getLength();
if (length == 0) return Double.NaN;
final double eDistance = getNode(size() - 1).distanceTo(getNode(0));
return eDistance / length;
}
/**
* Gets the fractal dimension of this path.
*
* @return the fractal dimension of this path or Double.NaN if this path has less than 5 nodes
*/
public double getFractalDimension() {
if (size() < 5) { // Must have at least 4 points after the start-node
return Double.NaN;
}
final List<Double> pathDists = new ArrayList<>();
final List<Double> eucDists = new ArrayList<>();
for (int i = 1; i < size(); i++) { //Start at the second node
double pDist = getNodeWithoutChecks(i).distanceTo(getNodeWithoutChecks(i - 1));
if (!pathDists.isEmpty()) {
double cumDist = pathDists.get(i - 2) + pDist;
pathDists.add(cumDist);
} else {
pathDists.add(pDist);
}
double eDist = getNodeWithoutChecks(i).distanceTo(getNodeWithoutChecks(0));
eucDists.add(eDist);
}
double numerator = 0.0;
for (int i = 0; i < eucDists.size(); i++) {
numerator += Math.log(1 + eucDists.get(i)) * Math.log(1 + pathDists.get(i));
}
double denominator = 0.0;
for (final double eucDist : eucDists) {
denominator += Math.log(1 + eucDist) * Math.log(1 + eucDist);
}
return numerator / denominator;
}
/**
* Gets the length of this Path
*
* @return the length of this Path
*/
public double getLength() {
if (nodes.size() < 2) return 0;
double totalLength = 0;
PathNode prevNode = nodes.get(0);
for (int i = 1; i < nodes.size(); ++i) {
final PathNode currentNode = nodes.get(i);
final double xdiff = currentNode.x - prevNode.x;
final double ydiff = currentNode.y - prevNode.y;
final double zdiff = currentNode.z - prevNode.z;
totalLength += Math.sqrt(xdiff * xdiff + ydiff * ydiff + zdiff * zdiff);
prevNode = currentNode;
}
return totalLength;
}
/**
* Computes the angle between the specified node and its two flanking neighbors.
* <p>
* With B being the specified node, A its previous neighbor, and C is next neighbor, computes the angle
* between the vectors AB, and BC.
* </p>
* @return the angle in degrees (0-360 range) or Double.NaN if specified node does not have sufficient neighbors
*/
public double getAngle(final int nodeIndex) {
if (nodeIndex < 3)
return Double.NaN;
final PointInImage a = getNode(nodeIndex - 1);
final PointInImage b = getNode(nodeIndex - 2);
final PointInImage c = getNode(nodeIndex);
final Vector3d v1 = new Vector3d(a.getX() - b.getX(), a.getY() - b.getY(), a.getZ() - b.getZ()); // BA
final Vector3d v2 = new Vector3d(c.getX() - b.getX(), c.getY() - b.getY(), c.getZ() - b.getZ()); // BC
v1.normalize();
v2.normalize();
double angle = Math.acos(v1.dot(v2));
angle = MathUtils.normalizeAngle(angle, Math.PI); // normalize angle between 0 and 2PI
return Math.toDegrees(angle);
}
/**
* Returns the overall extension angle of this path in the XY plane.
* The angle is obtained from the slope of a linear regression across all the path nodes.
*
* @return the overall 'extension' angle in degrees [0-360[ of this path in the XY plane.
*/
public double getExtensionAngleXY() {
return getExtensionAngle(MultiDThreePanes.XY_PLANE, false);
}
/**
* Returns the overall extension angle of this path in the XZ plane.
* The angle is obtained from the slope of a linear regression across all the path nodes.
*
* @return the overall 'extension' angle in degrees [0-360[ in the XZ plane or NaN if path is 2D.
*/
public double getExtensionAngleXZ() {
return (is3D()) ? getExtensionAngle(MultiDThreePanes.XZ_PLANE, false) : Double.NaN;
}
/**
* Returns the overall extension angle of this path in the ZY plane.
* The angle is obtained from the slope of a linear regression across all the path nodes.
*
* @return the overall 'extension' angle in degrees [0-360[ in the ZY plane or NaN if path is 2D.
*/
public double getExtensionAngleZY() {
return (is3D()) ? getExtensionAngle(MultiDThreePanes.ZY_PLANE, false) : Double.NaN;
}
private boolean is3D() throws IllegalArgumentException {
final double zRef = getNodeWithoutChecks(0).getZ();
for (int i=0; i< size(); i++) {
if (getNodeWithoutChecks(i).getZ() != zRef) return true;
}
return false;
}
private double getExtensionAngle(final int view, final boolean relative) {
if (!relative)
return getExtensionAngle(view);
if (parentPath == null)
return Double.NaN;
return getExtensionAngle3D(parentPath.getExtensionDirection3D());
}
private double getExtensionAngle(final int view) {
if (size() == 1)
return Double.NaN;
double slope;
if (size() == 2) {
slope = switch (view) {
case MultiDThreePanes.XY_PLANE -> getNodeWithoutChecks(1).y - getNodeWithoutChecks(0).y
/ getNodeWithoutChecks(1).x - getNodeWithoutChecks(0).x;
case MultiDThreePanes.XZ_PLANE -> getNodeWithoutChecks(1).z - getNodeWithoutChecks(0).z
/ getNodeWithoutChecks(1).x - getNodeWithoutChecks(0).x;
case MultiDThreePanes.ZY_PLANE -> getNodeWithoutChecks(1).y - getNodeWithoutChecks(0).y
/ getNodeWithoutChecks(1).z - getNodeWithoutChecks(0).z;
default -> throw new IllegalArgumentException("Not a valid plane");
};
} else {
final SimpleRegression sr = new SimpleRegression();
switch (view) {
case MultiDThreePanes.XY_PLANE:
getNodes().forEach(node -> sr.addData(node.x, node.y));
break;
case MultiDThreePanes.XZ_PLANE:
getNodes().forEach(node -> sr.addData(node.x, node.z));
break;
case MultiDThreePanes.ZY_PLANE:
getNodes().forEach(node -> sr.addData(node.z, node.y));
break;
default:
throw new IllegalArgumentException("Not a valid plane");
}
slope = sr.getSlope(); // Get the slope from the regression line
}
// Handle vertical lines (infinite slope or NaN slope) separately
if (Double.isInfinite(slope) || Double.isNaN(slope)) {
// For vertical lines, determine direction by looking at Y coordinates
double y1, y2;
switch (view) {
case MultiDThreePanes.XY_PLANE, MultiDThreePanes.ZY_PLANE:
y1 = getNodeWithoutChecks(0).y;
y2 = getNodeWithoutChecks(size() - 1).y;
break;
case MultiDThreePanes.XZ_PLANE:
y1 = getNodeWithoutChecks(0).z;
y2 = getNodeWithoutChecks(size() - 1).z;
break;
default:
y1 = y2 = 0;
}
// In image coordinates, Y increases downward, so North is negative Y direction
if (y2 <= y1) {
return 0.0; // North
} else {
return 180.0; // South
}
}
// Calculate direction vector from regression line. We need to determine the direction
// (positive or negative X direction) by looking at the overall X range of the data
double x1, x2;
switch (view) {
case MultiDThreePanes.XY_PLANE, MultiDThreePanes.XZ_PLANE:
x1 = getNodeWithoutChecks(0).x;
x2 = getNodeWithoutChecks(size() - 1).x;
break;
case MultiDThreePanes.ZY_PLANE:
x1 = getNodeWithoutChecks(0).z;
x2 = getNodeWithoutChecks(size() - 1).z;
break;
default:
x1 = x2 = 0; // This won't be reached due to earlier validation
}
// Determine direction: if path goes from left to right, deltaX = 1, else deltaX = -1
final double deltaX = (x2 >= x1) ? 1.0 : -1.0;
final double deltaY = slope * deltaX;
double angle;
// Calculate angle using atan2 for full quadrant information
// Convert to navigation/compass convention: North: 0°; East: 90°; South: 180°; West: 270°
// In image coordinates, Y increases downward, so North has a negative deltaY
angle = Math.atan2(-deltaY, deltaX); // Flip Y axis for proper North direction
// Now convert from standard math angles to compass bearing
// Math: East=0°, North=90°; Compass: North=0°, East=90°
angle = Math.PI / 2 - angle;
// Normalize angle to [0, 2π] range
angle = MathUtils.normalizeAngle(angle, Math.PI);
return Math.toDegrees(angle); // return angle in 0-360 degrees
}
/**
* Returns a (non-normalized) direction vector from the first node to the
* node at index {@code min(maxNodes, size()-1)}. This is useful for
* measuring the initial heading of a child path near a fork point.
*
* @param maxNodes the maximum node index to use as the end point
* @return a 3-element array {@code [dx, dy, dz]}, or {@code null} if the
* path has fewer than 2 nodes or the resulting vector is zero-length
*/
public double[] getInitialDirection(final int maxNodes) {
final int end = Math.min(maxNodes, size() - 1);
if (end < 1) return null;
final PointInImage p0 = getNodeWithoutChecks(0);
final PointInImage pN = getNodeWithoutChecks(end);
final double dx = pN.x - p0.x;
final double dy = pN.y - p0.y;
final double dz = pN.z - p0.z;
if (dx == 0 && dy == 0 && dz == 0) return null;
return new double[]{ dx, dy, dz };
}
/**
* Returns the 3D extension direction vector of this path using linear regression.
* The vector represents the overall direction from start to end of the path, fitted through all its nodes.
*
* @return the normalized Vector3d (length of 1) representing the extension direction,
* or null if path has only one point
*/
public Vector3d getExtensionDirection3D() {
if (size() == 1)
return null;
if (size() == 2) {
final Vector3d v = getNodeWithoutChecks(1).asVector();
v.sub(getNodeWithoutChecks(0).asVector());
return v;
}
// Parametric approach: Use linear regression for each coordinate
final SimpleRegression xRegression = new SimpleRegression();
final SimpleRegression yRegression = new SimpleRegression();
final SimpleRegression zRegression = new SimpleRegression();
// Add data points using index as parameter
for (int i = 0; i < size(); i++) {
final PointInImage node = getNodeWithoutChecks(i);
xRegression.addData(i, node.x);
yRegression.addData(i, node.y);
zRegression.addData(i, node.z);
}
// Calculate direction vector using regression slopes. Because we use indices
// this is the same as xRegression.predict(size() - 1) - xRegression.predict(0)
// but more direct and slightly more efficient:
// given y = mx + b:
// predict(size() - 1) = m * (size() - 1) + b
// predict(0) = m * 0 + b = b
// predict(size() - 1) - predict(0) = m * (size() - 1) + b - b = m * (size() - 1)
final double deltaX = xRegression.getSlope() * (size() - 1);
final double deltaY = yRegression.getSlope() * (size() - 1);
final double deltaZ = zRegression.getSlope() * (size() - 1);
final Vector3d direction = new Vector3d(deltaX, deltaY, deltaZ);
direction.normalize(); // Convert to unit vector
return direction;
}
/**
* Returns either the compass bearing or the relative branching angle of this path's 3D extension direction.
*
* @param relative determines the calculation mode:
* - false: returns compass bearing in XY plane (absolute angle)
* - true: returns angle relative to parent path (relative angle)
* @return when relative=false: compass bearing in degrees (0-360°) using navigation convention:
* 0° = North (negative Y direction in image coordinates),
* 90° = East (positive X direction),
* 180° = South (positive Y direction in image coordinates),
* 270° = West (negative X direction).
* The 3D direction vector is projected onto the XY plane for compass calculation.
* <p>
* When relative=true: acute angle in degrees (0-180°) between this path's 3D direction
* vector and its parent path's 3D direction vector. This measures the true 3D branching
* angle, not just the XY projection.
* <p>
* Returns Double.NaN if:
* - Path has only one point (cannot determine direction)
* - relative=true and path has no parent (startJoins is null)
* - relative=true and parent path has no direction (single point parent)
* @see #getExtensionDirection3D()
* @see #getExtensionAngle3D(Vector3d)
* @see #getExtensionAngles3D()
* @see #getExtensionAngleXY()
*/
public double getExtensionAngle3D(final boolean relative) {
if (relative) {
if (parentPath == null) return Double.NaN;
final Vector3d sjDirection = parentPath.getExtensionDirection3D();
return (sjDirection == null) ? Double.NaN : getExtensionAngle3D(sjDirection);
}
final Vector3d direction = getExtensionDirection3D();
if (direction == null)
return Double.NaN;
// Use X and Y components for compass calculation
final double deltaX = direction.x;
final double deltaY = direction.y;
// Calculate angle using atan2 for full quadrant information
// Convert to navigation/compass convention: North: 0°; East: 90°; South: 180°; West: 270°
// In image coordinates, Y increases downward, so North has a negative deltaY
double angle = Math.atan2(-deltaY, deltaX); // Flip Y axis for proper North direction
// Now convert from standard math angles to compass bearing
// Math: East=0°, North=90°; Compass: North=0°, East=90°
angle = Math.PI / 2 - angle;
// Normalize angle to [0, 2π] range
angle = MathUtils.normalizeAngle(angle, Math.PI);
return Math.toDegrees(angle); // return angle in 0-360 degrees
}
/**
* Returns the complete 3D orientation of this path's extension direction as spherical coordinates
* using navigation/compass convention.
* <p>
* This method provides both horizontal direction (azimuth) and vertical inclination (elevation)
* of the path's overall extension direction.
*
* @return double array containing [azimuth, elevation] in degrees, where:
* <p>
* <b>azimuth</b> (index 0): Compass bearing in XY plane (0-360°) following navigation convention:
* <ul>
* <li>0° = North (negative Y direction in image coordinates)</li>
* <li>90° = East (positive X direction)</li>
* <li>180° = South (positive Y direction in image coordinates)</li>
* <li>270° = West (negative X direction)</li>
* </ul>
* <p>
* <b>elevation</b> (index 1): Vertical angle from XY plane (-90° to +90°):
* <ul>
* <li>0° = horizontal (parallel to XY plane)</li>
* <li>+90° = pointing straight up (positive Z direction)</li>
* <li>-90° = pointing straight down (negative Z direction)</li>
* </ul>
* <p>
* Returns null if path has only one point or extension direction cannot be determined
* <p>
*
* @see #getExtensionDirection3D()
* @see #getExtensionAngle3D(boolean)
* @see #getExtensionAngleXY()
* @see #getExtensionAngleFromVertical()
*/
public double[] getExtensionAngles3D() {
final Vector3d direction = getExtensionDirection3D();
if (direction == null)
return null;
// Calculate azimuth angle in XY plane using navigation/compass convention
// 0°: North (negative Y), 90°: East (positive X), 180°: South (positive Y), 270°: West (negative X)
// Note: In image coordinates, Y increases downward, so North is negative Y direction
double azimuth = Math.atan2(direction.x, -direction.y);
azimuth = MathUtils.normalizeAngle(azimuth, Math.PI); // normalize to [0, 2π]
azimuth = Math.toDegrees(azimuth);
// Calculate elevation angle from XY plane (-90° to +90°)
final double xyLength = Math.sqrt(direction.x * direction.x + direction.y * direction.y);
double elevation = Math.atan2(direction.z, xyLength);
elevation = Math.toDegrees(elevation);
return new double[]{azimuth, elevation};
}
/**
* Returns a single angle representing the 3D extension direction.
* This is the angle between the path's direction vector and a reference vector.
*
* @param referenceVector the reference vector to measure angle from (e.g., new Vector3d(0, 0, 1) for vertical
* reference, or new Vector3d(1, 0, 0) for horizontal)
* @return the acute angle in degrees (0-180°), or Double.NaN if path has only one point
*/
public double getExtensionAngle3D(final Vector3d referenceVector) {
final Vector3d direction = getExtensionDirection3D();
if (direction == null)
return Double.NaN;
// Calculate angle between direction and reference vector
final double dotProduct = direction.dot(referenceVector);
// Clamp to [-1, 1] to handle floating point precision issues
final double clampedDot = Math.max(-1.0, Math.min(1.0, dotProduct));
final double angle = Math.acos(clampedDot);
return Math.toDegrees(angle);
}
/**
* Returns the angle between the path's 3D direction and the vertical axis.
*
* @return angle from vertical in degrees (0° = vertical, 90° = horizontal),
* or Double.NaN if path has only one point, or 90° if path is 2D
*/
public double getExtensionAngleFromVertical() {
return getExtensionAngle3D(new Vector3d(0, 0, 1)); // Use positive Z as vertical reference
}
/**
* Returns the angle between the path's 3D direction and the horizontal axis.
*
* @return angle from horizontal in degrees (09° = vertical, 0° = horizontal),
* or Double.NaN if path has only one point.
*/
public double getExtensionAngleFromHorizontal() {
return getExtensionAngle3D(new Vector3d(1, 0, 0)); // Use positive X as horizontal reference
}
protected String getRealLengthString() {
return String.format(Locale.US, "%.3f", getLength()); // see https://github.com/morphonets/SNT/issues/147
}
/**
* @deprecated No longer needed - radii and tangents are now stored per-node
*/
@Deprecated
public void createCircles() {
// No-op in new design - radii and tangents are stored per-node as needed
}
protected void setIsPrimary(final boolean primary) {
if (primary) setOrder(1);
}
/**
* Checks if this Path is root.
*
* @return true, if is primary (root)
*/
public boolean isPrimary() {
return order == 1 || (parentPath == null);
}
/*
* We call this if we're going to delete the path represented by this object
*/
protected void disconnectFromAll() {
/*
* This path can be connected to other ones either if: 1) this starts on other;
* 2) other starts on this In any of these cases, we need to also remove this
* from other's somehowJoins and other from this's somehowJoins.
*/
for (final Path other : connectedPaths) {
if (other.parentPath == this) {
other.parentPath = null;
other.branchPointIndex = -1;
}
other.connectedPaths.remove(this);
other.children.remove(this);
}
connectedPaths.clear();
children.clear();
parentPath = null;
branchPointIndex = -1;
setIsPrimary(true);
}
/**