Skip to content

feat: colors for +inf, -inf, NaN values - #151

Open
matthiasschabel wants to merge 3 commits into
pyapp-kit:mainfrom
matthiasschabel:feat/exceptional-colors
Open

matthiasschabel wants to merge 3 commits into
pyapp-kit:mainfrom
matthiasschabel:feat/exceptional-colors

Conversation

@matthiasschabel

@matthiasschabel matthiasschabel commented Aug 10, 2026 •

Copy link
Copy Markdown
Contributor

Closes #144.

Adds three optional colors so exceptional float values can be told apart: neg_inf,
pos_inf, nan. Today -inf is indistinguishable from any other under-range
value, and NaN is indistinguishable from bad. Log transformed signal data and
saturated logistic regression both produce infinities worth marking rather than blending
into the ends of the scale.

Each new color falls back to the one its class uses now, so nothing changes for an existing
colormap:

Class Color Falls back to
negative infinity neg_inf under, then the first ramp color
positive infinity pos_inf over, then the last ramp color
NaN nan bad, then transparent

bad stays the fallback for nan rather than being replaced by it, so
code that sets it is unaffected and either child can be set alone.

Two things the diff does not show:

  • The infinity masks are taken before xa *= N. That multiply overflows large finite values
    to infinity (float16 65504 does at N=256), and those are out of range, not infinite.
    There is a test for it, because classifying after the multiply looks right and is not.

to_napari now prefers nan_color over bad_color for napari's nan_color, since it is
the one converter target that represents the class. matplotlib's bad covers NaN and masked
together, so bad is still what goes there.

Depends on #150, which this branches from. Only the last commit is mine; the first two are
#150's.

@codecov

codecov Bot commented Aug 10, 2026 •

Copy link
Copy Markdown

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 95.77%. Comparing base (f0a4aec) to head (6a81529).

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #151      +/-   ##
==========================================
+ Coverage   95.72%   95.77%   +0.05%     
==========================================
  Files         168      168              
  Lines        2197     2227      +30     
==========================================
+ Hits         2103     2133      +30     
  Misses         94       94              

☔ View full report in Codecov by Harness.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

@tlambert03

Copy link
Copy Markdown
Member

@matthiasschabel I'm ok with this if it enables you to do things that regularly pop up in your work. My only objection would be the cognitive burden on the more casual user who doesn't particularly care to discriminate between "over" and "positive infinity". So, as long as a user not providing these values has behavior that just degrades to the old behavior, and as long as they are documented well (they are), then I'm fine with this. 👍

I think i'm most concerned with now having bad_color, nan_color, and masked_color. Are all three definitely needed? Or is nan vs bad just "regret" over an imprecise naming decision with "bad"? (i.e. what remains for bad_color to claim if nan and masked have been used?) @jni do you have thoughts here?

A couple questions. (some of these might be broken before this PR, but now is as good a time as any to address them). Do all of these over/under/inf/neg-inf values get preserved in:

  • cmap.reversed
  • cmap.with_extremes (should previously configured exceptional colors be lost unless they are repeated?)
  • pickle/unpickle
  • as_dict() and pydantic serialization?

(it occurs to me that some of that might not be tested)

@matthiasschabel

Copy link
Copy Markdown
Contributor Author

@matthiasschabel I'm ok with this if it enables you to do things that regularly pop up in your work. My only objection would be the cognitive burden on the more casual user who doesn't particularly care to discriminate between "over" and "positive infinity". So, as long as a user not providing these values has behavior that just degrades to the old behavior, and as long as they are documented well (they are), then I'm fine with this. 👍

Thanks for the close read and for being agreeable! I do think this proposal addresses legitimate use cases and closes the circle on handling special/exceptional values in cmap. The implementation should be purely additive and takes care to degrade in the predictable/expected backward-compatible path, so transparent to current users and hopefully the cognitive overhead is minimal.

When new classes are unused the routing appends fallback-resolved rows to a call-local copy of the LUT, so a class with no color of its own lands on exactly the row it lands on today. test_exceptional_colors_fall_back_to_the_legacy_extremes pins the four legacy destinations, and I also confirmed that outputs against main for data containing both infinities, NaN, and a masked entry, with and without under/over/bad set were identical.

I think i'm most concerned with now having bad_color, nan_color, and masked_color. Are all three definitely needed? Or is nan vs bad just "regret" over an imprecise naming decision with "bad"? (i.e. what remains for bad_color to claim if nan and masked have been used?) @jni do you have thoughts here?

Here's my argument in favor (in addition to the completeness argument):bad is not naming regret; it is the umbrella tier of a two-level hierarchy, parallel to under/over above neg_inf/pos_inf. What remains for bad to claim is the common (and matplotlib compatible) case: one color for "invalid, I don't care why". nan and masked are for when "measured but undefined" and "deliberately excluded" have to read differently. masked can mark values that are in range, which none of the other classes handle. Masking by a predicate gives you a cheap contour band or a polarity change overlay that stays put with changes in window level/width without touching the values.

A couple questions. (some of these might be broken before this PR, but now is as good a time as any to address them). Do all of these over/under/inf/neg-inf values get preserved in:

  • cmap.reversed
  • cmap.with_extremes (should previously configured exceptional colors be lost unless they are repeated?)
  • pickle/unpickle
  • as_dict() and pydantic serialization?

(it occurs to me that some of that might not be tested)

None of those four channels preserve the existing under/over/bad either.

  • reversed() passes only stops, name, and category, so it drops every extreme color and the interpolation mode (a nearest colormap comes back linear).
  • with_extremes() clears anything not repeated. This deviates from matplotlib's behavior, which preserves existing values that are not passed.
  • __reduce__ carries only color_stops, so a pickle round trip loses name, category, interpolation, and all extreme colors. pickle.loads(pickle.dumps(cm)) == cm is already False for any colormap with bad set.
  • as_dict() has no extreme keys, and it is what the pydantic serializer and _json_encode emit, so a model round trip silently strips them. A catalog colormap serializes as just its qualified name, so Colormap("viridis", under="red") comes back as plain viridis.

I think we should conform to matplotlib wherever it has a position:

  • reversed(): swap under and over, preserve bad, as matplotlib does; neg_inf and pos_inf swap along with the ends they extend, and nan/masked are preserved with bad. Interpolation carries through.
  • with_extremes(): preserve anything not passed, as matplotlib does. Clearing a single color then means constructing a fresh Colormap, the same limitation matplotlib has.
  • pickle: carry the full constructor state through __reduce__.
  • as_dict(): optional keys for interpolation and the extreme colors, emitted only when set, so existing payloads are byte-for-byte unchanged; the pydantic serializer falls back to the dict form when a catalog colormap carries extremes. Deserialization already works, since _validate calls the constructor and it accepts all of these as kwargs.

Would you prefer that these get broken up into more granular, separate PRs or lumped in with this one?

@tlambert03

Copy link
Copy Markdown
Member

Thank you for the thorough response! Yeah I thought that might be the case (that it was broken before this PR). And I agree that we should mirror mpl where there is prior art.

I suppose we should go ahead and split that fix out into a new PR. And it needn't hold this one up either.

I would like to get a quick opinion from @jni on the API addition, but then this is all good by me. Thanks again!

cmap colors three exceptional classes: under, over, and bad. Floating point
data has more. Negative and positive infinity are indistinguishable from
ordinary out-of-range values, and NaN is indistinguishable from a masked entry.

Adds neg_inf, pos_inf, nan, and masked. Each falls back to the color its class
uses now: neg_inf to under, pos_inf to over, nan and masked to bad. bad is kept
as the joint fallback for both of its children, so code that sets it is
unaffected and either child may be set alone.

Routing appends four fallback-resolved rows to a call-local copy of the
over/under LUT, so a class with no color of its own lands on exactly the row it
lands on now. Colormap.lut() is unchanged.

