Refine gradients and remove isolated colors

This commit is contained in:
wuyanwanwu
2026-08-14 01:57:19 +08:00
parent 02a17f5283
commit e028ecc9c8
14 changed files with 155 additions and 24 deletions
+138 -7
View File
@@ -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);