diff --git a/src/openflexure_microscope_server/things/auto_recentre_stage.py b/src/openflexure_microscope_server/things/auto_recentre_stage.py index e77c961d..ad1c71e6 100644 --- a/src/openflexure_microscope_server/things/auto_recentre_stage.py +++ b/src/openflexure_microscope_server/things/auto_recentre_stage.py @@ -1,8 +1,5 @@ import numpy as np import logging -import matplotlib.pyplot as plt -import json -import numpy as np import time from labthings_fastapi.thing import Thing @@ -18,176 +15,175 @@ CamDep = direct_thing_client_dependency(StreamingPiCamera2, "/camera/") CSMDep = direct_thing_client_dependency(CameraStageMapper, "/camera_stage_mapping/") AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") -def _gui_description(): - return { - "icon": "center_focus_weak", - "forms": [ - { - "name": "Run recentre", - "route": "/recentre", - "submitLabel": "Run recentre", - "isTask": True, - "schema": [ - { - "fieldType": "htmlBlock", - "name": "status_display", - "label": "current_status", - "content": ( - "Find the centre of the range of motion of the microscope, based on the position of the highest point." - "
" - "
" - "Requires a flat sample with fairly dense features to focus on." - ) - }, - ] - } - ] - } def turningpoints(lst): dx = np.diff(lst) - return (dx[1:] * dx[:-1] < 0) + return dx[1:] * dx[:-1] < 0 + def unpack_autofocus(scan_data): - """Extract z, sharpness data from a move_and_measure call - """ + """Extract z, sharpness data from a move_and_measure call""" scan_data = dict(scan_data) - jpeg_times = scan_data['jpeg_times'] - jpeg_sizes = scan_data['jpeg_sizes'] + jpeg_times = scan_data["jpeg_times"] + jpeg_sizes = scan_data["jpeg_sizes"] jpeg_sizes_MB = [x / 10**3 for x in jpeg_sizes] - stage_times = scan_data['stage_times'] - stage_positions = scan_data['stage_positions'] - stage_height = [pos['z'] for pos in stage_positions] + stage_times = scan_data["stage_times"] + stage_positions = scan_data["stage_positions"] + stage_height = [pos["z"] for pos in stage_positions] - jpeg_heights = np.interp(jpeg_times,stage_times,stage_height) + jpeg_heights = np.interp(jpeg_times, stage_times, stage_height) - turning = np.where(turningpoints(jpeg_heights))[0]+1 + turning = np.where(turningpoints(jpeg_heights))[0] + 1 - return jpeg_heights[turning[0]:turning[1]], jpeg_sizes_MB[turning[0]:turning[1]] + return jpeg_heights[turning[0] : turning[1]], jpeg_sizes_MB[turning[0] : turning[1]] class RecentringThing(Thing): @thing_action - def looping_autofocus(self, autofocus: AutofocusDep, stage: StageDep, dz = 2000): + def looping_autofocus(self, autofocus: AutofocusDep, stage: StageDep, dz=2000): repeat = True attempts = 0 while repeat and attempts < 10: - height_min = stage.position['z'] - dz / 2 - height_max = stage.position['z'] + dz / 2 - data = autofocus.fast_autofocus(dz = dz) + height_min = stage.position["z"] - dz / 2 + height_max = stage.position["z"] + dz / 2 + data = autofocus.fast_autofocus(dz=dz) heights, _ = unpack_autofocus(data) - print(min(heights), max(heights), stage.position['z']) + print(min(heights), max(heights), stage.position["z"]) time.sleep(0.3) # TODO: max heights seems badly wrong! Something about turning? - if stage.position['z'] - height_min < dz / 5 or height_max - stage.position['z'] < dz / 5: + if ( + stage.position["z"] - height_min < dz / 5 + or height_max - stage.position["z"] < dz / 5 + ): print(heights) attempts += 1 else: repeat = False @thing_action - def recentre(self, autofocus: AutofocusDep, stage: StageDep, max_steps = 15, lateral_distance = 5000): - """ Recentre the OpenFlexure stage, based on the - location of the xy turning point - - Autofocuses at multiple points around the sample to - find the overall maximum (or minimum) height, which - corresponds to the centre of the stage. + def recentre( + self, + autofocus: AutofocusDep, + stage: StageDep, + max_steps=15, + lateral_distance=5000, + ): + """Recentre the OpenFlexure stage, based on the + location of the xy turning point - max_steps: The maximum number of moves in x or y before - aborting due to a poorly positioned stage or hard to focus - sample + Autofocuses at multiple points around the sample to + find the overall maximum (or minimum) height, which + corresponds to the centre of the stage. - lateral_distance: The xy distance between areas to check. - Below 3000 becomes less reliable, as focus shouldn't shift - much between these sites, making the procedure more sensitive - to noise or a failed autofocus. - """ + max_steps: The maximum number of moves in x or y before + aborting due to a poorly positioned stage or hard to focus + sample - max_steps = 20 - dx = lateral_distance + lateral_distance: The xy distance between areas to check. + Below 3000 becomes less reliable, as focus shouldn't shift + much between these sites, making the procedure more sensitive + to noise or a failed autofocus. + """ - centre = list(stage.position.values()) + max_steps = 20 + dx = lateral_distance - # A list of all the positions we've focused - focused_pos = [[],[]] + centre = list(stage.position.values()) - self.looping_autofocus(autofocus, stage) + # A list of all the positions we've focused + focused_pos = [[], []] - for direction in [0, 1]: - # Start off with the current position, and moving in the positive direction - focused_pos[direction] = [list(stage.position.values())] - moves = +1 - - print(centre) - - stage.move_absolute(x = centre[0], y = centre[1], z = centre[2]) - steps = 0 - all_heights = [] + self.looping_autofocus(autofocus, stage) - # We'll run this for x, then y - while True: - # If we're moving in the positive direction, we want the highest point - # Otherwise, we want the lowest - if moves > 0: - starting_point = np.max(np.array(focused_pos[direction])[:,direction]) - else: - starting_point = np.min(np.array(focused_pos[direction])[:,direction]) - - # Next location is an extra move in the direction we want - destination = centre - destination[direction] = starting_point + moves * dx - stage.move_absolute(x = int(destination[0]), y = int(destination[1]), z = destination[2]) - self.looping_autofocus(autofocus, stage) - position = list(stage.position.values()) - focused_pos[direction].append(position) + for direction in [0, 1]: + # Start off with the current position, and moving in the positive direction + focused_pos[direction] = [list(stage.position.values())] + moves = +1 - logging.info(focused_pos) + print(centre) - steps += 1 - if steps > max_steps: - logging.warning("Couldn't find a suitable position. Roughly centre the stage and check your sample is suitable for autofocus") + stage.move_absolute(x=centre[0], y=centre[1], z=centre[2]) + steps = 0 + all_heights = [] + + # We'll run this for x, then y + while True: + # If we're moving in the positive direction, we want the highest point + # Otherwise, we want the lowest + if moves > 0: + starting_point = np.max( + np.array(focused_pos[direction])[:, direction] + ) + else: + starting_point = np.min( + np.array(focused_pos[direction])[:, direction] + ) + + # Next location is an extra move in the direction we want + destination = centre + destination[direction] = starting_point + moves * dx + stage.move_absolute( + x=int(destination[0]), y=int(destination[1]), z=destination[2] + ) + self.looping_autofocus(autofocus, stage) + position = list(stage.position.values()) + focused_pos[direction].append(position) + + logging.info(focused_pos) + + steps += 1 + if steps > max_steps: + logging.warning( + "Couldn't find a suitable position. Roughly centre the stage and check your sample is suitable for autofocus" + ) + break + + if len(focused_pos[direction]) > 4: + all_heights = [x[2] for x in focused_pos[direction]] + direction_index = [x[direction] for x in focused_pos[direction]] + + sorted_all_heights = [ + x for y, x in sorted(zip(direction_index, all_heights)) + ] + + sorted_lateral = sorted(direction_index) + quad_fit = np.polyfit(sorted_lateral, sorted_all_heights, 2) + quad_fit_func = np.poly1d(quad_fit) + + turning = quad_fit_func.deriv() + + turning_loc = -turning[0] / (turning[1]) + + logging.warning(sorted_all_heights) + if ( + np.argmax(sorted_all_heights) != 0 + and np.argmax(sorted_all_heights) != len(all_heights) - 1 + ): + logging.info( + f"Breaking because the highest point is at {np.argmax(sorted_all_heights)} in the list" + ) + # plt.plot(sorted_lateral, sorted_all_heights,'.') + # plt.plot(sorted_lateral, quad_fit_func(sorted_lateral)) + # plt.show() break - - if len(focused_pos[direction]) > 4: - - all_heights = [x[2] for x in focused_pos[direction]] - direction_index = [x[direction] for x in focused_pos[direction]] - - sorted_all_heights = [x for y, x in sorted(zip(direction_index, all_heights))] - - sorted_lateral = sorted(direction_index) - quad_fit = np.polyfit(sorted_lateral, sorted_all_heights, 2) - quad_fit_func = np.poly1d(quad_fit) - - turning = quad_fit_func.deriv() - - turning_loc = -turning[0]/(turning[1]) - - logging.warning(sorted_all_heights) - if np.argmax(sorted_all_heights) != 0 and np.argmax(sorted_all_heights) != len(all_heights) - 1: - logging.info(f'Breaking because the highest point is at {np.argmax(sorted_all_heights)} in the list') + else: + if turning_loc < np.min(sorted_lateral): + moves = -1 + elif turning_loc > np.max(sorted_lateral): + moves = 1 + else: # plt.plot(sorted_lateral, sorted_all_heights,'.') # plt.plot(sorted_lateral, quad_fit_func(sorted_lateral)) # plt.show() - break - else: - if turning_loc < np.min(sorted_lateral): - moves = -1 - elif turning_loc > np.max(sorted_lateral): - moves = 1 - else: - # plt.plot(sorted_lateral, sorted_all_heights,'.') - # plt.plot(sorted_lateral, quad_fit_func(sorted_lateral)) - # plt.show() - pass + pass - - # Centre value is replaced by the maximum value recorded in that axis - centre[direction] = focused_pos[direction][np.argmax(all_heights)][direction] - stage.move_absolute(x = centre[0], y = centre[1], z = centre[2]) - self.looping_autofocus(autofocus, stage) + # Centre value is replaced by the maximum value recorded in that axis + centre[direction] = focused_pos[direction][np.argmax(all_heights)][ + direction + ] + stage.move_absolute(x=centre[0], y=centre[1], z=centre[2]) + self.looping_autofocus(autofocus, stage) - logging.info(f"Centre of ROM is at {centre, stage.position['z']} \n") + logging.info(f"Centre of ROM is at {centre, stage.position['z']} \n") - return focused_pos + return focused_pos diff --git a/src/openflexure_microscope_server/things/smart_scan.py b/src/openflexure_microscope_server/things/smart_scan.py index ffbcf08e..f55852e3 100644 --- a/src/openflexure_microscope_server/things/smart_scan.py +++ b/src/openflexure_microscope_server/things/smart_scan.py @@ -4,7 +4,6 @@ import os import time from PIL import Image from scipy.stats import norm -import pprint import logging from copy import deepcopy @@ -25,84 +24,96 @@ AutofocusDep = direct_thing_client_dependency(AutofocusThing, "/autofocus/") RecentreStage = direct_thing_client_dependency(RecentringThing, "/auto_recentre_stage/") SettingsDep = direct_thing_client_dependency(SettingsManager, "/settings/") + def check_dist(image, stats_list, multiple): - '''tests whether each pixel in image is within multiple stdevs (stats_list[1]) of the mean (stats_list[0]) for all three channels + """tests whether each pixel in image is within multiple stdevs (stats_list[1]) of the mean (stats_list[0]) for all three channels returns a mask with True for pixels within that range (similar to stats list) and False otherwise - ''' - return( - np.all(np.abs(image - stats_list[np.newaxis, np.newaxis, 0, :]) < stats_list[np.newaxis, np.newaxis, 1, :] * multiple, axis = 2) + """ + return np.all( + np.abs(image - stats_list[np.newaxis, np.newaxis, 0, :]) + < stats_list[np.newaxis, np.newaxis, 1, :] * multiple, + axis=2, ) -def closest(current, focused_path): - ''' Finds the index of the closest x-y position in a list from the current position, - with ties split by the later element in the list (most recently taken) - - must be float64 to deal with the huge numbers involved!''' - - current_pos = np.array(current[:2], dtype='float64') - path_pos = np.asarray(focused_path, dtype='float64').T[:2].T - dist_2 = np.sqrt(np.sum((path_pos - current_pos)**2, axis=1, dtype='float64'), dtype = 'float64') +def closest(current, focused_path): + """Finds the index of the closest x-y position in a list from the current position, + with ties split by the later element in the list (most recently taken) + + must be float64 to deal with the huge numbers involved!""" + + current_pos = np.array(current[:2], dtype="float64") + path_pos = np.asarray(focused_path, dtype="float64").T[:2].T + + dist_2 = np.sqrt( + np.sum((path_pos - current_pos) ** 2, axis=1, dtype="float64"), dtype="float64" + ) min_dist = np.argmin(dist_2) mask = np.where(dist_2 == dist_2[min_dist], 1, 0) try: closest = np.max(np.nonzero(mask)) except: closest = 0 - return closest + return closest + def unpack_autofocus(scan_data): """Extract z, sharpness data from a move_and_measure call - + Data will start at `start_index`, i.e. `start_index` points are dropped from the beginning of the array. """ - jpeg_times = scan_data['jpeg_times'] - jpeg_sizes = scan_data['jpeg_sizes'] + jpeg_times = scan_data["jpeg_times"] + jpeg_sizes = scan_data["jpeg_sizes"] jpeg_sizes_MB = [x / 10**3 for x in jpeg_sizes] - stage_times = scan_data['stage_times'] - stage_positions = scan_data['stage_positions'] + stage_times = scan_data["stage_times"] + stage_positions = scan_data["stage_positions"] stage_height = [pos[2] for pos in stage_positions] - jpeg_heights = np.interp(jpeg_times,stage_times,stage_height) + jpeg_heights = np.interp(jpeg_times, stage_times, stage_height) def turningpoints(lst): dx = np.diff(lst) return dx[1:] * dx[:-1] < 0 - turning = np.where(turningpoints(jpeg_heights))[0]+1 + turning = np.where(turningpoints(jpeg_heights))[0] + 1 + + return jpeg_heights[turning[0] : turning[1]], jpeg_sizes_MB[turning[0] : turning[1]] - return jpeg_heights[turning[0]:turning[1]], jpeg_sizes_MB[turning[0]:turning[1]] def limit_focus_change(prev_pos, prev_z, new_pos, new_z, limit): # limit is the largest ratio of change in z to change in xy that's allowed - prev_xy = np.asarray(prev_pos, dtype = 'float64') - new_xy = np.asarray(new_pos, dtype = 'float64') + prev_xy = np.asarray(prev_pos, dtype="float64") + new_xy = np.asarray(new_pos, dtype="float64") - dist = np.sqrt(np.sum(((new_xy - prev_xy)/10**4)**2, dtype = 'float64')) + dist = np.sqrt(np.sum(((new_xy - prev_xy) / 10**4) ** 2, dtype="float64")) - focus_change = abs(new_z - prev_z)/10**4 + focus_change = abs(new_z - prev_z) / 10**4 if dist == 0: - print(f'Not moved between {prev_pos} and {new_pos}') + print(f"Not moved between {prev_pos} and {new_pos}") movement_ratio = 0 else: - movement_ratio = np.divide(focus_change, dist, dtype='float64') + movement_ratio = np.divide(focus_change, dist, dtype="float64") # print('Movement ratio is {0} in z per lateral step. The limit is {1}'.format(round(movement_ratio, 4), round(limit,4))) # print(f'This is the distance between {prev_pos}, {prev_z} and {new_pos}, {new_z}') if movement_ratio > limit: - return 'reject' + return "reject" else: - return 'accept' + return "accept" + def distance_to_site(current, next): - current = np.array(current, dtype='float64') - next = np.array(next, dtype='float64') - if (next[1] - current[1])**2 + (next[0] - current[0])**2 < 0: - print(f'Negative distance between {next} and {current}') - return np.sqrt((next[1] - current[1])**2 + (next[0] - current[0])**2, dtype='float64') + current = np.array(current, dtype="float64") + next = np.array(next, dtype="float64") + if (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2 < 0: + print(f"Negative distance between {next} and {current}") + return np.sqrt( + (next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2, dtype="float64" + ) + # def set_template(microscope, pos): # microscope.move(pos) @@ -121,10 +132,12 @@ def distance_to_site(current, next): # stats_list = np.vstack([mu, std]) # return stats_list + def distance_to_site(current, next): - next = np.array(next, dtype='float64') - current = np.array(current, dtype='float64') - return np.sqrt( (next[1] - current[1])**2 + (next[0] - current[0])**2 ) + next = np.array(next, dtype="float64") + current = np.array(current, dtype="float64") + return np.sqrt((next[1] - current[1]) ** 2 + (next[0] - current[0]) ** 2) + class SmartScanThing(Thing): @thing_action @@ -139,14 +152,16 @@ class SmartScanThing(Thing): ch2 = (background_LUV.T[1]).flatten() ch3 = (background_LUV.T[2]).flatten() - points = np.array([np.asarray(ch1),np.asarray(ch2),np.asarray(ch3)]).T + points = np.array([np.asarray(ch1), np.asarray(ch2), np.asarray(ch3)]).T # we get the mean and standard deviation of values in each channel mu, std = np.apply_along_axis(norm.fit, 0, points) stats_list = np.vstack([mu, std]) - settings.update_external_metadata(data = {"background_stats": stats_list.tolist()}) + settings.update_external_metadata( + data={"background_stats": stats_list.tolist()} + ) current_keys = settings.external_metadata_in_state logging.info(type(current_keys)) @@ -157,11 +172,19 @@ class SmartScanThing(Thing): logging.info(settings.external_metadata_in_state) logging.info(settings.external_metadata) - + @thing_action - def sample_scan(self, autofocus: AutofocusDep, stage: StageDep, cam: CamDep, csm: CSMDep, settings: SettingsDep, recentre: RecentreStage): + def sample_scan( + self, + autofocus: AutofocusDep, + stage: StageDep, + cam: CamDep, + csm: CSMDep, + settings: SettingsDep, + recentre: RecentreStage, + ): # try: - stats_list = np.array(settings.external_metadata['background_stats']) + stats_list = np.array(settings.external_metadata["background_stats"]) # print(stats_list) # except: # logging.warning("No template set") @@ -183,13 +206,17 @@ class SmartScanThing(Thing): camera_stage_mapping_matrix = csm.image_to_stage_displacement_matrix - #TODO: CHECK HOW TO GET CALIBRATION IMAGE SIZE + # TODO: CHECK HOW TO GET CALIBRATION IMAGE SIZE dx = dx * arr.shape[1] / 100 dy = dy * arr.shape[0] / 100 - dx = np.array(np.dot(np.array([0,dx]), camera_stage_mapping_matrix), dtype=int)[0] - dy = np.array(np.dot(np.array([dy,0]), camera_stage_mapping_matrix), dtype=int)[1] + dx = np.array( + np.dot(np.array([0, dx]), camera_stage_mapping_matrix), dtype=int + )[0] + dy = np.array( + np.dot(np.array([dy, 0]), camera_stage_mapping_matrix), dtype=int + )[1] dz = 3000 @@ -198,7 +225,7 @@ class SmartScanThing(Thing): print(dx, dy) # construct a 2D scan path - path = [[stage.position['x'], stage.position['y']]] + path = [[stage.position["x"], stage.position["y"]]] # a list of the sites images have been taken at, and sites with a successful autofocus focused_path = [] @@ -210,7 +237,7 @@ class SmartScanThing(Thing): start_time = time.strftime("%H_%M_%S-%d_%m_%Y") - folder_path = r'scans' + folder_path = r"scans" if not os.path.exists(folder_path): os.makedirs(folder_path) @@ -222,17 +249,19 @@ class SmartScanThing(Thing): # move to each x-y position. in z, move to the height of the closest x-y position that successfully focused while len(path) > 0: - loc = [path[0][0], path[0][1], stage.position['z']] + loc = [path[0][0], path[0][1], stage.position["z"]] path.remove(loc[:2]) - stage.move_absolute(x = int(loc[0]), y = int(loc[1]), z = int(loc[2])) + stage.move_absolute(x=int(loc[0]), y=int(loc[1]), z=int(loc[2])) if len(focused_path) > 1: z_index = closest(loc, focused_path) # print('Moving to {0}'.format([coords[0], coords[1], focused_path[z_index][2]])) # print(focused_path) - stage.move_absolute(x = int(loc[0]), y = int(loc[1]), z = int(focused_path[z_index][2])) + stage.move_absolute( + x=int(loc[0]), y=int(loc[1]), z=int(focused_path[z_index][2]) + ) # capture an image, convert it to LUV, then make lists of the 3 channels' values img = cam.grab_jpeg() @@ -246,43 +275,58 @@ class SmartScanThing(Thing): # mask the image to only include pixels outside 5 stds of the mean of all three channels. # get the percent of pixels that are in the mask (assumed sample) img_mask = check_dist(img2, stats_list, 3) - background_coverage = round(100*np.count_nonzero(img_mask)/img_mask.size, 1) - + background_coverage = round( + 100 * np.count_nonzero(img_mask) / img_mask.size, 1 + ) # if more than 92% of the image is background, treat it as background and continue if 100 - background_coverage < sample_coverage: - category = 'background' + category = "background" # fig.set_facecolor("gray") else: # if not, it's sample. run an autofocus and use the updated height - new_pos = [[stage.position['x'] - dx, stage.position['y']], [stage.position['x'] + dx, stage.position['y']], [stage.position['x'], stage.position['y'] - dy], [stage.position['x'], stage.position['y'] + dy]] + new_pos = [ + [stage.position["x"] - dx, stage.position["y"]], + [stage.position["x"] + dx, stage.position["y"]], + [stage.position["x"], stage.position["y"] - dy], + [stage.position["x"], stage.position["y"] + dy], + ] for pos in new_pos: - if pos not in [sublist[:2] for sublist in true_path] and pos not in path: + if ( + pos not in [sublist[:2] for sublist in true_path] + and pos not in path + ): path.append(pos) - category = 'sample' + category = "sample" # fig.set_facecolor("pink") attempts = 0 while True: recentre.looping_autofocus(dz=3000) - current_height = stage.position['z'] + current_height = stage.position["z"] # if there have been successful autofocuses in this scan, find the closest one in x-y # test if the change in z between them exceeds a ratio (indicating a failed autofocus) if len(focused_path) > 0: nearest_focused_site = focused_path[closest(loc, focused_path)] - result = limit_focus_change(nearest_focused_site[0:2], nearest_focused_site[-1], loc[0:2], current_height, 0.3) + result = limit_focus_change( + nearest_focused_site[0:2], + nearest_focused_site[-1], + loc[0:2], + current_height, + 0.3, + ) # if there haven't been any previous autofocuses, we have to assume this one worked else: - result = 'accept' + result = "accept" # if the autofocus worked, take a new, more focused image. add the current position to the list of successful locations - if result == 'accept': + if result == "accept": loc = list(stage.position.values()) # img = microscope.grab_image_array() focused_path.append(loc) @@ -291,14 +335,18 @@ class SmartScanThing(Thing): if attempts >= 5: break else: - # if the autofocus was rejected, we return to the height of the closest successful autofocus. not perfect, but better than wandering out of focus - print('Resetting from a previous focus position. Options are {0}, we chose {1}'.format(focused_path, nearest_focused_site)) - stage.move_absolute(z = int(focused_path[z_index][2])) + # if the autofocus was rejected, we return to the height of the closest successful autofocus. not perfect, but better than wandering out of focus + print( + "Resetting from a previous focus position. Options are {0}, we chose {1}".format( + focused_path, nearest_focused_site + ) + ) + stage.move_absolute(z=int(focused_path[z_index][2])) attempts += 1 img = cam.capture_jpeg() img = np.array(Image.open(img.open())) - img = cv2.resize(img, (0,0), fx = 0.5, fy = 0.5) + img = cv2.resize(img, (0, 0), fx=0.5, fy=0.5) img = Image.fromarray(img) img.save(os.path.join(folder_path, f"rabbit_{loc[0]}_{loc[1]}.jpg")) @@ -307,9 +355,9 @@ class SmartScanThing(Thing): # add the current position to the list of all positions visited true_path.append(loc) - path = sorted(path, key = lambda x: distance_to_site(loc[:2], x)) + path = sorted(path, key=lambda x: distance_to_site(loc[:2], x)) if len(true_path) > 750: break - - stage.move_absolute(x = starting_loc[0], y = starting_loc[1], z = starting_loc[2]) + + stage.move_absolute(x=starting_loc[0], y=starting_loc[1], z=starting_loc[2])