ImageConsumer

程序结构

MediaPipe4U 的本质是不停的处理来自图像源的帧(以下简称图像帧)。MediaPipe4U 融合各种不同的 AI、AR 技术, 这些技术大都通过消费图像帧来完成计算,例如 Google MediaPipe, Nvidia NvAR。MediaPipe4U 使用发布订阅(PUB/SUB)模式向各种不同的技术投递图像,以此融合各种视觉处理技术。

图像源请阅读 ImageSource 部分。 一个简单的工作流可以表达为如下图

NvAR

从图中可以看出,所有的 ImageConsumer 都消费同一帧图像,并且,他们是串行运行的(顺序执行),这是为了让底层的线程管理变得简单,同时可以减少内存复制。 由于这样的设计,在实现 ImageConsumer 时就要求 ImageConsumer :heavy_exclamation_mark:不能同步消费图片,同步消费将堵塞下一个 Consumer 接收帧。

推荐的 ImageConsumer 的简单实现模式:缓冲一个队列,当帧到来时只是简单的将其入队 (Enqueue),然后通过开启后台线程循环消费队列(Dequeue),或者在UE组件的 Tick 事件中消费缓冲队列中的帧。


ImageFrame 生命周期

从上面的程序结构可以看出,所有消费者(ImageConsumer)都消费同一帧图像,这减少了内存的复制,程序会更高效,但是引入了一个问题,就是什么时候释放这个帧。

池化(Pooling)

实时上,ImageSource 内部维护着一个帧对象池,这实际是一个内存池, 当所有的消费者消费完成时,这一帧数据将重新回到池中,帧的内存可以被反复使用从而降低频繁分配和释放内存的开销。 池中还有其他的空闲帧内存时,就不用担心因为某个 ImageSource 消费过慢而影响其他 ImageConsumerImageConsumer 之间可以并行处理互不干扰。

如果一个 ImageConsumer 拿到图像帧长期不消费(没有 Release),ImageSource 帧对象池就会被填满,导致 ImageSource 无空闲内存可用,从而不再分发图像帧。

释放

要让一个图像帧可以重新回到对象池(内存池),只需要在 ImageConsumer 完成它的工作后调用帧对象 (IMediaPipeTexture)的 Release 函数即可。

背后原理:ImageSource 对每一个图像帧都维护着一个引用计数,它非常像 C++ 的 shared ptr 设计,但是这是 MediaPipe4U 自己实现的,并不使用 C++ 智能指针。


C++ 实现 IImageConsumer

MediaPipe4U 为上述的 ImageConsumer 抽象了接口,这是一个 C++ 接口(不是 UnrealEngine 接口,不能在蓝图中使用),只需要实现它,你就可以将基于图像处理的功能集成到 MediaPipe4U 中。


class IImageConsumer
{
public:
	virtual ~IImageConsumer() = default;
	virtual  bool CanConsume() = 0;
	virtual bool Consume(const FImageSourceInfo& SourceInfo, IMediaPipeTexture* Texture) = 0;
};


IImageConsumer 函数列表

函数 说明 注意事项
CanConsume 返回一个值,当为 true 时,表示可以消费图像帧,ImageSource 将会回调 Consume 函数,如果为 false 表示这个 Comsumer 不接受图像帧,ImageSource 将不会再调用 Consume 函数。 不再需要消费图像时,可以返回 false,达到 “关闭”的功能。
Consume 用来消费图像帧。参数说明: SourceInfo 图像的高、宽、格式等信息, Texture:图像帧的指针,包含图像数据(字节数组)。 :bangbang:注意:
实现 Consume 函数不能使用同步模式,堵塞当前线程将导致其他 Consumer 得不到图像帧。
Consume 返回 false 时不能释放 (Release)图像帧,返回 false 表示一个 Consumer 没有对这一帧图形进行消费。

SourceInfoTexture 都是只读的,你只能读取,不可以修改。
SourceInfo 存储帧信息,Texture 存储帧数据。

SourceInfo 结构


struct FImageSourceInfo
{
	int Width = 0;
	int Height = 0;
	int WidthStep = 0;
	MediaPipeImageFormat Format = MediaPipeImageFormat::UNKNOWN;
	bool bIsStatic = false;
	int CvMatType = 0;
	int NumOfChannels = 0;
	int ByteDepth = 0;
};
属性 说明
Width 图像宽度 (像素)
Height 图像高度 (像素)
WidthStep 行数据跨度,(可以不考虑字节对齐,MediaPipe4U 中的所有 ImageSource 都没有字节对齐)你可以简单认为 WidthStep = Wdith * NumOfChannels
Format 格式,因为要兼容 UE,目前 MediaPipe4U 只有 RBGA 和 BGRA 两种格式,但是枚举中定义了 mediapipe 支持的所有格式。
IsStatic 是否是静态图像(静态表示从图片获得的图像帧,非静态表示视频流里的图像帧)。
CvMatType 这在和 OpenCV 交互的场景中很有用,目前,只有 CV_8U
NumOfChannels 图片的通道数
ByteDepth 位深度

之所以提供这么多信息,是为了在和其他技术栈的图片结构(例如 cv::Mat, NvImage 等)交互数据时候可以直接使用这些信息填充数据,而不用为了获得这些属性复制内存。

IMediaPipeTexture 接口

IMediaPipeTexture 是一个简单的图片数据接口,兼容绝大多数流行的图片处理库(内部已经集成了 GStreamer, OpenCV, NvImage),方便转换到各种第三方库的图片数据结构。


class IMediaPipeTexture
{
public:
    virtual ~IMediaPipeTexture() = default;
    virtual long GetImageId() const = 0;
    //get uint8*
    virtual void* GetData() const = 0; // 目前只有 uint8 数组
    virtual void Release() = 0;
    virtual long DataSize() const = 0;
};

函数 说明
GetImageId 帧 id, MediaPipe4U 内部使用,你需要知道,由于帧是池化的,这个 id 是会重复的,你不能用它作为图像的唯一标识。
GetData 包含图片数据的数组,目前只有 uint8 数组。
DataSize 图像数据的字节大小,num * sizeof(uint8),由于只有 uint8 数据,所以他也等于 GetData.Num()。
Release :bangbang:当一个 IImageConsumer 完成了帧的处理后必须调用此方法,让帧能够重新返回帧对象池。

注册和注销

当你实现了 IImageConsumer 以后,还需要注册你的实现到 MediaPipe4U 的图片工作流中,通过调用 FImageWorkflow::RegisterConsumer 函数完成注册。

FImageWorkflow 是单例模式,通过静态函数 Get 获取唯一实例。

TSharedPtr<IImageConsumer> yourInstance = MakeSharedable<IImageConsumer>(new YourImageConsumerClass());
FImageWorkflow::Get().RegisterConsumer(yourInstance);

当你不再需要这个ImageConsumer 的时候,你还可以注销它:

FImageWorkflow::Get().UnregisterConsumer(yourInstance);

你也可以让这个实例的 CanConsume 返回 false, 来达到让它”停止工作”的目的。

如果一个 Consumer 不需要消费图像帧时候,强烈建议建议注销或者让它“停止工作”,这样可以提高程序的性能。

与 UObject 集成

由于 UnrealEngine 的接口(UInterface)存在 GC,为了保持底层稳定性,IImageConsumer 并不是一个 UE 接口,但是 MediaPipe4U 提供了一个 UnrealEngine 接口来包装它, 方便你把它集成到 Component 或 Actor:

UINTERFACE()
class UImageConsumerProvider : public UInterface
{
	GENERATED_BODY()
};

/**
 * 
 */
class MEDIAPIPE_API IImageConsumerProvider
{
	GENERATED_BODY()
public:
	virtual IImageConsumer* GetImageConsumer() = 0;
};

它只有一个 C++ 纯虚函数,返回一个你实现的 IImageConsumer。这意味着这个包装你也必须用 C++ 实现,因为图像处理影响整个 MediaPipe4U 的稳定性和性能,不支持使用蓝图实现。

可以让组件(Component)同时实现 IImageConsumerIImageConsumerProvider 接口,在 GetImageConsumer 中返回 this 来包装一个 UE 组件。

可以参考的一个例子是NvAR 的实现:

//.h
UCLASS(BlueprintType)
class MEDIAPIPENVAR_API ANvARLiveLinkActor : public AActor, public IImageConsumer, public IImageConsumerProvider

//.cpp
IImageConsumer* ANvARLiveLinkActor::GetImageConsumer()
{
	return this;
}

FImageWorkflowRegisterConsumerUnregisterConsumer 也接受一个 IImageConsumerProvider 进行注册/注销。

当你使用 IImageConsumerProvider 时你必须非常小心 UE 的 GC,如果你的实现是一个 UComponent,建议你调用 AddToRoot 来防止 GC 清理它。

由于 IImageConsumerProvider 是 UnrealEngine 接口,不但可以通过 C++ 代码注册,也支持在蓝图中使用 MediaPipe4U 提供的蓝图函数库来注册。

蓝图函数名称:

  • RegisterImageConsumer
  • UnregisterImageConsumer

ImageConsumer