一. 引言
为了进一步提升我司年会集体舞蹈期间队形变换的准确度与效率,(在理想情况下)让每位小伙伴都能够精准而又快速地移动到他们的目的地,本项目立项了。
在本文中,会选用宇宙行Logo上作为一个测试的例子。而为啥用宇宙行Logo呢,是因为它还是比较有代表性的: 首先,它的原图像是连续的,不是由点阵组成的,可以用来检验本方法生成点阵图的效果;第二是它在变成点阵以后辨识度仍然很高,降低采用目测方法衡量算法效果的难度。
以下是最终生成的效果图:
二. 技术实现
2.1 使用K-Means算法找到原始图片中的站位点
Obviously,为了让我们在设想中的队形图片能够转化为能够代表小伙伴们站位的点阵,第一步要做的就是把原本连续的图像离散化。用人话说,就是要从原图中提取出特定数量(k)的特殊位点,并让它们组合起来以后的新图像能够最大限度地代表被转化的原图。
下图以宇宙行的Logo生成的范例:
可以很清楚地看到,在上图中,通过应用本文的方法,我们成功地在 k=100 的条件下生成了一个点阵形式的目标图像。在其中,每一个点,都代表在我们的集体舞蹈中的一位成员。
相应的,在这个过程中生成了一个由每个点的坐标组成的数组arr=[{x,y},...],以供我们在接下来的步骤使用。
Why K-Means?
作为最广为人知的聚类算法,K-Means的简要步骤为
- 随机找 k 个点作为质心(种子);
- 计算其他点到这 k 个种子的距离,选择最近的那个作为该点的类别;
- 更新各类的质心,迭代到质心的不变为止。
联想到我们需要在各类图像上找到适合的 k个站位点,能否把目标等价于找到图像中的 k个质心(Centroid) ?
对图像中的全体点做基于(x,y)坐标的聚类后,得到的k个聚类中心是否就可以看做最能代表这幅图像的k个点?
从实践上,答案应该是肯定的。
Talk is cheap, 下面是本方法的详细描述 (代码非完整版本,如画图等部分代码已省略,请酌情脑补)。
2.1.1 读取原图像并获取Matrix
由于我这边的代码实现是基于JavaScript的,所以第一步我将原图load到了Canvas上并获取到了imgData:
let img = new Image();
img.src = src;
img.onload = function () {
let context = Canvas.getContext("2d");
context.drawImage(img, 0, 0, width, height);
imgData = context.getImageData(0, 0, _this.conf.width, _this.conf.height);
getCentroids(imgData);
}
2.1.2 使用K-Means获取站位点
要使用K-Means的话,虽然自己写一个也不算太复杂,但是我还是为了提高效率(懒)使用了开源的库, 执行:
npm install ml-kmeans
理论上,好像把聚类应用在我们的imgData Matrix上,一切就好像迎刃而解了喔.?
findCentroids() {
let j, x, y, _this = this, imgData = _this.imgData,kNum = _this.conf.kNum;
let points = [];
let step = 3;
for (x = 0; x < imgData.width; x += step) {
for (y = 0; y < imgData.height; y += step) {
j = (y * imgData.width + x) * 4;
if (imgData.data[j + 3] < 100) continue;
points.push([x,y]);
}
}
let ans = kmeans(points,kNum,{ initialization: "kmeans++" });
let centroids = ans.centroids;
let ps = [];
for (let centroid of centroids){
x = Math.round(centroid.centroid[0]);
y = Math.round(centroid.centroid[1]);
j = (y * imgData.width + x) * 4;
let point = {
x: x,
y: y,
color: 'rgba('+imgData.data[j]+','+imgData.data[j+1]+','+imgData.data[j+2]+',' + (imgData.data[j + 3] / 255) + ')'
};
ps.push(point);
}
return ps;
}
我们用一个简单的点阵图(同时也可视作团体舞站位的初始队列)来做一个实验。
运用上面的代码,我们用同样的颜色标注生成的Centroids后,得到的图像如下:
啊咧?情况不对劲喔?为什么会这样子呢?
2.1.3 优化质心获取过程
让我们回到K-Means的定义上来,它的第一步就是在随机地找到k个种子——亦即在第一次迭代开始前选取的质心可能会影响我们最终的结果。
打开控制台,在log出来的聚类数组里面,我们可以很轻易地看到,有几个聚类的size是非常明显地小于其余聚类的。可以很肯定地说,这些问题聚类的生成都是由初始点选取过于靠近所导致的,它们初始的坐标都在同一个彩色圆圈里面,所以哪怕K-Means一直迭代到收敛,它们还是会挤在一起。
为了解决这个问题,这里尝试性地实现了下面两个功能:
- 合并质心最靠近的小聚类
- 分裂size明显大于其余聚类的簇
let appropriate = [];
for (let size of Object.keys(size_obj)){
if(size < (0.75*most_common[0])){
smaller = smaller.concat(size_obj[size]);
}else if(size>(1.25*most_common[0])){
larger = larger.concat(size_obj[size]);
}else{
middle = middle.concat(size_obj[size]);
}
}
for(let id of middle){
appropriate.push(centroids[id].centroid)
}
}
mergeSmallerClusters(smaller,centroids){
let points = [],centers = [];
for (let i of smaller){
points.push(centroids[i].centroid);
}
while (points.length){
let p = points.pop();
let distance = 9999999999999, loc = 0;
for(let i =0;i<points.length;i++){
let d = this.calDistance(p,points[i]);
if(d<distance){
distance = d;
loc = i;
}
}
let p2 = points[loc];
try{
centers.push([(p[0]+p2[0])/2,(p[1]+p2[1])/2]);
points.splice(loc,1);
}catch (e) {
centers.push(p);
}
}
return centers;
}
splitLargerClusters(larger,centroids,clusterIds,points){
let clusters = {};
let centers = [];
for(let k of larger){
clusters[k] = [];
}
for(let i= 0;i<clusterIds.length;i++){
let id = clusterIds[i];
if(larger.indexOf(id)>-1){
clusters[id].push(points[i])
}
}
for(let id of Object.keys(clusters)){
let centroid = centroids[id].centroid;
let distance = -1, loc = 0 , cluster= clusters[id];
for(let i =0;i<cluster.length;i++){
let d = this.calDistance(centroid,cluster[i]);
if(d>distance){
distance = d;
loc = i;
}
}
centers.push(centroid);
centers.push(cluster[loc]);
}
return centers;
}
然后将它们与正常聚类的质心数组合并后重新洗牌,作为新一轮K-Means的初始种子。
let s = this.mergeSmallerClusters(smaller,centroids);
let l = this.splitLargerClusters(larger,centroids,ans.clusters,points);
centers = [];
centers = centers.concat(appropriate);
centers = centers.concat(s);
centers = centers.concat(l);
shuffle(centers);
ans= kmeans(points,kNum,{ initialization: centers||"kmeans++" });
centroids = ans.centroids;
可以看到,我们这样迭代了三轮,初始队列.png里面的站位点就已经收敛在正确的位置上面了。
而宇宙行Logo达到基本收敛(可以看到。99%质心都是Appropriate)用了27次迭代(大力出奇迹)。
2.2 找到前后队形的质心之间的对应关系
2.2.1 普通思路下的尝试
还是继续上面的例子,此时两幅图的K个质心已经被我们找到了。现在剩下的就只有把它们两两之间配对起来了。
我首先尝试的是按照它们的坐标(x,y)去计算出一个欧氏距离矩阵(Distance Matrix)。然后根据距离矩阵,每一步无放回地找到当前距离最短的配对。
欧氏距离 (Euclidean Distance) 公式:
理论上来说,这个方法好像没什么问题,因为每一次找出来的都是当前的最优解...然而,把结果log出来一看,就会发现:事情好像有点不对劲啊...
如上图,在一百个配对里面(k * k的矩阵),前面的点距离(第三个值)和后面的也相差太大了吧...? 换到真实的应用场景中就是有的小伙伴在每次队形变换时候需要移动的距离是其他人的几十上百倍...虽然这个方法的确每一步都是pick的当前最短的距离,可最终效果不怎么妙呀,把这个当做最终计划真的不会被揍吗?
可视化之后的样子:
如图所见,有个别几个点相对于其它点移动的距离是特别特别的长...
那么问题来了,有没有一个应对这个问题的解决方案呢?
DengDengDengDeng~
2.2.2 应用匈牙利算法(Hungarian Algorithm)
匈牙利算法(Hungarian Algorithm)是一种组合优化算法(combinatorial optimization algorithm),用于求解指派问题(assignment problem),算法时间复杂度为O(n3)。Harold Kuhn发表于1955年,由于该算法基于两位匈牙利数学家的早期研究成果,所以被称作“匈牙利算法”。 —— 维基百科
它的核心思想是: 一件大的事物若除去一件小的事物,对这件事没有多大影响。
实际上,匈牙利算法是一个通过不断调整,解决匹配过程中的冲突,从而最终获得一个最优解的过程。
本着不能重复造轮子的思想,实现代码如下:
npm install munkres-js
findPairsViaHungarian(m){
// m -> 一个以i为行,j为列的距离矩阵. i.e. m[i][j] = Distance(i,j)
let res = munkres(m)
let pairs = [];
for(let p of res){
pairs.push([p[0],p[1],m[p[0]][p[1]]]);
}
return pairs;
}
嗯,最终效果看起来不错,起码比之前好得多。
三. 结论
通过对K-Means聚类和匈牙利算法的应用,在大力出奇迹的核心思想(不在意中间过程的开销,只把最终效果作为目的)指导下,可以认为本方法是work的。
但还有一点不足就是有时候第一步生成的站位点因为算法中初始选点的随机性,还是有一点不理想。所以为此专门写了一个页面来不断筛选相对优秀的结果。只要尝试足够多,坏结果就追不上我~
而且这里的结果也只是考虑了不同点的坐标的理想情况。在真实世界的实际应用中,可能每位小伙伴的高矮胖瘦等等也会作为我们舞蹈队形编排的考量,因此本文方法仅供参考。
完结,撒花。