diff --git a/core/internal/icc/parser.go b/core/internal/icc/parser.go index 5be20b268..fb8a98bab 100644 --- a/core/internal/icc/parser.go +++ b/core/internal/icc/parser.go @@ -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) diff --git a/core/internal/server/wayland/gamma.go b/core/internal/server/wayland/gamma.go index 2ece22ef3..b7ef43631 100644 --- a/core/internal/server/wayland/gamma.go +++ b/core/internal/server/wayland/gamma.go @@ -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)) } diff --git a/core/internal/server/wayland/gamma_test.go b/core/internal/server/wayland/gamma_test.go index 1e2c7ddc3..8134841bf 100644 --- a/core/internal/server/wayland/gamma_test.go +++ b/core/internal/server/wayland/gamma_test.go @@ -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]) + } + }) +}