Skip to content

Commit c388dde

Browse files
joelostblomTomAugspurger
authored andcommitted
ENH: Color text based on background in Styler (#21263)
* Color text based on background gradient Closes #21258
1 parent cea0a81 commit c388dde

File tree

3 files changed

+79
-12
lines changed

3 files changed

+79
-12
lines changed

doc/source/whatsnew/v0.24.0.txt

+1-1
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,6 @@ Reshaping
181181
Other
182182
^^^^^
183183

184-
-
184+
- :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`)
185185
-
186186
-

pandas/io/formats/style.py

+53-9
Original file line numberDiff line numberDiff line change
@@ -863,7 +863,7 @@ def highlight_null(self, null_color='red'):
863863
return self
864864

865865
def background_gradient(self, cmap='PuBu', low=0, high=0, axis=0,
866-
subset=None):
866+
subset=None, text_color_threshold=0.408):
867867
"""
868868
Color the background in a gradient according to
869869
the data in each column (optionally row).
@@ -879,26 +879,39 @@ def background_gradient(self, cmap='PuBu', low=0, high=0, axis=0,
879879
1 or 'columns' for columnwise, 0 or 'index' for rowwise
880880
subset: IndexSlice
881881
a valid slice for ``data`` to limit the style application to
882+
text_color_threshold: float or int
883+
luminance threshold for determining text color. Facilitates text
884+
visibility across varying background colors. From 0 to 1.
885+
0 = all text is dark colored, 1 = all text is light colored.
886+
887+
.. versionadded:: 0.24.0
882888
883889
Returns
884890
-------
885891
self : Styler
886892
887893
Notes
888894
-----
889-
Tune ``low`` and ``high`` to keep the text legible by
890-
not using the entire range of the color map. These extend
891-
the range of the data by ``low * (x.max() - x.min())``
892-
and ``high * (x.max() - x.min())`` before normalizing.
895+
Set ``text_color_threshold`` or tune ``low`` and ``high`` to keep the
896+
text legible by not using the entire range of the color map. The range
897+
of the data is extended by ``low * (x.max() - x.min())`` and ``high *
898+
(x.max() - x.min())`` before normalizing.
899+
900+
Raises
901+
------
902+
ValueError
903+
If ``text_color_threshold`` is not a value from 0 to 1.
893904
"""
894905
subset = _maybe_numeric_slice(self.data, subset)
895906
subset = _non_reducing_slice(subset)
896907
self.apply(self._background_gradient, cmap=cmap, subset=subset,
897-
axis=axis, low=low, high=high)
908+
axis=axis, low=low, high=high,
909+
text_color_threshold=text_color_threshold)
898910
return self
899911

900912
@staticmethod
901-
def _background_gradient(s, cmap='PuBu', low=0, high=0):
913+
def _background_gradient(s, cmap='PuBu', low=0, high=0,
914+
text_color_threshold=0.408):
902915
"""Color background in a range according to the data."""
903916
with _mpl(Styler.background_gradient) as (plt, colors):
904917
rng = s.max() - s.min()
@@ -909,8 +922,39 @@ def _background_gradient(s, cmap='PuBu', low=0, high=0):
909922
# https://github.com/matplotlib/matplotlib/issues/5427
910923
normed = norm(s.values)
911924
c = [colors.rgb2hex(x) for x in plt.cm.get_cmap(cmap)(normed)]
912-
return ['background-color: {color}'.format(color=color)
913-
for color in c]
925+
if (not isinstance(text_color_threshold, (float, int)) or
926+
not 0 <= text_color_threshold <= 1):
927+
msg = "`text_color_threshold` must be a value from 0 to 1."
928+
raise ValueError(msg)
929+
930+
def relative_luminance(color):
931+
"""
932+
Calculate relative luminance of a color.
933+
934+
The calculation adheres to the W3C standards
935+
(https://www.w3.org/WAI/GL/wiki/Relative_luminance)
936+
937+
Parameters
938+
----------
939+
color : matplotlib color
940+
Hex code, rgb-tuple, or HTML color name.
941+
942+
Returns
943+
-------
944+
float
945+
The relative luminance as a value from 0 to 1
946+
"""
947+
rgb = colors.colorConverter.to_rgba_array(color)[:, :3]
948+
rgb = np.where(rgb <= .03928, rgb / 12.92,
949+
((rgb + .055) / 1.055) ** 2.4)
950+
lum = rgb.dot([.2126, .7152, .0722])
951+
return lum.item()
952+
953+
text_colors = ['#f1f1f1' if relative_luminance(x) <
954+
text_color_threshold else '#000000' for x in c]
955+
956+
return ['background-color: {color};color: {tc}'.format(
957+
color=color, tc=tc) for color, tc in zip(c, text_colors)]
914958

915959
def set_properties(self, subset=None, **kwargs):
916960
"""

pandas/tests/io/formats/test_style.py

+25-2
Original file line numberDiff line numberDiff line change
@@ -1017,9 +1017,9 @@ def test_hide_columns_mult_levels(self):
10171017
assert ctx['body'][1][2]['display_value'] == 3
10181018

10191019

1020+
@td.skip_if_no_mpl
10201021
class TestStylerMatplotlibDep(object):
10211022

1022-
@td.skip_if_no_mpl
10231023
def test_background_gradient(self):
10241024
df = pd.DataFrame([[1, 2], [2, 4]], columns=['A', 'B'])
10251025

@@ -1031,7 +1031,30 @@ def test_background_gradient(self):
10311031

10321032
result = df.style.background_gradient(
10331033
subset=pd.IndexSlice[1, 'A'])._compute().ctx
1034-
assert result[(1, 0)] == ['background-color: #fff7fb']
1034+
1035+
assert result[(1, 0)] == ['background-color: #fff7fb',
1036+
'color: #000000']
1037+
1038+
@pytest.mark.parametrize(
1039+
'c_map,expected', [
1040+
(None, {
1041+
(0, 0): ['background-color: #440154', 'color: #f1f1f1'],
1042+
(1, 0): ['background-color: #fde725', 'color: #000000']}),
1043+
('YlOrRd', {
1044+
(0, 0): ['background-color: #ffffcc', 'color: #000000'],
1045+
(1, 0): ['background-color: #800026', 'color: #f1f1f1']})])
1046+
def test_text_color_threshold(self, c_map, expected):
1047+
df = pd.DataFrame([1, 2], columns=['A'])
1048+
result = df.style.background_gradient(cmap=c_map)._compute().ctx
1049+
assert result == expected
1050+
1051+
@pytest.mark.parametrize("text_color_threshold", [1.1, '1', -1, [2, 2]])
1052+
def test_text_color_threshold_raises(self, text_color_threshold):
1053+
df = pd.DataFrame([[1, 2], [2, 4]], columns=['A', 'B'])
1054+
msg = "`text_color_threshold` must be a value from 0 to 1."
1055+
with tm.assert_raises_regex(ValueError, msg):
1056+
df.style.background_gradient(
1057+
text_color_threshold=text_color_threshold)._compute()
10351058

10361059

10371060
def test_block_names():

0 commit comments

Comments
 (0)