Improve perceptual MARD color matching
This commit is contained in:
+138
-31
@@ -8,6 +8,22 @@ type BeadColor = {
|
||||
name: string;
|
||||
hex: string;
|
||||
rgb: [number, number, number];
|
||||
lab: Oklab;
|
||||
};
|
||||
|
||||
type Oklab = [number, number, number];
|
||||
|
||||
type ColorBin = {
|
||||
key: number;
|
||||
count: number;
|
||||
rgb: [number, number, number];
|
||||
lab: Oklab;
|
||||
};
|
||||
|
||||
type ColorCluster = {
|
||||
count: number;
|
||||
rgb: [number, number, number];
|
||||
lab: Oklab;
|
||||
};
|
||||
|
||||
type Pixel = {
|
||||
@@ -37,12 +53,32 @@ const MARD_SERIES_NAMES: Record<string, string> = {
|
||||
F: "红色系", G: "肤棕系", H: "黑白灰系", M: "莫兰迪系",
|
||||
};
|
||||
|
||||
const PALETTE: BeadColor[] = MARD_221.map(([code, hex]) => ({
|
||||
code,
|
||||
name: `MARD ${MARD_SERIES_NAMES[code[0]]}`,
|
||||
hex,
|
||||
rgb: [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)],
|
||||
}));
|
||||
function rgbToOklab([red, green, blue]: [number, number, number]): Oklab {
|
||||
const linear = (value: number) => {
|
||||
const channel = value / 255;
|
||||
return channel <= 0.04045 ? channel / 12.92 : ((channel + 0.055) / 1.055) ** 2.4;
|
||||
};
|
||||
const r = linear(red);
|
||||
const g = linear(green);
|
||||
const b = linear(blue);
|
||||
const l = Math.cbrt(0.4122214708 * r + 0.5363325363 * g + 0.0514459929 * b);
|
||||
const m = Math.cbrt(0.2119034982 * r + 0.6806995451 * g + 0.1073969566 * b);
|
||||
const s = Math.cbrt(0.0883024619 * r + 0.2817188376 * g + 0.6299787005 * b);
|
||||
return [
|
||||
0.2104542553 * l + 0.793617785 * m - 0.0040720468 * s,
|
||||
1.9779984951 * l - 2.428592205 * m + 0.4505937099 * s,
|
||||
0.0259040371 * l + 0.7827717662 * m - 0.808675766 * s,
|
||||
];
|
||||
}
|
||||
|
||||
function oklabDistance(a: Oklab, b: Oklab) {
|
||||
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
|
||||
}
|
||||
|
||||
const PALETTE: BeadColor[] = MARD_221.map(([code, hex]) => {
|
||||
const rgb: [number, number, number] = [parseInt(hex.slice(1, 3), 16), parseInt(hex.slice(3, 5), 16), parseInt(hex.slice(5, 7), 16)];
|
||||
return { code, name: `MARD ${MARD_SERIES_NAMES[code[0]]}`, hex, rgb, lab: rgbToOklab(rgb) };
|
||||
});
|
||||
|
||||
const hexToRgb = (hex: string): [number, number, number] => [
|
||||
parseInt(hex.slice(1, 3), 16),
|
||||
@@ -50,19 +86,11 @@ const hexToRgb = (hex: string): [number, number, number] => [
|
||||
parseInt(hex.slice(5, 7), 16),
|
||||
];
|
||||
|
||||
function colorDistance(a: [number, number, number], b: [number, number, number]) {
|
||||
const meanR = (a[0] + b[0]) / 2;
|
||||
const r = a[0] - b[0];
|
||||
const g = a[1] - b[1];
|
||||
const blue = a[2] - b[2];
|
||||
return (2 + meanR / 256) * r * r + 4 * g * g + (2 + (255 - meanR) / 256) * blue * blue;
|
||||
}
|
||||
|
||||
function nearestColor(rgb: [number, number, number], palette: BeadColor[]) {
|
||||
function nearestColor(lab: Oklab, palette: BeadColor[]) {
|
||||
let closest = palette[0];
|
||||
let smallest = Number.POSITIVE_INFINITY;
|
||||
for (const color of palette) {
|
||||
const distance = colorDistance(rgb, color.rgb);
|
||||
const distance = oklabDistance(lab, color.lab);
|
||||
if (distance < smallest) {
|
||||
smallest = distance;
|
||||
closest = color;
|
||||
@@ -71,6 +99,95 @@ function nearestColor(rgb: [number, number, number], palette: BeadColor[]) {
|
||||
return closest;
|
||||
}
|
||||
|
||||
function mergePerceptualColors(data: Uint8ClampedArray) {
|
||||
const pixelKeys: number[] = [];
|
||||
const histogram = new Map<number, { count: number; red: number; green: number; blue: number }>();
|
||||
for (let index = 0; index < data.length; index += 4) {
|
||||
const red = data[index];
|
||||
const green = data[index + 1];
|
||||
const blue = data[index + 2];
|
||||
const key = (red >> 3) << 10 | (green >> 3) << 5 | (blue >> 3);
|
||||
pixelKeys.push(key);
|
||||
const bin = histogram.get(key);
|
||||
if (bin) {
|
||||
bin.count += 1;
|
||||
bin.red += red;
|
||||
bin.green += green;
|
||||
bin.blue += blue;
|
||||
} else {
|
||||
histogram.set(key, { count: 1, red, green, blue });
|
||||
}
|
||||
}
|
||||
|
||||
const bins: ColorBin[] = [...histogram.entries()].map(([key, value]) => {
|
||||
const rgb: [number, number, number] = [value.red / value.count, value.green / value.count, value.blue / value.count];
|
||||
return { key, count: value.count, rgb, lab: rgbToOklab(rgb) };
|
||||
}).sort((a, b) => b.count - a.count);
|
||||
|
||||
const clusters: ColorCluster[] = [];
|
||||
const mergeThreshold = 0.025;
|
||||
for (const bin of bins) {
|
||||
let nearestIndex = -1;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
for (let index = 0; index < clusters.length; index++) {
|
||||
const distance = oklabDistance(bin.lab, clusters[index].lab);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
}
|
||||
if (nearestIndex >= 0 && nearestDistance <= mergeThreshold) {
|
||||
const cluster = clusters[nearestIndex];
|
||||
const total = cluster.count + bin.count;
|
||||
cluster.rgb = cluster.rgb.map((value, channel) => (value * cluster.count + bin.rgb[channel] * bin.count) / total) as [number, number, number];
|
||||
cluster.lab = cluster.lab.map((value, channel) => (value * cluster.count + bin.lab[channel] * bin.count) / total) as Oklab;
|
||||
cluster.count = total;
|
||||
} else {
|
||||
clusters.push({ count: bin.count, rgb: [...bin.rgb], lab: [...bin.lab] });
|
||||
}
|
||||
}
|
||||
|
||||
const binClusters = new Map<number, number>();
|
||||
for (const bin of bins) {
|
||||
let nearestIndex = 0;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
clusters.forEach((cluster, index) => {
|
||||
const distance = oklabDistance(bin.lab, cluster.lab);
|
||||
if (distance < nearestDistance) {
|
||||
nearestDistance = distance;
|
||||
nearestIndex = index;
|
||||
}
|
||||
});
|
||||
binClusters.set(bin.key, nearestIndex);
|
||||
}
|
||||
return { pixelKeys, clusters, binClusters };
|
||||
}
|
||||
|
||||
function chooseDistinctMardColors(clusters: ColorCluster[], maximum: number) {
|
||||
const candidates = new Map<string, { color: BeadColor; count: number; error: number; score: number }>();
|
||||
for (const cluster of clusters) {
|
||||
const color = nearestColor(cluster.lab, PALETTE);
|
||||
const error = oklabDistance(cluster.lab, color.lab);
|
||||
const chroma = Math.hypot(cluster.lab[1], cluster.lab[2]);
|
||||
const detailBonus = 1 + Math.min(0.65, chroma * 2.2) + (cluster.lab[0] < 0.28 ? 0.35 : 0);
|
||||
const current = candidates.get(color.code) ?? { color, count: 0, error: 0, score: 0 };
|
||||
current.count += cluster.count;
|
||||
current.error += error * cluster.count;
|
||||
current.score += cluster.count * detailBonus / (1 + error * 5);
|
||||
candidates.set(color.code, current);
|
||||
}
|
||||
|
||||
const separated: BeadColor[] = [];
|
||||
const minimumMardDistance = 0.035;
|
||||
const ranked = [...candidates.values()].sort((a, b) => b.score - a.score || a.error / a.count - b.error / b.count);
|
||||
for (const candidate of ranked) {
|
||||
if (separated.every((selected) => oklabDistance(candidate.color.lab, selected.lab) >= minimumMardDistance)) {
|
||||
separated.push(candidate.color);
|
||||
}
|
||||
}
|
||||
return separated.slice(0, Math.max(1, Math.min(maximum, separated.length)));
|
||||
}
|
||||
|
||||
function drawFittedImage(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
image: HTMLImageElement,
|
||||
@@ -192,27 +309,17 @@ export default function Home() {
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
drawFittedImage(ctx, image, width, height, fitMode, crop.zoom, crop.x, crop.y);
|
||||
const data = ctx.getImageData(0, 0, width, height).data;
|
||||
const initialMatches: BeadColor[] = [];
|
||||
const counts = new Map<string, number>();
|
||||
for (let i = 0; i < data.length; i += 4) {
|
||||
const match = nearestColor([data[i], data[i + 1], data[i + 2]], PALETTE);
|
||||
initialMatches.push(match);
|
||||
counts.set(match.code, (counts.get(match.code) ?? 0) + 1);
|
||||
}
|
||||
const limitedPalette = [...PALETTE]
|
||||
.sort((a, b) => (counts.get(b.code) ?? 0) - (counts.get(a.code) ?? 0))
|
||||
.slice(0, Math.max(2, Math.min(colorLimit, PALETTE.length)));
|
||||
const converted = initialMatches.map((match, index) => {
|
||||
const rgb: [number, number, number] = [data[index * 4], data[index * 4 + 1], data[index * 4 + 2]];
|
||||
return { color: limitedPalette.some((c) => c.code === match.code) ? match : nearestColor(rgb, limitedPalette) };
|
||||
});
|
||||
const { pixelKeys, clusters, binClusters } = mergePerceptualColors(data);
|
||||
const limitedPalette = chooseDistinctMardColors(clusters, Math.max(2, Math.min(colorLimit, PALETTE.length)));
|
||||
const clusterMatches = clusters.map((cluster) => nearestColor(cluster.lab, limitedPalette));
|
||||
const converted = pixelKeys.map((key) => ({ color: clusterMatches[binClusters.get(key) ?? 0] }));
|
||||
setGridWidth(width);
|
||||
setGridHeight(height);
|
||||
setRequestedWidth(width);
|
||||
setRequestedHeight(height);
|
||||
setPixels(converted);
|
||||
setSelectedCodes(new Set());
|
||||
setStatus(`已转换为 ${width} × ${height},共 ${width * height} 颗拼豆`);
|
||||
setStatus(`已转换为 ${width} × ${height},共 ${width * height} 颗拼豆 · ${limitedPalette.length} 种差异色`);
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
Reference in New Issue
Block a user