diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 2d607fe9..0a09e9e9 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -2,8 +2,10 @@ from collections import abc from copy import copy +from typing import Any import dask +import dask.dataframe as dd import datashader as ds import geopandas as gpd import matplotlib @@ -18,6 +20,7 @@ from matplotlib.colors import ListedColormap, Normalize from scanpy._settings import settings as sc_settings from spatialdata import get_extent, get_values, join_spatialelement_table +from spatialdata._core.query.relational_query import match_table_to_element from spatialdata.models import PointsModel, ShapesModel, get_table_keys from spatialdata.transformations import set_transformation from spatialdata.transformations.transformations import Identity @@ -62,6 +65,23 @@ _Normalize = Normalize | abc.Sequence[Normalize] +def _coerce_categorical_source(cat_source: Any) -> pd.Categorical: + """Return a pandas Categorical from known, concrete sources only.""" + if isinstance(cat_source, dd.Series): + if pd.api.types.is_categorical_dtype(cat_source.dtype) and getattr(cat_source.cat, "known", True) is False: + cat_source = cat_source.cat.as_known() + cat_source = cat_source.compute() + + if isinstance(cat_source, pd.Series): + if pd.api.types.is_categorical_dtype(cat_source.dtype): + return cat_source.array + return pd.Categorical(cat_source) + if isinstance(cat_source, pd.Categorical): + return cat_source + + return pd.Categorical(pd.Series(cat_source)) + + def _split_colorbar_params(params: dict[str, object] | None) -> tuple[dict[str, object], dict[str, object], str | None]: """Split colorbar params into layout hints, Matplotlib kwargs, and label override.""" layout: dict[str, object] = {} @@ -173,14 +193,6 @@ def _render_shapes( if len(color_vector) == 0: color_vector = [render_params.cmap_params.na_color.get_hex_with_alpha()] - # filter by `groups` - if isinstance(groups, list) and color_source_vector is not None: - mask = color_source_vector.isin(groups) - shapes = shapes[mask] - shapes = shapes.reset_index(drop=True) - color_source_vector = color_source_vector[mask] - color_vector = color_vector[mask] - # continuous case: leave NaNs as NaNs; utils maps them to na_color during draw if color_source_vector is None and not values_are_categorical: _series = color_vector if isinstance(color_vector, pd.Series) else pd.Series(color_vector) @@ -299,14 +311,21 @@ def _render_shapes( transformed_element[col_for_color] = color_vector if color_source_vector is None else color_source_vector # Render shapes with datashader color_by_categorical = col_for_color is not None and color_source_vector is not None + if color_by_categorical: + cat_series = transformed_element[col_for_color] + if not pd.api.types.is_categorical_dtype(cat_series): + cat_series = cat_series.astype("category") + transformed_element[col_for_color] = cat_series + aggregate_with_reduction = None + continuous_nan_shapes = None if col_for_color is not None and (render_params.groups is None or len(render_params.groups) > 1): if color_by_categorical: - agg = cvs.polygons( - transformed_element, - geometry="geometry", - agg=ds.by(col_for_color, ds.count()), + # add nan as a category so that shapes with nan value are colored in the nan color + transformed_element[col_for_color] = ( + transformed_element[col_for_color].cat.add_categories("nan").fillna("nan") ) + agg = cvs.polygons(transformed_element, geometry="geometry", agg=ds.by(col_for_color, ds.count())) else: reduction_name = render_params.ds_reduction if render_params.ds_reduction is not None else "mean" logger.info( @@ -322,6 +341,13 @@ def _render_shapes( ) # save min and max values for drawing the colorbar aggregate_with_reduction = (agg.min(), agg.max()) + + # nan shapes need to be rendered separately (else: invisible, bc nan is skipped by aggregation methods) + transformed_element_nan_color = transformed_element[transformed_element[col_for_color].isnull()] + if len(transformed_element_nan_color) > 0: + continuous_nan_shapes = _datashader_aggregate_with_function( + "any", cvs, transformed_element_nan_color, None, "shapes" + ) else: agg = cvs.polygons(transformed_element, geometry="geometry", agg=ds.count()) @@ -355,12 +381,20 @@ def _render_shapes( agg = agg.where((agg <= norm.vmin) | (np.isnan(agg)), other=2) agg = agg.where((agg != norm.vmin) | (np.isnan(agg)), other=0.5) - color_key = ( - [_hex_no_alpha(x) for x in color_vector.categories.values] - if (type(color_vector) is pd.core.arrays.categorical.Categorical) - and (len(color_vector.categories.values) > 1) - else None - ) + color_key: dict[str, str] | None = None + if color_by_categorical and col_for_color is not None: + cat_series = _coerce_categorical_source(transformed_element[col_for_color]) + colors_arr = np.asarray(color_vector, dtype=object) + color_key = {} + for cat in cat_series.categories: + if cat == "nan": + key_color = render_params.cmap_params.na_color.get_hex() + else: + idx = np.flatnonzero(cat_series == cat) + key_color = colors_arr[idx[0]] if idx.size else render_params.cmap_params.na_color.get_hex() + if isinstance(key_color, str) and key_color.startswith("#"): + key_color = _hex_no_alpha(key_color) + color_key[str(cat)] = key_color if color_by_categorical or col_for_color is None: ds_cmap = None @@ -393,7 +427,19 @@ def _render_shapes( min_alpha=_convert_alpha_to_datashader_range(render_params.fill_alpha), span=ds_span, clip=norm.clip, - ) + ) # prevent min_alpha == 255, bc that led to fully colored test plots instead of just colored points/shapes + + if continuous_nan_shapes is not None: + # for coloring by continuous variable: render nan shapes separately + nan_color_hex = render_params.cmap_params.na_color.get_hex() + if nan_color_hex.startswith("#") and len(nan_color_hex) == 9: + nan_color_hex = nan_color_hex[:7] + continuous_nan_shapes = ds.tf.shade( + continuous_nan_shapes, + cmap=nan_color_hex, + how="linear", + min_alpha=_convert_alpha_to_datashader_range(render_params.fill_alpha), + ) # shade outlines if needed if render_params.outline_alpha[0] > 0 and isinstance(render_params.outline_params.outer_outline_color, Color): @@ -436,6 +482,17 @@ def _render_shapes( extent=x_ext + y_ext, ) + if continuous_nan_shapes is not None: + # for coloring by continuous variable: render nan points separately + rgba_image_nan, trans_data_nan = _create_image_from_datashader_result(continuous_nan_shapes, factor, ax) + _ax_show_and_transform( + rgba_image_nan, + trans_data_nan, + ax, + zorder=render_params.zorder, + alpha=render_params.fill_alpha, + extent=x_ext + y_ext, + ) rgba_image, trans_data = _create_image_from_datashader_result(ds_result, factor, ax) _cax = _ax_show_and_transform( rgba_image, @@ -508,7 +565,7 @@ def _render_shapes( _cax = _get_collection_shape( shapes=shapes, s=render_params.scale, - c=color_vector, + c=color_vector.copy(), # copy bc c is modified in _get_collection_shape render_params=render_params, rasterized=sc_settings._vector_friendly, cmap=render_params.cmap_params.cmap, @@ -525,31 +582,20 @@ def _render_shapes( path.vertices = trans.transform(path.vertices) if not values_are_categorical: + # Respect explicit vmin/vmax; otherwise derive from finite numeric values, falling back to [0, 1] if unavailable vmin = render_params.cmap_params.norm.vmin vmax = render_params.cmap_params.norm.vmax if vmin is None or vmax is None: - # Extract numeric values only (filter out strings and other non-numeric types) - if isinstance(color_vector, np.ndarray): - if np.issubdtype(color_vector.dtype, np.number): - # Already numeric, can use directly - numeric_values = color_vector - else: - # Mixed types - extract only numeric values using pandas - numeric_values = pd.to_numeric(color_vector, errors="coerce") - numeric_values = numeric_values[np.isfinite(numeric_values)] - if len(numeric_values) > 0: - if vmin is None: - vmin = float(np.nanmin(numeric_values)) - if vmax is None: - vmax = float(np.nanmax(numeric_values)) - else: - # No numeric values found, use defaults - if vmin is None: - vmin = 0.0 - if vmax is None: - vmax = 1.0 + numeric_values = pd.to_numeric(np.asarray(color_vector), errors="coerce") + finite_mask = np.isfinite(numeric_values) + if finite_mask.any(): + data_min = float(np.nanmin(numeric_values[finite_mask])) + data_max = float(np.nanmax(numeric_values[finite_mask])) + if vmin is None: + vmin = data_min + if vmax is None: + vmax = data_max else: - # Not a numpy array, use defaults if vmin is None: vmin = 0.0 if vmax is None: @@ -616,9 +662,13 @@ def _render_points( groups = render_params.groups palette = render_params.palette + if isinstance(groups, str): + groups = [groups] + sdata_filt = sdata.filter_by_coordinate_system( coordinate_system=coordinate_system, - filter_tables=bool(table_name), + # keep tables intact; we pick the right rows ourselves via the table metadata + filter_tables=False, ) points = sdata.points[element] @@ -656,26 +706,11 @@ def _render_points( ) added_color_from_table = True - if groups is not None and col_for_color is not None: - if col_for_color in points.columns: - points_color_values = points[col_for_color] - else: - points_color_values = get_values( - value_key=col_for_color, - sdata=sdata_filt, - element_name=element, - table_name=table_name, - table_layer=table_layer, - ) - points_color_values = points.merge(points_color_values, how="left", left_index=True, right_index=True)[ - col_for_color - ] - points = points[points_color_values.isin(groups)] - if len(points) <= 0: - raise ValueError(f"None of the groups {groups} could be found in the column '{col_for_color}'.") - n_points = len(points) points_pd_with_color = points + # When we pull colors from a table, keep the raw points (with color) for later, + # but strip the color column from the model we register in sdata so color lookup + # keeps using the table instead of seeing duplicates on the points dataframe. points_for_model = ( points_pd_with_color.drop(columns=[col_for_color], errors="ignore") if added_color_from_table and col_for_color is not None @@ -690,20 +725,19 @@ def _render_points( dtype=points[["x", "y"]].values.dtype, ) else: - adata_obs = sdata_filt[table_name].obs + matched_table = match_table_to_element(sdata=sdata, element_name=element, table_name=table_name) + adata_obs = matched_table.obs.copy() # if the points are colored by values in X (or a different layer), add the values to obs - if col_for_color in sdata_filt[table_name].var_names: + if col_for_color in matched_table.var_names: if table_layer is None: - adata_obs[col_for_color] = sdata_filt[table_name][:, col_for_color].X.flatten().copy() + adata_obs[col_for_color] = matched_table[:, col_for_color].X.flatten().copy() else: - adata_obs[col_for_color] = sdata_filt[table_name][:, col_for_color].layers[table_layer].flatten().copy() - if groups is not None: - adata_obs = adata_obs[adata_obs[col_for_color].isin(groups)] + adata_obs[col_for_color] = matched_table[:, col_for_color].layers[table_layer].flatten().copy() adata = AnnData( X=points[["x", "y"]].values, obs=adata_obs, dtype=points[["x", "y"]].values.dtype, - uns=sdata_filt[table_name].uns, + uns=matched_table.uns, ) sdata_filt[table_name] = adata @@ -711,8 +745,8 @@ def _render_points( # Convert back to dask dataframe to modify sdata transformation_in_cs = sdata_filt.points[element].attrs["transform"][coordinate_system] - points = dask.dataframe.from_pandas(points_for_model, npartitions=1) - sdata_filt.points[element] = PointsModel.parse(points, coordinates={"x": "x", "y": "y"}) + points_dd = dask.dataframe.from_pandas(points_for_model, npartitions=1) + sdata_filt.points[element] = PointsModel.parse(points_dd, coordinates={"x": "x", "y": "y"}) # restore transformation in coordinate system of interest set_transformation( element=sdata_filt.points[element], @@ -740,9 +774,14 @@ def _render_points( ) assert isinstance(default_color, Color) # shut up mypy + color_element = sdata_filt.points[element] + # Always pass the table through to color resolution; dropping the color column + # from the registered points (see above) avoids duplicate-origin ambiguities. + color_table_name = table_name + color_source_vector, color_vector, _ = _set_color_source_vec( sdata=sdata_filt, - element=points, + element=color_element, element_name=element, value_to_plot=col_for_color, groups=groups, @@ -750,7 +789,7 @@ def _render_points( na_color=default_color, cmap_params=render_params.cmap_params, alpha=render_params.alpha, - table_name=table_name, + table_name=color_table_name, render_type="points", coordinate_system=coordinate_system, ) @@ -763,7 +802,7 @@ def _render_points( transformation=transformation_in_cs, to_coordinate_system=coordinate_system, ) - points = points_with_color_dd + points_dd = points_with_color_dd # color_source_vector is None when the values aren't categorical if color_source_vector is None and render_params.transfunc is not None: @@ -778,7 +817,7 @@ def _render_points( if method is None: method = "datashader" if n_points > 10000 else "matplotlib" - if method != "matplotlib": + if method == "datashader": # we only notify the user when we switched away from matplotlib logger.info( f"Using '{method}' backend with '{render_params.ds_reduction}' as reduction" @@ -787,7 +826,6 @@ def _render_points( " this behaviour." ) - if method == "datashader": # NOTE: s in matplotlib is in units of points**2 # use dpi/100 as a factor for cases where dpi!=100 px = int(np.round(np.sqrt(render_params.size) * (fig_params.fig.dpi / 100))) @@ -806,15 +844,52 @@ def _render_points( # use datashader for the visualization of points cvs = ds.Canvas(plot_width=plot_width, plot_height=plot_height, x_range=x_ext, y_range=y_ext) - color_by_categorical = col_for_color is not None and transformed_element[col_for_color].values.dtype in ( - object, - "categorical", + # ensure color column exists on the transformed element with positional alignment + if col_for_color is not None and col_for_color not in transformed_element.columns: + series_index = transformed_element.index + if color_source_vector is not None: + if isinstance(color_source_vector, dd.Series): + color_source_vector = color_source_vector.compute() + source_series = ( + color_source_vector.reindex(series_index) + if isinstance(color_source_vector, pd.Series) + else pd.Series(color_source_vector, index=series_index) + ) + transformed_element = transformed_element.assign(col_for_color=source_series) + else: + if isinstance(color_vector, dd.Series): + color_vector = color_vector.compute() + color_series = ( + color_vector.reindex(series_index) + if isinstance(color_vector, pd.Series) + else pd.Series(color_vector, index=series_index) + ) + transformed_element = transformed_element.assign(col_for_color=color_series) + transformed_element = transformed_element.rename(columns={"col_for_color": col_for_color}) + + color_dtype = transformed_element[col_for_color].dtype if col_for_color is not None else None + color_by_categorical = col_for_color is not None and ( + color_source_vector is not None + or pd.api.types.is_categorical_dtype(color_dtype) + or pd.api.types.is_object_dtype(color_dtype) + or pd.api.types.is_string_dtype(color_dtype) ) - if color_by_categorical and transformed_element[col_for_color].values.dtype == object: + if color_by_categorical and not pd.api.types.is_categorical_dtype(color_dtype): transformed_element[col_for_color] = transformed_element[col_for_color].astype("category") + aggregate_with_reduction = None - if col_for_color is not None and (render_params.groups is None or len(render_params.groups) > 1): + continuous_nan_points = None + if col_for_color is not None: if color_by_categorical: + # add nan as category so that nan points are shown in the nan color + cat_series = transformed_element[col_for_color] + if not pd.api.types.is_categorical_dtype(cat_series): + cat_series = cat_series.astype("category") + if hasattr(cat_series.cat, "as_known"): + cat_series = cat_series.cat.as_known() + if "nan" not in cat_series.cat.categories: + cat_series = cat_series.cat.add_categories("nan") + transformed_element[col_for_color] = cat_series.fillna("nan") agg = cvs.points(transformed_element, "x", "y", agg=ds.by(col_for_color, ds.count())) else: reduction_name = render_params.ds_reduction if render_params.ds_reduction is not None else "sum" @@ -831,6 +906,12 @@ def _render_points( ) # save min and max values for drawing the colorbar aggregate_with_reduction = (agg.min(), agg.max()) + # nan points need to be rendered separately (else: invisible, bc nan is skipped by aggregation methods) + transformed_element_nan_color = transformed_element[transformed_element[col_for_color].isnull()] + if len(transformed_element_nan_color) > 0: + continuous_nan_points = _datashader_aggregate_with_function( + "any", cvs, transformed_element_nan_color, None, "points" + ) else: agg = cvs.points(transformed_element, "x", "y", agg=ds.count()) @@ -850,20 +931,28 @@ def _render_points( agg = agg.where((agg <= norm.vmin) | (np.isnan(agg)), other=2) agg = agg.where((agg != norm.vmin) | (np.isnan(agg)), other=0.5) - color_key: list[str] | None = ( - list(color_vector.categories.values) - if (type(color_vector) is pd.core.arrays.categorical.Categorical) - and (len(color_vector.categories.values) > 1) - else None - ) - - # remove alpha from color if it's hex - if color_key is not None and all(len(x) == 9 for x in color_key) and color_key[0][0] == "#": - color_key = [x[:-2] for x in color_key] - if isinstance(color_vector[0], str) and ( - color_vector is not None and all(len(x) == 9 for x in color_vector) and color_vector[0][0] == "#" + color_key: dict[str, str] | None = None + if color_by_categorical and col_for_color is not None: + cat_series = _coerce_categorical_source(transformed_element[col_for_color]) + colors_arr = np.asarray(color_vector, dtype=object) + color_key = {} + for cat in cat_series.categories: + if cat == "nan": + key_color = render_params.cmap_params.na_color.get_hex() + else: + idx = np.flatnonzero(cat_series == cat) + key_color = colors_arr[idx[0]] if idx.size else render_params.cmap_params.na_color.get_hex() + if isinstance(key_color, str) and key_color.startswith("#"): + key_color = _hex_no_alpha(key_color) + color_key[str(cat)] = key_color + + if ( + color_vector is not None + and len(color_vector) > 0 + and isinstance(color_vector[0], str) + and color_vector[0].startswith("#") ): - color_vector = np.asarray([x[:-2] for x in color_vector]) + color_vector = np.asarray([_hex_no_alpha(x) for x in color_vector]) if color_by_categorical or col_for_color is None: ds_result = _datashader_map_aggregate_to_color( @@ -896,6 +985,29 @@ def _render_points( min_alpha=_convert_alpha_to_datashader_range(render_params.alpha), ) + if continuous_nan_points is not None: + # for coloring by continuous variable: render nan points separately + nan_color_hex = render_params.cmap_params.na_color.get_hex() + if nan_color_hex.startswith("#") and len(nan_color_hex) == 9: + nan_color_hex = nan_color_hex[:7] + continuous_nan_points = ds.tf.spread(continuous_nan_points, px=px, how="max") + continuous_nan_points = ds.tf.shade( + continuous_nan_points, + cmap=nan_color_hex, + how="linear", + ) + + if continuous_nan_points is not None: + # for coloring by continuous variable: render nan points separately + rgba_image_nan, trans_data_nan = _create_image_from_datashader_result(continuous_nan_points, factor, ax) + _ax_show_and_transform( + rgba_image_nan, + trans_data_nan, + ax, + zorder=render_params.zorder, + alpha=render_params.alpha, + extent=x_ext + y_ext, + ) rgba_image, trans_data = _create_image_from_datashader_result(ds_result, factor, ax) _ax_show_and_transform( rgba_image, @@ -936,6 +1048,7 @@ def _render_points( alpha=render_params.alpha, transform=trans_data, zorder=render_params.zorder, + plotnonfinite=True, # nan points should be rendered as well ) cax = ax.add_collection(_cax) if update_parameters: @@ -944,10 +1057,27 @@ def _render_points( ax.set_xbound(extent["x"]) ax.set_ybound(extent["y"]) - if ( - len(set(color_vector)) != 1 - or list(set(color_vector))[0] != render_params.cmap_params.na_color.get_hex_with_alpha() - ): + # Decide whether there is any informative color variation. + # We skip legend/colorbar only if all colors are equal to the NA color. + want_decorations = True + if color_vector is None: + want_decorations = False + else: + cv = np.asarray(color_vector) + if cv.size == 0: + want_decorations = False + else: + unique_vals = set(cv.tolist()) + if len(unique_vals) == 1: + only_val = next(iter(unique_vals)) + na_hex = render_params.cmap_params.na_color.get_hex() + if isinstance(only_val, str) and only_val.startswith("#") and na_hex.startswith("#"): + only_norm = _hex_no_alpha(only_val) + na_norm = _hex_no_alpha(na_hex) + if only_norm == na_norm: + want_decorations = False + + if want_decorations: if color_source_vector is None: palette = ListedColormap(dict.fromkeys(color_vector)) else: diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 11d5d10d..f1dabdbd 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -371,17 +371,19 @@ def _get_collection_shape( def _as_rgba_array(x: Any) -> np.ndarray: return np.asarray(ColorConverter().to_rgba_array(x)) + n_shapes = len(shapes) + # Case A: per-row numeric colors given as Nx3 or Nx4 float array if ( c_arr.ndim == 2 - and c_arr.shape[0] == len(shapes) + and c_arr.shape[0] == n_shapes and c_arr.shape[1] in (3, 4) and np.issubdtype(c_arr.dtype, np.number) ): fill_c = _as_rgba_array(c_arr) # Case B: continuous numeric vector len == n_shapes (possibly with NaNs) - elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and np.issubdtype(c_arr.dtype, np.number): + elif c_arr.ndim == 1 and len(c_arr) == n_shapes and np.issubdtype(c_arr.dtype, np.number): finite_mask = np.isfinite(c_arr) # Select or build a normalization that ignores NaNs for scaling @@ -403,7 +405,8 @@ def _as_rgba_array(x: Any) -> np.ndarray: if finite_mask.any(): fill_c[finite_mask] = cmap(used_norm(c_arr[finite_mask])) - elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and c_arr.dtype == object: + # Case B': 1D object/str column: may contain numeric-like and/or explicit color specs + elif c_arr.ndim == 1 and len(c_arr) == n_shapes and c_arr.dtype == object: # Split into numeric vs color-like c_series = pd.Series(c_arr, copy=False) num = pd.to_numeric(c_series, errors="coerce").to_numpy() @@ -434,15 +437,19 @@ def _as_rgba_array(x: Any) -> np.ndarray: else: fill_c = _as_rgba_array(c) - # Apply optional fill alpha without destroying existing transparency + # Apply global fill alpha from render_params + if getattr(render_params, "fill_alpha", None) is not None: + fill_c[..., -1] *= float(render_params.fill_alpha) + + # Override with explicit fill_alpha if provided if fill_alpha is not None: nonzero_alpha = fill_c[..., -1] > 0 - fill_c[nonzero_alpha, -1] = fill_alpha + fill_c[nonzero_alpha, -1] = float(fill_alpha) # Outline handling - if outline_alpha and outline_alpha > 0.0: + if outline_alpha is not None and outline_alpha > 0.0: outline_c_array = _as_rgba_array(outline_color) - outline_c_array[..., -1] = outline_alpha + outline_c_array[..., -1] = float(outline_alpha) outline_c = outline_c_array.tolist() else: outline_c = [None] * fill_c.shape[0] @@ -536,9 +543,7 @@ def _create_patches( rows.append(pr) return pd.DataFrame(rows) - patches = _create_patches( - shapes_df, fill_c.tolist(), outline_c.tolist() if hasattr(outline_c, "tolist") else outline_c, s - ) + patches = _create_patches(shapes_df, fill_c.tolist(), outline_c, s) return PatchCollection( patches["geometry"].values.tolist(), @@ -998,7 +1003,8 @@ def _set_color_source_vec( if len(origins) > 1: raise ValueError( - f"Color key '{value_to_plot}' for element '{element_name}' been found in multiple locations: {origins}." + f"Color key '{value_to_plot}' for element '{element_name}' was found in multiple locations: {origins}. " + "Please keep it in exactly one place (preferably on the points parquet for speed) to avoid ambiguity." ) if len(origins) == 1 and value_to_plot is not None: @@ -1021,6 +1027,64 @@ def _set_color_source_vec( color_source_vector if isinstance(color_source_vector, pd.Series) else pd.Series(color_source_vector) ) + if color_series.isna().all(): + element_label = _format_element_name(element_name) + location = f"table '{table_name}'" if table_name is not None else "the element" + # Provide dtype hints to help diagnose index alignment issues + dtype_hints: list[str] = [] + color_index_dtype = getattr(color_series.index, "dtype", None) + element_index_dtype = ( + getattr(getattr(element, "index", None), "dtype", None) if element is not None else None + ) + + table_instance_dtype = None + table_index_dtype = None + instance_key = None + if table_name is not None and sdata is not None and table_name in sdata.tables: + table = sdata.tables[table_name] + table_index_dtype = getattr(getattr(table, "obs", None), "index", None) + if table_index_dtype is not None: + table_index_dtype = getattr(table_index_dtype, "dtype", None) + try: + _, _, instance_key = get_table_keys(table) + except (KeyError, ValueError, TypeError, AttributeError): + instance_key = None + if instance_key is not None and hasattr(table, "obs") and instance_key in table.obs: + table_instance_dtype = table.obs[instance_key].dtype + + if ( + element_index_dtype is not None + and table_instance_dtype is not None + and element_index_dtype != table_instance_dtype + ): + dtype_hints.append( + f"element index dtype is {element_index_dtype}, '{instance_key}' dtype is {table_instance_dtype}" + ) + if ( + table_index_dtype is not None + and table_instance_dtype is not None + and table_index_dtype != table_instance_dtype + ): + dtype_hints.append( + f"table index dtype is {table_index_dtype}, '{instance_key}' dtype is {table_instance_dtype}" + ) + if ( + color_index_dtype is not None + and element_index_dtype is not None + and color_index_dtype != element_index_dtype + ): + dtype_hints.append( + f"color index dtype is {color_index_dtype}, element index dtype is {element_index_dtype}" + ) + + dtype_hint = f" (hint: {'; '.join(dtype_hints)})" if dtype_hints else "" + raise ValueError( + f"Column '{value_to_plot}' for element '{element_label}' contains only missing values after aligning " + f"with {location}. This usually means the instance ids/indices could not be aligned or converted, so " + "colors cannot be determined. Please ensure the table annotates the element with matching instance ids." + f"{dtype_hint}" + ) + kind, processed = _infer_color_data_kind( series=color_series, value_to_plot=value_to_plot, @@ -1045,6 +1109,9 @@ def _set_color_source_vec( return None, numeric_vector, False assert isinstance(processed, pd.Categorical) + if not processed.ordered: + # ensure deterministic category order when the source is unordered (e.g., from a Python set) + processed = processed.reorder_categories(sorted(processed.categories)) color_source_vector = processed # convert, e.g., `pd.Series` # Use the provided table_name parameter, fall back to only one present @@ -1121,6 +1188,12 @@ def _set_color_source_vec( # do not rename categories, as colors need not be unique color_vector = color_source_vector.map(color_mapping) + # nan handling: only add the NA category if needed, and store it as a hex string + na_color_hex = na_color.get_hex_with_alpha() if isinstance(na_color, Color) else str(na_color) + if pd.isna(color_vector).any(): + if na_color_hex not in color_vector.categories: + color_vector = color_vector.add_categories(na_color_hex) + color_vector[pd.isna(color_vector)] = na_color_hex return color_source_vector, color_vector, True @@ -1148,15 +1221,18 @@ def _map_color_seg( if pd.api.types.is_categorical_dtype(color_vector.dtype): # Case A: users wants to plot a categorical column - if np.any(color_source_vector.isna()): - cell_id[color_source_vector.isna()] = 0 val_im: ArrayLike = map_array(seg.copy(), cell_id, color_vector.codes + 1) cols = colors.to_rgba_array(color_vector.categories) elif pd.api.types.is_numeric_dtype(color_vector.dtype): # Case B: user wants to plot a continous column if isinstance(color_vector, pd.Series): color_vector = color_vector.to_numpy() - cols = cmap_params.cmap(cmap_params.norm(color_vector)) + # normalize only the not nan values, else the whole array would contain only nan values + normed_color_vector = color_vector.copy().astype(float) + normed_color_vector[~np.isnan(normed_color_vector)] = cmap_params.norm( + normed_color_vector[~np.isnan(normed_color_vector)] + ) + cols = cmap_params.cmap(normed_color_vector) val_im = map_array(seg.copy(), cell_id, cell_id) else: # Case C: User didn't specify any colors @@ -2639,6 +2715,7 @@ def _validate_col_for_column_table( elif table_name is not None: tables = get_element_annotators(sdata, element_name) if table_name not in tables: + logger.warning(f"Table '{table_name}' does not annotate element '{element_name}'.") raise KeyError(f"Table '{table_name}' does not annotate element '{element_name}'.") if col_for_color not in sdata[table_name].obs.columns and col_for_color not in sdata[table_name].var_names: raise KeyError( @@ -3032,7 +3109,7 @@ def _prepare_transformation( def _datashader_map_aggregate_to_color( agg: DataArray, cmap: str | list[str] | ListedColormap, - color_key: None | list[str] = None, + color_key: list[str] | dict[str, str] | None = None, min_alpha: float = 40, span: None | list[float] = None, clip: bool = True, diff --git a/tests/_images/Labels_can_annotate_labels_with_nan_in_table_X_continuous.png b/tests/_images/Labels_can_annotate_labels_with_nan_in_table_X_continuous.png new file mode 100644 index 00000000..0848f5ca Binary files /dev/null and b/tests/_images/Labels_can_annotate_labels_with_nan_in_table_X_continuous.png differ diff --git a/tests/_images/Labels_can_annotate_labels_with_nan_in_table_obs_categorical.png b/tests/_images/Labels_can_annotate_labels_with_nan_in_table_obs_categorical.png new file mode 100644 index 00000000..acc01793 Binary files /dev/null and b/tests/_images/Labels_can_annotate_labels_with_nan_in_table_obs_categorical.png differ diff --git a/tests/_images/Labels_can_annotate_labels_with_nan_in_table_obs_continuous.png b/tests/_images/Labels_can_annotate_labels_with_nan_in_table_obs_continuous.png new file mode 100644 index 00000000..edb7ee70 Binary files /dev/null and b/tests/_images/Labels_can_annotate_labels_with_nan_in_table_obs_continuous.png differ diff --git a/tests/_images/Labels_can_color_labels_by_categorical_variable_in_other_table.png b/tests/_images/Labels_can_color_labels_by_categorical_variable_in_other_table.png index b3db4ac5..62c74bba 100644 Binary files a/tests/_images/Labels_can_color_labels_by_categorical_variable_in_other_table.png and b/tests/_images/Labels_can_color_labels_by_categorical_variable_in_other_table.png differ diff --git a/tests/_images/Labels_respects_custom_colors_from_uns_with_groups_and_palette.png b/tests/_images/Labels_respects_custom_colors_from_uns_with_groups_and_palette.png index 499fb50f..46230f10 100644 Binary files a/tests/_images/Labels_respects_custom_colors_from_uns_with_groups_and_palette.png and b/tests/_images/Labels_respects_custom_colors_from_uns_with_groups_and_palette.png differ diff --git a/tests/_images/Labels_subset_categorical_label_maintains_order.png b/tests/_images/Labels_subset_categorical_label_maintains_order.png index 9e1c2cc6..a18d77cd 100644 Binary files a/tests/_images/Labels_subset_categorical_label_maintains_order.png and b/tests/_images/Labels_subset_categorical_label_maintains_order.png differ diff --git a/tests/_images/Labels_subset_categorical_label_maintains_order_when_palette_overwrite.png b/tests/_images/Labels_subset_categorical_label_maintains_order_when_palette_overwrite.png index 6d0806c5..34f063bf 100644 Binary files a/tests/_images/Labels_subset_categorical_label_maintains_order_when_palette_overwrite.png and b/tests/_images/Labels_subset_categorical_label_maintains_order_when_palette_overwrite.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_df_categorical.png b/tests/_images/Points_can_annotate_points_with_nan_in_df_categorical.png new file mode 100644 index 00000000..dca4da2b Binary files /dev/null and b/tests/_images/Points_can_annotate_points_with_nan_in_df_categorical.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_df_categorical_datashader.png b/tests/_images/Points_can_annotate_points_with_nan_in_df_categorical_datashader.png new file mode 100644 index 00000000..d539271e Binary files /dev/null and b/tests/_images/Points_can_annotate_points_with_nan_in_df_categorical_datashader.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_df_continuous.png b/tests/_images/Points_can_annotate_points_with_nan_in_df_continuous.png new file mode 100644 index 00000000..9a5dabb3 Binary files /dev/null and b/tests/_images/Points_can_annotate_points_with_nan_in_df_continuous.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_df_continuous_datashader.png b/tests/_images/Points_can_annotate_points_with_nan_in_df_continuous_datashader.png new file mode 100644 index 00000000..c7d6a536 Binary files /dev/null and b/tests/_images/Points_can_annotate_points_with_nan_in_df_continuous_datashader.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_table_X_continuous.png b/tests/_images/Points_can_annotate_points_with_nan_in_table_X_continuous.png new file mode 100644 index 00000000..126f4660 Binary files /dev/null and b/tests/_images/Points_can_annotate_points_with_nan_in_table_X_continuous.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_table_X_continuous_datashader.png b/tests/_images/Points_can_annotate_points_with_nan_in_table_X_continuous_datashader.png new file mode 100644 index 00000000..bf0888c0 Binary files /dev/null and b/tests/_images/Points_can_annotate_points_with_nan_in_table_X_continuous_datashader.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_categorical.png b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_categorical.png new file mode 100644 index 00000000..a62c0017 Binary files /dev/null and b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_categorical.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_categorical_datashader.png b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_categorical_datashader.png new file mode 100644 index 00000000..53b515b4 Binary files /dev/null and b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_categorical_datashader.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_categorical_matplotlib.png b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_categorical_matplotlib.png new file mode 100644 index 00000000..0c2fb7b4 Binary files /dev/null and b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_categorical_matplotlib.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_continuous.png b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_continuous.png new file mode 100644 index 00000000..a36cea06 Binary files /dev/null and b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_continuous.png differ diff --git a/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_continuous_datashader.png b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_continuous_datashader.png new file mode 100644 index 00000000..21bafaf2 Binary files /dev/null and b/tests/_images/Points_can_annotate_points_with_nan_in_table_obs_continuous_datashader.png differ diff --git a/tests/_images/Points_can_annotate_points_with_table_and_groups.png b/tests/_images/Points_can_annotate_points_with_table_and_groups.png index 00e7f427..17358b18 100644 Binary files a/tests/_images/Points_can_annotate_points_with_table_and_groups.png and b/tests/_images/Points_can_annotate_points_with_table_and_groups.png differ diff --git a/tests/_images/Points_datashader_can_color_by_category.png b/tests/_images/Points_datashader_can_color_by_category.png index 1c856028..1badee5f 100644 Binary files a/tests/_images/Points_datashader_can_color_by_category.png and b/tests/_images/Points_datashader_can_color_by_category.png differ diff --git a/tests/_images/Points_datashader_colors_from_table_obs.png b/tests/_images/Points_datashader_colors_from_table_obs.png index e1f156fc..fe6ca8f6 100644 Binary files a/tests/_images/Points_datashader_colors_from_table_obs.png and b/tests/_images/Points_datashader_colors_from_table_obs.png differ diff --git a/tests/_images/Points_points_categorical_color_column_datashader.png b/tests/_images/Points_points_categorical_color_column_datashader.png index 373bc983..3fc4934a 100644 Binary files a/tests/_images/Points_points_categorical_color_column_datashader.png and b/tests/_images/Points_points_categorical_color_column_datashader.png differ diff --git a/tests/_images/Shapes_can_annotate_shapes_with_nan_in_df_categorical.png b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_df_categorical.png new file mode 100644 index 00000000..9f669ead Binary files /dev/null and b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_df_categorical.png differ diff --git a/tests/_images/Shapes_can_annotate_shapes_with_nan_in_df_categorical_datashader.png b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_df_categorical_datashader.png new file mode 100644 index 00000000..aabdc7bf Binary files /dev/null and b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_df_categorical_datashader.png differ diff --git a/tests/_images/Shapes_can_annotate_shapes_with_nan_in_df_continuous.png b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_df_continuous.png new file mode 100644 index 00000000..817d2891 Binary files /dev/null and b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_df_continuous.png differ diff --git a/tests/_images/Shapes_can_annotate_shapes_with_nan_in_df_continuous_datashader.png b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_df_continuous_datashader.png new file mode 100644 index 00000000..87c6966d Binary files /dev/null and b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_df_continuous_datashader.png differ diff --git a/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_X_continuous.png b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_X_continuous.png new file mode 100644 index 00000000..c53187d4 Binary files /dev/null and b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_X_continuous.png differ diff --git a/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_X_continuous_datashader.png b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_X_continuous_datashader.png new file mode 100644 index 00000000..221a4d0f Binary files /dev/null and b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_X_continuous_datashader.png differ diff --git a/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_obs_categorical.png b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_obs_categorical.png new file mode 100644 index 00000000..be6e407b Binary files /dev/null and b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_obs_categorical.png differ diff --git a/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_obs_categorical_datashader.png b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_obs_categorical_datashader.png new file mode 100644 index 00000000..32b8dfa5 Binary files /dev/null and b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_obs_categorical_datashader.png differ diff --git a/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_obs_continuous.png b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_obs_continuous.png new file mode 100644 index 00000000..a8f5a938 Binary files /dev/null and b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_obs_continuous.png differ diff --git a/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_obs_continuous_datashader.png b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_obs_continuous_datashader.png new file mode 100644 index 00000000..62e646da Binary files /dev/null and b/tests/_images/Shapes_can_annotate_shapes_with_nan_in_table_obs_continuous_datashader.png differ diff --git a/tests/_images/Shapes_can_filter_with_groups.png b/tests/_images/Shapes_can_filter_with_groups.png index 4f9b71be..5d66f966 100644 Binary files a/tests/_images/Shapes_can_filter_with_groups.png and b/tests/_images/Shapes_can_filter_with_groups.png differ diff --git a/tests/_images/Shapes_datashader_can_color_by_category.png b/tests/_images/Shapes_datashader_can_color_by_category.png index 7886c694..bcffb467 100644 Binary files a/tests/_images/Shapes_datashader_can_color_by_category.png and b/tests/_images/Shapes_datashader_can_color_by_category.png differ diff --git a/tests/_images/Shapes_datashader_can_color_by_category_with_cmap.png b/tests/_images/Shapes_datashader_can_color_by_category_with_cmap.png index c72a92bc..7a7fb9d9 100644 Binary files a/tests/_images/Shapes_datashader_can_color_by_category_with_cmap.png and b/tests/_images/Shapes_datashader_can_color_by_category_with_cmap.png differ diff --git a/tests/conftest.py b/tests/conftest.py index 2299f126..4a33148b 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -173,6 +173,48 @@ def test_sdata_multiple_images_diverging_dims(): return sd.SpatialData(images=images) +@pytest.fixture +def sdata_blobs_points_with_nans_in_table() -> SpatialData: + """Get blobs sdata where the table annotates the points and includes nan values""" + blob = blobs() + n_obs = len(blob["blobs_points"]) + adata = AnnData(get_standard_RNG().normal(size=(n_obs, 2))) + adata.X[0:30, 0] = np.nan + adata.var = pd.DataFrame({}, index=["col1", "col2"]) + adata.obs = pd.DataFrame(get_standard_RNG().normal(size=(n_obs, 3)), columns=["col_a", "col_b", "col_c"]) + adata.obs.iloc[0:30, adata.obs.columns.get_loc("col_a")] = np.nan + adata.obs["instance_id"] = np.arange(adata.n_obs) + cat_pattern = ["a", "b", np.nan] + repeats = (n_obs + len(cat_pattern) - 1) // len(cat_pattern) + adata.obs["category"] = pd.Categorical((cat_pattern * repeats)[:n_obs]) + adata.obs["instance_id"] = list(range(adata.n_obs)) + adata.obs["region"] = "blobs_points" + table = TableModel.parse(adata=adata, region_key="region", instance_key="instance_id", region="blobs_points") + blob["table"] = table + return blob + + +@pytest.fixture +def sdata_blobs_shapes_with_nans_in_table() -> SpatialData: + """Get blobs sdata where the table annotates the shapes and includes nan values""" + blob = blobs() + n_obs = len(blob["blobs_polygons"]) + adata = AnnData(get_standard_RNG().normal(size=(n_obs, 2))) + adata.X[0, 0] = np.nan + adata.var = pd.DataFrame({}, index=["col1", "col2"]) + adata.obs = pd.DataFrame(get_standard_RNG().normal(size=(n_obs, 3)), columns=["col_a", "col_b", "col_c"]) + adata.obs.iloc[0, adata.obs.columns.get_loc("col_a")] = np.nan + adata.obs["instance_id"] = np.arange(adata.n_obs) + cat_pattern = ["a", "b", np.nan, "c", "a"] + repeats = (n_obs + len(cat_pattern) - 1) // len(cat_pattern) + adata.obs["category"] = pd.Categorical((cat_pattern * repeats)[:n_obs]) + adata.obs["instance_id"] = list(range(adata.n_obs)) + adata.obs["region"] = "blobs_polygons" + table = TableModel.parse(adata=adata, region_key="region", instance_key="instance_id", region="blobs_polygons") + blob["table"] = table + return blob + + @pytest.fixture def sdata_blobs_shapes_annotated() -> SpatialData: """Get blobs sdata with continuous annotation of polygons.""" diff --git a/tests/pl/test_render_labels.py b/tests/pl/test_render_labels.py index a585d4eb..d9736709 100644 --- a/tests/pl/test_render_labels.py +++ b/tests/pl/test_render_labels.py @@ -1,6 +1,7 @@ import dask.array as da import matplotlib import matplotlib.pyplot as plt +import numpy as np import pandas as pd import pytest import scanpy as sc @@ -263,6 +264,18 @@ def test_plot_can_annotate_labels_with_table_layer(self, sdata_blobs: SpatialDat sdata_blobs["table"].layers["normalized"] = get_standard_RNG().random(sdata_blobs["table"].X.shape) sdata_blobs.pl.render_labels("blobs_labels", color="channel_0_sum", table_layer="normalized").pl.show() + def test_plot_can_annotate_labels_with_nan_in_table_obs_categorical(self, sdata_blobs: SpatialData): + sdata_blobs["table"].obs["cat_color"] = pd.Categorical(["a", "b", "b", "a", "b"] * 5 + [np.nan]) + sdata_blobs.pl.render_labels("blobs_labels", color="cat_color").pl.show() + + def test_plot_can_annotate_labels_with_nan_in_table_obs_continuous(self, sdata_blobs: SpatialData): + sdata_blobs["table"].obs["cont_color"] = [np.nan, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13] * 2 + sdata_blobs.pl.render_labels("blobs_labels", color="cont_color").pl.show() + + def test_plot_can_annotate_labels_with_nan_in_table_X_continuous(self, sdata_blobs: SpatialData): + sdata_blobs["table"].X[0:5, 0] = np.nan + sdata_blobs.pl.render_labels("blobs_labels", color="channel_0_sum").pl.show() + def _prepare_labels_with_small_objects(self, sdata_blobs: SpatialData) -> SpatialData: # add a categorical column adata = sdata_blobs["table"] diff --git a/tests/pl/test_render_points.py b/tests/pl/test_render_points.py index 34b63e94..382184cd 100644 --- a/tests/pl/test_render_points.py +++ b/tests/pl/test_render_points.py @@ -522,6 +522,60 @@ def test_plot_can_annotate_points_with_table_layer(self, sdata_blobs: SpatialDat sdata_blobs.pl.render_points("blobs_points", color="feature0", size=10, table_layer="normalized").pl.show() + def test_plot_can_annotate_points_with_nan_in_table_obs_categorical_matplotlib( + self, sdata_blobs_points_with_nans_in_table: SpatialData + ): + sdata_blobs_points_with_nans_in_table.pl.render_points( + "blobs_points", color="category", size=40, method="matplotlib" + ).pl.show() + + def test_plot_can_annotate_points_with_nan_in_table_obs_categorical_datashader( + self, sdata_blobs_points_with_nans_in_table: SpatialData + ): + sdata_blobs_points_with_nans_in_table.pl.render_points( + "blobs_points", color="category", size=40, method="datashader" + ).pl.show() + + def test_plot_can_annotate_points_with_nan_in_table_obs_continuous( + self, sdata_blobs_points_with_nans_in_table: SpatialData + ): + sdata_blobs_points_with_nans_in_table.pl.render_points("blobs_points", color="col_a", size=30).pl.show() + + def test_plot_can_annotate_points_with_nan_in_table_obs_continuous_datashader( + self, sdata_blobs_points_with_nans_in_table: SpatialData + ): + sdata_blobs_points_with_nans_in_table.pl.render_points( + "blobs_points", color="col_a", size=40, method="datashader" + ).pl.show() + + def test_plot_can_annotate_points_with_nan_in_table_X_continuous( + self, sdata_blobs_points_with_nans_in_table: SpatialData + ): + sdata_blobs_points_with_nans_in_table.pl.render_points("blobs_points", color="col1", size=30).pl.show() + + def test_plot_can_annotate_points_with_nan_in_table_X_continuous_datashader( + self, sdata_blobs_points_with_nans_in_table: SpatialData + ): + sdata_blobs_points_with_nans_in_table.pl.render_points( + "blobs_points", color="col1", size=40, method="datashader" + ).pl.show() + + def test_plot_can_annotate_points_with_nan_in_df_categorical(self, sdata_blobs: SpatialData): + sdata_blobs["blobs_points"]["cat_color"] = pd.Series([np.nan, "a", "b", "c"] * 50, dtype="category") + sdata_blobs.pl.render_points("blobs_points", color="cat_color", size=30).pl.show() + + def test_plot_can_annotate_points_with_nan_in_df_categorical_datashader(self, sdata_blobs: SpatialData): + sdata_blobs["blobs_points"]["cat_color"] = pd.Series([np.nan, "a", "b", "c"] * 50, dtype="category") + sdata_blobs.pl.render_points("blobs_points", color="cat_color", size=40, method="datashader").pl.show() + + def test_plot_can_annotate_points_with_nan_in_df_continuous(self, sdata_blobs: SpatialData): + sdata_blobs["blobs_points"]["cont_color"] = pd.Series([np.nan, 2, 9, 13] * 50) + sdata_blobs.pl.render_points("blobs_points", color="cont_color", size=30).pl.show() + + def test_plot_can_annotate_points_with_nan_in_df_continuous_datashader(self, sdata_blobs: SpatialData): + sdata_blobs["blobs_points"]["cont_color"] = pd.Series([np.nan, 2, 9, 13] * 50) + sdata_blobs.pl.render_points("blobs_points", color="cont_color", size=40, method="datashader").pl.show() + def test_raises_when_table_does_not_annotate_element(sdata_blobs: SpatialData): # Work on an independent copy since we mutate tables diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index fce236d6..e3e20b3c 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -787,6 +787,58 @@ def test_plot_can_annotate_shapes_with_table_layer(self, sdata_blobs: SpatialDat sdata_blobs.pl.render_shapes("blobs_circles", color="feature0", table_layer="normalized").pl.show() + def test_plot_can_annotate_shapes_with_nan_in_table_obs_categorical( + self, sdata_blobs_shapes_with_nans_in_table: SpatialData + ): + sdata_blobs_shapes_with_nans_in_table.pl.render_shapes("blobs_polygons", color="category").pl.show() + + def test_plot_can_annotate_shapes_with_nan_in_table_obs_categorical_datashader( + self, sdata_blobs_shapes_with_nans_in_table: SpatialData + ): + sdata_blobs_shapes_with_nans_in_table.pl.render_shapes( + "blobs_polygons", color="category", method="datashader" + ).pl.show() + + def test_plot_can_annotate_shapes_with_nan_in_table_obs_continuous( + self, sdata_blobs_shapes_with_nans_in_table: SpatialData + ): + sdata_blobs_shapes_with_nans_in_table.pl.render_shapes("blobs_polygons", color="col_a").pl.show() + + def test_plot_can_annotate_shapes_with_nan_in_table_obs_continuous_datashader( + self, sdata_blobs_shapes_with_nans_in_table: SpatialData + ): + sdata_blobs_shapes_with_nans_in_table.pl.render_shapes( + "blobs_polygons", color="col_a", method="datashader" + ).pl.show() + + def test_plot_can_annotate_shapes_with_nan_in_table_X_continuous( + self, sdata_blobs_shapes_with_nans_in_table: SpatialData + ): + sdata_blobs_shapes_with_nans_in_table.pl.render_shapes("blobs_polygons", color="col1").pl.show() + + def test_plot_can_annotate_shapes_with_nan_in_table_X_continuous_datashader( + self, sdata_blobs_shapes_with_nans_in_table: SpatialData + ): + sdata_blobs_shapes_with_nans_in_table.pl.render_shapes( + "blobs_polygons", color="col1", method="datashader" + ).pl.show() + + def test_plot_can_annotate_shapes_with_nan_in_df_categorical(self, sdata_blobs: SpatialData): + sdata_blobs["blobs_polygons"]["cat_color"] = pd.Series([np.nan, "x", "x", "y", "y"], dtype="category") + sdata_blobs.pl.render_shapes("blobs_polygons", color="cat_color").pl.show() + + def test_plot_can_annotate_shapes_with_nan_in_df_categorical_datashader(self, sdata_blobs: SpatialData): + sdata_blobs["blobs_polygons"]["cat_color"] = pd.Series([np.nan, "x", "x", "y", "y"], dtype="category") + sdata_blobs.pl.render_shapes("blobs_polygons", color="cat_color", method="datashader").pl.show() + + def test_plot_can_annotate_shapes_with_nan_in_df_continuous(self, sdata_blobs: SpatialData): + sdata_blobs["blobs_polygons"]["cont_color"] = [np.nan, 2, 3, 4, 5] + sdata_blobs.pl.render_shapes("blobs_polygons", color="cont_color").pl.show() + + def test_plot_can_annotate_shapes_with_nan_in_df_continuous_datashader(self, sdata_blobs: SpatialData): + sdata_blobs["blobs_polygons"]["cont_color"] = [np.nan, 2, 3, 4, 5] + sdata_blobs.pl.render_shapes("blobs_polygons", color="cont_color", method="datashader").pl.show() + def test_plot_respects_custom_colors_from_uns(self, sdata_blobs: SpatialData): shapes_name = "blobs_polygons" # Ensure that the table annotations point to the shapes element