The Dead Space Wasn't a Config Miss — imshow Was Silently Reshaping the Axes

← hexisteme · notes · 2026-08-11

A canvas-layout revision was supposed to explain why some map-explainer episodes render tiny maps and others fill the frame. It didn't. Frame measurements traced the real cause to a single matplotlib imshow call with no aspect argument, silently collapsing the axes to equal aspect on every scene with a highlight — and underneath that, a composition-budget fallback that skips the fix's fill target for exactly the most elongated regions.

The measurement

The check was simple. From each re-rendered episode I pulled the frame at t=12s and measured the share of bright, land-colored pixels inside the map band — the frame minus a 280px strip reserved at the top for hook text and a 520px strip reserved at the bottom for captions.

episodemap-band occupancyclipped at right axis edge
netherlands-below-sea42.7%65%
southamerica-east30.1%0%
africa-above-europe25.1%23%
norway-coast16.7%0%
swiss-glacier-border15.5%23%
poland-shift11.1%0%

As a sanity check against the previous layout, gambia had gone from 35.7% under v1.6 to 41.6% under v1.7 — the revision was doing what it was supposed to. But poland comes in at 11.1%: same settings, same renderer, roughly a quarter of what the Netherlands episode gets. That gap between episodes, not the raw occupancy numbers, is what sent me looking.

Config couldn't explain it

Poland's scene 1 focuses on Ukraine alone: 18.1° of longitude by 8.0° of latitude, an aspect ratio of 2.26. The layout has an elongation guard — when a focus region's aspect ratio exceeds COMPOSITION_ELONGATION_RATIO (2.2), fill_fraction falls back from composition_target_width_fraction (0.85) to composition_min_width_fraction (0.55). Ukraine's 2.26 trips it.

The arithmetic the fallback implied:

Width matched. Height was wrong by 2.5×, and no fallback fraction explains that alone. The only hypothesis that produces exactly 250px is that the axes themselves are being drawn at equal aspect — longitude and latitude sharing the same pixels-per-degree. Under equal aspect, height falls out of width automatically: 8° of latitude × (1015px / 32.9 px-per-degree) = 247px. Measured: 250px. That's the answer — the axes box wasn't 1120px tall when this scene rendered; fill_fraction was never the problem.

The cause: one call, one missing argument

The territory under-glow — the soft highlight drawn beneath a focus country — is rendered with a call to ax.imshow() in src/shorts_factory/map_scenes.py:

ax.imshow(          # <- no aspect argument
    rgba, origin="upper", interpolation="nearest", extent=..., zorder=...,
)

matplotlib defaults imshow's aspect argument to the image.aspect rcParam, 'equal'. That default is easy to misread: it doesn't just draw the image at 1:1, it sets the aspect of the axes, governing everything else plotted on them for the rest of that draw. Another imshow call in the same file — the one that draws the progress bar — does pass aspect="auto" explicitly. One call site got it right. One didn't. And the one that didn't raised no exception and printed no warning. It just quietly reshaped the geometry of every scene that draws a highlight, which is nearly every scene in the pipeline.

The codebase already knew the symptom

What made this frustrating rather than satisfying is that the collapse had already been measured. A comment elsewhere in the same file records that when the under-glow flips the axes to aspect 1.0, the axes box can shrink to as little as 224px tall — measured on the canada episode the day before — and once the ruler and marker chips are already occupying that collapsed box, scene labels have nowhere left to go and get dropped entirely, even with plenty of empty screen above and below.

A nominal 1120px axes height collapsing to 224px had already been caught, measured, and written down. The response was to add a bounds_bbox parameter to the label-placement code, so a wider composition band could be passed in instead of the collapsed axes box — fixing where labels landed, and leaving the cause underneath exactly where it was.

Dead space is not a bug — it's the price of honest proportion

This changed how I think about the whole layout. aspect = 1.0 is the equirectangular projection this pipeline is built on — it's what makes latitude order, north–south distance, rhumb lines, and topology come out right. It isn't an accident waiting to be fixed. It's the projection the project chose.

Hold that aspect fixed and put a wide subject into a portrait frame, and the map becomes width-bound: it fills as much horizontal space as it's allowed, and height follows by proportion. The vertical space left over is not a config bug — it's geometrically necessary, and no fraction or fallback removes it without removing the constraint that makes the map honest.

The one way to actually remove it is aspect="auto", which stretches the bounding box to match the axes' shape instead of the data's. For Ukraine, that means going 2.5× taller than its real shape (2.26 / 0.906). That's not a layout fix, that's a lie about geography.