The infinity masks are taken before the input is scaled by N: that multiply
overflows large finite values to infinity (float16 65504 does it), and those
are out of range rather than infinite.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Reviewed-By: Codex (gpt-5.6-sol, reasoning effort xhigh)
@jni

jni commented Aug 23, 2026

Copy link
Copy Markdown

Hello, and thanks for flagging me! I'm flattered. 😂

I don't have a strong opinion about the extra values. I can see that they are in some sense "interesting" to preserve (+inf being something that could never get captured into the colormap regardless of how you set the contrast limits). On the other hand, I have gotten by this far without needing to make this distinction, and so have millions of matplotlib users, it seems. So, the extra maintenance and cognitive burden of the added values is worth weighing against the marginal added utility.

Some things that would demonstrate that utility as far as I'm concerned:

  • some prior art: we know matplotlib doesn't distinguish inf/nan from extreme or masked values. Do other packages in other languages? e.g. ggplot in R? D3 in js?
  • some figures in papers that make the distinction, and were thus probably built with some complicated script to get around the existing cmap limitations.
  • more users e.g. requesting the distinction in said packages.

The maintenance burden is quite small but we've all seen tiny maintenance costs accumulate over time to create a large maintenance cost, so I do think it's worth being cautious here.

None of this is to outright block this PR, just to pause and assess a bit.

@matthiasschabel

Copy link
Copy Markdown
Contributor Author
  • some prior art: we know matplotlib doesn't distinguish inf/nan from extreme or masked values. Do other packages in other languages? e.g. ggplot in R? D3 in js?

matplotlib already supports differentiating NaN from masked in line plots: https://matplotlib.org/stable/gallery/lines_bars_and_markers/masked_demo.html

I, obviously, also felt it was important enough that I supported it in the MATLAB scientific visualization code that I am hoping to replace with napari. But I think the strongest argument is that the existing support is partial yet incomplete in that it doesn't cover the full range of cases that are possible (masked being the one where there is some conceptual overlap with things like label arrays since a mask is really just a binary label array). But, from the underlying numerics, there are only -inf/under range/in range/over range/+inf and nan representations for a floating point value - that is complete.

@jni

jni commented Aug 24, 2026

Copy link
Copy Markdown

matplotlib already supports differentiating NaN from masked in line plots: matplotlib.org/stable/gallery/lines_bars_and_markers/masked_demo.html

I think that example shows the opposite: masked and NaN values are treated the same way by the plotting machinery. There's no API (afaict?) for doing different things in the two cases.

@matthiasschabel

matthiasschabel commented Aug 24, 2026 •

Copy link
Copy Markdown
Contributor Author

I think that example shows the opposite: masked and NaN values are treated the same way by the plotting machinery. There's no API (afaict?) for doing different things in the two cases.

Sorry, I wasn't completely clear - I was referring to data rather than rendering. np.ma preserves values under the mask so they are still present, just flagged as ignored, while NaN obliterates them. The demo itself relies on this: y4[y3 > 0.7] = np.nan only works because y3 still holds its data under the mask. Same gap in the plot, but the two arrays aren't interchangeable — and matplotlib carries that distinction all the way through to the LUT lookup, where it throws it away.

masked_vs_nan

For my use case, it is extremely common to generate model fitting masks that exclude regions of low SNR/background noise and to not fit them at all. A specific example is in log-linear regression of the exponential T2* decay on multiecho MRI data: values outside the valid mask indicate voxels that were not fit and have entirely unknown value, while NaNs indicate regression failure, usually because there are zero signal values at long echo times. So mapping "problematic model fits to data in the region of interest" to the same color as "data outside the region of interest that wasn't fit" confounds the two very different cases. Another argument for separating masking, related to this, is identifying the location/number of voxels where signal values are equal to zero : masking is the only way to mark specific values like this that are in range.

@jni

jni commented Aug 24, 2026

Copy link
Copy Markdown

Same gap in the plot, but the two arrays aren't interchangeable — and matplotlib carries that distinction all the way through to the LUT lookup, where it throws it away.

