Skip to content

MMCore: (more) proper support for multi-channel cameras#936

Draft
marktsuchida wants to merge 17 commits into
micro-manager:mainfrom
marktsuchida:seq-acq-tracking
Draft

MMCore: (more) proper support for multi-channel cameras#936
marktsuchida wants to merge 17 commits into
micro-manager:mainfrom
marktsuchida:seq-acq-tracking

Conversation

@marktsuchida
Copy link
Copy Markdown
Member

@marktsuchida marktsuchida commented May 2, 2026

This is a DIV-bumping change; it might become the main change for DIV 76 (#886).

  • Introduce internal SequenceAcquisition object to explicitly represent and track the lifetime of each sequence acquisition, replacing ad-hoc state scattered across CMMCore
  • Add proper support for multi-channel (composite) camera sequence acquisition, with MMCore managing channel tags and participant tracking instead of mutating physical camera objects
  • Make autoshutter open/close thread-safe
  • Document the device-side sequence acquisition contract in MMDevice

Device interface changes

Bumps the device interface version:

  • MM::Camera: add GetChannelCameraPtr() for MMCore to query participating physical cameras
  • Remove the AddTag/RemoveTag mechanism (tags are now generated by MMCore during InsertImage())

Updated device adapters: MultiCamera, ArduinoCounter, CameraPulser, TwoPhoton. Regular (physical, single-channel) cameras don't need to change.


Notable behavioral changes

  • Physical cameras of a composite camera no longer emit channel tags when running solo
  • Composite cameras open the shutter once (not per-physical-camera) and close it only after all physical cameras finish
  • PrepareForAcq/InsertImage/AcqFinished callbacks now reject callers not participating in the active acquisition
  • SequenceAcquisitionStarted/Stopped notifications fire once per composite camera, not per physical camera
  • Intrinsic multi-channel cameras must provide a valid in-range CameraChannelIndex; CameraChannelName is now added by MMCore
  • Nested multi-channel cameras are detected and disallowed
  • If autoshutter was opened but camera failed to start, close the shutter (also issue Stopped notification)
  • Concurrent sequence acquisitions involving the same primary/physical cameras is explicitly prohibited
  • Equal width/height/bpp/nComponents is enforced upfront for composite camera channels

Things not covered here

  • This opens up a path toward per-channel sequence buffers, but that is for another PR.
  • We don't (yet) make an effort to make multiple concurrent sequence acquisitions usable. That probably also needs per-acquisition sequence buffers. Can also support unequal image size/format in this case.
  • The multi-channel-related tags still have the primary camera's label prefixed, to preserve behavior.
  • We don't freeze the autoshutter state and shutter to use at the start of acquisition. I'm not sure if that would be better or worse.

SequenceAcquisition is created for each new sequence acquisition and is
cleaned up (for now) lazily. Better cleanup will be possible once we
start tracking cameras participating in composite cameras (like Multi
Camera).

This is a start toward more directly managing sequence acquisitions by
the Core, so that multiple cameras/acquisitions can be better supported
and thread safety can be improved. For now, it does little more than
just create the SequenceAcquisition object.

(Assisted by Claude Code; any errors are mine.)
Simplify the way in which frame tags are generated for multi-channel
cameras. Especially for composite cameras (e.g. Multi Camera), avoid
mutating the physical camera objects (which could not be cleanly undone
at the right timing) and instead map physical cameras to tags in the
Core.

Known behavioral changes:

1. Physical cameras associated with Multi Camera (or similar composite
   cameras) no longer incorrectly generate camera channel name/index
   tags when running sequence acquisition as an individual camera.
2. When autoshutter is enabled, composite cameras no longer cause the
   shutter to be opened/closed multiple times (even though it was
   idempotent). None of the physical cameras start acquiring until the
   shutter finishes opening, and the shutter is not closed until all
   physical cameras finish/stop acquiring.
3. If opening the shutter fails, `PrepareForAcq()` now returns an error.
4. `PrepareForAcq/InsertImage/AcqFinished` callbacks will fail if not
   issued from a camera currently participating in a sequence
   acquisition (previously any device could theoretically call these at
   any time and send images to the buffer).
5. The `SequenceAcquisitionStarted/Stopped` notifications now use the
   primary camera label and are issued once, instead of for each
   physical camera, in the case of a composite multi-channel camera.
6. Intrinsic multi-channel cameras must tag images with a valid
   CameraChannelIndex that is in range; this is now verified.
   CameraChannelName is now added by MMCore so does not need to be added
   by the camera.
7. Nested multi-channel cameras are explicitly detected and disallowed.

Bumps the device interface version due to the following changes:

- In `MM::Camera`, add `GetChannelCameraPtr()` so that MMCore can query
  the primary camera for all participating (physical) cameras.

- Remove the `AddTag/RemoveTag` mechanism; instead, let the
  `SequenceAcquisition` object manage all participating cameras in a
  composite multi-channel camera acquisition, and generate tags
  (`CameraChannelIndex`, `CameraChannelName`) during `InsertImage()` in
  MMCore, where possible (`CameraChannelIndex` is still required from
  intrinsic multi-channel cameras, because they come from the same
  source).

Note: the CameraChannelIndex and CameraChannelName tags are still
prefixed with `primariCameraLabel-` for now, to preserve existing
behavior.

Composite multi-channel cameras MultiCamera, ArduinoCounter, and
CameraPulser are updated to remove the physical camera mutations
(`AddTag`/`RemoveTag`) and implement `GetChannelCameraPtr()`.

The only intrinsic multi-channel camera, TwoPhoton, is updated to remove
the tags that are no longer necessary.

(Assisted by Claude Code; any errors are mine.)
Finally.

(Assisted by Claude Code; any errors are mine.)
Factor out the common code and remove minor differences (which were
presumably not intentional).

Differences resolved:

- Camera adapter lock is acquired _before_ initializing the sequence
  buffer (because the latter accesses camera parameters).

- Don't catch std::bad_alloc (CircularBuffer::Initialize() already
  does for the main buffer memory).

