diff --git a/doc/source/whatsnew/v0.24.0.txt b/doc/source/whatsnew/v0.24.0.txt index e931450cb5c01..0c604f9aad993 100644 --- a/doc/source/whatsnew/v0.24.0.txt +++ b/doc/source/whatsnew/v0.24.0.txt @@ -181,7 +181,7 @@ Reshaping Other ^^^^^ -- +- :meth: `~pandas.io.formats.style.Styler.background_gradient` now takes a ``text_color_threshold`` parameter to automatically lighten the text color based on the luminance of the background color. This improves readability with dark background colors without the need to limit the background colormap range. (:issue:`21258`) - - diff --git a/pandas/io/formats/style.py b/pandas/io/formats/style.py index f876ceb8a26bf..668cafec4b522 100644 --- a/pandas/io/formats/style.py +++ b/pandas/io/formats/style.py @@ -863,7 +863,7 @@ def highlight_null(self, null_color='red'): return self def background_gradient(self, cmap='PuBu', low=0, high=0, axis=0, - subset=None): + subset=None, text_color_threshold=0.408): """ Color the background in a gradient according to the data in each column (optionally row). @@ -879,6 +879,12 @@ def background_gradient(self, cmap='PuBu', low=0, high=0, axis=0, 1 or 'columns' for columnwise, 0 or 'index' for rowwise subset: IndexSlice a valid slice for ``data`` to limit the style application to + text_color_threshold: float or int + luminance threshold for determining text color. Facilitates text + visibility across varying background colors. From 0 to 1. + 0 = all text is dark colored, 1 = all text is light colored. + + .. versionadded:: 0.24.0 Returns ------- @@ -886,19 +892,26 @@ def background_gradient(self, cmap='PuBu', low=0, high=0, axis=0, Notes ----- - Tune ``low`` and ``high`` to keep the text legible by - not using the entire range of the color map. These extend - the range of the data by ``low * (x.max() - x.min())`` - and ``high * (x.max() - x.min())`` before normalizing. + Set ``text_color_threshold`` or tune ``low`` and ``high`` to keep the + text legible by not using the entire range of the color map. The range + of the data is extended by ``low * (x.max() - x.min())`` and ``high * + (x.max() - x.min())`` before normalizing. + + Raises + ------ + ValueError + If ``text_color_threshold`` is not a value from 0 to 1. """ subset = _maybe_numeric_slice(self.data, subset) subset = _non_reducing_slice(subset) self.apply(self._background_gradient, cmap=cmap, subset=subset, - axis=axis, low=low, high=high) + axis=axis, low=low, high=high, + text_color_threshold=text_color_threshold) return self @staticmethod - def _background_gradient(s, cmap='PuBu', low=0, high=0): + def _background_gradient(s, cmap='PuBu', low=0, high=0, + text_color_threshold=0.408): """Color background in a range according to the data.""" with _mpl(Styler.background_gradient) as (plt, colors): rng = s.max() - s.min() @@ -909,8 +922,39 @@ def _background_gradient(s, cmap='PuBu', low=0, high=0): # https://github.com/matplotlib/matplotlib/issues/5427 normed = norm(s.values) c = [colors.rgb2hex(x) for x in plt.cm.get_cmap(cmap)(normed)] - return ['background-color: {color}'.format(color=color) - for color in c] + if (not isinstance(text_color_threshold, (float, int)) or + not 0 <= text_color_threshold <= 1): + msg = "`text_color_threshold` must be a value from 0 to 1." + raise ValueError(msg) + + def relative_luminance(color): + """ + Calculate relative luminance of a color. + + The calculation adheres to the W3C standards + (https://www.w3.org/WAI/GL/wiki/Relative_luminance) + + Parameters + ---------- + color : matplotlib color + Hex code, rgb-tuple, or HTML color name. + + Returns + ------- + float + The relative luminance as a value from 0 to 1 + """ + rgb = colors.colorConverter.to_rgba_array(color)[:, :3] + rgb = np.where(rgb <= .03928, rgb / 12.92, + ((rgb + .055) / 1.055) ** 2.4) + lum = rgb.dot([.2126, .7152, .0722]) + return lum.item() + + text_colors = ['#f1f1f1' if relative_luminance(x) < + text_color_threshold else '#000000' for x in c] + + return ['background-color: {color};color: {tc}'.format( + color=color, tc=tc) for color, tc in zip(c, text_colors)] def set_properties(self, subset=None, **kwargs): """ diff --git a/pandas/tests/io/formats/test_style.py b/pandas/tests/io/formats/test_style.py index c1ab9cd184340..b355cda8df1bd 100644 --- a/pandas/tests/io/formats/test_style.py +++ b/pandas/tests/io/formats/test_style.py @@ -1017,9 +1017,9 @@ def test_hide_columns_mult_levels(self): assert ctx['body'][1][2]['display_value'] == 3 +@td.skip_if_no_mpl class TestStylerMatplotlibDep(object): - @td.skip_if_no_mpl def test_background_gradient(self): df = pd.DataFrame([[1, 2], [2, 4]], columns=['A', 'B']) @@ -1031,7 +1031,30 @@ def test_background_gradient(self): result = df.style.background_gradient( subset=pd.IndexSlice[1, 'A'])._compute().ctx - assert result[(1, 0)] == ['background-color: #fff7fb'] + + assert result[(1, 0)] == ['background-color: #fff7fb', + 'color: #000000'] + + @pytest.mark.parametrize( + 'c_map,expected', [ + (None, { + (0, 0): ['background-color: #440154', 'color: #f1f1f1'], + (1, 0): ['background-color: #fde725', 'color: #000000']}), + ('YlOrRd', { + (0, 0): ['background-color: #ffffcc', 'color: #000000'], + (1, 0): ['background-color: #800026', 'color: #f1f1f1']})]) + def test_text_color_threshold(self, c_map, expected): + df = pd.DataFrame([1, 2], columns=['A']) + result = df.style.background_gradient(cmap=c_map)._compute().ctx + assert result == expected + + @pytest.mark.parametrize("text_color_threshold", [1.1, '1', -1, [2, 2]]) + def test_text_color_threshold_raises(self, text_color_threshold): + df = pd.DataFrame([[1, 2], [2, 4]], columns=['A', 'B']) + msg = "`text_color_threshold` must be a value from 0 to 1." + with tm.assert_raises_regex(ValueError, msg): + df.style.background_gradient( + text_color_threshold=text_color_threshold)._compute() def test_block_names():