From d6f5dc6f239258c96b3a7234730ce4b1910cefb6 Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Tue, 7 Jan 2025 14:18:26 +0000
Subject: [PATCH 01/13] Add brightness normalisation to image_mosaic.py

---
 brainglobe_stitch/image_mosaic.py | 119 +++++++++++++++++++++++++++++-
 1 file changed, 118 insertions(+), 1 deletion(-)

diff --git a/brainglobe_stitch/image_mosaic.py b/brainglobe_stitch/image_mosaic.py
index 99cdc79..7aad4e1 100644
--- a/brainglobe_stitch/image_mosaic.py
+++ b/brainglobe_stitch/image_mosaic.py
@@ -80,6 +80,11 @@ def __init__(self, directory: Path):
 
         self.load_mesospim_directory()
 
+        self.scale_factors: Optional[npt.NDArray] = None
+        self.intensity_adjusted: List[bool] = [False] * len(
+            self.tiles[0].resolution_pyramid
+        )
+
     def __del__(self):
         if self.h5_file:
             self.h5_file.close()
@@ -355,6 +360,25 @@ def read_big_stitcher_transforms(self) -> None:
             stitched_position = stitched_translations[tile.id]
             tile.position = stitched_position
 
+    def reload_resolution_pyramid_level(self, resolution_level: int) -> None:
+        """
+        Reload the data for a given resolution level.
+
+        Parameters
+        ----------
+        resolution_level: int
+            The resolution level to reload the data for.
+        """
+        if self.h5_file:
+            for tile in self.tiles:
+                tile.data_pyramid[resolution_level] = da.from_array(
+                    self.h5_file[
+                        f"t00000/{tile.name}/{resolution_level}/cells"
+                    ]
+                )
+
+            self.intensity_adjusted[resolution_level] = False
+
     def calculate_overlaps(self) -> None:
         """
         Calculate the overlaps between the tiles in the ImageMosaic.
@@ -397,9 +421,97 @@ def calculate_overlaps(self) -> None:
                     )
                     tile_i.neighbours.append(tile_j.id)
 
+    def normalise_intensity(
+        self, resolution_level: int = 0, percentile: int = 80
+    ) -> None:
+        """
+        Normalise the intensity of the image at a given resolution level.
+
+        Parameters
+        ----------
+        resolution_level: int
+            The resolution level to normalise the intensity at.
+        percentile: int
+            The percentile based on which the normalisation is done.
+        """
+        if self.intensity_adjusted[resolution_level]:
+            print("Intensity already adjusted at this resolution scale.")
+            return
+
+        if self.scale_factors is None:
+            # Calculate scale factors on at least resolution level 2
+            # The tiles are adjusted as the scale factors are calculated
+            self.calculate_intensity_scale_factors(
+                max(resolution_level, 2), percentile
+            )
+
+            if self.intensity_adjusted[resolution_level]:
+                return
+
+        assert self.scale_factors is not None
+
+        # Adjust the intensity of each tile based on the scale factors
+        for tile in self.tiles:
+            if self.scale_factors[tile.id] != 1.0:
+                tile.data_pyramid[resolution_level] = da.multiply(
+                    tile.data_pyramid[resolution_level],
+                    self.scale_factors[tile.id],
+                ).astype(tile.data_pyramid[resolution_level].dtype)
+
+        self.intensity_adjusted[resolution_level] = True
+
+    def calculate_intensity_scale_factors(
+        self, resolution_level: int, percentile: int
+    ):
+        """
+        Calculate the scale factors for normalising the intensity of the image.
+
+        Parameters
+        ----------
+        resolution_level: int
+            The resolution level to calculate the scale factors at.
+        percentile: int
+            The percentile based on which the normalisation is done.
+        """
+        num_tiles = len(self.tiles)
+        scale_factors = np.ones((num_tiles, num_tiles))
+
+        for tile_i in self.tiles:
+            # Iterate through the neighbours of each tile
+            print(f"Calculating scale factors for tile {tile_i.id}")
+            for neighbour_id in tile_i.neighbours:
+                tile_j = self.tiles[neighbour_id]
+                overlap = self.overlaps[(tile_i.id, tile_j.id)]
+
+                # Extract the overlapping data from both tiles
+                i_overlap, j_overlap = overlap.extract_tile_overlaps(
+                    resolution_level
+                )
+
+                # Calculate the percentile intensity of the overlapping data
+                median_i = da.percentile(i_overlap.ravel(), percentile)
+                median_j = da.percentile(j_overlap.ravel(), percentile)
+
+                curr_scale_factor = (median_i / median_j).compute()
+                scale_factors[tile_i.id][tile_j.id] = curr_scale_factor[0]
+
+                # Adjust the tile intensity based on the scale factor
+                tile_j.data_pyramid[resolution_level] = da.multiply(
+                    tile_j.data_pyramid[resolution_level],
+                    curr_scale_factor,
+                ).astype(tile_j.data_pyramid[resolution_level].dtype)
+
+        self.intensity_adjusted[resolution_level] = True
+        # Calculate the product of the scale factors for each tile's neighbours
+        # The product is the final scale factor for that tile
+        self.scale_factors = np.prod(scale_factors, axis=0)
+
+        return
+
     def fuse(
         self,
         output_file_name: str,
+        normalise_intensity: bool = False,
         downscale_factors: Tuple[int, int, int] = (1, 2, 2),
         chunk_shape: Tuple[int, int, int] = (128, 128, 128),
         pyramid_depth: int = 5,
@@ -413,7 +525,9 @@ def fuse(
         ----------
         output_file_name: str
             The name of the output file, suffix dictates the output file type.
-            Accepts .zarr and .h5 extensions.
+            Accepts .zarr and .h5 extensions.#
+        normalise_intensity: bool, default: False
+            Normalise the intensity differences 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)
@@ -435,6 +549,9 @@ def fuse(
             max([tile.position[2] for tile in self.tiles]) + x_size,
         )
 
+        if normalise_intensity:
+            self.normalise_intensity(0, 80)
+
         if output_path.suffix == ".zarr":
             self._fuse_to_zarr(
                 output_path,

From df6cadb249c2abfc94a674f696ff6f7993770add Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Tue, 7 Jan 2025 16:31:11 +0000
Subject: [PATCH 02/13] Add normalise intensity options to UI

---
 brainglobe_stitch/image_mosaic.py     |   6 +-
 brainglobe_stitch/stitching_widget.py | 129 +++++++++++++++++++++++---
 2 files changed, 116 insertions(+), 19 deletions(-)

diff --git a/brainglobe_stitch/image_mosaic.py b/brainglobe_stitch/image_mosaic.py
index 7aad4e1..d879dd6 100644
--- a/brainglobe_stitch/image_mosaic.py
+++ b/brainglobe_stitch/image_mosaic.py
@@ -510,7 +510,7 @@ def calculate_intensity_scale_factors(
 
     def fuse(
         self,
-        output_file_name: str,
+        output_path: Path,
         normalise_intensity: bool = False,
         downscale_factors: Tuple[int, int, int] = (1, 2, 2),
         chunk_shape: Tuple[int, int, int] = (128, 128, 128),
@@ -523,7 +523,7 @@ def fuse(
 
         Parameters
         ----------
-        output_file_name: str
+        output_path: Path
             The name of the output file, suffix dictates the output file type.
             Accepts .zarr and .h5 extensions.#
         normalise_intensity: bool, default: False
@@ -539,8 +539,6 @@ def fuse(
         compression_level: int, default: 6
             The compression level to use (only used for zarr).
         """
