OpenCV-5-文件和UI操作

1 前言

OpenCV中用于和操作系统、文件系统及相机等硬件交互的函数被包含在模块HighGUI(high-level graphics user interface)中。HighGUI划分为硬件、文件系统和GUI三部分。

总的来说该模块能够读取或者写入如图像和视频文件,打开并管理窗口,显示图片,处理简单的鼠标和键盘事件。也可以用于创建有用的控件,如创建一个滑动条并添加到窗口中。当然如果你熟悉当前平台原生的图形界面系统,也可以直接使用原生接口。

硬件部分主要处理和相机相关的任务,在大多数操作系统中,相机交互都是一个繁琐和痛苦的任务。

文件系统部分主要负责加载和存储图片及视频,OpenCV一个不错的设计方式是我们可以使用和从相机中读取数据的方法去读取一个视频文件。类似的OpenCV提供了一组相当通用的函数用于读取和存储图片,这些方法通过文件内部数据决定需要读取的文件类型,并自动正确的处理图像编解码逻辑。另外OpenCV还提供一组基于XML或YML的函数从而很方便的以一种简单,可读的,基于文本的方式去载入和存储OpenCV原生类型数据。

GUI部分提供了一些简单的函数用于创建窗口,及将图片显示到某个窗口中,同时也负责处理这个窗口中的简单的鼠标和键盘事件。通过链接Qt库(一个跨平台的窗口工具包),还可以实现更丰富的功能。

在OpenCV3.0以后,HighGUI核心功能被拆解为imgcodecs(图像编解码)、videoio(视频及图像捕获和视频编解码)和highgui(用户界面)三个模块,其XML和YML文件操作部分被划分到Core模块中。

2 文件操作

OpenCV提供了一系列用于图像加载和保存的函数,这些函数在很多方面都和通用的基于XML或者YAML数据的函数存在差异。主要区别是前者依赖于图像编码和解码算法。其中一些有损算法会丢失部分图像信息,这对图像而言是可以接受的,但是对于非图像数据如参数数据是不能够接受的。另外需要注意的是有损图像算法造成的肉眼难以观测的瑕疵可能会给视觉算法造成影响。

2.1 图像

函数cv::imread()cv::imwrite()用于从磁盘中加载图片资源,或者将图片写入到磁盘中,这两个方法内部包含图片的编解码逻辑,也包含和文件系统的交互。

2.1.1 载入图片

载入图片的函数原型如下。如果图像加载失败将会返回一个空数组,可以通过矩阵的成员函数cv::Mat::empty() == true判断。

// filename:需要载人文件的绝对路径
// flags:图片文件的识别标记,具体取值见下表
cv::Mat cv::imread(const string& filename, int flags = cv::IMREAD_COLOR);

该函数会不关心文件路径中包含的文件扩展名,而是分析文件中前几个字节的内容(被称为签名Signature或者魔法序列Magic Sequence)来决定文件的格式,从而使用正确的解码算法。参数flags的所有取值及其含义如下表。

参数flags取值 含义
cv::IMREAD_COLOR 加载为三通道矩阵,如果原图为灰度,则三个通道值都等于灰度值
cv::IMREAD_GRAYSCALE 加载为单通道灰度矩阵,即使原图为彩色图像,都会计算出灰度值加载
cv::IMREAD_ANYCOLOR 加载为文件实际通道数的矩阵,但是不超过3个通道
cv::IMREAD_ANYDEPTH 允许加载位深度为8的图像,默认按8位深度加载图像
cv::IMREAD_UNCHANGED 加载原始图像,可以大于3个通道,可以大于8位深度
2.1.2 写入图片

写入图片的函数原型如下。

// 返回值,图片是否保存成功
// fileName:图片文件写入的位置,需要通过后缀确定文件格式,详细信息见下表
// image:图片数据
// params:文件编码参数,详细信息见下表
bool cv::imwrite(const string& filename, cv::InputArray image,
                 const vector<int>& params = vector<int>());

参数fileName中常用的后缀及其对应的图片编码标准见下表。该函数支持存储整型或者浮点型,单通道、三通道或者四通道的图片。

fileName可用后缀 编码标准 备注
.jpg或.jpeg JPEG baseline 8位位深度,单或者三通道输入
.jp2 JPEG 2000 8或者16位位深度,单或者三通道输入
.tif或.tiff TIFF 8或者16位位深度,单、三或者四通道输入
.png PNG 8或者16位位深度,单、三或者四通道输入
.bmp BMP 8位位深度,单、三或者四通道输入
.ppm或.pgm NetPBM 8位位深度,单通道(PGM)或者三通道(PPM)输入

第三个参数params指定了图像编码时的一些参数,对于不同的图片格式,可以接受的参数是不同的。该参数接收包含整型元素的标准向量,该向量结构为一个标识后紧跟其值,再接下一个标识和值。所有可选的表示以及对应的取值范围和使用条件见下表。

params可用标识 含义 取值区间 范围
cv::IMWRITE_JPG_QUALITY JPEG标准使用的压缩质量 [0, 100] 95
cv::IMWRITE_PNG_COMPRESSION PNG标准的压缩比,值越高表示压缩率更高 [0, 9] 3
cv::IMWRITE_PXM_BINARY 在处理PPM、PGM和PBM标准时是否使用二进制格式存储 0或者1 1
2.1.3 图像编解码

OpenCV涉及到编解码的函数需要底层库的支持,通常情况下大多数平台的操作系统中对于不同的图片格式都至少包含1个可以用的编解码库。另外OpenCV自身也包含一些常用图片格式的编解码库,如JPEG、PNG、TIFF等。对于每一种编码标准等库,你有三种处理方式。第一种是不包含这个库,这意味着你将不能正确的显示和存储这种格式的图片。第二种是使用OpenCV提供的编解码库,那么你需要在编译OpenCV时同时编译这些库。第三种是使用OpenCV外部(如操作系统)提供的库。

在编译Windows环境中的OpenCV时,默认的时第二种选项。在编译OS X或者Linux版本的CV库时,默认使用第三种方案,但是如果CMake未检测到相应的编解码库,则会使用第二种方案。当然你也可以更改CMake的编译配置。需要注意如果在Linux上编译CV库选择了第三种方案,那么需要在编译CV库之前安装对应的编解码库,如libjpeg和libjpeg-dev。

前文讲到的读取和写入图片操作都是一系列操作的集合,有时我们也需要单独使用图像的编解码函数完成一些任务,如处理内存中的数据。图像编码函数cv::imencode可以直接处理矩阵数据,其原型如下。它输出了字节流数据,这并不奇怪,因此经过编码器处理后的数据已经是压缩数据,其大小和格式都和原图不相同。此外参数ext不仅用于推断图片的格式,在很多操作系统中它也用于索引相应的编解码库。

// ext:文件扩展名,用于推断出应该使用的编码器
// img:需要编码的图像数据
// buf:编码后的图像数据,无符号字符数组,该函数会自动分配合适的内存空间
// params:特定编码器所需要的参数字典,取值请参照函数cv::imwrite
void cv::imencode(const string& ext, cv::InputArray img,
                  vector<uchar>& buf, const vector<int>& params = vector<int>());

图像解码函数cv::imdecode()可以从字节流数据解码得到图像数据,其原型如下。

// buf:压缩的图像数据缓存,类型通常为std::vector<uchar>
// flag:解码后得到的图像数据处理策略,见函数cv::imread()说明
cv::Mat cv::imdecode(cv::InputArray buf, int flags = cv::IMREAD_COLOR);

和函数cv::imread()一样,该函数并不需要文件扩展名来推断编码标准,因为压缩的字节流数据的前几个字节包含了使用的编码标准信息。如果传入的参数buf为空,或者包含无效的、错误的数据以及其他异常情况,该函数返回值为空矩阵。

2.2 视频

2.2.1 读取视频数据

cv::VideoCapture用于读取视频数据,它是本系列前面文章中讲到的函数对象概念的一种。它可以从文件中读取或者通过摄像头捕获视频数据,其构造函数如下。

