Skip to content

ENH color brightening #9985

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 1 commit into from
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions doc/users/next_whats_new/2017-12_color_brightening.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
Color brightening
----------------------

Often we would like the ability add subtle color variation to our plots,
especially when similar element are plotted in close proximity to one another.
`matplotlib.colors.brighten_color` can be used to take an existing color and
brighten or darken it, by giving a positive or negative fraction.

.. plot:: mpl_examples/color/color_brighten_demo.py
31 changes: 31 additions & 0 deletions examples/color/color_brighten_demo.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
"""
===================
Brighten color demo
===================

Demo of `~.brighten_color` utility.

"""

import matplotlib.pyplot as plt
import matplotlib.colors as mcolors
import numpy as np

fig, ax = plt.subplots()

lightfacs = [-1., -0.5, 0., 0.5, 0.75, 1.0]
N = len(lightfacs)
y = np.linspace(0., 1., N+1) * N - 0.5
for n, lightfac in enumerate(lightfacs):
brightened_color = mcolors.brighten_color('blue', lightfac)
ax.fill_between([0, 1], [y[n], y[n]], [y[n+1], y[n+1]],
facecolor=brightened_color, edgecolor='k')

ax.set_yticklabels([''] + lightfacs)

ax.set_xlim([0, 1])
ax.set_ylim(np.min(y), np.max(y))
ax.set_ylabel('Brightening Fraction')
ax.set_xticks([])
ax.set_title('Brightening of Color Blue')
plt.show()
48 changes: 48 additions & 0 deletions lib/matplotlib/colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,12 +51,14 @@
from six.moves import zip

from collections import Sized
import colorsys
import itertools
import re
import warnings

import numpy as np
import matplotlib.cbook as cbook

from ._color_data import BASE_COLORS, TABLEAU_COLORS, CSS4_COLORS, XKCD_COLORS


Expand Down Expand Up @@ -1992,3 +1994,49 @@ def from_levels_and_colors(levels, colors, extend='neither'):

norm = BoundaryNorm(levels, ncolors=n_data_colors)
return cmap, norm


def brighten_color(color, frac):
Copy link
Member

Choose a reason for hiding this comment

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

to 🚲 🏠 the name a bit, should this be adjust_color or adjust_tint or something like that? I almost left a comment after quickly skimming the code as "do we need to add a darken color too?" (and then I read the docstring and left this comment instead).

Copy link
Contributor

Choose a reason for hiding this comment

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

Does a function in the module colors really need to have color as part of its name?

Copy link
Member Author

Choose a reason for hiding this comment

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

adjust_lightness? Opens up to adjust saturation.

"""A color helper utility to either darken or brighten the given color.

This function first converts the given color to RGB using
`~.colorConverter` and then to
HSL using `colorsys.rgb_to_hsl`. The lightness is modified according
Copy link
Contributor

Choose a reason for hiding this comment

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

Seems foolish to use the non-vectorized colorsys functions when we already have a vectorized rgb_to_hsv higher up in the file.

I know that HSV and HSL are different, but either we should write a vectorized rgb_to_hsl for consistency, or just use the HSV one that's already here

Copy link
Member Author

Choose a reason for hiding this comment

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

This is outside what I'm willing to do for this PR.

If we could just use HSV, that'd be great, but my understanding is that to get white in HSV you need to change both S and V, whereas in HSL you can just change L. HSL seems the better colourspace for what we are doing here.

Just for context, this is an old PR that was sitting around for 2+ years, so I'm willing to put it through, but I'm not interested enough in getting the functionality that I'm willing to write a whole rgb_to_hsl conversion routine. If someone wants to put in a PR that does that, either before this one is accepted, or after, then that'd be swell. But I think the PR has merit even if it is not vectorized.

to the given fraction, clipped to be between 0 and 1, and then converted
back to RGB using `colorsys.hsl_to_rgb`:
L = np.clip(L0 * (1.0 + frac), 0., 1.), where L0 is the original
Copy link
Contributor

Choose a reason for hiding this comment

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

I'm not convinced that this operation really makes a lot of sense, e.g. is going from L=0.1 to L=0.2 (frac=1) really "equivalent" from going to L=0.5 to L=1? If anything I think this should something like a probit or logit scale.

Copy link
Contributor

@eric-wieser eric-wieser Dec 13, 2017

Choose a reason for hiding this comment

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

See this comment - I agree with you

Copy link
Member Author

Choose a reason for hiding this comment

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

I don’t feel strongly about this. Do you want black to stay black or go grey? I could go either way. I still prefer fractional change because I think that’s what most people would mean when they say to lighten an image. I’d you want the histogram to move right but black would still stay black.

We could also just forget any algorithm and just ask the user for the L value they want perhaps w an accompanying way to get L so they know where they are starting from.

Copy link
Contributor

@anntzer anntzer Dec 13, 2017

Choose a reason for hiding this comment

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

I would indeed prefer a design where we expose a color class with methods such as color.hsl_replace(l=...), color.hsv_replace(s=...) (and allowing multiple kwargs of course, just leaving the ones not specified untouched).

lighness of the color in HSL space.

Parameters
----------
color : string, list, hexvalue
Any acceptable Matplotlib color value, such as 'red',
'slategrey', '#FFEE11', (1,0,0)

frac : float
The amount by which to darken or brighten the color. Negative
darkens, positive brightens. Lowest valid negative value is -1.
Highest positive value is 1/L of the original color, where L is
the lightness in HSL space. Entries outside this range are allowed,
but the resulting lightness is clipped between 0. and 1.

Returns
-------
color : tuple of floats
tuple representing converted rgb values

"""

