2 years ago

#64243

test-img

Jacob Birkett

Inconsistent results with floating point arithmetic (color conversion formulae)

I am trying to parse a bunch of color values from various files, perform lightness changes, and write them back into the files. I have to put this in a PKGBUILD for the Arch User Repository and would love to avoid any dependencies.

I have written the simple class below, and it works sometimes. The test function at the bottom of the snippet makes an iterable of Color instances from a decent number of test values, converts them to HSL, and then back to RGB. It then compares each with the original, to check if they match.

Sometimes the script exits successfully, but more often times not. I randomly see huge discrepancies, such as RGB #c7c7c7 -> HSL -> RGB #be3841. This doesn't make sense to me, how can it be this far off? There has to be an issue with my math, but I have checked that against Wikipedia so many times that I have come to the conclusion that it is simply a rounding error due to the precision of a 64-bit float, which Python uses by default on my platform.

I simply do not understand the randomness. I have tried using gmpy2 with 128-bit precision to no avail, it fails just as often. And if I use Decimal, the interpreter complains that I am accessing local variables before assignment which means that the comparison operators in the algorithms are not working as I would expect.

If you quickly run the script a few times, eventually there won't be an error. https://www.online-python.com/ujtET97ZxQ

class Color:
    def __init__(self, rgba):
        rgba = rgba.lstrip("#")

        if len(rgba) > 8:
            raise Exception("Expected up to 8 hexadecimal characters")

        self._red = int(rgba[0:2], 16)
        self._green = int(rgba[2:4], 16)
        self._blue = int(rgba[4:6], 16)

        if len(rgba) == 8:
            self.alpha = int(rgba[6:8], 16)
        else:
            self.alpha = None

        self._rgb_changed = True
        self._hsl_changed = False

    def __repr__(self):
        return self.hex()

    def __hash__(self):
        return hash(self.hex())

    def __eq__(self, other):
        return self.__class__ is other.__class__ and self.__hash__() == other.__hash__()

    def hex(self):
        return "#{:02x}{:02x}{:02x}".format(self.red, self.green, self.blue) + ("{:02x}".format(self.alpha) if self.alpha else "")

    def hsl(self):
        if self._hsl_changed:
            raise Exception(
                "Cannot convert from RGB when the last modified value was of HSL")

        rp = self._red / 255
        gp = self._green / 255
        bp = self._blue / 255

        cmax = max(rp, gp, bp)
        cmin = min(rp, gp, bp)
        delta = cmax - cmin

        if delta == 0:
            self._hue = 0
        elif cmax == rp:
            self._hue = 60 * ((gp - bp) / delta % 6)
        elif cmax == gp:
            self._hue = 60 * ((bp - rp) / delta + 2)
        elif cmax == bp:
            self._hue = 60 * ((rp - gp) / delta + 4)

        self._lightness = (cmax + cmin) / 2

        if delta == 0:
            self._saturation = 0
        else:
            self._saturation = delta / (1 - abs(2 * self._lightness - 1))

        self._rgb_changed = False

        return (self._hue, self._saturation, self._lightness)

    def rgb(self):
        if self._rgb_changed:
            raise Exception(
                "Cannot convert from HSL when the last modified value was of RGB")

        c = (1 - abs(2 * self._lightness - 1)) * self._saturation
        x = c * (1 - abs((self._hue / 60) % 2 - 1))
        m = self._lightness - c / 2

        if self._hue >= 0 and self._hue < 60:
            (rp, gp, bp) = (c, x, 0)
        elif self._hue >= 60 and self._hue < 120:
            (rp, gp, bp) = (x, c, 0)
        elif self._hue >= 120 and self._hue < 180:
            (rp, gp, bp) = (0, c, x)
        elif self._hue >= 180 and self._hue < 240:
            (rp, gp, bp) = (0, x, c)
        elif self._hue >= 240 and self._hue < 300:
            (rp, gp, bp) = (x, 0, c)
        elif self._hue >= 300 and self._hue < 360:
            (rp, gp, bp) = (c, 0, x)

        self._red = round((rp + m) * 255)
        self._green = round((gp + m) * 255)
        self._blue = round((bp + m) * 255)

        self._hsl_changed = False

        return (self._red, self._green, self._blue)

    @property
    def red(self):
        if self._hsl_changed:
            self.rgb()

        return self._red

    @property
    def green(self):
        if self._hsl_changed:
            self.rgb()

        return self._green

    @property
    def blue(self):
        if self._hsl_changed:
            self.rgb()

        return self._blue

    @red.setter
    def red(self, red):
        self._red = red
        self._rgb_changed = True

    @green.setter
    def green(self, green):
        self._green = green
        self._rgb_changed = True

    @blue.setter
    def blue(self, blue):
        self._blue = blue
        self._rgb_changed = True

    @property
    def hue(self):
        if self._rgb_changed:
            self.hsl()

        return self._hue

    @property
    def saturation(self):
        if self._rgb_changed:
            self.hsl()

        return self._saturation

    @property
    def lightness(self):
        if self._rgb_changed:
            self.hsl()

        return self._lightness

    @hue.setter
    def hue(self, hue):
        self._hue = hue
        self._hsl_changed = True

    @saturation.setter
    def saturation(self, saturation):
        self._saturation = saturation
        self._hsl_changed = True

    @lightness.setter
    def lightness(self, lightness):
        self._lightness = lightness
        self._hsl_changed = True


def __test(colors):
    from copy import deepcopy

    colors = set(Color(color) for color in colors)
    colors_mutated = deepcopy(colors)

    for color in colors_mutated:
        color.hsl()
        color.rgb()

    for (color_a, color_b) in zip(colors, colors_mutated):
        if color_a != color_b:
            raise AssertionError(
                "Colors do not match! ({}, {})".format(color_a, color_b))

        print("{} == {}".format(color_a, color_b))


if __name__ == "__main__":
    colors = set("#353b48, #666666, #444852, #fcfcfc, #434343, #90939b, #353537, #2b303b, #b6b8c0, #241f31, #303440, #000000, #9398a2, #dfdfdf, #f0f1f2, #cfcfcf, #d3d8e2, #505666, #808080, #8a939f, #282b36, #afb8c6, #383838, #4dadd4, #353a48, #838383, #202229, #7a7f8a, #7a7f8b, #2e3340, #70788d, #66a1dc, #17191f, #d7d7d7, #545860, #39404d, #161a26, #be3841, #3c4049, #2f3a42, #f0f2f5, #4e4eff, #262934, #1d1f26, #404552, #353945, #383c45, #8f939d, #f7ef45, #a4aab7, #b2cdf1, #444a58, #bac3cf, #ff00ff, #f46067, #5c6070, #c7cacf, #525762, #ff0b00, #323644, #f75a61, #464646, #ecedf0, #171717, #e01b24, #1b1b1b, #797d87, #15171c, #8c919d, #4d4f52, #5b627b, #728495, #454c5c, #4080fb, #e2e2e2, #d1d3da, #c0e3ff, #3580e4, #b7c0d3, #232428, #2d323f, #6e6e6e, #dcdcdc, #b9bcc2, #cc575d, #a1a1a1, #52555e, #353a47, #7c818c, #979dac, #2f343f, #dde3e9, #828282, #c5dcf7, #001aff, #722563, #afb8c5, #222529, #8abfdd, #666a74, #f68086, #edf5fb, #4b5162, #a9acb2, #786613, #c7c7c7, #eeeff1, #2b2e37, #f70505, #292c36, #3e434f, #5c616c, #f57900, #2d303b, #f5f6f7, #5f697f, #2e3436, #808791, #f08437, #cbd2e3, #e5a50a, #eeeeee, #252932, #e7e8eb, #3e4350, #ff1111, #ef2929, #fc4138, #fcfdfd, #7a7a7a, #21242b, #bebebe, #ffffff, #252a35, #5252ff, #767b87, #535353, #3e3e3e, #aa5555, #5f6578, #c4c7cc, #383c4a, #102b68, #21252b, #f3af0b, #cfd6e6, #d7787d, #ff7a80, #fdfdfd, #398dd3, #a51d2d, #73d216, #f8f8f9, #262932, #2f343b, #2b2e39, #2d3036, #f04a50, #006098, #3f4453, #ad4242, #1b1c21, #b9bfce, #ff1616, #e5e5e5, #ed686f, #eaebed, #fbfcfc, #398cd3, #262933, #5294e2, #0000ff, #d7d8dd, #2b2f3b, #f13039, #999999, #1f1f1f, #50dbb5, #525252, #ff2121, #f27835, #91949c, #adafb5, #3b3c3e, #d3d4d8, #525d76, #434652, #cacaca, #2d323d, #f9fafb, #617c95, #ededed, #1a1a1a, #d8354a, #90949e, #313541, #a8a8a8, #dbdfe3, #cecece, #0f0f0f, #1d242a, #b8babf, #0f1116, #eef4fc, #e2e7ef, #d3dae3".split(", "))
    __test(colors)

python

python-3.x

math

floating-point

precision

0 Answers

Your Answer

Accepted video resources