Reduce mixed colors in dominant sampling
This commit is contained in:
+87
-6
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user