关闭

iOS-利用OpenCV Template Matching识别视频中的特定物体

  在视频或计算机视觉方面的应用中,有时需要识别视频中的特定物体。比如科幻片《头号玩家》中,反派的无人机在寻找主角车辆时,通过匹配之前拍摄的车辆特征图片来识别,并追踪打击。在新的iOS版本中,可以利用CoreML+Vision根据训练好的模型来识别,但此文介绍的是利用OpenCV库的Template Matching来识别,以应付一些简单的场合。我们最终要实现的是在视频中识别苹果Logo(这个Logo是事先拍好的),效果如下动图所示:

template matching

  整个项目主要通过以下几个步骤实现:
  一. 集成OpenCV最新的iOS版本Framework;
  二. 使用AVFoundation获取视频流;
  三. 利用OpenCV进行模版匹配(Template Matching);
  四. 绘制矩形提示位置。

想直接看源码的同学可以访问Github中的项目源码。下完源码记得手动下载OpenCV framework, 它太大了,无法直接放在项目中,老司机都懂,😂 。


一. 集成OpenCV最新的iOS版本Framework

  先在Github上下载OpenCV的最新Release版本:https://github.com/opencv/opencv/releases。iOS请选择文件opencv-3.4.1-ios-framework.zip(版本号可能更新)并解压。然后新建一个名为TamplateMatching的新iOS项目,因为需要以C++方式调用OpenCV的函数,所以语言选择Objective-C更加方便一点。最后将解压好的opencv2.framework文件拖到项目中,选择“Copy items if needed”。由于OpenCV部分宏定义和OC的同名,容易冲突,需要在引入iOS库之前#import ,所以建立PCH文件并在其顶部添加:

#ifdef __cplusplus
#import <opencv2/opencv.hpp>
#endif

之后将项目编译通过,准备工作完成。


二. 使用AVFoundation获取视频流

  使用AVFoundation获取视频流的方法网上已有很多,这里只做简单实现。从系统层面看,我们获取视频数据主体的步骤为:1.摄像头捕获视频流;2.输入到系统;3.系统再输出到我们的类中处理。相关的类:先通过AVCaptureDevice类提供用于视频输入的设备,然后绑定到AVCaptureDeviceInput类来提供具体的输入。视频流捕获行为由AVCaptureSession类管理,将AVCaptureDeviceInput对象加入到AVCaptureSession对象中来为Session提供输入。最后由AVCaptureVideoDataOutput类来提供视频的输出,输出的数据就由我们的类来处理。
在上面所建项目的ViewController中,我们直接进行视频拍摄处理。代码如下:

#import "ViewController.h"
#import <AVFoundation/AVFoundation.h>


@interface ViewController () <AVCaptureVideoDataOutputSampleBufferDelegate> {
}

@property (nonatomic,strong) AVCaptureSession *captureSession;
@property (nonatomic,strong) AVCaptureVideoDataOutput *videoOutput;
@property (nonatomic,strong) AVCaptureVideoPreviewLayer *videoPreviewLayer;

@end


@implementation ViewController

- (void)viewDidLoad {
    [super viewDidLoad];

    //检查视频权限,有授权则开始视频捕获
    [self checkAuthorization];
}

//检查视频权限
- (void)checkAuthorization {
    if ([AVCaptureDevice authorizationStatusForMediaType:AVMediaTypeVideo] == AVAuthorizationStatusAuthorized) {
        [self setupCaptureSession];
    }
    else{
        [AVCaptureDevice requestAccessForMediaType:AVMediaTypeVideo completionHandler:^(BOOL granted) {
            if (granted) {
                [self setupCaptureSession];
            } else {
                UIAlertController *alertController = [UIAlertController alertControllerWithTitle:@"未授权拍摄视频" message:@"请前往系统设置开放授权" preferredStyle:UIAlertControllerStyleAlert];
                UIAlertAction *okAction = [UIAlertAction actionWithTitle:@"好的" style:UIAlertActionStyleCancel handler:^(UIAlertAction * _Nonnull action) {}];
                [alertController addAction:okAction];
                [self presentViewController:alertController animated:YES completion:nil];
            }
        }];
    }
}

