AvatarX 智能终端平台

实时美颜模块 OpenCV

  1. 双边滤波 距离近 + 颜色相近的像素权重大;距离远或颜色差异大的像素权重小。
    降采样:宽高都缩到四分之一,1920×1080 变为 480×270。

    “我在小图上用双边滤波降低细小波动,再放大与原图混合。双边权重兼顾距离和颜色差,减少跨边缘模糊;降采样减少计算量,但会牺牲细节”

  2. LUT 以空间换时间,用一个数组存储预计算结果,直接根据颜色去索引映射至目标值。
    创建了 1×256 的 CV_8UC1 表
    https://zhuanlan.zhihu.com/p/604897964
    https://dev.epicgames.com/documentation/unreal-engine/using-lookup-tables-for-color-grading-in-unreal-engine?application_version=5.5&lang=zh-CN
    https://blog.csdn.net/m0_51133195/article/details/154124578

    “美白曲线本来需要算对数,我把 256 个输入的结果预计算成表。参数没变就复用,每帧只做查表。”

  3. 局部形变 反向映射,离中心越远,形变越弱,到边界为零。

  4. 妆容即口红,以两个嘴角的中点作为中心,嘴角距离决定椭圆尺寸;生成椭圆 mask,再高斯模糊边缘,即“羽化”。减少 G、增加 R,形成红润效果。

  5. GPU 磨皮 以 RGB 通道颜色综合阈值判断肤色,命中肤色的中心像素做邻域均值再混合。(HLSL shader)

“我的优化思路分为计算和数据传输两部分,让 GPU 和 CPU 混合处理。计算方面,大眼、瘦脸等特效需要为多个位置重复进行复杂计算,可以用 SIMD 指令进行 CPU 多核并行计算,来批量生成映射,再由 GPU 执行重映射。数据传输方面,利用 cuda 尽可能让特效按顺序在 GpuMat 上连续处理显存中的图像,避免每个效果在 GPU 和 CPU 间反复拷贝。对于高度依赖 CPU 复杂数学逻辑的特性(贴纸特效),只下载必要的 ROI(局部区域),用 OpenCV 函数和复杂数学计算处理后回写原 GPU 区域,规避 PCIe 带宽瓶颈。”

面部驱动

  • YuNet 是人脸检测模型
  • ONNX 是存储模型计算结构和权重的格式,通过 OpenCV 的 FaceDetectorYN 加载 .onnx
  • 检测回答“哪里有人脸”;关键点定位回答“这张脸的眼鼻嘴在哪”
  1. 每张检测结果有 15 个数:框的位置和宽高 4 个、5 个点的 x/y 共 10 个、分数 1 个。点序为右眼、左眼、鼻尖、右嘴角、左嘴角。
  2. 处理检测节流:缩到 320×320,每两个处理帧检测一次
  3. 丢失帧沿用上帧数据:丢失帧<5沿用,以抗闪烁
  4. 参数平滑逻辑:权重平滑,进一步降低坐标抖动。(平滑后=0.7×上次平滑值+0.3×本次检测值)

数字人道具模块

  1. warpAffine 仿射变换,双眼两个点可以给出三个信息:中点代表位置,距离代表大小,连线方向代表平面内角度。计算 dx=rightEye.x-leftEye.x、dy=rightEye.y-leftEye.y,再取 angle=-atan2(dy,dx)×180/π,斜率即倾斜角度。
  2. 眼距 sqrt(dx²+dy²),目标贴纸宽度为眼距的 2.5 倍。

渲染管线模块

  1. YUV 中 Y 表示亮度,U/V 表示色度。人眼对亮度细节更敏感,因此可以保留更多亮度信息、减少色度采样。4:2:0 表示图像中每个 2×2 像素区域有 4 个 Y,配一组 U/V。
    NV12 把所有 Y 放在一个平面,再把 U/V 交错放在第二个平面。YUV420P 则是 Y、U、V 三个独立平面。两者都是 4:2:0,但字节排列不同。
    https://zhuanlan.zhihu.com/p/1888633916459569669
    https://blog.csdn.net/byhook/article/details/84037338

  2. 色彩矩阵:把 RGB 换成 Y、U、V。8 位 Limited Range 的 Y 映射到 16~235,色度映射到 16~240。

  3. 色彩分级:整体调色滤镜,3维 LUT,用 512×512 图片保存一个 64×64×64 的颜色立方体:64 个蓝色切片按 8×8 平铺,每个切片内部是红绿坐标。

  4. 读写同一张纹理冲突黑屏问题:A 已经绑定为输出 → 又尝试把 A 绑定为 shader 输入时,D3D11 会检测到冲突,将冲突的输入槽置为 NULL,而不是按要求绑定 A。

    “同一资源不应同时作为 shader 输入与渲染输出。绑定冲突时 D3D11 可能将输入槽置空,随后采样得不到预期画面。需要清理绑定,并加互斥锁,让多级滤镜交替读写两张纹理,避免输入输出冲突。”

    项目的 convertRGBtoNV12 在绑定输入前,先做这件事:

    1
    2
    3
    // 清空当前渲染目标:A 不再被当作输出画布。
    ID3D11RenderTargetView* nullRTV[] = { nullptr };
    m_context->OMSetRenderTargets(1, nullRTV, nullptr);

    这里的“清空”是清空输出槽的绑定,不是擦掉纹理 A 的画面,也不是释放纹理。
    随后才把 A 接到输入槽,并选择另一个输出目标:

    1
    2
    3
    4
    5
    // 现在可以把已经画好的 A 作为输入读取。
    m_context->PSSetShaderResources(0, 1, &inputTex);

    // 将计算出的亮度写到 NV12 的 Y 平面,而不是写回 A。
    m_context->OMSetRenderTargets(1, &targetY, nullptr);

    使用Ping-Pong 双缓冲,使每一步都有独立的输入和输出
    如果连续做磨皮、美白、锐化,每一步都需要“读上一轮结果、写新结果”。
    准备 A、B 两张纹理交替使用:

    操作 读取 写入
    磨皮 原图 A
    美白 A B
    锐化 B A

采集模块

  1. 摄像头共享与独占:有2级,前1级可共享读不调整帧源配置;后一级使用独占控制,能控制帧源配置。每次申请失败要先清理资源。

    微软官方WinRTFrameReader标准流程:
    ① 寻址(Enumerate):在庞杂的系统设备列表中,拿到目标摄像头的“唯一身份证”(DeviceID)。
    ② 配置与初始化(Initialize Settings):向系统提交一份“需求清单”,决定接下来是用CPU还是GPU处理数据,是独占硬件还是与其他软件共享。
    ③ 定位数据源(Find Source):从摄像头提供的多个功能镜头中,精准剥离出“彩色画面(Color)”的数据流。
    ④ 建立阅读器(Create Reader):为选定的镜头建立一个专属的数据接收队列,并决定当程序处理不过来时该怎么办。
    ⑤ 订阅事件(Subscribe Event):挂载一个FrameArrived回调函数,告诉系统有新画面时通知程序。
    ⑥ 开机拉流(Start):正式命令摄像头开始录像。
    ⑦ 拆解快递(Frame Arrived Callback):获取最新帧,进行格式转换,并提取为 C++ 能读写的裸内存。
    ⑧ 关机清理(Cleanup):停止数据流,释放摄像头设备,销毁阅读器。

  2. WGC 帧池,是 Windows 截取窗口或屏幕时,用来暂存、复用采集画面的缓冲区集合,负责在“系统产生画面”和“程序处理画面”之间交接数据。帧池是一组复用的帧缓冲区。
    ① 确定截取哪个窗口
    ② 创建两缓冲帧池
    ③ 注册回调,然后启动采集,“有新帧时,调用 onFrameArrived”
    ④ 系统产生新画面,触发回调,系统将采集画面放入帧池,触发 FrameArrived 事件,这个通知在帧池内部工作线程触发
    ⑤ 回调中调用 TryGetNextFrame(),真正取得帧
    ⑥ 从帧里取得纹理,再提供给渲染
    frame → Surface → ID3D11Texture2D → 创建或复用 SRV
    然后在锁内更新 m_currentSRV。后续通过 getTexture() 取得这个 SRV,作为读取画面的入口。
    ⑦ 用完采集帧,结束借用,允许缓冲复用。帧的有效使用结束后,缓冲可以归还给系统,装下一张画面。C++/WinRT 包装对象在析构时释放自己持有的接口引用。
    局部 frame 离开作用域 → 包装对象释放引用 → 最后一个帧引用消失 → 帧生命周期结束 → WGC 回收缓冲。

  3. 屏幕采集窗口最小化边界:是指在创建帧池前判断 size.Width<=0 || size.Height<=0 失败时返回,避免用非法尺寸创建资源,是启动阶段的防御。

  4. WASAPI:音频捕获客户端(Capture Client)
    ① 环境准备(COM Initialization):初始化底层组件环境。
    ② 发现设备(Device Enumeration):查找系统中的麦克风或扬声器。
    ③ 激活音频客户端(Activate IAudio Client)):与指定的硬件设备建立连接。
    ④ 协商格式与初始化(Negotiate & Initialize):获取设备的音频格式(采样率、位深),并告诉系统我们要怎么用它(独占还是共享?要不要录系统声音?)。
    ⑤ 获取捕获服务(Get lAudio Capture Client):拿到真正用来“抽水”的数据读取工具。
    ⑥ 启动数据流(Start):命令声卡开始录音。
    ⑦ 捕获大循环(The Capture Loop):在一个死循环中,不断检查有没有新数据。如果有,拿出来转换格式;拿完必须把缓冲区还给系统。
    ⑧ 停止与清理(Stop & Cleanup):停止流,释放所有资源。

  5. 音频统一浮点32位格式便于乘音量、混合和重采样。

  6. 静音包实现按原包长填 0,保持时长连续,不删除以防止和视频错位。

编码推流模块

  1. NVENC/AMF/x264 硬编软编回退:NVENC 使用 NVIDIA 视频编码硬件;AMF 是 AMD 媒体接口,这里用于硬件编码;x264 用 CPU 做 H.264 软件编码。可以都输出 H.264。

  2. 码控模式切换:

    模式 直观解释 使用时要知道
    CBR 恒定码率 尽量围绕固定带宽预算输出 适合带宽约束,但并非每毫秒完全相同;复杂画面可能更糊
    VBR 可变码率 简单画面少用,复杂画面多用 通常结合目标与最大码率控制波动
    CQP 恒定质量因子 固定量化参数,压缩强度较固定 码率随内容变化;不等于所有场景的感知画质完全相同
  3. 低延迟策略:关闭 B 帧 ctx->max_b_frames=0;;缩减编码器缓冲区大小 ctx->rc_buffer_size = bitrate /2;,使编码器在网卡时立刻降低画质,而不是堆积帧;编码器低延迟参数优化(开启低延迟模式);减小网络数据包大小和缓冲。

    RTMP 调优:RTMP 用来把 FLV 音视频流经网络持续发送到服务器。TCP 保证有序可靠传输,但丢包后的重传等待可能带来延迟。
    在 StreamMuxer::applyNetworkoptions 中设置 rw_timeout=3000000,单位微秒,即 3 秒;开启 flush_packets=1 每个包写出后,刷新底层 I/O 缓冲,减少数据留在 FFmpeg 缓冲里的等待,强制立刻发包;开启 tcp_nodelay=1 禁用系统的 Nagle 算法合并小包的等待,让系统有包就发,不为了节省头部开销而“拼凑大包”,降低发送延迟;将 FFmpeg 内部的包缓冲队列 fifo_size 从默认的一万砍到50,一旦网络拥堵立刻让上层感知并降码率。

  4. 拥塞降码率与丢非关键帧:

    本次检查时的队列包数 当前代码执行什么
    大于 30 先删除队列中非关键视频包;只有实际删掉至少一个包,才尝试把当前目标码率乘 0.7
    21~30,包括 30 把当前目标码率乘 0.95
    1~20,包括 20 这次检查不调整目标码率
    等于 0 若当前目标低于用户配置值,则乘 1.02,逐步恢复,最多恢复到用户配置值

    两套拥塞控制:第一套根据队列包数量;第二套根据处理一张视频帧,包括相应的格式处理、同步、编码和编码包入队总耗时。

    规则 算的是什么 数字例子
    单次耗时大于 200 ms,或平滑耗时大于 150 ms 判为拥塞 不是队列超过 20/30 的那套判断
    前两次连续拥塞 该控制器自己的当前目标×0.7 2500→1750→1225 kbps
    连续拥塞次数超过 2 该控制器自己的当前目标×0.5 1225→612.5 kbps,再受上下限约束
    符合恢复条件 每次增加 50000 bit/s,即 50 kbps 1500→1550 kbps,不是增加 50%
  5. 断线重连:“发送线程发现网络写包失败后,会标记对应输出不可用,暂时跳过这一路发送,并周期性尝试关闭旧 IO、重新打开原推流地址。连接成功后清除失败标记,恢复后续包的发送。重试由出队包计数触发(30个);另外,网络连接恢复和媒体播放恢复是两回事,还要验证媒体头、编码参数、关键帧和时间戳是否正确恢复。”

  6. 音视频同步:
    用已处理音频样本累计数作为同步参考,并用视频 PTS 计数推算视频时长。
    计算 PTS 以实现同步。DTS 由编码器生成,在关闭 B 帧的情况下,理论上两者相同。

    “把音频样本数和视频帧计数换算成秒,以音频为参考。视频落后就重复画面补时间,视频超前就跳过输入等待;阈值是一帧时长,单次最多补五帧。”

    1
    2
    3
    4
    5
    6
    7
    // 音频样本数除以采样率,得到音频时间。若 diff>一帧时长,音频走得更远,视频时间落后,需要重复编码当前画面,让视频计数追上;补帧数限制最多 5。若 diff<-一帧时长,视频走得太快,丢掉本次输入,不增加视频计数,等音频追上。
    double audioSec = (double)m_totalAudioSamples / m_config.audio.sampleRate;

    // 视频计数除以帧率,得到视频时间。
    double videoSec = (double)m_videoPtsCounter / m_config.base.fps;
    double frameDur = 1.0 / m_config.base.fps;
    double diff = audioSec - videoSec;

端云内容模块

  1. 处理场景/素材云端同步:客户端把对象序列化为 JSON 发送给云端,或把云端 JSON 还原成本地对象实现设置同步。

    “项目使用 Qt 的 JSON API 做场景数据转换。保存时,手动把场景和元素的属性写入 QJsonObject,把多个元素组织成 QJsonArray,再通过 QJsonDocument 转成 JSON 文本,包装成 HTTP 请求发送。加载时先解析云端 JSON,再读取素材 ID、类型和布局属性,获取或创建素材源,重新创建 SceneItem 并恢复位置、尺寸和层级。JSON 序列化负责数据表达,上传、下载和应用到本地对象这些流程合起来才是这里的同步。”

  2. 验证码/密码/企微扫码三种登录鉴权:
    “密码登录提交账号密码、租户 ID 和客户端标识等,组成 JSON,发送 POST 到 /auth/login;企微扫码通过 WebEngine 加载二维码页面,拦截并捕获携带 Code 的重定向链接,再提取 Code 提交给业务后端换取本系统 Token。验证码部分实现了接口封装,具体短信服务商不是我接入的。登录成功后统一保存 Token,由 HttpManager(定义了 RESTful动作 get/post/put/del)在后续请求中附加 Authorization 请求头,获取用户资料和权限,供后续使用。”

    序列化:网络层将 C++ 的 QJsonObject 对象序列化为紧凑的 JSON 字符串(QJsonDocument:Compact)。

  3. Bearer Token:是一种加密字符串,它代表的是用户的身份或权限。服务端通过验证这个 Token,确认请求的发起者具备访问某些资源的权限。QString finalToken=token.startsWith("Bearer")?token:("Bearer"+token);强制为 Token 加上了 Bearer 前缀。

    ① 替代传统明文认证,提升安全性:是服务器生成的 “临时凭证”:令牌本身不包含用户敏感信息(如密码),即使泄露,攻击者也无法反向推导出用户账号密码;令牌可设置短期有效期,降低泄露后的风险范围。
    ② 简化后续请求的鉴权流程,提升效率:用户只需在首次登录时提交真实凭证(如账号密码),后续向服务器发起请求时,无需重复提交敏感信息,只需在 HTTP 请求头中携带 Bearer Token 即可,流程极大简化。
    ③ 灵活控制权限范围(配合令牌内容设计):服务器可在生成令牌时,将用户的权限信息(如 “只能访问订单列表,不能修改订单”)、用户 ID 等数据加密存储在令牌中(如 JWT 格式的 Bearer Token),后续验证令牌时可直接解析出权限,实现 “精细化权限控制”。

  4. 创建者与 authList 权限模型:SceneAuthEntry 中有 userId、query、add、edit、del;云端字段对应 queryAuth、addAuth、editAuth、deleteAuth。它类似“每个场景一张授权名单”。

    ① 编辑/删除先检查对应全局权限,可直接放行。
    ② 当前用户是创建者,或者创建者为 0,直接放行。
    ③ 遍历 authList,找到用户,按本次操作检查对应值是否为 1。
    ④ 没匹配则拒绝。
    例:张三创建场景,李四只有 query=1、edit=0,可以查看但不能通过这项对象授权编辑。不过李四若拥有全局编辑权限,会在第一步被放行,因此不能只看 authList。

  5. 共享布局与个人偏好两级内容合并:平台提供默认布局配置序列化成 JSON 保存到云端,提供给多个用户使用的同一套场景设置,并存储个人偏好设置,将各个元素的位置、尺寸等信息从共享配置读取,而设备、组件的选择保留个人偏好。

  6. 素材下载热替换:异步下载和替换,使用 emit 通知管理器替换源文件

