Skip to content

Scale bar: explicit raster controls + renderer-aware DPI#17

Open
cvanelteren wants to merge 5 commits intomoss-xyz:mainfrom
cvanelteren:fix/scalebar-raster-controls
Open

Scale bar: explicit raster controls + renderer-aware DPI#17
cvanelteren wants to merge 5 commits intomoss-xyz:mainfrom
cvanelteren:fix/scalebar-raster-controls

Conversation

@cvanelteren
Copy link

Summary: Make scale bar raster rendering quality explicit and less sensitive to external rc/style state. Changes: set OffsetImage kwargs explicitly in scale_bar (interpolation default none, dpi_cor default True, resample default False); add bar-level raster controls (raster_dpi and raster_dpi_scale); in ScaleBar.draw(renderer, ...) default raster_dpi to renderer.dpi when not set so exports stay sharp when savefig dpi differs from figure construction dpi; add validation and defaults for these keys. Why: the existing path rasterizes to figure dpi and can blur when save-time dpi differs or when interpolation is inherited from global rc/style.

@moss-xyz moss-xyz self-assigned this Feb 21, 2026
@moss-xyz
Copy link
Owner

#16

@moss-xyz
Copy link
Owner

@cvanelteren I appreciate you taking a stab at this - and sorry you had to follow my janky conventions for doing type checking and setting up defaults 😅 next time I'm unemployed I'm hoping to switch to a better type checking set up.

I've got a few comments though:

First, re: your last commit: I don't want to bypass rasterization for unrotated scale bars. I'm worried about creating inconsistent experiences, and unintentionally making the package more difficult to use later down the road (users would have to implicitly know to bypass rasterisation in these types of edge cases).

Second, re: the general approach of modifying the dpi of the temp figure that was created: I'm not sure this is actually solving the issue 🤔 regardless of what number I put in for the raster_dpi setting, the text turns out to be fuzzy, while the bar renders crisply. This even happens with absurdly low dpi levels like 10 - but only when the subplots are set up with Ultraplot. When I do the same with classic matplotlib plots, the effect is as expected.

Are you sure there isn't something else that Ultraplot is doing during the rendering pipeline that is causing the text to appear fuzzy, while the bar artists render clearly?

I saw in your last comment as well that you said you could get the text to render clearly when you used an AnchoredText offsetbox - was that in place of the Text objects I am currently using to generate labels, or somewhere else?

@cvanelteren
Copy link
Author

The issue is that we use a different font by default (Tex Gyre) if you use that font and set the interpolation to nearest it looks fuzzy regardless of the settings. So in essence we do two things: set the default interpolation to None and set the default font to a different settings. Other than that I don't thing there is a thing that is different.

@cvanelteren
Copy link
Author

I will look around a bit more perhaps I missed something.

@moss-xyz
Copy link
Owner

I'm not sure your explanation of font family makes sense to me? Here's an illustration of my point:

When I'm using matplotlib instead, the scale bar text rasterizes as expected, pretty clear:

image

However I can set raster_dpi to something absurdly low, like 30, and both the text and the bar are impacted (here, interpolation is set to none)

image

I took this to mean that your edits worked as I expected - but I don't get the same result when using Ultraplot in place of Matplotlib

This is how the scale bar renders normally:

image

This is what the scale bar looks like when raster_dpi is set to 30

image

No, I didn't accidentally copy-and-paste the same image twice, that's two different outputs!

This is weird to me on two levels:

  • Why do the text and bar appear to have two different DPIs? The bar renders so crisply compared to the text...
  • Why does the overall look of the scale bar artists (text and bars) not change when raster_dpi is changed? It does on the matplotlib example...

That's why I'm asking if there's something odd about the rendering pipeline when Ultraplot is used instead of Matplotlib 🤔

Or, let me know if you don't get the same result? My code is below

Expand to view
from matplotlib_map_utils.core.scale_bar import ScaleBar, scale_bar
import matplotlib.pyplot as plt
import ultraplot as uplt
import cartopy.crs as ccrs


