Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions docs/release-notes/3764.feature.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
{func}`scanpy.pl.dotplot` now supports a `group_colors` parameter for custom per-group coloring with perceptually uniform color gradients via OKLab interpolation. {smaller}`R Baber`
2 changes: 2 additions & 0 deletions pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ skmisc = [ "scikit-misc>=0.5.1" ] # highly_variable_genes m
harmony = [ "harmonypy" ] # Harmony dataset integration
scanorama = [ "scanorama" ] # Scanorama dataset integration
scrublet = [ "scikit-image>=0.23.1" ] # Doublet detection with automatic thresholds
# Plotting
plotting = [ "colour-science" ]
# Acceleration
rapids = [ "cudf>=0.9", "cuml>=0.9", "cugraph>=0.9" ] # GPU accelerated calculation of neighbors
dask = [ "dask[array]>=2024.5.1", "anndata[dask]" ] # Use the Dask parallelization engine
Expand Down
343 changes: 264 additions & 79 deletions src/scanpy/plotting/_dotplot.py

Large diffs are not rendered by default.

53 changes: 53 additions & 0 deletions src/scanpy/plotting/_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -1105,3 +1105,56 @@ def _deprecated_scale(
def _dk(dendrogram: bool | str | None) -> str | None: # noqa: FBT001
"""Convert the `dendrogram` parameter to a `dendrogram_key` parameter."""
return None if isinstance(dendrogram, bool) else dendrogram


def _create_white_to_color_gradient(color: ColorLike, n_steps: int = 256):
"""Generate a perceptually uniform colormap from white to a target color.

This function uses the OKLab color space for interpolation to ensure that
the brightness of the generated colormap changes uniformly.

Parameters
----------
color
The target color for the gradient. Can be any valid matplotlib color.
n_steps
The number of steps in the colormap.

Returns
-------
A `matplotlib.colors.ListedColormap` object.
"""
try:
import colour
except ImportError:
msg = (
"Please install the `colour-science` package to use `group_colors`: "
"`pip install colour-science` or `pip install scanpy[plotting]`"
)
raise ImportError(msg) from None
from matplotlib.colors import ListedColormap, to_hex

# Convert the input color to a hex string
hex_color = to_hex(color, keep_alpha=False)

# Define the color space for interpolation
space = "OKLab"

# Convert start (white) and end (target color) to the OKLab color space
target_oklab = colour.convert(hex_color, "Hexadecimal", space)
white_oklab = colour.convert("#ffffff", "Hexadecimal", space)

# Create the gradient through linear interpolation in OKLab
gradient = colour.algebra.lerp(
np.linspace(0, 1, n_steps)[..., np.newaxis],
white_oklab,
target_oklab,
)

# Convert the gradient back to sRGB for display
rgb_gradient = colour.convert(gradient, space, "sRGB")

# Clip values to be within the valid [0, 1] range for RGB
clipped_rgb = np.clip(rgb_gradient, 0, 1)

return ListedColormap(clipped_rgb)
1 change: 1 addition & 0 deletions src/testing/scanpy/_pytest/marks.py
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ def _generate_next_value_(

mod: str

colour = "colour-science"
dask = auto()
dask_ml = auto()
fa2 = auto()
Expand Down
Binary file modified tests/_images/clustermap/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/clustermap_withcolor/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot2/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot3/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot_dict/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot_gene_symbols/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/_images/dotplot_group_colors/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot_groupby_index/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot_groupby_list_catorder/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot_obj/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot_obj_std_scale_group/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot_obj_std_scale_group_swap_axes/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot_obj_std_scale_var/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot_obj_std_scale_var_swap_axes/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot_obj_swap_axes/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot_std_scale_group/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added tests/_images/dotplot_std_scale_var/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/dotplot_totals/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/multiple_plots/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/ranked_genes/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file modified tests/_images/ranked_genes_dotplot/expected.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
192 changes: 191 additions & 1 deletion tests/test_plotting.py
Original file line number Diff line number Diff line change
Expand Up @@ -834,7 +834,7 @@ def test_correlation(image_comparer):
[pytest.param(name, fn, id=name) for name, fn in _RANK_GENES_GROUPS_PARAMS],
)
def test_rank_genes_groups(image_comparer, name, fn):
save_and_compare_images = partial(image_comparer, ROOT, tol=15)
save_and_compare_images = partial(image_comparer, ROOT, tol=25)

pbmc = pbmc68k_reduced()
sc.tl.rank_genes_groups(pbmc, "louvain", n_genes=pbmc.raw.shape[1])
Expand Down Expand Up @@ -1856,3 +1856,193 @@ def test_violin_scale_warning(monkeypatch):
def test_dogplot() -> None:
"""Test that the dogplot function runs without errors."""
sc.pl.dogplot()


def test_dotplot_group_colors_raises_error_on_missing_dep(monkeypatch):
"""Check that an informative ImportError is raised when colour-science is missing."""
import sys

# Remove colour from sys.modules if present and block reimport
monkeypatch.setitem(sys.modules, "colour", None)

adata = pbmc68k_reduced()
markers = ["CD79A"]
group_colors = {"CD19+ B": "blue"}

with pytest.raises(ImportError, match="pip install colour-science"):
sc.pl.dotplot(
adata,
markers,
groupby="bulk_labels",
group_colors=group_colors,
show=False,
)


@needs.colour
@pytest.mark.parametrize(
("name", "swap_axes"),
[
("dotplot_group_colors", False),
("dotplot_group_colors_swap_axes", True),
],
)
def test_dotplot_group_colors(image_comparer, name, swap_axes):
"""Check group_colors parameter with custom colors per group."""
save_and_compare_images = partial(image_comparer, ROOT, tol=15)

adata = pbmc68k_reduced()

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

group_colors = {
"CD14+ Monocyte": "gray",
"Dendritic": "#a65628", # brown
"CD8+ Cytotoxic T": "red",
"CD8+/CD45RA+ Naive Cytotoxic": "green",
"CD4+/CD45RA+/CD25- Naive T": "orange",
"CD4+/CD25 T Reg": "blue",
"CD4+/CD45RO+ Memory": "#ff7f00", # orange
"CD19+ B": "#984ea3", # purple
"CD56+ NK": "pink",
"CD34+": "cyan",
}

sc.pl.dotplot(
adata,
markers,
groupby="bulk_labels",
group_colors=group_colors,
dendrogram=True,
swap_axes=swap_axes,
show=False,
)
save_and_compare_images(name)


@needs.colour
def test_dotplot_group_colors_fallback(image_comparer):
"""Check that fallback to default cmap works for groups not in group_colors."""
save_and_compare_images = partial(image_comparer, ROOT, tol=15)

adata = pbmc68k_reduced()

markers = ["SERPINB1", "IGFBP7", "GNLY", "IFITM1"]

# Intentionally incomplete dict to test fallback
group_colors = {
"CD14+ Monocyte": "gray",
"Dendritic": "purple",
}

# Expect warning about missing groups since we only specify 2 of 10 groups
with pytest.warns(
UserWarning, match="will use the default colormap as no specific colors"
):
sc.pl.dotplot(
adata,
markers,
groupby="bulk_labels",
group_colors=group_colors,
cmap="Reds", # Fallback cmap
dendrogram=True,
show=False,
)
save_and_compare_images("dotplot_group_colors_fallback")


@needs.colour
def test_dotplot_group_colors_warns_on_cmap():
"""Check that a warning is raised when both cmap and group_colors are passed."""
adata = pbmc68k_reduced()
markers = ["CD79A"]
group_colors = {"CD19+ B": "blue"}

# Expect both warnings: one for cmap+group_colors, one for missing groups
with pytest.warns(UserWarning, match="cmap|colormap") as record:
sc.pl.dotplot(
adata,
markers,
groupby="bulk_labels",
group_colors=group_colors,
cmap="viridis",
show=False,
)
# Check that we got both expected warnings
warning_messages = [str(w.message) for w in record]
assert any("Both `cmap` and `group_colors`" in msg for msg in warning_messages)
assert any("no specific colors were assigned" in msg for msg in warning_messages)


@needs.colour
def test_dotplot_group_colors_warns_on_missing_groups():
"""Check that a warning is raised when not all groups have colors assigned."""
adata = pbmc68k_reduced()
markers = ["CD79A"]
# Only assign color to one group - others should trigger warning
group_colors = {"CD19+ B": "blue"}

with pytest.warns(
UserWarning, match="will use the default colormap as no specific colors"
):
sc.pl.dotplot(
adata,
markers,
groupby="bulk_labels",
group_colors=group_colors,
show=False,
)


def test_dotplot_group_colors_coverage_mock(mocker):
"""Force-runs the group_colors logic using a MOCK 'colour' library. Uses the built-in 'mocker' fixture to avoid top-level imports."""
import importlib
import sys

import scanpy.plotting # <--- Need this to fix the reference later
import scanpy.plotting._dotplot
import scanpy.plotting._utils

# 1. Create a Fake 'colour' library using the existing 'mocker' fixture
mock_colour = mocker.MagicMock()
# Fake OKLab conversion returning a red-ish color
mock_colour.convert.return_value = np.array([0.6, 0.2, 0.1])
# Fake Gradient returning random RGBs (256 steps)
mock_colour.algebra.lerp.return_value = np.random.rand(256, 3)

# 2. Patch 'sys.modules' so Python thinks 'colour' is installed
mocker.patch.dict(sys.modules, {"colour": mock_colour})

# We MUST reload the modules so they detect the "installed" package
importlib.reload(scanpy.plotting._utils)
importlib.reload(scanpy.plotting._dotplot)

try:
# 3. Setup dummy data WITH STRING INDICES
adata = AnnData(
X=np.random.rand(4, 2),
obs=pd.DataFrame(
{"group": ["A", "B", "A", "B"]}, index=["c1", "c2", "c3", "c4"]
),
var=pd.DataFrame(index=["gene1", "gene2"]),
)

# 4. Run the DotPlot with group_colors
sc.pl.dotplot(
adata,
["gene1", "gene2"],
groupby="group",
group_colors={"A": "red", "B": "blue"},
show=False,
)

finally:
# Cleanup: Reload modules back to original state
importlib.reload(scanpy.plotting._utils)
importlib.reload(scanpy.plotting._dotplot)

# CRITICAL FIX: Re-link the class in the parent package.
# This ensures 'sc.pl.DotPlot' points to the same class as the reloaded module,
# preventing "isinstance" failures in subsequent tests.
scanpy.plotting.DotPlot = scanpy.plotting._dotplot.DotPlot
scanpy.plotting.dotplot = scanpy.plotting._dotplot.dotplot
Loading