一、前言

  在上一篇博文中,我们初步介绍了本系列唯一的 DEMO —— “纯粹的线条”,并在文末讨论了实现此 DEMO 所需要走的两步:(1)设计自己的图像处理类(2)了解必要的 MFC 知识。本篇博文就是围绕第一步展开:首先讨论了图像处理中为什么要使用类,进一步->为什么要设计自己的类,最后讨论了核心类 CImg 的设计与实现,以及 CImg 的子类、封装各类算法的 CImgProc 类的设计,为最后的一篇博文(Demo 的实现)打下基础。

二、为什么要有类  

  对图像处理进行编程,必然要考虑到两个元素:图像数据 和 相关的处理算法。图像数据的存储方式当然是二维字符数组,一般我们会先在内存中开辟出一段区间存放图像数据,然后使用指向此区间首地址的指针来操作这些数据。而图像处理算法,则是针对图像数据的处理,然后把处理后的图像数据(二维字符数组)保存在内存的另一段区间中。

  下面是 OpenCV 1.0 中的一段典型的图像处理算法函数:

void cvDilate( const CvArr* src, CvArr* dst, IplConvKernel* element=NULL, int iterations=1 );

  其中参数 src 和 dst 自然表示着原图像和目标图像的存储区间首地址指针了。这样做直观自然,但是缺点也不少,用起来比较混乱,各种指针、各种算法子函数到处都是。有了类之后,我们就可以充分利用它的封装性,把图像数据与操作数据的算法函数组织在一起,不仅使程序结构更加紧凑,并且提高了类内部数据的安全性。现在最新版的 OpenCV 2.2 增加了对 C++ 的兼容,这也说明在图像处理中应用面向对象思想是不错的选择了。

三、 为什么要有自己的类

  接下来我们说说为什么要有自己的类。C# 提供有 Bitmap,功能自然比较强大(在不考虑它的速度的前提下...)。MFC 提供的 CImage,封装了很多绘图功能和图像操作功能,“但仍然没有提供完整的图像处理能力”。(这句话是参考书作者说的,笔者还没有亲身体会到...所以先打上引号...)

  再说了,用封装好的类,感觉总是隔了一层,不了解它的底层实现机制,用起来感觉挺不爽的。这种情况还是自己造造轮子吧~ 下面我们参考《数字图像处理与机器视觉》一书,逐步设计出具备基本功能的图像处理类 CImg。

四、构造 CImg 头文件

  在 C++ 中设计的类,需要把它塞进 .h 和 .cpp 文件里去,当然要考虑类的成员分布问题。下面是一种比较好的设计风格:

  • 头文件(.h):包括类的定义、类的成员数据、类的成员函数的声明、内联函数的实现
  • 源文件(.cpp):类中成员函数的实现

  下面直接给出头文件中的所有代码,然后再慢慢解释:

#ifndef _IMG_H_
#define _IMG_H_

// 在计算图像大小时,采用公式:biSizeImage = biWidth' × biHeight。
// 是biWidth',而不是biWidth,这里的biWidth'必须是4的整倍数,表示
// 大于或等于biWidth的,离4最近的整倍数。WIDTHBYTES就是用来计算 biWidth'
#define WIDTHBYTES(bits) (((bits) + 31) / 32 * 4)

class CImg
{
//构造、析构函数
public:

//构造函数
CImg();

//析构函数
virtual ~CImg();

//成员变量
public:

// 图像信息结构体指针
BITMAPINFOHEADER *m_pBMIH;
// 图像数据指针
BYTE **m_lpData;

protected:
int m_nColorTableEntries;
LPVOID m_lpvColorTable;

//成员函数
public:

//判断位图是否有效
BOOL IsValidate();

// 从文件加载位图
BOOL AttachFromFile(char* filePath);
// 将位图保存到文件
BOOL SaveToFile(char* filePath);

// 在DC上绘制位图
BOOL Draw(CDC* pDC);

// 设置像素的值
void SetPixel(int x, int y, COLORREF color);
// 获取像素的值
COLORREF GetPixel(int x, int y);
// 获取灰度值
BYTE GetGray(int x, int y);

// 获取行的像素数
int GetWidthPixel();
// 获取列的像素数
int GetHeightPixel();
// 获取一行的字节数
int GetWidthByte();

private:

//释放图像所占用的空间
void CleanUp();
};

