Python利用OpenCV实现赫夫曼编码压缩图像实践

862 阅读8分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第5天,点击查看活动详情

声明:此篇文章实现的是基础的霍夫曼编码压缩,不能保证其压缩效果。尤其是对于已压缩过的大分辨率jpg图像。

(一)  OpenCV环境安装

在网站www.lfd.uci.edu/~gohlke/pyt…中下载合适的版本,将其放在Anaconda3\Lib\site-packages目录下:

image.png

运行Anaconda Prompt,进入到该目录并执行指令:

pip install opencv_python-4.5.5-cp39-cp39-win_amd64.whl

初始下载的版本不合适,提示不兼容:

ERROR: opencv_python-4.5.5+mkl-cp37-cp37m-win_amd64.whl is not a supported wheel on this platform

可键入如下指令:

python -m pip debug –verbose

从而查看对应的兼容tags

image.png

下载成功截图如下:

image.png

后续通过import cv2 即可导入OpenCV库

 

(二) . 无损压缩方法

1.      初始数据获取

cv.imread(filename,flag)函数用于读取指定的图像文件,并将读取回来的结果返回。其中filename为读取文件路径,flag表示读取图片的方式,可以设置为读取三通道的BGR 彩色图像或单通道的灰度图像。若读取成功,将返回一个nparray 多维数组。该数组将作为后续压缩算法的初始数据

对其中一张图片调用该方法并输出结果:

111.png

输出结果如下:可知读取后的数据为三维数组,且值分布为0 – 255。

11111.png

2.      统计数据频率(频数)

通过numpy库中的nditer()方法可以实现直接遍历多维数组,在遍历时使用字典来统计对应像素值的出现频率。若之前未出现则以0代替。

11111.png

3.      定义节点

自定义树赫夫曼节点类Huffmannode,其中增加父节点与权值:若parent = 0 表示该节点尚未加入到赫夫曼树中。

1.png

4.      选择最小权值节点

由于在自底向上的过程中需要不断找到两个最小权值的节点,故编辑方法select_node来寻找parent = 0 中两个具有最小权值节点。

1.png

5.      构建Huffman树

思路:若原数据节点为n,则建树过程中新增的节点为n-1,总计有2*n-1节点。通过已收集的字典数据,每次寻找最低权值的两个节点,合并后并将合并的中间节点也放入huffman数组中。

1.png

6.      Huffman树的数据编码

思路:由于初始节点位于Huffman数组的0 – n-1位,故仅需遍历前n位,向上寻找其父节点并记录其左右方向(左为0,右为1)。由于是自底向上寻找,故最后还需翻转得到的编码字符串。

1.png

最终,返回的coding_dict即表示编码对应的字典。

7.      根据字典编码初始数组

根据得到的编码字典,即可在遍历数组时将对应的数据转为二进制的01字符串:

1.png

输出结果如下:(由于二进制字符串较长,故仅输出前100位。)

1.png

由图可知,该数组的像素值为255的频率最大,故其表示内容为“11”。相应的,出现频率次数越少的像素值对应的编码字符串就越长。

8.      写入二进制文件

注意点:

  •  写入文件时需要将该二进制字符串转为bytes数组,若以ASCII码或其他编码方式直接写入文件中,则每一个字符“0”或“1”都将占据1字节的大小,最终反而会生成以MB为单位的文件。

  • 由于文件的最小存储单位是1字节(1byte = 8bit),而得到的二进制字符串并不一定是8的整数倍,故我们需要特判并在二进制字符串后补全0,写入时以8位一起写入。

1.png

输出的二进制文件大小如下:比原图像增加了100KB左右,大小约为300KB。

1.png

分析原因来源:

  • 文件读写:可能是二进制文件写入中浪费了部分的数据空间。

  • 压缩算法:由于对图中BGR三通道中0 - 255同时做赫夫曼编码,故三者的数值分配不一定能产生较大的频率差(即频率相近),因而会对压缩效率产生一定的影响。

  • 图像文件:可能是该图像存储时已经被压缩过,对该图像再次压缩出现数据膨胀的情况。

9.      二进制文件的读取

在读取二进制文件为输出字符串时,需要提前知道实际二进制文件的字符串长度,对于不同程序而言,此处可以通过共享变量,进程通信,或再次将字典写入二进制文件中等方式实现。但本实验中在同一程序下继续编辑下面内容。(即预知二进制文件读取的字符串的有效长度l)

1.png

10.   图像的还原

还原图像时需要将原字典翻转key-value,由二进制字符串映射数字。随后在遍历读取的二进制字符串时根据字典查找对应的数值。

1.png

由于读取图像时未读取png文件的第四个透明度通道,故还原后png文件丧失透明度,且大小减少了略多于四分之一。336*3/4 = 252KB > 237 KB

1.png

至此即完成了基础的赫夫曼编码图像压缩。

运行结果

对于无损编码压缩测试结果如下(不尽人意啊):中间产出的压缩文件略大于原图像大小。

1.png

但是对比左右侧图像,左侧原图,右侧解压后生成的图,还是一模一样滴~

image.png

程序代码

import cv2
import numpy as np
from math import inf
import datetime
import os

# 初始化哈夫曼结点
class Huffmannode(object):
    def __init__(self):
        self.parent = 0
        self.left = 0
        self.right = 0
        self.weight = 0

# 选择最小的结点下标
def select_node(huffman):
    # 仅有两个直接返回
    if len(huffman) == 2:
        return 0, 1
    min = semin = inf  # 无穷大
    least1 = least2 = -1 # 两个最小节点
    for i in range(len(huffman)):
        if huffman[i].parent == 0:
            if min > huffman[i].weight:
                semin = min
                least2 = least1
                min = huffman[i].weight
                least1 = i
            elif semin > huffman[i].weight:
                semin = huffman[i].weight
                least2 = i
    return least1, least2

