内窥镜影像采集与病灶辅助分析工作站

图像针对息肉(突起)、炎症/糜烂(红色)处理。

内镜图像增强

开发降噪、亮度调整、对比度拉伸和色彩校正处理,使用双边滤波抑制图像噪声并保留黏膜边缘;通过 LUT 完成像素映射,并结合组织区域掩膜排除暗区、反光区和无效边缘。

输入是 1920×1080、8 位 NV12、BT.709 有限范围。先按 709 矩阵把 Y=16~235、UV=16~240 转成 RGB8 的 0~255 数值,再依次执行:原图生成掩膜 → 双边滤波 → 三维 LUT 色彩校正 → 颜色转换(RGB → YCrCb/YUV) → 一维 LUT 亮度(Y1)调整 → CLAHE 对比度增强(Y2) → 恢复 RGB → 掩膜融合。

掩膜从未经增强的原图生成,避免先提亮后把暗区误判成有效区。双边滤波先抑制细小波动,随后亮度、对比度变换不会先把噪声放大。色彩校正采用三维 LUT 色彩校正,放在亮度调整之前。后两项选择性启用,先亮度后 CLAHE。

降噪基线采用原分辨率、邻域直径 5、sigmaColor=20、sigmaSpace=2,不沿用旧美颜模块的宽高各缩小到四分之一。它优先减少细节损失;处理成本由有效视野 ROI 和 SIMD/GPU 实现控制,不靠大幅降采样换速度。例如暗处原来有 2 个灰度级的噪声,先乘以 3 倍对比度后就变成 6 个灰度级,会更显眼。反过来先抑制小波动,再适度拉开对比度,更容易控制观感。但不能为了“干净”抹掉细小纹理;需要同时保留未增强画面供对照。

  1. 双边滤波降噪:距离近 + 颜色相近的像素权重大;距离远或颜色差异大的像素权重小。抑制了边缘模糊,保留边界。
  2. 亮度调整:一维 LUT,以原亮度 Y(0~255)为索引,查出目标亮度。提前计算映射曲线,避免逐像素重复计算。
    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
  3. 对比度拉伸:CLAHE 局部直方图均衡化,以增强局部明暗差异,并抑制噪声放大
    ① 图像分块(Tiles):将图像划分为 M×N 个小块(OpenCV 默认 8×8),每个块独立进行直方图均衡化。
    ② 对比度限制:对每个小块的直方图设置截断阈值(即 ClipLimit),当某一灰度级的像素数量超出该阈值时,超出部分的像素会被均匀分配到其他灰度级中。限制了最大对比度增益,抑制噪声放大与过曝。
    ③ 双线性插值为:避免块拼接产生伪影,对相邻块的结果进行双线性插值,使过渡平滑。
  4. 色彩校正:三维 LUT
    整体调色滤镜,用于修正成像链路偏色,3维 LUT,用 512×512 图片保存一个 64×64×64 个颜色采样点:64 个蓝色切片按 8×8 平铺,每个切片内部是红绿坐标,通过查表和插值得到输出颜色。
  5. 掩膜:显示结果 = 原图×(1-M) + 增强图×M;暗区、反光区,即无效边缘。
    区域 到底是什么 简单做法 为什么排除
    暗区 画面里照明不足、非常暗的部分 计算亮度,低于阈值就标记;例如 Y<20 强行提亮容易把噪声一起放大
    反光区 灯光照在湿润表面,形成的强亮斑 找“非常亮、接近白色”的像素:RGB 三通道都很高 已经饱和的亮斑没有细节可恢复,继续增强只会更刺眼

“我的优化思路分为计算和数据传输两部分,让 GPU 和 CPU 混合处理。计算方面,暗区、反光区掩膜的生成需要对大量像素重复进行计算和判断,可以用 SIMD 指令在 CPU 上进行并行计算,批量生成掩膜,再由 GPU 执行原图与增强图的逐像素混合。数据传输方面,利用 CUDA 尽可能让图像处理按顺序在 GpuMat 上连续执行,避免每个效果都在 GPU 和 CPU 间反复拷贝。掩膜外接矩形就是必要的 ROI(局部区域),对于需要 CPU 处理的部分计算,仅复制部分图像,再回写 GPU 原图对应区域,减少整帧传输导致的 PCIe 带宽瓶颈。”

“整体的数据传输链路上,原本优化思路,最初设计是优化计算和数据传输两部分,也就是让 GPU 和 CPU 混合处理。在计算上,对于需要大量的复杂 CPU 计算,可以通过 SIMD 指令在 CPU 上进行批量并行计算,然后再由 GPU 执行这个原图的各种特效处理,利用这个 CUDA 让图像尽可能地按顺序在 GpuMat 上连续执行,避免这些处理在 GPU 和 CPU 间反复拷贝。然后还有另外要 CPU 处理的部分计算,可以使用 ROI(局部区域)仅复制部分图像,再回写 GPU 原图对应区域,减少整帧传输导致的 PCIe 带宽瓶颈。其中还准备用 D3D11 的乒乓双缓冲来回输入输出两张纹理进行合成。但后期发现没有这么复杂的需求,就改成了优先采用 CUDA,在显存中连续完成图像处理,并通过互操作将结果交给 D3D11 合成显示;在不支持 CUDA 时,回退 CPU 进行图像处理。CPU 路径对掩膜生成等重复像素计算采用 SIMD 并行优化,并根据掩膜确定 ROI,减少处理范围。并由 D3D11 统一完成原图融合、病灶标注和显示。因为如果用 shader 需要自己编写很多复杂的处理,而 CUDA 可以直接调用 OpenCV 库很多现成的函数。”

互操作的解决方案:消除数据复制。
图形互操作的核心机制是:CUDA 能够将纹理直接映射到其地址空间,从而实现:

  • 零拷贝(Zero-Copy): 数据在 GPU 显存中原地不动。
  • 高速访问: CUDA Kernel 可以直接读取和写入这些图形资源,无需经过 PCIe 总线或主机内存。
    解决了原本频繁的 CPU/GPU 内存 复制(通过慢速的 PCIe 总线)造成的巨大性能瓶颈。

但本项目仍存在 GPU 内的复制,D3D11 的二维纹理映射到 CUDA 后,得到的是 CUDA 数组 cudaArray,需要复制到 GpuMat。

粒子模拟、光线追踪、漫反射之类的复杂计算一般应该也可以用 CUDA 互操作来高效计算吧。
“在光线遍历阶段,尽管相交测试主要由 RTCore 处理,但次级光线的生成、着色计算以及尤其是包含复杂材质交互(如次表面散射光谱渲染)的光路计算,仍需回归到 CUDA 的通用计算单元进行处理。这种“RTCore 负责几何求交,CUDA 负责着色与逻辑”的分工模式,已成为 UnrealEngine5 的 Lumen 全局光照系统与 Unity HDRP 的光线追踪模块的标准架构”
GPU 并行计算在游戏引擎中的集成与应用:基于 CUDA 架构的系统性综述

病灶模型接入

基于 ONNX Runtime 接入息肉与异常黏膜检测模型,完成输入缩放、颜色转换、归一化、推理及结果解析;根据画面变化控制模型执行时机,并对连续帧检测框的位置和置信度进行平滑。

固定输入为:未增强1080P RGB 原图 → 双线性等比例缩放 → 居中填充到 640×640,背景填充灰色 RGB=(114,114,114) → Float32 除以 255(归一化) → NCHW [1,3,640,640](批次、通道、高、宽)

拉伸与留边:检测框怎么回到原图

  • 拉伸:改变图片比例填充正方形,x、y 分别直接按比例还原。

  • 留边:英文是 letterbox,看电影时,上下出现黑边,就是类似的思路。还原时先平移(向上-y轴)再乘比例。
    对同一张 1920×1080 图像:
    ① 宽和高都缩小为原来的 1/3:1920×1080 → 640×360
    这时图像没有被拉变形,但高度只有 360,距离模型要求的 640 还差:640−360=280

    ② 把这 280 平分到上下:上下各补 140。

输出解析

输出固定为 Float32 [1,N,6],每一行依次是 x1,y1,x2,y2,score,classId。坐标是 640×640 输入上的像素边界,score 已为 0~1 分数,classId 以整数值放在浮点 Tensor 中:0=息肉候选,1=异常黏膜候选。模型图不包含 NMS,客户端先去除非有限数、非法类别和无面积框,保留 score≥0.30,再做类别内 IoU=0.50 的 NMS,最后逆 letterbox 回原图。

