Summary
Color / cmap / norm resolution is scattered and re-implemented across render.py and utils.py, with genuine inconsistencies (not just duplication) — including a colorbar-vs-pixels mismatch and a private-matplotlib-API poke. Unifying it removes a recurring bug class and shrinks every render function.
1. CmapParams is a dumb data bag; callers re-implement its safe handling
render_params.py CmapParams carries cmap, norm, na_color, cmap_is_default but no behavior, so every consumer re-does the same operations:
- "copy the stateful
Normalize before use" is hand-written 5× (render.py:705, 1231, 1765, 1862). Forgetting it risks cross-channel/cross-call mutation.
- cmap-alpha is applied by poking matplotlib's private
_lut (render.py:1803-1804) — fragile across matplotlib versions and against the "no private APIs of other packages" guideline.
na_color → hex conversions and isinstance(na_color, Color) guards are scattered ~10× (utils.py:1411/1468/1898/..., render.py:630/1318).
Fix: give CmapParams methods — fresh_norm(), cmap_with_alpha(alpha), is_user_cmap, na_hex. Behavior-preserving; spot-check the 1-channel baseline for the cmap-alpha change.
2. Continuous→RGBA normalization reimplemented 4–6× with drift
The "NaN-safe normalize then cmap(norm(.))" logic exists in _map_color_seg (utils.py:1510-1514, 1567-1571), _get_collection_shape Case B (render.py:650-669) and again at 683-691. They have already drifted: _map_color_seg uses ~np.isnan, _get_collection_shape uses np.isfinite (differ on ±inf); the vmin==vmax guard exists in one but not the other. The colorbar span is derived independently from the pixel span — so the colorbar can disagree with the rendered pixels (the #687/#688 churn class).
Fix: a single resolve_continuous_norm(values, cmap_params) -> Normalize that feeds both the pixels and the colorbar ScalarMappable, plus _continuous_to_rgba(values, cmap_params).
3. The four _render_images blend branches collapse to one helper
Paths 2A/2B/2C/2D (render.py:~1877-2015) are six copies of stack → reduce → (clip) with three different reductions. Collapse to one _composite_channels(layers, channel_cmaps, alpha). (The averaging-vs-additive inconsistency in this same code is tracked separately in findings/multichannel-compositing-bugs.md; unifying the helper is the structural enabler.)
Sequencing / risk
Land the safe foundations first — CmapParams methods (#1) — then resolve_continuous_norm (#2), then the composite helper (#3). Effort: medium. Risk: low for the CmapParams methods (no baseline impact); medium for the norm/composite unification (CI baseline regen). #2 also fixes the colorbar-vs-pixels mismatch, so it's correctness, not just cleanup.
Part of a maintainability/refactor audit of main.
Summary
Color / cmap / norm resolution is scattered and re-implemented across
render.pyandutils.py, with genuine inconsistencies (not just duplication) — including a colorbar-vs-pixels mismatch and a private-matplotlib-API poke. Unifying it removes a recurring bug class and shrinks every render function.1.
CmapParamsis a dumb data bag; callers re-implement its safe handlingrender_params.pyCmapParamscarriescmap, norm, na_color, cmap_is_defaultbut no behavior, so every consumer re-does the same operations:Normalizebefore use" is hand-written 5× (render.py:705, 1231, 1765, 1862). Forgetting it risks cross-channel/cross-call mutation._lut(render.py:1803-1804) — fragile across matplotlib versions and against the "no private APIs of other packages" guideline.na_color → hexconversions andisinstance(na_color, Color)guards are scattered ~10× (utils.py:1411/1468/1898/...,render.py:630/1318).Fix: give
CmapParamsmethods —fresh_norm(),cmap_with_alpha(alpha),is_user_cmap,na_hex. Behavior-preserving; spot-check the 1-channel baseline for the cmap-alpha change.2. Continuous→RGBA normalization reimplemented 4–6× with drift
The "NaN-safe normalize then
cmap(norm(.))" logic exists in_map_color_seg(utils.py:1510-1514, 1567-1571),_get_collection_shapeCase B (render.py:650-669) and again at683-691. They have already drifted:_map_color_seguses~np.isnan,_get_collection_shapeusesnp.isfinite(differ on ±inf); the vmin==vmax guard exists in one but not the other. The colorbar span is derived independently from the pixel span — so the colorbar can disagree with the rendered pixels (the #687/#688 churn class).Fix: a single
resolve_continuous_norm(values, cmap_params) -> Normalizethat feeds both the pixels and the colorbarScalarMappable, plus_continuous_to_rgba(values, cmap_params).3. The four
_render_imagesblend branches collapse to one helperPaths 2A/2B/2C/2D (
render.py:~1877-2015) are six copies ofstack → reduce → (clip)with three different reductions. Collapse to one_composite_channels(layers, channel_cmaps, alpha). (The averaging-vs-additive inconsistency in this same code is tracked separately infindings/multichannel-compositing-bugs.md; unifying the helper is the structural enabler.)Sequencing / risk
Land the safe foundations first —
CmapParamsmethods (#1) — thenresolve_continuous_norm(#2), then the composite helper (#3). Effort: medium. Risk: low for the CmapParams methods (no baseline impact); medium for the norm/composite unification (CI baseline regen). #2 also fixes the colorbar-vs-pixels mismatch, so it's correctness, not just cleanup.Part of a maintainability/refactor audit of
main.