# 编码
def huffman_code(origin_dict):
    # 给结点赋权重
    n = len(origin_dict) # 当前节点数
    m = 2 * n - 1 # 建树完毕后的节点数
    huffman = []
    for i in origin_dict:
        temp_huffmannode = Huffmannode()
        temp_huffmannode.weight = origin_dict[i]
        huffman.append(temp_huffmannode)
    # 构建Huffman树,选择两个最小权值的结点合并
    for i in range(n, m):
        least1, least2 = select_node(huffman)
        temp_huffmannode = Huffmannode() # 初始化合并的中间节点
        temp_huffmannode.weight = huffman[least1].weight + huffman[least2].weight # 权值合并
        temp_huffmannode.right = least1  # 略小的放右边
        temp_huffmannode.left = least2  # 略大的放左边
        huffman[least1].parent = huffman[least2].parent = i
        huffman.append(temp_huffmannode)
    # 0,1编码
    codeing_dict = dict.fromkeys(origin_dict, None)
    for i in range(0, n):
        codestr = ''
        k = i
        parent = huffman[i].parent
        while parent != 0:
            if huffman[parent].left == k:
                codestr += '0'
                k = parent
                parent = huffman[parent].parent
            else:
                codestr += '1'
                k = parent
                parent = huffman[parent].parent
        codeing_dict[list(origin_dict.keys())[i]] = list(reversed(codestr))
    for k in codeing_dict.items():
        codeing_dict[k[0]] = ''.join(k[1])
    return codeing_dict

if __name__ == '__main__':
    basePath = "D:\\PythonProject\\test_image\\"
    filename = "green"
    filetype = "png"
    filepath = basePath + filename + "." + filetype
    binary_filename = filename + "_binary.bin"
    #读取图像
    img = cv2.imread(basePath + filename + "." + filetype,1)

    height = len(img)
    width = len(img[0])
    print("width: "+ str(width) +" height: " + str(height))

    start = datetime.datetime.now() # 记录开始时间
    # print(img)
    # cv2.imshow("img",img)
    # key = cv2.waitKey(0)  # 等待按键命令

    # 创建字典计算频率
    dic = {}
    for x in np.nditer(img):
        # x 本身是numpy.narray 需要做一次类型转化
        # get方法,如果有键返回该键对应的值,如果没键,可以设置返回值
        dic[x+0] = dic.get(x+0, 0) + 1

    # 对256位进行霍夫曼编码
    code_dict = huffman_code(dic)
    print("Huffman编码计算完成")
    # print(code_dict)
    # 得到压缩后的二进制字符串
    compress_str = ""
    for x in np.nditer(img):
        compress_str += code_dict[x+0]
    # print("得到的二进制字符串(0 - 99位)")
    # print(compress_str[0:100])

    # 将二进制字符串写入对应的二进制文件中
    print("即将写入文件路径:"+basePath + binary_filename)
    # with open(basePath + "binary.bin", "wb") as fp:
    #     fp.write(bytes_content)

    # 首先需要保存原字符串长度
    l = len(compress_str)
    s = compress_str
    # 首先将s变成的8的整数倍长的字符串 可以直接在后面填0
    if r := l % 8:
        s += "0" * (8 - r)
    # 将s转成bytes
    b = bytes([int(s[i:i + 8], 2) for i in range(0, len(s), 8)])

    print("写入二进制文件")
    # bytes 可以直接写入以二进制模式打开的文件
    with open(basePath + binary_filename, "wb") as f:
        f.write(b)

    # 为了能够从文件中得到完全一致的二进制字符串,需保留原始长度l
    # 从文件中还原
    with open(basePath + binary_filename, "rb") as f:
        data = f.read()
        binstr = "".join(["{:08b}".format(c) for c in data])
        binstr = binstr[:l]  # 此时的binstr为原来的二进制字符串

    if binstr == compress_str:
        print("解压字符串相同")
    else:
       print("解压字符串不相同")

    # 逆转字典,用于解压
    reverse_dict = dict(zip(code_dict.values(),code_dict.keys()))
    # print(reverse_dict)

    # 遍历二进制字符串并根据字典翻译成像素值
    num_str = ''
    num_arr = []
    num = 0
    for i in range(0,len(binstr)):
        num_str += binstr[i]
        num = reverse_dict.get(num_str, -1)
        if num != -1:
            num_arr.append(num)
            num_str = ''
    # print("压缩数为:")
    # print(num_arr)
    num_arr = np.array(num_arr).reshape(height, width, 3)
    # print(num_arr)
    end = datetime.datetime.now()
    cv2.imwrite(basePath + filename + "_1." + filetype, num_arr)

    # 求压缩比
    origin_size = os.path.getsize(filepath)
    compress_size = os.path.getsize(basePath + binary_filename)
    output_size = os.path.getsize(basePath + filename + "_1." + filetype)
    compress_ratio = origin_size/compress_size
    # 求均方误差
    diff = 0
    for i in range(0, height):
        for j in range(0, width):
            for k in range(0, 3):
                diff += (img[i][j][k] - num_arr[i][j][k] )**2
    diff /= (height*width*3)

    print("图像名称: " + str(filename +"."+ filetype))
    print("原图像大小: " + str(origin_size) + " 字节")
    print("压缩文件大小: " + str(compress_size) + " 字节")
    print("生成图像大小: " + str(output_size) + " 字节")
    print("压缩比: "+ str(compress_ratio))
    print("均方误差: " + str(diff))
    print("程序执行时间: " + str((end-start).seconds) +" 秒")