Reduce mixed colors in dominant sampling

This commit is contained in:
wuyanwanwu
2026-08-14 01:23:37 +08:00
parent 1bd885f5b0
commit 8c505a205c
12 changed files with 101 additions and 20 deletions
+87 -6
View File
@@ -127,6 +127,21 @@ function oklabDistance(a: Oklab, b: Oklab) {
return Math.hypot(a[0] - b[0], a[1] - b[1], a[2] - b[2]);
}
function isPerceptualBridge(color: Oklab, first: Oklab, second: Oklab) {
const vector = [second[0] - first[0], second[1] - first[1], second[2] - first[2]] as Oklab;
const lengthSquared = vector[0] ** 2 + vector[1] ** 2 + vector[2] ** 2;
if (lengthSquared < 0.07 ** 2) return false;
const offset = [color[0] - first[0], color[1] - first[1], color[2] - first[2]] as Oklab;
const position = (offset[0] * vector[0] + offset[1] * vector[1] + offset[2] * vector[2]) / lengthSquared;
if (position < 0.12 || position > 0.88) return false;
const projection: Oklab = [
first[0] + vector[0] * position,
first[1] + vector[1] * position,
first[2] + vector[2] * position,
];
return oklabDistance(color, projection) <= 0.025;
}
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) };
@@ -253,20 +268,21 @@ function sampleDominantRegions(
cropX: number,
cropY: number,
) {
// Center-weighted 3x3 sampling preserves geometric corners better than a
// flat vote: center=4, orthogonal neighbors=2, diagonal neighbors=1.
// A mildly center-weighted 3x3 vote keeps geometric corners without letting
// one anti-aliased center sample overpower the surrounding real colors.
const scale = 3;
const sampleWeights = [
[1, 1, 1],
[1, 2, 1],
[2, 4, 2],
[1, 2, 1],
[1, 1, 1],
];
const totalSampleWeight = 16;
const totalSampleWeight = 10;
const sample = document.createElement("canvas");
sample.width = width * scale;
sample.height = height * scale;
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
ctx.clearRect(0, 0, sample.width, sample.height);
ctx.imageSmoothingEnabled = false;
drawFittedImage(ctx, image, sample.width, sample.height, fitMode, cropZoom, cropX, cropY);
const source = ctx.getImageData(0, 0, sample.width, sample.height).data;
const result = new Uint8ClampedArray(width * height * 4);
@@ -315,7 +331,20 @@ function sampleDominantRegions(
confidence[row * width + column] = transparentWeight / totalSampleWeight;
continue;
}
const dominant = groups.sort((a, b) => b.weight - a.weight || Number(b.containsCenter) - Number(a.containsCenter))[0];
const rankedGroups = groups.map((group) => {
let effectiveWeight = group.weight;
if (group.count <= 2) {
for (let first = 0; first < groups.length; first++) {
for (let second = first + 1; second < groups.length; second++) {
if (groups[first] === group || groups[second] === group) continue;
if (groups[first].weight + groups[second].weight < group.weight) continue;
if (isPerceptualBridge(group.lab, groups[first].lab, groups[second].lab)) effectiveWeight = 0;
}
}
}
return { group, effectiveWeight };
});
const dominant = rankedGroups.sort((a, b) => b.effectiveWeight - a.effectiveWeight || b.group.weight - a.group.weight || Number(b.group.containsCenter) - Number(a.group.containsCenter))[0].group;
const representative = dominant.samples.reduce((best, current) =>
oklabDistance(current.lab, dominant.lab) < oklabDistance(best.lab, dominant.lab) ? current : best,
);
@@ -329,6 +358,55 @@ function sampleDominantRegions(
return { data: result, confidence };
}
function cleanThinTransitionBands(colors: Array<BeadColor | null>, width: number, height: number) {
const cleaned = [...colors];
const usage = new Map<string, number>();
colors.forEach((color) => { if (color) usage.set(color.code, (usage.get(color.code) ?? 0) + 1); });
const maximumTransitionUsage = Math.max(4, Math.ceil(width * height * 0.015));
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) > maximumTransitionUsage) 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 candidates = [...neighbors.values()].sort((a, b) => b.count - a.count).slice(0, 3);
let replacement: BeadColor | null = null;
let replacementSupport = 0;
for (let first = 0; first < candidates.length; first++) {
for (let second = first + 1; second < candidates.length; second++) {
if (candidates[first].count + candidates[second].count < 4) continue;
if (!isPerceptualBridge(current.lab, candidates[first].color.lab, candidates[second].color.lab)) continue;
const preferred = candidates[first].count >= candidates[second].count ? candidates[first] : candidates[second];
if (preferred.count > replacementSupport) {
replacement = preferred.color;
replacementSupport = preferred.count;
}
}
}
if (replacement) {
cleaned[index] = replacement;
changed += 1;
}
}
}
return { colors: cleaned, changed };
}
function cleanLowConfidenceIsolatedColors(
colors: Array<BeadColor | null>,
confidence: Float32Array,
@@ -541,6 +619,9 @@ export default function Home() {
const cleaned = cleanLowConfidenceIsolatedColors(matchedColors, dominantConfidence, width, height);
matchedColors = cleaned.colors;
cleanedCount = cleaned.changed;
const transitionCleaned = cleanThinTransitionBands(matchedColors, width, height);
matchedColors = transitionCleaned.colors;
cleanedCount += transitionCleaned.changed;
}
const converted = matchedColors.map((color) => ({ color }));
setGridWidth(width);