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 float
s, 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.
import cv2
? \$\endgroup\$BGR_to_HSL_saturation_1
, why does it accepthsl
? I'd expect that it accepts something called likebgr
, and returns HSL. \$\endgroup\$