diff --git a/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/CameraTab.java b/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/CameraTab.java index 1fcc264..074fc15 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/CameraTab.java +++ b/src/main/java/org/micromanager/lightsheetmanager/gui/tabs/CameraTab.java @@ -10,7 +10,10 @@ import javax.swing.JLabel; import java.awt.Font; import java.awt.Rectangle; +import java.util.ArrayList; +import java.util.List; import java.util.Objects; +import java.util.function.Function; public class CameraTab extends Panel implements ListeningPanel { @@ -82,44 +85,19 @@ private void createUserInterface() { // TODO: should change roi for all cameras? private void createEventHandlers() { // roi is full camera - btnFullROI_.registerListener(() -> { - final CameraBase[] cameras = model_.devices().imagingCameras(); - for (CameraBase camera : cameras) { - camera.setROI(camera.getResolution()); - } - }); + btnFullROI_.registerListener(() -> applyROI(this::binnedSensor)); // roi 1/2 - btnHalfROI_.registerListener(() -> { - final CameraBase[] cameras = model_.devices().imagingCameras(); - for (CameraBase camera : cameras) { - camera.setROI(computeCenterRectangle(camera.getResolution(), 2)); - } - }); + btnHalfROI_.registerListener(() -> applyROI(c -> computeCenterRectangle(binnedSensor(c), 2))); // roi 1/4 - btnQuarterROI_.registerListener(() -> { - final CameraBase[] cameras = model_.devices().imagingCameras(); - for (CameraBase camera : cameras) { - camera.setROI(computeCenterRectangle(camera.getResolution(), 4)); - } - }); + btnQuarterROI_.registerListener(() -> applyROI(c -> computeCenterRectangle(binnedSensor(c), 4))); // roi 1/8 - btnEigthROI_.registerListener(() -> { - final CameraBase[] cameras = model_.devices().imagingCameras(); - for (CameraBase camera : cameras) { - camera.setROI(computeCenterRectangle(camera.getResolution(), 8)); - } - }); + btnEigthROI_.registerListener(() -> applyROI(c -> computeCenterRectangle(binnedSensor(c), 8))); // set custom roi - btnCustomROI_.registerListener(() -> { - final CameraBase[] cameras = model_.devices().imagingCameras(); - for (CameraBase camera : cameras) { - camera.setROI(customROI()); - } - }); + btnCustomROI_.registerListener(() -> applyROI(c -> customROI())); // populate spinner with current roi btnCurrentROI_.registerListener(() -> { @@ -137,6 +115,68 @@ private void createEventHandlers() { }); } + /** + * Applies a per-camera ROI to every imaging camera, then checks that they still agree. + * + *

The cameras must end up with the same frame size or the next acquisition kills the JVM, so + * a partial apply is reported rather than swallowed: the vendor adapter rejects an out-of-range + * ROI per camera, which is exactly how two cameras end up at different sizes. Failures are + * collected and shown once, after every camera has been tried; the previous per-camera modal + * dialog inside the loop froze the UI for seconds at a time. + * + * @param target computes the ROI to apply to a given camera, in binned pixels + */ + private void applyROI(final Function target) { + final CameraBase[] cameras = model_.devices().imagingCameras(); + if (cameras.length == 0) { + model_.studio().logs().showError("No imaging camera available; check that a camera is " + + "assigned in the hardware configuration and set as Active on the " + + "Acquisition tab."); + return; + } + + final List rejected = new ArrayList<>(); + for (final CameraBase camera : cameras) { + if (!camera.setROI(target.apply(camera))) { + rejected.add(camera.getDeviceName()); + } + } + + final String mismatch = CameraBase.describeFrameSizeMismatch(cameras); + if (rejected.isEmpty() && mismatch == null) { + return; // every camera accepted the roi and they all agree + } + + final StringBuilder message = new StringBuilder(); + if (!rejected.isEmpty()) { + message.append("The requested ROI was rejected by: ") + .append(String.join(", ", rejected)) + .append(".\n\nROI coordinates are in binned pixels, so the largest usable " + + "offset and size shrink as binning grows.\n\n"); + } + if (mismatch != null) { + message.append("The imaging cameras no longer agree on frame size: ") + .append(mismatch) + .append(".\n\nAcquisitions are blocked until they match, because cameras with " + + "different frame sizes crash Micro-Manager outright."); + } + model_.studio().logs().showError(message.toString().trim()); + } + + /** + * Returns this camera's sensor size in binned pixels, i.e. the largest ROI it can be given. + * + *

{@code core.setROI()} takes binned coordinates while + * {@link CameraBase#getResolution()} deliberately reports unbinned pixels, so the sensor has to + * be scaled down before it is used as an ROI. Skipping this made every preset out of range at + * binning above 1: a 2400 px sensor at 2x2 binning only addresses 1200. + */ + private Rectangle binnedSensor(final CameraBase camera) { + final Rectangle sensor = camera.getResolution(); + final int binning = Math.max(1, camera.getBinning()); + return new Rectangle(0, 0, sensor.width / binning, sensor.height / binning); + } + // Returns the custom ROI set by the spinners. private Rectangle customROI() { return new Rectangle( diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/PLogicScape.java b/src/main/java/org/micromanager/lightsheetmanager/model/PLogicScape.java index 3b73b67..f19c82e 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/PLogicScape.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/PLogicScape.java @@ -620,7 +620,7 @@ private boolean cleanUpControllerAfterAcquisitionSide( // make sure SPIM state machine is stopped scanner_.setSPIMState(ASIScanner.SPIMState.IDLE); - // NB: no sheet width/offset to restore here — SCAPE never writes the galvo x-axis + // NB: no sheet width/offset to restore here; SCAPE never writes the galvo x-axis // (see prepareControllerForAcquisitionSide), so nothing can have clobbered it. // move piezo back to desired position 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 98feac1..8aee9fd 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngine.java @@ -118,6 +118,33 @@ protected boolean validateSaveLocation() { return true; } + /** + * Validates that every imaging camera will deliver the same frame size, before anything is armed. + * + *

Cameras that disagree overrun the shared Core circular buffer and take the whole JVM with + * them: {@code EXCEPTION_ACCESS_VIOLATION} inside {@code popNextImageMD}, no Java exception, no + * recovery, no data. Refusing to arm is the only place this can be stopped from inside LSM. + * Observed in the field 2026-07-28 on a dual-Kinetix rig where a partly-applied ROI left one + * camera at 1200x1200 and the other at 600x600. + * + *

Called from both geometry engines' {@code setup()} before any hardware is touched, so a + * failure costs nothing and leaves the microscope untouched. + * + * @return true if the cameras agree, or there is only one; false to abort setup + */ + protected boolean validateCameraFrameSizes() { + final CameraBase[] cameras = model_.devices().imagingCameras(); + final String mismatch = CameraBase.describeFrameSizeMismatch(cameras); + if (mismatch == null) { + return true; + } + studio_.logs().showError("The imaging cameras have different frame sizes: " + mismatch + + "\n\nAcquiring with mismatched frame sizes crashes Micro-Manager outright, so this " + + "acquisition was not started.\n\nSet the same ROI and binning on every imaging " + + "camera from the Camera tab, then try again."); + return false; + } + public AcquisitionEngine(final LightSheetManager model) { model_ = Objects.requireNonNull(model); studio_ = model.studio(); diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java index 751506e..851953a 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineDispim.java @@ -80,6 +80,11 @@ boolean setup() { } } + // mismatched camera frame sizes kill the JVM once acquisition starts, so refuse to arm + if (!validateCameraFrameSizes()) { + return false; // early exit => cameras disagree on frame size + } + return true; } 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 337289a..6fe60a1 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/acquisitions/AcquisitionEngineScape.java @@ -95,6 +95,11 @@ boolean setup() { } } + // mismatched camera frame sizes kill the JVM once acquisition starts, so refuse to arm + if (!validateCameraFrameSizes()) { + return false; // early exit => cameras disagree on frame size + } + // // check pixel size // if (core_.getPixelSizeUm() < 1e-6) { // studio_.logs().showError( diff --git a/src/main/java/org/micromanager/lightsheetmanager/model/devices/cameras/CameraBase.java b/src/main/java/org/micromanager/lightsheetmanager/model/devices/cameras/CameraBase.java index 5945637..21e7731 100644 --- a/src/main/java/org/micromanager/lightsheetmanager/model/devices/cameras/CameraBase.java +++ b/src/main/java/org/micromanager/lightsheetmanager/model/devices/cameras/CameraBase.java @@ -41,18 +41,35 @@ public double getExposure() { return exposure; } + /** + * Returns this camera's ROI in binned pixels, the unit {@code core.setROI()} uses. + * + *

Device-scoped: the no-argument {@code core_.getROI()} reads whichever camera the Core is + * pointed at, which is the wrong camera on any dual-camera rig. + */ // TODO: take binning into account public Rectangle getROI() { Rectangle roi = new Rectangle(); try { - roi = core_.getROI(); + roi = core_.getROI(deviceName_); } catch (Exception e) { studio_.logs().showError("could not get camera roi"); } return roi; } - public void setROI(final Rectangle roi) { + /** + * Applies an ROI to this camera, in binned pixels. + * + *

Reports the outcome rather than showing it: callers apply ROIs to several cameras and must + * be able to tell a partial apply from a clean one, because a partial apply leaves the cameras + * disagreeing on frame size; see {@link #describeFrameSizeMismatch(CameraBase[])}. Showing a + * dialog here also blocked the EDT once per camera. + * + * @param roi the ROI in binned pixels + * @return true if the camera accepted the ROI + */ + public boolean setROI(final Rectangle roi) { final boolean isLiveModeOn = studio_.live().isLiveModeOn(); if (isLiveModeOn) { studio_.live().setLiveModeOn(false); @@ -61,14 +78,60 @@ public void setROI(final Rectangle roi) { studio_.live().getDisplay().close(); } } + boolean accepted = true; try { core_.setROI(deviceName_, roi.x, roi.y, roi.width, roi.height); } catch (Exception e) { - studio_.logs().showError("could not set camera roi"); + accepted = false; + studio_.logs().logError("could not set roi " + roi.width + "x" + roi.height + " at (" + + roi.x + ", " + roi.y + ") on camera " + deviceName_ + ": " + e.getMessage()); } if (isLiveModeOn) { studio_.live().setLiveModeOn(true); } + return accepted; + } + + /** + * Describes how the given cameras disagree on frame size, or returns null when they agree. + * + *

Every camera's {@code StartSequenceAcquisition} re-initializes the shared Core + * circular buffer to its own frame size, while the JNI image pop sizes its copy from the + * Core-active camera's dimensions with no bounds check. Two cameras with different frame sizes + * therefore read past the end of a buffer slot and kill the JVM outright with an + * {@code EXCEPTION_ACCESS_VIOLATION}, not a Java exception, so nothing downstream can catch or + * recover from it. + * + *

Compares dimensions only: MMCore exposes no per-device bytes-per-pixel accessor, so a + * bit-depth mismatch between two cameras is not detected here. + * + *

A zero-area frame counts as a disagreement even if every camera reports one, because + * {@link #getROI()} returns an empty rectangle when the read itself fails. Two unreadable + * cameras would otherwise look like two matching ones and pass. + * + * @param cameras the cameras that will image together + * @return a description of the disagreement, or null if every camera reports the same frame size + */ + public static String describeFrameSizeMismatch(final CameraBase[] cameras) { + if (cameras == null || cameras.length < 2) { + return null; // a single camera cannot disagree with itself + } + final Rectangle first = cameras[0].getROI(); + boolean disagree = false; + final StringBuilder sizes = new StringBuilder(); + for (final CameraBase camera : cameras) { + final Rectangle roi = camera.getROI(); + if (roi.width <= 0 || roi.height <= 0 + || roi.width != first.width || roi.height != first.height) { + disagree = true; + } + if (sizes.length() > 0) { + sizes.append(", "); + } + sizes.append(camera.getDeviceName()) + .append(" = ").append(roi.width).append("x").append(roi.height); + } + return disagree ? sizes.toString() : null; } public void setROI() { @@ -103,6 +166,12 @@ public CameraMode getTriggerMode() { @Override public abstract int getBinning(); + /** + * Returns the physical sensor size in unbinned pixels. + * + *

Binning is not applied here because readout and reset times depend on the number of + * physical rows read, which does not change with binning. + */ @Override public abstract Rectangle getResolution();