Repository: brainglobe/brainglobe-stitch
Pull request #40: Adds linear interpolation for overlapping regions
Author: login: IgorTatarnikov
Created: 2025-04-14T12:47:34Z | Merged: 2025-04-29T14:49:33Z

## Description

**What is this PR**

- [ ] Bug fix
- [x] Addition of a new feature
- [ ] Other

**Why is this PR needed?**
Currently, there is no way to combine the data from both tiles within the overlapping regions, the tiles are placed in reverse acquisition order, therefore data from the first tile to be acquired is placed in the overlapping region. 

**What does this PR do?**
Adds basic linear interpolation for the overlapping region. There is a GUI button to preview the interpolation, and a checkbox to enable linear interpolation in the final fused image.

## How has this PR been tested?

Tests added to cover the new functionality.

NOTE:
To test the functionality locally you must install Fiji and also add BigStitcher to the update sites. This can be done using the GUI [see here](https://imagej.net/plugins/bigstitcher/#download) or via the command line after installing Fiji.

./ImageJ-linux64 --update add-update-sites "BigStitcherUpdate" "[sites.imagej.net/BigStitcher](https://sites.imagej.net/BigStitcher/)"
./ImageJ-linux64 --update update

Replace ImageJ-linux64 with the path to the local ImageJ executable.

Three different datasets are available for testing:

- Tiny (~28 MB): this is mostly used for automated testing and doesn't work for visual inspection. It can be downloaded [here](https://gin.g-node.org/IgorTatarnikov/brainglobe-stitch-test/raw/master/brainglobe-stitch/brainglobe-stitch-test-data.zip).
- Small (~1.8 GB): this dataset is more useful for visual inspection. It can be downloaded [here](https://gin.g-node.org/IgorTatarnikov/brainglobe-stitch-test/raw/master/brainglobe-stitch/big_test_data.zip).
- A full size dataset is available (~130 GB): this dataset works best for testing overall performance, especially when reading or writing data. It's available by request.

## Is this a breaking change?
No

## Does this PR require an update to the documentation?
Will come later.

## Checklist:

- [x] The code has been tested locally
- [x] Tests have been added to cover all new functionality (unit & integration)
- [x] The code has been formatted with [pre-commit](https://pre-commit.com/)



Diff:
diff --git a/brainglobe_stitch/image_mosaic.py b/brainglobe_stitch/image_mosaic.py
index 422ec07..157b0cf 100644
--- a/brainglobe_stitch/image_mosaic.py
+++ b/brainglobe_stitch/image_mosaic.py
@@ -50,6 +50,8 @@ class ImageMosaic:
         The tiles in the image.
     tile_names : List[str]
         The names of the image tiles from BigDataViewer.
+    tile_metadata : List[Dict]
+        The metadata for each tile.
     overlaps : Dict[Tuple[int, int], Overlap]
         A dictionary of tile pairs and their overlaps.
     x_y_resolution : float
@@ -84,6 +86,9 @@ def __init__(self, directory: Path):
         self.intensity_adjusted: List[bool] = [False] * len(
             self.tiles[0].resolution_pyramid
         )
+        self.overlaps_interpolated: List[bool] = [False] * len(
+            self.tiles[0].resolution_pyramid
+        )
 
     def __del__(self):
         if self.h5_file:
@@ -378,6 +383,7 @@ def reload_resolution_pyramid_level(self, resolution_level: int) -> None:
                 )
 
             self.intensity_adjusted[resolution_level] = False
+            self.overlaps_interpolated[resolution_level] = False
 
     def calculate_overlaps(self) -> None:
         """
@@ -509,10 +515,41 @@ def calculate_intensity_scale_factors(
 
         return
 
+    def interpolate_overlaps(self, resolution_level: int) -> None:
+        """
+        Interpolate the overlaps between the tiles at a given resolution level.
+
+        Parameters
+        ----------
+        resolution_level: int
+            The resolution level to interpolate the overlaps at.
+        """
+        tile_shape = self.tiles[0].data_pyramid[resolution_level].shape
+
+        if self.overlaps_interpolated[resolution_level]:
+            print("Overlaps already interpolated at this resolution scale.")
+            return
+
+        for tile_i in self.tiles[:-1]:
+            # Iterate through each neighbour
+            for neighbour_id in tile_i.neighbours:
+                tile_j = self.tiles[neighbour_id]
+                overlap = self.overlaps[(tile_i.id, tile_j.id)]
+
+                overlap.linear_interpolation(resolution_level, tile_shape)
+
+                print(
+                    f"Done interpolating tile {tile_i.id}" f" and {tile_j.id}"
+                )
+
+        self.overlaps_interpolated[resolution_level] = True
+
     def fuse(
         self,
         output_path: Path,
         normalise_intensity: bool = False,
+        normalise_intensity_percentile: int = 80,
+        interpolate: bool = False,
         downscale_factors: Tuple[int, int, int] = (1, 2, 2),
         chunk_shape: Tuple[int, int, int] = (128, 128, 128),
         pyramid_depth: int = 5,
@@ -529,6 +566,10 @@ def fuse(
             Accepts .zarr and .h5 extensions.#
         normalise_intensity: bool, default: False
             Normalise the intensity differences between tiles.
+        normalise_intensity_percentile: int, default: 80
+            The brightness percentile to use for normalising intensity.
+        interpolate: bool, default: False
+            Interpolate the overlaps between tiles.
         downscale_factors: Tuple[int, int, int], default: (1, 2, 2)
             The factors to downscale the image by in the z, y, x dimensions.
         chunk_shape: Tuple[int, ...], default: (128, 128, 128)
@@ -549,7 +590,14 @@ def fuse(
         )
 
         if normalise_intensity:
-            self.normalise_intensity(0, 80)
+            self.normalise_intensity(0, normalise_intensity_percentile)
+        elif self.intensity_adjusted[0]:
+            self.reload_resolution_pyramid_level(0)
+
+        if interpolate:
+            self.interpolate_overlaps(0)
+        elif self.overlaps_interpolated[0]:
+            self.reload_resolution_pyramid_level(0)
 
         if output_path.suffix == ".zarr":
             self._fuse_to_zarr(
diff --git a/brainglobe_stitch/stitching_widget.py b/brainglobe_stitch/stitching_widget.py
index 18955fa..d77879b 100644
--- a/brainglobe_stitch/stitching_widget.py
+++ b/brainglobe_stitch/stitching_widget.py
@@ -234,6 +234,15 @@ def __init__(self, napari_viewer: Viewer):
         self.adjust_intensity_button.setEnabled(False)
         self.layout().addWidget(self.adjust_intensity_button)
 
+        self.liner_interpolation_button = QPushButton(
+            "Preview Linear Interpolation"
+        )
+        self.liner_interpolation_button.clicked.connect(
+            self._on_linear_interpolation_button_clicked
+        )
+        self.liner_interpolation_button.setEnabled(False)
+        self.layout().addWidget(self.liner_interpolation_button)
+
         self.reset_preview_button = QPushButton("Reset Preview")
         self.reset_preview_button.clicked.connect(
             self._on_reset_preview_button_clicked
@@ -247,6 +256,7 @@ def __init__(self, napari_viewer: Viewer):
             QFormLayout(parent=self.fuse_option_widget)
         )
         self.normalise_intensity_toggle = QCheckBox()
+        self.interpolate_overlaps_toggle = QCheckBox()
 
         self.select_output_path = QWidget()
         self.select_output_path.setLayout(QHBoxLayout())
@@ -270,13 +280,14 @@ def __init__(self, napari_viewer: Viewer):
         self.fuse_option_widget.layout().addRow(
             "Normalise intensity:", self.normalise_intensity_toggle
         )
+        self.fuse_option_widget.layout().addRow(
+            "Interpolate overlaps:", self.interpolate_overlaps_toggle
+        )
         self.fuse_option_widget.layout().addRow(QLabel("Output file name:"))
         self.fuse_option_widget.layout().addRow(self.select_output_path)
 
         self.layout().addWidget(self.fuse_option_widget)
 
-        self.layout().addWidget(self.fuse_option_widget)
-
         self.fuse_button = QPushButton("Fuse")
         self.fuse_button.setEnabled(False)
         self.fuse_button.clicked.connect(self._on_fuse_button_clicked)
@@ -364,6 +375,7 @@ def _on_add_tiles_button_clicked(self) -> None:
         worker.yielded.connect(self._set_tile_layers)
         worker.start()
         self.adjust_intensity_button.setEnabled(True)
+        self.liner_interpolation_button.setEnabled(True)
         self.reset_preview_button.setEnabled(True)
 
     def _set_tile_layers(self, tile_layer: napari.layers.Image) -> None:
@@ -451,6 +463,8 @@ def _on_stitch_button_clicked(self) -> None:
         self.fuse_button.setEnabled(False)
         self.stitch_button.setEnabled(False)
         self.adjust_intensity_button.setEnabled(False)
+        self.liner_interpolation_button.setEnabled(False)
+        self.reset_preview_button.setEnabled(False)
         worker.finished.connect(self._on_stitch_finished)
         worker.start()
 
@@ -465,6 +479,7 @@ def _on_stitch_finished(self):
         self.fuse_button.setEnabled(True)
         self.stitch_button.setEnabled(True)
         self.adjust_intensity_button.setEnabled(True)
+        self.liner_interpolation_button.setEnabled(True)
         self.reset_preview_button.setEnabled(True)
 
     def _on_adjust_intensity_button_clicked(self):
@@ -486,9 +501,32 @@ def _on_adjust_intensity_button_clicked(self):
 
         show_info("Intensity adjusted")
 
+    def _on_linear_interpolation_button_clicked(self):
+        if self.image_mosaic.overlaps_interpolated[self.resolution_to_display]:
+            self.image_mosaic.reload_resolution_pyramid_level(
+                self.resolution_to_display
+            )
+
+        self.image_mosaic.interpolate_overlaps(
+            resolution_level=self.resolution_to_display
+        )
+
+        data_for_napari = self.image_mosaic.data_for_napari(
+            self.resolution_to_display
+        )
+
+        self.update_tiles_from_mosaic(data_for_napari)
+
+        show_info("Linear interpolation applied to overlapping regions")
+
     def _on_reset_preview_button_clicked(self):
-        for i, scaled in enumerate(self.image_mosaic.intensity_adjusted):
-            if scaled:
+        for i, (scaled, interpolated) in enumerate(
+            zip(
+                self.image_mosaic.intensity_adjusted,
+                self.image_mosaic.overlaps_interpolated,
+            )
+        ):
+            if scaled or interpolated:
                 self.image_mosaic.reload_resolution_pyramid_level(
                     resolution_level=i
                 )
@@ -563,6 +601,8 @@ def _on_fuse_button_clicked(self) -> None:
         self.image_mosaic.fuse(
             output_path,
             normalise_intensity=self.normalise_intensity_toggle.isChecked(),
+            normalise_intensity_percentile=self.percentile_field.value(),
+            interpolate=self.interpolate_overlaps_toggle.isChecked(),
         )
 
         show_info("Fusing complete")
diff --git a/brainglobe_stitch/tile.py b/brainglobe_stitch/tile.py
index 5bb32bf..8d4bd8a 100644
--- a/brainglobe_stitch/tile.py
+++ b/brainglobe_stitch/tile.py
@@ -1,4 +1,4 @@
-from typing import Dict, List, Tuple
+from typing import Dict, List, Tuple, Union
 
 import dask.array as da
 import numpy as np
@@ -42,7 +42,7 @@ def __init__(
         self,
         tile_name: str,
         tile_id: int,
-        attributes: Dict[str, str],
+        attributes: Dict[str, Union[str, int, float]],
     ):
         self.name: str = tile_name
         self.id: int = tile_id
@@ -212,3 +212,79 @@ def replace_overlap_data(self, resolution_level: int, new_data: da.Array):
                 scaled_coordinates[idx][2] : scaled_coordinates[idx][2]
                 + scaled_size[2],
             ] = new_data
+
+    def linear_interpolation(
+        self, resolution_level: int, tile_shape: Tuple[int, ...]
+    ):
+        """
+        Perform linear interpolation on the overlapping data for both tiles.
+
+        Parameters
+        ----------
+        resolution_level: int
+            The resolution level to perform the interpolation on.
+        tile_shape: Tuple[int, ...]
+            The shape of the tile data at the given resolution level.
+        """
+        # Extract overlapping data from both tiles
+        i_overlap, j_overlap = self.extract_tile_overlaps(resolution_level)
+
+        x_overlap_size = self.size[resolution_level][2]
+        y_overlap_size = self.size[resolution_level][1]
+
+        z_size, y_size, x_size = tile_shape
+        tile_i, tile_j = self.tiles
+
+        if x_overlap_size / x_size < 0.4 and y_overlap_size / y_size < 0.4:
+            # Skip the small diagonal overlaps
+            return
+        elif x_overlap_size / x_size < 0.4:
+            # The thin overlap is in the x dimension
+            x_lin = np.linspace(1, 0, x_overlap_size)
+
+            # 1 in the first column,
+            # linearly decreasing to 0 in the last column
+            yx_grid = np.tile(x_lin, (y_overlap_size, 1))
+
+            # The decreasing image is the one for which the overlap
+            # is at the end of the x dimension
+            if tile_i.position[2] < tile_j.position[2]:
+                decreasing_image = i_overlap
+                increasing_image = j_overlap
+            else:
+                decreasing_image = j_overlap
+                increasing_image = i_overlap
+        else:
+            # The thin overlap is in the y dimension
+            y_lin = np.linspace(1, 0, y_overlap_size)
+
+            # 1 in the first row,
+            # linearly decreasing to 0 in the last row
+            yx_grid = np.tile(y_lin, (x_overlap_size, 1)).T
+
+            # The decreasing image is the one for which the overlap
+            # is at the end of the y dimension
+            if tile_i.position[1] < tile_j.position[1]:
+                decreasing_image = i_overlap
+                increasing_image = j_overlap
+            else:
+                decreasing_image = j_overlap
+                increasing_image = i_overlap
+
+        interp = (
+            np.multiply(
+                decreasing_image.compute(),
+                yx_grid,
+                dtype=np.float32,
+            )
+            + np.multiply(
+                increasing_image.compute(),
+                1 - yx_grid,
+                dtype=np.float32,
+            )
+        ).astype(i_overlap.dtype)
+
+        # Replace the overlap data with the interpolated data
+        # Allows future interpolation to be done on the
+        # interpolated data
+        self.replace_overlap_data(resolution_level, interp)
diff --git a/tests/test_unit/conftest.py b/tests/test_unit/conftest.py
index b4b411c..17e1a33 100644
--- a/tests/test_unit/conftest.py
+++ b/tests/test_unit/conftest.py
@@ -211,6 +211,34 @@ def test_constants(imagej_path):
             [6, 7, 118],
             [5, 123, 116],
         ],
+        "EXPECTED_OVERLAP_COORDINATES": [
+            [3, 120, 2],
+            [6, 7, 118],
+            [5, 123, 116],
+            [6, 120, 118],
+            [5, 123, 116],
+            [3, 120, 2],
+            [6, 7, 118],
+            [5, 123, 116],
+            [6, 120, 118],
+            [5, 123, 116],
+            [6, 123, 118],
+            [6, 123, 118],
+        ],
+        "EXPECTED_OVERLAP_SIZE": [
+            [106, 12, 126],
+            [104, 125, 12],
+            [105, 9, 14],
+            [103, 15, 10],
+            [104, 125, 12],
+            [106, 12, 126],
+            [104, 125, 12],
+            [105, 9, 14],
+            [103, 15, 10],
+            [104, 125, 12],
+            [106, 12, 126],
+            [106, 12, 126],
+        ],
         "EXPECTED_INTENSITY_FACTORS": [
             1.00000,
             0.99636,
@@ -221,6 +249,7 @@ def test_constants(imagej_path):
             1.06679,
             1.08642,
         ],
+        "EXPECTED_NUM_OVERLAPS": 12,
         "EXPECTED_FUSED_SHAPE": (113, 251, 246),
         "CHANNELS": ["561 nm", "647 nm"],
         "PIXEL_SIZE_XY": 4.08,
diff --git a/tests/test_unit/test_image_mosaic.py b/tests/test_unit/test_image_mosaic.py
index c098284..3f9721f 100644
--- a/tests/test_unit/test_image_mosaic.py
+++ b/tests/test_unit/test_image_mosaic.py
@@ -91,6 +91,34 @@ def test_stitch(mocker, image_mosaic, naive_bdv_directory, test_constants):
     )
 
 
+def test_calculate_overlaps(image_mosaic, test_constants):
+    """
+    Ensure that the calculate_overlaps method calculates the overlaps
+    correctly. The expected overlaps are stored in the dictionary returned by
+    the test_constants fixture.
+    """
+    image_mosaic.calculate_overlaps()
+
+    assert (
+        len(image_mosaic.overlaps) == test_constants["EXPECTED_NUM_OVERLAPS"]
+    )
+
+    expected_overlap_coordinates = test_constants[
+        "EXPECTED_OVERLAP_COORDINATES"
+    ]
+    expected_overlap_size = test_constants["EXPECTED_OVERLAP_SIZE"]
+
+    for idx, overlap in enumerate(image_mosaic.overlaps):
+        assert (
+            image_mosaic.overlaps[overlap].coordinates
+            == expected_overlap_coordinates[idx]
+        ).all()
+        assert (
+            image_mosaic.overlaps[overlap].size[0]
+            == expected_overlap_size[idx]
+        ).all()
+
+
 def test_data_for_napari(image_mosaic, test_constants):
     """
     Checks the return of the data_for_napari method. Each element of the
@@ -215,6 +243,38 @@ def test_calculate_intensity_scale_factors(image_mosaic, test_constants):
     )
 
 
+@pytest.mark.parametrize("resolution_level", [0, 1, 2, 3])
+def test_interpolate_overlaps(
+    image_mosaic, resolution_level, mocker, test_constants
+):
+    image_mosaic.reload_resolution_pyramid_level(resolution_level)
+    mock_linear_interpolation = mocker.patch(
+        "brainglobe_stitch.tile.Overlap.linear_interpolation",
+    )
+
+    image_mosaic.interpolate_overlaps(resolution_level)
+
+    assert image_mosaic.overlaps_interpolated[resolution_level]
+    assert (
+        mock_linear_interpolation.call_count
+        == test_constants["EXPECTED_NUM_OVERLAPS"]
+    )
+
+
+def test_interpolate_overlaps_already_done(image_mosaic, mocker):
+    resolution_level = 2
+    image_mosaic.reload_resolution_pyramid_level(resolution_level)
+    image_mosaic.overlaps_interpolated[resolution_level] = True
+    mock_linear_interpolation = mocker.patch(
+        "brainglobe_stitch.tile.Overlap.linear_interpolation",
+    )
+    image_mosaic.interpolate_overlaps(resolution_level)
+
+    assert image_mosaic.overlaps_interpolated[resolution_level]
+    mock_linear_interpolation.assert_not_called()
+    image_mosaic.reload_resolution_pyramid_level(resolution_level)
+
+
 def test_fuse_invalid_file_type(image_mosaic):
     output_file = image_mosaic.xml_path.parent / "fused.txt"
     with pytest.raises(ValueError):
@@ -247,7 +307,6 @@ def test_fuse_zarr_normalise_intensity(image_mosaic, mocker, test_constants):
         "brainglobe_stitch.image_mosaic.ImageMosaic.normalise_intensity"
     )
 
