diff --git a/src/main/java/org/micromanager/lightsheetmanager/LightSheetManagerPlugin.java b/src/main/java/org/micromanager/lightsheetmanager/LightSheetManagerPlugin.java index 72074cd..5af8952 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/LightSheetManagerPlugin.java +++ b/src/main/java/org/micromanager/lightsheetmanager/LightSheetManagerPlugin.java @@ -14,7 +14,7 @@ public class LightSheetManagerPlugin implements MenuPlugin, SciJavaPlugin { public static final String copyright = "Applied Scientific Instrumentation (ASI), 2022-2026"; public static final String description = "A plugin to control various types of light sheet microscopes."; public static final String menuName = "Light Sheet Manager"; - public static final String version = "0.7.7"; + public static final String version = "0.7.8"; private Studio studio_; private LightSheetManager model_; diff --git a/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/channels/ChannelTablePanel.java b/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/channels/ChannelTablePanel.java index 1eb037e..ec1cce1 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/channels/ChannelTablePanel.java +++ b/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/channels/ChannelTablePanel.java @@ -8,6 +8,7 @@ import org.micromanager.lightsheetmanager.gui.components.Panel; import org.micromanager.lightsheetmanager.LightSheetManager; import org.micromanager.lightsheetmanager.api.data.ChannelMode; +import org.micromanager.lightsheetmanager.api.data.GeometryType; import org.micromanager.lightsheetmanager.gui.components.SettingsListener; import org.micromanager.lightsheetmanager.gui.utils.DialogUtils; import org.micromanager.lightsheetmanager.model.channels.ChannelSpec; @@ -31,6 +32,10 @@ public class ChannelTablePanel extends Panel implements SettingsListener { private ComboBox cmbChannelGroup_; private ComboBox cmbChannelMode_; + // The last mode actually written to the settings, used to put the combo back when a mode is + // refused. Tracked here rather than read back from the settings, which lag the builder. + private ChannelMode lastChannelMode_; + private final ChannelTable table_; private final LightSheetManager model_; @@ -43,6 +48,16 @@ public ChannelTablePanel(final LightSheetManager model, final CheckBox checkBox) model.userSettings().addChangeListener(this); } + /** + * Returns true when the geometry cannot use per-volume hardware channel switching. + *

+ * The controller clocks that channel counter from the view select signal, which does not + * alternate on a single view geometry, so the mode is only usable with two views. + */ + private boolean isVolumeHwRefused() { + return model_.devices().adapter().geometry() == GeometryType.SCAPE; + } + private void createUserInterface() { lblChannelGroup_ = new JLabel("Channel Group:"); lblChangeChannel_ = new JLabel("Channel Mode:"); @@ -60,9 +75,12 @@ private void createUserInterface() { model_.acquisitions().settings().channels().group(), 120, 22); - cmbChannelMode_ = new ComboBox<>(ChannelMode.values(), - model_.acquisitions().settings().channels().mode(), - 140, 22); + lastChannelMode_ = model_.acquisitions().settings().channels().mode(); + if (lastChannelMode_ == ChannelMode.VOLUME_HW && isVolumeHwRefused()) { + // Settings loaded from an earlier build could hold a mode that is refused below. + lastChannelMode_ = ChannelMode.VOLUME; + } + cmbChannelMode_ = new ComboBox<>(ChannelMode.values(), lastChannelMode_, 140, 22); add(lblChannelGroup_, "split 2"); add(cmbChannelGroup_, "wrap"); @@ -128,12 +146,15 @@ private void createEventHandlers() { // select channel mode cmbChannelMode_.registerListener(() -> { final ChannelMode selected = cmbChannelMode_.getSelected(); - model_.acquisitions().settingsBuilder().channelBuilder().mode(selected); - if (selected == ChannelMode.VOLUME_HW) { + if (selected == ChannelMode.VOLUME_HW && isVolumeHwRefused()) { SwingUtilities.invokeLater(() -> { DialogUtils.showErrorMessage(cmbChannelMode_, "Not Implemented", "Not implemented in SCAPE, please contact ASI to request this feature."); + cmbChannelMode_.setSelected(lastChannelMode_); }); + } else { + model_.acquisitions().settingsBuilder().channelBuilder().mode(selected); + lastChannelMode_ = selected; } }); 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 efaff40..7799182 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java @@ -673,12 +673,17 @@ public void close() { channelIndex, used[channelIndex])); } } else { - // Hardware channel modes (SLICE_HW / VOLUME_HW with hardware timepoints - // off): the controller emits all channels in one trigger, so a single - // merged iterator is correct here, unchanged. + // SLICE_HW with hardware timepoints off: the controller emits all + // channels within one armed run, so this submits a single iterator + // carrying no per-channel presets. Stamping a preset per channel made + // AcqEngJ split the merge and start one more camera sequence than the + // controller was armed to deliver, which then waited for frames that + // never arrived. VOLUME_HW reaches this branch too, but needs the + // opposite channel axis order and is refused on SCAPE. currentAcquisition_.submitEventIterator( - LightSheetEventAdapter.createChannelAcqEvents( - baseEvent.copy(), acqSettings_, cameraNames, null)); + LightSheetEventAdapter.createChannelPerSliceAcqEvents( + baseEvent.copy(), acqSettings_, cameraNames, + acqSettings_.channels().used(), null)); } } else { currentAcquisition_.submitEventIterator( 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 c4cfa3a..ee65271 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/LightSheetEventAdapter.java @@ -95,54 +95,6 @@ public static Iterator createTimelapseVolumeAcqEvents( return new AcquisitionEventIterator(baseEvent, acqFunctions, eventMonitor); } - /** - * - * @param interleaved true: do we want to do every channel at each z slice before moving to - * the next z slice - * false: do an entire volume in one channel, then the next one - */ - public static Iterator createMultiChannelVolumeAcqEvents( - AcquisitionEvent baseEvent, AcquisitionSettings settings, - String[] cameraDeviceNames, - Function eventMonitor, boolean interleaved) { - - Function> channels = - channels(settings.channels().used()); - - Function> zStack = - zStack(0, settings.volume().slicesPerView()); - - Function> cameras = cameras(cameraDeviceNames); - - ArrayList>> acqFunctions = new ArrayList<>(); - if (interleaved) { - acqFunctions.add(cameras); - acqFunctions.add(zStack); - acqFunctions.add(channels); - } else { - acqFunctions.add(channels); - acqFunctions.add(cameras); - acqFunctions.add(zStack); - } - return new AcquisitionEventIterator(baseEvent, acqFunctions, eventMonitor); - } - - public static Iterator createVolumeAcqEvents( - AcquisitionEvent baseEvent, AcquisitionSettings settings, - String[] cameraDeviceNames, - Function eventMonitor) { - - Function> cameras = cameras(cameraDeviceNames); - - Function> zStack = - zStack(0, settings.volume().slicesPerView()); - - ArrayList>> acqFunctions = new ArrayList<>(); - acqFunctions.add(cameras); - acqFunctions.add(zStack); - return new AcquisitionEventIterator(baseEvent, acqFunctions, eventMonitor); - } - public static Iterator createChannelAcqEvents( AcquisitionEvent baseEvent, AcquisitionSettings settings, String[] cameraDeviceNames, @@ -233,6 +185,148 @@ public static Iterator createSingleChannelVolumeAcqEvents( return new AcquisitionEventIterator(baseEvent, acqFunctions, eventMonitor); } + /** + * Build events for one volume whose channels the controller switches slice by slice. + *

+ * Slice-by-slice switching only. Volume-by-volume switching changes channel once per + * volume and needs the opposite axis order; see {@link #createChannelPerVolumeAcqEvents}. Using + * this factory for it would not fail, since the event count still matches; the images would + * simply be filed against the wrong coordinates. + *

+ * The controller emits every channel within a single armed run, so the event stream has to + * describe one sequence rather than one per channel. Two things follow from that. + *

+ * The events carry no per-channel config preset. A preset that differs between consecutive events + * makes AcqEngJ break the merge and start one more camera sequence than the controller was armed + * to deliver, so the extra sequence waits for frames that never arrive. Presets also move whatever + * devices they name, such as filter wheels, which cannot follow a controller switching channels + * at slice rate. + *

+ * The channel axis is innermost because the controller interleaves channels within a slice, and + * AcqEngJ assigns each image to the first event matching that camera in sequence order, so the + * event order per camera has to match the order the camera delivers frames. + *

+ * A single channel is the exception. Hardware channel switching is not set up at all for one + * channel, so that channel's own preset and offset apply, as they do on the software path. + * Baking them into the base event leaves every event carrying the same preset, which cannot + * split the merge, since a sequence only breaks where consecutive presets differ. + * + * @param usedChannels the channels the controller was programmed for + */ + public static Iterator createChannelPerSliceAcqEvents( + AcquisitionEvent baseEvent, AcquisitionSettings settings, + String[] cameraDeviceNames, ChannelSpec[] usedChannels, + 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); + } + + // 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> channels = + channelAxis(usedChannels.length, cameraDeviceNames.length); + + ArrayList>> acqFunctions = new ArrayList<>(); + acqFunctions.add(cameras); + acqFunctions.add(zStack); + acqFunctions.add(channels); + return new AcquisitionEventIterator(baseEvent, acqFunctions, eventMonitor); + } + + /** + * Build events for a volume whose channels the controller switches volume by volume. + *

+ * Not implemented. Present so the gap is visible next to + * {@link #createChannelPerSliceAcqEvents} rather than being rediscovered. + *

+ * This mode switches channel once per volume instead of once per slice, so the controller + * delivers every slice of one channel before it starts the next channel. The channel axis + * therefore has to sit outside the z axis, the opposite of the slice-by-slice factory, + * and the combined axis slot cannot be derived the way {@link #channelAxis} derives it, since + * the cameras fan out below the channels rather than above them. Getting the order wrong does + * not fail: the event count still matches, so the images are simply filed against the wrong + * coordinates. + *

+ * A single view geometry cannot use this at all, because the controller clocks its channel + * counter from the view select signal, which never alternates with one view. The two view + * geometry is what this is for, and its event submission is currently disabled, so nothing + * calls this yet. Implement it against two view hardware rather than from this description. + */ + public static Iterator createChannelPerVolumeAcqEvents( + AcquisitionEvent baseEvent, AcquisitionSettings settings, + String[] cameraDeviceNames, ChannelSpec[] usedChannels, + Function eventMonitor) { + throw new UnsupportedOperationException( + "volume-by-volume hardware channel switching is not implemented; " + + "it needs the channel axis outside the z axis, unlike " + + "createChannelPerSliceAcqEvents"); + } + + /** + * Fan an event out over controller-switched channels, writing only the {@link #CAMERA_AXIS} + * coordinate. + *

+ * 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 + * 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) { + return (AcquisitionEvent event) -> new Iterator<>() { + + private int channelIndex_ = 0; + + @Override + public boolean hasNext() { + return channelIndex_ < numChannels; + } + + @Override + public AcquisitionEvent next() { + AcquisitionEvent channelEvent = event.copy(); + + Object position = event.getAxisPosition(CAMERA_AXIS); + int cameraIndex = 0; + if (position != null) { + try { + cameraIndex = Integer.parseInt(position.toString()); + } catch (NumberFormatException e) { + // ignore => number already assigned + } + } + + channelEvent.setAxisPosition(CAMERA_AXIS, channelIndex_ * numCameras + cameraIndex); + channelIndex_++; + return channelEvent; + } + }; + } + /** * Fan an event out over the cameras, deriving the channel-axis base from the event. *