scanpy icon indicating copy to clipboard operation
scanpy copied to clipboard

feat(plotting): Add group_cmaps parameter to sc.pl.dotplot to specify unique colormaps for each group

Open rwbaber opened this issue 4 months ago • 4 comments

  • [ ] Closes #
  • [x] Tests included: New tests were added for the core functionality, swap_axes compatibility, and error conditions.

Description

This PR introduces a new feature to sc.pl.dotplot: the group_cmaps parameter. This allows users to assign a unique colormap to each category in a dot plot, which is particularly useful for creating publication-ready figures that align with a paper's established color scheme for different cell types.

Key Changes

  • group_cmaps parameter: Added a new parameter to sc.pl.dotplot and the DotPlot class that accepts a dictionary mapping group names to colormap names.
  • Stacked Colorbar Legend: Implemented a new stacked horizontal colorbar legend (via _plot_stacked_colorbars function) that appears when group_cmaps is used. This legend clearly displays the colormap and for each group with corresponding label.
  • Robust Error Handling: Added a ValueError that is raised if group_cmaps is used but a group in the plot data is not defined in the dictionary.
  • Pre-existing Layout Bug Fix: Set the width of the dot plot legend area within the dotplot wrapper function to 2.0 (instead of DEFAULT_LEGENDS_WIDTH=1.5) to fix a pre-existing issue where the circles of the size legend could overlap (especially with dendrogram=True).
    • As a result, the reference images (expected.png) for several existing dotplot tests were updated to reflect the new, corrected layout.
  • Code Refactoring: The DotPlot.__init__ method was refactored into a smaller helper function (_prepare_dot_data) to improve readability and pass the automated code complexity checks by pre-commit.
  • Comprehensive Tests: Added new tests to verify the core functionality, ensure compatibility with swap_axes, and confirm that the correct errors and warnings are raised.

Example Plot

from testing.scanpy._helpers.data import (pbmc3k_processed)
import scanpy as sc

adata = pbmc3k_processed()

markers = ["SERPINB1", "IGFBP7", "GNLY", "IFITM1", "IMP3", "UBALD2", "LTB", "CLPP"]

group_cmaps = {
    "CD4 T cells":              "pink_r",
    "CD14+ Monocytes":          "Reds",
    "B cells":                  "Greys",
    "CD8 T cells":              "Greens",
    "NK cells":                 "Oranges",
    "FCGR3A+ Monocytes":        "Purples",
    "Dendritic cells":          "Blues",
    "Megakaryocytes":           "bone_r",
}

sc.pl.dotplot(
    adata,
    markers,
    groupby="louvain",
    group_cmaps=group_cmaps,  # Define custom color maps for each group from 'groupby'
    dendrogram=True,
    standard_scale='var',
    dot_max=1.0,
    title="Dot Plot with Unique Per-Group Colormaps\n(pbmc3k_processed)",
)

dotplot_pbmc

rwbaber avatar Aug 16 '25 14:08 rwbaber

Good idea! However I’m concerned that the color maps aren’t visually comparable.

  • Did you think about adding a color bar instead, like for the heatmap

  • alternatively, I’d replace the parameter with group_colors or so. We could then use cspace_convert to make colormaps on the fly, like this: https://gist.github.com/flying-sheep/a38e925d0b0f7ad5c6eb1ada8fa5c128

    Unfortunately colorspacious seems unmaintained, and I haven’t figured out to make this code numerically robust. https://github.com/njsmith/colorspacious/issues/18

flying-sheep avatar Aug 20 '25 08:08 flying-sheep

Thanks for the feedback! The visual comparability is a good point, I didn’t think about that. I personally prefer your second suggestion of making (perceptually uniform) colormaps on the fly. Though, the heatmap-like color bar could be an additional option. I think that should be fairly straightforward to implement.

Since colorspacious is unmaintained, perhaps we can check some alternatives. After a quick search, I found this post on the Oklab color space which might be another way to implement this: Converting from linear sRGB to Oklab. I’ll look into it a bit more some other time and report back!

rwbaber avatar Aug 22 '25 10:08 rwbaber

Hi @flying-sheep,

I finally found some time to further look into this. I started with your GitHub Gist notebook but using the colour package. I used the OKLab color space to generate sequential colormaps by varying lightness while keeping a and b channels constant. And it seems like this can create the colormaps without the out-of-gamut errors or warnings - I think that was the issue you had before, right? I do get the similar artifacts as you in some colorbars when using CAM02-UCS (see third example in the gist linked below).

