基于鸿蒙OS开发静脉输液智能监控系统(6)-视觉识别与贴纸刻度读取算法

目录

  1. 输液液位识别的技术挑战
  2. IVGuard创新方案:贴纸刻度读取
  3. VisionResult数据结构
  4. VisionService架构设计
  5. MVP模拟算法详解
  6. OpenCV NAPI桥接设计
  7. 液位读取算法设计
  8. 多标记同时检测
  9. 识别精度优化
  10. MonitorPage与VisionService的集成

1. 输液液位识别的技术挑战

1.1 临床场景分析

IVGuard项目的核心使命是实现对输液瓶液位的自动、持续、可靠监控。这一目标看似简单——不过是"看看瓶子里还剩多少液体"——但在真实的临床环境中,视觉识别面临的挑战远超想象。理解这些挑战,是理解IVGuard为何选择贴纸刻度方案而非传统颜色分界线方案的前提。

1.1.1 病房光线变化

医院病房的光照条件极不稳定,且具有明显的时变性。白天与夜晚的亮度差异可达数十倍,病房内的日光灯色温通常在4000K-6500K之间波动,而傍晚时分的自然光色温可能降至3000K以下。更关键的是,护士查房时常开关顶灯,走廊灯光透过门缝形成侧向强光,患者使用阅读灯造成局部高亮区域,甚至窗帘的半开半闭都会在药瓶表面形成明暗交替的条纹阴影。

从计算机视觉的角度分析,这些光线变化会导致以下问题:

  • 曝光不一致:同一药瓶在不同光线条件下,摄像头自动曝光(AE)调整后的图像亮度差异可达3-5个EV值,直接导致基于像素阈值的检测方法失效。
  • 白平衡偏移:色温变化使同一液体的RGB值发生系统性偏移。例如0.9%氯化钠注射液在日光灯下RGB约为(200, 210, 215),而在暖色灯光下可能偏移至(210, 195, 180)。
  • 高光与阴影:侧向强光在药瓶表面形成镜面反射高光(specular highlight),而背光面则形成深层阴影。这些高光和阴影区域可能与液体区域重叠,严重干扰液面检测。
1.1.2 拍摄角度倾斜

在IVGuard的实际使用场景中,患者或家属使用手机/手表对输液瓶进行拍摄时,拍摄角度几乎不可能保持正对药瓶。典型的倾斜角度在15度至45度之间,极端情况下甚至可能达到60度。这种倾斜带来两个严重问题:

  • 透视畸变:药瓶在图像中呈现梯形而非矩形,液面线从水平变为倾斜,且近端与远端的宽度差异导致液位比例计算出现偏差。具体而言,当倾斜角度为30度时,药瓶上下边缘的宽度比约为1:1.7,这意味着如果不进行透视校正,直接测量液位位置将产生约15%-25%的误差。
  • 遮挡问题:倾斜视角下,药瓶近端可能遮挡部分刻度标记,远端的刻度则因透视压缩而变得模糊不可读。输液管、床架、输液架等也可能在不同角度下遮挡药瓶的关键区域。
1.1.3 药瓶材质反光

输液瓶的材质多样且反光特性各异,这是视觉识别中最棘手的物理因素之一:

  • 玻璃瓶:传统玻璃输液瓶具有极强的镜面反射特性。在典型病房光照下,玻璃表面可形成面积占瓶面20%-40%的高光区域,高光区域内的像素饱和度极高,完全丧失液体颜色信息。此外,玻璃的折射特性使瓶内液体产生视觉位移,液面线在图像中的位置与实际位置存在2-5mm的偏移。
  • PVC/非PVC软袋:软袋表面虽然反光较弱,但表面褶皱会产生大量不规则的小高光点和阴影区域,形成类似噪声的纹理。此外,软袋在输液过程中形状不断变化(从饱满到瘪缩),液面形态从平直变为弧形,甚至出现不规则的液面弯曲。
  • 多层共挤膜瓶:这种新型材质兼具玻璃和软袋的部分问题——既有一定的反光性,又有表面微变形,但程度介于两者之间。
1.1.4 液体颜色差异

不同输液液体的颜色差异极大,这直接影响基于颜色差异的检测方法:

液体类型 外观颜色 RGB参考值 颜色特征
0.9%氯化钠注射液 近无色透明 (200, 205, 210) 与空气几乎无色差
5%葡萄糖注射液 近无色透明 (198, 203, 208) 与空气几乎无色差
复方氯化钠注射液 淡黄透明 (210, 205, 185) 微弱色差
左氧氟沙星注射液 淡黄绿色 (195, 210, 170) 可辨识色差
丹参注射液 棕红色 (140, 80, 60) 明显色差
氨基酸注射液 淡黄色 (215, 200, 160) 微弱色差
脂肪乳注射液 乳白色 (230, 225, 215) 与空气有差异但边界模糊

从上表可以清晰看出:临床最常用的0.9%氯化钠和5%葡萄糖注射液,其液体与空气之间几乎不存在颜色差异。这意味着任何基于"液体与空气颜色不同"这一假设的检测方案,在面对最常见液体时将从根本上失效。

1.2 传统颜色分界线检测方案

1.2.1 原理

传统颜色分界线检测方案的核心思想是:液体与瓶内空气之间存在颜色差异,通过检测这种差异的分界线来确定液面位置。具体步骤如下:

  1. 颜色空间转换:将摄像头采集的RGB图像转换到HSV或LAB颜色空间,以便更好地分离亮度与色度信息。
  2. 色彩阈值分割:基于预设的液体颜色范围,对图像进行阈值分割,将液体区域与空气区域分为两类。
  3. 分界线提取:在分割后的二值图像中,寻找液体区域与空气区域的交界线,该交界线即为液面线。
  4. 液位计算:根据液面线在药瓶中的相对位置,结合药瓶的已知高度,计算液位百分比。

该方法在理想条件下(有色液体、正对拍摄、均匀光照、玻璃瓶无反光)可以获得较好的检测效果,文献报道的精度可达正负5%。

1.2.2 局限性

然而,在真实临床环境中,颜色分界线检测方案面临以下致命局限:

局限性一:透明液体无颜色差异

这是最根本的问题。0.9%氯化钠注射液和5%葡萄糖注射液是中国临床使用量最大的两种输液,占所有输液量的60%以上。这两种液体几乎完全透明,液体与空气之间不存在可检测的颜色差异。在图像中,液面以下的透明液体区域与液面以上的空气区域在像素值上几乎完全一致,颜色阈值分割无法区分两者。

尝试通过边缘检测来寻找液面也面临困难:透明液体的液面在视觉上表现为一条极细的折射光线,宽度通常只有1-3个像素,且在光线不理想时完全不可见。这远低于一般边缘检测算法(如Canny算子)的可靠检测阈值。

局限性二:反光干扰

药瓶表面的镜面反射高光是颜色检测方案的严重干扰源。高光区域内的像素值接近白色(255, 255, 255),与任何液体颜色都不匹配,因此会被误分类为"空气区域"。当高光区域恰好出现在液面附近时,分割结果会出现液面线断裂、偏移甚至完全消失的情况。

更棘手的是,高光区域的形状和位置随拍摄角度和光源位置的变化而剧烈变化,无法通过简单的预处理(如高光检测与去除)来可靠消除。现有的高光去除算法(如基于镜面反射分量的分解方法)在处理圆柱形玻璃瓶表面时效果有限,因为瓶面的曲率使高光呈现复杂的带状分布。

局限性三:光线变化

病房光线变化导致的曝光和白平衡偏移,使预设的颜色阈值迅速失效。一个在日光灯下训练的液体颜色模型,在暖色灯光下可能将空气区域误判为液体(因为色温偏暖使空气区域的像素偏向液体的颜色空间),反之亦然。

自适应阈值方法虽然可以在一定程度上缓解这一问题,但其收敛速度慢、参数调优困难,在光线剧烈变化的场景(如开关灯瞬间)中仍会出现严重的误检。

1.3 为什么IVGuard不选颜色方案:临床可靠度不足

综合以上分析,IVGuard项目团队在技术选型阶段明确否决了颜色分界线检测方案,核心理由是临床可靠度不足

医疗安全场景对检测可靠度的要求远高于一般应用。一个液位监控系统的误检后果可能包括:

  • 假阳性(误报液位过低):导致护士不必要的巡查,增加工作负担,长期可能产生"狼来了"效应,降低对真实警报的响应率。
  • 假阴性(漏报液位过低):更危险——输液完成后的空滴可能导致空气栓塞、回血等严重并发症。文献报道,输液完成后未及时处理的发生率约为1.2%-3.5%,其中约0.1%可能引发严重后果。

颜色分界线方案在透明液体(占60%+使用量)上从根本上无法工作,在有色液体上也因反光和光线问题导致误检率不可接受。这意味着即使方案在实验室条件下表现良好,在真实临床环境中的可靠度也远未达到医疗安全所需的水平。

因此,IVGuard选择了一条完全不同的技术路线——贴纸刻度读取。这一方案的核心思想是:不依赖液体与空气的视觉差异,而是在药瓶表面贴附带有已知标记的专用贴纸,通过读取贴纸上的刻度来确定液位。由于贴纸标记的视觉特征(ArUco Marker)由设计确定、不受液体颜色影响、且可以通过透视校正消除角度误差,这一方案从根本上解决了颜色分界线检测方案的所有核心局限。


2. IVGuard创新方案:贴纸刻度读取

2.1 专用贴纸设计

IVGuard贴纸刻度读取方案的物理基础是一张精心设计的专用贴纸。这张贴纸是连接物理世界与数字识别的桥梁,其设计直接决定了算法的可靠度和精度。

2.1.1 贴纸整体布局

贴纸尺寸约为8cm x 15cm,这一尺寸经过精心计算,适配标准输液瓶(500ml玻璃瓶和250ml软袋)的贴附区域。贴纸的纵向方向与药瓶的轴线平行,横向方向绕药瓶表面约1/3周长,确保从任意正对角度拍摄时至少能看到贴纸的大部分区域。

贴纸从上到下分为三个功能区域:

+--------------------------------------------+
|  +-----+                        +-----+   |  <- 上部区域
|  |ArUco|                        |ArUco|   |     四角定位标记
|  |  0  |                        |  1  |   |
|  +-----+                        +-----+   |
|                                            |
|  --- 100% -----------------------------    |  <- 中部区域
|  ---  90% -----------------------------    |     液位刻度线
|  ---  80% -----------------------------    |     0%~100%
|  ---  70% -----------------------------    |
|  ---  60% -----------------------------    |
|  ---  50% -----------------------------    |
|  ---  40% -----------------------------    |
|  ---  30% -----------------------------    |
|  ---  20% -----------------------------    |
|  ---  10% -----------------------------    |
|  ---   0% -----------------------------    |
|                                            |
|  +-----+                        +-----+   |  <- 下部区域
|  |ArUco|    [NFC Chip Area]     |ArUco|   |     四角定位标记
|  |  2  |                        |  3  |   |     + NFC芯片
|  +-----+                        +-----+   |
|                                            |
|  ----------------------------------------  |
|  Drug: 0.9% NaCl  |  Vol: 500ml  |  ID: 7 |  <- 底部信息栏
+--------------------------------------------+
2.1.2 四角定位标记(ArUco Marker)

贴纸的四个角各放置一个ArUco Marker(按逆时针编号0-3),这是整个识别方案的关键。ArUco Marker是一种基于二进制编码的方形fiducial marker(基准标记),由OpenCV库原生支持检测。

每个ArUco Marker的特征:

  • 尺寸:2cm x 2cm(在8cm宽贴纸上占比25%,确保远距离可检测)
  • 字典:使用DICT_4X4_50预定义字典,即4x4的内部比特网格,50个可用ID
  • 编码:四个角的Marker ID分别为0、1、2、3(或使用连续ID以区分不同药瓶)
  • 边框:1比特宽的黑色边框,用于与背景分离

为什么选择ArUco而非其他Marker?

候选方案 优点 缺点 适用性
ArUco Marker OpenCV原生支持、检测速度快、ID编码自带校验 小尺寸时抗旋转能力弱
AprilTag 角点精度更高、抗遮挡能力更强 需要额外库、计算开销大
QR Code 信息容量大、普及度高 角点精度低、检测慢
自定义圆形标记 设计灵活 需自研检测算法、无现成优化

IVGuard选择ArUco Marker的核心原因是:OpenCV原生支持意味着未来NAPI桥接时无需引入额外的第三方库,且ArUco的检测速度(单帧小于5ms on ARM)满足实时性要求。4x4_50字典提供50个可用ID,足以区分同一病房内的所有输液瓶。

四角ArUco Marker的核心功能是确定贴纸的位置和透视角度。当摄像头从任意角度拍摄贴纸时,四个ArUco Marker各自被检测到4个角点,共计16个角点。但这16个角点中,最关键的是贴纸四角的4个外角点——它们定义了贴纸在图像中的四边形轮廓,也是后续透视变换的输入。

ArUco 0的4个角点: c0_0, c0_1, c0_2, c0_3
ArUco 1的4个角点: c1_0, c1_1, c1_2, c1_3
ArUco 2的4个角点: c2_0, c2_1, c2_2, c2_3
ArUco 3的4个角点: c3_0, c3_1, c3_2, c3_3

贴纸四角点提取规则:
  左上角 = c0_0 (ArUco 0的左上角点)
  右上角 = c1_1 (ArUco 1的右上角点)
  右下角 = c3_2 (ArUco 3的右下角点)
  左下角 = c2_3 (ArUco 2的左下角点)

这种四角点提取规则确保了即使ArUco Marker的内部角点因遮挡或反光而丢失,只要外角点被正确检测,透视变换仍然可以进行。

2.1.3 中间刻度线

