背景
上週,某公司的產品經理提了一個需求:根據使用者手機殼顏色來改變 App 主題顏色。可能是由於這天馬行空的需求激怒了程式設計師,導致程式設計師和產品經理打了起來,最後雙雙被公司開除。
那如何實現這個功能呢?首先需要獲取影象中的主色。
插一句題外話,作為程式設計師在桌面上還是要有一些必備的東西需要放的。
KMeans 演算法
k-平均演算法(英文:k-means clustering)源於訊號處理中的一種向量量化方法,現在則更多地作為一種聚類分析方法流行於資料探勘領域。k-平均聚類的目的是:把 n 個點(可以是樣本的一次觀察或一個例項)劃分到k個聚類中,使得每個點都屬於離他最近的均值(此即聚類中心)對應的聚類,以之作為聚類的標準。這個問題將歸結為一個把資料空間劃分為Voronoi cells的問題。
KMeans 演算法思想為:給定n個資料點{x1,x2,…,xn},找到K個聚類中心{a1,a2,…,aK},使得每個資料點與它最近的聚類中心的距離平方和最小,並將這個距離平方和稱為目標函式,記為Wn,其數學表示式為:
本文使用 KMeans 演算法對影象顏色做聚類。
演算法基本流程: 1、初始的 K 個聚類中心。 2、按照距離聚類中心的遠近對所有樣本進行分類。 3、重新計算聚類中心,判斷是否退出條件: 兩次聚類中心的距離足夠小視為滿足退出條件; 不退出則重新回到步驟2。
演算法實現
public List<Scalar> extract(ColorProcessor processor) {
// initialization the pixel data
int width = processor.getWidth();
int height = processor.getHeight();
byte[] R = processor.getRed();
byte[] G = processor.getGreen();
byte[] B = processor.getBlue();
//Create random points to use a the cluster center
Random random = new Random();
int index = 0;
for (int i = 0; i < numOfCluster; i++)
{
int randomNumber1 = random.nextInt(width);
int randomNumber2 = random.nextInt(height);
index = randomNumber2 * width + randomNumber1;
ClusterCenter cc = new ClusterCenter(randomNumber1, randomNumber2, R[index]&0xff, G[index]&0xff, B[index]&0xff);
cc.cIndex = i;
clusterCenterList.add(cc);
}
// create all cluster point
for (int row = 0; row < height; ++row)
{
for (int col = 0; col < width; ++col)
{
index = row * width + col;
pointList.add(new ClusterPoint(row, col, R[index]&0xff, G[index]&0xff, B[index]&0xff));
}
}
// initialize the clusters for each point
double[] clusterDisValues = new double[clusterCenterList.size()];
for(int i=0; i<pointList.size(); i++)
{
for(int j=0; j<clusterCenterList.size(); j++)
{
clusterDisValues[j] = calculateEuclideanDistance(pointList.get(i), clusterCenterList.get(j));
}
pointList.get(i).clusterIndex = (getCloserCluster(clusterDisValues));
}
// calculate the old summary
// assign the points to cluster center
// calculate the new cluster center
// computation the delta value
// stop condition--
double[][] oldClusterCenterColors = reCalculateClusterCenters();
int times = 10;
while(true)
{
stepClusters();
double[][] newClusterCenterColors = reCalculateClusterCenters();
if(isStop(oldClusterCenterColors, newClusterCenterColors))
{
break;
}
else
{
oldClusterCenterColors = newClusterCenterColors;
}
if(times > 10) {
break;
}
times++;
}
//update the result image
List<Scalar> colors = new ArrayList<Scalar>();
for(ClusterCenter cc : clusterCenterList) {
colors.add(cc.color);
}
return colors;
}
private boolean isStop(double[][] oldClusterCenterColors, double[][] newClusterCenterColors) {
boolean stop = false;
for (int i = 0; i < oldClusterCenterColors.length; i++) {
if (oldClusterCenterColors[i][0] == newClusterCenterColors[i][0] &&
oldClusterCenterColors[i][1] == newClusterCenterColors[i][1] &&
oldClusterCenterColors[i][2] == newClusterCenterColors[i][2]) {
stop = true;
break;
}
}
return stop;
}
/**
* update the cluster index by distance value
*/
private void stepClusters()
{
// initialize the clusters for each point
double[] clusterDisValues = new double[clusterCenterList.size()];
for(int i=0; i<pointList.size(); i++)
{
for(int j=0; j<clusterCenterList.size(); j++)
{
clusterDisValues[j] = calculateEuclideanDistance(pointList.get(i), clusterCenterList.get(j));
}
pointList.get(i).clusterIndex = (getCloserCluster(clusterDisValues));
}
}
/**
* using cluster color of each point to update cluster center color
*
* @return
*/
private double[][] reCalculateClusterCenters() {
// clear the points now
for(int i=0; i<clusterCenterList.size(); i++)
{
clusterCenterList.get(i).numOfPoints = 0;
}
// recalculate the sum and total of points for each cluster
double[] redSums = new double[numOfCluster];
double[] greenSum = new double[numOfCluster];
double[] blueSum = new double[numOfCluster];
for(int i=0; i<pointList.size(); i++)
{
int cIndex = (int)pointList.get(i).clusterIndex;
clusterCenterList.get(cIndex).numOfPoints++;
int tr = pointList.get(i).pixelColor.red;
int tg = pointList.get(i).pixelColor.green;
int tb = pointList.get(i).pixelColor.blue;
redSums[cIndex] += tr;
greenSum[cIndex] += tg;
blueSum[cIndex] += tb;
}
double[][] oldClusterCentersColors = new double[clusterCenterList.size()][3];
for(int i=0; i<clusterCenterList.size(); i++)
{
double sum = clusterCenterList.get(i).numOfPoints;
int cIndex = clusterCenterList.get(i).cIndex;
int red = (int)(greenSum[cIndex]/sum);
int green = (int)(greenSum[cIndex]/sum);
int blue = (int)(blueSum[cIndex]/sum);
clusterCenterList.get(i).color = new Scalar(red, green, blue);
oldClusterCentersColors[i][0] = red;
oldClusterCentersColors[i][0] = green;
oldClusterCentersColors[i][0] = blue;
}
return oldClusterCentersColors;
}
/**
*
* @param clusterDisValues
* @return
*/
private double getCloserCluster(double[] clusterDisValues)
{
double min = clusterDisValues[0];
int clusterIndex = 0;
for(int i=0; i<clusterDisValues.length; i++)
{
if(min > clusterDisValues[i])
{
min = clusterDisValues[i];
clusterIndex = i;
}
}
return clusterIndex;
}
/**
*
* @param p
* @param c
* @return distance value
*/
private double calculateEuclideanDistance(ClusterPoint p, ClusterCenter c)
{
int pr = p.pixelColor.red;
int pg = p.pixelColor.green;
int pb = p.pixelColor.blue;
int cr = c.color.red;
int cg = c.color.green;
int cb = c.color.blue;
return Math.sqrt(Math.pow((pr - cr), 2.0) + Math.pow((pg - cg), 2.0) + Math.pow((pb - cb), 2.0));
}
複製程式碼
在 Android 中使用該演算法來提取主色:
完整的演算法實現可以在:github.com/imageproces… 找到,它是一個典型的 KMeans 演算法。
我們的演算法中,K預設值是5,當然也可以自己指定。
以上演算法目前在 demo 上耗時蠻久,不過可以有優化空間。例如,可以使用 RxJava 在 computation 執行緒中做複雜的計算操作然後切換回ui執行緒。亦或者可以使用類似 Kotlin 的 Coroutines 來做複雜的計算操作然後切換回ui執行緒。
總結
提取影象中的主色,還有其他演算法例如八叉樹等,在 Android 中也可以使用 Palette 的 API來實現。
cv4j 是gloomyfish和我一起開發的影象處理庫,純java實現,我們已經分離了一個Android版本和一個Java版本。
如果您想看該系列先前的文章可以訪問下面的文集: www.jianshu.com/nb/10401400
最後提醒一句,作為程式設計師,還是要多健身。
Java與Android技術棧:每週更新推送原創技術文章,歡迎掃描下方的公眾號二維碼並關注,期待與您的共同成長和進步。