Skip to content

Commit 0788060

Browse files
committed
Improve color extraction with global ANSI matching, hue synthesis, and monochrome support
1 parent 715bc29 commit 0788060

4 files changed

Lines changed: 271 additions & 74 deletions

File tree

src/utils/color-extraction/color-analysis.js

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import {
99
ANSI_HUE_ARRAY,
1010
BRIGHT_COLOR_LIGHTNESS_BOOST,
1111
BRIGHT_COLOR_SATURATION_BOOST,
12+
SYNTHESIS_SCORE_THRESHOLD,
13+
ANSI_MIN_SATURATION_FOR_MATCH,
1214
} from './constants.js';
1315

1416
/**
@@ -164,20 +166,33 @@ export function findForegroundColor(colors, lightMode, usedIndices) {
164166

165167
/**
166168
* Calculates color quality score for ANSI color selection
167-
* Prioritizes hue accuracy, then favors more saturated colors
169+
* Balances hue accuracy, saturation preference, and lightness suitability
170+
* Lower score = better match
168171
*
169172
* @param {{h: number, s: number, l: number}} hsl - HSL color values
170173
* @param {number} targetHue - Target hue (0-360)
171174
* @returns {number} Score (lower is better)
172175
*/
173176
export function calculateColorScore(hsl, targetHue) {
174-
const hueDiff = calculateHueDistance(hsl.h, targetHue) * 3;
175-
const saturationPenalty = hsl.s < MIN_CHROMATIC_SATURATION ? 50 : 0;
176-
const saturationReward = (100 - hsl.s) / 2;
177-
const lightnessPenalty =
178-
hsl.l < TOO_DARK_THRESHOLD || hsl.l > TOO_BRIGHT_THRESHOLD ? 10 : 0;
177+
// Hue accuracy - primary factor
178+
const hueScore = calculateHueDistance(hsl.h, targetHue) * 2.5;
179179

180-
return hueDiff + saturationPenalty + saturationReward + lightnessPenalty;
180+
// Saturation preference - strongly prefer chromatic colors
181+
let satScore;
182+
if (hsl.s < ANSI_MIN_SATURATION_FOR_MATCH) satScore = 80;
183+
else if (hsl.s < 20) satScore = 40;
184+
else if (hsl.s < 30) satScore = 15;
185+
else satScore = Math.max(0, (50 - hsl.s) * 0.3);
186+
187+
// Lightness suitability - prefer mid-range, penalize extremes
188+
let lightnessScore;
189+
if (hsl.l < TOO_DARK_THRESHOLD)
190+
lightnessScore = (TOO_DARK_THRESHOLD - hsl.l) * 2.5;
191+
else if (hsl.l > TOO_BRIGHT_THRESHOLD)
192+
lightnessScore = (hsl.l - TOO_BRIGHT_THRESHOLD) * 2;
193+
else lightnessScore = Math.abs(hsl.l - 55) * 0.2;
194+
195+
return hueScore + satScore + lightnessScore;
181196
}
182197

