Skip to content

Commit f45706a

Browse files
committed
Honor real tint vs JPEG noise in monochrome palette generation
Two-way split based on detectMonochromeTint: - Average chroma below MinMeaningfulTintChroma (0.008) -> pure grayscale. Fixes B&W JPEGs and near-grayscale photos that previously produced a blue palette from JPEG compression noise (3 of 16 samples with chroma 0.005 clustered around hue 240 were enough to trip the old detector). - Above the floor -> strong-tint generator. Removes the middle "subdued rainbow at chroma 0.06" path that produced six visibly different hues from images users perceived as monochrome (sepia paintings, faintly blue-green pixel art, themed wallpapers). Strong-tint generator now uses synthesizeMonoBgIfMuddy for bg, so Nord/Tokyo Night/sepia keep their image-derived background instead of clamping to extreme lightness. Contrast guard catches pathological gray inputs where darkest is close to lightest.
1 parent 6185382 commit f45706a

3 files changed

Lines changed: 175 additions & 75 deletions

File tree

internal/extraction/cache.go

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,16 +48,33 @@ func GetCacheKey(imagePath string, lightMode bool) string {
4848
return fmt.Sprintf("%x", hash)
4949
}
5050

51+
// modeCacheVersion is an optional per-mode suffix that lets us invalidate
52+
// cached palettes when a mode's generator algorithm changes. Cached entries
53+
// for older versions become orphaned but harmless — the next extraction
54+
// rebuilds at the new version. Bump when the generator output meaningfully
55+
// changes for a given mode.
56+
var modeCacheVersion = map[string]string{
57+
"monochromatic": "v4", // strong-tint bg/fg now derived from image via synthesizeMonoBgIfMuddy
58+
"normal": "v5", // strong-tint routing threshold lowered + image-derived bg preserved
59+
}
60+
5161
// buildCacheKey appends the mode suffix to a base cache key.
5262
// Returns "" when the base is "" (no caching possible) or falls through for "normal" mode.
5363
func buildCacheKey(base, mode string) string {
5464
if base == "" {
5565
return ""
5666
}
5767
if mode == "normal" {
68+
if v, ok := modeCacheVersion["normal"]; ok {
69+
return base + "_" + v
70+
}
5871
return base
5972
}
60-
return base + "_" + mode
73+
key := base + "_" + mode
74+
if v, ok := modeCacheVersion[mode]; ok {
75+
key += "_" + v
76+
}
77+
return key
6178
}
6279

6380
// GetMultiCacheKey generates a cache key for blended extraction across multiple images.

internal/extraction/constants.go

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,8 +22,23 @@ const (
2222
// Catches solid-color and near-monoharmonic wallpapers that aren't gray.
2323
HueClusterMagnitudeThreshold = 0.85
2424

25-
// Monochrome tint
26-
MonochromeTintStrength = 0.15
25+
// Monochrome tint pulls the canonical ANSI hue targets toward the image's
26+
// dominant hue. The auto-detected monochrome path uses a mild pull
27+
// (`MonochromeTintStrength`) so colors stay recognisable. The explicit
28+
// `monochromatic` mode uses a much stronger pull (`MonochromaticTintStrength`)
29+
// to produce a more unified mood while keeping the 6 ANSI slots
30+
// distinguishable for syntax highlighting.
31+
MonochromeTintStrength = 0.15
32+
MonochromaticTintStrength = 0.65
33+
34+
// MinMeaningfulTintChroma: minimum *average* chroma across all dominant
35+
// samples for `detectMonochromeTint` to consider the image actually
36+
// tinted. JPEG compression can leave a few samples with chroma
37+
// 0.003-0.008 in an otherwise-grayscale photo; without this floor the
38+
// detector calls a strongly-clustered noise band "a tint" and produces
39+
// a blue palette from a black-and-white image. Above this floor the
40+
// image is treated as a unified mono mood; below, pure grayscale.
41+
MinMeaningfulTintChroma = 0.008
2742

2843
// OKLCH scoring thresholds
2944
MinChromaForAnsiMatch = 0.035 // Minimum OKLCH chroma for a valid ANSI color match

internal/extraction/palette_monochrome.go

Lines changed: 140 additions & 72 deletions
Original file line numberDiff line numberDiff line change
@@ -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.
5377
func 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.
64106
func 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

Comments
 (0)