贴纸中部是液位刻度线区域,从0%到100%以10%为间隔标注刻度线,共11条水平线。每条刻度线包含:

  • 水平参考线:宽度覆盖贴纸全宽的80%,线宽0.5mm,深黑色(反光率小于5%)
  • 百分比标注:在参考线右端标注百分比数值,字体高度约3mm
  • 5%细分刻度:在每两条主刻度线之间添加一条较短的细分刻度线,提供5%的分辨率

刻度线的深黑色设计确保了在透视校正后的图像中,即使经过液面覆盖,刻度线与贴纸白色基底之间仍保持足够的对比度。实验表明,黑色线条在白色基底上的对比度(Michelson对比度)超过0.9,远高于液体与空气之间0.05-0.3的对比度。

刻度线的另一个重要作用是提供液位读取的绝对参考。在透视校正后的图像中,0%刻度线和100%刻度线定义了液位的全量程范围,中间刻度线提供了校准点。即使因贴纸贴附位置偏差导致刻度线与实际液面的对应关系有偏移,通过已知刻度线位置进行插值校正,仍然可以获得准确的液位读数。

2.1.4 NFC芯片

贴纸下部嵌入一枚NFC无源标签芯片(NTAG213或兼容型号),存储容量144字节,可写入以下信息:

  • 贴纸ID(stickerId):8字节,唯一标识此贴纸
  • 药物ID(drugDbId):16字节,关联药品数据库
  • 药物名称:32字节,如"0.9%氯化钠注射液"
  • 容量:4字节,如"500ml"
  • 有效期:8字节,贴纸有效期(建议6个月)
  • CRC校验:4字节,确保数据完整性
  • 保留:72字节,未来扩展

NFC芯片的作用是提供无需视觉识别即可获取药物信息的快速通道。护士可以用支持NFC的手机贴近贴纸,瞬间读取药物信息,无需对准摄像头。这在光线极差或贴纸被部分遮挡的紧急情况下尤为重要。

在IVGuard的数据模型中,Medicine类同时包含stickerIdnfcTagId字段(参见DataModels.ets),分别对应贴纸的视觉标记ID和NFC标签ID,两个ID在贴纸生产时即绑定。

2.1.5 贴纸尺寸与适配

贴纸尺寸8cm x 15cm的设计考量:

  • 宽度8cm:标准500ml玻璃瓶的直径约6.5cm,周长约20.4cm。8cm宽度约占周长的39%,确保从正面拍摄时贴纸完整可见。对于250ml软袋(宽度约8-9cm),贴纸可以横贴覆盖近全宽。
  • 高度15cm:标准500ml输液瓶的有效液位高度约12-14cm。15cm高度覆盖了从瓶口到瓶底的完整范围,预留了顶部和底部各约0.5cm的非刻度区域用于定位。
  • 圆角处理:贴纸四角采用2mm圆角,防止翘边,同时不影响ArUco Marker的检测(Marker距圆角有足够距离)。

贴纸材质选用医用级哑光PET不干胶,表面粗糙度Ra约1.5um,有效抑制镜面反射。粘合剂为医用级丙烯酸压敏胶,对玻璃和PVC表面均有良好粘附力,且可无残胶撕除。

2.2 三步识别流程

IVGuard的贴纸刻度读取方案采用清晰的三步识别流程,每一步都有明确的输入输出和错误处理机制:

原始帧图像
    |
    v
+---------------------------+
| Step 1: 标记检测          |  输入:原始帧
| (ArUco角点检测)           |  输出:4个Marker x 4角点 = 16角点
|                           |       + 4个Marker ID
+------------+--------------+
             |
             v
+---------------------------+
| Step 2: 透视校正          |  输入:16角点 -> 提取4个外角点
| (Perspective Correction)  |  输出:3x3透视变换矩阵M
|                           |       + 校正后图像
+------------+--------------+
             |
             v
+---------------------------+
| Step 3: 刻度读取          |  输入:校正后图像
| (Level Reading)           |  输出:液位百分比(0-100)
|                           |       + 置信度(0-1)
+---------------------------+
2.2.1 步骤一:标记检测(Marker Detection)

标记检测是整个流程的起点,使用OpenCV的detectMarkers函数检测图像中的所有ArUco Marker:

cv::Ptr<cv::aruco::Dictionary> dictionary =
    cv::aruco::getPredefinedDictionary(cv::aruco::DICT_4X4_50);
cv::Ptr<cv::aruco::DetectorParameters> parameters =
    cv::aruco::DetectorParameters::create();

std::vector<std::vector<cv::Point2f>> markerCorners;
std::vector<int> markerIds;

cv::aruco::detectMarkers(
    inputImage,
    dictionary,
    markerCorners,
    markerIds,
    parameters
);

检测结果中,markerCorners包含每个检测到的Marker的4个角点坐标(按逆时针顺序),markerIds包含对应的Marker ID。对于IVGuard贴纸,期望检测到ID为0、1、2、3的四个Marker。

容错策略:如果只检测到3个Marker(一个被遮挡),可以使用已知的贴纸尺寸比例和已检测的3个角点来推算第4个角点。如果只检测到2个或更少的Marker,则判定此次检测失败,等待下一帧。

角点亚像素精确化:检测到的角点精度直接影响后续透视校正的质量。在标记检测后,对每个角点进行亚像素级别的精确化:

cv::cornerSubPix(
    grayImage,
    markerCorners[i],
    cv::Size(5, 5),
    cv::Size(-1, -1),
    cv::TermCriteria(cv::TermCriteria::EPS + cv::TermCriteria::MAX_ITER, 30, 0.01)
);

亚像素精确化可将角点定位精度从约1像素提高到约0.1像素,对于液位精度而言,这意味着从约正负5%提升到约正负2%。

2.2.2 步骤二:透视校正(Perspective Correction)

透视校正是将倾斜拍摄的贴纸图像校正为正对视角的标准矩形图像,消除透视畸变对刻度读取的影响。

cv::Point2f srcPoints[4] = {
    markerCorners[0][0],  // 左上角
    markerCorners[1][1],  // 右上角
    markerCorners[3][2],  // 右下角
    markerCorners[2][3]   // 左下角
};

const int CORRECTED_WIDTH = 400;
const int CORRECTED_HEIGHT = 750;
cv::Point2f dstPoints[4] = {
    cv::Point2f(0, 0),
    cv::Point2f(CORRECTED_WIDTH, 0),
    cv::Point2f(CORRECTED_WIDTH, CORRECTED_HEIGHT),
    cv::Point2f(0, CORRECTED_HEIGHT)
};

cv::Mat M = cv::getPerspectiveTransform(srcPoints, dstPoints);

cv::Mat correctedImage;
cv::warpPerspective(inputImage, correctedImage, M,
    cv::Size(CORRECTED_WIDTH, CORRECTED_HEIGHT));

透视变换矩阵M是一个3x3矩阵,其形式为:

    | m00  m01  m02 |
M = | m10  m11  m12 |
    | m20  m21  m22 |

其中,变换后的点(x’, y’)与原始点(x, y)的关系为:

x' = (m00*x + m01*y + m02) / (m20*x + m21*y + m22)
y' = (m10*x + m11*y + m12) / (m20*x + m21*y + m22)

这个变换可以精确地消除透视畸变,将梯形贴纸校正为矩形。校正后的图像中,刻度线恢复为水平线,液面线也恢复为水平线,极大地简化了后续的刻度读取。

2.2.3 步骤三:刻度读取(Level Reading)

在透视校正后的图像中,贴纸被校正为标准矩形,刻度线恢复为水平线。此时,刻度读取的核心任务是确定液面在校正后图像中的纵向位置,并将其映射到0%-100%的液位范围。

刻度读取的具体算法将在第7章详细讨论,这里仅概述其核心思路:

  1. 确定刻度范围:在校正后的图像中,100%刻度线的纵坐标为y_top,0%刻度线的纵坐标为y_bottom。
  2. 检测液面线:在校正后图像的刻度区域中,检测液面的水平线位置y_liquid。
  3. 计算液位百分比level = (y_bottom - y_liquid) / (y_bottom - y_top) * 100
  4. 刻度校准:利用已知的10%间隔刻度线位置进行插值校准,消除贴纸贴附偏差。

2.3 多标记支持

ArUco Marker的ID编码天然支持多标记区分。DICT_4X4_50字典提供50个可用ID,而每个贴纸使用4个ID,因此最多可以同时区分12个不同的贴纸(12 x 4 = 48 小于 50)。

在实际临床场景中,同一病房内可能同时进行多瓶输液。每个输液瓶上贴附一张贴纸,其四个ArUco Marker使用连续的ID段(如贴纸1使用ID 0-3,贴纸2使用ID 4-7,以此类推)。通过Marker ID的前缀即可区分不同贴纸,实现多药瓶的同时监控。

在IVGuard的数据模型中,Medicine.stickerId字段存储贴纸标识(如"sticker_001"),而VisionResult中的markerId字段与此对应,使得识别结果可以精确关联到具体的药物和患者。


3. VisionResult数据结构

3.1 接口定义

VisionResult是IVGuard视觉识别系统的核心数据结构,承载了单次检测的完整结果。其定义位于VisionService.ets第3-8行:

export interface VisionResult {
  markerId: string    // 贴纸标记ID
  level: number       // 识别到的液位百分比
  confidence: number  // 置信度0-1
  corners: number[]   // 角点坐标[x1,y1,x2,y2,x3,y3,x4,y4]
}

3.2 字段详解

3.2.1 markerId: string

贴纸标记ID,是连接视觉识别结果与业务数据模型的桥梁。

  • 取值范围:格式为"sticker_XXX",XXX为三位数字编号
  • MVP默认值"sticker_001"
  • 关联关系:与Medicine.stickerId字段对应,通过DataStore可以查询到关联的药物信息和患者信息
  • 多标记场景:当画面中检测到多个贴纸时,每个贴纸生成一个VisionResult,各自携带不同的markerId

markerId的设计选择string而非number,是为了未来兼容UUID格式的贴纸标识,以及与NFC标签ID的统一表示。

3.2.2 level: number

识别到的液位百分比,是VisionResult中最重要的业务数据。

  • 取值范围:0-100(整数,无小数)
  • 精度:MVP阶段为整数百分比,正式版目标精度为正负2%
  • 衰减行为:MVP阶段通过mockDecay模拟,起始值85%,每3秒衰减1-2%
  • 特殊值
    • level = 0:输液完成,触发完成警报
    • level <= 20(可配置):触发低液位预警
    • level = 100:输液刚开始(理论值,实际监控通常从较低值开始)

level字段的值在校正后的图像中通过刻度读取算法计算得到。在MVP阶段,该值由模拟衰减模型生成,不经过实际的图像处理。

3.2.3 confidence: number

置信度,反映识别结果的可靠程度。

  • 取值范围:0.0-1.0(浮点数)
  • MVP默认值:0.92
  • 计算方式(正式版):
    • 角点检测置信度:基于ArUco Marker的边缘清晰度和角点定位精度
    • 刻度线检测置信度:基于液面线与最近刻度线的一致性
    • 综合加权:confidence = 0.6 * cornerConf + 0.4 * levelConf
  • 阈值处理
    • confidence >= 0.8:结果可靠,正常使用
    • 0.5 <= confidence < 0.8:结果低可信,UI显示警告标志
    • confidence < 0.5:结果不可信,丢弃并等待下一帧

confidence字段的设计使上层逻辑可以根据识别结果的可靠程度做出差异化决策,避免将低质量识别结果与高置信度结果同等对待。

3.2.4 corners: number[]

角点坐标数组,存储贴纸四角的图像坐标。

  • 数组长度:固定8个元素(4个点 x 2个坐标)
  • 排列顺序[x1, y1, x2, y2, x3, y3, x4, y4]
    • (x1, y1):左上角点
    • (x2, y2):右上角点
    • (x3, y3):右下角点
    • (x4, y4):左下角点
  • MVP默认值[100, 50, 300, 50, 300, 400, 100, 400]

角点坐标在MonitorOverlay组件中用于渲染检测框。在MVP阶段,角点坐标使用固定值模拟一个位于图像中偏左位置的矩形框(宽200px、高350px)。在正式版中,角点坐标由ArUco角点检测提供,将随贴纸在画面中的实际位置动态变化。

角点坐标的另一个用途是计算透视变换矩阵(第6章详述)。4个角点定义了贴纸在图像中的四边形轮廓,是透视校正的输入参数。

3.3 数据流图

VisionResult在IVGuard系统中的数据流如下:

CameraService.getMockFrame()
        |
        v
   原始帧(frame: number)
        |
        v
VisionService.detect(frame)  ------->  VisionResult[]
        |                                |
        |                                +-- markerId -> MonitorPage.activeMarkerId
        |                                +-- level -> MonitorPage.currentLevel
        |                                +-- confidence -> UI显示/阈值判断
        |                                +-- corners -> MonitorOverlay渲染
        |
        v
MonitorPage.checkAlert()
   +-- level <= threshold -> NotificationService.sendLevelAlert()
   +-- level <= 0 -> SpeechService.playCompleteAlert()

3.4 与其他数据模型的关联

VisionResult不是一个孤立的数据结构,它与IVGuard的其他数据模型紧密关联:

// VisionResult.markerId <-> Medicine.stickerId
const medicine = medicines.find(m => m.stickerId === visionResult.markerId)

// VisionResult.level -> MonitorSession.currentLevel
session.currentLevel = visionResult.level

// VisionResult -> LevelRecord
const record = LevelRecord.create(session.id, Date.now(), visionResult.level, flowRate)

// VisionResult.level -> AlertEvent
if (visionResult.level <= settings.warningThreshold) {
  const alert = AlertEvent.create(session.id, patientName, 'low_level', 'warning', '...')
}

这些关联关系构成了IVGuard从视觉识别到业务决策的完整数据链路。VisionResult作为这条链路的关键节点,承载了从底层视觉算法到上层业务逻辑的信息传递。理解VisionResult的含义和用法,是理解整个IVGuard监控流程的基础。


4. VisionService架构设计

4.1 设计理念

VisionService是IVGuard视觉识别系统的核心服务类,负责管理检测生命周期、执行识别算法、返回识别结果。其设计遵循以下原则:

  1. 静态类单例模式:VisionService不需要实例化,所有方法均为static,确保全局唯一的检测状态
  2. 生命周期管理:检测过程具有明确的开始(startDetection)和结束(stopDetection),避免资源泄漏
  3. 关注点分离:VisionService只负责视觉识别,不直接涉及UI渲染或数据持久化
  4. Mock友好:MVP阶段使用模拟数据,不依赖真实的摄像头帧处理

4.2 完整源码解析

VisionService的完整实现仅75行代码,精简而功能完备。以下逐段解析:

导入与接口定义(第1-8行)

import { hilog } from '@kit.PerformanceAnalysisKit'

export interface VisionResult {
  markerId: string
  level: number
  confidence: number
  corners: number[]
}

hilog是HarmonyOS提供的日志工具,VisionService使用它记录检测的启动和停止事件。0x0000是日志域(domain),'IVGuard'是日志标签,便于在hilog输出中过滤和定位。

VisionResult接口与VisionService定义在同一文件中,体现了高内聚的设计——数据结构和操作该数据结构的服务紧密关联。

状态变量(第10-13行)

export class VisionService {
  private static isActive: boolean = false
  private static mockLevel: number = 85
  private static mockDecayInterval: number = -1

三个private static变量构成了VisionService的全部内部状态:

变量 类型 初始值 说明
isActive boolean false 检测是否处于活跃状态
mockLevel number 85 当前模拟液位百分比
mockDecayInterval number -1 衰减定时器ID,-1表示无定时器

isActive:控制检测的开关状态。detect()方法在isActive为false时直接返回空数组,避免在检测未启动时产生无效结果。该状态同时被isActiveDetection()方法暴露给外部,供UI层查询。

mockLevel:MVP阶段的核心数据,模拟当前液位百分比。初始值为85,通过mockDecay()方法持续衰减,模拟输液过程。该值可被getMockLevel()读取和setMockLevel()设置,为调试和演示提供了便利。

mockDecayIntervalsetInterval返回的定时器ID。初始值-1表示无定时器运行。使用-1而非0作为哨兵值,是因为-1确保了与任何合法定时器ID的不重叠。

startDetection方法(第15-24行)

static startDetection(): void {
  VisionService.isActive = true
  VisionService.mockLevel = 85
  hilog.info(0x0000, 'IVGuard', 'Vision detection started')
  if (VisionService.mockDecayInterval === -1) {
    VisionService.mockDecayInterval = setInterval(() => {
      VisionService.mockDecay()
    }, 3000)
  }
}

启动检测流程,包含四个操作:

  1. 设置活跃状态为true
  2. 重置模拟液位为85%
  3. 记录启动日志
  4. 以3000ms间隔启动mockDecay定时器(带防重复启动保护)

stopDetection方法(第26-33行)

static stopDetection(): void {
  VisionService.isActive = false
  if (VisionService.mockDecayInterval !== -1) {
    clearInterval(VisionService.mockDecayInterval)
    VisionService.mockDecayInterval = -1
  }
  hilog.info(0x0000, 'IVGuard', 'Vision detection stopped')
}

停止检测流程,包含三个操作:

  1. 取消活跃状态
  2. 清除衰减定时器并重置ID为-1
  3. 记录停止日志

detect方法(第35-48行)

static detect(frame: number): VisionResult[] {
  if (!VisionService.isActive) {
    return []
  }
  const results: VisionResult[] = []
  const primary: VisionResult = {
    markerId: 'sticker_001',
    level: VisionService.mockLevel,
    confidence: 0.92,
    corners: [100, 50, 300, 50, 300, 400, 100, 400]
  }
  results.push(primary)
  return results
}

执行一次检测,返回VisionResult数组。MVP阶段始终返回包含一个元素的数组(主标记sticker_001)。frame参数为接口占位,不参与实际计算。

辅助方法(第50-74行)

static isActiveDetection(): boolean {
  return VisionService.isActive
}

static getMockLevel(): number {
  return VisionService.mockLevel
}

static setMockLevel(level: number): void {
  VisionService.mockLevel = level
}

private static mockDecay(): void {
  if (VisionService.mockLevel > 0) {
    const decay = 1 + Math.floor(Math.random() * 2)
    VisionService.mockLevel = VisionService.mockLevel - decay
    if (VisionService.mockLevel < 0) {
      VisionService.mockLevel = 0
    }
  }
}

static resetMock(): void {
  VisionService.mockLevel = 85
}

4.3 检测生命周期

VisionService的检测生命周期由三个核心方法管理:

                     startDetection()
  [未启动] -------------------------> [检测中]
                   isActive = true
                   mockLevel = 85
                   启动mockDecay定时器
                     |
                     | detect() 可用
                     | mockLevel 持续衰减
                     |
                     | stopDetection()
                     +-----------------> [已停止]
                       isActive = false
                       清除mockDecay定时器
                       mockDecayInterval = -1

startDetection()的调用时机

  • MonitorPage的aboutToAppear生命周期(页面出现时)
  • 用户点击"继续"按钮(恢复暂停的监控)

stopDetection()的调用时机

  • MonitorPage的aboutToDisappear生命周期(页面销毁时)
  • 用户点击"暂停"或"结束监控"按钮
  • checkAlert()检测到液位为0时(输液完成)
  • 应用切到后台时(节省资源)

4.4 辅助方法详解

4.4.1 isActiveDetection(): boolean

查询当前检测状态,返回isActive的值。此方法供MonitorPage和WatchService等外部模块查询检测状态,无需直接访问private变量。

典型使用场景:

  • MonitorPage在UI上显示"监控中"/"已暂停"状态
  • WatchService在手表端同步检测状态
  • SettingsPage在灵敏度调整时确认检测是否运行
4.4.2 getMockLevel(): number

获取当前模拟液位值。此方法与detect()的区别在于:getMockLevel()直接返回内部状态变量,不构造VisionResult对象,开销更小。MonitorPage的3秒轮询定时器优先使用getMockLevel()来更新液位显示,而detect()则用于获取完整的识别结果(包括markerId和corners)。

4.4.3 setMockLevel(level: number): void

手动设置模拟液位值,主要用于:

  • 调试:开发时快速设置液位到特定值,测试预警逻辑
  • 演示:产品演示时展示不同液位下的UI效果
  • 测试:单元测试中设置初始液位,验证衰减和预警行为
4.4.4 resetMock(): void

将模拟液位重置为85%。与setMockLevel(85)等价,但语义更清晰——强调"重置到初始状态"而非"设置为特定值"。

4.5 正式版预留的扩展点

当前75行的MVP实现中,有多处为正式版预留的扩展点:

  1. detect(frame)的frame参数:正式版将接收实际的摄像头帧数据(可能为PixelMap或ArrayBuffer),传给NAPI层的OpenCV处理
  2. 多标记支持:detect()的返回值已设计为数组,可自然扩展到多标记场景
  3. NAPI桥接调用点:detect()内部当前直接构造mock结果,正式版将替换为NAPI调用
  4. 异步处理:正式版的detect可能需要改为async(因为NAPI调用和图像处理耗时),返回Promise<VisionResult[]>
  5. 错误处理:正式版需要处理OpenCV调用失败、内存不足、NAPI异常等情况

4.6 与其他Service的协作关系

VisionService并非孤立运作,它与IVGuard的其他Service形成协作网络:

                    CameraService
                    (提供帧数据)
                         |
                         v
                    VisionService
                    (视觉识别)
                    /    |    \
                   v     v     v
          MonitorPage  DataStore  NotificationService
          (UI展示)    (持久化)   (预警通知)
                                   |
                                   v
                              SpeechService
                              (语音提醒)
  • CameraService:提供摄像头帧数据。当前CameraService.getMockFrame()返回随机数模拟帧,正式版将提供真实的摄像头预览帧buffer。
  • DataStore:持久化识别结果。VisionService不直接调用DataStore,而是通过MonitorPage中转,保持关注点分离。
  • NotificationService:在液位过低时发送通知。触发逻辑在MonitorPage的checkAlert()中,基于VisionService返回的level值。
  • SpeechService:语音提醒。当预警触发且用户开启了语音提醒时,播放低液位或输液完成语音。

5. MVP模拟算法详解

5.1 液位衰减模型

MVP阶段的液位数据由模拟衰减模型生成,不依赖实际的摄像头和图像处理。该模型的目标是以最简单的逻辑模拟一个真实输液过程的液位变化趋势。

5.1.1 衰减参数
参数 说明
起始液位 85% 模拟输液中途开始监控
衰减频率 3000ms (3秒) 每次衰减的时间间隔
衰减量 1-2% 随机:1 + Math.floor(Math.random() * 2)
边界保护 0 液位不低于0%
5.1.2 衰减公式

衰减的核心公式极其简洁:

const decay = 1 + Math.floor(Math.random() * 2)
mockLevel = mockLevel - decay
if (mockLevel < 0) {
  mockLevel = 0
}

衰减量的计算

Math.random()产生[0, 1)之间的均匀随机数,Math.random() * 2产生[0, 2)之间的随机数,Math.floor(Math.random() * 2)产生0或1的等概率整数,加上1后得到1或2的等概率整数。

这意味着每次衰减量为1%或2%,概率各50%。这种随机性模拟了实际输液过程中流速的微小波动——输液管内径的微小变化、患者体位调整导致的压力变化、气泡通过等都会使流速出现短暂的加速或减速。

衰减速率的物理意义

平均衰减速率为1.5%/3秒 = 0.5%/秒。以500ml标准输液瓶为例:

  • 100%液位约等于500ml液体
  • 1%液位约等于5ml液体
  • 0.5%/秒约等于2.5ml/秒

实际输液流速通常为20-60滴/分钟,标准滴管20滴约等于1ml,因此典型流速为1-3ml/分钟,即0.017-0.05ml/秒。MVP模拟的2.5ml/秒远高于实际流速,这是为了在演示和测试中快速观察液位变化,而非物理精确模拟。

5.1.3 液位衰减时间线

从85%起始液位出发,平均每次衰减1.5%,到达0%需要约57次衰减,按3秒间隔计算约171秒(约2分51秒)。具体衰减过程模拟如下:

时间(s)   液位(%)   事件
  0        85       startDetection() - 监控开始
  3        83-84    第一次衰减
  6        81-83    第二次衰减
  9        79-82
 12        77-81
 15        75-80    里程碑:75%以下
 30        65-73
 45        55-66
 60        45-59    里程碑:50%以下
 90        25-41    接近预警阈值
105        15-30    里程碑:20%以下 - 触发预警!
120         5-17
135         0-8     可能到达0% - 输液完成
171         0       平均到达0%的时间
5.1.4 边界保护机制

边界保护if (mockLevel < 0) { mockLevel = 0 }确保液位不会降至负值。虽然在大多数衰减步骤中mockLevel - decay的结果不会为负(因为mockDecay()中有前置条件if (mockLevel > 0)),但边界保护提供了双重保险:

  • 前置条件if (mockLevel > 0)检查防止在液位已为0时继续衰减
  • 后置保护if (mockLevel < 0) { mockLevel = 0 }防止衰减后的结果为负值

这种双重保护策略在医疗相关的数值计算中是良好的实践——即使逻辑上后置保护在当前实现中可能是冗余的,但它为未来代码修改(如修改前置条件或衰减公式)提供了安全网。

考虑一种可能的场景:未来修改衰减公式为decay = 1 + Math.floor(Math.random() * 3)(衰减量1-3%),当mockLevel为1%时,decay可能为3%,导致1 - 3 = -2。此时后置保护if (mockLevel < 0) { mockLevel = 0 }将正确地将其钳位到0%,避免出现负液位这种在医疗场景中毫无意义的值。

5.2 置信度模拟

MVP阶段置信度固定为0.92,不随检测条件变化。这一选择基于以下考量:

  • 0.92的含义:92%的置信度在医疗场景中属于"可信但需确认"的范围——高于0.8的"可靠"阈值,但不是1.0的"绝对确定"。这为UI层展示置信度信息提供了有意义的参考值。
  • 不设为1.0的原因:完美置信度1.0在实际检测中不可能出现,设为0.92更真实,也提醒开发者置信度是一个需要关注的指标。
  • 不随时间变化的原因:MVP阶段不模拟检测条件的变化(如光线变暗、遮挡增加等),因此置信度保持恒定。

正式版的置信度将根据以下因素动态计算:

  • ArUco Marker的边缘清晰度(受光线和对焦影响)
  • 角点检测的亚像素拟合误差
  • 液面线的连续性(受反光和气泡影响)
  • 前后帧一致性(突变导致低置信度)

5.3 角点坐标模拟

MVP阶段角点坐标固定为[100, 50, 300, 50, 300, 400, 100, 400],定义了一个矩形区域:

(100,50)-------------------------(300,50)
   |                               |
   |   模拟贴纸检测区域            |   宽度: 300-100 = 200px
   |                               |   高度: 400-50 = 350px
   |                               |
(100,400)------------------------(300,400)

这个矩形位于图像的偏左位置(x: 100-300, y: 50-400),模拟了手机拍摄输液瓶时贴纸在画面中的典型位置。矩形的宽高比200:350约为0.57,与贴纸实际宽高比80mm:150mm = 0.533接近,保持了视觉上的合理性。

MonitorOverlay组件使用这组坐标渲染检测框。在MVP阶段,检测框位置固定不变;正式版中,角点坐标将随贴纸在画面中的实际位置和角度动态更新。

角点坐标在MonitorOverlay中的具体使用方式:

// MonitorOverlay.ets 中使用corners渲染检测框
ForEach(this.results, (result: VisionResult) => {
  if (result.markerId === this.activeMarkerId) {
    Row()
      .width(200)     // 对应corners中x范围:300-100=200
      .height(350)    // 对应corners中y范围:400-50=350
      .borderWidth(2)
      .borderColor('#4CAF50')   // 激活标记:绿色边框
      .borderStyle(BorderStyle.Dashed)
      .position({ x: 100, y: 50 })  // 对应corners中左上角坐标
  } else {
    Row()
      .width(200)
      .height(350)
      .borderWidth(1)
      .borderColor('#999999')   // 非激活标记:灰色边框
      .borderStyle(BorderStyle.Dashed)
      .position({ x: 320, y: 50 })  // 偏移显示非激活标记
  }
})

5.4 为什么起始85%

起始液位设定为85%而非100%,是经过深思熟虑的设计决策,基于以下考量:

  1. 临床现实:IVGuard的典型使用场景不是从输液开始时启动监控,而是护士或家属在输液已经开始一段时间后想起"应该用手机监控一下"。此时液位通常已降至70%-90%之间,85%恰好处于这个区间的中间。

  2. 测试效率:从100%衰减到预警阈值20%需要约53次衰减(约159秒),而从85%只需约43次(约129秒)。30秒的时间节省在反复测试和演示中积少成多。

  3. 展示效果:从85%开始,用户可以看到液位从较高值逐渐下降的全过程,包括50%的"半瓶"视觉里程碑和20%的预警触发点。如果从100%开始,前15%的衰减缺乏视觉反馈的差异(99%、98%、97%在进度环上几乎看不出区别)。

  4. 数据意义:85%暗示"这不是一瓶新开始的输液,而是一瓶已经在进行中的输液",符合产品的定位——IVGuard不是替代护士配药和启动输液,而是辅助监控进行中的输液。

  5. resetMock的一致性resetMock()方法也重置为85%,而非100%,保持与startDetection()的一致性。如果用户想要模拟"从新输液开始"的场景,可以使用setMockLevel(100)手动设置。


6. OpenCV NAPI桥接设计

6.1 桥接架构

IVGuard正式版需要将OpenCV的C++图像处理能力通过Node-API(NAPI)桥接到ArkTS层。桥接架构如下图所示:

+----------------------------------------------------------+
|                     ArkTS Layer                           |
|                                                           |
|  VisionService.ets                                        |
|    detect(frame) --> napi.callNative("DetectMarkers")     |
|                    --> napi.callNative("PerspectiveCorrect")|
|                    --> napi.callNative("ReadLevel")        |
|                                                           |
+--------------------------+--------------------------------+
                           | Node-API (NAPI)
                           |
+--------------------------v--------------------------------+
|                    C++ Layer                              |
|                                                           |
|  vision_bridge.cpp                                        |
|    DetectMarkers()  --> cv::aruco::detectMarkers()        |
|    PerspectiveCorrect() --> cv::warpPerspective()         |
|    ReadLevel() --> 液面检测 + 刻度计算                    |
|                                                           |
|  依赖: libopencv_core.a, libopencv_imgproc.a,            |
|        libopencv_aruco.a, libopencv_calib3d.a            |
|                                                           |
+----------------------------------------------------------+

6.2 vision_bridge.cpp接口设计

NAPI桥接文件vision_bridge.cpp定义了三个核心函数,分别对应三步识别流程:

#include <napi/native_api.h>
#include <opencv2/opencv.hpp>
#include <opencv2/aruco.hpp>
#include <opencv2/imgproc.hpp>
#include <opencv2/calib3d.hpp>
#include <hilog/log.h>

#define LOG_TAG "IVGuard_Vision"
#define LOG_DOMAIN 0x0000

struct MarkerResult {
    int id;
    std::vector<cv::Point2f> corners;
};

struct LevelResult {
    int markerId;
    float level;
    float confidence;
    std::vector<float> corners;
};

// ======== 函数1:ArUco标记检测 ========
static napi_value DetectMarkers(napi_env env, napi_callback_info info) {
    size_t argc = 3;
    napi_value args[3];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    void* buffer = nullptr;
    size_t bufferLength = 0;
    napi_get_arraybuffer_info(env, args[0], &buffer, &bufferLength);

    int32_t width = 0, height = 0;
    napi_get_value_int32(env, args[1], &width);
    napi_get_value_int32(env, args[2], &height);

    cv::Mat frame(height, width, CV_8UC4, buffer);
    cv::Mat gray;
    cv::cvtColor(frame, gray, cv::COLOR_RGBA2GRAY);

    cv::Ptr<cv::aruco::Dictionary> dictionary =
        cv::aruco::getPredefinedDictionary(cv::aruco::DICT_4X4_50);
    cv::Ptr<cv::aruco::DetectorParameters> parameters =
        cv::aruco::DetectorParameters::create();

    std::vector<std::vector<cv::Point2f>> markerCorners;
    std::vector<int> markerIds;
    std::vector<std::vector<cv::Point2f>> rejectedCandidates;

    cv::aruco::detectMarkers(gray, dictionary, markerCorners,
        markerIds, parameters, rejectedCandidates);

    if (!markerCorners.empty()) {
        cv::TermCriteria criteria(
            cv::TermCriteria::EPS + cv::TermCriteria::MAX_ITER, 30, 0.01);
        for (size_t i = 0; i < markerCorners.size(); i++) {
            cv::cornerSubPix(gray, markerCorners[i],
                cv::Size(5, 5), cv::Size(-1, -1), criteria);
        }
    }

    napi_value resultArray;
    napi_create_array(env, &resultArray);

    for (size_t i = 0; i < markerIds.size(); i++) {
        napi_value obj;
        napi_create_object(env, &obj);

        napi_value idVal;
        std::string markerIdStr = "sticker_" +
            std::to_string(markerIds[i] / 4);
        napi_create_string_utf8(env, markerIdStr.c_str(),
            NAPI_AUTO_LENGTH, &idVal);
        napi_set_named_property(env, obj, "markerId", idVal);

        napi_value cornersArray;
        napi_create_array(env, &cornersArray);
        for (int j = 0; j < 4; j++) {
            napi_value xVal, yVal;
            napi_create_double(env, markerCorners[i][j].x, &xVal);
            napi_create_double(env, markerCorners[i][j].y, &yVal);
            napi_set_element(env, cornersArray, j * 2, xVal);
            napi_set_element(env, cornersArray, j * 2 + 1, yVal);
        }
        napi_set_named_property(env, obj, "corners", cornersArray);
        napi_set_element(env, resultArray, i, obj);
    }

    return resultArray;
}

// ======== 函数2:透视校正 ========
static napi_value PerspectiveCorrect(napi_env env, napi_callback_info info) {
    size_t argc = 2;
    napi_value args[2];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    void* buffer = nullptr;
    size_t bufferLength = 0;
    napi_get_arraybuffer_info(env, args[0], &buffer, &bufferLength);

    napi_value cornersVal = args[1];
    cv::Point2f srcPoints[4];
    for (int i = 0; i < 4; i++) {
        napi_value xVal, yVal;
        napi_get_element(env, cornersVal, i * 2, &xVal);
        napi_get_element(env, cornersVal, i * 2 + 1, &yVal);
        double x = 0, y = 0;
        napi_get_value_double(env, xVal, &x);
        napi_get_value_double(env, yVal, &y);
        srcPoints[i] = cv::Point2f(static_cast<float>(x),
                                    static_cast<float>(y));
    }

    const int W = 400, H = 750;
    cv::Point2f dstPoints[4] = {
        cv::Point2f(0, 0),
        cv::Point2f(W, 0),
        cv::Point2f(W, H),
        cv::Point2f(0, H)
    };
    cv::Mat M = cv::getPerspectiveTransform(srcPoints, dstPoints);

    cv::Mat frame(750, 400, CV_8UC4, buffer);
    cv::Mat corrected;
    cv::warpPerspective(frame, corrected, M, cv::Size(W, H));

    napi_value outputBuffer;
    void* outputData = nullptr;
    napi_create_arraybuffer(env,
        corrected.total() * corrected.elemSize(),
        &outputData, &outputBuffer);
    memcpy(outputData, corrected.data,
        corrected.total() * corrected.elemSize());

    return outputBuffer;
}

// ======== 函数3:液位读取 ========
static napi_value ReadLevel(napi_env env, napi_callback_info info) {
    size_t argc = 3;
    napi_value args[3];
    napi_get_cb_info(env, info, &argc, args, nullptr, nullptr);

    void* buffer = nullptr;
    size_t bufferLength = 0;
    napi_get_arraybuffer_info(env, args[0], &buffer, &bufferLength);

    int32_t width = 0, height = 0;
    napi_get_value_int32(env, args[1], &width);
    napi_get_value_int32(env, args[2], &height);

    cv::Mat corrected(height, width, CV_8UC4, buffer);
    cv::Mat gray;
    cv::cvtColor(corrected, gray, cv::COLOR_RGBA2GRAY);

    int y_top = height * 5 / 100;
    int y_bottom = height * 95 / 100;

    cv::Mat edges;
    cv::Canny(gray, edges, 50, 150);

    std::vector<cv::Vec2f> lines;
    cv::HoughLines(edges, lines, 1, CV_PI / 180, 80);

    float liquidY = -1;
    int horizontalCount = 0;
    for (size_t i = 0; i < lines.size(); i++) {
        float rho = lines[i][0];
        float theta = lines[i][1];
        if (theta < CV_PI / 18 || theta > CV_PI * 17 / 18) {
            float y = rho / sin(theta);
            if (y > y_top && y < y_bottom) {
                liquidY = y;
                horizontalCount++;
            }
        }
    }

    float level = 0;
    float confidence = 0;
    if (liquidY > 0) {
        level = (y_bottom - liquidY) / (y_bottom - y_top) * 100;
        confidence = 0.7f + 0.1f *
            std::min(horizontalCount, 3) / 3.0f;
        if (confidence > 1.0f) confidence = 1.0f;
    }

    napi_value result;
    napi_create_object(env, &result);

    napi_value levelVal;
    napi_create_double(env, static_cast<double>(level), &levelVal);
    napi_set_named_property(env, result, "level", levelVal);

    napi_value confVal;
    napi_create_double(env, static_cast<double>(confidence), &confVal);
    napi_set_named_property(env, result, "confidence", confVal);

    return result;
}

// ======== 模块注册 ========
EXTERN_C_START
static napi_value Init(napi_env env, napi_value exports) {
    napi_property_descriptor desc[] = {
        DECLARE_NAPI_FUNCTION("DetectMarkers", DetectMarkers),
        DECLARE_NAPI_FUNCTION("PerspectiveCorrect", PerspectiveCorrect),
        DECLARE_NAPI_FUNCTION("ReadLevel", ReadLevel),
    };
    napi_define_properties(env, exports,
        sizeof(desc) / sizeof(desc[0]), desc);
    return exports;
}
EXTERN_C_END

static napi_module nativeModule = {
    .nm_version = 1,
    .nm_flags = 0,
    .nm_filename = nullptr,
    .nm_register_func = Init,
    .nm_modname = "vision_bridge",
    .nm_priv = nullptr,
    .reserved = {0},
};

extern "C" __attribute__((constructor)) void RegisterModule(void) {
    napi_module_register(&nativeModule);
}

6.3 CMakeLists.txt配置

将OpenCV编译为HarmonyOS平台的静态库,需要精心配置CMakeLists.txt:

cmake_minimum_required(VERSION 3.16)
project(IVGuard_VisionBridge)

set(CMAKE_CXX_STANDARD 17)

# OpenCV SDK路径(预编译静态库)
set(OPENCV_SDK_DIR "${CMAKE_CURRENT_SOURCE_DIR}/libs/opencv")

# 头文件
include_directories(
    ${OPENCV_SDK_DIR}/include
    ${OPENCV_SDK_DIR}/include/opencv2
)

# NAPI头文件
include_directories(
    ${OHOS_SDK_NATIVE}/sysroot/usr/include
    ${OHOS_SDK_NATIVE}/sysroot/usr/include/napi
)

# 源文件
add_library(vision_bridge SHARED
    vision_bridge.cpp
)

# OpenCV静态库链接
target_link_libraries(vision_bridge
    ${OPENCV_SDK_DIR}/lib/arm64-v8a/libopencv_core.a
    ${OPENCV_SDK_DIR}/lib/arm64-v8a/libopencv_imgproc.a
    ${OPENCV_SDK_DIR}/lib/arm64-v8a/libopencv_aruco.a
    ${OPENCV_SDK_DIR}/lib/arm64-v8a/libopencv_calib3d.a
    ${OPENCV_SDK_DIR}/lib/arm64-v8a/libopencv_imgcodecs.a
    ${OPENCV_SDK_DIR}/lib/arm64-v8a/libopencv_highgui.a
    ${OPENCV_SDK_DIR}/lib/arm64-v8a/libopencv_flann.a
    ${OPENCV_SDK_DIR}/lib/arm64-v8a/libtegra_hal.a
    ${OPENCV_SDK_DIR}/lib/arm64-v8a/libittnotify.a
    log
    ace_napi.z
)

# ABI兼容
target_compile_options(vision_bridge PRIVATE
    -fPIC
    -Wall
    -O2
    -DNDEBUG
)

OpenCV静态库编译要点

OpenCV在HarmonyOS上的交叉编译是一个复杂的过程,涉及以下关键步骤:

  1. 交叉编译工具链:使用HarmonyOS NDK提供的Clang编译器,目标架构为arm64-v8a。CMake工具链文件需要指定CMAKE_SYSTEM_NAME为OHOS,CMAKE_C_COMPILERCMAKE_CXX_COMPILER指向NDK中的clang。

  2. OpenCV模块裁剪:完整OpenCV库超过100MB,对于IVGuard的场景只需要以下模块:

    • core:核心数据结构(Mat, Point等)
    • imgproc:图像处理(cvtColor, Canny, warpPerspective等)
    • aruco:ArUco标记检测
    • calib3d:透视变换计算
    • flann:aruco模块的依赖