-    image_mosaic.normalise_intensity = mock_normalise_intensity
     image_mosaic.fuse(file_path, normalise_intensity=True)
 
     mock_normalise_intensity.assert_called_once_with(0, 80)
@@ -262,6 +321,94 @@ def test_fuse_zarr_normalise_intensity(image_mosaic, mocker, test_constants):
     )
 
 
+def test_fuse_zarr_normalise_already_done(
+    image_mosaic, mocker, test_constants
+):
+    file_path = image_mosaic.xml_path.parent / "fused.zarr"
+
+    mock_fuse_to_zarr = mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic._fuse_to_zarr"
+    )
+    mock_normalise_intensity = mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic.normalise_intensity"
+    )
+    mock_reload_pyramid_level = mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic.reload_resolution_pyramid_level"
+    )
+    image_mosaic.intensity_adjusted[0] = True
+
+    image_mosaic.fuse(file_path, normalise_intensity=False)
+
+    mock_normalise_intensity.assert_not_called()
+    mock_reload_pyramid_level.assert_called_once_with(0)
+    mock_fuse_to_zarr.assert_called_once_with(
+        file_path,
+        test_constants["EXPECTED_FUSED_SHAPE"],
+        test_constants["DEFAULT_DOWNSAMPLE_FACTORS"],
+        test_constants["DEFAULT_PYRAMID_DEPTH"],
+        test_constants["DEFAULT_CHUNK_SHAPE"],
+        test_constants["DEFAULT_COMPRESSION_METHOD"],
+        test_constants["DEFAULT_COMPRESSION_LEVEL"],
+    )
+    image_mosaic.intensity_adjusted[0] = False
+
+
+def test_fuse_zarr_interpolate_already_done(
+    image_mosaic, mocker, test_constants
+):
+    file_path = image_mosaic.xml_path.parent / "fused.zarr"
+
+    mock_fuse_to_zarr = mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic._fuse_to_zarr"
+    )
+    mock_interpolate_overlaps = mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic.interpolate_overlaps"
+    )
+    mock_reload_pyramid_level = mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic.reload_resolution_pyramid_level"
+    )
+    image_mosaic.overlaps_interpolated[0] = True
+
+    image_mosaic.fuse(file_path, interpolate=False)
+
+    mock_interpolate_overlaps.assert_not_called()
+    mock_reload_pyramid_level.assert_called_once_with(0)
+    mock_fuse_to_zarr.assert_called_once_with(
+        file_path,
+        test_constants["EXPECTED_FUSED_SHAPE"],
+        test_constants["DEFAULT_DOWNSAMPLE_FACTORS"],
+        test_constants["DEFAULT_PYRAMID_DEPTH"],
+        test_constants["DEFAULT_CHUNK_SHAPE"],
+        test_constants["DEFAULT_COMPRESSION_METHOD"],
+        test_constants["DEFAULT_COMPRESSION_LEVEL"],
+    )
+    image_mosaic.overlaps_interpolated[0] = False
+
+
+def test_fuse_zarr_interpolate(image_mosaic, mocker, test_constants):
+    file_path = image_mosaic.xml_path.parent / "fused.zarr"
+
+    mock_fuse_to_zarr = mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic._fuse_to_zarr"
+    )
+    mock_interpolate_overlaps = mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic.interpolate_overlaps"
+    )
+
+    image_mosaic.fuse(file_path, interpolate=True)
+
+    mock_interpolate_overlaps.assert_called_once_with(0)
+    mock_fuse_to_zarr.assert_called_once_with(
+        file_path,
+        test_constants["EXPECTED_FUSED_SHAPE"],
+        test_constants["DEFAULT_DOWNSAMPLE_FACTORS"],
+        test_constants["DEFAULT_PYRAMID_DEPTH"],
+        test_constants["DEFAULT_CHUNK_SHAPE"],
+        test_constants["DEFAULT_COMPRESSION_METHOD"],
+        test_constants["DEFAULT_COMPRESSION_LEVEL"],
+    )
+
+
 @pytest.mark.parametrize(
     "downscale_factors, chunk_shape, pyramid_depth",
     [((2, 2, 2), (64, 64, 64), 2), ((4, 4, 4), (32, 32, 32), 3)],
@@ -280,12 +427,14 @@ def test_fuse_bdv_h5_custom(
     file_path = image_mosaic.xml_path.parent / "fused.h5"
 
     normalise_intensity = False
+    interpolate = False
     image_mosaic.fuse(
-        file_path,
-        normalise_intensity,
-        downscale_factors,
-        chunk_shape,
-        pyramid_depth,
+        output_path=file_path,
+        normalise_intensity=normalise_intensity,
+        interpolate=interpolate,
+        downscale_factors=downscale_factors,
+        chunk_shape=chunk_shape,
+        pyramid_depth=pyramid_depth,
     )
     mock_fuse_function.assert_called_once_with(
         file_path,
@@ -340,14 +489,16 @@ def test_fuse_bdv_zarr_custom(
     file_path = image_mosaic.xml_path.parent / "fused.zarr"
 
     normalise_intensity = False
+    interpolate = False
     image_mosaic.fuse(
-        file_path,
-        normalise_intensity,
-        downscale_factors,
-        chunk_shape,
-        pyramid_depth,
-        compression_method,
-        compression_level,
+        output_path=file_path,
+        normalise_intensity=normalise_intensity,
+        interpolate=interpolate,
+        downscale_factors=downscale_factors,
+        chunk_shape=chunk_shape,
+        pyramid_depth=pyramid_depth,
+        compression_method=compression_method,
+        compression_level=compression_level,
     )
     mock_fuse_function.assert_called_once_with(
         file_path,
diff --git a/tests/test_unit/test_stitching_widget.py b/tests/test_unit/test_stitching_widget.py
index c106d86..4ad5d5f 100644
--- a/tests/test_unit/test_stitching_widget.py
+++ b/tests/test_unit/test_stitching_widget.py
@@ -457,6 +457,8 @@ def test_on_stitch_finished(stitching_widget_with_mosaic, mocker):
     assert stitching_widget_with_mosaic.fuse_button.isEnabled()
     assert stitching_widget_with_mosaic.stitch_button.isEnabled()
     assert stitching_widget_with_mosaic.adjust_intensity_button.isEnabled()
+    assert stitching_widget_with_mosaic.reset_preview_button.isEnabled()
+    assert stitching_widget_with_mosaic.liner_interpolation_button.isEnabled()
 
 
 def tests_on_adjust_intensity_button_clicked(
@@ -481,6 +483,57 @@ def tests_on_adjust_intensity_button_clicked(
     )
 
 
+def test_on_linear_interpolation_button_clicked(
+    stitching_widget_with_mosaic, mocker
+):
+    """
+    Tests that the _on_linear_interpolation_button_clicked method correctly
+    calls the linear_interpolation method of the ImageMosaic object with the
+    correct arguments.
+    """
+    mock_linear_interpolation = mocker.patch(
+        "brainglobe_stitch.stitching_widget.ImageMosaic.interpolate_overlaps",
+        autospec=True,
+    )
+
+    stitching_widget_with_mosaic._on_linear_interpolation_button_clicked()
+
+    mock_linear_interpolation.assert_called_once_with(
+        stitching_widget_with_mosaic.image_mosaic,
+        resolution_level=3,
+    )
+
+
+def test_on_linear_interpolation_button_clicked_interpolated(
+    stitching_widget_with_mosaic, mocker
+):
+    """
+    Tests that the _on_linear_interpolation_button_clicked method correctly
+    calls the linear_interpolation method of the ImageMosaic object with the
+    correct arguments even if the interpolation has already been previewed..
+    """
+    mock_linear_interpolation = mocker.patch(
+        "brainglobe_stitch.stitching_widget.ImageMosaic.interpolate_overlaps",
+        autospec=True,
+    )
+    mock_reload_resolution_pyramid_level = mocker.patch(
+        "brainglobe_stitch.stitching_widget.ImageMosaic.reload_resolution_pyramid_level",
+        autospec=True,
+    )
+
+    stitching_widget_with_mosaic.image_mosaic.overlaps_interpolated[3] = True
+    stitching_widget_with_mosaic._on_linear_interpolation_button_clicked()
+
+    mock_reload_resolution_pyramid_level.assert_called_once_with(
+        stitching_widget_with_mosaic.image_mosaic,
+        resolution_level=3,
+    )
+    mock_linear_interpolation.assert_called_once_with(
+        stitching_widget_with_mosaic.image_mosaic,
+        resolution_level=3,
+    )
+
+
 def test_check_imagej_path_valid(stitching_widget):
     """
     Creates a mock imageJ file in the home directory and sets it as the
@@ -616,7 +669,11 @@ def test_on_fuse_button_clicked(
     stitching_widget._on_fuse_button_clicked()
 
     mock_fuse.assert_called_once_with(
-        stitching_widget.image_mosaic, output_path, normalise_intensity=False
+        stitching_widget.image_mosaic,
+        output_path,
+        normalise_intensity=False,
+        normalise_intensity_percentile=80,
+        interpolate=False,
     )
     mock_display_info.assert_called_once_with(
         stitching_widget,
@@ -674,3 +731,116 @@ def test_on_fuse_button_clicked_wrong_suffix(
     mock_display_info.assert_called_once_with(
         stitching_widget, "Warning", error_message
     )
+
+
+def test_on_reset_preview_button_clicked_no_change(
+    stitching_widget_with_mosaic, mocker
+):
+    """
+    Tests that the _on_reset_preview_button_clicked method doesn't call
+    the reload_resolution_pyramid_level if no changes to the pyramid have
+    been made.
+    """
+    mock_reset_preview = mocker.patch(
+        "brainglobe_stitch.stitching_widget.ImageMosaic.reload_resolution_pyramid_level",
+        autospec=True,
+    )
+    mock_show_info = mocker.patch(
+        "brainglobe_stitch.stitching_widget.show_info",
+        autospec=True,
+    )
+
+    stitching_widget_with_mosaic._on_reset_preview_button_clicked()
+
+    mock_reset_preview.assert_not_called()
+    mock_show_info.assert_called_once_with("Preview reset")
+
+
+def test_on_reset_preview_button_clicked_brightness_adjust(
+    stitching_widget_with_mosaic, mocker
+):
+    """
+    Tests that the _on_reset_preview_button_clicked method calls the
+    reload_resolution_pyramid_level if the brightness of the preview has
+    been adjusted.
+    """
+    mock_reset_preview = mocker.patch(
+        "brainglobe_stitch.stitching_widget.ImageMosaic.reload_resolution_pyramid_level",
+        autospec=True,
+    )
+    mock_show_info = mocker.patch(
+        "brainglobe_stitch.stitching_widget.show_info",
+        autospec=True,
+    )
+    level = stitching_widget_with_mosaic.resolution_to_display
+    stitching_widget_with_mosaic.image_mosaic.intensity_adjusted[level] = True
+
+    stitching_widget_with_mosaic._on_reset_preview_button_clicked()
+
+    mock_reset_preview.assert_called_once()
+    mock_show_info.assert_called_once_with("Preview reset")
+
+    stitching_widget_with_mosaic.image_mosaic.intensity_adjusted[level] = False
+
+
+def test_on_reset_preview_button_clicked_interpolation(
+    stitching_widget_with_mosaic, mocker
+):
+    """
+    Tests that the _on_reset_preview_button_clicked method calls the
+    reload_resolution_pyramid_level if the preview resolution is interpolated.
+    """
+    mock_reset_preview = mocker.patch(
+        "brainglobe_stitch.stitching_widget.ImageMosaic.reload_resolution_pyramid_level",
+        autospec=True,
+    )
+    mock_show_info = mocker.patch(
+        "brainglobe_stitch.stitching_widget.show_info",
+        autospec=True,
+    )
+    level = stitching_widget_with_mosaic.resolution_to_display
+    stitching_widget_with_mosaic.image_mosaic.overlaps_interpolated[level] = (
+        True
+    )
+
+    stitching_widget_with_mosaic._on_reset_preview_button_clicked()
+
+    mock_reset_preview.assert_called_once()
+    mock_show_info.assert_called_once_with("Preview reset")
+
+    stitching_widget_with_mosaic.image_mosaic.overlaps_interpolated[level] = (
+        False
+    )
+
+
+def test_on_reset_preview_button_clicked_both(
+    stitching_widget_with_mosaic, mocker
+):
+    """
+    Tests that the _on_reset_preview_button_clicked method calls the
+    reload_resolution_pyramid_level if the brightness of the preview has
+    been adjusted and the preview resolution is interpolated.
+    """
+    mock_reset_preview = mocker.patch(
+        "brainglobe_stitch.stitching_widget.ImageMosaic.reload_resolution_pyramid_level",
+        autospec=True,
+    )
+    mock_show_info = mocker.patch(
+        "brainglobe_stitch.stitching_widget.show_info",
+        autospec=True,
+    )
+    level = stitching_widget_with_mosaic.resolution_to_display
+    stitching_widget_with_mosaic.image_mosaic.intensity_adjusted[level] = True
+    stitching_widget_with_mosaic.image_mosaic.overlaps_interpolated[level] = (
+        True
+    )
+
+    stitching_widget_with_mosaic._on_reset_preview_button_clicked()
+
+    mock_reset_preview.assert_called_once()
+    mock_show_info.assert_called_once_with("Preview reset")
+
+    stitching_widget_with_mosaic.image_mosaic.intensity_adjusted[level] = False
+    stitching_widget_with_mosaic.image_mosaic.overlaps_interpolated[level] = (
+        False
+    )
diff --git a/tests/test_unit/test_tile.py b/tests/test_unit/test_tile.py
index 171eadb..51162ae 100644
--- a/tests/test_unit/test_tile.py
+++ b/tests/test_unit/test_tile.py
@@ -56,6 +56,18 @@ def generate_tile_data_random() -> Tuple[List[da.Array], npt.NDArray]:
     return generate_tile(image_data)
 
 
+def generate_tile_data_high() -> Tuple[List[da.Array], npt.NDArray]:
+    image_data = da.ones((256, 256, 256), dtype=np.int16) * TILE_DATA_HIGH
+
+    return generate_tile(image_data)
+
+
+def generate_tile_data_low() -> Tuple[List[da.Array], npt.NDArray]:
+    image_data = da.zeros((256, 256, 256), dtype=np.int16)
+
+    return generate_tile(image_data)
+
+
 @pytest.fixture
 def generate_overlap():
     """
@@ -211,3 +223,126 @@ def test_replace_overlap_data(generate_overlap, res_level):
             ]
             == 0
         ).all()
+
+
+@pytest.mark.parametrize(
+    "resolution_level, tile_positions",
+    [
+        (0, [[10, 0, 10], [0, 245, 15]]),
+        (0, [[10, 10, 0], [0, 15, 245]]),
+        (0, [[0, 245, 15], [10, 0, 10]]),
+        (0, [[0, 15, 245], [10, 10, 0]]),
+        (1, [[10, 0, 10], [0, 245, 15]]),
+        (1, [[10, 10, 0], [0, 15, 245]]),
+        (1, [[0, 245, 15], [10, 0, 10]]),
+        (1, [[0, 15, 245], [10, 10, 0]]),
+        (2, [[10, 0, 10], [0, 245, 15]]),
+        (2, [[10, 10, 0], [0, 15, 245]]),
+        (2, [[0, 245, 15], [10, 0, 10]]),
+        (2, [[0, 15, 245], [10, 10, 0]]),
+    ],
+)
+def test_linear_interpolation(resolution_level, tile_positions):
+    tile_zero = Tile(
+        "test_1", 0, {"channel": 0, "tile": 0, "illumination": 0, "angle": 0}
+    )
+    tile_one = Tile(
+        "test_2", 1, {"channel": 0, "tile": 1, "illumination": 0, "angle": 0}
+    )
+
+    # Set the position such that there is an overlap of size [246, 9, 251]
+    tile_zero.position = np.array(tile_positions[0])
+    tile_one.position = np.array(tile_positions[1])
+
+    (
+        tile_zero.data_pyramid,
+        tile_zero.resolution_pyramid,
+    ) = generate_tile_data_low()
+    (
+        tile_one.data_pyramid,
+        tile_one.resolution_pyramid,
+    ) = generate_tile_data_high()
+
+    overlap_coordinates = np.array(
+        [max(tile_zero.position[i], tile_one.position[i]) for i in range(3)]
+    )
+
+    overlap_size = (
+        tile_zero.data_pyramid[0].shape
+        - overlap_coordinates
+        + np.array(
+            [
+                min(tile_zero.position[i], tile_one.position[i])
+                for i in range(3)
+            ]
+        )
+    )
+
+    overlap = Overlap(overlap_coordinates, overlap_size, tile_zero, tile_one)
+
+    overlap.linear_interpolation(
+        resolution_level, tile_one.data_pyramid[resolution_level].shape
+    )
+
+    overlap_i, overlap_j = overlap.extract_tile_overlaps(resolution_level)
+
+    assert (overlap_i == overlap_j).all()
+    assert overlap_i.mean() == TILE_DATA_HIGH / 2
+    assert overlap_j.mean() == TILE_DATA_HIGH / 2
+
+
+@pytest.mark.parametrize(
+    "resolution_level, tile_positions",
+    [
+        (0, [[10, 0, 0], [0, 245, 245]]),
+        (1, [[10, 0, 0], [0, 245, 245]]),
+        (2, [[10, 0, 0], [0, 245, 245]]),
+    ],
+)
+def test_linear_interpolation_diagonal(resolution_level, tile_positions):
+    tile_zero = Tile(
+        "test_1", 0, {"channel": 0, "tile": 0, "illumination": 0, "angle": 0}
+    )
+    tile_one = Tile(
+        "test_2", 1, {"channel": 0, "tile": 1, "illumination": 0, "angle": 0}
+    )
+
+    # Set the position such that there is an overlap of size [246, 9, 251]
+    tile_zero.position = np.array(tile_positions[0])
+    tile_one.position = np.array(tile_positions[1])
+
+    (
+        tile_zero.data_pyramid,
+        tile_zero.resolution_pyramid,
+    ) = generate_tile_data_low()
+    (
+        tile_one.data_pyramid,
+        tile_one.resolution_pyramid,
+    ) = generate_tile_data_high()
+
+    overlap_coordinates = np.array(
+        [max(tile_zero.position[i], tile_one.position[i]) for i in range(3)]
+    )
+
+    overlap_size = (
+        tile_zero.data_pyramid[0].shape
+        - overlap_coordinates
+        + np.array(
+            [
+                min(tile_zero.position[i], tile_one.position[i])
+                for i in range(3)
+            ]
+        )
+    )
+
+    overlap = Overlap(overlap_coordinates, overlap_size, tile_zero, tile_one)
+
+    overlap.linear_interpolation(
+        resolution_level, tile_one.data_pyramid[resolution_level].shape
+    )
+
+    overlap_i, overlap_j = overlap.extract_tile_overlaps(resolution_level)
+
+    assert (overlap_i != overlap_j).all()
+    assert overlap_i.mean() == 0
+    assert overlap_j.mean() == TILE_DATA_HIGH


Reviews:
user: login: lauraporta
body: Review part 1
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-23T13:11:10Z
user: login: lauraporta
body: Very nice work 🙌🏻
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-29T14:25:47Z
user: login: lauraporta
body: Very nice work 🙌🏻
state: APPROVED
author_association: MEMBER
submitted_at: 2025-04-29T14:25:49Z
user: login: IgorTatarnikov
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T14:30:00Z
user: login: IgorTatarnikov
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T14:35:40Z
user: login: IgorTatarnikov
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T14:37:12Z
user: login: IgorTatarnikov
state: COMMENTED
author_association: MEMBER
submitted_at: 2025-04-29T14:44:00Z

Review comments:
diff_hunk: @@ -212,3 +212,79 @@ def replace_overlap_data(self, resolution_level: int, new_data: da.Array):
                 scaled_coordinates[idx][2] : scaled_coordinates[idx][2]
                 + scaled_size[2],
             ] = new_data
+
+    def linear_interpolation(
+        self, resolution_level: int, tile_shape: Tuple[int, ...]
+    ):
+        """
+        Perform linear interpolation on the overlapping data for both tiles.
+
+        Parameters
+        ----------
+        resolution_level: int
+            The resolution level to perform the interpolation on.
+        tile_shape: Tuple[int, ...]
+            The shape of the tile data at the given resolution level.
+        """
+        # Extract overlapping data from both tiles
+        i_overlap, j_overlap = self.extract_tile_overlaps(resolution_level)
+
+        x_overlap_size = self.size[resolution_level][2]
+        y_overlap_size = self.size[resolution_level][1]
+
+        z_size, y_size, x_size = tile_shape
+        tile_i, tile_j = self.tiles
+
+        if x_overlap_size / x_size < 0.4 and y_overlap_size / y_size < 0.4:
path: brainglobe_stitch/tile.py
user: login: lauraporta
body: why `.4`?
created_at: 2025-04-23T11:25:10Z
author_association: MEMBER
diff_hunk: @@ -262,6 +321,94 @@ def test_fuse_zarr_normalise_intensity(image_mosaic, mocker, test_constants):
     )
 
 
+def test_fuse_zarr_normalise_already_done(
+    image_mosaic, mocker, test_constants
+):
+    file_path = image_mosaic.xml_path.parent / "fused.zarr"
+
+    mock_fuse_to_zarr = mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic._fuse_to_zarr"
+    )
+    mock_normalise_intensity = mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic.normalise_intensity"
+    )
+    mock_reload_pyramid_level = mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic.reload_resolution_pyramid_level"
+    )
+    image_mosaic.intensity_adjusted[0] = True
+
+    image_mosaic.fuse(file_path, normalise_intensity=False)
+
+    mock_normalise_intensity.assert_not_called()
path: tests/test_unit/test_image_mosaic.py
user: login: lauraporta
body: Cool!
created_at: 2025-04-29T13:50:19Z
author_association: MEMBER
diff_hunk: @@ -674,3 +731,116 @@ def test_on_fuse_button_clicked_wrong_suffix(
     mock_display_info.assert_called_once_with(
         stitching_widget, "Warning", error_message
     )
+
+
+def test_on_reset_preview_button_clicked_no_change(
+    stitching_widget_with_mosaic, mocker
+):
+    """
+    Tests that the _on_reset_preview_button_clicked method doesn't call
+    the reload_resolution_pyramid_level if no changes to the pyramid have
+    been made.
+    """
+    mock_reset_preview = mocker.patch(
+        "brainglobe_stitch.stitching_widget.ImageMosaic.reload_resolution_pyramid_level",
+        autospec=True,
+    )
+    mock_show_info = mocker.patch(
+        "brainglobe_stitch.stitching_widget.show_info",
+        autospec=True,
+    )
+
+    stitching_widget_with_mosaic._on_reset_preview_button_clicked()
+
+    mock_reset_preview.assert_not_called()
+    mock_show_info.assert_called_once_with("Preview reset")
+
+
+def test_on_reset_preview_button_clicked_brightness_adjust(
+    stitching_widget_with_mosaic, mocker
+):
+    """
+    Tests that the _on_reset_preview_button_clicked method calls the
+    reload_resolution_pyramid_level if the brightness of the preview has
+    been adjusted.
+    """
+    mock_reset_preview = mocker.patch(
+        "brainglobe_stitch.stitching_widget.ImageMosaic.reload_resolution_pyramid_level",
+        autospec=True,
+    )
+    mock_show_info = mocker.patch(
+        "brainglobe_stitch.stitching_widget.show_info",
+        autospec=True,
+    )
+    level = stitching_widget_with_mosaic.resolution_to_display
+    stitching_widget_with_mosaic.image_mosaic.intensity_adjusted[level] = True
+
+    stitching_widget_with_mosaic._on_reset_preview_button_clicked()
+
+    mock_reset_preview.assert_called_once()
path: tests/test_unit/test_stitching_widget.py
user: login: lauraporta
body: Your testing strategy is very interesting, with all these mockers. I guess it is typical for testing UI
created_at: 2025-04-29T13:53:05Z
author_association: MEMBER
diff_hunk: @@ -212,3 +212,79 @@ def replace_overlap_data(self, resolution_level: int, new_data: da.Array):
                 scaled_coordinates[idx][2] : scaled_coordinates[idx][2]
                 + scaled_size[2],
             ] = new_data
