Refine gradients and remove isolated colors
This commit is contained in:
+138
-7
@@ -201,7 +201,9 @@ function mergePerceptualColors(data: Uint8ClampedArray) {
|
||||
}).sort((a, b) => b.count - a.count);
|
||||
|
||||
const clusters: ColorCluster[] = [];
|
||||
const mergeThreshold = 0.025;
|
||||
// Keep enough source-color resolution for broad gradients. The later MARD
|
||||
// selection still merges close colors globally and rejects tiny edge noise.
|
||||
const mergeThreshold = 0.015;
|
||||
for (const bin of bins) {
|
||||
let nearestIndex = -1;
|
||||
let nearestDistance = Number.POSITIVE_INFINITY;
|
||||
@@ -254,20 +256,145 @@ function chooseDistinctMardColors(clusters: ColorCluster[], maximum: number) {
|
||||
}
|
||||
|
||||
const separated: BeadColor[] = [];
|
||||
const totalPixels = clusters.reduce((total, cluster) => total + cluster.count, 0);
|
||||
const minimumMardDistance = 0.035;
|
||||
const broadGradientDistance = 0.02;
|
||||
const broadColorThreshold = Math.max(12, totalPixels * 0.008);
|
||||
const ranked = [...candidates.values()].sort((a, b) => b.score - a.score || a.error / a.count - b.error / b.count);
|
||||
for (const candidate of ranked) {
|
||||
const requiredDistance = candidate.count >= broadColorThreshold ? broadGradientDistance : minimumMardDistance;
|
||||
if (separated.every((selected) => oklabDistance(candidate.color.lab, selected.lab) >= requiredDistance)) {
|
||||
if (separated.every((selected) => oklabDistance(candidate.color.lab, selected.lab) >= minimumMardDistance)) {
|
||||
separated.push(candidate.color);
|
||||
}
|
||||
}
|
||||
return separated.slice(0, Math.max(0, Math.min(maximum, separated.length)));
|
||||
}
|
||||
|
||||
function matchClustersToPalette(clusters: ColorCluster[], limitedPalette: BeadColor[], colorLimit: number) {
|
||||
const matches = clusters.map((cluster) => nearestColor(cluster.lab, limitedPalette));
|
||||
if (colorLimit < 12) return matches;
|
||||
|
||||
// A large, smooth gradient may contain several useful MARD steps which the
|
||||
// global minimum-distance filter deliberately merges. Let only substantial
|
||||
// source clusters recover their accurate shade; tiny edge colors stay out.
|
||||
const totalPixels = clusters.reduce((total, cluster) => total + cluster.count, 0);
|
||||
const broadClusterThreshold = Math.max(24, totalPixels * 0.012);
|
||||
const maximumExtraShades = Math.min(8, Math.max(0, colorLimit - limitedPalette.length));
|
||||
const extraShades: BeadColor[] = [];
|
||||
const rankedClusters = clusters
|
||||
.map((cluster, index) => ({ cluster, index, exact: nearestColor(cluster.lab, PALETTE) }))
|
||||
.filter(({ cluster, exact, index }) => cluster.count >= broadClusterThreshold && exact.code !== matches[index].code)
|
||||
.sort((a, b) => b.cluster.count - a.cluster.count);
|
||||
|
||||
for (const candidate of rankedClusters) {
|
||||
if (extraShades.length >= maximumExtraShades) break;
|
||||
if (extraShades.some((color) => color.code === candidate.exact.code)) {
|
||||
matches[candidate.index] = candidate.exact;
|
||||
continue;
|
||||
}
|
||||
const nearestSelectedDistance = Math.min(...limitedPalette.map((color) => oklabDistance(color.lab, candidate.exact.lab)));
|
||||
if (nearestSelectedDistance < 0.018) continue;
|
||||
extraShades.push(candidate.exact);
|
||||
matches[candidate.index] = candidate.exact;
|
||||
}
|
||||
return matches;
|
||||
}
|
||||
|
||||
function preserveSmoothGradientSteps(
|
||||
colors: Array<BeadColor | null>,
|
||||
sourceData: Uint8ClampedArray,
|
||||
width: number,
|
||||
height: number,
|
||||
colorLimit: number,
|
||||
) {
|
||||
if (colorLimit < 12) return colors;
|
||||
const result = [...colors];
|
||||
const sourceLabs: Array<Oklab | null> = [];
|
||||
for (let index = 0; index < sourceData.length; index += 4) {
|
||||
sourceLabs.push(sourceData[index + 3] < TRANSPARENT_ALPHA_THRESHOLD
|
||||
? null
|
||||
: rgbToOklab([sourceData[index], sourceData[index + 1], sourceData[index + 2]]));
|
||||
}
|
||||
|
||||
const smoothMask = new Uint8Array(width * height);
|
||||
const directions = [[-1, 0], [1, 0], [0, -1], [0, 1]];
|
||||
for (let row = 1; row < height - 1; row++) {
|
||||
for (let column = 1; column < width - 1; column++) {
|
||||
const index = row * width + column;
|
||||
const current = sourceLabs[index];
|
||||
if (!current) continue;
|
||||
let maximumNeighborDistance = 0;
|
||||
for (const [rowOffset, columnOffset] of directions) {
|
||||
const neighbor = sourceLabs[(row + rowOffset) * width + column + columnOffset];
|
||||
if (!neighbor) { maximumNeighborDistance = Number.POSITIVE_INFINITY; break; }
|
||||
maximumNeighborDistance = Math.max(maximumNeighborDistance, oklabDistance(current, neighbor));
|
||||
}
|
||||
if (maximumNeighborDistance <= 0.018) smoothMask[index] = 1;
|
||||
}
|
||||
}
|
||||
|
||||
// Require a real two-dimensional smooth patch, so a one-cell antialiased
|
||||
// outline can never qualify as a gradient.
|
||||
const gradientPalette = new Set<string>();
|
||||
for (let row = 2; row < height - 2; row++) {
|
||||
for (let column = 2; column < width - 2; column++) {
|
||||
const index = row * width + column;
|
||||
if (!smoothMask[index]) continue;
|
||||
let smoothNeighbors = 0;
|
||||
for (let rowOffset = -1; rowOffset <= 1; rowOffset++) {
|
||||
for (let columnOffset = -1; columnOffset <= 1; columnOffset++) {
|
||||
if (smoothMask[(row + rowOffset) * width + column + columnOffset]) smoothNeighbors += 1;
|
||||
}
|
||||
}
|
||||
if (smoothNeighbors < 7) continue;
|
||||
const sourceLab = sourceLabs[index]!;
|
||||
const sourceChroma = Math.hypot(sourceLab[1], sourceLab[2]);
|
||||
// Near-neutral pixels should use the neutral MARD series. Otherwise a
|
||||
// barely visible numerical hue can create a conspicuous lavender/pink
|
||||
// stripe inside an otherwise blue-to-cream gradient.
|
||||
const gradientCandidates = sourceChroma < 0.035
|
||||
? PALETTE.filter((color) => color.code.startsWith("H"))
|
||||
: PALETTE;
|
||||
const exact = nearestColor(sourceLab, gradientCandidates);
|
||||
if (!gradientPalette.has(exact.code) && gradientPalette.size >= 8) continue;
|
||||
gradientPalette.add(exact.code);
|
||||
result[index] = exact;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function cleanGloballyRareIsolatedColors(colors: Array<BeadColor | null>, width: number, height: number) {
|
||||
const usage = new Map<string, number>();
|
||||
colors.forEach((color) => { if (color) usage.set(color.code, (usage.get(color.code) ?? 0) + 1); });
|
||||
const cleaned = [...colors];
|
||||
let changed = 0;
|
||||
for (let row = 0; row < height; row++) {
|
||||
for (let column = 0; column < width; column++) {
|
||||
const index = row * width + column;
|
||||
const current = colors[index];
|
||||
if (!current || (usage.get(current.code) ?? 0) > 2) continue;
|
||||
const neighbors = new Map<string, { color: BeadColor; count: number }>();
|
||||
for (let rowOffset = -1; rowOffset <= 1; rowOffset++) {
|
||||
for (let columnOffset = -1; columnOffset <= 1; columnOffset++) {
|
||||
if (rowOffset === 0 && columnOffset === 0) continue;
|
||||
const neighborRow = row + rowOffset;
|
||||
const neighborColumn = column + columnOffset;
|
||||
if (neighborRow < 0 || neighborColumn < 0 || neighborRow >= height || neighborColumn >= width) continue;
|
||||
const neighbor = colors[neighborRow * width + neighborColumn];
|
||||
if (!neighbor || neighbor.code === current.code) continue;
|
||||
const entry = neighbors.get(neighbor.code) ?? { color: neighbor, count: 0 };
|
||||
entry.count += 1;
|
||||
neighbors.set(neighbor.code, entry);
|
||||
}
|
||||
}
|
||||
const majority = [...neighbors.values()].sort((a, b) => b.count - a.count)[0];
|
||||
if (!majority) continue;
|
||||
const requiredSupport = (usage.get(current.code) ?? 0) === 1 ? 3 : 5;
|
||||
if (majority.count < requiredSupport) continue;
|
||||
cleaned[index] = majority.color;
|
||||
changed += 1;
|
||||
}
|
||||
}
|
||||
return { colors: cleaned, changed };
|
||||
}
|
||||
|
||||
function sampleDominantRegions(
|
||||
image: HTMLImageElement,
|
||||
width: number,
|
||||
@@ -630,7 +757,7 @@ export default function Home() {
|
||||
}
|
||||
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 clusterMatches = matchClustersToPalette(clusters, limitedPalette, colorLimit);
|
||||
let matchedColors: Array<BeadColor | null> = pixelKeys.map((key) => key === null ? null : clusterMatches[binClusters.get(key) ?? 0]);
|
||||
let cleanedCount = 0;
|
||||
if (dominantConfidence) {
|
||||
@@ -640,6 +767,10 @@ export default function Home() {
|
||||
const transitionCleaned = cleanThinTransitionBands(matchedColors, width, height);
|
||||
matchedColors = transitionCleaned.colors;
|
||||
cleanedCount += transitionCleaned.changed;
|
||||
matchedColors = preserveSmoothGradientSteps(matchedColors, data, width, height, colorLimit);
|
||||
const rareCleaned = cleanGloballyRareIsolatedColors(matchedColors, width, height);
|
||||
matchedColors = rareCleaned.colors;
|
||||
cleanedCount += rareCleaned.changed;
|
||||
}
|
||||
const converted = matchedColors.map((color) => ({ color }));
|
||||
setGridWidth(width);
|
||||
|
||||
Reference in New Issue
Block a user