I think that's the key when it comes to motivating this contribution.

So mapping "problematic model fits to data in the region of interest" to the same color as "data outside the region of interest that wasn't fit" confounds the two very different cases.

I totally get the motivation. What I'm asking is whether this distinction is standard practice in your field (and if so, whether you can demonstrate this with some references to some paper figures, for example), or whether you are pushing the cutting edge of data visualisation (in and outside your field).

The latter is valuable and commendable, but may not belong in a foundational library as much as in downstream libraries. (Again, that's a "may", I'm not strongly opposed here. It's just a different conversation.)

@matthiasschabel

Copy link
Copy Markdown
Contributor Author

I totally get the motivation. What I'm asking is whether this distinction is standard practice in your field (and if so, whether you can demonstrate this with some references to some paper figures, for example), or whether you are pushing the cutting edge of data visualisation (in and outside your field).

It's not really pushing the envelope, certainly not in remote sensing (one of my former lives). Here's an example of routinely processed satellite radiometry data : https://images.remss.com/amsr/amsr2_image_view_v08.2.html?&time=day&sat=amsr2&year=2026&month=8&day=24&pass=ascending&product=vapor where they composite a land mask (to show terrain elevation) with values derived from satellite measurements, where the missing data is indicated in black, land is masked, ice is white, and water vapor is color mapped in a certain range (OOB values are often given distinct colors, but not here). Maybe I'm just not familiar enough with how napari works, but it seems like this would be difficult to accomplish in the current state?

There's a bit of a chicken-and-egg problem in the sense that, if something is not supported or difficult to accomplish, people will generally just not bother, even if it's the right thing to do... Here's a paper trying to address various sources of measurement/modeling error in pharmacokinetic modeling (Figures 3/4) : https://www.sciencedirect.com/science/article/pii/S0730725X1400321X#f0015 (
image
), where they have a range of modeling issues that they're trying to visualize and probably want to be able to composite a parameter map on top of the anatomic image on the left where parameter values are good, with a transparent mask showing the label map for bad model fits superimposed but instead are forced to render the actual model values as black and show two figures...

The latter is valuable and commendable, but may not belong in a foundational library as much as in downstream libraries. (Again, that's a "may", I'm not strongly opposed here. It's just a different conversation.)

I see what you're saying here, both regarding potential complexity and the question of where this belongs. I'm just not clear if moving mask handling into, e.g. napari would actually simplify things.

Keep the proposal scoped to negative infinity, NaN, and positive infinity in addition to the existing under and over behavior. Masked arrays continue to use the existing bad color, including when they hide an infinity or NaN.
@matthiasschabel matthiasschabel changed the title feat: colors for +inf, -inf, NaN, and masked values feat: colors for +inf, -inf, NaN values Aug 25, 2026
@matthiasschabel

Copy link
Copy Markdown
Contributor Author

Since adding masked seems to be contentious, I've decreased the scope of this PR to just cover the unambiguously correct IEEE-754 values of -inf/nan/+inf - it may be that there are better downstream solutions for handling masked data than here in cmap.

@brisvag brisvag left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

FWIW I'm convinced by the discussion/explanations above, and in favour of adding all of these! I thinkl the extra complexity is worth it and well-scoped enough that maintenance is not a worry.

Also, totally agree on splitting in 2 PR as done now so we can get get this moving and deal with the tricky bits later!

Comment thread src/cmap/_colormap.py
Comment on lines +407 to +408
when unset). Entries masked by a `numpy.ma` masked array use `bad_color`,
whatever value they hide.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Some masked stuff is still present!

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The masked handling that remains was already on main: masked entries use bad, as documented for bad and in the __call__ docstring. I removed the extra mask_bad bookkeeping this PR had added for the masked color. The only new masked code is a final xa[mask_masked] = N + 2, which keeps all masked values consistently bad; masked NaN and masked +/-inf are both colored bad, so those exceptional values only get their special colors when they exist outside of the mask.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

This also means that, when you have the NaN/+-Inf values defined, bad is effectively only masked values.

