Add selectable color sampling strategies

This commit is contained in:
wuyanwanwu
2026-08-14 00:34:50 +08:00
parent acd8eb5c34
commit 1a02e27740
12 changed files with 95 additions and 23 deletions
+81 -9
View File
@@ -26,6 +26,8 @@ type ColorCluster = {
lab: Oklab;
};
type SamplingStrategy = "smooth" | "dominant";
type Pixel = {
color: BeadColor;
};
@@ -188,6 +190,68 @@ function chooseDistinctMardColors(clusters: ColorCluster[], maximum: number) {
return separated.slice(0, Math.max(1, Math.min(maximum, separated.length)));
}
function sampleDominantRegions(
image: HTMLImageElement,
width: number,
height: number,
fitMode: "cover" | "contain",
cropZoom: number,
cropX: number,
cropY: number,
) {
const scale = 4;
const sample = document.createElement("canvas");
sample.width = width * scale;
sample.height = height * scale;
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
ctx.fillStyle = "#f7f5ed";
ctx.fillRect(0, 0, sample.width, sample.height);
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);
const localMergeThreshold = 0.04;
for (let row = 0; row < height; row++) {
for (let column = 0; column < width; column++) {
const groups: Array<{ count: number; lab: Oklab; samples: Array<{ rgb: [number, number, number]; lab: Oklab }> }> = [];
for (let offsetY = 0; offsetY < scale; offsetY++) {
for (let offsetX = 0; offsetX < scale; offsetX++) {
const sourceIndex = ((row * scale + offsetY) * sample.width + column * scale + offsetX) * 4;
const rgb: [number, number, number] = [source[sourceIndex], source[sourceIndex + 1], source[sourceIndex + 2]];
const lab = rgbToOklab(rgb);
let closest: (typeof groups)[number] | undefined;
let closestDistance = Number.POSITIVE_INFINITY;
for (const group of groups) {
const distance = oklabDistance(lab, group.lab);
if (distance < closestDistance) {
closest = group;
closestDistance = distance;
}
}
if (closest && closestDistance <= localMergeThreshold) {
const total = closest.count + 1;
closest.lab = closest.lab.map((value, channel) => (value * closest.count + lab[channel]) / total) as Oklab;
closest.count = total;
closest.samples.push({ rgb, lab });
} else {
groups.push({ count: 1, lab: [...lab], samples: [{ rgb, lab }] });
}
}
}
const dominant = groups.sort((a, b) => b.count - a.count)[0];
const representative = dominant.samples.reduce((best, current) =>
oklabDistance(current.lab, dominant.lab) < oklabDistance(best.lab, dominant.lab) ? current : best,
);
const targetIndex = (row * width + column) * 4;
result[targetIndex] = representative.rgb[0];
result[targetIndex + 1] = representative.rgb[1];
result[targetIndex + 2] = representative.rgb[2];
result[targetIndex + 3] = 255;
}
}
return result;
}
function drawFittedImage(
ctx: CanvasRenderingContext2D,
image: HTMLImageElement,
@@ -255,6 +319,7 @@ export default function Home() {
const [gridHeight, setGridHeight] = useState(32);
const [colorLimit, setColorLimit] = useState(18);
const [fitMode, setFitMode] = useState<"cover" | "contain">("cover");
const [samplingStrategy, setSamplingStrategy] = useState<SamplingStrategy>("smooth");
const [cropZoom, setCropZoom] = useState(1);
const [cropX, setCropX] = useState(0);
const [cropY, setCropY] = useState(0);
@@ -301,14 +366,19 @@ export default function Home() {
if (!image) return;
const width = Math.max(8, Math.min(256, requestedWidth));
const height = Math.max(8, Math.min(256, requestedHeight));
const sample = document.createElement("canvas");
sample.width = width;
sample.height = height;
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
ctx.fillStyle = "#f7f5ed";
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;
let data: Uint8ClampedArray;
if (samplingStrategy === "dominant") {
data = sampleDominantRegions(image, width, height, fitMode, crop.zoom, crop.x, crop.y);
} else {
const sample = document.createElement("canvas");
sample.width = width;
sample.height = height;
const ctx = sample.getContext("2d", { willReadFrequently: true })!;
ctx.fillStyle = "#f7f5ed";
ctx.fillRect(0, 0, width, height);
drawFittedImage(ctx, image, width, height, fitMode, crop.zoom, crop.x, crop.y);
data = ctx.getImageData(0, 0, width, height).data;
}
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));
@@ -319,7 +389,8 @@ export default function Home() {
setRequestedHeight(height);
setPixels(converted);
setSelectedCodes(new Set());
setStatus(`已转换为 ${width} × ${height},共 ${width * height} 颗拼豆 · ${limitedPalette.length} 种差异色`);
const strategyName = samplingStrategy === "dominant" ? "区域主色" : "平滑取色";
setStatus(`已转换为 ${width} × ${height} · ${strategyName} · ${limitedPalette.length} 种差异色`);
};
useEffect(() => {
@@ -643,6 +714,7 @@ export default function Home() {
</div>
<label className="field"><span>使 <b>{colorLimit}</b></span><input type="range" min="2" max="64" value={colorLimit} onChange={(e) => setColorLimit(Number(e.target.value))} /></label>
<label className="field"><span></span><select value={samplingStrategy} onChange={(e) => setSamplingStrategy(e.target.value as SamplingStrategy)}><option value="smooth"></option><option value="dominant"></option></select></label>
<label className="field"><span></span><select value={fitMode} onChange={(e) => setFitMode(e.target.value as "cover" | "contain")}><option value="cover"></option><option value="contain"></option></select></label>
<button className="primary-button" onClick={() => convertImage()}></button>
<p className="privacy-note"></p>