HSI to RGB color conversion - algorithm

I'm trying to implement HSI <=> RGB color conversion
There are formulas on the wiki https://en.wikipedia.org/wiki/HSL_and_HSV#HSI_to_RGB
RGB to HSI seems to work fine.
However, I have difficulties with HSI to RGB.
I will write in Ruby, the examples will be in Ruby, however if you write in JS/Python/etc I think it will be understandable too, since it's just math.
Online ruby Interpreter.
def hsi_to_rgb(hsi_arr)
# to float
hue, saturation, intensity = hsi_arr.map(&:to_f)
hue /= 60
z = 1 - (hue % 2 - 1).abs
chroma = (3 * intensity * saturation) / (1 + z)
x = chroma * z
point = case hue
when 0..1 then [chroma, x, 0]
when 1..2 then [x, chroma, 0]
when 2..3 then [0, chroma, x]
when 3..4 then [0, x, chroma]
when 4..5 then [x, 0, chroma]
when 5..6 then [chroma, 0, x]
else [0, 0, 0]
end
# calculation rgb & scaling into range 0..255
m = intensity * (1 - saturation)
point.map { |channel| ((channel + m) * 255).round }
end
So, with simple html colors, everything seemed to work.
Until I tried values like this:
p hsi_to_rgb([0, 1, 1]) # => [765, 0, 0]
p hsi_to_rgb([360, 1, 1]) # => [765, 0, 0]
p hsi_to_rgb([357, 1, 1]) # => [729, 0, 36]
p hsi_to_rgb([357, 1, 0.5]) # => [364, 0, 18]
The values obtained are clearly incorrect, outside the range 0..255.
I have also seen implementations using trigonometric functions:
https://hypjudy.github.io/images/dip/hsi2rgb.jpg
However, I didn't get the right results either.
The only online RGB to HSI converter I found: https://www.picturetopeople.org/color_converter.html
Just to have something to compare it to.

