Thanks to visit codestin.com
Credit goes to github.com

Skip to content

Commit 530fc16

Browse files
PERF: Text handling speedups (#31001)
* Cache text rotation Affine2D Fix tests * Direct array creation More robust BBox creation * Speed up min/max calcs * Fast path for unrotated text * Faster FontProperties copy * Faster array operations * Faster shape check * Code review updates * More robust FontProperties hashing / copying * Skip redundant wrapped text context manager * Code review updates * Restore stub __copy__ * Text consolidate rotation code path * Prefer np.vstack().T to np.column_stack() for speed Revert "Prefer np.vstack().T to np.column_stack() for speed" This reverts commit 2e32436. Simplify column stack * Code review updates * Cleanup --------- Co-authored-by: Scott Shambaugh <[email protected]>
1 parent 948fbde commit 530fc16

5 files changed

Lines changed: 112 additions & 97 deletions

File tree

lib/matplotlib/font_manager.py

Lines changed: 11 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,6 @@
2828
from __future__ import annotations
2929

3030
from base64 import b64encode
31-
import copy
3231
import dataclasses
3332
from functools import cache, lru_cache
3433
import functools
@@ -767,15 +766,7 @@ def _from_any(cls, arg):
767766
return cls(**arg)
768767

769768
def __hash__(self):
770-
l = (tuple(self.get_family()),
771-
self.get_slant(),
772-
self.get_variant(),
773-
self.get_weight(),
774-
self.get_stretch(),
775-
self.get_size(),
776-
self.get_file(),
777-
self.get_math_fontfamily())
778-
return hash(l)
769+
return hash(tuple(self.__dict__.values()))
779770

780771
def __eq__(self, other):
781772
return hash(self) == hash(other)
@@ -791,7 +782,7 @@ def get_family(self):
791782
from their respective rcParams when searching for a matching font) in
792783
the order of preference.
793784
"""
794-
return self._family
785+
return list(self._family)
795786

796787
def get_name(self):
797788
"""
@@ -860,8 +851,8 @@ def set_family(self, family):
860851
"""
861852
family = mpl._val_or_rc(family, 'font.family')
862853
if isinstance(family, str):
863-
family = [family]
864-
self._family = family
854+
family = (family,)
855+
self._family = tuple(family)
865856

866857
def set_style(self, style):
867858
"""
@@ -1021,9 +1012,15 @@ def set_math_fontfamily(self, fontfamily):
10211012
_api.check_in_list(valid_fonts, math_fontfamily=fontfamily)
10221013
self._math_fontfamily = fontfamily
10231014

1015+
def __copy__(self):
1016+
# Bypass __init__ for speed, since values are already validated
1017+
new = FontProperties.__new__(FontProperties)
1018+
new.__dict__.update(self.__dict__)
1019+
return new
1020+
10241021
def copy(self):
10251022
"""Return a copy of self."""
1026-
return copy.copy(self)
1023+
return self.__copy__()
10271024

10281025
# Aliases
10291026
set_name = set_family

lib/matplotlib/font_manager.pyi

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -71,6 +71,7 @@ class FontProperties:
7171
math_fontfamily: str | None = ...,
7272
) -> None: ...
7373
def __hash__(self) -> int: ...
74+
def __copy__(self) -> FontProperties: ...
7475
def __eq__(self, other: object) -> bool: ...
7576
def get_family(self) -> list[str]: ...
7677
def get_name(self) -> str: ...

lib/matplotlib/lines.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -684,7 +684,8 @@ def recache(self, always=False):
684684
y = self._y
685685

686686
self._xy = np.column_stack(np.broadcast_arrays(x, y)).astype(float)
687-
self._x, self._y = self._xy.T # views
687+
self._x = self._xy[:, 0] # views of the x and y data
688+
self._y = self._xy[:, 1]
688689

