4
\$\begingroup\$

This is a NumPy script that converts BGR arrays to HSL arrays and back, without using OpenCV. Input and output values are arrays of 3 dimensions with values ranging from 0 to 1, the shape of the arrays are (height, width, 3) and the data type is float.

I will use it in my GUI program, to implement non-separable blend modes, I have implemented 24 separable blend modes, I posted them on GitHub, I implemented them because I haven't found them in cv2. I implemented them according to this, this, this, and PhotoshopMathFP.glsl .

I didn't use cv2.cvtColor(img, cv2.COLOR_BGR2HLS_FULL) because all my image operations are in float domain.

I implemented all the code entirely by myself, without outside help, I haven't used a single if statement in actual computation, except for one that checks whether further operations are needed, everything is done using boolean masks, and I made the code as efficient as I can make it. I ported the code from PhotoshopMathFP.glsl to NumPy, that is all.


Code

import numpy as np
from typing import Tuple


def BGR_to_HSL_saturation_1(
    hsl: np.ndarray,
    mina: np.ndarray,
    maxa: np.ndarray,
    delta: np.ndarray,
) -> np.ndarray:
    quotient = np.ones(shape=hsl.shape[:2], dtype=float)
    safe = (mina != 0) | (maxa != 0)
    quotient[safe] = delta[safe] / (mina[safe] + maxa[safe])
    return np.clip(quotient, 0, 1)


def BGR_to_HSL_saturation_2(
    hsl: np.ndarray,
    mina: np.ndarray,
    maxa: np.ndarray,
    delta: np.ndarray,
) -> np.ndarray:
    quotient = np.ones(shape=hsl.shape[:2], dtype=float)
    safe = mina + maxa < 2
    quotient[safe] = delta[safe] / (2 - mina[safe] - maxa[safe])
    return np.clip(quotient, 0, 1)


def BGR_to_HSL_helper_1(
    hsl: np.ndarray,
    mina: np.ndarray,
    maxa: np.ndarray,
    delta: np.ndarray,
    mask: np.ndarray,
) -> np.ndarray:
    more = ~(less := hsl[..., 2] < 0.5) & mask
    less &= mask
    hsl[..., 1][less] = BGR_to_HSL_saturation_1(hsl, mina, maxa, delta)[less]
    hsl[..., 1][more] = BGR_to_HSL_saturation_2(hsl, mina, maxa, delta)[more]
    return hsl


def BGR_to_HSL_delta(
    delta: np.ndarray, hsl: np.ndarray, img: np.ndarray, maxa: np.ndarray
) -> np.ndarray:
    ndelta = np.ones_like(hsl)
    safe = delta != 0
    delta = delta[safe, np.newaxis]
    ndelta[safe] = ((maxa[safe, np.newaxis] - img[safe]) / 6 + delta / 2) / delta
    return ndelta


def BGR_to_HSL_helper_2(
    hsl: np.ndarray,
    img: np.ndarray,
    maxa: np.ndarray,
    delta: np.ndarray,
    mask: np.ndarray,
) -> np.ndarray:
    delta = BGR_to_HSL_delta(delta, hsl, img, maxa)
    maxi = img == maxa[..., np.newaxis]
    for index, offset, color_a, color_b in (
        (0, 2 / 3, 1, 2),
        (1, 1 / 3, 2, 0),
        (2, 0, 0, 1),
    ):
        maski = maxi[..., index] & mask
        hsl[..., 0][maski] = (offset + delta[..., color_a] - delta[..., color_b])[maski]

    hsl[..., 0] %= 1
    return hsl


def BGR_to_HSL(img: np.ndarray) -> np.ndarray:
    delta = (maxa := img.max(axis=-1)) - (mina := img.min(axis=-1))
    hsl = np.zeros_like(img)
    hsl[..., 2] = (maxa + mina) / 2
    mask = delta != 0.0
    if mask.any():
        hsl = BGR_to_HSL_helper_1(hsl, mina, maxa, delta, mask)
        hsl = BGR_to_HSL_helper_2(hsl, img, maxa, delta, mask)
    return hsl


