Repository navigation
Description
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:
DirectLabelColormapvalidation errors because some color values becomeNaNValueError: zip() argument 2 is shorter than argument 1when 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:
- takes
layer.metadata["indices"] - merges those ids against a categorical
adata.obsvector - builds a category-to-color mapping from
adata.uns[f"{key}_colors"]if present - maps merged values to colors
- 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
*_colorsalready exists inadata.uns - avoid
zip(..., strict=True)for stored palettes, or validate/fallback gracefully before zipping
Environment
Observed with:
napari-spatialdata 0.7.0spatialdata 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
AlbertoFabbri93 commentedon Sep 11, 2026
I hit the same crash (
zip() argument 2 is shorter than argument 1at_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 buildsself.model.adataas 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.categorieson 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: samezip(vec.cat.categories, colors.tolist(), strict=True)crash.So the crash site has at least two independent triggers:
Both point at the same fix you already proposed (stop using
strict=Truefor 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.Fix cached categorical colors crash on per-element category subsets (s…
Fix cached categorical colors crash on per-element category subsets (s…
Fix cached categorical colors crash on per-element category subsets (s…
Fix cached categorical colors crash on per-element category subsets (s…