4. Python与C++混合编程:Python调用C++动态库
做嵌入式AI部署,你迟早会遇到一个坎:Python跑得太慢了。
尤其是图像预处理阶段——缩放、归一化、色彩空间转换、数据排布调整。这些操作在Python里用OpenCV写,看起来挺简洁,但放到边缘设备上,帧率直接掉一半。我刚开始做RK3588项目时就吃过这个亏,后来老老实实把预处理用C++重写,帧率从12fps提到了28fps。
说白了,Python是胶水语言,负责调度和逻辑;C++是性能担当,负责计算密集型任务。这一章我们就聊聊怎么让它们俩好好配合。
4.1 为什么需要混合编程?
先看一组我在Jetson Nano上实测的数据:
| 操作 | Python耗时(ms) | C++耗时(ms) | 加速比 |
|---|---|---|---|
| RGB→BGR + 归一化 (640×480) | 8.2 | 1.1 | 7.5x |
| Resize (640×480→320×320) | 5.6 | 0.8 | 7.0x |
| HWC→CHW + 归一化 | 6.8 | 0.9 | 7.6x |
| 完整预处理流水线 | 22.3 | 3.1 | 7.2x |
看到没?C++版本基本是Python的7倍以上。为什么会这样?Python的循环开销、动态类型检查、对象创建销毁,这些在图像处理这种逐像素操作上都是致命伤。
4.2 方案一:ctypes——轻量级调用
ctypes是Python标准库自带的,不需要额外安装。它的工作方式很简单:加载一个编译好的.so或.dll文件,然后声明函数签名,直接调用。
4.2.1 编写C++动态库
先写一个简单的图像预处理函数:
// preprocess.h
extern "C" {
void preprocess_image(
const unsigned char* src,
unsigned char* dst,
int width, int height, int channels
);
}
// preprocess.cpp
#include "preprocess.h"
#include <cstring>
extern "C" void preprocess_image(
const unsigned char* src,
unsigned char* dst,
int width, int height, int channels
) {
int size = width * height * channels;
// 示例:RGB转BGR + 归一化到[0,1]区间
for (int i = 0; i < size; i += 3) {
dst[i] = src[i+2]; // R→B
dst[i+1] = src[i+1]; // G→G
dst[i+2] = src[i]; // B→R
}
// 实际项目中还会做归一化、排布转换等
}
编译命令(以Linux为例):
g++ -shared -fPIC -o libpreprocess.so preprocess.cpp
extern "C",否则C++编译器会对函数名做name mangling,ctypes就找不到符号了。我刚开始写的时候忘了这个,折腾了半小时才反应过来。
4.2.2 Python端调用
import ctypes
import numpy as np
# 加载动态库
lib = ctypes.CDLL('./libpreprocess.so')
# 声明函数参数类型
lib.preprocess_image.argtypes = [
ctypes.POINTER(ctypes.c_ubyte), # src
ctypes.POINTER(ctypes.c_ubyte), # dst
ctypes.c_int, # width
ctypes.c_int, # height
ctypes.c_int # channels
]
lib.preprocess_image.restype = None
# 准备数据
img = np.random.randint(0, 256, (480, 640, 3), dtype=np.uint8)
output = np.zeros_like(img)
# 调用C++函数
lib.preprocess_image(
img.ctypes.data_as(ctypes.POINTER(ctypes.c_ubyte)),
output.ctypes.data_as(ctypes.POINTER(ctypes.c_ubyte)),
640, 480, 3
)
ctypes的优点就是轻,不需要任何依赖。但缺点也很明显:
- 参数类型声明繁琐,尤其是处理多维数组和结构体时
- 不支持C++类,只能调用C风格函数
- 错误信息不友好,段错误了都不知道在哪
4.3 方案二:pybind11——现代C++绑定
pybind11是我个人更推荐的方式。它基于C++11,可以自动处理Python对象和C++对象之间的转换,甚至支持numpy数组的直接传递。
4.3.1 安装与配置
pip install pybind11
# 或者从源码安装
git clone https://github.com/pybind/pybind11.git
cd pybind11 && mkdir build && cd build
cmake .. && make -j4 && sudo make install
4.3.2 编写绑定代码
// bindings.cpp
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
namespace py = pybind11;
void preprocess_numpy(py::array_t<uint8_t> input, py::array_t<uint8_t> output) {
auto buf_in = input.request();
auto buf_out = output.request();
uint8_t* ptr_in = static_cast<uint8_t*>(buf_in.ptr);
uint8_t* ptr_out = static_cast<uint8_t*>(buf_out.ptr);
size_t size = buf_in.size;
for (size_t i = 0; i < size; i += 3) {
ptr_out[i] = ptr_in[i+2];
ptr_out[i+1] = ptr_in[i+1];
ptr_out[i+2] = ptr_in[i];
}
}
// 模块定义
PYBIND11_MODULE(preprocess, m) {
m.doc() = "图像预处理加速模块";
m.def("preprocess_numpy", &preprocess_numpy,
"RGB转BGR + 归一化预处理",
py::arg("input"), py::arg("output"));
}
编译方式(使用CMake):
cmake_minimum_required(VERSION 3.10)
project(preprocess)
find_package(pybind11 REQUIRED)
pybind11_add_module(preprocess bindings.cpp)
target_link_libraries(preprocess PRIVATE pybind11::module)
4.3.3 Python端调用
import numpy as np
import preprocess # 直接导入编译好的模块
img = np.random.randint(0, 256, (480, 640, 3), dtype=np.uint8)
output = np.zeros_like(img)
# 直接调用,就像调用Python函数一样
preprocess.preprocess_numpy(img, output)
PYBIND11_MODULE 宏中的名字一致。我曾经因为拼写错误,import的时候报找不到模块,排查了半天才发现是名字写错了。
4.4 性能对比:ctypes vs pybind11
我在RK3588开发板上做了个对比测试,结果如下:
| 调用方式 | 单次调用耗时(μs) | 1000次调用耗时(ms) | 开发效率 | 适用场景 |
|---|---|---|---|---|
| 纯Python | 8200 | 8200 | 高 | 原型验证 |
| ctypes | 1100 | 1100 | 中 | 简单函数调用 |
| pybind11 | 1050 | 1050 | 高 | 复杂项目、类绑定 |
从性能上看,两者差距不大。但pybind11的开发体验好太多了——自动类型转换、异常处理、文档生成,这些在ctypes里都得自己手写。
4.5 实战:图像预处理加速
好了,理论说完了,我们来做一个完整的实战。这个案例来自我去年做的一个口罩检测项目,当时需要在RK3588上实时处理4路1080p视频流。
4.5.1 完整预处理流水线
一个典型的AI推理预处理包含以下步骤:
- 色彩空间转换:RGB→BGR(或反之)
- 归一化:像素值从[0,255]映射到[0,1]或[-1,1]
- 尺寸调整:Resize到模型输入尺寸
- 数据排布:HWC→CHW(通道维提前)
- 数据类型转换:uint8→float32
4.5.2 C++实现(使用pybind11)
// full_preprocess.cpp
#include <pybind11/pybind11.h>
#include <pybind11/numpy.h>
#include <opencv2/opencv.hpp>
namespace py = pybind11;
py::array_t<float> preprocess_pipeline(py::array_t<uint8_t> input,
int target_w, int target_h) {
// 将numpy数组转为cv::Mat
auto buf = input.request();
cv::Mat src(buf.shape[0], buf.shape[1], CV_8UC3, buf.ptr);
// 1. 色彩空间转换
cv::Mat bgr;
cv::cvtColor(src, bgr, cv::COLOR_RGB2BGR);
// 2. Resize
cv::Mat resized;
cv::resize(bgr, resized, cv::Size(target_w, target_h));
// 3. 归一化 + HWC→CHW + uint8→float32
int channels = 3;
py::array_t<float> result({channels, target_h, target_w});
auto buf_out = result.request();
float* ptr_out = static_cast<float*>(buf_out.ptr);
for (int c = 0; c < channels; c++) {
for (int h = 0; h < target_h; h++) {
for (int w = 0; w < target_w; w++) {
int idx = h * target_w * channels + w * channels + c;
ptr_out[c * target_h * target_w + h * target_w + w] =
resized.data[idx] / 255.0f;
}
}
}
return result;
}
PYBIND11_MODULE(fast_preprocess, m) {
m.def("preprocess_pipeline", &preprocess_pipeline,
"完整预处理流水线:RGB→BGR→Resize→归一化→CHW",
py::arg("input"), py::arg("target_w"), py::arg("target_h"));
}
4.5.3 Python调用与测试
import cv2
import numpy as np
import fast_preprocess
import time
# 读取图像
img = cv2.imread('test.jpg')
img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)
# 预热
for _ in range(10):
_ = fast_preprocess.preprocess_pipeline(img_rgb, 320, 320)
# 正式测试
start = time.perf_counter()
for _ in range(100):
result = fast_preprocess.preprocess_pipeline(img_rgb, 320, 320)
end = time.perf_counter()
print(f"平均耗时: {(end-start)/100*1000:.2f} ms")
print(f"输出形状: {result.shape}") # (3, 320, 320)
4.6 避坑指南
做混合编程,有几个坑我踩过,分享给你:
- 内存管理:C++里分配的内存,Python不会自动释放。记得用pybind11的
py::array_t或者手动管理生命周期。 - 线程安全:如果你的C++函数会被多个Python线程调用,记得加锁。我遇到过两次因为线程竞争导致的内存踩踏。
- 调试困难:C++段错误不会像Python那样给出清晰的traceback。建议先用小数据量测试,逐步增加复杂度。
- 交叉编译:在嵌入式设备上,你可能需要在PC上交叉编译动态库。记得目标架构和Python版本要匹配。
4.7 本章知识结构
下面这张图总结了Python与C++混合编程的核心流程:
4.8 总结
Python与C++混合编程,说白了就是各取所长。Python负责快速迭代和业务逻辑,C++负责榨干硬件性能。在边缘AI部署中,这几乎是必选项。
我个人更推荐pybind11,虽然需要多学一点C++11的语法,但长期来看开发效率更高。ctypes适合那种只需要调用一两个函数的简单场景。
嗯,这一章的内容就到这里。代码示例我都放在项目仓库的 chapter4/ 目录下了,你可以直接编译运行试试。记住,纸上得来终觉浅,动手跑一遍比看十遍都管用。