Skip to content

Commit 8fc591d

Browse files
authored
feat: decouple Matplotlib render resolution (DPI) from display size (#9144)
## 📝 Summary Closes #9124 This PR decouples the internal rendering resolution (DPI) of Matplotlib figures from their display size in the marimo notebook. The fundamental principle is that DPI should control image quality (sharpness), not the physical display size. Previously, increasing `fig.dpi` to achieve a sharper image caused the plot to expand linearly, often breaking the notebook layout. This change ensures that figures maintain a consistent display size based on a 100 DPI reference (Matplotlib's default), regardless of the rendering DPI. ## Changes - **Simplified Rendering Logic**: Removed the hardcoded "retina" logic (`*2` DPI and `//2` dimensions). - **Dynamic Scaling**: The rendering now respects the figure's actual DPI setting. - **Metadata Normalization**: Display dimensions (width/height) sent to the frontend are now scaled by `dpi / 100`. This ensures that a figure with a given `figsize` occupies the same space in the notebook even if the DPI is increased for higher quality. - **Enhanced Testing**: Added parameterized tests in `tests/_output/formatters/test_matplotlib.py` to verify that: 1. The actual PNG pixel count increases with DPI. 2. The metadata display size remains consistent regardless of DPI. ## Verification The following image demonstrates the fix: <img width="400" src="https://github.com/user-attachments/assets/4cacf0f4-f94a-4f9f-9926-2ab84d27f832" /> - **Top**: `fig.dpi = 20` (Low resolution, correct display size) - **Middle**: `fig.dpi = 300` (High resolution, **same display size**) - **Bottom**: `savefig.format = "svg"` (Consistent behavior with SVG) As shown, the display size remains stable even when the DPI is changed significantly, allowing users to control image quality without affecting the notebook layout. ## 📋 Pre-Review Checklist <!-- These checks need to be completed before a PR is reviewed --> - [x] For large changes, or changes that affect the public API: this change was discussed or approved through an issue, on [Discord](https://marimo.io/discord?ref=pr), or the community [discussions](https://github.com/marimo-team/marimo/discussions) (Please provide a link if applicable). - [ ] Any AI generated code has been reviewed line-by-line by the human PR author, who stands by it. - [x] Video or media evidence is provided for any visual changes (optional). <!-- PR is more likely to be merged if evidence is provided for changes made --> ## ✅ Merge Checklist - [x] I have read the [contributor guidelines](https://github.com/marimo-team/marimo/blob/main/CONTRIBUTING.md). - [ ] Documentation has been updated where applicable, including docstrings for API changes. - [x] Tests have been added for the changes made.
1 parent b95d26d commit 8fc591d

3 files changed

Lines changed: 40 additions & 41 deletions

File tree

frontend/src/components/editor/Output.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -263,7 +263,7 @@ const MimeBundleOutputRenderer: React.FC<{
263263
const { mode } = useAtomValue(viewStateAtom);
264264
const appView = mode === "present" || mode === "read";
265265

266-
// Extract metadata if present (e.g., for retina image rendering)
266+
// Extract metadata if present (e.g., to maintain a constant display size regardless of DPI/PPI)
267267
const metadata = mimebundle[METADATA_KEY];
268268

269269
// Filter out metadata from the mime entries and type narrow

marimo/_output/mpl.py

Lines changed: 7 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -83,11 +83,8 @@ def _render_figure_mimebundle(
8383
data_url = build_data_url(mimetype="image/svg+xml", data=plot_bytes)
8484
return "image/svg+xml", data_url
8585

86-
# Get current DPI and double it for retina display (like Jupyter)
87-
original_dpi = fig.figure.dpi # type: ignore[attr-defined]
88-
retina_dpi = original_dpi * 2
89-
90-
fig.figure.savefig(buf, format="png", bbox_inches="tight", dpi=retina_dpi) # type: ignore[attr-defined]
86+
dpi = fig.figure.dpi
87+
fig.figure.savefig(buf, format="png", bbox_inches="tight", dpi=dpi) # type: ignore[attr-defined]
9188

9289
png_bytes = buf.getvalue()
9390
plot_bytes = base64.b64encode(png_bytes)
@@ -98,12 +95,15 @@ def _render_figure_mimebundle(
9895
try:
9996
# Extract dimensions from the PNG
10097
width, height = _extract_png_dimensions(png_bytes)
98+
# Normalize to a fixed 100 DPI reference for consistent display size
99+
# https://matplotlib.org/stable/api/_as_gen/matplotlib.figure.Figure.html
100+
factor = dpi / 100
101101
mimebundle = {
102102
"image/png": data_url,
103103
METADATA_KEY: {
104104
"image/png": {
105-
"width": width // 2,
106-
"height": height // 2,
105+
"width": round(width / factor),
106+
"height": round(height / factor),
107107
}
108108
},
109109
}

tests/_output/formatters/test_matplotlib.py

Lines changed: 32 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -84,23 +84,25 @@ def _extract_png_dimensions(data_url: str) -> tuple[int, int]:
8484

8585

8686
@pytest.mark.skipif(not HAS_MPL, reason="optional dependencies not installed")
87-
async def test_matplotlib_retina_rendering(
88-
executing_kernel: Kernel, exec_req: ExecReqProvider
87+
@pytest.mark.parametrize("dpi", [72, 300])
88+
async def test_matplotlib_image_resolution_respects_dpi(
89+
executing_kernel: Kernel,
90+
exec_req: ExecReqProvider,
91+
dpi: int,
8992
) -> None:
90-
"""Test that matplotlib figures are rendered at 2x DPI for retina displays."""
93+
"""Test that the actual image resolution (pixels) scales with DPI."""
9194
from marimo._output.formatters.formatters import register_formatters
9295

9396
register_formatters(theme="light")
9497

9598
await executing_kernel.run(
9699
[
97100
exec_req.get(
98-
"""
101+
f"""
99102
import matplotlib.pyplot as plt
100103
101-
# Create a simple figure
102-
fig, ax = plt.subplots(figsize=(4, 3))
103-
ax.plot([1, 2, 3], [1, 2, 3])
104+
# Create an empty figure (no content) to isolate DPI effects
105+
fig = plt.figure(figsize=(4, 3), dpi={dpi})
104106
105107
# Get the formatted output
106108
result = fig._mime_()
@@ -124,12 +126,13 @@ async def test_matplotlib_retina_rendering(
124126
png_data_url = mimebundle["image/png"]
125127
width, height = _extract_png_dimensions(png_data_url)
126128

127-
# Verify it's rendering at high DPI (should be significantly larger than
128-
# the base figsize in pixels). At 2x DPI, a 4x3 inch figure should be
129-
# at least 500x400 pixels (allowing for different base DPI values)
130-
# The exact value depends on matplotlib's default DPI (can be 72, 90, 100, etc.)
131-
assert width >= 500, f"Expected high-res width (>500px), got {width}"
132-
assert height >= 350, f"Expected high-res height (>350px), got {height}"
129+
# https://matplotlib.org/stable/api/_as_gen/matplotlib.figure.Figure.savefig.html
130+
pad_inches = 0.1
131+
calc_width = round((4 + 2 * pad_inches) * dpi)
132+
calc_height = round((3 + 2 * pad_inches) * dpi)
133+
134+
assert calc_width - 5 < width < calc_width + 5
135+
assert calc_height - 5 < height < calc_height + 5
133136

134137
# Verify aspect ratio is preserved (4:3 ratio)
135138
aspect_ratio = width / height
@@ -140,23 +143,23 @@ async def test_matplotlib_retina_rendering(
140143

141144

142145
@pytest.mark.skipif(not HAS_MPL, reason="optional dependencies not installed")
143-
async def test_matplotlib_retina_metadata(
144-
executing_kernel: Kernel, exec_req: ExecReqProvider
146+
@pytest.mark.parametrize("dpi", [72, 300])
147+
async def test_matplotlib_display_size_remains_constant(
148+
executing_kernel: Kernel, exec_req: ExecReqProvider, dpi: int
145149
) -> None:
146-
"""Test that matplotlib figures include proper width/height metadata."""
150+
"""Test that the display size in the notebook remains constant even if DPI changes."""
147151
from marimo._output.formatters.formatters import register_formatters
148152

149153
register_formatters(theme="light")
150154

151155
await executing_kernel.run(
152156
[
153157
exec_req.get(
154-
"""
158+
f"""
155159
import matplotlib.pyplot as plt
156160
157-
# Create a simple figure
158-
fig, ax = plt.subplots(figsize=(4, 3))
159-
ax.plot([1, 2, 3], [1, 2, 3])
161+
# Create an empty figure (no content) to isolate DPI effects
162+
fig = plt.figure(figsize=(4, 3), dpi={dpi})
160163
result = fig._mime_()
161164
"""
162165
)
@@ -177,32 +180,28 @@ async def test_matplotlib_retina_metadata(
177180
"Metadata should include image/png dimensions"
178181
)
179182

180-
# Extract actual PNG dimensions
181-
png_data_url = mimebundle_data["image/png"]
182-
actual_width, actual_height = _extract_png_dimensions(png_data_url)
183-
184-
# Metadata dimensions should be half of actual (for retina display)
183+
# Metadata dimensions should be figsize (4x3 inches) in 100 DPI.
185184
png_metadata = metadata["image/png"]
186185
assert "width" in png_metadata
187186
assert "height" in png_metadata
188187

189188
metadata_width = png_metadata["width"]
190189
metadata_height = png_metadata["height"]
191190

192-
# Metadata should be approximately half the actual PNG dimensions
193-
assert abs(metadata_width - actual_width // 2) <= 2, (
194-
f"Metadata width {metadata_width} should be ~half of actual {actual_width}"
195-
)
196-
assert abs(metadata_height - actual_height // 2) <= 2, (
197-
f"Metadata height {metadata_height} should be ~half of actual {actual_height}"
198-
)
191+
# https://matplotlib.org/stable/api/_as_gen/matplotlib.figure.Figure.savefig.html
192+
pad_inches = 0.1
193+
calc_width = round((4 + 2 * pad_inches) * 100)
194+
calc_height = round((3 + 2 * pad_inches) * 100)
195+
196+
assert calc_width - 5 < metadata_width < calc_width + 5
197+
assert calc_height - 5 < metadata_height < calc_height + 5
199198

200199

201200
@pytest.mark.skipif(not HAS_MPL, reason="optional dependencies not installed")
202201
async def test_matplotlib_backwards_compatibility(
203202
executing_kernel: Kernel, exec_req: ExecReqProvider
204203
) -> None:
205-
"""Test that existing matplotlib code still works with retina rendering."""
204+
"""Test that existing matplotlib code still works with the new DPI rendering logic."""
206205
from marimo._output.formatters.formatters import register_formatters
207206

208207
register_formatters(theme="light")

0 commit comments

Comments
 (0)