def Hue_to_color(f1: np.ndarray, f2: np.ndarray, hue: np.ndarray) -> np.ndarray:
    hue %= 1
    color = np.zeros_like(hue)
    gmask = mask = hue < 2 / 3
    color[mask] = (f1 + (delta := f2 - f1) * (2 / 3 - hue) * 6)[mask]
    gmask |= (mask := hue < 0.5)
    color[mask] = f2[mask]
    gmask |= (mask := hue < 1 / 6)
    color[mask] = (f1 + delta * 6 * hue)[mask]
    mask = ~gmask
    color[mask] = f1[mask]
    return color


def HSL_to_BGR_helper(hsl: np.ndarray) -> Tuple[np.ndarray]:
    bgr = np.zeros_like(hsl)
    empty = (y := hsl[..., 1]) == 0
    bgr[empty] = np.dstack([z := hsl[..., 2]] * 3)[empty]
    f2 = np.zeros_like(y)
    mask = (less := z < 0.5) & (full := ~empty)
    f2[mask] = (z * (1 + y))[mask]
    mask = ~less & full
    f2[mask] = (y + z - y * z)[mask]
    f1 = 2 * z - f2
    return bgr, f1, f2, full


def HSL_to_BGR(hsl: np.ndarray) -> np.ndarray:
    bgr, f1, f2, full = HSL_to_BGR_helper(hsl)
    bgr[..., 0][full] = Hue_to_color(f1, f2, (x := hsl[..., 0]) - 1 / 3)[full]
    bgr[..., 1][full] = Hue_to_color(f1, f2, x)[full]
    bgr[..., 2][full] = Hue_to_color(f1, f2, x + 1 / 3)[full]
    return bgr


if __name__ == "__main__":
    import cv2

    shape = (36, 64, 3)
    zeros = np.zeros(shape)
    assert np.array_equal(BGR_to_HSL(zeros), zeros)
    assert np.array_equal(HSL_to_BGR(zeros), zeros)
    ones = np.ones(shape)
    assert np.array_equal(HSL_to_BGR(BGR_to_HSL(ones)), ones)
    for _ in range(256):
        bools = np.random.choice((0.0, 1.0), size=shape)
        assert np.isclose(HSL_to_BGR(BGR_to_HSL(bools)), bools).all()
        grey = np.random.random(size=shape[:2])
        grey = np.dstack([grey] * 3)
        assert np.isclose(HSL_to_BGR(BGR_to_HSL(grey)), grey).all()
        img = np.random.random(size=shape)
        assert np.isclose(HSL_to_BGR(BGR_to_HSL(img)), img).all()
        intimg = (img * 255).astype(np.uint8)
        diff = cv2.absdiff(
            cv2.cvtColor(intimg, cv2.COLOR_BGR2HLS_FULL),
            (BGR_to_HSL(img) * 255).astype(np.uint8)[..., (0, 2, 1)],
        )
        assert (diff > 16).sum() < 24
        assert not (diff[..., 1:] > 128).any()

Because I use floats, there is inherent impression, and there is so much conversion going on, the output is different from that obtained from cv2.cvtColor, but mostly the difference is small, but cv2.absdiff values can be over 250, I think it must be from the hue component but I am not sure.

My code converts from BGR color space to HSL color space, to get the same order as HLS you need to do hsl[..., (0, 2, 1)].

My code works for all inputs, and it is verified to be correct. How can I make it more efficient, and how can I make the output closer to that from cv2 (that is, when converted like this: (hsl * 255).astype(np.uint8)[..., (0, 2, 1)], the result of cv2.absdiff of the converted output and that from cv2.cvtColor(img, cv2.COLOR_BGR2HLS_FULL) would be less than the current value)?


I just added a new check and I have confirmed that the discrepancy of about 254 for some values is indeed from the hue channel, which is not a big problem, because the hue goes from red to yellow and then green then cyan, blue, magenta and finally back to red, it rotates and will always go back. The value wraps around and so a difference of 254 is just -2, nevertheless it is something to be fixed.


Edit

I just found out there are some huge discrepancies because the input values are invalid, the values inside the result of np.random.random(size=shape) would be very likely not be an integer between 0 and 255 when multiplied by 255.

If the values are fractions n / 255 where n is an integer between 0 and 255, my code gives the correct output, I tested with this:

byte = range(256)
colors = np.array(np.meshgrid(byte, byte, byte), dtype=np.uint8).T.reshape(-1, 3)
img = colors.reshape((4096, 4096, 3))
assert not (
    cv2.absdiff(
        cv2.cvtColor(img, cv2.COLOR_BGR2HLS_FULL),
        (BGR_to_HSL(img / 255) * 255).astype(np.uint8)[..., (0, 2, 1)],
    )
    > 1
).any()

I have converted all 16777216 colors and the absolute difference is never greater than 1.

So there wasn't actually a problem.

\$\endgroup\$
4
  • \$\begingroup\$ Have you run the code through Mypy? I don’t think all your type annotations are correct… \$\endgroup\$ Sep 2 at 2:28
  • \$\begingroup\$ without using OpenCV - then why do you import cv2? \$\endgroup\$
    – Reinderien
    Sep 2 at 17:30
  • 1
    \$\begingroup\$ That hasn't been made obvious, since you're running your verification routine in global code with no named function or comments. Anyway, I'll address this in a review. \$\endgroup\$
    – Reinderien
    Sep 2 at 17:32
  • \$\begingroup\$ For function BGR_to_HSL_saturation_1, why does it accept hsl? I'd expect that it accepts something called like bgr, and returns HSL. \$\endgroup\$
    – Reinderien
    Sep 2 at 17:40

2 Answers 2

2
\$\begingroup\$

I find the code easy to read; there are no legibility issues (within the context that you need to understand Numpy).

It's a good idea that you're doing division safety checks, but you should simplify them. For instance, this:

    safe = (mina != 0) | (maxa != 0)
    quotient[safe] = delta[safe] / (mina[safe] + maxa[safe])

should really just be

denominator = mina + maxa
safe = denominator != 0
quotient[safe] = delta[safe] / denominator[safe]

This also better handles the case where mina and maxa are safe individually but their sum is not.

There are many argument names that appear to be errors. For any BGR_to_HSL function, I would expect an argument name of bgr and not hsl.

Where possible, convert module calls to instance calls, e.g. np.clip(quotient) becomes quotient.clip().

From this expression:

((maxa[safe, np.newaxis] - img[safe]) / 6 + delta / 2) / delta

You can divide out delta from the last term and just + 0.5.

%= 1 is fine, but you might see a performance difference if you switch to a non-division method such as subtracting the result of np.fix().

Your functions could use """ docstrings.

Everything under your __main__ guard is still in the global scope. Put it in a function.

np.isclose().all() is just np.allclose().

\$\endgroup\$
2
\$\begingroup\$

Floating-point arithmetic is very slow and since you're doing a lot of it, it plays a major part in slowing things down. Since you're dealing with colours, you can replace floats with ints in the range from 0 to 255.


The code has a lot of readability issues.

Variable names are very hard to discern for people not familiar with the subject. This applies to almost every name here really.

You're trading readability off for efficiency, so to keep the code readable you need to add comments explaining what is going on. This is the case for all low-level programming and for bitwise operations in particular.

There are a lot of magic numbers (e.g. 1/3, 1/6, 24), they can be given meaningful names.

hsl: np.ndarray,
img: np.ndarray,
maxa: np.ndarray,
delta: np.ndarray,
mask: np.ndarray,

I see almost no point in specifying that those names refer to NumPy ndarrays if you're not telling us what types can actually go into these arrays (but a trailing comma is always appreciated).

\$\endgroup\$
2
  • 2
    \$\begingroup\$ “Floating-point arithmetic is very slow” This is simply not true in my experience. “Since you're dealing with colours, you can replace floats with ints in the range from 0 to 255.” This is also not true. Converting RGB to HLS (or any other color space really) and then back using integers will result in different colors because if rounding errors, which can be quite extreme in some cases involving angles. It depends on what you do with colors if an 8-bit integer is enough or not. And often it is so much more convenient working in floating-point, where you don’t have to worry about overflow. \$\endgroup\$ Sep 2 at 2:21
  • \$\begingroup\$ Re. numpy types, it's true that not describing what types go in the arrays is a problem, but it's false that you're better off not writing the typehint at all. To improve this, one solution is numpy.org/devdocs/reference/typing.html \$\endgroup\$
    – Reinderien
    Sep 2 at 17:26

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Not the answer you're looking for? Browse other questions tagged or ask your own question.