@@ -25,10 +25,26 @@ func synthesizeMonoBgIfMuddy(bgColor string, lightMode bool) string {
2525}
2626
2727// detectMonochromeTint detects the dominant tint hue from mostly-gray colors
28- // using weighted circular mean in OKLCH space. Colors with more chroma get more weight.
29- func detectMonochromeTint (colors []string ) (hue float64 , hasTint bool ) {
28+ // using weighted circular mean in OKLCH space. Colors with more chroma get
29+ // more weight.
30+ //
31+ // Returns:
32+ // - hue: mean tint direction (degrees, 0-360), undefined when hasTint=false.
33+ // - hasTint: true when the image has a meaningful color cast above
34+ // the JPEG-noise floor. A near-grayscale JPEG may still have a few
35+ // samples with chroma 0.003-0.008 from compression artifacts; treating
36+ // those as "tint" produces a blue palette from a black-and-white photo.
37+ // - tintStrength: how strongly the tint manifests, computed as
38+ // concentration * average chroma. concentration alone identifies a
39+ // tightly-clustered cast (sepia, blueprint) but doesn't distinguish it
40+ // from clustered noise; multiplying by chroma weights real color above
41+ // JPEG-grade noise. In [0, ~0.3] for realistic inputs.
42+ func detectMonochromeTint (colors []string ) (hue float64 , hasTint bool , tintStrength float64 ) {
43+ if len (colors ) == 0 {
44+ return 0 , false , 0
45+ }
3046 var sinSum , cosSum float64
31- var totalWeight float64
47+ var chromaSum float64
3248
3349 for _ , c := range colors {
3450 lch := color .HexToOKLCH (c )
@@ -37,120 +53,173 @@ func detectMonochromeTint(colors []string) (hue float64, hasTint bool) {
3753 rad := lch .H * math .Pi / 180
3854 sinSum += math .Sin (rad ) * weight
3955 cosSum += math .Cos (rad ) * weight
40- totalWeight += weight
56+ chromaSum += weight
4157 }
4258 }
4359
44- if totalWeight < 0.001 {
45- return 0 , false
60+ // Average chroma across ALL dominant samples (not just the chromatic
61+ // ones). A near-grayscale image dominated by C≈0 pixels yields a very
62+ // low average even if its handful of noisy samples cluster perfectly.
63+ avgChroma := chromaSum / float64 (len (colors ))
64+ if avgChroma < MinMeaningfulTintChroma {
65+ return 0 , false , 0
4666 }
4767
48- avgHue := math .Mod (math .Atan2 (sinSum / totalWeight , cosSum / totalWeight )* 180 / math .Pi + 360 , 360 )
49- return avgHue , true
68+ meanSin := sinSum / chromaSum
69+ meanCos := cosSum / chromaSum
70+ avgHue := math .Mod (math .Atan2 (meanSin , meanCos )* 180 / math .Pi + 360 , 360 )
71+ concentration := math .Sqrt (meanSin * meanSin + meanCos * meanCos )
72+ return avgHue , true , concentration * avgChroma
5073}
5174
52- // applyTint applies tint influence to an OKLCH hue based on the image's dominant tone.
75+ // applyTint applies tint influence to an OKLCH hue based on the image's
76+ // dominant tone, at the strength used by the auto-detected monochrome path.
5377func applyTint (ansiHue , tintHue float64 , hasTint bool ) float64 {
78+ return applyTintStrength (ansiHue , tintHue , hasTint , MonochromeTintStrength )
79+ }
80+
81+ // applyTintStrength is the parameterised form: `strength` is in [0, 1] where
82+ // 0 leaves the canonical ANSI hue alone and 1 snaps fully to the image hue.
83+ // The explicit `monochromatic` mode uses a higher strength to produce a
84+ // stronger unified mood while keeping ANSI slots distinguishable.
85+ func applyTintStrength (ansiHue , tintHue float64 , hasTint bool , strength float64 ) float64 {
5486 if ! hasTint {
5587 return ansiHue
5688 }
5789 hueDiff := math .Mod (tintHue - ansiHue + 540 , 360 ) - 180
58- return math .Mod (ansiHue + hueDiff * MonochromeTintStrength + 360 , 360 )
90+ return math .Mod (ansiHue + hueDiff * strength + 360 , 360 )
5991}
6092
61- // GenerateMonochromePalette generates a monochrome ANSI palette with distinguishable
62- // hue-tinted colors. Uses OKLCH hue targets at subdued chroma so colors remain
63- // functional for syntax highlighting while matching the monochrome mood.
93+ // GenerateMonochromePalette generates a palette for auto-detected monochrome
94+ // images. Two-way split:
95+ //
96+ // - hasTint=false: fully achromatic (B&W JPEGs, line art) → pure grayscale.
97+ // - hasTint=true: has a real color cast (sepia, blueprint, faint blue
98+ // pixel art, themed wallpaper) → strong-tint generator.
99+ //
100+ // The previous middle "subdued rainbow at chroma 0.06" path was removed
101+ // because users perceived it as wrong: an image they read as monochrome
102+ // produced six visibly-different hues. detectMonochromeTint's
103+ // MinMeaningfulTintChroma floor already filters out JPEG-noise tints, so
104+ // anything reaching the tinted branch is something the user wants
105+ // reflected as a unified mono mood.
64106func GenerateMonochromePalette (grayColors []string , lightMode bool ) [16 ]string {
65- sortedByLightness := SortColorsByLightness (grayColors )
107+ tintHue , hasTint , _ := detectMonochromeTint (grayColors )
108+ if ! hasTint {
109+ return generateGrayscaleMonochromaticPalette (grayColors , lightMode )
110+ }
111+ return generateTintedMonochromaticPalette (grayColors , lightMode , tintHue )
112+ }
113+
114+ // GenerateMonochromaticPalette generates a monochromatic-mood ANSI palette
115+ // keyed on the image's dominant hue. Background, foreground, the comment
116+ // gray, and the high-contrast endpoint sit on the base hue (the "mono"
117+ // feel), and the 6 ANSI slots use canonical hue targets pulled strongly
118+ // toward the base hue — so red still reads as red and green still reads as
119+ // green, just biased toward the image's tone.
120+ //
121+ // For fully achromatic images (B&W photos, line art, grayscale renders),
122+ // detectMonochromeTint returns hasTint=false; we fall through to a true
123+ // grayscale palette so the result actually feels monochrome instead of
124+ // arbitrarily pulling every ANSI slot toward extractDominantHue's red
125+ // fallback.
126+ func GenerateMonochromaticPalette (dominantColors []string , lightMode bool ) [16 ]string {
127+ tintHue , hasTint , _ := detectMonochromeTint (dominantColors )
128+ if ! hasTint {
129+ return generateGrayscaleMonochromaticPalette (dominantColors , lightMode )
130+ }
131+ return generateTintedMonochromaticPalette (dominantColors , lightMode , tintHue )
132+ }
133+
134+ // generateGrayscaleMonochromaticPalette builds a pure-grayscale palette for
135+ // images with no usable hue. ANSI 1-6 are a lightness stair of neutral grays
136+ // rather than tinted versions of canonical hues — for a true B&W source the
137+ // "mono" mood IS the absence of color.
138+ func generateGrayscaleMonochromaticPalette (dominantColors []string , lightMode bool ) [16 ]string {
139+ sortedByLightness := SortColorsByLightness (dominantColors )
66140 darkest := sortedByLightness [0 ]
67141 lightest := sortedByLightness [len (sortedByLightness )- 1 ]
68- tintHue , hasTint := detectMonochromeTint (grayColors )
69142
70143 var palette [16 ]string
71144
72- // Mono path treats the image AS the theme — keep image-derived bg unless it's
73- // genuinely muddy (L > 0.35 dark / < 0.75 light), so themed wallpapers
74- // (Nord, Tokyo Night, etc.) keep their authentic colors.
75145 if lightMode {
76- palette [0 ] = synthesizeMonoBgIfMuddy ( lightest .Color , lightMode )
77- palette [7 ] = darkest .Color
146+ palette [0 ] = color . OKLCHToHex (color. OKLCH { L : math . Max ( 0.94 , lightest .Lightness ), C : 0 , H : 0 } )
147+ palette [7 ] = color . OKLCHToHex (color. OKLCH { L : math . Min ( 0.22 , darkest .Lightness ), C : 0 , H : 0 })
78148 } else {
79- palette [0 ] = synthesizeMonoBgIfMuddy ( darkest .Color , lightMode )
80- palette [7 ] = lightest .Color
149+ palette [0 ] = color . OKLCHToHex (color. OKLCH { L : math . Min ( 0.14 , darkest .Lightness ), C : 0 , H : 0 } )
150+ palette [7 ] = color . OKLCHToHex (color. OKLCH { L : math . Max ( 0.88 , lightest .Lightness ), C : 0 , H : 0 })
81151 }
82152
83- if color .ContrastRatio (palette [0 ], palette [7 ]) < MinFgBgContrast {
84- bgLab := color .HexToOKLab (palette [0 ])
85- fgLab := color .HexToOKLab (palette [7 ])
86- if bgLab .L < 0.5 {
87- fgLab .L = 0.97
88- } else {
89- fgLab .L = 0.05
90- }
91- palette [7 ] = color .OKLabToHex (fgLab )
92- }
93-
94- // ANSI colors 1-6: use OKLCH hue targets with subdued chroma, tinted toward image tone
95- lightnessBase := 0.62
153+ // Lightness stair for ANSI 1-6. Distinguishable by L only.
154+ lightnessStair := [6 ]float64 {0.35 , 0.45 , 0.55 , 0.65 , 0.75 , 0.85 }
96155 if lightMode {
97- lightnessBase = 0.48
156+ // Mirror around 0.5 so darker grays still contrast against light bg.
157+ lightnessStair = [6 ]float64 {0.65 , 0.55 , 0.45 , 0.35 , 0.25 , 0.15 }
98158 }
99- for i := 0 ; i < len (OKLCHAnsiHues ); i ++ {
100- hue := applyTint (OKLCHAnsiHues [i ], tintHue , hasTint )
101- lightness := lightnessBase + (float64 (i )- 2.5 )* 0.03
102- chroma := 0.06 // Subdued but visible for syntax highlighting
103- palette [i + 1 ] = color .OKLCHToHex (color.OKLCH {L : lightness , C : chroma , H : hue })
159+ for i := 0 ; i < 6 ; i ++ {
160+ palette [i + 1 ] = color .OKLCHToHex (color.OKLCH {L : lightnessStair [i ], C : 0 , H : 0 })
104161 }
105162
106- // Color 8: neutral gray for comments with guaranteed contrast
107163 palette [8 ] = generateCommentColor (palette [0 ])
108164
109- // Colors 9-14: brighter, slightly more chromatic versions of 1-6
110- for i := 0 ; i < len (OKLCHAnsiHues ); i ++ {
111- hue := applyTint (OKLCHAnsiHues [i ], tintHue , hasTint )
112- baseLightness := lightnessBase + (float64 (i )- 2.5 )* 0.03
113- adjustment := 0.05
165+ // Bright variants: nudge toward the high-contrast end of the stair.
166+ for i := 0 ; i < 6 ; i ++ {
167+ bump := 0.08
114168 if lightMode {
115- adjustment = - 0.05
169+ bump = - 0.08
116170 }
117- lightness := math .Max (0 , math .Min (1 , baseLightness + adjustment ))
118- chroma := 0.08 // Slightly more chromatic than base
119- palette [i + 9 ] = color .OKLCHToHex (color.OKLCH {L : lightness , C : chroma , H : hue })
171+ l := math .Max (0.05 , math .Min (0.95 , lightnessStair [i ]+ bump ))
172+ palette [i + 9 ] = color .OKLCHToHex (color.OKLCH {L : l , C : 0 , H : 0 })
120173 }
121174
122- // Color 15: near-white or near-black
123175 if lightMode {
124- palette [15 ] = color .OKLCHToHex (color.OKLCH {L : math . Max ( 0.05 , darkest . Lightness - 0.05 ), C : 0.01 , H : tintHue })
176+ palette [15 ] = color .OKLCHToHex (color.OKLCH {L : 0.04 , C : 0 , H : 0 })
125177 } else {
126- palette [15 ] = color .OKLCHToHex (color.OKLCH {L : math . Min ( 0.98 , lightest . Lightness + 0.05 ), C : 0.01 , H : tintHue })
178+ palette [15 ] = color .OKLCHToHex (color.OKLCH {L : 0.99 , C : 0 , H : 0 })
127179 }
128180
129181 return palette
130182}
131183
132- // GenerateMonochromaticPalette generates a monochromatic ANSI palette based on
133- // the dominant hue from the image, with all colors sharing that hue at varying
134- // chroma and lightness levels.
135- func GenerateMonochromaticPalette (dominantColors []string , lightMode bool ) [16 ]string {
136- baseHue := extractDominantHue (dominantColors )
184+ // generateTintedMonochromaticPalette is the chromatic path: there's a real
185+ // tint to anchor the mood, so ANSI 1-6 use canonical hues pulled toward it.
186+ // Bg / fg are derived from the actual image colors via synthesizeMonoBgIfMuddy,
187+ // which keeps themed-wallpaper backgrounds (Nord L≈0.30, Tokyo Night L≈0.21,
188+ // solarized L≈0.85) intact and only synthesizes a near-black/near-white bg
189+ // when the image's bg is genuinely too muddy to use as-is.
190+ func generateTintedMonochromaticPalette (dominantColors []string , lightMode bool , baseHue float64 ) [16 ]string {
137191 sortedByLightness := SortColorsByLightness (dominantColors )
138192 darkest := sortedByLightness [0 ]
139193 lightest := sortedByLightness [len (sortedByLightness )- 1 ]
140194
141195 var palette [16 ]string
142196
197+ // Bg / fg: prefer image-derived colors, only synthesize when muddy.
198+ // This preserves authored themed-wallpaper backgrounds in mono mode.
143199 if lightMode {
144- palette [0 ] = color . OKLCHToHex (color. OKLCH { L : math . Max ( 0.90 , lightest .Lightness ), C : 0.015 , H : baseHue } )
145- palette [7 ] = color . OKLCHToHex (color. OKLCH { L : math . Min ( 0.30 , darkest .Lightness + 0.05 ), C : 0.04 , H : baseHue })
200+ palette [0 ] = synthesizeMonoBgIfMuddy ( lightest .Color , lightMode )
201+ palette [7 ] = darkest .Color
146202 } else {
147- palette [0 ] = color .OKLCHToHex (color.OKLCH {L : math .Min (0.18 , darkest .Lightness ), C : 0.025 , H : baseHue })
148- palette [7 ] = color .OKLCHToHex (color.OKLCH {L : math .Max (0.85 , lightest .Lightness - 0.05 ), C : 0.02 , H : baseHue })
203+ palette [0 ] = synthesizeMonoBgIfMuddy (darkest .Color , lightMode )
204+ palette [7 ] = lightest .Color
205+ }
206+
207+ // Contrast guard for pathological inputs (gray photos where darkest and
208+ // lightest are close together): if fg/bg contrast is below the readable
209+ // floor, force fg toward the opposite extreme.
210+ if color .ContrastRatio (palette [0 ], palette [7 ]) < MinFgBgContrast {
211+ bgLab := color .HexToOKLab (palette [0 ])
212+ fgLab := color .HexToOKLab (palette [7 ])
213+ if bgLab .L < 0.5 {
214+ fgLab .L = 0.97
215+ } else {
216+ fgLab .L = 0.05
217+ }
218+ palette [7 ] = color .OKLabToHex (fgLab )
149219 }
150220
151- // Colors 1-6: chroma stair from subtle to vivid, all at the same hue.
152- // Spread is 0.06–0.18 (well above OKLCH JND ~0.02) so slots are visibly distinct.
153- chromaLevels := [6 ]float64 {0.06 , 0.10 , 0.14 , 0.08 , 0.12 , 0.18 }
221+ // ANSI 1-6: canonical hues pulled strongly toward the base hue.
222+ chromaLevels := [6 ]float64 {0.09 , 0.11 , 0.13 , 0.10 , 0.12 , 0.14 }
154223 lightnessOffsets := [6 ]float64 {- 0.08 , - 0.02 , + 0.04 , + 0.10 , - 0.05 , + 0.07 }
155224 lightnessBase := 0.62
156225 if lightMode {
@@ -159,25 +228,24 @@ func GenerateMonochromaticPalette(dominantColors []string, lightMode bool) [16]s
159228
160229 for i := 0 ; i < 6 ; i ++ {
161230 lightness := math .Max (0.30 , math .Min (0.85 , lightnessBase + lightnessOffsets [i ]))
162- palette [i + 1 ] = color .OKLCHToHex (color.OKLCH {L : lightness , C : chromaLevels [i ], H : baseHue })
231+ hue := applyTintStrength (OKLCHAnsiHues [i ], baseHue , true , MonochromaticTintStrength )
232+ palette [i + 1 ] = color .OKLCHToHex (color.OKLCH {L : lightness , C : chromaLevels [i ], H : hue })
163233 }
164234
165- // Color 8: comment gray
166235 palette [8 ] = generateCommentColor (palette [0 ])
167236
168- // Colors 9-14: brighter versions
169- brightChromaLevels := [6 ]float64 {0.09 , 0.13 , 0.17 , 0.11 , 0.15 , 0.20 }
237+ brightChromaLevels := [6 ]float64 {0.11 , 0.14 , 0.16 , 0.12 , 0.15 , 0.17 }
170238 for i := 0 ; i < 6 ; i ++ {
171239 baseLightness := lightnessBase + lightnessOffsets [i ]
172240 adjustment := 0.10
173241 if lightMode {
174242 adjustment = - 0.10
175243 }
176244 lightness := math .Max (0.20 , math .Min (0.92 , baseLightness + adjustment ))
177- palette [i + 9 ] = color .OKLCHToHex (color.OKLCH {L : lightness , C : brightChromaLevels [i ], H : baseHue })
245+ hue := applyTintStrength (OKLCHAnsiHues [i ], baseHue , true , MonochromaticTintStrength )
246+ palette [i + 9 ] = color .OKLCHToHex (color.OKLCH {L : lightness , C : brightChromaLevels [i ], H : hue })
178247 }
179248
180- // Color 15: maximum-contrast endpoint (near-white in dark mode, near-black in light mode)
181249 if lightMode {
182250 palette [15 ] = color .OKLCHToHex (color.OKLCH {L : 0.08 , C : 0.03 , H : baseHue })
183251 } else {
0 commit comments