From 1b752e1024caad07814e42bfbd531e929955981e Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Wed, 21 Jan 2026 09:20:40 +1000 Subject: [PATCH 1/6] Fix(animation): Resolve noise and crashes in FuncAnimation This commit addresses two issues related to : 1. Noise and streaking artifacts in animations due to unconditional calls on every draw. 2. A caused by the keyword argument being passed to Matplotlib internals. The following changes have been made: - A flag has been introduced in the class to control when is called. - is now only called when the figure's layout is dirty, preventing unnecessary recalculations during animations. - The method in now only resizes the figure if the new size is different, preventing small oscillations. - The keyword argument is now popped in to prevent it from being passed to Matplotlib. --- ultraplot/figure.py | 20 +++++++++++++++++--- ultraplot/gridspec.py | 1 + 2 files changed, 18 insertions(+), 3 deletions(-) diff --git a/ultraplot/figure.py b/ultraplot/figure.py index b2612d6a3..5b11a3c9b 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -483,7 +483,9 @@ def _canvas_preprocess(self, *args, **kwargs): ctx2 = fig._context_authorized() # skip backend set_constrained_layout() ctx3 = rc.context(fig._render_context) # draw with figure-specific setting with ctx1, ctx2, ctx3: - fig.auto_layout() + if fig._layout_dirty: + fig.auto_layout() + fig._layout_dirty = False return func(self, *args, **kwargs) # Add preprocessor @@ -799,6 +801,9 @@ def __init__( self._is_authorized = False self._includepanels = None self._render_context = {} + self._layout_dirty = True + self._ultra_layout_scheduled = False + self._ultra_layout_in_progress = False rc_kw, rc_mode = _pop_rc(kwargs) kw_format = _pop_params(kwargs, self._format_signature) if figwidth is not None and figheight is not None: @@ -1497,6 +1502,7 @@ def _add_axes_panel( pax.yaxis.set_tick_params(**{pax._label_key("labelright"): on}) ax.yaxis.set_tick_params(**{ax._label_key("labelright"): False}) + self._layout_dirty = True return pax @_clear_border_cache @@ -1539,6 +1545,7 @@ def _add_figure_panel( pax._panel_side = side pax._panel_share = False pax._panel_parent = None + self._layout_dirty = True return pax @_clear_border_cache @@ -2308,6 +2315,7 @@ def add_axes(self, rect, **kwargs): %(figure.axes)s """ kwargs = self._parse_proj(**kwargs) + self._layout_dirty = True return super().add_axes(rect, **kwargs) @docstring._concatenate_inherited @@ -2316,7 +2324,9 @@ def add_subplot(self, *args, **kwargs): """ %(figure.subplot)s """ - return self._add_subplot(*args, **kwargs) + ax = self._add_subplot(*args, **kwargs) + self._layout_dirty = True + return ax @docstring._snippet_manager def subplot(self, *args, **kwargs): # shorthand @@ -2330,7 +2340,9 @@ def add_subplots(self, *args, **kwargs): """ %(figure.subplots)s """ - return self._add_subplots(*args, **kwargs) + axs = self._add_subplots(*args, **kwargs) + self._layout_dirty = True + return axs @docstring._snippet_manager def subplots(self, *args, **kwargs): @@ -2780,6 +2792,7 @@ def colorbar( pad=pad, ) cb = ax.colorbar(mappable, values, loc="fill", **kwargs) + self._layout_dirty = True return cb @docstring._concatenate_inherited @@ -2995,6 +3008,7 @@ def legend( pad=pad, ) leg = ax.legend(handles, labels, loc="fill", **kwargs) + self._layout_dirty = True return leg @docstring._snippet_manager diff --git a/ultraplot/gridspec.py b/ultraplot/gridspec.py index 6f4c2d229..3aa462612 100644 --- a/ultraplot/gridspec.py +++ b/ultraplot/gridspec.py @@ -1129,6 +1129,7 @@ def _update_params( wratios=None, width_ratios=None, height_ratios=None, + layout_array=None, ): """ Update the user-specified properties. From ecc714abf55e93c0fdae471e89457751e133b9b8 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 22 Jan 2026 19:27:16 +1000 Subject: [PATCH 2/6] Add layout hook --- ultraplot/__init__.py | 74 +++++++++++++++++++++++++++ ultraplot/axes/plot_types/__init__.py | 3 ++ ultraplot/figure.py | 40 ++++++++++++--- 3 files changed, 110 insertions(+), 7 deletions(-) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 9f382f187..2818cab3e 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -5,6 +5,7 @@ from __future__ import annotations import sys +from functools import wraps from pathlib import Path from typing import Optional @@ -83,6 +84,7 @@ def _setup(): from .utils import check_for_update check_for_update("ultraplot") + _patch_funcanimation_draw_idle() success = True finally: if success: @@ -103,6 +105,78 @@ def setup(eager: Optional[bool] = None) -> None: _LOADER.load_all(globals()) +def _patch_funcanimation_draw_idle(): + try: + import matplotlib.animation as mpl_animation + except Exception: + return + + if getattr(mpl_animation.FuncAnimation, "_ultra_draw_idle_patched", False): + return + + orig_init = mpl_animation.FuncAnimation.__init__ + orig_stop = getattr(mpl_animation.FuncAnimation, "_stop", None) + + def _install_draw_idle(self, fig): + if fig is None or not hasattr(fig, "_layout_dirty"): + return + canvas = getattr(fig, "canvas", None) + if canvas is None or not hasattr(canvas, "draw_idle"): + return + + count = getattr(canvas, "_ultra_draw_idle_count", 0) + if count == 0: + canvas._ultra_draw_idle_orig = canvas.draw_idle + + def draw_idle(*args, **kwargs): + return canvas.draw(*args, **kwargs) + + canvas.draw_idle = draw_idle + canvas._ultra_draw_idle_count = count + 1 + + import weakref + + canvas_ref = weakref.ref(canvas) + + def restore(): + canvas = canvas_ref() + if canvas is None: + return + count = getattr(canvas, "_ultra_draw_idle_count", 0) + if count <= 1: + orig = getattr(canvas, "_ultra_draw_idle_orig", None) + if orig is not None: + canvas.draw_idle = orig + delattr(canvas, "_ultra_draw_idle_orig") + canvas._ultra_draw_idle_count = 0 + else: + canvas._ultra_draw_idle_count = count - 1 + + self._ultra_restore_draw_idle = restore + self._ultra_draw_idle_finalizer = weakref.finalize(self, restore) + + @wraps(orig_init) + def __init__(self, fig, *args, **kwargs): + orig_init(self, fig, *args, **kwargs) + _install_draw_idle(self, fig) + + mpl_animation.FuncAnimation.__init__ = __init__ + + if orig_stop is not None: + + @wraps(orig_stop) + def _stop(self, *args, **kwargs): + restore = getattr(self, "_ultra_restore_draw_idle", None) + if restore is not None: + restore() + self._ultra_restore_draw_idle = None + return orig_stop(self, *args, **kwargs) + + mpl_animation.FuncAnimation._stop = _stop + + mpl_animation.FuncAnimation._ultra_draw_idle_patched = True + + def _build_registry_map(): global _REGISTRY_ATTRS if _REGISTRY_ATTRS is not None: diff --git a/ultraplot/axes/plot_types/__init__.py b/ultraplot/axes/plot_types/__init__.py index e69de29bb..206038347 100644 --- a/ultraplot/axes/plot_types/__init__.py +++ b/ultraplot/axes/plot_types/__init__.py @@ -0,0 +1,3 @@ +from . import curved_quiver, sankey + +__all__ = ["curved_quiver", "sankey"] diff --git a/ultraplot/figure.py b/ultraplot/figure.py index 5b11a3c9b..6ea87d3dc 100644 --- a/ultraplot/figure.py +++ b/ultraplot/figure.py @@ -485,7 +485,6 @@ def _canvas_preprocess(self, *args, **kwargs): with ctx1, ctx2, ctx3: if fig._layout_dirty: fig.auto_layout() - fig._layout_dirty = False return func(self, *args, **kwargs) # Add preprocessor @@ -2375,14 +2374,13 @@ def auto_layout(self, renderer=None, aspect=None, tight=None, resize=None): to `~Figure.subplots`, `~Figure.set_size_inches` was called manually, or the figure was resized manually with an interactive backend. """ + # *Impossible* to get notebook backend to work with auto resizing so we # just do the tight layout adjustments and skip resizing. gs = self.gridspec renderer = self._get_renderer() - if aspect is None: - aspect = True - if tight is None: - tight = self._tight_active + layout_aspect = True if aspect is None else aspect + layout_tight = self._tight_active if tight is None else tight if resize is False: # fix the size self._figwidth, self._figheight = self.get_size_inches() self._refwidth = self._refheight = None # critical! @@ -2402,19 +2400,47 @@ def _align_content(): # noqa: E306 self._align_super_labels(side, renderer) self._align_super_title(renderer) + before = self._layout_signature() + # Update the layout # WARNING: Tried to avoid two figure resizes but made # subsequent tight layout really weird. Have to resize twice. _draw_content() if not gs: + self._layout_dirty = False return - if aspect: + if layout_aspect: gs._auto_layout_aspect() _align_content() - if tight: + if layout_tight: gs._auto_layout_tight(renderer) _align_content() + after = self._layout_signature() + self._layout_dirty = before != after + if self._layout_dirty and getattr(self, "canvas", None): + # Only schedule when an interactive manager exists to avoid recursion. + if getattr(self.canvas, "manager", None) is not None: + if not getattr(self.canvas, "_is_idle_drawing", False): + self.canvas.draw_idle() + + def _layout_signature(self): + """ + Snapshot layout-defining state to detect convergence across draws. + """ + gs = self.gridspec + if not gs: + return None + return ( + tuple(self.get_size_inches()), + gs._left_default, + gs._right_default, + gs._bottom_default, + gs._top_default, + tuple(gs._hspace_total_default), + tuple(gs._wspace_total_default), + ) + @warnings._rename_kwargs( "0.10.0", mathtext_fallback="uplt.rc.mathtext_fallback = {}" ) From 9687f91613831d7272c10caba9bd6b3a26227b8d Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 22 Jan 2026 19:56:56 +1000 Subject: [PATCH 3/6] Add animation draw_idle toggle --- ultraplot/__init__.py | 9 +++++++++ ultraplot/internals/rcsetup.py | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 2818cab3e..2de707043 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -111,6 +111,13 @@ def _patch_funcanimation_draw_idle(): except Exception: return + try: + from .config import rc + except Exception: + return + if not rc.get("animation.force_draw_idle", True): + return + if getattr(mpl_animation.FuncAnimation, "_ultra_draw_idle_patched", False): return @@ -123,6 +130,8 @@ def _install_draw_idle(self, fig): canvas = getattr(fig, "canvas", None) if canvas is None or not hasattr(canvas, "draw_idle"): return + if getattr(canvas, "manager", None) is None: + return count = getattr(canvas, "_ultra_draw_idle_count", 0) if count == 0: diff --git a/ultraplot/internals/rcsetup.py b/ultraplot/internals/rcsetup.py index dc8c68463..60b967ce9 100644 --- a/ultraplot/internals/rcsetup.py +++ b/ultraplot/internals/rcsetup.py @@ -941,6 +941,12 @@ def copy(self): "name. If ``None``, a custom ultraplot style is used. " "If ``'default'``, the default matplotlib style is used.", ), + "animation.force_draw_idle": ( + True, + _validate_bool, + "Whether to force `draw_idle` to call `draw` during animations for " + "ultraplot figures to avoid backend redraw artifacts.", + ), # A-b-c labels "abc": ( False, From 486cc5dec84f9f10151898c2f19bfbbce74673bf Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 22 Jan 2026 20:00:22 +1000 Subject: [PATCH 4/6] Mark replacement --- ultraplot/__init__.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/ultraplot/__init__.py b/ultraplot/__init__.py index 2de707043..a266d8be5 100644 --- a/ultraplot/__init__.py +++ b/ultraplot/__init__.py @@ -137,6 +137,8 @@ def _install_draw_idle(self, fig): if count == 0: canvas._ultra_draw_idle_orig = canvas.draw_idle + # TODO: Replace this monkeypatch with a backend-level fix once + # draw_idle artifacts are resolved upstream. def draw_idle(*args, **kwargs): return canvas.draw(*args, **kwargs) From c3c82b5538816a25af2c5e8c50cb07da9ef74db2 Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 22 Jan 2026 20:39:32 +1000 Subject: [PATCH 5/6] Cleanup dirty branch --- ultraplot/axes/plot_types/__init__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/ultraplot/axes/plot_types/__init__.py b/ultraplot/axes/plot_types/__init__.py index 206038347..2a6a9dc3f 100644 --- a/ultraplot/axes/plot_types/__init__.py +++ b/ultraplot/axes/plot_types/__init__.py @@ -1,3 +1,3 @@ -from . import curved_quiver, sankey +from . import curved_quiver -__all__ = ["curved_quiver", "sankey"] +__all__ = ["curved_quiver"] From 51020d21429993e655977943f43c38a99a211cba Mon Sep 17 00:00:00 2001 From: cvanelteren Date: Thu, 22 Jan 2026 20:40:20 +1000 Subject: [PATCH 6/6] Cleanup dirty branch --- ultraplot/axes/plot_types/__init__.py | 3 --- 1 file changed, 3 deletions(-) diff --git a/ultraplot/axes/plot_types/__init__.py b/ultraplot/axes/plot_types/__init__.py index 2a6a9dc3f..e69de29bb 100644 --- a/ultraplot/axes/plot_types/__init__.py +++ b/ultraplot/axes/plot_types/__init__.py @@ -1,3 +0,0 @@ -from . import curved_quiver - -__all__ = ["curved_quiver"]