API¶
Signatures and docstrings are read out of skill/scripts/ when this page
builds, so what is shown is what the code has.
Installed, the three modules are a package:
from figure_gate import check_figure as cf # needs matplotlib
from figure_gate import check_palette as cp # standard library only
from figure_gate import suggest_fixes as sf
Vendoring, the default route, copies the files into your own project. They are then whatever you named them, imported flat:
Same modules, same signatures; only the import line differs. Everything below applies to both.
Not on this page: the thresholds. They are module-level constants, one
table per module on the gates, with the measurement behind each on
the style guide. Read them as cf.TYPE_FLOOR_PT.
Everything else is here, including the 21 gate functions, at the bottom.
audit is what most callers want: it runs all 21 and computes the renderer,
canvas and scale arguments they take. Call one directly and that is yours to
reproduce, which the how-to covers.
check_figure¶
Composition. Takes a built figure, measures what it renders at print size.
check_figure.audit
¶
audit(fig: Figure, scale: float | None = None, placed_frac: float = 1.0, *, context_axes: Sequence[Axes] | None = None, venue: str | None = None) -> tuple[bool, list[tuple[str, bool | str, str]]]
Run every gate over a figure. Returns (ok, rows).
check_palette.check returns the same shape. It returned (rows, ok)
until 0.4.0, and unpacking either one the wrong way binds a bool to the
rows and raises nothing at the call site, which is why they were made to
agree rather than documented as differing.
rows are (label, status, detail), one per gate, in the order the report
prints them. status is True, False, or the string "warn"; only a hard
False sets ok to False, so an advisory row reports without gating a
build.
placed_frac is the fraction of the content width the figure is placed at,
and venue names a row of VENUE_WIDTH_PT instead of setting
CONTENT_WIDTH_PT by hand; between them they decide the page scale every
type and stroke measurement runs through. scale overrides that
calculation outright. context_axes names axes whose fill is a context
surface rather than data ink, which is what stops a filled contourf panel
reading as saturated.
context_axes and venue are keyword-only. They were positional until
0.9.0, and a venue passed in the context_axes slot was iterated into a
frozenset of axes ids rather than raising, because a string is iterable.
The venue was discarded and the figure was measured at the wrong width: a
wrong verdict, reported green, from an argument order. Keyword-only is the
only shape in which that call cannot be written.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fig
|
Figure
|
The built figure. Measured through an Agg canvas at |
required |
scale
|
float | None
|
Points per authored inch, overriding |
None
|
placed_frac
|
float
|
Fraction of the content width the figure is placed at. |
1.0
|
venue
|
str | None
|
A key of |
None
|
context_axes
|
Sequence[Axes] | None
|
Axes whose fill is a context surface, not data ink. |
None
|
Returns:
| Type | Description |
|---|---|
bool
|
|
list[tuple[str, bool | str, str]]
|
report order; |
Source code in skill/scripts/check_figure.py
check_figure.report
¶
report(fig: Figure, name: str = '', scale: float | None = None, placed_frac: float = 1.0, *, context_axes: Sequence[Axes] | None = None, venue: str | None = None, suggest: bool = False) -> bool
audit(), printed. Returns the same ok bool and nothing else.
The arguments are audit's, plus name for the heading. Advisory rows
print as WARN and do not change the verdict, so a figure can be COMPOSED
with advisories; only a hard FAIL makes this return False.
suggest=True prints what suggest_fixes.py has to offer for the marked
rows, under the table. Off by default and kept in that file rather than
this one: a gate measures, and what to do about it is a separate claim that
can be wrong on its own. Several rows get more than one suggestion, because
the choice between them is the author's.
This is what the examples and the CLI call. Use audit() when the rows
themselves are wanted rather than a printed table.
context_axes, venue and suggest are keyword-only, matching audit.
They were positional until 0.9.0, and a venue passed in the
context_axes slot was iterated into a frozenset of axes ids rather than
raising, because a string is iterable. The venue was discarded and the
figure was measured at the wrong width: a wrong verdict, reported green,
from an argument order. Keyword-only is the only shape in which that call
cannot be written.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fig
|
Figure
|
The built figure. |
required |
name
|
str
|
A heading for the table. |
''
|
scale
|
float | None
|
Points per authored inch, overriding |
None
|
placed_frac
|
float
|
Fraction of the content width the figure is placed at. |
1.0
|
venue
|
str | None
|
A key of |
None
|
context_axes
|
Sequence[Axes] | None
|
Axes whose fill is a context surface, not data ink. |
None
|
suggest
|
bool
|
Print |
False
|
Returns:
| Type | Description |
|---|---|
bool
|
|
Source code in skill/scripts/check_figure.py
check_figure.describe
¶
Attach a text description to a figure, for readers who cannot see it.
Across 100,000 public Jupyter notebooks, 99.81% of programmatically
generated images shipped with no alt text at all, and the overwhelming
majority of them were matplotlib. Matplotlib has no field for this, so the
description is stashed on the figure and handed to savefig:
describe(fig, "Validation loss against training epoch for three "
"optimisers. All three fall; the Bayesian run reaches "
"0.05 by epoch 6, the baseline is still at 0.25 at 12.")
fig.savefig(path, metadata=alt_metadata(fig, path))
Say what the reader would have taken from looking, not what the figure is made of. "A line chart with three lines" describes the file; the numbers and the direction describe the finding.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fig
|
Figure
|
The figure to attach the description to. |
required |
text
|
str
|
The description. |
required |
Returns:
| Type | Description |
|---|---|
None
|
|
Source code in skill/scripts/check_figure.py
check_figure.alt_metadata
¶
The metadata= dict for savefig, carrying whatever describe set.
Pass the same path you are about to save to, so the description lands in
a field the target format actually has:
fig.savefig(path, metadata=alt_metadata(fig, path))
PNG, PDF and SVG all keep a description and none of them agrees with the others about what to call it, so the key is chosen from the suffix.
For a format that carries no description -- ps, or any of the rasters --
this returns None rather than an empty dict, and the difference is not
cosmetic. matplotlib's guard is elif metadata is not None: raise, so an
empty dict is rejected exactly as hard as a full one; savefig(path,
metadata={}) on a jpeg is a traceback. None is savefig's own default
for the argument and the only value those formats accept.
Called without a path, or with a buffer whose format cannot be read, it
returns Description. That is right for PNG and SVG and is what every
earlier version returned unconditionally.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fig
|
Figure
|
The figure |
required |
path
|
Any
|
The path about to be saved to. The suffix picks the key. |
None
|
Returns:
| Type | Description |
|---|---|
dict[str, str] | None
|
A dict for |
dict[str, str] | None
|
no description. Empty when |
Source code in skill/scripts/check_figure.py
check_figure.page_scale
¶
Scale from authored inches to points on the page.
placed_frac is the fraction of the content width the figure is placed at,
so it reads like the call site: \includegraphics[width=0.48\textwidth]
is placed_frac=0.48. Without it every figure is measured as if it were
full width, and a half-width figure is certified at twice the type size it
actually ships at - which is the wrong direction for a legibility gate to
be wrong in.
venue names a row of VENUE_WIDTH_PT and overrides CONTENT_WIDTH_PT for
this call, which is the usual way in: the width is a property of the
document, not of the checkout.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
fig
|
Figure
|
The figure, read for its authored width in inches. |
required |
placed_frac
|
float
|
Fraction of the content width the figure is placed at. |
1.0
|
venue
|
str | None
|
A key of |
None
|
Returns:
| Type | Description |
|---|---|
float
|
Points on the page per authored inch. |
float
|
set, which measures the figure at the size it was authored. |
Raises:
| Type | Description |
|---|---|
ValueError
|
|
Source code in skill/scripts/check_figure.py
check_figure.content_width_pt
¶
The usable page width to measure against, in points.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
venue
|
str | None
|
A key of |
None
|
Returns:
| Type | Description |
|---|---|
float | None
|
The width in points, or |
Raises:
| Type | Description |
|---|---|
KeyError
|
The venue is not in |
Source code in skill/scripts/check_figure.py
check_figure.scatter_diameter_pt
¶
The diameter in points that scatter(s=size) actually draws.
matplotlib documents s as "the marker size in points**2", which reads as
an area and is not one. The unit marker path for 'o' is a circle of radius
0.5, and scatter scales it by sqrt(s): the drawn diameter is sqrt(s)
points and the drawn area is pi * s / 4, a factor 4/pi below the nominal
number.
Measured rather than inferred. At 200 dpi, scatter(s=100) and
plot(markersize=10) each lay down 741 pixels of ink, which is what makes
markersize and sqrt(s) the same quantity and s an area only up to
that constant. test_scatter_size_is_a_squared_diameter pins it against
matplotlib rather than against this docstring.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
size
|
float | ndarray
|
The value passed to |
required |
Returns:
| Type | Description |
|---|---|
float | ndarray
|
The drawn diameter in points, |
float | ndarray
|
|
Public because both check_mark_ratio and check_overplotting decide on
it.
Source code in skill/scripts/check_figure.py
check_palette¶
Colour. Standard library only, so these are also the functions to port when the checks are reimplemented elsewhere.
check_palette.check
¶
check(colors: Sequence[str], surface: str = '#ffffff', all_pairs: bool = False, ordinal: bool = False, ink: Collection[str] = frozenset()) -> tuple[bool, list[tuple[str, bool | str, str]]]
Gate a palette. Returns (ok, rows).
The order matches check_figure.audit. It did not until 0.4.0: this
returned (rows, ok) and the README carried a paragraph warning about the
difference, which is documentation standing in for a fix. Unpacking either
one the wrong way binds a bool to the rows and raises nothing, so the two
were made the same rather than described.
rows are (name, status, detail), one per gate. status is True,
False, or the string "warn" for the advisory contrast row, and only a
hard False sets ok to False.
colors are hex strings. surface is the page they are drawn on and sets
what the contrast row measures against. all_pairs gates every pair rather
than adjacent ones, which is what a scatter needs and a line chart does
not. ordinal swaps the categorical separation rows for the ramp rows:
monotone lightness, even steps, a light end that still holds contrast.
ink names colours to treat as furniture rather than data.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
colors
|
Sequence[str]
|
Hex strings, the palette to gate. |
required |
surface
|
str
|
The page colour they are drawn on. |
'#ffffff'
|
all_pairs
|
bool
|
Gate every pair rather than adjacent ones. |
False
|
ordinal
|
bool
|
Swap the categorical rows for the ramp rows. |
False
|
ink
|
Collection[str]
|
Colours to treat as furniture rather than data. |
frozenset()
|
Returns:
| Type | Description |
|---|---|
bool
|
|
list[tuple[str, bool | str, str]]
|
|
tuple[bool, list[tuple[str, bool | str, str]]]
|
row is a hard False. |
Source code in skill/scripts/check_palette.py
679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 798 799 800 801 802 803 804 805 806 807 808 809 810 811 812 813 814 815 816 817 818 819 820 821 | |
check_palette.cmap_kind
¶
Classify hex samples as qualitative, sequential, diverging,
cyclic or misc.
misc is the failure: the lightness reverses, or its span is flat, or its
halves are monotone and its ends match neither cyclic nor diverging. A
colormap in that state encodes nothing the reader can order.
Fewer than CMAP_QUALITATIVE_N = 40 samples is read as a set of category
colours rather than a ramp, and gated on separation instead.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
samples
|
Sequence[str]
|
Hex strings sampled along the colormap, in order. |
required |
Returns:
| Type | Description |
|---|---|
str
|
One of |
str
|
|
Source code in skill/scripts/check_palette.py
check_palette.cmap_kind_rgb
¶
cmap_kind on float sRGB. See cmap_back_travel_rgb for why.
Source code in skill/scripts/check_palette.py
check_palette.cmap_back_travel
¶
How much of a ramp's lightness runs backwards, as a fraction of its span.
0.0 is monotone. CMAP_BACKTRAVEL_MAX = 0.02 is where a ramp stops
counting as ordered, because lightness that reverses makes two different
values render at the same lightness.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
samples
|
Sequence[str]
|
Hex strings sampled along the ramp, in ramp order. |
required |
Returns:
| Type | Description |
|---|---|
float
|
Backward lightness travel as a fraction of the ramp's span. 0.0 is |
float
|
monotone. |
Source code in skill/scripts/check_palette.py
check_palette.cmap_back_travel_rgb
¶
cmap_back_travel on float sRGB, without the 8-bit round trip.
The hex API rounds every channel to 1/255 before lightness is computed. On
a smooth ramp those roundings are oscillations of about 0.001 OKLab that
accumulate into the measure: winter reads 0.0343 through hex against
0.0139 in float, which straddles the 0.02 floor and made a sequential map
classify misc. Maps that genuinely reverse are unaffected, because their
reversals are orders of magnitude larger than the rounding.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
samples
|
Sequence[tuple[float, float, float]]
|
|
required |
Returns:
| Type | Description |
|---|---|
float
|
Backward lightness travel as a fraction of the ramp's span. |
Source code in skill/scripts/check_palette.py
check_palette.contrast
¶
WCAG contrast ratio between two hex colours, from 1.0 to 21.0.
The floors it is compared against: 4.5 for text, 3.0 for a hue against the surface it sits on.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
hex_a
|
str
|
A |
required |
hex_b
|
str
|
The colour to measure it against. |
required |
Returns:
| Type | Description |
|---|---|
float
|
The ratio, 1.0 (identical) to 21.0 (black on white). |
Source code in skill/scripts/check_palette.py
check_palette.delta_e
¶
CAM02-UCS colour difference between two linear-light colours.
Euclidean distance in CAM02-UCS, which is the space's intended use: Luo,
Cui & Li (2006) fitted it so that this distance predicts perceived
difference, so the number carries a unit somebody measured. CVD_TARGET
and NORMAL_FLOOR are quoted in it.
Not a CIELAB dEab and not a CIEDE2000. Over the gamut this file gates as series slots, one CAM02-UCS unit is a median 1.99 CIELAB dEab, but the ratio runs 1.72-2.24 across the interquartile range, so the two are not interconvertible at a fixed rate and a threshold quoted in one does not transfer to the other.
Before 0.8.0 this returned OKLab distance x100. See the CIECAM02 section above for why that number could not support a threshold.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rgb_a
|
Sequence[float]
|
Linear-light |
required |
rgb_b
|
Sequence[float]
|
The colour to measure it against. |
required |
Returns:
| Type | Description |
|---|---|
float
|
CAM02-UCS euclidean distance. |
Source code in skill/scripts/check_palette.py
check_palette.simulate
¶
Linear-light RGB as a "protan", "deutan" or "tritan" viewer sees it.
Vienot, Brettel & Mollon (1999), applied on linear light. Protan and deutan are what the gates decide on; the tritan matrix is validated only for the red-green forms, so its distances are printed and never gated.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rgb
|
Sequence[float]
|
Linear-light |
required |
kind
|
str
|
|
required |
Returns:
| Type | Description |
|---|---|
tuple[float, float, float]
|
Linear-light |
Source code in skill/scripts/check_palette.py
check_palette.simulate_anomalous
¶
Linear-light RGB as an anomalous trichromat of this severity sees it.
kind is "protan" or "deutan". severity runs 0.0 (normal vision) to
1.0 (dichromacy) and is read at the nearest tenth, which is where Machado,
Oliveira & Fernandes publish the table. No interpolation between two
published matrices, because that is a modelling claim the paper does not
make and this file would then be asserting.
simulate remains the dichromacy model and the anchor for every number the
style guide quotes. This is the range between the two ends, which is where
most colour vision deficiency actually sits.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rgb
|
Sequence[float]
|
Linear-light |
required |
kind
|
str
|
|
required |
severity
|
float
|
0.0 (normal vision) to 1.0 (dichromacy), read at the nearest tenth. |
required |
Returns:
| Type | Description |
|---|---|
tuple[float, float, float]
|
Linear-light |
Source code in skill/scripts/check_palette.py
check_palette.hex_to_linear
¶
A #rrggbb string as linear-light RGB, each channel in 0..1.
Linear light, not the 0..255 the hex digits carry: every distance and luminance below is defined on it, and averaging or mixing gamma-encoded values is the usual source of a wrong answer that looks plausible.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
h
|
str
|
A |
required |
Returns:
| Type | Description |
|---|---|
tuple[float, float, float]
|
|
Source code in skill/scripts/check_palette.py
check_palette.oklab_distance
¶
OKLab distance between two linear-light colours, x100.
What delta_e measured before 0.8.0, kept because the lightness rows still
reason in OKLab and because a caller comparing against a number this project
published earlier needs the old scale to do it.
It is not a colour difference in any calibrated sense: OKLab was fitted for
hue uniformity, not for discrimination, so distances in it have no published
referent. Gate on delta_e.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rgb_a
|
Sequence[float]
|
Linear-light |
required |
rgb_b
|
Sequence[float]
|
The colour to measure it against. |
required |
Returns:
| Type | Description |
|---|---|
float
|
OKLab euclidean distance, x100. |
Source code in skill/scripts/check_palette.py
check_palette.linear_to_oklab
¶
Linear-light RGB as OKLab (L, a, b).
OKLab rather than CIELAB because its lightness tracks perceived lightness
across hues, which is what every gate here asks about. l, m, s below are
the cone responses the published matrix names, not a lint slip.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rgb
|
Sequence[float]
|
Linear-light |
required |
Returns:
| Type | Description |
|---|---|
tuple[float, float, float]
|
|
Source code in skill/scripts/check_palette.py
check_palette.linear_to_cam02ucs
¶
Linear-light RGB as CAM02-UCS (J', a', b').
The space delta_e measures in. Euclidean distance here is a perceived
colour difference in the units Luo, Cui & Li (2006) fitted, which is what
lets the floors in this file cite a measurement instead of a preference.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rgb
|
Sequence[float]
|
Linear-light |
required |
Returns:
| Type | Description |
|---|---|
tuple[float, float, float]
|
|
Source code in skill/scripts/check_palette.py
check_palette.relative_luminance
¶
WCAG relative luminance of linear-light RGB.
The WCAG coefficients, and deliberately not OKLab's L: the contrast
ratios this project cites are defined against this number, so computing
them from a perceptual lightness would report figures no standard backs.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rgb
|
Sequence[float]
|
Linear-light |
required |
Returns:
| Type | Description |
|---|---|
float
|
Relative luminance in 0..1, by the WCAG coefficients. |
Source code in skill/scripts/check_palette.py
suggest_fixes¶
Remedies. Both take the rows audit returned, not the figure.
suggest_fixes.suggest
¶
Remedies for the rows of an audit that are not passing.
Takes rows rather than the figure: what is on offer depends on which
gates fired, and nothing here needs to look at the figure again. Returns
[(gate_name, [Remedy, ...]), ...] in the order the gates reported, and
skips gates with nothing to offer rather than padding them with a
restatement of the failure.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rows
|
Sequence[tuple[str, bool | str, str]]
|
|
required |
Returns:
| Type | Description |
|---|---|
list[tuple[str, list[Remedy]]]
|
|
list[tuple[str, list[Remedy]]]
|
Empty when nothing fired that this file has an answer for. |
Source code in skill/scripts/suggest_fixes.py
suggest_fixes.format_suggestions
¶
suggest, as lines ready to print under a report.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
rows
|
Sequence[tuple[str, bool | str, str]]
|
|
required |
indent
|
str
|
Leading whitespace for each line. |
' '
|
Returns:
| Type | Description |
|---|---|
list[str]
|
A list of lines. Empty when nothing fired that this file has an |
list[str]
|
answer for. |
Source code in skill/scripts/suggest_fixes.py
The gate functions¶
One row each, in the order audit runs them, which is the order
the gates tables them in and the order a report prints. Every one
returns (status, detail), where status is True, False or "warn".
The signature says what the gate needs beyond the figure. A r parameter is a
renderer, canvas an already-drawn canvas, and scale/placed_frac/venue
the page arithmetic. audit supplies all of them.
check_figure.check_clipping
¶
Text that runs off the canvas.
Reads the axis-aligned box and is correct to: an AABB is the bounding box
of the oriented box's own corners, so its extremes are attained by real
corners of the label and a min/max test against the canvas gives the same
answer either way. Rotation costs this gate nothing, and only
check_collisions had to change, because two AABBs can overlap where the
boxes inside them do not.
Source code in skill/scripts/check_figure.py
check_figure.check_collisions
¶
Text-on-text overlap. Tick labels on a shared axis are exempt: matplotlib lays those out itself and a 1px touch there is not a defect.
Compared as oriented boxes, because the axis-aligned one is 5x the ink at 45 degrees and the extra area is two empty triangles. Two parallel oblique labels with clear page between them collided on that box, which is the shape of false positive that teaches people to skim the row.
Source code in skill/scripts/check_figure.py
check_figure.check_text_readability
¶
check_text_readability(fig: Figure, r: Any, canvas: Any = None, scale: float | None = None, placed_frac: float = 1.0, venue: str | None = None) -> tuple[bool | str, str]
Whether each string can be read where it sits.
check_label_attribution asks which curve a label belongs to. This asks the
prior question — whether the label is legible at all — and the two come
apart hard: a label printed on its own curve is attributed perfectly, and
is read through the line crossing its letterforms.
Both clauses are measured off rendered pixels, because both depend on what happened to land behind the glyphs and no artist knows that about itself. The figure is drawn a second time with every string hidden; that render is the backdrop each label was placed onto.
Clutter. Inside a label's box the backdrop should be one surface. Pixels that no blend of {that surface, the grid, the axis rule} explains are data ink passing through the text — a curve, a marker, a spike between the strokes. Measuring the backdrop rather than the finished render is the whole trick: casing hides the evidence, because a white halo over an orange curve renders as clean white while punching a visible gap through the data. Both halves of that are defects and this sees them as one number.
Uniform data ink is not clutter. A label on a heatmap cell has the cell as its surface, so it is the contrast clause that governs there, which is the correct division: a flat fill is a background, a curve is not.
Contrast. The text against the backdrop it actually got, at the WCAG text threshold (4.5:1, or 3:1 for large text) rather than the 3:1 mark threshold, because a glyph stem is thinner than a mark. Casing counts: a black label with a white halo on a dark field is read against the halo.
Both clauses are measured over the pixels the label covers, not over the box
that contains it. A 45-degree string reports an axis-aligned extent five
times its own ink, and the extra area is two triangles nothing is drawn in;
strokes laid in one of them read as ink on the label, and a dark field in one
of them sets the contrast verdict for a string sitting on light ground. See
_oriented_mask.
Tick labels are included. They sit outside the axes on most figures and cost nothing to check there, and on the figures where they do not — an inset, a twinned frame, a label moved inside — that is exactly where they get crossed.
Source code in skill/scripts/check_figure.py
1043 1044 1045 1046 1047 1048 1049 1050 1051 1052 1053 1054 1055 1056 1057 1058 1059 1060 1061 1062 1063 1064 1065 1066 1067 1068 1069 1070 1071 1072 1073 1074 1075 1076 1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 1187 1188 1189 1190 1191 1192 1193 1194 1195 1196 1197 1198 1199 1200 1201 1202 1203 1204 1205 1206 1207 1208 1209 1210 1211 | |
check_figure.check_contrast_stack
¶
A figure where nothing is at full opacity has no focal point, and a long tail of alpha values reads as haze rather than hierarchy.
Source code in skill/scripts/check_figure.py
check_figure.check_mark_ratio
¶
One mark far larger than the rest stops reading as a mark and starts reading as an ornament stuck on top of the plot.
Reads scatter sizes and line markers, both converted to the area of the disc actually drawn. Bars and other patches are deliberately NOT counted: a bar thirty times another bar is the encoding working, not a defect. This gate is about marks whose size is not carrying the value.
Both operands go through one conversion because the two APIs take different
quantities and neither is an area. markersize is a diameter in points.
scatter(s=...) is a squared diameter, not the area its own
documentation calls it - see scatter_diameter_pt. Converting one side and
not the other leaves a standing 4/pi = 1.27x error on any figure mixing
scatter with plot(marker=...), which is enough against a 5.0 threshold
to fail a legal figure at a true 3.9x and pass a bad one at 6.4x. That
error was fixed on the markersize side first and survived on the s side
until this change, where two marks of measurably identical drawn area -
741 pixels each - still reported 1.3x.
Source code in skill/scripts/check_figure.py
check_figure.check_overplotting
¶
Warn when scatter points overlap into an unreadable mass.
For each PathCollection with offsets (a scatter), the fraction of points whose nearest neighbour in display pixels sits closer than the two marks' radii summed - which is exactly when the two discs intersect on the page. Above the threshold the marks merge into a blob — thin the count, use hollow markers, add transparency, or switch to hexbin.
Two separate errors used to make this roughly 1.8x too lenient, and a
scatter of 64 discs each overlapping its neighbours by a quarter of their
diameter rendered as one solid square while the gate returned clean. The
radius came from sqrt(s / pi), treating s as an area it is not (see
scatter_diameter_pt), which is 12.8% too large; and the comparison was
against one radius rather than two, which is the condition for a mark's
centre to be swallowed rather than for the two marks to touch.
Nearest is the wrong neighbour to ask about once radii vary. Contact is
d < r_i + r_j, and the j that minimises d need not be the j that
maximises r_j, so a mark can clear its nearest neighbour and be swallowed
whole by a larger one further off. Measured on a fixture of 60 marks - 40
small ones in pairs 8px apart, each pair under a 120px disc centred 20px
away - every mark is in contact and 40 of them are not visible in the render
at all, while the 1-NN test reported 33% and the gate returned "no scatter
overplotting".
Where every mark is the same size, nearest is the right neighbour: r_i +
r_j is then a constant, so some mark is within it exactly when the nearest
one is, and the 1-NN query answers the question outright. That is the common
case and it keeps the cheap path. Where radii differ the marks are grouped
into radius octaves and each group is asked separately; see
_contact_fraction for why the bound has to be the group's largest radius
rather than the scatter's, and what enumerating candidate pairs at
2 * r_max cost on a figure with one oversized mark in it.
Source code in skill/scripts/check_figure.py
1490 1491 1492 1493 1494 1495 1496 1497 1498 1499 1500 1501 1502 1503 1504 1505 1506 1507 1508 1509 1510 1511 1512 1513 1514 1515 1516 1517 1518 1519 1520 1521 1522 1523 1524 1525 1526 1527 1528 1529 1530 1531 1532 1533 1534 1535 1536 1537 1538 1539 1540 1541 1542 1543 1544 1545 1546 1547 1548 1549 1550 1551 1552 1553 1554 1555 1556 1557 1558 1559 1560 1561 1562 1563 1564 1565 1566 1567 1568 1569 1570 | |
check_figure.check_redundancy
¶
Side-by-side panels on the same scale should share their axis furniture. Two identical tick columns and two identical axis labels is duplicated ink.
Source code in skill/scripts/check_figure.py
1573 1574 1575 1576 1577 1578 1579 1580 1581 1582 1583 1584 1585 1586 1587 1588 1589 1590 1591 1592 1593 1594 1595 1596 1597 1598 1599 1600 1601 1602 1603 1604 1605 1606 1607 1608 1609 1610 1611 1612 1613 1614 1615 1616 1617 1618 1619 1620 1621 1622 1623 1624 1625 1626 1627 1628 1629 1630 1631 1632 1633 | |
check_figure.check_type_size
¶
check_type_size(fig: Figure, r: Any, scale: float | None = None, placed_frac: float = 1.0, venue: str | None = None) -> tuple[bool | str, str]
Every rendered string clears the legibility floor once the figure is scaled into the document.
This used to be a regex over the source file hunting for fontsize=, which
missed anything set through rcParams, anything computed, and anything set by
a helper. Reading get_fontsize() off the artists that actually rendered
reports what is on the page instead of what is in the source.
Source code in skill/scripts/check_figure.py
check_figure.check_line_weight
¶
check_line_weight(fig: Figure, scale: float | None = None, placed_frac: float = 1.0, venue: str | None = None) -> tuple[bool | str, str]
Every drawn stroke against the printer's floor, measured ON THE PAGE.
SIAM states it plainly in its instructions for authors: illustrations must
use lines one point or thicker, because thinner lines break up or disappear.
It is the same failure as the type floor and it has the same cause — a
stroke authored at 0.8pt in a 9-inch figure placed at 5.5 inches prints at
0.49pt — so it is measured the same way, through page_scale.
Furniture is held to a lower floor than data. A gridline that drops out at the printer costs the reader a reference; a data curve that drops out costs them the finding. The sheet ships the grid at 0.7pt deliberately, and failing it against the data floor would be failing the sheet's own design.
Source code in skill/scripts/check_figure.py
2745 2746 2747 2748 2749 2750 2751 2752 2753 2754 2755 2756 2757 2758 2759 2760 2761 2762 2763 2764 2765 2766 2767 2768 2769 2770 2771 2772 2773 2774 2775 2776 2777 2778 2779 2780 2781 2782 2783 2784 2785 2786 2787 2788 2789 2790 2791 2792 2793 2794 2795 2796 2797 2798 2799 2800 2801 2802 2803 2804 2805 2806 2807 2808 2809 2810 2811 2812 2813 | |
check_figure.check_banking
¶
Whether a panel's aspect ratio lets the reader compare rates of change.
Cleveland banks a panel to 45 degrees: choose the height-to-width ratio that puts the median absolute segment slope at 1, because slope discrimination is most accurate near 45 and falls away either side of it. A cycle that is plain in one aspect ratio is invisible in another, and the wrong one is usually the one the default produced.
The failure is a resolution failure, and it is measurable. On a saw wave whose decay limbs alternate between two rates, one exactly twice the other: at 2.4 x 5.2 inches the two limbs land 1.6 degrees apart on the page and the alternation cannot be seen at all; at 6.4 x 1.9 they land 10.6 degrees apart and it is the first thing you see. Same data, same axes, same limits.
Advisory, and loose, because the right aspect is a judgement about what the
reader's job is and this cannot know that. BANKING_SLOPE_MAX is a factor
of ten either side of banked - a typical segment steeper than 84 degrees or
flatter than 6 - which is a panel essentially vertical or essentially flat
over its own typical step. See _banking_slopes for what is excluded and
why; a scatter, a map at fixed aspect and a parametric curve are all cases
where the median slope means nothing.
Source code in skill/scripts/check_figure.py
check_figure.check_ink
¶
check_ink(fig: Figure, context_axes: Sequence[Axes] | None = None, canvas: Any = None) -> tuple[bool | str, str]
Ink as a fraction of each plotting area, measured off the rendered pixels rather than estimated from artist properties.
Near zero means a panel that did not need to be a panel. Very high means a panel with no ground left in it. Reported per-axes, and advisory only: the right density genuinely depends on the form, so this flags panels worth a second look rather than declaring them wrong.
Pass context_axes — a list of Axes whose fill is a context surface (e.g. a
contourf landscape) rather than data-ink. For those axes, the ink fraction
measures only marks ON TOP of the surface by separating the pixel values
into two clusters (k-means with k=2) and removing the larger cluster (the
surface). A figure with a filled terrain plus a few sparse marks will PASS
rather than WARN.
Pass canvas — an already-drawn Agg canvas — to avoid a second render.
Source code in skill/scripts/check_figure.py
1685 1686 1687 1688 1689 1690 1691 1692 1693 1694 1695 1696 1697 1698 1699 1700 1701 1702 1703 1704 1705 1706 1707 1708 1709 1710 1711 1712 1713 1714 1715 1716 1717 1718 1719 1720 1721 1722 1723 1724 1725 1726 1727 1728 1729 1730 1731 1732 1733 1734 1735 1736 1737 1738 1739 1740 1741 1742 1743 1744 1745 1746 1747 1748 1749 1750 1751 1752 1753 1754 1755 1756 1757 1758 1759 1760 1761 1762 1763 1764 1765 1766 1767 1768 1769 1770 1771 1772 1773 1774 1775 1776 1777 1778 1779 1780 1781 1782 1783 1784 | |
check_figure.check_series_color
¶
The hues actually drawn, put through the palette gates.
The hole this closes: check_palette.py judges a list of hexes someone
remembered to paste into a terminal, and this file never looked at color at
all. A figure on matplotlib's default tab10 cycle - whose orange and green
measure CAM02-UCS dE 2.4 under protanopia, against a floor of 10.5 - passed
every composition check
clean. Two scripts in one project that never spoke.
Only what is never legitimate is gated: separation under color blindness, and separation in normal vision. The lightness-band and chroma-floor rows are deliberately not applied to harvested colors. A black or gray series is legal - a reference curve, a control group - and failing it is precisely the noise that teaches people to skim past the row.
Scoped per panel. The comparison, the hue count and the all-pairs mode are all asked of one axes at a time, because the panel is the unit a reader separates hues within - a figure-wide bag gated hues that never share a frame against each other.
Source code in skill/scripts/check_figure.py
1997 1998 1999 2000 2001 2002 2003 2004 2005 2006 2007 2008 2009 2010 2011 2012 2013 2014 2015 2016 2017 2018 2019 2020 2021 2022 2023 2024 2025 2026 2027 2028 2029 2030 2031 2032 2033 2034 2035 2036 2037 2038 2039 2040 2041 2042 2043 2044 2045 2046 2047 2048 2049 2050 2051 2052 2053 2054 2055 2056 2057 2058 2059 2060 2061 2062 2063 2064 2065 2066 2067 2068 2069 | |
check_figure.check_dual_axis
¶
Two y scales in one frame, which nothing in this project banned and a
twinx figure sailed straight through.
Both scales are set by the author, so the crossing point of the two curves is an artifact of the limits chosen rather than anything in the data. Move the limits and the story changes; a reader cannot tell that from the figure.
The escape hatch is the one legitimate case: a pure unit relabel - degrees
C against degrees F, eV against nm - where the twin is furniture and carries
no data of its own. So the discriminator is data on both, not a shared
frame, which keeps the gate off secondary_yaxis and off correct work.
Source code in skill/scripts/check_figure.py
check_figure.check_form
¶
The mechanical subset of form choice - the three cases where the form is
wrong no matter what the data is. references/choosing-a-form.md carries
the judgement calls this cannot make.
Source code in skill/scripts/check_figure.py
check_figure.check_identity_channel
¶
Identity carried by color and nothing else.
A warning rather than a gate, and the reason is honesty about what the script can see: it can count the hues, but it cannot tell a direct label from any other piece of text in the axes. Failing on that guess would fire on correct work, and a gate people learn to skip is worse than no gate.
Source code in skill/scripts/check_figure.py
check_figure.check_label_attribution
¶
A direct label sitting nearer some other series than the one it names.
check_collisions compares text against text, so a label that clears every
other label and still floats in the corridor between two curves passes it
clean. That is not hypothetical: examples/demo.py shipped with "Tuned"
closer to a neighbouring curve than to its own and the whole suite was
green. Text against text and text against data are different questions.
Harvested narrowly on purpose - only text whose string matches exactly one series label is judged, because only there is the intent known. A callout, a panel letter, an "n = 300" attributes nothing to a curve, and failing those is the noise that teaches people to skim the row.
The threshold is a ratio rather than a distance because the judgement the reader makes is comparative: a label is unambiguous when its own curve is plainly the closest thing to it, not when it is some absolute number of points away.
Scatters count as series alongside lines, and so do the path-drawn ones:
step's staircase, a stackplot band, a contour set. Reading ax.lines
and offsets alone left a label sitting on top of a dense point cloud or
inside a stacked band invisible to the gate twice over: the series could not
own a label, and it could not be the neighbour that made one ambiguous. The
premise here is that a reader resolves a direct label by proximity, and a
reader does not know what artist class drew the ink.
A filled region that encloses the named series is not a rival for its label,
and neither is a series enclosed by a named region. A confidence band lies
on top of the curve it belongs to, so on distance alone it is always tied
with that curve at zero and every direct label under a band failed. The test
is _encloses, and it is geometric: an unlabelled band and one labelled for
the legend get the same answer, which the label-based test this replaced did
not - it passed _child3 and failed 95% CI.
Source code in skill/scripts/check_figure.py
2505 2506 2507 2508 2509 2510 2511 2512 2513 2514 2515 2516 2517 2518 2519 2520 2521 2522 2523 2524 2525 2526 2527 2528 2529 2530 2531 2532 2533 2534 2535 2536 2537 2538 2539 2540 2541 2542 2543 2544 2545 2546 2547 2548 2549 2550 2551 2552 2553 2554 2555 2556 2557 2558 2559 2560 2561 2562 2563 2564 2565 2566 2567 2568 2569 2570 2571 2572 2573 2574 2575 2576 2577 2578 2579 2580 2581 2582 2583 2584 2585 2586 2587 2588 2589 2590 2591 2592 2593 2594 2595 2596 2597 2598 2599 2600 2601 2602 2603 | |
check_figure.check_style_sheet
¶
Every key in the sheet against the rcParams that are actually in effect.
Three separate silent failures land here at once: a color written with a
leading # (which is a comment in this format, so matplotlib keeps its own
default), a forgotten plt.style.use, and an rcParams override applied
later. All three ship stock matplotlib while every other check passes.
A warning, not a gate, for one honest reason: a figure built on a different
project's sheet is correct work, and this compares against the global
rcParams rather than what the figure was drawn under, so a figure built
inside an rc_context that has since exited reads as drift when it is not.
Both make a hard failure the wrong instrument. The row names the keys.
Source code in skill/scripts/check_figure.py
check_figure.check_contour_dash
¶
Negative-level contours auto-dash via matplotlib default.
In a monochrome contour, rcParams["contour.negative_linestyle"] is
"dashed" by default, so negative-Z contours ship dashed isolines nobody
chose. The skill's own convention is dashing = unobserved / projected /
threshold, making this a silent semantic error every existing gate misses.
Non-monochrome (colored) contours are always solid and unaffected.
The condition used to be that EVERY level was non-positive, which is the
one shape a genuinely signed field never has: contour over data spanning
zero draws levels either side of it, matplotlib dashes the negative half,
and the gate skipped the figure entirely. It fired only on data that is
non-positive throughout — which is what the original test drew, so the hole
was invisible from inside the suite. The rule is now "any negative level",
asked of the drawn strokes.
Source code in skill/scripts/check_figure.py
check_figure.check_colormap
¶
Whether each colormap in the figure encodes what its data is.
Samples every named colormap an artist actually draws with, hands the
samples to check_palette.cmap_kind, and fails the two kinds that are not
an encoding. misc is a ramp whose lightness reverses, or is flat, or ends
somewhere neither cyclic nor diverging would: a reader cannot put two of its
values in order. A qualitative map is judged by the same all-pairs
separation floor a hand-built palette is, because an image puts every
category beside every other one.
Two ways this row passes without having judged anything, both deliberate.
A colormap matplotlib built from colours the author set on an artist is
skipped, since contour(colors=[...]) is three levels of one hue rather
than three categories, and classifying it qualitative would fail it. And the
row needs check_palette.py importable beside this file: without it there
is nothing to classify with, so it says so in the detail and passes. A pass
here is worth reading, not just counting.
Source code in skill/scripts/check_figure.py
2953 2954 2955 2956 2957 2958 2959 2960 2961 2962 2963 2964 2965 2966 2967 2968 2969 2970 2971 2972 2973 2974 2975 2976 2977 2978 2979 2980 2981 2982 2983 2984 2985 2986 2987 2988 2989 2990 2991 2992 2993 2994 2995 2996 2997 2998 2999 3000 3001 3002 3003 3004 3005 3006 3007 3008 3009 3010 3011 3012 3013 3014 3015 3016 3017 3018 3019 3020 3021 3022 3023 3024 3025 3026 3027 3028 3029 3030 3031 3032 3033 3034 3035 3036 3037 3038 3039 3040 3041 3042 3043 3044 3045 3046 3047 3048 3049 | |
check_figure.check_fonts
¶
Two silent failures between the figure on screen and the file you submit.
Type 3. Matplotlib defaults pdf.fonttype and ps.fonttype to 3. IEEE
PDF eXpress requires embedded Type 1 or TrueType and does not accept Type 3,
so the upload is refused before a reviewer sees it. ACM and Elsevier check
embedding in production instead, which is the same problem surfacing after
acceptance rather than a milder one. Neither publishes a rule rejecting a
submission for it, and this docstring said they did until the prose audit
read the sources. Nothing warns you either way: the figure renders
identically, and the paper bounces at the latest and most expensive possible
moment. Type 42 embeds TrueType outlines instead. figure.mplstyle sets it;
this catches the project that did not copy the sheet, and the notebook that
called rcParams.update afterwards.
Silent substitution. When none of the faces named in font.<family> is
installed, matplotlib falls back to its own default and logs nothing at
default verbosity. The guide calls the typeface the single largest visual
lever in the whole method, so a figure set in DejaVu because someone named
"Times New Roman" on a machine that does not have it is the lever quietly
disengaged. Falling back within the named list is not flagged — that is
what a fallback list is for, and the sheet ships one on purpose.
A warning rather than a gate, for the same reason check_style_sheet is:
both read the global rcParams rather than anything the figure carries, so
neither can tell a figure built under someone else's settings from a figure
built under none. A figure that will only ever be a PNG in a README is also
genuinely unaffected by the PDF font type. What is gated instead is the
thing this repo controls — the shipped figure.mplstyle declares 42, and
the suite fails if that line ever goes missing.
Source code in skill/scripts/check_figure.py
check_figure.check_alt_text
¶
Whether the figure carries a description for a reader who cannot see it.
A warning rather than a gate, and deliberately: on a paper the description frequently is the caption, and the caption lives in the .tex file where this cannot see it. Hard-failing every figure in that entirely reasonable setup is how a row becomes something everyone learns to skip, which is worse than not having it. Where there is no caption — a notebook, a README, a slide, a web page — nothing else is carrying this and the row is the only thing that will say so.