From eb01aaf017747d87d1f9b2dbac6c895ee7b02d65 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Fri, 18 Jul 2025 14:33:57 +0100 Subject: [PATCH 01/70] Completes all necessary movements along a single axis --- .../things/stage_measure.py | 188 ++++++++++++++++++ 1 file changed, 188 insertions(+) create mode 100644 src/openflexure_microscope_server/things/stage_measure.py diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py new file mode 100644 index 00000000..fea5db7a --- /dev/null +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -0,0 +1,188 @@ +import numpy as np +import logging +import cv2 +import json +from PIL import Image +import time +from typing import Annotated, Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Sequence, Tuple +from scipy.optimize import curve_fit +from camera_stage_mapping import camera_stage_tracker +from camera_stage_mapping import fft_image_tracking +from dataclasses import dataclass, field + +from labthings_fastapi.thing import Thing +from labthings_fastapi.dependencies.thing import direct_thing_client_dependency +from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger, InvocationCancelledError +from labthings_fastapi.decorators import thing_action, thing_property +from .stage import StageDependency as StageDep +from labthings_sangaboard import SangaboardThing +from labthings_picamera2.thing import StreamingPiCamera2 +from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict +from openflexure_microscope_server.things.autofocus import AutofocusThing +from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper + +StageDep = direct_thing_client_dependency(SangaboardThing, "/stage/") +CamDep = direct_thing_client_dependency(StreamingPiCamera2, "/camera/") +CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") +AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") + +def dict_generate(dict_steps, stream_resolution, dir, factor = 1): + ''' + Creates a single dictionary of step sizes + ''' + dict = { + 'x':(dict_steps/100) * stream_resolution[0] * factor * dir, + 'y':(dict_steps/100) * stream_resolution[1] * factor * dir + } + return dict + +def steps_generate(small_step, z_perc, big_step, dir, stream_resolution): + ''' + Creates all required dictionaries of all necessary step sizes. + ''' + step_sizes_big = dict_generate(big_step, stream_resolution, dir) + step_sizes_small = dict_generate(small_step, stream_resolution, dir) + minimum_offset_small = dict_generate(small_step, stream_resolution, dir, factor = 0.8) + z_steps = dict_generate(z_perc, stream_resolution, dir) + minimum_offset_z = dict_generate(z_perc, stream_resolution, dir, factor = 0.8) + + return step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big + +class RangeofMotionThing(Thing): + def rom_axis( + self, + autofocus: AutofocusDep, + stage: StageDep, + cam: CamDep, + csm: CSMDep, + cancel: CancelHook, + logger: InvocationLogger, + axis: str, + direction: int + ): + """ + Measure the range of motion in a single axis and direction. + """ + + try: + # Generate required dictionaries for step sizes and minimum offsets + stream_resolution = cam.stream_resolution + + step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big = steps_generate(20, 50, 200, direction, stream_resolution=stream_resolution) + + delta = { + 'x':0, + 'y':0 + } + + logger.info(f"Using the follwing steps: {step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big}") + + focus_data = autofocus.looping_autofocus(dz = 1000) + + starting_position = list(stage.position.values()) + + logger.info("Moving the stage in 4 medium sized steps.") + + # Medium sized steps + for loop in range(4): + image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + if axis == 'x': + csm.move_in_image_coordinates(x = z_steps['x'], y = 0) + else: + csm.move_in_image_coordinates(x = 0, y = z_steps['y']) + focus_data = autofocus.looping_autofocus(dz = 800) + image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + offset = [x * 1 for x in fft_image_tracking.displacement_between_images(image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels + delta['x'] = int(offset[1]) + delta['y'] = int(offset[0]) + logger.info(f"Offset measured as {delta[axis]}") + + # 1 big step followed by 3 small steps + while np.abs(delta[axis]) > minimum_offset_small[axis]: + + if axis == 'x': + csm.move_in_image_coordinates(x = step_sizes_big['x'], y = 0) + else: + csm.move_in_image_coordinates(x = 0, y = step_sizes_big['y']) + + for loop in range(3): + image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + if axis == 'x': + csm.move_in_image_coordinates(x = step_sizes_small['x'], y = 0) + else: + csm.move_in_image_coordinates(x = 0, y = step_sizes_small['y']) + focus_data = autofocus.looping_autofocus(dz = 800) + image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + offset = [x * 1 for x in fft_image_tracking.displacement_between_images(image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels + delta['x'] = int(offset[1]) + delta['y'] = int(offset[0]) + logger.info(f"Offset measured as {delta[axis]}") + + if np.abs(delta[axis]) < minimum_offset_small[axis]: # this means the edge has been found + logger.info(f"Edge has been found.") + break + + # Motion detection + + logger.info(f"Running motion detection") + + displacements = np.array([1,2,4,8,16,32,64,128,256,512,1024]) # Array of increasing step sizes + motion_minimum = 20 # minimum nuber of pixels for motion to be detected + + this_motion_step = { + 'x':np.zeros(np.shape(displacements)[0]), + 'y':np.zeros(np.shape(displacements)[0]), + 'z':0 + } + + this_motion_step[axis] = displacements * direction * -1 + + for loop in range(np.shape(displacements)[0]): + logger.info(f"Testing with step size {this_motion_step[axis][loop]}") + image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + stage.move_relative(x = this_motion_step['x'][loop], y = this_motion_step['y'][loop], z = this_motion_step['z']) + image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + offset = [x * 1 for x in fft_image_tracking.displacement_between_images( + image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels + delta['x'] = int(offset[1]) + delta['y'] = int(offset[0]) + logger.info(f"Offset measured as {np.abs(delta[axis])}") + if np.abs(delta[axis]) > motion_minimum: + logger.info("Motion detected.") + break + + stage.move_absolute(x = starting_position[0], y = starting_position[1], z = starting_position[2], block_cancellation=True) + + except: + logger.error("Stopping measurement because it was cancelled by the user") + stage.move_absolute(x = starting_position[0], y = starting_position[1], z = starting_position[2], block_cancellation=True) + raise Exception + return focus_data + + @thing_action + def rom_main( + self, + autofocus: AutofocusDep, + stage: StageDep, + cam: CamDep, + csm: CSMDep, + cancel: CancelHook, + logger: InvocationLogger + ): + """ + Measures the range of motion of the stage across the x and y axes. + """ + logger.info("Using the stage to measure the Range of Motion. Please ensure you are using a big enough sample.") + x_pos_results = self.rom_axis( + autofocus, + stage, + cam, + csm, + cancel, + logger, + axis = 'x', + direction = 1 + ) + return x_pos_results + + From e0b086bdc8028f73b42a1ae11b785072fc513df3 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Fri, 18 Jul 2025 17:27:25 +0100 Subject: [PATCH 02/70] Added parisitic motion detect, z prediction and saved results --- .../things/stage_measure.py | 113 +++++++++++++++++- 1 file changed, 108 insertions(+), 5 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index fea5db7a..948a8c68 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -26,6 +26,12 @@ CamDep = direct_thing_client_dependency(StreamingPiCamera2, "/camera/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") +def quadratic(x, a, b, c): + ''' + Quadratic function + ''' + return a * x**2 + b * x + c + def dict_generate(dict_steps, stream_resolution, dir, factor = 1): ''' Creates a single dictionary of step sizes @@ -45,8 +51,10 @@ def steps_generate(small_step, z_perc, big_step, dir, stream_resolution): minimum_offset_small = dict_generate(small_step, stream_resolution, dir, factor = 0.8) z_steps = dict_generate(z_perc, stream_resolution, dir) minimum_offset_z = dict_generate(z_perc, stream_resolution, dir, factor = 0.8) + wrong_axis_max_small = dict_generate(small_step, stream_resolution, dir, factor = 0.1) + wrong_axis_max_z = dict_generate(z_perc, stream_resolution, dir, factor = 0.1) - return step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big + return step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big, wrong_axis_max_small, wrong_axis_max_z class RangeofMotionThing(Thing): def rom_axis( @@ -65,10 +73,27 @@ class RangeofMotionThing(Thing): """ try: + if direction == 1: + dir_word = "positive" + else: + dir_word = "negative" + + logger.info(f"Beginning the {axis}-axis in the {dir_word} direction") + + stage_coords = [] + cor_lat_steps = [] + focused_positions = [] + axis_results = {} + # Generate required dictionaries for step sizes and minimum offsets stream_resolution = cam.stream_resolution - step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big = steps_generate(20, 50, 200, direction, stream_resolution=stream_resolution) + res_dic = { + 'x': stream_resolution[0], + 'y': stream_resolution[1] + } + + step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big, wrong_axis_max_small, wrong_axis_max_z = steps_generate(20, 50, 200, direction, stream_resolution=stream_resolution) delta = { 'x':0, @@ -81,6 +106,10 @@ class RangeofMotionThing(Thing): starting_position = list(stage.position.values()) + parasitic_motion = False + + stage_coords.append(stage.position) + logger.info("Moving the stage in 4 medium sized steps.") # Medium sized steps @@ -88,23 +117,54 @@ class RangeofMotionThing(Thing): image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) if axis == 'x': csm.move_in_image_coordinates(x = z_steps['x'], y = 0) + wrong_axis = 'y' else: csm.move_in_image_coordinates(x = 0, y = z_steps['y']) + wrong_axis = 'x' focus_data = autofocus.looping_autofocus(dz = 800) image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) offset = [x * 1 for x in fft_image_tracking.displacement_between_images(image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels delta['x'] = int(offset[1]) delta['y'] = int(offset[0]) logger.info(f"Offset measured as {delta[axis]}") + stage_coords.append(stage.position) + cor_lat_steps.append(offset) + focused_positions.append(stage.position) + + # Check for parasitic motion + + if np.abs(delta[wrong_axis]) > np.abs(wrong_axis_max_z[wrong_axis]): + logger.info(f"Parasitic motion detected in {wrong_axis}-axis whilst measuring {axis}-axis.") + parasitic_motion = True + break + + pixel_per_step = ((1/abs(csm.image_to_stage_displacement_matrix[0][1])) + + (1/abs(csm.image_to_stage_displacement_matrix[1][0])))/4 + lateral_positions = [i[axis] for i in focused_positions] + z_positions = [i['z'] for i in focused_positions] + parameters, covariance = curve_fit(quadratic, lateral_positions, z_positions) + relative_move = direction * 2 * res_dic[axis]/(2*pixel_per_step) # This is the number of steps to cover 200% of the FOV. + z_dest = quadratic(stage.position[axis] + relative_move, *parameters) + z_diff = z_dest - stage.position['z'] + + logger.info(f"Z calibration complete.") # 1 big step followed by 3 small steps - while np.abs(delta[axis]) > minimum_offset_small[axis]: + while np.abs(delta[axis]) > np.abs(minimum_offset_small[axis]) and parasitic_motion == False: + + stage.move_relative(z = z_diff) + + logger.info(f"Moved in z by {z_diff}") if axis == 'x': csm.move_in_image_coordinates(x = step_sizes_big['x'], y = 0) else: csm.move_in_image_coordinates(x = 0, y = step_sizes_big['y']) + stage_coords.append(stage.position) + + failure_count = 0 + for loop in range(3): image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) if axis == 'x': @@ -117,8 +177,28 @@ class RangeofMotionThing(Thing): delta['x'] = int(offset[1]) delta['y'] = int(offset[0]) logger.info(f"Offset measured as {delta[axis]}") + + while np.abs(delta[axis]) < minimum_offset_small[axis] and failure_count < 3: + logger.info(f"Correlation failed. Refocusing to check. Attempt {failure_count + 1}/3") + focus_data = autofocus.looping_autofocus(dz = 1000) + image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + failure_count += 1 + offset = [x * 1 for x in fft_image_tracking.displacement_between_images( + image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels + delta['x'] = int(offset[1]) + delta['y'] = int(offset[0]) + logger.info(f"Displacement found was {np.abs(delta[axis])}. Minimum offset is {minimum_offset_small[axis]}") - if np.abs(delta[axis]) < minimum_offset_small[axis]: # this means the edge has been found + stage_coords.append(stage.position) + cor_lat_steps.append(offset) + focused_positions.append(stage.position) + + if np.abs(delta[wrong_axis]) > np.abs(wrong_axis_max_small[wrong_axis]): + logger.info(f"Parasitic motion detected in {wrong_axis}-axis whilst measuring {axis}-axis.") + parasitic_motion = True + break + + if np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]): # this means the edge has been found logger.info(f"Edge has been found.") break @@ -151,13 +231,25 @@ class RangeofMotionThing(Thing): logger.info("Motion detected.") break + stage_coords[np.shape(stage_coords)[0] - 1] = stage.position + + final_pos = stage.position + + axis_results = { + "correlation_lateral_steps": cor_lat_steps, + "stage_positions": stage_coords, + "final_position": final_pos + } + stage.move_absolute(x = starting_position[0], y = starting_position[1], z = starting_position[2], block_cancellation=True) + + except: logger.error("Stopping measurement because it was cancelled by the user") stage.move_absolute(x = starting_position[0], y = starting_position[1], z = starting_position[2], block_cancellation=True) raise Exception - return focus_data + return axis_results @thing_action def rom_main( @@ -183,6 +275,17 @@ class RangeofMotionThing(Thing): axis = 'x', direction = 1 ) + # for axis_dir in [['x', 1],['x', -1],['y', 1],['y', -1]]: + # axis_dir_results = self.rom_axis( + # autofocus, + # stage, + # cam, + # csm, + # cancel, + # logger, + # axis = axis_dir[0], + # direction = axis_dir[1] + # ) return x_pos_results From 71e21d1e72e582afbc8dfc50160bb3002094a20c Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 22 Jul 2025 15:26:02 +0100 Subject: [PATCH 03/70] Step range and all other data is saved to stage_measure thing --- .../things/stage_measure.py | 94 ++++++++++++------- 1 file changed, 59 insertions(+), 35 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 948a8c68..afdae944 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -138,20 +138,20 @@ class RangeofMotionThing(Thing): parasitic_motion = True break - pixel_per_step = ((1/abs(csm.image_to_stage_displacement_matrix[0][1])) - + (1/abs(csm.image_to_stage_displacement_matrix[1][0])))/4 - lateral_positions = [i[axis] for i in focused_positions] - z_positions = [i['z'] for i in focused_positions] - parameters, covariance = curve_fit(quadratic, lateral_positions, z_positions) - relative_move = direction * 2 * res_dic[axis]/(2*pixel_per_step) # This is the number of steps to cover 200% of the FOV. - z_dest = quadratic(stage.position[axis] + relative_move, *parameters) - z_diff = z_dest - stage.position['z'] - - logger.info(f"Z calibration complete.") - # 1 big step followed by 3 small steps while np.abs(delta[axis]) > np.abs(minimum_offset_small[axis]) and parasitic_motion == False: + pixel_per_step = ((1/abs(csm.image_to_stage_displacement_matrix[0][1])) + + (1/abs(csm.image_to_stage_displacement_matrix[1][0])))/4 + lateral_positions = [i[axis] for i in focused_positions] + z_positions = [i['z'] for i in focused_positions] + parameters, covariance = curve_fit(quadratic, lateral_positions, z_positions) + relative_move = direction * 2 * res_dic[axis]/(2*pixel_per_step) # This is the number of steps to cover 200% of the FOV. + z_dest = quadratic(stage.position[axis] + relative_move, *parameters) + z_diff = z_dest - stage.position['z'] + + logger.info(f"Z calibration complete.") + stage.move_relative(z = z_diff) logger.info(f"Moved in z by {z_diff}") @@ -243,8 +243,6 @@ class RangeofMotionThing(Thing): stage.move_absolute(x = starting_position[0], y = starting_position[1], z = starting_position[2], block_cancellation=True) - - except: logger.error("Stopping measurement because it was cancelled by the user") stage.move_absolute(x = starting_position[0], y = starting_position[1], z = starting_position[2], block_cancellation=True) @@ -265,27 +263,53 @@ class RangeofMotionThing(Thing): Measures the range of motion of the stage across the x and y axes. """ logger.info("Using the stage to measure the Range of Motion. Please ensure you are using a big enough sample.") - x_pos_results = self.rom_axis( - autofocus, - stage, - cam, - csm, - cancel, - logger, - axis = 'x', - direction = 1 - ) - # for axis_dir in [['x', 1],['x', -1],['y', 1],['y', -1]]: - # axis_dir_results = self.rom_axis( - # autofocus, - # stage, - # cam, - # csm, - # cancel, - # logger, - # axis = axis_dir[0], - # direction = axis_dir[1] - # ) - return x_pos_results + start_time = time.time() + rom_results = {} + + for axis_dir in [['x', 1],['x', -1],['y', 1],['y', -1]]: + axis_dir_results = self.rom_axis( + autofocus, + stage, + cam, + csm, + cancel, + logger, + axis = axis_dir[0], + direction = axis_dir[1] + ) + rom_results[f"{axis_dir}"] = axis_dir_results + + end_time = time.time() + total_time = (end_time - start_time)/60 + + x_range = abs(rom_results["['x', 1]"]["final_position"]["x"] - rom_results["['x', -1]"]["final_position"]["x"]) + y_range = abs(rom_results["['y', 1]"]["final_position"]["y"] - rom_results["['y', -1]"]["final_position"]["y"]) + + step_range = [x_range, y_range] + + logger.info(f"Range of motion is {x_range} X {y_range}") + + rom_results["Time"] = total_time + rom_results["CSM Matrix"] = csm.image_to_stage_displacement_matrix + rom_results["Step Range"] = step_range + + self.thing_settings["rom_data"] = DenumpifyingDict(rom_results).model_dump() + + with open("/var/openflexure/ROM_Test_Results.json", 'w') as file_object: + json.dump(rom_results, file_object, indent = 3) + + return rom_results + + @thing_property + def rom_data(self) -> Optional[Dict]: + """ + The results of the last range of motion calibration that was run. + """ + filename = '/var/openflexure/settings/range_of_motion/settings.json' + with open(filename) as f: + rom_object = json.load(f) + + return rom_object + From 0267251a18f469fa2350a085c8f3e4a708c040cc Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 22 Jul 2025 16:29:17 +0100 Subject: [PATCH 04/70] Move and measure and z predict are now separate functions --- .../things/stage_measure.py | 73 ++++++++++--------- 1 file changed, 39 insertions(+), 34 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index afdae944..8d848d9a 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -56,6 +56,37 @@ def steps_generate(small_step, z_perc, big_step, dir, stream_resolution): return step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big, wrong_axis_max_small, wrong_axis_max_z +def predict_z(pixel_per_step, positions, axis, direction, stage: StageDep, cam: CamDep): + + res_dic = { + 'x': cam.stream_resolution[0], + 'y': cam.stream_resolution[1] + } + + lateral_positions = [i[axis] for i in positions] + z_positions = [i['z'] for i in positions] + parameters, covariance = curve_fit(quadratic, lateral_positions, z_positions) + relative_move = direction * 2 * res_dic[axis]/(2*pixel_per_step) # This is the number of steps to cover 200% of the FOV. + z_dest = quadratic(stage.position[axis] + relative_move, *parameters) + z_diff = z_dest - stage.position['z'] + + return z_diff + +def move_and_measure(step_size, axis, delta, image1, csm: CSMDep, autofocus: AutofocusDep, cam:CamDep): + if axis == 'x': + csm.move_in_image_coordinates(x = step_size['x'], y = 0) + wrong_axis = 'y' + else: + csm.move_in_image_coordinates(x = 0, y = step_size['y']) + wrong_axis = 'x' + focus_data = autofocus.looping_autofocus(dz = 800) + image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + offset = [x * 1 for x in fft_image_tracking.displacement_between_images(image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels + delta['x'] = int(offset[1]) + delta['y'] = int(offset[0]) + + return delta, offset, focus_data, wrong_axis + class RangeofMotionThing(Thing): def rom_axis( self, @@ -85,14 +116,12 @@ class RangeofMotionThing(Thing): focused_positions = [] axis_results = {} + pixel_per_step = ((1/abs(csm.image_to_stage_displacement_matrix[0][1])) + + (1/abs(csm.image_to_stage_displacement_matrix[1][0])))/4 + # Generate required dictionaries for step sizes and minimum offsets stream_resolution = cam.stream_resolution - res_dic = { - 'x': stream_resolution[0], - 'y': stream_resolution[1] - } - step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big, wrong_axis_max_small, wrong_axis_max_z = steps_generate(20, 50, 200, direction, stream_resolution=stream_resolution) delta = { @@ -115,17 +144,7 @@ class RangeofMotionThing(Thing): # Medium sized steps for loop in range(4): image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - if axis == 'x': - csm.move_in_image_coordinates(x = z_steps['x'], y = 0) - wrong_axis = 'y' - else: - csm.move_in_image_coordinates(x = 0, y = z_steps['y']) - wrong_axis = 'x' - focus_data = autofocus.looping_autofocus(dz = 800) - image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - offset = [x * 1 for x in fft_image_tracking.displacement_between_images(image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels - delta['x'] = int(offset[1]) - delta['y'] = int(offset[0]) + delta, offset, focus_data, wrong_axis = move_and_measure(step_size = z_steps, axis = axis, delta = delta, image1 = image1, csm = csm, autofocus = autofocus, cam = cam) logger.info(f"Offset measured as {delta[axis]}") stage_coords.append(stage.position) cor_lat_steps.append(offset) @@ -140,15 +159,7 @@ class RangeofMotionThing(Thing): # 1 big step followed by 3 small steps while np.abs(delta[axis]) > np.abs(minimum_offset_small[axis]) and parasitic_motion == False: - - pixel_per_step = ((1/abs(csm.image_to_stage_displacement_matrix[0][1])) - + (1/abs(csm.image_to_stage_displacement_matrix[1][0])))/4 - lateral_positions = [i[axis] for i in focused_positions] - z_positions = [i['z'] for i in focused_positions] - parameters, covariance = curve_fit(quadratic, lateral_positions, z_positions) - relative_move = direction * 2 * res_dic[axis]/(2*pixel_per_step) # This is the number of steps to cover 200% of the FOV. - z_dest = quadratic(stage.position[axis] + relative_move, *parameters) - z_diff = z_dest - stage.position['z'] + z_diff = predict_z(pixel_per_step = pixel_per_step, positions = focused_positions, axis = axis, direction = direction, stage = stage, cam = cam) logger.info(f"Z calibration complete.") @@ -163,19 +174,13 @@ class RangeofMotionThing(Thing): stage_coords.append(stage.position) + focus_data = autofocus.looping_autofocus(dz = 800) + failure_count = 0 for loop in range(3): image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - if axis == 'x': - csm.move_in_image_coordinates(x = step_sizes_small['x'], y = 0) - else: - csm.move_in_image_coordinates(x = 0, y = step_sizes_small['y']) - focus_data = autofocus.looping_autofocus(dz = 800) - image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - offset = [x * 1 for x in fft_image_tracking.displacement_between_images(image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels - delta['x'] = int(offset[1]) - delta['y'] = int(offset[0]) + delta, offset, focus_data, wrong_axis = move_and_measure(step_size = step_sizes_small, axis = axis, delta = delta, image1 = image1, csm = csm, autofocus = autofocus, cam = cam) logger.info(f"Offset measured as {delta[axis]}") while np.abs(delta[axis]) < minimum_offset_small[axis] and failure_count < 3: From 725f993366787035e4c8823a7359f745ffffef75 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 22 Jul 2025 16:53:22 +0100 Subject: [PATCH 05/70] Typehints and docstrings added --- .../things/stage_measure.py | 16 +++++++++++----- 1 file changed, 11 insertions(+), 5 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 8d848d9a..2f1a4ef3 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -9,6 +9,7 @@ from scipy.optimize import curve_fit from camera_stage_mapping import camera_stage_tracker from camera_stage_mapping import fft_image_tracking from dataclasses import dataclass, field +from numpy.typing import NDArray from labthings_fastapi.thing import Thing from labthings_fastapi.dependencies.thing import direct_thing_client_dependency @@ -32,7 +33,7 @@ def quadratic(x, a, b, c): ''' return a * x**2 + b * x + c -def dict_generate(dict_steps, stream_resolution, dir, factor = 1): +def dict_generate(dict_steps: int, stream_resolution: list, dir: int, factor: float = 1) -> dict: ''' Creates a single dictionary of step sizes ''' @@ -42,7 +43,7 @@ def dict_generate(dict_steps, stream_resolution, dir, factor = 1): } return dict -def steps_generate(small_step, z_perc, big_step, dir, stream_resolution): +def steps_generate(small_step: int, z_perc: int, big_step: int, dir: int, stream_resolution: list) -> dict: ''' Creates all required dictionaries of all necessary step sizes. ''' @@ -56,8 +57,10 @@ def steps_generate(small_step, z_perc, big_step, dir, stream_resolution): return step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big, wrong_axis_max_small, wrong_axis_max_z -def predict_z(pixel_per_step, positions, axis, direction, stage: StageDep, cam: CamDep): - +def predict_z(pixel_per_step: float, positions: list, axis: str, direction: int, stage: StageDep, cam: CamDep) -> float: + """ + Predicts the next z position for a big move using an array of previous positions. + """ res_dic = { 'x': cam.stream_resolution[0], 'y': cam.stream_resolution[1] @@ -72,7 +75,10 @@ def predict_z(pixel_per_step, positions, axis, direction, stage: StageDep, cam: return z_diff -def move_and_measure(step_size, axis, delta, image1, csm: CSMDep, autofocus: AutofocusDep, cam:CamDep): +def move_and_measure(step_size: dict, axis: str, delta: dict, image1, csm: CSMDep, autofocus: AutofocusDep, cam:CamDep): + """ + Moves the stage and measures the offset between the two positions. + """ if axis == 'x': csm.move_in_image_coordinates(x = step_size['x'], y = 0) wrong_axis = 'y' From 0bf3b4210ae6be0b66beba0434243d7b68291dc7 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 22 Jul 2025 16:54:25 +0100 Subject: [PATCH 06/70] Added stage measure to config list --- ofm_config_full.json | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/ofm_config_full.json b/ofm_config_full.json index 60b85a66..2be08676 100644 --- a/ofm_config_full.json +++ b/ofm_config_full.json @@ -15,7 +15,8 @@ "kwargs": { "scans_folder": "/var/openflexure/scans/" } - } + }, + "/stage_measure/":"openflexure_microscope_server.things.stage_measure:RangeofMotionThing" }, "settings_folder": "/var/openflexure/settings/", "log_folder": "/var/openflexure/logs/" From 154870142d175b858f1bb979fb04af21e476c851 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 23 Jul 2025 14:47:29 +0100 Subject: [PATCH 07/70] Optional autofocus and other minor tweaks --- .../things/stage_measure.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 2f1a4ef3..bbb3990d 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -69,13 +69,13 @@ def predict_z(pixel_per_step: float, positions: list, axis: str, direction: int, lateral_positions = [i[axis] for i in positions] z_positions = [i['z'] for i in positions] parameters, covariance = curve_fit(quadratic, lateral_positions, z_positions) - relative_move = direction * 2 * res_dic[axis]/(2*pixel_per_step) # This is the number of steps to cover 200% of the FOV. + relative_move = (direction * res_dic[axis])/pixel_per_step # This is the number of steps to cover 200% of the FOV. z_dest = quadratic(stage.position[axis] + relative_move, *parameters) z_diff = z_dest - stage.position['z'] return z_diff -def move_and_measure(step_size: dict, axis: str, delta: dict, image1, csm: CSMDep, autofocus: AutofocusDep, cam:CamDep): +def move_and_measure(step_size: dict, axis: str, delta: dict, image1, autofocus_proc: bool, focus_data: list, csm: CSMDep, autofocus: AutofocusDep, cam:CamDep): """ Moves the stage and measures the offset between the two positions. """ @@ -85,7 +85,8 @@ def move_and_measure(step_size: dict, axis: str, delta: dict, image1, csm: CSMDe else: csm.move_in_image_coordinates(x = 0, y = step_size['y']) wrong_axis = 'x' - focus_data = autofocus.looping_autofocus(dz = 800) + if autofocus_proc == True: + focus_data = autofocus.looping_autofocus(dz = 800) image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) offset = [x * 1 for x in fft_image_tracking.displacement_between_images(image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels delta['x'] = int(offset[1]) @@ -144,13 +145,14 @@ class RangeofMotionThing(Thing): parasitic_motion = False stage_coords.append(stage.position) + focused_positions.append(stage.position) - logger.info("Moving the stage in 4 medium sized steps.") + logger.info("Moving the stage in 5 medium sized steps.") # Medium sized steps - for loop in range(4): + for loop in range(5): image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - delta, offset, focus_data, wrong_axis = move_and_measure(step_size = z_steps, axis = axis, delta = delta, image1 = image1, csm = csm, autofocus = autofocus, cam = cam) + delta, offset, focus_data, wrong_axis = move_and_measure(step_size = z_steps, axis = axis, delta = delta, image1 = image1, autofocus_proc = True, focus_data = focus_data, csm = csm, autofocus = autofocus, cam = cam) logger.info(f"Offset measured as {delta[axis]}") stage_coords.append(stage.position) cor_lat_steps.append(offset) @@ -186,7 +188,7 @@ class RangeofMotionThing(Thing): for loop in range(3): image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - delta, offset, focus_data, wrong_axis = move_and_measure(step_size = step_sizes_small, axis = axis, delta = delta, image1 = image1, csm = csm, autofocus = autofocus, cam = cam) + delta, offset, focus_data, wrong_axis = move_and_measure(step_size = step_sizes_small, axis = axis, delta = delta, image1 = image1, autofocus_proc = False, focus_data = focus_data, csm = csm, autofocus = autofocus, cam = cam) logger.info(f"Offset measured as {delta[axis]}") while np.abs(delta[axis]) < minimum_offset_small[axis] and failure_count < 3: @@ -198,7 +200,7 @@ class RangeofMotionThing(Thing): image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels delta['x'] = int(offset[1]) delta['y'] = int(offset[0]) - logger.info(f"Displacement found was {np.abs(delta[axis])}. Minimum offset is {minimum_offset_small[axis]}") + logger.info(f"Displacement found was {delta[axis]}. Minimum offset is {minimum_offset_small[axis]}") stage_coords.append(stage.position) cor_lat_steps.append(offset) From ada120db9c1f4fbebbefc2b2cbd87ab53c781c05 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Thu, 24 Jul 2025 10:19:09 +0100 Subject: [PATCH 08/70] Minor tweaks to z_pred and lowered tolerance for failure --- .../things/stage_measure.py | 23 ++++++------------- 1 file changed, 7 insertions(+), 16 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index bbb3990d..522b4cfa 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -49,27 +49,21 @@ def steps_generate(small_step: int, z_perc: int, big_step: int, dir: int, stream ''' step_sizes_big = dict_generate(big_step, stream_resolution, dir) step_sizes_small = dict_generate(small_step, stream_resolution, dir) - minimum_offset_small = dict_generate(small_step, stream_resolution, dir, factor = 0.8) + minimum_offset_small = dict_generate(small_step, stream_resolution, dir, factor = 0.65) z_steps = dict_generate(z_perc, stream_resolution, dir) - minimum_offset_z = dict_generate(z_perc, stream_resolution, dir, factor = 0.8) + minimum_offset_z = dict_generate(z_perc, stream_resolution, dir, factor = 0.65) wrong_axis_max_small = dict_generate(small_step, stream_resolution, dir, factor = 0.1) wrong_axis_max_z = dict_generate(z_perc, stream_resolution, dir, factor = 0.1) return step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big, wrong_axis_max_small, wrong_axis_max_z -def predict_z(pixel_per_step: float, positions: list, axis: str, direction: int, stage: StageDep, cam: CamDep) -> float: +def predict_z(positions: list, axis: str, relative_move: float, stage: StageDep) -> float: """ Predicts the next z position for a big move using an array of previous positions. """ - res_dic = { - 'x': cam.stream_resolution[0], - 'y': cam.stream_resolution[1] - } - lateral_positions = [i[axis] for i in positions] z_positions = [i['z'] for i in positions] parameters, covariance = curve_fit(quadratic, lateral_positions, z_positions) - relative_move = (direction * res_dic[axis])/pixel_per_step # This is the number of steps to cover 200% of the FOV. z_dest = quadratic(stage.position[axis] + relative_move, *parameters) z_diff = z_dest - stage.position['z'] @@ -123,9 +117,6 @@ class RangeofMotionThing(Thing): focused_positions = [] axis_results = {} - pixel_per_step = ((1/abs(csm.image_to_stage_displacement_matrix[0][1])) - + (1/abs(csm.image_to_stage_displacement_matrix[1][0])))/4 - # Generate required dictionaries for step sizes and minimum offsets stream_resolution = cam.stream_resolution @@ -167,7 +158,7 @@ class RangeofMotionThing(Thing): # 1 big step followed by 3 small steps while np.abs(delta[axis]) > np.abs(minimum_offset_small[axis]) and parasitic_motion == False: - z_diff = predict_z(pixel_per_step = pixel_per_step, positions = focused_positions, axis = axis, direction = direction, stage = stage, cam = cam) + z_diff = predict_z(positions = focused_positions, axis = axis, relative_move = step_sizes_big[axis], stage = stage) logger.info(f"Z calibration complete.") @@ -180,10 +171,10 @@ class RangeofMotionThing(Thing): else: csm.move_in_image_coordinates(x = 0, y = step_sizes_big['y']) - stage_coords.append(stage.position) - focus_data = autofocus.looping_autofocus(dz = 800) + stage_coords.append(stage.position) + failure_count = 0 for loop in range(3): @@ -191,7 +182,7 @@ class RangeofMotionThing(Thing): delta, offset, focus_data, wrong_axis = move_and_measure(step_size = step_sizes_small, axis = axis, delta = delta, image1 = image1, autofocus_proc = False, focus_data = focus_data, csm = csm, autofocus = autofocus, cam = cam) logger.info(f"Offset measured as {delta[axis]}") - while np.abs(delta[axis]) < minimum_offset_small[axis] and failure_count < 3: + while np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]) and failure_count < 3: logger.info(f"Correlation failed. Refocusing to check. Attempt {failure_count + 1}/3") focus_data = autofocus.looping_autofocus(dz = 1000) image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) From b90ecf8c497e7153c0b5be35046bc9b7024ad59c Mon Sep 17 00:00:00 2001 From: Chish36 Date: Thu, 24 Jul 2025 14:49:28 +0100 Subject: [PATCH 09/70] Motion detection is now a function and a few other minor tweaks --- .../things/stage_measure.py | 81 ++++++++++--------- 1 file changed, 45 insertions(+), 36 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 522b4cfa..4f06bee1 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -57,14 +57,20 @@ def steps_generate(small_step: int, z_perc: int, big_step: int, dir: int, stream return step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big, wrong_axis_max_small, wrong_axis_max_z -def predict_z(positions: list, axis: str, relative_move: float, stage: StageDep) -> float: +def predict_z(positions: list, axis: str, relative_move: float, stage: StageDep, csm: CSMDep) -> float: """ Predicts the next z position for a big move using an array of previous positions. """ + + pixel_step = { + 'x':1/csm.image_to_stage_displacement_matrix[0][1], + 'y':1/csm.image_to_stage_displacement_matrix[1][0] + } + lateral_positions = [i[axis] for i in positions] z_positions = [i['z'] for i in positions] - parameters, covariance = curve_fit(quadratic, lateral_positions, z_positions) - z_dest = quadratic(stage.position[axis] + relative_move, *parameters) + parameters, _ = curve_fit(quadratic, lateral_positions, z_positions) + z_dest = quadratic(stage.position[axis] + (relative_move/pixel_step[axis]), *parameters) z_diff = z_dest - stage.position['z'] return z_diff @@ -88,6 +94,39 @@ def move_and_measure(step_size: dict, axis: str, delta: dict, image1, autofocus_ return delta, offset, focus_data, wrong_axis +def motion_detection(axis: str, direction: int, csm: CSMDep, stage: StageDep, cam: CamDep, logger: InvocationLogger) -> dict: + displacements = [1,2,4,8,16,32,64,128,256,512] # Array of increasing step sizes + motion_minimum = 20 # minimum nuber of pixels for motion to be detected + + this_motion_step = { + 'x': 0, + 'y': 0 + } + + delta = { + 'x': 0, + 'y': 0 + } + + for loop in range(np.shape(displacements)[0]): + this_motion_step[axis] = displacements[loop] * direction * -1 + logger.info(f"Testing with step size {this_motion_step[axis]}") + image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + csm.move_in_image_coordinates(x = this_motion_step['x'], y = this_motion_step['y']) + image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + offset = [x * 1 for x in fft_image_tracking.displacement_between_images( + image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels + delta['x'] = int(offset[1]) + delta['y'] = int(offset[0]) + logger.info(f"Offset measured as {np.abs(delta[axis])}") + if np.abs(delta[axis]) > motion_minimum: + logger.info("Motion detected.") + break + + true_final = stage.position + + return true_final + class RangeofMotionThing(Thing): def rom_axis( self, @@ -114,7 +153,6 @@ class RangeofMotionThing(Thing): stage_coords = [] cor_lat_steps = [] - focused_positions = [] axis_results = {} # Generate required dictionaries for step sizes and minimum offsets @@ -136,7 +174,6 @@ class RangeofMotionThing(Thing): parasitic_motion = False stage_coords.append(stage.position) - focused_positions.append(stage.position) logger.info("Moving the stage in 5 medium sized steps.") @@ -147,7 +184,6 @@ class RangeofMotionThing(Thing): logger.info(f"Offset measured as {delta[axis]}") stage_coords.append(stage.position) cor_lat_steps.append(offset) - focused_positions.append(stage.position) # Check for parasitic motion @@ -158,7 +194,7 @@ class RangeofMotionThing(Thing): # 1 big step followed by 3 small steps while np.abs(delta[axis]) > np.abs(minimum_offset_small[axis]) and parasitic_motion == False: - z_diff = predict_z(positions = focused_positions, axis = axis, relative_move = step_sizes_big[axis], stage = stage) + z_diff = predict_z(positions = stage_coords, axis = axis, relative_move = step_sizes_big[axis], stage = stage, csm = csm) logger.info(f"Z calibration complete.") @@ -195,7 +231,6 @@ class RangeofMotionThing(Thing): stage_coords.append(stage.position) cor_lat_steps.append(offset) - focused_positions.append(stage.position) if np.abs(delta[wrong_axis]) > np.abs(wrong_axis_max_small[wrong_axis]): logger.info(f"Parasitic motion detected in {wrong_axis}-axis whilst measuring {axis}-axis.") @@ -207,37 +242,11 @@ class RangeofMotionThing(Thing): break # Motion detection - logger.info(f"Running motion detection") - displacements = np.array([1,2,4,8,16,32,64,128,256,512,1024]) # Array of increasing step sizes - motion_minimum = 20 # minimum nuber of pixels for motion to be detected + final_pos = motion_detection(axis = axis, direction = direction, csm = csm, stage = stage, cam = cam, logger = logger) - this_motion_step = { - 'x':np.zeros(np.shape(displacements)[0]), - 'y':np.zeros(np.shape(displacements)[0]), - 'z':0 - } - - this_motion_step[axis] = displacements * direction * -1 - - for loop in range(np.shape(displacements)[0]): - logger.info(f"Testing with step size {this_motion_step[axis][loop]}") - image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - stage.move_relative(x = this_motion_step['x'][loop], y = this_motion_step['y'][loop], z = this_motion_step['z']) - image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - offset = [x * 1 for x in fft_image_tracking.displacement_between_images( - image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels - delta['x'] = int(offset[1]) - delta['y'] = int(offset[0]) - logger.info(f"Offset measured as {np.abs(delta[axis])}") - if np.abs(delta[axis]) > motion_minimum: - logger.info("Motion detected.") - break - - stage_coords[np.shape(stage_coords)[0] - 1] = stage.position - - final_pos = stage.position + stage_coords[np.shape(stage_coords)[0] - 1] = final_pos axis_results = { "correlation_lateral_steps": cor_lat_steps, From e327305549f4fa3f9b32afe535f2218d0e27e276 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Thu, 24 Jul 2025 14:57:44 +0100 Subject: [PATCH 10/70] Docstring added --- src/openflexure_microscope_server/things/stage_measure.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 4f06bee1..8ba7d479 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -95,6 +95,9 @@ def move_and_measure(step_size: dict, axis: str, delta: dict, image1, autofocus_ return delta, offset, focus_data, wrong_axis def motion_detection(axis: str, direction: int, csm: CSMDep, stage: StageDep, cam: CamDep, logger: InvocationLogger) -> dict: + """ + Moves the stage until motion is detected. + """ displacements = [1,2,4,8,16,32,64,128,256,512] # Array of increasing step sizes motion_minimum = 20 # minimum nuber of pixels for motion to be detected From e74ac9d859512fe9df55b4e16f9a5aa0dca1525c Mon Sep 17 00:00:00 2001 From: Chish36 Date: Fri, 25 Jul 2025 08:27:55 +0100 Subject: [PATCH 11/70] Improved docstrings and type hints. --- .../things/stage_measure.py | 173 ++++++++++-------- 1 file changed, 100 insertions(+), 73 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 8ba7d479..0a490cb8 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -1,24 +1,18 @@ import numpy as np -import logging import cv2 import json from PIL import Image import time -from typing import Annotated, Any, Callable, Dict, List, Mapping, NamedTuple, Optional, Sequence, Tuple +from typing import Dict, Optional from scipy.optimize import curve_fit -from camera_stage_mapping import camera_stage_tracker from camera_stage_mapping import fft_image_tracking -from dataclasses import dataclass, field -from numpy.typing import NDArray - from labthings_fastapi.thing import Thing from labthings_fastapi.dependencies.thing import direct_thing_client_dependency -from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger, InvocationCancelledError +from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger from labthings_fastapi.decorators import thing_action, thing_property -from .stage import StageDependency as StageDep from labthings_sangaboard import SangaboardThing from labthings_picamera2.thing import StreamingPiCamera2 -from labthings_fastapi.types.numpy import NDArray, denumpify, DenumpifyingDict +from labthings_fastapi.types.numpy import DenumpifyingDict from openflexure_microscope_server.things.autofocus import AutofocusThing from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper @@ -27,41 +21,66 @@ CamDep = direct_thing_client_dependency(StreamingPiCamera2, "/camera/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") +''' +This file contains all the functions used to measure the range of motion of the OpenFlexure Microscope translation stage. + +The range of motion is measured by first taking 5 'medium' sized steps which gives enough positions to predict future z positions. Next, one 'big' step is taken followed by 3 +'small' steps to test that the stage is still moving as expected and has not reached the edge. Once the edge has been found and too account for the possibility that a 'big' +step was take just before the edge was reached, the stage is moved in a sequence of increasing pixel sizes in the opposite direction until motion is detected. This is +position is taken as the true final position. +''' + def quadratic(x, a, b, c): - ''' - Quadratic function - ''' + """Quadratic function. Used for predicting z. + + :param x: The points at which to evaluate the quadratic. + :param a: The coefficient of x^2. + :param b: The coefficient of x. + :param c: The constant coefficient. + :return: The quadratic, evaluated at each point in ``x`` + """ return a * x**2 + b * x + c -def dict_generate(dict_steps: int, stream_resolution: list, dir: int, factor: float = 1) -> dict: - ''' - Creates a single dictionary of step sizes - ''' - dict = { - 'x':(dict_steps/100) * stream_resolution[0] * factor * dir, - 'y':(dict_steps/100) * stream_resolution[1] * factor * dir - } - return dict +def dict_generate(fov_perc: int, stream_resolution: list[int], direction: int, factor: float = 1) -> dict[str, float]: + """Creates a single dictionary of x and y moves for moving in image coordinates. -def steps_generate(small_step: int, z_perc: int, big_step: int, dir: int, stream_resolution: list) -> dict: - ''' - Creates all required dictionaries of all necessary step sizes. - ''' - step_sizes_big = dict_generate(big_step, stream_resolution, dir) - step_sizes_small = dict_generate(small_step, stream_resolution, dir) - minimum_offset_small = dict_generate(small_step, stream_resolution, dir, factor = 0.65) - z_steps = dict_generate(z_perc, stream_resolution, dir) - minimum_offset_z = dict_generate(z_perc, stream_resolution, dir, factor = 0.65) - wrong_axis_max_small = dict_generate(small_step, stream_resolution, dir, factor = 0.1) - wrong_axis_max_z = dict_generate(z_perc, stream_resolution, dir, factor = 0.1) + :param fov_perc: The percentage of field of view the stage should move by. + :param stream_resolution: The resolution of the stream from the camera. + :param direction: The direction the stage moves. + :param factor: Multiplicative factor which changes the final result. + :return: A dictionary with keys 'x' and 'y' with a pixel distance move the stage can make. + """ + xy_dict = { + "x": (fov_perc / 100) * stream_resolution[0] * factor * direction, + "y": (fov_perc / 100) * stream_resolution[1] * factor * direction, + } + return xy_dict + +def steps_generate(small_step: int, z_perc: int, big_step: int, direction: int, stream_resolution: list) -> tuple: + '''Creates all required dictionaries of all necessary step sizes.''' + step_sizes_big = dict_generate(big_step, stream_resolution, direction) + step_sizes_small = dict_generate(small_step, stream_resolution, direction) + minimum_offset_small = dict_generate( + small_step, stream_resolution, direction, factor=0.65 + ) + z_steps = dict_generate(z_perc, stream_resolution, direction) + minimum_offset_z = dict_generate(z_perc, stream_resolution, direction, factor=0.65) + wrong_axis_max_small = dict_generate( + small_step, stream_resolution, direction, factor=0.1 + ) + wrong_axis_max_z = dict_generate(z_perc, stream_resolution, direction, factor=0.1) return step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big, wrong_axis_max_small, wrong_axis_max_z def predict_z(positions: list, axis: str, relative_move: float, stage: StageDep, csm: CSMDep) -> float: - """ - Predicts the next z position for a big move using an array of previous positions. - """ + """Predicts the next z position for a 'big' move using an array of previous positions. This avoids long autofocuses and reduces the possibility of colliding with + the sample. + :params positions: The list of positions used for predicting z. This will usually be a list of all previuos positions. + :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. + :params relative_move: The move which the function is trying to predict z for. Here, this is inputted with units of pixels. + :return: A number of pixels the stage needs to move in z. + """ pixel_step = { 'x':1/csm.image_to_stage_displacement_matrix[0][1], 'y':1/csm.image_to_stage_displacement_matrix[1][0] @@ -75,9 +94,16 @@ def predict_z(positions: list, axis: str, relative_move: float, stage: StageDep, return z_diff -def move_and_measure(step_size: dict, axis: str, delta: dict, image1, autofocus_proc: bool, focus_data: list, csm: CSMDep, autofocus: AutofocusDep, cam:CamDep): - """ - Moves the stage and measures the offset between the two positions. +def move_and_measure(step_size: dict[str, float], axis: str, delta: dict[str, int], image1, autofocus_proc: bool, focus_data: list[float], csm: CSMDep, autofocus: AutofocusDep, cam:CamDep) -> tuple: + """Moves the stage and measures the offset between the two positions. + + :params step_size: A dictionary with keys 'x' and 'y' with pixel distances. + :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. + :params delta: A dictionary of 'x' and 'y' offsets. + :params image1: An image taken before moving to be correlated with image2. + :params autofocus_proc: If true, looping.autofocus will be used after the stage moves. + :params focus_data: A list of focus data returned by the autofocus procedure. + :return: All required data for the next move. This includes the updated delta value, offset and focus_data. Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. """ if axis == 'x': csm.move_in_image_coordinates(x = step_size['x'], y = 0) @@ -85,7 +111,7 @@ def move_and_measure(step_size: dict, axis: str, delta: dict, image1, autofocus_ else: csm.move_in_image_coordinates(x = 0, y = step_size['y']) wrong_axis = 'x' - if autofocus_proc == True: + if autofocus_proc: focus_data = autofocus.looping_autofocus(dz = 800) image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) offset = [x * 1 for x in fft_image_tracking.displacement_between_images(image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels @@ -95,8 +121,12 @@ def move_and_measure(step_size: dict, axis: str, delta: dict, image1, autofocus_ return delta, offset, focus_data, wrong_axis def motion_detection(axis: str, direction: int, csm: CSMDep, stage: StageDep, cam: CamDep, logger: InvocationLogger) -> dict: - """ - Moves the stage until motion is detected. + """Moves the stage until motion is detected. This happens at the end of each axis and direction and where the stage is moved in the opposite direction until + motion is detected. + + :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. + :params direction: The direction in which the stage was moving previous to motion detection being used. + :return: The stage coordinates where motion was detected. """ displacements = [1,2,4,8,16,32,64,128,256,512] # Array of increasing step sizes motion_minimum = 20 # minimum nuber of pixels for motion to be detected @@ -110,13 +140,13 @@ def motion_detection(axis: str, direction: int, csm: CSMDep, stage: StageDep, ca 'x': 0, 'y': 0 } - + for loop in range(np.shape(displacements)[0]): this_motion_step[axis] = displacements[loop] * direction * -1 logger.info(f"Testing with step size {this_motion_step[axis]}") - image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) csm.move_in_image_coordinates(x = this_motion_step['x'], y = this_motion_step['y']) - image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) offset = [x * 1 for x in fft_image_tracking.displacement_between_images( image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels delta['x'] = int(offset[1]) @@ -141,17 +171,24 @@ class RangeofMotionThing(Thing): logger: InvocationLogger, axis: str, direction: int - ): - """ - Measure the range of motion in a single axis and direction. + ) -> dict: + """Measure the range of motion in a single axis and direction. + + :params axis: The axis which is being measured. This must be 'x' or 'y'. + :params direction: The direction which is being measured. This must be 1 or -1. + :return: Results dictionary containing stage positions, correlations and the final position. """ + focus_data = autofocus.looping_autofocus(dz = 1000) + + starting_position = list(stage.position.values()) + try: if direction == 1: dir_word = "positive" else: dir_word = "negative" - + logger.info(f"Beginning the {axis}-axis in the {dir_word} direction") stage_coords = [] @@ -170,16 +207,12 @@ class RangeofMotionThing(Thing): logger.info(f"Using the follwing steps: {step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big}") - focus_data = autofocus.looping_autofocus(dz = 1000) - - starting_position = list(stage.position.values()) - parasitic_motion = False stage_coords.append(stage.position) logger.info("Moving the stage in 5 medium sized steps.") - + # Medium sized steps for loop in range(5): image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) @@ -196,14 +229,14 @@ class RangeofMotionThing(Thing): break # 1 big step followed by 3 small steps - while np.abs(delta[axis]) > np.abs(minimum_offset_small[axis]) and parasitic_motion == False: + while np.abs(delta[axis]) > np.abs(minimum_offset_small[axis]) and not parasitic_motion: z_diff = predict_z(positions = stage_coords, axis = axis, relative_move = step_sizes_big[axis], stage = stage, csm = csm) - logger.info(f"Z calibration complete.") + logger.info("Z calibration complete.") stage.move_relative(z = z_diff) - logger.info(f"Moved in z by {z_diff}") + logger.info(f"Moved in z by {z_diff}") if axis == 'x': csm.move_in_image_coordinates(x = step_sizes_big['x'], y = 0) @@ -216,15 +249,16 @@ class RangeofMotionThing(Thing): failure_count = 0 + # Turn into function for loop in range(3): image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) delta, offset, focus_data, wrong_axis = move_and_measure(step_size = step_sizes_small, axis = axis, delta = delta, image1 = image1, autofocus_proc = False, focus_data = focus_data, csm = csm, autofocus = autofocus, cam = cam) logger.info(f"Offset measured as {delta[axis]}") - + while np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]) and failure_count < 3: logger.info(f"Correlation failed. Refocusing to check. Attempt {failure_count + 1}/3") focus_data = autofocus.looping_autofocus(dz = 1000) - image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) failure_count += 1 offset = [x * 1 for x in fft_image_tracking.displacement_between_images( image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels @@ -241,11 +275,11 @@ class RangeofMotionThing(Thing): break if np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]): # this means the edge has been found - logger.info(f"Edge has been found.") + logger.info("Edge has been found.") break # Motion detection - logger.info(f"Running motion detection") + logger.info("Running motion detection") final_pos = motion_detection(axis = axis, direction = direction, csm = csm, stage = stage, cam = cam, logger = logger) @@ -257,12 +291,9 @@ class RangeofMotionThing(Thing): "final_position": final_pos } + finally: stage.move_absolute(x = starting_position[0], y = starting_position[1], z = starting_position[2], block_cancellation=True) - except: - logger.error("Stopping measurement because it was cancelled by the user") - stage.move_absolute(x = starting_position[0], y = starting_position[1], z = starting_position[2], block_cancellation=True) - raise Exception return axis_results @thing_action @@ -275,8 +306,9 @@ class RangeofMotionThing(Thing): cancel: CancelHook, logger: InvocationLogger ): - """ - Measures the range of motion of the stage across the x and y axes. + """Measures the range of motion of the stage across the x and y axes. + + :return: Results dictionary separated into keys of each axis and direction. """ logger.info("Using the stage to measure the Range of Motion. Please ensure you are using a big enough sample.") start_time = time.time() @@ -290,7 +322,7 @@ class RangeofMotionThing(Thing): csm, cancel, logger, - axis = axis_dir[0], + axis = axis_dir[0], direction = axis_dir[1] ) rom_results[f"{axis_dir}"] = axis_dir_results @@ -318,14 +350,9 @@ class RangeofMotionThing(Thing): @thing_property def rom_data(self) -> Optional[Dict]: - """ - The results of the last range of motion calibration that was run. - """ + """The results of the last range of motion calibration that was run.""" filename = '/var/openflexure/settings/range_of_motion/settings.json' with open(filename) as f: rom_object = json.load(f) return rom_object - - - From 61c387720201363e7040b8dd27c905a2e0afe3fe Mon Sep 17 00:00:00 2001 From: Chish36 Date: Fri, 25 Jul 2025 08:58:04 +0100 Subject: [PATCH 12/70] Removed the need for steps_generate function --- .../things/stage_measure.py | 53 +++++++++++-------- 1 file changed, 32 insertions(+), 21 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 0a490cb8..8f056b52 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -56,22 +56,6 @@ def dict_generate(fov_perc: int, stream_resolution: list[int], direction: int, f } return xy_dict -def steps_generate(small_step: int, z_perc: int, big_step: int, direction: int, stream_resolution: list) -> tuple: - '''Creates all required dictionaries of all necessary step sizes.''' - step_sizes_big = dict_generate(big_step, stream_resolution, direction) - step_sizes_small = dict_generate(small_step, stream_resolution, direction) - minimum_offset_small = dict_generate( - small_step, stream_resolution, direction, factor=0.65 - ) - z_steps = dict_generate(z_perc, stream_resolution, direction) - minimum_offset_z = dict_generate(z_perc, stream_resolution, direction, factor=0.65) - wrong_axis_max_small = dict_generate( - small_step, stream_resolution, direction, factor=0.1 - ) - wrong_axis_max_z = dict_generate(z_perc, stream_resolution, direction, factor=0.1) - - return step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big, wrong_axis_max_small, wrong_axis_max_z - def predict_z(positions: list, axis: str, relative_move: float, stage: StageDep, csm: CSMDep) -> float: """Predicts the next z position for a 'big' move using an array of previous positions. This avoids long autofocuses and reduces the possibility of colliding with the sample. @@ -198,15 +182,20 @@ class RangeofMotionThing(Thing): # Generate required dictionaries for step sizes and minimum offsets stream_resolution = cam.stream_resolution - step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big, wrong_axis_max_small, wrong_axis_max_z = steps_generate(20, 50, 200, direction, stream_resolution=stream_resolution) + small_step = 20 + medium_step = 50 + big_step = 200 + + minimum_offset_small = dict_generate(small_step, stream_resolution, direction, factor=0.65) + wrong_axis_max_small = dict_generate(small_step, stream_resolution, direction, factor=0.1) + wrong_axis_max_z = dict_generate(medium_step, stream_resolution, direction, factor=0.1) + step_sizes_big = dict_generate(big_step, stream_resolution, direction) delta = { 'x':0, 'y':0 } - logger.info(f"Using the follwing steps: {step_sizes_small, minimum_offset_small, z_steps, minimum_offset_z, step_sizes_big}") - parasitic_motion = False stage_coords.append(stage.position) @@ -216,7 +205,17 @@ class RangeofMotionThing(Thing): # Medium sized steps for loop in range(5): image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - delta, offset, focus_data, wrong_axis = move_and_measure(step_size = z_steps, axis = axis, delta = delta, image1 = image1, autofocus_proc = True, focus_data = focus_data, csm = csm, autofocus = autofocus, cam = cam) + delta, offset, focus_data, wrong_axis = move_and_measure( + step_size=dict_generate(medium_step, stream_resolution, direction), + axis=axis, + delta=delta, + image1=image1, + autofocus_proc=True, + focus_data=focus_data, + csm=csm, + autofocus=autofocus, + cam=cam, + ) logger.info(f"Offset measured as {delta[axis]}") stage_coords.append(stage.position) cor_lat_steps.append(offset) @@ -252,7 +251,19 @@ class RangeofMotionThing(Thing): # Turn into function for loop in range(3): image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - delta, offset, focus_data, wrong_axis = move_and_measure(step_size = step_sizes_small, axis = axis, delta = delta, image1 = image1, autofocus_proc = False, focus_data = focus_data, csm = csm, autofocus = autofocus, cam = cam) + delta, offset, focus_data, wrong_axis = move_and_measure( + step_size=dict_generate( + small_step, stream_resolution, direction + ), + axis=axis, + delta=delta, + image1=image1, + autofocus_proc=False, + focus_data=focus_data, + csm=csm, + autofocus=autofocus, + cam=cam, + ) logger.info(f"Offset measured as {delta[axis]}") while np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]) and failure_count < 3: From 23d53949b4e4e361286461434985468403ae2dd3 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Fri, 25 Jul 2025 10:24:23 +0100 Subject: [PATCH 13/70] Small moves and medium moves are now in separate functions --- .../things/stage_measure.py | 259 +++++++++++++----- 1 file changed, 187 insertions(+), 72 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 8f056b52..b45e2bc6 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -104,6 +104,157 @@ def move_and_measure(step_size: dict[str, float], axis: str, delta: dict[str, in return delta, offset, focus_data, wrong_axis +def medium_moves( + stream_resolution: list[int], + direction: int, + axis: str, + delta: dict[str,int], + focus_data: list[float], + stage_coords: list, + cor_lat_steps: list, + csm: CSMDep, + cam: CamDep, + stage:StageDep, + autofocus: AutofocusDep, + logger: InvocationLogger + ) -> tuple: + """Carries out the medium sized steps section of the range of motion test to get 5 points to make z position predictions with + + :params stream_resolution: The resolution of the stream from the camera. + :param direction: The direction the stage moves. + :params axis: The axis which is being measured. This must be 'x' or 'y'. + :params delta: A dictionary of 'x' and 'y' offsets. + :params focus_data: A list of focus data returned by the autofocus procedure. + :params stage_coords: A list of all previous positions the stage has been. + :params cor_lat_steps: A list of all correlation values. + :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta and parasitic_motion are updated and tracked after each move. + """ + + parasitic_motion = False + medium_step = 50 + wrong_axis_max_medium = dict_generate( + medium_step, stream_resolution, direction, factor=0.1 + ) + for loop in range(5): + image1 = cv2.resize( + np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 + ) + delta, offset, focus_data, wrong_axis = move_and_measure( + step_size=dict_generate(medium_step, stream_resolution, direction), + axis=axis, + delta=delta, + image1=image1, + autofocus_proc=True, + focus_data=focus_data, + csm=csm, + autofocus=autofocus, + cam=cam, + ) + logger.info(f"Offset measured as {delta[axis]}") + stage_coords.append(stage.position) + cor_lat_steps.append(offset) + + if np.abs(delta[wrong_axis]) > np.abs(wrong_axis_max_medium[wrong_axis]): + logger.info( + f"Parasitic motion detected in {wrong_axis}-axis whilst measuring {axis}-axis." + ) + parasitic_motion = True + break + return stage_coords, cor_lat_steps, delta, parasitic_motion + +def small_moves( + small_step: int, + stream_resolution: list[int], + direction: int, + axis: str, + delta: dict[str, int], + focus_data: list[float], + stage_coords: list, + cor_lat_steps: list, + minimum_offset_small: dict[str, float], + csm: CSMDep, + cam: CamDep, + stage: StageDep, + autofocus: AutofocusDep, + logger: InvocationLogger, +) -> tuple: + """Carries out the medium sized steps section of the range of motion test to get 5 points to make z position predictions with + + :params small_step: The integer value used to generate the small step sizes. + :params stream_resolution: The resolution of the stream from the camera. + :param direction: The direction the stage moves. + :params axis: The axis which is being measured. This must be 'x' or 'y'. + :params delta: A dictionary of 'x' and 'y' offsets. + :params focus_data: A list of focus data returned by the autofocus procedure. + :params stage_coords: A list of all previous positions the stage has been. + :params cor_lat_steps: A list of all correlation values. + :params minimum_offset_small: A dictionary containing the minimum values for a successful correlation. + :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta and parasitic_motion are updated and tracked after each move. + """ + failure_count = 0 + wrong_axis_max_small = dict_generate(small_step, stream_resolution, direction, factor=0.1) + parasitic_motion = False + + for loop in range(3): + image1 = cv2.resize( + np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 + ) + delta, offset, focus_data, wrong_axis = move_and_measure( + step_size=dict_generate(small_step, stream_resolution, direction), + axis=axis, + delta=delta, + image1=image1, + autofocus_proc=False, + focus_data=focus_data, + csm=csm, + autofocus=autofocus, + cam=cam, + ) + logger.info(f"Offset measured as {delta[axis]}") + + while ( + np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]) + and failure_count < 3 + ): + logger.info( + f"Correlation failed. Refocusing to check. Attempt {failure_count + 1}/3" + ) + focus_data = autofocus.looping_autofocus(dz=1000) + image2 = cv2.resize( + np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 + ) + failure_count += 1 + offset = [ + x * 1 + for x in fft_image_tracking.displacement_between_images( + image_0=image1, + image_1=image2, + sigma=10, + fractional_threshold=0.1, + pad=True, + ) + ] # Units is pixels + delta["x"] = int(offset[1]) + delta["y"] = int(offset[0]) + logger.info( + f"Displacement found was {delta[axis]}. Minimum offset is {minimum_offset_small[axis]}" + ) + + stage_coords.append(stage.position) + cor_lat_steps.append(offset) + + if np.abs(delta[wrong_axis]) > np.abs(wrong_axis_max_small[wrong_axis]): + logger.info( + f"Parasitic motion detected in {wrong_axis}-axis whilst measuring {axis}-axis." + ) + parasitic_motion = True + break + + if np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]): # this means the edge has been found + logger.info("Edge has been found.") + break + return stage_coords, cor_lat_steps, delta, parasitic_motion + def motion_detection(axis: str, direction: int, csm: CSMDep, stage: StageDep, cam: CamDep, logger: InvocationLogger) -> dict: """Moves the stage until motion is detected. This happens at the end of each axis and direction and where the stage is moved in the opposite direction until motion is detected. @@ -182,13 +333,8 @@ class RangeofMotionThing(Thing): # Generate required dictionaries for step sizes and minimum offsets stream_resolution = cam.stream_resolution - small_step = 20 - medium_step = 50 big_step = 200 - - minimum_offset_small = dict_generate(small_step, stream_resolution, direction, factor=0.65) - wrong_axis_max_small = dict_generate(small_step, stream_resolution, direction, factor=0.1) - wrong_axis_max_z = dict_generate(medium_step, stream_resolution, direction, factor=0.1) + small_step = 20 step_sizes_big = dict_generate(big_step, stream_resolution, direction) delta = { @@ -202,32 +348,27 @@ class RangeofMotionThing(Thing): logger.info("Moving the stage in 5 medium sized steps.") - # Medium sized steps - for loop in range(5): - image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - delta, offset, focus_data, wrong_axis = move_and_measure( - step_size=dict_generate(medium_step, stream_resolution, direction), - axis=axis, - delta=delta, - image1=image1, - autofocus_proc=True, - focus_data=focus_data, - csm=csm, - autofocus=autofocus, - cam=cam, - ) - logger.info(f"Offset measured as {delta[axis]}") - stage_coords.append(stage.position) - cor_lat_steps.append(offset) - - # Check for parasitic motion - - if np.abs(delta[wrong_axis]) > np.abs(wrong_axis_max_z[wrong_axis]): - logger.info(f"Parasitic motion detected in {wrong_axis}-axis whilst measuring {axis}-axis.") - parasitic_motion = True - break + stage_coords, cor_lat_steps, delta, parasitic_motion = medium_moves( + stream_resolution=stream_resolution, + direction=direction, + axis=axis, + delta=delta, + focus_data=focus_data, + stage_coords=stage_coords, + cor_lat_steps=cor_lat_steps, + csm=csm, + cam=cam, + stage=stage, + autofocus=autofocus, + logger=logger, + ) # 1 big step followed by 3 small steps + + minimum_offset_small = dict_generate( + small_step, stream_resolution, direction, factor=0.65 + ) + while np.abs(delta[axis]) > np.abs(minimum_offset_small[axis]) and not parasitic_motion: z_diff = predict_z(positions = stage_coords, axis = axis, relative_move = step_sizes_big[axis], stage = stage, csm = csm) @@ -246,48 +387,22 @@ class RangeofMotionThing(Thing): stage_coords.append(stage.position) - failure_count = 0 - - # Turn into function - for loop in range(3): - image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - delta, offset, focus_data, wrong_axis = move_and_measure( - step_size=dict_generate( - small_step, stream_resolution, direction - ), - axis=axis, - delta=delta, - image1=image1, - autofocus_proc=False, - focus_data=focus_data, - csm=csm, - autofocus=autofocus, - cam=cam, - ) - logger.info(f"Offset measured as {delta[axis]}") - - while np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]) and failure_count < 3: - logger.info(f"Correlation failed. Refocusing to check. Attempt {failure_count + 1}/3") - focus_data = autofocus.looping_autofocus(dz = 1000) - image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - failure_count += 1 - offset = [x * 1 for x in fft_image_tracking.displacement_between_images( - image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels - delta['x'] = int(offset[1]) - delta['y'] = int(offset[0]) - logger.info(f"Displacement found was {delta[axis]}. Minimum offset is {minimum_offset_small[axis]}") - - stage_coords.append(stage.position) - cor_lat_steps.append(offset) - - if np.abs(delta[wrong_axis]) > np.abs(wrong_axis_max_small[wrong_axis]): - logger.info(f"Parasitic motion detected in {wrong_axis}-axis whilst measuring {axis}-axis.") - parasitic_motion = True - break - - if np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]): # this means the edge has been found - logger.info("Edge has been found.") - break + stage_coords, cor_lat_steps, delta, parasitic_motion = small_moves( + small_step=small_step, + stream_resolution=stream_resolution, + direction=direction, + axis=axis, + delta=delta, + focus_data=focus_data, + stage_coords=stage_coords, + cor_lat_steps=cor_lat_steps, + minimum_offset_small=minimum_offset_small, + csm=csm, + cam=cam, + stage=stage, + autofocus=autofocus, + logger=logger, + ) # Motion detection logger.info("Running motion detection") From 1c7527c2efc0564513eea29a6e83fa7af02a57da Mon Sep 17 00:00:00 2001 From: Chish36 Date: Fri, 25 Jul 2025 12:38:06 +0100 Subject: [PATCH 14/70] Parisitic motion is now tracked with an exception --- .../things/stage_measure.py | 54 +++++++++---------- 1 file changed, 25 insertions(+), 29 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index b45e2bc6..61290a36 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -28,6 +28,8 @@ The range of motion is measured by first taking 5 'medium' sized steps which giv 'small' steps to test that the stage is still moving as expected and has not reached the edge. Once the edge has been found and too account for the possibility that a 'big' step was take just before the edge was reached, the stage is moved in a sequence of increasing pixel sizes in the opposite direction until motion is detected. This is position is taken as the true final position. + +Throughout the test, parasitic motion(motion in the axis not being measured) is tracked and an error is raised if it exceeds a reasonable amount. ''' def quadratic(x, a, b, c): @@ -78,7 +80,14 @@ def predict_z(positions: list, axis: str, relative_move: float, stage: StageDep, return z_diff -def move_and_measure(step_size: dict[str, float], axis: str, delta: dict[str, int], image1, autofocus_proc: bool, focus_data: list[float], csm: CSMDep, autofocus: AutofocusDep, cam:CamDep) -> tuple: +def move_and_measure( + step_size: dict[str, float], + axis: str, delta: dict[str, int], + image1, autofocus_proc: bool, + focus_data: list[float], + csm: CSMDep, + autofocus: AutofocusDep, + cam:CamDep) -> tuple: """Moves the stage and measures the offset between the two positions. :params step_size: A dictionary with keys 'x' and 'y' with pixel distances. @@ -127,10 +136,9 @@ def medium_moves( :params focus_data: A list of focus data returned by the autofocus procedure. :params stage_coords: A list of all previous positions the stage has been. :params cor_lat_steps: A list of all correlation values. - :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta and parasitic_motion are updated and tracked after each move. + :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta is updated and tracked after each move. """ - parasitic_motion = False medium_step = 50 wrong_axis_max_medium = dict_generate( medium_step, stream_resolution, direction, factor=0.1 @@ -154,13 +162,8 @@ def medium_moves( stage_coords.append(stage.position) cor_lat_steps.append(offset) - if np.abs(delta[wrong_axis]) > np.abs(wrong_axis_max_medium[wrong_axis]): - logger.info( - f"Parasitic motion detected in {wrong_axis}-axis whilst measuring {axis}-axis." - ) - parasitic_motion = True - break - return stage_coords, cor_lat_steps, delta, parasitic_motion + assert(np.abs(delta[wrong_axis]) < np.abs(wrong_axis_max_medium[wrong_axis])) + return stage_coords, cor_lat_steps, delta def small_moves( small_step: int, @@ -189,11 +192,10 @@ def small_moves( :params stage_coords: A list of all previous positions the stage has been. :params cor_lat_steps: A list of all correlation values. :params minimum_offset_small: A dictionary containing the minimum values for a successful correlation. - :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta and parasitic_motion are updated and tracked after each move. + :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta is updated and tracked after each move. """ failure_count = 0 wrong_axis_max_small = dict_generate(small_step, stream_resolution, direction, factor=0.1) - parasitic_motion = False for loop in range(3): image1 = cv2.resize( @@ -243,17 +245,12 @@ def small_moves( stage_coords.append(stage.position) cor_lat_steps.append(offset) - if np.abs(delta[wrong_axis]) > np.abs(wrong_axis_max_small[wrong_axis]): - logger.info( - f"Parasitic motion detected in {wrong_axis}-axis whilst measuring {axis}-axis." - ) - parasitic_motion = True - break + assert np.abs(delta[wrong_axis]) < np.abs(wrong_axis_max_small[wrong_axis]) if np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]): # this means the edge has been found logger.info("Edge has been found.") break - return stage_coords, cor_lat_steps, delta, parasitic_motion + return stage_coords, cor_lat_steps, delta def motion_detection(axis: str, direction: int, csm: CSMDep, stage: StageDep, cam: CamDep, logger: InvocationLogger) -> dict: """Moves the stage until motion is detected. This happens at the end of each axis and direction and where the stage is moved in the opposite direction until @@ -318,6 +315,10 @@ class RangeofMotionThing(Thing): starting_position = list(stage.position.values()) + stage_coords = [] + cor_lat_steps = [] + axis_results = {} + try: if direction == 1: dir_word = "positive" @@ -326,10 +327,6 @@ class RangeofMotionThing(Thing): logger.info(f"Beginning the {axis}-axis in the {dir_word} direction") - stage_coords = [] - cor_lat_steps = [] - axis_results = {} - # Generate required dictionaries for step sizes and minimum offsets stream_resolution = cam.stream_resolution @@ -342,13 +339,11 @@ class RangeofMotionThing(Thing): 'y':0 } - parasitic_motion = False - stage_coords.append(stage.position) logger.info("Moving the stage in 5 medium sized steps.") - stage_coords, cor_lat_steps, delta, parasitic_motion = medium_moves( + stage_coords, cor_lat_steps, delta = medium_moves( stream_resolution=stream_resolution, direction=direction, axis=axis, @@ -369,7 +364,7 @@ class RangeofMotionThing(Thing): small_step, stream_resolution, direction, factor=0.65 ) - while np.abs(delta[axis]) > np.abs(minimum_offset_small[axis]) and not parasitic_motion: + while np.abs(delta[axis]) > np.abs(minimum_offset_small[axis]): z_diff = predict_z(positions = stage_coords, axis = axis, relative_move = step_sizes_big[axis], stage = stage, csm = csm) logger.info("Z calibration complete.") @@ -387,7 +382,7 @@ class RangeofMotionThing(Thing): stage_coords.append(stage.position) - stage_coords, cor_lat_steps, delta, parasitic_motion = small_moves( + stage_coords, cor_lat_steps, delta = small_moves( small_step=small_step, stream_resolution=stream_resolution, direction=direction, @@ -416,7 +411,8 @@ class RangeofMotionThing(Thing): "stage_positions": stage_coords, "final_position": final_pos } - + except AssertionError: + logger.info("Parasitic motion detected.") finally: stage.move_absolute(x = starting_position[0], y = starting_position[1], z = starting_position[2], block_cancellation=True) From d494761cbb983f145dedd77e9cd51bdda490aa70 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 29 Jul 2025 12:15:36 +0100 Subject: [PATCH 15/70] Updated docstrings and fixed ruff errors --- .../things/stage_measure.py | 76 ++++++++----------- 1 file changed, 30 insertions(+), 46 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 61290a36..82acc5b4 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -1,15 +1,24 @@ +"""File contains all the functions used to measure the range of motion of the OpenFlexure Microscope translation stage. + +The range of motion is measured by first taking 5 'medium' sized steps which gives enough positions to predict future z positions. Next, one 'big' step is taken followed by 3 +'small' steps to test that the stage is still moving as expected and has not reached the edge. Once the edge has been found and too account for the possibility that a 'big' +step was take just before the edge was reached, the stage is moved in a sequence of increasing pixel sizes in the opposite direction until motion is detected. This is +position is taken as the true final position. + +Throughout the test, parasitic motion(motion in the axis not being measured) is tracked and an error is raised if it exceeds a reasonable amount. +""" + import numpy as np import cv2 import json from PIL import Image import time -from typing import Dict, Optional from scipy.optimize import curve_fit from camera_stage_mapping import fft_image_tracking from labthings_fastapi.thing import Thing from labthings_fastapi.dependencies.thing import direct_thing_client_dependency from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger -from labthings_fastapi.decorators import thing_action, thing_property +from labthings_fastapi.decorators import thing_action from labthings_sangaboard import SangaboardThing from labthings_picamera2.thing import StreamingPiCamera2 from labthings_fastapi.types.numpy import DenumpifyingDict @@ -21,17 +30,6 @@ CamDep = direct_thing_client_dependency(StreamingPiCamera2, "/camera/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") -''' -This file contains all the functions used to measure the range of motion of the OpenFlexure Microscope translation stage. - -The range of motion is measured by first taking 5 'medium' sized steps which gives enough positions to predict future z positions. Next, one 'big' step is taken followed by 3 -'small' steps to test that the stage is still moving as expected and has not reached the edge. Once the edge has been found and too account for the possibility that a 'big' -step was take just before the edge was reached, the stage is moved in a sequence of increasing pixel sizes in the opposite direction until motion is detected. This is -position is taken as the true final position. - -Throughout the test, parasitic motion(motion in the axis not being measured) is tracked and an error is raised if it exceeds a reasonable amount. -''' - def quadratic(x, a, b, c): """Quadratic function. Used for predicting z. @@ -43,8 +41,8 @@ def quadratic(x, a, b, c): """ return a * x**2 + b * x + c -def dict_generate(fov_perc: int, stream_resolution: list[int], direction: int, factor: float = 1) -> dict[str, float]: - """Creates a single dictionary of x and y moves for moving in image coordinates. +def generate_move_dicts(fov_perc: int, stream_resolution: list[int], direction: int, factor: float = 1) -> dict[str, float]: + """Create a single dictionary of x and y moves for moving in image coordinates. :param fov_perc: The percentage of field of view the stage should move by. :param stream_resolution: The resolution of the stream from the camera. @@ -52,15 +50,14 @@ def dict_generate(fov_perc: int, stream_resolution: list[int], direction: int, f :param factor: Multiplicative factor which changes the final result. :return: A dictionary with keys 'x' and 'y' with a pixel distance move the stage can make. """ - xy_dict = { + return { "x": (fov_perc / 100) * stream_resolution[0] * factor * direction, "y": (fov_perc / 100) * stream_resolution[1] * factor * direction, } - return xy_dict + def predict_z(positions: list, axis: str, relative_move: float, stage: StageDep, csm: CSMDep) -> float: - """Predicts the next z position for a 'big' move using an array of previous positions. This avoids long autofocuses and reduces the possibility of colliding with - the sample. + """Predict the next z position for a move using previous positions. :params positions: The list of positions used for predicting z. This will usually be a list of all previuos positions. :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. @@ -76,9 +73,8 @@ def predict_z(positions: list, axis: str, relative_move: float, stage: StageDep, z_positions = [i['z'] for i in positions] parameters, _ = curve_fit(quadratic, lateral_positions, z_positions) z_dest = quadratic(stage.position[axis] + (relative_move/pixel_step[axis]), *parameters) - z_diff = z_dest - stage.position['z'] - return z_diff + return z_dest - stage.position["z"] def move_and_measure( step_size: dict[str, float], @@ -88,7 +84,7 @@ def move_and_measure( csm: CSMDep, autofocus: AutofocusDep, cam:CamDep) -> tuple: - """Moves the stage and measures the offset between the two positions. + """Move the stage and measure the offset between the two positions. :params step_size: A dictionary with keys 'x' and 'y' with pixel distances. :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. @@ -127,7 +123,7 @@ def medium_moves( autofocus: AutofocusDep, logger: InvocationLogger ) -> tuple: - """Carries out the medium sized steps section of the range of motion test to get 5 points to make z position predictions with + """Carries out the medium sized steps section of the range of motion test to get 5 points to make z position predictions with. :params stream_resolution: The resolution of the stream from the camera. :param direction: The direction the stage moves. @@ -138,9 +134,8 @@ def medium_moves( :params cor_lat_steps: A list of all correlation values. :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta is updated and tracked after each move. """ - medium_step = 50 - wrong_axis_max_medium = dict_generate( + wrong_axis_max_medium = generate_move_dicts( medium_step, stream_resolution, direction, factor=0.1 ) for loop in range(5): @@ -148,7 +143,7 @@ def medium_moves( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) delta, offset, focus_data, wrong_axis = move_and_measure( - step_size=dict_generate(medium_step, stream_resolution, direction), + step_size=generate_move_dicts(medium_step, stream_resolution, direction), axis=axis, delta=delta, image1=image1, @@ -181,7 +176,7 @@ def small_moves( autofocus: AutofocusDep, logger: InvocationLogger, ) -> tuple: - """Carries out the medium sized steps section of the range of motion test to get 5 points to make z position predictions with + """Carries out 3 small moves in a given direction and axis. :params small_step: The integer value used to generate the small step sizes. :params stream_resolution: The resolution of the stream from the camera. @@ -195,14 +190,14 @@ def small_moves( :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta is updated and tracked after each move. """ failure_count = 0 - wrong_axis_max_small = dict_generate(small_step, stream_resolution, direction, factor=0.1) + wrong_axis_max_small = generate_move_dicts(small_step, stream_resolution, direction, factor=0.1) for loop in range(3): image1 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) delta, offset, focus_data, wrong_axis = move_and_measure( - step_size=dict_generate(small_step, stream_resolution, direction), + step_size=generate_move_dicts(small_step, stream_resolution, direction), axis=axis, delta=delta, image1=image1, @@ -253,8 +248,7 @@ def small_moves( return stage_coords, cor_lat_steps, delta def motion_detection(axis: str, direction: int, csm: CSMDep, stage: StageDep, cam: CamDep, logger: InvocationLogger) -> dict: - """Moves the stage until motion is detected. This happens at the end of each axis and direction and where the stage is moved in the opposite direction until - motion is detected. + """Move the stage until motion is detected along a specified axis and direction. :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. :params direction: The direction in which the stage was moving previous to motion detection being used. @@ -288,18 +282,17 @@ def motion_detection(axis: str, direction: int, csm: CSMDep, stage: StageDep, ca logger.info("Motion detected.") break - true_final = stage.position - - return true_final + return stage.position class RangeofMotionThing(Thing): + """A class used to measure the range of motion of the stage in X and Y.""" + def rom_axis( self, autofocus: AutofocusDep, stage: StageDep, cam: CamDep, csm: CSMDep, - cancel: CancelHook, logger: InvocationLogger, axis: str, direction: int @@ -310,7 +303,6 @@ class RangeofMotionThing(Thing): :params direction: The direction which is being measured. This must be 1 or -1. :return: Results dictionary containing stage positions, correlations and the final position. """ - focus_data = autofocus.looping_autofocus(dz = 1000) starting_position = list(stage.position.values()) @@ -332,7 +324,7 @@ class RangeofMotionThing(Thing): big_step = 200 small_step = 20 - step_sizes_big = dict_generate(big_step, stream_resolution, direction) + step_sizes_big = generate_move_dicts(big_step, stream_resolution, direction) delta = { 'x':0, @@ -360,7 +352,7 @@ class RangeofMotionThing(Thing): # 1 big step followed by 3 small steps - minimum_offset_small = dict_generate( + minimum_offset_small = generate_move_dicts( small_step, stream_resolution, direction, factor=0.65 ) @@ -470,11 +462,3 @@ class RangeofMotionThing(Thing): return rom_results - @thing_property - def rom_data(self) -> Optional[Dict]: - """The results of the last range of motion calibration that was run.""" - filename = '/var/openflexure/settings/range_of_motion/settings.json' - with open(filename) as f: - rom_object = json.load(f) - - return rom_object From 38906c8ffff07fcfeedac987508fa9de2e4aa13a Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 29 Jul 2025 12:28:20 +0100 Subject: [PATCH 16/70] Moved quadratic function to utilities file --- .../things/stage_measure.py | 12 +----------- src/openflexure_microscope_server/utilities.py | 11 +++++++++++ 2 files changed, 12 insertions(+), 11 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 82acc5b4..63b2a5eb 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -13,6 +13,7 @@ import cv2 import json from PIL import Image import time +from ..utilities import quadratic from scipy.optimize import curve_fit from camera_stage_mapping import fft_image_tracking from labthings_fastapi.thing import Thing @@ -30,17 +31,6 @@ CamDep = direct_thing_client_dependency(StreamingPiCamera2, "/camera/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") -def quadratic(x, a, b, c): - """Quadratic function. Used for predicting z. - - :param x: The points at which to evaluate the quadratic. - :param a: The coefficient of x^2. - :param b: The coefficient of x. - :param c: The constant coefficient. - :return: The quadratic, evaluated at each point in ``x`` - """ - return a * x**2 + b * x + c - def generate_move_dicts(fov_perc: int, stream_resolution: list[int], direction: int, factor: float = 1) -> dict[str, float]: """Create a single dictionary of x and y moves for moving in image coordinates. diff --git a/src/openflexure_microscope_server/utilities.py b/src/openflexure_microscope_server/utilities.py index d9debd1e..b5a4bde3 100644 --- a/src/openflexure_microscope_server/utilities.py +++ b/src/openflexure_microscope_server/utilities.py @@ -327,3 +327,14 @@ def _get_version_from_toml(toml_path: str) -> str: except (IOError, ValueError, KeyError): LOGGER.error("Problem opening pyproject.toml") return "Undefined" + +def quadratic(x, a, b, c): + """Quadratic function. Used for predicting z. + + :param x: The points at which to evaluate the quadratic. + :param a: The coefficient of x^2. + :param b: The coefficient of x. + :param c: The constant coefficient. + :return: The quadratic, evaluated at each point in ``x`` + """ + return a * x**2 + b * x + c From f7264441e13f5ddbd1bcee3ba6300b41067f62b7 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 29 Jul 2025 14:58:54 +0100 Subject: [PATCH 17/70] Updated labthings dependencies --- .../things/stage_measure.py | 74 ++++++++++--------- 1 file changed, 40 insertions(+), 34 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 63b2a5eb..a95bc61e 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -16,20 +16,22 @@ import time from ..utilities import quadratic from scipy.optimize import curve_fit from camera_stage_mapping import fft_image_tracking -from labthings_fastapi.thing import Thing -from labthings_fastapi.dependencies.thing import direct_thing_client_dependency -from labthings_fastapi.dependencies.invocation import CancelHook, InvocationLogger -from labthings_fastapi.decorators import thing_action +import labthings_fastapi as lt from labthings_sangaboard import SangaboardThing from labthings_picamera2.thing import StreamingPiCamera2 from labthings_fastapi.types.numpy import DenumpifyingDict -from openflexure_microscope_server.things.autofocus import AutofocusThing -from openflexure_microscope_server.things.camera_stage_mapping import CameraStageMapper -StageDep = direct_thing_client_dependency(SangaboardThing, "/stage/") -CamDep = direct_thing_client_dependency(StreamingPiCamera2, "/camera/") -CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") -AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") +# Things +from .autofocus import AutofocusThing +from .camera_stage_mapping import CameraStageMapper +from .camera import CameraDependency as CamDep +from .stage import StageDependency as StageDep + +CSMDep = lt.deps.direct_thing_client_dependency( + CameraStageMapper, "/camera_stage_mapping/" +) +AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/") + def generate_move_dicts(fov_perc: int, stream_resolution: list[int], direction: int, factor: float = 1) -> dict[str, float]: """Create a single dictionary of x and y moves for moving in image coordinates. @@ -100,19 +102,19 @@ def move_and_measure( return delta, offset, focus_data, wrong_axis def medium_moves( - stream_resolution: list[int], - direction: int, - axis: str, - delta: dict[str,int], - focus_data: list[float], - stage_coords: list, - cor_lat_steps: list, - csm: CSMDep, - cam: CamDep, - stage:StageDep, - autofocus: AutofocusDep, - logger: InvocationLogger - ) -> tuple: + stream_resolution: list[int], + direction: int, + axis: str, + delta: dict[str, int], + focus_data: list[float], + stage_coords: list, + cor_lat_steps: list, + csm: CSMDep, + cam: CamDep, + stage: StageDep, + autofocus: AutofocusDep, + logger: lt.deps.InvocationLogger, +) -> tuple: """Carries out the medium sized steps section of the range of motion test to get 5 points to make z position predictions with. :params stream_resolution: The resolution of the stream from the camera. @@ -164,7 +166,7 @@ def small_moves( cam: CamDep, stage: StageDep, autofocus: AutofocusDep, - logger: InvocationLogger, + logger: lt.deps.InvocationLogger, ) -> tuple: """Carries out 3 small moves in a given direction and axis. @@ -237,7 +239,14 @@ def small_moves( break return stage_coords, cor_lat_steps, delta -def motion_detection(axis: str, direction: int, csm: CSMDep, stage: StageDep, cam: CamDep, logger: InvocationLogger) -> dict: +def motion_detection( + axis: str, + direction: int, + csm: CSMDep, + stage: StageDep, + cam: CamDep, + logger: lt.deps.InvocationLogger, +) -> dict: """Move the stage until motion is detected along a specified axis and direction. :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. @@ -274,7 +283,7 @@ def motion_detection(axis: str, direction: int, csm: CSMDep, stage: StageDep, ca return stage.position -class RangeofMotionThing(Thing): +class RangeofMotionThing(lt.Thing): """A class used to measure the range of motion of the stage in X and Y.""" def rom_axis( @@ -283,9 +292,9 @@ class RangeofMotionThing(Thing): stage: StageDep, cam: CamDep, csm: CSMDep, - logger: InvocationLogger, + logger: lt.deps.InvocationLogger, axis: str, - direction: int + direction: int, ) -> dict: """Measure the range of motion in a single axis and direction. @@ -400,15 +409,14 @@ class RangeofMotionThing(Thing): return axis_results - @thing_action + @lt.thing_action def rom_main( self, autofocus: AutofocusDep, stage: StageDep, cam: CamDep, csm: CSMDep, - cancel: CancelHook, - logger: InvocationLogger + logger: lt.deps.InvocationLogger, ): """Measures the range of motion of the stage across the x and y axes. @@ -424,7 +432,6 @@ class RangeofMotionThing(Thing): stage, cam, csm, - cancel, logger, axis = axis_dir[0], direction = axis_dir[1] @@ -445,10 +452,9 @@ class RangeofMotionThing(Thing): rom_results["CSM Matrix"] = csm.image_to_stage_displacement_matrix rom_results["Step Range"] = step_range - self.thing_settings["rom_data"] = DenumpifyingDict(rom_results).model_dump() + self.rom_data = DenumpifyingDict(rom_results).model_dump() with open("/var/openflexure/ROM_Test_Results.json", 'w') as file_object: json.dump(rom_results, file_object, indent = 3) return rom_results - From 703e8293d592c54d783b8efa70727dc2234aa41c Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 29 Jul 2025 17:06:40 +0100 Subject: [PATCH 18/70] Rom data is now saved to settings.json --- .../things/stage_measure.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index a95bc61e..e89bf861 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -17,8 +17,6 @@ from ..utilities import quadratic from scipy.optimize import curve_fit from camera_stage_mapping import fft_image_tracking import labthings_fastapi as lt -from labthings_sangaboard import SangaboardThing -from labthings_picamera2.thing import StreamingPiCamera2 from labthings_fastapi.types.numpy import DenumpifyingDict # Things @@ -319,7 +317,7 @@ class RangeofMotionThing(lt.Thing): logger.info(f"Beginning the {axis}-axis in the {dir_word} direction") # Generate required dictionaries for step sizes and minimum offsets - stream_resolution = cam.stream_resolution + stream_resolution = [820,616] big_step = 200 small_step = 20 @@ -452,9 +450,13 @@ class RangeofMotionThing(lt.Thing): rom_results["CSM Matrix"] = csm.image_to_stage_displacement_matrix rom_results["Step Range"] = step_range - self.rom_data = DenumpifyingDict(rom_results).model_dump() + self.last_calibration = DenumpifyingDict(rom_results).model_dump() with open("/var/openflexure/ROM_Test_Results.json", 'w') as file_object: json.dump(rom_results, file_object, indent = 3) return rom_results + + last_calibration = lt.ThingSetting( + initial_value=None, model=dict, readonly=True + ) From 47bc1479e2ae5706b3d4713764885541e9c6711f Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 30 Jul 2025 10:39:09 +0100 Subject: [PATCH 19/70] Changed function names. --- src/openflexure_microscope_server/things/stage_measure.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index e89bf861..2c4d135e 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -99,7 +99,7 @@ def move_and_measure( return delta, offset, focus_data, wrong_axis -def medium_moves( +def acquie_z_predict_points( stream_resolution: list[int], direction: int, axis: str, @@ -150,7 +150,7 @@ def medium_moves( assert(np.abs(delta[wrong_axis]) < np.abs(wrong_axis_max_medium[wrong_axis])) return stage_coords, cor_lat_steps, delta -def small_moves( +def check_stage_operation( small_step: int, stream_resolution: list[int], direction: int, @@ -332,7 +332,7 @@ class RangeofMotionThing(lt.Thing): logger.info("Moving the stage in 5 medium sized steps.") - stage_coords, cor_lat_steps, delta = medium_moves( + stage_coords, cor_lat_steps, delta = acquie_z_predict_points( stream_resolution=stream_resolution, direction=direction, axis=axis, @@ -371,7 +371,7 @@ class RangeofMotionThing(lt.Thing): stage_coords.append(stage.position) - stage_coords, cor_lat_steps, delta = small_moves( + stage_coords, cor_lat_steps, delta = check_stage_operation( small_step=small_step, stream_resolution=stream_resolution, direction=direction, From db0dc112749a84d4d170ad5d8cc6e34c382d7a19 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 30 Jul 2025 11:10:39 +0100 Subject: [PATCH 20/70] Tracking correlations and stage coordinates with class now. --- .../things/stage_measure.py | 59 +++++++++++-------- 1 file changed, 33 insertions(+), 26 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 2c4d135e..1e294ae8 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -105,14 +105,13 @@ def acquie_z_predict_points( axis: str, delta: dict[str, int], focus_data: list[float], - stage_coords: list, - cor_lat_steps: list, + data, csm: CSMDep, cam: CamDep, stage: StageDep, autofocus: AutofocusDep, logger: lt.deps.InvocationLogger, -) -> tuple: +) -> dict[str, int]: """Carries out the medium sized steps section of the range of motion test to get 5 points to make z position predictions with. :params stream_resolution: The resolution of the stream from the camera. @@ -144,11 +143,11 @@ def acquie_z_predict_points( cam=cam, ) logger.info(f"Offset measured as {delta[axis]}") - stage_coords.append(stage.position) - cor_lat_steps.append(offset) + + data.measure(stage.position, offset) assert(np.abs(delta[wrong_axis]) < np.abs(wrong_axis_max_medium[wrong_axis])) - return stage_coords, cor_lat_steps, delta + return delta def check_stage_operation( small_step: int, @@ -157,15 +156,14 @@ def check_stage_operation( axis: str, delta: dict[str, int], focus_data: list[float], - stage_coords: list, - cor_lat_steps: list, + data, minimum_offset_small: dict[str, float], csm: CSMDep, cam: CamDep, stage: StageDep, autofocus: AutofocusDep, logger: lt.deps.InvocationLogger, -) -> tuple: +) -> dict[str, int]: """Carries out 3 small moves in a given direction and axis. :params small_step: The integer value used to generate the small step sizes. @@ -227,15 +225,14 @@ def check_stage_operation( f"Displacement found was {delta[axis]}. Minimum offset is {minimum_offset_small[axis]}" ) - stage_coords.append(stage.position) - cor_lat_steps.append(offset) + data.measure(stage.position, offset) assert np.abs(delta[wrong_axis]) < np.abs(wrong_axis_max_small[wrong_axis]) if np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]): # this means the edge has been found logger.info("Edge has been found.") break - return stage_coords, cor_lat_steps, delta + return delta def motion_detection( axis: str, @@ -281,6 +278,19 @@ def motion_detection( return stage.position +class RomDataTracker(): + """Class for tracking range of motion data.""" + + def __init__(self, stage_coords, cor_lat_steps): + """Define useful data tracked througout test.""" + self.stage_coords = stage_coords + self.cor_lat_steps = cor_lat_steps + + def measure(self, current_pos, cor): + """Store useful data.""" + self.stage_coords.append(current_pos) + self.cor_lat_steps.append(cor) + class RangeofMotionThing(lt.Thing): """A class used to measure the range of motion of the stage in X and Y.""" @@ -304,8 +314,7 @@ class RangeofMotionThing(lt.Thing): starting_position = list(stage.position.values()) - stage_coords = [] - cor_lat_steps = [] + rom_data = RomDataTracker([], []) axis_results = {} try: @@ -328,18 +337,17 @@ class RangeofMotionThing(lt.Thing): 'y':0 } - stage_coords.append(stage.position) + rom_data.stage_coords.append(stage.position) logger.info("Moving the stage in 5 medium sized steps.") - stage_coords, cor_lat_steps, delta = acquie_z_predict_points( + delta = acquie_z_predict_points( stream_resolution=stream_resolution, direction=direction, axis=axis, delta=delta, focus_data=focus_data, - stage_coords=stage_coords, - cor_lat_steps=cor_lat_steps, + data=rom_data, csm=csm, cam=cam, stage=stage, @@ -354,7 +362,7 @@ class RangeofMotionThing(lt.Thing): ) while np.abs(delta[axis]) > np.abs(minimum_offset_small[axis]): - z_diff = predict_z(positions = stage_coords, axis = axis, relative_move = step_sizes_big[axis], stage = stage, csm = csm) + z_diff = predict_z(positions = rom_data.stage_coords, axis = axis, relative_move = step_sizes_big[axis], stage = stage, csm = csm) logger.info("Z calibration complete.") @@ -369,17 +377,16 @@ class RangeofMotionThing(lt.Thing): focus_data = autofocus.looping_autofocus(dz = 800) - stage_coords.append(stage.position) + rom_data.stage_coords.append(stage.position) - stage_coords, cor_lat_steps, delta = check_stage_operation( + delta = check_stage_operation( small_step=small_step, stream_resolution=stream_resolution, direction=direction, axis=axis, delta=delta, focus_data=focus_data, - stage_coords=stage_coords, - cor_lat_steps=cor_lat_steps, + data = rom_data, minimum_offset_small=minimum_offset_small, csm=csm, cam=cam, @@ -393,11 +400,11 @@ class RangeofMotionThing(lt.Thing): final_pos = motion_detection(axis = axis, direction = direction, csm = csm, stage = stage, cam = cam, logger = logger) - stage_coords[np.shape(stage_coords)[0] - 1] = final_pos + rom_data.stage_coords[np.shape(rom_data.stage_coords)[0] - 1] = final_pos axis_results = { - "correlation_lateral_steps": cor_lat_steps, - "stage_positions": stage_coords, + "correlation_lateral_steps": rom_data.cor_lat_steps, + "stage_positions": rom_data.stage_coords, "final_position": final_pos } except AssertionError: From 63bd34f5488140cb0128079c9f8e9a3572f759c8 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 30 Jul 2025 11:16:35 +0100 Subject: [PATCH 21/70] Removed focus_data --- .../things/stage_measure.py | 26 ++++++------------- 1 file changed, 8 insertions(+), 18 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 1e294ae8..1874a106 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -70,7 +70,6 @@ def move_and_measure( step_size: dict[str, float], axis: str, delta: dict[str, int], image1, autofocus_proc: bool, - focus_data: list[float], csm: CSMDep, autofocus: AutofocusDep, cam:CamDep) -> tuple: @@ -81,8 +80,7 @@ def move_and_measure( :params delta: A dictionary of 'x' and 'y' offsets. :params image1: An image taken before moving to be correlated with image2. :params autofocus_proc: If true, looping.autofocus will be used after the stage moves. - :params focus_data: A list of focus data returned by the autofocus procedure. - :return: All required data for the next move. This includes the updated delta value, offset and focus_data. Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. + :return: All required data for the next move. This includes the updated delta value and offset. Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. """ if axis == 'x': csm.move_in_image_coordinates(x = step_size['x'], y = 0) @@ -91,20 +89,19 @@ def move_and_measure( csm.move_in_image_coordinates(x = 0, y = step_size['y']) wrong_axis = 'x' if autofocus_proc: - focus_data = autofocus.looping_autofocus(dz = 800) + autofocus.looping_autofocus(dz = 800) image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) offset = [x * 1 for x in fft_image_tracking.displacement_between_images(image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels delta['x'] = int(offset[1]) delta['y'] = int(offset[0]) - return delta, offset, focus_data, wrong_axis + return delta, offset, wrong_axis def acquie_z_predict_points( stream_resolution: list[int], direction: int, axis: str, delta: dict[str, int], - focus_data: list[float], data, csm: CSMDep, cam: CamDep, @@ -118,7 +115,6 @@ def acquie_z_predict_points( :param direction: The direction the stage moves. :params axis: The axis which is being measured. This must be 'x' or 'y'. :params delta: A dictionary of 'x' and 'y' offsets. - :params focus_data: A list of focus data returned by the autofocus procedure. :params stage_coords: A list of all previous positions the stage has been. :params cor_lat_steps: A list of all correlation values. :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta is updated and tracked after each move. @@ -131,13 +127,12 @@ def acquie_z_predict_points( image1 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) - delta, offset, focus_data, wrong_axis = move_and_measure( + delta, offset, wrong_axis = move_and_measure( step_size=generate_move_dicts(medium_step, stream_resolution, direction), axis=axis, delta=delta, image1=image1, autofocus_proc=True, - focus_data=focus_data, csm=csm, autofocus=autofocus, cam=cam, @@ -155,7 +150,6 @@ def check_stage_operation( direction: int, axis: str, delta: dict[str, int], - focus_data: list[float], data, minimum_offset_small: dict[str, float], csm: CSMDep, @@ -171,7 +165,6 @@ def check_stage_operation( :param direction: The direction the stage moves. :params axis: The axis which is being measured. This must be 'x' or 'y'. :params delta: A dictionary of 'x' and 'y' offsets. - :params focus_data: A list of focus data returned by the autofocus procedure. :params stage_coords: A list of all previous positions the stage has been. :params cor_lat_steps: A list of all correlation values. :params minimum_offset_small: A dictionary containing the minimum values for a successful correlation. @@ -184,13 +177,12 @@ def check_stage_operation( image1 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) - delta, offset, focus_data, wrong_axis = move_and_measure( + delta, offset, wrong_axis = move_and_measure( step_size=generate_move_dicts(small_step, stream_resolution, direction), axis=axis, delta=delta, image1=image1, autofocus_proc=False, - focus_data=focus_data, csm=csm, autofocus=autofocus, cam=cam, @@ -204,7 +196,7 @@ def check_stage_operation( logger.info( f"Correlation failed. Refocusing to check. Attempt {failure_count + 1}/3" ) - focus_data = autofocus.looping_autofocus(dz=1000) + autofocus.looping_autofocus(dz=1000) image2 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) @@ -310,7 +302,7 @@ class RangeofMotionThing(lt.Thing): :params direction: The direction which is being measured. This must be 1 or -1. :return: Results dictionary containing stage positions, correlations and the final position. """ - focus_data = autofocus.looping_autofocus(dz = 1000) + autofocus.looping_autofocus(dz = 1000) starting_position = list(stage.position.values()) @@ -346,7 +338,6 @@ class RangeofMotionThing(lt.Thing): direction=direction, axis=axis, delta=delta, - focus_data=focus_data, data=rom_data, csm=csm, cam=cam, @@ -375,7 +366,7 @@ class RangeofMotionThing(lt.Thing): else: csm.move_in_image_coordinates(x = 0, y = step_sizes_big['y']) - focus_data = autofocus.looping_autofocus(dz = 800) + autofocus.looping_autofocus(dz = 800) rom_data.stage_coords.append(stage.position) @@ -385,7 +376,6 @@ class RangeofMotionThing(lt.Thing): direction=direction, axis=axis, delta=delta, - focus_data=focus_data, data = rom_data, minimum_offset_small=minimum_offset_small, csm=csm, From 4106d0348c5d771367cd8897ba56841d78d4cb55 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 30 Jul 2025 12:04:29 +0100 Subject: [PATCH 22/70] Delta is tracked with class now --- .../things/stage_measure.py | 59 ++++++++----------- 1 file changed, 25 insertions(+), 34 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 1874a106..3b9a78c6 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -68,7 +68,7 @@ def predict_z(positions: list, axis: str, relative_move: float, stage: StageDep, def move_and_measure( step_size: dict[str, float], - axis: str, delta: dict[str, int], + axis: str, data, image1, autofocus_proc: bool, csm: CSMDep, autofocus: AutofocusDep, @@ -92,23 +92,22 @@ def move_and_measure( autofocus.looping_autofocus(dz = 800) image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) offset = [x * 1 for x in fft_image_tracking.displacement_between_images(image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels - delta['x'] = int(offset[1]) - delta['y'] = int(offset[0]) + data.delta['x'] = int(offset[1]) + data.delta['y'] = int(offset[0]) - return delta, offset, wrong_axis + return offset, wrong_axis def acquie_z_predict_points( stream_resolution: list[int], direction: int, axis: str, - delta: dict[str, int], data, csm: CSMDep, cam: CamDep, stage: StageDep, autofocus: AutofocusDep, logger: lt.deps.InvocationLogger, -) -> dict[str, int]: +) -> None: """Carries out the medium sized steps section of the range of motion test to get 5 points to make z position predictions with. :params stream_resolution: The resolution of the stream from the camera. @@ -127,29 +126,27 @@ def acquie_z_predict_points( image1 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) - delta, offset, wrong_axis = move_and_measure( + offset, wrong_axis = move_and_measure( step_size=generate_move_dicts(medium_step, stream_resolution, direction), axis=axis, - delta=delta, + data=data, image1=image1, autofocus_proc=True, csm=csm, autofocus=autofocus, cam=cam, ) - logger.info(f"Offset measured as {delta[axis]}") + logger.info(f"Offset measured as {data.delta[axis]}") data.measure(stage.position, offset) - assert(np.abs(delta[wrong_axis]) < np.abs(wrong_axis_max_medium[wrong_axis])) - return delta + assert(np.abs(data.delta[wrong_axis]) < np.abs(wrong_axis_max_medium[wrong_axis])) def check_stage_operation( small_step: int, stream_resolution: list[int], direction: int, axis: str, - delta: dict[str, int], data, minimum_offset_small: dict[str, float], csm: CSMDep, @@ -157,7 +154,7 @@ def check_stage_operation( stage: StageDep, autofocus: AutofocusDep, logger: lt.deps.InvocationLogger, -) -> dict[str, int]: +) -> None: """Carries out 3 small moves in a given direction and axis. :params small_step: The integer value used to generate the small step sizes. @@ -177,20 +174,20 @@ def check_stage_operation( image1 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) - delta, offset, wrong_axis = move_and_measure( + offset, wrong_axis = move_and_measure( step_size=generate_move_dicts(small_step, stream_resolution, direction), axis=axis, - delta=delta, + data=data, image1=image1, autofocus_proc=False, csm=csm, autofocus=autofocus, cam=cam, ) - logger.info(f"Offset measured as {delta[axis]}") + logger.info(f"Offset measured as {data.delta[axis]}") while ( - np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]) + np.abs(data.delta[axis]) < np.abs(minimum_offset_small[axis]) and failure_count < 3 ): logger.info( @@ -211,20 +208,19 @@ def check_stage_operation( pad=True, ) ] # Units is pixels - delta["x"] = int(offset[1]) - delta["y"] = int(offset[0]) + data.delta["x"] = int(offset[1]) + data.delta["y"] = int(offset[0]) logger.info( - f"Displacement found was {delta[axis]}. Minimum offset is {minimum_offset_small[axis]}" + f"Displacement found was {data.delta[axis]}. Minimum offset is {minimum_offset_small[axis]}" ) data.measure(stage.position, offset) - assert np.abs(delta[wrong_axis]) < np.abs(wrong_axis_max_small[wrong_axis]) + assert np.abs(data.delta[wrong_axis]) < np.abs(wrong_axis_max_small[wrong_axis]) - if np.abs(delta[axis]) < np.abs(minimum_offset_small[axis]): # this means the edge has been found + if np.abs(data.delta[axis]) < np.abs(minimum_offset_small[axis]): # this means the edge has been found logger.info("Edge has been found.") break - return delta def motion_detection( axis: str, @@ -273,10 +269,11 @@ def motion_detection( class RomDataTracker(): """Class for tracking range of motion data.""" - def __init__(self, stage_coords, cor_lat_steps): + def __init__(self, stage_coords, cor_lat_steps, delta): """Define useful data tracked througout test.""" self.stage_coords = stage_coords self.cor_lat_steps = cor_lat_steps + self.delta = delta def measure(self, current_pos, cor): """Store useful data.""" @@ -306,7 +303,7 @@ class RangeofMotionThing(lt.Thing): starting_position = list(stage.position.values()) - rom_data = RomDataTracker([], []) + rom_data = RomDataTracker([], [], {'x':0, 'y':0}) axis_results = {} try: @@ -324,20 +321,15 @@ class RangeofMotionThing(lt.Thing): small_step = 20 step_sizes_big = generate_move_dicts(big_step, stream_resolution, direction) - delta = { - 'x':0, - 'y':0 - } rom_data.stage_coords.append(stage.position) logger.info("Moving the stage in 5 medium sized steps.") - delta = acquie_z_predict_points( + acquie_z_predict_points( stream_resolution=stream_resolution, direction=direction, axis=axis, - delta=delta, data=rom_data, csm=csm, cam=cam, @@ -352,7 +344,7 @@ class RangeofMotionThing(lt.Thing): small_step, stream_resolution, direction, factor=0.65 ) - while np.abs(delta[axis]) > np.abs(minimum_offset_small[axis]): + while np.abs(rom_data.delta[axis]) > np.abs(minimum_offset_small[axis]): z_diff = predict_z(positions = rom_data.stage_coords, axis = axis, relative_move = step_sizes_big[axis], stage = stage, csm = csm) logger.info("Z calibration complete.") @@ -370,12 +362,11 @@ class RangeofMotionThing(lt.Thing): rom_data.stage_coords.append(stage.position) - delta = check_stage_operation( + check_stage_operation( small_step=small_step, stream_resolution=stream_resolution, direction=direction, axis=axis, - delta=delta, data = rom_data, minimum_offset_small=minimum_offset_small, csm=csm, From f0a67bac197107048d74edb678f47ada40959aaa Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 30 Jul 2025 12:13:55 +0100 Subject: [PATCH 23/70] Type set and added default values to data tracker class --- src/openflexure_microscope_server/things/stage_measure.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 3b9a78c6..f34e2ccc 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -269,7 +269,7 @@ def motion_detection( class RomDataTracker(): """Class for tracking range of motion data.""" - def __init__(self, stage_coords, cor_lat_steps, delta): + def __init__(self, stage_coords: list[dict[str, int]] = [], cor_lat_steps: list[list[float]] = [], delta: dict = {'x':0, 'y':0}): """Define useful data tracked througout test.""" self.stage_coords = stage_coords self.cor_lat_steps = cor_lat_steps @@ -303,7 +303,7 @@ class RangeofMotionThing(lt.Thing): starting_position = list(stage.position.values()) - rom_data = RomDataTracker([], [], {'x':0, 'y':0}) + rom_data = RomDataTracker() axis_results = {} try: @@ -381,7 +381,7 @@ class RangeofMotionThing(lt.Thing): final_pos = motion_detection(axis = axis, direction = direction, csm = csm, stage = stage, cam = cam, logger = logger) - rom_data.stage_coords[np.shape(rom_data.stage_coords)[0] - 1] = final_pos + rom_data.stage_coords[np.shape(np.array(rom_data.stage_coords))[0] - 1] = final_pos axis_results = { "correlation_lateral_steps": rom_data.cor_lat_steps, From d035b186a2baf6094179512a657a879f01b5a145 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 30 Jul 2025 12:36:09 +0100 Subject: [PATCH 24/70] Updated docstrings --- .../things/stage_measure.py | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index f34e2ccc..7248a030 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -77,7 +77,7 @@ def move_and_measure( :params step_size: A dictionary with keys 'x' and 'y' with pixel distances. :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. - :params delta: A dictionary of 'x' and 'y' offsets. + :params data: The object used to track stage coordinates, correlation and delta. :params image1: An image taken before moving to be correlated with image2. :params autofocus_proc: If true, looping.autofocus will be used after the stage moves. :return: All required data for the next move. This includes the updated delta value and offset. Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. @@ -97,7 +97,7 @@ def move_and_measure( return offset, wrong_axis -def acquie_z_predict_points( +def acquire_z_predict_points( stream_resolution: list[int], direction: int, axis: str, @@ -113,9 +113,7 @@ def acquie_z_predict_points( :params stream_resolution: The resolution of the stream from the camera. :param direction: The direction the stage moves. :params axis: The axis which is being measured. This must be 'x' or 'y'. - :params delta: A dictionary of 'x' and 'y' offsets. - :params stage_coords: A list of all previous positions the stage has been. - :params cor_lat_steps: A list of all correlation values. + :params data: The object used to track stage coordinates, correlation and delta. :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta is updated and tracked after each move. """ medium_step = 50 @@ -161,9 +159,7 @@ def check_stage_operation( :params stream_resolution: The resolution of the stream from the camera. :param direction: The direction the stage moves. :params axis: The axis which is being measured. This must be 'x' or 'y'. - :params delta: A dictionary of 'x' and 'y' offsets. - :params stage_coords: A list of all previous positions the stage has been. - :params cor_lat_steps: A list of all correlation values. + :params data: The object used to track stage coordinates, correlation and delta. :params minimum_offset_small: A dictionary containing the minimum values for a successful correlation. :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta is updated and tracked after each move. """ @@ -321,12 +317,11 @@ class RangeofMotionThing(lt.Thing): small_step = 20 step_sizes_big = generate_move_dicts(big_step, stream_resolution, direction) - rom_data.stage_coords.append(stage.position) logger.info("Moving the stage in 5 medium sized steps.") - acquie_z_predict_points( + acquire_z_predict_points( stream_resolution=stream_resolution, direction=direction, axis=axis, @@ -353,6 +348,7 @@ class RangeofMotionThing(lt.Thing): logger.info(f"Moved in z by {z_diff}") + # Big step if axis == 'x': csm.move_in_image_coordinates(x = step_sizes_big['x'], y = 0) else: From 8ce7f9f7790872324351c8a6a7e28420f5005cf2 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 30 Jul 2025 12:39:49 +0100 Subject: [PATCH 25/70] Removed empty lines --- .../things/stage_measure.py | 9 --------- 1 file changed, 9 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 7248a030..b30d48ab 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -312,7 +312,6 @@ class RangeofMotionThing(lt.Thing): # Generate required dictionaries for step sizes and minimum offsets stream_resolution = [820,616] - big_step = 200 small_step = 20 step_sizes_big = generate_move_dicts(big_step, stream_resolution, direction) @@ -320,7 +319,6 @@ class RangeofMotionThing(lt.Thing): rom_data.stage_coords.append(stage.position) logger.info("Moving the stage in 5 medium sized steps.") - acquire_z_predict_points( stream_resolution=stream_resolution, direction=direction, @@ -343,9 +341,7 @@ class RangeofMotionThing(lt.Thing): z_diff = predict_z(positions = rom_data.stage_coords, axis = axis, relative_move = step_sizes_big[axis], stage = stage, csm = csm) logger.info("Z calibration complete.") - stage.move_relative(z = z_diff) - logger.info(f"Moved in z by {z_diff}") # Big step @@ -355,7 +351,6 @@ class RangeofMotionThing(lt.Thing): csm.move_in_image_coordinates(x = 0, y = step_sizes_big['y']) autofocus.looping_autofocus(dz = 800) - rom_data.stage_coords.append(stage.position) check_stage_operation( @@ -374,9 +369,7 @@ class RangeofMotionThing(lt.Thing): # Motion detection logger.info("Running motion detection") - final_pos = motion_detection(axis = axis, direction = direction, csm = csm, stage = stage, cam = cam, logger = logger) - rom_data.stage_coords[np.shape(np.array(rom_data.stage_coords))[0] - 1] = final_pos axis_results = { @@ -425,9 +418,7 @@ class RangeofMotionThing(lt.Thing): x_range = abs(rom_results["['x', 1]"]["final_position"]["x"] - rom_results["['x', -1]"]["final_position"]["x"]) y_range = abs(rom_results["['y', 1]"]["final_position"]["y"] - rom_results["['y', -1]"]["final_position"]["y"]) - step_range = [x_range, y_range] - logger.info(f"Range of motion is {x_range} X {y_range}") rom_results["Time"] = total_time From 32129125d6a351335ac6df074fbd5db104ddf37d Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 30 Jul 2025 13:21:40 +0100 Subject: [PATCH 26/70] Fixed spelling --- src/openflexure_microscope_server/things/stage_measure.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index b30d48ab..4753aee3 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -233,7 +233,7 @@ def motion_detection( :return: The stage coordinates where motion was detected. """ displacements = [1,2,4,8,16,32,64,128,256,512] # Array of increasing step sizes - motion_minimum = 20 # minimum nuber of pixels for motion to be detected + motion_minimum = 20 # minimum number of pixels for motion to be detected this_motion_step = { 'x': 0, @@ -266,7 +266,7 @@ class RomDataTracker(): """Class for tracking range of motion data.""" def __init__(self, stage_coords: list[dict[str, int]] = [], cor_lat_steps: list[list[float]] = [], delta: dict = {'x':0, 'y':0}): - """Define useful data tracked througout test.""" + """Define useful data tracked throughout test.""" self.stage_coords = stage_coords self.cor_lat_steps = cor_lat_steps self.delta = delta From 6579a65f8407884a01ac1519644fc876d13369c3 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 30 Jul 2025 15:40:10 +0100 Subject: [PATCH 27/70] Made changes based on ruff-lint-increased feedback --- .../things/stage_measure.py | 125 +++++++++++++----- 1 file changed, 89 insertions(+), 36 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 4753aee3..b457f043 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -1,11 +1,15 @@ -"""File contains all the functions used to measure the range of motion of the OpenFlexure Microscope translation stage. +"""File contains all the functions used to measure the range of motion. -The range of motion is measured by first taking 5 'medium' sized steps which gives enough positions to predict future z positions. Next, one 'big' step is taken followed by 3 -'small' steps to test that the stage is still moving as expected and has not reached the edge. Once the edge has been found and too account for the possibility that a 'big' -step was take just before the edge was reached, the stage is moved in a sequence of increasing pixel sizes in the opposite direction until motion is detected. This is +The range of motion is measured by first taking 5 'medium' sized steps which gives enough positions +to predict future z positions. Next, one 'big' step is taken followed by 3 +'small' steps to test that the stage is still moving as expected and has not reached the edge. +Once the edge has been found and too account for the possibility that a 'big' +step was take just before the edge was reached, the stage is moved in a sequence +of increasing pixel sizes in the opposite direction until motion is detected. This is position is taken as the true final position. -Throughout the test, parasitic motion(motion in the axis not being measured) is tracked and an error is raised if it exceeds a reasonable amount. +Throughout the test, parasitic motion(motion in the axis not being measured) +is tracked and an error is raised if it exceeds a reasonable amount. """ import numpy as np @@ -31,7 +35,11 @@ CSMDep = lt.deps.direct_thing_client_dependency( AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/") -def generate_move_dicts(fov_perc: int, stream_resolution: list[int], direction: int, factor: float = 1) -> dict[str, float]: +def generate_move_dicts(fov_perc: int, + stream_resolution: list[int], + direction: int, + factor: float = 1 + ) -> dict[str, float]: """Create a single dictionary of x and y moves for moving in image coordinates. :param fov_perc: The percentage of field of view the stage should move by. @@ -46,12 +54,19 @@ def generate_move_dicts(fov_perc: int, stream_resolution: list[int], direction: } -def predict_z(positions: list, axis: str, relative_move: float, stage: StageDep, csm: CSMDep) -> float: +def predict_z(positions: list, + axis: str, + relative_move: float, + stage: StageDep, + csm: CSMDep + ) -> float: """Predict the next z position for a move using previous positions. - :params positions: The list of positions used for predicting z. This will usually be a list of all previuos positions. + :params positions: The list of positions used for predicting z. + This will usually be a list of all previous positions. :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. - :params relative_move: The move which the function is trying to predict z for. Here, this is inputted with units of pixels. + :params relative_move: The move which the function is trying to predict z for. + Here, this is inputted with units of pixels. :return: A number of pixels the stage needs to move in z. """ pixel_step = { @@ -68,8 +83,10 @@ def predict_z(positions: list, axis: str, relative_move: float, stage: StageDep, def move_and_measure( step_size: dict[str, float], - axis: str, data, - image1, autofocus_proc: bool, + axis: str, + data, + image1, + autofocus_proc: bool, csm: CSMDep, autofocus: AutofocusDep, cam:CamDep) -> tuple: @@ -80,7 +97,8 @@ def move_and_measure( :params data: The object used to track stage coordinates, correlation and delta. :params image1: An image taken before moving to be correlated with image2. :params autofocus_proc: If true, looping.autofocus will be used after the stage moves. - :return: All required data for the next move. This includes the updated delta value and offset. Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. + :return: All required data for the next move. This includes the updated delta value and offset. + Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. """ if axis == 'x': csm.move_in_image_coordinates(x = step_size['x'], y = 0) @@ -91,7 +109,12 @@ def move_and_measure( if autofocus_proc: autofocus.looping_autofocus(dz = 800) image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - offset = [x * 1 for x in fft_image_tracking.displacement_between_images(image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels + offset = [x * 1 for x in fft_image_tracking.displacement_between_images( + image_0 = image1, + image_1 = image2, + sigma=10, + fractional_threshold=0.1, + pad=True)] # Units is pixels data.delta['x'] = int(offset[1]) data.delta['y'] = int(offset[0]) @@ -108,19 +131,20 @@ def acquire_z_predict_points( autofocus: AutofocusDep, logger: lt.deps.InvocationLogger, ) -> None: - """Carries out the medium sized steps section of the range of motion test to get 5 points to make z position predictions with. + """Complete 5 medium sized steps to collect stage coordinates for prediction. :params stream_resolution: The resolution of the stream from the camera. :param direction: The direction the stage moves. :params axis: The axis which is being measured. This must be 'x' or 'y'. :params data: The object used to track stage coordinates, correlation and delta. - :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta is updated and tracked after each move. + :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. + Delta is updated and tracked after each move. """ medium_step = 50 wrong_axis_max_medium = generate_move_dicts( medium_step, stream_resolution, direction, factor=0.1 ) - for loop in range(5): + for _loop in range(5): image1 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) @@ -160,13 +184,19 @@ def check_stage_operation( :param direction: The direction the stage moves. :params axis: The axis which is being measured. This must be 'x' or 'y'. :params data: The object used to track stage coordinates, correlation and delta. - :params minimum_offset_small: A dictionary containing the minimum values for a successful correlation. - :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta is updated and tracked after each move. + :params minimum_offset_small: A dictionary containing the minimum values for + a successful correlation. + :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. + Delta is updated and tracked after each move. """ failure_count = 0 - wrong_axis_max_small = generate_move_dicts(small_step, stream_resolution, direction, factor=0.1) + wrong_axis_max_small = generate_move_dicts( + small_step, + stream_resolution, + direction, + factor=0.1) - for loop in range(3): + for _loop in range(3): image1 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) @@ -207,14 +237,16 @@ def check_stage_operation( data.delta["x"] = int(offset[1]) data.delta["y"] = int(offset[0]) logger.info( - f"Displacement found was {data.delta[axis]}. Minimum offset is {minimum_offset_small[axis]}" + f"Displacement found was {data.delta[axis]}.\ + Minimum offset is {minimum_offset_small[axis]}" ) data.measure(stage.position, offset) assert np.abs(data.delta[wrong_axis]) < np.abs(wrong_axis_max_small[wrong_axis]) - if np.abs(data.delta[axis]) < np.abs(minimum_offset_small[axis]): # this means the edge has been found + # this means the edge has been found + if np.abs(data.delta[axis]) < np.abs(minimum_offset_small[axis]): logger.info("Edge has been found.") break @@ -229,7 +261,8 @@ def motion_detection( """Move the stage until motion is detected along a specified axis and direction. :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. - :params direction: The direction in which the stage was moving previous to motion detection being used. + :params direction: The direction in which the stage was moving + previous to motion detection being used. :return: The stage coordinates where motion was detected. """ displacements = [1,2,4,8,16,32,64,128,256,512] # Array of increasing step sizes @@ -248,11 +281,21 @@ def motion_detection( for loop in range(np.shape(displacements)[0]): this_motion_step[axis] = displacements[loop] * direction * -1 logger.info(f"Testing with step size {this_motion_step[axis]}") - image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), + dsize=(0,0), + fx= 1, + fy= 1) csm.move_in_image_coordinates(x = this_motion_step['x'], y = this_motion_step['y']) - image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) + image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), + dsize=(0,0), + fx= 1, + fy= 1) offset = [x * 1 for x in fft_image_tracking.displacement_between_images( - image_0 = image1, image_1 = image2, sigma=10, fractional_threshold=0.1, pad=True)] # Units is pixels + image_0 = image1, + image_1 = image2, + sigma=10, + fractional_threshold=0.1, + pad=True)] delta['x'] = int(offset[1]) delta['y'] = int(offset[0]) logger.info(f"Offset measured as {np.abs(delta[axis])}") @@ -293,7 +336,8 @@ class RangeofMotionThing(lt.Thing): :params axis: The axis which is being measured. This must be 'x' or 'y'. :params direction: The direction which is being measured. This must be 1 or -1. - :return: Results dictionary containing stage positions, correlations and the final position. + :return: Results dictionary containing stage positions, + correlations and the final position. """ autofocus.looping_autofocus(dz = 1000) @@ -303,10 +347,7 @@ class RangeofMotionThing(lt.Thing): axis_results = {} try: - if direction == 1: - dir_word = "positive" - else: - dir_word = "negative" + dir_word = "positive" if direction == 1 else "negative" logger.info(f"Beginning the {axis}-axis in the {dir_word} direction") @@ -338,7 +379,12 @@ class RangeofMotionThing(lt.Thing): ) while np.abs(rom_data.delta[axis]) > np.abs(minimum_offset_small[axis]): - z_diff = predict_z(positions = rom_data.stage_coords, axis = axis, relative_move = step_sizes_big[axis], stage = stage, csm = csm) + z_diff = predict_z( + positions = rom_data.stage_coords, + axis = axis, + relative_move = step_sizes_big[axis], + stage = stage, + csm = csm) logger.info("Z calibration complete.") stage.move_relative(z = z_diff) @@ -380,7 +426,11 @@ class RangeofMotionThing(lt.Thing): except AssertionError: logger.info("Parasitic motion detected.") finally: - stage.move_absolute(x = starting_position[0], y = starting_position[1], z = starting_position[2], block_cancellation=True) + stage.move_absolute( + x = starting_position[0], + y = starting_position[1], + z = starting_position[2], + block_cancellation=True) return axis_results @@ -397,7 +447,8 @@ class RangeofMotionThing(lt.Thing): :return: Results dictionary separated into keys of each axis and direction. """ - logger.info("Using the stage to measure the Range of Motion. Please ensure you are using a big enough sample.") + logger.info("Using the stage to measure the Range of Motion.\ + Please ensure you are using a big enough sample.") start_time = time.time() rom_results = {} @@ -416,8 +467,10 @@ class RangeofMotionThing(lt.Thing): end_time = time.time() total_time = (end_time - start_time)/60 - x_range = abs(rom_results["['x', 1]"]["final_position"]["x"] - rom_results["['x', -1]"]["final_position"]["x"]) - y_range = abs(rom_results["['y', 1]"]["final_position"]["y"] - rom_results["['y', -1]"]["final_position"]["y"]) + x_range = abs(rom_results["['x', 1]"]["final_position"]["x"] - + rom_results["['x', -1]"]["final_position"]["x"]) + y_range = abs(rom_results["['y', 1]"]["final_position"]["y"] - + rom_results["['y', -1]"]["final_position"]["y"]) step_range = [x_range, y_range] logger.info(f"Range of motion is {x_range} X {y_range}") From d6c47bd43e3eeec60479f48c05f38deb1c9098c4 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 30 Jul 2025 15:44:02 +0100 Subject: [PATCH 28/70] Ruff formatting stuff --- .../things/stage_measure.py | 249 ++++++++++-------- .../utilities.py | 1 + 2 files changed, 142 insertions(+), 108 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index b457f043..9d3398dc 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -35,11 +35,9 @@ CSMDep = lt.deps.direct_thing_client_dependency( AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/") -def generate_move_dicts(fov_perc: int, - stream_resolution: list[int], - direction: int, - factor: float = 1 - ) -> dict[str, float]: +def generate_move_dicts( + fov_perc: int, stream_resolution: list[int], direction: int, factor: float = 1 +) -> dict[str, float]: """Create a single dictionary of x and y moves for moving in image coordinates. :param fov_perc: The percentage of field of view the stage should move by. @@ -54,12 +52,9 @@ def generate_move_dicts(fov_perc: int, } -def predict_z(positions: list, - axis: str, - relative_move: float, - stage: StageDep, - csm: CSMDep - ) -> float: +def predict_z( + positions: list, axis: str, relative_move: float, stage: StageDep, csm: CSMDep +) -> float: """Predict the next z position for a move using previous positions. :params positions: The list of positions used for predicting z. @@ -70,26 +65,30 @@ def predict_z(positions: list, :return: A number of pixels the stage needs to move in z. """ pixel_step = { - 'x':1/csm.image_to_stage_displacement_matrix[0][1], - 'y':1/csm.image_to_stage_displacement_matrix[1][0] + "x": 1 / csm.image_to_stage_displacement_matrix[0][1], + "y": 1 / csm.image_to_stage_displacement_matrix[1][0], } lateral_positions = [i[axis] for i in positions] - z_positions = [i['z'] for i in positions] - parameters, _ = curve_fit(quadratic, lateral_positions, z_positions) - z_dest = quadratic(stage.position[axis] + (relative_move/pixel_step[axis]), *parameters) + z_positions = [i["z"] for i in positions] + parameters, _ = curve_fit(quadratic, lateral_positions, z_positions) + z_dest = quadratic( + stage.position[axis] + (relative_move / pixel_step[axis]), *parameters + ) return z_dest - stage.position["z"] + def move_and_measure( - step_size: dict[str, float], - axis: str, - data, - image1, - autofocus_proc: bool, - csm: CSMDep, - autofocus: AutofocusDep, - cam:CamDep) -> tuple: + step_size: dict[str, float], + axis: str, + data, + image1, + autofocus_proc: bool, + csm: CSMDep, + autofocus: AutofocusDep, + cam: CamDep, +) -> tuple: """Move the stage and measure the offset between the two positions. :params step_size: A dictionary with keys 'x' and 'y' with pixel distances. @@ -100,26 +99,29 @@ def move_and_measure( :return: All required data for the next move. This includes the updated delta value and offset. Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. """ - if axis == 'x': - csm.move_in_image_coordinates(x = step_size['x'], y = 0) - wrong_axis = 'y' + if axis == "x": + csm.move_in_image_coordinates(x=step_size["x"], y=0) + wrong_axis = "y" else: - csm.move_in_image_coordinates(x = 0, y = step_size['y']) - wrong_axis = 'x' + csm.move_in_image_coordinates(x=0, y=step_size["y"]) + wrong_axis = "x" if autofocus_proc: - autofocus.looping_autofocus(dz = 800) - image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), dsize=(0,0), fx= 1, fy= 1) - offset = [x * 1 for x in fft_image_tracking.displacement_between_images( - image_0 = image1, - image_1 = image2, - sigma=10, - fractional_threshold=0.1, - pad=True)] # Units is pixels - data.delta['x'] = int(offset[1]) - data.delta['y'] = int(offset[0]) + autofocus.looping_autofocus(dz=800) + image2 = cv2.resize( + np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 + ) + offset = [ + x * 1 + for x in fft_image_tracking.displacement_between_images( + image_0=image1, image_1=image2, sigma=10, fractional_threshold=0.1, pad=True + ) + ] # Units is pixels + data.delta["x"] = int(offset[1]) + data.delta["y"] = int(offset[0]) return offset, wrong_axis + def acquire_z_predict_points( stream_resolution: list[int], direction: int, @@ -162,7 +164,10 @@ def acquire_z_predict_points( data.measure(stage.position, offset) - assert(np.abs(data.delta[wrong_axis]) < np.abs(wrong_axis_max_medium[wrong_axis])) + assert np.abs(data.delta[wrong_axis]) < np.abs( + wrong_axis_max_medium[wrong_axis] + ) + def check_stage_operation( small_step: int, @@ -191,10 +196,8 @@ def check_stage_operation( """ failure_count = 0 wrong_axis_max_small = generate_move_dicts( - small_step, - stream_resolution, - direction, - factor=0.1) + small_step, stream_resolution, direction, factor=0.1 + ) for _loop in range(3): image1 = cv2.resize( @@ -250,6 +253,7 @@ def check_stage_operation( logger.info("Edge has been found.") break + def motion_detection( axis: str, direction: int, @@ -265,39 +269,46 @@ def motion_detection( previous to motion detection being used. :return: The stage coordinates where motion was detected. """ - displacements = [1,2,4,8,16,32,64,128,256,512] # Array of increasing step sizes + displacements = [ + 1, + 2, + 4, + 8, + 16, + 32, + 64, + 128, + 256, + 512, + ] # Array of increasing step sizes motion_minimum = 20 # minimum number of pixels for motion to be detected - this_motion_step = { - 'x': 0, - 'y': 0 - } + this_motion_step = {"x": 0, "y": 0} - delta = { - 'x': 0, - 'y': 0 - } + delta = {"x": 0, "y": 0} for loop in range(np.shape(displacements)[0]): this_motion_step[axis] = displacements[loop] * direction * -1 logger.info(f"Testing with step size {this_motion_step[axis]}") - image1 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), - dsize=(0,0), - fx= 1, - fy= 1) - csm.move_in_image_coordinates(x = this_motion_step['x'], y = this_motion_step['y']) - image2 = cv2.resize(np.array(Image.open(cam.grab_jpeg().open())), - dsize=(0,0), - fx= 1, - fy= 1) - offset = [x * 1 for x in fft_image_tracking.displacement_between_images( - image_0 = image1, - image_1 = image2, - sigma=10, - fractional_threshold=0.1, - pad=True)] - delta['x'] = int(offset[1]) - delta['y'] = int(offset[0]) + image1 = cv2.resize( + np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 + ) + csm.move_in_image_coordinates(x=this_motion_step["x"], y=this_motion_step["y"]) + image2 = cv2.resize( + np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 + ) + offset = [ + x * 1 + for x in fft_image_tracking.displacement_between_images( + image_0=image1, + image_1=image2, + sigma=10, + fractional_threshold=0.1, + pad=True, + ) + ] + delta["x"] = int(offset[1]) + delta["y"] = int(offset[0]) logger.info(f"Offset measured as {np.abs(delta[axis])}") if np.abs(delta[axis]) > motion_minimum: logger.info("Motion detected.") @@ -305,10 +316,16 @@ def motion_detection( return stage.position -class RomDataTracker(): + +class RomDataTracker: """Class for tracking range of motion data.""" - def __init__(self, stage_coords: list[dict[str, int]] = [], cor_lat_steps: list[list[float]] = [], delta: dict = {'x':0, 'y':0}): + def __init__( + self, + stage_coords: list[dict[str, int]] = [], + cor_lat_steps: list[list[float]] = [], + delta: dict = {"x": 0, "y": 0}, + ): """Define useful data tracked throughout test.""" self.stage_coords = stage_coords self.cor_lat_steps = cor_lat_steps @@ -319,6 +336,7 @@ class RomDataTracker(): self.stage_coords.append(current_pos) self.cor_lat_steps.append(cor) + class RangeofMotionThing(lt.Thing): """A class used to measure the range of motion of the stage in X and Y.""" @@ -339,7 +357,7 @@ class RangeofMotionThing(lt.Thing): :return: Results dictionary containing stage positions, correlations and the final position. """ - autofocus.looping_autofocus(dz = 1000) + autofocus.looping_autofocus(dz=1000) starting_position = list(stage.position.values()) @@ -352,7 +370,7 @@ class RangeofMotionThing(lt.Thing): logger.info(f"Beginning the {axis}-axis in the {dir_word} direction") # Generate required dictionaries for step sizes and minimum offsets - stream_resolution = [820,616] + stream_resolution = [820, 616] big_step = 200 small_step = 20 step_sizes_big = generate_move_dicts(big_step, stream_resolution, direction) @@ -380,23 +398,24 @@ class RangeofMotionThing(lt.Thing): while np.abs(rom_data.delta[axis]) > np.abs(minimum_offset_small[axis]): z_diff = predict_z( - positions = rom_data.stage_coords, - axis = axis, - relative_move = step_sizes_big[axis], - stage = stage, - csm = csm) + positions=rom_data.stage_coords, + axis=axis, + relative_move=step_sizes_big[axis], + stage=stage, + csm=csm, + ) logger.info("Z calibration complete.") - stage.move_relative(z = z_diff) + stage.move_relative(z=z_diff) logger.info(f"Moved in z by {z_diff}") # Big step - if axis == 'x': - csm.move_in_image_coordinates(x = step_sizes_big['x'], y = 0) + if axis == "x": + csm.move_in_image_coordinates(x=step_sizes_big["x"], y=0) else: - csm.move_in_image_coordinates(x = 0, y = step_sizes_big['y']) + csm.move_in_image_coordinates(x=0, y=step_sizes_big["y"]) - autofocus.looping_autofocus(dz = 800) + autofocus.looping_autofocus(dz=800) rom_data.stage_coords.append(stage.position) check_stage_operation( @@ -404,7 +423,7 @@ class RangeofMotionThing(lt.Thing): stream_resolution=stream_resolution, direction=direction, axis=axis, - data = rom_data, + data=rom_data, minimum_offset_small=minimum_offset_small, csm=csm, cam=cam, @@ -415,22 +434,32 @@ class RangeofMotionThing(lt.Thing): # Motion detection logger.info("Running motion detection") - final_pos = motion_detection(axis = axis, direction = direction, csm = csm, stage = stage, cam = cam, logger = logger) - rom_data.stage_coords[np.shape(np.array(rom_data.stage_coords))[0] - 1] = final_pos + final_pos = motion_detection( + axis=axis, + direction=direction, + csm=csm, + stage=stage, + cam=cam, + logger=logger, + ) + rom_data.stage_coords[np.shape(np.array(rom_data.stage_coords))[0] - 1] = ( + final_pos + ) axis_results = { "correlation_lateral_steps": rom_data.cor_lat_steps, "stage_positions": rom_data.stage_coords, - "final_position": final_pos + "final_position": final_pos, } except AssertionError: logger.info("Parasitic motion detected.") finally: stage.move_absolute( - x = starting_position[0], - y = starting_position[1], - z = starting_position[2], - block_cancellation=True) + x=starting_position[0], + y=starting_position[1], + z=starting_position[2], + block_cancellation=True, + ) return axis_results @@ -447,30 +476,36 @@ class RangeofMotionThing(lt.Thing): :return: Results dictionary separated into keys of each axis and direction. """ - logger.info("Using the stage to measure the Range of Motion.\ - Please ensure you are using a big enough sample.") + logger.info( + "Using the stage to measure the Range of Motion.\ + Please ensure you are using a big enough sample." + ) start_time = time.time() rom_results = {} - for axis_dir in [['x', 1],['x', -1],['y', 1],['y', -1]]: + for axis_dir in [["x", 1], ["x", -1], ["y", 1], ["y", -1]]: axis_dir_results = self.rom_axis( autofocus, stage, cam, csm, logger, - axis = axis_dir[0], - direction = axis_dir[1] - ) + axis=axis_dir[0], + direction=axis_dir[1], + ) rom_results[f"{axis_dir}"] = axis_dir_results end_time = time.time() - total_time = (end_time - start_time)/60 + total_time = (end_time - start_time) / 60 - x_range = abs(rom_results["['x', 1]"]["final_position"]["x"] - - rom_results["['x', -1]"]["final_position"]["x"]) - y_range = abs(rom_results["['y', 1]"]["final_position"]["y"] - - rom_results["['y', -1]"]["final_position"]["y"]) + x_range = abs( + rom_results["['x', 1]"]["final_position"]["x"] + - rom_results["['x', -1]"]["final_position"]["x"] + ) + y_range = abs( + rom_results["['y', 1]"]["final_position"]["y"] + - rom_results["['y', -1]"]["final_position"]["y"] + ) step_range = [x_range, y_range] logger.info(f"Range of motion is {x_range} X {y_range}") @@ -480,11 +515,9 @@ class RangeofMotionThing(lt.Thing): self.last_calibration = DenumpifyingDict(rom_results).model_dump() - with open("/var/openflexure/ROM_Test_Results.json", 'w') as file_object: - json.dump(rom_results, file_object, indent = 3) + with open("/var/openflexure/ROM_Test_Results.json", "w") as file_object: + json.dump(rom_results, file_object, indent=3) return rom_results - last_calibration = lt.ThingSetting( - initial_value=None, model=dict, readonly=True - ) + last_calibration = lt.ThingSetting(initial_value=None, model=dict, readonly=True) diff --git a/src/openflexure_microscope_server/utilities.py b/src/openflexure_microscope_server/utilities.py index b5a4bde3..b0766894 100644 --- a/src/openflexure_microscope_server/utilities.py +++ b/src/openflexure_microscope_server/utilities.py @@ -328,6 +328,7 @@ def _get_version_from_toml(toml_path: str) -> str: LOGGER.error("Problem opening pyproject.toml") return "Undefined" + def quadratic(x, a, b, c): """Quadratic function. Used for predicting z. From 897cb022b1156ae72e3faf8b2201ba1fde13d74d Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 30 Jul 2025 17:44:19 +0100 Subject: [PATCH 29/70] Improved code readability --- .../things/stage_measure.py | 54 ++++++++++--------- .../utilities.py | 2 +- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 9d3398dc..b709ce3b 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -3,8 +3,8 @@ The range of motion is measured by first taking 5 'medium' sized steps which gives enough positions to predict future z positions. Next, one 'big' step is taken followed by 3 'small' steps to test that the stage is still moving as expected and has not reached the edge. -Once the edge has been found and too account for the possibility that a 'big' -step was take just before the edge was reached, the stage is moved in a sequence +Once the edge has been found and to account for the possibility that a 'big' +step was taken just before the edge was reached, the stage is moved in a sequence of increasing pixel sizes in the opposite direction until motion is detected. This is position is taken as the true final position. @@ -22,6 +22,7 @@ from scipy.optimize import curve_fit from camera_stage_mapping import fft_image_tracking import labthings_fastapi as lt from labthings_fastapi.types.numpy import DenumpifyingDict +import numpy.typing as npt # Things from .autofocus import AutofocusThing @@ -35,7 +36,7 @@ CSMDep = lt.deps.direct_thing_client_dependency( AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/") -def generate_move_dicts( +def _generate_move_dicts( fov_perc: int, stream_resolution: list[int], direction: int, factor: float = 1 ) -> dict[str, float]: """Create a single dictionary of x and y moves for moving in image coordinates. @@ -52,7 +53,7 @@ def generate_move_dicts( } -def predict_z( +def _predict_z( positions: list, axis: str, relative_move: float, stage: StageDep, csm: CSMDep ) -> float: """Predict the next z position for a move using previous positions. @@ -79,16 +80,16 @@ def predict_z( return z_dest - stage.position["z"] -def move_and_measure( +def _move_and_measure( step_size: dict[str, float], axis: str, data, - image1, + image1: npt.ArrayLike, autofocus_proc: bool, csm: CSMDep, autofocus: AutofocusDep, cam: CamDep, -) -> tuple: +) -> tuple[npt.ArrayLike, str]: """Move the stage and measure the offset between the two positions. :params step_size: A dictionary with keys 'x' and 'y' with pixel distances. @@ -122,7 +123,7 @@ def move_and_measure( return offset, wrong_axis -def acquire_z_predict_points( +def _acquire_z_predict_points( stream_resolution: list[int], direction: int, axis: str, @@ -143,15 +144,15 @@ def acquire_z_predict_points( Delta is updated and tracked after each move. """ medium_step = 50 - wrong_axis_max_medium = generate_move_dicts( + wrong_axis_max_medium = _generate_move_dicts( medium_step, stream_resolution, direction, factor=0.1 ) for _loop in range(5): image1 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) - offset, wrong_axis = move_and_measure( - step_size=generate_move_dicts(medium_step, stream_resolution, direction), + offset, wrong_axis = _move_and_measure( + step_size=_generate_move_dicts(medium_step, stream_resolution, direction), axis=axis, data=data, image1=image1, @@ -169,7 +170,7 @@ def acquire_z_predict_points( ) -def check_stage_operation( +def _check_stage_operation( small_step: int, stream_resolution: list[int], direction: int, @@ -195,7 +196,7 @@ def check_stage_operation( Delta is updated and tracked after each move. """ failure_count = 0 - wrong_axis_max_small = generate_move_dicts( + wrong_axis_max_small = _generate_move_dicts( small_step, stream_resolution, direction, factor=0.1 ) @@ -203,8 +204,8 @@ def check_stage_operation( image1 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) - offset, wrong_axis = move_and_measure( - step_size=generate_move_dicts(small_step, stream_resolution, direction), + offset, wrong_axis = _move_and_measure( + step_size=_generate_move_dicts(small_step, stream_resolution, direction), axis=axis, data=data, image1=image1, @@ -254,7 +255,7 @@ def check_stage_operation( break -def motion_detection( +def _motion_detection( axis: str, direction: int, csm: CSMDep, @@ -331,7 +332,7 @@ class RomDataTracker: self.cor_lat_steps = cor_lat_steps self.delta = delta - def measure(self, current_pos, cor): + def measure(self, current_pos: dict[str, int], cor: list[float]): """Store useful data.""" self.stage_coords.append(current_pos) self.cor_lat_steps.append(cor) @@ -365,20 +366,23 @@ class RangeofMotionThing(lt.Thing): axis_results = {} try: - dir_word = "positive" if direction == 1 else "negative" - logger.info(f"Beginning the {axis}-axis in the {dir_word} direction") + logger.info( + f"Beginning the {axis}-axis in the {'positive' if direction == 1 else 'negative'} direction" + ) # Generate required dictionaries for step sizes and minimum offsets stream_resolution = [820, 616] big_step = 200 small_step = 20 - step_sizes_big = generate_move_dicts(big_step, stream_resolution, direction) + step_sizes_big = _generate_move_dicts( + big_step, stream_resolution, direction + ) rom_data.stage_coords.append(stage.position) logger.info("Moving the stage in 5 medium sized steps.") - acquire_z_predict_points( + _acquire_z_predict_points( stream_resolution=stream_resolution, direction=direction, axis=axis, @@ -392,12 +396,12 @@ class RangeofMotionThing(lt.Thing): # 1 big step followed by 3 small steps - minimum_offset_small = generate_move_dicts( + minimum_offset_small = _generate_move_dicts( small_step, stream_resolution, direction, factor=0.65 ) while np.abs(rom_data.delta[axis]) > np.abs(minimum_offset_small[axis]): - z_diff = predict_z( + z_diff = _predict_z( positions=rom_data.stage_coords, axis=axis, relative_move=step_sizes_big[axis], @@ -418,7 +422,7 @@ class RangeofMotionThing(lt.Thing): autofocus.looping_autofocus(dz=800) rom_data.stage_coords.append(stage.position) - check_stage_operation( + _check_stage_operation( small_step=small_step, stream_resolution=stream_resolution, direction=direction, @@ -434,7 +438,7 @@ class RangeofMotionThing(lt.Thing): # Motion detection logger.info("Running motion detection") - final_pos = motion_detection( + final_pos = _motion_detection( axis=axis, direction=direction, csm=csm, diff --git a/src/openflexure_microscope_server/utilities.py b/src/openflexure_microscope_server/utilities.py index b0766894..d48f4293 100644 --- a/src/openflexure_microscope_server/utilities.py +++ b/src/openflexure_microscope_server/utilities.py @@ -329,7 +329,7 @@ def _get_version_from_toml(toml_path: str) -> str: return "Undefined" -def quadratic(x, a, b, c): +def quadratic(x: float, a: float, b: float, c: float): """Quadratic function. Used for predicting z. :param x: The points at which to evaluate the quadratic. From 4cde2d5cd1ec863f92b1287aea5298fdd866dfeb Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 30 Jul 2025 17:58:03 +0100 Subject: [PATCH 30/70] Changed typehints to literal for axis and direction --- .../things/stage_measure.py | 26 +++++++++++-------- 1 file changed, 15 insertions(+), 11 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index b709ce3b..c7d2e698 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -15,6 +15,7 @@ is tracked and an error is raised if it exceeds a reasonable amount. import numpy as np import cv2 import json +from typing import Literal from PIL import Image import time from ..utilities import quadratic @@ -37,7 +38,10 @@ AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocu def _generate_move_dicts( - fov_perc: int, stream_resolution: list[int], direction: int, factor: float = 1 + fov_perc: int, + stream_resolution: list[int], + direction: Literal[1, -1], + factor: float = 1, ) -> dict[str, float]: """Create a single dictionary of x and y moves for moving in image coordinates. @@ -54,7 +58,7 @@ def _generate_move_dicts( def _predict_z( - positions: list, axis: str, relative_move: float, stage: StageDep, csm: CSMDep + positions: list, axis: Literal["x", "y"], relative_move: float, stage: StageDep, csm: CSMDep ) -> float: """Predict the next z position for a move using previous positions. @@ -82,7 +86,7 @@ def _predict_z( def _move_and_measure( step_size: dict[str, float], - axis: str, + axis: Literal["x", "y"], data, image1: npt.ArrayLike, autofocus_proc: bool, @@ -125,8 +129,8 @@ def _move_and_measure( def _acquire_z_predict_points( stream_resolution: list[int], - direction: int, - axis: str, + direction: Literal[1, -1], + axis: Literal["x", "y"], data, csm: CSMDep, cam: CamDep, @@ -173,8 +177,8 @@ def _acquire_z_predict_points( def _check_stage_operation( small_step: int, stream_resolution: list[int], - direction: int, - axis: str, + direction: Literal[1, -1], + axis: Literal["x", "y"], data, minimum_offset_small: dict[str, float], csm: CSMDep, @@ -256,8 +260,8 @@ def _check_stage_operation( def _motion_detection( - axis: str, - direction: int, + axis: Literal["x", "y"], + direction: Literal[1, -1], csm: CSMDep, stage: StageDep, cam: CamDep, @@ -348,8 +352,8 @@ class RangeofMotionThing(lt.Thing): cam: CamDep, csm: CSMDep, logger: lt.deps.InvocationLogger, - axis: str, - direction: int, + axis: Literal["x", "y"], + direction: Literal[1,-1], ) -> dict: """Measure the range of motion in a single axis and direction. From 024c74f67bead149ffc5d964595ee97d405de4b1 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Thu, 31 Jul 2025 10:30:10 +0100 Subject: [PATCH 31/70] More docstrings and comments. Removed json dump to openflexure folder. --- .../things/stage_measure.py | 58 ++++++++++++++----- 1 file changed, 43 insertions(+), 15 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index c7d2e698..924663a7 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -9,12 +9,12 @@ of increasing pixel sizes in the opposite direction until motion is detected. Th position is taken as the true final position. Throughout the test, parasitic motion(motion in the axis not being measured) -is tracked and an error is raised if it exceeds a reasonable amount. +is tracked and an error is raised if it exceeds a minimum amount. Currently +this is 10% of the expected motion is the measured axis. """ import numpy as np import cv2 -import json from typing import Literal from PIL import Image import time @@ -54,11 +54,15 @@ def _generate_move_dicts( return { "x": (fov_perc / 100) * stream_resolution[0] * factor * direction, "y": (fov_perc / 100) * stream_resolution[1] * factor * direction, - } + } # factor used for creating minimum offsets. def _predict_z( - positions: list, axis: Literal["x", "y"], relative_move: float, stage: StageDep, csm: CSMDep + positions: list, + axis: Literal["x", "y"], + relative_move: float, + stage: StageDep, + csm: CSMDep, ) -> float: """Predict the next z position for a move using previous positions. @@ -67,6 +71,8 @@ def _predict_z( :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. :params relative_move: The move which the function is trying to predict z for. Here, this is inputted with units of pixels. + :param stage: A direct_thing_client dependency for the the microscope stage. + :param csm: A direct_thing_client dependency for camera stage mapping. :return: A number of pixels the stage needs to move in z. """ pixel_step = { @@ -74,7 +80,7 @@ def _predict_z( "y": 1 / csm.image_to_stage_displacement_matrix[1][0], } - lateral_positions = [i[axis] for i in positions] + lateral_positions = [i[axis] for i in positions] # x or y positions z_positions = [i["z"] for i in positions] parameters, _ = curve_fit(quadratic, lateral_positions, z_positions) z_dest = quadratic( @@ -101,6 +107,9 @@ def _move_and_measure( :params data: The object used to track stage coordinates, correlation and delta. :params image1: An image taken before moving to be correlated with image2. :params autofocus_proc: If true, looping.autofocus will be used after the stage moves. + :param csm: A direct_thing_client dependency for camera stage mapping. + :param autofocus: A direct_thing_client dependency for autofocus. + :param camera: A raw_thing_client depeendency for the camera. :return: All required data for the next move. This includes the updated delta value and offset. Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. """ @@ -144,6 +153,10 @@ def _acquire_z_predict_points( :param direction: The direction the stage moves. :params axis: The axis which is being measured. This must be 'x' or 'y'. :params data: The object used to track stage coordinates, correlation and delta. + :param csm: A direct_thing_client dependency for camera stage mapping. + :param camera: A raw_thing_client depeendency for the camera. + :param stage: A raw_thing_client depeendency for the microscope stage. + :param autofocus: A direct_thing_client dependency for autofocus. :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta is updated and tracked after each move. """ @@ -154,7 +167,7 @@ def _acquire_z_predict_points( for _loop in range(5): image1 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 - ) + ) # Captures image with option to resize offset, wrong_axis = _move_and_measure( step_size=_generate_move_dicts(medium_step, stream_resolution, direction), axis=axis, @@ -196,13 +209,17 @@ def _check_stage_operation( :params data: The object used to track stage coordinates, correlation and delta. :params minimum_offset_small: A dictionary containing the minimum values for a successful correlation. + :param csm: A direct_thing_client dependency for camera stage mapping. + :param camera: A raw_thing_client depeendency for the camera. + :param stage: A raw_thing_client depeendency for the microscope stage. + :param autofocus: A direct_thing_client dependency for autofocus. :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. Delta is updated and tracked after each move. """ failure_count = 0 wrong_axis_max_small = _generate_move_dicts( small_step, stream_resolution, direction, factor=0.1 - ) + ) # Dictionary with maximum allowed parasitic motion for _loop in range(3): image1 = cv2.resize( @@ -220,6 +237,7 @@ def _check_stage_operation( ) logger.info(f"Offset measured as {data.delta[axis]}") + # If correlation is too small, refocuses and capture new image 3 times. while ( np.abs(data.delta[axis]) < np.abs(minimum_offset_small[axis]) and failure_count < 3 @@ -272,6 +290,9 @@ def _motion_detection( :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. :params direction: The direction in which the stage was moving previous to motion detection being used. + :param csm: A direct_thing_client dependency for camera stage mapping. + :param stage: A raw_thing_client depeendency for the microscope stage. + :param camera: A raw_thing_client depeendency for the camera. :return: The stage coordinates where motion was detected. """ displacements = [ @@ -285,7 +306,7 @@ def _motion_detection( 128, 256, 512, - ] # Array of increasing step sizes + ] # Array of increasing pixel sizes motion_minimum = 20 # minimum number of pixels for motion to be detected this_motion_step = {"x": 0, "y": 0} @@ -353,10 +374,14 @@ class RangeofMotionThing(lt.Thing): csm: CSMDep, logger: lt.deps.InvocationLogger, axis: Literal["x", "y"], - direction: Literal[1,-1], + direction: Literal[1, -1], ) -> dict: """Measure the range of motion in a single axis and direction. + :param stage: A raw_thing_client depeendency for the microscope stage. + :param cam: A raw_thing_client depeendency for the camera. + :param csm: A raw_thing_client depeendency for camera stage mapping. + :param logger: A raw_thing_client depeendency for the logger. :params axis: The axis which is being measured. This must be 'x' or 'y'. :params direction: The direction which is being measured. This must be 1 or -1. :return: Results dictionary containing stage positions, @@ -366,11 +391,10 @@ class RangeofMotionThing(lt.Thing): starting_position = list(stage.position.values()) - rom_data = RomDataTracker() + rom_data = RomDataTracker() # initialise data tracking object axis_results = {} try: - logger.info( f"Beginning the {axis}-axis in the {'positive' if direction == 1 else 'negative'} direction" ) @@ -482,6 +506,11 @@ class RangeofMotionThing(lt.Thing): ): """Measures the range of motion of the stage across the x and y axes. + :param autofocus: A raw_thing_client dependency for autofocus. + :param stage: A raw_thing_client depeendency for the microscope stage. + :param cam: A raw_thing_client depeendency for the camera. + :param csm: A raw_thing_client depeendency for camera stage mapping. + :param logger: A raw_thing_client depeendency for the logger. :return: Results dictionary separated into keys of each axis and direction. """ logger.info( @@ -491,6 +520,7 @@ class RangeofMotionThing(lt.Thing): start_time = time.time() rom_results = {} + # Loop through all axes and directions. for axis_dir in [["x", 1], ["x", -1], ["y", 1], ["y", -1]]: axis_dir_results = self.rom_axis( autofocus, @@ -501,6 +531,7 @@ class RangeofMotionThing(lt.Thing): axis=axis_dir[0], direction=axis_dir[1], ) + # Save results from single axis and direction rom_results[f"{axis_dir}"] = axis_dir_results end_time = time.time() @@ -515,7 +546,7 @@ class RangeofMotionThing(lt.Thing): - rom_results["['y', -1]"]["final_position"]["y"] ) step_range = [x_range, y_range] - logger.info(f"Range of motion is {x_range} X {y_range}") + logger.info(f"Range of motion is {x_range} X {y_range} steps") rom_results["Time"] = total_time rom_results["CSM Matrix"] = csm.image_to_stage_displacement_matrix @@ -523,9 +554,6 @@ class RangeofMotionThing(lt.Thing): self.last_calibration = DenumpifyingDict(rom_results).model_dump() - with open("/var/openflexure/ROM_Test_Results.json", "w") as file_object: - json.dump(rom_results, file_object, indent=3) - return rom_results last_calibration = lt.ThingSetting(initial_value=None, model=dict, readonly=True) From f501e7741d38e338dd349da35045ad7660091f4e Mon Sep 17 00:00:00 2001 From: Chish36 Date: Thu, 31 Jul 2025 15:17:38 +0100 Subject: [PATCH 32/70] More refactoring --- .../things/stage_measure.py | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 924663a7..214abf46 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -135,7 +135,6 @@ def _move_and_measure( return offset, wrong_axis - def _acquire_z_predict_points( stream_resolution: list[int], direction: Literal[1, -1], @@ -217,9 +216,6 @@ def _check_stage_operation( Delta is updated and tracked after each move. """ failure_count = 0 - wrong_axis_max_small = _generate_move_dicts( - small_step, stream_resolution, direction, factor=0.1 - ) # Dictionary with maximum allowed parasitic motion for _loop in range(3): image1 = cv2.resize( @@ -269,7 +265,15 @@ def _check_stage_operation( data.measure(stage.position, offset) - assert np.abs(data.delta[wrong_axis]) < np.abs(wrong_axis_max_small[wrong_axis]) + assert np.abs(data.delta[wrong_axis]) < np.abs( + _generate_move_dicts( + small_step, + stream_resolution, + direction, + factor=0.1)[ + wrong_axis + ] + ) # this means the edge has been found if np.abs(data.delta[axis]) < np.abs(minimum_offset_small[axis]): @@ -396,7 +400,8 @@ class RangeofMotionThing(lt.Thing): try: logger.info( - f"Beginning the {axis}-axis in the {'positive' if direction == 1 else 'negative'} direction" + f"Beginning the {axis}-axis in the {'positive' if direction == 1 else 'negative'}\ + direction" ) # Generate required dictionaries for step sizes and minimum offsets From e95f31d832a3a5a7e7ccda1d30e452019e3dd0a1 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Fri, 1 Aug 2025 11:08:29 +0100 Subject: [PATCH 33/70] Resolved data tracking error. --- .../things/stage_measure.py | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 214abf46..899e892e 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -366,6 +366,11 @@ class RomDataTracker: self.stage_coords.append(current_pos) self.cor_lat_steps.append(cor) + def reset_tracker(self): + """Empty the rom tracker.""" + self.stage_coords.clear() + self.cor_lat_steps.clear() + class RangeofMotionThing(lt.Thing): """A class used to measure the range of motion of the stage in X and Y.""" @@ -484,10 +489,13 @@ class RangeofMotionThing(lt.Thing): ) axis_results = { - "correlation_lateral_steps": rom_data.cor_lat_steps, - "stage_positions": rom_data.stage_coords, + "correlation_lateral_steps": rom_data.cor_lat_steps.copy(), + "stage_positions": rom_data.stage_coords.copy(), "final_position": final_pos, } + + rom_data.reset_tracker() + except AssertionError: logger.info("Parasitic motion detected.") finally: From 875bf1a6de6ea4f07efef388eb53d8a591fa2f7a Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 5 Aug 2025 16:17:47 +0100 Subject: [PATCH 34/70] Fixed ruff format errors --- src/openflexure_microscope_server/things/stage_measure.py | 7 ++----- 1 file changed, 2 insertions(+), 5 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 899e892e..9b3c61d2 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -135,6 +135,7 @@ def _move_and_measure( return offset, wrong_axis + def _acquire_z_predict_points( stream_resolution: list[int], direction: Literal[1, -1], @@ -266,11 +267,7 @@ def _check_stage_operation( data.measure(stage.position, offset) assert np.abs(data.delta[wrong_axis]) < np.abs( - _generate_move_dicts( - small_step, - stream_resolution, - direction, - factor=0.1)[ + _generate_move_dicts(small_step, stream_resolution, direction, factor=0.1)[ wrong_axis ] ) From 8dad346320dd6387d1dd7c946b8c24d497c1e288 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 6 Aug 2025 11:44:20 +0100 Subject: [PATCH 35/70] Made changes to docstrings and a few minor code changes. --- .../things/stage_measure.py | 75 +++++++++---------- 1 file changed, 34 insertions(+), 41 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 9b3c61d2..3184c599 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -45,10 +45,15 @@ def _generate_move_dicts( ) -> dict[str, float]: """Create a single dictionary of x and y moves for moving in image coordinates. + This can either be used to create move sizes or minimum move sizes. For example, + the stage must move a minimum distance for a move to be considered successful. + We define the minimum with this function. This is also used to define the maximally + allowed motion in the wrong axis. + :param fov_perc: The percentage of field of view the stage should move by. :param stream_resolution: The resolution of the stream from the camera. :param direction: The direction the stage moves. - :param factor: Multiplicative factor which changes the final result. + :param factor: Reduction factor which allows for minimum move sizes to be created. :return: A dictionary with keys 'x' and 'y' with a pixel distance move the stage can make. """ return { @@ -66,11 +71,11 @@ def _predict_z( ) -> float: """Predict the next z position for a move using previous positions. - :params positions: The list of positions used for predicting z. - This will usually be a list of all previous positions. - :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. - :params relative_move: The move which the function is trying to predict z for. - Here, this is inputted with units of pixels. + :param positions: The list of positions used for predicting z. + This will be a list of all previous positions. + :param axis: The axis in which the stage is moving. This must be 'x' or 'y'. + :param relative_move: The move which the function is trying to predict z for. + Here, this is inputted with units of pixels. :param stage: A direct_thing_client dependency for the the microscope stage. :param csm: A direct_thing_client dependency for camera stage mapping. :return: A number of pixels the stage needs to move in z. @@ -89,7 +94,6 @@ def _predict_z( return z_dest - stage.position["z"] - def _move_and_measure( step_size: dict[str, float], axis: Literal["x", "y"], @@ -102,14 +106,14 @@ def _move_and_measure( ) -> tuple[npt.ArrayLike, str]: """Move the stage and measure the offset between the two positions. - :params step_size: A dictionary with keys 'x' and 'y' with pixel distances. - :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. - :params data: The object used to track stage coordinates, correlation and delta. - :params image1: An image taken before moving to be correlated with image2. - :params autofocus_proc: If true, looping.autofocus will be used after the stage moves. + :param step_size: A dictionary with keys 'x' and 'y' with pixel distances. + :param axis: The axis in which the stage is moving. This must be 'x' or 'y'. + :param data: The object used to track stage coordinates, correlation and delta. + :param image1: An image taken before moving to be correlated with image2. + :param autofocus_proc: If true, looping.autofocus will be used after the stage moves. :param csm: A direct_thing_client dependency for camera stage mapping. :param autofocus: A direct_thing_client dependency for autofocus. - :param camera: A raw_thing_client depeendency for the camera. + :param cam: A raw_thing_client depeendency for the camera. :return: All required data for the next move. This includes the updated delta value and offset. Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. """ @@ -124,12 +128,9 @@ def _move_and_measure( image2 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) - offset = [ - x * 1 - for x in fft_image_tracking.displacement_between_images( + offset = fft_image_tracking.displacement_between_images( image_0=image1, image_1=image2, sigma=10, fractional_threshold=0.1, pad=True - ) - ] # Units is pixels + ) # Units is pixels data.delta["x"] = int(offset[1]) data.delta["y"] = int(offset[0]) @@ -202,13 +203,19 @@ def _check_stage_operation( ) -> None: """Carries out 3 small moves in a given direction and axis. + Each position after the first is correlated with the previous position + to check the stage has moved as far as it should. If the correlation is + less than expected, 3 attempts are made to refocus the image to ensure that + image quality is not causing the correlation to be unsuccessful. If the correlation + is still too low then the edge is found. + :params small_step: The integer value used to generate the small step sizes. :params stream_resolution: The resolution of the stream from the camera. :param direction: The direction the stage moves. :params axis: The axis which is being measured. This must be 'x' or 'y'. :params data: The object used to track stage coordinates, correlation and delta. :params minimum_offset_small: A dictionary containing the minimum values for - a successful correlation. + a successful correlation. :param csm: A direct_thing_client dependency for camera stage mapping. :param camera: A raw_thing_client depeendency for the camera. :param stage: A raw_thing_client depeendency for the microscope stage. @@ -247,16 +254,14 @@ def _check_stage_operation( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) failure_count += 1 - offset = [ - x * 1 - for x in fft_image_tracking.displacement_between_images( + offset = fft_image_tracking.displacement_between_images( image_0=image1, image_1=image2, sigma=10, fractional_threshold=0.1, pad=True, ) - ] # Units is pixels + # Units is pixels data.delta["x"] = int(offset[1]) data.delta["y"] = int(offset[0]) logger.info( @@ -296,18 +301,9 @@ def _motion_detection( :param camera: A raw_thing_client depeendency for the camera. :return: The stage coordinates where motion was detected. """ - displacements = [ - 1, - 2, - 4, - 8, - 16, - 32, - 64, - 128, - 256, - 512, - ] # Array of increasing pixel sizes + # Array of increasing pixel sizes (powers of 2 from 1 to 512) + displacements = [2**i for i in range(10)] + motion_minimum = 20 # minimum number of pixels for motion to be detected this_motion_step = {"x": 0, "y": 0} @@ -324,16 +320,13 @@ def _motion_detection( image2 = cv2.resize( np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 ) - offset = [ - x * 1 - for x in fft_image_tracking.displacement_between_images( + offset = fft_image_tracking.displacement_between_images( image_0=image1, image_1=image2, sigma=10, fractional_threshold=0.1, pad=True, ) - ] delta["x"] = int(offset[1]) delta["y"] = int(offset[0]) logger.info(f"Offset measured as {np.abs(delta[axis])}") @@ -388,8 +381,8 @@ class RangeofMotionThing(lt.Thing): :param cam: A raw_thing_client depeendency for the camera. :param csm: A raw_thing_client depeendency for camera stage mapping. :param logger: A raw_thing_client depeendency for the logger. - :params axis: The axis which is being measured. This must be 'x' or 'y'. - :params direction: The direction which is being measured. This must be 1 or -1. + :param axis: The axis which is being measured. This must be 'x' or 'y'. + :param direction: The direction which is being measured. This must be 1 or -1. :return: Results dictionary containing stage positions, correlations and the final position. """ From 3a0961e9dc4b721ccf5a5b98bb8a8e040f9dd7c3 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 6 Aug 2025 14:33:20 +0100 Subject: [PATCH 36/70] Clearer dictionary keys --- .../things/stage_measure.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 3184c599..3a539aa4 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -535,18 +535,20 @@ class RangeofMotionThing(lt.Thing): direction=axis_dir[1], ) # Save results from single axis and direction - rom_results[f"{axis_dir}"] = axis_dir_results + rom_results[f"{'positive' if axis_dir[1] == 1 else 'negative'} {axis_dir[0]}"] = ( + axis_dir_results + ) end_time = time.time() total_time = (end_time - start_time) / 60 x_range = abs( - rom_results["['x', 1]"]["final_position"]["x"] - - rom_results["['x', -1]"]["final_position"]["x"] + rom_results["positive x"]["final_position"]["x"] + - rom_results["negative x"]["final_position"]["x"] ) y_range = abs( - rom_results["['y', 1]"]["final_position"]["y"] - - rom_results["['y', -1]"]["final_position"]["y"] + rom_results["positive y"]["final_position"]["y"] + - rom_results["negative y"]["final_position"]["y"] ) step_range = [x_range, y_range] logger.info(f"Range of motion is {x_range} X {y_range} steps") From 1afaa534137ee4200bb668a64b2f17a221854b12 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 6 Aug 2025 14:50:17 +0100 Subject: [PATCH 37/70] Removed cv2 resize --- .../things/stage_measure.py | 24 +++++-------------- 1 file changed, 6 insertions(+), 18 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 3a539aa4..2427b4e9 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -125,9 +125,7 @@ def _move_and_measure( wrong_axis = "x" if autofocus_proc: autofocus.looping_autofocus(dz=800) - image2 = cv2.resize( - np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 - ) + image2 = np.array(Image.open(cam.grab_jpeg().open())) offset = fft_image_tracking.displacement_between_images( image_0=image1, image_1=image2, sigma=10, fractional_threshold=0.1, pad=True ) # Units is pixels @@ -166,9 +164,7 @@ def _acquire_z_predict_points( medium_step, stream_resolution, direction, factor=0.1 ) for _loop in range(5): - image1 = cv2.resize( - np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 - ) # Captures image with option to resize + image1 = np.array(Image.open(cam.grab_jpeg().open())) offset, wrong_axis = _move_and_measure( step_size=_generate_move_dicts(medium_step, stream_resolution, direction), axis=axis, @@ -226,9 +222,7 @@ def _check_stage_operation( failure_count = 0 for _loop in range(3): - image1 = cv2.resize( - np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 - ) + image1 = np.array(Image.open(cam.grab_jpeg().open())) offset, wrong_axis = _move_and_measure( step_size=_generate_move_dicts(small_step, stream_resolution, direction), axis=axis, @@ -250,9 +244,7 @@ def _check_stage_operation( f"Correlation failed. Refocusing to check. Attempt {failure_count + 1}/3" ) autofocus.looping_autofocus(dz=1000) - image2 = cv2.resize( - np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 - ) + image2 = np.array(Image.open(cam.grab_jpeg().open())) failure_count += 1 offset = fft_image_tracking.displacement_between_images( image_0=image1, @@ -313,13 +305,9 @@ def _motion_detection( for loop in range(np.shape(displacements)[0]): this_motion_step[axis] = displacements[loop] * direction * -1 logger.info(f"Testing with step size {this_motion_step[axis]}") - image1 = cv2.resize( - np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 - ) + image1 = np.array(Image.open(cam.grab_jpeg().open())) csm.move_in_image_coordinates(x=this_motion_step["x"], y=this_motion_step["y"]) - image2 = cv2.resize( - np.array(Image.open(cam.grab_jpeg().open())), dsize=(0, 0), fx=1, fy=1 - ) + image2 = np.array(Image.open(cam.grab_jpeg().open())) offset = fft_image_tracking.displacement_between_images( image_0=image1, image_1=image2, From ab9a324122708b6103ae48616ca7ad30ee403525 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Wed, 6 Aug 2025 16:51:20 +0100 Subject: [PATCH 38/70] Typehint added and import removed. --- .../things/stage_measure.py | 43 ++++++++++--------- 1 file changed, 22 insertions(+), 21 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 2427b4e9..f63853b7 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -14,7 +14,6 @@ this is 10% of the expected motion is the measured axis. """ import numpy as np -import cv2 from typing import Literal from PIL import Image import time @@ -94,6 +93,7 @@ def _predict_z( return z_dest - stage.position["z"] + def _move_and_measure( step_size: dict[str, float], axis: Literal["x", "y"], @@ -127,8 +127,8 @@ def _move_and_measure( autofocus.looping_autofocus(dz=800) image2 = np.array(Image.open(cam.grab_jpeg().open())) offset = fft_image_tracking.displacement_between_images( - image_0=image1, image_1=image2, sigma=10, fractional_threshold=0.1, pad=True - ) # Units is pixels + image_0=image1, image_1=image2, sigma=10, fractional_threshold=0.1, pad=True + ) # Units is pixels data.delta["x"] = int(offset[1]) data.delta["y"] = int(offset[0]) @@ -164,7 +164,7 @@ def _acquire_z_predict_points( medium_step, stream_resolution, direction, factor=0.1 ) for _loop in range(5): - image1 = np.array(Image.open(cam.grab_jpeg().open())) + image1 = np.array(Image.open(cam.grab_jpeg().open())) offset, wrong_axis = _move_and_measure( step_size=_generate_move_dicts(medium_step, stream_resolution, direction), axis=axis, @@ -175,6 +175,7 @@ def _acquire_z_predict_points( autofocus=autofocus, cam=cam, ) + logger.info(f"Offset measured as {data.delta[axis]}") data.measure(stage.position, offset) @@ -247,13 +248,13 @@ def _check_stage_operation( image2 = np.array(Image.open(cam.grab_jpeg().open())) failure_count += 1 offset = fft_image_tracking.displacement_between_images( - image_0=image1, - image_1=image2, - sigma=10, - fractional_threshold=0.1, - pad=True, - ) - # Units is pixels + image_0=image1, + image_1=image2, + sigma=10, + fractional_threshold=0.1, + pad=True, + ) + # Units is pixels data.delta["x"] = int(offset[1]) data.delta["y"] = int(offset[0]) logger.info( @@ -282,7 +283,7 @@ def _motion_detection( stage: StageDep, cam: CamDep, logger: lt.deps.InvocationLogger, -) -> dict: +) -> dict[str, int]: """Move the stage until motion is detected along a specified axis and direction. :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. @@ -309,12 +310,12 @@ def _motion_detection( csm.move_in_image_coordinates(x=this_motion_step["x"], y=this_motion_step["y"]) image2 = np.array(Image.open(cam.grab_jpeg().open())) offset = fft_image_tracking.displacement_between_images( - image_0=image1, - image_1=image2, - sigma=10, - fractional_threshold=0.1, - pad=True, - ) + image_0=image1, + image_1=image2, + sigma=10, + fractional_threshold=0.1, + pad=True, + ) delta["x"] = int(offset[1]) delta["y"] = int(offset[0]) logger.info(f"Offset measured as {np.abs(delta[axis])}") @@ -523,9 +524,9 @@ class RangeofMotionThing(lt.Thing): direction=axis_dir[1], ) # Save results from single axis and direction - rom_results[f"{'positive' if axis_dir[1] == 1 else 'negative'} {axis_dir[0]}"] = ( - axis_dir_results - ) + rom_results[ + f"{'positive' if axis_dir[1] == 1 else 'negative'} {axis_dir[0]}" + ] = axis_dir_results end_time = time.time() total_time = (end_time - start_time) / 60 From 1e7f62f8ad537fbefd9e22924b22bdd6dd1e9bd1 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 12 Aug 2025 12:20:21 +0100 Subject: [PATCH 39/70] 2 unit tests added. Added a dummy matrix to mock_csm and changed position in mock_stage to a dictionary. --- tests/mock_things/mock_csm.py | 4 +++ tests/mock_things/mock_stage.py | 2 +- tests/test_stage_measure.py | 47 +++++++++++++++++++++++++++++++++ 3 files changed, 52 insertions(+), 1 deletion(-) create mode 100644 tests/test_stage_measure.py diff --git a/tests/mock_things/mock_csm.py b/tests/mock_things/mock_csm.py index 20178a8b..4daa0d57 100644 --- a/tests/mock_things/mock_csm.py +++ b/tests/mock_things/mock_csm.py @@ -17,3 +17,7 @@ class MockCSMThing: """ image_resolution = (123, 456) + image_to_stage_displacement_matrix = [ + [0.03061156624485296, 1.8031242270940833], + [1.773236372778601, 0.006660431608601435], + ] diff --git a/tests/mock_things/mock_stage.py b/tests/mock_things/mock_stage.py index 9a36d044..6733986a 100644 --- a/tests/mock_things/mock_stage.py +++ b/tests/mock_things/mock_stage.py @@ -16,4 +16,4 @@ class MockStageThing: is not artificially inflated. """ - position = (111, 222, 333) + position = {"x": 3635, "y": 10, "z": 617} diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py new file mode 100644 index 00000000..a4a226a7 --- /dev/null +++ b/tests/test_stage_measure.py @@ -0,0 +1,47 @@ +"""File contains unit tests for stage_measure.""" + +from openflexure_microscope_server.things.stage_measure import ( + _generate_move_dicts, + _predict_z, +) + +from .mock_things.mock_csm import MockCSMThing +from .mock_things.mock_stage import MockStageThing + +stage_mock = MockStageThing() +csm_mock = MockCSMThing() + + +def test_generate_move_dicts(): + """Check that the dictionary generated for moves is correct.""" + mock_perc = 50 + mock_res = [820, 616] + mock_dir = 1 + mock_dict = _generate_move_dicts( + fov_perc=mock_perc, stream_resolution=mock_res, direction=mock_dir + ) + expected_dict = {"x": 410, "y": 308} + assert mock_dict == expected_dict + + +def test_predict_z(): + """Check that the prediction for the next z position is correct.""" + mock_positions = [ + {"x": 0, "y": 0, "z": 42}, + {"x": 727, "y": 2, "z": 154}, + {"x": 1454, "y": 4, "z": 228}, + {"x": 2181, "y": 6, "z": 351}, + {"x": 2908, "y": 8, "z": 509}, + {"x": 3635, "y": 10, "z": 617}, + ] + mock_axis = "x" + mock_move = 2908 + mock_z_diff = _predict_z( + positions=mock_positions, + axis=mock_axis, + relative_move=mock_move, + stage=stage_mock, + csm=csm_mock, + ) + expected_z_diff = 1343.1625053206606 + assert mock_z_diff == expected_z_diff From 6bed02eccf795765655aff69b3c8d2041c64594c Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 12 Aug 2025 13:11:01 +0100 Subject: [PATCH 40/70] Removed assert statement and replaced with custom exception --- .../things/stage_measure.py | 27 ++++++++++++------- 1 file changed, 17 insertions(+), 10 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index f63853b7..1c965412 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -36,6 +36,20 @@ CSMDep = lt.deps.direct_thing_client_dependency( AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/") +class ParasiticMotionError(Exception): + """Custom exception raised when parasitic motion is detected. + + Parasitic motion is when motion in the direction not being measured + is too high. + """ + + pass + +def parasitic_detect(delta: float, max_allowed_delta: float) -> None: + """Compare two values and raise parasitic motion error.""" + if delta > max_allowed_delta: + raise ParasiticMotionError(f"Parasitic motion detected. {delta} is greate than {max_allowed_delta}") + def _generate_move_dicts( fov_perc: int, stream_resolution: list[int], @@ -180,10 +194,7 @@ def _acquire_z_predict_points( data.measure(stage.position, offset) - assert np.abs(data.delta[wrong_axis]) < np.abs( - wrong_axis_max_medium[wrong_axis] - ) - + parasitic_detect(delta=abs(data.delta[wrong_axis]), max_allowed_delta=abs(wrong_axis_max_medium[wrong_axis])) def _check_stage_operation( small_step: int, @@ -264,11 +275,7 @@ def _check_stage_operation( data.measure(stage.position, offset) - assert np.abs(data.delta[wrong_axis]) < np.abs( - _generate_move_dicts(small_step, stream_resolution, direction, factor=0.1)[ - wrong_axis - ] - ) + parasitic_detect(abs(data.delta[wrong_axis]),abs(_generate_move_dicts(small_step, stream_resolution, direction, factor=0.1)[wrong_axis])) # this means the edge has been found if np.abs(data.delta[axis]) < np.abs(minimum_offset_small[axis]): @@ -475,7 +482,7 @@ class RangeofMotionThing(lt.Thing): rom_data.reset_tracker() - except AssertionError: + except ParasiticMotionError: logger.info("Parasitic motion detected.") finally: stage.move_absolute( From e3a17240db5b441b1ce9ed2cd0e70fd76ca99a6d Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 12 Aug 2025 15:02:59 +0100 Subject: [PATCH 41/70] Typehint given to data --- .../things/stage_measure.py | 71 +++++++++++-------- 1 file changed, 42 insertions(+), 29 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 1c965412..98bae455 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -35,6 +35,29 @@ CSMDep = lt.deps.direct_thing_client_dependency( ) AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/") +class RomDataTracker: + """Class for tracking range of motion data.""" + + def __init__( + self, + stage_coords: list[dict[str, int]] = [], + cor_lat_steps: list[npt.ArrayLike] = [], + delta: dict = {"x": 0, "y": 0}, + ): + """Define useful data tracked throughout test.""" + self.stage_coords = stage_coords + self.cor_lat_steps = cor_lat_steps + self.delta = delta + + def measure(self, current_pos: dict[str, int], cor: npt.ArrayLike): + """Store useful data.""" + self.stage_coords.append(current_pos) + self.cor_lat_steps.append(cor) + + def reset_tracker(self): + """Empty the rom tracker.""" + self.stage_coords.clear() + self.cor_lat_steps.clear() class ParasiticMotionError(Exception): """Custom exception raised when parasitic motion is detected. @@ -45,10 +68,14 @@ class ParasiticMotionError(Exception): pass + def parasitic_detect(delta: float, max_allowed_delta: float) -> None: """Compare two values and raise parasitic motion error.""" if delta > max_allowed_delta: - raise ParasiticMotionError(f"Parasitic motion detected. {delta} is greate than {max_allowed_delta}") + raise ParasiticMotionError( + f"Parasitic motion detected. {delta} is greater than {max_allowed_delta}" + ) + def _generate_move_dicts( fov_perc: int, @@ -153,7 +180,7 @@ def _acquire_z_predict_points( stream_resolution: list[int], direction: Literal[1, -1], axis: Literal["x", "y"], - data, + data: RomDataTracker, csm: CSMDep, cam: CamDep, stage: StageDep, @@ -194,7 +221,11 @@ def _acquire_z_predict_points( data.measure(stage.position, offset) - parasitic_detect(delta=abs(data.delta[wrong_axis]), max_allowed_delta=abs(wrong_axis_max_medium[wrong_axis])) + parasitic_detect( + delta=abs(data.delta[wrong_axis]), + max_allowed_delta=abs(wrong_axis_max_medium[wrong_axis]), + ) + def _check_stage_operation( small_step: int, @@ -275,7 +306,14 @@ def _check_stage_operation( data.measure(stage.position, offset) - parasitic_detect(abs(data.delta[wrong_axis]),abs(_generate_move_dicts(small_step, stream_resolution, direction, factor=0.1)[wrong_axis])) + parasitic_detect( + abs(data.delta[wrong_axis]), + abs( + _generate_move_dicts( + small_step, stream_resolution, direction, factor=0.1 + )[wrong_axis] + ), + ) # this means the edge has been found if np.abs(data.delta[axis]) < np.abs(minimum_offset_small[axis]): @@ -333,31 +371,6 @@ def _motion_detection( return stage.position -class RomDataTracker: - """Class for tracking range of motion data.""" - - def __init__( - self, - stage_coords: list[dict[str, int]] = [], - cor_lat_steps: list[list[float]] = [], - delta: dict = {"x": 0, "y": 0}, - ): - """Define useful data tracked throughout test.""" - self.stage_coords = stage_coords - self.cor_lat_steps = cor_lat_steps - self.delta = delta - - def measure(self, current_pos: dict[str, int], cor: list[float]): - """Store useful data.""" - self.stage_coords.append(current_pos) - self.cor_lat_steps.append(cor) - - def reset_tracker(self): - """Empty the rom tracker.""" - self.stage_coords.clear() - self.cor_lat_steps.clear() - - class RangeofMotionThing(lt.Thing): """A class used to measure the range of motion of the stage in X and Y.""" From 773f19ee36563bcab14d1492dc2cbd1c4cd4aa03 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 12 Aug 2025 16:14:08 +0100 Subject: [PATCH 42/70] Added unit test for parasitic error --- .../things/stage_measure.py | 2 +- tests/test_stage_measure.py | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 98bae455..13761c52 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -69,7 +69,7 @@ class ParasiticMotionError(Exception): pass -def parasitic_detect(delta: float, max_allowed_delta: float) -> None: +def _parasitic_detect(delta: float, max_allowed_delta: float) -> None: """Compare two values and raise parasitic motion error.""" if delta > max_allowed_delta: raise ParasiticMotionError( diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index a4a226a7..79c48c43 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -1,8 +1,11 @@ """File contains unit tests for stage_measure.""" +import pytest from openflexure_microscope_server.things.stage_measure import ( _generate_move_dicts, _predict_z, + _parasitic_detect, + ParasiticMotionError ) from .mock_things.mock_csm import MockCSMThing @@ -45,3 +48,10 @@ def test_predict_z(): ) expected_z_diff = 1343.1625053206606 assert mock_z_diff == expected_z_diff + +def test_parasitic_detect(): + """Check that the parasitic error is raised correctly.""" + mock_delta = 100 + mock_max = 50 + with pytest.raises(ParasiticMotionError): + _parasitic_detect(delta=mock_delta, max_allowed_delta=mock_max) From 094cb94da8b875d5fd608e33b73f4455b50c13a4 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 12 Aug 2025 16:25:31 +0100 Subject: [PATCH 43/70] Ruff formatting --- src/openflexure_microscope_server/things/stage_measure.py | 6 ++++-- tests/test_stage_measure.py | 3 ++- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 13761c52..93716c38 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -35,6 +35,7 @@ CSMDep = lt.deps.direct_thing_client_dependency( ) AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/") + class RomDataTracker: """Class for tracking range of motion data.""" @@ -59,6 +60,7 @@ class RomDataTracker: self.stage_coords.clear() self.cor_lat_steps.clear() + class ParasiticMotionError(Exception): """Custom exception raised when parasitic motion is detected. @@ -221,7 +223,7 @@ def _acquire_z_predict_points( data.measure(stage.position, offset) - parasitic_detect( + _parasitic_detect( delta=abs(data.delta[wrong_axis]), max_allowed_delta=abs(wrong_axis_max_medium[wrong_axis]), ) @@ -306,7 +308,7 @@ def _check_stage_operation( data.measure(stage.position, offset) - parasitic_detect( + _parasitic_detect( abs(data.delta[wrong_axis]), abs( _generate_move_dicts( diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index 79c48c43..0083bbf2 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -5,7 +5,7 @@ from openflexure_microscope_server.things.stage_measure import ( _generate_move_dicts, _predict_z, _parasitic_detect, - ParasiticMotionError + ParasiticMotionError, ) from .mock_things.mock_csm import MockCSMThing @@ -49,6 +49,7 @@ def test_predict_z(): expected_z_diff = 1343.1625053206606 assert mock_z_diff == expected_z_diff + def test_parasitic_detect(): """Check that the parasitic error is raised correctly.""" mock_delta = 100 From 61167888a655f5f71a9cb993417ad6b393d35f28 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 12 Aug 2025 17:10:47 +0100 Subject: [PATCH 44/70] Fixed pydoctor errors --- src/openflexure_microscope_server/things/stage_measure.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 93716c38..5a4f4b1d 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -114,10 +114,10 @@ def _predict_z( """Predict the next z position for a move using previous positions. :param positions: The list of positions used for predicting z. - This will be a list of all previous positions. + This will be a list of all previous positions. :param axis: The axis in which the stage is moving. This must be 'x' or 'y'. :param relative_move: The move which the function is trying to predict z for. - Here, this is inputted with units of pixels. + Here, this is inputted with units of pixels. :param stage: A direct_thing_client dependency for the the microscope stage. :param csm: A direct_thing_client dependency for camera stage mapping. :return: A number of pixels the stage needs to move in z. @@ -256,7 +256,7 @@ def _check_stage_operation( :params axis: The axis which is being measured. This must be 'x' or 'y'. :params data: The object used to track stage coordinates, correlation and delta. :params minimum_offset_small: A dictionary containing the minimum values for - a successful correlation. + a successful correlation. :param csm: A direct_thing_client dependency for camera stage mapping. :param camera: A raw_thing_client depeendency for the camera. :param stage: A raw_thing_client depeendency for the microscope stage. From e5b0c7f0499ce95e086b1be5416119e918c4108c Mon Sep 17 00:00:00 2001 From: Ben Chisholm Date: Mon, 18 Aug 2025 21:04:53 +0100 Subject: [PATCH 45/70] Created function to collate data with unit test for it --- .../things/stage_measure.py | 37 ++++++++++++------- tests/test_stage_measure.py | 33 +++++++++++++++++ 2 files changed, 57 insertions(+), 13 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 5a4f4b1d..3bfee9be 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -372,6 +372,28 @@ def _motion_detection( return stage.position +def _collate_data(data: dict, time: float, csm: CSMDep) -> dict: + """Collate anmd calculate all useful data from ROM test. + + :param data: Dictionary created from ROM test. + :param time: Total time of the range of motion test. + :param csm: A direct_thing_client dependency for camera stage mapping. + """ + x_range = abs( + data["positive x"]["final_position"]["x"] + - data["negative x"]["final_position"]["x"] + ) + y_range = abs( + data["positive y"]["final_position"]["y"] + - data["negative y"]["final_position"]["y"] + ) + step_range = [x_range, y_range] + + data["Time"] = time + data["CSM Matrix"] = csm.image_to_stage_displacement_matrix + data["Step Range"] = step_range + return data + class RangeofMotionThing(lt.Thing): """A class used to measure the range of motion of the stage in X and Y.""" @@ -553,20 +575,9 @@ class RangeofMotionThing(lt.Thing): end_time = time.time() total_time = (end_time - start_time) / 60 - x_range = abs( - rom_results["positive x"]["final_position"]["x"] - - rom_results["negative x"]["final_position"]["x"] - ) - y_range = abs( - rom_results["positive y"]["final_position"]["y"] - - rom_results["negative y"]["final_position"]["y"] - ) - step_range = [x_range, y_range] - logger.info(f"Range of motion is {x_range} X {y_range} steps") + rom_results = _collate_data(data = rom_results, time=total_time, csm=csm) - rom_results["Time"] = total_time - rom_results["CSM Matrix"] = csm.image_to_stage_displacement_matrix - rom_results["Step Range"] = step_range + logger.info(f"Range of motion is {rom_results['Step Range'][0]} X {rom_results['Step Range'][1]} steps") self.last_calibration = DenumpifyingDict(rom_results).model_dump() diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index 0083bbf2..3f7a30d9 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -6,6 +6,7 @@ from openflexure_microscope_server.things.stage_measure import ( _predict_z, _parasitic_detect, ParasiticMotionError, + _collate_data ) from .mock_things.mock_csm import MockCSMThing @@ -56,3 +57,35 @@ def test_parasitic_detect(): mock_max = 50 with pytest.raises(ParasiticMotionError): _parasitic_detect(delta=mock_delta, max_allowed_delta=mock_max) + +def test_collate_data(): + """Check that the data is being collected and calculated correctly.""" + mock_data = { + "positive x": { + "final_position": { + "x": 100 + } + }, + "negative x": { + "final_position": { + "x": 50 + } + }, + "positive y": { + "final_position": { + "y": 100 + } + }, + "negative y": { + "final_position": { + "y": 50 + } + } +} + + mock_step_range = [50, 50] + + mock_time = 123 + mock_dict = _collate_data(data=mock_data, time=mock_time, csm=MockCSMThing) + + assert mock_dict["Step Range"] == mock_step_range \ No newline at end of file From 1e7ad7bf2954222bf08a169dc3a5fdf5692f2723 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Tue, 19 Aug 2025 10:53:42 +0100 Subject: [PATCH 46/70] Fixed ruff errors --- .../things/stage_measure.py | 9 ++-- tests/test_stage_measure.py | 47 +++++-------------- 2 files changed, 18 insertions(+), 38 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 3bfee9be..236dfdf8 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -372,9 +372,10 @@ def _motion_detection( return stage.position + def _collate_data(data: dict, time: float, csm: CSMDep) -> dict: """Collate anmd calculate all useful data from ROM test. - + :param data: Dictionary created from ROM test. :param time: Total time of the range of motion test. :param csm: A direct_thing_client dependency for camera stage mapping. @@ -575,9 +576,11 @@ class RangeofMotionThing(lt.Thing): end_time = time.time() total_time = (end_time - start_time) / 60 - rom_results = _collate_data(data = rom_results, time=total_time, csm=csm) + rom_results = _collate_data(data=rom_results, time=total_time, csm=csm) - logger.info(f"Range of motion is {rom_results['Step Range'][0]} X {rom_results['Step Range'][1]} steps") + logger.info( + f"Range of motion is {rom_results['Step Range'][0]} X {rom_results['Step Range'][1]} steps" + ) self.last_calibration = DenumpifyingDict(rom_results).model_dump() diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index 3f7a30d9..b7e9abc9 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -1,14 +1,7 @@ """File contains unit tests for stage_measure.""" import pytest -from openflexure_microscope_server.things.stage_measure import ( - _generate_move_dicts, - _predict_z, - _parasitic_detect, - ParasiticMotionError, - _collate_data -) - +from openflexure_microscope_server.things import stage_measure as sm from .mock_things.mock_csm import MockCSMThing from .mock_things.mock_stage import MockStageThing @@ -21,7 +14,7 @@ def test_generate_move_dicts(): mock_perc = 50 mock_res = [820, 616] mock_dir = 1 - mock_dict = _generate_move_dicts( + mock_dict = sm._generate_move_dicts( fov_perc=mock_perc, stream_resolution=mock_res, direction=mock_dir ) expected_dict = {"x": 410, "y": 308} @@ -40,7 +33,7 @@ def test_predict_z(): ] mock_axis = "x" mock_move = 2908 - mock_z_diff = _predict_z( + mock_z_diff = sm._predict_z( positions=mock_positions, axis=mock_axis, relative_move=mock_move, @@ -55,37 +48,21 @@ def test_parasitic_detect(): """Check that the parasitic error is raised correctly.""" mock_delta = 100 mock_max = 50 - with pytest.raises(ParasiticMotionError): - _parasitic_detect(delta=mock_delta, max_allowed_delta=mock_max) + with pytest.raises(sm.ParasiticMotionError): + sm._parasitic_detect(delta=mock_delta, max_allowed_delta=mock_max) + def test_collate_data(): """Check that the data is being collected and calculated correctly.""" mock_data = { - "positive x": { - "final_position": { - "x": 100 - } - }, - "negative x": { - "final_position": { - "x": 50 - } - }, - "positive y": { - "final_position": { - "y": 100 - } - }, - "negative y": { - "final_position": { - "y": 50 - } + "positive x": {"final_position": {"x": 100}}, + "negative x": {"final_position": {"x": 50}}, + "positive y": {"final_position": {"y": 100}}, + "negative y": {"final_position": {"y": 50}}, } -} mock_step_range = [50, 50] - mock_time = 123 - mock_dict = _collate_data(data=mock_data, time=mock_time, csm=MockCSMThing) + mock_dict = sm._collate_data(data=mock_data, time=mock_time, csm=MockCSMThing) - assert mock_dict["Step Range"] == mock_step_range \ No newline at end of file + assert mock_dict["Step Range"] == mock_step_range From 3f29e6ccea1dd6bb12929f1aa3a3474c264cb650 Mon Sep 17 00:00:00 2001 From: Chish36 Date: Mon, 8 Sep 2025 15:36:52 +0100 Subject: [PATCH 47/70] Changed variable name, fixed parasitic error --- .../things/stage_measure.py | 15 +++++++++++---- 1 file changed, 11 insertions(+), 4 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 236dfdf8..72bbea69 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -374,7 +374,7 @@ def _motion_detection( def _collate_data(data: dict, time: float, csm: CSMDep) -> dict: - """Collate anmd calculate all useful data from ROM test. + """Collate and calculate all useful data from ROM test. :param data: Dictionary created from ROM test. :param time: Total time of the range of motion test. @@ -522,6 +522,11 @@ class RangeofMotionThing(lt.Thing): except ParasiticMotionError: logger.info("Parasitic motion detected.") + return { + "correlation_lateral_steps": 0, + "stage_positions": 0, + "final_position": {"x": 0, "y": 0, "z": 0}, + } finally: stage.move_absolute( x=starting_position[0], @@ -555,7 +560,7 @@ class RangeofMotionThing(lt.Thing): Please ensure you are using a big enough sample." ) start_time = time.time() - rom_results = {} + rom_json = {} # Loop through all axes and directions. for axis_dir in [["x", 1], ["x", -1], ["y", 1], ["y", -1]]: @@ -569,14 +574,16 @@ class RangeofMotionThing(lt.Thing): direction=axis_dir[1], ) # Save results from single axis and direction - rom_results[ + rom_json[ f"{'positive' if axis_dir[1] == 1 else 'negative'} {axis_dir[0]}" ] = axis_dir_results end_time = time.time() total_time = (end_time - start_time) / 60 - rom_results = _collate_data(data=rom_results, time=total_time, csm=csm) + rom_results = _collate_data( + data=rom_json, time=total_time, csm=csm, logger=logger + ) logger.info( f"Range of motion is {rom_results['Step Range'][0]} X {rom_results['Step Range'][1]} steps" From 19b2afe7d76f3233079503c1c0475c2ac2f2737d Mon Sep 17 00:00:00 2001 From: jaknapper Date: Tue, 9 Sep 2025 16:19:50 +0100 Subject: [PATCH 48/70] Logging fixes --- .../things/stage_measure.py | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 72bbea69..c12912c9 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -429,8 +429,8 @@ class RangeofMotionThing(lt.Thing): try: logger.info( - f"Beginning the {axis}-axis in the {'positive' if direction == 1 else 'negative'}\ - direction" + f"Beginning the {axis}-axis in the {'positive' if direction == 1 else 'negative'} " + "direction" ) # Generate required dictionaries for step sizes and minimum offsets @@ -556,8 +556,8 @@ class RangeofMotionThing(lt.Thing): :return: Results dictionary separated into keys of each axis and direction. """ logger.info( - "Using the stage to measure the Range of Motion.\ - Please ensure you are using a big enough sample." + "Using the stage to measure the Range of Motion. " + "Please ensure you are using a big enough sample." ) start_time = time.time() rom_json = {} @@ -581,12 +581,10 @@ class RangeofMotionThing(lt.Thing): end_time = time.time() total_time = (end_time - start_time) / 60 - rom_results = _collate_data( - data=rom_json, time=total_time, csm=csm, logger=logger - ) + rom_results = _collate_data(data=rom_json, time=total_time, csm=csm) logger.info( - f"Range of motion is {rom_results['Step Range'][0]} X {rom_results['Step Range'][1]} steps" + f"Range of motion is {rom_results['Step Range'][0]} x {rom_results['Step Range'][1]} steps" ) self.last_calibration = DenumpifyingDict(rom_results).model_dump() From 8a0836f368623544cc304e459026b1b164112672 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 16 Oct 2025 10:42:48 +0100 Subject: [PATCH 49/70] Fix some annotations, arguments, and docstring formatting --- .../things/stage_measure.py | 54 ++++++++++--------- .../utilities.py | 2 +- 2 files changed, 30 insertions(+), 26 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index c12912c9..593b2789 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -13,16 +13,19 @@ is tracked and an error is raised if it exceeds a minimum amount. Currently this is 10% of the expected motion is the measured axis. """ -import numpy as np -from typing import Literal -from PIL import Image +from typing import Literal, Any, Optional import time -from ..utilities import quadratic + from scipy.optimize import curve_fit +from PIL import Image +import numpy as np +import numpy.typing as npt + from camera_stage_mapping import fft_image_tracking import labthings_fastapi as lt from labthings_fastapi.types.numpy import DenumpifyingDict -import numpy.typing as npt + +from openflexure_microscope_server.utilities import quadratic # Things from .autofocus import AutofocusThing @@ -41,21 +44,21 @@ class RomDataTracker: def __init__( self, - stage_coords: list[dict[str, int]] = [], - cor_lat_steps: list[npt.ArrayLike] = [], - delta: dict = {"x": 0, "y": 0}, - ): + stage_coords: Optional[list[dict[str, int]]] = None, + cor_lat_steps: Optional[list[npt.ArrayLike]] = None, + delta: Optional[dict[str, int]] = None, + ) -> None: """Define useful data tracked throughout test.""" - self.stage_coords = stage_coords - self.cor_lat_steps = cor_lat_steps - self.delta = delta + self.stage_coords = [] if stage_coords is None else stage_coords + self.cor_lat_steps = [] if cor_lat_steps is None else cor_lat_steps + self.delta = {"x": 0, "y": 0} if delta is None else delta - def measure(self, current_pos: dict[str, int], cor: npt.ArrayLike): + def measure(self, current_pos: dict[str, int], cor: npt.ArrayLike) -> None: """Store useful data.""" self.stage_coords.append(current_pos) self.cor_lat_steps.append(cor) - def reset_tracker(self): + def reset_tracker(self) -> None: """Empty the rom tracker.""" self.stage_coords.clear() self.cor_lat_steps.clear() @@ -114,10 +117,10 @@ def _predict_z( """Predict the next z position for a move using previous positions. :param positions: The list of positions used for predicting z. - This will be a list of all previous positions. + This will be a list of all previous positions. :param axis: The axis in which the stage is moving. This must be 'x' or 'y'. :param relative_move: The move which the function is trying to predict z for. - Here, this is inputted with units of pixels. + Here, this is inputted with units of pixels. :param stage: A direct_thing_client dependency for the the microscope stage. :param csm: A direct_thing_client dependency for camera stage mapping. :return: A number of pixels the stage needs to move in z. @@ -140,7 +143,7 @@ def _predict_z( def _move_and_measure( step_size: dict[str, float], axis: Literal["x", "y"], - data, + data: RomDataTracker, image1: npt.ArrayLike, autofocus_proc: bool, csm: CSMDep, @@ -158,7 +161,7 @@ def _move_and_measure( :param autofocus: A direct_thing_client dependency for autofocus. :param cam: A raw_thing_client depeendency for the camera. :return: All required data for the next move. This includes the updated delta value and offset. - Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. + Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. """ if axis == "x": csm.move_in_image_coordinates(x=step_size["x"], y=0) @@ -191,6 +194,8 @@ def _acquire_z_predict_points( ) -> None: """Complete 5 medium sized steps to collect stage coordinates for prediction. + Delta is updated and tracked after each move. + :params stream_resolution: The resolution of the stream from the camera. :param direction: The direction the stage moves. :params axis: The axis which is being measured. This must be 'x' or 'y'. @@ -200,7 +205,6 @@ def _acquire_z_predict_points( :param stage: A raw_thing_client depeendency for the microscope stage. :param autofocus: A direct_thing_client dependency for autofocus. :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. - Delta is updated and tracked after each move. """ medium_step = 50 wrong_axis_max_medium = _generate_move_dicts( @@ -234,7 +238,7 @@ def _check_stage_operation( stream_resolution: list[int], direction: Literal[1, -1], axis: Literal["x", "y"], - data, + data: RomDataTracker, minimum_offset_small: dict[str, float], csm: CSMDep, cam: CamDep, @@ -256,7 +260,7 @@ def _check_stage_operation( :params axis: The axis which is being measured. This must be 'x' or 'y'. :params data: The object used to track stage coordinates, correlation and delta. :params minimum_offset_small: A dictionary containing the minimum values for - a successful correlation. + a successful correlation. :param csm: A direct_thing_client dependency for camera stage mapping. :param camera: A raw_thing_client depeendency for the camera. :param stage: A raw_thing_client depeendency for the microscope stage. @@ -335,7 +339,7 @@ def _motion_detection( :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. :params direction: The direction in which the stage was moving - previous to motion detection being used. + previous to motion detection being used. :param csm: A direct_thing_client dependency for camera stage mapping. :param stage: A raw_thing_client depeendency for the microscope stage. :param camera: A raw_thing_client depeendency for the camera. @@ -373,7 +377,7 @@ def _motion_detection( return stage.position -def _collate_data(data: dict, time: float, csm: CSMDep) -> dict: +def _collate_data(data: dict, time: float, csm: CSMDep) -> dict[str, Any]: """Collate and calculate all useful data from ROM test. :param data: Dictionary created from ROM test. @@ -418,7 +422,7 @@ class RangeofMotionThing(lt.Thing): :param axis: The axis which is being measured. This must be 'x' or 'y'. :param direction: The direction which is being measured. This must be 1 or -1. :return: Results dictionary containing stage positions, - correlations and the final position. + correlations and the final position. """ autofocus.looping_autofocus(dz=1000) @@ -545,7 +549,7 @@ class RangeofMotionThing(lt.Thing): cam: CamDep, csm: CSMDep, logger: lt.deps.InvocationLogger, - ): + ) -> dict[str, Any]: """Measures the range of motion of the stage across the x and y axes. :param autofocus: A raw_thing_client dependency for autofocus. diff --git a/src/openflexure_microscope_server/utilities.py b/src/openflexure_microscope_server/utilities.py index d48f4293..318e6457 100644 --- a/src/openflexure_microscope_server/utilities.py +++ b/src/openflexure_microscope_server/utilities.py @@ -329,7 +329,7 @@ def _get_version_from_toml(toml_path: str) -> str: return "Undefined" -def quadratic(x: float, a: float, b: float, c: float): +def quadratic(x: float, a: float, b: float, c: float) -> float: """Quadratic function. Used for predicting z. :param x: The points at which to evaluate the quadratic. From 9fe6ad8efbd160d61bf33f42d4631240f3301646 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 16 Oct 2025 11:28:31 +0100 Subject: [PATCH 50/70] Reduce argument numbers with data class and constants --- .../things/stage_measure.py | 229 ++++++++---------- 1 file changed, 98 insertions(+), 131 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 593b2789..8b1893c6 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -15,6 +15,7 @@ this is 10% of the expected motion is the measured axis. from typing import Literal, Any, Optional import time +from dataclasses import dataclass from scipy.optimize import curve_fit from PIL import Image @@ -38,6 +39,11 @@ CSMDep = lt.deps.direct_thing_client_dependency( ) AutofocusDep = lt.deps.direct_thing_client_dependency(AutofocusThing, "/autofocus/") +## Size of movement in percentage of field of view +SMALL_STEP = 20 +MEDIUM_STEP = 50 +BIG_STEP = 200 + class RomDataTracker: """Class for tracking range of motion data.""" @@ -64,6 +70,20 @@ class RomDataTracker: self.cor_lat_steps.clear() +@dataclass +class RomDeps: + """Grouped dependencies for the Range of motion Thing. + + These are used to pass the dependencies from actions to other sub-functions. + """ + + autofocus: AutofocusDep + stage: StageDep + cam: CamDep + csm: CSMDep + logger: lt.deps.InvocationLogger + + class ParasiticMotionError(Exception): """Custom exception raised when parasitic motion is detected. @@ -71,8 +91,6 @@ class ParasiticMotionError(Exception): is too high. """ - pass - def _parasitic_detect(delta: float, max_allowed_delta: float) -> None: """Compare two values and raise parasitic motion error.""" @@ -132,9 +150,9 @@ def _predict_z( lateral_positions = [i[axis] for i in positions] # x or y positions z_positions = [i["z"] for i in positions] - parameters, _ = curve_fit(quadratic, lateral_positions, z_positions) + fit_params, *_others = curve_fit(quadratic, lateral_positions, z_positions) z_dest = quadratic( - stage.position[axis] + (relative_move / pixel_step[axis]), *parameters + stage.position[axis] + (relative_move / pixel_step[axis]), *fit_params ) return z_dest - stage.position["z"] @@ -146,9 +164,7 @@ def _move_and_measure( data: RomDataTracker, image1: npt.ArrayLike, autofocus_proc: bool, - csm: CSMDep, - autofocus: AutofocusDep, - cam: CamDep, + rom_deps: RomDeps, ) -> tuple[npt.ArrayLike, str]: """Move the stage and measure the offset between the two positions. @@ -156,22 +172,19 @@ def _move_and_measure( :param axis: The axis in which the stage is moving. This must be 'x' or 'y'. :param data: The object used to track stage coordinates, correlation and delta. :param image1: An image taken before moving to be correlated with image2. - :param autofocus_proc: If true, looping.autofocus will be used after the stage moves. - :param csm: A direct_thing_client dependency for camera stage mapping. - :param autofocus: A direct_thing_client dependency for autofocus. - :param cam: A raw_thing_client depeendency for the camera. + :param rom_deps: All dependencies that were passed to the calling Action :return: All required data for the next move. This includes the updated delta value and offset. Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. """ if axis == "x": - csm.move_in_image_coordinates(x=step_size["x"], y=0) + rom_deps.csm.move_in_image_coordinates(x=step_size["x"], y=0) wrong_axis = "y" else: - csm.move_in_image_coordinates(x=0, y=step_size["y"]) + rom_deps.csm.move_in_image_coordinates(x=0, y=step_size["y"]) wrong_axis = "x" if autofocus_proc: - autofocus.looping_autofocus(dz=800) - image2 = np.array(Image.open(cam.grab_jpeg().open())) + rom_deps.autofocus.looping_autofocus(dz=800) + image2 = np.array(Image.open(rom_deps.cam.grab_jpeg().open())) offset = fft_image_tracking.displacement_between_images( image_0=image1, image_1=image2, sigma=10, fractional_threshold=0.1, pad=True ) # Units is pixels @@ -186,11 +199,7 @@ def _acquire_z_predict_points( direction: Literal[1, -1], axis: Literal["x", "y"], data: RomDataTracker, - csm: CSMDep, - cam: CamDep, - stage: StageDep, - autofocus: AutofocusDep, - logger: lt.deps.InvocationLogger, + rom_deps: RomDeps, ) -> None: """Complete 5 medium sized steps to collect stage coordinates for prediction. @@ -200,32 +209,26 @@ def _acquire_z_predict_points( :param direction: The direction the stage moves. :params axis: The axis which is being measured. This must be 'x' or 'y'. :params data: The object used to track stage coordinates, correlation and delta. - :param csm: A direct_thing_client dependency for camera stage mapping. - :param camera: A raw_thing_client depeendency for the camera. - :param stage: A raw_thing_client depeendency for the microscope stage. - :param autofocus: A direct_thing_client dependency for autofocus. + :param rom_deps: All dependencies that were passed to the calling Action :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. """ - medium_step = 50 wrong_axis_max_medium = _generate_move_dicts( - medium_step, stream_resolution, direction, factor=0.1 + MEDIUM_STEP, stream_resolution, direction, factor=0.1 ) for _loop in range(5): - image1 = np.array(Image.open(cam.grab_jpeg().open())) + image1 = rom_deps.cam.grab_as_array() offset, wrong_axis = _move_and_measure( - step_size=_generate_move_dicts(medium_step, stream_resolution, direction), + step_size=_generate_move_dicts(MEDIUM_STEP, stream_resolution, direction), axis=axis, data=data, image1=image1, autofocus_proc=True, - csm=csm, - autofocus=autofocus, - cam=cam, + rom_deps=rom_deps, ) - logger.info(f"Offset measured as {data.delta[axis]}") + rom_deps.logger.info(f"Offset measured as {data.delta[axis]}") - data.measure(stage.position, offset) + data.measure(rom_deps.stage.position, offset) _parasitic_detect( delta=abs(data.delta[wrong_axis]), @@ -234,17 +237,12 @@ def _acquire_z_predict_points( def _check_stage_operation( - small_step: int, stream_resolution: list[int], direction: Literal[1, -1], axis: Literal["x", "y"], data: RomDataTracker, minimum_offset_small: dict[str, float], - csm: CSMDep, - cam: CamDep, - stage: StageDep, - autofocus: AutofocusDep, - logger: lt.deps.InvocationLogger, + rom_deps: RomDeps, ) -> None: """Carries out 3 small moves in a given direction and axis. @@ -254,46 +252,41 @@ def _check_stage_operation( image quality is not causing the correlation to be unsuccessful. If the correlation is still too low then the edge is found. - :params small_step: The integer value used to generate the small step sizes. + Delta is updated and tracked after each move. + :params stream_resolution: The resolution of the stream from the camera. :param direction: The direction the stage moves. :params axis: The axis which is being measured. This must be 'x' or 'y'. :params data: The object used to track stage coordinates, correlation and delta. :params minimum_offset_small: A dictionary containing the minimum values for a successful correlation. - :param csm: A direct_thing_client dependency for camera stage mapping. - :param camera: A raw_thing_client depeendency for the camera. - :param stage: A raw_thing_client depeendency for the microscope stage. - :param autofocus: A direct_thing_client dependency for autofocus. + :param rom_deps: All dependencies that were passed to the calling Action :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. - Delta is updated and tracked after each move. """ failure_count = 0 for _loop in range(3): - image1 = np.array(Image.open(cam.grab_jpeg().open())) + image1 = rom_deps.cam.grab_as_array() offset, wrong_axis = _move_and_measure( - step_size=_generate_move_dicts(small_step, stream_resolution, direction), + step_size=_generate_move_dicts(SMALL_STEP, stream_resolution, direction), axis=axis, data=data, image1=image1, autofocus_proc=False, - csm=csm, - autofocus=autofocus, - cam=cam, + rom_deps=rom_deps, ) - logger.info(f"Offset measured as {data.delta[axis]}") + rom_deps.logger.info(f"Offset measured as {data.delta[axis]}") # If correlation is too small, refocuses and capture new image 3 times. while ( np.abs(data.delta[axis]) < np.abs(minimum_offset_small[axis]) and failure_count < 3 ): - logger.info( + rom_deps.logger.info( f"Correlation failed. Refocusing to check. Attempt {failure_count + 1}/3" ) - autofocus.looping_autofocus(dz=1000) - image2 = np.array(Image.open(cam.grab_jpeg().open())) + rom_deps.autofocus.looping_autofocus(dz=1000) + image2 = rom_deps.cam.grab_as_array() failure_count += 1 offset = fft_image_tracking.displacement_between_images( image_0=image1, @@ -305,45 +298,39 @@ def _check_stage_operation( # Units is pixels data.delta["x"] = int(offset[1]) data.delta["y"] = int(offset[0]) - logger.info( + rom_deps.logger.info( f"Displacement found was {data.delta[axis]}.\ Minimum offset is {minimum_offset_small[axis]}" ) - data.measure(stage.position, offset) + data.measure(rom_deps.stage.position, offset) _parasitic_detect( abs(data.delta[wrong_axis]), abs( _generate_move_dicts( - small_step, stream_resolution, direction, factor=0.1 + SMALL_STEP, stream_resolution, direction, factor=0.1 )[wrong_axis] ), ) # this means the edge has been found if np.abs(data.delta[axis]) < np.abs(minimum_offset_small[axis]): - logger.info("Edge has been found.") + rom_deps.logger.info("Edge has been found.") break def _motion_detection( axis: Literal["x", "y"], direction: Literal[1, -1], - csm: CSMDep, - stage: StageDep, - cam: CamDep, - logger: lt.deps.InvocationLogger, + rom_deps: RomDeps, ) -> dict[str, int]: """Move the stage until motion is detected along a specified axis and direction. :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. :params direction: The direction in which the stage was moving previous to motion detection being used. - :param csm: A direct_thing_client dependency for camera stage mapping. - :param stage: A raw_thing_client depeendency for the microscope stage. - :param camera: A raw_thing_client depeendency for the camera. - :return: The stage coordinates where motion was detected. + :param rom_deps: All dependencies that were passed to the calling Action """ # Array of increasing pixel sizes (powers of 2 from 1 to 512) displacements = [2**i for i in range(10)] @@ -356,10 +343,12 @@ def _motion_detection( for loop in range(np.shape(displacements)[0]): this_motion_step[axis] = displacements[loop] * direction * -1 - logger.info(f"Testing with step size {this_motion_step[axis]}") - image1 = np.array(Image.open(cam.grab_jpeg().open())) - csm.move_in_image_coordinates(x=this_motion_step["x"], y=this_motion_step["y"]) - image2 = np.array(Image.open(cam.grab_jpeg().open())) + rom_deps.logger.info(f"Testing with step size {this_motion_step[axis]}") + image1 = rom_deps.cam.grab_as_array() + rom_deps.csm.move_in_image_coordinates( + x=this_motion_step["x"], y=this_motion_step["y"] + ) + image2 = rom_deps.cam.grab_as_array() offset = fft_image_tracking.displacement_between_images( image_0=image1, image_1=image2, @@ -369,19 +358,19 @@ def _motion_detection( ) delta["x"] = int(offset[1]) delta["y"] = int(offset[0]) - logger.info(f"Offset measured as {np.abs(delta[axis])}") + rom_deps.logger.info(f"Offset measured as {np.abs(delta[axis])}") if np.abs(delta[axis]) > motion_minimum: - logger.info("Motion detected.") + rom_deps.logger.info("Motion detected.") break - return stage.position + return rom_deps.stage.position -def _collate_data(data: dict, time: float, csm: CSMDep) -> dict[str, Any]: +def _collate_data(data: dict, total_time: float, csm: CSMDep) -> dict[str, Any]: """Collate and calculate all useful data from ROM test. :param data: Dictionary created from ROM test. - :param time: Total time of the range of motion test. + :param total_time: Total time of the range of motion test. :param csm: A direct_thing_client dependency for camera stage mapping. """ x_range = abs( @@ -394,7 +383,7 @@ def _collate_data(data: dict, time: float, csm: CSMDep) -> dict[str, Any]: ) step_range = [x_range, y_range] - data["Time"] = time + data["Time"] = total_time data["CSM Matrix"] = csm.image_to_stage_displacement_matrix data["Step Range"] = step_range return data @@ -403,67 +392,56 @@ def _collate_data(data: dict, time: float, csm: CSMDep) -> dict[str, Any]: class RangeofMotionThing(lt.Thing): """A class used to measure the range of motion of the stage in X and Y.""" + last_calibration = lt.ThingSetting(initial_value=None, model=dict, readonly=True) + def rom_axis( self, - autofocus: AutofocusDep, - stage: StageDep, - cam: CamDep, - csm: CSMDep, - logger: lt.deps.InvocationLogger, axis: Literal["x", "y"], direction: Literal[1, -1], + rom_deps: RomDeps, ) -> dict: """Measure the range of motion in a single axis and direction. - :param stage: A raw_thing_client depeendency for the microscope stage. - :param cam: A raw_thing_client depeendency for the camera. - :param csm: A raw_thing_client depeendency for camera stage mapping. - :param logger: A raw_thing_client depeendency for the logger. + :param rom_deps: All dependencies that were passed to the calling Action :param axis: The axis which is being measured. This must be 'x' or 'y'. :param direction: The direction which is being measured. This must be 1 or -1. :return: Results dictionary containing stage positions, correlations and the final position. """ - autofocus.looping_autofocus(dz=1000) + rom_deps.autofocus.looping_autofocus(dz=1000) - starting_position = list(stage.position.values()) + starting_position = list(rom_deps.stage.position.values()) rom_data = RomDataTracker() # initialise data tracking object axis_results = {} try: - logger.info( - f"Beginning the {axis}-axis in the {'positive' if direction == 1 else 'negative'} " - "direction" + dir_str = "positive" if direction == 1 else "negative" + rom_deps.logger.info( + f"Beginning the {axis}-axis in the {dir_str} direction" ) # Generate required dictionaries for step sizes and minimum offsets stream_resolution = [820, 616] - big_step = 200 - small_step = 20 step_sizes_big = _generate_move_dicts( - big_step, stream_resolution, direction + BIG_STEP, stream_resolution, direction ) - rom_data.stage_coords.append(stage.position) + rom_data.stage_coords.append(rom_deps.stage.position) - logger.info("Moving the stage in 5 medium sized steps.") + rom_deps.logger.info("Moving the stage in 5 medium sized steps.") _acquire_z_predict_points( stream_resolution=stream_resolution, direction=direction, axis=axis, data=rom_data, - csm=csm, - cam=cam, - stage=stage, - autofocus=autofocus, - logger=logger, + rom_deps=rom_deps, ) # 1 big step followed by 3 small steps minimum_offset_small = _generate_move_dicts( - small_step, stream_resolution, direction, factor=0.65 + SMALL_STEP, stream_resolution, direction, factor=0.65 ) while np.abs(rom_data.delta[axis]) > np.abs(minimum_offset_small[axis]): @@ -471,46 +449,38 @@ class RangeofMotionThing(lt.Thing): positions=rom_data.stage_coords, axis=axis, relative_move=step_sizes_big[axis], - stage=stage, - csm=csm, + stage=rom_deps.stage, + csm=rom_deps.csm, ) - logger.info("Z calibration complete.") - stage.move_relative(z=z_diff) - logger.info(f"Moved in z by {z_diff}") + rom_deps.logger.info("Z calibration complete.") + rom_deps.stage.move_relative(z=z_diff) + rom_deps.logger.info(f"Moved in z by {z_diff}") # Big step if axis == "x": - csm.move_in_image_coordinates(x=step_sizes_big["x"], y=0) + rom_deps.csm.move_in_image_coordinates(x=step_sizes_big["x"], y=0) else: - csm.move_in_image_coordinates(x=0, y=step_sizes_big["y"]) + rom_deps.csm.move_in_image_coordinates(x=0, y=step_sizes_big["y"]) - autofocus.looping_autofocus(dz=800) - rom_data.stage_coords.append(stage.position) + rom_deps.autofocus.looping_autofocus(dz=800) + rom_data.stage_coords.append(rom_deps.stage.position) _check_stage_operation( - small_step=small_step, stream_resolution=stream_resolution, direction=direction, axis=axis, data=rom_data, minimum_offset_small=minimum_offset_small, - csm=csm, - cam=cam, - stage=stage, - autofocus=autofocus, - logger=logger, + rom_deps=rom_deps, ) # Motion detection - logger.info("Running motion detection") + rom_deps.logger.info("Running motion detection") final_pos = _motion_detection( axis=axis, direction=direction, - csm=csm, - stage=stage, - cam=cam, - logger=logger, + rom_deps=rom_deps, ) rom_data.stage_coords[np.shape(np.array(rom_data.stage_coords))[0] - 1] = ( final_pos @@ -525,14 +495,14 @@ class RangeofMotionThing(lt.Thing): rom_data.reset_tracker() except ParasiticMotionError: - logger.info("Parasitic motion detected.") + rom_deps.logger.error("Parasitic motion detected.") return { "correlation_lateral_steps": 0, "stage_positions": 0, "final_position": {"x": 0, "y": 0, "z": 0}, } finally: - stage.move_absolute( + rom_deps.stage.move_absolute( x=starting_position[0], y=starting_position[1], z=starting_position[2], @@ -559,6 +529,9 @@ class RangeofMotionThing(lt.Thing): :param logger: A raw_thing_client depeendency for the logger. :return: Results dictionary separated into keys of each axis and direction. """ + rom_deps = RomDeps( + autofocus=autofocus, stage=stage, csm=csm, cam=cam, logger=logger + ) logger.info( "Using the stage to measure the Range of Motion. " "Please ensure you are using a big enough sample." @@ -569,13 +542,9 @@ class RangeofMotionThing(lt.Thing): # Loop through all axes and directions. for axis_dir in [["x", 1], ["x", -1], ["y", 1], ["y", -1]]: axis_dir_results = self.rom_axis( - autofocus, - stage, - cam, - csm, - logger, axis=axis_dir[0], direction=axis_dir[1], + rom_deps=rom_deps, ) # Save results from single axis and direction rom_json[ @@ -585,7 +554,7 @@ class RangeofMotionThing(lt.Thing): end_time = time.time() total_time = (end_time - start_time) / 60 - rom_results = _collate_data(data=rom_json, time=total_time, csm=csm) + rom_results = _collate_data(data=rom_json, total_time=total_time, csm=csm) logger.info( f"Range of motion is {rom_results['Step Range'][0]} x {rom_results['Step Range'][1]} steps" @@ -594,5 +563,3 @@ class RangeofMotionThing(lt.Thing): self.last_calibration = DenumpifyingDict(rom_results).model_dump() return rom_results - - last_calibration = lt.ThingSetting(initial_value=None, model=dict, readonly=True) From 23c4ac6c181654d802bf85e92f5f21e910c42dd3 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 16 Oct 2025 14:11:58 +0100 Subject: [PATCH 51/70] Adjust data types and remove need to reset tracker --- .../things/stage_measure.py | 66 ++++++------------- 1 file changed, 21 insertions(+), 45 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 8b1893c6..0fb6f279 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -24,7 +24,6 @@ import numpy.typing as npt from camera_stage_mapping import fft_image_tracking import labthings_fastapi as lt -from labthings_fastapi.types.numpy import DenumpifyingDict from openflexure_microscope_server.utilities import quadratic @@ -48,6 +47,7 @@ BIG_STEP = 200 class RomDataTracker: """Class for tracking range of motion data.""" + # TODO: Find out what these variable are def __init__( self, stage_coords: Optional[list[dict[str, int]]] = None, @@ -64,11 +64,6 @@ class RomDataTracker: self.stage_coords.append(current_pos) self.cor_lat_steps.append(cor) - def reset_tracker(self) -> None: - """Empty the rom tracker.""" - self.stage_coords.clear() - self.cor_lat_steps.clear() - @dataclass class RomDeps: @@ -366,33 +361,12 @@ def _motion_detection( return rom_deps.stage.position -def _collate_data(data: dict, total_time: float, csm: CSMDep) -> dict[str, Any]: - """Collate and calculate all useful data from ROM test. - - :param data: Dictionary created from ROM test. - :param total_time: Total time of the range of motion test. - :param csm: A direct_thing_client dependency for camera stage mapping. - """ - x_range = abs( - data["positive x"]["final_position"]["x"] - - data["negative x"]["final_position"]["x"] - ) - y_range = abs( - data["positive y"]["final_position"]["y"] - - data["negative y"]["final_position"]["y"] - ) - step_range = [x_range, y_range] - - data["Time"] = total_time - data["CSM Matrix"] = csm.image_to_stage_displacement_matrix - data["Step Range"] = step_range - return data - - class RangeofMotionThing(lt.Thing): """A class used to measure the range of motion of the stage in X and Y.""" - last_calibration = lt.ThingSetting(initial_value=None, model=dict, readonly=True) + calibrated_range = lt.ThingSetting( + initial_value=None, model=Optional[list[int, int]], readonly=True + ) def rom_axis( self, @@ -482,6 +456,7 @@ class RangeofMotionThing(lt.Thing): direction=direction, rom_deps=rom_deps, ) + rom_data.stage_coords[np.shape(np.array(rom_data.stage_coords))[0] - 1] = ( final_pos ) @@ -492,15 +467,6 @@ class RangeofMotionThing(lt.Thing): "final_position": final_pos, } - rom_data.reset_tracker() - - except ParasiticMotionError: - rom_deps.logger.error("Parasitic motion detected.") - return { - "correlation_lateral_steps": 0, - "stage_positions": 0, - "final_position": {"x": 0, "y": 0, "z": 0}, - } finally: rom_deps.stage.move_absolute( x=starting_position[0], @@ -554,12 +520,22 @@ class RangeofMotionThing(lt.Thing): end_time = time.time() total_time = (end_time - start_time) / 60 - rom_results = _collate_data(data=rom_json, total_time=total_time, csm=csm) - - logger.info( - f"Range of motion is {rom_results['Step Range'][0]} x {rom_results['Step Range'][1]} steps" + x_range = abs( + rom_json["positive x"]["final_position"]["x"] + - rom_json["negative x"]["final_position"]["x"] ) + y_range = abs( + rom_json["positive y"]["final_position"]["y"] + - rom_json["negative y"]["final_position"]["y"] + ) + step_range = [x_range, y_range] - self.last_calibration = DenumpifyingDict(rom_results).model_dump() + logger.info(f"Range of motion is {step_range[0]} x {step_range[1]} steps") - return rom_results + self.calibrated_range = step_range + + return { + "Time": total_time, + "CSM Matrix": csm.image_to_stage_displacement_matrix.tolist(), + "Step Range": step_range, + } From ee76a46882d6613fa3216060c85ee299a1ff2d4d Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 16 Oct 2025 14:25:04 +0100 Subject: [PATCH 52/70] Move actions to top of ROM thing --- .../things/stage_measure.py | 128 +++++++++--------- 1 file changed, 64 insertions(+), 64 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 0fb6f279..a4fd2e52 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -368,7 +368,70 @@ class RangeofMotionThing(lt.Thing): initial_value=None, model=Optional[list[int, int]], readonly=True ) - def rom_axis( + @lt.thing_action + def rom_main( + self, + autofocus: AutofocusDep, + stage: StageDep, + cam: CamDep, + csm: CSMDep, + logger: lt.deps.InvocationLogger, + ) -> dict[str, Any]: + """Measures the range of motion of the stage across the x and y axes. + + :param autofocus: A raw_thing_client dependency for autofocus. + :param stage: A raw_thing_client depeendency for the microscope stage. + :param cam: A raw_thing_client depeendency for the camera. + :param csm: A raw_thing_client depeendency for camera stage mapping. + :param logger: A raw_thing_client depeendency for the logger. + :return: Results dictionary separated into keys of each axis and direction. + """ + rom_deps = RomDeps( + autofocus=autofocus, stage=stage, csm=csm, cam=cam, logger=logger + ) + logger.info( + "Using the stage to measure the Range of Motion. " + "Please ensure you are using a big enough sample." + ) + start_time = time.time() + rom_json = {} + + # Loop through all axes and directions. + for axis_dir in [["x", 1], ["x", -1], ["y", 1], ["y", -1]]: + axis_dir_results = self._rom_axis( + axis=axis_dir[0], + direction=axis_dir[1], + rom_deps=rom_deps, + ) + # Save results from single axis and direction + rom_json[ + f"{'positive' if axis_dir[1] == 1 else 'negative'} {axis_dir[0]}" + ] = axis_dir_results + + end_time = time.time() + total_time = (end_time - start_time) / 60 + + x_range = abs( + rom_json["positive x"]["final_position"]["x"] + - rom_json["negative x"]["final_position"]["x"] + ) + y_range = abs( + rom_json["positive y"]["final_position"]["y"] + - rom_json["negative y"]["final_position"]["y"] + ) + step_range = [x_range, y_range] + + logger.info(f"Range of motion is {step_range[0]} x {step_range[1]} steps") + + self.calibrated_range = step_range + + return { + "Time": total_time, + "CSM Matrix": csm.image_to_stage_displacement_matrix.tolist(), + "Step Range": step_range, + } + + def _rom_axis( self, axis: Literal["x", "y"], direction: Literal[1, -1], @@ -476,66 +539,3 @@ class RangeofMotionThing(lt.Thing): ) return axis_results - - @lt.thing_action - def rom_main( - self, - autofocus: AutofocusDep, - stage: StageDep, - cam: CamDep, - csm: CSMDep, - logger: lt.deps.InvocationLogger, - ) -> dict[str, Any]: - """Measures the range of motion of the stage across the x and y axes. - - :param autofocus: A raw_thing_client dependency for autofocus. - :param stage: A raw_thing_client depeendency for the microscope stage. - :param cam: A raw_thing_client depeendency for the camera. - :param csm: A raw_thing_client depeendency for camera stage mapping. - :param logger: A raw_thing_client depeendency for the logger. - :return: Results dictionary separated into keys of each axis and direction. - """ - rom_deps = RomDeps( - autofocus=autofocus, stage=stage, csm=csm, cam=cam, logger=logger - ) - logger.info( - "Using the stage to measure the Range of Motion. " - "Please ensure you are using a big enough sample." - ) - start_time = time.time() - rom_json = {} - - # Loop through all axes and directions. - for axis_dir in [["x", 1], ["x", -1], ["y", 1], ["y", -1]]: - axis_dir_results = self.rom_axis( - axis=axis_dir[0], - direction=axis_dir[1], - rom_deps=rom_deps, - ) - # Save results from single axis and direction - rom_json[ - f"{'positive' if axis_dir[1] == 1 else 'negative'} {axis_dir[0]}" - ] = axis_dir_results - - end_time = time.time() - total_time = (end_time - start_time) / 60 - - x_range = abs( - rom_json["positive x"]["final_position"]["x"] - - rom_json["negative x"]["final_position"]["x"] - ) - y_range = abs( - rom_json["positive y"]["final_position"]["y"] - - rom_json["negative y"]["final_position"]["y"] - ) - step_range = [x_range, y_range] - - logger.info(f"Range of motion is {step_range[0]} x {step_range[1]} steps") - - self.calibrated_range = step_range - - return { - "Time": total_time, - "CSM Matrix": csm.image_to_stage_displacement_matrix.tolist(), - "Step Range": step_range, - } From f1730ff2c0acfa12b2b0cb53b541abc85e9b115a Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 16 Oct 2025 15:13:44 +0100 Subject: [PATCH 53/70] Tidy up ROM action and add a lock --- .../things/stage_measure.py | 68 +++++++++---------- 1 file changed, 33 insertions(+), 35 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index a4fd2e52..c6acf5d2 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -16,6 +16,7 @@ this is 10% of the expected motion is the measured axis. from typing import Literal, Any, Optional import time from dataclasses import dataclass +from threading import Lock from scipy.optimize import curve_fit from PIL import Image @@ -64,6 +65,11 @@ class RomDataTracker: self.stage_coords.append(current_pos) self.cor_lat_steps.append(cor) + @property + def final_position(self) -> dict[str, int]: + """The last stage coordinate recorded.""" + return self.stage_coords[-1].copy() + @dataclass class RomDeps: @@ -368,8 +374,13 @@ class RangeofMotionThing(lt.Thing): initial_value=None, model=Optional[list[int, int]], readonly=True ) + def __init__(self) -> None: + """Initalise and create the lock.""" + super().__init__() + self._lock = Lock() + @lt.thing_action - def rom_main( + def perform_rom_test( self, autofocus: AutofocusDep, stage: StageDep, @@ -386,6 +397,10 @@ class RangeofMotionThing(lt.Thing): :param logger: A raw_thing_client depeendency for the logger. :return: Results dictionary separated into keys of each axis and direction. """ + got_lock = self._lock.acquire(blocking=False) + if not got_lock: + raise RuntimeError("Trying to run ROM test when a test is already running.") + rom_deps = RomDeps( autofocus=autofocus, stage=stage, csm=csm, cam=cam, logger=logger ) @@ -394,32 +409,24 @@ class RangeofMotionThing(lt.Thing): "Please ensure you are using a big enough sample." ) start_time = time.time() - rom_json = {} - # Loop through all axes and directions. - for axis_dir in [["x", 1], ["x", -1], ["y", 1], ["y", -1]]: - axis_dir_results = self._rom_axis( - axis=axis_dir[0], - direction=axis_dir[1], - rom_deps=rom_deps, - ) - # Save results from single axis and direction - rom_json[ - f"{'positive' if axis_dir[1] == 1 else 'negative'} {axis_dir[0]}" - ] = axis_dir_results + # The total range for the two axes under test + step_range = [] + + for axis in ["x", "y"]: + # The final position of the axis under test in the given direction + axis_limits = [] + for axis_dir in [1, -1]: + axis_data = self._rom_axis( + axis=axis, direction=axis_dir, rom_deps=rom_deps + ) + # Append final position + axis_limits.append(axis_data.final_position[axis]) + # Calculate step range. + step_range.append(abs(axis_limits[0] - axis_limits[1])) end_time = time.time() - total_time = (end_time - start_time) / 60 - - x_range = abs( - rom_json["positive x"]["final_position"]["x"] - - rom_json["negative x"]["final_position"]["x"] - ) - y_range = abs( - rom_json["positive y"]["final_position"]["y"] - - rom_json["negative y"]["final_position"]["y"] - ) - step_range = [x_range, y_range] + total_time = end_time - start_time logger.info(f"Range of motion is {step_range[0]} x {step_range[1]} steps") @@ -450,7 +457,6 @@ class RangeofMotionThing(lt.Thing): starting_position = list(rom_deps.stage.position.values()) rom_data = RomDataTracker() # initialise data tracking object - axis_results = {} try: dir_str = "positive" if direction == 1 else "negative" @@ -520,15 +526,7 @@ class RangeofMotionThing(lt.Thing): rom_deps=rom_deps, ) - rom_data.stage_coords[np.shape(np.array(rom_data.stage_coords))[0] - 1] = ( - final_pos - ) - - axis_results = { - "correlation_lateral_steps": rom_data.cor_lat_steps.copy(), - "stage_positions": rom_data.stage_coords.copy(), - "final_position": final_pos, - } + rom_data.stage_coords[-1] = final_pos finally: rom_deps.stage.move_absolute( @@ -538,4 +536,4 @@ class RangeofMotionThing(lt.Thing): block_cancellation=True, ) - return axis_results + return rom_data From 8a1acbea28a5ff9988ceaa6e581aad8433955d26 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 16 Oct 2025 20:57:36 +0100 Subject: [PATCH 54/70] Refactor as class methods, renaming methods based on intended purpose. This was a very big commit as starting the refactor broke everything --- .../things/stage_measure.py | 725 +++++++++--------- 1 file changed, 375 insertions(+), 350 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index c6acf5d2..8ce934f1 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -13,15 +13,13 @@ is tracked and an error is raised if it exceeds a minimum amount. Currently this is 10% of the expected motion is the measured axis. """ -from typing import Literal, Any, Optional +from typing import Literal, Any, Optional, overload import time from dataclasses import dataclass from threading import Lock from scipy.optimize import curve_fit -from PIL import Image import numpy as np -import numpy.typing as npt from camera_stage_mapping import fft_image_tracking import labthings_fastapi as lt @@ -44,32 +42,64 @@ SMALL_STEP = 20 MEDIUM_STEP = 50 BIG_STEP = 200 +PARASITIC_MOTION_TOL = 0.1 + class RomDataTracker: - """Class for tracking range of motion data.""" + """Class for tracking range of motion data. - # TODO: Find out what these variable are - def __init__( - self, - stage_coords: Optional[list[dict[str, int]]] = None, - cor_lat_steps: Optional[list[npt.ArrayLike]] = None, - delta: Optional[dict[str, int]] = None, - ) -> None: + The attributes storing data are: + + * ``stage_coords`` A list of the the stage coordinates at each measurement location + * ``displacements`` A list of the displacement calculated after each movement + """ + + def __init__(self) -> None: """Define useful data tracked throughout test.""" - self.stage_coords = [] if stage_coords is None else stage_coords - self.cor_lat_steps = [] if cor_lat_steps is None else cor_lat_steps - self.delta = {"x": 0, "y": 0} if delta is None else delta + self.stage_coords: list[dict[str, int]] = [] + self.displacements: list[dict[str, int]] = [] - def measure(self, current_pos: dict[str, int], cor: npt.ArrayLike) -> None: - """Store useful data.""" + def record_movement( + self, current_pos: dict[str, int], offset: dict[str, int] + ) -> None: + """Record the current position and the measured offset of the last move.""" self.stage_coords.append(current_pos) - self.cor_lat_steps.append(cor) + self.displacements.append(offset) @property def final_position(self) -> dict[str, int]: """The last stage coordinate recorded.""" return self.stage_coords[-1].copy() + def _predict_z_displacament( + self, + movement: dict[str, int], + stage_position: dict[str, int], + csm_matrix: np.ndarray, + ) -> float: + """Predict the z-displacement needed for a given movement. + + :param movement: The movement to be performed in image coordinates. + :param stage_position: The current stage position in stage coordinates. + :param csm_matrix: The camera stage mapping matrix. + :returns: The predicted relative z displacement needed to stay in focus. + """ + pixel_step = { + "x": 1 / csm_matrix[0][1], + "y": 1 / csm_matrix[1][0], + } + + axis = _axis_from_movement_dict(movement) + + lateral_positions = [i[axis] for i in self.stage_coords] # x or y positions + z_positions = [i["z"] for i in self.stage_coords] + fit_params, *_others = curve_fit(quadratic, lateral_positions, z_positions) + z_dest = quadratic( + stage_position[axis] + (movement[axis] / pixel_step[axis]), *fit_params + ) + + return z_dest - stage_position["z"] + @dataclass class RomDeps: @@ -93,280 +123,6 @@ class ParasiticMotionError(Exception): """ -def _parasitic_detect(delta: float, max_allowed_delta: float) -> None: - """Compare two values and raise parasitic motion error.""" - if delta > max_allowed_delta: - raise ParasiticMotionError( - f"Parasitic motion detected. {delta} is greater than {max_allowed_delta}" - ) - - -def _generate_move_dicts( - fov_perc: int, - stream_resolution: list[int], - direction: Literal[1, -1], - factor: float = 1, -) -> dict[str, float]: - """Create a single dictionary of x and y moves for moving in image coordinates. - - This can either be used to create move sizes or minimum move sizes. For example, - the stage must move a minimum distance for a move to be considered successful. - We define the minimum with this function. This is also used to define the maximally - allowed motion in the wrong axis. - - :param fov_perc: The percentage of field of view the stage should move by. - :param stream_resolution: The resolution of the stream from the camera. - :param direction: The direction the stage moves. - :param factor: Reduction factor which allows for minimum move sizes to be created. - :return: A dictionary with keys 'x' and 'y' with a pixel distance move the stage can make. - """ - return { - "x": (fov_perc / 100) * stream_resolution[0] * factor * direction, - "y": (fov_perc / 100) * stream_resolution[1] * factor * direction, - } # factor used for creating minimum offsets. - - -def _predict_z( - positions: list, - axis: Literal["x", "y"], - relative_move: float, - stage: StageDep, - csm: CSMDep, -) -> float: - """Predict the next z position for a move using previous positions. - - :param positions: The list of positions used for predicting z. - This will be a list of all previous positions. - :param axis: The axis in which the stage is moving. This must be 'x' or 'y'. - :param relative_move: The move which the function is trying to predict z for. - Here, this is inputted with units of pixels. - :param stage: A direct_thing_client dependency for the the microscope stage. - :param csm: A direct_thing_client dependency for camera stage mapping. - :return: A number of pixels the stage needs to move in z. - """ - pixel_step = { - "x": 1 / csm.image_to_stage_displacement_matrix[0][1], - "y": 1 / csm.image_to_stage_displacement_matrix[1][0], - } - - lateral_positions = [i[axis] for i in positions] # x or y positions - z_positions = [i["z"] for i in positions] - fit_params, *_others = curve_fit(quadratic, lateral_positions, z_positions) - z_dest = quadratic( - stage.position[axis] + (relative_move / pixel_step[axis]), *fit_params - ) - - return z_dest - stage.position["z"] - - -def _move_and_measure( - step_size: dict[str, float], - axis: Literal["x", "y"], - data: RomDataTracker, - image1: npt.ArrayLike, - autofocus_proc: bool, - rom_deps: RomDeps, -) -> tuple[npt.ArrayLike, str]: - """Move the stage and measure the offset between the two positions. - - :param step_size: A dictionary with keys 'x' and 'y' with pixel distances. - :param axis: The axis in which the stage is moving. This must be 'x' or 'y'. - :param data: The object used to track stage coordinates, correlation and delta. - :param image1: An image taken before moving to be correlated with image2. - :param rom_deps: All dependencies that were passed to the calling Action - :return: All required data for the next move. This includes the updated delta value and offset. - Also returns what wrong_axis is i.e. if the direction is 'x', wrong_axis = 'y'. - """ - if axis == "x": - rom_deps.csm.move_in_image_coordinates(x=step_size["x"], y=0) - wrong_axis = "y" - else: - rom_deps.csm.move_in_image_coordinates(x=0, y=step_size["y"]) - wrong_axis = "x" - if autofocus_proc: - rom_deps.autofocus.looping_autofocus(dz=800) - image2 = np.array(Image.open(rom_deps.cam.grab_jpeg().open())) - offset = fft_image_tracking.displacement_between_images( - image_0=image1, image_1=image2, sigma=10, fractional_threshold=0.1, pad=True - ) # Units is pixels - data.delta["x"] = int(offset[1]) - data.delta["y"] = int(offset[0]) - - return offset, wrong_axis - - -def _acquire_z_predict_points( - stream_resolution: list[int], - direction: Literal[1, -1], - axis: Literal["x", "y"], - data: RomDataTracker, - rom_deps: RomDeps, -) -> None: - """Complete 5 medium sized steps to collect stage coordinates for prediction. - - Delta is updated and tracked after each move. - - :params stream_resolution: The resolution of the stream from the camera. - :param direction: The direction the stage moves. - :params axis: The axis which is being measured. This must be 'x' or 'y'. - :params data: The object used to track stage coordinates, correlation and delta. - :param rom_deps: All dependencies that were passed to the calling Action - :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. - """ - wrong_axis_max_medium = _generate_move_dicts( - MEDIUM_STEP, stream_resolution, direction, factor=0.1 - ) - for _loop in range(5): - image1 = rom_deps.cam.grab_as_array() - offset, wrong_axis = _move_and_measure( - step_size=_generate_move_dicts(MEDIUM_STEP, stream_resolution, direction), - axis=axis, - data=data, - image1=image1, - autofocus_proc=True, - rom_deps=rom_deps, - ) - - rom_deps.logger.info(f"Offset measured as {data.delta[axis]}") - - data.measure(rom_deps.stage.position, offset) - - _parasitic_detect( - delta=abs(data.delta[wrong_axis]), - max_allowed_delta=abs(wrong_axis_max_medium[wrong_axis]), - ) - - -def _check_stage_operation( - stream_resolution: list[int], - direction: Literal[1, -1], - axis: Literal["x", "y"], - data: RomDataTracker, - minimum_offset_small: dict[str, float], - rom_deps: RomDeps, -) -> None: - """Carries out 3 small moves in a given direction and axis. - - Each position after the first is correlated with the previous position - to check the stage has moved as far as it should. If the correlation is - less than expected, 3 attempts are made to refocus the image to ensure that - image quality is not causing the correlation to be unsuccessful. If the correlation - is still too low then the edge is found. - - Delta is updated and tracked after each move. - - :params stream_resolution: The resolution of the stream from the camera. - :param direction: The direction the stage moves. - :params axis: The axis which is being measured. This must be 'x' or 'y'. - :params data: The object used to track stage coordinates, correlation and delta. - :params minimum_offset_small: A dictionary containing the minimum values for - a successful correlation. - :param rom_deps: All dependencies that were passed to the calling Action - :return: Stage_coords and cor_lat_steps are lists of data tracked throughout the test. - """ - failure_count = 0 - - for _loop in range(3): - image1 = rom_deps.cam.grab_as_array() - offset, wrong_axis = _move_and_measure( - step_size=_generate_move_dicts(SMALL_STEP, stream_resolution, direction), - axis=axis, - data=data, - image1=image1, - autofocus_proc=False, - rom_deps=rom_deps, - ) - rom_deps.logger.info(f"Offset measured as {data.delta[axis]}") - - # If correlation is too small, refocuses and capture new image 3 times. - while ( - np.abs(data.delta[axis]) < np.abs(minimum_offset_small[axis]) - and failure_count < 3 - ): - rom_deps.logger.info( - f"Correlation failed. Refocusing to check. Attempt {failure_count + 1}/3" - ) - rom_deps.autofocus.looping_autofocus(dz=1000) - image2 = rom_deps.cam.grab_as_array() - failure_count += 1 - offset = fft_image_tracking.displacement_between_images( - image_0=image1, - image_1=image2, - sigma=10, - fractional_threshold=0.1, - pad=True, - ) - # Units is pixels - data.delta["x"] = int(offset[1]) - data.delta["y"] = int(offset[0]) - rom_deps.logger.info( - f"Displacement found was {data.delta[axis]}.\ - Minimum offset is {minimum_offset_small[axis]}" - ) - - data.measure(rom_deps.stage.position, offset) - - _parasitic_detect( - abs(data.delta[wrong_axis]), - abs( - _generate_move_dicts( - SMALL_STEP, stream_resolution, direction, factor=0.1 - )[wrong_axis] - ), - ) - - # this means the edge has been found - if np.abs(data.delta[axis]) < np.abs(minimum_offset_small[axis]): - rom_deps.logger.info("Edge has been found.") - break - - -def _motion_detection( - axis: Literal["x", "y"], - direction: Literal[1, -1], - rom_deps: RomDeps, -) -> dict[str, int]: - """Move the stage until motion is detected along a specified axis and direction. - - :params axis: The axis in which the stage is moving. This must be 'x' or 'y'. - :params direction: The direction in which the stage was moving - previous to motion detection being used. - :param rom_deps: All dependencies that were passed to the calling Action - """ - # Array of increasing pixel sizes (powers of 2 from 1 to 512) - displacements = [2**i for i in range(10)] - - motion_minimum = 20 # minimum number of pixels for motion to be detected - - this_motion_step = {"x": 0, "y": 0} - - delta = {"x": 0, "y": 0} - - for loop in range(np.shape(displacements)[0]): - this_motion_step[axis] = displacements[loop] * direction * -1 - rom_deps.logger.info(f"Testing with step size {this_motion_step[axis]}") - image1 = rom_deps.cam.grab_as_array() - rom_deps.csm.move_in_image_coordinates( - x=this_motion_step["x"], y=this_motion_step["y"] - ) - image2 = rom_deps.cam.grab_as_array() - offset = fft_image_tracking.displacement_between_images( - image_0=image1, - image_1=image2, - sigma=10, - fractional_threshold=0.1, - pad=True, - ) - delta["x"] = int(offset[1]) - delta["y"] = int(offset[0]) - rom_deps.logger.info(f"Offset measured as {np.abs(delta[axis])}") - if np.abs(delta[axis]) > motion_minimum: - rom_deps.logger.info("Motion detected.") - break - - return rom_deps.stage.position - - class RangeofMotionThing(lt.Thing): """A class used to measure the range of motion of the stage in X and Y.""" @@ -375,9 +131,11 @@ class RangeofMotionThing(lt.Thing): ) def __init__(self) -> None: - """Initalise and create the lock.""" + """Initialise and create the lock.""" super().__init__() self._lock = Lock() + self._stream_resolution: Optional[tuple[int, int]] = None + self._rom_data = RomDataTracker() @lt.thing_action def perform_rom_test( @@ -410,6 +168,8 @@ class RangeofMotionThing(lt.Thing): ) start_time = time.time() + self._set_stream_resolution(cam) + # The total range for the two axes under test step_range = [] @@ -417,19 +177,16 @@ class RangeofMotionThing(lt.Thing): # The final position of the axis under test in the given direction axis_limits = [] for axis_dir in [1, -1]: - axis_data = self._rom_axis( - axis=axis, direction=axis_dir, rom_deps=rom_deps - ) - # Append final position - axis_limits.append(axis_data.final_position[axis]) + # Create a new tracker at start of measurement. + self._rom_data = RomDataTracker() + self._move_until_edge(axis=axis, direction=axis_dir, rom_deps=rom_deps) + # Append final position from tracker + axis_limits.append(self._rom_data.final_position[axis]) # Calculate step range. step_range.append(abs(axis_limits[0] - axis_limits[1])) - end_time = time.time() - total_time = end_time - start_time - + total_time = time.time() - start_time logger.info(f"Range of motion is {step_range[0]} x {step_range[1]} steps") - self.calibrated_range = step_range return { @@ -438,13 +195,22 @@ class RangeofMotionThing(lt.Thing): "Step Range": step_range, } - def _rom_axis( + def _set_stream_resolution(self, cam: CamDep) -> None: + """Set the self._stream_reolution attribute by reading camera. + + :param cam: The camera dependency. + """ + stream_shape = cam.grab_as_array().shape + # Swap axes as numpy is [y, x] + self._stream_resolution = [stream_shape[1], stream_shape[0]] + + def _move_until_edge( self, axis: Literal["x", "y"], direction: Literal[1, -1], rom_deps: RomDeps, ) -> dict: - """Measure the range of motion in a single axis and direction. + """Move in one direction until no movement is detected. :param rom_deps: All dependencies that were passed to the calling Action :param axis: The axis which is being measured. This must be 'x' or 'y'. @@ -452,81 +218,56 @@ class RangeofMotionThing(lt.Thing): :return: Results dictionary containing stage positions, correlations and the final position. """ + # Start by performing an autofocus and recording starting position rom_deps.autofocus.looping_autofocus(dz=1000) - starting_position = list(rom_deps.stage.position.values()) - rom_data = RomDataTracker() # initialise data tracking object - try: dir_str = "positive" if direction == 1 else "negative" rom_deps.logger.info( f"Beginning the {axis}-axis in the {dir_str} direction" ) - # Generate required dictionaries for step sizes and minimum offsets - stream_resolution = [820, 616] - step_sizes_big = _generate_move_dicts( - BIG_STEP, stream_resolution, direction - ) - - rom_data.stage_coords.append(rom_deps.stage.position) + self._rom_data.stage_coords.append(rom_deps.stage.position) rom_deps.logger.info("Moving the stage in 5 medium sized steps.") - _acquire_z_predict_points( - stream_resolution=stream_resolution, + self._initial_moves_for_z_prediction( direction=direction, axis=axis, - data=rom_data, rom_deps=rom_deps, ) - # 1 big step followed by 3 small steps - - minimum_offset_small = _generate_move_dicts( - SMALL_STEP, stream_resolution, direction, factor=0.65 - ) - - while np.abs(rom_data.delta[axis]) > np.abs(minimum_offset_small[axis]): - z_diff = _predict_z( - positions=rom_data.stage_coords, - axis=axis, - relative_move=step_sizes_big[axis], - stage=rom_deps.stage, - csm=rom_deps.csm, + still_moving = True + # Loop taking 1 big step followed by 3 small steps to check if the + # stage is moving as expected or has reached end of range of motion. + # Stop once end of range of motion is detected. + while still_moving: + self._big_z_corrected_movement( + axis=axis, direction=direction, rom_deps=rom_deps ) - rom_deps.logger.info("Z calibration complete.") - rom_deps.stage.move_relative(z=z_diff) - rom_deps.logger.info(f"Moved in z by {z_diff}") - - # Big step - if axis == "x": - rom_deps.csm.move_in_image_coordinates(x=step_sizes_big["x"], y=0) - else: - rom_deps.csm.move_in_image_coordinates(x=0, y=step_sizes_big["y"]) - + # Autofocus and record position rom_deps.autofocus.looping_autofocus(dz=800) - rom_data.stage_coords.append(rom_deps.stage.position) + self._rom_data.stage_coords.append(rom_deps.stage.position) - _check_stage_operation( - stream_resolution=stream_resolution, - direction=direction, + # Perform small moves to check stage is still moving. + still_moving = self._stage_still_moves( axis=axis, - data=rom_data, - minimum_offset_small=minimum_offset_small, + direction=direction, rom_deps=rom_deps, ) - # Motion detection - rom_deps.logger.info("Running motion detection") - final_pos = _motion_detection( + # Stage may have crashed during a large move. Move stage back until motion + # is detected + rom_deps.logger.info("Moving stage back until motion is detect") + self._move_back_until_motion_detected( axis=axis, direction=direction, rom_deps=rom_deps, ) - rom_data.stage_coords[-1] = final_pos + # Replace final position. + self._rom_data.stage_coords[-1] = rom_deps.stage.position finally: rom_deps.stage.move_absolute( @@ -536,4 +277,288 @@ class RangeofMotionThing(lt.Thing): block_cancellation=True, ) - return rom_data + def _img_percentate_to_img_coords( + self, fov_perc: int, axis: Literal["x", "y"] + ) -> float: + """For a given image percentage and axis return the distance in img coords. + + :param fov_perc: The percentage of field of view the stage should move by. + :param axis: The resolution of the stream from the camera. + :return: Distance in image coordinates (pixels) + """ + if self._stream_resolution is None: + raise RuntimeError( + "Stream resolution mut be set before generating movement in coords" + ) + + img_index = 0 if axis == "x" else 1 + return (fov_perc / 100) * self._stream_resolution[img_index] + + def _movement_in_img_coords( + self, + fov_perc: int, + axis: Literal["x", "y"], + direction: Literal[1, -1], + ) -> dict[str, float]: + """Return the dictionary for a move in image coordinates. + + This dictionary can be passed directly to csm.move_in_image_coordinates + + :param fov_perc: The percentage of field of view the stage should move by. + :param axis: The resolution of the stream from the camera. + :param direction: The direction the stage moves. + + :return: The movement size in image coordinates + """ + distance = self._img_percentate_to_img_coords(fov_perc=fov_perc, axis=axis) + if axis == "x": + return {"x": distance * direction, "y": 0} + return {"x": 0, "y": distance * direction} + + def _initial_moves_for_z_prediction( + self, + direction: Literal[1, -1], + axis: Literal["x", "y"], + rom_deps: RomDeps, + ) -> None: + """Perform 5 medium sized moves with autofocus for z feed-forward. + + z-feed forward allows prediction of the z-position as the stage moves. For the + feed forward calculation to work an initial number of measurements must be + taken. This method performs these initial measurements. + + :param direction: The direction the stage moves. + :param axis: The axis which is being measured. This must be 'x' or 'y'. + :param rom_deps: All dependencies that were passed to the calling Action + """ + movement = self._movement_in_img_coords( + fov_perc=MEDIUM_STEP, axis=axis, direction=direction + ) + for _loop in range(5): + offset = self._move_and_measure(movement=movement, rom_deps=rom_deps) + + rom_deps.logger.info(f"Offset measured as {offset[axis]}") + + self._rom_data.record_movement(rom_deps.stage.position, offset) + _detect_parasitic_motion(movement, offset) + + def _big_z_corrected_movement( + self, axis: Literal["x", "y"], direction: Literal[1, -1], rom_deps: RomDeps + ) -> None: + """Take one big move with feed-forward z-correction. + + The size is defined by the BIG_STEP constant. + + :param axis: The axis to move in. + :param direction: The direction to move in. + :param rom_deps: All dependencies that were passed to the calling Action + """ + big_movement = self._movement_in_img_coords( + fov_perc=BIG_STEP, axis=axis, direction=direction + ) + z_disp = self._rom_data._predict_z_displacament( + movement=big_movement[axis], + stage_position=rom_deps.stage.position, + csm_matrix=rom_deps.csm.image_to_stage_displacement_matrix, + ) + rom_deps.stage.move_relative(z=z_disp) + rom_deps.csm.move_in_image_coordinates(**big_movement) + + def _stage_still_moves( + self, + axis: Literal["x", "y"], + direction: Literal[1, -1], + rom_deps: RomDeps, + ) -> bool: + """Carry out 3 small moves in a given direction and axis. + + Each position after the first is correlated with the previous position + to check the stage has moved as far as it should. If the correlation is + less than expected, 3 attempts are made to refocus the image to ensure that + image quality is not causing the correlation to be unsuccessful. If the correlation + is still too low then the edge is found. + + Delta is updated and tracked after each move. + + :param stream_resolution: The resolution of the stream from the camera. + :param direction: The direction the stage moves. + :param axis: The axis which is being measured. This must be 'x' or 'y'. + :param rom_deps: All dependencies that were passed to the calling Action + """ + movement = self._movement_in_img_coords( + fov_perc=SMALL_STEP, axis=axis, direction=direction + ) + abs_min_offset = abs(movement[axis] * 0.65) + + for _loop in range(3): + offset = self._move_and_measure( + movement=movement, + rom_deps=rom_deps, + # Don't autofocus initially + perform_autofocus=False, + # But retry focus 3 times if detected motion is too small + max_autofocus_repeats=3, + abs_min_offset=abs_min_offset, + ) + rom_deps.logger.info(f"Offset measured as {offset[axis]}") + + self._rom_data.record_movement(rom_deps.stage.position, offset) + _detect_parasitic_motion(movement, offset) + + if abs(offset[axis]) < abs_min_offset: + # If the offset is below the abs min offset, the edge has been found. + rom_deps.logger.info("Edge has been found.") + return False + # If we reached here the stage is still moving fine + return True + + def _move_back_until_motion_detected( + self, + axis: Literal["x", "y"], + direction: Literal[1, -1], + rom_deps: RomDeps, + ) -> None: + """Move the stage against the direction of test unil motion is detected. + + In the case that the stage has reached the end of the motion, this method moves + the motor in the opposite direction until motion is detected. + + :param axis: The axis in which the stage is moving. This must be 'x' or 'y'. + :param direction: The direction in which the stage was moving during the test, + this method will move in the opposite direction. + :param rom_deps: All dependencies that were passed to the calling Action + """ + # Array of increasing pixel sizes (powers of 2 from 1 to 512) + displacements = [2**i for i in range(10)] + + motion_minimum = 20 # minimum number of pixels for motion to be detected + + movement = {"x": 0, "y": 0} + + for displacement in displacements: + # Increment movement + movement[axis] = displacement * direction * -1 + rom_deps.logger.info(f"Testing with step size {movement[axis]}") + + offset = self._move_and_measure( + movement=movement, rom_deps=rom_deps, perform_autofocus=False + ) + + rom_deps.logger.info(f"Offset measured as {abs(offset[axis])}") + if abs(offset[axis]) > motion_minimum: + rom_deps.logger.info("Motion detected.") + return + # If this point is reached then motion is never detected + raise RuntimeError("Cannot detect motion again after reaching end of range.") + + def _move_and_measure( + self, + movement: dict[str, float], + rom_deps: RomDeps, + perform_autofocus: bool = True, + max_autofocus_repeats: int = 0, + abs_min_offset: float = 0.0, + ) -> dict[str, float]: + """Move the stage and measure the offset between the two positions. + + :param movement: A dictionary containing the distance to move in image coords. + :param rom_deps: All dependencies that were passed to the calling Action + :param perform_autofocus: Set to False to disable atutofocus after move. + Default is True + :param max_autofocus_repeats: The number of times to repeat the focus if the + detected (on-axis) offset is below ``abs_min_offset``. This will only work + if ``abs_min_offset`` is also set + :param abs_min_offset: The absolute minimum (on-axis) offset, under which the + autofocus is repeated. + :return: All required data for the next move. This includes the updated delta + value and offset. + """ + # Take image before move + before_img = rom_deps.cam.grab_as_array() + # Move and autofocus if required + rom_deps.csm.move_in_image_coordinates(**movement) + if perform_autofocus: + rom_deps.autofocus.looping_autofocus(dz=800) + # Take image and calculate offset + offset = self._offset_from(before_img, rom_deps=rom_deps) + + if max_autofocus_repeats > 0: + axis = _axis_from_movement_dict(movement) + af_repeats = 0 + while abs(offset[axis]) < abs_min_offset: + af_repeats += 1 + rom_deps.logger.info( + f"Motion not detected. Refocusing to check. Attempt {af_repeats}/3." + ) + rom_deps.autofocus.looping_autofocus(dz=800) + # Re-take image and calculate offset + offset = self._offset_from(before_img, rom_deps=rom_deps) + if af_repeats >= max_autofocus_repeats: + # break if the maximum number of tries are exceeded. + break + + return offset + + def _offset_from( + self, before_img: np.ndarray, rom_deps: RomDeps + ) -> dict[str, float]: + """Take an image and calculate the offset from an input image. + + :param before_img: The image the offset should be calculated with respect to. + :param rom_deps: All dependencies that were passed to the calling Action + :return: The calculated offset as a dictionary in pixels + """ + after_img = rom_deps.cam.grab_as_array() + offset = fft_image_tracking.displacement_between_images( + image_0=before_img, + image_1=after_img, + sigma=10, + fractional_threshold=0.1, + pad=True, + ) + return {"x": offset[1], "y": offset[0]} + + +@overload +def _axis_from_movement_dict( + movement: dict[str, float], return_other: bool = False +) -> str: ... + + +@overload +def _axis_from_movement_dict( + movement: dict[str, float], return_other: bool = True +) -> tuple[str, str]: ... + + +def _axis_from_movement_dict(movement: dict[str, float], return_other: bool = False): + """Return the axis that a given movement dictionary moves in. + + For example: ``_axis_from_movement_dict({"x": 10, "y":0})`` will return ``x``. + + :param movement: The movement dictionary. + :param return_other: If True return a tuple with the first value being the movement + axis and the second being the other axis. Default is False + :return: The movement axis (and optionally the other axis) as strings. + """ + if return_other: + return ("y", "x") if movement["x"] == 0 else ("x", "y") + return "y" if movement["x"] == 0 else "x" + + +def _detect_parasitic_motion( + movement: dict[str, float], offset: dict[str, float] +) -> None: + """Compare a desired movement to measured offset and error if parasitic motion is too high. + + :param movement: The movement dictionary in image coordinates. + :param offset: The offset calculated from correlation. + """ + movement_axis, other_axis = _axis_from_movement_dict(movement, return_other=True) + parasitic_motion = abs(offset[other_axis]) + max_parasitic_motion = abs(movement[movement_axis] * PARASITIC_MOTION_TOL) + if parasitic_motion > max_parasitic_motion: + raise ParasiticMotionError( + f"Parasitic motion detected. Detected motion of {parasitic_motion} pixels " + f"this exceeds a maximum for this move of {max_parasitic_motion} pixels." + ) From 50d6298c0627106d6088111d85f2e3774d81dcea Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Thu, 16 Oct 2025 22:28:32 +0100 Subject: [PATCH 55/70] Start updating ROM tests for new structure --- .../things/stage_measure.py | 12 +- tests/test_stage_measure.py | 130 ++++++++++++------ 2 files changed, 97 insertions(+), 45 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 8ce934f1..dee255b5 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -57,21 +57,21 @@ class RomDataTracker: def __init__(self) -> None: """Define useful data tracked throughout test.""" self.stage_coords: list[dict[str, int]] = [] - self.displacements: list[dict[str, int]] = [] + self.offsets: list[dict[str, float]] = [] def record_movement( self, current_pos: dict[str, int], offset: dict[str, int] ) -> None: """Record the current position and the measured offset of the last move.""" self.stage_coords.append(current_pos) - self.displacements.append(offset) + self.offsets.append(offset) @property def final_position(self) -> dict[str, int]: """The last stage coordinate recorded.""" return self.stage_coords[-1].copy() - def _predict_z_displacament( + def predict_z_displacament( self, movement: dict[str, int], stage_position: dict[str, int], @@ -356,7 +356,7 @@ class RangeofMotionThing(lt.Thing): big_movement = self._movement_in_img_coords( fov_perc=BIG_STEP, axis=axis, direction=direction ) - z_disp = self._rom_data._predict_z_displacament( + z_disp = self._rom_data.predict_z_displacament( movement=big_movement[axis], stage_position=rom_deps.stage.position, csm_matrix=rom_deps.csm.image_to_stage_displacement_matrix, @@ -541,6 +541,10 @@ def _axis_from_movement_dict(movement: dict[str, float], return_other: bool = Fa axis and the second being the other axis. Default is False :return: The movement axis (and optionally the other axis) as strings. """ + # Not exclusive or, this errors if both axes are zero or neither axes are zero. + if not ((movement["x"] == 0) ^ (movement["y"] == 0)): + raise ValueError("Either x or y movement should be zero, but not both.") + if return_other: return ("y", "x") if movement["x"] == 0 else ("x", "y") return "y" if movement["x"] == 0 else "x" diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index b7e9abc9..3f5d375b 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -1,24 +1,10 @@ """File contains unit tests for stage_measure.""" +import logging import pytest -from openflexure_microscope_server.things import stage_measure as sm -from .mock_things.mock_csm import MockCSMThing -from .mock_things.mock_stage import MockStageThing +from openflexure_microscope_server.things import stage_measure -stage_mock = MockStageThing() -csm_mock = MockCSMThing() - - -def test_generate_move_dicts(): - """Check that the dictionary generated for moves is correct.""" - mock_perc = 50 - mock_res = [820, 616] - mock_dir = 1 - mock_dict = sm._generate_move_dicts( - fov_perc=mock_perc, stream_resolution=mock_res, direction=mock_dir - ) - expected_dict = {"x": 410, "y": 308} - assert mock_dict == expected_dict +LOGGER = logging.getLogger("mock-invocation_logger") def test_predict_z(): @@ -31,38 +17,100 @@ def test_predict_z(): {"x": 2908, "y": 8, "z": 509}, {"x": 3635, "y": 10, "z": 617}, ] - mock_axis = "x" - mock_move = 2908 - mock_z_diff = sm._predict_z( - positions=mock_positions, - axis=mock_axis, - relative_move=mock_move, - stage=stage_mock, - csm=csm_mock, + rom_data = stage_measure.RomDataTracker() + + for position in mock_positions: + offset = {"x": 54.4, "y": 0} + rom_data.record_movement(position, offset) + + csm_matrix = [ + [0.03061156624485296, 1.8031242270940833], + [1.773236372778601, 0.006660431608601435], + ] + + mock_z_diff = rom_data.predict_z_displacament( + movement={"x": 2908, "y": 0}, + stage_position={"x": 3635, "y": 10, "z": 617}, + csm_matrix=csm_matrix, ) expected_z_diff = 1343.1625053206606 assert mock_z_diff == expected_z_diff +@pytest.mark.parametrize( + ("movement", "axis", "other_axis"), + [ + ({"x": 2908, "y": 0}, "x", "y"), + ({"x": -29, "y": 0}, "x", "y"), + ({"x": 0, "y": 123}, "y", "x"), + ({"x": 0, "y": -456}, "y", "x"), + ], +) +def test_axis_from_movement_dict(movement, axis, other_axis): + """Test that _axis_from_movement_dict identifies the correct axes.""" + assert stage_measure._axis_from_movement_dict(movement) == axis + + ret_axes = stage_measure._axis_from_movement_dict(movement, return_other=True) + assert ret_axes == (axis, other_axis) + + +@pytest.mark.parametrize("movement", [{"x": 0, "y": 0}, {"x": 10, "y": 10}]) +def test_error_on_axis_from_movement_dict(movement): + """Check _axis_from_movement_dict errors if both axes are zero, or both are non-zero.""" + with pytest.raises(ValueError, match="Either x or y movement should be zero"): + assert stage_measure._axis_from_movement_dict(movement) + + def test_parasitic_detect(): """Check that the parasitic error is raised correctly.""" - mock_delta = 100 - mock_max = 50 - with pytest.raises(sm.ParasiticMotionError): - sm._parasitic_detect(delta=mock_delta, max_allowed_delta=mock_max) + with pytest.raises(stage_measure.ParasiticMotionError): + stage_measure._detect_parasitic_motion( + movement={"x": 2908, "y": 0}, offset={"x": 2908, "y": 300} + ) -def test_collate_data(): - """Check that the data is being collected and calculated correctly.""" - mock_data = { - "positive x": {"final_position": {"x": 100}}, - "negative x": {"final_position": {"x": 50}}, - "positive y": {"final_position": {"y": 100}}, - "negative y": {"final_position": {"y": 50}}, - } +@pytest.fixture +def rom_thing() -> stage_measure.RangeofMotionThing: + """Return a RangeofMotionThing.""" + return stage_measure.RangeofMotionThing() - mock_step_range = [50, 50] - mock_time = 123 - mock_dict = sm._collate_data(data=mock_data, time=mock_time, csm=MockCSMThing) - assert mock_dict["Step Range"] == mock_step_range +@pytest.fixture +def mock_rom_deps(mocker) -> stage_measure.RomDeps: + """Return a RomDeps object full of mocks, except the logger which is LOGGER.""" + return stage_measure.RomDeps( + autofocus=mocker.Mock(), + stage=mocker.Mock(), + cam=mocker.Mock(), + csm=mocker.Mock(), + logger=LOGGER, + ) + + +def test_offset_from(rom_thing, mock_rom_deps, mocker): + """Check the calls and returns for RangeofMotionThing._offset_from.""" + # Set up mock for the FFT displacement + disp_between_route = ( + "openflexure_microscope_server.things.stage_measure." + "fft_image_tracking.displacement_between_images" + ) + mock_disp_between = mocker.patch( + disp_between_route, + side_effect=([123, 456],), + ) + + # Run it + offset = rom_thing._offset_from(before_img="MOCK_IMAGE", rom_deps=mock_rom_deps) + + # Check the offset is a dictionary with the correct values for the axes + assert offset["x"] == 456 + assert offset["y"] == 123 + # Check 1 image was taken + assert mock_rom_deps.cam.grab_as_array.call_count == 1 + mock_after_image = mock_rom_deps.cam.grab_as_array.return_value + # Check the FFT displacement was called once + assert mock_disp_between.call_count == 1 + displacement_kwargs = mock_disp_between.call_args.kwargs + # Check the image inputs + assert displacement_kwargs["image_0"] == "MOCK_IMAGE" + assert displacement_kwargs["image_1"] == mock_after_image From 664c7de5f83b2a780e35c565e87d8f5563d536c4 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 17 Oct 2025 00:01:03 +0100 Subject: [PATCH 56/70] Add more ROM tests --- .../things/stage_measure.py | 18 +-- tests/test_stage_measure.py | 105 ++++++++++++++++++ 2 files changed, 115 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index dee255b5..518f64a8 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -380,9 +380,8 @@ class RangeofMotionThing(lt.Thing): Delta is updated and tracked after each move. - :param stream_resolution: The resolution of the stream from the camera. - :param direction: The direction the stage moves. :param axis: The axis which is being measured. This must be 'x' or 'y'. + :param direction: The direction the stage moves. :param rom_deps: All dependencies that were passed to the calling Action """ movement = self._movement_in_img_coords( @@ -428,16 +427,19 @@ class RangeofMotionThing(lt.Thing): this method will move in the opposite direction. :param rom_deps: All dependencies that were passed to the calling Action """ + + def _reverse_move_dict(displacement: float) -> dict[str, float]: + move = {"x": 0, "y": 0} + move[axis] = displacement * direction * -1 + return move + # Array of increasing pixel sizes (powers of 2 from 1 to 512) - displacements = [2**i for i in range(10)] + movements = [_reverse_move_dict(2**i) for i in range(10)] motion_minimum = 20 # minimum number of pixels for motion to be detected - movement = {"x": 0, "y": 0} - - for displacement in displacements: + for movement in movements: # Increment movement - movement[axis] = displacement * direction * -1 rom_deps.logger.info(f"Testing with step size {movement[axis]}") offset = self._move_and_measure( @@ -463,7 +465,7 @@ class RangeofMotionThing(lt.Thing): :param movement: A dictionary containing the distance to move in image coords. :param rom_deps: All dependencies that were passed to the calling Action - :param perform_autofocus: Set to False to disable atutofocus after move. + :param perform_autofocus: Set to False to disable autofocus after move. Default is True :param max_autofocus_repeats: The number of times to repeat the focus if the detected (on-axis) offset is below ``abs_min_offset``. This will only work diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index 3f5d375b..796748ac 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -114,3 +114,108 @@ def test_offset_from(rom_thing, mock_rom_deps, mocker): # Check the image inputs assert displacement_kwargs["image_0"] == "MOCK_IMAGE" assert displacement_kwargs["image_1"] == mock_after_image + + +@pytest.mark.parametrize("perform_autofocus", [True, False]) +def test_move_and_measure(perform_autofocus, rom_thing, mock_rom_deps, mocker): + """Test _move_and_measure with and without initial autofocus checking call counts. + + This doesn't test with the repeate autofocus if motion isn't detected + """ + mock_offset_value = {"x": 100, "y": 3} + mocker.patch.object(rom_thing, "_offset_from", side_effect=(mock_offset_value,)) + movement = {"x": 100, "y": 0} + offset = rom_thing._move_and_measure( + movement=movement, rom_deps=mock_rom_deps, perform_autofocus=perform_autofocus + ) + + # Check exactly 1 move + assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1 + # The kwargs of the call should movement dict + assert mock_rom_deps.csm.move_in_image_coordinates.call_args.kwargs == movement + # Check autofocus call count + expected_af_count = 1 if perform_autofocus else 0 + assert mock_rom_deps.autofocus.looping_autofocus.call_count == expected_af_count + # And check final return + assert offset == mock_offset_value + + +@pytest.mark.parametrize( + ("x_offsets", "n_offset_measures", "expected_return"), + [ + ([5.1, 0.2], 1, {"x": 5.1, "y": 0}), + ([-5.1, 0.2], 1, {"x": -5.1, "y": 0}), + ([0.1, 5.2, 0.3, 0.4, 0.5], 2, {"x": 5.2, "y": 0}), + ([0.1, 0.2, 5.3, 0.4, 0.5], 3, {"x": 5.3, "y": 0}), + ([0.1, 0.2, 0.3, 5.4, 0.5], 4, {"x": 5.4, "y": 0}), + # n_offset_measures shouldn't go higher than 4 as max autofocus repeats is 3 + ([0.1, 0.2, 0.3, 0.4, 5.5], 4, {"x": 0.4, "y": 0}), + ], +) +def test_move_and_measure_with_refocus( + x_offsets, n_offset_measures, expected_return, rom_thing, mock_rom_deps, mocker +): + """Test _move_and_measure with final refocus if offset is too small.""" + return_dicts = tuple({"x": x, "y": 0} for x in x_offsets) + offset_from_mock = mocker.patch.object( + rom_thing, "_offset_from", side_effect=return_dicts + ) + movement = {"x": 10, "y": 0} + offset = rom_thing._move_and_measure( + movement=movement, + rom_deps=mock_rom_deps, + perform_autofocus=False, + max_autofocus_repeats=3, + abs_min_offset=5, + ) + # Check exactly 1 move + assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1 + # Check expected _offset_from calls + assert offset_from_mock.call_count == n_offset_measures + # The kwargs of the call should movement dict + assert mock_rom_deps.csm.move_in_image_coordinates.call_args.kwargs == movement + # Check autofocus call count is 1 less than number of offset measures as no autofocus + # is performed before the first one + expected_af_count = n_offset_measures - 1 + assert mock_rom_deps.autofocus.looping_autofocus.call_count == expected_af_count + # And check final return + assert offset == expected_return + + +def test_move_back_until_motion_detected(rom_thing, mock_rom_deps, mocker): + """Check that _move_back_until_motion_detected is making increasing negative moves. + + The moves for this method should be in opposite direction to the direction + specified as this is moving back after the stage reaches end of its movement. + """ + + def gen_offset(*_args, **_kwargs): + return {"x": 0, "y": 0} + + mock_move_n_meas = mocker.patch.object( + rom_thing, "_move_and_measure", side_effect=gen_offset + ) + + with pytest.raises(RuntimeError, match="Cannot detect motion again"): + rom_thing._move_back_until_motion_detected("y", -1, rom_deps=mock_rom_deps) + assert mock_move_n_meas.call_count == 10 + for i, call_args in enumerate(mock_move_n_meas.call_args_list): + call_args.kwargs["movement"] = {"x": 0, "y": 2**i} + call_args.kwargs["perform_autofocus"] = False + + ## Reset mock and change the side effect + mock_move_n_meas.reset_mock() + mock_move_n_meas.side_effect = ( + {"x": 0, "y": 0}, + {"x": 0, "y": 0}, + {"x": 21, "y": 0}, + ) + + # Other axis and direction this time + rom_thing._move_back_until_motion_detected("x", 1, rom_deps=mock_rom_deps) + + # Should only be called 3 times + assert mock_move_n_meas.call_count == 3 + for i, call_args in enumerate(mock_move_n_meas.call_args_list): + call_args.kwargs["movement"] = {"x": -(2**i), "y": 0} + call_args.kwargs["perform_autofocus"] = False From 3d49cbf921708be394212ef26de68633c62de14f Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 17 Oct 2025 01:29:36 +0100 Subject: [PATCH 57/70] Adding even more ROM tests --- .../things/stage_measure.py | 22 ++++---- tests/test_stage_measure.py | 50 +++++++++++++++++++ 2 files changed, 60 insertions(+), 12 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 518f64a8..a40ccd8b 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -43,6 +43,7 @@ MEDIUM_STEP = 50 BIG_STEP = 200 PARASITIC_MOTION_TOL = 0.1 +DETECT_MOTION_TOL = 0.65 class RomDataTracker: @@ -196,7 +197,7 @@ class RangeofMotionThing(lt.Thing): } def _set_stream_resolution(self, cam: CamDep) -> None: - """Set the self._stream_reolution attribute by reading camera. + """Set the self._stream_resolution attribute by reading camera. :param cam: The camera dependency. """ @@ -288,7 +289,7 @@ class RangeofMotionThing(lt.Thing): """ if self._stream_resolution is None: raise RuntimeError( - "Stream resolution mut be set before generating movement in coords" + "Stream resolution must be set before generating movement in coords" ) img_index = 0 if axis == "x" else 1 @@ -372,13 +373,11 @@ class RangeofMotionThing(lt.Thing): ) -> bool: """Carry out 3 small moves in a given direction and axis. - Each position after the first is correlated with the previous position - to check the stage has moved as far as it should. If the correlation is - less than expected, 3 attempts are made to refocus the image to ensure that - image quality is not causing the correlation to be unsuccessful. If the correlation - is still too low then the edge is found. - - Delta is updated and tracked after each move. + An image is taken before and after each move to check has moved as far as it + should. If the offset (calculated by cross correlation) is less than expected, + 3 attempts are made to refocus the image to ensure that image quality is not + causing the offset to mistakenly be reported as too low be unsuccessful. If the + calculated offset is still too low then the edge is found. :param axis: The axis which is being measured. This must be 'x' or 'y'. :param direction: The direction the stage moves. @@ -387,7 +386,7 @@ class RangeofMotionThing(lt.Thing): movement = self._movement_in_img_coords( fov_perc=SMALL_STEP, axis=axis, direction=direction ) - abs_min_offset = abs(movement[axis] * 0.65) + abs_min_offset = abs(movement[axis] * DETECT_MOTION_TOL) for _loop in range(3): offset = self._move_and_measure( @@ -472,8 +471,7 @@ class RangeofMotionThing(lt.Thing): if ``abs_min_offset`` is also set :param abs_min_offset: The absolute minimum (on-axis) offset, under which the autofocus is repeated. - :return: All required data for the next move. This includes the updated delta - value and offset. + :return: The calculated offset from cross correlation. """ # Take image before move before_img = rom_deps.cam.grab_as_array() diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index 796748ac..fb2ffb74 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -219,3 +219,53 @@ def test_move_back_until_motion_detected(rom_thing, mock_rom_deps, mocker): for i, call_args in enumerate(mock_move_n_meas.call_args_list): call_args.kwargs["movement"] = {"x": -(2**i), "y": 0} call_args.kwargs["perform_autofocus"] = False + + +@pytest.mark.parametrize( + ("good_moves", "expected_to_detect_motion", "offset_calls"), + [ + ([0, 1, 2], True, 3), # First 3 pass all good + ([1, 2, 3], True, 4), # First is bad, will refocus, still complete + ([2, 3, 4], True, 5), # First 2 are bad, will refocus twice, still complete + ([3, 4, 5], True, 6), # First 3 are bad, will refocus 3 times, still complete + ([4, 5, 6], False, 4), # First 4 are bad, fails + # Check second and 3rd measurement can fail after first fails 3 times + ([3, 5, 7], True, 8), + ([3, 6, 9], True, 10), + ([3, 7, 11], True, 12), + # But they can't fail 4 times + ([3, 8, 9], False, 8), + ([3, 7, 12], False, 12), + ], +) +def test_stage_still_moves( + good_moves, + expected_to_detect_motion, + offset_calls, + rom_thing, + mock_rom_deps, + mocker, +): + """Test _stage_still_moves correctly detects stage movement.""" + rom_thing._stream_resolution = [800, 600] + min_offset = 800 * stage_measure.SMALL_STEP / 100 * stage_measure.DETECT_MOTION_TOL + + def gen_offsets(*_args, **_kwargs): + """Generate offset dictionaries with small moves unless count matches ``good_moves``.""" + i = 0 + while True: + x = min_offset * 1.2 if i in good_moves else 0.1 + yield {"x": x, "y": 0} + i += 1 + + mock_offset_from = mocker.patch.object( + rom_thing, "_offset_from", side_effect=gen_offsets() + ) + + still_moves = rom_thing._stage_still_moves( + axis="x", + direction=1, + rom_deps=mock_rom_deps, + ) + assert still_moves is expected_to_detect_motion + assert mock_offset_from.call_count == offset_calls From 95f5bbb16c1ab77f288bc50e8e7675ee2e11e86b Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 17 Oct 2025 12:06:34 +0100 Subject: [PATCH 58/70] Ensure ROM z-predict returns an integer and add more tests. --- .../things/stage_measure.py | 10 +- tests/test_stage_measure.py | 100 ++++++++++++++---- 2 files changed, 85 insertions(+), 25 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index a40ccd8b..79731503 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -72,12 +72,12 @@ class RomDataTracker: """The last stage coordinate recorded.""" return self.stage_coords[-1].copy() - def predict_z_displacament( + def predict_z_displacement( self, movement: dict[str, int], stage_position: dict[str, int], csm_matrix: np.ndarray, - ) -> float: + ) -> int: """Predict the z-displacement needed for a given movement. :param movement: The movement to be performed in image coordinates. @@ -99,7 +99,7 @@ class RomDataTracker: stage_position[axis] + (movement[axis] / pixel_step[axis]), *fit_params ) - return z_dest - stage_position["z"] + return int(z_dest - stage_position["z"]) @dataclass @@ -357,8 +357,8 @@ class RangeofMotionThing(lt.Thing): big_movement = self._movement_in_img_coords( fov_perc=BIG_STEP, axis=axis, direction=direction ) - z_disp = self._rom_data.predict_z_displacament( - movement=big_movement[axis], + z_disp = self._rom_data.predict_z_displacement( + movement=big_movement, stage_position=rom_deps.stage.position, csm_matrix=rom_deps.csm.image_to_stage_displacement_matrix, ) diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index fb2ffb74..ba92c37e 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -1,5 +1,6 @@ """File contains unit tests for stage_measure.""" +from copy import copy import logging import pytest from openflexure_microscope_server.things import stage_measure @@ -7,8 +8,18 @@ from openflexure_microscope_server.things import stage_measure LOGGER = logging.getLogger("mock-invocation_logger") -def test_predict_z(): - """Check that the prediction for the next z position is correct.""" +@pytest.fixture +def csm_matrix(): + """Return an example CSM matrix.""" + return [ + [0.03061156624485296, 1.8031242270940833], + [1.773236372778601, 0.006660431608601435], + ] + + +@pytest.fixture +def example_rom_data(): + """Return some example data in a RomDataTracker.""" mock_positions = [ {"x": 0, "y": 0, "z": 42}, {"x": 727, "y": 2, "z": 154}, @@ -19,21 +30,21 @@ def test_predict_z(): ] rom_data = stage_measure.RomDataTracker() + # loop through mock positions recording them with an offset. for position in mock_positions: offset = {"x": 54.4, "y": 0} rom_data.record_movement(position, offset) + return rom_data - csm_matrix = [ - [0.03061156624485296, 1.8031242270940833], - [1.773236372778601, 0.006660431608601435], - ] - mock_z_diff = rom_data.predict_z_displacament( +def test_predict_z(csm_matrix, example_rom_data): + """Check that the prediction for the next z position is correct.""" + mock_z_diff = example_rom_data.predict_z_displacement( movement={"x": 2908, "y": 0}, stage_position={"x": 3635, "y": 10, "z": 617}, csm_matrix=csm_matrix, ) - expected_z_diff = 1343.1625053206606 + expected_z_diff = 1343 assert mock_z_diff == expected_z_diff @@ -61,28 +72,56 @@ def test_error_on_axis_from_movement_dict(movement): assert stage_measure._axis_from_movement_dict(movement) -def test_parasitic_detect(): - """Check that the parasitic error is raised correctly.""" - with pytest.raises(stage_measure.ParasiticMotionError): - stage_measure._detect_parasitic_motion( - movement={"x": 2908, "y": 0}, offset={"x": 2908, "y": 300} - ) +@pytest.mark.parametrize( + ("par_fraction", "should_error"), + [ + (-0.20, True), + (-0.11, True), + (-0.09, False), + (-0.05, False), + (0.00, False), + (0.05, False), + (0.09, False), + (0.11, True), + (0.20, True), + ], +) +def test_parasitic_detect(par_fraction, should_error): + """Check error is raised if the fraction of parastitic motion is too high.""" + movement = {"x": 2908, "y": 0} + + offset = copy(movement) + offset["y"] = movement["x"] * par_fraction + if should_error: + with pytest.raises(stage_measure.ParasiticMotionError): + stage_measure._detect_parasitic_motion(movement=movement, offset=offset) + else: + # Nothing to check here as the only job of _detect_parasitic_motion is to + # error if there is too much motion + stage_measure._detect_parasitic_motion(movement=movement, offset=offset) @pytest.fixture -def rom_thing() -> stage_measure.RangeofMotionThing: - """Return a RangeofMotionThing.""" - return stage_measure.RangeofMotionThing() +def rom_thing(example_rom_data) -> stage_measure.RangeofMotionThing: + """Return a RangeofMotionThing already populated with some example rom_data.""" + rom_thing = stage_measure.RangeofMotionThing() + rom_thing._stream_resolution = [800, 600] + rom_thing._rom_data = example_rom_data + return rom_thing @pytest.fixture -def mock_rom_deps(mocker) -> stage_measure.RomDeps: +def mock_rom_deps(csm_matrix, mocker) -> stage_measure.RomDeps: """Return a RomDeps object full of mocks, except the logger which is LOGGER.""" + mock_csm = mocker.Mock() + # Set up mock csm to return a CSM matrix + mock_csm.image_to_stage_displacement_matrix = csm_matrix + return stage_measure.RomDeps( autofocus=mocker.Mock(), stage=mocker.Mock(), cam=mocker.Mock(), - csm=mocker.Mock(), + csm=mock_csm, logger=LOGGER, ) @@ -247,7 +286,6 @@ def test_stage_still_moves( mocker, ): """Test _stage_still_moves correctly detects stage movement.""" - rom_thing._stream_resolution = [800, 600] min_offset = 800 * stage_measure.SMALL_STEP / 100 * stage_measure.DETECT_MOTION_TOL def gen_offsets(*_args, **_kwargs): @@ -269,3 +307,25 @@ def test_stage_still_moves( ) assert still_moves is expected_to_detect_motion assert mock_offset_from.call_count == offset_calls + + +def test_big_z_corrected_movement(rom_thing, mock_rom_deps): + """Check big z corrected move moves in x/y and z the expected distances.""" + mock_rom_deps.stage.position = {"x": 5000, "y": 30, "z": 500} + + rom_thing._big_z_corrected_movement("x", direction=1, rom_deps=mock_rom_deps) + + expected_movement = {"x": 800 * stage_measure.BIG_STEP / 100, "y": 0} + + # Check there is one z move in steps + assert mock_rom_deps.stage.move_relative.call_count == 1 + move_kwargs = mock_rom_deps.stage.move_relative.call_args.kwargs + assert "x" not in move_kwargs + assert "y" not in move_kwargs + assert "z" in move_kwargs + move_kwargs["z"] = 1162 + + # And one move in image coordinates + assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1 + lat_mov_kwargs = mock_rom_deps.csm.move_in_image_coordinates.call_args.kwargs + assert lat_mov_kwargs == expected_movement From 30217a4c389242281742642eaeb939e7735b6a69 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 17 Oct 2025 15:18:42 +0100 Subject: [PATCH 59/70] Add tests for initial moves at start of ROM test --- .../things/stage_measure.py | 4 +- tests/test_stage_measure.py | 62 ++++++++++++++++++- 2 files changed, 62 insertions(+), 4 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 79731503..24b39bc5 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -233,8 +233,8 @@ class RangeofMotionThing(lt.Thing): rom_deps.logger.info("Moving the stage in 5 medium sized steps.") self._initial_moves_for_z_prediction( - direction=direction, axis=axis, + direction=direction, rom_deps=rom_deps, ) @@ -318,8 +318,8 @@ class RangeofMotionThing(lt.Thing): def _initial_moves_for_z_prediction( self, - direction: Literal[1, -1], axis: Literal["x", "y"], + direction: Literal[1, -1], rom_deps: RomDeps, ) -> None: """Perform 5 medium sized moves with autofocus for z feed-forward. diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index ba92c37e..667bcaf8 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -8,6 +8,31 @@ from openflexure_microscope_server.things import stage_measure LOGGER = logging.getLogger("mock-invocation_logger") +# Useful generators +def increasing_xy_dict_generator(*_args, **_kwargs): + """Generate x-y dictionaries of incremening sizes. + + These don't simulate expected effects, but allow checking sequential reads of a + function/property were used. + """ + i = 0 + while True: + yield {"x": i, "y": i} + i += 1 + + +def increasing_xyz_dict_generator(*_args, **_kwargs): + """Generate x-y-z dictionaries of incremening sizes. + + These don't simulate expected effects, but allow checking sequential reads of a + function/property were used. + """ + i = 0 + while True: + yield {"x": i, "y": i, "z": i} + i += 1 + + @pytest.fixture def csm_matrix(): """Return an example CSM matrix.""" @@ -135,7 +160,7 @@ def test_offset_from(rom_thing, mock_rom_deps, mocker): ) mock_disp_between = mocker.patch( disp_between_route, - side_effect=([123, 456],), + return_value=[123, 456], ) # Run it @@ -162,7 +187,7 @@ def test_move_and_measure(perform_autofocus, rom_thing, mock_rom_deps, mocker): This doesn't test with the repeate autofocus if motion isn't detected """ mock_offset_value = {"x": 100, "y": 3} - mocker.patch.object(rom_thing, "_offset_from", side_effect=(mock_offset_value,)) + mocker.patch.object(rom_thing, "_offset_from", return_value=mock_offset_value) movement = {"x": 100, "y": 0} offset = rom_thing._move_and_measure( movement=movement, rom_deps=mock_rom_deps, perform_autofocus=perform_autofocus @@ -329,3 +354,36 @@ def test_big_z_corrected_movement(rom_thing, mock_rom_deps): assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1 lat_mov_kwargs = mock_rom_deps.csm.move_in_image_coordinates.call_args.kwargs assert lat_mov_kwargs == expected_movement + + +def test_initial_moves_for_z_prediction(rom_thing, mock_rom_deps, mocker): + """Check the initial moves are of the correct size and are recorded.""" + # Mock the _offset_from and stage.position to return generated dictionaries that + # increment each time they are called. (All values 0 the first time, all values 1 + # the second time ...) + mocker.patch.object( + rom_thing, "_offset_from", side_effect=increasing_xy_dict_generator() + ) + type(mock_rom_deps.stage).position = mocker.PropertyMock( + side_effect=increasing_xyz_dict_generator() + ) + + expected_movement = {"x": -800 * stage_measure.MEDIUM_STEP / 100, "y": 0} + + # Remove the mock RomData before starting + rom_thing._rom_data = stage_measure.RomDataTracker() + + # Run it! + rom_thing._initial_moves_for_z_prediction("x", direction=-1, rom_deps=mock_rom_deps) + + # Check that _rom_data now contains the 5 mocked returns in order. + assert rom_thing._rom_data.offsets == [{"x": i, "y": i} for i in range(5)] + assert rom_thing._rom_data.stage_coords == [ + {"x": i, "y": i, "z": i} for i in range(5) + ] + + # Check that the csm movement function is called 5 times + assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 5 + # Each time with the expected movement + for arg_list in mock_rom_deps.csm.move_in_image_coordinates.call_args_list: + assert arg_list.kwargs == expected_movement From a935105e56402025946dab2505e9c40a2e342fbd Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 17 Oct 2025 15:26:39 +0100 Subject: [PATCH 60/70] Update docstrings in response to review. --- .../things/stage_measure.py | 25 +++++++++++-------- 1 file changed, 15 insertions(+), 10 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 24b39bc5..e4c17de7 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -1,14 +1,14 @@ """File contains all the functions used to measure the range of motion. -The range of motion is measured by first taking 5 'medium' sized steps which gives enough positions -to predict future z positions. Next, one 'big' step is taken followed by 3 -'small' steps to test that the stage is still moving as expected and has not reached the edge. -Once the edge has been found and to account for the possibility that a 'big' -step was taken just before the edge was reached, the stage is moved in a sequence -of increasing pixel sizes in the opposite direction until motion is detected. This is +The range of motion is measured by first taking 5 'medium' sized steps which gives +enough positions to predict future z positions. Next, one 'big' step is taken followed +by 3 'small' steps to test that the stage is still moving as expected and has not +reached the edge. Once the edge has been found and to account for the possibility that +the edge was reached during a "big" step, the stage is moved in a sequence of steps +(incrementing in size) in the opposite direction until motion is detected. This is position is taken as the true final position. -Throughout the test, parasitic motion(motion in the axis not being measured) +Throughout the test, parasitic motion (motion in the axis not being measured) is tracked and an error is raised if it exceeds a minimum amount. Currently this is 10% of the expected motion is the measured axis. """ @@ -211,7 +211,11 @@ class RangeofMotionThing(lt.Thing): direction: Literal[1, -1], rom_deps: RomDeps, ) -> dict: - """Move in one direction until no movement is detected. + """Move in one direction until movement per step decreases significantly. + + This should move until the edge of the stage. Once the edge is reached there + will be some movement as there is no hard stop, but it will reduce + significantly. :param rom_deps: All dependencies that were passed to the calling Action :param axis: The axis which is being measured. This must be 'x' or 'y'. @@ -376,8 +380,9 @@ class RangeofMotionThing(lt.Thing): An image is taken before and after each move to check has moved as far as it should. If the offset (calculated by cross correlation) is less than expected, 3 attempts are made to refocus the image to ensure that image quality is not - causing the offset to mistakenly be reported as too low be unsuccessful. If the - calculated offset is still too low then the edge is found. + causing the offset to mistakenly be reported as a low value. If the + calculated offset is still too low then this is taken as an indication that the + edge has been found. :param axis: The axis which is being measured. This must be 'x' or 'y'. :param direction: The direction the stage moves. From b314ced4f74f82ae778786fa14b963f9481dfd1e Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 17 Oct 2025 17:31:09 +0100 Subject: [PATCH 61/70] Final Range of motion tests --- .../things/stage_measure.py | 20 +- tests/test_stage_measure.py | 190 +++++++++++++++++- 2 files changed, 191 insertions(+), 19 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index e4c17de7..f9e2c14c 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -225,7 +225,8 @@ class RangeofMotionThing(lt.Thing): """ # Start by performing an autofocus and recording starting position rom_deps.autofocus.looping_autofocus(dz=1000) - starting_position = list(rom_deps.stage.position.values()) + starting_position = rom_deps.stage.position + self._rom_data.stage_coords.append(starting_position) try: dir_str = "positive" if direction == 1 else "negative" @@ -233,8 +234,6 @@ class RangeofMotionThing(lt.Thing): f"Beginning the {axis}-axis in the {dir_str} direction" ) - self._rom_data.stage_coords.append(rom_deps.stage.position) - rom_deps.logger.info("Moving the stage in 5 medium sized steps.") self._initial_moves_for_z_prediction( axis=axis, @@ -275,12 +274,7 @@ class RangeofMotionThing(lt.Thing): self._rom_data.stage_coords[-1] = rom_deps.stage.position finally: - rom_deps.stage.move_absolute( - x=starting_position[0], - y=starting_position[1], - z=starting_position[2], - block_cancellation=True, - ) + rom_deps.stage.move_absolute(**starting_position, block_cancellation=True) def _img_percentate_to_img_coords( self, fov_perc: int, axis: Literal["x", "y"] @@ -377,10 +371,10 @@ class RangeofMotionThing(lt.Thing): ) -> bool: """Carry out 3 small moves in a given direction and axis. - An image is taken before and after each move to check has moved as far as it - should. If the offset (calculated by cross correlation) is less than expected, - 3 attempts are made to refocus the image to ensure that image quality is not - causing the offset to mistakenly be reported as a low value. If the + An image is taken before and after each move to check the stage has moved as + far as it should. If the offset (calculated by cross correlation) is less than + expected, 3 attempts are made to refocus the image to ensure that image quality + is not causing the offset to mistakenly be reported as a low value. If the calculated offset is still too low then this is taken as an indication that the edge has been found. diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index 667bcaf8..ced2962b 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -2,7 +2,15 @@ from copy import copy import logging +import dataclasses +import tempfile + +from fastapi.testclient import TestClient +import numpy as np import pytest + +import labthings_fastapi as lt + from openflexure_microscope_server.things import stage_measure LOGGER = logging.getLogger("mock-invocation_logger") @@ -36,10 +44,12 @@ def increasing_xyz_dict_generator(*_args, **_kwargs): @pytest.fixture def csm_matrix(): """Return an example CSM matrix.""" - return [ - [0.03061156624485296, 1.8031242270940833], - [1.773236372778601, 0.006660431608601435], - ] + return np.array( + [ + [0.03061156624485296, 1.8031242270940833], + [1.773236372778601, 0.006660431608601435], + ] + ) @pytest.fixture @@ -126,13 +136,24 @@ def test_parasitic_detect(par_fraction, should_error): stage_measure._detect_parasitic_motion(movement=movement, offset=offset) +def test_error_if_no_stream_res_set_when_requesting_img_coords(): + """Check a RuntimeError thrown when requesting image coordinates if resolution unset.""" + with pytest.raises(RuntimeError, match="Stream resolution must be set"): + stage_measure.RangeofMotionThing()._img_percentate_to_img_coords(20, "x") + + @pytest.fixture def rom_thing(example_rom_data) -> stage_measure.RangeofMotionThing: - """Return a RangeofMotionThing already populated with some example rom_data.""" + """Yield a RangeofMotionThing already populated with some example rom_data.""" rom_thing = stage_measure.RangeofMotionThing() rom_thing._stream_resolution = [800, 600] rom_thing._rom_data = example_rom_data - return rom_thing + + with tempfile.TemporaryDirectory() as tmpdir: + server = lt.ThingServer(settings_folder=tmpdir) + server.add_thing(rom_thing, "/rom_thing/") + with TestClient(server.app): + yield rom_thing @pytest.fixture @@ -387,3 +408,160 @@ def test_initial_moves_for_z_prediction(rom_thing, mock_rom_deps, mocker): # Each time with the expected movement for arg_list in mock_rom_deps.csm.move_in_image_coordinates.call_args_list: assert arg_list.kwargs == expected_movement + + +def test_move_until_edge_error(rom_thing, mock_rom_deps, mocker): + """Check that if there is an error while moving to the edge the stage returns to start.""" + mock_position_dict = {"x": 123, "y": 456, "z": 789} + type(mock_rom_deps.stage).position = mocker.PropertyMock( + return_value=mock_position_dict + ) + mocker.patch.object( + rom_thing, "_initial_moves_for_z_prediction", side_effect=RuntimeError("Mock") + ) + + # Remove the mock RomData before starting + rom_thing._rom_data = stage_measure.RomDataTracker() + + # Error should be raised even though it is in the Try: + with pytest.raises(RuntimeError, match="Mock"): + rom_thing._move_until_edge("y", direction=-1, rom_deps=mock_rom_deps) + # However the "finally" should have executed returning to the starting position + assert mock_rom_deps.stage.move_absolute.call_count == 1 + abs_move_kwargs = mock_rom_deps.stage.move_absolute.call_args.kwargs + expected_abs_move_kwargs = dict(**mock_position_dict, block_cancellation=True) + assert abs_move_kwargs == expected_abs_move_kwargs + + +def test_move_until_edge(rom_thing, mock_rom_deps, mocker): + """Check move until edge runs the correct movement sequence.""" + # Remove the mock RomData before starting + rom_thing._rom_data = stage_measure.RomDataTracker() + + mock_rom_deps.stage.position = {"x": "mock", "y": "starting", "z": "pos"} + + def add_fake_initial_positions(*_args, **_kwargs): + """Rather than run initial moves just add some fake data.""" + for i in range(5): + rom_thing._rom_data.record_movement(f"mock-init-pos{i + 1}", "mock-offset") + + def update_stage_pos_on_big_move(*_args, **_kwargs): + """With big moves update the stage position.""" + big_move_count = 1 + while True: + mock_rom_deps.stage.position = f"mocked-big-move-pos{big_move_count}" + yield + big_move_count += 1 + + def set_final_pos(*_args, **_kwargs): + """When move_back_until_motion_detected is run set a final stage position.""" + mock_rom_deps.stage.position = "mock-final-pos" + + # Mock the main movement functions + mock_init_moves = mocker.patch.object( + rom_thing, + "_initial_moves_for_z_prediction", + side_effect=add_fake_initial_positions, + ) + mock_big_moves = mocker.patch.object( + rom_thing, + "_big_z_corrected_movement", + side_effect=update_stage_pos_on_big_move(), + ) + mock_move_check = mocker.patch.object( + rom_thing, "_stage_still_moves", side_effect=[True] * 9 + [False] + ) + mock_move_back = mocker.patch.object( + rom_thing, "_move_back_until_motion_detected", side_effect=set_final_pos + ) + + # Remove the mock RomData before starting + rom_thing._rom_data = stage_measure.RomDataTracker() + + # Run function + rom_thing._move_until_edge("y", direction=-1, rom_deps=mock_rom_deps) + + # Check the call counts are as expected + # One call of initial moves + assert mock_init_moves.call_count == 1 + # Big moves and the check the stage is moving are each called 10 times as the mock + # for _stage_still_moves replies False on the 10th call. + assert mock_big_moves.call_count == 10 + assert mock_move_check.call_count == 10 + # And one call of _move_back_until_motion_detected + assert mock_move_back.call_count == 1 + + assert rom_thing._rom_data.stage_coords == [ + {"x": "mock", "y": "starting", "z": "pos"}, + "mock-init-pos1", + "mock-init-pos2", + "mock-init-pos3", + "mock-init-pos4", + "mock-init-pos5", + "mocked-big-move-pos1", + "mocked-big-move-pos2", + "mocked-big-move-pos3", + "mocked-big-move-pos4", + "mocked-big-move-pos5", + "mocked-big-move-pos6", + "mocked-big-move-pos7", + "mocked-big-move-pos8", + "mocked-big-move-pos9", + "mock-final-pos", # This overwrites the 10th big move position recording. + ] + + +def test_perform_rom_test_locked(rom_thing, mock_rom_deps): + """Check the error if the Rom test is locked.""" + # Not an RLock so no need to thread. + rom_thing._lock.acquire() + err_msg = "Trying to run ROM test when a test is already running." + with pytest.raises(RuntimeError, match=err_msg): + rom_thing.perform_rom_test(**dataclasses.asdict(mock_rom_deps)) + + +def test_perform_rom_test_(rom_thing, mock_rom_deps, mocker): + """Check that perform Rom Test runs the expected high level algorithm.""" + + def check_and_modify_rom_data(axis: str, direction: int, **_kwargs): + """Check the rom_data is empty each time, and add a final position.""" + # check rom_data was cleared at the start of this run + assert len(rom_thing._rom_data.stage_coords) == 0 + dist = 1111 if axis == "x" else 2222 + final_pos = {"x": 0, "y": 0} + final_pos[axis] = dist * direction + rom_thing._rom_data.stage_coords.append(final_pos) + + mock_move_until_edge = mocker.patch.object( + rom_thing, "_move_until_edge", side_effect=check_and_modify_rom_data + ) + + mocker.patch.object( + mock_rom_deps.cam, "grab_as_array", return_value=np.zeros([123, 456, 3]) + ) + final_dict = rom_thing.perform_rom_test(**dataclasses.asdict(mock_rom_deps)) + + # This should take less than 1 sec + assert final_dict["Time"] <= 1 + # CSM should be 2x2 list + assert isinstance(final_dict["CSM Matrix"], list) + assert len(final_dict["CSM Matrix"]) == 2 + assert len(final_dict["CSM Matrix"][0]) == 2 + assert len(final_dict["CSM Matrix"][1]) == 2 + # And step range should be [2222, 4444] + assert final_dict["Step Range"] == [2222, 4444] + + # Check that the axes were called in the expected order. + assert mock_move_until_edge.call_count == 4 + + assert mock_move_until_edge.call_args_list[0].kwargs["axis"] == "x" + assert mock_move_until_edge.call_args_list[0].kwargs["direction"] == 1 + + assert mock_move_until_edge.call_args_list[1].kwargs["axis"] == "x" + assert mock_move_until_edge.call_args_list[1].kwargs["direction"] == -1 + + assert mock_move_until_edge.call_args_list[2].kwargs["axis"] == "y" + assert mock_move_until_edge.call_args_list[2].kwargs["direction"] == 1 + + assert mock_move_until_edge.call_args_list[3].kwargs["axis"] == "y" + assert mock_move_until_edge.call_args_list[3].kwargs["direction"] == -1 From 635375a0123149681e8d94f7f9c17e8698684280 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 20 Oct 2025 16:40:41 +0100 Subject: [PATCH 62/70] Fix assumption that CSM was a numpy array not a list --- .../things/stage_measure.py | 7 +++---- tests/test_stage_measure.py | 10 ++++------ 2 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index f9e2c14c..9dcce848 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -61,7 +61,7 @@ class RomDataTracker: self.offsets: list[dict[str, float]] = [] def record_movement( - self, current_pos: dict[str, int], offset: dict[str, int] + self, current_pos: dict[str, int], offset: dict[str, float] ) -> None: """Record the current position and the measured offset of the last move.""" self.stage_coords.append(current_pos) @@ -76,7 +76,7 @@ class RomDataTracker: self, movement: dict[str, int], stage_position: dict[str, int], - csm_matrix: np.ndarray, + csm_matrix: list[list[int]], ) -> int: """Predict the z-displacement needed for a given movement. @@ -98,7 +98,6 @@ class RomDataTracker: z_dest = quadratic( stage_position[axis] + (movement[axis] / pixel_step[axis]), *fit_params ) - return int(z_dest - stage_position["z"]) @@ -192,7 +191,7 @@ class RangeofMotionThing(lt.Thing): return { "Time": total_time, - "CSM Matrix": csm.image_to_stage_displacement_matrix.tolist(), + "CSM Matrix": csm.image_to_stage_displacement_matrix, "Step Range": step_range, } diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index ced2962b..85460972 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -44,12 +44,10 @@ def increasing_xyz_dict_generator(*_args, **_kwargs): @pytest.fixture def csm_matrix(): """Return an example CSM matrix.""" - return np.array( - [ - [0.03061156624485296, 1.8031242270940833], - [1.773236372778601, 0.006660431608601435], - ] - ) + return [ + [0.03061156624485296, 1.8031242270940833], + [1.773236372778601, 0.006660431608601435], + ] @pytest.fixture From 72c35c302cb440c38b1ea3186deb2a00a9e834d4 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 20 Oct 2025 21:44:47 +0100 Subject: [PATCH 63/70] Adjust CSM Thing to allow calculation of stage coords from img coords and use this in stage measure Using this stops the z-axis being flipped when the off diagonal elements of the CSM are different signs. --- .../things/camera_stage_mapping.py | 10 +++++- .../things/stage_measure.py | 36 ++++++++----------- tests/test_stage_measure.py | 17 ++++++--- 3 files changed, 36 insertions(+), 27 deletions(-) diff --git a/src/openflexure_microscope_server/things/camera_stage_mapping.py b/src/openflexure_microscope_server/things/camera_stage_mapping.py index a05a3385..57f3e742 100644 --- a/src/openflexure_microscope_server/things/camera_stage_mapping.py +++ b/src/openflexure_microscope_server/things/camera_stage_mapping.py @@ -268,10 +268,18 @@ class CameraStageMapper(lt.Thing): an image usually helps resolve any ambiguity. """ self.assert_calibrated() + stage.move_relative(**self.convert_image_to_stage_coordinates(x=x, y=y)) + + @lt.thing_action + def convert_image_to_stage_coordinates( + self, x: float, y: float + ) -> Mapping[str, int]: + """Convert image coordinates to stage coordinates.""" + self.assert_calibrated() relative_move: np.ndarray = np.dot( np.array([y, x]), np.array(self.image_to_stage_displacement_matrix) ) - stage.move_relative(x=relative_move[0], y=relative_move[1]) + return {"x": int(relative_move[0]), "y": int(relative_move[1])} @lt.thing_property def thing_state(self) -> Mapping[str, Any]: diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 9dcce848..06e3d030 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -74,30 +74,23 @@ class RomDataTracker: def predict_z_displacement( self, - movement: dict[str, int], + axis: Literal["x", "y"], + stage_movement: dict[str, int], stage_position: dict[str, int], - csm_matrix: list[list[int]], ) -> int: """Predict the z-displacement needed for a given movement. - :param movement: The movement to be performed in image coordinates. + :param axis: The axis which is being measured. This must be 'x' or 'y'. + :param stage_movement: The movement to be performed in stage coordinates. :param stage_position: The current stage position in stage coordinates. - :param csm_matrix: The camera stage mapping matrix. :returns: The predicted relative z displacement needed to stay in focus. """ - pixel_step = { - "x": 1 / csm_matrix[0][1], - "y": 1 / csm_matrix[1][0], - } - - axis = _axis_from_movement_dict(movement) - - lateral_positions = [i[axis] for i in self.stage_coords] # x or y positions + # x or y positions + lateral_positions = [i[axis] for i in self.stage_coords] z_positions = [i["z"] for i in self.stage_coords] fit_params, *_others = curve_fit(quadratic, lateral_positions, z_positions) - z_dest = quadratic( - stage_position[axis] + (movement[axis] / pixel_step[axis]), *fit_params - ) + z_dest = quadratic(stage_position[axis] + stage_movement[axis], *fit_params) + return int(z_dest - stage_position["z"]) @@ -248,7 +241,6 @@ class RangeofMotionThing(lt.Thing): self._big_z_corrected_movement( axis=axis, direction=direction, rom_deps=rom_deps ) - # Autofocus and record position rom_deps.autofocus.looping_autofocus(dz=800) self._rom_data.stage_coords.append(rom_deps.stage.position) @@ -334,7 +326,6 @@ class RangeofMotionThing(lt.Thing): ) for _loop in range(5): offset = self._move_and_measure(movement=movement, rom_deps=rom_deps) - rom_deps.logger.info(f"Offset measured as {offset[axis]}") self._rom_data.record_movement(rom_deps.stage.position, offset) @@ -351,16 +342,19 @@ class RangeofMotionThing(lt.Thing): :param direction: The direction to move in. :param rom_deps: All dependencies that were passed to the calling Action """ - big_movement = self._movement_in_img_coords( + movement = self._movement_in_img_coords( fov_perc=BIG_STEP, axis=axis, direction=direction ) + # Convert to stage coordinates + stage_movemenet = rom_deps.csm.convert_image_to_stage_coordinates(**movement) + z_disp = self._rom_data.predict_z_displacement( - movement=big_movement, + axis=axis, + stage_movement=stage_movemenet, stage_position=rom_deps.stage.position, - csm_matrix=rom_deps.csm.image_to_stage_displacement_matrix, ) rom_deps.stage.move_relative(z=z_disp) - rom_deps.csm.move_in_image_coordinates(**big_movement) + rom_deps.csm.move_in_image_coordinates(**movement) def _stage_still_moves( self, diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index 85460972..b2d2e5e8 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -45,7 +45,7 @@ def increasing_xyz_dict_generator(*_args, **_kwargs): def csm_matrix(): """Return an example CSM matrix.""" return [ - [0.03061156624485296, 1.8031242270940833], + [0.03061156624485296, -1.8031242270940833], [1.773236372778601, 0.006660431608601435], ] @@ -70,12 +70,12 @@ def example_rom_data(): return rom_data -def test_predict_z(csm_matrix, example_rom_data): +def test_predict_z(example_rom_data): """Check that the prediction for the next z position is correct.""" mock_z_diff = example_rom_data.predict_z_displacement( - movement={"x": 2908, "y": 0}, + axis="x", + stage_movement={"x": 5243, "y": 0}, stage_position={"x": 3635, "y": 10, "z": 617}, - csm_matrix=csm_matrix, ) expected_z_diff = 1343 assert mock_z_diff == expected_z_diff @@ -157,9 +157,16 @@ def rom_thing(example_rom_data) -> stage_measure.RangeofMotionThing: @pytest.fixture def mock_rom_deps(csm_matrix, mocker) -> stage_measure.RomDeps: """Return a RomDeps object full of mocks, except the logger which is LOGGER.""" + + def apply_csm(x: float, y: float) -> dict[str, int]: + """Convert image coordinates to stage coordinates.""" + vec = np.dot(np.array([y, x]), np.array(csm_matrix)) + return {"x": int(vec[0]), "y": int(vec[1])} + mock_csm = mocker.Mock() # Set up mock csm to return a CSM matrix mock_csm.image_to_stage_displacement_matrix = csm_matrix + mock_csm.convert_image_to_stage_coordinates.side_effect = apply_csm return stage_measure.RomDeps( autofocus=mocker.Mock(), @@ -367,7 +374,7 @@ def test_big_z_corrected_movement(rom_thing, mock_rom_deps): assert "x" not in move_kwargs assert "y" not in move_kwargs assert "z" in move_kwargs - move_kwargs["z"] = 1162 + assert move_kwargs["z"] == 1148 # And one move in image coordinates assert mock_rom_deps.csm.move_in_image_coordinates.call_count == 1 From 7f657ae853498adc33ae0195b2ed245540cfd30d Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Mon, 20 Oct 2025 23:02:08 +0100 Subject: [PATCH 64/70] No error on parasitic motion after small step checking for stage movement, use background detect --- .../things/stage_measure.py | 35 ++++++++++++------- tests/test_stage_measure.py | 23 ++++++------ 2 files changed, 34 insertions(+), 24 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 06e3d030..8180eb5d 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -329,7 +329,13 @@ class RangeofMotionThing(lt.Thing): rom_deps.logger.info(f"Offset measured as {offset[axis]}") self._rom_data.record_movement(rom_deps.stage.position, offset) - _detect_parasitic_motion(movement, offset) + if _parasitic_motion_detected(movement, offset): + raise ParasiticMotionError( + "Parasitic motion detected during initial images to calculate " + "z-curvature. This may indicate you have started at the end of the " + "range of travel, or that camera stage mapping is poorly " + "calibrated." + ) def _big_z_corrected_movement( self, axis: Literal["x", "y"], direction: Literal[1, -1], rom_deps: RomDeps @@ -386,17 +392,17 @@ class RangeofMotionThing(lt.Thing): rom_deps=rom_deps, # Don't autofocus initially perform_autofocus=False, - # But retry focus 3 times if detected motion is too small + # But retry focus 3 times if detected motion is too small or parasitic max_autofocus_repeats=3, abs_min_offset=abs_min_offset, ) + parasitic_motion = _parasitic_motion_detected(movement, offset) rom_deps.logger.info(f"Offset measured as {offset[axis]}") - self._rom_data.record_movement(rom_deps.stage.position, offset) - _detect_parasitic_motion(movement, offset) - if abs(offset[axis]) < abs_min_offset: - # If the offset is below the abs min offset, the edge has been found. + if abs(offset[axis]) < abs_min_offset or parasitic_motion: + # If the offset is below the abs min offset, or significant + # parasitic motion is detected then the edge has been found. rom_deps.logger.info("Edge has been found.") return False # If we reached here the stage is still moving fine @@ -477,7 +483,8 @@ class RangeofMotionThing(lt.Thing): if max_autofocus_repeats > 0: axis = _axis_from_movement_dict(movement) af_repeats = 0 - while abs(offset[axis]) < abs_min_offset: + parasitic_motion = _parasitic_motion_detected(movement, offset) + while abs(offset[axis]) < abs_min_offset or parasitic_motion: af_repeats += 1 rom_deps.logger.info( f"Motion not detected. Refocusing to check. Attempt {af_repeats}/3." @@ -485,6 +492,7 @@ class RangeofMotionThing(lt.Thing): rom_deps.autofocus.looping_autofocus(dz=800) # Re-take image and calculate offset offset = self._offset_from(before_img, rom_deps=rom_deps) + parasitic_motion = _parasitic_motion_detected(movement, offset) if af_repeats >= max_autofocus_repeats: # break if the maximum number of tries are exceeded. break @@ -500,6 +508,11 @@ class RangeofMotionThing(lt.Thing): :param rom_deps: All dependencies that were passed to the calling Action :return: The calculated offset as a dictionary in pixels """ + is_sample, _bg_message = rom_deps.cam.image_is_sample() + if not is_sample: + raise RuntimeError( + "No sample detected. Sample must cover the whole range of motion." + ) after_img = rom_deps.cam.grab_as_array() offset = fft_image_tracking.displacement_between_images( image_0=before_img, @@ -542,7 +555,7 @@ def _axis_from_movement_dict(movement: dict[str, float], return_other: bool = Fa return "y" if movement["x"] == 0 else "x" -def _detect_parasitic_motion( +def _parasitic_motion_detected( movement: dict[str, float], offset: dict[str, float] ) -> None: """Compare a desired movement to measured offset and error if parasitic motion is too high. @@ -553,8 +566,4 @@ def _detect_parasitic_motion( movement_axis, other_axis = _axis_from_movement_dict(movement, return_other=True) parasitic_motion = abs(offset[other_axis]) max_parasitic_motion = abs(movement[movement_axis] * PARASITIC_MOTION_TOL) - if parasitic_motion > max_parasitic_motion: - raise ParasiticMotionError( - f"Parasitic motion detected. Detected motion of {parasitic_motion} pixels " - f"this exceeds a maximum for this move of {max_parasitic_motion} pixels." - ) + return parasitic_motion > max_parasitic_motion diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index b2d2e5e8..e3b283d3 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -106,7 +106,7 @@ def test_error_on_axis_from_movement_dict(movement): @pytest.mark.parametrize( - ("par_fraction", "should_error"), + ("par_fraction", "too_high"), [ (-0.20, True), (-0.11, True), @@ -119,19 +119,17 @@ def test_error_on_axis_from_movement_dict(movement): (0.20, True), ], ) -def test_parasitic_detect(par_fraction, should_error): - """Check error is raised if the fraction of parastitic motion is too high.""" +def test_parasitic_detect(par_fraction, too_high): + """Check parasitic motion is detected if the fraction of parasitic motion is too high.""" movement = {"x": 2908, "y": 0} offset = copy(movement) offset["y"] = movement["x"] * par_fraction - if should_error: - with pytest.raises(stage_measure.ParasiticMotionError): - stage_measure._detect_parasitic_motion(movement=movement, offset=offset) - else: - # Nothing to check here as the only job of _detect_parasitic_motion is to - # error if there is too much motion - stage_measure._detect_parasitic_motion(movement=movement, offset=offset) + + detected = stage_measure._parasitic_motion_detected( + movement=movement, offset=offset + ) + assert detected == too_high def test_error_if_no_stream_res_set_when_requesting_img_coords(): @@ -163,6 +161,9 @@ def mock_rom_deps(csm_matrix, mocker) -> stage_measure.RomDeps: vec = np.dot(np.array([y, x]), np.array(csm_matrix)) return {"x": int(vec[0]), "y": int(vec[1])} + mock_cam = mocker.Mock() + mock_cam.image_is_sample.return_value = (True, "Mocked not measured.") + mock_csm = mocker.Mock() # Set up mock csm to return a CSM matrix mock_csm.image_to_stage_displacement_matrix = csm_matrix @@ -171,7 +172,7 @@ def mock_rom_deps(csm_matrix, mocker) -> stage_measure.RomDeps: return stage_measure.RomDeps( autofocus=mocker.Mock(), stage=mocker.Mock(), - cam=mocker.Mock(), + cam=mock_cam, csm=mock_csm, logger=LOGGER, ) From bffd7f9d0b2d9d4993e2945829e0357ea5f6ddeb Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 21 Oct 2025 10:48:34 +0100 Subject: [PATCH 65/70] Improve type consistency and error message --- .../things/stage_measure.py | 31 +++++++++++++------ 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 8180eb5d..0e7ad37a 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -166,10 +166,14 @@ class RangeofMotionThing(lt.Thing): # The total range for the two axes under test step_range = [] - for axis in ["x", "y"]: + # Strictly typed definitions to iterate over for MyPys sake. + axes: tuple[Literal["x"], Literal["y"]] = ("x", "y") + directions: tuple[Literal[1], Literal[-1]] = (1, -1) + + for axis in axes: # The final position of the axis under test in the given direction axis_limits = [] - for axis_dir in [1, -1]: + for axis_dir in directions: # Create a new tracker at start of measurement. self._rom_data = RomDataTracker() self._move_until_edge(axis=axis, direction=axis_dir, rom_deps=rom_deps) @@ -195,14 +199,14 @@ class RangeofMotionThing(lt.Thing): """ stream_shape = cam.grab_as_array().shape # Swap axes as numpy is [y, x] - self._stream_resolution = [stream_shape[1], stream_shape[0]] + self._stream_resolution = (stream_shape[1], stream_shape[0]) def _move_until_edge( self, axis: Literal["x", "y"], direction: Literal[1, -1], rom_deps: RomDeps, - ) -> dict: + ) -> None: """Move in one direction until movement per step decreases significantly. This should move until the edge of the stage. Once the edge is reached there @@ -426,7 +430,7 @@ class RangeofMotionThing(lt.Thing): """ def _reverse_move_dict(displacement: float) -> dict[str, float]: - move = {"x": 0, "y": 0} + move = {"x": 0.0, "y": 0.0} move[axis] = displacement * direction * -1 return move @@ -511,7 +515,8 @@ class RangeofMotionThing(lt.Thing): is_sample, _bg_message = rom_deps.cam.image_is_sample() if not is_sample: raise RuntimeError( - "No sample detected. Sample must cover the whole range of motion." + "No sample detected. Sample must be densely featured and cover the " + "whole range of motion." ) after_img = rom_deps.cam.grab_as_array() offset = fft_image_tracking.displacement_between_images( @@ -524,19 +529,25 @@ class RangeofMotionThing(lt.Thing): return {"x": offset[1], "y": offset[0]} +@overload +def _axis_from_movement_dict(movement: dict[str, float]) -> str: ... + + @overload def _axis_from_movement_dict( - movement: dict[str, float], return_other: bool = False + movement: dict[str, float], return_other: Literal[False] ) -> str: ... @overload def _axis_from_movement_dict( - movement: dict[str, float], return_other: bool = True + movement: dict[str, float], return_other: Literal[True] ) -> tuple[str, str]: ... -def _axis_from_movement_dict(movement: dict[str, float], return_other: bool = False): +def _axis_from_movement_dict( + movement: dict[str, float], return_other: bool = False +) -> str | tuple[str, str]: """Return the axis that a given movement dictionary moves in. For example: ``_axis_from_movement_dict({"x": 10, "y":0})`` will return ``x``. @@ -557,7 +568,7 @@ def _axis_from_movement_dict(movement: dict[str, float], return_other: bool = Fa def _parasitic_motion_detected( movement: dict[str, float], offset: dict[str, float] -) -> None: +) -> bool: """Compare a desired movement to measured offset and error if parasitic motion is too high. :param movement: The movement dictionary in image coordinates. From b8eb1907c387baf4875a19fa6388dd23154bd4fd Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Tue, 21 Oct 2025 14:14:06 +0100 Subject: [PATCH 66/70] Modify range of motion _move_back_until_motion_detected to move in SMALL_STEP steps This will carry on until at least 1.5x BIG_STEP in total motion. If no motion is detected then something is up. --- .../things/stage_measure.py | 27 +++++++++---------- tests/test_stage_measure.py | 21 +++++++-------- 2 files changed, 23 insertions(+), 25 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 0e7ad37a..c3078702 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -5,8 +5,8 @@ enough positions to predict future z positions. Next, one 'big' step is taken fo by 3 'small' steps to test that the stage is still moving as expected and has not reached the edge. Once the edge has been found and to account for the possibility that the edge was reached during a "big" step, the stage is moved in a sequence of steps -(incrementing in size) in the opposite direction until motion is detected. This is -position is taken as the true final position. +in the opposite direction until motion is detected. This is position is taken as the +true final position. Throughout the test, parasitic motion (motion in the axis not being measured) is tracked and an error is raised if it exceeds a minimum amount. Currently @@ -421,30 +421,29 @@ class RangeofMotionThing(lt.Thing): """Move the stage against the direction of test unil motion is detected. In the case that the stage has reached the end of the motion, this method moves - the motor in the opposite direction until motion is detected. + the motor in the opposite direction until reliable motion is detected. :param axis: The axis in which the stage is moving. This must be 'x' or 'y'. :param direction: The direction in which the stage was moving during the test, this method will move in the opposite direction. :param rom_deps: All dependencies that were passed to the calling Action """ + movement = self._movement_in_img_coords( + fov_perc=SMALL_STEP, axis=axis, direction=-direction + ) - def _reverse_move_dict(displacement: float) -> dict[str, float]: - move = {"x": 0.0, "y": 0.0} - move[axis] = displacement * direction * -1 - return move + max_moves = int(1.5 * BIG_STEP / SMALL_STEP) + # minimum number of pixels for motion to be detected as reliable + motion_minimum = abs(movement[axis] * DETECT_MOTION_TOL) - # Array of increasing pixel sizes (powers of 2 from 1 to 512) - movements = [_reverse_move_dict(2**i) for i in range(10)] - - motion_minimum = 20 # minimum number of pixels for motion to be detected - - for movement in movements: + for i in range(max_moves): # Increment movement rom_deps.logger.info(f"Testing with step size {movement[axis]}") + # Run an autofocus every 3 steps + run_autofocus = i % 3 == 2 offset = self._move_and_measure( - movement=movement, rom_deps=rom_deps, perform_autofocus=False + movement=movement, rom_deps=rom_deps, perform_autofocus=run_autofocus ) rom_deps.logger.info(f"Offset measured as {abs(offset[axis])}") diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index e3b283d3..5f702a9a 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -279,19 +279,18 @@ def test_move_back_until_motion_detected(rom_thing, mock_rom_deps, mocker): The moves for this method should be in opposite direction to the direction specified as this is moving back after the stage reaches end of its movement. """ - - def gen_offset(*_args, **_kwargs): - return {"x": 0, "y": 0} - mock_move_n_meas = mocker.patch.object( - rom_thing, "_move_and_measure", side_effect=gen_offset + rom_thing, "_move_and_measure", return_value={"x": 0, "y": 0} ) with pytest.raises(RuntimeError, match="Cannot detect motion again"): rom_thing._move_back_until_motion_detected("y", -1, rom_deps=mock_rom_deps) - assert mock_move_n_meas.call_count == 10 - for i, call_args in enumerate(mock_move_n_meas.call_args_list): - call_args.kwargs["movement"] = {"x": 0, "y": 2**i} + + max_tries = int(1.5 * stage_measure.BIG_STEP / stage_measure.SMALL_STEP) + expected_step = 800 * stage_measure.SMALL_STEP / 100 + assert mock_move_n_meas.call_count == max_tries + for _i, call_args in enumerate(mock_move_n_meas.call_args_list): + call_args.kwargs["movement"] = {"x": 0, "y": expected_step} call_args.kwargs["perform_autofocus"] = False ## Reset mock and change the side effect @@ -299,7 +298,7 @@ def test_move_back_until_motion_detected(rom_thing, mock_rom_deps, mocker): mock_move_n_meas.side_effect = ( {"x": 0, "y": 0}, {"x": 0, "y": 0}, - {"x": 21, "y": 0}, + {"x": expected_step * 0.8, "y": 0}, ) # Other axis and direction this time @@ -307,8 +306,8 @@ def test_move_back_until_motion_detected(rom_thing, mock_rom_deps, mocker): # Should only be called 3 times assert mock_move_n_meas.call_count == 3 - for i, call_args in enumerate(mock_move_n_meas.call_args_list): - call_args.kwargs["movement"] = {"x": -(2**i), "y": 0} + for _i, call_args in enumerate(mock_move_n_meas.call_args_list): + call_args.kwargs["movement"] = {"x": -expected_step, "y": 0} call_args.kwargs["perform_autofocus"] = False From 82f435256bb3431e2b4ed51cd741d7522612c324 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Wed, 22 Oct 2025 09:57:01 +0100 Subject: [PATCH 67/70] Release lock at end of ROM test --- .../things/stage_measure.py | 62 ++++++++++--------- 1 file changed, 33 insertions(+), 29 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index c3078702..9c8562b3 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -152,40 +152,44 @@ class RangeofMotionThing(lt.Thing): if not got_lock: raise RuntimeError("Trying to run ROM test when a test is already running.") - rom_deps = RomDeps( - autofocus=autofocus, stage=stage, csm=csm, cam=cam, logger=logger - ) - logger.info( - "Using the stage to measure the Range of Motion. " - "Please ensure you are using a big enough sample." - ) - start_time = time.time() + try: + rom_deps = RomDeps( + autofocus=autofocus, stage=stage, csm=csm, cam=cam, logger=logger + ) + logger.info( + "Using the stage to measure the Range of Motion. " + "Please ensure you are using a big enough sample." + ) + start_time = time.time() - self._set_stream_resolution(cam) + self._set_stream_resolution(cam) - # The total range for the two axes under test - step_range = [] + # The total range for the two axes under test + step_range = [] - # Strictly typed definitions to iterate over for MyPys sake. - axes: tuple[Literal["x"], Literal["y"]] = ("x", "y") - directions: tuple[Literal[1], Literal[-1]] = (1, -1) + # Strictly typed definitions to iterate over for MyPys sake. + axes: tuple[Literal["x"], Literal["y"]] = ("x", "y") + directions: tuple[Literal[1], Literal[-1]] = (1, -1) - for axis in axes: - # The final position of the axis under test in the given direction - axis_limits = [] - for axis_dir in directions: - # Create a new tracker at start of measurement. - self._rom_data = RomDataTracker() - self._move_until_edge(axis=axis, direction=axis_dir, rom_deps=rom_deps) - # Append final position from tracker - axis_limits.append(self._rom_data.final_position[axis]) - # Calculate step range. - step_range.append(abs(axis_limits[0] - axis_limits[1])) - - total_time = time.time() - start_time - logger.info(f"Range of motion is {step_range[0]} x {step_range[1]} steps") - self.calibrated_range = step_range + for axis in axes: + # The final position of the axis under test in the given direction + axis_limits = [] + for axis_dir in directions: + # Create a new tracker at start of measurement. + self._rom_data = RomDataTracker() + self._move_until_edge( + axis=axis, direction=axis_dir, rom_deps=rom_deps + ) + # Append final position from tracker + axis_limits.append(self._rom_data.final_position[axis]) + # Calculate step range. + step_range.append(abs(axis_limits[0] - axis_limits[1])) + total_time = time.time() - start_time + logger.info(f"Range of motion is {step_range[0]} x {step_range[1]} steps") + self.calibrated_range = step_range + finally: + self._lock.release() return { "Time": total_time, "CSM Matrix": csm.image_to_stage_displacement_matrix, From ba8a4ca744ce8f9fe45d98b52998fb7a7cbdc5cd Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 24 Oct 2025 14:05:07 +0000 Subject: [PATCH 68/70] Apply suggestions from code review of branch rom_test_only Co-authored-by: Beth Probert --- src/openflexure_microscope_server/things/stage_measure.py | 7 ++++--- tests/test_stage_measure.py | 6 +++--- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 9c8562b3..24aca185 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -158,7 +158,8 @@ class RangeofMotionThing(lt.Thing): ) logger.info( "Using the stage to measure the Range of Motion. " - "Please ensure you are using a big enough sample." + "Please ensure you are using a sample that covers the whole range of " + "motion. This should be approximately 12 x 12 mm." ) start_time = time.time() @@ -275,7 +276,7 @@ class RangeofMotionThing(lt.Thing): finally: rom_deps.stage.move_absolute(**starting_position, block_cancellation=True) - def _img_percentate_to_img_coords( + def _img_percentage_to_img_coords( self, fov_perc: int, axis: Literal["x", "y"] ) -> float: """For a given image percentage and axis return the distance in img coords. @@ -308,7 +309,7 @@ class RangeofMotionThing(lt.Thing): :return: The movement size in image coordinates """ - distance = self._img_percentate_to_img_coords(fov_perc=fov_perc, axis=axis) + distance = self._img_percentage_to_img_coords(fov_perc=fov_perc, axis=axis) if axis == "x": return {"x": distance * direction, "y": 0} return {"x": 0, "y": distance * direction} diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index 5f702a9a..69f6c1bd 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -18,7 +18,7 @@ LOGGER = logging.getLogger("mock-invocation_logger") # Useful generators def increasing_xy_dict_generator(*_args, **_kwargs): - """Generate x-y dictionaries of incremening sizes. + """Generate x-y dictionaries of incrementing sizes. These don't simulate expected effects, but allow checking sequential reads of a function/property were used. @@ -30,7 +30,7 @@ def increasing_xy_dict_generator(*_args, **_kwargs): def increasing_xyz_dict_generator(*_args, **_kwargs): - """Generate x-y-z dictionaries of incremening sizes. + """Generate x-y-z dictionaries of incrementing sizes. These don't simulate expected effects, but allow checking sequential reads of a function/property were used. @@ -135,7 +135,7 @@ def test_parasitic_detect(par_fraction, too_high): def test_error_if_no_stream_res_set_when_requesting_img_coords(): """Check a RuntimeError thrown when requesting image coordinates if resolution unset.""" with pytest.raises(RuntimeError, match="Stream resolution must be set"): - stage_measure.RangeofMotionThing()._img_percentate_to_img_coords(20, "x") + stage_measure.RangeofMotionThing()._img_percentage_to_img_coords(20, "x") @pytest.fixture From 08046ff657267c9957458973307c82f6742e7b83 Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 24 Oct 2025 17:27:43 +0100 Subject: [PATCH 69/70] Clarify behaviour of overloads, and better error checking of _move_and_measure inputs --- .../things/stage_measure.py | 27 ++++++++++++------ tests/test_stage_measure.py | 28 +++++++++++++++++++ 2 files changed, 47 insertions(+), 8 deletions(-) diff --git a/src/openflexure_microscope_server/things/stage_measure.py b/src/openflexure_microscope_server/things/stage_measure.py index 24aca185..45175c2c 100644 --- a/src/openflexure_microscope_server/things/stage_measure.py +++ b/src/openflexure_microscope_server/things/stage_measure.py @@ -83,7 +83,7 @@ class RomDataTracker: :param axis: The axis which is being measured. This must be 'x' or 'y'. :param stage_movement: The movement to be performed in stage coordinates. :param stage_position: The current stage position in stage coordinates. - :returns: The predicted relative z displacement needed to stay in focus. + :return: The predicted relative z displacement needed to stay in focus. """ # x or y positions lateral_positions = [i[axis] for i in self.stage_coords] @@ -479,6 +479,11 @@ class RangeofMotionThing(lt.Thing): autofocus is repeated. :return: The calculated offset from cross correlation. """ + if max_autofocus_repeats > 0 and abs_min_offset <= 0: + raise ValueError( + "abs_min_offset must be positive if max_autofocus_repeats > 0." + ) + # Take image before move before_img = rom_deps.cam.grab_as_array() # Move and autofocus if required @@ -533,22 +538,26 @@ class RangeofMotionThing(lt.Thing): return {"x": offset[1], "y": offset[0]} -@overload -def _axis_from_movement_dict(movement: dict[str, float]) -> str: ... - - +# The number of return arguments depends on if return_other is True or False +# let MyPy know with overloads typed to Literal[False] and Literal[True]. @overload def _axis_from_movement_dict( movement: dict[str, float], return_other: Literal[False] ) -> str: ... - - @overload def _axis_from_movement_dict( movement: dict[str, float], return_other: Literal[True] ) -> tuple[str, str]: ... +# Overload the case where return_other is not specified, this is needed +# because MyPy doesn't see that return other's default is `False` and use +# the `Literal[False]` overload. +@overload +def _axis_from_movement_dict(movement: dict[str, float]) -> str: ... + + +# Finally The function. def _axis_from_movement_dict( movement: dict[str, float], return_other: bool = False ) -> str | tuple[str, str]: @@ -573,10 +582,12 @@ def _axis_from_movement_dict( def _parasitic_motion_detected( movement: dict[str, float], offset: dict[str, float] ) -> bool: - """Compare a desired movement to measured offset and error if parasitic motion is too high. + """Compare desired movement to measured offset, report if parasitic motion was detected. :param movement: The movement dictionary in image coordinates. :param offset: The offset calculated from correlation. + :return: True if too much parasitic motion is detects. False if movement is as + expected. """ movement_axis, other_axis = _axis_from_movement_dict(movement, return_other=True) parasitic_motion = abs(offset[other_axis]) diff --git a/tests/test_stage_measure.py b/tests/test_stage_measure.py index 69f6c1bd..9f4e3a4e 100644 --- a/tests/test_stage_measure.py +++ b/tests/test_stage_measure.py @@ -273,6 +273,34 @@ def test_move_and_measure_with_refocus( assert offset == expected_return +def test_move_and_measure_with_bad_refocus_args(rom_thing, mocker): + """Check error if abs_min_offset is not positive when using it to determine if to autofocus.""" + offset_from_mock = mocker.patch.object( + rom_thing, "_offset_from", return_value={"x": 0, "y": 0} + ) + + # First check with abs_min_offset not set. The default should be zero. + with pytest.raises(ValueError, match="abs_min_offset must be positive"): + rom_thing._move_and_measure( + movement={"x": 10, "y": 0}, + rom_deps=mock_rom_deps, + perform_autofocus=False, + max_autofocus_repeats=3, + ) + # Then check with abs_min_offset negative, this might happen if the the expected + # move calculation is not made absolute. + with pytest.raises(ValueError, match="abs_min_offset must be positive"): + rom_thing._move_and_measure( + movement={"x": 10, "y": 0}, + rom_deps=mock_rom_deps, + perform_autofocus=False, + max_autofocus_repeats=3, + abs_min_offset=-123.456, + ) + # Should have never measured and offset. Just error straight away. + assert offset_from_mock.call_count == 0 + + def test_move_back_until_motion_detected(rom_thing, mock_rom_deps, mocker): """Check that _move_back_until_motion_detected is making increasing negative moves. From ba84dc2f92b1f4312cc8e2e29cb068d4d28ec67b Mon Sep 17 00:00:00 2001 From: Julian Stirling Date: Fri, 24 Oct 2025 17:34:32 +0100 Subject: [PATCH 70/70] Be clear that quadratic can be used with a float or an array --- .../utilities.py | 26 ++++++++++++++++--- 1 file changed, 23 insertions(+), 3 deletions(-) diff --git a/src/openflexure_microscope_server/utilities.py b/src/openflexure_microscope_server/utilities.py index 318e6457..b7377f9a 100644 --- a/src/openflexure_microscope_server/utilities.py +++ b/src/openflexure_microscope_server/utilities.py @@ -1,6 +1,15 @@ """Utility functions and classes.""" -from typing import TypeVar, Callable, ParamSpec, Optional, Any, Concatenate, Self +from typing import ( + TypeVar, + Callable, + ParamSpec, + Optional, + Any, + Concatenate, + Self, + overload, +) import os import re import sys @@ -11,6 +20,7 @@ import tomllib from functools import wraps from pydantic import BaseModel +import numpy as np T = TypeVar("T") P = ParamSpec("P") @@ -329,10 +339,20 @@ def _get_version_from_toml(toml_path: str) -> str: return "Undefined" -def quadratic(x: float, a: float, b: float, c: float) -> float: +# Let MyPy know that the output type matches x. +@overload +def quadratic(x: np.ndarray, a: float, b: float, c: float) -> np.ndarray: ... +@overload +def quadratic(x: float, a: float, b: float, c: float) -> float: ... + + +def quadratic( + x: float | np.ndarray, a: float, b: float, c: float +) -> float | np.ndarray: """Quadratic function. Used for predicting z. - :param x: The points at which to evaluate the quadratic. + :param x: The point or points at which to evaluate the quadratic. This can be a + float or a numpy array. The return will be the same type. :param a: The coefficient of x^2. :param b: The coefficient of x. :param c: The constant coefficient.