Skip to content

Commit

Permalink
switch to math.sumprod to reduce floating point error, make contrast …
Browse files Browse the repository at this point in the history
…computation more robust, both for small errors and for Display P3 colors
  • Loading branch information
apparebit committed May 31, 2024
1 parent 5a74344 commit 36e16fb
Show file tree
Hide file tree
Showing 3 changed files with 86 additions and 13 deletions.
57 changes: 53 additions & 4 deletions prettypretty/color/apca.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,20 @@
import math


# Switching from naive floating point math to math.sumprod caused one test to
# have -1e-16 for blue component, which triggers a math domain error in
# srgb_to_luminance. The work-around is to clamp slightly negative values to 0.
# FIXME: Consider doing the same when converting srgb or p3 to their linear
# versions.
_NEGATIVE_SRGB_TOLERANCE = -1e-15

_APCA_EXPONENT = 2.4
_APCA_COEFFICIENTS = (0.2126729, 0.7151522, 0.0721750)
_APCA_SRGB_COEFFICIENTS = (0.2126729, 0.7151522, 0.0721750)
_APCA_P3_COEFFICIENTS = (
0.2289829594805780,
0.6917492625852380,
0.0792677779341829,
)

_APCA_BLACK_THRESHOLD = 0.022
_APCA_BLACK_CLIP = 1.414
Expand All @@ -29,9 +41,29 @@ def srgb_to_luminance(r: float, g: float, b: float) -> float:
"""
:bdg-warning:`Internal API` Determine the non-standard APCA luminance for
the given sRGB color.
Conceptually, this function performs a conversion similar to that from sRGB
to XYZ. That means undoing the gamma correction, which is impossible for
out-of-gamut, negative sRGB coordinates. To prevent spurious exceptions
caused by negative coordinates that are well within the floating point error
margins for color conversion, this function clamps values greater than
-1e-15 to zero.
"""
if not all(_NEGATIVE_SRGB_TOLERANCE < c for c in (r, g, b)):
raise ValueError(f'Negative sRGB coordinate for {r}, {g}, {b}')

# The max(...) clamps c to a minimum of zero.
linear_srgb = (math.pow(max(c, 0.0), _APCA_EXPONENT) for c in (r, g, b))
return math.sumprod(_APCA_SRGB_COEFFICIENTS, linear_srgb)


def p3_to_luminance(r: float, g: float, b: float) -> float:
"""
:bdg-warning:`Internal API` Determine the non-standard APCA luminance for
the given P3 color.
"""
linear_srgb = (math.pow(c, _APCA_EXPONENT) for c in (r, g, b))
return sum(c1 * c2 for c1, c2 in zip(_APCA_COEFFICIENTS, linear_srgb))
linear_p3 = (math.pow(c, _APCA_EXPONENT) for c in (r, g, b))
return math.sumprod(_APCA_P3_COEFFICIENTS, linear_p3)


def luminance_to_contrast(
Expand Down Expand Up @@ -81,19 +113,36 @@ def luminance_to_contrast(
return contrast + _APCA_OFFSET


def contrast(
def srgb_contrast(
text_color: tuple[float, float, float],
background_color: tuple[float, float, float],
) -> float:
"""
Compute the contrast between the given text and background colors in the
sRGB color space.
Both text and background colors must be in gamut for sRGB.
"""
text_luminance = srgb_to_luminance(*text_color)
background_luminance = srgb_to_luminance(*background_color)
return luminance_to_contrast(text_luminance, background_luminance)


def p3_contrast(
text_color: tuple[float, float, float],
background_color: tuple[float, float, float],
) -> float:
"""
Compute the contrast between the given text and background colors in the
Display P3 color space.
Both text and background color must in gamut for Display P3.
"""
text_luminance = p3_to_luminance(*text_color)
background_luminance = p3_to_luminance(*background_color)
return luminance_to_contrast(text_luminance, background_luminance)


def use_black_text(r: float, g: float, b: float) -> bool:
"""
Determine whether text should be black or white to maximize its contrast
Expand Down
5 changes: 1 addition & 4 deletions prettypretty/color/conversion.py
Original file line number Diff line number Diff line change
Expand Up @@ -68,10 +68,7 @@
_Matrix: TypeAlias = tuple[_Vector, _Vector, _Vector]

def _multiply(matrix: _Matrix, vector: _Vector) -> _Vector:
return cast(
_Vector,
tuple(sum(r * c for r, c in zip(row, vector)) for row in matrix)
)
return cast(_Vector, tuple(math.sumprod(row, vector) for row in matrix))


# --------------------------------------------------------------------------------------
Expand Down
37 changes: 32 additions & 5 deletions prettypretty/color/object.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
from collections.abc import Iterable
from typing import cast, Literal, overload, Self

from .apca import contrast, use_black_text, use_black_background
from .apca import p3_contrast, srgb_contrast, use_black_text, use_black_background
from .conversion import get_converter
from .difference import deltaE_oklab, closest_oklab
from .gamut import map_into_gamut
Expand Down Expand Up @@ -230,11 +230,38 @@ def contrast_against(self, background: Self) -> float:
"""
Determine the asymmetric contrast of text with this color against the
given background.
The underlying APCA algorithm computes a non-standard luminance for the
two colors. Just like conversions from sRGB to XYZ and from Display P3
to XYZ, there are separate coefficients for converting sRGB and Display
P3 colors.
1. This method first converts both colors to sRGB and, if they are in
gamut, converts them to APCA's custom luminance, and then computes
contrast.
2. If the two colors are out of sRGB gamut, this method tries the same
for Display P3.
This method fails with an exception if the colors are out of gamut for
Display P3, too.
"""
return contrast(
cast(FloatCoordinateSpec, self.to('srgb').coordinates),
cast(FloatCoordinateSpec, background.to('srgb').coordinates),
)
fg_srgb = self.to('srgb')
bg_srgb = background.to('srgb')
if fg_srgb.in_gamut() and bg_srgb.in_gamut():
return srgb_contrast(
cast(FloatCoordinateSpec, fg_srgb.coordinates),
cast(FloatCoordinateSpec, bg_srgb.coordinates),
)

fg_p3 = self.to('p3')
bg_p3 = background.to('p3')
if fg_p3.in_gamut() and bg_p3.in_gamut():
return p3_contrast(
cast(FloatCoordinateSpec, fg_p3.coordinates),
cast(FloatCoordinateSpec, bg_p3.coordinates),
)

raise ValueError(f'{fg_p3} and/or {bg_p3} are out of gamut for Display P3')

def use_black_text(self) -> bool:
"""
Expand Down

0 comments on commit 36e16fb

Please sign in to comment.