183198
/**
@@ -208,12 +223,19 @@ export function findBestColorMatch(targetHue, colorPool, usedIndices) {
208223

209224
/**
210225
* Generates a lighter version of a color for bright ANSI slots
226+
* Scales the boost based on available headroom to avoid washing out bright colors
211227
* @param {string} hexColor - Base hex color
212228
* @returns {string} Lightened hex color
213229
*/
214230
export function generateBrightVersion(hexColor) {
215231
const hsl = getColorHSL(hexColor);
216-
const newLightness = Math.min(100, hsl.l + BRIGHT_COLOR_LIGHTNESS_BOOST);
232+
// Scale boost based on available headroom so bright colors don't wash out
233+
const headroom = 90 - hsl.l;
234+
const boost = Math.max(
235+
5,
236+
Math.min(BRIGHT_COLOR_LIGHTNESS_BOOST, headroom * 0.6)
237+
);
238+
const newLightness = Math.min(90, hsl.l + boost);
217239
const newSaturation = Math.min(100, hsl.s * BRIGHT_COLOR_SATURATION_BOOST);
218240
return hslToHex(hsl.h, newSaturation, newLightness);
219241
}
@@ -242,3 +264,92 @@ export function sortColorsByLightness(colors) {
242264
})
243265
.sort((a, b) => a.lightness - b.lightness);
244266
}
267+
268+
/**
269+
* Synthesizes an ANSI color when no good match exists in the image
270+
* Uses the average saturation and lightness of already-assigned colors
271+
* to create a color that fits the palette's visual mood
272+
*
273+
* @param {number} targetHue - Target ANSI hue (0-360)
274+
* @param {string[]} existingColors - Already-assigned ANSI colors
275+
* @returns {string} Synthesized hex color
276+
*/
277+
export function synthesizeAnsiColor(targetHue, existingColors) {
278+
let totalS = 0,
279+
totalL = 0,
280+
count = 0;
281+
282+
for (const color of existingColors) {
283+
if (!color) continue;
284+
const hsl = getColorHSL(color);
285+
if (hsl.s >= ANSI_MIN_SATURATION_FOR_MATCH) {
286+
totalS += hsl.s;
287+
totalL += hsl.l;
288+
count++;
289+
}
290+
}
291+
292+
// Fall back to reasonable defaults if no reference colors
293+
const avgS = count > 0 ? totalS / count : 50;
294+
const avgL = count > 0 ? totalL / count : 55;
295+
296+
// Clamp to ensure the synthesized color is visually clear
297+
const synS = Math.max(35, Math.min(75, avgS));
298+
const synL = Math.max(40, Math.min(70, avgL));
299+
300+
return hslToHex(targetHue, synS, synL);
301+
}
302+
303+
/**
304+
* Finds optimal ANSI color assignments using global greedy matching
305+
* Instead of assigning colors sequentially (red first, then green, etc.),
306+
* this finds the globally best (ANSI slot, color) pair at each step,
307+
* preventing earlier slots from stealing good matches from later ones
308+
*
309+
* @param {string[]} colorPool - Available colors to choose from
310+
* @param {Set<number>} usedIndices - Already used color indices
311+
* @returns {Array<{poolIndex: number, score: number}|null>} Assignment for each ANSI slot (0-5)
312+
*/
313+
export function findOptimalAnsiAssignment(colorPool, usedIndices) {
314+
// Pre-compute and sort scores for all (ANSI slot, color) pairs
315+
const allScores = ANSI_HUE_ARRAY.map(targetHue => {
316+
return colorPool
317+
.map((color, poolIndex) => {
318+
if (usedIndices.has(poolIndex))
319+
return {poolIndex, score: Infinity};
320+
const hsl = getColorHSL(color);
321+
return {poolIndex, score: calculateColorScore(hsl, targetHue)};
322+
})
323+
.sort((a, b) => a.score - b.score);
324+
});
325+
326+
const assignments = new Array(6).fill(null);
327+
const assignedPoolIndices = new Set(usedIndices);
328+
329+
// Iteratively assign the globally best pair
330+
for (let round = 0; round < 6; round++) {
331+
let bestAnsi = -1;
332+
let bestPoolIndex = -1;
333+
let bestScore = Infinity;
334+
335+
for (let a = 0; a < 6; a++) {
336+
if (assignments[a] !== null) continue;
337+
// Find best unassigned candidate for this slot
338+
for (const candidate of allScores[a]) {
339+
if (assignedPoolIndices.has(candidate.poolIndex)) continue;
340+
if (candidate.score < bestScore) {
341+
bestScore = candidate.score;
342+
bestAnsi = a;
343+
bestPoolIndex = candidate.poolIndex;
344+
}
345+
break; // First unassigned is best (list is sorted)
346+
}
347+
}
348+
349+
if (bestAnsi === -1) break;
350+
assignments[bestAnsi] = {poolIndex: bestPoolIndex, score: bestScore};
351+
assignedPoolIndices.add(bestPoolIndex);
352+
}
353+
354+
return assignments;
355+
}

src/utils/color-extraction/constants.js

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,10 @@
1313
export const ANSI_PALETTE_SIZE = 16;
1414

1515
/** Number of dominant colors to extract from image for analysis */
16-
export const DOMINANT_COLORS_TO_EXTRACT = 32;
16+
export const DOMINANT_COLORS_TO_EXTRACT = 48;
1717

