X Tutup
Skip to content

Fix: prevent imshow pixel overlap with grid lines#31015

Closed
Taufik041 wants to merge 1 commit intomatplotlib:mainfrom
Taufik041:fix-imshow-misalignment
Closed

Fix: prevent imshow pixel overlap with grid lines#31015
Taufik041 wants to merge 1 commit intomatplotlib:mainfrom
Taufik041:fix-imshow-misalignment

Conversation

@Taufik041
Copy link

PR summary

Closes #31009

Why is this change necessary?

This change is necessary to fix a visual misalignment where imshow pixels 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_image used math.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 to round(), 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

import matplotlib.pyplot as plt
import numpy as np
fig, ax = plt.subplots()
N = 12
ax.imshow(np.random.rand(N, N))
seps = np.arange(-0.5, N)
ax.vlines(seps, -0.5, N - 0.5, linewidth=1)
ax.hlines(seps, -0.5, N - 0.5, linewidth=1)
plt.show()

Copilot AI review requested due to automatic review settings January 22, 2026 11:55
@Taufik041 Taufik041 force-pushed the fix-imshow-misalignment branch from b4ab7eb to 304c51f Compare January 22, 2026 11:58
Copy link
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

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

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_image in lib/matplotlib/image.py to use round() instead of math.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.

Comment on lines 423 to 425
# 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.
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
# 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.

Copilot uses AI. Check for mistakes.

def test_imshow_pixel_rounding():
"""
Test that imshow rounds output dimensions to the nearest integer
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

There's a trailing whitespace at the end of line 1877. This should be removed to maintain code quality standards.

Suggested change
Test that imshow rounds output dimensions to the nearest integer
Test that imshow rounds output dimensions to the nearest integer

Copilot uses AI. Check for mistakes.
# 1. Setup a figure with known DPI
dpi = 100
fig = plt.figure(dpi=dpi)

Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

There's trailing whitespace at the end of line 1884. This should be removed to maintain code quality standards.

Suggested change

Copilot uses AI. Check for mistakes.
# helps align the raster edges with the vector grid lines.
out_width = round(out_width_base)
out_height = round(out_height_base)

Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

There's trailing whitespace at the end of line 431. This should be removed to maintain code quality standards.

Suggested change

Copilot uses AI. Check for mistakes.
Comment on lines +437 to +438
out_width = int(out_width_base)
out_height = int(out_height_base)
Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

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.

Suggested change
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)

Copilot uses AI. Check for mistakes.
# 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]

Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

There's trailing whitespace at the end of line 1890. This should be removed to maintain code quality standards.

Suggested change

Copilot uses AI. Check for mistakes.
Comment on lines +1877 to +1905
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)

Copy link

Copilot AI Jan 22, 2026

Choose a reason for hiding this comment

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

There's trailing whitespace at the end of line 1896. This should be removed to maintain code quality standards.

Suggested change
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)

Copilot uses AI. Check for mistakes.
@Taufik041 Taufik041 force-pushed the fix-imshow-misalignment branch 2 times, most recently from ac32dd6 to 3ed8fe3 Compare January 22, 2026 12:11
Copy link
Contributor

@ayshih ayshih left a comment

Choose a reason for hiding this comment

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

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.

Comment on lines +426 to +444
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)
Copy link
Contributor

Choose a reason for hiding this comment

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

Suggested change
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.

Comment on lines +426 to +427
out_width = round(out_width_base)
out_height = round(out_height_base)
Copy link
Contributor

@ayshih ayshih Jan 22, 2026

Choose a reason for hiding this comment

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

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.

@ayshih
Copy link
Contributor

ayshih commented Jan 23, 2026

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.

I have opened #31021 implementing this approach

@rcomer
Copy link
Member

rcomer commented Jan 23, 2026

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.
https://matplotlib.org/devdocs/devel/contribute.html#generative-ai

@rcomer rcomer closed this Jan 23, 2026
@Taufik041 Taufik041 deleted the fix-imshow-misalignment branch January 23, 2026 16:41
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