Skip to main content

figure-gate

CI PyPI

Two scripts that read a built matplotlib figure and report which gates it fails. audit(fig) returns (ok, rows) — 19 rows, one per gate, each a (label, status, detail) triple where status is True, False, or "warn". check(colors) does the same for a palette in 5 rows. Every threshold is a module-level constant you can read and change.

There is also an Agent Skill wrapper that applies the same checks when Claude Code builds a figure.

git clone https://github.com/narenp12/figure-gate && cd figure-gate
python skill/scripts/check_palette.py "#E69F00,#56B4E9,#009E73" --pairs all
python skill/scripts/check_figure.py     # self-test on a broken figure

Validation loss against training epoch for three optimisers. All three curves fall; the Bayesian run reaches 0.05 by epoch 6, the baseline is still at 0.25 at epoch 12.

python examples/demo.py builds that figure and audits it, with every decision commented against the failure it avoids. python examples/gallery.py covers the harder forms: a shared-axis grid, a filled field with a colorbar, an axis-free schematic, three statistical forms, a log-log convergence plot with a slope triangle, and a dense attractor. Writing those six found five defects in the checks themselves.

Why the two scripts talk to each other

A figure on matplotlib's default tab10 cycle, with a twinx second axis, returned 19 passing rows from the composition checks. check_palette.py rated that same cycle's orange and green at dE 1.4 under protanopia — one hue to that reader, against a floor of 8. The composition checker had no access to the colors it was drawing.

check_series_color closes that. It reads the hues off the figure's own artists, decides from the mark types whether the figure needs adjacent separation (lines, bars) or all-pairs separation (scatter), and runs them through the palette gates. That is row 11 of the 19.

What each gate measures

check_palette.py — takes hex strings on the command line or via check(), imports nothing outside the standard library. Distances are OKLab dE x100.

Gate Threshold Fails when
Lightness band L_MIN, L_MAX = 0.43, 0.77 OKLab lightness outside the band
Chroma floor CHROMA_MIN = 0.10 OKLab chroma below it — the color reads as gray
CVD separation CVD_TARGET = 8.0 two hues under 8 dE in protan or deutan simulation
Normal-vision floor NORMAL_FLOOR = 15.0 two hues under 15 dE in full color
Contrast vs surface CONTRAST_MIN = 3.0 a hue under 3:1 on the page (advisory)

--ordinal swaps those five rows for four that apply to a ramp: lightness monotone, adjacent dL gap, light-end contrast, and step uniformity (largest/smallest dL). Protanopia and deuteranopia are gated, together about 8% of males. Tritan separation is measured and printed in the detail string but not gated: prevalence is around 0.01%, and the Vienot matrix used here is validated only for the red-green forms, so the number is indicative rather than decisive.

check_figure.py — renders the figure through an Agg canvas and measures the result. audit() returns these 19 rows in this order.

