手写笔记增强 -- 图像处理入门

Sep. 25th, 2016


图像处理 python

在网上看到一位教授自己写的笔记增强的程序:Compressing and enhancing hand-written notes, 对手写笔记进行了色彩上的增强,看起来更加清晰,而且由于减少了色彩的种类,还附带缩小了图片的大小,文章里还介绍了图像处理的一些原理和技巧,很实用。正好这学期也有数字图像处理的课程,于是就阅读了下源码,copy着实现了一个简易的版本,当作入门好了。

下面来一个比较正式的问题描述:

输入:一张手写笔记的图片,可以是 .jpg, .png 等多种格式,颜色模式可以是灰度图、RGB或者RGBA等。 输出:一张RGB颜色模式的 .png 格式的图片,要求原图片中的字迹更加清晰,示例如下:


接下来就是图像处理的过程了:

首先要做的就是把背景的颜色与其它颜色区分开。一个比较直观的想法是背景的颜色在一张纸上占了绝大部分,因此只要扫描图片,找到占比最多的颜色即可。然而在一张RGB模式的图像里,表现出的颜色是由R、G、B三个颜色通道(channel)合成得来的(如下图所示),所以即使看起来很相似的颜色,对应的三个通道的值也不尽相同,这就对找寻背景色带来了一定的困难。考虑到既然颜色很相似,那么可以将相似的颜色化为同一种颜色来处理,这样就减少了颜色的种类,作为背景色的颜色占的比例也就会更大,也就更容易找出来。

具体如何来操作呢?这里分为三步:

def sample_pixels(image, options):
    pixels = image.reshape((-1, 3))
    pixel_num = pixels.shape[0]
    sample_num = int(pixel_num * options.samplepercent)
    idx = np.arange(pixel_num)
    np.random.shuffle(idx)
    return pixels[idx[:sample_num]]
def quantize(sample, bits_per_channel):
    shift = 8 - bits_per_channel
    halfbin = (1 << shift) >> 1
    return ((sample.astype(int) >> shift) << shift) + halfbin
def pack_rgb(image):
    packed = (image[:, 0] << 16 |
              image[:, 1] << 8 |
              image[:, 2])
    return packed

def unpack_rgb(packed):
    rgb = ((packed >> 16) & 0xff,
           (packed >> 8) & 0xff,
           (packed) & 0xff)
    return np.array(rgb)

得到背景色之后,要找出前景色,也就是笔记线条和字等的颜色,由于它们都与背景色差距比较大(不然怎么看清楚-_-!),所以考虑使用不同颜色之间的欧式距离来区分,但是根据教授的实验,会出现这样的问题:

背景色近似白色,三个通道值为$[238,238,242]$,而手写的笔记里由于另一面的字迹可能出现渗透(bleed-through),在图片里表现为模糊的灰色,通道值为$[160,168,160]$,而书写笔记时使用了粉红色,其通道值为$[243,179,182]$,可以发现,粉红色与背景色更接近,如果采取这种方法,粉色也会被作为背景色。

考虑使用HSV(Hue-Saturation-Value)的色彩模型,这个模型相当于将RGB的色彩空间沿中轴线竖了起来,$H$代表不同的色调,取值范围为$[0,360]$;$S$代表不同的色彩饱和度,范围是$[0,1]$,值越大,颜色中所含的白色成分也就越少;$V$代表色彩的明度,取值范围为$[0,1]$,0表示黑,1表示白。

使用这种颜色模型,对于上述问题中的三个颜色,对应的HSV值分别为:白色$[240,0.017,0.949]$,灰色$[120,0.048,0.659]$,粉红色$[357,0.263,0.953]$。可以看出,灰色与白色的$V$值相差很大,而粉色与白色的$S$值相差很大,因此需要选择一个差值$S$的阈值和差值$V$的阈值来将灰色辨别为背景色,粉红色辨别为前景色,经过教授的实验,当满足下面两条之一时,即可将某颜色作为前景色处理:

根据图像的不同,这两个阈值在程序里可以通过-s-v参数来指定。根据原理也不难推知,如果反面字迹的渗透比较严重,要适当增大$V$值,如果基本没有渗透,而线条的颜色或者字迹的颜色与背景色比较像,则要适当减小$V$值或者减小$S$值;如果字迹颜色与背景色差异较大,那么增大$S$值可以减少反面渗透的影响。

def rgb_to_sv(rgb):
    if not isinstance(rgb, np.ndarray):
        rgb = np.array(rgb)
    axis = len(rgb.shape) - 1
    cmax = rgb.max(axis=axis).astype(np.float32)
    cmin = rgb.min(axis=axis).astype(np.float32)
    delta = cmax - cmin
    saturation = delta.astype(np.float32) / cmax
    saturation = np.where(cmax == 0, 0, saturation)
    value = cmax / 255.0
    return saturation, value

