Skip to content

Title: Labels categorical coloring fails for label ids missing from table rows when *_colors is present in adata.uns #392

Open

Description

@ArneDefauw

Hi, I was developing a napari plugin that uses napari-spatialdata, and observed a bug in the labels categorical coloring path.

Summary

For labels layers, napari-spatialdata colors by merging the label ids from the layer against a categorical vector from adata.obs.

If some label ids are present in the labels layer but do not have a matching row in the table, and the corresponding *_colors key is present in adata.uns, coloring can fail with:

  • DirectLabelColormap validation errors because some color values become NaN
  • ValueError: zip() argument 2 is shorter than argument 1 when the stored palette length does not exactly match the category count

Why this looks incorrect

A labels element can legitimately contain ids that are not represented in the table, for example when:

  • the segmentation is larger than the annotated subset
  • the table only contains active / labeled / filtered instances
  • the table comes from a left join against the labels element

In that case, labels coloring should still succeed and give missing ids a fallback color.

Current behavior

In the categorical labels path, napari-spatialdata:

  1. takes layer.metadata["indices"]
  2. merges those ids against a categorical adata.obs vector
  3. builds a category-to-color mapping from adata.uns[f"{key}_colors"] if present
  4. maps merged values to colors
  5. builds a DirectLabelColormap

If *_colors is absent, a missing-value fallback color is added.

If *_colors is present, no missing fallback is added, so merge-missing rows stay NaN and later become invalid label colors.

Also, the stored palette branch uses strict zipping between categories and colors, so incomplete palettes raise immediately.

Expected behavior

  • Missing label ids should get a default “unannotated / missing” color instead of crashing.
  • Stored palettes should be handled defensively when their length does not exactly match the category count.

Suggested fix

In the categorical labels coloring path:

  • always handle merge-missing values explicitly, even when *_colors already exists in adata.uns
  • avoid zip(..., strict=True) for stored palettes, or validate/fallback gracefully before zipping

Environment

Observed with:

  • napari-spatialdata 0.7.0
  • spatialdata 0.7.2

Minimal repro:

import numpy as np
import pandas as pd
from anndata import AnnData
from napari.utils import DirectLabelColormap

# Simulate a labels layer with ids 1, 2, 3
element_indices = pd.Series([1, 2, 3], name="element_indices")

# Simulate a table that only has rows for ids 1 and 2
adata = AnnData(
    shape=(2, 0),
    obs=pd.DataFrame(
        {
            "instance_id": [1, 2],
            "user_class": pd.Categorical([0, 1], categories=[0, 1]),
        }
    ),
)

# Stored categorical palette is present
adata.uns["user_class_colors"] = np.array(["#80808099", "#ff0000"], dtype=object)

# Same logic as napari-spatialdata categorical labels coloring
vec = adata.obs[["instance_id", "user_class"]].set_index("instance_id")["user_class"]
colors = adata.uns["user_class_colors"]
color_dict = dict(zip(vec.cat.categories, colors.tolist(), strict=True))

merge_df = pd.merge(
    element_indices,
    vec,
    left_on="element_indices",
    right_index=True,
    how="left",
)

merge_df["color"] = merge_df["user_class"].map(color_dict)
print(merge_df)
# element_indices=3 has no table row, so color becomes float NaN

index_color_mapping = dict(zip(merge_df["element_indices"], merge_df["color"], strict=False))
print(index_color_mapping)
# {1: '#80808099', 2: '#ff0000', 3: nan}

DirectLabelColormap(color_dict=index_color_mapping)
# Fails because the color for label id 3 is NaN, not a valid color

I should be able to draft a PR for this issue next week I think

Activity

  1. AlbertoFabbri93 commented on Sep 11, 2026

    @AlbertoFabbri93

    I hit the same crash (zip() argument 2 is shorter than argument 1 at _widgets.py:229) from a different trigger that might be a useful second data point.

    Versions: napari-spatialdata 0.7.2, napari 0.9.0, spatialdata 0.8.0, anndata 0.13.3, scanpy 1.12.4, pandas 3.0.5, Python 3.14.6, macOS.

    In my case every label id in the mask did have a matching table row, not the "missing row" scenario from the issue description. The mismatch came purely from viewing a subset: a categorical column (manual_ann_cell_type) has 11 categories across ~221k cells spanning 47 labels elements ("cores"). adata.uns["manual_ann_cell_type_colors"] is correctly 11-long at the whole-table level. But opening a single core's labels layer in napari builds self.model.adata as a subset restricted to that core's ~5.5k cells, and that particular core only contains 9 of the 11 categories in practice. vec.cat.categories on the subset still reports all 11 (categories persist on a pandas categorical subset even when some aren't observed, normal pandas behavior), while whatever backs the subset's .uns["manual_ann_cell_type_colors"] ends up sized to 9: same zip(vec.cat.categories, colors.tolist(), strict=True) crash.

    So the crash site has at least two independent triggers:

    1. a label id with no matching table row (this issue's repro)
    2. a per-view subset (e.g. one labels/core element) simply not containing every category the global column has, even with full table coverage

    Both point at the same fix you already proposed (stop using strict=True for the stored-palette branch, fall back gracefully on any mismatch). This doesn't need a separate issue, just flagging that the fix should cover "category not observed in this view" as well as "row missing from table," since the second case doesn't require any join gap at all, just a normal multi-sample dataset.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions