简介:面向高通Camera驱动、算法及多媒体框架开发者的技术专题PDF,以Chi-CDK Feature2框架为对象,系统梳理其整体架构与核心运行机制,覆盖FeatureGraph/FeatureNode管理、CHITargetBufferManager缓冲区管理、Request/Execute/Result三阶段流程、RealTime/RTBay2YUV/RTMFSRYUV实时链路、Camx XML pipeline定义、FeatureInternalLink跨pipeline连接以及Link状态机等关键技术点,并详细展开FeatureGraph从简单模型到实际实现的推导过程,帮助读者理解camera pipeline从配置到buffer提交的完整逻辑。资源为一份PDF文档,共1个文件,大小约1000KB,以图文和流程图辅助讲解,适合作为源码阅读前的体系化预习资料。已有8049人学习。内容后半部分深入FeatureGraph实现细节,包括m_linkData的visit机制、Link状态变化、FeatureNode ready判定、request Sequence(-1)/Sequence(0)执行、dependency获取与buffer释放等关键点,也可用于实战排错时的快速查阅。
1. 高通 Camera chi-cdk 的 feature2 框架:为什么 HAL 层要养一张“处理图”
调试高通 Camera 的 chi-cdk 时,feature2 是绕不开的一块。只要你想在预览或者拍照链路上插一个自己的处理节点,比如做美颜、多帧降噪、景深虚化,最终都会落到 feature2 的拓扑里。它本质上是把相机处理流程拆成一个个带输入输出端口的节点,再用一张有向无环图把这些节点串起来。和早期的 feature1 那种一条流水线写死的做法相比,feature2 最大的变化是:加一个效果节点,不再需要把主链路从头改一遍,而是改一张“接线图”。这篇笔记就围绕这个框架讲清楚它是什么、怎么用、参数怎么设、以及最容易踩的坑,适合刚接手 chi-cdk 的 HAL 工程师,也适合被拉到平台项目里做效果优化的应用层同学。
我把 feature2 讲明白的方式很直接:先看它要解决什么痛点,再拆它的核心设计,然后按“实现一个自己的节点”走一遍最小可跑通的流程,最后集中说排查思路。这样你回去对着代码能很快定位到该改哪里,而不是在几个目录里互相翻。
2. feature2 的核心设计:节点、端口和一张有向图
2.1 从 feature1 到 feature2:为什么“图”比“流水线”好用
早期的 feature1 框架更像一条固定的流水线:预览是预览,拍照是拍照,每个场景写死一串处理模块,模块之间靠硬编码的顺序衔接。想在某两个模块之间插一个新的处理步骤,得改主流程代码,改动链路长,而且不同项目的 usecase 差异一大,代码就很容易长出各种分支。
feature2 把这个结构改成了有向无环图(DAG)。每个处理单元叫一个 “feature”(代码里往往是Feature类或者对应的FeatureRequest),feature 之间通过端口连接。一条完整链路比如“sensor 出帧 → 降噪 → 美颜 → 变焦 → 编码器”,就被表示成 5 个节点和 4 条边的一幅拓扑。某个项目想省掉美颜,不删代码,只改拓扑描述;想加一个超分节点,也只需要在拓扑里新挂一个 feature 并把前后两个端口连起来。
这种抽象带来的实际收益在调试期就能感受到。我调试某个项目的夜景多帧合成时,需要把“帧选择 + 合成”拆开单独验证。在 feature1 里我得临时复制一条完整链路再删掉一半节点,feature2 里我只需要构造一个两节点的拓扑,一个做帧选择,一个做合成,直接提交到 chi 节点的 usecase 上,验证完再回去改正式拓扑。整个闭环从改代码、编译、push 到看效果,时间能缩短一半以上。
2.2 节点与端口:数据往里进,处理后往外出
在 feature2 里,一个 feature 节点至少要有一对端口:输入端口和输出端口。端口之间传输的是 buffer 和 metadata。buffer 是图像数据本体,metadata 是伴随这一帧的辅助信息,比如曝光时间、增益、人脸坐标、AWB 状态。节点从输入端口拿到 buffer,自己的算法处理完之后,把结果写到输出端口对应的 buffer 里,再通知下一个节点。
端口有几个属性直接影响你做事的粒度。一个是端口对应的 buffer 需求,比如宽高、格式、数量;另一个是端口的工作模式,常见有bypass和copy。bypass 意味着这个节点处理时直接使用上游 buffer,不做额外拷贝;copy 模式是复制一份再处理。刚上手时,很多问题出在这里:某些上游节点输出的格式是 NV12,你的算法要 YUV420 半平面,于是不得不在端口配置里把格式转掉,或者自己申请一块新 buffer。搞不清楚端口属性,后面配置拓扑时就会面对各种“负优化”。
通常一个节点会有一个主输入和一个主输出,但也可以有多个端口。比如一个“画质增强”节点,可以同时接收主图输入和深度图输入,输出一张增强后的图。多端口的存在让图结构比简单的链式多了一层表达能力,代价是拓扑解析和 buffer 申请的逻辑变复杂,你得显式地指定每个端口关联的是哪条边。
2.3 节点权重的意义:让框架知道你的算法有多重
feature2 的设计里有一个常被忽略的参数:节点的权重(processing cost)。框架调度时并不只是做拓扑排序,它还要考虑当前硬件的计算压力。有的节点是 GPU 算子,有的是 DSP 算子,有的纯 CPU,甚至有的节点要占用单独的硬件资源(比如某个专用加速器)。节点权重就是告诉框架“我这个算法大约要消耗多少算力”。
平台在用例运行前会做资源预算。如果预算超了,它可能在运行时选择降级,比如切到一个更轻的算法路径,或者挂起某些非关键节点。你自定义的节点如果权重给得过高,会出现一种很玄学的现象:单帧处理没问题,连拍或连续预览时,帧率掉得厉害,甚至直接丢帧。而把权重调成符合实现水平的数值,调度就平顺了。
我一般会先做一次单节点耗时测量,把单位帧的耗时除以帧间隔时间作为权重数量级参考,再留一点余量。权重也不要给太低,太低会让框架以为负载很轻,同时塞进多个重负载节点,结果一起超预算。
2.4 buffer 流转与引用计数:谁负责释放内存
在用 feature2 的时候,最容易出问题的是 buffer 谁申请、谁释放、谁引用。平台里 buffer 通常来自ChiBufferManager,节点拿到 buffer 后,不自己随意释放,而是通过引用计数来管理。一个 buffer 可能同时被下游节点引用(比如你要做帧对齐),也可能被多个节点共享读。
在实际代码里,一个比较规范的做法是:输入端口给你 buffer,你用完马上把引用还回去;如果你还要留作后用,比如做多帧缓存,就把它的引用计数加一,存到自己管理的队列里,等真正用完再一次一次减。如果不加引用直接存,下游节点可能已经把这个 buffer 回收到池子里,下一帧的同一个地址写入了新数据,你手里的老帧就被“悄悄改掉”了。这是多帧类功能最常见的翻车原因。
3. 实现一个自定义 feature2 节点:从接口到回调
3.1 代码骨架:头文件定义两个核心结构
新增一个 feature2 节点,通常要做三件小事:定义一个ChiFeatureType的扩展,定义节点自己的请求处理类,以及定义端口和拓扑。代码上先落一个头文件,声明你的 feature 类型、端口数量和端口属性。下面是一个常见骨架示意:
// my_beauty_feature.h #include "chi.h" #include "chifeature2.h" // 1. 扩展 feature type,在现有枚举后追加你自己的类型 enum ChiFeatureTypeMyBeauty { ChiFeatureTypeMyBeauty = 0x1001, // 选一个平台没用到的值 }; // 2. 定义这个 feature 的端口数量与配置 static const ChiFeaturePortInfo MyBeautyInputPort = { .portId = 0, .portType = ChiFeaturePortTypeInput, .portProperty = ChiFeaturePortPropertyDefault, }; static const ChiFeaturePortInfo MyBeautyOutputPort = { .portId = 0, .portType = ChiFeaturePortTypeOutput, .portProperty = ChiFeaturePortPropertyDefault, }; // 3. 定义 feature 的请求处理类 class MyBeautyFeature : public ChiFeature2Base { public: MyBeautyFeature(ChiFeature2RequestInfo& reqInfo, ChiFeature2Session* session) : ChiFeature2Base(reqInfo, session) {} // 处理输入buffer,产出输出buffer ChiFeature2StatusCode ProcessRequest(ChiFeature2RequestInfo& reqInfo, ChiFeature2Buffer* inBuf, ChiFeature2Buffer* outBuf) override; };这里的关键是继承ChiFeature2Base并实现ProcessRequest。平台框架会按拓扑顺序调用节点,每个节点只关心自己的输入输出,不需要管上游是谁、下游是谁。
3.2 注册与加载:把节点挂到 chi-cdk 的用例上
节点类写完只是第一步,你还需要让 chi-cdk 在解析拓扑时认识你这个 feature。常见做法是把这个 feature 的构造入口注册到一个 feature 工厂里面,同时在用例描述文件或代码里把 topology 中对应的节点类型指向你的类型。
注册的方式各平台大同小异,一般是在ChiFeature2Case初始化的时候,把自己的构造器塞进一个表里,后续解析到 type 时按表查出来创建实例。这一步最常见的坑是:枚举值冲突。如果你的 feature type 数值和平台预留或已有的枚举重叠,解析会直接失败,日志里会看到“unknown feature type”之类的报错,而且位置还不一定好找。
3.3 最简拓扑:两个节点也能跑一个用例
为了验证你的 feature 实现是否正常,可以先把链路缩到最小:模拟一个“输入源节点 → 你的处理节点 → 输出节点”。输入源用现成的测试帧,输出节点直接把 buffer 丢给 preview 或者 dump 到文件。下面是一个构造拓扑的示意:
// build_my_topology.cpp ChiFeature2Topology topo; topo.portNum = 4; // 两个节点,一共4个端口 // 输入源节点,只有输出端口 topo.nodes[0].featureType = ChiFeatureTypeTestSource; topo.nodes[0].outputPort[0].portId = 0; topo.nodes[0].outputPort[0].type = ChiFeaturePortTypeOutput; // 你的节点,一个输入一个输出 topo.nodes[1].featureType = ChiFeatureTypeMyBeauty; topo.nodes[1].inputPort[0].portId = 0; topo.nodes[1].outputPort[0].portId = 0; // 节点间连线:source 的 0 号输出 -> mybeauty 的 0 号输入 topo.links[0].srcFeatureId = 0; topo.links[0].srcPortId = 0; topo.links[0].dstFeatureId = 1; topo.links[0].dstPortId = 0;构造拓扑时最重要的两点:一是每个节点的 featureType 和端口号对得上,二是连线必须是有向的,而且不能成环。出现环路时拓扑校验会失败,日志一般会打印“cycle detected”。
3.4 参数下发与效果回调:控制端怎么找到节点
节点跑通之后,下一步是让上层能动态调参。feature2 里一般会暴露一个属性接口,控制端通过这个接口把参数传进去。常见的做法是定义一个 request 的 private data 结构,把你的参数比如美颜等级、磨皮半径填进去,然后随 request 一起下发。
我在实现时通常会预留一个SetParameter的方法供外部调用,参数存进节点自己的状态对象。ProcessRequest 每次执行时读取状态对象,组装成算法需要的参数格式。需要注意的是:这个状态对象可能会被多个线程读写,下发参数的线程和处理帧的线程不是同一个,因此读写的临界区要用锁保护,否则会出现某一帧突然很丑的偶发问题——这是典型的数据竞争。
回调则用来告诉上层这一帧处理完了,把结果 buffer 的句柄和该帧的 timestamp 返回。调用回调时不要在锁里面直接做耗时操作,否则会拖慢整个 request 处理线程,导致后面的节点全部排队。正确姿势是把回调里要做的事尽量精简成一个通知,真正的内容放到外部线程去消化。
4. feature2 的配置参数与移植:从 demo 到真机用例
4.1 端口属性配置和 buffer 池设计
端口属性决定这个节点申请什么样的 buffer。你需要关注三个维度:尺寸、格式和 buffer 数量。尺寸不一定要和 sensor 输出一致,有时为了性能,会先用一个较小的中间尺寸做分析,再放大到全尺寸输出。格式则要和你的算法输入对齐,不匹配就得依赖内部转换,这一层转换如果放在 ProcessRequest 里就会吃掉不少耗时。
buffer 数量是另一个容易让人犹豫的配置。数量太少,上下游都会等 buffer,帧率直接掉;数量太多,内存占用大,而且某些硬件模块需要连续的内存,碎片化会引发分配失败。经验上,多帧类算法(比如 3 帧降噪)的中间 buffer 至少要比帧数多 2,预览类算法用 3 到 4 个来回流转比较稳妥。具体可以压测时再调。
4.2 拓扑和用例绑定:上层如何选中你的图
feature2 的拓扑要绑定到具体的 usecase,平台才能在有预览请求时把这条链路跑起来。常见的做法是给某个预览/拍照用例指定默认拓扑 ID,根据 ID 找到对应的节点图。
从移植角度看,替换链路时最怕的是“拓扑描述雷同但语义不同”。比如两个项目都叫预览拓扑,一个要求先美颜再降噪,另一个要求先降噪再美颜。如果只改了节点连线顺序而没改 buffer 格式,就会出现一种别扭情况:算法没报错,但效果明显不对。遇到这类问题,我建议先把节点顺序和格式变化列成一张对照表,每次改动拓扑时同时核一遍两列,能省下很多盲目排查的时间。
4.3 多相机也要走同一张图:feature2 的并发与同步
双摄或者多摄项目里,多个 sensor 的数据会同时进入 feature2。这时你需要考虑拓扑里多个输入节点的时间同步。很多实现里有一个同步节点,负责对齐两个输入源的时间戳,然后把对齐后的帧交给后端算法。
同步判断热门路是:以主摄时间为基准,从副摄的环形缓冲里找时间戳最接近的一帧。如果两帧时间差超过阈值,就丢弃或等待。feature2 下的同步节点本质上也是一个 feature,它的端口比较多,配置时要显式说明“哪个端口是主、哪个端口是辅”,否则对齐逻辑会拿错帧。常见现象是虚化算法出来的边缘忽好忽坏,多半就是同步没对齐。
4.4 性能评估:先看帧耗时分布,再谈优化
把 feature2 链路跑起来后,性能评估不要只看整条链路的平均耗时。因为节点多的时候,某些节点是并行处理的,平均帧耗时看不出瓶颈位置。我习惯在每个节点的入口和出口各打一个时间戳,汇总统计每个节点的耗时占比,再结合帧率看整体。
统计表大概是这个形式:
| 节点 | 耗时(ms) | 备注 |
|---|---|---|
| 输入源 | 8 | 含 sensor 等待 |
| 降噪节点 | 4 | 占用 DSP |
| 我的美颜节点 | 3 | CPU 算子 |
| 编码器节点 | 6 | 含 JPEG 编码 |
如果发现耗时集中在输入等待,问题大概率不在算法身上,而在 buffer 配置不够导致排队;如果耗时集中在某个算子,那就得从算法内部优化,或者换硬件加速。feature2 的图结构在这里的优势是:你可以在不改链路的前提下,临时把某个节点设成 bypass,马上就能对比出它到底占了多少开销。
5. feature2 使用中的常见问题排查与避坑
5.1 帧率掉一半,查出来是 buffer 数量不足
现象:预览链路加了自己的节点后,帧率从 30 掉到 15,CPU 占用并不高,但日志里反复出现 buffer 等待超时。
原因:新节点从池子里申请 buffer 的时机晚,持有时间长,上游要等它归还 buffer 才能继续填帧,等效地把整条链路卡成半速。
解决:先给中间 buffer 加数量,比如从 2 加到 4,再观察帧率是否恢复。如果恢复了,再逐步往下减直到找到临界值。注意不要一次加太多,内存占用会涨。
5.2 输出图是绿的或者花的,多半是格式没对上
现象:自己的节点输出后,预览画面整体偏色或者出现马赛克一样的噪点,但节点内部算法自测没问题。
原因:输出端口声明的 buffer 格式和你实际写入的数据格式不一致。比如你写入了 NV12,但端口属性写的是 YUYV,下游按 YUYV 解析自然花掉。
解决:把端口的 buffer format 改为实际写入的格式。如果算法指定只能输出一种格式而下游必须用另一种格式,就在两个节点之间插入一个转换节点,或者在端口上配置转换开关,让框架帮你做一次内部转换。
5.3 回调里直接做重活,把整个图卡死
现象:画面一卡一卡的,尤其是多帧连拍时特别明显,看起来像内存不够,但实测占用很低。
原因:回调用来处理结果通知的那个函数体里,直接做了写文件、拷贝大 buffer、或者上锁等待等其他操作。这个回调运行在框架处理线程上,一旦阻塞,后续节点全部排队。
解决:把回调函数体重构成轻量级:只塞一个 event 到队列里,立刻返回。具体耗时的目标如写 jpg、上传、统计,放到另外的工作线程里消费 event。
5.4 改了拓扑配置却不生效
现象:在配置里新增了一条边,重新编译跑起来,效果还是老样子,打印拓扑信息也显示新节点在,但实际路径根本没走。
原因:usecase 选择拓扑的逻辑是按某个条件匹配的,比如按相机 ID、按场景模式。你改的是默认拓扑,而运行时的实际场景走的是另一条拓扑。
解决:在 feature2 初始化的日志里确认当前选中的拓扑 ID 和节点列表。如果确定没选中你改的那条,去查用例与拓扑的映射表,把新配置挂到正确的场景上。
5.5 玄学崩溃:不同帧率下偶发 crash
现象:跑长时间压力测试,几分钟到几十分钟不定期崩溃,崩溃栈在 buffer release 附近,抓 log 稳定复现不了。
原因:某个分支路径里没有遵守引用计数规则,提前把 buffer 释放了,另一条路径还在用。
解决:把所有对 buffer 的获取和释放都打上断点,配合 log 比对每一帧的引用计数变化。重点检查异常分支,比如算法判断“当前帧不处理”时,有没有把原本该 hold 住的引用清掉。
6. 进阶:用 feature2 做动态降级与效果优先级控制
到了产品阶段,feature2 除了跑通链路,还要处理“某些时刻性能不够用”的问题。这时候我会用拓扑里的动态切边来做降级。比如平时走“美颜 + 降噪 + 超分”三节点,当检测到温升或者帧率掉到阈值以下,就切到“降噪 + 美颜”两节点,甚至只保留降噪。切换时只需要在运行时重新提交一组连线,不需要改传感器配置。
实现动态降级前,先给节点排好优先级:谁可以丢、谁不能丢。比如降噪关系到基本画质,美颜是效果卖点,超分是加分项。我会把降级的优先级从低到高设为“超分 → 美颜 → 降噪”,这样即便切到最简链路,出图也不至于没法看。每一步降级后要重新评估一次帧耗时,确保没有反向波动。
效果优先级控制还有一个常见场景:3A 和效果节点的竞争。某些算法会修改曝光或增益,如果和 3A 抢控制权,画面会闪。我的做法是在 feature2 节点里做一个“效果预算”上限,超过预算就不允许覆盖 3A 参数,这样算是一种软约束。调试这类问题时,我会同时打印 3A 的最终曝光值和算法的期望曝光值,比对看是谁赢了。
最后把个人习惯总结一下。我每次新接手一个 feature2 相关项目,会先做三件事:读一遍当前 usecase 的拓扑定义、打印一次运行时拓扑、跑一个最小链路的 demo,把这三件事做完再往正式链路上加东西。凭经验直接改大拓扑,往往会在无关的环节浪费大量时间。这个习惯帮我避开了不少“改半天发现没走到那一条链路”的尴尬,希望帮到你。
本文还有配套的精品资源,点击获取