def get_fgmask(bgcolor, sample, options):
    s_bg, v_bg = rgb_to_sv(bgcolor)
    s_sample, v_sample = rgb_to_sv(sample)
    s_diff = np.abs(s_bg - s_sample)
    v_diff = np.abs(v_bg - v_sample)
    return ((v_diff >= options.value_threshold) |
            (s_diff >= options.sat_threshold))

这样就可以得到样本中的每一个像素点是否为前景色的bool数组,接下来就要对这些像素点进行处理。为了使字迹显得更加清楚,很重要的一点是颜色的种类要比较少,因此要将颜色相似的像素点的颜色变为相同。这里采用k-means聚类的思想,根据笔记中颜色的实际种类选取类的个数$k$(程序里通过参数-n来指定,默认为$8$),然后将图像的所有前景色像素点作为样本点,找到$k-1$个聚类中心作为不同颜色笔迹的实际颜色(首先将背景色的类排除掉了)。再为原图中每一个像素点找到它所在的颜色类,记录下来。

def get_featurecolor(sample, options, kmeans_iter=40):
    bgcolor = get_bgcolor(sample,options.quantized_bits)
    fgmask = get_fgmask(bgcolor, sample, options)
    centers, _ = kmeans(sample[fgmask].astype(np.float32),
                        options.num_colors - 1,
                        iter=kmeans_iter)
    featurecolor = np.vstack((bgcolor, centers)).astype(np.uint8)
    return featurecolor

def apply_featurecolor(image, featurecolor, options):
    bgcolor = featurecolor[0]
    orig_shape = image.shape
    pixels = image.reshape((-1, 3))
    fgmask = get_fgmask(bgcolor, pixels, options)
    pixel_num = pixels.shape[0]
    labels = np.zeros(pixel_num, dtype=np.uint8)
    labels[fgmask], _ = vq(pixels[fgmask], featurecolor)
    return labels.reshape(orig_shape[:-1])

到这里主要部分已经搞定了,存储之前又做了两个小功能:一个是色彩饱和度的增强,也就是减少颜色中含有的白色的成分,让颜色看起来更饱满,程序里默认开启,可以通过-S参数来关闭;另一个是将背景色化为白色,这个要根据具体图像来具体分析,程序里默认关闭,可以通过-w参数来开启。

def save(basename, labels, featurecolor, options):
    if options.saturate:
        featurecolor = featurecolor.astype(np.float32)
        cmin = featurecolor.min()
        cmax = featurecolor.max()
        featurecolor = 255 * (featurecolor - cmin) / (cmax - cmin)
        featurecolor = featurecolor.astype(np.uint8)
    if options.white_bg:
        featurecolor = featurecolor.copy()
        featurecolor[0] = np.array([255, 255, 255])
    output_img = Image.fromarray(labels, 'P')
    output_img.putpalette(featurecolor.flatten())
    output_img.save(basename + '.png')

存储之后,发现图片的大小有了明显的减少,刚开始还在奇怪,每个像素点还是用RGB三个通道在存储,图片大小怎么就变小了呢?查了一下资料才明白,png图片的压缩中有一种策略是将不同的颜色只存储一遍,再将每个位置用了哪种颜色的信息存储下来,就可以还原整张图了。其实在上面代码的最后三行也可以看出来这种思想。

回过头来看整个程序的实现,比较厉害的就是它可以保留笔记的原有颜色,平常使用的类似应用都只能得到黑白二值图像,而缺点也恰恰在此,拍照条件的不同,导致同样的笔记颜色差距很大,如果想要真实还原原图的多种颜色,还需要对参数做比较多的调整,也是比较繁琐的事情。当然只要设置颜色数为2,对于大多数图像也都能得到不错的效果。

这次看教授写的源码,也了解到了几个以前不知道的numpy的使用技巧,这里也列举一下:

# 从大小为n的数组a中随机选取m个作为一个新的数组b
    idx = np.arange(n)
    np.random.shuffle(idx)
    b = a[idx[:m]]

# 把数组a中出现次数最多的元素p提取出来
    unique, count = np.unique(a, return_counts=True)
    p = unique[count.argmax()]

# 将RBG值转换为HSV值时,输入像素矩阵rgb(大小为n*3),根据公式计算出S值
    axis = len(rgb.shape) - 1
    cmax = rgb.max(axis=axis).astype(np.float32)
    cmin = rgb.min(axis=axis).astype(np.float32)
    delta = cmax - cmin
    saturation = delta.astype(np.float32) / cmax
    saturation = np.where(cmax == 0, 0, saturation)

# 长度为n的bool数组f存储一些值,将数组a中对应位置的f值为True的元素提取出来作为一个新数组b
    b=a[f]

看源码真的获益颇多,教授的整体思路都非常清晰,函数的耦合性也很低,每个函数都是独立的模块,基本没有冗余的代码,命令行参数也很明了,以后还是要多读一些代码~~