figure-gate 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- check_figure.py +1759 -0
- check_palette.py +229 -0
- figure_gate-0.1.0.dist-info/METADATA +270 -0
- figure_gate-0.1.0.dist-info/RECORD +7 -0
- figure_gate-0.1.0.dist-info/WHEEL +4 -0
- figure_gate-0.1.0.dist-info/entry_points.txt +3 -0
- figure_gate-0.1.0.dist-info/licenses/LICENSE +21 -0
check_figure.py
ADDED
|
@@ -0,0 +1,1759 @@
|
|
|
1
|
+
"""Composition checks for a rendered matplotlib figure.
|
|
2
|
+
|
|
3
|
+
`check_palette.py` answers "are these colors legal". This answers "is this
|
|
4
|
+
figure composed". They fail on different things: a figure can clear every
|
|
5
|
+
palette, type-size and ornament gate and still land as a washed-out smudge with
|
|
6
|
+
its axis label sliced off.
|
|
7
|
+
|
|
8
|
+
Every check here is mechanical and reads the figure's own artists, so it runs in
|
|
9
|
+
a test rather than in a design review.
|
|
10
|
+
|
|
11
|
+
from check_figure import audit
|
|
12
|
+
ok, rows = audit(fig)
|
|
13
|
+
ok, rows = audit(fig, context_axes=[ax]) # contourf background is not ink
|
|
14
|
+
ok, rows = audit(fig, venue="neurips") # measure type against \textwidth
|
|
15
|
+
|
|
16
|
+
python check_figure.py # self-test on a deliberately bad figure
|
|
17
|
+
python check_figure.py --venues # the content widths it knows
|
|
18
|
+
|
|
19
|
+
Checks, in the order `audit` runs them
|
|
20
|
+
1. Clipping - no text extends past the canvas
|
|
21
|
+
2. Text collision - no two text bounding boxes overlap
|
|
22
|
+
3. Text readability - no data ink crosses a label; text clears WCAG on the
|
|
23
|
+
backdrop it actually got
|
|
24
|
+
4. Contrast stack - something is at full opacity; alpha levels are few
|
|
25
|
+
5. Mark ratio - largest / smallest mark area within MARK_RATIO_MAX
|
|
26
|
+
6. Overplotting - scatter marks do not merge into an unreadable mass
|
|
27
|
+
7. Axis redundancy - shared-axis panels do not repeat a tick label column
|
|
28
|
+
8. Type size - every rendered string clears the floor once scaled
|
|
29
|
+
9. Line weight - every stroke clears LINE_FLOOR_PT once scaled
|
|
30
|
+
10. Ink coverage - the data region is neither empty nor packed
|
|
31
|
+
11. Series color - the hues in each panel separate under color blindness
|
|
32
|
+
12. Dual axis - no second y scale carrying data of its own
|
|
33
|
+
13. Form - no pie, no 3D, no truncated bar baseline
|
|
34
|
+
14. Identity channel - series are not told apart by color alone
|
|
35
|
+
15. Label attribution - each label is nearest the curve it names
|
|
36
|
+
16. Style sheet - figure.mplstyle is the one actually in effect
|
|
37
|
+
17. Contour dash - dashing is not spent on a signed contour's negatives
|
|
38
|
+
18. Fonts - Type 42 embedding; the named face is installed
|
|
39
|
+
19. Alt text - the figure carries a description
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
import itertools
|
|
43
|
+
from collections import Counter
|
|
44
|
+
from pathlib import Path
|
|
45
|
+
|
|
46
|
+
MARK_RATIO_MAX = 5.0 # area ratio of largest to smallest data mark
|
|
47
|
+
ALPHA_LEVELS_MAX = 3 # distinct transparency levels in one figure
|
|
48
|
+
INK_MIN, INK_MAX = 0.02, 0.55 # fraction of the axes area carrying data ink
|
|
49
|
+
|
|
50
|
+
# The theme has six categorical slots and the guide's claim is that there is no
|
|
51
|
+
# seventh: a generated hue is indistinguishable from an existing slot under
|
|
52
|
+
# simulated color blindness. Until now that claim was prose only.
|
|
53
|
+
MAX_SERIES_HUES = 6
|
|
54
|
+
|
|
55
|
+
# Ink and furniture from `figure.mplstyle`. These land in `ax.lines` and
|
|
56
|
+
# `ax.patches` beside the data - reference rules, annotation boxes, spine-colored
|
|
57
|
+
# strokes - and none of them carries a categorical identity, so counting them as
|
|
58
|
+
# a data hue would fail figures for the color of their own furniture.
|
|
59
|
+
INK_TOKENS = {"#000000", "#52514e", "#777570", # ink primary/secondary/muted
|
|
60
|
+
"#898781", # muted, pre-4.5:1 spelling
|
|
61
|
+
"#e1e0d9", "#c3c2b7", "#ffffff"} # grid, axis, surface
|
|
62
|
+
|
|
63
|
+
# Two axes are the same frame when their bounds agree this closely. An inset or
|
|
64
|
+
# a colorbar never does; `twinx`/`twiny` always does.
|
|
65
|
+
FRAME_TOL = 1e-3
|
|
66
|
+
|
|
67
|
+
# A label is correctly attributed when its own curve is at least this many times
|
|
68
|
+
# closer than the next nearest curve. A ratio rather than an absolute distance
|
|
69
|
+
# because the judgement the reader makes is comparative: "is this label nearer
|
|
70
|
+
# that curve or this one?"
|
|
71
|
+
LABEL_MARGIN = 2.0
|
|
72
|
+
|
|
73
|
+
# --- text readability --------------------------------------------------------
|
|
74
|
+
# Data ink inside a label's box, as a fraction of that box. Calibrated against
|
|
75
|
+
# a lw=1.6 curve at 150 dpi: crossing a label horizontally is ~14% of the box,
|
|
76
|
+
# a single vertical spike ~4%, and a label on clean ground with gridlines behind
|
|
77
|
+
# it measures 0.
|
|
78
|
+
TEXT_CLUTTER_MAX = 0.03
|
|
79
|
+
# Distance, in 0-255 RGB, at which a pixel stops being explainable as a blend of
|
|
80
|
+
# the label's surface with the figure's furniture. Well above subpixel and gamma
|
|
81
|
+
# noise, well below any two distinguishable hues.
|
|
82
|
+
TEXT_BLEND_TOL = 26.0
|
|
83
|
+
# A mark is an edge; ground is whatever varies slowly. These set where the line
|
|
84
|
+
# between the two falls: how wide a neighbourhood a pixel is compared against,
|
|
85
|
+
# and how far it has to sit from that neighbourhood's average to be a mark. The
|
|
86
|
+
# window is wider than a 1.6pt hairline at 150 dpi (~3px) so a hairline stands
|
|
87
|
+
# clear of its own average, and narrow enough that a viridis ramp does not.
|
|
88
|
+
TEXT_EDGE_WINDOW = 9
|
|
89
|
+
TEXT_EDGE_TOL = 14.0
|
|
90
|
+
# A backdrop color has to cover at least this much of a label's box before it
|
|
91
|
+
# can set the contrast verdict. Without a floor, one stray antialiased pixel on
|
|
92
|
+
# a field decides whether the label is legible.
|
|
93
|
+
TEXT_BACKDROP_MIN_SHARE = 0.10
|
|
94
|
+
# Below this many pixels a text box is a glyph or two and the fractions computed
|
|
95
|
+
# off it are noise.
|
|
96
|
+
TEXT_FOOTPRINT_MIN_PX = 60
|
|
97
|
+
# WCAG 2.1 text thresholds. A glyph stem is thinner than a mark, so text does
|
|
98
|
+
# not get the 3:1 the mark gates use; large text (>=18pt, or >=14pt bold, ON
|
|
99
|
+
# PAGE) does.
|
|
100
|
+
TEXT_CONTRAST_MIN = 4.5
|
|
101
|
+
TEXT_CONTRAST_MIN_LARGE = 3.0
|
|
102
|
+
|
|
103
|
+
# Type floor, in points ON THE PAGE, after the figure is scaled to fit.
|
|
104
|
+
#
|
|
105
|
+
# Stricter than every journal that publishes a number: Nature's floor is 5pt,
|
|
106
|
+
# Science asks 5-7pt for labels and 6-8pt for axes, PNAS 6-8pt with nothing
|
|
107
|
+
# under 2mm printed. Those are the sizes at which a string is still *possible*
|
|
108
|
+
# to read. 7.5 is the size at which it is comfortable, and it is cheap to hold
|
|
109
|
+
# because the fix is nearly always cutting words rather than shrinking type.
|
|
110
|
+
TYPE_FLOOR_PT = 7.5
|
|
111
|
+
|
|
112
|
+
# Stroke floor, in points ON THE PAGE. SIAM's instructions for authors: "lines
|
|
113
|
+
# one point or thicker; thinner lines may break up or disappear."
|
|
114
|
+
LINE_FLOOR_PT = 1.0
|
|
115
|
+
|
|
116
|
+
# SET THIS PER PROJECT: the usable width, in points, of the page the figure
|
|
117
|
+
# lands in. Render one page, place a full-width figure, measure it. Within ~5%
|
|
118
|
+
# is fine, since it only sets the type floor.
|
|
119
|
+
#
|
|
120
|
+
# Set it once and the scale is derived per figure from that figure's own width,
|
|
121
|
+
# which is the part people get wrong: a wide figure shrinks much harder than a
|
|
122
|
+
# narrow one, so the same 8pt label is comfortable in one and illegible in the
|
|
123
|
+
# other.
|
|
124
|
+
#
|
|
125
|
+
# None means "assume scale 1.0" - correct when you author each figure at the
|
|
126
|
+
# width it is actually placed at, which makes points authored equal points
|
|
127
|
+
# printed and this whole calculation disappear.
|
|
128
|
+
CONTENT_WIDTH_PT = None
|
|
129
|
+
|
|
130
|
+
# `\the\textwidth` and `\the\columnwidth`, read out of the class and style files
|
|
131
|
+
# these venues ship. The measure-it-yourself instruction above is the honest
|
|
132
|
+
# general answer and it is also the step people skip, so the common cases are
|
|
133
|
+
# here already. Pass one as `venue=` rather than editing CONTENT_WIDTH_PT.
|
|
134
|
+
#
|
|
135
|
+
# VERIFY BEFORE TRUSTING for anything that matters: put `\the\textwidth` in your
|
|
136
|
+
# own document and read the log. Style files get revised between years, a
|
|
137
|
+
# `geometry` package call in the preamble silently overrides all of this, and a
|
|
138
|
+
# figure certified against the wrong width is certified at the wrong type size.
|
|
139
|
+
VENUE_WIDTH_PT = {
|
|
140
|
+
"neurips": 397.48, # \textwidth, neurips_*.sty (5.5in)
|
|
141
|
+
"iclr": 397.48, # \textwidth, iclr*_conference.sty
|
|
142
|
+
"icml": 487.82, # \textwidth, icml*.sty (two-column page)
|
|
143
|
+
"icml-column": 234.88, # \columnwidth
|
|
144
|
+
"acl": 455.24, # \textwidth, acl.sty (16cm)
|
|
145
|
+
"acl-column": 219.08, # \columnwidth (7.7cm)
|
|
146
|
+
"ieee": 516.0, # \textwidth, IEEEtran
|
|
147
|
+
"ieee-column": 252.0, # \columnwidth, IEEEtran
|
|
148
|
+
"nature": 518.74, # double column, 183mm
|
|
149
|
+
"nature-column": 252.28, # single column, 89mm
|
|
150
|
+
"article-letter": 345.0, # \textwidth, article 10pt letterpaper
|
|
151
|
+
"article-a4": 418.25, # \textwidth, article 10pt a4paper
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def content_width_pt(venue=None):
|
|
156
|
+
"""The usable page width to measure against, in points."""
|
|
157
|
+
if venue is None:
|
|
158
|
+
return CONTENT_WIDTH_PT
|
|
159
|
+
try:
|
|
160
|
+
return VENUE_WIDTH_PT[venue]
|
|
161
|
+
except KeyError:
|
|
162
|
+
raise KeyError(
|
|
163
|
+
f"unknown venue {venue!r}. Known: "
|
|
164
|
+
f"{', '.join(sorted(VENUE_WIDTH_PT))}. For anything else, put "
|
|
165
|
+
"\\the\\textwidth in the document, read the log, and set "
|
|
166
|
+
"CONTENT_WIDTH_PT to what it says.") from None
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
def page_scale(fig, placed_frac=1.0, venue=None):
|
|
170
|
+
"""Scale from authored inches to points on the page.
|
|
171
|
+
|
|
172
|
+
`placed_frac` is the fraction of the content width the figure is placed at,
|
|
173
|
+
so it reads like the call site: `\\includegraphics[width=0.48\\textwidth]`
|
|
174
|
+
is `placed_frac=0.48`. Without it every figure is measured as if it were
|
|
175
|
+
full width, and a half-width figure is certified at twice the type size it
|
|
176
|
+
actually ships at - which is the wrong direction for a legibility gate to
|
|
177
|
+
be wrong in.
|
|
178
|
+
|
|
179
|
+
`venue` names a row of `VENUE_WIDTH_PT` and overrides `CONTENT_WIDTH_PT` for
|
|
180
|
+
this call, which is the usual way in: the width is a property of the
|
|
181
|
+
document, not of the checkout.
|
|
182
|
+
"""
|
|
183
|
+
width = content_width_pt(venue)
|
|
184
|
+
if width is None:
|
|
185
|
+
if placed_frac != 1.0:
|
|
186
|
+
raise ValueError(
|
|
187
|
+
"placed_frac requires a content width. With CONTENT_WIDTH_PT "
|
|
188
|
+
"None and no venue= the checker assumes you authored the "
|
|
189
|
+
"figure at the width it is placed at, which already makes the "
|
|
190
|
+
"scale 1.0; a fractional placement contradicts that. Pass "
|
|
191
|
+
"venue=, set CONTENT_WIDTH_PT, or author at the placed width "
|
|
192
|
+
"and drop placed_frac.")
|
|
193
|
+
return 1.0
|
|
194
|
+
return width * placed_frac / (fig.get_size_inches()[0] * 72)
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
def _renderer(fig):
|
|
198
|
+
"""Return (renderer, canvas). Canvas is an Agg canvas with drawn buffer.
|
|
199
|
+
|
|
200
|
+
Text extents need a renderer that can measure. The SVG canvas cannot, so
|
|
201
|
+
swap in an Agg one when that is what the figure was built under.
|
|
202
|
+
Reused across checks so check_ink does not render a second time."""
|
|
203
|
+
if hasattr(fig.canvas, "get_renderer"):
|
|
204
|
+
fig.canvas.draw()
|
|
205
|
+
return fig.canvas.get_renderer(), fig.canvas
|
|
206
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
207
|
+
canvas = FigureCanvasAgg(fig)
|
|
208
|
+
canvas.draw()
|
|
209
|
+
return canvas.get_renderer(), canvas
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def _texts(fig, r):
|
|
213
|
+
"""Visible, non-empty Text artists with their window extents.
|
|
214
|
+
|
|
215
|
+
Matched on the concrete class, not on `hasattr(get_text)` - a ContourSet
|
|
216
|
+
also has a `get_text`, with a different signature.
|
|
217
|
+
"""
|
|
218
|
+
from matplotlib.text import Text
|
|
219
|
+
out, seen = [], set()
|
|
220
|
+
for t in fig.findobj(match=lambda a: isinstance(a, Text)):
|
|
221
|
+
if id(t) in seen:
|
|
222
|
+
continue
|
|
223
|
+
seen.add(id(t))
|
|
224
|
+
if not t.get_visible() or not str(t.get_text()).strip():
|
|
225
|
+
continue
|
|
226
|
+
try:
|
|
227
|
+
bb = t.get_window_extent(renderer=r)
|
|
228
|
+
except Exception:
|
|
229
|
+
continue
|
|
230
|
+
if bb.width <= 0 or bb.height <= 0:
|
|
231
|
+
continue
|
|
232
|
+
out.append((t, bb))
|
|
233
|
+
return out
|
|
234
|
+
|
|
235
|
+
|
|
236
|
+
def _tick_texts(fig):
|
|
237
|
+
ids = set()
|
|
238
|
+
for ax in fig.axes:
|
|
239
|
+
for t in ax.get_xticklabels() + ax.get_yticklabels():
|
|
240
|
+
ids.add(id(t))
|
|
241
|
+
return ids
|
|
242
|
+
|
|
243
|
+
|
|
244
|
+
def _ghost_ticks(fig):
|
|
245
|
+
"""Tick artists that exist on an axes but never reach the page: those on a
|
|
246
|
+
hidden axes (`ax.axis("off")`), and those at locations outside the current
|
|
247
|
+
view. Counting either as clipped text reports a defect that is not there.
|
|
248
|
+
"""
|
|
249
|
+
ids = set()
|
|
250
|
+
for ax in fig.axes:
|
|
251
|
+
if not ax.axison:
|
|
252
|
+
ids.update(id(t) for t in
|
|
253
|
+
ax.get_xticklabels() + ax.get_yticklabels())
|
|
254
|
+
continue
|
|
255
|
+
for locs, labels, (lo, hi) in (
|
|
256
|
+
(ax.get_xticks(), ax.get_xticklabels(), ax.get_xlim()),
|
|
257
|
+
(ax.get_yticks(), ax.get_yticklabels(), ax.get_ylim()),
|
|
258
|
+
):
|
|
259
|
+
lo, hi = min(lo, hi), max(lo, hi)
|
|
260
|
+
for pos, lab in zip(locs, labels):
|
|
261
|
+
if not (lo <= pos <= hi):
|
|
262
|
+
ids.add(id(lab))
|
|
263
|
+
return ids
|
|
264
|
+
|
|
265
|
+
|
|
266
|
+
def _polar_radial_ticks(fig):
|
|
267
|
+
"""Radial tick labels on polar axes.
|
|
268
|
+
|
|
269
|
+
matplotlib places these inside the disc, because on a polar plot there is
|
|
270
|
+
no outside to place them in — `set_rlabel_position` moves them to a
|
|
271
|
+
different angle, never off the data. So a curve running under '0.8' is not
|
|
272
|
+
a composition mistake anybody made, and the advice this gate gives ("move
|
|
273
|
+
the label to clear ground") names a move that does not exist.
|
|
274
|
+
|
|
275
|
+
Both clauses, not just clutter. Exempting clutter alone still left the
|
|
276
|
+
contrast clause failing eight of eight ordinary polar plots built on this
|
|
277
|
+
project's own style sheet, at 2.0:1 against a curve the radial axis crosses
|
|
278
|
+
by construction. A gate the bundled sheet cannot satisfy is measuring the
|
|
279
|
+
projection, not the figure.
|
|
280
|
+
|
|
281
|
+
The count is reported rather than dropped, so the author is told these
|
|
282
|
+
strings went unjudged instead of assuming they passed.
|
|
283
|
+
"""
|
|
284
|
+
ids = set()
|
|
285
|
+
for ax in fig.axes:
|
|
286
|
+
if getattr(ax, "name", "") == "polar":
|
|
287
|
+
ids.update(id(t) for t in ax.get_yticklabels())
|
|
288
|
+
return ids
|
|
289
|
+
|
|
290
|
+
|
|
291
|
+
def _overlap(a, b):
|
|
292
|
+
dx = min(a.x1, b.x1) - max(a.x0, b.x0)
|
|
293
|
+
dy = min(a.y1, b.y1) - max(a.y0, b.y0)
|
|
294
|
+
return dx > 0 and dy > 0
|
|
295
|
+
|
|
296
|
+
|
|
297
|
+
def check_clipping(fig, r):
|
|
298
|
+
w, h = fig.canvas.get_width_height()
|
|
299
|
+
ghosts = _ghost_ticks(fig)
|
|
300
|
+
bad = []
|
|
301
|
+
for t, bb in _texts(fig, r):
|
|
302
|
+
if id(t) in ghosts:
|
|
303
|
+
continue
|
|
304
|
+
if bb.x0 < -1 or bb.y0 < -1 or bb.x1 > w + 1 or bb.y1 > h + 1:
|
|
305
|
+
bad.append(str(t.get_text())[:32])
|
|
306
|
+
return (not bad,
|
|
307
|
+
"no text past the canvas" if not bad
|
|
308
|
+
else f"clipped: {bad} <- add constrained_layout or widen the figure")
|
|
309
|
+
|
|
310
|
+
|
|
311
|
+
def check_collisions(fig, r):
|
|
312
|
+
"""Text-on-text overlap. Tick labels on a shared axis are exempt: matplotlib
|
|
313
|
+
lays those out itself and a 1px touch there is not a defect."""
|
|
314
|
+
ticks = _tick_texts(fig)
|
|
315
|
+
items = [(t, bb) for t, bb in _texts(fig, r) if id(t) not in ticks]
|
|
316
|
+
hits = []
|
|
317
|
+
for (ta, ba), (tb, bb) in itertools.combinations(items, 2):
|
|
318
|
+
if _overlap(ba, bb):
|
|
319
|
+
hits.append((str(ta.get_text())[:22], str(tb.get_text())[:22]))
|
|
320
|
+
return (not hits,
|
|
321
|
+
f"{len(items)} text objects, none overlapping" if not hits
|
|
322
|
+
else f"overlapping: {hits[:4]}")
|
|
323
|
+
|
|
324
|
+
|
|
325
|
+
def _halo(t):
|
|
326
|
+
"""(color, linewidth) of a stroke path effect, when the text wears casing.
|
|
327
|
+
|
|
328
|
+
Casing — the cartographic term for a contrasting outline around a label —
|
|
329
|
+
is what lets a label sit over busy ground at all. Reading it off the artist
|
|
330
|
+
rather than guessing from the pixels keeps the two clauses below separable:
|
|
331
|
+
whether the halo *should* have worked, and whether it *did*.
|
|
332
|
+
"""
|
|
333
|
+
from matplotlib import patheffects as pe
|
|
334
|
+
for effect in (t.get_path_effects() or ()):
|
|
335
|
+
if not isinstance(effect, pe.Stroke): # withStroke subclasses Stroke
|
|
336
|
+
continue
|
|
337
|
+
gc = getattr(effect, "_gc", {}) or {}
|
|
338
|
+
fg = gc.get("foreground")
|
|
339
|
+
if fg is not None:
|
|
340
|
+
return fg, float(gc.get("linewidth", 0.0) or 0.0)
|
|
341
|
+
return None, 0.0
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _furniture(fig):
|
|
345
|
+
"""Colors a label is allowed to sit on, read off the figure that drew them.
|
|
346
|
+
|
|
347
|
+
Casing exists precisely so a gridline can pass behind a label; the axis rule
|
|
348
|
+
and the tick marks are the same kind of thing. Data ink is not furniture,
|
|
349
|
+
which is the whole distinction this list draws.
|
|
350
|
+
|
|
351
|
+
Harvested from the artists rather than from `INK_TOKENS` for two reasons.
|
|
352
|
+
The token set contains the text ink itself, and a black reference line
|
|
353
|
+
crossing a black label is a defect rather than furniture. And a project that
|
|
354
|
+
swapped the style sheet's grid color would otherwise have every gridline in
|
|
355
|
+
every figure counted as data passing through its labels — the check would
|
|
356
|
+
fire hardest on exactly the people who customised it.
|
|
357
|
+
"""
|
|
358
|
+
from matplotlib.colors import to_rgb
|
|
359
|
+
out = {tuple(to_rgb(fig.get_facecolor()))}
|
|
360
|
+
for ax in fig.axes:
|
|
361
|
+
out.add(tuple(to_rgb(ax.get_facecolor())))
|
|
362
|
+
for axis in (ax.xaxis, ax.yaxis):
|
|
363
|
+
for line in axis.get_gridlines():
|
|
364
|
+
out.add(tuple(to_rgb(line.get_color())))
|
|
365
|
+
for tick in axis.get_ticklines():
|
|
366
|
+
out.add(tuple(to_rgb(tick.get_color())))
|
|
367
|
+
for spine in ax.spines.values():
|
|
368
|
+
out.add(tuple(to_rgb(spine.get_edgecolor())))
|
|
369
|
+
return [tuple(c * 255.0 for c in rgb) for rgb in out]
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
def _near_any(pixels, anchors, tol):
|
|
373
|
+
"""Which of an (N, 3) block of pixels a blend of `anchors` explains.
|
|
374
|
+
|
|
375
|
+
Antialiasing puts pixels on the straight line between two colors that meet,
|
|
376
|
+
so the test is distance to the nearest *segment* joining a pair of anchors,
|
|
377
|
+
not distance to the nearest anchor. Without that, the halfway pixel where a
|
|
378
|
+
gridline meets the page reads as a third color and every gridline in the
|
|
379
|
+
figure counts as foreign ink.
|
|
380
|
+
"""
|
|
381
|
+
import numpy as np
|
|
382
|
+
pix = pixels.astype(float)
|
|
383
|
+
best = np.full(len(pix), np.inf)
|
|
384
|
+
for a, b in itertools.combinations_with_replacement(anchors, 2):
|
|
385
|
+
a, b = np.asarray(a, float), np.asarray(b, float)
|
|
386
|
+
seg = b - a
|
|
387
|
+
span = float(seg @ seg)
|
|
388
|
+
if span < 1.0:
|
|
389
|
+
d = np.linalg.norm(pix - a, axis=1)
|
|
390
|
+
else:
|
|
391
|
+
u = np.clip(((pix - a) @ seg) / span, 0.0, 1.0)
|
|
392
|
+
d = np.linalg.norm(pix - (a + u[:, None] * seg), axis=1)
|
|
393
|
+
np.minimum(best, d, out=best)
|
|
394
|
+
return best <= tol
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def _box_blur(field, size):
|
|
398
|
+
"""Moving average over an (H, W, 3) block, edges extended.
|
|
399
|
+
|
|
400
|
+
`scipy.ndimage.uniform_filter` does this and is what the first version
|
|
401
|
+
called. Written out in numpy instead so `check_figure.py` stays a file you
|
|
402
|
+
can copy next to your figures with nothing but matplotlib installed —
|
|
403
|
+
the promise in the README is three files and no install, and a hard scipy
|
|
404
|
+
import quietly broke it. Separable and cumulative, so it costs two passes
|
|
405
|
+
regardless of the window.
|
|
406
|
+
"""
|
|
407
|
+
import numpy as np
|
|
408
|
+
half = size // 2
|
|
409
|
+
out = field.astype(float)
|
|
410
|
+
for axis in (0, 1):
|
|
411
|
+
padded = np.concatenate(
|
|
412
|
+
[np.repeat(out.take([0], axis=axis), half, axis=axis),
|
|
413
|
+
out,
|
|
414
|
+
np.repeat(out.take([-1], axis=axis), half, axis=axis)],
|
|
415
|
+
axis=axis)
|
|
416
|
+
cum = np.cumsum(padded, axis=axis)
|
|
417
|
+
zero = np.zeros_like(cum.take([0], axis=axis))
|
|
418
|
+
cum = np.concatenate([zero, cum], axis=axis)
|
|
419
|
+
n = out.shape[axis]
|
|
420
|
+
hi = cum.take(range(size, size + n), axis=axis)
|
|
421
|
+
lo = cum.take(range(0, n), axis=axis)
|
|
422
|
+
out = (hi - lo) / size
|
|
423
|
+
return out
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _foreign_ink(block, furniture, tol):
|
|
427
|
+
"""Fraction of a backdrop patch that is a mark rather than ground.
|
|
428
|
+
|
|
429
|
+
Ground is whatever varies slowly: the page, a flat fill, a viridis field.
|
|
430
|
+
A mark is an *edge* — a curve, a marker, an isoline — so the test is each
|
|
431
|
+
pixel against a local average of its neighbours rather than against the
|
|
432
|
+
patch's dominant color. Testing against the dominant color was the obvious
|
|
433
|
+
first version and it failed every annotation on a heatmap, because a smooth
|
|
434
|
+
ramp differs from its own mode everywhere while being, to a reader, one
|
|
435
|
+
surface.
|
|
436
|
+
|
|
437
|
+
Furniture is exempt at the second step rather than the first: a gridline IS
|
|
438
|
+
an edge, and casing exists precisely so it can pass behind a label.
|
|
439
|
+
"""
|
|
440
|
+
import numpy as np
|
|
441
|
+
|
|
442
|
+
field = block.astype(float)
|
|
443
|
+
local = _box_blur(field, TEXT_EDGE_WINDOW)
|
|
444
|
+
edge = np.linalg.norm(field - local, axis=2) > TEXT_EDGE_TOL
|
|
445
|
+
if not edge.any():
|
|
446
|
+
return 0.0
|
|
447
|
+
pix = field[edge].reshape(-1, 3)
|
|
448
|
+
return float(edge.sum() - _near_any(pix, furniture, tol).sum()) / edge.size
|
|
449
|
+
|
|
450
|
+
|
|
451
|
+
def _worst_backdrop(block, fg, min_share):
|
|
452
|
+
"""The backdrop color the text reads worst against, among those covering at
|
|
453
|
+
least `min_share` of its box.
|
|
454
|
+
|
|
455
|
+
A single color is the wrong summary for a field: a label on viridis sits on
|
|
456
|
+
a range, and both the mean and the mode can clear the threshold while a
|
|
457
|
+
third of the box does not. A share floor keeps a handful of stray pixels
|
|
458
|
+
from setting the verdict.
|
|
459
|
+
"""
|
|
460
|
+
import numpy as np
|
|
461
|
+
pix = block.reshape(-1, 3)
|
|
462
|
+
keys, counts = np.unique(pix // 8, axis=0, return_counts=True)
|
|
463
|
+
worst, ratio = None, float("inf")
|
|
464
|
+
for key, n in zip(keys, counts):
|
|
465
|
+
if n / len(pix) < min_share:
|
|
466
|
+
continue
|
|
467
|
+
color = pix[((pix // 8) == key).all(axis=1)].mean(axis=0)
|
|
468
|
+
r = _contrast_255(fg, color)
|
|
469
|
+
if r < ratio:
|
|
470
|
+
worst, ratio = color, r
|
|
471
|
+
if worst is None:
|
|
472
|
+
return pix.mean(axis=0), _contrast_255(fg, pix.mean(axis=0))
|
|
473
|
+
return worst, ratio
|
|
474
|
+
|
|
475
|
+
|
|
476
|
+
def check_text_readability(fig, r, canvas=None, scale=None, placed_frac=1.0,
|
|
477
|
+
venue=None):
|
|
478
|
+
"""Whether each string can be read where it sits.
|
|
479
|
+
|
|
480
|
+
`check_label_attribution` asks which curve a label belongs to. This asks the
|
|
481
|
+
prior question — whether the label is legible at all — and the two come
|
|
482
|
+
apart hard: a label printed *on* its own curve is attributed perfectly, and
|
|
483
|
+
is read through the line crossing its letterforms.
|
|
484
|
+
|
|
485
|
+
Both clauses are measured off rendered pixels, because both depend on what
|
|
486
|
+
happened to land behind the glyphs and no artist knows that about itself.
|
|
487
|
+
The figure is drawn a second time with every string hidden; that render is
|
|
488
|
+
the backdrop each label was placed onto.
|
|
489
|
+
|
|
490
|
+
*Clutter.* Inside a label's box the backdrop should be one surface. Pixels
|
|
491
|
+
that no blend of {that surface, the grid, the axis rule} explains are data
|
|
492
|
+
ink passing through the text — a curve, a marker, a spike between the
|
|
493
|
+
strokes. Measuring the backdrop rather than the finished render is the whole
|
|
494
|
+
trick: casing hides the evidence, because a white halo over an orange curve
|
|
495
|
+
renders as clean white while punching a visible gap through the data. Both
|
|
496
|
+
halves of that are defects and this sees them as one number.
|
|
497
|
+
|
|
498
|
+
Uniform data ink is not clutter. A label on a heatmap cell has the cell as
|
|
499
|
+
its surface, so it is the contrast clause that governs there, which is the
|
|
500
|
+
correct division: a flat fill is a background, a curve is not.
|
|
501
|
+
|
|
502
|
+
*Contrast.* The text against the backdrop it actually got, at the WCAG text
|
|
503
|
+
threshold (4.5:1, or 3:1 for large text) rather than the 3:1 mark threshold,
|
|
504
|
+
because a glyph stem is thinner than a mark. Casing counts: a black label
|
|
505
|
+
with a white halo on a dark field is read against the halo.
|
|
506
|
+
|
|
507
|
+
Tick labels are included. They sit outside the axes on most figures and cost
|
|
508
|
+
nothing to check there, and on the figures where they do not — an inset, a
|
|
509
|
+
twinned frame, a label moved inside — that is exactly where they get
|
|
510
|
+
crossed.
|
|
511
|
+
"""
|
|
512
|
+
import numpy as np
|
|
513
|
+
from matplotlib.colors import to_rgb
|
|
514
|
+
|
|
515
|
+
items = _texts(fig, r)
|
|
516
|
+
if not items:
|
|
517
|
+
return True, "no text to read"
|
|
518
|
+
if canvas is None:
|
|
519
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
520
|
+
canvas = FigureCanvasAgg(fig)
|
|
521
|
+
canvas.draw()
|
|
522
|
+
|
|
523
|
+
# Hiding text changes what constrained_layout has to fit, so the second
|
|
524
|
+
# render would come back with every artist in a slightly different place and
|
|
525
|
+
# the backdrop would not line up with the boxes measured against the first.
|
|
526
|
+
# Pin the layout for the duration; the engine is put back before returning.
|
|
527
|
+
engine = fig.get_layout_engine()
|
|
528
|
+
fig.set_layout_engine("none")
|
|
529
|
+
visible = [t.get_visible() for t, _ in items]
|
|
530
|
+
try:
|
|
531
|
+
for t, _ in items:
|
|
532
|
+
t.set_visible(False)
|
|
533
|
+
canvas.draw()
|
|
534
|
+
backdrop = np.asarray(
|
|
535
|
+
canvas.buffer_rgba())[:, :, :3].astype(np.int16).copy()
|
|
536
|
+
finally:
|
|
537
|
+
for (t, _), v in zip(items, visible):
|
|
538
|
+
t.set_visible(v)
|
|
539
|
+
canvas.draw()
|
|
540
|
+
if engine is not None:
|
|
541
|
+
fig.set_layout_engine(engine)
|
|
542
|
+
|
|
543
|
+
if scale is None:
|
|
544
|
+
scale = page_scale(fig, placed_frac, venue)
|
|
545
|
+
H, W = backdrop.shape[:2]
|
|
546
|
+
furniture = _furniture(fig)
|
|
547
|
+
# Ticks that exist on the axes but never reach the page — a hidden axes, a
|
|
548
|
+
# location outside the view. `check_clipping` learned about these the same
|
|
549
|
+
# way this did: by reporting a defect on a schematic that draws no axes and
|
|
550
|
+
# still carries the tick Text objects matplotlib made for it.
|
|
551
|
+
ghosts = _ghost_ticks(fig)
|
|
552
|
+
radial = _polar_radial_ticks(fig)
|
|
553
|
+
cluttered, faint, checked, unjudged = [], [], 0, 0
|
|
554
|
+
|
|
555
|
+
for t, bb in items:
|
|
556
|
+
if id(t) in ghosts:
|
|
557
|
+
continue
|
|
558
|
+
if id(t) in radial:
|
|
559
|
+
unjudged += 1
|
|
560
|
+
continue
|
|
561
|
+
xa, xb = max(int(bb.x0) - 1, 0), min(int(bb.x1) + 2, W)
|
|
562
|
+
ya, yb = max(int(bb.y0) - 1, 0), min(int(bb.y1) + 2, H)
|
|
563
|
+
# Agg's origin is top-left, the figure's is bottom-left
|
|
564
|
+
block = backdrop[slice(H - yb, H - ya), slice(xa, xb)]
|
|
565
|
+
# Below this the fractions are counting antialiasing, not measuring.
|
|
566
|
+
if block.size // 3 < TEXT_FOOTPRINT_MIN_PX:
|
|
567
|
+
continue
|
|
568
|
+
checked += 1
|
|
569
|
+
|
|
570
|
+
fg = np.array(to_rgb(t.get_color())) * 255.0
|
|
571
|
+
halo_color, _ = _halo(t)
|
|
572
|
+
name = str(t.get_text())[:22]
|
|
573
|
+
|
|
574
|
+
frac = _foreign_ink(block, furniture, TEXT_BLEND_TOL)
|
|
575
|
+
if frac > TEXT_CLUTTER_MAX:
|
|
576
|
+
cluttered.append(
|
|
577
|
+
f"{name!r} sits on data ink over {frac:.0%} of its box"
|
|
578
|
+
+ (" — the casing hides it by erasing the data underneath"
|
|
579
|
+
if halo_color else " and wears no casing"))
|
|
580
|
+
|
|
581
|
+
pt = t.get_fontsize() * scale
|
|
582
|
+
weight = t.get_fontweight()
|
|
583
|
+
bold = (weight in ("bold", "heavy", "black", "extra bold", "semibold")
|
|
584
|
+
or (isinstance(weight, (int, float)) and weight >= 600))
|
|
585
|
+
floor = (TEXT_CONTRAST_MIN_LARGE
|
|
586
|
+
if pt >= 18.0 or (pt >= 14.0 and bold) else TEXT_CONTRAST_MIN)
|
|
587
|
+
if halo_color:
|
|
588
|
+
# Casing replaces the backdrop under the strokes, so that is what
|
|
589
|
+
# the reader reads against.
|
|
590
|
+
ratio = _contrast_255(fg, np.array(to_rgb(halo_color)) * 255.0)
|
|
591
|
+
else:
|
|
592
|
+
_, ratio = _worst_backdrop(block, fg, TEXT_BACKDROP_MIN_SHARE)
|
|
593
|
+
if ratio < floor:
|
|
594
|
+
faint.append(f"{name!r} at {ratio:.1f}:1 on its backdrop "
|
|
595
|
+
f"(text needs {floor}:1)")
|
|
596
|
+
|
|
597
|
+
skipped = (f"; {unjudged} polar radial labels not judged (matplotlib "
|
|
598
|
+
"places them on the data and offers nowhere else to put them)"
|
|
599
|
+
if unjudged else "")
|
|
600
|
+
if not checked:
|
|
601
|
+
return True, "no text large enough to measure" + skipped
|
|
602
|
+
bad = cluttered + faint
|
|
603
|
+
if not bad:
|
|
604
|
+
return True, (f"{checked} strings read clean against their backdrop"
|
|
605
|
+
+ skipped)
|
|
606
|
+
return False, ("; ".join(bad[:3])
|
|
607
|
+
+ " <- move the label to clear ground; casing rescues a "
|
|
608
|
+
"gridline, not a curve")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def _contrast_255(rgb_a, rgb_b):
|
|
612
|
+
"""WCAG contrast for two 0-255 RGB triples.
|
|
613
|
+
|
|
614
|
+
Duplicated from `check_palette.contrast` rather than imported: this file
|
|
615
|
+
already treats that import as optional (`check_series_color` degrades to a
|
|
616
|
+
note when it is missing), and a legibility gate that silently stops running
|
|
617
|
+
when a sibling file is absent is the kind of decoration this repo exists to
|
|
618
|
+
argue against.
|
|
619
|
+
"""
|
|
620
|
+
def lin(c):
|
|
621
|
+
c = c / 255.0
|
|
622
|
+
return c / 12.92 if c <= 0.04045 else ((c + 0.055) / 1.055) ** 2.4
|
|
623
|
+
|
|
624
|
+
lums = []
|
|
625
|
+
for rgb in (rgb_a, rgb_b):
|
|
626
|
+
r, g, b = (lin(float(v)) for v in rgb)
|
|
627
|
+
lums.append(0.2126 * r + 0.7152 * g + 0.0722 * b)
|
|
628
|
+
hi, lo = max(lums), min(lums)
|
|
629
|
+
return (hi + 0.05) / (lo + 0.05)
|
|
630
|
+
|
|
631
|
+
|
|
632
|
+
def check_contrast_stack(fig):
|
|
633
|
+
"""A figure where nothing is at full opacity has no focal point, and a long
|
|
634
|
+
tail of alpha values reads as haze rather than hierarchy."""
|
|
635
|
+
alphas = []
|
|
636
|
+
for ax in fig.axes:
|
|
637
|
+
for a in list(ax.collections) + list(ax.lines) + list(ax.patches):
|
|
638
|
+
if not a.get_visible():
|
|
639
|
+
continue
|
|
640
|
+
al = a.get_alpha()
|
|
641
|
+
# unset alpha means opaque, and that is exactly what this check
|
|
642
|
+
# wants to know about, so it counts as 1.0 rather than being skipped
|
|
643
|
+
alphas.append(1.0 if al is None else round(float(al), 2))
|
|
644
|
+
if not alphas:
|
|
645
|
+
return True, "no data artists"
|
|
646
|
+
levels = sorted(set(alphas))
|
|
647
|
+
solid = any(x >= 0.99 for x in alphas)
|
|
648
|
+
ok = len(levels) <= ALPHA_LEVELS_MAX and solid
|
|
649
|
+
note = ""
|
|
650
|
+
if not solid:
|
|
651
|
+
note = " <- nothing is opaque, so the figure has no focal point"
|
|
652
|
+
elif len(levels) > ALPHA_LEVELS_MAX:
|
|
653
|
+
note = f" <- {len(levels)} levels reads as haze; keep to {ALPHA_LEVELS_MAX}"
|
|
654
|
+
return ok, f"alpha levels {levels}{note}"
|
|
655
|
+
|
|
656
|
+
|
|
657
|
+
def check_mark_ratio(fig):
|
|
658
|
+
"""One mark far larger than the rest stops reading as a mark and starts
|
|
659
|
+
reading as an ornament stuck on top of the plot.
|
|
660
|
+
|
|
661
|
+
Reads scatter sizes (already an area in pt^2) and line markers (a diameter
|
|
662
|
+
in points, so squared to match). Bars and other patches are deliberately
|
|
663
|
+
NOT counted: a bar thirty times another bar is the encoding working, not a
|
|
664
|
+
defect. This gate is about marks whose size is not carrying the value.
|
|
665
|
+
"""
|
|
666
|
+
worst = None
|
|
667
|
+
for ax in fig.axes:
|
|
668
|
+
sizes = []
|
|
669
|
+
for c in ax.collections:
|
|
670
|
+
s = getattr(c, "get_sizes", lambda: [])()
|
|
671
|
+
sizes.extend(float(v) for v in s if v > 0)
|
|
672
|
+
for ln in ax.lines:
|
|
673
|
+
if not ln.get_visible() or ln.get_marker() in ("", "None", None):
|
|
674
|
+
continue
|
|
675
|
+
ms = float(ln.get_markersize())
|
|
676
|
+
if ms > 0:
|
|
677
|
+
sizes.append(ms ** 2)
|
|
678
|
+
if len(sizes) < 2:
|
|
679
|
+
continue
|
|
680
|
+
ratio = max(sizes) / min(sizes)
|
|
681
|
+
if worst is None or ratio > worst[0]:
|
|
682
|
+
worst = (ratio, min(sizes), max(sizes))
|
|
683
|
+
if worst is None:
|
|
684
|
+
return True, "fewer than two mark sizes"
|
|
685
|
+
ratio, lo, hi = worst
|
|
686
|
+
return (ratio <= MARK_RATIO_MAX,
|
|
687
|
+
f"largest/smallest mark area {ratio:.1f}x (s={lo:.0f} to {hi:.0f})"
|
|
688
|
+
+ ("" if ratio <= MARK_RATIO_MAX
|
|
689
|
+
else f" <- cap at {MARK_RATIO_MAX}x"))
|
|
690
|
+
|
|
691
|
+
|
|
692
|
+
def check_overplotting(fig):
|
|
693
|
+
"""Warn when scatter points overlap into an unreadable mass.
|
|
694
|
+
|
|
695
|
+
For each PathCollection with offsets (a scatter), estimates the fraction of
|
|
696
|
+
points whose nearest neighbour in display pixels is within one marker
|
|
697
|
+
radius. Above the threshold the marks merge into a blob — thin the count,
|
|
698
|
+
use hollow markers, add transparency, or switch to hexbin.
|
|
699
|
+
"""
|
|
700
|
+
import numpy as np
|
|
701
|
+
try:
|
|
702
|
+
from scipy.spatial import cKDTree
|
|
703
|
+
except ImportError: # optional, see `_box_blur`
|
|
704
|
+
cKDTree = None
|
|
705
|
+
|
|
706
|
+
OVERPLOT_THRESHOLD = 0.5
|
|
707
|
+
dpi = fig.dpi
|
|
708
|
+
|
|
709
|
+
bad = []
|
|
710
|
+
for i, ax in enumerate(fig.axes):
|
|
711
|
+
for j, coll in enumerate(ax.collections):
|
|
712
|
+
try:
|
|
713
|
+
offsets = coll.get_offsets()
|
|
714
|
+
except Exception:
|
|
715
|
+
continue
|
|
716
|
+
if offsets.size < 2:
|
|
717
|
+
continue
|
|
718
|
+
try:
|
|
719
|
+
xy = ax.transData.transform(offsets)
|
|
720
|
+
except Exception:
|
|
721
|
+
continue
|
|
722
|
+
sizes = getattr(coll, "get_sizes", lambda: [])()
|
|
723
|
+
if len(sizes) == 0:
|
|
724
|
+
continue
|
|
725
|
+
sizes = np.asarray(sizes, dtype=float)
|
|
726
|
+
if sizes.size == 1:
|
|
727
|
+
sizes = np.full(len(offsets), sizes[0])
|
|
728
|
+
radius_px = np.sqrt(sizes / np.pi) * dpi / 72.0
|
|
729
|
+
|
|
730
|
+
n = len(xy)
|
|
731
|
+
if n < 2:
|
|
732
|
+
continue
|
|
733
|
+
if cKDTree is not None:
|
|
734
|
+
dists, _ = cKDTree(xy).query(xy, k=2)
|
|
735
|
+
if dists.ndim < 2:
|
|
736
|
+
continue
|
|
737
|
+
nn_dist = dists[:, 1]
|
|
738
|
+
else:
|
|
739
|
+
# Same nearest-neighbour distance, O(n^2). Only reached where
|
|
740
|
+
# scipy is absent, and only for the scatters this check looks
|
|
741
|
+
# at, so the cost lands on figures that already draw n points.
|
|
742
|
+
d = np.hypot(xy[:, 0][:, None] - xy[None, :, 0],
|
|
743
|
+
xy[:, 1][:, None] - xy[None, :, 1])
|
|
744
|
+
np.fill_diagonal(d, np.inf)
|
|
745
|
+
nn_dist = d.min(axis=1)
|
|
746
|
+
overlap = int((nn_dist < radius_px).sum())
|
|
747
|
+
frac = overlap / n
|
|
748
|
+
if frac > OVERPLOT_THRESHOLD:
|
|
749
|
+
bad.append((i, j, frac))
|
|
750
|
+
|
|
751
|
+
if not bad:
|
|
752
|
+
return True, "no scatter overplotting"
|
|
753
|
+
detail = "; ".join(f"ax{i}.col{j} {f:.0%}" for i, j, f in bad)
|
|
754
|
+
return "warn", (f"overplotting: {detail} — marks merge into blob; "
|
|
755
|
+
"thin counts, use hollow markers, add transparency, "
|
|
756
|
+
"or switch to hexbin")
|
|
757
|
+
|
|
758
|
+
|
|
759
|
+
def check_redundancy(fig, r):
|
|
760
|
+
"""Side-by-side panels on the same scale should share their axis furniture.
|
|
761
|
+
Two identical tick columns and two identical axis labels is duplicated ink."""
|
|
762
|
+
# Only same-row panels can share a y axis, and only same-column panels can
|
|
763
|
+
# share an x axis. Two panels side by side each legitimately need their own
|
|
764
|
+
# x label; repeating the y label between them is the duplication.
|
|
765
|
+
rows, cols = {}, {}
|
|
766
|
+
for ax in fig.axes:
|
|
767
|
+
ss = ax.get_subplotspec()
|
|
768
|
+
if ss is None:
|
|
769
|
+
continue
|
|
770
|
+
# A panel at `axis("off")` shows no furniture to duplicate. Its tick
|
|
771
|
+
# Text objects still exist and still carry their strings, which is how
|
|
772
|
+
# three image panels with no visible axis at all came to be told to
|
|
773
|
+
# "use sharex/sharey" — advice with nothing to act on.
|
|
774
|
+
if not ax.axison:
|
|
775
|
+
continue
|
|
776
|
+
r_, c_ = ss.rowspan.start, ss.colspan.start
|
|
777
|
+
rows.setdefault(r_, []).append(ax)
|
|
778
|
+
cols.setdefault(c_, []).append(ax)
|
|
779
|
+
|
|
780
|
+
dupes = []
|
|
781
|
+
for group, getter, axis in ((rows, "get_ylabel", "y"),
|
|
782
|
+
(cols, "get_xlabel", "x")):
|
|
783
|
+
for _, axes in group.items():
|
|
784
|
+
vals = [getattr(a, getter)().strip() for a in axes]
|
|
785
|
+
vals = [v for v in vals if v]
|
|
786
|
+
for label, n in Counter(vals).items():
|
|
787
|
+
if n > 1:
|
|
788
|
+
dupes.append(f"{axis}label {label!r} x{n}")
|
|
789
|
+
|
|
790
|
+
dup_ticks = 0
|
|
791
|
+
for _, axes in rows.items():
|
|
792
|
+
cols_seen = Counter(
|
|
793
|
+
tuple(t.get_text() for t in a.get_yticklabels()
|
|
794
|
+
if t.get_text() and t.get_visible())
|
|
795
|
+
for a in axes)
|
|
796
|
+
dup_ticks += sum(n - 1 for v, n in cols_seen.items() if v and n > 1)
|
|
797
|
+
|
|
798
|
+
ok = not dupes and not dup_ticks
|
|
799
|
+
if ok:
|
|
800
|
+
return True, "axis furniture not duplicated"
|
|
801
|
+
bits = dupes + ([f"repeated y tick column x{dup_ticks}"] if dup_ticks else [])
|
|
802
|
+
return False, "; ".join(bits) + " <- use sharex/sharey"
|
|
803
|
+
|
|
804
|
+
|
|
805
|
+
def check_type_size(fig, r, scale=None, placed_frac=1.0, venue=None):
|
|
806
|
+
"""Every rendered string clears the legibility floor once the figure is
|
|
807
|
+
scaled into the document.
|
|
808
|
+
|
|
809
|
+
This used to be a regex over the source file hunting for `fontsize=`, which
|
|
810
|
+
missed anything set through rcParams, anything computed, and anything set by
|
|
811
|
+
a helper. Reading `get_fontsize()` off the artists that actually rendered
|
|
812
|
+
reports what is on the page instead of what is in the source.
|
|
813
|
+
"""
|
|
814
|
+
scale = page_scale(fig, placed_frac, venue) if scale is None else scale
|
|
815
|
+
ghosts = _ghost_ticks(fig)
|
|
816
|
+
sizes = [(round(float(t.get_fontsize()) * scale, 1), str(t.get_text())[:22])
|
|
817
|
+
for t, _ in _texts(fig, r) if id(t) not in ghosts]
|
|
818
|
+
if not sizes:
|
|
819
|
+
return True, "no text"
|
|
820
|
+
small = sorted({(pt, s) for pt, s in sizes if pt < TYPE_FLOOR_PT})
|
|
821
|
+
mn = min(pt for pt, _ in sizes)
|
|
822
|
+
if not small:
|
|
823
|
+
detail = f"smallest {mn:.1f}pt on page (floor {TYPE_FLOOR_PT})"
|
|
824
|
+
if placed_frac < 0.35:
|
|
825
|
+
return "warn", (f"{detail}; placed at {placed_frac:.0%} of content width"
|
|
826
|
+
" — labels may be too small to read; author at the"
|
|
827
|
+
" width it ships at")
|
|
828
|
+
return True, detail
|
|
829
|
+
return False, (f"under {TYPE_FLOOR_PT}pt on page at scale {scale}: {small[:4]}"
|
|
830
|
+
" <- cut words, do not shrink type")
|
|
831
|
+
|
|
832
|
+
|
|
833
|
+
def _axes_drew_anything(ax):
|
|
834
|
+
"""Whether anything was drawn into this axes.
|
|
835
|
+
|
|
836
|
+
Not `_has_data`, which is a different question defined further down this
|
|
837
|
+
file: that one asks whether an axes carries *data*, which is what the
|
|
838
|
+
dual-axis gate needs. This asks whether the panel was used at all, so a
|
|
839
|
+
table or a lone annotation counts. The two names are close enough that one
|
|
840
|
+
shadowed the other once; keep them apart.
|
|
841
|
+
|
|
842
|
+
Every container matplotlib puts drawn content in: a blank axes has all of
|
|
843
|
+
them at zero, and a panel holding only an annotation or only a bar still
|
|
844
|
+
reports through one of them. `ax.patch` is the background and is
|
|
845
|
+
deliberately not consulted — it exists on the blank axes too.
|
|
846
|
+
"""
|
|
847
|
+
return any(len(getattr(ax, name, ())) for name in
|
|
848
|
+
("lines", "collections", "patches", "images", "texts",
|
|
849
|
+
"tables", "artists"))
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
def check_ink(fig, context_axes=None, canvas=None):
|
|
853
|
+
"""Ink as a fraction of each plotting area, measured off the rendered
|
|
854
|
+
pixels rather than estimated from artist properties.
|
|
855
|
+
|
|
856
|
+
Near zero means a panel that did not need to be a panel. Very high means a
|
|
857
|
+
panel with no ground left in it. Reported per-axes, and advisory only: the
|
|
858
|
+
right density genuinely depends on the form, so this flags panels worth a
|
|
859
|
+
second look rather than declaring them wrong.
|
|
860
|
+
|
|
861
|
+
Pass `context_axes` — a list of Axes whose fill is a context surface (e.g. a
|
|
862
|
+
contourf landscape) rather than data-ink. For those axes, the ink fraction
|
|
863
|
+
measures only marks ON TOP of the surface by separating the pixel values
|
|
864
|
+
into two clusters (k-means with k=2) and removing the larger cluster (the
|
|
865
|
+
surface). A figure with a filled terrain plus a few sparse marks will PASS
|
|
866
|
+
rather than WARN.
|
|
867
|
+
|
|
868
|
+
Pass `canvas` — an already-drawn Agg canvas — to avoid a second render.
|
|
869
|
+
"""
|
|
870
|
+
import numpy as np
|
|
871
|
+
|
|
872
|
+
if canvas is None:
|
|
873
|
+
from matplotlib.backends.backend_agg import FigureCanvasAgg
|
|
874
|
+
canvas = FigureCanvasAgg(fig)
|
|
875
|
+
canvas.draw()
|
|
876
|
+
buf = np.asarray(canvas.buffer_rgba())[:, :, :3].astype(int)
|
|
877
|
+
h = buf.shape[0]
|
|
878
|
+
bg = buf[0, 0]
|
|
879
|
+
# anything more than a few levels off the page color counts as ink
|
|
880
|
+
ink_mask = (np.abs(buf - bg).sum(axis=2) > 24)
|
|
881
|
+
|
|
882
|
+
if context_axes is None:
|
|
883
|
+
context_axes = []
|
|
884
|
+
context_ids = frozenset(id(ax) for ax in context_axes)
|
|
885
|
+
|
|
886
|
+
rows = []
|
|
887
|
+
for i, ax in enumerate(fig.axes):
|
|
888
|
+
# A colorbar is a solid ramp by construction: 100% ink, always, on
|
|
889
|
+
# every figure that has one. Measuring it means every heatmap in the
|
|
890
|
+
# world stands at WARN for the one axes in it whose density is not a
|
|
891
|
+
# choice anybody made. matplotlib labels the axes it creates.
|
|
892
|
+
if ax.get_label() == "<colorbar>":
|
|
893
|
+
continue
|
|
894
|
+
bb = ax.get_window_extent(renderer=canvas.get_renderer())
|
|
895
|
+
x0, x1 = int(max(bb.x0, 0)), int(min(bb.x1, buf.shape[1]))
|
|
896
|
+
y0, y1 = int(max(bb.y0, 0)), int(min(bb.y1, h))
|
|
897
|
+
# Agg's origin is top-left, the figure's is bottom-left
|
|
898
|
+
sub = ink_mask[h - y1:h - y0, x0:x1]
|
|
899
|
+
if sub.size == 0:
|
|
900
|
+
continue
|
|
901
|
+
|
|
902
|
+
if id(ax) in context_ids:
|
|
903
|
+
# Separate surface pixels from mark pixels via 2-means on color.
|
|
904
|
+
sub_buf = buf[h - y1:h - y0, x0:x1].astype(float)
|
|
905
|
+
flat = sub_buf.reshape(-1, 3)
|
|
906
|
+
m1 = flat.mean(axis=0)
|
|
907
|
+
# init second centroid offset so they diverge
|
|
908
|
+
m2 = m1 + 30.0
|
|
909
|
+
for _ in range(12):
|
|
910
|
+
d1 = np.abs(flat - m1).sum(axis=1)
|
|
911
|
+
d2 = np.abs(flat - m2).sum(axis=1)
|
|
912
|
+
c1 = d1 <= d2
|
|
913
|
+
c2 = ~c1
|
|
914
|
+
if c1.sum() == 0 or c2.sum() == 0:
|
|
915
|
+
break
|
|
916
|
+
nm1 = flat[c1].mean(axis=0)
|
|
917
|
+
nm2 = flat[c2].mean(axis=0)
|
|
918
|
+
if (np.abs(nm1 - m1).sum() < 0.5
|
|
919
|
+
and np.abs(nm2 - m2).sum() < 0.5):
|
|
920
|
+
break
|
|
921
|
+
m1, m2 = nm1, nm2
|
|
922
|
+
surf = c1 if c1.sum() > c2.sum() else c2
|
|
923
|
+
surf_mask = surf.reshape(sub.shape)
|
|
924
|
+
# Ink = pixels in the ink_mask AND not in the surface cluster
|
|
925
|
+
frac = float((sub & ~surf_mask).mean())
|
|
926
|
+
else:
|
|
927
|
+
frac = float(sub.mean())
|
|
928
|
+
# An empty panel is structural, not a low number. The frame and the
|
|
929
|
+
# tick marks of a blank axes measure about 0.03 on their own, over the
|
|
930
|
+
# 0.02 floor, so the blank subplot in a grid — the case that actually
|
|
931
|
+
# ships — read as merely sparse. Ask whether anything was drawn.
|
|
932
|
+
rows.append((i, frac,
|
|
933
|
+
_axes_drew_anything(ax) and INK_MIN <= frac <= INK_MAX))
|
|
934
|
+
|
|
935
|
+
if not rows:
|
|
936
|
+
return True, "no measurable axes"
|
|
937
|
+
detail = ", ".join(f"ax{i} {f:.2f}" for i, f, _ in rows)
|
|
938
|
+
odd = [i for i, _, g in rows if not g]
|
|
939
|
+
if not odd:
|
|
940
|
+
return True, f"ink fraction: {detail} (typical {INK_MIN}-{INK_MAX})"
|
|
941
|
+
return "warn", (f"ink fraction: {detail} (typical {INK_MIN}-{INK_MAX})"
|
|
942
|
+
f" - look at ax{odd}: empty panels and saturated ones both"
|
|
943
|
+
" read badly, though a heatmap legitimately runs high")
|
|
944
|
+
|
|
945
|
+
|
|
946
|
+
# --- color ------------------------------------------------------------------
|
|
947
|
+
|
|
948
|
+
def _hex(c):
|
|
949
|
+
"""A drawn color as lowercase 6-digit hex, or None if it never reaches the
|
|
950
|
+
page. Fully transparent is the case that matters: a bar's default edge color
|
|
951
|
+
is `none`, and reading it as black would put the ink token on every bar."""
|
|
952
|
+
from matplotlib.colors import to_hex, to_rgba
|
|
953
|
+
try:
|
|
954
|
+
r, g, b, a = to_rgba(c)
|
|
955
|
+
except (ValueError, TypeError):
|
|
956
|
+
return None
|
|
957
|
+
return None if a == 0 else to_hex((r, g, b))
|
|
958
|
+
|
|
959
|
+
|
|
960
|
+
def _colors_of(value):
|
|
961
|
+
"""Normalize the several shapes matplotlib returns a color in: a string from
|
|
962
|
+
`Line2D`, an RGBA tuple from `Patch`, an Nx4 array from a `Collection`."""
|
|
963
|
+
if isinstance(value, str):
|
|
964
|
+
return [value]
|
|
965
|
+
import numpy as np
|
|
966
|
+
try:
|
|
967
|
+
arr = np.asarray(value, dtype=float)
|
|
968
|
+
except (ValueError, TypeError):
|
|
969
|
+
return []
|
|
970
|
+
if arr.ndim == 1:
|
|
971
|
+
return [tuple(arr)] if arr.size in (3, 4) else []
|
|
972
|
+
if arr.ndim == 2 and arr.shape[1] in (3, 4):
|
|
973
|
+
return [tuple(row) for row in arr]
|
|
974
|
+
return []
|
|
975
|
+
|
|
976
|
+
|
|
977
|
+
def _artist_kind(artist):
|
|
978
|
+
"""A coarse artist family: a line, a scatter's marks, or a filled area.
|
|
979
|
+
|
|
980
|
+
The wrap check needs it. A cycler that wraps reuses one artist *type* - two
|
|
981
|
+
lines, two scatters - and hands them different labels. One series shown as a
|
|
982
|
+
band and its mean line and its points is three types in one hue, which is
|
|
983
|
+
the opposite: one identity, several artists. Kind tells them apart."""
|
|
984
|
+
from matplotlib.lines import Line2D
|
|
985
|
+
from matplotlib.collections import PathCollection
|
|
986
|
+
if isinstance(artist, Line2D):
|
|
987
|
+
# `plot(..., linestyle="none", marker="o")` is a Line2D that draws no
|
|
988
|
+
# line. Calling it one made a path and its own start marker, in one
|
|
989
|
+
# hue, read as a wrapped cycler handing two identities to one color —
|
|
990
|
+
# which is the exact case this function exists to tell apart.
|
|
991
|
+
stroke = str(artist.get_linestyle()).strip().lower()
|
|
992
|
+
drawn = stroke not in ("none", "", " ") and artist.get_linewidth() > 0
|
|
993
|
+
return "line" if drawn else "marks"
|
|
994
|
+
if isinstance(artist, PathCollection):
|
|
995
|
+
return "marks"
|
|
996
|
+
return "area" # bars, fill_between polys, other patches
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def _data_colors_by_axes(fig):
|
|
1000
|
+
"""`{axes: [(hex, label, kind), ...]}` - the categorical colors on the
|
|
1001
|
+
figure, kept apart by the panel they were drawn in and tagged with the kind
|
|
1002
|
+
of artist that drew them. `label` is None for an artist matplotlib named
|
|
1003
|
+
itself.
|
|
1004
|
+
|
|
1005
|
+
Structure the old figure-wide bag threw away. The panel is the unit a reader
|
|
1006
|
+
compares a hue within: two hues in different panels never sit side by side,
|
|
1007
|
+
so pooling every axes into one set gated a flow-chart node in one panel
|
|
1008
|
+
against a regression curve in another. The kind lets one identity drawn as
|
|
1009
|
+
several artist types in one hue read as one series, not a wrapped cycler.
|
|
1010
|
+
|
|
1011
|
+
Harvested narrowly on purpose. Two exclusions do the work:
|
|
1012
|
+
|
|
1013
|
+
- an artist with a colormap attached (`get_array()` is not None) encodes a
|
|
1014
|
+
*value*, not an identity - a heatmap or a scatter colored by magnitude is
|
|
1015
|
+
a continuous encoding and answers to the viridis rule instead. This is
|
|
1016
|
+
also the escape hatch for an ordinal ramp: draw it `c=values, cmap=...`,
|
|
1017
|
+
never as a pre-evaluated RGBA list, and it is read as the value it is.
|
|
1018
|
+
- the ink tokens, per `INK_TOKENS` above
|
|
1019
|
+
"""
|
|
1020
|
+
out = {}
|
|
1021
|
+
for ax in fig.axes:
|
|
1022
|
+
items = []
|
|
1023
|
+
for artist in list(ax.lines) + list(ax.patches) + list(ax.collections):
|
|
1024
|
+
if not artist.get_visible():
|
|
1025
|
+
continue
|
|
1026
|
+
if getattr(artist, "get_array", lambda: None)() is not None:
|
|
1027
|
+
continue
|
|
1028
|
+
raw = str(artist.get_label() or "")
|
|
1029
|
+
label = raw if raw and not raw.startswith("_") else None
|
|
1030
|
+
kind = _artist_kind(artist)
|
|
1031
|
+
seen = set()
|
|
1032
|
+
for getter in ("get_color", "get_facecolor", "get_edgecolor"):
|
|
1033
|
+
fn = getattr(artist, getter, None)
|
|
1034
|
+
if fn is None:
|
|
1035
|
+
continue
|
|
1036
|
+
try:
|
|
1037
|
+
value = fn()
|
|
1038
|
+
except (ValueError, TypeError, AttributeError):
|
|
1039
|
+
continue
|
|
1040
|
+
for c in _colors_of(value):
|
|
1041
|
+
h = _hex(c)
|
|
1042
|
+
if h and h not in INK_TOKENS and h not in seen:
|
|
1043
|
+
seen.add(h)
|
|
1044
|
+
items.append((h, label, kind))
|
|
1045
|
+
if items:
|
|
1046
|
+
out[ax] = items
|
|
1047
|
+
return out
|
|
1048
|
+
|
|
1049
|
+
|
|
1050
|
+
def _data_colors(fig):
|
|
1051
|
+
"""Flat `(hex, label)` across the whole figure, for the checks that only
|
|
1052
|
+
need the bag of identified hues and not the panel each lives in."""
|
|
1053
|
+
return [(h, label) for items in _data_colors_by_axes(fig).values()
|
|
1054
|
+
for h, label, _ in items]
|
|
1055
|
+
|
|
1056
|
+
|
|
1057
|
+
def _axes_all_pairs(ax):
|
|
1058
|
+
"""Which separation mode a *panel* needs. `check_palette.py` has to ask for
|
|
1059
|
+
this on the command line because a list of hexes does not say what it will be
|
|
1060
|
+
drawn as; a built figure does say. Scatter puts every series next to every
|
|
1061
|
+
other, so every pair has to separate. Lines and bars only ever put
|
|
1062
|
+
neighbours next to each other, and gating all pairs there would fail
|
|
1063
|
+
palettes the guide explicitly sanctions.
|
|
1064
|
+
|
|
1065
|
+
Asked per axes, not per figure: a scatter in one panel does not make a
|
|
1066
|
+
line-only panel two panels over answer to the stricter rule."""
|
|
1067
|
+
from matplotlib.collections import PathCollection
|
|
1068
|
+
return any(isinstance(c, PathCollection) for c in ax.collections)
|
|
1069
|
+
|
|
1070
|
+
|
|
1071
|
+
def check_series_color(fig):
|
|
1072
|
+
"""The hues actually drawn, put through the palette gates.
|
|
1073
|
+
|
|
1074
|
+
The hole this closes: `check_palette.py` judges a list of hexes someone
|
|
1075
|
+
remembered to paste into a terminal, and this file never looked at color at
|
|
1076
|
+
all. A figure on matplotlib's default `tab10` cycle - whose orange and green
|
|
1077
|
+
measure OKLab dE 1.4 under protanopia - passed every composition check
|
|
1078
|
+
clean. Two scripts in one project that never spoke.
|
|
1079
|
+
|
|
1080
|
+
Only what is never legitimate is gated: separation under color blindness,
|
|
1081
|
+
and separation in normal vision. The lightness-band and chroma-floor rows
|
|
1082
|
+
are deliberately *not* applied to harvested colors. A black or gray series
|
|
1083
|
+
is legal - a reference curve, a control group - and failing it is precisely
|
|
1084
|
+
the noise that teaches people to skim past the row.
|
|
1085
|
+
|
|
1086
|
+
Scoped per panel. The comparison, the hue count and the all-pairs mode are
|
|
1087
|
+
all asked of one axes at a time, because the panel is the unit a reader
|
|
1088
|
+
separates hues within - a figure-wide bag gated hues that never share a
|
|
1089
|
+
frame against each other.
|
|
1090
|
+
"""
|
|
1091
|
+
by_ax = _data_colors_by_axes(fig)
|
|
1092
|
+
if not by_ax:
|
|
1093
|
+
return True, "no categorical series colors"
|
|
1094
|
+
|
|
1095
|
+
fails, notes = [], []
|
|
1096
|
+
cp = None
|
|
1097
|
+
try:
|
|
1098
|
+
import check_palette as cp
|
|
1099
|
+
except ImportError:
|
|
1100
|
+
notes.append("check_palette.py is not importable beside this file, "
|
|
1101
|
+
"so separation went unchecked")
|
|
1102
|
+
|
|
1103
|
+
for ax, items in by_ax.items():
|
|
1104
|
+
distinct = list(dict.fromkeys(h for h, _, _ in items))
|
|
1105
|
+
|
|
1106
|
+
if len(distinct) > MAX_SERIES_HUES:
|
|
1107
|
+
fails.append(f"{len(distinct)} distinct data hues in one panel, "
|
|
1108
|
+
f"theme has {MAX_SERIES_HUES} <- fold the tail into "
|
|
1109
|
+
"'Other' or facet")
|
|
1110
|
+
|
|
1111
|
+
# One hue carrying two identities is what a seventh series looks like
|
|
1112
|
+
# once the cycler wraps: matplotlib reuses slot 1 without complaint and
|
|
1113
|
+
# the legend confidently lists both. Narrowed to labels on artists of the
|
|
1114
|
+
# *same kind*: a wrap reuses one artist type, whereas a band, its mean
|
|
1115
|
+
# line and its points in one hue is one series shown three ways, each
|
|
1116
|
+
# legitimately labelled. Keyed on kind, that reads as one identity.
|
|
1117
|
+
by_hue_kind = {}
|
|
1118
|
+
for h, label, kind in items:
|
|
1119
|
+
if label:
|
|
1120
|
+
by_hue_kind.setdefault((h, kind), set()).add(label)
|
|
1121
|
+
for (h, kind), labels in sorted(by_hue_kind.items()):
|
|
1122
|
+
if len(labels) > 1:
|
|
1123
|
+
fails.append(f"{h} carries {len(labels)} identities "
|
|
1124
|
+
f"{sorted(labels)} on {kind} artists"
|
|
1125
|
+
" <- the color cycle wrapped")
|
|
1126
|
+
|
|
1127
|
+
if len(distinct) >= 2 and cp is not None:
|
|
1128
|
+
rows, _ = cp.check(distinct, all_pairs=_axes_all_pairs(ax))
|
|
1129
|
+
for name, status, detail in rows:
|
|
1130
|
+
if not name.startswith(("CVD separation", "Normal-vision floor")):
|
|
1131
|
+
continue
|
|
1132
|
+
if status is False:
|
|
1133
|
+
fails.append(detail)
|
|
1134
|
+
else:
|
|
1135
|
+
notes.append(detail.split(" <-")[0].strip())
|
|
1136
|
+
|
|
1137
|
+
max_per_panel = max(
|
|
1138
|
+
(len(list(dict.fromkeys(h for h, _, _ in items)))
|
|
1139
|
+
for items in by_ax.values()), default=0)
|
|
1140
|
+
head = f"up to {max_per_panel} data hues per panel"
|
|
1141
|
+
if fails:
|
|
1142
|
+
return False, f"{head}: " + "; ".join(fails)
|
|
1143
|
+
return True, f"{head}: " + ("; ".join(notes) if notes else "nothing to compare")
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
# --- structure --------------------------------------------------------------
|
|
1147
|
+
|
|
1148
|
+
def _has_data(ax):
|
|
1149
|
+
return any(a.get_visible() for a in
|
|
1150
|
+
list(ax.lines) + list(ax.patches)
|
|
1151
|
+
+ list(ax.collections) + list(ax.images))
|
|
1152
|
+
|
|
1153
|
+
|
|
1154
|
+
def check_dual_axis(fig):
|
|
1155
|
+
"""Two y scales in one frame, which nothing in this project banned and a
|
|
1156
|
+
`twinx` figure sailed straight through.
|
|
1157
|
+
|
|
1158
|
+
Both scales are set by the author, so the crossing point of the two curves
|
|
1159
|
+
is an artifact of the limits chosen rather than anything in the data. Move
|
|
1160
|
+
the limits and the story changes; a reader cannot tell that from the figure.
|
|
1161
|
+
|
|
1162
|
+
The escape hatch is the one legitimate case: a *pure unit relabel* - degrees
|
|
1163
|
+
C against degrees F, eV against nm - where the twin is furniture and carries
|
|
1164
|
+
no data of its own. So the discriminator is data on both, not a shared
|
|
1165
|
+
frame, which keeps the gate off `secondary_yaxis` and off correct work.
|
|
1166
|
+
"""
|
|
1167
|
+
pairs = []
|
|
1168
|
+
for i, j in itertools.combinations(range(len(fig.axes)), 2):
|
|
1169
|
+
a, b = fig.axes[i], fig.axes[j]
|
|
1170
|
+
if any(abs(x - y) > FRAME_TOL for x, y in
|
|
1171
|
+
zip(a.get_position().bounds, b.get_position().bounds)):
|
|
1172
|
+
continue
|
|
1173
|
+
if _has_data(a) and _has_data(b):
|
|
1174
|
+
pairs.append(f"ax{i}+ax{j}")
|
|
1175
|
+
if not pairs:
|
|
1176
|
+
return True, "one data scale per frame"
|
|
1177
|
+
return False, (f"two y scales sharing a frame: {', '.join(pairs)} <- the "
|
|
1178
|
+
"crossing point is set by the limits, not the data. Two "
|
|
1179
|
+
"panels, small multiples, or index both to a common base")
|
|
1180
|
+
|
|
1181
|
+
|
|
1182
|
+
def check_form(fig):
|
|
1183
|
+
"""The mechanical subset of form choice - the three cases where the form is
|
|
1184
|
+
wrong no matter what the data is. `references/choosing-a-form.md` carries
|
|
1185
|
+
the judgement calls this cannot make.
|
|
1186
|
+
"""
|
|
1187
|
+
from matplotlib.container import BarContainer
|
|
1188
|
+
from matplotlib.patches import Wedge
|
|
1189
|
+
|
|
1190
|
+
bad = []
|
|
1191
|
+
for i, ax in enumerate(fig.axes):
|
|
1192
|
+
if any(isinstance(p, Wedge) for p in ax.patches):
|
|
1193
|
+
bad.append(f"ax{i} pie/donut: angle and area are the two tasks the "
|
|
1194
|
+
"eye judges worst - a dot plot or a bar reads as position")
|
|
1195
|
+
if hasattr(ax, "get_zlim"):
|
|
1196
|
+
bad.append(f"ax{i} 3D: perspective makes the encoding unreadable and "
|
|
1197
|
+
"occludes data - facet or use color for the third variable")
|
|
1198
|
+
for con in getattr(ax, "containers", []):
|
|
1199
|
+
if not isinstance(con, BarContainer):
|
|
1200
|
+
continue
|
|
1201
|
+
vertical = getattr(con, "orientation", "vertical") == "vertical"
|
|
1202
|
+
lim = ax.get_ylim() if vertical else ax.get_xlim()
|
|
1203
|
+
scale = ax.get_yscale() if vertical else ax.get_xscale()
|
|
1204
|
+
# A log axis cannot include zero, so a log bar chart is truncated by
|
|
1205
|
+
# construction and this gate has nothing to say about it.
|
|
1206
|
+
if scale == "linear" and min(lim) > 0:
|
|
1207
|
+
axis = "y" if vertical else "x"
|
|
1208
|
+
bad.append(
|
|
1209
|
+
f"ax{i} bars on a truncated {axis} axis (starts at "
|
|
1210
|
+
f"{min(lim):.4g}): bar length encodes the value, so a "
|
|
1211
|
+
"cut baseline misstates every ratio <- the fix is the "
|
|
1212
|
+
"form, not the axis - use a dot plot")
|
|
1213
|
+
break
|
|
1214
|
+
if not bad:
|
|
1215
|
+
return True, "no pie, no 3D, no truncated bar baseline"
|
|
1216
|
+
return False, "; ".join(bad)
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def check_identity_channel(fig):
|
|
1220
|
+
"""Identity carried by color and nothing else.
|
|
1221
|
+
|
|
1222
|
+
A warning rather than a gate, and the reason is honesty about what the
|
|
1223
|
+
script can see: it can count the hues, but it cannot tell a direct label
|
|
1224
|
+
from any other piece of text in the axes. Failing on that guess would fire
|
|
1225
|
+
on correct work, and a gate people learn to skip is worse than no gate.
|
|
1226
|
+
"""
|
|
1227
|
+
labeled = {h for h, label in _data_colors(fig) if label}
|
|
1228
|
+
if len(labeled) < 2:
|
|
1229
|
+
return True, "fewer than two identified series"
|
|
1230
|
+
if fig.legends or any(ax.get_legend() is not None for ax in fig.axes):
|
|
1231
|
+
return True, f"{len(labeled)} series, legend present"
|
|
1232
|
+
if any(ax.texts for ax in fig.axes):
|
|
1233
|
+
return True, f"{len(labeled)} series, in-axes text (assumed direct labels)"
|
|
1234
|
+
return "warn", (f"{len(labeled)} series told apart by hue alone - no legend "
|
|
1235
|
+
"and no text in the axes. Direct labels beat a legend here: "
|
|
1236
|
+
"they remove the match-the-swatch step, and orange and sky "
|
|
1237
|
+
"blue are under 3:1 on white, where that step is hardest")
|
|
1238
|
+
|
|
1239
|
+
|
|
1240
|
+
def _polyline_px(line, ax):
|
|
1241
|
+
"""A line's vertices in display space, densified so no gap exceeds 2px.
|
|
1242
|
+
|
|
1243
|
+
Vertices alone are not enough on a sparsely sampled line: two points 200px
|
|
1244
|
+
apart say nothing about the stroke between them, and that stroke is what the
|
|
1245
|
+
label actually lands next to.
|
|
1246
|
+
"""
|
|
1247
|
+
import numpy as np
|
|
1248
|
+
xy = np.asarray(line.get_xydata(), dtype=float)
|
|
1249
|
+
if xy.ndim != 2 or len(xy) == 0:
|
|
1250
|
+
return None
|
|
1251
|
+
pts = ax.transData.transform(xy)
|
|
1252
|
+
pts = pts[np.isfinite(pts).all(axis=1)]
|
|
1253
|
+
if len(pts) < 2:
|
|
1254
|
+
return pts if len(pts) else None
|
|
1255
|
+
step = pts[1:] - pts[:-1]
|
|
1256
|
+
counts = np.maximum(1, np.ceil(np.hypot(step[:, 0], step[:, 1]) / 2.0)
|
|
1257
|
+
).astype(int)
|
|
1258
|
+
out = [p + (np.arange(k) / k)[:, None] * d
|
|
1259
|
+
for p, d, k in zip(pts[:-1], step, counts)]
|
|
1260
|
+
out.append(pts[-1:])
|
|
1261
|
+
return np.vstack(out)
|
|
1262
|
+
|
|
1263
|
+
|
|
1264
|
+
def _legend_text_ids(fig):
|
|
1265
|
+
"""Ids of every Text that belongs to a legend, figure-level or axes-level.
|
|
1266
|
+
|
|
1267
|
+
A legend is a lookup key placed *away* from the curves by design, so its
|
|
1268
|
+
entries can never pass a proximity check - and they slip past the `t.axes is
|
|
1269
|
+
ax` guard, because a legend child's `.axes` resolves to the parent axes. The
|
|
1270
|
+
attribution check has to drop them explicitly or it fails every figure that
|
|
1271
|
+
keeps a legend."""
|
|
1272
|
+
ids = set()
|
|
1273
|
+
legends = list(fig.legends)
|
|
1274
|
+
for ax in fig.axes:
|
|
1275
|
+
lg = ax.get_legend()
|
|
1276
|
+
if lg is not None:
|
|
1277
|
+
legends.append(lg)
|
|
1278
|
+
for lg in legends:
|
|
1279
|
+
for t in lg.get_texts():
|
|
1280
|
+
ids.add(id(t))
|
|
1281
|
+
return ids
|
|
1282
|
+
|
|
1283
|
+
|
|
1284
|
+
def _box_distance(bb, pts):
|
|
1285
|
+
"""Shortest distance from a text's box to any point on a polyline, in px.
|
|
1286
|
+
Zero when the stroke passes under the text."""
|
|
1287
|
+
import numpy as np
|
|
1288
|
+
if len(pts) == 0:
|
|
1289
|
+
return float('inf')
|
|
1290
|
+
dx = np.maximum.reduce([bb.x0 - pts[:, 0], pts[:, 0] - bb.x1,
|
|
1291
|
+
np.zeros(len(pts))])
|
|
1292
|
+
dy = np.maximum.reduce([bb.y0 - pts[:, 1], pts[:, 1] - bb.y1,
|
|
1293
|
+
np.zeros(len(pts))])
|
|
1294
|
+
return float(np.min(np.hypot(dx, dy)))
|
|
1295
|
+
|
|
1296
|
+
|
|
1297
|
+
def check_label_attribution(fig, r):
|
|
1298
|
+
"""A direct label sitting nearer some other series than the one it names.
|
|
1299
|
+
|
|
1300
|
+
`check_collisions` compares text against text, so a label that clears every
|
|
1301
|
+
other label and still floats in the corridor between two curves passes it
|
|
1302
|
+
clean. That is not hypothetical: `examples/demo.py` shipped with "Tuned"
|
|
1303
|
+
closer to a neighbouring curve than to its own and the whole suite was
|
|
1304
|
+
green. Text against text and text against data are different questions.
|
|
1305
|
+
|
|
1306
|
+
Harvested narrowly on purpose - only text whose string matches exactly one
|
|
1307
|
+
series label is judged, because only there is the intent known. A callout,
|
|
1308
|
+
a panel letter, an "n = 300" attributes nothing to a curve, and failing
|
|
1309
|
+
those is the noise that teaches people to skim the row.
|
|
1310
|
+
|
|
1311
|
+
The threshold is a ratio rather than a distance because the judgement the
|
|
1312
|
+
reader makes is comparative: a label is unambiguous when its own curve is
|
|
1313
|
+
plainly the closest thing to it, not when it is some absolute number of
|
|
1314
|
+
points away.
|
|
1315
|
+
"""
|
|
1316
|
+
bad, checked = [], 0
|
|
1317
|
+
legend_ids = _legend_text_ids(fig)
|
|
1318
|
+
all_texts = _texts(fig, r)
|
|
1319
|
+
for ax in fig.axes:
|
|
1320
|
+
px = {}
|
|
1321
|
+
for line in ax.lines:
|
|
1322
|
+
if not line.get_visible():
|
|
1323
|
+
continue
|
|
1324
|
+
p = _polyline_px(line, ax)
|
|
1325
|
+
if p is not None and len(p):
|
|
1326
|
+
px[line] = p
|
|
1327
|
+
if len(px) < 2:
|
|
1328
|
+
continue
|
|
1329
|
+
|
|
1330
|
+
lines_list = list(px.keys())
|
|
1331
|
+
owners = {}
|
|
1332
|
+
for i, line in enumerate(lines_list):
|
|
1333
|
+
owners.setdefault(str(line.get_label()).strip(), []).append(i)
|
|
1334
|
+
|
|
1335
|
+
for t, bb in all_texts:
|
|
1336
|
+
if t.axes is not ax or id(t) in legend_ids:
|
|
1337
|
+
continue
|
|
1338
|
+
match = owners.get(str(t.get_text()).strip())
|
|
1339
|
+
if not match or len(match) != 1:
|
|
1340
|
+
continue
|
|
1341
|
+
own_line = lines_list[match[0]]
|
|
1342
|
+
checked += 1
|
|
1343
|
+
# A floor on the own-curve distance: without it a label printed
|
|
1344
|
+
# directly on its line divides by ~zero, and every other line in
|
|
1345
|
+
# the figure reads as infinitely far.
|
|
1346
|
+
d_own = max(_box_distance(bb, px[own_line]), 0.5)
|
|
1347
|
+
# The minimum over every OTHER curve, box-to-polyline. A KD-tree
|
|
1348
|
+
# over the pooled points was tried here for speed and was wrong:
|
|
1349
|
+
# it returns the nearest *points*, so for a label sitting close to
|
|
1350
|
+
# its own dense curve all the near points belong to that curve, no
|
|
1351
|
+
# other curve is ever reached, and `d_other` stays infinite. Which
|
|
1352
|
+
# is to say it passed every label it was closest to — the common
|
|
1353
|
+
# case, and the one the gate exists for.
|
|
1354
|
+
d_other = min(_box_distance(bb, p)
|
|
1355
|
+
for line, p in px.items() if line is not own_line)
|
|
1356
|
+
if d_other < LABEL_MARGIN * d_own:
|
|
1357
|
+
bad.append(f"{str(t.get_text())[:22]!r} is {d_own:.0f}px from "
|
|
1358
|
+
f"its own curve and {d_other:.0f}px from another")
|
|
1359
|
+
if not checked:
|
|
1360
|
+
return True, "no direct labels matched to a series"
|
|
1361
|
+
if not bad:
|
|
1362
|
+
return True, f"{checked} direct label{'s' if checked != 1 else ''}, "\
|
|
1363
|
+
f"each nearest the curve it names"
|
|
1364
|
+
return False, ("; ".join(bad) + f" <- the reader resolves a direct label "
|
|
1365
|
+
"by proximity, so it has to be plainly nearest its own "
|
|
1366
|
+
"curve. Move it to where that curve is furthest from its "
|
|
1367
|
+
"neighbours, or draw a leader line to the anchor")
|
|
1368
|
+
|
|
1369
|
+
|
|
1370
|
+
# --- the sheet itself -------------------------------------------------------
|
|
1371
|
+
|
|
1372
|
+
def _style_sheet():
|
|
1373
|
+
"""`figure.mplstyle` as the skill tells you to lay it out (beside this
|
|
1374
|
+
script), and as this repository lays it out (`assets/` next to
|
|
1375
|
+
`scripts/`)."""
|
|
1376
|
+
here = Path(__file__).resolve().parent
|
|
1377
|
+
for cand in (here / "figure.mplstyle",
|
|
1378
|
+
here.parent / "assets" / "figure.mplstyle"):
|
|
1379
|
+
if cand.is_file():
|
|
1380
|
+
return cand
|
|
1381
|
+
return None
|
|
1382
|
+
|
|
1383
|
+
|
|
1384
|
+
def _is_dashed_linestyle(ls):
|
|
1385
|
+
"""True if a linestyle value is not solid.
|
|
1386
|
+
|
|
1387
|
+
Accepts strings ('dashed', '--', '-.', ':') and (offset, dashes) tuples.
|
|
1388
|
+
"""
|
|
1389
|
+
if isinstance(ls, str):
|
|
1390
|
+
return ls.lower() not in ("solid", "-", "", "none")
|
|
1391
|
+
if isinstance(ls, (tuple, list)):
|
|
1392
|
+
if len(ls) < 2:
|
|
1393
|
+
return False
|
|
1394
|
+
dashes = ls[1]
|
|
1395
|
+
return dashes is not None and bool(dashes)
|
|
1396
|
+
return False
|
|
1397
|
+
|
|
1398
|
+
|
|
1399
|
+
def check_contour_dash(fig):
|
|
1400
|
+
"""Negative-level contours auto-dash via matplotlib default.
|
|
1401
|
+
|
|
1402
|
+
In a monochrome contour, `rcParams["contour.negative_linestyle"]` is
|
|
1403
|
+
"dashed" by default, so negative-Z contours ship dashed isolines nobody
|
|
1404
|
+
chose. The skill's own convention is dashing = unobserved / projected /
|
|
1405
|
+
threshold, making this a silent semantic error every existing gate misses.
|
|
1406
|
+
|
|
1407
|
+
Non-monochrome (colored) contours are always solid and unaffected.
|
|
1408
|
+
"""
|
|
1409
|
+
from matplotlib.contour import ContourSet
|
|
1410
|
+
|
|
1411
|
+
warned = []
|
|
1412
|
+
for i, ax in enumerate(fig.axes):
|
|
1413
|
+
for c in ax.collections:
|
|
1414
|
+
if not isinstance(c, ContourSet):
|
|
1415
|
+
continue
|
|
1416
|
+
levels = getattr(c, "levels", None)
|
|
1417
|
+
if levels is None or len(levels) == 0:
|
|
1418
|
+
continue
|
|
1419
|
+
if not all(l <= 0 for l in levels):
|
|
1420
|
+
continue
|
|
1421
|
+
if not getattr(c, "monochrome", False):
|
|
1422
|
+
continue
|
|
1423
|
+
nl = getattr(c, "negative_linestyles", None)
|
|
1424
|
+
if nl is None:
|
|
1425
|
+
continue
|
|
1426
|
+
# negative_linestyles may be a scalar (string or tuple) or a list;
|
|
1427
|
+
# handle both. If it's a list, any non-solid entry triggers.
|
|
1428
|
+
styles = nl if isinstance(nl, (tuple, list)) else [nl]
|
|
1429
|
+
if any(_is_dashed_linestyle(s) for s in styles):
|
|
1430
|
+
warned.append(
|
|
1431
|
+
f"ax{i}: negative-level contours auto-dashed — dashing "
|
|
1432
|
+
"reads as projected/unobserved here; pass "
|
|
1433
|
+
'linestyles="solid" to contour on signed data')
|
|
1434
|
+
break
|
|
1435
|
+
|
|
1436
|
+
if not warned:
|
|
1437
|
+
return True, "no auto-dashed negative contours"
|
|
1438
|
+
return "warn", "; ".join(warned)
|
|
1439
|
+
|
|
1440
|
+
|
|
1441
|
+
def check_line_weight(fig, scale=None, placed_frac=1.0, venue=None):
|
|
1442
|
+
"""Every drawn stroke against the printer's floor, measured ON THE PAGE.
|
|
1443
|
+
|
|
1444
|
+
SIAM states it plainly in its instructions for authors: illustrations must
|
|
1445
|
+
use lines one point or thicker, because thinner lines break up or disappear.
|
|
1446
|
+
It is the same failure as the type floor and it has the same cause — a
|
|
1447
|
+
stroke authored at 0.8pt in a 9-inch figure placed at 5.5 inches prints at
|
|
1448
|
+
0.49pt — so it is measured the same way, through `page_scale`.
|
|
1449
|
+
|
|
1450
|
+
Furniture is held to a lower floor than data. A gridline that drops out at
|
|
1451
|
+
the printer costs the reader a reference; a data curve that drops out costs
|
|
1452
|
+
them the finding. The sheet ships the grid at 0.7pt deliberately, and
|
|
1453
|
+
failing it against the data floor would be failing the sheet's own design.
|
|
1454
|
+
"""
|
|
1455
|
+
from matplotlib.lines import Line2D
|
|
1456
|
+
from matplotlib.collections import LineCollection
|
|
1457
|
+
|
|
1458
|
+
if scale is None:
|
|
1459
|
+
scale = page_scale(fig, placed_frac, venue)
|
|
1460
|
+
|
|
1461
|
+
thin, widths = [], []
|
|
1462
|
+
for ax in fig.axes:
|
|
1463
|
+
# A colorbar's dividers ship at 0.4pt and are matplotlib's, not
|
|
1464
|
+
# anybody's design decision — the same reason `check_ink` skips this
|
|
1465
|
+
# axes entirely.
|
|
1466
|
+
if ax.get_label() == "<colorbar>":
|
|
1467
|
+
continue
|
|
1468
|
+
gridlines = {id(g) for axis in (ax.xaxis, ax.yaxis)
|
|
1469
|
+
for g in axis.get_gridlines()}
|
|
1470
|
+
for artist in list(ax.lines) + list(ax.collections):
|
|
1471
|
+
if not artist.get_visible() or id(artist) in gridlines:
|
|
1472
|
+
continue
|
|
1473
|
+
if isinstance(artist, Line2D):
|
|
1474
|
+
stroke = str(artist.get_linestyle()).strip().lower()
|
|
1475
|
+
if stroke in ("none", "", " "):
|
|
1476
|
+
continue
|
|
1477
|
+
raw = [artist.get_linewidth()]
|
|
1478
|
+
elif isinstance(artist, LineCollection):
|
|
1479
|
+
raw = list(artist.get_linewidth())
|
|
1480
|
+
elif getattr(artist, "filled", None) is False:
|
|
1481
|
+
# An unfilled ContourSet is strokes. A *filled* one is bands
|
|
1482
|
+
# whose linewidth is the seam between two fills, which no
|
|
1483
|
+
# reader is being asked to see.
|
|
1484
|
+
raw = list(artist.get_linewidth())
|
|
1485
|
+
else:
|
|
1486
|
+
continue
|
|
1487
|
+
for w in raw:
|
|
1488
|
+
on_page = float(w) * scale
|
|
1489
|
+
if on_page <= 0:
|
|
1490
|
+
continue
|
|
1491
|
+
widths.append(on_page)
|
|
1492
|
+
if on_page < LINE_FLOOR_PT:
|
|
1493
|
+
name = str(artist.get_label() or "")
|
|
1494
|
+
thin.append(f"{name if name and not name.startswith('_') else 'a stroke'}"
|
|
1495
|
+
f" at {on_page:.2f}pt")
|
|
1496
|
+
|
|
1497
|
+
if not widths:
|
|
1498
|
+
return True, "no strokes to measure"
|
|
1499
|
+
if not thin:
|
|
1500
|
+
return True, (f"{len(widths)} strokes, thinnest {min(widths):.2f}pt on "
|
|
1501
|
+
f"page (floor {LINE_FLOOR_PT})")
|
|
1502
|
+
seen = list(dict.fromkeys(thin))
|
|
1503
|
+
return False, (f"under {LINE_FLOOR_PT}pt on page at scale {scale:.2f}: "
|
|
1504
|
+
f"{seen[:4]} <- SIAM: lines thinner than one point break up "
|
|
1505
|
+
"or disappear in print")
|
|
1506
|
+
|
|
1507
|
+
|
|
1508
|
+
def check_fonts(fig):
|
|
1509
|
+
"""Two silent failures between the figure on screen and the file you submit.
|
|
1510
|
+
|
|
1511
|
+
*Type 3.* Matplotlib defaults `pdf.fonttype` and `ps.fonttype` to 3. IEEE,
|
|
1512
|
+
ACM and Elsevier all reject submissions carrying Type 3 fonts, and IEEE PDF
|
|
1513
|
+
eXpress fails the upload outright. Nothing warns you: the figure renders
|
|
1514
|
+
identically, and the paper bounces at the latest and most expensive possible
|
|
1515
|
+
moment. Type 42 embeds TrueType outlines instead. `figure.mplstyle` sets it;
|
|
1516
|
+
this catches the project that did not copy the sheet, and the notebook that
|
|
1517
|
+
called `rcParams.update` afterwards.
|
|
1518
|
+
|
|
1519
|
+
*Silent substitution.* When none of the faces named in `font.<family>` is
|
|
1520
|
+
installed, matplotlib falls back to its own default and logs nothing at
|
|
1521
|
+
default verbosity. The guide calls the typeface the single largest visual
|
|
1522
|
+
lever in the whole method, so a figure set in DejaVu because someone named
|
|
1523
|
+
"Times New Roman" on a machine that does not have it is the lever quietly
|
|
1524
|
+
disengaged. Falling back *within* the named list is not flagged — that is
|
|
1525
|
+
what a fallback list is for, and the sheet ships one on purpose.
|
|
1526
|
+
|
|
1527
|
+
A warning rather than a gate, for the same reason `check_style_sheet` is:
|
|
1528
|
+
both read the *global* rcParams rather than anything the figure carries, so
|
|
1529
|
+
neither can tell a figure built under someone else's settings from a figure
|
|
1530
|
+
built under none. A figure that will only ever be a PNG in a README is also
|
|
1531
|
+
genuinely unaffected by the PDF font type. What is gated instead is the
|
|
1532
|
+
thing this repo controls — the shipped `figure.mplstyle` declares 42, and
|
|
1533
|
+
the suite fails if that line ever goes missing.
|
|
1534
|
+
"""
|
|
1535
|
+
import matplotlib as mpl
|
|
1536
|
+
from matplotlib.font_manager import FontProperties, findfont, get_font
|
|
1537
|
+
|
|
1538
|
+
notes = []
|
|
1539
|
+
type3 = [k for k in ("pdf.fonttype", "ps.fonttype")
|
|
1540
|
+
if int(mpl.rcParams[k]) == 3]
|
|
1541
|
+
if type3:
|
|
1542
|
+
notes.append(f"{' and '.join(type3)} = 3 (Type 3) <- IEEE PDF eXpress "
|
|
1543
|
+
"rejects the upload and ACM/Elsevier reject the "
|
|
1544
|
+
"submission; set both to 42")
|
|
1545
|
+
|
|
1546
|
+
family = mpl.rcParams["font.family"]
|
|
1547
|
+
generic = family[0] if isinstance(family, (list, tuple)) else family
|
|
1548
|
+
wanted = list(mpl.rcParams.get(f"font.{generic}", []))
|
|
1549
|
+
if wanted:
|
|
1550
|
+
try:
|
|
1551
|
+
got = get_font(findfont(FontProperties(family=generic))).family_name
|
|
1552
|
+
except Exception:
|
|
1553
|
+
got = None
|
|
1554
|
+
if got is not None and not any(got.lower() == w.lower() for w in wanted):
|
|
1555
|
+
notes.append(f"asked for {wanted[:3]}, rendering in {got!r} — none "
|
|
1556
|
+
"of the named faces is installed on this machine")
|
|
1557
|
+
|
|
1558
|
+
if notes:
|
|
1559
|
+
return "warn", "; ".join(notes)
|
|
1560
|
+
return True, f"Type 42 embedding, {generic} face resolves within the list"
|
|
1561
|
+
|
|
1562
|
+
|
|
1563
|
+
ALT_TEXT_ATTR = "_figure_gate_alt"
|
|
1564
|
+
# Under this many characters a description is naming the figure, not describing
|
|
1565
|
+
# it. "Validation loss" is a title; the alt text has to carry what the reader
|
|
1566
|
+
# would have taken from looking.
|
|
1567
|
+
ALT_TEXT_MIN_CHARS = 60
|
|
1568
|
+
|
|
1569
|
+
|
|
1570
|
+
def describe(fig, text):
|
|
1571
|
+
"""Attach a text description to a figure, for readers who cannot see it.
|
|
1572
|
+
|
|
1573
|
+
Across 100,000 public Jupyter notebooks, 99.81% of programmatically
|
|
1574
|
+
generated images shipped with no alt text at all, and the overwhelming
|
|
1575
|
+
majority of them were matplotlib. Matplotlib has no field for this, so the
|
|
1576
|
+
description is stashed on the figure and handed to `savefig`:
|
|
1577
|
+
|
|
1578
|
+
describe(fig, "Validation loss against training epoch for three "
|
|
1579
|
+
"optimisers. All three fall; the Bayesian run reaches "
|
|
1580
|
+
"0.05 by epoch 6, the baseline is still at 0.25 at 12.")
|
|
1581
|
+
fig.savefig(path, metadata=alt_metadata(fig))
|
|
1582
|
+
|
|
1583
|
+
Say what the reader would have taken from looking, not what the figure is
|
|
1584
|
+
made of. "A line chart with three lines" describes the file; the numbers
|
|
1585
|
+
and the direction describe the finding.
|
|
1586
|
+
"""
|
|
1587
|
+
setattr(fig, ALT_TEXT_ATTR, str(text))
|
|
1588
|
+
return fig
|
|
1589
|
+
|
|
1590
|
+
|
|
1591
|
+
def alt_metadata(fig):
|
|
1592
|
+
"""The `metadata=` dict for `savefig`, carrying whatever `describe` set.
|
|
1593
|
+
|
|
1594
|
+
PNG, PDF and SVG each keep a description field and matplotlib writes to all
|
|
1595
|
+
three from this key, so the text survives into the file rather than living
|
|
1596
|
+
only in the build script.
|
|
1597
|
+
"""
|
|
1598
|
+
text = getattr(fig, ALT_TEXT_ATTR, None)
|
|
1599
|
+
return {"Description": text} if text else {}
|
|
1600
|
+
|
|
1601
|
+
|
|
1602
|
+
def check_alt_text(fig):
|
|
1603
|
+
"""Whether the figure carries a description for a reader who cannot see it.
|
|
1604
|
+
|
|
1605
|
+
A warning rather than a gate, and deliberately: on a paper the description
|
|
1606
|
+
frequently *is* the caption, and the caption lives in the .tex file where
|
|
1607
|
+
this cannot see it. Hard-failing every figure in that entirely reasonable
|
|
1608
|
+
setup is how a row becomes something everyone learns to skip, which is worse
|
|
1609
|
+
than not having it. Where there is no caption — a notebook, a README, a
|
|
1610
|
+
slide, a web page — nothing else is carrying this and the row is the only
|
|
1611
|
+
thing that will say so.
|
|
1612
|
+
"""
|
|
1613
|
+
text = str(getattr(fig, ALT_TEXT_ATTR, "") or "").strip()
|
|
1614
|
+
if not text:
|
|
1615
|
+
return "warn", ("no description attached <- describe(fig, \"...\") "
|
|
1616
|
+
"and pass alt_metadata(fig) to savefig; if the "
|
|
1617
|
+
"document's caption carries it, this row is discharged")
|
|
1618
|
+
if len(text) < ALT_TEXT_MIN_CHARS:
|
|
1619
|
+
return "warn", (f"description is {len(text)} characters — that is a "
|
|
1620
|
+
"title, not a description of what the reader would "
|
|
1621
|
+
"have seen")
|
|
1622
|
+
return True, f"described in {len(text)} characters"
|
|
1623
|
+
|
|
1624
|
+
|
|
1625
|
+
def check_style_sheet(fig):
|
|
1626
|
+
"""Every key in the sheet against the rcParams that are actually in effect.
|
|
1627
|
+
|
|
1628
|
+
Three separate silent failures land here at once: a color written with a
|
|
1629
|
+
leading `#` (which is a comment in this format, so matplotlib keeps its own
|
|
1630
|
+
default), a forgotten `plt.style.use`, and an rcParams override applied
|
|
1631
|
+
later. All three ship stock matplotlib while every other check passes.
|
|
1632
|
+
|
|
1633
|
+
A warning, not a gate, for one honest reason: a figure built on a *different*
|
|
1634
|
+
project's sheet is correct work, and this compares against the global
|
|
1635
|
+
rcParams rather than what the figure was drawn under, so a figure built
|
|
1636
|
+
inside an `rc_context` that has since exited reads as drift when it is not.
|
|
1637
|
+
Both make a hard failure the wrong instrument. The row names the keys.
|
|
1638
|
+
"""
|
|
1639
|
+
import matplotlib as mpl
|
|
1640
|
+
path = _style_sheet()
|
|
1641
|
+
if path is None:
|
|
1642
|
+
return True, "no figure.mplstyle beside this script, nothing to compare"
|
|
1643
|
+
written = mpl.rc_params_from_file(path, use_default_template=False)
|
|
1644
|
+
drift = []
|
|
1645
|
+
for key, value in written.items():
|
|
1646
|
+
try:
|
|
1647
|
+
same = mpl.rcParams[key] == value
|
|
1648
|
+
except KeyError:
|
|
1649
|
+
continue
|
|
1650
|
+
if not isinstance(same, bool): # a numpy array of comparisons
|
|
1651
|
+
same = bool(getattr(same, "all", lambda: same)())
|
|
1652
|
+
if not same:
|
|
1653
|
+
drift.append(key)
|
|
1654
|
+
if not drift:
|
|
1655
|
+
return True, f"all {len(written)} keys match {path.name}"
|
|
1656
|
+
return "warn", (f"{len(drift)} of {len(written)} keys differ from "
|
|
1657
|
+
f"{path.name}: {sorted(drift)[:5]}"
|
|
1658
|
+
f"{' ...' if len(drift) > 5 else ''} <- the sheet is not "
|
|
1659
|
+
"the one in effect: check plt.style.use, and check no color "
|
|
1660
|
+
"in the sheet was written with a leading #")
|
|
1661
|
+
|
|
1662
|
+
|
|
1663
|
+
def audit(fig, scale=None, placed_frac=1.0, context_axes=None, venue=None):
|
|
1664
|
+
r, canvas = _renderer(fig)
|
|
1665
|
+
rows = [
|
|
1666
|
+
("Clipping", *check_clipping(fig, r)),
|
|
1667
|
+
("Text collision", *check_collisions(fig, r)),
|
|
1668
|
+
("Text readability",
|
|
1669
|
+
*check_text_readability(fig, r, canvas, scale, placed_frac, venue)),
|
|
1670
|
+
("Contrast stack", *check_contrast_stack(fig)),
|
|
1671
|
+
("Mark ratio", *check_mark_ratio(fig)),
|
|
1672
|
+
("Overplotting", *check_overplotting(fig)),
|
|
1673
|
+
("Axis redundancy", *check_redundancy(fig, r)),
|
|
1674
|
+
("Type size", *check_type_size(fig, r, scale, placed_frac, venue)),
|
|
1675
|
+
("Line weight", *check_line_weight(fig, scale, placed_frac, venue)),
|
|
1676
|
+
("Ink coverage", *check_ink(fig, context_axes, canvas)),
|
|
1677
|
+
("Series color", *check_series_color(fig)),
|
|
1678
|
+
("Dual axis", *check_dual_axis(fig)),
|
|
1679
|
+
("Form", *check_form(fig)),
|
|
1680
|
+
("Identity channel", *check_identity_channel(fig)),
|
|
1681
|
+
("Label attribution", *check_label_attribution(fig, r)),
|
|
1682
|
+
("Style sheet", *check_style_sheet(fig)),
|
|
1683
|
+
("Contour dash", *check_contour_dash(fig)),
|
|
1684
|
+
("Fonts", *check_fonts(fig)),
|
|
1685
|
+
("Alt text", *check_alt_text(fig)),
|
|
1686
|
+
]
|
|
1687
|
+
# "warn" rows are advisory: they report something worth a look without
|
|
1688
|
+
# failing the build. Only a hard False gates.
|
|
1689
|
+
return all(s is not False for _, s, _ in rows), rows
|
|
1690
|
+
|
|
1691
|
+
|
|
1692
|
+
def report(fig, name="", scale=None, placed_frac=1.0, context_axes=None,
|
|
1693
|
+
venue=None):
|
|
1694
|
+
ok, rows = audit(fig, scale, placed_frac, context_axes, venue)
|
|
1695
|
+
print(f"\nComposition audit{': ' + name if name else ''}")
|
|
1696
|
+
warned = False
|
|
1697
|
+
for label, status, detail in rows:
|
|
1698
|
+
tag = "WARN" if status == "warn" else ("PASS" if status else "FAIL")
|
|
1699
|
+
warned = warned or status == "warn"
|
|
1700
|
+
print(f" [{tag}] {label:<18} {detail}")
|
|
1701
|
+
verdict = "COMPOSED" if ok else "FIX THE MARKED CHECKS"
|
|
1702
|
+
if ok and warned:
|
|
1703
|
+
verdict += " (with advisories)"
|
|
1704
|
+
print(f"\n -> {verdict}\n")
|
|
1705
|
+
return ok
|
|
1706
|
+
|
|
1707
|
+
|
|
1708
|
+
def self_test_figure():
|
|
1709
|
+
"""A figure that breaks several checks on purpose.
|
|
1710
|
+
|
|
1711
|
+
Kept as a function rather than inlined under __main__ so the test suite can
|
|
1712
|
+
assert the gate still fails on it. A gate nobody has watched fail is
|
|
1713
|
+
decoration, and this one silently stopped working twice while it was being
|
|
1714
|
+
written.
|
|
1715
|
+
"""
|
|
1716
|
+
import matplotlib.pyplot as plt
|
|
1717
|
+
fig, (a, b) = plt.subplots(1, 2, figsize=(4, 2))
|
|
1718
|
+
for ax in (a, b):
|
|
1719
|
+
ax.scatter([1, 2, 3], [1, 2, 3], s=12, alpha=0.4)
|
|
1720
|
+
ax.scatter([2], [2], marker="*", s=400, alpha=0.7)
|
|
1721
|
+
ax.set_xlabel("a very long axis label that will not fit")
|
|
1722
|
+
ax.set_ylabel("Batch size")
|
|
1723
|
+
return fig
|
|
1724
|
+
|
|
1725
|
+
|
|
1726
|
+
def main():
|
|
1727
|
+
"""Run the self-test. Exits 0 when the gate correctly rejects a bad figure,
|
|
1728
|
+
so `check-figure` in a build verifies the checker itself is still working."""
|
|
1729
|
+
try:
|
|
1730
|
+
import matplotlib
|
|
1731
|
+
except ImportError:
|
|
1732
|
+
raise SystemExit(
|
|
1733
|
+
"check_figure.py needs matplotlib (numpy comes with it; scipy is "
|
|
1734
|
+
"optional and only a speed-up):\n"
|
|
1735
|
+
" pip install matplotlib\n\n"
|
|
1736
|
+
"The composition RULES are library-agnostic and written up in the "
|
|
1737
|
+
"style guide; only this automated check is matplotlib-specific. On "
|
|
1738
|
+
"another plotting stack, apply the rules by hand or port the "
|
|
1739
|
+
"checks - each one reads geometry any library can report.")
|
|
1740
|
+
import sys
|
|
1741
|
+
if "--venues" in sys.argv:
|
|
1742
|
+
print("\nContent widths, in points. Pass one as venue= to audit().")
|
|
1743
|
+
print("Verify against `\\the\\textwidth` in your own document before "
|
|
1744
|
+
"trusting one for anything that matters.\n")
|
|
1745
|
+
for name, pt in sorted(VENUE_WIDTH_PT.items()):
|
|
1746
|
+
print(f" {name:<16} {pt:>7.2f} pt ({pt / 72:.2f} in)")
|
|
1747
|
+
print()
|
|
1748
|
+
return
|
|
1749
|
+
|
|
1750
|
+
matplotlib.use("agg")
|
|
1751
|
+
composed = report(self_test_figure(), "self-test (expected: FAIL)")
|
|
1752
|
+
if composed:
|
|
1753
|
+
raise SystemExit(
|
|
1754
|
+
"The self-test figure passed, which means the checker is broken.")
|
|
1755
|
+
print(" The gate correctly rejected a deliberately bad figure.\n")
|
|
1756
|
+
|
|
1757
|
+
|
|
1758
|
+
if __name__ == "__main__":
|
|
1759
|
+
main()
|