NMS 是在同一帧中去掉高度重叠的重复检测框;跟踪是把不同帧的结果关联成同一个目标,两者不同。本项目明确做类别内 NMS:息肉候选之间相互抑制,异常黏膜候选之间相互抑制,不跨类别删除。

  1. ONNX Runtime:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    27
    28
    29
    30
    31
    32
    33
    34
    35
    36
    37
    // 初始化阶段:会话持续存在,不应每一帧重新加载模型
    Ort::Env env(ORT_LOGGING_LEVEL_WARNING, "lesion-demo");
    Ort::SessionOptions options;
    Ort::Session session(env, modelPath.c_str(), options);

    // 初始化阶段:CPU 输入缓冲预分配;这里不自动启用 GPU 推理
    constexpr int H = 640;
    constexpr int W = 640;
    std::vector<float> input(3 * H * W);
    std::array<int64_t, 4> shape{1, 3, H, W};
    auto memoryInfo = Ort::MemoryInfo::CreateCpu(OrtArenaAllocator, OrtMemTypeDefault);

    // 保留名称持有者,确保调用期间 name 指针不会悬空
    Ort::AllocatorWithDefaultOptions allocator;
    auto inputName = session.GetInputNameAllocated(0, allocator);
    auto outputName = session.GetOutputNameAllocated(0, allocator);
    const char* inputNames[] = {inputName.get()};
    const char* outputNames[] = {outputName.get()};

    // 每帧填充:从交错 RGB 转为 NCHW,逐行取指针以尊重 Mat 的行跨度
    for (int y = 0; y < H; ++y) {
    const auto* row = rgb640.ptr<cv::Vec3b>(y);
    for (int x = 0; x < W; ++x) {
    for (int c = 0; c < 3; ++c) {
    input[c * H * W + y * W + x] = row[x][c] / 255.0f;
    }
    }
    }

    // Tensor 包装已有内存;input 必须存活且本次推理期间不能被另一个任务改写
    auto tensor = Ort::Value::CreateTensor<float>(
    memoryInfo, input.data(), input.size(), shape.data(), shape.size());

    // 同步调用:结果归本次任务所有,之后按已校验的输出协议解析
    auto outputs = session.Run(Ort::RunOptions{nullptr},
    inputNames, &tensor, 1, outputNames, 1);

病灶跟踪与标注

根据检测框重叠关系、中心点距离和运动方向关联连续帧病灶,为同一目标维护跟踪标识;将病灶框、类别、置信度和检查时间绘制为独立图层,并保留原始无标注图像。

把 IoU、距离、方向得分乘权重再相加,计算把框认作同一个目标的“代价”,得分越低越好(成本)。

  1. 怎么判断两个框是不是同一个目标?
    判断依据 通俗理解 为什么有用
    框的重叠程度 新框与旧框是不是大部分压在一起? 两帧间移动不大时,同一个目标的框通常重叠很多
    中心点距离 两个框的中心离得近不近? 移动稍大、不再重叠时,也能继续比较
    运动方向 它原来往右走,新框是不是也往右了? 有多个相邻目标时,辅助区分
  2. IoU:重叠程度,IoU = 交集面积 ÷ 并集面积
  3. 预测:假设同一个目标的中心位置连续变化,第一次:x=100,过了 0.1 秒:x=110,程序估计它每秒向右移动 100 像素。那么再过 0.1 秒,先猜:下一次 x ≈ 120。新检测回来后,再看它是否在预测位置附近。
    预测中心=上次中心+速度×经过时间,
  4. 多目标匹配
    匹配代价,越小越好 A B
    T1 0.1 0.4
    T2 0.2 0.9
    如果 T1 先挑,选了最便宜的 A,T2 就只剩 B:总成本 = 0.1 + 0.9 = 1.0,独立贪心。
    如果整体考虑,让 T1 选 B、T2 选 A:总成本 = 0.4 + 0.2 = 0.6
    第二种搭配整体更合理(匈牙利算法)。
  5. 轨迹保留生命周期:目标短暂消失时,不能马上删除,会导致同一目标 ID 不断变化,不利于连续记录。
    轨迹状态固定为:新候选 → 连续两次检测命中后确认 → 未匹配时进入丢失状态 → 距最后匹配超过 500 ms 删除。候选时用虚线提示。
    采用两种期限进行平滑:
    时间规则 做什么
    200 ms 显示期限 距最近真实检测太久,就隐藏框,避免一直画旧框
    500 ms 内部保留期限 暂时保留轨迹记录,目标重新出现后仍有机会接回旧 ID
  6. 平滑:位置和置信度平滑。
    平滑检测框抖动,新显示值=(1-α)×旧显示值+α×新观测值,α=0.3时,新显示位置 = 旧显示位置×0.7 + 新位置×0.3
    根据运动速度动态调整检测间隔,目标快速移动时要减少平滑,让框更快追上;目标静止时再加强平滑。
    例如静止时 τ=80 ms,中心位移超过上次框对角线的 10% 时 τ=20 ms,加快追随;换轨迹或场景切换直接重置。
    显示置信度用 τ=200 ms 单独平滑,原始分数另存,检测阈值仍作用于原始分数。

