Skip to content

Commit f727935

Browse files
committed
yolo (#29)
Closes: gh-29
1 parent 74af8bc commit f727935

1 file changed

Lines changed: 158 additions & 17 deletions

File tree

src/ColorUtil.ts

Lines changed: 158 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -200,6 +200,132 @@ function deltaE(l1: any, l2: any) {
200200
return Math.sqrt(dL * dL + da * da + db * db);
201201
}
202202

203+
function rgbToHsl([r, g, b]: [number, number, number]): [number, number, number] {
204+
const r1 = r / 255;
205+
const g1 = g / 255;
206+
const b1 = b / 255;
207+
const max = Math.max(r1, g1, b1);
208+
const min = Math.min(r1, g1, b1);
209+
const l = (max + min) / 2;
210+
const d = max - min;
211+
212+
if (d === 0) {
213+
return [0, 0, l];
214+
}
215+
216+
const s = d / (1 - Math.abs(2 * l - 1));
217+
let h = 0;
218+
219+
switch (max) {
220+
case r1:
221+
h = ((g1 - b1) / d + (g1 < b1 ? 6 : 0)) / 6;
222+
break;
223+
case g1:
224+
h = ((b1 - r1) / d + 2) / 6;
225+
break;
226+
default:
227+
h = ((r1 - g1) / d + 4) / 6;
228+
break;
229+
}
230+
231+
return [h, s, l];
232+
}
233+
234+
function circularHueDistance(h1: number, h2: number): number {
235+
const diff = Math.abs(h1 - h2);
236+
return Math.min(diff, 1 - diff);
237+
}
238+
239+
function clamp(value: number, min: number, max: number): number {
240+
return Math.max(min, Math.min(max, value));
241+
}
242+
243+
type PaletteCandidate = {
244+
rgb: [number, number, number];
245+
lab: { L: number; a: number; b: number };
246+
hsl: [number, number, number];
247+
hsv: [number, number, number];
248+
prominence: number;
249+
chroma: number;
250+
};
251+
252+
function makePaletteCandidate(rgb: [number, number, number], prominence: number): PaletteCandidate {
253+
const lab = rgbToLab(rgb);
254+
return {
255+
rgb,
256+
lab,
257+
hsl: rgbToHsl(rgb),
258+
hsv: rgbToHsv(rgb),
259+
prominence,
260+
chroma: Math.sqrt(lab.a * lab.a + lab.b * lab.b),
261+
};
262+
}
263+
264+
function dedupePalette(colors: [number, number, number][]): [number, number, number][] {
265+
const deduped: [number, number, number][] = [];
266+
for (const color of colors) {
267+
const lab = rgbToLab(color);
268+
const isDuplicate = deduped.some((existing) => deltaE(lab, rgbToLab(existing)) < 8);
269+
if (!isDuplicate) {
270+
deduped.push(color);
271+
}
272+
}
273+
return deduped;
274+
}
275+
276+
function pickAnchorColor(colors: [number, number, number][]): PaletteCandidate {
277+
const prepared = dedupePalette(colors).map((rgb, index, arr) =>
278+
makePaletteCandidate(rgb, arr.length <= 1 ? 1 : 1 - index / arr.length)
279+
);
280+
281+
const vivid = prepared.filter((candidate) => {
282+
const [, s, l] = candidate.hsl;
283+
return candidate.chroma >= 18 && s >= 0.12 && l >= 0.06 && l <= 0.82;
284+
});
285+
286+
const usable = vivid.length ? vivid : prepared;
287+
288+
const weightedHueX = usable.reduce(
289+
(sum, candidate) => sum + Math.cos(candidate.hsl[0] * Math.PI * 2) * candidate.prominence,
290+
0
291+
);
292+
const weightedHueY = usable.reduce(
293+
(sum, candidate) => sum + Math.sin(candidate.hsl[0] * Math.PI * 2) * candidate.prominence,
294+
0
295+
);
296+
const dominantHue =
297+
weightedHueX === 0 && weightedHueY === 0
298+
? usable[0].hsl[0]
299+
: (Math.atan2(weightedHueY, weightedHueX) / (Math.PI * 2) + 1) % 1;
300+
301+
const scored = usable.map((candidate) => {
302+
const [, saturation, lightness] = candidate.hsl;
303+
const [, , value] = candidate.hsv;
304+
const normalizedDepth = 1 - clamp(lightness, 0, 1);
305+
const darknessPreference = 1 - Math.abs(lightness - 0.28) / 0.28;
306+
const hueCloseness = 1 - circularHueDistance(candidate.hsl[0], dominantHue) / 0.5;
307+
const neutralPenalty = candidate.chroma < 24 ? (24 - candidate.chroma) / 24 : 0;
308+
const washedPenalty = lightness > 0.72 ? (lightness - 0.72) / 0.28 : 0;
309+
const crushedPenalty = value < 0.16 ? (0.16 - value) / 0.16 : 0;
310+
311+
const score =
312+
candidate.prominence * 0.34 +
313+
saturation * 0.24 +
314+
clamp(candidate.chroma / 90, 0, 1) * 0.2 +
315+
clamp(darknessPreference, 0, 1) * 0.14 +
316+
clamp(normalizedDepth, 0, 1) * 0.08 +
317+
clamp(hueCloseness, 0, 1) * 0.1 -
318+
neutralPenalty * 0.18 -
319+
washedPenalty * 0.12 -
320+
crushedPenalty * 0.08;
321+
322+
return { candidate, score };
323+
});
324+
325+
scored.sort((a, b) => b.score - a.score);
326+
return scored[0].candidate;
327+
}
328+
203329
export async function getAccentColorFromUrl(
204330
imageUrl: string,
205331
targetLightness = 0,
@@ -276,28 +402,42 @@ export async function getAccentColorFromUrl(
276402
}
277403
}
278404

279-
const paletteLab = paletteRgb.map(rgbToLab);
405+
const anchor = pickAnchorColor(paletteRgb);
280406
const candidates = COLORS.map((hex) => {
281407
const rgb = hexToRgb(hex);
282408
const lab = rgbToLab(rgb);
283-
return { hex, lab, Lnorm: lab.L / 100 };
409+
const hsl = rgbToHsl(rgb);
410+
const chroma = Math.sqrt(lab.a * lab.a + lab.b * lab.b);
411+
return { hex, lab, hsl, chroma, Lnorm: lab.L / 100 };
284412
});
285413

286-
const colorWeight = opts?.colorWeight ?? 0.8;
287-
const lightnessWeight = opts?.lightnessWeight ?? 0.2;
414+
const anchorLightness = clamp(anchor.hsl[2], 0, 1);
415+
const effectiveTargetLightness =
416+
targetLightness > 0 ? anchorLightness * 0.7 + targetLightness * 0.3 : anchorLightness;
417+
const colorWeight = opts?.colorWeight ?? 0.52;
418+
const lightnessWeight = opts?.lightnessWeight ?? 0.28;
288419

289420
const scored = candidates.map((cand) => {
290-
let minDE = Infinity;
291-
for (const p of paletteLab) {
292-
const de = deltaE(cand.lab, p);
293-
if (de < minDE) {
294-
minDE = de;
295-
}
296-
}
297-
const colorDistNorm = Math.min(1, minDE / 100);
298-
const lightDiff = Math.abs(cand.Lnorm - targetLightness);
299-
const score = colorWeight * colorDistNorm + lightnessWeight * lightDiff;
300-
return { hex: cand.hex, score, minDE, lightDiff };
421+
const de = deltaE(cand.lab, anchor.lab);
422+
const colorDistNorm = Math.min(1, de / 100);
423+
const hueDiff = circularHueDistance(cand.hsl[0], anchor.hsl[0]);
424+
const huePenalty = hueDiff / 0.5;
425+
const lightDiff = Math.abs(cand.hsl[2] - effectiveTargetLightness);
426+
const chromaDiff = Math.abs(cand.chroma - anchor.chroma) / 100;
427+
const tooBrightPenalty =
428+
cand.hsl[2] > anchor.hsl[2] + 0.12 ? cand.hsl[2] - (anchor.hsl[2] + 0.12) : 0;
429+
const tooMutedPenalty =
430+
cand.chroma + 10 < anchor.chroma ? (anchor.chroma - (cand.chroma + 10)) / 100 : 0;
431+
432+
const score =
433+
colorWeight * colorDistNorm +
434+
0.32 * huePenalty +
435+
lightnessWeight * lightDiff +
436+
0.18 * chromaDiff +
437+
0.35 * tooBrightPenalty +
438+
0.22 * tooMutedPenalty;
439+
440+
return { hex: cand.hex, score };
301441
});
302442

303443
scored.sort((a, b) => a.score - b.score);
@@ -393,8 +533,9 @@ export function generateTextColor(hexCover: string, hShiftDeg = 12, coeff = 0.81
393533
const rgbCover = hexToRgb(hexCover);
394534
const [h, s, v] = rgbToHsv(rgbCover);
395535
const newH = (h + hShiftDeg / 360) % 1;
396-
const newS = clamp01(v * coeff);
397-
const newV = 1;
536+
const liftedS = Math.max(s * 0.7, 0.28);
537+
const newS = clamp01(liftedS * coeff);
538+
const newV = v < 0.38 ? 1 : 0.96;
398539
const rgbText = hsvToRgb([newH, newS, newV]);
399540
return rgbToHex(rgbText);
400541
}

0 commit comments

Comments
 (0)