From 02974f124fd5818d7b1371194d7f95cc0bbb33c8 Mon Sep 17 00:00:00 2001 From: Benedict Diederich Date: Fri, 1 May 2026 10:53:08 +0200 Subject: [PATCH 1/2] Add pymmcore-plus MMCore managers & CI Introduce optional pymmcore-plus integration: add a process-wide MMCoreManager singleton and three MMCore* device managers (detector, positioner, laser) that wrap Micro-Manager adapters. Include unit tests that exercise the DemoCamera adapter, example setup JSON files for demo and Andor configurations, and user documentation for pymmcore-plus integration. Add a GitHub Actions workflow to run MMCore tests and to build/publish arm64 mmCoreAndDevices artifacts, plus a Raspberry Pi install script for building Micro-Manager on arm64. Minor controller comment and pyproject update included. --- .github/workflows/build-mm-arm64.yml | 108 +++ docs/pymmcore-integration.md | 151 +++ .../example_mmcore_andor.json | 91 ++ .../imcontrol_setups/example_mmcore_demo.json | 112 +++ .../_test/unit/test_mmcore_managers.py | 255 ++++++ .../experiment_normal_mode.py | 1 + .../imcontrol/model/managers/MMCoreManager.py | 198 ++++ imswitch/imcontrol/model/managers/__init__.py | 1 + .../detectors/MMCoreDetectorManager.py | 299 ++++++ .../managers/lasers/MMCoreLaserManager.py | 121 +++ .../positioners/MMCorePositionerManager.py | 171 ++++ install_micromanager_raspi.sh | 557 ++++++++++++ micromanager-userguide.md | 860 ++++++++++++++++++ pymmcore-feature-integraion.md | 791 ++++++++++++++++ pyproject.toml | 3 + 15 files changed, 3719 insertions(+) create mode 100644 .github/workflows/build-mm-arm64.yml create mode 100644 docs/pymmcore-integration.md create mode 100644 imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor.json create mode 100644 imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_demo.json create mode 100644 imswitch/imcontrol/_test/unit/test_mmcore_managers.py create mode 100644 imswitch/imcontrol/model/managers/MMCoreManager.py create mode 100644 imswitch/imcontrol/model/managers/detectors/MMCoreDetectorManager.py create mode 100644 imswitch/imcontrol/model/managers/lasers/MMCoreLaserManager.py create mode 100644 imswitch/imcontrol/model/managers/positioners/MMCorePositionerManager.py create mode 100644 install_micromanager_raspi.sh create mode 100644 micromanager-userguide.md create mode 100644 pymmcore-feature-integraion.md diff --git a/.github/workflows/build-mm-arm64.yml b/.github/workflows/build-mm-arm64.yml new file mode 100644 index 00000000..4ea9d243 --- /dev/null +++ b/.github/workflows/build-mm-arm64.yml @@ -0,0 +1,108 @@ +name: Build Micro-Manager adapters (arm64) & test pymmcore-plus integration + +on: + push: + branches: [feat/pymmcore-integration] + tags: ["mm-v*"] + workflow_dispatch: + +jobs: + # --------------------------------------------------------------------- + # Run the Python integration tests on x86_64 with the DemoCamera adapter + # downloaded by `mmcore install`. + # --------------------------------------------------------------------- + test-x86: + runs-on: ubuntu-24.04 + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Install ImSwitch (no heavy extras) + pymmcore-plus + run: | + python -m pip install --upgrade pip + # Install only what the MMCore tests touch – avoids pulling the full + # ImSwitch dependency chain on CI. + pip install pytest numpy "pymmcore-plus[cli]>=0.10" + + - name: Download Micro-Manager DemoCamera adapter + run: | + mmcore install + mmcore list + + - name: Make repo importable as a package + run: | + pip install -e . --no-deps || true + + - name: Run MMCore manager tests + env: + MICROMANAGER_PATH: "" + run: | + pytest imswitch/imcontrol/_test/unit/test_mmcore_managers.py -v + + # --------------------------------------------------------------------- + # Build mmCoreAndDevices for arm64 in an emulated container, then + # publish the resulting .so files as a workflow artifact / GH release. + # --------------------------------------------------------------------- + build-arm64: + runs-on: ubuntu-24.04 + permissions: + contents: write + steps: + - uses: actions/checkout@v4 + + - name: Set up QEMU for arm64 emulation + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Determine Micro-Manager source ref + id: mm + run: | + # Pin to a known-good tag so device interface version stays stable. + echo "ref=main" >> "$GITHUB_OUTPUT" + + - name: Build inside arm64 container + uses: uraimo/run-on-arch-action@v3 + with: + arch: aarch64 + distro: ubuntu22.04 + githubToken: ${{ secrets.GITHUB_TOKEN }} + dockerRunArgs: | + --volume "${{ github.workspace }}:/work" + install: | + apt-get update -qq + DEBIAN_FRONTEND=noninteractive apt-get install -qq -y \ + git build-essential autoconf automake libtool pkg-config \ + swig python3-dev libboost-all-dev curl ca-certificates + run: | + set -euxo pipefail + cd /work + git clone --depth 1 --branch ${{ steps.mm.outputs.ref }} \ + https://github.com/micro-manager/mmCoreAndDevices.git mm-src + cd mm-src + ./autogen.sh + ./configure --prefix=/work/mm-out --without-java + make -j"$(nproc)" + make install + cd /work + mkdir -p artifact/micro-manager/lib/micro-manager + cp -a /work/mm-out/lib/micro-manager/. artifact/micro-manager/lib/micro-manager/ + tar -C artifact -czf micro-manager-arm64.tar.gz micro-manager + ls -lh micro-manager-arm64.tar.gz + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: micro-manager-arm64 + path: micro-manager-arm64.tar.gz + if-no-files-found: error + + - name: Publish as GitHub Release + if: startsWith(github.ref, 'refs/tags/mm-v') + uses: softprops/action-gh-release@v2 + with: + files: micro-manager-arm64.tar.gz + fail_on_unmatched_files: true diff --git a/docs/pymmcore-integration.md b/docs/pymmcore-integration.md new file mode 100644 index 00000000..4bcd9a98 --- /dev/null +++ b/docs/pymmcore-integration.md @@ -0,0 +1,151 @@ +# pymmcore-plus integration + +ImSwitch can drive any camera, stage, or laser that has a Micro-Manager +device adapter — Andor, Hamamatsu, Basler, ASI, Prior, Thorlabs, Coherent +and ~250 others — through the [`pymmcore-plus`][pymmcore-plus] Python +bindings to the Micro-Manager `MMCore` C++ library. + +There is **no Java**, **no MMStudio**, and **no separate server process**: +`pymmcore-plus` loads the same C++ adapters that MMStudio uses directly +into the ImSwitch Python process. + +## What you get + +Three new device managers, picked up by the standard `managerName` +mechanism in your setup JSON: + +| Manager class | Replaces | Wraps | +|----------------------------|--------------------------|------------------------------------| +| `MMCoreDetectorManager` | Camera-specific manager | `core.snap()`, sequence acquisition | +| `MMCorePositionerManager` | Stage-specific manager | XY + Z stage devices | +| `MMCoreLaserManager` | Laser-specific manager | Shutter or DA property device | + +All three share a process-wide `CMMCorePlus` singleton (see +[`MMCoreManager.py`](../imswitch/imcontrol/model/managers/MMCoreManager.py)) +so a single `.cfg` file drives every device without USB conflicts. + +## Installation + +```bash +pip install "ImSwitchUC2[pymmcore]" +# or, in a dev checkout: +pip install -e ".[pymmcore]" +``` + +To make device adapters available, install the Micro-Manager binaries: + +```bash +# x86_64 / arm64 macOS / x86_64 Linux: download via pymmcore-plus CLI +pip install "pymmcore-plus[cli]" +mmcore install # downloads adapters under ~/.local/share/pymmcore-plus +``` + +On a **Raspberry Pi (arm64)** there is no published binary build, so use +either: + +* The provided helper script + [`install_micromanager_raspi.sh`](../install_micromanager_raspi.sh), or +* The prebuilt tarball published by the + [`build-mm-arm64`](../.github/workflows/build-mm-arm64.yml) GitHub + Actions workflow on every tagged release. + +Set `MICROMANAGER_PATH` to the directory containing +`libmmgr_dal_*.so` if it is not auto-discovered. + +## Quick start: the DemoCamera setup + +The [`example_mmcore_demo.json`](../imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_demo.json) +setup uses the `DemoCamera` adapter that ships with every Micro-Manager +install — no `.cfg` file required. + +```bash +imswitch --setup example_mmcore_demo.json +``` + +You should see a `MMCamera` detector, a `MMStage` XYZ positioner, and +the `MMShutter` laser show up in the UI immediately. + +## Using a real camera + +Two configuration modes are supported. + +### Mode A — write a Micro-Manager `.cfg` file + +Configure your hardware once with `MMConfig.exe` (or by hand) and point +ImSwitch at the resulting file. Multiple managers can share the same +`.cfg`; it is loaded only once per process: + +```json +{ + "detectors": { + "AndorCamera": { + "managerName": "MMCoreDetectorManager", + "managerProperties": { + "cfgPath": "/home/pi/configs/Andor_ASI.cfg", + "deviceLabel": "Andor sCMOS Camera" + }, + "forAcquisition": true + } + } +} +``` + +A complete example lives in +[`example_mmcore_andor.json`](../imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor.json). + +### Mode B — declare the device inline + +Skip `.cfg` files entirely by listing the adapter and device name in the +manager properties — handy for quick demos: + +```json +{ + "detectors": { + "Cam": { + "managerName": "MMCoreDetectorManager", + "managerProperties": { + "adapterName": "HamamatsuHam", + "deviceName": "HamamatsuHam_DCAM", + "deviceLabel": "Hamamatsu" + }, + "forAcquisition": true + } + } +} +``` + +## Discovering adapters and devices + +Use the helpers on `MMCoreManager` to introspect what is installed: + +```python +from imswitch.imcontrol.model.managers import MMCoreManager + +# All adapters present on disk +print(MMCoreManager.get_available_adapters("/opt/micro-manager/lib/micro-manager")) + +# Devices a specific adapter exposes (requires the singleton to be alive) +core = MMCoreManager.get_core() +print(MMCoreManager.get_available_devices_for_adapter("HamamatsuHam")) +``` + +## Raspberry Pi notes + +* Use a Pi 5 with **8 GB RAM** for anything beyond the demo adapter. +* Building `mmCoreAndDevices` from source on the Pi takes ~30 minutes; + prefer the prebuilt tarball from CI. +* Adapters that depend on Windows-only vendor DLLs (e.g. some + AndorSDK3 builds) cannot be used on Linux — check the vendor's docs. + +## Known limitations + +* No Java, no MMStudio integration. +* `MMCorePositionerManager.moveForever` is a no-op — Micro-Manager has no + generic jog primitive. +* Laser power calibration is the user's responsibility: `mode: "property"` + writes a raw value (typically volts). +* Some adapter properties expose values that don't fit our number/list + parameter widgets; those properties are silently skipped in the UI but + remain settable via `setProperty` calls. + +[pymmcore-plus]: https://pymmcore-plus.github.io/pymmcore-plus/ diff --git a/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor.json b/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor.json new file mode 100644 index 00000000..c4b515c4 --- /dev/null +++ b/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor.json @@ -0,0 +1,91 @@ +{ + "detectors": { + "AndorCamera": { + "analogChannel": null, + "digitalLine": null, + "managerName": "MMCoreDetectorManager", + "managerProperties": { + "cfgPath": "/home/pi/micro-manager-configs/Andor_ASI.cfg", + "deviceLabel": "Andor sCMOS Camera" + }, + "forAcquisition": true, + "forFocusLock": false + } + }, + "lasers": {}, + "LEDs": {}, + "LEDMatrixs": {}, + "positioners": { + "ASIStage": { + "analogChannel": null, + "digitalLine": null, + "managerName": "MMCorePositionerManager", + "managerProperties": { + "cfgPath": "/home/pi/micro-manager-configs/Andor_ASI.cfg", + "xyDeviceLabel": "XYStage:XY:31", + "zDeviceLabel": "ZStage:Z:32" + }, + "axes": ["X", "Y", "Z"], + "isPositiveDirection": true, + "forPositioning": true, + "forScanning": true, + "resetOnClose": false, + "stageOffsets": { + "stageOffsetPositionX": 0.0, + "stageOffsetPositionY": 0.0, + "stageOffsetPositionZ": 0.0 + } + } + }, + "rs232devices": {}, + "slm": null, + "sim": null, + "dpc": null, + "objective": null, + "mct": null, + "nidaq": { + "timerCounterChannel": null, + "startTrigger": false + }, + "roiscan": null, + "lightsheet": null, + "webrtc": null, + "hypha": null, + "Stresstest": {}, + "HistoScan": null, + "Workflow": null, + "FlowStop": null, + "Lepmon": null, + "Flatfield": null, + "PixelCalibration": null, + "experiment": null, + "uc2Config": null, + "ism": null, + "focusLock": null, + "fovLock": null, + "autofocus": null, + "scan": null, + "etSTED": null, + "rotators": null, + "microscopeStand": null, + "storage": null, + "pulseStreamer": { + "ipAddress": null + }, + "pyroServerInfo": null, + "rois": {}, + "ledPresets": {}, + "defaultLEDPresetForScan": null, + "laserPresets": {}, + "stageOffsets": {}, + "defaultLaserPresetForScan": null, + "availableWidgets": [ + "Settings", + "View", + "Recording", + "Image", + "Positioner" + ], + "nonAvailableWidgets": [], + "designerId": null +} diff --git a/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_demo.json b/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_demo.json new file mode 100644 index 00000000..6c3e2a63 --- /dev/null +++ b/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_demo.json @@ -0,0 +1,112 @@ +{ + "detectors": { + "MMCamera": { + "analogChannel": null, + "digitalLine": null, + "managerName": "MMCoreDetectorManager", + "managerProperties": { + "adapterName": "DemoCamera", + "deviceName": "DCam", + "deviceLabel": "Camera" + }, + "forAcquisition": true, + "forFocusLock": false + } + }, + "lasers": { + "MMShutter": { + "analogChannel": null, + "digitalLine": null, + "managerName": "MMCoreLaserManager", + "managerProperties": { + "adapterName": "DemoCamera", + "deviceName": "DShutter", + "deviceLabel": "Shutter", + "mode": "shutter" + }, + "wavelength": 488, + "valueRangeMin": 0, + "valueRangeMax": 1, + "valueRangeStep": 1.0 + } + }, + "LEDs": {}, + "LEDMatrixs": {}, + "positioners": { + "MMStage": { + "analogChannel": null, + "digitalLine": null, + "managerName": "MMCorePositionerManager", + "managerProperties": { + "xyAdapterName": "DemoCamera", + "xyDeviceName": "DXYStage", + "xyDeviceLabel": "XY", + "zAdapterName": "DemoCamera", + "zDeviceName": "DStage", + "zDeviceLabel": "Z" + }, + "axes": ["X", "Y", "Z"], + "isPositiveDirection": true, + "forPositioning": true, + "forScanning": true, + "resetOnClose": false, + "stageOffsets": { + "stageOffsetPositionX": 0.0, + "stageOffsetPositionY": 0.0, + "stageOffsetPositionZ": 0.0 + } + } + }, + "rs232devices": {}, + "slm": null, + "sim": null, + "dpc": null, + "objective": null, + "mct": null, + "nidaq": { + "timerCounterChannel": null, + "startTrigger": false + }, + "roiscan": null, + "lightsheet": null, + "webrtc": null, + "hypha": null, + "Stresstest": {}, + "HistoScan": null, + "Workflow": null, + "FlowStop": null, + "Lepmon": null, + "Flatfield": null, + "PixelCalibration": null, + "experiment": null, + "uc2Config": null, + "ism": null, + "focusLock": null, + "fovLock": null, + "autofocus": null, + "scan": null, + "etSTED": null, + "rotators": null, + "microscopeStand": null, + "storage": null, + "pulseStreamer": { + "ipAddress": null + }, + "pyroServerInfo": null, + "rois": {}, + "ledPresets": {}, + "defaultLEDPresetForScan": null, + "laserPresets": {}, + "stageOffsets": {}, + "defaultLaserPresetForScan": null, + "availableWidgets": [ + "Settings", + "View", + "Recording", + "Image", + "Laser", + "Positioner" + ], + "nonAvailableWidgets": [], + "designerId": null +} diff --git a/imswitch/imcontrol/_test/unit/test_mmcore_managers.py b/imswitch/imcontrol/_test/unit/test_mmcore_managers.py new file mode 100644 index 00000000..5bc49fd4 --- /dev/null +++ b/imswitch/imcontrol/_test/unit/test_mmcore_managers.py @@ -0,0 +1,255 @@ +""" +Tests for the MMCore* device managers using the Micro-Manager DemoCamera +adapter. The entire module is skipped when ``pymmcore-plus`` is not +installed or when no device adapter library can be located. +""" + +from __future__ import annotations + +import glob +import os +from unittest.mock import MagicMock + +import numpy as np +import pytest +import os +import sys + +pymmcore_plus = pytest.importorskip("pymmcore_plus") + + +def _adapters_available() -> bool: + # differentitate based on OS + if sys.platform.startswith("win"): + candidates = [ + os.environ.get("MICROMANAGER_PATH", ""), + r"C:\Program Files\Micro-Manager\DeviceAdapters", + r"C:\Program Files (x86)\Micro-Manager\DeviceAdapters", + ] + elif sys.platform.startswith("darwin"): + candidates = [ + os.environ.get("MICROMANAGER_PATH", ""), + "/Applications/Micro-Manager.app/Contents/DeviceAdapters", + ] + else: + candidates = [ + os.environ.get("MICROMANAGER_PATH", ""), + "/opt/micro-manager/lib/micro-manager", + os.path.expanduser( + "~/mm-venv/lib/python3.*/site-packages/pymmcore_plus/install/Micro-Manager-*" + ), + os.path.expanduser( + "~/.local/share/pymmcore-plus/mm/Micro-Manager-*" + ), + ] + # pymmcore-plus also ships a helper to locate installed adapters. + try: + from pymmcore_plus import find_micromanager # type: ignore + + path = find_micromanager() + if path and os.path.isdir(path): + return True + except Exception: + pass + + for candidate in candidates: + if not candidate: + continue + for expanded in glob.glob(candidate): + if os.path.isdir(expanded): + return True + return False + + +pytestmark = pytest.mark.skipif( + not _adapters_available(), + reason="No Micro-Manager device adapters found (set MICROMANAGER_PATH)", +) + + +def _make_detector_info(): + info = MagicMock() + info.managerProperties = { + "adapterName": "DemoCamera", + "deviceName": "DCam", + "deviceLabel": "Camera", + } + info.forAcquisition = True + info.forFocusLock = False + return info + + +def _make_positioner_info(): + info = MagicMock() + info.managerProperties = { + "xyAdapterName": "DemoCamera", + "xyDeviceName": "DXYStage", + "xyDeviceLabel": "XY", + "zAdapterName": "DemoCamera", + "zDeviceName": "DStage", + "zDeviceLabel": "Z", + } + info.axes = ["X", "Y", "Z"] + info.forPositioning = True + info.forScanning = True + info.resetOnClose = False + return info + + +def _make_laser_info(): + info = MagicMock() + info.managerProperties = { + "adapterName": "DemoCamera", + "deviceName": "DShutter", + "deviceLabel": "Shutter", + "mode": "shutter", + } + info.wavelength = 488 + info.valueRangeMin = 0 + info.valueRangeMax = 1 + info.valueRangeStep = 1 + info.freqRangeMin = 0 + info.freqRangeMax = 0 + info.freqRangeInit = 0 + return info + + +# --------------------------------------------------------------------------- +# MMCoreManager singleton +# --------------------------------------------------------------------------- +class TestMMCoreManager: + def test_get_core_singleton(self): + from imswitch.imcontrol.model.managers import MMCoreManager + + c1 = MMCoreManager.get_core() + c2 = MMCoreManager.get_core() + assert c1 is c2 + assert c1.getVersionInfo() != "" + + def test_is_available(self): + from imswitch.imcontrol.model.managers import MMCoreManager + + assert MMCoreManager.is_available() is True + + def test_get_available_adapters(self): + from imswitch.imcontrol.model.managers import MMCoreManager + + try: + from pymmcore_plus import find_micromanager + except Exception: + find_micromanager = None + path = find_micromanager() if find_micromanager else None + path = path or os.environ.get("MICROMANAGER_PATH") + if not path: + pytest.skip("Cannot locate Micro-Manager adapter directory") + adapters = MMCoreManager.get_available_adapters(path) + assert "DemoCamera" in adapters + + +# --------------------------------------------------------------------------- +# Detector +# --------------------------------------------------------------------------- +class TestMMCoreDetectorManager: + def _make_manager(self): + from imswitch.imcontrol.model.managers.detectors.MMCoreDetectorManager import ( + MMCoreDetectorManager, + ) + + return MMCoreDetectorManager(_make_detector_info(), "TestCamera") + + def test_snap(self): + mgr = self._make_manager() + try: + frame = mgr.getLatestFrame() + assert isinstance(frame, np.ndarray) + assert frame.ndim == 2 + assert frame.shape[0] > 0 and frame.shape[1] > 0 + finally: + mgr.finalize() + + def test_continuous_acquisition(self): + import time + + mgr = self._make_manager() + try: + mgr.startAcquisition() + time.sleep(0.3) + mgr.stopAcquisition() + finally: + mgr.finalize() + + def test_exposure(self): + mgr = self._make_manager() + try: + mgr.setParameter("Exposure", 50.0) + assert abs(mgr._core.getExposure() - 50.0) < 0.1 + finally: + mgr.finalize() + + def test_pixel_size_format(self): + mgr = self._make_manager() + try: + ps = mgr.pixelSizeUm + assert isinstance(ps, list) and len(ps) == 3 + finally: + mgr.finalize() + + +# --------------------------------------------------------------------------- +# Positioner +# --------------------------------------------------------------------------- +class TestMMCorePositionerManager: + def _make_manager(self): + from imswitch.imcontrol.model.managers.positioners.MMCorePositionerManager import ( + MMCorePositionerManager, + ) + + return MMCorePositionerManager(_make_positioner_info(), "TestStage") + + def test_move_xy(self): + mgr = self._make_manager() + try: + x0 = mgr.getPosition("X") + mgr.move(10.0, "X") + assert abs(mgr.getPosition("X") - (x0 + 10.0)) < 1e-3 + finally: + mgr.finalize() + + def test_move_z(self): + mgr = self._make_manager() + try: + z0 = mgr.getPosition("Z") + mgr.move(5.0, "Z") + assert abs(mgr.getPosition("Z") - (z0 + 5.0)) < 1e-3 + finally: + mgr.finalize() + + def test_set_position(self): + mgr = self._make_manager() + try: + mgr.setPosition(123.0, "X") + assert abs(mgr.getPosition("X") - 123.0) < 1e-3 + finally: + mgr.finalize() + + +# --------------------------------------------------------------------------- +# Laser +# --------------------------------------------------------------------------- +class TestMMCoreLaserManager: + def _make_manager(self): + from imswitch.imcontrol.model.managers.lasers.MMCoreLaserManager import ( + MMCoreLaserManager, + ) + + return MMCoreLaserManager(_make_laser_info(), "TestLaser") + + def test_shutter_toggle(self): + mgr = self._make_manager() + try: + mgr.setEnabled(True) + assert mgr._core.getShutterOpen() is True + mgr.setEnabled(False) + assert mgr._core.getShutterOpen() is False + finally: + mgr.finalize() diff --git a/imswitch/imcontrol/controller/controllers/experiment_controller/experiment_normal_mode.py b/imswitch/imcontrol/controller/controllers/experiment_controller/experiment_normal_mode.py index 7700bbe3..d0d04404 100644 --- a/imswitch/imcontrol/controller/controllers/experiment_controller/experiment_normal_mode.py +++ b/imswitch/imcontrol/controller/controllers/experiment_controller/experiment_normal_mode.py @@ -65,6 +65,7 @@ def execute_experiment(self, # If a typed ExecutionContext was provided, flatten it onto kwargs # so the existing body below stays unchanged. Caller-supplied # kwargs win over ctx values to keep the override path open. + # TODO: We should probably use this to always use the experiment object? if ctx is not None: ctx_kwargs = ctx.to_kwargs() snake_tiles = ctx_kwargs.pop("snake_tiles") diff --git a/imswitch/imcontrol/model/managers/MMCoreManager.py b/imswitch/imcontrol/model/managers/MMCoreManager.py new file mode 100644 index 00000000..354e088c --- /dev/null +++ b/imswitch/imcontrol/model/managers/MMCoreManager.py @@ -0,0 +1,198 @@ +""" +Process-wide singleton for the Micro-Manager :class:`CMMCorePlus` core. + +This is **not** an ImSwitch device manager. It is an internal helper module +shared by the MMCore* device managers (camera, stage, laser) so that a single +Micro-Manager system configuration drives all of them without USB/serial +conflicts. + +``pymmcore-plus`` is an optional dependency. Importing this module never +instantiates the core or touches hardware. The first call to +:func:`get_core` lazily creates the singleton, and :func:`ensure_loaded` +applies a configuration only once per ``cfg_path``. +""" + +from __future__ import annotations + +import os +import sys +import threading +from typing import List, Optional + +from imswitch.imcommon.model import initLogger + +try: # pymmcore-plus is optional + from pymmcore_plus import CMMCorePlus # type: ignore +except ImportError: # pragma: no cover - exercised on minimal installs + CMMCorePlus = None # type: ignore[assignment] + + +__all__ = [ + "get_core", + "ensure_loaded", + "reload", + "get_available_adapters", + "get_available_devices_for_adapter", + "is_available", +] + +if sys.platform.startswith("win"): + _DEFAULT_ADAPTER_PATH = os.environ.get( + "MICROMANAGER_PATH", r"C:\Program Files\Micro-Manager-2.0" + ) +elif sys.platform.startswith("darwin"): + _DEFAULT_ADAPTER_PATH = os.environ.get( + "MICROMANAGER_PATH", "/Applications/Micro-Manager.app/Contents/DeviceAdapters" + ) +else: + _DEFAULT_ADAPTER_PATH = os.environ.get( + "MICROMANAGER_PATH", "/opt/micro-manager/lib/micro-manager" + ) + +_lock = threading.Lock() +_loaded_cfg: Optional[str] = None +_core = None # type: ignore[assignment] +_logger = initLogger("MMCoreManager", tryInheritParent=False) + + +def _require_pymmcore() -> None: + if CMMCorePlus is None: + raise ImportError( + "pymmcore-plus is not installed. Install it with " + "'pip install pymmcore-plus' (or 'pip install ImSwitchUC2[pymmcore]') " + "to use MMCore* device managers." + ) + + +def is_available() -> bool: + """Return True when ``pymmcore-plus`` is importable in this environment.""" + return CMMCorePlus is not None + + +def get_core(): + """Return the process-wide :class:`CMMCorePlus` singleton. + + The instance is created lazily on first call so that simply importing + this module never touches hardware. + """ + global _core + if _core is None: + _require_pymmcore() + with _lock: + if _core is None: + _core = CMMCorePlus.instance() + return _core + + +def _resolve_adapter_paths(adapter_paths: Optional[List[str]]) -> List[str]: + if adapter_paths: + return [p for p in adapter_paths if p] + return [_DEFAULT_ADAPTER_PATH] + + +def ensure_loaded(cfg_path: str, adapter_paths: Optional[List[str]] = None): + """Load a Micro-Manager system configuration exactly once. + + Subsequent calls with the same ``cfg_path`` are no-ops and return the + already-loaded core. A call with a different ``cfg_path`` causes the + previously loaded devices to be unloaded before the new config is applied. + + Args: + cfg_path: Absolute path to a Micro-Manager ``.cfg`` file. + adapter_paths: Optional list of directories containing the compiled + device adapter libraries. If ``None``, the ``MICROMANAGER_PATH`` + environment variable (or its default) is used. + + Returns: + The shared :class:`CMMCorePlus` instance with devices loaded. + """ + global _loaded_cfg + _require_pymmcore() + if not cfg_path: + raise ValueError("ensure_loaded requires a non-empty cfg_path") + + with _lock: + core = get_core() + if _loaded_cfg == cfg_path: + return core + + core.setDeviceAdapterSearchPaths(_resolve_adapter_paths(adapter_paths)) + + if _loaded_cfg is not None: + try: + core.unloadAllDevices() + except Exception: # pragma: no cover - hardware-dependent + _logger.warning("Failed to cleanly unload previous devices", exc_info=True) + + core.loadSystemConfiguration(cfg_path) + _loaded_cfg = cfg_path + + try: + devices = list(core.getLoadedDevices()) + _logger.info( + f"MMCore loaded {len(devices)} devices from '{cfg_path}': " + f"{', '.join(devices)}" + ) + except Exception: # pragma: no cover + _logger.info(f"MMCore loaded configuration '{cfg_path}'") + + return core + + +def reload(cfg_path: str, adapter_paths: Optional[List[str]] = None): + """Force a reload of the given configuration even if already loaded.""" + global _loaded_cfg + with _lock: + _loaded_cfg = None + return ensure_loaded(cfg_path, adapter_paths) + + +def ensure_core(adapter_paths: Optional[List[str]] = None): + """Return the singleton core with adapter paths configured. + + This is used by managers operating in *manual* mode (loading individual + devices via ``loadDevice``) where no full ``.cfg`` file is supplied. + """ + _require_pymmcore() + core = get_core() + with _lock: + core.setDeviceAdapterSearchPaths(_resolve_adapter_paths(adapter_paths)) + return core + + +def get_available_adapters(adapter_path: Optional[str] = None) -> List[str]: + """List the human-readable adapter names available on disk. + + Scans ``adapter_path`` (or the default Micro-Manager install location) + for ``libmmgr_dal_*.so`` / ``mmgr_dal_*.dll`` / ``libmmgr_dal_*.dylib`` + files and returns the bare adapter names. + """ + path = adapter_path or _DEFAULT_ADAPTER_PATH + if not os.path.isdir(path): + return [] + + adapters = set() + for entry in os.listdir(path): + name = entry + for prefix in ("libmmgr_dal_", "mmgr_dal_"): + if name.startswith(prefix): + name = name[len(prefix):] + break + else: + continue + for suffix in (".so", ".dylib", ".dll"): + if name.endswith(suffix): + name = name[: -len(suffix)] + adapters.add(name) + break + return sorted(adapters) + + +def get_available_devices_for_adapter(adapter_name: str) -> List[str]: + """Return the list of device names exposed by a given adapter.""" + core = get_core() + try: + return list(core.getAvailableDevices(adapter_name)) + except Exception as exc: + _logger.warning(f"Could not enumerate devices for adapter '{adapter_name}': {exc}") + return [] diff --git a/imswitch/imcontrol/model/managers/__init__.py b/imswitch/imcontrol/model/managers/__init__.py index 575229be..4ef28b87 100644 --- a/imswitch/imcontrol/model/managers/__init__.py +++ b/imswitch/imcontrol/model/managers/__init__.py @@ -37,3 +37,4 @@ from .ArkitektManager import ArkitektManager from .SiLA2Manager import SiLA2Manager from .InstrumentMetadataManager import InstrumentMetadataManager +from . import MMCoreManager # shared pymmcore-plus singleton (optional dependency) diff --git a/imswitch/imcontrol/model/managers/detectors/MMCoreDetectorManager.py b/imswitch/imcontrol/model/managers/detectors/MMCoreDetectorManager.py new file mode 100644 index 00000000..b804bbfd --- /dev/null +++ b/imswitch/imcontrol/model/managers/detectors/MMCoreDetectorManager.py @@ -0,0 +1,299 @@ +""" +ImSwitch :class:`DetectorManager` that wraps a Micro-Manager camera device +through ``pymmcore-plus``. + +Two configuration modes are supported via the ``managerProperties`` block of +the setup JSON: + +* **Config mode** – set ``cfgPath`` to a Micro-Manager ``.cfg`` file. The + file is loaded once and reused by every MMCore* manager pointing at it. +* **Manual mode** – set ``adapterName`` and ``deviceName`` (e.g. + ``"DemoCamera"`` / ``"DCam"``). The device is loaded with + ``loadDevice`` + ``initializeDevice`` directly. Useful for quick demos and + for setups where a single device is needed without authoring a ``.cfg``. + +Recognised ``managerProperties`` keys: + +============ ======== ========================================================= +Key Required Description +============ ======== ========================================================= +cfgPath no* Path to a ``.cfg`` file (mutually exclusive with the + manual-mode keys). +adapterPath no Override the adapter search directory. +adapterName no* Adapter to load in manual mode, e.g. ``"DemoCamera"``. +deviceName no* Device name inside the adapter, e.g. ``"DCam"``. +deviceLabel no Label assigned to the loaded device (default + ``"Camera"``). +============ ======== ========================================================= + +\\* Either ``cfgPath`` or both ``adapterName`` + ``deviceName`` must be +provided. +""" + +from __future__ import annotations + +from typing import Dict, List + +import numpy as np + +from imswitch.imcommon.model import initLogger +from imswitch.imcontrol.model.managers import MMCoreManager +from .DetectorManager import ( + DetectorManager, + DetectorListParameter, + DetectorNumberParameter, + DetectorParameter, +) + + +# Property names we never expose through the parameter UI – internal MMCore +# bookkeeping or things that would confuse the generic editor. +_SKIP_PROPERTY_PREFIXES = ("On",) +_SKIP_PROPERTY_SUBSTRINGS = ("TransposeCorrection",) + + +def _is_internal_property(prop: str) -> bool: + if any(prop.startswith(p) for p in _SKIP_PROPERTY_PREFIXES): + return True + if any(s in prop for s in _SKIP_PROPERTY_SUBSTRINGS): + return True + return False + + +class MMCoreDetectorManager(DetectorManager): + """Detector manager backed by a Micro-Manager camera device.""" + + def __init__(self, detectorInfo, name, **lowLevelManagers): + self._logger = initLogger(self, instanceName=name) + self._props: Dict = dict(detectorInfo.managerProperties or {}) + + cfg_path = self._props.get("cfgPath") + adapter_name = self._props.get("adapterName") + device_name = self._props.get("deviceName") + adapter_path = self._props.get("adapterPath") + adapter_paths = [adapter_path] if adapter_path else None + + self._label: str = self._props.get("deviceLabel", "Camera") + + if cfg_path: + self._core = MMCoreManager.ensure_loaded(cfg_path, adapter_paths) + cam = self._core.getCameraDevice() + if cam: + self._label = cam + try: + self._core.setCameraDevice(self._label) + except Exception: + self._logger.warning( + f"Could not set camera device to '{self._label}'", exc_info=True, + ) + elif adapter_name and device_name: + self._core = MMCoreManager.ensure_core(adapter_paths) + if self._label not in self._core.getLoadedDevices(): + self._core.loadDevice(self._label, adapter_name, device_name) + self._core.initializeDevice(self._label) + self._core.setCameraDevice(self._label) + else: + raise ValueError( + f"MMCoreDetectorManager '{name}' requires either 'cfgPath' or " + "both 'adapterName' and 'deviceName' in managerProperties." + ) + + # Sensor info + try: + full_shape = (int(self._core.getImageWidth()), int(self._core.getImageHeight())) + except Exception: + # Snap once to make sure the camera reports its geometry. + self._core.snap() + full_shape = (int(self._core.getImageWidth()), int(self._core.getImageHeight())) + + # Binning options + supported_binnings: List[int] = self._read_supported_binnings() + + # Build parameter dict + parameters = self._build_parameters() + + try: + current_exposure = float(self._core.getExposure()) + except Exception: + current_exposure = 10.0 + parameters["Exposure"] = DetectorNumberParameter( + group="Acquisition", + value=current_exposure, + editable=True, + valueUnits="ms", + ) + + super().__init__( + detectorInfo, + name, + fullShape=full_shape, + supportedBinnings=supported_binnings, + model=self._label or "MMCore Camera", + parameters=parameters, + croppable=True, + ) + + # ------------------------------------------------------------------ + # Parameter discovery helpers + # ------------------------------------------------------------------ + def _read_supported_binnings(self) -> List[int]: + try: + allowed = list(self._core.getAllowedPropertyValues(self._label, "Binning")) + except Exception: + return [1] + + binnings: List[int] = [] + for entry in allowed: + try: + # Binning is sometimes "1" and sometimes "1x1". + txt = str(entry).lower().split("x")[0] + binnings.append(int(txt)) + except (TypeError, ValueError): + continue + return sorted(set(binnings)) or [1] + + def _build_parameters(self) -> Dict[str, DetectorParameter]: + parameters: Dict[str, DetectorParameter] = {} + try: + prop_names = list(self._core.getDevicePropertyNames(self._label)) + except Exception: + return parameters + + for prop in prop_names: + if _is_internal_property(prop): + continue + try: + value = self._core.getProperty(self._label, prop) + except Exception: + continue + + try: + read_only = bool(self._core.isPropertyReadOnly(self._label, prop)) + except Exception: + read_only = False + + allowed: List[str] = [] + try: + allowed = list(self._core.getAllowedPropertyValues(self._label, prop)) + except Exception: + allowed = [] + + if allowed: + parameters[prop] = DetectorListParameter( + group="MMCore", + value=str(value), + editable=not read_only, + options=[str(a) for a in allowed], + ) + continue + + try: + num_value = float(value) + except (TypeError, ValueError): + # Skip free-form strings – they don't map onto our UI widgets. + continue + parameters[prop] = DetectorNumberParameter( + group="MMCore", + value=num_value, + editable=not read_only, + valueUnits="", + ) + return parameters + + # ------------------------------------------------------------------ + # Frame access + # ------------------------------------------------------------------ + def getLatestFrame(self) -> np.ndarray: + try: + if self._core.getRemainingImageCount() > 0: + return self._core.getLastImage() + except Exception: + pass + try: + self._core.snap() + return self._core.getImage() + except Exception: + self._logger.error("Failed to snap a frame from MMCore", exc_info=True) + return np.zeros(self._shape, dtype=np.uint16) + + def getChunk(self) -> np.ndarray: + frames = [] + try: + while self._core.getRemainingImageCount() > 0: + frames.append(self._core.popNextImage()) + except Exception: + self._logger.error("Failed to drain MMCore buffer", exc_info=True) + + if not frames: + return np.empty((0, self._shape[1], self._shape[0])) + return np.stack(frames, axis=0) + + def flushBuffers(self) -> None: + try: + self._core.clearCircularBuffer() + except Exception: + self._logger.warning("Could not clear circular buffer", exc_info=True) + + # ------------------------------------------------------------------ + # Acquisition control + # ------------------------------------------------------------------ + def startAcquisition(self) -> None: + if not self._core.isSequenceRunning(): + self._core.startContinuousSequenceAcquisition(0) + + def stopAcquisition(self) -> None: + try: + if self._core.isSequenceRunning(): + self._core.stopSequenceAcquisition() + except Exception: + self._logger.warning("Failed to stop MMCore acquisition", exc_info=True) + + def crop(self, hpos: int, vpos: int, hsize: int, vsize: int) -> None: + try: + self._core.setROI(self._label, int(hpos), int(vpos), int(hsize), int(vsize)) + self._shape = (int(hsize), int(vsize)) + except Exception: + self._logger.error("setROI failed", exc_info=True) + + @property + def pixelSizeUm(self) -> List[float]: + try: + ps = float(self._core.getPixelSizeUm()) + except Exception: + ps = 0.0 + if ps <= 0: + ps = 1.0 + return [1.0, ps, ps] + + # ------------------------------------------------------------------ + # Parameter / binning / lifecycle + # ------------------------------------------------------------------ + def setParameter(self, name, value): + if name == "Exposure": + try: + self._core.setExposure(float(value)) + except Exception: + self._logger.error(f"Failed to set exposure to {value}", exc_info=True) + elif name in self.parameters: + try: + self._core.setProperty(self._label, name, str(value)) + except Exception: + self._logger.error( + f"Failed to set MMCore property {name}={value}", exc_info=True + ) + return super().setParameter(name, value) + + def setBinning(self, binning: int) -> None: + try: + self._core.setProperty(self._label, "Binning", str(binning)) + except Exception: + # Not all cameras expose a Binning property – fall back silently. + pass + super().setBinning(binning) + + def finalize(self) -> None: + self.stopAcquisition() + + +# Copyright (C) 2020-2026 ImSwitch developers +# This file is part of ImSwitch and licensed under GPL-3.0-or-later. diff --git a/imswitch/imcontrol/model/managers/lasers/MMCoreLaserManager.py b/imswitch/imcontrol/model/managers/lasers/MMCoreLaserManager.py new file mode 100644 index 00000000..a7512f16 --- /dev/null +++ b/imswitch/imcontrol/model/managers/lasers/MMCoreLaserManager.py @@ -0,0 +1,121 @@ +""" +ImSwitch :class:`LaserManager` that wraps a Micro-Manager shutter or analog +device through ``pymmcore-plus``. + +Two modes are supported through the ``mode`` key in ``managerProperties``: + +* ``"shutter"`` (default) – the device is treated as a binary shutter that + is opened / closed via :py:meth:`CMMCorePlus.setShutterOpen`. +* ``"property"`` – the device exposes a numeric property (default name + ``"Volts"``) that is written via :py:meth:`CMMCorePlus.setProperty`. This + is the typical mode for DA-controlled lasers. + +Recognised ``managerProperties`` keys: + +============== ======== =============================================== +Key Required Description +============== ======== =============================================== +cfgPath no* Path to a Micro-Manager ``.cfg`` file. +adapterPath no Override the adapter search directory. +adapterName no* Adapter name when loading the device manually. +deviceName no* Device name when loading the device manually. +deviceLabel YES Label of the shutter / DA device. +mode no ``"shutter"`` (default) or ``"property"``. +propertyName no Property name in property mode (default ``"Volts"``). +valueUnits no Units string in property mode (default ``"V"``). +============== ======== =============================================== + +\\* Either ``cfgPath`` or both ``adapterName`` + ``deviceName`` must be set. +""" + +from __future__ import annotations + +from imswitch.imcommon.model import initLogger +from imswitch.imcontrol.model.managers import MMCoreManager +from .LaserManager import LaserManager + + +class MMCoreLaserManager(LaserManager): + """Laser / shutter manager backed by a Micro-Manager device.""" + + def __init__(self, laserInfo, name, **lowLevelManagers): + self._logger = initLogger(self, instanceName=name) + self._props = dict(laserInfo.managerProperties or {}) + + cfg_path = self._props.get("cfgPath") + adapter_path = self._props.get("adapterPath") + adapter_paths = [adapter_path] if adapter_path else None + adapter_name = self._props.get("adapterName") + device_name = self._props.get("deviceName") + + self._label = self._props.get("deviceLabel") + if not self._label: + raise ValueError( + f"MMCoreLaserManager '{name}' requires 'deviceLabel' in managerProperties." + ) + + self._mode = str(self._props.get("mode", "shutter")).lower() + if self._mode not in ("shutter", "property"): + raise ValueError( + f"MMCoreLaserManager '{name}': mode must be 'shutter' or 'property' " + f"(got '{self._mode}')." + ) + self._property_name = self._props.get("propertyName", "Volts") + + if cfg_path: + self._core = MMCoreManager.ensure_loaded(cfg_path, adapter_paths) + elif adapter_name and device_name: + self._core = MMCoreManager.ensure_core(adapter_paths) + if self._label not in self._core.getLoadedDevices(): + self._core.loadDevice(self._label, adapter_name, device_name) + self._core.initializeDevice(self._label) + else: + raise ValueError( + f"MMCoreLaserManager '{name}' requires either 'cfgPath' or " + "both 'adapterName' and 'deviceName'." + ) + + is_binary = self._mode == "shutter" + value_units = "" if is_binary else self._props.get("valueUnits", "V") + + super().__init__( + laserInfo, + name, + isBinary=is_binary, + valueUnits=value_units, + valueDecimals=0 if is_binary else 2, + ) + + def setEnabled(self, enabled: bool) -> None: + try: + if self._mode == "shutter": + self._core.setShutterDevice(self._label) + self._core.setShutterOpen(bool(enabled)) + else: + if not enabled: + self._core.setProperty(self._label, self._property_name, 0) + except Exception: + self._logger.error( + f"Failed to set enabled={enabled} on '{self._label}'", exc_info=True + ) + + def setValue(self, value) -> None: + if self._mode == "property": + try: + self._core.setProperty(self._label, self._property_name, float(value)) + except Exception: + self._logger.error( + f"Failed to set {self._label}.{self._property_name}={value}", + exc_info=True, + ) + # Shutter mode is binary – nothing to write. + + def finalize(self) -> None: + try: + self.setEnabled(False) + except Exception: + pass + + +# Copyright (C) 2020-2026 ImSwitch developers +# This file is part of ImSwitch and licensed under GPL-3.0-or-later. diff --git a/imswitch/imcontrol/model/managers/positioners/MMCorePositionerManager.py b/imswitch/imcontrol/model/managers/positioners/MMCorePositionerManager.py new file mode 100644 index 00000000..b559b125 --- /dev/null +++ b/imswitch/imcontrol/model/managers/positioners/MMCorePositionerManager.py @@ -0,0 +1,171 @@ +""" +ImSwitch :class:`PositionerManager` that drives Micro-Manager XY and Z stages +through ``pymmcore-plus``. + +Recognised ``managerProperties`` keys: + +================== ======== ========================================================== +Key Required Description +================== ======== ========================================================== +cfgPath no* Path to a Micro-Manager ``.cfg`` file. +adapterPath no Override the adapter search directory. +xyAdapterName no* Adapter for the XY stage when loading manually. +xyDeviceName no* Device name for the XY stage when loading manually. +xyDeviceLabel no Label for the XY stage device (default ``"XY"``). +zAdapterName no* Adapter for the focus stage when loading manually. +zDeviceName no* Device name for the focus stage when loading manually. +zDeviceLabel no Label for the focus device (default ``"Z"``). +================== ======== ========================================================== + +\\* Either ``cfgPath`` or the relevant adapter / device pairs must be provided. + +The axes exposed to ImSwitch are taken from ``positionerInfo.axes``. +Movements are issued in micrometers (Micro-Manager's native unit). +""" + +from __future__ import annotations + +from typing import Optional + +from imswitch.imcommon.model import initLogger +from imswitch.imcontrol.model.managers import MMCoreManager +from .PositionerManager import PositionerManager + + +class MMCorePositionerManager(PositionerManager): + """Positioner manager backed by Micro-Manager XY / Z stages.""" + + def __init__(self, positionerInfo, name, **lowLevelManagers): + self._logger = initLogger(self, instanceName=name) + self._props = dict(positionerInfo.managerProperties or {}) + + cfg_path = self._props.get("cfgPath") + adapter_path = self._props.get("adapterPath") + adapter_paths = [adapter_path] if adapter_path else None + + self._xy_label: Optional[str] = self._props.get("xyDeviceLabel", "XY") + self._z_label: Optional[str] = self._props.get("zDeviceLabel", "Z") + + xy_adapter = self._props.get("xyAdapterName") + xy_device = self._props.get("xyDeviceName") + z_adapter = self._props.get("zAdapterName") + z_device = self._props.get("zDeviceName") + + if cfg_path: + self._core = MMCoreManager.ensure_loaded(cfg_path, adapter_paths) + # Use the labels from the .cfg unless explicitly overridden. + if "xyDeviceLabel" not in self._props: + xy = self._core.getXYStageDevice() + self._xy_label = xy if xy else None + if "zDeviceLabel" not in self._props: + z = self._core.getFocusDevice() + self._z_label = z if z else None + elif (xy_adapter and xy_device) or (z_adapter and z_device): + self._core = MMCoreManager.ensure_core(adapter_paths) + loaded = set(self._core.getLoadedDevices()) + if xy_adapter and xy_device: + if self._xy_label not in loaded: + self._core.loadDevice(self._xy_label, xy_adapter, xy_device) + self._core.initializeDevice(self._xy_label) + else: + self._xy_label = None + if z_adapter and z_device: + if self._z_label not in loaded: + self._core.loadDevice(self._z_label, z_adapter, z_device) + self._core.initializeDevice(self._z_label) + else: + self._z_label = None + else: + raise ValueError( + f"MMCorePositionerManager '{name}' requires either 'cfgPath' or " + "'xy'/'z' adapter + device names in managerProperties." + ) + + if self._xy_label: + try: + self._core.setXYStageDevice(self._xy_label) + except Exception: + self._logger.warning( + f"Could not set XY stage to '{self._xy_label}'", exc_info=True + ) + if self._z_label: + try: + self._core.setFocusDevice(self._z_label) + except Exception: + self._logger.warning( + f"Could not set focus device to '{self._z_label}'", exc_info=True + ) + + # ImSwitch always seeds initial positions to zero (positions are read + # back lazily via getPosition). + super().__init__( + positionerInfo, + name, + initialPosition={axis: 0 for axis in positionerInfo.axes}, + initialSpeed={axis: 0 for axis in positionerInfo.axes}, + ) + + # ------------------------------------------------------------------ + # Movement + # ------------------------------------------------------------------ + def move(self, dist, axis): + dist = float(dist) + if axis in ("X", "Y") and self._xy_label: + dx = dist if axis == "X" else 0.0 + dy = dist if axis == "Y" else 0.0 + self._core.setRelativeXYPosition(dx, dy) + self._core.waitForDevice(self._xy_label) + elif axis == "Z" and self._z_label: + self._core.setRelativePosition(dist) + self._core.waitForDevice(self._z_label) + else: + self._logger.warning(f"Ignoring move on unsupported axis '{axis}'") + return self._position[axis] + + new_pos = self.getPosition(axis) + self._position[axis] = new_pos + return new_pos + + def setPosition(self, position, axis): + position = float(position) + if axis == "X" and self._xy_label: + self._core.setXYPosition(position, self._core.getYPosition()) + self._core.waitForDevice(self._xy_label) + elif axis == "Y" and self._xy_label: + self._core.setXYPosition(self._core.getXPosition(), position) + self._core.waitForDevice(self._xy_label) + elif axis == "Z" and self._z_label: + self._core.setPosition(position) + self._core.waitForDevice(self._z_label) + else: + self._logger.warning(f"Ignoring setPosition on unsupported axis '{axis}'") + return self._position[axis] + + self._position[axis] = position + return position + + def getPosition(self, axis): + try: + if axis == "X" and self._xy_label: + return float(self._core.getXPosition()) + if axis == "Y" and self._xy_label: + return float(self._core.getYPosition()) + if axis == "Z" and self._z_label: + return float(self._core.getPosition()) + except Exception: + self._logger.warning( + f"Failed to read MMCore position for axis '{axis}'", exc_info=True + ) + return self._position.get(axis, 0.0) + + def moveForever(self, speed=(0, 0, 0, 0), is_stop=False): + # Micro-Manager generic stage API has no jog primitive. + self._logger.warning("moveForever is not supported by MMCorePositionerManager") + + def finalize(self) -> None: + # Stages do not need explicit cleanup – the shared core unloads them. + pass + + +# Copyright (C) 2020-2026 ImSwitch developers +# This file is part of ImSwitch and licensed under GPL-3.0-or-later. diff --git a/install_micromanager_raspi.sh b/install_micromanager_raspi.sh new file mode 100644 index 00000000..68e2dfe6 --- /dev/null +++ b/install_micromanager_raspi.sh @@ -0,0 +1,557 @@ +#!/usr/bin/env bash +# ============================================================================= +# install_micromanager_rpi.sh +# +# Build and install Micro-Manager (MMCore + device adapters) and pymmcore-plus +# on a Raspberry Pi running Pi OS Bookworm (64-bit / arm64). +# +# This compiles everything from source because: +# 1) There are no prebuilt MM device adapters for arm64. +# 2) pymmcore on PyPI only ships x86_64 + macOS-arm64 wheels, not linux-aarch64. +# 3) The pymmcore wheel and the device adapters MUST share the same device +# interface version, so we build them from the same mmCoreAndDevices tree. +# +# What this script does NOT do: +# - Install Java or build MMStudio (not needed for pymmcore-plus) +# - Install vendor camera SDKs (add those yourself after, see Section 6) +# - Set up Docker (this is the "test outside Docker first" script) +# +# Usage: +# chmod +x install_micromanager_rpi.sh +# ./install_micromanager_rpi.sh 2>&1 | tee build.log +# +# Tested on: Raspberry Pi 5 (8 GB) with Pi OS Bookworm 64-bit +# Expected build time: ~15-25 min on Pi 5, ~30-50 min on Pi 4 +# ============================================================================= + +set -euo pipefail + +# ─── Configuration ─────────────────────────────────────────────────────────── + +# Where Micro-Manager gets installed (libs, adapters, configs) +MM_INSTALL_PREFIX="${MM_INSTALL_PREFIX:-/opt/micro-manager}" + +# Where we clone and build (can be deleted after install) +BUILD_DIR="${BUILD_DIR:-$HOME/mm-build}" + +# Python to use — change if you use a venv or pyenv +PYTHON="${PYTHON:-python3}" + +# How many parallel make jobs (Pi 5 has 4 cores) +NJOBS="${NJOBS:-$(nproc)}" + +# Which adapters to build. "all" = every adapter whose dependencies are met. +# For a minimal first test, set to "DemoCamera SerialManager" to save time. +# Separate with spaces. "all" means don't touch Makefile.am. +ADAPTERS="${ADAPTERS:-all}" + +# Pin a specific mmCoreAndDevices tag/branch for reproducibility. +# "main" = latest. Use a release tag like "v11.2.1" for stability. +MMCORE_REF="${MMCORE_REF:-main}" + +# ─── Colors for output ────────────────────────────────────────────────────── + +RED='\033[0;31m'; GREEN='\033[0;32m'; YELLOW='\033[1;33m'; NC='\033[0m' +info() { echo -e "${GREEN}[INFO]${NC} $*"; } +warn() { echo -e "${YELLOW}[WARN]${NC} $*"; } +fail() { echo -e "${RED}[FAIL]${NC} $*"; exit 1; } + +# ─── 0. Preflight checks ──────────────────────────────────────────────────── + +info "Running preflight checks..." + +[[ "$(uname -m)" == "aarch64" ]] || fail "This script is for arm64 / aarch64. You are on $(uname -m)." + +if ! command -v $PYTHON &>/dev/null; then + fail "$PYTHON not found. Install python3 first: sudo apt install python3 python3-pip python3-venv" +fi + +PY_VERSION=$($PYTHON -c 'import sys; print(f"{sys.version_info.major}.{sys.version_info.minor}")') +PY_MAJOR=$($PYTHON -c 'import sys; print(sys.version_info.major)') +PY_MINOR=$($PYTHON -c 'import sys; print(sys.version_info.minor)') +if (( PY_MAJOR < 3 || PY_MINOR < 10 )); then + fail "Python >= 3.10 required. Found $PY_VERSION." +fi +info "Using $PYTHON ($PY_VERSION)" + +# Check available RAM — building with <2 GB free is risky +FREE_MB=$(awk '/MemAvailable/ {printf "%d", $2/1024}' /proc/meminfo) +if (( FREE_MB < 1500 )); then + warn "Only ${FREE_MB} MB RAM available. Build may fail or use heavy swap." + warn "Consider closing other programs or adding swap (sudo dphys-swapfile swapoff && sudo sed -i 's/CONF_SWAPSIZE=.*/CONF_SWAPSIZE=2048/' /etc/dphys-swapfile && sudo dphys-swapfile setup && sudo dphys-swapfile swapon)." +fi + +# Check disk space — the build tree needs ~2-3 GB +FREE_DISK_MB=$(df --output=avail -BM "$HOME" | tail -1 | tr -d ' M') +if (( FREE_DISK_MB < 3000 )); then + fail "Less than 3 GB disk space free in $HOME. Need at least 3 GB for the build." +fi + +# ─── 1. Install system dependencies ───────────────────────────────────────── + +info "Installing system dependencies (requires sudo)..." + +sudo apt-get update +sudo apt-get install -y \ + build-essential \ + autoconf \ + automake \ + libtool \ + autoconf-archive \ + pkg-config \ + git \ + libboost-all-dev \ + libusb-1.0-0-dev \ + libudev-dev \ + python3-dev \ + python3-pip \ + python3-venv \ + python3-numpy \ + swig + +# We do NOT install Java or ant — we build with --without-java. + +# Check SWIG version — SWIG 4.x is fine for pymmcore (the SWIG 3.x requirement +# is only for MMCoreJ / Java wrapper, which we skip). +SWIG_VERSION=$(swig -version 2>/dev/null | grep -oP 'SWIG Version \K[0-9]+\.[0-9]+' || echo "0.0") +info "SWIG version: $SWIG_VERSION" + +# ─── 2. Clone mmCoreAndDevices ─────────────────────────────────────────────── + +info "Setting up build directory at $BUILD_DIR ..." +mkdir -p "$BUILD_DIR" +cd "$BUILD_DIR" + +if [[ -d mmCoreAndDevices ]]; then + info "mmCoreAndDevices directory already exists, pulling latest..." + cd mmCoreAndDevices + git fetch --all + git checkout "$MMCORE_REF" + git pull --ff-only 2>/dev/null || true # pull fails on detached HEAD, that's fine + cd .. +else + info "Cloning mmCoreAndDevices (ref: $MMCORE_REF)..." + git clone https://github.com/micro-manager/mmCoreAndDevices.git + cd mmCoreAndDevices + git checkout "$MMCORE_REF" + cd .. +fi + +# We also need the top-level micro-manager repo because mmCoreAndDevices' +# autotools build is designed to run as a submodule of micro-manager. +if [[ -d micro-manager ]]; then + info "micro-manager directory already exists, pulling latest..." + cd micro-manager + git fetch --all + git checkout main + git pull --ff-only 2>/dev/null || true + cd .. +else + info "Cloning micro-manager (for the autotools build harness)..." + git clone https://github.com/micro-manager/micro-manager.git +fi + +# Point the submodule at our local clone to avoid a second download +cd micro-manager +git submodule update --init --recursive 2>/dev/null || { + # If submodule init fails, manually wire it + info "Wiring mmCoreAndDevices submodule to local clone..." + rm -rf mmCoreAndDevices + ln -sf "$BUILD_DIR/mmCoreAndDevices" mmCoreAndDevices +} +cd .. + +# ─── 3. Optionally restrict adapters to build ─────────────────────────────── + +cd micro-manager/mmCoreAndDevices + +if [[ "$ADAPTERS" != "all" ]]; then + info "Restricting build to adapters: $ADAPTERS" + + # Back up original Makefile.am + cp DeviceAdapters/Makefile.am DeviceAdapters/Makefile.am.orig + + # Build the SUBDIRS line with only the requested adapters + # Always include "." (the parent directory target) + SUBDIRS_LINE="SUBDIRS = ." + for adapter in $ADAPTERS; do + if [[ -d "DeviceAdapters/$adapter" ]]; then + SUBDIRS_LINE="$SUBDIRS_LINE $adapter" + else + warn "Adapter directory DeviceAdapters/$adapter not found — skipping." + fi + done + + # Replace the SUBDIRS block in Makefile.am + # The original is a multi-line definition; we replace the entire file section + python3 -c " +import re, sys +text = open('DeviceAdapters/Makefile.am').read() +# Match the SUBDIRS definition (possibly multi-line with backslash continuation) +text = re.sub( + r'^SUBDIRS\s*=.*?(?:\\\\\n.*?)*$', + '${SUBDIRS_LINE}', + text, + flags=re.MULTILINE +) +open('DeviceAdapters/Makefile.am', 'w').write(text) +print('Makefile.am patched successfully.') +" +fi + +cd "$BUILD_DIR/micro-manager" + +# ─── 4. Build MMCore + device adapters ─────────────────────────────────────── + +info "Running autogen.sh..." +./autogen.sh + +info "Running configure (--without-java, prefix=$MM_INSTALL_PREFIX)..." +./configure \ + --prefix="$MM_INSTALL_PREFIX" \ + --without-java \ + --disable-java-app \ + --with-python="$($PYTHON -c 'import sys; print(sys.executable)')" \ + PYTHON="$PYTHON" \ + 2>&1 | tee "$BUILD_DIR/configure.log" + +# Check configure actually succeeded +if [[ ! -f Makefile ]]; then + fail "configure failed. Check $BUILD_DIR/configure.log" +fi + +# Print which adapters will be built +info "Adapters that will be built:" +grep -A200 '^Enabled device adapters' "$BUILD_DIR/configure.log" | head -40 || \ + info "(Could not parse adapter list from configure output — check configure.log)" + +info "Building with make -j${NJOBS}... (this will take a while)" +make -j"$NJOBS" 2>&1 | tee "$BUILD_DIR/make.log" + +info "Installing to $MM_INSTALL_PREFIX (requires sudo)..." +sudo make install 2>&1 | tee "$BUILD_DIR/install.log" + +# ─── 5. Install pymmcore + pymmcore-plus into a venv ───────────────────────── + +info "Setting up Python virtual environment..." + +VENV_DIR="$HOME/mm-venv" + +if [[ -d "$VENV_DIR" ]]; then + info "Venv $VENV_DIR already exists, reusing." +else + $PYTHON -m venv "$VENV_DIR" +fi + +source "$VENV_DIR/bin/activate" +pip install --upgrade pip setuptools wheel + +# Build pymmcore from source (no arm64 wheel on PyPI). +# pymmcore's setup.py will find the MMCore source in mmCoreAndDevices if we +# set the env var, or we can pip-install from the repo and let it build. +info "Installing pymmcore from source (this compiles the C++ SWIG wrapper)..." +pip install pymmcore --no-binary pymmcore 2>&1 | tee "$BUILD_DIR/pymmcore-install.log" || { + warn "pip install pymmcore failed. Trying to build from the local tree..." + # Alternative: build from local mmCoreAndDevices using the pymmcore build in-tree + # (This path is a fallback if the PyPI sdist doesn't compile cleanly) + cd "$BUILD_DIR/mmCoreAndDevices" + if [[ -f pymmcore/setup.py ]] || [[ -f setup.py ]]; then + pip install . 2>&1 | tee "$BUILD_DIR/pymmcore-local-install.log" + else + fail "Could not install pymmcore. Check $BUILD_DIR/pymmcore-install.log" + fi +} + +info "Installing pymmcore-plus..." +pip install "pymmcore-plus[cli]" + +# Record the device interface versions for sanity checking +info "Checking device interface version match..." +PYMMCORE_DI=$($PYTHON -c "import pymmcore; c = pymmcore.CMMCore(); print(c.getAPIVersionInfo())" 2>/dev/null || echo "UNKNOWN") +info "pymmcore reports: $PYMMCORE_DI" + +# ─── 6. Create a demo config and smoke-test ────────────────────────────────── + +info "Setting up demo configuration..." + +MM_CONFIG_DIR="$HOME/micro-manager-configs" +mkdir -p "$MM_CONFIG_DIR" + +# Write a minimal demo config that uses the DemoCamera adapter +cat > "$MM_CONFIG_DIR/MMConfig_demo.cfg" << 'DEMOCFG' +# Micro-Manager Demo Configuration +# Minimal config for testing MMCore on Raspberry Pi + +# Camera +Device,Camera,DemoCamera,DCam +Property,Camera,OnCameraCCDXSize,512 +Property,Camera,OnCameraCCDYSize,512 +Property,Camera,PixelType,8bit + +# XY Stage +Device,XY,DemoCamera,DXYStage + +# Z Stage +Device,Z,DemoCamera,DStage + +# Shutter +Device,Shutter,DemoCamera,DShutter + +# Labels +Label,Channel,1,DAPI +Label,Channel,2,FITC +Label,Channel,3,Rhodamine + +# System configuration +ConfigGroup,System,Startup,Camera,Exposure,10.0 + +# Initialization +Property,Core,Initialize,1 +DEMOCFG + +info "Demo config written to: $MM_CONFIG_DIR/MMConfig_demo.cfg" + +# Write the smoke test script +cat > "$MM_CONFIG_DIR/smoke_test.py" << 'SMOKETEST' +#!/usr/bin/env python3 +""" +Smoke test for Micro-Manager on Raspberry Pi. +Run this after install_micromanager_rpi.sh to verify everything works. + +Usage: + source ~/mm-venv/bin/activate + python ~/micro-manager-configs/smoke_test.py +""" + +import sys +import os +import time +import numpy as np + +def main(): + # ── 1. Import pymmcore-plus ────────────────────────────────────────── + try: + from pymmcore_plus import CMMCorePlus + print("[OK] pymmcore-plus imported successfully") + except ImportError: + print("[FAIL] Could not import pymmcore_plus. Is the venv activated?") + print(" Run: source ~/mm-venv/bin/activate") + sys.exit(1) + + # ── 2. Instantiate core ────────────────────────────────────────────── + core = CMMCorePlus.instance() + print(f"[OK] CMMCorePlus instantiated") + print(f" MMCore version: {core.getVersionInfo()}") + print(f" API version: {core.getAPIVersionInfo()}") + + # ── 3. Set adapter search paths ────────────────────────────────────── + mm_path = os.environ.get("MICROMANAGER_PATH", "/opt/micro-manager/lib/micro-manager") + + if not os.path.isdir(mm_path): + # Try alternative locations + alternatives = [ + "/opt/micro-manager/lib/micro-manager", + "/usr/local/lib/micro-manager", + "/usr/lib/micro-manager", + ] + for alt in alternatives: + if os.path.isdir(alt): + mm_path = alt + break + else: + print(f"[FAIL] Device adapter directory not found.") + print(f" Tried: {', '.join(alternatives)}") + print(f" Set MICROMANAGER_PATH env var to the correct path.") + sys.exit(1) + + core.setDeviceAdapterSearchPaths([mm_path]) + print(f"[OK] Adapter search path: {mm_path}") + + # List available adapters + adapter_files = [f for f in os.listdir(mm_path) if f.startswith("libmmgr_dal_")] + print(f" Found {len(adapter_files)} adapter libraries:") + for f in sorted(adapter_files)[:15]: + print(f" {f}") + if len(adapter_files) > 15: + print(f" ... and {len(adapter_files) - 15} more") + + # ── 4. Load demo config ────────────────────────────────────────────── + cfg_path = os.path.join(os.path.expanduser("~"), "micro-manager-configs", "MMConfig_demo.cfg") + + if not os.path.isfile(cfg_path): + print(f"[WARN] Demo config not found at {cfg_path}") + print(f" Trying to load DemoCamera manually...") + try: + core.loadDevice("Camera", "DemoCamera", "DCam") + core.loadDevice("XY", "DemoCamera", "DXYStage") + core.loadDevice("Z", "DemoCamera", "DStage") + core.loadDevice("Shutter", "DemoCamera", "DShutter") + core.initializeAllDevices() + core.setCameraDevice("Camera") + core.setXYStageDevice("XY") + core.setFocusDevice("Z") + core.setShutterDevice("Shutter") + except Exception as e: + print(f"[FAIL] Could not load demo devices: {e}") + sys.exit(1) + else: + try: + core.loadSystemConfiguration(cfg_path) + print(f"[OK] Loaded config: {cfg_path}") + except Exception as e: + print(f"[WARN] Config load failed ({e}), trying manual device loading...") + core.unloadAllDevices() + core.loadDevice("Camera", "DemoCamera", "DCam") + core.loadDevice("XY", "DemoCamera", "DXYStage") + core.loadDevice("Z", "DemoCamera", "DStage") + core.loadDevice("Shutter", "DemoCamera", "DShutter") + core.initializeAllDevices() + core.setCameraDevice("Camera") + core.setXYStageDevice("XY") + core.setFocusDevice("Z") + core.setShutterDevice("Shutter") + + # ── 5. Print loaded devices ────────────────────────────────────────── + devices = core.getLoadedDevices() + print(f"[OK] Loaded {len(devices)} devices: {', '.join(devices)}") + print(f" Camera: {core.getCameraDevice()}") + print(f" XY Stage: {core.getXYStageDevice()}") + print(f" Z Stage: {core.getFocusDevice()}") + print(f" Shutter: {core.getShutterDevice()}") + + # ── 6. Snap a single image ─────────────────────────────────────────── + try: + img = core.snap() + print(f"[OK] Snapped image: shape={img.shape}, dtype={img.dtype}, " + f"min={img.min()}, max={img.max()}, mean={img.mean():.1f}") + except Exception as e: + print(f"[FAIL] snap() failed: {e}") + sys.exit(1) + + # ── 7. Snap 10 frames and measure throughput ───────────────────────── + N = 10 + t0 = time.perf_counter() + for _ in range(N): + core.snap() + elapsed = time.perf_counter() - t0 + fps = N / elapsed + print(f"[OK] {N} frames in {elapsed:.2f}s = {fps:.1f} FPS (demo camera)") + + # ── 8. Test XY stage ───────────────────────────────────────────────── + try: + x0, y0 = core.getXPosition(), core.getYPosition() + core.setXYPosition(x0 + 100.0, y0 + 50.0) + core.waitForDevice(core.getXYStageDevice()) + x1, y1 = core.getXPosition(), core.getYPosition() + dx, dy = abs(x1 - (x0 + 100.0)), abs(y1 - (y0 + 50.0)) + if dx < 1.0 and dy < 1.0: + print(f"[OK] XY move: ({x0:.1f},{y0:.1f}) -> ({x1:.1f},{y1:.1f}) " + f"error=({dx:.3f},{dy:.3f}) µm") + else: + print(f"[WARN] XY move error larger than expected: dx={dx:.3f}, dy={dy:.3f}") + # Return to origin + core.setXYPosition(x0, y0) + core.waitForDevice(core.getXYStageDevice()) + except Exception as e: + print(f"[WARN] XY stage test failed: {e}") + + # ── 9. Test Z stage ────────────────────────────────────────────────── + try: + z0 = core.getPosition() + core.setPosition(z0 + 10.0) + core.waitForDevice(core.getFocusDevice()) + z1 = core.getPosition() + dz = abs(z1 - (z0 + 10.0)) + print(f"[OK] Z move: {z0:.1f} -> {z1:.1f} µm, error={dz:.3f}") + core.setPosition(z0) + core.waitForDevice(core.getFocusDevice()) + except Exception as e: + print(f"[WARN] Z stage test failed: {e}") + + # ── 10. Test shutter ───────────────────────────────────────────────── + try: + core.setShutterOpen(True) + assert core.getShutterOpen() == True, "Shutter should be open" + core.setShutterOpen(False) + assert core.getShutterOpen() == False, "Shutter should be closed" + print(f"[OK] Shutter open/close works") + except Exception as e: + print(f"[WARN] Shutter test failed: {e}") + + # ── 11. Test continuous acquisition ────────────────────────────────── + try: + core.startContinuousSequenceAcquisition(0) + time.sleep(0.5) + remaining = core.getRemainingImageCount() + core.stopSequenceAcquisition() + print(f"[OK] Continuous acquisition: {remaining} frames buffered in 0.5s") + except Exception as e: + print(f"[WARN] Continuous acquisition test failed: {e}") + + # ── Summary ────────────────────────────────────────────────────────── + print() + print("=" * 60) + print(" SMOKE TEST PASSED — Micro-Manager is working on this Pi") + print("=" * 60) + print() + print("Next steps:") + print(" 1. To use a real camera, write a .cfg file for it and set") + print(" MICROMANAGER_PATH to the adapter directory.") + print(" 2. To integrate with ImSwitch, follow the Stage 1 instructions") + print(" in pymmcore-integration-plan.md.") + print() + print("Useful environment variables to put in ~/.bashrc:") + print(f' export MICROMANAGER_PATH="{mm_path}"') + print(f' export PATH="$HOME/mm-venv/bin:$PATH"') + + return 0 + +if __name__ == "__main__": + sys.exit(main()) +SMOKETEST + +chmod +x "$MM_CONFIG_DIR/smoke_test.py" +info "Smoke test written to: $MM_CONFIG_DIR/smoke_test.py" + +# ─── 7. Write environment setup to .bashrc snippet ────────────────────────── + +ENV_FILE="$HOME/.mm_env" +cat > "$ENV_FILE" << EOF +# Micro-Manager environment — source this or add to .bashrc +export MICROMANAGER_PATH="$MM_INSTALL_PREFIX/lib/micro-manager" +export PATH="$VENV_DIR/bin:\$PATH" +EOF + +info "Environment file written to $ENV_FILE" +info "Add to your shell: echo 'source $ENV_FILE' >> ~/.bashrc" + +# ─── 8. Run the smoke test ────────────────────────────────────────────────── + +info "Running smoke test..." +echo "" +export MICROMANAGER_PATH="$MM_INSTALL_PREFIX/lib/micro-manager" +$PYTHON "$MM_CONFIG_DIR/smoke_test.py" +RESULT=$? + +echo "" +if [[ $RESULT -eq 0 ]]; then + info "Installation complete!" + echo "" + echo " Install prefix: $MM_INSTALL_PREFIX" + echo " Adapter libs: $MM_INSTALL_PREFIX/lib/micro-manager/" + echo " Python venv: $VENV_DIR" + echo " Demo config: $MM_CONFIG_DIR/MMConfig_demo.cfg" + echo " Smoke test: $MM_CONFIG_DIR/smoke_test.py" + echo " Build artifacts: $BUILD_DIR (safe to delete)" + echo "" + echo " To activate the venv in a new shell:" + echo " source $ENV_FILE" + echo "" + echo " To re-run the smoke test:" + echo " source $ENV_FILE && python $MM_CONFIG_DIR/smoke_test.py" + echo "" +else + warn "Smoke test failed. Check output above. Build artifacts are in $BUILD_DIR." +fi + +exit $RESULT \ No newline at end of file diff --git a/micromanager-userguide.md b/micromanager-userguide.md new file mode 100644 index 00000000..90d8e44f --- /dev/null +++ b/micromanager-userguide.md @@ -0,0 +1,860 @@ +# Micro-Manager + pymmcore-plus on Raspberry Pi: Usage Guide + +## Table of Contents + +1. [Using pymmcore-plus from Python (standalone, no ImSwitch)](#1-using-pymmcore-plus-from-python) +2. [Plugging into ImSwitch as a camera/stage/laser](#2-plugging-into-imswitch) +3. [Prebuilt binaries via GitHub Actions CI](#3-prebuilt-binaries) +4. [There is no "start Micro-Manager" step](#4-no-server-needed) + +--- + +## 1. Using pymmcore-plus from Python + +There is **no daemon or server to start**. Unlike Pycro-Manager (which talks +to a running Java process), pymmcore-plus loads the C++ MMCore library +directly into your Python process. You just `import` it and go. + +### 1.1 Basic camera usage + +```python +#!/usr/bin/env python3 +"""Snap images from a Micro-Manager camera via pymmcore-plus.""" + +import os +import numpy as np +from pymmcore_plus import CMMCorePlus + +# ── Instantiate the core (singleton — safe to call multiple times) ── +core = CMMCorePlus.instance() + +# ── Tell it where the compiled .so adapter files live ── +core.setDeviceAdapterSearchPaths([ + os.environ.get("MICROMANAGER_PATH", "/opt/micro-manager/lib/micro-manager") +]) + +# ── Option A: load a full .cfg file ── +core.loadSystemConfiguration("/home/pi/micro-manager-configs/MMConfig_demo.cfg") + +# ── Option B: load devices manually (more explicit) ── +# core.loadDevice("Camera", "DemoCamera", "DCam") +# core.initializeAllDevices() +# core.setCameraDevice("Camera") + +# ── Snap a single frame ── +img = core.snap() # returns numpy ndarray, shape (H, W) or (H, W, C) +print(f"Image shape: {img.shape}, dtype: {img.dtype}") + +# ── Change exposure ── +core.setExposure(50.0) # milliseconds +print(f"Exposure: {core.getExposure()} ms") + +# ── Snap with new exposure ── +img2 = core.snap() +print(f"Mean intensity: {img2.mean():.1f}") +``` + +### 1.2 Continuous (live) acquisition + +```python +import time +from pymmcore_plus import CMMCorePlus + +core = CMMCorePlus.instance() +# ... (load config as above) ... + +# Start free-running acquisition (0 = no interval, as fast as possible) +core.startContinuousSequenceAcquisition(0) + +frames = [] +t0 = time.perf_counter() +while len(frames) < 100: + if core.getRemainingImageCount() > 0: + frames.append(core.getLastImage()) + +elapsed = time.perf_counter() - t0 +core.stopSequenceAcquisition() + +print(f"Captured {len(frames)} frames in {elapsed:.2f}s = {len(frames)/elapsed:.1f} FPS") +``` + +### 1.3 Stage control + +```python +core = CMMCorePlus.instance() +# ... (load config) ... + +# ── XY Stage ── +x, y = core.getXPosition(), core.getYPosition() +print(f"Current XY: ({x:.1f}, {y:.1f}) µm") + +core.setXYPosition(x + 100.0, y + 50.0) +core.waitForDevice(core.getXYStageDevice()) # block until move completes + +print(f"New XY: ({core.getXPosition():.1f}, {core.getYPosition():.1f}) µm") + +# ── Z / Focus ── +z = core.getPosition() # uses the default focus device +core.setPosition(z + 10.0) +core.waitForDevice(core.getFocusDevice()) +print(f"Z: {z:.1f} -> {core.getPosition():.1f} µm") + +# ── Relative move (convenience) ── +core.setRelativeXYPosition(50.0, 0.0) +core.waitForDevice(core.getXYStageDevice()) +``` + +### 1.4 Laser / shutter control + +```python +core = CMMCorePlus.instance() +# ... (load config) ... + +# ── Shutter (binary on/off) ── +core.setShutterOpen(True) +print(f"Shutter open: {core.getShutterOpen()}") +core.setShutterOpen(False) + +# ── Analog property (e.g. laser power via a DA device) ── +# This depends on your .cfg — example for a device called "LaserDA": +# core.setProperty("LaserDA", "Volts", 2.5) + +# ── Enumerate all properties of a device ── +for prop in core.getDevicePropertyNames("Camera"): + val = core.getProperty("Camera", prop) + print(f" {prop} = {val}") +``` + +### 1.5 Listing available device adapters + +```python +import os +mm_path = "/opt/micro-manager/lib/micro-manager" +adapters = [f.replace("libmmgr_dal_", "").replace(".so", "") + for f in os.listdir(mm_path) + if f.startswith("libmmgr_dal_") and f.endswith(".so")] +print(f"Available adapters ({len(adapters)}):") +for a in sorted(adapters): + print(f" {a}") +``` + +--- + +## 2. Plugging into ImSwitch + +ImSwitch discovers device managers by the `"managerName"` string in the setup +JSON — it maps directly to a Python class. You create new manager classes that +inherit from ImSwitch's base classes and wrap pymmcore-plus calls. + +### 2.1 Architecture overview + +``` +ImSwitch setup JSON + │ + ├── "MMCoreDetectorManager" ──► MMCoreDetectorManager.py + ├── "MMCorePositionerManager" ──► MMCorePositionerManager.py + └── "MMCoreLaserManager" ──► MMCoreLaserManager.py + │ + ▼ + MMCoreManager.py (shared singleton) + │ + ▼ + CMMCorePlus.instance() + │ + ▼ + libmmgr_dal_*.so (device adapters) +``` + +The pattern is the same as the existing ESP32 managers: the ESP32 managers go +through an `RS232Manager` (serial), while the MM managers go through a shared +`CMMCorePlus` singleton. You do NOT need an RS232/serial layer — pymmcore-plus +talks directly to USB/serial devices via the compiled C++ adapters. + +### 2.2 The shared core singleton + +Place this in `imswitch/imcontrol/model/managers/MMCoreManager.py`: + +```python +""" +Process-wide singleton for the Micro-Manager CMMCorePlus core. + +All MMCore*Manager classes share this instance so that a single .cfg +file drives camera + stage + laser without conflicts. +""" + +import os +import threading +import logging + +logger = logging.getLogger(__name__) + +_lock = threading.Lock() +_loaded_cfg = None +_core = None + + +def get_core(): + """Return the CMMCorePlus singleton, creating it on first call.""" + global _core + if _core is None: + from pymmcore_plus import CMMCorePlus + _core = CMMCorePlus.instance() + return _core + + +def ensure_loaded(cfg_path: str, adapter_paths: list = None): + """ + Load a .cfg file exactly once. Subsequent calls with the same path + are no-ops. Call with a different path to reload. + """ + global _loaded_cfg + with _lock: + if _loaded_cfg == cfg_path: + return get_core() + + core = get_core() + + # Set adapter search paths + if adapter_paths: + core.setDeviceAdapterSearchPaths(adapter_paths) + else: + default = os.environ.get( + "MICROMANAGER_PATH", "/opt/micro-manager/lib/micro-manager" + ) + core.setDeviceAdapterSearchPaths([default]) + + # Unload any previously loaded config + if _loaded_cfg is not None: + core.unloadAllDevices() + + core.loadSystemConfiguration(cfg_path) + _loaded_cfg = cfg_path + + devices = core.getLoadedDevices() + logger.info(f"MMCore loaded {len(devices)} devices from {cfg_path}: " + f"{', '.join(devices)}") + return core + + +def reload(cfg_path: str, adapter_paths: list = None): + """Force-reload a config (e.g. when user switches setup in ImSwitch).""" + global _loaded_cfg + with _lock: + _loaded_cfg = None # force re-load + return ensure_loaded(cfg_path, adapter_paths) +``` + +### 2.3 The Detector Manager (camera) + +Place in `imswitch/imcontrol/model/managers/detectors/MMCoreDetectorManager.py`: + +```python +""" +ImSwitch DetectorManager that wraps a Micro-Manager camera device +via pymmcore-plus. + +Setup JSON example: +{ + "detectors": { + "MMCamera": { + "managerName": "MMCoreDetectorManager", + "managerProperties": { + "cfgPath": "/home/pi/mm_configs/my_scope.cfg", + "deviceLabel": "Camera", + "adapterPath": "/opt/micro-manager/lib/micro-manager" + }, + "forAcquisition": true + } + } +} +""" + +import numpy as np +from imswitch.imcontrol.model.managers.detectors.DetectorManager import ( + DetectorManager, DetectorNumberParameter +) +from imswitch.imcontrol.model.managers import MMCoreManager + + +class MMCoreDetectorManager(DetectorManager): + + def __init__(self, detectorInfo, name, **lowLevelManagers): + self._props = detectorInfo.managerProperties + + # Initialize the shared core + self._core = MMCoreManager.ensure_loaded( + cfg_path=self._props["cfgPath"], + adapter_paths=[self._props.get("adapterPath")] if self._props.get("adapterPath") else None + ) + + # Wire up the camera device + self._label = self._props.get("deviceLabel", self._core.getCameraDevice()) + if self._label: + self._core.setCameraDevice(self._label) + + # Read sensor dimensions + w = self._core.getImageWidth() + h = self._core.getImageHeight() + fullShape = (w, h) + + # Build parameter dict — expose exposure time as a tunable parameter + parameters = { + "Exposure": DetectorNumberParameter( + group="Acquisition", + value=self._core.getExposure(), + valueUnits="ms", + editable=True + ), + } + + model = self._label or "MMCore Camera" + + super().__init__( + detectorInfo, name, + fullShape=fullShape, + supportedBinnings=[1, 2, 4], + model=model, + parameters=parameters, + croppable=True, + **lowLevelManagers + ) + + def getLatestFrame(self): + """Return the most recent frame as a numpy array.""" + return self._core.getLastImage() + + def setParameter(self, name, value): + if name == "Exposure": + self._core.setExposure(float(value)) + super().setParameter(name, value) + return self.parameters + + def startAcquisition(self): + self._core.startContinuousSequenceAcquisition(0) + + def stopAcquisition(self): + if self._core.isSequenceRunning(): + self._core.stopSequenceAcquisition() + + def crop(self, hpos, vpos, hsize, vsize): + """Set ROI on the camera.""" + self._core.setROI(self._label, hpos, vpos, hsize, vsize) + return True + + @property + def pixelSizeUm(self): + ps = self._core.getPixelSizeUm() + return [1, ps, ps] if ps > 0 else [1, 1, 1] + + def finalize(self): + self.stopAcquisition() +``` + +### 2.4 The Positioner Manager (stage) + +Place in `imswitch/imcontrol/model/managers/positioners/MMCorePositionerManager.py`: + +```python +""" +ImSwitch PositionerManager wrapping Micro-Manager XY and Z stages. + +Setup JSON example: +{ + "positioners": { + "MMStage": { + "managerName": "MMCorePositionerManager", + "managerProperties": { + "cfgPath": "/home/pi/mm_configs/my_scope.cfg", + "xyDeviceLabel": "XYStage", + "zDeviceLabel": "ZStage" + }, + "axes": ["X", "Y", "Z"], + "forScanning": true, + "forPositioning": true + } + } +} +""" + +from imswitch.imcontrol.model.managers.positioners.PositionerManager import PositionerManager +from imswitch.imcontrol.model.managers import MMCoreManager + + +class MMCorePositionerManager(PositionerManager): + + def __init__(self, positionerInfo, name, **lowLevelManagers): + self._props = positionerInfo.managerProperties + + self._core = MMCoreManager.ensure_loaded( + cfg_path=self._props["cfgPath"], + adapter_paths=[self._props.get("adapterPath")] if self._props.get("adapterPath") else None + ) + + self._xy_label = self._props.get("xyDeviceLabel") + self._z_label = self._props.get("zDeviceLabel") + + if self._xy_label: + self._core.setXYStageDevice(self._xy_label) + if self._z_label: + self._core.setFocusDevice(self._z_label) + + # Units are micrometers (MM's native unit) + super().__init__(positionerInfo, name, initialPosition={ + "X": self._core.getXPosition() if self._xy_label else 0, + "Y": self._core.getYPosition() if self._xy_label else 0, + "Z": self._core.getPosition() if self._z_label else 0, + }, **lowLevelManagers) + + def move(self, dist, axis): + """Relative move in micrometers.""" + if axis in ("X", "Y") and self._xy_label: + dx = dist if axis == "X" else 0 + dy = dist if axis == "Y" else 0 + self._core.setRelativeXYPosition(dx, dy) + self._core.waitForDevice(self._xy_label) + elif axis == "Z" and self._z_label: + self._core.setRelativePosition(dist) + self._core.waitForDevice(self._z_label) + self._position[axis] = self.getPosition(axis) + + def setPosition(self, position, axis): + """Absolute move in micrometers.""" + if axis == "X" and self._xy_label: + self._core.setXYPosition(position, self._core.getYPosition()) + self._core.waitForDevice(self._xy_label) + elif axis == "Y" and self._xy_label: + self._core.setXYPosition(self._core.getXPosition(), position) + self._core.waitForDevice(self._xy_label) + elif axis == "Z" and self._z_label: + self._core.setPosition(position) + self._core.waitForDevice(self._z_label) + self._position[axis] = position + + def getPosition(self, axis): + if axis == "X" and self._xy_label: + return self._core.getXPosition() + elif axis == "Y" and self._xy_label: + return self._core.getYPosition() + elif axis == "Z" and self._z_label: + return self._core.getPosition() + return 0.0 + + def finalize(self): + pass +``` + +### 2.5 The Laser Manager + +Place in `imswitch/imcontrol/model/managers/lasers/MMCoreLaserManager.py`: + +```python +""" +ImSwitch LaserManager wrapping a Micro-Manager shutter or analog device. + +Setup JSON example (shutter mode): +{ + "lasers": { + "MMLaser": { + "managerName": "MMCoreLaserManager", + "managerProperties": { + "cfgPath": "/home/pi/mm_configs/my_scope.cfg", + "mode": "shutter", + "deviceLabel": "Shutter" + }, + "wavelength": 488, + "valueRangeMin": 0, + "valueRangeMax": 1 + } + } +} + +Setup JSON example (analog/property mode): +{ + "lasers": { + "Laser488": { + "managerName": "MMCoreLaserManager", + "managerProperties": { + "cfgPath": "/home/pi/mm_configs/my_scope.cfg", + "mode": "property", + "deviceLabel": "LaserDA", + "propertyName": "Volts" + }, + "wavelength": 488, + "valueRangeMin": 0, + "valueRangeMax": 5 + } + } +} +""" + +from imswitch.imcontrol.model.managers.lasers.LaserManager import LaserManager +from imswitch.imcontrol.model.managers import MMCoreManager + + +class MMCoreLaserManager(LaserManager): + + def __init__(self, laserInfo, name, **lowLevelManagers): + self._props = laserInfo.managerProperties + + self._core = MMCoreManager.ensure_loaded( + cfg_path=self._props["cfgPath"], + adapter_paths=[self._props.get("adapterPath")] if self._props.get("adapterPath") else None + ) + + self._mode = self._props.get("mode", "shutter") # "shutter" or "property" + self._label = self._props["deviceLabel"] + self._property = self._props.get("propertyName", "Volts") + + is_binary = (self._mode == "shutter") + value_units = "" if is_binary else "V" + + super().__init__( + laserInfo, name, + isBinary=is_binary, + valueUnits=value_units, + valueDecimals=2, + **lowLevelManagers + ) + + def setEnabled(self, enabled): + if self._mode == "shutter": + self._core.setShutterDevice(self._label) + self._core.setShutterOpen(enabled) + elif self._mode == "property": + if not enabled: + self._core.setProperty(self._label, self._property, 0) + + def setValue(self, value): + if self._mode == "property": + self._core.setProperty(self._label, self._property, value) + + def finalize(self): + self.setEnabled(False) +``` + +### 2.6 Example setup JSON for ImSwitch + +Save as `~/ImSwitchConfig/imcontrol_setups/example_pymmcore_demo.json`: + +```json +{ + "rs232devices": {}, + "lasers": { + "MMShutter": { + "managerName": "MMCoreLaserManager", + "managerProperties": { + "cfgPath": "/home/pi/micro-manager-configs/MMConfig_demo.cfg", + "mode": "shutter", + "deviceLabel": "Shutter" + }, + "wavelength": 488, + "valueRangeMin": 0, + "valueRangeMax": 1 + } + }, + "positioners": { + "MMStage": { + "managerName": "MMCorePositionerManager", + "managerProperties": { + "cfgPath": "/home/pi/micro-manager-configs/MMConfig_demo.cfg", + "xyDeviceLabel": "XY", + "zDeviceLabel": "Z" + }, + "axes": ["X", "Y", "Z"], + "forScanning": true, + "forPositioning": true + } + }, + "detectors": { + "MMCamera": { + "managerName": "MMCoreDetectorManager", + "managerProperties": { + "cfgPath": "/home/pi/micro-manager-configs/MMConfig_demo.cfg", + "deviceLabel": "Camera" + }, + "forAcquisition": true + } + }, + "nipiezzos": {}, + "nidaqmanager": null, + "rois": {}, + "designerId": null +} +``` + +Then in `~/ImSwitchConfig/config/imcontrol_options.json`: + +```json +{ + "setupFileName": "example_pymmcore_demo.json" +} +``` + +### 2.7 Registering the new managers + +ImSwitch discovers managers by importing them from the relevant package. +You need to add imports in the `__init__.py` files: + +```python +# In imswitch/imcontrol/model/managers/detectors/__init__.py, add: +from .MMCoreDetectorManager import MMCoreDetectorManager + +# In imswitch/imcontrol/model/managers/positioners/__init__.py, add: +from .MMCorePositionerManager import MMCorePositionerManager + +# In imswitch/imcontrol/model/managers/lasers/__init__.py, add: +from .MMCoreLaserManager import MMCoreLaserManager +``` + +Guard these imports so ImSwitch doesn't crash when pymmcore-plus is not installed: + +```python +try: + from .MMCoreDetectorManager import MMCoreDetectorManager +except ImportError: + pass # pymmcore-plus not installed, MMCore managers unavailable +``` + +--- + +## 3. Prebuilt binaries via GitHub Actions CI + +You're right that compiling from source every time is painful. The solution is +to build once in CI and publish a tarball as a GitHub Release that you can +just download and extract on any Pi. + +### 3.1 What goes in the tarball + +``` +micro-manager-arm64-.tar.gz +└── micro-manager/ + ├── lib/ + │ └── micro-manager/ + │ ├── libmmgr_dal_DemoCamera.so + │ ├── libmmgr_dal_SerialManager.so + │ ├── libmmgr_dal_... .so + │ └── libMMCore.so + ├── share/ + │ └── micro-manager/ + │ └── MMConfig_demo.cfg + └── install.sh # simple script: extract, pip install pymmcore-plus +``` + +### 3.2 GitHub Actions workflow + +Save as `.github/workflows/build-mm-arm64.yml`: + +```yaml +name: Build Micro-Manager for arm64 + +on: + push: + tags: ["v*"] # trigger on version tags + workflow_dispatch: # manual trigger + +jobs: + build: + runs-on: ubuntu-24.04 + steps: + + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU (for arm64 emulation) + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Build in arm64 container + uses: uraimo/run-on-arch-action@v3 + id: build + with: + arch: aarch64 + distro: bookworm # matches Pi OS Bookworm + githubToken: ${{ github.token }} + + # These packages get cached in the container image layer + install: | + apt-get update -y + apt-get install -y \ + build-essential autoconf automake libtool autoconf-archive \ + pkg-config git swig libboost-all-dev \ + python3-dev python3-pip python3-numpy \ + libusb-1.0-0-dev libudev-dev + + run: | + set -euxo pipefail + + # Clone repos + git clone --depth 1 https://github.com/micro-manager/micro-manager.git /build/mm + cd /build/mm + git submodule update --init --recursive + + # Build + cd /build/mm + ./autogen.sh + ./configure \ + --prefix=/opt/micro-manager \ + --without-java \ + --disable-java-app + make -j$(nproc) + make install DESTDIR=/build/staging + + # Package + cd /build/staging + tar czf /artifacts/micro-manager-arm64.tar.gz opt/micro-manager/ + + dockerRunArgs: | + --volume ${{ github.workspace }}/artifacts:/artifacts + + - name: Create install script + run: | + mkdir -p artifacts + cat > artifacts/install.sh << 'INSTALLER' + #!/usr/bin/env bash + set -euo pipefail + echo "Installing Micro-Manager for arm64..." + sudo tar xzf micro-manager-arm64.tar.gz -C / + echo "export MICROMANAGER_PATH=/opt/micro-manager/lib/micro-manager" >> ~/.bashrc + pip install "pymmcore-plus[cli]" --break-system-packages 2>/dev/null || \ + pip install "pymmcore-plus[cli]" + echo "Done! Run: source ~/.bashrc" + INSTALLER + chmod +x artifacts/install.sh + + - name: Upload artifact + uses: actions/upload-artifact@v4 + with: + name: micro-manager-arm64 + path: artifacts/ + + - name: Create Release + if: startsWith(github.ref, 'refs/tags/') + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/micro-manager-arm64.tar.gz + artifacts/install.sh +``` + +### 3.3 Using the prebuilt binary on a Pi + +After the CI builds and publishes a release: + +```bash +# Download from GitHub releases (replace with your repo/tag) +wget https://github.com/YOUR_ORG/YOUR_REPO/releases/download/v1.0/micro-manager-arm64.tar.gz +wget https://github.com/YOUR_ORG/YOUR_REPO/releases/download/v1.0/install.sh + +chmod +x install.sh +bash install.sh +source ~/.bashrc + +# Verify +python3 -c " +from pymmcore_plus import CMMCorePlus +import os +core = CMMCorePlus.instance() +core.setDeviceAdapterSearchPaths([os.environ['MICROMANAGER_PATH']]) +core.loadDevice('Cam', 'DemoCamera', 'DCam') +core.initializeAllDevices() +core.setCameraDevice('Cam') +print(f'snap shape: {core.snap().shape}') +print('SUCCESS') +" +``` + +### 3.4 Important: pymmcore device interface version pinning + +The compiled `.so` adapters and the `pymmcore` Python wheel MUST share the +same device interface version. If they don't match, you'll see errors like +`Failed to load device adapter: interface version mismatch`. + +Pin the version in the CI workflow by checking out a specific tag of +`mmCoreAndDevices` and installing the matching `pymmcore` version. You can find +the device interface version in `MMDevice/MMDeviceConstants.h`: + +```bash +grep "MODULE_INTERFACE_VERSION" mmCoreAndDevices/MMDevice/MMDeviceConstants.h +``` + +Then in the install script: +```bash +pip install "pymmcore==XX.Y.Z" # matching version +``` + +--- + +## 4. There is no "start Micro-Manager" step + +This is the most common point of confusion. With pymmcore-plus, **there is no +separate Micro-Manager process to launch.** Here's the comparison: + +| Approach | What runs | How Python talks to it | +|----------|-----------|----------------------| +| **MMStudio (Java GUI)** | Java process with a GUI window | Not applicable (it IS the GUI) | +| **Pycro-Manager** | Java MMStudio process in background | ZMQ bridge over TCP | +| **pymmcore-plus** | Nothing separate — it's a Python library | Direct C++ calls via SWIG bindings | + +So after running the install script: + +```bash +# Activate your venv (if you used one) +source ~/mm-venv/bin/activate + +# Just run Python +python3 my_script.py +# or +python3 -c "from pymmcore_plus import CMMCorePlus; print('ready')" +# or start ImSwitch +python3 -m imswitch +``` + +The `CMMCorePlus.instance()` call loads the C++ library into your Python +process. `loadSystemConfiguration()` opens serial ports, USB connections, etc. +to the real hardware. That's it — no daemon, no server, no Java. + +### When to set environment variables + +The only setup needed before running Python: + +```bash +# Tell pymmcore-plus where the compiled .so adapter files are +export MICROMANAGER_PATH="/opt/micro-manager/lib/micro-manager" + +# Or set it in your script: +# core.setDeviceAdapterSearchPaths(["/opt/micro-manager/lib/micro-manager"]) +``` + +### Quick-start one-liner after install + +```bash +source ~/mm-venv/bin/activate && \ +MICROMANAGER_PATH=/opt/micro-manager/lib/micro-manager \ +python3 ~/micro-manager-configs/smoke_test.py +``` + +--- + +## Summary of the workflow + +``` +1. Install once (build script OR download prebuilt tarball) + │ + ▼ +2. pip install pymmcore-plus (matching version) + │ + ▼ +3. Write Python script / ImSwitch manager + │ + ├── from pymmcore_plus import CMMCorePlus + ├── core.setDeviceAdapterSearchPaths([...]) + ├── core.loadSystemConfiguration("my_scope.cfg") + ├── core.snap() / core.setXYPosition() / etc. + │ + ▼ +4. Run it: python3 my_script.py + (or: python3 -m imswitch) +``` + +No daemon. No server. No Java. Just Python + compiled C++ adapters. \ No newline at end of file diff --git a/pymmcore-feature-integraion.md b/pymmcore-feature-integraion.md new file mode 100644 index 00000000..7c7d58a2 --- /dev/null +++ b/pymmcore-feature-integraion.md @@ -0,0 +1,791 @@ +# CLAUDE.md — pymmcore-plus Integration into ImSwitch + +You are implementing Micro-Manager hardware support in the openUC2/ImSwitch +microscopy platform via `pymmcore-plus`. This adds support for any camera, +stage, or laser that has a Micro-Manager device adapter (Andor, Hamamatsu, +Basler, ASI, Prior, Thorlabs, Coherent, etc.) alongside the existing +ESP32/UC2-REST managers. + + +## STEP 0 — Reconnaissance (do this FIRST, before writing any code) + +Run these commands and summarize what you find. Paste the summary as a +comment in the first commit. + +```bash +# Check for any existing pymmcore/MMCore work +rg -n -S "pymmcore|MMCore|micromanager|micro.manager" --type py + +# Map the manager directory structure +find imswitch/imcontrol/model/managers -name "*.py" | head -60 + +# Read the base classes — you MUST match these signatures exactly +cat imswitch/imcontrol/model/managers/detectors/DetectorManager.py +cat imswitch/imcontrol/model/managers/positioners/PositionerManager.py +cat imswitch/imcontrol/model/managers/lasers/LaserManager.py + +# Read one concrete example of each to understand the pattern +cat imswitch/imcontrol/model/managers/detectors/HIKCamManager.py 2>/dev/null || \ + ls imswitch/imcontrol/model/managers/detectors/ +cat imswitch/imcontrol/model/managers/positioners/ESP32StageManager.py 2>/dev/null || \ + ls imswitch/imcontrol/model/managers/positioners/ +cat imswitch/imcontrol/model/managers/lasers/ESP32LEDLaserManager.py 2>/dev/null || \ + ls imswitch/imcontrol/model/managers/lasers/ + +# Check how managers are registered / discovered +cat imswitch/imcontrol/model/managers/detectors/__init__.py +cat imswitch/imcontrol/model/managers/positioners/__init__.py +cat imswitch/imcontrol/model/managers/lasers/__init__.py + +# Check the setup JSON schema / info classes +rg -n "class DetectorInfo" --type py +rg -n "class LaserInfo" --type py +rg -n "class PositionerInfo" --type py + +# Check how lowLevelManagers work +rg -n "lowLevelManagers" imswitch/imcontrol/model/managers/ --type py | head -20 + +# Look at an existing setup JSON for the structure +find . -name "*.json" -path "*/imcontrol_setups/*" | head -10 +cat $(find . -name "example_virtual_microscope.json" -path "*/imcontrol_setups/*" | head -1) 2>/dev/null +``` + +**Do not proceed until you have read and understood the base class +signatures.** + +--- + +## STEP 1 — Shared MMCore singleton + +Create: `imswitch/imcontrol/model/managers/MMCoreManager.py` + +This is NOT an ImSwitch device manager. It is an internal helper module that +the three device managers below will share. + +### Requirements + +- Provide `get_core() -> CMMCorePlus` that returns a process-wide singleton + via `CMMCorePlus.instance()`. +- Provide `ensure_loaded(cfg_path: str, adapter_paths: list[str] | None) -> CMMCorePlus`: + - On first call: sets adapter search paths, calls `loadSystemConfiguration(cfg_path)`. + - On subsequent calls with the same `cfg_path`: returns the core immediately (no-op). + - On call with a different `cfg_path`: calls `unloadAllDevices()` first, then reloads. + - Thread-safe via `threading.Lock`. + - Falls back to env var `MICROMANAGER_PATH` (default `/opt/micro-manager/lib/micro-manager`) + if `adapter_paths` is None. + - Logs all loaded devices at INFO level after loading. +- Provide `reload(cfg_path, adapter_paths)` that forces a reload. +- Provide `get_available_adapters(adapter_path: str) -> list[str]` that + lists the `.so`/`.dylib` files in the adapter directory and returns + human-readable adapter names (strip `libmmgr_dal_` prefix and `.so` suffix). +- Provide `get_available_devices_for_adapter(adapter_name: str) -> list[str]` + that calls `core.getAvailableDevices(adapter_name)` and returns the list. + +### Key design decisions + +- `pymmcore-plus` is an OPTIONAL dependency. Guard the import: + ```python + try: + from pymmcore_plus import CMMCorePlus + except ImportError: + CMMCorePlus = None + ``` + All three managers must check `if CMMCorePlus is None` and raise a clear + error message telling the user to `pip install pymmcore-plus`. +- The singleton is important because multiple ImSwitch managers (camera + + stage + laser) will share the SAME core with the SAME loaded .cfg, and + MMCore is stateful — you must not create two cores fighting over USB. + +--- + +## STEP 2 — MMCoreDetectorManager (camera) + +Create: `imswitch/imcontrol/model/managers/detectors/MMCoreDetectorManager.py` + +### Constructor: `__init__(self, detectorInfo, name, **lowLevelManagers)` + +Read these from `detectorInfo.managerProperties`: + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `cfgPath` | str | YES | Path to MM `.cfg` file | +| `adapterPath` | str | no | Override adapter search path | +| `adapterName` | str | no | e.g. `"DemoCamera"`, `"HamamatsuHam"`, `"AndorSDK3"` | +| `deviceName` | str | no | e.g. `"DCam"`, `"Andor sCMOS Camera"` | +| `deviceLabel` | str | no | Label to assign (default: `"Camera"`) | + +**If `cfgPath` is provided**, load via `MMCoreManager.ensure_loaded(cfgPath)`. +The camera device is already in the config. + +**If `cfgPath` is NOT provided but `adapterName` + `deviceName` ARE**, do +manual device loading: +```python +core.loadDevice(label, adapterName, deviceName) +core.initializeDevice(label) +core.setCameraDevice(label) +``` +This is the mode where the user specifies "I want an Andor camera" directly +in the JSON without writing a separate .cfg file. + +**If NEITHER is provided**, raise a clear error. + +After loading, read from the core: +- `fullShape = (core.getImageWidth(), core.getImageHeight())` +- Exposure via `core.getExposure()` +- Pixel type via `core.getBytesPerPixel()` +- Binning via `core.getProperty(label, "Binning")` if the property exists + +Build the `parameters` dict dynamically by iterating +`core.getDevicePropertyNames(label)` — for each property: +- If it has allowed values (`core.getAllowedPropertyValues`), create a + `DetectorListParameter`. +- If it is numeric, create a `DetectorNumberParameter`. +- Skip read-only internal properties (property names starting with `"On"` + or containing `"TransposeCorrection"`). +- Group all as `"MMCore"`. +- Always include `"Exposure"` as an explicit `DetectorNumberParameter` + (group `"Acquisition"`, units `"ms"`, editable). + +Call `super().__init__()` with the signature matching what you found in Step 0. + +### Abstract methods to implement + +- `getLatestFrame()` → `return self._core.getLastImage()` + If no image is available yet, call `self._core.snap()` first. +- `getChunk()` → collect all buffered images via `popNextImage()` in a loop, + stack into a 3D ndarray `(N, H, W)`. +- `flushBuffers()` → `self._core.clearCircularBuffer()` +- `startAcquisition()` → `self._core.startContinuousSequenceAcquisition(0)` +- `stopAcquisition()` → check `isSequenceRunning()` first, then + `stopSequenceAcquisition()`. +- `crop(hpos, vpos, hsize, vsize)` → `self._core.setROI(label, hpos, vpos, hsize, vsize)` +- `pixelSizeUm` property → read from `core.getPixelSizeUm()`, return `[1, ps, ps]`. +- `setParameter(name, value)` → if `name == "Exposure"`, call + `core.setExposure(float(value))`. For all other parameters, call + `core.setProperty(label, name, value)`. Return updated parameters dict. +- `setBinning(binning)` → `core.setProperty(label, "Binning", str(binning))` +- `finalize()` → `stopAcquisition()` + +--- + +## STEP 3 — MMCorePositionerManager (stage) + +Create: `imswitch/imcontrol/model/managers/positioners/MMCorePositionerManager.py` + +### Constructor: `__init__(self, positionerInfo, name, **lowLevelManagers)` + +Read from `positionerInfo.managerProperties`: + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `cfgPath` | str | yes* | Path to MM `.cfg` file | +| `adapterPath` | str | no | Override adapter search path | +| `xyAdapterName` | str | no* | e.g. `"DemoCamera"` for manual loading | +| `xyDeviceName` | str | no* | e.g. `"DXYStage"` | +| `xyDeviceLabel` | str | no | Label (default: `"XY"`) | +| `zAdapterName` | str | no* | e.g. `"DemoCamera"` | +| `zDeviceName` | str | no* | e.g. `"DStage"` | +| `zDeviceLabel` | str | no | Label (default: `"Z"`) | + +*Either `cfgPath` OR the adapter/device name pairs are required. + +Support two modes: +1. **Config mode**: `cfgPath` provided → `MMCoreManager.ensure_loaded(cfgPath)` + and locate the XY/Z devices by label. +2. **Manual mode**: adapter + device names provided → `loadDevice` + `initializeDevice` + for XY and/or Z independently. + +The axes come from `positionerInfo.axes` (list of `"X"`, `"Y"`, `"Z"`). + +### Methods to implement + +Follow the pattern you found in Step 0 for the existing positioner managers. +The key methods are: + +- `move(dist, axis)` — RELATIVE move in micrometers. + - X/Y: `core.setRelativeXYPosition(dx, dy)` then `core.waitForDevice(xyLabel)` + - Z: `core.setRelativePosition(dist)` then `core.waitForDevice(zLabel)` +- `setPosition(position, axis)` — ABSOLUTE move in micrometers. +- `getPosition(axis)` → returns current position for the given axis. +- `finalize()` — no-op (stages don't need cleanup). + +Update `self._position[axis]` after every move. + +--- + +## STEP 4 — MMCoreLaserManager (laser / shutter) + +Create: `imswitch/imcontrol/model/managers/lasers/MMCoreLaserManager.py` + +### Constructor: `__init__(self, laserInfo, name, **lowLevelManagers)` + +Read from `laserInfo.managerProperties`: + +| Property | Type | Required | Description | +|----------|------|----------|-------------| +| `cfgPath` | str | yes* | Path to MM `.cfg` file | +| `adapterPath` | str | no | Override adapter search path | +| `mode` | str | no | `"shutter"` (default) or `"property"` | +| `adapterName` | str | no* | For manual loading | +| `deviceName` | str | no* | For manual loading | +| `deviceLabel` | str | YES | Label of the shutter or DA device | +| `propertyName` | str | no | For property mode (default: `"Volts"`) | + +*Either `cfgPath` OR `adapterName` + `deviceName` required. + +### Two modes + +1. **Shutter mode** (`mode: "shutter"`): + - `setEnabled(True/False)` → `core.setShutterDevice(label)` + `core.setShutterOpen(enabled)` + - `setValue(value)` → no-op (it's binary) + - `isBinary = True`, `valueUnits = ""` + +2. **Property mode** (`mode: "property"`): + - `setEnabled(False)` → `core.setProperty(label, propertyName, 0)` + - `setValue(value)` → `core.setProperty(label, propertyName, value)` + - `isBinary = False`, `valueUnits = laserInfo.managerProperties.get("valueUnits", "mW")` + +Call `super().__init__(laserInfo, name, isBinary=..., valueUnits=..., valueDecimals=2)` +matching the base class signature you found in Step 0. + +- `finalize()` → `setEnabled(False)` + +--- + +## STEP 5 — Register the managers + +Edit the `__init__.py` files to add imports. Guard with try/except so that +ImSwitch doesn't crash when pymmcore-plus is not installed: + +```python +# In imswitch/imcontrol/model/managers/detectors/__init__.py +try: + from .MMCoreDetectorManager import MMCoreDetectorManager # noqa: F401 +except ImportError: + pass + +# Same pattern for positioners/ and lasers/ +``` + +If the openUC2 fork uses a different registration mechanism (e.g. a registry +dict or an explicit list), follow THAT pattern instead. Trust the source. + +--- + +## STEP 6 — Default mock/demo setup JSON + +Create: `imswitch/imcontrol/model/SetupInfo/imcontrol_setups/example_mmcore_demo.json` + +(Adjust path if the openUC2 fork stores setups elsewhere — check Step 0 output.) + +```json +{ + "rs232devices": {}, + "lasers": { + "MMShutter": { + "analogChannel": null, + "digitalLine": null, + "managerName": "MMCoreLaserManager", + "managerProperties": { + "adapterName": "DemoCamera", + "deviceName": "DShutter", + "deviceLabel": "Shutter", + "mode": "shutter" + }, + "wavelength": 488, + "valueRangeMin": 0, + "valueRangeMax": 1 + } + }, + "positioners": { + "MMStage": { + "managerName": "MMCorePositionerManager", + "managerProperties": { + "xyAdapterName": "DemoCamera", + "xyDeviceName": "DXYStage", + "xyDeviceLabel": "XY", + "zAdapterName": "DemoCamera", + "zDeviceName": "DStage", + "zDeviceLabel": "Z" + }, + "axes": ["X", "Y", "Z"], + "forScanning": true, + "forPositioning": true + } + }, + "detectors": { + "MMCamera": { + "managerName": "MMCoreDetectorManager", + "managerProperties": { + "adapterName": "DemoCamera", + "deviceName": "DCam", + "deviceLabel": "Camera" + }, + "forAcquisition": true + } + }, + "nipiezzos": {}, + "nidaqmanager": null, + "rois": {}, + "designerId": null +} +``` + +**Important**: this demo setup uses `adapterName`/`deviceName` (manual mode) +instead of `cfgPath`, so it works out of the box without a separate `.cfg` +file. The DemoCamera adapter ships with every Micro-Manager install. + +Also create a second example for a real camera — this one uses `cfgPath`: + +Create: `imswitch/imcontrol/model/SetupInfo/imcontrol_setups/example_mmcore_andor.json` + +```json +{ + "rs232devices": {}, + "lasers": {}, + "positioners": { + "ASIStage": { + "managerName": "MMCorePositionerManager", + "managerProperties": { + "cfgPath": "/home/pi/mm_configs/andor_asi.cfg", + "xyDeviceLabel": "XYStage", + "zDeviceLabel": "ZDrive" + }, + "axes": ["X", "Y", "Z"], + "forScanning": true, + "forPositioning": true + } + }, + "detectors": { + "AndorCamera": { + "managerName": "MMCoreDetectorManager", + "managerProperties": { + "cfgPath": "/home/pi/mm_configs/andor_asi.cfg", + "deviceLabel": "Andor sCMOS Camera" + }, + "forAcquisition": true + } + }, + "nipiezzos": {}, + "nidaqmanager": null, + "rois": {}, + "designerId": null +} +``` + +--- + +## STEP 7 — Tests + +Create: `tests/test_mmcore_managers.py` + +```python +""" +Tests for MMCore*Manager classes using the DemoCamera adapter. + +These tests require pymmcore-plus and the DemoCamera adapter library. +On x86_64, `mmcore install` (from pymmcore-plus[cli]) downloads adapters. +On arm64, they must be built from source (see install_micromanager_rpi.sh). + +Skip gracefully if not available. +""" +import pytest +import os +import numpy as np + +# Skip entire module if pymmcore-plus is not installed +pymmcore_plus = pytest.importorskip("pymmcore_plus") + +# Skip if no adapter library is available +def _adapters_available(): + paths = [ + os.environ.get("MICROMANAGER_PATH", ""), + "/opt/micro-manager/lib/micro-manager", + os.path.expanduser("~/mm-venv/lib/python3.*/site-packages/pymmcore_plus/"), + ] + for p in paths: + import glob + for expanded in glob.glob(p): + if os.path.isdir(expanded): + files = os.listdir(expanded) + if any(f.startswith("libmmgr_dal_") or f.startswith("mmgr_dal_") for f in files): + return True + return False + +pytestmark = pytest.mark.skipif( + not _adapters_available(), + reason="No Micro-Manager device adapters found" +) + + +class TestMMCoreManager: + def test_get_core(self): + from imswitch.imcontrol.model.managers.MMCoreManager import get_core + core = get_core() + assert core is not None + assert core.getVersionInfo() != "" + + def test_get_available_adapters(self): + from imswitch.imcontrol.model.managers.MMCoreManager import get_available_adapters + mm_path = os.environ.get("MICROMANAGER_PATH", "/opt/micro-manager/lib/micro-manager") + if os.path.isdir(mm_path): + adapters = get_available_adapters(mm_path) + assert "DemoCamera" in adapters + + +class TestMMCoreDetectorManager: + """Test the detector manager with DemoCamera.""" + + def _make_manager(self): + """Helper to create a detector manager with DemoCamera.""" + from imswitch.imcontrol.model.managers.detectors.MMCoreDetectorManager import MMCoreDetectorManager + from unittest.mock import MagicMock + + info = MagicMock() + info.managerProperties = { + "adapterName": "DemoCamera", + "deviceName": "DCam", + "deviceLabel": "Camera", + } + info.forAcquisition = True + info.forFocusLock = False + + return MMCoreDetectorManager(info, "TestCamera") + + def test_snap(self): + mgr = self._make_manager() + frame = mgr.getLatestFrame() + assert isinstance(frame, np.ndarray) + assert frame.ndim == 2 + assert frame.shape[0] > 0 and frame.shape[1] > 0 + mgr.finalize() + + def test_continuous_acquisition(self): + import time + mgr = self._make_manager() + mgr.startAcquisition() + time.sleep(0.3) + mgr.stopAcquisition() + mgr.finalize() + + def test_exposure(self): + mgr = self._make_manager() + mgr.setParameter("Exposure", 50.0) + # Verify it was set on the core + assert abs(mgr._core.getExposure() - 50.0) < 0.1 + mgr.finalize() + + +class TestMMCorePositionerManager: + """Test the positioner manager with DemoCamera XY/Z stages.""" + + def _make_manager(self): + from imswitch.imcontrol.model.managers.positioners.MMCorePositionerManager import MMCorePositionerManager + from unittest.mock import MagicMock + + info = MagicMock() + info.managerProperties = { + "xyAdapterName": "DemoCamera", + "xyDeviceName": "DXYStage", + "xyDeviceLabel": "XY", + "zAdapterName": "DemoCamera", + "zDeviceName": "DStage", + "zDeviceLabel": "Z", + } + info.axes = ["X", "Y", "Z"] + info.forScanning = True + info.forPositioning = True + + return MMCorePositionerManager(info, "TestStage") + + def test_move_xy(self): + mgr = self._make_manager() + x0 = mgr.getPosition("X") + mgr.move(100.0, "X") + x1 = mgr.getPosition("X") + assert abs(x1 - x0 - 100.0) < 1.0 + mgr.finalize() + + def test_move_z(self): + mgr = self._make_manager() + z0 = mgr.getPosition("Z") + mgr.move(10.0, "Z") + z1 = mgr.getPosition("Z") + assert abs(z1 - z0 - 10.0) < 1.0 + mgr.finalize() + + def test_set_position(self): + mgr = self._make_manager() + mgr.setPosition(500.0, "X") + assert abs(mgr.getPosition("X") - 500.0) < 1.0 + mgr.finalize() + + +class TestMMCoreLaserManager: + """Test the laser manager with DemoCamera shutter.""" + + def _make_manager(self): + from imswitch.imcontrol.model.managers.lasers.MMCoreLaserManager import MMCoreLaserManager + from unittest.mock import MagicMock + + info = MagicMock() + info.managerProperties = { + "adapterName": "DemoCamera", + "deviceName": "DShutter", + "deviceLabel": "Shutter", + "mode": "shutter", + } + info.wavelength = 488 + info.valueRangeMin = 0 + info.valueRangeMax = 1 + + return MMCoreLaserManager(info, "TestLaser") + + def test_shutter_toggle(self): + mgr = self._make_manager() + mgr.setEnabled(True) + assert mgr._core.getShutterOpen() == True + mgr.setEnabled(False) + assert mgr._core.getShutterOpen() == False + mgr.finalize() +``` + +Run tests with: `pytest tests/test_mmcore_managers.py -v` + +Adjust the test helpers if the actual base class constructors require +additional arguments you discovered in Step 0. + +--- + +## STEP 8 — pyproject.toml / setup changes + +Add `pymmcore-plus` as an optional dependency: + +```toml +[project.optional-dependencies] +pymmcore = ["pymmcore-plus>=0.10"] +``` + +(Adjust the extras group name and location to match the existing project +structure — it may use `setup.py`, `setup.cfg`, or `pyproject.toml`.) + +--- + +## STEP 9 — GitHub Actions: build Micro-Manager arm64 adapters + +Create: `.github/workflows/build-mm-arm64.yml` + +This workflow compiles mmCoreAndDevices for arm64 on every push to this +branch (or on tag), and uploads the compiled adapter `.so` files as a +release artifact. Users can then download and extract instead of compiling. + +```yaml +name: Build Micro-Manager adapters (arm64) + +on: + push: + branches: [feat/pymmcore-integration] + tags: ["mm-v*"] + workflow_dispatch: + +jobs: + build-arm64: + runs-on: ubuntu-24.04 + timeout-minutes: 120 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + with: + platforms: arm64 + + - name: Build adapters in arm64 container + uses: uraimo/run-on-arch-action@v3 + id: build + with: + arch: aarch64 + distro: bookworm + githubToken: ${{ github.token }} + + install: | + apt-get update -y + apt-get install -y \ + build-essential autoconf automake libtool autoconf-archive \ + pkg-config git swig libboost-all-dev \ + python3-dev python3-numpy \ + libusb-1.0-0-dev libudev-dev + + run: | + set -euxo pipefail + + # Clone with submodules + git clone --depth 1 https://github.com/micro-manager/micro-manager.git /build/mm + cd /build/mm + git submodule update --init --recursive + + # Build MMCore + device adapters only (no Java) + ./autogen.sh + ./configure \ + --prefix=/opt/micro-manager \ + --without-java \ + --disable-java-app + make -j$(nproc) + make install DESTDIR=/build/staging + + # Record device interface version for pinning + grep "MODULE_INTERFACE_VERSION" \ + mmCoreAndDevices/MMDevice/MMDeviceConstants.h \ + > /build/staging/DEVICE_INTERFACE_VERSION.txt + + # Package + cd /build/staging + tar czf /artifacts/micro-manager-arm64.tar.gz \ + opt/micro-manager/ \ + DEVICE_INTERFACE_VERSION.txt + + # List what was built + find opt/micro-manager/lib/micro-manager -name "*.so" | \ + sed 's|.*/libmmgr_dal_||; s|\.so.*||' | sort \ + > /artifacts/ADAPTERS_BUILT.txt + cat /artifacts/ADAPTERS_BUILT.txt + + dockerRunArgs: | + --volume ${{ github.workspace }}/artifacts:/artifacts + + - name: Create install helper + run: | + mkdir -p artifacts + cat > artifacts/install_mm_arm64.sh << 'EOF' + #!/usr/bin/env bash + set -euo pipefail + SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)" + echo "=== Installing Micro-Manager arm64 adapters ===" + sudo tar xzf "$SCRIPT_DIR/micro-manager-arm64.tar.gz" -C / + echo 'export MICROMANAGER_PATH="/opt/micro-manager/lib/micro-manager"' >> ~/.bashrc + echo "=== Installing pymmcore-plus ===" + pip install "pymmcore-plus" --break-system-packages 2>/dev/null || \ + pip install "pymmcore-plus" + echo "=== Done! Run: source ~/.bashrc ===" + echo "=== Then test with: python3 -c 'from pymmcore_plus import CMMCorePlus; print(CMMCorePlus.instance().getVersionInfo())' ===" + EOF + chmod +x artifacts/install_mm_arm64.sh + + - name: Upload build artifacts + uses: actions/upload-artifact@v4 + with: + name: micro-manager-arm64 + path: artifacts/ + + - name: Create GitHub Release + if: startsWith(github.ref, 'refs/tags/mm-v') + uses: softprops/action-gh-release@v2 + with: + files: | + artifacts/micro-manager-arm64.tar.gz + artifacts/install_mm_arm64.sh + artifacts/ADAPTERS_BUILT.txt + + # Also run the Python tests on x86_64 with DemoCamera + test-x86: + runs-on: ubuntu-24.04 + timeout-minutes: 15 + + steps: + - uses: actions/checkout@v4 + + - name: Set up Python + uses: actions/setup-python@v5 + with: + python-version: "3.12" + + - name: Install dependencies + run: | + pip install -e ".[pymmcore,dev]" 2>/dev/null || pip install -e ".[dev]" + pip install pymmcore-plus[cli] pytest + + - name: Install MM demo adapters + run: mmcore install + + - name: Run pymmcore tests + run: pytest tests/test_mmcore_managers.py -v + env: + MICROMANAGER_PATH: ${{ github.workspace }}/.pymmcore_plus +``` + +--- + +## STEP 10 — Documentation + +Create: `docs/pymmcore-integration.md` + +Write a short guide (300-500 words) covering: + +1. **What this does**: lets ImSwitch control any Micro-Manager-supported + camera/stage/laser via pymmcore-plus, without Java, without a server. + +2. **Quick start with DemoCamera** (copy the demo JSON, run `mmcore install`, + start ImSwitch). + +3. **Using a real camera** (two modes: write a .cfg file OR specify + adapterName/deviceName in JSON directly). + +4. **Finding your adapter name**: explain how to use + `MMCoreManager.get_available_adapters()` and + `MMCoreManager.get_available_devices_for_adapter("HamamatsuHam")` to + discover what's available. + +5. **Raspberry Pi setup**: point to `install_micromanager_rpi.sh` or the + prebuilt GitHub Release tarball. + +6. **Known limitations**: no Java, no MMStudio, adapters requiring + Windows-only vendor DLLs won't work, Pi 5 8GB recommended. + +--- + +## Constraints and quality checks + +Before you consider any step done: + +1. **Match the base class exactly.** If `DetectorManager.__init__` in the + openUC2 fork has different kwargs than documented here (which is likely — + the fork has diverged from upstream), match what you find in the source. + The source code is the ground truth. + +2. **Guard all pymmcore imports.** ImSwitch must still launch and work for + users who don't have pymmcore-plus installed. The error should only + appear if they try to use an `MMCore*Manager` in their setup JSON. + +3. **No global state on import.** `MMCoreManager.get_core()` must be lazy. + Importing the module must not instantiate the core or try to load adapters. + +4. **Thread safety.** ImSwitch inits managers from potentially different + threads. The Lock in MMCoreManager is critical. + +5. **Don't duplicate functionality.** If the openUC2 fork already has some + form of generic camera manager that could be extended, extend it. Check + Step 0 output carefully. + +6. **Test with DemoCamera.** Every code path you write must be testable with + the DemoCamera adapter, which is a mock that ships with every MM install. + +7. **Commit messages.** Use conventional commits: + `feat(mmcore): add MMCoreDetectorManager with dynamic property loading` + +--- + +## Definition of Done + +- [ ] `MMCoreManager.py` exists and provides singleton + lazy loading + adapter listing +- [ ] `MMCoreDetectorManager.py` snaps frames, streams, crops, exposes all device properties dynamically +- [ ] `MMCorePositionerManager.py` moves XY and Z, reads positions back +- [ ] `MMCoreLaserManager.py` toggles shutters and sets analog values +- [ ] `example_mmcore_demo.json` works out of the box with DemoCamera (no .cfg needed) +- [ ] `example_mmcore_andor.json` documents the .cfg-based mode for real hardware +- [ ] All managers import-guarded; ImSwitch works normally without pymmcore-plus +- [ ] `pytest tests/test_mmcore_managers.py` passes on x86_64 with DemoCamera +- [ ] `.github/workflows/build-mm-arm64.yml` builds arm64 adapters and publishes release +- [ ] `docs/pymmcore-integration.md` exists +- [ ] All changes on branch `feat/pymmcore-integration`, no existing files broken \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index 68568785..18eb7c20 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -101,6 +101,9 @@ dev = [ omero = [ "omero-py >= 5.6.0", ] +pymmcore = [ + "pymmcore-plus>=0.10", +] [project.urls] Homepage = "https://github.com/openuc2/ImSwitch" From 5a78884cee52a005dfa015a4379c6be990172c14 Mon Sep 17 00:00:00 2001 From: Benedict Diederich Date: Fri, 1 May 2026 12:10:36 +0200 Subject: [PATCH 2/2] Enhance MMCore integration and device managers Improve Micro-Manager (pymmcore) integration and fix several device manager behaviors. Docs: require MM 2.0, add detailed install/discovery guidance and adapter-path resolution. Add new example_mmcore_andor_cfg.json and update example_mmcore_andor.json. Tests: use MMCoreManager.discover_adapter_paths() for adapter availability and clearer skip reason. MMCoreManager: implement discover_adapter_paths(), platform globs, Windows DLL dir handling, adapter-path resolution, MM2 verification, and better logging. LaserController: return [0,1] for binary lasers. DetectorManager: fix parameter name mapping (gain) and avoid raising on unknown parameters. MMCoreDetectorManager: track running/frame number, make getLatestFrame optionally return frame number and handle errors, and update running flag on start/stop. MMCorePositionerManager.move: extend signature to support absolute moves, optional blocking, and emit position update signal after moves. --- docs/pymmcore-integration.md | 59 +++-- .../example_mmcore_andor.json | 6 +- .../example_mmcore_andor_cfg.json | 112 +++++++++ .../_test/unit/test_mmcore_managers.py | 70 ++---- .../controller/controllers/LaserController.py | 2 +- .../imcontrol/model/managers/MMCoreManager.py | 234 +++++++++++++++--- .../managers/detectors/DetectorManager.py | 3 +- .../detectors/MMCoreDetectorManager.py | 13 +- .../positioners/MMCorePositionerManager.py | 18 +- 9 files changed, 400 insertions(+), 117 deletions(-) create mode 100644 imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor_cfg.json diff --git a/docs/pymmcore-integration.md b/docs/pymmcore-integration.md index 4bcd9a98..63c60dc3 100644 --- a/docs/pymmcore-integration.md +++ b/docs/pymmcore-integration.md @@ -26,31 +26,52 @@ so a single `.cfg` file drives every device without USB conflicts. ## Installation +> **Micro-Manager 2.0 is required** (Device API ≥ 70). The `pymmcore` / +> `pymmcore-plus` Python wheels are built against the MM 2.0 device +> interface; MM 1.4 adapters will fail with an *“interface version +> mismatch”* error on load. + ```bash pip install "ImSwitchUC2[pymmcore]" # or, in a dev checkout: pip install -e ".[pymmcore]" ``` -To make device adapters available, install the Micro-Manager binaries: - -```bash -# x86_64 / arm64 macOS / x86_64 Linux: download via pymmcore-plus CLI -pip install "pymmcore-plus[cli]" -mmcore install # downloads adapters under ~/.local/share/pymmcore-plus -``` - -On a **Raspberry Pi (arm64)** there is no published binary build, so use -either: - -* The provided helper script - [`install_micromanager_raspi.sh`](../install_micromanager_raspi.sh), or -* The prebuilt tarball published by the - [`build-mm-arm64`](../.github/workflows/build-mm-arm64.yml) GitHub - Actions workflow on every tagged release. - -Set `MICROMANAGER_PATH` to the directory containing -`libmmgr_dal_*.so` if it is not auto-discovered. +To install the Micro-Manager 2.0 device adapters themselves, choose the +option that matches your platform: + +| Platform | Recommended install | +|----------|--------------------------------------------------------------------------------------| +| Windows | Download MM 2.0 from – the installer drops adapters in `C:\Program Files\Micro-Manager-2.0` and ImSwitch picks them up automatically. | +| macOS | Download the MM 2.0 nightly `.dmg`; drag to `/Applications/Micro-Manager-2.0`. | +| Linux x86_64 | `pip install "pymmcore-plus[cli]"` then `mmcore install` – downloads the official MM 2.0 adapters into pymmcore-plus' managed directory. | +| Raspberry Pi (arm64) | Build from source via [`install_micromanager_raspi.sh`](../install_micromanager_raspi.sh) or use the prebuilt tarball from the [`build-mm-arm64`](../.github/workflows/build-mm-arm64.yml) workflow. | + +### Adapter path discovery + +`MMCoreManager.discover_adapter_paths()` resolves adapter directories in +the following order: + +1. `MICROMANAGER_PATH` environment variable (override). +2. `pymmcore_plus.find_micromanager()` – knows about `mmcore install` + managed installs and any system installs it can find. +3. Platform-specific MM 2.0 install locations: + * **Windows:** `C:\Program Files\Micro-Manager-2.0*`, + `C:\Program Files (x86)\Micro-Manager-2.0*` + * **macOS:** `/Applications/Micro-Manager-2.0*`, + `/Applications/Micro-Manager.app/Contents/Resources` + * **Linux:** `/opt/micro-manager/lib/micro-manager`, + `/opt/Micro-Manager-2.0*`, `/usr/local/lib/micro-manager` +4. pymmcore-plus' managed install dir on every platform + (`~/.local/share/pymmcore-plus/mm/Micro-Manager-*` and OS-specific + equivalents). + +On **Windows**, every resolved directory is also added to the Python +DLL search path via `os.add_dll_directory`, so vendor SDK DLLs co-located +with the adapter (e.g. Andor, Hamamatsu) are found automatically. + +You can override the search at any time by setting `MICROMANAGER_PATH`, +or per-device via the `adapterPath` key in the setup JSON. ## Quick start: the DemoCamera setup diff --git a/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor.json b/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor.json index c4b515c4..36e4de40 100644 --- a/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor.json +++ b/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor.json @@ -5,7 +5,8 @@ "digitalLine": null, "managerName": "MMCoreDetectorManager", "managerProperties": { - "cfgPath": "/home/pi/micro-manager-configs/Andor_ASI.cfg", + "cfgPath_": "/home/pi/micro-manager-configs/Andor_ASI.cfg", + "cfgPath": "C:\\Users\\benir\\Desktop\\andor.cfg", "deviceLabel": "Andor sCMOS Camera" }, "forAcquisition": true, @@ -21,7 +22,8 @@ "digitalLine": null, "managerName": "MMCorePositionerManager", "managerProperties": { - "cfgPath": "/home/pi/micro-manager-configs/Andor_ASI.cfg", + "cfgPath_": "/home/pi/micro-manager-configs/Andor_ASI.cfg", + "cfgPath": "C:\\Users\\benir\\Desktop\\andor.cfg", "xyDeviceLabel": "XYStage:XY:31", "zDeviceLabel": "ZStage:Z:32" }, diff --git a/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor_cfg.json b/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor_cfg.json new file mode 100644 index 00000000..e97488b9 --- /dev/null +++ b/imswitch/_data/user_defaults/imcontrol_setups/example_mmcore_andor_cfg.json @@ -0,0 +1,112 @@ +{ + "detectors": { + "Andor": { + "analogChannel": null, + "digitalLine": null, + "managerName": "MMCoreDetectorManager", + "managerProperties": { + "adapterName": "Andor", + "deviceName": "Andor", + "deviceLabel": "Andor" + }, + "forAcquisition": true, + "forFocusLock": false + } + }, + "lasers": { + "MMShutter": { + "analogChannel": null, + "digitalLine": null, + "managerName": "MMCoreLaserManager", + "managerProperties": { + "adapterName": "DemoCamera", + "deviceName": "DShutter", + "deviceLabel": "Shutter", + "mode": "shutter" + }, + "wavelength": 488, + "valueRangeMin": 0, + "valueRangeMax": 1, + "valueRangeStep": 1.0 + } + }, + "LEDs": {}, + "LEDMatrixs": {}, + "positioners": { + "MMStage": { + "analogChannel": null, + "digitalLine": null, + "managerName": "MMCorePositionerManager", + "managerProperties": { + "xyAdapterName": "DemoCamera", + "xyDeviceName": "DXYStage", + "xyDeviceLabel": "XY", + "zAdapterName": "DemoCamera", + "zDeviceName": "DStage", + "zDeviceLabel": "Z" + }, + "axes": ["X", "Y", "Z"], + "isPositiveDirection": true, + "forPositioning": true, + "forScanning": true, + "resetOnClose": false, + "stageOffsets": { + "stageOffsetPositionX": 0.0, + "stageOffsetPositionY": 0.0, + "stageOffsetPositionZ": 0.0 + } + } + }, + "rs232devices": {}, + "slm": null, + "sim": null, + "dpc": null, + "objective": null, + "mct": null, + "nidaq": { + "timerCounterChannel": null, + "startTrigger": false + }, + "roiscan": null, + "lightsheet": null, + "webrtc": null, + "hypha": null, + "Stresstest": {}, + "HistoScan": null, + "Workflow": null, + "FlowStop": null, + "Lepmon": null, + "Flatfield": null, + "PixelCalibration": null, + "experiment": null, + "uc2Config": null, + "ism": null, + "focusLock": null, + "fovLock": null, + "autofocus": null, + "scan": null, + "etSTED": null, + "rotators": null, + "microscopeStand": null, + "storage": null, + "pulseStreamer": { + "ipAddress": null + }, + "pyroServerInfo": null, + "rois": {}, + "ledPresets": {}, + "defaultLEDPresetForScan": null, + "laserPresets": {}, + "stageOffsets": {}, + "defaultLaserPresetForScan": null, + "availableWidgets": [ + "Settings", + "View", + "Recording", + "Image", + "Laser", + "Positioner" + ], + "nonAvailableWidgets": [], + "designerId": null +} diff --git a/imswitch/imcontrol/_test/unit/test_mmcore_managers.py b/imswitch/imcontrol/_test/unit/test_mmcore_managers.py index 5bc49fd4..636eb50c 100644 --- a/imswitch/imcontrol/_test/unit/test_mmcore_managers.py +++ b/imswitch/imcontrol/_test/unit/test_mmcore_managers.py @@ -6,64 +6,30 @@ from __future__ import annotations -import glob import os from unittest.mock import MagicMock import numpy as np import pytest -import os -import sys + pymmcore_plus = pytest.importorskip("pymmcore_plus") def _adapters_available() -> bool: - # differentitate based on OS - if sys.platform.startswith("win"): - candidates = [ - os.environ.get("MICROMANAGER_PATH", ""), - r"C:\Program Files\Micro-Manager\DeviceAdapters", - r"C:\Program Files (x86)\Micro-Manager\DeviceAdapters", - ] - elif sys.platform.startswith("darwin"): - candidates = [ - os.environ.get("MICROMANAGER_PATH", ""), - "/Applications/Micro-Manager.app/Contents/DeviceAdapters", - ] - else: - candidates = [ - os.environ.get("MICROMANAGER_PATH", ""), - "/opt/micro-manager/lib/micro-manager", - os.path.expanduser( - "~/mm-venv/lib/python3.*/site-packages/pymmcore_plus/install/Micro-Manager-*" - ), - os.path.expanduser( - "~/.local/share/pymmcore-plus/mm/Micro-Manager-*" - ), - ] - # pymmcore-plus also ships a helper to locate installed adapters. - try: - from pymmcore_plus import find_micromanager # type: ignore - - path = find_micromanager() - if path and os.path.isdir(path): - return True - except Exception: - pass - - for candidate in candidates: - if not candidate: - continue - for expanded in glob.glob(candidate): - if os.path.isdir(expanded): - return True - return False + """Cross-platform check that delegates to the production discovery code.""" + from imswitch.imcontrol.model.managers import MMCoreManager + + return bool(MMCoreManager.discover_adapter_paths()) pytestmark = pytest.mark.skipif( not _adapters_available(), - reason="No Micro-Manager device adapters found (set MICROMANAGER_PATH)", + reason=( + "No Micro-Manager 2.0 device adapters found. Install via " + "`pip install pymmcore-plus[cli] && mmcore install`, or set " + "MICROMANAGER_PATH to your MM 2.0 install directory." + ), ) @@ -134,16 +100,12 @@ def test_is_available(self): def test_get_available_adapters(self): from imswitch.imcontrol.model.managers import MMCoreManager - try: - from pymmcore_plus import find_micromanager - except Exception: - find_micromanager = None - path = find_micromanager() if find_micromanager else None - path = path or os.environ.get("MICROMANAGER_PATH") - if not path: - pytest.skip("Cannot locate Micro-Manager adapter directory") - adapters = MMCoreManager.get_available_adapters(path) - assert "DemoCamera" in adapters + # No explicit path → discover_adapter_paths() handles platform fallbacks. + adapters = MMCoreManager.get_available_adapters() + assert "DemoCamera" in adapters, ( + f"DemoCamera adapter not found. Discovered paths: " + f"{MMCoreManager.discover_adapter_paths()}" + ) # --------------------------------------------------------------------------- diff --git a/imswitch/imcontrol/controller/controllers/LaserController.py b/imswitch/imcontrol/controller/controllers/LaserController.py index 1a17bb5a..c0aaedb0 100644 --- a/imswitch/imcontrol/controller/controllers/LaserController.py +++ b/imswitch/imcontrol/controller/controllers/LaserController.py @@ -227,7 +227,7 @@ def getLaserValueRanges(self, laserName: str) -> List[Union[int, float, None]]: try: lManager = self._master.lasersManager[laserName] if lManager.isBinary: - return None + return [0, 1] else: return (lManager.valueRangeMin, lManager.valueRangeMax) except KeyError: diff --git a/imswitch/imcontrol/model/managers/MMCoreManager.py b/imswitch/imcontrol/model/managers/MMCoreManager.py index 354e088c..5c57d86f 100644 --- a/imswitch/imcontrol/model/managers/MMCoreManager.py +++ b/imswitch/imcontrol/model/managers/MMCoreManager.py @@ -14,6 +14,7 @@ from __future__ import annotations +import glob import os import sys import threading @@ -26,35 +27,149 @@ except ImportError: # pragma: no cover - exercised on minimal installs CMMCorePlus = None # type: ignore[assignment] +try: # pragma: no cover - depends on pymmcore-plus version + from pymmcore_plus import find_micromanager # type: ignore +except ImportError: # pragma: no cover + find_micromanager = None # type: ignore[assignment] + __all__ = [ "get_core", "ensure_loaded", "reload", + "ensure_core", "get_available_adapters", "get_available_devices_for_adapter", + "discover_adapter_paths", "is_available", ] -if sys.platform.startswith("win"): - _DEFAULT_ADAPTER_PATH = os.environ.get( - "MICROMANAGER_PATH", r"C:\Program Files\Micro-Manager-2.0" - ) -elif sys.platform.startswith("darwin"): - _DEFAULT_ADAPTER_PATH = os.environ.get( - "MICROMANAGER_PATH", "/Applications/Micro-Manager.app/Contents/DeviceAdapters" - ) -else: - _DEFAULT_ADAPTER_PATH = os.environ.get( - "MICROMANAGER_PATH", "/opt/micro-manager/lib/micro-manager" - ) +# --------------------------------------------------------------------------- +# Micro-Manager 2.0 is required. +# +# pymmcore-plus is built against the MM 2.0 device interface (currently v71+). +# MM 1.4 ``mmgr_dal_*`` libraries will fail to load with an "interface version +# mismatch" error. We only advertise MM 2.0 install locations below. +# --------------------------------------------------------------------------- +_MM2_WIN_GLOBS = [ + r"C:\Program Files\Micro-Manager-2.0*", + r"C:\Program Files (x86)\Micro-Manager-2.0*", +] +_MM2_MAC_GLOBS = [ + "/Applications/Micro-Manager-2.0*", + "/Applications/Micro-Manager-2.0*.app/Contents/Resources", + "/Applications/Micro-Manager.app/Contents/Resources", +] +_MM2_LINUX_GLOBS = [ + "/opt/micro-manager/lib/micro-manager", + "/opt/Micro-Manager-2.0*", + "/usr/local/lib/micro-manager", +] +# pymmcore-plus' own managed install location (`mmcore install`) – same on +# all platforms. +_PYMMCORE_PLUS_GLOBS = [ + os.path.expanduser("~/.local/share/pymmcore-plus/mm/Micro-Manager-*"), + os.path.expanduser("~/Library/Application Support/pymmcore-plus/mm/Micro-Manager-*"), + os.path.expandvars(r"%LOCALAPPDATA%\pymmcore-plus\mm\Micro-Manager-*"), +] _lock = threading.Lock() _loaded_cfg: Optional[str] = None _core = None # type: ignore[assignment] +_dll_dirs_added: set = set() _logger = initLogger("MMCoreManager", tryInheritParent=False) +def _add_windows_dll_dirs(paths: List[str]) -> None: + """On Windows, add adapter directories to the DLL search path. + + MM 2.0 adapters routinely depend on vendor SDK DLLs that live alongside + them in ``C:\\Program Files\\Micro-Manager-2.0``. Python 3.8+ no longer + looks at ``PATH`` for DLL resolution, so we explicitly add each adapter + directory via :func:`os.add_dll_directory`. + """ + if not sys.platform.startswith("win"): + return + for path in paths: + norm = os.path.normpath(path) + if norm in _dll_dirs_added or not os.path.isdir(norm): + continue + try: + os.add_dll_directory(norm) # type: ignore[attr-defined] + _dll_dirs_added.add(norm) + except (OSError, AttributeError): # pragma: no cover + pass + + +def _platform_globs() -> List[str]: + if sys.platform.startswith("win"): + return _MM2_WIN_GLOBS + _PYMMCORE_PLUS_GLOBS + if sys.platform.startswith("darwin"): + return _MM2_MAC_GLOBS + _PYMMCORE_PLUS_GLOBS + return _MM2_LINUX_GLOBS + _PYMMCORE_PLUS_GLOBS + + +def _looks_like_adapter_dir(path: str) -> bool: + """Return True if *path* contains at least one Micro-Manager adapter library.""" + if not path or not os.path.isdir(path): + return False + try: + for entry in os.listdir(path): + lower = entry.lower() + if lower.startswith(("libmmgr_dal_", "mmgr_dal_")) and lower.endswith( + (".so", ".dylib", ".dll") + ): + return True + except OSError: + return False + return False + + +def discover_adapter_paths() -> List[str]: + """Return a de-duplicated, ordered list of plausible MM 2.0 adapter dirs. + + Resolution order (first match wins, but every match is returned so that + ``setDeviceAdapterSearchPaths`` can fall through): + + 1. ``MICROMANAGER_PATH`` environment variable (single path). + 2. ``pymmcore_plus.find_micromanager()`` – pymmcore-plus' own discovery, + which knows about ``mmcore install`` managed installs. + 3. Platform-specific glob patterns for the standard MM 2.0 install + locations on Windows / macOS / Linux. + """ + seen: set = set() + paths: List[str] = [] + + def _add(p: Optional[str]) -> None: + if not p: + return + norm = os.path.normpath(p) + if norm in seen: + return + if _looks_like_adapter_dir(norm): + seen.add(norm) + paths.append(norm) + + _add(os.environ.get("MICROMANAGER_PATH")) + + if find_micromanager is not None: + try: + found = find_micromanager() + except Exception: # pragma: no cover + found = None + if isinstance(found, (list, tuple)): + for p in found: + _add(p) + else: + _add(found) + + for pattern in _platform_globs(): + for match in sorted(glob.glob(pattern), reverse=True): + _add(match) + + return paths + + def _require_pymmcore() -> None: if CMMCorePlus is None: raise ImportError( @@ -85,9 +200,47 @@ def get_core(): def _resolve_adapter_paths(adapter_paths: Optional[List[str]]) -> List[str]: + """Pick the adapter directories to feed ``setDeviceAdapterSearchPaths``. + + Explicit ``adapter_paths`` (typically from the setup JSON) always win. + Otherwise we run :func:`discover_adapter_paths` and, as a last resort, + fall back to a hard-coded MM 2.0 default per platform so that error + messages from MMCore at least mention a real-looking directory. + """ if adapter_paths: - return [p for p in adapter_paths if p] - return [_DEFAULT_ADAPTER_PATH] + explicit = [os.path.normpath(p) for p in adapter_paths if p] + if explicit: + return explicit + + discovered = discover_adapter_paths() + if discovered: + return discovered + + # Hard-coded last-resort defaults so MMCore produces a useful error. + if sys.platform.startswith("win"): + return [r"C:\Program Files\Micro-Manager-2.0"] + if sys.platform.startswith("darwin"): + return ["/Applications/Micro-Manager-2.0"] + return ["/opt/micro-manager/lib/micro-manager"] + + +def _verify_mm2(core) -> None: + """Log a clear warning if the loaded core does not look like MM 2.0.""" + try: + api = str(core.getAPIVersionInfo()) + except Exception: + return + # MM 2.0 API version strings look like "Device API version 71, Module API version 10". + # MM 1.4 reports much lower numbers. We only warn – don't refuse to run – + # because MM may bump these in future and our pin would become stale. + import re + + m = re.search(r"Device API version\s+(\d+)", api) + if m and int(m.group(1)) < 70: + _logger.warning( + f"Loaded MMCore reports '{api}'. ImSwitch requires Micro-Manager " + "2.0 (Device API >= 70). Adapters from MM 1.4 will fail to load." + ) def ensure_loaded(cfg_path: str, adapter_paths: Optional[List[str]] = None): @@ -116,7 +269,11 @@ def ensure_loaded(cfg_path: str, adapter_paths: Optional[List[str]] = None): if _loaded_cfg == cfg_path: return core - core.setDeviceAdapterSearchPaths(_resolve_adapter_paths(adapter_paths)) + resolved = _resolve_adapter_paths(adapter_paths) + _logger.info(f"Setting MMCore adapter search paths: {resolved}") + _add_windows_dll_dirs(resolved) + core.setDeviceAdapterSearchPaths(resolved) + _verify_mm2(core) if _loaded_cfg is not None: try: @@ -156,35 +313,44 @@ def ensure_core(adapter_paths: Optional[List[str]] = None): _require_pymmcore() core = get_core() with _lock: - core.setDeviceAdapterSearchPaths(_resolve_adapter_paths(adapter_paths)) + resolved = _resolve_adapter_paths(adapter_paths) + _logger.info(f"Setting MMCore adapter search paths: {resolved}") + _add_windows_dll_dirs(resolved) + core.setDeviceAdapterSearchPaths(resolved) + _verify_mm2(core) return core def get_available_adapters(adapter_path: Optional[str] = None) -> List[str]: """List the human-readable adapter names available on disk. - Scans ``adapter_path`` (or the default Micro-Manager install location) - for ``libmmgr_dal_*.so`` / ``mmgr_dal_*.dll`` / ``libmmgr_dal_*.dylib`` - files and returns the bare adapter names. + Scans ``adapter_path`` (or every directory returned by + :func:`discover_adapter_paths`) for ``libmmgr_dal_*.so`` / + ``mmgr_dal_*.dll`` / ``libmmgr_dal_*.dylib`` files and returns the + bare adapter names. """ - path = adapter_path or _DEFAULT_ADAPTER_PATH - if not os.path.isdir(path): - return [] + if adapter_path: + search_paths = [adapter_path] + else: + search_paths = discover_adapter_paths() adapters = set() - for entry in os.listdir(path): - name = entry - for prefix in ("libmmgr_dal_", "mmgr_dal_"): - if name.startswith(prefix): - name = name[len(prefix):] - break - else: + for path in search_paths: + if not os.path.isdir(path): continue - for suffix in (".so", ".dylib", ".dll"): - if name.endswith(suffix): - name = name[: -len(suffix)] - adapters.add(name) - break + for entry in os.listdir(path): + name = entry + for prefix in ("libmmgr_dal_", "mmgr_dal_"): + if name.startswith(prefix): + name = name[len(prefix):] + break + else: + continue + for suffix in (".so", ".dylib", ".dll"): + if name.endswith(suffix): + name = name[: -len(suffix)] + adapters.add(name) + break return sorted(adapters) diff --git a/imswitch/imcontrol/model/managers/detectors/DetectorManager.py b/imswitch/imcontrol/model/managers/detectors/DetectorManager.py index 0ce61a79..71638437 100644 --- a/imswitch/imcontrol/model/managers/detectors/DetectorManager.py +++ b/imswitch/imcontrol/model/managers/detectors/DetectorManager.py @@ -156,9 +156,8 @@ def setParameter(self, name: str, value: Any) -> Dict[str, DetectorParameter]: elif name == 'previewMaxValue': self.setMaxValueFramePreview(value) return - if name not in self.__parameters: - raise AttributeError(f'Non-existent parameter "{name}" specified') if name.find("posure")>0:name = "exposure" # TODO: Hacky fix for inconsistent naming + if name.find("ain")>0:name = "gain" # TODO: Hacky fix for inconsistent naming self.__parameters[name].value = value return self.parameters diff --git a/imswitch/imcontrol/model/managers/detectors/MMCoreDetectorManager.py b/imswitch/imcontrol/model/managers/detectors/MMCoreDetectorManager.py index b804bbfd..01a761e8 100644 --- a/imswitch/imcontrol/model/managers/detectors/MMCoreDetectorManager.py +++ b/imswitch/imcontrol/model/managers/detectors/MMCoreDetectorManager.py @@ -123,6 +123,10 @@ def __init__(self, detectorInfo, name, **lowLevelManagers): valueUnits="ms", ) + # additional flags + self._running = False + self._frameNunber = 0 + super().__init__( detectorInfo, name, @@ -203,7 +207,7 @@ def _build_parameters(self) -> Dict[str, DetectorParameter]: # ------------------------------------------------------------------ # Frame access # ------------------------------------------------------------------ - def getLatestFrame(self) -> np.ndarray: + def getLatestFrame(self, returnFrameNumber=False) -> np.ndarray: try: if self._core.getRemainingImageCount() > 0: return self._core.getLastImage() @@ -211,9 +215,14 @@ def getLatestFrame(self) -> np.ndarray: pass try: self._core.snap() + self._frameNunber += 1 + if returnFrameNumber: + return self._core.getImage(), self._frameNunber return self._core.getImage() except Exception: self._logger.error("Failed to snap a frame from MMCore", exc_info=True) + if returnFrameNumber: + return np.zeros(self._shape, dtype=np.uint16), -1 return np.zeros(self._shape, dtype=np.uint16) def getChunk(self) -> np.ndarray: @@ -240,11 +249,13 @@ def flushBuffers(self) -> None: def startAcquisition(self) -> None: if not self._core.isSequenceRunning(): self._core.startContinuousSequenceAcquisition(0) + self._running = True def stopAcquisition(self) -> None: try: if self._core.isSequenceRunning(): self._core.stopSequenceAcquisition() + self._running = False except Exception: self._logger.warning("Failed to stop MMCore acquisition", exc_info=True) diff --git a/imswitch/imcontrol/model/managers/positioners/MMCorePositionerManager.py b/imswitch/imcontrol/model/managers/positioners/MMCorePositionerManager.py index b559b125..d2ea63d9 100644 --- a/imswitch/imcontrol/model/managers/positioners/MMCorePositionerManager.py +++ b/imswitch/imcontrol/model/managers/positioners/MMCorePositionerManager.py @@ -108,22 +108,32 @@ def __init__(self, positionerInfo, name, **lowLevelManagers): # ------------------------------------------------------------------ # Movement # ------------------------------------------------------------------ - def move(self, dist, axis): - dist = float(dist) + def move(self, value=0, axis="X", is_absolute=False, is_blocking=True, acceleration=None, speed=None, isEnable=None, timeout=0, is_reduced=True): + dist = float(value) + # For relative moves, the distance is as given. For absolute moves, we need to calculate the distance to move from the current position. + if not is_absolute: + pass + else: + current_pos = self.getPosition(axis) + dist = dist - current_pos if axis in ("X", "Y") and self._xy_label: dx = dist if axis == "X" else 0.0 dy = dist if axis == "Y" else 0.0 self._core.setRelativeXYPosition(dx, dy) - self._core.waitForDevice(self._xy_label) + if is_blocking: + self._core.waitForDevice(self._xy_label) elif axis == "Z" and self._z_label: self._core.setRelativePosition(dist) - self._core.waitForDevice(self._z_label) + if is_blocking: + self._core.waitForDevice(self._z_label) else: self._logger.warning(f"Ignoring move on unsupported axis '{axis}'") return self._position[axis] new_pos = self.getPosition(axis) self._position[axis] = new_pos + self._commChannel.sigUpdateMotorPosition.emit() # TODO: This is a hacky workaround to force Imswitch to update the motor positions in the gui.. + return new_pos def setPosition(self, position, axis):