/**************************************************
inline int CImg::GetWidthPixel()

功能:
返回CImg实例中的图像每行的像素数

参数:

返回值:
int类型,返回图像每行的像素数
**************************************************
*/
inline
int CImg::GetWidthPixel()
{
return m_pBMIH->biWidth;
}

/**************************************************
inline int CImg::GetHeightPixel()

功能:
返回CImg实例中的图像每列的像素数

参数:

返回值:
int类型,返回图像每列的像素数目
**************************************************
*/
inline
int CImg::GetHeightPixel()
{
return m_pBMIH->biHeight;
}

/**************************************************
inline int CImg::GetWidthByte()

功能:
返回CImg实例中的图像每行占用的字节数
限制:


参数:

返回值:
int类型,返回图像每行占用的字节数
**************************************************
*/
inline
int CImg::GetWidthByte()
{
return WIDTHBYTES((m_pBMIH->biWidth)*m_pBMIH->biBitCount);
}

/**************************************************
inline SetPixel(int x, int y, COLORREF color)

功能:
设置指定像素的颜色

参数:
int x
指定像素的横坐标
int y
指定像素的纵坐标
COLORREF
RGB颜色值
返回值:

**************************************************
*/
inline
void CImg::SetPixel(int x, int y, COLORREF color)
{
if(m_pBMIH->biBitCount == 24) //真彩色位图
{
m_lpData[m_pBMIH
->biHeight - y - 1][x*3] = GetBValue(color);
m_lpData[m_pBMIH
->biHeight - y - 1][x*3 + 1] = GetGValue(color);
m_lpData[m_pBMIH
->biHeight - y - 1][x*3 + 2] = GetRValue(color);
}
}

/**************************************************
inline COLORREF CImg::GetPixel(int x, int y)

功能:
返回指定坐标位置像素的颜色值

参数:
int x
指定像素的横坐标
int y
制定像素的纵坐标

返回值:
COLERREF类型,返回用RGB形式表示的指定位置的颜色值
**************************************************
*/
inline COLORREF CImg::GetPixel(
int x, int y)
{
if(m_pBMIH->biBitCount == 24) //真彩色位图
{
COLORREF color
= RGB(m_lpData[m_pBMIH->biHeight - y - 1][x*3 + 2],
m_lpData[m_pBMIH
->biHeight - y - 1][x*3 + 1],
m_lpData[m_pBMIH
->biHeight - y - 1][x*3]);
return color;
}
else
{
throw "not support!";
return 0;
}
}


/**************************************************
inline BYTE CImg::GetGray(int x, int y)

功能:
返回指定坐标位置像素的灰度值
限制:

参数:
int x
指定像素的横坐标
int y
制定像素的纵坐标
返回值:
BYTE类型,返回给定像素的灰度值
**************************************************
*/
inline BYTE CImg::GetGray(
int x, int y)
{
COLORREF color
= GetPixel(x, y);
BYTE r, g, b,
byte;

r
= GetRValue(color);
g
= GetGValue(color);
b
= GetBValue(color);

double dGray = (0.299*r + 0.578*g + 0.114*b);

byte = (int)dGray;

return byte;
}

#endif // _IMG_H_

(1)构造和析构函数

  一个类,当然要有构造和析构函数了:

//构造函数
CImg();

//析构函数
virtual ~CImg();

(2)数据成员

  第一个数据成员自然是图像数据了,这里具体则是指向二维数组首地址的指针:  

// 图像数据指针
BYTE **m_lpData;

  然后我们需要一些数据成员来保存图像的一些信息,比如宽度、高度、图像类型等等。这里我们使用了 MFC 提供的结构体 BITMAPINFOHEADER(对于它的全部成员,可以查看 MSDN 详细介绍)。在类中,我们也没有直接定义结构体,而是定义的指向此类结构体的指针:

// 图像信息结构体指针
BITMAPINFOHEADER *m_pBMIH;

  至于其他两个私有类型变量,m_nColorTableEntries 表示颜色索引表中的颜色个数,而 m_lpvColorTable 指向了颜色索引表的地址。但 24 位真彩色并不需要颜色索引表,这里保留它们是为了方便以后对非 24 位真彩色图像处理时的扩充。