影像采集与渲染

通过采集卡接入内镜主机视频信号,处理设备占用、采集格式变化和信号中断;基于 D3D11 完成离屏渲染与颜色转换,将原始画面、增强结果和病灶标注按图层顺序合成显示。

驱动将采集卡注册为 Windows 视频采集设备可以使用 WinRT MediaCapture 通过 SharingMode 选择共享只读或独占控制,检查初始化、启动结果。

  1. 设备占用:“原本的已经在用的预览软件已经打开采集卡,工作站需要同时读取画面,因此优先尝试共享只读,获取设备当前输出,不修改源配置;共享初始化失败后,先清理本次资源,再尝试独占控制。而如果需要调整设备的分辨率、帧率或源格式,则直接申请独占控制。因为两个软件会竞争采集设备的控制权限,需要设置独占控制和共享只读两种模式的降级兼容策略。”
    “然后启动失败时,程序仍可能已经创建部分资源,像注册回调之类的,会导致与下次尝试的冲突,需要先取消订阅和释放引用(帧对象)什么的”。
    步骤 对应 API 做什么
    1. 找设备 DeviceInformation::FindAllAsync 枚举设备名称和 ID;现有 getCameras 使用这条路径
    2. 配置会话 MediaCapture InitializationSettings 设置 VideoDeviceId、SharingMode、MemoryPreference 等
    3. 打开会话 InitializeAsync 按配置尝试连接设备,失败时处理异常
    4. 选择视频源 FrameSources 找到彩色视频源;需要改源格式时必须先有控制权限
    5. 创建读取器 CreateFrameReaderAsync 为选中的源建立读取入口
    6. 订阅通知 FrameArrived 注册新帧到达回调,并保存订阅 token
    7. 启动读取 StartAsync 检查返回状态是否为 Success
    8. 持续取帧 TryAcquireLatestFrame 在通知回调中尝试取得最新帧,空结果直接返回
    微软官方 WinRTFrameReader 标准流程:
    ① 寻址(Enumerate):在庞杂的系统设备列表中,拿到目标摄像头的“唯一身份证”(DeviceID)。
    ② 配置与初始化(Initialize Settings):向系统提交一份“需求清单”,决定接下来是用 CPU 还是 GPU 处理数据,是独占硬件还是与其他软件共享。
    ③ 定位数据源(Find Source):从摄像头提供的多个功能镜头中,精准剥离出“彩色画面(Color)”的数据流。
    ④ 建立阅读器(Create Reader):为选定的镜头建立一个专属的数据接收队列,并决定当程序处理不过来时该怎么办。
    ⑤ 订阅事件(Subscribe Event):挂载一个 FrameArrived 回调函数,告诉系统有新画面时通知程序。
    ⑥ 开机拉流(Start):正式命令摄像头开始录像。
    ⑦ 拆解快递(Frame Arrived Callback):获取最新帧,进行格式转换,并提取为 C++ 能读写的裸内存。
    ⑧ 关机清理(Cleanup):停止数据流,释放摄像头设备,销毁阅读器。
  2. 采集格式变化:应用收到的分辨率、像素格式或帧率改变,需要更新对应缓冲、纹理、颜色转换及坐标映射。
    “先关闭帧分发入口,协调在途采集回调结束。清理未执行的预览和推理任务,等待正在使用旧缓冲的任务完成;录制端按顺序处理完旧帧,渲染端确认 GPU 不再使用旧资源。随后更新缓冲、纹理和编码配置,全部就绪后再恢复新格式帧分发,避免新旧格式数据混用或提前复用缓冲。”
  3. 信号中断:设备暂时无法提供有效的内镜画面,例如 HDMI 断开或主机切换输出。程序需要提示无信号、隐藏旧标注并记录中断时间。
  4. 离屏渲染:先把结果写进自己的纹理,不直接写窗口后台缓冲。这样截图、示教和预览可复用同一结果,输出尺寸不随窗口改变。
    多消费者情况可以保留原始结果和多种效果的叠加合成处理,配合 D3D11 的乒乓双缓冲,可以连续处理保留多个纹理。

检查录制与语音备注

基于 FFmpeg 完成检查视频编码、封装和文件分段,按照检查编号关联视频、关键图像及时间戳;通过 WASAPI 采集医生语音备注,处理采样格式转换和静音数据,并写入对应检查记录。

检查开始后建立检查编号,用 FFmpeg 将视频编码为 H.264,将医生语音编码为 AAC(48 kHz、单声道、Float32),封装为 MP4;目标每 5 分钟切一段,在 IDR 边界分段。关键图像保存 PNG。MP4、PNG 和时间索引通过 HTTPS 文件接口提交院内归档服务,用检查编号关联。编码解决数据压缩,封装保存轨道和时间戳,归档解决文件归属、完整性及检索。WASAPI 采集的音频统一为32位浮点格式处理,便于乘音量、混合和重采样,静音保留原有时长,音视频使用共同的录制时间起点进行同步。

怎么知道编码器输出的是关键帧?AV_PKT_FLAG_KEY

1
2
3
4
5
6
7
8
// 源码逻辑:读取编码器返回的包标志,判断是否被标为关键帧
bool isKeyFrame = (pkt->flags & AV_PKT_FLAG_KEY) != 0;

// 源码逻辑:检查是否到达分段条件
checkFileSplit(isKeyFrame, pkt->pts);

// 然后把这个包交给发送/写文件线程
emitPacket(pkt, true);
  1. 静音包,实现按原包长填 0,保持时长连续,不删除以防止和视频错位。
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    // 输出统一为 Float32:音频帧数 × 声道数 × 每样本字节数
    int bytesNeeded =
    numFramesAvailable * m_mixFormat->nChannels * sizeof(float);

    // 分配本包输出空间;resize 本身不代表已经填零
    QByteArray floatData;
    floatData.resize(bytesNeeded);

    // 获取输出地址,并检查 WASAPI 是否把本包标记为静音
    float* outPtr = reinterpret_cast<float*>(floatData.data());
    bool isSilent = (flags & AUDCLNT_BUFFERFLAGS_SILENT) != 0;

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

编码与传输

封装 NVENC、AMF 和 H.264 编码路径,根据工作站显卡环境选择编码方式;整理音视频时间戳后将检查画面发送至院内示教服务,网络异常时保留本地录制并重新建立影像会话。

NVENC 使用 NVIDIA 专用编码硬件,AMF 是 AMD 媒体框架接口,libx264 用 CPU 编 H.264。

模型任务交互

从采集链路旁路提取图像帧,完成尺寸调整和格式转换后提交本地或院内推理服务;通过任务编号、检查编号和图像时间戳匹配异步结果,过滤已超过显示时机的标注数据。

摄像头每 33.3 ms 来一帧,模型一次可能花 100 ms。若采集回调同步等待模型,预览和录制也会被拖住。旁路把有所有权的帧交给分析线程或院内推理服务,采集继续前进。模型返回时当前画面很可能已经变了,因此还需要结果归属与时效检查。

性能处理

预先分配模型输入与图像转换缓冲,根据画面变化和病灶跟踪状态控制检测频率;推理线程只读取最新待分析帧,检测间隔内沿用跟踪结果,完成模型计算与影像预览、检查录制分别执行。

预先分配缓冲区,重复使用。模型每次需要一块输入内存。假设输入为:640×640、RGB、Float32。这块内存大约需要 4.9 MB。可以在模型初始化时分配一次,每次分析时填写新的像素数据,避免反复申请、释放。
图像缩放、颜色转换使用的固定尺寸缓冲区也可以复用。

但有一个约束:
模型还在读取时,不能把下一张画面写进去。采用一个串行推理线程,就在本次推理完成后再填写下一次输入,比较容易管理。

模型没有执行的那些帧,可以根据已有轨迹估计病灶框的新位置。超过允许时间还没有新检测结果,就停止显示旧框,避免它一直挂在画面上。
采集后分成三条处理链路:

链路 主要目标 数据策略
预览 显示及时 优先显示较新的画面
推理 结果接近当前画面 只保留最新待分析帧
录制 保存连续检查过程 按时间顺序处理,不能跟着推理丢帧

“我预先分配模型输入和图像转换缓冲,串行推理时重复使用。根据画面变化和跟踪状态调整检测频率,推理忙碌期间只保留最新待分析帧,避免结果越来越滞后。预览、推理和录制分别执行:推理可以跳过旧候选,录制按顺序处理;检测间隔内用跟踪结果估计框的位置,并限制旧结果的显示时间。”