Skip to content

Commit 5dab5c6

Browse files
committed
Add PeripathDetector demo script
1 parent fa792e2 commit 5dab5c6

3 files changed

Lines changed: 161 additions & 12 deletions

File tree

‎src/main/java/sc/fiji/snt/analysis/RoiConverter.java‎

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -601,6 +601,34 @@ public static Roi convert(final Collection<Path> paths, final ImagePlus imp) {
601601
return combine(Arrays.asList(holdingOverlay.toArray()));
602602
}
603603

604+
/**
605+
* Creates a grouped {@link PointRoi} from a list of
606+
* {@link PeripathDetector.Detection}s. Each detection's pixel coordinates
607+
* and stack position are computed via {@link PeripathDetector.Detection#xyzct()}.
608+
*
609+
* @param detections the detections to convert
610+
* @param imp the image used to resolve stack positions (channel, Z, frame)
611+
* @param name the ROI name, or {@code null} for no name
612+
* @param color the stroke color, or {@code null} for default
613+
* @return a PointRoi containing all detection points with per-point Z positions
614+
* @throws IllegalArgumentException if detections or imp is null
615+
*/
616+
public static PointRoi toPointRoi(final List<PeripathDetector.Detection> detections,
617+
final ImagePlus imp, final String name, final Color color) {
618+
if (detections == null || imp == null)
619+
throw new IllegalArgumentException("detections and image cannot be null");
620+
final PointRoi roi = new PointRoi();
621+
for (final PeripathDetector.Detection d : detections) {
622+
final double[] loc = d.xyzct();
623+
final int zSlice = (int) Math.round(loc[2]) + 1;
624+
final int stackPos = imp.getStackIndex((int) loc[3], zSlice, (int) loc[4]);
625+
roi.addPoint(loc[0], loc[1], stackPos);
626+
}
627+
if (name != null) roi.setName(name);
628+
if (color != null) roi.setStrokeColor(color);
629+
return roi;
630+
}
631+
604632
public static Roi get2DBoundingBox(final Collection<Path> paths, final int exportPlane) {
605633
final BoundingBox box = new BoundingBox();
606634
paths.forEach(path -> box.append(path.getUnscaledNodes().iterator()) );

‎src/main/java/sc/fiji/snt/gui/cmds/PeripathDetectorCmd.java‎

Lines changed: 3 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
import sc.fiji.snt.Path;
3737
import sc.fiji.snt.SNTUtils;
3838
import sc.fiji.snt.analysis.PeripathDetector;
39+
import sc.fiji.snt.analysis.RoiConverter;
3940
import sc.fiji.snt.util.ImgUtils;
4041

4142
import java.util.Collection;
@@ -244,18 +245,8 @@ public void run() {
244245
if (rm == null) rm = new RoiManager();
245246
for (final Map.Entry<Path, List<PeripathDetector.Detection>> entry : resultsByPath.entrySet()) {
246247
final Path path = entry.getKey();
247-
final PointRoi roi = new PointRoi();
248-
for (final PeripathDetector.Detection d : entry.getValue()) {
249-
final double[] loc = d.xyzct();
250-
// addPoint(x, y, position) stores a per-point stack position
251-
final int zSlice = (int) Math.round(loc[2]) + 1; // 0-based pixel; Z: 1-based slice
252-
final int stackPos = imp.getStackIndex((int) loc[3], zSlice, (int) loc[4]);
253-
roi.addPoint(loc[0], loc[1], stackPos);
254-
}
255-
roi.setName(path.getName() + " (" + entry.getValue().size() + " maxima)");
256-
if (path.getColor() != null)
257-
roi.setStrokeColor(path.getColor());
258-
rm.addRoi(roi);
248+
final String name = path.getName() + " (" + entry.getValue().size() + " maxima)";
249+
rm.addRoi(RoiConverter.toPointRoi(entry.getValue(), imp, name, path.getColor()));
259250
}
260251
resetUI();
261252
rm.runCommand("sort");
Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
/**
2+
* file: Peripath_Detection_Demo.groovy
3+
* author: Tiago Ferreira
4+
* version: 2026.04.09
5+
* info: Demonstrates the PeripathDetector workflow: automated detection of
6+
* intensity maxima (spines, varicosities, puncta) around traced paths.
7+
*
8+
* The script loads a demo image of a cultured hippocampal neuron with
9+
* synaptic markers, auto-traces a dendrite, fits radii, runs detection,
10+
* and then shows how the annular search region (torus mask) can be used
11+
* to extract the peri-neuronal signal for further analysis.
12+
*
13+
* @see https://imagej.net/plugins/snt/spines-varicosities
14+
* @see https://imagej.net/plugins/snt/scripting
15+
* @see https://javadoc.scijava.org/SNT/
16+
* @see sc.fiji.snt.analysis.PeripathDetector
17+
*/
18+
19+
#@ SNTService snt
20+
21+
// Ensure SNT is up-to-date and is not busy with another operation
22+
snt.requireVersion("5.0.7")
23+
if (snt.isActive() && snt.getUI() && !snt.getUI().isReady()) {
24+
print("Please complete current operation before running this script!")
25+
return
26+
}
27+
28+
// 1) Load a demo image. We'll use CIL:810: it is a cultured hippocampal
29+
// neuron with 3 channels: Ch1: N-caherin; Ch2: V-glut; Ch3: NMDAR. We'll
30+
// detect V-glut puncta (Ch2) around a traced dendrite traced from Ch1
31+
imp = snt.demoImage("cil810")
32+
detectionChannel = 2 // V-glut channel
33+
34+
// 2) Trace detection neurite. This demo image has no pre-existing
35+
// reconstructions, so we'll auto-trace a path between two manually chosen
36+
// points on a primary dendrite
37+
plugin = snt.initialize(imp, snt.isActive()) // image to be traced, boolean flag for GUI display
38+
startPoint = SNTPoint.of(58.986, 39.211, 0.0)
39+
endPoint = SNTPoint.of(0.339, 17.967, 0.0)
40+
path = plugin.autoTrace(startPoint, endPoint, null)
41+
println("Traced path: ${path.getName()} (${path.size()} nodes)")
42+
43+
// 3) Fit radii. PeripathDetector uses per-node radii to define the search
44+
// annulus. Without fitted radii, a fallback (2x voxel size) is used for all
45+
// nodes, which may not reflect the actual neurite thickness. path.fitRadii()
46+
// fits circular cross-sections to the image signal at each node, computing
47+
// locally adaptive radii, equivalent to the GUI's "Fit Path" action
48+
path.fitRadii(imp)
49+
println("Mean fitted radius: ${sprintf('%.2f', path.getMeanRadius())} ${path.getCalibration().unit}")
50+
51+
// 4) Configure the detector. The detector searches for intensity maxima in
52+
// an annular band around each path node. The annulus is defined by inner/
53+
// outer radius settings:
54+
//
55+
// innerRadiusMultiplier: where the search starts, as a fraction of the node's
56+
// fitted radius. 1.0 = at the neurite edge; < 1.0 = includes the membrane;
57+
// > 1.0 = starts outside the neurite
58+
//
59+
// outerRadiusMultiplier: where the search ends, as a multiple of the node's
60+
// radius. 2.0 = searches up to 2x the neurite thickness
61+
//
62+
// prominence: noise tolerance for MaximumFinder (grayscale units). A maximum
63+
// must protrude above its surrounding saddle by at least this value.
64+
// Higher = fewer, more confident detections. A good starting point may be
65+
// ~5% of the channel's dynamic range. It can be previewed using IJ's built-in
66+
// command
67+
//
68+
// mergingDistance: nearby detections within this distance (physical units) are
69+
// merged, keeping the brightest. Set to -1 for auto (= outer radius)
70+
cfg = new PeripathDetector.Config()
71+
.innerRadiusMultiplier(.70) // start within the neurite
72+
.outerRadiusMultiplier(2.5) // search up to 2.5x neurite thickness
73+
.prominence(50.0) // moderate noise tolerance
74+
.mergingDistance(-1) // auto
75+
.assignToNearestPath(true)
76+
77+
// 5) Run detection
78+
detectionImg = ImgUtils.getCtSlice3d(imp, detectionChannel, 1)
79+
paths = [path]
80+
detections = PeripathDetector.detect(paths, detectionImg, cfg)
81+
println("Detected ${detections.size()} maxima in channel ${detectionChannel}")
82+
83+
// 6) Display detections as ROIs. RoiConverter.toPointRoi() creates one
84+
// multi-point ROI per path, with per-point Z positions and the path's
85+
// name/color for easier identification
86+
rm = RoiManager.getInstance2()
87+
if (rm == null) {
88+
rm = new RoiManager()
89+
}
90+
detections.groupBy {
91+
it.path
92+
}.each {
93+
p, dets -> name = "${p.getName()} (${dets.size()} maxima)"
94+
rm.addRoi(RoiConverter.toPointRoi(dets, imp, name, p.getColor()))
95+
}
96+
rm.runCommand("Sort")
97+
rm.runCommand("Show All")
98+
99+
// 7) Generate and display the torus mask, a binary image representing the
100+
// annular search region.
101+
// NB: This mask is an approximation for visualization and segmentation. The
102+
// detection itself operates on per-node cross-section profiles, _not_ this
103+
// mask. For 2D images, each node contributes a perpendicular line segment;
104+
// small gaps between nodes are expected from digitization.
105+
maskImp = ImpUtils.create("Torus Mask", imp.getWidth(), imp.getHeight(), imp.getNSlices(), 8) // title, dimensions, and bit-depth
106+
maskImp.setCalibration(imp.getCalibration())
107+
PeripathDetector.createTorusMask(paths, maskImp, cfg, 1) // set masked pixels to 1
108+
109+
// 8) Use the mask to extract peri-neuronal signal. We'll multiply the
110+
// detection channel by the normalized mask (0/1) to isolate only the
111+
// signal within the torus region. This is useful for measuring intensities,
112+
// computing integrated density, or feeding into downstream analysis
113+
// (e.g., colocalization, thresholding)
114+
chImp = ImpUtils.getChannel(imp, detectionChannel)
115+
extracted = ImageCalculator.run(chImp, maskImp, "multiply create 32-bit")
116+
extracted.setTitle("Ch${detectionChannel} Signal in Torus")
117+
extracted.show()
118+
119+
println("Done. ${detections.size()} maxima detected. ROIs, torus mask, and extracted signal displayed.")
120+
121+
122+
// Imports below:
123+
import ij.IJ
124+
import ij.plugin.ImageCalculator
125+
import ij.plugin.frame.RoiManager
126+
import sc.fiji.snt.analysis.PeripathDetector
127+
import sc.fiji.snt.analysis.RoiConverter
128+
import sc.fiji.snt.util.ImgUtils
129+
import sc.fiji.snt.util.ImpUtils
130+
import sc.fiji.snt.util.SNTPoint

0 commit comments

Comments
 (0)