scverse / scverse/spatialdata

transform() of multiscale labels uses linear interpolation and invents label ids

Open
#1,202 0 comments 0 reactions 0 assignees View on GitHub

Nobody has claimed this yet.

bug 🚨 element: labels 🏷️ method: transforms 🔃 needs: triage priority: high
Dominant language
Python
Stars
394
Forks
95
Avg merge
4d 3h
Merged PRs (30d)
7

Description

[!NOTE]
This whole message is AI-generated. The issue was automatically discovered and reported by an AI agent (Claude) during an autonomous bug hunt on the spatialdata code base. It has not been verified or triaged by a human yet; the needs: triage label is set so that a maintainer can confirm it. The reproduction script below was executed by the agent in an isolated environment (see Environment) and its output is pasted verbatim.

Summary

Rotating or scaling a multiscale labels element (DataTree) with transform() resamples it with dask_image.ndinterp.affine_transform's default order=1 (linear interpolation). New label ids that never existed in the input appear (blends of neighbouring ids) and match no table row. Single-scale labels are handled correctly (order=0). The same applies to transform_to_coordinate_system() and transform(sdata, ...). Side inconsistency: single-scale images are resampled with nearest neighbour while multiscale images are linearly interpolated.

Severity (agent's assessment): high — silent data corruption of segmentation masks

Where: src/spatialdata/_core/operations/transform.py, DataTree overload (kwargs = {"prefilter": False} for labels, no order=0, ~line 330); the DataArray overload correctly uses order=0

Expected behaviour

The set of label ids after the transformation is a subset of the input ids (nearest-neighbour resampling), for both single-scale and multiscale labels.

Reproduction

Save as repro.py and run uv run repro.py (the PEP 723 header pins spatialdata to the commit the bug was found on; replace the URL fragment with @main to test the current main branch).

# /// script
# requires-python = ">=3.12"
# dependencies = [
#     "spatialdata @ git+https://github.com/scverse/spatialdata.git@ccf1ea048d054b6624214bf618008a9f9ae223e0",
# ]
# ///
"""transform() of multiscale labels uses linear interpolation and invents label ids (single-scale is fine)."""
import warnings
import numpy as np
from spatialdata import transform
from spatialdata.models import Labels2DModel
from spatialdata.transformations import Affine, Scale, set_transformation

warnings.simplefilter("ignore")
arr = np.zeros((64, 64), dtype=np.uint16)
arr[:32, :] = 1
arr[32:, :] = 50
theta = np.deg2rad(30)
rotation = Affine(
    np.array([[np.cos(theta), -np.sin(theta), 0], [np.sin(theta), np.cos(theta), 0], [0, 0, 1]]),
    input_axes=("x", "y"),
    output_axes=("x", "y"),
)
bug = False
cases = [
    ("single-scale labels, rotation", Labels2DModel.parse(arr.copy()), rotation),
    ("multiscale labels, rotation", Labels2DModel.parse(arr.copy(), scale_factors=[2]), rotation),
    ("multiscale labels, Scale(1.5)", Labels2DModel.parse(arr.copy(), scale_factors=[2]), Scale([1.5, 1.5], axes=("x", "y"))),
]
for name, labels, t in cases:
    set_transformation(labels, t, "global")
    out = transform(labels, to_coordinate_system="global")
    data = out.data if not hasattr(out, "scale0") else out["scale0"]["image"].data
    uniques = np.unique(np.asarray(data.compute())).tolist()
    ok = set(uniques) <= {0, 1, 50}
    print(f"{name:32s} -> unique ids: {uniques[:10]}{' ...' if len(uniques) > 10 else ''}  ({'OK' if ok else 'INVENTED IDS'})")
    bug |= not ok
print("VERDICT:", "BUG REPRODUCED" if bug else "NOT REPRODUCED")
Observed output
single-scale labels, rotation    -> unique ids: [0, 1, 50]  (OK)
multiscale labels, rotation      -> unique ids: [0, 1, 3, 4, 5, 6, 7, 8, 9, 10] ...  (INVENTED IDS)
multiscale labels, Scale(1.5)    -> unique ids: [0, 1, 17, 50]  (INVENTED IDS)
VERDICT: BUG REPRODUCED
uv run --no-project --python 3.13   26.84s user 10.31s system 46% cpu 1:19.27 total

Possible fix direction (unverified)

Pass order=0 for labels in the DataTree branch (and decide one interpolation policy for images in both branches, ideally exposing order, see #725). Add a test asserting set(np.unique(out)) <= set(np.unique(in)) for labels after rotation/scaling.

Environment

uv run repro.py with the PEP 723 metadata in the script (fresh, isolated environment; spatialdata built from main @ ccf1ea0 (2026-08-28); Python 3.13, latest releases of the dependencies at run time: pandas 3.0, anndata 0.13, zarr 3.3, dask 2026.8, numpy 2.5, geopandas 1.1, shapely 2.1). macOS (arm64). Also reproduced in a second environment with pandas 2.3.3 / anndata 0.12.11 / numpy 2.4.4 / zarr 3.2.1.

Possibly related issues

#725, #1070


Automatically generated; discovered by an AI agent (Claude) and not yet reviewed by a human.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with src/spatialdata/_core/operations/transform.py and inspect the DataTree labels branch, then run the provided repro.py with uv to confirm the invented ids. Compare its behavior with the DataArray labels overload and verify that transformed multiscale labels retain only input ids for rotation and scaling, including through transform_to_coordinate_system().

Written by the indexing model from the issue text.

Assessment

Tech stack
python
Domain
data
Issue type
Bug
Difficulty
3/5
Estimated time
1-2 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
72/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.