First-Time Setup Guide
This guide walks through everything required to run a PyCLM experiment on a new microscope system. By the end you will have:
A working Python environment with PyCLM installed
A hardware configuration file (
pyclm_config.toml)A
PositionMovermatched to your focus-maintenance hardwareA pair of experiment configuration files (
[experiment].tomlandschedule.toml)A position list exported from MicroManager
1. Install PyCLM
Create a virtual environment using uv and install pyclm:
git clone https://github.com/Harrison-Oatman/PyCLM.git
cd PyCLM
uv sync --group dev
To use CellposeSAM segmentation, install the optional cellpose extras (requires a CUDA-capable GPU):
uv sync --extra cellpose
2. Configure MicroManager
PyCLM drives hardware through pymmcore-plus, which loads a standard MicroManager .cfg file.
See MicroManager documentation
Before running PyCLM you must have:
A working MicroManager installation with device adapters for your camera, stage, and (if applicable) SLM.
A
.cfgfile that loads without errors in MicroManager. Run MicroManager once and use the Hardware Configuration Wizard to create and verify this file.Config groups for any device state that changes between imaging channels (e.g. filter wheels, laser lines). PyCLM switches channels by calling
setConfig(group, preset), so each illumination condition you intend to image needs a named preset in a named group.
PyCLM does not use MicroManager’s graphical interface at runtime — the .cfg file is the only dependency.
3. Create pyclm_config.toml
Place a pyclm_config.toml in the experiment directory or at the repository root. PyCLM searches the experiment directory first, then the working directory.
# Absolute path to your MicroManager .cfg file
config_path = "C:/Program Files/Micro-Manager-2.0/MyScope.cfg"
# SLM (DMD) pixel dimensions [height, width]
slm_shape_h = 1140
slm_shape_w = 912
# Affine transform from camera pixel coordinates to SLM pixel coordinates.
# This is a 2x3 matrix [[a, b, tx], [c, d, ty]].
# Obtain it by running the MicroManager Projector plugin calibration.
affine_transform = [[-0.29, -0.002, 939.9], [0.004, -0.579, 1505.2]]
If your microscope has no SLM, set the shape to the physical DMD resolution anyway — PyCLM will skip hardware calls when no SLM device is detected.
4. Choose or Implement a PositionMover
PyCLM needs to know how to move to an imaging position on your hardware. Three choices are available:
Class |
When to use |
|---|---|
|
Default. Simple XY + Z move, no focus maintenance. |
|
Nikon Ti2 with Perfect Focus System. |
Custom subclass |
Any other hardware-autofocus system. |
Using a built-in mover
Pass a mover instance to run_pyclm or Controller:
from pyclm import run_pyclm
from pyclm import PFSPositionMover # Nikon PFS
# from pyclm import BasicPositionMover # simple XYZ
run_pyclm(
"path/to/experiment_dir",
position_mover=PFSPositionMover(),
)
Writing a custom mover
Subclass PositionMover and implement move_to. The method receives a MicroscopePosition (with attributes x, y, z, and an extras dict for optional device values) and a core object that exposes the MicroManager API.
from pyclm import PositionMover, run_pyclm
class MyFocusMover(PositionMover):
def move_to(self, position, core) -> tuple[bool, float]:
# 1. Move XY
core.setXYPosition(position.x, position.y)
# 2. Move Z
core.setPosition(position.z)
# 3. Apply any optional hardware-specific values stored in extras.
# For example, a laser autofocus offset:
af_offset = position.extras.get("AFOffset")
if af_offset is not None:
core.setAutoFocusOffset(af_offset)
# ... poll or wait for hardware confirmation ...
# Return (z_was_adjusted: bool, actual_z: float)
return True, core.getZPosition()
run_pyclm("path/to/experiment_dir", position_mover=MyFocusMover())
The extras dict is populated from any devices in the position list beyond the XY and Z stages (see Section 5 below).
5. Export a Position List from MicroManager
Open MicroManager and navigate to Devices → Stage Position List. Add all imaging positions, assigning names that match your experiment TOML files (see Section 6). Save the list as PositionList.pos and place it in the experiment directory.
Position naming convention: Each position name must start with the stem of a .toml filename in the same directory, followed by - and any suffix. For example:
feedback_ctrl-pos1
feedback_ctrl-pos2
open_loop-pos1
These three positions would use feedback_ctrl.toml for the first two and open_loop.toml for the third.
PyCLM also supports the Nikon Elements multipoints.xml format (exported from the xy-positions tab of an NDAcquire). If both files are present, PositionList.pos takes precedence.
6. Write Experiment TOML Files
Each experiment type is described by a TOML file. Multiple positions can share the same experiment file; one experiment file can therefore run simultaneously at several locations.
Below is a fully annotated example for a feedback-controlled optogenetic experiment:
# ── Optional: device state applied to every channel in this experiment ──────
[config_groups]
# "GroupName" = "PresetName" (MicroManager config group)
Shutter = "Open"
[device_properties]
# "DeviceName-PropertyName" = value
# "Laser488-PowerSetpoint" = 5.0
# ── Imaging defaults (all channels inherit these unless overridden) ──────────
[imaging]
exposure = 50 # exposure time in milliseconds
every_t = 1 # acquire every N timepoints (1 = every timepoint)
save = true # write to HDF5
binning = 1 # camera binning (1, 2, or 4)
[imaging.config_groups]
# Config groups applied specifically to all imaging channels
LightPath = "Confocal"
# ── Channel definitions ──────────────────────────────────────────────────────
# "group" is the MicroManager config group used to switch channels.
# "presets" lists the presets within that group that will be imaged.
[channels]
group = "FP"
presets = ["GFP", "RFP"]
# Override imaging defaults for specific channels:
[channels.GFP]
exposure = 100
every_t = 1
[channels.RFP]
exposure = 50
every_t = 2 # image RFP half as often as GFP
# ── Stimulation channel ──────────────────────────────────────────────────────
# This is the light delivery channel — the DMD pattern is applied here.
[stimulation]
exposure = 200 # ms; set to 0 to deliver no stimulation
every_t = 1
[stimulation.config_groups]
LightPath = "DMD"
[stimulation.device_properties]
# "Sola-PowerSetpoint" = 20.0
# ── Segmentation (optional) ──────────────────────────────────────────────────
# Remove this section (or set method = "none") for open-loop experiments.
[segmentation]
method = "cellpose"
# Additional kwargs are forwarded to the segmentation method constructor:
# model = "cpsam" # cellpose built-in model
# model = "finetuned_mcf10a" # custom pre-trained model
# ── Pattern generation ───────────────────────────────────────────────────────
# "method" must match a registered PatternMethod name
# either built into pyclm (see documentation/method_zoo.md),
# or custom (see Section 10 below).
# All other keys are forwarded as constructor kwargs to the pattern method.
[pattern]
method = "circle"
rad = 150 # circle radius in µm
# ── Optional timing offsets (in timepoints, not seconds) ────────────────────
# t_delay = 5 # wait N timepoints before starting this experiment
# t_stop = 100 # stop after N timepoints (0 = run until schedule ends)
7. Write schedule.toml
Place one schedule.toml in the experiment directory. It controls the overall timing of the multi-experiment:
[timing]
steps = 120 # total number of timepoints
interval_seconds = 30.0 # time between consecutive timepoints
setup_time_seconds = 2.0 # delay before the first timepoint
time_between_positions = 2.0 # pause between consecutive positions within a timepoint
8. Experiment Directory Layout
Before running, your experiment directory should contain:
experiment_dir/
├── PositionList.pos # position list from MicroManager (preferred)
│ or multipoints.xml # legacy alternative
├── schedule.toml
├── feedback_ctrl.toml # one .toml per experiment type
├── open_loop.toml
└── pyclm_config.toml # optional; falls back to repository root
PyCLM writes output files alongside the configuration files:
experiment_dir/
├── feedback_ctrl.pos1.hdf5
├── feedback_ctrl.pos2.hdf5
├── open_loop.pos1.hdf5
└── log.log
9. Run the Experiment
From the command line:
uv run pyclm path/to/experiment_dir
Pass --config if pyclm_config.toml is not in the experiment directory or repository root:
uv run pyclm path/to/experiment_dir --config path/to/pyclm_config.toml
Use --dry to do a full rehearsal without connecting to the microscope (images are read from a tif-source/ folder in the working directory):
uv run pyclm path/to/experiment_dir --dry
Programmatically (required when using a custom PositionMover or custom pattern/segmentation methods):
from pyclm import run_pyclm, PFSPositionMover
run_pyclm(
"path/to/experiment_dir",
position_mover=PFSPositionMover(),
# segmentation_methods={"my_seg": MySegmentationMethod},
# pattern_methods={"my_pattern": MyPatternMethod},
)
The experiment can be aborted at any time with Ctrl+C. Data already written to HDF5 is not lost.
10. Custom Pattern and Segmentation Methods
For a more detailed explanation, see Writing custom pattern methods.
Pattern method
Subclass PatternMethod and implement generate. Call add_requirement in __init__ to declare what image data the method needs at each timepoint.
import numpy as np
from pyclm import PatternMethod
class MyPattern(PatternMethod):
name = "my_pattern" # used as method = "my_pattern" in the TOML
def __init__(self, threshold=0.5, **kwargs):
super().__init__(**kwargs)
self.threshold = threshold
# Request the raw GFP image and its segmentation at every timepoint:
self.add_requirement("GFP", raw=True, seg=True)
def generate(self, context) -> np.ndarray:
raw = context.raw("GFP") # np.ndarray, camera coordinates
seg = context.segmentation("GFP")
# Return a float32 array in [0, 1] with the same shape as the camera ROI.
pattern = (seg > 0).astype(np.float32)
return pattern
Register and run:
run_pyclm("path/to/experiment_dir", pattern_methods={"my_pattern": MyPattern})
Segmentation method
Subclass SegmentationMethod and implement segment:
import numpy as np
from pyclm import SegmentationMethod
class MySegmentation(SegmentationMethod):
name = "my_seg"
def __init__(self, experiment_name, threshold=128, **kwargs):
super().__init__(experiment_name)
self.threshold = threshold
def segment(self, data: np.ndarray) -> np.ndarray:
# Return a label image (integer array, 0 = background).
from skimage.measure import label
binary = data > self.threshold
return label(binary).astype(np.int32)
run_pyclm("path/to/experiment_dir", segmentation_methods={"my_seg": MySegmentation})
The method name is then available as method = "my_seg" in the [segmentation] block of any experiment TOML.