はじめに
画像からパーラービーズの図案を作る場合、単純に画像を縮小するだけでは、色が不自然になったり細部が消えたりすることがあります。
そこで今回は、Next.js、TypeScript、Canvas APIを使い、画像をグリッド化して実際のビーズカラーに近い色へ変換する方法を紹介します。
処理は次の流れで行います。
- 画像をブラウザで読み込む
- Canvas上で画像サイズを調整する
- グリッドごとの代表色を取得する
- RGBをLAB色空間へ変換する
- CIEDE2000で最も近いビーズカラーを探す
- 変換結果をグリッドとして表示する
使用技術
- Next.js
- React
- TypeScript
- Canvas API
- CIEDE2000
- Tailwind CSS
画像をCanvasに読み込む
最初に、アップロードされた画像をCanvasへ描画します。
function loadImage(file: File): Promise<HTMLImageElement> {
return new Promise((resolve, reject) => {
const image = new Image();
const url = URL.createObjectURL(file);
image.onload = () => {
URL.revokeObjectURL(url);
resolve(image);
};
image.onerror = () => {
URL.revokeObjectURL(url);
reject(new Error("画像を読み込めませんでした"));
};
image.src = url;
});
}
大きな画像をそのまま処理すると計算量が増えるため、先に適切なサイズへ縮小します。
const MAX_PROCESSING_WIDTH = 800;
const MIN_PROCESSING_WIDTH = 240;
const PIXELS_PER_BEAD = 6;
function getProcessingWidth(gridWidth: number): number {
return Math.min(
MAX_PROCESSING_WIDTH,
Math.max(
MIN_PROCESSING_WIDTH,
Math.ceil(gridWidth * PIXELS_PER_BEAD)
)
);
}
例えば完成する図案が横50個の場合、処理用画像は約300px幅になります。最終的な図案が表現できないほど高い解像度で計算しないことがポイントです。
グリッドごとの色を取得する
画像を指定したグリッドサイズに分割し、それぞれの範囲に含まれるピクセルの平均色を計算します。
type RGB = [number, number, number];
function averageColor(
pixels: RGB[],
): RGB {
let red = 0;
let green = 0;
let blue = 0;
for (const [r, g, b] of pixels) {
red += r;
green += g;
blue += b;
}
return [
Math.round(red / pixels.length),
Math.round(green / pixels.length),
Math.round(blue / pixels.length),
];
}
1点だけをサンプリングするより、範囲内の平均色を使ったほうが、細い線や輪郭を残しやすくなります。
透明ピクセルを含む画像では、一定以上の不透明ピクセルが存在する場合だけビーズを配置します。
const visibleCoverage = visiblePixels / sampleCount;
if (visibleCoverage >= 0.18) {
result.push(averageColor(visiblePixelColors));
} else {
result.push(null);
}
RGBではなくLAB色空間で比較する
RGBの数値上の距離は、人間が感じる色の違いと必ずしも一致しません。
そのため、最初にRGBをLAB色空間へ変換します。
type LAB = [number, number, number];
function rgbToLab([r, g, b]: RGB): LAB {
let red = r / 255;
let green = g / 255;
let blue = b / 255;
red =
red > 0.04045
? Math.pow((red + 0.055) / 1.055, 2.4)
: red / 12.92;
green =
green > 0.04045
? Math.pow((green + 0.055) / 1.055, 2.4)
: green / 12.92;
blue =
blue > 0.04045
? Math.pow((blue + 0.055) / 1.055, 2.4)
: blue / 12.92;
let x =
(red * 0.4124564 +
green * 0.3575761 +
blue * 0.1804375) /
0.95047;
let y =
red * 0.2126729 +
green * 0.7151522 +
blue * 0.072175;
let z =
(red * 0.0193339 +
green * 0.119192 +
blue * 0.9503041) /
1.08883;
const convert = (value: number) =>
value > 0.008856
? Math.pow(value, 1 / 3)
: 7.787 * value + 16 / 116;
x = convert(x);
y = convert(y);
z = convert(z);
return [
116 * y - 16,
500 * (x - y),
200 * (y - z),
];
}
変換したLAB値をCIEDE2000で比較し、パレット内で色差が最も小さい色を選択します。
function findBestMatch(
targetRgb: RGB,
palette: BeadColor[],
): BeadColor | null {
const targetLab = rgbToLab(targetRgb);
let bestColor: BeadColor | null = null;
let smallestDifference = Infinity;
for (const color of palette) {
const colorLab = color.lab ?? rgbToLab(color.rgb);
const difference = ciede2000(targetLab, colorLab);
if (difference < smallestDifference) {
smallestDifference = difference;
bestColor = color;
}
}
return bestColor;
}
パレット側のLAB値を事前計算しておけば、同じ色を何度も変換する必要がなくなります。
メインスレッドを長時間ブロックしない
各グリッドとすべてのパレット色を比較すると、スマートフォンでは画面が一時的に反応しなくなることがあります。
そこで、処理を小さな単位に分割します。
const timeBudgetMs = isMobile ? 8 : 12;
function processBatch() {
const startedAt = performance.now();
while (currentIndex < pixels.length) {
results[currentIndex] = findBestMatch(
pixels[currentIndex],
palette,
);
currentIndex++;
if (
performance.now() - startedAt >= timeBudgetMs
) {
break;
}
}
if (currentIndex < pixels.length) {
requestAnimationFrame(processBatch);
} else {
resolve(results);
}
}
一度にすべてを計算するのではなく、約8~12msごとにブラウザへ制御を戻すことで、処理中でもUIを操作しやすくなります。
実際に作成したツール
この方法を使って、画像からパーラービーズ図案を作成できるWebツールを実装しました。
画像のアップロード、グリッドサイズの変更、ビーズカラーの選択、必要なビーズ数の確認、PNG・PDF出力などができます。
変換処理はブラウザ側のCanvasを中心に実行しています。
まとめ
画像をビーズ図案へ変換するときは、次の点が重要でした。
- 処理前に画像を適切なサイズへ縮小する
- 1点ではなく範囲内の平均色を使用する
- RGB距離ではなくLABとCIEDE2000で色を比較する
- パレットのLAB値を事前計算する
- 処理を分割してUIの停止を防ぐ
同じ方法は、クロスステッチ図案、ドット絵、モザイク画像などの生成にも応用できます。