Gate Threshold Fails when
Clipping canvas bounds a text artist's bbox extends past the canvas
Text collision bbox overlap two text bboxes overlap, tick labels on a shared axis exempted
Text readability TEXT_CONTRAST_MIN = 4.5 text misses WCAG AA against the backdrop it actually got, or data ink crosses its glyphs
Contrast stack ALPHA_LEVELS_MAX = 3 nothing in the figure is opaque, or transparency uses more than 3 distinct levels
Mark ratio MARK_RATIO_MAX = 5.0 largest data mark exceeds 5x the smallest by area
Overplotting OVERPLOT_THRESHOLD = 0.5 over half a scatter's points have a nearest neighbour inside one marker radius
Axis redundancy shared scale panels on a shared scale repeat tick labels or axis titles
Type size TYPE_FLOOR_PT = 7.5 a string renders under 7.5pt on the printed page
Line weight LINE_FLOOR_PT = 1.0 a stroke renders under 1pt on the printed page (SIAM's floor)
Ink coverage INK_MIN, INK_MAX = 0.02, 0.55 a panel's ink fraction falls outside the band (advisory)
Series color palette gates, MAX_SERIES_HUES = 6 the hues actually drawn fail CVD or normal-vision separation, or one panel carries more than 6
Dual axis a twinx second scale carries data of its own
Form pie, 3D, or bars on a truncated baseline
Identity channel two or more series, no legend and no text in the axes (advisory)
Label attribution LABEL_MARGIN = 2.0 a label's nearest other series is closer than 2x its distance to the one it names
Style sheet 40 keys the rcParams in effect differ from figure.mplstyle (advisory)
Contour dash a signed contour set dashes its negative levels
Fonts Type 42 PDF/PS export would embed Type 3, or no named typeface resolves (advisory)
Alt text ALT_TEXT_MIN_CHARS = 60 no description is attached, or the attached one is under 60 characters (advisory)

Thresholds cite a published floor where one exists — SIAM's one point, WCAG's 4.5:1, the Nature/Science/PNAS type minima. The rest were measured, and skill/references/style-guide.md records the measurement and the figure that motivated each one.

The gate that catches people

Type size is measured on the printed page, not on the canvas. A figure authored at 14 inches (1008pt) and placed on a 750pt slide renders at 750/1008 = 0.74x, so a 9pt label arrives at 6.7pt — under the 7.5pt floor, and under it by an amount no one notices on a monitor. page_scale derives that ratio per figure and the type gate measures what renders, so sizes set through rcParams or by a helper are caught along with the ones set inline.

Placing at a fraction of the content width changes the ratio again: audit(fig, placed_frac=0.48) mirrors \includegraphics[width=0.48\textwidth], and without it a half-width figure is certified at twice the type size it ships at. For a venue in the table, audit(fig, venue="neurips") supplies the width; python check_figure.py --venues prints all twelve with their content widths in points.

Install

Copy three files. check_palette.py needs only the standard library; check_figure.py needs matplotlib; scipy is optional and changes only speed (check_overplotting uses a KD-tree when scipy imports and an O(n^2) numpy path when it does not).

git clone https://github.com/narenp12/figure-gate
cp figure-gate/skill/assets/figure.mplstyle       your-project/diagrams/
cp figure-gate/skill/scripts/check_palette.py     your-project/diagrams/
cp figure-gate/skill/scripts/check_figure.py      your-project/diagrams/

Installing instead of vendoring pins a version and puts the same two checkers on PATH as check-palette and check-figure:

uv add figure-gate          # or: uv tool install figure-gate

Copying is the default because a vendored checker is one you can read and edit beside the figures it gates, and the thresholds are meant to be edited.

Two settings need your document's values:

  1. font.serif in figure.mplstyle — your document's body typeface.
  2. CONTENT_WIDTH_PT at the top of check_figure.py — the usable width of the page, in points. Left None, the scale is 1.0 and the page calculation does nothing, which is correct if you author each figure at the width it is placed at. venue= overrides it per call.

As a Claude Code skill

cp -r figure-gate/skill ~/.claude/skills/research-figures

Claude then runs these checks when you ask for a figure for a paper or a deck. skill/SKILL.md is the workflow; skill/references/style-guide.md is the measurement behind each threshold.

Use it

from pathlib import Path

import numpy as np
import matplotlib
matplotlib.use("agg")
import matplotlib.pyplot as plt
from matplotlib import colormaps

from check_figure import report

plt.style.use(str(Path(__file__).parent / "figure.mplstyle"))

x = np.linspace(0, 12, 300)
y = np.exp(-0.12 * x)

okabe = colormaps["okabe_ito"]          # matplotlib >= 3.11
fig, ax = plt.subplots(figsize=(7, 4), constrained_layout=True)
ax.plot(x, y, color=okabe(1))           # line width comes from the sheet

report(fig, "my-figure")                # prints 19 rows, returns True if ok

Two details that are load-bearing. The style sheet is resolved relative to __file__, because plt.style.use("figure.mplstyle") resolves against the working directory and breaks the first time a test runner or build script invokes the module from elsewhere. And the backend is set explicitly: the checks measure rendered geometry, and the examples in this repository all call matplotlib.use("agg") before importing pyplot for that reason.

audit is the same checks without the printing, which is what a test wants:

from check_figure import audit

@pytest.mark.parametrize("name", sorted(FIGURES))
def test_figure_is_composed(name):
    ok, rows = audit(build(name))
    assert ok, "\n".join(f"{k}: {d}" for k, s, d in rows if not s)

Where this sits

Prescriptive style sheets already exist and are good: SciencePlots and LovelyPlots for journal looks, tueplots and mpl_sizes for exact conference sizing. Accessibility tooling exists too: matplotalt generates alt text, Chart4Blind converts a chart image into an accessible one, contrast reporters check colors in isolation.

Each of those acts before or beside the figure. None of them reads the built result and reports what it fails, which is the only thing here — so a style sheet and this are complementary: set defaults with one, verify them with the other.

What it does not do

Every check is an elimination gate: each one forbids a single enumerated failure, and none looks at the figure as a whole. 19 passing rows means the figure avoids 19 named defects. It does not mean the figure is good, and the checker cannot see that an arrow points at the wrong object, that reading order runs backwards, or that a label is true of the concept and false of the curve beside it. Render it and look at it.

The rules also do not transfer to interactive web charts, where hover, responsive reflow and dark mode change most of the constraints.

Design notes

Use what matplotlib ships. viridis for sequential, RdBu for diverging, okabe_ito for categorical, style sheets for defaults, constrained_layout for layout. Earlier versions hand-rolled all four and each was worse: RdBu's poles clear every gate in check_palette.py unmodified, and a windowed custom ramp discarded 35% of viridis for no measured gain.

WARN is not FAIL. Five rows are advisory. A sub-3:1 hue is legal when it carries a direct label; a heatmap panel legitimately measures 0.98 ink coverage. Failing those would train people to ignore the row, and an ignored gate is worth less than no gate.

Gates are tested for their ability to fail. The suite is 166 tests, and each check has one asserting it catches a figure with exactly that defect. The style sheet has its own tests because # starts a comment in matplotlib's style format: grid.color: #e1e0d9 parses as an empty value, matplotlib keeps its default, and every other test stays green.

The full reasoning — measurements behind each threshold, and the rules that were tried and reverted — is in skill/references/style-guide.md.

Which form the data wants is the decision no styling rule rescues, and it is in skill/references/choosing-a-form.md, built on Cleveland & McGill's ordering of the elementary perceptual tasks. Only its mechanical subset is gated: a script can rule out a pie or a truncated bar baseline, but it cannot tell you a box plot is hiding an n of 8.

Requirements

  • check_palette.py — Python 3.8+, standard library only. Tested on 3.8, 3.9, 3.11 and 3.13. Copied, it runs on 3.8; installed from PyPI it does not, because the package carries check_figure.py too and that needs 3.9.
  • check_figure.py — Python 3.9+, matplotlib 3.8+.
  • colormaps["okabe_ito"] needs matplotlib 3.11+. On older versions the palette is eight hex strings, listed in the style guide.

Those are the versions CI runs the palette checker on, with no pip install at all, because "standard library only" is a load-bearing claim. The test job runs against the current matplotlib and pins one row to 3.8.4, so a break in either direction shows up.

Contributing

New gates are welcome at the bar the project holds itself to: a test proving the gate fails on a figure with that defect, a test proving it does not over-fire on the nearest legitimate case, and a note naming the real failure that motivated it. See CONTRIBUTING.md and SECURITY.md.

License

MIT — see LICENSE.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

figure_gate-0.1.2.tar.gz (1.4 MB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

figure_gate-0.1.2-py3-none-any.whl (40.6 kB view details)

Uploaded Python 3

File details

Details for the file figure_gate-0.1.2.tar.gz.

File metadata

  • Download URL: figure_gate-0.1.2.tar.gz
  • Upload date:
  • Size: 1.4 MB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for figure_gate-0.1.2.tar.gz
Algorithm Hash digest
SHA256 1974a7ecae3996f0fc1360c5ec1a6828f8521ae9c14ee1062e4b85b3769fd42b
MD5 f500ac953aa4d4c072e5d45adabe6d6d
BLAKE2b-256 500e520a35a51a6b713afaa8a7caeaa5700788d4adf575f552b6336b389ffe47

See more details on using hashes here.

Provenance

The following attestation bundles were made for figure_gate-0.1.2.tar.gz:

Publisher: release.yml on narenp12/figure-gate

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file figure_gate-0.1.2-py3-none-any.whl.

File metadata

  • Download URL: figure_gate-0.1.2-py3-none-any.whl
  • Upload date:
  • Size: 40.6 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.14

File hashes

Hashes for figure_gate-0.1.2-py3-none-any.whl
Algorithm Hash digest
SHA256 9e71d27be1dc5ac017dfd9861eb4cb54a517c6f2d1dbfc7481e0d0846b2de475
MD5 c16e93f28cc3d4ed966687db566883dc
BLAKE2b-256 28c05b72d567667aa24162d2440ec784c1469358c4fcfb02019a5d2362994642

See more details on using hashes here.

Provenance

The following attestation bundles were made for figure_gate-0.1.2-py3-none-any.whl:

Publisher: release.yml on narenp12/figure-gate

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Release history Release notifications | RSS feed

0.8.0

2 files

0.7.0

2 files

0.6.0

2 files

0.5.0

2 files

0.4.0

2 files

0.3.0

2 files

0.2.0

2 files

0.1.4

2 files

0.1.3

2 files

This release

0.1.2 This release

2 files

0.1.1

2 files

0.1.0

2 files

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