Skip to content

Tick gutters label the pixels they sit next to - #74

Merged
CSSFrancis merged 1 commit into
mainfrom
fix/imshow-centre-ticks
Sep 14, 2026
Merged

CSSFrancis merged 1 commit into
mainfrom
fix/imshow-centre-ticks

Conversation

@CSSFrancis

Copy link
Copy Markdown
Owner

Follow-up to #73. Stacked on fix/issues-66-72, so the diff shows only the tick work. Merge #73 first; GitHub then retargets this PR to main.

What was wrong

The 2-D tick gutters took a value's fraction of the axis array and used it as a fraction of the gutter width. Two things broke:

  1. Half-pixel stretch (imshow). Axis values sit at pixel centres, but the first and last centres were drawn at the gutter's ends. Every label moved outward by up to half a pixel. Markers, widgets, pointer events (Pointer events in the pixel-centre convention; bold and outlined text markers #73) and display_to_data all use the centre convention, so the ticks were the one part that disagreed.
  2. Letterboxing ignored (imshow and pcolormesh). The gutter spans the whole image area, but the image is drawn "contain" inside it. When the image is pillarboxed or letterboxed, the ticks ran across the empty margin instead of the image.

On top of that, an origin='lower' image (descending y axis) got no y ticks at all, and set_ylim did nothing on one.

A 6×4 imshow with axes=[arange(6), arange(4)] and a red dot on every pixel centre. Before: x ticks span the whole gutter and read 0.6, 1.35, …; y reads 0.37, 1.12, …. After: tick i sits directly under or beside the centre of pixel i

origin='lower' with physical axes and set_view(x0=0.7, x1=1.5, y0=0.4, y1=1.2). Before: no y ticks, the y zoom is ignored, and the x ticks drift. After: both axes are ticked, the view is the requested window, and the marked peak at (1.05, 0.65) nm reads correctly against the ticks

Before screenshots are from fix/issues-66-72, rendered from a git worktree; the tick code there is the same as on main. After screenshots are from this branch.

Fix

  • Renderer: ticks now go through the same transform as markers and pointer events: axis value → _axisValToImg2d_imgToCanvas2d. _axisValToImg2d is the inverse of Pointer events in the pixel-centre convention; bold and outlined text markers #73's _imgToAxisVal2d; it handles ascending or descending arrays and extends linearly past either end. pcolormesh uses _axisValToFrac(arr, v) × n − ½, so its mapping is unchanged. Letterboxing, zoom < 1, pan clamping and descending axes all come from _imgToCanvas2d instead of a separate calculation. The visible value range is the two gutter ends passed through _canvasToImg2d, clamped to the outer pixel edges. Label spacing uses abs(), so a descending axis also gets its labels.
  • Python: set_view / set_xlim / set_ylim read the axis the same way, through the new Plot2D._axis_extent_frac, which mirrors _axisValToImg2d. They also accept a descending axis.

Behaviour change (api_change fragment)

  • On imshow the view edges are pixel edges. set_xlim(-0.5, n - 0.5) shows the whole image, and set_xlim(0, n - 1) now crops the outer half pixels, as matplotlib's imshow does. get_xlim() still returns the first and last axis values. I didn't change it, because other tests treat it as "the axis range"; say if you'd rather it return the extent.
  • Three TestImshowView tests encoded the stretched mapping, using linspace(0, 32, 32) as if it ran edge to edge. They now use arange axes and expect the pixel-edge view described above.

Tests

  • test_plot2d/test_axis_ticks.py:
    • Screenshot checks: tick marks are located in real screenshots and compared with data_to_display of the pixel centres. Cases: pillarboxed x, letterboxed y, physical units with zoom and pan, origin='lower', and pcolormesh edges.
    • set_view math: whole extent, a window between pixel centres, physical units, descending axis, and mesh.
    • JS ↔ Python parity: the JS round trip, plus agreement between JS and Python on ascending, descending and non-uniform axes.
  • Against fix/issues-66-72, 8 of these fail and the 3 parity tests error because the JS helper doesn't exist there. The two that pass on both are the whole-extent and mesh set_view checks, whose mapping didn't change.
  • Full suite run locally (Windows, Chromium): 2451 passed, 58 skipped.
  • The visual baselines (imshow_labels, the gridspec image scenes, pcolormesh_uniform) still pass within tolerance, so I didn't regenerate them from Windows.

https://claude.ai/code/session_01EUrvzeXdzNjPKBTk5jCtp1

The 2-D gutters took a value's fraction of the axis array as a fraction of
the gutter. imshow axes hold pixel CENTRES, so the first and last centres
landed on the gutter's ends and every label stretched outward by up to half
a pixel -- the one place left that disagreed with markers, widgets, pointer
events and display_to_data. Worse, the gutter spans the whole image area
while the image is drawn "contain" inside it, so a pillarboxed or
letterboxed image's ticks ran across the empty margin (meshes too). A
descending origin='lower' y axis drew no ticks at all.

Ticks now go through the transform markers use: axis value ->
_axisValToImg2d (the inverse of _imgToAxisVal2d; ascending or descending,
continued linearly past the ends; pcolormesh keeps its edge mapping) ->
_imgToCanvas2d, which brings the fit rect, zoom < 1, pan clamping and
descending axes along. The visible range is the gutter ends through
_canvasToImg2d, clamped to the outer pixel edges; label spacing takes abs()
so a descending axis keeps its labels.

set_view / set_xlim / set_ylim read the axis the same way through
Plot2D._axis_extent_frac, the Python mirror of _axisValToImg2d: on imshow
the view edges are pixel edges, so set_xlim(-0.5, n - 0.5) is the whole
image and set_xlim(0, n - 1) crops the outer half pixels, as matplotlib's
imshow does. A descending axis is accepted in either order (set_ylim on an
origin='lower' image silently did nothing). The three TestImshowView tests
that encoded the stretched mapping now use arange axes and extent values.

Claude-Session: https://claude.ai/code/session_01EUrvzeXdzNjPKBTk5jCtp1
@codecov-commenter

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 91.18%. Comparing base (6c93609) to head (f28049c).

Additional details and impacted files
@@                 Coverage Diff                  @@
##           fix/issues-66-72      #74      +/-   ##
====================================================
+ Coverage             91.16%   91.18%   +0.02%     
====================================================
  Files                    41       41              
  Lines                  4865     4880      +15     
====================================================
+ Hits                   4435     4450      +15     
  Misses                  430      430              

☔ 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.

@CSSFrancis
CSSFrancis changed the base branch from fix/issues-66-72 to main September 14, 2026 15:03
@CSSFrancis
CSSFrancis merged commit 4f339d8 into main Sep 14, 2026
10 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