(Assisted by Claude Code; any errors are mine.)
(Assisted by Claude Code; any errors are mine.)
Remove the unsafe hacks; when shutter is in the same adapter as the
_primary_ camera (whose adapter we _may_ hold the lock for, in the same
or different thread), fall back to closing the shutter afterwards on the
thread calling stopSequenceAcquisition().

But always use try_lock() so that we start closing the shutter asap when
we are able to -- so that the specimen is not exposed to illumination
while the camera tears down the sequence acquisition (which for some
cameras might be slow).

Real (physical) cameras don't typically have a shutter in the same
adapter. Demo/simulated cameras often do, but then we don't care if the
shutter closure is ordered after the return from
StopSequenceAcquisition(). Multi Camera (and similar) does not call
AcqFinished() (only its physical cameras do), so even if the shutter is
from the same adapter as the composite camera, we don't need to defer
shutter closure.

In no case do we call the shutter without holding the device module lock
any more.

(Assisted by Claude Code; any errors are mine.)
Another long-standing bug we can now easily fix.

(Assisted by Claude Code; any errors are mine.)
That is, disallow starting a sequence acquisition where any of the
participants (primary + physical) overlap with an ongoing acquisition.

(Assisted by Claude Code; any errors are mine.)
It will fail anyway on InsertImage(), so fail fast.

(Assisted by Claude Code; any errors are mine.)
This would always cause problems or errors; now catch upfront.

For now, no acquisition can be running because we use a single, global
buffer.

(Assisted by Claude Code; any errors are mine.)
If the shutter is in the same adapter as the camera _and_ the camera
calls PrepareForAcq() from a thread other than the one calling into
StartSequenceAcquisition(), defer the shutter opening so that we don't
deadlock.

This is probably not an issue today, but would become one if, say, a
MultiCamera-like device decides to start physical cameras in parallel
(which has its own set of problems if they come from the same adapter,
so perhaps is unlikely to happen).

Even with this, there will be a deadlock if the camera's
StartSequenceAcquisition() waits for PrepareForAcq() to return; that is
forbidden and is now documented (there's no reasonable way to work
around that situation, and fortunately we don't face it in any known
existing camera).

(Assisted by Claude Code; any errors are mine.)
Avoid waiting forever when shutter opening/waiting failed. Also, avoid
leaking exceptions back to caller of PrepareForAcq().

(Assisted by Claude Code; any errors are mine.)
When the autoshutter is in the same adapter module as the camera and
AcqFinished's try_lock on the module lock fails, the old code
unconditionally set shutterCloseDeferred_ and relied on
stopSequenceAcquisition to drain it. That broke natural completion —
finite-frame acquisitions that complete without anyone calling
stopSequenceAcquisition would leave the autoshutter open forever,
because the deferred-close was only drained from the stop path.

The new design splits that path atomically (under
SequenceAcquisition::mu_):

- If stopRequested_ is set → defer (stopper will drain via
  TakeDeferredShutterClose).
- Otherwise → spawn a worker thread that takes the module lock
  blockingly and closes the shutter itself, then posts
  ShutterOpenChanged. The thread is owned by the SA
  (shutterCloseWorker_) and joined either by stopSequenceAcquisition or
  by ~SequenceAcquisition.

To make the atomicity work, CMMCore::stopSequenceAcquisition now calls
MarkStopRequested() before taking the module lock. That ordering is
important: it guarantees that any AcqFinished racing with the stopper
observes stopRequested_=true and takes the defer branch instead of
spawning a worker the stopper would have to chase.

(Assisted by Claude Code; any errors are mine.)
If the camera is a participant in a composite camera acquisition, it
remains "running" until that acquisition as a whole finishes.

We keep the camera IsCapturing() check, too, because some cameras may
briefly return IsCapturing() == true after calling AcqFinished().

(Assisted by Claude Code; any errors are mine.)
DRY.

(Assisted by Claude Code; any errors are mine.)
Reject attempts to stop a non-primary participant in a composite camera
acquisition.

(Previous behavior was to roguely stop just that physical camera, which
doesn't really make sense.)

(Assisted by Claude Code; any errors are mine.)
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant