Skip to content

Render the pyplot axes frame on the position it reports [mpl compatibility] - #272

Closed
sselvakumaran wants to merge 2 commits into
agent/fix-pyplot-savefig-scalefrom
agent/fix-pyplot-frame-geometry
Closed

Render the pyplot axes frame on the position it reports [mpl compatibility]#272
sselvakumaran wants to merge 2 commits into
agent/fix-pyplot-savefig-scalefrom
agent/fix-pyplot-frame-geometry

Conversation

@sselvakumaran

@sselvakumaran sselvakumaran commented Jul 24, 2026

Copy link
Copy Markdown
Contributor

Stacked on #206 (agent/fix-pyplot-savefig-scale) and targets that branch — it changes the raster scale on the same pyplot/_grid.py / pyplot/_mplfig.py code path, so this builds on it rather than racing it.

Summary

  • resolve each axes' Matplotlib rectangle once, in Figure._axes_rect(), and use it for both what scripts read and what the exporters place
  • make Axes.get_position() grid-aware: a panel reports its gridspec cell under the live SubplotParams, so an n x m grid returns n * m distinct boxes instead of one repeated 1x1 default
  • pin the rendered plot rect to that rectangle, subtracting only the gutters the renderers reserve outside the padding (title band, top-side x axis, secondary-y band)
  • alpha-composite absolutely placed panels instead of pasting them opaquely, so a panel's chrome can overhang a neighbour the way Matplotlib's single canvas allows
  • consolidate the equal-aspect solve onto the same reservation rule, so matshow in a grid stops solving against a plot box 26 px taller than the one drawn

Deliberately narrow: no autoscale, tick-spacing, colorbar-placement, or legend work (#240, #244, #246, #248/#254 own those).

Root causes

Two, both in the shim.

1. The shim never told the renderers which rectangle it had promised. Axes.get_position() returned figure.subplot.* correctly, but _build_chart passed padding=None, so _svg.layout() (and the browser's ChartView._layout()) fell back to label-aware default margins — 62/14/10/42 px at ordinary export sizes. Those margins have nothing to do with the SubplotParams frame, so a default 640x480 figure drew its frame at x 0.0969..0.9781 (w 0.8812, 13.8 % too wide) with its top edge at y 0.0208 instead of 0.1208. Same cause for get_position() itself: python/xy/pyplot/_axes.py:2595 returned self._figure_rect or (0.125, 0.11, 0.775, 0.77), and a subplots() panel has no _figure_rect, so all 64 panels of an 8x8 grid reported one identical box.

2. Panel tiles were pasted opaquely. A free-form panel is deliberately larger than its gridspec cell — its tick labels and title live outside the plot rect — and stitch_png pasted each tile with canvas[...] = tile, background included. Each column therefore erased the one to its left, and only the rightmost column of a dense grid survived.

Worth stating precisely, because it narrows what reviewers need to check: the grid panels' plot rects were already sub-pixel correct before this PR. I measured them against Axes.get_window_extent() and the 2x2 / 5x5 / 8x8 / ratio / subplots_adjust cases were all within 0.75 px on the base branch. C3 was entirely root cause 2, not a geometry error. The geometry errors were the single-axes cases (and, once panels are pinned, matshow/twinx panels).

Core boundary

No core changes. The diff is four files, all under python/xy/pyplot/ plus tests and spec:

python/xy/pyplot/_axes.py    | 112 +++++++++++++++----
python/xy/pyplot/_grid.py    |  88 ++++++++++++----
python/xy/pyplot/_mplfig.py  |  96 ++++++++++++-----

python/xy/_svg.py, _raster.py, js/src/, and src/*.rs are untouched, so no non-pyplot chart changes behavior. The shim now supplies an explicit padding on a path where it previously supplied none; padding is an existing, already-documented xy.chart() option and the renderers' handling of it is unchanged.

Rendered-frame accuracy

Absolute plot rects from _svg.layout() (the single resolver both static exporters use), against Matplotlib 3.11's Axes.get_window_extent(). Max |delta| in px over (x0, y0, w, h) across all panels:

case panels before after
single 640x480 1 18.0 / 47.6 / 68.0 / 58.4 0 / 0 / 0 / 0
single + title/labels 1 18.0 / 17.6 / 68.0 / 28.4 0 / 0 / 0 / 0
single 320x240 1 4.0 / 30.0 / 36.0 / 27.0 0 / 0 / 0 / 0
add_axes rect 1 0 / 0 / 0 / 0 0 / 0 / 0 / 0
2x2 grid 4 0.45 / 0.40 / 0.45 / 0 0.45 / 0.40 / 0.45 / 0
1x3 + subplots_adjust 3 0.39 / 0 / 0.22 / 0 0.39 / 0 / 0.22 / 0
2x3 + width/height ratios 6 0.25 / 0.33 / 0.43 / 0.33 0.25 / 0.33 / 0.43 / 0.33
5x5, wspace=hspace=0 25 0.75 / 0 / 0 / 0 0.75 / 0 / 0 / 0
8x8 grid 64 0.61 / 0.15 / 0.15 / 0.15 0.61 / 0.15 / 0.15 / 0.15
2x2 grid of matshow 4 0.23 / 26.4 / 0 / 26.0 0.23 / 0.40 / 0 / 0
1x2 grid with twinx 2 0.50 / 0 / 42.05 / 0.50 0.50 / 0 / 0.05 / 0.50
2 rows of imshow strips 2 0 / 0 / 0 / 0 0 / 0 / 0 / 0

get_position() output is now byte-identical to Matplotlib 3.11 for 1x1, 2x2, 8x8 + subplots_adjust, 2x3 + ratios, and 5x5 + zero spacing (diff of both engines' bounds: identical, including Matplotlib's own 0.10999999999999999 bottom edge).

Visual comparison

Columns are Matplotlib 3.11, the branch base (#206), and this PR.

Matplotlib, before, and after frame geometry comparison

PDSH impact

Global SSIM against the Matplotlib 3.11 render of the same cell, plus ink coverage (mpl / before / after):

PDSH cell SSIM before SSIM after ink
04.07 cell 29 — 8x8 imshow of load_digits 0.5711 0.8280 0.308 / 0.083 / 0.280
04.10 cell 16 — 5x5 imshow of fetch_olivetti_faces 0.6756 0.9375 0.594 / 0.301 / 0.592
04.01 — single-axes sine/cosine 0.7370 0.9405 0.041 / 0.038 / 0.036
04.08 — 2x3 sharex/sharey grid 0.8983 0.9121 0.054 / 0.040 / 0.046
04.07 cell 14 — 2-row colormap strip 0.9410 0.9410 0.382 / 0.374 / 0.374

Rendered from the real datasets (sklearn.datasets.load_digits / fetch_olivetti_faces), not substitutes.

Honest reading of that table:

  • The two dense-grid cells go from unreadable to essentially Matplotlib. This is the blocker the audit flagged, and it is gone.
  • Neither is claimed visually complete. 04.07 cell 29 still sits at 0.83, not ~0.99: the panels now all draw and land on their cells, but the residual is independent tick/typography and frame-stroke work owned by other PRs in this stack. The digits case's ink is 0.280 against Matplotlib's 0.308 for the same reason.
  • The 2-row colormap strip (04.07 cell 14) is unchanged at 0.9410. Its panels were already sub-pixel and already composited without overlap, so this PR does nothing for it. I am not claiming it.
  • The 2x3 sharex grid gains only 0.014. Its frames were already correct; what remains differing is tick-label typography, not geometry.

Regression sweep

24 dual-engine figures (line/scatter/bar/barh/hist/fill_between/log/imshow/matshow/twinx/pie/errorbar/step/axis("equal"), four figure sizes, six grid shapes, add_axes + inset), scored by global SSIM against Matplotlib 3.11:

24 cases   mean SSIM 0.7329 -> 0.8462
improved 21, regressed 0, unchanged 3

Largest gains imshow +0.371, matshow +0.347, 320x240 figure +0.205. The only negative movement in the whole sweep is add_axes + inset at -0.0014 — below the sampling noise of this metric, and its measured plot rects are 0 px off Matplotlib both before and after, so I read it as flat rather than a regression. Nothing else moved down.

Known limitation (deliberate)

An axes carrying a colorbar keeps the old label-aware margins: _frame_padding() returns None when self._colorbar is not None. Matplotlib's colorbar() takes its strip out of the parent axes rectangle, while the renderers reserve it outside the padding; pinning the frame without reconciling that would push the colorbar off the canvas. Reconciling it is colorbar-placement work (#244/#246), and spec/matplotlib/compat.md now records the gap explicitly.

Validation

  • 2183 passed, 12 skipped across the full suite (tests/), up from 2168 passed — the 15 new tests in tests/pyplot/test_frame_geometry.py
  • the same 2 pre-existing failures before and after: tests/test_framing.py::test_javascript_{decodes_python_golden_frame_without_payload_copies,rejects_malformed_and_unaligned_frames}, both FileNotFoundError: 'node' — no Node toolchain in this environment, unrelated to this change
  • the new tests fail on the base branch: 14 failed, 1 passed with the three source files reverted, so they pin real behavior rather than snapshotting it
  • two stale expectations in tests/pyplot/test_axes_layout.py updated, both because the new geometry is more Matplotlib-correct: axis("equal") now yields (-0.31967741935483873, 1.3196774193548388), which is Matplotlib 3.11's own answer for that figure to 15 digits (previously ±1.3348, solved against the wrong plot rect); and get_position().bounds now carries Matplotlib's own 0.11 rounding so it needs pytest.approx
  • uv run --no-project --with pre-commit pre-commit run --all-files — ruff check, ruff format, docs-app codespell all pass
  • ruff check . clean; ruff format --check . clean
  • ty check python/xy/pyplot/Found 14 diagnostics, identical count and locations before and after (all in pre-existing _colors.py / _artists.py / _axes.py:1283-1477 code)
  • python3 scripts/abi_smoke.py121 checks passed
  • composed SVG verified structurally for the 8x8 grid: 64 nested panel <svg> elements, 64 <image> marks, 0 full-panel background rects — the SVG path was already transparent and never had the clobbering bug, so it needed no change

Not verified

  • The browser/HTML path. npm and node are not installed in this environment and python/xy/static/ has no bundles, so node js/build.mjs cannot run and the interactive client could not be exercised. No TypeScript was changed, and ChartView._layout() was read to confirm it applies the same title/top-axis/secondary-y/colorbar reservations outside padding that _svg.layout() does — but that is code reading, not a render.

Measurement provenance

This is a Python-only PR — git diff --name-only touches nothing under src/, and git rev-parse HEAD:src is 46eba622… on both this branch and its base. So the native core is a constant across the before/after panels, and both panels deliberately share one dylib (4301be03…) rather than two.

That control is load-bearing here, because the obvious failure mode is not loud: the main checkout's prebuilt libxy_core.dylib (4509266e…) reports ABI 37, and this tree's _native.py also expects 37, so a stray load of the wrong library passes the ABI gate silently instead of raising ImportError. I therefore verified in-process which image was actually mapped — asking dyld (_dyld_image_count / _dyld_get_image_name) for the process's Mach-O images and asserting exactly one libxy_core is mapped, that it resolves inside this worktree, and that its sha256 is 4301be03… — rather than trusting XY_NATIVE_LIB to have won. The check has a working negative control: pointed at the main checkout's dylib it fails on the mapped-path assertion, and notably gets that far without an ImportError, confirming the silent-swap mode was real for this tree.

Every geometry, SSIM, and ink number above was re-measured with that assertion active and reproduced identically, as did the comparison sheet (sha256 2c03d918…). Renders ran through a worktree-local venv with no _xy.pth, with PYTHONPATH and XY_NATIVE_LIB both pointed at this worktree; xy.__file__ was asserted under it on every run.

The shim reported Matplotlib's figure.subplot.* rectangle from
get_position() but never told the renderers about it, so they fell back
to label-aware default margins: a default 640x480 figure drew its frame
13.8% too wide with its top edge 48px too high. get_position() was also
not grid-aware, collapsing every panel of an n x m grid onto the 1x1
default rectangle.

Resolve the axes rectangle once, in Figure._axes_rect(), and use it for
both what scripts read and what the exporters place. Derive the chart
padding from that rectangle, subtracting the gutters the renderers
reserve outside it (title band, top-side x axis, secondary-y band) so a
title still draws above the axes without moving it. The grid compositor
adds the same gutters to the panel it allocates, which also lets the
equal-aspect solve measure the real plot rect.

Separately, alpha-composite absolutely placed panels instead of pasting
them opaquely. A panel is wider than its gridspec cell because its tick
labels live outside the plot rect, so each column erased the one to its
left and a dense subplot grid rendered only its last column.

Colorbar-bearing axes keep the label-aware margins: Matplotlib's
colorbar() takes its strip out of the parent axes rectangle while the
renderers reserve it outside the padding.
Three-up Matplotlib 3.11.1 / before / after renders of four figures: a
single-axes line plot, a 2x3 sharex/sharey grid, cell 29 of
examples/pdsh/pdsh_04_07_customizing_colorbars.ipynb (8x8 imshow over the
real sklearn load_digits images), and cell 16 of
examples/pdsh/pdsh_04_10_customizing_ticks.ipynb (5x5 imshow over the real
sklearn fetch_olivetti_faces images, wspace=hspace=0). Temporary — for PR
review only, to be removed before merge.
@coderabbitai

coderabbitai Bot commented Jul 24, 2026

Copy link
Copy Markdown

Important

Review skipped

Draft detected.

Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 78c23446-120b-4b44-8f8c-c371451dafa0

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch agent/fix-pyplot-frame-geometry

Comment @coderabbitai help to get the list of available commands.

@codspeed-hq

codspeed-hq Bot commented Jul 24, 2026

Copy link
Copy Markdown

Merging this PR will not alter performance

✅ 97 untouched benchmarks
⏩ 1 skipped benchmark1


Comparing agent/fix-pyplot-frame-geometry (b7b06da) with agent/fix-pyplot-savefig-scale (bdb02c8)

Open in CodSpeed

Footnotes

  1. 1 benchmark was skipped, so the baseline result was used instead. If it was deleted from the codebase, click here and archive it to remove it from the performance reports.

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.

1 participant