diff --git a/src/cell_gater/model/data_model.py b/src/cell_gater/model/data_model.py index 53d09c6..e8412f9 100644 --- a/src/cell_gater/model/data_model.py +++ b/src/cell_gater/model/data_model.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from dataclasses import dataclass, field from pathlib import Path from typing import Sequence @@ -8,15 +10,21 @@ @dataclass class DataModel: + """Model containing all necessary fields for gating.""" + events: EmitterGroup = field(init=False, default=None, repr=True) _samples: Sequence[str] = field(default_factory=list, init=False) - _regionprops_df: pd.DataFrame = field( - default_factory=pd.DataFrame, init=False - ) + _regionprops_df: pd.DataFrame = field(default_factory=pd.DataFrame, init=False) + _regionprops_columns: Sequence[str] = field(default_factory=list, init=False) _image_paths: Sequence[Path] = field(default_factory=list, init=False) _mask_paths: Sequence[Path] = field(default_factory=list, init=False) + _lower_bound_marker: str | None = field(default=None, init=False) + _upper_bound_marker: str | None = field(default=None, init=False) + _markers: Sequence[str] = field(default_factory=list, init=False) + _active_marker: str | None = field(default=None, init=False) def __post_init__(self) -> None: + """Allow fields in the dataclass to emit events when changed.""" self.events = EmitterGroup( source=self, samples=Event, @@ -25,33 +33,77 @@ def __post_init__(self) -> None: @property def samples(self): + """Samples derived from the regionprops csv file names.""" return self._samples @samples.setter - def samples(self, samples: Sequence[str]): + def samples(self, samples: Sequence[str]) -> None: self._samples = samples @property def regionprops_df(self): + """Regionprops dataframe derived from possibly multiple csv files.""" return self._regionprops_df @regionprops_df.setter - def regionprops_df(self, regionprops: pd.DataFrame): + def regionprops_df(self, regionprops: pd.DataFrame) -> None: self._regionprops_df = regionprops self.events.regionprops_df() @property def image_paths(self): + """The paths to the images.""" return self._image_paths @image_paths.setter - def image_paths(self, image_paths: Sequence[Path]): + def image_paths(self, image_paths: Sequence[Path]) -> None: self._image_paths = image_paths @property def mask_paths(self): + """The paths to the mask images.""" return self._mask_paths @mask_paths.setter - def mask_paths(self, mask_paths: Sequence[Path]): + def mask_paths(self, mask_paths: Sequence[Path]) -> None: self._mask_paths = mask_paths + + @property + def lower_bound_marker(self): + """The lower bound column name of the marker columns to be included from regionprops_df.""" + return self._lower_bound_marker + + @lower_bound_marker.setter + def lower_bound_marker(self, marker: str) -> None: + self._lower_bound_marker = marker + + @property + def upper_bound_marker(self): + """The inclusive upper bound column name of the marker columns to be included from regionprops_df.""" + return self._upper_bound_marker + + @upper_bound_marker.setter + def upper_bound_marker(self, marker: str) -> None: + self._upper_bound_marker = marker + + @property + def markers(self): + """The markers included for gating.""" + return self._markers + + @markers.setter + def markers(self, markers: Sequence[str]) -> None: + self._markers = markers + + @property + def active_marker(self): + """The marker currently used on x-axis for gating.""" + return self._active_marker + + @active_marker.setter + def active_marker(self, marker: str) -> None: + self._active_marker = marker + + def validate(self): + """Validate the input data from the user provided through the SampleWidget.""" + pass diff --git a/src/cell_gater/widgets/sample_widget.py b/src/cell_gater/widgets/sample_widget.py index 9e828ce..513a869 100644 --- a/src/cell_gater/widgets/sample_widget.py +++ b/src/cell_gater/widgets/sample_widget.py @@ -1,12 +1,10 @@ from pathlib import Path -from typing import Any from napari import Viewer from napari.utils.history import ( get_open_history, update_open_history, ) -from qtpy.QtCore import Qt from qtpy.QtWidgets import ( QComboBox, QFileDialog, @@ -25,10 +23,25 @@ class SampleWidget(QWidget): """Sample widget for loading required data.""" - def __init__(self, viewer: Viewer) -> None: + def __init__(self, viewer: Viewer, model: DataModel | None = None) -> None: + """ + Create the QWidget visuals. + + This widget sets up the QtWidget elements used to determine what the input is that the user wants to use + for cell gating. It also connects these elements to their appropriate callback functions. + + Parameters + ---------- + viewer : napari.Viewer + The napari Viewer instance. + model : DataModel | None + The data model dataclass. If provided, this means that the plugin is used by means of a CLI to be + implemented. + + """ super().__init__() self._viewer = viewer - self._model = DataModel() + self._model = DataModel() if model is None else model self.setLayout(QGridLayout()) self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed) @@ -49,38 +62,30 @@ def __init__(self, viewer: Viewer) -> None: self.load_mask_dir_button.clicked.connect(self._open_mask_dir_dialog) self.layout().addWidget(self.load_mask_dir_button, 1, 2) - # The lower bound marker column + # The lower bound marker column dropdown lower_col = QLabel("Select lowerbound marker column:") self.lower_bound_marker_col = QComboBox() if len(self.model.regionprops_df) > 0: self.lower_bound_marker_col.addItems([None] + self.model.regionprops_df.columns) - self.lower_bound_marker_col.currentTextChanged.connect(self._update_marker_widget) + self.lower_bound_marker_col.currentTextChanged.connect(self._update_model_lowerbound) self.layout().addWidget(lower_col, 2, 0) self.layout().addWidget(self.lower_bound_marker_col, 3, 0) - # The upper bound marker column + # The upper bound marker column dropdown upper_col = QLabel("Select upperbound marker column:") self.upper_bound_marker_col = QComboBox() if len(self.model.regionprops_df) > 0: self.upper_bound_marker_col.addItems([None] + self.model.regionprops_df.columns) - self.upper_bound_marker_col.currentTextChanged.connect(self._update_marker_widget) + self.upper_bound_marker_col.currentTextChanged.connect(self._update_model_upperbound) self.layout().addWidget(upper_col, 2, 1) self.layout().addWidget(self.upper_bound_marker_col, 3, 1) - # Dropdown of samples once directory is loaded - selection_label = QLabel("Select sample:") - self.sample_selection_dropdown = QComboBox() - if len(self.model.samples) > 0: - self.sample_selection_dropdown.addItems([None]) - self.sample_selection_dropdown.addItems(self.model.samples) - self.sample_selection_dropdown.currentTextChanged.connect(self._on_sample_changed) + self.validate_button = QPushButton("Validate input") + self.validate_button.clicked.connect(self.model.validate) + self.layout().addWidget(self.validate_button, 4, 0) - self.layout().addWidget(selection_label, 4, 0, Qt.AlignCenter) - self.layout().addWidget(self.sample_selection_dropdown, 4, 1) - - self.model.events.regionprops_df.connect(self._set_samples_dropdown) - self.model.events.regionprops_df.connect(self._set_marker_lowerbound) - self.model.events.regionprops_df.connect(self._set_marker_upperbound) + self.model.events.regionprops_df.connect(self._set_dropdown_marker_lowerbound) + self.model.events.regionprops_df.connect(self._set_dropdown_marker_upperbound) @property def viewer(self) -> Viewer: @@ -92,10 +97,8 @@ def model(self) -> DataModel: """Data model of the widget.""" return self._model - def _on_sample_changed(self): - pass - def _dir_dialog(self): + """Open dialog for a user to pass on a directory.""" dlg = QFileDialog() hist = get_open_history() dlg.setHistory(hist) @@ -128,38 +131,36 @@ def _open_mask_dir_dialog(self): if folder not in {"", None}: self._set_mask_paths(folder) - def _set_image_paths(self, folder): + def _set_image_paths(self, folder: str) -> None: + """Set the image paths in the DataModel.""" self.model.image_paths = list(Path(folder).glob("*tif")) napari_notification(f"{len(self.model.image_paths)} paths of images loaded.") - def _set_mask_paths(self, folder): + def _set_mask_paths(self, folder: str) -> None: + """Set the paths of the masks in the DataModel.""" self.model.mask_paths = list(Path(folder).glob("*tif")) napari_notification(f"{len(self.model.mask_paths)} paths of masks loaded.") - def _assign_regionprops_to_model(self, folder): - self.model.regionprops_df = stack_csv_files(folder) + def _assign_regionprops_to_model(self, folder: str) -> None: + """Read the csv files in the directory and assign the resulting concatenated DataFrame in the DataModel.""" + self.model.regionprops_df = stack_csv_files(Path(folder)) - def _set_samples_dropdown(self, event: Any): - if (region_props := self.model.regionprops_df) is not None: - self.model.samples = list(region_props["sample_id"].cat.categories) - - # New directory loaded so we reload the dropdown items - self.sample_selection_dropdown.clear() - if len(self.model.samples) > 0: - self.sample_selection_dropdown.addItems([None]) - self.sample_selection_dropdown.addItems(self.model.samples) - - def _update_marker_widget(self): - pass - - def _set_marker_lowerbound(self): + def _set_dropdown_marker_lowerbound(self): self.lower_bound_marker_col.clear() region_props = self.model.regionprops_df if region_props is not None and len(region_props) > 0: self.lower_bound_marker_col.addItems(region_props.columns) - def _set_marker_upperbound(self): + def _set_dropdown_marker_upperbound(self): self.upper_bound_marker_col.clear() region_props = self.model.regionprops_df if region_props is not None and len(region_props) > 0: self.upper_bound_marker_col.addItems(region_props.columns) + + def _update_model_lowerbound(self): + lower_bound_marker = self.lower_bound_marker_col.currentText() + self.model.lower_bound_marker = lower_bound_marker + + def _update_model_upperbound(self): + upper_bound_marker = self.upper_bound_marker_col.currentText() + self.model.upper_bound_marker = upper_bound_marker