Skip to content

Commit 8ee82e5

Browse files
committed
perf: defer Matplotlib import until something actually plots
`import spatialmath` unconditionally pulled in all of Matplotlib, even for users who never plot anything: `spatialmath/base/__init__.py` did `from spatialmath.base.animate import *` / `from spatialmath.base.graphics import *` at package-import time, and those two modules do `import matplotlib.pyplot`. `geom2d.py`, `geom3d.py`, `spline.py`, and both `transforms2d.py`/`transforms3d.py` had their own copies of the same pattern, some via a `try: import matplotlib.pyplot ... except ImportError:` optional-dependency check that still imported eagerly, just without crashing if it failed. Measured on this machine: bare `import matplotlib` costs ~96ms, `matplotlib.path` (needed structurally by Polygon2's geometry, not just plotting) adds ~0ms on top of that, but `matplotlib.pyplot` specifically (backend resolution + figure/state setup) adds another ~160ms. That pyplot cost is what this change removes from `import spatialmath`. Approach: don't touch matplotlib usage inside graphics.py/animate.py at all (dozens of `plt.Axes`-style annotations in there - rewriting those would be the disproportionate version of this fix). Instead, defer the module import itself: - spatialmath/base/__init__.py: replace the two blanket `import *` lines with a PEP 562 module `__getattr__` that imports animate.py/graphics.py lazily on first access of one of their names (Animate, plot_box, tranimate, etc.) rather than unconditionally at package import time. - spatialmath/base/transforms2d.py, transforms3d.py: both had a module-level `try: import matplotlib.pyplot ... except ImportError: _matplotlib_exists = False` used purely to decide whether to define trplot/trplot2/tranimate/tranimate2 at all. Replaced with a cheap `importlib.util.find_spec("matplotlib")` check (no real import), and moved the small number of actual `plotvol2/3`, `axes_logic`, `Animate`/`Animate2` and `plt.show()` call sites into the specific functions that use them - they were already the only things those two files needed matplotlib for. - spatialmath/spline.py, geom3d.py: same pattern, moved `import matplotlib.pyplot as plt` from module top into the one or two methods that actually plot. - spatialmath/geom2d.py: nuance here - Polygon2.__init__ uses `matplotlib.path.Path` for real geometry (point containment etc.), and Polygon2.transformed() uses `matplotlib.transforms.Affine2D` for the same reason, so both stay at module level (cheap anyway, per the measurement above). Only `matplotlib.pyplot` itself, and the `plot_ellipse` import (only used by Ellipse.plot), moved. All of the above needed `from __future__ import annotations` added where not already present, so a signature like `ax: Optional[plt.Axes] = None` doesn't force `plt` to be a real bound name at function *definition* time (which happens at module-import time, before the deferred import ever runs) - annotations become lazy strings instead, which type checkers still read fine. Matches the existing convention in 8 other files in this codebase. One real (if minor) bug this surfaced: tests/test_geom3d.py used `plt.figure()` without importing matplotlib.pyplot itself - it was only working because `from spatialmath.geom3d import *` used to leak `plt` in as a wildcard-imported name. Fixed by importing it directly, which is what the test should have done regardless of this change. Verified: - `import spatialmath` no longer puts `matplotlib.pyplot` in sys.modules; `matplotlib` (the ~96ms base package, structurally needed by Polygon2's geometry) still does. - Wall-clock `import spatialmath`, same machine, same warm caches: ~670ms average on 3 runs before this change (upstream/master, via a throwaway worktree), ~447ms average after. - Full test suite green in both CI-like mode (`CI=true MPLBACKEND=Agg`: 338 passed, 4 skipped) and a local-run simulation (`MPLBACKEND=Agg`, `CI` unset): 332 passed (excluding two files that depend on a separate, already-open PR for their own local-run fixes, unrelated to this change). - Manual smoke test: trplot, trplot2, Polygon2.plot/.animate/.contains, Ellipse.plot, Line3.plot, Plane3.plot, BSplineSE3.visualize all still produce real output under a forced Agg backend. - `black --check` clean at the pinned 23.10.0.
1 parent 8b83c1f commit 8ee82e5