-        output_path = self.directory / output_file_name
-
         z_size, y_size, x_size = self.tiles[0].data_pyramid[0].shape
         # Calculate the shape of the fused image
         fused_image_shape: Tuple[int, int, int] = (
diff --git a/brainglobe_stitch/stitching_widget.py b/brainglobe_stitch/stitching_widget.py
index 64f4764..5fca2e4 100644
--- a/brainglobe_stitch/stitching_widget.py
+++ b/brainglobe_stitch/stitching_widget.py
@@ -14,6 +14,7 @@
 from napari.qt.threading import create_worker
 from napari.utils.notifications import show_info, show_warning
 from qtpy.QtWidgets import (
+    QCheckBox,
     QComboBox,
     QFileDialog,
     QFormLayout,
@@ -22,9 +23,11 @@
     QLineEdit,
     QProgressBar,
     QPushButton,
+    QSpinBox,
     QVBoxLayout,
     QWidget,
 )
+from superqt import QCollapsible
 
 from brainglobe_stitch.file_utils import (
     check_mesospim_directory,
@@ -208,12 +211,67 @@ def __init__(self, napari_viewer: Viewer):
         self.stitch_button.setEnabled(False)
         self.layout().addWidget(self.stitch_button)
 
-        self.fuse_option_widget = QWidget()
-        self.fuse_option_widget.setLayout(QFormLayout())
-        self.output_file_name_field = QLineEdit()
+        self.adjust_intensity_button = QPushButton("Adjust Intensity")
+        self.adjust_intensity_button.clicked.connect(
+            self._on_adjust_intensity_button_clicked
+        )
+        self.adjust_intensity_button.setEnabled(False)
+        self.layout().addWidget(self.adjust_intensity_button)
+
+        self.adjust_intensity_collapsible = QCollapsible(
+            "Intensity Adjustment Options"
+        )
+        self.adjust_intensity_menu = QWidget()
+        self.adjust_intensity_menu.setLayout(
+            QFormLayout(parent=self.adjust_intensity_menu)
+        )
+
+        self.percentile_field = QSpinBox(parent=self.adjust_intensity_menu)
+        self.percentile_field.setRange(0, 100)
+        self.percentile_field.setValue(80)
+        self.adjust_intensity_menu.layout().addRow(
+            "Percentile", self.percentile_field
+        )
+
+        self.adjust_intensity_collapsible.setContent(
+            self.adjust_intensity_menu
+        )
+
+        self.layout().addWidget(self.adjust_intensity_collapsible)
+        self.adjust_intensity_collapsible.collapse(animate=False)
+
+        self.fuse_option_widget = QWidget(parent=self)
+        self.fuse_option_widget.setLayout(
+            QFormLayout(parent=self.fuse_option_widget)
+        )
+        self.normalise_intensity_toggle = QCheckBox()
+
+        self.select_output_path = QWidget()
+        self.select_output_path.setLayout(QHBoxLayout())
+
+        self.select_output_path_text_field = QLineEdit()
+        self.select_output_path_text_field.setText(str(self.working_directory))
+        self.select_output_path.layout().addWidget(
+            self.select_output_path_text_field
+        )
+
+        self.open_file_dialog_output = QPushButton("Browse")
+        self.open_file_dialog_output.clicked.connect(
+            self._on_open_file_dialog_output_clicked
+        )
+        self.select_output_path.layout().addWidget(
+            self.open_file_dialog_output
+        )
+
+        self.fuse_option_widget.layout().addWidget(self.select_output_path)
+
         self.fuse_option_widget.layout().addRow(
-            "Output file name:", self.output_file_name_field
+            "Normalise intensity:", self.normalise_intensity_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)
 
@@ -294,6 +352,7 @@ def _on_add_tiles_button_clicked(self) -> None:
         )
         worker.yielded.connect(self._set_tile_layers)
         worker.start()
+        self.adjust_intensity_button.setEnabled(True)
 
     def _set_tile_layers(self, tile_layer: napari.layers.Image) -> None:
         """
@@ -370,12 +429,20 @@ def _on_stitch_button_clicked(self) -> None:
             display_info(self, "Warning", error_message)
             return
 
-        self.image_mosaic.stitch(
+        worker = create_worker(
+            self.image_mosaic.stitch,
             self.imagej_path,
             resolution_level=2,
             selected_channel=self.fuse_channel_dropdown.currentText(),
         )
 
+        self.fuse_button.setEnabled(False)
+        self.stitch_button.setEnabled(False)
+        self.adjust_intensity_button.setEnabled(False)
+        worker.finished.connect(self._on_stitch_finished)
+        worker.start()
+
+    def _on_stitch_finished(self):
         show_info("Stitching complete")
 
         napari_data = self.image_mosaic.data_for_napari(
@@ -384,9 +451,39 @@ def _on_stitch_button_clicked(self) -> None:
 
         self.update_tiles_from_mosaic(napari_data)
         self.fuse_button.setEnabled(True)
+        self.stitch_button.setEnabled(True)
+        self.adjust_intensity_button.setEnabled(True)
+
+    def _on_adjust_intensity_button_clicked(self):
+        self.image_mosaic.normalise_intensity(
+            resolution_level=self.resolution_to_display,
+            percentile=self.percentile_field.value(),
+        )
+
+        data_for_napari = self.image_mosaic.data_for_napari(
+            self.resolution_to_display
+        )
+
+        self.update_tiles_from_mosaic(data_for_napari)
+
+    def _on_open_file_dialog_output_clicked(self) -> None:
+        """
+        Open a file dialog to select the output file path.
+        """
+        output_file_str = QFileDialog.getSaveFileName(
+            self, "Select output file", str(self.working_directory)
+        )[0]
+        # A blank string is returned if the user cancels the dialog
+        if not output_file_str:
+            return
+
+        self.select_output_path_text_field.setText(output_file_str)
 
     def _on_fuse_button_clicked(self) -> None:
-        if not self.output_file_name_field.text():
+        if (
+            self.select_output_path_text_field.text()
+            == str(self.working_directory)
+        ) or (not self.select_output_path_text_field.text()):
             error_message = "Output file name not specified"
             show_warning(error_message)
             display_info(self, "Warning", error_message)
@@ -398,10 +495,10 @@ def _on_fuse_button_clicked(self) -> None:
             display_info(self, "Warning", error_message)
             return
 
-        path = self.working_directory / self.output_file_name_field.text()
+        output_path = Path(self.select_output_path_text_field.text())
         valid_extensions = [".zarr", ".h5"]
 
-        if path.suffix not in valid_extensions:
+        if output_path.suffix not in valid_extensions:
             error_message = (
                 f"Output file name should end with "
                 f"{', '.join(valid_extensions)}"
@@ -410,15 +507,16 @@ def _on_fuse_button_clicked(self) -> None:
             display_info(self, "Warning", error_message)
             return
 
-        if path.exists():
+        if output_path.exists():
             error_message = (
-                f"Output file {path} already exists. Replace existing file?"
+                f"Output file {output_path} already exists. "
+                f"Replace existing file?"
             )
             if display_warning(self, "Warning", error_message):
                 (
-                    shutil.rmtree(path)
-                    if path.suffix == ".zarr"
-                    else path.unlink()
+                    shutil.rmtree(output_path)
+                    if output_path.suffix == ".zarr"
+                    else output_path.unlink()
                 )
             else:
                 show_warning(
@@ -428,11 +526,12 @@ def _on_fuse_button_clicked(self) -> None:
                 return
 
         self.image_mosaic.fuse(
-            self.output_file_name_field.text(),
+            output_path,
+            normalise_intensity=self.normalise_intensity_toggle.isChecked(),
         )
 
         show_info("Fusing complete")
-        display_info(self, "Info", f"Fused image saved to {path}")
+        display_info(self, "Info", f"Fused image saved to {output_path}")
 
     def check_imagej_path(self) -> None:
         """

From 90ddd5f19c540b482c1e8837b10e6f13eef841af Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Tue, 7 Jan 2025 16:35:48 +0000
Subject: [PATCH 03/13] WIP tests for image_mosaic

---
 tests/test_unit/test_image_mosaic.py | 133 +++++++++++++++++++++++++++
 1 file changed, 133 insertions(+)

diff --git a/tests/test_unit/test_image_mosaic.py b/tests/test_unit/test_image_mosaic.py
index 5135f9d..0d9df43 100644
--- a/tests/test_unit/test_image_mosaic.py
+++ b/tests/test_unit/test_image_mosaic.py
@@ -109,6 +109,139 @@ def test_data_for_napari(image_mosaic, test_constants):
         assert (tile_data[1] == expected_pos).all()
 
 
+@pytest.mark.parametrize(
+    "resolution_level",
+    [0, 1],
+)
+def test_normalise_intensity(
+    mocker, test_constants, image_mosaic, resolution_level
+):
+    def force_set_scale_factors(*args, **kwargs):
+        image_mosaic.scale_factors = test_constants[
+            "EXPECTED_INTENSITY_FACTORS"
+        ]
+        image_mosaic.intensity_adjusted[args[0]] = True
+
+    mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic.calculate_intensity_scale_factors",
+        side_effect=force_set_scale_factors,
+    )
+
+    image_mosaic.reload_resolution_pyramid_level(resolution_level)
+    assert not image_mosaic.intensity_adjusted[resolution_level]
+    image_mosaic.scale_factors = None
+
+    image_mosaic.normalise_intensity(resolution_level)
+    assert image_mosaic.intensity_adjusted[resolution_level]
+    assert len(image_mosaic.scale_factors) == test_constants["NUM_TILES"]
+
+    for i in range(test_constants["NUM_TILES"]):
+        # Check that there each tile has the correct number of pending tasks
+        # in the dask graph
+        # Expect to have 4: 2 for loading the data, 2 for scaling the data
+        # Since the resolution levels are less than 2, the scaling factors are
+        # calculated on a different resolution level and then applied to the
+        # current resolution level
+        image_mosaic.tiles[i].data_pyramid[resolution_level].dask
+        if test_constants["EXPECTED_INTENSITY_FACTORS"][i] != 1.0:
+            assert (
+                len(
+                    image_mosaic.tiles[i]
+                    .data_pyramid[resolution_level]
+                    .dask.layers
+                )
+                == 4
+            )
+
+
+@pytest.mark.parametrize(
+    "resolution_level",
+    [2, 3, 4],
+)
+def test_normalise_intensity_done_with_factors(
+    mocker, image_mosaic, resolution_level, test_constants
+):
+    def force_set_scale_factors(*args, **kwargs):
+        image_mosaic.scale_factors = test_constants[
+            "EXPECTED_INTENSITY_FACTORS"
+        ]
+        image_mosaic.intensity_adjusted[args[0]] = True
+
+    mock_calc_intensity_factors = mocker.patch(
+        "brainglobe_stitch.image_mosaic.ImageMosaic.calculate_intensity_scale_factors",
+        side_effect=force_set_scale_factors,
+    )
+
+    image_mosaic.reload_resolution_pyramid_level(resolution_level)
+    assert not image_mosaic.intensity_adjusted[resolution_level]
+    image_mosaic.scale_factors = None
+
+    image_mosaic.normalise_intensity(resolution_level)
+    assert image_mosaic.intensity_adjusted[resolution_level]
+    assert len(image_mosaic.scale_factors) == test_constants["NUM_TILES"]
+
+    mock_calc_intensity_factors.assert_called_once_with(resolution_level, 80)
+
+    # Check that no scale adjustment calculations are queued for the tiles
+    # at the specified resolution level as the correction factors were
+    # calculated based on this resolution level,
+    # therefore no calculations queued.
+    for i in range(test_constants["NUM_TILES"]):
+        assert (
+            len(
+                image_mosaic.tiles[i]
+                .data_pyramid[resolution_level]
+                .dask.layers
+            )
+            == 2
+        )
+
+
+@pytest.mark.parametrize(
+    "resolution_level",
+    [0, 1, 2, 3, 4],
+)
+def test_normalise_intensity_already_adjusted(
+    image_mosaic, resolution_level, test_constants
+):
+    image_mosaic.reload_resolution_pyramid_level(resolution_level)
+    image_mosaic.intensity_adjusted[resolution_level] = True
+    image_mosaic.normalise_intensity(resolution_level)
+
+    assert image_mosaic.intensity_adjusted[resolution_level]
+
+    # Check that no scale adjustment calculations are queued for the tiles
+    # at the specified resolution level
+    for i in range(test_constants["NUM_TILES"]):
+        assert (
+            len(
+                image_mosaic.tiles[i]
+                .data_pyramid[resolution_level]
+                .dask.layers
+            )
+            == 2
+        )
+
+
+def test_calculate_intensity_scale_factors(image_mosaic, test_constants):
+    resolution_level = 2
+    percentile = 50
+    image_mosaic.reload_resolution_pyramid_level(resolution_level)
+    image_mosaic.scale_factors = None
+
+    image_mosaic.calculate_intensity_scale_factors(
+        resolution_level, percentile
+    )
+
+    assert len(image_mosaic.scale_factors) == test_constants["NUM_TILES"]
+    # Check the relative tolerance
+    assert np.allclose(
+        image_mosaic.scale_factors,
+        test_constants["EXPECTED_INTENSITY_FACTORS"],
+        rtol=1e-2,
+    )
+
+
 def test_fuse_invalid_file_type(image_mosaic):
     with pytest.raises(ValueError):
         image_mosaic.fuse("fused.txt")

