# NumPy script to convert BGR to HSL and back

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,
) -> np.ndarray:
more = ~(less := hsl[..., 2] < 0.5) & 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,
) -> 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),
):

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
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)
color[mask] = (f1 + (delta := f2 - f1) * (2 / 3 - hue) * 6)[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)
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.

• Have you run the code through Mypy? I don’t think all your type annotations are correct… Sep 2 at 2:28
• without using OpenCV - then why do you import cv2? Sep 2 at 17:30
• 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. Sep 2 at 17:32
• 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. Sep 2 at 17:40

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().

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.

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,