    通过CMake配置-DBUILD_opencv_videoio=OFF -DBUILD_opencv_features2d=OFF等选项,可以将静态库大小控制在约15-20MB。

  3. ABI兼容:HarmonyOS的arm64-v8a ABI与标准AARCH64 Linux ABI基本兼容,但需要禁用NEON之外的SIMD指令,确保在所有HarmonyOS设备上正常运行。

  4. C++标准库:HarmonyOS NDK使用libc++作为C++标准库,OpenCV编译时需要链接到同一标准库,避免ABI不兼容问题。

6.4 NAPI调用封装

在VisionService的ArkTS层,预留了NAPI调用的封装点。当前MVP实现中detect()直接返回mock数据,正式版将替换为NAPI调用:

// 正式版VisionService.detect()的预期实现
import visionBridge from 'libvision_bridge.so'

static async detect(frame: ArrayBuffer): Promise<VisionResult[]> {
  if (!VisionService.isActive) {
    return []
  }

  try {
    // 步骤1:标记检测
    const markerData = visionBridge.DetectMarkers(
      frame,
      VisionService.frameWidth,
      VisionService.frameHeight
    )

    if (markerData.length === 0) {
      return []
    }

    // 对每个检测到的标记执行步骤2和步骤3
    const results: VisionResult[] = []
    for (let i = 0; i < markerData.length; i++) {
      const marker = markerData[i]

      // 步骤2:透视校正
      const correctedFrame = visionBridge.PerspectiveCorrect(
        frame,
        marker.corners
      )

      // 步骤3:液位读取
      const levelData = visionBridge.ReadLevel(
        correctedFrame,
        VisionService.CORRECTED_WIDTH,
        VisionService.CORRECTED_HEIGHT
      )

      const vr: VisionResult = {
        markerId: marker.markerId,
        level: Math.round(levelData.level),
        confidence: levelData.confidence,
        corners: marker.corners
      }
      results.push(vr)
    }

    return results
  } catch (e) {
    hilog.error(0x0000, 'IVGuard', `Vision detect failed: ${e}`)
    return []
  }
}

6.5 ArUco标记检测原理

6.5.1 预定义字典

OpenCV的ArUco模块提供了多种预定义字典,每种字典由两个参数定义:标记尺寸(n x n比特)和字典大小(可用ID数量)。IVGuard选择的DICT_4X4_50表示:

  • 标记尺寸:4 x 4比特。每个标记由4行4列的黑白方格组成,加上1比特宽的黑色边框,总共6 x 6的方格。
  • 字典大小:50个可用ID。每个ID对应一个唯一的4x4比特模式,且所有模式之间具有最大的汉明距离(至少3比特差异),确保了ID解码的鲁棒性。

4x4的标记尺寸在2cm x 2cm的物理尺寸下提供了足够的信息冗余。更大的尺寸(如5x5或6x6)虽然提供更多ID和更高的鲁棒性,但需要更大的物理尺寸才能可靠检测,在8cm宽的贴纸上不太现实。

6.5.2 角点亚像素精确化

ArUco检测的默认角点精度约为正负1像素。对于IVGuard的应用,这个精度不够——假设校正后图像高度750像素,1像素的角点误差可能导致约0.13%的液位误差。虽然看起来很小,但4个角点的误差会累积,在最坏情况下可能导致约0.5%的液位误差。

亚像素精确化使用cornerSubPix函数,基于灰度图像中角点附近的梯度分布,将角点位置精化到亚像素级别:

cv::cornerSubPix(
    grayImage,
    markerCorners[i],
    cv::Size(5, 5),    // 搜索窗口大小
    cv::Size(-1, -1),  // 死区大小(-1表示忽略)
    cv::TermCriteria(
        cv::TermCriteria::EPS + cv::TermCriteria::MAX_ITER,
        30,      // 最多迭代30次
        0.01     // 精度目标0.01像素
    )
);

经过亚像素精确化后,角点定位精度可提高到约0.1像素,对应的液位误差降低到约0.05%,完全满足正负2%的精度目标。

6.5.3 ID解码与验证

ArUco标记的ID解码过程包括以下步骤:

  1. 分割:将检测到的标记区域分割为n x n的方格(4 x 4 = 16个方格)
  2. 采样:在每个方格的中心采样灰度值
  3. 二值化:使用Otsu方法将采样值分为黑白两类
  4. 解码:将4x4的黑白模式与字典中的模式进行匹配
  5. 校验:计算汉明距离,确保匹配的唯一性

ID解码的自校验机制是ArUco的一个优势——即使在部分遮挡或反光情况下,只要4x4比特中有足够多的比特被正确读取(至少12/16,因为最小汉明距离为3),ID仍可被正确解码。这提供了内在的抗干扰能力。

6.6 透视变换矩阵计算

透视变换是IVGuard贴纸刻度读取的关键数学工具。其原理是将一个平面上的四边形映射到另一个平面上的矩形。

6.6.1 数学原理

给定4对对应点:src = {p1, p2, p3, p4} -> dst = {q1, q2, q3, q4},透视变换矩阵M满足:

| m00  m01  m02 |   | x |   | x' * w |
| m10  m11  m12 | * | y | = | y' * w |
| m20  m21  m22 |   | 1 |   |   w    |

其中x’ = (m00x + m01y + m02) / w,y’ = (m10x + m11y + m12) / w,w = m20x + m21y + m22。

矩阵M有8个自由度(通常设m22=1),需要4对对应点(8个方程)来唯一确定。getPerspectiveTransform函数通过求解8x8线性方程组来计算M。

6.6.2 变换质量评估

透视变换的质量直接影响刻度读取的精度。以下因素可能导致变换质量下降:

  • 角点检测误差:角点位置偏差1像素,在校正后图像中可能导致约0.5-2像素的位置误差,具体取决于透视畸变的程度。畸变越大,误差放大效应越明显。
  • 共面性假设:透视变换假设所有源点位于同一平面上。如果贴纸贴附在曲面上(如药瓶表面),贴纸实际上是一个微曲面而非平面,这会引入系统性误差。对于标准玻璃瓶(曲率半径约3.25cm),在8cm宽的贴纸范围内,曲面偏差约0.25cm,对应的像素误差约5-10像素。这一误差可以通过曲面校正来缓解,但增加了算法复杂度。
  • 数值稳定性:当透视畸变极大(如拍摄角度接近90度侧面)时,变换矩阵的条件数可能变得很大,导致数值计算不稳定。这种情况下应该直接判定检测失败,而非尝试校正。
6.6.3 反变换与验证

在校正后的图像中读取液位后,可以通过反透视变换将液面位置映射回原始图像坐标:

// 反透视变换
cv::Mat M_inv;
cv::invert(M, M_inv);

// 将校正后图像中的液面位置(y_liquid)映射回原始图像
// 取校正图像中间列的液面点
cv::Point2f correctedPoint(CORRECTED_WIDTH / 2, y_liquid);
std::vector<cv::Point2f> src = {correctedPoint};
std::vector<cv::Point2f> dst;
cv::perspectiveTransform(src, dst, M_inv);
// dst[0]即为液面在原始图像中的位置

反变换主要用于MonitorOverlay在原始摄像头预览上标注液面线的位置,实现可视化反馈。这一功能在MVP阶段尚未实现,MonitorOverlay中的液面线位置是通过简单的线性映射计算而非透视反变换。


7. 液位读取算法设计

7.1 在校正图像中检测液面

经过透视校正后的图像具有以下理想特性:

  • 贴纸区域被校正为标准矩形(400x750像素)
  • 刻度线恢复为水平线,位于已知位置
  • 液面线也恢复为水平线
  • 图像纵坐标与液位百分比呈线性关系

在这种理想条件下,液面检测转化为一个简单的水平线定位问题。

7.2 液面水平线检测算法

7.2.1 灰度转换与预处理

首先将校正后的RGBA图像转换为灰度图:

cv::Mat gray;
cv::cvtColor(corrected, gray, cv::COLOR_RGBA2GRAY);

// 高斯模糊去除噪声
cv::Mat blurred;
cv::GaussianBlur(gray, blurred, cv::Size(5, 5), 0);

高斯模糊的目的是去除贴纸表面细微的纹理噪声和打印不均匀性,同时保留液面线和刻度线这样的显著边缘。5x5的核大小在400x750的校正图像中提供了适当的模糊程度。

7.2.2 边缘检测

使用Canny算子检测图像中的边缘:

cv::Mat edges;
cv::Canny(blurred, edges, 50, 150);

Canny算子的两个阈值(50和150)经过以下考量:

  • 低阈值50:保留较弱的边缘,包括透明液体与空气之间的微弱分界线
  • 高阈值150:抑制噪声边缘,避免将贴纸纹理误检为液面线
  • 阈值比3:1:OpenCV推荐的阈值比,在边缘连接和噪声抑制之间取得平衡
7.2.3 霍夫直线检测

使用标准霍夫变换(HoughLines)检测边缘图像中的直线:

std::vector<cv::Vec2f> lines;
cv::HoughLines(edges, lines, 1, CV_PI / 180, 80);

参数说明:

  • rho = 1:距离分辨率1像素
  • theta = CV_PI / 180:角度分辨率1度
  • threshold = 80:直线投票阈值,需要至少80个边缘点在同一直线上

阈值80的选择基于:校正图像宽度400像素,刻度线宽度约320像素(80%宽度),考虑边缘检测的不完整性,设定为320 * 0.25 = 80,即要求至少25%的刻度线宽度上有连续边缘。

7.2.4 水平线筛选

在检测到的所有直线中,筛选出接近水平的线:

std::vector<float> horizontalYPositions;
for (size_t i = 0; i < lines.size(); i++) {
    float rho = lines[i][0];
    float theta = lines[i][1];
    // 水平线条件:角度接近0或PI(偏差小于10度)
    if (theta < CV_PI / 18 || theta > CV_PI * 17 / 18) {
        float y = rho / sin(theta);
        if (y > y_top && y < y_bottom) {
            horizontalYPositions.push_back(y);
        }
    }
}

10度的角度容忍度(CV_PI / 18)允许液面线有轻微的倾斜——这在实际场景中可能由贴纸贴附不完全竖直或药瓶轻微倾斜导致。

7.2.5 区分刻度线与液面线

在校正后的图像中,水平线可能来自两个来源:

