From ea2aa78e27b1cafccfbb759651172ca8ced6b0aa Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Thu, 27 Mar 2025 11:19:04 +0100 Subject: [PATCH 1/4] cmap works --- src/spatialdata_plot/pl/utils.py | 647 ++++++++++++++++++++++++------- 1 file changed, 509 insertions(+), 138 deletions(-) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 5d745b71..9e4b285b 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -55,7 +55,13 @@ from skimage.morphology import erosion, square from skimage.segmentation import find_boundaries from skimage.util import map_array -from spatialdata import SpatialData, get_element_annotators, get_extent, get_values, rasterize +from spatialdata import ( + SpatialData, + get_element_annotators, + get_extent, + get_values, + rasterize, +) from spatialdata._core.query.relational_query import _locate_value from spatialdata._types import ArrayLike from spatialdata.models import Image2DModel, Labels2DModel, SpatialElement @@ -106,7 +112,9 @@ def _get_coordinate_system_mapping(sdata: SpatialData) -> dict[str, list[str]]: mapping: dict[str, list[str]] = {} if len(coordsys_keys) < 1: - raise ValueError("SpatialData object must have at least one coordinate system to generate a mapping.") + raise ValueError( + "SpatialData object must have at least one coordinate system to generate a mapping." + ) for key in coordsys_keys: mapping[key] = [] @@ -182,19 +190,30 @@ def _prepare_params_plot( dpi = rcParams["figure.dpi"] if dpi is None else dpi if num_panels > 1 and ax is None: fig, grid = _panel_grid( - num_panels=num_panels, hspace=hspace, wspace=wspace, ncols=ncols, dpi=dpi, figsize=figsize + num_panels=num_panels, + hspace=hspace, + wspace=wspace, + ncols=ncols, + dpi=dpi, + figsize=figsize, ) axs: None | Sequence[Axes] = [plt.subplot(grid[c]) for c in range(num_panels)] elif num_panels > 1: if not isinstance(ax, Sequence): - raise TypeError(f"Expected `ax` to be a `Sequence`, but got {type(ax).__name__}") + raise TypeError( + f"Expected `ax` to be a `Sequence`, but got {type(ax).__name__}" + ) if ax is not None and len(ax) != num_panels: - raise ValueError(f"Len of `ax`: {len(ax)} is not equal to number of panels: {num_panels}.") + raise ValueError( + f"Len of `ax`: {len(ax)} is not equal to number of panels: {num_panels}." + ) if fig is None: raise ValueError( f"Invalid value of `fig`: {fig}. If a list of `Axes` is passed, a `Figure` must also be specified." ) - assert ax is None or isinstance(ax, Sequence), f"Invalid type of `ax`: {type(ax)}, expected `Sequence`." + assert ax is None or isinstance( + ax, Sequence + ), f"Invalid type of `ax`: {type(ax)}, expected `Sequence`." axs = ax else: axs = None @@ -207,7 +226,9 @@ def _prepare_params_plot( # set scalebar if scalebar_dx is not None: - scalebar_dx, scalebar_units = _get_scalebar(scalebar_dx, scalebar_units, num_panels) + scalebar_dx, scalebar_units = _get_scalebar( + scalebar_dx, scalebar_units, num_panels + ) fig_params = FigParams( fig=fig, @@ -216,7 +237,9 @@ def _prepare_params_plot( num_panels=num_panels, frameon=frameon, ) - scalebar_params = ScalebarParams(scalebar_dx=scalebar_dx, scalebar_units=scalebar_units) + scalebar_params = ScalebarParams( + scalebar_dx=scalebar_dx, scalebar_units=scalebar_units + ) return fig_params, scalebar_params @@ -298,16 +321,24 @@ def _get_centroid_of_pathpatch(pathpatch: mpatches.PathPatch) -> tuple[float, fl area = 0.5 * np.sum(x[:-1] * y[1:] - x[1:] * y[:-1]) # Calculate the centroid coordinates - centroid_x = np.sum((x[:-1] + x[1:]) * (x[:-1] * y[1:] - x[1:] * y[:-1])) / (6 * area) - centroid_y = np.sum((y[:-1] + y[1:]) * (x[:-1] * y[1:] - x[1:] * y[:-1])) / (6 * area) + centroid_x = np.sum((x[:-1] + x[1:]) * (x[:-1] * y[1:] - x[1:] * y[:-1])) / ( + 6 * area + ) + centroid_y = np.sum((y[:-1] + y[1:]) * (x[:-1] * y[1:] - x[1:] * y[:-1])) / ( + 6 * area + ) return centroid_x, centroid_y -def _scale_pathpatch_around_centroid(pathpatch: mpatches.PathPatch, scale_factor: float) -> None: +def _scale_pathpatch_around_centroid( + pathpatch: mpatches.PathPatch, scale_factor: float +) -> None: 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_factor for vertex in vertices] + ) pathpatch.get_path().vertices = scaled_vertices @@ -341,12 +372,21 @@ def _get_collection_shape( 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 ( + 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 + 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" @@ -360,7 +400,9 @@ def _get_collection_shape( c = cmap(c) else: try: - norm = colors.Normalize(vmin=min(c), vmax=max(c)) if norm is None else norm + 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" @@ -372,7 +414,9 @@ def _get_collection_shape( fill_c[..., -1] *= render_params.fill_alpha if render_params.outline_params.outline: - outline_c = ColorConverter().to_rgba_array(render_params.outline_params.outline_color) + outline_c = ColorConverter().to_rgba_array( + render_params.outline_params.outline_color + ) outline_c[..., -1] = render_params.outline_alpha outline_c = outline_c.tolist() else: @@ -384,7 +428,11 @@ def _get_collection_shape( shapes_df = shapes_df.reset_index(drop=True) def _assign_fill_and_outline_to_row( - fill_c: list[Any], outline_c: list[Any], row: dict[str, Any], idx: int, is_multiple_shapes: bool + fill_c: list[Any], + outline_c: list[Any], + row: dict[str, Any], + idx: int, + is_multiple_shapes: bool, ) -> None: try: if is_multiple_shapes and len(fill_c) == 1: @@ -394,13 +442,18 @@ def _assign_fill_and_outline_to_row( 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 + raise IndexError( + "Could not assign fill and outline colors due to a mismatch in row numbers." + ) from e def _process_polygon(row: pd.Series, s: float) -> dict[str, Any]: coords = np.array(row["geometry"].exterior.coords) centroid = np.mean(coords, axis=0) scaled_coords = (centroid + (coords - centroid) * s).tolist() - return {**row.to_dict(), "geometry": mpatches.Polygon(scaled_coords, closed=True)} + return { + **row.to_dict(), + "geometry": mpatches.Polygon(scaled_coords, closed=True), + } def _process_multipolygon(row: pd.Series, s: float) -> list[dict[str, Any]]: mp = _make_patch_from_multipolygon(row["geometry"]) @@ -413,10 +466,14 @@ def _process_multipolygon(row: pd.Series, s: float) -> list[dict[str, Any]]: def _process_point(row: pd.Series, s: 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"] * s + ), } - def _create_patches(shapes_df: GeoDataFrame, fill_c: list[Any], outline_c: list[Any], s: float) -> pd.DataFrame: + 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 @@ -432,7 +489,9 @@ def _create_patches(shapes_df: GeoDataFrame, fill_c: list[Any], outline_c: list[ 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) + _assign_fill_and_outline_to_row( + fill_c, outline_c, processed_row, idx, is_multiple_shapes + ) rows.append(processed_row) return pd.DataFrame(rows) @@ -484,9 +543,13 @@ def _get_scalebar( len_lib: int | None = None, ) -> tuple[Sequence[float] | None, Sequence[str] | None]: if scalebar_dx is not None: - _scalebar_dx = _get_list(scalebar_dx, _type=float, ref_len=len_lib, name="scalebar_dx") + _scalebar_dx = _get_list( + scalebar_dx, _type=float, ref_len=len_lib, name="scalebar_dx" + ) scalebar_units = "um" if scalebar_units is None else scalebar_units - _scalebar_units = _get_list(scalebar_units, _type=str, ref_len=len_lib, name="scalebar_units") + _scalebar_units = _get_list( + scalebar_units, _type=str, ref_len=len_lib, name="scalebar_units" + ) else: _scalebar_dx = None _scalebar_units = None @@ -530,15 +593,21 @@ def _set_outline( **kwargs: Any, ) -> OutlineParams: if not isinstance(outline_width, int | float): - raise TypeError(f"Invalid type of `outline_width`: {type(outline_width)}, expected `int` or `float`.") + raise TypeError( + f"Invalid type of `outline_width`: {type(outline_width)}, expected `int` or `float`." + ) if outline_width == 0.0: outline = False if outline_width < 0.0: - logger.warning(f"Negative line widths are not allowed, changing {outline_width} to {(-1) * outline_width}") + logger.warning( + f"Negative line widths are not allowed, changing {outline_width} to {(-1) * outline_width}" + ) outline_width *= -1 # the default black and white colors can be changed using the contour_config parameter - if len(outline_color) in {3, 4} and all(isinstance(c, float) for c in outline_color): + if len(outline_color) in {3, 4} and all( + isinstance(c, float) for c in outline_color + ): outline_color = matplotlib.colors.to_hex(outline_color) if outline: @@ -548,7 +617,9 @@ def _set_outline( return OutlineParams(outline, outline_color, outline_width) -def _get_subplots(num_images: int, ncols: int = 4, width: int = 4, height: int = 3) -> plt.Figure | plt.Axes: +def _get_subplots( + num_images: int, ncols: int = 4, width: int = 4, height: int = 3 +) -> plt.Figure | plt.Axes: """Set up the axs objects. Parameters @@ -676,7 +747,9 @@ def _get_colors_for_categorical_obs( palette = default_102 else: palette = ["grey" for _ in range(len_cat)] - logger.info("input has more than 103 categories. Uniform 'grey' color will be used for all categories.") + logger.info( + "input has more than 103 categories. Uniform 'grey' color will be used for all categories." + ) else: # raise error when user didn't provide the right number of colors in palette if isinstance(palette, list) and len(palette) != len(categories): @@ -721,7 +794,12 @@ def _set_color_source_vec( return color, color, False # Figure out where to get the color from - origins = _locate_value(value_key=value_to_plot, sdata=sdata, element_name=element_name, table_name=table_name) + origins = _locate_value( + value_key=value_to_plot, + sdata=sdata, + element_name=element_name, + table_name=table_name, + ) if len(origins) > 1: raise ValueError( @@ -737,9 +815,13 @@ def _set_color_source_vec( table_layer=table_layer, )[value_to_plot] + print(color_source_vector) + # numerical case, return early # TODO temporary split until refactor is complete - if color_source_vector is not None and not isinstance(color_source_vector.dtype, pd.CategoricalDtype): + if color_source_vector is not None and not isinstance( + color_source_vector.dtype, pd.CategoricalDtype + ): if ( not isinstance(element, GeoDataFrame) and isinstance(palette, list) @@ -753,7 +835,9 @@ def _set_color_source_vec( ) return None, color_source_vector, False - color_source_vector = pd.Categorical(color_source_vector) # convert, e.g., `pd.Series` + color_source_vector = pd.Categorical( + color_source_vector + ) # convert, e.g., `pd.Series` color_mapping = _get_categorical_color_mapping( adata=sdata.table, @@ -776,7 +860,9 @@ def _set_color_source_vec( return color_source_vector, color_vector, True - logger.warning(f"Color key '{value_to_plot}' for element '{element_name}' not been found, using default colors.") + logger.warning( + f"Color key '{value_to_plot}' for element '{element_name}' not been found, using default colors." + ) color = np.full(sdata[table_name].n_obs, to_hex(na_color)) return color, color, False @@ -822,7 +908,9 @@ def _map_color_seg( val_im = map_array(seg.copy(), cell_id, cell_id) if "#" in str(color_vector[0]): # we have hex colors - assert all(_is_color_like(c) for c in color_vector), "Not all values are color-like." + assert all( + _is_color_like(c) for c in color_vector + ), "Not all values are color-like." cols = colors.to_rgba_array(color_vector) else: cols = cmap_params.cmap(cmap_params.norm(color_vector)) @@ -842,7 +930,9 @@ def _map_color_seg( if seg.shape[0] == 1: seg = np.squeeze(seg, axis=0) seg_bound: ArrayLike = np.clip(seg_im - find_boundaries(seg)[:, :, None], 0, 1) - return np.dstack((seg_bound, np.where(val_im > 0, 1, 0))) # add transparency here + return np.dstack( + (seg_bound, np.where(val_im > 0, 1, 0)) + ) # add transparency here if len(val_im.shape) != len(seg_im.shape): val_im = np.expand_dims((val_im > 0).astype(int), axis=-1) @@ -854,13 +944,20 @@ def _generate_base_categorial_color_mapping( cluster_key: str, color_source_vector: ArrayLike | pd.Series[CategoricalDtype], na_color: ColorLike, + cmap_params: CmapParams | None = None, ) -> Mapping[str, str]: - if adata is not None and cluster_key in adata.uns and f"{cluster_key}_colors" in adata.uns: + if ( + adata is not None + and cluster_key in adata.uns + and f"{cluster_key}_colors" in adata.uns + ): colors = adata.uns[f"{cluster_key}_colors"] categories = color_source_vector.categories.tolist() + ["NaN"] if "#" not in na_color: # should be unreachable, but just for safety - raise ValueError("Expected `na_color` to be a hex color, but got a non-hex color.") + raise ValueError( + "Expected `na_color` to be a hex color, but got a non-hex color." + ) colors = [to_hex(to_rgba(color)[:3]) for color in colors] na_color = to_hex(to_rgba(na_color)[:3]) @@ -870,7 +967,9 @@ def _generate_base_categorial_color_mapping( return dict(zip(categories, colors, strict=True)) - return _get_default_categorial_color_mapping(color_source_vector) + return _get_default_categorial_color_mapping( + color_source_vector=color_source_vector, cmap_params=cmap_params + ) def _modify_categorical_color_mapping( @@ -883,32 +982,62 @@ def _modify_categorical_color_mapping( if palette is None or isinstance(palette, list) and palette[0] is None: # subset base mapping to only those specified in groups - modified_mapping = {key: mapping[key] for key in mapping if key in groups or key == "NaN"} - elif len(palette) == len(groups) and isinstance(groups, list) and isinstance(palette, list): + modified_mapping = { + key: mapping[key] for key in mapping if key in groups or key == "NaN" + } + elif ( + len(palette) == len(groups) + and isinstance(groups, list) + and isinstance(palette, list) + ): modified_mapping = dict(zip(groups, palette, strict=True)) else: - raise ValueError(f"Expected palette to be of length `{len(groups)}`, found `{len(palette)}`.") + raise ValueError( + f"Expected palette to be of length `{len(groups)}`, found `{len(palette)}`." + ) return modified_mapping def _get_default_categorial_color_mapping( color_source_vector: ArrayLike | pd.Series[CategoricalDtype], + cmap_params: CmapParams | None = None, ) -> Mapping[str, str]: len_cat = len(color_source_vector.categories.unique()) - if len_cat <= 20: - palette = default_20 - elif len_cat <= 28: - palette = default_28 - elif len_cat <= len(default_102): # 103 colors - palette = default_102 + + # If cmap_params is provided and has a valid colormap, use it + if cmap_params is not None and cmap_params.cmap is not None: + # Generate evenly spaced indices for the colormap + color_idx = np.linspace(0, 1, len_cat) + if isinstance(cmap_params.cmap, ListedColormap): + palette = [to_hex(x) for x in cmap_params.cmap(color_idx)] + elif isinstance(cmap_params.cmap, LinearSegmentedColormap): + palette = [to_hex(cmap_params.cmap(x)) for x in color_idx] + else: + # Fall back to default palettes if cmap is not of expected type + palette = None else: - palette = ["grey" for _ in range(len_cat)] - logger.info("input has more than 103 categories. Uniform 'grey' color will be used for all categories.") + palette = None + + # Fall back to default palettes if no valid cmap was used + if palette is None: + if len_cat <= 20: + palette = default_20 + elif len_cat <= 28: + palette = default_28 + elif len_cat <= len(default_102): # 103 colors + palette = default_102 + else: + palette = ["grey" for _ in range(len_cat)] + logger.info( + "input has more than 103 categories. Uniform 'grey' color will be used for all categories." + ) return { cat: to_hex(to_rgba(col)[:3]) - for cat, col in zip(color_source_vector.categories, palette[:len_cat], strict=True) + for cat, col in zip( + color_source_vector.categories, palette[:len_cat], strict=True + ) } @@ -924,12 +1053,19 @@ def _get_categorical_color_mapping( render_type: Literal["points"] | None = None, ) -> Mapping[str, str]: if not isinstance(color_source_vector, Categorical): - raise TypeError(f"Expected `categories` to be a `Categorical`, but got {type(color_source_vector).__name__}") + raise TypeError( + f"Expected `categories` to be a `Categorical`, but got {type(color_source_vector).__name__}" + ) if isinstance(groups, str): groups = [groups] - if not palette and render_type == "points" and cmap_params is not None and not cmap_params.cmap_is_default: + if ( + not palette + and render_type == "points" + and cmap_params is not None + and not cmap_params.cmap_is_default + ): palette = cmap_params.cmap color_idx = color_idx = np.linspace(0, 1, len(color_source_vector.categories)) @@ -944,20 +1080,35 @@ def _get_categorical_color_mapping( if cluster_key is None: # user didn't specify a column to use for coloring - base_mapping = _get_default_categorial_color_mapping(color_source_vector) + base_mapping = _get_default_categorial_color_mapping( + color_source_vector=color_source_vector, cmap_params=cmap_params + ) else: - base_mapping = _generate_base_categorial_color_mapping(adata, cluster_key, color_source_vector, na_color) + base_mapping = _generate_base_categorial_color_mapping( + adata=adata, + cluster_key=cluster_key, + color_source_vector=color_source_vector, + na_color=na_color, + cmap_params=cmap_params, + ) - return _modify_categorical_color_mapping(mapping=base_mapping, groups=groups, palette=palette) + return _modify_categorical_color_mapping( + mapping=base_mapping, groups=groups, palette=palette + ) def _maybe_set_colors( - source: AnnData, target: AnnData, key: str, palette: str | ListedColormap | Cycler | Sequence[Any] | None = None + source: AnnData, + target: AnnData, + key: str, + palette: str | ListedColormap | Cycler | Sequence[Any] | None = None, ) -> None: color_key = f"{key}_colors" try: if palette is not None: - raise KeyError("Unable to copy the palette when there was other explicitly specified.") + raise KeyError( + "Unable to copy the palette when there was other explicitly specified." + ) target.uns[color_key] = source.uns[color_key] except KeyError: if isinstance(palette, str): @@ -965,7 +1116,9 @@ def _maybe_set_colors( if isinstance(palette, ListedColormap): # `scanpy` requires it palette = cycler(color=palette.colors) palette = None - add_colors_for_categorical_sample_annotation(target, key=key, force_update_colors=True, palette=palette) + add_colors_for_categorical_sample_annotation( + target, key=key, force_update_colors=True, palette=palette + ) def _decorate_axs( @@ -994,12 +1147,16 @@ def _decorate_axs( # there is not need to plot a legend or a colorbar if legend_fontoutline is not None: - path_effect = [patheffects.withStroke(linewidth=legend_fontoutline, foreground="w")] + path_effect = [ + patheffects.withStroke(linewidth=legend_fontoutline, foreground="w") + ] else: path_effect = [] # Adding legends - if color_source_vector is not None and isinstance(color_source_vector.dtype, pd.CategoricalDtype): + if color_source_vector is not None and isinstance( + color_source_vector.dtype, pd.CategoricalDtype + ): # order of clusters should agree to palette order clusters = color_source_vector.remove_unused_categories().unique() clusters = clusters[~clusters.isnull()] @@ -1010,7 +1167,11 @@ def _decorate_axs( "color": color_vector, } ) - color_mapping = group_to_color_matching.drop_duplicates("cats").set_index("cats")["color"].to_dict() + color_mapping = ( + group_to_color_matching.drop_duplicates("cats") + .set_index("cats")["color"] + .to_dict() + ) _add_categorical_legend( ax, pd.Categorical(values=color_source_vector, categories=clusters), @@ -1068,13 +1229,21 @@ def _get_list( ) for v in var: if not isinstance(v, _type): - raise ValueError(f"Variable: `{name}` has invalid type: {type(v)}, expected: {_type}.") + raise ValueError( + f"Variable: `{name}` has invalid type: {type(v)}, expected: {_type}." + ) return var raise ValueError(f"Can't make a list from variable: `{var}`") -def save_fig(fig: Figure, path: str | Path, make_dir: bool = True, ext: str = "png", **kwargs: Any) -> None: +def save_fig( + fig: Figure, + path: str | Path, + make_dir: bool = True, + ext: str = "png", + **kwargs: Any, +) -> None: """ Save a figure. @@ -1118,8 +1287,12 @@ def save_fig(fig: Figure, path: str | Path, make_dir: bool = True, ext: str = "p fig.savefig(path, **kwargs) -def _get_linear_colormap(colors: list[str], background: str) -> list[LinearSegmentedColormap]: - return [LinearSegmentedColormap.from_list(c, [background, c], N=256) for c in colors] +def _get_linear_colormap( + colors: list[str], background: str +) -> list[LinearSegmentedColormap]: + return [ + LinearSegmentedColormap.from_list(c, [background, c], N=256) for c in colors + ] def _get_listed_colormap(color_dict: dict[str, str]) -> ListedColormap: @@ -1162,7 +1335,9 @@ def _make_patch_from_multipolygon(mp: shapely.MultiPolygon) -> mpatches.PathPatc else: inside, outside = _split_multipolygon_into_outer_and_inner(mp) if len(inside) > 0: - codes = np.ones(len(inside), dtype=mpath.Path.code_type) * mpath.Path.LINETO + codes = ( + np.ones(len(inside), dtype=mpath.Path.code_type) * mpath.Path.LINETO + ) codes[0] = mpath.Path.MOVETO all_codes = np.concatenate((codes, codes)) vertices = np.concatenate((outside, inside[::-1])) @@ -1184,7 +1359,11 @@ def _mpl_ax_contains_elements(ax: Axes) -> bool: Based on: https://stackoverflow.com/a/71966295 """ return ( - len(ax.lines) > 0 or len(ax.collections) > 0 or len(ax.images) > 0 or len(ax.patches) > 0 or len(ax.tables) > 0 + len(ax.lines) > 0 + or len(ax.collections) > 0 + or len(ax.images) > 0 + or len(ax.patches) > 0 + or len(ax.tables) > 0 ) @@ -1223,7 +1402,9 @@ def _get_valid_cs( ): # not nice, but ruff wants it (SIM114) valid_cs.append(cs) else: - logger.info(f"Dropping coordinate system '{cs}' since it doesn't have relevant elements.") + logger.info( + f"Dropping coordinate system '{cs}' since it doesn't have relevant elements." + ) return valid_cs @@ -1331,7 +1512,9 @@ def _multiscale_to_spatial_image( if isinstance(scale, str): if scale not in scales and scale != "full": - raise ValueError(f'Scale {scale} does not exist. Please select one of {scales} or set scale = "full"!') + raise ValueError( + f'Scale {scale} does not exist. Please select one of {scales} or set scale = "full"!' + ) optimal_scale = scale if scale == "full": # use scale with highest resolution @@ -1362,11 +1545,23 @@ def _multiscale_to_spatial_image( data_var_keys = list(multiscale_image[optimal_scale].data_vars) image = multiscale_image[optimal_scale][data_var_keys[0]] - return Labels2DModel.parse(image) if is_label else Image2DModel.parse(image, c_coords=image.coords["c"].values) + return ( + Labels2DModel.parse(image) + if is_label + else Image2DModel.parse(image, c_coords=image.coords["c"].values) + ) def _get_elements_to_be_rendered( - render_cmds: list[tuple[str, ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams]], + render_cmds: list[ + tuple[ + str, + ImageRenderParams + | LabelsRenderParams + | PointsRenderParams + | ShapesRenderParams, + ] + ], cs_contents: pd.DataFrame, cs: str, ) -> list[str]: @@ -1426,21 +1621,37 @@ def _validate_show_parameters( return_ax: bool, save: str | Path | None, ) -> None: - if coordinate_systems is not None and not isinstance(coordinate_systems, list | str): - raise TypeError("Parameter 'coordinate_systems' must be a string or a list of strings.") + if coordinate_systems is not None and not isinstance( + coordinate_systems, list | str + ): + raise TypeError( + "Parameter 'coordinate_systems' must be a string or a list of strings." + ) font_weights = ["light", "normal", "medium", "semibold", "bold", "heavy", "black"] if legend_fontweight is not None and ( not isinstance(legend_fontweight, int | str) - or (isinstance(legend_fontweight, str) and legend_fontweight not in font_weights) + or ( + isinstance(legend_fontweight, str) and legend_fontweight not in font_weights + ) ): - readable_font_weights = ", ".join(font_weights[:-1]) + ", or " + font_weights[-1] + readable_font_weights = ( + ", ".join(font_weights[:-1]) + ", or " + font_weights[-1] + ) raise TypeError( "Parameter 'legend_fontweight' must be an integer or one of", f"the following strings: {readable_font_weights}.", ) - font_sizes = ["xx-small", "x-small", "small", "medium", "large", "x-large", "xx-large"] + font_sizes = [ + "xx-small", + "x-small", + "small", + "medium", + "large", + "x-large", + "xx-large", + ] if legend_fontsize is not None and ( not isinstance(legend_fontsize, int | float | str) @@ -1495,7 +1706,9 @@ def _validate_show_parameters( raise TypeError("Parameter 'pad_extent' must be numeric.") if ax is not None and not isinstance(ax, Axes | list): - raise TypeError("Parameter 'ax' must be a matplotlib.axes.Axes or a list of Axes.") + raise TypeError( + "Parameter 'ax' must be a matplotlib.axes.Axes or a list of Axes." + ) if not isinstance(return_ax, bool): raise TypeError("Parameter 'return_ax' must be a boolean.") @@ -1505,27 +1718,53 @@ def _validate_show_parameters( def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[str, Any]: - if (element := param_dict.get("element")) is not None and not isinstance(element, str): + if (element := param_dict.get("element")) is not None and not isinstance( + element, str + ): raise ValueError( "Parameter 'element' must be a string. If you want to display more elements, pass `element` " "as `None` or chain pl.render(...).pl.render(...).pl.show()" ) if element_type == "images": - param_dict["element"] = [element] if element is not None else list(param_dict["sdata"].images.keys()) + param_dict["element"] = ( + [element] + if element is not None + else list(param_dict["sdata"].images.keys()) + ) elif element_type == "labels": - param_dict["element"] = [element] if element is not None else list(param_dict["sdata"].labels.keys()) + param_dict["element"] = ( + [element] + if element is not None + else list(param_dict["sdata"].labels.keys()) + ) elif element_type == "points": - param_dict["element"] = [element] if element is not None else list(param_dict["sdata"].points.keys()) + param_dict["element"] = ( + [element] + if element is not None + else list(param_dict["sdata"].points.keys()) + ) elif element_type == "shapes": - param_dict["element"] = [element] if element is not None else list(param_dict["sdata"].shapes.keys()) + param_dict["element"] = ( + [element] + if element is not None + else list(param_dict["sdata"].shapes.keys()) + ) - if (channel := param_dict.get("channel")) is not None and not isinstance(channel, list | str | int): - raise TypeError("Parameter 'channel' must be a string, an integer, or a list of strings or integers.") + if (channel := param_dict.get("channel")) is not None and not isinstance( + channel, list | str | int + ): + raise TypeError( + "Parameter 'channel' must be a string, an integer, or a list of strings or integers." + ) if isinstance(channel, list): if not all(isinstance(c, str | int) for c in channel): - raise TypeError("Each item in 'channel' list must be a string or an integer.") + raise TypeError( + "Each item in 'channel' list must be a string or an integer." + ) if not all(isinstance(c, type(channel[0])) for c in channel): - raise TypeError("Each item in 'channel' list must be of the same type, either string or integer.") + raise TypeError( + "Each item in 'channel' list must be of the same type, either string or integer." + ) elif "channel" in param_dict: param_dict["channel"] = [channel] if channel is not None else None @@ -1533,12 +1772,18 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if (contour_px := param_dict.get("contour_px")) and not isinstance(contour_px, int): raise TypeError("Parameter 'contour_px' must be an integer.") - if (color := param_dict.get("color")) and element_type in {"shapes", "points", "labels"}: + if (color := param_dict.get("color")) and element_type in { + "shapes", + "points", + "labels", + }: if not isinstance(color, str): raise TypeError("Parameter 'color' must be a string.") if element_type in {"shapes", "points"}: if _is_color_like(color): - logger.info("Value for parameter 'color' appears to be a color, using it as such.") + logger.info( + "Value for parameter 'color' appears to be a color, using it as such." + ) param_dict["col_for_color"] = None else: param_dict["col_for_color"] = color @@ -1555,7 +1800,9 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if (outline_alpha := param_dict.get("outline_alpha")) and ( not isinstance(outline_alpha, float | int) or not 0 <= outline_alpha <= 1 ): - raise TypeError("Parameter 'outline_alpha' must be numeric and between 0 and 1.") + raise TypeError( + "Parameter 'outline_alpha' must be numeric and between 0 and 1." + ) if contour_px is not None and contour_px <= 0: raise ValueError("Parameter 'contour_px' must be a positive number.") @@ -1572,8 +1819,12 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if fill_alpha < 0: raise ValueError("Parameter 'fill_alpha' cannot be negative.") - if (cmap := param_dict.get("cmap")) is not None and (palette := param_dict.get("palette")) is not None: - raise ValueError("Both `palette` and `cmap` are specified. Please specify only one of them.") + if (cmap := param_dict.get("cmap")) is not None and ( + palette := param_dict.get("palette") + ) is not None: + raise ValueError( + "Both `palette` and `cmap` are specified. Please specify only one of them." + ) param_dict["cmap"] = cmap if (groups := param_dict.get("groups")) is not None: @@ -1588,14 +1839,21 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if isinstance((palette := param_dict["palette"]), list): if not all(isinstance(p, str) for p in palette): - raise ValueError("If specified, parameter 'palette' must contain only strings.") + raise ValueError( + "If specified, parameter 'palette' must contain only strings." + ) elif isinstance(palette, str | type(None)) and "palette" in param_dict: param_dict["palette"] = [palette] if palette is not None else None - if element_type in ["shapes", "points", "labels"] and (palette := param_dict.get("palette")) is not None: + if ( + element_type in ["shapes", "points", "labels"] + and (palette := param_dict.get("palette")) is not None + ): groups = param_dict.get("groups") if groups is None: - raise ValueError("When specifying 'palette', 'groups' must also be specified.") + raise ValueError( + "When specifying 'palette', 'groups' must also be specified." + ) if len(groups) != len(palette): raise ValueError( f"The length of 'palette' and 'groups' must be the same, length is {len(palette)} and" @@ -1609,7 +1867,9 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if "cmap" in param_dict: param_dict["cmap"] = [cmap] if cmap is not None else None else: - raise TypeError("Parameter 'cmap' must be a string, a Colormap, or a list of these types.") + raise TypeError( + "Parameter 'cmap' must be a string, a Colormap, or a list of these types." + ) if (na_color := param_dict.get("na_color")) != "default" and ( na_color is not None and not _is_color_like(na_color) @@ -1619,7 +1879,9 @@ 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: @@ -1653,18 +1915,24 @@ def _ensure_table_and_layer_exist_in_sdata( if table_layer: if table_layer in sdata.tables[table_name].layers: return True - raise ValueError(f"Layer '{table_layer}' not found in table '{table_name}'.") + raise ValueError( + f"Layer '{table_layer}' not found in table '{table_name}'." + ) return True # using sdata.tables[table_name].X if table_layer: # user specified a layer but we have no tables => invalid if len(sdata.tables) == 0: - raise ValueError("Trying to use 'table_layer' but no tables are present in the SpatialData object.") + raise ValueError( + "Trying to use 'table_layer' but no tables are present in the SpatialData object." + ) if len(sdata.tables) == 1: single_table_name = list(sdata.tables.keys())[0] if table_layer in sdata.tables[single_table_name].layers: return True - raise ValueError(f"Layer '{table_layer}' not found in table '{single_table_name}'.") + raise ValueError( + f"Layer '{table_layer}' not found in table '{single_table_name}'." + ) # more than one tables, try to find which one has the given layer found_table = False for tname in sdata.tables: @@ -1682,10 +1950,14 @@ def _ensure_table_and_layer_exist_in_sdata( return True # not using any table - assert _ensure_table_and_layer_exist_in_sdata(param_dict.get("sdata"), table_name, table_layer) + assert _ensure_table_and_layer_exist_in_sdata( + param_dict.get("sdata"), table_name, table_layer + ) if (method := param_dict.get("method")) not in ["matplotlib", "datashader", None]: - raise ValueError("If specified, parameter 'method' must be either 'matplotlib' or 'datashader'.") + raise ValueError( + "If specified, parameter 'method' must be either 'matplotlib' or 'datashader'." + ) valid_ds_reduction_methods = [ "sum", @@ -1699,8 +1971,12 @@ def _ensure_table_and_layer_exist_in_sdata( "max", "min", ] - if (ds_reduction := param_dict.get("ds_reduction")) and (ds_reduction not in valid_ds_reduction_methods): - raise ValueError(f"Parameter 'ds_reduction' must be one of the following: {valid_ds_reduction_methods}.") + if (ds_reduction := param_dict.get("ds_reduction")) and ( + ds_reduction not in valid_ds_reduction_methods + ): + raise ValueError( + f"Parameter 'ds_reduction' must be one of the following: {valid_ds_reduction_methods}." + ) if method == "datashader" and ds_reduction is None: param_dict["ds_reduction"] = "sum" @@ -1760,12 +2036,22 @@ def _validate_label_render_params( element_params[el]["table_name"] = None element_params[el]["color"] = None if (color := param_dict["color"]) is not None: - color, table_name = _validate_col_for_column_table(sdata, el, color, param_dict["table_name"], labels=True) + color, table_name = _validate_col_for_column_table( + sdata, el, color, param_dict["table_name"], labels=True + ) element_params[el]["table_name"] = table_name element_params[el]["color"] = color - element_params[el]["palette"] = param_dict["palette"] if element_params[el]["table_name"] is not None else None - element_params[el]["groups"] = param_dict["groups"] if element_params[el]["table_name"] is not None else None + element_params[el]["palette"] = ( + param_dict["palette"] + if element_params[el]["table_name"] is not None + else None + ) + element_params[el]["groups"] = ( + param_dict["groups"] + if element_params[el]["table_name"] is not None + else None + ) return element_params @@ -1825,8 +2111,12 @@ def _validate_points_render_params( element_params[el]["table_name"] = table_name element_params[el]["col_for_color"] = col_for_color - element_params[el]["palette"] = param_dict["palette"] if param_dict["col_for_color"] is not None else None - element_params[el]["groups"] = param_dict["groups"] if param_dict["col_for_color"] is not None else None + element_params[el]["palette"] = ( + param_dict["palette"] if param_dict["col_for_color"] is not None else None + ) + element_params[el]["groups"] = ( + param_dict["groups"] if param_dict["col_for_color"] is not None else None + ) element_params[el]["ds_reduction"] = param_dict["ds_reduction"] return element_params @@ -1899,8 +2189,12 @@ def _validate_shape_render_params( element_params[el]["table_name"] = table_name element_params[el]["col_for_color"] = col_for_color - element_params[el]["palette"] = param_dict["palette"] if param_dict["col_for_color"] is not None else None - element_params[el]["groups"] = param_dict["groups"] if param_dict["col_for_color"] is not None else None + element_params[el]["palette"] = ( + param_dict["palette"] if param_dict["col_for_color"] is not None else None + ) + element_params[el]["groups"] = ( + param_dict["groups"] if param_dict["col_for_color"] is not None else None + ) element_params[el]["method"] = param_dict["method"] element_params[el]["ds_reduction"] = param_dict["ds_reduction"] @@ -1908,28 +2202,40 @@ def _validate_shape_render_params( def _validate_col_for_column_table( - sdata: SpatialData, element_name: str, col_for_color: str | None, table_name: str | None, labels: bool = False + sdata: SpatialData, + element_name: str, + col_for_color: str | None, + table_name: str | None, + labels: bool = False, ) -> tuple[str | None, str | None]: if not labels and col_for_color in sdata[element_name].columns: table_name = None elif table_name is not None: tables = get_element_annotators(sdata, element_name) if table_name not in tables or ( - col_for_color not in sdata[table_name].obs.columns and col_for_color not in sdata[table_name].var_names + col_for_color not in sdata[table_name].obs.columns + and col_for_color not in sdata[table_name].var_names ): table_name = None col_for_color = None else: tables = get_element_annotators(sdata, element_name) for table_name in tables.copy(): - if col_for_color not in sdata[table_name].obs.columns and col_for_color not in sdata[table_name].var_names: + if ( + col_for_color not in sdata[table_name].obs.columns + and col_for_color not in sdata[table_name].var_names + ): tables.remove(table_name) if len(tables) == 0: col_for_color = None elif len(tables) >= 1: table_name = next(iter(tables)) if len(tables) > 1: - warnings.warn(f"Multiple tables contain color column, using {table_name}", UserWarning, stacklevel=2) + warnings.warn( + f"Multiple tables contain color column, using {table_name}", + UserWarning, + stacklevel=2, + ) return col_for_color, table_name @@ -1963,10 +2269,15 @@ def _validate_image_render_params( spatial_element = param_dict["sdata"][el] spatial_element_ch = ( - spatial_element.c if isinstance(spatial_element, DataArray) else spatial_element["scale0"].c + spatial_element.c + if isinstance(spatial_element, DataArray) + else spatial_element["scale0"].c ) if (channel := param_dict["channel"]) is not None and ( - (isinstance(channel[0], int) and max([abs(ch) for ch in channel]) <= len(spatial_element_ch)) + ( + isinstance(channel[0], int) + and max([abs(ch) for ch in channel]) <= len(spatial_element_ch) + ) or all(ch in spatial_element_ch for ch in channel) ): element_params[el]["channel"] = channel @@ -1977,18 +2288,26 @@ def _validate_image_render_params( if isinstance(palette := param_dict["palette"], list): if len(palette) == 1: - palette_length = len(channel) if channel is not None else len(spatial_element_ch) + palette_length = ( + len(channel) if channel is not None else len(spatial_element_ch) + ) palette = palette * palette_length - if (channel is not None and len(palette) != len(channel)) and len(palette) != len(spatial_element_ch): + if (channel is not None and len(palette) != len(channel)) and len( + palette + ) != len(spatial_element_ch): palette = None element_params[el]["palette"] = palette element_params[el]["na_color"] = param_dict["na_color"] if (cmap := param_dict["cmap"]) is not None: if len(cmap) == 1: - cmap_length = len(channel) if channel is not None else len(spatial_element_ch) + cmap_length = ( + len(channel) if channel is not None else len(spatial_element_ch) + ) cmap = cmap * cmap_length - if (channel is not None and len(cmap) != len(channel)) or len(cmap) != len(spatial_element_ch): + if (channel is not None and len(cmap) != len(channel)) or len(cmap) != len( + spatial_element_ch + ): cmap = None element_params[el]["cmap"] = cmap element_params[el]["norm"] = param_dict["norm"] @@ -2006,15 +2325,24 @@ def _validate_image_render_params( def _get_wanted_render_elements( sdata: SpatialData, sdata_wanted_elements: list[str], - params: ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams, + params: ( + ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams + ), cs: str, element_type: Literal["images", "labels", "points", "shapes"], ) -> tuple[list[str], list[str], bool]: wants_elements = True - if element_type in ["images", "labels", "points", "shapes"]: # Prevents eval security risk + if element_type in [ + "images", + "labels", + "points", + "shapes", + ]: # Prevents eval security risk wanted_elements: list[str] = [params.element] wanted_elements_on_cs = [ - element for element in wanted_elements if cs in set(get_transformation(sdata[element], get_all=True).keys()) + element + for element in wanted_elements + if cs in set(get_transformation(sdata[element], get_all=True).keys()) ] sdata_wanted_elements.extend(wanted_elements_on_cs) @@ -2074,7 +2402,9 @@ def _ax_show_and_transform( return im -def set_zero_in_cmap_to_transparent(cmap: Colormap | str, steps: int | None = None) -> ListedColormap: +def set_zero_in_cmap_to_transparent( + cmap: Colormap | str, steps: int | None = None +) -> ListedColormap: """ Modify colormap so that 0s are transparent. @@ -2097,7 +2427,10 @@ def set_zero_in_cmap_to_transparent(cmap: Colormap | str, steps: int | None = No def _get_extent_and_range_for_datashader_canvas( - spatial_element: SpatialElement, coordinate_system: str, ax: Axes, fig_params: FigParams + spatial_element: SpatialElement, + coordinate_system: str, + ax: Axes, + fig_params: FigParams, ) -> tuple[Any, Any, list[Any], list[Any], Any]: extent = get_extent(spatial_element, coordinate_system=coordinate_system) x_ext = [min(0, extent["x"][0]), extent["x"][1]] @@ -2123,7 +2456,9 @@ def _get_extent_and_range_for_datashader_canvas( plot_width = x_ext[1] - x_ext[0] plot_height = y_ext[1] - y_ext[0] plot_width_px = int(round(fig_params.fig.get_size_inches()[0] * fig_params.fig.dpi)) - plot_height_px = int(round(fig_params.fig.get_size_inches()[1] * fig_params.fig.dpi)) + plot_height_px = int( + round(fig_params.fig.get_size_inches()[1] * fig_params.fig.dpi) + ) factor: float factor = np.min([plot_width / plot_width_px, plot_height / plot_height_px]) plot_width = int(np.round(plot_width / factor)) @@ -2133,10 +2468,16 @@ def _get_extent_and_range_for_datashader_canvas( def _create_image_from_datashader_result( - ds_result: ds.transfer_functions.Image | np.ndarray[Any, np.dtype[np.uint8]], factor: float, ax: Axes + ds_result: ds.transfer_functions.Image | np.ndarray[Any, np.dtype[np.uint8]], + factor: float, + ax: Axes, ) -> tuple[MaskedArray[tuple[int, ...], Any], matplotlib.transforms.Transform]: # create SpatialImage from datashader output to get it back to original size - rgba_image_data = ds_result.copy() if isinstance(ds_result, np.ndarray) else ds_result.to_numpy().base + rgba_image_data = ( + ds_result.copy() + if isinstance(ds_result, np.ndarray) + else ds_result.to_numpy().base + ) rgba_image_data = np.transpose(rgba_image_data, (2, 0, 1)) rgba_image = Image2DModel.parse( rgba_image_data, @@ -2153,7 +2494,9 @@ def _create_image_from_datashader_result( def _datashader_aggregate_with_function( - reduction: Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None, + reduction: ( + Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None + ), cvs: Canvas, spatial_element: GeoDataFrame | dask.dataframe.core.DataFrame, col_for_color: str | None, @@ -2202,10 +2545,14 @@ def _datashader_aggregate_with_function( try: element_function = element_function_map[element_type] except KeyError as e: - raise ValueError(f"Element type '{element_type}' is not supported. Use 'points' or 'shapes'.") from e + raise ValueError( + f"Element type '{element_type}' is not supported. Use 'points' or 'shapes'." + ) from e if element_type == "points": - points_aggregate = element_function(spatial_element, "x", "y", agg=reduction_function) + points_aggregate = element_function( + spatial_element, "x", "y", agg=reduction_function + ) if reduction == "any": # replace False/True by nan/1 points_aggregate = points_aggregate.astype(int) @@ -2213,11 +2560,15 @@ def _datashader_aggregate_with_function( return points_aggregate # is shapes - return element_function(spatial_element, geometry="geometry", agg=reduction_function) + return element_function( + spatial_element, geometry="geometry", agg=reduction_function + ) def _datshader_get_how_kw_for_spread( - reduction: Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None, + reduction: ( + Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None + ), ) -> str: # Get the best input for the how argument of ds.tf.spread(), needed for numerical values reduction = reduction or "sum" @@ -2243,8 +2594,13 @@ def _datshader_get_how_kw_for_spread( def _prepare_transformation( - element: DataArray | GeoDataFrame | dask.dataframe.core.DataFrame, coordinate_system: str, ax: Axes | None = None -) -> tuple[matplotlib.transforms.Affine2D, matplotlib.transforms.CompositeGenericTransform | None]: + element: DataArray | GeoDataFrame | dask.dataframe.core.DataFrame, + coordinate_system: str, + ax: Axes | None = None, +) -> tuple[ + matplotlib.transforms.Affine2D, + matplotlib.transforms.CompositeGenericTransform | None, +]: trans = get_transformation(element, get_all=True)[coordinate_system] affine_trans = trans.to_affine_matrix(input_axes=("x", "y"), output_axes=("x", "y")) trans = mtransforms.Affine2D(matrix=affine_trans) @@ -2312,11 +2668,19 @@ def _datashader_map_aggregate_to_color( agg_under = agg.where(agg < span[0]) img_under = ds.tf.shade( - agg_under, cmap=[to_hex(cmap.get_under())[:7]], min_alpha=min_alpha, color_key=color_key + agg_under, + cmap=[to_hex(cmap.get_under())[:7]], + min_alpha=min_alpha, + color_key=color_key, ) agg_over = agg.where(agg > span[1]) - img_over = ds.tf.shade(agg_over, cmap=[to_hex(cmap.get_over())[:7]], min_alpha=min_alpha, color_key=color_key) + img_over = ds.tf.shade( + agg_over, + cmap=[to_hex(cmap.get_over())[:7]], + min_alpha=min_alpha, + color_key=color_key, + ) # stack the 3 arrays manually: go from under, through in to over and always overlay the values where alpha=0 stack = img_under.to_numpy().base @@ -2329,4 +2693,11 @@ def _datashader_map_aggregate_to_color( stack[stack[:, :, 3] == 0] = img_over[stack[:, :, 3] == 0] return stack - return ds.tf.shade(agg, cmap=cmap, color_key=color_key, min_alpha=min_alpha, span=span, how="linear") + return ds.tf.shade( + agg, + cmap=cmap, + color_key=color_key, + min_alpha=min_alpha, + span=span, + how="linear", + ) From 0eddc52777734d112c24d0aa42594285919524d4 Mon Sep 17 00:00:00 2001 From: "pre-commit-ci[bot]" <66853113+pre-commit-ci[bot]@users.noreply.github.com> Date: Thu, 27 Mar 2025 10:19:47 +0000 Subject: [PATCH 2/4] [pre-commit.ci] auto fixes from pre-commit.com hooks for more information, see https://pre-commit.ci --- src/spatialdata_plot/pl/utils.py | 475 +++++++------------------------ 1 file changed, 110 insertions(+), 365 deletions(-) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 9e4b285b..edd0bb7f 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -112,9 +112,7 @@ def _get_coordinate_system_mapping(sdata: SpatialData) -> dict[str, list[str]]: mapping: dict[str, list[str]] = {} if len(coordsys_keys) < 1: - raise ValueError( - "SpatialData object must have at least one coordinate system to generate a mapping." - ) + raise ValueError("SpatialData object must have at least one coordinate system to generate a mapping.") for key in coordsys_keys: mapping[key] = [] @@ -200,20 +198,14 @@ def _prepare_params_plot( axs: None | Sequence[Axes] = [plt.subplot(grid[c]) for c in range(num_panels)] elif num_panels > 1: if not isinstance(ax, Sequence): - raise TypeError( - f"Expected `ax` to be a `Sequence`, but got {type(ax).__name__}" - ) + raise TypeError(f"Expected `ax` to be a `Sequence`, but got {type(ax).__name__}") if ax is not None and len(ax) != num_panels: - raise ValueError( - f"Len of `ax`: {len(ax)} is not equal to number of panels: {num_panels}." - ) + raise ValueError(f"Len of `ax`: {len(ax)} is not equal to number of panels: {num_panels}.") if fig is None: raise ValueError( f"Invalid value of `fig`: {fig}. If a list of `Axes` is passed, a `Figure` must also be specified." ) - assert ax is None or isinstance( - ax, Sequence - ), f"Invalid type of `ax`: {type(ax)}, expected `Sequence`." + assert ax is None or isinstance(ax, Sequence), f"Invalid type of `ax`: {type(ax)}, expected `Sequence`." axs = ax else: axs = None @@ -226,9 +218,7 @@ def _prepare_params_plot( # set scalebar if scalebar_dx is not None: - scalebar_dx, scalebar_units = _get_scalebar( - scalebar_dx, scalebar_units, num_panels - ) + scalebar_dx, scalebar_units = _get_scalebar(scalebar_dx, scalebar_units, num_panels) fig_params = FigParams( fig=fig, @@ -237,9 +227,7 @@ def _prepare_params_plot( num_panels=num_panels, frameon=frameon, ) - scalebar_params = ScalebarParams( - scalebar_dx=scalebar_dx, scalebar_units=scalebar_units - ) + scalebar_params = ScalebarParams(scalebar_dx=scalebar_dx, scalebar_units=scalebar_units) return fig_params, scalebar_params @@ -321,24 +309,16 @@ def _get_centroid_of_pathpatch(pathpatch: mpatches.PathPatch) -> tuple[float, fl area = 0.5 * np.sum(x[:-1] * y[1:] - x[1:] * y[:-1]) # Calculate the centroid coordinates - centroid_x = np.sum((x[:-1] + x[1:]) * (x[:-1] * y[1:] - x[1:] * y[:-1])) / ( - 6 * area - ) - centroid_y = np.sum((y[:-1] + y[1:]) * (x[:-1] * y[1:] - x[1:] * y[:-1])) / ( - 6 * area - ) + centroid_x = np.sum((x[:-1] + x[1:]) * (x[:-1] * y[1:] - x[1:] * y[:-1])) / (6 * area) + centroid_y = np.sum((y[:-1] + y[1:]) * (x[:-1] * y[1:] - x[1:] * y[:-1])) / (6 * area) return centroid_x, centroid_y -def _scale_pathpatch_around_centroid( - pathpatch: mpatches.PathPatch, scale_factor: float -) -> None: +def _scale_pathpatch_around_centroid(pathpatch: mpatches.PathPatch, scale_factor: float) -> None: 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_factor for vertex in vertices]) pathpatch.get_path().vertices = scaled_vertices @@ -372,21 +352,12 @@ def _get_collection_shape( 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 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 - ) + 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" @@ -400,9 +371,7 @@ def _get_collection_shape( c = cmap(c) else: try: - norm = ( - colors.Normalize(vmin=min(c), vmax=max(c)) if norm is None else norm - ) + 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" @@ -414,9 +383,7 @@ def _get_collection_shape( fill_c[..., -1] *= render_params.fill_alpha if render_params.outline_params.outline: - outline_c = ColorConverter().to_rgba_array( - render_params.outline_params.outline_color - ) + outline_c = ColorConverter().to_rgba_array(render_params.outline_params.outline_color) outline_c[..., -1] = render_params.outline_alpha outline_c = outline_c.tolist() else: @@ -442,9 +409,7 @@ def _assign_fill_and_outline_to_row( 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 + raise IndexError("Could not assign fill and outline colors due to a mismatch in row numbers.") from e def _process_polygon(row: pd.Series, s: float) -> dict[str, Any]: coords = np.array(row["geometry"].exterior.coords) @@ -466,14 +431,10 @@ def _process_multipolygon(row: pd.Series, s: float) -> list[dict[str, Any]]: def _process_point(row: pd.Series, s: 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"] * s), } - def _create_patches( - shapes_df: GeoDataFrame, fill_c: list[Any], outline_c: list[Any], s: float - ) -> pd.DataFrame: + 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 @@ -489,9 +450,7 @@ def _create_patches( 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 - ) + _assign_fill_and_outline_to_row(fill_c, outline_c, processed_row, idx, is_multiple_shapes) rows.append(processed_row) return pd.DataFrame(rows) @@ -543,13 +502,9 @@ def _get_scalebar( len_lib: int | None = None, ) -> tuple[Sequence[float] | None, Sequence[str] | None]: if scalebar_dx is not None: - _scalebar_dx = _get_list( - scalebar_dx, _type=float, ref_len=len_lib, name="scalebar_dx" - ) + _scalebar_dx = _get_list(scalebar_dx, _type=float, ref_len=len_lib, name="scalebar_dx") scalebar_units = "um" if scalebar_units is None else scalebar_units - _scalebar_units = _get_list( - scalebar_units, _type=str, ref_len=len_lib, name="scalebar_units" - ) + _scalebar_units = _get_list(scalebar_units, _type=str, ref_len=len_lib, name="scalebar_units") else: _scalebar_dx = None _scalebar_units = None @@ -593,21 +548,15 @@ def _set_outline( **kwargs: Any, ) -> OutlineParams: if not isinstance(outline_width, int | float): - raise TypeError( - f"Invalid type of `outline_width`: {type(outline_width)}, expected `int` or `float`." - ) + raise TypeError(f"Invalid type of `outline_width`: {type(outline_width)}, expected `int` or `float`.") if outline_width == 0.0: outline = False if outline_width < 0.0: - logger.warning( - f"Negative line widths are not allowed, changing {outline_width} to {(-1) * outline_width}" - ) + logger.warning(f"Negative line widths are not allowed, changing {outline_width} to {(-1) * outline_width}") outline_width *= -1 # the default black and white colors can be changed using the contour_config parameter - if len(outline_color) in {3, 4} and all( - isinstance(c, float) for c in outline_color - ): + if len(outline_color) in {3, 4} and all(isinstance(c, float) for c in outline_color): outline_color = matplotlib.colors.to_hex(outline_color) if outline: @@ -617,9 +566,7 @@ def _set_outline( return OutlineParams(outline, outline_color, outline_width) -def _get_subplots( - num_images: int, ncols: int = 4, width: int = 4, height: int = 3 -) -> plt.Figure | plt.Axes: +def _get_subplots(num_images: int, ncols: int = 4, width: int = 4, height: int = 3) -> plt.Figure | plt.Axes: """Set up the axs objects. Parameters @@ -747,9 +694,7 @@ def _get_colors_for_categorical_obs( palette = default_102 else: palette = ["grey" for _ in range(len_cat)] - logger.info( - "input has more than 103 categories. Uniform 'grey' color will be used for all categories." - ) + logger.info("input has more than 103 categories. Uniform 'grey' color will be used for all categories.") else: # raise error when user didn't provide the right number of colors in palette if isinstance(palette, list) and len(palette) != len(categories): @@ -819,9 +764,7 @@ def _set_color_source_vec( # numerical case, return early # TODO temporary split until refactor is complete - if color_source_vector is not None and not isinstance( - color_source_vector.dtype, pd.CategoricalDtype - ): + if color_source_vector is not None and not isinstance(color_source_vector.dtype, pd.CategoricalDtype): if ( not isinstance(element, GeoDataFrame) and isinstance(palette, list) @@ -835,9 +778,7 @@ def _set_color_source_vec( ) return None, color_source_vector, False - color_source_vector = pd.Categorical( - color_source_vector - ) # convert, e.g., `pd.Series` + color_source_vector = pd.Categorical(color_source_vector) # convert, e.g., `pd.Series` color_mapping = _get_categorical_color_mapping( adata=sdata.table, @@ -860,9 +801,7 @@ def _set_color_source_vec( return color_source_vector, color_vector, True - logger.warning( - f"Color key '{value_to_plot}' for element '{element_name}' not been found, using default colors." - ) + logger.warning(f"Color key '{value_to_plot}' for element '{element_name}' not been found, using default colors.") color = np.full(sdata[table_name].n_obs, to_hex(na_color)) return color, color, False @@ -908,9 +847,7 @@ def _map_color_seg( val_im = map_array(seg.copy(), cell_id, cell_id) if "#" in str(color_vector[0]): # we have hex colors - assert all( - _is_color_like(c) for c in color_vector - ), "Not all values are color-like." + assert all(_is_color_like(c) for c in color_vector), "Not all values are color-like." cols = colors.to_rgba_array(color_vector) else: cols = cmap_params.cmap(cmap_params.norm(color_vector)) @@ -930,9 +867,7 @@ def _map_color_seg( if seg.shape[0] == 1: seg = np.squeeze(seg, axis=0) seg_bound: ArrayLike = np.clip(seg_im - find_boundaries(seg)[:, :, None], 0, 1) - return np.dstack( - (seg_bound, np.where(val_im > 0, 1, 0)) - ) # add transparency here + return np.dstack((seg_bound, np.where(val_im > 0, 1, 0))) # add transparency here if len(val_im.shape) != len(seg_im.shape): val_im = np.expand_dims((val_im > 0).astype(int), axis=-1) @@ -946,18 +881,12 @@ def _generate_base_categorial_color_mapping( na_color: ColorLike, cmap_params: CmapParams | None = None, ) -> Mapping[str, str]: - if ( - adata is not None - and cluster_key in adata.uns - and f"{cluster_key}_colors" in adata.uns - ): + if adata is not None and cluster_key in adata.uns and f"{cluster_key}_colors" in adata.uns: colors = adata.uns[f"{cluster_key}_colors"] categories = color_source_vector.categories.tolist() + ["NaN"] if "#" not in na_color: # should be unreachable, but just for safety - raise ValueError( - "Expected `na_color` to be a hex color, but got a non-hex color." - ) + raise ValueError("Expected `na_color` to be a hex color, but got a non-hex color.") colors = [to_hex(to_rgba(color)[:3]) for color in colors] na_color = to_hex(to_rgba(na_color)[:3]) @@ -967,9 +896,7 @@ def _generate_base_categorial_color_mapping( return dict(zip(categories, colors, strict=True)) - return _get_default_categorial_color_mapping( - color_source_vector=color_source_vector, cmap_params=cmap_params - ) + return _get_default_categorial_color_mapping(color_source_vector=color_source_vector, cmap_params=cmap_params) def _modify_categorical_color_mapping( @@ -982,19 +909,11 @@ def _modify_categorical_color_mapping( if palette is None or isinstance(palette, list) and palette[0] is None: # subset base mapping to only those specified in groups - modified_mapping = { - key: mapping[key] for key in mapping if key in groups or key == "NaN" - } - elif ( - len(palette) == len(groups) - and isinstance(groups, list) - and isinstance(palette, list) - ): + modified_mapping = {key: mapping[key] for key in mapping if key in groups or key == "NaN"} + elif len(palette) == len(groups) and isinstance(groups, list) and isinstance(palette, list): modified_mapping = dict(zip(groups, palette, strict=True)) else: - raise ValueError( - f"Expected palette to be of length `{len(groups)}`, found `{len(palette)}`." - ) + raise ValueError(f"Expected palette to be of length `{len(groups)}`, found `{len(palette)}`.") return modified_mapping @@ -1029,15 +948,11 @@ def _get_default_categorial_color_mapping( palette = default_102 else: palette = ["grey" for _ in range(len_cat)] - logger.info( - "input has more than 103 categories. Uniform 'grey' color will be used for all categories." - ) + logger.info("input has more than 103 categories. Uniform 'grey' color will be used for all categories.") return { cat: to_hex(to_rgba(col)[:3]) - for cat, col in zip( - color_source_vector.categories, palette[:len_cat], strict=True - ) + for cat, col in zip(color_source_vector.categories, palette[:len_cat], strict=True) } @@ -1053,19 +968,12 @@ def _get_categorical_color_mapping( render_type: Literal["points"] | None = None, ) -> Mapping[str, str]: if not isinstance(color_source_vector, Categorical): - raise TypeError( - f"Expected `categories` to be a `Categorical`, but got {type(color_source_vector).__name__}" - ) + raise TypeError(f"Expected `categories` to be a `Categorical`, but got {type(color_source_vector).__name__}") if isinstance(groups, str): groups = [groups] - if ( - not palette - and render_type == "points" - and cmap_params is not None - and not cmap_params.cmap_is_default - ): + if not palette and render_type == "points" and cmap_params is not None and not cmap_params.cmap_is_default: palette = cmap_params.cmap color_idx = color_idx = np.linspace(0, 1, len(color_source_vector.categories)) @@ -1092,9 +1000,7 @@ def _get_categorical_color_mapping( cmap_params=cmap_params, ) - return _modify_categorical_color_mapping( - mapping=base_mapping, groups=groups, palette=palette - ) + return _modify_categorical_color_mapping(mapping=base_mapping, groups=groups, palette=palette) def _maybe_set_colors( @@ -1106,9 +1012,7 @@ def _maybe_set_colors( color_key = f"{key}_colors" try: if palette is not None: - raise KeyError( - "Unable to copy the palette when there was other explicitly specified." - ) + raise KeyError("Unable to copy the palette when there was other explicitly specified.") target.uns[color_key] = source.uns[color_key] except KeyError: if isinstance(palette, str): @@ -1116,9 +1020,7 @@ def _maybe_set_colors( if isinstance(palette, ListedColormap): # `scanpy` requires it palette = cycler(color=palette.colors) palette = None - add_colors_for_categorical_sample_annotation( - target, key=key, force_update_colors=True, palette=palette - ) + add_colors_for_categorical_sample_annotation(target, key=key, force_update_colors=True, palette=palette) def _decorate_axs( @@ -1147,16 +1049,12 @@ def _decorate_axs( # there is not need to plot a legend or a colorbar if legend_fontoutline is not None: - path_effect = [ - patheffects.withStroke(linewidth=legend_fontoutline, foreground="w") - ] + path_effect = [patheffects.withStroke(linewidth=legend_fontoutline, foreground="w")] else: path_effect = [] # Adding legends - if color_source_vector is not None and isinstance( - color_source_vector.dtype, pd.CategoricalDtype - ): + if color_source_vector is not None and isinstance(color_source_vector.dtype, pd.CategoricalDtype): # order of clusters should agree to palette order clusters = color_source_vector.remove_unused_categories().unique() clusters = clusters[~clusters.isnull()] @@ -1167,11 +1065,7 @@ def _decorate_axs( "color": color_vector, } ) - color_mapping = ( - group_to_color_matching.drop_duplicates("cats") - .set_index("cats")["color"] - .to_dict() - ) + color_mapping = group_to_color_matching.drop_duplicates("cats").set_index("cats")["color"].to_dict() _add_categorical_legend( ax, pd.Categorical(values=color_source_vector, categories=clusters), @@ -1229,9 +1123,7 @@ def _get_list( ) for v in var: if not isinstance(v, _type): - raise ValueError( - f"Variable: `{name}` has invalid type: {type(v)}, expected: {_type}." - ) + raise ValueError(f"Variable: `{name}` has invalid type: {type(v)}, expected: {_type}.") return var raise ValueError(f"Can't make a list from variable: `{var}`") @@ -1287,12 +1179,8 @@ def save_fig( fig.savefig(path, **kwargs) -def _get_linear_colormap( - colors: list[str], background: str -) -> list[LinearSegmentedColormap]: - return [ - LinearSegmentedColormap.from_list(c, [background, c], N=256) for c in colors - ] +def _get_linear_colormap(colors: list[str], background: str) -> list[LinearSegmentedColormap]: + return [LinearSegmentedColormap.from_list(c, [background, c], N=256) for c in colors] def _get_listed_colormap(color_dict: dict[str, str]) -> ListedColormap: @@ -1335,9 +1223,7 @@ def _make_patch_from_multipolygon(mp: shapely.MultiPolygon) -> mpatches.PathPatc else: inside, outside = _split_multipolygon_into_outer_and_inner(mp) if len(inside) > 0: - codes = ( - np.ones(len(inside), dtype=mpath.Path.code_type) * mpath.Path.LINETO - ) + codes = np.ones(len(inside), dtype=mpath.Path.code_type) * mpath.Path.LINETO codes[0] = mpath.Path.MOVETO all_codes = np.concatenate((codes, codes)) vertices = np.concatenate((outside, inside[::-1])) @@ -1359,11 +1245,7 @@ def _mpl_ax_contains_elements(ax: Axes) -> bool: Based on: https://stackoverflow.com/a/71966295 """ return ( - len(ax.lines) > 0 - or len(ax.collections) > 0 - or len(ax.images) > 0 - or len(ax.patches) > 0 - or len(ax.tables) > 0 + len(ax.lines) > 0 or len(ax.collections) > 0 or len(ax.images) > 0 or len(ax.patches) > 0 or len(ax.tables) > 0 ) @@ -1402,9 +1284,7 @@ def _get_valid_cs( ): # not nice, but ruff wants it (SIM114) valid_cs.append(cs) else: - logger.info( - f"Dropping coordinate system '{cs}' since it doesn't have relevant elements." - ) + logger.info(f"Dropping coordinate system '{cs}' since it doesn't have relevant elements.") return valid_cs @@ -1512,9 +1392,7 @@ def _multiscale_to_spatial_image( if isinstance(scale, str): if scale not in scales and scale != "full": - raise ValueError( - f'Scale {scale} does not exist. Please select one of {scales} or set scale = "full"!' - ) + raise ValueError(f'Scale {scale} does not exist. Please select one of {scales} or set scale = "full"!') optimal_scale = scale if scale == "full": # use scale with highest resolution @@ -1545,21 +1423,14 @@ def _multiscale_to_spatial_image( data_var_keys = list(multiscale_image[optimal_scale].data_vars) image = multiscale_image[optimal_scale][data_var_keys[0]] - return ( - Labels2DModel.parse(image) - if is_label - else Image2DModel.parse(image, c_coords=image.coords["c"].values) - ) + return Labels2DModel.parse(image) if is_label else Image2DModel.parse(image, c_coords=image.coords["c"].values) def _get_elements_to_be_rendered( render_cmds: list[ tuple[ str, - ImageRenderParams - | LabelsRenderParams - | PointsRenderParams - | ShapesRenderParams, + ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams, ] ], cs_contents: pd.DataFrame, @@ -1621,23 +1492,15 @@ def _validate_show_parameters( return_ax: bool, save: str | Path | None, ) -> None: - if coordinate_systems is not None and not isinstance( - coordinate_systems, list | str - ): - raise TypeError( - "Parameter 'coordinate_systems' must be a string or a list of strings." - ) + if coordinate_systems is not None and not isinstance(coordinate_systems, list | str): + raise TypeError("Parameter 'coordinate_systems' must be a string or a list of strings.") font_weights = ["light", "normal", "medium", "semibold", "bold", "heavy", "black"] if legend_fontweight is not None and ( not isinstance(legend_fontweight, int | str) - or ( - isinstance(legend_fontweight, str) and legend_fontweight not in font_weights - ) + or (isinstance(legend_fontweight, str) and legend_fontweight not in font_weights) ): - readable_font_weights = ( - ", ".join(font_weights[:-1]) + ", or " + font_weights[-1] - ) + readable_font_weights = ", ".join(font_weights[:-1]) + ", or " + font_weights[-1] raise TypeError( "Parameter 'legend_fontweight' must be an integer or one of", f"the following strings: {readable_font_weights}.", @@ -1706,9 +1569,7 @@ def _validate_show_parameters( raise TypeError("Parameter 'pad_extent' must be numeric.") if ax is not None and not isinstance(ax, Axes | list): - raise TypeError( - "Parameter 'ax' must be a matplotlib.axes.Axes or a list of Axes." - ) + raise TypeError("Parameter 'ax' must be a matplotlib.axes.Axes or a list of Axes.") if not isinstance(return_ax, bool): raise TypeError("Parameter 'return_ax' must be a boolean.") @@ -1718,53 +1579,27 @@ def _validate_show_parameters( def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[str, Any]: - if (element := param_dict.get("element")) is not None and not isinstance( - element, str - ): + if (element := param_dict.get("element")) is not None and not isinstance(element, str): raise ValueError( "Parameter 'element' must be a string. If you want to display more elements, pass `element` " "as `None` or chain pl.render(...).pl.render(...).pl.show()" ) if element_type == "images": - param_dict["element"] = ( - [element] - if element is not None - else list(param_dict["sdata"].images.keys()) - ) + param_dict["element"] = [element] if element is not None else list(param_dict["sdata"].images.keys()) elif element_type == "labels": - param_dict["element"] = ( - [element] - if element is not None - else list(param_dict["sdata"].labels.keys()) - ) + param_dict["element"] = [element] if element is not None else list(param_dict["sdata"].labels.keys()) elif element_type == "points": - param_dict["element"] = ( - [element] - if element is not None - else list(param_dict["sdata"].points.keys()) - ) + param_dict["element"] = [element] if element is not None else list(param_dict["sdata"].points.keys()) elif element_type == "shapes": - param_dict["element"] = ( - [element] - if element is not None - else list(param_dict["sdata"].shapes.keys()) - ) + param_dict["element"] = [element] if element is not None else list(param_dict["sdata"].shapes.keys()) - if (channel := param_dict.get("channel")) is not None and not isinstance( - channel, list | str | int - ): - raise TypeError( - "Parameter 'channel' must be a string, an integer, or a list of strings or integers." - ) + if (channel := param_dict.get("channel")) is not None and not isinstance(channel, list | str | int): + raise TypeError("Parameter 'channel' must be a string, an integer, or a list of strings or integers.") if isinstance(channel, list): if not all(isinstance(c, str | int) for c in channel): - raise TypeError( - "Each item in 'channel' list must be a string or an integer." - ) + raise TypeError("Each item in 'channel' list must be a string or an integer.") if not all(isinstance(c, type(channel[0])) for c in channel): - raise TypeError( - "Each item in 'channel' list must be of the same type, either string or integer." - ) + raise TypeError("Each item in 'channel' list must be of the same type, either string or integer.") elif "channel" in param_dict: param_dict["channel"] = [channel] if channel is not None else None @@ -1781,9 +1616,7 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st raise TypeError("Parameter 'color' must be a string.") if element_type in {"shapes", "points"}: if _is_color_like(color): - logger.info( - "Value for parameter 'color' appears to be a color, using it as such." - ) + logger.info("Value for parameter 'color' appears to be a color, using it as such.") param_dict["col_for_color"] = None else: param_dict["col_for_color"] = color @@ -1800,9 +1633,7 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if (outline_alpha := param_dict.get("outline_alpha")) and ( not isinstance(outline_alpha, float | int) or not 0 <= outline_alpha <= 1 ): - raise TypeError( - "Parameter 'outline_alpha' must be numeric and between 0 and 1." - ) + raise TypeError("Parameter 'outline_alpha' must be numeric and between 0 and 1.") if contour_px is not None and contour_px <= 0: raise ValueError("Parameter 'contour_px' must be a positive number.") @@ -1819,12 +1650,8 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if fill_alpha < 0: raise ValueError("Parameter 'fill_alpha' cannot be negative.") - if (cmap := param_dict.get("cmap")) is not None and ( - palette := param_dict.get("palette") - ) is not None: - raise ValueError( - "Both `palette` and `cmap` are specified. Please specify only one of them." - ) + if (cmap := param_dict.get("cmap")) is not None and (palette := param_dict.get("palette")) is not None: + raise ValueError("Both `palette` and `cmap` are specified. Please specify only one of them.") param_dict["cmap"] = cmap if (groups := param_dict.get("groups")) is not None: @@ -1839,21 +1666,14 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if isinstance((palette := param_dict["palette"]), list): if not all(isinstance(p, str) for p in palette): - raise ValueError( - "If specified, parameter 'palette' must contain only strings." - ) + raise ValueError("If specified, parameter 'palette' must contain only strings.") elif isinstance(palette, str | type(None)) and "palette" in param_dict: param_dict["palette"] = [palette] if palette is not None else None - if ( - element_type in ["shapes", "points", "labels"] - and (palette := param_dict.get("palette")) is not None - ): + if element_type in ["shapes", "points", "labels"] and (palette := param_dict.get("palette")) is not None: groups = param_dict.get("groups") if groups is None: - raise ValueError( - "When specifying 'palette', 'groups' must also be specified." - ) + raise ValueError("When specifying 'palette', 'groups' must also be specified.") if len(groups) != len(palette): raise ValueError( f"The length of 'palette' and 'groups' must be the same, length is {len(palette)} and" @@ -1867,9 +1687,7 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if "cmap" in param_dict: param_dict["cmap"] = [cmap] if cmap is not None else None else: - raise TypeError( - "Parameter 'cmap' must be a string, a Colormap, or a list of these types." - ) + raise TypeError("Parameter 'cmap' must be a string, a Colormap, or a list of these types.") if (na_color := param_dict.get("na_color")) != "default" and ( na_color is not None and not _is_color_like(na_color) @@ -1879,9 +1697,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: @@ -1915,24 +1731,18 @@ def _ensure_table_and_layer_exist_in_sdata( if table_layer: if table_layer in sdata.tables[table_name].layers: return True - raise ValueError( - f"Layer '{table_layer}' not found in table '{table_name}'." - ) + raise ValueError(f"Layer '{table_layer}' not found in table '{table_name}'.") return True # using sdata.tables[table_name].X if table_layer: # user specified a layer but we have no tables => invalid if len(sdata.tables) == 0: - raise ValueError( - "Trying to use 'table_layer' but no tables are present in the SpatialData object." - ) + raise ValueError("Trying to use 'table_layer' but no tables are present in the SpatialData object.") if len(sdata.tables) == 1: single_table_name = list(sdata.tables.keys())[0] if table_layer in sdata.tables[single_table_name].layers: return True - raise ValueError( - f"Layer '{table_layer}' not found in table '{single_table_name}'." - ) + raise ValueError(f"Layer '{table_layer}' not found in table '{single_table_name}'.") # more than one tables, try to find which one has the given layer found_table = False for tname in sdata.tables: @@ -1950,14 +1760,10 @@ def _ensure_table_and_layer_exist_in_sdata( return True # not using any table - assert _ensure_table_and_layer_exist_in_sdata( - param_dict.get("sdata"), table_name, table_layer - ) + assert _ensure_table_and_layer_exist_in_sdata(param_dict.get("sdata"), table_name, table_layer) if (method := param_dict.get("method")) not in ["matplotlib", "datashader", None]: - raise ValueError( - "If specified, parameter 'method' must be either 'matplotlib' or 'datashader'." - ) + raise ValueError("If specified, parameter 'method' must be either 'matplotlib' or 'datashader'.") valid_ds_reduction_methods = [ "sum", @@ -1971,12 +1777,8 @@ def _ensure_table_and_layer_exist_in_sdata( "max", "min", ] - if (ds_reduction := param_dict.get("ds_reduction")) and ( - ds_reduction not in valid_ds_reduction_methods - ): - raise ValueError( - f"Parameter 'ds_reduction' must be one of the following: {valid_ds_reduction_methods}." - ) + if (ds_reduction := param_dict.get("ds_reduction")) and (ds_reduction not in valid_ds_reduction_methods): + raise ValueError(f"Parameter 'ds_reduction' must be one of the following: {valid_ds_reduction_methods}.") if method == "datashader" and ds_reduction is None: param_dict["ds_reduction"] = "sum" @@ -2036,22 +1838,12 @@ def _validate_label_render_params( element_params[el]["table_name"] = None element_params[el]["color"] = None if (color := param_dict["color"]) is not None: - color, table_name = _validate_col_for_column_table( - sdata, el, color, param_dict["table_name"], labels=True - ) + color, table_name = _validate_col_for_column_table(sdata, el, color, param_dict["table_name"], labels=True) element_params[el]["table_name"] = table_name element_params[el]["color"] = color - element_params[el]["palette"] = ( - param_dict["palette"] - if element_params[el]["table_name"] is not None - else None - ) - element_params[el]["groups"] = ( - param_dict["groups"] - if element_params[el]["table_name"] is not None - else None - ) + element_params[el]["palette"] = param_dict["palette"] if element_params[el]["table_name"] is not None else None + element_params[el]["groups"] = param_dict["groups"] if element_params[el]["table_name"] is not None else None return element_params @@ -2111,12 +1903,8 @@ def _validate_points_render_params( element_params[el]["table_name"] = table_name element_params[el]["col_for_color"] = col_for_color - element_params[el]["palette"] = ( - param_dict["palette"] if param_dict["col_for_color"] is not None else None - ) - element_params[el]["groups"] = ( - param_dict["groups"] if param_dict["col_for_color"] is not None else None - ) + element_params[el]["palette"] = param_dict["palette"] if param_dict["col_for_color"] is not None else None + element_params[el]["groups"] = param_dict["groups"] if param_dict["col_for_color"] is not None else None element_params[el]["ds_reduction"] = param_dict["ds_reduction"] return element_params @@ -2189,12 +1977,8 @@ def _validate_shape_render_params( element_params[el]["table_name"] = table_name element_params[el]["col_for_color"] = col_for_color - element_params[el]["palette"] = ( - param_dict["palette"] if param_dict["col_for_color"] is not None else None - ) - element_params[el]["groups"] = ( - param_dict["groups"] if param_dict["col_for_color"] is not None else None - ) + element_params[el]["palette"] = param_dict["palette"] if param_dict["col_for_color"] is not None else None + element_params[el]["groups"] = param_dict["groups"] if param_dict["col_for_color"] is not None else None element_params[el]["method"] = param_dict["method"] element_params[el]["ds_reduction"] = param_dict["ds_reduction"] @@ -2213,18 +1997,14 @@ 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 or ( - col_for_color not in sdata[table_name].obs.columns - and col_for_color not in sdata[table_name].var_names + col_for_color not in sdata[table_name].obs.columns and col_for_color not in sdata[table_name].var_names ): table_name = None col_for_color = None else: tables = get_element_annotators(sdata, element_name) for table_name in tables.copy(): - if ( - col_for_color not in sdata[table_name].obs.columns - and col_for_color not in sdata[table_name].var_names - ): + if col_for_color not in sdata[table_name].obs.columns and col_for_color not in sdata[table_name].var_names: tables.remove(table_name) if len(tables) == 0: col_for_color = None @@ -2269,15 +2049,10 @@ def _validate_image_render_params( spatial_element = param_dict["sdata"][el] spatial_element_ch = ( - spatial_element.c - if isinstance(spatial_element, DataArray) - else spatial_element["scale0"].c + spatial_element.c if isinstance(spatial_element, DataArray) else spatial_element["scale0"].c ) if (channel := param_dict["channel"]) is not None and ( - ( - isinstance(channel[0], int) - and max([abs(ch) for ch in channel]) <= len(spatial_element_ch) - ) + (isinstance(channel[0], int) and max([abs(ch) for ch in channel]) <= len(spatial_element_ch)) or all(ch in spatial_element_ch for ch in channel) ): element_params[el]["channel"] = channel @@ -2288,26 +2063,18 @@ def _validate_image_render_params( if isinstance(palette := param_dict["palette"], list): if len(palette) == 1: - palette_length = ( - len(channel) if channel is not None else len(spatial_element_ch) - ) + palette_length = len(channel) if channel is not None else len(spatial_element_ch) palette = palette * palette_length - if (channel is not None and len(palette) != len(channel)) and len( - palette - ) != len(spatial_element_ch): + if (channel is not None and len(palette) != len(channel)) and len(palette) != len(spatial_element_ch): palette = None element_params[el]["palette"] = palette element_params[el]["na_color"] = param_dict["na_color"] if (cmap := param_dict["cmap"]) is not None: if len(cmap) == 1: - cmap_length = ( - len(channel) if channel is not None else len(spatial_element_ch) - ) + cmap_length = len(channel) if channel is not None else len(spatial_element_ch) cmap = cmap * cmap_length - if (channel is not None and len(cmap) != len(channel)) or len(cmap) != len( - spatial_element_ch - ): + if (channel is not None and len(cmap) != len(channel)) or len(cmap) != len(spatial_element_ch): cmap = None element_params[el]["cmap"] = cmap element_params[el]["norm"] = param_dict["norm"] @@ -2325,9 +2092,7 @@ def _validate_image_render_params( def _get_wanted_render_elements( sdata: SpatialData, sdata_wanted_elements: list[str], - params: ( - ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams - ), + params: (ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams), cs: str, element_type: Literal["images", "labels", "points", "shapes"], ) -> tuple[list[str], list[str], bool]: @@ -2340,9 +2105,7 @@ def _get_wanted_render_elements( ]: # Prevents eval security risk wanted_elements: list[str] = [params.element] wanted_elements_on_cs = [ - element - for element in wanted_elements - if cs in set(get_transformation(sdata[element], get_all=True).keys()) + element for element in wanted_elements if cs in set(get_transformation(sdata[element], get_all=True).keys()) ] sdata_wanted_elements.extend(wanted_elements_on_cs) @@ -2402,9 +2165,7 @@ def _ax_show_and_transform( return im -def set_zero_in_cmap_to_transparent( - cmap: Colormap | str, steps: int | None = None -) -> ListedColormap: +def set_zero_in_cmap_to_transparent(cmap: Colormap | str, steps: int | None = None) -> ListedColormap: """ Modify colormap so that 0s are transparent. @@ -2456,9 +2217,7 @@ def _get_extent_and_range_for_datashader_canvas( plot_width = x_ext[1] - x_ext[0] plot_height = y_ext[1] - y_ext[0] plot_width_px = int(round(fig_params.fig.get_size_inches()[0] * fig_params.fig.dpi)) - plot_height_px = int( - round(fig_params.fig.get_size_inches()[1] * fig_params.fig.dpi) - ) + plot_height_px = int(round(fig_params.fig.get_size_inches()[1] * fig_params.fig.dpi)) factor: float factor = np.min([plot_width / plot_width_px, plot_height / plot_height_px]) plot_width = int(np.round(plot_width / factor)) @@ -2473,11 +2232,7 @@ def _create_image_from_datashader_result( ax: Axes, ) -> tuple[MaskedArray[tuple[int, ...], Any], matplotlib.transforms.Transform]: # create SpatialImage from datashader output to get it back to original size - rgba_image_data = ( - ds_result.copy() - if isinstance(ds_result, np.ndarray) - else ds_result.to_numpy().base - ) + rgba_image_data = ds_result.copy() if isinstance(ds_result, np.ndarray) else ds_result.to_numpy().base rgba_image_data = np.transpose(rgba_image_data, (2, 0, 1)) rgba_image = Image2DModel.parse( rgba_image_data, @@ -2494,9 +2249,7 @@ def _create_image_from_datashader_result( def _datashader_aggregate_with_function( - reduction: ( - Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None - ), + reduction: (Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None), cvs: Canvas, spatial_element: GeoDataFrame | dask.dataframe.core.DataFrame, col_for_color: str | None, @@ -2545,14 +2298,10 @@ def _datashader_aggregate_with_function( try: element_function = element_function_map[element_type] except KeyError as e: - raise ValueError( - f"Element type '{element_type}' is not supported. Use 'points' or 'shapes'." - ) from e + raise ValueError(f"Element type '{element_type}' is not supported. Use 'points' or 'shapes'.") from e if element_type == "points": - points_aggregate = element_function( - spatial_element, "x", "y", agg=reduction_function - ) + points_aggregate = element_function(spatial_element, "x", "y", agg=reduction_function) if reduction == "any": # replace False/True by nan/1 points_aggregate = points_aggregate.astype(int) @@ -2560,15 +2309,11 @@ def _datashader_aggregate_with_function( return points_aggregate # is shapes - return element_function( - spatial_element, geometry="geometry", agg=reduction_function - ) + return element_function(spatial_element, geometry="geometry", agg=reduction_function) def _datshader_get_how_kw_for_spread( - reduction: ( - Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None - ), + reduction: (Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None), ) -> str: # Get the best input for the how argument of ds.tf.spread(), needed for numerical values reduction = reduction or "sum" From 216c4b2491e0abd026dd336b6772a0876a15e16d Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Thu, 27 Mar 2025 11:31:06 +0100 Subject: [PATCH 3/4] added tests --- src/spatialdata_plot/pl/utils.py | 498 ++++++++----------------------- tests/pl/test_render_shapes.py | 45 ++- 2 files changed, 162 insertions(+), 381 deletions(-) diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 9e4b285b..13b983fe 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -112,9 +112,7 @@ def _get_coordinate_system_mapping(sdata: SpatialData) -> dict[str, list[str]]: mapping: dict[str, list[str]] = {} if len(coordsys_keys) < 1: - raise ValueError( - "SpatialData object must have at least one coordinate system to generate a mapping." - ) + raise ValueError("SpatialData object must have at least one coordinate system to generate a mapping.") for key in coordsys_keys: mapping[key] = [] @@ -200,20 +198,14 @@ def _prepare_params_plot( axs: None | Sequence[Axes] = [plt.subplot(grid[c]) for c in range(num_panels)] elif num_panels > 1: if not isinstance(ax, Sequence): - raise TypeError( - f"Expected `ax` to be a `Sequence`, but got {type(ax).__name__}" - ) + raise TypeError(f"Expected `ax` to be a `Sequence`, but got {type(ax).__name__}") if ax is not None and len(ax) != num_panels: - raise ValueError( - f"Len of `ax`: {len(ax)} is not equal to number of panels: {num_panels}." - ) + raise ValueError(f"Len of `ax`: {len(ax)} is not equal to number of panels: {num_panels}.") if fig is None: raise ValueError( f"Invalid value of `fig`: {fig}. If a list of `Axes` is passed, a `Figure` must also be specified." ) - assert ax is None or isinstance( - ax, Sequence - ), f"Invalid type of `ax`: {type(ax)}, expected `Sequence`." + assert ax is None or isinstance(ax, Sequence), f"Invalid type of `ax`: {type(ax)}, expected `Sequence`." axs = ax else: axs = None @@ -226,9 +218,7 @@ def _prepare_params_plot( # set scalebar if scalebar_dx is not None: - scalebar_dx, scalebar_units = _get_scalebar( - scalebar_dx, scalebar_units, num_panels - ) + scalebar_dx, scalebar_units = _get_scalebar(scalebar_dx, scalebar_units, num_panels) fig_params = FigParams( fig=fig, @@ -237,9 +227,7 @@ def _prepare_params_plot( num_panels=num_panels, frameon=frameon, ) - scalebar_params = ScalebarParams( - scalebar_dx=scalebar_dx, scalebar_units=scalebar_units - ) + scalebar_params = ScalebarParams(scalebar_dx=scalebar_dx, scalebar_units=scalebar_units) return fig_params, scalebar_params @@ -321,24 +309,16 @@ def _get_centroid_of_pathpatch(pathpatch: mpatches.PathPatch) -> tuple[float, fl area = 0.5 * np.sum(x[:-1] * y[1:] - x[1:] * y[:-1]) # Calculate the centroid coordinates - centroid_x = np.sum((x[:-1] + x[1:]) * (x[:-1] * y[1:] - x[1:] * y[:-1])) / ( - 6 * area - ) - centroid_y = np.sum((y[:-1] + y[1:]) * (x[:-1] * y[1:] - x[1:] * y[:-1])) / ( - 6 * area - ) + centroid_x = np.sum((x[:-1] + x[1:]) * (x[:-1] * y[1:] - x[1:] * y[:-1])) / (6 * area) + centroid_y = np.sum((y[:-1] + y[1:]) * (x[:-1] * y[1:] - x[1:] * y[:-1])) / (6 * area) return centroid_x, centroid_y -def _scale_pathpatch_around_centroid( - pathpatch: mpatches.PathPatch, scale_factor: float -) -> None: +def _scale_pathpatch_around_centroid(pathpatch: mpatches.PathPatch, scale_factor: float) -> None: 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_factor for vertex in vertices]) pathpatch.get_path().vertices = scaled_vertices @@ -372,21 +352,12 @@ def _get_collection_shape( 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 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 - ) + 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" @@ -400,9 +371,7 @@ def _get_collection_shape( c = cmap(c) else: try: - norm = ( - colors.Normalize(vmin=min(c), vmax=max(c)) if norm is None else norm - ) + 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" @@ -414,9 +383,7 @@ def _get_collection_shape( fill_c[..., -1] *= render_params.fill_alpha if render_params.outline_params.outline: - outline_c = ColorConverter().to_rgba_array( - render_params.outline_params.outline_color - ) + outline_c = ColorConverter().to_rgba_array(render_params.outline_params.outline_color) outline_c[..., -1] = render_params.outline_alpha outline_c = outline_c.tolist() else: @@ -442,9 +409,7 @@ def _assign_fill_and_outline_to_row( 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 + raise IndexError("Could not assign fill and outline colors due to a mismatch in row numbers.") from e def _process_polygon(row: pd.Series, s: float) -> dict[str, Any]: coords = np.array(row["geometry"].exterior.coords) @@ -466,14 +431,10 @@ def _process_multipolygon(row: pd.Series, s: float) -> list[dict[str, Any]]: def _process_point(row: pd.Series, s: 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"] * s), } - def _create_patches( - shapes_df: GeoDataFrame, fill_c: list[Any], outline_c: list[Any], s: float - ) -> pd.DataFrame: + 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 @@ -489,9 +450,7 @@ def _create_patches( 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 - ) + _assign_fill_and_outline_to_row(fill_c, outline_c, processed_row, idx, is_multiple_shapes) rows.append(processed_row) return pd.DataFrame(rows) @@ -543,13 +502,9 @@ def _get_scalebar( len_lib: int | None = None, ) -> tuple[Sequence[float] | None, Sequence[str] | None]: if scalebar_dx is not None: - _scalebar_dx = _get_list( - scalebar_dx, _type=float, ref_len=len_lib, name="scalebar_dx" - ) + _scalebar_dx = _get_list(scalebar_dx, _type=float, ref_len=len_lib, name="scalebar_dx") scalebar_units = "um" if scalebar_units is None else scalebar_units - _scalebar_units = _get_list( - scalebar_units, _type=str, ref_len=len_lib, name="scalebar_units" - ) + _scalebar_units = _get_list(scalebar_units, _type=str, ref_len=len_lib, name="scalebar_units") else: _scalebar_dx = None _scalebar_units = None @@ -593,21 +548,15 @@ def _set_outline( **kwargs: Any, ) -> OutlineParams: if not isinstance(outline_width, int | float): - raise TypeError( - f"Invalid type of `outline_width`: {type(outline_width)}, expected `int` or `float`." - ) + raise TypeError(f"Invalid type of `outline_width`: {type(outline_width)}, expected `int` or `float`.") if outline_width == 0.0: outline = False if outline_width < 0.0: - logger.warning( - f"Negative line widths are not allowed, changing {outline_width} to {(-1) * outline_width}" - ) + logger.warning(f"Negative line widths are not allowed, changing {outline_width} to {(-1) * outline_width}") outline_width *= -1 # the default black and white colors can be changed using the contour_config parameter - if len(outline_color) in {3, 4} and all( - isinstance(c, float) for c in outline_color - ): + if len(outline_color) in {3, 4} and all(isinstance(c, float) for c in outline_color): outline_color = matplotlib.colors.to_hex(outline_color) if outline: @@ -617,9 +566,7 @@ def _set_outline( return OutlineParams(outline, outline_color, outline_width) -def _get_subplots( - num_images: int, ncols: int = 4, width: int = 4, height: int = 3 -) -> plt.Figure | plt.Axes: +def _get_subplots(num_images: int, ncols: int = 4, width: int = 4, height: int = 3) -> plt.Figure | plt.Axes: """Set up the axs objects. Parameters @@ -747,16 +694,12 @@ def _get_colors_for_categorical_obs( palette = default_102 else: palette = ["grey" for _ in range(len_cat)] - logger.info( - "input has more than 103 categories. Uniform 'grey' color will be used for all categories." - ) - else: - # raise error when user didn't provide the right number of colors in palette - if isinstance(palette, list) and len(palette) != len(categories): - raise ValueError( - f"The number of provided values in the palette ({len(palette)}) doesn't agree with the number of " - f"categories that should be colored ({categories})." - ) + logger.info("input has more than 103 categories. Uniform 'grey' color will be used for all categories.") + elif isinstance(palette, list) and len(palette) != len(categories): + raise ValueError( + f"The number of provided values in the palette ({len(palette)}) doesn't agree with the number of " + f"categories that should be colored ({categories})." + ) # otherwise, single channels turn out grey color_idx = np.linspace(0, 1, len_cat) if len_cat > 1 else [0.7] @@ -815,13 +758,9 @@ def _set_color_source_vec( table_layer=table_layer, )[value_to_plot] - print(color_source_vector) - # numerical case, return early # TODO temporary split until refactor is complete - if color_source_vector is not None and not isinstance( - color_source_vector.dtype, pd.CategoricalDtype - ): + if color_source_vector is not None and not isinstance(color_source_vector.dtype, pd.CategoricalDtype): if ( not isinstance(element, GeoDataFrame) and isinstance(palette, list) @@ -835,9 +774,7 @@ def _set_color_source_vec( ) return None, color_source_vector, False - color_source_vector = pd.Categorical( - color_source_vector - ) # convert, e.g., `pd.Series` + color_source_vector = pd.Categorical(color_source_vector) # convert, e.g., `pd.Series` color_mapping = _get_categorical_color_mapping( adata=sdata.table, @@ -860,9 +797,7 @@ def _set_color_source_vec( return color_source_vector, color_vector, True - logger.warning( - f"Color key '{value_to_plot}' for element '{element_name}' not been found, using default colors." - ) + logger.warning(f"Color key '{value_to_plot}' for element '{element_name}' not been found, using default colors.") color = np.full(sdata[table_name].n_obs, to_hex(na_color)) return color, color, False @@ -908,9 +843,7 @@ def _map_color_seg( val_im = map_array(seg.copy(), cell_id, cell_id) if "#" in str(color_vector[0]): # we have hex colors - assert all( - _is_color_like(c) for c in color_vector - ), "Not all values are color-like." + assert all(_is_color_like(c) for c in color_vector), "Not all values are color-like." cols = colors.to_rgba_array(color_vector) else: cols = cmap_params.cmap(cmap_params.norm(color_vector)) @@ -930,9 +863,7 @@ def _map_color_seg( if seg.shape[0] == 1: seg = np.squeeze(seg, axis=0) seg_bound: ArrayLike = np.clip(seg_im - find_boundaries(seg)[:, :, None], 0, 1) - return np.dstack( - (seg_bound, np.where(val_im > 0, 1, 0)) - ) # add transparency here + return np.dstack((seg_bound, np.where(val_im > 0, 1, 0))) # add transparency here if len(val_im.shape) != len(seg_im.shape): val_im = np.expand_dims((val_im > 0).astype(int), axis=-1) @@ -946,18 +877,12 @@ def _generate_base_categorial_color_mapping( na_color: ColorLike, cmap_params: CmapParams | None = None, ) -> Mapping[str, str]: - if ( - adata is not None - and cluster_key in adata.uns - and f"{cluster_key}_colors" in adata.uns - ): + if adata is not None and cluster_key in adata.uns and f"{cluster_key}_colors" in adata.uns: colors = adata.uns[f"{cluster_key}_colors"] categories = color_source_vector.categories.tolist() + ["NaN"] if "#" not in na_color: # should be unreachable, but just for safety - raise ValueError( - "Expected `na_color` to be a hex color, but got a non-hex color." - ) + raise ValueError("Expected `na_color` to be a hex color, but got a non-hex color.") colors = [to_hex(to_rgba(color)[:3]) for color in colors] na_color = to_hex(to_rgba(na_color)[:3]) @@ -967,9 +892,7 @@ def _generate_base_categorial_color_mapping( return dict(zip(categories, colors, strict=True)) - return _get_default_categorial_color_mapping( - color_source_vector=color_source_vector, cmap_params=cmap_params - ) + return _get_default_categorial_color_mapping(color_source_vector=color_source_vector, cmap_params=cmap_params) def _modify_categorical_color_mapping( @@ -982,19 +905,11 @@ def _modify_categorical_color_mapping( if palette is None or isinstance(palette, list) and palette[0] is None: # subset base mapping to only those specified in groups - modified_mapping = { - key: mapping[key] for key in mapping if key in groups or key == "NaN" - } - elif ( - len(palette) == len(groups) - and isinstance(groups, list) - and isinstance(palette, list) - ): + modified_mapping = {key: mapping[key] for key in mapping if key in groups or key == "NaN"} + elif len(palette) == len(groups) and isinstance(groups, list) and isinstance(palette, list): modified_mapping = dict(zip(groups, palette, strict=True)) else: - raise ValueError( - f"Expected palette to be of length `{len(groups)}`, found `{len(palette)}`." - ) + raise ValueError(f"Expected palette to be of length `{len(groups)}`, found `{len(palette)}`.") return modified_mapping @@ -1005,7 +920,7 @@ def _get_default_categorial_color_mapping( ) -> Mapping[str, str]: len_cat = len(color_source_vector.categories.unique()) - # If cmap_params is provided and has a valid colormap, use it + # Try to use provided colormap first if cmap_params is not None and cmap_params.cmap is not None: # Generate evenly spaced indices for the colormap color_idx = np.linspace(0, 1, len_cat) @@ -1019,7 +934,7 @@ def _get_default_categorial_color_mapping( else: palette = None - # Fall back to default palettes if no valid cmap was used + # Fall back to default palettes if needed if palette is None: if len_cat <= 20: palette = default_20 @@ -1028,17 +943,10 @@ def _get_default_categorial_color_mapping( elif len_cat <= len(default_102): # 103 colors palette = default_102 else: - palette = ["grey" for _ in range(len_cat)] - logger.info( - "input has more than 103 categories. Uniform 'grey' color will be used for all categories." - ) + palette = ["grey"] * len_cat + logger.info("input has more than 103 categories. Uniform 'grey' color will be used for all categories.") - return { - cat: to_hex(to_rgba(col)[:3]) - for cat, col in zip( - color_source_vector.categories, palette[:len_cat], strict=True - ) - } + return dict(zip(color_source_vector.categories, palette[:len_cat], strict=True)) def _get_categorical_color_mapping( @@ -1053,19 +961,12 @@ def _get_categorical_color_mapping( render_type: Literal["points"] | None = None, ) -> Mapping[str, str]: if not isinstance(color_source_vector, Categorical): - raise TypeError( - f"Expected `categories` to be a `Categorical`, but got {type(color_source_vector).__name__}" - ) + raise TypeError(f"Expected `categories` to be a `Categorical`, but got {type(color_source_vector).__name__}") if isinstance(groups, str): groups = [groups] - if ( - not palette - and render_type == "points" - and cmap_params is not None - and not cmap_params.cmap_is_default - ): + if not palette and render_type == "points" and cmap_params is not None and not cmap_params.cmap_is_default: palette = cmap_params.cmap color_idx = color_idx = np.linspace(0, 1, len(color_source_vector.categories)) @@ -1092,9 +993,7 @@ def _get_categorical_color_mapping( cmap_params=cmap_params, ) - return _modify_categorical_color_mapping( - mapping=base_mapping, groups=groups, palette=palette - ) + return _modify_categorical_color_mapping(mapping=base_mapping, groups=groups, palette=palette) def _maybe_set_colors( @@ -1106,9 +1005,7 @@ def _maybe_set_colors( color_key = f"{key}_colors" try: if palette is not None: - raise KeyError( - "Unable to copy the palette when there was other explicitly specified." - ) + raise KeyError("Unable to copy the palette when there was other explicitly specified.") target.uns[color_key] = source.uns[color_key] except KeyError: if isinstance(palette, str): @@ -1116,9 +1013,7 @@ def _maybe_set_colors( if isinstance(palette, ListedColormap): # `scanpy` requires it palette = cycler(color=palette.colors) palette = None - add_colors_for_categorical_sample_annotation( - target, key=key, force_update_colors=True, palette=palette - ) + add_colors_for_categorical_sample_annotation(target, key=key, force_update_colors=True, palette=palette) def _decorate_axs( @@ -1147,16 +1042,12 @@ def _decorate_axs( # there is not need to plot a legend or a colorbar if legend_fontoutline is not None: - path_effect = [ - patheffects.withStroke(linewidth=legend_fontoutline, foreground="w") - ] + path_effect = [patheffects.withStroke(linewidth=legend_fontoutline, foreground="w")] else: path_effect = [] # Adding legends - if color_source_vector is not None and isinstance( - color_source_vector.dtype, pd.CategoricalDtype - ): + if color_source_vector is not None and isinstance(color_source_vector.dtype, pd.CategoricalDtype): # order of clusters should agree to palette order clusters = color_source_vector.remove_unused_categories().unique() clusters = clusters[~clusters.isnull()] @@ -1167,11 +1058,7 @@ def _decorate_axs( "color": color_vector, } ) - color_mapping = ( - group_to_color_matching.drop_duplicates("cats") - .set_index("cats")["color"] - .to_dict() - ) + color_mapping = group_to_color_matching.drop_duplicates("cats").set_index("cats")["color"].to_dict() _add_categorical_legend( ax, pd.Categorical(values=color_source_vector, categories=clusters), @@ -1229,9 +1116,7 @@ def _get_list( ) for v in var: if not isinstance(v, _type): - raise ValueError( - f"Variable: `{name}` has invalid type: {type(v)}, expected: {_type}." - ) + raise ValueError(f"Variable: `{name}` has invalid type: {type(v)}, expected: {_type}.") return var raise ValueError(f"Can't make a list from variable: `{var}`") @@ -1287,12 +1172,8 @@ def save_fig( fig.savefig(path, **kwargs) -def _get_linear_colormap( - colors: list[str], background: str -) -> list[LinearSegmentedColormap]: - return [ - LinearSegmentedColormap.from_list(c, [background, c], N=256) for c in colors - ] +def _get_linear_colormap(colors: list[str], background: str) -> list[LinearSegmentedColormap]: + return [LinearSegmentedColormap.from_list(c, [background, c], N=256) for c in colors] def _get_listed_colormap(color_dict: dict[str, str]) -> ListedColormap: @@ -1335,9 +1216,7 @@ def _make_patch_from_multipolygon(mp: shapely.MultiPolygon) -> mpatches.PathPatc else: inside, outside = _split_multipolygon_into_outer_and_inner(mp) if len(inside) > 0: - codes = ( - np.ones(len(inside), dtype=mpath.Path.code_type) * mpath.Path.LINETO - ) + codes = np.ones(len(inside), dtype=mpath.Path.code_type) * mpath.Path.LINETO codes[0] = mpath.Path.MOVETO all_codes = np.concatenate((codes, codes)) vertices = np.concatenate((outside, inside[::-1])) @@ -1359,11 +1238,7 @@ def _mpl_ax_contains_elements(ax: Axes) -> bool: Based on: https://stackoverflow.com/a/71966295 """ return ( - len(ax.lines) > 0 - or len(ax.collections) > 0 - or len(ax.images) > 0 - or len(ax.patches) > 0 - or len(ax.tables) > 0 + len(ax.lines) > 0 or len(ax.collections) > 0 or len(ax.images) > 0 or len(ax.patches) > 0 or len(ax.tables) > 0 ) @@ -1402,9 +1277,7 @@ def _get_valid_cs( ): # not nice, but ruff wants it (SIM114) valid_cs.append(cs) else: - logger.info( - f"Dropping coordinate system '{cs}' since it doesn't have relevant elements." - ) + logger.info(f"Dropping coordinate system '{cs}' since it doesn't have relevant elements.") return valid_cs @@ -1512,9 +1385,7 @@ def _multiscale_to_spatial_image( if isinstance(scale, str): if scale not in scales and scale != "full": - raise ValueError( - f'Scale {scale} does not exist. Please select one of {scales} or set scale = "full"!' - ) + raise ValueError(f'Scale {scale} does not exist. Please select one of {scales} or set scale = "full"!') optimal_scale = scale if scale == "full": # use scale with highest resolution @@ -1545,21 +1416,14 @@ def _multiscale_to_spatial_image( data_var_keys = list(multiscale_image[optimal_scale].data_vars) image = multiscale_image[optimal_scale][data_var_keys[0]] - return ( - Labels2DModel.parse(image) - if is_label - else Image2DModel.parse(image, c_coords=image.coords["c"].values) - ) + return Labels2DModel.parse(image) if is_label else Image2DModel.parse(image, c_coords=image.coords["c"].values) def _get_elements_to_be_rendered( render_cmds: list[ tuple[ str, - ImageRenderParams - | LabelsRenderParams - | PointsRenderParams - | ShapesRenderParams, + ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams, ] ], cs_contents: pd.DataFrame, @@ -1621,23 +1485,15 @@ def _validate_show_parameters( return_ax: bool, save: str | Path | None, ) -> None: - if coordinate_systems is not None and not isinstance( - coordinate_systems, list | str - ): - raise TypeError( - "Parameter 'coordinate_systems' must be a string or a list of strings." - ) + if coordinate_systems is not None and not isinstance(coordinate_systems, list | str): + raise TypeError("Parameter 'coordinate_systems' must be a string or a list of strings.") font_weights = ["light", "normal", "medium", "semibold", "bold", "heavy", "black"] if legend_fontweight is not None and ( not isinstance(legend_fontweight, int | str) - or ( - isinstance(legend_fontweight, str) and legend_fontweight not in font_weights - ) + or (isinstance(legend_fontweight, str) and legend_fontweight not in font_weights) ): - readable_font_weights = ( - ", ".join(font_weights[:-1]) + ", or " + font_weights[-1] - ) + readable_font_weights = ", ".join(font_weights[:-1]) + ", or " + font_weights[-1] raise TypeError( "Parameter 'legend_fontweight' must be an integer or one of", f"the following strings: {readable_font_weights}.", @@ -1706,9 +1562,7 @@ def _validate_show_parameters( raise TypeError("Parameter 'pad_extent' must be numeric.") if ax is not None and not isinstance(ax, Axes | list): - raise TypeError( - "Parameter 'ax' must be a matplotlib.axes.Axes or a list of Axes." - ) + raise TypeError("Parameter 'ax' must be a matplotlib.axes.Axes or a list of Axes.") if not isinstance(return_ax, bool): raise TypeError("Parameter 'return_ax' must be a boolean.") @@ -1718,53 +1572,27 @@ def _validate_show_parameters( def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[str, Any]: - if (element := param_dict.get("element")) is not None and not isinstance( - element, str - ): + if (element := param_dict.get("element")) is not None and not isinstance(element, str): raise ValueError( "Parameter 'element' must be a string. If you want to display more elements, pass `element` " "as `None` or chain pl.render(...).pl.render(...).pl.show()" ) if element_type == "images": - param_dict["element"] = ( - [element] - if element is not None - else list(param_dict["sdata"].images.keys()) - ) + param_dict["element"] = [element] if element is not None else list(param_dict["sdata"].images.keys()) elif element_type == "labels": - param_dict["element"] = ( - [element] - if element is not None - else list(param_dict["sdata"].labels.keys()) - ) + param_dict["element"] = [element] if element is not None else list(param_dict["sdata"].labels.keys()) elif element_type == "points": - param_dict["element"] = ( - [element] - if element is not None - else list(param_dict["sdata"].points.keys()) - ) + param_dict["element"] = [element] if element is not None else list(param_dict["sdata"].points.keys()) elif element_type == "shapes": - param_dict["element"] = ( - [element] - if element is not None - else list(param_dict["sdata"].shapes.keys()) - ) + param_dict["element"] = [element] if element is not None else list(param_dict["sdata"].shapes.keys()) - if (channel := param_dict.get("channel")) is not None and not isinstance( - channel, list | str | int - ): - raise TypeError( - "Parameter 'channel' must be a string, an integer, or a list of strings or integers." - ) + if (channel := param_dict.get("channel")) is not None and not isinstance(channel, list | str | int): + raise TypeError("Parameter 'channel' must be a string, an integer, or a list of strings or integers.") if isinstance(channel, list): if not all(isinstance(c, str | int) for c in channel): - raise TypeError( - "Each item in 'channel' list must be a string or an integer." - ) + raise TypeError("Each item in 'channel' list must be a string or an integer.") if not all(isinstance(c, type(channel[0])) for c in channel): - raise TypeError( - "Each item in 'channel' list must be of the same type, either string or integer." - ) + raise TypeError("Each item in 'channel' list must be of the same type, either string or integer.") elif "channel" in param_dict: param_dict["channel"] = [channel] if channel is not None else None @@ -1781,9 +1609,7 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st raise TypeError("Parameter 'color' must be a string.") if element_type in {"shapes", "points"}: if _is_color_like(color): - logger.info( - "Value for parameter 'color' appears to be a color, using it as such." - ) + logger.info("Value for parameter 'color' appears to be a color, using it as such.") param_dict["col_for_color"] = None else: param_dict["col_for_color"] = color @@ -1800,9 +1626,7 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if (outline_alpha := param_dict.get("outline_alpha")) and ( not isinstance(outline_alpha, float | int) or not 0 <= outline_alpha <= 1 ): - raise TypeError( - "Parameter 'outline_alpha' must be numeric and between 0 and 1." - ) + raise TypeError("Parameter 'outline_alpha' must be numeric and between 0 and 1.") if contour_px is not None and contour_px <= 0: raise ValueError("Parameter 'contour_px' must be a positive number.") @@ -1819,12 +1643,8 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if fill_alpha < 0: raise ValueError("Parameter 'fill_alpha' cannot be negative.") - if (cmap := param_dict.get("cmap")) is not None and ( - palette := param_dict.get("palette") - ) is not None: - raise ValueError( - "Both `palette` and `cmap` are specified. Please specify only one of them." - ) + if (cmap := param_dict.get("cmap")) is not None and (palette := param_dict.get("palette")) is not None: + raise ValueError("Both `palette` and `cmap` are specified. Please specify only one of them.") param_dict["cmap"] = cmap if (groups := param_dict.get("groups")) is not None: @@ -1839,21 +1659,14 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if isinstance((palette := param_dict["palette"]), list): if not all(isinstance(p, str) for p in palette): - raise ValueError( - "If specified, parameter 'palette' must contain only strings." - ) + raise ValueError("If specified, parameter 'palette' must contain only strings.") elif isinstance(palette, str | type(None)) and "palette" in param_dict: param_dict["palette"] = [palette] if palette is not None else None - if ( - element_type in ["shapes", "points", "labels"] - and (palette := param_dict.get("palette")) is not None - ): + if element_type in ["shapes", "points", "labels"] and (palette := param_dict.get("palette")) is not None: groups = param_dict.get("groups") if groups is None: - raise ValueError( - "When specifying 'palette', 'groups' must also be specified." - ) + raise ValueError("When specifying 'palette', 'groups' must also be specified.") if len(groups) != len(palette): raise ValueError( f"The length of 'palette' and 'groups' must be the same, length is {len(palette)} and" @@ -1867,9 +1680,7 @@ def _type_check_params(param_dict: dict[str, Any], element_type: str) -> dict[st if "cmap" in param_dict: param_dict["cmap"] = [cmap] if cmap is not None else None else: - raise TypeError( - "Parameter 'cmap' must be a string, a Colormap, or a list of these types." - ) + raise TypeError("Parameter 'cmap' must be a string, a Colormap, or a list of these types.") if (na_color := param_dict.get("na_color")) != "default" and ( na_color is not None and not _is_color_like(na_color) @@ -1879,9 +1690,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: @@ -1915,24 +1724,18 @@ def _ensure_table_and_layer_exist_in_sdata( if table_layer: if table_layer in sdata.tables[table_name].layers: return True - raise ValueError( - f"Layer '{table_layer}' not found in table '{table_name}'." - ) + raise ValueError(f"Layer '{table_layer}' not found in table '{table_name}'.") return True # using sdata.tables[table_name].X if table_layer: # user specified a layer but we have no tables => invalid if len(sdata.tables) == 0: - raise ValueError( - "Trying to use 'table_layer' but no tables are present in the SpatialData object." - ) + raise ValueError("Trying to use 'table_layer' but no tables are present in the SpatialData object.") if len(sdata.tables) == 1: single_table_name = list(sdata.tables.keys())[0] if table_layer in sdata.tables[single_table_name].layers: return True - raise ValueError( - f"Layer '{table_layer}' not found in table '{single_table_name}'." - ) + raise ValueError(f"Layer '{table_layer}' not found in table '{single_table_name}'.") # more than one tables, try to find which one has the given layer found_table = False for tname in sdata.tables: @@ -1950,14 +1753,10 @@ def _ensure_table_and_layer_exist_in_sdata( return True # not using any table - assert _ensure_table_and_layer_exist_in_sdata( - param_dict.get("sdata"), table_name, table_layer - ) + assert _ensure_table_and_layer_exist_in_sdata(param_dict.get("sdata"), table_name, table_layer) if (method := param_dict.get("method")) not in ["matplotlib", "datashader", None]: - raise ValueError( - "If specified, parameter 'method' must be either 'matplotlib' or 'datashader'." - ) + raise ValueError("If specified, parameter 'method' must be either 'matplotlib' or 'datashader'.") valid_ds_reduction_methods = [ "sum", @@ -1971,12 +1770,8 @@ def _ensure_table_and_layer_exist_in_sdata( "max", "min", ] - if (ds_reduction := param_dict.get("ds_reduction")) and ( - ds_reduction not in valid_ds_reduction_methods - ): - raise ValueError( - f"Parameter 'ds_reduction' must be one of the following: {valid_ds_reduction_methods}." - ) + if (ds_reduction := param_dict.get("ds_reduction")) and (ds_reduction not in valid_ds_reduction_methods): + raise ValueError(f"Parameter 'ds_reduction' must be one of the following: {valid_ds_reduction_methods}.") if method == "datashader" and ds_reduction is None: param_dict["ds_reduction"] = "sum" @@ -2036,22 +1831,12 @@ def _validate_label_render_params( element_params[el]["table_name"] = None element_params[el]["color"] = None if (color := param_dict["color"]) is not None: - color, table_name = _validate_col_for_column_table( - sdata, el, color, param_dict["table_name"], labels=True - ) + color, table_name = _validate_col_for_column_table(sdata, el, color, param_dict["table_name"], labels=True) element_params[el]["table_name"] = table_name element_params[el]["color"] = color - element_params[el]["palette"] = ( - param_dict["palette"] - if element_params[el]["table_name"] is not None - else None - ) - element_params[el]["groups"] = ( - param_dict["groups"] - if element_params[el]["table_name"] is not None - else None - ) + element_params[el]["palette"] = param_dict["palette"] if element_params[el]["table_name"] is not None else None + element_params[el]["groups"] = param_dict["groups"] if element_params[el]["table_name"] is not None else None return element_params @@ -2111,12 +1896,8 @@ def _validate_points_render_params( element_params[el]["table_name"] = table_name element_params[el]["col_for_color"] = col_for_color - element_params[el]["palette"] = ( - param_dict["palette"] if param_dict["col_for_color"] is not None else None - ) - element_params[el]["groups"] = ( - param_dict["groups"] if param_dict["col_for_color"] is not None else None - ) + element_params[el]["palette"] = param_dict["palette"] if param_dict["col_for_color"] is not None else None + element_params[el]["groups"] = param_dict["groups"] if param_dict["col_for_color"] is not None else None element_params[el]["ds_reduction"] = param_dict["ds_reduction"] return element_params @@ -2189,12 +1970,8 @@ def _validate_shape_render_params( element_params[el]["table_name"] = table_name element_params[el]["col_for_color"] = col_for_color - element_params[el]["palette"] = ( - param_dict["palette"] if param_dict["col_for_color"] is not None else None - ) - element_params[el]["groups"] = ( - param_dict["groups"] if param_dict["col_for_color"] is not None else None - ) + element_params[el]["palette"] = param_dict["palette"] if param_dict["col_for_color"] is not None else None + element_params[el]["groups"] = param_dict["groups"] if param_dict["col_for_color"] is not None else None element_params[el]["method"] = param_dict["method"] element_params[el]["ds_reduction"] = param_dict["ds_reduction"] @@ -2213,18 +1990,14 @@ 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 or ( - col_for_color not in sdata[table_name].obs.columns - and col_for_color not in sdata[table_name].var_names + col_for_color not in sdata[table_name].obs.columns and col_for_color not in sdata[table_name].var_names ): table_name = None col_for_color = None else: tables = get_element_annotators(sdata, element_name) for table_name in tables.copy(): - if ( - col_for_color not in sdata[table_name].obs.columns - and col_for_color not in sdata[table_name].var_names - ): + if col_for_color not in sdata[table_name].obs.columns and col_for_color not in sdata[table_name].var_names: tables.remove(table_name) if len(tables) == 0: col_for_color = None @@ -2269,15 +2042,10 @@ def _validate_image_render_params( spatial_element = param_dict["sdata"][el] spatial_element_ch = ( - spatial_element.c - if isinstance(spatial_element, DataArray) - else spatial_element["scale0"].c + spatial_element.c if isinstance(spatial_element, DataArray) else spatial_element["scale0"].c ) if (channel := param_dict["channel"]) is not None and ( - ( - isinstance(channel[0], int) - and max([abs(ch) for ch in channel]) <= len(spatial_element_ch) - ) + (isinstance(channel[0], int) and max([abs(ch) for ch in channel]) <= len(spatial_element_ch)) or all(ch in spatial_element_ch for ch in channel) ): element_params[el]["channel"] = channel @@ -2288,26 +2056,18 @@ def _validate_image_render_params( if isinstance(palette := param_dict["palette"], list): if len(palette) == 1: - palette_length = ( - len(channel) if channel is not None else len(spatial_element_ch) - ) + palette_length = len(channel) if channel is not None else len(spatial_element_ch) palette = palette * palette_length - if (channel is not None and len(palette) != len(channel)) and len( - palette - ) != len(spatial_element_ch): + if (channel is not None and len(palette) != len(channel)) and len(palette) != len(spatial_element_ch): palette = None element_params[el]["palette"] = palette element_params[el]["na_color"] = param_dict["na_color"] if (cmap := param_dict["cmap"]) is not None: if len(cmap) == 1: - cmap_length = ( - len(channel) if channel is not None else len(spatial_element_ch) - ) + cmap_length = len(channel) if channel is not None else len(spatial_element_ch) cmap = cmap * cmap_length - if (channel is not None and len(cmap) != len(channel)) or len(cmap) != len( - spatial_element_ch - ): + if (channel is not None and len(cmap) != len(channel)) or len(cmap) != len(spatial_element_ch): cmap = None element_params[el]["cmap"] = cmap element_params[el]["norm"] = param_dict["norm"] @@ -2325,9 +2085,7 @@ def _validate_image_render_params( def _get_wanted_render_elements( sdata: SpatialData, sdata_wanted_elements: list[str], - params: ( - ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams - ), + params: (ImageRenderParams | LabelsRenderParams | PointsRenderParams | ShapesRenderParams), cs: str, element_type: Literal["images", "labels", "points", "shapes"], ) -> tuple[list[str], list[str], bool]: @@ -2340,9 +2098,7 @@ def _get_wanted_render_elements( ]: # Prevents eval security risk wanted_elements: list[str] = [params.element] wanted_elements_on_cs = [ - element - for element in wanted_elements - if cs in set(get_transformation(sdata[element], get_all=True).keys()) + element for element in wanted_elements if cs in set(get_transformation(sdata[element], get_all=True).keys()) ] sdata_wanted_elements.extend(wanted_elements_on_cs) @@ -2402,9 +2158,7 @@ def _ax_show_and_transform( return im -def set_zero_in_cmap_to_transparent( - cmap: Colormap | str, steps: int | None = None -) -> ListedColormap: +def set_zero_in_cmap_to_transparent(cmap: Colormap | str, steps: int | None = None) -> ListedColormap: """ Modify colormap so that 0s are transparent. @@ -2456,9 +2210,7 @@ def _get_extent_and_range_for_datashader_canvas( plot_width = x_ext[1] - x_ext[0] plot_height = y_ext[1] - y_ext[0] plot_width_px = int(round(fig_params.fig.get_size_inches()[0] * fig_params.fig.dpi)) - plot_height_px = int( - round(fig_params.fig.get_size_inches()[1] * fig_params.fig.dpi) - ) + plot_height_px = int(round(fig_params.fig.get_size_inches()[1] * fig_params.fig.dpi)) factor: float factor = np.min([plot_width / plot_width_px, plot_height / plot_height_px]) plot_width = int(np.round(plot_width / factor)) @@ -2473,11 +2225,7 @@ def _create_image_from_datashader_result( ax: Axes, ) -> tuple[MaskedArray[tuple[int, ...], Any], matplotlib.transforms.Transform]: # create SpatialImage from datashader output to get it back to original size - rgba_image_data = ( - ds_result.copy() - if isinstance(ds_result, np.ndarray) - else ds_result.to_numpy().base - ) + rgba_image_data = ds_result.copy() if isinstance(ds_result, np.ndarray) else ds_result.to_numpy().base rgba_image_data = np.transpose(rgba_image_data, (2, 0, 1)) rgba_image = Image2DModel.parse( rgba_image_data, @@ -2494,9 +2242,7 @@ def _create_image_from_datashader_result( def _datashader_aggregate_with_function( - reduction: ( - Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None - ), + reduction: (Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None), cvs: Canvas, spatial_element: GeoDataFrame | dask.dataframe.core.DataFrame, col_for_color: str | None, @@ -2545,14 +2291,10 @@ def _datashader_aggregate_with_function( try: element_function = element_function_map[element_type] except KeyError as e: - raise ValueError( - f"Element type '{element_type}' is not supported. Use 'points' or 'shapes'." - ) from e + raise ValueError(f"Element type '{element_type}' is not supported. Use 'points' or 'shapes'.") from e if element_type == "points": - points_aggregate = element_function( - spatial_element, "x", "y", agg=reduction_function - ) + points_aggregate = element_function(spatial_element, "x", "y", agg=reduction_function) if reduction == "any": # replace False/True by nan/1 points_aggregate = points_aggregate.astype(int) @@ -2560,15 +2302,11 @@ def _datashader_aggregate_with_function( return points_aggregate # is shapes - return element_function( - spatial_element, geometry="geometry", agg=reduction_function - ) + return element_function(spatial_element, geometry="geometry", agg=reduction_function) def _datshader_get_how_kw_for_spread( - reduction: ( - Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None - ), + reduction: (Literal["sum", "mean", "any", "count", "std", "var", "max", "min"] | None), ) -> str: # Get the best input for the how argument of ds.tf.spread(), needed for numerical values reduction = reduction or "sum" diff --git a/tests/pl/test_render_shapes.py b/tests/pl/test_render_shapes.py index affeebd0..953eb843 100644 --- a/tests/pl/test_render_shapes.py +++ b/tests/pl/test_render_shapes.py @@ -315,11 +315,54 @@ def test_plot_datashader_can_color_by_category(self, sdata_blobs: SpatialData): adata.obs["category"] = RNG.choice(["a", "b", "c"], size=adata.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") + table = TableModel.parse( + adata=adata, + region_key="region", + instance_key="instance_id", + region="blobs_polygons", + ) sdata_blobs["table"] = table sdata_blobs.pl.render_shapes(element="blobs_polygons", color="category", method="datashader").pl.show() + def test_plot_datashader_can_color_by_category_with_cmap(self, sdata_blobs: SpatialData): + RNG = np.random.default_rng(seed=42) + n_obs = len(sdata_blobs["blobs_polygons"]) + adata = AnnData(RNG.normal(size=(n_obs, 10))) + adata.obs = pd.DataFrame(RNG.normal(size=(n_obs, 3)), columns=["a", "b", "c"]) + adata.obs["category"] = RNG.choice(["a", "b", "c"], size=adata.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", + ) + sdata_blobs["table"] = table + + sdata_blobs.pl.render_shapes( + element="blobs_polygons", color="category", method="datashader", cmap="cool" + ).pl.show() + + def test_plot_can_color_by_category_with_cmap(self, sdata_blobs: SpatialData): + RNG = np.random.default_rng(seed=42) + n_obs = len(sdata_blobs["blobs_polygons"]) + adata = AnnData(RNG.normal(size=(n_obs, 10))) + adata.obs = pd.DataFrame(RNG.normal(size=(n_obs, 3)), columns=["a", "b", "c"]) + adata.obs["category"] = RNG.choice(["a", "b", "c"], size=adata.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", + ) + sdata_blobs["table"] = table + + sdata_blobs.pl.render_shapes(element="blobs_polygons", color="category", cmap="cool").pl.show() + def test_plot_datashader_can_color_by_value(self, sdata_blobs: SpatialData): sdata_blobs["table"].obs["region"] = "blobs_polygons" sdata_blobs["table"].uns["spatialdata_attrs"]["region"] = "blobs_polygons" From faddcd7b2f999539c7bd74fd6863d0d58e0b910f Mon Sep 17 00:00:00 2001 From: Tim Treis Date: Thu, 27 Mar 2025 14:22:42 +0100 Subject: [PATCH 4/4] fixed hex color bug, added images from runner --- src/spatialdata_plot/pl/render.py | 107 ++++++++++++++---- src/spatialdata_plot/pl/utils.py | 41 ++++++- ...Shapes_can_color_by_category_with_cmap.png | Bin 0 -> 15926 bytes ...shader_can_color_by_category_with_cmap.png | Bin 0 -> 16159 bytes 4 files changed, 126 insertions(+), 22 deletions(-) create mode 100644 tests/_images/Shapes_can_color_by_category_with_cmap.png create mode 100644 tests/_images/Shapes_datashader_can_color_by_category_with_cmap.png diff --git a/src/spatialdata_plot/pl/render.py b/src/spatialdata_plot/pl/render.py index f5c334e4..49cbaed1 100644 --- a/src/spatialdata_plot/pl/render.py +++ b/src/spatialdata_plot/pl/render.py @@ -45,6 +45,7 @@ _get_extent_and_range_for_datashader_canvas, _get_linear_colormap, _get_transformation_matrix_for_datashader, + _hex_no_alpha, _is_coercable_to_float, _map_color_seg, _maybe_set_colors, @@ -191,7 +192,10 @@ def _render_shapes( lambda x: (np.hstack([x, np.ones((x.shape[0], 1))]) @ tm)[:, :2] ) transformed_element = ShapesModel.parse( - gpd.GeoDataFrame(data=sdata_filt.shapes[element].drop("geometry", axis=1), geometry=transformed_element) + gpd.GeoDataFrame( + data=sdata_filt.shapes[element].drop("geometry", axis=1), + geometry=transformed_element, + ) ) plot_width, plot_height, x_ext, y_ext, factor = _get_extent_and_range_for_datashader_canvas( @@ -208,7 +212,11 @@ def _render_shapes( aggregate_with_reduction = 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())) + 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( @@ -216,7 +224,11 @@ def _render_shapes( "to the matplotlib result." ) agg = _datashader_aggregate_with_function( - render_params.ds_reduction, cvs, transformed_element, col_for_color, "shapes" + render_params.ds_reduction, + cvs, + transformed_element, + col_for_color, + "shapes", ) # save min and max values for drawing the colorbar aggregate_with_reduction = (agg.min(), agg.max()) @@ -246,7 +258,7 @@ def _render_shapes( agg = agg.where((agg != norm.vmin) | (np.isnan(agg)), other=0.5) color_key = ( - [x[:-2] for x in color_vector.categories.values] + [_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 @@ -257,7 +269,7 @@ def _render_shapes( if color_vector is not None: ds_cmap = color_vector[0] if isinstance(ds_cmap, str) and ds_cmap[0] == "#": - ds_cmap = ds_cmap[:-2] + ds_cmap = _hex_no_alpha(ds_cmap) ds_result = _datashader_map_aggregate_to_color( agg, @@ -272,7 +284,10 @@ def _render_shapes( # else: all elements would get alpha=0 and the color bar would have a weird range if aggregate_with_reduction[0] == aggregate_with_reduction[1]: ds_cmap = matplotlib.colors.to_hex(render_params.cmap_params.cmap(0.0), keep_alpha=False) - aggregate_with_reduction = (aggregate_with_reduction[0], aggregate_with_reduction[0] + 1) + aggregate_with_reduction = ( + aggregate_with_reduction[0], + aggregate_with_reduction[0] + 1, + ) ds_result = _datashader_map_aggregate_to_color( agg, @@ -468,7 +483,9 @@ def _render_points( # we construct an anndata to hack the plotting functions if table_name is None: adata = AnnData( - X=points[["x", "y"]].values, obs=points[coords].reset_index(), dtype=points[["x", "y"]].values.dtype + X=points[["x", "y"]].values, + obs=points[coords].reset_index(), + dtype=points[["x", "y"]].values.dtype, ) else: adata_obs = sdata_filt[table_name].obs @@ -496,7 +513,9 @@ def _render_points( sdata_filt.points[element] = PointsModel.parse(points, coordinates={"x": "x", "y": "y"}) # restore transformation in coordinate system of interest set_transformation( - element=sdata_filt.points[element], transformation=transformation_in_cs, to_coordinate_system=coordinate_system + element=sdata_filt.points[element], + transformation=transformation_in_cs, + to_coordinate_system=coordinate_system, ) if col_for_color is not None: @@ -586,7 +605,11 @@ def _render_points( "to the matplotlib result." ) agg = _datashader_aggregate_with_function( - render_params.ds_reduction, cvs, transformed_element, col_for_color, "points" + render_params.ds_reduction, + cvs, + transformed_element, + col_for_color, + "points", ) # save min and max values for drawing the colorbar aggregate_with_reduction = (agg.min(), agg.max()) @@ -642,7 +665,10 @@ def _render_points( # else: all elements would get alpha=0 and the color bar would have a weird range if aggregate_with_reduction[0] == aggregate_with_reduction[1] and (ds_span is None or ds_span != [0, 1]): ds_cmap = matplotlib.colors.to_hex(render_params.cmap_params.cmap(0.0), keep_alpha=False) - aggregate_with_reduction = (aggregate_with_reduction[0], aggregate_with_reduction[0] + 1) + aggregate_with_reduction = ( + aggregate_with_reduction[0], + aggregate_with_reduction[0] + 1, + ) ds_result = _datashader_map_aggregate_to_color( agg, @@ -805,7 +831,12 @@ def _render_images( # norm needs to be passed directly to ax.imshow(). If we normalize before, that method would always clip. _ax_show_and_transform( - layer, trans_data, ax, cmap=cmap, zorder=render_params.zorder, norm=render_params.cmap_params.norm + layer, + trans_data, + ax, + cmap=cmap, + zorder=render_params.zorder, + norm=render_params.cmap_params.norm, ) if legend_params.colorbar: @@ -832,7 +863,11 @@ def _render_images( else: # -> use given cmap for each channel channel_cmaps = [render_params.cmap_params.cmap] * n_channels stacked = ( - np.stack([channel_cmaps[ind](layers[ch]) for ind, ch in enumerate(channels)], 0).sum(0) / n_channels + np.stack( + [channel_cmaps[ind](layers[ch]) for ind, ch in enumerate(channels)], + 0, + ).sum(0) + / n_channels ) stacked = stacked[:, :, :3] logger.warning( @@ -844,7 +879,13 @@ def _render_images( "Consider using 'palette' instead." ) - _ax_show_and_transform(stacked, trans_data, ax, render_params.alpha, zorder=render_params.zorder) + _ax_show_and_transform( + stacked, + trans_data, + ax, + render_params.alpha, + zorder=render_params.zorder, + ) # 2B) Image has n channels, no palette/cmap info -> sample n categorical colors elif palette is None and not got_multiple_cmaps: @@ -858,7 +899,13 @@ def _render_images( colored = np.stack([channel_cmaps[ind](layers[ch]) for ind, ch in enumerate(channels)], 0).sum(0) colored = colored[:, :, :3] - _ax_show_and_transform(colored, trans_data, ax, render_params.alpha, zorder=render_params.zorder) + _ax_show_and_transform( + colored, + trans_data, + ax, + render_params.alpha, + zorder=render_params.zorder, + ) # 2C) Image has n channels and palette info elif palette is not None and not got_multiple_cmaps: @@ -869,16 +916,32 @@ def _render_images( colored = np.stack([channel_cmaps[i](layers[c]) for i, c in enumerate(channels)], 0).sum(0) colored = colored[:, :, :3] - _ax_show_and_transform(colored, trans_data, ax, render_params.alpha, zorder=render_params.zorder) + _ax_show_and_transform( + colored, + trans_data, + ax, + render_params.alpha, + zorder=render_params.zorder, + ) elif palette is None and got_multiple_cmaps: channel_cmaps = [cp.cmap for cp in render_params.cmap_params] # type: ignore[union-attr] colored = ( - np.stack([channel_cmaps[ind](layers[ch]) for ind, ch in enumerate(channels)], 0).sum(0) / n_channels + np.stack( + [channel_cmaps[ind](layers[ch]) for ind, ch in enumerate(channels)], + 0, + ).sum(0) + / n_channels ) colored = colored[:, :, :3] - _ax_show_and_transform(colored, trans_data, ax, render_params.alpha, zorder=render_params.zorder) + _ax_show_and_transform( + colored, + trans_data, + ax, + render_params.alpha, + zorder=render_params.zorder, + ) elif palette is not None and got_multiple_cmaps: raise ValueError("If 'palette' is provided, 'cmap' must be None.") @@ -999,7 +1062,9 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) # outline-only case elif render_params.fill_alpha == 0.0 and render_params.outline_alpha > 0.0: cax = _draw_labels( - seg_erosionpx=render_params.contour_px, seg_boundaries=True, alpha=render_params.outline_alpha + seg_erosionpx=render_params.contour_px, + seg_boundaries=True, + alpha=render_params.outline_alpha, ) alpha_to_decorate_ax = render_params.outline_alpha @@ -1010,7 +1075,9 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) # ... then overlay the contour cax_contour = _draw_labels( - seg_erosionpx=render_params.contour_px, seg_boundaries=True, alpha=render_params.outline_alpha + seg_erosionpx=render_params.contour_px, + seg_boundaries=True, + alpha=render_params.outline_alpha, ) # pass the less-transparent _cax for the legend @@ -1035,7 +1102,7 @@ def _draw_labels(seg_erosionpx: int | None, seg_boundaries: bool, alpha: float) legend_fontweight=legend_params.legend_fontweight, legend_loc=legend_params.legend_loc, legend_fontoutline=legend_params.legend_fontoutline, - na_in_legend=legend_params.na_in_legend if groups is None else len(groups) == len(set(color_vector)), + na_in_legend=(legend_params.na_in_legend if groups is None else len(groups) == len(set(color_vector))), colorbar=legend_params.colorbar, scalebar_dx=scalebar_params.scalebar_dx, scalebar_units=scalebar_params.scalebar_units, diff --git a/src/spatialdata_plot/pl/utils.py b/src/spatialdata_plot/pl/utils.py index 33eab531..a2e8f767 100644 --- a/src/spatialdata_plot/pl/utils.py +++ b/src/spatialdata_plot/pl/utils.py @@ -921,9 +921,8 @@ def _get_default_categorial_color_mapping( cmap_params: CmapParams | None = None, ) -> Mapping[str, str]: len_cat = len(color_source_vector.categories.unique()) - # Try to use provided colormap first - if cmap_params is not None and cmap_params.cmap is not None: + if cmap_params is not None and cmap_params.cmap is not None and not cmap_params.cmap_is_default: # Generate evenly spaced indices for the colormap color_idx = np.linspace(0, 1, len_cat) if isinstance(cmap_params.cmap, ListedColormap): @@ -2441,3 +2440,41 @@ def _datashader_map_aggregate_to_color( span=span, how="linear", ) + + +def _hex_no_alpha(hex: str) -> str: + """ + Return a hex color string without an alpha component. + + Parameters + ---------- + hex : str + The input hex color string. Must be in one of the following formats: + - "#RRGGBB": a hex color without an alpha channel. + - "#RRGGBBAA": a hex color with an alpha channel that will be removed. + + Returns + ------- + str + The hex color string in "#RRGGBB" format. + """ + if not isinstance(hex, str): + raise TypeError("Input must be a string") + if not hex.startswith("#"): + raise ValueError("Invalid hex color: must start with '#'") + + hex_digits = hex[1:] + length = len(hex_digits) + + if length == 6: + if not all(c in "0123456789abcdefABCDEF" for c in hex_digits): + raise ValueError("Invalid hex color: contains non-hex characters") + return hex # Already in #RRGGBB format. + + if length == 8: + if not all(c in "0123456789abcdefABCDEF" for c in hex_digits): + raise ValueError("Invalid hex color: contains non-hex characters") + # Return only the first 6 characters, stripping the alpha. + return "#" + hex_digits[:6] + + raise ValueError("Invalid hex color length: must be either '#RRGGBB' or '#RRGGBBAA'") diff --git a/tests/_images/Shapes_can_color_by_category_with_cmap.png b/tests/_images/Shapes_can_color_by_category_with_cmap.png new file mode 100644 index 0000000000000000000000000000000000000000..e7d300f4dae4f859f7b01ae06133c13097b9c8ac GIT binary patch literal 15926 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%0DmA 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 G{C@$2+}xM| literal 0 HcmV?d00001 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 new file mode 100644 index 0000000000000000000000000000000000000000..dcef7d21826152ab494e3f7cb57e26ea61600dd4 GIT binary patch literal 16159 zcma*O1z45Y8aBM?mJVqIL>iHl?odENy1To(Bt*IeN$EzqLqI@6q`OnP`(Jxz&N=^g zzMSh~7#Q}u-@V?op69M7{GGfM1}ZTs1Oma3krr2iKwx6P7ZWlf_|1O(Mi=<@+F3%~ zS=r9i+0DSw1R`hPY;SGnY;9pk>1yKWWMOCfisdC6%S$Fob7yCJr`N2kHvj$$7CT2X zRwuiM4R93{dua_P2n1ao`UT_X_K6w-5sZ)#7g2Ff-(T?3R&jZ_I-atB`VO0@B_Kz; z29JZ|t(@Ge1r7{3H%8^bU90x$@wQ&I<`h#~A1$@GaZ4*3w?VF?8V=UP`WBVVRy$c1 z>kWh!y+(HteImhjp(p53SzTsoQAj3Ju>5uH=P8zgOp^cg0V!$l200BGpUpvtLp~=!o(e{5vtHDA{x} zl%mK~f1aU;gV*ZN1cZ669r``;RaI4Sn2-AFwt7~XjgZ5_!Filds8x9UZ1cG#;&*2g z5fOn$KzQkXq<^tLr)D$L=(IJ~R_!|1+;szLIQ_6orpUt=-aM(_`aShH^ka!0vi~{G?4^G=&-r3=9J=?@SXj1A{E5 z_4KG!2 z&952@<-M)%-^C3L4W}EO3S8!fzwm*3^Dhf$P5w0X`kgO#eB5{;bllRy-__k+VvUK3Nyo}MVybnzIaHEh^(zMf+#;l= zPt9$~hdY@?$FMUH!Ta(4^3QB-1b8!L6&2$V6O@&UuNdO)Ev7@CSGKnLQeT-_*JTQL zB~@2fTalZwr}DV$swD2yDP(*%W}mU<^SN&` zSVluD?nB9mwQ}t3r9O;Z+x2s>U{l_o&cR;6#=`382!PXQa;DGpd*FpbdP2j-7SkV3 zH) z;;qyu;^Sxt2nat@UIu}~iHn2NH6rx*^YP*E5qfT2_h&zJ>W}h?Q|*%Ne4yP$@bmMl z{`Spdx2WL9&=4&X)2GfLr1-eFXZ>Qxbg{%?Sfa?y($r0VeS^goMK&P(yNaw%L~esa z@_tj{1m5)9jOlkL9gP9A+ZPZPy>mK=N(ADk%deGXJrJapoE&^IoZCmzdBq$Ysum5l zyGn4SznKp4c%Bt58jNd7N+P15ptuM|E5$ew7?#J2jleAocQrkqHKSnNA6+l>VzKOv zKX5ZP{<=uD-8=YLerEglGh{B%PWxbv&0M3j_2Dq(cQ=lxJ90&NfO~F^p?j70K?hm3 zJGCX6-1hH}02du@xJkA)fCh6GJlB4-@B*a|S(tS~6seEoM z;FILff9AB3e)|@>LsC+R7CraMEJu<*f0ljw79Erb=l$74m%YixRd#;WMUSfo8m6$| z>oSApo!&le8ws@i>Gm4`eJPQoCaf;6Mvj1%>&-NHcz8CmVbZpnT}H`h@*z;_JXXW$ zN=;n!0sV@Sl$>zy0z$5gdcLP3@whqL!mBG*HJgCQ8(P7b(+)JRutZ zdt!Uw%?YxEgnc8j>w0I$i+4{7In&Ynb<0-f^O`bC+8CP9O{R3?bdX232F{%nG6i)^ z=Bz4O^C5dp<3UK6syfZC?@dfhCfPxqRx&U!DE|6YoIX!QQ85-31LOUOz-4;vryfJa zFMMvQGBPrpQ3dZyR4;TtmR}q!XsuNfL?{ihGH#eD4ivnlPcYJx8hO2)Gp}uP(5NJv zIwXr9%HYEPTLxyrskU{=^JIL9I%;{0SlUI$wApnZwc~iLkDi6av^xaj6DjYQ=|j#- zqu!Xbv_Wu4V5aMANUpE1KM`{dX!iE?g(~JsR{7q0%F4>7d(&pDh_j&o{GgpX8KtCJDt30nfqqXIqhc#*slt)4pZsMXm$p?|!OFoxmlDV;UHgk^49WdY` zVG$AE=y541FojiB3EUPv=u%TtArncAEG(PLok7mqqn(_D%OPz80rTuUyKp2qq)ze_ z2xyL!??lom#JO6JU!E_l$muuc5W0U?!%}zopiQ;Qtb;3UYWnbwEJdvKBo}-f!jLjDd@Lz#}fz|`>OO13ChvyO!J9oU@dGRh@7n)+ZpgfD{ zAas8Q3u`%QRnpevPA-eeD8!utH#DjJNOX$QFpU~$IjWG{E&`*y6>4+BDEtg zfd1yQxeaL_@pxDMq;B~w^&Hb|`xmolw*Gu8L^9M%c zCPj0*6EiXLrW5%gfyrX75b;NaFQRx|`wbic>P!UWp4(KQM)veZl2|OYwGKV_`$KLn z4@cr?-=wgce5|EY_4IuGJU%=_zzcS?M2%HcR8+miy^1s7MIQM6653TIbAKf!Z#X8g zU+oa7<9)3QMsq2o;$RJ2F{{0liKMJ+G%_65l0h*xvpUqO@sHW7K_Od0@_h_cjY!9h zupqqBg+Q@cZyt(qS1<~bUR(NYVu*9laU^82#F$N65yx2h{u-ODX+}7`sl=c*2dtgO(QSf zdGvmI3K~nd=~BL28Wg$+cwHE83?#ly1N^O!#$%}}|aI};I;+`HRRIsa|d^SGNoeYtGk?uzC|CN41xV;%aI zH%|I=hPpeZy0;7;)^QuWHM}bD5|S)29wCuIE1gK$in$~TSKDiF+3}RkjN+J1@}-g0 zE6z9^cgfB>_~)Sqa40?JHbStOaC$Qu%14T9bV#uN!-81KeNuX!FPJ~*G}q8L=_2G&7rQ|U*0 zVXNP7ilmUK2huV$supz;2v1SoB*C)G$q566RO(QrEm>B%7jo7F|LSU%LguWWnYk14 zXXNWQ*$yH1t>xtfM;t^XqWW2DBh0}i&};H;E$0)|^7YzJuudiXW@DcEx>5ztZ2gBv(5<4XtFh-^Cm?OugS~OgiHEp}W!|Wg^-TM}x(E8ZCBY$sCW= z;Us0SiNEIsU)SQQ;dVCS*R^xnsMk_u_L>o{sEPtk7)gURyj_w) z*QFdAY0(O+NIU!0Y9-eZD+44j$kzK)^K>go6h+0I_p11i6{*^;Bct;l_q7bw+V?l? zq_#*ln>-gSqT_UN!p=#4Fc%x$Ke;$F1HY>y8bt?X28zTkA8tP4N0Zl*<-%0jQLG!! z`N<=QONbQ6kJj1fr!d|9P&FK*V|MQ?o4uG z9g{t#!m!uza%Xdinx`)%Jl0ukt zZm^lW;LaL8(_oP$XlA+4YahWmrF|y*eSu@lIl5@}TAwoe6|>$G4UNRi3i5e1H3NgA zv){{Gfqt!>&nwg6YIk}~J{)ny32XnE=1s8# zaa*{&y;gG_YCV;=G1Y0U*?}u~wtQ!5p8nqDkDN{L1G30v5Um~{o7!>0>3{BM&2dz| zk*jJAW?3LFXKk318SJTE9W)P&xM-IYPZ}B-jK3iC+i|HeN6Pc+z(MtOb><_;5sZaj zrBTN0{)|=jWzDnSXxG4fR^pAoxzIkDNGj9 z4>?t%cZ09fx7f8C@EzOz3AlM#1gW$E^9|A%SYGHpGQqtX;lv5_d-|0E(Qi2)!nVW1 zXC(r2cO90-eu?(e#vJ|{P&ix@P?w|D2vi{cwS9$U+7wL>wb0oulO2<=5(Mu%zohLbqOThBaS&F3= zLef+iMOL2I>{RJ*aKgU$@*~Hy12pJQs(-W_#&Mg9UXNgF| zEBb9EmFw%_cjgAgj)h|M2v48RQge*r^ajA)oPRDZTgGCxfXqAN28Zkt%YXT>gdQr@ zS*2utemP4S3;&+i$ax_AU3i^GC1)Kvx z1Okh0Fl&2FnTfmX2{LkU3Q@zMU@NslDVJMUk*d7~E(gELDOc>#$a0tNGGRdoU9gx8 zY*F@-yjb7IJd;5wNw)@wkQ(}p(=q>hI{A*Jhy2#dgs%>HUukR@E^Z1X_+H%Sc~`E` zw&r4YdC&xmcPwFcrpYlP!TY)q%_0R=47WE5ymZj_r_1oqmpgy*{d$+}cQ_Wg2+j{q zJc7s}AOrZ%$D$DN()>6S8!&da%)>-ed-KuoJ06Fva)CQnyeo(QRhUQNEL0T?wBZ zQr)T@(FZJN+IfF^iJ3zu+9@qV+4V{qwo$Z)Cmh3Uxsz))t|}8r+9tOgT`(qPJJpDP zm*Ga@MP$scyJtpvgQ-JUI`GUtV=%PYtSYd$b-wq<^Is61 z?c}=Q-Gtwf;gObKf4;=v>sYDyLK2S^1-Sh3wnmCF{NN~YK6M}!rSM@Pzm~C8L$3E* zvD3|R9g)$6s~gd^=l0fwDdhf=q2+puDM$lii2kUDn>~+OK{K#sXf;v@*(NbWJ5Nus zHJE1oMcgKuBI_LWZ!bKhRt4|wsHz!_75w9R^k?$`q#ysHMi=7^ch?L#u!i%q&T-@5 zG~?>tlmnYR5tf+4f3cO;L;KCyqp&L$2M>BcLAml#TpjV(<`HS8mNcZh)_wjV@0xWg z(r$f!nRptHdFeCdn8$_lL2&_C1S4(SXsnBqUHE6DbfqZ#_ceDt+RAb{D}M}LwDtyS zAm*L-;&gqHfyH(qz%P^QS!`)EtD$Oy^P>)~4Wve+dYs%HSuF{n#Aw z8pR5hjIYb4eupf4>-abz-S!qR{3rm=VB$zl!xX+NF=B$ zPqa1RJb-8w9u;LeoX%Tg{wuIdtDYT5IPC1~04T=RrvEZupR;SP<>lh%pWi%}7zDp+Q4K zbF>ml0X`g5MC9?kc$zHOWvM!J8da{-3~PUPbxfRD(9n>ALBhr6xGs&Z_LGO( z^d}KuXfr?)SqO4b*7HHg%VT*^3gQwzx@=EgUqMx56?u3JiepR?XK60Qpv3R`vb~75 z*L>L4ZO$=PXQQ=O7Z-=4Sz{iMnMnaOFQbb;GwS8rgYN%foV;q@Et83~jEsYvonGMt z%rzHJp02IDR#Xr@xEL5=_Ub+s+98a_n)p>qsQN!pNurL=Y__fEssjT9>HxK{w%@TM zVUimI1q!mhu>k{gR!gTgz);rq_TJdqmcOaU72NYAIoqaMHj-g-b+j@WthfS}wvL|Le3TNfmaE^H58oqL+oY|nOBeo|O1fq$VAd}nO6I1* zv^)9EOXf~Cc&!FsoR|WEWaCj)>D;b_emkO<{gK2iT#~8VHWL%4nU~s z>FJ@G6o;jA|+Z$=Vz?cR~1 zG)D~f6q&MUJ*Y1v`(M$=(&H+8j7^)lx*l_mI6xYel;T|cz=8`W)?q8Y!WSreI6`Wr z#>V&d&Ytn)dElw>L}DpJ^!?4pK*ho0Q*BeF?!KKJI$di;sONXjxo^c|V+BYcVo<=` zz}6r#GIEyR;{%gMHGE!z9B+KT_(+Dp7aTtyZf~A>uZl&*u-)K$1jn{fK2$2}#u`L+ zgD&_aR=r`M%7pY78r)tC*gii#K88m^icd-T0d(`7(QG&%90TFyxD$zj&G*hdCpWij zL(gLaAF7HsIII!zxiay&9jpUok(QPgA@|qzz@TGz&NKCIh%7peUHurEt6|}>8L9^i zl`z2vI3mN?2DYfFWj;$(yZ#hQ%v)MIqooctfJ9K5WXD^x85uQccUXFiJ97kFp2cjpm zKT$@a{c_|h)_IhYX7}}KkNr_c?ch`Yk))1?dls}Is~^;*o?jKIQ}{KsK1q9~huyGn z6E1owXsyotQoalO91E|c@V#zAGHI{|ql@Iv9MW8UkVIT)IlwP)`?i5mWZ7~gV1IXA zS644DJ%$)bokxjF!yfCS+MIXjw${XMh>n}dT41a!`JxrbPd;4bJbS?u9D+eckyalE zzuM{Ttv^PS8lvMK8i$j@tw5qyM1Y*JHfG9(0d$8J54EOc!ZaIpt1}0F{9qyY(T9SC2zZW0`RNmfH z&qTd|oAc%M4oNgtx#Iz8y1-tF6Fz?DuiVk%#K{Gn@yJgENAQR?yXSNnqq-;4vhHXR z2wXOSc9VRY-hEMBITEwEY>IA5aK{i76#K(gPbsS4z1DlKbRY}jt!hH=@!`If3vY-4O{WcD(@tfYJ7fV^0{h95fmG;V{_q&_1o;hecJr#?)!b6e`QVp@W>wF_ z*HJWBRKex)nIW0J3>16WXHk;lA^cr9qT9RkywhzM$#l`*%(AmOYL_&Yq=CA=ElIzh z7n;bZKgZ?M*4!2&r7nuRP^w*Y-h`7Og9Eg9LOpMo&?h2A8i&%sVA+wWw2KRoFAk?v z2{@9s$s!BUEa0$38$QjTn+__37~IJp_K~STVNOi;cecbkBq}+HUS7=KwXoOmwyg?r2l7i zJQ%C?ohIs%PXTT88^ZG_NuIbjxYD+^EYN}jbsepy$~SD``dHMM#U1+S(TBur~D+jD5qSwwZgdVPP8ChkJ9pLiv zW^>*heCB5&u;FNVTdwYfn0Hpz;RDnV{_FMl_4ReA%pZdOvN}YB?AOVvKYmbB zQHdO~wS1g+y47hzciMbcBBciBo7@>$pc0}#-@e#vedqlM~|A3uJaZuQ~@28!X27^)hZ zxg_9by;D*W)z(gzyS}<|hX9if^5f@E_`*7E2ZxscH#m2`N2V$_>w7ZYg#VFc5WQhS zv4dba_au|EzX}*z1$yni!5=gavAatRfXSP2r>_6?6^-n7luu3E%(eUrN2h-yJt*DoY=2QEFt*?1DIF> ze0ccqeFlZUbI1OF?LAtA` z8c}b1)C6idFxp=DT@UI_xB2jSUHqYX_Dr-sO00=4hQjv>CJztT|G=OcZTIs_V$$eJ zY>oK+`}fP#w`YH5a7Qg!sHmuzbX%r1CFy%WD{s6#Xnsi?$4bW)xeupfcD_5(PrUvSMa8qTmMvqXZ3*z_S}e6BP= zr_S`f2~SAC!@Ec5ltTw*e#0h9dG2Q)$)eEc48qwWvaTNa~P0jGhjFhFEJSZF>4R*wDlC=~=iE|1MrGiiDG zy62%l>#ug)kOh*rq^2e@I19jjNrSq_=~e>?^pKX*VZL-8hd}UQRm-(yE)N!TJY+<} zJdM{7Z6f2UjJ{lwA?h=0irrAv^r_OC_Zu-!`QB}Z!2beAoj5)wxT&9iz3QPyIkx!A3uZ>{%n!dly<@HB(2 zeZf^*k6Y@fQzO-o4U&!eYB~c6S=<*{1>N+8_iS9s$kj50FD0F+g12}*l+yCox6ozC z7vEY^iiT;vOsk!7@w=Beluj3;-zUDWn3z&km%$K6Rx~m(VY4H@yD3?Wo&0s1OpUbiQzrK9;~ZUyfV$mIKHDLH9De{m3#ith4zfRlOxwuC($v>!T%wLhQ= zS^wfgol-@d$~>$4=m@^Cfing zAk{5q@UNC-Ky3!V0H^rS8H+&2E8k1fs>$3M?u?~QoI^uf`SaAr5a&PybjKXY&l~}L zKzdG3Eh>A2nZ1g+nXaubee2a|m?x@0Q!fEl=Ly+z4`Hp^H&aikHBt+U<$_kHc>0JlaQ=MKQ*`78&X=@*^zl|>D%>Q&RCGcI8JWA z`_Rz96M{j~TdMKRed~*xv&N(=y!{xlCpGkyC5m(s$p3Vx zd6cXQb4$;6XZtR9}%8yP_Lg4`i_Y(T!sZe5O`#k}d z-54vmTTGhP_4KZ#py2Go9xav|G&r=vzR)aIAU}JHDd@lY%xA4A{1ZBl4>7sd&zD?0B+QBHoo8qwz< zCL=V)0J4nec8=HOz$Gp1(VX2Z>o^+WSwufRI$~M3kHd#eUEGPDv#W z{#mfS6*l(lp!MW)*HUwL7M75u-Q(Fm8Ny(9cTr#6b|3obUGs9QX;~5uNI4De2C&dU zpGj<2m;SEG{{6{DOqJS{eG9O5sDk^7&E3h_AfA`y^KH)b>4F=}hp+_9W4#s5f3~Nc zycnR5BASr2w3T7rw|UZN_rzqnYQbSC-b>FPt4}Yk&o?$;|SbOrB%SKjM7y{ZB4IN#4vf51X`Tt!GgI42dHX>dn zuLVCL4r^qXfaR^judFKn;2>#LS$YB{bzyC7QqX3CLqktfatRo!g5FqBHa0e%?M+1k z%?mWCzmhcI^i4Orv66792!?SGM|uOSz||KL8an5__&0<#I@|vB^9x@x=y7bInB3jn zrEplpy(yPMRusCv1vY4d6J4p!+A`}87(|#*2=zPI?+~kiy;4aYkYj@0hO8{={q1Ez zR+fJx3HNKB2F-V`wHoZRVO8Yiqd;s89D-the}58kZeWN`)LN;%S5-A#;IbH_!6zWd z2j0Eq*+Q9SZ8fX;m;HriwL`3ysClO;$AVj*;~b^FsP}Dx_bNr)i|=;tFKh2>9|V;0 z6D%tdPLyRGdIlBfC$hc$IDw9n1(JXhch{%;%~*7!KCdq;PY9Ttdd7@7-)U?A1XYNL z*O4ZK@n$UY>8p_WdF?veh20|_5Uwdwq&AemB1}-H&?+!y_YWZ@ob6ut^GF`Wwq0obL2kb9ouhXS_XV zw3^tvT_7pzyo1z5SN)AykWo>efrtg*Ew~`V<_`nc0H7!dm$g0s9>Y;ho}S!Y)=OV*On3vT&lf~P#ki?#GG=sE5}A(vNBUCa( zG2$QpCk&Z2Aq`@HZ$N4g0QFMM+K-rmSN_XI~=CV^zO<&LAlBO4-kA9Yv0LFQwQlnkbD%UmjIE9 zRBk(R+~;pAdDZ^6QW=1P$Sbwzy8~vzy3g6!;{YPqC6g>OF*c3?4Zw{^R{}hEkFom; zXk04G3&;+l$RjkMtpAnK1^=#$fYwDU*I3XVu)0{FHTJK_@Bb9Z`**PSpBJrM$jWAE zk(2Tj0O2f*5foor1^Q_c*gO+Ysb|5^0Kh+&0*BvXb^kZXpM7L$ZpYz;`QZ^NF6S71x36$2B zcyK4b7Hu&e&nEu)5&B_&aRwwLCB?0r|5gyHovHV_w7ogofm$pUGu5=e3lu=I zw`PD>@Om8=nzcbkrx6s)0NEC`53Hch$W6hs`#pFA=S11W%hi<&4iTLaM86Ul)c|s@ zV6$7~*L7b(NOJ_3Z!K1eoQj@49H<#ihpm?`Ez@-SMd}OFOX}Tv;iBl*P82_>%OLNNggPy&}eF!QQxPh-5?46 zqrczb00As>^6BrjH&dk=-GG5zZ6xcxU}347QBzk3Oz8O7d%dzNQc%9$rjr z?8*tx?!aHgwAQ4q!(_QcljZ}iiT!3FuHkW<*8$7AuNNYqdFkn-0E3GGCI{lru*-KI zHm@fDd^oHqHX{S?D`t8B!M@L zb(orb6Kdd^HcRGo#POZO&6{b_ab)4cyxqw1n3Os+z6^{fkPoJa?+@-b&H*MK1WE^`|H9nP9P(acL z39M4xR$_lDv5-o`9#o(QmG_DmZF)B*3s92%A8I1#Fp8Hw-wiFlaj zFtZhOvSB{+-}PxXPgznu6@%6k$lf^*09fzlbw}#E|Pnyt~v$Au0oQ$haSrOM`as6A6D2t8^MNu9D*m_s z`q4J86dg4p>*u@XHFViMj}1mY9F!Gy&@r@lbs%M;A`>SKl{h>^$ZhOF6<93PeibP7 z%BG`}Cxt~w3LWieol{dDFHxw9!FuuQNW&pr#%z zORwhd9})l6m895TsHef9WJ8x% zDIO|r|22)`p71o(z{4iA2#qRTbP8TcQ&ScwBEVi#5V{|4x(C7eE`2r?4}b{Y^Y#Ip z2*3~~Fmk_|3?@Mpp|LR~HQf;V>@fCJCPieoY%7u<0JicvZT{rE^aGB)K<^xNeXo~{Xdw+iBi~woj zP%tP`Gs_OJ51d|z^5Hr>3fX~17$vsc5D^UHGEA!Z$UzDNe0=pnnPW@8@iT7j zsnldDZJJG-`D8TlU2vjSEm;&O?OKk3V;l=qt&7hlh#17*!4WUbL_#MLR#CxI5WJ>I z=uG4(6&Ovz z-&9<2Ij_@ye}T?&uXhSSJ#Yt{z>JI$dn#M%TjzuMr~ZH>`~n1mB%@n@G^yBXyb#-F zwx(!o(&6tqUQ!c@JhB$#MT1Etehv~BJ7alhX=A+jxg9BGC%Fl`%D_}Xa7c)% zMSG#Scyx3$P)GzJP_4E3peaJjwh`FDKR`49icA5Ss@l2W0~{0>9q!8kNGbdt?6;SP zW}t@vgEs&ex@tN}_JJ}(*@~P3)xlT?-OOfdk+JtXWqo{om#!VS1d52K69S7;6G>4ZIK4H!=iyZe&bg8hRo$Hz!Km&?U9ABw7d>* zF9v~haIlL10oc%o`A-%7H!#I?-T+!V=XyqS5@^$vZXQ=#-e5nfY13t8TWzA(t35R(9e@OYo@5+nDOl zLH3{!4Blxl>$Pcvy)DD6UMb9JF(!U~xU{4xc~E}}9c^LY=hub-Z@$oU4~_`~505&= zQ+Dw(y>?wNXXjN6rZ}KebemV;v@!_GeR=&JdXsXg>=H1RJs0o1KF z1LW@tdf_kp9!h|`f)L63sdAn7P!aJbfDbC_>M0EL^op|d?^RU3f+>p$Fac=BY*eML z0!Z9@km!i7==#R2-S7^EfsRg!p7wH1BZ`zahnU;8lz>@Ng*Jfy{_J-#cow^0gmBUZ zps@GA_S^j)jyq`saI59_Fl??Z==>yrLGh=o!YWkJmCmXQDhy!S0%7^;cn$2WZwB3n zl2N1;R4CW!g}wPBX!%D(ld|Ky~9*)_wg9ur@$FA8v&x zegIMJGoY9la1dL7IS8aDq*-6T1_LoTjUBun1YmWTnAB91cQ%b+a^eX&|90RHfzTeq z?83q@P+4AqaZ2EQKD(R?|4$Dos;;Q41m`%N;gX%7KfSWDl6b+X@Xn4}8?^hhe&FCd z++9P>Q;V zC(jvin!^8kV8tRLQ0>kI>}$QemL9_@!FKM{x9Utu48Qf&?j}?f<;|O%cVLApbX&JU zMFyi*e0tu;AI=@Z=I#0b#W4W94q#x1oiSR-aHbHZd35>Tw4mgW*ZlHyz)M$=g`IsY zAN_~GRM5Eayf83WFBjY>K>q}fFA1Dj&CA-qj5_s=>w}4rAVyzRwPAA(91@bfpKp%L z|D~QRv0lz~J|TN84t@QSpAc9?kQ)LU8HNZb6F|Gm0Vk&hSX~3H{4TqAfDmyuZK)j) zmV@|#8OR2Ol6!`MeR&K_(%)@9UTOD0wLCjq%A|dhlvSQm$0Q&?_IC~mW}kH@CI*Xu zRo9w1H^K0DwP)Vvasd#iDeV}f8xRM3CyPx&!U*mLtV6N5pX6`f&rhUYFlLLrz~ zkpnW`(B}Jh@3In;NYjbLmgp}oER<}~xM zl*@L$klg1?5z2VMfLwNWVq*ai?}~*UuSa$VS=&{BW32`Nm?AK5l|kh!ZfH=4p7t5A zoGK=yKfvFrfLaHw2sgmp7zf0w0*qgc`#oOz&5