//配置视频并开始捕获
- (void)setupCaptureSession {
    NSArray *possibleDevices = [AVCaptureDevice devicesWithMediaType:AVMediaTypeVideo];
    AVCaptureDevice *videoDevice = [possibleDevices firstObject];
    if (!videoDevice) return;

    // 创建Session
    AVCaptureSession *session = [[AVCaptureSession alloc] init];
    [session beginConfiguration];

    // 添加输入设备
    NSError *error = nil;
    AVCaptureDeviceInput* input = [AVCaptureDeviceInput deviceInputWithDevice:videoDevice error:&error];
    [session addInput:input];

    // 添加输出
    self.videoOutput = [[AVCaptureVideoDataOutput alloc] init];
    //此项设置很重要,在我们处理视频帧图片,需要32BGRA格式。而系统默认的为JPEG格式,所以需要在此设置。
    [self.videoOutput setVideoSettings:@{(id)kCVPixelBufferPixelFormatTypeKey:@(kCVPixelFormatType_32BGRA)}];
    dispatch_queue_t queue = dispatch_queue_create("SampleBufferQueue", NULL);
    [self.videoOutput setSampleBufferDelegate:self queue:queue];
    [session addOutput:self.videoOutput];

    // 完成配置
    [session commitConfiguration];
    self.captureSession = session;

    // 添加视频预览层
    self.videoPreviewLayer = [AVCaptureVideoPreviewLayer layer];
    self.videoPreviewLayer.frame = self.view.layer.bounds;
    self.videoPreviewLayer.session = session;
    [self.view.layer addSublayer:self.videoPreviewLayer];

    // 开始视频
    [session startRunning];
}


#pragma mark - AVCaptureVideoDataOutputSampleBufferDelegate
- (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
    //我们在这里处理视频数据,用于识别特定物体
}

@end

完成这一步,运行APP,授予摄像权限,便可以看到视频拍摄画面。
  


三. 利用OpenCV进行模板匹配(Template Matching)

  使用OpenCV进行模版匹配时,我们要传给它一张大图,一张小的模板图,然后它在大图中找到模板图片的位置。对于视频的每一帧,我们都需要进行一次匹配,确定视频中是否存在该物体。我们建立一个类来专门负责这些匹配工作,向项目添加一个继承自NSObject的Cocoa Touch Class,命名为TemplateMatch。它与其他类的交互如下:

template matching

由于需要以C++方式调用OpenCV的相关函数,所以将.m文件的扩展名重命名为.mm,来进行Objective C++编码。

1. 模板匹配类头文件

头文件很简单,就是提供用于设置模板图片的属性,和执行匹配的方法:

#import <UIKit/UIKit.h>
#import <CoreMedia/CoreMedia.h>


@interface TemplateMatch : NSObject

@property(nonatomic,strong) UIImage *templateImage;     //模板图片。由于匹配方法会被多次调用,所以模板图片适合单次设定。

//在Buffer中匹配预设的模板,如果成功则返回位置以及区域大小。
//这里返回的Rect基于AVCapture Metadata的坐标系统,即值在0.0-1.0之间,方便AVCaptureVideoPreviewLayer类进行转换。
- (CGRect)matchWithSampleBuffer:(CMSampleBufferRef)sampleBuffer;

@end

  由于视频由不同的帧组成,而模板图片则固定不变。所以我们提供一个属性来设置模板图片,一次设完即可。而匹配行为则由一个方法实现,提供给外部多次调用。

2. 模板匹配类实现

2.1 准备工作

  OpenCV的模板匹配有个缺陷,就是如果模板图片和大图的比例相差太大,则无法匹配到。比如我们要在1000*1000像素的图片中,查找大概100*100像素大小的物体,但提供的模版图片只有30*30,那么匹配到的概率会大大降低。所以我们需要缩放模板图片来提高匹配概率。这里我们使用C++标准库容器来存放各个缩放等级的模板图片,以及用平方函数来计算各个等级缩放比例。类实现部分的开头如下:

#import "TemplateMatch.h"
#include <vector>
#include <math.h>

using namespace cv;
using namespace std;

@interface TemplateMatch() {
    UIImage *_templateImage;
    vector<Mat> _scaledTempls; //各个缩放等级的模板图片矩阵
}

@end

在类实现的开头部分我们定义一些常量,以便集中调整计算参数:

@implementation TemplateMatch

static const float resizeRatio = 0.35;     //原图缩放比例,越小性能越好,但识别度越低
static const int maxTryTimes = 4;          //未达到预定识别度时,再尝试的次数限制
static const float acceptableValue = 0.7;  //达到此识别度才被认为正确
static const float scaleRation = 0.75;     //当模板未被识别时,尝试放大/缩小模板。 指定每次模板缩小的比例

//......

@end
2.2 图片数据的转换

  OpenCV用矩阵类Mat来代表所处理的图片,所以需要将UIImage对象和CMSampleBufferRef数据转换为Mat对象,并将颜色调整为灰度以更好地适配OpenCV的一些函数。在类实现中添加以下方法:

//UIImage转为OpenCV灰图矩阵
- (Mat)cvMatGrayFromUIImage:(UIImage *)image {
    Mat img;
    Mat img_color = [self cvMatFromUIImage:image];
    cvtColor(img_color, img, CV_BGR2GRAY);

    return img;
}

//UIImage转为OpenCV矩阵
- (Mat)cvMatFromUIImage:(UIImage *)image {
    CGColorSpaceRef colorSpace = CGImageGetColorSpace(image.CGImage);
    CGFloat cols = image.size.width;
    CGFloat rows = image.size.height;

    Mat cvMat(rows, cols, CV_8UC4); // 8位图, 4通道 (颜色 通道 + alpha)

    CGContextRef contextRef = CGBitmapContextCreate(cvMat.data,                 // 数据来源
                                                    cols,                       // 宽
                                                    rows,                       // 高
                                                    8,                          // 8位
                                                    cvMat.step[0],              // 每行字节
                                                    colorSpace,                 // 颜色空间
                                                    kCGImageAlphaNoneSkipLast |
                                                    kCGBitmapByteOrderDefault); // Bitmap图信息

    CGContextDrawImage(contextRef, CGRectMake(0, 0, cols, rows), image.CGImage);
    CGContextRelease(contextRef);

    return cvMat;
}

//Buffer转为OpenCV矩阵
- (Mat)cvMatFromBuffer:(CMSampleBufferRef)buffer {
    CVImageBufferRef pixelBuffer = CMSampleBufferGetImageBuffer(buffer);
    CVPixelBufferLockBaseAddress( pixelBuffer, 0 );

    //取得高宽,以及数据起始地址
    int bufferWidth = (int)CVPixelBufferGetWidth(pixelBuffer);
    int bufferHeight = (int)CVPixelBufferGetHeight(pixelBuffer);
    unsigned char *pixel = (unsigned char *)CVPixelBufferGetBaseAddress(pixelBuffer);

    //转为OpenCV矩阵
    Mat mat = Mat(bufferHeight,bufferWidth,CV_8UC4,pixel,CVPixelBufferGetBytesPerRow(pixelBuffer));

    //结束处理
    CVPixelBufferUnlockBaseAddress( pixelBuffer, 0 );

    //转为灰度图矩阵
    Mat matGray;
    cvtColor(mat, matGray, CV_BGR2GRAY);

    return matGray;
}
2.3 模板图片属性的设置

  模板图片一次设置,多次复用。一些准备性工作也在设置时完成,比如各个缩放比例的版本。另外,对于一张iPhone拍摄的原图,OpenCV模板匹配的性能其实并不高,通常会占用5-10秒的时间。这在视频捕获的应用场景中延时太高了,所以我们需要对模板图片和原图进行同比例压缩以提高性能。这里还有一个图片横屏竖屏问题,AVFoundation提供的视频帧默认是横屏的,相当于iPhone Home键在右侧时拍摄那样。如下图所示:

coordinates

所以要把模板图片逆时针旋转90度。在实际应用中,应该在ViewController中根据手机的旋转情况,对模板图片做相应的旋转,然后提供给TemplateMatch类。这里先简单处理下,就默认为竖屏方式。模板图片属性设置过程如下:

//设置模板图片
//由于拍摄会存在拉远拉近的行为,所以需要建立不同大小的模板图片,进行多次匹配
- (void)setTemplateImage:(UIImage *)templateImage {
    //保存默认模板图,并取得模板矩阵
    _templateImage = templateImage;
    Mat templUp = [self cvMatGrayFromUIImage:templateImage];

    //本例子默认采用竖屏拍摄,而AVFoundation提供的数据为横屏模式,所以需要将模板图逆时针旋转90度
    //更好的方式,是在ViewController中根据屏幕方向动态旋转模板图,并重新赋值。这里暂时简化处理。
    Mat templ;
    cv::rotate(templUp, templ, ROTATE_90_COUNTERCLOCKWISE);

    //设置新模板,需清空旧模板
    _scaledTempls.clear();

    //为了提高性能,模板图和原图进行同比列压缩
    Mat templResized;
    resize(templ, templResized, cv::Size(0, 0), resizeRatio, resizeRatio);
    _scaledTempls.push_back(templResized); //默认模板图也存放于模板数组中,以便循环匹配

    //由于模板图和原图大小比例不一致,需要放大缩小模板图,来多次比较。所以建立不同比例的模板图。
    for(int i=0;i
2.4 进行匹配

  匹配时,我们接受AVFoundation提供的CMSampleBufferRef视频帧数据,然后将它转换成Mat矩阵。同模板图片一样,对矩阵进行压缩以提高性能。之后,在一个循环里依次取出各个缩放等级的模板图和大图进行匹配。由于AVCapture Metadata的坐标系统的值范围在(-1,1)之间,所以在匹配成功后,要按位置在图中所处的比例来进行换算。这也正好省略了将位置绝对值还原的到图压缩前的值这步计算。代码如下:

//接受Buffer进行匹配
- (CGRect)matchWithSampleBuffer:(CMSampleBufferRef)sampleBuffer {
    Mat img = [self cvMatFromBuffer:sampleBuffer]; //Buffer转换到矩阵

    //如果图片为空,则返回空值
    if (resizeRatio <= 0 || img.cols <= 0 || img.rows <= 0) {
        return CGRectZero;
    }

    //为了提高性能,将原图缩小。模板图也已同比例缩小。
    Mat imgResized = Mat();
    resize(img, imgResized, cv::Size(0, 0), resizeRatio, resizeRatio);

    //进行匹配
    cv::Rect rect = [self matchWithMat:imgResized];

    //除以行列数得到点位置在全图中的比例,转为AVCapture Metadata的坐标系统
    CGPoint point = CGPointMake(rect.x / CGFloat(imgResized.cols), rect.y  / CGFloat(imgResized.rows));
    CGSize templSize = CGSizeMake(rect.width / CGFloat(imgResized.cols), rect.height / CGFloat(imgResized.rows));

    return CGRectMake(point.x, point.y, templSize.width, templSize.height);
}

//调用OpenCV进行匹配
//此方法具体解释参考OpenCV官方文档: https://docs.opencv.org/3.2.0/de/da9/tutorial_template_matching.html
- (cv::Rect)matchWithMat:(Mat)img {
    double minVal;
    double maxVal;
    cv::Point minLoc;
    cv::Point maxLoc;

    //匹配不同大小的模板图
    for (int i=0; i < _scaledTempls.size(); i++) {
        Mat templ = _scaledTempls[i];

        //创建结果矩阵,用于存放单次匹配到的位置信息(单次会匹配到很多,后面根据不同算法取最大或最小值)
        int result_cols = img.cols - templ.cols + 1;
        int result_rows = img.rows - templ.rows + 1;
        Mat result;
        result.create(result_rows, result_cols, CV_32FC1);

        //OpenCV匹配
        matchTemplate(img, templ, result, TM_CCOEFF_NORMED);

        //整理出本次匹配的最大最小值
        minMaxLoc(result, &minVal, &maxVal, &minLoc, &maxLoc, Mat());

        //TM_CCOEFF_NORMED算法,取最大值为最佳匹配
        //当最大值符合要求,认为匹配成功
        if (maxVal >= acceptableValue) {
            NSLog(@"matched point:%d,%d maxVal:%f, tried times:%d",maxLoc.x,maxLoc.y,maxVal,i + 1);
            return cv::Rect(maxLoc,cv::Size(templ.rows,templ.cols));
        }
    }

    //未匹配到,则返回空区域
    return cv::Rect();
}

3. 回到ViewController

  在TemplateMatch类完成后,我们就可以回到ViewController进行调用了。先是引入TemplateMatch类头文件、声明对象、初始化等等,然后将模板图片赋给它。 模板图片也是从事先拍好的照片中截取出来的。代码如下(只列出了增加的代码):

//......
#import "TemplateMatch.h"

@interface ViewController ()  {
    TemplateMatch *templateMatch;
}

//......

@end

@implementation ViewController

- (void)viewDidLoad {
    //......

    //初始化模板匹配对象,并设置模板图
    templateMatch = [[TemplateMatch alloc] init];
    templateMatch.templateImage = [UIImage imageNamed:@"apple"];

    //.....
}

接下来就是视频捕获代理方法中,进行调用了:

- (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
    CGRect rect = [templateMatch matchWithSampleBuffer:sampleBuffer]; //将buffer提交给OpenCV进行模板匹配
    dispatch_async(dispatch_get_main_queue(), ^{
        if (!CGRectEqualToRect(rect,CGRectZero)) { //匹配成功,则绘制标识框

        }
        else{ //未匹配到,则隐藏标识框

        }
    });
}

就这么几行代码,是不是使用起来So easy? 😀


四. 绘制矩形提示位置

  最后,要将匹配到位置区域绘制到屏幕上。这里我们就简单增加一个CALayer, 然后设置好红色边框,再根据位置的不同调整下Frame, 就可以有一个动态红框框了。先声明一个对象:

@interface ViewController ()  {
    //......
    CALayer *rectangleLayer;
}

再定义绘制方法:

// 绘制标识框
- (void)drawRectangle:(CGRect)rect {
    if (rectangleLayer == nil) {
        rectangleLayer = [CALayer layer];
        rectangleLayer.frame = CGRectMake(0, 0, templateMatch.templateImage.size.width, templateMatch.templateImage.size.height);
        [rectangleLayer setBorderWidth:2.0];
        [rectangleLayer setBorderColor:[UIColor.redColor CGColor]];
        [self.view.layer addSublayer:rectangleLayer];
    }

    rectangleLayer.frame = CGRectMake(rect.origin.x, rect.origin.y, rect.size.width, rect.size.height);
    rectangleLayer.hidden = NO;
}

// 隐藏标识框
- (void)hideRectangle {
    rectangleLayer.hidden = YES;
}

  终于到了世界的尽头,我们再完善下视频捕获代理方法。由于视频的尺寸和屏幕宽高比不一定一致, 再加上点坐标对应的是横屏方式,所以绘制红框时需要进行坐标转换。幸运的是AVFoundation已提供现有方法rectForMetadataOutputRectOfInterest来转换:

- (void)captureOutput:(AVCaptureOutput *)output didOutputSampleBuffer:(CMSampleBufferRef)sampleBuffer fromConnection:(AVCaptureConnection *)connection {
    CGRect rect = [templateMatch matchWithSampleBuffer:sampleBuffer]; //将buffer提交给OpenCV进行模板匹配
    dispatch_async(dispatch_get_main_queue(), ^{
        if (!CGRectEqualToRect(rect,CGRectZero)) { //匹配成功,则绘制标识框
            [self drawRectangle:[self.videoPreviewLayer rectForMetadataOutputRectOfInterest:rect]]; //由于视频的尺寸和屏幕宽高比不一定一致,所以对于视频中的一个点坐标,需要转换到屏幕的对应位置中。
        }
        else{ //未匹配到,则隐藏标识框
            [self hideRectangle];
        }
    });
}

  这样一个关于Template Matching的项目就完成了,总共才300行不到代码,很容易阅读和掌握。

本文对应Demo源码下载。下完源码记得手动下载OpenCV framework, 它太大了,无法直接放在项目中。
原创文章,如需转载,请注明出处(https://blog.happyyun.com),非常感谢!

来评论一下吧!

您的邮箱地址不会被公开。 必填项已标记 *