Fix the misplacement of rasterized object in DrawingArea in the vectorized backends#30171
Fix the misplacement of rasterized object in DrawingArea in the vectorized backends#30171Mr-Milk wants to merge 1 commit intomatplotlib:mainfrom
DrawingArea in the vectorized backends#30171Conversation
DrawingArea in the vectorized backends
|
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! |
timhoffm
left a comment
There was a problem hiding this comment.
Sorry, review capacity is currently quite limited.
Do you have an idea how to test this?
|
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. |
lib/matplotlib/offsetbox.py
Outdated
| while type(actual_renderer).__name__ == 'MixedModeRenderer': | ||
| actual_renderer = getattr(actual_renderer, '_renderer', actual_renderer) | ||
| renderer_name = type(actual_renderer).__name__ |
There was a problem hiding this comment.
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?
|
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. |
There was a problem hiding this comment.
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()andAuxTransformBox.draw()to rescale translation/DPI transforms when drawing rasterized children underMixedModeRenderer. - Add
MixedModeRenderer.get_image_magnification()to expose a vector→raster scaling factor. - Add regression tests comparing rasterized vs non-rasterized output for
DrawingAreaandAuxTransformBoxacrosspdf/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.
| 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 |
There was a problem hiding this comment.
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.
| c.draw(renderer) | ||
| self.dpi_transform.clear() | ||
| self.dpi_transform.scale(dpi_cor) | ||
| self.offset_transform.set_matrix(off_mat) |
There was a problem hiding this comment.
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.
| 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) |
| 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) |
There was a problem hiding this comment.
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.
| 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 |
There was a problem hiding this comment.
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)).
| from matplotlib.backends.backend_mixed import MixedModeRenderer |
| c.draw(renderer) | ||
| self.offset_transform.set_matrix(off_mat) | ||
| self.ref_offset_transform.set_matrix(ref_mat) |
There was a problem hiding this comment.
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.
| 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) |
| 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. | ||
| """ |
There was a problem hiding this comment.
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.
| 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() |
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 #28549The 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