Skip to content

Commit 9278db0

Browse files
committed
Drop canonical-hue ANSI fallback in auto-extract
1 parent c3377be commit 9278db0

3 files changed

Lines changed: 36 additions & 68 deletions

File tree

internal/extraction/analysis.go

Lines changed: 0 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -255,39 +255,6 @@ func SortColorsByLightness(colors []string) []ColorLightnessInfo {
255255
return result
256256
}
257257

258-
// SynthesizeAnsiColor synthesizes an ANSI color when no good match exists in the image.
259-
// Uses the average chroma and lightness of already-assigned colors (in OKLCH) to create
260-
// a color that fits the palette's visual mood.
261-
func SynthesizeAnsiColor(targetHue float64, existingColors []string) string {
262-
var totalC, totalL float64
263-
count := 0
264-
265-
for _, c := range existingColors {
266-
if c == "" {
267-
continue
268-
}
269-
lch := color.HexToOKLCH(c)
270-
if lch.C >= MinChromaForAnsiMatch {
271-
totalC += lch.C
272-
totalL += lch.L
273-
count++
274-
}
275-
}
276-
277-
avgC := 0.10 // Default chroma for synthesis
278-
avgL := 0.60 // Default lightness
279-
if count > 0 {
280-
avgC = totalC / float64(count)
281-
avgL = totalL / float64(count)
282-
}
283-
284-
// Clamp to sane ranges
285-
synC := math.Max(0.06, math.Min(0.18, avgC))
286-
synL := math.Max(0.40, math.Min(0.75, avgL))
287-
288-
return color.OKLCHToHex(color.OKLCH{L: synL, C: synC, H: targetHue})
289-
}
290-
291258
// AnsiAssignment represents the assignment of a pool color to an ANSI slot.
292259
type AnsiAssignment struct {
293260
PoolIndex int

internal/extraction/constants.go

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

33
const (
44
ANSIPaletteSize = 16
5-
CacheVersion = 7 // Bumped: mono bg synthesis + relaxed thresholds
5+
CacheVersion = 8 // Bumped: auto-extract no longer synthesizes canonical-hue fallbacks
66
ImageScaleSize = 400
77
MinPixelsToSample = 1000
88
MaxPixelsToSample = 50000
@@ -26,13 +26,12 @@ const (
2626
MonochromeTintStrength = 0.15
2727

2828
// OKLCH scoring thresholds
29-
MinChromaForAnsiMatch = 0.035 // Minimum OKLCH chroma for a valid ANSI color match
30-
LowChromaThreshold = 0.05 // Low but visible chroma (mild penalty in scoring)
31-
IdealChromaMin = 0.06 // Sweet spot for ANSI colors
32-
IdealChromaMax = 0.20
33-
TooDarkLightness = 0.25 // OKLab L below this is too dark for ANSI colors
34-
TooBrightLightness = 0.87 // OKLab L above this is too bright
35-
SynthesisScoreThreshold = 150.0
29+
MinChromaForAnsiMatch = 0.035 // Minimum OKLCH chroma for a valid ANSI color match
30+
LowChromaThreshold = 0.05 // Low but visible chroma (mild penalty in scoring)
31+
IdealChromaMin = 0.06 // Sweet spot for ANSI colors
32+
IdealChromaMax = 0.20
33+
TooDarkLightness = 0.25 // OKLab L below this is too dark for ANSI colors
34+
TooBrightLightness = 0.87 // OKLab L above this is too bright
3635

3736
// Contrast thresholds
3837
MinContrastRatio = 4.5 // WCAG AA minimum for normal text

internal/extraction/palette_chromatic.go

Lines changed: 29 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,9 @@ import (
1010
// colors. Slots 8 and 9-15 are left empty — pass through finalizePalette to fill them.
1111
// Mode transforms call this directly when they intend to overwrite bg/fg/ANSI in OKLCH,
1212
// avoiding wasted slot 8/9-15 generation that the full chromatic pipeline would do.
13+
//
14+
// Slot 1-6 hues are taken verbatim from the image's best matches (no synthesis at
15+
// canonical sRGB primary hues). NormalizeBrightness handles AA contrast downstream.
1316
func extractChromaticHues(dominantColors []string, lightMode bool) [16]string {
1417
topCount := 12
1518
if len(dominantColors) < topCount {
@@ -34,40 +37,36 @@ func extractChromaticHues(dominantColors []string, lightMode bool) [16]string {
3437

3538
assignments := FindOptimalAnsiAssignment(dominantColors, usedIndices, lightMode)
3639

37-
var matchedColors []string
38-
synthesizedSlots := [6]bool{}
3940
for i := 0; i < 6; i++ {
4041
assignment := assignments[i]
41-
if assignment != nil && assignment.Score < SynthesisScoreThreshold {
42-
lch := color.HexToOKLCH(dominantColors[assignment.PoolIndex])
43-
if lch.C >= MinChromaForAnsiMatch {
44-
palette[i+1] = dominantColors[assignment.PoolIndex]
45-
matchedColors = append(matchedColors, palette[i+1])
46-
usedIndices[assignment.PoolIndex] = true
47-
continue
48-
}
49-
}
50-
synthesizedSlots[i] = true
51-
}
52-
53-
// Stagger synthesized slots so they don't all share the same lightness — without
54-
// stagger, multiple synthesized slots end up at avgL with only hue differing,
55-
// which is hard to distinguish at low chroma.
56-
synthStagger := [6]float64{-0.06, +0.02, +0.07, -0.04, -0.02, +0.04}
57-
for i := 0; i < 6; i++ {
58-
if !synthesizedSlots[i] {
42+
if assignment != nil {
43+
palette[i+1] = dominantColors[assignment.PoolIndex]
44+
usedIndices[assignment.PoolIndex] = true
5945
continue
6046
}
61-
base := SynthesizeAnsiColor(OKLCHAnsiHues[i], matchedColors)
62-
lch := color.HexToOKLCH(base)
63-
lch.L = math.Max(0.30, math.Min(0.85, lch.L+synthStagger[i]))
64-
palette[i+1] = color.OKLCHToHex(lch)
65-
matchedColors = append(matchedColors, palette[i+1])
47+
// Pool exhausted — the optimal-assignment loop ran out of unused colors.
48+
// Fall back to the next available pool entry rather than synthesizing a
49+
// canonical-hue color that isn't in the image.
50+
palette[i+1] = nextUnusedColor(dominantColors, usedIndices)
6651
}
6752

6853
return palette
6954
}
7055

56+
// nextUnusedColor returns the first dominant color not yet claimed by another slot,
57+
// marking it used. Returns the first pool entry as a last resort when the pool is
58+
// fully consumed (extremely degenerate — len(dominantColors) is bounded ≥ 8 by
59+
// ExtractColors).
60+
func nextUnusedColor(dominantColors []string, usedIndices map[int]bool) string {
61+
for i, c := range dominantColors {
62+
if !usedIndices[i] {
63+
usedIndices[i] = true
64+
return c
65+
}
66+
}
67+
return dominantColors[0]
68+
}
69+
7170
// synthesizeBgIfTooMid replaces a mid-lightness image bg with a synthesized OKLCH
7271
// color at a sane bg lightness, preserving hue. Without this, images with no truly
7372
// dark/light pixels (e.g. a sunset photo or a Nord-themed wallpaper) produce muddy
@@ -87,8 +86,11 @@ func synthesizeBgIfTooMid(bgColor string, lightMode bool) string {
8786
}
8887

8988
// GenerateChromaticPalette: vibrant chromatic palette from image-derived hues.
90-
// OKLCH-based optimal assignment for slots 1-6, contrast-aware bg/fg, synthesized
91-
// missing hues. finalizePalette derives slots 8/9-15 and enforces AA contrast.
89+
// OKLCH-based optimal assignment for slots 1-6, contrast-aware bg/fg. Slots 1-6
90+
// always come from the image — the pipeline does not synthesize canonical-hue
91+
// fallbacks, so wallpapers without a strong red/green/etc. produce palettes
92+
// faithful to the source rather than to ANSI conventions.
93+
// finalizePalette derives slots 8/9-15 and enforces AA contrast.
9294
func GenerateChromaticPalette(dominantColors []string, lightMode bool) [16]string {
9395
palette := extractChromaticHues(dominantColors, lightMode)
9496
finalizePalette(&palette)

0 commit comments

Comments
 (0)