Skip to content

Fix the misplacement of rasterized object in DrawingArea in the vectorized backends#30171

Open
Mr-Milk wants to merge 1 commit intomatplotlib:mainfrom
Mr-Milk:fix-drawingarea
Open

Fix the misplacement of rasterized object in DrawingArea in the vectorized backends#30171
Mr-Milk wants to merge 1 commit intomatplotlib:mainfrom
Mr-Milk:fix-drawingarea

Conversation

@Mr-Milk
Copy link
Copy Markdown
Contributor

@Mr-Milk Mr-Milk commented Jun 13, 2025

PR summary

This PR is trying to fix a bug when rendering rasterized Artists in DrawingArea, they will be misplaced in the vectorized backend. closes #28549

The fix is done by enforcing correct DPI transformation during rasterization, specifically in the vectorized backends

There is no API change or documentation change associated with this PR.

PR checklist

@Mr-Milk Mr-Milk changed the title Enhance the renderer support of the DrawingArea class by adding DPI correction for rasterized content Fix the misplacement of rasterized object in DrawingArea in the vectorized backends Jun 13, 2025
@Mr-Milk Mr-Milk marked this pull request as ready for review June 13, 2025 12:40
@Mr-Milk
Copy link
Copy Markdown
Contributor Author

Mr-Milk commented Jun 17, 2025

Hi all, just a gentle follow-up on this PR. I'd appreciate any feedback when time permits. Let me know if there's anything I can improve or clarify. Thanks!

Copy link
Copy Markdown
Member

@timhoffm timhoffm left a comment

Choose a reason for hiding this comment

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

Sorry, review capacity is currently quite limited.

Do you have an idea how to test this?

@Mr-Milk
Copy link
Copy Markdown
Contributor Author

Mr-Milk commented Jun 18, 2025

Thanks for the feedback! I think we can test this using image comparison—by generating a target image and validating output against it.

On a related note, I'm not entirely confident that my current method for checking whether the renderer is a vectorized backend is the most robust or appropriate approach. I'd really appreciate any input or suggestions on improving that part of the implementation.

Comment on lines +50 to +52
while type(actual_renderer).__name__ == 'MixedModeRenderer':
actual_renderer = getattr(actual_renderer, '_renderer', actual_renderer)
renderer_name = type(actual_renderer).__name__
Copy link
Copy Markdown
Member

@timhoffm timhoffm Jun 18, 2025

Choose a reason for hiding this comment

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

This approach feels quite hacky. The functionality should live in a method MxedModeRenderer.is_vector_renderer(). Possibly that function should be made a core feature Renderer so that the whole _is_vector_renderer can go away. Though I'm not sure whether there are implications for third party renders. @anntzer what do you as a backend implementer think?

@anntzer
Copy link
Copy Markdown
Contributor

anntzer commented Jun 18, 2025

I suspect whether the backend has vector output is not actually the right test here; indeed, mplcairo doesn't exhibit the #28549 bug even for vector output. Rather, the bug likely resides either in backend_{pdf,ps,svg}, or, more likely (just my guess), in MixedModeRenderer.
Therefore, I think I'd prefer if we could figure out what's wrong in MixedModeRenderer (if anything) and directly fix it there, or, if not, at least just directly test whether the renderer is a MixedModeRenderer (_is_mixedmode_renderer) and run the extra code in that case.

Copilot AI review requested due to automatic review settings April 8, 2026 21:09
Copy link
Copy Markdown
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

Fixes a rendering bug in Matplotlib’s mixed-mode (vector + raster) backends where rasterized artists embedded in OffsetBox containers (notably DrawingArea / AuxTransformBox) can be drawn at an incorrect location due to a DPI-space mismatch when switching to the raster renderer. This targets the misplacement reported in #28549 for PDF/SVG/PS outputs.