(3)有效性判断

  在对图像数据进行操作之前,首先要判断此图像是否有效:

//判断位图是否有效
BOOL IsValidate();

(4)读取/保存图像

  我们在进行图像处理时,一般是针对保存在本地的图片进行的,所以必须要有读取/保存图像的功能:

// 从文件加载位图
BOOL AttachFromFile(char* filePath);
// 将位图保存到文件
BOOL SaveToFile(char* filePath);

  P.S. 为简化读取复杂度,在本系列博文中我们仅使用 24 位真彩色 BMP 类型的图片作为源图像。

(5)设置/获取像素的 RGB 值,获取像素的灰度值

  图像处理的操作,归根到底还是对每一像素的操作,所以设置/获取某像素的 RGB 值的操作也必不可少:

// 设置像素的值
void SetPixel(int x, int y, COLORREF color);
// 获取像素的值
COLORREF GetPixel(int x, int y);

  同时,获取某一像素的灰度值也是比较常用的操作,我们也就集成到 CImg 类中了:

BYTE GetGray(int x, int y);

  大家可以发现,以上三个函数的实现都是在 .h 文件中,而不是在 .cpp 文件中,而且函数前面还多了 inline 这个声明。这就是 C++ 中的所谓内联函数了,它的作用和宏定义非常类似,编译器在编译此段时,就是将它在每个调用点 “内联” 地展开。那么一般什么样的函数适合设计为内联函数呢?《Google C++ 风格指南》中做了很恰当的说明:

  "当函数体较小的时, 内联该函数可以令目标代码更加高效. 对于函数体比较短, 性能关键的函数, 鼓励使用内联。"

  这三个函数的实现代码都非常小,而且会被经常调用(特别是 SetPixel 函数),因此设计成 inline 函数再好不过了!

(6)获取行/列像素值

  有时候我们希望获取图像的长与宽(单位:px),于是有了下面的两个简单的函数:

// 获取行的像素数
int GetWidthPixel();
// 获取列的像素数
int GetHeightPixel(); 

  同样,把他们设计成内联函数是个不错的选择。

(7)获取每一行的字节数

// 获取一行的字节数
int GetWidthByte();

  读者可能比较奇怪,上面不是已经有了一个 GetWidthPixel 了吗?怎么还来一个? 

  呵呵,注意这个函数返回值的单位不再是像素(px)的个数,而是字节(byte)的个数。那么你也许还会奇怪,既然是24位真彩色图,那么每个像素当然是 3 个字节了(R、G、B),我们直接用 GetWidthPixel() * 3 不就 OK 了么?怎么还需要另外定一个函数?

  注意,在 windows 中有这样一个规则:位图的每一行占用字节数必须是 4 的整数倍,如果不是,则需补齐。这样一说才明白过来吧!原来还缺了 “补齐” 这一步。

  那么怎么补齐?我们首先可以得到每一行图像数据的位数 bits = GetWidthPixel() * 3 * 8,然后考虑到每一行字节数必须是4的倍数的话,那么最小的步长应该就是 8 * 4 = 32 (bits) 了。换句话说,我们有一行有 bits 个位,怎么样补全使之成为 32 的整数倍?

  在程序的头部提供了用于计算字节数的宏:

#define WIDTHBYTES(bits)  (((bits) + 31) / 32 * 4)

  (这里笔者不做过多解释,请读者想一想这算法能起作用吗?如果能的话,可不可以把后面的 /32*4 简化成 /8 ?)

  于是,函数的实现代码如下:

inline int CImg::GetWidthByte()
{
return WIDTHBYTES((m_pBMIH->biWidth)*m_pBMIH->biBitCount);
}
  

  注:当图像为 24 位真彩色图时,m_BMIH->bitBitCount = 24。

(8)在DC(设备上下文)上绘图

  图像处理完了,当然要在某个窗口中显示出来才能看到效果嘛,所以我们需要有个函数来对 MFC 的设备上下文(Device Context)进行图形绘制。对 C# 比较熟悉的同学一定对 Graphic 这个类非常熟悉,要把图像绘制到窗口中,就得对窗口的 Grapgic 对象进行绘制。C# 中的 Graphic 和 MFC 中的 DC,就是差不多的地位了。