Comment thread src/cmap/_colormap.py
if bytes:
lut = (lut * 255).astype(np.uint8)
# the lut will have three additional colors at the end for under, over, and bad
N = len(lut) - 3

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Sounds like this needs to be updated to be -5?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Comment was not very clear - -3 is right here. At that line the LUT is the N ramp colors plus under, over and bad, so len(lut) - 3 is N. The neg_inf/pos_inf/nan rows are appended on the next line, after N is computed.

Comment thread src/cmap/_colormap.py
Comment on lines +512 to +519
return np.vstack(
(
lut,
under if self.neg_inf_color is None else self.neg_inf_color.rgba,
over if self.pos_inf_color is None else self.pos_inf_color.rgba,
bad if self.nan_color is None else self.nan_color.rgba,
)
)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

How does this work? Don't we need over to still map to over even if pos_inf exists, and they are distinct?


Maybe this would be simpler if we just did like with over/under/bad and always have them in the loot? The cost is minimal and it just simplifies the code significantly...

@matthiasschabel matthiasschabel Sep 30, 2026 •

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

over still maps to over: finite over-range values use row N + 1, and only +inf uses the new row, which takes over's color when pos_inf is unset.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

The issue with extending the LUT is that it would change lut(with_over_under=True), which is public and documented, so it could break outside callers:

  • The lut() docstring says the shape is (N+3, 4), with under, over and bad at N, N+1 and N+2.
  • The under_color and bad_color attribute docstrings say lut[-3] is under and lut[-1] is bad.
  • Adding three rows makes the shape (N+6, 4) in either ordering. Appending after bad breaks lut[-3]/lut[-1] and len(lut) - 3 as the ramp length. Inserting before under breaks N/N+1/N+2 and len(lut) - 3 as well.
  • Each ordering keeps one of the two documented indexing schemes and drops the other. Neither keeps the documented shape.

Otherwise, yes, it would be simpler and cleaner.

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I see, thanks for the explanation.
I don't know what the deprecation policy (if any) is here (@tlambert03 ?), but I think it might be worth considering making this public through that same interface as well?

…layout

NaN now goes directly to its final row (N + 2 legacy bad, N + 5 when an
exceptional color is set) and masked entries are written last, so a masked
entry stays bad whatever value it hides. This drops the mask_bad
intermediate and the write-then-overwrite sequence.

Document why N is re-derived from len(lut) and that the neg_inf/pos_inf/nan
colors are applied by __call__ rather than stored in lut(). to_napari tests
the nan color with `is not None` instead of relying on Color's length.

Tests: run the masked-dtype cases with and without the new colors, add masked
infinity, scalar, and integer-input cases, and drop the object-dtype
TypeError case that only pinned an unrelated limitation.

Reviewed-By: Claude (claude-opus-5-5)
Reviewed-By: Codex (gpt-6-astra, reasoning effort high)
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
@tlambert03

Copy link
Copy Markdown
Member

thanks all for the discussion here. I'm inclined to accept this new feature.

I was originally slightly opposed, mostly just on the basis that it adds cognitive burden for the average user reading the documentation and feature set, for what I expect to be a rather narrow use case. I would say I mostly line up with everything @jni said, including his hedging on his own confidence on whether it belongs 😂

What I'm asking is whether this distinction is standard practice in your field, or whether you are pushing the cutting edge of data visualisation (in and outside your field).

The latter is valuable and commendable, but may not belong in a foundational library as much as in downstream libraries.

I think the latter is commendable, and you've defended your rationale well, politely, and patiently, and I do want to receive that sort of contribution here. Given @brisvag's seconding of the feature, that was the nudge I think i needed to be persuaded to just go with it. It's keyword only, comes at the end of a long list of parameters anyway, and can't easily be done downstream given the knobs we allow you to twiddle here.

so, can you clean this up for final review? Get CI green again, and resolve all resolved comments leaving only those that need input?

This branch has not been deployed

No deployments
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.

Improved handling of out-of-range values

4 participants