From 3a54f4546d54188fcbc47ff2d3780e37ea53024b Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Mon, 20 Oct 2025 22:40:18 +0200 Subject: [PATCH 01/14] Fix NaN handling and transparency in shape rendering - Fix KeyError in _convert_shapes by using proper DataFrame indexing - Improve NaN value handling in color processing to use na_color correctly - Preserve transparency from colormaps when fill_alpha is specified - Add support for mixed numeric/color arrays in shape coloring - Refactor _convert_shapes to use positional indexing for better reliability --- src/spatialdata_plot/pl/render.py | 22 +- src/spatialdata_plot/pl/utils.py | 504 +++++++++++++----------------- 2 files changed, 236 insertions(+), 290 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 52c8b773..e5cf0e41 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -141,6 +141,15 @@ def _render_shapes( 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: + color_vector = np.asarray(color_vector, dtype=float) + if np.isnan(color_vector).any(): + nan_count = int(np.isnan(color_vector).sum()) + logger.warning( + f"Found {nan_count} NaN values in color data. These observations will be colored with the 'na_color'." + ) + # Using dict.fromkeys here since set returns in arbitrary order # remove the color of NaN values, else it might be assigned to a category # order of color in the palette should agree to order of occurence @@ -447,12 +456,13 @@ def _render_shapes( path.vertices = trans.transform(path.vertices) if not values_are_categorical: - # If the user passed a Normalize object with vmin/vmax we'll use those, - # if not we'll use the min/max of the color_vector - _cax.set_clim( - vmin=render_params.cmap_params.norm.vmin or min(color_vector), - vmax=render_params.cmap_params.norm.vmax or max(color_vector), - ) + vmin = render_params.cmap_params.norm.vmin + vmax = render_params.cmap_params.norm.vmax + if vmin is None: + vmin = float(np.nanmin(color_vector)) + if vmax is None: + vmax = float(np.nanmax(color_vector)) + _cax.set_clim(vmin=vmin, vmax=vmax) if ( len(set(color_vector)) != 1 diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 82b84973..24d34c7c 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -305,140 +305,161 @@ def _get_collection_shape( **kwargs: Any, ) -> PatchCollection: """ - Get a PatchCollection for rendering given geometries with specified colors and outlines. - - Args: - - shapes (list[GeoDataFrame]): List of geometrical shapes. - - c: Color parameter. - - s (float): Scale of the shape. - - norm: Normalization for the color map. - - fill_alpha (float, optional): Opacity for the fill color. - - outline_alpha (float, optional): Opacity for the outline. - - outline_color (optional): Color for the outline. - - linewidth (float, optional): Width for the outline. - - **kwargs: Additional keyword arguments. + Build a PatchCollection for shapes with correct handling of: + - continuous numeric vectors with NaNs, + - per-row RGBA arrays, + - a single color or a list of color specs. - Returns - ------- - - PatchCollection: Collection of patches for rendering. + Only NaNs are painted with na_color; finite values are mapped via norm+cmap. """ cmap = kwargs["cmap"] - try: - # fails when numeric - if len(c.shape) == 1 and c.shape[0] in [3, 4] and c.shape[0] == len(shapes) and c.dtype == float: - if norm is None: - c = cmap(c) - else: - try: - norm = colors.Normalize(vmin=min(c), vmax=max(c)) if norm is None else norm - except ValueError as e: - raise ValueError( - "Could not convert values in the `color` column to float, if `color` column represents" - " categories, set the column to categorical dtype." - ) from e - c = cmap(norm(c)) - else: - fill_c = ColorConverter().to_rgba_array(c) - except ValueError: - if norm is None: - c = cmap(c) + # Resolve na color once + na_rgba = colors.to_rgba(render_params.cmap_params.na_color.get_hex_with_alpha()) + + # Try to interpret c as numpy array + c_arr = np.asarray(c) + fill_c: np.ndarray + + def _as_rgba_array(x: Any) -> np.ndarray: + return ColorConverter().to_rgba_array(x) + + # 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[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): + finite_mask = np.isfinite(c_arr) + + # Select or build a normalization that ignores NaNs for scaling + if isinstance(norm, Normalize): + used_norm: Normalize = norm else: - try: - norm = colors.Normalize(vmin=min(c), vmax=max(c)) if norm is None else norm - except ValueError as e: - raise ValueError( - "Could not convert values in the `color` column to float, if `color` column represents" - " categories, set the column to categorical dtype." - ) from e - c = cmap(norm(c)) + if finite_mask.any(): + vmin = float(np.nanmin(c_arr[finite_mask])) + vmax = float(np.nanmax(c_arr[finite_mask])) + if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax: + vmin, vmax = 0.0, 1.0 + else: + vmin, vmax = 0.0, 1.0 + used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False) + + # Map finite values through cmap(norm(.)); NaNs get na_color + fill_c = np.empty((len(c_arr), 4), dtype=float) + fill_c[:] = na_rgba + 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: + # Split into numeric vs color-like + s = pd.Series(c_arr, copy=False) + num = pd.to_numeric(s, errors="coerce").to_numpy() + is_num = np.isfinite(num) + + # init with na color + fill_c = np.empty((len(s), 4), dtype=float) + fill_c[:] = na_rgba + + # numeric entries via cmap(norm) + if is_num.any(): + if isinstance(norm, Normalize): + used_norm = norm + else: + vmin = float(np.nanmin(num[is_num])) if is_num.any() else 0.0 + vmax = float(np.nanmax(num[is_num])) if is_num.any() else 1.0 + if not np.isfinite(vmin) or not np.isfinite(vmax) or vmin == vmax: + vmin, vmax = 0.0, 1.0 + used_norm = colors.Normalize(vmin=vmin, vmax=vmax, clip=False) + fill_c[is_num] = cmap(used_norm(num[is_num])) + + # non-numeric entries as explicit colors + if (~is_num).any(): + fill_c[~is_num] = ColorConverter().to_rgba_array(s[~is_num].tolist()) + + # Case C: single color or list of color-like specs (strings or tuples) + else: + fill_c = _as_rgba_array(c) - fill_c = ColorConverter().to_rgba_array(c) - # fill_c[..., -1] *= fill_alpha # NOTE: this contradicts matplotlib behavior, therefore discarded + # Apply optional fill alpha without destroying existing transparency if fill_alpha is not None: - fill_c[..., -1] = fill_alpha + nonzero_alpha = fill_c[..., -1] > 0 + fill_c[nonzero_alpha, -1] = fill_alpha + # Outline handling if outline_alpha and outline_alpha > 0.0: - outline_c = ColorConverter().to_rgba_array(outline_color) + outline_c = _as_rgba_array(outline_color) outline_c[..., -1] = outline_alpha outline_c = outline_c.tolist() else: outline_c = [None] outline_c = outline_c * fill_c.shape[0] + # Build DataFrame of valid geometries shapes_df = pd.DataFrame(shapes, copy=True) shapes_df = shapes_df[shapes_df["geometry"].apply(lambda geom: not geom.is_empty)] shapes_df = shapes_df.reset_index(drop=True) def _assign_fill_and_outline_to_row( - fill_c: list[Any], - outline_c: list[Any], + fill_colors: list[Any], + outline_colors: list[Any], row: dict[str, Any], idx: int, is_multiple_shapes: bool, ) -> None: - try: - if is_multiple_shapes and len(fill_c) == 1: - row["fill_c"] = fill_c[0] - row["outline_c"] = outline_c[0] - else: - row["fill_c"] = fill_c[idx] - row["outline_c"] = outline_c[idx] - except IndexError as e: - raise IndexError("Could not assign fill and outline colors due to a mismatch in row numbers.") from e + if is_multiple_shapes and len(fill_colors) == 1: + row["fill_c"] = fill_colors[0] + row["outline_c"] = outline_colors[0] + else: + row["fill_c"] = fill_colors[idx] + row["outline_c"] = outline_colors[idx] - def _process_polygon(row: pd.Series, s: float) -> dict[str, Any]: + def _process_polygon(row: pd.Series, scale: float) -> dict[str, Any]: coords = np.array(row["geometry"].exterior.coords) centroid = np.mean(coords, axis=0) - scaled_vectors = (coords - centroid) * s - scaled_coords = (centroid + scaled_vectors).tolist() - return { - **row.to_dict(), - "geometry": mpatches.Polygon(scaled_coords, closed=True), - } + scaled = (centroid + (coords - centroid) * scale).tolist() + return {**row.to_dict(), "geometry": mpatches.Polygon(scaled, closed=True)} - def _process_multipolygon(row: pd.Series, s: float) -> list[dict[str, Any]]: + def _process_multipolygon(row: pd.Series, scale: float) -> list[dict[str, Any]]: mp = _make_patch_from_multipolygon(row["geometry"]) row_dict = row.to_dict() for m in mp: - _scale_pathpatch_around_centroid(m, s) - + _scale_pathpatch_around_centroid(m, scale) return [{**row_dict, "geometry": m} for m in mp] - def _process_point(row: pd.Series, s: float) -> dict[str, Any]: + def _process_point(row: pd.Series, scale: float) -> dict[str, Any]: return { **row.to_dict(), - "geometry": mpatches.Circle((row["geometry"].x, row["geometry"].y), radius=row["radius"] * s), + "geometry": mpatches.Circle((row["geometry"].x, row["geometry"].y), radius=row["radius"] * scale), } - def _create_patches(shapes_df: GeoDataFrame, fill_c: list[Any], outline_c: list[Any], s: float) -> pd.DataFrame: - rows = [] - is_multiple_shapes = len(shapes_df) > 1 - - for idx, row in shapes_df.iterrows(): + def _create_patches(shapes_df_: GeoDataFrame, fill_colors: list[Any], outline_colors: list[Any], scale: float) -> pd.DataFrame: + rows: list[dict[str, Any]] = [] + is_multiple = len(shapes_df_) > 1 + for idx, row in shapes_df_.iterrows(): geom_type = row["geometry"].geom_type - processed_rows = [] - + processed: list[dict[str, Any]] = [] if geom_type == "Polygon": - processed_rows.append(_process_polygon(row, s)) + processed.append(_process_polygon(row, scale)) elif geom_type == "MultiPolygon": - processed_rows.extend(_process_multipolygon(row, s)) + processed.extend(_process_multipolygon(row, scale)) elif geom_type == "Point": - processed_rows.append(_process_point(row, s)) - - for processed_row in processed_rows: - _assign_fill_and_outline_to_row(fill_c, outline_c, processed_row, idx, is_multiple_shapes) - rows.append(processed_row) - + processed.append(_process_point(row, scale)) + for pr in processed: + _assign_fill_and_outline_to_row(fill_colors, outline_colors, pr, idx, is_multiple) + rows.append(pr) return pd.DataFrame(rows) patches = _create_patches(shapes_df, fill_c, outline_c, s) + return PatchCollection( patches["geometry"].values.tolist(), snap=False, lw=linewidth, facecolor=patches["fill_c"], - edgecolor=None if all(outline is None for outline in outline_c) else outline_c, + edgecolor=None if all(o is None for o in outline_c) else outline_c, **kwargs, ) @@ -651,57 +672,6 @@ def _get_subplots(num_images: int, ncols: int = 4, width: int = 4, height: int = return fig, axes -def _normalize( - img: xr.DataArray, - pmin: float | None = None, - pmax: float | None = None, - eps: float = 1e-20, - clip: bool = False, - name: str = "normed", -) -> xr.DataArray: - """Perform a min max normalisation on the xr.DataArray. - - This function was adapted from the csbdeep package. - - Parameters - ---------- - dataarray - A xarray DataArray with an image field. - pmin - Lower quantile (min value) used to perform quantile normalization. - pmax - Upper quantile (max value) used to perform quantile normalization. - eps - Epsilon float added to prevent 0 division. - clip - Ensures that normed image array contains no values greater than 1. - - Returns - ------- - xr.DataArray - A min-max normalized image. - """ - pmin = pmin or 0.0 - pmax = pmax or 100.0 - - perc = np.percentile(img, [pmin, pmax]) - - # Ensure perc is an array of two elements - if np.isscalar(perc): - logger.warning( - "Percentile range is too small, using the same percentile for both min " - "and max. Consider using a larger percentile range." - ) - perc = np.array([perc, perc]) - - norm = (img - perc[0]) / (perc[1] - perc[0] + eps) # type: ignore - - if clip: - norm = np.clip(norm, 0, 1) - - return norm - - def _get_colors_for_categorical_obs( categories: Sequence[str | int], palette: ListedColormap | str | list[str] | None = None, @@ -1220,13 +1190,6 @@ def _get_linear_colormap(colors: list[str], background: str) -> list[LinearSegme return [LinearSegmentedColormap.from_list(c, [background, c], N=256) for c in colors] -def _get_listed_colormap(color_dict: dict[str, str]) -> ListedColormap: - sorted_labels = sorted(color_dict.keys()) - colors = [color_dict[k] for k in sorted_labels] - - return ListedColormap(["black"] + colors, N=len(colors) + 1) - - def _split_multipolygon_into_outer_and_inner(mp: shapely.MultiPolygon): # type: ignore # https://stackoverflow.com/a/21922058 @@ -2505,39 +2468,6 @@ def _prepare_transformation( return trans, trans_data -def _get_datashader_trans_matrix_of_single_element( - trans: Identity | Scale | Affine | MapAxis | Translation, -) -> ArrayLike: - flip_matrix = np.array([[1, 0, 0], [0, -1, 0], [0, 0, 1]]) - tm: ArrayLike = trans.to_affine_matrix(("x", "y"), ("x", "y")) - - if isinstance(trans, Identity): - return np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) - if isinstance(trans, (Scale | Affine)): - # idea: "flip the y-axis", apply transformation, flip back - flip_and_transform: ArrayLike = flip_matrix @ tm @ flip_matrix - return flip_and_transform - if isinstance(trans, MapAxis): - # no flipping needed - return tm - # for a Translation, we need the transposed transformation matrix - tm_T = tm.T - assert isinstance(tm_T, np.ndarray) - return tm_T - - -def _get_transformation_matrix_for_datashader( - trans: Scale | Identity | Affine | MapAxis | Translation | SDSequence, -) -> ArrayLike: - """Get the affine matrix needed to transform shapes for rendering with datashader.""" - if isinstance(trans, SDSequence): - tm = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]]) - for x in trans.transformations: - tm = tm @ _get_datashader_trans_matrix_of_single_element(x) - return tm - return _get_datashader_trans_matrix_of_single_element(trans) - - def _datashader_map_aggregate_to_color( agg: DataArray, cmap: str | list[str] | ListedColormap, @@ -2638,182 +2568,188 @@ def _hex_no_alpha(hex: str) -> str: raise ValueError("Invalid hex color length: must be either '#RRGGBB' or '#RRGGBBAA'") - def _convert_shapes( - shapes: GeoDataFrame, target_shape: str, max_extent: float, warn_above_extent_fraction: float = 0.5 + shapes: GeoDataFrame, + target_shape: str, + max_extent: float, + warn_above_extent_fraction: float = 0.5, ) -> GeoDataFrame: - """Convert the shapes stored in a GeoDataFrame (geometry column) to the target_shape.""" - # NOTE: possible follow-up: when converting equally sized shapes to hex, automatically scale resulting hexagons - # so that they are perfectly adjacent to each other - + """Convert shapes in a GeoDataFrame to the target_shape, using positional indexing.""" if warn_above_extent_fraction < 0.0 or warn_above_extent_fraction > 1.0: - warn_above_extent_fraction = 0.5 # set to default if the value is outside [0, 1] + warn_above_extent_fraction = 0.5 warn_shape_size = False - # define individual conversion methods + # work on a copy with a clean positional index + shapes = shapes.reset_index(drop=True).copy() + def _circle_to_hexagon(center: shapely.Point, radius: float) -> tuple[shapely.Polygon, None]: - # Create hexagon with point at top (30° offset from standard orientation) - vertices = [ - (center.x + radius * math.cos(math.radians(angle)), center.y + radius * math.sin(math.radians(angle))) - for angle in range(30, 390, 60) # Start at 30° and go every 60° + verts = [ + ( + center.x + radius * math.cos(math.radians(a)), + center.y + radius * math.sin(math.radians(a)), + ) + for a in range(30, 390, 60) ] - return shapely.Polygon(vertices), None + return shapely.Polygon(verts), None def _circle_to_square(center: shapely.Point, radius: float) -> tuple[shapely.Polygon, None]: - vertices = [ - (center.x + radius * math.cos(math.radians(angle)), center.y + radius * math.sin(math.radians(angle))) - for angle in range(45, 360, 90) + verts = [ + ( + center.x + radius * math.cos(math.radians(a)), + center.y + radius * math.sin(math.radians(a)), + ) + for a in range(45, 360, 90) ] - return shapely.Polygon(vertices), None + return shapely.Polygon(verts), None def _circle_to_circle(center: shapely.Point, radius: float) -> tuple[shapely.Point, float]: return center, radius - def _polygon_to_hexagon(polygon: shapely.Polygon) -> tuple[shapely.Polygon, None]: - center, radius = _polygon_to_circle(polygon) - return _circle_to_hexagon(center, radius) - - def _polygon_to_square(polygon: shapely.Polygon) -> tuple[shapely.Polygon, None]: - center, radius = _polygon_to_circle(polygon) - return _circle_to_square(center, radius) - def _polygon_to_circle(polygon: shapely.Polygon) -> tuple[shapely.Point, float]: coords = np.array(polygon.exterior.coords) - circle_points = coords[ConvexHull(coords).vertices] - center = np.mean(circle_points, axis=0) - radius = max(float(np.linalg.norm(p - center)) for p in circle_points) - assert isinstance(radius, float) # shut up mypy + hull_pts = coords[ConvexHull(coords).vertices] + center = np.mean(hull_pts, axis=0) + radius = float(np.max(np.linalg.norm(hull_pts - center, axis=1))) + nonlocal warn_shape_size if 2 * radius > max_extent * warn_above_extent_fraction: - nonlocal warn_shape_size warn_shape_size = True return shapely.Point(center), radius - def _multipolygon_to_hexagon(multipolygon: shapely.MultiPolygon) -> tuple[shapely.Polygon, None]: - center, radius = _multipolygon_to_circle(multipolygon) - return _circle_to_hexagon(center, radius) + def _polygon_to_hexagon(polygon: shapely.Polygon) -> tuple[shapely.Polygon, None]: + c, r = _polygon_to_circle(polygon) + return _circle_to_hexagon(c, r) - def _multipolygon_to_square(multipolygon: shapely.MultiPolygon) -> tuple[shapely.Polygon, None]: - center, radius = _multipolygon_to_circle(multipolygon) - return _circle_to_square(center, radius) + def _polygon_to_square(polygon: shapely.Polygon) -> tuple[shapely.Polygon, None]: + c, r = _polygon_to_circle(polygon) + return _circle_to_square(c, r) def _multipolygon_to_circle(multipolygon: shapely.MultiPolygon) -> tuple[shapely.Point, float]: - coords = [] - for polygon in multipolygon.geoms: - coords.extend(polygon.exterior.coords) - points = np.array(coords) - circle_points = points[ConvexHull(points).vertices] - center = np.mean(circle_points, axis=0) - radius = max(float(np.linalg.norm(p - center)) for p in circle_points) - assert isinstance(radius, float) # shut up mypy + pts = [] + for poly in multipolygon.geoms: + pts.extend(poly.exterior.coords) + pts = np.array(pts) + hull_pts = pts[ConvexHull(pts).vertices] + center = np.mean(hull_pts, axis=0) + radius = float(np.max(np.linalg.norm(hull_pts - center, axis=1))) + nonlocal warn_shape_size if 2 * radius > max_extent * warn_above_extent_fraction: - nonlocal warn_shape_size warn_shape_size = True return shapely.Point(center), radius - # define dict with all conversion methods + def _multipolygon_to_hexagon(multipolygon: shapely.MultiPolygon) -> tuple[shapely.Polygon, None]: + c, r = _multipolygon_to_circle(multipolygon) + return _circle_to_hexagon(c, r) + + def _multipolygon_to_square(multipolygon: shapely.MultiPolygon) -> tuple[shapely.Polygon, None]: + c, r = _multipolygon_to_circle(multipolygon) + return _circle_to_square(c, r) + + # choose conversion methods if target_shape == "circle": conversion_methods = { "Point": _circle_to_circle, "Polygon": _polygon_to_circle, - "Multipolygon": _multipolygon_to_circle, + "MultiPolygon": _multipolygon_to_circle, } - pass elif target_shape == "hex": conversion_methods = { "Point": _circle_to_hexagon, "Polygon": _polygon_to_hexagon, - "Multipolygon": _multipolygon_to_hexagon, + "MultiPolygon": _multipolygon_to_hexagon, } elif target_shape == "visium_hex": - # For visium_hex, we only support Points and warn for other geometry types + # estimate hex radius from point spacing when possible point_centers = [] non_point_count = 0 - - for i in range(shapes.shape[0]): - if shapes["geometry"][i].type == "Point": - point_centers.append((shapes["geometry"][i].x, shapes["geometry"][i].y)) + for geom in shapes.geometry: + if geom.geom_type == "Point": + point_centers.append((geom.x, geom.y)) else: non_point_count += 1 - if non_point_count > 0: warnings.warn( - f"visium_hex conversion only supports Point geometries. Found {non_point_count} non-Point geometries " - f"that will be converted using regular hex conversion. Consider using shape='hex' for mixed geometry types.", + "visium_hex supports Points best. Non-Point geometries will use regular hex conversion.", UserWarning, stacklevel=2, ) + if len(point_centers) >= 2: + centers = np.array(point_centers, dtype=float) + # pairwise min distance + dmin = np.inf + for i in range(len(centers)): + diffs = centers[i + 1 :] - centers[i] + if diffs.size: + d = np.min(np.linalg.norm(diffs, axis=1)) + dmin = min(dmin, d) + if not np.isfinite(dmin) or dmin <= 0: + # fallback + conversion_methods = { + "Point": _circle_to_hexagon, + "Polygon": _polygon_to_hexagon, + "MultiPolygon": _multipolygon_to_hexagon, + } + else: + hex_radius = dmin / math.sqrt(3.0) - if len(point_centers) < 2: - # If we have fewer than 2 points, fall back to regular hex conversion + def _circle_to_visium_hex(center: shapely.Point, radius: float) -> tuple[shapely.Polygon, None]: + return _circle_to_hexagon(center, hex_radius) + + def _polygon_to_visium_hex(polygon: shapely.Polygon) -> tuple[shapely.Polygon, None]: + return _polygon_to_hexagon(polygon) + + def _multipolygon_to_visium_hex(multipolygon: shapely.MultiPolygon) -> tuple[shapely.Polygon, None]: + return _multipolygon_to_hexagon(multipolygon) + + conversion_methods = { + "Point": _circle_to_visium_hex, + "Polygon": _polygon_to_visium_hex, + "MultiPolygon": _multipolygon_to_visium_hex, + } + else: conversion_methods = { "Point": _circle_to_hexagon, "Polygon": _polygon_to_hexagon, - "Multipolygon": _multipolygon_to_hexagon, - } - else: - # Calculate typical spacing between point centers - centers_array = np.array(point_centers) - distances = [] - for i in range(len(point_centers)): - for j in range(i + 1, len(point_centers)): - dist = np.linalg.norm(centers_array[i] - centers_array[j]) - distances.append(dist) - - # Use min dist of closest neighbors as the side length for radius calc - side_length = np.min(distances) - hex_radius = (side_length * 2.0 / math.sqrt(3)) / 2.0 - - # Create conversion methods - def _circle_to_visium_hex(center: shapely.Point, radius: float) -> tuple[shapely.Polygon, None]: - return _circle_to_hexagon(center, hex_radius) - - def _polygon_to_visium_hex(polygon: shapely.Polygon) -> tuple[shapely.Polygon, None]: - # Fall back to regular hex conversion for non-points - return _polygon_to_hexagon(polygon) - - def _multipolygon_to_visium_hex(multipolygon: shapely.MultiPolygon) -> tuple[shapely.Polygon, None]: - # Fall back to regular hex conversion for non-points - return _multipolygon_to_hexagon(multipolygon) - - conversion_methods = { - "Point": _circle_to_visium_hex, - "Polygon": _polygon_to_visium_hex, - "Multipolygon": _multipolygon_to_visium_hex, + "MultiPolygon": _multipolygon_to_hexagon, } else: conversion_methods = { "Point": _circle_to_square, "Polygon": _polygon_to_square, - "Multipolygon": _multipolygon_to_square, + "MultiPolygon": _multipolygon_to_square, } - # convert every shape - for i in range(shapes.shape[0]): - if shapes["geometry"][i].type == "Point": - converted, radius = conversion_methods["Point"](shapes["geometry"][i], shapes["radius"][i]) # type: ignore - elif shapes["geometry"][i].type == "Polygon": - converted, radius = conversion_methods["Polygon"](shapes["geometry"][i]) # type: ignore - elif shapes["geometry"][i].type == "MultiPolygon": - converted, radius = conversion_methods["Multipolygon"](shapes["geometry"][i]) # type: ignore + # ensure radius column exists if needed + if "radius" not in shapes.columns: + shapes["radius"] = np.nan + + # convert all geometries using positional indexing + for i in range(len(shapes)): + geom = shapes.geometry.iloc[i] + gtype = geom.geom_type + if gtype == "Point": + r = shapes["radius"].iloc[i] + r = float(r) if np.isfinite(r) else 0.0 + converted, radius = conversion_methods["Point"](geom, r) # type: ignore[arg-type] + elif gtype == "Polygon": + converted, radius = conversion_methods["Polygon"](geom) # type: ignore[arg-type] + elif gtype == "MultiPolygon": + converted, radius = conversion_methods["MultiPolygon"](geom) # type: ignore[arg-type] else: - error_type = shapes["geometry"][i].type - raise ValueError(f"Converting shape {error_type} to {target_shape} is not supported.") - shapes["geometry"][i] = converted + raise ValueError(f"Converting shape {gtype} to {target_shape} is not supported.") + shapes.at[i, "geometry"] = converted if radius is not None: - if "radius" not in shapes.columns: - shapes["radius"] = np.nan - shapes["radius"][i] = radius + shapes.at[i, "radius"] = radius if warn_shape_size: logger.info( - f"When converting the shapes, the size of at least one target shape extends " - f"{warn_above_extent_fraction * 100}% of the original total bound of the shapes. The conversion" - " might not give satisfying results in this scenario." + f"At least one converted shape spans >= {warn_above_extent_fraction * 100:.0f}% of the original total bound. " + "Results may be suboptimal." ) return shapes + def _convert_alpha_to_datashader_range(alpha: float) -> float: """Convert alpha from the range [0, 1] to the range [0, 255] used in datashader.""" # prevent a value of 255, bc that led to fully colored test plots instead of just colored points/shapes From ed66c8501fff92ee4c972136c1dd9e23efe51bfd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 20:42:43 +0000 Subject: [PATCH 02/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spatialdata_plot/pl/utils.py | 30 ++++++++++++++---------------- tests/conftest.py | 2 +- 2 files changed, 15 insertions(+), 17 deletions(-) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 24d34c7c..a9b9f89c 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -24,7 +24,6 @@ import pandas as pd import shapely import spatialdata as sd -import xarray as xr from anndata import AnnData from cycler import Cycler, cycler from datashader.core import Canvas @@ -325,8 +324,11 @@ def _as_rgba_array(x: Any) -> np.ndarray: return ColorConverter().to_rgba_array(x) # 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[1] in (3, 4) and np.issubdtype( - c_arr.dtype, np.number + if ( + c_arr.ndim == 2 + and c_arr.shape[0] == len(shapes) + and c_arr.shape[1] in (3, 4) + and np.issubdtype(c_arr.dtype, np.number) ): fill_c = _as_rgba_array(c_arr) @@ -352,7 +354,7 @@ def _as_rgba_array(x: Any) -> np.ndarray: fill_c[:] = na_rgba 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: # Split into numeric vs color-like s = pd.Series(c_arr, copy=False) @@ -435,7 +437,9 @@ def _process_point(row: pd.Series, scale: float) -> dict[str, Any]: "geometry": mpatches.Circle((row["geometry"].x, row["geometry"].y), radius=row["radius"] * scale), } - def _create_patches(shapes_df_: GeoDataFrame, fill_colors: list[Any], outline_colors: list[Any], scale: float) -> pd.DataFrame: + def _create_patches( + shapes_df_: GeoDataFrame, fill_colors: list[Any], outline_colors: list[Any], scale: float + ) -> pd.DataFrame: rows: list[dict[str, Any]] = [] is_multiple = len(shapes_df_) > 1 for idx, row in shapes_df_.iterrows(): @@ -1765,9 +1769,7 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if (norm := param_dict.get("norm")) is not None: if element_type in {"images", "labels"} and not isinstance(norm, Normalize): raise TypeError("Parameter 'norm' must be of type Normalize.") - if element_type in {"shapes", "points"} and not isinstance( - norm, bool | Normalize - ): + if element_type in {"shapes", "points"} and not isinstance(norm, bool | Normalize): raise TypeError("Parameter 'norm' must be a boolean or a mpl.Normalize.") if (scale := param_dict.get("scale")) is not None: @@ -1786,15 +1788,11 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st raise ValueError("Parameter 'size' must be a positive number.") if element_type == "shapes" and (shape := param_dict.get("shape")) is not None: - valid_shapes = {"circle", "hex", "visium_hex", "square"} + valid_shapes = {"circle", "hex", "visium_hex", "square"} if not isinstance(shape, str): - raise TypeError( - f"Parameter 'shape' must be a String from {valid_shapes} if not None." - ) + raise TypeError(f"Parameter 'shape' must be a String from {valid_shapes} if not None.") if shape not in valid_shapes: - raise ValueError( - f"'{shape}' is not supported for 'shape', please choose from {valid_shapes}." - ) + raise ValueError(f"'{shape}' is not supported for 'shape', please choose from {valid_shapes}.") table_name = param_dict.get("table_name") table_layer = param_dict.get("table_layer") @@ -2568,6 +2566,7 @@ def _hex_no_alpha(hex: str) -> str: raise ValueError("Invalid hex color length: must be either '#RRGGBB' or '#RRGGBBAA'") + def _convert_shapes( shapes: GeoDataFrame, target_shape: str, @@ -2749,7 +2748,6 @@ def _multipolygon_to_visium_hex(multipolygon: shapely.MultiPolygon) -> tuple[sha return shapes - def _convert_alpha_to_datashader_range(alpha: float) -> float: """Convert alpha from the range [0, 1] to the range [0, 255] used in datashader.""" # prevent a value of 255, bc that led to fully colored test plots instead of just colored points/shapes diff --git a/tests/conftest.py b/tests/conftest.py index 61c66573..b44d6d8e 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -551,4 +551,4 @@ def sdata_hexagonal_grid_spots(): # Use ShapesModel.parse() to create a properly validated GeoDataFrame shapes_gdf = ShapesModel.parse(gdf) - return SpatialData(shapes={"spots": shapes_gdf}) \ No newline at end of file + return SpatialData(shapes={"spots": shapes_gdf}) From c709932595e7812df24390dd1f9ec42e0498277b Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 21 Oct 2025 19:01:04 +0200 Subject: [PATCH 03/14] fix --- src/spatialdata_plot/pl/render.py | 19 ++++++++++- src/spatialdata_plot/pl/utils.py | 56 +++++++++++++++++++++++++++++-- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index e5cf0e41..c837a586 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -204,7 +204,10 @@ def _render_shapes( # Handle circles encoded as points with radius if is_point.any(): - scale = shapes[is_point]["radius"] * render_params.scale + radius_values = shapes[is_point]["radius"] + # Convert to numeric, replacing non-numeric values with NaN + radius_numeric = pd.to_numeric(radius_values, errors='coerce') + scale = radius_numeric * render_params.scale shapes.loc[is_point, "geometry"] = _geometry[is_point].buffer(scale.to_numpy()) # apply transformations to the individual points @@ -227,6 +230,20 @@ def _render_shapes( # in case we are coloring by a column in table if col_for_color is not None and col_for_color not in transformed_element.columns: + # Ensure color vector length matches the number of shapes + if len(color_vector) != len(transformed_element): + if len(color_vector) == 1: + # If single color, broadcast to all shapes + color_vector = [color_vector[0]] * len(transformed_element) + else: + # If lengths don't match, pad or truncate to match + if len(color_vector) > len(transformed_element): + color_vector = color_vector[:len(transformed_element)] + else: + # Pad with the last color or na_color + na_color = render_params.cmap_params.na_color.get_hex_with_alpha() + color_vector = list(color_vector) + [na_color] * (len(transformed_element) - len(color_vector)) + 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 diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index a9b9f89c..fbfc7ebb 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -93,6 +93,50 @@ ColorLike = tuple[float, ...] | list[float] | str +def _extract_scalar_value(value: Any, default: float = 0.0) -> float: + """ + Extract a scalar float value from various data types. + + Handles pandas Series, arrays, lists, and other iterables by taking the first element. + Converts non-numeric values to the default value. + + Parameters + ---------- + value : Any + The value to extract a scalar from + default : float, default 0.0 + Default value to return if conversion fails + + Returns + ------- + float + The extracted scalar value + """ + try: + # Handle pandas Series or similar objects with iloc + if hasattr(value, 'iloc'): + if len(value) > 0: + value = value.iloc[0] + else: + return default + + # Handle other array-like objects + elif hasattr(value, '__len__') and not isinstance(value, (str, bytes)): + if len(value) > 0: + value = value[0] + else: + return default + + # Convert to float, handling NaN values + if pd.isna(value): + return default + + return float(value) + + except (TypeError, ValueError, IndexError): + return default + + def _verify_plotting_tree(sdata: SpatialData) -> SpatialData: """Verify that the plotting tree exists, and if not, create it.""" if not hasattr(sdata, "plotting_tree"): @@ -285,9 +329,10 @@ def _get_centroid_of_pathpatch(pathpatch: mpatches.PathPatch) -> tuple[float, fl def _scale_pathpatch_around_centroid(pathpatch: mpatches.PathPatch, scale_factor: float) -> None: + scale_value = _extract_scalar_value(scale_factor, default=1.0) centroid = _get_centroid_of_pathpatch(pathpatch) vertices = pathpatch.get_path().vertices - scaled_vertices = np.array([centroid + (vertex - centroid) * scale_factor for vertex in vertices]) + scaled_vertices = np.array([centroid + (vertex - centroid) * scale_value for vertex in vertices]) pathpatch.get_path().vertices = scaled_vertices @@ -421,7 +466,8 @@ def _assign_fill_and_outline_to_row( def _process_polygon(row: pd.Series, scale: float) -> dict[str, Any]: coords = np.array(row["geometry"].exterior.coords) centroid = np.mean(coords, axis=0) - scaled = (centroid + (coords - centroid) * scale).tolist() + scale_value = _extract_scalar_value(scale, default=1.0) + scaled = (centroid + (coords - centroid) * scale_value).tolist() return {**row.to_dict(), "geometry": mpatches.Polygon(scaled, closed=True)} def _process_multipolygon(row: pd.Series, scale: float) -> list[dict[str, Any]]: @@ -432,9 +478,13 @@ def _process_multipolygon(row: pd.Series, scale: float) -> list[dict[str, Any]]: return [{**row_dict, "geometry": m} for m in mp] def _process_point(row: pd.Series, scale: float) -> dict[str, Any]: + radius_value = _extract_scalar_value(row["radius"], default=0.0) + scale_value = _extract_scalar_value(scale, default=1.0) + radius = radius_value * scale_value + return { **row.to_dict(), - "geometry": mpatches.Circle((row["geometry"].x, row["geometry"].y), radius=row["radius"] * scale), + "geometry": mpatches.Circle((row["geometry"].x, row["geometry"].y), radius=radius), } def _create_patches( From 649354f8ddc9234ec45155e65a2c9a8f165d61b9 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:01:35 +0000 Subject: [PATCH 04/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spatialdata_plot/pl/render.py | 6 +++--- src/spatialdata_plot/pl/utils.py | 20 ++++++++++---------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index c837a586..6c5027ff 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -206,7 +206,7 @@ def _render_shapes( if is_point.any(): radius_values = shapes[is_point]["radius"] # Convert to numeric, replacing non-numeric values with NaN - radius_numeric = pd.to_numeric(radius_values, errors='coerce') + radius_numeric = pd.to_numeric(radius_values, errors="coerce") scale = radius_numeric * render_params.scale shapes.loc[is_point, "geometry"] = _geometry[is_point].buffer(scale.to_numpy()) @@ -238,12 +238,12 @@ def _render_shapes( else: # If lengths don't match, pad or truncate to match if len(color_vector) > len(transformed_element): - color_vector = color_vector[:len(transformed_element)] + color_vector = color_vector[: len(transformed_element)] else: # Pad with the last color or na_color na_color = render_params.cmap_params.na_color.get_hex_with_alpha() color_vector = list(color_vector) + [na_color] * (len(transformed_element) - len(color_vector)) - + 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 diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index fbfc7ebb..4c9531ad 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -96,17 +96,17 @@ def _extract_scalar_value(value: Any, default: float = 0.0) -> float: """ Extract a scalar float value from various data types. - + Handles pandas Series, arrays, lists, and other iterables by taking the first element. Converts non-numeric values to the default value. - + Parameters ---------- value : Any The value to extract a scalar from default : float, default 0.0 Default value to return if conversion fails - + Returns ------- float @@ -114,25 +114,25 @@ def _extract_scalar_value(value: Any, default: float = 0.0) -> float: """ try: # Handle pandas Series or similar objects with iloc - if hasattr(value, 'iloc'): + if hasattr(value, "iloc"): if len(value) > 0: value = value.iloc[0] else: return default - + # Handle other array-like objects - elif hasattr(value, '__len__') and not isinstance(value, (str, bytes)): + elif hasattr(value, "__len__") and not isinstance(value, (str, bytes)): if len(value) > 0: value = value[0] else: return default - + # Convert to float, handling NaN values if pd.isna(value): return default - + return float(value) - + except (TypeError, ValueError, IndexError): return default @@ -481,7 +481,7 @@ def _process_point(row: pd.Series, scale: float) -> dict[str, Any]: radius_value = _extract_scalar_value(row["radius"], default=0.0) scale_value = _extract_scalar_value(scale, default=1.0) radius = radius_value * scale_value - + return { **row.to_dict(), "geometry": mpatches.Circle((row["geometry"].x, row["geometry"].y), radius=radius), From 848d2f369fe3bd6f08a7500c0f3503b1e9b5224c Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 21 Oct 2025 19:11:13 +0200 Subject: [PATCH 05/14] tightened colorbar tick formatting --- src/spatialdata_plot/pl/render.py | 9 ++++++++- src/spatialdata_plot/pl/utils.py | 7 +++++++ 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index c837a586..2abe0d09 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -9,6 +9,7 @@ import geopandas as gpd import matplotlib import matplotlib.pyplot as plt +import matplotlib.ticker import numpy as np import pandas as pd import scanpy as sc @@ -956,7 +957,13 @@ def _render_images( if legend_params.colorbar: sm = plt.cm.ScalarMappable(cmap=cmap, norm=render_params.cmap_params.norm) - fig_params.fig.colorbar(sm, ax=ax) + cb = fig_params.fig.colorbar(sm, ax=ax) + # Ensure colorbar values are always displayed as floats + cb.formatter.set_powerlimits((0, 0)) # Disable scientific notation + cb.formatter.set_useOffset(False) # Disable offset + cb.formatter.set_scientific(False) # Disable scientific notation + # Set a custom formatter that always shows decimal places + cb.formatter = matplotlib.ticker.FuncFormatter(lambda x, p: f'{x:.1f}') # 2) Image has any number of channels but 1 else: diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index fbfc7ebb..7602ff0c 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -17,6 +17,7 @@ import matplotlib.patches as mpatches import matplotlib.path as mpath import matplotlib.pyplot as plt +import matplotlib.ticker import matplotlib.transforms as mtransforms import numpy as np import numpy.ma as ma @@ -1143,6 +1144,12 @@ def _decorate_axs( # TODO: na_in_legend should have some effect here cb = plt.colorbar(cax, ax=ax, pad=0.01, fraction=0.08, aspect=30) cb.solids.set_alpha(alpha) + # Ensure colorbar values are always displayed as floats + cb.formatter.set_powerlimits((0, 0)) # Disable scientific notation + cb.formatter.set_useOffset(False) # Disable offset + cb.formatter.set_scientific(False) # Disable scientific notation + # Set a custom formatter that always shows decimal places + cb.formatter = matplotlib.ticker.FuncFormatter(lambda x, p: f'{x:.1f}') if isinstance(scalebar_dx, list) and isinstance(scalebar_units, list): scalebar = ScaleBar(scalebar_dx, units=scalebar_units, **scalebar_kwargs) From a30fa38755600bba2732ba69e456b7d86cfe3ffd Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:11:53 +0000 Subject: [PATCH 06/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spatialdata_plot/pl/render.py | 2 +- src/spatialdata_plot/pl/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 4ae65654..cc6f7f8c 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -963,7 +963,7 @@ def _render_images( cb.formatter.set_useOffset(False) # Disable offset cb.formatter.set_scientific(False) # Disable scientific notation # Set a custom formatter that always shows decimal places - cb.formatter = matplotlib.ticker.FuncFormatter(lambda x, p: f'{x:.1f}') + cb.formatter = matplotlib.ticker.FuncFormatter(lambda x, p: f"{x:.1f}") # 2) Image has any number of channels but 1 else: diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 703d0515..5de53bc8 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -1149,7 +1149,7 @@ def _decorate_axs( cb.formatter.set_useOffset(False) # Disable offset cb.formatter.set_scientific(False) # Disable scientific notation # Set a custom formatter that always shows decimal places - cb.formatter = matplotlib.ticker.FuncFormatter(lambda x, p: f'{x:.1f}') + cb.formatter = matplotlib.ticker.FuncFormatter(lambda x, p: f"{x:.1f}") if isinstance(scalebar_dx, list) and isinstance(scalebar_units, list): scalebar = ScaleBar(scalebar_dx, units=scalebar_units, **scalebar_kwargs) From 975c464ad997ae6d75d81fbd2ad016abc3a29c0b Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 21 Oct 2025 19:15:00 +0200 Subject: [PATCH 07/14] more robust numbering --- src/spatialdata_plot/pl/render.py | 6 +----- src/spatialdata_plot/pl/utils.py | 6 +----- 2 files changed, 2 insertions(+), 10 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 4ae65654..94fbc0ca 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -959,11 +959,7 @@ def _render_images( sm = plt.cm.ScalarMappable(cmap=cmap, norm=render_params.cmap_params.norm) cb = fig_params.fig.colorbar(sm, ax=ax) # Ensure colorbar values are always displayed as floats - cb.formatter.set_powerlimits((0, 0)) # Disable scientific notation - cb.formatter.set_useOffset(False) # Disable offset - cb.formatter.set_scientific(False) # Disable scientific notation - # Set a custom formatter that always shows decimal places - cb.formatter = matplotlib.ticker.FuncFormatter(lambda x, p: f'{x:.1f}') + cb.formatter = matplotlib.ticker.FormatStrFormatter('%.3g') # 2) Image has any number of channels but 1 else: diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 703d0515..ef995d71 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -1145,11 +1145,7 @@ def _decorate_axs( cb = plt.colorbar(cax, ax=ax, pad=0.01, fraction=0.08, aspect=30) cb.solids.set_alpha(alpha) # Ensure colorbar values are always displayed as floats - cb.formatter.set_powerlimits((0, 0)) # Disable scientific notation - cb.formatter.set_useOffset(False) # Disable offset - cb.formatter.set_scientific(False) # Disable scientific notation - # Set a custom formatter that always shows decimal places - cb.formatter = matplotlib.ticker.FuncFormatter(lambda x, p: f'{x:.1f}') + cb.formatter = matplotlib.ticker.FormatStrFormatter('%.3g') if isinstance(scalebar_dx, list) and isinstance(scalebar_units, list): scalebar = ScaleBar(scalebar_dx, units=scalebar_units, **scalebar_kwargs) From 5daba44f11bdddadacada5058353b360daf93cf5 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:15:47 +0000 Subject: [PATCH 08/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spatialdata_plot/pl/render.py | 2 +- src/spatialdata_plot/pl/utils.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 94fbc0ca..648f9edf 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -959,7 +959,7 @@ def _render_images( sm = plt.cm.ScalarMappable(cmap=cmap, norm=render_params.cmap_params.norm) cb = fig_params.fig.colorbar(sm, ax=ax) # Ensure colorbar values are always displayed as floats - cb.formatter = matplotlib.ticker.FormatStrFormatter('%.3g') + cb.formatter = matplotlib.ticker.FormatStrFormatter("%.3g") # 2) Image has any number of channels but 1 else: diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index ef995d71..2c3223a2 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -1145,7 +1145,7 @@ def _decorate_axs( cb = plt.colorbar(cax, ax=ax, pad=0.01, fraction=0.08, aspect=30) cb.solids.set_alpha(alpha) # Ensure colorbar values are always displayed as floats - cb.formatter = matplotlib.ticker.FormatStrFormatter('%.3g') + cb.formatter = matplotlib.ticker.FormatStrFormatter("%.3g") if isinstance(scalebar_dx, list) and isinstance(scalebar_units, list): scalebar = ScaleBar(scalebar_dx, units=scalebar_units, **scalebar_kwargs) From 755cceb2b2e943d8d503524809e8d58f13abb75e Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 21 Oct 2025 19:20:49 +0200 Subject: [PATCH 09/14] removed formatter --- src/spatialdata_plot/pl/render.py | 2 -- src/spatialdata_plot/pl/utils.py | 2 -- 2 files changed, 4 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 94fbc0ca..39f28af3 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -958,8 +958,6 @@ def _render_images( if legend_params.colorbar: sm = plt.cm.ScalarMappable(cmap=cmap, norm=render_params.cmap_params.norm) cb = fig_params.fig.colorbar(sm, ax=ax) - # Ensure colorbar values are always displayed as floats - cb.formatter = matplotlib.ticker.FormatStrFormatter('%.3g') # 2) Image has any number of channels but 1 else: diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index ef995d71..4e4ceb9c 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -1144,8 +1144,6 @@ def _decorate_axs( # TODO: na_in_legend should have some effect here cb = plt.colorbar(cax, ax=ax, pad=0.01, fraction=0.08, aspect=30) cb.solids.set_alpha(alpha) - # Ensure colorbar values are always displayed as floats - cb.formatter = matplotlib.ticker.FormatStrFormatter('%.3g') if isinstance(scalebar_dx, list) and isinstance(scalebar_units, list): scalebar = ScaleBar(scalebar_dx, units=scalebar_units, **scalebar_kwargs) From 3cbf46857549068149d9fbf109f8647e28742b77 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 21 Oct 2025 19:33:09 +0200 Subject: [PATCH 10/14] img from runner + mypy --- src/spatialdata_plot/pl/render.py | 2 +- src/spatialdata_plot/pl/utils.py | 35 ++++++++++-------- .../Shapes_colorbar_can_be_normalised.png | Bin 21254 -> 18548 bytes 3 files changed, 20 insertions(+), 17 deletions(-) diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index 39f28af3..6ada5397 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -957,7 +957,7 @@ def _render_images( if legend_params.colorbar: sm = plt.cm.ScalarMappable(cmap=cmap, norm=render_params.cmap_params.norm) - cb = fig_params.fig.colorbar(sm, ax=ax) + fig_params.fig.colorbar(sm, ax=ax) # 2) Image has any number of channels but 1 else: diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 4e4ceb9c..9ef5bcfd 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -350,7 +350,8 @@ def _get_collection_shape( **kwargs: Any, ) -> PatchCollection: """ - Build a PatchCollection for shapes with correct handling of: + Build a PatchCollection for shapes with correct handling of. + - continuous numeric vectors with NaNs, - per-row RGBA arrays, - a single color or a list of color specs. @@ -367,7 +368,7 @@ def _get_collection_shape( fill_c: np.ndarray def _as_rgba_array(x: Any) -> np.ndarray: - return ColorConverter().to_rgba_array(x) + return np.asarray(ColorConverter().to_rgba_array(x)) # Case A: per-row numeric colors given as Nx3 or Nx4 float array if ( @@ -403,12 +404,12 @@ def _as_rgba_array(x: Any) -> np.ndarray: elif c_arr.ndim == 1 and len(c_arr) == len(shapes) and c_arr.dtype == object: # Split into numeric vs color-like - s = pd.Series(c_arr, copy=False) - num = pd.to_numeric(s, errors="coerce").to_numpy() + c_series = pd.Series(c_arr, copy=False) + num = pd.to_numeric(c_series, errors="coerce").to_numpy() is_num = np.isfinite(num) # init with na color - fill_c = np.empty((len(s), 4), dtype=float) + fill_c = np.empty((len(c_series), 4), dtype=float) fill_c[:] = na_rgba # numeric entries via cmap(norm) @@ -425,7 +426,7 @@ def _as_rgba_array(x: Any) -> np.ndarray: # non-numeric entries as explicit colors if (~is_num).any(): - fill_c[~is_num] = ColorConverter().to_rgba_array(s[~is_num].tolist()) + fill_c[~is_num] = ColorConverter().to_rgba_array(c_series[~is_num].tolist()) # Case C: single color or list of color-like specs (strings or tuples) else: @@ -438,12 +439,11 @@ def _as_rgba_array(x: Any) -> np.ndarray: # Outline handling if outline_alpha and outline_alpha > 0.0: - outline_c = _as_rgba_array(outline_color) - outline_c[..., -1] = outline_alpha - outline_c = outline_c.tolist() + outline_c_array = _as_rgba_array(outline_color) + outline_c_array[..., -1] = outline_alpha + outline_c = outline_c_array.tolist() else: - outline_c = [None] - outline_c = outline_c * fill_c.shape[0] + outline_c = [None] * fill_c.shape[0] # Build DataFrame of valid geometries shapes_df = pd.DataFrame(shapes, copy=True) @@ -507,7 +507,9 @@ def _create_patches( rows.append(pr) return pd.DataFrame(rows) - patches = _create_patches(shapes_df, fill_c, outline_c, s) + patches = _create_patches( + shapes_df, fill_c.tolist(), outline_c.tolist() if hasattr(outline_c, "tolist") else outline_c, s + ) return PatchCollection( patches["geometry"].values.tolist(), @@ -2677,8 +2679,8 @@ def _multipolygon_to_circle(multipolygon: shapely.MultiPolygon) -> tuple[shapely pts = [] for poly in multipolygon.geoms: pts.extend(poly.exterior.coords) - pts = np.array(pts) - hull_pts = pts[ConvexHull(pts).vertices] + pts_array = np.array(pts) + hull_pts = pts_array[ConvexHull(pts_array).vertices] center = np.mean(hull_pts, axis=0) radius = float(np.max(np.linalg.norm(hull_pts - center, axis=1))) nonlocal warn_shape_size @@ -2695,6 +2697,7 @@ def _multipolygon_to_square(multipolygon: shapely.MultiPolygon) -> tuple[shapely return _circle_to_square(c, r) # choose conversion methods + conversion_methods: dict[str, Any] if target_shape == "circle": conversion_methods = { "Point": _circle_to_circle, @@ -2792,8 +2795,8 @@ def _multipolygon_to_visium_hex(multipolygon: shapely.MultiPolygon) -> tuple[sha if warn_shape_size: logger.info( - f"At least one converted shape spans >= {warn_above_extent_fraction * 100:.0f}% of the original total bound. " - "Results may be suboptimal." + f"At least one converted shape spans >= {warn_above_extent_fraction * 100:.0f}% of the " + "original total bound. Results may be suboptimal." ) return shapes diff --git a/tests/_images/Shapes_colorbar_can_be_normalised.png b/tests/_images/Shapes_colorbar_can_be_normalised.png index 7b095252afe9ea2358a8f8d3f8c4a981cde1bc14..f18723e82befda3ecda0d58d47ab872439521ca8 100644 GIT binary patch literal 18548 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 literal 21254 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�oX?`#fZErO{Y+O=nPomgJR}l8 zx(!r5K5HVRO;S_a0q~nWKgss2_rn{Jbz*Uns5ewdV?(G@S5s)(B zN1^if+F5tP`&y30&sec#b{Dp$LcrgtA0{yzTjHwNI7*j8co+w${m2sg!rQDbooT+2 zU*YZMrfr|LN{y2BCcAHhoTgyvMdmCL1{ExO?NQwus=iPCL-x7!oQ?DFI2$qLLM4`2 zkmJk$9DlU7)YOzBIch40-r(q7+_~*?;2e9vADAvv z(x1F1VjVbNCrw|-0<1z?6|=N7NZt1H1aS40rYka+Fy_(+nvopFYS2BKq z(>?K9T&h9CY&_3oLBLaq1fs0gt^0Yrq@}6Zgc2f|a!8aQX%1l$d#c!(ktyvA##?|x zd*QDJF7go(Qu@8{(lBmc>`hq%Kl4JwE*hkvf^lQW)u1kIRMl&{L+E@2T>Z8@Tm^|> zrYKUR2>^e#o1U}D^@~$B==u4{hQ9iz*K(muLP8ZnU2Gg2jv%#&s@E`cx6gB$FYcFO zU$MY!=L*`LFzd)u??(DLAcbJ4)*_i@#mnLz7KQx1`x`1z2mzAI+F3l`z`*I= zJh#0Prg$`F@T~L2oIB*FaZ>16_7z{k3$ex(Xt{^ISvk?hNurX<&lTuTJ=Xo=g?~+6 znLj)WAng&1h4SR}553-*Vj|AREwWdm?f(7ByLYb$OJateO@WqeC`w}FHg_1?&TX(U zM6!p3p4r$pfY&$vu~3s^9Nf($6db!g6$Q~I(-C%YWv1A@8H^}>L5PVkA2^h%wmD5V zvD)1I=n+hGNV5HXQmM>9`9yb=^eAffUXe$}GS&Od~Vpysq zf1^oeCz9XhOm42>HZTG!qK4+BA|4GnV>>X5@XMB5v9~&`>fzsd*~4ONNPPu#dYps= zA6zLfz&7jkP8*X;+R<4O;a{h49m@K!-MhVzaKyqK;6aW^Sa2}wqwKZ?<5ed}GKDgt zMV`JRboBHNhd*yVv~20pMd*305Y%1+eiok3TRU)n_f@6ILfQ%t z%+ZhImymjjQup$4g2fhx2Qf{kC|ltMNkO^9J7b|hm^wFU&2f!S{*=i`6a^ Date: Tue, 21 Oct 2025 19:42:00 +0200 Subject: [PATCH 11/14] new unit tests for nan behaviour --- tests/pl/test_render_shapes.py | 49 ++++++++++++++++++++++++++++++++++ tests/pl/test_utils.py | 32 ++++++++++++++++++++++ 2 files changed, 81 insertions(+) diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index 1a6dc3bb..56066604 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -684,3 +684,52 @@ def test_warns_when_table_does_not_annotate_element(sdata_blobs: SpatialData): table_name="other_table", ).pl.show() ) + + def test_plot_can_handle_nan_values_in_color_data(self, sdata_blobs: SpatialData): + """Test that NaN values in color data are handled gracefully.""" + sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_circles"] * sdata_blobs["table"].n_obs) + sdata_blobs["table"].uns["spatialdata_attrs"]["region"] = "blobs_circles" + + # Add color column with NaN values + sdata_blobs.shapes["blobs_circles"]["color_with_nan"] = [1.0, 2.0, np.nan, 4.0, 5.0] + + # Test that rendering works with NaN values and issues warning + with pytest.warns(UserWarning, match="Found 1 NaN values in color data"): + sdata_blobs.pl.render_shapes( + element="blobs_circles", + color="color_with_nan", + na_color="red" + ).pl.show() + + def test_plot_colorbar_normalization_with_nan_values(self, sdata_blobs: SpatialData): + """Test that colorbar normalization works correctly with NaN values.""" + sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_polygons"] * sdata_blobs["table"].n_obs) + sdata_blobs["table"].uns["spatialdata_attrs"]["region"] = "blobs_polygons" + + sdata_blobs.shapes["blobs_polygons"]["color_with_nan"] = [1.0, 2.0, np.nan, 4.0, 5.0] + + # Test colorbar with NaN values - should use nanmin/nanmax + sdata_blobs.pl.render_shapes( + element="blobs_polygons", + color="color_with_nan", + na_color="gray" + ).pl.show() + + def test_plot_can_handle_non_numeric_radius_values(self, sdata_blobs: SpatialData): + """Test that non-numeric radius values are handled gracefully.""" + sdata_blobs.shapes["blobs_circles"]["radius_mixed"] = [1.0, "invalid", 3.0, np.nan, 5.0] + + sdata_blobs.pl.render_shapes(element="blobs_circles", color="red").pl.show() + + def test_plot_can_handle_mixed_numeric_and_color_data(self, sdata_blobs: SpatialData): + """Test handling of mixed numeric and color-like data.""" + sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_circles"] * sdata_blobs["table"].n_obs) + sdata_blobs["table"].uns["spatialdata_attrs"]["region"] = "blobs_circles" + + sdata_blobs.shapes["blobs_circles"]["mixed_data"] = [1.0, 2.0, np.nan, "red", 5.0] + + sdata_blobs.pl.render_shapes( + element="blobs_circles", + color="mixed_data", + na_color="gray" + ).pl.show() diff --git a/tests/pl/test_utils.py b/tests/pl/test_utils.py index 0eef85e3..1cd8911b 100644 --- a/tests/pl/test_utils.py +++ b/tests/pl/test_utils.py @@ -89,6 +89,38 @@ def test_is_color_like(color_result: tuple[ColorLike, bool]): assert spatialdata_plot.pl.utils._is_color_like(color) == result +def test_extract_scalar_value(): + """Test the new _extract_scalar_value function for robust numeric conversion.""" + from spatialdata_plot.pl.utils import _extract_scalar_value + + # Test basic functionality + assert _extract_scalar_value(3.14) == 3.14 + assert _extract_scalar_value(42) == 42.0 + + # Test with collections + assert _extract_scalar_value(pd.Series([1.0, 2.0, 3.0])) == 1.0 + assert _extract_scalar_value([1.0, 2.0, 3.0]) == 1.0 + + # Test edge cases + assert _extract_scalar_value(np.nan) == 0.0 + assert _extract_scalar_value("invalid") == 0.0 + assert _extract_scalar_value([], default=1.0) == 1.0 + + +def test_plot_can_handle_per_row_rgba_colors(sdata_blobs: SpatialData): + """Test handling of per-row RGBA color arrays.""" + rgba_colors = np.array([ + [1.0, 0.0, 0.0, 1.0], # red + [0.0, 1.0, 0.0, 1.0], # green + [0.0, 0.0, 1.0, 1.0], # blue + [1.0, 1.0, 0.0, 1.0], # yellow + [1.0, 0.0, 1.0, 1.0], # magenta + ]) + sdata_blobs.shapes["blobs_circles"]["rgba_colors"] = rgba_colors.tolist() + + sdata_blobs.pl.render_shapes(element="blobs_circles", color="rgba_colors").pl.show() + + @pytest.mark.parametrize( "input_output", [ From dd6fd9a615181f378bcc405cdec9eee5e7bfc9b6 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Tue, 21 Oct 2025 17:42:13 +0000 Subject: [PATCH 12/14] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- tests/pl/test_render_shapes.py | 32 ++++++++++---------------------- tests/pl/test_utils.py | 24 +++++++++++++----------- 2 files changed, 23 insertions(+), 33 deletions(-) diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index 56066604..e8b301a1 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -689,47 +689,35 @@ def test_plot_can_handle_nan_values_in_color_data(self, sdata_blobs: SpatialData """Test that NaN values in color data are handled gracefully.""" sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_circles"] * sdata_blobs["table"].n_obs) sdata_blobs["table"].uns["spatialdata_attrs"]["region"] = "blobs_circles" - + # Add color column with NaN values sdata_blobs.shapes["blobs_circles"]["color_with_nan"] = [1.0, 2.0, np.nan, 4.0, 5.0] - + # Test that rendering works with NaN values and issues warning with pytest.warns(UserWarning, match="Found 1 NaN values in color data"): - sdata_blobs.pl.render_shapes( - element="blobs_circles", - color="color_with_nan", - na_color="red" - ).pl.show() + sdata_blobs.pl.render_shapes(element="blobs_circles", color="color_with_nan", na_color="red").pl.show() def test_plot_colorbar_normalization_with_nan_values(self, sdata_blobs: SpatialData): """Test that colorbar normalization works correctly with NaN values.""" sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_polygons"] * sdata_blobs["table"].n_obs) sdata_blobs["table"].uns["spatialdata_attrs"]["region"] = "blobs_polygons" - + sdata_blobs.shapes["blobs_polygons"]["color_with_nan"] = [1.0, 2.0, np.nan, 4.0, 5.0] - + # Test colorbar with NaN values - should use nanmin/nanmax - sdata_blobs.pl.render_shapes( - element="blobs_polygons", - color="color_with_nan", - na_color="gray" - ).pl.show() + sdata_blobs.pl.render_shapes(element="blobs_polygons", color="color_with_nan", na_color="gray").pl.show() def test_plot_can_handle_non_numeric_radius_values(self, sdata_blobs: SpatialData): """Test that non-numeric radius values are handled gracefully.""" sdata_blobs.shapes["blobs_circles"]["radius_mixed"] = [1.0, "invalid", 3.0, np.nan, 5.0] - + sdata_blobs.pl.render_shapes(element="blobs_circles", color="red").pl.show() def test_plot_can_handle_mixed_numeric_and_color_data(self, sdata_blobs: SpatialData): """Test handling of mixed numeric and color-like data.""" sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_circles"] * sdata_blobs["table"].n_obs) sdata_blobs["table"].uns["spatialdata_attrs"]["region"] = "blobs_circles" - + sdata_blobs.shapes["blobs_circles"]["mixed_data"] = [1.0, 2.0, np.nan, "red", 5.0] - - sdata_blobs.pl.render_shapes( - element="blobs_circles", - color="mixed_data", - na_color="gray" - ).pl.show() + + sdata_blobs.pl.render_shapes(element="blobs_circles", color="mixed_data", na_color="gray").pl.show() diff --git a/tests/pl/test_utils.py b/tests/pl/test_utils.py index 1cd8911b..f57bf0f8 100644 --- a/tests/pl/test_utils.py +++ b/tests/pl/test_utils.py @@ -92,15 +92,15 @@ def test_is_color_like(color_result: tuple[ColorLike, bool]): def test_extract_scalar_value(): """Test the new _extract_scalar_value function for robust numeric conversion.""" from spatialdata_plot.pl.utils import _extract_scalar_value - + # Test basic functionality assert _extract_scalar_value(3.14) == 3.14 assert _extract_scalar_value(42) == 42.0 - + # Test with collections assert _extract_scalar_value(pd.Series([1.0, 2.0, 3.0])) == 1.0 assert _extract_scalar_value([1.0, 2.0, 3.0]) == 1.0 - + # Test edge cases assert _extract_scalar_value(np.nan) == 0.0 assert _extract_scalar_value("invalid") == 0.0 @@ -109,15 +109,17 @@ def test_extract_scalar_value(): def test_plot_can_handle_per_row_rgba_colors(sdata_blobs: SpatialData): """Test handling of per-row RGBA color arrays.""" - rgba_colors = np.array([ - [1.0, 0.0, 0.0, 1.0], # red - [0.0, 1.0, 0.0, 1.0], # green - [0.0, 0.0, 1.0, 1.0], # blue - [1.0, 1.0, 0.0, 1.0], # yellow - [1.0, 0.0, 1.0, 1.0], # magenta - ]) + rgba_colors = np.array( + [ + [1.0, 0.0, 0.0, 1.0], # red + [0.0, 1.0, 0.0, 1.0], # green + [0.0, 0.0, 1.0, 1.0], # blue + [1.0, 1.0, 0.0, 1.0], # yellow + [1.0, 0.0, 1.0, 1.0], # magenta + ] + ) sdata_blobs.shapes["blobs_circles"]["rgba_colors"] = rgba_colors.tolist() - + sdata_blobs.pl.render_shapes(element="blobs_circles", color="rgba_colors").pl.show() From 1df49901cbc1d52ef90c827bcbb8a15edcbf66e0 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 21 Oct 2025 19:47:39 +0200 Subject: [PATCH 13/14] tests --- tests/pl/test_render_shapes.py | 32 ++++++++++---------------------- tests/pl/test_utils.py | 27 ++++++++++++++++----------- 2 files changed, 26 insertions(+), 33 deletions(-) diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index 56066604..e8b301a1 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -689,47 +689,35 @@ def test_plot_can_handle_nan_values_in_color_data(self, sdata_blobs: SpatialData """Test that NaN values in color data are handled gracefully.""" sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_circles"] * sdata_blobs["table"].n_obs) sdata_blobs["table"].uns["spatialdata_attrs"]["region"] = "blobs_circles" - + # Add color column with NaN values sdata_blobs.shapes["blobs_circles"]["color_with_nan"] = [1.0, 2.0, np.nan, 4.0, 5.0] - + # Test that rendering works with NaN values and issues warning with pytest.warns(UserWarning, match="Found 1 NaN values in color data"): - sdata_blobs.pl.render_shapes( - element="blobs_circles", - color="color_with_nan", - na_color="red" - ).pl.show() + sdata_blobs.pl.render_shapes(element="blobs_circles", color="color_with_nan", na_color="red").pl.show() def test_plot_colorbar_normalization_with_nan_values(self, sdata_blobs: SpatialData): """Test that colorbar normalization works correctly with NaN values.""" sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_polygons"] * sdata_blobs["table"].n_obs) sdata_blobs["table"].uns["spatialdata_attrs"]["region"] = "blobs_polygons" - + sdata_blobs.shapes["blobs_polygons"]["color_with_nan"] = [1.0, 2.0, np.nan, 4.0, 5.0] - + # Test colorbar with NaN values - should use nanmin/nanmax - sdata_blobs.pl.render_shapes( - element="blobs_polygons", - color="color_with_nan", - na_color="gray" - ).pl.show() + sdata_blobs.pl.render_shapes(element="blobs_polygons", color="color_with_nan", na_color="gray").pl.show() def test_plot_can_handle_non_numeric_radius_values(self, sdata_blobs: SpatialData): """Test that non-numeric radius values are handled gracefully.""" sdata_blobs.shapes["blobs_circles"]["radius_mixed"] = [1.0, "invalid", 3.0, np.nan, 5.0] - + sdata_blobs.pl.render_shapes(element="blobs_circles", color="red").pl.show() def test_plot_can_handle_mixed_numeric_and_color_data(self, sdata_blobs: SpatialData): """Test handling of mixed numeric and color-like data.""" sdata_blobs["table"].obs["region"] = pd.Categorical(["blobs_circles"] * sdata_blobs["table"].n_obs) sdata_blobs["table"].uns["spatialdata_attrs"]["region"] = "blobs_circles" - + sdata_blobs.shapes["blobs_circles"]["mixed_data"] = [1.0, 2.0, np.nan, "red", 5.0] - - sdata_blobs.pl.render_shapes( - element="blobs_circles", - color="mixed_data", - na_color="gray" - ).pl.show() + + sdata_blobs.pl.render_shapes(element="blobs_circles", color="mixed_data", na_color="gray").pl.show() diff --git a/tests/pl/test_utils.py b/tests/pl/test_utils.py index 1cd8911b..9b245788 100644 --- a/tests/pl/test_utils.py +++ b/tests/pl/test_utils.py @@ -1,5 +1,6 @@ import matplotlib import matplotlib.pyplot as plt +import numpy as np import pandas as pd import pytest import scanpy as sc @@ -91,16 +92,17 @@ def test_is_color_like(color_result: tuple[ColorLike, bool]): def test_extract_scalar_value(): """Test the new _extract_scalar_value function for robust numeric conversion.""" + from spatialdata_plot.pl.utils import _extract_scalar_value - + # Test basic functionality assert _extract_scalar_value(3.14) == 3.14 assert _extract_scalar_value(42) == 42.0 - + # Test with collections assert _extract_scalar_value(pd.Series([1.0, 2.0, 3.0])) == 1.0 assert _extract_scalar_value([1.0, 2.0, 3.0]) == 1.0 - + # Test edge cases assert _extract_scalar_value(np.nan) == 0.0 assert _extract_scalar_value("invalid") == 0.0 @@ -109,15 +111,18 @@ def test_extract_scalar_value(): def test_plot_can_handle_per_row_rgba_colors(sdata_blobs: SpatialData): """Test handling of per-row RGBA color arrays.""" - rgba_colors = np.array([ - [1.0, 0.0, 0.0, 1.0], # red - [0.0, 1.0, 0.0, 1.0], # green - [0.0, 0.0, 1.0, 1.0], # blue - [1.0, 1.0, 0.0, 1.0], # yellow - [1.0, 0.0, 1.0, 1.0], # magenta - ]) + + rgba_colors = np.array( + [ + [1.0, 0.0, 0.0, 1.0], # red + [0.0, 1.0, 0.0, 1.0], # green + [0.0, 0.0, 1.0, 1.0], # blue + [1.0, 1.0, 0.0, 1.0], # yellow + [1.0, 0.0, 1.0, 1.0], # magenta + ] + ) sdata_blobs.shapes["blobs_circles"]["rgba_colors"] = rgba_colors.tolist() - + sdata_blobs.pl.render_shapes(element="blobs_circles", color="rgba_colors").pl.show() From d561e34a923f997f0e2f4259c8cfd7698ae75c17 Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Tue, 21 Oct 2025 19:54:21 +0200 Subject: [PATCH 14/14] test --- tests/pl/test_utils.py | 21 ++++++++------------- 1 file changed, 8 insertions(+), 13 deletions(-) diff --git a/tests/pl/test_utils.py b/tests/pl/test_utils.py index 9b245788..a9296d2e 100644 --- a/tests/pl/test_utils.py +++ b/tests/pl/test_utils.py @@ -109,21 +109,16 @@ def test_extract_scalar_value(): assert _extract_scalar_value([], default=1.0) == 1.0 -def test_plot_can_handle_per_row_rgba_colors(sdata_blobs: SpatialData): - """Test handling of per-row RGBA color arrays.""" +def test_plot_can_handle_rgba_color_specifications(sdata_blobs: SpatialData): + """Test handling of RGBA color specifications.""" + # Test with RGBA tuple + sdata_blobs.pl.render_shapes(element="blobs_circles", color=(1.0, 0.0, 0.0, 0.8)).pl.show() - rgba_colors = np.array( - [ - [1.0, 0.0, 0.0, 1.0], # red - [0.0, 1.0, 0.0, 1.0], # green - [0.0, 0.0, 1.0, 1.0], # blue - [1.0, 1.0, 0.0, 1.0], # yellow - [1.0, 0.0, 1.0, 1.0], # magenta - ] - ) - sdata_blobs.shapes["blobs_circles"]["rgba_colors"] = rgba_colors.tolist() + # Test with RGB tuple (no alpha) + sdata_blobs.pl.render_shapes(element="blobs_circles", color=(0.0, 1.0, 0.0)).pl.show() - sdata_blobs.pl.render_shapes(element="blobs_circles", color="rgba_colors").pl.show() + # Test with string color + sdata_blobs.pl.render_shapes(element="blobs_circles", color="blue").pl.show() @pytest.mark.parametrize(