diff --git a/setup.cfg b/setup.cfg index c9f6f04..ad4712a 100644 --- a/setup.cfg +++ b/setup.cfg @@ -30,6 +30,8 @@ install_requires = magicgui qtpy iohub + pandas + openpyxl python_requires = >=3.10 include_package_data = True diff --git a/src/napari_iohub/_reader.py b/src/napari_iohub/_reader.py index e8b285d..9c17b68 100644 --- a/src/napari_iohub/_reader.py +++ b/src/napari_iohub/_reader.py @@ -17,6 +17,7 @@ open_ome_zarr, ) from pydantic.color import Color +import pandas as pd if TYPE_CHECKING: from _typeshed import StrOrBytesPath @@ -246,7 +247,22 @@ def plate_to_layers( plate: Plate, row_range: tuple[int, int] = None, col_range: tuple[int, int] = None, + metadata_df: pd.DataFrame = None, + meta_list: list = None, ): + """ + Convert a Plate object to a list of layers for visualization in napari. + + Args: + plate (Plate): The Plate object to convert. + row_range (tuple[int, int], optional): The range of rows to include. Defaults to None. + col_range (tuple[int, int], optional): The range of columns to include. Defaults to None. + metadata_df (pd.DataFrame, optional): The metadata DataFrame. Defaults to None. + meta_list (list, optional): The list of metadata. Defaults to None. + + Returns: + list: A list of layers for visualization in napari. + """ plate_arrays = [] rows = plate.metadata.rows if row_range: @@ -265,6 +281,7 @@ def plate_to_layers( well_path = f"{row_name}/{col_name}" if well_path in well_paths: well = plate[row_name][col_name] + # Stack the well images by position layers_kwargs, ch_axis, arrays = stack_well_by_position(well) row_arrays.append([a[0] for a in arrays]) height, width = arrays[0][0].shape[-2:] @@ -274,16 +291,30 @@ def plate_to_layers( height * (i + 1), width * (j + 1), ] + # Calculate the bounding box extents for each well for k in range(len(boxes)): boxes[k].append(box_extents[k] - 0.5) + # Add the well path and position to the properties dictionary + well_id = well_path + "/" + next(well.positions())[0] + meta_value = "" + + # FIXME: Figure out parsing of metadata values from supplied keys. + # meta_value = metadata_df.loc[ + # metadata_df["Well ID"] == row_name + col_name, meta_list + # ].values[0] + # meta_value = "\n".join(meta_value) + properties["fov"].append( - well_path + "/" + next(well.positions())[0] + well_id + "/" + next(well.positions())[0] + meta_value ) else: row_arrays.append(None) plate_arrays.append(row_arrays) + + # Get the shape and dtype of the first non-empty block first_blocks = next(a for a in plate_arrays[0] if a is not None) fill_args = [(b.shape, b.dtype) for b in first_blocks] + plate_levels = [] for level, _ in enumerate(first_blocks): plate_level = [] @@ -291,6 +322,7 @@ def plate_to_layers( row_level = [] for c in r: if c is None: + # Create an empty block with the same shape and dtype arr = da.zeros( shape=fill_args[level][0], dtype=fill_args[level][1] ) @@ -298,7 +330,10 @@ def plate_to_layers( arr = c[level] row_level.append(arr) plate_level.append(row_level) + # Block the row levels to create the plate level plate_levels.append(da.block(plate_level)) + + # Create layers from the plate levels layers = layers_from_arrays( layers_kwargs, ch_axis, @@ -306,6 +341,8 @@ def plate_to_layers( mode="stitch", layer_type="image", ) + + # Add a plate map layer with bounding boxes and properties layers.append( [ make_bbox(boxes), @@ -313,12 +350,13 @@ def plate_to_layers( "face_color": "transparent", "edge_color": "black", "properties": properties, - "text": {"string": "fov", "color": "orange"}, + "text": {"string": "fov", "color": "white"}, "name": "Plate Map", }, "shapes", ] ) + return layers diff --git a/src/napari_iohub/_widget.py b/src/napari_iohub/_widget.py index 068169d..067f50e 100644 --- a/src/napari_iohub/_widget.py +++ b/src/napari_iohub/_widget.py @@ -1,12 +1,15 @@ import logging import os -from typing import Callable - +from typing import Callable, List, Optional import napari from iohub.ngff import Plate, Row, Well, open_ome_zarr from napari.qt.threading import create_worker from napari.utils.notifications import show_info from qtpy.QtCore import Qt +from superqt import QRangeSlider +from napari_iohub._reader import plate_to_layers, well_to_layers +import pandas as pd + from qtpy.QtWidgets import ( QComboBox, QFileDialog, @@ -18,14 +21,30 @@ QVBoxLayout, QWidget, ) -from superqt import QRangeSlider - -from napari_iohub._reader import plate_to_layers, well_to_layers def _add_nav_combobox( parent: QWidget, label: str, connect: Callable, form_layout: QFormLayout ): + """ + Add a navigation combo box widget to the form layout. + + Parameters + ---------- + parent : QWidget + The parent widget. + label : str + The label for the combo box. + connect : Callable + The callback function to connect to the combo box's currentTextChanged signal. + form_layout : QFormLayout + The form layout to add the combo box to. + + Returns + ------- + QComboBox + The created combo box widget. + """ cb = QComboBox(parent) label = QLabel(cb, text=label) cb.currentTextChanged[str].connect(connect) @@ -34,6 +53,21 @@ def _add_nav_combobox( def _choose_dir(parent: QWidget, caption="Open a directory"): + """ + Open a file dialog to choose a directory. + + Parameters + ---------- + parent : QWidget + The parent widget. + caption : str, optional + The caption for the file dialog, by default "Open a directory" + + Returns + ------- + str + The selected directory path. + """ path = QFileDialog.getExistingDirectory( parent=parent, caption=caption, @@ -49,6 +83,65 @@ def __init__(self): class MainWidget(QWidget): + """ + A widget for napari that provides functionality to load and view NGFF HCS plate datasets. + + Parameters + ---------- + napari_viewer : napari.Viewer + The napari viewer instance. + + Attributes + ---------- + viewer : napari.Viewer + The napari viewer instance. + dataset : Plate + The loaded NGFF HCS plate dataset. + view_mode : str + The view mode for multiple positions ('stitch' or 'stack'). + main_layout : QVBoxLayout + The main layout of the widget. + dataset_path_le : QLineEdit + The line edit widget to display the path of the loaded dataset. + row_range_rs : QRangeSlider + The range slider widget for selecting the row range. + col_range_rs : QRangeSlider + The range slider widget for selecting the column range. + view_plate_btn : QPushButton + The button to show the plate. + row_cb : QComboBox + The combo box widget for selecting the row. + well_cb : QComboBox + The combo box widget for selecting the well. + view_well_btn : QPushButton + The button to show the well. + + Methods + ------- + _add_load_data_layout() + Add the layout for loading the dataset. + _add_plate_layout() + Add the layout for viewing one FOV from every well. + _add_well_layout() + Add the layout for viewing all FOVs in a well. + _add_range_slider(label, form_layout) + Add a range slider widget to the form layout. + _load_dataset() + Load the dataset from the selected directory. + _show_plate() + Show the plate with the selected row and column range. + _load_row(row_name) + Load the selected row and update the well combo box. + _load_well(well_name) + Load the selected well. + _view_mode(view_mode) + Set the view mode for multiple positions. + _show_well() + Show the well with the selected view mode. + _update_layers(layers) + Update the layers in the viewer with the given layer data. + """ + def __init__(self, napari_viewer: napari.Viewer): super().__init__() self.viewer = napari_viewer @@ -57,22 +150,48 @@ def __init__(self, napari_viewer: napari.Viewer): self.main_layout = QVBoxLayout() self._add_load_data_layout() self.main_layout.addWidget(QHLine()) + self._add_metadata_layout() + self.main_layout.addWidget(QHLine()) self._add_plate_layout() self.main_layout.addWidget(QHLine()) self._add_well_layout() self.setLayout(self.main_layout) def _add_load_data_layout(self): + """ + Add the layout for loading the dataset. + + This layout includes a button to load the dataset, a line edit widget to display the path of the loaded dataset, + a button to load the metadata file, and a tooltip for the load buttons. + """ form_layout = QFormLayout() - load_btn = QPushButton("Load dataset") - load_btn.clicked.connect(self._load_dataset) - load_btn.setToolTip("Select a directory of the NGFF HCS plate dataset") + load_zarr_btn = QPushButton("Load zarr store") + load_zarr_btn.clicked.connect(self._load_dataset) + load_zarr_btn.setToolTip( + "Select a directory of the NGFF HCS plate zarr store." + ) self.dataset_path_le = QLineEdit() self.dataset_path_le.setReadOnly(True) - form_layout.addRow(load_btn, self.dataset_path_le) + form_layout.addRow(load_zarr_btn, self.dataset_path_le) + + load_metadata_btn = QPushButton("Load metadata file") + load_metadata_btn.clicked.connect(self._load_metadata) + load_metadata_btn.setToolTip( + "Select an Excel file containing the metadata." + ) + self.metadata_path_le = QLineEdit() + self.metadata_path_le.setReadOnly(True) + form_layout.addRow(load_metadata_btn, self.metadata_path_le) + self.main_layout.addLayout(form_layout) def _add_plate_layout(self): + """ + Add the layout for viewing one FOV from every well. + + This layout includes a label, two range slider widgets for selecting the row and column range, + and a button to show the plate. + """ outer_layout = QVBoxLayout() form_layout = QFormLayout() label = QLabel(text="View one FOV from every well") @@ -85,6 +204,12 @@ def _add_plate_layout(self): self.main_layout.addLayout(outer_layout) def _add_well_layout(self): + """ + Add the layout for viewing all FOVs in a well. + + This layout includes a label, two combo box widgets for selecting the row and well, + a combo box widget for selecting the view mode, and a button to show the well. + """ outer_layout = QVBoxLayout() label = QLabel(text="View all FOVs in a well") outer_layout.addWidget(label) @@ -110,7 +235,61 @@ def _add_well_layout(self): outer_layout.addWidget(self.view_well_btn) self.main_layout.addLayout(outer_layout) + def _add_metadata_layout(self, meta_list: Optional[List[str]] = None): + """ + Add the layout for selecting wells by metadata. + + This layout includes a label and several combo box widgets for selecting well metadata, + and an action button to filter the wells based on the selected metadata. + + Parameters + ---------- + meta_list : List[str], optional + The list of well metadata names, by default None. + """ + outer_layout = QVBoxLayout() + label = QLabel(text="Overlay metadata") + outer_layout.addWidget(label) + form_layout = QFormLayout() + self.meta1_cb = _add_nav_combobox( + self, "Meta1", self._update_well_combobox, form_layout + ) + self.meta2_cb = _add_nav_combobox( + self, "Meta2", self._update_well_combobox, form_layout + ) + self.meta3_cb = _add_nav_combobox( + self, "Meta3", self._update_well_combobox, form_layout + ) + if meta_list: + for i, meta_name in enumerate(meta_list, start=1): + meta_cb = _add_nav_combobox( + self, + f"Meta{i}", + self._update_well_combobox, + form_layout, + ) + setattr(self, f"meta{i}_cb", meta_cb) + # filter_btn = QPushButton("Filter wells by metadata") #Not implemented yet. + # form_layout.addRow(filter_btn) + outer_layout.addLayout(form_layout) + self.main_layout.addLayout(outer_layout) + def _add_range_slider(self, label: str, form_layout: QFormLayout): + """ + Add a range slider widget to the form layout. + + Parameters + ---------- + label : str + The label for the range slider. + form_layout : QFormLayout + The form layout to add the range slider to. + + Returns + ------- + QRangeSlider + The range slider widget. + """ slider = QRangeSlider(Qt.Orientation.Horizontal) slider.setRange(0, 1) slider.setValue((0, 1)) @@ -122,6 +301,13 @@ def _add_range_slider(self, label: str, form_layout: QFormLayout): return slider def _load_dataset(self): + """ + Load the dataset from the selected directory. + + This method opens a file dialog to select a directory, sets the dataset path line edit widget, + and loads the dataset using the open_ome_zarr function from iohub.ngff module. + It also updates the row and column combo box widgets with the available row and column names. + """ path = _choose_dir(self) logging.debug(f"Got dataset path '{path}'") if not path: @@ -146,7 +332,41 @@ def _load_dataset(self): self.view_plate_btn.clicked.connect(self._show_plate) self.view_well_btn.clicked.connect(self._show_well) + def _load_metadata(self): + """ + Load the metadata file. + + This method opens a file dialog to select an Excel file containing the metadata. + It then reads the file and saves the column headings in self.meta_list. + """ + path = QFileDialog.getOpenFileName( + self, + "Open metadata file", + os.getcwd(), + "Excel files (*.xlsx *.xls)", + )[0] + logging.debug(f"Got metadata path '{path}'") + if not path: + return + self.metadata_path_le.setText(path) + + try: + self.metadata_df = pd.read_excel(path) + self.meta_list = list(self.metadata_df.columns) + except Exception as e: + logging.error(f"Error loading metadata file: {e}") + return + + self.meta1_cb.addItems(self.meta_list) + self.meta2_cb.addItems(self.meta_list) + self.meta3_cb.addItems(self.meta_list) + def _show_plate(self): + """ + Show the plate with the selected row and column range. + + This method creates a worker thread to load the plate data and update the layers in the viewer. + """ row_range = self.row_range_rs.value() col_range = self.col_range_rs.value() show_info( @@ -158,12 +378,32 @@ def _show_plate(self): plate=self.dataset, row_range=row_range, col_range=col_range, + metadata_df=self.metadata_df, + meta_list=self.meta_list, ) + + # We could pass the metadata_df to the worker here and have it update the Plate Layout layer. Is that the best approach to overlay the metadata? worker.returned.connect(self._update_layers) logging.debug("Starting plate data loading worker") worker.start() + def _update_well_combobox(self): + """ + Select wells by metadata. + + This method updates the row and well combo box widgets based on the selected metadata. + """ + logging.debug("Selecting wells by metadata") + def _load_row(self, row_name: str): + """ + Load the selected row and update the well combo box. + + Parameters + ---------- + row_name : str + The selected row name. + """ logging.debug(f"Got row name '{row_name}'") self.row: Row = self.dataset[row_name] self.well_cb.clear() @@ -176,14 +416,35 @@ def _load_row(self, row_name: str): self.well_cb.addItems(self.well_names) def _load_well(self, well_name: str): + """ + Load the selected well. + + Parameters + ---------- + well_name : str + The selected well name. + """ logging.debug(f"Got well name '{well_name}'") self.well: Well = self.row[well_name] def _view_mode(self, view_mode: str): + """ + Set the view mode for multiple positions. + + Parameters + ---------- + view_mode : str + The selected view mode. + """ logging.debug(f"Got well name '{view_mode}'") self.view_mode = view_mode def _show_well(self): + """ + Show the well with the selected view mode. + + This method creates a worker thread to load the well data and update the layers in the viewer. + """ show_info(f"Showing well '{self.well.zgroup.name}' \n") self.well.print_tree() worker = create_worker( @@ -197,6 +458,14 @@ def _show_well(self): worker.start() def _update_layers(self, layers: list[tuple]): + """ + Update the layers in the viewer with the given layer data. + + Parameters + ---------- + layers : list[tuple] + The layer data to update the viewer with. + """ logging.debug("Clearing existing layers in the viewer") # FIXME: different dimensions can cause errors # here it clears all layers which will clear user settings too diff --git a/src/scripts/debug_napari_iohub.py b/src/scripts/debug_napari_iohub.py new file mode 100644 index 0000000..99ba9e3 --- /dev/null +++ b/src/scripts/debug_napari_iohub.py @@ -0,0 +1,32 @@ +# This script can be modified to debug and test specific methods of the plugin + +import napari +import time +from napari_iohub._widget import MainWidget +from pathlib import Path + +zarr_store = Path( + "/hpc/projects/intracellular_dashboard/viral-sensor/2024_05_03_DENV_eFT226_Timecourse/0-convert/2024_05_03_DENV_eFT226_Timecourse_1.zarr" +) +metadata_file = Path("20240503_Plate Map Template.xlsx") + + +def main(): + viewer = napari.Viewer() + napari_iohub = MainWidget(viewer) + viewer.window.add_dock_widget(napari_iohub) + # recorder.ui.qbutton_connect_to_mm.click() + # recorder.calib_scheme = "5-State" + + # for repeat in range(REPEATS): + # for swing in SWINGS: + # print("Calibrating with swing = " + str(swing)) + # recorder.swing = swing + # recorder.directory = SAVE_DIR + # recorder.run_calibration() + # time.sleep(100) + + +if __name__ == "__main__": + main() + input("Press Enter to close")