diff --git a/brainglobe_registration/registration_widget.py b/brainglobe_registration/registration_widget.py index f98e0da..67ab52f 100644 --- a/brainglobe_registration/registration_widget.py +++ b/brainglobe_registration/registration_widget.py @@ -14,12 +14,14 @@ from bg_atlasapi import BrainGlobeAtlas from bg_atlasapi.list_atlases import get_downloaded_atlases from brainglobe_utils.qtpy.collapsible_widget import CollapsibleWidgetContainer +from napari.utils.notifications import show_error from napari.viewer import Viewer from qtpy.QtWidgets import ( QPushButton, QTabWidget, ) from skimage.segmentation import find_boundaries +from skimage.transform import rescale from brainglobe_registration.elastix.register import run_registration from brainglobe_registration.utils.brainglobe_logo import header_widget @@ -49,6 +51,7 @@ def __init__(self, napari_viewer: Viewer): self._viewer = napari_viewer self._atlas: BrainGlobeAtlas = None self._moving_image = None + self._moving_image_data_backup = None self.transform_params: dict[str, dict] = { "rigid": {}, @@ -101,10 +104,12 @@ def __init__(self, napari_viewer: Viewer): self.adjust_moving_image_widget.adjust_image_signal.connect( self._on_adjust_moving_image ) - self.adjust_moving_image_widget.reset_image_signal.connect( self._on_adjust_moving_image_reset_button_click ) + self.adjust_moving_image_widget.scale_image_signal.connect( + self._on_scale_moving_image + ) self.transform_select_view = TransformSelectView() self.transform_select_view.transform_type_added_signal.connect( @@ -151,11 +156,11 @@ def _on_atlas_dropdown_index_changed(self, index): # Hacky way of having an empty first dropdown if index == 0: if self._atlas: - curr_atlas_layer_index = find_layer_index( + current_atlas_layer_index = find_layer_index( self._viewer, self._atlas.atlas_name ) - self._viewer.layers.pop(curr_atlas_layer_index) + self._viewer.layers.pop(current_atlas_layer_index) self._atlas = None self.run_button.setEnabled(False) self._viewer.grid.enabled = False @@ -166,11 +171,11 @@ def _on_atlas_dropdown_index_changed(self, index): atlas = BrainGlobeAtlas(atlas_name) if self._atlas: - curr_atlas_layer_index = find_layer_index( + current_atlas_layer_index = find_layer_index( self._viewer, self._atlas.atlas_name ) - self._viewer.layers.pop(curr_atlas_layer_index) + self._viewer.layers.pop(current_atlas_layer_index) else: self.run_button.setEnabled(True) @@ -189,6 +194,7 @@ def _on_sample_dropdown_index_changed(self, index): self._viewer, self._sample_images[index] ) self._moving_image = self._viewer.layers[viewer_index] + self._moving_image_data_backup = self._moving_image.data.copy() def _on_adjust_moving_image(self, x: int, y: int, rotate: float): adjust_napari_image_layer(self._moving_image, x, y, rotate) @@ -299,3 +305,41 @@ def _on_default_file_selection_change( def _on_sample_popup_about_to_show(self): self._sample_images = get_image_layer_names(self._viewer) self.get_atlas_widget.update_sample_image_names(self._sample_images) + + def _on_scale_moving_image(self, x: float, y: float): + """ + Scale the moving image to have resolution equal to the atlas. + + Parameters + ---------- + x : float + Moving image x pixel size (> 0.0). + y : float + Moving image y pixel size (> 0.0). + + Will show an error if the pixel sizes are less than or equal to 0. + Will show an error if the moving image or atlas is not selected. + """ + if x <= 0 or y <= 0: + show_error("Pixel sizes must be greater than 0") + return + + if self._moving_image and self._atlas: + if self._moving_image_data_backup is None: + self._moving_image_data_backup = self._moving_image.data.copy() + + x_factor = x / self._atlas.resolution[0] + y_factor = y / self._atlas.resolution[1] + + self._moving_image.data = rescale( + self._moving_image_data_backup, + (y_factor, x_factor), + mode="constant", + preserve_range=True, + anti_aliasing=True, + ) + else: + show_error( + "Sample image or atlas not selected. " + "Please select a sample image and atlas before scaling", + ) diff --git a/brainglobe_registration/widgets/adjust_moving_image_view.py b/brainglobe_registration/widgets/adjust_moving_image_view.py index 4740e25..c7ba3fd 100644 --- a/brainglobe_registration/widgets/adjust_moving_image_view.py +++ b/brainglobe_registration/widgets/adjust_moving_image_view.py @@ -14,8 +14,8 @@ class AdjustMovingImageView(QWidget): A QWidget subclass that provides controls for adjusting the moving image. This widget provides controls for adjusting the x and y offsets and - rotation of the moving image. It emits signals when the image is adjusted - or reset. + rotation of the moving image. It emits signals when the image is adjusted, + scaled, or reset. Attributes ---------- @@ -33,9 +33,12 @@ class AdjustMovingImageView(QWidget): _on_reset_image_button_click(): Resets the x and y offsets and rotation to 0 and emits the reset_image_signal. + _on_scale_image_button_click(): + Emits the scale_image_signal with the entered pixel sizes. """ adjust_image_signal = Signal(int, int, float) + scale_image_signal = Signal(float, float) reset_image_signal = Signal() def __init__(self, parent=None): @@ -54,15 +57,27 @@ def __init__(self, parent=None): offset_range = 2000 rotation_range = 360 - self.adjust_moving_image_x = QSpinBox() + self.adjust_moving_image_voxel_size_x = QDoubleSpinBox(parent=self) + self.adjust_moving_image_voxel_size_x.setDecimals(2) + self.adjust_moving_image_voxel_size_x.setRange(0.01, 100.00) + self.adjust_moving_image_voxel_size_y = QDoubleSpinBox(parent=self) + self.adjust_moving_image_voxel_size_y.setDecimals(2) + self.adjust_moving_image_voxel_size_y.setRange(0.01, 100.00) + self.scale_moving_image_button = QPushButton() + self.scale_moving_image_button.setText("Scale Image") + self.scale_moving_image_button.clicked.connect( + self._on_scale_image_button_click + ) + + self.adjust_moving_image_x = QSpinBox(parent=self) self.adjust_moving_image_x.setRange(-offset_range, offset_range) self.adjust_moving_image_x.valueChanged.connect(self._on_adjust_image) - self.adjust_moving_image_y = QSpinBox() + self.adjust_moving_image_y = QSpinBox(parent=self) self.adjust_moving_image_y.setRange(-offset_range, offset_range) self.adjust_moving_image_y.valueChanged.connect(self._on_adjust_image) - self.adjust_moving_image_rotate = QDoubleSpinBox() + self.adjust_moving_image_rotate = QDoubleSpinBox(parent=self) self.adjust_moving_image_rotate.setRange( -rotation_range, rotation_range ) @@ -71,15 +86,26 @@ def __init__(self, parent=None): self._on_adjust_image ) - self.adjust_moving_image_reset_button = QPushButton(parent=self) + self.adjust_moving_image_reset_button = QPushButton() self.adjust_moving_image_reset_button.setText("Reset Image") self.adjust_moving_image_reset_button.clicked.connect( self._on_reset_image_button_click ) - self.layout().addRow(QLabel("Adjust the moving image: ")) - self.layout().addRow("X offset:", self.adjust_moving_image_x) - self.layout().addRow("Y offset:", self.adjust_moving_image_y) + self.layout().addRow(QLabel("Adjust the moving image scale:")) + self.layout().addRow( + "Sample image X pixel size (\u03BCm / pixel):", + self.adjust_moving_image_voxel_size_x, + ) + self.layout().addRow( + "Sample image Y pixel size (\u03BCm / pixel):", + self.adjust_moving_image_voxel_size_y, + ) + self.layout().addRow(self.scale_moving_image_button) + + self.layout().addRow(QLabel("Adjust the sample image position:")) + self.layout().addRow("X offset (pixels):", self.adjust_moving_image_x) + self.layout().addRow("Y offset (pixels):", self.adjust_moving_image_y) self.layout().addRow( "Rotation (degrees):", self.adjust_moving_image_rotate ) @@ -106,3 +132,12 @@ def _on_reset_image_button_click(self): self.adjust_moving_image_rotate.setValue(0) self.reset_image_signal.emit() + + def _on_scale_image_button_click(self): + """ + Emit the scale_image_signal with the entered pixel sizes. + """ + self.scale_image_signal.emit( + self.adjust_moving_image_voxel_size_x.value(), + self.adjust_moving_image_voxel_size_y.value(), + ) diff --git a/brainglobe_registration/widgets/transform_select_view.py b/brainglobe_registration/widgets/transform_select_view.py index eba8c32..d141816 100644 --- a/brainglobe_registration/widgets/transform_select_view.py +++ b/brainglobe_registration/widgets/transform_select_view.py @@ -169,7 +169,7 @@ def _on_transform_type_change(self, index): self.file_selections[index].setCurrentIndex(0) if index >= len(self.transform_type_selections) - 1: - curr_length = self.rowCount() + current_length = self.rowCount() self.setRowCount(self.rowCount() + 1) self.transform_type_selections.append(QComboBox()) @@ -180,7 +180,7 @@ def _on_transform_type_change(self, index): self.transform_type_signaller.map ) self.transform_type_signaller.setMapping( - self.transform_type_selections[-1], curr_length + self.transform_type_selections[-1], current_length ) self.file_selections.append(QComboBox()) @@ -191,14 +191,16 @@ def _on_transform_type_change(self, index): self.file_signaller.map ) self.file_signaller.setMapping( - self.file_selections[-1], curr_length + self.file_selections[-1], current_length ) self.setCellWidget( - curr_length, 0, self.transform_type_selections[curr_length] + current_length, + 0, + self.transform_type_selections[current_length], ) self.setCellWidget( - curr_length, 1, self.file_selections[curr_length] + current_length, 1, self.file_selections[current_length] ) else: diff --git a/tests/test_adjust_moving_image_view.py b/tests/test_adjust_moving_image_view.py index e56107e..fb2dc41 100644 --- a/tests/test_adjust_moving_image_view.py +++ b/tests/test_adjust_moving_image_view.py @@ -14,6 +14,12 @@ def adjust_moving_image_view() -> AdjustMovingImageView: return adjust_moving_image_view +def test_init(qtbot, adjust_moving_image_view): + qtbot.addWidget(adjust_moving_image_view) + + assert adjust_moving_image_view.layout().rowCount() == 9 + + @pytest.mark.parametrize( "x_value, expected", [ @@ -94,3 +100,26 @@ def test_reset_image_button_click(qtbot, adjust_moving_image_view): assert adjust_moving_image_view.adjust_moving_image_x.value() == 0 assert adjust_moving_image_view.adjust_moving_image_y.value() == 0 assert adjust_moving_image_view.adjust_moving_image_rotate.value() == 0 + + +@pytest.mark.parametrize( + "x_scale, y_scale", + [(2.5, 2.5), (10, 20), (10.221, 10.228)], +) +def test_scale_image_button_click( + qtbot, adjust_moving_image_view, x_scale, y_scale +): + qtbot.addWidget(adjust_moving_image_view) + + with qtbot.waitSignal( + adjust_moving_image_view.scale_image_signal, timeout=1000 + ) as blocker: + adjust_moving_image_view.adjust_moving_image_voxel_size_x.setValue( + x_scale + ) + adjust_moving_image_view.adjust_moving_image_voxel_size_y.setValue( + y_scale + ) + adjust_moving_image_view.scale_moving_image_button.click() + + assert blocker.args == [round(x_scale, 2), round(y_scale, 2)] diff --git a/tests/test_parameter_list_view.py b/tests/test_parameter_list_view.py index 00a701f..cabbfce 100644 --- a/tests/test_parameter_list_view.py +++ b/tests/test_parameter_list_view.py @@ -56,7 +56,7 @@ def test_parameter_list_view_cell_change(parameter_list_view, qtbot): def test_parameter_list_view_cell_change_last_row(parameter_list_view, qtbot): qtbot.addWidget(parameter_list_view) - curr_row_count = parameter_list_view.rowCount() + current_row_count = parameter_list_view.rowCount() last_row_index = len(param_dict) parameter_list_view.setItem( @@ -65,7 +65,7 @@ def test_parameter_list_view_cell_change_last_row(parameter_list_view, qtbot): parameter_list_view.setItem(last_row_index, 1, QTableWidgetItem("true")) assert parameter_list_view.param_dict["TestParameter"] == ["true"] - assert parameter_list_view.rowCount() == curr_row_count + 1 + assert parameter_list_view.rowCount() == current_row_count + 1 def test_parameter_list_view_cell_change_last_row_no_param( @@ -73,12 +73,12 @@ def test_parameter_list_view_cell_change_last_row_no_param( ): qtbot.addWidget(parameter_list_view) - curr_row_count = parameter_list_view.rowCount() + current_row_count = parameter_list_view.rowCount() last_row_index = len(param_dict) parameter_list_view.setItem(last_row_index, 1, QTableWidgetItem("true")) - assert parameter_list_view.rowCount() == curr_row_count + assert parameter_list_view.rowCount() == current_row_count def test_parameter_list_view_cell_change_last_row_no_value( @@ -86,11 +86,11 @@ def test_parameter_list_view_cell_change_last_row_no_value( ): qtbot.addWidget(parameter_list_view) - curr_row_count = parameter_list_view.rowCount() + current_row_count = parameter_list_view.rowCount() last_row_index = len(param_dict) parameter_list_view.setItem( last_row_index, 0, QTableWidgetItem("TestParameter") ) - assert parameter_list_view.rowCount() == curr_row_count + assert parameter_list_view.rowCount() == current_row_count diff --git a/tests/test_registration_widget.py b/tests/test_registration_widget.py index aa91594..2b74c98 100644 --- a/tests/test_registration_widget.py +++ b/tests/test_registration_widget.py @@ -65,3 +65,70 @@ def test_sample_dropdown_index_changed_with_valid_index( registration_widget._moving_image.name == registration_widget._sample_images[1] ) + + +def test_scale_moving_image_no_atlas( + make_napari_viewer_with_images, registration_widget, mocker +): + mocked_show_error = mocker.patch( + "brainglobe_registration.registration_widget.show_error" + ) + registration_widget._atlas = None + registration_widget.adjust_moving_image_widget.scale_image_signal.emit( + 10, 10 + ) + mocked_show_error.assert_called_once_with( + "Sample image or atlas not selected. " + "Please select a sample image and atlas before scaling" + ) + + +def test_scale_moving_image_no_sample_image( + make_napari_viewer_with_images, registration_widget, mocker +): + mocked_show_error = mocker.patch( + "brainglobe_registration.registration_widget.show_error" + ) + registration_widget._moving_image = None + registration_widget.adjust_moving_image_widget.scale_image_signal.emit( + 10, 10 + ) + mocked_show_error.assert_called_once_with( + "Sample image or atlas not selected. " + "Please select a sample image and atlas before scaling" + ) + + +@pytest.mark.parametrize( + "x_scale_factor, y_scale_factor", + [ + (0.5, 0.5), + (1.0, 1.0), + (2.0, 2.0), + (0.5, 1.0), + (1.0, 0.5), + ], +) +def test_scale_moving_image( + make_napari_viewer_with_images, + registration_widget, + mocker, + x_scale_factor, + y_scale_factor, +): + mock_atlas = mocker.patch( + "brainglobe_registration.registration_widget.BrainGlobeAtlas" + ) + mock_atlas.resolution = [20, 20, 20] + registration_widget._atlas = mock_atlas + + current_size = registration_widget._moving_image.data.shape + registration_widget.adjust_moving_image_widget.scale_image_signal.emit( + mock_atlas.resolution[1] * x_scale_factor, + mock_atlas.resolution[2] * y_scale_factor, + ) + + assert registration_widget._moving_image.data.shape == ( + current_size[0] * y_scale_factor, + current_size[1] * x_scale_factor, + )