|
659 | 659 | quantizeColors(canvas, colorCount) { |
660 | 660 | const ctx = canvas.getContext('2d'); |
661 | 661 | const imageData = ctx.getImageData(0, 0, canvas.width, canvas.height); |
662 | | - const data = imageData.data; |
663 | | - const factor = 256 / Math.cbrt(colorCount); |
664 | 662 |
|
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); |
670 | 666 |
|
671 | 667 | const resultCanvas = document.createElement('canvas'); |
672 | 668 | resultCanvas.width = canvas.width; |
673 | 669 | resultCanvas.height = canvas.height; |
674 | 670 | const resultCtx = resultCanvas.getContext('2d'); |
675 | | - resultCtx.putImageData(imageData, 0, 0); |
| 671 | + resultCtx.putImageData(quantizedData, 0, 0); |
676 | 672 |
|
677 | 673 | return resultCanvas; |
678 | 674 | } |
| 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 | + } |
679 | 790 |
|
680 | 791 | applyDithering(canvas, method, palette) { |
681 | 792 | const ctx = canvas.getContext('2d'); |
|
0 commit comments