diff --git a/.github/workflows/build-and-publish-docs.yml b/.github/workflows/build-and-publish-docs.yml index a8c1163b..1773007a 100644 --- a/.github/workflows/build-and-publish-docs.yml +++ b/.github/workflows/build-and-publish-docs.yml @@ -3,7 +3,7 @@ on: push: branches: - master - - qy/add-plantseg-v1-installation + - qy/mask-pred permissions: contents: write diff --git a/docs/chapters/plantseg_interactive_napari/preprocessing.md b/docs/chapters/plantseg_interactive_napari/preprocessing.md index 40c74944..4e0626b8 100644 --- a/docs/chapters/plantseg_interactive_napari/preprocessing.md +++ b/docs/chapters/plantseg_interactive_napari/preprocessing.md @@ -11,3 +11,9 @@ This section describes the data processing functionalities available in PlantSeg ## Widget: Image Rescaling --8<-- "napari/dataprocessing/rescale.md" + +## Widget: Image Pair Operations + +```python exec="1" html="1" +--8<-- "napari/dataprocessing/image_pair_operations.py" +``` diff --git a/docs/chapters/python_api/functionals/data_processing.md b/docs/chapters/python_api/functionals/data_processing.md index f96862e5..ee8bbeae 100644 --- a/docs/chapters/python_api/functionals/data_processing.md +++ b/docs/chapters/python_api/functionals/data_processing.md @@ -10,6 +10,7 @@ Basic data processing functions are provided in the `dataprocessing` module. The ::: plantseg.functionals.dataprocessing.dataprocessing.image_median ::: plantseg.functionals.dataprocessing.dataprocessing.image_gaussian_smoothing ::: plantseg.functionals.dataprocessing.dataprocessing.image_crop +::: plantseg.functionals.dataprocessing.dataprocessing.process_images ## Segmentation Functions diff --git a/docs/chapters/python_api/tasks/dataprocessing_tasks.md b/docs/chapters/python_api/tasks/dataprocessing_tasks.md index 6d6cec2a..60e11669 100644 --- a/docs/chapters/python_api/tasks/dataprocessing_tasks.md +++ b/docs/chapters/python_api/tasks/dataprocessing_tasks.md @@ -1,41 +1,45 @@ -# Import and export tasks +# Data Processing Tasks -## Gaussian smoothing task +## Image Preprocessing Tasks + +### Gaussian smoothing task ::: plantseg.tasks.dataprocessing_tasks.gaussian_smoothing_task -## Image cropping task +### Image cropping task ::: plantseg.tasks.dataprocessing_tasks.image_cropping_task - -## Image rescale to shape task +### Image rescale to shape task ::: plantseg.tasks.dataprocessing_tasks.image_rescale_to_shape_task - -## Image rescale to voxel size task +### Image rescale to voxel size task ::: plantseg.tasks.dataprocessing_tasks.image_rescale_to_voxel_size_task -## Set image voxel size task +### Set image voxel size task ::: plantseg.tasks.dataprocessing_tasks.set_voxel_size_task -## Label Postprocessing +## Image pair operation task + +::: plantseg.tasks.dataprocessing_tasks.image_pair_operation_task + +## Label Postprocessing Tasks -## Remove false positives task +### Remove false positives task ::: plantseg.tasks.dataprocessing_tasks.remove_false_positives_by_foreground_probability_task -## Fix Over/Under segmentation task +### Fix Over/Under segmentation task ::: plantseg.tasks.dataprocessing_tasks.fix_over_under_segmentation_from_nuclei_task -## Set biggest object as background task +### Set biggest object as background task ::: plantseg.tasks.dataprocessing_tasks.set_biggest_instance_to_zero_task -## Relabel task +### Relabel task ::: plantseg.tasks.dataprocessing_tasks.relabel_segmentation_task diff --git a/docs/snippets/napari/dataprocessing/image_pair_operations.py b/docs/snippets/napari/dataprocessing/image_pair_operations.py new file mode 100644 index 00000000..8c587d51 --- /dev/null +++ b/docs/snippets/napari/dataprocessing/image_pair_operations.py @@ -0,0 +1,10 @@ +import sys + +sys.path.append("docs/snippets") + +from napari_widgets_render import render_widget + +from plantseg.viewer_napari.widgets import widget_image_pair_operations + +html = render_widget(widget_image_pair_operations) +print(html) diff --git a/plantseg/functionals/dataprocessing/__init__.py b/plantseg/functionals/dataprocessing/__init__.py index 58b37db3..87210978 100644 --- a/plantseg/functionals/dataprocessing/__init__.py +++ b/plantseg/functionals/dataprocessing/__init__.py @@ -3,8 +3,11 @@ remove_false_positives_by_foreground_probability, ) from plantseg.functionals.dataprocessing.dataprocessing import ( + ImagePairOperation, + add_images, compute_scaling_factor, compute_scaling_voxelsize, + divide_images, fix_layout, fix_layout_to_CYX, fix_layout_to_CZYX, @@ -14,10 +17,14 @@ image_gaussian_smoothing, image_median, image_rescale, + max_images, + multiply_images, normalize_01, normalize_01_channel_wise, + process_images, scale_image_to_voxelsize, select_channel, + subtract_images, ) from plantseg.functionals.dataprocessing.labelprocessing import ( relabel_segmentation, @@ -45,6 +52,14 @@ "fix_layout_to_ZYX", "fix_layout_to_YX", "fix_layout", + # simple image operations + "ImagePairOperation", + "process_images", + "max_images", + "add_images", + "subtract_images", + "multiply_images", + "divide_images", # labelprocessing "relabel_segmentation", "set_background_to_value", diff --git a/plantseg/functionals/dataprocessing/dataprocessing.py b/plantseg/functionals/dataprocessing/dataprocessing.py index 43be6eb6..a012faec 100644 --- a/plantseg/functionals/dataprocessing/dataprocessing.py +++ b/plantseg/functionals/dataprocessing/dataprocessing.py @@ -353,3 +353,155 @@ def normalize_01_channel_wise(data: np.ndarray, channel_axis: int = 0, eps=1e-12 # Move the axis back to its original position return np.moveaxis(normalized_channels, 0, channel_axis) + + +ImagePairOperation = Literal["add", "multiply", "subtract", "divide", "max"] + + +def process_images( + image1: np.ndarray, + image2: np.ndarray, + operation: ImagePairOperation, + normalize_input: bool = False, + clip_output: bool = False, + normalize_output: bool = True, +) -> np.ndarray: + """ + General function for performing image operations with optional preprocessing and post-processing. + + Args: + image1 (np.ndarray): First input image. + image2 (np.ndarray): Second input image. + operation (str): Operation to perform ('add', 'multiply', 'subtract', 'divide', 'max'). + normalize_input (bool): Whether to normalize the input images to the range [0, 1]. Default is False. + clip_output (bool): Whether to clip the resulting image values to the range [0, 1]. Default is False. + normalize_output (bool): Whether to normalize the output image to the range [0, 1]. Default is True. + + Returns: + np.ndarray: The resulting image after performing the operation. + """ + # Preprocessing: Normalize input images if specified + if normalize_input: + image1, image2 = normalize_01(image1), normalize_01(image2) + + # Perform the specified operation + if operation == "add": + result = image1 + image2 + elif operation == "multiply": + result = image1 * image2 + elif operation == "subtract": + result = image1 - image2 + elif operation == "divide": + result = image1 / image2 + elif operation == "max": + result = np.maximum(image1, image2) + else: + raise ValueError(f"Unsupported operation: {operation}") + + # Post-processing: Clip and/or normalize output if specified + if clip_output: + result = np.clip(result, 0, 1) + if normalize_output: + result = normalize_01(result) + + return result + + +def add_images( + image1: np.ndarray, + image2: np.ndarray, + clip_output: bool = False, + normalize_output: bool = True, + normalize_input: bool = False, +) -> np.ndarray: + """ + Adds two images with optional preprocessing and post-processing. + """ + return process_images( + image1, + image2, + operation="add", + clip_output=clip_output, + normalize_output=normalize_output, + normalize_input=normalize_input, + ) + + +def multiply_images( + image1: np.ndarray, + image2: np.ndarray, + clip_output: bool = False, + normalize_output: bool = True, + normalize_input: bool = False, +) -> np.ndarray: + """ + Multiplies two images with optional preprocessing and post-processing. + """ + return process_images( + image1, + image2, + operation="multiply", + clip_output=clip_output, + normalize_output=normalize_output, + normalize_input=normalize_input, + ) + + +def subtract_images( + image1: np.ndarray, + image2: np.ndarray, + clip_output: bool = False, + normalize_output: bool = True, + normalize_input: bool = False, +) -> np.ndarray: + """ + Subtracts the second image from the first with optional preprocessing and post-processing. + """ + return process_images( + image1, + image2, + operation="subtract", + clip_output=clip_output, + normalize_output=normalize_output, + normalize_input=normalize_input, + ) + + +def divide_images( + image1: np.ndarray, + image2: np.ndarray, + clip_output: bool = False, + normalize_output: bool = True, + normalize_input: bool = False, +) -> np.ndarray: + """ + Divides the first image by the second with optional preprocessing and post-processing. + """ + return process_images( + image1, + image2, + operation="divide", + clip_output=clip_output, + normalize_output=normalize_output, + normalize_input=normalize_input, + ) + + +def max_images( + image1: np.ndarray, + image2: np.ndarray, + clip_output: bool = False, + normalize_output: bool = True, + normalize_input: bool = False, +) -> np.ndarray: + """ + Computes the pixel-wise maximum of two images with optional preprocessing and post-processing. + """ + return process_images( + image1, + image2, + operation="max", + clip_output=clip_output, + normalize_output=normalize_output, + normalize_input=normalize_input, + ) diff --git a/plantseg/tasks/dataprocessing_tasks.py b/plantseg/tasks/dataprocessing_tasks.py index e710305f..98fe2023 100644 --- a/plantseg/tasks/dataprocessing_tasks.py +++ b/plantseg/tasks/dataprocessing_tasks.py @@ -2,9 +2,11 @@ from plantseg.core.image import ImageDimensionality, ImageLayout, PlantSegImage, SemanticType from plantseg.functionals.dataprocessing import ( + ImagePairOperation, fix_over_under_segmentation_from_nuclei, image_gaussian_smoothing, image_rescale, + process_images, relabel_segmentation, remove_false_positives_by_foreground_probability, set_biggest_instance_to_zero, @@ -306,3 +308,38 @@ def relabel_segmentation_task(image: PlantSegImage, background: int | None = Non new_data = relabel_segmentation(data, background=background) new_image = image.derive_new(new_data, name=f"{image.name}_relabeled") return new_image + + +@task_tracker +def image_pair_operation_task( + image1: PlantSegImage, + image2: PlantSegImage, + operation: ImagePairOperation, + normalize_input: bool = False, + clip_output: bool = False, + normalize_output: bool = False, +) -> PlantSegImage: + """ + Task to perform an operation on two images. + + Args: + image1 (PlantSegImage): First image to process. + Image2 (PlantSegImage): Second image to process. + operation (str): Operation to perform on the images. + normalize_input (bool): Normalize input images before processing. + clip_output (bool): Clip output values to the range [0, 1]. + normalize_output (bool): Normalize output values to the range [0, 1]. + + Returns: + PlantSegImage: New image resulting from the operation. + """ + result = process_images( + image1.get_data(), + image2.get_data(), + operation=operation, + normalize_input=normalize_input, + clip_output=clip_output, + normalize_output=normalize_output, + ) + new_image = image1.derive_new(result, name=f"{image1.name}_{operation}_{image2.name}") + return new_image diff --git a/plantseg/viewer_napari/containers.py b/plantseg/viewer_napari/containers.py index 97b55bde..bc342236 100644 --- a/plantseg/viewer_napari/containers.py +++ b/plantseg/viewer_napari/containers.py @@ -13,6 +13,7 @@ widget_filter_segmentation, widget_fix_over_under_segmentation_from_nuclei, widget_gaussian_smoothing, + widget_image_pair_operations, widget_infos, widget_open_file, widget_proofreading_initialisation, @@ -55,6 +56,7 @@ def get_preprocessing_tab(): widget_gaussian_smoothing, widget_rescaling, widget_cropping, + widget_image_pair_operations, ], labels=False, ) diff --git a/plantseg/viewer_napari/widgets/__init__.py b/plantseg/viewer_napari/widgets/__init__.py index 95a888c3..4b1c4cd3 100644 --- a/plantseg/viewer_napari/widgets/__init__.py +++ b/plantseg/viewer_napari/widgets/__init__.py @@ -2,6 +2,7 @@ widget_cropping, widget_fix_over_under_segmentation_from_nuclei, widget_gaussian_smoothing, + widget_image_pair_operations, widget_relabel, widget_remove_false_positives_by_foreground, widget_rescaling, @@ -40,6 +41,7 @@ "widget_gaussian_smoothing", "widget_rescaling", "widget_cropping", + "widget_image_pair_operations", # IO "widget_open_file", "widget_export_image", diff --git a/plantseg/viewer_napari/widgets/dataprocessing.py b/plantseg/viewer_napari/widgets/dataprocessing.py index 1d6eba14..473c1403 100644 --- a/plantseg/viewer_napari/widgets/dataprocessing.py +++ b/plantseg/viewer_napari/widgets/dataprocessing.py @@ -7,9 +7,11 @@ from plantseg.core.zoo import model_zoo from plantseg.io.voxelsize import VoxelSize from plantseg.tasks.dataprocessing_tasks import ( + ImagePairOperation, fix_over_under_segmentation_from_nuclei_task, gaussian_smoothing_task, image_cropping_task, + image_pair_operation_task, image_rescale_to_shape_task, image_rescale_to_voxel_size_task, relabel_segmentation_task, @@ -114,7 +116,7 @@ def widget_cropping( ps_image = PlantSegImage.from_napari_layer(image) - widgets_to_update = [] + widgets_to_update = None return schedule_task( image_cropping_task, @@ -632,3 +634,64 @@ def widget_set_biggest_instance_to_zero( }, widgets_to_update=widgets_to_update, ) + + +######################################################################################################################## +# # +# Image Pair Operation Widget # +# # +######################################################################################################################## + + +@magicgui( + call_button="Run Operation", + image1={ + "label": "Image 1", + "tooltip": "First image to apply the operation.", + }, + image2={ + "label": "Image 2", + "tooltip": "Second image to apply the operation.", + }, + operation={ + "label": "Operation", + "choices": ImagePairOperation, + }, + normalize_input={ + "label": "Normalize input", + "tooltip": "Normalize the input images to the range [0, 1].", + }, + clip_output={ + "label": "Clip output", + "tooltip": "Clip the output to the range [0, 1].", + }, + normalize_output={ + "label": "Normalize output", + "tooltip": "Normalize the output image to the range [0, 1].", + }, +) +def widget_image_pair_operations( + image1: Image, + image2: Image, + operation: ImagePairOperation = "add", + normalize_input: bool = False, + clip_output: bool = False, + normalize_output: bool = False, +) -> None: + """Apply an operation to two image layers.""" + + ps_image1 = PlantSegImage.from_napari_layer(image1) + ps_image2 = PlantSegImage.from_napari_layer(image2) + + return schedule_task( + image_pair_operation_task, + task_kwargs={ + "image1": ps_image1, + "image2": ps_image2, + "operation": operation, + "normalize_input": normalize_input, + "clip_output": clip_output, + "normalize_output": normalize_output, + }, + widgets_to_update=[], + ) diff --git a/tests/functionals/dataprocessing/test_image_math.py b/tests/functionals/dataprocessing/test_image_math.py new file mode 100644 index 00000000..e2dd07fe --- /dev/null +++ b/tests/functionals/dataprocessing/test_image_math.py @@ -0,0 +1,116 @@ +import numpy as np +import pytest + +from plantseg.functionals.dataprocessing import ( + add_images, + divide_images, + max_images, + multiply_images, + process_images, + subtract_images, +) + + +def normalize_01(image: np.ndarray) -> np.ndarray: + min_val, max_val = image.min(), image.max() + if max_val - min_val == 0: + return np.zeros_like(image) + return (image - min_val) / (max_val - min_val) + + +@pytest.mark.parametrize( + "operation, expected_result", + [ + ("add", np.array([[2, 4], [6, 8]])), + ("multiply", np.array([[1, 4], [9, 16]])), + ("subtract", np.array([[0, 0], [0, 0]])), + ("divide", np.array([[1, 1], [1, 1]])), + ("max", np.array([[1, 2], [3, 4]])), + ], +) +def test_process_images(operation, expected_result): + image1 = np.array([[1, 2], [3, 4]]) + image2 = np.array([[1, 2], [3, 4]]) + + result = process_images( + image1, image2, operation=operation, normalize_input=False, clip_output=False, normalize_output=False + ) + assert np.allclose(result, expected_result), f"Failed for operation: {operation}" + + +def test_process_images_clip_output(): + image1 = np.array([[0.5, 1.5], [2.5, 3.5]]) + image2 = np.array([[1.0, 1.0], [1.0, 1.0]]) + + result = process_images(image1, image2, operation="add", clip_output=True, normalize_output=False) + expected = np.clip(image1 + image2, 0, 1) + assert np.allclose(result, expected), "Clipping failed" + + +def test_process_images_normalize_input_output(): + image1 = np.array([[10, 20], [30, 40]]) + image2 = np.array([[1, 2], [3, 4]]) + + result = process_images(image1, image2, operation="add", normalize_input=True, normalize_output=True) + assert np.allclose(result, normalize_01(result)), "Normalization failed" + + +# Test cases for specific operations +def test_add_images(): + image1 = np.array([[1, 2], [3, 4]]) + image2 = np.array([[1, 2], [3, 4]]) + + result = add_images(image1, image2, clip_output=False, normalize_output=False, normalize_input=False) + expected = image1 + image2 + assert np.allclose(result, expected), "Addition failed" + + +def test_multiply_images(): + image1 = np.array([[1, 2], [3, 4]]) + image2 = np.array([[1, 2], [3, 4]]) + + result = multiply_images(image1, image2, clip_output=False, normalize_output=False, normalize_input=False) + expected = image1 * image2 + assert np.allclose(result, expected), "Multiplication failed" + + +def test_subtract_images(): + image1 = np.array([[1, 2], [3, 4]]) + image2 = np.array([[1, 2], [3, 4]]) + + result = subtract_images(image1, image2, clip_output=False, normalize_output=False, normalize_input=False) + expected = image1 - image2 + assert np.allclose(result, expected), "Subtraction failed" + + +def test_divide_images(): + image1 = np.array([[1, 2], [3, 4]]) + image2 = np.array([[1, 2], [3, 4]]) + + result = divide_images(image1, image2, clip_output=False, normalize_output=False, normalize_input=False) + expected = image1 / image2 + assert np.allclose(result, expected), "Division failed" + + +def test_max_images(): + image1 = np.array([[1, 2], [3, 4]]) + image2 = np.array([[4, 3], [2, 1]]) + + result = max_images(image1, image2, clip_output=False, normalize_output=False, normalize_input=False) + expected = np.maximum(image1, image2) + assert np.allclose(result, expected), "Max operation failed" + + +def test_process_images_invalid_operation(): + image1 = np.array([[1, 2], [3, 4]]) + image2 = np.array([[1, 2], [3, 4]]) + + with pytest.raises(ValueError, match="Unsupported operation: invalid"): + process_images( + image1, + image2, + operation="invalid", + normalize_input=False, + clip_output=False, + normalize_output=False, + )