-
Notifications
You must be signed in to change notification settings - Fork 17
Description
We need to implement a focus-map pipeline that estimates a global Z-offset surface over XY and applies it automatically during scans. Add a FocusLockManager to persist map(s) and params, extend FocusLockController with map acquisition and lock/settle signaling, and integrate with ExperimentController and PositionerManager/ESP32StageManager to apply Z corrections on the fly. Provide API and socket signals for UI/automation. Include a watchdog that aborts experiments when focus lock goes out of bounds.
Goals
- Record focus at multiple XY points (autofocus or manual) → build a surface (initially 3-point plane; optional denser fit).
- Persist focus maps and focus-lock params (disk + in-memory cache).
- Apply Z-offset automatically during XY moves (pre-move correction + runtime lock).
- Provide lock state, setpoint, current error, and settled status via global commchannel signals.
- Expose map/params via
@APIExportand sockets for visualization and control. - Add a watchdog to stop an experiment if focus lock is unhealthy/out-of-band.
- Load/save focus-lock and map config on boot.
- Provide a Mock focus-lock when hardware/controller is absent.
Out of Scope (v1)
- Advanced surfaces (RBF/Splines, regional fits) → v2.
- UI widgets for map editing/visual overlay → minimal hooks & APIs only => we are going to implement this in the frontend anyway
- Laser autofocus specifics (can be consumed later via same interface - actually already implemented in the Focuslockcontroller)
Architecture Changes
New
-
FocusLockManager- Responsibilities: manage state, persistence, interpolation, parameter store.
- Storage: JSON file(s) under the current profile (e.g.,
~/.imswitch/focus/focusmap_<profile>.json). - Offers:
get_z_offset(x, y),get_params(),save_map(),load_map(),clear_map(),interpolate(point).
Extend
-
FocusLockController-
New: map acquisition flow (grid or manual points), emit signals, calculate/set settled. (interface should have min/max x/y positions, ngrid x/y)
-
New signals (global/commchannel):
focuslock/state→{locked: bool, enabled: bool}focuslock/setpoint→{z_ref_um: float}focuslock/error→{error_um: float, abs_error_um: float, pct: float}focuslock/settled→{settled: bool, band_um: float, timeout_ms: int}focusmap/updated→{n_points: int, method: "plane", ts: iso8601}focuslock/watchdog→{status: "ok"|"warn"|"abort", reason: str}
-
New APIExports:
start_focus_map_acquisition(grid_rows:int, grid_cols:int, margin:float=0.0)add_focus_point(x_um:float, y_um:float, z_um:float=None, autofocus:bool=True)fit_focus_map(method:str="plane")clear_focus_map()get_focus_map()get_focus_params() / set_focus_params({...})lock(enable: bool)(explicit lock/unlock)
-
Settling logic (band or timeout) computed inside controller (not UI).
-
-
ExperimentController
=> it should also work in case the focuslockcontroller is not avialble =>default values
-
Reads
FocusLockManagerfor each XY site:- If map exists: pre-move Z to
Z_ref + Z_map(x,y) + channel_offset. - Optionally enable live focus lock mode for continuous correction.
- If map exists: pre-move Z to
-
Subscribes to
focuslock/settledandfocuslock/watchdogto gate acquisitions and abort on fault. -
New config flags (per protocol):
use_focus_map: booluse_focus_lock_live: boolfocus_settle_band_um: float(default e.g., 1.0)focus_settle_timeout_ms: int(default e.g., 1500)channel_z_offsets: {channel_name: float_um}
-
PositionerManager/ESP32StageManager(optional)- Hook: pre-move callback to fetch Z offset and add channel offset.
- Provide atomic XY→Z move sequence or staged movement: move Z first or last as needed (configurable).
-
Mock (
MockFocusLockController)- Returns default zeros and
settled=True, allows testing without hardware.
- Returns default zeros and
Data & Persistence
Focus Map JSON (v1, plane)
{
"profile": "default",
"method": "plane",
"points": [
{"x_um": 0.0, "y_um": 0.0, "z_um": 12.3},
{"x_um": 1000.0, "y_um": 0.0, "z_um": 12.9},
{"x_um": 0.0, "y_um": 1000.0, "z_um": 11.8}
],
"fit": {
"plane": {"a": 0.0004, "b": -0.0005, "c": 12.3} // z = a*x + b*y + c
},
"channel_z_offsets": {"DAPI": 0.0, "FITC": 0.8, "TRITC": 1.2},
"created_at": "2025-09-18T06:40:00Z",
"updated_at": "2025-09-18T06:45:12Z"
}Focus-Lock Params JSON
{
"lock_enabled": true,
"z_ref_um": 12345.6,
"settle_band_um": 1.0,
"settle_timeout_ms": 1500,
"watchdog": {
"max_abs_error_um": 5.0,
"max_time_without_settle_ms": 5000,
"action": "abort"
}
}APIs & Signals
@APIExport (FocusLockController)
start_focus_map_acquisition(rows:int, cols:int, margin:float=0.0) -> dictadd_focus_point(x_um:float, y_um:float, z_um:Optional[float]=None, autofocus:bool=True) -> dictfit_focus_map(method:str="plane") -> dict # returns coefficientsclear_focus_map() -> dictget_focus_map() -> dictset_focus_params(params:dict) -> dictget_focus_params() -> dictlock(enable:bool) -> dictstatus() -> dict # {locked, settled, error_um, pct, setpoint}
Socket/Signals (commchannel topics)
focuslock/statefocuslock/setpointfocuslock/errorfocuslock/settledfocusmap/updatedfocuslock/watchdog
Settled rule: emit settled=True if abs_error_um ≤ settle_band_um continuously for ≥ settle_window_ms, else False. Also emit watchdog if abs_error_um > max_abs_error_um for more than max_time_without_settle_ms.
Core Algorithms
Plane fit (minimum viable)
Given N≥3 points (x_i, y_i, z_i), least-squares solve z = a x + b y + c.
Function: FocusLockManager._fit_plane(points) -> (a, b, c)
Interpolation
get_z_offset(x, y) = a*x + b*y + c (add per-channel offset in ExperimentController).
Integration Flow
-
Acquisition/Calibration
-
User triggers map acquisition (grid or manual).
-
For each XY:
- Move XY, autofocus (or manual), read Z →
add_focus_point.
- Move XY, autofocus (or manual), read Z →
-
Fit plane →
fit_focus_map→ persisted by manager →focusmap/updated.
-
-
During Scan
-
On XY site:
- Query
Z_correction = FocusLockManager.get_z_offset(x,y) + channel_offset. - Pre-position Z to
Z_ref + Z_correction. - If
use_focus_lock_live:lock(True)and wait forfocuslock/settled=True(respect timeout). - If not settled in time → watchdog → Experiment abort.
- Query
-
-
Fallback
- If FocusLockController missing →
MockFocusLockControllerreturns zero offsets andsettled=True.
- If FocusLockController missing →
Config (load on boot)
focuslock:
enabled: true
settle_band_um: 1.0
settle_timeout_ms: 1500
settle_window_ms: 200
watchdog:
max_abs_error_um: 5.0
max_time_without_settle_ms: 5000
action: abort
focusmap:
method: plane
path: ~/.imswitch/focus/focusmap_default.json
use_focus_map: true
experiment:
use_focus_lock_live: true
channel_z_offsets:
DAPI: 0.0
FITC: 0.8
TRITC: 1.2Code Skeletons
focus_lock_manager.py
class FocusLockManager:
def __init__(self, storage_path: Path):
self._map = None
self._params = {}
self._path = storage_path
def load_map(self) -> None: ...
def save_map(self) -> None: ...
def clear_map(self) -> None: ...
def add_point(self, x_um: float, y_um: float, z_um: float) -> None: ...
def fit(self, method: str = "plane") -> dict: ...
def get_z_offset(self, x_um: float, y_um: float) -> float: ...
def set_params(self, params: dict) -> None: ...
def get_params(self) -> dict: ...focus_lock_controller.py (additions)
class FocusLockController(ImConWidgetController):
settled = Signal(bool, dict) # {band_um, timeout_ms}
state = Signal(dict)
error = Signal(dict)
map_updated = Signal(dict)
watchdog = Signal(dict)
@APIExport
def start_focus_map_acquisition(self, rows:int, cols:int, margin:float=0.0): ...
@APIExport
def add_focus_point(self, x_um:float, y_um:float, z_um:float=None, autofocus:bool=True): ...
@APIExport
def fit_focus_map(self, method:str="plane"): ...
@APIExport
def get_focus_map(self): ...
@APIExport
def lock(self, enable: bool): ...
def _update_settled(self): ...
def _publish_error(self, err_um: float): ...experiment_controller.py (hooks)
if cfg.focusmap.use_focus_map:
z_corr = flm.get_z_offset(x_um, y_um) + channel_offsets.get(channel, 0.0)
self.stage.move_z_to(z_ref + z_corr)
if cfg.experiment.use_focus_lock_live:
foc.lock(True)
if not self._wait_for_settle(timeout_ms=cfg.focuslock.settle_timeout_ms):
self._abort("Focus did not settle")Watchdog Logic
- Trigger
warnifabs_error_um > settle_band_umfor> settle_timeout_ms. - Trigger
abortifabs_error_um > max_abs_error_umfor> max_time_without_settle_msor loss of lock signal. - Experiment listens and stops gracefully (close devices, flush buffers, mark run incomplete with reason).
Acceptance Criteria
- Given a recorded 3-point map,
get_z_offset(x,y)returns plane-interpolated Z with <0.2 µm RMS error on synthetic tests. - During multi-site acquisition with
use_focus_map: true, the stage pre-positions Z per site, and images are captured only afterfocuslock/settled=Trueor timeout (then abort). - Losing focus beyond
max_abs_error_umfor> max_time_without_settle_msemitsfocuslock/watchdog: abortand stops the experiment. - Map and params persist across restarts; APIs return current config and map.
- When FocusLock is absent, Mock path runs without exceptions and
settled=True.
Documentation
- New section: “Focus Map & Z-Offset Correction” (workflow, APIs, config).
- Signal reference (topics + payload).
- Example scripts (map acquisition + scan).
Open Questions
- Preferred persistence location under current ImSwitch profile?
- Atomic move order (Z-first vs Z-last) for your stages—default to Z-first?
- Default bands/timeouts (provide sensible defaults; tune on hardware).
- Channel naming source of truth (illumination manager vs acquisition config).