  1. 刻度线:贴纸上的0%-100%刻度线,位置已知且固定
  2. 液面线:实际的液面位置,位置未知且随时间变化

区分策略:

// 已知的刻度线位置(在校正后图像中)
const float KNOWN_SCALE_Y[] = {
    37.5, 75.0, 112.5, 150.0, 187.5,  // 100%, 90%, 80%, 70%, 60%
    225.0, 262.5, 300.0, 337.5, 375.0, // 50%, 40%, 30%, 20%, 10%
    412.5                               // 0%
};

// 过滤已知刻度线
float TOLERANCE = 5.0;  // 像素容忍度
std::vector<float> candidateYPositions;
for (float y : horizontalYPositions) {
    bool isKnownScale = false;
    for (float scaleY : KNOWN_SCALE_Y) {
        if (fabs(y - scaleY) < TOLERANCE) {
            isKnownScale = true;
            break;
        }
    }
    if (!isKnownScale) {
        candidateYPositions.push_back(y);
    }
}

容忍度5像素的选择:校正后图像高度750像素对应15cm贴纸高度,5像素对应约1mm。刻度线检测位置与理论位置的偏差通常在0.5mm以内,5像素的容忍度可以覆盖所有正常情况,同时不会将距离刻度线5像素以外的液面线误判为刻度线。

7.2.6 液面线最终确定

在过滤了刻度线的候选位置中,选择最可能的液面线:

float liquidY = -1;
if (!candidateYPositions.empty()) {
    // 策略1:选择与上次检测结果最接近的候选线(时序一致性)
    if (lastLiquidY > 0) {
        float minDist = FLT_MAX;
        for (float y : candidateYPositions) {
            float dist = fabs(y - lastLiquidY);
            if (dist < minDist && dist < 50) {  // 最大移动50像素/帧
                minDist = dist;
                liquidY = y;
            }
        }
    }

    // 策略2:无历史信息时,选择置信度最高的线
    if (liquidY < 0) {
        // 基于边缘强度排序,选择最强的候选线
        // 简化实现:选择最靠近图像中间的候选线
        liquidY = candidateYPositions[0];
    }
}

时序一致性策略基于物理约束:液位在3秒采样间隔内不可能发生剧烈变化。如果候选线与上次检测结果的距离超过50像素(对应约10%液位变化),则认为该候选线不可信,可能是由反光或气泡产生的伪影。

7.3 液位百分比计算

确定液面线位置后,计算液位百分比:

// 定义刻度范围
int y_top = CORRECTED_HEIGHT * 5 / 100;     // 100%刻度线位置
int y_bottom = CORRECTED_HEIGHT * 95 / 100;  // 0%刻度线位置

if (liquidY > 0) {
    // 基本计算
    float level = (y_bottom - liquidY) / (y_bottom - y_top) * 100.0f;

    // 刻度校准
    // 利用已知的10%间隔刻度线位置进行分段线性插值
    // 消除贴纸贴附偏差和曲面效应
    float calibratedLevel = calibrateWithScaleLines(level, liquidY, KNOWN_SCALE_Y);

    // 钳位到[0, 100]范围
    if (calibratedLevel < 0) calibratedLevel = 0;
    if (calibratedLevel > 100) calibratedLevel = 100;

    return std::round(calibratedLevel);
}

刻度校准函数

float calibrateWithScaleLines(float rawLevel, float liquidY,
                               const float scalePositions[]) {
    // 在液面位置附近找到两条刻度线
    int upperScaleIdx = -1, lowerScaleIdx = -1;
    for (int i = 0; i < 10; i++) {
        if (liquidY > scalePositions[i + 1] && liquidY <= scalePositions[i]) {
            upperScaleIdx = i;      // 上方刻度线(更高液位百分比)
            lowerScaleIdx = i + 1;  // 下方刻度线(更低液位百分比)
            break;
        }
    }

    if (upperScaleIdx >= 0 && lowerScaleIdx >= 0) {
        // 分段线性插值
        float upperY = scalePositions[upperScaleIdx];  // 已知位置
        float lowerY = scalePositions[lowerScaleIdx];  // 已知位置
        float upperLevel = (10 - upperScaleIdx) * 10.0f;  // 已知液位
        float lowerLevel = (10 - lowerScaleIdx) * 10.0f;  // 已知液位

        // 在两条刻度线之间插值
        float ratio = (liquidY - upperY) / (lowerY - upperY);
        return upperLevel + ratio * (lowerLevel - upperLevel);
    }

    // 回退:使用原始计算
    return rawLevel;
}

7.4 置信度计算

置信度是识别结果可靠程度的量化指标,由以下因素综合计算:

7.4.1 角点检测置信度(cornerConf)
float calculateCornerConfidence(
    const std::vector<std::vector<cv::Point2f>>& markerCorners,
    const std::vector<int>& markerIds) {

    int detectedCount = markerIds.size();
    int expectedCount = 4;

    // 基础分:检测到的标记数量
    float countScore = static_cast<float>(detectedCount) / expectedCount;

    // 角点质量分:基于边缘梯度强度
    float gradientScore = 0;
    for (const auto& corners : markerCorners) {
        for (const auto& pt : corners) {
            // 计算角点处的梯度幅值
            float gx = getSobelX(gray, pt);
            float gy = getSobelY(gray, pt);
            float magnitude = sqrt(gx * gx + gy * gy);
            gradientScore += std::min(magnitude / 100.0f, 1.0f);
        }
    }
    gradientScore /= (detectedCount * 4);

    // 综合角点置信度
    return 0.6f * countScore + 0.4f * gradientScore;
}

角点置信度的计算包含两个维度:检测数量(是否检测到全部4个标记)和检测质量(角点处的边缘是否清晰)。当有标记被遮挡时,countScore降低;当光线暗淡或对焦模糊时,gradientScore降低。

7.4.2 液面线检测置信度(levelConf)
float calculateLevelConfidence(float liquidY, int horizontalCount,
    const std::vector<float>& candidateYPositions) {

    // 因素1:候选线数量(越多表示越不确定)
    float ambiguityScore = 1.0f / (1.0f + candidateYPositions.size() * 0.2f);

    // 因素2:液面线连续性(基于霍夫投票数)
    float continuityScore = std::min(horizontalCount / 3.0f, 1.0f);

    // 因素3:时序一致性(与上次检测结果的偏差)
    float temporalScore = 1.0f;
    if (lastLiquidY > 0) {
        float delta = fabs(liquidY - lastLiquidY);
        temporalScore = std::max(1.0f - delta / 50.0f, 0.0f);
    }

    // 综合液位置信度
    return 0.3f * ambiguityScore +
           0.4f * continuityScore +
           0.3f * temporalScore;
}

液位置信度考虑三个维度:歧义性(候选线越少越可信)、连续性(水平线越长越可信)、时序一致性(与上次结果越接近越可信)。

7.4.3 综合置信度
float confidence = 0.6f * cornerConf + 0.4f * levelConf;
return std::min(std::max(confidence, 0.0f), 1.0f);

角点置信度权重0.6高于液位置信度权重0.4,因为角点检测是整个流程的基础——角点检测失败的后果更严重(无法进行透视校正和刻度读取),而液面线检测失败只影响精度不影响功能。

7.5 异常情况处理

液位读取算法需要处理多种异常情况:

异常情况 检测方法 处理策略
未检测到标记 markerIds为空 返回空结果,等待下一帧
标记数量不足 markerIds.size() < 3 尝试用3个标记推算第4个,否则返回空
无水平线被检测 horizontalYPositions为空 返回上次检测结果(降低置信度)
液面位置突变 与上次偏差大于10% 丢弃该帧,使用上次结果
置信度过低 confidence < 0.5 丢弃该帧,等待下一帧
贴纸被严重遮挡 角点无法形成有效四边形 返回空结果
反光覆盖液面区域 液面线断裂 基于部分线段插值,降低置信度

突变检测是其中最关键的异常处理。在正常输液过程中,液位不可能在3秒内变化超过10%。如果检测到液位突变,几乎可以确定是误检而非真实的液位变化。此时应丢弃当前帧的结果,使用上次检测结果代替,并将置信度降低0.1作为警告。


8. 多标记同时检测

8.1 快速切换模式

在当前MVP实现中,VisionService.detect()返回的VisionResult数组只包含一个元素(主标记sticker_001)。但在正式版中,同一相机画面中可能检测到多个贴纸标记,需要支持多标记同时检测和切换。

快速切换模式的设计思路是:摄像头持续采集帧数据,每帧都检测所有可见的ArUco Marker。检测结果按Marker ID分组,每组对应一个贴纸。MonitorPage根据用户的选择或自动策略,决定当前显示哪个贴纸的详细信息。

摄像头帧
    |
    v
DetectMarkers()
    |
    v
[Marker 0,1,2,3]  [Marker 4,5,6,7]
    |                    |
    v                    v
贴纸A (sticker_001)  贴纸B (sticker_002)
    |                    |
    v                    v
[PerspectiveCorrect]  [PerspectiveCorrect]
    |                    |
    v                    v
[ReadLevel: 45%]     [ReadLevel: 72%]
    |                    |
    v                    v
VisionResult[0]       VisionResult[1]

8.2 主+辅监控策略

IVGuard采用主+辅监控策略来处理多标记场景:

  • 主标记(Primary):当前用户关注的贴纸,显示详细液位信息、流速、预估剩余时间
  • 辅标记(Secondary):非当前关注的贴纸,显示简化信息(仅液位百分比和简短标签)

MonitorOverlay组件对两种标记使用不同的视觉样式:

// MonitorOverlay.ets中的主辅标记渲染逻辑
ForEach(this.results, (result: VisionResult) => {
  if (result.markerId === this.activeMarkerId) {
    // 主标记:绿色虚线边框,宽2px
    Row()
      .width(200)
      .height(350)
      .borderWidth(2)
      .borderColor('#4CAF50')     // 绿色 - 激活
      .borderStyle(BorderStyle.Dashed)
      .position({ x: 100, y: 50 })
  } else {
    // 辅标记:灰色虚线边框,宽1px
    Row()
      .width(200)
      .height(350)
      .borderWidth(1)
      .borderColor('#999999')     // 灰色 - 非激活
      .borderStyle(BorderStyle.Dashed)
      .position({ x: 320, y: 50 })
  }
})

颜色编码的设计意图:

  • 绿色(#4CAF50):安全、正常、活跃——主标记正在被积极监控
  • 灰色(#999999):中性、非活跃——辅标记存在但不被关注
  • 橙色(#FF9800)(LevelProgress中level 20-50%时):注意、警告
  • 红色(#F44336)(LevelProgress中level < 20%时):危险、紧急

8.3 标记切换逻辑

标记切换可以由用户手动触发,也可以由系统自动触发:

手动切换:MonitorPage底部有"上一瓶"/"下一瓶"按钮,对应代码中的activeSessionIndex增减操作:

// MonitorPage.ets 第189-209行
if (this.sessions.length > 1) {
  Button('上一瓶')
    .onClick(() => {
      if (this.activeSessionIndex > 0) {
        this.activeSessionIndex--
      }
    })
  Button('下一瓶')
    .onClick(() => {
      if (this.activeSessionIndex < this.sessions.length - 1) {
        this.activeSessionIndex++
      }
    })
}

自动切换:当任一辅标记的液位降至预警阈值以下时,系统可以自动将其提升为主标记,确保用户注意到低液位警报。

8.4 MonitorOverlay的渲染细节

MonitorOverlay组件是视觉识别结果在UI上的直接映射。它将VisionResult中的角点坐标和液位数据渲染为可视化的检测框和液面线。

// MonitorOverlay.ets 完整渲染逻辑
@Component
export struct MonitorOverlay {
  @Prop results: VisionResult[] = []
  @Prop activeMarkerId: string = ''
  @Prop activeLevel: number = 0

  build() {
    Stack() {
      // 渲染每个检测到的标记的检测框
      ForEach(this.results, (result: VisionResult) => {
        if (result.markerId === this.activeMarkerId) {
          Row()
            .width(200)
            .height(350)
            .borderWidth(2)
            .borderColor('#4CAF50')
            .borderStyle(BorderStyle.Dashed)
            .position({ x: 100, y: 50 })
        } else {
          Row()
            .width(200)
            .height(350)
            .borderWidth(1)
            .borderColor('#999999')
            .borderStyle(BorderStyle.Dashed)
            .position({ x: 320, y: 50 })
        }
      }, (result: VisionResult) => result.markerId)

      // 渲染液面线
      if (this.results.length > 0 && this.activeLevel > 0) {
        Row()
          .width(200)
          .height(2)
          .backgroundColor('#2196F3')
          .position({ x: 100, y: 50 + (100 - this.activeLevel) * 3.5 })

        Text(`${this.activeLevel}%`)
          .fontSize(14)
          .fontColor('#2196F3')
          .fontWeight(FontWeight.Bold)
          .position({ x: 305, y: 50 + (100 - this.activeLevel) * 3.5 - 10 })
      }
    }
    .width('100%')
    .height('100%')
  }
}

液面线位置计算y = 50 + (100 - this.activeLevel) * 3.5

这个公式的含义:

  • 基准偏移50px(检测框顶部y坐标)
  • (100 - activeLevel)将液位百分比转换为从顶部算起的偏移比例
  • 乘以3.5将百分比映射为像素(350px检测框高度 / 100% = 3.5px/%)

当activeLevel = 100%时,y = 50 + 0 = 50(液面在框顶部)
当activeLevel = 0%时,y = 50 + 350 = 400(液面在框底部)

液面线使用蓝色(#2196F3)而非绿色或红色,是为了与检测框的绿色/灰色和液位进度环的绿/橙/红色区分,提供独立的视觉层次。

8.5 未来:同时跟踪多个标记

当前的主+辅监控策略是一种折中方案——同时检测多个标记但只详细展示一个。未来的增强版本将支持同时跟踪多个标记,在同一个画面中展示所有标记的详细液位信息。

实现同时跟踪需要解决以下技术挑战:

  1. 计算性能:每个标记需要独立的透视校正和液位读取,N个标记的计算量为N倍。在ARM平台上,单次完整检测耗时约50ms,4个标记约200ms,仍可满足3秒采样间隔的要求。

  2. 布局适配:多个标记的检测框和液面线在小屏幕(如手表)上可能重叠。需要设计智能布局算法,根据检测框的位置和大小自动调整显示方式。

  3. 资源管理:同时跟踪多个标记意味着更频繁的摄像头帧处理和NAPI调用,对电池和CPU的消耗更高。需要根据灵敏度和电池状态动态调整跟踪数量。

  4. 数据关联:每个标记的检测结果需要正确关联到对应的MonitorSession和Medicine,避免串号。这要求在ArUco Marker ID到业务ID的映射中确保数据一致性。


9. 识别精度优化

9.1 灵敏度三档设计

IVGuard在AppSettings中提供三档灵敏度设置,控制检测的采样间隔和精度:

灵敏度 采样间隔 适用场景 电池影响
low(低) 5秒 长时间稳定输液,不需要频繁监控
medium(中) 3秒 一般输液监控,默认设置
high(高) 1秒 快速输液或关键药物,需要精确监控

灵敏度通过AppSettings.sensitivity字段配置,默认值为"medium"。在MonitorPage中,采样间隔根据灵敏度设置动态调整:

// 灵敏度对应的采样间隔
const SENSITIVITY_INTERVALS: Record<string, number> = {
  'low': 5000,
  'medium': 3000,
  'high': 1000
}

// 在startMonitoring中使用
const interval = SENSITIVITY_INTERVALS[settings.sensitivity] || 3000
this.timerId = setInterval(() => {
  // ...检测逻辑
}, interval)

三档设计的合理性

  • 低灵敏度的必要性:输液通常持续1-4小时,在稳定阶段(液位50%以上)每5秒采样一次已经足够,可以显著节省电池。以500ml输液为例,典型流速约2ml/分钟,5秒内液位变化约0.03%,远低于5%的检测精度,不可能漏检重要变化。

  • 中灵敏度的默认性:3秒采样间隔是精度和电池的平衡点。在MVP阶段,VisionService的mockDecay也使用3秒间隔,保持了与模拟算法的一致性。

  • 高灵敏度的适用性:在输液即将完成(液位接近20%预警阈值)时,自动切换到高灵敏度可以更精确地掌握剩余时间,帮助护士合理安排处理时机。某些需要精确控制流速的药物(如血管活性药物)也需要高频采样。

9.2 帧率控制与电池平衡

摄像头帧采集和图像处理是电池消耗的主要来源。IVGuard采用以下策略平衡帧率与电池:

策略一:按需采集

不连续采集摄像头帧,而是在每次采样间隔到期时才采集一帧。这避免了持续的视频流对电池的消耗:

时间线:
|---3s---|       |---3s---|       |---3s---|
[采集帧] [睡眠] [采集帧] [睡眠] [采集帧]
  50ms            50ms            50ms

每3秒仅50ms的活跃时间,占空比约1.7%,电池消耗远低于连续视频流。

策略二:分辨率适配

在低灵敏度模式下降低摄像头分辨率(如640x480而非1920x1080),因为较低的采样频率不需要高精度。分辨率降低使每帧处理时间从约50ms降至约15ms,进一步节省CPU和电池。

策略三:智能唤醒

当液位接近预警阈值时,自动提高灵敏度。例如,当液位从25%降至20%时,将采样间隔从3秒缩短到1秒,确保不会因为采样延迟而错过预警时机。

// 智能唤醒逻辑(未来实现)
if (this.currentLevel <= 30 && this.currentLevel > 20) {
  // 接近预警阈值,提高灵敏度
  this.switchToInterval(2000)  // 2秒间隔
}
if (this.currentLevel <= 20) {
  // 已进入预警区间,最高灵敏度
  this.switchToInterval(1000)  // 1秒间隔
}

9.3 异常帧过滤

异常帧是指标识结果明显偏离物理规律的帧,通常由以下原因导致:

  • 瞬间遮挡:手、物体短暂遮挡摄像头或贴纸
  • 运动模糊:手机抖动导致图像模糊
  • 反光闪烁:光线角度变化导致大面积高光
  • 算法误检:噪声或其他图案被误识别为标记

IVGuard采用**连续3帧差异>10%**的过滤规则:

// 异常帧过滤逻辑
private recentLevels: number[] = []

private isAnomalousFrame(newLevel: number): boolean {
  this.recentLevels.push(newLevel)
  if (this.recentLevels.length > 3) {
    this.recentLevels.shift()
  }
  if (this.recentLevels.length < 3) {
    return false  // 不足3帧,不过滤
  }

  // 检查连续3帧的差异
  let maxDelta = 0
  for (let i = 1; i < this.recentLevels.length; i++) {
    const delta = Math.abs(this.recentLevels[i] - this.recentLevels[i - 1])
    if (delta > maxDelta) {
      maxDelta = delta
    }
  }

  return maxDelta > 10  // 任一相邻帧差异>10%视为异常
}

为什么是10%而非更小的阈值:以3秒采样间隔、最大流速3ml/分钟、500ml瓶为例,3秒内最大液位变化约0.18%,远低于10%。因此10%的阈值在正常输液过程中不可能被触发,而异常帧(如遮挡导致的液位跳变50%)则会被可靠捕获。

为什么是3帧而非1帧:单帧差异>10%可能由真实的快速变化引起(如输液管脱落导致突然排空)。连续3帧都出现大幅差异才判定为异常,可以避免误过滤真实事件。

9.4 光线补偿

光线变化是影响识别精度的主要环境因素。IVGuard采用以下光线补偿策略:

9.4.1 自动曝光调整建议

当检测到图像整体亮度过低或过高时,通过UI提示用户调整拍摄位置或角度:

// 光线质量评估
private assessLightQuality(grayImageData: number[]): LightQuality {
  // 计算图像平均亮度
  let sum = 0
  for (let i = 0; i < grayImageData.length; i++) {
    sum += grayImageData[i]
  }
  const avgBrightness = sum / grayImageData.length

  if (avgBrightness < 60) {
    return LightQuality.TOO_DARK   // 建议:增加光照
  } else if (avgBrightness > 200) {
    return LightQuality.TOO_BRIGHT // 建议:避免直射光
  } else {
    return LightQuality.GOOD
  }
}
9.4.2 白平衡补偿

在ArUco标记检测前,对图像进行白平衡补偿,减少色温偏移对检测的影响:

// 简单的灰度世界白平衡
cv::Scalar meanColor = cv::mean(frame);
float avgGray = (meanColor[0] + meanColor[1] + meanColor[2]) / 3;
std::vector<float> scaleFactors = {
    avgGray / meanColor[0],
    avgGray / meanColor[1],
    avgGray / meanColor[2]
};

for (int y = 0; y < frame.rows; y++) {
    for (int x = 0; x < frame.cols; x++) {
        frame.at<cv::Vec4b>(y, x)[0] *= scaleFactors[0];
        frame.at<cv::Vec4b>(y, x)[1] *= scaleFactors[1];
        frame.at<cv::Vec4b>(y, x)[2] *= scaleFactors[2];
    }
}

灰度世界假设(Gray World Assumption)认为,在色彩丰富的场景中,所有颜色的平均值趋向灰色。这一假设在IVGuard的场景中大致成立——贴纸的黑白标记和药瓶的透明/浅色液体在统计上趋向灰色。白平衡补偿可以有效减少色温偏移对ArUco检测的干扰。

9.4.3 高光区域处理

对于玻璃瓶表面的高光区域,采用以下处理策略:

  1. 高光检测:在HSV空间中,高光区域的饱和度S极低(< 30)且亮度V极高(> 230)。
  2. 高光掩码:生成高光区域的二值掩码。
  3. 掩码引导检测:在ArUco标记检测时,忽略高光掩码覆盖的区域,避免将高光误检为标记边缘。
// 高光检测与掩码生成
cv::Mat hsv;
cv::cvtColor(frame, hsv, cv::COLOR_RGB2HSV);
std::vector<cv::Mat> channels;
cv::split(hsv, channels);

cv::Mat specularMask;
cv::bitwise_and(channels[1] < 30, channels[2] > 230, specularMask);

// 使用掩码引导ArUco检测
// OpenCV的ArUco检测不直接支持掩码输入
// 替代方案:在掩码区域填充中性灰,降低高光干扰
for (int y = 0; y < frame.rows; y++) {
    for (int x = 0; x < frame.cols; x++) {
        if (specularMask.at<uchar>(y, x)) {
            frame.at<cv::Vec4b>(y, x) = cv::Vec4b(128, 128, 128, 255);
        }
    }
}

中性灰(128, 128, 128)的选择原因:这一灰度值既不是黑色也不是白色,不会被ArUco检测误认为是标记的一部分,同时不会引入额外的边缘信息干扰后续的角点检测。

9.5 精度验证方法

IVGuard的识别精度验证采用以下方法:

  1. 静态标定:使用标准液位(0%, 20%, 50%, 80%, 100%)的标定瓶,在不同光线条件和拍摄角度下各采集50帧,统计识别误差分布。

  2. 动态追踪:在实验室条件下模拟输液过程,每30秒记录一次识别结果和真实液位,计算全程追踪误差。

  3. 临床验证:在真实病房环境中,与护士人工读数对比,统计符合率和误检率。

目标精度指标:

指标 目标值 说明
静态精度 正负2% 标准条件下,单帧识别误差
动态精度 正负5% 全程追踪的最大累积误差
检出率 > 95% 可正确识别的帧数占比
误检率 < 1% 将非标记误判为标记的帧数占比
漏检率 < 5% 标记存在但未被检测到的帧数占比

10. MonitorPage与VisionService的集成

10.1 集成架构概览

MonitorPage是IVGuard应用中视觉识别功能面向用户的最终呈现页面,它与VisionService的集成构成了从底层数据到UI展示的完整链路:

+----------------+     +----------------+     +------------------+
| CameraService  |     | VisionService  |     | MonitorPage      |
| (帧数据提供)   | --> | (视觉识别)     | --> | (UI展示+交互)    |
+----------------+     +----------------+     +------------------+
                              |                       |
                              |                       v
                              |               +------------------+
                              +-------------> | NotificationSvc  |
                              |               | SpeechService    |
                              |               | DataStore        |
                              v               +------------------+
                        VisionResult[]
                        mockLevel
                        isActive

MonitorPage作为集成中心,负责:

  1. 管理VisionService的检测生命周期
  2. 定时轮询VisionService获取识别结果
  3. 根据识别结果更新UI状态
  4. 基于识别结果触发预警逻辑

10.2 3秒轮询机制

MonitorPage的核心工作机制是一个3秒间隔的轮询定时器,在startMonitoring()方法中启动:

// MonitorPage.ets 第51-66行
private startMonitoring(): void {
  VisionService.startDetection()
  this.isActive = true
  this.currentLevel = VisionService.getMockLevel()
  if (this.timerId === -1) {
    this.timerId = setInterval(() => {
      this.currentLevel = VisionService.getMockLevel()
      this.flowRate = 1.5 + Math.random() * 2
      this.visionResults = VisionService.detect(0)
      if (this.visionResults.length > 0) {
        this.activeMarkerId = this.visionResults[0].markerId
      }
      this.checkAlert()
    }, 3000)
  }
}

轮询体中的四个操作

  1. this.currentLevel = VisionService.getMockLevel():更新当前液位显示值。使用getMockLevel()而非detect()的返回值,因为后者需要构造完整的VisionResult对象,开销更大。在只需要液位数值时,直接读取内部状态变量更高效。

  2. this.flowRate = 1.5 + Math.random() * 2:模拟流速。MVP阶段流速使用随机数(1.5-3.5 ml/min),模拟真实流速的波动范围。正式版中,流速将基于连续两次检测的液位差和采样间隔计算:flowRate = (level_prev - level_current) / interval_seconds * ml_per_percent

  3. this.visionResults = VisionService.detect(0):获取完整的识别结果数组。虽然getMockLevel()已经提供了液位数值,但detect()返回的VisionResult数组包含了markerId和corners信息,这些信息用于:

    • 更新activeMarkerId:标识当前正在跟踪的贴纸
    • 传递给MonitorOverlay:渲染检测框和液面线
  4. this.checkAlert():执行预警检查(详见10.4节)

定时器防护if (this.timerId === -1)确保不会重复创建定时器。这与VisionService中startDetection()的定时器防护逻辑一致,形成双重保护。

轮询间隔与衰减间隔的一致性:MonitorPage的轮询间隔(3000ms)与VisionService的mockDecay间隔(3000ms)相同。这确保了每次轮询时都能获取到最新衰减后的液位值。如果轮询间隔短于衰减间隔,则会出现连续两次轮询获取相同液位的情况;如果轮询间隔长于衰减间隔,则会遗漏某些衰减步骤。两者一致是最自然的同步方案。

患者端-实时监控页_液位下降中

10.3 生命周期管理

MonitorPage与VisionService的生命周期严格绑定,确保检测资源在页面存在期间有效,页面销毁时释放。

10.3.1 aboutToAppear:启动监控
// MonitorPage.ets 第25-45行
aboutToAppear(): void {
  const context = getContext(this) as common.UIAbilityContext
  DataStore.init(context).then(() => {
    this.sessions = DataStore.loadSessions()
    const medicines = DataStore.loadMedicines()
    if (this.sessions.length > 0) {
      const s = this.sessions[0]
      this.patientName = s.patientName
      for (let i = 0; i < medicines.length; i++) {
        if (medicines[i].id === s.medicineId) {
          this.medicineName = medicines[i].name
          break
        }
      }
    } else {
      this.medicineName = '示例药物'
      this.patientName = '张三'
    }
  })
  this.startMonitoring()
}

aboutToAppear执行两个初始化操作:

  1. 数据初始化:从DataStore加载MonitorSession和Medicine数据,获取患者姓名和药物名称。当没有历史数据时,使用"张三"和"示例药物"作为默认值,确保页面不会显示空白。

  2. 启动监控:调用startMonitoring(),内部调用VisionService.startDetection()启动检测并开始mockDecay定时器。

注意DataStore.init()是异步操作,但startMonitoring()是同步调用且不依赖DataStore的结果。这是因为VisionService的启动不依赖持久化数据——它只需设置初始状态和启动定时器。数据加载和监控启动可以并行执行。

10.3.2 aboutToDisappear:停止监控
// MonitorPage.ets 第47-49行
aboutToDisappear(): void {
  this.stopMonitoring()
}

页面销毁时调用stopMonitoring(),内部执行:

  1. VisionService.stopDetection():停止检测,清除mockDecay定时器
  2. 设置this.isActive = false
  3. 清除轮询定时器

这确保了页面离开后不会有残留的定时器继续运行,避免内存泄漏和无效计算。

10.4 预警检查:checkAlert()

checkAlert()是MonitorPage中最关键的预警逻辑,它基于VisionService返回的液位数据决定是否触发预警:

// MonitorPage.ets 第77-93行
private checkAlert(): void {
  const settings = DataStore.loadSettings()
  if (this.currentLevel <= settings.warningThreshold && !this.alertTriggered) {
    this.alertTriggered = true
    NotificationService.sendLevelAlert(this.patientName, this.currentLevel)
    if (settings.voiceAlertEnabled) {
      SpeechService.playLowLevelAlert()
    }
  }
  if (this.currentLevel <= 0) {
    this.stopMonitoring()
    NotificationService.sendAlert('输液完成', `${this.patientName}的输液已完成,请通知护士`)
    if (settings.voiceAlertEnabled) {
      SpeechService.playCompleteAlert()
    }
  }
}

两级预警机制

  1. 低液位预警currentLevel <= warningThreshold,默认20%):

    • 条件:液位降至预警阈值以下且尚未触发过预警
    • 动作:发送通知 + 可选语音提醒
    • 防重复:!this.alertTriggered确保同一次监控中只触发一次低液位预警
  2. 输液完成警报currentLevel <= 0):

    • 条件:液位降至0%
    • 动作:停止监控 + 发送完成通知 + 可选语音提醒
    • 不设防重复:因为stopMonitoring()会设置isActive = false,后续checkAlert()不会再被调用

warningThreshold的配置性:预警阈值通过AppSettings.warningThreshold配置,默认值为20%。用户可以在SettingsPage中自定义阈值(通常在10%-30%之间)。DataStore.loadSettings()每次从持久化存储中读取最新设置,确保阈值修改即时生效。

语音提醒的开关settings.voiceAlertEnabled控制是否在预警时播放语音。这一开关的存在是因为在夜间病房中,语音提醒可能打扰其他患者休息。用户可以在SettingsPage中关闭语音提醒,仅保留通知和震动。

alertTriggered标志@State alertTriggered: boolean = false使用ArkTS的状态管理装饰器,确保其值变化时UI可以响应式更新。当alertTriggered从false变为true时,MonitorPage可以在UI上显示预警状态标识(如红色边框或闪烁图标)。

10.5 UI状态与数据绑定

MonitorPage中的多个@State变量直接绑定到VisionService的输出,形成响应式的数据流:

@State currentLevel: number = 85        // <--> VisionService.getMockLevel()
@State flowRate: number = 2.5           // <--> 1.5 + Math.random() * 2
@State isActive: boolean = false        // <--> VisionService.isActiveDetection()
@State visionResults: VisionResult[] = []  // <--> VisionService.detect()
@State activeMarkerId: string = ''      // <--> visionResults[0].markerId
@State alertTriggered: boolean = false  // <--> checkAlert()结果

数据绑定在UI中的体现

  1. LevelProgress组件:接收currentLevel作为@Prop,自动更新环形进度条和颜色:
LevelProgress({ level: this.currentLevel })

LevelProgress内部根据level值选择颜色:

  • level > 50%:绿色(#4CAF50) - 安全
  • 20% <= level <= 50%:橙色(#FF9800) - 注意
  • level < 20%:红色(#F44336) - 危险
  1. 流速显示:直接绑定flowRate值:
Text(`${this.flowRate.toFixed(1)} ml/min`)
  1. 预估剩余时间:基于currentLevelflowRate动态计算:
Text(`${Math.max(0, Math.ceil(this.currentLevel / this.flowRate))} 分钟`)

计算逻辑:剩余液位百分比 / 流速(ml/min) = 预估分钟数。这是基于一个简化假设:1%液位约等于1ml液体。正式版中需要根据药瓶实际容量进行换算。

  1. MonitorOverlay组件:接收visionResultsactiveMarkerId
MonitorOverlay({ results: this.visionResults, activeMarkerId: this.activeMarkerId })
  1. 暂停/继续按钮:绑定isActive状态:
Button(this.isActive ? '暂停' : '继续')
  .backgroundColor(this.isActive ? '#FF9800' : '#4CAF50')
  .onClick(() => {
    if (this.isActive) {
      this.stopMonitoring()
    } else {
      this.startMonitoring()
    }
  })

10.6 监控暂停与恢复

MonitorPage支持监控的暂停和恢复,通过startMonitoring()stopMonitoring()的交替调用实现:

[监控中] --暂停--> [已暂停] --继续--> [监控中]
startMonitoring()   stopMonitoring()   startMonitoring()
  |                   |                  |
  v                   v                  v
VisionService.       VisionService.     VisionService.
startDetection()     stopDetection()    startDetection()
mockLevel重置85%     mockLevel保持      mockLevel重置85%
轮询定时器启动       轮询定时器停止     轮询定时器重启
isActive=true        isActive=false     isActive=true

关键行为:每次调用startMonitoring()(无论是首次还是恢复),VisionService.startDetection()都会将mockLevel重置为85%。这意味着暂停后恢复的监控不会从暂停时的液位继续,而是重新从85%开始。

这一行为在MVP阶段是合理的——因为mock数据没有持久化需求,每次启动模拟都从85%开始提供了可重复的演示体验。但在正式版中,恢复监控应该从暂停时的液位继续,这需要在VisionService中增加状态保存机制:

// 正式版改进:暂停时保持液位
static startDetection(): void {
  VisionService.isActive = true
  // 不再重置mockLevel,保持暂停前的值
  // VisionService.mockLevel = 85  // 移除此行
  hilog.info(0x0000, 'IVGuard', 'Vision detection started')
  if (VisionService.mockDecayInterval === -1) {
    VisionService.mockDecayInterval = setInterval(() => {
      VisionService.mockDecay()
    }, 3000)
  }
}

10.7 完整的监控会话流程

从用户进入MonitorPage到离开,完整的监控会话流程如下:

用户点击"开始监控"按钮
    |
    v
MonitorPage.aboutToAppear()
    |
    +--> DataStore.init() + loadSessions() + loadMedicines()
    |    (异步,加载患者和药物信息)
    |
    +--> startMonitoring()
         |
         +--> VisionService.startDetection()
         |    - isActive = true
         |    - mockLevel = 85
         |    - 启动mockDecay定时器(3s)
         |
         +--> 启动轮询定时器(3s)
              |
              v (每3秒执行一次)
              +--> currentLevel = getMockLevel()
              +--> flowRate = random
              +--> visionResults = detect(0)
              +--> activeMarkerId = visionResults[0].markerId
              +--> checkAlert()
                   |
                   +--> if level <= threshold:
                   |    - NotificationService.sendLevelAlert()
                   |    - SpeechService.playLowLevelAlert()
                   |    - alertTriggered = true
                   |
                   +--> if level <= 0:
                        - stopMonitoring()
                        - NotificationService.sendAlert('输液完成')
                        - SpeechService.playCompleteAlert()
    |
    v (用户离开页面或点击结束)
MonitorPage.aboutToDisappear()
    |
    +--> stopMonitoring()
         |
         +--> VisionService.stopDetection()
         |    - isActive = false
         |    - 清除mockDecay定时器
         |
         +--> 清除轮询定时器
         +--> isActive = false

10.8 与其他页面的数据传递

MonitorPage从其他页面接收的数据通过DataStore间接传递,而非直接的路由参数:

ScanPage --> DataStore.saveMedicines() --> MonitorPage从DataStore.loadMedicines()
NurseHomePage --> DataStore.saveSessions() --> MonitorPage从DataStore.loadSessions()
SettingsPage --> DataStore.saveSettings() --> MonitorPage从DataStore.loadSettings()

这种间接传递的设计优势:

  1. 解耦:MonitorPage不依赖特定的入口页面,任何页面都可以启动监控
  2. 持久化:即使应用重启,DataStore中的数据仍然可用
  3. 一致性:所有页面通过同一个DataStore访问数据,避免数据不一致

从MonitorPage导航到其他页面的路径:

  • 返回router.back()返回上一页
  • 找护士站router.pushUrl({ url: 'pages/HospitalNavPage' })导航到医院导航页面

10.9 未来增强:实时摄像头预览集成

当前MVP版本的MonitorPage使用深色背景(#1A1A2E)模拟摄像头预览区域,实际的摄像头帧并未显示。正式版将集成CameraService,在预览区域显示实时摄像头画面,并在画面上叠加MonitorOverlay的检测结果:

// 正式版MonitorPage的预览区域
Stack() {
  // 实时摄像头预览
  XComponent({
    id: 'cameraPreview',
    type: XComponentType.SURFACE,
    libraryName: 'camera'
  })
    .width('100%')
    .height(300)
    .onLoad(() => {
      CameraService.startPreview(this.surfaceId)
    })

  // 检测结果叠加层
  MonitorOverlay({
    results: this.visionResults,
    activeMarkerId: this.activeMarkerId,
    activeLevel: this.currentLevel
  })

  // 暂停覆盖层
  if (!this.isActive) {
    Column() {
      Text('监控已暂停')
        .fontSize(16)
        .fontColor('#FFFFFF')
    }
    .width('100%')
    .height(300)
    .justifyContent(FlexAlign.Center)
    .backgroundColor('rgba(0,0,0,0.5)')
  }
}

XComponent是HarmonyOS提供的用于嵌入原生组件(如摄像头预览)的容器。通过XComponent的surface,CameraService可以将摄像头帧直接渲染到UI层,而VisionService则从相同的帧数据中进行识别处理。

10.10 WatchService与VisionService的跨设备同步

IVGuard的WatchService负责在配对的HarmonyOS手表上同步监控状态。VisionService的检测结果通过WatchService传递到手表端:

VisionService.getMockLevel()
        |
        v
WatchService.sendLevelUpdate(level, flowRate)
        |
        v
手表端IVGuard Watch App
  - 显示液位百分比
  - 低液位时震动提醒

跨设备同步的关键设计:

  1. 数据来源统一:手表端不独立进行视觉识别,而是从手机端的VisionService获取结果。这避免了在手表的小屏幕和低性能处理器上运行OpenCV的不现实需求。

  2. 推送而非轮询:手机端在每次轮询获取VisionService结果后,主动推送到手表端。手表端无需维护自己的轮询定时器,节省电池。

  3. 降级显示:手表端只显示液位百分比和预警状态,不显示检测框和液面线。这符合手表小屏幕的交互特性。


附录

A. VisionService完整源码(75行)

import { hilog } from '@kit.PerformanceAnalysisKit'

export interface VisionResult {
  markerId: string
  level: number
  confidence: number
  corners: number[]
}

export class VisionService {
  private static isActive: boolean = false
  private static mockLevel: number = 85
  private static mockDecayInterval: number = -1

  static startDetection(): void {
    VisionService.isActive = true
    VisionService.mockLevel = 85
    hilog.info(0x0000, 'IVGuard', 'Vision detection started')
    if (VisionService.mockDecayInterval === -1) {
      VisionService.mockDecayInterval = setInterval(() => {
        VisionService.mockDecay()
      }, 3000)
    }
  }

  static stopDetection(): void {
    VisionService.isActive = false
    if (VisionService.mockDecayInterval !== -1) {
      clearInterval(VisionService.mockDecayInterval)
      VisionService.mockDecayInterval = -1
    }
    hilog.info(0x0000, 'IVGuard', 'Vision detection stopped')
  }

  static detect(frame: number): VisionResult[] {
    if (!VisionService.isActive) {
      return []
    }
    const results: VisionResult[] = []
    const primary: VisionResult = {
      markerId: 'sticker_001',
      level: VisionService.mockLevel,
      confidence: 0.92,
      corners: [100, 50, 300, 50, 300, 400, 100, 400]
    }
    results.push(primary)
    return results
  }

  static isActiveDetection(): boolean {
    return VisionService.isActive
  }

  static getMockLevel(): number {
    return VisionService.mockLevel
  }

  static setMockLevel(level: number): void {
    VisionService.mockLevel = level
  }

  private static mockDecay(): void {
    if (VisionService.mockLevel > 0) {
      const decay = 1 + Math.floor(Math.random() * 2)
      VisionService.mockLevel = VisionService.mockLevel - decay
      if (VisionService.mockLevel < 0) {
        VisionService.mockLevel = 0
      }
    }
  }

  static resetMock(): void {
    VisionService.mockLevel = 85
  }
}

B. MonitorOverlay完整源码

import { VisionResult } from '../service/VisionService'

@Component
export struct MonitorOverlay {
  @Prop results: VisionResult[] = []
  @Prop activeMarkerId: string = ''
  @Prop activeLevel: number = 0

  build() {
    Stack() {
      ForEach(this.results, (result: VisionResult) => {
        if (result.markerId === this.activeMarkerId) {
          Row()
            .width(200)
            .height(350)
            .borderWidth(2)
            .borderColor('#4CAF50')
            .borderStyle(BorderStyle.Dashed)
            .position({ x: 100, y: 50 })
        } else {
          Row()
            .width(200)
            .height(350)
            .borderWidth(1)
            .borderColor('#999999')
            .borderStyle(BorderStyle.Dashed)
            .position({ x: 320, y: 50 })
        }
      }, (result: VisionResult) => result.markerId)

      if (this.results.length > 0 && this.activeLevel > 0) {
        Row()
          .width(200)
          .height(2)
          .backgroundColor('#2196F3')
          .position({ x: 100, y: 50 + (100 - this.activeLevel) * 3.5 })

        Text(`${this.activeLevel}%`)
          .fontSize(14)
          .fontColor('#2196F3')
          .fontWeight(FontWeight.Bold)
          .position({ x: 305, y: 50 + (100 - this.activeLevel) * 3.5 - 10 })
      }
    }
    .width('100%')
    .height('100%')
  }
}

C. MonitorPage与VisionService集成关键代码

// MonitorPage.ets 中的集成代码

private startMonitoring(): void {
  VisionService.startDetection()
  this.isActive = true
  this.currentLevel = VisionService.getMockLevel()
  if (this.timerId === -1) {
    this.timerId = setInterval(() => {
      this.currentLevel = VisionService.getMockLevel()
      this.flowRate = 1.5 + Math.random() * 2
      this.visionResults = VisionService.detect(0)
      if (this.visionResults.length > 0) {
        this.activeMarkerId = this.visionResults[0].markerId
      }
      this.checkAlert()
    }, 3000)
  }
}

private stopMonitoring(): void {
  VisionService.stopDetection()
  this.isActive = false
  if (this.timerId !== -1) {
    clearInterval(this.timerId)
    this.timerId = -1
  }
}

private checkAlert(): void {
  const settings = DataStore.loadSettings()
  if (this.currentLevel <= settings.warningThreshold && !this.alertTriggered) {
    this.alertTriggered = true
    NotificationService.sendLevelAlert(this.patientName, this.currentLevel)
    if (settings.voiceAlertEnabled) {
      SpeechService.playLowLevelAlert()
    }
  }
  if (this.currentLevel <= 0) {
    this.stopMonitoring()
    NotificationService.sendAlert('输液完成', `${this.patientName}的输液已完成,请通知护士`)
    if (settings.voiceAlertEnabled) {
      SpeechService.playCompleteAlert()
    }
  }
}

D. ArUco Marker ID与贴纸ID的映射规则

ArUco Marker ID     贴纸ID          说明
0, 1, 2, 3     -->  sticker_001    贴纸1(ID组0)
4, 5, 6, 7     -->  sticker_002    贴纸2(ID组1)
8, 9, 10, 11   -->  sticker_003    贴纸3(ID组2)
...
44, 45, 46, 47 -->  sticker_012    贴纸12(ID组11)

映射公式:
  stickerId = "sticker_" + String(markerId / 4 + 1).padStart(3, '0')
  或者
  stickerIndex = markerId / 4 + 1

每个贴纸使用4个连续的ArUco Marker ID,Marker ID 0-3为贴纸1,4-7为贴纸2,以此类推。这种映射规则简单而清晰,且通过整除运算即可实现,无需维护额外的映射表。

E. 灵敏度设置与系统参数对照表

参数 low medium high
采样间隔 5000ms 3000ms 1000ms
摄像头分辨率 640x480 1280x720 1920x1080
每帧处理耗时(预估) 15ms 30ms 50ms
每小时CPU活跃时间 10.8s 36s 180s
日均电池消耗(预估) 1-2% 3-5% 8-12%
液位检测精度 正负5% 正负3% 正负2%
适用场景 稳定输液 一般监控 精确监控
Logo

作为“人工智能6S店”的官方数字引擎,为AI开发者与企业提供一个覆盖软硬件全栈、一站式门户。

更多推荐