Skip to content

Fix the repeating palette, and give plotting a theme - #86

Merged
shanikawm merged 1 commit into
mainfrom
feat/plotting-theme
Aug 1, 2026
Merged

Fix the repeating palette, and give plotting a theme#86
shanikawm merged 1 commit into
mainfrom
feat/plotting-theme

Conversation

@shanikawm

Copy link
Copy Markdown
Contributor

Two defects in truecell/plotting.py and one addition. Everything here is
about how plots look; no analysis code is touched.

The palette repeated colours

_PALETTE_36 was named for 36 entries and held 30 distinct ones:

#A3A500 at indices [14, 29]     #F8766D at [0, 31]     #00BFC4 at [4, 32]
#C77CFF at [6, 33]              #FF61CC at [7, 34]     #00B8E7 at [12, 35]

_palette sliced that list for any n <= 36 and only fell through to a
tab20 ramp above it. So an object with 30 to 36 groups drew two clusters in
exactly the same colour
, with nothing on the figure to say which was which.
That is a wrong plot, not a cosmetic one, and 30 clusters is an ordinary atlas,
not a corner case.

The list is gone. hue_pal(n) computes ggplot's ramp directly — n evenly
spaced HCL hues at c=100, l=65, with the polarLUV-to-sRGB conversion written
out so R's per-channel fixup = TRUE clamp is reproduced rather than
approximated. Colour libraries typically reject out-of-gamut values or rescale
the whole triplet; R clamps each channel independently, and several of ggplot's
actual colours are out of the sRGB gamut before that clamp. It cannot repeat
and it cannot run out.

It is also more faithful, not just safer. ggplot spreads the hue circle
across however many groups there are, so the colours for 9 groups are not the
colours for 8 plus one more. The old list had been built by concatenating
several such runs — which is both where the duplicates came from and why it
matched Seurat only at n = 8. hue_pal is verified exact against
scales::hue_pal() for n = 1-6, 8 and 9.

Rasterisation was inconsistent

rasterized=True appeared twice in the file, both in feature_plot, which
therefore rasterised unconditionally. Every other scatter drew one vector path
per cell, so a PDF or SVG of a 100k-cell dim_plot embedded 100k circles.

dim_plot, feature_scatter, variable_feature_plot, image_dim_plot,
image_feature_plot, spatial_dim_plot and spatial_feature_plot now take
raster= alongside feature_plot. The default None resolves to Seurat's own
rule — rasterise above 100,000 points — so it means the same thing here as in
SingleDimPlot. PNG output is unaffected either way.

A theme layer

The module named absolute point sizes at 40 call sites (fontsize=8 twelve
times, fontsize=9 ten times, and so on), so changing the house style meant
editing the source. Text now scales from one base size through named roles:

import truecell as tc

tc.set_theme(base_size=13, style="seurat")   # bigger text, cowplot's look
fig = tc.dim_plot(pbmc)

with tc.theme_context(base_size=8):          # scoped to the block
    panel = tc.feature_plot(pbmc, ["MS4A1", "LYZ"])

base_size also writes rcParams["font.size"]. This is load-bearing and was a
real gap found while rendering the comparison: the roles only reach text this
module sizes explicitly, and axis labels and tick labels belong to matplotlib —
so before that line, raising the base grew the titles and left the axis
furniture at 10pt. Half a theme.

Two presets ship, "seurat" (cowplot's look, which is what Seurat draws) and
"minimal". Neither is applied unless asked for; the default touches no
rcParams at all.

hue_pal(n) is exported too, for matching group colours in a figure drawn
outside truecell.

Verification

Default output is unchanged. Twelve plots were rendered against main and
against this branch. Six differed — and pinning the old palette via
set_theme(palette=...) made all twelve byte-identical to main. One test
therefore establishes both halves: the 40-site font refactor is a no-op at
base_size=10 (0.8 x 10 == 8, and so on), and the palette is the sole
behavioural change.

Every new guard was mutation-tested; all four mutations went red:

Mutation Caught by
Restore the old duplicate-bearing palette 6 tests, failing at exactly n=30 and n=36 — the range where the original bug lived
"small": 0.8 -> 0.85 2 size tests
_should_raster always False the raster rule test
Drop font.size from the rc block the axis-furniture test

1010 passed, 25 skipped. The docs test caught the new exports as unpaged and
they now have an API section; the strict site build is green.

What this changes for you

Committed figures will change. Any group-coloured plot whose group count is
not 8 gets different — correct — colours. Numbers, ordering and layout are
untouched.

They are deliberately not regenerated here. The tutorial figures are
already dirty in the working tree from the lockfile migration, and regenerating
in this PR would make the two indistinguishable in the diff. That is one
decision to make, not two.

Lint on plotting.py goes 106 -> 121, all UP045 (Optional[X]), matching the
file's existing idiom rather than introducing a second one. That belongs to the
existing chore/ruff-clean branch.

🤖 Generated with Claude Code

Two defects and one addition, all in truecell/plotting.py.

`_PALETTE_36` was named for 36 colours and held 30. `#A3A500` sat at
index 14 and 29, and five more were doubled in the final five slots.
`_palette` sliced that list for any n <= 36 and only reached its
`tab20` fallback above it, so an object with 30 to 36 groups drew two
clusters in exactly the same colour with nothing on the figure to say
so. `hue_pal(n)` replaces it, computing ggplot's ramp directly — n
evenly spaced HCL hues, polarLUV to sRGB written out so R's
per-channel `fixup = TRUE` clamp is reproduced rather than
approximated. It cannot repeat and it cannot run out.

That also closes a fidelity gap rather than only a safety one. ggplot
spreads the hue circle across however many groups there are, so the
colours for 9 groups are not the colours for 8 plus one more; the old
list was several such runs concatenated, which is both where the
duplicates came from and why it matched Seurat only at n=8. The new
one is verified exact against R for n = 1-6, 8 and 9.

Rasterisation was inconsistent: `feature_plot` rasterised always,
every other scatter never, so a PDF of a 100k-cell `dim_plot` held
100k vector circles. Seven more plots take `raster=` now, defaulting
to Seurat's own rule of rasterising above 100,000 points.

The theme replaces 40 hard-coded point sizes with roles scaled from
one base size. `base_size` also writes rcParams["font.size"], because
the roles only reach text this module sizes explicitly — axis and tick
labels belong to matplotlib, and without it raising the base grew the
titles and left the axis furniture at 10pt.

Default output is unchanged. Twelve plots rendered on both sides of
this commit with the old palette pinned come out byte-identical, which
is what establishes the palette as the sole behavioural change, and
0.8 * 10 == 8 keeps every size where it was.

The palette fix does change committed figures: any group-coloured plot
whose group count is not 8 gets different, correct colours. Numbers,
ordering and layout are untouched. Regenerating them is a separate
change, and the tutorial figures are already dirty in the working tree
from the lockfile migration.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@shanikawm
shanikawm merged commit 4eea338 into main Aug 1, 2026
6 checks passed
This was referenced Aug 1, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant