|
39 | 39 | _render_ds_outlines, |
40 | 40 | ) |
41 | 41 | from spatialdata_plot.pl.render_params import ( |
| 42 | + CmapParams, |
42 | 43 | Color, |
43 | 44 | ColorbarSpec, |
44 | 45 | FigParams, |
@@ -1018,6 +1019,63 @@ def _render_points( |
1018 | 1019 | ) |
1019 | 1020 |
|
1020 | 1021 |
|
| 1022 | +def _normalize_dtype_to_float(arr: np.ndarray) -> np.ndarray: |
| 1023 | + """Normalize an array to float64 in [0, 1] for display with matplotlib. |
| 1024 | +
|
| 1025 | + Intended for RGB/RGBA image data where negative values are not meaningful. |
| 1026 | +
|
| 1027 | + - uint8 → divide by 255 |
| 1028 | + - other unsigned int → divide by dtype max |
| 1029 | + - signed int → divide by dtype max, clip negatives to 0 |
| 1030 | + - float already in [0, 1] → pass through |
| 1031 | + - float outside [0, 1] → global auto-range (preserves relative balance across channels) |
| 1032 | + """ |
| 1033 | + if arr.dtype == np.uint8: |
| 1034 | + return arr.astype(np.float64) / 255.0 |
| 1035 | + if arr.dtype.kind == "u": |
| 1036 | + return arr.astype(np.float64) / np.iinfo(arr.dtype).max |
| 1037 | + if arr.dtype.kind == "i": |
| 1038 | + return np.clip(arr.astype(np.float64) / np.iinfo(arr.dtype).max, 0, 1) |
| 1039 | + # Float: if already in [0, 1], keep as-is; otherwise auto-range globally |
| 1040 | + arr_f: np.ndarray = arr.astype(np.float64) |
| 1041 | + vmin, vmax = arr_f.min(), arr_f.max() |
| 1042 | + if vmin >= 0.0 and vmax <= 1.0: |
| 1043 | + return arr_f |
| 1044 | + if vmin == vmax: |
| 1045 | + return np.zeros_like(arr_f) |
| 1046 | + logger.info( |
| 1047 | + "Float RGB image has values outside [0, 1] (range [%.3f, %.3f]); " |
| 1048 | + "auto-ranging globally. Pass an explicit 'norm' to control contrast.", |
| 1049 | + vmin, |
| 1050 | + vmax, |
| 1051 | + ) |
| 1052 | + result: np.ndarray = (arr_f - vmin) / (vmax - vmin) |
| 1053 | + return result |
| 1054 | + |
| 1055 | + |
| 1056 | +def _is_rgb_image(channel_coords: list[Any]) -> tuple[bool, bool]: |
| 1057 | + """Check if channel coordinates indicate an RGB(A) image. |
| 1058 | +
|
| 1059 | + Checks case-insensitively whether channel names are {r, g, b} or {r, g, b, a}. |
| 1060 | +
|
| 1061 | + Parameters |
| 1062 | + ---------- |
| 1063 | + channel_coords |
| 1064 | + The channel coordinate values from the image. |
| 1065 | +
|
| 1066 | + Returns |
| 1067 | + ------- |
| 1068 | + tuple[bool, bool] |
| 1069 | + (is_rgb, has_alpha) — whether the image is RGB and whether it includes an alpha channel. |
| 1070 | + """ |
| 1071 | + names = {str(c).lower() for c in channel_coords} |
| 1072 | + if names == {"r", "g", "b", "a"} and len(channel_coords) == 4: |
| 1073 | + return True, True |
| 1074 | + if names == {"r", "g", "b"} and len(channel_coords) == 3: |
| 1075 | + return True, False |
| 1076 | + return False, False |
| 1077 | + |
| 1078 | + |
1021 | 1079 | def _render_images( |
1022 | 1080 | sdata: sd.SpatialData, |
1023 | 1081 | render_params: ImageRenderParams, |
@@ -1083,6 +1141,50 @@ def _render_images( |
1083 | 1141 |
|
1084 | 1142 | _, trans_data = _prepare_transformation(img, coordinate_system, ax) |
1085 | 1143 |
|
| 1144 | + # Detect RGB(A) images by channel names — skip when user overrides with palette/cmap |
| 1145 | + is_rgb, has_alpha = _is_rgb_image(channels) |
| 1146 | + has_explicit_cmap = ( |
| 1147 | + isinstance(render_params.cmap_params, CmapParams) and not render_params.cmap_params.cmap_is_default |
| 1148 | + ) |
| 1149 | + if is_rgb and palette is None and not got_multiple_cmaps and not has_explicit_cmap: |
| 1150 | + coord_map = {str(c).lower(): c for c in channels} |
| 1151 | + ordered = [coord_map[ch] for ch in ("r", "g", "b")] |
| 1152 | + |
| 1153 | + # Apply norm per channel if user provided one, otherwise normalize by dtype |
| 1154 | + user_norm = ( |
| 1155 | + render_params.cmap_params.norm |
| 1156 | + if isinstance(render_params.cmap_params, CmapParams) |
| 1157 | + and isinstance(render_params.cmap_params.norm, Normalize) |
| 1158 | + and (render_params.cmap_params.norm.vmin is not None or render_params.cmap_params.norm.vmax is not None) |
| 1159 | + else None |
| 1160 | + ) |
| 1161 | + |
| 1162 | + if user_norm is not None: |
| 1163 | + rgb_layers = [] |
| 1164 | + for ch in ordered: |
| 1165 | + ch_norm = copy(user_norm) |
| 1166 | + rgb_layers.append(np.clip(ch_norm(img.sel(c=ch).values).astype(np.float64), 0, 1)) |
| 1167 | + stacked = np.stack(rgb_layers, axis=-1) |
| 1168 | + else: |
| 1169 | + stacked = _normalize_dtype_to_float(np.moveaxis(img.sel(c=ordered).values, 0, -1)) |
| 1170 | + |
| 1171 | + show_kwargs: dict[str, Any] = {"zorder": render_params.zorder} |
| 1172 | + |
| 1173 | + if has_alpha and render_params.alpha == 1.0: |
| 1174 | + alpha_layer = _normalize_dtype_to_float(img.sel(c=coord_map["a"]).values) |
| 1175 | + stacked = np.concatenate([stacked, alpha_layer[..., np.newaxis]], axis=-1) |
| 1176 | + else: |
| 1177 | + show_kwargs["alpha"] = render_params.alpha |
| 1178 | + if has_alpha: |
| 1179 | + logger.info( |
| 1180 | + "Image has an alpha channel, but an explicit 'alpha' value was provided. " |
| 1181 | + "Using the user-specified alpha=%.2f instead of the per-pixel alpha from the data.", |
| 1182 | + render_params.alpha, |
| 1183 | + ) |
| 1184 | + |
| 1185 | + _ax_show_and_transform(stacked, trans_data, ax, **show_kwargs) |
| 1186 | + return |
| 1187 | + |
1086 | 1188 | # 1) Image has only 1 channel |
1087 | 1189 | if n_channels == 1 and not isinstance(render_params.cmap_params, list): |
1088 | 1190 | layer = img.sel(c=channels[0]).squeeze() if isinstance(channels[0], str) else img.isel(c=channels[0]).squeeze() |
@@ -1138,13 +1240,16 @@ def _render_images( |
1138 | 1240 | else: |
1139 | 1241 | ch_norm = render_params.cmap_params.norm |
1140 | 1242 |
|
1141 | | - if ch_norm is not None: |
1142 | | - layers[ch] = ch_norm(layers[ch]) |
| 1243 | + # Auto-ranging norms are stateful — copy so each channel normalizes independently |
| 1244 | + if isinstance(ch_norm, Normalize) and (ch_norm.vmin is None or ch_norm.vmax is None): |
| 1245 | + ch_norm = copy(ch_norm) |
| 1246 | + |
| 1247 | + layers[ch] = ch_norm(layers[ch]) |
1143 | 1248 |
|
1144 | 1249 | # 2A) Image has 3 channels, no palette info, and no/only one cmap was given |
1145 | 1250 | if palette is None and n_channels == 3 and not isinstance(render_params.cmap_params, list): |
1146 | 1251 | if render_params.cmap_params.cmap_is_default: # -> use RGB |
1147 | | - stacked = np.stack([layers[ch] for ch in layers], axis=-1) |
| 1252 | + stacked = np.clip(np.stack([layers[ch] for ch in layers], axis=-1), 0, 1) |
1148 | 1253 | else: # -> use given cmap for each channel |
1149 | 1254 | channel_cmaps = [render_params.cmap_params.cmap] * n_channels |
1150 | 1255 | stacked = ( |
@@ -1182,15 +1287,15 @@ def _render_images( |
1182 | 1287 | [channel_cmaps[ch_ind](layers[ch]) for ch_ind, ch in enumerate(channels)], |
1183 | 1288 | 0, |
1184 | 1289 | ).sum(0) |
1185 | | - colored = colored[:, :, :3] |
| 1290 | + colored = np.clip(colored[:, :, :3], 0, 1) |
1186 | 1291 | elif n_channels == 3: |
1187 | 1292 | seed_colors = _get_colors_for_categorical_obs(list(range(n_channels))) |
1188 | 1293 | channel_cmaps = [_get_linear_colormap([c], "k")[0] for c in seed_colors] |
1189 | 1294 | colored = np.stack( |
1190 | 1295 | [channel_cmaps[ind](layers[ch]) for ind, ch in enumerate(channels)], |
1191 | 1296 | 0, |
1192 | 1297 | ).sum(0) |
1193 | | - colored = colored[:, :, :3] |
| 1298 | + colored = np.clip(colored[:, :, :3], 0, 1) |
1194 | 1299 | else: |
1195 | 1300 | if isinstance(render_params.cmap_params, list): |
1196 | 1301 | cmap_is_default = render_params.cmap_params[0].cmap_is_default |
@@ -1241,7 +1346,7 @@ def _render_images( |
1241 | 1346 |
|
1242 | 1347 | channel_cmaps = [_get_linear_colormap([c], "k")[0] for c in palette if isinstance(c, str)] |
1243 | 1348 | colored = np.stack([channel_cmaps[i](layers[c]) for i, c in enumerate(channels)], 0).sum(0) |
1244 | | - colored = colored[:, :, :3] |
| 1349 | + colored = np.clip(colored[:, :, :3], 0, 1) |
1245 | 1350 |
|
1246 | 1351 | _ax_show_and_transform( |
1247 | 1352 | colored, |
|
0 commit comments