From 141dc3cde6c186f10747d6bdf306fef8143e4255 Mon Sep 17 00:00:00 2001 From: jmcoreymv Date: Thu, 18 Apr 2024 17:05:03 -0700 Subject: [PATCH] Z-Stacking features, Video generation features, others (#361) --- .gitignore | 5 +- data/objectives.json | 329 ++++++++---- data/settings.json | 15 +- image_utils.py | 51 ++ lumascope_api.py | 8 +- lumaviewpro.kv | 102 +++- lumaviewpro.py | 577 ++++++++++++--------- modules/artifact_locations.py | 32 ++ modules/common_utils.py | 22 +- modules/composite_generation.py | 102 +++- modules/objectives_loader.py | 52 ++ modules/protocol.py | 2 +- modules/protocol_execution_record.py | 12 +- modules/protocol_post_processing_helper.py | 336 ++++++++---- modules/stitcher.py | 148 ++++-- modules/video_builder.py | 274 ++++++---- modules/zstack_config.py | 6 +- 17 files changed, 1388 insertions(+), 685 deletions(-) create mode 100644 modules/artifact_locations.py create mode 100644 modules/objectives_loader.py diff --git a/.gitignore b/.gitignore index 9cae219..d642adf 100644 --- a/.gitignore +++ b/.gitignore @@ -4,10 +4,7 @@ data/current.json calls.txt logs/LVP_Log* logs/profile/* -capture/**/*.tiff -capture/**/*.tsv -capture/Autofocus Characterization/ -capture/Backlash Characterization/ +capture/ script_testing.py venv*/ build/ diff --git a/data/objectives.json b/data/objectives.json index 38197f1..2ea566b 100644 --- a/data/objectives.json +++ b/data/objectives.json @@ -1,122 +1,227 @@ { - "2.5x": { - "description": "2.5x", - "magnification": 2.5, - "system_magnification": 0.598, - "focal_length": 80.0, - "aperture": 0, - "DOF": 0, - "AF_min": 0, - "AF_max": 0, - "AF_range": 0, - "z_fine": 0, - "z_coarse": 0, - "xy_fine": 0, - "xy_coarse": 0 + "1.25x Oly": { + "description": "1.25x Plan Apochromat Olympus", + "magnification": 1.25, + "focal_length": 144.0, + "aperture": 0.04, + "DOF": 1, + "working_distance": 5, + "AF_min": 300, + "AF_max": 1000, + "AF_range": 3000, + "z_fine": 100, + "z_coarse": 1000, + "xy_fine": 300, + "xy_coarse": 3000 }, - "4x": { - "description": "4x", - "magnification": 4, - "system_magnification": 1.063, - "focal_length": 45.0, - "aperture": 0.1, - "DOF": 55.5, - "AF_min": 20.0, - "AF_max": 72.0, - "AF_range": 150.0, - "z_fine": 27.75, - "z_coarse": 277.5, - "xy_fine": 100.0, - "xy_coarse": 1000.0 + "2.5x Meiji": { + "description": "2.5x Plan Semi Apochromat Meiji", + "magnification": 2.5, + "focal_length": 80.0, + "aperture": 0.07, + "DOF": 100, + "working_distance": 5.7, + "AF_min": 50, + "AF_max": 350, + "AF_range": 1000, + "z_fine": 35, + "z_coarse": 350, + "xy_fine": 200, + "xy_coarse": 2000 }, - "10x": { - "description": "10x", - "magnification": 10, - "system_magnification": 2.656, - "focal_length": 18.0, - "aperture": 0.25, - "DOF": 8.5, - "AF_min": 8.0, - "AF_max": 36.0, - "AF_range": 75.0, - "z_fine": 4.25, - "z_coarse": 42.5, - "xy_fine": 40.0, - "xy_coarse": 400.0 + "4x Oly": { + "description": "4x U Plan Fl Olympus", + "magnification": 4, + "focal_length": 45.0, + "aperture": 0.13, + "DOF": 55.5, + "working_distance": 17, + "AF_min": 20.0, + "AF_max": 72.0, + "AF_range": 150.0, + "z_fine": 27.75, + "z_coarse": 277.5, + "xy_fine": 100.0, + "xy_coarse": 1000.0 }, - "20x": { - "description": "20x", - "magnification": 20, - "system_magnification": 5.311, - "focal_length": 9.0, - "aperture": 0.4, - "DOF": 5.8, - "AF_min": 2.0, - "AF_max": 18.0, - "AF_range": 40.0, - "z_fine": 2.9, - "z_coarse": 29.0, - "xy_fine": 20.0, - "xy_coarse": 200.0 + "10x Oly": { + "description": "10x U Plan Fl Olympus", + "magnification": 10, + "focal_length": 18.0, + "aperture": 0.3, + "DOF": 8.5, + "working_distance": 10, + "AF_min": 8.0, + "AF_max": 36.0, + "AF_range": 75.0, + "z_fine": 4.25, + "z_coarse": 42.5, + "xy_fine": 40.0, + "xy_coarse": 400.0 }, - "40x": { - "description": "40x", - "magnification": 40, - "system_magnification": 10.622, - "focal_length": 4.5, - "aperture": 0.65, - "DOF": 1.0, - "AF_min": 1.0, - "AF_max": 9.0, - "AF_range": 20.0, - "z_fine": 0.5, - "z_coarse": 5.0, - "xy_fine": 10.0, - "xy_coarse": 100.0 + "10x Phase": { + "description": "10x Achromat IPC Phase, 1mm glass, Olympus", + "magnification": 10, + "focal_length": 18.0, + "aperture": 0.25, + "DOF": 8.5, + "working_distance": 8.8, + "AF_min": 8.0, + "AF_max": 36.0, + "AF_range": 75.0, + "z_fine": 4.25, + "z_coarse": 42.5, + "xy_fine": 40.0, + "xy_coarse": 400.0 }, - "60x": { - "description": "60x", - "magnification": 60, - "system_magnification": 15.933, - "focal_length": 3.0, - "aperture": 0.85, - "DOF": 0.4, - "AF_min": 0.5, - "AF_max": 6.0, - "AF_range": 15.0, - "z_fine": 0.2, - "z_coarse": 2.0, - "xy_fine": 5.0, - "xy_coarse": 50.0 + "20x Oly": { + "description": "20x LWD U Plan Fl, 0.17mm glass, Olympus", + "magnification": 20, + "focal_length": 9.0, + "aperture": 0.5, + "DOF": 5.8, + "working_distance": 2.1, + "AF_min": 2.0, + "AF_max": 18.0, + "AF_range": 40.0, + "z_fine": 2.9, + "z_coarse": 29.0, + "xy_fine": 20.0, + "xy_coarse": 200.0 }, - "100x": { - "description": "100x", - "magnification": 100, - "system_magnification": 23.900, - "focal_length": 2.0, - "aperture": 0.95, - "DOF": 0.19, - "AF_min": 0.2, - "AF_max": 4.0, - "AF_range": 10.0, - "z_fine": 0.11, - "z_coarse": 1.1, - "xy_fine": 2.0, - "xy_coarse": 20.0 + "20x w/collar": { + "description": "20x LWD U Plan Fl with collar, Olympus", + "magnification": 20, + "focal_length": 9.0, + "aperture": 0.45, + "DOF": 5.8, + "working_distance": 6.6, + "AF_min": 2.0, + "AF_max": 18.0, + "AF_range": 40.0, + "z_fine": 2.9, + "z_coarse": 29.0, + "xy_fine": 20.0, + "xy_coarse": 200.0 }, - "100x (oil)": { - "description": "100x (oil)", - "magnification": 111, - "system_magnification": 26.556, - "focal_length": 1.8, - "aperture": 0.95, - "DOF": 0.19, - "AF_min": 0.2, - "AF_max": 4.0, - "AF_range": 10.0, - "z_fine": 0.11, - "z_coarse": 1.1, - "xy_fine": 2.0, - "xy_coarse": 20.0 + "20x Phase": { + "description": "20x LWD Achromat IPC Phase, 1mm glass, Olympus", + "magnification": 20, + "focal_length": 9.0, + "aperture": 0.4, + "DOF": 5.8, + "working_distance": 3.2, + "AF_min": 2.0, + "AF_max": 18.0, + "AF_range": 40.0, + "z_fine": 2.9, + "z_coarse": 29.0, + "xy_fine": 20.0, + "xy_coarse": 200.0 + }, + "40x w/collar": { + "description": "40x LWD U Plan Fl Olympus", + "magnification": 40, + "focal_length": 4.5, + "aperture": 0.60, + "DOF": 1.0, + "working_distance": 2.7, + "AF_min": 1.0, + "AF_max": 9.0, + "AF_range": 20.0, + "z_fine": 0.5, + "z_coarse": 5.0, + "xy_fine": 10.0, + "xy_coarse": 100.0 + }, + "40x Phase": { + "description": "40x LWD Achromat Phase, 1mm glass, Olympus", + "magnification": 40, + "focal_length": 4.5, + "aperture": 0.55, + "DOF": 1.0, + "working_distance": 2.2, + "AF_min": 1.0, + "AF_max": 9.0, + "AF_range": 20.0, + "z_fine": 0.5, + "z_coarse": 5.0, + "xy_fine": 10.0, + "xy_coarse": 100.0 + }, + "60x w/collar": { + "description": "60x U Plan UPLFLN60x Olympus", + "magnification": 60, + "focal_length": 3.0, + "aperture": 0.90, + "DOF": 0.4, + "working_distance": 0.2, + "AF_min": 0.5, + "AF_max": 6.0, + "AF_range": 15.0, + "z_fine": 0.2, + "z_coarse": 2.0, + "xy_fine": 5.0, + "xy_coarse": 50.0 + }, + "60x Meiji": { + "description": "60x Plan Achromat", + "magnification": 60, + "focal_length": 3.333333, + "aperture": 0.80, + "DOF": 0.4, + "working_distance": 0.23, + "AF_min": 0.5, + "AF_max": 6.0, + "AF_range": 15.0, + "z_fine": 0.2, + "z_coarse": 2.0, + "xy_fine": 5.0, + "xy_coarse": 50.0 + }, + "100x U Plan Oly": { + "description": "100x U Plan (oil) Olympus", + "magnification": 100, + "focal_length": 1.8, + "aperture": 1.30, + "DOF": 0.19, + "working_distance": 0.2, + "AF_min": 0.2, + "AF_max": 4.0, + "AF_range": 10.0, + "z_fine": 0.11, + "z_coarse": 1.1, + "xy_fine": 4.0, + "xy_coarse": 40.0 + }, + "100x M Plan Oly": { + "description": "100x M Plan Fl (dry) Olympus", + "magnification": 100, + "focal_length": 1.8, + "aperture": 0.95, + "DOF": 0.19, + "working_distance": 0.2, + "AF_min": 0.2, + "AF_max": 4.0, + "AF_range": 10.0, + "z_fine": 0.11, + "z_coarse": 1.1, + "xy_fine": 4.0, + "xy_coarse": 40.0 + }, + "100x Meiji": { + "description": "100x Plan Apochromat(oil) MA983 Meiji", + "magnification": 100, + "focal_length": 2.0, + "aperture": 1.25, + "DOF": 0.19, + "working_distance": 0.2, + "AF_min": 0.2, + "AF_max": 4.0, + "AF_range": 10.0, + "z_fine": 0.11, + "z_coarse": 1.1, + "xy_fine": 4.0, + "xy_coarse": 40.0 } } diff --git a/data/settings.json b/data/settings.json index d160dd6..c8fd805 100644 --- a/data/settings.json +++ b/data/settings.json @@ -7,20 +7,7 @@ "x": 5500.0, "y": 4000.0 }, - "objective": { - "desc": "20x", - "magnification": 20, - "aperture": 0.4, - "DOF": 5.8, - "AF_min": 2.0, - "AF_max": 18.0, - "AF_range": 40.0, - "z_fine": 2.9, - "z_coarse": 29.0, - "xy_fine": 20.0, - "xy_coarse": 200.0, - "ID": "20x" - }, + "objective_id": "20x Oly", "frame": { "width": 1900, "height": 1900 diff --git a/image_utils.py b/image_utils.py index 4bbbe91..08cc41c 100644 --- a/image_utils.py +++ b/image_utils.py @@ -188,3 +188,54 @@ def add_scale_bar(image, objective: dict): ) return image + + +def add_timestamp(image, timestamp_str: str): + height, width = image.shape[0], image.shape[1] + + dtype = image.dtype + + text_color_bg = (0,0,0) + font_scale = max(0.75, width/2000) + font_face = cv2.FONT_HERSHEY_SIMPLEX + font_thickness = 1 + + text_size, _ = cv2.getTextSize( + text=timestamp_str, + fontFace=font_face, + fontScale=font_scale, + thickness=font_thickness + ) + text_w, text_h = text_size + + bottom_offset = int(height/40) + left_offset = int(width/40) + + top_offset = height - bottom_offset + + if dtype == np.uint8: + text_intensity = 2**8-1 + else: # 16-bit + text_intensity = 2**16-1 + + cv2.rectangle( + image, + (left_offset, top_offset), + (left_offset+text_w, top_offset+text_h), + text_color_bg, + -1 + ) + + cv2.putText( + img=image, + text=f"{timestamp_str}", + org=(left_offset, int(top_offset + text_h + font_scale - 1)), + fontFace=font_face, + fontScale=font_scale, + color=(text_intensity,text_intensity,text_intensity), + thickness=font_thickness, + lineType=cv2.LINE_AA, + bottomLeftOrigin=False + ) + + return image diff --git a/lumascope_api.py b/lumascope_api.py index 1f4e9ce..2121a6c 100644 --- a/lumascope_api.py +++ b/lumascope_api.py @@ -45,6 +45,7 @@ from lvp_logger import logger import modules.common_utils as common_utils import modules.coord_transformations as coord_transformations +import modules.objectives_loader as objectives_loader import pathlib import time import threading @@ -61,6 +62,7 @@ class Lumascope(): def __init__(self): """Initialize Microscope""" self._coordinate_transformer = coord_transformations.CoordinateTransformer() + self._objectives_loader = objectives_loader.ObjectiveLoader() # LED Control Board try: @@ -109,8 +111,8 @@ def __init__(self): def set_labware(self, labware): self._labware = labware - def set_objective(self, objective): - self._objective = objective + def set_objective(self, objective_id): + self._objective = self._objectives_loader.get_objective_info(objective_id=objective_id) def set_scale_bar(self, enabled: bool): self._scale_bar['enabled'] = enabled @@ -211,7 +213,7 @@ def get_image(self, force_to_8bit: bool = True): self.image_buffer = self.camera.array.copy() if use_scale_bar: - self.image_buffer = image_utils.add_scale_bar(image=self.image_buffer, objective=self._objective) #magnification=self._objective['magnification']) + self.image_buffer = image_utils.add_scale_bar(image=self.image_buffer, objective=self._objective) if force_to_8bit and self.image_buffer.dtype != np.uint8: self.image_buffer = image_utils.convert_12bit_to_8bit(self.image_buffer) diff --git a/lumaviewpro.kv b/lumaviewpro.kv index 98361e3..3001b2b 100644 --- a/lumaviewpro.kv +++ b/lumaviewpro.kv @@ -1077,6 +1077,36 @@ height: '30dp' font_size: '12sp' on_release: root.apply_tiling() + + # Post-protocol creation Z-Stacking + BoxLayout: + orientation: 'horizontal' + id: protocol_zstacking_box_layout_id + size_hint_y: None + height: '30dp' + opacity: 1 + spacing: '5dp' + + Label: + id: protocol_zstacking_box_label_id + text: 'Z-Stacking' + size_hint_y: None + height: '30dp' + size_hint_x: None + width: '75dp' + font_size: '12sp' + + Label: + + Button: + id: protocol_zstacking_apply_id + text: 'Apply' + size_hint_y: None + size_hint_x: None + width: '50dp' + height: '30dp' + font_size: '12sp' + on_release: root.apply_zstacking() # Z-stack BoxLayout: @@ -1241,32 +1271,62 @@ height: '30dp' FolderChooseBTN: - text: 'Select Image Folder' + text: 'Apply Video Gen to Protocol Folder' size_hint_y: None height: '30dp' font_size: '12sp' - on_release: self.choose('video_input_images_folder') + on_release: self.choose('apply_video_gen_to_folder') + + # FPS + BoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: '30dp' + padding: '0dp' + spacing: '5dp' + Label: + text: 'Frames/Second' + halign: 'left' + valign: 'middle' + font_size: '12sp' + size_hint_x: None + width: '120dp' + + TextInput: + id: video_gen_fps_id + multiline: False + padding: ['2dp', (self.height-self.line_height)/2] + halign: 'center' + input_filter: 'int' + text: '5' + font_size: '12sp' - # Save method - FileSaveBTN: - text: 'Set Video Output Path' - size_hint_y: None - height: '30dp' - font_size: '12sp' - on_release: self.choose('video_output_path') + # Timestamp Overlay + BoxLayout: + orientation: 'horizontal' + size_hint_y: None + height: '35dp' + padding: '0dp' + spacing: '5dp' + Label: + text: 'Overlay Timestamp' + halign: 'left' + valign: 'middle' + font_size: '12sp' + text_size: self.size - # Pad - Widget: - size_hint_y: None - height: '10dp' + ToggleButton: + id: enable_timestamp_overlay_btn + size_hint: None, None + size: '45dp', '30dp' + padding: ['2dp', (self.height-self.line_height)/2] + border: 0, 0, 0, 0 + valign: 'middle' + halign: 'right' + background_normal: './data/icons/ToggleL.png' + background_down: './data/icons/ToggleRW.png' + state: 'down' - Button: - text: 'Generate Video' - size_hint_y: None - height: '30dp' - font_size: '12sp' - on_release: root.create_video() - # Empty widget to consume space at bottom of controls panel Widget: @@ -1527,7 +1587,7 @@ text: 'Select' font_size:'12dp' text_autoupdate: True - on_release: root.load_ojectives() + on_release: root.load_objectives() on_text: root.select_objective() BoxLayout: diff --git a/lumaviewpro.py b/lumaviewpro.py index 642900b..e9f1474 100644 --- a/lumaviewpro.py +++ b/lumaviewpro.py @@ -128,6 +128,7 @@ from modules.composite_generation import CompositeGeneration import modules.coord_transformations as coord_transformations import modules.labware_loader as labware_loader +import modules.objectives_loader as objectives_loader from modules.protocol_execution_record import ProtocolExecutionRecord from modules.protocol_run_modes import ProtocolRunMode from modules.zstack_config import ZStackConfig @@ -150,6 +151,9 @@ global wellplate_loader wellplate_loader = None +global objective_helper +objective_helper = None + global coordinate_transformer coordinate_transformer = None @@ -220,6 +224,88 @@ def is_image_saving_enabled() -> bool: return True +def get_zstack_positions() -> tuple[bool, dict]: + zstack_settings = lumaview.ids['motionsettings_id'].ids['verticalcontrol_id'].ids['zstack_id'] + range = float(zstack_settings.ids['zstack_range_id'].text) + step_size = float(zstack_settings.ids['zstack_stepsize_id'].text) + z_reference = common_utils.convert_zstack_reference_position_setting_to_config( + text_label=zstack_settings.ids['zstack_spinner'].text + ) + + current_pos = lumaview.scope.get_current_position('Z') + + zstack_config = ZStackConfig( + range=range, + step_size=step_size, + current_z_reference=z_reference, + current_z_value=current_pos + ) + + if zstack_config.number_of_steps() <= 0: + return False, {None: None} + + return True, zstack_config.step_positions() + + +def get_current_objective_info() -> tuple[str, dict]: + objective_id = settings['objective_id'] + objective = objective_helper.get_objective_info(objective_id=objective_id) + return objective_id, objective + + +def _handle_ui_update_for_axis(axis: str): + axis = axis.upper() + if axis == 'Z': + lumaview.ids['motionsettings_id'].ids['verticalcontrol_id'].update_gui() + elif axis in ('X', 'Y', 'XY'): + lumaview.ids['motionsettings_id'].update_xy_stage_control_gui() + + +# Wrapper function when moving to update UI position +def move_absolute_position( + axis: str, + pos: float, + wait_until_complete: bool = False, + overshoot_enabled: bool = True +): + lumaview.scope.move_absolute_position( + axis=axis, + pos=pos, + wait_until_complete=wait_until_complete, + overshoot_enabled=overshoot_enabled + ) + + _handle_ui_update_for_axis(axis=axis) + + +# Wrapper function when moving to update UI position +def move_relative_position( + axis: str, + um: float, + wait_until_complete: bool = False, + overshoot_enabled: bool = True +): + lumaview.scope.move_relative_position( + axis=axis, + um=um, + wait_until_complete=wait_until_complete, + overshoot_enabled=overshoot_enabled + ) + + _handle_ui_update_for_axis(axis=axis) + + +def move_home(axis: str): + axis = axis.upper() + + if axis == 'Z': + lumaview.scope.zhome() + elif axis == 'XY': + lumaview.scope.xyhome() + + _handle_ui_update_for_axis(axis=axis) + + # ------------------------------------------------------------------------- # SCOPE DISPLAY Image representing the microscope camera # ------------------------------------------------------------------------- @@ -275,14 +361,14 @@ def touch(self, target: Widget, event: MotionEvent): x_dist_pixel = texture_click_pos_x - texture_width/2 # Positive means to the right of center y_dist_pixel = texture_click_pos_y - texture_height/2 # Positive means above center - focal_length = settings['objective']['focal_length'] - pixel_size_um = common_utils.get_pixel_size(focal_length=focal_length) + _, objective = get_current_objective_info() + pixel_size_um = common_utils.get_pixel_size(focal_length=objective['focal_length']) x_dist_um = x_dist_pixel * pixel_size_um y_dist_um = y_dist_pixel * pixel_size_um - lumaview.scope.move_relative_position(axis='X', um=x_dist_um) - lumaview.scope.move_relative_position(axis='Y', um=y_dist_um) + move_relative_position(axis='X', um=x_dist_um) + move_relative_position(axis='Y', um=y_dist_um) @staticmethod @@ -652,7 +738,7 @@ def composite_capture(self): img = np.zeros((settings['frame']['height'], settings['frame']['width'], 3), dtype=dtype) - for layer in common_utils.get_layers(): + for layer in common_utils.get_fluorescence_layers(): if settings[layer]['acquire'] == True: # Go to focus and wait for arrival @@ -670,15 +756,6 @@ def composite_capture(self): # update illumination to currently selected settings illumination = settings[layer]['ill'] - # Dark field capture - scope_leds_off() - - # TODO: replace sleep + get_image with scope.capture - will require waiting on capture complete - time.sleep(2*exposure/1000+0.2) - scope_display.update_scopedisplay() # Why? - - darkfield = lumaview.scope.get_image(force_to_8bit=not use_full_pixel_depth) - # Florescent capture if lumaview.scope.led: lumaview.scope.led_on(lumaview.scope.color2ch(layer), illumination) @@ -688,17 +765,15 @@ def composite_capture(self): # TODO: replace sleep + get_image with scope.capture - will require waiting on capture complete time.sleep(2*exposure/1000+0.2) - exposed = lumaview.scope.get_image(force_to_8bit=not use_full_pixel_depth) + img_gray = lumaview.scope.get_image(force_to_8bit=not use_full_pixel_depth) - scope_display.update_scopedisplay() # Why? - corrected = exposed - np.minimum(exposed,darkfield) # buffer the images if layer == 'Blue': - img[:,:,0] = corrected + img[:,:,0] = img_gray elif layer == 'Green': - img[:,:,1] = corrected + img[:,:,1] = img_gray elif layer == 'Red': - img[:,:,2] = corrected + img[:,:,2] = img_gray # # if Brightfield is included # else: # a = 0.3 @@ -908,16 +983,17 @@ def on_touch_down(self, touch, *args): if 'ctrl' in self._active_key_presses: # Focus control vertical_control = lumaview.ids['motionsettings_id'].ids['verticalcontrol_id'] + overshoot_enabled = False if touch.button == 'scrolldown': if 'shift' in self._active_key_presses: - vertical_control.coarse_up() + vertical_control.coarse_up(overshoot_enabled=overshoot_enabled) else: - vertical_control.fine_up() + vertical_control.fine_up(overshoot_enabled=overshoot_enabled) elif touch.button == 'scrollup': if 'shift' in self._active_key_presses: - vertical_control.coarse_down() + vertical_control.coarse_down(overshoot_enabled=overshoot_enabled) else: - vertical_control.fine_down() + vertical_control.fine_down(overshoot_enabled=overshoot_enabled) else: # Digital zoom control @@ -1174,68 +1250,59 @@ class VideoCreationControls(BoxLayout): def __init__(self, **kwargs): global video_creation_controls super().__init__(**kwargs) - logger.info('LVP Main: VideoCreationControls.__init__()') - self._post = post_processing.PostProcessing() video_creation_controls = self - self._first_open = False - self._input_images_loc = None - self._output_file_loc = None - - - def activate(self): - if self._first_open is False: - self._first_open = True - - - def deactivate(self): - pass - - - def set_input_images_loc(self, directory: str | pathlib.Path) -> None: - self._input_images_loc = pathlib.Path(directory) - - - def set_output_file_loc(self, file_loc: str | pathlib.Path) -> None: - self._output_file_loc = pathlib.Path(file_loc) @show_popup - def create_video(self, popup) -> None: + def run_video_gen(self, popup, path) -> None: status_map = { True: "Success", False: "FAILED" } popup.title = "Video Builder" - popup.text = "Generating video..." + popup.text = "Generating video(s)..." - if self._input_images_loc is None: - popup.text = f"{popup.text} {status_map[False]} - Set Image Folder" - time.sleep(2) - self.done = True - return + fps = int(self.ids['video_gen_fps_id'].text) + + ts_overlay_btn = self.ids['enable_timestamp_overlay_btn'] + enable_timestamp_overlay = True if ts_overlay_btn.state == 'down' else False - if self._output_file_loc is None: - self._output_file_loc = self._input_images_loc.joinpath("movie.avi") + if fps < 1: + msg = "Video generation frames/second must be >= 1 fps" + final_text = f"Generating video(s) - {status_map[False]}" + final_text += f"\n{msg}" + popup.text = final_text + logger.error(f"{msg}") + time.sleep(5) + self.done = True video_builder = VideoBuilder() - status = video_builder.create_video_from_directory( - input_directory=self._input_images_loc, - frames_per_sec=10, - output_file_loc=self._output_file_loc, + result = video_builder.load_folder( + path=pathlib.Path(path), + tiling_configs_file_loc=pathlib.Path(source_path) / "data" / "tiling.json", + frames_per_sec=fps, + enable_timestamp_overlay=enable_timestamp_overlay ) - - popup.text = f"{popup.text} {status_map[status]}\n- Output: {self._output_file_loc}" + final_text = f"Generating video(s) - {status_map[result['status']]}" + if result['status'] is False: + final_text += f"\n{result['message']}" + popup.text = final_text + time.sleep(5) + self.done = True + return + + popup.text = final_text time.sleep(2) self.done = True - self._launch_video() + # self._launch_video() - def _launch_video(self) -> None: - try: - os.startfile(self._output_file_loc) - except Exception as e: - logger.error(f"Unable to launch video {self._output_file_loc}:\n{e}") + # def _launch_video(self) -> None: + # try: + # os.startfile(self._output_file_loc) + # except Exception as e: + # logger.error(f"Unable to launch video {self._output_file_loc}:\n{e}") class CellCountControls(BoxLayout): @@ -2003,10 +2070,8 @@ def __init__(self, **kwargs): self.is_complete = False self.record_autofocus_to_file = False - Clock.schedule_interval(self.update_gui, 0.3) - - def update_gui(self, dt=0): + def update_gui(self): try: set_pos = lumaview.scope.get_target_position('Z') # Get target value except: @@ -2015,34 +2080,39 @@ def update_gui(self, dt=0): self.ids['obj_position'].value = max(0, set_pos) self.ids['z_position_id'].text = format(max(0, set_pos), '.2f') - def coarse_up(self): + + def coarse_up(self, overshoot_enabled: bool = True): logger.info('[LVP Main ] VerticalControl.coarse_up()') - coarse = settings['objective']['z_coarse'] - lumaview.scope.move_relative_position('Z', coarse) # Move UP - self.update_gui() + _, objective = get_current_objective_info() + coarse = objective['z_coarse'] + move_relative_position('Z', coarse, overshoot_enabled=overshoot_enabled) + - def fine_up(self): + def fine_up(self, overshoot_enabled: bool = True): logger.info('[LVP Main ] VerticalControl.fine_up()') - fine = settings['objective']['z_fine'] - lumaview.scope.move_relative_position('Z', fine) # Move UP - self.update_gui() + _, objective = get_current_objective_info() + fine = objective['z_fine'] + move_relative_position('Z', fine, overshoot_enabled=overshoot_enabled) - def fine_down(self): + + def fine_down(self, overshoot_enabled: bool = True): logger.info('[LVP Main ] VerticalControl.fine_down()') - fine = settings['objective']['z_fine'] - lumaview.scope.move_relative_position('Z', -fine) # Move DOWN - self.update_gui() + _, objective = get_current_objective_info() + fine = objective['z_fine'] + move_relative_position('Z', -fine, overshoot_enabled=overshoot_enabled) + - def coarse_down(self): + def coarse_down(self, overshoot_enabled: bool = True): logger.info('[LVP Main ] VerticalControl.coarse_down()') - coarse = settings['objective']['z_coarse'] - lumaview.scope.move_relative_position('Z', -coarse) # Move DOWN - self.update_gui() + _, objective = get_current_objective_info() + coarse = objective['z_coarse'] + move_relative_position('Z', -coarse, overshoot_enabled=overshoot_enabled) + def set_position(self, pos): logger.info('[LVP Main ] VerticalControl.set_position()') - lumaview.scope.move_absolute_position('Z', float(pos)) - self.update_gui() + move_absolute_position('Z', float(pos)) + def set_bookmark(self): logger.info('[LVP Main ] VerticalControl.set_bookmark()') @@ -2063,13 +2133,11 @@ def set_all_bookmarks(self): def goto_bookmark(self): logger.info('[LVP Main ] VerticalControl.goto_bookmark()') pos = settings['bookmark']['z'] - lumaview.scope.move_absolute_position('Z', pos) - self.update_gui() + move_absolute_position('Z', pos) def home(self): logger.info('[LVP Main ] VerticalControl.home()') - lumaview.scope.zhome() - self.update_gui() + move_home(axis='Z') # User selected the autofocus function def autofocus(self): @@ -2100,11 +2168,12 @@ def autofocus(self): return center = lumaview.scope.get_current_position('Z') - range = settings['objective']['AF_range'] + _, objective = get_current_objective_info() + range = objective['AF_range'] self.z_min = max(0, center-range) # starting minimum z-height for autofocus self.z_max = center+range # starting maximum z-height for autofocus - self.resolution = settings['objective']['AF_max'] # starting step size for autofocus + self.resolution = objective['AF_max'] # starting step size for autofocus self.exposure = lumaview.scope.get_exposure_time() # camera exposure to determine 'wait' time self.positions = [] # List of positions to step through @@ -2118,7 +2187,7 @@ def autofocus(self): self.is_autofocus = True # Start the autofocus process at z-minimum - lumaview.scope.move_absolute_position('Z', self.z_min) + move_absolute_position('Z', self.z_min) # schedule focus iterate logger.info('[LVP Main ] Clock.schedule_interval(self.focus_iterate, 0.01)') @@ -2203,7 +2272,8 @@ def focus_iterate(self, dt): if next_target > self.z_max: # Calculate new step size for resolution - AF_min = settings['objective']['AF_min'] + _, objective = get_current_objective_info() + AF_min = objective['AF_min'] prev_resolution = self.resolution self.resolution = prev_resolution / 3 # SELECT DESIRED RESOLUTION FRACTION @@ -2225,7 +2295,7 @@ def focus_iterate(self, dt): self.focus_measures = [] # go to new z_min - lumaview.scope.move_absolute_position('Z', self.z_min) + move_absolute_position('Z', self.z_min) if self.resolution == AF_min: self.last = True @@ -2235,7 +2305,7 @@ def focus_iterate(self, dt): focus = self.focus_best(self.positions, self.focus_measures) # go to best focus - lumaview.scope.move_absolute_position('Z', focus) # move to absolute target + move_absolute_position('Z', focus) # move to absolute target # end autofocus sequence logger.info('[LVP Main ] Clock.unschedule(self.focus_iterate)') @@ -2252,7 +2322,7 @@ def focus_iterate(self, dt): else: # move to next position - lumaview.scope.move_relative_position('Z', self.resolution) + move_relative_position('Z', self.resolution) # update last focus self.last_focus = focus @@ -2267,7 +2337,6 @@ def focus_iterate(self, dt): if self.record_autofocus_to_file: self.save_autofocus_data() - self.update_gui() # Algorithms for estimating the quality of the focus def focus_function(self, image, algorithm = 'vollath4'): @@ -2413,51 +2482,51 @@ def update_gui(self, dt=0, full_redraw: bool = False): def fine_left(self): logger.info('[LVP Main ] XYStageControl.fine_left()') - fine = settings['objective']['xy_fine'] - lumaview.scope.move_relative_position('X', -fine) # Move LEFT fine step - self.update_gui() + _, objective = get_current_objective_info() + fine = objective['xy_fine'] + move_relative_position('X', -fine) # Move LEFT fine step def fine_right(self): logger.info('[LVP Main ] XYStageControl.fine_right()') - fine = settings['objective']['xy_fine'] - lumaview.scope.move_relative_position('X', fine) # Move RIGHT fine step - self.update_gui() + _, objective = get_current_objective_info() + fine = objective['xy_fine'] + move_relative_position('X', fine) # Move RIGHT fine step def coarse_left(self): logger.info('[LVP Main ] XYStageControl.coarse_left()') - coarse = settings['objective']['xy_coarse'] - lumaview.scope.move_relative_position('X', -coarse) # Move LEFT coarse step - self.update_gui() + _, objective = get_current_objective_info() + coarse = objective['xy_coarse'] + move_relative_position('X', -coarse) # Move LEFT coarse step def coarse_right(self): logger.info('[LVP Main ] XYStageControl.coarse_right()') - coarse = settings['objective']['xy_coarse'] - lumaview.scope.move_relative_position('X', coarse) # Move RIGHT - self.update_gui() + _, objective = get_current_objective_info() + coarse = objective['xy_coarse'] + move_relative_position('X', coarse) # Move RIGHT def fine_back(self): logger.info('[LVP Main ] XYStageControl.fine_back()') - fine = settings['objective']['xy_fine'] - lumaview.scope.move_relative_position('Y', -fine) # Move BACK - self.update_gui() + _, objective = get_current_objective_info() + fine = objective['xy_fine'] + move_relative_position('Y', -fine) # Move BACK def fine_fwd(self): logger.info('[LVP Main ] XYStageControl.fine_fwd()') - fine = settings['objective']['xy_fine'] - lumaview.scope.move_relative_position('Y', fine) # Move FORWARD - self.update_gui() + _, objective = get_current_objective_info() + fine = objective['xy_fine'] + move_relative_position('Y', fine) # Move FORWARD def coarse_back(self): logger.info('[LVP Main ] XYStageControl.coarse_back()') - coarse = settings['objective']['xy_coarse'] - lumaview.scope.move_relative_position('Y', -coarse) # Move BACK - self.update_gui() + _, objective = get_current_objective_info() + coarse = objective['xy_coarse'] + move_relative_position('Y', -coarse) # Move BACK def coarse_fwd(self): logger.info('[LVP Main ] XYStageControl.coarse_fwd()') - coarse = settings['objective']['xy_coarse'] - lumaview.scope.move_relative_position('Y', coarse) # Move FORWARD - self.update_gui() + _, objective = get_current_objective_info() + coarse = objective['xy_coarse'] + move_relative_position('Y', coarse) # Move FORWARD def set_xposition(self, x_pos): logger.info('[LVP Main ] XYStageControl.set_xposition()') @@ -2476,8 +2545,8 @@ def set_xposition(self, x_pos): logger.info(f'[LVP Main ] X pos {x_pos} Stage X {stage_x}') # Move to x-position - lumaview.scope.move_absolute_position('X', stage_x) # position in text is in mm - self.update_gui() + move_absolute_position('X', stage_x) # position in text is in mm + def set_yposition(self, y_pos): logger.info('[LVP Main ] XYStageControl.set_yposition()') @@ -2494,8 +2563,8 @@ def set_yposition(self, y_pos): ) # Move to y-position - lumaview.scope.move_absolute_position('Y', stage_y) # position in text is in mm - self.update_gui() + move_absolute_position('Y', stage_y) # position in text is in mm + def set_xbookmark(self): logger.info('[LVP Main ] XYStageControl.set_xbookmark()') @@ -2548,7 +2617,7 @@ def goto_xbookmark(self): px=x_pos, py=0 ) - lumaview.scope.move_absolute_position('X', stage_x) # set current x position in um + move_absolute_position('X', stage_x) # set current x position in um def goto_ybookmark(self): logger.info('[LVP Main ] XYStageControl.goto_ybookmark()') @@ -2565,7 +2634,7 @@ def goto_ybookmark(self): px=0, py=y_pos ) - lumaview.scope.move_absolute_position('Y', stage_y) # set current y position in um + move_absolute_position('Y', stage_y) # set current y position in um # def calibrate(self): # logger.info('[LVP Main ] XYStageControl.calibrate()') @@ -2586,8 +2655,7 @@ def home(self): global lumaview if lumaview.scope.motion.driver: # motor controller is actively connected - lumaview.scope.xyhome() - # TODO: update GUI, + move_home(axis='XY') else: logger.warning('[LVP Main ] Motion controller not available.') @@ -2714,9 +2782,10 @@ def apply_tiling(self): labware = wellplate_loader.get_plate(plate_key=settings['protocol']['labware']) labware.set_positions() + _, objective = get_current_objective_info() tiles = self.tiling_config.get_tile_centers( config_label=self.ids['tiling_size_spinner'].text, - focal_length=settings['objective']['focal_length'], + focal_length=objective['focal_length'], frame_size=settings['frame'], fill_factor=TilingConfig.DEFAULT_FILL_FACTORS['position'] ) @@ -2724,14 +2793,6 @@ def apply_tiling(self): if len(tiles) == 1: # No tiling return - # Get existing max tile group ID to start from - # existing_max_tile_group_id = -1 - - # for row_idx in range(len(self._protocol_df)): - # step = self._protocol_df.iloc[row_idx] - # tile_group_id = step['Tile'] - # if tile_group_id != "": - # existing_max_tile_group_id = max(tile_group_id, existing_max_tile_group_id) existing_max_tile_group_id = self._protocol_df['Tile Group ID'].max() tile_group_id = existing_max_tile_group_id + 1 @@ -2754,24 +2815,6 @@ def apply_tiling(self): x_tile = round(x + tile_position["x"]/1000, common_utils.max_decimal_precision('x')) # in 'plate' coordinates y_tile = round(y + tile_position["y"]/1000, common_utils.max_decimal_precision('y')) # in 'plate' coordinates - - # if orig_step_df["Custom Step"]: - # new_step_name = common_utils.generate_default_step_name( - # custom_name_prefix=orig_step_df['Name'], - # well_label=orig_step_df['Well'], - # color=orig_step_df['Color'], - # z_height_idx=None, - # scan_count=None, - # tile_label=tile_label - # ) - # else: - # new_step_name = common_utils.generate_default_step_name( - # well_label=orig_step_df['Well'], - # color=orig_step_df['Color'], - # z_height_idx=orig_step_df['Z-Slice'], - # scan_count=None, - # tile_label=tile_label - # ) new_step_dict = self.create_step_dict( name=orig_step_df['Name'], @@ -2802,6 +2845,61 @@ def apply_tiling(self): stage.set_protocol_steps(df=self._protocol_df) self.update_step_ui() + + def apply_zstacking(self): + logger.info('[LVP Main ] Apply Z-Stacking to protocol') + labware = wellplate_loader.get_plate(plate_key=settings['protocol']['labware']) + labware.set_positions() + + zstack_valid, zstack_positions = get_zstack_positions() + if not zstack_valid: + return + + existing_max_zstack_group_id = self._protocol_df['Z-Stack Group ID'].max() + + zstack_group_id = existing_max_zstack_group_id + 1 + + new_steps = list() + for row_idx in range(len(self._protocol_df)): + orig_step_df = self._protocol_df.iloc[row_idx] + orig_step_dict = orig_step_df.to_dict() + + # If already part of a Z-Stack, copy it over to the new protocol + if orig_step_df['Z-Slice'] not in (None, "", -1): + new_steps.append(orig_step_dict) + continue + + # Create a z-stack + for zstack_slice, zstack_position in zstack_positions.items(): + new_step_dict = self.create_step_dict( + name=orig_step_df['Name'], + x=orig_step_df["X"], + y=orig_step_df["Y"], + z=zstack_position, + af=orig_step_df['Auto_Focus'], + color=orig_step_df['Color'], + fc=orig_step_df['False_Color'], + ill=orig_step_df['Illumination'], + gain=orig_step_df['Gain'], + auto_gain=orig_step_df['Auto_Gain'], + exp=orig_step_df['Exposure'], + objective=orig_step_df['Objective'], + well=orig_step_df['Well'], + tile=orig_step_df['Tile'], + zslice=zstack_slice, + custom_step=orig_step_df['Custom Step'], + tile_group_id=orig_step_df['Tile Group ID'], + zstack_group_id=zstack_group_id + ) + + new_steps.append(new_step_dict) + + zstack_group_id += 1 + + self._protocol_df = pd.DataFrame.from_dict(new_steps) + stage.set_protocol_steps(df=self._protocol_df) + self.update_step_ui() + def update_step_ui(self): # Number of Steps @@ -2912,42 +3010,19 @@ def new_protocol(self): labware = wellplate_loader.get_plate(plate_key=settings['protocol']['labware']) labware.set_positions() + objective_id, objective = get_current_objective_info() tiles = self.tiling_config.get_tile_centers( config_label=self.ids['tiling_size_spinner'].text, - focal_length=settings['objective']['focal_length'], + focal_length=objective['focal_length'], frame_size=settings['frame'], fill_factor=TilingConfig.DEFAULT_FILL_FACTORS['position'] ) self._protocol_df = self.create_empty_protocol() - # Z-stack related - def _zstack_positions() -> dict: - zstack_settings = lumaview.ids['motionsettings_id'].ids['verticalcontrol_id'].ids['zstack_id'] - range = float(zstack_settings.ids['zstack_range_id'].text) - step_size = float(zstack_settings.ids['zstack_stepsize_id'].text) - z_reference = common_utils.convert_zstack_reference_position_setting_to_config( - text_label=zstack_settings.ids['zstack_spinner'].text - ) - - current_pos = lumaview.scope.get_current_position('Z') - - zstack_config = ZStackConfig( - range=range, - step_size=step_size, - current_z_reference=z_reference, - current_z_value=current_pos - ) - - if zstack_config.number_of_steps() <= 0: - return {None: None} - - # begin moving to the first position - return zstack_config.step_positions() - use_zstacking = self.ids['acquire_zstack_id'].active if use_zstacking: - zstack_positions = _zstack_positions() + _, zstack_positions = get_zstack_positions() else: zstack_positions = {None: None} @@ -2958,7 +3033,6 @@ def _zstack_positions() -> dict: for pos in labware.pos_list: for tile_label, tile_position in tiles.items(): for zstack_slice, zstack_position in zstack_positions.items(): - # Iterate through all the colors to create the steps for layer in common_utils.get_layers(): if settings[layer]['acquire'] == False: continue @@ -2966,7 +3040,7 @@ def _zstack_positions() -> dict: x = round(pos[0] + tile_position["x"]/1000, common_utils.max_decimal_precision('x')) # in 'plate' coordinates y = round(pos[1] + tile_position["y"]/1000, common_utils.max_decimal_precision('y')) # in 'plate' coordinates - if use_zstacking: + if use_zstacking and (zstack_slice is not None): z = zstack_position else: z = settings[layer]['focus'] @@ -2979,7 +3053,6 @@ def _zstack_positions() -> dict: gain = round(settings[layer]['gain'], common_utils.max_decimal_precision('gain')) auto_gain = common_utils.to_bool(settings[layer]['auto_gain']) exp = round(settings[layer]['exp'], common_utils.max_decimal_precision('exposure')) - objective = settings['objective']['ID'] custom_step = False well_label = labware.get_well_label(x=pos[0], y=pos[1]) @@ -3010,7 +3083,7 @@ def _zstack_positions() -> dict: gain=gain, auto_gain=auto_gain, exp=exp, - objective=objective, + objective=objective_id, well=well_label, tile=tile_label, zslice=zstack_slice_label, @@ -3310,9 +3383,9 @@ def go_to_step(self, ignore_auto_gain: bool = False): # Move into position if lumaview.scope.motion.driver: - lumaview.scope.move_absolute_position('X', sx) - lumaview.scope.move_absolute_position('Y', sy) - lumaview.scope.move_absolute_position('Z', step["Z"]) + move_absolute_position('X', sx) + move_absolute_position('Y', sy) + move_absolute_position('Z', step["Z"]) else: logger.warning('[LVP Main ] Motion controller not available.') @@ -3323,7 +3396,6 @@ def go_to_step(self, ignore_auto_gain: bool = False): lumaview.ids['imagesettings_id'].ids['toggle_imagesettings'].state = 'down' lumaview.ids['imagesettings_id'].toggle_settings() - # set accordion item to corresponding channel id = f"{color}_accordion" lumaview.ids['imagesettings_id'].ids[id].collapse = False @@ -3361,9 +3433,6 @@ def go_to_step(self, ignore_auto_gain: bool = False): layer.ids['exp_text'].text = str(step["Exposure"]) layer.ids['exp_slider'].value = float(step["Exposure"]) - # update position in stage control - lumaview.ids['motionsettings_id'].update_xy_stage_control_gui() - layer.apply_settings(ignore_auto_gain=ignore_auto_gain) @@ -3435,7 +3504,9 @@ def modify_step(self): self._protocol_df.at[self.curr_step, "Gain"] = round(layer_id.ids['gain_slider'].value, common_utils.max_decimal_precision('gain')) self._protocol_df.at[self.curr_step, "Auto_Gain"] = layer_id.ids['auto_gain'].active self._protocol_df.at[self.curr_step, "Exposure"] = round(layer_id.ids['exp_slider'].value, common_utils.max_decimal_precision('exposure')) - self._protocol_df.at[self.curr_step, "Objective"] = settings['objective']['ID'] + + objective_id, _ = get_current_objective_info() + self._protocol_df.at[self.curr_step, "Objective"] = objective_id stage.set_protocol_steps(df=self._protocol_df) @@ -3480,6 +3551,7 @@ def insert_step(self, after_current_step: bool = True): zstack_group_id = -1 z = lumaview.scope.get_current_position('Z') + objective_id, _ = get_current_objective_info() step_dict = self.create_step_dict( name=name, x=round(px, common_utils.max_decimal_precision('x')), @@ -3492,7 +3564,7 @@ def insert_step(self, after_current_step: bool = True): gain=round(layer_id.ids['gain_slider'].value, common_utils.max_decimal_precision('gain')), auto_gain=layer_id.ids['auto_gain'].active, exp=round(layer_id.ids['exp_slider'].value, common_utils.max_decimal_precision('exposure')), - objective=settings['objective']['ID'], + objective=objective_id, well=well, tile=tile, zslice=zslice, @@ -3596,9 +3668,9 @@ def run_autofocus_scan(self): ) # Move into position - lumaview.scope.move_absolute_position('X', sx) - lumaview.scope.move_absolute_position('Y', sy) - lumaview.scope.move_absolute_position('Z', step["Z"]) + move_absolute_position('X', sx) + move_absolute_position('Y', sy) + move_absolute_position('Z', step["Z"]) logger.info('[LVP Main ] Clock.schedule_interval(self.autofocus_scan_iterate, 0.1)') Clock.schedule_interval(self.autofocus_scan_iterate, 0.1) @@ -3750,12 +3822,12 @@ def run_scan(self): def perform_grease_redistribution(self): z_orig = lumaview.scope.get_current_position('Z') logger.info('[LVP Main ] Performing Z-axis grease redistribution') - lumaview.scope.move_absolute_position('Z', 0) + move_absolute_position('Z', 0) z_status = False while not z_status: z_status = lumaview.scope.get_target_status('Z') time.sleep(0.1) - lumaview.scope.move_absolute_position('Z', z_orig) + move_absolute_position('Z', z_orig) z_status = False while not z_status: z_status = lumaview.scope.get_target_status('Z') @@ -4151,10 +4223,9 @@ def on_touch_down(self, touch): py=plate_y ) - lumaview.scope.move_absolute_position('X', stage_x) - lumaview.scope.move_absolute_position('Y', stage_y) - lumaview.ids['motionsettings_id'].update_xy_stage_control_gui() - + move_absolute_position('X', stage_x) + move_absolute_position('Y', stage_y) + def draw_labware( self, @@ -4385,13 +4456,17 @@ def __init__(self, **kwargs): logger.exception('[LVP Main ] Unable to read scopes.json.') raise - try: - os.chdir(source_path) - with open('./data/objectives.json', "r") as read_file: - self.objectives = json.load(read_file) - except: - logger.exception('[LVP Main ] Unable to open objectives.json.') - raise + # try: + # os.chdir(source_path) + # with open('./data/objectives.json', "r") as read_file: + # self.objectives = json.load(read_file) + # except: + # logger.exception('[LVP Main ] Unable to open objectives.json.') + # raise + + + # def get_objective_info(self, objective_id: str) -> dict: + # return self.objectives[objective_id] # load settings from JSON file @@ -4458,10 +4533,11 @@ def load_settings(self, filename="./data/current.json"): # self.update_binning_size() lumaview.scope.set_stage_offset(stage_offset=settings['stage_offset']) - self.ids['objective_spinner'].text = settings['objective']['ID'] - # TODO self.ids['objective_spinner'].text = settings['objective']['description'] - self.ids['magnification_id'].text = str(settings['objective']['magnification']) - lumaview.scope.set_objective(objective=settings['objective']) + objective_id = settings['objective_id'] + self.ids['objective_spinner'].text = objective_id + objective = objective_helper.get_objective_info(objective_id=objective_id) + self.ids['magnification_id'].text = f"{objective['magnification']}" + lumaview.scope.set_objective(objective_id=objective_id) self.ids['frame_width_id'].text = str(settings['frame']['width']) self.ids['frame_height_id'].text = str(settings['frame']['height']) @@ -4647,10 +4723,10 @@ def set_ui_features_for_scope(self) -> None: stage.set_motion_capability(enabled=selected_scope_config['XYStage']) - def load_ojectives(self): - logger.info('[LVP Main ] MicroscopeSettings.load_ojectives()') + def load_objectives(self): + logger.info('[LVP Main ] MicroscopeSettings.load_objectives()') spinner = self.ids['objective_spinner'] - spinner.values = list(self.objectives.keys()) + spinner.values = objective_helper.get_objectives_list() def select_objective(self): @@ -4658,16 +4734,16 @@ def select_objective(self): global lumaview global settings - spinner = self.ids['objective_spinner'] - settings['objective'] = self.objectives[spinner.text] - settings['objective']['ID'] = spinner.text + objective_id = self.ids['objective_spinner'].text + objective = objective_helper.get_objective_info(objective_id=objective_id) + settings['objective_id'] = objective_id microscope_settings_id = lumaview.ids['motionsettings_id'].ids['microscope_settings_id'] - microscope_settings_id.ids['magnification_id'].text = str(settings['objective']['magnification']) + microscope_settings_id.ids['magnification_id'].text = f"{objective['magnification']}" - lumaview.scope.set_objective(settings['objective']) + lumaview.scope.set_objective(objective_id=objective_id) fov_size = common_utils.get_field_of_view( - focal_length=settings['objective']['focal_length'], + focal_length=objective['focal_length'], frame_size=settings['frame'] ) self.ids['field_of_view_width_id'].text = str(round(fov_size['width'],0)) @@ -4690,8 +4766,11 @@ def frame_size(self): self.ids['frame_width_id'].text = str(width) self.ids['frame_height_id'].text = str(height) + objective_id = settings['objective_id'] + objective = objective_helper.get_objective_info(objective_id=objective_id) + fov_size = common_utils.get_field_of_view( - focal_length=settings['objective']['focal_length'], + focal_length=objective['focal_length'], frame_size=settings['frame'] ) self.ids['field_of_view_width_id'].text = str(round(fov_size['width'],0)) @@ -4828,9 +4907,7 @@ def goto_focus(self): logger.info('[LVP Main ] LayerControl.goto_focus()') global lumaview pos = settings[self.layer]['focus'] - lumaview.scope.move_absolute_position('Z', pos) # set current z height in usteps - control = lumaview.ids['motionsettings_id'].ids['verticalcontrol_id'] - control.update_gui() + move_absolute_position('Z', pos) # set current z height in usteps def update_led_state(self): @@ -4951,7 +5028,7 @@ def aquire_zstack(self): # begin moving to the first position self.positions = zstack_config.step_positions() self.n_pos = 0 - lumaview.scope.move_absolute_position('Z', self.positions[self.n_pos]) + move_absolute_position('Z', self.positions[self.n_pos]) if self.ids['zstack_aqr_btn'].state == 'down': logger.info('[LVP Main ] Clock.schedule_interval(self.zstack_iterate, 0.01)') @@ -4963,7 +5040,7 @@ def aquire_zstack(self): # self.zstack_event.cancel() logger.info('[LVP Main ] Clock.unschedule(self.zstack_iterate)') Clock.unschedule(self.zstack_iterate) - lumaview.scope.move_absolute_position('Z', self._current_z_pos, wait_until_complete=True) + move_absolute_position('Z', self._current_z_pos, wait_until_complete=True) def zstack_iterate(self, dt): @@ -4988,13 +5065,13 @@ def zstack_iterate(self, dt): self.n_pos += 1 if self.n_pos < len(self.positions): - lumaview.scope.move_absolute_position('Z', self.positions[self.n_pos]) + move_absolute_position('Z', self.positions[self.n_pos]) else: self.ids['zstack_aqr_btn'].text = 'Acquire' self.ids['zstack_aqr_btn'].state = 'normal' logger.info('[LVP Main ] Clock.unschedule(self.zstack_iterate)') Clock.unschedule(self.zstack_iterate) - lumaview.scope.move_absolute_position('Z', self._current_z_pos, wait_until_complete=True) + move_absolute_position('Z', self._current_z_pos, wait_until_complete=True) # Button the triggers 'filechooser.open_file()' from plyer @@ -5094,7 +5171,11 @@ def choose(self, context): self.context = context # Show previously selected/default folder - if self.context in ("apply_stitching_to_folder", "apply_composite_gen_to_folder"): + if self.context in ( + "apply_stitching_to_folder", + "apply_composite_gen_to_folder", + "apply_video_gen_to_folder" + ): selected_path = pathlib.Path(settings['live_folder']) / PROTOCOL_DATA_DIR_NAME if selected_path.exists() is False: selected_path = pathlib.Path(settings['live_folder']) @@ -5151,10 +5232,6 @@ def on_selection_function(self, *a, **k): if self.context == 'live_folder': settings['live_folder'] = str(pathlib.Path(path).resolve()) - - elif self.context == 'video_input_images_folder': - video_creation_controls.set_input_images_loc(directory=path) - elif self.context == 'apply_cell_count_method_to_folder': cell_count_content.apply_method_to_folder( path=path @@ -5163,6 +5240,8 @@ def on_selection_function(self, *a, **k): stitch_controls.run_stitcher(path=pathlib.Path(path)) elif self.context == 'apply_composite_gen_to_folder': composite_gen_controls.run_composite_gen(path=pathlib.Path(path)) + elif self.context == 'apply_video_gen_to_folder': + video_creation_controls.run_video_gen(path=pathlib.Path(path)) else: raise Exception(f"on_selection_function(): Unknown selection {self.context}") @@ -5181,8 +5260,6 @@ def choose(self, context): filetypes = [('TSV', '.tsv')] elif self.context == 'saveas_cell_count_method': filetypes = [('JSON', '.json')] - elif self.context == 'video_output_path': - filetypes = [('AVI', '.avi')] else: logger.exception(f"Unsupported handling for {self.context}") return @@ -5235,14 +5312,6 @@ def on_selection_function(self, *a, **k): if os.path.splitext(filename)[1] == "": filename += ".json" cell_count_content.save_method_as(file=filename) - - elif self.context == 'video_output_path': - if self.selection: - logger.info('[LVP Main ] Set video output path to file:' + self.selection[0]) - filepath = pathlib.Path(self.selection[0]) - if filepath.suffix == "": - filepath = filepath.with_suffix(".avi") - video_creation_controls.set_output_file_loc(file_loc=filepath) def load_log_level(): @@ -5291,8 +5360,7 @@ def on_start(self): load_log_level() load_mode() logger.info('[LVP Main ] LumaViewProApp.on_start()') - lumaview.scope.xyhome() - + move_home(axis='XY') def build(self): @@ -5313,6 +5381,7 @@ def build(self): global stage global wellplate_loader global coordinate_transformer + global objective_helper self.icon = './data/icons/icon.png' stage = Stage() @@ -5341,7 +5410,9 @@ def build(self): # load labware file wellplate_loader = labware_loader.WellPlateLoader() - coordinate_transformer = coord_transformations.CoordinateTransformer()#wellplate_loader=wellplate_loader) + coordinate_transformer = coord_transformations.CoordinateTransformer() + + objective_helper = objectives_loader.ObjectiveLoader() # load settings file if os.path.exists("./data/current.json"): diff --git a/modules/artifact_locations.py b/modules/artifact_locations.py new file mode 100644 index 0000000..af304f4 --- /dev/null +++ b/modules/artifact_locations.py @@ -0,0 +1,32 @@ + + +def stitcher_output_dir() -> str: + return "Stitched" + + +def stitcher_output_metadata_filename() -> str: + return "stitcher_metadata.tsv" + + +def composite_output_dir() -> str: + return "Composite" + + +def composite_output_metadata_filename() -> str: + return "composite_metadata.tsv" + + +def composite_and_stitched_output_dir() -> str: + return "Composite and Stitched" + + +def composite_and_stitched_output_metadata_filename() -> str: + return "composite_and_stitched_metadata.tsv" + + +def video_output_dir() -> str: + return "Video" + + +def video_output_metadata_filename() -> str: + return "video_metadata.tsv" diff --git a/modules/common_utils.py b/modules/common_utils.py index d7004ed..69e384f 100644 --- a/modules/common_utils.py +++ b/modules/common_utils.py @@ -10,7 +10,8 @@ def generate_default_step_name( z_height_idx = None, scan_count = None, tile_label = None, - custom_name_prefix = None + custom_name_prefix = None, + stitched: bool = False, ): if custom_name_prefix not in (None, ""): name = f"{custom_name_prefix}_{color}" @@ -26,6 +27,9 @@ def generate_default_step_name( DESIRED_SCAN_COUNT_DIGITS = 4 if scan_count not in (None, ""): name = f'{name}_{scan_count:0>{DESIRED_SCAN_COUNT_DIGITS}}' + + if stitched: + name = f'{name}_stitched' return name @@ -43,14 +47,12 @@ def get_tile_label_from_name(name: str) -> str | None: return None -def get_well_label_from_name(name: str) -> str | None: - name = name.split('_') - - # Handle case where channel name is parent folder and remov eit - split_name = os.path.split(name[0]) - if len(split_name) == 2: - return split_name[1] +def get_first_section_from_name(name: str) -> str | None: + + # This will retrieve just the filename if the name has parent folders + name = pathlib.Path(name).name + name = name.split('_') return name[0] @@ -89,6 +91,10 @@ def replace_layer_in_step_name(step_name: str, new_layer_name: str) -> str | Non def is_custom_name(name: str) -> bool: + + # This will retrieve just the filename if name includes parent folders + name = pathlib.Path(name).name + name = name.split('_') # All generated names have at least one '_' diff --git a/modules/composite_generation.py b/modules/composite_generation.py index 20403e2..fc1c53f 100644 --- a/modules/composite_generation.py +++ b/modules/composite_generation.py @@ -7,6 +7,7 @@ import numpy as np import pandas as pd +import modules.artifact_locations as artifact_locations import modules.common_utils as common_utils import image_utils from modules.protocol_post_processing_helper import ProtocolPostProcessingHelper @@ -26,9 +27,13 @@ def load_folder(self, path: str | pathlib.Path, tiling_configs_file_loc: pathlib path=path, tiling_configs_file_loc=tiling_configs_file_loc, include_stitched_images=True, - include_composite_images=False + include_composite_images=False, + include_composite_and_stitched_images=False, ) + output_path = path / artifact_locations.composite_output_dir() + output_path_stitched_and_composite = path / artifact_locations.composite_and_stitched_output_dir() + if results['status'] is False: return { 'status': False, @@ -43,13 +48,9 @@ def load_folder(self, path: str | pathlib.Path, tiling_configs_file_loc: pathlib 'message': 'No images found in selected folder' } - df['Composite Group Index'] = df.groupby(by=['Scan Count','Z-Slice','Well','Objective','X','Y'], dropna=False).ngroup() - # Handle composite generation for stitched images also stitched_images_df = results['stitched_images'] - if stitched_images_df is not None: - stitched_images_df['Composite Group Index'] = stitched_images_df.groupby(by=['Scan Count', 'Z-Slice', 'Well'], dropna=False).ngroup() loop_list = itertools.chain( df.groupby(by=['Composite Group Index']), stitched_images_df.groupby(by=['Composite Group Index']) @@ -58,6 +59,9 @@ def load_folder(self, path: str | pathlib.Path, tiling_configs_file_loc: pathlib loop_list = df.groupby(by=['Composite Group Index']) logger.info(f"{self._name}: Generating composite images") + composite_metadata = [] + composite_metadata_stitched = [] + for _, composite_group in loop_list: if len(composite_group) == 0: @@ -67,20 +71,41 @@ def load_folder(self, path: str | pathlib.Path, tiling_configs_file_loc: pathlib logger.debug(f"{self._name}: Skipping composite generation for {composite_group.iloc[0]['Filename']} since {len(composite_group)} layer(s) found.") continue - composite_filename = common_utils.replace_layer_in_step_name( - step_name=composite_group.iloc[0]['Filename'], - new_layer_name='Composite' + # composite_filename = common_utils.replace_layer_in_step_name( + # step_name=composite_group.iloc[0]['Filename'], + # new_layer_name='Composite' + # ) + + first_row = composite_group.iloc[0] + composite_filename_base = common_utils.generate_default_step_name( + well_label=first_row['Well'], + color='Composite', + z_height_idx=first_row['Z-Slice'], + scan_count=first_row['Scan Count'], + tile_label=first_row['Tile'], + custom_name_prefix=first_row['Name'] ) + if first_row['Stitched'] == True: + selected_output_path = output_path_stitched_and_composite + selected_metadata = composite_metadata_stitched + else: + selected_output_path = output_path + selected_metadata = composite_metadata + + if not selected_output_path.exists(): + selected_output_path.mkdir(exist_ok=True, parents=True) + # Don't support OME-TIFF for composite currently - if '.ome' in composite_filename: - composite_filename = composite_filename.replace('.ome', '') + composite_filename = f"{composite_filename_base}.tiff" + # if '.ome' in composite_filename: + # composite_filename = composite_filename.replace('.ome', '') # Create parent folder if needed - split_name = os.path.split(composite_filename) - if len(split_name) == 2: - composite_path = path / split_name[0] - pathlib.Path(composite_path).mkdir(parents=True, exist_ok=True) + # split_name = os.path.split(composite_filename) + # if len(split_name) == 2: + # composite_path = path / split_name[0] + # pathlib.Path(composite_path).mkdir(parents=True, exist_ok=True) # Filter out non-fluorescence layers allowed_layers = common_utils.get_fluorescence_layers() @@ -90,12 +115,53 @@ def load_folder(self, path: str | pathlib.Path, tiling_configs_file_loc: pathlib path=path, df=composite_group[['Filename','Color']] ) + + output_file_loc = selected_output_path / composite_filename + logger.debug(f"{self._name}: - {output_file_loc}") + if not cv2.imwrite( + filename=str(output_file_loc), + img=composite_image + ): + logger.error(f"{self._name}: Unable to write image {output_file_loc}") + continue - logger.debug(f"{self._name}: - {composite_filename}") + selected_metadata.append({ + 'Filename': composite_filename, + 'Name': first_row['Name'], + 'Protocol Group Index': first_row['Protocol Group Index'], + 'Scan Count': first_row['Scan Count'], + 'X': first_row['X'], + 'Y': first_row['Y'], + 'Z-Slice': first_row['Z-Slice'], + 'Well': first_row['Well'], + 'Color': 'Composite', + 'Objective': first_row['Objective'], + 'Tile Group ID': first_row['Tile Group ID'], + 'Tile': first_row['Tile'], + 'Custom Step': first_row['Custom Step'], + 'Stitch Group Index': first_row['Stitch Group Index'], + 'Stitched': first_row['Stitched'], + 'Composite': True, + 'Timestamp': first_row['Timestamp'] + }) + + for metadata, path, metadata_filename in ( + (composite_metadata, output_path, artifact_locations.composite_output_metadata_filename()), + (composite_metadata_stitched, output_path_stitched_and_composite, artifact_locations.composite_and_stitched_output_metadata_filename()) + ): + metadata_df = pd.DataFrame(metadata) + if len(metadata_df) == 0: + continue - _ = cv2.imwrite( - filename=str(path / composite_filename), - img=composite_image + if not path.exists(): + path.mkdir(parents=True, exist_ok=True) + + metadata_df.to_csv( + path_or_buf=path / metadata_filename, + header=True, + index=False, + sep='\t', + lineterminator='\n', ) logger.info(f"{self._name}: Complete") diff --git a/modules/objectives_loader.py b/modules/objectives_loader.py new file mode 100644 index 0000000..06d436c --- /dev/null +++ b/modules/objectives_loader.py @@ -0,0 +1,52 @@ + +''' +MIT License + +Copyright (c) 2023 Etaluma, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyribackground_downght notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +``` +This open source software was developed for use with Etaluma microscopes. +''' + +import json + +from lvp_logger import logger + +class ObjectiveLoader: + + def __init__(self, *arg): + + with open('./data/objectives.json', "r") as read_file: + self._objectives = json.load(read_file) + + + def get_objective_info(self, objective_id: str) -> dict: + try: + objective_info = self._objectives[objective_id] + except: + raise Exception(f"Unable to retrieve information for objective {objective_id}") + + return objective_info + + + def get_objectives_list(self) -> list: + return list(self._objectives.keys()) diff --git a/modules/protocol.py b/modules/protocol.py index d4d65e4..2030d1d 100644 --- a/modules/protocol.py +++ b/modules/protocol.py @@ -266,7 +266,7 @@ def get_tile_groups(self): 'Color', 'Z-Slice', 'Objective', - 'Z-Stack Group ID', + # 'Z-Stack Group ID', 'Custom Step' ], dropna=False diff --git a/modules/protocol_execution_record.py b/modules/protocol_execution_record.py index 2bdd861..7d476c9 100644 --- a/modules/protocol_execution_record.py +++ b/modules/protocol_execution_record.py @@ -23,7 +23,6 @@ def __init__( if (outfile is None) and (records is None): raise Exception(f"Must specify outfile or records") - self._outfile_fp = None if outfile is not None: @@ -69,14 +68,16 @@ def add_step( self._outfile_fp.flush() - def get_data_from_filename(self, filename: str | pathlib.Path) -> int | None: + def get_data_from_filename(self, filename: str | pathlib.Path) -> dict | None: record = self._records.loc[self._records['Filename'] == filename] if len(record) != 1: return None + first_row = record.iloc[0] return { - 'Step Index': record['Step Index'].values[0], - 'Scan Count': record['Scan Count'].values[0] + 'Step Index': first_row['Step Index'], + 'Scan Count': first_row['Scan Count'], + 'Timestamp': first_row['Timestamp'] } @@ -104,7 +105,8 @@ def from_file(cls, file_path: pathlib.Path): 'Filename': row[0], 'Step Name': row[1], 'Step Index': int(row[2]), - 'Scan Count': int(row[3]) + 'Scan Count': int(row[3]), + 'Timestamp': datetime.datetime.strptime(row[4], "%Y-%m-%d %H:%M:%S.%f") } ) diff --git a/modules/protocol_post_processing_helper.py b/modules/protocol_post_processing_helper.py index 29bd10d..04d3cc5 100644 --- a/modules/protocol_post_processing_helper.py +++ b/modules/protocol_post_processing_helper.py @@ -4,10 +4,11 @@ import pandas as pd +import modules.artifact_locations as artifact_locations +import modules.common_utils as common_utils from modules.protocol import Protocol from modules.protocol_execution_record import ProtocolExecutionRecord -import modules.common_utils as common_utils from lvp_logger import logger @@ -18,7 +19,7 @@ def __init__(self): @staticmethod - def _get_image_filenames_from_folder(path: pathlib.Path) -> list: + def _get_image_filenames_from_folder(path: pathlib.Path, exclude_paths: list = []) -> list: tiff_images = path.rglob('*.tif[f]') ome_tiff_images = path.rglob('*.ome.tif[f]') images = [] @@ -26,7 +27,13 @@ def _get_image_filenames_from_folder(path: pathlib.Path) -> list: images.extend(ome_tiff_images) image_names = [] for image in images: - image_names.append(os.path.relpath(image, path)) + image_name = os.path.relpath(image, path) + parent_dir = str(pathlib.Path(image_name).parent) + + if parent_dir in exclude_paths: + continue + + image_names.append(image_name) return image_names @@ -80,12 +87,160 @@ def _is_composite_image(image_filename: str) -> bool: return False + def _get_post_generated_images( + self, + parent_path: pathlib.Path, + artifact_subfolder: str, + metadata_filename: str + ) -> pd.DataFrame | None: + images_path = parent_path / artifact_subfolder + image_metadata_path = images_path / metadata_filename + + load_images = True + if not images_path.exists(): + logger.info(f'{self._name}: No folder found at {images_path}') + load_images = False + + if load_images and not image_metadata_path.exists(): + logger.error(f'{self._name}: No metadata found at {image_metadata_path}') + load_images = False + + if not load_images: + return None + + parse_dates = ['Timestamp'] + df = pd.read_csv( + filepath_or_buffer=image_metadata_path, + sep='\t', + lineterminator='\n', + parse_dates=parse_dates + ) + + # Add subfolder to filename path + df['Filename'] = df.apply(lambda row: str(pathlib.Path(artifact_subfolder, row['Filename'])), axis=1) + + df = df.fillna('') + return df + + + def _get_image_tile_groups( + self, + image_names: list, + protocol_tile_groups, + protocol_execution_record, + ) -> pd.DataFrame | None: + + image_tile_groups = [] + for image_name in image_names: + file_data = protocol_execution_record.get_data_from_filename(filename=image_name) + if file_data is None: + logger.warning(f"No info found in protocol execution record for {image_name}") + continue + + for protocol_group_index, protocol_group_data in protocol_tile_groups.items(): + match = protocol_group_data[protocol_group_data['Step Index'] == file_data['Step Index']] + if len(match) == 0: + continue + + if len(match) > 1: + raise Exception(f"Expected 1 match, but found multiple") + + first_row = match.iloc[0] + image_tile_groups.append( + { + 'Filename': image_name, + 'Name': first_row['Name'], + 'Protocol Group Index': protocol_group_index, + 'Scan Count': file_data['Scan Count'], + 'Step Index': first_row['Step Index'], + 'X': first_row['X'], + 'Y': first_row['Y'], + 'Z-Slice': first_row['Z-Slice'], + 'Well': first_row['Well'], + 'Color': first_row['Color'], + 'Objective': first_row['Objective'], + 'Tile': first_row['Tile'], + 'Tile Group ID': first_row['Tile Group ID'], + 'Z-Stack Group ID': first_row['Z-Stack Group ID'], + 'Custom Step': first_row['Custom Step'], + 'Timestamp': file_data['Timestamp'], + 'Stitched': False, + 'Composite': False + } + ) + + df = pd.DataFrame(image_tile_groups) + + df = self._add_stitch_group_index(df=df) + df = self._add_composite_group_index(df=df) + df = self._add_video_group_index(df=df) + df = df.fillna('') + + return df + + + @staticmethod + def _add_stitch_group_index(df: pd.DataFrame) -> pd.DataFrame: + df['Stitch Group Index'] = df.groupby( + by=[ + 'Protocol Group Index', + 'Scan Count', + 'Z-Slice', + 'Well', + 'Color', + 'Objective', + 'Tile Group ID', + 'Custom Step' + ], + dropna=False + ).ngroup() + return df + + + @staticmethod + def _add_composite_group_index(df: pd.DataFrame) -> pd.DataFrame: + df['Composite Group Index'] = df.groupby( + by=[ + 'Scan Count', + 'Z-Slice', + 'Well', + 'Objective', + 'X', + 'Y', + 'Tile', + 'Custom Step' + ], + dropna=False + ).ngroup() + return df + + + @staticmethod + def _add_video_group_index(df: pd.DataFrame) -> pd.DataFrame: + df['Video Group Index'] = df.groupby( + by=[ + 'Protocol Group Index', + 'Z-Slice', + 'Well', + 'Color', + 'Objective', + 'X', + 'Y', + 'Tile', + 'Custom Step' + ], + dropna=False + ).ngroup() + return df + + def load_folder( self, path: str | pathlib.Path, tiling_configs_file_loc: pathlib.Path, include_stitched_images: bool = False, - include_composite_images: bool = False + include_composite_images: bool = False, + include_composite_and_stitched_images: bool = False, ) -> dict: logger.info(f'{self._name}: Loading folder {path}') path = pathlib.Path(path) @@ -119,117 +274,77 @@ def load_folder( 'message': 'Protocol Execution Record not loaded' } - image_names = self._get_image_filenames_from_folder(path=path) - - protocol_tile_groups = protocol.get_tile_groups() - image_tile_groups = [] - - stitched_images = [] - composite_image_names = [] - composite_images = [] - - logger.info(f"{self._name}: Matching images to stitching groups") - for image_name in image_names: - file_data = protocol_execution_record.get_data_from_filename(filename=image_name) - if file_data is None: - if (include_stitched_images == True) and (self._is_stitched_image(image_filename=image_name) == True): - well = common_utils.get_well_label_from_name(name=image_name) - color = common_utils.get_layer_from_name(name=image_name) - - # Assumes stitched image name is of format ____stitched.tiff - # Not a valid method for non-stitched images - scan_count = int(image_name.split('_')[-2]) - - # Extract Z-slice if applicable - tmp = image_name.split('_')[-3] - if tmp.startswith('Z'): - z_slice = int(tmp[1:]) - else: - z_slice = None - - stitched_images.append({ - 'Filename': image_name, - 'Well': well, - 'Color': color, - 'Scan Count': scan_count, - 'Z-Slice': z_slice - }) - - elif (include_composite_images == True) and (self._is_composite_image(image_filename=image_name) == True): - composite_image_names.append(image_name) - - continue - - scan_count = file_data['Scan Count'] - - for protocol_group_index, protocol_group_data in protocol_tile_groups.items(): - match = protocol_group_data[protocol_group_data['Step Index'] == file_data['Step Index']] - if len(match) == 0: - continue - - if len(match) > 1: - raise Exception(f"Expected 1 match, but found multiple") - - image_tile_groups.append( - { - 'Filename': image_name, - 'Name': match['Name'].values[0], - 'Protocol Group Index': protocol_group_index, - 'Scan Count': scan_count, - 'Step Index': match['Step Index'].values[0], - 'X': match['X'].values[0], - 'Y': match['Y'].values[0], - 'Z-Slice': match['Z-Slice'].values[0], - 'Well': match['Well'].values[0], - 'Color': match['Color'].values[0], - 'Objective': match['Objective'].values[0], - 'Tile Group ID': match['Tile Group ID'].values[0], - 'Z-Stack Group ID': match['Z-Stack Group ID'].values[0], - 'Custom Step': match['Custom Step'].values[0] - } - ) - - break - - # Process composite images - if len(composite_image_names) > 0: - for name in composite_image_names: - well = common_utils.get_well_label_from_name(name=name) - - # Assumes composite image name is of format ___.tiff - # Not a valid method for non-composite images - scan_count = int(name.split('_')[-1].split('.')[0]) - - # Extract Z-slice if applicable - tmp = name.split('_')[-2] - if tmp.startswith('Z'): - z_slice = int(tmp[1:]) - else: - z_slice = None - - + if include_stitched_images: + stitched_images_df = self._get_post_generated_images( + parent_path=path, + artifact_subfolder=artifact_locations.stitcher_output_dir(), + metadata_filename=artifact_locations.stitcher_output_metadata_filename() + ) + + if stitched_images_df is not None: + # Create empty 'Tile label' for already stitched images + stitched_images_df['Tile'] = "" + + stitched_images_df = self._add_stitch_group_index(df=stitched_images_df) + stitched_images_df = self._add_composite_group_index(df=stitched_images_df) + stitched_images_df = self._add_video_group_index(df=stitched_images_df) + stitched_images_df['Stitched'] = True + else: + stitched_images_df = None - composite_images.append({ - 'filename': name, - 'well': well, - 'scan_count': scan_count, - 'z_slice': z_slice - }) - - - df = pd.DataFrame(image_tile_groups) + if include_composite_images: + composite_images_df = self._get_post_generated_images( + parent_path=path, + artifact_subfolder=artifact_locations.composite_output_dir(), + metadata_filename=artifact_locations.composite_output_metadata_filename() + ) - if len(stitched_images) > 0: - stitched_images_df = pd.DataFrame(stitched_images) + if composite_images_df is not None: + composite_images_df = self._add_stitch_group_index(df=composite_images_df) + composite_images_df = self._add_composite_group_index(df=composite_images_df) + composite_images_df = self._add_video_group_index(df=composite_images_df) + composite_images_df['Composite'] = True else: - stitched_images_df = None + composite_images_df = None - if len(composite_images) > 0: - composite_images_df = pd.DataFrame(composite_images) + if include_composite_and_stitched_images: + # Create empty 'Tile label' for already stitched images + stitched_images_df['Tile'] = "" + + composite_and_stitched_images_df = self._get_post_generated_images( + parent_path=path, + artifact_subfolder=artifact_locations.composite_and_stitched_output_dir(), + metadata_filename=artifact_locations.composite_and_stitched_output_metadata_filename() + ) + + if composite_and_stitched_images_df is not None: + composite_and_stitched_images_df = self._add_stitch_group_index(df=composite_and_stitched_images_df) + composite_and_stitched_images_df = self._add_composite_group_index(df=composite_and_stitched_images_df) + composite_and_stitched_images_df = self._add_video_group_index(df=composite_and_stitched_images_df) + composite_and_stitched_images_df['Composite'] = True + composite_and_stitched_images_df['Stitched'] = True else: - composite_images_df = None + composite_and_stitched_images_df = None + + + protocol_tile_groups = protocol.get_tile_groups() + exclude_paths = [ + artifact_locations.composite_output_dir(), + artifact_locations.stitcher_output_dir(), + artifact_locations.composite_and_stitched_output_dir() + ] + + image_names = self._get_image_filenames_from_folder( + path=path, + exclude_paths=exclude_paths + ) + image_tile_groups_df = self._get_image_tile_groups( + image_names=image_names, + protocol_tile_groups=protocol_tile_groups, + protocol_execution_record=protocol_execution_record, + ) return { 'status': True, @@ -237,7 +352,8 @@ def load_folder( 'protocol_execution_record': protocol_execution_record, 'image_names': image_names, 'protocol_tile_groups': protocol_tile_groups, - 'image_tile_groups': df, + 'image_tile_groups':image_tile_groups_df, 'stitched_images': stitched_images_df, - 'composite_images': composite_images_df + 'composite_images': composite_images_df, + 'composite_and_stitched_images': composite_and_stitched_images_df, } diff --git a/modules/stitcher.py b/modules/stitcher.py index 536887c..6a6fb8c 100644 --- a/modules/stitcher.py +++ b/modules/stitcher.py @@ -7,6 +7,7 @@ import numpy as np import pandas as pd +import modules.artifact_locations as artifact_locations import modules.common_utils as common_utils import image_utils from modules.protocol_post_processing_helper import ProtocolPostProcessingHelper @@ -22,11 +23,13 @@ def __init__(self): def load_folder(self, path: str | pathlib.Path, tiling_configs_file_loc: pathlib.Path) -> dict: + path = pathlib.Path(path) results = self._protocol_post_processing_helper.load_folder( path=path, tiling_configs_file_loc=tiling_configs_file_loc, include_stitched_images=False, - include_composite_images=False + include_composite_images=True, + include_composite_and_stitched_images=False, ) if results['status'] is False: @@ -34,41 +37,115 @@ def load_folder(self, path: str | pathlib.Path, tiling_configs_file_loc: pathlib 'status': False, 'message': f'Failed to load protocol data from {path}' } - + + output_path = path / artifact_locations.stitcher_output_dir() + output_path_stitched_and_composite = path / artifact_locations.composite_and_stitched_output_dir() + df = results['image_tile_groups'] - df['Stitch Group Index'] = df.groupby(by=['Protocol Group Index','Scan Count']).ngroup() - - # composite_images_df = results['composite_images'] - # if composite_images_df is not None: - # composite_images_df['stitch_group_index'] = composite_images_df.groupby(by=['scan_count', 'z_slice', 'well']).ngroup() - # loop_list = itertools.chain( - # df.groupby(by=['stitch_group_index']), - # composite_images_df.groupby(by=['stitch_group_index']) - # ) - # else: - # loop_list = df.groupby(by=['stitch_group_index']) + + composite_images_df = results['composite_images'] + if composite_images_df is not None: + loop_list = itertools.chain( + df.groupby(by=['Stitch Group Index']), + composite_images_df.groupby(by=['Stitch Group Index']) + ) + else: + loop_list = df.groupby(by=['Stitch Group Index']) logger.info(f"{self._name}: Generating stitched images") - for _, stitch_group in df.groupby(by=['Stitch Group Index']): + stitched_metadata = [] + stitched_metadata_composite = [] + + count = 0 + for _, stitch_group in loop_list: # pos2pix = self._calc_pos2pix_from_objective(objective=stitch_group['objective'].values[0]) + if len(stitch_group) == 0: + continue + + if len(stitch_group) == 1: + logger.debug(f"{self._name}: Skipping stitching generation for {stitch_group.iloc[0]['Filename']} since only {len(stitch_group)} image tile found.") + continue - stitched_image = self.simple_position_stitcher( + stitched_image, center = self.simple_position_stitcher( path=path, df=stitch_group[['Filename', 'X', 'Y', 'Z-Slice']] ) + stitched_filename = self._generate_stitched_filename(df=stitch_group) + + first_row = stitch_group.iloc[0] + if first_row['Composite'] == True: + selected_output_path = output_path_stitched_and_composite + selected_metadata = stitched_metadata_composite + else: + selected_output_path = output_path + selected_metadata = stitched_metadata + + if not selected_output_path.exists(): + selected_output_path.mkdir(exist_ok=True, parents=True) + # stitched_image = self.position_stitcher( # path=path, # df=stitch_group[['filename', 'x', 'y']], # pos2pix=int(pos2pix * tiling_config.TilingConfig.DEFAULT_FILL_FACTORS['position']) # ) - stitched_filename = self._generate_stitched_filename(df=stitch_group) - logger.debug(f"{self._name}: - {stitched_filename}") + output_file_loc = selected_output_path / stitched_filename + logger.debug(f"{self._name}: - {output_file_loc}") - cv2.imwrite( - filename=str(path / stitched_filename), + if not cv2.imwrite( + filename=str(output_file_loc), img=stitched_image + ): + logger.error(f"{self._name}: Unable to write image {output_file_loc}") + continue + + selected_metadata.append({ + 'Filename': stitched_filename, + 'Name': first_row['Name'], + 'Protocol Group Index': first_row['Protocol Group Index'], + 'Scan Count': first_row['Scan Count'], + 'X': center['x'], + 'Y': center['y'], + 'Z-Slice': first_row['Z-Slice'], + 'Well': first_row['Well'], + 'Color': first_row['Color'], + 'Objective': first_row['Objective'], + 'Tile Group ID': first_row['Tile Group ID'], + 'Custom Step': first_row['Custom Step'], + 'Stitch Group Index': first_row['Stitch Group Index'], + 'Stitched': True, + 'Composite': first_row['Composite'], + 'Timestamp': first_row['Timestamp'], + }) + + count += 1 + + if count == 0: + logger.info(f"{self._name}: No sets of images found to stitch") + return { + 'status': False, + 'message': 'No images found' + } + + for metadata, path, metadata_filename in ( + (stitched_metadata, output_path, artifact_locations.stitcher_output_metadata_filename()), + (stitched_metadata_composite, output_path_stitched_and_composite, artifact_locations.composite_and_stitched_output_metadata_filename()) + ): + metadata_df = pd.DataFrame(metadata) + if len(metadata_df) == 0: + continue + + if not path.exists(): + path.mkdir(parents=True, exist_ok=True) + + metadata_df = pd.DataFrame(metadata) + metadata_df.to_csv( + path_or_buf=path / metadata_filename, + header=True, + index=False, + sep='\t', + lineterminator='\n', ) logger.info(f"{self._name}: Complete") @@ -101,30 +178,7 @@ def _generate_stitched_filename(df: pd.DataFrame) -> str: scan_count=row0['Scan Count'] ) - # if custom_step is True: - # prefix = row0['Filename'].split("_")[0] - # name = common_utils.generate_default_step_name( - # custom_name_prefix=prefix, - # well_label=row0['well'], - # color=row0['color'], - # z_height_idx=row0['z_slice'], - # scan_count=row0['scan_count'] - # ) - # else: - # name = common_utils.generate_default_step_name( - # well_label=row0['well'], - # color=row0['color'], - # z_height_idx=row0['z_slice'], - # scan_count=row0['scan_count'] - # ) - outfile = f"{name}_stitched.tiff" - - # Handle case of individual folders per channel - split_name = os.path.split(row0['Filename']) - if len(split_name) == 2: - outfile = os.path.join(split_name[0], outfile) - return outfile @@ -145,6 +199,14 @@ def simple_position_stitcher(path: pathlib.Path, df: pd.DataFrame): num_x_tiles = df['X'].nunique() num_y_tiles = df['Y'].nunique() + # Used to find the center of the image in X/Y coordinates + x_center = df['X'].unique().mean() + y_center = df['Y'].unique().mean() + center = { + 'x': round(x_center, common_utils.max_decimal_precision(parameter='x')), + 'y': round(y_center, common_utils.max_decimal_precision(parameter='y')), + } + source_image_sample_filename = df['Filename'].values[0] source_image_sample = images[source_image_sample_filename] source_image_w = source_image_sample.shape[1] @@ -207,7 +269,7 @@ def simple_position_stitcher(path: pathlib.Path, df: pd.DataFrame): else: stitched_img[y_val:y_val+im_y, x_val:x_val+im_x,:] = image - return stitched_img + return stitched_img, center @staticmethod diff --git a/modules/video_builder.py b/modules/video_builder.py index d4a62c0..643c111 100644 --- a/modules/video_builder.py +++ b/modules/video_builder.py @@ -1,7 +1,14 @@ +import itertools import pathlib import cv2 +import pandas as pd + +import image_utils +import modules.artifact_locations as artifact_locations +import modules.common_utils as common_utils +from modules.protocol_post_processing_helper import ProtocolPostProcessingHelper from lvp_logger import logger @@ -10,50 +17,155 @@ class VideoBuilder: CODECS = [ 0, - 'mp4v' + 'mp4v', + 'mjpg' ] def __init__(self): self._name = self.__class__.__name__ + self._protocol_post_processing_helper = ProtocolPostProcessingHelper() - - def _get_all_images_in_directory(self, directory: pathlib.Path) -> list[pathlib.Path]: - if not directory.exists(): - raise Exception(f"Directory {directory} does not exist") - - if not directory.is_dir(): - raise Exception(f"{directory} is not a directory") - - images = [] - for image_path in directory.glob("*.tif*"): - images.append(image_path) - - return images + def load_folder( + self, path: str | pathlib.Path, + tiling_configs_file_loc: pathlib.Path, + frames_per_sec: int, + enable_timestamp_overlay: bool + ) -> dict: + results = self._protocol_post_processing_helper.load_folder( + path=path, + tiling_configs_file_loc=tiling_configs_file_loc, + include_stitched_images=True, + include_composite_images=True, + include_composite_and_stitched_images=True, + ) - def _all_images_same_size(self, image_list: list[pathlib.Path]) -> bool: - - frame_shapes = {} + if results['status'] is False: + return { + 'status': False, + 'message': f'Failed to load protocol data from {path}' + } - if len(image_list) == 0: - return False, frame_shapes + df = results['image_tile_groups'] - for image in image_list: - shape = self._get_frame_size(image=image) - - # Track which images have which frame shapes - # Useful for troubleshooting/logging - if shape not in frame_shapes: - frame_shapes[shape] = [] + if len(df) == 0: + return { + 'status': False, + 'message': 'No images found in selected folder' + } + + grouping_key = 'Video Group Index' + + # Raw images first + loop_list = df.groupby(by=[grouping_key]) + + stitched_images_df = results['stitched_images'] + if stitched_images_df is not None: + loop_list = itertools.chain( + loop_list, + stitched_images_df.groupby(by=[grouping_key]) + ) + + composite_images_df = results['composite_images'] + if composite_images_df is not None: + loop_list = itertools.chain( + loop_list, + composite_images_df.groupby(by=[grouping_key]) + ) + + composite_and_stitched_images_df = results['composite_and_stitched_images'] + if composite_and_stitched_images_df is not None: + loop_list = itertools.chain( + loop_list, + composite_and_stitched_images_df.groupby(by=[grouping_key]) + ) + + logger.info(f"{self._name}: Generating video(s)") + metadata = [] + + for _, video_group in loop_list: - frame_shapes[shape].append(image) - - # Check if more than one size of image was found - if len(frame_shapes) > 1: - return False, frame_shapes + if len(video_group) == 0: + continue + + if len(video_group) == 1: + logger.debug(f"{self._name}: Skipping video generation for {video_group.iloc[0]['Filename']} since only {len(video_group)} image found.") + continue + + first_row = video_group.iloc[0] + video_filename_base = common_utils.generate_default_step_name( + well_label=first_row['Well'], + color=first_row['Color'], + z_height_idx=first_row['Z-Slice'], + tile_label=first_row['Tile'], + custom_name_prefix=first_row['Name'], + stitched=first_row['Stitched'] + ) + video_filename = f"{video_filename_base}.avi" + + output_path = path / artifact_locations.video_output_dir() + if not output_path.exists(): + output_path.mkdir(exist_ok=True, parents=True) + + output_file_loc = output_path / video_filename + + status = self._create_video( + path=path, + df=video_group[['Filename', 'Scan Count', 'Timestamp']], + frames_per_sec=frames_per_sec, + enable_timestamp_overlay=enable_timestamp_overlay, + output_file_loc=str(output_file_loc) + ) + + if status == False: + logger.error(f"{self._name}: Unable to create video {output_file_loc}") + continue + + metadata.append({ + 'Filename': video_filename, + 'Name': first_row['Name'], + 'Protocol Group Index': first_row['Protocol Group Index'], + 'X': first_row['X'], + 'Y': first_row['Y'], + 'Z-Slice': first_row['Z-Slice'], + 'Well': first_row['Well'], + 'Color': first_row['Color'], + 'Objective': first_row['Objective'], + 'Tile Group ID': first_row['Tile Group ID'], + 'Custom Step': first_row['Custom Step'], + 'Stitch Group Index': first_row['Stitch Group Index'], + 'Composite Group Index': first_row['Composite Group Index'], + 'Video Group Index': first_row['Video Group Index'], + 'Stitched': first_row['Stitched'], + 'Composite': first_row['Composite'], + }) + + metadata_df = pd.DataFrame(metadata) - return True, frame_shapes - + if len(metadata_df) == 0: + return { + 'status': False, + 'message': 'No images found' + } + + if not output_path.exists(): + path.mkdir(parents=True, exist_ok=True) + + metadata_filename = artifact_locations.video_output_metadata_filename() + metadata_df.to_csv( + path_or_buf=output_path / metadata_filename, + header=True, + index=False, + sep='\t', + lineterminator='\n', + ) + + logger.info(f"{self._name}: Complete") + return { + 'status': True, + 'message': 'Success' + } + @staticmethod def _get_fourcc_code(codec: str | int): @@ -64,85 +176,63 @@ def _get_fourcc_code(codec: str | int): return fourcc - - def _get_frame_size(self, image: pathlib.Path) -> dict: - frame = cv2.imread(str(image), cv2.IMREAD_UNCHANGED) - height, width, _ = frame.shape - - return (height, width) - - - def create_video_from_directory( + + def _create_video( self, - input_directory: pathlib.Path, + path: pathlib.Path, + df: pd.DataFrame, frames_per_sec: int, + enable_timestamp_overlay: bool, output_file_loc: pathlib.Path ) -> bool: - - def _are_valid_inputs(): - if not issubclass(type(input_directory), pathlib.Path): - logger.error(f"[{self._name}] Expected input directory to be of type pathlib.Path, got {type(input_directory)}") - return False - - if not issubclass(type(output_file_loc), pathlib.Path): - logger.error(f"[{self._name}] Expected output file location to be of type pathlib.Path, got {type(output_file_loc)}") - return False - - if type(frames_per_sec) not in (int, float): - logger.error(f"[{self._name}] Invalid type for frames_per_sec, must be int or float") - return False + df = df.sort_values(by=['Scan Count'], ascending=True) - if frames_per_sec <= 0: - logger.error(f"[{self._name}] Invalid value for frames_per_sec, must be >0") - return False + # codec = self.CODECS[0] # Set to AVI + codec = self.CODECS[1] # Set to mp4v + fourcc = self._get_fourcc_code(codec=codec) + + def _get_image_info() -> tuple: + source_image_sample_filename = df['Filename'].values[0] + source_image_sample_filepath = path / source_image_sample_filename + source_image_sample = cv2.imread(str(source_image_sample_filepath), cv2.IMREAD_UNCHANGED) + is_color = True if source_image_sample.ndim == 3 else False - return True + if is_color: + frame_height, frame_width, _ = source_image_sample.shape + else: + frame_height, frame_width = source_image_sample.shape - - if not _are_valid_inputs(): - return False - - - logger.info(f"""[{self._name}] Starting video creation: - Input directory: {input_directory} - Output file: {output_file_loc} - """) + return (frame_height, frame_width), is_color - images = self._get_all_images_in_directory(directory=input_directory) - - if len(images) == 0: - logger.error(f"[{self._name}] No images found in {input_directory}") - return False - - logger.info(f"[{self._name}] Found {len(images)} images") - - valid_image_size_match, frame_sizes = self._all_images_same_size(image_list=images) - - if not valid_image_size_match: - logger.error(f"[{self._name}] Not all images in {input_directory} have matching dimensions:\n{frame_sizes}") - return False - - codec = self.CODECS[0] # Set to AVI - - fourcc = self._get_fourcc_code(codec=codec) - - frame_height, frame_width = self._get_frame_size(image=images[0]) + def _get_timestamp_str(val): + frame_ts = val.to_pydatetime() + frame_ts_str = frame_ts.strftime("%Y-%m-%d %H:%M:%S") + return frame_ts_str + (frame_height, frame_width), is_color = _get_image_info() video = cv2.VideoWriter( filename=str(output_file_loc), fourcc=fourcc, fps=frames_per_sec, frameSize=(frame_width, frame_height), - isColor=True + isColor=is_color ) logger.info(f"[{self._name}] Writing video to {output_file_loc}") - for image in images: - video.write(cv2.imread(str(image), cv2.IMREAD_UNCHANGED)) + + for _, row in df.iterrows(): + image_path = path / row['Filename'] + image = cv2.imread(str(image_path), cv2.IMREAD_UNCHANGED) + + if enable_timestamp_overlay: + frame_ts = _get_timestamp_str(row['Timestamp']) + image = image_utils.add_timestamp(image=image, timestamp_str=frame_ts) + + video.write(image) cv2.destroyAllWindows() video.release() - logger.info(f"[{self._name}] Video creation complete") + logger.debug(f"[{self._name}] - Complete") return True diff --git a/modules/zstack_config.py b/modules/zstack_config.py index 5468db6..251eafe 100644 --- a/modules/zstack_config.py +++ b/modules/zstack_config.py @@ -1,6 +1,8 @@ import numpy as np +import modules.common_utils as common_utils + class ZStackConfig: @@ -21,7 +23,7 @@ def number_of_steps(self) -> int: if self._step_size == 0: return 0 - return np.floor(self._range/self._step_size) + return np.floor(self._range/self._step_size)+1 def step_positions(self) -> dict[int, float]: @@ -35,5 +37,7 @@ def step_positions(self) -> dict[int, float]: start_pos = self._current_z_value position_values = (np.arange(n_steps)*self._step_size + start_pos).tolist() + max_precision = common_utils.max_decimal_precision(parameter='z') + position_values = [round(val, max_precision) for val in position_values] return {index: value for index, value in enumerate(position_values)}