Your implementation looks correct (assuming Wikipedia is correct).
The only missing part is limiting the RGB output to [0, 255].
As Giacomo Catenazzi commented, instead of clipping to [0, 255], it's better dividing R,G,B by max(R, G, B) in case the maximum is above 255.
In most color space conversion formulas there are values that are in the valid range of the source color space, but falls out of the valid range of the destination color space.
The common solution is clipping the result to the valid range.
In some cases there are undefined values.
Take a look at the first 3 rows of the examples table.
The Hue is marked N/A for white, black and gray colors.
All of the sample HSI values the you choose:
[0, 1, 1]
[360, 1, 1]
[357, 1, 1]
[357, 1, 0.5]
Falls out of the valid range of the RGB color space (after HSI to RGB conversion).
I suggest you to test the valid tuples from the examples table:
H S I R G B
--- ---- ---- ---- ---- ----
0 100% 33.3% 100% 0% 0%
60 100% 50% 75% 75% 0%
120 100% 16.7% 0% 50% 0%
180 40% 83.3% 50% 100% 100%
240 25% 66.7% 50% 50% 100%
300 57.1% 58.3% 75% 25% 75%
61.8 69.9% 47.1% 62.8% 64.3% 14.2%
251.1 75.6% 42.6% 25.5% 10.4% 91.8%
134.9 66.7% 34.9% 11.6% 67.5% 25.5%
49.5 91.1% 59.3% 94.1% 78.5% 5.3%
283.7 68.6% 59.6% 70.4% 18.7% 89.7%
14.3 44.6% 57% 93.1% 46.3% 31.6%
56.9 36.3% 83.5% 99.8% 97.4% 53.2%
162.4 80% 49.5% 9.9% 79.5% 59.1%
248.3 53.3% 31.9% 21.1% 14.9% 59.7%
240.5 13.5% 57% 49.5% 49.3% 72.1%
I don't know the syntax of Rubi programming language, but your implementation looks correct.
Here is a Python implementation that matches the conversion formula from Wikipedia:
def hsi_to_rgb(hsi):
"""
Convert HSI tuple to RGB tuple (without scaling the result by 255)
Formula: https://en.wikipedia.org/wiki/HSL_and_HSV#HSI_to_RGB
H - Range [0, 360] (degrees)
S - Range [0, 1]
V - Range [0, 1]
The R,G,B output range is [0, 1]
"""
H, S, I = float(hsi[0]), float(hsi[1]), float(hsi[2])
Htag = H / 60
Z = 1 - abs(Htag % 2 - 1)
C = (3 * I * S) / (1 + Z)
X = C * Z
if 0 <= Htag <= 1:
R1, G1, B1 = C, X, 0
elif 1 <= Htag <= 2:
R1, G1, B1 = X, C, 0
elif 2 <= Htag <= 3:
R1, G1, B1 = 0, C, X
elif 3 <= Htag <= 4:
R1, G1, B1 = 0, X, C
elif 4 <= Htag <= 5:
R1, G1, B1 = X, 0, C
elif 5 <= Htag <= 6:
R1, G1, B1 = C, 0, X
else:
R1, G1, B1 = 0, 0, 0 # Undefined
# Calculation rgb
m = I * (1 - S)
R, G, B = R1 + m, G1 + m, B1 + m
# Limit R, G, B to valid range:
#R = max(min(R, 1), 0)
#G = max(min(G, 1), 0)
#B = max(min(B, 1), 0)
# Handling RGB values above 1:
# -----------------------------
# Avoiding weird colours - see the comment of Giacomo Catenazzi.
# Find the maximum between R, G, B, and if the value is above 1, divide the 3 channels with such numbers.
max_rgb = max((R, G, B))
if max_rgb > 1:
R /= max_rgb
G /= max_rgb
B /= max_rgb
return (R, G, B)
def rgb2percent(rgb):
""" Convert rgb tuple to percentage with one decimal digit accuracy """
rgb_per = (round(rgb[0]*1000.0)/10, round(rgb[1]*1000.0)/10, round(rgb[2]*1000.0)/10)
return rgb_per
print(rgb2percent(hsi_to_rgb([ 0, 100/100, 33.3/100]))) # => (99.9, 0.0, 0.0) Wiki: 100% 0% 0%
print(rgb2percent(hsi_to_rgb([ 60, 100/100, 50/100]))) # => (75.0, 75.0, 0.0) Wiki: 75% 75% 0%
print(rgb2percent(hsi_to_rgb([ 120, 100/100, 16.7/100]))) # => ( 0.0, 50.1, 0.0) Wiki: 0% 50% 0%
print(rgb2percent(hsi_to_rgb([ 180, 40/100, 83.3/100]))) # => (50.0, 100.0, 100.0) Wiki: 50% 100% 100%
print(rgb2percent(hsi_to_rgb([ 240, 25/100, 66.7/100]))) # => (50.0, 50.0, 100.0) Wiki: 50% 50% 100%
print(rgb2percent(hsi_to_rgb([ 300, 57.1/100, 58.3/100]))) # => (74.9, 25.0, 74.9) Wiki: 75% 25% 75%
print(rgb2percent(hsi_to_rgb([ 61.8, 69.9/100, 47.1/100]))) # => (62.8, 64.3, 14.2) Wiki: 62.8% 64.3% 14.2%
print(rgb2percent(hsi_to_rgb([251.1, 75.6/100, 42.6/100]))) # => (25.5, 10.4, 91.9) Wiki: 25.5% 10.4% 91.8%
print(rgb2percent(hsi_to_rgb([134.9, 66.7/100, 34.9/100]))) # => (11.6, 67.6, 25.5) Wiki: 11.6% 67.5% 25.5%
print(rgb2percent(hsi_to_rgb([ 49.5, 91.1/100, 59.3/100]))) # => (94.1, 78.5, 5.3) Wiki: 94.1% 78.5% 5.3%
print(rgb2percent(hsi_to_rgb([283.7, 68.6/100, 59.6/100]))) # => (70.4, 18.7, 89.7) Wiki: 70.4% 18.7% 89.7%
print(rgb2percent(hsi_to_rgb([ 14.3, 44.6/100, 57/100]))) # => (93.2, 46.3, 31.6) Wiki: 93.1% 46.3% 31.6%
print(rgb2percent(hsi_to_rgb([ 56.9, 36.3/100, 83.5/100]))) # => (99.9, 97.4, 53.2) Wiki: 99.8% 97.4% 53.2%
print(rgb2percent(hsi_to_rgb([162.4, 80/100, 49.5/100]))) # => ( 9.9, 79.5, 59.1) Wiki: 9.9% 79.5% 59.1%
print(rgb2percent(hsi_to_rgb([248.3, 53.3/100, 31.9/100]))) # => (21.1, 14.9, 59.7) Wiki: 21.1% 14.9% 59.7%
print(rgb2percent(hsi_to_rgb([240.5, 13.5/100, 57/100]))) # => (49.5, 49.3, 72.2) Wiki: 49.5% 49.3% 72.1%
As you can see, the results matches the examples table from Wikipedia.

Comparisons with the WIKI color table:
def print_rgb(rgb)
puts "[%s]" % rgb.map {|val| "%5.1f" % ((val / 255.0) * 100) }.join(", ")
end
print_rgb hsi_to_rgb([ 0, 100/100.0, 33.3/100.0]) # => [100.0, 0.0, 0.0] Wiki: 100% 0% 0%
print_rgb hsi_to_rgb([ 60, 100/100.0, 50/100.0]) # => [ 74.9, 74.9, 0.0] Wiki: 75% 75% 0%
print_rgb hsi_to_rgb([ 120, 100/100.0, 16.7/100.0]) # => [ 0.0, 50.2, 0.0] Wiki: 0% 50% 0%
print_rgb hsi_to_rgb([ 180, 40/100.0, 83.3/100.0]) # => [ 49.8, 100.0, 100.0] Wiki: 50% 100% 100%
print_rgb hsi_to_rgb([ 240, 25/100.0, 66.7/100.0]) # => [ 50.2, 50.2, 100.0] Wiki: 50% 50% 100%
print_rgb hsi_to_rgb([ 300, 57.1/100.0, 58.3/100.0]) # => [ 74.9, 25.1, 74.9] Wiki: 75% 25% 75%
print_rgb hsi_to_rgb([ 61.8, 69.9/100.0, 47.1/100.0]) # => [ 62.7, 64.3, 14.1] Wiki: 62.8% 64.3% 14.2%
print_rgb hsi_to_rgb([251.1, 75.6/100.0, 42.6/100.0]) # => [ 25.5, 10.6, 91.8] Wiki: 25.5% 10.4% 91.8%
print_rgb hsi_to_rgb([134.9, 66.7/100.0, 34.9/100.0]) # => [ 11.8, 67.5, 25.5] Wiki: 11.6% 67.5% 25.5%
print_rgb hsi_to_rgb([ 49.5, 91.1/100.0, 59.3/100.0]) # => [ 94.1, 78.4, 5.1] Wiki: 94.1% 78.5% 5.3%
print_rgb hsi_to_rgb([283.7, 68.6/100.0, 59.6/100.0]) # => [ 70.6, 18.8, 89.8] Wiki: 70.4% 18.7% 89.7%
print_rgb hsi_to_rgb([ 14.3, 44.6/100.0, 57/100.0]) # => [ 93.3, 46.3, 31.8] Wiki: 93.1% 46.3% 31.6%
print_rgb hsi_to_rgb([ 56.9, 36.3/100.0, 83.5/100.0]) # => [100.0, 97.3, 53.3] Wiki: 99.8% 97.4% 53.2%
print_rgb hsi_to_rgb([162.4, 80/100.0, 49.5/100.0]) # => [ 9.8, 79.6, 59.2] Wiki: 9.9% 79.5% 59.1%
print_rgb hsi_to_rgb([248.3, 53.3/100.0, 31.9/100.0]) # => [ 21.2, 14.9, 59.6] Wiki: 21.1% 14.9% 59.7%
print_rgb hsi_to_rgb([240.5, 13.5/100.0, 57/100.0]) # => [ 49.4, 49.4, 72.2] Wiki: 49.5% 49.3% 72.1%
The values are slightly different, since the method returns integer RGB values from the range 0..255
As Rotem said, the HSI values I tried to convert to RGB are out of RGB range.
All other RGB values of 16.7M colors are converted correctly.

