Skip to content

Fixed lingering bugs with image rendering related to exact half display pixels#31313

Merged
QuLogic merged 6 commits intomatplotlib:mainfrom
ayshih:round_half_down
Mar 20, 2026
Merged

Fixed lingering bugs with image rendering related to exact half display pixels#31313
QuLogic merged 6 commits intomatplotlib:mainfrom
ayshih:round_half_down

Conversation

@ayshih
Copy link
Contributor

@ayshih ayshih commented Mar 16, 2026

PR summary

This is a follow-on to #31021 to fix image-rendering bugs when a display pixel is exactly aligned with the edge between two image pixels (or with the edge of the image). An attempt at a fix was part of #31021, but the tests there had not been carefully crafted to verify that the bugs were comprehensively fixed.

Before this PR

before

After this PR

after

Generating code

import matplotlib.pyplot as plt
import numpy as np
from matplotlib.transforms import Transform

# Create a nonaffine identity to easily convert an affine transform to its nonaffine equivalent
class NonAffineIdentityTransform(Transform):
    input_dims = 2
    output_dims = 2

    def inverted(self):
        return self
nonaffine_identity = NonAffineIdentityTransform()

fig = plt.figure(figsize=(10, 5))
fig.set_facecolor('g')

# All values in this test are chosen carefully so that many display pixels are
# aligned with an edge or a corner of an input pixel

# Layout:
# Top row is origin='upper', bottom row is origin='lower'
# Column 1: affine transform, anchored at whole pixel
# Column 2: affine transform, anchored at half pixel
# Column 3: nonaffine transform, anchored at whole pixel
# Column 4: nonaffine transform, anchored at half pixel
# Column 5: affine transform, anchored at half pixel, interpolation='hanning'

# Each axes patch is magenta, so seeing a magenta line at an edge of the image
# means that the image is not filling the axes

corner_x = [0.01, 0.199, 0.41, 0.599, 0.81]
corner_y = [0.1, 0.5]

axs = []
for cy in corner_y:
    for ix, cx in enumerate(corner_x):
        mx = cx + 0.0005 if ix in [1, 3, 4] else cx
        my = cy + 0.011 if ix in [1, 3, 4] else cy
        axs.append(fig.add_axes([mx, my, 0.175, 0.35], xticks=[], yticks=[]))

N = 10

data = np.arange(N**2).reshape((N, N)) % 9
seps = np.arange(-0.5, N)

for i, ax in enumerate(axs):
    ax.set_facecolor('m')

    ax.imshow(data, cmap='Blues',
              interpolation='hanning' if i % 5 == 4 else 'nearest',
              origin='upper' if i >= 5 else 'lower',
              transform=nonaffine_identity + ax.transData if i % 4 >= 2 else ax.transData)

    ax.vlines(seps, -0.5, N - 0.5, linewidth=0.5, color='red', linestyle=(0, (3, 6)))
    ax.hlines(seps, -0.5, N - 0.5, linewidth=0.5, color='red', linestyle=(0, (3, 6)))

    for spine in ax.spines:
        ax.spines[spine].set_linestyle((0, (5, 10)))

plt.show()

AI Disclosure

N/A

PR checklist

@ayshih ayshih changed the title WIP: Fixed lingering rounding bug with image placement in the vertical direction WIP: Fixed lingering rounding bug with image rendering related to exact half display pixels Mar 17, 2026
@ayshih ayshih changed the title WIP: Fixed lingering rounding bug with image rendering related to exact half display pixels WIP: Fixed lingering bugs with image rendering related to exact half display pixels Mar 17, 2026
@ayshih ayshih force-pushed the round_half_down branch 7 times, most recently from 92de70f to 51745bf Compare March 18, 2026 20:04
@ayshih ayshih changed the title WIP: Fixed lingering bugs with image rendering related to exact half display pixels Fixed lingering bugs with image rendering related to exact half display pixels Mar 19, 2026
@ayshih ayshih marked this pull request as ready for review March 19, 2026 04:27
@ayshih
Copy link
Contributor Author

