From 95e5438566fd468d318e1c0299d53be3a15acbf5 Mon Sep 17 00:00:00 2001 From: jmcoreymv Date: Thu, 16 Nov 2023 12:55:49 -0800 Subject: [PATCH] Updates to tiling, protocol file save organization, protocol run fixes, protocol defaults. (#303) --- capture/.DS_Store | Bin 6148 -> 0 bytes data/example_protocol.tsv | 6 +- data/new_default_protocol.tsv | 5 + data/settings.json | 14 +- data/tiling.json | 49 +++ labware.py | 8 +- lumascope_api.py | 50 ++- lumaviewpro.kv | 52 +-- lumaviewpro.py | 646 ++++++++++++++++++++++------------ modules/common_utils.py | 39 ++ modules/tiling_config.py | 145 ++++++++ 11 files changed, 712 insertions(+), 302 deletions(-) delete mode 100644 capture/.DS_Store create mode 100644 data/new_default_protocol.tsv create mode 100644 data/tiling.json create mode 100644 modules/common_utils.py create mode 100644 modules/tiling_config.py diff --git a/capture/.DS_Store b/capture/.DS_Store deleted file mode 100644 index 398b8e175cb222f88949a31f7e966d472e1de5c7..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 6148 zcmeHK!AiqG5Peg7s0h-NcoFssim84;S}Nk%4~Vo{C=?5#_k4-J;P-jbH#%1A;Snn=vAkMu7W4IH&Ny8(FVxXn7Ph{VYuuS@!uaQ%a-DVM zlGmMcopwf 1: for i in range(stitch): - for j in range(stich): + for j in range(stitch): if j % 2 == 1: i = self.plate['columns'] - i - 1 self.stitch_list.append([i,j]) @@ -137,6 +137,12 @@ def get_well_index(self, x, y): return i, j + def get_well_label(self, x, y): + well_x, well_y = self.get_well_index(x=x, y=y) + letter = chr(ord('A') + well_y) + return f'{letter}{well_x + 1}' + + class PitriDish(LabWare): """A class that stores and computes actions for petri dish labware""" diff --git a/lumascope_api.py b/lumascope_api.py index 42be4509..2fd485ac 100644 --- a/lumascope_api.py +++ b/lumascope_api.py @@ -43,6 +43,7 @@ # Import additional libraries from lvp_logger import logger +import pathlib import time import threading import os @@ -168,6 +169,10 @@ def get_next_save_path(self, path): """ + # TODO for now converting pathlib.Path's to strings for the algorithm below + if issubclass(type(path), pathlib.Path): + path = str(path) + # Extract file extension (.tiff) and file_id (00001) dot_idx = path.rfind('.') under_idx = path.rfind('_') @@ -185,7 +190,7 @@ def get_next_save_path(self, path): return f'{path[:under_idx]}_{new_file_id}.{file_extension}' - def save_image(self, array, save_folder = './capture', file_root = 'img_', append = 'ms', color = 'BF'): + def save_image(self, array, save_folder = './capture', file_root = 'img_', append = 'ms', color = 'BF', tail_id_mode = "increment"): """CAMERA FUNCTIONS save image (as array) to file """ @@ -213,29 +218,52 @@ def save_image(self, array, save_folder = './capture', file_root = 'img_', appen # else: # append = '' - # generate filename and save path string - initial_id = '_000001' - filename = file_root + append + initial_id + '.tiff' - path = save_folder + '/' + filename + if type(save_folder) == str: + save_folder = pathlib.Path(save_folder) - # Obtain next save path if current directory already exists - while os.path.exists(path): - path = self.get_next_save_path(path) + if file_root is None: + file_root = "" + + # generate filename and save path string + if tail_id_mode == "increment": + initial_id = '_000001' + filename = file_root + append + initial_id + '.tiff' + path = save_folder / filename + + # Obtain next save path if current directory already exists + while os.path.exists(path): + path = self.get_next_save_path(path) + + elif tail_id_mode == None: + filename = file_root + append + '.tiff' + path = save_folder / filename + + else: + raise Exception(f"tail_id_mode: {tail_id_mode} not implemented") + try: - cv2.imwrite(path, img.astype(np.uint8)) + cv2.imwrite(str(path), img.astype(np.uint8)) logger.info(f'[SCOPE API ] Saving Image to {path}') except: logger.exception("[SCOPE API ] Error: Unable to save. Perhaps save folder does not exist?") - def save_live_image(self, save_folder = './capture', file_root = 'img_', append = 'ms', color = 'BF'): + def save_live_image( + self, + save_folder = './capture', + file_root = 'img_', + append = 'ms', + color = 'BF', + tail_id_mode = "increment" + ): + """CAMERA FUNCTIONS Grab the current live image and save to file """ array = self.get_image() if array is False: return - self.save_image(array, save_folder, file_root, append, color) + self.save_image(array, save_folder, file_root, append, color, tail_id_mode) def get_max_width(self): """CAMERA FUNCTIONS diff --git a/lumaviewpro.kv b/lumaviewpro.kv index f70c1c07..58854bfc 100644 --- a/lumaviewpro.kv +++ b/lumaviewpro.kv @@ -1061,13 +1061,12 @@ Spinner: id: tiling_size_spinner sync_height: True - text: 'New' + text: '' font_size: '12dp' size_hint_y: None height: '30dp' text_autoupdate: True - on_text: root.select_tiling_size() - values: '1x1', '2x2', '3x3', '4x4', '5x5', '6x6', '7x7', '8x8', '9x9' + values: '' # # Z-stack @@ -1528,25 +1527,6 @@ disabled: True font_size: '12sp' - BoxLayout: - orientation: 'horizontal' - size_hint_y: None - height: '30dp' - Label: - text: 'Field of View' - size_hint_x: None - width: '120dp' - font_size: '12sp' - TextInput: - id: FOV_id - multiline: False - padding: ['5dp', (self.height-self.line_height)/2] - halign: 'center' - # input_filter: 'int' - text: 'tbd' - disabled: True - font_size: '12sp' - # Frame Size BoxLayout: orientation: 'horizontal' @@ -1961,34 +1941,6 @@ valign: 'middle' text_size: self.size - BoxLayout: - orientation: 'horizontal' - size_hint_y: None - height: '30dp' - spacing: '5dp' - FolderChooseBTN: - id: folder_btn - # size_hint_y: None - # height: '30dp' - # font_size: '12sp' - # text: 'Folder' - border: 0, 0, 0, 0 - background_normal: './data/icons/folder.png' # Press to Select Folder - background_down: './data/icons/folder_down.png' - size_hint: None, None - size: '30dp', '30dp' - on_release: self.choose(root.layer) - TextInput: - id: root_text - # size_hint_y: None - # height: '30dp' - font_size: '12sp' - multiline: False - text: 'File Root' - padding: ['5dp', (self.height-self.line_height)/2] - on_text: root.root_text() - hint_text: 'Filename Root' - Label: : diff --git a/lumaviewpro.py b/lumaviewpro.py index 14c26921..5c91864e 100644 --- a/lumaviewpro.py +++ b/lumaviewpro.py @@ -38,6 +38,7 @@ ''' # General +import datetime import os import pathlib import numpy as np @@ -107,6 +108,9 @@ from image_stitcher import image_stitcher from modules.video_builder import VideoBuilder +from modules.tiling_config import TilingConfig +import modules.common_utils as common_utils + import cv2 # Hardware @@ -223,44 +227,49 @@ def get_well_label(self): except: logger.exception('[LVP Main ] Error talking to Motor board.') raise - + x_target, y_target = protocol_settings.stage_to_plate(x_target, y_target) - well_x, well_y = current_labware.get_well_index(x_target, y_target) - letter = chr(ord('A') + well_y) - return f'{letter}{well_x + 1}' + return current_labware.get_well_label(x=x_target, y=y_target) + def live_capture(self): - print("Custom capture") + print("Live capture") logger.info('[LVP Main ] CompositeCapture.live_capture()') global lumaview - save_folder = settings['live_folder'] + save_folder = pathlib.Path(settings['live_folder']) / "Manual" + save_folder.mkdir(parents=True, exist_ok=True) file_root = 'live_' color = 'BF' well_label = self.get_well_label() - append = f'{well_label}_{color}' - layers = ['BF', 'PC', 'EP', 'Blue', 'Green', 'Red'] - for layer in layers: + for layer in common_utils.get_layers(): accordion = layer + '_accordion' if lumaview.ids['imagesettings_id'].ids[accordion].collapse == False: - # Get the custom layer string value and remove any surrounding whitespace/underscores - custom_layer_str = lumaview.ids['imagesettings_id'].ids[layer].ids['root_text'].text - custom_layer_str = custom_layer_str.strip("_ ") - - append = f'{well_label}_{custom_layer_str}' + append = f'{well_label}_{layer}' if lumaview.ids['imagesettings_id'].ids[layer].ids['false_color'].active: color = layer - break + break # lumaview.scope.get_image() lumaview.scope.save_live_image(save_folder, file_root, append, color) - def custom_capture(self, channel, illumination, gain, exposure, false_color = True): + def custom_capture( + self, + save_folder, + channel, + illumination, + gain, + exposure, + false_color = True, + tile_label = None, + z_height_idx = None, + scan_count = None + ): print("Custom capture") logger.info('[LVP Main ] CompositeCapture.custom_capture()') global lumaview @@ -272,10 +281,15 @@ def custom_capture(self, channel, illumination, gain, exposure, false_color = Tr # Save Settings color = lumaview.scope.ch2color(channel) - save_folder = settings[color]['save_folder'] - file_root = settings[color]['file_root'] - well_label = self.get_well_label() - append = f'{well_label}_{color}' + # file_root = settings[color]['file_root'] + + name = common_utils.generate_default_step_name( + well_label=self.get_well_label(), + color=color, + z_height_idx=z_height_idx, + scan_count=scan_count, + tile_label=tile_label + ) # Illuminate if lumaview.scope.led: @@ -289,7 +303,13 @@ def custom_capture(self, channel, illumination, gain, exposure, false_color = Tr time.sleep(2*exposure/1000+0.2) use_color = color if false_color else 'BF' - lumaview.scope.save_live_image(save_folder, file_root, append, use_color) + lumaview.scope.save_live_image( + save_folder=save_folder, + file_root=None, + append=name, + color=use_color, + tail_id_mode=None + ) scope_leds_off() @@ -305,8 +325,7 @@ def composite_capture(self): scope_display = self.ids['viewer_id'].ids['scope_display_id'] img = np.zeros((settings['frame']['height'], settings['frame']['width'], 3)) - layers = ['BF', 'PC', 'EP', 'Blue', 'Green', 'Red'] - for layer in layers: + for layer in common_utils.get_layers(): if settings[layer]['acquire'] == True: # Go to focus and wait for arrival @@ -1104,7 +1123,7 @@ def __init__(self, **kwargs): self.post = post_processing.PostProcessing() #global settings #stitching params (see more info in image_stitcher.py): - #self.raw_images_folder = settings['live_folder'] # I'm guessing not ./capture/ because that would have frames over time already (to make video) + #self.raw_images_folder = settings['save_folder'] # I'm guessing not ./capture/ because that would have frames over time already (to make video) self.raw_images_folder = './capture/' # I'm guessing not ./capture/ because that would have frames over time already (to make video) self.combine_colors = False #True if raw images are in separate red/green/blue channels and need to be first combined self.ext = "tiff" #or read it from settings? @@ -1116,10 +1135,21 @@ def __init__(self, **kwargs): self.pos2pix = 2630 # relevant if stitching method is position. The scale conversion for pos info into pixels - self.tiling_target = [] - self.tiling_min = [120000, 80000] - self.tiling_max = [0, 0] - self.tiling_count = [1, 1] + # self.tiling_target = [] + self.tiling_min = { + "x": 120000, + "y": 80000 + } + + self.tiling_max = { + "x": 0, + "y": 0 + } + + self.tiling_count = { + "x": 1, + "y": 1 + } self.accordion_item_states = { 'cell_count_accordion_id': None, @@ -1383,8 +1413,7 @@ def toggle_settings(self): if self.ids['toggle_imagesettings'].state == 'normal': self.pos = lumaview.width - 30, 0 - layers = ['BF', 'PC', 'EP', 'Blue', 'Green', 'Red'] - for layer in layers: + for layer in common_utils.get_layers(): Clock.unschedule(lumaview.ids['imagesettings_id'].ids[layer].ids['histo_id'].histogram) logger.info('[LVP Main ] Clock.unschedule(lumaview...histogram)') else: @@ -1394,8 +1423,7 @@ def toggle_settings(self): scope_display.start() def update_transmitted(self): - layers = ['BF', 'PC', 'EP'] - for layer in layers: + for layer in common_utils.get_transmitted_layers(): accordion = layer + '_accordion' # Remove 'Colorize' option in transmitted channels control @@ -1416,8 +1444,7 @@ def accordion_collapse(self): scope_leds_off() # turn off all LED toggle buttons and histograms - layers = ['BF', 'PC', 'EP', 'Blue', 'Green', 'Red'] - for layer in layers: + for layer in common_utils.get_layers(): lumaview.ids['imagesettings_id'].ids[layer].ids['apply_btn'].state = 'normal' Clock.unschedule(lumaview.ids['imagesettings_id'].ids[layer].ids['histo_id'].histogram) logger.info('[LVP Main ] Clock.unschedule(lumaview...histogram)') @@ -2053,16 +2080,36 @@ def __init__(self, **kwargs): self.labware = json.load(read_file) read_file.close() + + self.step_names = list() - self.step_values = [] + self.step_values = np.empty((0,10), float) self.curr_step = 0 # TODO isn't step 1 indexed? Why is is 0? + + self.z_height_map = {} - self.tiling_target = [] - self.tiling_min = [120000, 80000] - self.tiling_max = [0, 0] - self.tiling_count = [1, 1] + self.tiling_config = TilingConfig() + self.tiling_min = { + "x": 120000, + "y": 80000 + } + self.tiling_max = { + "x": 0, + "y": 0 + } + + self.tiling_count = self.tiling_config.get_mxn_size(self.tiling_config.default_config()) + + self.scan_count = 0 self.exposures = 1 # 1 indexed + Clock.schedule_once(self._init_ui, 0) + + + def _init_ui(self, dt=0): + self.ids['tiling_size_spinner'].values = self.tiling_config.available_configs() + self.ids['tiling_size_spinner'].text = self.tiling_config.default_config() + # Update Protocol Period def update_period(self): @@ -2185,44 +2232,68 @@ def new_protocol(self): current_labware.load_plate(settings['protocol']['labware']) current_labware.set_positions() - tiling_pos_list = self.get_tile_centers() + tiles = self.tiling_config.get_tile_centers( + config_label=self.ids['tiling_size_spinner'].text, + focal_length=settings['objective']['focal_length'], + frame_size=settings['frame'], + fill_factor=0.85 + ) self.step_names = list() - self.step_values = [] + self.step_values = np.empty((0,10), float) - # Iterate through all the positions in the scan + # Iterate through all the positions in the scan for pos in current_labware.pos_list: - for tile in tiling_pos_list: + for tile_label, tile_position in tiles.items(): # Iterate through all the colors to create the steps - layers = ['BF', 'PC', 'EP', 'Blue', 'Green', 'Red'] - for layer in layers: - if settings[layer]['acquire'] == True: - - x = pos[0] + tile[0]/1000 # in 'plate' coordinates - y = pos[1] + tile[1]/1000 # in 'plate' coordinates - z = settings[layer]['focus'] - af = settings[layer]['autofocus'] - ch = lumaview.scope.color2ch(layer) - fc = settings[layer]['false_color'] - ill = settings[layer]['ill'] - gain = settings[layer]['gain'] - auto_gain = int(settings[layer]['auto_gain']) - exp = settings[layer]['exp'] - - self.step_values.append([x, y, z, af, ch, fc, ill, gain, auto_gain, exp]) - - self.step_values = np.array(self.step_values) + for layer in common_utils.get_layers(): + if settings[layer]['acquire'] == False: + continue + + PRECISION = 2 + x = round(pos[0] + tile_position["x"]/1000, PRECISION) # in 'plate' coordinates + y = round(pos[1] + tile_position["y"]/1000, PRECISION) # in 'plate' coordinates + z = settings[layer]['focus'] + af = settings[layer]['autofocus'] + ch = lumaview.scope.color2ch(layer) + fc = settings[layer]['false_color'] + ill = settings[layer]['ill'] + gain = settings[layer]['gain'] + auto_gain = int(settings[layer]['auto_gain']) + exp = settings[layer]['exp'] + + z_height_idx = self.z_height_map.get(z, None) + + step_name = common_utils.generate_default_step_name( + well_label=current_labware.get_well_label(x=x, y=y), + color=layer, + z_height_idx=z_height_idx, + scan_count=None, + tile_label=tile_label + ) + self.step_names.append(step_name) + + self.step_values = np.append(self.step_values, np.array([[x, y, z, af, ch, fc, ill, gain, auto_gain, exp]]), axis=0) + + # self.step_values = np.array(self.step_values) # Number of Steps length = self.step_values.shape[0] # Update text with current step and number of steps in protocol self.curr_step = 0 # start at the first step - self.ids['step_number_input'].text = str(self.curr_step+1) + + if len(self.step_names) > 0: + self.ids['step_name_input'].text = self.step_names[self.curr_step] + self.ids['step_number_input'].text = str(self.curr_step+1) + else: + self.ids['step_number_input'].text = '0' + self.ids['step_name_input'].text = '' + self.ids['step_total_input'].text = str(length) # self.step_names = [self.ids['step_name_input'].text] * length - self.step_names = [''] * length + # self.step_names = [''] * length settings['protocol']['filepath'] = '' self.ids['protocol_filename'].text = '' @@ -2243,7 +2314,7 @@ def _validate_labware(self, labware: str): # Load Protocol from File - def load_protocol(self, filepath="./data/example_protocol.tsv"): + def load_protocol(self, filepath="./data/new_default_protocol.tsv"): logger.info('[LVP Main ] ProtocolSettings.load_protocol()') # Load protocol @@ -2252,7 +2323,7 @@ def load_protocol(self, filepath="./data/example_protocol.tsv"): verify = next(csvreader) if not (verify[0] == 'LumaViewPro Protocol'): return - period = next(csvreader) + period = next(csvreader) period = float(period[1]) duration = next(csvreader) duration = float(duration[1]) @@ -2267,23 +2338,42 @@ def load_protocol(self, filepath="./data/example_protocol.tsv"): header = next(csvreader) # skip a line self.step_names = list() - self.step_values = [] + self.step_values = np.empty((0,10), float) for row in csvreader: self.step_names.append(row[0]) - self.step_values.append(row[1:]) + self.step_values = np.append(self.step_values, np.array([row[1:]]), axis=0) file_pointer.close() - self.step_values = np.array(self.step_values) - self.step_values = self.step_values.astype(float) + # self.step_values = np.array(self.step_values) + + # Index and build a map of Z-heights. Indicies will be used in step/file naming + # Only build the height map if we have at least 2 heights in the protocol. + # Otherwise, we don't want "_Z" added to the name + z_heights = sorted(set(self.step_values[:,2].astype('float').tolist())) + if len(z_heights) >= 2: + self.z_height_map = {z_height: idx for idx, z_height in enumerate(z_heights)} + + # Extract tiling config from step names + tiling_config_label = self.tiling_config.determine_tiling_label_from_names(names=self.step_names) + if tiling_config_label is not None: + self.ids['tiling_size_spinner'].text = tiling_config_label + else: + self.ids['tiling_size_spinner'].text = self.tiling_config.no_tiling_label() settings['protocol']['filepath'] = filepath self.ids['protocol_filename'].text = os.path.basename(filepath) # Update GUI self.curr_step = 0 # start at first step - self.ids['step_number_input'].text = str(self.curr_step+1) - self.ids['step_name_input'].text = '' + + if len(self.step_names) > 0: + self.ids['step_name_input'].text = self.step_names[self.curr_step] + self.ids['step_number_input'].text = str(self.curr_step+1) + else: + self.ids['step_number_input'].text = '0' + self.ids['step_name_input'].text = '' + self.ids['step_total_input'].text = str(len(self.step_names)) self.ids['capture_period'].text = str(period) self.ids['capture_dur'].text = str(duration) @@ -2292,12 +2382,15 @@ def load_protocol(self, filepath="./data/example_protocol.tsv"): settings['protocol']['period'] = period settings['protocol']['duration'] = duration settings['protocol']['labware'] = labware - + # Update Labware Selection in Spinner self.ids['labware_spinner'].text = settings['protocol']['labware'] + + self.go_to_step() + # Save Protocol to File - def save_protocol(self, filepath=''): + def save_protocol(self, filepath='', update_protocol_filepath: bool = True): logger.info('[LVP Main ] ProtocolSettings.save_protocol()') # Gather information @@ -2307,7 +2400,7 @@ def save_protocol(self, filepath=''): self.step_names self.step_values - if len(filepath)==0: + if (type(filepath) == str) and len(filepath)==0: # If there is no current file path, "save" button will act as "save as" if len(settings['protocol']['filepath']) == 0: FileSaveBTN_instance=FileSaveBTN() @@ -2315,9 +2408,11 @@ def save_protocol(self, filepath=''): return filepath = settings['protocol']['filepath'] else: - settings['protocol']['filepath'] = filepath - if filepath[-4:].lower() != '.tsv': + if update_protocol_filepath: + settings['protocol']['filepath'] = filepath + + if (type(filepath) == str) and (filepath[-4:].lower() != '.tsv'): filepath = filepath+'.tsv' self.ids['protocol_filename'].text = os.path.basename(filepath) @@ -2397,18 +2492,30 @@ def go_to_step(self): return # Extract Values from protocol list and array - name = str(self.step_names[self.curr_step]) - x = self.step_values[self.curr_step, 0] - y = self.step_values[self.curr_step, 1] - z = self.step_values[self.curr_step, 2] - af = self.step_values[self.curr_step, 3] - ch = self.step_values[self.curr_step, 4] - fc = self.step_values[self.curr_step, 5] - ill = self.step_values[self.curr_step, 6] - gain = self.step_values[self.curr_step, 7] - auto_gain = self.step_values[self.curr_step, 8] - exp = self.step_values[self.curr_step, 9] - + name = str(self.step_names[self.curr_step]) + x = self.step_values[self.curr_step, 0] + y = self.step_values[self.curr_step, 1] + z = self.step_values[self.curr_step, 2] + af = self.step_values[self.curr_step, 3] + ch = self.step_values[self.curr_step, 4] + fc = self.step_values[self.curr_step, 5] + ill = self.step_values[self.curr_step, 6] + gain = self.step_values[self.curr_step, 7] + auto_gain = self.step_values[self.curr_step, 8] + exp = self.step_values[self.curr_step, 9] + + if 'numpy' in str(type(x)): + x = x.astype(float) + y = y.astype(float) + z = z.astype(float) + af = bool(af.astype(float)) + ch = int(ch.astype(float)) + fc = bool(fc.astype(float)) + ill = ill.astype(float) + gain = gain.astype(float) + auto_gain = bool(auto_gain.astype(float)) + exp = exp.astype(float) + self.ids['step_name_input'].text = name # Convert plate coordinates to stage coordinates @@ -2479,10 +2586,14 @@ def delete_step(self): self.step_names.pop(self.curr_step) self.step_values = np.delete(self.step_values, self.curr_step, axis = 0) - self.curr_step = self.curr_step - 1 + self.curr_step = max(self.curr_step - 1, 0) # Update total number of steps to GUI self.ids['step_total_input'].text = str(len(self.step_names)) + + if len(self.step_names) == 0: + self.ids['step_number_input'].text = '0' + self.next_step() # Modify Current Step of Protocol @@ -2508,8 +2619,7 @@ def modify_step(self): c_layer = False - layers = ['BF', 'PC', 'EP', 'Blue', 'Green', 'Red'] - for layer in layers: + for layer in common_utils.get_layers(): accordion = layer + '_accordion' if lumaview.ids['imagesettings_id'].ids[accordion].collapse == False: c_layer = layer @@ -2541,8 +2651,7 @@ def insert_step(self): name = self.ids['step_name_input'].text c_layer = False - layers = ['BF', 'PC', 'EP', 'Blue', 'Green', 'Red'] - for layer in layers: + for layer in common_utils.get_layers(): accordion = layer + '_accordion' if lumaview.ids['imagesettings_id'].ids[accordion].collapse == False: c_layer = layer @@ -2559,16 +2668,17 @@ def insert_step(self): sy = lumaview.scope.get_current_position('Y') px, py = self.stage_to_plate(sx, sy) - step = [px, # x - py, # y - lumaview.scope.get_current_position('Z'),# z - int(layer_id.ids['autofocus'].active), # autofocus - ch, # ch - int(layer_id.ids['false_color'].active), # false color - layer_id.ids['ill_slider'].value, # ill - layer_id.ids['gain_slider'].value, # gain - int(layer_id.ids['auto_gain'].active), # auto_gain - layer_id.ids['exp_slider'].value, # exp + step = [ + px, # x + py, # y + lumaview.scope.get_current_position('Z'),# z + int(layer_id.ids['autofocus'].active), # autofocus + ch, # ch + int(layer_id.ids['false_color'].active), # false color + layer_id.ids['ill_slider'].value, # ill + layer_id.ids['gain_slider'].value, # gain + int(layer_id.ids['auto_gain'].active), # auto_gain + layer_id.ids['exp_slider'].value # exp ] # Insert into List and Array @@ -2577,74 +2687,90 @@ def insert_step(self): self.ids['step_total_input'].text = str(len(self.step_names)) + # Handle special case for inserting a step from an empty protocol + if len(self.step_names) == 1: + self.ids['step_number_input'].text = '1' + self.go_to_step() + # # Tiling # --------------------------- # - def select_tiling_size(self): - logger.debug('[LVP Main ] PostProcessing.select_tiling_size() partially implemented') - logger.info('[LVP Main ] ProtocolSettings.select_tiling_size()') - spinner = self.ids['tiling_size_spinner'] + # def select_tiling_size(self): + # logger.debug('[LVP Main ] PostProcessing.select_tiling_size() partially implemented') + # logger.info('[LVP Main ] ProtocolSettings.select_tiling_size()') + # spinner = self.ids['tiling_size_spinner'] #settings['protocol']['labware'] = spinner.text #TODO change key - x_count = int(spinner.text[0]) - y_count = int(spinner.text[0]) # For now, only squares are allowed so x_ = y_ - self.tiling_count = [x_count, y_count] - #print(self.x_tiling_count) - focal_length = settings['objective']['focal_length'] - magnification = 47.8 / focal_length # Etaluma tube focal length [mm] - # in theory could be different in different scopes - # could be looked up by model number - # although all are currently the same - pixel_width = 2.0 # [um/pixel] Basler pixel size (could be looked up from Camera class) - um_per_pixel = pixel_width / magnification - - fov_size_x = um_per_pixel * settings['frame']['width'] - fov_size_y = um_per_pixel * settings['frame']['height'] - fillfactor = 0.85 # this is to ensure some of the tile images overlap. - # TODO this should be a setting in the gui - #print(magnification) - #print(settings['frame']['width']) + # self.tiling_count = self.tiling_config.get_mxn_size(spinner.text) - x_fov = fillfactor * fov_size_x - y_fov = fillfactor * fov_size_y - #x_current = lumaview.scope.get_current_position('X') - #x_current = np.clip(x_current, 0, 120000) # prevents crosshairs from leaving the stage area - x_center = 60000 # TODO make center of a well - #y_current = lumaview.scope.get_current_position('Y') - #y_current = np.clip(y_current, 0, 80000) # prevents crosshairs from leaving the stage area - y_center = 40000 # TODO make center of a well - self.tiling_min = [x_center - x_count*x_fov/2, y_center - y_count*y_fov/2] - #print(self.tiling_min) - self.tiling_max = [x_center + x_count*x_fov/2, y_center + y_count*y_fov/2] + # # x_count = int(spinner.text[0]) + # # y_count = int(spinner.text[0]) # For now, only squares are allowed so x_ = y_ + # # self.tiling_count = (x_count, y_count) + # focal_length = settings['objective']['focal_length'] + # magnification = 47.8 / focal_length # Etaluma tube focal length [mm] + # # in theory could be different in different scopes + # # could be looked up by model number + # # although all are currently the same + # pixel_width = 2.0 # [um/pixel] Basler pixel size (could be looked up from Camera class) + # um_per_pixel = pixel_width / magnification + + # fov_size_x = um_per_pixel * settings['frame']['width'] + # fov_size_y = um_per_pixel * settings['frame']['height'] + # # fill_factor = 0.75 # this is to ensure some of the tile images overlap. + # # TODO this should be a setting in the gui + # fill_factor = 1 + # #print(magnification) + # #print(settings['frame']['width']) + + # x_fov = fill_factor * fov_size_x + # y_fov = fill_factor * fov_size_y + # #x_current = lumaview.scope.get_current_position('X') + # #x_current = np.clip(x_current, 0, 120000) # prevents crosshairs from leaving the stage area + # x_center = 60000 # TODO make center of a well + # #y_current = lumaview.scope.get_current_position('Y') + # #y_current = np.clip(y_current, 0, 80000) # prevents crosshairs from leaving the stage area + # y_center = 40000 # TODO make center of a well + # self.tiling_min = { + # "x": x_center - self.tiling_count["n"]*x_fov/2, + # "y": y_center - self.tiling_count["m"]*y_fov/2 + # } + + # #print(self.tiling_min) + # self.tiling_max = { + # "x": x_center + self.tiling_count["n"]*x_fov/2, + # "y": y_center + self.tiling_count["m"]*y_fov/2 + # } #print(self.tiling_max) - # DEPRICATED this talks to the wrong stage view + # DEPRECATED this talks to the wrong stage view #lumaview.ids['motionsettings_id'].ids['post_processing_id'].ids['tiling_stage_id'].ROI_count = self.tiling_count #lumaview.ids['motionsettings_id'].ids['post_processing_id'].ids['tiling_stage_id'].ROI_min = self.tiling_min #lumaview.ids['motionsettings_id'].ids['post_processing_id'].ids['tiling_stage_id'].ROI_max = self.tiling_max #print(lumaview.ids['motionsettings_id'].ids['post_processing_id'].ids['tiling_stage_id'].ROI_min) #print(lumaview.ids['motionsettings_id'].ids['post_processing_id'].ids['tiling_stage_id'].ROI_max) - return + # return - def start_tiling(self): - logger.debug('[LVP Main ] PostProcessing.start_tiling() not yet implemented') - return self.get_tile_centers() + # def start_tiling(self): + # logger.debug('[LVP Main ] PostProcessing.start_tiling() not yet implemented') + # return self.get_tile_centers() - def get_tile_centers(self): - logger.info('[LVP Main ] PostProcessing.get_tile_centers()') - tiles = [] - ax = (self.tiling_max[0] + self.tiling_min[0])/2 - ay = (self.tiling_max[1] + self.tiling_min[1])/2 - dx = (self.tiling_max[0] - self.tiling_min[0])/self.tiling_count[0] - dy = (self.tiling_max[1] - self.tiling_min[1])/self.tiling_count[1] - for i in range(self.tiling_count[0]): - for j in range(self.tiling_count[1]): - x = self.tiling_min[0] + (i+0.5)*dx - ax - y = self.tiling_min[1] + (j+0.5)*dy - ay - tiles.append([x, y]) - print(x,y) - return tiles + # def get_tile_centers(self): + # logger.info('[LVP Main ] PostProcessing.get_tile_centers()') + # tiles = [] + # ax = (self.tiling_max["x"] + self.tiling_min["x"])/2 + # ay = (self.tiling_max["y"] + self.tiling_min["y"])/2 + # dx = (self.tiling_max["x"] - self.tiling_min["x"])/self.tiling_count["x"] + # dy = (self.tiling_max["y"] - self.tiling_min["y"])/self.tiling_count["y"] + # for i in range(self.tiling_count["x"]): + # for j in range(self.tiling_count["y"]): + # tiles.append( + # { + # "x": self.tiling_min["x"] + (i+0.5)*dx - ax, + # "y": self.tiling_min["y"] + (j+0.5)*dy - ay + # } + # ) + # return tiles # Run one scan of protocol, autofocus at each step, and update protocol def run_zstack_scan(self): @@ -2696,8 +2822,7 @@ def run_autofocus_scan(self): # toggle all LEDs AND TOGGLE BUTTONS OFF scope_leds_off() - layers = ['BF', 'PC', 'EP', 'Blue', 'Green', 'Red'] - for layer in layers: + for layer in common_utils.get_layers(): lumaview.ids['imagesettings_id'].ids[layer].ids['apply_btn'].state = 'normal' logger.info('[LVP Main ] Clock.unschedule(self.autofocus_scan_iterate)') @@ -2760,6 +2885,15 @@ def autofocus_scan_iterate(self, dt): gain = self.step_values[self.curr_step, 7] # camera gain auto_gain = self.step_values[self.curr_step, 8] # camera autogain exp = self.step_values[self.curr_step, 9] # camera exposure + + # TODO fix these casts + if 'numpy' in str(type(ch)): + ch = int(ch.astype(float)) + fc = bool(fc.astype(float)) + ill = ill.astype(float) + gain = gain.astype(float) + auto_gain = bool(auto_gain.astype(float)) + exp = exp.astype(float) # set camera settings and turn on LED lumaview.scope.leds_off() @@ -2796,10 +2930,17 @@ def run_scan(self, protocol = False): # begin at current step set to 0 (curr_step = 0) self.curr_step = 0 self.ids['step_number_input'].text = str(self.curr_step+1) - + self.go_to_step() + x = self.step_values[self.curr_step, 0] y = self.step_values[self.curr_step, 1] z = self.step_values[self.curr_step, 2] + + # TODO fix these type casts depending on how these values are loaded + if 'numpy' in str(type(x)): + x = x.astype(float) + y = y.astype(float) + z = z.astype(float) # Convert plate coordinates to stage coordinates sx, sy = self.plate_to_stage(x, y) @@ -2819,8 +2960,7 @@ def run_scan(self, protocol = False): # toggle all LEDs AND TOGGLE BUTTONS OFF scope_leds_off() - layers = ['BF', 'PC', 'EP', 'Blue', 'Green', 'Red'] - for layer in layers: + for layer in common_utils.get_layers(): lumaview.ids['imagesettings_id'].ids[layer].ids['apply_btn'].state = 'normal' logger.info('[LVP Main ] Clock.unschedule(self.scan_iterate)') @@ -2843,59 +2983,88 @@ def scan_iterate(self, dt): y_status = lumaview.scope.get_target_status('Y') z_status = lumaview.scope.get_target_status('Z') - # If target location has been reached - if x_status and y_status and z_status and not lumaview.scope.get_overshoot(): - logger.info('[LVP Main ] Scan Step:' + str(self.step_names[self.curr_step])) - - # identify image settings - af = self.step_values[self.curr_step, 3] # autofocus - ch = self.step_values[self.curr_step, 4] # LED channel - fc = self.step_values[self.curr_step, 5] # image false color - ill = self.step_values[self.curr_step, 6] # LED illumination - gain = self.step_values[self.curr_step, 7] # camera gain - auto_gain = self.step_values[self.curr_step, 8] # camera autogain - exp = self.step_values[self.curr_step, 9] # camera exposure - - # Set camera settings - lumaview.scope.set_gain(gain) - lumaview.scope.set_auto_gain(bool(auto_gain)) - lumaview.scope.set_exposure_time(exp) - - # If the autofocus is selected, is not currently running and has not completed, begin autofocus - if af and not is_complete: - # turn on LED - lumaview.scope.leds_off() - lumaview.scope.led_on(ch, ill) - - # Begin autofocus routine - lumaview.ids['motionsettings_id'].ids['verticalcontrol_id'].ids['autofocus_id'].state = 'down' - lumaview.ids['motionsettings_id'].ids['verticalcontrol_id'].autofocus() - - return + # Check if target location has not been reached yet + if (not x_status) or (not y_status) or (not z_status) or lumaview.scope.get_overshoot(): + return + + logger.info('[LVP Main ] Scan Step:' + str(self.step_names[self.curr_step])) + + # identify image settings + z_height = self.step_values[self.curr_step, 2] # Z-height + af = self.step_values[self.curr_step, 3] # autofocus + ch = self.step_values[self.curr_step, 4] # LED channel + fc = self.step_values[self.curr_step, 5] # image false color + ill = self.step_values[self.curr_step, 6] # LED illumination + gain = self.step_values[self.curr_step, 7] # camera gain + auto_gain = self.step_values[self.curr_step, 8] # camera autogain + exp = self.step_values[self.curr_step, 9] # camera exposure + + # TODO fix these casts + if 'numpy' in str(type(z_height)): + z_height = z_height.astype(float) + af = bool(af.astype(float)) + ch = int(ch.astype(float)) + fc = bool(fc.astype(float)) + ill = ill.astype(float) + gain = gain.astype(float) + auto_gain = bool(auto_gain.astype(float)) + exp = exp.astype(float) - # reset the is_complete flag on autofocus - lumaview.ids['motionsettings_id'].ids['verticalcontrol_id'].is_complete = False + # Set camera settings + lumaview.scope.set_gain(gain) + lumaview.scope.set_auto_gain(bool(auto_gain)) + lumaview.scope.set_exposure_time(exp) + + # If the autofocus is selected, is not currently running and has not completed, begin autofocus + if af and not is_complete: + # turn on LED + lumaview.scope.leds_off() + lumaview.scope.led_on(ch, ill) - # capture image - self.custom_capture(ch, ill, gain, exp, bool(fc)) + # Begin autofocus routine + lumaview.ids['motionsettings_id'].ids['verticalcontrol_id'].ids['autofocus_id'].state = 'down' + lumaview.ids['motionsettings_id'].ids['verticalcontrol_id'].autofocus() + + return + + # reset the is_complete flag on autofocus + lumaview.ids['motionsettings_id'].ids['verticalcontrol_id'].is_complete = False + + z_height_idx = self.z_height_map.get(z_height, None) + + tile_label = common_utils.get_tile_label_from_name(name=self.step_names[self.curr_step]) + + # capture image + self.custom_capture( + save_folder=self.protocol_run_dir, + channel=ch, + illumination=ill, + gain=gain, + exposure=exp, + false_color=bool(fc), + tile_label=tile_label, + z_height_idx=z_height_idx, + scan_count=self.scan_count + ) - # increment to the next step - self.curr_step += 1 + # increment to the next step + self.curr_step += 1 - if self.curr_step < len(self.step_names): + if self.curr_step < len(self.step_names): - # Update Step number text - self.ids['step_number_input'].text = str(self.curr_step+1) - self.go_to_step() + # Update Step number text + self.ids['step_number_input'].text = str(self.curr_step+1) + self.go_to_step() - # if all positions have already been reached - else: - logger.info('[LVP Main ] Scan Complete') - self.ids['run_scan_btn'].state = 'normal' - self.ids['run_scan_btn'].text = 'Run One Scan' + # if all positions have already been reached + else: + self.scan_count += 1 + logger.info('[LVP Main ] Scan Complete') + self.ids['run_scan_btn'].state = 'normal' + self.ids['run_scan_btn'].text = 'Run One Scan' - logger.info('[LVP Main ] Clock.unschedule(self.scan_iterate)') - Clock.unschedule(self.scan_iterate) # unschedule all copies of scan iterate + logger.info('[LVP Main ] Clock.unschedule(self.scan_iterate)') + Clock.unschedule(self.scan_iterate) # unschedule all copies of scan iterate # Run protocol without xy movement def run_stationary(self): @@ -2907,17 +3076,50 @@ def run_stationary(self): self.ids['run_stationary_btn'].text = 'Run Stationary Protocol' # 'normal' + @staticmethod + def _create_protocol_run_folder(parent_dir: str | pathlib.Path): + now = datetime.datetime.now() + time_string = now.strftime("%Y%m%d_%H%M%S") + parent_dir = pathlib.Path(parent_dir) + protocol_run_dir = parent_dir / time_string + protocol_run_dir.mkdir(exist_ok=True) + return protocol_run_dir + + # Run the complete protocol def run_protocol(self): logger.info('[LVP Main ] ProtocolSettings.run_protocol()') self.n_scans = int(float(settings['protocol']['duration'])*60 / float(settings['protocol']['period'])) + self.scan_count = 0 self.start_t = time.time() # start of cycle in seconds if self.ids['run_protocol_btn'].state == 'down': + + if os.path.basename(settings['protocol']['filepath']) == "": + protocol_filename = "unsaved_protocol.tsv" + else: + protocol_filename = os.path.basename(settings['protocol']['filepath']) + + # Create the folder to save the protocol captures and protocol itself + save_folder = pathlib.Path(settings['live_folder']) / "ProtocolData" + save_folder.mkdir(parents=True, exist_ok=True) + self.protocol_run_dir = self._create_protocol_run_folder(parent_dir=save_folder) + protocol_filepath = self.protocol_run_dir / protocol_filename + self.save_protocol( + filepath=protocol_filepath, + update_protocol_filepath=False + ) + logger.info('[LVP Main ] Clock.unschedule(self.scan_iterate)') Clock.unschedule(self.scan_iterate) # unschedule all copies of scan iterate self.run_scan(protocol = True) logger.info('[LVP Main ] Clock.schedule_interval(self.protocol_iterate, 1)') + + # Move to first step when starting run + self.curr_step = 0 + self.ids['step_number_input'].text = str(self.curr_step+1) + self.go_to_step() + Clock.schedule_interval(self.protocol_iterate, 1) else: @@ -2930,6 +3132,7 @@ def run_protocol(self): # self.protocol_event.cancel() scope_leds_off() + def protocol_iterate(self, dt): logger.info('[LVP Main ] ProtocolSettings.protocol_iterate()') @@ -3197,6 +3400,7 @@ def load_settings(self, filename="./data/current.json"): else: try: settings = json.load(read_file) + # update GUI values from JSON data: self.ids['scope_spinner'].text = settings['microscope'] self.ids['objective_spinner'].text = settings['objective']['ID'] @@ -3223,13 +3427,11 @@ def load_settings(self, filename="./data/current.json"): else: zstack_settings.ids['zstack_steps_id'].text = '0' - layers = ['BF', 'PC', 'EP', 'Blue', 'Green', 'Red'] - for layer in layers: + for layer in common_utils.get_layers(): lumaview.ids['imagesettings_id'].ids[layer].ids['ill_slider'].value = settings[layer]['ill'] lumaview.ids['imagesettings_id'].ids[layer].ids['gain_slider'].value = settings[layer]['gain'] lumaview.ids['imagesettings_id'].ids[layer].ids['exp_slider'].value = settings[layer]['exp'] # lumaview.ids['imagesettings_id'].ids[layer].ids['exp_slider'].value = float(np.log10(settings[layer]['exp'])) - lumaview.ids['imagesettings_id'].ids[layer].ids['root_text'].text = settings[layer]['file_root'] lumaview.ids['imagesettings_id'].ids[layer].ids['false_color'].active = settings[layer]['false_color'] lumaview.ids['imagesettings_id'].ids[layer].ids['acquire'].active = settings[layer]['acquire'] lumaview.ids['imagesettings_id'].ids[layer].ids['autofocus'].active = settings[layer]['autofocus'] @@ -3309,8 +3511,8 @@ def frame_size(self): w = int(self.ids['frame_width_id'].text) h = int(self.ids['frame_height_id'].text) - width = int(min(int(w), lumaview.scope.get_max_width())/4)*4 - height = int(min(int(h), lumaview.scope.get_max_height())/4)*4 + width = int(min(w, lumaview.scope.get_max_width())/4)*4 + height = int(min(h, lumaview.scope.get_max_height())/4)*4 settings['frame']['width'] = width settings['frame']['height'] = height @@ -3420,10 +3622,6 @@ def exp_text(self): self.apply_settings() - def root_text(self): - logger.info('[LVP Main ] LayerControl.root_text()') - settings[self.layer]['file_root'] = self.ids['root_text'].text - def false_color(self): logger.info('[LVP Main ] LayerControl.false_color()') settings[self.layer]['false_color'] = self.ids['false_color'].active @@ -3467,10 +3665,8 @@ def apply_settings(self): else: logger.warning('[LVP Main ] LED controller not available.') - # turn the state of remaining channels to 'normal' and text to 'OFF' - layers = ['BF', 'PC', 'EP', 'Blue', 'Green', 'Red'] - - for layer in layers: + # turn the state of remaining channels to 'normal' and text to 'OFF' + for layer in common_utils.get_layers(): Clock.unschedule(lumaview.ids['imagesettings_id'].ids[layer].ids['histo_id'].histogram) if layer == self.layer: Clock.schedule_interval(lumaview.ids['imagesettings_id'].ids[self.layer].ids['histo_id'].histogram, 0.5) @@ -3660,14 +3856,10 @@ class FolderChooseBTN(Button): def choose(self, context): logger.info(f'[LVP Main ] FolderChooseBTN.choose({context})') self.context = context - logger.info(f"Settings: {settings}") # Show previously selected/default folder - selected_path = None - if (context == 'live_folder') and ('live_folder' in settings): - selected_path = settings['live_folder'] - elif context in settings: - selected_path = settings[context]['save_folder'] + selected_path = settings['live_folder'] + # Note: Could likely use tkinter filedialog for all platforms # works on windows and MacOSX @@ -3716,9 +3908,9 @@ def on_selection_function(self, *a, **k): cell_count_content.apply_method_to_folder( path=path ) + else: + raise Exception(f"on_selection_function(): Unknown selection {self.context}") - else: # Channel Save Folder selections - settings[self.context]['save_folder'] = path # Button the triggers 'filechooser.save_file()' from plyer class FileSaveBTN(Button): diff --git a/modules/common_utils.py b/modules/common_utils.py new file mode 100644 index 00000000..c3cacc8d --- /dev/null +++ b/modules/common_utils.py @@ -0,0 +1,39 @@ + +def generate_default_step_name( + well_label, + color, + z_height_idx = None, + scan_count = None, + tile_label = None +): + name = f"{well_label}_{color}" + + if z_height_idx not in (None, ""): + name = f"{name}_Z{z_height_idx}" + + DESIRED_SCAN_COUNT_DIGITS = 6 + if scan_count not in (None, ""): + name = f'{name}_{scan_count:0>{DESIRED_SCAN_COUNT_DIGITS}}' + + if tile_label not in (None, ""): + name = f"{name}_T{tile_label}" + + return name + + +def get_tile_label_from_name(name: str) -> str | None: + name = name.split('_') + + last_segment = name[-1] + if last_segment.startswith('T'): + return last_segment[1:] + + return None + + +def get_layers() -> list[str]: + return ['BF', 'PC', 'EP', 'Blue', 'Green', 'Red'] + + +def get_transmitted_layers() -> list[str]: + return ['BF', 'PC', 'EP'] diff --git a/modules/tiling_config.py b/modules/tiling_config.py new file mode 100644 index 00000000..7b68215c --- /dev/null +++ b/modules/tiling_config.py @@ -0,0 +1,145 @@ + +import itertools +import json + +import modules.common_utils as common_utils + +class TilingConfig: + + def __init__(self): + + with open('./data/tiling.json', "r") as fp: + self._available_configs = json.load(fp) + + + def available_configs(self) -> list[str]: + return list(self._available_configs['data'].keys()) + + + def get_mxn_size(self, config_label: str) -> dict: + return self._available_configs['data'][config_label] + + + def get_label_from_mxn_size(self, m: int, n: int) -> str | None: + for config_label, config_data in self._available_configs['data'].items(): + if (config_data['m'] == m) and (config_data['n'] == n): + return config_label + + return None + + + def determine_tiling_label_from_names(self, names: list): + label_letters = set() + label_numbers = set() + for name in names: + label = common_utils.get_tile_label_from_name(name=name) + if label is None: + continue + + label_letter = label[0] + label_number = int(label[1:]) + + label_letters.add(label_letter) + label_numbers.add(label_number) + + m = len(label_letters) + n = len(label_numbers) + if m != n: + raise Exception(f"Tiling configuration requires equal dimensions, but found {m}x{n}") + + return self.get_label_from_mxn_size(m=m, n=n) + + + def default_config(self) -> str: + return self._available_configs["metadata"]["default"] + + + def no_tiling_label(self) -> str: + return "1x1" + + + def _calc_range( + self, + config_label: str, + focal_length: float, + frame_size: dict[int], + fill_factor: int + ) -> dict[dict]: + tiling_mxn = self.get_mxn_size(config_label) + + magnification = 47.8 / focal_length # Etaluma tube focal length [mm] + # in theory could be different in different scopes + # could be looked up by model number + # although all are currently the same + pixel_width = 2.0 # [um/pixel] Basler pixel size (could be looked up from Camera class) + um_per_pixel = pixel_width / magnification + + fov_size_x = um_per_pixel * frame_size['width'] + fov_size_y = um_per_pixel * frame_size['height'] + x_fov = fill_factor * fov_size_x + y_fov = fill_factor * fov_size_y + #x_current = lumaview.scope.get_current_position('X') + #x_current = np.clip(x_current, 0, 120000) # prevents crosshairs from leaving the stage area + x_center = 60000 # TODO make center of a well + #y_current = lumaview.scope.get_current_position('Y') + #y_current = np.clip(y_current, 0, 80000) # prevents crosshairs from leaving the stage area + y_center = 40000 # TODO make center of a well + tiling_min = { + "x": x_center - tiling_mxn["n"]*x_fov/2, + "y": y_center - tiling_mxn["m"]*y_fov/2 + } + + tiling_max = { + "x": x_center + tiling_mxn["n"]*x_fov/2, + "y": y_center + tiling_mxn["m"]*y_fov/2 + } + + return { + 'mxn': tiling_mxn, + 'min': tiling_min, + 'max': tiling_max, + } + + + def get_tile_centers( + self, + config_label: str, + focal_length: float, + frame_size: dict[int], + fill_factor: int + ) -> dict: + ranges = self._calc_range( + config_label=config_label, + focal_length=focal_length, + frame_size=frame_size, + fill_factor=fill_factor + ) + + tiling_mxn = ranges['mxn'] + tiling_min = ranges['min'] + tiling_max = ranges['max'] + + tiles = {} + ax = (tiling_max["x"] + tiling_min["x"])/2 + ay = (tiling_max["y"] + tiling_min["y"])/2 + dx = (tiling_max["x"] - tiling_min["x"])/tiling_mxn["n"] + dy = (tiling_max["y"] - tiling_min["y"])/tiling_mxn["m"] + + PRECISION = 2 # Digits + + for i, j in itertools.product(range(tiling_mxn["m"]), range(tiling_mxn["n"])): + + if (tiling_mxn["m"] == 1) and (tiling_mxn["n"] == 1): + # Handle special case where tiling is 1x1 (i.e. no tiling) + tile_label = "" + else: + row_letter = chr(i+ord('A')) + col_number = j+1 + tile_label = f"{row_letter}{col_number}" + + tiles[tile_label] = { + "x": round(tiling_min["x"] + (j+0.5)*dx - ax, PRECISION), + "y": round(tiling_min["y"] + (i+0.5)*dy - ay, PRECISION) + } + + return tiles