Related

Epipolar Geometry Pure Translation Implementing Equation 9.6 from book Multiview Geometry

Implementing Equation 9.6
We want to calculate how each pixel in image will move when we know the camera translation and depth of each pixel.
The book Multive View Geometry gives solution in Chapter 9 in section 9.5
height = 512
width = 512
f = 711.11127387
#Camera intrinsic parameters
K = np.array([[f, 0.0, width/2],
[0.0, f, height/2],
[0.0, 0.0, 1.0]])
Kinv= np.array([[1, 0, -width/2],
[0, 1, -height/2],
[0, 0, f ]])
Kinv = np.linalg.inv(K)
#Translation matrix on the Z axis change dist will change the height
T = np.array([[ 0.0],
[0.0],
[-0.1]])
plt.figure(figsize=(10, 10))
ax = plt.subplot(1, 1, 1)
plt.imshow(old_seg)
for row, col in [(150,100), (450, 350)]:
ppp = np.array([[col],[row],[0]])
print (" Point ", ppp)
plt.scatter(ppp[0][0], ppp[1][0])
# Equation 9.6
new_pt = ppp + K.dot(T/old_depth[row][col])
print (K)
print (T/old_depth[row][col])
print (K.dot(T/old_depth[row][col]))
plt.scatter(new_pt[0][0], new_pt[1][0], c='c', marker=">")
ax.plot([ppp[0][0],new_pt[0][0]], [ppp[1][0],new_pt[1][0]], c='g', alpha=0.5)
Output
Point [[100]
[150]
[ 0]]
[[711.11127387 0. 256. ]
[ 0. 711.11127387 256. ]
[ 0. 0. 1. ]]
[[ 0. ]
[ 0. ]
[-0.16262454]]
[[-41.63188234]
[-41.63188234]
[ -0.16262454]]
Point [[350]
[450]
[ 0]]
[[711.11127387 0. 256. ]
[ 0. 711.11127387 256. ]
[ 0. 0. 1. ]]
[[ 0. ]
[ 0. ]
[-0.19715078]]
[[-50.47059987]
[-50.47059987]
[ -0.19715078]]
I expect the bottom point to move in the opposite direction .
What mistake am I doing ?

RAW 12 bits per pixel data format

I was analyzing a 12 bit per pixel, GRBG, Little Endian, 1920x1280 resolution raw image but I am confused how data or RGB pixels are stored. Image size is 4915200 bytes, when calculated 4915200/(1920x1280) = 2. That means each pixel takes 2 bytes and 4 bits in 2bytes are used for padding. I tried to edit image with Hex editor but I have no idea how pixels are stored in image. Please do share if you have any idea.
Image Link
That means each pixel takes 2 bytes and 4 bits in 2bytes are used for padding
Well, sort of. It means each sample is stored in two consecutive bytes, with 4 bits of padding. But in raw images, samples usually aren't pixels, not exactly. Raw images have not been demosaiced yet, they are raw after all. For GRGB, the Bayer pattern looks like this:
What's in the file, is a 1920x1280 grid of 12+4 bit samples, arranged in the same order as pixels would have been, but each sample has only one channel, namely the one that corresponds to its position in the Bayer pattern.
Additionally, the color space is probably linear, not Gamma-compressed. The color balance is unknown unless you reverse engineer it. A proper decoder would have a calibrated color matrix, but I don't have that.
I combined these two things and guessed a color balance to do a really basic decoding (with bad demosaicing, just to demonstrate that the above information is probably accurate):
Using this C# code:
Bitmap bm = new Bitmap(1920, 1280);
for (int y = 0; y < 1280; y += 2)
{
int i = y * 1920 * 2;
for (int x = 0; x < 1920; x += 2)
{
const int stride = 1920 * 2;
int d0 = data[i] + (data[i + 1] << 8);
int d1 = data[i + 2] + (data[i + 3] << 8);
int d2 = data[i + stride] + (data[i + stride + 1] << 8);
int d3 = data[i + stride + 2] + (data[i + stride + 3] << 8);
i += 4;
int r = Math.Min((int)(Math.Sqrt(d1) * 4.5), 255);
int b = Math.Min((int)(Math.Sqrt(d2) * 9), 255);
int g0 = Math.Min((int)(Math.Sqrt(d0) * 5), 255);
int g3 = Math.Min((int)(Math.Sqrt(d3) * 5), 255);
int g1 = Math.Min((int)(Math.Sqrt((d0 + d3) * 0.5) * 5), 255);
bm.SetPixel(x, y, Color.FromArgb(r, g0, b));
bm.SetPixel(x + 1, y, Color.FromArgb(r, g1, b));
bm.SetPixel(x, y + 1, Color.FromArgb(r, g1, b));
bm.SetPixel(x + 1, y + 1, Color.FromArgb(r, g3, b));
}
}
You can load your image into a Numpy array and reshape correctly like this:
import numpy as np
# Load image and reshape
img = np.fromfile('Image_12bpp_grbg_LittleEndian_1920x1280.raw',dtype=np.uint16).reshape((1280,1920))
print(img.shape)
(1280, 1920)
Then you can demosaic and scale to get a 16-bit PNG. Note that I don't know your calibration coefficients so I guessed:
#!/usr/bin/env python3
# Demosaicing Bayer Raw image
# https://stackoverflow.com/a/68823014/2836621
import cv2
import numpy as np
filename = 'Image_12bpp_grbg_LittleEndian_1920x1280.raw'
# Set width and height
w, h = 1920, 1280
# Read mosaiced image as GRGRGR...
# BGBGBG...
bayer = np.fromfile(filename, dtype=np.uint16).reshape((h,w))
# Extract g0, g1, b, r from mosaic
g0 = bayer[0::2, 0::2] # every second pixel down and across starting at 0,0
g1 = bayer[1::2, 1::2] # every second pixel down and across starting at 1,1
r = bayer[0::2, 1::2] # every second pixel down and across starting at 0,1
b = bayer[1::2, 0::2] # every second pixel down and across starting at 1,0
# Apply (guessed) color matrix for 16-bit PNG
R = np.sqrt(r) * 1200
B = np.sqrt(b) * 2300
G = np.sqrt((g0+g1)/2) * 1300 # very crude
# Stack into 3 channel
BGR16 = np.dstack((B,G,R)).astype(np.uint16)
# Save result as 16-bit PNG
cv2.imwrite('result.png', BGR16)
Keywords: Python, raw, image processing, Bayer, de-Bayer, mosaic, demosaic, de-mosaic, GBRG, 12-bit.

axes don't match array / size mismatch, m1: [132096 x 344], m2: [118336 x 128]

This is a linear auto-encoder code, the original picture is 344*344 RGB, after the training process is over, I want to show the decoded picture using the code below, but it has ValueError: axes don't match array
pytorch, google colab(GPU)
enter code here:
EPOCH = 20
BATCH_SIZE = 128
LR = 0.005 # learning rate
torch.cuda.empty_cache()
data_transforms = torchvision.transforms.Compose([
torchvision.transforms.RandomResizedCrop(344),
torchvision.transforms.RandomHorizontalFlip(),
torchvision.transforms.ToTensor()])
path1 = 'drive/My Drive/Colab/image/test/'
train_data = torchvision.datasets.ImageFolder(path1,
transform=data_transforms)
train_loader = Data.DataLoader(dataset=train_data, batch_size=BATCH_SIZE,
shuffle=True)
class AutoEncoder(nn.Module):
def __init__(self):
super(AutoEncoder, self).__init__()
self.encoder = nn.Sequential(
nn.Linear(3*344*344, 128),
nn.Tanh(), # 激活
nn.Linear(128, 64),
nn.Tanh(),
nn.Linear(64, 12),
nn.Tanh(),
nn.Linear(12, 3), # compress to 3 features which can be
visualized in plt
)
self.decoder = nn.Sequential(
nn.Linear(3, 12),
nn.Tanh(),
nn.Linear(12, 64),
nn.Tanh(),
nn.Linear(64, 128),
nn.Tanh(),
nn.Linear(128, 3*344*344),
nn.Sigmoid(), # compress to a range (0, 1)
)
def forward(self, x):
x = x.view(x.size(0), -1)
encoded = self.encoder(x)
decoded = self.decoder(encoded)
return encoded, decoded
autoencoder = AutoEncoder()
optimizer = torch.optim.Adam(autoencoder.parameters(), lr=LR)
loss_func = nn.MSELoss()
for epoch in range(EPOCH):
for step, (x, b_label) in enumerate(train_loader):
b_x = x.view(-1, 3*344*344) # batch x, shape (batch, 28*28)
b_y = x.view(-1, 3*344*344) # batch y, shape (batch, 28*28)
encoded, decoded = autoencoder(b_x)
loss = loss_func(decoded, b_y) # mean square error
optimizer.zero_grad() # clear gradients for this
training step
loss.backward() # backpropagation, compute
gradients
optimizer.step() # apply gradients
###################################################
######## below is used to plot decoded pic ########
with torch.no_grad():
for img, label in train_loader :
fig = plt.figure()
)
imggg = np.transpose(img[0],(1,2,0))
ax1 = fig.add_subplot(121)
ax1.imshow(imggg)
if torch.cuda.is_available():
img = Variable(img.to())
else:
img = Variable(img)
encoded, decoded = autoencoder(img)
decodeddd = np.transpose(decoded.cpu()[0],(1,2,0))
ax2 = fig.add_subplot(122)
ax2.imshow(decodeddd)
I expect the output of 2 pics but now only the original one shows, the decoded one doesn't show.
The training process works well, but I don't know what's the problem with picture's size.
decoder is returning a linear output of shape BATCH_SIZE x 355008. First, we need to reshape the second dimension to 3 dimensions of shape 3 x 344 x 344 before applying transpose on it. Replacing decodeddd with following should do the trick:
decodeddd = np.transpose(decoded.cpu()[0].view(3, 344, 344),(1,2,0))

