plotpress 0.23.2__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.
- plotpress/__init__.py +108 -0
- plotpress/_interactive.py +2849 -0
- plotpress/_spectral.py +154 -0
- plotpress/_version.py +1 -0
- plotpress/artists.py +1382 -0
- plotpress/axes.py +3221 -0
- plotpress/colors.py +498 -0
- plotpress/figure.py +3084 -0
- plotpress/fonts/__init__.py +51 -0
- plotpress/fonts/families.py +192 -0
- plotpress/fonts/installed.py +82 -0
- plotpress/fonts/metrics.py +265 -0
- plotpress/png.py +93 -0
- plotpress/polar.py +240 -0
- plotpress/primitives.py +335 -0
- plotpress/qt.py +427 -0
- plotpress/raster.py +1316 -0
- plotpress/style.py +91 -0
- plotpress/svg.py +2589 -0
- plotpress/ticker.py +212 -0
- plotpress/transform.py +85 -0
- plotpress/vega.py +1324 -0
- plotpress/vega_lite.py +1199 -0
- plotpress-0.23.2.dist-info/METADATA +378 -0
- plotpress-0.23.2.dist-info/RECORD +28 -0
- plotpress-0.23.2.dist-info/WHEEL +5 -0
- plotpress-0.23.2.dist-info/licenses/LICENSE +21 -0
- plotpress-0.23.2.dist-info/top_level.txt +1 -0
plotpress/raster.py
ADDED
|
@@ -0,0 +1,1316 @@
|
|
|
1
|
+
"""Raster (PNG) backend via Pillow.
|
|
2
|
+
|
|
3
|
+
A second renderer that draws a Figure's primitives directly onto a Pillow canvas
|
|
4
|
+
(supersampled, then downscaled for antialiasing). Pillow ships as a pure wheel
|
|
5
|
+
on every platform, so PNG export needs no cairo/native SVG rasterizer. The
|
|
6
|
+
geometry mirrors :mod:`plotpress.svg` -- both consume the same transforms.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .artists import (
|
|
16
|
+
Annotation, Barbs, Bars, BoxPlot, Contour, ErrorBar, EventPlot, FillBetween,
|
|
17
|
+
FrameLine2D, FrameQuadMesh, Pie, Polygon, Quiver, ScatterCollection, Span,
|
|
18
|
+
Stem, Table, Text, Violin,
|
|
19
|
+
)
|
|
20
|
+
from .colors import colorbar_ticks, to_hex
|
|
21
|
+
# Which files can draw a given font stack is declared once, in fonts/families,
|
|
22
|
+
# next to which width table measures it -- see that module for why the two must
|
|
23
|
+
# be decided together. Imported under the old private names so anything
|
|
24
|
+
# monkeypatching this module keeps working.
|
|
25
|
+
from .fonts.families import HELVETICA_FILES as _HELVETICA_METRIC_FILES
|
|
26
|
+
from .fonts.families import HELVETICA_FILES_BOLD as _HELVETICA_METRIC_FILES_BOLD
|
|
27
|
+
from .fonts.families import font_files as _font_files
|
|
28
|
+
from .primitives import artist_to_prims
|
|
29
|
+
from .primitives import ImagePrim as PImage
|
|
30
|
+
from .primitives import Line as PLine
|
|
31
|
+
from .primitives import Markers as PMarkers
|
|
32
|
+
from .primitives import Path as PPath
|
|
33
|
+
from .primitives import PolygonBatch as PPolyBatch
|
|
34
|
+
from .primitives import Rect as PRect
|
|
35
|
+
from .primitives import Segments as PSegments
|
|
36
|
+
from .svg import (
|
|
37
|
+
_effective_rect, _group_axes_extra, _group_colorbar_extra, _group_colorbars,
|
|
38
|
+
_max_ytick_width, _pixel_rect,
|
|
39
|
+
_resolve_tick_labels,
|
|
40
|
+
)
|
|
41
|
+
from .ticker import log_ticks, nice_ticks
|
|
42
|
+
from .transform import LinearTransform
|
|
43
|
+
|
|
44
|
+
_DASH = {"-": None, "--": (6, 4), ":": (1, 3), "-.": (6, 3, 1, 3)}
|
|
45
|
+
_font_cache = {}
|
|
46
|
+
_PIL_H = {"left": "l", "center": "m", "right": "r"}
|
|
47
|
+
_PIL_V = {"baseline": "s", "center": "m", "top": "a", "bottom": "d"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _rgb(color):
|
|
51
|
+
c = to_hex(color).lstrip("#")
|
|
52
|
+
if len(c) == 3:
|
|
53
|
+
c = "".join(ch * 2 for ch in c)
|
|
54
|
+
return (int(c[0:2], 16), int(c[2:4], 16), int(c[4:6], 16))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _rgba(color, alpha=1.0):
|
|
58
|
+
return _rgb(color) + (int(round(alpha * 255)),)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _composite_polygon(canvas, pts, rgba, outline=None, outline_width=1):
|
|
62
|
+
"""Draw a filled polygon with correct alpha by compositing a bbox layer.
|
|
63
|
+
|
|
64
|
+
Drawing directly with an RGBA fill *replaces* pixels (alpha is then dropped
|
|
65
|
+
by the final RGB conversion), so translucent fills would render opaque.
|
|
66
|
+
Compositing a small transparent layer blends properly instead.
|
|
67
|
+
"""
|
|
68
|
+
from PIL import Image as PILImage, ImageDraw
|
|
69
|
+
|
|
70
|
+
xs = [p[0] for p in pts]
|
|
71
|
+
ys = [p[1] for p in pts]
|
|
72
|
+
x0, y0 = int(math.floor(min(xs))), int(math.floor(min(ys)))
|
|
73
|
+
x1, y1 = int(math.ceil(max(xs))), int(math.ceil(max(ys)))
|
|
74
|
+
w, h = max(1, x1 - x0), max(1, y1 - y0)
|
|
75
|
+
layer = PILImage.new("RGBA", (w, h), (0, 0, 0, 0))
|
|
76
|
+
ldraw = ImageDraw.Draw(layer)
|
|
77
|
+
ldraw.polygon([(px - x0, py - y0) for px, py in pts], fill=rgba,
|
|
78
|
+
outline=outline, width=max(1, int(round(outline_width))))
|
|
79
|
+
canvas.alpha_composite(layer, (x0, y0))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
def _font(size, family=None, bold=False):
|
|
83
|
+
"""A Pillow font for ``size`` px, honoring ``family`` where the system has it.
|
|
84
|
+
|
|
85
|
+
Falls back to Pillow's built-in face when nothing else resolves, which keeps
|
|
86
|
+
headless machines rendering rather than failing.
|
|
87
|
+
"""
|
|
88
|
+
from PIL import ImageFont
|
|
89
|
+
|
|
90
|
+
key = (int(round(size)), family, bool(bold))
|
|
91
|
+
if key in _font_cache:
|
|
92
|
+
return _font_cache[key]
|
|
93
|
+
|
|
94
|
+
font = None
|
|
95
|
+
for name in _font_files(family, bold):
|
|
96
|
+
try:
|
|
97
|
+
font = ImageFont.truetype(name, key[0])
|
|
98
|
+
break
|
|
99
|
+
except OSError:
|
|
100
|
+
continue # not installed here; try the next
|
|
101
|
+
if font is None:
|
|
102
|
+
try:
|
|
103
|
+
font = ImageFont.load_default(size=key[0])
|
|
104
|
+
except Exception:
|
|
105
|
+
# TypeError on very old Pillow (no size=); OSError/ImportError if the
|
|
106
|
+
# sized default needs FreeType and this build lacks it. The unsized
|
|
107
|
+
# bitmap default is the last resort that always exists.
|
|
108
|
+
font = ImageFont.load_default()
|
|
109
|
+
_font_cache[key] = font
|
|
110
|
+
return font
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def figure_to_image(fig, scale=2, frame=0, animate_unit="main"):
|
|
114
|
+
"""Render ``fig`` to a Pillow ``Image`` (RGB), supersampled by ``scale``.
|
|
115
|
+
|
|
116
|
+
``frame``/``animate_unit`` select which frame of any ``plot_frames()``
|
|
117
|
+
series registered under ``animate_unit`` to draw (see :func:`save_gif`);
|
|
118
|
+
every other artist, and any ``FrameLine2D`` under a different slider
|
|
119
|
+
unit, is unaffected and renders as it always has.
|
|
120
|
+
"""
|
|
121
|
+
from PIL import Image as PILImage, ImageDraw
|
|
122
|
+
|
|
123
|
+
fig._settle_layout()
|
|
124
|
+
dpi = fig.style.dpi
|
|
125
|
+
if not (dpi > 0):
|
|
126
|
+
# See the matching check in svg.figure_to_svg -- dpi is a plain,
|
|
127
|
+
# freely-mutable Style attribute, and a non-positive value produces
|
|
128
|
+
# an empty or negative-size image Pillow itself rejects with much
|
|
129
|
+
# less clarity (a bare "cannot write empty image").
|
|
130
|
+
raise ValueError(f"Figure.style.dpi must be > 0, got {dpi!r}")
|
|
131
|
+
W = int(round(fig.figsize[0] * dpi))
|
|
132
|
+
H = int(round(fig.figsize[1] * dpi))
|
|
133
|
+
S = max(1, int(scale))
|
|
134
|
+
canvas = PILImage.new("RGBA", (W * S, H * S), _rgba(fig.style.facecolor))
|
|
135
|
+
draw = ImageDraw.Draw(canvas)
|
|
136
|
+
|
|
137
|
+
for ax in fig.axes:
|
|
138
|
+
_raster_axes(ax, fig, W * S, H * S, S, draw, canvas, frame, animate_unit)
|
|
139
|
+
_raster_figtexts(fig, W * S, H * S, S, draw)
|
|
140
|
+
_raster_figure_legend(fig, fig.style, W * S, H * S, S, draw)
|
|
141
|
+
_raster_groups(fig, W * S, H * S, S, draw)
|
|
142
|
+
|
|
143
|
+
if S > 1:
|
|
144
|
+
canvas = canvas.resize((W, H), PILImage.LANCZOS)
|
|
145
|
+
return canvas.convert("RGB")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def save_png(fig, path, scale=2):
|
|
149
|
+
figure_to_image(fig, scale=scale).save(path, format="PNG")
|
|
150
|
+
return path
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
def save_gif(fig, path, fps=10, scale=2, slider_unit="main", label_frames=True):
|
|
154
|
+
"""Animate a ``plot_frames()`` figure to a looping GIF via Pillow.
|
|
155
|
+
|
|
156
|
+
Every ``FrameLine2D``/``FrameQuadMesh`` series registered under
|
|
157
|
+
``slider_unit`` (``"main"``, the figure's shared/global slider, by
|
|
158
|
+
default) is stepped through all of its frames and the results stitched
|
|
159
|
+
into a looping GIF -- the same data an interactive HTML slider scrubs
|
|
160
|
+
through, as a self-contained file. A series under a *different* slider
|
|
161
|
+
unit (an axes-local, non-shared ``plot_frames(..., shared=False)``) stays
|
|
162
|
+
on its own frame 0 throughout, since only one unit can drive the
|
|
163
|
+
animation at a time; pass its ``slider_group``/axes unit name to animate
|
|
164
|
+
that one instead.
|
|
165
|
+
|
|
166
|
+
``label_frames`` stamps each frame with its slider value in the top-right
|
|
167
|
+
corner (``"{slider_label} = {value}"``) -- an interactive HTML shows this
|
|
168
|
+
right next to its slider, and a GIF has no slider to show it on, so
|
|
169
|
+
without a label an exported frame is anonymous about which one it is.
|
|
170
|
+
Pass ``False`` for bare frames.
|
|
171
|
+
|
|
172
|
+
Raises ``ValueError`` if the figure has no ``plot_frames()`` series
|
|
173
|
+
registered under ``slider_unit`` -- there is nothing to animate.
|
|
174
|
+
"""
|
|
175
|
+
if slider_unit not in fig._sliders:
|
|
176
|
+
available = sorted(fig._sliders) or ["(none)"]
|
|
177
|
+
raise ValueError(
|
|
178
|
+
f"no plot_frames() series registered under slider_unit={slider_unit!r}; "
|
|
179
|
+
f"available slider units: {available}"
|
|
180
|
+
)
|
|
181
|
+
spec = fig._sliders[slider_unit]
|
|
182
|
+
frames = []
|
|
183
|
+
for f in range(spec["n"]):
|
|
184
|
+
im = figure_to_image(fig, scale=scale, frame=f, animate_unit=slider_unit)
|
|
185
|
+
if label_frames:
|
|
186
|
+
_label_frame(im, spec["label"], spec["values"][f])
|
|
187
|
+
frames.append(im)
|
|
188
|
+
duration_ms = max(1, round(1000.0 / fps))
|
|
189
|
+
frames[0].save(path, format="GIF", save_all=True, append_images=frames[1:],
|
|
190
|
+
duration=duration_ms, loop=0)
|
|
191
|
+
return path
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
def _label_frame(im, label, value):
|
|
195
|
+
"""Stamp ``label = value`` in the top-right corner, in place."""
|
|
196
|
+
from PIL import ImageDraw
|
|
197
|
+
|
|
198
|
+
draw = ImageDraw.Draw(im)
|
|
199
|
+
text = f"{label} = {_format_slider_value(value)}"
|
|
200
|
+
pad = 10
|
|
201
|
+
_text(draw, im.width - pad, pad, text, (17, 17, 17), _font(13.0, None),
|
|
202
|
+
ha="right", va="top", outline=(255, 255, 255), stroke=2.5)
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def _format_slider_value(v):
|
|
206
|
+
"""An integral slider value (a day, a month) reads as ``5``, not ``5.0``."""
|
|
207
|
+
return str(int(v)) if float(v).is_integer() else f"{v:.3g}"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def save_pdf(fig, path):
|
|
211
|
+
"""Vector PDF via svglib + reportlab (no cairo needed)."""
|
|
212
|
+
import io
|
|
213
|
+
|
|
214
|
+
try:
|
|
215
|
+
from reportlab.graphics import renderPDF
|
|
216
|
+
from svglib.svglib import svg2rlg
|
|
217
|
+
except ImportError as e:
|
|
218
|
+
raise RuntimeError(
|
|
219
|
+
"PDF export needs svglib + reportlab (standard dependencies); "
|
|
220
|
+
"reinstall plotpress to restore them"
|
|
221
|
+
) from e
|
|
222
|
+
drawing = svg2rlg(io.StringIO(fig.to_svg()))
|
|
223
|
+
renderPDF.drawToFile(drawing, path)
|
|
224
|
+
return path
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
# -- axes -------------------------------------------------------------------
|
|
228
|
+
def _raster_spines(ax, L, T, Wp, Hp, S, draw):
|
|
229
|
+
"""Per-side box outline -- the raster counterpart of ``svg._render_spines``."""
|
|
230
|
+
st = ax.style
|
|
231
|
+
edges = {
|
|
232
|
+
"top": (L, T, L + Wp, T), "bottom": (L, T + Hp, L + Wp, T + Hp),
|
|
233
|
+
"left": (L, T, L, T + Hp), "right": (L + Wp, T, L + Wp, T + Hp),
|
|
234
|
+
}
|
|
235
|
+
for side, (x0, y0, x1, y1) in edges.items():
|
|
236
|
+
spine = ax.spines[side]
|
|
237
|
+
if not spine.get_visible():
|
|
238
|
+
continue
|
|
239
|
+
color = spine._color if spine._color is not None else st.spine_color
|
|
240
|
+
width = spine._linewidth if spine._linewidth is not None else st.spine_width
|
|
241
|
+
fill = _rgba(color, spine._alpha) if spine._alpha is not None else _rgb(color)
|
|
242
|
+
draw.line([x0, y0, x1, y1], fill=fill,
|
|
243
|
+
width=max(1, int(round(width * S))))
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
def _raster_axes(ax, fig, W, H, S, draw, canvas, frame=0, animate_unit="main"):
|
|
247
|
+
st = ax.style
|
|
248
|
+
(xmin, xmax), (ymin, ymax) = ax._resolved_limits()
|
|
249
|
+
L, T, Wp, Hp = _effective_rect(ax, *_pixel_rect(ax, W, H), (xmin, xmax), (ymin, ymax))
|
|
250
|
+
xlim_t = (xmax, xmin) if ax._xinverted else (xmin, xmax)
|
|
251
|
+
ylim_t = (ymax, ymin) if ax._yinverted else (ymin, ymax)
|
|
252
|
+
tr = LinearTransform(xlim_t, ylim_t, (L, T, Wp, Hp),
|
|
253
|
+
xscale=ax._xscale, yscale=ax._yscale)
|
|
254
|
+
|
|
255
|
+
if not ax._visible:
|
|
256
|
+
return
|
|
257
|
+
|
|
258
|
+
if ax._is_colorbar:
|
|
259
|
+
_raster_colorbar(ax, tr, L, T, Wp, Hp, S, draw, canvas)
|
|
260
|
+
_raster_labels(ax, st, L, T, Wp, Hp, S, draw) # title only, mirrors the SVG backend
|
|
261
|
+
return
|
|
262
|
+
|
|
263
|
+
is_twin = ax._twin_of is not None
|
|
264
|
+
is_secondary = ax._secondary_of is not None
|
|
265
|
+
overlay = is_twin or is_secondary
|
|
266
|
+
if not overlay:
|
|
267
|
+
draw.rectangle([L, T, L + Wp, T + Hp], fill=_rgb(ax.get_facecolor()))
|
|
268
|
+
|
|
269
|
+
xticks = (ax._xticks if ax._xticks is not None else
|
|
270
|
+
(log_ticks(xmin, xmax) if ax._xscale == "log" else nice_ticks(xmin, xmax)))
|
|
271
|
+
yticks = (ax._yticks if ax._yticks is not None else
|
|
272
|
+
(log_ticks(ymin, ymax) if ax._yscale == "log" else nice_ticks(ymin, ymax)))
|
|
273
|
+
|
|
274
|
+
if ax._grid and not ax._axis_off and not overlay:
|
|
275
|
+
gc = _rgba(st.grid_color, ax._grid_alpha if ax._grid_alpha is not None
|
|
276
|
+
else st.grid_alpha)
|
|
277
|
+
gw = max(1, int(round(st.grid_width * S)))
|
|
278
|
+
for xt in xticks:
|
|
279
|
+
x = float(tr.x(xt))
|
|
280
|
+
if L <= x <= L + Wp:
|
|
281
|
+
draw.line([x, T, x, T + Hp], fill=gc, width=gw)
|
|
282
|
+
for yt in yticks:
|
|
283
|
+
y = float(tr.y(yt))
|
|
284
|
+
if T <= y <= T + Hp:
|
|
285
|
+
draw.line([L, y, L + Wp, y], fill=gc, width=gw)
|
|
286
|
+
|
|
287
|
+
# Artists go onto a scratch layer, and only the part of that layer inside
|
|
288
|
+
# the axes rect is composited back. This is the raster counterpart of the
|
|
289
|
+
# SVG backend's <clipPath>: without it the two backends disagree the moment
|
|
290
|
+
# any data falls outside the limits, and the PNG paints it across the rest
|
|
291
|
+
# of the figure -- over neighbouring subplots, labels and the legend.
|
|
292
|
+
_clip_artists(ax, tr, st, S, canvas, (L, T, Wp, Hp), frame, animate_unit)
|
|
293
|
+
|
|
294
|
+
if not ax._axis_off:
|
|
295
|
+
if is_twin:
|
|
296
|
+
_raster_twin_ticks(ax, st, tr, xticks, yticks, L, T, Wp, Hp, S, draw)
|
|
297
|
+
elif is_secondary:
|
|
298
|
+
xst = st.copy(**ax._tick_overrides["x"]) if ax._tick_overrides["x"] else st
|
|
299
|
+
yst = st.copy(**ax._tick_overrides["y"]) if ax._tick_overrides["y"] else st
|
|
300
|
+
is_x = ax._secondary_dim == "x"
|
|
301
|
+
_raster_ticks(ax, xst, yst, tr, xticks if is_x else [], yticks if not is_x else [],
|
|
302
|
+
L, T, Wp, Hp, S, draw,
|
|
303
|
+
xside=ax._xtick_side, yside=ax._ytick_side)
|
|
304
|
+
else:
|
|
305
|
+
xst = st.copy(**ax._tick_overrides["x"]) if ax._tick_overrides["x"] else st
|
|
306
|
+
yst = st.copy(**ax._tick_overrides["y"]) if ax._tick_overrides["y"] else st
|
|
307
|
+
_raster_ticks(ax, xst, yst, tr, xticks, yticks, L, T, Wp, Hp, S, draw,
|
|
308
|
+
xside=ax._xtick_side, yside=ax._ytick_side)
|
|
309
|
+
if ax._minor_ticks_on:
|
|
310
|
+
from .ticker import minor_ticks
|
|
311
|
+
mxst = (xst.copy(**ax._minor_tick_overrides["x"])
|
|
312
|
+
if ax._minor_tick_overrides["x"] else xst)
|
|
313
|
+
myst = (yst.copy(**ax._minor_tick_overrides["y"])
|
|
314
|
+
if ax._minor_tick_overrides["y"] else yst)
|
|
315
|
+
xminor = (ax._xticks_minor if ax._xticks_minor is not None
|
|
316
|
+
else minor_ticks(xticks, xmin, xmax, ax._xscale))
|
|
317
|
+
yminor = (ax._yticks_minor if ax._yticks_minor is not None
|
|
318
|
+
else minor_ticks(yticks, ymin, ymax, ax._yscale))
|
|
319
|
+
_raster_minor_ticks(mxst, myst, tr, xminor, yminor, L, T, Wp, Hp, S, draw,
|
|
320
|
+
xside=ax._xtick_side, yside=ax._ytick_side)
|
|
321
|
+
_raster_spines(ax, L, T, Wp, Hp, S, draw)
|
|
322
|
+
if not is_twin:
|
|
323
|
+
_raster_labels(ax, st, L, T, Wp, Hp, S, draw)
|
|
324
|
+
if ax._show_legend:
|
|
325
|
+
_raster_legend(ax, st, L, T, Wp, Hp, S, draw)
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _draw_prim(p, S, draw, canvas):
|
|
329
|
+
"""Draw one backend-agnostic primitive onto the raster canvas."""
|
|
330
|
+
if isinstance(p, PImage):
|
|
331
|
+
if p.w <= 0 or p.h <= 0:
|
|
332
|
+
return
|
|
333
|
+
from PIL import Image as PILImage
|
|
334
|
+
im = PILImage.fromarray(p.rgba, "RGBA").resize(
|
|
335
|
+
(max(1, int(round(p.w))), max(1, int(round(p.h)))), PILImage.NEAREST)
|
|
336
|
+
canvas.alpha_composite(im, (int(round(p.x)), int(round(p.y))))
|
|
337
|
+
return
|
|
338
|
+
if isinstance(p, PMarkers):
|
|
339
|
+
finite = np.isfinite(p.points).all(axis=1)
|
|
340
|
+
edge = _rgb(p.edgecolor) if (p.edgecolor and p.edgewidth > 0) else None
|
|
341
|
+
ew = max(1, int(round(p.edgewidth))) if edge else 1
|
|
342
|
+
for (cx, cy), dm, col, ok in zip(p.points, p.diameters, p.colors, finite):
|
|
343
|
+
if ok:
|
|
344
|
+
rad = dm / 2.0
|
|
345
|
+
draw.ellipse([cx - rad, cy - rad, cx + rad, cy + rad],
|
|
346
|
+
fill=_rgba(col, p.alpha), outline=edge, width=ew)
|
|
347
|
+
return
|
|
348
|
+
if isinstance(p, PLine):
|
|
349
|
+
# Same stroke_opacity-dropped-on-the-floor bug as PPath below (axvline/
|
|
350
|
+
# axhline/axline share this prim).
|
|
351
|
+
col = _rgba(p.stroke, p.stroke_opacity) if p.stroke_opacity < 1 else _rgb(p.stroke)
|
|
352
|
+
_polyline(draw, np.array([p.p0, p.p1]), col,
|
|
353
|
+
max(1, int(round(p.stroke_width * S))), _DASH.get(p.linestyle))
|
|
354
|
+
elif isinstance(p, PRect):
|
|
355
|
+
pts = [(p.x, p.y), (p.x + p.w, p.y), (p.x + p.w, p.y + p.h), (p.x, p.y + p.h)]
|
|
356
|
+
_composite_polygon(canvas, pts, _rgba(p.fill, p.fill_opacity))
|
|
357
|
+
elif isinstance(p, PSegments):
|
|
358
|
+
# Same fix (hlines/vlines share this prim).
|
|
359
|
+
col = _rgba(p.stroke, p.stroke_opacity) if p.stroke_opacity < 1 else _rgb(p.stroke)
|
|
360
|
+
w = max(1, int(round(p.stroke_width * S)))
|
|
361
|
+
dash = _DASH.get(p.linestyle)
|
|
362
|
+
for a, b, c, d in p.segs:
|
|
363
|
+
_polyline(draw, np.array([[a, b], [c, d]]), col, w, dash)
|
|
364
|
+
elif isinstance(p, PPolyBatch):
|
|
365
|
+
outline = _rgb(p.edge) if p.edge else None
|
|
366
|
+
al = int(round(p.alpha * 255))
|
|
367
|
+
for verts, fc in zip(p.polys, p.fills):
|
|
368
|
+
pts = [tuple(v) for v in verts]
|
|
369
|
+
rgba = (_rgb(fc) if isinstance(fc, str)
|
|
370
|
+
else (int(fc[0]), int(fc[1]), int(fc[2]))) + (al,)
|
|
371
|
+
_composite_polygon(canvas, pts, rgba, outline=outline,
|
|
372
|
+
outline_width=p.edge_width * S)
|
|
373
|
+
elif isinstance(p, PPath):
|
|
374
|
+
if p.fill:
|
|
375
|
+
pts = [tuple(v) for sub in p.subpaths for v in sub if np.isfinite(v).all()]
|
|
376
|
+
if len(pts) >= 3:
|
|
377
|
+
# Regression: the outline drew at PIL's own default width (1px)
|
|
378
|
+
# regardless of stroke_width -- fill_between()/fill()'s own
|
|
379
|
+
# edgecolor rendered, but linewidth had no visible effect at
|
|
380
|
+
# all in PNG/PDF output, only in SVG.
|
|
381
|
+
outline = _rgb(p.stroke) if p.stroke else None
|
|
382
|
+
_composite_polygon(canvas, pts, _rgba(p.fill, p.fill_opacity),
|
|
383
|
+
outline=outline, outline_width=p.stroke_width * S)
|
|
384
|
+
else:
|
|
385
|
+
# Regression: stroke_opacity was carried on the prim (SVG already
|
|
386
|
+
# emits stroke-opacity from it) but never read here, so plot()
|
|
387
|
+
# and every line-based method built on it (step, ecdfplot, the
|
|
388
|
+
# psd/csd/cohere/spectrum family, xcorr/acorr) rendered fully
|
|
389
|
+
# opaque in PNG/PDF regardless of alpha=, agreeing with SVG only
|
|
390
|
+
# by accident when alpha happened to be 1.0.
|
|
391
|
+
col = (_rgba(p.stroke, p.stroke_opacity) if p.stroke_opacity < 1
|
|
392
|
+
else _rgb(p.stroke))
|
|
393
|
+
w = max(1, int(round(p.stroke_width * S)))
|
|
394
|
+
dash = _DASH.get(p.linestyle)
|
|
395
|
+
for sub in p.subpaths:
|
|
396
|
+
_polyline(draw, sub, col, w, dash)
|
|
397
|
+
|
|
398
|
+
|
|
399
|
+
def _clip_artists(ax, tr, st, S, canvas, rect, frame=0, animate_unit="main"):
|
|
400
|
+
"""Draw ``ax``'s artists clipped to ``rect`` = (left, top, w, h) in pixels.
|
|
401
|
+
|
|
402
|
+
Pillow has no clip region, so the artists are drawn onto a transparent layer
|
|
403
|
+
the size of the canvas and only the rect is composited back. The layer is
|
|
404
|
+
cached on the canvas and cleared per axes rather than reallocated, because a
|
|
405
|
+
figure can carry hundreds of axes and a full-canvas RGBA allocation each
|
|
406
|
+
time dominates the render.
|
|
407
|
+
"""
|
|
408
|
+
from PIL import Image as PILImage, ImageDraw
|
|
409
|
+
|
|
410
|
+
L, T, Wp, Hp = rect
|
|
411
|
+
box = (max(0, int(math.floor(L))), max(0, int(math.floor(T))),
|
|
412
|
+
min(canvas.size[0], int(math.ceil(L + Wp))),
|
|
413
|
+
min(canvas.size[1], int(math.ceil(T + Hp))))
|
|
414
|
+
if box[2] <= box[0] or box[3] <= box[1]:
|
|
415
|
+
return
|
|
416
|
+
|
|
417
|
+
layer = getattr(canvas, "_plotpress_layer", None)
|
|
418
|
+
if layer is None or layer.size != canvas.size:
|
|
419
|
+
layer = PILImage.new("RGBA", canvas.size, (0, 0, 0, 0))
|
|
420
|
+
canvas._plotpress_layer = layer
|
|
421
|
+
ldraw = ImageDraw.Draw(layer)
|
|
422
|
+
|
|
423
|
+
# Stable sort by zorder (ties keep call order), matching svg.py's draw order.
|
|
424
|
+
for artist in sorted(ax.artists, key=lambda a: a.zorder):
|
|
425
|
+
_raster_artist(artist, tr, st, S, ldraw, layer, rect, frame, animate_unit)
|
|
426
|
+
|
|
427
|
+
canvas.alpha_composite(layer.crop(box), (box[0], box[1]))
|
|
428
|
+
# Clear only what was used, so the next axes starts from transparent.
|
|
429
|
+
layer.paste((0, 0, 0, 0), box)
|
|
430
|
+
|
|
431
|
+
|
|
432
|
+
def _raster_artist(artist, tr, st, S, draw, canvas, clip, frame=0, animate_unit="main"):
|
|
433
|
+
prims = artist_to_prims(artist, tr, 0, 0, size_scale=st.dpi / 72.0 * S)
|
|
434
|
+
if prims is not None:
|
|
435
|
+
for p in prims:
|
|
436
|
+
_draw_prim(p, S, draw, canvas)
|
|
437
|
+
return
|
|
438
|
+
if isinstance(artist, FrameLine2D):
|
|
439
|
+
# Only the unit being animated steps through its frames; a FrameLine2D
|
|
440
|
+
# under any other slider unit stays on frame 0, same as static output.
|
|
441
|
+
# linestyle="none" -- nothing to draw, and unlike the SVG backend a
|
|
442
|
+
# static bitmap has no persistent element id a later frame needs to
|
|
443
|
+
# find, so it's safe to just skip the draw call outright.
|
|
444
|
+
if artist.linestyle == "none":
|
|
445
|
+
return
|
|
446
|
+
f = frame if artist.slider_unit == animate_unit else 0
|
|
447
|
+
f = min(f, artist.n_frames - 1)
|
|
448
|
+
x0, y0 = artist.frame_xy(f)
|
|
449
|
+
_polyline(draw, tr.xy(x0, y0), _rgb(artist.color),
|
|
450
|
+
max(1, int(round(artist.linewidth * S))),
|
|
451
|
+
_DASH.get(artist.linestyle))
|
|
452
|
+
elif isinstance(artist, FrameQuadMesh):
|
|
453
|
+
# Same animate_unit rule as FrameLine2D above -- substitute that
|
|
454
|
+
# frame's own fully-realized QuadMesh so this hits the ordinary
|
|
455
|
+
# QuadMesh path in artist_to_prims, image flip/orientation included.
|
|
456
|
+
f = frame if artist.slider_unit == animate_unit else 0
|
|
457
|
+
f = min(f, artist.n_frames - 1)
|
|
458
|
+
mesh_prims = artist_to_prims(artist.frame_mesh(f), tr, 0, 0,
|
|
459
|
+
size_scale=st.dpi / 72.0 * S)
|
|
460
|
+
for p in mesh_prims or []:
|
|
461
|
+
_draw_prim(p, S, draw, canvas)
|
|
462
|
+
elif isinstance(artist, Bars):
|
|
463
|
+
_bars(artist, tr, S, draw)
|
|
464
|
+
elif isinstance(artist, Stem):
|
|
465
|
+
_stem(artist, tr, st, S, draw)
|
|
466
|
+
elif isinstance(artist, ErrorBar):
|
|
467
|
+
_errorbar(artist, tr, st, S, draw)
|
|
468
|
+
elif isinstance(artist, EventPlot):
|
|
469
|
+
_eventplot(artist, tr, S, draw)
|
|
470
|
+
elif isinstance(artist, Quiver):
|
|
471
|
+
_quiver(artist, tr, S, draw)
|
|
472
|
+
elif isinstance(artist, Barbs):
|
|
473
|
+
_barbs(artist, tr, st, S, draw)
|
|
474
|
+
elif isinstance(artist, Contour):
|
|
475
|
+
for lvl, color, segs in artist.line_segments:
|
|
476
|
+
fill = (_rgba(color, artist.alpha) if artist.alpha < 1 else _rgb(color))
|
|
477
|
+
for a, b, c, e in segs:
|
|
478
|
+
draw.line([float(tr.x(a)), float(tr.y(b)),
|
|
479
|
+
float(tr.x(c)), float(tr.y(e))],
|
|
480
|
+
fill=fill, width=max(1, int(round(1.2 * S))))
|
|
481
|
+
elif isinstance(artist, Pie):
|
|
482
|
+
_pie(artist, tr, st, S, draw)
|
|
483
|
+
elif isinstance(artist, BoxPlot):
|
|
484
|
+
_boxplot(artist, tr, st, S, draw)
|
|
485
|
+
elif isinstance(artist, Violin):
|
|
486
|
+
_violin(artist, tr, draw)
|
|
487
|
+
elif isinstance(artist, Text):
|
|
488
|
+
from .svg import _axes_fraction_xy, _bbox_pad, text_box
|
|
489
|
+
|
|
490
|
+
if artist.axes_fraction:
|
|
491
|
+
x, y = _axes_fraction_xy(tr, artist.x, artist.y)
|
|
492
|
+
else:
|
|
493
|
+
x, y = float(tr.x(artist.x)), float(tr.y(artist.y))
|
|
494
|
+
if artist.bbox is not None:
|
|
495
|
+
# text_box measures in unscaled pixels (font metrics don't know
|
|
496
|
+
# about the raster scale factor); scale the padded box to canvas.
|
|
497
|
+
box = _bbox_pad(text_box(x / S, y / S, artist.text, artist.size,
|
|
498
|
+
artist.ha, artist.va, st,
|
|
499
|
+
bold=artist.bold, italic=artist.italic),
|
|
500
|
+
artist.bbox)
|
|
501
|
+
_raster_bbox(draw, [c * S for c in box], artist.bbox, S)
|
|
502
|
+
fill = _rgba(artist.color, artist.alpha) if artist.alpha < 1 else _rgb(artist.color)
|
|
503
|
+
_text(draw, x, y, artist.text,
|
|
504
|
+
fill, _font(artist.size * S, st.font_family, bold=artist.bold),
|
|
505
|
+
artist.ha, artist.va, artist.rotation,
|
|
506
|
+
_rgb(artist.outline) if artist.outline else None,
|
|
507
|
+
artist.size * 0.15 * S, italic=artist.italic)
|
|
508
|
+
elif isinstance(artist, Annotation):
|
|
509
|
+
from .svg import _axes_fraction_xy, _bbox_pad, leader_anchor, text_box
|
|
510
|
+
|
|
511
|
+
if artist.axes_fraction:
|
|
512
|
+
tx, ty = _axes_fraction_xy(tr, artist.xytext[0], artist.xytext[1])
|
|
513
|
+
else:
|
|
514
|
+
tx, ty = float(tr.x(artist.xytext[0])), float(tr.y(artist.xytext[1]))
|
|
515
|
+
# The box is measured in unscaled pixels, so scale it to this canvas.
|
|
516
|
+
box = text_box(tx / S, ty / S, artist.text, artist.size,
|
|
517
|
+
artist.ha, artist.va, st, bold=artist.bold, italic=artist.italic)
|
|
518
|
+
if artist.bbox is not None:
|
|
519
|
+
box = _bbox_pad(box, artist.bbox) # the leader below anchors to this, padded, edge
|
|
520
|
+
if artist.arrowprops is not None:
|
|
521
|
+
px, py = float(tr.x(artist.xy[0])), float(tr.y(artist.xy[1]))
|
|
522
|
+
col = (artist.arrowprops.get("color", artist.color)
|
|
523
|
+
if isinstance(artist.arrowprops, dict) else artist.color)
|
|
524
|
+
a = (artist.arrowprops.get("alpha", 1.0)
|
|
525
|
+
if isinstance(artist.arrowprops, dict) else 1.0)
|
|
526
|
+
# Same attachment rule as the SVG backend -- see svg.leader_anchor.
|
|
527
|
+
sx, sy = leader_anchor(box, (px / S, py / S))
|
|
528
|
+
_quiver_arrow(draw, sx * S, sy * S, px, py,
|
|
529
|
+
_rgba(col, a) if a < 1 else _rgb(col), S)
|
|
530
|
+
if artist.bbox is not None:
|
|
531
|
+
_raster_bbox(draw, [c * S for c in box], artist.bbox, S)
|
|
532
|
+
fill = (_rgba(artist.color, artist.alpha) if artist.alpha < 1
|
|
533
|
+
else _rgb(artist.color))
|
|
534
|
+
_text(draw, tx, ty, artist.text, fill,
|
|
535
|
+
_font(artist.size * S, st.font_family, bold=artist.bold),
|
|
536
|
+
artist.ha, artist.va, 0.0,
|
|
537
|
+
_rgb(artist.outline) if artist.outline else None,
|
|
538
|
+
artist.size * 0.15 * S, italic=artist.italic)
|
|
539
|
+
elif isinstance(artist, Table):
|
|
540
|
+
_raster_table(artist, tr, st, S, draw)
|
|
541
|
+
|
|
542
|
+
|
|
543
|
+
def _raster_table(t, tr, st, S, draw):
|
|
544
|
+
"""``ax.table()`` -- mirrors svg._render_table's own cell layout exactly
|
|
545
|
+
(same header logic, same fill-color precedence) so the two backends
|
|
546
|
+
agree; see there for the shape of ``t.cell_colors``/``row_colors``/
|
|
547
|
+
``col_colors``."""
|
|
548
|
+
from .svg import _axes_fraction_xy
|
|
549
|
+
|
|
550
|
+
x0, y0, w, h = t.bbox
|
|
551
|
+
left, bottom = _axes_fraction_xy(tr, x0, y0)
|
|
552
|
+
right, top = _axes_fraction_xy(tr, x0 + w, y0 + h)
|
|
553
|
+
rect_w, rect_h = right - left, bottom - top
|
|
554
|
+
|
|
555
|
+
has_col_header = t.col_labels is not None
|
|
556
|
+
has_row_header = t.row_labels is not None
|
|
557
|
+
body_rows = t.cell_text
|
|
558
|
+
n_data_rows = len(body_rows)
|
|
559
|
+
n_data_cols = len(body_rows[0]) if body_rows else (len(t.col_labels) if has_col_header else 0)
|
|
560
|
+
n_rows = n_data_rows + (1 if has_col_header else 0)
|
|
561
|
+
n_cols = n_data_cols + (1 if has_row_header else 0)
|
|
562
|
+
if n_rows == 0 or n_cols == 0:
|
|
563
|
+
return
|
|
564
|
+
cell_w, cell_h = rect_w / n_cols, rect_h / n_rows
|
|
565
|
+
fs = (t.fontsize if t.fontsize is not None else st.tick_label_size) * S
|
|
566
|
+
row0 = 1 if has_col_header else 0
|
|
567
|
+
col0 = 1 if has_row_header else 0
|
|
568
|
+
font = _font(fs, st.font_family, bold=False)
|
|
569
|
+
bold_font = _font(fs, st.font_family, bold=True)
|
|
570
|
+
edge = _rgb("#888888")
|
|
571
|
+
|
|
572
|
+
def cell_fill(r, c):
|
|
573
|
+
if has_col_header and r == 0 and c >= col0 and t.col_colors:
|
|
574
|
+
i = c - col0
|
|
575
|
+
if i < len(t.col_colors):
|
|
576
|
+
return t.col_colors[i]
|
|
577
|
+
if has_row_header and c == 0 and r >= row0 and t.row_colors:
|
|
578
|
+
i = r - row0
|
|
579
|
+
if i < len(t.row_colors):
|
|
580
|
+
return t.row_colors[i]
|
|
581
|
+
if r >= row0 and c >= col0 and t.cell_colors:
|
|
582
|
+
ri, ci = r - row0, c - col0
|
|
583
|
+
if ri < len(t.cell_colors) and ci < len(t.cell_colors[ri]):
|
|
584
|
+
return t.cell_colors[ri][ci]
|
|
585
|
+
return "#ffffff"
|
|
586
|
+
|
|
587
|
+
def cell_text(r, c):
|
|
588
|
+
if has_col_header and r == 0:
|
|
589
|
+
return "" if (c == 0 and has_row_header) else t.col_labels[c - col0]
|
|
590
|
+
if has_row_header and c == 0:
|
|
591
|
+
return t.row_labels[r - row0]
|
|
592
|
+
return body_rows[r - row0][c - col0]
|
|
593
|
+
|
|
594
|
+
for r in range(n_rows):
|
|
595
|
+
for c in range(n_cols):
|
|
596
|
+
cx0, cy0 = left + c * cell_w, top + r * cell_h
|
|
597
|
+
fill = cell_fill(r, c)
|
|
598
|
+
draw.rectangle(
|
|
599
|
+
[cx0, cy0, cx0 + cell_w, cy0 + cell_h],
|
|
600
|
+
fill=_rgba(fill, t.alpha) if t.alpha < 1 else _rgb(fill),
|
|
601
|
+
outline=edge, width=max(1, int(round(0.75 * S))))
|
|
602
|
+
text = cell_text(r, c)
|
|
603
|
+
if text:
|
|
604
|
+
draw.text((cx0 + cell_w / 2.0, cy0 + cell_h / 2.0), text,
|
|
605
|
+
fill=_rgb(st.text_color),
|
|
606
|
+
font=bold_font if (r < row0 or c < col0) else font,
|
|
607
|
+
anchor="mm")
|
|
608
|
+
|
|
609
|
+
|
|
610
|
+
# -- primitives -------------------------------------------------------------
|
|
611
|
+
def _polyline(draw, pts, color, width, dash=None):
|
|
612
|
+
mask = np.isfinite(pts).all(axis=1)
|
|
613
|
+
n = len(pts)
|
|
614
|
+
i = 0
|
|
615
|
+
while i < n:
|
|
616
|
+
if not mask[i]:
|
|
617
|
+
i += 1
|
|
618
|
+
continue
|
|
619
|
+
j = i
|
|
620
|
+
while j < n and mask[j]:
|
|
621
|
+
j += 1
|
|
622
|
+
seg = [tuple(p) for p in pts[i:j]]
|
|
623
|
+
if len(seg) >= 2:
|
|
624
|
+
if dash:
|
|
625
|
+
_dashed(draw, seg, color, width, dash)
|
|
626
|
+
else:
|
|
627
|
+
draw.line(seg, fill=color, width=width, joint="curve")
|
|
628
|
+
i = j
|
|
629
|
+
|
|
630
|
+
|
|
631
|
+
def _dashed(draw, seg, color, width, dash):
|
|
632
|
+
on = True
|
|
633
|
+
di = 0
|
|
634
|
+
remaining = dash[0]
|
|
635
|
+
for (x0, y0), (x1, y1) in zip(seg[:-1], seg[1:]):
|
|
636
|
+
seglen = math.hypot(x1 - x0, y1 - y0)
|
|
637
|
+
pos = 0.0
|
|
638
|
+
while pos < seglen:
|
|
639
|
+
step = min(remaining, seglen - pos)
|
|
640
|
+
t0, t1 = pos / seglen, (pos + step) / seglen
|
|
641
|
+
if on:
|
|
642
|
+
draw.line([x0 + (x1 - x0) * t0, y0 + (y1 - y0) * t0,
|
|
643
|
+
x0 + (x1 - x0) * t1, y0 + (y1 - y0) * t1],
|
|
644
|
+
fill=color, width=width)
|
|
645
|
+
pos += step
|
|
646
|
+
remaining -= step
|
|
647
|
+
if remaining <= 1e-6:
|
|
648
|
+
di = (di + 1) % len(dash)
|
|
649
|
+
remaining = dash[di]
|
|
650
|
+
on = not on
|
|
651
|
+
|
|
652
|
+
|
|
653
|
+
def _bars(bars, tr, S, draw):
|
|
654
|
+
for i in range(len(bars.pos)):
|
|
655
|
+
p, ln, th, ba = bars.pos[i], bars.length[i], bars.thickness[i], bars.base[i]
|
|
656
|
+
if bars.orientation == "vertical":
|
|
657
|
+
x0, x1 = float(tr.x(p - th / 2)), float(tr.x(p + th / 2))
|
|
658
|
+
y0, y1 = float(tr.y_base(ba)), float(tr.y_base(ba + ln))
|
|
659
|
+
else:
|
|
660
|
+
y0, y1 = float(tr.y(p - th / 2)), float(tr.y(p + th / 2))
|
|
661
|
+
x0, x1 = float(tr.x_base(ba)), float(tr.x_base(ba + ln))
|
|
662
|
+
box = [min(x0, x1), min(y0, y1), max(x0, x1), max(y0, y1)]
|
|
663
|
+
outline = _rgb(bars.edgecolor) if bars.edgecolor else None
|
|
664
|
+
draw.rectangle(box, fill=_rgba(bars.colors[i], bars.alpha), outline=outline,
|
|
665
|
+
width=max(1, int(round(bars.linewidth * S))) if outline else 1)
|
|
666
|
+
|
|
667
|
+
|
|
668
|
+
def _stem(stem, tr, st, S, draw):
|
|
669
|
+
if stem.x.size == 0:
|
|
670
|
+
return
|
|
671
|
+
y0 = float(tr.y_base(stem.baseline))
|
|
672
|
+
xb, yb = tr.x(stem.x), tr.y(stem.y)
|
|
673
|
+
for x, y in zip(xb, yb):
|
|
674
|
+
draw.line([float(x), y0, float(x), float(y)], fill=_rgb(stem.linecolor),
|
|
675
|
+
width=max(1, int(round(1.2 * S))))
|
|
676
|
+
draw.line([float(tr.x(stem.x.min())), y0, float(tr.x(stem.x.max())), y0],
|
|
677
|
+
fill=_rgb(st.spine_color), width=max(1, int(round(0.8 * S))))
|
|
678
|
+
r = st.marker_size / 2.0 * st.dpi / 72.0 * S
|
|
679
|
+
for x, y in zip(xb, yb):
|
|
680
|
+
draw.ellipse([x - r, y - r, x + r, y + r], fill=_rgb(stem.markercolor))
|
|
681
|
+
|
|
682
|
+
|
|
683
|
+
def _errorbar(eb, tr, st, S, draw):
|
|
684
|
+
xb, yb = tr.x(eb.x), tr.y(eb.y)
|
|
685
|
+
col = _rgb(eb.color)
|
|
686
|
+
ecol = _rgb(eb.ecolor)
|
|
687
|
+
ew = max(1, int(round(eb.elinewidth * S)))
|
|
688
|
+
cw = max(1, int(round(eb.capthick * S)))
|
|
689
|
+
if eb.linestyle and eb.linestyle != "none":
|
|
690
|
+
_polyline(draw, np.column_stack([xb, yb]), col,
|
|
691
|
+
max(1, int(round(eb.linewidth * S))))
|
|
692
|
+
cap = eb.capsize * S
|
|
693
|
+
if eb.yerr is not None:
|
|
694
|
+
ylo, yhi = tr.y_base(eb.y - eb.yerr), tr.y_base(eb.y + eb.yerr)
|
|
695
|
+
for x, a, b in zip(xb, ylo, yhi):
|
|
696
|
+
draw.line([x, a, x, b], fill=ecol, width=ew)
|
|
697
|
+
draw.line([x - cap, a, x + cap, a], fill=ecol, width=cw)
|
|
698
|
+
draw.line([x - cap, b, x + cap, b], fill=ecol, width=cw)
|
|
699
|
+
if eb.xerr is not None:
|
|
700
|
+
# Regression: this branch didn't exist at all -- errorbar(xerr=...)
|
|
701
|
+
# (and, composed on top of it, barh()'s own xerr) drew nothing in
|
|
702
|
+
# PNG/PDF output, only in SVG (see svg._render_errorbar, which
|
|
703
|
+
# already has both branches).
|
|
704
|
+
xlo, xhi = tr.x_base(eb.x - eb.xerr), tr.x_base(eb.x + eb.xerr)
|
|
705
|
+
for y, a, b in zip(yb, xlo, xhi):
|
|
706
|
+
draw.line([a, y, b, y], fill=ecol, width=ew)
|
|
707
|
+
draw.line([a, y - cap, a, y + cap], fill=ecol, width=cw)
|
|
708
|
+
draw.line([b, y - cap, b, y + cap], fill=ecol, width=cw)
|
|
709
|
+
r = eb.markersize / 2.0 * st.dpi / 72.0 * S
|
|
710
|
+
for x, y in zip(xb, yb):
|
|
711
|
+
if np.isfinite(x) and np.isfinite(y): # see svg._render_errorbar
|
|
712
|
+
draw.ellipse([x - r, y - r, x + r, y + r], fill=col)
|
|
713
|
+
|
|
714
|
+
|
|
715
|
+
def _eventplot(ev, tr, S, draw):
|
|
716
|
+
half = ev.linelength / 2.0
|
|
717
|
+
col = _rgba(ev.color, ev.alpha) if ev.alpha < 1 else _rgb(ev.color)
|
|
718
|
+
for row, off in zip(ev.rows, ev.offsets):
|
|
719
|
+
if ev.orientation == "horizontal":
|
|
720
|
+
y0, y1 = float(tr.y(off - half)), float(tr.y(off + half))
|
|
721
|
+
for e in row:
|
|
722
|
+
x = float(tr.x(e))
|
|
723
|
+
draw.line([x, y0, x, y1], fill=col, width=max(1, int(round(1.2 * S))))
|
|
724
|
+
else:
|
|
725
|
+
x0, x1 = float(tr.x(off - half)), float(tr.x(off + half))
|
|
726
|
+
for e in row:
|
|
727
|
+
y = float(tr.y(e))
|
|
728
|
+
draw.line([x0, y, x1, y], fill=col, width=max(1, int(round(1.2 * S))))
|
|
729
|
+
|
|
730
|
+
|
|
731
|
+
def _quiver(q, tr, S, draw):
|
|
732
|
+
tx, ty = q.tips()
|
|
733
|
+
x0, y0, x1, y1 = tr.x(q.X), tr.y(q.Y), tr.x(tx), tr.y(ty)
|
|
734
|
+
col = _rgba(q.color, q.alpha) if q.alpha < 1 else _rgb(q.color)
|
|
735
|
+
hl = 5.0 * S
|
|
736
|
+
w = max(1, int(round(1.2 * S)))
|
|
737
|
+
for bx, by, ex, ey in zip(x0, y0, x1, y1):
|
|
738
|
+
draw.line([bx, by, ex, ey], fill=col, width=w)
|
|
739
|
+
ang = math.atan2(ey - by, ex - bx)
|
|
740
|
+
for da in (-math.radians(25), math.radians(25)):
|
|
741
|
+
draw.line([ex, ey, ex - hl * math.cos(ang + da), ey - hl * math.sin(ang + da)],
|
|
742
|
+
fill=col, width=w)
|
|
743
|
+
|
|
744
|
+
|
|
745
|
+
def _barbs(b, tr, st, S, draw):
|
|
746
|
+
"""Mirrors svg._render_barbs -- same shared geometry (svg._barb_geometry/
|
|
747
|
+
_barb_angles), so the two backends draw identical barbs."""
|
|
748
|
+
from .svg import _barb_angles, _barb_geometry
|
|
749
|
+
|
|
750
|
+
L = b.length * st.dpi / 72.0 * S
|
|
751
|
+
cx, cy = tr.x(b.X), tr.y(b.Y)
|
|
752
|
+
mag, ang = _barb_angles(b, tr)
|
|
753
|
+
col = _rgba(b.color, b.alpha) if b.alpha < 1 else _rgb(b.color)
|
|
754
|
+
w = max(1, int(round(1.2 * S)))
|
|
755
|
+
r = 0.12 * L
|
|
756
|
+
for x, y, spd, a in zip(cx, cy, mag, ang):
|
|
757
|
+
lines, polys, calm = _barb_geometry(float(x), float(y), float(a), float(spd), L)
|
|
758
|
+
if calm:
|
|
759
|
+
draw.ellipse([x - r, y - r, x + r, y + r], outline=col, width=w)
|
|
760
|
+
continue
|
|
761
|
+
for x0, y0, x1, y1 in lines:
|
|
762
|
+
draw.line([x0, y0, x1, y1], fill=col, width=w)
|
|
763
|
+
for poly in polys:
|
|
764
|
+
draw.polygon(poly, fill=col)
|
|
765
|
+
|
|
766
|
+
|
|
767
|
+
def _pie(pie, tr, st, S, draw):
|
|
768
|
+
cx = tr.px_left + tr.px_w / 2.0
|
|
769
|
+
cy = tr.px_top + tr.px_h / 2.0
|
|
770
|
+
R = 0.42 * min(tr.px_w, tr.px_h) * pie.radius
|
|
771
|
+
box = [cx - R, cy - R, cx + R, cy + R]
|
|
772
|
+
font = _font(10 * S, st.font_family) # matches svg's fixed 10px
|
|
773
|
+
txt_fill = _rgb(st.text_color)
|
|
774
|
+
ang = math.radians(pie.startangle)
|
|
775
|
+
for i, frac in enumerate(pie.fracs):
|
|
776
|
+
a1 = ang - frac * 2 * math.pi
|
|
777
|
+
draw.pieslice(box, -math.degrees(ang), -math.degrees(a1),
|
|
778
|
+
fill=_rgba(pie.colors[i], pie.alpha), outline=(255, 255, 255),
|
|
779
|
+
width=max(1, int(round(1.5 * S))))
|
|
780
|
+
am = (ang + a1) / 2.0
|
|
781
|
+
if pie.labels is not None:
|
|
782
|
+
lx, ly = cx + 1.15 * R * math.cos(am), cy - 1.15 * R * math.sin(am)
|
|
783
|
+
ha = "left" if math.cos(am) >= 0 else "right"
|
|
784
|
+
_text(draw, lx, ly, str(pie.labels[i]), txt_fill, font, ha, "center")
|
|
785
|
+
pct = pie.pct_text(frac)
|
|
786
|
+
if pct is not None:
|
|
787
|
+
px, py = cx + 0.6 * R * math.cos(am), cy - 0.6 * R * math.sin(am)
|
|
788
|
+
_text(draw, px, py, pct, txt_fill, font, "center", "center")
|
|
789
|
+
ang = a1
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
def _boxplot(bp, tr, st, S, draw):
|
|
793
|
+
col = _rgba(bp.color, bp.alpha) if bp.alpha < 1 else _rgb(bp.color)
|
|
794
|
+
w = max(1, int(round(1.3 * S)))
|
|
795
|
+
wm = max(1, int(round(1.8 * S)))
|
|
796
|
+
r = st.marker_size / 2.0 * st.dpi / 72.0 * S
|
|
797
|
+
for pos, s in zip(bp.positions, bp.stats):
|
|
798
|
+
c0, c1 = pos - bp.width / 2, pos + bp.width / 2
|
|
799
|
+
if bp.orientation == "vertical":
|
|
800
|
+
x0, x1 = float(tr.x(c0)), float(tr.x(c1))
|
|
801
|
+
xc = float(tr.x(pos))
|
|
802
|
+
yq1, yq3, ym = float(tr.y(s["q1"])), float(tr.y(s["q3"])), float(tr.y(s["med"]))
|
|
803
|
+
ylo, yhi = float(tr.y(s["lo"])), float(tr.y(s["hi"]))
|
|
804
|
+
draw.rectangle([min(x0, x1), min(yq1, yq3), max(x0, x1), max(yq1, yq3)],
|
|
805
|
+
outline=col, width=w)
|
|
806
|
+
draw.line([x0, ym, x1, ym], fill=col, width=wm)
|
|
807
|
+
draw.line([xc, yq1, xc, ylo], fill=col, width=S)
|
|
808
|
+
draw.line([xc, yq3, xc, yhi], fill=col, width=S)
|
|
809
|
+
draw.line([x0, ylo, x1, ylo], fill=col, width=S)
|
|
810
|
+
draw.line([x0, yhi, x1, yhi], fill=col, width=S)
|
|
811
|
+
for fx in s["fliers"]:
|
|
812
|
+
fy = float(tr.y(fx))
|
|
813
|
+
draw.ellipse([xc - r, fy - r, xc + r, fy + r], outline=col, width=S)
|
|
814
|
+
else:
|
|
815
|
+
y0, y1 = float(tr.y(c0)), float(tr.y(c1))
|
|
816
|
+
yc = float(tr.y(pos))
|
|
817
|
+
xq1, xq3, xm = float(tr.x(s["q1"])), float(tr.x(s["q3"])), float(tr.x(s["med"]))
|
|
818
|
+
xlo, xhi = float(tr.x(s["lo"])), float(tr.x(s["hi"]))
|
|
819
|
+
draw.rectangle([min(xq1, xq3), min(y0, y1), max(xq1, xq3), max(y0, y1)],
|
|
820
|
+
outline=col, width=w)
|
|
821
|
+
draw.line([xm, y0, xm, y1], fill=col, width=wm)
|
|
822
|
+
draw.line([xq1, yc, xlo, yc], fill=col, width=S)
|
|
823
|
+
draw.line([xq3, yc, xhi, yc], fill=col, width=S)
|
|
824
|
+
draw.line([xlo, y0, xlo, y1], fill=col, width=S)
|
|
825
|
+
draw.line([xhi, y0, xhi, y1], fill=col, width=S)
|
|
826
|
+
for fx in s["fliers"]:
|
|
827
|
+
fxx = float(tr.x(fx))
|
|
828
|
+
draw.ellipse([fxx - r, yc - r, fxx + r, yc + r], outline=col, width=S)
|
|
829
|
+
|
|
830
|
+
|
|
831
|
+
def _violin(v, tr, draw):
|
|
832
|
+
for pos, grid, hw in zip(v.positions, v.grids, v.halfwidths):
|
|
833
|
+
if v.orientation == "vertical":
|
|
834
|
+
left = np.column_stack([tr.x(pos - hw), tr.y(grid)])
|
|
835
|
+
right = np.column_stack([tr.x(pos + hw)[::-1], tr.y(grid)[::-1]])
|
|
836
|
+
else:
|
|
837
|
+
left = np.column_stack([tr.x(grid), tr.y(pos - hw)])
|
|
838
|
+
right = np.column_stack([tr.x(grid)[::-1], tr.y(pos + hw)[::-1]])
|
|
839
|
+
poly = [tuple(p) for p in np.vstack([left, right])]
|
|
840
|
+
draw.polygon(poly, fill=_rgba(v.color, v.alpha), outline=_rgb(v.color))
|
|
841
|
+
|
|
842
|
+
|
|
843
|
+
def _raster_bbox(draw, box, bbox, S):
|
|
844
|
+
"""The raster counterpart of ``svg._bbox_svg`` -- a rect (or rounded rect)
|
|
845
|
+
drawn behind a label. ``box`` is already padded and scaled to this canvas.
|
|
846
|
+
"""
|
|
847
|
+
x0, y0, x1, y1 = box
|
|
848
|
+
fill = _rgba(bbox["facecolor"], bbox["alpha"])
|
|
849
|
+
edge = _rgb(bbox["edgecolor"]) if bbox["edgecolor"] not in (None, "none") else None
|
|
850
|
+
width = max(1, int(round(bbox["linewidth"] * S)))
|
|
851
|
+
if bbox["boxstyle"] == "round":
|
|
852
|
+
radius = min(8.0 * S, (x1 - x0) / 2.0, (y1 - y0) / 2.0)
|
|
853
|
+
draw.rounded_rectangle([x0, y0, x1, y1], radius=radius, fill=fill,
|
|
854
|
+
outline=edge, width=width)
|
|
855
|
+
else:
|
|
856
|
+
draw.rectangle([x0, y0, x1, y1], fill=fill, outline=edge, width=width)
|
|
857
|
+
|
|
858
|
+
|
|
859
|
+
#: Faux-italic slant, as a fraction of glyph height -- there is no italic font
|
|
860
|
+
#: *file* in the bundled/installed registry (see fonts/families.py), only a
|
|
861
|
+
#: regular/bold split, so italic is a horizontal shear applied to the rendered
|
|
862
|
+
#: glyphs rather than a different face. Matches roughly what real italic faces
|
|
863
|
+
#: lean (~8-12 degrees); SVG gets a real ``font-style="italic"`` instead,
|
|
864
|
+
#: since a browser resolves an actual italic system face for that.
|
|
865
|
+
_ITALIC_SHEAR = 0.20
|
|
866
|
+
|
|
867
|
+
|
|
868
|
+
def _text(draw, x, y, s, fill, font, ha="left", va="baseline", rotation=0.0,
|
|
869
|
+
outline=None, stroke=0.0, italic=False):
|
|
870
|
+
"""Draw text, optionally with a contrasting halo (see svg._text_svg).
|
|
871
|
+
|
|
872
|
+
Pillow's ``stroke_width`` paints the rim under the glyph exactly as SVG's
|
|
873
|
+
``paint-order="stroke"`` does, so the two backends agree. Multi-line ``s``
|
|
874
|
+
(containing ``\\n``) is Pillow's own job -- ``ImageDraw.text`` detects the
|
|
875
|
+
newline and switches to its multiline layout automatically, anchor and
|
|
876
|
+
all, so nothing extra is needed here for that case.
|
|
877
|
+
"""
|
|
878
|
+
kw = {}
|
|
879
|
+
if outline and stroke >= 1.0:
|
|
880
|
+
kw = {"stroke_width": int(round(stroke)), "stroke_fill": outline}
|
|
881
|
+
anchor = _PIL_H.get(ha, "l") + _PIL_V.get(va, "s")
|
|
882
|
+
if italic and not rotation:
|
|
883
|
+
# No rotation to fight with, so this can (and should) honor ha/va
|
|
884
|
+
# exactly rather than falling back to the rotated path's "anchor is
|
|
885
|
+
# always the image center" approximation below.
|
|
886
|
+
from PIL import Image as PILImage, ImageDraw
|
|
887
|
+
|
|
888
|
+
abbox = draw.textbbox((0, 0), s, font=font, anchor=anchor)
|
|
889
|
+
w, h = abbox[2] - abbox[0], abbox[3] - abbox[1]
|
|
890
|
+
pad = 4 + int(round(stroke))
|
|
891
|
+
# The anchor point itself, in this padded canvas's local coordinates --
|
|
892
|
+
# drawing *at* that point with the same anchor is a tautology (that is
|
|
893
|
+
# what "anchor" means), so it is exactly the position drawn at below.
|
|
894
|
+
ax_local, ay_local = pad - abbox[0], pad - abbox[1]
|
|
895
|
+
tmp = PILImage.new("RGBA", (max(1, w + 2 * pad), max(1, h + 2 * pad)),
|
|
896
|
+
(0, 0, 0, 0))
|
|
897
|
+
ImageDraw.Draw(tmp).text((ax_local, ay_local), s, fill=fill, font=font,
|
|
898
|
+
anchor=anchor, **kw)
|
|
899
|
+
shear_pad = int(round(tmp.height * _ITALIC_SHEAR))
|
|
900
|
+
tmp = tmp.transform(
|
|
901
|
+
(tmp.width + shear_pad, tmp.height), PILImage.AFFINE,
|
|
902
|
+
(1, -_ITALIC_SHEAR, shear_pad, 0, 1, 0), resample=PILImage.BICUBIC)
|
|
903
|
+
# Track the anchor point through that same forward shear (the AFFINE
|
|
904
|
+
# data above is the inverse, output->input, map PIL actually applies).
|
|
905
|
+
ax_final = ax_local + _ITALIC_SHEAR * ay_local - shear_pad
|
|
906
|
+
draw._image.alpha_composite(tmp, (int(round(x - ax_final)), int(round(y - ay_local))))
|
|
907
|
+
return
|
|
908
|
+
if rotation:
|
|
909
|
+
from PIL import Image as PILImage, ImageDraw
|
|
910
|
+
|
|
911
|
+
bbox = draw.textbbox((0, 0), s, font=font)
|
|
912
|
+
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
913
|
+
pad = 4 + int(round(stroke))
|
|
914
|
+
tmp = PILImage.new("RGBA", (max(1, w + 2 * pad), max(1, h + 2 * pad)),
|
|
915
|
+
(0, 0, 0, 0))
|
|
916
|
+
ImageDraw.Draw(tmp).text((pad - bbox[0], pad - bbox[1]), s, fill=fill,
|
|
917
|
+
font=font, **kw)
|
|
918
|
+
if italic:
|
|
919
|
+
# Rotated italic keeps the same "anchor is the image center"
|
|
920
|
+
# approximation the plain rotated case already uses below --
|
|
921
|
+
# tracking an anchor point through shear *and* an expand=True
|
|
922
|
+
# rotation exactly isn't worth the complexity for a combination
|
|
923
|
+
# this rare.
|
|
924
|
+
shear_pad = int(round(tmp.height * _ITALIC_SHEAR))
|
|
925
|
+
tmp = tmp.transform(
|
|
926
|
+
(tmp.width + shear_pad, tmp.height), PILImage.AFFINE,
|
|
927
|
+
(1, -_ITALIC_SHEAR, shear_pad, 0, 1, 0), resample=PILImage.BICUBIC)
|
|
928
|
+
tmp = tmp.rotate(rotation, expand=True) # PIL & matplotlib: CCW positive
|
|
929
|
+
draw._image.alpha_composite(tmp, (int(x - tmp.width / 2), int(y - tmp.height / 2)))
|
|
930
|
+
return
|
|
931
|
+
draw.text((x, y), s, fill=fill, font=font, anchor=anchor, **kw)
|
|
932
|
+
|
|
933
|
+
|
|
934
|
+
def _quiver_arrow(draw, x0, y0, x1, y1, col, S):
|
|
935
|
+
w = max(1, int(round(1.2 * S)))
|
|
936
|
+
draw.line([x0, y0, x1, y1], fill=col, width=w)
|
|
937
|
+
ang = math.atan2(y1 - y0, x1 - x0)
|
|
938
|
+
hl = 7.0 * S
|
|
939
|
+
for da in (-0.4, 0.4):
|
|
940
|
+
draw.line([x1, y1, x1 - hl * math.cos(ang + da), y1 - hl * math.sin(ang + da)],
|
|
941
|
+
fill=col, width=w)
|
|
942
|
+
|
|
943
|
+
|
|
944
|
+
# -- furniture --------------------------------------------------------------
|
|
945
|
+
def _raster_ticks(ax, xst, yst, tr, xticks, yticks, L, T, Wp, Hp, S, draw,
|
|
946
|
+
xside="bottom", yside="left"):
|
|
947
|
+
xts = xst.tick_size * S
|
|
948
|
+
xcol = _rgb(xst.spine_color)
|
|
949
|
+
xfs = xst.tick_label_size * S
|
|
950
|
+
xfont = _font(xfs, xst.font_family)
|
|
951
|
+
xtw = max(1, int(round(xst.tick_width * S)))
|
|
952
|
+
yts = yst.tick_size * S
|
|
953
|
+
ycol = _rgb(yst.spine_color)
|
|
954
|
+
yfs = yst.tick_label_size * S
|
|
955
|
+
yfont = _font(yfs, yst.font_family)
|
|
956
|
+
ytw = max(1, int(round(yst.tick_width * S)))
|
|
957
|
+
xlabels = _resolve_tick_labels(ax._xticklabels, xticks)
|
|
958
|
+
ylabels = _resolve_tick_labels(ax._yticklabels, yticks)
|
|
959
|
+
x_top = xside == "top"
|
|
960
|
+
x_axis = T if x_top else T + Hp
|
|
961
|
+
x_sign = -1 if x_top else 1
|
|
962
|
+
y_right = yside == "right"
|
|
963
|
+
y_axis = L + Wp if y_right else L
|
|
964
|
+
y_sign = 1 if y_right else -1
|
|
965
|
+
for xt, lab in zip(xticks, xlabels):
|
|
966
|
+
x = float(tr.x(xt))
|
|
967
|
+
draw.line([x, x_axis, x, x_axis + x_sign * xts], fill=xcol, width=xtw)
|
|
968
|
+
ly = x_axis + x_sign * (xts + 1)
|
|
969
|
+
draw.text((x, ly), lab, fill=_rgb(xst.text_color), font=xfont,
|
|
970
|
+
anchor=("md" if x_top else "ma"))
|
|
971
|
+
for yt, lab in zip(yticks, ylabels):
|
|
972
|
+
y = float(tr.y(yt))
|
|
973
|
+
draw.line([y_axis, y, y_axis + y_sign * yts, y], fill=ycol, width=ytw)
|
|
974
|
+
lx = y_axis + y_sign * (yts + 2)
|
|
975
|
+
draw.text((lx, y), lab, fill=_rgb(yst.text_color), font=yfont,
|
|
976
|
+
anchor=("lm" if y_right else "rm"))
|
|
977
|
+
|
|
978
|
+
|
|
979
|
+
def _raster_minor_ticks(xst, yst, tr, xticks, yticks, L, T, Wp, Hp, S, draw,
|
|
980
|
+
xside="bottom", yside="left"):
|
|
981
|
+
"""Unlabeled minor tick marks -- the raster counterpart of the SVG one."""
|
|
982
|
+
xts = xst.tick_size * 0.6 * S
|
|
983
|
+
xcol = _rgb(xst.spine_color)
|
|
984
|
+
xtw = max(1, int(round(xst.tick_width * S)))
|
|
985
|
+
yts = yst.tick_size * 0.6 * S
|
|
986
|
+
ycol = _rgb(yst.spine_color)
|
|
987
|
+
ytw = max(1, int(round(yst.tick_width * S)))
|
|
988
|
+
x_top = xside == "top"
|
|
989
|
+
x_axis = T if x_top else T + Hp
|
|
990
|
+
x_sign = -1 if x_top else 1
|
|
991
|
+
y_right = yside == "right"
|
|
992
|
+
y_axis = L + Wp if y_right else L
|
|
993
|
+
y_sign = 1 if y_right else -1
|
|
994
|
+
for xt in xticks:
|
|
995
|
+
x = float(tr.x(xt))
|
|
996
|
+
draw.line([x, x_axis, x, x_axis + x_sign * xts], fill=xcol, width=xtw)
|
|
997
|
+
for yt in yticks:
|
|
998
|
+
y = float(tr.y(yt))
|
|
999
|
+
draw.line([y_axis, y, y_axis + y_sign * yts, y], fill=ycol, width=ytw)
|
|
1000
|
+
|
|
1001
|
+
|
|
1002
|
+
def _raster_twin_ticks(ax, st, tr, xticks, yticks, L, T, Wp, Hp, S, draw):
|
|
1003
|
+
col = _rgb(st.spine_color)
|
|
1004
|
+
ts = st.tick_size * S
|
|
1005
|
+
fs = st.tick_label_size * S
|
|
1006
|
+
font = _font(fs, st.font_family)
|
|
1007
|
+
tw = max(1, int(round(st.tick_width * S)))
|
|
1008
|
+
if ax._twin_shared == "x": # twinx: y-axis on the RIGHT
|
|
1009
|
+
xr = L + Wp
|
|
1010
|
+
for yt, lab in zip(yticks, _resolve_tick_labels(ax._yticklabels, yticks)):
|
|
1011
|
+
y = float(tr.y(yt))
|
|
1012
|
+
draw.line([xr, y, xr + ts, y], fill=col, width=tw)
|
|
1013
|
+
draw.text((xr + ts + 2, y), lab, fill=_rgb(st.text_color),
|
|
1014
|
+
font=font, anchor="lm")
|
|
1015
|
+
if ax._ylabel:
|
|
1016
|
+
lx = xr + ts + (_max_ytick_width(ax, st) + st.label_size + 4) * S
|
|
1017
|
+
_vtext(draw, ax._ylabel, lx, T + Hp / 2.0,
|
|
1018
|
+
_rgb(st.text_color), _font(st.label_size * S, st.font_family))
|
|
1019
|
+
else: # twiny: x-axis on the TOP
|
|
1020
|
+
for xt, lab in zip(xticks, _resolve_tick_labels(ax._xticklabels, xticks)):
|
|
1021
|
+
x = float(tr.x(xt))
|
|
1022
|
+
draw.line([x, T, x, T - ts], fill=col, width=tw)
|
|
1023
|
+
draw.text((x, T - ts - 1), lab, fill=_rgb(st.text_color),
|
|
1024
|
+
font=font, anchor="md")
|
|
1025
|
+
if ax._xlabel:
|
|
1026
|
+
draw.text((L + Wp / 2.0, T - ts - fs - st.label_size * S),
|
|
1027
|
+
ax._xlabel, fill=_rgb(st.text_color),
|
|
1028
|
+
font=_font(st.label_size * S, st.font_family), anchor="md")
|
|
1029
|
+
|
|
1030
|
+
|
|
1031
|
+
def _raster_labels(ax, st, L, T, Wp, Hp, S, draw):
|
|
1032
|
+
cx = L + Wp / 2.0
|
|
1033
|
+
ts, fs = st.tick_size, st.tick_label_size
|
|
1034
|
+
if ax._xlabel and not ax._axis_off:
|
|
1035
|
+
# Overrides (align_xlabels) are stamped in 1x figure-pixel space, like
|
|
1036
|
+
# the SVG backend's -- scale to this backend's supersampled space.
|
|
1037
|
+
if ax._xlabel_y_override is not None:
|
|
1038
|
+
y = ax._xlabel_y_override * S
|
|
1039
|
+
elif ax._xtick_side == "top":
|
|
1040
|
+
y = T - (ts + fs + st.label_size) * S
|
|
1041
|
+
else:
|
|
1042
|
+
y = T + Hp + (ts + fs + st.label_size + 4) * S
|
|
1043
|
+
draw.text((cx, y), ax._xlabel, fill=_rgb(st.text_color),
|
|
1044
|
+
font=_font(st.label_size * S, st.font_family), anchor="mm")
|
|
1045
|
+
if ax._ylabel and not ax._axis_off:
|
|
1046
|
+
# Mirror svg._render_labels exactly: clear the *measured* tick labels.
|
|
1047
|
+
# Substituting the tick font size for their width put this up to ~9px
|
|
1048
|
+
# from where the SVG draws it, jammed against the figure edge.
|
|
1049
|
+
if ax._ylabel_x_override is not None:
|
|
1050
|
+
lx = ax._ylabel_x_override * S
|
|
1051
|
+
elif ax._ytick_side == "right":
|
|
1052
|
+
lx = L + Wp + (ts + _max_ytick_width(ax, st) + st.label_size + 4) * S
|
|
1053
|
+
else:
|
|
1054
|
+
lx = L - (ts + _max_ytick_width(ax, st) + st.label_size + 4) * S
|
|
1055
|
+
_vtext(draw, ax._ylabel, lx, T + Hp / 2.0,
|
|
1056
|
+
_rgb(st.text_color), _font(st.label_size * S, st.font_family))
|
|
1057
|
+
if ax._title:
|
|
1058
|
+
# Pillow refuses a bottom/baseline anchor on multiline text, and the
|
|
1059
|
+
# title is the only label that uses one -- so a "\n" in a title raised
|
|
1060
|
+
# ValueError and took the whole PNG export with it, where every other
|
|
1061
|
+
# label merely broke the line. Stack the lines by hand instead. A
|
|
1062
|
+
# single-line title takes the same path and lands exactly where it did.
|
|
1063
|
+
from .svg import twiny_headroom
|
|
1064
|
+
|
|
1065
|
+
size = ax._title_size or st.title_size
|
|
1066
|
+
font = _font(size * S, st.font_family)
|
|
1067
|
+
line_h = size * 1.2 * S
|
|
1068
|
+
top = T - (8 + twiny_headroom(ax, st)) * S
|
|
1069
|
+
for i, line in enumerate(reversed(ax._title.split("\n"))):
|
|
1070
|
+
draw.text((cx, top - i * line_h), line,
|
|
1071
|
+
fill=_rgb(st.text_color), font=font, anchor="mb")
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _vtext(draw, text, x, y, fill, font):
|
|
1075
|
+
"""Draw vertical (rotated 90°) text centered at (x, y)."""
|
|
1076
|
+
from PIL import Image as PILImage, ImageDraw
|
|
1077
|
+
|
|
1078
|
+
bbox = draw.textbbox((0, 0), text, font=font)
|
|
1079
|
+
w, h = bbox[2] - bbox[0], bbox[3] - bbox[1]
|
|
1080
|
+
tmp = PILImage.new("RGBA", (max(1, w + 4), max(1, h + 4)), (0, 0, 0, 0))
|
|
1081
|
+
ImageDraw.Draw(tmp).text((2 - bbox[0], 2 - bbox[1]), text, fill=fill, font=font)
|
|
1082
|
+
tmp = tmp.rotate(90, expand=True)
|
|
1083
|
+
draw._image.alpha_composite(
|
|
1084
|
+
tmp, (int(x - tmp.width / 2), int(y - tmp.height / 2)))
|
|
1085
|
+
|
|
1086
|
+
|
|
1087
|
+
_LEGEND_ANCHORS = {
|
|
1088
|
+
"upper right": (1.0, 0.0), "upper left": (0.0, 0.0),
|
|
1089
|
+
"lower left": (0.0, 1.0), "lower right": (1.0, 1.0),
|
|
1090
|
+
"upper center": (0.5, 0.0), "lower center": (0.5, 1.0),
|
|
1091
|
+
"center left": (0.0, 0.5), "center right": (1.0, 0.5),
|
|
1092
|
+
"right": (1.0, 0.5), "center": (0.5, 0.5), "best": (1.0, 0.0),
|
|
1093
|
+
}
|
|
1094
|
+
|
|
1095
|
+
|
|
1096
|
+
def _raster_legend(ax, st, L, T, Wp, Hp, S, draw):
|
|
1097
|
+
# Regression: this recomputed entries/fontsize independently of
|
|
1098
|
+
# svg.py's _legend_layout() instead of reusing it, so legend(handles=,
|
|
1099
|
+
# fontsize=) rendered correctly in SVG but was silently ignored here --
|
|
1100
|
+
# every entry always came straight from ax.artists at the style's own
|
|
1101
|
+
# fixed size, in both backends, the same class of bug as the marker
|
|
1102
|
+
# color list, errorbar xerr, and polygon outline width fixes before it.
|
|
1103
|
+
source = ax._legend_handles if ax._legend_handles is not None else ax.artists
|
|
1104
|
+
# not in (None, "") rather than truthiness: label=0/0.0/False is a
|
|
1105
|
+
# legitimate label (matplotlib shows it as "0"), not an opt-out the way
|
|
1106
|
+
# None/"" is -- matches svg.py's own legend_entries()/_legend_layout().
|
|
1107
|
+
entries = [a for a in source if getattr(a, "label", None) not in (None, "")]
|
|
1108
|
+
if not entries:
|
|
1109
|
+
return
|
|
1110
|
+
fs = (ax._legend_fontsize if ax._legend_fontsize is not None else st.tick_label_size) * S
|
|
1111
|
+
font = _font(fs, st.font_family)
|
|
1112
|
+
title_font = _font(fs, st.font_family, bold=True) # SVG draws the title bold
|
|
1113
|
+
line_h = fs + 6 * S
|
|
1114
|
+
sample = 22 * S
|
|
1115
|
+
pad = 6 * S
|
|
1116
|
+
ncol = min(max(1, ax._legend_ncol), len(entries))
|
|
1117
|
+
nrows = (len(entries) + ncol - 1) // ncol
|
|
1118
|
+
# PIL's own draw.textlength()/draw.text() need an actual str -- unlike
|
|
1119
|
+
# svg.py's _esc(), which stringifies internally -- so a bare int/float
|
|
1120
|
+
# label (a common loop-variable accident) must be coerced here.
|
|
1121
|
+
tw = max(draw.textlength(str(a.label), font=font) for a in entries)
|
|
1122
|
+
col_w = sample + tw + pad * 2
|
|
1123
|
+
title = ax._legend_title
|
|
1124
|
+
title_h = line_h if title else 0
|
|
1125
|
+
box_w = col_w * ncol + pad
|
|
1126
|
+
if title:
|
|
1127
|
+
box_w = max(box_w, draw.textlength(title, font=title_font) + pad * 2)
|
|
1128
|
+
box_h = line_h * nrows + pad + title_h
|
|
1129
|
+
|
|
1130
|
+
fx, fy = _LEGEND_ANCHORS.get(ax._legend_loc, (1.0, 0.0))
|
|
1131
|
+
if ax._legend_bbox_to_anchor is not None:
|
|
1132
|
+
# Mirrors svg.py's own _legend_origin() -- see there for why this
|
|
1133
|
+
# is a corner-at-a-point placement, not the plain-loc inset below.
|
|
1134
|
+
ax_x, ax_y = ax._legend_bbox_to_anchor
|
|
1135
|
+
anchor_x = L + ax_x * Wp
|
|
1136
|
+
anchor_y = T + (1.0 - ax_y) * Hp
|
|
1137
|
+
bx, by = anchor_x - fx * box_w, anchor_y - fy * box_h
|
|
1138
|
+
else:
|
|
1139
|
+
bx = L + 6 * S + fx * max(0.0, Wp - box_w - 12 * S)
|
|
1140
|
+
by = T + 6 * S + fy * max(0.0, Hp - box_h - 12 * S)
|
|
1141
|
+
_raster_draw_legend(entries, st, S, draw, bx, by, box_w, box_h, ncol, col_w,
|
|
1142
|
+
line_h, sample, pad, title, title_h, font, title_font,
|
|
1143
|
+
ax._legend_framealpha)
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _raster_figure_legend(fig, st, W, H, S, draw):
|
|
1147
|
+
"""The PNG counterpart of svg._render_figure_legend.
|
|
1148
|
+
|
|
1149
|
+
Geometry comes from the *shared* layout in svg.py rather than from Pillow's
|
|
1150
|
+
own measurements, because figure.py already reserved a band of the canvas
|
|
1151
|
+
using those numbers -- measuring again here could size the box to something
|
|
1152
|
+
the reservation does not match.
|
|
1153
|
+
"""
|
|
1154
|
+
from .svg import figure_legend_layout, figure_legend_origin
|
|
1155
|
+
|
|
1156
|
+
lay = figure_legend_layout(fig)
|
|
1157
|
+
if lay is None:
|
|
1158
|
+
return
|
|
1159
|
+
spec = fig._figure_legend
|
|
1160
|
+
pad_px = spec["pad"] * min(W / S, H / S) + 4
|
|
1161
|
+
bx, by = figure_legend_origin(spec, lay, W / S, H / S, pad_px)
|
|
1162
|
+
fs = lay["fs"] * S
|
|
1163
|
+
_raster_draw_legend(
|
|
1164
|
+
lay["entries"], st, S, draw, bx * S, by * S,
|
|
1165
|
+
lay["box_w"] * S, lay["box_h"] * S, lay["ncol"], lay["col_w"] * S,
|
|
1166
|
+
lay["line_h"] * S, lay["sample_w"] * S, lay["pad"] * S,
|
|
1167
|
+
lay["title"], lay["title_h"] * S,
|
|
1168
|
+
_font(fs, st.font_family), _font(fs, st.font_family, bold=True),
|
|
1169
|
+
lay["framealpha"])
|
|
1170
|
+
|
|
1171
|
+
|
|
1172
|
+
def _raster_draw_legend(entries, st, S, draw, bx, by, box_w, box_h, ncol, col_w,
|
|
1173
|
+
line_h, sample, pad, title, title_h, font, title_font,
|
|
1174
|
+
framealpha=0.85):
|
|
1175
|
+
"""Paint a legend box whose geometry has already been decided.
|
|
1176
|
+
|
|
1177
|
+
Regression: this box was always fully opaque (255, 255, 255) regardless
|
|
1178
|
+
of the SVG backend's own fill-opacity="0.85" on the same box -- the two
|
|
1179
|
+
backends drew a different-looking legend for the same figure. Both now
|
|
1180
|
+
read the same framealpha (see Axes.legend/Figure.legend).
|
|
1181
|
+
"""
|
|
1182
|
+
draw.rectangle([bx, by, bx + box_w, by + box_h], fill=_rgba("#ffffff", framealpha),
|
|
1183
|
+
outline=(204, 204, 204))
|
|
1184
|
+
if title:
|
|
1185
|
+
draw.text((bx + box_w / 2, by + pad), title, fill=_rgb(st.text_color),
|
|
1186
|
+
font=title_font, anchor="ma")
|
|
1187
|
+
for i, a in enumerate(entries):
|
|
1188
|
+
r, c = divmod(i, ncol)
|
|
1189
|
+
sx = bx + pad + c * col_w
|
|
1190
|
+
ry = by + pad + title_h + line_h * r + line_h / 2.0
|
|
1191
|
+
if isinstance(a, Bars):
|
|
1192
|
+
color = _rgb(a.colors[0] if a.colors else "#333333")
|
|
1193
|
+
else:
|
|
1194
|
+
color = _rgb(getattr(a, "color", None)
|
|
1195
|
+
or getattr(a, "linecolor", None) or "#333333")
|
|
1196
|
+
if isinstance(a, ScatterCollection):
|
|
1197
|
+
rr = 4 * S
|
|
1198
|
+
draw.ellipse([sx + sample / 2 - rr, ry - rr, sx + sample / 2 + rr, ry + rr], fill=color)
|
|
1199
|
+
elif isinstance(a, (Bars, FillBetween, Span, Polygon)):
|
|
1200
|
+
# SVG gives this swatch the artist's fill-opacity; composite the
|
|
1201
|
+
# same alpha over the box's white background so the two backends
|
|
1202
|
+
# agree on how a translucent fill reads in the legend.
|
|
1203
|
+
alpha = getattr(a, "alpha", 1.0) if isinstance(
|
|
1204
|
+
a, (FillBetween, Span, Polygon)) else 1.0
|
|
1205
|
+
swatch = tuple(int(round(c * alpha + 255 * (1.0 - alpha)))
|
|
1206
|
+
for c in color[:3])
|
|
1207
|
+
draw.rectangle([sx, ry - 5 * S, sx + sample, ry + 5 * S], fill=swatch)
|
|
1208
|
+
else:
|
|
1209
|
+
# Match svg.draw_legend: the swatch carries the artist's dash
|
|
1210
|
+
# pattern, so a dashed reference line is not drawn as a solid one.
|
|
1211
|
+
width = max(1, int(round(2 * S)))
|
|
1212
|
+
dash = _DASH.get(getattr(a, "linestyle", "-"))
|
|
1213
|
+
seg = [(sx, ry), (sx + sample, ry)]
|
|
1214
|
+
if dash:
|
|
1215
|
+
_dashed(draw, seg, color, width, tuple(d * S for d in dash))
|
|
1216
|
+
else:
|
|
1217
|
+
draw.line([sx, ry, sx + sample, ry], fill=color, width=width)
|
|
1218
|
+
draw.text((sx + sample + pad, ry), str(a.label), fill=_rgb(st.text_color),
|
|
1219
|
+
font=font, anchor="lm")
|
|
1220
|
+
|
|
1221
|
+
|
|
1222
|
+
def _raster_figtexts(fig, W, H, S, draw):
|
|
1223
|
+
st = fig.style
|
|
1224
|
+
if fig._suptitle:
|
|
1225
|
+
t = fig._suptitle
|
|
1226
|
+
size = (t.get("size") or st.title_size * 1.5) * S
|
|
1227
|
+
draw.text((W / 2, 6 * S), t["text"], fill=_rgb(st.text_color),
|
|
1228
|
+
font=_font(size, st.font_family, bold=True), anchor="ma")
|
|
1229
|
+
if fig._supxlabel:
|
|
1230
|
+
t = fig._supxlabel
|
|
1231
|
+
size = (t.get("size") or st.label_size * 1.2) * S
|
|
1232
|
+
draw.text((W / 2, H - 6 * S), t["text"], fill=_rgb(st.text_color),
|
|
1233
|
+
font=_font(size, st.font_family), anchor="md")
|
|
1234
|
+
if fig._supylabel:
|
|
1235
|
+
t = fig._supylabel
|
|
1236
|
+
size = (t.get("size") or st.label_size * 1.2) * S
|
|
1237
|
+
_vtext(draw, t["text"], 6 * S + size / 2, H / 2, _rgb(st.text_color), _font(size, st.font_family))
|
|
1238
|
+
for t in fig._fig_texts:
|
|
1239
|
+
from .svg import _bbox_pad, text_box
|
|
1240
|
+
|
|
1241
|
+
size = (t["size"] or st.font_size) * S
|
|
1242
|
+
x, y = t["x"] * W, (1.0 - t["y"]) * H
|
|
1243
|
+
anchor = _PIL_H.get(t["ha"], "l") + _PIL_V.get(t["va"], "s")
|
|
1244
|
+
bbox = t.get("bbox")
|
|
1245
|
+
if bbox is not None:
|
|
1246
|
+
box = _bbox_pad(text_box(x / S, y / S, t["s"], t["size"] or st.font_size,
|
|
1247
|
+
t["ha"], t["va"], st), bbox)
|
|
1248
|
+
_raster_bbox(draw, [c * S for c in box], bbox, S)
|
|
1249
|
+
alpha = t.get("alpha", 1.0)
|
|
1250
|
+
color = t["color"] or st.text_color
|
|
1251
|
+
fill = _rgba(color, alpha) if alpha < 1 else _rgb(color)
|
|
1252
|
+
draw.text((x, y), t["s"], fill=fill,
|
|
1253
|
+
font=_font(size, st.font_family), anchor=anchor)
|
|
1254
|
+
|
|
1255
|
+
|
|
1256
|
+
def _raster_groups(fig, W, H, S, draw):
|
|
1257
|
+
"""The PNG counterpart of svg._render_groups."""
|
|
1258
|
+
st = fig.style
|
|
1259
|
+
for g in fig._groups:
|
|
1260
|
+
members = g["axes"] + _group_colorbars(g["axes"], fig)
|
|
1261
|
+
rects = [_pixel_rect(ax, W, H) for ax in members]
|
|
1262
|
+
extras = [_group_colorbar_extra(ax, st) if ax._is_colorbar
|
|
1263
|
+
else _group_axes_extra(ax, st) for ax in members]
|
|
1264
|
+
pad_l, pad_r, pad_t, pad_b = (v * S for v in g["pad"])
|
|
1265
|
+
x0 = min(r[0] - e[2] * S for r, e in zip(rects, extras)) - pad_l
|
|
1266
|
+
y0 = min(r[1] - e[0] * S for r, e in zip(rects, extras)) - pad_t
|
|
1267
|
+
x1 = max(r[0] + r[2] + e[3] * S for r, e in zip(rects, extras)) + pad_r
|
|
1268
|
+
y1 = max(r[1] + r[3] + e[1] * S for r, e in zip(rects, extras)) + pad_b
|
|
1269
|
+
pts = np.array([[x0, y0], [x1, y0], [x1, y1], [x0, y1], [x0, y0]])
|
|
1270
|
+
# linestyle="none" -- an invisible box (title only), same as every
|
|
1271
|
+
# other line-drawing method, not a solid border falling through
|
|
1272
|
+
# _DASH.get() unmatched.
|
|
1273
|
+
if g["linestyle"] != "none":
|
|
1274
|
+
dash = _DASH.get(g["linestyle"])
|
|
1275
|
+
dash_scaled = tuple(d * S for d in dash) if dash else None
|
|
1276
|
+
_polyline(draw, pts, _rgb(g["color"]),
|
|
1277
|
+
max(1, int(round(g["linewidth"] * S))), dash_scaled)
|
|
1278
|
+
size = (g["fontsize"] or fig.style.title_size) * S
|
|
1279
|
+
font = _font(size, fig.style.font_family, bold=True)
|
|
1280
|
+
color = _rgb(g["color"])
|
|
1281
|
+
pos = g["title_position"]
|
|
1282
|
+
if pos == "top":
|
|
1283
|
+
draw.text(((x0 + x1) / 2, y0 - 6 * S), g["title"], fill=color,
|
|
1284
|
+
font=font, anchor="md")
|
|
1285
|
+
elif pos == "bottom":
|
|
1286
|
+
draw.text(((x0 + x1) / 2, y1 + 6 * S), g["title"], fill=color,
|
|
1287
|
+
font=font, anchor="ma")
|
|
1288
|
+
elif pos == "left":
|
|
1289
|
+
draw.text((x0 - 6 * S, (y0 + y1) / 2), g["title"], fill=color,
|
|
1290
|
+
font=font, anchor="rm")
|
|
1291
|
+
else:
|
|
1292
|
+
draw.text((x1 + 6 * S, (y0 + y1) / 2), g["title"], fill=color,
|
|
1293
|
+
font=font, anchor="lm")
|
|
1294
|
+
|
|
1295
|
+
|
|
1296
|
+
def _raster_colorbar(ax, tr, L, T, Wp, Hp, S, draw, canvas):
|
|
1297
|
+
from PIL import Image as PILImage
|
|
1298
|
+
|
|
1299
|
+
src = ax._cbar_source
|
|
1300
|
+
lut = src.lut
|
|
1301
|
+
grad = np.flipud(lut).reshape(-1, 1, 3).astype(np.uint8)
|
|
1302
|
+
alpha = np.full((grad.shape[0], 1, 1), 255, np.uint8)
|
|
1303
|
+
rgba = np.concatenate([grad, alpha], axis=2)
|
|
1304
|
+
im = PILImage.fromarray(rgba, "RGBA").resize(
|
|
1305
|
+
(max(1, int(Wp)), max(1, int(Hp))), PILImage.BILINEAR)
|
|
1306
|
+
canvas.alpha_composite(im, (int(L), int(T)))
|
|
1307
|
+
draw.rectangle([L, T, L + Wp, T + Hp], outline=_rgb(ax.style.spine_color),
|
|
1308
|
+
width=max(1, int(round(ax.style.spine_width * S))))
|
|
1309
|
+
st = ax.style
|
|
1310
|
+
_, fracs, tlabels = colorbar_ticks(src.norm)
|
|
1311
|
+
font = _font(st.tick_label_size * S, st.font_family)
|
|
1312
|
+
for frac, lab in zip(fracs, tlabels):
|
|
1313
|
+
y = T + (1 - frac) * Hp
|
|
1314
|
+
draw.line([L + Wp, y, L + Wp + st.tick_size * S, y], fill=_rgb(st.spine_color), width=S)
|
|
1315
|
+
draw.text((L + Wp + st.tick_size * S + 2, y), lab, fill=_rgb(st.text_color),
|
|
1316
|
+
font=font, anchor="lm")
|