@@ -91,7 +91,10 @@ public class AlongPathDetectorCmd extends CommonDynamicCmd {
9191 + "These locations may have naturally enlarged radii and may produce false positives." )
9292 private boolean excludeJunctions = true ;
9393
94- @ Parameter (label = "Output" , choices = {"ROIs" , "Bookmarked locations" })
94+ private static final String OUTPUT_BOOKMARKS = "Bookmarked locations" ;
95+ private static final String OUTPUT_ROIS = "ROIs" ;
96+
97+ @ Parameter (label = "Output" , choices = {OUTPUT_ROIS , OUTPUT_BOOKMARKS })
9598 private String outputChoice ;
9699
97100 @ Parameter (label = "Paths" , required = false , persist = false )
@@ -106,6 +109,7 @@ private void init() {
106109 if (paths == null || paths .isEmpty ()) {
107110 paths = snt .getUI ().getPathManager ().getSelectedPaths (true );
108111 }
112+ if (noPathsError ()) return ;
109113
110114 // If selected paths have no radii but their fitted flavors do have radii, use those instead
111115 paths = paths .stream ()
@@ -140,12 +144,30 @@ private void init() {
140144 mItem .setMaximumValue (0.0 );
141145 resolveInput ("channel" );
142146 }
147+
148+ final MutableModuleItem <String > outputChoiceItem = getInfo ().getMutableInput ("outputChoice" , String .class );
149+ // ROI output needs a classic canvas to attach the PointRoi t
150+ if (snt .getImagePlus () == null )
151+ outputChoiceItem .setChoices (List .of (OUTPUT_BOOKMARKS ));
143152 }
144153
145- @ Override
146- public void run () {
154+ private boolean noPathsError () {
147155 if (paths == null || paths .isEmpty ()) {
148156 error ("No paths selected for analysis." );
157+ return true ;
158+ }
159+ return false ;
160+ }
161+
162+ @ Override
163+ public void run () {
164+ if (noPathsError ()) return ;
165+
166+ // Short-circuit ROI output as early as possible, before running
167+ final boolean roiOutput = !(ui != null && OUTPUT_BOOKMARKS .equals (outputChoice ));
168+ if (roiOutput && (snt == null || snt .getImagePlus () == null )) {
169+ error (String .format ("ROI output requires a %s. Use 'Bookmarked locations' output instead." ,
170+ (snt != null && snt .isStreamMode () ? "materialized crop" : "valid image" )));
149171 return ;
150172 }
151173
@@ -230,16 +252,18 @@ public void run() {
230252 SNTUtils .log ("AlongPathDetectorCmd: " + cfg );
231253
232254 // Run detection
233- final List <Detection > results ;
234- if (intensityImg != null ) {
235- // Temporarily stamp each path's canvasOffset/spacing from the live session state, so
236- // AlongPathDetector's on-skeleton intensity sampling reads the correct voxel from
237- // intensityImg. Same save/apply/restore idiom as SNT#makePathVolume(): bulk-added paths
238- // (addTree()/SWC import/.traces loading) never get canvasOffset/spacing stamped otherwise,
239- // and intensityImg is always the CURRENTLY ACTIVE grid (getLoadedData()/getCtSlice3d()),
240- // matching snt.getActiveCanvasPixelOffset() (crop-relative while a materialized crop is active)
241- final List <PointInCanvas > originalOffsets = new ArrayList <>(pathsToProcess .size ());
242- final List <ij .measure .Calibration > originalSpacings = new ArrayList <>(pathsToProcess .size ());
255+ // Temporarily stamp each path's canvasOffset/spacing from the live session state. This is needed
256+ // regardless of whether intensity filtering is active: it is not just AlongPathDetector's own
257+ // on-skeleton sampling that depends on it, but also Detection#xyzct()/RoiConverter's world->pixel
258+ // conversion for bookmark/ROI output further below. Same save/apply/restore idiom as
259+ // SNT#makePathVolume(): bulk-added paths (addTree()/SWC import/.traces loading) never get
260+ // canvasOffset/spacing stamped otherwise (see PathAndFillManager#assignSpatialSettings()'s NB),
261+ // so they default to (0,0,0) - wrong whenever the source has a non-zero world-origin offset
262+ // (Stream mode). snt can be null when this command runs headless against Paths that already
263+ // carry their own correct canvasOffset; leave those untouched.
264+ final List <PointInCanvas > originalOffsets = (snt == null ) ? null : new ArrayList <>(pathsToProcess .size ());
265+ final List <ij .measure .Calibration > originalSpacings = (snt == null ) ? null : new ArrayList <>(pathsToProcess .size ());
266+ if (snt != null ) {
243267 final PointInCanvas liveOffset = snt .getActiveCanvasPixelOffset ();
244268 final ij .measure .Calibration liveCal = snt .getCalibration ();
245269 for (final Path p : pathsToProcess ) {
@@ -248,73 +272,74 @@ public void run() {
248272 p .setCanvasOffset (liveOffset );
249273 p .setSpacing (liveCal );
250274 }
251- try {
252- results = AlongPathDetector .detect (pathsToProcess , intensityImg , cfg );
253- } finally {
275+ }
276+ try {
277+ final List <Detection > results = AlongPathDetector .detect (pathsToProcess , intensityImg , cfg );
278+
279+ if (results .isEmpty ()) {
280+ error ("No swellings detected with current parameters." );
281+ return ;
282+ }
283+
284+ SNTUtils .log ("Detected " + results .size () + " swellings" );
285+
286+ // Group results by path, resolving fitted paths back to their
287+ // unfitted originals so that labels and counts match PathManagerUI
288+ final Map <Path , List <Detection >> resultsByPath = results .stream ()
289+ .collect (Collectors .groupingBy (v ->
290+ v .path .isFittedVersionOfAnotherPath ()
291+ ? v .path .getUnfitted () : v .path
292+ ));
293+
294+ // Update spine/varicosity counts on the (unfitted) paths
295+ resultsByPath .forEach ((p , detections ) -> p .setSpineOrVaricosityCount (
296+ p .getSpineOrVaricosityCount () + detections .size ()));
297+
298+ if (ui != null )
299+ ui .getPathManager ().applyDefaultTags ("No. of Spine/Varicosity Markers" );
300+
301+ if (ui != null && OUTPUT_BOOKMARKS .equals (outputChoice )) {
302+
303+ // NB: Detection#xyzct()/RoiConverter (ROI branch below) read each Path's CURRENT
304+ // canvasOffset/calibration to convert back to pixel space, so this must run while paths
305+ // are still stamped with the live offset set above - see the identical NB in
306+ // PeripathDetectorCmd#run()
307+ resultsByPath .forEach ((path , detections ) -> {
308+ final List <double []> locs = detections .stream ()
309+ .map (Detection ::xyzct )
310+ .collect (Collectors .toList ());
311+ ui .getBookmarkManager ().add (path .getName () + " Swelling " , locs , path .getColor ());
312+ });
313+ resetUI ();
314+ ui .selectTab ("Bookmarks" );
315+ ui .showStatus (results .size () + " swellings added to Bookmark Manager." , true );
316+
317+ } else {
318+ // imp/roiOutput already validated up front, before detection ran - see the guard at
319+ // the top of run()
320+ final ImagePlus imp = snt .getImagePlus ();
321+ RoiManager rm = RoiManager .getInstance2 ();
322+ if (rm == null ) rm = new RoiManager ();
323+ for (final Map .Entry <Path , List <Detection >> entry : resultsByPath .entrySet ()) {
324+ final Path path = entry .getKey ();
325+ final String name = path .getName () + " (" + entry .getValue ().size () + " swellings)" ;
326+ rm .addRoi (RoiConverter .toPointRoi (entry .getValue (), imp , name , path .getColor ()));
327+ }
328+ resetUI ();
329+ rm .runCommand ("sort" );
330+ rm .runCommand ("show all" );
331+ }
332+ } finally {
333+ // Restore each Path's original canvasOffset/spacing only now that all output (bookmarks/
334+ // ROIs) has been built from the live-offset-stamped state above
335+ if (snt != null ) {
254336 int i = 0 ;
255337 for (final Path p : pathsToProcess ) {
256338 p .setCanvasOffset (originalOffsets .get (i ));
257339 p .setSpacing (originalSpacings .get (i ));
258340 i ++;
259341 }
260342 }
261- } else {
262- results = AlongPathDetector .detect (pathsToProcess , intensityImg , cfg );
263- }
264-
265- if (results .isEmpty ()) {
266- error ("No swellings detected with current parameters." );
267- return ;
268- }
269-
270- SNTUtils .log ("Detected " + results .size () + " swellings" );
271-
272- // Group results by path, resolving fitted paths back to their
273- // unfitted originals so that labels and counts match PathManagerUI
274- final Map <Path , List <Detection >> resultsByPath = results .stream ()
275- .collect (Collectors .groupingBy (v ->
276- v .path .isFittedVersionOfAnotherPath ()
277- ? v .path .getUnfitted () : v .path
278- ));
279-
280- // Update spine/varicosity counts on the (unfitted) paths
281- resultsByPath .forEach ((p , detections ) -> p .setSpineOrVaricosityCount (
282- p .getSpineOrVaricosityCount () + detections .size ()));
283-
284- if (ui != null )
285- ui .getPathManager ().applyDefaultTags ("No. of Spine/Varicosity Markers" );
286-
287- if (ui != null && outputChoice .toLowerCase ().contains ("bookmark" )) {
288-
289- resultsByPath .forEach ((path , detections ) -> {
290- final List <double []> locs = detections .stream ()
291- .map (Detection ::xyzct )
292- .collect (Collectors .toList ());
293- ui .getBookmarkManager ().add (path .getName () + " Swelling " , locs , path .getColor ());
294- });
295- resetUI ();
296- ui .selectTab ("Bookmarks" );
297- ui .showStatus (results .size () + " swellings added to Bookmark Manager." , true );
298-
299- } else {
300- // Stream mode without a materialized crop: no classic canvas for RoiManager/PointRoi to
301- // attach to (unlike detection itself above, this really has no Stream-mode equivalent)
302- final ImagePlus imp = (snt != null ) ? snt .getImagePlus () : null ;
303- if (imp == null ) {
304- error (String .format ("ROI output requires a %s. Use 'Bookmarked locations' output instead." ,
305- (snt != null && snt .isStreamMode () ? "materialized crop" : "valid image" )));
306- return ;
307- }
308- RoiManager rm = RoiManager .getInstance2 ();
309- if (rm == null ) rm = new RoiManager ();
310- for (final Map .Entry <Path , List <Detection >> entry : resultsByPath .entrySet ()) {
311- final Path path = entry .getKey ();
312- final String name = path .getName () + " (" + entry .getValue ().size () + " swellings)" ;
313- rm .addRoi (RoiConverter .toPointRoi (entry .getValue (), imp , name , path .getColor ()));
314- }
315- resetUI ();
316- rm .runCommand ("sort" );
317- rm .runCommand ("show all" );
318343 }
319344 }
320345}
0 commit comments