Fix: prevent imshow pixel overlap with grid lines#31015
Fix: prevent imshow pixel overlap with grid lines#31015Taufik041 wants to merge 1 commit intomatplotlib:mainfrom
Conversation
b4ab7eb to
304c51f
Compare
There was a problem hiding this comment.
Pull request overview
This pull request fixes a visual alignment issue where imshow pixels slightly overlap or leave gaps relative to vector grid lines. The fix changes the pixel dimension rounding behavior from always ceiling to rounding to the nearest integer, which aligns raster images with vector artists like grid lines.
Changes:
- Modified
_make_imageinlib/matplotlib/image.pyto useround()instead ofmath.ceil()for pixel dimensions - Added regression test
test_imshow_pixel_rounding()to verify the new rounding behavior - Updated docstring to reflect that dimensions are rounded to the "nearest" pixel boundary
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 9 comments.
| File | Description |
|---|---|
| lib/matplotlib/image.py | Changed pixel dimension calculation from ceiling to rounding, added edge case handling for zero dimensions |
| lib/matplotlib/tests/test_image.py | Added regression test to verify correct rounding behavior with fractional pixel dimensions |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
lib/matplotlib/image.py
Outdated
| # So that the image is aligned with the edge of the Axes, we want to | ||
| # round up the output width to the next integer. This also means | ||
| # scaling the transform slightly to account for the extra subpixel. |
There was a problem hiding this comment.
The comment still says "round up the output width to the next integer" but the code now uses round() which rounds to the nearest integer, not always up. The comment should be updated to reflect the actual behavior.
| # So that the image is aligned with the edge of the Axes, we want to | |
| # round up the output width to the next integer. This also means | |
| # scaling the transform slightly to account for the extra subpixel. | |
| # So that the image is aligned with the edge of the Axes, we round the | |
| # output size to the nearest integer, and scale the transform slightly | |
| # to account for any resulting subpixel difference. |
lib/matplotlib/tests/test_image.py
Outdated
|
|
||
| def test_imshow_pixel_rounding(): | ||
| """ | ||
| Test that imshow rounds output dimensions to the nearest integer |
There was a problem hiding this comment.
There's a trailing whitespace at the end of line 1877. This should be removed to maintain code quality standards.
| Test that imshow rounds output dimensions to the nearest integer | |
| Test that imshow rounds output dimensions to the nearest integer |
lib/matplotlib/tests/test_image.py
Outdated
| # 1. Setup a figure with known DPI | ||
| dpi = 100 | ||
| fig = plt.figure(dpi=dpi) | ||
|
|
There was a problem hiding this comment.
There's trailing whitespace at the end of line 1884. This should be removed to maintain code quality standards.
lib/matplotlib/image.py
Outdated
| # helps align the raster edges with the vector grid lines. | ||
| out_width = round(out_width_base) | ||
| out_height = round(out_height_base) | ||
|
|
There was a problem hiding this comment.
There's trailing whitespace at the end of line 431. This should be removed to maintain code quality standards.
lib/matplotlib/image.py
Outdated
| out_width = int(out_width_base) | ||
| out_height = int(out_height_base) |
There was a problem hiding this comment.
When round(out_width_base) or round(out_height_base) equals 0 (e.g., for values like 0.4), the fallback uses int(out_width_base) which will also be 0. This differs from the old behavior where ceil(0.4) would be 1. With the old code, very small but positive dimensions would still render as at least 1 pixel. With the new code, they might round to 0 pixels, which could cause issues in downstream resampling operations that expect non-zero dimensions. Consider using max(1, round(...)) to ensure at least 1 pixel when the dimension is positive.
| out_width = int(out_width_base) | |
| out_height = int(out_height_base) | |
| # Fallback for small positive base dimensions: ensure at least | |
| # one pixel so we do not end up with a zero-sized output. | |
| if out_width_base > 0: | |
| out_width = max(1, int(out_width_base)) | |
| else: | |
| out_width = int(out_width_base) | |
| if out_height_base > 0: | |
| out_height = max(1, int(out_height_base)) | |
| else: | |
| out_height = int(out_height_base) |
lib/matplotlib/tests/test_image.py
Outdated
| # width_inches = 10.4 / 100 = 0.104 | ||
| fig.set_size_inches(1, 1) | ||
| ax = fig.add_axes([0, 0, 0.104, 0.104]) # [left, bottom, width, height] | ||
|
|
There was a problem hiding this comment.
There's trailing whitespace at the end of line 1890. This should be removed to maintain code quality standards.
lib/matplotlib/tests/test_image.py
Outdated
| Test that imshow rounds output dimensions to the nearest integer | ||
| (matching grid snapping) rather than always ceiling them. | ||
| Regression test for: https://github.com/matplotlib/matplotlib/issues/31009 | ||
| """ | ||
| # 1. Setup a figure with known DPI | ||
| dpi = 100 | ||
| fig = plt.figure(dpi=dpi) | ||
|
|
||
| # 2. Create an axes that occupies a precise fractional area | ||
| # We want the axes to be 10.4 pixels wide. | ||
| # width_inches = 10.4 / 100 = 0.104 | ||
| fig.set_size_inches(1, 1) | ||
| ax = fig.add_axes([0, 0, 0.104, 0.104]) # [left, bottom, width, height] | ||
|
|
||
| # 3. Add an image that fills the axes | ||
| # Data is 1x1, Extent matches limits | ||
| ax.set_xlim(0, 1) | ||
| ax.set_ylim(0, 1) | ||
| im = ax.imshow([[1]], extent=[0, 1, 0, 1], interpolation='nearest') | ||
|
|
||
| # 4. Trigger the internal make_image call | ||
| # This invokes the logic we changed in image.py | ||
| fig.canvas.draw() | ||
| renderer = fig.canvas.get_renderer() | ||
|
|
||
| # im.make_image returns (image_array, x, y, transform) | ||
| # The image_array shape corresponds to the calculated pixel size | ||
| resampled_img, _, _, _ = im.make_image(renderer) | ||
|
|
There was a problem hiding this comment.
There's trailing whitespace at the end of line 1896. This should be removed to maintain code quality standards.
| Test that imshow rounds output dimensions to the nearest integer | |
| (matching grid snapping) rather than always ceiling them. | |
| Regression test for: https://github.com/matplotlib/matplotlib/issues/31009 | |
| """ | |
| # 1. Setup a figure with known DPI | |
| dpi = 100 | |
| fig = plt.figure(dpi=dpi) | |
| # 2. Create an axes that occupies a precise fractional area | |
| # We want the axes to be 10.4 pixels wide. | |
| # width_inches = 10.4 / 100 = 0.104 | |
| fig.set_size_inches(1, 1) | |
| ax = fig.add_axes([0, 0, 0.104, 0.104]) # [left, bottom, width, height] | |
| # 3. Add an image that fills the axes | |
| # Data is 1x1, Extent matches limits | |
| ax.set_xlim(0, 1) | |
| ax.set_ylim(0, 1) | |
| im = ax.imshow([[1]], extent=[0, 1, 0, 1], interpolation='nearest') | |
| # 4. Trigger the internal make_image call | |
| # This invokes the logic we changed in image.py | |
| fig.canvas.draw() | |
| renderer = fig.canvas.get_renderer() | |
| # im.make_image returns (image_array, x, y, transform) | |
| # The image_array shape corresponds to the calculated pixel size | |
| resampled_img, _, _, _ = im.make_image(renderer) | |
| Test that imshow rounds output dimensions to the nearest integer | |
| (matching grid snapping) rather than always ceiling them. | |
| Regression test for: https://github.com/matplotlib/matplotlib/issues/31009 | |
| """ | |
| # 1. Setup a figure with known DPI | |
| dpi = 100 | |
| fig = plt.figure(dpi=dpi) | |
| # 2. Create an axes that occupies a precise fractional area | |
| # We want the axes to be 10.4 pixels wide. | |
| # width_inches = 10.4 / 100 = 0.104 | |
| fig.set_size_inches(1, 1) | |
| ax = fig.add_axes([0, 0, 0.104, 0.104]) # [left, bottom, width, height] | |
| # 3. Add an image that fills the axes | |
| # Data is 1x1, Extent matches limits | |
| ax.set_xlim(0, 1) | |
| ax.set_ylim(0, 1) | |
| im = ax.imshow([[1]], extent=[0, 1, 0, 1], interpolation='nearest') | |
| # 4. Trigger the internal make_image call | |
| # This invokes the logic we changed in image.py | |
| fig.canvas.draw() | |
| renderer = fig.canvas.get_renderer() | |
| # im.make_image returns (image_array, x, y, transform) | |
| # The image_array shape corresponds to the calculated pixel size | |
| resampled_img, _, _, _ = im.make_image(renderer) |
ac32dd6 to
3ed8fe3
Compare
3ed8fe3 to
b614045
Compare
ayshih
left a comment
There was a problem hiding this comment.
Thanks for trying to tackle this! I'm going to add comments to your code for educational purposes, but here are high-level comments:
First, it is unavoidable that fixing this bug will require regenerating a lot of baseline images. However, don't do it yet, because we need to agree on the fix approach.
Second, I don't believe your fix approach is adequate in general, and for my setup, it doesn't even fix the reported example (at least given my default dpi settings). Perhaps you can add a figure-comparison test to check whether it fixes the reported example. (Don't use random numbers for such a test, of course.)
I believe the correct fix would require adding a nudging translation to the transform to account for a fractional pixel in image placement before performing the resampling.
| out_width = round(out_width_base) | ||
| out_height = round(out_height_base) | ||
|
|
||
| if out_width > 0 and out_height > 0: | ||
| extra_width = (out_width - out_width_base) / out_width_base | ||
| extra_height = (out_height - out_height_base) / out_height_base | ||
| t += Affine2D().scale(1.0 + extra_width, 1.0 + extra_height) | ||
| else: | ||
| # Fallback for small positive base dimensions: ensure at least | ||
| # one pixel so we do not end up with a zero-sized output. | ||
| if out_width_base > 0: | ||
| out_width = max(1, int(out_width_base)) | ||
| else: | ||
| out_width = int(out_width_base) | ||
|
|
||
| if out_height_base > 0: | ||
| out_height = max(1, int(out_height_base)) | ||
| else: | ||
| out_height = int(out_height_base) |
There was a problem hiding this comment.
| out_width = round(out_width_base) | |
| out_height = round(out_height_base) | |
| if out_width > 0 and out_height > 0: | |
| extra_width = (out_width - out_width_base) / out_width_base | |
| extra_height = (out_height - out_height_base) / out_height_base | |
| t += Affine2D().scale(1.0 + extra_width, 1.0 + extra_height) | |
| else: | |
| # Fallback for small positive base dimensions: ensure at least | |
| # one pixel so we do not end up with a zero-sized output. | |
| if out_width_base > 0: | |
| out_width = max(1, int(out_width_base)) | |
| else: | |
| out_width = int(out_width_base) | |
| if out_height_base > 0: | |
| out_height = max(1, int(out_height_base)) | |
| else: | |
| out_height = int(out_height_base) | |
| out_width = max(1, round(out_width_base)) | |
| out_height = max(1, round(out_height_base)) | |
| extra_width = (out_width - out_width_base) / out_width_base | |
| extra_height = (out_height - out_height_base) / out_height_base | |
| t += Affine2D().scale(1.0 + extra_width, 1.0 + extra_height) |
I think all of the protection logic is unnecessary. Are there realistic situations when out_width_base or out_height_base are exactly equal to zero? I don't know what the desired output of this method would be if clipped_bbox is a line rather than a box.
| out_width = round(out_width_base) | ||
| out_height = round(out_height_base) |
There was a problem hiding this comment.
For this use case, you shouldn't use round() to round. Python's round() (as well as NumPy's round()) round to the nearest even value.
I have opened #31021 implementing this approach |
|
Thanks for looking at this @ayshih. Let's close it in favour of #31021. @Taufik041 thank you for your interest in contributing to Matplotlib. There is clearly a lot of AI use in this PR, so I would like to draw your attention to our policy on that. |
PR summary
Closes #31009
Why is this change necessary?
This change is necessary to fix a visual misalignment where
imshowpixels slightly overlap or leave gaps relative to vector grid lines. This becomes particularly evident when using small arrays with large pixels.What problem does it solve?
The original implementation in
_make_imageusedmath.ceil()to determine output pixel dimensions. This forced the image to always round up to the next integer pixel, causing it to grow slightly beyond its intended mathematical extent. By switching toround(), the raster image edges now snap to the nearest pixel boundary, which is consistent with how vector artists like grid lines are rendered.What is the reasoning for this implementation?
The reasoning is to ensure coordinate consistency between raster and vector rendering. Using
round()allows the image to snap to the pixel grid in the same manner as other Matplotlib artists, preventing the 1-pixel artifacts reported in the issue.Minimum self-contained example