张老师打开管理员配置的教学场景
  1. 下载共享场景配置和张老师的个人偏好。
  2. 根据元素 UUID 合并:位置、大小用管理员的;摄像头使用张老师选择的 USB 摄像头。
  3. 场景里的课件图片需要从云端下载。
  4. 图片下载完成后,找到对应的图片元素,替换它关联的内容源。
  5. 课件显示在管理员安排的位置,不必重建整个场景。

端云 AI

  1. 旁路采样:当系统从摄像头或麦克风采集到原始数据时,数据流被一分为二:
    ① 主路(Main Pipeline):传给m_recordcontroller进行高强度编码(如H.264/AAC),并推流给观众。
    ② 旁路(AI Bypass):传给_aiBridge,进行低分辨率降采样和切片,供Al审核使用。
    分一路根据 PTS 每秒采样 2 帧(2FPS),对音频分片(每2秒一个 PCM 切片),通过 HTTP 上传字节流QByteArray。
  2. 视频缩放与音频重采样:使用 sws_scale 把 NV12 转为 YUV420P,硬编码输出为 480×480;主采集是 48 kHz 双声道 Float32,云端需要 16 kHz 单声道 S16。同时改变采样率、声道数和数值格式,转换结果写入 FIFO。
  3. 按时间切片上传:FIFO 累计够 32000 个样本,取出 64000 字节作为一片 2 秒 PCM,交给上传回调。未够一片的数据继续留在 FIFO。
  4. 队列丢弃策略:视频队列在入队前若 size()>5,或音频入队前若 size()>100,丢最旧任务。丢旧视频使分析更接近当前状态;音频更重连续性,所以阈值更大。保证实时性>完整性。
  5. 端云状态轮询与自动停播逻辑: 默认 1000 ms 轮询云端状态,下播或 roomId 失效时自动停播。

处理崩溃报告与调用栈还原

  1. dump 和调用栈分别是什么:
    调用栈记录函数调用关系:例如用户开相机 → 初始化设备 → 创建 reader → 出错。机器运行时主要留下地址,符号文件把地址对应到函数名、源码文件和行号。

    dump 是出错时线程、寄存器和部分内存等现场数据;PDB 是某次构建的符号映射。dump 保存现场,PDB 帮助解释现场。 不是拿到任何同名 PDB 都能还原,必须匹配实际 EXE/DLL 的构建。

  2. 仓库实际做了哪些步骤
    注册未处理 SEH、CRT 无效参数、纯虚函数、std::terminate 和信号等入口;出错后建立 CrashDumps 目录,调用 MiniDumpWriteDump 写 dump,再输出文本栈。

    文本栈使用 SymInitialize 加载符号,StackWalk64 沿栈遍历,SymFromAddr 查函数名,SymGetLineFromAddr64 查行号。g_IsInCrashHandler 用于避免处理器递归进入,最后终止进程。

    “访问冲突”常指读写无效地址;“纯虚调用”可能与对象生命周期异常有关。但异常名只告诉你症状,不能直接断定根因。最顶层可能只是崩溃处理函数,真正错误还要看异常上下文和之前调用。

  3. Release 为什么还要保留符号
    CourseStudio.pro 在 Release 中增加 /Zi 与 /DEBUG,便于保留调试符号。Release 仍可以优化;有符号不等于变量都完整保留,优化会造成内联或变量不可见。

    一个能讲清的排查过程:先确认版本与匹配符号,再打开 dump,找异常线程、异常地址和调用链,检查对象生命周期、参数和并发时序,结合复现步骤验证假设。不能只看到某库在栈顶就断言库有 bug。

    当前在崩溃进程内使用 Qt、堆分配和符号解析,严重内存损坏或死锁时报告仍可能失败;手动捕获上下文的 terminate/signal 路径也未必保留最初抛错点。应讲“提升现场可诊断性”,不要承诺所有崩溃都能完整还原。

“我用 dump 保留现场,用对应构建的 PDB 还原函数和源码位置,再结合异常线程及日志查生命周期和并发问题。Release 也生成符号,但优化和现场损坏会限制可见信息。”