如何利用图片对比算法处理白屏检测

背景

做过小程序或者快应用的同学应该知道,先通过 sitemap 配置应用可以爬取的页面,最终用户可以通过在平台关键字,搜索触达爬取到的页面。这个 sitemap 技术的原理类似于搜索引擎:先通过爬虫去爬取相关的页面内容,保存快照和页面链接,等到与用户搜索内容匹配的时候,再展示快照;点击快照内容时,通过预先设置的页面链接,跳转到应用的实际页面,这样就完成了一次触达过程。

难点

为了保证外链的有效性,维护团队会定时去巡检爬取的外链,以检查是否能正常访问,排除已经失效的死链。此举可以保证链接的有效性,但还是无法保证内容的有效性。也就是快应用的业务每天可能都在发生改变,页面的链接内容可能已经下架了,链接却依然能正常访问,这是常见的一种异常情况-- 内容白屏,非常影响用户体验。为了提高用户体验,必须下架这一类内容白屏的链接。

据目前查到的资料判断,页面白屏通常是在页面加载后,判断页面上的关键 dom 是否有加载来判断。快应用开发者的业务千差万别,作为快应用的平台方,去判断快应用中的关键 dom 很明显是不可行的。另一种方式是判断快应用中 ajax 请求的状态来判断,当请求状态不是 200 就断定内容异常。但这种方式也无法处理请求状态为 200,但是 ajax 返回内容数据为空的情况。进一步 ajax 内容为空,与页面内容空白并不是成必然关系;就算能遍历判断 ajax 返回空白,也无法佐证页面就是空白的。

快应用之间的差异很大,要从中寻找出一条,放置于四海之内皆准的法则,来判断快应用内容白屏的情况,这似乎没有现成的方法可以借鉴,问题似乎陷入了困境。曾一度认为像这种内容白屏的情况,是否只能由快应用的开发者去通知平台方,平台方再去下架该链接。但是不到万不得已,我们并不想给开发者带来如此糟糕的开发体验。在查阅了一些资料后,我们有了一个新的解决思路:如果一个页面的截图,跟预先提供的一张纯白色图片,相比足够的相似,那么就可以认为该页面是一个内容白屏页面。判断内容白屏这个问题,就转变成判断两张图片相似度的需求。

常见的图片相似度方案比较

下面先来简单科普几种常见的图片对比方案:

  1. hash 算法

    将每个图片生成一个哈希值,最终比较两张图片的哈希值,结果越接近,图片越相似。

哈希算法一般的处理步骤是:首先将图片缩小得足够小,一般是(9*8)只保留结构、明暗等基本信息,摒弃不同尺寸、比例带来的图片差异。 然后将图片灰度化,最后计算像素差异值生成哈希指纹。根据计算差异值方式的不同,哈希算法又可以细分为 aHash(average hash)、pHash(Perceptual hash)、dHash(different hash),他们又各有优劣:

  • aHash:平均值哈希。速度比较快,但是常常不太精确。

  • pHash:感知哈希。精确度比较高,但是速度方面较差一些。

  • dHash:差异值哈希。精确度较高,且速度也非常快。

    2.ssim(structural similarity)算法

SSIM(structural similarity)结构相似度算法,一种全参考的图像质量评价指标,分别通过对图片亮度、对比度、结构进行对比,一般是用来评估图片压缩后的质量

3.PSNR: (Peak Signal to Noise Ratio)峰值信噪比算法

与 ssim 一样也是一种全参考的图像质量评价指标。它是基于对应像素点间的误差,即基于误差敏感的图像质量评价。由于并未考虑到人眼的视觉特性(人眼对空间频率较低的对比差异敏感度较高,人眼对亮度对比差异的敏感度较色度高,人眼对一个区域的感知结果,会受到其周围邻近区域的影响等),因而经常出现评价结果与人的主观感觉不一致的情况。

4.histogram 算法

将每个图片都生成颜色的直方图,如果两个图片的颜色直方图很接近,也可以认为图片本身很接近。

直方图比较也有以下 4 种方式

  • Correlation 相关性比较
  • Chi-Square 卡方比较
  • Intersection 十字交叉性
  • Bhattacharyya distance 巴氏距离

实践

实践是检验真理的唯一标准。单单看算法解释,是无法知道算法的最终结果 是不是符合我们预期的。下面博主我将逐一将这些算法进行实验:

首先以一张白色空白的图片为基准:

再选择几张合适的网页截图,作为这次测试的实验对象:

为了方便对照结果,以上几张图片,从左到右分别给它们命名如下:`'temp1.png', 'temp2.png', 'temp3.png', 'temp4.png'`

temp1.png: 几乎完全白屏

temp2.png: 中间主要内容白屏

temp3.png: 内容丰富,完全不白屏

temp4.png: 内容一般,不白屏

  1. 感知哈希算法

使用的是 github 上开源的一份 dhash 源码 https://github.com/hjaurum/DHash

测试的结果是: {'temp1.png': 3, 'temp2.png': 11, 'temp3.png': 20, 'temp4.png': 4}

结果说明:测试结果越接近 0,说明测试对象与白屏越接近。temp2.png 的结果值比较高,算法断定与白屏较为不一样,temp4 的结果值比较低,算法断定与白屏比较接近,算法断言的结果与实际情况是违背的。

结论:不适用 。由于该算法要先将图片缩得非常小小,信息丢失得非常多,所以跟纯白色图片的对比结果十分受对比图片的颜色分布影响,对比结果很不准确。

  1. ssim 算法

ssim 使用的是compare_ssim这个库。

from SSIM_PIL import compare_ssim
from PIL import Image

def compare_image_in_ssim(imgpath1, imgpath2):
  image1 = Image.open(imgpath1)
  image2 = Image.open(imgpath2)
  value = compare_ssim(image1, image2)
  return value

测试的结果是:{'temp1.png': 0.9960819466091012, 'temp2.png': 0.9753513654872917, 'temp3.png': 0.7930904675108524, 'temp4.png': 0.9460743183359872}

结果说明: 测试结果越接近 1,说明测试对象与白屏越接近。同样地,该算法断言的结果与实际情况是相违背的。

结论:不适用 。由于 ssim 算法通常是用来评价图片质量,图片的失真程度的。所以在这里用来判断测试图片与白屏之间有多失真,显然用途是不正确的

由于 psnr 算法与 ssim 一样也是一种全参考的图像质量评价指标,与 ssim 算法的作用类似,所以这里就不再另外单独对 psnr 进行实验了。

  1. histogram 算法

根据 OpenCV 上的一个例子Histogram Comparison稍作修改

import cv2 as cv

def histogram(src_base, src_test1):
    ## [Convert to HSV]
    hsv_base = cv.cvtColor(src_base, cv.COLOR_BGR2HSV)
    hsv_test1 = cv.cvtColor(src_test1, cv.COLOR_BGR2HSV)
    ## [Using 50 bins for hue and 60 for saturation]
    h_bins = 50
    s_bins = 60
    histSize = [h_bins, s_bins]

    # hue varies from 0 to 179, saturation from 0 to 255
    h_ranges = [0, 180]
    s_ranges = [0, 256]
    ranges = h_ranges + s_ranges # concat lists

    # Use the 0-th and 1-st channels
    channels = [0, 1]
    ## [Using 50 bins for hue and 60 for saturation]

    ## [Calculate the histograms for the HSV images]
    hist_base = cv.calcHist([hsv_base], channels, None, histSize, ranges, accumulate=False)
    cv.normalize(hist_base, hist_base, alpha=0, beta=1, norm_type=cv.NORM_MINMAX)

    hist_test1 = cv.calcHist([hsv_test1], channels, None, histSize, ranges, accumulate=False)
    cv.normalize(hist_test1, hist_test1, alpha=0, beta=1, norm_type=cv.NORM_MINMAX)

    ## [Calculate the histograms for the HSV images]

    res = []
    ## [Apply the histogram comparison methods]
    for compare_method in range(4):
        base_base = cv.compareHist(hist_base, hist_base, compare_method)
        base_test1 = cv.compareHist(hist_base, hist_test1, compare_method)
        data = {"method": compare_method, "base_base": base_base, "base_test1": base_test1}
        res.append(data)
## [Apply the histogram comparison methods]

测试后的结果如下:

{
  "Correlation": {
    "temp1.png": 0.9986657002039772,
    "temp2.png": 0.9999985026552713,
    "temp3.png": 0.9975293986570175,
    "temp4.png": 0.9999895931732444
  },
  "Chi-Square": {
    "temp1.png": 0,
    "temp2.png": 0,
    "temp3.png": 0,
    "temp4.png": 0
  },
  "Intersection": {
    "temp1.png": 1,
    "temp2.png": 1,
    "temp3.png": 1,
    "temp4.png": 1
  },
  "Bhattacharyya distance": {
    "temp1.png": 0.1818559920237636,
    "temp2.png": 0.04000132137383061,
    "temp3.png": 0.3436786429149872,
    "temp4.png": 0.12853959709846752
  }
}

结果说明:

  • Correlation 相关性比较 : 结果越接近 1 越相似;
  • Chi-Square 卡方比较: 结果越接近 0 越相似;
  • Intersection 十字交叉性: 结果越接近 1 越相似;
  • Bhattacharyya distance 巴氏距离: 结果越接近 0 越相似;

结论: 不适用,Correlation 相关性比较,Chi-Square 卡方比较, Intersection 十字交叉性,这三种直方图比较方法完全没有辨识度,几乎断定每一个测试对象与白屏都是一样的。Bhattacharyya distance 巴氏距离,对于识别白屏具有一定的辨识度,但是结果比较离散,temp4.pngtemp1.png 更接近与白屏,这跟实际情况是相违背的,而且这样子的结果也无法通过找到一个阈值,来断定测试对象就是白屏了。

在经过上一轮的测试后,常用的图片相似度算法,拿过来直接运用,来判断白屏的测试宣告失败!

失败乃成功之母;知道失败的原因,这样才能为以后的成功做准备。回归到白屏检测这个问题上来,白屏检测的本质是:要断定这张图上面没有内容、或者仅有非常少量的内容。哈希对比,将图片缩小得非常小,图片信息丢失了很多。也就是将一张图片做到了高度马赛克了,空白位置占原图像的多大比例这个信息,已经完全丢失了。可以感受一下 temp1.png & temp4 png 高度马赛克后的图片。

ssim 算法主要是用来评估图片质量,反映图片失真程度的,用在白屏检测这里,用途也不是很“正当”。剩下 histogram 算法,主要是先生成颜色的直方图,再进行比较来代替图片本身的比较。一般来说正常的网页肯定是图文并茂的,直方图应该也比较丰富;白屏的网页,直方图的结果应该是比较单一的。

所有的结果都不是令人满意的,问题到这里还是陷入了死胡同。

最终解决方案

不想失败在这个地方,就只能收拾好心情重新再出发,去寻找真正适合的方案。在网上泡了很久,终于在看了阮一峰老师的这篇《相似图片搜索的原理(二)》里面的内容特征法之后,有了新的思路。

所谓内容特征法就是:如果两张图片是相近,那么他们的黑白轮廓也应该相近。上一步实验用的直方图比较是基于灰度图来做的(如上图中间部分),灰度图虽然比彩图要简单,但还是保留了很多的信息。只有少量内容的白屏如(temp1.png),它的灰度图也比完全无内容的白屏丰富不少,最终生成的直方图跟白屏的差异也会不小。这就给结果带来了不少干扰和不确定性。要排除这种干扰,就要将图片无关的信息都去掉,只保留黑白轮廓,最后只对比黑白轮廓,来断定内容的丰富情况与是否白屏。看起来这种内容特征法,似乎就是我们一直在苦苦寻找的可行方案。

通过博客中提到的“大律法”,将灰度图片转化成黑白二值图,最终通过直方图比较这份二值图。优化后的算法如下:

import cv2 as cv
def otsu_histogram(img, img1):
    dst = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
    dst1 = cv.cvtColor(img1, cv.COLOR_BGR2GRAY)
    ret,th = cv.threshold(dst1,0,255,cv.THRESH_BINARY+cv.THRESH_OTSU)

    hist_base = cv.calcHist([dst], [0], None, [256], (0, 256), accumulate=False)
    hist_test1 = cv.calcHist([th], [0], None, [256], (0, 256), accumulate=False)

    cv.normalize(hist_base, hist_base, alpha=0, beta=1, norm_type=cv.NORM_MINMAX)
    cv.normalize(hist_test1, hist_test1, alpha=0, beta=1, norm_type=cv.NORM_MINMAX)


    res = []
    ## [Apply the histogram comparison methods]
    for compare_method in range(4):
        base_base = cv.compareHist(hist_base, hist_base, compare_method)
        base_test1 = cv.compareHist(hist_base, hist_test1, compare_method)
        data = {"method": compare_method, "base_base": base_base, "base_test1": base_test1}
        res.append(data)
    return res

测试后的结果如下:

{
  "Correlation": {
    "temp1.png": 0.999998199473158,
    "temp2.png": 0.9999303693978338,
    "temp3.png": 0.9807698254660822,
    "temp4.png": 0.9994585418568566
  },
  "Chi-Square": {
    "temp1.png": 0.0,
    "temp2.png": 0.0,
    "temp3.png": 0.0,
    "temp4.png": 0.0
  },
  "Intersection": {
    "temp1.png": 1.0,
    "temp2.png": 1.0,
    "temp3.png": 1.0,
    "temp4.png": 1.0
  },
  "Bhattacharyya distance": {
    "temp1.png": 0.03078108788995894,
    "temp2.png": 0.07647753684216813,
    "temp3.png": 0.2944281499295871,
    "temp4.png": 0.12674005983573874
  }
}

结果分析:Bhattacharyya distance 巴氏距离的结果断定 temp1.png & temp2.png 与白屏比较相似,temp3.png & temp4.png 与白屏较不相似,断言结果与实际是相符合。不仅如此,测试对象与白屏有多接近的算法结果中temp1.png>temp2.png>temp4.png>temp3.png,结果是符合实际,且层次分明的,这是能通过某个阈值来判断是否白屏的前提条件。似乎看到了胜利的曙光……

但,个例的成功并不代表是可以推广开来的经验,仅仅是这四张图片的成功,还不能认为该算法是可以判断出白屏的。接下来,博主我用了 1600 张不同的网页截图进行了测试,其中有白屏和非白屏的网页截图。最终识别出来的白屏有 150 张左右,识别出来的白屏中误判的有少数几张是非白屏的,白屏识别的正确率有 95%左右。有这样子的一个识别率,是可喜的,所以现在才可以认为该算法是可靠的。基于测试结果,经过不断调整参数,0.08 这个阈值用来判断白屏是准确率最高的,优化后的白屏算法如下:

def is_white_page(img, img1, threadhold=0.08):
  '''
  大律法+直方图: 判断是否白屏
  Args:
    img: 白色的图片
    img1: 对比的图片
  Return: Boolean ,跟白屏的相似度小于阈值返回true
  '''
  dst = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
  dst1 = cv.cvtColor(img1, cv.COLOR_BGR2GRAY)
  ret,th = cv.threshold(dst1,0,255,cv.THRESH_BINARY+cv.THRESH_OTSU)

  hist_base = cv.calcHist([dst], [0], None, [256], (0, 256), accumulate=False)
  hist_test1 = cv.calcHist([th], [0], None, [256], (0, 256), accumulate=False)

  cv.normalize(hist_base, hist_base, alpha=0, beta=1, norm_type=cv.NORM_MINMAX)
  cv.normalize(hist_test1, hist_test1, alpha=0, beta=1, norm_type=cv.NORM_MINMAX)

  base_test1 = cv.compareHist(hist_base, hist_test1, 3)
  if base_test1 <= threadhold:
    return True
  else:
    return False

