fix(icc): apply the user gamma and contrast on profiled outputs

This commit is contained in:
bbedward
2026-09-15 10:25:14 -04:00
parent cfe93cc0d4
commit 9226251c9c
3 changed files with 127 additions and 25 deletions
+8 -1
View File
@@ -544,6 +544,13 @@ func SampleCurve(curve Curve, t float64) float64 {
// the ramp, so treating them as one would wash the display out instead of
// calibrating it. A profile without a vcgt table therefore has no ramp.
func GenerateGammaRamp(size uint32, profile *Profile) (GammaRamp, error) {
return GenerateGammaRampAt(size, profile, func(t float64) float64 { return t })
}
// GenerateGammaRampAt samples the vcgt table at input(t) for each ramp
// position t in [0, 1], so a caller can reshape the ramp input (contrast)
// before the table is applied.
func GenerateGammaRampAt(size uint32, profile *Profile, input func(float64) float64) (GammaRamp, error) {
if size < 2 {
return GammaRamp{}, errors.New("icc: ramp size must be at least 2")
}
@@ -559,7 +566,7 @@ func GenerateGammaRamp(size uint32, profile *Profile) (GammaRamp, error) {
vcgt := profile.VCGT
for i := uint32(0); i < size; i++ {
t := float64(i) / float64(size-1)
t := input(float64(i) / float64(size-1))
ramp.Red[i] = resampleVCGT(vcgt.Red, t)
ramp.Green[i] = resampleVCGT(vcgt.Green, t)
ramp.Blue[i] = resampleVCGT(vcgt.Blue, t)
+37 -24
View File
@@ -169,38 +169,51 @@ func GenerateIdentityRamp(size uint32) GammaRamp {
return ramp
}
// ProfileRampWithTemp builds the gamma ramp for an output that has an ICC
// profile. The profile describes the display at its reference white point
// (baseTemp), so a temperature target is composed as the ratio between the
// target ramp and the base ramp. targetTemp <= 0 means "no target", which
// leaves the profile as measured.
// ProfileRampWithTemp builds the ramp for an output with an ICC profile: the
// vcgt table is sampled at the contrast-adjusted input, the user gamma is
// applied to the result, and a temperature target (targetTemp > 0) is composed
// as the white point ratio against baseTemp, the profile's reference white.
func ProfileRampWithTemp(size uint32, profile *icc.Profile, baseTemp, targetTemp int, gamma, contrast float64) (GammaRamp, error) {
iccRamp, err := icc.GenerateGammaRamp(size, profile)
iccRamp, err := icc.GenerateGammaRampAt(size, profile, func(t float64) float64 { return applyContrast(t, contrast) })
if err != nil {
return GammaRamp{}, err
}
ramp := GammaRamp{Red: iccRamp.Red, Green: iccRamp.Green, Blue: iccRamp.Blue}
if targetTemp <= 0 || targetTemp == baseTemp {
return ramp, nil
tint := whitepointRatio(baseTemp, targetTemp)
ramp := GammaRamp{
Red: make([]uint16, size),
Green: make([]uint16, size),
Blue: make([]uint16, size),
}
base := GenerateGammaRamp(size, baseTemp, gamma, contrast)
target := GenerateGammaRamp(size, targetTemp, gamma, contrast)
for i := range ramp.Red {
ramp.Red[i] = scaleRampValue(ramp.Red[i], base.Red[i], target.Red[i])
ramp.Green[i] = scaleRampValue(ramp.Green[i], base.Green[i], target.Green[i])
ramp.Blue[i] = scaleRampValue(ramp.Blue[i], base.Blue[i], target.Blue[i])
for i := range size {
ramp.Red[i] = adjustRampValue(iccRamp.Red[i], tint.r, gamma)
ramp.Green[i] = adjustRampValue(iccRamp.Green[i], tint.g, gamma)
ramp.Blue[i] = adjustRampValue(iccRamp.Blue[i], tint.b, gamma)
}
return ramp, nil
}
// scaleRampValue rescales a profile ramp value by the ratio of the target and
// base temperature ramps at the same index.
func scaleRampValue(value, base, target uint16) uint16 {
if base == 0 {
return value
func whitepointRatio(baseTemp, targetTemp int) rgb {
if targetTemp <= 0 || targetTemp == baseTemp {
return rgb{r: 1, g: 1, b: 1}
}
base := calcWhitepoint(baseTemp)
target := calcWhitepoint(targetTemp)
return rgb{
r: channelRatio(target.r, base.r),
g: channelRatio(target.g, base.g),
b: channelRatio(target.b, base.b),
}
return uint16(clamp01(float64(value)*float64(target)/float64(base)/65535.0) * 65535.0)
}
func channelRatio(target, base float64) float64 {
if base <= 0 {
return 1
}
return target / base
}
func adjustRampValue(value uint16, tint, gamma float64) uint16 {
linear := clamp01(float64(value) / 65535.0 * tint)
return uint16(math.Round(clamp01(math.Pow(linear, 1.0/gamma)) * 65535.0))
}
@@ -240,3 +240,85 @@ func TestProfileRampWithTemp(t *testing.T) {
}
})
}
func identityVCGTProfile(entries int) *icc.Profile {
table := func() []uint16 {
out := make([]uint16, entries)
for i := range out {
out[i] = uint16(float64(i) / float64(entries-1) * 65535.0)
}
return out
}
return &icc.Profile{
HasVCGT: true,
VCGT: &icc.VCGT{Channels: 3, Entries: entries, Red: table(), Green: table(), Blue: table()},
}
}
func TestProfileRampWithTempAppliesGammaAndContrast(t *testing.T) {
const size = uint32(256)
const tolerance = 2
profile := identityVCGTProfile(256)
closeTo := func(t *testing.T, name string, got, want []uint16) {
t.Helper()
for i := range want {
diff := int(got[i]) - int(want[i])
if diff < -tolerance || diff > tolerance {
t.Fatalf("%s[%d] = %d, want %d (±%d)", name, i, got[i], want[i], tolerance)
}
}
}
cases := []struct {
name string
target int
gamma float64
contrast float64
}{
{"no target, gamma only", noTempTarget, 1.8, 1.0},
{"no target, contrast only", noTempTarget, 1.0, 1.4},
{"reference target, both", neutralTemp, 0.8, 0.7},
{"warm target, both", 4000, 1.5, 1.3},
{"cool target, both", 8000, 0.9, 0.6},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
ramp, err := ProfileRampWithTemp(size, profile, neutralTemp, tc.target, tc.gamma, tc.contrast)
if err != nil {
t.Fatalf("ProfileRampWithTemp: %v", err)
}
plainTemp := tc.target
if plainTemp <= 0 {
plainTemp = neutralTemp
}
want := GenerateGammaRamp(size, plainTemp, tc.gamma, tc.contrast)
closeTo(t, "red", ramp.Red, want.Red)
closeTo(t, "green", ramp.Green, want.Green)
closeTo(t, "blue", ramp.Blue, want.Blue)
})
}
t.Run("gamma changes the profile ramp without a target", func(t *testing.T) {
flat, _ := ProfileRampWithTemp(size, profile, neutralTemp, noTempTarget, 1.0, 1.0)
raised, _ := ProfileRampWithTemp(size, profile, neutralTemp, noTempTarget, 2.0, 1.0)
mid := int(size) / 2
if raised.Red[mid] <= flat.Red[mid] {
t.Fatalf("gamma 2.0 should lift mid gray: got %d, flat %d", raised.Red[mid], flat.Red[mid])
}
})
t.Run("contrast pivots the profile ramp at mid gray", func(t *testing.T) {
const oddSize = uint32(257)
flat, _ := ProfileRampWithTemp(oddSize, profile, neutralTemp, noTempTarget, 1.0, 1.0)
high, _ := ProfileRampWithTemp(oddSize, profile, neutralTemp, noTempTarget, 1.0, 2.0)
mid := int(oddSize) / 2
quarter := int(oddSize) / 4
if diff := int(high.Green[mid]) - int(flat.Green[mid]); diff < -tolerance || diff > tolerance {
t.Fatalf("mid gray should hold under contrast: got %d, flat %d", high.Green[mid], flat.Green[mid])
}
if high.Green[quarter] >= flat.Green[quarter] {
t.Fatalf("quarter gray should drop under contrast 2.0: got %d, flat %d", high.Green[quarter], flat.Green[quarter])
}
})
}