ayshih commented Mar 19, 2026

This PR is now ready for review! I'm annoyed that I didn't catch these subtleties as part of #31021, but fixing them changes only four existing baseline images – only one of those in a meaningful way – because the bugged criteria are rather specific (but, at the same time, not overly unusual to accidentally stumble across). A couple of comments:

  • The part of this PR's code that might raise an eyebrow is that I add 1e-8 in a bunch of places to get the truncating/rounding to work as desired. The issue is that it is annoyingly common with the internal floating-point calculations to end up one floating-point tick on the "wrong" side of a threshold: e.g., 224.99999999999997 instead of 225 when truncating, or 219.49999999999997 instead of 219.5 when rounding half up. Thus, the 1e-8 functions as a tolerance for such situations, where I picked 1e-8 because it covers 1e-13 relative error for 100,000 pixels.
  • The new figure test (alignment_half_display_pixels.png) overlaps in purpose with the existing test nn_pixel_alignment.png. However, I chose to keep the previous test instead of replacing it because it's plausible that the existing test might catch something different given its realistic way of creating a plot as opposed to the highly artificial way of the new test.

Comment on lines +1913 to +1914
# All values in this test are chosen carefully so that many display pixels are
# aligned with an edge or a corner of an input pixel
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure how exactly (maybe checking ax.get_window_extent()?), but if this test is very dependent on specific locations of Axes, then it might be a good idea to assert that they are in the places you expect, in case layout somehow changes in the future?

Or, if possible, maybe switch to figure-level artists, to remove some level of indirection (but I'm not sure if those exercise what you want)?

Copy link
Contributor Author

@ayshih ayshih Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Rather than testing specific values, I added asserts for the precise height/width for each axes and the precise anchoring (whether on whole pixels or half pixels), which are the critical requirements. Translations, if they were to happen for some reason, would be "okay" as far as this test is concerned.

@QuLogic
Copy link
Member

QuLogic commented Mar 19, 2026

  • Thus, the 1e-8 functions as a tolerance for such situations, where I picked 1e-8 because it covers 1e-13 relative error for 100,000 pixels.

Maybe you can mention that in the commit message?

@QuLogic QuLogic added this to the v3.11.0 milestone Mar 19, 2026
Copy link
Member

@tacaswell tacaswell left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Adding asserts that the axes a really where they should be via get_window_extent is good but I would be happy to merge without it.

ayshih added 4 commits March 20, 2026 00:35
The interpolator was previously written to minimize bias with each step,
effectively equivalent to rounding half even.  However, rendering
alignment requires there to be bias equivalent to rounding half up.
The Agg renderer internally has the vertical axis flipped, so the
rounding of the bounds in the vertical direction needs to be round
half down instead of round half up.
The code previously performed the flipping in output space, but the
flipping needs to be in input space because that is where the rounding
to nearest input pixel takes place.  Also, flipping in the vertical
direction already happens if origin='lower', so flip vertically only if
origin='upper'.
There are multiple instances where a value that would ideally be exactly
at a half display pixel might instead be one floating-point tick away in
the wrong direction.  Here fudge amounts are added so that the rounding
occurs in the desired direction.  An analogous situation occurs for
truncation to a whole pixel.  The amount of 1e-8 is chosen because it
covers 1e-13 relative error for 100,000 pixels.
@ayshih
Copy link
Contributor Author

ayshih commented Mar 20, 2026

  • Thus, the 1e-8 functions as a tolerance for such situations, where I picked 1e-8 because it covers 1e-13 relative error for 100,000 pixels.

Maybe you can mention that in the commit message?

Now included

@QuLogic QuLogic merged commit f117602 into matplotlib:main Mar 20, 2026
44 of 54 checks passed
@ayshih ayshih deleted the round_half_down branch March 20, 2026 12:43
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants