OpenCV Mat UMat GpuMat Matx HostMem InputArray等设计哲学
一、概览:
GpuMat对应于cuda;HostMem 可以看作是一种特殊的Mat,其存储对应cuda在主机分配的锁页内存,可以不经显示download upload自动转变成GpuMat(但是和GpuMat并无继承关系);UMat对应于opencl的存储 Matx指代常量Mat,编译时即确定:InputArray则是一种代理模式。 注意,InputAray和Mat UMat GpuMat Matx等无继承关系!!
二、然后我们通过几个点来深入了解一下opencv为何这么设计,以及一些细节。
一、为何一些数据结构之间有时候可以转换有时候不可以
首先要知道opencv的数据结构本质是要管理一块存储,也许是主机内存也许是cuda显存 也许是opencl存储 那么,无论是Mat Matx 还是其他数据结构,本质上都是一个header+一个数据指针,不同数据结构之间并无继承关系。那么有一个情况需要解释,比如HostMem和Mat同样是主机内存,那么可以HostMem就会有从HostMem转变为Mat的构造函数,同时因为HostMem是cuda分配的,如果是带有deviceMapped的主机内存(opencv管这叫shared HostMem),也可以调用转变为GpuMat的构造函数,需要强调,这些构造函数本质上是转移了data指针并创造了一个新的header。
二、为何InputArray
不是一个基类?
对于许多OpenCV的C++开发者来说,第一次在函数签名中遇到 cv::InputArray
、cv::OutputArray
时,心中难免会产生疑问:“这到底是什么类型?为什么我不直接传递 cv::Mat
?” 当我们进一步发现,Mat
、GpuMat
甚至 std::vector
都可以被传递给一个 InputArray
参数时,这种好奇心会变得更加强烈。
这背后,隐藏着OpenCV设计者们关于性能、灵活性和扩展性的深刻思考。本文将结合我们对OpenCV数据结构的理解,深入探讨这个看似“奇怪”却极其精妙的设计选择。
1、舞台上的演员们:OpenCV的数据江湖
在深入探讨设计哲学之前,我们必须先认识一下舞台上的主要“演员们”。它们的核心任务都是管理一块内存,但这块内存的“家”却各不相同。
cv::Mat
: 最家喻户晓的明星。它是一个通用的N维数组容器,主要负责管理主机(CPU)内存。它是OpenCV图像处理的基石。cv::cuda::GpuMat
: CUDA阵营的先锋。它专门管理在NVIDIA GPU显存中的数据,是进行CUDA加速运算的主体。cv::cuda::HostMem
:GpuMat
的得力助手。它可以看作是一种特殊的Mat
,其数据存储在由CUDA分配的**主机端锁页内存(Pinned Memory)**中。这种内存的特殊之处在于,它可以被GPU直接访问(DMA),从而极大地加速了主机与设备之间的数据传输,甚至可以实现数据流的并发。cv::UMat
: OpenCL阵营的代表,透明计算的未来。它是一个更为抽象的容器,其管理的内存可能在CPU上,也可能在GPU、DSP或其他OpenCL设备上。UMat
的美妙之处在于它能根据计算上下文自动处理数据同步,对开发者隐藏了复杂的内存迁移操作。cv::Matx
: 轻量级的“便签条”。它是一个小尺寸、固定大小的矩阵,其内存通常直接在**栈(Stack)**上分配。由于大小在编译时就已确定,避免了堆内存分配的开销,非常适合用于表示3D点、像素值等小型数据。std::vector
: 来自C++标准库的“外援”。无论是std::vector<Point>
还是std::vector<float>
,它们都是OpenCV算法中常见的数据结构。
关键点:这些数据结构,尤其是 Mat
、GpuMat
、UMat
和 std::vector
,彼此之间并无继承关系。它们是独立的、为了不同目的而设计的类。
2、核心挑战:如何让一个函数“通吃”所有数据类型?
现在,问题来了。假设我们要写一个函数,比如计算数组的均值。我们希望这个函数既能处理CPU上的Mat
,也能处理GPU上的GpuMat
,甚至还能处理一个std::vector<float>
。
一个遵循传统面向对象(OOP)思路的开发者可能会立刻想到:继承!
我们可以设计一个抽象基类 Array
,然后让 Mat
、GpuMat
等都公有继承自它:
// 一个看似很美的“继承”方案(但OpenCV没有采纳)
class Array {
public:virtual ~Array() {}virtual int getRows() const = 0;// ... 其他通用接口
};class Mat : public Array { /*...*/ };
class GpuMat : public Array { /*...*/ };// 函数签名
void calculateMean(const Array& arr);
然而,这个方案存在三个对于高性能计算库而言几乎是致命的缺陷。
-
性能的枷锁——虚函数开销:为了实现多态,基类中的函数必须是虚函数。这意味着每个对象都需要额外存储一个虚函数表指针,并且每次函数调用都需要一次间接寻址。在像素级的海量循环中,这种微小的开销会被无限放大,违背了OpenCV追求极致性能的初衷。
-
灵活性的噩梦——侵入式设计:这个方案最大的问题是,它要求所有被处理的类型都必须从
Array
继承。我们不可能去修改C++标准库,让std::vector
继承自我们的Array
!我们也无法让一个C风格的原始数组指针继承一个类。这种“侵入式”的设计会极大地限制库的通用性。 -
稳定性的隐患——脆弱的ABI:对于一个被全球开发者使用的库,保持二进制接口(ABI)的稳定至关重要。一旦基类
Array
的结构(如增删虚函数)发生改变,所有依赖它的、已编译的程序都可能需要重新编译,这是一场灾难。
3、OpenCV的答案:优雅的代理模式(Proxy Pattern)
面对上述挑战,OpenCV的设计者们给出了一个非凡的答案:代理模式。InputArray
、OutputArray
就是这个模式的实现者。
InputArray
不是一个基类,而是一个轻量级的“代理”或“适配器”。
它本身不拥有数据,而是像一个经纪人一样,持有对“真正”数据(Mat
, GpuMat
, vector
…)的引用或指针,并对外提供一个统一的接口。
这种设计是如何工作的呢?
模式一:转发共同能力
当函数需要执行一个通用操作时(比如获取尺寸),它会调用InputArray
的接口,例如arr.size()
。InputArray
内部会判断自己当前代理的是哪位“明星”(Mat
? GpuMat
?),然后将这个调用转发给实际对象的对应方法。
// 函数实现者视角
void myFunction(cv::InputArray arr) {// 无需关心 arr 到底是 Mat 还是 GpuMat// InputArray 会自动将调用转发给它代理的对象的 .size() 方法cv::Size sz = arr.size(); // ...
}```这实现了多态的好处,却没有虚函数的性能开销,也无需修改任何原始类。#### 模式二:直接获取特有能力当需要执行某个特定类型才有的操作时(比如将`GpuMat`传入一个自定义的CUDA核函数),`InputArray`也提供了一个“逃生通道”。你可以从它那里获取到原始对象的引用。```cpp
// 函数实现者视角
void myCudaFunction(cv::InputArray arr) {// 确认代理的是GpuMat后,获取其可写引用cv::cuda::GpuMat& d_mat = arr.getGpuMatRef(); // 现在可以调用 GpuMat 的所有特有方法了my_cuda_kernel<<<...>>>(d_mat.ptr<float>(), ...);
}
这保证了设计的灵活性和功能的完整性,我们不会因为使用了代理而丢失对底层对象的完全控制。
结论:一场工程智慧的胜利
现在,我们可以清晰地回答最初的问题了。
OpenCV之所以不采用传统的继承体系,而是设计出InputArray
这样的代理类,是为了在一套API中,同时实现三个看似矛盾的目标:
- 极致的性能:避免了虚函数带来的开销。
- 无与伦比的灵活性:通过非侵入式的设计,使其能够适配
Mat
、GpuMat
、UMat
、std::vector
等众多类型,而无需它们做出任何改变。 - 坚如磐石的稳定性:代理类本身结构稳定,易于扩展以支持新类型,而不会破坏二进制兼容性。
InputArray
的设计哲学,是典型的用组合(代理是一种组合形式)优于继承的工程实践。它或许在初学时带来一丝困惑,但一旦理解其背后的深意,你便会由衷地赞叹这种设计的优雅与强大。它不仅仅是一个技术选择,更是OpenCV作为一个高性能、高通用性计算库的立身之本。
InputArray除了可以作为通用接口接受不同数据结构外,还有什么作用?
两个层面:抽象接口的转发和具体对象的直接访问。这两种模式是相辅相成的。
模式一:转发/代理 (Forwarding/Delegation) - 处理“共同能力”
当一个操作是所有或大多数数组类型(Mat
, UMat
, GpuMat
, vector
…)都应该具备的通用能力时,_OutputArray
类会为这个操作提供一个自己的成员函数。
最典型的“共同能力”就是:
- 创建/分配内存 (
create
,createSameSize
) - 释放/清空 (
release
,clear
) - 赋值 (
setTo
,assign
,move
)
工作流程:
- 函数实现者调用
OutputArray
的方法,例如dst.create(size, type)
。 _OutputArray
内部会检查它当前“代理”的是哪种具体对象(Mat
?GpuMat
?)。- 然后,它将这个调用**转发(Forward)**给它所代理的那个具体对象的相应方法。
- 如果
dst
包裹的是一个Mat
,它内部会调用the_mat.create(size, type)
。 - 如果
dst
包裹的是一个GpuMat
,它内部会调用the_gpumat.create(size, type)
。
- 如果
这么做的好处是:多态性和代码复用。函数实现者无需写 if-else
来判断 dst
的具体类型,只需面向 OutputArray
这个统一的抽象接口编程即可。这使得一个函数(如 cv::cvtColor
)可以无缝地同时支持 Mat
、UMat
和 GpuMat
作为输出。
模式二:直接获取 (Direct Access) - 处理“特有能力”
当一个操作是某个具体类(比如 GpuMat
)特有的能力,而其他类(如 Mat
)没有这个能力时,_OutputArray
接口中就不会包含这个操作。
例如:
- 直接访问
GpuMat
的step
成员进行指针运算。 - 调用
Mat
特有的push_back()
方法。 - 将
GpuMat
传递给一个需要cudaStream_t
参数的自定义CUDA核函数。
在这种情况下,函数实现者就必须先“揭开”OutputArray
的代理面纱,拿到它背后包裹的那个原始对象。
工作流程:
- 函数实现者首先需要知道或判断
OutputArray
代理的是哪种类型。 - 然后调用
get...Ref()
方法,如dst.getMatRef()
或dst.getGpuMatRef()
,来获取一个可写的引用。 - 拿到这个引用后,就可以像操作一个普通的
Mat
或GpuMat
对象一样,调用它所有特有的方法和成员。
这么做的好处是:灵活性和完整性。它提供了一个“逃生通道”,确保了即使OutputArray
的抽象接口没有覆盖某个功能,开发者依然可以使用具体类的全部能力,不会因为使用了代理类而丢失功能。
总结对比
行为模式 | 调用方式 | 适用场景 | 设计目的 |
---|---|---|---|
转发/代理 | 直接调用 dst.create(...) , dst.setTo(...) 等 OutputArray 的方法。 | 处理所有数组类型都支持的通用操作。 | 抽象与多态:隐藏具体实现,让函数可以处理多种数据类型。 |
直接获取 | 先调用 dst.getMatRef() 等获取具体引用,再调用该引用的特有方法。 | 处理某个特定数组类型才有的专属操作。 | 灵活性与完整性:不限制开发者使用具体类的全部功能。 |
OpenCV 的 Input/Output
代理类设计,是一个在高度抽象(为了易用和通用)和完全控制(为了性能和功能完整性)之间取得精妙平衡的典范。