From 11e7eede4bf1ef7239418f3bb0e089f1b537d17b Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 5 Aug 2026 16:21:53 -0700 Subject: [PATCH 1/3] order the channel axis innermost when the controller interleaves channels --- .../acquisitions/AcquisitionEngineScape.java | 3 +- .../acquisitions/LightSheetEventAdapter.java | 78 ++++++++++++------- 2 files changed, 53 insertions(+), 28 deletions(-) diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java index 214986e..0932826 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java @@ -614,7 +614,8 @@ public void close() { if (acqSettings_.channels().enabled()) { currentAcquisition_.submitEventIterator( LightSheetEventAdapter.createTimelapseMultiChannelVolumeAcqEvents( - baseEvent.copy(), acqSettings_, cameraNames, null)); + baseEvent.copy(), acqSettings_, cameraNames, + acqSettings_.channels().used(), null)); } else { currentAcquisition_.submitEventIterator( LightSheetEventAdapter.createTimelapseVolumeAcqEvents( diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java index 108d360..eb8972c 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java @@ -41,34 +41,51 @@ private LightSheetEventAdapter() { throw new AssertionError("Utility class; do not instantiate."); } + /** + * Build events for a timelapse of volumes whose channels the controller switches slice by slice. + *

+ * The controller free-runs the time points here, so the axis order below the time axis has to be + * the interleaved order {@link #createChannelPerSliceAcqEvents} describes: the channel axis + * innermost, because the controller switches channel within a slice and AcqEngJ assigns each + * image to the first event matching that camera in sequence order. Ordering the channels above + * the slices instead does not fail, since the event count still matches; nearly every frame is + * filed against the wrong channel and slice. + *

+ * A single channel takes the same exception as that factory: hardware channel switching is not + * set up for one channel, so its own preset and offset apply and are baked onto the base event, + * where they cannot split the merge. + * + * @param usedChannels the channels the controller was programmed for + */ public static Iterator createTimelapseMultiChannelVolumeAcqEvents( AcquisitionEvent baseEvent, AcquisitionSettings settings, - String[] cameraDeviceNames, + String[] cameraDeviceNames, ChannelSpec[] usedChannels, Function eventMonitor) { if (settings.numTimePoints() <= 1) { throw new RuntimeException("timelapse selected but only one timepoint"); } - Function> timelapse = - timelapse(settings.numTimePoints(), settings.timePointIntervalSec()); - if (settings.channels().count() == 1) { - throw new RuntimeException("Expected multiple channels but only one found"); + if (usedChannels.length == 1) { + applySingleChannel(baseEvent, usedChannels[0]); } - Function> channels = - channels(settings.channels().used()); - + Function> timelapse = + timelapse(settings.numTimePoints(), settings.timePointIntervalSec()); + // Base 0 so cameras() leaves the plain camera index on the axis; channelAxis() runs + // innermost and folds it into the combined slot. + Function> cameras = + cameras(cameraDeviceNames, 0); Function> zStack = zStack(0, settings.volume().slicesPerView()); - - Function> cameras = cameras(cameraDeviceNames); + Function> channels = + channelAxis(usedChannels.length, cameraDeviceNames.length); ArrayList>> acqFunctions = new ArrayList<>(); acqFunctions.add(timelapse); - acqFunctions.add(channels); acqFunctions.add(cameras); acqFunctions.add(zStack); + acqFunctions.add(channels); return new AcquisitionEventIterator(baseEvent, acqFunctions, eventMonitor); } @@ -219,22 +236,7 @@ public static Iterator createChannelPerSliceAcqEvents( Function eventMonitor) { if (usedChannels.length == 1) { - final ChannelSpec channel = usedChannels[0]; - baseEvent.setConfigGroup(channel.getGroup()); - baseEvent.setConfigPreset(channel.getName()); - - // Channel z-offset: mirror channels(), apply the offset to the current stage/z position. - double zPos; - if (baseEvent.getZPosition() == null) { - try { - zPos = Engine.getCore().getPosition() + channel.getOffset(); - } catch (Exception e) { - throw new RuntimeException(e); - } - } else { - zPos = baseEvent.getZPosition() + channel.getOffset(); - } - baseEvent.setZ(baseEvent.getZIndex(), zPos); + applySingleChannel(baseEvent, usedChannels[0]); } // Base 0 so cameras() leaves the plain camera index on the axis; channelAxis() runs @@ -253,6 +255,28 @@ public static Iterator createChannelPerSliceAcqEvents( return new AcquisitionEventIterator(baseEvent, acqFunctions, eventMonitor); } + /** + * Bake one channel's preset and z-offset onto the base event, as {@link #channels} stamps them + * per event on the software path. + */ + private static void applySingleChannel(AcquisitionEvent baseEvent, ChannelSpec channel) { + baseEvent.setConfigGroup(channel.getGroup()); + baseEvent.setConfigPreset(channel.getName()); + + // Channel z-offset: mirror channels(), apply the offset to the current stage/z position. + double zPos; + if (baseEvent.getZPosition() == null) { + try { + zPos = Engine.getCore().getPosition() + channel.getOffset(); + } catch (Exception e) { + throw new RuntimeException(e); + } + } else { + zPos = baseEvent.getZPosition() + channel.getOffset(); + } + baseEvent.setZ(baseEvent.getZIndex(), zPos); + } + /** * Build events for a volume whose channels the controller switches volume by volume. *

From 333e4f216798aec7b45b53bfa64e6dfa1a6e0c41 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 5 Aug 2026 16:59:30 -0700 Subject: [PATCH 2/3] pack the camera axis channel-fastest to match 1.4 --- .../model/acquisitions/AcquisitionEngine.java | 9 +-- .../acquisitions/AcquisitionEngineScape.java | 2 +- .../acquisitions/LightSheetEventAdapter.java | 71 ++++++++++++++----- 3 files changed, 58 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java index 133f07c..ead6517 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java @@ -380,8 +380,9 @@ protected DefaultSummaryMetadata addMMSummaryMetadata(JSONObject summaryMetadata summaryMetadata.put(PropertyKey.CHANNEL_GROUP.key(), acqSettings_.channels().group()); // one name per position on the store's channel axis; with simultaneous cameras the - // camera index varies fastest (LightSheetEventAdapter.cameras assigns - // channelIndex * numCameras + cameraIndex), so repeat each channel name per camera + // channel index varies fastest, so walk cameras outermost and repeat the whole channel + // list per camera. This loop order is the slot order: reverse one and every image gets + // the wrong name. final List channelNames = new ArrayList<>(); final List baseChannelNames = new ArrayList<>(); if (acqSettings_.channels().enabled() && acqSettings_.channels().count() > 0) { @@ -392,8 +393,8 @@ protected DefaultSummaryMetadata addMMSummaryMetadata(JSONObject summaryMetadata baseChannelNames.add("Default"); } if (model_.devices().adapter().numSimultaneousCameras() > 1) { - for (String channelName : baseChannelNames) { - for (CameraBase camera : model_.devices().imagingCameras()) { + for (CameraBase camera : model_.devices().imagingCameras()) { + for (String channelName : baseChannelNames) { channelNames.add(acqSettings_.channels().enabled() ? channelName + "-" + camera.getDeviceName() : camera.getDeviceName()); diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java index 0932826..7ac9241 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java @@ -664,7 +664,7 @@ public void close() { currentAcquisition_.submitEventIterator( LightSheetEventAdapter.createSingleChannelVolumeAcqEvents( baseEvent.copy(), acqSettings_, cameraNames, null, - channelIndex, used[channelIndex])); + channelIndex, used[channelIndex], used.length)); } } else { // SLICE_HW with hardware timepoints off: the controller emits all diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java index eb8972c..69a62e9 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java @@ -26,8 +26,12 @@ public final class LightSheetEventAdapter { // viewer size/display dimensions by them, so the VALUES cannot be changed; an axis MM does not // know is dropped by TIFF storage and never displayed. // CAMERA_AXIS is deliberately AcqEngJ's "channel" axis: LSM packs the combined - // (channelIndex * numCameras + cameraIndex) slot into it, which is why the name here says camera + // (channelIndex + cameraIndex * numChannels) slot into it, which is why the name here says camera // while the value says channel. Note setChannelName() writes this same axis. + // Channel varies fastest, matching what 1.4 writes for one-sided dual-camera acquisitions, so a + // pipeline built to read 1.4 datasets reads ours without re-indexing. The slot naming in + // AcquisitionEngine.addMMSummaryMetadata walks the same order; the two must change together or + // the names label the wrong images. public static final String TIME_AXIS = "time"; public static final String POSITION_AXIS = "position"; public static final String CAMERA_AXIS = "channel"; @@ -79,7 +83,7 @@ public static Iterator createTimelapseMultiChannelVolumeAcqEve Function> zStack = zStack(0, settings.volume().slicesPerView()); Function> channels = - channelAxis(usedChannels.length, cameraDeviceNames.length); + channelAxis(usedChannels.length); ArrayList>> acqFunctions = new ArrayList<>(); acqFunctions.add(timelapse); @@ -161,12 +165,15 @@ public static Iterator createAcqEvents( * * @param channelIndex zero-based index of this channel among the used channels * @param channel the channel to acquire + * @param numUsedChannels how many channels the submitting loop is iterating; taken from the + * caller's captured array rather than re-read here, so the slot stride + * cannot change between one channel's submission and the next */ public static Iterator createSingleChannelVolumeAcqEvents( AcquisitionEvent baseEvent, AcquisitionSettings settings, String[] cameraDeviceNames, Function eventMonitor, - int channelIndex, ChannelSpec channel) { + int channelIndex, ChannelSpec channel, int numUsedChannels) { // Bake this one channel's config into the base event (what channels().next() does per channel). baseEvent.setConfigGroup(channel.getGroup()); @@ -186,13 +193,14 @@ public static Iterator createSingleChannelVolumeAcqEvents( } baseEvent.setZ(baseEvent.getZIndex(), zPos); - // cameras() would now derive the same base from CAMERA_AXIS (setChannelName above put the - // channel index there); passing it explicitly keeps this path independent of that coupling. - final int channelAxisBase = isUsingMultipleCameras - ? channelIndex * cameraDeviceNames.length : channelIndex; + // No channel stage follows here, so cameras() writes the finished slot: this channel's index + // as the base, stepping a whole channel count per camera. cameras() would otherwise derive + // the base from CAMERA_AXIS (setChannelName above put the channel index there); passing it + // explicitly keeps this path independent of that coupling. + final int cameraStride = isUsingMultipleCameras ? numUsedChannels : 1; Function> cameras = - cameras(cameraDeviceNames, channelAxisBase); + cameras(cameraDeviceNames, channelIndex, cameraStride); Function> zStack = zStack(0, settings.volume().slicesPerView()); @@ -246,7 +254,7 @@ public static Iterator createChannelPerSliceAcqEvents( Function> zStack = zStack(0, settings.volume().slicesPerView()); Function> channels = - channelAxis(usedChannels.length, cameraDeviceNames.length); + channelAxis(usedChannels.length); ArrayList>> acqFunctions = new ArrayList<>(); acqFunctions.add(cameras); @@ -313,14 +321,13 @@ public static Iterator createChannelPerVolumeAcqEvents( * Deliberately sets no config group or preset, and no channel z-offset: the controller owns * channel switching here, and the offset stays wherever the user's own preset left it. * Runs innermost, after {@link #cameras}, so the value already on the axis is the camera index; - * it is replaced with the combined slot {@code channelIndex * numCameras + cameraIndex} that + * it is replaced with the combined slot {@code channelIndex + cameraIndex * numChannels} that * sizes and names the datastore. * * @param numChannels the number of channels the controller was programmed for - * @param numCameras the number of cameras this event fans out over */ public static Function> channelAxis( - int numChannels, int numCameras) { + int numChannels) { return (AcquisitionEvent event) -> new Iterator<>() { private int channelIndex_ = 0; @@ -344,7 +351,7 @@ public AcquisitionEvent next() { } } - channelEvent.setAxisPosition(CAMERA_AXIS, channelIndex_ * numCameras + cameraIndex); + channelEvent.setAxisPosition(CAMERA_AXIS, channelIndex_ + cameraIndex * numChannels); channelIndex_++; return channelEvent; } @@ -366,17 +373,40 @@ public static Function> cameras(Str * {@code CAMERA_AXIS} is AcqEngJ's {@code "channel"} axis ({@code AcqEngMetadata.CHANNEL_AXIS}): * {@code AcquisitionEvent.setChannelName(s)} compiles to {@code setAxisPosition("channel", s)}. So the * channel index and the camera index share one axis, and the coordinate written here is the combined - * slot {@code channelIndex * numCameras + cameraIndex} that {@code addMMSummaryMetadata} names and + * slot {@code channelIndex + cameraIndex * numChannels} that {@code addMMSummaryMetadata} names and * sizes the datastore for. Do not treat a value already present on that axis as a camera base unless * you put it there: on the {@code channels()}-composed paths it is the raw channel index written by * {@code setChannelName}. + *

+ * This overload steps one slot per camera, which is only the whole coordinate where a single + * channel is in play; a multi-channel caller wants the stride overload below. * * @param channelAxisBase the channel-axis base for this fan-out, or {@code null} to derive it from - * the event: the channel index {@code channels()} left on the axis, times - * the camera count when using multiple cameras + * the event: the channel index {@code channels()} left on the axis */ public static Function> cameras( String[] cameraDeviceNames, Integer channelAxisBase) { + return cameras(cameraDeviceNames, channelAxisBase, 1); + } + + /** + * Fan an event out over the cameras, advancing the {@link #CAMERA_AXIS} coordinate by + * {@code cameraStride} per camera. + *

+ * Channel varies fastest on that axis, so a camera step is a step of {@code numChannels}, not of + * one. Two shapes use this: + *

+ * + * @param cameraStride how far the slot advances per camera; {@code numChannels} when this call + * produces the finished coordinate, {@code 1} when {@link #channelAxis} does + */ + public static Function> cameras( + String[] cameraDeviceNames, Integer channelAxisBase, int cameraStride) { return (AcquisitionEvent event) -> new Iterator<>() { private int cameraIndex_ = 0; @@ -410,11 +440,14 @@ public AcquisitionEvent next() { // ignore => number already assigned } } - baseIndex = isUsingMultipleCameras - ? parsed * cameraDeviceNames_.length : parsed; + // The channel index is the base as it stands: channel varies fastest, so it is + // the camera step that scales, and that is cameraStride's job. A multi-channel + // caller of this derive path must therefore pass numChannels as the stride; + // today every caller that reaches it has channels disabled, so this is 0. + baseIndex = parsed; } - cameraEvent.setAxisPosition(CAMERA_AXIS, baseIndex + cameraIndex_); + cameraEvent.setAxisPosition(CAMERA_AXIS, baseIndex + cameraIndex_ * cameraStride); cameraIndex_++; return cameraEvent; } From 40edf2b5004843000a3c416ec1f9af5a4ae4f425 Mon Sep 17 00:00:00 2001 From: Brandon Date: Wed, 5 Aug 2026 17:04:27 -0700 Subject: [PATCH 3/3] remove the unused channel event factory that packs the camera axis wrong --- .../acquisitions/LightSheetEventAdapter.java | 26 +++---------------- 1 file changed, 3 insertions(+), 23 deletions(-) diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java index 69a62e9..b0049ad 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java @@ -116,26 +116,6 @@ public static Iterator createTimelapseVolumeAcqEvents( return new AcquisitionEventIterator(baseEvent, acqFunctions, eventMonitor); } - public static Iterator createChannelAcqEvents( - AcquisitionEvent baseEvent, AcquisitionSettings settings, - String[] cameraDeviceNames, - Function eventMonitor) { - - Function> channels = - channels(settings.channels().used()); - - Function> cameras = cameras(cameraDeviceNames); - - Function> zStack = - zStack(0, settings.volume().slicesPerView()); - - ArrayList>> acqFunctions = new ArrayList<>(); - acqFunctions.add(channels); - acqFunctions.add(cameras); - acqFunctions.add(zStack); - return new AcquisitionEventIterator(baseEvent, acqFunctions, eventMonitor); - } - public static Iterator createAcqEvents( AcquisitionEvent baseEvent, AcquisitionSettings settings, String[] cameraDeviceNames, @@ -159,9 +139,9 @@ public static Iterator createAcqEvents( * its own event iterator. AcqEngJ appends a SequenceEnd flush at the end of every * submitted iterator, guaranteeing exactly one camera sequence + one controller fire per * channel-volume regardless of whether the channel presets are identical, distinct, or - * property-sequenceable. Composing all channels into a single iterator (the old - * {@link #createChannelAcqEvents}) instead lets AcqEngJ merge identical-preset channels into one - * sequence, firing the controller once and collapsing the channel dimension. + * property-sequenceable. Composing all channels into a single iterator instead lets AcqEngJ merge + * identical-preset channels into one sequence, firing the controller once and collapsing the + * channel dimension. * * @param channelIndex zero-based index of this channel among the used channels * @param channel the channel to acquire