+
+    def linear_interpolation(
+        self, resolution_level: int, tile_shape: Tuple[int, ...]
+    ):
+        """
+        Perform linear interpolation on the overlapping data for both tiles.
+
+        Parameters
+        ----------
+        resolution_level: int
+            The resolution level to perform the interpolation on.
+        tile_shape: Tuple[int, ...]
+            The shape of the tile data at the given resolution level.
+        """
+        # Extract overlapping data from both tiles
+        i_overlap, j_overlap = self.extract_tile_overlaps(resolution_level)
+
+        x_overlap_size = self.size[resolution_level][2]
+        y_overlap_size = self.size[resolution_level][1]
+
+        z_size, y_size, x_size = tile_shape
+        tile_i, tile_j = self.tiles
+
+        if x_overlap_size / x_size < 0.4 and y_overlap_size / y_size < 0.4:
+            # Skip the small diagonal overlaps
+            return
+        elif x_overlap_size / x_size < 0.4:
+            # The thin overlap is in the x dimension
+            x_lin = np.linspace(1, 0, x_overlap_size)
+
+            # 1 in the first column,
+            # linearly decreasing to 0 in the last column
+            yx_grid = np.tile(x_lin, (y_overlap_size, 1))
+
+            # The decreasing image is the one for which the overlap
+            # is at the end of the x dimension
+            if tile_i.position[2] < tile_j.position[2]:
+                decreasing_image = i_overlap
+                increasing_image = j_overlap
+            else:
+                decreasing_image = j_overlap
+                increasing_image = i_overlap
+        else:
+            # The thin overlap is in the y dimension
+            y_lin = np.linspace(1, 0, y_overlap_size)
+
+            # 1 in the first row,
+            # linearly decreasing to 0 in the last row
+            yx_grid = np.tile(y_lin, (x_overlap_size, 1)).T
+
+            # The decreasing image is the one for which the overlap
+            # is at the end of the y dimension
+            if tile_i.position[1] < tile_j.position[1]:
+                decreasing_image = i_overlap
+                increasing_image = j_overlap
+            else:
+                decreasing_image = j_overlap
+                increasing_image = i_overlap
+
+        interp = (
+            np.multiply(
+                decreasing_image.compute(),
path: brainglobe_stitch/tile.py
user: login: lauraporta
body: Calling this method `compute()` sounds so cryptic to me 😂 I've just seen that is part of dask
created_at: 2025-04-29T14:08:11Z
author_association: MEMBER
diff_hunk: @@ -212,3 +212,79 @@ def replace_overlap_data(self, resolution_level: int, new_data: da.Array):
                 scaled_coordinates[idx][2] : scaled_coordinates[idx][2]
                 + scaled_size[2],
             ] = new_data
+
+    def linear_interpolation(
+        self, resolution_level: int, tile_shape: Tuple[int, ...]
+    ):
+        """
+        Perform linear interpolation on the overlapping data for both tiles.
+
+        Parameters
+        ----------
+        resolution_level: int
+            The resolution level to perform the interpolation on.
+        tile_shape: Tuple[int, ...]
+            The shape of the tile data at the given resolution level.
+        """
+        # Extract overlapping data from both tiles
+        i_overlap, j_overlap = self.extract_tile_overlaps(resolution_level)
+
+        x_overlap_size = self.size[resolution_level][2]
+        y_overlap_size = self.size[resolution_level][1]
+
+        z_size, y_size, x_size = tile_shape
+        tile_i, tile_j = self.tiles
+
+        if x_overlap_size / x_size < 0.4 and y_overlap_size / y_size < 0.4:
+            # Skip the small diagonal overlaps
path: brainglobe_stitch/tile.py
user: login: lauraporta
body: I am not sure I get what is a diagonal overlap between tiles 🤔
created_at: 2025-04-29T14:20:28Z
author_association: MEMBER
diff_hunk: @@ -212,3 +212,79 @@ def replace_overlap_data(self, resolution_level: int, new_data: da.Array):
                 scaled_coordinates[idx][2] : scaled_coordinates[idx][2]
                 + scaled_size[2],
             ] = new_data
