Repository: fmi-faim/faim-ipa (language: python)

Issue:
Use lower-case module names everywhere
We currently have CamelCase class names inside CamelCase module files with the same name, and expose classes on module level by importing them in `__init__.py`, e.g.:

https://github.com/fmi-faim/faim-ipa/blob/f4191359d3f35144190188e59dcdb90eebcefb78/src/faim_ipa/hcs/cellvoyager/__init__.py#L1-L2
https://github.com/fmi-faim/faim-ipa/blob/f4191359d3f35144190188e59dcdb90eebcefb78/src/faim_ipa/hcs/imagexpress/__init__.py#L1-L5

This is problematic, as changing the order of imports such as:

```patch
from .ImageXpressPlateAcquisition import ImageXpressPlateAcquisition  # noqa: F401
from .ImageXpressWellAcquisition import ImageXpressWellAcquisition  # noqa: F401
+++ from .MixedAcquisition import MixedAcquisition  # noqa: F401
from .SinglePlaneAcquisition import SinglePlaneAcquisition  # noqa: F401
from .StackAcquisition import StackAcquisition  # noqa: F401
--- from .MixedAcquisition import MixedAcquisition  # noqa: F401
```

leads to errors like this:

```
ERROR tests/hcs/imagexpress/test_ImageXpress.py - TypeError: module() takes at most 2 arguments (3 given)
```

.. as we mix modules and classes with the same name.

---

We should instead use all-lower-case names for the module files, and `CamelCase` exclusively for the class names.
What do you think, @tibuch?




Discussion / hints:
I don't know what the best practice is here.

I think I had the files in CamelCase because I wanted to name them like the class inside. Maybe this is a java thing which I tried to replicate. 

But definitely in favor of fixing this.

Fix patch:
diff --git a/.gitignore b/.gitignore
index 85295861..8923d42d 100644
--- a/.gitignore
+++ b/.gitignore
@@ -91,3 +91,4 @@ mobie-example-project/
 
 # scratchpad
 scratchpad/
+.pixi
diff --git a/pyproject.toml b/pyproject.toml
index c3631b4a..cdbada1e 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -7,17 +7,16 @@ name = "faim-ipa"
 dynamic = ["version"]
 description = 'Tools used at FMI-FAIM for Image Processing and Analysis.'
 readme = "README.md"
