Skip to main content

poikilos

Matplotlib themes, plus five helpers for publication figures. Poikilos is Greek for many-colored.

A theme is a look (fonts, spines, ticks, sizes) plus a palette (colors). Any look takes any palette.

Theme Look Palette
plain, plain-dark plain matplotlib's tab10 on white or #242424
rose-pine, rose-pine-moon, rose-pine-dawn plain Rosé Pine
lilaq, lilaq-moon lilaq lilaq 0.6.0, default and moon
pgl pgl seaborn's whitegrid greys, tab10
Light Dark
plain plain-dark
rose-pine-dawn rose-pine
lilaq lilaq-moon
pgl

rose-pine-moon has its own gallery image.

Install

uv add "poikilos @ git+https://github.com/ramirc0/poikilos"
uv add "poikilos[labels] @ git+https://github.com/ramirc0/poikilos"  # adds adjustText for label_points

Needs Python 3.11 and matplotlib 3.11 or later.

Usage

import matplotlib.pyplot as plt

import poikilos as pk

pk.use("rose-pine")

fig, ax = plt.subplots()
ax.plot([0, 1, 2], [0, 1, 0])
pk.despine(ax)
pk.save_figure(fig, "results/plot")  # writes plot.svg and plot.png

use() resets every style rcParam to matplotlib's default, then applies the theme. It leaves the backend alone, so it works before or after importing pyplot. Importing poikilos changes nothing.

For one figure in another theme, use context(). It restores the previous rcParams on exit. Save inside the block, because matplotlib reads savefig.dpi at save time.

with pk.context("lilaq"):
    fig, ax = plt.subplots()
    ax.plot(x, y)
    pk.save_figure(fig, "results/lilaq")

Mixing and overrides

palette= puts another palette on a theme's look. rc= sets any rcParams on top. use(), context() and rc_params() all take both.

pk.use("lilaq", palette="rose-pine-dawn")  # lilaq look, Rosé Pine Dawn colors
pk.use("plain", rc={"axes.grid": True})

use() validates everything before it touches matplotlib. A misspelled key in rc raises KeyError and a bad value raises ValueError. Either way the current rcParams stay as they were. The next use() resets any rcParam you set by hand, so set them after use() or pass them in rc.

seaborn's set_theme() also resets rcParams. Call it before pk.use().

pk.rc_params(theme, palette=None) returns a theme's rcParams as a dict without applying them.

Palettes as data

Palettes are TOML files in src/poikilos/palettes/. pk.PALETTES holds them with every color resolved to hex:

dawn = pk.PALETTES["rose-pine-dawn"]
dawn.background  # '#faf4ed'
dawn.cycle  # ('#b4637a', '#ea9d34', ...)
dawn.colors["love"]  # '#b4637a', by the palette's own name

pk.THEMES maps each theme name to its (look, palette) pair.

A palette sets root colors only: background, foreground, frame, ticks, grid, legend edge, boxplot median and mean, and the cycle. Legend face, title, savefig and hatch colors inherit from those. An rc= override of a root color therefore carries through to them.

