From 1810fa3bc2168ff091e297304573db4c13d2bd93 Mon Sep 17 00:00:00 2001 From: Eduardo Hirata-Miyasaki Date: Thu, 27 Jul 2023 14:01:34 -0700 Subject: [PATCH] Implement O3 autofocus on light-sheet arm (#48) * adding pythonnet and drift correction * removing the pythonnet dependency that should live in copylot * cleanup the demo code * move drift_correction.py to examples * add copylot to dependencies * add O3 stage setup * move serial numbers to acq_engine * rename drift_correction script * add defocus stack acquisition * style * update examples * refactor z stage move * add galvo scanning to acquire_ls_defocus_stack * Update acquire_defocus_stack.py * update examples * switch to pylablib stage and relative moves * add some logging * add KIM101 compensation * fix galvo reset position bug * add find_focus.py example * style * ignore tests in examples dir * move o3 refocus to acq_engine * add checks and logging * debug acq engine o3 refocus * update example * Create separate logs directory * move conda env logger to logs * rename to mantis_acquisition_log * save acquired stacks * implement timed o3 refocus * update kim101 calibration * add relative O3 travel limits * logger fixes * style * add waveorder to deps and format pyproject * update kim101 compensation factor * add threshold and plotting to focus finding * update waveorder dependency * add microscope_operations documentation * update data structure specs * make acquire_ls_defocus_stack MantisAcquisition static method * update examples * rename_kim101 example * rename z_range vars to avoid confusion --------- Co-authored-by: Uditha Velidandla --- docs/data_structure.md | 8 +- examples/acquire_defocus_stack.py | 48 ++++ examples/calibrate_kim101.py | 71 +++++ examples/test_find_focus.py | 24 ++ examples/test_kim101_copylot.py | 34 +++ examples/test_kim101_pylablib.py | 15 + mantis/acquisition/AcquisitionSettings.py | 3 + mantis/acquisition/acq_engine.py | 213 +++++++++++++- mantis/acquisition/microscope_operations.py | 300 +++++++++++++++++++- pyproject.toml | 71 +++-- 10 files changed, 735 insertions(+), 52 deletions(-) create mode 100644 examples/acquire_defocus_stack.py create mode 100644 examples/calibrate_kim101.py create mode 100644 examples/test_find_focus.py create mode 100644 examples/test_kim101_copylot.py create mode 100644 examples/test_kim101_pylablib.py diff --git a/docs/data_structure.md b/docs/data_structure.md index 901dea41..6ba663e2 100644 --- a/docs/data_structure.md +++ b/docs/data_structure.md @@ -11,8 +11,6 @@ Organization of the raw data is constrained by the `pycromanager`-based acquisit YYYY_MM_DD |--- _ -| |--- mantis_acquisition_log_YYYYMMDDTHHMMSS.txt -| | |--- positions.csv | | |--- platemap.csv @@ -31,6 +29,10 @@ YYYY_MM_DD | |--- _lightsheet_NDTiffStack_1.tif | ... | +| |--- logs # contains acquisition logs +| |--- mantis_acquisition_log_YYYYMMDDTHHMMSS.txt +| |--- conda_environment_log_YYYYMMDDTHHMMSS.txt +| |--- _ # one experiment folder may contain multiple acquisitions | ... | @@ -41,7 +43,7 @@ YYYY_MM_DD ``` -An example dataset is provided in: `//ESS/comp_micro/rawdata/mantis/2023_02_21_mantis_dataset_standard/`. +An example dataset is provided in: `//ESS/comp_micro/rawdata/mantis/2023_02_21_mantis_dataset_standard/`. (TODO: this example is now outdates) Each acquisition will contain a PTCZYX dataset; some dimensions may be singleton. diff --git a/examples/acquire_defocus_stack.py b/examples/acquire_defocus_stack.py new file mode 100644 index 00000000..4b85cd25 --- /dev/null +++ b/examples/acquire_defocus_stack.py @@ -0,0 +1,48 @@ +import numpy as np +from pycromanager import Core, Studio +from mantis.acquisition.microscope_operations import ( + setup_kim101_stage, + acquire_ls_defocus_stack_and_display, + set_relative_kim101_position, +) +from waveorder.focus import focus_from_transverse_band + +mmc = Core() +mmStudio = Studio() +z_start = -105 +z_end = 105 +z_step = 15 +galvo = 'AP Galvo' +galvo_range = [-0.5, 0, 0.5] + +z_stage = setup_kim101_stage('74000291') +z_range = np.arange(z_start, z_end + z_step, z_step) + +# run 5 times over +for i in range(5): + data = acquire_ls_defocus_stack_and_display( + mmc, + mmStudio, + z_stage, + z_range, + galvo, + galvo_range, + close_display=False, + ) + + focus_indices = [] + for stack in data: + idx = focus_from_transverse_band( + stack, NA_det=1.35, lambda_ill=0.55, pixel_size=6.5/40/1.4 + ) + focus_indices.append(idx) + + valid_focus_indices = [idx for idx in focus_indices if idx is not None] + print(f'Valid focus indices: {valid_focus_indices}') + + focus_idx = int(np.median(valid_focus_indices)) + o3_displacement = int(z_range[focus_idx]) + print(f'O3 displacement: {o3_displacement} steps') + + set_relative_kim101_position(z_stage, o3_displacement) + diff --git a/examples/calibrate_kim101.py b/examples/calibrate_kim101.py new file mode 100644 index 00000000..1edc3e09 --- /dev/null +++ b/examples/calibrate_kim101.py @@ -0,0 +1,71 @@ +# Calibration procedure + +# Image a 1 um fluorescent bead with epi illumination and LS detection. Focus O3 +# on the bead. This script will defocus on one side of the bead and measure the +# image intensity. The stage calibration factor is determined from the +# difference in slope of average image intensity vs z position when traveling +# in the positive or negative direction + +# This calibration procedure works alright, but could be improved + +#%% +import numpy as np +from pycromanager import Core, Studio +import matplotlib.pyplot as plt +from mantis.acquisition.microscope_operations import setup_kim101_stage, acquire_ls_defocus_stack_and_display + +#%% +mmc = Core() +mmStudio = Studio() +z_stage = setup_kim101_stage('74000291') + +z_start = 0 +z_end = 105 +z_step = 15 +galvo = 'AP Galvo' +galvo_range = [0]*5 + +z_range = np.hstack( + ( + np.arange(z_start, z_end + z_step, z_step), + np.arange(z_end, z_start - z_step, -z_step) + ) +) + +#%% +data = acquire_ls_defocus_stack_and_display( + mmc, + mmStudio, + z_stage, + z_range, + galvo, + galvo_range, + close_display = False +) + +# %% +steps_per_direction = len(z_range)//2 +intensity = data.max(axis=(-1, -2)) + +pos_int = intensity[:, :steps_per_direction] +pos_z = z_range[:steps_per_direction] + +neg_int = intensity[:, steps_per_direction:] +neg_z = z_range[steps_per_direction:] + +A = np.vstack([pos_z, np.ones(len(pos_z))]).T +pos_slope = [] +neg_slope = [] +for i in range(len(galvo_range)): + m, c = np.linalg.lstsq(A, pos_int[i], rcond=None)[0] + pos_slope.append(m) + m, c = np.linalg.lstsq(np.flipud(A), neg_int[i], rcond=None)[0] + neg_slope.append(m) + +compensation_factor = np.mean(pos_slope) / np.mean(neg_slope) +print(compensation_factor) + +# %% +plt.plot(intensity.flatten()) + +# %% diff --git a/examples/test_find_focus.py b/examples/test_find_focus.py new file mode 100644 index 00000000..3a00820f --- /dev/null +++ b/examples/test_find_focus.py @@ -0,0 +1,24 @@ +import os +import tifffile +import glob +import napari +import numpy as np +from waveorder.focus import focus_from_transverse_band + +data_path = r'D:\2023_07_07_O3_autofocus' +dataset = 'kidney_rfp_fov0' + +viewer = napari.Viewer() +files = glob.glob(os.path.join(data_path, dataset, '*.ome.tif')) + +data = [] +points = [] +for i, file in enumerate(files): + stack = tifffile.imread(file, is_ome=False) + focus_idx = focus_from_transverse_band(stack, NA_det=1.35, lambda_ill=0.55, pixel_size=6.5/(40*1.4)) + data.append(stack) + points.append([i, focus_idx, 50, 50]) + +viewer.add_image(np.asarray(data)) +viewer.add_points(np.asarray(points), size=20) +napari.run() \ No newline at end of file diff --git a/examples/test_kim101_copylot.py b/examples/test_kim101_copylot.py new file mode 100644 index 00000000..035dda7d --- /dev/null +++ b/examples/test_kim101_copylot.py @@ -0,0 +1,34 @@ +# This script tests controlling the KIM101 O3 stage using copylot. Ivan found +# that copylot control of the stage runs into errors after ~100 relative moves +# of the stage. We currently control the stage with pylablib and have not run +# into such problems + +from copylot.hardware.stages.thorlabs.KIM001 import KCube_PiezoInertia +import time +from copylot import logger +# from waveorder.focus import focus_from_transverse_band + + +def test_labelfree_stage(): + with KCube_PiezoInertia(serial_number='74000565', simulator=False) as stage_LF: + print(f'LF current position {stage_LF.position}') + stage_LF.move_relative(10) + + +def test_light_sheet_stage(): + ### LIGHT SHEET STAGE + with KCube_PiezoInertia(serial_number='74000291', simulator=False) as stage_LS: + + # Change the acceleration and step rate + stage_LS.step_rate = 500 + stage_LS.step_acceleration = 1000 + + # Test relative movement + step_size = -10 + for i in range(10): + stage_LS.move_relative(10) + # stage_LS.move_relative(-step_size) + + +if __name__ == '__main__': + test_light_sheet_stage() diff --git a/examples/test_kim101_pylablib.py b/examples/test_kim101_pylablib.py new file mode 100644 index 00000000..547998b4 --- /dev/null +++ b/examples/test_kim101_pylablib.py @@ -0,0 +1,15 @@ +from pylablib.devices import Thorlabs +devices = Thorlabs.list_kinesis_devices() + +stage = Thorlabs.KinesisPiezoMotor('74000291') + +p = stage.get_position() +for i in range(50): + # stage.move_to(p+25); stage.wait_move() + # stage.move_to(p-25); stage.wait_move() + + # relative moves work better + stage.move_by(25); stage.wait_move() + stage.move_by(-25); stage.wait_move() + +print('done') diff --git a/mantis/acquisition/AcquisitionSettings.py b/mantis/acquisition/AcquisitionSettings.py index cfc7b16c..d1431bc0 100644 --- a/mantis/acquisition/AcquisitionSettings.py +++ b/mantis/acquisition/AcquisitionSettings.py @@ -80,3 +80,6 @@ class MicroscopeSettings: use_autofocus: bool = False autofocus_stage: Optional[str] = None autofocus_method: Optional[str] = None + use_o3_refocus: bool = False + o3_refocus_config: Optional[ConfigSettings] = None + o3_refocus_interval_min: Optional[int] = None diff --git a/mantis/acquisition/acq_engine.py b/mantis/acquisition/acq_engine.py index e8c8cf1c..cb403cf6 100644 --- a/mantis/acquisition/acq_engine.py +++ b/mantis/acquisition/acq_engine.py @@ -6,12 +6,15 @@ from dataclasses import asdict from datetime import datetime from functools import partial +from typing import Iterable import nidaqmx import numpy as np +import tifffile from nidaqmx.constants import Slope from pycromanager import Acquisition, Core, Studio, multi_d_acquisition_events, start_headless +from waveorder.focus import focus_from_transverse_band from mantis.acquisition import microscope_operations from mantis.acquisition.logger import configure_logger, log_conda_environment @@ -47,6 +50,11 @@ MCL_STEP_TIME = 1.5 # in ms LC_CHANGE_TIME = 20 # in ms LS_CHANGE_TIME = 200 # time needed to change LS filter wheel, in ms +LS_KIM101_SN = 74000291 +LF_KIM101_SN = 74000565 + +NA_DETECTION = 1.35 +LS_PIXEL_SIZE = 6.5 / (40 * 1.4) # in um logger = logging.getLogger(__name__) @@ -85,6 +93,7 @@ def __init__( self.type = 'light-sheet' if self.headless else 'label-free' self.mmc = None self.mmStudio = None + self.o3_stage = None logger.debug(f'Initializing {self.type} acquisition engine') if enabled: @@ -183,6 +192,10 @@ def setup(self): self.mmc, 'Core', 'Focus', self.slice_settings.z_stage_name ) + # Setup O3 scan stage + if self.microscope_settings.use_o3_refocus: + self.o3_stage = microscope_operations.setup_kim101_stage(LS_KIM101_SN) + # Note: sequencing should be turned off by default # Setup z sequencing if self.slice_settings.use_sequencing: @@ -281,32 +294,33 @@ def __init__( if not enable_lf_acq or not enable_ls_acq: raise Exception('Disabling LF or LS acquisition is not currently supported') - # Create acquisition directory + # Create acquisition directory and log directory self._acq_dir = _create_acquisition_directory(self._root_dir, self._acq_name) + self._logs_dir = os.path.join(self._acq_dir, 'logs') + os.mkdir(self._logs_dir) # Setup logger timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") - configure_logger( - os.path.join(self._acq_dir, f'mantis_acquisition_log_{timestamp}.txt') - ) - - # initialize time and position settings - self._time_settings = TimeSettings() - self._position_settings = PositionSettings() + acq_log_path = os.path.join(self._logs_dir, f'mantis_acquisition_log_{timestamp}.txt') + configure_logger(acq_log_path) if self._demo_run: logger.info('NOTE: This is a demo run') - logger.debug(f'Starting mantis acquisition log at: {self._acq_dir}') + logger.debug(f'Starting mantis acquisition log at: {acq_log_path}') # Log conda environment outs, errs = log_conda_environment( - os.path.join(self._acq_dir, f'conda_environment_log_{timestamp}.txt') + os.path.join(self._logs_dir, f'conda_environment_log_{timestamp}.txt') ) if errs is None: logger.debug(outs.decode('ascii').strip()) else: logger.error(errs.decode('ascii')) + # initialize time and position settings + self._time_settings = TimeSettings() + self._position_settings = PositionSettings() + # Connect to MM running LF acq self.lf_acq = BaseChannelSliceAcquisition( enabled=enable_lf_acq, @@ -599,6 +613,167 @@ def go_to_position(self, position_index: int): self.lf_acq.microscope_settings.autofocus_stage, ) + @staticmethod + def acquire_ls_defocus_stack( + mmc: Core, + z_stage, + z_range: Iterable, + galvo: str, + galvo_range: Iterable, + config_group: str = None, + config_name: str = None, + ): + """Acquire defocus stacks at different galvo positions and return image data + + Parameters + ---------- + mmc : Core + mmStudio : Studio + z_stage : str or KinesisPiezoMotor + z_range : Iterable + galvo : str + galvo_range : Iterable + config_group : str, optional + config_name : str, optional + + Returns + ------- + data : np.array + + """ + data = [] + + # Set config + if config_name is not None: + mmc.set_config(config_group, config_name) + mmc.wait_for_config(config_group, config_name) + + # Open shutter + auto_shutter_state, shutter_state = microscope_operations.get_shutter_state(mmc) + microscope_operations.open_shutter(mmc) + + # get galvo starting position + p0 = mmc.get_position(galvo) + + # set camera to internal trigger + # TODO: do this properly, context manager? + microscope_operations.set_property( + mmc, 'Prime BSI Express', 'TriggerMode', 'Internal Trigger' + ) + + # acquire stack at different galvo positions + for p_idx, p in enumerate(galvo_range): + # set galvo position + mmc.set_position(galvo, p0 + p) + + # acquire defocus stack + z_stack = microscope_operations.acquire_defocus_stack(mmc, z_stage, z_range) + data.append(z_stack) + + # Reset camera triggering + microscope_operations.set_property( + mmc, 'Prime BSI Express', 'TriggerMode', 'Edge Trigger' + ) + + # Reset galvo + mmc.set_position(galvo, p0) + + # Reset shutter + microscope_operations.reset_shutter(mmc, auto_shutter_state, shutter_state) + + return np.asarray(data) + + def refocus_ls_path(self): + logger.info('Running O3 refocus algorithm on light-sheet arm') + + # Define O3 z range + # 1 step is approx 20 nm, 15 steps are 300 nm which is sub-Nyquist sampling + # The stack starts away from O2 and moves closer + o3_z_start = -105 + o3_z_end = 105 + o3_z_step = 15 + o3_z_range = np.arange(o3_z_start, o3_z_end + o3_z_step, o3_z_step) + + # Define relative travel limits, in steps + o3_z_stage = self.ls_acq.o3_stage + target_z_position = o3_z_stage.true_position + o3_z_range + max_z_position = 500 # O3 is allowed to travel ~10 um towards O2 + min_z_position = -1000 # O3 is allowed to travel ~20 um away from O2 + if np.any(target_z_position > max_z_position) or np.any( + target_z_position < min_z_position + ): + logger.error('O3 relative travel limits will be exceeded. Aborting O3 refocus.') + return + + # Define galvo range, i.e. galvo positions at which O3 defocus stacks + # are acquired, here at 30%, 50%, and 70% of galvo range. Should be odd number + galvo_scan_range = self.ls_acq.slice_settings.z_range + len_galvo_scan_range = len(galvo_scan_range) + galvo_range = [ + galvo_scan_range[int(0.3 * len_galvo_scan_range)], + galvo_scan_range[int(0.5 * len_galvo_scan_range)], + galvo_scan_range[int(0.7 * len_galvo_scan_range)], + ] + + # Acquire defocus stacks at several galvo positions + data = self.acquire_ls_defocus_stack( + mmc=self.ls_acq.mmc, + z_stage=o3_z_stage, + z_range=o3_z_range, + galvo=self.ls_acq.slice_settings.z_stage_name, + galvo_range=galvo_range, + config_group=self.ls_acq.microscope_settings.o3_refocus_config.config_group, + config_name=self.ls_acq.microscope_settings.o3_refocus_config.config_name, + ) + + # Save acquired stacks in logs + timestamp = datetime.now().strftime("%Y%m%dT%H%M%S") + tifffile.imwrite( + os.path.join(self._logs_dir, f'ls_refocus_data_{timestamp}.ome.tif'), + np.expand_dims(data, -3).astype('uint16'), + ) + + # Find in-focus slice + wavelength = 0.55 # in um, approx + # works well to distinguish between noise and sample when using z_step = 15 + # the idea is that true features in the sample will come in focus slowly + threshold_FWHM = 3.0 + + focus_indices = [] + for stack_idx, stack in enumerate(data): + idx = focus_from_transverse_band( + stack, + NA_det=NA_DETECTION, + lambda_ill=wavelength, + pixel_size=LS_PIXEL_SIZE, + threshold_FWHM=threshold_FWHM, + plot_path=os.path.join( + self._logs_dir, f'ls_refocus_plot_{timestamp}_Pos{stack_idx}.png' + ), + ) + focus_indices.append(idx) + logger.debug( + 'Stacks at galvo positions %s are in focus at slice %s', + np.round(galvo_range, 3), + focus_indices, + ) + + # Refocus O3 + # Some focus_indices may be None, e.g. if there is no sample + valid_focus_indices = [idx for idx in focus_indices if idx is not None] + if valid_focus_indices: + focus_idx = int(np.median(valid_focus_indices)) + o3_displacement = int(o3_z_range[focus_idx]) + + logger.info(f'Moving O3 by {o3_displacement} steps') + microscope_operations.set_relative_kim101_position( + self.ls_acq.o3_stage, o3_displacement + ) + else: + logger.error( + 'Could not determine the correct O3 in-focus position. O3 will not move' + ) + def setup(self): """ Setup the mantis acquisition. This method sets up the label-free @@ -703,8 +878,9 @@ def acquire(self): ) logger.info('Starting acquisition') + ls_o3_refocus_time = time.time() for t_idx in range(self.time_settings.num_timepoints): - t_start = time.time() + timepoint_start_time = time.time() for p_idx in range(self.position_settings.num_positions): p_label = self.position_settings.position_labels[p_idx] @@ -726,6 +902,19 @@ def acquire(self): ) continue + # O3 refocus + # Failing to refocus O3 will not abort the acquisition at the current PT index + if self.ls_acq.microscope_settings.use_o3_refocus: + current_time = time.time() + # Always refocus at the start + if ( + (t_idx == 0 and p_idx == 0) + or current_time - ls_o3_refocus_time + > self.ls_acq.microscope_settings.o3_refocus_interval_min * 60 + ): + self.refocus_ls_path() + ls_o3_refocus_time = current_time + # start acquisition lf_events = deepcopy(lf_cz_events) for _event in lf_events: @@ -746,7 +935,7 @@ def acquire(self): self.await_cz_acq_completion() # wait for time interval between time points - t_wait = self.time_settings.time_interval_s - (time.time() - t_start) + t_wait = self.time_settings.time_interval_s - (time.time() - timepoint_start_time) if t_wait > 0 and t_idx < self.time_settings.num_timepoints - 1: logger.info(f"Waiting {t_wait/60:.2f} minutes until the next time point") time.sleep(t_wait) diff --git a/mantis/acquisition/microscope_operations.py b/mantis/acquisition/microscope_operations.py index 840cd865..63da24e6 100644 --- a/mantis/acquisition/microscope_operations.py +++ b/mantis/acquisition/microscope_operations.py @@ -1,14 +1,20 @@ import logging import time -from typing import Tuple +from functools import partial +from typing import Iterable, Tuple import nidaqmx +import numpy as np from nidaqmx.constants import AcquisitionType +from pycromanager import Core, Studio +from pylablib.devices.Thorlabs import KinesisPiezoMotor logger = logging.getLogger(__name__) +KIM101_COMPENSATION_FACTOR = 1.03 + def _try_mmc_call(mmc, mmc_call_name, *mmc_carr_args): """Wrapper that tries to repeat calls to mmCore if they fail. Largely copied @@ -218,3 +224,295 @@ def autofocus(mmc, mmStudio, z_stage_name: str, z_position): logger.error(f'Autofocus call failed after {len(z_offsets)} tries') return autofocus_success + + +def setup_kim101_stage(serial_number: int, max_voltage=112, velocity=500, acceleration=1000): + """Setup stage on a KIM101 with given drive parameters + + Parameters + ---------- + serial_number : int + 8-digit serial number of the KIM101 controller + max_voltage : int, optional + Max drive voltage in units of Volts, by default 112 + velocity : int, optional + Drive velocity in unit of steps per second, by default 500 + acceleration : int, optional + Drive acceleration in units of steps/s^2, by default 1000 + + Returns + ------- + stage : KinesisPiezoMotor + + """ + logger.debug(f'Setting up Kinesis Piezo Motor stage with serial number {serial_number}') + stage = KinesisPiezoMotor(str(serial_number)) + + # Set drive parameters + logger.debug( + 'Applying drive parameters max voltage: %s, velocity: %s, acceleration: %s', + max_voltage, + velocity, + acceleration, + ) + stage.setup_drive(max_voltage, velocity, acceleration) + + # manually keep track of the stage true position based on the relative + # number of steps in each direction + stage.true_position = 0 + + return stage + + +def set_relative_kim101_position( + stage: KinesisPiezoMotor, + distance: int, +): + """Make a relative move with a KIM101 stage, compensating for different + travel distance per step in the positive and negative directions + + Parameters + ---------- + stage : KinesisPiezoMotor + distance : int + Travel distance, in number of steps + """ + + # keep track of the stage actual position, in steps, not accounting for the + # compensation factor + stage.true_position += distance + + if distance < 0: + distance *= KIM101_COMPENSATION_FACTOR + + stage.move_by(int(distance)) + stage.wait_move() + + +def create_ram_datastore( + mmStudio: Studio, +): + """Create a Micro-manager RAM datastore and associate a display window with it + + Parameters + ---------- + mmStudio : Studio + + Returns + ------- + datastore + + """ + datastore = mmStudio.get_data_manager().create_ram_datastore() + mmStudio.get_display_manager().create_display(datastore) + + return datastore + + +def acquire_defocus_stack( + mmc: Core, + z_stage, + z_range: Iterable, + mmStudio: Studio = None, + datastore=None, + channel_ind: int = 0, + position_ind: int = 0, +): + """Snap image at every z position and put image in a Micro-manager datastore + + Parameters + ---------- + mmc : Core + z_stage : str or coPylot stage object + z_range : Iterable + mmStudio : Studio, optional + If not None, images will be added to a Micro-manager RAM datastore and + displayed. If None, acquired images will only be returned as np.array + datastore : micromanager.data.Datastore, optional + Micro-manager datastore object + channel_ind : int, optional + Channel index of acquired images in the Micro-manager datastore, by default 0 + position_ind : int, optional + Position index of acquired images in the Micro-manager datastore, by default 0 + + Returns + ------- + data : np.array + + """ + data = [] + relative_z_steps = np.hstack((z_range[0], np.diff(z_range))) + + # get z0 and define move_z callable for the given stage + if isinstance(z_stage, str): + # this is a MM stage + move_z = partial(mmc.set_relative_position, z_stage) # test if this works + elif isinstance(z_stage, KinesisPiezoMotor): + # this is a pylablib stage + move_z = partial(set_relative_kim101_position, z_stage) + else: + raise RuntimeError(f'Unknown z stage: {z_stage}') + + for z_ind, rel_z in enumerate(relative_z_steps): + # set z position + move_z(rel_z) + + # snap image + mmc.snap_image() + tagged_image = mmc.get_tagged_image() + + # get image data + image_data = np.reshape( + tagged_image.pix, (tagged_image.tags['Height'], tagged_image.tags['Width']) + ) + data.append(image_data.astype('uint16')) + + # set image coordinates and put in datastore + if mmStudio is not None: + image = mmStudio.get_data_manager().convert_tagged_image(tagged_image) + coords_builder = image.get_coords().copy() + coords_builder = coords_builder.z(z_ind) + coords_builder = coords_builder.channel(channel_ind) + coords_builder = coords_builder.stage_position(position_ind) + mm_coords = coords_builder.build() + + image = image.copy_at_coords(mm_coords) + datastore.put_image(image) + + # reset z stage + move_z(-relative_z_steps.sum()) + + return np.asarray(data) + + +def acquire_ls_defocus_stack_and_display( + mmc: Core, + mmStudio: Studio, + z_stage: str or KinesisPiezoMotor, + z_range: Iterable, + galvo: str, + galvo_range: Iterable, + config_group: str = None, + config_name: str = None, + close_display: bool = True, +): + """Utility function similar to MantisAcquisition.acquire_ls_defocus_stack + which can acquire O3 defocus stacks at multiple galvo positions and + display them in a Micro-manager window + + Parameters + ---------- + mmc : Core + mmStudio : Studio + z_stage : str or KinesisPiezoMotor + z_range : Iterable + galvo : str + galvo_range : Iterable + config_group : str, optional + config_name : str, optional + close_display : bool, optional + + Returns + ------- + data : np.array + + """ + datastore = create_ram_datastore(mmStudio) + data = [] + + # Set config + if config_name is not None: + mmc.set_config(config_group, config_name) + mmc.wait_for_config(config_group, config_name) + + # Open shutter + auto_shutter_state, shutter_state = get_shutter_state(mmc) + open_shutter(mmc) + + # get galvo starting position + p0 = mmc.get_position(galvo) + + # acquire stack at different galvo positions + for p_idx, p in enumerate(galvo_range): + # set galvo position + mmc.set_position(galvo, p0 + p) + + # acquire defocus stack + z_stack = acquire_defocus_stack( + mmc, z_stage, z_range, mmStudio, datastore, channel_ind=0, position_ind=p_idx + ) + data.append(z_stack) + + # freeze datastore to indicate that we are finished writing to it + datastore.freeze() + + # Reset galvo + mmc.set_position(galvo, p0) + + # Reset shutter + reset_shutter(mmc, auto_shutter_state, shutter_state) + + # Close datastore and associated displays; if close_display=False, display + # window must be manually closed + if close_display: + datastore.close() + + return np.asarray(data) + + +def get_shutter_state(mmc: Core): + """Return the current state of the shutter + + Parameters + ---------- + mmc : Core + + Returns + ------- + auto_shutter_state : bool + shutter_state : bool + + """ + auto_shutter_state = mmc.get_auto_shutter() + shutter_state = mmc.get_shutter_open() + + return auto_shutter_state, shutter_state + + +def open_shutter(mmc: Core): + """Open shutter if mechanical shutter exists + + Parameters + ---------- + mmc : Core + + """ + + shutter_device = mmc.get_shutter_device() + if shutter_device: + logger.debug(f'Opening shutter {shutter_device}') + mmc.set_auto_shutter(False) + mmc.set_shutter_open(True) + + +def reset_shutter(mmc: Core, auto_shutter_state: bool, shutter_state: bool): + """Reset shutter if mechanical shutter exists + + Parameters + ---------- + mmc : Core + auto_shutter_state : bool + shutter_state : bool + + """ + + shutter_device = mmc.get_shutter_device() + if shutter_device: + logger.debug( + 'Resetting shutter %s to state Open:%s, Autoshutter: %s', + shutter_device, + shutter_state, + auto_shutter_state, + ) + mmc.set_shutter_open(shutter_state) + mmc.set_auto_shutter(auto_shutter_state) diff --git a/pyproject.toml b/pyproject.toml index 4719e8b1..611ef7d9 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,58 +1,54 @@ [build-system] -requires = [ - "setuptools >= 42", - "wheel", - "setuptools_scm[toml]>=3.4" -] +requires = ["setuptools >= 42", "setuptools_scm[toml]>=3.4", "wheel"] [project] -name = "mantis" +name = "mantis" description = "Acquisition engine for collecting data on the mantis microscope" readme = "README.md" -license = {file = "LICENSE"} +license = { file = "LICENSE" } requires-python = ">=3.10, <4.0" # the dynamically determined project metadata attributes dynamic = ["version"] classifiers = [ - "Development Status :: 3 - Alpha", - "Intended Audience :: Science/Research", - "Programming Language :: Python :: 3 :: Only", - "Programming Language :: Python :: 3.10" + "Development Status :: 3 - Alpha", + "Intended Audience :: Science/Research", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.10", ] # list package dependencies here dependencies = [ - "numpy", - "pydantic", - "pycromanager==0.25.40", - "nidaqmx", - "iohub==0.1.0.dev3", - "scipy", - "slurmkit @ git+https://github.com/royerlab/slurmkit", - "natsort", - "napari[all]; 'arm64' not in platform_machine", # with Qt5 and skimage - "napari; 'arm64' in platform_machine", # without Qt5 and skimage - "PyQt6; 'arm64' in platform_machine", - "matplotlib", + "iohub==0.1.0.dev3", + "matplotlib", + "napari; 'arm64' in platform_machine", # without Qt5 and skimage + "napari[all]; 'arm64' not in platform_machine", # with Qt5 and skimage + "PyQt6; 'arm64' in platform_machine", + "natsort", + "nidaqmx", + "numpy", + "pycromanager==0.25.40", + "pydantic", + "pylablib==1.4.1", + "scipy", + "slurmkit @ git+https://github.com/royerlab/slurmkit", + "tifffile", + "waveorder @ git+https://github.com/mehta-lab/waveorder", ] [project.optional-dependencies] # note that dev dependencies are only pinned to major versions dev = [ - "black==22.3.0", - "flake8~=5.0", - "isort~=5.12", - "pre-commit~=2.19", - "pylint~=2.14", - "pytest~=7.1", -] -build = [ - "twine", - "build", + "black==22.3.0", + "flake8~=5.0", + "isort~=5.12", + "pre-commit~=2.19", + "pylint~=2.14", + "pytest~=7.1", ] +build = ["build", "twine"] [project.scripts] mantis = "mantis.cli.main:cli" @@ -66,7 +62,7 @@ packages = ["mantis"] zip-safe = false [tool.setuptools.dynamic] -version = {attr = "mantis.__version__"} +version = { attr = "mantis.__version__" } [tool.black] line-length = 95 @@ -97,7 +93,7 @@ profile = "black" line_length = 95 lines_between_types = 1 default_section = "THIRDPARTY" -no_lines_before = ["STDLIB",] +no_lines_before = ["STDLIB"] ensure_newline_before_comments = true skip_glob = ["examples/*"] @@ -105,6 +101,9 @@ skip_glob = ["examples/*"] # disable all conventions, refactors, warnings (C, R, W) and the following: # E0401: unable-to-import (since it is possible that no one environment has all required packages) # E1136: unsubscriptable-object (anecdotal false positives for numpy objects) -disable = ["C", "R", "W", "unsubscriptable-object", "import-error"] +disable = ["C", "R", "W", "import-error", "unsubscriptable-object"] msg-template = "{line},{column},{category},{symbol}:{msg}" reports = "n" + +[tool.pytest.ini_options] +addopts = "--ignore examples/"