-requires-python = ">=3.9"
+requires-python = ">=3.10"
 license = "BSD-3-Clause"
 keywords = []
 authors = [
-  { name = "Tim-Oliver Buchholz", email = "tim-oliver.buchholz@fmi.ch" },
-  { name = "Jan Eglinger", email = "jan.eglinger@fmi.ch" },
+  {name = "Tim-Oliver Buchholz", email = "tim-oliver.buchholz@fmi.ch"},
+  {name = "Jan Eglinger", email = "jan.eglinger@fmi.ch"},
 ]
 classifiers = [
   "Development Status :: 4 - Beta",
   "Programming Language :: Python",
-  "Programming Language :: Python :: 3.9",
   "Programming Language :: Python :: 3.10",
   "Programming Language :: Python :: 3.11",
   "Programming Language :: Python :: 3.12",
@@ -25,15 +24,16 @@ classifiers = [
   "Programming Language :: Python :: Implementation :: PyPy",
 ]
 dependencies = [
-  "pydantic",
+  "defusedxml",
   "distributed",
-  "numpy<2",
   "matplotlib",
-  "scikit-image",
-  "pandas",
+  "numpy<2",
   "ome-zarr",
+  "pandas",
+  "pint",
+  "pydantic>=2",
+  "scikit-image",
   "tqdm",
-  "pint"
 ]
 
 [project.urls]
@@ -43,14 +43,46 @@ Homepage = "https://github.com/fmi-faim/faim-ipa.git"
 "Source Code" = "https://github.com/fmi-faim/faim-ipa"
 "User Support" = "https://github.com/fmi-faim/faim-ipa/issues"
 
-[tool.hatch.version]
-source = "vcs"
+[tool.coverage.paths]
+faim_ipa = ["src/faim_ipa", "*/faim-ipa/src/faim_ipa"]
+tests = ["tests", "*/faim-ipa/tests"]
+
+[tool.coverage.report]
+show_missing = true
+exclude_lines = [
+  "no cov",
+  "if __name__ == .__main__.:",
+  "if TYPE_CHECKING:",
+]
+
+[tool.coverage.run]
+source_pkgs = ["faim_ipa", "tests"]
+branch = true
+parallel = true
+omit = [
+  "src/faim_ipa/__about__.py",
+]
+
+[tool.hatch.build.targets.sdist]
+exclude = [
+  "/examples",
+  "/resources",
+  "*/__pycache__",
+  "/tests",
+  "/scratchpad",
+  "/.github",
+  ".pre-commit-config.yaml",
+]
+
+[[tool.hatch.envs.all.matrix]]
+python = ["3.10", "3.11", "3.12"]
 
 [tool.hatch.envs.default]
 dependencies = [
   "coverage[toml]>=6.5",
   "pytest",
 ]
+
 [tool.hatch.envs.default.scripts]
 test = "pytest {args:tests}"
 test-cov = "coverage run -m pytest {args:tests}"
@@ -73,49 +105,31 @@ cov = [
   "cov-report",
 ]
 
-[[tool.hatch.envs.all.matrix]]
-python = ["3.9", "3.10", "3.11", "3.12"]
-
 [tool.hatch.envs.types]
 dependencies = [
   "mypy>=1.0.0",
 ]
+
 [tool.hatch.envs.types.scripts]
 check = "mypy --install-types --non-interactive {args:src/faim_ipa tests}"
 
 [tool.hatch.metadata]
 allow-direct-references = true
 
-[tool.hatch.build.targets.sdist]
-exclude = [
-   "/examples",
-  "/resources",
-  "*/__pycache__",
-  "/tests",
-  "/scratchpad",
-  "/.github",
-  ".pre-commit-config.yaml",
-]
+[tool.hatch.version]
+source = "vcs"
 
-[tool.coverage.run]
-source_pkgs = ["faim_ipa", "tests"]
-branch = true
-parallel = true
-omit = [
-  "src/faim_ipa/__about__.py",
+[tool.pytest.ini_options]
+addopts = [
+    "--import-mode=importlib",
 ]
 
-[tool.coverage.paths]
-faim_ipa = ["src/faim_ipa", "*/faim-ipa/src/faim_ipa"]
-tests = ["tests", "*/faim-ipa/tests"]
+[tool.ruff.lint]
+ignore = ["FA100", "S101"]
 
-[tool.coverage.report]
-show_missing = true
-exclude_lines = [
-  "no cov",
-  "if __name__ == .__main__.:",
-  "if TYPE_CHECKING:",
-]
+[tool.ruff.lint.extend-per-file-ignores]
+"**/tests/*" = ["F811", "INP001", "SLF001"]
+"**/examples/*" = ["INP001"]
 
 [tool.setuptools_scm]
 write_to = "src/faim_ipa/_version.py"
diff --git a/src/faim_ipa/alignment/__init__.py b/src/faim_ipa/alignment/__init__.py
index 453dfe1c..702439fb 100644
--- a/src/faim_ipa/alignment/__init__.py
+++ b/src/faim_ipa/alignment/__init__.py
@@ -1,1 +1,1 @@
-from .alignment import GridAlignment, StageAlignment  # noqa: F401
+from faim_ipa.alignment.alignment import GridAlignment, StageAlignment  # noqa: F401
diff --git a/src/faim_ipa/alignment/alignment.py b/src/faim_ipa/alignment/alignment.py
index b1f5b2fe..3a6f8475 100644
--- a/src/faim_ipa/alignment/alignment.py
+++ b/src/faim_ipa/alignment/alignment.py
@@ -1,9 +1,10 @@
-from abc import ABC
+from abc import ABC, abstractmethod
 from copy import copy
 
 import numpy as np
 
-from faim_ipa.stitching import Tile, stitching_utils
+from faim_ipa.stitching import stitching_utils
+from faim_ipa.stitching.tile import Tile
 
 
 class AbstractAlignment(ABC):
@@ -15,6 +16,7 @@ def __init__(self, tiles: list[Tile]) -> None:
         self._unaligned_tiles = stitching_utils.shift_to_origin(tiles)
         self._aligned_tiles = self._align(self._unaligned_tiles)
 
+    @abstractmethod
     def _align(self, tiles: list[Tile]) -> list[Tile]:
         raise NotImplementedError
 
@@ -45,12 +47,12 @@ def _align(self, tiles: list[Tile]) -> list[Tile]:
         grid_positions_x = set()
         tile_map = {}
         for tile in tiles:
-            assert (
-                tile.shape[-2:] == tile_shape[-2:]
-            ), "All tiles must have the same YX shape."
+            if tile.shape[-2:] != tile_shape[-2:]:
+                message = f"All tiles must have the same YX shape. {tile.shape[-2:]} <=> {tile_shape[-2:]}"
+                raise ValueError(message)
             y_pos = int(np.round(tile.position.y / tile_shape[-2]))
             x_pos = int(np.round(tile.position.x / tile_shape[-1]))
-            if (y_pos, x_pos) in tile_map.keys():
+            if (y_pos, x_pos) in tile_map:
                 tile_map[(y_pos, x_pos)].append(tile)
             else:
                 tile_map[(y_pos, x_pos)] = [tile]
@@ -61,7 +63,7 @@ def _align(self, tiles: list[Tile]) -> list[Tile]:
         grid_positions_x = sorted(grid_positions_x)
         for y_pos in grid_positions_y:
             for x_pos in grid_positions_x:
-                if (y_pos, x_pos) in tile_map.keys():
+                if (y_pos, x_pos) in tile_map:
                     for unaligned_tile in tile_map[(y_pos, x_pos)]:
                         new_tile = copy(unaligned_tile)
                         new_tile.position.y = y_pos * tile_shape[-2]
diff --git a/src/faim_ipa/dask_utils.py b/src/faim_ipa/dask_utils.py
index 6f11ac4f..20236128 100644
--- a/src/faim_ipa/dask_utils.py
+++ b/src/faim_ipa/dask_utils.py
@@ -1,100 +1,8 @@
-from multiprocessing import Process, Queue
-from time import sleep
+from __future__ import annotations
 
 import numpy as np
 
 
-class LocalClusterFactory:
-    """Creates a local dask cluster in a sub-process."""
-
-    def __init__(
-        self,
-        n_workers: int = None,
-        threads_per_worker: int = None,
-        processes: bool = None,
-        memory_limit: str = None,
-        local_directory: str = None,
-    ):
-        self.n_workers = n_workers
-        self.threads_per_worker = threads_per_worker
-        self.processes = processes
-        self.memory_limit = memory_limit
-        self.local_directory = local_directory
-        self._queue = Queue(1)
-        self._scheduler_address = None
-
-        self._subprocess = Process(
-            target=self._run_cluster,
-            args=(
-                self._queue,
-                self.n_workers,
-                self.threads_per_worker,
-                self.processes,
-                self.memory_limit,
-                self.local_directory,
-            ),
-        )
-        self._subprocess.start()
-        self._scheduler_address = None
-        self._client = None
-
-    def _get_scheduler_address(self):
-        if self._scheduler_address is None:
-            scheduler_address = self._queue.get()
-            self._scheduler_address = scheduler_address
-            self._queue.close()
-            self._queue = None
-
-        return self._scheduler_address
-
-    def _shutdown(self):
-        if self._client is not None and self._client.scheduler is not None:
-            self._client.shutdown()
-            self._client = None
-
-        if self._subprocess is not None and self._subprocess.is_alive():
-            self._subprocess.join()
-
-    def __del__(self):
-        self._shutdown()
-
-    @staticmethod
-    def _run_cluster(
-        queue: Queue,
-        n_workers: int,
-        threads_per_worker: int,
-        processes: bool,
-        memory_limit: str,
-        local_directory: str,
-    ):
-        import dask
-        import distributed
-
-        dask.config.set({"distributed.workers.memory.spill": 0.90})
-        dask.config.set({"distributed.workers.memory.target": 0.85})
-        dask.config.set({"distributed.workers.memory.terminate": 0.98})
-
-        client = distributed.Client(
-            n_workers=n_workers,
-            threads_per_worker=threads_per_worker,
-            processes=processes,
-            memory_limit=memory_limit,
-            local_directory=local_directory,
-        )
-        queue.put(client.cluster.scheduler.address)
-        while client.cluster.scheduler.status.value != "closed":
-            sleep(5)
-
-    def get_client(self):
-        """Get a dask client for the local cluster."""
-        if self._client is None:
-            import distributed
-
-            self._client = distributed.Client(self._get_scheduler_address())
-
-        return self._client
-
-
 def mean_cast_to(target_dtype):
     """
     Wrap np.mean to cast the result to a given dtype.
diff --git a/src/faim_ipa/detection/blobs.py b/src/faim_ipa/detection/blobs.py
index 2b8d110f..c0e46f2a 100644
--- a/src/faim_ipa/detection/blobs.py
+++ b/src/faim_ipa/detection/blobs.py
@@ -1,11 +1,11 @@
-from typing import Optional
+from __future__ import annotations
 
 import numpy as np
 from scipy.ndimage import gaussian_laplace
 from skimage.feature import peak_local_max
-from skimage.morphology import h_maxima, ball
-from skimage.util import img_as_float32
 from skimage.feature.blob import _prune_blobs
+from skimage.morphology import ball, h_maxima
+from skimage.util import img_as_float32
 
 from faim_ipa.detection.utils import estimate_log_rescale_factor
 
@@ -17,7 +17,7 @@ def detect_blobs(
     h: int,
     scale_factors: list[int],
     overlap: float,
-    background_img: Optional[np.ndarray] = None,
+    background_img: np.ndarray | None = None,
 ) -> np.ndarray:
     """Detect blobs of different sizes.
 
@@ -49,10 +49,11 @@ def detect_blobs(
     -------
     Detected spots.
     """
-    if background_img is not None:
-        image = img_as_float32(img) - img_as_float32(background_img)
-    else:
-        image = img_as_float32(img)
+    image = (
+        img_as_float32(img) - img_as_float32(background_img)
+        if background_img is not None
+        else img_as_float32(img)
+    )
 
     rescale_factor = estimate_log_rescale_factor(
         axial_sigma=axial_sigma, lateral_sigma=lateral_sigma
@@ -62,7 +63,7 @@ def detect_blobs(
         (axial_sigma * f, lateral_sigma * f, lateral_sigma * f) for f in scale_factors
     ]
 
-    scale_cube = np.empty(image.shape + (len(sigmas),), dtype=np.uint8)
+    scale_cube = np.empty((*image.shape, len(sigmas)), dtype=np.uint8)
 
     h_ = img_as_float32(np.array(h, dtype=img.dtype))
     scale_norm = np.mean([axial_sigma, lateral_sigma, lateral_sigma])
diff --git a/src/faim_ipa/detection/spots.py b/src/faim_ipa/detection/spots.py
index 9ff97c8c..f3267ccd 100644
--- a/src/faim_ipa/detection/spots.py
+++ b/src/faim_ipa/detection/spots.py
@@ -1,8 +1,8 @@
-from typing import Optional
+from __future__ import annotations
 
 import numpy as np
 from scipy.ndimage import gaussian_laplace
-from skimage.morphology import h_maxima, ball
+from skimage.morphology import ball, h_maxima
 from skimage.util import img_as_float32
 
 from faim_ipa.detection.utils import estimate_log_rescale_factor
@@ -13,7 +13,7 @@ def detect_spots(
     axial_sigma: float,
     lateral_sigma: float,
     h: int,
-    background_img: Optional[np.ndarray] = None,
+    background_img: np.ndarray | None = None,
 ) -> np.ndarray:
     """Detect diffraction limited spots.
 
@@ -39,10 +39,11 @@ def detect_spots(
     -------
     Detected spots.
     """
-    if background_img is not None:
-        image = img_as_float32(img) - img_as_float32(background_img)
-    else:
-        image = img_as_float32(img)
+    image = (
+        img_as_float32(img) - img_as_float32(background_img)
+        if background_img is not None
+        else img_as_float32(img)
+    )
 
     rescale_factor = estimate_log_rescale_factor(
         axial_sigma=axial_sigma, lateral_sigma=lateral_sigma
diff --git a/src/faim_ipa/detection/utils.py b/src/faim_ipa/detection/utils.py
index a12140b9..1349ae40 100644
--- a/src/faim_ipa/detection/utils.py
+++ b/src/faim_ipa/detection/utils.py
@@ -1,8 +1,10 @@
 import numpy as np
-from scipy.ndimage import gaussian_laplace, gaussian_filter
+from scipy.ndimage import gaussian_filter, gaussian_laplace
 
 
-def compute_axial_sigma(wavelength: float, NA: float, axial_spacing: float):
+def compute_axial_sigma(
+    wavelength: float, NA: float, axial_spacing: float  # noqa: N803
+):
     """
     Sigma which produces a Gaussian with the same full width
     half maximum as described by Abbe's diffraction formula for axial resolution.
@@ -25,7 +27,9 @@ def compute_axial_sigma(wavelength: float, NA: float, axial_spacing: float):
     return 2 * wavelength / (NA**2) / (2 * np.sqrt(2 * np.log(2))) / axial_spacing
 
 
-def compute_lateral_sigma(wavelength: float, NA: float, lateral_spacing: float):
+def compute_lateral_sigma(
+    wavelength: float, NA: float, lateral_spacing: float  # noqa: N803
+):
     """
     Sigma which produces a Gaussian with the same full width
     half maximum as the theoretical resolution limit in Y/X described by E. Abbe.
diff --git a/src/faim_ipa/hcs/acquisition.py b/src/faim_ipa/hcs/acquisition.py
index ba73d8c8..eee1a2b0 100644
--- a/src/faim_ipa/hcs/acquisition.py
+++ b/src/faim_ipa/hcs/acquisition.py
@@ -2,13 +2,13 @@
 from collections.abc import Iterable
 from enum import Enum
 from pathlib import Path
-from typing import Any, Optional, Union
+from typing import Any
 
 import numpy as np
 import pandas as pd
 
-from faim_ipa.io.ChannelMetadata import ChannelMetadata
-from faim_ipa.stitching import Tile
+from faim_ipa.io.metadata import ChannelMetadata
+from faim_ipa.stitching.tile import Tile
 
 
 class TileAlignmentOptions(Enum):
@@ -22,16 +22,16 @@ class PlateAcquisition(ABC):
     _acquisition_dir = None
     _wells = None
     _alignment: TileAlignmentOptions = None
-    _background_correction_matrices: Optional[dict[str, Union[Path, str]]]
-    _illumination_correction_matrices: Optional[dict[str, Union[Path, str]]]
+    _background_correction_matrices: dict[str, Path | str] | None
+    _illumination_correction_matrices: dict[str, Path | str] | None
     _common_well_shape: tuple[int, int, int, int, int] = None
 
     def __init__(
         self,
-        acquisition_dir: Union[Path, str],
+        acquisition_dir: Path | str,
         alignment: TileAlignmentOptions,
-        background_correction_matrices: Optional[dict[str, Union[Path, str]]],
-        illumination_correction_matrices: Optional[dict[str, Union[Path, str]]],
+        background_correction_matrices: dict[str, Path | str] | None,
+        illumination_correction_matrices: dict[str, Path | str] | None,
     ) -> None:
         self._acquisition_dir = acquisition_dir
         self._alignment = alignment
@@ -57,19 +57,18 @@ def _build_well_acquisitions(self, files: pd.DataFrame) -> list["WellAcquisition
         raise NotImplementedError
 
     def get_well_acquisitions(
-        self, selection: Optional[list[str]] = None
+        self, selection: list[str] | None = None
     ) -> list["WellAcquisition"]:
         if selection is None:
             return self._wells
-        else:
-            return [well for well in self._wells if well.name in selection]
+        return [well for well in self._wells if well.name in selection]
 
     @abstractmethod
     def get_channel_metadata(self) -> dict[int, ChannelMetadata]:
         """Channel metadata."""
         raise NotImplementedError
 
-    def get_well_names(self, wells: Optional[list[str]] = None) -> Iterable[str]:
+    def get_well_names(self, wells: list[str] | None = None) -> Iterable[str]:
         """
         Get the names of all wells in the acquisition.
         """
@@ -88,7 +87,7 @@ def get_omero_channel_metadata(self) -> list[dict]:
         ch_metadata = self.get_channel_metadata()
         max_channel = max(list(ch_metadata.keys()))
         for index in range(max_channel + 1):
-            if index in ch_metadata.keys():
+            if index in ch_metadata:
                 metadata = ch_metadata[index]
                 ome_channels.append(
                     {
@@ -137,10 +136,7 @@ def get_common_well_shape(self) -> tuple[int, int, int, int, int]:
             (time, channel, z, y, x)
         """
         if self._common_well_shape is None:
-            well_shapes = []
-            for well in self.get_well_acquisitions():
-                well_shapes.append(well.get_shape())
-
+            well_shapes = [well.get_shape() for well in self.get_well_acquisitions()]
             self._common_well_shape = tuple(np.max(well_shapes, axis=0))
 
         return self._common_well_shape
@@ -154,8 +150,8 @@ class WellAcquisition(ABC):
     name: str = None
     _files = None
     _alignment: TileAlignmentOptions = None
-    _background_correction_matrices: Optional[dict[str, Union[Path, str]]]
-    _illumination_correction_matrices: Optional[dict[str, Union[Path, str]]]
+    _background_correction_matrices: dict[str, Path | str] | None
+    _illumination_correction_matrices: dict[str, Path | str] | None
     _tiles = None
     _shape: tuple[int, int] = None
     _dtype: np.dtype = None
@@ -164,12 +160,12 @@ def __init__(
         self,
         files: pd.DataFrame,
         alignment: TileAlignmentOptions,
-        background_correction_matrices: Optional[dict[str, Union[Path, str]]],
-        illumination_correction_matrices: Optional[dict[str, Union[Path, str]]],
+        background_correction_matrices: dict[str, Path | str] | None,
+        illumination_correction_matrices: dict[str, Path | str] | None,
     ) -> None:
-        assert (
-            files["well"].nunique() == 1
-        ), "WellAcquisition must contain files from a single well."
+        if files["well"].nunique() != 1:
+            msg = "WellAcquisition must contain files from a single well."
+            raise ValueError(msg)
         self.name = files["well"].iloc[0]
         self._files = files
         self._alignment = alignment
@@ -207,7 +203,8 @@ def _align_tiles(self, tiles: list[Tile]) -> list[Tile]:
 
             return GridAlignment(tiles=tiles).get_tiles()
 
-        raise ValueError(f"Unknown alignment option: {self._alignment}")
+        msg = f"Unknown alignment option: {self._alignment}"
+        raise ValueError(msg)
 
     def get_tiles(self) -> list[Tile]:
         """List of tiles."""
@@ -238,7 +235,7 @@ def get_yx_spacing(self) -> tuple[float, float]:
         raise NotImplementedError
 
     @abstractmethod
-    def get_z_spacing(self) -> Optional[float]:
+    def get_z_spacing(self) -> float | None:
         """
         Get the z spacing of the well acquisition.
         """
@@ -307,12 +304,11 @@ def get_shape(self):
         Compute the theoretical shape of the stitched well image.
         """
         if self._shape is None:
-            tile_extents = []
-            for tile in self._tiles:
-                tile_extents.append(
-                    tile.get_position()
-                    + np.array((1,) * (5 - len(tile.shape)) + tile.shape)
-                )
+            tile_extents = [
+                tile.get_position()
+                + np.array((1,) * (5 - len(tile.shape)) + tile.shape)
+                for tile in self._tiles
+            ]
             self._shape = tuple(np.max(tile_extents, axis=0))
 
         return self._shape
diff --git a/src/faim_ipa/hcs/cellvoyager/CellVoyagerWellAcquisition.py b/src/faim_ipa/hcs/cellvoyager/CellVoyagerWellAcquisition.py
deleted file mode 100644
index a71346b4..00000000
--- a/src/faim_ipa/hcs/cellvoyager/CellVoyagerWellAcquisition.py
+++ /dev/null
@@ -1,155 +0,0 @@
-from decimal import Decimal
-from pathlib import Path
-from typing import Optional, Union
-
-import numpy as np
-import pandas as pd
-from tifffile import imread
-
-from faim_ipa.hcs.acquisition import TileAlignmentOptions, WellAcquisition
-from faim_ipa.hcs.cellvoyager.StackedTile import StackedTile
-from faim_ipa.stitching import Tile
-from faim_ipa.stitching.Tile import TilePosition
-
-
-class CellVoyagerWellAcquisition(WellAcquisition):
-    """
-    Data structure for a CellVoyager well acquisition.
-    """
-
-    def __init__(
-        self,
-        files: pd.DataFrame,
-        alignment: TileAlignmentOptions,
-        metadata: pd.DataFrame,
-        background_correction_matrices: dict[str, Union[Path, str]] = None,
-        illumination_correction_matrices: dict[str, Union[Path, str]] = None,
-        n_planes_in_stacked_tile: int = 1,
-    ):
-        self._metadata = metadata
-        self._z_spacing = self._compute_z_spacing(files)
-        self._dtype = self._get_dtype(files)
-        self._n_planes_in_stacked_tile = n_planes_in_stacked_tile
-        super().__init__(
-            files=files,
-            alignment=alignment,
-            background_correction_matrices=background_correction_matrices,
-            illumination_correction_matrices=illumination_correction_matrices,
-        )
-
-    def _get_dtype(self, files: pd.DataFrame) -> np.dtype:
-        return imread(files["path"].iloc[0]).dtype
-
-    def _compute_z_spacing(self, files: pd.DataFrame) -> Optional[float]:
-        if "ZIndex" in files.columns:
-            z_steps = np.array(
-                files.astype({"Z": float}).groupby("ZIndex", sort=True).mean("Z")["Z"]
-            )
-
-            precision = -Decimal(str(z_steps[0])).as_tuple().exponent
-            z_step = np.round(np.mean(np.diff(z_steps)), decimals=precision)
-            return z_step
-        else:
-            return None
-
-    def _assemble_tiles(self) -> list[Tile]:
-        min_z_index = 0
-        max_z_index = min_z_index + 1
-        if "ZIndex" in self._files.columns:
-            min_z_index = self._files["ZIndex"].min()
-            max_z_index = self._files["ZIndex"].max() + 1
-
-        tiles = {}
-        for i, row in self._files.iterrows():
-            if "ZIndex" in row:
-                tile_z_index = (
-                    row["ZIndex"] - min_z_index
-                ) // self._n_planes_in_stacked_tile
-            else:
-                tile_z_index = min_z_index
-            tczyx_index = (
-                row["TimePoint"],
-                row["Ch"],
-                tile_z_index,
-                row["Y"],
-                row["X"],
-            )
-            if tczyx_index not in tiles:
-                tiles[tczyx_index] = [row]
-            else:
-                tiles[tczyx_index].append(row)
-
-        stacked_tiles = []
-        for tczyx_index, rows in tiles.items():
-            row_dict = {}
-            for r in rows:
-                if "ZIndex" in r:
-                    row_dict[r["ZIndex"]] = r["path"]
-                else:
-                    row_dict[min_z_index] = r["path"]
-
-            files = []
-            z_start = tczyx_index[2] * self._n_planes_in_stacked_tile + min_z_index
-            for z in range(
-                z_start, min(z_start + self._n_planes_in_stacked_tile, max_z_index)
-            ):
-                if z in row_dict.keys():
-                    files.append(row_dict[z])
-                else:
-                    files.append(None)
-
-            time_point = tczyx_index[0]
-            channel = tczyx_index[1]
-            y, x = tczyx_index[3], tczyx_index[4]
-
-            ch_metadata = self._metadata[self._metadata["Ch"] == channel].iloc[0]
-            shape = (
-                len(files),
-                int(ch_metadata["VerticalPixels"]),
-                int(ch_metadata["HorizontalPixels"]),
-            )
-
-            yx_spacing = self.get_yx_spacing()
-
-            bgcm = None
-            if self._background_correction_matrices is not None:
-                bgcm = self._background_correction_matrices[str(channel)]
-
-            icm = None
-            if self._illumination_correction_matrices is not None:
-                icm = self._illumination_correction_matrices[str(channel)]
-
-            stacked_tiles.append(
-                StackedTile(
-                    paths=files,
-                    shape=shape,
-                    position=TilePosition(
-                        time=time_point,
-                        channel=int(channel),
-                        z=z_start,
-                        y=int(-float(y) / yx_spacing[0]),
-                        x=int(float(x) / yx_spacing[1]),
-                    ),
-                    background_correction_matrix_path=bgcm,
-                    illumination_correction_matrix_path=icm,
-                    dtype=self._dtype,
-                )
-            )
-
-        return stacked_tiles
-
-    def get_axes(self) -> list[str]:
-        if self._z_spacing is not None:
-            return ["c", "z", "y", "x"]
-        else:
-            return ["c", "y", "x"]
-
-    def get_yx_spacing(self) -> tuple[float, float]:
-        ch_metadata = self._metadata.iloc[0]
-        return (
-            float(ch_metadata["VerticalPixelDimension"]),
-            float(ch_metadata["HorizontalPixelDimension"]),
-        )
-
-    def get_z_spacing(self) -> Optional[float]:
-        return self._z_spacing
diff --git a/src/faim_ipa/hcs/cellvoyager/StackAcquisition.py b/src/faim_ipa/hcs/cellvoyager/StackAcquisition.py
deleted file mode 100644
index 76ff2577..00000000
--- a/src/faim_ipa/hcs/cellvoyager/StackAcquisition.py
+++ /dev/null
@@ -1,155 +0,0 @@
-from os.path import exists, join
-from pathlib import Path
-import re
-from typing import Optional, Union
-from xml.etree import ElementTree as ET
-
-import pandas as pd
-from tqdm import tqdm
-
-from faim_ipa.hcs.acquisition import (
-    PlateAcquisition,
-    TileAlignmentOptions,
-    WellAcquisition,
-)
-from faim_ipa.hcs.cellvoyager.CellVoyagerWellAcquisition import (
-    CellVoyagerWellAcquisition,
-)
-from faim_ipa.io.ChannelMetadata import ChannelMetadata
-
-BTS_NS = "{http://www.yokogawa.co.jp/BTS/BTSSchema/1.0}"
-
-
-class StackAcquisition(PlateAcquisition):
-    def __init__(
-        self,
-        acquisition_dir: Union[Path, str],
-        alignment: TileAlignmentOptions,
-        background_correction_matrices: Optional[dict[str, Union[Path, str]]] = None,
-        illumination_correction_matrices: Optional[dict[str, Union[Path, str]]] = None,
-        n_planes_in_stacked_tile: int = 1,
-    ):
-        self._n_planes_in_stacked_tile = n_planes_in_stacked_tile
-        super().__init__(
-            acquisition_dir=acquisition_dir,
-            alignment=alignment,
-            background_correction_matrices=background_correction_matrices,
-            illumination_correction_matrices=illumination_correction_matrices,
-        )
-
-    def get_channel_metadata(self) -> dict[int, ChannelMetadata]:
-        metadata = self._parse_metadata()
-        ch_metadata = {}
-
-        for i, row in metadata.iterrows():
-            index = int(row["Ch"]) - 1
-            ch_metadata[index] = ChannelMetadata(
-                channel_index=index,
-                channel_name=row["Ch"],
-                display_color=row["Color"],
-                spatial_calibration_x=row["HorizontalPixelDimension"],
-                spatial_calibration_y=row["VerticalPixelDimension"],
-                spatial_calibration_units="um",
-                z_spacing=self.get_z_spacing(),
-                wavelength=self.__parse_filter_wavelength(row["Acquisition"]),
-                exposure_time=row["ExposureTime"],
-                exposure_time_unit="ms",
-                objective=row["Objective"],
-            )
-
-        assert min(ch_metadata.keys()) == 0, "Channel indices must start at 0."
-
-        return ch_metadata
-
-    def get_z_spacing(self) -> float:
-        return self._wells[0].get_z_spacing()
-
-    def _build_well_acquisitions(self, files: pd.DataFrame) -> list[WellAcquisition]:
-        wells = []
-        for well in tqdm(files["well"].unique()):
-            wells.append(
-                CellVoyagerWellAcquisition(
-                    files=files[files["well"] == well],
-                    alignment=self._alignment,
-                    metadata=self._parse_metadata(),
-                    background_correction_matrices=self._background_correction_matrices,
-                    illumination_correction_matrices=self._illumination_correction_matrices,
-                    n_planes_in_stacked_tile=self._n_planes_in_stacked_tile,
-                )
-            )
-        return wells
-
-    @staticmethod
-    def __parse_filter_wavelength(value) -> int:
-        try:
-            return int(re.match(r"BP(\d+)/", value).group(1))
-        except AttributeError:  # no cov
-            return 0
-
-    def _parse_metadata(self) -> pd.DataFrame:
-        mrf_file = join(self._acquisition_dir, "MeasurementDetail.mrf")
-        if not exists(mrf_file):
-            raise ValueError(
-                f"MeasurementDetail.mrf not found in: {self._acquisition_dir}"
-            )
-        mrf_tree = ET.parse(mrf_file)
-        mrf_root = mrf_tree.getroot()
-
-        channels = []
-        for channel in mrf_root.findall(BTS_NS + "MeasurementChannel"):
-            row = {
-                key.replace(BTS_NS, ""): value for key, value in channel.attrib.items()
-            }
-            channels.append(row)
-
-        mes_file = join(
-            self._acquisition_dir,
-            mrf_root.attrib[BTS_NS + "MeasurementSettingFileName"],
-        )
-        if not exists(mes_file):
-            raise ValueError(f"Settings file not found: {mes_file}")
-        mes_tree = ET.parse(mes_file)
-        mes_root = mes_tree.getroot()
-
-        channel_settings = []
-        for channel in mes_root.find(BTS_NS + "ChannelList").findall(
-            BTS_NS + "Channel"
-        ):
-            row = {
-                key.replace(BTS_NS, ""): value for key, value in channel.attrib.items()
-            }
-            channel_settings.append(row)
-
-        return pd.merge(
-            pd.DataFrame(channels),
-            pd.DataFrame(channel_settings),
-            left_on="Ch",
-            right_on="Ch",
-        )
-
-    def _parse_files(self) -> pd.DataFrame:
-        mlf_file = join(self._acquisition_dir, "MeasurementData.mlf")
-        if not exists(mlf_file):
-            raise ValueError(
-                f"MeasurementData.mlf not found in: {self._acquisition_dir}"
-            )
-        mlf_tree = ET.parse(mlf_file)
-        mlf_root = mlf_tree.getroot()
-
-        files = []
-        for record in mlf_root.findall(BTS_NS + "MeasurementRecord"):
-            row = {
-                key.replace(BTS_NS, ""): value for key, value in record.attrib.items()
-            }
-            if row.pop("Type") == "IMG":
-                row |= {
-                    "path": join(self._acquisition_dir, record.text),
-                    "well": chr(ord("@") + int(row.pop("Row")))
-                    + row.pop("Column").zfill(2),
-                }
-                files.append(row)
-
-        files = pd.DataFrame(files)
-        files["TimePoint"] = files["TimePoint"].astype(int)
-        files["ZIndex"] = files["ZIndex"].astype(int)
-        return files
diff --git a/src/faim_ipa/hcs/cellvoyager/ZAdjustedStackAcquisition.py b/src/faim_ipa/hcs/cellvoyager/ZAdjustedStackAcquisition.py
deleted file mode 100644
index d60759e8..00000000
--- a/src/faim_ipa/hcs/cellvoyager/ZAdjustedStackAcquisition.py
+++ /dev/null
@@ -1,125 +0,0 @@
-from os.path import join
-from pathlib import Path
-from typing import Optional, Union
-from warnings import warn
-
-import numpy as np
-from pandas.core.api import DataFrame as DataFrame
-
-from faim_ipa.hcs.acquisition import TileAlignmentOptions
-from faim_ipa.hcs.cellvoyager.StackAcquisition import StackAcquisition
-
-
-class ZAdjustedStackAcquisition(StackAcquisition):
-    _trace_log_files = []
-
-    def __init__(
-        self,
-        acquisition_dir: Union[Path, str],
-        trace_log_files: list[Union[Path, str]],
-        alignment: TileAlignmentOptions,
-        background_correction_matrices: Optional[dict[str, Union[Path, str]]] = None,
-        illumination_correction_matrices: Optional[dict[str, Union[Path, str]]] = None,
-        n_planes_in_stacked_tile: int = 1,
-    ):
-        self._trace_log_files = trace_log_files
-        super().__init__(
-            acquisition_dir,
-            alignment,
-            background_correction_matrices,
-            illumination_correction_matrices,
-            n_planes_in_stacked_tile=n_planes_in_stacked_tile,
-        )
-
-    def _parse_files(self) -> DataFrame:
-        files = super()._parse_files()
-        z_mapping = self._create_z_mapping()
-        # merge files left with mapping on path
-        merged = files.merge(z_mapping, how="left", left_on=["path"], right_on=["path"])
-        if np.any(merged["z_pos"].isna()):
-            raise ValueError("At least one invalid z position.")
-        min_z = np.min(merged["z_pos"].astype(float))
-        z_spacing = np.mean(
-            merged[merged["ZIndex"].astype(int) == 2]["Z"].astype(float)
-        ) - np.mean(merged[merged["ZIndex"].astype(int) == 1]["Z"].astype(float))
-        # Shift ZIndex for each field in each well according to the auto-focus value
-        for well in merged["well"].unique():
-            for field in merged[merged["well"] == well]["FieldIndex"].unique():
-                for ch in merged.query(f"well == '{well}' & FieldIndex == '{field}'")[
-                    "Ch"
-                ].unique():
-                    z_index_offset = int(
-                        np.round(
-                            (
-                                np.min(
-                                    merged.query(
-                                        f"well == '{well}' & FieldIndex == '{field}' & Ch == '{ch}'"
-                                    )["z_pos"]
-                                )
-                                - min_z
-                            )
-                            / z_spacing
-                        )
-                    )
-                    merged.loc[
-                        (merged["well"] == well)
-                        & (merged["FieldIndex"] == field)
-                        & (merged["Ch"] == ch),
-                        "ZIndex",
-                    ] += z_index_offset
-
-        # Start at 0
-        merged["ZIndex"] = merged["ZIndex"] - merged["ZIndex"].min()
-        # update Z
-        merged["Z"] = merged["z_pos"]
-        return merged
-
-    def _create_z_mapping(self) -> DataFrame:
-        z_pos = []
-        filenames = []
-        missing = []
-        value = None
-        for trace_file in self._trace_log_files:
-            with open(trace_file) as log:
-                for line in log:
-                    tokens = line.split(",")
-                    if (
-                        (len(tokens) > 14)
-                        and (tokens[7] == "--->")
-                        and (tokens[8] == "MS_MANU")
-                    ):
-                        value = float(tokens[14])
-                    elif (
-                        (len(tokens) > 12)
-                        and (tokens[7] == "--->")
-                        and (tokens[8] == "AF_MANU")
-                        and (tokens[9] == "34")
-                    ):
-                        value = float(tokens[12])
-                    elif (
-                        (len(tokens) > 8)
-                        and (tokens[4] == "Measurement")
-                        and (tokens[7] == "_init_frame_save")
-                    ):
-                        filename = tokens[8]
-                        if value is None:
-                            missing.append(join(self._acquisition_dir, filename))
-                        else:
-                            filenames.append(join(self._acquisition_dir, filename))
-                            z_pos.append(value)
-                    elif (
-                        (len(tokens) > 7)
-                        and (tokens[6] == "EndPeriod")
-                        and (tokens[7] == "acquire frames")
-                    ):
-                        value = None
-
-        if len(missing) > 0:
-            warn("Z position information missing for some files.")
-            warn(f"First file without z position information: {missing[0]}")
-        return DataFrame(
-            {
-                "path": filenames,
-                "z_pos": z_pos,
-            }
-        )
diff --git a/src/faim_ipa/hcs/cellvoyager/__init__.py b/src/faim_ipa/hcs/cellvoyager/__init__.py
index 6e4de60d..d74f9d00 100644
--- a/src/faim_ipa/hcs/cellvoyager/__init__.py
+++ b/src/faim_ipa/hcs/cellvoyager/__init__.py
@@ -1,2 +1,13 @@
-from .StackAcquisition import StackAcquisition  # noqa: F401
-from .ZAdjustedStackAcquisition import ZAdjustedStackAcquisition  # noqa: F401
+from faim_ipa.hcs.cellvoyager.acquisition import (
+    CellVoyagerWellAcquisition,
+    StackAcquisition,
+    ZAdjustedStackAcquisition,
+)
+from faim_ipa.hcs.cellvoyager.tile import StackedTile
+
+__all__ = [
+    "CellVoyagerWellAcquisition",
+    "StackAcquisition",
+    "ZAdjustedStackAcquisition",
+    "StackedTile",
+]
diff --git a/src/faim_ipa/hcs/cellvoyager/acquisition.py b/src/faim_ipa/hcs/cellvoyager/acquisition.py
new file mode 100644
index 00000000..0b32daa2
--- /dev/null
+++ b/src/faim_ipa/hcs/cellvoyager/acquisition.py
@@ -0,0 +1,417 @@
+from __future__ import annotations
+
+import re
+from decimal import Decimal
+from os.path import exists, join
+from typing import TYPE_CHECKING
+from warnings import warn
+
+import numpy as np
+import pandas as pd
+from defusedxml.ElementTree import parse
+from tifffile import imread
+from tqdm import tqdm
+
+from faim_ipa.hcs.acquisition import (
+    PlateAcquisition,
+    TileAlignmentOptions,
+    WellAcquisition,
+)
+from faim_ipa.hcs.cellvoyager.tile import StackedTile
+from faim_ipa.io.metadata import ChannelMetadata
+from faim_ipa.stitching.tile import Tile, TilePosition
+
+if TYPE_CHECKING:
+    from pathlib import Path
+
+BTS_NS = "{http://www.yokogawa.co.jp/BTS/BTSSchema/1.0}"
+
+
+class CellVoyagerWellAcquisition(WellAcquisition):
+    """
+    Data structure for a CellVoyager well acquisition.
+    """
+
+    def __init__(
+        self,
+        files: pd.DataFrame,
+        alignment: TileAlignmentOptions,
+        metadata: pd.DataFrame,
+        background_correction_matrices: dict[str, Path | str] | None = None,
+        illumination_correction_matrices: dict[str, Path | str] | None = None,
+        n_planes_in_stacked_tile: int = 1,
+    ):
+        self._metadata = metadata
+        self._z_spacing = self._compute_z_spacing(files)
+        self._dtype = self._get_dtype(files)
+        self._n_planes_in_stacked_tile = n_planes_in_stacked_tile
+        super().__init__(
+            files=files,
+            alignment=alignment,
+            background_correction_matrices=background_correction_matrices,
+            illumination_correction_matrices=illumination_correction_matrices,
+        )
+
+    def _get_dtype(self, files: pd.DataFrame) -> np.dtype:
+        return imread(files["path"].iloc[0]).dtype
+
+    def _compute_z_spacing(self, files: pd.DataFrame) -> float | None:
+        if "ZIndex" in files.columns:
+            z_steps = np.array(
+                files.astype({"Z": float}).groupby("ZIndex", sort=True).mean("Z")["Z"]
+            )
+
+            precision = -Decimal(str(z_steps[0])).as_tuple().exponent
+            return np.round(np.mean(np.diff(z_steps)), decimals=precision)
+        return None
+
+    def _assemble_tiles(self) -> list[Tile]:
+        min_z_index = 0
+        max_z_index = min_z_index + 1
+        if "ZIndex" in self._files.columns:
+            min_z_index = self._files["ZIndex"].min()
+            max_z_index = self._files["ZIndex"].max() + 1
+
+        tiles = {}
+        for _i, row in self._files.iterrows():
+            if "ZIndex" in row:
+                tile_z_index = (
+                    row["ZIndex"] - min_z_index
+                ) // self._n_planes_in_stacked_tile
+            else:
+                tile_z_index = min_z_index
+            tczyx_index = (
+                row["TimePoint"],
+                row["Ch"],
+                tile_z_index,
+                row["Y"],
+                row["X"],
+            )
+            if tczyx_index not in tiles:
+                tiles[tczyx_index] = [row]
+            else:
+                tiles[tczyx_index].append(row)
+
+        stacked_tiles = []
+        for tczyx_index, rows in tiles.items():
+            row_dict = {}
+            for r in rows:
+                if "ZIndex" in r:
+                    row_dict[r["ZIndex"]] = r["path"]
+                else:
+                    row_dict[min_z_index] = r["path"]
+
+            files = []
+            z_start = tczyx_index[2] * self._n_planes_in_stacked_tile + min_z_index
+            for z in range(
+                z_start, min(z_start + self._n_planes_in_stacked_tile, max_z_index)
+            ):
+                if z in row_dict:
+                    files.append(row_dict[z])
+                else:
+                    files.append(None)
+
+            time_point = tczyx_index[0]
+            channel = tczyx_index[1]
+            y, x = tczyx_index[3], tczyx_index[4]
+
+            ch_metadata = self._metadata[self._metadata["Ch"] == channel].iloc[0]
+            shape = (
+                len(files),
+                int(ch_metadata["VerticalPixels"]),
+                int(ch_metadata["HorizontalPixels"]),
+            )
+
+            yx_spacing = self.get_yx_spacing()
+
+            bgcm = None
+            if self._background_correction_matrices is not None:
+                bgcm = self._background_correction_matrices[str(channel)]
+
+            icm = None
+            if self._illumination_correction_matrices is not None:
+                icm = self._illumination_correction_matrices[str(channel)]
+
+            stacked_tiles.append(
+                StackedTile(
+                    paths=files,
+                    shape=shape,
+                    position=TilePosition(
+                        time=time_point,
+                        channel=int(channel),
+                        z=z_start,
+                        y=int(-float(y) / yx_spacing[0]),
+                        x=int(float(x) / yx_spacing[1]),
+                    ),
+                    background_correction_matrix_path=bgcm,
+                    illumination_correction_matrix_path=icm,
+                    dtype=self._dtype,
+                )
+            )
+
+        return stacked_tiles
+
+    def get_axes(self) -> list[str]:
+        if self._z_spacing is not None:
+            return ["c", "z", "y", "x"]
+        return ["c", "y", "x"]
+
+    def get_yx_spacing(self) -> tuple[float, float]:
+        ch_metadata = self._metadata.iloc[0]
+        return (
+            float(ch_metadata["VerticalPixelDimension"]),
+            float(ch_metadata["HorizontalPixelDimension"]),
+        )
+
+    def get_z_spacing(self) -> float | None:
+        return self._z_spacing
+
+
+class StackAcquisition(PlateAcquisition):
+    def __init__(
+        self,
+        acquisition_dir: Path | str,
+        alignment: TileAlignmentOptions,
+        background_correction_matrices: dict[str, Path | str] | None = None,
+        illumination_correction_matrices: dict[str, Path | str] | None = None,
+        n_planes_in_stacked_tile: int = 1,
+    ):
+        self._n_planes_in_stacked_tile = n_planes_in_stacked_tile
+        super().__init__(
+            acquisition_dir=acquisition_dir,
+            alignment=alignment,
+            background_correction_matrices=background_correction_matrices,
+            illumination_correction_matrices=illumination_correction_matrices,
+        )
+
+    def get_channel_metadata(self) -> dict[int, ChannelMetadata]:
+        metadata = self._parse_metadata()
+        ch_metadata = {}
+
+        for _i, row in metadata.iterrows():
+            index = int(row["Ch"]) - 1
+            ch_metadata[index] = ChannelMetadata(
+                channel_index=index,
+                channel_name=row["Ch"],
+                display_color=row["Color"],
+                spatial_calibration_x=row["HorizontalPixelDimension"],
+                spatial_calibration_y=row["VerticalPixelDimension"],
+                spatial_calibration_units="um",
+                z_spacing=self.get_z_spacing(),
+                wavelength=self.__parse_filter_wavelength(row["Acquisition"]),
+                exposure_time=row["ExposureTime"],
+                exposure_time_unit="ms",
+                objective=row["Objective"],
+            )
+
+        assert min(ch_metadata.keys()) == 0, "Channel indices must start at 0."
+
+        return ch_metadata
+
+    def get_z_spacing(self) -> float:
+        return self._wells[0].get_z_spacing()
+
+    def _build_well_acquisitions(self, files: pd.DataFrame) -> list[WellAcquisition]:
+        return [
+            CellVoyagerWellAcquisition(
+                files=files[files["well"] == well],
+                alignment=self._alignment,
+                metadata=self._parse_metadata(),
+                background_correction_matrices=self._background_correction_matrices,
+                illumination_correction_matrices=self._illumination_correction_matrices,
+                n_planes_in_stacked_tile=self._n_planes_in_stacked_tile,
+            )
+            for well in tqdm(files["well"].unique())
+        ]
+
+    @staticmethod
+    def __parse_filter_wavelength(value) -> int:
+        try:
+            return int(re.match(r"BP(\d+)/", value).group(1))
+        except AttributeError:  # no cov
+            return 0
+
+    def _parse_metadata(self) -> pd.DataFrame:
+        mrf_file = join(self._acquisition_dir, "MeasurementDetail.mrf")
+        if not exists(mrf_file):
+            msg = f"MeasurementDetail.mrf not found in: {self._acquisition_dir}"
+            raise ValueError(msg)
+        mrf_tree = parse(mrf_file)
+        mrf_root = mrf_tree.getroot()
+
+        channels = []
+        for channel in mrf_root.findall(BTS_NS + "MeasurementChannel"):
+            row = {
+                key.replace(BTS_NS, ""): value for key, value in channel.attrib.items()
+            }
+            channels.append(row)
+
+        mes_file = join(
+            self._acquisition_dir,
+            mrf_root.attrib[BTS_NS + "MeasurementSettingFileName"],
+        )
+        if not exists(mes_file):
+            msg = f"Settings file not found: {mes_file}"
+            raise ValueError(msg)
+        mes_tree = parse(mes_file)
+        mes_root = mes_tree.getroot()
+
+        channel_settings = []
+        for channel in mes_root.find(BTS_NS + "ChannelList").findall(
+            BTS_NS + "Channel"
+        ):
+            row = {
+                key.replace(BTS_NS, ""): value for key, value in channel.attrib.items()
+            }
+            channel_settings.append(row)
+
+        return pd.merge(
+            pd.DataFrame(channels),
+            pd.DataFrame(channel_settings),
+            left_on="Ch",
+            right_on="Ch",
+        )
+
+    def _parse_files(self) -> pd.DataFrame:
+        mlf_file = join(self._acquisition_dir, "MeasurementData.mlf")
+        if not exists(mlf_file):
+            msg = f"MeasurementData.mlf not found in: {self._acquisition_dir}"
+            raise ValueError(msg)
+        mlf_tree = parse(mlf_file)
+        mlf_root = mlf_tree.getroot()
+
+        files = []
+        for record in mlf_root.findall(BTS_NS + "MeasurementRecord"):
+            row = {
+                key.replace(BTS_NS, ""): value for key, value in record.attrib.items()
+            }
+            if row.pop("Type") == "IMG":
+                row |= {
+                    "path": join(self._acquisition_dir, record.text),
+                    "well": chr(ord("@") + int(row.pop("Row")))
+                    + row.pop("Column").zfill(2),
+                }
+                files.append(row)
+
+        files = pd.DataFrame(files)
+        files["TimePoint"] = files["TimePoint"].astype(int)
+        files["ZIndex"] = files["ZIndex"].astype(int)
+        return files
+
+
+class ZAdjustedStackAcquisition(StackAcquisition):
+    _trace_log_files: list[str | Path]
+
+    def __init__(
+        self,
+        acquisition_dir: Path | str,
+        trace_log_files: list[Path | str],
+        alignment: TileAlignmentOptions,
+        background_correction_matrices: dict[str, Path | str] | None = None,
+        illumination_correction_matrices: dict[str, Path | str] | None = None,
+        n_planes_in_stacked_tile: int = 1,
+    ):
+        self._trace_log_files = trace_log_files
+        super().__init__(
+            acquisition_dir,
+            alignment,
+            background_correction_matrices,
+            illumination_correction_matrices,
+            n_planes_in_stacked_tile=n_planes_in_stacked_tile,
+        )
+
+    def _parse_files(self) -> pd.DataFrame:
+        files = super()._parse_files()
+        z_mapping = self._create_z_mapping()
+        # merge files left with mapping on path
+        merged = files.merge(z_mapping, how="left", left_on=["path"], right_on=["path"])
+        if np.any(merged["z_pos"].isna()):
+            msg = "At least one invalid z position."
+            raise ValueError(msg)
+        min_z = np.min(merged["z_pos"].astype(float))
+        z_spacing = np.mean(
+            merged[merged["ZIndex"].astype(int) == 2]["Z"].astype(float)
+        ) - np.mean(merged[merged["ZIndex"].astype(int) == 1]["Z"].astype(float))
+        # Shift ZIndex for each field in each well according to the auto-focus value
+        for well in merged["well"].unique():
+            for field in merged[merged["well"] == well]["FieldIndex"].unique():
+                for ch in merged.query(f"well == '{well}' & FieldIndex == '{field}'")[
+                    "Ch"
+                ].unique():
+                    z_index_offset = int(
+                        np.round(
+                            (
+                                np.min(
+                                    merged.query(
+                                        f"well == '{well}' & FieldIndex == '{field}' & Ch == '{ch}'"
+                                    )["z_pos"]
+                                )
+                                - min_z
+                            )
+                            / z_spacing
+                        )
+                    )
+                    merged.loc[
+                        (merged["well"] == well)
+                        & (merged["FieldIndex"] == field)
+                        & (merged["Ch"] == ch),
+                        "ZIndex",
+                    ] += z_index_offset
+
+        # Start at 0
+        merged["ZIndex"] = merged["ZIndex"] - merged["ZIndex"].min()
+        # update Z
+        merged["Z"] = merged["z_pos"]
+        return merged
+
+    def _create_z_mapping(self) -> pd.DataFrame:
+        z_pos = []
+        filenames = []
+        missing = []
+        value = None
+        for trace_file in self._trace_log_files:
+            with open(trace_file) as log:
+                for line in log:
+                    tokens = line.split(",")
+                    if (
+                        (len(tokens) > 14)
+                        and (tokens[7] == "--->")
+                        and (tokens[8] == "MS_MANU")
+                    ):
+                        value = float(tokens[14])
+                    elif (
+                        (len(tokens) > 12)
+                        and (tokens[7] == "--->")
+                        and (tokens[8] == "AF_MANU")
+                        and (tokens[9] == "34")
+                    ):
+                        value = float(tokens[12])
+                    elif (
+                        (len(tokens) > 8)
+                        and (tokens[4] == "Measurement")
+                        and (tokens[7] == "_init_frame_save")
+                    ):
+                        filename = tokens[8]
+                        if value is None:
+                            missing.append(join(self._acquisition_dir, filename))
+                        else:
+                            filenames.append(join(self._acquisition_dir, filename))
+                            z_pos.append(value)
+                    elif (
+                        (len(tokens) > 7)
+                        and (tokens[6] == "EndPeriod")
+                        and (tokens[7] == "acquire frames")
+                    ):
+                        value = None
+
+        if len(missing) > 0:
+            warn("Z position information missing for some files.", stacklevel=2)
+            warn(
+                f"First file without z position information: {missing[0]}", stacklevel=2
+            )
+        return pd.DataFrame(
+            {
+                "path": filenames,
+                "z_pos": z_pos,
+            }
+        )
diff --git a/src/faim_ipa/hcs/cellvoyager/StackedTile.py b/src/faim_ipa/hcs/cellvoyager/tile.py
similarity index 79%
rename from src/faim_ipa/hcs/cellvoyager/StackedTile.py
rename to src/faim_ipa/hcs/cellvoyager/tile.py
index dbff2c1f..487d31be 100644
--- a/src/faim_ipa/hcs/cellvoyager/StackedTile.py
+++ b/src/faim_ipa/hcs/cellvoyager/tile.py
@@ -1,23 +1,21 @@
 from pathlib import Path
-from typing import Optional, Union
 
 import numpy as np
 from numpy._typing import NDArray
 from tifffile import imread
 
-from faim_ipa.stitching import Tile
-from faim_ipa.stitching.Tile import TilePosition
+from faim_ipa.stitching.tile import Tile, TilePosition
 
 
 class StackedTile(Tile):
     def __init__(
         self,
-        paths: list[Union[Path, str]],
+        paths: list[Path | str],
         shape: tuple[int, int, int],
         dtype: np.dtype,
         position: TilePosition,
-        background_correction_matrix_path: Optional[Union[Path, str]] = None,
-        illumination_correction_matrix_path: Optional[Union[Path, str]] = None,
+        background_correction_matrix_path: Path | str | None = None,
+        illumination_correction_matrix_path: Path | str | None = None,
     ):
         super().__init__(
             path=None,
diff --git a/src/faim_ipa/hcs/converter.py b/src/faim_ipa/hcs/converter.py
index 32c07665..0c43195e 100644
--- a/src/faim_ipa/hcs/converter.py
+++ b/src/faim_ipa/hcs/converter.py
@@ -1,7 +1,9 @@
+from __future__ import annotations
+
 import os
 from os.path import join
 from pathlib import Path
-from typing import Callable, Optional, Union
+from typing import TYPE_CHECKING
 
 import dask.array as da
 import zarr
@@ -19,13 +21,17 @@
 from pydantic import BaseModel
 
 from faim_ipa import dask_utils
-from faim_ipa.hcs.acquisition import PlateAcquisition
 from faim_ipa.hcs.plate import PlateLayout, get_rows_and_columns
 from faim_ipa.stitching import stitching_utils
 
+if TYPE_CHECKING:
+    from collections.abc import Callable
+
+    from faim_ipa.hcs.acquisition import PlateAcquisition
+
 
 class NGFFPlate(BaseModel):
-    root_dir: Union[Path, str]
+    root_dir: Path | str
     name: str
     layout: PlateLayout
     order_name: str
@@ -71,7 +77,7 @@ def __init__(
         self._client = client
 
     def create_zarr_plate(
-        self, plate_acquisition: PlateAcquisition, wells: Optional[list[str]] = None
+        self, plate_acquisition: PlateAcquisition, wells: list[str] | None = None
     ) -> zarr.Group:
         """
         Create empty NGFF zarr plate.
@@ -109,19 +115,19 @@ def create_zarr_plate(
             attrs["barcode"] = self._ngff_plate.barcode
             plate.attrs.put(attrs)
             return plate
-        else:
-            store = parse_url(plate_path, mode="w").store
-            return zarr.group(store=store)
+        store = parse_url(plate_path, mode="w").store
+        return zarr.group(store=store)
 
     def run(
         self,
         plate: zarr.Group,
         plate_acquisition: PlateAcquisition,
-        wells: list[str] = None,
+        wells: list[str] | None = None,
         well_sub_group: str = "0",
-        chunks: Union[tuple[int, int], tuple[int, int, int]] = (2048, 2048),
+        chunks: tuple[int, int] | tuple[int, int, int] = (2048, 2048),
         max_layer: int = 3,
-        storage_options: dict = None,
+        storage_options: dict | None = None,
+        *,
         build_acquisition_mask: bool = False,
     ):
         """
@@ -193,7 +199,9 @@ def _write_metadata(
         fmt.validate_coordinate_transformations(
             dims, len(datasets), coordinate_transformations
         )
-        for dataset, transform in zip(datasets, coordinate_transformations):
+        for dataset, transform in zip(
+            datasets, coordinate_transformations, strict=True
+        ):
             dataset["coordinateTransformations"] = transform
         axes = Axes(well_acquisition.get_axes(), fmt).to_list()
         spatial_calibration_unit = Unit(
@@ -213,7 +221,7 @@ def _write_metadata(
         }
         group.attrs["acquisition_metadata"] = {
             "channels": [
-                ch_metadata.dict()
+                ch_metadata.model_dump()
                 for ch_metadata in plate_acquisition.get_channel_metadata().values()
             ]
         }
@@ -322,14 +330,14 @@ def _bin_yx(self, image_da):
                 },
                 trim_excess=True,
             )
-        else:
-            return image_da
+        return image_da
 
     def _stitch_well_image(
         self,
         chunks,
         well_acquisition,
         output_shape: tuple[int, int, int, int, int],
+        *,
         build_acquisition_mask: bool,
     ):
         from faim_ipa.stitching import DaskTileStitcher
@@ -346,8 +354,9 @@ def _stitch_well_image(
                 chunks[-2],
                 chunks[-1],
             )
-        else:
-            raise NotImplementedError("Tile data must be 2D or 3D.")  # pragma: no cover
+        else:  # pragma: no cover
+            msg = "Tile data must be 2D or 3D."
+            raise NotImplementedError(msg)
 
         stitcher = DaskTileStitcher(
             tiles=well_acquisition.get_tiles(),
@@ -355,29 +364,28 @@ def _stitch_well_image(
             output_shape=output_shape,
             dtype=bool if build_acquisition_mask else well_acquisition.get_dtype(),
         )
-        image_da = stitcher.get_stitched_dask_array(
+        return stitcher.get_stitched_dask_array(
             warp_func=self._warp_func,
             fuse_func=(
                 stitching_utils.fuse_sum if build_acquisition_mask else self._fuse_func
             ),
             build_acquisition_mask=build_acquisition_mask,
         )
-        return image_da
 
     def _create_well_group(
-        self, plate, well_acquisition, well_sub_group, add_to_well_images=True
+        self, plate, well_acquisition, well_sub_group, *, add_to_well_images=True
     ):
         row, col = well_acquisition.get_row_col()
         well_group = plate.require_group(row).require_group(col)
         well_group.require_group(well_sub_group)
         if add_to_well_images:
             zattrs = well_group.attrs.asdict()
-            if "well" in zattrs.keys() and "images" in zattrs["well"].keys():
+            if "well" in zattrs and "images" in zattrs["well"]:
                 existing_images = well_group.attrs.asdict()["well"]["images"]
             else:
                 existing_images = []
             write_well_metadata(
-                well_group, existing_images + [{"path": well_sub_group}]
+                well_group, [*existing_images, {"path": well_sub_group}]
             )
         return well_group
 
@@ -388,18 +396,16 @@ def _get_storage_options(
         chunks: tuple[int, ...],
     ):
         if storage_options is None:
-            return dict(
-                dimension_separator="/",
-                compressor=Blosc(cname="zstd", clevel=3, shuffle=Blosc.SHUFFLE),
-                chunks=ConvertToNGFFPlate._out_chunks(output_shape, chunks),
-                write_empty_chunks=False,
-            )
-        else:
-            return storage_options
+            return {
+                "dimension_separator": "/",
+                "compressor": Blosc(cname="zstd", clevel=3, shuffle=Blosc.SHUFFLE),
+                "chunks": ConvertToNGFFPlate._out_chunks(output_shape, chunks),
+                "write_empty_chunks": False,
+            }
+        return storage_options
 
     @staticmethod
     def _out_chunks(shape, chunks):
         if len(shape) == len(chunks):
             return chunks
-        else:
-            return (1,) * (len(shape) - len(chunks)) + chunks
+        return (1,) * (len(shape) - len(chunks)) + chunks
diff --git a/src/faim_ipa/hcs/imagexpress/ImageXpressPlateAcquisition.py b/src/faim_ipa/hcs/imagexpress/ImageXpressPlateAcquisition.py
deleted file mode 100644
index 64aab1e6..00000000
--- a/src/faim_ipa/hcs/imagexpress/ImageXpressPlateAcquisition.py
+++ /dev/null
@@ -1,134 +0,0 @@
-import os
-import re
-from abc import abstractmethod
-from pathlib import Path
-from typing import Optional, Union
-
-import pandas as pd
-from tqdm import tqdm
-
-from faim_ipa.hcs.acquisition import (
-    PlateAcquisition,
-    TileAlignmentOptions,
-    WellAcquisition,
-)
-from faim_ipa.hcs.imagexpress.ImageXpressWellAcquisition import (
-    ImageXpressWellAcquisition,
-)
-from faim_ipa.io.ChannelMetadata import ChannelMetadata
-from faim_ipa.io.MetaSeriesTiff import load_metaseries_tiff_metadata
-from faim_ipa.utils import rgb_to_hex, wavelength_to_rgb
-
-
-class ImageXpressPlateAcquisition(PlateAcquisition):
-    def __init__(
-        self,
-        acquisition_dir: Union[Path, str],
-        alignment: TileAlignmentOptions,
-        background_correction_matrices: Optional[dict[str, Union[Path, str]]] = None,
-        illumination_correction_matrices: Optional[dict[str, Union[Path, str]]] = None,
-    ):
-        super().__init__(
-            acquisition_dir=acquisition_dir,
-            alignment=alignment,
-            background_correction_matrices=background_correction_matrices,
-            illumination_correction_matrices=illumination_correction_matrices,
-        )
-
-    def _parse_files(self) -> pd.DataFrame:
-        """Parse all files in the acquisition directory.
-
-        Returns
-        -------
-        DataFrame
-            Table of all files in the acquisition.
-        """
-        return pd.DataFrame(
-            ImageXpressPlateAcquisition._list_and_match_files(
-                root_dir=self._acquisition_dir,
-                root_re=self._get_root_re(),
-                filename_re=self._get_filename_re(),
-            )
-        )
-
-    @staticmethod
-    def _list_and_match_files(
-        root_dir: Union[Path, str],
-        root_re: re.Pattern,
-        filename_re: re.Pattern,
-    ) -> list[list[dict[dict, str]]]:
-        files = []
-        for root, _, filenames in os.walk(root_dir):
-            m_root = root_re.fullmatch(root)
-            if m_root:
-                for f in filenames:
-                    m_filename = filename_re.fullmatch(f)
-                    if m_filename:
-                        row = m_root.groupdict()
-                        row |= m_filename.groupdict()
-                        if "channel" not in row or row["channel"] is None:
-                            row["channel"] = "w1"
-                        row["path"] = str(Path(root).joinpath(f))
-                        files.append(row)
-        return files
-
-    @abstractmethod
-    def _get_root_re(self) -> re.Pattern:
-        """Regular expression for matching the root directory of the acquisition."""
-        raise NotImplementedError
-
-    @abstractmethod
-    def _get_filename_re(self) -> re.Pattern:
-        """Regular expression for matching the filename of the acquisition."""
-        raise NotImplementedError
-
-    def _build_well_acquisitions(self, files: pd.DataFrame) -> list[WellAcquisition]:
-        wells = []
-        for well in tqdm(files["well"].unique()):
-            wells.append(
-                ImageXpressWellAcquisition(
-                    files=files[files["well"] == well],
-                    alignment=self._alignment,
-                    z_spacing=self._get_z_spacing(),
-                    background_correction_matrices=self._background_correction_matrices,
-                    illumination_correction_matrices=self._illumination_correction_matrices,
-                )
-            )
-
-        return wells
-
-    @abstractmethod
-    def _get_z_spacing(self) -> Optional[float]:
-        raise NotImplementedError
-
-    def get_channel_metadata(self) -> dict[int, ChannelMetadata]:
-        ch_metadata = {}
-        _files = self._wells[0]._files
-        for ch in _files["channel"].unique():
-            channel_files = _files[_files["channel"] == ch]
-            path = channel_files["path"].iloc[0]
-            metadata = load_metaseries_tiff_metadata(path=path)
-            index = int(ch[1:]) - 1
-            if "Z Projection Method" in metadata.keys():
-                name = (
-                    f"{metadata['Z Projection Method'].replace(' ', '-')}-Projection_"
-                    f"{metadata['_IllumSetting_']}"
-                )
-            else:
-                name = metadata["_IllumSetting_"]
-            ch_metadata[index] = ChannelMetadata(
-                channel_index=index,
-                channel_name=name,
-                display_color=rgb_to_hex(*wavelength_to_rgb(metadata["wavelength"])),
-                spatial_calibration_x=metadata["spatial-calibration-x"],
-                spatial_calibration_y=metadata["spatial-calibration-y"],
-                spatial_calibration_units=metadata["spatial-calibration-units"],
-                z_spacing=self._get_z_spacing(),
-                wavelength=metadata["wavelength"],
-                exposure_time=float(metadata["Exposure Time"].split(" ")[0]),
-                exposure_time_unit=metadata["Exposure Time"].split(" ")[1],
-                objective=metadata["_MagSetting_"],
-            )
-
-        assert min(ch_metadata.keys()) == 0, "Channel indices must start at 0."
-        return ch_metadata
diff --git a/src/faim_ipa/hcs/imagexpress/ImageXpressWellAcquisition.py b/src/faim_ipa/hcs/imagexpress/ImageXpressWellAcquisition.py
deleted file mode 100644
index e9662530..00000000
--- a/src/faim_ipa/hcs/imagexpress/ImageXpressWellAcquisition.py
+++ /dev/null
@@ -1,91 +0,0 @@
-from pathlib import Path
-from typing import Optional, Union
-
-import pandas as pd
-
-from faim_ipa.hcs.acquisition import TileAlignmentOptions, WellAcquisition
-from faim_ipa.io.MetaSeriesTiff import load_metaseries_tiff_metadata
-from faim_ipa.stitching import Tile
-from faim_ipa.stitching.Tile import TilePosition
-
-
-class ImageXpressWellAcquisition(WellAcquisition):
-    def __init__(
-        self,
-        files: pd.DataFrame,
-        alignment: TileAlignmentOptions,
-        z_spacing: Optional[float],
-        background_correction_matrices: dict[str, Union[Path, str]] = None,
-        illumination_correction_matrices: dict[str, Union[Path, str]] = None,
-    ) -> None:
-        self._z_spacing = z_spacing
-        super().__init__(
-            files=files,
-            alignment=alignment,
-            background_correction_matrices=background_correction_matrices,
-            illumination_correction_matrices=illumination_correction_matrices,
-        )
-
-    def _assemble_tiles(self) -> list[Tile]:
-        tiles = []
-        for i, row in self._files.iterrows():
-            file = row["path"]
-            time_point = row["t"] if "t" in row.index and row["t"] is not None else 0
-            channel = row["channel"]
-            metadata = load_metaseries_tiff_metadata(file)
-            if self._z_spacing is None:
-                z = 1
-            else:
-                z = row["z"] if row["z"] is not None else 1
-
-            bgcm = None
-            if self._background_correction_matrices is not None:
-                bgcm = self._background_correction_matrices[channel]
-
-            icm = None
-            if self._illumination_correction_matrices is not None:
-                icm = self._illumination_correction_matrices[channel]
-
-            tiles.append(
-                Tile(
-                    path=file,
-                    shape=(metadata["pixel-size-y"], metadata["pixel-size-x"]),
-                    position=TilePosition(
-                        time=time_point,
-                        channel=int(channel[1:]),
-                        z=z,
-                        y=int(
-                            metadata["stage-position-y"]
-                            / metadata["spatial-calibration-y"]
-                        ),
-                        x=int(
-                            metadata["stage-position-x"]
-                            / metadata["spatial-calibration-x"]
-                        ),
-                    ),
-                    background_correction_matrix_path=bgcm,
-                    illumination_correction_matrix_path=icm,
-                )
-            )
-        return tiles
-
-    def get_yx_spacing(self) -> tuple[float, float]:
-        metadata = load_metaseries_tiff_metadata(self._files.iloc[0]["path"])
-        return (metadata["spatial-calibration-y"], metadata["spatial-calibration-x"])
-
-    def get_z_spacing(self) -> Optional[float]:
-        return self._z_spacing
-
-    def get_axes(self) -> list[str]:
-        axes = ["y", "x"]
-
-        if "z" in self._files.columns:
-            axes = ["z"] + axes
-
-        if self._files["channel"].nunique() > 1:
-            axes = ["c"] + axes
-
-        if "t" in self._files.columns and self._files["t"].nunique() > 1:
-            axes = ["t"] + axes
-
-        return axes
diff --git a/src/faim_ipa/hcs/imagexpress/MixedAcquisition.py b/src/faim_ipa/hcs/imagexpress/MixedAcquisition.py
deleted file mode 100644
index 251e1dc5..00000000
--- a/src/faim_ipa/hcs/imagexpress/MixedAcquisition.py
+++ /dev/null
@@ -1,85 +0,0 @@
-import re
-from pathlib import Path
-from typing import Optional, Union
-
-import pandas as pd
-from numpy._typing import NDArray
-
-from faim_ipa.hcs.acquisition import TileAlignmentOptions
-from faim_ipa.hcs.imagexpress import StackAcquisition
-
-
-class MixedAcquisition(StackAcquisition):
-    """Image stack acquisition with Projectsion acquired with a Molecular
-    Devices ImageXpress Micro Confocal system.
-
-    MIP-2P-2sub-Stack --> {name} [Optional]
-    └── 2023-02-21 --> {date}
-        └── 1334 --> {acquisition id}
-            ├── Projection-Mix_E07_s1_w1E94C24BD-45E4-450A-9919-257C714278F7.tif
-            ├── Projection-Mix_E07_s1_w1_thumb4BFD4018-E675-475E-B5AB-2E959E6B6DA1.tif
-            ├── ...
-            ├── Projection-Mix_E08_s2_w3CCE83D85-0912-429E-9F18-716A085BB5BC.tif
-            ├── Projection-Mix_E08_s2_w3_thumb4D88636E-181E-4AF6-BC53-E7A435959C8F.tif
-            ├── ZStep_1
-            │   ├── Projection-Mix_E07_s1_w1E78EB128-BD0D-4D94-A6AD-3FF28BB1B105.tif
-            │   ├── Projection-Mix_E07_s1_w1_thumb187DE64B-038A-4671-BF6B-683721723769.tif
-            │   ├── Projection-Mix_E07_s1_w2C0A49256-E289-4C0F-ADC9-F7728ABDB141.tif
-            │   ├── Projection-Mix_E07_s1_w2_thumb57D4B151-71BF-480E-8CC4-C23A2690B763.tif
-            │   ├── Projection-Mix_E07_s1_w427CCB2E4-1BF4-45E7-8BC7-264B48EF9C4A.tif
-            │   ├── Projection-Mix_E07_s1_w4_thumb555647D0-77F1-4A43-9472-AE509F95E236.tif
-            │   ├── ...
-            │   └── Projection-Mix_E08_s2_w4_thumbD2785594-4F49-464F-9F80-1B82E30A560A.tif
-            ├── ...
-            └── ZStep_9
-                ├── Projection-Mix_E07_s1_w1091EB8A5-272A-466D-B8A0-7547C6BA392B.tif
-                ├── ...
-                └── Projection-Mix_E08_s2_w2_thumb210C0D5D-C20E-484D-AFB2-EFE669A56B84.tif
-
-    Image data is stored in {name}_{well}_{field}_w{channel}{md_id}.tif.
-    The *_thumb*.tif files, used by Molecular Devices as preview, are ignored.
-    """
-
-    def __init__(
-        self,
-        acquisition_dir: Union[Path, str],
-        alignment: TileAlignmentOptions,
-        background_correction_matrix: Optional[dict[str, NDArray]] = None,
-        illumination_correction_matrix: Optional[NDArray] = None,
-    ):
-        super().__init__(
-            acquisition_dir=acquisition_dir,
-            alignment=alignment,
-            background_correction_matrices=background_correction_matrix,
-            illumination_correction_matrices=illumination_correction_matrix,
-        )
-
-    def _parse_files(self) -> pd.DataFrame:
-        files = self._filter_mips(super()._parse_files())
-        self._z_spacing = self._compute_z_spacing(files)
-        return files
-
-    def _filter_mips(self, files: pd.DataFrame) -> pd.DataFrame:
-        """Remove MIP files if the whole stack was acquired."""
-        _files = files.copy()
-        for ch in _files["channel"].unique():
-            channel_files = _files[_files["channel"] == ch]
-            z_positions = channel_files["z"].unique()
-            has_mip = None in z_positions
-            has_stack = len(z_positions) > 1
-            if has_mip and has_stack:
-                _files.drop(
-                    _files[(_files["channel"] == ch) & (_files["z"].isna())].index,
-                    inplace=True,
-                )
-        return _files
-
-    def _get_root_re(self) -> re.Pattern:
-        return re.compile(
-            r".*[\/\\](?P<date>\d{4}-\d{2}-\d{2})[\/\\](?P<acq_id>\d+)(?:[\/\\]TimePoint_(?P<t>\d+))?(?:[\/\\]ZStep_(?P<z>\d+))?.*"
-        )
-
-    def _get_filename_re(self) -> re.Pattern:
-        return re.compile(
-            r"(?P<name>.*)_(?P<well>[A-Z]+\d{2})_?(?P<field>s\d+)?_?(?P<channel>w[1-9]{1})?(?!_thumb)(?P<md_id>[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})(?P<ext>.tif)"
-        )
diff --git a/src/faim_ipa/hcs/imagexpress/SinglePlaneAcquisition.py b/src/faim_ipa/hcs/imagexpress/SinglePlaneAcquisition.py
deleted file mode 100644
index 16204199..00000000
--- a/src/faim_ipa/hcs/imagexpress/SinglePlaneAcquisition.py
+++ /dev/null
@@ -1,63 +0,0 @@
-import re
-from pathlib import Path
-from typing import Optional, Union
-
-from faim_ipa.hcs.acquisition import TileAlignmentOptions
-from faim_ipa.hcs.imagexpress import ImageXpressPlateAcquisition
-
-
-class SinglePlaneAcquisition(ImageXpressPlateAcquisition):
-    """Parse top folder (single planes) of an acquisition of a MolecularDevices ImageXpress Micro Confocal system.
-
-    Storage layout on disk for 2 wells with 2 fields and 2 channels::
-
-        MIP-2P-2sub --> {name} [Optional]
-        └── 2022-07-05 --> {date}
-            └── 1075 --> {acquisition id}
-                ├── MIP-2P-2sub_C05_s1_w146C9B2CD-0BB3-4B8A-9187-2805F4C90506.tif
-                ├── MIP-2P-2sub_C05_s1_w1_thumb6EFE77C6-B96D-412A-9FD1-710DBDA32821.tif
-                ├── MIP-2P-2sub_C05_s1_w2B90625C8-6EA7-4E54-8289-C539EB75263E.tif
-                ├── MIP-2P-2sub_C05_s1_w2_thumbEDDF803A-AE5E-4190-8C06-F54341AEC4A6.tif
-                ├── MIP-2P-2sub_C05_s2_w1E2913F7F-E229-4B6A-BFED-02BCF54561FA.tif
-                ├── MIP-2P-2sub_C05_s2_w1_thumb72E3641A-C91B-4501-900A-245BAC58FF46.tif
-                ├── MIP-2P-2sub_C05_s2_w241C38630-BCFD-4393-8706-58755CECE059.tif
-                ├── MIP-2P-2sub_C05_s2_w2_thumb5377A5AC-9BBF-4BAF-99A2-24896E3373A2.tif
-                ├── MIP-2P-2sub_C06_s1_w152C23B9A-EB4C-4DF6-8A7F-F4147A9E7DDE.tif
-                ├── MIP-2P-2sub_C06_s1_w1_thumb541AA634-387C-4B84-B0D8-EE4CB1C88E81.tif
-                ├── MIP-2P-2sub_C06_s1_w2FB0D7D9B-3EA0-445E-9A05-7D01154A9A5C.tif
-                ├── MIP-2P-2sub_C06_s1_w2_thumb8FA1E466-57CD-4237-B09B-CAB48154647D.tif
-                ├── MIP-2P-2sub_C06_s2_w1F365E60C-BCC2-4B74-9856-BCE07C8B0FD3.tif
-                ├── MIP-2P-2sub_C06_s2_w1_thumb9652366E-36A0-4B7F-8B18-DA89D7DB41BD.tif
-                ├── MIP-2P-2sub_C06_s2_w20EEC6AEA-1727-41E6-806C-40FF6AF68B6C.tif
-                └── MIP-2P-2sub_C06_s2_w2_thumb710CD846-0185-4362-BBAF-C700AE0013B3.tif
-
-    Image data is stored in {name}_{well}_{field}_w{channel}{md_id}.tif.
-    The *_thumb*.tif files, used by Molecular Devices as preview, are ignored.
-    """
-
-    def __init__(
-        self,
-        acquisition_dir: Union[Path, str],
-        alignment: TileAlignmentOptions,
-        background_correction_matrices: Optional[dict[str, Union[Path, str]]] = None,
-        illumination_correction_matrices: Optional[dict[str, Union[Path, str]]] = None,
-    ):
-        super().__init__(
-            acquisition_dir=acquisition_dir,
-            alignment=alignment,
-            background_correction_matrices=background_correction_matrices,
-            illumination_correction_matrices=illumination_correction_matrices,
-        )
-
-    def _get_root_re(self) -> re.Pattern:
-        return re.compile(
-            r".*[\/\\](?P<date>\d{4}-\d{2}-\d{2})[\/\\](?P<acq_id>\d+)(?:[\/\\]TimePoint_(?P<t>\d+))?"
-        )
-
-    def _get_filename_re(self) -> re.Pattern:
-        return re.compile(
-            r"(?P<name>.*)_(?P<well>[A-Z]+\d{2})_?(?P<field>s\d+)?_?(?P<channel>w[1-9]{1})?(?!_thumb)(?P<md_id>[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})(?P<ext>.tif)"
-        )
-
-    def _get_z_spacing(self) -> Optional[float]:
-        return None
diff --git a/src/faim_ipa/hcs/imagexpress/StackAcquisition.py b/src/faim_ipa/hcs/imagexpress/StackAcquisition.py
deleted file mode 100644
index e57a30f1..00000000
--- a/src/faim_ipa/hcs/imagexpress/StackAcquisition.py
+++ /dev/null
@@ -1,96 +0,0 @@
-import re
-from decimal import Decimal
-from pathlib import Path
-from typing import Optional, Union
-
-import numpy as np
-import pandas as pd
-
-from faim_ipa.hcs.acquisition import TileAlignmentOptions
-from faim_ipa.hcs.imagexpress import ImageXpressPlateAcquisition
-from faim_ipa.io.MetaSeriesTiff import load_metaseries_tiff_metadata
-
-
-class StackAcquisition(ImageXpressPlateAcquisition):
-    """Image stack acquisition with a Molecular Devices ImageXpress Micro
-    Confocal system.
-
-    MIP-2P-2sub-Stack --> {name} [Optional]
-    └── 2023-02-21 --> {date}
-        └── 1334 --> {acquisition id}
-            ├── ZStep_1
-            │   ├── Projection-Mix_E07_s1_w1E78EB128-BD0D-4D94-A6AD-3FF28BB1B105.tif
-            │   ├── Projection-Mix_E07_s1_w1_thumb187DE64B-038A-4671-BF6B-683721723769.tif
-            │   ├── Projection-Mix_E07_s1_w2C0A49256-E289-4C0F-ADC9-F7728ABDB141.tif
-            │   ├── Projection-Mix_E07_s1_w2_thumb57D4B151-71BF-480E-8CC4-C23A2690B763.tif
-            │   ├── Projection-Mix_E07_s1_w427CCB2E4-1BF4-45E7-8BC7-264B48EF9C4A.tif
-            │   ├── Projection-Mix_E07_s1_w4_thumb555647D0-77F1-4A43-9472-AE509F95E236.tif
-            │   ├── ...
-            │   └── Projection-Mix_E08_s2_w4_thumbD2785594-4F49-464F-9F80-1B82E30A560A.tif
-            ├── ...
-            └── ZStep_9
-                ├── Projection-Mix_E07_s1_w1091EB8A5-272A-466D-B8A0-7547C6BA392B.tif
-                ├── ...
-                └── Projection-Mix_E08_s2_w2_thumb210C0D5D-C20E-484D-AFB2-EFE669A56B84.tif
-
-    Image data is stored in {name}_{well}_{field}_w{channel}{md_id}.tif.
-    The *_thumb*.tif files, used by Molecular Devices as preview, are ignored.
-    """
-
-    _z_spacing: float = None
-
-    def __init__(
-        self,
-        acquisition_dir: Union[Path, str],
-        alignment: TileAlignmentOptions,
-        background_correction_matrices: Optional[dict[str, Union[Path, str]]] = None,
-        illumination_correction_matrices: Optional[dict[str, Union[Path, str]]] = None,
-    ):
-        super().__init__(
-            acquisition_dir=acquisition_dir,
-            alignment=alignment,
-            background_correction_matrices=background_correction_matrices,
-            illumination_correction_matrices=illumination_correction_matrices,
-        )
-
-    def _parse_files(self) -> pd.DataFrame:
-        files = super()._parse_files()
-        self._z_spacing = self._compute_z_spacing(files)
-        return files
-
-    def _get_root_re(self) -> re.Pattern:
-        return re.compile(
-            r".*[\/\\](?P<date>\d{4}-\d{2}-\d{2})[\/\\](?P<acq_id>\d+)(?:[\/\\]TimePoint_(?P<t>\d+))?(?:[\/\\]ZStep_(?P<z>\d+))"
-        )
-
-    def _get_filename_re(self) -> re.Pattern:
-        return re.compile(
-            r"(?P<name>.*)_(?P<well>[A-Z]+\d{2})_?(?P<field>s\d+)?_?(?P<channel>w[1-9]{1})?(?!_thumb)(?P<md_id>[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})(?P<ext>.tif)"
-        )
-
-    def _get_z_spacing(self) -> Optional[float]:
-        return self._z_spacing
-
-    def _compute_z_spacing(self, files: pd.DataFrame) -> Optional[float]:
-        assert "z" in files.columns, "No z column in files DataFrame."
-        channel_with_stack = np.sort(files[files["z"] == "2"]["channel"].unique())[0]
-        subset = files[files["channel"] == channel_with_stack]
-        subset = subset[subset["well"] == np.sort(subset["well"].unique())[0]]
-        first_field = np.sort(subset["field"].unique())[0]
-        if first_field is not None:
-            subset = subset[subset["field"] == np.sort(subset["field"].unique())[0]]
-
-        plane_positions = []
-
-        for i, row in subset.iterrows():
-            file = row["path"]
-            if "z" in row.keys() and row["z"] is not None:
-                metadata = load_metaseries_tiff_metadata(file)
-                z_position = metadata["stage-position-z"]
-                plane_positions.append(z_position)
-
-        plane_positions = np.array(sorted(plane_positions), dtype=np.float32)
-
-        precision = -Decimal(str(plane_positions[0])).as_tuple().exponent
-        z_step = np.round(np.mean(np.diff(plane_positions)), decimals=precision)
-        return z_step
diff --git a/src/faim_ipa/hcs/imagexpress/__init__.py b/src/faim_ipa/hcs/imagexpress/__init__.py
index e5f00530..b08a768d 100644
--- a/src/faim_ipa/hcs/imagexpress/__init__.py
+++ b/src/faim_ipa/hcs/imagexpress/__init__.py
@@ -1,5 +1,15 @@
-from .ImageXpressPlateAcquisition import ImageXpressPlateAcquisition  # noqa: F401
-from .ImageXpressWellAcquisition import ImageXpressWellAcquisition  # noqa: F401
-from .SinglePlaneAcquisition import SinglePlaneAcquisition  # noqa: F401
-from .StackAcquisition import StackAcquisition  # noqa: F401
-from .MixedAcquisition import MixedAcquisition  # noqa: F401
+from faim_ipa.hcs.imagexpress.acquisition import (
+    ImageXpressPlateAcquisition,
+    ImageXpressWellAcquisition,
+    MixedAcquisition,
+    SinglePlaneAcquisition,
+    StackAcquisition,
+)
+
+__all__ = [
+    "ImageXpressPlateAcquisition",
+    "ImageXpressWellAcquisition",
+    "MixedAcquisition",
+    "SinglePlaneAcquisition",
+    "StackAcquisition",
+]
diff --git a/src/faim_ipa/hcs/imagexpress/acquisition.py b/src/faim_ipa/hcs/imagexpress/acquisition.py
new file mode 100644
index 00000000..80c818e7
--- /dev/null
+++ b/src/faim_ipa/hcs/imagexpress/acquisition.py
@@ -0,0 +1,431 @@
+import os
+import re
+from abc import abstractmethod
+from decimal import Decimal
+from pathlib import Path
+
+import numpy as np
+import pandas as pd
+from numpy._typing import NDArray
+from tqdm import tqdm
+
+from faim_ipa.hcs.acquisition import (
+    PlateAcquisition,
+    TileAlignmentOptions,
+    WellAcquisition,
+)
+from faim_ipa.io.metadata import ChannelMetadata
+from faim_ipa.io.metaseries import load_metaseries_tiff_metadata
+from faim_ipa.stitching.tile import Tile, TilePosition
+from faim_ipa.utils import rgb_to_hex, wavelength_to_rgb
+
+
+class ImageXpressWellAcquisition(WellAcquisition):
+    def __init__(
+        self,
+        files: pd.DataFrame,
+        alignment: TileAlignmentOptions,
+        z_spacing: float | None,
+        background_correction_matrices: dict[str, Path | str] | None = None,
+        illumination_correction_matrices: dict[str, Path | str] | None = None,
+    ) -> None:
+        self._z_spacing = z_spacing
+        super().__init__(
+            files=files,
+            alignment=alignment,
+            background_correction_matrices=background_correction_matrices,
+            illumination_correction_matrices=illumination_correction_matrices,
+        )
+
+    def _assemble_tiles(self) -> list[Tile]:
+        tiles = []
+        for _i, row in self._files.iterrows():
+            file = row["path"]
+            time_point = row["t"] if "t" in row.index and row["t"] is not None else 0
+            channel = row["channel"]
+            metadata = load_metaseries_tiff_metadata(file)
+            z = (
+                1
+                if self._z_spacing is None
+                else row["z"] if row["z"] is not None else 1
+            )
+
+            bgcm = None
+            if self._background_correction_matrices is not None:
+                bgcm = self._background_correction_matrices[channel]
+
+            icm = None
+            if self._illumination_correction_matrices is not None:
+                icm = self._illumination_correction_matrices[channel]
+
+            tiles.append(
+                Tile(
+                    path=file,
+                    shape=(metadata["pixel-size-y"], metadata["pixel-size-x"]),
+                    position=TilePosition(
+                        time=time_point,
+                        channel=int(channel[1:]),
+                        z=z,
+                        y=int(
+                            metadata["stage-position-y"]
+                            / metadata["spatial-calibration-y"]
+                        ),
+                        x=int(
+                            metadata["stage-position-x"]
+                            / metadata["spatial-calibration-x"]
+                        ),
+                    ),
+                    background_correction_matrix_path=bgcm,
+                    illumination_correction_matrix_path=icm,
+                )
+            )
+        return tiles
+
+    def get_yx_spacing(self) -> tuple[float, float]:
+        metadata = load_metaseries_tiff_metadata(self._files.iloc[0]["path"])
+        return (metadata["spatial-calibration-y"], metadata["spatial-calibration-x"])
+
+    def get_z_spacing(self) -> float | None:
+        return self._z_spacing
+
+    def get_axes(self) -> list[str]:
+        axes = ["y", "x"]
+
+        if "z" in self._files.columns:
+            axes = ["z", *axes]
+
+        if self._files["channel"].nunique() > 1:
+            axes = ["c", *axes]
+
+        if "t" in self._files.columns and self._files["t"].nunique() > 1:
+            axes = ["t", *axes]
+
+        return axes
+
+
+class ImageXpressPlateAcquisition(PlateAcquisition):
+    def __init__(
+        self,
+        acquisition_dir: Path | str,
+        alignment: TileAlignmentOptions,
+        background_correction_matrices: dict[str, Path | str] | None = None,
+        illumination_correction_matrices: dict[str, Path | str] | None = None,
+    ):
+        super().__init__(
+            acquisition_dir=acquisition_dir,
+            alignment=alignment,
+            background_correction_matrices=background_correction_matrices,
+            illumination_correction_matrices=illumination_correction_matrices,
+        )
+
+    def _parse_files(self) -> pd.DataFrame:
+        """Parse all files in the acquisition directory.
+
+        Returns
+        -------
+        DataFrame
+            Table of all files in the acquisition.
+        """
+        return pd.DataFrame(
+            ImageXpressPlateAcquisition._list_and_match_files(
+                root_dir=self._acquisition_dir,
+                root_re=self._get_root_re(),
+                filename_re=self._get_filename_re(),
+            )
+        )
+
+    @staticmethod
+    def _list_and_match_files(
+        root_dir: Path | str,
+        root_re: re.Pattern,
+        filename_re: re.Pattern,
+    ) -> list[list[dict[dict, str]]]:
+        files = []
+        for root, _, filenames in os.walk(root_dir):
+            m_root = root_re.fullmatch(root)
+            if m_root:
+                for f in filenames:
+                    m_filename = filename_re.fullmatch(f)
+                    if m_filename:
+                        row = m_root.groupdict()
+                        row |= m_filename.groupdict()
+                        if "channel" not in row or row["channel"] is None:
+                            row["channel"] = "w1"
+                        row["path"] = str(Path(root).joinpath(f))
+                        files.append(row)
+        return files
+
+    @abstractmethod
+    def _get_root_re(self) -> re.Pattern:
+        """Regular expression for matching the root directory of the acquisition."""
+        raise NotImplementedError
+
+    @abstractmethod
+    def _get_filename_re(self) -> re.Pattern:
+        """Regular expression for matching the filename of the acquisition."""
+        raise NotImplementedError
+
+    def _build_well_acquisitions(self, files: pd.DataFrame) -> list[WellAcquisition]:
+        return [
+            ImageXpressWellAcquisition(
+                files=files[files["well"] == well],
+                alignment=self._alignment,
+                z_spacing=self._get_z_spacing(),
+                background_correction_matrices=self._background_correction_matrices,
+                illumination_correction_matrices=self._illumination_correction_matrices,
+            )
+            for well in tqdm(files["well"].unique())
+        ]
+
+    @abstractmethod
+    def _get_z_spacing(self) -> float | None:
+        raise NotImplementedError
+
+    def get_channel_metadata(self) -> dict[int, ChannelMetadata]:
+        ch_metadata = {}
+        _files = self._wells[0]._files
+        for ch in _files["channel"].unique():
+            channel_files = _files[_files["channel"] == ch]
+            path = channel_files["path"].iloc[0]
+            metadata = load_metaseries_tiff_metadata(path=path)
+            index = int(ch[1:]) - 1
+            if "Z Projection Method" in metadata:
+                name = (
+                    f"{metadata['Z Projection Method'].replace(' ', '-')}-Projection_"
+                    f"{metadata['_IllumSetting_']}"
+                )
+            else:
+                name = metadata["_IllumSetting_"]
+            ch_metadata[index] = ChannelMetadata(
+                channel_index=index,
+                channel_name=name,
+                display_color=rgb_to_hex(*wavelength_to_rgb(metadata["wavelength"])),
+                spatial_calibration_x=metadata["spatial-calibration-x"],
+                spatial_calibration_y=metadata["spatial-calibration-y"],
+                spatial_calibration_units=metadata["spatial-calibration-units"],
+                z_spacing=self._get_z_spacing(),
+                wavelength=metadata["wavelength"],
+                exposure_time=float(metadata["Exposure Time"].split(" ")[0]),
+                exposure_time_unit=metadata["Exposure Time"].split(" ")[1],
+                objective=metadata["_MagSetting_"],
+            )
+
+        assert min(ch_metadata.keys()) == 0, "Channel indices must start at 0."
+        return ch_metadata
+
+
+class SinglePlaneAcquisition(ImageXpressPlateAcquisition):
+    """Parse top folder (single planes) of an acquisition of a MolecularDevices ImageXpress Micro Confocal system.
+
+    Storage layout on disk for 2 wells with 2 fields and 2 channels::
+
+        MIP-2P-2sub --> {name} [Optional]
+        └── 2022-07-05 --> {date}
+            └── 1075 --> {acquisition id}
+                ├── MIP-2P-2sub_C05_s1_w146C9B2CD-0BB3-4B8A-9187-2805F4C90506.tif
+                ├── MIP-2P-2sub_C05_s1_w1_thumb6EFE77C6-B96D-412A-9FD1-710DBDA32821.tif
+                ├── MIP-2P-2sub_C05_s1_w2B90625C8-6EA7-4E54-8289-C539EB75263E.tif
+                ├── MIP-2P-2sub_C05_s1_w2_thumbEDDF803A-AE5E-4190-8C06-F54341AEC4A6.tif
+                ├── MIP-2P-2sub_C05_s2_w1E2913F7F-E229-4B6A-BFED-02BCF54561FA.tif
+                ├── MIP-2P-2sub_C05_s2_w1_thumb72E3641A-C91B-4501-900A-245BAC58FF46.tif
+                ├── MIP-2P-2sub_C05_s2_w241C38630-BCFD-4393-8706-58755CECE059.tif
+                ├── MIP-2P-2sub_C05_s2_w2_thumb5377A5AC-9BBF-4BAF-99A2-24896E3373A2.tif
+                ├── MIP-2P-2sub_C06_s1_w152C23B9A-EB4C-4DF6-8A7F-F4147A9E7DDE.tif
+                ├── MIP-2P-2sub_C06_s1_w1_thumb541AA634-387C-4B84-B0D8-EE4CB1C88E81.tif
+                ├── MIP-2P-2sub_C06_s1_w2FB0D7D9B-3EA0-445E-9A05-7D01154A9A5C.tif
+                ├── MIP-2P-2sub_C06_s1_w2_thumb8FA1E466-57CD-4237-B09B-CAB48154647D.tif
+                ├── MIP-2P-2sub_C06_s2_w1F365E60C-BCC2-4B74-9856-BCE07C8B0FD3.tif
+                ├── MIP-2P-2sub_C06_s2_w1_thumb9652366E-36A0-4B7F-8B18-DA89D7DB41BD.tif
+                ├── MIP-2P-2sub_C06_s2_w20EEC6AEA-1727-41E6-806C-40FF6AF68B6C.tif
+                └── MIP-2P-2sub_C06_s2_w2_thumb710CD846-0185-4362-BBAF-C700AE0013B3.tif
+
+    Image data is stored in {name}_{well}_{field}_w{channel}{md_id}.tif.
+    The *_thumb*.tif files, used by Molecular Devices as preview, are ignored.
+    """
+
+    def __init__(
+        self,
+        acquisition_dir: Path | str,
+        alignment: TileAlignmentOptions,
+        background_correction_matrices: dict[str, Path | str] | None = None,
+        illumination_correction_matrices: dict[str, Path | str] | None = None,
+    ):
+        super().__init__(
+            acquisition_dir=acquisition_dir,
+            alignment=alignment,
+            background_correction_matrices=background_correction_matrices,
+            illumination_correction_matrices=illumination_correction_matrices,
+        )
+
+    def _get_root_re(self) -> re.Pattern:
+        return re.compile(
+            r".*[\/\\](?P<date>\d{4}-\d{2}-\d{2})[\/\\](?P<acq_id>\d+)(?:[\/\\]TimePoint_(?P<t>\d+))?"
+        )
+
+    def _get_filename_re(self) -> re.Pattern:
+        return re.compile(
+            r"(?P<name>.*)_(?P<well>[A-Z]+\d{2})_?(?P<field>s\d+)?_?(?P<channel>w[1-9]{1})?(?!_thumb)(?P<md_id>[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})(?P<ext>.tif)"
+        )
+
+    def _get_z_spacing(self) -> float | None:
+        return None
+
+
+class StackAcquisition(ImageXpressPlateAcquisition):
+    """Image stack acquisition with a Molecular Devices ImageXpress Micro
+    Confocal system.
+
+    MIP-2P-2sub-Stack --> {name} [Optional]
+    └── 2023-02-21 --> {date}
+        └── 1334 --> {acquisition id}
+            ├── ZStep_1
+            │   ├── Projection-Mix_E07_s1_w1E78EB128-BD0D-4D94-A6AD-3FF28BB1B105.tif
+            │   ├── Projection-Mix_E07_s1_w1_thumb187DE64B-038A-4671-BF6B-683721723769.tif
+            │   ├── Projection-Mix_E07_s1_w2C0A49256-E289-4C0F-ADC9-F7728ABDB141.tif
+            │   ├── Projection-Mix_E07_s1_w2_thumb57D4B151-71BF-480E-8CC4-C23A2690B763.tif
+            │   ├── Projection-Mix_E07_s1_w427CCB2E4-1BF4-45E7-8BC7-264B48EF9C4A.tif
+            │   ├── Projection-Mix_E07_s1_w4_thumb555647D0-77F1-4A43-9472-AE509F95E236.tif
+            │   ├── ...
+            │   └── Projection-Mix_E08_s2_w4_thumbD2785594-4F49-464F-9F80-1B82E30A560A.tif
+            ├── ...
+            └── ZStep_9
+                ├── Projection-Mix_E07_s1_w1091EB8A5-272A-466D-B8A0-7547C6BA392B.tif
+                ├── ...
+                └── Projection-Mix_E08_s2_w2_thumb210C0D5D-C20E-484D-AFB2-EFE669A56B84.tif
+
+    Image data is stored in {name}_{well}_{field}_w{channel}{md_id}.tif.
+    The *_thumb*.tif files, used by Molecular Devices as preview, are ignored.
+    """
+
+    _z_spacing: float = None
+
+    def __init__(
+        self,
+        acquisition_dir: Path | str,
+        alignment: TileAlignmentOptions,
+        background_correction_matrices: dict[str, Path | str] | None = None,
+        illumination_correction_matrices: dict[str, Path | str] | None = None,
+    ):
+        super().__init__(
+            acquisition_dir=acquisition_dir,
+            alignment=alignment,
+            background_correction_matrices=background_correction_matrices,
+            illumination_correction_matrices=illumination_correction_matrices,
+        )
+
+    def _parse_files(self) -> pd.DataFrame:
+        files = super()._parse_files()
+        self._z_spacing = self._compute_z_spacing(files)
+        return files
+
+    def _get_root_re(self) -> re.Pattern:
+        return re.compile(
+            r".*[\/\\](?P<date>\d{4}-\d{2}-\d{2})[\/\\](?P<acq_id>\d+)(?:[\/\\]TimePoint_(?P<t>\d+))?(?:[\/\\]ZStep_(?P<z>\d+))"
+        )
+
+    def _get_filename_re(self) -> re.Pattern:
+        return re.compile(
+            r"(?P<name>.*)_(?P<well>[A-Z]+\d{2})_?(?P<field>s\d+)?_?(?P<channel>w[1-9]{1})?(?!_thumb)(?P<md_id>[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})(?P<ext>.tif)"
+        )
+
+    def _get_z_spacing(self) -> float | None:
+        return self._z_spacing
+
+    def _compute_z_spacing(self, files: pd.DataFrame) -> float | None:
+        assert "z" in files.columns, "No z column in files DataFrame."
+        channel_with_stack = np.sort(files[files["z"] == "2"]["channel"].unique())[0]
+        subset = files[files["channel"] == channel_with_stack]
+        subset = subset[subset["well"] == np.sort(subset["well"].unique())[0]]
+        first_field = np.sort(subset["field"].unique())[0]
+        if first_field is not None:
+            subset = subset[subset["field"] == np.sort(subset["field"].unique())[0]]
+
+        plane_positions = []
+
+        for _i, row in subset.iterrows():
+            file = row["path"]
+            if "z" in row and row["z"] is not None:
+                metadata = load_metaseries_tiff_metadata(file)
+                z_position = metadata["stage-position-z"]
+                plane_positions.append(z_position)
+
+        plane_positions = np.array(sorted(plane_positions), dtype=np.float32)
+
+        precision = -Decimal(str(plane_positions[0])).as_tuple().exponent
+        return np.round(np.mean(np.diff(plane_positions)), decimals=precision)
+
+
+class MixedAcquisition(StackAcquisition):
+    """Image stack acquisition with Projectsion acquired with a Molecular
+    Devices ImageXpress Micro Confocal system.
+
+    MIP-2P-2sub-Stack --> {name} [Optional]
+    └── 2023-02-21 --> {date}
+        └── 1334 --> {acquisition id}
+            ├── Projection-Mix_E07_s1_w1E94C24BD-45E4-450A-9919-257C714278F7.tif
+            ├── Projection-Mix_E07_s1_w1_thumb4BFD4018-E675-475E-B5AB-2E959E6B6DA1.tif
+            ├── ...
+            ├── Projection-Mix_E08_s2_w3CCE83D85-0912-429E-9F18-716A085BB5BC.tif
+            ├── Projection-Mix_E08_s2_w3_thumb4D88636E-181E-4AF6-BC53-E7A435959C8F.tif
+            ├── ZStep_1
+            │   ├── Projection-Mix_E07_s1_w1E78EB128-BD0D-4D94-A6AD-3FF28BB1B105.tif
+            │   ├── Projection-Mix_E07_s1_w1_thumb187DE64B-038A-4671-BF6B-683721723769.tif
+            │   ├── Projection-Mix_E07_s1_w2C0A49256-E289-4C0F-ADC9-F7728ABDB141.tif
+            │   ├── Projection-Mix_E07_s1_w2_thumb57D4B151-71BF-480E-8CC4-C23A2690B763.tif
+            │   ├── Projection-Mix_E07_s1_w427CCB2E4-1BF4-45E7-8BC7-264B48EF9C4A.tif
+            │   ├── Projection-Mix_E07_s1_w4_thumb555647D0-77F1-4A43-9472-AE509F95E236.tif
+            │   ├── ...
+            │   └── Projection-Mix_E08_s2_w4_thumbD2785594-4F49-464F-9F80-1B82E30A560A.tif
+            ├── ...
+            └── ZStep_9
+                ├── Projection-Mix_E07_s1_w1091EB8A5-272A-466D-B8A0-7547C6BA392B.tif
+                ├── ...
+                └── Projection-Mix_E08_s2_w2_thumb210C0D5D-C20E-484D-AFB2-EFE669A56B84.tif
+
+    Image data is stored in {name}_{well}_{field}_w{channel}{md_id}.tif.
+    The *_thumb*.tif files, used by Molecular Devices as preview, are ignored.
+    """
+
+    def __init__(
+        self,
+        acquisition_dir: Path | str,
+        alignment: TileAlignmentOptions,
+        background_correction_matrix: dict[str, NDArray] | None = None,
+        illumination_correction_matrix: NDArray | None = None,
+    ):
+        super().__init__(
+            acquisition_dir=acquisition_dir,
+            alignment=alignment,
+            background_correction_matrices=background_correction_matrix,
+            illumination_correction_matrices=illumination_correction_matrix,
+        )
+
+    def _parse_files(self) -> pd.DataFrame:
+        files = self._filter_mips(super()._parse_files())
+        self._z_spacing = self._compute_z_spacing(files)
+        return files
+
+    def _filter_mips(self, files: pd.DataFrame) -> pd.DataFrame:
+        """Remove MIP files if the whole stack was acquired."""
+        _files = files.copy()
+        for ch in _files["channel"].unique():
+            channel_files = _files[_files["channel"] == ch]
+            z_positions = channel_files["z"].unique()
+            has_mip = None in z_positions
+            has_stack = len(z_positions) > 1
+            if has_mip and has_stack:
+                _files.drop(
+                    _files[(_files["channel"] == ch) & (_files["z"].isna())].index,
+                    inplace=True,
+                )
+        return _files
+
+    def _get_root_re(self) -> re.Pattern:
+        return re.compile(
+            r".*[\/\\](?P<date>\d{4}-\d{2}-\d{2})[\/\\](?P<acq_id>\d+)(?:[\/\\]TimePoint_(?P<t>\d+))?(?:[\/\\]ZStep_(?P<z>\d+))?.*"
+        )
+
+    def _get_filename_re(self) -> re.Pattern:
+        return re.compile(
+            r"(?P<name>.*)_(?P<well>[A-Z]+\d{2})_?(?P<field>s\d+)?_?(?P<channel>w[1-9]{1})?(?!_thumb)(?P<md_id>[0-9A-F]{8}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{4}-[0-9A-F]{12})(?P<ext>.tif)"
+        )
diff --git a/src/faim_ipa/hcs/plate.py b/src/faim_ipa/hcs/plate.py
index c579f822..66230694 100644
--- a/src/faim_ipa/hcs/plate.py
+++ b/src/faim_ipa/hcs/plate.py
@@ -1,5 +1,4 @@
 from enum import IntEnum
-from typing import Union
 
 
 class PlateLayout(IntEnum):
@@ -11,44 +10,23 @@ class PlateLayout(IntEnum):
     I384 = 384
 
 
-def get_rows_and_columns(
-    layout: Union[PlateLayout, int]
-) -> tuple[list[str], list[str]]:
+def get_rows_and_columns(layout: PlateLayout | int) -> tuple[list[str], list[str]]:
     """Return rows and columns for requested layout."""
     if layout == PlateLayout.I18:
-        rows = ["A", "B", "C"]
+        rows = list("ABC")
         cols = [str(i).zfill(2) for i in range(1, 7)]
-        assert len(rows) * len(cols) == 18
     elif layout == PlateLayout.I24:
-        rows = ["A", "B", "C", "D"]
+        rows = list("ABCD")
         cols = [str(i).zfill(2) for i in range(1, 7)]
-        assert len(rows) * len(cols) == 24
     elif layout == PlateLayout.I96:
-        rows = ["A", "B", "C", "D", "E", "F", "G", "H"]
+        rows = list("ABCDEFGH")
         cols = [str(i).zfill(2) for i in range(1, 13)]
-        assert len(rows) * len(cols) == 96
     elif layout == PlateLayout.I384:
-        rows = [
-            "A",
-            "B",
-            "C",
-            "D",
-            "E",
-            "F",
-            "G",
-            "H",
-            "I",
-            "J",
-            "K",
-            "L",
-            "M",
-            "N",
-            "O",
-            "P",
-        ]
+        rows = list("ABCDEFGHIJKLMNOP")
         cols = [str(i).zfill(2) for i in range(1, 25)]
-        assert len(rows) * len(cols) == 384
     else:
-        raise NotImplementedError(f"{layout} layout not supported.")
+        msg = f"{layout} layout not supported."
+        raise NotImplementedError(msg)
 
+    assert len(rows) * len(cols) == layout
     return rows, cols
diff --git a/src/faim_ipa/UIntHistogram.py b/src/faim_ipa/histogram.py
similarity index 93%
rename from src/faim_ipa/UIntHistogram.py
rename to src/faim_ipa/histogram.py
index cd673587..f2e918c4 100644
--- a/src/faim_ipa/UIntHistogram.py
+++ b/src/faim_ipa/histogram.py
@@ -25,7 +25,7 @@ def _add(list_a, list_b):
         :param list_b: list(numeric)
         :return: list(numeric)
         """
-        return list(map(lambda e: e[0] + e[1], zip(list_a, list_b)))
+        return [e[0] + e[1] for e in zip(list_a, list_b, strict=False)]
 
     @staticmethod
     def _get_hist(data):
@@ -151,13 +151,12 @@ def combine(self, histogram):
         if self.frequencies is None:
             self.frequencies = histogram.frequencies
             self.offset = histogram.offset
-        else:
-            if histogram.frequencies is not None:
-                self._aggregate_histograms(
-                    offset_data=histogram.offset,
-                    bins=histogram.n_bins(),
-                    freq=histogram.frequencies,
-                )
+        elif histogram.frequencies is not None:
+            self._aggregate_histograms(
+                offset_data=histogram.offset,
+                bins=histogram.n_bins(),
+                freq=histogram.frequencies,
+            )
 
         return self
 
@@ -183,9 +182,10 @@ def plot(self, width=1):
         Plot histogram.
         """
         if width > 1:
-            heights = []
-            for i in range(self.offset, self.offset + self.n_bins(), width):
-                heights.append(np.sum(self.frequencies[i : i + width]))
+            heights = [
+                np.sum(self.frequencies[i : i + width])
+                for i in range(self.offset, self.offset + self.n_bins(), width)
+            ]
         else:
             heights = self.frequencies
 
@@ -228,7 +228,8 @@ def quantile(self, q):
         :param q: uint
         :return: quantile
         """
-        assert q >= 0 and q <= 1
+        assert q >= 0
+        assert q <= 1
         if self.frequencies is None:
             return 0
         return self.offset + np.argmax(
diff --git a/src/faim_ipa/io/ChannelMetadata.py b/src/faim_ipa/io/metadata.py
similarity index 58%
rename from src/faim_ipa/io/ChannelMetadata.py
rename to src/faim_ipa/io/metadata.py
index 49bc9768..163e472e 100644
--- a/src/faim_ipa/io/ChannelMetadata.py
+++ b/src/faim_ipa/io/metadata.py
@@ -1,5 +1,3 @@
-from typing import Optional
-
 from pydantic import BaseModel, NonNegativeInt, PositiveFloat
 
 
@@ -10,8 +8,8 @@ class ChannelMetadata(BaseModel):
     spatial_calibration_x: PositiveFloat
     spatial_calibration_y: PositiveFloat
     spatial_calibration_units: str
-    z_spacing: Optional[PositiveFloat] = None
-    wavelength: Optional[NonNegativeInt] = None
-    exposure_time: Optional[PositiveFloat] = None
-    exposure_time_unit: Optional[str] = None
+    z_spacing: PositiveFloat | None = None
+    wavelength: NonNegativeInt | None = None
+    exposure_time: PositiveFloat | None = None
+    exposure_time_unit: str | None = None
     objective: str
diff --git a/src/faim_ipa/io/MetaSeriesTiff.py b/src/faim_ipa/io/metaseries.py
similarity index 100%
rename from src/faim_ipa/io/MetaSeriesTiff.py
rename to src/faim_ipa/io/metaseries.py
diff --git a/src/faim_ipa/MetaSeriesUtils_dask.py b/src/faim_ipa/metaseries_utils_dask.py
similarity index 95%
rename from src/faim_ipa/MetaSeriesUtils_dask.py
rename to src/faim_ipa/metaseries_utils_dask.py
index 6dacd803..b8dded6c 100644
--- a/src/faim_ipa/MetaSeriesUtils_dask.py
+++ b/src/faim_ipa/metaseries_utils_dask.py
@@ -52,7 +52,7 @@ def fuse_fw(tiles: ArrayLike, positions: ArrayLike) -> ArrayLike:
     im_fused = np.zeros((ny_tot, nx_tot), dtype=tiles.dtype)
     ny_tile, nx_tile = tiles.shape[-2:]
 
-    for tile, pos in zip(tiles, positions):
+    for tile, pos in zip(tiles, positions, strict=True):
         im_fused[pos[0] : pos[0] + ny_tile, pos[1] : pos[1] + nx_tile] = tile
 
     return im_fused
@@ -70,7 +70,7 @@ def fuse_rev(tiles: ArrayLike, positions: ArrayLike) -> ArrayLike:
     im_fused = np.zeros((ny_tot, nx_tot), dtype=tiles.dtype)
     ny_tile, nx_tile = tiles.shape[-2:]
 
-    for tile, pos in reversed(list(zip(tiles, positions))):
+    for tile, pos in reversed(list(zip(tiles, positions, strict=True))):
         im_fused[pos[0] : pos[0] + ny_tile, pos[1] : pos[1] + nx_tile] = tile
 
     return im_fused
diff --git a/src/faim_ipa/mobie.py b/src/faim_ipa/mobie.py
index a9703a46..1de60b1b 100644
--- a/src/faim_ipa/mobie.py
+++ b/src/faim_ipa/mobie.py
@@ -19,7 +19,7 @@
 from skimage.measure import regionprops_table
 from tqdm.auto import tqdm
 
-from faim_ipa.UIntHistogram import UIntHistogram
+from faim_ipa.histogram import UIntHistogram
 
 
 def hex_to_rgba(h) -> str:
@@ -96,17 +96,17 @@ def add_wells_to_project(
                 view={},  # do not create default view for source
             )
 
-            if key not in sources.keys():
+            if key not in sources:
                 sources[key] = [name]
             else:
                 sources[key].append(name)
 
-            if key not in plate_hists.keys():
+            if key not in plate_hists:
                 plate_hists[key] = copy(hists[k])
             else:
                 plate_hists[key].combine(hists[k])
 
-            if key not in plate_colors.keys():
+            if key not in plate_colors:
                 plate_colors[key] = hex_to_rgba(ch["color"])
 
     _add_well_regions(
@@ -126,6 +126,7 @@ def add_wells_to_project(
 def add_labels_view(
     plate: zarr.Group,
     dataset_folder: str,
+    *,
     well_group: str = "0",
     channel: int = 0,
     label_name: str = "default",
@@ -161,14 +162,14 @@ def add_labels_view(
         spacing = datasets[0]["coordinateTransformations"][0]["scale"]
         props = regionprops_table(
             label_img[np.newaxis, :],
-            properties=("label", "centroid") + extra_properties,
+            properties=("label", "centroid", *extra_properties),
             spacing=spacing,
         )
         if not add_empty_tables and len(props["label"]) == 0:
             continue
 
         # write default.tsv to dataset_folder/tables/name
-        # TODO reconcile once saving table data inside zarr is possible
+        # TODO: reconcile once saving table data inside zarr is possible
         table_folder = join(dataset_folder, "tables", name)
         os.makedirs(table_folder, exist_ok=True)
 
@@ -262,7 +263,6 @@ def compute_aggregate_table_values(
     summary.columns = ["_".join(headers) for headers in summary.columns.to_flat_index()]
     # add suffix to column names
     summary.columns = [f"{header}_{table_suffix}" for header in summary.columns]
-    print(summary)
 
     # join with original wells table
     wells_table.join(summary, on="region_id").to_csv(
@@ -295,7 +295,7 @@ def _add_channel_plate_overviews(
         )
         default["sourceTransforms"].append(
             get_merged_grid_source_transform(
-                sources=[src for src in sources[ch]],
+                sources=list(sources[ch]),
                 merged_source_name=f"merged_view_plate_{name}",
                 positions=[to_position(src[:3]) for src in sources[ch]],
             )
@@ -324,9 +324,9 @@ def _add_channel_plate_overviews(
 
 def _get_well_sources_per_channel(sources):
     wells_per_channel = {}
-    for ch in sources.keys():
+    for ch in sources:
         for well in sources[ch]:
-            if well[:3] not in wells_per_channel.keys():
+            if well[:3] not in wells_per_channel:
                 wells_per_channel[well[:3]] = [well]
             else:
                 wells_per_channel[well[:3]].append(well)
diff --git a/src/faim_ipa/stitching/__init__.py b/src/faim_ipa/stitching/__init__.py
index d8f1b7b3..7a510850 100644
--- a/src/faim_ipa/stitching/__init__.py
+++ b/src/faim_ipa/stitching/__init__.py
@@ -1,3 +1,7 @@
-from .BoundingBox5D import BoundingBox5D  # noqa: F401
-from .DaskTileStitcher import DaskTileStitcher  # noqa: F401
-from .Tile import Tile  # noqa: F401
+from faim_ipa.stitching.bounding_box import BoundingBox5D
+from faim_ipa.stitching.dask import DaskTileStitcher
+
+__all__ = [
+    "BoundingBox5D",
+    "DaskTileStitcher",
+]
diff --git a/src/faim_ipa/stitching/BoundingBox5D.py b/src/faim_ipa/stitching/bounding_box.py
similarity index 100%
rename from src/faim_ipa/stitching/BoundingBox5D.py
rename to src/faim_ipa/stitching/bounding_box.py
diff --git a/src/faim_ipa/stitching/DaskTileStitcher.py b/src/faim_ipa/stitching/dask.py
similarity index 90%
rename from src/faim_ipa/stitching/DaskTileStitcher.py
rename to src/faim_ipa/stitching/dask.py
index 270517a2..0df5c41d 100644
--- a/src/faim_ipa/stitching/DaskTileStitcher.py
+++ b/src/faim_ipa/stitching/dask.py
@@ -1,13 +1,13 @@
+from collections.abc import Callable
 from copy import copy
 from functools import partial
-from typing import Callable, Optional, Union
 
 import numpy as np
 from dask import array as da
 from dask.array.core import normalize_chunks
 
 from faim_ipa.stitching import BoundingBox5D, stitching_utils
-from faim_ipa.stitching.Tile import Tile
+from faim_ipa.stitching.tile import Tile
 
 
 class DaskTileStitcher:
@@ -18,8 +18,8 @@ class DaskTileStitcher:
     def __init__(
         self,
         tiles: list[Tile],
-        chunk_shape: Union[tuple[int, int], tuple[int, int, int]],
-        output_shape: Optional[tuple[int, int, int, int, int]] = None,
+        chunk_shape: tuple[int, int] | tuple[int, int, int],
+        output_shape: tuple[int, int, int, int, int] | None = None,
         dtype: np.dtype = np.uint16,
     ):
         """
@@ -54,7 +54,7 @@ def _build_tiles_lut(self):
                 tile.position.channel,
                 tile.position.z,
             )
-            if tcz_pos in lut.keys():
+            if tcz_pos in lut:
                 lut[tcz_pos].append(tile)
             else:
                 lut[tcz_pos] = [tile]
@@ -82,7 +82,7 @@ def _compute_block_to_tile_map(self):
                 shape=self.chunk_shape,
             )
             pos = (block_bbox.time_start, block_bbox.channel_start, block_bbox.z_start)
-            if pos in tiles_lut.keys():
+            if pos in tiles_lut:
                 for tile in tiles_lut[pos]:
                     tile_bbox = BoundingBox5D.from_pos_and_shape(
                         position=tile.get_position(),
@@ -97,18 +97,17 @@ def _compute_output_shape(self):
         """
         Compute the shape of the stitched image.
         """
-        tile_extents = []
-        for tile in self.tiles:
-            tile_extents.append(
-                tile.get_position()
-                + np.array((1,) * (5 - len(tile.shape)) + tile.shape)
-            )
+        tile_extents = [
+            (tile.get_position() + np.array((1,) * (5 - len(tile.shape)) + tile.shape))
+            for tile in self.tiles
+        ]
         return tuple(np.max(tile_extents, axis=0))
 
     def get_stitched_dask_array(
         self,
         warp_func: Callable = stitching_utils.translate_tiles_2d,
         fuse_func: Callable = stitching_utils.fuse_mean,
+        *,
         build_acquisition_mask: bool = False,
     ) -> da.array:
         """
@@ -145,6 +144,7 @@ def get_stitched_image(
         self,
         transform_func: Callable = stitching_utils.translate_tiles_2d,
         fuse_func: Callable = stitching_utils.fuse_mean,
+        *,
         build_acquisition_mask: bool = False,
     ) -> np.ndarray:
         """
diff --git a/src/faim_ipa/stitching/stitching_utils.py b/src/faim_ipa/stitching/stitching_utils.py
index edee19c7..f4d4956c 100644
--- a/src/faim_ipa/stitching/stitching_utils.py
+++ b/src/faim_ipa/stitching/stitching_utils.py
@@ -4,7 +4,7 @@
 from numpy._typing import NDArray
 from scipy.ndimage import distance_transform_cdt
 
-from faim_ipa.stitching.Tile import Tile, TilePosition
+from faim_ipa.stitching.tile import Tile, TilePosition
 
 
 def fuse_linear(warped_tiles: NDArray, warped_distance_masks: NDArray) -> NDArray:
@@ -101,7 +101,9 @@ def fuse_mean(warped_tiles: NDArray, warped_distance_masks: NDArray) -> NDArray:
     return fused_image.astype(warped_tiles.dtype)
 
 
-def fuse_sum(warped_tiles: NDArray, warped_distance_masks: NDArray) -> NDArray:
+def fuse_sum(
+    warped_tiles: NDArray, warped_distance_masks: NDArray  # noqa: ARG001
+) -> NDArray:
     """
     Fuse transformed tiles and compute the sum of the overlapping pixels.
 
@@ -144,7 +146,7 @@ def fuse_overlay_fwd(warped_tiles: NDArray, warped_distance_masks: NDArray) -> N
     warped_masks = warped_distance_masks.astype(bool)
 
     fused_image = np.zeros_like(warped_tiles[0])
-    for tile, mask in zip(warped_tiles, warped_masks):
+    for tile, mask in zip(warped_tiles, warped_masks, strict=True):
         fused_image[mask] = tile[mask]
 
     return fused_image
@@ -172,14 +174,14 @@ def fuse_overlay_bwd(warped_tiles: NDArray, warped_distance_masks: NDArray) -> N
     warped_masks = warped_distance_masks.astype(bool)
 
     fused_image = np.zeros_like(warped_tiles[0])
-    for tile, mask in zip(reversed(warped_tiles), reversed(warped_masks)):
+    for tile, mask in zip(reversed(warped_tiles), reversed(warped_masks), strict=True):
         fused_image[mask] = tile[mask]
 
     return fused_image
 
 
 def translate_tiles_2d(
-    block_info, chunk_shape, tiles, build_acquisition_mask: bool = False
+    block_info, chunk_shape, tiles, *, build_acquisition_mask: bool = False
 ):
     """
     Translate tiles to their relative position inside the given block.
@@ -203,7 +205,8 @@ def translate_tiles_2d(
     )
 
     if not all(tile.shape == tiles[0].shape for tile in tiles):
-        raise ValueError("All tiles must have the same shape.")
+        msg = "All tiles must have the same shape."
+        raise ValueError(msg)
     distance_mask = get_distance_mask(tiles[0].shape)
     if distance_mask.ndim == 2:
         distance_mask = distance_mask[np.newaxis, ...]
@@ -212,10 +215,9 @@ def translate_tiles_2d(
     warped_distance_masks = []
     for tile in tiles:
         tile_origin = np.array(tile.get_zyx_position())
-        if build_acquisition_mask:
-            tile_data = tile.load_data_mask()
-        else:
-            tile_data = tile.load_data()
+        tile_data = (
+            tile.load_data_mask() if build_acquisition_mask else tile.load_data()
+        )
         if tile_data.ndim == 2:
             tile_data = tile_data[np.newaxis, ...]
         warped_tile = shift_yx(chunk_zyx_origin, tile_data, tile_origin, chunk_shape)
@@ -271,6 +273,7 @@ def assemble_chunk(
     warp_func=None,
     fuse_func=None,
     dtype=None,
+    *,
     build_acquisition_mask: bool = False,
 ):
     """
@@ -299,7 +302,10 @@ def assemble_chunk(
 
     if len(tiles) > 0:
         warped_tiles, warped_distance_masks = warp_func(
-            block_info, chunk_shape[-3:], tiles, build_acquisition_mask
+            block_info,
+            chunk_shape[-3:],
+            tiles,
+            build_acquisition_mask=build_acquisition_mask,
         )
 
         if len(tiles) > 1:
diff --git a/src/faim_ipa/stitching/Tile.py b/src/faim_ipa/stitching/tile.py
similarity index 85%
rename from src/faim_ipa/stitching/Tile.py
rename to src/faim_ipa/stitching/tile.py
index d07db6d1..d4524490 100644
--- a/src/faim_ipa/stitching/Tile.py
+++ b/src/faim_ipa/stitching/tile.py
@@ -1,5 +1,4 @@
 from pathlib import Path
-from typing import Optional, Union
 
 import numpy as np
 from numpy._typing import NDArray
@@ -8,8 +7,8 @@
 
 
 class TilePosition(BaseModel):
-    time: Optional[NonNegativeInt]
-    channel: Optional[NonNegativeInt]
+    time: NonNegativeInt | None
+    channel: NonNegativeInt | None
     z: int
     y: int
     x: int
@@ -29,16 +28,16 @@ class Tile:
     path: str
     shape: tuple[int, ...]
     position: TilePosition
-    background_correction_matrix_path: Optional[Union[Path, str]] = None
-    illumination_correction_matrix_path: Optional[Union[Path, str]] = None
+    background_correction_matrix_path: Path | str | None = None
+    illumination_correction_matrix_path: Path | str | None = None
 
     def __init__(
         self,
-        path: Union[Path, str],
+        path: Path | str,
         shape: tuple[int, int],
         position: TilePosition,
-        background_correction_matrix_path: Optional[Union[Path, str]] = None,
-        illumination_correction_matrix_path: Optional[Union[Path, str]] = None,
+        background_correction_matrix_path: Path | str | None = None,
+        illumination_correction_matrix_path: Path | str | None = None,
     ):
         super().__init__()
         self.path = path
@@ -81,9 +80,7 @@ def load_data(self) -> NDArray:
         """
         data = imread(self.path)
         data = self._apply_background_correction(data)
-        data = self._apply_illumination_correction(data)
-
-        return data
+        return self._apply_illumination_correction(data)
 
     def load_data_mask(self) -> NDArray:
         """
diff --git a/src/faim_ipa/utils.py b/src/faim_ipa/utils.py
index 714b2736..0fba8f8f 100644
--- a/src/faim_ipa/utils.py
+++ b/src/faim_ipa/utils.py
@@ -1,7 +1,7 @@
 import logging
+import os.path
 import pathlib
 from datetime import datetime
-import os.path
 
 import pydantic
 from pydantic import BaseModel
@@ -19,40 +19,40 @@ def wavelength_to_rgb(wavelength, gamma=0.8):
     """
 
     wavelength = float(wavelength)
-    if 380 <= wavelength <= 440:
+    if 380 <= wavelength <= 440:  # noqa: PLR2004
         attenuation = 0.3 + 0.7 * (wavelength - 380) / (440 - 380)
-        R = ((-(wavelength - 440) / (440 - 380)) * attenuation) ** gamma
-        G = 0.0
-        B = (1.0 * attenuation) ** gamma
-    elif 440 <= wavelength <= 490:
-        R = 0.0
-        G = ((wavelength - 440) / (490 - 440)) ** gamma
-        B = 1.0
-    elif 490 <= wavelength <= 510:
-        R = 0.0
-        G = 1.0
-        B = (-(wavelength - 510) / (510 - 490)) ** gamma
-    elif 510 <= wavelength <= 580:
-        R = ((wavelength - 510) / (580 - 510)) ** gamma
-        G = 1.0
-        B = 0.0
-    elif 580 <= wavelength <= 645:
-        R = 1.0
-        G = (-(wavelength - 645) / (645 - 580)) ** gamma
-        B = 0.0
-    elif 645 <= wavelength <= 750:
+        r = ((-(wavelength - 440) / (440 - 380)) * attenuation) ** gamma
+        g = 0.0
+        b = (1.0 * attenuation) ** gamma
+    elif 440 <= wavelength <= 490:  # noqa: PLR2004
+        r = 0.0
+        g = ((wavelength - 440) / (490 - 440)) ** gamma
+        b = 1.0
+    elif 490 <= wavelength <= 510:  # noqa: PLR2004
+        r = 0.0
+        g = 1.0
+        b = (-(wavelength - 510) / (510 - 490)) ** gamma
+    elif 510 <= wavelength <= 580:  # noqa: PLR2004
+        r = ((wavelength - 510) / (580 - 510)) ** gamma
+        g = 1.0
+        b = 0.0
+    elif 580 <= wavelength <= 645:  # noqa: PLR2004
+        r = 1.0
+        g = (-(wavelength - 645) / (645 - 580)) ** gamma
+        b = 0.0
+    elif 645 <= wavelength <= 750:  # noqa: PLR2004
         attenuation = 0.3 + 0.7 * (750 - wavelength) / (750 - 645)
-        R = (1.0 * attenuation) ** gamma
-        G = 0.0
-        B = 0.0
+        r = (1.0 * attenuation) ** gamma
+        g = 0.0
+        b = 0.0
     else:
-        R = 0.0
-        G = 0.0
-        B = 0.0
-    R *= 255
-    G *= 255
-    B *= 255
-    return int(R), int(G), int(B)
+        r = 0.0
+        g = 0.0
+        b = 0.0
+    r *= 255
+    g *= 255
+    b *= 255
+    return int(r), int(g), int(b)
 
 
 def rgb_to_hex(r, g, b):
@@ -70,7 +70,7 @@ def create_logger(name: str) -> logging.Logger:
     name
         Name of the logger instance.
     """
-    logger = logging.Logger(name.capitalize())
+    logger = logging.getLogger(name.capitalize())
     now = datetime.now().strftime("%Y-%m-%d_%H-%M-%S")
     handler = logging.FileHandler(f"{now}-{name}.log")
     handler.setLevel(logging.INFO)
@@ -148,10 +148,11 @@ def make_paths_absolute(self):
         The paths are assumed to be relative to a git-root directory somewhere
         in the parent directories of the class implementing `IPAConfig`.
         """
-        if pydantic.__version__.startswith("2"):
-            fields = self.model_fields_set
-        else:
-            fields = self.__fields_set__
+        fields = (
+            self.model_fields_set
+            if pydantic.__version__.startswith("2")
+            else self.__fields_set__
+        )
 
         for f in fields:
             attr = getattr(self, f)
@@ -166,10 +167,11 @@ def make_paths_relative(self):
         somewhere in the parent directories of the class implementing
         `IPAConfig`.
         """
-        if pydantic.__version__.startswith("2"):
-            fields = self.model_fields_set
-        else:
-            fields = self.__fields_set__
+        fields = (
+            self.model_fields_set
+            if pydantic.__version__.startswith("2")
+            else self.__fields_set__
+        )
 
         for f in fields:
             attr = getattr(self, f)
diff --git a/src/faim_ipa/visiview/StackedTile.py b/src/faim_ipa/visiview/StackedTile.py
deleted file mode 100644
index 0f63fa2d..00000000
--- a/src/faim_ipa/visiview/StackedTile.py
+++ /dev/null
@@ -1,42 +0,0 @@
-from pathlib import Path
-from typing import Optional, Union
-
-import numpy as np
-import tifffile
-from numpy._typing import NDArray
-
-from faim_ipa.stitching import Tile
-from faim_ipa.stitching.Tile import TilePosition
-
-
-class StackedTile(Tile):
-    def __init__(
-        self,
-        path: Union[Path, str],
-        shape: tuple[int, int],
-        position: TilePosition,
-        background_correction_matrix_path: Optional[Union[Path, str]] = None,
-        illumination_correction_matrix_path: Optional[Union[Path, str]] = None,
-        memmap: bool = True,
-    ):
-        self._memmap = memmap
-        super().__init__(
-            path=path,
-            shape=shape,
-            position=position,
-            background_correction_matrix_path=background_correction_matrix_path,
-            illumination_correction_matrix_path=illumination_correction_matrix_path,
-        )
-
-    def load_data(self) -> NDArray:
-        if self._memmap:
-            data = tifffile.memmap(self.path, mode="r")
-        else:
-            data = tifffile.imread(self.path)
-
-        data = self._apply_background_correction(data)
-        data = self._apply_illumination_correction(data)
-        return data
-
-    def load_data_mask(self) -> NDArray:
-        return np.ones(self.shape, dtype=bool)
diff --git a/src/faim_ipa/visiview/__init__.py b/src/faim_ipa/visiview/__init__.py
index e69de29b..4ebb209c 100644
--- a/src/faim_ipa/visiview/__init__.py
+++ b/src/faim_ipa/visiview/__init__.py
@@ -0,0 +1,11 @@
+from faim_ipa.visiview.acquisition import (
+    RegionAcquisitionOME,
+    RegionAcquisitionSTK,
+    StackedTile,
+)
+
+__all__ = [
+    "RegionAcquisitionOME",
+    "RegionAcquisitionSTK",
+    "StackedTile",
+]
diff --git a/src/faim_ipa/visiview/RegionAcquisition.py b/src/faim_ipa/visiview/acquisition.py
similarity index 66%
rename from src/faim_ipa/visiview/RegionAcquisition.py
rename to src/faim_ipa/visiview/acquisition.py
index 532c854f..0993c6ad 100644
--- a/src/faim_ipa/visiview/RegionAcquisition.py
+++ b/src/faim_ipa/visiview/acquisition.py
@@ -1,16 +1,49 @@
 from pathlib import Path
-from typing import Optional, Union
 
+import numpy as np
 import pandas as pd
+import tifffile
 from numpy._typing import NDArray
 from tifffile import TiffFile
 
 from faim_ipa.hcs.acquisition import TileAlignmentOptions, WellAcquisition
-from faim_ipa.io.ChannelMetadata import ChannelMetadata
-from faim_ipa.stitching import Tile
-from faim_ipa.stitching.Tile import TilePosition
+from faim_ipa.io.metadata import ChannelMetadata
+from faim_ipa.stitching.tile import Tile, TilePosition
 from faim_ipa.visiview.ome_companion_utils import parse_basic_metadata
-from faim_ipa.visiview.StackedTile import StackedTile
+
+
+class StackedTile(Tile):
+    def __init__(
+        self,
+        path: Path | str,
+        shape: tuple[int, int],
+        position: TilePosition,
+        background_correction_matrix_path: Path | str | None = None,
+        illumination_correction_matrix_path: Path | str | None = None,
+        *,
+        memmap: bool = True,
+    ):
+        self._memmap = memmap
+        super().__init__(
+            path=path,
+            shape=shape,
+            position=position,
+            background_correction_matrix_path=background_correction_matrix_path,
+            illumination_correction_matrix_path=illumination_correction_matrix_path,
+        )
+
+    def load_data(self) -> NDArray:
+        data = (
+            tifffile.memmap(self.path, mode="r")
+            if self._memmap
+            else tifffile.imread(self.path)
+        )
+
+        data = self._apply_background_correction(data)
+        return self._apply_illumination_correction(data)
+
+    def load_data_mask(self) -> NDArray:
+        return np.ones(self.shape, dtype=bool)
 
 
 class RegionAcquisitionSTK(WellAcquisition):
@@ -18,17 +51,20 @@ def __init__(
         self,
         files: pd.DataFrame,
         alignment: TileAlignmentOptions,
-        background_correction_matrices: Optional[dict[str, NDArray]],
-        illumination_correction_matrices: Optional[dict[str, NDArray]],
-        axes: list[str] = ["c", "z", "y", "x"],
+        background_correction_matrices: dict[str, NDArray] | None,
+        illumination_correction_matrices: dict[str, NDArray] | None,
+        axes: list[str] | None = None,
         *,
         memmap: bool = True,
     ):
+        if axes is None:
+            axes = ["c", "z", "y", "x"]
         path = files.iloc[0]["path"]
         with TiffFile(path) as tif:
             metadata = tif.stk_metadata
             if metadata is None:
-                raise ValueError(f"STK metadata is missing. Please check " f"{path}")
+                msg = f"STK metadata is missing. Please check " f"{path}"
+                raise ValueError(msg)
             x_spacing = metadata["XCalibration"]
             y_spacing = metadata["YCalibration"]
             self._yx_spacing = (y_spacing, x_spacing)
@@ -46,7 +82,7 @@ def __init__(
 
     def _assemble_tiles(self) -> list[Tile]:
         tiles = []
-        for i, row in self._files.iterrows():
+        for _i, row in self._files.iterrows():
             file = row["path"]
             time_point = row["time"]
             channel = row["channel"]
@@ -68,7 +104,7 @@ def _assemble_tiles(self) -> list[Tile]:
 
         return tiles
 
-    def get_z_spacing(self) -> Optional[float]:
+    def get_z_spacing(self) -> float | None:
         return self._z_spacing
 
     def get_yx_spacing(self) -> tuple[float, float]:
@@ -82,13 +118,16 @@ class RegionAcquisitionOME(WellAcquisition):
     def __init__(
         self,
         files: pd.DataFrame,
-        ome_xml: Union[Path, str],
+        ome_xml: Path | str,
         alignment: TileAlignmentOptions,
-        background_correction_matrices: Optional[dict[str, NDArray]],
-        illumination_correction_matrices: Optional[dict[str, NDArray]],
-        axes: list[str] = ["c", "z", "y", "x"],
+        background_correction_matrices: dict[str, NDArray] | None,
+        illumination_correction_matrices: dict[str, NDArray] | None,
+        axes: list[str] | None = None,
+        *,
         memmap: bool = True,
     ):
+        if axes is None:
+            axes = ["c", "z", "y", "x"]
         self.metadata = parse_basic_metadata(companion_file=ome_xml)
         self.stage_positions = self.metadata["stage_positions"]
         path = files.iloc[0]["path"]
@@ -106,7 +145,7 @@ def __init__(
 
     def _assemble_tiles(self) -> list[Tile]:
         tiles = []
-        for i, row in self._files.iterrows():
+        for _i, row in self._files.iterrows():
             file = row["path"]
             time_point = row["time"]
             channel = row["channel"]
@@ -134,7 +173,7 @@ def _assemble_tiles(self) -> list[Tile]:
 
         return tiles
 
-    def get_z_spacing(self) -> Optional[float]:
+    def get_z_spacing(self) -> float | None:
         return self.metadata["z_spacing"]
 
     def get_yx_spacing(self) -> tuple[float, float]:
diff --git a/src/faim_ipa/visiview/ome_companion_utils.py b/src/faim_ipa/visiview/ome_companion_utils.py
index 46caaee2..8fd578b2 100644
--- a/src/faim_ipa/visiview/ome_companion_utils.py
+++ b/src/faim_ipa/visiview/ome_companion_utils.py
@@ -1,18 +1,18 @@
 from decimal import Decimal
 from pathlib import Path
-from typing import Any, Optional, Union
+from typing import Any
 
-import lxml
 import numpy as np
-from lxml import etree
+from defusedxml import ElementTree
+from defusedxml.ElementTree import parse
 
-from faim_ipa.io.ChannelMetadata import ChannelMetadata
+from faim_ipa.io.metadata import ChannelMetadata
 from faim_ipa.utils import rgb_to_hex, wavelength_to_rgb
 
 SCHEMA = "{http://www.openmicroscopy.org/Schemas/OME/2016-06}"
 
 
-def get_z_spacing(metadata: lxml.etree.ElementTree) -> Optional[float]:
+def get_z_spacing(metadata: ElementTree) -> float | None:
     """
     Get the Z spacing from the first image in the XML metadata.
 
@@ -33,13 +33,12 @@ def get_z_spacing(metadata: lxml.etree.ElementTree) -> Optional[float]:
 
     if len(z_positions) == 1:
         return None
-    else:
-        z_positions = np.array(sorted(z_positions))
-        precision = -Decimal(str(z_positions[0])).as_tuple().exponent
-        return np.round(np.diff(z_positions).mean(), precision)
+    z_positions = np.array(sorted(z_positions))
+    precision = -Decimal(str(z_positions[0])).as_tuple().exponent
+    return np.round(np.diff(z_positions).mean(), precision)
 
 
-def get_yx_spacing(metadata: lxml.etree.ElementTree) -> tuple[float, float]:
+def get_yx_spacing(metadata: ElementTree) -> tuple[float, float]:
     """
     Get the YX spacing from the first image in the XML metadata.
 
@@ -60,7 +59,7 @@ def get_yx_spacing(metadata: lxml.etree.ElementTree) -> tuple[float, float]:
     )
 
 
-def get_exposure_time(metadata: lxml.etree.ElementTree) -> tuple[float, str]:
+def get_exposure_time(metadata: ElementTree) -> tuple[float, str]:
     """
     Get the exposure time and unit from the first image in the XML metadata.
 
@@ -79,7 +78,7 @@ def get_exposure_time(metadata: lxml.etree.ElementTree) -> tuple[float, str]:
     return plane_0.get("ExposureTime"), plane_0.get("ExposureTimeUnit")
 
 
-def get_channels(metadata: lxml.etree.ElementTree) -> dict[str, ChannelMetadata]:
+def get_channels(metadata: ElementTree) -> dict[str, ChannelMetadata]:
     """
     Get the channel metadata from the XML metadata.
 
@@ -126,7 +125,7 @@ def get_channels(metadata: lxml.etree.ElementTree) -> dict[str, ChannelMetadata]
 
 
 def get_stage_positions(
-    metadata: lxml.etree.ElementTree,
+    metadata: ElementTree,
 ) -> dict[str, tuple[float, float]]:
     """
     Get the stage positions for each image from the XML metadata.
@@ -142,8 +141,8 @@ def get_stage_positions(
     """
     positions = {}
     for i, image in enumerate(metadata.iterchildren(f"{SCHEMA}Image")):
-        id = image.get("ID")
-        index = int(id.split(":")[-1])
+        image_id = image.get("ID")
+        index = int(image_id.split(":")[-1])
         assert index == i, f"Expected index {i} but got {index}"
 
         try:
@@ -159,7 +158,7 @@ def get_stage_positions(
     return positions
 
 
-def parse_basic_metadata(companion_file: Union[Path, str]) -> dict[str, Any]:
+def parse_basic_metadata(companion_file: Path | str) -> dict[str, Any]:
     """
     Parse the basic metadata from the XML companion file.
 
@@ -183,11 +182,10 @@ def parse_basic_metadata(companion_file: Union[Path, str]) -> dict[str, Any]:
         - stage_positions
     """
     with open(companion_file, "rb") as f:
-        root = etree.parse(f).getroot()
-        metadata = dict(
-            z_spacing=get_z_spacing(root),
-            yx_spacing=get_yx_spacing(root),
-            channels=get_channels(root),
-            stage_positions=get_stage_positions(root),
-        )
-    return metadata
+        root = parse(f).getroot()
+        return {
+            "z_spacing": get_z_spacing(root),
+            "yx_spacing": get_yx_spacing(root),
+            "channels": get_channels(root),
+            "stage_positions": get_stage_positions(root),
+        }
diff --git a/src/faim_ipa/Zarr.py b/src/faim_ipa/zarr_utils.py
similarity index 85%
rename from src/faim_ipa/Zarr.py
rename to src/faim_ipa/zarr_utils.py
index 4c799d4f..d00d1807 100644
--- a/src/faim_ipa/Zarr.py
+++ b/src/faim_ipa/zarr_utils.py
@@ -15,6 +15,7 @@ def write_labels_to_group(
     labels,
     labels_name,
     parent_group: Group,
+    *,
     storage_options: dict[str, Any],
     max_layer: int = 0,
     overwrite: bool = False,
@@ -28,9 +29,9 @@ def write_labels_to_group(
     )
 
     axes = parent_group.attrs.asdict()["multiscales"][0]["axes"]
-    assert len(axes) == len(
-        labels.shape
-    ), f"Group axes don't match label image dimensions: {len(axes)} <> {len(labels.shape)}."
+    if len(axes) == len(labels.shape):
+        message = f"Group axes don't match label image dimensions: {len(axes)} <> {len(labels.shape)}."
+        raise ValueError(message)
 
     write_image(
         image=labels,


Test patch:
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fb0290d8..8ce99529 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -22,7 +22,7 @@ jobs:
       fail-fast: false
       matrix:
         os: [ubuntu-latest, windows-latest, macos-latest]
-        python-version: ['3.9', '3.10', '3.11', '3.12']
+        python-version: ['3.10', '3.11', '3.12']
 
     steps:
     - uses: actions/checkout@v3
diff --git a/tests/alignment/test_alignment.py b/tests/alignment/test_alignment.py
index 365ee851..2293e552 100644
--- a/tests/alignment/test_alignment.py
+++ b/tests/alignment/test_alignment.py
@@ -1,7 +1,6 @@
 import pytest
 
-from faim_ipa.stitching import Tile
-from faim_ipa.stitching.Tile import TilePosition
+from faim_ipa.stitching.tile import Tile, TilePosition
 
 
 @pytest.fixture
@@ -39,7 +38,7 @@ def tiles() -> list[Tile]:
     ]
 
 
-def test_StageAlignment(tiles):
+def test_stage_alignment(tiles):
     from faim_ipa.alignment import StageAlignment
 
     alignment = StageAlignment(tiles)
@@ -54,7 +53,7 @@ def test_StageAlignment(tiles):
         assert tile.position.x in [0, 511, 512, 522]
 
 
-def test_GridAlignment(tiles):
+def test_grid_alignment(tiles):
     from faim_ipa.alignment import GridAlignment
 
     alignment = GridAlignment(tiles)
@@ -69,8 +68,8 @@ def test_GridAlignment(tiles):
         assert tile.position.x in [0, 512]
 
 
-def test_AbstractAlignment(tiles):
+def test_abstract_alignment(tiles):
     from faim_ipa.alignment.alignment import AbstractAlignment
 
-    with pytest.raises(NotImplementedError):
+    with pytest.raises(TypeError):
         AbstractAlignment(tiles)
diff --git a/tests/detection/test_detection_utils.py b/tests/detection/test_detection_utils.py
index bd7adab3..ba5f26cd 100644
--- a/tests/detection/test_detection_utils.py
+++ b/tests/detection/test_detection_utils.py
@@ -11,18 +11,17 @@
 
 def test_compute_axial_sigma():
     wl = 514
-    NA = 1.45
+    na = 1.45
     axial_spacing = 100
 
-    assert_almost_equal(compute_axial_sigma(wl, NA, axial_spacing), 2.08, 2)
+    assert_almost_equal(compute_axial_sigma(wl, na, axial_spacing), 2.08, 2)
 
 
 def test_compute_lateral_sigma():
     wl = 514
-    NA = 1.45
+    na = 1.45
     lateral_spacing = 100
-    print(compute_lateral_sigma(wl, NA, lateral_spacing))
-    assert_almost_equal(compute_lateral_sigma(wl, NA, lateral_spacing), 0.75, 2)
+    assert_almost_equal(compute_lateral_sigma(wl, na, lateral_spacing), 0.75, 2)
 
 
 def test_estimate_log_rescale_factor():
diff --git a/tests/hcs/cellvoyager/test_StackAcquisition.py b/tests/hcs/cellvoyager/test_stack_acquisition.py
similarity index 91%
rename from tests/hcs/cellvoyager/test_StackAcquisition.py
rename to tests/hcs/cellvoyager/test_stack_acquisition.py
index 26e2f9cd..07ae22e3 100644
--- a/tests/hcs/cellvoyager/test_StackAcquisition.py
+++ b/tests/hcs/cellvoyager/test_stack_acquisition.py
@@ -5,20 +5,18 @@
 from tifffile import imread
 
 from faim_ipa.hcs.acquisition import TileAlignmentOptions
-from faim_ipa.hcs.cellvoyager import StackAcquisition
-from faim_ipa.hcs.cellvoyager.StackedTile import StackedTile
+from faim_ipa.hcs.cellvoyager import StackAcquisition, StackedTile
 
 
 @pytest.fixture
 def cv_acquisition() -> Path:
-    dir = (
+    return (
         Path(__file__).parent.parent.parent.parent
         / "resources"
         / "CV8000"
         / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839"
         / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack"
     )
-    return dir
 
 
 def test_get_channel_metadata(cv_acquisition):
@@ -131,17 +129,17 @@ def test_get_well_acquisitions(cv_acquisition):
 
 
 def test_raise_value_errors(cv_acquisition):
-    with pytest.raises(ValueError):
+    with pytest.raises(ValueError, match=r"MeasurementData\.mlf not found"):
         plate = StackAcquisition(
             acquisition_dir=".",
             alignment=TileAlignmentOptions.GRID,
         )
 
-    with pytest.raises(ValueError):
-        plate = StackAcquisition(
-            acquisition_dir=cv_acquisition,
-            alignment=TileAlignmentOptions.GRID,
-        )
-        # Change acquisition_dir to mock missing mrf and mes files.
-        plate._acquisition_dir = "."
+    plate = StackAcquisition(
+        acquisition_dir=cv_acquisition,
+        alignment=TileAlignmentOptions.GRID,
+    )
+    # Change acquisition_dir to mock missing mrf and mes files.
+    plate._acquisition_dir = "."
+    with pytest.raises(ValueError, match=r"MeasurementDetail\.mrf not found"):
         plate._parse_metadata()
diff --git a/tests/hcs/cellvoyager/test_CellVoyagerWellAcquisition.py b/tests/hcs/cellvoyager/test_well_acquisition.py
similarity index 97%
rename from tests/hcs/cellvoyager/test_CellVoyagerWellAcquisition.py
rename to tests/hcs/cellvoyager/test_well_acquisition.py
index cab26a01..8601cfe3 100644
--- a/tests/hcs/cellvoyager/test_CellVoyagerWellAcquisition.py
+++ b/tests/hcs/cellvoyager/test_well_acquisition.py
@@ -6,10 +6,10 @@
 import pytest
 
 from faim_ipa.hcs.acquisition import TileAlignmentOptions
-from faim_ipa.hcs.cellvoyager.CellVoyagerWellAcquisition import (
+from faim_ipa.hcs.cellvoyager import (
     CellVoyagerWellAcquisition,
+    StackedTile,
 )
-from faim_ipa.hcs.cellvoyager.StackedTile import StackedTile
 
 
 @pytest.fixture
diff --git a/tests/hcs/cellvoyager/test_ZAdjustedStackAcquisition.py b/tests/hcs/cellvoyager/test_z_adjusted_stack_acquisition.py
similarity index 79%
rename from tests/hcs/cellvoyager/test_ZAdjustedStackAcquisition.py
rename to tests/hcs/cellvoyager/test_z_adjusted_stack_acquisition.py
index 2caa0caf..7c29344e 100644
--- a/tests/hcs/cellvoyager/test_ZAdjustedStackAcquisition.py
+++ b/tests/hcs/cellvoyager/test_z_adjusted_stack_acquisition.py
@@ -10,14 +10,13 @@
 
 @pytest.fixture
 def cv_acquisition() -> Path:
-    dir = (
+    return (
         Path(__file__).parent.parent.parent.parent
         / "resources"
         / "CV8000"
         / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack_20230918_135839"
         / "CV8000-Minimal-DataSet-2C-3W-4S-FP2-stack"
     )
-    return dir
 
 
 @pytest.fixture
@@ -43,14 +42,26 @@ def incomplete_trace_log_file() -> Path:
 
 
 def test__parse_files(cv_acquisition, trace_log_file):
-    with pytest.warns(UserWarning, match="First file without z position"):
+    with pytest.warns() as record:
         plate = ZAdjustedStackAcquisition(
             acquisition_dir=cv_acquisition,
             trace_log_files=[trace_log_file],
             alignment=TileAlignmentOptions.GRID,
         )
+    assert len(record) == 2
+    assert str(record[0].message) == "Z position information missing for some files."
+    assert str(record[1].message).startswith(
+        "First file without z position information"
+    )
+
+    with pytest.warns() as record2:
+        files = plate._parse_files()
+    assert len(record2) == 2
+    assert str(record2[0].message) == "Z position information missing for some files."
+    assert str(record2[1].message).startswith(
+        "First file without z position information"
+    )
 
-    files = plate._parse_files()
     assert len(files) == 96
     assert files["well"].unique().tolist() == ["D08", "E03", "F08"]
     assert files["Ch"].unique().tolist() == ["1", "2"]
@@ -87,12 +98,17 @@ def test__parse_files(cv_acquisition, trace_log_file):
 
 
 def test_get_well_acquisitions(cv_acquisition, trace_log_file):
-    with pytest.warns(UserWarning, match="First file without z position"):
+    with pytest.warns() as record:
         plate = ZAdjustedStackAcquisition(
             acquisition_dir=cv_acquisition,
             trace_log_files=[trace_log_file],
             alignment=TileAlignmentOptions.GRID,
         )
+    assert len(record) == 2
+    assert str(record[0].message) == "Z position information missing for some files."
+    assert str(record[1].message).startswith(
+        "First file without z position information"
+    )
 
     wells = plate.get_well_acquisitions()
     assert len(wells) == 3
@@ -105,7 +121,7 @@ def test_get_well_acquisitions(cv_acquisition, trace_log_file):
                 f"{str(tile.position.channel + 1).zfill(2)}\\.tif"
             )
             re_file_name = re.compile(file_name)
-            from faim_ipa.hcs.cellvoyager.StackedTile import StackedTile
+            from faim_ipa.hcs.cellvoyager.tile import StackedTile
 
             assert isinstance(tile, StackedTile)
             assert re_file_name.match(tile._paths[0])
diff --git a/tests/hcs/imagexpress/test_ImageXpress.py b/tests/hcs/imagexpress/test_plate_acquisitions.py
similarity index 99%
rename from tests/hcs/imagexpress/test_ImageXpress.py
rename to tests/hcs/imagexpress/test_plate_acquisitions.py
index cc088bfb..7f34ab5d 100644
--- a/tests/hcs/imagexpress/test_ImageXpress.py
+++ b/tests/hcs/imagexpress/test_plate_acquisitions.py
@@ -4,7 +4,6 @@
 import pytest
 from numpy.testing import assert_almost_equal
 
-from faim_ipa.hcs import imagexpress
 from faim_ipa.hcs.acquisition import (
     PlateAcquisition,
     TileAlignmentOptions,
@@ -12,6 +11,7 @@
 )
 from faim_ipa.hcs.imagexpress import (
     ImageXpressPlateAcquisition,
+    MixedAcquisition,
     SinglePlaneAcquisition,
     StackAcquisition,
 )
@@ -167,7 +167,7 @@ def test_stack_acquisition(stack_acquisition: PlateAcquisition):
 
 @pytest.fixture
 def mixed_acquisition(acquisition_dir):
-    return imagexpress.MixedAcquisition(
+    return MixedAcquisition(
         acquisition_dir,
         alignment=TileAlignmentOptions.GRID,
     )
diff --git a/tests/hcs/imagexpress/test_ImageXpressWellAcquisition.py b/tests/hcs/imagexpress/test_well_acquisition.py
similarity index 100%
rename from tests/hcs/imagexpress/test_ImageXpressWellAcquisition.py
rename to tests/hcs/imagexpress/test_well_acquisition.py
diff --git a/tests/hcs/test_acquisition.py b/tests/hcs/test_acquisition.py
index 56e91597..82458200 100644
--- a/tests/hcs/test_acquisition.py
+++ b/tests/hcs/test_acquisition.py
@@ -10,9 +10,8 @@
     TileAlignmentOptions,
     WellAcquisition,
 )
-from faim_ipa.io.ChannelMetadata import ChannelMetadata
-from faim_ipa.stitching import Tile
-from faim_ipa.stitching.Tile import TilePosition
+from faim_ipa.io.metadata import ChannelMetadata
+from faim_ipa.stitching.tile import Tile, TilePosition
 
 
 @pytest.fixture
@@ -395,8 +394,8 @@ def test_align_tiles(dummy_well):
 
 def test_alignment_not_implemented(dummy_well):
     dummy_well._alignment = "Unknown"
-    with pytest.raises(ValueError):
-        dummy_well._align_tiles(dummy_well._align_tiles(dummy_well._tiles))
+    with pytest.raises(ValueError, match="Unknown alignment option"):
+        dummy_well._align_tiles(dummy_well._tiles)
 
 
 def test_get_shape(dummy_well):
diff --git a/tests/hcs/test_converter.py b/tests/hcs/test_converter.py
index 1add6df9..96c170f7 100644
--- a/tests/hcs/test_converter.py
+++ b/tests/hcs/test_converter.py
@@ -5,7 +5,13 @@
 import numpy as np
 import pytest
 import zarr
-from distributed import Client, LocalCluster
+from distributed.utils_test import (
+    cleanup,  # noqa: F401
+    client,  # noqa: F401
+    cluster_fixture,  # noqa: F401
+    loop,  # noqa: F401
+    loop_in_thread,  # noqa: F401
+)
 from numcodecs import Blosc
 
 from faim_ipa import dask_utils
@@ -13,11 +19,10 @@
 from faim_ipa.hcs.cellvoyager import StackAcquisition
 from faim_ipa.hcs.converter import ConvertToNGFFPlate, NGFFPlate
 from faim_ipa.hcs.plate import PlateLayout
-from faim_ipa.stitching import Tile
-from faim_ipa.stitching.Tile import TilePosition
+from faim_ipa.stitching.tile import Tile, TilePosition
 
 
-def test_NGFFPlate():
+def test_ngff_plate():
     root_dir = "/path/to/root_dir"
     name = "plate_name"
     layout = PlateLayout.I18
@@ -78,9 +83,8 @@ def plate_acquisition_2d():
 
     for well in acq.get_well_acquisitions():
         tiles = well.get_tiles()
-        new_tiles = []
-        for tile in tiles:
-            new_tiles.append(
+        new_tiles = [
+            (
                 Tile(
                     path=tile._paths[0],
                     shape=(2000, 2000),
@@ -93,14 +97,16 @@ def plate_acquisition_2d():
                     ),
                 )
             )
+            for tile in tiles
+        ]
 
         well._tiles = new_tiles
 
     return acq
 
 
-def test__create_zarr_plate(tmp_dir, plate_acquisition, hcs_plate):
-    converter = ConvertToNGFFPlate(hcs_plate)
+def test__create_zarr_plate(tmp_dir, plate_acquisition, hcs_plate, client):
+    converter = ConvertToNGFFPlate(hcs_plate, client=client)
     zarr_plate = converter.create_zarr_plate(plate_acquisition, wells=None)
 
     assert exists(join(tmp_dir, "plate_name.zarr"))
@@ -170,8 +176,8 @@ def test__mean_cast_to():
     assert mean_cast_to(input_array) == 1
 
 
-def test__create_well_group(tmp_dir, plate_acquisition, hcs_plate):
-    converter = ConvertToNGFFPlate(hcs_plate)
+def test__create_well_group(tmp_dir, plate_acquisition, hcs_plate, client):
+    converter = ConvertToNGFFPlate(hcs_plate, client=client)
     zarr_plate = converter.create_zarr_plate(plate_acquisition)
     well_group = converter._create_well_group(
         plate=zarr_plate,
@@ -206,8 +212,8 @@ def test__create_well_group(tmp_dir, plate_acquisition, hcs_plate):
     ]
 
 
-def test__stitch_well_image_2d(tmp_dir, plate_acquisition_2d, hcs_plate):
-    converter = ConvertToNGFFPlate(hcs_plate)
+def test__stitch_well_image_2d(plate_acquisition_2d, hcs_plate, client):
+    converter = ConvertToNGFFPlate(hcs_plate, client=client)
     well_acquisition = plate_acquisition_2d.get_well_acquisitions()[0]
     well_img_da = converter._stitch_well_image(
         chunks=(1, 1, 10, 1000, 1000),
@@ -220,8 +226,8 @@ def test__stitch_well_image_2d(tmp_dir, plate_acquisition_2d, hcs_plate):
     assert well_img_da.dtype == np.uint16
 
 
-def test__stitch_well_image_mask_2d(tmp_dir, plate_acquisition_2d, hcs_plate):
-    converter = ConvertToNGFFPlate(hcs_plate)
+def test__stitch_well_image_mask_2d(plate_acquisition_2d, hcs_plate, client):
+    converter = ConvertToNGFFPlate(hcs_plate, client=client)
     well_acquisition = plate_acquisition_2d.get_well_acquisitions()[0]
     well_img_da = converter._stitch_well_image(
         chunks=(1, 1, 10, 1000, 1000),
@@ -234,12 +240,10 @@ def test__stitch_well_image_mask_2d(tmp_dir, plate_acquisition_2d, hcs_plate):
     assert well_img_da.dtype == bool
 
 
-def test__stitch_well_image_3d(tmp_dir, plate_acquisition, hcs_plate):
-    import distributed
-
+def test__stitch_well_image_3d(plate_acquisition, hcs_plate, client):
     converter = ConvertToNGFFPlate(
         hcs_plate,
-        client=distributed.Client(threads_per_worker=1, processes=False, n_workers=1),
+        client=client,
     )
     well_acquisition = plate_acquisition.get_well_acquisitions()[0]
     well_img_da = converter._stitch_well_image(
@@ -253,12 +257,10 @@ def test__stitch_well_image_3d(tmp_dir, plate_acquisition, hcs_plate):
     assert well_img_da.dtype == np.uint16
 
 
-def test__stitch_well_image_mask_3d(tmp_dir, plate_acquisition, hcs_plate):
-    import distributed
-
+def test__stitch_well_image_mask_3d(plate_acquisition, hcs_plate, client):
     converter = ConvertToNGFFPlate(
         hcs_plate,
-        client=distributed.Client(threads_per_worker=1, processes=False, n_workers=1),
+        client=client,
     )
     well_acquisition = plate_acquisition.get_well_acquisitions()[0]
     well_img_da = converter._stitch_well_image(
@@ -272,10 +274,11 @@ def test__stitch_well_image_mask_3d(tmp_dir, plate_acquisition, hcs_plate):
     assert well_img_da.dtype == bool
 
 
-def test__bin_yx(tmp_dir, plate_acquisition, hcs_plate):
+def test__bin_yx(plate_acquisition, hcs_plate, client):
     converter = ConvertToNGFFPlate(
         hcs_plate,
         yx_binning=2,
+        client=client,
     )
     well_acquisition = plate_acquisition.get_well_acquisitions()[0]
     well_img_da = converter._stitch_well_image(
@@ -296,13 +299,11 @@ def test__bin_yx(tmp_dir, plate_acquisition, hcs_plate):
     assert binned_yx.dtype == np.uint16
 
 
-def test_run(tmp_dir, plate_acquisition, hcs_plate):
+def test_run(tmp_dir, plate_acquisition, hcs_plate, client):
     converter = ConvertToNGFFPlate(
         hcs_plate,
         yx_binning=2,
-        client=LocalCluster(
-            n_workers=1, threads_per_worker=4, processes=False
-        ).get_client(),
+        client=client,
     )
     plate = converter.create_zarr_plate(plate_acquisition)
     plate = converter.run(
@@ -326,14 +327,14 @@ def test_run(tmp_dir, plate_acquisition, hcs_plate):
         assert exists(join(path, ".zattrs"))
         assert exists(join(path, ".zgroup"))
 
-        assert "acquisition_metadata" in plate[row][col]["0"].attrs.keys()
-        assert "multiscales" in plate[row][col]["0"].attrs.keys()
-        assert "omero" in plate[row][col]["0"].attrs.keys()
+        assert "acquisition_metadata" in plate[row][col]["0"].attrs
+        assert "multiscales" in plate[row][col]["0"].attrs
+        assert "omero" in plate[row][col]["0"].attrs
 
         axes = plate[row][col]["0"].attrs["multiscales"][0]["axes"]
         for axis in axes:
             if axis["type"] == "space":
-                assert "unit" in axis.keys()
+                assert "unit" in axis
                 assert axis["unit"] == "micrometer"
 
         assert exists(join(path, "0", ".zarray"))
@@ -343,13 +344,11 @@ def test_run(tmp_dir, plate_acquisition, hcs_plate):
         assert plate[row][col]["0"]["1"].shape == (2, 4, 1000, 1000)
 
 
-def test_run_selection(tmp_dir, plate_acquisition, hcs_plate):
+def test_run_selection(tmp_dir, plate_acquisition, hcs_plate, client):
     converter = ConvertToNGFFPlate(
         hcs_plate,
         yx_binning=2,
-        client=LocalCluster(
-            n_workers=1, threads_per_worker=4, processes=False
-        ).get_client(),
+        client=client,
     )
     plate = converter.create_zarr_plate(plate_acquisition)
     plate = converter.run(
@@ -374,9 +373,9 @@ def test_run_selection(tmp_dir, plate_acquisition, hcs_plate):
         assert exists(join(path, ".zattrs"))
         assert exists(join(path, ".zgroup"))
 
-        assert "acquisition_metadata" in plate[row][col]["0"].attrs.keys()
-        assert "multiscales" in plate[row][col]["0"].attrs.keys()
-        assert "omero" in plate[row][col]["0"].attrs.keys()
+        assert "acquisition_metadata" in plate[row][col]["0"].attrs
+        assert "multiscales" in plate[row][col]["0"].attrs
+        assert "omero" in plate[row][col]["0"].attrs
 
         assert exists(join(path, "0", ".zarray"))
         assert exists(join(path, "1", ".zarray"))
@@ -385,17 +384,17 @@ def test_run_selection(tmp_dir, plate_acquisition, hcs_plate):
         assert plate[row][col]["0"]["1"].shape == (2, 4, 1000, 1000)
 
 
-def test_provide_client(tmp_dir, plate_acquisition, hcs_plate):
+def test_provide_client(hcs_plate, client):
     converter = ConvertToNGFFPlate(
         hcs_plate,
         yx_binning=2,
-        client=Client(),
+        client=client,
     )
     assert converter._client is not None
 
 
-def test__drop_missing_axes(tmp_dir, plate_acquisition_2d, hcs_plate):
-    converter = ConvertToNGFFPlate(hcs_plate)
+def test__drop_missing_axes(plate_acquisition_2d, hcs_plate, client):
+    converter = ConvertToNGFFPlate(hcs_plate, client=client)
     well_acquisition = plate_acquisition_2d.get_well_acquisitions()[0]
     well_img_da = converter._stitch_well_image(
         chunks=(1, 1, 10, 1000, 1000),
diff --git a/tests/io/test_ChannelMetadata.py b/tests/io/test_metadata.py
similarity index 88%
rename from tests/io/test_ChannelMetadata.py
rename to tests/io/test_metadata.py
index 5dbd0c83..c997f591 100644
--- a/tests/io/test_ChannelMetadata.py
+++ b/tests/io/test_metadata.py
@@ -1,9 +1,10 @@
-from faim_ipa.io.MetaSeriesTiff import load_metaseries_tiff_metadata
-from faim_ipa.io.ChannelMetadata import ChannelMetadata
-
 from pathlib import Path
+
 import pytest
 
+from faim_ipa.io.metadata import ChannelMetadata
+from faim_ipa.io.metaseries import load_metaseries_tiff_metadata
+
 
 @pytest.fixture
 def transmission_sample_image_path():
diff --git a/tests/io/test_MetaSeriesTiff.py b/tests/io/test_metaseries.py
similarity index 97%
rename from tests/io/test_MetaSeriesTiff.py
rename to tests/io/test_metaseries.py
index 0e4493f0..c9d8a464 100644
--- a/tests/io/test_MetaSeriesTiff.py
+++ b/tests/io/test_metaseries.py
@@ -8,7 +8,7 @@
 
 import numpy as np
 
-from faim_ipa.io.MetaSeriesTiff import load_metaseries_tiff
+from faim_ipa.io.metaseries import load_metaseries_tiff
 
 ROOT_DIR = Path(__file__).parent.parent
 
diff --git a/tests/stitching/test_Tile.py b/tests/stitching/test_Tile.py
index e0cc5e67..54ea8015 100644
--- a/tests/stitching/test_Tile.py
+++ b/tests/stitching/test_Tile.py
@@ -3,8 +3,7 @@
 from numpy.testing import assert_array_equal
 from tifffile import imwrite
 
-from faim_ipa.stitching import Tile
-from faim_ipa.stitching.Tile import TilePosition
+from faim_ipa.stitching.tile import Tile, TilePosition
 
 
 def test_fields():
@@ -95,7 +94,7 @@ def test_get_position():
     assert tile.get_yx_position() == (2, 7)
 
 
-def test_Tile_data_mask():
+def test_tile_data_mask():
     tile = Tile(
         path="path",
         shape=(10, 10),
@@ -107,8 +106,8 @@ def test_Tile_data_mask():
     assert mask.all()
 
 
-def test_CVStackedTile_data_mask():
-    from faim_ipa.hcs.cellvoyager.StackedTile import StackedTile
+def test_cellvoyager_data_mask():
+    from faim_ipa.hcs.cellvoyager.tile import StackedTile
 
     tile = StackedTile(
         paths=["path1", None, "path3"],
@@ -124,8 +123,8 @@ def test_CVStackedTile_data_mask():
     assert mask[2].all()
 
 
-def test_VisiViewStackedTile_data_mask():
-    from faim_ipa.visiview.StackedTile import StackedTile
+def test_visiview_data_mask():
+    from faim_ipa.visiview.acquisition import StackedTile
 
     tile = StackedTile(
         path="path1",
diff --git a/tests/stitching/test_BoundingBox.py b/tests/stitching/test_bounding_box.py
similarity index 100%
rename from tests/stitching/test_BoundingBox.py
rename to tests/stitching/test_bounding_box.py
diff --git a/tests/stitching/test_TileStitcher.py b/tests/stitching/test_dask.py
similarity index 98%
rename from tests/stitching/test_TileStitcher.py
rename to tests/stitching/test_dask.py
index c25cbea9..a2d5149d 100644
--- a/tests/stitching/test_TileStitcher.py
+++ b/tests/stitching/test_dask.py
@@ -2,8 +2,8 @@
 import pytest
 from numpy.testing import assert_array_equal
 
-from faim_ipa.stitching import DaskTileStitcher, Tile, stitching_utils
-from faim_ipa.stitching.Tile import TilePosition
+from faim_ipa.stitching import DaskTileStitcher, stitching_utils
+from faim_ipa.stitching.tile import Tile, TilePosition
 
 
 @pytest.fixture
diff --git a/tests/stitching/test_stitching_utils.py b/tests/stitching/test_stitching_utils.py
index 3d6aac42..de9ce5a5 100644
--- a/tests/stitching/test_stitching_utils.py
+++ b/tests/stitching/test_stitching_utils.py
@@ -2,7 +2,9 @@
 
 import numpy as np
 import pytest
-from faim_ipa.stitching import Tile
+from numpy.testing import assert_array_equal
+from scipy.ndimage import distance_transform_cdt
+
 from faim_ipa.stitching.stitching_utils import (
     assemble_chunk,
     fuse_linear,
@@ -16,9 +18,7 @@
     shift_yx,
     translate_tiles_2d,
 )
-from faim_ipa.stitching.Tile import TilePosition
-from numpy.testing import assert_array_equal
-from scipy.ndimage import distance_transform_cdt
+from faim_ipa.stitching.tile import Tile, TilePosition
 
 
 @pytest.fixture
@@ -146,7 +146,7 @@ def __init__(self, yx_position, data):
         self.shape = data.shape
 
     def get_zyx_position(self):
-        return (0,) + self._yx_position
+        return (0, *self._yx_position)
 
     def load_data(self):
         return self._data
@@ -249,7 +249,7 @@ def test_shift_to_origin():
 
 
 def test_get_distance_mask():
-    expected_distance_mask_2D = np.array(
+    expected_distance_mask_2d = np.array(
         [
             [1, 1, 1, 1, 1, 1],
             [1, 2, 2, 2, 2, 1],
@@ -262,15 +262,15 @@ def test_get_distance_mask():
 
     result = get_distance_mask((5, 6))
     assert result.dtype == np.uint16
-    assert_array_equal(result, expected_distance_mask_2D)
+    assert_array_equal(result, expected_distance_mask_2d)
 
     result = get_distance_mask((1, 5, 6))
     assert result.dtype == np.uint16
-    assert_array_equal(result, expected_distance_mask_2D.reshape((1, 5, 6)))
+    assert_array_equal(result, expected_distance_mask_2d.reshape((1, 5, 6)))
 
     result = get_distance_mask((4, 5, 6))
     assert result.dtype == np.uint16
-    assert_array_equal(result, np.stack([expected_distance_mask_2D for n in range(4)]))
+    assert_array_equal(result, np.stack([expected_distance_mask_2d for n in range(4)]))
 
 
 def test_translate_3d_tiles_2d(tiles):
@@ -401,7 +401,7 @@ def test_translate_2d_shape_mismatch(tiles):
             "shape": (1, 1, 1, 10, 20),
         }
     }
-    with pytest.raises(ValueError):
+    with pytest.raises(ValueError, match="All tiles must have the same shape"):
         translate_tiles_2d(
             block_info=block_info,
             chunk_shape=(1, 10, 20),
diff --git a/tests/test_dask_utils.py b/tests/test_dask_utils.py
deleted file mode 100644
index 19e68259..00000000
--- a/tests/test_dask_utils.py
+++ /dev/null
@@ -1,64 +0,0 @@
-import sys
-from multiprocessing import Process, Queue
-
-import pytest
-from distributed import Client
-
-from faim_ipa.dask_utils import LocalClusterFactory
-
-
-@pytest.mark.skipif(sys.platform in ["win32", "darwin"], reason="only runs on Linux")
-def test_local_cluster_factory(tmp_path_factory):
-    fac = LocalClusterFactory(
-        n_workers=1,
-        threads_per_worker=1,
-        processes=True,
-        memory_limit="1GB",
-        local_directory=str(tmp_path_factory.mktemp("dask")),
-    )
-
-    client = fac.get_client()
-    assert client.scheduler_info() is not None
-    assert client.scheduler_info()["workers"] is not None
-    assert len(client.scheduler_info()["workers"]) == 1
-    for worker in client.scheduler_info()["workers"].values():
-        assert worker["nthreads"] == 1
-        assert worker["memory_limit"] == 1e9
-
-    assert fac._subprocess.is_alive()
-
-    c2 = fac.get_client()
-    assert client == c2
-    assert fac._scheduler_address == fac._get_scheduler_address()
-
-    assert fac._subprocess.is_alive()
-
-    fac._shutdown()
-
-    assert not fac._subprocess.is_alive()
-    assert fac._subprocess.exitcode == 0
-
-    fac._shutdown()
-
-
-@pytest.mark.skipif(sys.platform in ["win32", "darwin"], reason="only runs on Linux")
-def test__start_local_cluster(tmp_path_factory):
-    queue = Queue(1)
-    p = Process(
-        target=LocalClusterFactory._run_cluster,
-        args=(
-            queue,
-            1,
-            1,
-            True,
-            "1GB",
-            str(tmp_path_factory.mktemp("dask")),
-        ),
-    )
-    p.start()
-    sa = queue.get()
-    assert sa is not None
-    client = Client(sa)
-    client.shutdown()
-    p.join()
-    assert p.exitcode == 0
diff --git a/tests/test_UIntHistogram.py b/tests/test_histogram.py
similarity index 99%
rename from tests/test_UIntHistogram.py
rename to tests/test_histogram.py
index a4598fee..b606eba4 100644
--- a/tests/test_UIntHistogram.py
+++ b/tests/test_histogram.py
@@ -7,7 +7,7 @@
 import numpy as np
 from numpy.testing import assert_almost_equal, assert_array_equal, assert_equal
 
-from faim_ipa.UIntHistogram import UIntHistogram
+from faim_ipa.histogram import UIntHistogram
 
 ROOT_DIR = Path(__file__).parent
 
diff --git a/tests/test_MetaSeriesUtils_dask.py b/tests/test_metaseries_utils_dask.py
similarity index 92%
rename from tests/test_MetaSeriesUtils_dask.py
rename to tests/test_metaseries_utils_dask.py
index 6cde1d03..19bfc68c 100644
--- a/tests/test_MetaSeriesUtils_dask.py
+++ b/tests/test_metaseries_utils_dask.py
@@ -1,7 +1,7 @@
 import numpy as np
 import pytest
 
-from faim_ipa.MetaSeriesUtils_dask import fuse_fw, fuse_random_gradient, fuse_rev
+from faim_ipa.metaseries_utils_dask import fuse_fw, fuse_random_gradient, fuse_rev
 
 
 def tiles():
@@ -18,7 +18,7 @@ def positions():
 
 
 @pytest.mark.parametrize(
-    "tiles,positions",
+    ("tiles", "positions"),
     [
         (tiles(), positions()),
     ],
@@ -39,7 +39,7 @@ def test_fuse_rev(tiles, positions):
 
 
 @pytest.mark.parametrize(
-    "tiles,positions",
+    ("tiles", "positions"),
     [
         (tiles(), positions()),
     ],
@@ -60,7 +60,7 @@ def test_fuse_fw(tiles, positions):
 
 
 @pytest.mark.parametrize(
-    "tiles,positions",
+    ("tiles", "positions"),
     [
         (tiles(), positions()),
     ],