Issues calculating HSP color model

355 views Asked by At

[Intro]

HSP color model is a made-up color model created in 2006. It uses the same values as HSV for Hue and Saturation but, for calculating the P (perceived brightness), it uses Weighted Euclidean norm of the [R, G, B] vector. More info: https://alienryderflex.com/hsp.html

As you can see, at the bottom of the website, there are formulas for calculating between RGB and HSP that I've taken and re-formatted for Python.

[Issues]

In some places, I found that for calculating the Perceived brightness, you need to first linearize the RGB channels (assuming it's sRGB) but if you do so, then the formulas no longer work. For that reason, I'm not doing that and applying the formulas directly on the input RGB color. Also, I found in a js library someone made it so the perceived brightness is in range 0-255. I don't know where they got that idea, but it should be in range 0-100 (percentage).

[Where it all goes wrong]

I don't have any issues with calculating from RGB to HSP. The problem is when calculating RGB from HSP. I won't bother you with the full code since you can take it from the link above but I'm giving you a snippet of the part that doesn't work correctly (or I have a mistake that I can't find).

P.S: After further investigation, it turns out that more than just this snippet gives false results!

elif H < 4 / 6:  # B > G > R
    H = 6 * (-H + 4 / 6)
    B = (P ** 2 / (Pb + Pg * H ** 2)) ** 0.5
    G = B * H
    R = 0

This is the part where Saturation is 100%. The problem is that when you pass it these values HSP(253, 100, 50), or any similar ones, the resulting blue is beyond the acceptable range (in this case 356). I tried clamping the values to 255 but then when doing the RGB to HSV conversion, the values don't match so the problem isn't there.

Any ideas?

1

There are 1 answers

0
Aleks K. On

So, I found a mistake in my code which brought down the out-of-range values from 300+ to a maximum of 261 which is acceptable to be clamped at 255 (for 8-bit colors) without needing to do anything to the other values. No values need to be clamped on the black side.

Here's my simplified version of the calculation with comments:

def hsp_to_rgb(HSP: tuple | list, depth: int = 8, normalized: bool = False):
    """### Takes an HSP color and returns R, G, B values.

    #### N/B: All examples below are given for 8-bit color depth that has range 0-255. \
        If you want to use this function with a different depth the actual range is 0-(max value for bit depth).

    ### Args:
        `color` (tuple | list): Either int in range 0-255 or float in range 0-1 
        `depth` (int): The bit depth of the input RGB values. Defaults to 8-bit (range 0-255)
        `normalized` (bool, optional): Returns the values in range 0-1. Defaults to False.

    Reference: http://alienryderflex.com/hsp.html

    ### Returns:
        list[int, int, int] | list[float, float, float]: (H, S, P)
    """
    H, S, P = HSP[0]/360, HSP[1]/100, HSP[2]/100
    max_value = 2 ** depth - 1

    def wrap(HSP: tuple | list, c1: float, c2: float, c3: float, S1: bool):
        """### This is an internal helper function for the hsp_to_rgb function to lift off some of the calculations.
        c1, c2, c3 - Pr, Pg, Pb in different order

        ### Args:
            `HSP` (tuple | list): Hue, Saturation, Perceived brightness in range 0-1
            `c1` (float): Constant. Either 0.299, 0.587 or 0.114
            `c2` (float): Constant. Either 0.299, 0.587 or 0.114
            `c3` (float): Constant. Either 0.299, 0.587 or 0.114
            `S1` (bool): Whether S (Saturation) is 1 (100%). Defaults to False

        ### Returns:
            tuple[float, float, float]: R, G, B values in different order depending on the constants.
        """
        if S1:
            ch1 = (HSP[2] ** 2 / (c1 + c2 * HSP[0] ** 2)) ** 0.5
            ch2 = ch1 * HSP[0]
            ch3 = 0
            return ch3, ch1, ch2

        min_over_max = 1 - HSP[1]
        part = 1 + HSP[0] * (1 / min_over_max - 1)
        ch1 = HSP[2] / (c1 / min_over_max ** 2 + c2 * part ** 2 + c3) ** 0.5
        ch2 = ch1 / min_over_max
        ch3 = ch1 + HSP[0] * (ch2 - ch1)
        return ch1, ch2, ch3

    # Get weights constants
    Pr, Pg, Pb = 0.299, 0.587, 0.114

    # Calculate R, G, B based on the Hue
    if H < 1 / 6:  # R > G > B
        H = 6 * H
        B, R, G = wrap((H, S, P), Pr, Pg, Pb, S >= 1)
    elif H < 2 / 6:  # G > R > B
        H = 6 * (-H + 2 / 6)
        B, G, R = wrap((H, S, P), Pg, Pr, Pb, S >= 1)
    elif H < 3 / 6:  # G > B > R
        H = 6 * (H - 2 / 6)
        R, G, B = wrap((H, S, P), Pg, Pb, Pr, S >= 1)
    elif H < 4 / 6:  # B > G > R
        H = 6 * (-H + 4 / 6)
        R, B, G = wrap((H, S, P), Pb, Pg, Pr, S >= 1)
    elif H < 5 / 6:  # B > R > G
        H = 6 * (H - 4 / 6)
        G, B, R = wrap((H, S, P), Pb, Pr, Pg, S >= 1)
    else:            # R > B > G
        H = 6 * (-H + 1)
        G, R, B = wrap((H, S, P), Pr, Pb, Pg, S >= 1)

    return [min(i, 1.0) for i in (R, G, B)] if normalized else [min(i*max_value, 255) for i in (R, G, B)]

This works pretty well and the conversions are really accurate. Note that in order to get perfect conversions, you'll need to use an exact floating-point number for the calculations. Otherwise, you'll get a number of overlapping values due to limitations of the system. Ex. RGB = 256 * 256 * 256 = 16 777 216 colors, whereas HSP = 360 * 100 * 100 = 3 600 000 unique colors.