Spaces:
Running
Running
| import { rgbToLab, labDist } from './colorUtils.js'; | |
| /** | |
| * CIEDE2000 color difference. | |
| * More perceptually uniform than Euclidean Lab distance. | |
| */ | |
| export function ciede2000(lab1, lab2) { | |
| const [L1, a1, b1] = lab1; | |
| const [L2, a2, b2] = lab2; | |
| const C1 = Math.sqrt(a1 * a1 + b1 * b1); | |
| const C2 = Math.sqrt(a2 * a2 + b2 * b2); | |
| const Cmean = (C1 + C2) / 2; | |
| const Cmean7 = Cmean ** 7; | |
| const G = 0.5 * (1 - Math.sqrt(Cmean7 / (Cmean7 + 6103515625))); // 25^7 | |
| const a1p = a1 * (1 + G); | |
| const a2p = a2 * (1 + G); | |
| const C1p = Math.sqrt(a1p * a1p + b1 * b1); | |
| const C2p = Math.sqrt(a2p * a2p + b2 * b2); | |
| let h1p = Math.atan2(b1, a1p) * 180 / Math.PI; | |
| if (h1p < 0) h1p += 360; | |
| let h2p = Math.atan2(b2, a2p) * 180 / Math.PI; | |
| if (h2p < 0) h2p += 360; | |
| const dLp = L2 - L1; | |
| const dCp = C2p - C1p; | |
| let dhp; | |
| if (C1p * C2p === 0) { | |
| dhp = 0; | |
| } else if (Math.abs(h2p - h1p) <= 180) { | |
| dhp = h2p - h1p; | |
| } else if (h2p - h1p > 180) { | |
| dhp = h2p - h1p - 360; | |
| } else { | |
| dhp = h2p - h1p + 360; | |
| } | |
| const dHp = 2 * Math.sqrt(C1p * C2p) * Math.sin(dhp * Math.PI / 360); | |
| const Lpmean = (L1 + L2) / 2; | |
| const Cpmean = (C1p + C2p) / 2; | |
| let Hpmean; | |
| if (C1p * C2p === 0) { | |
| Hpmean = h1p + h2p; | |
| } else if (Math.abs(h1p - h2p) <= 180) { | |
| Hpmean = (h1p + h2p) / 2; | |
| } else if (h1p + h2p < 360) { | |
| Hpmean = (h1p + h2p + 360) / 2; | |
| } else { | |
| Hpmean = (h1p + h2p - 360) / 2; | |
| } | |
| const T = 1 | |
| - 0.17 * Math.cos((Hpmean - 30) * Math.PI / 180) | |
| + 0.24 * Math.cos(2 * Hpmean * Math.PI / 180) | |
| + 0.32 * Math.cos((3 * Hpmean + 6) * Math.PI / 180) | |
| - 0.20 * Math.cos((4 * Hpmean - 63) * Math.PI / 180); | |
| const SL = 1 + 0.015 * (Lpmean - 50) ** 2 / Math.sqrt(20 + (Lpmean - 50) ** 2); | |
| const SC = 1 + 0.045 * Cpmean; | |
| const SH = 1 + 0.015 * Cpmean * T; | |
| const Cpmean7 = Cpmean ** 7; | |
| const RT = -2 * Math.sqrt(Cpmean7 / (Cpmean7 + 6103515625)) | |
| * Math.sin(60 * Math.exp(-(((Hpmean - 275) / 25) ** 2)) * Math.PI / 180); | |
| return Math.sqrt( | |
| (dLp / SL) ** 2 + | |
| (dCp / SC) ** 2 + | |
| (dHp / SH) ** 2 + | |
| RT * (dCp / SC) * (dHp / SH) | |
| ); | |
| } | |
| /** | |
| * Assign stroke colors to anchor groups using nearest-anchor distance. | |
| * | |
| * @param {{ anchors: {hex,r,g,b}[], strokes: {hex,r,g,b}[] }} structure | |
| * @param {{ hex: string, r: number, g: number, b: number }[]} allColors | |
| * @param {{ distFn?: 'lab' | 'ciede2000' }} options | |
| * @returns {number[][]} groups - array of color index arrays (anchor order) | |
| */ | |
| export function buildStructuralColorGroups(structure, allColors, options = {}) { | |
| const { anchors, strokes } = structure; | |
| const distFn = options.distFn || 'ciede2000'; | |
| // Build color index map | |
| const colorIndex = new Map(); | |
| allColors.forEach((c, i) => colorIndex.set(c.hex, i)); | |
| // Compute anchor Labs | |
| const anchorLabs = anchors.map(a => rgbToLab(a.r, a.g, a.b)); | |
| // Initialize groups with anchor colors | |
| const groups = anchors.map(a => { | |
| const idx = colorIndex.get(a.hex); | |
| return idx !== undefined ? [idx] : []; | |
| }); | |
| // Distance function | |
| const dist = distFn === 'ciede2000' ? ciede2000 : labDist; | |
| // Assign each stroke to nearest anchor | |
| for (const s of strokes) { | |
| const idx = colorIndex.get(s.hex); | |
| if (idx === undefined) continue; | |
| const sLab = rgbToLab(s.r, s.g, s.b); | |
| let bestGi = 0, bestDist = Infinity; | |
| for (let gi = 0; gi < anchorLabs.length; gi++) { | |
| const d = dist(sLab, anchorLabs[gi]); | |
| if (d < bestDist) { bestDist = d; bestGi = gi; } | |
| } | |
| groups[bestGi].push(idx); | |
| } | |
| return groups.filter(g => g.length > 0); | |
| } | |