ffmpeg开发教程(6) : 使用OpenCV处理图像

OpenCV的全称是Open Source Computer Vision Library,是一个跨平台的计算机视觉库。本例的代码和ffmpeg开发教程(4) : 第一个简单例子
非常类似。打开视频后,不是吧视频帧图像保存到硬盘中,而是使用OpenCV来对图像进行处理,比如显示图像的轮廓线。OpenCV本身也可以直接打开视频(OpenCV内部也使用 ffmpeg的函数)。

OpenCV 使用BGR24图像格式(AV_PIX_FMT_BGR24)

sws_getContext
                    (
                            pCodecContext->width,
                            pCodecContext->height,
                            pCodecContext->pix_fmt,
                            pCodecContext->width,
                            pCodecContext->height,
                            AV_PIX_FMT_BGR24,
                            SWS_BILINEAR,
                            nullptr,
                            nullptr,
                            nullptr
                    );

OpenCV图像数据的基本数据机构是Mat类型,可以直接把AVFrame数据转换成Mat格式:

Mat image(height, width, CV_8UC3, pFrameRGB->data[0], pFrameRGB->linesize[0]);

然后我们就可以使用OpenCV的一些图像处理的函数来对图像进行处理,本例使用了findContours和approxPolyDP 来获取图像的外轮廓,并对外轮廓进行一些平滑处理:

void opencv_get_contours(Mat img, vector<vector<Point> > &contours, vector<Vec4i> &hierarchy) {
    Mat image;
    Mat canny_output;
    int thresh = 100;
    vector<vector<Point> > contours1;
    cv::cvtColor(img, image, COLOR_BGR2GRAY);
    Canny(image, canny_output, thresh, thresh * 2, 3);

    findContours(canny_output, contours1, hierarchy,
                 RETR_TREE, CHAIN_APPROX_SIMPLE);

    int total_points = 0;
    int total_points1 = 0;
    contours.resize(contours1.size());
    for (int i = 0; i < contours1.size(); i++) {
        total_points += contours1[i].size();
        double area = contourArea(contours1[i]);
        if (area > 8) {
            approxPolyDP(contours1[i], contours[i], 2, true);
        } else {
            contours[i] = contours1[i];
        }
        total_points1 += contours[i].size();
    }
    cout << "Before approxPolyDP: " << total_points
         << " After approxPolyDP:" << total_points1 << " Shape:" << contours.size() << "\n";

}

void
opencv_process_frame(int width, int height, AVFrame *pFrameRGB, bool show,
                     int max_size, bool color) {
    Mat image(height, width, CV_8UC3, pFrameRGB->data[0], pFrameRGB->linesize[0]);
    cv::Mat greyMat;
    Mat outImg;

    int maxSize = max(image.size().width, image.size().height);
    float scale = (float) max_size / (float) maxSize;
    cv::resize(image, outImg, cv::Size(), scale, scale);
    if (!color) {
        cv::cvtColor(outImg, greyMat, COLOR_BGR2GRAY);
        outImg = greyMat;
    }
    int dst_width = outImg.size().width;
    int dst_height = outImg.size().height;
    int length = outImg.total() * outImg.elemSize();


    vector<vector<Point> > contours;
    vector<Vec4i> hierarchy;

    opencv_get_contours(image, contours, hierarchy);
    RNG rng(12345);
    Mat newImage = Mat::zeros(image.size(), CV_8UC3);
    for (int i = 0; i < contours.size(); i++) {
        InputArray contour = contours[i];
        double area = contourArea(contour);
        Scalar color = Scalar(255, 255, 255);
        drawContours(newImage, contours, i, color, 1, 8, hierarchy, 0, Point());
    }

    resizeWindow("opencv", dst_width, dst_height);
    imshow("opencv", newImage);
    waitKey(1);
    imwrite("screen_" + to_string(frame_counter++) + ".jpg", newImage);
    cout << frame_counter << endl;

}

由于OpenCV的运算量比较大,因此在我本机是运行时,视频有些滞后,我们把处理后的图像保存到硬盘上(调用imwrite),然后使用视频处理软件(Adobe Premiere Pro)。得到如下效果:

 

ffmpeg开发教程(5) : 使用SDL2 显示视频

上篇我们保存了视频的前500帧到硬盘中,本篇稍微修改下使用SDL2 显示视频,SDL2是个跨平台的用户界面软件包(支持音频,视频,键盘,鼠标以及游戏杆,3D图像显示)。

首先使用vcpkg install SDL2 下载安装SDL2软件包。

The package sdl2:x64-osx provides CMake targets:

    find_package(SDL2 CONFIG REQUIRED)
    target_link_libraries(main PRIVATE SDL2::SDL2 SDL2::SDL2main)

安装提示如何在CMake引用SDL2,因此我们修改下根CMakeList.txt, 添加对SDL2库的引用:

find_package(SDL2 CONFIG REQUIRED)

...
target_link_libraries(${TUTORIAL_TARGET} PRIVATE SDL2::SDL2 SDL2::SDL2main)

SDL2具体用法这里不介绍,基本用法如下:

// Make a screen to put our video
    screen = SDL_CreateWindow(
            "FFmpeg Tutorial02",
            SDL_WINDOWPOS_UNDEFINED,
            SDL_WINDOWPOS_UNDEFINED,
            pCodecContext->width,
            pCodecContext->height,
            0
    );

renderer = SDL_CreateRenderer(screen, -1, SDL_RENDERER_ACCELERATED);

// Allocate a place to put our YUV image on that screen
    texture = SDL_CreateTexture(
            renderer,
            SDL_PIXELFORMAT_YV12,
            SDL_TEXTUREACCESS_STREAMING,
            pCodecContext->width,
            pCodecContext->height
    );

...

首先创建Windows对象SDL_CreateWindow,然后创建Windows相应的渲染器。然后我们创建一个和视频大小相当的YUV格式的图像空间(Texture)。

有了这个Texture对象,我们使用直接使用SDL_UpdateYUVTexture,把解码后的AVFrame数据拷贝到这个Texture对象上:

SDL_UpdateYUVTexture(texture, NULL,
                                     pFrame->data[0], pFrame->linesize[0],
                                     pFrame->data[1], pFrame->linesize[1],
                                     pFrame->data[2], pFrame->linesize[2]);

然后使用渲染器(Render)显示图像到屏幕:

SDL_RenderClear(renderer);
                SDL_RenderCopy(renderer, texture, nullptr, nullptr);
                SDL_RenderPresent(renderer);

完整代码参见tutorialo2.cpp.

tutorial02 没有考虑显示的帧速,是按照能显示多快就显示多快来显示的,所以会给你感觉快进的效果。而且也没有考虑音频播放,以及音频和视频的同步的问题。
因为我后面的项目只处理视频,因此这部分方面本教程就不涉及了。

ffmpeg开发教程(4) : 第一个简单例子

上篇提到FFmpeg解码的一个基本流程:

本篇为一个简单的例子,读取一个视频文件,比如bunny.mp4 , 读取视频的基本信息,然后解码该视频,并把前500帧的图像存储到硬盘。

C++中调用FFmpeg API

之前说过FFmpeg API是以C接口提供,在C++调用,需要说明调用的FFmpeg接口为C接口,编译链接时才能正确链接。

ffmpeg-tutorialtutorial.hpp中我们使用extern “C”  标明ffmpeg的引用为C接口

#if defined (__cplusplus)
extern "C" {
#endif
#include <libavcodec/avcodec.h>
#include <libavformat/avformat.h>
#include <libswscale/swscale.h>
#include <libavutil/imgutils.h>
#include <cstdio>
#if defined (__cplusplus)
}

定义好头文件中ffmpeg引用后,我们就可以使用ffmpeg 函数来操作视频文件。

读取视频文件

AVFormatContext *pFormatContext = avformat_alloc_context();
    // Open video file
    if (avformat_open_input(&pFormatContext, argv[1], nullptr, nullptr) != 0)
        return -1; // Couldn't open file

    // Retrieve stream information
    if (avformat_find_stream_info(pFormatContext, nullptr) < 0)
        return -1; // Couldn't find stream information

    // Dump information about file onto standard error
    av_dump_format(pFormatContext, 0, argv[1], 0);

argv[1] 命令行参数指明视频文件名,比如 bunny.mp4, AVFormatContext 为视频格式数据结构(帮助记忆,可以把比AVFormatContext看做文件指针),保存了该视频的一些参数,。如视频,音频参数,编解码格式,片场等。av_dump_format 显示了这些参数,比如bunny.mp4的参数显示如下:

Input #0, mov,mp4,m4a,3gp,3g2,mj2, from './bunny.mp4':
  Metadata:
    major_brand     : mp42
    minor_version   : 0
    compatible_brands: isomavc1mp42
    creation_time   : 2010-01-10T08:29:06.000000Z
  Duration: 00:09:56.47, start: 0.000000, bitrate: 2119 kb/s
    Stream #0:0(und): Audio: aac (LC) (mp4a / 0x6134706D), 44100 Hz, stereo, fltp, 125 kb/s (default)
    Metadata:
      creation_time   : 2010-01-10T08:29:06.000000Z
      handler_name    : (C) 2007 Google Inc. v08.13.2007.
    Stream #0:1(und): Video: h264 (High) (avc1 / 0x31637661), yuv420p, 1280x720 [SAR 1:1 DAR 16:9], 1991 kb/s, 24 fps, 24 tbr, 24k tbn, 48 tbc (default)
    Metadata:
      creation_time   : 2010-01-10T08:29:06.000000Z
      handler_name    : (C) 2007 Google Inc. v08.13.2007.

查找视频流

一个视频文件比如(mp4, avi)中包含了多个和多种数据流,有的为音频数据,有的为视频数据或是字幕数据,或者有多个视频,音频,字幕数据流。一般来说只包含一个视频,下面代码查找第一个视频数据流。视频数据流的类型为AVMEDIA_TYPE_VIDEO

int videoStream = -1;
    AVCodecParameters *pCodecParameters = nullptr;
    for (int i = 0; i < pFormatContext->nb_streams; i++) {
        pCodecParameters = pFormatContext->streams[i]->codecpar;
        if (pCodecParameters->codec_type == AVMEDIA_TYPE_VIDEO) {
            videoStream = i;
            break;
        }
    }

查找并初始化视频解码器

找到视频数据流后,就需要对应的解码器来解压视频数据,AVCodec,AVCodecContext 为对于的解码器的数据结构。

// Find the decoder for the video stream
    AVCodec *pCodec = avcodec_find_decoder(pCodecParameters->codec_id);
    if (pCodec == nullptr) {
        fprintf(stderr, "Unsupported codec!\n");
        return -1; // Codec not found
    }
    AVCodecContext *pCodecContext = avcodec_alloc_context3(pCodec);
    if (!pCodecContext) {
        printf("failed to allocated memory for AVCodecContext");
        return -1;
    }

    if (avcodec_parameters_to_context(pCodecContext, pCodecParameters) < 0) {
        printf("failed to copy codec params to codec context");
        return -1;
    }

    if (avcodec_open2(pCodecContext, pCodec, nullptr) < 0) {
        printf("failed to open codec through avcodec_open2");
        return -1;
    }

解码视频数据

有了解码器,可以从视频中读取数据包(AVPacket)。AVPacket 为压缩过的数据包,使用函数av_read_frame(pFormatContext, pPacket) 将数据读取到AVPacket中。

AVPacket *pPacket = av_packet_alloc();

// Read frames and save first 500 frames to disk

while (av_read_frame(pFormatContext, pPacket) >= 0) {
    // Is this a packet from the video stream?
    if (pPacket->stream_index == videoStream) {

        // Decode video frame
        int frameFinished = decode_frame(pCodecContext, pFrame, pPacket);

        // Did we get a video frame?
        if (frameFinished) {
          ...
        }
    }

    // Free the packet that was allocated by av_read_frame
    av_packet_unref(pPacket);
}

int decode_frame(AVCodecContext *pCodecContext,
                 AVFrame *pFrame,
                 const AVPacket *pPacket) {
    int frameFinished = 0;
    int ret = avcodec_receive_frame(pCodecContext, pFrame);
    if (ret == 0)
        frameFinished = 1;
    if (ret == 0 || ret == AVERROR(EAGAIN))
        avcodec_send_packet(pCodecContext, pPacket);
    return frameFinished;
}

对于视频,它的帧有多重格式,有I,B,P帧,其中I类型的帧为完整的帧,B类和P类依赖于其它帧(只记录了变化量,这也是压缩的基本思想),因此decode_frame 可能返回不是完整的帧,我们定义一个函数decode_frame,它的返回值frameFinished标明返回的是否是完整的图像帧。

保存彩色图像到硬盘

bunny.mp4图像采用的yuv420p 标准,其中的Y为灰度,你可以参照0_hello_world中直接使用AVFrame保存黑白图像到硬盘。如果需要保存彩色图像,我们需要对图像格式进行转换,也就是从YUV到RGB格式转换。ffmpeg中的swscale 接口可以完成这项工作。

// Allocate an AVFrame structure
AVFrame *pFrameRGB = av_frame_alloc();
if (pFrameRGB == nullptr)
    return -1;

// Determine required buffer size and allocate buffer
int numBytes = av_image_get_buffer_size(AV_PIX_FMT_RGB24, pCodecContext->width,
                                        pCodecContext->height, IMAGE_ALIGN);
uint8_t *buffer = (uint8_t *) av_malloc(numBytes * sizeof(uint8_t));

struct SwsContext *sws_ctx =
        sws_getContext
                (
                        pCodecContext->width,
                        pCodecContext->height,
                        pCodecContext->pix_fmt,
                        pCodecContext->width,
                        pCodecContext->height,
                        AV_PIX_FMT_RGB24,
                        SWS_BILINEAR,
                        nullptr,
                        nullptr,
                        nullptr
                );
        ...

// Convert the image from its native format to RGB
    sws_scale
            (
                    sws_ctx,
                    (uint8_t const *const *) pFrame->data,
                    pFrame->linesize,
                    0,
                    pCodecContext->height,
                    pFrameRGB->data,
                    pFrameRGB->linesize
            );

    ...
    
void save_frame(AVFrame *pFrame,
                int width, int height, int iFrame) {
    FILE *pFile;
    char szFilename[32];
    int y;
    // Open file
    sprintf(szFilename, "frame%d.ppm", iFrame);
    pFile = fopen(szFilename, "wb");
    if (pFile == nullptr)
        return;
    // Write header
    fprintf(pFile, "P6\n%d %d\n255\n", width, height);
    // Write pixel data
    for (y = 0; y < height; y++)
        fwrite(pFrame->data[0] + y * pFrame->linesize[0], 1, width * 3, pFile);
    // Close file
    fclose(pFile);
}

完整代码参见tutorial01.cpp, 运行可以看到bunny.mp4前500帧图像:

ffmpeg开发教程(3) : 关于视频的一些基本概念

后续教程参考ffmpeg-libav-tutorialAn ffmpeg and SDL Tutorial, 对代码进行了一些修改,而且采用C++作为开发语言(其实为C++和C混合编程,ffmpeg 软件包提供的API为C语言接口)

视频

我们知道视频其实是利用的人的视觉暂留现象(比如1秒钟显示24帧图像),经过我们的大脑处理后就成了连贯的图像。

编解码 Codec –压缩数据

原始的视频图像,如果没有经过压缩,那我们来计算一个30分钟的视频,假定图像的分辨率为 1080X1920,采用RGB格式(3个字节,16,777,216种颜色),帧率为24/秒。

toppf = 1080 * 1920 //total_of_pixels_per_frame
cpp = 3 //cost_per_pixel
tis = 30 * 60 //time_in_seconds
fps = 24 //frames_per_second

required_storage = tis * fps * toppf * cpp

那么这段30分钟视频大约需要250.28G的存储空间,带宽需要1.11Gbps。数据量很大,因此需要对图像进行压缩,这也是我们为什么需要编解码codec 算法。常见的codec 算法有h264。

视频文件 — 存储音频和视频

一般的影像同时包含图像和声音,如何有效的将图像和声音保存在一个文件,并提供图像和声音的同步,比如我们常见的视频文件avi, mp4, mkv就是不同的视频文件格式,有些视频同时还包含了字幕信息。这些视频文件就像一个包装盒,将不同的声音,图像和其它比如字幕信息有效的管理保存起来。

FFmpeg libav 基本架构

下图是libav解码的一个基本流程:

ffmpeg开发的一个基本过程就是先把视频文件(比如bunny.mp4) 载入到一个AVFormatContext 结构中(实际是读入视频文件头信息)

然后从载入的视频头信息中,我们可以从中了解到该视频有几个视频,几个音频(比如左右声道),我们本教程只关心视频。可以将视频读入到AVStream数据结构中。

从AVSteam (Stream,流意为连续的数据流)。可以分解出视频数据包AVPacket。AVPacket 还是编码过(压缩)的数据,需要经过解码后(使用相应的AVCodec)得到AVFrame,AVFrame 不在是压缩的,可以直接显示在屏幕上。有了AVFrame后,FFmpeg提供了很多滤镜算法,可以对图像进行处理后再显示,或者在使用不同的编解码器(Codec)转换成其它视频格式,比如从mp4到webm等等。

ffmpeg开发教程(2) :编译第一个示例项目

ffmpeg-tutorial 克隆到本地, 其项目结构如下:

.
├── CMakeLists.txt
├── README.md
├── apps
│   ├── CMakeLists.txt
│   └── tutorial01
│       └── CMakeLists.txt
├── bunny.mp4
├── cmake-build-debug
├── docs
│   └── CMakeLists.txt
├── include
│   ├── fact.hpp
│   └── tutorial.hpp
├── libs
│   ├── CMakeLists.txt
│   └── FFmpeg
│       └── CMakeLists.txt
├── src
│   ├── fact.cpp
│   ├── tutorial01.cpp
│   └── tutorial02.cpp
└── tests
    ├── CMakeLists.txt
    └── all_tests.cpp

根目录下CMakeLists.txt为总控CMakeLists文件,子目录下的CMakeLists.txt比如apps, docs, libs, tests下为子目录CMakeLists文件,分别用来编译应用,文档,引用到的软件包和单元测试。

我们先来看下根目录下的CMakeLists定义:

cmake_minimum_required(VERSION 3.12)
if (DEFINED ENV{VCPKG_ROOT} AND NOT DEFINED CMAKE_TOOLCHAIN_FILE)
    set(CMAKE_TOOLCHAIN_FILE "$ENV{VCPKG_ROOT}/scripts/buildsystems/vcpkg.cmake"
            CACHE STRING "")
endif ()

set(VCPKG_TARGET_TRIPLET $ENV{VCPKG_DEFAULT_TRIPLET} CACHE INTERNAL "" FORCE)

project(ffmpeg_tutorial)

function(TUTORIAL name)
    set(TUTORIAL_TARGET ${name})
    add_executable(${TUTORIAL_TARGET} ${CMAKE_HOME_DIRECTORY}/src/${TUTORIAL_TARGET}.cpp)
    target_link_libraries(${TUTORIAL_TARGET} PRIVATE FFmpeg)
    if (WIN32)
        install(TARGETS ${name}
                RUNTIME DESTINATION ${CMAKE_BINARY_DIR})
    endif ()

endfunction(TUTORIAL)

include_directories("include" )
add_subdirectory(libs)
add_subdirectory(apps)
add_subdirectory(docs)
add_subdirectory(tests)

上篇文章我们提到使用vcpkg来管理C++软件包,因此在根CMakeLists文件中我们可以设定MAKE_TOOLCHAIN_FILE,指到vcpkg的目录,这样使用vcpkg安装所依赖的软件包后,CMake可以找到所需的库文件定义和引用。

而set(VCPKG_TARGET_TRIPLET $ENV{VCPKG_DEFAULT_TRIPLET} CACHE INTERNAL “” FORCE)
定义库定义对应的操作系统和计算机硬件架构比如32位或是64位, 使用静态库还是动态库。
最后添加各个子目录的CMakeLists定义。

使用命令行编译 (Mac,Linux,Windows)

mkdir build
cd build
cmake ..
ninja

使用CLion(Mac,Linux,Windows)

使用CLion前需要配置CMake

-DCMAKE_TOOLCHAIN_FILE=~/Workspace/vcpkg/scripts/buildsystems/vcpkg.cmake -GNinja

使用XCode (Mac)

首先使用CMake 生成XCode workspace 文件。

mkdir xcode
cd xcode
cmake -G Xcode ..

-- The C compiler identification is AppleClang 11.0.0.11000033
-- The CXX compiler identification is AppleClang 11.0.0.11000033
-- Check for working C compiler: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang
-- Check for working C compiler: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang -- works
-- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang++
-- Check for working CXX compiler: /Applications/Xcode.app/Contents/Developer/Toolchains/XcodeDefault.xctoolchain/usr/bin/clang++ -- works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done

生成ffmpeg_tutorial.xcodeproj,就可以使用XCode编译调试该项目了:

使用 Visual Studio (Windows)

同样可以使用-G 来生成 Visual Studio 项目

cmake -G "Visual Studio 16 2019" -A x64

cmake -G "Visual Studio 15 2017" -A Win32

cmake -G "Visual Studio 15 2017" -A x64

cmake -G "Visual Studio 15 2017" -A ARM

cmake -G "Visual Studio 15 2017" -A ARM64

对于不同的Visual Studio 版本和 32位,64位静态使用不同的参数,比如使用Visual Studio 2017,64位机器

C:\Workspace\ffmpeg-tutorial\vs2017 (master -> origin)
λ cmake -G "Visual Studio 15 2017" -A x64 ..
-- Selecting Windows SDK version 10.0.18362.0 to target Windows 10.0.18363.
-- The C compiler identification is MSVC 19.16.27035.0
-- The CXX compiler identification is MSVC 19.16.27035.0
-- Check for working C compiler: C:/Program Files (x86)/Microsoft Visual Studio/2017/Enterprise/VC/Tools/MSVC/14.16.27023/bin/Hostx86/x64/cl.exe
-- Check for working C compiler: C:/Program Files (x86)/Microsoft Visual Studio/2017/Enterprise/VC/Tools/MSVC/14.16.27023/bin/Hostx86/x64/cl.exe - works -- Detecting C compiler ABI info
-- Detecting C compiler ABI info - done
-- Detecting C compile features
-- Detecting C compile features - done
-- Check for working CXX compiler: C:/Program Files (x86)/Microsoft Visual Studio/2017/Enterprise/VC/Tools/MSVC/14.16.27023/bin/Hostx86/x64/cl.exe
-- Check for working CXX compiler: C:/Program Files (x86)/Microsoft Visual Studio/2017/Enterprise/VC/Tools/MSVC/14.16.27023/bin/Hostx86/x64/cl.exe - works
-- Detecting CXX compiler ABI info
-- Detecting CXX compiler ABI info - done
-- Detecting CXX compile features
-- Detecting CXX compile features - done
-- Configuring done
-- Generating done
-- Build files have been written to: C:/Workspace/ffmpeg-tutorial/vs2017

然后使用Visual Studio 2017打开ffmpeg_tutorial.sln

使用VSCode (Mac,Linux, Windows)

VSCode可以直接打开该项目,只是需要创建一个运行的配置文件,参考如下:

{
    // Use IntelliSense to learn about possible attributes.
    // Hover to view descriptions of existing attributes.
    // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
    "version": "0.2.0",
    "configurations": [
        {
            "name": "(lldb) Launch",
            "type": "cppdbg",
            "request": "launch",
            "program": "${workspaceFolder}/build/apps/tutorial01/tutorial01",
            "args": ["./bunny.mp4"],
            "stopAtEntry": false,
            "cwd": "${workspaceFolder}",
            "environment": [],
            "externalConsole": false,
            "MIMode": "lldb"
        }
    ]
}

之后的教程,你可以选择自己喜欢的开发环境,或者直接命令行。