Changes:

  • Adjust DrawingArea.draw() and AuxTransformBox.draw() to rescale translation/DPI transforms when drawing rasterized children under MixedModeRenderer.
  • Add MixedModeRenderer.get_image_magnification() to expose a vector→raster scaling factor.
  • Add regression tests comparing rasterized vs non-rasterized output for DrawingArea and AuxTransformBox across pdf/svg/png.

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 6 comments.

File Description
lib/matplotlib/offsetbox.py Applies DPI/magnification-aware transform scaling for rasterized children in DrawingArea and AuxTransformBox under mixed-mode rendering.
lib/matplotlib/backends/backend_mixed.py Adds an explicit magnification accessor intended to convert vector display units to raster pixel units in mixed-mode.
lib/matplotlib/tests/test_offsetbox.py Adds figure-equality regression tests for #28549 (and also adds an unrelated borderpad tuple test).

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines 476 to 510
def test_anchored_offsetbox_tuple_and_float_borderpad():
"""
Test AnchoredOffsetbox correctly handles both float and tuple for borderpad.
"""

fig, ax = plt.subplots()

# Case 1: Establish a baseline with float value
text_float = AnchoredText("float", loc='lower left', borderpad=5)
ax.add_artist(text_float)

# Case 2: Test that a symmetric tuple gives the exact same result.
text_tuple_equal = AnchoredText("tuple", loc='lower left', borderpad=(5, 5))
ax.add_artist(text_tuple_equal)

# Case 3: Test that an asymmetric tuple with different values works as expected.
text_tuple_asym = AnchoredText("tuple_asym", loc='lower left', borderpad=(10, 4))
ax.add_artist(text_tuple_asym)

# Draw the canvas to calculate final positions
fig.canvas.draw()

pos_float = text_float.get_window_extent()
pos_tuple_equal = text_tuple_equal.get_window_extent()
pos_tuple_asym = text_tuple_asym.get_window_extent()

# Assertion 1: Prove that borderpad=5 is identical to borderpad=(5, 5).
assert pos_tuple_equal.x0 == pos_float.x0
assert pos_tuple_equal.y0 == pos_float.y0

# Assertion 2: Prove that the asymmetric padding moved the box
# further from the origin than the baseline in the x-direction and less far
# in the y-direction.
assert pos_tuple_asym.x0 > pos_float.x0
assert pos_tuple_asym.y0 < pos_float.y0
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

borderpad is documented and used as a scalar in AnchoredOffsetbox.get_offset (it’s multiplied by a float). Passing a tuple here will raise TypeError (tuple * float). Either drop the tuple case from this test, or update AnchoredOffsetbox/AnchoredText to explicitly accept (xpad, ypad) and apply it consistently in both x/y directions.

Copilot uses AI. Check for mistakes.
Comment on lines +717 to +720
c.draw(renderer)
self.dpi_transform.clear()
self.dpi_transform.scale(dpi_cor)
self.offset_transform.set_matrix(off_mat)
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

This code mutates dpi_transform and offset_transform and then restores them, but restoration isn’t protected against exceptions. If c.draw(renderer) raises, the transforms remain scaled and can corrupt subsequent draws/layout. Wrap the temporary scaling/restoration in a try/finally to guarantee state restoration.

Suggested change
c.draw(renderer)
self.dpi_transform.clear()
self.dpi_transform.scale(dpi_cor)
self.offset_transform.set_matrix(off_mat)
try:
c.draw(renderer)
finally:
self.dpi_transform.clear()
self.dpi_transform.scale(dpi_cor)
self.offset_transform.set_matrix(off_mat)

Copilot uses AI. Check for mistakes.
Comment on lines +701 to +716
if c.get_rasterized() and isinstance(renderer, MixedModeRenderer):
# When using MixedModeRenderer (PDF/SVG/PS), the figure DPI is
# set to the vector renderer's DPI (typically 72 pt/inch), so
# dpi_cor ≈ 1.0. But rasterized children are drawn into a
# RendererAgg buffer at renderer.dpi (the user's requested DPI).
# The child's transform is dpi_transform + offset_transform,
# where offset_transform is a translation in vector-DPI display
# units. Both must be scaled to raster-DPI pixel coordinates.
mag = renderer.get_image_magnification()
raster_dpi_cor = dpi_cor * mag
off_mat = self.offset_transform.get_matrix().copy()
scaled_off = off_mat.copy()
scaled_off[:2, 2] *= mag # scale tx and ty to raster DPI
self.dpi_transform.clear()
self.dpi_transform.scale(raster_dpi_cor)
self.offset_transform.set_matrix(scaled_off)
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

