Skip to content

Public API for the HyperSpy backend: data coordinates, identity, lifecycle - #84

Merged
CSSFrancis merged 24 commits into
mainfrom
feat/hyperspy-public-api
Oct 22, 2026
Merged

CSSFrancis merged 24 commits into
mainfrom
feat/hyperspy-public-api

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

Companion to hyperspy/hyperspy#3623. HyperSpy's anyplotlib backend was reaching into private state (_state, _plot, _hspy_* attributes it patched onto figures and axes) and doing its own data↔pixel maths. This branch adds the public API the backend needs, so the HyperSpy side can drop those workarounds.

Why

Review of #3623 asked for the backend contract to be backend-neutral, not "matplotlib plus patches". The anyplotlib backend was the worst offender: it kept a shadow copy of each image's calibration, converted every widget and marker position to image pixels by hand, wrapped figures to intercept close, and replayed axis labels set before the first plot. Every one of those had a bug in calibrated or flipped coordinates.

What

Coordinates

  • Plot2D.transform (ImageTransform) with data_to_pixel / pixel_to_data, plus public x_axis / y_axis.
  • imshow(extent=) and Plot2D.set_extent_edges take matplotlib-style edge extents.
  • Widgets and markers accept coords="data" (default stays "pixel"). Widget.get(key, coords=) reads either frame. Data-coordinate rectangles are converted as spans, so they stay correct on flipped axes.
  • Length-1 marker sequences broadcast like scalars, and empty data-coordinate marker groups are allowed.

Identity and introspection

  • Widget.kind, Widget.plot.
  • MarkerGroup.marker_type, name and get().
  • Axes.figure, primary_plot, plots, image_plot, has_plot.
  • Figure.get_axes() now lists empty axes too. This is an API change: callers that assumed every axis has a plot need to check has_plot.

Events and updates

  • Bound methods work as event handlers and are held weakly.
  • Widget.set(..., notify=False) updates a widget without firing its own callbacks (used to sync from Python without feedback loops).

Figure lifecycle

  • Figure.on_close, closed, displayed, display(). Figure and panel close handlers run independently, so one failing handler doesn't stop the rest.
  • close() now also closes the underlying ipywidget so a closed figure can be garbage-collected. Behaviour change: the notebook view disappears on close, like plt.close(). Without this, every closed figure leaked.

Smaller fixes

  • Axes-level labels set before a plot is attached are kept, and survive plot replacement.
  • A widget's repr reports geometry in its own frame.

Docs: the close/lifecycle behaviour, bound-method handlers, notify=False, data-coordinate widgets and markers in Getting Started, and a note that data-coordinate widgets keep their pixel position on re-calibration while markers follow the new calibration.

Tests

The full suite passes, including the Playwright tests. Each commit was checked on its own: the plot2d edge-extent commit was moved after the MarkerGroup accessors it tests.

On the HyperSpy side, hyperspy/drawing/backends/anyplotlib on #3623 uses only this public API. It now requires anyplotlib>=0.11.0, so this needs to be in the next release.

Assisted-by: claude-code:claude-opus-5-5

add_event_handler stamped _event_types onto every handler, which a bound
method refuses, so registering obj.method raised AttributeError.  The
stamp is now best-effort, and handlers are keyed by (instance, function)
for bound methods so remove_handler(obj.method) finds the registration
even though each attribute access builds a new method object.

Assisted-by: claude-code:claude-opus-5-5
widget.set(..., notify=False) is the documented way to move a widget
from Python without echoing through its pointer_move handlers.  The
private _notify flag stays as an alias so existing callers keep working.

Assisted-by: claude-code:claude-opus-5-5
Per-marker kwargs (radius, widths, heights, angles, U, V, sizes) now
broadcast a length-1 sequence across the group instead of rejecting it
when the group has more than one marker, and group-wide kwargs
(linewidths, alpha, fontsize, outline_width, single colours) unwrap one.
This matches matplotlib, which stores singleton styles as 1-tuples.

Assisted-by: claude-code:claude-opus-5-5
Widgets and transform='data' markers on a Plot2D are placed in image
pixels, but users work in the calibrated axis units.  Plot2D.transform
returns a live ImageTransform that converts points, (x, y) pairs and
lengths both ways, mirroring the renderer's _axisValToImg2d mapping:
pixel-centre axes for imshow, cell edges for pcolormesh, linear
continuation past either end, and support for offset, non-unit scale,
descending (origin='lower') and non-uniform axes.

Plot2D also gains data_to_pixel / pixel_to_data shorthands and read-only
x_axis / y_axis properties.

Assisted-by: claude-code:claude-opus-5-5
A widget handle now exposes its owning plot and its kind, so callers
(drag callbacks, removers, backends) no longer tag widgets with their
own attributes or read _plot / _type.  Widget.plot drops back to None
when the widget is removed, and read-only properties can no longer be
shadowed by an attribute assignment routed into the wire state.

Assisted-by: claude-code:claude-opus-5-5
2-D widgets are placed in image pixels, so callers working in calibrated
units had to convert every position, length and drag readout themselves.
Every Plot2D add_*_widget (and add_widget) now takes coords="data":
the widget converts its geometry through Plot2D.transform on every read
and write, so attribute access, get(), to_dict(), set() and therefore
drag callbacks all see calibrated values.  The wire state stays in
pixels (serialised via the new private _wire_dict), so the renderer and
JS drags are untouched.

Each widget class declares the role of its geometric fields (position,
length, signed vector, point list, strokes) in _GEOMETRY.  get() and
to_dict() take a coords= override, and set_coords() switches frames
without moving the widget.  1-D and bar adders accept coords="data"
(their only frame) so one call works on any panel.

Assisted-by: claude-code:claude-opus-5-5
transform='data' markers on a 2-D panel are positioned in image pixels.
A group created with coords="data" (via the Plot2D add_* methods or
markers.add) keeps its offsets, segments, vertices, widths/heights,
radius and U/V in calibrated units and converts them through
Plot2D.transform when serialised.  Because conversion happens at render
time the markers stay on their data position across set_extent.
size_units='px' lengths and axes/display transforms are left alone.

Assisted-by: claude-code:claude-opus-5-5
Axes now exposes its owning figure and attached plot through read-only
properties, so callers stop tagging axes with their own attributes or
reading _plot.  image_plot returns the primary plot only when it is a
2-D image (Plot2D/PlotMesh).  Overlays (extra lines, markers, widgets)
live on the plot and can never shadow it.  Assigning the same figure to
ax.figure is tolerated for matplotlib-style code.

Assisted-by: claude-code:claude-opus-5-5
Axes.set_xlabel / set_ylabel / set_title delegate to the attached plot,
and before one exists they store the label and apply it (once) when
imshow / plot / ... attaches the first plot, on grid axes and insets
alike.  Callers no longer need to buffer labels themselves.

Assisted-by: claude-code:claude-opus-5-5
Figure.on_close(callback) registers a figure-level 'close' handler (a
connection id is returned for remove_handler), and close() now fires
'close' on the figure registry before the panels', each with its own
Event, and still hides the widget if a handler raises.  Figure.closed
and Figure.displayed expose the lifecycle state, and display() shows the
figure at most once (force=True to repeat); being shown through the
rich repr also counts as displayed.

The lifecycle tests also cover the deferred Axes label setters.

Assisted-by: claude-code:claude-opus-5-5
Lets code holding a marker group read its type, registry name and
properties without reaching into _type / _name / _data.

Assisted-by: claude-code:claude-opus-5-5
Axes.imshow(extent=(left, right, bottom, top)) and
Plot2D.set_extent_edges() derive the per-pixel-centre axes from the
image's outer edges, so callers coming from matplotlib no longer compute
pixel centres themselves.  top is the y at the top of the displayed
image for either origin.

The tests also cover the MarkerGroup accessors added in the next commit.

Assisted-by: claude-code:claude-opus-5-5
Figure.get_axes only returned axes registered by _register_panel, i.e.
ones with a plot, so apl.subplots(1, 1) gave fig.get_axes() == [].
Track axes created by add_subplot and include the still-empty ones;
plotted axes are listed exactly as before.

Assisted-by: claude-code:claude-opus-5-5
_points_to_pixels turns an empty (0, 2) array into [] via tolist(), and
_offsets_2d rejected [] as not (N, 2), so an empty coords="data" group
could neither be created nor updated.  Treat any empty offsets as zero
markers.

Assisted-by: claude-code:claude-opus-5-5
A coords="data" rectangle converted its corner (x, y) and size (w, h)
field by field, so with origin="lower" or a descending x axis the
pixel rectangle was anchored at the wrong corner (off-image in the
descending case).  Define the data frame as the span
[x, x + w] x [y, y + h] with (x, y) the corner of smallest data value,
convert both ends of each span and normalise to (min, length) in
pixels, and invert the same way for reads, get/to_dict and drags.
Partial sets fill the other half from the current geometry, and
omitted constructor arguments default to the same pixel rectangle as
in pixel coordinates.  Marker rectangles/squares are centred and use
unsigned lengths, so they were already right.

Assisted-by: claude-code:claude-opus-5-5
The try/finally in Figure.close wrapped both the figure-level and the
per-panel close dispatch, so a raising figure handler skipped every
panel's close handlers (and a raising panel skipped later panels).
Fire each registry on its own, still mark the figure closed and
hidden, then re-raise the first exception.

Assisted-by: claude-code:claude-opus-5-5
Widget.__repr__ printed the raw _data, i.e. image pixels, for a
coords="data" widget whose attributes read in data units.  Build it
from to_dict() and tag data-frame widgets on 2-D panels.

Assisted-by: claude-code:claude-opus-5-5
Labels set through Axes.set_xlabel/set_ylabel/set_title were applied
once to the first attached plot and then forgotten, so
ax.set_xlabel("A"); ax.imshow(a); ax.imshow(b) lost the label.  Keep
them on the axes and re-apply them in _attach (grid and inset).
Labels set on the plot object itself stay with that plot.  Adds tests
for grid axes and insets before attach and for replacement.

Assisted-by: claude-code:claude-opus-5-5
coords="data" widgets store pixels and stay put on screen after
set_extent, while coords="data" marker groups keep their data
position.  Say so in Widget.coords and the getting started page.
Covered by test_recalibration_keeps_pixels.

Assisted-by: claude-code:claude-opus-5-5
Figure.close overrides ipywidgets' Widget.close but never called it,
so the comm stayed open and ipywidgets' global instance registry kept
every figure (and its plots and data) alive forever: a closed figure
was never collectable.  Call the base close after the handlers and
the hide.  Python-side state stays usable (set_data, to_html).

Assisted-by: claude-code:claude-opus-5-5
Assisted-by: claude-code:claude-opus-5-5
@codecov-commenter

codecov-commenter commented Oct 22, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 97.50446% with 14 lines in your changes missing coverage. Please review.
✅ Project coverage is 92.07%. Comparing base (632eb4c) to head (d8309ab).
⚠️ Report is 2 commits behind head on main.

Files with missing lines Patch % Lines
anyplotlib/markers.py 92.85% 6 Missing ⚠️
anyplotlib/plot2d/_transform.py 97.61% 3 Missing ⚠️
anyplotlib/widgets/_base.py 97.54% 3 Missing ⚠️
anyplotlib/figure/_figure.py 95.83% 2 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main      #84      +/-   ##
==========================================
+ Coverage   91.24%   92.07%   +0.83%     
==========================================
  Files          41       42       +1     
  Lines        4991     5489     +498     
==========================================
+ Hits         4554     5054     +500     
+ Misses        437      435       -2     

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

The coords example was inserted mid-paragraph, so the sentence after it
ended the literal block without a blank line (a -W failure).  Keep the
show_handles caveat with show_handles and put the example last.

Assisted-by: claude-code:claude-opus-5-5
@CSSFrancis
CSSFrancis merged commit 2157acb into main Oct 22, 2026
12 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants