Color Library HSV Bug
While experimenting with terminal color schemes, I have been using the Color library. This library is pretty new! When I last experimented with color schemes, I used the colour library. I would like to compare colors using the ΔE* CIEDE2000 algorithm, however, and I switched to the Color library because the colour library does not support the CIELAB color space. It is a nice library! Referencing the source code while I use it, I spotted a bug in the HSV color model implementation.
The bug is in the rgb2hsv
function, which converts from the RGB color model
to the HSV color
model.
rgb2hsv :: (Ord e, Fractional e) => Color RGB e -> Color HSV e
ColorRGB r g b) = ColorHSV h s v
rgb2hsv (where
!max' = max r (max g b)
!min' = min r (min g b)
!h' | max' == r = ( (g - b) / (max' - min')) / 6
| max' == g = (2 + (b - r) / (max' - min')) / 6
| max' == b = (4 + (r - g) / (max' - min')) / 6
| otherwise = 0
!h
| h' < 0 = h' + 1
| otherwise = h'
!s
| max' == 0 = 0
| otherwise = (max' - min') / max'
!v = max'
{-# INLINE rgb2hsv #-}
The correct equations for conversion from RGB to HSV are as follows, where \(R, G, B \in [0,1]\).
First, the maximum and minimum RGB component is computed.
\[ X_{max} = \max(R, G, B) \]
\[ X_{min} = \min(R, G, B) \]
The chroma is then computed as the difference of these values.
\[ C = X_{max} - X_{min} \]
The hue
(H
) is generally measured in degrees, as it is the angle
component of the cylindrical geometry of the HSV color model.
The calculation depends on which RGB component is greatest, and the zero
case is special.
\[ H = \left\{ \begin{array}{ll} 0, & \text{if } C = 0 \\ 60^{\circ} \times (0 + \frac{G - B}{C}), & \text{if } X_{max} = R \\ 60^{\circ} \times (2 + \frac{B - R}{C}), & \text{if } X_{max} = G \\ 60^{\circ} \times (4 + \frac{R - G}{C}), & \text{if } X_{max} = B \\ \end{array} \right. \]
The saturation
(S
) is generally measured as a percentage, representing the
distance from the center (0%) to the edge (100%) of the cylindrical
geometry of the HSV
color model. The zero case is special.
\[ S = \left\{ \begin{array}{ll} 0, & \text{if } C = 0 \\ \frac{C}{X_{max}}, & \text{otherwise} \\ \end{array} \right. \]
The value (V
) is generally measured as a percentage as
well, representing the distance from the bottom (0%) to the top (100%)
of the cylindrical geometry of the HSV color model. It
is simply the value of the maximum RGB component.
\[ V = X_{max} \]
The bug in the above implementation is in the calculation of the hue.
The special case is not handled! When converting a greyscale color, all
of the RGB components are equal, so the chroma is zero. The first guard
(max' == r
) is true because both values being compared are
the same, and there is a division by zero as a result.
I wrote a test program (available on GitHub) that demonstrates the bug.
blackSRGB :: Color (SRGB 'NonLinear) Word8
= ColorSRGB 0x00 0x00 0x00
blackSRGB
blackHSV :: Color (HSV (SRGB 'NonLinear)) Float
= convert blackSRGB
blackHSV
main :: IO ()
= print blackHSV main
Running this program prints the following. The hue is NaN, but the correct value is 0.
<HSV-SRGB 'NonLinear:( NaN, 0.00000000, 0.00000000)>
The fix is simple: handle the special case first. Note that the
otherwise
case is still required in order to satisfy the
type checker.
rgb2hsv :: (Ord e, Fractional e) => Color RGB e -> Color HSV e
ColorRGB r g b) = ColorHSV h s v
rgb2hsv (where
!max' = max r (max g b)
!min' = min r (min g b)
!c' = max' - min'
!h' | c' == 0 = 0
| max' == r = ( (g - b) / c') / 6
| max' == g = (2 + (b - r) / c') / 6
| max' == b = (4 + (r - g) / c') / 6
| otherwise = 0
!h
| h' < 0 = h' + 1
| otherwise = h'
!s
| max' == 0 = 0
| otherwise = c' / max'
!v = max'
{-# INLINE rgb2hsv #-}
The rgb2hsl
function, which converts from the RGB color model
to the HSL color
model, has a similar bug. I fixed it in the same way.
I submitted a pull request to the library repository.