您现在的位置是:首页 >技术杂谈 >OpenCV实战(26)——视频序列处理网站首页技术杂谈
OpenCV实战(26)——视频序列处理
OpenCV实战(26)——视频序列处理
0. 前言
视频信号包含丰富的视觉信息,视频由一系列图像组成,每一图像称为一帧 (frame
),这些图像以固定的时间间隔(通过指定帧率,单位为秒/帧)拍摄并显示运动场景。随着计算机算力的提升,现在已经可以对视频序列执行复杂的视觉分析,处理速度已经接近甚至快于实际视频帧率 (frame rate
)。本节将学习如何读取、处理和存储视频序列。
1. 读取视频序列
为了处理视频序列,我们需要能够读取视频的每一帧。OpenCV
提供了一个易于使用的类 cv::VideoCapture
,用于从视频文件、USB
或 IP
摄像机中提取帧数据。为了读取视频序列帧,需要创建一个 cv::VideoCapture
类的实例,然后循环提取每个视频帧。
(1) 首先打开待读取的视频,并检查视频是否已成功打开:
int main()
{
// 打开视频文件
cv::VideoCapture capture("spiderman_binary.mp4");
if (!capture.isOpened())
return 1;
要打开视频,需要在 cv::VideoCapture
对象的构造函数中指定视频文件名。创建 cv::VideoCapture
后,就可以使用 open
方法打开视频。视频成功打开(通过 isOpened
方法验证)后,就可以提取视频帧。
(2) 提取视频属性,例如帧率等:
// 获取帧率
double rate= capture.get(cv::CAP_PROP_FPS);
可以通过使用带有所需标志的 get
方法查询 cv::VideoCapture
对象并获取与视频文件相关联的信息,例如使用 CAP_PROP_FPS
标志获取帧率,由于它是一个泛型函数,会返回双精度值,即使我们可能需要其他数据类型。如果我们需要其他数据类型,例如,视频文件中的总帧数为整数:
long t= static_cast<long>(capture.get(CAP_PROP_FRAME_COUNT));
使用不同的标志,可以获取与视频相关的其它有用信息。
此外,set
方法用于为 cv::VideoCapture
实例设定参数。例如,可以使用 CAP_PROP_POS_FRAMES
标志将指针移动到特定帧:
double position= 100.0;
capture.set(CAP_PROP_POS_FRAMES, position);
可以使用 CAP_PROP_POS_MSEC
指定以毫秒为单位的位置,或者也可以使用 CAP_PROP_POS_AVI_RATIO
指定视频内部的相对位置 (0.0
对应于视频的开头,1.0
对应于视频的结尾),如果请求的参数设置成功,则该方法返回 true
。需要注意的是,获取或设置特定视频参数取决于压缩和存储视频序列使用的编解码器,如果使用某些参数不成功,那可能是由于使用了错误的编解码器。
(3) 创建一个变量存储每一帧,并在窗口中显示提取的帧图像:
bool stop(false);
cv::Mat frame; // 当前帧
cv::namedWindow("Extracted Frame");
(4) 计算每帧之间的延迟(单位为毫秒):
int delay= 1000/rate;
(5) 在每次迭代中,从 capture
中读取一个帧并显示,直到视频结束退出循环:
while (!stop) {
// 读取下一帧
if (!capture.read(frame))
break;
cv::imshow("Extracted Frame",frame);
std::string name(b);
std::ostringstream ss; ss << std::setfill('0') << std::setw(3) << i; name+= ss.str(); i++;
name+=ext;
std::cout << name <<std::endl;
cv::Mat test;
// cv::resize(frame, test, cv::Size(), 0.2,0.2);
// cv::imwrite(name, frame);
// cv::imwrite(name, test);
// 时延
if (cv::waitKey(delay) >= 0)
stop= true;
}
成功打开视频后,可以通过重复调用 read
方法来获取帧,也可以调用重载的读取运算符:capture >> frame;
也可以调用以下两个基本方法:
capture.grab();
capture.retrieve(frame);
通过使用 cv::waitKey
函数在显示每一帧时引入延迟,我们将延迟设置为与输入视频帧速率相对应的值(如果每秒帧数为 fps
,则两帧之间的延迟为 1000/fps
,以毫秒为单位),可以通过修改延迟值以较慢/快的速度显示视频。但是,如果要显示视频帧,需要确保窗口有足够的时间进行刷新。cv::waitKey
函数可以通过任意按键来中断读取过程,该函数返回按下的键的 ASCII
代码。如果指定给 cv::waitKey
函数的延迟为 0
,则将无限期地等待用户按下按键。
(6) 当完成循环时,关闭并释放视频资源:
// 关闭视频文件
capture.release();
cv::waitKey();
调用 release
方法可以关闭视频文件,但是,并非必须显式调用该方法,因为 cv::VideoCapture
析构函数也调用了 release
方法。
执行以上代码,可以在窗口中播放视频:
需要注意的是,为了打开指定的视频文件,计算机中必须安装相应的编解码器;否则,cv::VideoCapture
将无法解码输入文件。通常,如果能够使用计算机上的视频播放器打开视频文件,那么 OpenCV
也应该能够读取该文件。
我们也可以读取连接到计算机的摄像头(例如 USB
摄像头)的视频流。此时,我们只需为 open
函数指定一个 ID
号(整数),将 ID
指定为 0
会打开默认的相机。在这种情况下,为了防止相机的视频流被无限读取,需要使用适当的方法来停止视频序列处理。除此之外,我们还可以加载网络视频,只需要提供正确的网络地址作为参数:
cv::VideoCapture capture("http://example/spiderman_binary.mp4");
2. 处理视频帧
本节中,我们的目标是对视频序列的每一帧应用图像处理函数,可以通过将 OpenCV
视频捕获框架封装到自定义类中实现。除此之外,自定义类允许我们指定一个处理函数,每次提取新帧时都会调用该函数。
我们需要能够指定一个处理函数(回调函数),该函数会被调用处理视频序列的每一帧。这个函数可以定义为接收一个 cv::Mat
实例并输出处理过的视频帧。
2.1 视频处理
(1) 根据以上分析,在我们的框架中,处理函数必须具有以下签名才能成为有效的回调:
void processFrame(cv::Mat& img, cv::Mat& out);
(2) 作为处理函数的示例,使用计算输入图像边缘的简单函数 canny
:
// 处理函数
void canny(cv::Mat& img, cv::Mat& out) {
// 转换为灰度图像
if (img.channels()==3)
cv::cvtColor(img,out,cv::COLOR_BGR2GRAY);
// Canny 边缘检测
cv::Canny(out,out,100,200);
// 反转图像
cv::threshold(out,out,128,255,cv::THRESH_BINARY_INV);
}
(3) VideoProcessor
类封装了视频处理任务的所有内容,使用这个类时,首先创建一个类实例,指定一个输入视频文件,设定回调函数:
// 使用 VideoProcessor 类创建实例
VideoProcessor processor;
// 打开视频文件
processor.setInput("spiderman_binary.mp4");
// 展示输入、输出
processor.displayInput("Input Video");
processor.displayOutput("Output Video");
processor.setDelay(1000./processor.getFrameRate());
// 设置回调函数
processor.setFrameProcessor(canny);
// 输出处理结果
processor.setOutput("spiderman_binary.mp4",-1,15);
processor.stopAtFrameNo(51);
processor.run();
运行此代码,则两个窗口将以原始帧速率 (setDelay
方法引入延迟)播放输入视频和输出结果,输出窗口如下所示:
2.2 自定义视频处理类 VideoProcessor
我们的目标是创建一个类来封装视频处理算法的通用函数,该类包括几个控制视频帧处理不同方面的成员变量:
class VideoProcessor {
private:
// OpenCV video capture
cv::VideoCapture capture;
// 回调函数
void (*process)(cv::Mat &, cv::Mat &);
// 指向实现FrameProcessor接口的类的指针
FrameProcessor *frameProcessor;
// 是否调用回调函数
bool callIt;
// 输入窗口名
std::string windowNameInput;
// 输出窗口名
std::string windowNameOutput;
// 时延
int delay;
// 处理帧的数量
long fnumber;
// 停止帧编号
long frameToStop;
bool stop;
第一个成员变量是 cv::VideoCapture
对象,第二个属性是进程函数指针(指向回调函数),可以使用相应的 setter
方法指定此函数:
// 设置回调函数
void setFrameProcessor(void (*frameProcessingCallback)(cv::Mat&, cv::Mat&)) {
frameProcessor = 0;
process = frameProcessingCallback;
callProcess();
}
打开视频文件:
// 设置相机ID
bool setInput(int id) {
fnumber = 0;
capture.release();
images.clear();
return capture.open(id);
}
使用两个方法来创建显示窗口,分别显示输入帧和输出帧:
// 显示输入帧
void displayInput(std::string wn) {
windowNameInput = wn;
cv::namedWindow(windowNameInput);
}
// 显示处理后的帧
void displayOutput(std::string wn) {
windowNameOutput = wn;
cv::namedWindow(windowNameOutput);
}
主要方法 run
是包含帧提取循环的方法:
void run() {
// 当前帧
cv::Mat frame;
// 输出帧
cv::Mat output;
if (!isOpened())
return;
stop = false;
while (!isStopped()) {
// 读取下一帧
if (!readNextFrame(frame))
break;
// 显示输入帧
if (windowNameInput.length() != 0)
cv::imshow(windowNameInput,frame);
if (callIt) {
if (process)
process(frame, output);
else if (frameProcessor)
frameProcessor->process(frame,output);
fnumber++;
} else {
output = frame;
}
if (outputFile.length() != 0)
writeNextFrame(output);
// 显示输出帧
if (windowNameOutput.length() != 0)
cv::imshow(windowNameOutput,output);
if (delay >= 0 && cv::waitKey(delay) >= 0)
stopIt();
if (frameToStop >= 0 && getFrameNumber() == frameToStop)
stopIt();
}
}
};
run
方法使用读取帧的私有方法:
void run() {
// 当前帧
cv::Mat frame;
// 输出帧
cv::Mat output;
if (!isOpened())
return;
stop = false;
while (!isStopped()) {
// 读取下一帧
if (!readNextFrame(frame))
break;
// 显示输入帧
if (windowNameInput.length() != 0)
cv::imshow(windowNameInput,frame);
if (callIt) {
process(frame, output);
fnumber++;
} else {
output = frame;
}
// 显示输出帧
if (windowNameOutput.length() != 0)
cv::imshow(windowNameOutput,output);
if (delay >= 0 && cv::waitKey(delay) >= 0)
stopIt();
if (frameToStop >= 0 && getFrameNumber() == frameToStop)
stopIt();
}
}
// 停止处理
void stopIt() {
stop = true;
}
// 是否停止处理
bool isStopped() {
return stop;
}
bool isOpened() {
capture.isOpened();
}
// 设置两帧之间时延
void setDelay(int d) {
delay = d;
}
此方法使用读取帧的私有方法:
bool readNextFrame(cv::Mat& frame) {
return capture.read(frame);
}
run
方法首先调用 cv::VideoCapture
类的 read
方法。然后执行一系列操作,但在调用每个操作之前,会进行检查以确定是否已经执行请求。只有指定了输入窗口名称(使用 displayInput
方法),才会显示输入窗口;只有在指定了一个回调函数时才会调用回调函数(使用 setFrameProcessor
);仅当定义了输出窗口名称时才显示输出窗口(使用 displayOutput
);仅当已指定延迟时才会引入延迟(使用 setDelay
方法)。最后,如果定义了停止帧(使用 stopAtFrameNo
),则需要检查当前帧编号。
如果我们仅仅需要简单地打开和播放视频文件(不调用回调函数),可以使用以下方法来指定是否要调用回调函数:
// 调用回调函数
void callProcess() {
callIt = true;
}
// 不调用回调函数
void dontCallProcess() {
callIt = false;
}
最后,该类还提供了在特定帧处停止的方法:
// 停止帧
void stopAtFrameNo(long frame) {
frameToStop = frame;
}
// 返回下一帧的帧号
long getFrameNumber() {
if (images.size() == 0) {
long f = static_cast<long>(capture.get(cv::CAP_PROP_POS_FRAMES));
return f;
} else {
return static_cast<long>(itImg-images.begin());
}
}
该类还包含许多 getter
和 setter
方法,它们基本上是 cv::VideoCapture
框架的通用 set
和 get
方法的包装器。VideoProcessor
类用于简化视频处理模块的部署,我们可以对其进行额外的改进。
2.3 处理一系列图像
有时,输入序列由一系列单独存储在不同文件中的图像组成。可以修改自定义类以适应此类输入,只需要添加一个成员变量,该变量保存图像文件名向量及其对应的迭代器:
// 输入图像文件名矢量
std::vector<std::string> images;
// 图像矢量迭代器
std::vector<std::string>::const_iterator itImg;
新的 setInput 方法用于指定要读取的文件名:
// 设置输入图像矢量
bool setInput(const std::vector<std::string> &imgs) {
fnumber = 0;
capture.release();
images = imgs;
itImg = images.begin();
return true;
}
isOpened 方法修改如下:
bool isOpened() {
return capture.isOpened() || !images.empty();
}
最后一个需要修改的方法是私有方法 readNextFrame
,该方法将从视频或文件名向量中读取内容,具体取决于已指定的输入。如果图像文件名的向量不为空,则输入是一系列单独存储的图像序列,使用视频文件名调用 setInput
会清空此向量:
// 获取下一帧
bool readNextFrame(cv::Mat &frame) {
if (images.size() == 0) return capture.read(frame);
else {
if (itImg != images.end()) {
frame = cv::imread(*itImg);
itImg++;
return frame.data != 0;
}
return false;
}
}
2.4 使用帧处理器类
在面向对象中,使用帧处理类替代帧处理函数更加灵活,在定义视频处理算法时,类会带来更大的灵活性。因此,我们可以定义一个接口,任何希望在 VideoProcessor
中使用的类都需要实现该接口:
class FrameProcessor {
public:
virtual void process(cv::Mat &input, cv::Mat &output) = 0;
};
setter
方法允许将 FrameProcessor
实例输入到 VideoProcessor
框架,并将其分配给添加的 frameProcessor
成员变量,该变量定义为指向 FrameProcessor
对象的指针:
// 设置实现FrameProcessor接口的类实例
void setFrameProcessor(FrameProcessor* frameProcessorPtr) {
process = 0;
frameProcessor = frameProcessorPtr;
callProcess();
}
当指定帧处理器类实例时,它会使之前已设置的帧处理函数无效。修改 run
方法的 while
循环以适配此修改:
void run() {
// 当前帧
cv::Mat frame;
// 输出帧
cv::Mat output;
if (!isOpened())
return;
stop = false;
while (!isStopped()) {
// 读取下一帧
if (!readNextFrame(frame))
break;
// 显示输入帧
if (windowNameInput.length() != 0)
cv::imshow(windowNameInput,frame);
if (callIt) {
if (process)
process(frame, output);
else if (frameProcessor)
frameProcessor->process(frame,output);
fnumber++;
} else {
output = frame;
}
// 显示输出帧
if (windowNameOutput.length() != 0)
cv::imshow(windowNameOutput,output);
if (delay >= 0 && cv::waitKey(delay) >= 0)
stopIt();
if (frameToStop >= 0 && getFrameNumber() == frameToStop)
stopIt();
}
}
3. 存储视频序列
在上一小节中,我们学习了如何读取视频文件并提取视频帧。本节将介绍如何写入视频帧,从而创建视频文件。通过本节学习,我们将能够完成典型的视频处理流程——读取输入视频流,处理视频帧,然后将结果存储在新的视频文件中。
3.1 存储视频文件
(1) 在 OpenCV
中,写入视频文件需要使用 cv::VideoWriter
类。通过指定文件名、生成视频的播放帧率、每帧的大小以及是否创建彩色视频来构造一个实例:
writer.open(outputFile, // 文件名
codec, // 编码器
framerate, // 帧率
getFrameSize(), // 帧尺寸
isColor); // 是否为彩色视频
(2) 此外,必须通过编解码器参数指定视频数据的保存方式。
(3) 打开视频文件后,可以通过重复调用 write
方法向其中添加帧:
writer.write(frame);
(4) 使用 cv::VideoWriter
类,可以很容易地扩展我们在视频读取一节中介绍的 VideoProcessor
类,以能够写入视频文件。实现完整的视频处理流程,读取视频,处理视频并将结果写入视频文件:
VideoProcessor processor;
// 打开视频文件
processor.setInput("spiderman_binary.mp4");
// 设置回调函数
processor.setFrameProcessor(canny);
// 输出处理结果
processor.setOutput("spiderman_binary.mp4",-1,15);
processor.run();
(5) 同时,我们还希望让用户可以将帧写为单独的图像。在我们的框架中,采用了一种命名约定,视频帧由前缀名称后跟给定位数组成的数字组成。当帧被保存时,数字会自动增加。然后,要将输出结果保存为一系列图像:
processor.setOutput("example", // 前缀
".jpg", // 文件扩展名
3, // 编号位数
0) // 开始索引
3.2 修改 VideoProcessor 类
修改 VideoProcessor
类以使其能够写入视频文件。首先,必须将 cv::VideoWriter
成员变量以及一些其它属性添加到类中:
class VideoProcessor {
private:
// ...
// OpenCV 视频写入对象
cv::VideoWriter writer;
// 输出文件名
std::string outputFile;
// 当前帧索引
int currentIndex;
int digits;
std::string extension;
setOutput
方法用于指定(和打开)输出视频文件:
// 设置输出视频文件
bool setOutput(const std::string &filename, int codec=0, double framerate=0.0, bool isColor=true) {
outputFile = filename;
extension.clear();
if (framerate == 0.0)
framerate = getFrameRate();
char c[4];
if (codec == 0) {
codec = getCodec(c);
}
// 打开输出视频
return writer.open(outputFile, // 文件名
codec, // 编码器
framerate, // 帧率
getFrameSize(), // 帧尺寸
isColor); // 是否为彩色视频
}
私有方法 writeNextFrame
处理帧写入过程(作为视频文件中或一系列图像):
// 写入帧
void writeNextFrame(cv::Mat &frame) {
if (extension.length()) {
std::stringstream ss;
ss << outputFile << std::setfill('0') << std::setw(digits) << currentIndex++ << extension;
cv::imwrite(ss.str(), frame);
} else {
writer.write(frame);
}
}
对于输出由单个图像文件组成的情况,我们需要一个额外的 setter
方法:
// 将输出设置为一系列图像文件
bool setOutput(const std::string &filename, // 文件名前缀
const std::string &ext, // 图像文件扩展名
int numberOfDigits = 3, // 编号位数
int startIndex = 0) { // 开始索引
if (numberOfDigits<0) return false;
outputFile = filename;
extension = ext;
digits = numberOfDigits;
currentIndex = startIndex;
return true;
}
最后,在 run
方法的视频捕获循环中添加新步骤:
void run() {
// 当前帧
cv::Mat frame;
// 输出帧
cv::Mat output;
if (!isOpened())
return;
stop = false;
while (!isStopped()) {
// 读取下一帧
if (!readNextFrame(frame))
break;
// 显示输入帧
if (windowNameInput.length() != 0)
cv::imshow(windowNameInput,frame);
if (callIt) {
if (process)
process(frame, output);
else if (frameProcessor)
frameProcessor->process(frame,output);
fnumber++;
} else {
output = frame;
}
if (outputFile.length() != 0)
writeNextFrame(output);
// 显示输出帧
if (windowNameOutput.length() != 0)
cv::imshow(windowNameOutput,output);
if (delay >= 0 && cv::waitKey(delay) >= 0)
stopIt();
if (frameToStop >= 0 && getFrameNumber() == frameToStop)
stopIt();
}
}
将视频写入文件时,会使用编解码器进行保存。编解码器是能够对视频流进行编码和解码的软件模块。编解码器定义了文件的格式和用于存储信息的压缩方案。显然,使用给定编解码器编码的视频必须使用相同的编解码器进行解码。因此,使用四字符编码唯一标识编解码器。当软件需要写入视频文件时,通过读取指定的四字符编码来确定要使用的编解码器。
3.3 编解码器四字符编码
顾名思义,四字符代码由 4
个 ASCII
字符组成,也可以通过将它们相加后转换为整数。使用 cv::VideoCapture
实例 get
方法的 CAP_PROP_FOURCC
标志,可以获得打开的视频文件的编码。可以在 VideoProcessor
类中定义一个方法来返回输入视频的四字符编码:
// 获取输入视频的编码
int getCodec(char codec[4]) {
if (images.size() != 0) return -1;
union {
int value;
char code[4]; } returned;
returned.value = static_cast<int>(capture.get(cv::CAP_PROP_FOURCC));
codec[0] = returned.code[0];
codec[1] = returned.code[1];
codec[2] = returned.code[2];
codec[3] = returned.code[3];
return returned.value;
}
get
方法返回一个 double
类型值,然后需要将其转换为整数,表示提取的四字符编码。打开测试视频序列,获取四字符编码:
// 编码
char codec[4];
processor.getCodec(codec);
std::cout << "Codec: " << codec[0] << codec[1] << codec[2] << codec[3] << std::endl;
写入视频文件时,必须使用四字符编码指定编解码器,这是 cv::VideoWriter
类的 open
方法中的第二个参数。例如,可以使用与输入视频相同的选项 (setOutput
方法中的默认选项);也可以传递值 -1
,使用该值将弹出一个窗口,要求用户从可用编解码器列表中选择一个编解码器。可以在此窗口的列表中看到计算机上已安装的编解码器列表,然后所选编解码器的编码会自动传递到 open
方法。
4. 完整代码
头文件 (videoprocessor.h
) 和主函数文件 (videoprocessing.cpp
) 完整代码可以在 gitcode 中获取。
小结
本节介绍了如何读取、处理和存储视频序列,为了便于处理视频文件,创建了 videoProcessor
类来封装视频处理算法的通用函数。
系列链接
OpenCV实战(1)——OpenCV与图像处理基础
OpenCV实战(2)——OpenCV核心数据结构
OpenCV实战(3)——图像感兴趣区域
OpenCV实战(4)——像素操作
OpenCV实战(5)——图像运算详解
OpenCV实战(6)——OpenCV策略设计模式
OpenCV实战(7)——OpenCV色彩空间转换
OpenCV实战(8)——直方图详解
OpenCV实战(9)——基于反向投影直方图检测图像内容
OpenCV实战(10)——积分图像详解
OpenCV实战(11)——形态学变换详解
OpenCV实战(12)——图像滤波详解
OpenCV实战(13)——高通滤波器及其应用
OpenCV实战(14)——图像线条提取
OpenCV实战(15)——轮廓检测详解
OpenCV实战(16)——角点检测详解
OpenCV实战(17)——FAST特征点检测
OpenCV实战(18)——特征匹配
OpenCV实战(19)——特征描述符
OpenCV实战(20)——图像投影关系
OpenCV实战(21)——基于随机样本一致匹配图像
OpenCV实战(22)——单应性及其应用
OpenCV实战(23)——相机标定
OpenCV实战(24)——相机姿态估计
OpenCV实战(25)——3D场景重建