+
+    def linear_interpolation(
+        self, resolution_level: int, tile_shape: Tuple[int, ...]
+    ):
+        """
+        Perform linear interpolation on the overlapping data for both tiles.
+
+        Parameters
+        ----------
+        resolution_level: int
+            The resolution level to perform the interpolation on.
+        tile_shape: Tuple[int, ...]
+            The shape of the tile data at the given resolution level.
+        """
+        # Extract overlapping data from both tiles
+        i_overlap, j_overlap = self.extract_tile_overlaps(resolution_level)
+
+        x_overlap_size = self.size[resolution_level][2]
+        y_overlap_size = self.size[resolution_level][1]
+
+        z_size, y_size, x_size = tile_shape
+        tile_i, tile_j = self.tiles
+
+        if x_overlap_size / x_size < 0.4 and y_overlap_size / y_size < 0.4:
path: brainglobe_stitch/tile.py
user: login: IgorTatarnikov
body: That was just a safe number that seemed to work. I need to deal with the diagonal overlaps properly at some point.
created_at: 2025-04-29T14:30:00Z
author_association: MEMBER
diff_hunk: @@ -674,3 +731,116 @@ def test_on_fuse_button_clicked_wrong_suffix(
     mock_display_info.assert_called_once_with(
         stitching_widget, "Warning", error_message
     )
+
+
+def test_on_reset_preview_button_clicked_no_change(
+    stitching_widget_with_mosaic, mocker
+):
+    """
+    Tests that the _on_reset_preview_button_clicked method doesn't call
+    the reload_resolution_pyramid_level if no changes to the pyramid have
+    been made.
+    """
+    mock_reset_preview = mocker.patch(
+        "brainglobe_stitch.stitching_widget.ImageMosaic.reload_resolution_pyramid_level",
+        autospec=True,
+    )
+    mock_show_info = mocker.patch(
+        "brainglobe_stitch.stitching_widget.show_info",
+        autospec=True,
+    )
+
+    stitching_widget_with_mosaic._on_reset_preview_button_clicked()
+
+    mock_reset_preview.assert_not_called()
+    mock_show_info.assert_called_once_with("Preview reset")
+
+
+def test_on_reset_preview_button_clicked_brightness_adjust(
+    stitching_widget_with_mosaic, mocker
+):
+    """
+    Tests that the _on_reset_preview_button_clicked method calls the
+    reload_resolution_pyramid_level if the brightness of the preview has
+    been adjusted.
+    """
+    mock_reset_preview = mocker.patch(
+        "brainglobe_stitch.stitching_widget.ImageMosaic.reload_resolution_pyramid_level",
+        autospec=True,
+    )
+    mock_show_info = mocker.patch(
+        "brainglobe_stitch.stitching_widget.show_info",
+        autospec=True,
+    )
+    level = stitching_widget_with_mosaic.resolution_to_display
+    stitching_widget_with_mosaic.image_mosaic.intensity_adjusted[level] = True
+
+    stitching_widget_with_mosaic._on_reset_preview_button_clicked()
+
+    mock_reset_preview.assert_called_once()
path: tests/test_unit/test_stitching_widget.py
user: login: IgorTatarnikov
body: I always struggle with UI testing for that reason, since I'm not testing backend logic I end up mocking everything and just checking the UI passes the correct things to the backend.
created_at: 2025-04-29T14:35:40Z
author_association: MEMBER
diff_hunk: @@ -212,3 +212,79 @@ def replace_overlap_data(self, resolution_level: int, new_data: da.Array):
                 scaled_coordinates[idx][2] : scaled_coordinates[idx][2]
                 + scaled_size[2],
             ] = new_data