1818
/** Cache version number - increment when cache format changes */
19-
export const CACHE_VERSION = 1;
19+
export const CACHE_VERSION = 2;
2020

2121
// ============================================================================
2222
// COLOR DETECTION THRESHOLDS
@@ -97,9 +97,18 @@ export const DARK_COLOR_THRESHOLD = 50;
9797
/** Base saturation for subtle balanced palettes (0-100) */
9898
export const SUBTLE_PALETTE_SATURATION = 28;
9999

100-
/** Saturation for monochrome palette colors (0-100) */
100+
/** Saturation for monochrome palette neutral colors like color8/15 (0-100) */
101101
export const MONOCHROME_SATURATION = 5;
102102

103+
/** Saturation for ANSI colors 1-6 in monochrome palettes - enough to distinguish hues (0-100) */
104+
export const MONOCHROME_ANSI_SATURATION = 30;
105+
106+
/** Saturation for bright ANSI colors 9-14 in monochrome palettes (0-100) */
107+
export const MONOCHROME_ANSI_BRIGHT_SATURATION = 40;
108+
109+
/** How much the image's tonal tint influences monochrome ANSI hues (0-1) */
110+
export const MONOCHROME_TINT_STRENGTH = 0.15;
111+
103112
/** Saturation multiplier for color8 in monochrome (0-1) */
104113
export const MONOCHROME_COLOR8_SATURATION_FACTOR = 0.5;
105114

@@ -109,6 +118,16 @@ export const BRIGHT_COLOR_LIGHTNESS_BOOST = 18;
109118
/** Saturation multiplier for bright ANSI colors (9-14) */
110119
export const BRIGHT_COLOR_SATURATION_BOOST = 1.1;
111120

121+
// ============================================================================
122+
// ANSI COLOR MATCHING
123+
// ============================================================================
124+
125+
/** Score threshold above which a synthesized color is used instead of a poor match */
126+
export const SYNTHESIS_SCORE_THRESHOLD = 180;
127+
128+
/** Minimum saturation for a color to be considered a valid ANSI match */
129+
export const ANSI_MIN_SATURATION_FOR_MATCH = 12;
130+
112131
// ============================================================================
113132
// STANDARD ANSI COLOR HUES
114133
// ============================================================================
@@ -138,7 +157,7 @@ export const ANSI_HUE_ARRAY = [
138157
// ============================================================================
139158

140159
/** Maximum image dimension for fast processing (pixels) */
141-
export const IMAGE_SCALE_SIZE = 200;
160+
export const IMAGE_SCALE_SIZE = 300;
142161

143162
/** Minimum pixels to sample for reliable color extraction */
144163
export const MIN_PIXELS_TO_SAMPLE = 1000;

src/utils/color-extraction/index.js

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,9 @@
88
import {DOMINANT_COLORS_TO_EXTRACT} from './constants.js';
99
import {getCacheKey, loadCachedPalette, savePaletteToCache} from './cache.js';
1010
import {extractDominantColors} from './median-cut.js';
11-
import {isMonochromeImage, hasLowColorDiversity} from './color-analysis.js';
11+
import {isMonochromeImage} from './color-analysis.js';
1212
import {
1313
generateChromaticPalette,
14-
generateSubtleBalancedPalette,
1514
generateMonochromePalette,
1615
generateMonochromaticPalette,
1716
generatePastelPalette,
@@ -117,16 +116,8 @@ export async function extractColorsWithImageMagick(
117116
'Detected monochrome image - generating grayscale palette'
118117
);
119118
palette = generateMonochromePalette(dominantColors, lightMode);
120-
} else if (hasLowColorDiversity(dominantColors)) {
121-
log.info('Detected low diversity - generating subtle palette');
122-
palette = generateSubtleBalancedPalette(
123-
dominantColors,
124-
lightMode
125-
);
126119
} else {
127-
log.info(
128-
'Detected diverse image - generating chromatic palette'
129-
);
120+
log.info('Generating chromatic palette');
130121
palette = generateChromaticPalette(dominantColors, lightMode);
131122
}
132123
}

0 commit comments

Comments
 (0)