From 1e8e7c94793e1a2fc98f37d69b32e29c647430fc Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Tue, 7 Jan 2025 16:37:09 +0000
Subject: [PATCH 04/13] WIP tests for intensity normalisation

---
 tests/test_unit/conftest.py | 10 ++++++++++
 1 file changed, 10 insertions(+)

diff --git a/tests/test_unit/conftest.py b/tests/test_unit/conftest.py
index 96992c2..b4b411c 100644
--- a/tests/test_unit/conftest.py
+++ b/tests/test_unit/conftest.py
@@ -211,6 +211,16 @@ def test_constants(imagej_path):
             [6, 7, 118],
             [5, 123, 116],
         ],
+        "EXPECTED_INTENSITY_FACTORS": [
+            1.00000,
+            0.99636,
+            1.00000,
+            1.04878,
+            0.58846,
+            0.55362,
+            1.06679,
+            1.08642,
+        ],
         "EXPECTED_FUSED_SHAPE": (113, 251, 246),
         "CHANNELS": ["561 nm", "647 nm"],
         "PIXEL_SIZE_XY": 4.08,

From 8c0041379b306c44d1e5c2dd0c3574091650bb70 Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Tue, 7 Jan 2025 16:54:44 +0000
Subject: [PATCH 05/13] Update tests

