Fix the repeating palette, and give plotting a theme - #86
Merged
Conversation
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>
This was referenced Aug 1, 2026
Merged
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Two defects in
truecell/plotting.pyand one addition. Everything here isabout how plots look; no analysis code is touched.
The palette repeated colours
_PALETTE_36was named for 36 entries and held 30 distinct ones:_palettesliced that list for anyn <= 36and only fell through to atab20ramp above it. So an object with 30 to 36 groups drew two clusters inexactly 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 —nevenlyspaced HCL hues at c=100, l=65, with the polarLUV-to-sRGB conversion written
out so R's per-channel
fixup = TRUEclamp is reproduced rather thanapproximated. 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_palis verified exact againstscales::hue_pal()for n = 1-6, 8 and 9.Rasterisation was inconsistent
rasterized=Trueappeared twice in the file, both infeature_plot, whichtherefore rasterised unconditionally. Every other scatter drew one vector path
per cell, so a PDF or SVG of a 100k-cell
dim_plotembedded 100k circles.dim_plot,feature_scatter,variable_feature_plot,image_dim_plot,image_feature_plot,spatial_dim_plotandspatial_feature_plotnow takeraster=alongsidefeature_plot. The defaultNoneresolves to Seurat's ownrule — 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=8twelvetimes,
fontsize=9ten times, and so on), so changing the house style meantediting the source. Text now scales from one base size through named roles:
base_sizealso writesrcParams["font.size"]. This is load-bearing and was areal 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 norcParams at all.
hue_pal(n)is exported too, for matching group colours in a figure drawnoutside truecell.
Verification
Default output is unchanged. Twelve plots were rendered against
mainandagainst this branch. Six differed — and pinning the old palette via
set_theme(palette=...)made all twelve byte-identical tomain. One testtherefore 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 solebehavioural change.
Every new guard was mutation-tested; all four mutations went red:
"small": 0.8->0.85_should_rasteralwaysFalsefont.sizefrom the rc block1010 passed, 25 skipped. The docs test caught the new exports as unpaged andthey 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.pygoes 106 -> 121, allUP045(Optional[X]), matching thefile's existing idiom rather than introducing a second one. That belongs to the
existing
chore/ruff-cleanbranch.🤖 Generated with Claude Code