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 {@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();