// fileName:视频文件的绝对路径
cv::VideoCapture::VideoCapture(const string& filename);

// device:摄像头硬件的标识
cv::VideoCapture::VideoCapture(int device);

cv::VideoCapture::VideoCapture();

对于第一个构造方法,可以通过成员函数cv::VideoCapture::isOpened()来判断文件是否成功打开。导致文件无法打开的原因除了文件本身不存在外,还可能是无法找到对应的编解码库。由于编解码器涉及到很多计算、专利及法律问题,和编码相关函数无法工作的情况并不像我们希望的那样很少发生。

使用摄像头标识的构造函数不会受到解码器限制,但是需要可用的硬件资源。该参数表示我们希望使用哪个硬件,以及操作系统使用何种方式与相机硬件交互。使用哪个硬件由相机的编号(Identifier)决定,从0递增。而交互的方式由相机域(Domain)表示,可以用的域值见下表。而参数device是相机标识和域的和。

相机域的取值 真实值
cv::CAP_ANY 0
cv::CAP_MIL 100
cv::CAP_VFW 200
cv::CAP_V4L 200
cv::CAP_V4L2 200
cv::CAP_FIREWIRE 300
cv::CAP_IEEE1394 300
cv::CAP_DC1394 300
cv::CAP_CMU1394 300
cv::CAP_QT 500
cv::CAP_DSHOW 700
cv::CAP_PVAPI 800
cv::CAP_OPENNI 900
cv::CAP_ANDROID 1000
... ...

下面的代码片段创建了一个cv::VideoCapture实例,并用其打开第一个FireWire类型的相机。在大多数情况下如果只存在一个相机,则指定默认的域cv::CAP_ANY即可。在某些平台上,该参数可设置为-1,这样会弹出一个窗口用于选择相机。

cv::VideoCapture capture( cv::CAP_FIREWIRE );

使用不带参数的构造函数创建出的cv::VideoCapture实例是不可用的,需要指定相机的资源后才可以使用,指定方式如下。

cv::VideoCapture cap;
// 可以指定文件
cap.open("my_video.avi");
// 可以指定相机
cap.open(cv::CAP_FIREWIRE);
2.2.2 读取视频帧

函数cv::VideoCapture::read可以读取当前的视频帧,并将“标记”向后移动,使得下次再调用该函数时得到的是新的视频帧。其原型如下。

// 返回值:读取是否成功,如读到视频文件末尾后会返回false
// image:读取到的视频帧数据,如果读取失败则为空矩阵
bool cv::VideoCapture::read(cv::OutputArray image);

重载后的运算符>>也可以读取下一帧数据,其原型如下。

// image:读取到的视频帧数据,如果读取失败则为空矩阵
cv::VideoCapture& cv::VideoCapture::operator >> (cv::Mat& image);

前面的两种方式都是一次从文件或者相机中取出一帧解码后的图片,而使用函数cv::VideoCapture::grabcv::VideoCapture::retrieve可以将这个过程分解为抓取(Grab)和解码(Retrieve)两个阶段,其函数原型如下。

// 返回值:是否成功获取到原始数据
bool cv::VideoCapture::grab();

// image:读取到的图像数据
// channel:硬件设备的传感器的索引,对于立体成像摄像机很常见,目前仅支持Kinect和Videre相机
// 此时该参数表示处理哪个传感器拍摄到的图片
// 对于支持多传感器的情况,可以调用一次cv::VideoCapture::grab()函数
// 多次bool cv::VideoCapture::retrieve函数
bool cv::VideoCapture::retrieve(cv::OutputArray image, int channel = 0);

函数cv::VideoCapture::grab会将当前未处理的数据拷贝至一个中间缓存,这部分缓存是外部不可见的。但是这样做是有原因的,将读取视频帧分为提取原始数据和解码数据两步也是很有必要的。最常见的情况就是需要处理多个相机数据时,如需要合成立体图。在这种情况下,缩短各个相机获得图像的时间差就变得很重要,最理想的情况下所有相机采集的都是同一时刻的图像。因此将原始数据采集和编解码操作分开就很有意义,可以使得各个相机尽可能的同步。

当获取到原始数据后,函数bool cv::VideoCapture::retrieve就可以处理这些原始数据,并且分配内存空间,做必要的数据拷贝,最终将处理好的图像数据用cv::Mat实例的方式返回。

2.2.3 操作元数据

视频文件不仅包含视频数据,还包含重要的元数据,这部分数据对于正确的处理视频文件是必不可少的。当视频文件被打开后,这部分信息被拷贝到cv::VideoCapture实例内部数据区。负责读取和写入这部分元数据以及其他属性的函数原型如下。

// propid:属性标识,详细信息见下表
double cv::VideoCapture::get(int propid);

// 返回值:是否设置成功,该方法只能设置部分属性,更多的细节再后面详细介绍视频编解码时再展开
// value:需要设置的值
bool cv::VideoCapture::set(int propid, double value);

可以操作的属性见下表,需要注意的是并不是所有被OpenCV识别的属性都能够被处理,这取决于具体的场景是否包含了这部分信息。同样的也不是所有存在的属性都被OpenCV所支持,因为技术和标准总是在不断更新的。

视频捕获属性 是否只支持相机模式 含义
cv::CAP_PROP_POS_MSEC 视频文件当前播放的时刻(单位为毫秒)或者视频捕捉的当前帧时间戳
cv::CAP_PROP_POS_FRAMES 当前帧的索引值,从0开始计数
cv::CAP_PROP_POS_AVI_RATIO 当前视频播放的相对位置,取值范围为[0.0, 1.0]
cv::CAP_PROP_FRAME_WIDTH 每一帧图像的宽度
cv::CAP_PROP_FRAME_HEIGHT 每一帧图像的高度
cv::CAP_PROP_FPS 仅限于视频文件 视频的帧率,即每秒的帧数
cv::CAP_PROP_FOURCC 编解码标准的四字符代码
cv::CAP_PROP_FRAME_COUNT 视频文件的总帧数,并不总是可靠的
cv::CAP_PROP_FORMAT 视频数据的格式,如CV_8UC3
cv::CAP_PROP_MODE 视频捕获模式,其值和捕获视频使用的特定硬件(如DC1394)相关
cv::CAP_PROP_BRIGHTNESS 相机的亮度设置,仅在支持时才有效
cv::CAP_PROP_CONTRAST 相机的对比度设置,仅在支持时才有效
cv::CAP_PROP_SATURATION 相机的饱和度设置,仅在支持时才有效
cv::CAP_PROP_HUE 相机的色调设置,仅在支持时才有效
cv::CAP_PROP_GAIN 相机的增益设置,仅在支持时才有效
cv::CAP_PROP_EXPOSURE 相机的曝光度设置,仅在支持时才有效
cv::CAP_PROP_CONVERT_RGB 如果为非0值,捕获到的图像将会被转换为3个通道
cv::CAP_PROP_WHITE_BALANCE 相机的白平衡设置,仅在支持时才有效
cv::CAP_PROP_RECTIFICATION 立体相机整流标志,仅支持DC1394-2.x

上表的所有值都是以双精度型读取或者设置的,这对于大多数场景都是有效或者可以接受的,但是对于属性cv::CAP_PROP_FOURCC而言,必须经过转换才有意义,转换的示例代码如下。

cv::VideoCapture cap("my_video.avi");

unsigned f = (unsigned)cap.get(cv::CAP_PROP_FOURCC);
char fourcc[] = {(char) f, //第一个字符取最低8位
                 (char)(f >> 8),  // 第二个字符为8~15位
                 (char)(f >> 16), // 第三个字符为16~23位
                 (char)(f >> 24), // 第四个字符为24~31位
                 '\0'};
2.2.4 写入视频文件

cv::VideoWriter可以处理视频文件写入的任务,它包含两个构造函数,其中默认构造函数不包含任何参数,但是需要在使用时设置。包含写入器能正常工作的所有必要参数的构造函数如下。

