Add selectable color sampling strategies
This commit is contained in:
+81
-9
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user