Which reframes the actual choice: not "eliminate the dead space" but "use it" or "pick subjects that are already tall." v1.7's change — raising fill from 0.70 to 0.85 — is the right lever inside that constraint, and the numbers back it up: gambia went from 35.7% to 41.6%, netherlands sits at 42.7%.

Where v1.7 doesn't reach

The elongation fallback is where the fix stops working: any episode whose focus region has an aspect ratio above 2.2 falls back to the 0.55 minimum and never sees the 0.85 target — exactly the branch behind the lowest-occupancy episodes in my table, poland at 11.1%.

The fallback's own justification is avoiding over-zoom, but that assumes aspect is free to vary, and under this renderer it isn't. Under equal aspect, width is the binding constraint: setting fill to 0.85 makes the focus occupy 85% of the axes width and nothing more, no matter how elongated the region is — it can't overflow the way the fallback assumes. For Ukraine, 0.85 instead of 0.55 means going from 565px to 863px wide, and 250px to 386px tall.

Rather than take that on faith, I instrumented the actual _apply_composition_budgetfit_bbox_to_composition calls and back-solved used_fill from each returned bbox. All 67 scenes in the corpus matched exactly 0.85 or 0.55 — no in-between values, confirming the branch really is binary in practice.

Measured, not predicted

I ran an A/B on the swiss episode — 5 of 5 scenes clamped — sampling 9 frames at 4-second intervals:

min_widthmap-band occupancyclipped
0.5517.5%21%
0.8022.8% (1.31×)19%

A naive area prediction from (0.80/0.55)² says 2.12×. Measured came back at 1.31×. Both are correct, and they measure different things: raising fill tightens the camera around the focus country, so it grows in frame while its neighbors leave the frame entirely — area gained by one country is partly area lost from the countries around it. On screen the difference isn't subtle even though the multiplier is smaller than predicted: at 0.55 Switzerland is an unresolvable blob, at 0.80 the western bulge and eastern Graubünden are both legible.

This doesn't close the gap everywhere: two of the three lowest-occupancy episodes — southamerica and norway — are on the world-scene path this fix doesn't reach. world_zoom_padding_fraction (0.6) is a separate lever, and world-scene margin might be deliberate — showing "where on Earth this is" arguably needs the extra space — so I'm treating it as a different problem.

What would prove this wrong, and what generalizes

Two conditions would falsify this analysis, on record rather than assumed away:

Three things about this generalize past matplotlib and past this pipeline:

  1. A library call's default argument can silently redefine your layout. imshow looks like a function that draws an image; it also mutates the aspect of the axes it's drawn on. In the same file, one call passed aspect="auto" and one didn't, and the one that didn't raised nothing — it just changed the geometry of everything drawn after it.
  2. You can measure a symptom precisely, write it down, and still never fix the cause. The 224px collapse was already measured and sitting in a comment. That knowledge got spent building a workaround in the label-placement code instead of tracing it upstream, and the cause became a permanent, invisible fixture of the system. A workaround can be where knowledge goes to die.
  3. "I raised the config value and the effect differs per episode" is itself a signal. I verified the v1.7 change on one episode — gambia, 35.7% to 41.6% — and rolled it out to 15. Only the spread across episodes afterward (11% to 43%) sent me back to look, and that's what surfaced both the fallback branch and the aspect collapse underneath it. Don't A/B on a single sample, especially when different samples take different code paths without telling you.

FAQ

Q. Why does matplotlib's imshow change my axes aspect ratio?
Because imshow defaults its aspect argument to the image.aspect rcParam, which is 'equal' — and matplotlib applies that setting to the axes itself, not just to the image being drawn. Any imshow call that omits aspect reshapes the axes box for the rest of that draw call, with no error raised.

Q. How do I stop imshow from resizing the plot?
Pass aspect="auto" explicitly. That tells matplotlib to stretch the image to fill the axes' existing shape instead of reshaping the axes to match the image's data aspect.

Q. Why did raising a composition-budget config value help some scenes and do nothing for others?
Because the scenes were taking different code paths. Scenes whose focus region's aspect ratio exceeded a fixed elongation threshold fell back to a lower fill fraction and never saw the higher one, so the same config change produced different results depending on which branch a given scene's geometry hit.

Q. How do you debug a layout bug that never throws an error?
Measure the actual rendered geometry instead of trusting the code path. Instrument the real function calls, back-solve what value was actually used from the returned output, and test that against every candidate hypothesis — in this case, computing the exact pixel height an equal-aspect axes would produce and checking it against the measured frame — until one hypothesis matches exactly.

Related notes

← hexisteme · notes · CC-BY 4.0