---
 brainglobe_stitch/image_mosaic.py    |  2 +-
 tests/test_unit/test_image_mosaic.py | 37 +++++++++++++++++-----------
 2 files changed, 24 insertions(+), 15 deletions(-)

diff --git a/brainglobe_stitch/image_mosaic.py b/brainglobe_stitch/image_mosaic.py
index d879dd6..80da55d 100644
--- a/brainglobe_stitch/image_mosaic.py
+++ b/brainglobe_stitch/image_mosaic.py
@@ -701,7 +701,7 @@ def _fuse_to_bdv_h5(
         output_path: Path,
         fused_image_shape: Tuple[int, int, int],
         downscale_factors: Tuple[int, int, int],
-        pyramid_depth,
+        pyramid_depth: int,
         chunk_shape: Tuple[int, int, int],
     ) -> None:
         """
diff --git a/tests/test_unit/test_image_mosaic.py b/tests/test_unit/test_image_mosaic.py
index 0d9df43..02e7e20 100644
--- a/tests/test_unit/test_image_mosaic.py
+++ b/tests/test_unit/test_image_mosaic.py
@@ -142,7 +142,6 @@ def force_set_scale_factors(*args, **kwargs):
         # Since the resolution levels are less than 2, the scaling factors are
         # calculated on a different resolution level and then applied to the
         # current resolution level
-        image_mosaic.tiles[i].data_pyramid[resolution_level].dask
         if test_constants["EXPECTED_INTENSITY_FACTORS"][i] != 1.0:
             assert (
                 len(
@@ -243,19 +242,20 @@ def test_calculate_intensity_scale_factors(image_mosaic, test_constants):
 
 
 def test_fuse_invalid_file_type(image_mosaic):
+    output_file = image_mosaic.xml_path.parent / "fused.txt"
     with pytest.raises(ValueError):
-        image_mosaic.fuse("fused.txt")
+        image_mosaic.fuse(output_file)
 
 
 def test_fuse_bdv_h5_defaults(image_mosaic, mocker, test_constants):
     mock_fuse_function = mocker.patch(
         "brainglobe_stitch.image_mosaic.ImageMosaic._fuse_to_bdv_h5",
     )
-    file_name = "fused.h5"
+    file_path = image_mosaic.xml_path.parent / "fused.h5"
 
-    image_mosaic.fuse(file_name)
+    image_mosaic.fuse(file_path)
     mock_fuse_function.assert_called_once_with(
-        image_mosaic.xml_path.parent / file_name,
+        file_path,
         test_constants["EXPECTED_FUSED_SHAPE"],
         test_constants["DEFAULT_DOWNSAMPLE_FACTORS"],
         test_constants["DEFAULT_PYRAMID_DEPTH"],
@@ -278,11 +278,18 @@ def test_fuse_bdv_h5_custom(
     mock_fuse_function = mocker.patch(
         "brainglobe_stitch.image_mosaic.ImageMosaic._fuse_to_bdv_h5",
     )
-    file_name = "fused.h5"
+    file_path = image_mosaic.xml_path.parent / "fused.h5"
 
-    image_mosaic.fuse(file_name, downscale_factors, chunk_shape, pyramid_depth)
+    normalise_intensity = False
+    image_mosaic.fuse(
+        file_path,
+        normalise_intensity,
+        downscale_factors,
+        chunk_shape,
+        pyramid_depth,
+    )
     mock_fuse_function.assert_called_once_with(
-        image_mosaic.xml_path.parent / file_name,
+        file_path,
         test_constants["EXPECTED_FUSED_SHAPE"],
         downscale_factors,
         pyramid_depth,
@@ -291,16 +298,16 @@ def test_fuse_bdv_h5_custom(
 
 
 def test_fuse_zarr_file(image_mosaic, mocker, test_constants):
-    file_name = "fused.zarr"
+    file_path = image_mosaic.xml_path.parent / "fused.zarr"
 
     mock_fuse_to_zarr = mocker.patch(
         "brainglobe_stitch.image_mosaic.ImageMosaic._fuse_to_zarr"
     )
 
-    image_mosaic.fuse(file_name)
+    image_mosaic.fuse(file_path)
 
     mock_fuse_to_zarr.assert_called_once_with(
-        image_mosaic.xml_path.parent / file_name,
+        file_path,
         test_constants["EXPECTED_FUSED_SHAPE"],
         test_constants["DEFAULT_DOWNSAMPLE_FACTORS"],
         test_constants["DEFAULT_PYRAMID_DEPTH"],
@@ -331,10 +338,12 @@ def test_fuse_bdv_zarr_custom(
     mock_fuse_function = mocker.patch(
         "brainglobe_stitch.image_mosaic.ImageMosaic._fuse_to_zarr",
     )
-    file_name = "fused.zarr"
+    file_path = image_mosaic.xml_path.parent / "fused.zarr"
 
+    normalise_intensity = False
     image_mosaic.fuse(
-        file_name,
+        file_path,
+        normalise_intensity,
         downscale_factors,
         chunk_shape,
         pyramid_depth,
@@ -342,7 +351,7 @@ def test_fuse_bdv_zarr_custom(
         compression_level,
     )
     mock_fuse_function.assert_called_once_with(
-        image_mosaic.xml_path.parent / file_name,
+        file_path,
         test_constants["EXPECTED_FUSED_SHAPE"],
         downscale_factors,
         pyramid_depth,

From a549a6a0b647df19edcca13510310f2c9e5e9f02 Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Mon, 13 Jan 2025 16:22:27 +0000
Subject: [PATCH 06/13] Fixed failing tests

---
 tests/test_unit/test_stitching_widget.py | 17 ++++++++++-------
 1 file changed, 10 insertions(+), 7 deletions(-)

diff --git a/tests/test_unit/test_stitching_widget.py b/tests/test_unit/test_stitching_widget.py
index a475ca7..2a66e21 100644
--- a/tests/test_unit/test_stitching_widget.py
+++ b/tests/test_unit/test_stitching_widget.py
@@ -376,15 +376,15 @@ def test_on_stitch_button_clicked(
     stitching_widget = stitching_widget_with_mosaic
     stitching_widget.imagej_path = test_constants["MOCK_IMAGEJ_EXEC_PATH"]
 
-    mock_stitch_function = mocker.patch(
-        "brainglobe_stitch.stitching_widget.ImageMosaic.stitch",
+    mock_create_worker = mocker.patch(
+        "brainglobe_stitch.stitching_widget.create_worker",
         autospec=True,
     )
 
     stitching_widget._on_stitch_button_clicked()
 
-    mock_stitch_function.assert_called_once_with(
-        stitching_widget.image_mosaic,
+    mock_create_worker.assert_called_once_with(
+        stitching_widget_with_mosaic.image_mosaic.stitch,
         stitching_widget.imagej_path,
         resolution_level=2,
         selected_channel="",
@@ -484,11 +484,14 @@ def test_on_fuse_button_clicked(
         autospec=True,
     )
 
-    stitching_widget.output_file_name_field.setText(file_name)
+    output_path = stitching_widget.working_directory / file_name
+    stitching_widget.select_output_path_text_field.setText(str(output_path))
 
     stitching_widget._on_fuse_button_clicked()
 
-    mock_fuse.assert_called_once_with(stitching_widget.image_mosaic, file_name)
+    mock_fuse.assert_called_once_with(
+        stitching_widget.image_mosaic, output_path, normalise_intensity=False
+    )
     mock_display_info.assert_called_once_with(
         stitching_widget,
         "Info",
@@ -527,7 +530,7 @@ def test_on_fuse_button_clicked_wrong_suffix(
 ):
     stitching_widget = stitching_widget_with_mosaic
 
-    stitching_widget.output_file_name_field.setText("fused_image.tif")
+    stitching_widget.select_output_path_text_field.setText("fused_image.tif")
     error_message = "Output file name should end with .zarr, .h5"
 
     mock_show_warning = mocker.patch(

From 62c2d49a9be5597a745f9dc171413bb776189e98 Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Tue, 14 Jan 2025 14:47:08 +0000
Subject: [PATCH 07/13] Adds test for fuse call with normalise_intensity

---
 tests/test_unit/test_image_mosaic.py | 25 +++++++++++++++++++++++++
 1 file changed, 25 insertions(+)

diff --git a/tests/test_unit/test_image_mosaic.py b/tests/test_unit/test_image_mosaic.py
index 02e7e20..33216dd 100644
--- a/tests/test_unit/test_image_mosaic.py
+++ b/tests/test_unit/test_image_mosaic.py
@@ -263,6 +263,31 @@ def test_fuse_bdv_h5_defaults(image_mosaic, mocker, test_constants):
     )
 
 
+def test_fuse_zarr_normalise_intensity(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"
+    )
+
+    image_mosaic.normalise_intensity = mock_normalise_intensity
+    image_mosaic.fuse(file_path, normalise_intensity=True)
+
+    mock_normalise_intensity.assert_called_once_with(0, 80)
+    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)],

From 9fd1d7333f6fcd7273789e7f1fb3689dab902a15 Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Tue, 14 Jan 2025 15:03:45 +0000
Subject: [PATCH 08/13] Adds test for stitch button click no imagej path

---
 tests/test_unit/test_stitching_widget.py | 24 ++++++++++++++++++++++++
 1 file changed, 24 insertions(+)

diff --git a/tests/test_unit/test_stitching_widget.py b/tests/test_unit/test_stitching_widget.py
index 2a66e21..60b364d 100644
--- a/tests/test_unit/test_stitching_widget.py
+++ b/tests/test_unit/test_stitching_widget.py
@@ -391,6 +391,30 @@ def test_on_stitch_button_clicked(
     )
 
 
+def test_on_stitch_button_clicked_no_imagej(
+    stitching_widget, test_constants, mocker
+):
+    """
+    Tests that the _on_stitch_button_clicked method correctly shows a warning
+    message to the user when the imageJ path is not set.
+    """
+    mock_show_warning = mocker.patch(
+        "brainglobe_stitch.stitching_widget.show_warning"
+    )
+    mock_display_info = mocker.patch(
+        "brainglobe_stitch.stitching_widget.display_info",
+        autospec=True,
+    )
+    error_message = "Select the ImageJ path prior to stitching"
+
+    stitching_widget._on_stitch_button_clicked()
+
+    mock_show_warning.assert_called_once_with(error_message)
+    mock_display_info.assert_called_once_with(
+        stitching_widget, "Warning", error_message
+    )
+
+
 def test_check_imagej_path_valid(stitching_widget):
     """
     Creates a mock imageJ file in the home directory and sets it as the