Helpers

  • despine(ax, categorical_x=False, categorical_y=False) fits each continuous axis to its data and widens it to the enclosing ticks. It then offsets the left and bottom spines by 10 pt and trims them to the end ticks. The top and right spines and their ticks go. Pass categorical_x=True for bar charts, categorical_y=True for horizontal bars, and both for heatmaps. A categorical axis has no spine or offset, so its labels sit by the data. Call it after everything on the figure is drawn. It picks ticks for the axes size after layout.
  • fit_legend(fig) grows the figure by the room legends outside the axes take. Constrained layout otherwise keeps figsize and shrinks the axes, so a square plot turns into a rectangle. The figure ends up larger than figsize. Call it after adding every legend, before despine.
  • label_points(ax, points, labels, offsets=None, leader=True, **text_kwargs) labels the collection ax.scatter returns. Each label sits in a box outlined like its leader line, or stays bare with bbox=None. It moves labels off each other and off the markers, but a label can still touch a marker. That happens most near the edge of the axes, since labels stay inside them. Call it last, after despine and every title and legend. Needs the labels extra. offsets=[(dx, dy), ...], in points, fixes each label's place instead and needs no extra. leader=False drops the leader lines.
  • save_figure(fig, path, formats=("svg", "png"), **savefig_kwargs) writes path.svg and path.png, or one file per entry in formats. It drops a suffix that names a format, such as .svg or .pdf, and keeps any other dots, so x.curve gives x.curve.svg and x.curve.png.
  • zero_line(ax, axis="y", **line_kwargs) draws a bold line at zero, for values that change sign. It uses the frame color at twice the grid width. axis="x" draws x = 0 and "both" draws both. The limits widen to include zero.

Recipes per look

Looks only set rcParams. Anything that needs code per axes is a recipe.

plain. Call despine on every axes. Without it you get matplotlib's default frame minus the top and right spines. despine styles the left and bottom spines only, so style a twin axes by hand:

ax2 = ax.twinx()
ax2.plot(x, y2, clip_on=False)  # despine never sees the twin
ax2.set_ylim(0, 100)  # limits on ticks, so the spine ends on one
ax2.spines[["left", "bottom"]].set_visible(False)
ax2.spines["right"].set_visible(True)  # the look hides right spines
ax2.spines["right"].set_position(("outward", 10))
pk.despine(ax)  # last, once the twin is drawn

lilaq. lilaq's tick steps and dotted lines take a few lines of code:

from matplotlib.ticker import MaxNLocator, NullLocator

ax.plot(x, y, marker="o")  # lilaq draws a dot at every data point
for axis in (ax.xaxis, ax.yaxis):
    axis.set_major_locator(MaxNLocator(7, steps=[1, 2, 5, 10]))
ax.xaxis.set_minor_locator(NullLocator())  # instead, on a categorical axis

lilaq rounds the step to 1, 2 or 5 and scales the tick count with the axis length. That gives about five ticks on its default 6 × 4 cm plot, where nbins=7 switches steps at nearly the same data ranges. On axes of another size, lilaq picks a different count. The dots stay out of the look on purpose. A marker in axes.prop_cycle makes plot(color="k") use up a cycle slot and beads dense lines. seaborn would also apply it unevenly. scatterplot and pointplot drop it. lineplot keeps it but leaves it out of the legend, and without hue takes the marker from the wrong cycle slot.

pgl. The Programmable Genomics Laboratory's papers use seaborn's whitegrid without spines, with a dashed grid in place of tick marks. This look follows them. Never call despine on it, because that brings the spines back.

ax.xaxis.grid(False)  # categorical x; seaborn's categorical plots already do this
pk.zero_line(ax)  # values change sign; axis="x" for x = 0

Heatmaps need ax.grid(False). To sign positive tick labels on a scale that runs from − to +:

from matplotlib.ticker import ScalarFormatter


class SignedFormatter(ScalarFormatter):
    def __call__(self, x, pos=None):
        text = super().__call__(x, pos)
        return f"+{text}" if x > 0 and text.strip("0.") else text


ax.xaxis.set_major_formatter(SignedFormatter())

It keeps matplotlib's formatting, so +1.50 sits next to −0.50.

PDF text as TrueType. rc={"pdf.fonttype": 42} embeds fonts as TrueType. Use it only with a TrueType font. Anthropic Sans Text and New Computer Modern are CFF (.otf) fonts. So is Overpass from Debian's fonts-overpass package. With them, matplotlib writes CFF outlines into a TrueType font stream. Common viewers still render that PDF, but it breaks the PDF spec and strict checkers flag it.

Fonts

