GeezerVison is showing github.com — Fixed inaccurate image placement and even more resampling bugs by ayshih · Pull Request #31021 · matplotlib/matplotlib
Conversation
ayshih
commented
Jan 23, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
PR summary
This PR now fixes a bunch of bugs:
- Misaligned placement of
AxesImage, PcolorImage, and NonUniformImage
- Interpolation inaccuracies when affine resampling under nearest-neighbor interpolation
- Edge effects when affine resampling under nearest-neighbor interpolation
- Reflected data beyond the image bounds when nonaffine resampling under nearest-neighbor interpolation
- Alpha/color issues at the edges when affine resampling under non-nearest-neighbor interpolation in RGBA space
Original post:
This PR fixes a bug with how images are resampled for drawing when they are positioned at a fractional pixel (which is nearly always the case). The existing code resamples the image to the (mostly) correct width/height in display pixels, but essentially assumes that the beginning edge of the display array is aligned with the beginning edge of the image array. This resampled image is then placed at the nearest display pixel, which combined with above, means that the drawn image can be up to a pixel off from the optimal location.
The "mostly" above is because the above bug means that the drawn image can leave a gap to the axes at the end edge of the drawn image, so there's additional, discomforting code to slightly enlarge the resampled image to cover up that gap. This additional code runs only for affine transformations, so you can see the gap when using a nonaffine transformation.
Before this PR:
This PR fixes the resampling logic so that it anticipates the rounding that will be used in the later drawing and defines the transform appropriately with the rounding baked in. That way, each display pixel has been resampled exactly correctly to match the eventual drawing. Furthermore, this means there are should no longer any gaps at the end edge of the drawn image, so there no longer needs to be an enlarging step.
The resampling code for nonaffine transforms has already been scrubbed extensively (#30054 and #30184), so drawing using a nonaffine transform now gives optimal results with this PR.
Unfortunately, while drawing using an affine transform is also broadly improved, there are still cases where the results are not optimal due to inaccuracies in the image-resampling code for affine transforms. See #30184 (comment) for some details. I'm mulling over whether to greatly expand the solution described in #30184 (comment), that is, taking the switching to the nonaffine-transform code path for extreme zooms (>128x) and extending it down to minor zooms (>~5x).
Update: I've made changes to how the Agg resampler handles affine transforms when using nearest-neighbor interpolation, and now drawing using an affine transform also gives optimal results.
After this PR:
Generating script:
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, axs = plt.subplots(2, 3)
N = [3, 7, 11]
for j in range(3):
data = np.arange(N[j]**2).reshape((N[j], N[j])) % 4
seps = np.arange(-0.5, N[j])
for i in range(2):
if i == 0:
axs[i, j].imshow(data, cmap='Grays')
axs[i, j].set_title('affine')
else:
axs[i, j].imshow(data, cmap='Grays', transform=nonaffine_identity + axs[i, j].transData)
axs[i, j].set_title('nonaffine')
axs[i, j].set_axis_off()
axs[i, j].vlines(seps, -1, N[j], linewidth=0.5, color='red', linestyle='dashed')
axs[i, j].hlines(seps, -1, N[j], linewidth=0.5, color='red', linestyle='dashed')
plt.show()
Fixes #31009
PR checklist
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
adeak and scottshambaugh reacted with hooray emoji
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
ayshih
changed the title
Fixed image resampling when the image is placed at a fractional pixel
Fixed image resampling when the image is positioned at a fractional pixel
Jan 24, 2026
ayshih
commented
Jan 29, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
I've made changes to how the Agg resampler handles affine transforms when using nearest-neighbor interpolation, and now drawing using an affine transform also gives optimal results.
To elaborate, the issue at hand is that the Agg resampler uses an integer-based interpolator for affine resampling (see some details in #30184 (comment)). The resulting inaccuracies can be obvious when using nearest-neighbor interpolation. This PR replaces that interpolator with a floating-point-based interpolator, and qualitatively there is no discernable performance hit.
As a nice benefit of this change, the solution described in #30184 (comment) – falling back to nonaffine resampling under extreme zooms – is no longer necessary to preserve the alignment of pixel edges.
To be safe performance-wise, affine resampling under non-nearest-neighbor interpolation continues to use the integer-based interpolator. Unlike with nearest-neighbor interpolation, it's very difficult to tell if there are slight inaccuracies with the interpolator. This can be revisited in the future.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
ayshih
changed the title
Fixed image resampling when the image is positioned at a fractional pixel
Fixed inaccurate image placement and even more resampling bugs
Jan 29, 2026
ayshih
commented
Jan 30, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
|
Even with the fixing the affine resampling, the resampled image continued to sometimes have a gap at the end. It turns out that a rendering box was being applied to affine resampling, even under nearest-neighbor interpolation, and that could hinder the rendering of the edges. Under nearest-neighbor interpolation, the interpolation is now calculated exactly correctly, so there is no longer a need to apply a rendering box, which avoids the occasional appearance of gaps between the image and the axes spines.
The reason for the rendering box was for non-nearest-neighbor interpolation. To resample up to the edge of the image, the code uses the assumption of reflection wrapping on all edges. For nearest-neighbor interpolation, this PR now fills to zero beyond the bounds of the source image instead of reflection wrapping, which allows us to avoid a rendering box.
For arbitrary nonaffine resampling, a rendering box cannot be reliably defined, so nonaffine resampling has had the long-standing bug of always showing the reflection wrapping.
Before this PR
The same removal of reflection wrapping done for affine resampling under nearest-neighbor interpolation can be done for nonaffine resampling under nearest-neighbor interpolation,
After this PR
Fixing the nonaffine resampling under non-nearest-neighbor interpolation is a more challenging problem, and must be deferred to a future effort.
Generating code
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.patches import Rectangle
from matplotlib.transforms import Affine2D, 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, axs = plt.subplots(2, 3)
N = 11
interpolation = ['nearest', 'hanning', 'bilinear']
for j in range(3):
data = np.arange(N**2).reshape((N, N))
data = data / N**2 + (data % 4) / 6
rotation = Affine2D().rotate_around(N/2-0.5, N/2-0.5, 1)
for i in range(2):
if i == 0:
axs[i, j].imshow(data, cmap='Grays', interpolation=interpolation[j],
transform=rotation + axs[i, j].transData)
axs[i, j].set_title(f"{interpolation[j]}\naffine")
else:
axs[i, j].imshow(data, cmap='Grays', interpolation=interpolation[j],
transform=nonaffine_identity + rotation + axs[i, j].transData)
axs[i, j].set_title('nonaffine')
axs[i, j].set_axis_off()
box = Rectangle((-0.5, -0.5), N, N,
edgecolor='red', facecolor='none', lw=0.5, ls='dashed',
transform=rotation + axs[i, j].transData)
axs[i, j].add_artist(box)
plt.show()
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
ayshih
commented
Jan 30, 2026
•
edited
Loading
Uh oh!
There was an error while loading. Please reload this page.
|
This PR now also fixes the pixel alignment for PcolorImage and NonUniformImage. The coordinates are specified differently (edges versus centers) for these two image types, so the two types are not supposed to agree. Just look at the pixel alignment to grid lines in the upper right of each panel (away from the yellow/red box).
Before this PR
After this PR
Generating code
import matplotlib.pyplot as plt
import numpy as np
from matplotlib.image import NonUniformImage
from matplotlib.patches import Rectangle
fig, axs = plt.subplots(2, 3)
N = [4, 9, 11]
for j in range(3):
data = np.arange(N[j]**2).reshape((N[j], N[j])) % 5
seps = np.arange(-0.5, N[j] + 1)
x_edges = np.arange(-0.5, N[j])
x_edges[2:] += 1
y_edges = np.arange(-0.5, N[j])
y_edges[3:] += 1
x_mids = (x_edges[:-1] + x_edges[1:]) / 2
y_mids = (y_edges[:-1] + y_edges[1:]) / 2
for i in range(2):
if i == 0:
axs[i, j].pcolorfast(x_edges, y_edges, data, cmap='Grays', interpolation='nearest')
axs[i, j].set_title('PcolorImage')
else:
im = NonUniformImage(axs[i, j], interpolation='nearest', cmap='Grays')
im.set_data(x_mids, y_mids, data)
axs[i, j].add_image(im)
axs[i, j].set_title('NonUniformImage')
axs[i, j].set_aspect('equal')
axs[i, j].set_axis_off()
axs[i, j].vlines(seps, -1, N[j] + 1, lw=0.5, color='red', ls='dashed')
axs[i, j].hlines(seps, -1, N[j] + 1, lw=0.5, color='red', ls='dashed')
box = Rectangle((0.5, 1.5), 2, 2, edgecolor='yellow', facecolor='none', lw=1)
axs[i, j].add_artist(box)
plt.show()
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
This comment was marked as outdated.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
modulo fixing the c++ style issue.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Some minor nits, but also a small ask: please add a bit more context to the commit messages; it is helpful to find out the why of a change without having to search out the PR. Maybe not the entirety of the test images you have here, but at least more than the single-line messages.
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
please add a bit more context to the commit messages; it is helpful to find out the why of a change without having to search out the PR.
Sure, I've added a bunch of sentences to the commit messages
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
|
I'd like to keep the individual commits, so please squash the fixups into the originals. I assume that a lot of the commits affect test images repeatedly, so it's probably fine to keep those updates in a single separate commit.
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
The code now specifies the resampling with the foreknowledge that the
resampled image will be placed at the nearest display pixel. This
fixes situations where the rendered image can appear to be shifted by up
to one display pixel.
The built-in Agg interpolator for determining the nearest-neighbor
pixels under an affine transform uses integer-based math, but that
results in slight inaccuracies that can become visible at even a
minor level of magnification. A custom interpolator that uses
floating-point-based math is now used instead.
For nearest-neighbor interpolation, the code no longer applies any
antialiasing of edges under an affine transform for more accurate edges
and no longer shows data beyond the image bounds under a non-affine
transform.
Calculations now take into account that rendering will be to the nearest
display pixel.
Premultiplied image data is passed to the Agg resampler so that the
interpolation is handled correctly. This code now tells Agg that the
image is premultiplied so that the antialiasing of edges is handled
correctly.
This fixes a subtle issue where a resampled pixel falling exactly
between two input pixels would normally round half up, but would also be
rendered with rounding half up, resulting in the resampled pixel
having the wrong value. The code now functionally rounds half down,
which cancels out the effect of rendering rounding half up in these
situations, and otherwise makes no difference.
…olation
This fixes a visual issue where there can appear to be a slight gap
between an image and the axes spines even when it is supposed to fill
the axes. The code now expands the rendering edges to fill the axes
without degrading the placement accuracy within the image.
|
Sure, I've squashed the commits down to nine commits
|
Sorry, something went wrong.
Uh oh!
There was an error while loading. Please reload this page.
Uh oh!
There was an error while loading. Please reload this page.
pllim
added a commit
to pllim/astropy
that referenced
this pull request
Mar 3, 2026
Reviewers
QuLogic
QuLogic left review comments
Uh oh!
There was an error while loading. Please reload this page.
Assignees
No one assigned
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
|