Repository: scverse/spatialdata

Issue:
Generating multiscales for labels causes artifacts
When parsing a labels element with scale factors, I unconsciously obtained artifacts.

Given a single-scale labels image with unsigned integer dtype and passing `scale_factors`, I assumed SpatialData chooses from the `Labels2DModel` the corresponding scaling method that preserves unique labels (does not introduce new labels as interpolation result from two neighboring labels).

However, it leaves the decision to `to_multiscale`, which defaults to `XARRAY_COARSEN`, same as with normal images.

That means, when passing `scale_factors` to labels, `method` must also be passed explicitly. This seems unintuitive and more users will fall into this trap. I suggest when the class is Image2DLabels / Image3DLabels, we override the default `method`.

```python
import numpy as np
from spatialdata.models import Labels2DModel
from multiscale_spatial_image.to_multiscale.to_multiscale import Methods
import pytest


@pytest.mark.parametrize(
    # "method" not passed, passed as None and passed with "nearest" interpolation
    "method_kwarg", [{}, {"method": None}, {"method": Methods.DASK_IMAGE_NEAREST}]
)
def test_parse_labels_multiscale(method_kwarg):
    # A labels image with one label value 4, that partially covers 2×2 blocks.
    # Downsampling with interpolation would produce values 1, 2, 3, 4.
    array = np.array(
        [[0, 0, 0, 0], [0, 4, 4, 4], [4, 4, 4, 4], [0, 4, 4, 4]], dtype=np.uint16
    )
    actual = Labels2DModel.parse(array, scale_factors=(2,), **method_kwarg)
    assert actual.scale0.image.dtype == array.dtype
    assert actual.scale1.image.dtype == array.dtype
    assert set(np.unique(array)) == set(
        np.unique(actual.scale0.image)
    ), "Scale0 should be preserved"
    assert set(np.unique(array)) >= set(
        np.unique(actual.scale1.image)
    ), "Subsequent scales should not have interpolation artifacts"
```