689690
self._subslice = False
690691
if (self.axes

lib/matplotlib/text.py

Lines changed: 97 additions & 81 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44

55
from collections.abc import Sequence
66
import functools
7+
import itertools
78
import logging
89
import math
910
from numbers import Real
@@ -24,6 +25,25 @@
2425
_log = logging.getLogger(__name__)
2526

2627

28+
@functools.lru_cache(maxsize=128)
29+
def _rotate(theta):
30+
"""
31+
Return an Affine2D object that rotates by the given angle in radians.
32+
"""
33+
return Affine2D().rotate(theta)
34+
35+
36+
def _rotate_point(angle, x, y):
37+
"""
38+
Rotate point (x, y) by rotation angle in degrees
39+
"""
40+
if angle == 0:
41+
return (x, y)
42+
angle_rad = math.radians(angle)
43+
cos, sin = math.cos(angle_rad), math.sin(angle_rad)
44+
return (cos * x - sin * y, sin * x + cos * y)
45+
46+
2747
def _get_textbox(text, renderer):
2848
"""
2949
Calculate the bounding box of the text.
@@ -39,8 +59,8 @@ def _get_textbox(text, renderer):
3959

4060
projected_xys = []
4161

42-
theta = np.deg2rad(text.get_rotation())
43-
tr = Affine2D().rotate(-theta)
62+
theta = math.radians(text.get_rotation())
63+
tr = _rotate(-theta)
4464

4565
_, parts = text._get_layout(renderer)
4666

@@ -57,7 +77,7 @@ def _get_textbox(text, renderer):
5777
xt_box, yt_box = min(projected_xs), min(projected_ys)
5878
w_box, h_box = max(projected_xs) - xt_box, max(projected_ys) - yt_box
5979

60-
x_box, y_box = Affine2D().rotate(theta).transform((xt_box, yt_box))
80+
x_box, y_box = _rotate(theta).transform((xt_box, yt_box))
6181

6282
return x_box, y_box, w_box, h_box
6383

@@ -355,10 +375,10 @@ def _char_index_at(self, x):
355375
return (np.abs(size_accum - std_x)).argmin()
356376

357377
def get_rotation(self):
358-
"""Return the text angle in degrees between 0 and 360."""
378+
"""Return the text angle in degrees in the range [0, 360)."""
359379
if self.get_transform_rotates_text():
360380
return self.get_transform().transform_angles(
361-
[self._rotation], [self.get_unitless_position()]).item(0)
381+
[self._rotation], [self.get_unitless_position()]).item(0) % 360
362382
else:
363383
return self._rotation
364384

@@ -496,9 +516,6 @@ def _get_layout(self, renderer):
496516
ymax = 0
497517
ymin = ys[-1] - descent # baseline of last line minus its descent
498518

499-
# get the rotation matrix
500-
M = Affine2D().rotate_deg(self.get_rotation())
501-
502519
# now offset the individual text lines within the box
503520
malign = self._get_multialignment()
504521
if malign == 'left':
@@ -511,16 +528,17 @@ def _get_layout(self, renderer):
511528
for x, y, w in zip(xs, ys, ws)]
512529

513530
# the corners of the unrotated bounding box
514-
corners_horiz = np.array(
515-
[(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin)])
531+
corners_horiz = [(xmin, ymin), (xmin, ymax), (xmax, ymax), (xmax, ymin)]
516532

517533
# now rotate the bbox
518-
corners_rotated = M.transform(corners_horiz)
534+
angle = self.get_rotation()
535+
rotate = functools.partial(_rotate_point, angle)
536+
corners_rotated = [rotate(x, y) for x, y in corners_horiz]
537+
519538
# compute the bounds of the rotated box
520-
xmin = corners_rotated[:, 0].min()
521-
xmax = corners_rotated[:, 0].max()
522-
ymin = corners_rotated[:, 1].min()
523-
ymax = corners_rotated[:, 1].max()
539+
xs, ys = zip(*corners_rotated)
540+
xmin, xmax = min(xs), max(xs)
541+
ymin, ymax = min(ys), max(ys)
524542
width = xmax - xmin
525543
height = ymax - ymin
526544

@@ -531,7 +549,6 @@ def _get_layout(self, renderer):
531549

532550
rotation_mode = self.get_rotation_mode()
533551
if rotation_mode != "anchor":
534-
angle = self.get_rotation()
535552
if rotation_mode == 'xtick':
536553
halign = self._ha_for_angle(angle)
537554
elif rotation_mode == 'ytick':
@@ -577,15 +594,16 @@ def _get_layout(self, renderer):
577594
else:
578595
offsety = ymin1
579596

580-
offsetx, offsety = M.transform((offsetx, offsety))
597+
offsetx, offsety = rotate(offsetx, offsety)
581598

582599
xmin -= offsetx
583600
ymin -= offsety
584601

585602
bbox = Bbox.from_bounds(xmin, ymin, width, height)
586603

587604
# now rotate the positions around the first (x, y) position
588-
xys = M.transform(offset_layout) - (offsetx, offsety)
605+
xys = [(x - offsetx, y - offsety)
606+
for x, y in itertools.starmap(rotate, offset_layout)]
589607

590608
return bbox, list(zip(lines, wads, xys))
591609

@@ -726,11 +744,11 @@ def _get_wrap_line_width(self):
726744
# Calculate available width based on text alignment
727745
alignment = self.get_horizontalalignment()
728746
self.set_rotation_mode('anchor')
729-
rotation = self.get_rotation()
747+
angle = self.get_rotation()
730748

731-
left = self._get_dist_to_box(rotation, x0, y0, figure_box)
749+
left = self._get_dist_to_box(angle, x0, y0, figure_box)
732750
right = self._get_dist_to_box(
733-
(180 + rotation) % 360, x0, y0, figure_box)
751+
(180 + angle) % 360, x0, y0, figure_box)
734752

735753
if alignment == 'left':
736754
line_width = left
@@ -839,67 +857,65 @@ def draw(self, renderer):
839857

840858
renderer.open_group('text', self.get_gid())
841859

842-
with self._cm_set(text=self._get_wrapped_text()):
843-
bbox, info = self._get_layout(renderer)
844-
trans = self.get_transform()
860+
bbox, info = self._get_layout(renderer)
861+
trans = self.get_transform()
862+
863+
# don't use self.get_position here, which refers to text
864+
# position in Text:
865+
x, y = self._x, self._y
866+
if np.ma.is_masked(x):
867+
x = np.nan
868+
if np.ma.is_masked(y):
869+
y = np.nan
870+
posx = float(self.convert_xunits(x))
871+
posy = float(self.convert_yunits(y))
872+
posx, posy = trans.transform((posx, posy))
873+
if np.isnan(posx) or np.isnan(posy):
874+
return # don't throw a warning here
875+
if not np.isfinite(posx) or not np.isfinite(posy):
876+
_log.warning("posx and posy should be finite values")
877+
return
878+
canvasw, canvash = renderer.get_canvas_width_height()
845879

846-
# don't use self.get_position here, which refers to text
847-
# position in Text:
848-
x, y = self._x, self._y
849-
if np.ma.is_masked(x):
850-
x = np.nan
851-
if np.ma.is_masked(y):
852-
y = np.nan
853-
posx = float(self.convert_xunits(x))
854-
posy = float(self.convert_yunits(y))
855-
posx, posy = trans.transform((posx, posy))
856-
if np.isnan(posx) or np.isnan(posy):
857-
return # don't throw a warning here
858-
if not np.isfinite(posx) or not np.isfinite(posy):
859-
_log.warning("posx and posy should be finite values")
860-
return
861-
canvasw, canvash = renderer.get_canvas_width_height()
862-
863-
# Update the location and size of the bbox
864-
# (`.patches.FancyBboxPatch`), and draw it.
865-
if self._bbox_patch:
866-
self.update_bbox_position_size(renderer)
867-
self._bbox_patch.draw(renderer)
868-
869-
gc = renderer.new_gc()
870-
gc.set_foreground(mcolors.to_rgba(self.get_color()), isRGBA=True)
871-
gc.set_alpha(self.get_alpha())
872-
gc.set_url(self._url)
873-
gc.set_antialiased(self._antialiased)
874-
gc.set_snap(self.get_snap())
875-
self._set_gc_clip(gc)
876-
877-
angle = self.get_rotation()
878-
879-
for line, wad, (x, y) in info:
880-
881-
mtext = self if len(info) == 1 else None
882-
x = x + posx
883-
y = y + posy
884-
if renderer.flipy():
885-
y = canvash - y
886-
clean_line, ismath = self._preprocess_math(line)
887-
888-
if self.get_path_effects():
889-
from matplotlib.patheffects import PathEffectRenderer
890-
textrenderer = PathEffectRenderer(
891-
self.get_path_effects(), renderer)
892-
else:
893-
textrenderer = renderer
894-
895-
if self.get_usetex():
896-
textrenderer.draw_tex(gc, x, y, clean_line,
897-
self._fontproperties, angle,
898-
mtext=mtext)
899-
else:
900-
textrenderer.draw_text(gc, x, y, clean_line,
901-
self._fontproperties, angle,
902-
ismath=ismath, mtext=mtext)
880+
# Update the location and size of the bbox
881+
# (`.patches.FancyBboxPatch`), and draw it.
882+
if self._bbox_patch:
883+
self.update_bbox_position_size(renderer)
884+
self._bbox_patch.draw(renderer)
885+
886+
gc = renderer.new_gc()
887+
gc.set_foreground(mcolors.to_rgba(self.get_color()), isRGBA=True)
888+
gc.set_alpha(self.get_alpha())
889+
gc.set_url(self._url)
890+
gc.set_antialiased(self._antialiased)
891+
gc.set_snap(self.get_snap())
892+
self._set_gc_clip(gc)
893+
894+
angle = self.get_rotation()
895+
896+
for line, wad, (x, y) in info:
897+
898+
mtext = self if len(info) == 1 else None
899+
x = x + posx
900+
y = y + posy
901+
if renderer.flipy():
902+
y = canvash - y
903+
clean_line, ismath = self._preprocess_math(line)
904+
905+
if self.get_path_effects():
906+
from matplotlib.patheffects import PathEffectRenderer
907+
textrenderer = PathEffectRenderer(self.get_path_effects(), renderer)
908+
else:
909+
textrenderer = renderer
910+
911+
if self.get_usetex():
912+
textrenderer.draw_tex(gc, x, y, clean_line,
913+
self._fontproperties, angle,
914+
mtext=mtext)
915+
else:
916+
textrenderer.draw_text(gc, x, y, clean_line,
917+
self._fontproperties, angle,
918+
ismath=ismath, mtext=mtext)
903919

904920
gc.restore()
905921
renderer.close_group('text')

lib/matplotlib/transforms.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -841,7 +841,7 @@ def from_extents(*args, minpos=None):
841841
set. This is useful when dealing with logarithmic scales and other
842842
scales where negative bounds result in floating point errors.
843843
"""
844-
bbox = Bbox(np.reshape(args, (2, 2)))
844+
bbox = Bbox(np.asarray(args, dtype=float).reshape((2, 2)))
845845
if minpos is not None:
846846
bbox._minpos[:] = minpos
847847
return bbox

0 commit comments

Comments
 (0)