After a study of the Wikipedia entry on sRGB I implemented a set of functions to help with color conversions:
import "math"
// https://en.wikipedia.org/wiki/SRGB#Transformation
var byteDecoded [256]float32 = func() (floats [256]float32) {
for i := 0; i < 256; i++ {
floats[i] = float32(i) / 255
}
return floats
}()
// Standard returns the sRGB color space value in range [0.0-1.0] for v, assuming v is in linear RGB in range [0.0-1.0].
func Standard(v float32) float32 {
if v <= 0.0031308 {
return v * 12.92
}
return float32(1.055*math.Pow(float64(v), 1.0/2.4) - 0.055)
}
// Standardb returns the sRGB color space value in range [0-255] for v, assuming v is in linear RGB in range [0.0-1.0].
func Standardb(v float32) uint8 {
if v >= 1 {
return 255
}
if v <= 0 {
return 0
}
return uint8(Standard(v)*255 + 0.5)
}
// Linear returns the linear RGB color space value in range [0.0-1.0] for v, assuming v is in sRGB in range [0.0-1.0].
func Linear(v float32) float32 {
if v <= 0.04045 {
return v * (1.0 / 12.92)
}
return float32(math.Pow((float64(v)+0.055)/1.055, 2.4))
}
// Linearb returns the linear RGB color space value in range [0.0-1.0] for b, assuming b is in sRGB in range [0-255].
func Linearb(b uint8) float32 {
return Linear(byteDecoded[b])
}
I then played with some results.
log.Printf("Half of sRGB 255 calculated in linear RGB is %d", Standardb(Linearb(255)/2))
prints Half of sRGB 255 calculated in linear RGB is 188
.
I then made this:
Top half: checkerboarded red (255, 0, 0) and green (0, 255, 0) pixels.
Lower left: naive mixdown by division with 2 (128, 128, 0).
Lower right: (188, 188, 0)
The lower half shows two different attempts at what the top half could look like when scaled down by 50% on both axes. Since the top half is interleaved full green and full red pixels, a downscale would have to add half red and half green together, the value for which is what I calculated earlier (188).
The lower right matches the top half quite exactly on my plain consumer display monitor when crossing my eyes, so it seems like this whole conversion math is working out.
But what about darker colors?
log.Printf("Half of sRGB 64 calculated in linear RGB is %d", Standardb(Linearb(64)/2))
prints Half of sRGB 64 calculated in linear RGB is 44
.
I do the same as before:
Top half: checkerboarded dark red (64, 0, 0) and dark green (0, 64, 0) pixels.
Lower left: naive mixdown by division with 2 (32, 32, 0).
Lower right: (44, 44, 0)
This time, on my display, the naive (incorrect) method matches the upper half almost perfectly, while the value that I went through the effort to calculate in the lower right looks way too bright.
Did I make a mistake? Or is this just the extent of error to expect on consumer display devices?
Did I make a mistake?
Yes and no. Your code is correct, but your testing methodology has an oversight:
is this just the extent of error to expect on consumer display devices?
Yes. In particular, this likely is caused by the display panel's control driver. See someone making a similar observation here: https://electronics.stackexchange.com/questions/401617/lcd-pixels-how-chess-board-pixel-fill-patterns-are-called
The intensity of the problem can be reduced by going from a checkerboard pixel pattern to alternatingly colored horizontal pixel lines. The math comes out the same, but the results can look wildly different.
First image with horizontal lines:
Second image with horizontal lines:
Bonus: second image with vertical lines (try squinting; it looks even more accurate for me):
Also note that "plain consumer monitor" is already a wide range in terms of the display's ability to be accurate. If I drag this here very Stackoverflow window over to my cheaper second monitor I am completely unable to confirm any of the assertions you are making due to how wildly inaccurate its color output is.