// filename:输出文件的绝对路径
// fourcc:使用的编码标准,使用宏CV_FOURCC()生成
// fps:生成的视频文件的帧率
// frame_size:每帧画面都尺寸
// is_color:是否是彩色视频,设置为false时可以处理灰度帧
cv::VideoWriter::VideoWriter(const string& filename, int fourcc,
                             double fps, cv::Size frame_size,
                             bool is_color = true);

在实际应用的时候也可以使用默认构造函数对象,然后再调用函数cv::VideoWriter::open来设置必要参数,其代码如下。

cv::VideoWriter out;

out.open("my_video.mpg", CV_FOURCC('D','I','V','X'), 30.0,
         cv::Size( 640, 480 ), true);

在完成视频写入器后不要忘记调用函数cv::VideoWriter::isOpened来判断环境是否初始化成功,如果初始化失败,可能是由于程序没有指定的路径的文件写入权限,或者大多数情况下都是你指定的编码器不可用。能够使用的编码器取决于你的操作系统自带的或者额外安装的库,对于跨平台的程序,一定要处理好在特定平台特定的编码器不可用的情况。

当环境成功初始化后,函数cv::VideoWriter::write可以写入指定的视频帧,其原型如下。需要注意写入的视频帧必须和初始化写入器环境时设置的帧大小即灰度模式相同,即如果设置了isColorfalse,则必须是三通道的数据。

// image:待写入的下一帧数据
cv::VideoWriter::write(const Mat& image);

重载后的运算符<<也可以写入视频帧,示例代码如下。

my_video_writer << my_frame;

2.3 YAML和XML文件

除了标准的视频文件输出外,OpenCV还提供了一个机制用于序列化和反序列化其提供的各种数据结构,并以YAML或者XML文件格式从磁盘读取或者存储到磁盘中,此外这套机制也支持OpenCV到配置和日志文件。类cv::FileStorage是这个功能的核心类,它可以上表示磁盘上的一个文件,其构造函数如下。

FileStorage::FileStorage();

// fileName:YAML或者XML文件的绝对路径
// flag:数据写入策略,可选值为cv::FileStorage::WRITE或cv::FileStorage::APPEND
FileStorage::FileStorage(string fileName, int flag);

如果使用了默认的构造函数,则需要调用函数FileStorage::open打开一个文件,其原型如下。

FileStorage::open(string fileName, int flag);
2.3.1 写入数据

当文件被成功打开后,就可以使用重载的运算符<<写入数据。cv::FileStorage内部以两种方式存储数据,分别是键值对的映射模式(Mapping)和直接存储的序列(Sequence)模式。使用任意模式存储时,需要先为其提供一个String类型的名字,然后再存储条目的内容,存储方式如下。

// 存储一个整形数据
myFileStorage << "someInteger" << 27;
// 存储一个矩阵
myFileStorage << "anArray" << cv::Mat::eye(3,3,CV_32F);

如果创建一个映射关系可以在映射的名后使用特殊符号{}开启或结束一个映射。如果创建的序列关系存在多个值时可以使用特殊符号[]开启或结束一个序列。使用方式如下。

myFileStorage << "theCat" << "{";
myFileStorage << "fur" << "gray" << "eyes" << "green" << "weightLbs" << 16;
myFileStorage << "}";

myFileStorage << "theTeam" << "[";
myFileStorage << "eddie" << "tom" << "Scott";
myFileStorage << "]";

在完成数据写入后,调用函数cv::FileStorage::release()关闭文件,实例程序WriteYML创建了一个yml格式的数据文件,其核心代码如下。

int main(int, char** argv) {
    cv::FileStorage fs("test.yml", cv::FileStorage::WRITE);
    fs << "frameCount" << 5;

    time_t rawtime;
    time(&rawtime);
    fs << "calibrationDate" << asctime(localtime(&rawtime));

    cv::Mat cameraMatrix = 
        (cv::Mat_<double>(3,3) << 1000, 0, 320, 0, 1000, 240, 0, 0, 1);
    cv::Mat distCoeffs = (cv::Mat_<double>(5,1) << 0.1, 0.01, -0.001, 0, 0);
    fs << "cameraMatrix" << cameraMatrix << "distCoeffs" << distCoeffs;

    fs << "features" << "[";
    for( int i = 0; i < 3; i++ ) {
        int x = rand() % 640;
        int y = rand() % 480;
        uchar lbp = rand() % 256;

        fs << "{:" << "x" << x << "y" << y << "lbp" << "[:";
        for( int j = 0; j < 8; j++ ) {
            fs << ((lbp >> j) & 1);
        }
        fs << "]" << "}";
    }
    fs << "]";
    fs.release();

    return 0;
}

该程序运行后得到的YAML文件内容如下。

%YAML:1.0
frameCount: 5
calibrationDate: "Fri Jun 17 14:09:29 2011\n"
cameraMatrix: !!opencv-matrix
   rows: 3
   cols: 3
   dt: d
   data: [ 1000., 0., 320., 0., 1000., 240., 0., 0., 1. ]
distCoeffs: !!opencv-matrix
   rows: 5
   cols: 1
   dt: d
   data: [ 1.0000000000000001e-01, 1.0000000000000000e-02,
       -1.0000000000000000e-03, 0., 0. ]
features:
   - { x:167, y:49,  lbp:[ 1, 0, 0, 1, 1, 0, 1, 1 ] }
   - { x:298, y:130, lbp:[ 0, 0, 0, 1, 0, 0, 1, 1 ] }
   - { x:344, y:158, lbp:[ 1, 1, 0, 0, 0, 0, 1, 0 ] }

在上面的处理结果中,可以看到有时一个映射或者一个序列中的所有数据都被存储到了同一行,有时它们被存储在了不同行。这是因为YAML可以识别特殊符号{:[:,它们表示不需要换行,如果输出文件格式为XML,则 :会被忽略。

2.3.2 读取数据

如果需要从某个文件中读取数据,则在构造FileStorage对象时参数flag需要设置为cv::FileStorage::READ。另外,和写入数据一样,也可以使用默认构造方法先创建一个实例,再调用FileStorage::open方法打开一个指定的文件。

当文件被成功打开后就可以使用重载的数组运算符[]或者使用迭代器cv::FileNodeIterator查询数据,需要注意的是不再需要读取数据时,记得调用函数cv::FileStorage::release()关闭文件。

使用重载的数组运算符[]方式获取数据时,如果读取的是映射关系数据时,需要使用和目标对象关联的字符串类型的键值查询数据,如果读取的是序列数据时,则使用整型的下标即可。返回值是cv::FileNode的实例,它是查询结果的封装。

cv::FileNode的实例可以表示一个对象、数字或者字符串,通过重载的运算符>>可以提取其中的数据,使用示例如下。

cv::Mat anArray;
myFileStorage["calibrationMatrix"] >> anArray;

int aNumber;
myFileStorage["someInteger"] >> aNumber;

// 甚至可以使用如下方式获取数据
int aNumber;
aNumber = (int)myFileStorage["someInteger"];

迭代器对象cv::FileNodeIterator可以遍历一个cv::FileNode对象,其成员函数cv::FileNode::begin()cv::FileNode::end()分别获取映射或者序列的首个元素以及末尾元素后一个标记。通过重载的求值运算符*可以得到当前位置的cv::FileNode对象。在读取数据时,OpenCV将每个数据都视为cv::FileNode对象,即其自身内部的映射或者序列都被视为由多个cv::FileNode对象组成。该迭代器对象也支持递增和递减运算符。如果遍历的是映射,则每个对象都包含一个名字,可以通过cv::FileNode::Name获取。

cv::FileNode的成员函数见下表,其中函数cv::FileNode::type()返回的值是一个枚举类型,可能的取值在下文介绍。

成员函数 描述
cv::FileNode fn() 默认构造函数
cv::FileNode fn1(fn0) 复制构造函数,从节点fn0复制到节点f1
cv::FileNode fn1(fs, node) 构造函数,从C风格的CvFileStorage指针和C风格的CvFileNode指针中构造C++风格的文件节点cv::FileNode对象
fn[(string)key]
fn[(const char*)key]
使用映射节点的名字获取对应的节点,可以是STL和C风格的字符串
fn[(int)id] 通过下标获取序列中的子节点
fn.type() 返回节点类型
fn.empty() 判断节点是否为空
fn.isNone() 判断节点是否含None值
fn.isSeq() 判断节点是否为一个序列
fn.isMap() 判断节点是否为一个映射
fn.isInt()
fn.isReal()
fn.isString()
判断节点是否为一个整型值,浮点值或者是字符串
fn.name() 如果该节点是一个映射,则返回其名字
size_t sz = fn.size() 如果该节点为映射或者为序列,则返回元素个数
(int)fn
(float)fn
(double)fn
(string)fn
如果节点类型为int、浮点型、双精度型或者字符串型,则分别返回节点的值

函数cv::FileNode::type()返回的值和其对应的含义如下表。需要注意的是浮点型数据和双精度数据并没有区分,这是因为XML和YAML文件都是ASCII格式的文本文件,在这种格式下浮点型数据在被转换成内部机器变量类型之前是不带有精度信息的,因此这里没有对它们进行区分。

枚举值 描述
cv::FileNode::NONE = 0 节点不包含任何数据,类型为None
cv::FileNode::INT = 1 整型数据
cv::FileNode::REAL = 2
cv::FileNode::FLOAT = 2
浮点型数据
cv::FileNode::STR = 3
cv::FileNode::STRING = 3
字符串型数据
cv::FileNode::REF = 4 引用类型,如一个复合对象
cv::FileNode::SEQ = 5 包含子节点的序列
cv::FileNode::MAP = 6 包含子节点的映射
cv::FileNode::FLOW = 8 节点是序列或者映射的紧凑表示
cv::FileNode::USER = 16 节点是注册对象,如矩阵
cv::FileNode::EMPTY = 32 空节点,未赋值
cv::FileNode::NAMED = 64 不包含子节点的映射,包含名字

cv::FileNode::FLOW开始,所有枚举值的真实值都是2的幂,这是因为后4种的任意一种或者全部都能与前面的任意一种组合。示例ReadYML使用前面介绍的方法从文件test.yml中读取数据,其核心代码如下。

cv::FileStorage fs2("test.yml", cv::FileStorage::READ);

// 第一种方式,使用重载的类型转换运算符
int frameCount = (int)fs2["frameCount"];

// 第二种方式,使用重载的输入运算符>>
std::string date;
fs2["calibrationDate"] >> date;

cv::Mat cameraMatrix2, distCoeffs2;
fs2["cameraMatrix"] >> cameraMatrix2;
fs2["distCoeffs"] >> distCoeffs2;

cout << "frameCount: " << frameCount << endl
     << "calibration date: " << date << endl
     << "camera matrix: " << cameraMatrix2 << endl
     << "distortion coeffs: " << distCoeffs2 << endl;

cv::FileNode features = fs2["features"];
cv::FileNodeIterator it = features.begin(), it_end = features.end();
int idx = 0;
std::vector<uchar> lbpval;

// 使用迭代器遍历序列
for (; it != it_end; ++it, idx++) {
    cout << "feature #" << idx << ": ";
    cout << "x=" << (int)(*it)["x"] << ", y=" << (int)(*it)["y"] << ", lbp: (";

    // 使用重载的输入运算符>>读取数字组成的数组
    (*it)["lbp"] >> lbpval;
    for (int i = 0; i < (int)lbpval.size(); i++) {
        cout << " " << (int)lbpval[i];
        cout << ")" << endl;
    }
}
fs.release();

3 UI操作

HighGUI模块处理能够处理文件和设备相关任务外,还提供了一些基础的UI功能,如创建窗口,在窗口中显示图片,处理这些窗口中的用户交互事件。这些功能是部分跨平台的,因为同样的接口对于不同的操作系统实现并不一样,如在Linux系统上使用了X11,在Mac OS上使用了Cocoa,在Windows系统中使用了Win32 API。另外对于部分系统而言这些功能是不可用的,如在安卓系统中就没有实现。Qt是一个跨平台的UI工具库,它比HighGUI提供的接口更强大,可以预见在未来它能够取代HighGUI的地位。

3.1 不包含Qt库的原生接口

需要注意如果在编译OpenCV时使用了Qt工具库,某些函数的表现将会和原生函数有一定差异,这里先介绍原生函数,在下小节中再介绍Qt库。HighGUI的输入工具函数支持键盘输入,图像区域的鼠标点击,和鼠标滑轮滚动事件。

3.1.1 窗口

创建窗口的函数原型如下。OpenCV引用窗口的方式是名字,而一些操作系统是通过句柄,OpenCV内部提供在两种模式间转换的功能。目前参数flags可以选的值只能是0或者cv::WINDOW_AUTOSIZE,在介绍完Qt库后,它会有更多选项。另外当设置为cv::WINDOW_AUTOSIZE时,用户不能调整窗口的大小。

// name:窗口的名字和标识
//      作为名字将会显示在窗口上,作为标识可以作为其他HighGUI函数的参数从而操作同一个窗口对象
// flags:当该窗口用于显示图片时,是否需要自动调整其大小和图片尺寸相同
int cv::namedWindow(const string& name, int flags = 0);

销毁窗口的函数原型如下。

// name:需要销毁的窗口名字
int cv::destroyWindow(const string& name);
3.1.2 显示图片

在某个窗口上显示图片的函数原型如下。需要注意的是窗口将会拷贝图片里的数据用于展示,因此调用该函数后更改图片的内容,窗口并不会刷新。

// name:需要显示图片的窗口标识
// image:需要显示的图片
void cv::imshow(const string& name, cv::InputArray image);

函数cv::waitKey包含事件监听和更新窗口两个功能。事件监听功能指它在指定时间内等待键盘输入事件,当然也可以一直等待。他可以接收来自任意窗口的键盘事件,当然在没有窗口存在时这个功能将不会生效。更新窗口功能指的是可以创造一个任意窗口更新的机会,这意味着如果不调用该函数,可能窗口永远不会显示图片,或者在窗口移动、尺寸更新、或者从被遮挡状态下显示的时候可能会发生十分奇怪的现象。该函数原型如下。

// return:如果在指定时间内没有任何键盘时间,则返回-1,否则返回键位对应的ASCII值
// delay:等待的时间,设置为0时表示一直等待,单位为毫秒
int cv::waitKey(int delay = 0);

示例DisplayImage在桌面上展示了一张图片,其核心代码如下。

int main( int argc, char** argv ) {
    // 使用图片路径位名字创建窗口
    cv::namedWindow( argv[1], 1 );
    // 加载图片
    cv::Mat = cv::imread( argv[1] );
    // 在窗口中显示图片
    cv::imshow( argv[1], img );

    // 挂起线程等待用户输入ESC键
    while( true ) {
        if( cv::waitKey( 100 /* milliseconds */ ) == 27 ) {
            break;
        }
    }

    // 销毁窗口
    cv::destroyWindow( argv[1] );
    exit(0);
}

该示例程序的运行结果如下图。

在继续介绍后续内容之前,先熟悉一些操作窗口的函数,它们的原型如下。其中函数cv::startWindowThread适用于Linux和Mac OS X系统,它会开启一个线程用于自动更新窗口,负责处理窗口尺寸更新等任务。如果未调用该函数,只有在显示处理窗口更新事件时才会更新窗口,即调用函数cv::waitKey时。

// 移动窗口至左上角顶点的位置为P(x,y),单位为像素
void cv::moveWindow(const char* name, int x, int y);

// 销毁所有由OpenCV创建的窗口,释放内存资源
void cv::destroyAllWindows(void);

// 开启自动更新窗口的线程
// 返回值为0时表示开启失败,这可能是由于当前版本的OpenCV不支持这个功能
int cv::startWindowThread(void);
3.1.3 鼠标事件

响应鼠标事件是通过向OpenCV注册回调函数完成的,该回调函数类型为cv::MouseCallback,其原型如下。

// event:鼠标事件的类型,详情见下文,如左键单击事件
// x,y:相对于图片,鼠标事件发生的坐标,单位为像素
// flags:鼠标事件发生的标记,详情见下文,如鼠标事件发生的时候同时按住了某个键
// param:OpenCV透传的信息
void your_mouse_callback(int event, int x, int y,
                         int flags, void* param);

参数event的可能取值及其含义如下表。

参数event枚举值 真实值
cv::EVENT_MOUSEMOVE 0
cv::EVENT_LBUTTONDOWN 1
cv::EVENT_RBUTTONDOWN 2
cv::EVENT_MBUTTONDOWN 3
cv::EVENT_LBUTTONUP 4
cv::EVENT_RBUTTONUP 5
cv::EVENT_MBUTTONUP 6
cv::EVENT_LBUTTONDBLCLK 7
cv::EVENT_RBUTTONDBLCLK 8
cv::EVENT_MBUTTONDBLCLK 9

参数flags表示鼠标事件发生时,同时键盘上被按住的键。其取值如下表。

参数flags枚举值 真实值
cv::EVENT_FLAG_LBUTTON 1
cv::EVENT_FLAG_RBUTTON 2
cv::EVENT_FLAG_MBUTTON 4
cv::EVENT_FLAG_CTRLKEY 8
cv::EVENT_FLAG_SHIFTKEY 16
cv::EVENT_FLAG_ALTKEY 32

参数param是用于OpenCV向外部传递额外信息的,他可以是任意对象。一个常见的场景就是在调用接下来将要讲到的注册回调函数接口时,在注册接口中将对应的参数传为this,即表示注释逻辑发生的类。在这个回调函数被调用时,this会被作为参数抛出,则我们就可以知道它是在哪个类中注册的了。

注册注册回调函数的接口原型如下。

// windowName:接收事件的窗口标识,只有该窗口发生的事件才会触发回调
// on_mouse:回调函数的地址
// param:回调执行时携带的额外信息
void cv::setMouseCallback(const string& windowName, 
                          cv::MouseCallback on_mouse,
                          void* param = NULL);

示例DrawBoxes支持使用鼠标画框,其核心代码如下。

Rect box;
bool drawing_box = false;

// 绘图函数
void draw_box(cv::Mat& img, cv::Rect box) {
    // 绘制红色矩形
    cv::rectangle(img, box.tl(), box.br(), cv::Scalar(0x00,0x00,0xff));
}

int main(int argc, char** argv) {
    box = cv::Rect(-1,-1,0,0);
    // 使用temp和image两张图片,image作为原图,每次拷贝至temp后绘制矩形再显示
    cv::Mat image(200, 200, CV_8UC3), temp;
    image.copyTo(temp);
    // 重载=运算符,以该值赋值每一个元素
    image = cv::Scalar::all(0);

    cv::namedWindow( "Box Example" );
    // 注册回调函数,透传图像image
    // 传image是因为每一次绘制的结果都需要叠加
    cv::setMouseCallback("Box Example", my_mouse_callback, (void*)&image);

    // 程序主循环,以15毫秒(66.6帧率)不断更新图片,并且如果此时正在绘制,则绘制最新的矩形
    // 如果输入了ESC键,则退出循环
    for (;;) {
        image.copyTo(temp);
        if (drawing_box) {
            draw_box(temp, box);
        }
        cv::imshow( "Box Example", temp );

        if (cv::waitKey( 15 ) == 27) {
            break;
        }
    }
    return 0;
}

// 鼠标事件监听函数,如果按下左键,则记录开始绘制矩形,在移动过程中更新矩形尺寸,
// 抬起左键时绘制矩形,绘图过程结束
void my_mouse_callback(int event, int x, int y, int flags, void* param) {
    cv::Mat& image = *(cv::Mat*) param;
    switch (event) {
        case cv::EVENT_LBUTTONDOWN: {
            drawing_box = true;
            box = cv::Rect( x, y, 0, 0 );
        }
            break;
        case cv::EVENT_MOUSEMOVE: {
            if (drawing_box) {
                box.width = x - box.x;
                box.height = y - box.y;
            }
        }
            break;
        case cv::EVENT_LBUTTONUP: {
            drawing_box = false;
            if (box.width < 0) {
                box.x += box.width;
                box.width *= -1;
            }
            if (box.height < 0) {
                box.y += box.height;
                box.height *= -1;
            }
            draw_box(image, box);
        }
            break;
    }
}
3.1.4 滑动条

HighGUI模块提供了滚动条(Trackbar)UI组件,和窗口一样它也是通过名字来标识的,其构建函数如下。滑动条会被添加到指定窗口的顶部或者底部,这和具体的操作系统相关。滑动条不会挡住窗口内已经显示的图像,但是通常会放大窗口。另外滚动条的名字也会被显示在控件旁边,同样其显示的位置取决于具体的操作系统,但是通常位于滚动条左侧。

// trackbarName:滚动条的名字和标识
// windowName:滚动条将被添加至的窗口标识
// value:滚动条当前的数值,使用指针的原因是在回调函数里可以访问这个值
// count:滚动条的最大值
// onChange:滚动条发生改变时的回调函数,详细信息下文介绍
// param:给滚动条回调函数使用的透传参数,可以是任何对象
int cv::createTrackbar(const string& trackbarName, const string& windowName,
                       int* value, int count,
                       cv::TrackbarCallback onChange = NULL, void* param = NULL);

滚动条显示的效果如下。

参数onChange是滚动条发生改变的回调函数,它和鼠标事件的回调函数类似,其函数类型原型如下。该参数不是必须的,当其设置为NULL时,需要通过监听参数value指向的变量的改变从而响应滚动条发生的事件。

// pos:当前滚动条的值
// param:透传出来的参数,在调用cv::setTrackbarCallback()时设置
void your_trackbar_callback(int pos, void* param = NULL);

此外,还可以通过如下两个函数来获取和设置滚动条的游标位置。

int cv::getTrackbarPos(const string& trackbarName,
                       const string& windowName);

void cv::setTrackbarPos(const string& trackbarName,
                        const string& windowName, int pos);
3.1.5 另类按钮

HighGUI模块不支持按钮控件,如果嫌使用其他库麻烦,有一个小技巧可以另类的实现按钮功能。可以创建一个只有两个位置的滚动条,或者使用键盘事件来替代按钮。甚至可以使用更复杂的方案,即创建一个假的控制面板,通过监听鼠标的点击区域来模拟按钮事件,但是与其使用这么复杂的方式,还不如直接使用Qt库内的UI控件。

示例Trackbar使用了只包含两个值的滚动条来模拟了一个开关按钮,用于控制视频的播放和暂停逻辑。其核心代码如下。

// 设置滚动条游标值
int g_switch_value = 1;

// 定义滚动条的回调函数
void switch_callback(int position, void * param) {
    if (position == 0) {
        cout << "Pause\n"; 
    } else {
        cout << "Run\n";
    }
}

int main(int argc, char** argv) {
    // 当前视频帧
    cv::Mat frame;

    cv::VideoCapture g_capture;
    cv::namedWindow("Example", 1);
    // 创建滚动条
    cv::createTrackbar("Switch", "Example", &g_switch_value, 1, switch_callback);

    // 挂起程序,直至按下退出键,挂起程序时根据开关状态播放或暂停视频播放
    for (;;) {
        if (g_switch_value) {
            g_capture >> frame;
            if (frame.empty()) {
                break;
            }
            cv::imshow("Example", frame);
        }
        if (cv::waitKey(10) == 27) {
            break;
        }
    }
    return 0;
}

3.2 Qt库

尽管我们看到的GUI库里面提供的基础功的接口都是统一的,但是实际上它们在不同平台上都是基于特定原生库封装的。OpenCV还支持在编译时导入Qt库,从而提供更多跨平台的强大UI功能。需要注意的是使用依赖Qt库的OpenCV函数和直接使用Qt库是有差异的,在本小节末尾将会详细讲到这点。其实OpenCV即使使用了Qt库支持的UI接口,这部分功能更多的仍然是面向实验室的开发和调试需求。如果想要创建商业的应用,还是需要使用平台原生库或者更强大的UI库。

在编译时使用的cmake配置中-D WITH_QT设置为ON就可以将Qt库编译到OpenCV中。

3.2.1 工具条和状态条

在包含Qt库时打开窗口会看到两个额外的UI控件,分别是工具条(Toolbar)和状态条(Status bar),它们显示的效果如下图。

工具条前四个按钮用于移动图像,接下来四个用于缩放图像,再接下来是保存图像,最后是打开属性窗口的按钮。状态条会显示当前鼠标的位置,以及当前位置的像素颜色RGB值。在包含Qt库时,也可以在调用函数cv::namedWindow()时使用标记cv::GUI_NORMAL隐藏工具条和状态条。与之对应的是cv::GUI_EXTENDED选项,但是它的值为0,所以这是默认选项。

默认情况下,状态条显示的是鼠标位置和像素颜色,可以调用如下函数自定义文本内容。需要注意该函数只有对使用了标记cv::GUI_EXTENDED创建的窗口才有效。当达到设置的超时时间后,状态条会展示默认的文案。

// name:需要展示文本盖层的窗口
// text:展示的文本内容
// delay:文本展示的时长,0表示一直展示
int cv::displayStatusBar(const string& name, const string& text, int delay);
3.2.2 操作菜单

在创建窗口时启用cv::GUI_EXTENDED选项后,在图片上点击右键可以弹出操作按钮,其效果如下图。其选项和工具条的按钮相同。

3.2.3 文本盖层

使用Qt库的OpenCV还提供了函数用于在图片上展示一个透明的文本盖层,函数原型如下。需要注意文字有固定的尺寸,如果内容过长会溢出,但是可以使用换行符\n输入多行文字。默认情况下,文本居中显示。另外文本盖层是唯一的,即当前展示的文本盖层会替换掉之前的盖层,并且重新开始计时。

// name:需要展示文本盖层的窗口
// text:展示的文本内容
// delay:文本展示的时长,0表示一直展示
int cv::displayOverlay(const string& name, const string& text, int delay);
3.2.4 属性窗口

在工具条和以及使用鼠标右键点击窗口区域弹出的菜单中,都提供了属性按钮,点击该按钮将会弹出一个属性窗口,另外也可以使用快捷键Ctrl+P。需要注意的是每个程序只有一个属性窗口,我们只需要配置这个窗口,在其中加入需要的按钮以及滑动条等控件。需要注意的是当该窗口未配置时是不可用的。属性窗口的示例如下图。

3.2.5 滑动条

从上图中可以看出添加Qt库的依赖后,使用同一个函数cv::createTrackbar创建的滑动条细节更丰富。另外通过将窗口名参数设置为空字符串可以直接将滚动条添加到属性窗口中,示例代码如下。

int contrast = 128;
cv::createTrackbar("Contrast:", "", &contrast, 255, on_change_contrast);
3.2.6 按钮

Qt库支持在属性窗口中创建普通按钮、单选按钮和复选框。创建按钮的函数原型如下。

// buttonName:按钮的标识,会显示在按钮旁边,传入“”时,会自动依次编号生成,如button 0
// onChange:按钮事件的回调函数
// params:按钮事件回调函数的透传参数
// buttonType:按钮类型,取值为cv::PUSH_BUTTON等
// initialState:按钮的初始状态
int cv::createButton(const string& buttonName,
                     cv::ButtonCallback onChange = NULL, void* params,
                     int buttonType = cv::PUSH_BUTTON, int initialState = 0);

参数buttonType的取值为cv::PUSH_BUTTONcv::RADIOBOXcv::CHECKBOXcv::PUSH_BUTTON为普通按钮,响应点击事件。cv::RADIOBOX为单选按钮,同为一组cv::RADIOBOX类型的按钮在按下时所有按钮都会收到回调事件,稍后将会介绍此类按钮的分组方法。cv::CHECKBOX为多选按钮,选中和为选中状态时值分别为1或者0

参数onChange是处理按钮事件的回调函数,其原型如下。

// state:按钮事件类型
// params:创建按钮时设置的透传参数
void your_button_callback(int state, void* params);

按钮创建后会被自动组织为按钮栏(Button Bars),一个按钮栏就是一组按钮,它们在属性窗口中会被排列为一行。上图中配置属性窗口的代码如下。

cv::createButton("", NULL, NULL, cv::PUSH_BUTTON);
cv::createButton("", NULL, NULL, cv::PUSH_BUTTON);
cv::createButton("", NULL, NULL, cv::PUSH_BUTTON);
cv::createTrackbar("Trackbar2", "", &mybar1, 255);
cv::createButton("Button3", NULL, NULL, cv::RADIOBOX, 1);
cv::createButton("Button4", NULL, NULL, cv::RADIOBOX, 0);
cv::createButton("Button5", NULL, NULL, cv::CHECKBOX, 0);

在上面的代码中前三个按钮和后三个按钮中间插入了一个滑动条,因此它们被分别组织成为两组即两行控件。遗憾的是不存在手动为按钮分组或者分行的接口,只能通过插入滚动条来完成。

3.2.7 字体和字号

Qt库还支持一些更美观和灵活的文字样式,它被封装为类CvFont,其构造方式如下。

// fontName:系统字体名字,如Times,如果系统中不存在指定的字体,则加载默认字体
// pointSize:文字的大小,单位为点,设置为0时使用默认字号,通常为12point
// color:文字的颜色,BGR颜色模型,默认值为黑色
// weight:文字的线条宽度,取值空间为1-100,或者可以使用定义好的值,详细信息见下表
// spacing:每个字符之间的间距,可以取负值
CvFont fontQt(const string& fontName, int pointSize,
              cv::Scalar color = cv::Scalar::all(0), 
              int weight = cv::FONT_NORMAL, int spacing = 0);

参数weight预定义的常量和真实值如下表。

参数weight取值 描述
cv::FONT_LIGHT 25
cv::FONT_NORMAL 50
cv::FONT_DEMIBOLD 63
cv::FONT_BOLD 75
cv::FONT_BLACK 87

成功创建文字样式实例CvFont后,就可以使用如下函数在某个图片上显示文字。

// image:需要添加文字的图像
// text:需要添加的文字
// location:文字展示的坐标,第一个字符的基线左下角坐标
// font:文字样式
void cv::addText(cv::Mat& image,
                 const string& text, cv::Point location, CvFont *font);
3.2.8 窗口

使用Qt库创建的窗口大部分状态属性都是可以读可写的,相应的函数原型如下。

// name:窗口的标识
// prop_id:属性的标识,取值见下表
// prop_value:属性的值
void cv::setWindowProperty(const string& name, int prop_id, double prop_value);

double cv::getWindowProperty(const string& name, int prop_id);

参数prop_id的取值信息见下表。

参数prop_id取值 描述
cv::WIND_PROP_FULL_SIZE 设置为cv::WINDOW_FULLSCREEN表示全屏窗口,设置为cv::WINDOW_NORMAL表示常规窗口
cv::WIND_PROP_AUTOSIZE 设置为cv::WINDOW_AUTOSIZE表示窗口会根据显示的图片自动调整大小,设置为cv::WINDOW_NORMAL表示将图像大小调整为窗口大小
cv::WIND_PROP_ASPECTRATIO 设置为cv::WINDOW_FREERATIO表示允许窗口有任意长宽比,即用户可以调整窗口长宽比,设置为cv::WINDOW_KEEPRATIO表示用户不能调整长宽比,但是可以改变大小

窗口状态是可以被存储和复原的,它不仅包含窗口的位置和大小,还包括所有的滚动条和按钮状态。存储窗口状态的函数原型如下。

// name:需要保存状态的窗口标识
void cv::saveWindowParameters(const string& name);

复原窗口状态的函数原型如下。

// name:需要复原状态的窗口标识
void cv::loadWindowParameters(const string& name);

复原窗口状态函数在即使程序被杀死或者重启后仍然能够恢复到上一次保存到状态,它是如何实现的并不需要我们关心,只需要知道状态保存和可执行文件的名字相关,也就是说如果改变了可执行程序的名字 ,则不能恢复到上一次保存到状态。

3.2.9 和OpenGL交互

在编译OpenCV库时如果同时打开了Qt库,并启用了OpenGL相关功能,就能够调用相应的接口生成图像,或者在图像上面添加蒙板。打开Qt库的CMake选项在前文已经讲过,打开OpenGL功能需要在CMake中将选项-D WITH_QT_OPENGL设置为ON。该功能可以高效的可视化和调试机器人或者增强现实应用,另外它对想要在原始图像上生成三维模型并观测效果的场景也很有帮助。下图是类似场景一个简单例子的示意效果。

使用OpenGL绘图的方式很简单,首先需要定义一个符合规范的OpenGL回调函数,在其内部使用OpenGL接口完成需要的绘制任务。每次窗口重绘时都会调用这个函数,包含调用函数cv::imshow()。该函数原型如下。

// params:在注册OpenGL回调函数时设置的透传参数
void your_opengl_callback(void* params);

声明好自定义的回调函数后,调用还需要如下接口注册该回调函数。

// windowName:注册的窗口标识,绘制的结果将被显示在这个窗口上
// callback:注册的回调函数
// params:透传参数
void cv::createOpenGLCallback(const string& windowName, 
                              cv::OpenGLCallback callback, void* params = NULL);

另外如果如果需要获得不同的显示效果,需要在回调函数开始处使用OpenGL接口gluPerspective()设置投影矩阵,当然这是OpenGL老版本的接口,这些接口仅用于调试。如果想要了解现代OpenGL的更多信息,可以移步至文集OpenGL

OpenCV文档中有一段绘制立方体的代码,对其稍为修改,使用变量rotxroty替换固定的旋转角度,通过滑动条来设置这两个参数的值,从而改变立方体现实效果。最终的示例程序Cube核心代码如下。

void on_opengl( void* param ) {
    // 这里使用到的都是OpenGL老版本的接口,这些接口仅用于调试
    // 如果想要了解现代OpenGL的更多信息,可以移步至文集OpenGL
    glMatrixModel( GL_MODELVIEW );
    glLoadIdentity();
    glTranslated( 0.0, 0.0, -1.0 );

    glRotatef( rotx, 1, 0, 0 );
    glRotatef( roty, 0, 1, 0 );
    glRotatef( 0, 0, 0, 1 );

    static const int coords[6][4][3] = {
        { { +1, -1, -1 }, { -1, -1, -1 }, { -1, +1, -1 }, { +1, +1, -1 } },
        { { +1, +1, -1 }, { -1, +1, -1 }, { -1, +1, +1 }, { +1, +1, +1 } },
        { { +1, -1, +1 }, { +1, -1, -1 }, { +1, +1, -1 }, { +1, +1, +1 } },
        { { -1, -1, -1 }, { -1, -1, +1 }, { -1, +1, +1 }, { -1, +1, -1 } },
        { { +1, -1, +1 }, { -1, -1, +1 }, { -1, -1, -1 }, { +1, -1, -1 } },
        { { -1, -1, +1 }, { +1, -1, +1 }, { +1, +1, +1 }, { -1, +1, +1 } }
    };

    for (int i = 0; i < 6; ++i) {
        glColor3ub( i*20, 100+i*10, i*42 );
        glBegin(GL_QUADS);
        for (int j = 0; j < 4; ++j) {
            glVertex3d(0.2 * coords[i][j][0],
                       0.2 * coords[i][j][1], 0.2 * coords[i][j][2]);
        }
        glEnd();
    }
}

3.3 外置的跨平台GUI库

在开发正式工程时,即使使用内置的Qt库仍然难以满足需求,因此我们需要使用外置的GUI工具包。本节会简单介绍Qt库,wxWidgets库和微软模版库(Windows Template Library, WTL)。使用外置工具包的首要任务就是如何将OpenCV的图像转换成工具库所期望的图片格式,以及弄清工具包内的哪个组件是用于图像显示的。

3.3.1 Qt库

示例程序Qt使用Qt库读取并在屏幕展示视频文件,其核心代码如下。该示例创建了一个Qt程序对象,使用自定义的QMoviePlayer对象来读取和显示视频文件。

int main(int argc, char* argv[]) {
    // 创建Qt的应用程序
    QApplication app(argc, argv);
    QMoviePlayer mp;
    mp.open(argv[1]);
    mp.show();

    return app.exec();
}

QMoviePlayer的头文件定义如下。

class QMoviePlayer : public QWidget {
    Q_OBJECT;

    public:
    QMoviePlayer(QWidget *parent = NULL);
    virtual ~QMoviePlayer() {};
    bool open( string file );

    private:
    // Qt库的播放器UI控制类
    Ui::QMoviePlayer ui;
    cv::VideoCapture m_cap;

    // Qt库内部的图像抽象类
    QImage  m_qt_img;
    // CV库内部的图像抽象类
    cv::Mat m_cv_img;
    // 控制视频播放速率的时钟
    QTimer* m_timer;

    // 重载QWidget的绘制函数
    void paintEvent( QPaintEvent* q );
    // 将CV的图像数据拷贝至Qt的图像数据中
    void _copyImage( void );

    public slots:
    // 读取并显示下一帧图像
    void nextFrame();
};

QMoviePlayer的构造函数实现如下。其中仅包含Ui::QMoviePlayer的配置逻辑。

QMoviePlayer::QMoviePlayer(QWidget *parent)
: QWidget(parent) {
    ui.setupUi(this);
}

打开视频文件的函数QMoviePlayer::open(string file)实现如下。

bool QMoviePlayer::open(string file) {
    // 使用OpenCV接口打开视频文件
    if (!m_cap.open(file)) {
        return false;
    }
    
    // 读取第一帧到OpenCV的图片模型中
    m_cap.read(m_cv_img);
    // 创建Qt库图像模型
    m_qt_img = QImage(QSize(m_cv_img.cols, m_cv_img.rows), QImage::Format_RGB888);
    // 设置Qt库视频播放器UI的逻辑分辨率
    ui.frame->setMinimumSize(m_qt_img.width(), m_qt_img.height());
    ui.frame->setMaximumSize(m_qt_img.width(), m_qt_img.height());
    // 将图像数据从CV模型拷贝到Qt模型
    _copyImage();

    // 创建时钟并绑定事件,每个设定的间隔结束后都会调用函数nextFrame()
    m_timer = new QTimer(this);
    connect(m_timer, SIGNAL(timeout()), this, SLOT(nextFrame()));
    // 运行时钟,时间间隔为1000/视频帧率,即每帧画面的显示时间,单位为毫秒
    m_timer->start(1000. / m_cap.get(cv::CAP_PROP_FPS));

    return true;
}

将图像数据从CV模型拷贝到Qt模型的函数QMoviePlayer::_copyImage(void)实现如下。

void QMoviePlayer::_copyImage(void) {
    // 首先使用m_qt_img的数据段指针创建一个CV的图片模型
    cv::Mat cv_header_to_qt_image(cv::Size(m_qt_img.width(), m_qt_img.height()),
                                  CV_8UC3, m_qt_img.bits());
    // 使用CV的函数将m_cv_img数据转换后拷贝至cv_header_to_qt_image中,
    //从而将其拷贝到m_qt_img中
    cv::cvtColor(m_cv_img, cv_header_to_qt_image, cv::BGR2RGB);
}

读取并显示下一帧图像的函数QMoviePlayer::nextFrame()实现如下。

void QMoviePlayer::nextFrame() {
    if (!m_cap.isOpened()) {
        return;
    }
    // 读取当前帧图像到CV模型内
    m_cap.read(m_cv_img);
    // 将图像数据从CV模型拷贝到Qt模型
    _copyImage();
    // 通知Qt库需要刷新页面
    this->update();
}

Qt库中QWidget实例在需要重绘时会自动调用绘制函数QMoviePlayer::paintEvent(QPaintEvent* e),该函数的实现如下。一个可能需要重绘的时机是在调用函数如在调用函数update()后。

void QMoviePlayer::paintEvent(QPaintEvent* e) {
    // 创建绘制器
    QPainter painter(this);
    // 绘制图像
    painter.drawImage(QPoint(ui.frame->x(), ui.frame->y()), m_qt_img);
}
3.3.2 wxWidgets库

示例程序WXWidgets使用wxWidgets库播放视频文件的例子,和上一个例子类似,本例也将播放视频任务的逻辑封装到一个单独的类WxMoviePlayer中。程序执行的最顶层代码如下。

// 继承wxWidgets中的顶层类wxApp定义类MyApp
class MyApp : public wxApp {
    public:
    virtual bool OnInit();
};

// 创建main函数,创建MyApp实例作为应用程序,并将其和main函数绑定
DECLARE_APP(MyApp);
IMPLEMENT_APP(MyApp);

// 重写应用程序运行后调用的函数
bool MyApp::OnInit() {
    // 创建帧(窗口管理对象)
    wxFrame* frame = new wxFrame(NULL, wxID_ANY, wxT("ch4_wx"));
    frame->Show(true);
    
    // 创建我们自己编写的视频播放器,并和该窗口关联
    WxMoviePlayer* mp = new WxMoviePlayer(frame, wxPoint(-1, -1), 
                                          wxSize(640, 480));
    // 打开某个视频文件
    mp->open(wxString(argv[1]));
    mp->Show(true);

    return true;
}

视频播放器WxMoviePlayer头文件声明如下。

// 需要显示在窗口上的视图类都应该继承于wxWindow
class WxMoviePlayer : public wxWindow {
    public:
    WxMoviePlayer(wxWindow* parent, const wxPoint& pos, const wxSize& size);
    virtual ~WxMoviePlayer() {};
    bool open(wxString file);

    private:
    cv::VideoCapture m_cap;
    cv::Mat m_cv_img;
    // 和设备无关的图片
    wxImage m_wx_img;
    // 和设备相关的图片
    wxBitmap m_wx_bmp;
    wxTimer *m_timer;
    wxWindow *m_parent;

    void _copyImage(void);

    // 绘制事件,用于绘制视频帧
    void OnPaint(wxPaintEvent& e);
    // 时钟事件,用于刷新视频帧
    void OnTimer(wxTimerEvent& e);
    // 键盘事件,用于监听ESC输入退出程序
    void OnKey(wxKeyEvent& e);

    protected:
    DECLARE_EVENT_TABLE();
};

视频播放器WxMoviePlayer的实现文件中注册事件部分代码如下。

BEGIN_EVENT_TABLE(WxMoviePlayer, wxWindow)
    EVT_PAINT(WxMoviePlayer::OnPaint)
    EVT_TIMER(TIMER_ID, WxMoviePlayer::OnTimer)
    EVT_CHAR(WxMoviePlayer::OnKey)
END_EVENT_TABLE()

视频播放器WxMoviePlayer的构造函数实现如下。

WxMoviePlayer::WxMoviePlayer(wxWindow* parent, const wxPoint& pos, 
                             const wxSize& size)
: wxWindow(parent, -1, pos, size, wxSIMPLE_BORDER) {
    // 打开视频文件时再设置时钟
    m_timer = NULL;
    // 记录父框架
    m_parent = parent;
}

当窗口重绘时会调用绘制函数OnPaint(wxPaintEvent& event),其实现如下。

void WxMoviePlayer::OnPaint(wxPaintEvent& event) {
    wxPaintDC dc(this);
    if (!dc.Ok()) {
        return;
    }

    int x,y,w,h;
    dc.BeginDrawing();
    dc.GetClippingBox(&x, &y, &w, &h);
    // m_wx_bmp是wxBitmap实例,它和设备相关,可以直接展示
    dc.DrawBitmap(m_wx_bmp, x, y);
    dc.EndDrawing();

    return;
}

拷贝图像数据方法实现如下。

void WxMoviePlayer::_copyImage(void) {
    m_wx_bmp = wxBitmap(m_wx_img);
    // 标记窗口需要重绘
    Refresh(FALSE);
    // 强制执行窗口刷新检查,如果检查到窗口需要重会,则调用重绘函数
    Update();
}

打开视频文件的方法实现如下。

bool WxMoviePlayer::open(wxString file) {
    // 使用CV接口打开一个视频文件
    if (!m_cap.open(std::string(file.mb_str()))) {
        return false;
    }
    
    // 使用CV接口读取当前视频帧
    m_cap.read(m_cv_img);
    // 使用CV的图片数据创建wxWidgets的设备无关图片实例,需要注意这里不存在数据拷贝,
    // 指向的都是同一份图像数据
    m_wx_img = wxImage(m_cv_img.cols, m_cv_img.rows, m_cv_img.data, TRUE);

    // 将设备无关图像转换为设备相关位图
    _copyImage();

    // 创建时钟
    m_timer = new wxTimer(this, TIMER_ID);
    // 按指定间隔运行时钟,每个时间间隔结束后都会产生一个wxTimerEvent事件,最后会调用回调函数
    m_timer->Start(1000. / m_cap.get(cv::CAP_PROP_FPS));

    return true;
}

时钟的回调函数实现如下。

void WxMoviePlayer::OnTimer(wxTimerEvent& event) {
    if (!m_cap.isOpened()) {
        return;
    }

    // 读取当前视频帧
    m_cap.read(m_cv_img);
    // 转换颜色格式
    cv::cvtColor(m_cv_img, m_cv_img, cv::BGR2RGB);
    // 从设备无关图像转换为设备相关位图
    _copyImage();
}

键盘事件的回调函数实现如下。

void WxMoviePlayer::OnKey(wxKeyEvent& e) {
    if (e.GetKeyCode() == WXK_ESCAPE) {
        // 关闭框架,结束程序
        m_parent->Close();
    }
}
3.3.3 窗口模版库(Windows Template Library, WTL)

WTL是Win32 API的简单C++封装,软件Visual Studio可以创建一个基于视图窗口的WTL应用程序,这应该是默认选项。默认生成的文件和工程名相关。示例程序WTL使用这种方式创建了名为OpenCVTest的工程,使用WTL的接口和OpenCV接口完成了一个简单的视频播放程序。

该示例程序比直接使用DirectShow来处理视频流效率更低,但是它更简单。另外如果使用的是.NET运行时,或者C#、VB.NET或者托管的C++(Managed C++),可能需要了解OpenCV的完整封装Emgu

4 小结

本章前半部分主要介绍了如何与磁盘文件以及物理设备进行交互,并介绍了HighGUI模块中用于读写图像文件,这些接口内部会自动处理对应格式文件的编解码任务。同样也介绍了和图像相似的视频文件的读写方法,以及如何使用摄像头拍摄视频文件。另外也介绍了OpenCV提供接口使用XML和YML文件读取以及存储原生类型数据,这两种文件允许数据被读到内存中后映射为键值对结构以便于检索。

后半部分主要是和UI操作以及事件监听相关的内容,首先熟悉了原始的HighGUI接口 ,然后介绍了如何在编译OpenCV时选择Qt库优化原生HighGUI接口的实现从而得到更强大的功能。最后简单介绍了如何使用完全独立的外部UI库,并给出了相应示例程序。