Skip to content

Commit f6a572c

Browse files
authored
perf: defer Matplotlib import until something actually plots (#198)
## Problem `import spatialmath` unconditionally pulls in all of Matplotlib, even for users who never plot anything (RAI wishlist item: "Don't depend on matplotlib unless you're actually plotting"). `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 matplotlib turned out to be missing. Measured on my 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's the cost this PR removes from `import spatialmath`. (Caveat: on this machine matplotlib isn't the single biggest contributor to overall import time — `scipy.interpolate` and `sympy`, when installed, are comparable — so this alone may not fully explain reports of ~9s import time on other machines; worth keeping an eye on.) ## Approach Deliberately **not** touching matplotlib usage inside `graphics.py`/ `animate.py` themselves — dozens of `plt.Axes`-style annotations live in there, and rewriting those would be a much bigger, riskier change than this needs to be. Instead, defer the **module import** of `graphics`/`animate` as a whole, plus a few other files with the same eager-import pattern: - **`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.), not 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. - **`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 — `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 (module-import time, before the deferred import ever runs) — annotations become lazy strings instead, which type checkers still read fine. Matches the existing convention already used in 8 other files in this codebase. One real (minor) bug this surfaced: `tests/test_geom3d.py` used `plt.figure()` without importing `matplotlib.pyplot` itself — it only worked because `from spatialmath.geom3d import *` used to leak `plt` in as a wildcard-imported name. Fixed by importing it directly. ## Testing - `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 whose local-run behaviour depends on a separate, already-open PR, unrelated to this change). - Manual smoke test with a forced Agg backend: `trplot`, `trplot2`, `Polygon2.plot`/`.animate`/`.contains`, `Ellipse.plot`, `Line3.plot`, `Plane3.plot`, `BSplineSE3.visualize` all still produce real output. - `black --check` clean at the pinned 23.10.0.
1 parent 6f9e892 commit f6a572c

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
@@ -17,21 +19,24 @@
1719
import sys
1820
import math
1921
import warnings
22+
import importlib.util
23+
from typing import TYPE_CHECKING
2024
import numpy as np
2125
import warnings
2226

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

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

37+
if TYPE_CHECKING:
38+
from matplotlib.axes import Axes
39+
3540
_eps = np.finfo(np.float64).eps
3641

3742
try: # pragma: no cover
@@ -1311,10 +1316,6 @@ def _FindCorrespondences(
13111316

13121317

13131318
if _matplotlib_exists:
1314-
import matplotlib.pyplot as plt
1315-
1316-
# from mpl_toolkits.axisartist import Axes
1317-
from matplotlib.axes import Axes
13181319

13191320
def trplot2(
13201321
T: Union[SO2Array, SE2Array],
@@ -1569,6 +1570,8 @@ def trplot2(
15691570

15701571
if block is not None:
15711572
# calling this at all, causes FuncAnimation to fail so when invoked from tranimate2 skip this bit
1573+
import matplotlib.pyplot as plt
1574+
15721575
plt.show(block=block)
15731576
return ax
15741577

‎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
import warnings
2226

@@ -46,12 +50,15 @@
4650
Ab2M,
4751
)
4852
from spatialmath.base.quaternions import r2q, q2r, qeye, qslerp, qunit
49-
from spatialmath.base.graphics import plotvol3, axes_logic
50-
from spatialmath.base.animate import Animate
5153
import spatialmath.base.symbolic as sym
5254

5355
from spatialmath.base.types import *
5456

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

5764
# ---------------------------------------------------------------------------------------#
@@ -2994,13 +3001,10 @@ def trprint(
29943001
return s
29953002

29963003

2997-
try:
2998-
import matplotlib.pyplot as plt
2999-
from mpl_toolkits.mplot3d import Axes3D
3000-
3001-
_matplotlib_exists = True
3002-
except ImportError:
3003-
_matplotlib_exists = False
3004+
# cheap existence check, doesn't actually import matplotlib: the real
3005+
# import happens lazily inside trplot()/tranimate() when a plot is
3006+
# actually made
3007+
_matplotlib_exists = importlib.util.find_spec("matplotlib") is not None
30043008

30053009
if _matplotlib_exists:
30063010

@@ -3191,6 +3195,8 @@ def trplot(
31913195
# animation
31923196
# anaglyph
31933197

3198+
from spatialmath.base.graphics import plotvol3, axes_logic
3199+
31943200
if dims is None:
31953201
ax = axes_logic(ax, 3, projection)
31963202
else:
@@ -3504,6 +3510,8 @@ def tranimate(T: Union[SO3Array, SE3Array], **kwargs) -> str:
35043510
35053511
:seealso: `trplot`, `plotvol3`
35063512
"""
3513+
from spatialmath.base.animate import Animate
3514+
35073515
# accept dim (matches Animate/plotvol2/plotvol3), keep dims as an
35083516
# alias for backward compatibility with the previous docstring/API
35093517
dim = kwargs.pop("dim", kwargs.pop("dims", None))

‎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
# ======================================================================== #
@@ -1233,6 +1236,8 @@ def plot(
12331236
12341237
:seealso: :meth:`intersect_volume`
12351238
"""
1239+
import matplotlib.pyplot as plt
1240+
12361241
if ax is None:
12371242
ax = plt.gca()
12381243

‎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)