diff --git a/.github/workflows/test_and_deploy.yaml b/.github/workflows/test_and_deploy.yaml index 0772b72..1091ab1 100644 --- a/.github/workflows/test_and_deploy.yaml +++ b/.github/workflows/test_and_deploy.yaml @@ -55,7 +55,7 @@ jobs: run: | pip install . # stop the build if there are Python syntax errors, undefined symbols or failed code style checks - python -m pylint --fail-under=8 -f parseable src + python -m pylint --ignore-patterns=test_.*.py --fail-under=8 -f parseable src # this runs the platform-specific tests declared in tox.ini - name: Test with tox diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..9b0e587 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,25 @@ +# enable pre-commit.ci at https://pre-commit.ci/ +# it adds: +# 1. auto fixing pull requests +# 2. auto updating the pre-commit configuration +ci: + autoupdate_schedule: monthly + autofix_commit_msg: "style(pre-commit.ci): auto fixes [...]" + autoupdate_commit_msg: "ci(pre-commit.ci): autoupdate" + +repos: + - repo: https://github.com/abravalheri/validate-pyproject + rev: v0.12.1 + hooks: + - id: validate-pyproject + + - repo: https://github.com/charliermarsh/ruff-pre-commit + rev: v0.0.257 + hooks: + - id: ruff + args: [--fix] + + - repo: https://github.com/psf/black + rev: 23.1.0 + hooks: + - id: black \ No newline at end of file diff --git a/setup.cfg b/setup.cfg index 47e4e59..73423e9 100644 --- a/setup.cfg +++ b/setup.cfg @@ -56,3 +56,7 @@ napari-feature-classifier = [options.entry_points] napari.manifest = napari-feature-classifier = napari_feature_classifier:napari.yaml + +[coverage:run] +omit = + src/napari_feature_classifier/dev_main.py diff --git a/src/napari_feature_classifier/_tests/test_annotator_widgets.py b/src/napari_feature_classifier/_tests/test_annotator_widgets.py index eb382ae..3e5886d 100644 --- a/src/napari_feature_classifier/_tests/test_annotator_widgets.py +++ b/src/napari_feature_classifier/_tests/test_annotator_widgets.py @@ -1,14 +1,17 @@ """ Tests for annotator widget initialization""" -import numpy as np import imageio from pathlib import Path -from napari_feature_classifier.annotator_init_widget import InitializeLabelAnnotatorWidget +from napari_feature_classifier.annotator_init_widget import ( + InitializeLabelAnnotatorWidget, +) from napari_feature_classifier.annotator_widget import ( LabelAnnotator, - get_class_selection + get_class_selection, ) -lbl_img_np = imageio.v2.imread(Path("src/napari_feature_classifier/sample_data/test_labels.tif")) +lbl_img_np = imageio.v2.imread( + Path("src/napari_feature_classifier/sample_data/test_labels.tif") +) # make_napari_viewer is a pytest fixture that returns a napari viewer object @@ -22,15 +25,21 @@ def test_annotator_widgets(make_napari_viewer): label_layer = viewer.add_labels(lbl_img_np) # Start init widget - _ = InitializeLabelAnnotatorWidget(viewer) + init_widget = InitializeLabelAnnotatorWidget(viewer) + init_widget.initialize_annotator() # Start the annotator widget annotator_widget = LabelAnnotator(viewer) # call our widget method - #my_widget._on_click() + # my_widget._on_click() annotator_widget._init_annotation(label_layer) + # TODO: Test toggle_label + + +# TODO: Test saving annotations + def test_custom_class_selection(make_napari_viewer): """ @@ -40,12 +49,10 @@ def test_custom_class_selection(make_napari_viewer): viewer = make_napari_viewer() viewer.add_labels(lbl_img_np) - class_names = ['Class Test', 'Class XYZ', '12345'] + class_names = ["Class Test", "Class XYZ", "12345"] # Start the annotator widget with a list of named classes - LabelAnnotator( - viewer, - ClassSelection = get_class_selection(class_names = class_names) - ) + LabelAnnotator(viewer, ClassSelection=get_class_selection(class_names=class_names)) + def test_numbered_class_selection(make_napari_viewer): """ @@ -55,7 +62,4 @@ def test_numbered_class_selection(make_napari_viewer): viewer = make_napari_viewer() viewer.add_labels(lbl_img_np) # Start the annotator widget with a given number of classes - LabelAnnotator( - viewer, - ClassSelection = get_class_selection(n_classes = 8) - ) + LabelAnnotator(viewer, ClassSelection=get_class_selection(n_classes=8)) diff --git a/src/napari_feature_classifier/_tests/test_classifier_widget.py b/src/napari_feature_classifier/_tests/test_classifier_widget.py index 6f29d99..440dbac 100644 --- a/src/napari_feature_classifier/_tests/test_classifier_widget.py +++ b/src/napari_feature_classifier/_tests/test_classifier_widget.py @@ -14,8 +14,13 @@ LoadClassifierContainer, ) -lbl_img_np = imageio.v2.imread(Path("src/napari_feature_classifier/sample_data/test_labels.tif")) -clf_path = Path("src/napari_feature_classifier/sample_data/test_labels_classifier.clf") +lbl_img_np = imageio.v2.imread( + Path("src/napari_feature_classifier/sample_data/test_labels.tif") +) +clf_path = Path( + "src/napari_feature_classifier/sample_data/" "test_labels_classifier.clf" +) + # make_napari_viewer is a pytest fixture that returns a napari viewer object # capsys is a pytest fixture that captures stdout and stderr output streams @@ -32,14 +37,21 @@ def test_classifier_widgets_initialization_no_features_selected(make_napari_view classifier_widget.initialize_run_widget() - # TODO: Catch that it doesn't actually initialize, but instead shows a + # TODO: Catch that it doesn't actually initialize, but instead shows a # message to the user that now features were selected assert classifier_widget._run_container is None +def test_classifier_initializtion_without_label_image(make_napari_viewer): + viewer = make_napari_viewer() + classifier_widget = ClassifierWidget(viewer) + assert classifier_widget._init_container._last_selected_label_layer is None + features = make_features(np.unique(lbl_img_np)[1:], roi_id="ROI1", n_features=6) features_no_roi_id = features.drop(columns=["roi_id"]) + + # make_napari_viewer is a pytest fixture that returns a napari viewer object @pytest.mark.parametrize("features", [features, features_no_roi_id]) def test_running_classification_through_widget(features, make_napari_viewer): @@ -85,9 +97,137 @@ def test_running_classification_through_widget(features, make_napari_viewer): # Delete the classifier file (cleanup to avoid overwriting confirmation) os.remove("lbl_img_np_classifier.clf") + +# Test classifier results export +def test_prediction_export(make_napari_viewer, capsys): + """ + Tests if the main widget launches + """ + # make viewer and add an image layer using our fixture + viewer = make_napari_viewer() + label_layer = viewer.add_labels(lbl_img_np) + label_layer.features = features + + # Start init widget + classifier_widget = ClassifierWidget(viewer) + + # Select relevant features + classifier_widget._init_container._feature_combobox.value = [ + "feature_1", + "feature_2", + "feature_3", + ] + + classifier_widget.initialize_run_widget() + + # Check that the run widget is initialized + assert classifier_widget._run_container is not None + + # Add some annotations manually + label_layer.features.loc[0, "annotations"] = 1.0 + label_layer.features.loc[1, "annotations"] = 1.0 + label_layer.features.loc[3, "annotations"] = 3.0 + + # Run the classifier + classifier_widget._run_container.run() + + # Test result export + classifier_widget._run_container.export_results() + df = pd.read_csv(classifier_widget._run_container._export_destination.value) + assert df.shape == (16, 5) + assert df["prediction"].isna().sum() == 0 + + # Delete the classifier file (cleanup to avoid overwriting confirmation) + os.remove("lbl_img_np_classifier.clf") + os.remove("lbl_img_np_predictions.csv") + + message = "INFO: Annotations were saved at lbl_img_np_predictions.csv" + assert message in capsys.readouterr().out + + # TODO: Add a test to check the overwrite confirmations working correctly # For classifier files, for annotations and for exported predictions + +features = make_features(np.unique(lbl_img_np)[1:], roi_id="ROI1", n_features=6) + + +# make_napari_viewer is a pytest fixture that returns a napari viewer object +def test_classifier_fails_running_without_annotation(make_napari_viewer, capsys): + """ + Tests if the main widget launches + """ + # make viewer and add an image layer using our fixture + viewer = make_napari_viewer() + label_layer = viewer.add_labels(lbl_img_np) + label_layer.features = features + + # Start init widget + classifier_widget = ClassifierWidget(viewer) + + # Select relevant features + classifier_widget._init_container._feature_combobox.value = [ + "feature_1", + "feature_2", + "feature_3", + ] + + classifier_widget.initialize_run_widget() + + # Run the classifier + classifier_widget._run_container.run() + + expected_message = ( + "INFO: Training failed. A typical reason are not " + "having enough annotations. \nThe error message was: Found array " + "with 0 sample(s) (shape=(0, 3)) while a minimum of 1 is required " + "by RandomForestClassifier." + ) + assert expected_message in capsys.readouterr().out + + +def test_layer_selection_changes(make_napari_viewer): + """ + Tests if the main widget launches + """ + # make viewer and add an image layer using our fixture + viewer = make_napari_viewer() + label_layer1 = viewer.add_labels(lbl_img_np, name="test_labels") + label_layer2 = viewer.add_labels(lbl_img_np, name="test_labels_2") + label_layer2.features = features + label_layer1.features = features + + # Start init widget + classifier_widget = ClassifierWidget(viewer) + + # Select relevant features + classifier_widget._init_container._feature_combobox.value = [ + "feature_1", + "feature_2", + "feature_3", + ] + + classifier_widget.initialize_run_widget() + + assert ( + classifier_widget._run_container._last_selected_label_layer.name + == "test_labels_2" + ) + assert ( + str(classifier_widget._run_container._export_destination.value) + == "test_labels_2_predictions.csv" + ) + viewer.layers.selection.active = label_layer1 + assert ( + classifier_widget._run_container._last_selected_label_layer.name + == "test_labels" + ) + assert ( + classifier_widget._run_container._export_destination.value.name + == "test_labels_predictions.csv" + ) + + # make_napari_viewer is a pytest fixture that returns a napari viewer object # capsys is a pytest fixture that captures stdout and stderr output streams def test_load_classifier_widget(make_napari_viewer, capsys): @@ -109,3 +249,12 @@ def test_load_classifier_widget(make_napari_viewer, capsys): assert loading_widget._run_container._prediction_layer.visible assert "prediction" in label_layer.features.columns + +def test_change_of_loader_filter(make_napari_viewer, capsys): + viewer = make_napari_viewer() + loading_widget = LoadClassifierContainer(viewer) + assert loading_widget._filter.value == "*.clf" + assert loading_widget._clf_destination.filter is None + loading_widget._filter.value = "*.pkl" + assert loading_widget._clf_destination.filter == "*.pkl" + assert loading_widget._filter.value == "*.pkl" diff --git a/src/napari_feature_classifier/_tests/test_feature_loading.py b/src/napari_feature_classifier/_tests/test_feature_loading.py new file mode 100644 index 0000000..c9f2ae4 --- /dev/null +++ b/src/napari_feature_classifier/_tests/test_feature_loading.py @@ -0,0 +1,35 @@ +""" Tests feature loading""" +import pandas as pd + +import imageio +from pathlib import Path +from napari_feature_classifier.feature_loader_widget import ( + load_features_factory, +) + +lbl_img_np = imageio.v2.imread( + Path("src/napari_feature_classifier/sample_data/test_labels.tif") +) +csv_path = Path("src/napari_feature_classifier/sample_data/test_df.csv") + + +def test_feature_loading_csv(make_napari_viewer, capsys): + """ + Tests if the main widget launches + """ + # make viewer and add an image layer using our fixture + viewer = make_napari_viewer() + labels_layer = viewer.add_labels(lbl_img_np) + + # Start feature loading widget + loading_widget = load_features_factory() + loading_widget.layer.value = labels_layer + loading_widget.path.value = csv_path + features = pd.read_csv(csv_path) + loading_widget.__call__() + assert (labels_layer.features == features).all().all() + + # Assert that this message is logged + expected = "INFO: Loaded features and attached them " + expected += 'to "lbl_img_np" layer\n' + assert expected in capsys.readouterr().out diff --git a/src/napari_feature_classifier/classifier_widget.py b/src/napari_feature_classifier/classifier_widget.py index af23ddc..5853a68 100644 --- a/src/napari_feature_classifier/classifier_widget.py +++ b/src/napari_feature_classifier/classifier_widget.py @@ -17,7 +17,6 @@ PushButton, Select, ) -from qtpy.QtWidgets import QMessageBox # pylint: disable=E0611 from napari_feature_classifier.annotator_init_widget import LabelAnnotatorTextSelector from napari_feature_classifier.annotator_widget import ( @@ -117,7 +116,9 @@ def get_class_names(self): def get_feature_options(self, layer): """ Get the feature options of the currently selected layer - Only works if a label layer is selected (we don't load features from other layers) + + Only works if a label layer is selected (we don't load features from + other layers) """ if isinstance(layer, napari.layers.Labels): return list(layer.features.columns) @@ -265,7 +266,7 @@ def __init__( # Export options self._export_destination = FileEdit( label="Prediction Export Path", - value=f"{self._last_selected_label_layer}_prediction.csv", + value=f"{self._last_selected_label_layer}_predictions.csv", mode="w", ) self._export_button = PushButton(text="Export Classifier Result") @@ -552,7 +553,7 @@ class LoadClassifierContainer(Container): The button that launches the `ClassifierRunContainer` _filter: magicgui.widgets.RadioButtons The radio button widget that allows the user to select the file filter - to use for selecting the classifier file. See + to use for selecting the classifier file. See https://github.com/fractal-napari-plugins-collection/napari-feature-classifier/issues/36 for more details. _run_container: ClassifierRunContainer