From 4ba9b2200a2abd9c1233ca221db49fabea3d39c1 Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Tue, 14 Jan 2025 15:05:05 +0000
Subject: [PATCH 09/13] Add image mosaic to the no imagej test

---
 tests/test_unit/test_stitching_widget.py | 6 +++---
 1 file changed, 3 insertions(+), 3 deletions(-)

diff --git a/tests/test_unit/test_stitching_widget.py b/tests/test_unit/test_stitching_widget.py
index 60b364d..642157e 100644
--- a/tests/test_unit/test_stitching_widget.py
+++ b/tests/test_unit/test_stitching_widget.py
@@ -392,7 +392,7 @@ def test_on_stitch_button_clicked(
 
 
 def test_on_stitch_button_clicked_no_imagej(
-    stitching_widget, test_constants, mocker
+    stitching_widget_with_mosaic, test_constants, mocker
 ):
     """
     Tests that the _on_stitch_button_clicked method correctly shows a warning
@@ -407,11 +407,11 @@ def test_on_stitch_button_clicked_no_imagej(
     )
     error_message = "Select the ImageJ path prior to stitching"
 
-    stitching_widget._on_stitch_button_clicked()
+    stitching_widget_with_mosaic._on_stitch_button_clicked()
 
     mock_show_warning.assert_called_once_with(error_message)
     mock_display_info.assert_called_once_with(
-        stitching_widget, "Warning", error_message
+        stitching_widget_with_mosaic, "Warning", error_message
     )
 
 

From 48ab3d95770a6facfafdac3607abcc79c7121364 Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Tue, 14 Jan 2025 15:09:00 +0000
Subject: [PATCH 10/13] Add tests for no image_mosaic

---
 tests/test_unit/test_stitching_widget.py | 24 ++++++++++++++++++++++++
 1 file changed, 24 insertions(+)

diff --git a/tests/test_unit/test_stitching_widget.py b/tests/test_unit/test_stitching_widget.py
index 642157e..15d5a16 100644
--- a/tests/test_unit/test_stitching_widget.py
+++ b/tests/test_unit/test_stitching_widget.py
@@ -391,6 +391,30 @@ def test_on_stitch_button_clicked(
     )
 
 
+def test_on_stitch_button_clicked_no_image_mosaic(
+    stitching_widget, test_constants, mocker
+):
+    """
+    Tests that the _on_stitch_button_clicked method correctly shows a warning
+    message to the user when the ImageMosaic object is not set.
+    """
+    mock_show_warning = mocker.patch(
+        "brainglobe_stitch.stitching_widget.show_warning"
+    )
+    mock_display_info = mocker.patch(
+        "brainglobe_stitch.stitching_widget.display_info",
+        autospec=True,
+    )
+    error_message = "Open a mesoSPIM directory prior to stitching"
+
+    stitching_widget._on_stitch_button_clicked()
+
+    mock_show_warning.assert_called_once_with(error_message)
+    mock_display_info.assert_called_once_with(
+        stitching_widget, "Warning", error_message
+    )
+
+
 def test_on_stitch_button_clicked_no_imagej(
     stitching_widget_with_mosaic, test_constants, mocker
 ):

From 49eddf8216e6e0c527e6daba22c5830bd78dbc02 Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Tue, 14 Jan 2025 15:26:03 +0000
Subject: [PATCH 11/13] Add test for stitch_finished

---
 tests/test_unit/test_stitching_widget.py | 20 ++++++++++++++++++++
 1 file changed, 20 insertions(+)

diff --git a/tests/test_unit/test_stitching_widget.py b/tests/test_unit/test_stitching_widget.py
index 15d5a16..12b5518 100644
--- a/tests/test_unit/test_stitching_widget.py
+++ b/tests/test_unit/test_stitching_widget.py
@@ -439,6 +439,26 @@ def test_on_stitch_button_clicked_no_imagej(
     )
 
 
+def test_on_stitch_finished(stitching_widget_with_mosaic, mocker):
+    """
+    Tests that the _on_stitch_finished method correctly sets the image_mosaic
+    attribute of the StitchingWidget to None and enables the create_pyramid
+    button.
+    """
+    mock_show_info = mocker.patch(
+        "brainglobe_stitch.stitching_widget.show_info"
+    )
+    stitching_widget = stitching_widget_with_mosaic
+
+    stitching_widget._on_stitch_finished()
+
+    mock_show_info.assert_called_once_with("Stitching complete")
+
+    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()
+
+
 def test_check_imagej_path_valid(stitching_widget):
     """
     Creates a mock imageJ file in the home directory and sets it as the

From 9319a927520e94cd7dda5ba7b79c965b7af9cace Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Tue, 14 Jan 2025 15:34:20 +0000
Subject: [PATCH 12/13] Adds test for clicking adjust_intensity_button

---
 tests/test_unit/test_stitching_widget.py | 22 ++++++++++++++++++++++
 1 file changed, 22 insertions(+)

diff --git a/tests/test_unit/test_stitching_widget.py b/tests/test_unit/test_stitching_widget.py
index 12b5518..b8f2c66 100644
--- a/tests/test_unit/test_stitching_widget.py
+++ b/tests/test_unit/test_stitching_widget.py
@@ -459,6 +459,28 @@ def test_on_stitch_finished(stitching_widget_with_mosaic, mocker):
     assert stitching_widget_with_mosaic.adjust_intensity_button.isEnabled()
 
 
+def tests_on_adjust_intensity_button_clicked(
+    stitching_widget_with_mosaic, mocker
+):
+    """
+    Tests that the _on_adjust_intensity_button_clicked method correctly calls
+    the adjust_intensity method of the ImageMosaic object with the correct
+    arguments.
+    """
+    mock_normalise_intensity = mocker.patch(
+        "brainglobe_stitch.stitching_widget.ImageMosaic.normalise_intensity",
+        autospec=True,
+    )
+
+    stitching_widget_with_mosaic._on_adjust_intensity_button_clicked()
+
+    mock_normalise_intensity.assert_called_once_with(
+        stitching_widget_with_mosaic.image_mosaic,
+        resolution_level=3,
+        percentile=80,
+    )
+
+
 def test_check_imagej_path_valid(stitching_widget):
     """
     Creates a mock imageJ file in the home directory and sets it as the

From 87b9b12f45333a3482f43dad9a5bef5c67cb5e9b Mon Sep 17 00:00:00 2001
From: Igor Tatarnikov <igor.tatarnikov@gmail.com>
Date: Tue, 14 Jan 2025 16:04:48 +0000
Subject: [PATCH 13/13] Adds tests for _on_open_dialog_output_clicked

---
 tests/test_unit/test_stitching_widget.py | 36 ++++++++++++++++++++++++
 1 file changed, 36 insertions(+)

diff --git a/tests/test_unit/test_stitching_widget.py b/tests/test_unit/test_stitching_widget.py
index b8f2c66..c106d86 100644
--- a/tests/test_unit/test_stitching_widget.py
+++ b/tests/test_unit/test_stitching_widget.py
@@ -559,6 +559,42 @@ def test_update_tiles_from_mosaic(
         assert (tile.translate == test_data[1]).all()
 
 
+def test_on_open_dialog_output_clicked(stitching_widget, mocker):
+    """
+    Test that the on_open_dialog_output_clicked method.
+    The directory is provided by mocking the return of the
+    QFileDialog.getExistingDirectory method.
+    """
+    test_dir = str(Path.home() / "test_dir")
+    mocker.patch(
+        "brainglobe_stitch.stitching_widget.QFileDialog.getSaveFileName",
+        return_value=[test_dir],
+    )
+
+    stitching_widget._on_open_file_dialog_output_clicked()
+
+    assert stitching_widget.select_output_path_text_field.text() == test_dir
+
+
+def test_on_open_dialog_output_clicked_cancelled(stitching_widget, mocker):
+    """
+    Mocks the QFileDialog.getExistingDirectory method to return an empty string
+    to mimic the user cancelling the file dialog.
+    The select_output_path_text_field should retain its original value.
+    """
+    original_value = stitching_widget.select_output_path_text_field.text()
+    mocker.patch(
+        "brainglobe_stitch.stitching_widget.QFileDialog.getExistingDirectory",
+        return_value="",
+    )
+
+    stitching_widget._on_open_file_dialog_clicked()
+
+    assert (
+        stitching_widget.select_output_path_text_field.text() == original_value
+    )
+
+
 @pytest.mark.parametrize("file_name", ["fused_image.h5", "fused_image.zarr"])
 def test_on_fuse_button_clicked(
     stitching_widget_with_mosaic, mocker, file_name