Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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 {

Expand Down Expand Up @@ -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(() -> {
Expand All @@ -137,6 +115,68 @@ private void createEventHandlers() {
});
}

/**
* Applies a per-camera ROI to every imaging camera, then checks that they still agree.
*
* <p>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<CameraBase, Rectangle> 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<String> 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.
*
* <p>{@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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,33 @@ protected boolean validateSaveLocation() {
return true;
}

/**
* Validates that every imaging camera will deliver the same frame size, before anything is armed.
*
* <p>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.
*
* <p>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();
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,18 +41,35 @@ public double getExposure() {
return exposure;
}

/**
* Returns this camera's ROI in binned pixels, the unit {@code core.setROI()} uses.
*
* <p>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.
*
* <p>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);
Expand All @@ -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.
*
* <p>Every camera's {@code StartSequenceAcquisition} re-initializes the <em>shared</em> 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.
*
* <p>Compares dimensions only: MMCore exposes no per-device bytes-per-pixel accessor, so a
* bit-depth mismatch between two cameras is not detected here.
*
* <p>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() {
Expand Down Expand Up @@ -103,6 +166,12 @@ public CameraMode getTriggerMode() {
@Override
public abstract int getBinning();

/**
* Returns the physical sensor size in unbinned pixels.
*
* <p>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();

Expand Down
Loading