7 files changed

Lines changed: 103 additions & 26 deletions

File tree

spatialmath/base/__init__.py

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,58 @@
99
from spatialmath.base.transformsNd import * # lgtm [py/polluting-import]
1010
from spatialmath.base.vectors import * # lgtm [py/polluting-import]
1111
from spatialmath.base.symbolic import * # lgtm [py/polluting-import]
12-
from spatialmath.base.animate import * # lgtm [py/polluting-import]
13-
from spatialmath.base.graphics import * # lgtm [py/polluting-import]
1412
from spatialmath.base.numeric import * # lgtm [py/polluting-import]
1513

14+
import importlib
15+
16+
# `animate` and `graphics` both import Matplotlib, which is slow to import
17+
# (mostly backend resolution in matplotlib.pyplot) and is only actually
18+
# needed once something tries to plot. Load them lazily, on first access
19+
# of one of their names below, instead of unconditionally at package
20+
# import time.
21+
_LAZY_SUBMODULES = ("animate", "graphics")
22+
23+
_LAZY_ATTRS = {
24+
"Animate": "animate",
25+
"Animate2": "animate",
26+
"plot_text": "graphics",
27+
"plot_point": "graphics",
28+
"plot_homline": "graphics",
29+
"plot_box": "graphics",
30+
"plot_arrow": "graphics",
31+
"plot_polygon": "graphics",
32+
"circle": "graphics",
33+
"plot_circle": "graphics",
34+
"ellipse": "graphics",
35+
"plot_ellipse": "graphics",
36+
"sphere": "graphics",
37+
"plot_sphere": "graphics",
38+
"ellipsoid": "graphics",
39+
"plot_ellipsoid": "graphics",
40+
"cylinder": "graphics",
41+
"plot_cylinder": "graphics",
42+
"plot_cone": "graphics",
43+
"plot_cuboid": "graphics",
44+
"axes_logic": "graphics",
45+
"plotvol2": "graphics",
46+
"plotvol3": "graphics",
47+
"expand_dims": "graphics",
48+
"isnotebook": "graphics",
49+
}
50+
51+
52+
def __getattr__(name):
53+
if name in _LAZY_SUBMODULES:
54+
return importlib.import_module(f"spatialmath.base.{name}")
55+
modname = _LAZY_ATTRS.get(name)
56+
if modname is None:
57+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
58+
module = importlib.import_module(f"spatialmath.base.{modname}")
59+
value = getattr(module, name)
60+
globals()[name] = value # cache so future lookups skip __getattr__
61+
return value
62+
63+
1664
from spatialmath.base.argcheck import (
1765
assertmatrix,
1866
ismatrix,

spatialmath/base/transforms2d.py

Lines changed: 13 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
# Part of Spatial Math Toolbox for Python
24
# Copyright (c) 2000 Peter Corke
35
# MIT Licence, see details in top-level file: LICENCE
@@ -16,20 +18,23 @@
1618

1719
import sys
1820
import math
21+
import importlib.util
22+
from typing import TYPE_CHECKING
1923
import numpy as np
2024

21-
try:
22-
import matplotlib.pyplot as plt
23-
24-
_matplotlib_exists = True
25-
except ImportError:
26-
_matplotlib_exists = False
25+
# cheap existence check, doesn't actually import matplotlib: the real
26+
# import happens lazily inside trplot2()/tranimate2() when a plot is
27+
# actually made
28+
_matplotlib_exists = importlib.util.find_spec("matplotlib") is not None
2729

2830
import spatialmath.base as smb
2931
from spatialmath.base.types import *
3032
from spatialmath.base.transformsNd import rt2tr
3133
from spatialmath.base.vectors import unitvec
3234

35+
if TYPE_CHECKING:
36+
from matplotlib.axes import Axes
37+
3338
_eps = np.finfo(np.float64).eps
3439

3540
try: # pragma: no cover
@@ -1227,10 +1232,6 @@ def _FindCorrespondences(
12271232

12281233

12291234
if _matplotlib_exists:
1230-
import matplotlib.pyplot as plt
1231-
1232-
# from mpl_toolkits.axisartist import Axes
1233-
from matplotlib.axes import Axes
12341235

12351236
def trplot2(
12361237
T: Union[SO2Array, SE2Array],
@@ -1485,6 +1486,8 @@ def trplot2(
14851486

14861487
if block is not None:
14871488
# calling this at all, causes FuncAnimation to fail so when invoked from tranimate2 skip this bit
1489+
import matplotlib.pyplot as plt
1490+
14881491
plt.show(block=block)
14891492
return ax
14901493

spatialmath/base/transforms3d.py

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
# Part of Spatial Math Toolbox for Python
24
# Copyright (c) 2000 Peter Corke
35
# MIT Licence, see details in top-level file: LICENCE
@@ -15,8 +17,10 @@
1517
# pylint: disable=invalid-name
1618

1719
import sys
20+
import importlib.util
1821
from collections.abc import Iterable
1922
import math
23+
from typing import TYPE_CHECKING
2024
import numpy as np
2125

2226
from spatialmath.base.argcheck import getunit, getvector, isvector, isscalar, ismatrix
@@ -44,12 +48,15 @@
4448
Ab2M,
4549
)
4650
from spatialmath.base.quaternions import r2q, q2r, qeye, qslerp, qunit
47-
from spatialmath.base.graphics import plotvol3, axes_logic
48-
from spatialmath.base.animate import Animate
4951
import spatialmath.base.symbolic as sym
5052

5153
from spatialmath.base.types import *
5254

55+
if TYPE_CHECKING:
56+
# for static type checkers only, both are only ever really imported
57+
# lazily, inside trplot()/tranimate(), when a plot is actually made
58+
from mpl_toolkits.mplot3d import Axes3D
59+
5360
_eps = np.finfo(np.float64).eps
5461

5562
# ---------------------------------------------------------------------------------------#
@@ -2910,13 +2917,10 @@ def _vec2s(fmt, v):
29102917
return ", ".join([fmt.format(x) for x in v])
29112918

29122919

2913-
try:
2914-
import matplotlib.pyplot as plt
2915-
from mpl_toolkits.mplot3d import Axes3D
2916-
2917-
_matplotlib_exists = True
2918-
except ImportError:
2919-
_matplotlib_exists = False
2920+
# cheap existence check, doesn't actually import matplotlib: the real
2921+
# import happens lazily inside trplot()/tranimate() when a plot is
2922+
# actually made
2923+
_matplotlib_exists = importlib.util.find_spec("matplotlib") is not None
29202924

29212925
if _matplotlib_exists:
29222926

@@ -3107,6 +3111,8 @@ def trplot(
31073111
# animation
31083112
# anaglyph
31093113

3114+
from spatialmath.base.graphics import plotvol3, axes_logic
3115+
31103116
if dims is None:
31113117
ax = axes_logic(ax, 3, projection)
31123118
else:
@@ -3419,6 +3425,8 @@ def tranimate(T: Union[SO3Array, SE3Array], **kwargs) -> str:
34193425
34203426
:seealso: `trplot`, `plotvol3`
34213427
"""
3428+
from spatialmath.base.animate import Animate
3429+
34223430
dim = kwargs.pop("dims", None)
34233431
ax = kwargs.pop("ax", None)
34243432
anim = Animate(dim=dim, ax=ax, **kwargs)

spatialmath/geom2d.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,15 +9,14 @@
99

1010
from functools import reduce
1111
import warnings
12-
import matplotlib.pyplot as plt
12+
from typing import TYPE_CHECKING
1313
from matplotlib.path import Path
1414
from matplotlib.patches import PathPatch
1515
from matplotlib.transforms import Affine2D
1616
import numpy as np
1717

1818
from spatialmath import SE2
1919
import spatialmath.base as smb
20-
from spatialmath.base import plot_ellipse
2120
from spatialmath.base.types import (
2221
Points2,
2322
Optional,
@@ -37,6 +36,9 @@
3736
cast,
3837
)
3938

39+
if TYPE_CHECKING:
40+
import matplotlib.pyplot as plt
41+
4042
_eps = np.finfo(np.float64).eps
4143

4244

@@ -450,6 +452,8 @@ def plot(self, ax: Optional[plt.Axes] = None, **kwargs) -> None:
450452
451453
:seealso: :meth:`animate` :func:`matplotlib.PathPatch`
452454
"""
455+
import matplotlib.pyplot as plt
456+
453457
self.patch = PathPatch(self.path, **kwargs)
454458
ax = smb.axes_logic(ax, 2)
455459
ax.add_patch(self.patch)
@@ -1063,6 +1067,8 @@ def plot(self, **kwargs) -> None:
10631067
10641068
:seealso: :func:`~spatialmath.base.graphics.plot_ellipse`
10651069
"""
1070+
from spatialmath.base import plot_ellipse
1071+
10661072
return plot_ellipse(self._E, centre=self._centre, **kwargs)
10671073

10681074
def contains(self, p):

spatialmath/geom3d.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,15 @@
66
import numpy as np
77
import math
88
from collections import namedtuple
9-
import matplotlib.pyplot as plt
9+
from typing import TYPE_CHECKING
1010
import spatialmath.base as base
1111
from spatialmath.base.types import *
1212
from spatialmath.baseposelist import BasePoseList
1313
import warnings
1414

15+
if TYPE_CHECKING:
16+
import matplotlib.pyplot as plt
17+
1518
_eps = np.finfo(np.float64).eps
1619

1720
# ======================================================================== #
@@ -1232,6 +1235,8 @@ def plot(
12321235
12331236
:seealso: :meth:`intersect_volume`
12341237
"""
1238+
import matplotlib.pyplot as plt
1239+
12351240
if ax is None:
12361241
ax = plt.gca()
12371242

spatialmath/spline.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
from __future__ import annotations
2+
13
# Copyright (c) 2024 Robotics and AI Institute LLC dba RAI Institute.
24
# MIT Licence, see details in top-level file: LICENCE
35

@@ -6,16 +8,18 @@
68
"""
79

810
from abc import ABC, abstractmethod
9-
from typing import List, Optional, Tuple
11+
from typing import TYPE_CHECKING, List, Optional, Tuple
1012

11-
import matplotlib.pyplot as plt
1213
import numpy as np
1314
from scipy.interpolate import BSpline, CubicSpline
1415
from scipy.spatial.transform import Rotation, RotationSpline
1516

1617
from spatialmath import SE3, SO3, Twist3
1718
from spatialmath.base.transforms3d import tranimate
1819

20+
if TYPE_CHECKING:
21+
import matplotlib.pyplot as plt
22+
1923

2024
class SplineSE3(ABC):
2125
def __init__(self) -> None:
@@ -39,6 +43,8 @@ def visualize(
3943
Args:
4044
sample_times: which times to sample the spline at and plot
4145
"""
46+
import matplotlib.pyplot as plt
47+
4248
if ax is None:
4349
fig = plt.figure(figsize=(10, 10))
4450
ax = fig.add_subplot(projection="3d")

tests/test_geom3d.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@
1414
import spatialmath.base as base
1515
import pytest
1616
import sys
17+
import matplotlib.pyplot as plt
1718

1819

1920
class Line3Test(unittest.TestCase):

0 commit comments

Comments
 (0)