frac = float(frac)
if not np.isfinite(frac):
raise ValueError('argument frac must be a finite float')

rgb = colorConverter.to_rgb(color)
Copy link
Member

Choose a reason for hiding this comment

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

don't think these all need to be on separate lines


h, l, s = colorsys.rgb_to_hls(*rgb)

l *= 1. + frac

l = np.clip(l, 0., 1.)

return colorsys.hls_to_rgb(h, l, s)
34 changes: 33 additions & 1 deletion lib/matplotlib/tests/test_colors.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@
import numpy as np
import pytest

from numpy.testing.utils import assert_array_equal, assert_array_almost_equal
from numpy.testing.utils import (assert_array_equal,
assert_array_almost_equal, assert_equal)

from matplotlib import cycler
import matplotlib
Expand Down Expand Up @@ -715,3 +716,34 @@ def __add__(self, other):
else:
assert len(recwarn) == 0
recwarn.clear()


def _brighten_test_helper(color, shade, expected):
sc = mcolors.brighten_color(color, shade)
assert_equal(sc, expected)
Copy link
Member

Choose a reason for hiding this comment

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

Are these both tuples? Just assert sc == expected.



def test_color_brightening():
Copy link
Member

Choose a reason for hiding this comment

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

What about parameterization? and do these floats need to be this long?

TestBrighten(object):
    test_colors = ( 'red', 'red', 'red', 'red', 'red', [0, .5, .9], 'slategrey')
    test_brighten = (0, 0.50, 1.00, -0.50, -1.00, 0.20, -0.20)
    known_brighten_result = ((1.0, 0.0, 0.0), (1.0, 0.5, 0.5),
               (1.0, 1.0, 1.0), (0.5, 0.0, 0.0), (0.0, 0.0, 0.0),
               (0.080000000000000071, 0.59111111111111092, 1.0),
               (0.35097730430754981, 0.40156862745098038, 0.45215995059441105))
    @pytest.mark.parametrize('color, shade, expected', 
                   zip(test_colors, test_brighten, known_brighten_result)) 
    def test_color_brightening(color, shade, expected):
        sc = mcolors.brighten_color(color, shade)
        assert_equal(sc, expected)

Copy link
Member Author

Choose a reason for hiding this comment

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

I didn't write the test, so this is a disinterested comment, but: I'm still not sure what the advantage of parameterization is? I don't find the above very easy to parse...

Copy link
Member

Choose a reason for hiding this comment

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

You don't have to explicitly loop through the cases and in this case there's no needed for the secondary helper function.

Copy link
Member Author

Choose a reason for hiding this comment

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

hmmm, well, there was no need for the helper fcn anyway. And

@pytest.mark.parametrize('color, shade, expected', 
                   zip(test_colors, test_brighten, known_brighten_result)) 

is not any shorter than writing

for color, brighten, expected in zip(test_colors,
                                      test_brighten,
                                      known_brighten_result):

The latter has the benefit of being standard python, whereas the former requires knowing pytest decorators. I'm not adverse to change, but only if there is a compelling reason!

Copy link
Member

Choose a reason for hiding this comment

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

The two big wins from parameterization come from when you can stack more than 1 and get the outer-product of the input and because it lets each test fail independently.

Copy link
Member

Choose a reason for hiding this comment

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

I always forget about that, but the test failing independenty is the real reason to use parameterization becuase that's what makes it a true unit test. Plus in pytest you can add a list of lables for each paramterized set and so you can identify what the inputs are testing for.

test_colors = (
'red',
'red',
'red',
'red',
'red',
[0, .5, .9],
'slategrey',
)
test_brighten = (0, 0.50, 1.00, -0.50, -1.00, 0.20, -0.20)
known_brighten_result = (
(1.0, 0.0, 0.0),
(1.0, 0.5, 0.5),
(1.0, 1.0, 1.0),
(0.5, 0.0, 0.0),
(0.0, 0.0, 0.0),
(0.080000000000000071, 0.59111111111111092, 1.0),
(0.35097730430754981, 0.40156862745098038, 0.45215995059441105)
)
for color, brighten, expected in zip(test_colors,
test_brighten,
known_brighten_result):
_brighten_test_helper(color, brighten, expected)
Copy link
Contributor

@eric-wieser eric-wieser Dec 13, 2017

Choose a reason for hiding this comment

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

I think we're missing the obvious case that this should just be written:

brighten = mcolors.brighten_color
assert_equal(brighten('red', 0.0), (1.0, 0.0, 0.0))
assert_equal(brighten('red', 0.5), (1.0, 0.5, 0.5))
assert_equal(brighten('red', 1.0), (1.0, 1.0, 1.0))
...

These aren't test parameters - they're the tests themselves, and the loop and helper functions just obscure the pairing between the results and the expected values