数字图像处理在前端的应用探索

by 夏枯


场景介绍

现代所有电商企业都很重视的一个问题是如何定点了解用户的需求,为他们进行个性化推荐商品。对于淘宝来说,可以根据一位女性在淘宝上一系列缩略图中点开哪些大图,然后分析这些大图片特征来判断这位女性购物的倾向。业界现有的方案都是把所有的图片处理过程交由服务器端来做。这种方案的缺点在于其对服务器端资源消耗非常巨大,并且在服务器和浏览器端反覆传输图片非常浪费流量。我在这篇文章中,提出了一个不同的方案:在客户端(即浏览器端)完成一部分图片处理运算得出数据量更小的特征矩阵,然后把这些特征矩阵传送给服务器进行分类处理,从而充分利用用户的部分计算资源,以减少服务器集群的运算负担和传输负担,使整体效率更高。

应用架构

我用两张图简单更清楚地来描述一下原有解决方案和更改之后的解决方案以及它们的优缺点。

业界原有架构

抓取用户购物倾向的场景与微软前一段时间的How-old应用的现有解决方案相同:用户上传或点击加载照片,服务器检测图像数据,提取特征,然后放入服务器的分类器测出人的年龄和性别等。都是由浏览器端完成交互任务,由服务器来完成分析图像,归类图像的任务,即把所有复杂事务交给服务器端解决。
实现架构图。传统上来讲,前端(浏览器端)只是用来实现用户交互以及信息表现的一个工具。所以在这个应用里,前端(浏览器端)只做了以下两件事:

  • 提供了一个用户提交自定义图片的接口。
  • 提供了分析结果的表现接口。

运算负担

这样会将所有运算负担都放在服务器上,服务器需要完成两部分任务:

  • 根据浏览器端传过来的图片数据提取出特征矩阵。
  • 把特征矩阵放入分类器中进行分类,得到最后的年龄、性别结果以及置信概率。

特征矩阵:特征矩阵这名字听着高端,但是这个概念其实是非常容易理解的。每幅图像之所以不同,是因为每个像素点的像素RGB值不同。那么两个相似的图像,理所当然,每个像素RGB值相似。而特征矩阵就是对这些像素RGB值进行处理以后得到的矩阵,这样的话这个矩阵就可以近似代表了一个图像的特征,相似的图像一般特征矩阵也相似,不相似的图像特征矩阵也不相似。怎么处理呢?举一个人脸检测常用的简单方法示例:当前有一个人脸图像集合。通过观察可以发现,眼睛的颜色要比两颊的深。因此,用于人脸检测的哈尔特征是分别放置在眼睛和脸颊的两个相邻矩形。然后计算这些矩形内部像素值的差值。

分类器:这里可以简单地认为分类器是一个拥有大量已知数据对(图片数据——年龄和性别数据等)的黑箱。可以自动把输入的图片对应出来年龄、性别和置信概率。

传输负担

这里有一个必要的传送负担,就是把图片的传送给服务器端。图片若是很大,那么就会占用比较大的带宽资源,特别是在移动端上会消耗大量的流量。

那么我们理所当然可以想到,把特征提取过程放在前端来做,这样只需要向服务器传送一个特征向量就可以了,但是这里有一个可能存在的问题,在人脸检测这种复杂的场景中,提取的特征矩阵有可能和图像一样大,或者比图像还大,那么这个传输负担就没有办法减小了。

更改后架构

根据更改后的架构,服务器端只需要根据特征向量进行模式识别(即分类),这样做的优缺点如下:
优点:

  • 避免了传输负担。
  • 减小了服务器端运算负担。

缺点:

  • 占用移动端内存以及计算资源,并且这个资源占用量随着图片的增大而增大。由于js的执行效率不高,所以这个资源占有量有多大还有待实测。

应用于场景——在前端提取人脸特征

那么这个架构模型是否真的可以实用呢,下面我通过一个稍微简化的模型来具体实现这样的架构(这个实现实用了Canvas,如果对Canvas不熟悉,请拉到最下方附件1先了解下)。

简化模型

这里为了简化我们的模型,我们假设:

  • 一副图像的灰度均值为我们所需要的特征向量
  • 把分类器简化为:灰度均值大于160的为类型A,灰度均值小于160的为类型B

现有架构

那么我们现在就可以把整个服务的架构具体实现为:

注:由于这个分类器非常简单,因此不需要服务器集群来进行分类。

实验结果

实验室网址


在这个过程中,平均灰度值是由前端JS运算出来的,等价与人脸识别场景中的特征矩阵提取过程。而分类是由服务器端的一个php文件来实现的。
在这个简单的例子中,我们发现了对于运算负担和传输负担的减小是非常巨大的。

  • 计算负担:平均灰度值的运算被分摊到了用户计算机上;
  • 传输负担:传输原本需要226KB(此张图片的大小),现在只需要几百个字节(只需要一个几乎为空的http请求)。

进阶问题

人脸检测的这个场景实现背后其实是极其复杂的,它不单单是对1.人脸的特征提取,还包括了2.对人脸这个目标的检测。因为用户一般不会选择只有人脸的图像

一般用户会倾向于检测这样的图像


这样的图形不仅有单纯的人脸,而且还有复杂的背景。所以在现实场景中第二个复杂的任务就是首先对目标进行检测提取。
而对目标检测提取这一个过程,虽然理论上是可以用前端代码来实现的,但是这个运算量将极其巨大,时间和空间复杂度都极其高。所以这一个过程在现阶段前端运算效率和浏览器性能下是无法实现的。

总结

这篇文章主要探讨了一下在HTML5Canvas出现以后对传统图片处理应用架构的优化可能性思考。

  1. 首先,阐述了现有图片处理应用架构和构思了可能的优化方案;
  2. 之后,通过一个高斯模糊的Canvas应用向大家简单介绍了Canvas的用法;
  3. 其次带入具体场景,用简化的模型对我们优化的架构进行具体实验

结论:虽然该技术在复杂的图片处理及检测分类中计算力还略显不足。但是通过分担后端运算的方式来优化架构,减小服务器的压力还是很有前景的。从集中式图片处理运算中分出一部分简单运算给客户端,从而实现一个简略的“分布式”是一个值得的尝试。

前端技术支持(附件1)

多年以来,前端所承载的任务一直都是布局以及界面交互,对于图像处理这个领域并未有过多少涉及。这是因为针对前端图像处理的技术基础直到HTML5标准的出现才真正意义上建立起来。下面简单介绍下Canvas,HTML5中一个能够为JS提供像素级数据操作的元素。有了这个技术基础,在前端提取出图像的特征向量就有了实现的根基。

我将利用Canvas为基础的纯js实现图像的高斯模糊的例子让大家对Canvas的使用方法有个大致的了解。

利用Canvas实现高斯模糊

主要思路

  1. 上传图像
  2. 渲染到Canvas画布上
  3. 从Canvas画布拿到图像的像素矩阵A
  4. 对像素矩阵A进行高斯模糊处理得到像素矩阵B
  5. 把像素矩阵B重新绘制在Canvas画布上

运行结果

实验室地址

  • 图片大小:512*512
  • 图片格式:bmp
  • 高斯模糊半径:20

代码实现细节

注:高斯模糊算法细节不建议在这里详细研究,这个例子主要目的是让大家了解怎么使用Canvas来进行图像处理应用。

index.html文件

<!DOCTYPE html>
<html>
<head lang="en">
    <meta charset="UTF-8">
    <title>Image Process -- Gaussian Blur</title>
    <script src="http://g.alicdn.com/dt/libs/jquery/1.10.2/jquery.js"></script>
    <script src="gaussianBlur.js"></script>
</head>
<body>
<form>
    <input type="file" id="imgFile"/>
</form>
<img id="imgSource">
<canvas id="canvas"></canvas>
<hr/>
<p>高斯模糊过程花费<span id="time"></span>ms</p>
</body>
</html>

gaussianBlur.js文件