Color gradient algorithm

Given two rgb colors and a rectangle, I'm able to create a basic linear gradient. This blog post gives very good explanation on how to create it. But I want to add one more variable to this algorithm, angle. I want to create linear gradient where I can specified the angle of the color.
For example, I have a rectangle (400x100). From color is red (255, 0, 0) and to color is green (0, 255, 0) and angle is 0°, so I will have the following color gradient.
Given I have the same rectangle, from color and to color. But this time I change angle to 45°. So I should have the following color gradient.
Your question actually consists of two parts:
How to generate a smooth color gradient between two colors.
How to render a gradient on an angle.
The intensity of the gradient must be constant in a perceptual color space or it will look unnaturally dark or light at points in the gradient. You can see this easily in a gradient based on simple interpolation of the sRGB values, particularly the red-green gradient is too dark in the middle. Using interpolation on linear values rather than gamma-corrected values makes the red-green gradient better, but at the expense of the back-white gradient. By separating the light intensities from the color you can get the best of both worlds.
Often when a perceptual color space is required, the Lab color space will be proposed. I think sometimes it goes too far, because it tries to accommodate the perception that blue is darker than an equivalent intensity of other colors such as yellow. This is true, but we are used to seeing this effect in our natural environment and in a gradient you end up with an overcompensation.
A power-law function of 0.43 was experimentally determined by researchers to be the best fit for relating gray light intensity to perceived brightness.
I have taken here the wonderful samples prepared by Ian Boyd and added my own proposed method at the end. I hope you'll agree that this new method is superior in all cases.
Algorithm MarkMix
Input:
color1: Color, (rgb) The first color to mix
color2: Color, (rgb) The second color to mix
mix: Number, (0..1) The mix ratio. 0 ==> pure Color1, 1 ==> pure Color2
Output:
color: Color, (rgb) The mixed color
//Convert each color component from 0..255 to 0..1
r1, g1, b1 ← Normalize(color1)
r2, g2, b2 ← Normalize(color1)
//Apply inverse sRGB companding to convert each channel into linear light
r1, g1, b1 ← sRGBInverseCompanding(r1, g1, b1)
r2, g2, b2 ← sRGBInverseCompanding(r2, g2, b2)
//Linearly interpolate r, g, b values using mix (0..1)
r ← LinearInterpolation(r1, r2, mix)
g ← LinearInterpolation(g1, g2, mix)
b ← LinearInterpolation(b1, b2, mix)
//Compute a measure of brightness of the two colors using empirically determined gamma
gamma ← 0.43
brightness1 ← Pow(r1+g1+b1, gamma)
brightness2 ← Pow(r2+g2+b2, gamma)
//Interpolate a new brightness value, and convert back to linear light
brightness ← LinearInterpolation(brightness1, brightness2, mix)
intensity ← Pow(brightness, 1/gamma)
//Apply adjustment factor to each rgb value based
if ((r+g+b) != 0) then
factor ← (intensity / (r+g+b))
r ← r * factor
g ← g * factor
b ← b * factor
end if
//Apply sRGB companding to convert from linear to perceptual light
r, g, b ← sRGBCompanding(r, g, b)
//Convert color components from 0..1 to 0..255
Result ← MakeColor(r, g, b)
End Algorithm MarkMix
Here's the code in Python:
def all_channels(func):
def wrapper(channel, *args, **kwargs):
try:
return func(channel, *args, **kwargs)
except TypeError:
return tuple(func(c, *args, **kwargs) for c in channel)
return wrapper
#all_channels
def to_sRGB_f(x):
''' Returns a sRGB value in the range [0,1]
for linear input in [0,1].
'''
return 12.92*x if x <= 0.0031308 else (1.055 * (x ** (1/2.4))) - 0.055
#all_channels
def to_sRGB(x):
''' Returns a sRGB value in the range [0,255]
for linear input in [0,1]
'''
return int(255.9999 * to_sRGB_f(x))
#all_channels
def from_sRGB(x):
''' Returns a linear value in the range [0,1]
for sRGB input in [0,255].
'''
x /= 255.0
if x <= 0.04045:
y = x / 12.92
else:
y = ((x + 0.055) / 1.055) ** 2.4
return y
def all_channels2(func):
def wrapper(channel1, channel2, *args, **kwargs):
try:
return func(channel1, channel2, *args, **kwargs)
except TypeError:
return tuple(func(c1, c2, *args, **kwargs) for c1,c2 in zip(channel1, channel2))
return wrapper
#all_channels2
def lerp(color1, color2, frac):
return color1 * (1 - frac) + color2 * frac
def perceptual_steps(color1, color2, steps):
gamma = .43
color1_lin = from_sRGB(color1)
bright1 = sum(color1_lin)**gamma
color2_lin = from_sRGB(color2)
bright2 = sum(color2_lin)**gamma
for step in range(steps):
intensity = lerp(bright1, bright2, step, steps) ** (1/gamma)
color = lerp(color1_lin, color2_lin, step, steps)
if sum(color) != 0:
color = [c * intensity / sum(color) for c in color]
color = to_sRGB(color)
yield color
Now for part 2 of your question. You need an equation to define the line that represents the midpoint of the gradient, and a distance from the line that corresponds to the endpoint colors of the gradient. It would be natural to put the endpoints at the farthest corners of the rectangle, but judging by your example in the question that is not what you did. I picked a distance of 71 pixels to approximate the example.
The code to generate the gradient needs to change slightly from what's shown above, to be a little more flexible. Instead of breaking the gradient into a fixed number of steps, it is calculated on a continuum based on the parameter t which ranges between 0.0 and 1.0.
class Line:
''' Defines a line of the form ax + by + c = 0 '''
def __init__(self, a, b, c=None):
if c is None:
x1,y1 = a
x2,y2 = b
a = y2 - y1
b = x1 - x2
c = x2*y1 - y2*x1
self.a = a
self.b = b
self.c = c
self.distance_multiplier = 1.0 / sqrt(a*a + b*b)
def distance(self, x, y):
''' Using the equation from
https://en.wikipedia.org/wiki/Distance_from_a_point_to_a_line#Line_defined_by_an_equation
modified so that the distance can be positive or negative depending
on which side of the line it's on.
'''
return (self.a * x + self.b * y + self.c) * self.distance_multiplier
class PerceptualGradient:
GAMMA = .43
def __init__(self, color1, color2):
self.color1_lin = from_sRGB(color1)
self.bright1 = sum(self.color1_lin)**self.GAMMA
self.color2_lin = from_sRGB(color2)
self.bright2 = sum(self.color2_lin)**self.GAMMA
def color(self, t):
''' Return the gradient color for a parameter in the range [0.0, 1.0].
'''
intensity = lerp(self.bright1, self.bright2, t) ** (1/self.GAMMA)
col = lerp(self.color1_lin, self.color2_lin, t)
total = sum(col)
if total != 0:
col = [c * intensity / total for c in col]
col = to_sRGB(col)
return col
def fill_gradient(im, gradient_color, line_distance=None, max_distance=None):
w, h = im.size
if line_distance is None:
def line_distance(x, y):
return x - ((w-1) / 2.0) # vertical line through the middle
ul = line_distance(0, 0)
ur = line_distance(w-1, 0)
ll = line_distance(0, h-1)
lr = line_distance(w-1, h-1)
if max_distance is None:
low = min([ul, ur, ll, lr])
high = max([ul, ur, ll, lr])
max_distance = min(abs(low), abs(high))
pix = im.load()
for y in range(h):
for x in range(w):
dist = line_distance(x, y)
ratio = 0.5 + 0.5 * dist / max_distance
ratio = max(0.0, min(1.0, ratio))
if ul > ur: ratio = 1.0 - ratio
pix[x, y] = gradient_color(ratio)
>>> w, h = 406, 101
>>> im = Image.new('RGB', [w, h])
>>> line = Line([w/2 - h/2, 0], [w/2 + h/2, h-1])
>>> grad = PerceptualGradient([252, 13, 27], [41, 253, 46])
>>> fill_gradient(im, grad.color, line.distance, 71)
And here's the result of the above:
I wanted to point out the common mistake that happens in color mixing when people try average the r, g, and b components:
R = (R1 + R2) / 2;
G = (G1 + G2) / 2;
B = (B1 + B2) / 2;
You can watch the excellent 4 Minute Physics video on the subject:
Computer Color is Broken
The short version is that trying to niavely mixing two colors by averaging the components is wrong:
R = R1*(1-mix) + R2*mix;
G = G1*(1-mix) + G2*mix;
B = B1*(1-mix) + B2*mix;
The problem is that RGB colors on computers are in the sRGB color space. And those numerical values have a gamma of approx 2.4 applied. In order to mix the colors correctly you must first undo this gamma adjustment:
undo the gamma adjustment
apply your r,g,b mixing algorithm above
reapply the gamma
Without applying the inverse gamma, the mixed colors are darker than they're supposed to be. This can be seen in a side-by-side color gradient experiment.
Top (wrong): without accounting for sRGB gamma
Bottom (right): with accounting for sRGB gamma
The algorithm
Rather than the naive:
//This is the wrong algorithm. Don't do this
Color ColorMixWrong(Color c1, Color c2, Single mix)
{
//Mix [0..1]
// 0 --> all c1
// 0.5 --> equal mix of c1 and c2
// 1 --> all c2
Color result;
result.r = c1.r*(1-mix) + c2.r*(mix);
result.g = c1.g*(1-mix) + c2.g*(mix);
result.b = c1.b*(1-mix) + c2.b*(mix);
return result;
}
The correct form is:
//This is the wrong algorithm. Don't do this
Color ColorMix(Color c1, Color c2, Single mix)
{
//Mix [0..1]
// 0 --> all c1
// 0.5 --> equal mix of c1 and c2
// 1 --> all c2
//Invert sRGB gamma compression
c1 = InverseSrgbCompanding(c1);
c2 = InverseSrgbCompanding(c2);
result.r = c1.r*(1-mix) + c2.r*(mix);
result.g = c1.g*(1-mix) + c2.g*(mix);
result.b = c1.b*(1-mix) + c2.b*(mix);
//Reapply sRGB gamma compression
result = SrgbCompanding(result);
return result;
}
The gamma adjustment of sRGB isn't quite just 2.4. They actually have a linear section near black - so it's a piecewise function.
Color InverseSrgbCompanding(Color c)
{
//Convert color from 0..255 to 0..1
Single r = c.r / 255;
Single g = c.g / 255;
Single b = c.b / 255;
//Inverse Red, Green, and Blue
if (r > 0.04045) r = Power((r+0.055)/1.055, 2.4) else r = r / 12.92;
if (g > 0.04045) g = Power((g+0.055)/1.055, 2.4) else g = g / 12.92;
if (b > 0.04045) b = Power((b+0.055)/1.055, 2.4) else b = b / 12.92;
//return new color. Convert 0..1 back into 0..255
Color result;
result.r = r*255;
result.g = g*255;
result.b = b*255;
return result;
}
And you re-apply the companding as:
Color SrgbCompanding(Color c)
{
//Convert color from 0..255 to 0..1
Single r = c.r / 255;
Single g = c.g / 255;
Single b = c.b / 255;
//Apply companding to Red, Green, and Blue
if (r > 0.0031308) r = 1.055*Power(r, 1/2.4)-0.055 else r = r * 12.92;
if (g > 0.0031308) g = 1.055*Power(g, 1/2.4)-0.055 else g = g * 12.92;
if (b > 0.0031308) b = 1.055*Power(b, 1/2.4)-0.055 else b = b * 12.92;
//return new color. Convert 0..1 back into 0..255
Color result;
result.r = r*255;
result.g = g*255;
result.b = b*255;
return result;
}
Update: Mark's right
I tested #MarkRansom comment that the color blending in linear RGB space is good when colors are equal RGB total value; but the linear blending scale does not seem linear - especially for the black-white case.
So i tried mixing in Lab color space, as my intuition suggested (as well as this photography stackexchange answer):
Mark's algorithm sometimes falls over
That's quite simple. Besides angle, you would actually need one more parameter, i.e. how tight/wide the gradient should be. Let's instead just work with two points:
__D
__--
__--
__--
__--
M
Where M is the middle point of the gradient (between red and green) and D shows the direction and distance. Therefore, the gradient becomes:
M'
| __D
| __--
| __--
| __--
| __--
M
__-- |
__-- |
__-- |
__-- |
D'-- |
M"
Which means, along the vector D'D, you change from red to green, linearly as you already know. Along the vector M'M", you keep the color constant.
That was the theory. Now implementation depends on how you actually draw the pixels. Let's assume nothing and say you want to decide the color pixel by pixel (so you can draw in any pixel order.)
That's simple! Let's take a point:
M'
| SA __D
__--| __--
P-- |__ A __--
| -- /| \ __--
| -- | |_--
| --M
|__-- |
__--CA |
__-- |
__-- |
D'-- |
M"
Point P, has angle A with the coordinate system defined by M and D. We know that along the vector M'M", the color doesn't change, so sin(A) doesn't have any significance. Instead, cos(A) shows relatively how far towards D or D' the pixels color should go to. The point CA shows |PM|cos(A) which means the mapping of P over the line defined by M and D, or in details the length of the line PM multiplied by cos(A).
So the algorithm becomes as follows
For every pixel
Calculate CA
If farther than D, definitely green. If before D', definitely red.
Else find the color from red to green based on the ratio of |D'CA|/|D'D|
Based on your comments, if you want to determine the wideness from the canvas size, you can easily calculate D based on your input angle and canvas size, although I personally advise using a separate parameter.
The way I solved this is first by being able to calculate L (lightness) for an RGB color: calculate only the Y (luminance) of CIE XYZ and use that to get L.
static private float rgbToL (float r, float g, float b) {
float Y = 0.21263900587151f * r + 0.71516867876775f * g + 0.072192315360733f * b;
return Y <= 0.0088564516f ? Y * 9.032962962f : 1.16f * (float)Math.pow(Y, 1 / 3f) - 0.16f;
}
That gives L as 0-1 for any RGB. Then to lerp RGB: first interpolate linear RGB, then fix lightness by lerping the start/end L and scale the RGB by targetL / resultL. I posted an Rgb class that does this.
The same library also has an Hsl class which stores a color as HSLuv. It does interpolation by converting to linear RGB, interpolating, converting back to HSLuv and then fixing the brightness by interpolating L from the start/end HSLuv colors.
The comment of #user2799037 is totally correct:
each line is moved by some pixels to the right compared to the previous one.
The actual constant can be computed as the tangent of the angle you specified.

