diff --git a/README.md b/README.md index 3843b61..479f46e 100644 --- a/README.md +++ b/README.md @@ -30,10 +30,16 @@ hook by running `pre-commit install` in the directory with the `.pre-commit-conf ## Installation +Install napari (see https://napari.org/stable/tutorials/fundamentals/installation) + You can install `napari-cell-gater` via [pip]: pip install napari-cell-gater +or directly from github via [pip] with: + + pip install git+https://github.com/melonora/napari-cell-gater.git@stable + ## Visual Workflow ![Image Alt Text](/docs/VisualWorkflow_highres.png) diff --git a/src/cell_gater/_tests/_test_sample_widget.py b/src/cell_gater/_tests/_test_sample_widget.py new file mode 100644 index 0000000..c897044 --- /dev/null +++ b/src/cell_gater/_tests/_test_sample_widget.py @@ -0,0 +1,41 @@ +import tempfile +from typing import Any + +import numpy as np +import pandas as pd + +from cell_gater.widgets.sample_widget import SampleWidget + +rng = np.random.default_rng(42) +# if we get reports that with some column names there is a problem, we get it here to automatically test. +MARKER_DF = pd.DataFrame( + { + "CellID": list(range(5)), + "DNA": rng.random(5), + "Rabbit IgG": rng.random(5), + "Goat IgG": rng.random(5), + "DNA2": rng.random(5), + "CD73": rng.random(5), + "Some.name.with.dots": rng.random(5), + } +) + + +def test_populate_markers_on_csv_load(make_napari_viewer: Any) -> None: + viewer = make_napari_viewer() + widget = SampleWidget(viewer) + with tempfile.TemporaryDirectory() as tmpdir: + MARKER_DF.to_csv(tmpdir + "/test1.csv", index=False) + MARKER_DF.to_csv(tmpdir + "/test12.csv", index=False) + + widget._open_sample_dialog(folder=tmpdir) + + lower_marker_cols = [ + widget.lower_bound_marker_col.itemText(index) for index in range(widget.lower_bound_marker_col.count()) + ] + upper_marker_cols = [ + widget.upper_bound_marker_col.itemText(index) for index in range(widget.upper_bound_marker_col.count()) + ] + reference_cols = list(MARKER_DF.columns) + reference_cols.append("sample_id") + assert lower_marker_cols == upper_marker_cols == reference_cols diff --git a/src/cell_gater/_tests/test_reader.py b/src/cell_gater/_tests/test_reader.py deleted file mode 100644 index ea0ec5f..0000000 --- a/src/cell_gater/_tests/test_reader.py +++ /dev/null @@ -1,31 +0,0 @@ -import numpy as np - -from cell_gater import napari_get_reader - - -# tmp_path is a pytest fixture -def test_reader(tmp_path): - """An example of how you might test your plugin.""" - - # write some fake data using your supported file format - my_test_file = str(tmp_path / "myfile.npy") - original_data = np.random.rand(20, 20) - np.save(my_test_file, original_data) - - # try to read it back in - reader = napari_get_reader(my_test_file) - assert callable(reader) - - # make sure we're delivering the right format - layer_data_list = reader(my_test_file) - assert isinstance(layer_data_list, list) and len(layer_data_list) > 0 - layer_data_tuple = layer_data_list[0] - assert isinstance(layer_data_tuple, tuple) and len(layer_data_tuple) > 0 - - # make sure it's the same as it started - np.testing.assert_allclose(original_data, layer_data_tuple[0]) - - -def test_get_reader_pass(): - reader = napari_get_reader("fake.file") - assert reader is None diff --git a/src/cell_gater/_tests/test_sample_data.py b/src/cell_gater/_tests/test_sample_data.py deleted file mode 100644 index 51c5db8..0000000 --- a/src/cell_gater/_tests/test_sample_data.py +++ /dev/null @@ -1,7 +0,0 @@ -# from cell_gater import make_sample_data - -# add your tests here... - - -def test_something(): - pass diff --git a/src/cell_gater/_tests/test_widget.py b/src/cell_gater/_tests/test_widget.py deleted file mode 100644 index 87c5c2c..0000000 --- a/src/cell_gater/_tests/test_widget.py +++ /dev/null @@ -1,66 +0,0 @@ -import numpy as np - -from cell_gater._widget import ( - ExampleQWidget, - ImageThreshold, - threshold_autogenerate_widget, - threshold_magic_widget, -) - - -def test_threshold_autogenerate_widget(): - # because our "widget" is a pure function, we can call it and - # test it independently of napari - im_data = np.random.random((100, 100)) - thresholded = threshold_autogenerate_widget(im_data, 0.5) - assert thresholded.shape == im_data.shape - # etc. - - -# make_napari_viewer is a pytest fixture that returns a napari viewer object -# you don't need to import it, as long as napari is installed -# in your testing environment -def test_threshold_magic_widget(make_napari_viewer): - viewer = make_napari_viewer() - layer = viewer.add_image(np.random.random((100, 100))) - - # our widget will be a MagicFactory or FunctionGui instance - my_widget = threshold_magic_widget() - - # if we "call" this object, it'll execute our function - thresholded = my_widget(viewer.layers[0], 0.5) - assert thresholded.shape == layer.data.shape - # etc. - - -def test_image_threshold_widget(make_napari_viewer): - viewer = make_napari_viewer() - layer = viewer.add_image(np.random.random((100, 100))) - my_widget = ImageThreshold(viewer) - - # because we saved our widgets as attributes of the container - # we can set their values without having to "interact" with the viewer - my_widget._image_layer_combo.value = layer - my_widget._threshold_slider.value = 0.5 - - # this allows us to run our functions directly and ensure - # correct results - my_widget._threshold_im() - assert len(viewer.layers) == 2 - - -# capsys is a pytest fixture that captures stdout and stderr output streams -def test_example_q_widget(make_napari_viewer, capsys): - # make viewer and add an image layer using our fixture - viewer = make_napari_viewer() - viewer.add_image(np.random.random((100, 100))) - - # create our widget, passing in the viewer - my_widget = ExampleQWidget(viewer) - - # call our widget method - my_widget._on_click() - - # read captured output and check that it's as we expected - captured = capsys.readouterr() - assert captured.out == "napari has 1 layers\n" diff --git a/src/cell_gater/_tests/test_writer.py b/src/cell_gater/_tests/test_writer.py deleted file mode 100644 index 09573dc..0000000 --- a/src/cell_gater/_tests/test_writer.py +++ /dev/null @@ -1,7 +0,0 @@ -# from cell_gater import write_single_image, write_multiple - -# add your tests here... - - -def test_something(): - pass diff --git a/src/cell_gater/_writer.py b/src/cell_gater/_writer.py deleted file mode 100644 index aa0fa25..0000000 --- a/src/cell_gater/_writer.py +++ /dev/null @@ -1,65 +0,0 @@ -""" -This module is an example of a barebones writer plugin for napari. - -It implements the Writer specification. -see: https://napari.org/stable/plugins/guides.html?#writers - -Replace code below according to your needs. -""" - -from __future__ import annotations - -from typing import TYPE_CHECKING, Any, List, Sequence, Tuple, Union - -if TYPE_CHECKING: - DataType = Union[Any, Sequence[Any]] - FullLayerData = Tuple[DataType, dict, str] - - -def write_single_image(path: str, data: Any, meta: dict) -> List[str]: - """Writes a single image layer. - - Parameters - ---------- - path : str - A string path indicating where to save the image file. - data : The layer data - The `.data` attribute from the napari layer. - meta : dict - A dictionary containing all other attributes from the napari layer - (excluding the `.data` layer attribute). - - Returns - ------- - [path] : A list containing the string path to the saved file. - """ - - # implement your writer logic here ... - - # return path to any file(s) that were successfully written - return [path] - - -def write_multiple(path: str, data: List[FullLayerData]) -> List[str]: - """Writes multiple layers of different types. - - Parameters - ---------- - path : str - A string path indicating where to save the data file(s). - data : A list of layer tuples. - Tuples contain three elements: (data, meta, layer_type) - `data` is the layer data - `meta` is a dictionary containing all other metadata attributes - from the napari layer (excluding the `.data` layer attribute). - `layer_type` is a string, eg: "image", "labels", "surface", etc. - - Returns - ------- - [path] : A list containing (potentially multiple) string paths to the saved file(s). - """ - - # implement your writer logic here ... - - # return path to any file(s) that were successfully written - return [path] diff --git a/src/cell_gater/model/data_model.py b/src/cell_gater/model/data_model.py index 5ee7f4f..e417231 100644 --- a/src/cell_gater/model/data_model.py +++ b/src/cell_gater/model/data_model.py @@ -7,6 +7,7 @@ import pandas as pd from napari.utils.events import EmitterGroup, Event + @dataclass class DataModel: """Model containing all necessary fields for gating.""" @@ -146,6 +147,10 @@ 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 markers_image_indices(self): """The markers included for gating.""" @@ -155,10 +160,6 @@ def markers_image_indices(self): def markers_image_indices(self, markers_image_indices: Sequence[str]) -> None: self._markers_image_indices = markers_image_indices - @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.""" diff --git a/src/cell_gater/utils/csv_df.py b/src/cell_gater/utils/csv_df.py index 9feb75e..7549b9f 100644 --- a/src/cell_gater/utils/csv_df.py +++ b/src/cell_gater/utils/csv_df.py @@ -27,13 +27,13 @@ def stack_csv_files(csv_dir: Path) -> pd.DataFrame | None: napari_notification(f"Loaded {len(csv_files)} regionprops csvs.") df = pd.DataFrame() for file in csv_files: - if not file.name.startswith('.'): + if not file.name.startswith("."): df_file = pd.read_csv(file) df_file["sample_id"] = file.stem df = pd.concat([df, df_file], ignore_index=True) df["sample_id"] = df.sample_id.astype("category") else: - print(f"Skipping file {file.name} as it is a hidden file.") + napari_notification(f"Skipping file {file.name} as it is a hidden file.") return df diff --git a/src/cell_gater/utils/misc.py b/src/cell_gater/utils/misc.py index f7bac20..737d21b 100644 --- a/src/cell_gater/utils/misc.py +++ b/src/cell_gater/utils/misc.py @@ -5,6 +5,7 @@ ) -def napari_notification(msg, severity=NotificationSeverity.INFO): +def napari_notification(msg: str, severity: NotificationSeverity = NotificationSeverity.INFO) -> None: + """Display a napari notification within the napari viewer with a given severity.""" notification_ = Notification(msg, severity=severity) - notification_manager.dispatch(notification_) \ No newline at end of file + notification_manager.dispatch(notification_) diff --git a/src/cell_gater/widgets/sample_widget.py b/src/cell_gater/widgets/sample_widget.py index d8efc6f..dcdd252 100644 --- a/src/cell_gater/widgets/sample_widget.py +++ b/src/cell_gater/widgets/sample_widget.py @@ -1,3 +1,5 @@ +from __future__ import annotations + from pathlib import Path from napari import Viewer @@ -125,11 +127,12 @@ def _dir_dialog(self): QFileDialog.Options(), ) - def _open_sample_dialog(self): + def _open_sample_dialog(self, folder: str | None = None): """Open directory file dialog for regionprop directory.""" - folder = self._dir_dialog() + if not folder: + folder = self._dir_dialog() - if folder not in {"", None}: + if isinstance(folder, str) and folder != "": self._assign_regionprops_to_model(folder) update_open_history(folder) diff --git a/src/cell_gater/widgets/scatter_widget.py b/src/cell_gater/widgets/scatter_widget.py index 14631c5..e8a4aab 100644 --- a/src/cell_gater/widgets/scatter_widget.py +++ b/src/cell_gater/widgets/scatter_widget.py @@ -3,6 +3,7 @@ import sys from itertools import product + import numpy as np import pandas as pd from dask_image.imread import imread @@ -36,9 +37,10 @@ #Good to have features -#Ideas to maybe implement -#TODO dynamic plotting of points on top of created polygons -#TODO save plots as images for QC, perhaps when saving gates run plotting function to go through all samples and markers and save plots +# Ideas to maybe implement +# TODO dynamic plotting of points on top of created polygons +# TODO save plots as images for QC, perhaps when saving gates run plotting function to go through all samples and markers and save plots + class ScatterInputWidget(QWidget): """Widget for a scatter plot with markers on the x axis and any dtype column on the y axis.""" @@ -58,7 +60,7 @@ def __init__(self, model: DataModel, viewer: Viewer) -> None: selection_label = QLabel("Select sample:") self.sample_selection_dropdown = QComboBox() - self.sample_selection_dropdown.addItems(sorted(self.model.samples, key=self.natural_sort_key) ) + self.sample_selection_dropdown.addItems(sorted(self.model.samples, key=self.natural_sort_key)) self.sample_selection_dropdown.currentTextChanged.connect(self._on_sample_changed) marker_label = QLabel("Marker label:") @@ -200,10 +202,13 @@ def update_plot(self): self.scatter_canvas.plot_scatter_plot(self.model) self.scatter_canvas.fig.draw() + ################### ### PLOT POINTS ### ################### + # TODO dynamic plotting of points on top of created polygons + def plot_points(self): """Plot positive cells in Napari.""" df = self.model.regionprops_df @@ -432,6 +437,7 @@ def _file_dialog(self): def natural_sort_key(self, s): """Key function for natural sorting.""" import re + return [int(text) if text.isdigit() else text.lower() for text in re.split(r"(\d+)", s)] @property