X Tutup
Skip to content

Fixed inaccurate image placement and even more resampling bugs#31021

Merged
QuLogic merged 9 commits intomatplotlib:mainfrom
ayshih:make_image_fix
Mar 2, 2026
Merged

Fixed inaccurate image placement and even more resampling bugs#31021
QuLogic merged 9 commits intomatplotlib:mainfrom
ayshih:make_image_fix

Conversation

@ayshih
Copy link
Contributor

@ayshih ayshih commented Jan 23, 2026

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:

before

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:

after

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

@QuLogic
Copy link
Member

QuLogic commented Jan 24, 2026

Does this replace #30175?

@ayshih
Copy link
Contributor Author

ayshih commented Jan 24, 2026

Yes, so I'll close #30175

@ayshih 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 ayshih force-pushed the make_image_fix branch 5 times, most recently from 3b36edb to af951be Compare January 29, 2026 04:07
@ayshih
Copy link
Contributor Author

ayshih commented Jan 29, 2026

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.

@ayshih 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 ayshih force-pushed the make_image_fix branch 2 times, most recently from 104077d to e919599 Compare January 30, 2026 05:25
@ayshih
Copy link
Contributor Author

ayshih commented Jan 30, 2026

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

before

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

after

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()

@ayshih ayshih force-pushed the make_image_fix branch 2 times, most recently from f5088a9 to 7472d60 Compare January 30, 2026 14:46
@ayshih
Copy link
Contributor Author

ayshih commented Jan 30, 2026

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

before

After this PR

after

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()

@ayshih ayshih force-pushed the make_image_fix branch 2 times, most recently from a034951 to 50b6ed3 Compare January 31, 2026 04:46
@ayshih

This comment was marked as outdated.

@ayshih ayshih force-pushed the make_image_fix branch 2 times, most recently from c752389 to 6fdf13e Compare January 31, 2026 05:37
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.

modulo fixing the c++ style issue.

Copy link
Member

@QuLogic QuLogic left a 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.

@ayshih
Copy link
Contributor Author

ayshih commented Feb 28, 2026

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

@QuLogic
Copy link
Member

QuLogic commented Mar 1, 2026

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.

ayshih added 9 commits March 1, 2026 21:57
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.
@ayshih
Copy link
Contributor Author

ayshih commented Mar 2, 2026

Sure, I've squashed the commits down to nine commits

@QuLogic QuLogic merged commit 86f65d7 into matplotlib:main Mar 2, 2026
36 of 38 checks passed
@QuLogic QuLogic added this to the v3.11.0 milestone Mar 2, 2026
@ayshih ayshih deleted the make_image_fix branch March 2, 2026 13:38
pllim added a commit to pllim/astropy that referenced this pull request Mar 3, 2026
@ayshih ayshih mentioned this pull request Mar 4, 2026
5 tasks
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.

[Bug]: Large pixels may overlap when using imshow()

4 participants

X Tutup