Skip to content

Commit 6e63fcb

Browse files
committed
Fix auto-extract pipeline defects
- Drop NormalizeBrightness outlier loop that flattened ANSI lightness toward an average and killed the natural yellow-bright/blue-dim hierarchy - Thread lightMode into CalculateColorScore so light-mode picks bias toward darker candidates (L≈0.45) that actually contrast against white bg - Synthesize a sane bg when image lacks dark/light extremes — preserves hue but pulls L into the proper bg range so backgrounds aren't muddy - Add circular-variance hue check to IsMonochromeImage so solid-color wallpapers (e.g. all-blue) route to the dedicated mono generator - Lower monochrome image threshold 0.7 → 0.6 for better classification on borderline-achromatic inputs - Stagger synthesized ANSI slots so they don't all collapse to the same lightness when several slots fall back to synthesis - Boost chroma instead of L for already-bright bases in GenerateBrightVersion so bright yellow is visibly distinct from yellow - Return -1 from FindForegroundColor synthesized fallback so the caller doesn't lock a real pool color out of ANSI assignment Cleanup: drop unused AdjustColorLightness, OutlierLightnessThreshold, BrightThemeThreshold, VeryDarkBgLightness, VeryLightBgLightness. Cache version bumped to 6.
1 parent 44107ed commit 6e63fcb

5 files changed

Lines changed: 111 additions & 149 deletions

File tree

internal/extraction/analysis.go

Lines changed: 54 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -24,23 +24,42 @@ func CalculateHueDistance(hue1, hue2 float64) float64 {
2424
}
2525

2626
// IsMonochromeImage detects whether the extracted colors are mostly monochrome/grayscale
27-
// using OKLCH chroma (perceptually accurate colorfulness metric).
28-
// Returns true if more than 70% of colors have chroma below the threshold.
27+
// or have very low hue diversity (e.g., a solid blue wallpaper). Returns true if the
28+
// achromatic share exceeds MonochromeImageThreshold OR if chromatic hues cluster
29+
// within a narrow arc on the wheel.
2930
func IsMonochromeImage(colors []string) bool {
3031
if len(colors) == 0 {
3132
return false
3233
}
3334

3435
lowChromaCount := 0
36+
var sinSum, cosSum float64
37+
chromaticCount := 0
3538

3639
for _, c := range colors {
3740
lch := color.HexToOKLCH(c)
3841
if lch.C < MonochromeChromaThreshold {
3942
lowChromaCount++
43+
continue
4044
}
45+
rad := lch.H * math.Pi / 180
46+
sinSum += math.Sin(rad)
47+
cosSum += math.Cos(rad)
48+
chromaticCount++
49+
}
50+
51+
if float64(lowChromaCount)/float64(len(colors)) > MonochromeImageThreshold {
52+
return true
4153
}
4254

43-
return float64(lowChromaCount)/float64(len(colors)) > MonochromeImageThreshold
55+
// Circular variance: |Σe^iθ|/N close to 1 = hues clustered tightly.
56+
if chromaticCount >= 4 {
57+
mag := math.Sqrt(sinSum*sinSum+cosSum*cosSum) / float64(chromaticCount)
58+
if mag > HueClusterMagnitudeThreshold {
59+
return true
60+
}
61+
}
62+
return false
4463
}
4564

4665
// findColorByPerceptualLightness finds a color by OKLab perceptual lightness extremity.
@@ -116,58 +135,58 @@ func FindForegroundColor(colors []string, lightMode bool, bgColor string, usedIn
116135
return colors[candidates[0].index], candidates[0].index
117136
}
118137

119-
// Fallback: adjust the original fg to meet contrast
138+
// Fallback: synthesize a foreground at the right contrast. Returns -1 for index
139+
// so callers don't lock a real pool color out of ANSI assignment based on a
140+
// color that isn't actually the synthesized fg.
120141
bgLab := color.HexToOKLab(bgColor)
121142
fgLab := color.HexToOKLab(fgColor)
122143
if bgLab.L < 0.5 {
123-
// Dark bg: push fg lighter
124144
fgLab.L = math.Min(1.0, bgLab.L+0.65)
125145
} else {
126-
// Light bg: push fg darker
127146
fgLab.L = math.Max(0.0, bgLab.L-0.65)
128147
}
129-
adjustedHex := color.OKLabToHex(fgLab)
130-
return adjustedHex, fgIndex
148+
return color.OKLabToHex(fgLab), -1
131149
}
132150

133-
// CalculateColorScore calculates a color quality score for ANSI color selection
134-
// using OKLCH perceptual color space. Lower score = better match.
135-
func CalculateColorScore(lch color.OKLCH, targetHue float64) float64 {
136-
// Hue accuracy in perceptually uniform OKLCH space
151+
// CalculateColorScore scores a candidate color for an ANSI slot. Lower = better match.
152+
// The lightness ideal shifts based on lightMode so light-mode palettes pick darker
153+
// candidates (which actually contrast against a near-white bg).
154+
func CalculateColorScore(lch color.OKLCH, targetHue float64, lightMode bool) float64 {
137155
hueScore := CalculateHueDistance(lch.H, targetHue) * 2.0
138156

139-
// Chroma preference - strongly prefer chromatic colors
140157
var chromaScore float64
141158
if lch.C < MinChromaForAnsiMatch {
142-
chromaScore = 80 // Heavy penalty for near-gray
159+
chromaScore = 80
143160
} else if lch.C < LowChromaThreshold {
144161
chromaScore = 40
145162
} else if lch.C < IdealChromaMin {
146163
chromaScore = 15
147164
} else if lch.C <= IdealChromaMax {
148-
chromaScore = 0 // Ideal range - no penalty
165+
chromaScore = 0
149166
} else {
150-
chromaScore = (lch.C - IdealChromaMax) * 50 // Mild penalty for very vivid
167+
chromaScore = (lch.C - IdealChromaMax) * 50
168+
}
169+
170+
idealL := 0.60
171+
if lightMode {
172+
idealL = 0.45
151173
}
152174

153-
// Lightness suitability - prefer mid-range, penalize extremes
154-
// OKLab L is perceptually linear, so these thresholds are meaningful
155175
var lightnessScore float64
156176
if lch.L < TooDarkLightness {
157177
lightnessScore = (TooDarkLightness - lch.L) * 200
158178
} else if lch.L > TooBrightLightness {
159179
lightnessScore = (lch.L - TooBrightLightness) * 150
160180
} else {
161-
// Slight preference for L around 0.55-0.65 (good readability zone)
162-
lightnessScore = math.Abs(lch.L-0.60) * 20
181+
lightnessScore = math.Abs(lch.L-idealL) * 20
163182
}
164183

165184
return hueScore + chromaScore + lightnessScore
166185
}
167186

168187
// FindBestColorMatch finds the best matching color for a specific ANSI color role
169188
// using OKLCH-based scoring. Returns the index of the best match in colorPool.
170-
func FindBestColorMatch(targetHue float64, colorPool []string, usedIndices map[int]bool) int {
189+
func FindBestColorMatch(targetHue float64, colorPool []string, usedIndices map[int]bool, lightMode bool) int {
171190
bestIndex := -1
172191
bestScore := math.Inf(1)
173192

@@ -177,7 +196,7 @@ func FindBestColorMatch(targetHue float64, colorPool []string, usedIndices map[i
177196
}
178197

179198
lch := color.HexToOKLCH(colorPool[i])
180-
score := CalculateColorScore(lch, targetHue)
199+
score := CalculateColorScore(lch, targetHue, lightMode)
181200

182201
if score < bestScore {
183202
bestScore = score
@@ -191,30 +210,28 @@ func FindBestColorMatch(targetHue float64, colorPool []string, usedIndices map[i
191210
return 0
192211
}
193212

194-
// GenerateBrightVersion generates a perceptually lighter version of a color for bright ANSI slots.
195-
// Works in OKLab space so the boost is perceptually uniform.
213+
// GenerateBrightVersion generates a perceptually distinct "bright" variant for ANSI
214+
// slots 9-14. For mid-lightness bases the variant is mostly L-boosted; for already-
215+
// bright bases (L >= 0.78, e.g. yellow) there's no L headroom, so the variant gets
216+
// a stronger chroma boost instead — otherwise bright yellow ≈ regular yellow.
196217
func GenerateBrightVersion(hex string) string {
197218
lab := color.HexToOKLab(hex)
198219
lch := color.OKLabToOKLCH(lab)
199220

200-
// Scale boost based on available headroom
221+
if lab.L >= 0.78 {
222+
newL := math.Min(0.94, lab.L+0.04)
223+
newC := math.Min(0.32, math.Max(lch.C+0.04, lch.C*1.3))
224+
return color.OKLCHToHex(color.OKLCH{L: newL, C: newC, H: lch.H})
225+
}
226+
201227
headroom := 0.92 - lab.L
202228
boost := math.Max(0.04, math.Min(BrightColorLightnessBoost, headroom*0.6))
203229
newL := math.Min(0.92, lab.L+boost)
204-
205-
// Slight chroma boost for vibrancy
206-
newC := math.Min(0.3, lch.C*BrightColorSaturationBoost)
230+
newC := math.Min(0.30, lch.C*BrightColorSaturationBoost)
207231

208232
return color.OKLCHToHex(color.OKLCH{L: newL, C: newC, H: lch.H})
209233
}
210234

211-
// AdjustColorLightness adjusts a color to the given target OKLab lightness (0-1).
212-
func AdjustColorLightness(hex string, targetLightness float64) string {
213-
lab := color.HexToOKLab(hex)
214-
lab.L = targetLightness
215-
return color.OKLabToHex(lab)
216-
}
217-
218235
// ColorLightnessInfo holds a color with its perceptual lightness and hue.
219236
type ColorLightnessInfo struct {
220237
Color string
@@ -280,7 +297,7 @@ type AnsiAssignment struct {
280297
// global greedy matching. Instead of assigning colors sequentially, this finds the
281298
// globally best (ANSI slot, color) pair at each step, preventing earlier slots from
282299
// stealing good matches from later ones.
283-
func FindOptimalAnsiAssignment(colorPool []string, usedIndices map[int]bool) [6]*AnsiAssignment {
300+
func FindOptimalAnsiAssignment(colorPool []string, usedIndices map[int]bool, lightMode bool) [6]*AnsiAssignment {
284301
type candidate struct {
285302
poolIndex int
286303
score float64
@@ -296,7 +313,7 @@ func FindOptimalAnsiAssignment(colorPool []string, usedIndices map[int]bool) [6]
296313
candidates[i] = &candidate{poolIndex: i, score: math.Inf(1)}
297314
} else {
298315
lch := color.HexToOKLCH(c)
299-
candidates[i] = &candidate{poolIndex: i, score: CalculateColorScore(lch, targetHue)}
316+
candidates[i] = &candidate{poolIndex: i, score: CalculateColorScore(lch, targetHue, lightMode)}
300317
}
301318
}
302319
sort.Slice(candidates, func(i, j int) bool {

internal/extraction/constants.go

Lines changed: 12 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ package extraction
22

33
const (
44
ANSIPaletteSize = 16
5-
CacheVersion = 5 // Bumped: mood iteration (stronger character per mood) + midnight/aurora
5+
CacheVersion = 6 // Bumped: auto-extract pipeline fixes (outlier loop, scoring, bg synth)
66
ImageScaleSize = 400
77
MinPixelsToSample = 1000
88
MaxPixelsToSample = 50000
@@ -16,7 +16,11 @@ const (
1616

1717
// Monochrome detection (OKLCH chroma-based)
1818
MonochromeChromaThreshold = 0.04 // OKLCH chroma below this = achromatic
19-
MonochromeImageThreshold = 0.7 // 70% achromatic pixels = monochrome image
19+
MonochromeImageThreshold = 0.6 // 60% achromatic pixels = monochrome image
20+
// Low-hue-diversity detection: if circular variance over chromatic colors is
21+
// below this (= hues clustered tightly), the image is treated as monochromatic.
22+
// Catches solid-color and near-monoharmonic wallpapers that aren't gray.
23+
HueClusterMagnitudeThreshold = 0.85
2024

2125
// Monochrome tint
2226
MonochromeTintStrength = 0.15
@@ -30,23 +34,17 @@ const (
3034
TooBrightLightness = 0.87 // OKLab L above this is too bright
3135
SynthesisScoreThreshold = 150.0
3236

33-
// Background/foreground and normalization
34-
VeryDarkBgLightness = 0.25 // OKLab L: very dark background
35-
VeryLightBgLightness = 0.82 // OKLab L: very light background
36-
MinContrastRatio = 4.5 // WCAG AA minimum for normal text
37-
MinHighContrastRatio = 7.0 // WCAG AAA target for the high-contrast mode
38-
MinCommentContrast = 3.0 // Minimum for color8 (comments)
39-
MinFgBgContrast = 7.0 // Target contrast for fg/bg pair
37+
// Contrast thresholds
38+
MinContrastRatio = 4.5 // WCAG AA minimum for normal text
39+
MinHighContrastRatio = 7.0 // WCAG AAA target for the high-contrast mode
40+
MinCommentContrast = 3.0 // Minimum for color8 (comments)
41+
MinFgBgContrast = 7.0 // Target contrast for fg/bg pair
4042

4143
// Bright version generation
4244
BrightColorLightnessBoost = 0.12 // OKLab L boost for bright variants
4345
BrightColorSaturationBoost = 1.1 // Chroma multiplier for bright variants
4446

45-
// Normalization
46-
OutlierLightnessThreshold = 0.15 // OKLab L deviation to be an outlier
47-
BrightThemeThreshold = 0.55 // OKLab L: avg above this = bright theme
48-
DarkColorThreshold = 0.50 // OKLab L: below this = dark
49-
47+
DarkColorThreshold = 0.50 // OKLab L: below this = dark
5048
)
5149

5250
// OKLCHAnsiHues contains OKLCH hue targets for the 6 ANSI color slots.

internal/extraction/normalize.go

Lines changed: 9 additions & 91 deletions
Original file line numberDiff line numberDiff line change
@@ -1,99 +1,17 @@
11
package extraction
22

3-
import (
4-
"math"
3+
import "aether/internal/color"
54

6-
"aether/internal/color"
7-
)
8-
9-
// NormalizeBrightness normalizes the brightness of ANSI colors using OKLab perceptual
10-
// lightness and WCAG contrast ratios to ensure readability against the background.
5+
// NormalizeBrightness ensures every ANSI color (slots 1-6) meets WCAG AA contrast
6+
// against the background, boosting lightness in OKLCH where needed. It does NOT
7+
// flatten lightness toward an average — preserving natural brightness hierarchy
8+
// (yellow brighter than blue, etc.) is intentional.
119
func NormalizeBrightness(palette [16]string) [16]string {
12-
bgLab := color.HexToOKLab(palette[0])
13-
bgL := bgLab.L
14-
15-
isVeryDarkBg := bgL < VeryDarkBgLightness
16-
isVeryLightBg := bgL > VeryLightBgLightness
17-
18-
colorIndices := []int{1, 2, 3, 4, 5, 6}
19-
20-
type colorInfo struct {
21-
index int
22-
labL float64 // OKLab perceptual lightness
23-
}
24-
25-
ansiColors := make([]colorInfo, len(colorIndices))
26-
for idx, i := range colorIndices {
27-
lab := color.HexToOKLab(palette[i])
28-
ansiColors[idx] = colorInfo{
29-
index: i,
30-
labL: lab.L,
10+
for i := 1; i <= 6; i++ {
11+
if color.ContrastRatio(palette[0], palette[i]) < MinContrastRatio {
12+
palette[i] = boostContrastAgainstBg(palette[i], palette[0], MinContrastRatio)
13+
palette[i+8] = GenerateBrightVersion(palette[i])
3114
}
3215
}
33-
34-
avgL := 0.0
35-
for _, c := range ansiColors {
36-
avgL += c.labL
37-
}
38-
avgL /= float64(len(ansiColors))
39-
40-
isBrightTheme := avgL > BrightThemeThreshold
41-
42-
if isVeryDarkBg {
43-
// On very dark backgrounds, ensure all ANSI colors are visible
44-
for _, ci := range ansiColors {
45-
contrast := color.ContrastRatio(palette[0], palette[ci.index])
46-
if contrast < MinContrastRatio {
47-
// Boost lightness until contrast is met
48-
palette[ci.index] = boostContrastAgainstBg(palette[ci.index], palette[0], MinContrastRatio)
49-
palette[ci.index+8] = GenerateBrightVersion(palette[ci.index])
50-
}
51-
}
52-
return palette
53-
}
54-
55-
if isVeryLightBg {
56-
// On very light backgrounds, ensure all ANSI colors are dark enough
57-
for _, ci := range ansiColors {
58-
contrast := color.ContrastRatio(palette[0], palette[ci.index])
59-
if contrast < MinContrastRatio {
60-
palette[ci.index] = boostContrastAgainstBg(palette[ci.index], palette[0], MinContrastRatio)
61-
palette[ci.index+8] = GenerateBrightVersion(palette[ci.index])
62-
}
63-
}
64-
return palette
65-
}
66-
67-
// Normal background - detect and fix perceptual outliers
68-
for _, ci := range ansiColors {
69-
deviation := math.Abs(ci.labL - avgL)
70-
if deviation <= OutlierLightnessThreshold {
71-
continue
72-
}
73-
74-
isDarkOutlierInBrightTheme := isBrightTheme && ci.labL < avgL-OutlierLightnessThreshold
75-
isBrightOutlierInDarkTheme := !isBrightTheme && ci.labL > avgL+OutlierLightnessThreshold
76-
77-
if isDarkOutlierInBrightTheme || isBrightOutlierInDarkTheme {
78-
var adjustedL float64
79-
if isDarkOutlierInBrightTheme {
80-
adjustedL = avgL - 0.08
81-
} else {
82-
adjustedL = avgL + 0.08
83-
}
84-
palette[ci.index] = AdjustColorLightness(palette[ci.index], adjustedL)
85-
palette[ci.index+8] = GenerateBrightVersion(palette[ci.index])
86-
}
87-
}
88-
89-
// Final pass: verify all ANSI colors (1-6) meet minimum contrast
90-
for _, ci := range ansiColors {
91-
contrast := color.ContrastRatio(palette[0], palette[ci.index])
92-
if contrast < MinContrastRatio {
93-
palette[ci.index] = boostContrastAgainstBg(palette[ci.index], palette[0], MinContrastRatio)
94-
palette[ci.index+8] = GenerateBrightVersion(palette[ci.index])
95-
}
96-
}
97-
9816
return palette
9917
}

0 commit comments

Comments
 (0)