bounds = [-122.3094136,   41.02776967, -121.65305008,   41.61986174]
c_lon = (bounds[0] + bounds[2]) / 2
c_lat = (bounds[1] + bounds[3]) / 2
plot_crs = ccrs.LambertConformal(central_longitude=c_lon, central_latitude=c_lat)

# try using uplt vs plt
fig, ax = uplt.subplots(projection=plot_crs, dpi=150, figsize=(5,5))
ax.set_extent((bounds[0], bounds[2], bounds[1], bounds[3]), crs=ccrs.PlateCarree())
# change raster_dpi to different values (higher, lower) and compare outputs
scale_bar(ax, bar={"projection": plot_crs, "raster_dpi":150, "interpolation":"none"}, text={"fontsize":20})
# fig.savefig('./ultraplot_test.png', dpi=150)

@cvanelteren
Copy link
Author

That's why I'm asking if there's something odd about the rendering pipeline when Ultraplot is used instead of Matplotlib

We do set the dpi by default to 1k, and add some stuff to the drawing, but from my testing this does not influence this issue.

Is the frame on the matplotlib end in the same space or is there another axis created on which the scalebar is plotted?

@moss-xyz
Copy link
Owner

Hmmm if by frame you mean figure (?), then yes - the scalebar is created on a separate figure and axis