读到这里,你以为终于就可以任务完成收工大吉了吗?不,还没有。

在上面对 1600 张网页截图的测试中还有两类截图,实际上是白屏,但是算法没有识别出来的:

(一)网页 header 跟 footer 内容比较丰富 ,但是 body 为空的

(二)网页背景色较深的

对于第一种情况,header & footer 内容丰富,会带起整张图片的丰富度,因此白屏判断会出现误判。header 跟 footer 在白屏判断中并不是重点,也就是它们有没有内容有什么内容,其实是无关紧要的,认定白屏的重点在于页面的主区域是否大面积空白无内容。很自然地,想到解决方案就是在比较之前将对结果干扰的,实际上不需要太关心的 header & footer 裁剪掉,再进行比较。

对于第二种情况,就比较复杂和艰难了。用“大律法”处理生成黑白图后,会是一张比较黑的,也就是算法断定内容丰富的一张图。既然图片大部分区域是黑色,博主我曾经反向思维想过用一张纯黑色的图片与这类图片比较,如果结果很接近,那是不是就说明,这张图片是一张背景较深的白屏图片呢?测试的结果是令人失望的,实际上内容丰富的图片与这类深色背景的图片,经过“大律法”处理后,都是一张接近全黑色的图片,两者之间并没有区分度。所以对于这种情况博主我想到的解决方案是 - - 还没想到,.囧.

不管怎么样,先对第一种情况进行优化处理,得到优化后的白屏检测算法如下:

def is_white_page(img, img1, threadhold=0.08):
  '''
  大律法+直方图: 判断是否白屏
  Args:
    img: 白色的图片
    img1: 对比的图片
  Return: Boolean ,跟白屏的相似度小于阈值返回true
  '''
  img1 = clip(img1)
  img = clip(img)
  dst = cv.cvtColor(img, cv.COLOR_BGR2GRAY)
  dst1 = cv.cvtColor(img1, cv.COLOR_BGR2GRAY)
  ret,th = cv.threshold(dst1,0,255,cv.THRESH_BINARY+cv.THRESH_OTSU)

  hist_base = cv.calcHist([dst], [0], None, [256], (0, 256), accumulate=False)
  hist_test1 = cv.calcHist([th], [0], None, [256], (0, 256), accumulate=False)

  cv.normalize(hist_base, hist_base, alpha=0, beta=1, norm_type=cv.NORM_MINMAX)
  cv.normalize(hist_test1, hist_test1, alpha=0, beta=1, norm_type=cv.NORM_MINMAX)

  base_test1 = cv.compareHist(hist_base, hist_test1, 3)
  if base_test1 <= threadhold:
    return True
  else:
    return False

def clip(img, size ={'top': 160, 'bottom': 140}):
  '''
  裁剪图片
  top: 裁掉顶部区域的大小
  bottom:裁掉顶部区域的大小
  '''
  sz1 = img.shape[0]         #图像的高度(行 范围)
  sz2 = img.shape[1]         #图像的宽度(列 范围)

  a=size['top'] # y start
  b=sz1-size['bottom'] # y end
  c=0 # x start
  d=sz2 # x end
  return img[a:b,c:d]  #裁剪后的图像

后记: 历经波折,最终还是把一开始看似不可能实现的白屏检测方案给实现出来了。古人云:只要功夫深,铁杵磨成针。工作得久了,越来越感受到专注与坚持对于解决问题的重要性,特别是坚持,当你在面对较为艰难与复杂的工程问题时显得尤为重要。 因为这类问题,肯定不是一下子就有明确的解决思路,解决方案也不是一次两次就可以轻松成功的,在路上你会失败很多次,也会被教做人很多次。忍受住那一次次失望,在失败中总结经验,总会离成功越来越近的,但坚持能让你能走到最后,成功的那一步。

如果大家有任何的问题,欢迎给我留言反馈。

参考链接