数字图像处理-前端实现
源码地址:github.com/weiruifeng/…
数字图像处理(Digital Image Processing)是指用计算机进行的处理。说起数字图像处理大家都会想到C++有很多库和算法,MATLAB的方便,但自从有了canvas,JavaScript可以对图像进行像素级的操作,甚至还可以直接处理图像的二进制原始数据。
获取数据和保存图片
获取数据
利用 fileReader 和 canvas 配合获取图像
<canvas id="myCanvas">抱歉,您的浏览器还不支持canvas。</canvas> <input type="file" id="myFile" /> 复制代码
当用户选择图片时
file.onchange = function(event) {
const selectedFile = event.target.files[0]; const reader = new FileReader(); reader.onload = putImage2Canvas; reader.readAsDataURL(selectedFile); } function putImage2Canvas(event) { const img = new Image(); img.src = event.target.result; img.onload = function(){ myCanvas.width = img.width; myCanvas.height = img.height; var context = myCanvas.getContext('2d'); context.drawImage(img, 0, 0); const imgdata = context.getImageData(0, 0, img.width, img.height); // 处理imgdata } } 复制代码
其中,ImageData对象中存储着canvas对象真实的像素数据,包含3个只读属性: **width:**图片宽度,单位是像素 **height:**图片高度,单位是像素 **data:**Uint8ClampedArray类型的一维数组,包含着RGBA格式的整型数据,范围在0至255之间(包括255) **关系:**Uint8ClampedArray的length = 4 * width * height 数字图像处理应用的数据便是ImageData.data的数据
保存图片
HTMLCanvasElement提供一个 toDataURL 方法,此方法在保存图片的时候非常有用。它返回一个包含被类型参数规定的图像表现格式的数据链接。 数据链接的格式为
data:[<mediatype>][;base64],<data>
复制代码
mediatype 是个 MIME 类型的字符串,例如 "image/jpeg" 表示 JPEG 图像文件。如果被省略,则默认值为 text/plain;charset=US-ASCII
通过HTML中a标签的download属性便可进行下载
downloadFile(fileName, url) {
const aLink = document.createElement('a');
aLink.download = fileName;
aLink.href = url;
aLink.click();
}
// 下载图片
downloadFile(fileName, myCanvas.toDataURL());
复制代码
点运算
点运算(Point Operation)能够让用户改变图像数据占据的灰度范围,可以看作是 从像素到像素的复制操作。 如果输入图像为,输出图像为),则点运算可表示为:
其中放 被称为灰度变换函数,它描述了输入灰度值和输出灰度值之间的转换关系。一旦灰度变换函数确定,该点运算就完全被确定下来了。
点运算一般操作有灰度均衡化、线性变换、阈值变换、窗口变换、灰度拉伸等。
灰度直方图
概述
灰度直方图用于统计一幅灰度图像的像素点(0~255)的个数或者比例,从图形上来说,灰度直方图就是一个二维图,横坐标表示灰度值(灰度级别),纵坐标表示具有各个灰度值或者灰度级别的像素在图像中出现的次数或者概率。
代码
/**
* 统计数据(针对灰度图像)
* @param data 原始数据
* @param strength 分份
* @returns {Array}
*/
function statistics(data, strength = 1) { const statistArr = []; for (let i = 0, len = data.length; i < len; i += 4) { const key = Math.round(data[i] / strength); statistArr[key] = statistArr[key] || 0; statistArr[key]++; } return statistArr; } 复制代码
通过直方图可以看出一副图像的像素分布情况。
直方图均衡化
概述
我们都知道,如果图像的对比度越大,图片就会清晰醒目,对比度小,图像就会显得灰蒙蒙的。所谓对比度,在灰色图像里黑与白的比值,也就是从黑到白的渐变层次。比值越大,从黑到白的渐变层次就越多,从而色彩表现越丰富。
直方图均衡化是图像处理领域中利用图像直方图对对比度进行调整的方法。目的是使得图像的每个像素值在图像上都一样多,会使得背景和前景都太亮或者太暗的图像变得更加清晰。
理论基础
考虑一个离散的灰度图像{x},让表示灰度出现的次数,这样图像中灰度为 的像素的出现概率是:
是图像中所有的灰度数(通常为256),是图像中所有的像素数, 实际上是像素值为 的图像的直方图,归一化到 。
把对应于的累积分布函数,定义为:
是图像的累计归一化直方图。
我们创建一个形式为的转换,对于原始图像中的每个值它就产生一个,这样的累计概率函数就可以在所有值范围内进行线性化,转换公式定义为:
对于常数,图像处理中是256。
将带入(3)得:
将(3)(4)计算得:
公式5便是原像素与变换后的像素点的关系。
代码
/**
* 该函数用来对图像进行直方图均衡
* @param data
*/
function inteEqualize(data) { // 灰度映射表 const bMap = new Array(256); // 灰度映射表 const lCount = new Array(256); for (let i = 0; i < 256; i++) { // 清零 lCount[i] = 0; } // 计算各个灰度值的计数(只针对灰度图像) for (let i = 0, len = data.length; i < len; i += 4) { lCount[data[i]]++; } // 计算灰度映射表 for (let i = 0; i < 256; i++) { let lTemp = 0; for (let j = 0; j < i; j++) { lTemp += lCount[j]; } // 计算对应的新灰度值 bMap[i] = Math.round(lTemp * 255 / (data.length / 4)); } // 赋值 for (let i = 0, len = data.length; i < len; i += 4) { data[i] = bMap[data[i]]; data[i + 1] = bMap[data[i + 1]]; data[i + 2] = bMap[data[i + 2]]; } } 复制代码
灰度的线性变换
理论基础
灰度的线性变换就是将图像中所有的点的灰度按照线性灰度变换函数进行变换,该线性灰度变换函数是一个一维线性函数:
灰度变换方程为:
式中参数为线性函数的斜率, 为线性函数在轴的截距,表示输入图像的灰度,表示输出图像的灰度。
灰度图像有以下规律:
- 当时,输出图像当对比度将增大;当f时,输出图像当对比度将减小;
- 当且不等于0时,操作仅使所有像素当灰度值上移或下移,其效果是使整个图像更暗或更亮;
- 如果,暗区域将变亮,亮区域将变暗,点运算完成了图像求补运算;
- 如果时,输出图像和输入图像相同;
- 如果时,输出图像的灰度正好反转;
代码
/**
* 该函数用来对图像灰度
* @param data
* @param fA 线性变换的斜率
* @param fB 线性变换的截距
*/
function linerTrans(data, fA, fB) { for (let i = 0, len = data.length; i < len; i += 4) { // 针对RGB三个进行转换 for (let j = 0; j < 3; j++) { let fTemp = fA * data[i + j] + fB; if (fTemp > 255) { fTemp = 255; } else if (fTemp < 0) { fTemp = 0; } else { fTemp = Math.round(fTemp); } data[i + j] = fTemp; } } } 复制代码
灰度的阈值变换
理论基础
灰度的阈值变换可以将一幅灰度图像转换成黑白二值图像。由用户提前设置一个阈值,如果图像中某像素的灰度值小于该阈值,则将该像素的灰度值设置为0,否则设置为255 。
代码
/**
* 该函数用来对图像进行阈值变换
* @param data
* @param bthre 阈值
*/
function thresholdTrans(data, bthre) { for (let i = 0, len = data.length; i < len; i += 4) { // 针对RGB三个进行转换 for (let j = 0; j < 3; j++) { if (data[i + j] < bthre) { data[i + j] = 0; } else { data[i + j] = 255; } } } } 复制代码
灰度的窗口变换
理论基础
灰度的窗口变换限定一个窗口范围,该窗口中的灰度值保持不变;小于该窗口下限的灰度值直接设置为0;大于该窗口上限的灰度值直接设置为255 。
灰度窗口变换的变换函数表达式如下:
式中,表示窗口的下限,表示窗口的上限。
灰度的窗口变换可以用来去除背景是浅色,物体是深色的图片背景。
代码
/**
* 该函数用来对图像进行窗口变换。只有在窗口范围内对灰度保持不变
* @param data
* @param bLow 下限
* @param bUp 上限
*/
function windowTrans(data, bLow, bUp) { for (let i = 0, len = data.length; i < len; i += 4) { // 针对RGB三个进行转换 for (let j = 0; j < 3; j++) { if (data[i + j] < bLow) { data[i + j] = 0; } else if (data[i + j] > bUp) { data[i + j] = 255; } } } } 复制代码
灰度拉伸变换函数
灰度拉伸与灰度线性变换有点类似,不同之处在于灰度拉伸不是完全的线性变换,而是分段进行线性变换。
函数表达式如下:
灰度变换函数如图:
代码
/**
* 该函数用来对图像进行灰度拉伸
* 该函数的运算结果是将原图在x1和x2之间的灰度拉伸到y1和y2之间
* @param data
* @param bx1 灰度拉伸第一个点的X坐标
* @param by1 灰度拉伸第一个点的Y坐标
* @param bx2 灰度拉伸第二个点的X坐标
* @param by2 灰度拉伸第二个点的Y坐标
*/
function grayStretch(data, bx1, by1, bx2, by2) { // 灰度映射表 const bMap = new Array(256); for (let i = 0; i < bx1; i++) { // 防止分母为0 if (bx1 > 0) { // 线性变换 bMap[i] = Math.round(by1 * i / bx1); } else { bMap[i] = 0; } } for (let i = bx1; i < bx2; i++) { // 判断bx1是否等于bx2(防止分母为0) if (bx2 !== bx1) { bMap[i] = Math.round((by2 - by1) * (i - bx1) / (bx2 - bx1)); } else { // 直接赋值为by1 bMap[i] = by1; } } for (let i = bx2; i < 256; i++) { // 判断bx2是否等于256(防止分母为0) if (bx2 !== 255) { // 线性变换 bMap[i] = by2 + Math.round((255 - by2) * (i - bx2) / (255 - bx2)); } else { // 直接赋值为255 bMap[i] = 255; } } for (let i = 0, len = data.length; i < len; i += 4) { data[i] = bMap[data[i]]; data[i + 1] = bMap[data[i + 1]]; data[i + 2] = bMap[data[i + 2]]; } } 复制代码
图像的几何变换
HTML5中的canvas有完善的图像处理接口,在对图像进行几何变换时,我们可以直接使用canvas接口即可,下面简单列举几个几何变换的接口:
-
图像平移
context.translate(x, y); 复制代码
-
图像缩放
context.scale(scalewidth, scaleheight); 复制代码
-
镜像变换
canvas中并没有为镜像变换专门提供方法,但不必紧张,至此我们依然尚未接触到像素级的操作。在上一节中介绍了图像缩放的相关内容,其中讲到scalewidth和scaleheight的绝对值大于1时为放大,小于1时为缩小,但并没有提到其正负。
content.translate(myCanvas.width/2, myCanvas.height/2); content.scale(-1, 1); content.translate(myCanvas.width/2, myCanvas.height/2); content.drawImage(img, 10, 10); 复制代码
-
图像旋转
context.rotate(angle); 复制代码
-
图像转置
canvas没有为图像转置专门提供方法,但我们可以利用旋转和镜像组合的方法实现图像转置的目的。图像的转置可以分解为水平翻转后再顺时针旋转90°,或是垂直翻转后再逆时针旋转90°。下面我们利用顺时针旋转90°后再水平翻转实现图像转置的操作
context.translate(myCanvas.width/2, myCanvas.height/2); context.scale(-1, 1); context.rotate(90*Math.PI/180); context.translate(-myCanvas.width/2, -myCanvas.height/2); context.drawImage(img, 10, 10); 复制代码
图像增强
图像增强是为了将图像中感兴趣的部分有选择的突出,而衰减其次要信息,从而提高图像的可读性。常见的目的有突出目标的轮廓,衰减各种噪声等。
图像增强技术通常有两类方法:空间域法和频率域法。空间域法主要在空间域中对图像像素灰度值直接进行运算处理。本章只介绍空间域法。
空间域法等图像增强技术可以用下式来描述:
其中是处理前的图像,表示处理后的图像,为空间运算函数。
图像的灰度修正
图像的灰度修正是根据图像不同的降质现象而采用不同的修正方法。常见的方法参考点运算里面的方法。
模版操作
模版是一个矩阵方块,模版操作可看作是加权求和的过程,使用到的图像区域中的每个像素分别于矩阵方块中的每个元素对应相乘,所有乘积之和作为区域中心像素的新值,是数字图像处理中经常用到的一种运算方式,图像的平滑、锐化、细化以及边缘检测都要用到模版操作。
例如:有一种常见的平滑算法是将原图中一个像素的灰度值和它周围临近八个像素的灰度值相加,然后将求得的平均值(除以9)作为新图中该像素的灰度值。表示如下:
使用模版处理图像时,要注意边界问题,因为用模版在处理边界时会报错,常用的处理办法有:
- 忽略边界像素,即处理后的像素将丢掉这些像素。
- 保留原边界像素,即复制边界像素到处理后的图像。
常用模版
-
低通滤波器
-
高通滤波器
-
平移和差分边缘检测
-
匹配滤波边缘检测
-
边缘检测
-
梯度方向检测
代码
/**
* 模版操作
* @param data 数据
* @param lWidth 图像宽度
* @param lHeight 图像高度
* @param tempObj 模版数据
* @param tempObj.iTempW 模版宽度
* @param tempObj.iTempH 模版高度
* @param tempObj.iTempMX 模版中心元素X坐标
* @param tempObj.iTempMY 模版中心元素Y坐标
* @param tempObj.fpArray 模版数组
* @param tempObj.fCoef 模版系数
*/
function template(data, lWidth, lHeight, tempObj) { const { iTempW, iTempH, iTempMX, iTempMY, fpArray, fCoef } = tempObj; // 保存原始数据 const dataInit = []; for (let i = 0, len = data.length; i < len; i++) { dataInit[i] = data[i]; } // 行(除去边缘几行) for (let i = iTempMY; i < lHeight - iTempMY - 1; i++) { // 列(除去边缘几列) for (let j = iTempMX; j < lWidth - iTempMX - 1; j++) { const count = (i * lWidth + j) * 4; const fResult = [0, 0, 0]; for (let k = 0; k < iTempH; k++) { for (let l = 0; l < iTempW; l++) { const weight = fpArray[k * iTempW + l]; const y = i - iTempMY + k; const x = j - iTempMX + l; const key = (y * lWidth + x) * 4; // 保存像素值 for (let i = 0; i < 3; i++) { fResult[i] += dataInit[key + i] * weight; } } } for (let i = 0; i < 3; i++) { // 乘上系数 fResult[i] *= fCoef; // 取绝对值 fResult[i] = Math.abs(fResult[i]); fResult[i] = fResult[i] > 255 ? 255 : Math.ceil(fResult[i]); // 将修改后的值放回去 data[count + i] = fResult[i]; } } } } 复制代码
代码中处理边界使用的是保留原边界像素。
平滑和锐化
平滑的思想是通过一点和周围几个点的运算来去除突然变化的点,从而滤掉一定的噪声,但图像有一定程度的模糊,常用的模版是低通滤波器的模版。
锐化的目的是使模糊的图像变得更加清晰起来。图像的模糊实质就是图像受到平均或积分运算造成的,因此可以对图像进行逆运算如微分运算来使图像清晰话。从频谱角度来分析,图像模糊的实质是其高频分量被衰减,因而可以通过高通滤波操作来清晰图像。锐化处理也会将图片的噪声放大,因此,一般是先去除或减轻噪声后再进行锐化处理。
图像锐化一般有两种方法:微积分和高通滤波。高通滤波法可以参考高通滤波模版。微分锐化介绍一下拉普拉斯锐化。
梯度锐化
设图像为,定义 在点处的梯度矢量为:
梯度有两个重要的性质:
梯度的方向在函数 最大变化率方向上
梯度的幅度用表示,其值为:
由此式可得出这样的结论:梯度的数值就是在其最大变化率方向上的单位距离所增加的量。
对于离散的数字图像,上式可以改写成:
为了计算方便,也可以采用下面的近似计算公式:
这种梯度法又称为水平垂直差分法,还有一种是交叉地进行差分计算,称为罗伯特梯度法:
采用绝对差算法近似为:
由于在图像变化缓慢的地方梯度很小,所以图像会显得很暗,通常的做法是给一个阈值,如果小于该阈值,则保持原灰度值不变;如果大于或等于阈值,则赋值为:
基于水平垂直差分法的算法代码如下:
/**
* 该函数用来对图像进行梯度锐化
* @param data 数据
* @param lWidth 宽度
* @param lHeight 高度
* @param bThre 阈值
*/
function gradSharp(data, lWidth, lHeight, bThre) { // 保存原始数据 const dataInit = []; for (let i = 0, len = data.length; i < len; i++) { dataInit[i] = data[i]; } for (let i = 0; i < lHeight - 1; i++) { for (let j = 0; j < lWidth - 1; j++) { const lpSrc = (i * lWidth + j) * 4; const lpSrc1 = ((i + 1) * lWidth + j) * 4; const lpSrc2 = (i * lWidth + j + 1) * 4; for (let i = 0; i < 3; i++) { const bTemp = Math.abs(dataInit[lpSrc + i] - dataInit[lpSrc1 + i]) + Math.abs(dataInit[lpSrc + i] - dataInit[lpSrc2 + i]); if (bTemp >= 255) { data[lpSrc + i] = 255; // 判断是否大于阈值,对于小于情况,灰度值不变 } else if (bTemp >= bThre) { data[lpSrc + i] = bTemp; } } } } } 复制代码
拉普拉斯锐化
我们知道,一个函数的一阶微分描述了函数图像的增长或降低,二阶微分描述的则是图像变化的速度,如急剧增长或下降还是平缓的增长或下降。拉普拉斯运算也是偏导数运算的线性组合,而且是一种各向同性的线性运算。
设为拉普拉斯算子,则:
对于离散数字图像,其一阶偏导数为:
则其二阶偏导数为:
所以,拉普拉斯算子为:
对于扩散现象引起的图像模糊,可以用下式来进行锐化:
这里是与扩散效应有关的系数。该系数取值要合理,如果过大,图像轮廓边缘会产生过冲;反之如果过小,锐化效果就不明显。
如果令,则变换公式为:
这样变可以得到一个模版矩阵:
其实,我们通过常用的拉普拉斯锐化模版还有另外一种形式:
代码参考模版中的代码。
中值滤波
原理
中值滤波是一种非线性数字滤波器技术,一般采用一个含有奇数个点的滑动窗口,将窗口中个点灰度值的中值来代替定点(一般是窗口的中心点)的灰度值。对于奇数个元素,中值是指按大小排序后,中间的数值,对于偶数个元素,中值是指排序后中间两个灰度值的平均值。
中值滤波是图像处理中的一个常用步骤,它对于斑点噪声和椒盐噪声来说尤其有用。
代码
/**
* 中值滤波
* @param data 数据
* @param lWidth 图像宽度
* @param lHeight 图像高度
* @param filterObj 模版数据
* @param filterObj.iFilterW 模版宽度
* @param filterObj.iFilterH 模版高度
* @param filterObj.iFilterMX 模版中心元素X坐标
* @param filterObj.iFilterMY 模版中心元素Y坐标
*/
function medianFilter(data, lWidth, lHeight, filterObj) { const { iFilterW, iFilterH, iFilterMX, iFilterMY } = filterObj; // 保存原始数据 const dataInit = []; for (let i = 0, len = data.length; i < len; i++) { dataInit[i] = data[i]; } // 行(除去边缘几行) for (let i = iFilterMY; i < lHeight - iFilterH - iFilterMY - 1; i++) { for (let j = iFilterMX; j < lWidth - iFilterW - iFilterMX - 1; j++) { const count = (i * lWidth + j) * 4; const fResult = [[], [], []]; for (let k = 0; k < iFilterH; k++) { for (let l = 0; l < iFilterW; l++) { const y = i - iFilterMY + k; const x = j - iFilterMX + l; const key = (y * lWidth + x) * 4; // 保存像素值 for (let i = 0; i < 3; i++) { fResult[i].push(dataInit[key + i]); } } } // 将中值放回去 for (let w = 0; w < 3; w++) { data[count + w] = getMedianNum(fResult[w]); } } } } /** * 将数组排序后获取中间的值 * @param bArray * @returns {*|number} */ function getMedianNum(bArray) { const len = bArray.length; bArray.sort(); let bTemp = 0; // 计算中值 if ((len % 2) > 0) { bTemp = bArray[(len - 1) / 2]; } else { bTemp = (bArray[len / 2] + bArray[len / 2 - 1]) / 2; } return bTemp; } export { medianFilter }; 复制代码
图像形态学
形态学的理论基础是集合论。数学形态学提出了一套独特的变换和运算方法。下面我们来看看最基本的 几种数学形态学运算。
对一个给定的目标图像和一个结构元素,想象一下将在图像上移动。在每一个当前位置,只有三中可能的状态:
- 与均不为空
如图所示:
第一种情况说明与相关最大;第二种情况说明与不相关;而第三种情况说明与只是部分相关。
腐蚀和膨胀
原理
当满足条件1的点的全体构成结构元素与图像的最大相关点集,我们称这个点集为对的腐蚀,当满足条件1和2的点x的全体构成元素与图像的最大相关点集,我们称这个点集为对的膨胀。简单的说,腐蚀可以看作是将图像中每一个与结构元素全等的子集收缩为点,膨胀则是将中的每一个点扩大为。
腐蚀与膨胀的操作是用一个给定的模版对图像X进行集合运算,如图所示:
代码
代码为针对二值图像进行的腐蚀和膨胀算法。
/**
* 说明:
* 该函数用于对图像进行腐蚀运算。
* 结构元素为水平方向或垂直方向的三个点,中间点位于原点;
* 或者由用户自己定义3*3的结构元素。
* 要求目标图像为只有0和255两个灰度值的灰度图像
* @param data 图像数据
* @param lWidth 原图像宽度(像素数)
* @param lHeight 原图像高度(像素数)
* @param nMode 腐蚀方式,0表示水平方向,1表示垂直方向,2表示自定义结构元素
* @param structure 自定义的3*3结构元素
*/
function erosionDIB(data, lWidth, lHeight, nMode, structure) { // 保存原始数据 const dataInit = []; for (let i = 0, len = data.length; i < len; i++) { dataInit[i] = data[i]; } if (nMode === 0) { // 使用水平方向的结构元素进行腐蚀 for (let j = 0; j < lHeight; j++) { // 由于使用1*3的结构元素,为防止越界,所以不处理最左边和最右边的两列像素 for (let i = 1; i < lWidth - 1; i++) { const lpSrc = j * lWidth + i; for (let k = 0; k < 3; k++) { // 如果原图像中当前点自身或者左右如果有一个点不是黑色,则将目标图像中的当前点赋成白色 for (let n = 0; n < 3; n++) { const pixel = lpSrc + n - 1; data[lpSrc * 4 + k] = 0; if (dataInit[pixel * 4 + k] === 255) { data[lpSrc * 4 + k] = 255; break; } } } } } } else if (nMode === 1) { // 使用垂直方向的结构元素进行腐蚀 // 由于使用1*3的结构元素,为防止越界,所以不处理最上边和最下边的两列像素 for (let j = 1; j < lHeight - 1; j++) { for (let i = 0; i < lWidth; i++) { const lpSrc = j * lWidth + i; for (let k = 0; k < 3; k++) { // 如果原图像中当前点自身或者左右如果有一个点不是黑色,则将目标图像中的当前点赋成白色 for (let n = 0; n < 3; n++) { const pixel = (j + n - 1) * lWidth + i; data[lpSrc * 4 + k] = 0; if (dataInit[pixel * 4] === 255) { data[lpSrc * 4 + k] = 255; break; } } } } } } else { // 由于使用3*3的结构元素,为防止越界,所以不处理最左边和最右边的两列像素和最上边和最下边的两列元素 for (let j = 1; j < lHeight - 1; j++) { for (let i = 1; i < lWidth - 1; i++) { const lpSrc = j * lWidth + i; for (let k = 0; k < 3; k++) { data[lpSrc * 4 + k] = 0; // 如果原图像中对应结构元素中为黑色的那些点中有一个不是黑色,则将目标图像中的当前点赋成白色 for (let m = 0; m < 3; m++) { for (let n = 0; n < 3; n++) { if (structure[m][n] === -1) { continue; } const pixel = lpSrc + ((2 - m) - 1) * lWidth + (n - 1); if (dataInit[pixel * 4] === 255) { data[lpSrc * 4 + k] = 255; break; } } } } } } } } /** * 说明: * 该函数用于对图像进行膨胀运算。 * 结构元素为水平方向或垂直方向的三个点,中间点位于原点; * 或者由用户自己定义3*3的结构元素。 * 要求目标图像为只有0和255两个灰度值的灰度图像 * @param data 图像数据 * @param lWidth 原图像宽度(像素数) * @param lHeight 原图像高度(像素数) * @param nMode 腐蚀方式,0表示水平方向,1表示垂直方向,2表示自定义结构元素 * @param structure 自定义的3*3结构元素 */ function dilationDIB(data, lWidth, lHeight, nMode, structure) { // 保存原始数据 const dataInit = []; for (let i = 0, len = data.length; i < len; i++) { dataInit[i] = data[i]; } if (nMode === 0) { // 使用水平方向的结构元素进行腐蚀 for (let j = 0; j < lHeight; j++) { // 由于使用1*3的结构元素,为防止越界,所以不处理最左边和最右边的两列像素 for (let i = 1; i < lWidth - 1; i++) { const lpSrc = j * lWidth + i; for (let k = 0; k < 3; k++) { // 如果原图像中当前点自身或者左右如果有一个点不是黑色,则将目标图像中的当前点赋成白色 for (let n = 0; n < 3; n++) { const pixel = lpSrc + n - 1; data[lpSrc * 4 + k] = 255; if (dataInit[pixel * 4 + k] === 0) { data[lpSrc * 4 + k] = 0; break; } } } } } } else if (nMode === 1) { // 使用垂直方向的结构元素进行腐蚀 // 由于使用1*3的结构元素,为防止越界,所以不处理最上边和最下边的两列像素 for (let j = 1; j < lHeight - 1; j++) { for (let i = 0; i < lWidth; i++) { const lpSrc = j * lWidth + i; for (let k = 0; k < 3; k++) { // 如果原图像中当前点自身或者左右如果有一个点不是黑色,则将目标图像中的当前点赋成白色 for (let n = 0; n < 3; n++) { const pixel = (j + n - 1) * lWidth + i; data[lpSrc * 4 + k] = 255; if (dataInit[pixel * 4] === 0) { data[lpSrc * 4 + k] = 0; break; } } } } } } else { // 由于使用3*3的结构元素,为防止越界,所以不处理最左边和最右边的两列像素和最上边和最下边的两列元素 for (let j = 1; j < lHeight - 1; j++) { for (let i = 1; i < lWidth - 1; i++) { const lpSrc = j * lWidth + i; for (let k = 0; k < 3; k++) { data[lpSrc * 4 + k] = 255; // 如果原图像中对应结构元素中为黑色的那些点中有一个不是黑色,则将目标图像中的当前点赋成白色 for (let m = 0; m < 3; m++) { for (let n = 0; n < 3; n++) { if (structure[m][n] === -1) { continue; } const pixel = lpSrc + ((2 - m) - 1) * lWidth + (n - 1); if (dataInit[pixel * 4] === 0) { data[lpSrc * 4 + k] = 0; break; } } } } } } } } 复制代码
开运算和闭运算
我们知道, 腐蚀是一种消除边界点,使边界向内部收缩的过程,可以用来消除小且无意义的物体。而膨胀是将与物体接触的所有背景点合并到该物体中,使边界向外部扩张的过程,可以用来填补物体中的空洞。
先腐蚀后膨胀的过程称为开运算。用来消除小物体、在纤细点处分离物体、平滑较大物体的边界的同时并不明显改变其面积;先膨胀后腐蚀的过程称为闭运算。用来填充物体内细小空洞、连接邻近物体、平滑其边界的同时并不明显改变其面积。
开运算和闭运算是腐蚀和膨胀的结合,因此代码可以参考腐蚀和膨胀的代码。
细化
细化就是寻找图形、笔画的中轴或骨架,以其骨架取代该图形或笔划。在文字识别或图像理解中,先对被处理的图像进行细化有助于突出和减少冗余的信息量。
下面是一个具体的细化算法(Zhang快速并行细化算法):
一幅图像中的一个区域,对各点标记名称,其中P1位于中心。如图所示:
如果(即黑点),下面四个条件如果同时满足,则删除。
其中是的非零邻点的个数,是以,,···,p9为序时这些点的值从到变化的次数。
对图像中的每一个点重复这一步骤,直到所有的点都不可删除为止。
代码
/**
* 说明:
* 该函数用于对图像进行细化运算
* 要求目标图像为只有0和255两个灰度值的灰度图像
* @param data 图像数据
* @param lWidth 原图像宽度(像素数)
* @param lHeight 原图像高度(像素数)
*/
function thinDIB(data, lWidth, lHeight) { // 保存原始数据 const dataInit = []; for (let i = 0, len = data.length; i < len; i++) { dataInit[i] = data[i]; } let bModified = true; const neighBour = [ [0, 0, 0], [0, 0, 0], [0, 0, 0] ]; while (bModified) { bModified = false; for (let j = 1; j < lHeight - 1; j++) { for (let i = 1; i < lWidth - 1; i++) { let bCondition1 = false; let bCondition2 = false; let bCondition3 = false; let bCondition4 = false; const lpSrc = j * lWidth + i; // 如果原图像中当前点为白色,则跳过 if (dataInit[lpSrc * 4]) { continue; } // 获取当前点相邻的3*3区域内像素值,0代表白色,1代表黑色 const bourLength = 3; for (let m = 0; m < bourLength; m++) { for (let n = 0; n < bourLength; n++) { const pixel = lpSrc + ((2 - m) - 1) * lWidth + (n - 1); neighBour[m][n] = (255 - dataInit[pixel * 4]) ? 1 : 0; } } const borderArr = [neighBour[0][1], neighBour[0][0], neighBour[1][0], neighBour[2][0], neighBour[2][1], neighBour[2][2], neighBour[1][2], neighBour[0][2]]; let nCount1 = 0; let nCount2 = 0; for (let i = 0, len = borderArr.length; i < len; i++) { nCount1 += borderArr[i]; if (borderArr[i] === 0 && borderArr[(i + 1) % len] === 1) { nCount2++; } } // 判断 2<= NZ(P1)<=6 if (nCount1 >= 2 && nCount1 <= 6) { bCondition1 = true; } // 判断Z0(P1) = 1 if (nCount2 === 1) { bCondition2 = true; } // 判断P2*P4*P8=0 if (borderArr[0] * borderArr[2] * borderArr[6] === 0) { bCondition3 = true; } // 判断P2*P4*P6=0 if (borderArr[0] * borderArr[2] * borderArr[4] === 0) { bCondition4 = true; } for (let k = 0; k < 3; k++) { if (bCondition1 && bCondition2 && bCondition3 && bCondition4) { data[lpSrc * 4 + k] = 255; bModified = true; } else { data[lpSrc * 4 + k] = 0; } } } } if (bModified) { for (let i = 0, len = data.length; i < len; i++) { dataInit[i] = data[i]; } } } } 复制代码
边缘、轮廓与填充
边缘检测
图片的边缘是图像的最基本特征,所谓边缘是指其周围像素灰度有阶跃变化或屋顶变化的那些像素的集合。边缘的种类可以分为两种:一种称为阶跃性边缘,它两边的像素的灰度值有着显著的不同;另一种称为屋顶状边缘,它位于灰度值从增加到减少到变化转折点。
边缘检测算子检测每个像素到邻域并对灰度变化率进行量化,也包括方向的确定。大多数使用基于方向导数掩模求卷积的方法。下面是几种常用的边缘检测算子:
-
Roberts边缘检测算子:
Roberts边缘检测算子是一种利用局部差分算子寻找边缘的算子。它由下式给出:
其中,f(x, y)是具有整数像素坐标的输入图像,平方根运算使该处理类似于在人类视觉系统中发生的过程。
-
Sobel边缘算子
上面两个卷积核形成了Sobel边缘算子,图像中的每个点都用这两个核做卷积,一个核对通常的垂直边缘影响最大,而另一个对水平边缘影响最大。两个卷机的最大值作为该点的输出位。
-
Prewitt边缘算子
上面两个卷积核形成了Prewitt边缘算子,和使用Sobel算子的方法一样,图像中的每个点都是用这两个核进行卷积,取最大值作为输出。Prewitt算子也产生一幅边缘幅度图像。
-
Krisch边缘算子
上面的8个卷积核组成了Kirsch边缘算子。图像中的每个点都用8个掩模进行卷积,每个掩模都对某个特定边缘方向作出最大响应。所有8个方向中的最大值作为边缘幅度图像的输出。最大响应掩模的序号构成了边缘方向的编号。
-
高斯-拉普拉斯算子
拉普拉斯算子是对二维函数进行运算的二阶导数算子。通常使用的拉普拉斯算子如下:
各边缘检测算子对比
算子 | 优缺点比较 |
---|---|
Roberts | 对具有陡峭的低噪声的图像处理效果较好,但利用Roberts算子提取边缘的结果是边缘比较粗,因此边缘定位鄙视很准确。 |
Sobel | 对灰度渐变和噪声较多的图像处理效果比较好,Sobel算子对边缘定位比较准确。 |
Prewit | 对灰度渐变和噪声较多的图像处理效果较好 |
Kirsch | 对灰度渐变和噪声较多的图像处理效果较好 |
高斯-拉普拉斯 | 对图像中的 阶段性边缘点定位准确,对噪声非常敏感,丢失一部分边缘的方向信息,造成一些不连续的边缘检测。 |
轮廓提取与轮廓跟踪
轮廓提取和轮廓跟踪的目的都是获取图像的外部轮廓特征。二值图像轮廓提取的算法非常简单,就是掏空内部点:如果原图中一点为黑,且它的8个相邻点都是黑色时(此时该点是内部点),则将该点删除。用形态学的内容就是用一个九个点的结构元素对原图进行腐蚀,再用原图像减去腐蚀图像。
图像轮廓提取图像对比:
轮廓跟踪就是通过顺序找出边缘点来跟踪出边界。首先按照从左到右,从下到上的顺序搜索,找到的第一个黑点一定是最左下方的边界点,记为A。它的右、右上、上、左上四个邻点中至少有一个是边界点,记为B。从B开始找起,按右、右上、上、左、左上、左下、下、右下的顺序找相邻点中的边界点C。如果C就是A点,则表明已经转了一圈,程序结束;否则从C点继续找,直到找到A为止。判断是不是边界点很容易:如果它的上下左右四个邻点都不是黑点则它即为边界点。
这种方法需要对每个边界像素周围的八个点进行判断,计算量比较大。还有一种跟踪准则:
首先按照上述方法找到最左下方的边界点。以这个边界点开始,假设已经沿顺时针方向环绕整个图像一圈找到了所有的边界点。由于边界是连续的,所以每一个边界点都可以用这个边界点对前一个边界点所张的角度来表示。因此可以使用下面的跟踪准则:从第一个边界点开始,定义初始的搜索方向为沿左上方;如果左上方的点是黑点,则为边界点,否则在搜索方向的基础上逆时针旋转90度,继续勇同样的方法继续搜索下一个黑点,直到返回最初多边界点为止。
轮廓跟踪算法示意图如下:
种子填充
种子填充算法是图形学中的算法,是轮廓提取算法的逆运算。
种子填充算法首先假定封闭轮廓线内某点是已知的,然后算法开始搜索与种子点相邻且位于轮廓线内的点。如果相邻点不在轮廓内,那么就到达轮廓线的边界;如果相邻点位于轮廓线之内,那么这一点就成为新的种子点,然后继续搜索下去。
算法流程如下:
- 种子像素压入堆栈;
- 当堆栈非空时,从堆栈中推出一个像素,并将该像素设置成所要的值;
- 对于每个与当前像素相邻的四连通或八连通像素,进行上述两部分内容的测试;
- 若所测试的像素在区域内没有被填充过,则将该像素压入堆栈
对于第三步中四连通区域和八连通区域,解释如下:
四连通区域中各像素在水平和垂直四个方向上是连通的。八连通区域各像素在水平、垂直及四个对角线方向都是连通的。
总结
本文对前端进行数字图像处理做了一个基础的讲解,主要针对获取图像数据、保存图像、点运算、几何处理、图像增强、数字形态学和边缘检测轮廓提取做了一个简单的分析和实现,并没有算法做很深的研究。