+
+    def linear_interpolation(
+        self, resolution_level: int, tile_shape: Tuple[int, ...]
+    ):
+        """
+        Perform linear interpolation on the overlapping data for both tiles.
+
+        Parameters
+        ----------
+        resolution_level: int
+            The resolution level to perform the interpolation on.
+        tile_shape: Tuple[int, ...]
+            The shape of the tile data at the given resolution level.
+        """
+        # Extract overlapping data from both tiles
+        i_overlap, j_overlap = self.extract_tile_overlaps(resolution_level)
+
+        x_overlap_size = self.size[resolution_level][2]
+        y_overlap_size = self.size[resolution_level][1]
+
+        z_size, y_size, x_size = tile_shape
+        tile_i, tile_j = self.tiles
+
+        if x_overlap_size / x_size < 0.4 and y_overlap_size / y_size < 0.4:
+            # Skip the small diagonal overlaps
+            return
+        elif x_overlap_size / x_size < 0.4:
+            # The thin overlap is in the x dimension
+            x_lin = np.linspace(1, 0, x_overlap_size)
+
+            # 1 in the first column,
+            # linearly decreasing to 0 in the last column
+            yx_grid = np.tile(x_lin, (y_overlap_size, 1))
+
+            # The decreasing image is the one for which the overlap
+            # is at the end of the x dimension
+            if tile_i.position[2] < tile_j.position[2]:
+                decreasing_image = i_overlap
+                increasing_image = j_overlap
+            else:
+                decreasing_image = j_overlap
+                increasing_image = i_overlap
+        else:
+            # The thin overlap is in the y dimension
+            y_lin = np.linspace(1, 0, y_overlap_size)
+
+            # 1 in the first row,
+            # linearly decreasing to 0 in the last row
+            yx_grid = np.tile(y_lin, (x_overlap_size, 1)).T
+
+            # The decreasing image is the one for which the overlap
+            # is at the end of the y dimension
+            if tile_i.position[1] < tile_j.position[1]:
+                decreasing_image = i_overlap
+                increasing_image = j_overlap
+            else:
+                decreasing_image = j_overlap
+                increasing_image = i_overlap
+
+        interp = (
+            np.multiply(
+                decreasing_image.compute(),
path: brainglobe_stitch/tile.py
user: login: IgorTatarnikov
body: Yea it's the dask way of loading the object into memory, including any queued computations. `dask` collects all manipulations for the data and executes only when the data is requested/computed. Very nice when working with subsections of large arrays.
created_at: 2025-04-29T14:37:12Z
author_association: MEMBER
diff_hunk: @@ -212,3 +212,79 @@ def replace_overlap_data(self, resolution_level: int, new_data: da.Array):
                 scaled_coordinates[idx][2] : scaled_coordinates[idx][2]
                 + scaled_size[2],
             ] = new_data
+
+    def linear_interpolation(
+        self, resolution_level: int, tile_shape: Tuple[int, ...]
+    ):
+        """
+        Perform linear interpolation on the overlapping data for both tiles.
+
+        Parameters
+        ----------
+        resolution_level: int
+            The resolution level to perform the interpolation on.
+        tile_shape: Tuple[int, ...]
+            The shape of the tile data at the given resolution level.
+        """
+        # Extract overlapping data from both tiles
+        i_overlap, j_overlap = self.extract_tile_overlaps(resolution_level)
+
+        x_overlap_size = self.size[resolution_level][2]
+        y_overlap_size = self.size[resolution_level][1]
+
+        z_size, y_size, x_size = tile_shape
+        tile_i, tile_j = self.tiles
+
+        if x_overlap_size / x_size < 0.4 and y_overlap_size / y_size < 0.4:
+            # Skip the small diagonal overlaps
path: brainglobe_stitch/tile.py
user: login: IgorTatarnikov
body: The diagonal overlap happens when tiles are only touching at one corner. Saw there are four tiles (1-4) arranged as below:
1		3
2		4

1--2 and 3--4 have a long overlap in the `x`, 1--3 and 2--4 have a long overlap in the `y`, and 1--4 have a small diagonal overlap, the "bottom-right" corner of 1 overlaps with the "top-left" corner of 4.

That overlap should be covered when the long overlaps are handled so I haven't found a direct need to address it.
created_at: 2025-04-29T14:44:00Z
author_association: MEMBER

PR comments:
user: login: codecov[bot]
created_at: 2025-04-14T12:52:52Z
body: ## [Codecov](https://app.codecov.io/gh/brainglobe/brainglobe-stitch/pull/40?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 96.30%. Comparing base [(`b0e25a5`)](https://app.codecov.io/gh/brainglobe/brainglobe-stitch/commit/b0e25a51fdda799ba54d6fd141e8a80712be9ae2?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe) to head [(`9c01b21`)](https://app.codecov.io/gh/brainglobe/brainglobe-stitch/commit/9c01b21051fd1268fb8bcd1608c8654d0b24920a?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe).
> Report is 1 commits behind head on main.

<details><summary>Additional details and impacted files</summary>


```diff
@@            Coverage Diff             @@
##             main      #40      +/-   ##
==========================================
+ Coverage   95.24%   96.30%   +1.06%     
==========================================
  Files           6        6              
  Lines         778      839      +61     
==========================================
+ Hits          741      808      +67     
+ Misses         37       31       -6     
```

</details>

[:umbrella: View full report in Codecov by Sentry](https://app.codecov.io/gh/brainglobe/brainglobe-stitch/pull/40?dropdown=coverage&src=pr&el=continue&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe).   
:loudspeaker: Have feedback on the report? [Share it here](https://about.codecov.io/codecov-pr-comment-feedback/?utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=brainglobe).

<details><summary> :rocket: New features to boost your workflow: </summary>

- :snowflake: [Test Analytics](https://docs.codecov.com/docs/test-analytics): Detect flaky tests, report on failures, and find test suite problems.
</details>
author_association: NONE
user: login: lauraporta
created_at: 2025-04-23T13:13:43Z
body: Having a BigStitcher error (might be something wrong on my config)... while I investigate on that I want to point out that I get a "stitching complete" log even if it failed.

Pasting the error just in case:
```python
2025-04-23 15:06:23.291 napari[32878:104203602] +[IMKClient subclass]: chose IMKClient_Modern
2025-04-23 15:06:23.291 napari[32878:104203602] +[IMKInputSession subclass]: chose IMKInputSession_Modern
2025-04-23 15:07:21.221 napari[32878:104203602] The class 'NSOpenPanel' overrides the method identifier.  This method is implemented by class 'NSWindow'
Tile positions not found. Writing tile config file.
Stitching /Users/laura/Downloads/big_test_data/test_data_bdv.xml
Loading TileConfiguration from /Users/laura/Downloads/big_test_data/test_data_bdv_tile_config.txt

---------------------------------------------------------------------------
ValueError                                Traceback (most recent call last)
File ~/mambaforge3/envs/bg-stitch/lib/python3.12/site-packages/superqt/utils/_qthreading.py:613, in create_worker.<locals>.reraise(e=ValueError('zero-size array to reduction operation maximum which has no identity'))
    612 def reraise(e):
--> 613     raise e
        e = ValueError('zero-size array to reduction operation maximum which has no identity')
File ~/mambaforge3/envs/bg-stitch/lib/python3.12/site-packages/superqt/utils/_qthreading.py:175, in WorkerBase.run(self=<napari._qt.qthreading.FunctionWorker object>)
    173     warnings.filterwarnings("always")
    174     warnings.showwarning = lambda *w: self.warned.emit(w)
--> 175     result = self.work()
        self = <napari._qt.qthreading.FunctionWorker object at 0x1558162a0>    176 if isinstance(result, Exception):
    177     if isinstance(result, RuntimeError):
    178         # The Worker object has likely been deleted.
    179         # A deleted wrapped C/C++ object may result in a runtime
    180         # error that will cause segfault if we try to do much other
    181         # than simply notify the user.

File ~/mambaforge3/envs/bg-stitch/lib/python3.12/site-packages/superqt/utils/_qthreading.py:354, in FunctionWorker.work(self=<napari._qt.qthreading.FunctionWorker object>)
    353 def work(self) -> _R:
--> 354     return self._func(*self._args, **self._kwargs)
        self._func = <bound method ImageMosaic.stitch of <brainglobe_stitch.image_mosaic.ImageMosaic object at 0x166b5f500>>
        self = <napari._qt.qthreading.FunctionWorker object at 0x1558162a0>
        self._args = (PosixPath('/Applications/Fiji.app'),)
        self._kwargs = {'resolution_level': 2, 'selected_channel': '561 nm'}
File ~/source/github/brainglobe/brainglobe-stitch/brainglobe_stitch/image_mosaic.py:353, in ImageMosaic.stitch(self=<brainglobe_stitch.image_mosaic.ImageMosaic object>, fiji_path=PosixPath('/Applications/Fiji.app'), resolution_level=2, selected_channel='561 nm')
    349 # Wait for the BigStitcher to write XML file
    350 # Need to find a better way to do this
    351 sleep(1)
--> 353 self.read_big_stitcher_transforms()
        self = <brainglobe_stitch.image_mosaic.ImageMosaic object at 0x166b5f500>    355 self.calculate_overlaps()

File ~/source/github/brainglobe/brainglobe-stitch/brainglobe_stitch/image_mosaic.py:363, in ImageMosaic.read_big_stitcher_transforms(self=<brainglobe_stitch.image_mosaic.ImageMosaic object>)
    358 """
    359 Read the BigStitcher transforms from the XML file and update the tile
    360 positions accordingly.
    361 """
    362 assert self.xml_path is not None
--> 363 stitched_translations = get_big_stitcher_transforms(self.xml_path)
        self.xml_path = PosixPath('/Users/laura/Downloads/big_test_data/test_data_bdv.xml')
        self = <brainglobe_stitch.image_mosaic.ImageMosaic object at 0x166b5f500>    364 for tile in self.tiles:
    365     stitched_position = stitched_translations[tile.id]

File ~/source/github/brainglobe/brainglobe-stitch/brainglobe_stitch/file_utils.py:305, in get_big_stitcher_transforms(xml_path=PosixPath('/Users/laura/Downloads/big_test_data/test_data_bdv.xml'))
    303 norm_grids = grids - grids.min(axis=0)
    304 # Calculate the maximum delta (from BigStitcher) for each dimension
--> 305 max_delta = np.absolute(deltas).max(axis=0)
        deltas = array([], shape=(0, 3), dtype=int32)
        np = <module 'numpy' from '/Users/laura/mambaforge3/envs/bg-stitch/lib/python3.12/site-packages/numpy/__init__.py'>
        np.absolute = <ufunc 'absolute'>    307 # Calculate the start and end coordinates for each tile such that the
    308 # first tile is at 0,0,0 and provide enough padding to account for the
    309 # transforms from BigStitcher
    310 translations = norm_grids + deltas + max_delta

File ~/mambaforge3/envs/bg-stitch/lib/python3.12/site-packages/numpy/_core/_methods.py:44, in _amax(a=array([], shape=(0, 3), dtype=int32), axis=0, out=None, keepdims=False, initial=<no value>, where=True)
     42 def _amax(a, axis=None, out=None, keepdims=False,
     43           initial=_NoValue, where=True):
---> 44     return umr_maximum(a, axis, None, out, keepdims, initial, where)
        umr_maximum = <built-in method reduce of numpy.ufunc object at 0x10124a740>
        a = array([], shape=(0, 3), dtype=int32)
        axis = 0
        out = None
        keepdims = False
        initial = <no value>
        where = True
ValueError: zero-size array to reduction operation maximum which has no identity
INFO: Stitching complete
```
author_association: MEMBER