![issue_labels_multiscales](https://github.com/user-attachments/assets/2867fbb6-479b-407b-9d5f-9e6edf8c9998)

I also was sure the exact same method call was once generating multiscales without artifacts, but I don't know when, and the above example reproduces that there is a problem.

Versions:
- multiscale_spatial_image==1.0.1
- spatial_image==1.1.0
- spatialdata==0.2.2


Discussion:
codecov[bot]: ## [Codecov](https://app.codecov.io/gh/scverse/spatialdata/pull/697?dropdown=coverage&src=pr&el=h1&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) Report
All modified and coverable lines are covered by tests :white_check_mark:
> Project coverage is 91.85%. Comparing base [(`b80b364`)](https://app.codecov.io/gh/scverse/spatialdata/commit/b80b364b56feaa42f317e1cca05e3a3db02f7475?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) to head [(`5dece8e`)](https://app.codecov.io/gh/scverse/spatialdata/commit/5dece8efd733b1e55638e684812fbc1365c0f200?dropdown=coverage&el=desc&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse).

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


```diff
@@            Coverage Diff             @@
##             main     #697      +/-   ##
==========================================
+ Coverage   91.83%   91.85%   +0.02%     
==========================================
  Files          44       44              
  Lines        6781     6788       +7     
==========================================
+ Hits         6227     6235       +8     
+ Misses        554      553       -1     
```

| [Files with missing lines](https://app.codecov.io/gh/scverse/spatialdata/pull/697?dropdown=coverage&src=pr&el=tree&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse) | Coverage Δ | |
|---|---|---|
| [src/spatialdata/models/models.py](https://app.codecov.io/gh/scverse/spatialdata/pull/697?src=pr&el=tree&filepath=src%2Fspatialdata%2Fmodels%2Fmodels.py&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse#diff-c3JjL3NwYXRpYWxkYXRhL21vZGVscy9tb2RlbHMucHk=) | `87.82% <100.00%> (+0.26%)` | :arrow_up: |

... and [4 files with indirect coverage changes](https://app.codecov.io/gh/scverse/spatialdata/pull/697/indirect-changes?src=pr&el=tree-more&utm_medium=referral&utm_source=github&utm_content=comment&utm_campaign=pr+comments&utm_term=scverse)

</details>

Patch:
diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml
index 1280d81..39d4edd 100644
--- a/.pre-commit-config.yaml
+++ b/.pre-commit-config.yaml
@@ -21,13 +21,13 @@ repos:
       hooks:
           - id: blacken-docs
     - repo: https://github.com/pre-commit/mirrors-mypy
-      rev: v1.11.1
+      rev: v1.11.2
       hooks:
           - id: mypy
             additional_dependencies: [numpy, types-requests]
             exclude: tests/|docs/
     - repo: https://github.com/astral-sh/ruff-pre-commit
-      rev: v0.6.2
+      rev: v0.6.3
       hooks:
           - id: ruff
             args: [--fix, --exit-non-zero-on-fix]
diff --git a/src/spatialdata/models/models.py b/src/spatialdata/models/models.py
index e281ce1..b5d136c 100644
--- a/src/spatialdata/models/models.py
+++ b/src/spatialdata/models/models.py
@@ -255,6 +255,17 @@ class Labels2DModel(RasterSchema):
             **kwargs,
         )
 
+    @classmethod
+    def parse(  # noqa: D102
+        self,
+        *args: Any,
+        **kwargs: Any,
+    ) -> DataArray | DataTree:
+        if kwargs.get("scale_factors") is not None and kwargs.get("method") is None:
+            # Override default scaling method to preserve labels
+            kwargs["method"] = Methods.DASK_IMAGE_NEAREST
+        return super().parse(*args, **kwargs)
+
 
 class Labels3DModel(RasterSchema):
     dims = DimsSchema((Z, Y, X))
@@ -270,6 +281,13 @@ class Labels3DModel(RasterSchema):
             **kwargs,
         )
 
+    @classmethod
+    def parse(self, *args: Any, **kwargs: Any) -> DataArray | DataTree:  # noqa: D102
+        if kwargs.get("scale_factors") is not None and kwargs.get("method") is None:
+            # Override default scaling method to preserve labels
+            kwargs["method"] = Methods.DASK_IMAGE_NEAREST
+        return super().parse(*args, **kwargs)
+
 
 class Image2DModel(RasterSchema):
     dims = DimsSchema((C, Y, X))


Test patch:
diff --git a/tests/models/test_models.py b/tests/models/test_models.py
index 6f68115..4b5b218 100644
--- a/tests/models/test_models.py
+++ b/tests/models/test_models.py
@@ -195,6 +195,27 @@ class TestModels:
             with pytest.raises(ValueError):
                 model.parse(image, **kwargs)
 
+    @pytest.mark.parametrize("model", [Labels2DModel, Labels3DModel])
+    def test_labels_model_with_multiscales(self, model):
+        # Passing "scale_factors" should generate multiscales with a "method" appropriate for labels
+        dims = np.array(model.dims.dims).tolist()
+        n_dims = len(dims)
+
+        # A labels image with one label value 4, that partially covers 2×2 blocks.
+        # Downsampling with interpolation would produce values 1, 2, 3, 4.
+        image: ArrayLike = np.array([[0, 0, 0, 0], [0, 4, 4, 4], [4, 4, 4, 4], [0, 4, 4, 4]], dtype=np.uint16)
+        if n_dims == 3:
+            image = np.stack([image] * image.shape[0])
+        actual = model.parse(image, scale_factors=(2,))
+        assert isinstance(actual, DataTree)
+        assert actual.children.keys() == {"scale0", "scale1"}
+        assert actual.scale0.image.dtype == image.dtype
+        assert actual.scale1.image.dtype == image.dtype
+        assert set(np.unique(image)) == set(np.unique(actual.scale0.image)), "Scale0 should be preserved"
+        assert set(np.unique(image)) >= set(
+            np.unique(actual.scale1.image)
+        ), "Subsequent scales should not have interpolation artifacts"
+
     @pytest.mark.parametrize("model", [ShapesModel])
     @pytest.mark.parametrize("path", [POLYGON_PATH, MULTIPOLYGON_PATH, POINT_PATH])
     def test_shapes_model(self, model: ShapesModel, path: Path) -> None: