背景说明

在开发机器视觉应用时,Python 这一脚本语言很适合作为胶水将其它语言编写的组件粘贴起来,也就是说控制逻辑部分变动比较快的话,使用 JS, Lua 或 Python 来写,除了开发速度快,还有利于跨平台运行;而底层的基础设施 Infra 则采用机器执行效率更高的语言,甚至可以为不同的硬件做汇编指令级别的定制。


本文要解决的问题,包括:

  • 从源码编译并安装 OpenCV 4.x(如 4.4.5)
  • 在 C++ 代码中调用 OpenCV 库,完成简单的图像矩阵化操作
  • 将 C++ 代码编译并链接成 .so 动态链接库
  • 使用 make 工具链,自动化完成构建工作
  • 在 Python 中调用动态连接库

编译 OpenCV

安装依赖

# [compiler] 编译器
sudo apt-get install build-essential
# [required] 必备的依赖:git 版本控制、构建工具、视频编码库等
sudo apt-get install cmake git libgtk2.0-dev pkg-config libavcodec-dev libavformat-dev libswscale-dev
# [optional] 可选的依赖:Python 集成、文件格式支持(jpg, png, tiff)等
sudo apt-get install python-dev python-numpy libtbb2 libtbb-dev libjpeg-dev libpng-dev libtiff-dev libjasper-dev libdc1394-22-dev

下载源码

下文以 $BASE_DIR 指代源码存放的目录。

cd $BASE_DIR
git clone https://github.com/opencv/opencv.git --depth 1
git clone https://github.com/opencv/opencv_contrib.git --depth 1

注意:--depth == 1 表示只获取最新的代码,至于提交历史和文件过往版本则不会下载,大大节省下载的数据量。

准备用于构建的目录

进入 opencv repo 所在的目录,创建 build 临时目录,用于存放构建的结果。

cd $BASE_DIR/opencv
mkdir build
cd build

运行 cmake 命令,生成 MakeFile 文件。

cmake \
-D CMAKE_BUILD_TYPE=Release \ # 发行版
-D CMAKE_INSTALL_PREFIX=/usr/local \ # 安装位置
-D PYTHON_DEFAULT_EXECUTABLE=/usr/bin/python3 \ # py 执行文件路径
# -D OPENCV_EXTRA_MODULES_PATH=../../opencv_contrib/modules \
-D BUILD_opencv_python3=ON \ # 兼容 py3
-D BUILD_opencv_python2=OFF \
-D PYTHON3_EXCUTABLE=/usr/bin/python3 \
-D PYTHON3_INCLUDE_DIR=/usr/include/python3.6 \
-D WITH_CUDA=ON \ # 支持 CUDA
# 这个 Flag 开关非常重要,
# 开启了才会生成一个被 pkg-config 命令使用的 opencv4.pc 文件,
# 之后在编译需链接 opencv 库的 C++ 项目时会用到该命令。
-D OPENCV_GENERATE_PKGCONFIG=ON \ 
..

注意:结尾的 .. 必不可少,因为上级目录才是 opencv 的源码库。[^1]

编译并安装

# 开启多线程加速,编译时出现的非致命警告可以忽略
make -j 64
# 将 opencv4 安装到 CMAKE_INSTALL_PREFIX 指定的路径
sudo make install

更新环境变量

打开个人 home 目录下的 .bashrc 文件,在最后添加如下环境变量:

vim ~/.bashrc
# or ~/.zshrc
export LD_LIBRARY_PATH=/usr/local/lib:$LD_LIBRARY_PATH # Linker 链接器查找库的路径
export PKG_CONFIG_PATH=$PKG_CONFIG_PATH:/usr/local/lib/pkgconfig # pkg-config 存放包配置的路径

至此安装完毕,可通过以下命令查看版本。

确认 OpenCV 版本

$ pkg-config --modversion opencv4
4.5.5

你可以会好奇,pkg-config 从哪里读到了这些版本信息。还记得上面我们在 CMAKE 命令参数中写明要生成 PKG CONFIG 吗?也就是通过添加 -D OPENCV_GENERATE_PKGCONFIG=ON 选项自动生成的。

神秘的 .pc 文件

继续追根溯源,在 /usr/local/lib/pkgconfig 目录下面有一个 opencv4.pc 文件,查看一下里面的内容:

$ cat /usr/local/lib/pkgconfig/opencv4.pc
# Package Information for pkg-config

prefix=/usr/local
exec_prefix=${prefix}
libdir=${exec_prefix}/lib
includedir=${prefix}/include/opencv4

Name: OpenCV
Description: Open Source Computer Vision Library
Version: 4.5.5
Libs: -L${exec_prefix}/lib -lopencv_gapi -lopencv_highgui -lopencv_ml -lopencv_objdetect -lopencv_photo -lopencv_stitching -lopencv_video -lopencv_calib3d -lopencv_features2d -lopencv_dnn -lopencv_flann -lopencv_videoio -lopencv_imgcodecs -lopencv_imgproc -lopencv_core
Libs.private: -ldl -lm -lpthread -lrt
Cflags: -I${includedir}

关于 .pc 文件
opencv3.x 之前的版本是生成名为 opencv.pc 的文件,这里编译的是 opencv 4.4.5,因此变成了 opencv4.pc
该 opencv4.pc 文件,实际上指定了安装 opencv 生成的 Include(包含目录)和 Libs(库目录 )的路径,以便在使用 g++ 编译包含 opencv 库函数的 c++ 代码时,可使用 pkg-config 命令输出 opencv 库的搜索路径。

pkg-config 命令

有了 opencv4.pc 文件,以及将该文件路径添加到 PKG_CONFIG_PATH 环境变量之后。就可以通过 pkg-config 命令来查看安装的 opencv 的一些信息了,比如:

# 查看opencv的Include目录/头文件路径
$ pkg-config --cflags opencv4
-I/usr/local/include/opencv4
# 查看opencv的Libs目录/库目录
$ pkg-config --libs opencv4
-L/usr/local/lib -lopencv_gapi -lopencv_highgui -lopencv_ml -lopencv_objdetect -lopencv_photo -lopencv_stitching -lopencv_video 
-lopencv_calib3d -lopencv_features2d -lopencv_dnn -lopencv_flann -lopencv_videoio -lopencv_imgcodecs -lopencv_imgproc -lopencv_core

编译自己的程序

Demo 源码

文件开头统一定义了 ABI 接口,因为 C++ 11 新提案修改了 std::string 和 std::list 的 ABI 接口。[^2]

#define _GLIBCXX_USE_CXX11_ABI 1
#include <time.h>
#include <iostream>
#include <opencv4/opencv2/opencv.hpp>
#include <vector>

/* 计算图像的角距
 * data: 数据源
 * width: 宽
 * height: 高
 * min_area: 最小可视区域
 */
extern "C" float calc_angle_moment(uchar *data, int width, int height, int min_area = 1000) {
    cv::Mat src(height, width, CV_8UC1, data);
    int kernel_size = 30;
    if (src.cols > 800) {
        cv::resize(src, src, {src.cols / 2, src.rows / 2});
        kernel_size = 15;
    }
    cv::Mat edge;
    double thre = cv::threshold(src, edge, 127, 255, cv::THRESH_OTSU);
    double mean = cv::sum(src)[0] / ((double)src.cols * src.rows);
    if (mean > thre) {
        edge = 255 - edge;
    }
    cv::Mat er, kernel;
    kernel = cv::getStructuringElement(cv::MORPH_RECT, {3, 3});
    cv::morphologyEx(edge, edge, cv::MORPH_CLOSE, kernel);
    kernel = cv::getStructuringElement(cv::MORPH_RECT, {kernel_size, 1});
    cv::erode(edge, er, kernel);
    std::vector<std::vector<cv::Point>> contours;
    std::vector<cv::Vec4i> hierarchy;
    cv::findContours(er, contours, hierarchy, cv::RETR_EXTERNAL,
                     cv::CHAIN_APPROX_SIMPLE);
    double area = min_area;
    int index = -1;
    for (size_t i = 0; i < contours.size(); i++) {
        double cur_area = cv::contourArea(contours[i]);
        cv::RotatedRect r = cv::minAreaRect(cv::Mat(contours[i]));
        if (r.size.width > 2 * r.size.height || r.size.width * 2 < r.size.height) {
            if (cur_area > area) {
                area = cur_area;
                index = i;
            }
        }
    }
    if (index == -1)
        return 361.0f;
    cv::Moments m = cv::moments(contours[index]);
    return atan2(2 * m.mu11, m.mu20 - m.mu02) / 2 * 180 / CV_PI;
}
int main() {
    // 业务相关的测试代码
    return 0;
}

编译代码

g++ -shared -fPIC --std=c++11 -Wfatal-errors -O3 hough_cuda.cpp -o libhough.so $(pkg-config --cflags --libs opencv4)

编译参数解释

表 1: 重要的编译参数一览表

参数 说明
$(pkg-config \
--cflags \
--libs opencv4)
-I/usr/local/include/opencv4 -L/usr/local/lib -lopencv_gapi -lopencv_highgui -lopencv_ml -lopencv_objdetect -lopencv_photo -lopencv_stitching -lopencv_video -lopencv_calib3d -lopencv_features2d -lopencv_dnn -lopencv_flann -lopencv_videoio -lopencv_imgcodecs -lopencv_imgproc -lopencv_core
-l 指定编译期需要链接的共享库名字
-L 指定编译期需要链接的共享库路径
-Wl,rpath= 指定运行期共享库的路径
-Wl,option 传递参数给linker
-Wall 记录所有警告,包括非致命的警告
-c 只编译不链接,即跳过链接阶段
-fPIC Position Independent Code
字面意思位置独立的代码,实际上共享库,由于要被不同的程序在不同时刻上加载,因此代码段的位置在虚拟空间上不能被固定。

PIC Flag

如果未设置 PIC flag,g++ 会报错如下:

$ g++ hough_cuda.cpp -o libhough.so -shared --std=c++11 -Wall -Wfatal-errors -O3 `pkg-config --cflags --libs opencv4`

/usr/bin/ld: /tmp/ccoemTXQ.o: relocation R_X86_64_PC32 against symbol `_ZN2cv7MatExprD1Ev' can not be used when making a shared object; recompile with -fPIC
/usr/bin/ld: final link failed: Bad value
collect2: error: ld returned 1 exit status

从第一行报错信息来看,有个符号 _ZN2cv7MatExprD1Ev 无法被使用。该符号使用的是 C++ 函数签名,看不出所以然来,我们将其转换成人类可读的形式:

$ c++filt _ZN2cv7MatExprD1Ev
cv::MatExpr::~MatExpr()

由此可知,这是 OpenCV 的一个库函数。当构建共享库时,一个符号 cv::MatExpr::~MatExpr() 的地址无法重新分配。我们根据建议设置 flag --fPIC 并重新编译即可。

自动化

典型的 c++ 项目的编译方式应该是使用 cmake + make 的方式。需要编写一个 CMakeLists.txt 或 Makefile 文件:

CMake 其实最后也会生成一个 Makefile,这里笔者先跳过 CMake,直接写 Makefile。

CC = g++

.PHONY: all
all : libhough.so

.PHONY: lib
lib : libhough.so

libhough.so : hough_cuda.cpp
        $(CC) $^ -o $@  -shared -fPIC --std=c++11 -Wall -Wfatal-errors -O3 `pkg-config --cflags --libs opencv4`
        ldd $@

# 库文件以lib开始; 共享库文件以.so为后缀; -shared生成共享库

# -l<lib_name> 指定编译期需要链接的共享库名字
# -L<lib_dir>  指定编译期需要链接的共享库路径
# -Wl,rpath=<lib_dir> 指定运行期共享库的路径
# -Wl,option 传递参数给linker
test : test.cpp
        $(CC) $^ -o $@  -fPIC --std=c++11 -Wfatal-errors `pkg-config --cflags --libs opencv4`
        ldd test  # 显示依赖库
        ./test

# export LD_LIBRARY_PATH=.  # 如果编译期不用rpath,也可以在运行期指定so路径给操作系统
.PHONY: clean
clean:
        rm -fv *.so *.o test
        echo done

一键生成

直接在命令打 Make 就能构建共享库

$ make
g++ hough_cuda.cpp -o libhough.so  -shared -fPIC --std=c++11 -Wall -Wfatal-errors -O3 `pkg-config --cflags --libs opencv4`
ldd libhough.so
        linux-vdso.so.1 (0x00007ffcadde8000)
        libopencv_features2d.so.405 => /usr/local/lib/libopencv_features2d.so.405 (0x00007f393b3f2000)
        libopencv_imgcodecs.so.405 => /usr/local/lib/libopencv_imgcodecs.so.405 (0x00007f393b18f000)
        libopencv_imgproc.so.405 => /usr/local/lib/libopencv_imgproc.so.405 (0x00007f393a8d1000)
        libopencv_core.so.405 => /usr/local/lib/libopencv_core.so.405 (0x00007f393a23e000)
        libstdc++.so.6 => /usr/lib/x86_64-linux-gnu/libstdc++.so.6 (0x00007f3939e6a000)
        libm.so.6 => /lib/x86_64-linux-gnu/libm.so.6 (0x00007f3939acc000)
        libgcc_s.so.1 => /lib/x86_64-linux-gnu/libgcc_s.so.1 (0x00007f39398b4000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f39394c3000)
        libopencv_flann.so.405 => /usr/local/lib/libopencv_flann.so.405 (0x00007f3939259000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f393903a000)
        libjpeg.so.8 => /usr/lib/x86_64-linux-gnu/libjpeg.so.8 (0x00007f3938dd2000)
        libwebp.so.6 => /usr/lib/x86_64-linux-gnu/libwebp.so.6 (0x00007f3938b69000)
        libpng16.so.16 => /usr/lib/x86_64-linux-gnu/libpng16.so.16 (0x00007f3938937000)
        libtiff.so.5 => /usr/lib/x86_64-linux-gnu/libtiff.so.5 (0x00007f39386c0000)
        libopenjp2.so.7 => /usr/lib/x86_64-linux-gnu/libopenjp2.so.7 (0x00007f393846a000)
        libIlmImf-2_2.so.22 => /usr/lib/x86_64-linux-gnu/libIlmImf-2_2.so.22 (0x00007f3937fa6000)
        libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f3937da2000)
        librt.so.1 => /lib/x86_64-linux-gnu/librt.so.1 (0x00007f3937b9a000)
        libz.so.1 => /lib/x86_64-linux-gnu/libz.so.1 (0x00007f393797d000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f393b8d3000)
        liblzma.so.5 => /lib/x86_64-linux-gnu/liblzma.so.5 (0x00007f3937757000)
        libjbig.so.0 => /usr/lib/x86_64-linux-gnu/libjbig.so.0 (0x00007f3937549000)
        libHalf.so.12 => /usr/lib/x86_64-linux-gnu/libHalf.so.12 (0x00007f3937306000)
        libIex-2_2.so.12 => /usr/lib/x86_64-linux-gnu/libIex-2_2.so.12 (0x00007f39370e8000)
        libIlmThread-2_2.so.12 => /usr/lib/x86_64-linux-gnu/libIlmThread-2_2.so.12 (0x00007f3936ee1000)

重新生成

$ make # 若是代码没有改变,且生成的目标还存在,那么再次 Make 不能触发任何动作
make: Nothing to be done for 'all'.
$
$ make clean # 清理目标文件
rm -fv *.so *.o test
removed 'libhough.so'
echo done
done
$ make # 此时可以再次生成
g++ hough_cuda.cpp -o libhough.so  -shared -fPIC --std=c++11 -Wall -Wfatal-errors -O3 `pkg-config --cflags --libs opencv4`
ldd libhough.so
...
<skip-same-output>

Py 调用 Cpp

在 Python 代码中,调用上一节所生成的 libhough.so 动态链接库中的函数。

import pathlib
import sys

import cv2
import ctypes
import numpy as np

dll_path = pathlib.Path(__file__).parent / "libhough.so"

image_process_cuda=ctypes.CDLL(str(dll_path.absolute()))
image_process_cuda.calc_angle_moment.restype = ctypes.c_float

def calc_angle_moment(src):
    gray = cv2.cvtColor(src,cv2.COLOR_BGR2GRAY)
    if not gray.flags['C_CONTIGUOUS']:
        # 如果图像的内存分配方式不是类似 C 数组那样占用连续的内存,那么必须强制转换
        gray = np.ascontiguous(gray, dtype=gray.dtype)  
    a_ctypes_ptr = ctypes.cast(gray.ctypes.data, ctypes.POINTER(ctypes.c_ubyte))
    h, w = tuple(map(int, img.shape[:2]))
    angle = image_process_cuda.calc_angle_moment(a_ctypes_ptr, w, h)
    # 返回值校验:无效角度
    if angle > 360.0:
        angle = None
    return angle

if __name__ == "__main__":
    img_path = sys.argv[1]
    assert pathlib.Path(img_path).exists(), f"Image({img_path}) not found"
    print(f"Try to read image from `{img_path}`")
    im = cv2.imread()
    res = calc_angle_moment(im)
    print(f"Angle Moment: {res}")

参考链接

  1. Installation in Linux | OpenCV Org
  2. OpenCV modules | OpenCV Org
    (OpenCV 4.5.5 各模块的说明文档)

添加新评论