Skip to content

Commit 2d151b0

Browse files
committed
Scale tinted-monochrome chroma toward source image's saturation
Near-grayscale wallpapers (e.g. blue-gray aerial photos with mean dominant chroma ~0.02) were producing a full vivid rainbow because the tinted-mono generator stamped ANSI 1-6 at fixed chroma 0.09-0.14. Now those arrays are scaled by a factor derived from the source image's mean chroma, clamped to [0.4, 1.0]. Strongly-tinted sources (Nord, sepia, blueprint) reach 1.0 and are unchanged; grayer sources mute down toward the floor, which still keeps the six hues distinguishable so red=error / green=success terminal semantics survive.
1 parent b6d1d7f commit 2d151b0

2 files changed

Lines changed: 53 additions & 4 deletions

File tree

internal/extraction/constants.go

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,20 @@ const (
3838
// base hue (red rotating to purple on a blue-tinted source).
3939
MaxAnsiTintShift = 30.0
4040

41+
// Monochrome chroma fidelity: the tinted-mono ANSI slots scale their chroma
42+
// toward the source image's actual chroma rather than a fixed vivid level, so
43+
// a near-grayscale photo yields a muted palette that reads like the wallpaper
44+
// instead of a forced rainbow. The source's mean dominant chroma is amplified
45+
// by MonoChromaFidelityGain (sampled/averaged photo pixels read flatter than
46+
// synthesized terminal slots, so a mild boost keeps hues legible), divided by
47+
// the canonical chroma arrays' mean, then clamped to
48+
// [MonoChromaFactorFloor, 1.0]. 1.0 reproduces the previous vivid output, so
49+
// strongly-tinted sources (sepia, blueprint, Nord) are unchanged; only
50+
// desaturated sources get pulled down. The floor keeps the 6 hues
51+
// distinguishable for syntax highlighting even on near-gray images.
52+
MonoChromaFidelityGain = 2.5
53+
MonoChromaFactorFloor = 0.4
54+
4155
// MinMeaningfulTintChroma: minimum *average* chroma across all dominant
4256
// samples for `detectMonochromeTint` to consider the image actually
4357
// tinted. JPEG compression can leave a few samples with chroma

internal/extraction/palette_monochrome.go

Lines changed: 39 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,37 @@ func detectMonochromeTint(colors []string) (hue float64, hasTint bool, tintStren
7272
return avgHue, true, concentration * avgChroma
7373
}
7474

75+
// meanDominantChroma returns the mean OKLCH chroma across the dominant colors,
76+
// a proxy for how colorful the source image is. Used by the tinted-mono path
77+
// to scale synthesized ANSI chroma toward the image's actual saturation.
78+
func meanDominantChroma(colors []string) float64 {
79+
if len(colors) == 0 {
80+
return 0
81+
}
82+
var sum float64
83+
for _, c := range colors {
84+
sum += color.HexToOKLCH(c).C
85+
}
86+
return sum / float64(len(colors))
87+
}
88+
89+
func mean6(v [6]float64) float64 {
90+
var sum float64
91+
for _, x := range v {
92+
sum += x
93+
}
94+
return sum / 6
95+
}
96+
97+
// monoChromaFactor maps the source image's mean chroma to a [floor, 1.0] scale
98+
// applied to the canonical mono ANSI chroma arrays. refMean is the mean of those
99+
// arrays, so a source colorful enough to reach it yields factor 1.0 (unchanged
100+
// vivid output); grayer sources scale down toward MonoChromaFactorFloor.
101+
func monoChromaFactor(dominantColors []string, refMean float64) float64 {
102+
srcChroma := meanDominantChroma(dominantColors)
103+
return clampF(srcChroma*MonoChromaFidelityGain/refMean, MonoChromaFactorFloor, 1.0)
104+
}
105+
75106
// applyTint applies tint influence to an OKLCH hue based on the image's
76107
// dominant tone, at the strength used by the auto-detected monochrome path.
77108
func applyTint(ansiHue, tintHue float64, hasTint bool) float64 {
@@ -230,8 +261,13 @@ func generateTintedMonochromaticPalette(dominantColors []string, lightMode bool,
230261
palette[7] = color.OKLabToHex(fgLab)
231262
}
232263

233-
// ANSI 1-6: canonical hues pulled strongly toward the base hue.
264+
// ANSI 1-6: canonical hues pulled strongly toward the base hue. Chroma is
265+
// scaled toward the source image's actual saturation (chromaFactor) so a
266+
// near-grayscale image produces a muted palette instead of a fixed rainbow;
267+
// strongly-tinted sources keep the full vivid levels (factor 1.0).
234268
chromaLevels := [6]float64{0.09, 0.11, 0.13, 0.10, 0.12, 0.14}
269+
brightChromaLevels := [6]float64{0.11, 0.14, 0.16, 0.12, 0.15, 0.17}
270+
chromaFactor := monoChromaFactor(dominantColors, mean6(chromaLevels))
235271
lightnessOffsets := [6]float64{-0.08, -0.02, +0.04, +0.10, -0.05, +0.07}
236272
lightnessBase := 0.62
237273
if lightMode {
@@ -241,12 +277,11 @@ func generateTintedMonochromaticPalette(dominantColors []string, lightMode bool,
241277
for i := 0; i < 6; i++ {
242278
lightness := math.Max(0.30, math.Min(0.85, lightnessBase+lightnessOffsets[i]))
243279
hue := applyTintStrength(OKLCHAnsiHues[i], baseHue, true, MonochromaticTintStrength)
244-
palette[i+1] = color.OKLCHToHex(color.OKLCH{L: lightness, C: chromaLevels[i], H: hue})
280+
palette[i+1] = color.OKLCHToHex(color.OKLCH{L: lightness, C: chromaLevels[i] * chromaFactor, H: hue})
245281
}
246282

247283
palette[8] = generateCommentColor(palette[0])
248284

249-
brightChromaLevels := [6]float64{0.11, 0.14, 0.16, 0.12, 0.15, 0.17}
250285
for i := 0; i < 6; i++ {
251286
baseLightness := lightnessBase + lightnessOffsets[i]
252287
adjustment := 0.10
@@ -255,7 +290,7 @@ func generateTintedMonochromaticPalette(dominantColors []string, lightMode bool,
255290
}
256291
lightness := math.Max(0.20, math.Min(0.92, baseLightness+adjustment))
257292
hue := applyTintStrength(OKLCHAnsiHues[i], baseHue, true, MonochromaticTintStrength)
258-
palette[i+9] = color.OKLCHToHex(color.OKLCH{L: lightness, C: brightChromaLevels[i], H: hue})
293+
palette[i+9] = color.OKLCHToHex(color.OKLCH{L: lightness, C: brightChromaLevels[i] * chromaFactor, H: hue})
259294
}
260295

261296
if lightMode {

0 commit comments

Comments
 (0)