👾
k-means法を用いて画像をドット絵風に変換する
はじめに
k-means法を用いて画像を減色しドット絵風に変換するWebアプリを作りました。
(よろしければstarを頂けると幸いです)
変換例
Lenna
- k=4
- 75x75
Mandrill
- k=8
- 50x50
海中のイラスト
- k=12
- 54x30
寿司のイラスト
- k=16
- 34x21
方針
k-means法を利用します。処理の流れは以下の通りです。
- ランダムにK(定数)個の画素を選び、クラスタ分けに用いる代表色を決定する。
- 各画素について、最も近い代表色を選びクラスタ分けをする。
- 各クラスタについて平均色を計算し、新たな代表色とする。
- 上記の処理でクラスタの割当てが変化しない、または変化量が閾値を下回った場合に収束したと判断して処理を終了する。そうでなければ処理を繰り返す。
実装
画像の読み込み
ImageData
で画像データを取得し、pixels
に入れ直しています。
export function pixelizeImageData(imageData: ImageData, k: number): ImageData {
const { width, height, data } = imageData
const pixels: number[][] = []
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const base = (y * width + x) * 4
pixels.push([data[base + 0], data[base + 1], data[base + 2]])
}
}
// 省略
}
クラスタ分けに用いる代表色の決定
ランダムにK個の画素を選び、その画素の色をクラスタ分けに用いる代表色の初期値とします。
function chooseAtRandom(array: number[][], count: number): number[][] {
count = count || 1
array = array.slice()
const result = []
for (const i of Array.from({ length: count }).keys()) {
const arrayIndex = Math.floor(Math.random() * array.length)
result[i] = array[arrayIndex]
array.splice(arrayIndex, 1)
}
return result
}
export function pixelizeImageData(imageData: ImageData, k: number): ImageData {
// 省略
let chosenPixels = chooseAtRandom(pixels, k)
// 省略
}
色の距離の計算
function abs(color1: number[], color2: number[]): number {
return (color1[0] - color2[0]) ** 2 + (color1[1] - color2[1]) ** 2 + (color1[2] - color2[2]) ** 2
}
各画素のクラスタ分け
これ以降の処理はクラスタ分けが収束するまで繰り返します。ここでは収束判定をサボって10回固定にしています。
各画素について、代表色のうち色の距離が最も近いものを選びクラスタ分けをします。
export function pixelizeImageData(imageData: ImageData, k: number): ImageData {
// 省略
let groupIndexes: number[] = []
// これ以降の処理を収束するまで繰り返す
for (let iter = 0; iter < 10; iter++) {
groupIndexes = []
for (const pixel of pixels) {
const distances = chosenPixels.map(cp => abs(pixel, cp))
groupIndexes.push(distances.indexOf(Math.min(...distances)))
}
// 省略
}
// 省略
}
代表色の更新
各クラスタについて平均色を計算し、それを新たな代表色とします。
export function pixelizeImageData(imageData: ImageData, k: number): ImageData {
// 省略
// これ以降の処理を収束するまで繰り返す
for (let iter = 0; iter < 10; iter++) {
// 省略
const groupIndexCount = Array.from({ length: k }, (_, i) => groupIndexes.filter(x => x === i).length)
const means = Array.from({ length: k }, () => [0, 0, 0])
for (let i = 0; i < pixels.length; i++) {
means[groupIndexes[i]][0] += pixels[i][0] / groupIndexCount[groupIndexes[i]]
means[groupIndexes[i]][1] += pixels[i][1] / groupIndexCount[groupIndexes[i]]
means[groupIndexes[i]][2] += pixels[i][2] / groupIndexCount[groupIndexes[i]]
}
chosenPixels = means
}
// 省略
}
変更をCanvasに出力
クラスタの割当てが収束したら、pixels
のRGBの値で新しいImageData
を作成し返却します。
export function pixelizeImageData(imageData: ImageData, k: number): ImageData {
// 省略
// これ以降の処理を収束するまで繰り返す
for (let iter = 0; iter < 10; iter++) {
// 省略
}
const newData = new Uint8ClampedArray(data.length)
for (let y = 0; y < height; y++) {
for (let x = 0; x < width; x++) {
const base = (y * width + x) * 4
const color = chosenPixels[groupIndexes[y * width + x]]
newData[base + 0] = color[0]
newData[base + 1] = color[1]
newData[base + 2] = color[2]
newData[base + 3] = 255
}
}
return new ImageData(newData, width, height)
}
Discussion