I added a second example generating white-to-color gradients by interpolating all channels in OKLab. This is the implementation I would use to create colormaps on the fly for the DotPlot feature.

Here's the gist with the examples: Perceptually uniform cmaps

Is this what you had in mind? Then I'd go ahead and integrate this into DotPlot.

rwbaber avatar Sep 29 '25 18:09 rwbaber

I’m super sorry that I didn’t write back. Yes, this is exactly what I was thinking!

flying-sheep avatar Nov 10 '25 12:11 flying-sheep

:x: 3 Tests Failed:

Tests completed Failed Passed Skipped
2263 3 2260 446
View the top 3 failed test(s) by shortest run time
src/scanpy/preprocessing/_normalization.py::scanpy.preprocessing._normalization.normalize_total
Stack Traces | 0.006s run time
194     ...     np.array(
195     ...         [
196     ...             [3, 3, 3, 6, 6],
197     ...             [1, 1, 1, 2, 2],
198     ...             [1, 22, 1, 2, 2],
199     ...         ],
200     ...         dtype="float32",
201     ...     )
202     ... )
203     >>> adata.X
Differences (unified diff with -expected +actual):
    @@ -1,3 +1,3 @@
    -array([[ 3.,  3.,  3.,  6.,  6.],
    -       [ 1.,  1.,  1.,  2.,  2.],
    -       [ 1., 22.,  1.,  2.,  2.]], dtype=float32)
    +array([[  3.,   3.,   3.,   6.,   6.],
    +       [  1.,   1.,   1.,   2.,   2.],
    +       [  1.,  22.,   1.,   2.,   2.]], dtype=float32)

#x1B[1m#x1B[.../scanpy/preprocessing/_normalization.py#x1B[0m:203: DocTestFailure
src/scanpy/preprocessing/_simple.py::scanpy.preprocessing._simple.normalize_per_cell
Stack Traces | 0.008s run time
507     Returns `None` if `copy=False`, else returns an updated `AnnData` object. Sets the following fields:
508 
509     `adata.X` : :class:`numpy.ndarray` | :class:`scipy.sparse.csr_matrix` (dtype `float`)
510         Normalized count data matrix.
511 
512     Examples
513     --------
514     >>> import scanpy as sc
515     >>> adata = AnnData(np.array([[1, 0], [3, 0], [5, 6]], dtype=np.float32))
516     >>> print(adata.X.sum(axis=1))
Expected:
    [ 1.  3. 11.]
Got:
    [  1.   3.  11.]

#x1B[1m#x1B[.../scanpy/preprocessing/_simple.py#x1B[0m:516: DocTestFailure
src/scanpy/plotting/_baseplot_class.py::scanpy.plotting._baseplot_class.BasePlot.add_dendrogram
Stack Traces | 0.056s run time
283 
284         Examples
285         --------
286         >>> import scanpy as sc
287         >>> adata = sc.datasets.pbmc68k_reduced()
288         >>> markers = {"T-cell": "CD3D", "B-cell": "CD79A", "myeloid": "CST3"}
289         >>> plot = sc.pl._baseplot_class.BasePlot(
290         ...     adata, markers, groupby="bulk_labels"
291         ... ).add_dendrogram()
292         >>> plot.plot_group_extra  # doctest: +NORMALIZE_WHITESPACE
Expected:
    {'kind': 'dendrogram',
     'width': 0.8,
     'dendrogram_key': None,
     'dendrogram_ticks': array([0.5, 1.5, 2.5, 3.5, 4.5, 5.5, 6.5, 7.5, 8.5, 9.5])}
Got:
    {'kind': 'dendrogram', 'width': 0.8, 'dendrogram_key': None, 'dendrogram_ticks': array([ 0.5,  1.5,  2.5,  3.5,  4.5,  5.5,  6.5,  7.5,  8.5,  9.5])}

#x1B[1m#x1B[.../scanpy/plotting/_baseplot_class.py#x1B[0m:292: DocTestFailure

To view more test analytics, go to the Test Analytics Dashboard 📋 Got 3 mins? Take this short survey to help us improve Test Analytics.

codecov[bot] avatar Dec 21 '25 23:12 codecov[bot]