Creating nested loops of arbitrary depth in ruby

I want to write a ruby program that steps over space in an arbitrary number of dimensions.
In 3 dimensions what I'm doing looks like this:
x_range = (-1..1)
y_range = (-1..1)
z_range = (-1..1)
step_size = 0.01
x_range.step(step_size) do |x|
y_range.step(step_size) do |y|
z_range.step(step_size) do |z|
# do something with the point x,y,z
end
end
end
I want to do the same for n dimensions
This is the first thing that comes to mind for me:
def enumerate(nDimens, bottom, top, step_size)
bottom = (bottom / step_size).to_i
top = (top / step_size).to_i
range = (bottom..top).to_a.map{ |x| x * step_size }
return range.repeated_permutation(nDimens)
end
stepper = enumerate(4, -1, 1, 0.1)
loop do
puts "#{stepper.next()}"
end
This produces:
[-1.0, -1.0, -1.0, -1.0]
[-1.0, -1.0, -1.0, -0.9]
[-1.0, -1.0, -1.0, -0.8]
# Lots more...
[1.0, 1.0, 1.0, 0.8]
[1.0, 1.0, 1.0, 0.9]
[1.0, 1.0, 1.0, 1.0]
This assumes all dimensions have the same range, but it'd be easy to adjust if for some reason that doesn't hold.
Recursion could solve this kind of problem easily and perfectly. Code below fits any number of dimensions, and also various length of ranges.
def traversal(ranges, step_size, conjunction = [], &blk)
ranges[0].step(step_size) do |x|
conjunction.push(x)
if ranges.size > 1
traversal(ranges[1..-1], step_size, conjunction, &blk)
else
blk.call(conjunction) if block_given?
conjunction.pop
end
end
conjunction.pop
end
Run: (dimension = 4, length = 3, 3, 4, 2)
x = (1..3)
y = (4..6)
z = (7..10)
w = (100..101)
test_data = [x, y, z, w]
step_size = 1
traversal(test_data, step_size) do |x|
puts "Point: #{x.join('-')}"
end
Output: (72 points in total, which is 3 * 3 * 4 * 2)
Point: 1-4-7-100
Point: 1-4-7-101
Point: 1-4-8-100
Point: 1-4-8-101
Point: 1-4-9-100
Point: 1-4-9-101
Point: 1-4-10-100
Point: 1-4-10-101
Point: 1-5-7-100
Point: 1-5-7-101
Point: 1-5-8-100
Point: 1-5-8-101
Point: 1-5-9-100
Point: 1-5-9-101
Point: 1-5-10-100
Point: 1-5-10-101
Point: 1-6-7-100
Point: 1-6-7-101
Point: 1-6-8-100
Point: 1-6-8-101
Point: 1-6-9-100
Point: 1-6-9-101
Point: 1-6-10-100
Point: 1-6-10-101
Point: 2-4-7-100
Point: 2-4-7-101
Point: 2-4-8-100
Point: 2-4-8-101
Point: 2-4-9-100
Point: 2-4-9-101
Point: 2-4-10-100
Point: 2-4-10-101
Point: 2-5-7-100
Point: 2-5-7-101
Point: 2-5-8-100
Point: 2-5-8-101
Point: 2-5-9-100
Point: 2-5-9-101
Point: 2-5-10-100
Point: 2-5-10-101
Point: 2-6-7-100
Point: 2-6-7-101
Point: 2-6-8-100
Point: 2-6-8-101
Point: 2-6-9-100
Point: 2-6-9-101
Point: 2-6-10-100
Point: 2-6-10-101
Point: 3-4-7-100
Point: 3-4-7-101
Point: 3-4-8-100
Point: 3-4-8-101
Point: 3-4-9-100
Point: 3-4-9-101
Point: 3-4-10-100
Point: 3-4-10-101
Point: 3-5-7-100
Point: 3-5-7-101
Point: 3-5-8-100
Point: 3-5-8-101
Point: 3-5-9-100
Point: 3-5-9-101
Point: 3-5-10-100
Point: 3-5-10-101
Point: 3-6-7-100
Point: 3-6-7-101
Point: 3-6-8-100
Point: 3-6-8-101
Point: 3-6-9-100
Point: 3-6-9-101
Point: 3-6-10-100
Point: 3-6-10-101
If ranges are not too big you can do something like this:
n = 5 # 5 dimentions
x = (-1..1).to_a
x.product(*[x]*(n-1)).each {|i| p i}
Result:
[-1, -1, -1, -1, -1]
[-1, -1, -1, -1, 0]
[-1, -1, -1, -1, 1]
[-1, -1, -1, 0, -1]
[-1, -1, -1, 0, 0]
[-1, -1, -1, 0, 1]
[-1, -1, -1, 1, -1]
[-1, -1, -1, 1, 0]
[-1, -1, -1, 1, 1]
[-1, -1, 0, -1, -1]
[-1, -1, 0, -1, 0]
# skipped
This is what you could do... here is an example iterator.
#next(l[dim] array of lower ranges ,h[dim] = upper ranges, step[dim], dim = dimensions -1, curr[dim] = current state in dim dimensions )
def nextx(l ,h, step, dim, curr)
x = dim
update= false
while (update==false)
if curr[x] == h[x]
if x > 0
x = x-1
else
exit
end
else
curr[x]= curr[x]+step[x]
while (x < dim)
x = x+1
curr[x] = l[x]
end
update = true
end
end
return curr
end
l = [0,0,0]
h = [3,3,3]
step = [1,1,1]
currx = [0,0,2]
i = 0
while i < 70
currx = nextx(l, h, step, 2, currx)
puts currx.inspect
i=i+1
end
This is typically encountered in algorithms that explore a search space. The do loops are creating a product space from the one dimensional ranges.
First pack as many ranges are needed into an array e.g.
search_space_1Ds = [x_range.step(step_size).to_a, y_range.step(step_size).to_a, z_range.step(step_size).to_a]
then the following will work with an arbitrary number of dimensions.
search_space = search_spaces_1Ds.shift.product(*search_space_1Ds)
search_space.map do |vec|
# calculate something with vec
end
This implementation is not only concise, it makes it very clear what your algorithm is doing; enumerating through a search space that is created as the product space of the one dimensional search spaces.

Resources