Skip to content

Commit 905384c

Browse files
ayutazclaude
andcommitted
feat: K-meansクラスタリングによる高品質な色量子化を実装
- K-means++初期化による最適な初期重心選択 - 10回反復のK-meansアルゴリズム実装 - サンプリングによるパフォーマンス最適化(最大10000ピクセル) - 画像の色分布に基づいた自然な代表色選択 参考: https://zenn.dev/3w36zj6/articles/a1bd35a3c867a8 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
1 parent 62f1cf4 commit 905384c

1 file changed

Lines changed: 119 additions & 8 deletions

File tree

src/js/app-v3.js

Lines changed: 119 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -659,23 +659,134 @@
659659
quantizeColors(canvas, colorCount) {
660660
const ctx = canvas.getContext('2d');
661661
const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height);
662-
const data = imageData.data;
663-
const factor = 256 / Math.cbrt(colorCount);
664662

665-
for (let i = 0; i < data.length; i += 4) {
666-
data[i] = Math.round(data[i] / factor) * factor;
667-
data[i + 1] = Math.round(data[i + 1] / factor) * factor;
668-
data[i + 2] = Math.round(data[i + 2] / factor) * factor;
669-
}
663+
// Use K-means clustering for better color selection
664+
const palette = this.kMeansClustering(imageData, colorCount);
665+
const quantizedData = this.applyPaletteToImageData(imageData, palette);
670666

671667
const resultCanvas = document.createElement('canvas');
672668
resultCanvas.width = canvas.width;
673669
resultCanvas.height = canvas.height;
674670
const resultCtx = resultCanvas.getContext('2d');
675-
resultCtx.putImageData(imageData, 0, 0);
671+
resultCtx.putImageData(quantizedData, 0, 0);
676672

677673
return resultCanvas;
678674
}
675+
676+
kMeansClustering(imageData, k) {
677+
const data = imageData.data;
678+
const pixels = [];
679+
680+
// Extract unique pixels (sample for performance)
681+
const step = Math.max(1, Math.floor(data.length / (4 * 10000))); // Sample up to 10000 pixels
682+
for (let i = 0; i < data.length; i += 4 * step) {
683+
pixels.push([data[i], data[i + 1], data[i + 2]]);
684+
}
685+
686+
// Initialize centroids using K-means++ method
687+
const centroids = this.initializeCentroidsKMeansPlusPlus(pixels, k);
688+
689+
// K-means iterations
690+
const maxIterations = 10;
691+
for (let iter = 0; iter < maxIterations; iter++) {
692+
// Assign pixels to clusters
693+
const clusters = Array(k).fill(null).map(() => []);
694+
695+
for (const pixel of pixels) {
696+
let minDist = Infinity;
697+
let bestCluster = 0;
698+
699+
for (let j = 0; j < k; j++) {
700+
const dist = this.colorDistanceSquared(pixel, centroids[j]);
701+
if (dist < minDist) {
702+
minDist = dist;
703+
bestCluster = j;
704+
}
705+
}
706+
707+
clusters[bestCluster].push(pixel);
708+
}
709+
710+
// Update centroids
711+
let converged = true;
712+
for (let j = 0; j < k; j++) {
713+
if (clusters[j].length > 0) {
714+
const newCentroid = [
715+
Math.round(clusters[j].reduce((sum, p) => sum + p[0], 0) / clusters[j].length),
716+
Math.round(clusters[j].reduce((sum, p) => sum + p[1], 0) / clusters[j].length),
717+
Math.round(clusters[j].reduce((sum, p) => sum + p[2], 0) / clusters[j].length)
718+
];
719+
720+
if (this.colorDistanceSquared(centroids[j], newCentroid) > 1) {
721+
converged = false;
722+
}
723+
724+
centroids[j] = newCentroid;
725+
}
726+
}
727+
728+
if (converged) break;
729+
}
730+
731+
return centroids;
732+
}
733+
734+
initializeCentroidsKMeansPlusPlus(pixels, k) {
735+
const centroids = [];
736+
737+
// Choose first centroid randomly
738+
centroids.push([...pixels[Math.floor(Math.random() * pixels.length)]]);
739+
740+
// Choose remaining centroids
741+
for (let i = 1; i < k; i++) {
742+
const distances = pixels.map(pixel => {
743+
let minDist = Infinity;
744+
for (const centroid of centroids) {
745+
const dist = this.colorDistanceSquared(pixel, centroid);
746+
if (dist < minDist) {
747+
minDist = dist;
748+
}
749+
}
750+
return minDist;
751+
});
752+
753+
// Choose pixel with probability proportional to squared distance
754+
const totalDist = distances.reduce((sum, d) => sum + d, 0);
755+
let random = Math.random() * totalDist;
756+
757+
for (let j = 0; j < pixels.length; j++) {
758+
random -= distances[j];
759+
if (random <= 0) {
760+
centroids.push([...pixels[j]]);
761+
break;
762+
}
763+
}
764+
}
765+
766+
return centroids;
767+
}
768+
769+
colorDistanceSquared(color1, color2) {
770+
const dr = color1[0] - color2[0];
771+
const dg = color1[1] - color2[1];
772+
const db = color1[2] - color2[2];
773+
return dr * dr + dg * dg + db * db;
774+
}
775+
776+
applyPaletteToImageData(imageData, palette) {
777+
const data = new Uint8ClampedArray(imageData.data);
778+
779+
for (let i = 0; i < data.length; i += 4) {
780+
const pixel = [data[i], data[i + 1], data[i + 2]];
781+
const nearestColor = this.findNearestColor(pixel, palette);
782+
783+
data[i] = nearestColor[0];
784+
data[i + 1] = nearestColor[1];
785+
data[i + 2] = nearestColor[2];
786+
}
787+
788+
return new ImageData(data, imageData.width, imageData.height);
789+
}
679790

680791
applyDithering(canvas, method, palette) {
681792
const ctx = canvas.getContext('2d');

0 commit comments

Comments
 (0)