【学习图像处理】之实验四——图像编码之LZW编码
LZW编解码
前言
这应该是本学期图像处理的最后一次实验内容了,虽然还有选做的算数编码,那个我还在研究中,目前的进度是卡在如何解决运算量过大导致的运行时间过长问题。
LZW编码看起来简单,实际上遇到的问题还是不少,踩了很多坑之后才解决,记录一下我的经验,希望对大家有帮助。(由于一些优化理论还没有学习,因此后续可能还会进行优化)
LZW编码
1、简介
LZW编码和上一次我们实现的赫夫曼编码一样属于无损编码的类别。不同于赫夫曼编码根据频率的不同使用变长编码,使得高概率短编码,低概率长编码,最终达到总长度变短的目的;LZW编码使用的是固定长编码,通过将几个值合并在一起使用一个编码表示来降低总长度。举个例子:原本120个像素,每个像素8bit;现在通过将其中重复出现的像素序列(比如在灰度图中可能有大量连续的0,那么我就可以把类似00赋予一个新的编码,只要编码的位数少于,就可以达到压缩的目的)用一个合适长度的新编码表示,最终达到,比方说,的总长,这样相比原本的960bit就实现了压缩的目的。
2、原理
上面说的是一个实现压缩的思路,然而实际上LZW编码的实现原理更加简单,这篇博客给出了非常完整的例子和词典的扩充演示,并且还特意指出了解码时可能出现的一种特例情况,建议完整的看一遍,不然只靠空口说原理,理解起来总还是有难度的。
不过大家也发现了,网上几乎所有的LZW编码原理讲解,包括演示用的都是字母,“ABAAD”和“01003”这样的像素值组合总还是不一样的。像素值具有一个非常不好的特点,就是歧义性;“100”可以解释成一个像素,也可以解释成多个像素的组合。
因此即便我只用了一个小时就参照另一篇博客实现了对于输入字符串的LZW编解码,也通过了老师给出的演示样例。但是当真正应用于图像编解码时,出现了两个问题:一)解码歧义性;二)反向压缩(变得更大了)。
3、两个问题
歧义性的问题在盖哥的帮助下得到了解决。我们用char
(你可以理解为ASCII码)来代替int
型数值表示像素值。这样一来在解码得到的字符串的每一位(每一个char
)就恰好对应一个像素值,歧义性的问题也就不存在了。(解释的更清楚一些,就是因为char
恰好是一个字节的大小,不考虑符号位的话恰好可以表示0-255之间的数,而我们的像素数据本质上也是Byte
型的。)
反向压缩的问题实际上才是本次实验的重点,在谷歌上查阅到了一本参考书籍为我们提供了思路。
首先我们说一下反向压缩的原因:主要原因是我们使用了int来表示编码,而一个int
本身占用4个字节,根据编码的结果看来,最高只有9万多,这意味着有一整个字节被完全浪费,还有一个字节处于极少使用的情况。
我们可以使用更小的变量类型,例如两个字节的short
,但是如果不改变实现方法,那显然又会有不够用的情况。这里就用到了书中给到的第一个思路:定长码表。我们最初实现的时候码表是无限扩增的,因此需要足够大的位数来表示编码;假设我们给定码表的长度是65536(),那么我们的编码就不会超过short可以表示的范围。
你可能会问码表满了图像还没编码完怎么办呢?重置初始值(只有0-255的编码)。我们应该明确一点,LZW的编码和解码都是按照顺序动态完成的,因此不会存在清空之后对应不上的问题。
当然至此问题还是没有解决。实际上,图片的不同将会导致LZW编码的最佳码表长度发生变化,使用short
可以实现部分图像的压缩(我们常用的《柿子椒》可以达到最佳压缩),但是对于另一部分效果不明显,甚至依然会出现反向压缩的问题(例如《照相的男人》)。
对此,参考书中提供的第二个思路,也就是最开头我举的那个例子,给了我想法:使用bitset
这样一个模板类型,它可以帮你建立一个位数自定义的变量类型,从而实现一个更灵活的LZW编码。不过也不是没有问题,bitset
是一个固定长的变量,也就是说你只可以定义为类似bitset<10>
,而不可以定义为bitset<n>
,这样带来的坏处就是我们必须重新编译才可以得到用不同位数进行LZW编码的结果。你可以尝试用vector<bool>
来解决这个问题。
一、实验内容
将⼀幅给定的灰度图象进行编解码。编解码方法可以是算术编解码、LZW 编解码方法中的⼀种。
又是一个字数很短的要求,往往越是这样越难切入。这一次,我将LZW编解码的相关内容写成了一个类主要之前写高精度习惯了,并且在编解码过程中调用了部分实验三中编写的函数,如果你对那些部分有疑问可以看我对于赫夫曼编码的博客。
二、代码实现与分析
1、LZW类定义
class LZW //利用LZW算法进行编码或解码
{
public:
LZW(); //构造函数,初始化私有成员
~LZW();
void printDict(); //输出字典
void outputDict(const char*); //将字典写到文件里
void decoding(vector<bitset<16>>); //解码函数
void encoding(string); //编码函数
vector<bitset<16>> get_encode_result();
string get_decode_result(); //获取解码结果
void dict_reset(); //当字典满了之后需要重置编码表(清空)
bool dict_is_full(); //判断字典满了
void decode_table_reset(map<bitset<16>, string, bitset_comparer<16>>&); //解码表重置
private:
map<string, bitset<16>> dictionary; //编码用的字典
string last_code; //用来记录上次编解码的字符
int code_value; //新增加编码的值
int dic_size; //字典最大容量512,满了之后清空
vector<bitset<16>> encode_result; //用来保存编码结果
string decode_result; //解码结果
};
1.1 私有成员
一个类的定义过程应该是先明确私有成员,再不断添加相关方法。我们编码解码需要一个字典,因此用一个map
来实现。根据之前给过的参考博客中的写法,我们需要last_code
来保存上一次的编解码内容。根据我们改进后的算法,需要给字典一个长度计数dic_size
,方便判断何时重置。当然还有每次加入的新编码的值code_value
。
至于把编解码结果加入到私有成员中,这只是我的编码喜好,方便debug的时候检查。实际上在函数中返回一个变量就可以了,也许还有助于减小内存开销。
如果你仔细观察,会发现解码的时候用的map
有三个参数,即map<bitset<16>, string, bitset_comparer<16>>
。这是因为bitset
本身是没有对operator<
进行重载的,这导致它不能作为map
的key值(因为map
是一个红黑树结构)。所以第三个参数是我自己写的对于小于运算的重载。
1.2 公有成员
下面对构造函数、编码、解码函数进行解释,其他的辅助功能很容易实现。
1.2.1 构造函数
LZW::LZW()
{
int i;
char c; //这里一定要用一个char来表示,因为char的一字节大小恰好可以表示0-255,而to_string(int)生成的是字符串,将使我们无法还原编码
string key = "";
bitset<16> value;
for (i = 0; i < 256; i++) //初始化0-255像素值的编码
{
key = "";
c = i;
key += c; //这里一定要使用空string拼接c,如果用c+""就会错误
value = i;
dictionary.insert(make_pair(key, value));
}
last_code = "";
code_value = 256;
dic_size = 256; //初始大小为256
}
从这里开始我就疯狂踩坑了,首先是key值的赋予。最初的时候我使用to_string()
这个函数,但是这样是得到一个字符串,例如to_string(128)
的结果就是"128"
,这样就会最终造成我们无法解码的问题。
用一个char
来转换的解决这个问题,之前也说过了。但是由于我们后需要合并像素值,所以肯定还是要用string
类型来做,所以就涉及到一个char
和string
的转换问题。
我想当然的认为可以用如下的表达式进行转换:c+""
,然后正如下图给出的测试,是不行的。。。
所以还是乖乖地用一个临时变量吧!
1.2.2 LZW编码
void LZW::encoding(string s)
{
string::iterator s_it; //*s_it是一个char
last_code = ""; //初始状态,上一次编码置空
string null = "";
bitset<16> value;
for (s_it = s.begin(); s_it != s.end(); s_it++)
{
if (dict_is_full()) //字典满了就重置
{
dict_reset();
}
if (dictionary.find(last_code + *s_it) != dictionary.end())
last_code = last_code + *s_it; //直到找到最长前缀,进入else
else
{
encode_result.push_back(dictionary[last_code]);
value = code_value++;
dictionary.insert(make_pair(last_code + *s_it, value));
dic_size++;
last_code = null + *s_it;
}
}
if (last_code != "") //最后一次的输出
{
encode_result.push_back(dictionary[last_code]);
}
}
再次放上参考博客的链接,我这里只不过是把博客中的java代码改写成了c++而已,整个处理的过程没有任何变动。说一下dict_reset()
,这个函数是将我们的码表重置到构造函数中新建的那样,包括code_value
和dic_size
都要重置,但是last_code
不重置,不重置,不重置!
1.2.3 LZW解码
void LZW::decoding(vector<bitset<16>> s)
{
map<bitset<16>, string, bitset_comparer<16>> decode_table; //建立一个键值互换的表用来加速解码
decode_table_reset(decode_table);
last_code = "";
string c = ""; //c for current,当前解码字符
bitset<16> key;
vector<bitset<16>>::iterator it;
for (it = s.begin(); it != s.end(); it++)
{
if (dic_size % 65536 == 0) //注意这个数值是pow(2,n),n是bitset位数
{
decode_table_reset(decode_table);
}
if (decode_table.find(*it) != decode_table.end())
c = decode_table[*it];
else
{
if ((*it).to_ulong() == code_value) //当出现连续的同字符编码,可能会超过字典已有范围
c += c[0];
else
{
cout << "当前编码:" << (*it).to_ulong() << endl;
cout << "code_value:" << code_value << endl;
cout << "编码错误" << endl;
exit(1);
}
}
if (last_code != "")
{
key = code_value++;
decode_table.insert(make_pair(key, last_code + c[0]));
dic_size++;
}
decode_result += c;
last_code = c;
}
}
解码的时候我们需要一个键值互换的map
来实现,这里我开头也提到了,bitset
本身是不能作为key值构造字典的,需要重载小于运算符,具体实现见文末的传送门。
2、编码调用函数
上面类定义中我们完成了编解码的运算部分,但我们还需要解决那两个函数的形参获取问题,以及文件写入、读取问题。因此下面分别写出编码调用函数和解码调用函数
bool LZWEncode(BMPFILE &src, const char* cFilename)
{
LZW encode_lzw;
string encode_data = "";
string null = "";
char c;
int i, j;
for (i = 0; i < src.imagew; i++)
for (j = 0; j < src.imageh; j++)
{
null = "";
c = src.pDataAt(i)[j];
encode_data += null + c;
}
encode_lzw.encoding(encode_data);
vector<bitset<16>> encode_result = encode_lzw.get_encode_result();
if (!writeHeader(src, cFilename))
{
cout << "bmp头部写入失败!" << endl;
return false;
}
else
{
cout << "bmp头部写入成功!" << endl;
}
string result = ""; //由于bitset直接写入文件是按照ASCII码,因此我们要调用writeBit才行
vector<bitset<16>>::iterator it;
for (it = encode_result.begin(); it != encode_result.end(); it++)
{
result += (*it).to_string();
}
if (!writeBit(result, cFilename))
{
cout << "像素数据写入异常!" << endl;
return false;
}
else
{
cout << "像素数据写入成功!" << endl;
return true;
}
}
编码的数据还是比较容易获得的,我们遍历一次图像,将像素值转换成char
后合并成一个大字符串即可。
文件头、信息头、调色板写入文件和上一次实验中一致,直接调用函数即可。但是这一次我们的像素数据编码结果是一个bitset
动态数组,对于32位及以下的bitset
变量,其占用空间为4字节,而不是我们定义的bit数。因此我们需要将这些bitset
再转换成'1' '0'
字符串,然后调用writeBit
将其按位写入文件。
3、解码调用函数
bool LZWDecode(HXLBMPFILE &des, const char* cFilename)
{
LZW decode_lzw;
if (!readHeader(des, cFilename)) //首先读取头部
{
cout << "bmp头部读取失败!" << endl;
return false;
}
else
cout << "bmp头部读取成功" << endl;
FILE *f;
if ((f = fopen(cFilename, "r+b")) == NULL)
{
cout << "无法打开文件!" << endl;
return false;
}
fseek(f, sizeof(BITMAPFILEHEADER) + sizeof(BITMAPINFOHEADER) + 256 * sizeof(RGBQUAD), SEEK_SET); //指向像素数据包
char buffer;
int flag;
string read_string = "";
while ((flag = fread(&buffer, sizeof(char), 1, f) != 0))
{
read_string += buffer; //我们先将编码数据读入一个字符串
}
fclose(f);
string::iterator it;
string binary_data = "";
for (it = read_string.begin(); it != read_string.end(); it++)
{
binary_data += Byte2Binary(*it); //然后把每一位(char)转换成二进制,再合并成一个字符串
}
read_string.clear();
int i, j = 15; //这里j的取值需要根据bitset<>的大小决定
string code = "";
vector<bitset<16>> decode_data;
for (i = 0; i < binary_data.size(); i++)
{
code += binary_data[i];
j--;
if (j < 0)
{
bitset<16> temp(code);
decode_data.push_back(temp); //把二进制组成的字符串,按位数转成bitset类型,构成待解码数据
code = "";
j = 15;
}
}
binary_data.clear();
decode_lzw.decoding(decode_data);
string decode_result = decode_lzw.get_decode_result();
string::iterator result_it = decode_result.begin();
for (int i = 0; i < des.imagew; i++)
for (int j = 0; j < des.imageh; j++)
{
des.pDataAt(i)[j] = *result_it; //将像素值写入
result_it++;
}
des.SaveBMPFILE("result.bmp");
return true;
}
解码的形参变量获取稍微需要一点转换:
考虑到我们定义的bitset
变量位数不一定恰好能够按字节读取出来(比如bitset<9>
,第一个字节有8bit,下一个字节还有1个bit,直接fread是读取不了的),因此我们首先一个字节一个字节的将全部像素数据读取出来,并合并到一个字符串中,read_string
。
然后,将这个字符串中的每个字符,调用Byte2Binary
这个上次实验中编写的函数,转换为二进制后,再次合并到一个字符串中,binary_data
。
最后对这个字符串进行遍历,每次取出你定义位数的子串构造一个bitset
对象,添加到解码数据组中。
解码结束之后,只需要一个char
对应一个像素值的赋值即可。
结语
LZW编码的原理简单易懂,实现并不复杂。再加上,上一次实验中编写了许多有关输入输出编码的函数,因此总体来说本次实验的编程负担并没有上次重。
但是这不意味着很轻易就能够实现,我花费了三天从一个又一个坑中跳出来,这种debug的经验是一种收获。
本次实验完整的项目文件与代码可见我的gitee。
上一篇: 项目——基于YUV色域转换和LZW编码的BMP图像压缩
下一篇: LZW编码实现(C)