// 在DC上绘制位图
BOOL Draw(CDC* pDC);

(9)资源清理

  在类的最后我们可以发现一个私有函数 CleanUp:

void CleanUp();

  看名字就可以知道它是干啥的了——清理类中所申请的内存资源。现在我们不需要知道它的具体实现,只要知道它的用途就行,等到下面介绍 .cpp 文件时再详细解释。

五、构造 CImg 源文件

  将类放在 .h 和 .cpp 两个文件的好处之一,是可以提供不同层次的服务。如果你不想了解底层实现细节,OK,那么只需要看 .h 文件就行,它会提供所有数据成员、成员函数的声明,你就可以轻松地使用这个类了。但如果你还想了解具体的函数实现,那就需要在 .cpp 文件中仔细分析代码了。

  下面给出 CImg.cpp 的全部源代码:

#include "stdafx.h"
#include
"Img.h"

#ifdef _DEBUG
#define new DEBUG_NEW
#undef THIS_FILE
static char THIS_FILE[] = __FILE__;
#endif

/**************************************************
CImg::CImg()

功能:
CImg类的构造函数

参数:


返回值:

**************************************************
*/
CImg::CImg()
{
m_pBMIH
= NULL;

m_lpData
= NULL;
}

/**************************************************
CImg::~CImg()

功能:
CImg类的析构函数

参数:

返回值:

**************************************************
*/
CImg::
~CImg()
{
CleanUp();
}

/**************************************************
void CleanUp()

功能:
释放图像信息结构体的空间、释放图像数据占用的空间
相当于擦除图像数据

参数:

返回值:

**************************************************
*/
void CImg::CleanUp()
{
if(m_lpData != NULL)
{
for (int i; i<m_pBMIH->biHeight; i++)
{
delete[] m_lpData[i];
}
delete[] m_lpData;
}

if(m_pBMIH != NULL)
{
delete[] m_pBMIH;
}
}

/**************************************************
BOOL CImg::IsValidate()

功能:
判断位图是否有限

参数:

返回值:
BOOL类型,TRUE表示有效,FALSE表示无效
**************************************************
*/
BOOL CImg::IsValidate()
{
return m_pBMIH != NULL;
}

/**************************************************
BOOL AttachFromFile(char* filePath)

功能:
从文件中读取位图数据

参数:
char* filePath
文件路径
返回值:
BOOL类型,返回是否读取成功
**************************************************
*/
BOOL CImg::AttachFromFile(
char* filePath)
{
// 使用CFile对象简化操作
CFile file;
if(!file.Open(filePath, CFile::modeRead|CFile::shareDenyWrite))
return FALSE;

// 文件数据
LPBYTE *lpData;
// 位图信息头
BITMAPINFOHEADER *pBMIH;
// 颜色表指针
LPVOID lpvColorTable = NULL;
// 颜色表颜色数目
int nColorTableEntries;

BITMAPFILEHEADER bmfHeader;

// 读取文件头
if(!file.Read(&bmfHeader, sizeof(bmfHeader)))
return FALSE;


// 检查开头两字节是否为BM
if(bmfHeader.bfType != MAKEWORD('B', 'M'))
{
return FALSE;
}

// 读取信息头
pBMIH = (BITMAPINFOHEADER*)new BYTE[bmfHeader.bfOffBits - sizeof(bmfHeader)];
if(!file.Read(pBMIH, bmfHeader.bfOffBits - sizeof(bmfHeader)))
{
delete pBMIH;
return FALSE;
}

// 定位到颜色表
nColorTableEntries =
(bmfHeader.bfOffBits
- sizeof(bmfHeader) - sizeof(BITMAPINFOHEADER))/sizeof(RGBQUAD);
if(nColorTableEntries > 0)
{
lpvColorTable
= pBMIH + 1;
}

pBMIH
->biHeight = abs(pBMIH->biHeight);

// 读取图像数据,WIDTHBYTES宏用于生成每行字节数
int nWidthBytes = WIDTHBYTES((pBMIH->biWidth)*pBMIH->biBitCount);

// 申请biHeight个长度为biWidthBytes的数组,用他们来保存位图数据
lpData = new LPBYTE[(pBMIH->biHeight)];
for(int i=0; i<(pBMIH->biHeight); i++)
{
lpData[i]
= new BYTE[nWidthBytes];
file.Read(lpData[i], nWidthBytes);
}

// 更新数据
CleanUp();

m_lpData
= lpData;
m_pBMIH
= pBMIH;
m_lpvColorTable
= lpvColorTable;
m_nColorTableEntries
= nColorTableEntries;

file.Close();

return TRUE;
}

/**************************************************
BOOL SaveToFile(char* filePath)

功能:
将位图数据保存到文件中

参数:
char* filePath
文件路径
返回值:
BOOL类型,返回是否保存成功
**************************************************
*/
BOOL CImg::SaveToFile(
char* filePath)
{
if(!IsValidate())
return FALSE;

CFile file;
if(!file.Open(filePath, CFile::modeRead|CFile::modeWrite|CFile::modeCreate))
{
return FALSE;
}

// 构建BITMAPFILEHEADER结构
BITMAPFILEHEADER bmfHeader = { 0 };
int nWidthBytes = WIDTHBYTES((m_pBMIH->biWidth)*m_pBMIH->biBitCount);


bmfHeader.bfType
= MAKEWORD('B', 'M');
bmfHeader.bfOffBits
= sizeof(BITMAPFILEHEADER)
+ sizeof(BITMAPINFOHEADER) + m_nColorTableEntries*4;

bmfHeader.bfSize
= bmfHeader.bfOffBits + m_pBMIH->biHeight * nWidthBytes;

// 向文件中写入数据
file.Write(&bmfHeader, sizeof(bmfHeader));
file.Write(m_pBMIH,
sizeof(BITMAPINFOHEADER) + m_nColorTableEntries*4);

for(int i=0; i<m_pBMIH->biHeight; i++)
{
file.Write(m_lpData[i], nWidthBytes);
}

file.Close();

return TRUE;
}

/**************************************************
BOOL Draw(CDC* pDC)

功能:
在给定的设备上下文环境中绘制图像

参数:
CDC* pDC
指定的设备上下文环境指针
返回值:
BOOL类型,TRUE为成功,FALSE为失败
**************************************************
*/
BOOL CImg::Draw(CDC
* pDC)
{
if(m_pBMIH == NULL)
return FALSE;

for(int i=0; i<m_pBMIH->biHeight; i++)
{

::SetDIBitsToDevice(
*pDC, 0, 0, m_pBMIH->biWidth,
m_pBMIH
->biHeight, 0, 0, i, 1, m_lpData[i], (BITMAPINFO*)m_pBMIH, DIB_RGB_COLORS);
}

return TRUE;
}

以上函数的实现代码都有详细的注释,就不做全面的解释了,下面仅挑出两点来说明一下:

(1)析构函数

  我们并没有使用默认的析构函数,而是在其中加了 CleanUP 这个函数,那么 CleanUp 函数所起到的作用到底是什么呢?我们先来看看它的源代码:

 1 void CImg::CleanUp()
2 {
3 if(m_lpData != NULL)
4 {
5 for (int i; i<m_pBMIH->biHeight; i++)
6 {
7 delete[] m_lpData[i];
8 }
9 delete[] m_lpData;
10 }
11
12 if(m_pBMIH != NULL)
13 {
14 delete[] m_pBMIH;
15 }
16 }

  3-10行为第一部分:使用 delete[] 释放图像数据所占用的内存空间;12-15行为第二部分:使用 delete[] 释放图像信息结构体所占用的内存空间。既然说要释放,那么之前总应该有申请内存的语句吧,但是在构造函数中,并没有使用关键字 new 啊?

CImg::CImg()
{
m_pBMIH
= NULL;

m_lpData
= NULL;
}

  其实,在创建了空的 CImg 对象后,我们马上会调用 AttachFromFile() 函数,使用特定的本地图像文件对其数据成员进行初始化,观察 AttachFromFile() 函数可以发现这两段代码:

pBMIH = (BITMAPINFOHEADER*)new BYTE[bmfHeader.bfOffBits - sizeof(bmfHeader)];
// 申请biHeight个长度为biWidthBytes的数组,用他们来保存位图数据
lpData = new LPBYTE[(pBMIH->biHeight)];
for(int i=0; i<(pBMIH->biHeight); i++)
{
lpData[i]
= new BYTE[nWidthBytes];
file.Read(lpData[i], nWidthBytes);
}

  呵呵,发现了吧,在对图像信息结构体 pBMIH 和 图像数据 lpData 初始化时,都使用了 new 动态申请了内存空间,这样我们在析构的时候,自然要先把这些手动申请的空间还给系统了,以免引起内存泄露。

(2)读取/保存图像文件

  大家看到源代码中的读取图像(AttachFromFile)函数 和 保存图像(SaveToFile)函数时,或许会感觉比较复杂,理解它们需要对 BMP 图像文件的存储结构有一定的了解。如果你不太想了解底层原理,可以直接把这两个函数 copy 过去用就行。但如果真的想知道轮子是怎么造出来的,建议去看园子里的 这一篇 博文,讲得非常详细,这里就不再赘述了。

  弄懂了 BMP 文件的存储结构,相信再去看 AttachFromFile 和 SaveToFile 这两个函数就会非常轻松了~

六、构造 CImgProc

  之所以构造出这个类来,是出于两方面的考虑(1)让 CImg 类封装图像的数据成员和一些最基本的处理函数,而把所有的特定算法的实现都封装到 CImgProc 类中,这样一来使 “数据” 和 “逻辑” 分离,从而使程序的层次结构更加清晰(2)使 CImgProc 公共地继承自 CImg,那么自然就继承了 CImg 中的数据和成员函数了,这样我们在实现算法时,只需使用 CImgProc 就够了,非常方便。

  下面给出 CImgProc 类的头文件:

#ifndef _IMGPROC_H
#define _IMGPROC_H

#include
"Img.h"

class CImgProc : public CImg
{
//构造与析构函数
public:
//构造函数
CImgProc();
//析构函数
virtual ~CImgProc();

//各种图像算法
public:
// 灰度阈值变换
void Threshold(CImgProc *pTo, BYTE nThres);

};
//class CImgProc

#endif//_IMGPROC_H_

  头文件中仅额外申明了一个算法子函数,那就是 Demo 中需要使用到的 “灰度阈值变换算法” —— Threshold 了。以后若需要添加其他的算法的话,只要在头文件中声明,然后在源文件中实现就OK~ 下面给出 CImgProc 类的源文件代码:

#include "stdafx.h"
#include
"ImgProc.h"

#ifdef _DEBUG
#undef THIS_FILE
static char THIS_FILE[]=__FILE__;
#define new DEBUG_NEW
#endif

// 构造函数
CImgProc::CImgProc()
{
}

//析构函数
CImgProc::~CImgProc()
{
}

/**************************************************
void CImgProcess::Threshold(CImgProc *pTo, BYTE bThre)

功能:
图像的阈值变换

参数:
CImgProc * pTo
输出CImgProc对象的指针
BYTE bThre
设置的基准阈值
返回值:

**************************************************
*/
void CImgProc::Threshold(CImgProc *pTo, BYTE bThre)
{
int i, j;
BYTE bt;
for(j = 0; j<m_pBMIH->biHeight; j ++)
{
for(i=0; i<m_pBMIH->biWidth; i++)
{
bt
= GetGray(i, j);
if(bt<bThre)
bt
= 0;
else
bt
= 255;

pTo
->SetPixel(i, j, RGB(bt, bt, bt));
}
}
}

七、结语

  本篇博文将面向对象的思想引入 Vc 平台下的图像处理中,逐步构建了 图像类 CImg 及其用于实现各类图像算法的派生子类 CImgProc,需要源文件的童鞋可以 点击此处 下载。我们已经走完了第一步,下一步(也是本系列博文的最后一篇),将会在 MFC 下利用我们设计的类来完成关于 “灰度阈值变换的” Demo。敬请期待系列博文之:

  swicth ( VcImageProc ) case 4:Demo 的实现,基于MFC

P.S. 粘贴代码时勾选了折叠代码选项,结果浏览时点击+号不能展开... 不知道是哪出了问题...

作者: 鹏程 发表于 2011-08-04 20:17 原文链接

推荐.NET配套的通用数据层ORM框架:CYQ.Data 通用数据层框架
新浪微博粉丝精灵,刷粉丝、刷评论、刷转发、企业商家微博营销必备工具"