The scaling branch should be skipped when the renderer is already rasterizing (e.g., this DrawingArea is inside a rasterized parent). In that case points_to_pixels is already in raster-DPI units, so multiplying by mag again will over-scale (dpi factors can effectively be applied twice). Consider guarding with and not renderer._rasterizing (and/or renderer._raster_depth == 0) so the correction only applies when entering rasterization from vector mode.

Copilot uses AI. Check for mistakes.
from matplotlib.patches import (
FancyBboxPatch, FancyArrowPatch, bbox_artist as mbbox_artist)
from matplotlib.transforms import Bbox, BboxBase, TransformedBbox
from matplotlib.backends.backend_mixed import MixedModeRenderer
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

Importing MixedModeRenderer at module import time pulls in backend code (backend_mixed -> backend_agg) from a core artist module, which can increase import time and risks circular-import issues. Prefer avoiding the hard import (e.g., use a local import inside the rasterization branch, or switch to a capability/attribute check rather than isinstance(renderer, MixedModeRenderer)).

Suggested change
from matplotlib.backends.backend_mixed import MixedModeRenderer

Copilot uses AI. Check for mistakes.
Comment on lines +936 to +938
c.draw(renderer)
self.offset_transform.set_matrix(off_mat)
self.ref_offset_transform.set_matrix(ref_mat)
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

Same as in DrawingArea.draw: temporary mutation of offset_transform/ref_offset_transform should be protected with try/finally so state is restored even if c.draw(renderer) errors.

Suggested change
c.draw(renderer)
self.offset_transform.set_matrix(off_mat)
self.ref_offset_transform.set_matrix(ref_mat)
try:
c.draw(renderer)
finally:
self.offset_transform.set_matrix(off_mat)
self.ref_offset_transform.set_matrix(ref_mat)

Copilot uses AI. Check for mistakes.
Comment on lines +93 to +98
When using `.MixedModeRenderer`, the figure DPI is set to the vector
renderer's DPI (typically 72 for PDF/SVG/PS) but rasterized content
is drawn at ``self.dpi`` (the user's requested DPI). This factor
converts from vector-space display coordinates to raster-space pixel
coordinates.
"""
Copy link

Copilot AI Apr 8, 2026

Choose a reason for hiding this comment

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

MixedModeRenderer.get_image_magnification currently returns self.dpi / self._figdpi unconditionally. When MixedModeRenderer is in raster mode (self._renderer is the Agg raster renderer), callers typically expect magnification to match the active raster renderer (usually 1.0) to avoid unintended extra resampling/memory use for images drawn while rasterizing. Consider returning 1.0 (or delegating to the active renderer) when rasterizing, and only returning dpi/_figdpi when in vector mode.

Suggested change
When using `.MixedModeRenderer`, the figure DPI is set to the vector
renderer's DPI (typically 72 for PDF/SVG/PS) but rasterized content
is drawn at ``self.dpi`` (the user's requested DPI). This factor
converts from vector-space display coordinates to raster-space pixel
coordinates.
"""
In vector mode, `.MixedModeRenderer` converts from the figure DPI
(typically 72 for PDF/SVG/PS) to ``self.dpi`` (the user's requested
DPI). When rasterizing, defer to the active raster renderer so image
drawing uses that renderer's native magnification.
"""
if self._raster_renderer is not None and self._renderer is self._raster_renderer:
return self._renderer.get_image_magnification()

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[Bug]: Rasterized Artists in DrawingArea will be misplaced in the vectorized backend

4 participants