Basically when passing an ax to the scale_bar() function, I create a temporary clone of that figure manually using _temp_figure() (which in turn calls plt.subplots(), which returns fig_temp and ax_temp. Each artist (Text, DrawingArea/Rectangle, all the Packers, and a final AnchoredOffsetbox) are then created and ultimately passed to ax_temp via _render_as_image(), which calls, in order:

  • ax.add_artist()
  • fig.draw_without_rendering()
  • FigureCanvasAgg(fig).draw()

This pipeline was what I figured out I needed to do in order to place the artists and then render it, such that I could then load the image back into Pillow and return it for placement with OffsetImage on the original ax.

Do you think there could be an issue with my use of FigureCanvasAgg()? Or the fact that I am using plt.subplots() to make the temp fig/ax instead of uplt.subplots()?

@cvanelteren
Copy link
Author

I see. That should not be happening. I will investigate on my end and report back.

@cvanelteren
Copy link
Author

Ah ok I think I tracked the issue. The DPI mismatch was a nice clue. In _render_as_image the process is doing coarseley:
1. fig.draw_without_rendering()
2. FigureCanvasAgg(fig).draw()
On UltraPlot figures, step (1) went through Matplotlib’s print-style renderer path and left the temp figure at 72 dpi (points/inch space), so step (2) rasterized from that state.

@cvanelteren
Copy link
Author

I updated the PR to fix it
image

@cvanelteren cvanelteren force-pushed the fix/scalebar-raster-controls branch from 3cd220d to 8aba1bb Compare February 22, 2026 09:33
@cvanelteren
Copy link
Author

Note I am also fixing the issue on our end.

@cvanelteren
Copy link
Author

merged: Ultraplot/UltraPlot#591

Comment on lines +259 to +274
# For the default function mode, dispatch to the Artist class so final
# rasterization happens at draw-time with the active renderer dpi.
if draw == True and return_aob == True:
_ = ax.add_artist(
ScaleBar(
style=style,
location=location,
bar=bar,
units=units,
labels=labels,
text=text,
aob=aob,
zorder=zorder,
)
)
return
Copy link
Owner

Choose a reason for hiding this comment

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

@cvanelteren can you talk me through why this is necessary?

Am I understanding correctly that it is because you want to re-using lines 210-212, but it looks to me like that logic is already repeated in lines 298-302, no?

Copy link
Author

Choose a reason for hiding this comment

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

You’re right that the old code looked duplicated: one place used renderer.dpi in ScaleBar.draw(), and another defaulted to figure.dpi inside scale_bar(). The reason both existed is that they serve different call contexts (draw-time artist render vs direct function render), but the intent is the same: resolve raster DPI consistently. I refactored that into a shared _resolve_raster_dpi(...) helper so both paths now use the same logic, with renderer DPI preferred when available and figure DPI as fallback, then applying raster_dpi_scale in one place. This keeps behavior unchanged but makes the control flow clearer and easier to reason about.

Copy link
Owner

Choose a reason for hiding this comment

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

That's a brilliant change, thank you.

I'll test the changes on my end tomorrow, including with v2.1 of Ultraplot, but I'm curious - what is the difference between draw-time artist render versus direct function render?

I want to make sure I understand why you dispatch to ScaleBar from within scale_bar(), which feels inelegant to me: the code flow basically becomes scale_bar(draw=True) -> ScaleBar -> scale_bar(draw=False) (which then returns to ScaleBar for final rendering). Why is this a better way to go about it, rather than just allowing scale_bar() to finish the render itself?

(Note: some of this is my fault; the return_aob setting is a bit jank actually, definitely something I would look to fix in the next version)

Copy link
Author

@cvanelteren cvanelteren Feb 26, 2026

Choose a reason for hiding this comment

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

If scale_bar() “finishes” immediately, it rasterizes earlier using figure-time context, not guaranteed final render context. For this module, that matters because it explicitly rasterizes to an image (_render_as_image + OffsetImage) at lines 503-524. The chain right now, however, is a bit inelegant but it narrows the construction path plan and renderer-correct output. It would be good to split this into explicit apis, but that I leave that up to you.

Copy link
Owner

Choose a reason for hiding this comment

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

Ah wait, does that mean if a user initialises the figure with a DPI of 150, but then decides to render it via savefig or something with a DPI of 300, it will automatically upscale it to a DPI of 300?

If so that actually solves a problem that was identified in an issue earlier: #7

I would love to do a better job of splitting out these portions of the API in the next version - and it seems like I should do so by simply inverting my call stack, so that scale_bar() actually calls ScaleBar and immediately attaches it to the passed ax, as you do here, and then I have a separate function that handles the construction of the scale bar itself (called, in turn, by ScaleBar)!

Copy link
Author

Choose a reason for hiding this comment

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

That sounds like a good idea I think.

@moss-xyz
Copy link
Owner

moss-xyz commented Feb 24, 2026

@cvanelteren I think we're getting close, thank you for your help so far

I've left one comment specific to some lines of code you've added.

I also see you removed fig.draw_without_rendering(), which I was under the impression was required in order to ensure everything was placed correctly. Do you know why my understanding might have been incorrect?

Finally... how much of this PR even necessary anymore, or were the only fixes that needed to happen on the Ultraplot side of things? I feel like the dpi and interpolation settings we added might have been due to a red herring as to what was causing this issue...

Comparing the original package version to this branch, I definitely see a difference in how things are rendered, but I'm only on Ultraplot v2.0.1, which I'm not sure has the changes you just made

@cvanelteren
Copy link
Author

Finally... how much of this PR even necessary anymore, or were the only fixes that needed to happen on the Ultraplot side of things? I feel like the dpi and interpolation settings we added might have been due to a red herring as to what was causing this issue...

The extra stuff are nices to haves but not essential. I think it would still be good to include. The defaults can be left the same as you had them but it prevents potential leakage issues like we introduced on our end (and potential other packages that have a similar issue).

Comparing the original package version to this branch, I definitely see a difference in how things are rendered, but I'm only on Ultraplot v2.0.1, which I'm not sure has the changes you just made

The changes are on main, haven't drafted a release yet for the fix (so it's not available through pypi yet). Quickest would be to install the git directly (or clone and run pip install -e .)

@cvanelteren
Copy link
Author

The dpi fix is live on 2.1 now. Available on pypi and conda-forge.

@moss-xyz
Copy link
Owner

I did a bit of initial testing today - but I'll do the final tests and hopefully close out this PR tomorrow!

@cvanelteren
Copy link
Author

No rush ;-)

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.

2 participants