window.onload = function(){
    var input = document.getElementById('imgFile');

    input.onchange = function(){
        var file = input.files[0];
        var fr = new FileReader();
        fr.onload = function () {
            var img = document.getElementById("imgSource"),// 获取到图像元素节点
                canvas = document.getElementById('canvas');// 获取到canvas元素结点

            img.src = fr.result;
            // 把canvas元素的长宽设置为图片的长宽大小
            canvas.width = img.width;
            canvas.height = img.height;

            var context = canvas.getContext("2d");// 获取到canvas元素的二维操作方法集
            context.drawImage(img, 0, 0);// 将原始图像画在canvas画布上,第二第三个参数表示在左上角的偏移坐标是(0,0)
            var canvasData = context.getImageData(0, 0, canvas.width, canvas.height);// 从canvas画布上获取到原始图像的每个像素点数据,示例图像为512*512,因此获得到一个1048576(512 * 512 * 4)大小的数组,需要再乘4是因为每个像素点都有四个8位二进制数分别表示rgba(红色通道、绿色通道、蓝色通道、透明度)。拥有像素级数据以后,此副图像已经可以任由我们宰割了!

            // 开始
            var startTime = +new Date();

            var tempData = gaussBlur1(canvasData, 20);// 调用函数进行运算
            context.putImageData(tempData,0,0);// 把计算后的数据再放进canvas中显示出来即可

            var endTime = +new Date();
            console.log(" 一共经历时间:" + (endTime - startTime) + "ms");
            $('#time').html((endTime - startTime));
        };
        fr.readAsDataURL(file);
    }

};

// @handleEdge用与处理高斯模糊过程中边界处理问题
function handleEdge(i, x, w) {
    ...
    // 这部分代码省略,是一些数学运算
    ...
    return m;
}

// @gaussBlur高斯模糊
// 参数:
//  imgData: 处理的图片数据数组
//  radius: 高斯模糊半径
//  sigma: 标准差(一般情况下缺省即可)
// 关于高斯模糊算法的细节问题,不在这里展开了。详细请看下面的一个链接教程。
function gaussBlur1(imgData,radius, sigma) {
    ...
    // 这部分代码省略,是一些数学运算
    ...
    return imgData;
}

高斯模糊算法细节

如果你想看更为详细的高斯模糊算法细节,可以在这个链接找到。高斯模糊算法细节

性能测试

运算时间复杂度:O(2xy(2r))

  • x为图像的宽度
  • y为图像的长度
  • r为模糊半径
浏览器 图像大小 模糊半径 运行时间
Chrome@43.0 512*512 20 150ms

细节实现(附件2)

index.html文件

<!DOCTYPE html>
<html>
<head lang="en">
    <meta charset="UTF-8">
    <title>Image Process -- Gaussian Blur</title>
    <script src="http://g.alicdn.com/dt/libs/jquery/1.10.2/jquery.js"></script>
    <script src="grayAverage.js"></script>
</head>
<body>
    <form name="upload">
        <input type="file" id="upload_image" />
    </form>
    <img id="imgSource" style="display:none">
    <canvas id="canvas"></canvas>
    <hr/>
    <p>这张图片的平均灰度值为:<span id="grayVal"></span></p>
    <p>这张图片属于<span id="grayClass"></span></p>
</body>
</html>

grayAverage.js文件

window.onload = function(){
    /**
     * upload image
     */
    var input = document.getElementById('upload_image');

    input.onchange = function () {
        var file = input.files[0];
        var fr = new FileReader();
        fr.onload = function () {
            var img = document.getElementById("imgSource"),
                canvas = document.getElementById('canvas');

            img.src = fr.result;
            canvas.width = img.width;
            canvas.height = img.height;

            var context = canvas.getContext("2d");
            context.drawImage(img, 0, 0);

            var canvasData = context.getImageData(0, 0, canvas.width, canvas.height);

            // 开始
            var startTime = +new Date();

            var grayVal = grayAverage(canvasData);

            console.log(grayVal);
            $.ajax({
                type: 'post',
                dataType: 'json',
                url: 'http://1.imgprocess.sinaapp.com/index.php?id=1'
            }).success(function (data) {
                alert(data);
            });

            var endTime = +new Date();
            console.log(" 一共经历时间:" + (endTime - startTime) + "ms");
        };
        fr.readAsDataURL(file);
    };

};

// @garyAverage求图像的平均灰度值
function grayAverage (imgData) {
    var pixes = imgData.data,
        width = imgData.width,
        height = imgData.height;

    var length = width * height,
        sum = 0;
    for (var i = 0; i < length; i++) {

        sum += pixes[i] + pixes[i + 1] + pixes[i + 2];
    }

    return (sum / (3 * width * height));
}

index.php文件

<?php

$grayVal = $_GET['grayVal']; // 取到前端计算出来的平均灰度值
// 分类器:根据前端传过来的特征向量(即平均灰度值)来进行分类,然后把分类结果返回前端显示给用户
if ($grayVal > 160) {
    echo 'A';
} else {
    echo 'B';
}

?>