No fonts ship with poikilos. Each look names a fallback chain that ends in a DejaVu family bundled with matplotlib, so a missing font falls back silently.

  • plain: Anthropic Sans Text, Google Sans Flex, Arimo, Arial, DejaVu Sans.
  • lilaq: New Computer Modern, Palatino Linotype, P052, DejaVu Serif. Math uses matplotlib's bundled Computer Modern.
  • pgl: Overpass, Arial, Arimo, DejaVu Sans.

matplotlib knows New Computer Modern as NewComputerModern. From CTAN, install only the 10 pt cuts Typst uses: NewCM10-Regular.otf, NewCM10-Italic.otf, NewCM10-Bold.otf, NewCM10-BoldItalic.otf and NewCMMath-Regular.otf. The 8 pt and Book cuts register under the same family and weight, and matplotlib may pick any of them.

After installing a font, delete matplotlib's font cache so it rescans:

rm "$(python -c 'import matplotlib; print(matplotlib.get_cachedir())')"/fontlist-*.json

Check that a family resolves without falling back:

from matplotlib.font_manager import FontProperties, findfont

findfont(FontProperties(family="NewComputerModern"), fallback_to_default=False)

The themes set svg.fonttype: none, so an SVG names its font instead of embedding it. A viewer without the font shows a substitute. PNGs look the same everywhere.

Limits

  • matplotlib hard-codes the colors of table cells, quiver, barbs and spy. Palettes do not reach them.
  • rcParams cannot fix the size of the data area. The lilaq look's figure size gives about lilaq's 6 × 4 cm for a plot with a title and both axis labels.
  • matplotlib's scatter draws from its own color cycle, separate from plot.
  • Some cycle colors have under 3:1 contrast with their background, such as two petroff10 colors on lilaq-moon. scripts/gallery.py prints the ratios. Text has at least 4.5:1 in every palette, and a test checks it.

Development

uv sync
uv run pytest                          # includes image tests against tests/baseline/3.11
uv run ruff check && uv run ruff format --check
uv run python scripts/gallery.py       # regenerate assets/gallery
uv run python scripts/lilaq_parity.py  # compare with lilaq 0.6.0 in build/parity

CHANGELOG.md has the version history and the versioning rules.

License

MIT (see LICENSE).

Metadata

Release files for poikilos 1.0.0

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for poikilos 1.0.0
File Size Uploaded Provenance
poikilos-1.0.0.tar.gz 550.2 kB Without provenance Details

Built distribution (wheel)

Table of built distributions (wheels) for poikilos 1.0.0
File Interpreter ABI Platform
poikilos-1.0.0-py3-none-any.whl Python 3 none any Details

Total release size: 571.0 kB

Release files / poikilos-1.0.0.tar.gz

Download URL poikilos-1.0.0.tar.gz
Size 550.2 kB
Tags Source
SHA-256 checksum
How to use checksums
f12d48ee92f5290692a62799f0e3130a03c5a3cc5a354efb2b9de52a94e72d8b
BLAKE2b-256 checksum
How to use checksums
4f4d2ba750f49ea1fb8f3719898746eabc80eb848012543a97f68aa04ca2fcc4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Release files / poikilos-1.0.0-py3-none-any.whl

Download URL poikilos-1.0.0-py3-none-any.whl
Size 20.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
d5cafa072b435c3cfffcab3d1ea21b260367360b4c4c8db95596e6c8efb74526
BLAKE2b-256 checksum
How to use checksums
265d7f2d589eab4c41a1c5b06cd2d8718dd9542c2914e4fbe613e46ee1aa183d
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via uv/0.13.1 {"installer":{"name":"uv","version":"0.13.1","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"Ubuntu","version":"24.04","id":"noble","libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":true}

Security for poikilos 1.0.0

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

No provenance found

None of the files in this release were uploaded with provenance metadata.

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

Release history Release notifications | RSS feed

This release

1.0.0 This release

2 release files No provenance

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page