問題
想編寫一個程序,使我可以從圖像中提取一組顏色,希望考慮到人類的感知並以一種看起來自然的方式進行操作。配色方案通常可以刻畫出整個圖像的“氛圍”,因此我認為能夠從圖像提取色彩有趣而且有用。
所以……我花了一些時間思考可以做些什麽。我設計了一些相當簡單的算法,例如,將圖像有規則地切成塊,然後輸出每個部分的平均顏色。也許還可以添加額外的圖層,在這些圖層中將塊進行比較並組合成組,或者將每種顏色與另一種顏色遞歸組合,直到達到所需的顏色數量為止。這些基於規則的做法都不太理想,不過我很快發現,這個問題已經有通用的解決方案了,而且通用方案是相當有效的。
那麽從圖像提取顏色的通用方法是什麽呢?請看下文詳解。
解決方案
K-means聚類就是一種解決此類問題的不錯方法,通過該方法可以將一組數據點劃分為幾個不相交的子集,其中每個子集中的點被視為彼此“接近”(根據某種度量標準)。當處理幾何空間中點時,標準歐幾裏得距離函數是一個常用度量。事實證明,這種方法正是我們需要的從圖像提取一組顏色的方法。
具體案例
在我們的案例中,“數據點”是顏色,而距離函數則是兩種顏色“有多麽不同”的度量。我們的任務是將這些顏色分組為給定數量的集合,然後計算每個集合的平均顏色。在每個集合中使用均值似乎是一個相當明智的選擇。當然,我們也可以改用任何其他統計量度(眾數,中位數或其他任何形式!),也可能得到不錯的結果。
以下程序使用JavaScript!代碼編寫✨
數據點
每個數據點都是一種顏色,可以表示為RGB色彩空間。
在JavaScript中,單個數據點可能看起來像這樣:
// An array if we want to be general let colour = [100,168,92];
// Or an object if we want to be more explicit let colour = {red: 100, green: 168, blue: 92};
距離函數
由於我們希望能夠計算兩種顏色的相似程度,因此我們需要一個函數。一個簡單的選擇就是使用每種顏色的成分值來計算歐式距離。
我們的距離函數如下所示:
// Distance function
function euclideanDistance(a, b) {
let sum = 0;
for (let i = 0; i < a.length; i++) {
sum += Math.pow(b[i] — a[i], 2);
}
return Math.sqrt(sum);
}
由於我們尚未在函數中指定固定數量的組件,因此該組件可用於n-dimensional數據點(具有n個分量)。如果以後我們要用其他方式表示顏色,這很有用。
算法
用於k-means聚類的最常見算法稱為勞埃德(Lloyd)算法(盡管通常簡稱為的k-means算法)。我們將在這裏使用該算法。
我們將創建一組稱為“質心”的對象,每個對象都定義一個唯一的簇。
一個質心與之相關的兩件事:
- 數據集範圍內的一個點(質心的位置)
- 數據集中的一組數據點(質心簇中的點)
該算法包括三個主要步驟:
1。初始化。選擇質心的初始值。在這種情況下,我們隻為每個簇選擇一個隨機點作為質心。
2。分配。將每個數據點分配給離簇均值(質心)距離最小的聚類。
3。更新。將每個質心設置為與其相關聯的所有數據點的均值(即質心的所在的簇中點的均值)。
該算法將執行初始化一次,然後執行分配和更新並依次重複,直到算法收斂為止。
當分配質心不再有任何變化時,該算法“收斂”。
輔助函數
讓我們定義一些輔助函數:用於計算給定數據集範圍的函數,另一個用於在給定範圍內生成隨機整數的函數。 n-dimensional數據集的“範圍”——即每個維度對應一個範圍。
// Calculate range of a one-dimensional data set
function rangeOf(data) { return {min: Math.min(data), max: Math.max(data)}; }
// Calculate range of an n-dimensional data set function rangesOf(data) { let ranges = [];
for (let i = 0; i < data[0].length; i++) { ranges.push(rangeOf(data.map(x => x[i]))); }
return ranges; }
// Generate random integer in a given closed interval function randomIntBetween(a, b) { return Math.floor(Math.random() * (b - a + 1)) + a; }
有了上述準備之後,可以給算法的三個步驟編寫代碼了。
第一步-初始化
對於所需的每個質心(假設質心數為k),我們在提供的數據集範圍內生成一個隨機的整數值點,並將其附加到數組中。數組中的每個點代表一個質心的位置。當然,質心與數據的維數相同。
function initialiseCentroidsRandomly(data, k) { let ranges = rangesOf(data); let centroids = []; for (let i = 0; i < k; i++) { let centroid = []; for (let r in ranges) { centroid.push( randomIntBetween(ranges[r].min, ranges[r].max)); }
centroids.push(centroid); }
return centroids; }
第二步-分配
這是我們將數據點分配給簇的階段。對於每個點,我們選擇距離最短的質心,然後將點附加到對應的簇。
注:這裏我們使用了數組的Map功能以及箭頭功能如果您不太確定發生了什麽,請點擊鏈接快速瀏覽一下。
function clusterDataPoints(data, centroids) { let clusters = [];
centroids.forEach(function() { clusters.push([]); });
data.forEach(function(point) {
let nearestCentroid = Math.min( centroids.map(x => euclideanDistance(point, x)));
clusters[centroids.indexOf(nearestCentroid)].push(point); });
return clusters; }
第三步-更新
對於每個簇,我們計算其內部數據點的平均值,並將其設置為簇的質心位置。然後,我們返回新的質心集。
實際上,這裏使用了另一個函數均值(這裏沒有給出)。它返回一個點,其成分是每個傳遞的點中相應成分值的均值。
function getNewCentroids(clusters) { let centroids = [];
clusters.forEach(function(cluster) { centroids.push(meanPoint(cluster)); });
return centroids; }
注意點!
這種算法有一個問題,就是有時候簇會變空。對於發生這種情況時該怎麽做尚無共識,但是一些可能的方法是:
- 刪除簇(有點傻)
- 為簇分配一個隨機數據點
- 將最接近的數據點分配給簇
- 重新啟動算法,希望它不再發生
盡管最後一個選項似乎有些困難,但是該算法(帶有隨機初始化)是不確定的,因此隻需重啟就可以很好地工作。
匯總
現在,我們已經定義了基本功能,剩下的就是編寫一些匯總代碼,以根據算法定義在需要時調用每個函數。
我將把它留給您作為練習,參考這裏。 🌈