【模型量化】X5工具链量化实战:MediaPipe手部关键点模型

 RDK官方提供了2种手部关键点识别模型,分别适配X3/X5和S100/S600,手册链接如下:

 X3/X5:手势识别 | RDKDOC

 S100/S600:人手关键点及手势识别(mediapipe) | RDK DOC

 也许是训练集数据影响,X3/X5模型效果在1-2米内的近距离效果较差,我们当然希望模型在不同距离能拥有相同的鲁棒性

 MediaPipe的手部关键点识别模型是普遍广泛使用、业界认可度较高的开源模型,本文将展示如何将MediaPipe手部关键点模型面向X5量化。


一、环境要求

 onnx模型产生、模型量化、模型测试依赖环境如下:

  • onnxruntime(onnx导出与测试使用)

  • tf2onnx(onnx导出使用)

  • RDK X5量化工具链环境(量化环境)

  • OpenCV/skimage(校准数据集、数据预处理使用)

  • 以上环境的所有依赖


二、ONNX模型导出

 X5工具链源模型支持Caffe模型和ONNX模型两种,但Caffe模型用的比较少,本文只介绍通过以ONNX为源模型格式的量化方式。

 来到谷歌mediapipe识别官网手部特征点检测部分:hand_landmarker

 模型下载链接:hand_landmarker.task

 这是一个.task后缀的压缩包,直接把后缀改为zip解压,或者在命令行使用zip命令解压都可以,解压后内部文件如下:

 这是mediapipe手部关键点的两个重要模型:

  • hand_detector.tflite:

    • 用于手掌检测

    • 输入是192x192x3的rgb图像,数值范围0.0f-1.0f

    • 输出是2016x18大小的张量,可以处理2016若干个[矩形框,手掌关键点(用于正反手判断),置信度]数据组

    • 格式是tensorflow导出的模型权重文件

  • hand_landmarks_detector.tflite:

    • 用于手部关键点检测,通常是先用手掌检测模型检测图中的多手掌->截取roi并resize->手部关键点检测

    • 输入是224x224x3的rgb图像,数值范围0.0f-1.0f

    • 输出0:1x63张量,为21个手部关键点的相机坐标系x,y,z坐标

    • 输出1:0.0f-1.0f的浮点标量,输入图像中包含手的置信度

    • 输出2:0.0f-1.0f的浮点标量,用于判断左右手

    • 输出3:1x63张量,为21个手部关键点的世界坐标系x,y,z坐标

    • 格式是tensorflow导出的模型权重文件

 使用tf2onnx工具将它们导出为onnx模型,注意指定opset11(X5工具链只支持opset11量化)

python -m tf2onnx.convert \
    --tflite ./hand_detector.tflite \
    --output ./palm_detection_mediapipe_opset11.onnx \
    --opset 11
python -m tf2onnx.convert \
    --tflite ./hand_landmarks_detector.tflite \
    --output ./hand_landmark_full_opset11.onnx \
    --opset 11


 完成后目录下应该多出了2个onnx模型,把它们转移到量化的工作目录中。


三、校准数据产生

 校准数据必须是符合量化中间模型输入格式的二进制数据模型,接下来介绍如何产生校准数据


3.1.准备原始图片

 准备100张原模型训练图片,以常用图片格式png/jpg保存都可以

 mediapipe训练集抽样可以自己到互联网找一下,这边不提供了


3.2.校准数据格式确认

 需要确认3方面格式:

  1. 数据排列方式:模型的输入同步,仅支持NCHWNHWC,这边两个模型的输入分别是192x192x3224x224x3,向左补上一个batch维度后分别是1x192x192x31x224x224x3,显然是NHWC

  2. 数据范围:这块稍微有点复杂,校准数据的数据范围经稍后yaml配置的偏移和缩放后要与原onnx模型的范围一致,由于我们需求的输入是nv12数据,它是整型的,数据范围在0-255,所以校准数据的数据范围也必须在0-255,但是为了适配浮点模型的量化,他又必须是float格式

  3. 色彩通道排列:与原模型一致,保存rgb即可(注意opencv读图默认是bgr,生成校准数据时要转换一下)

所以我们校准数据的格式确定了:NHWC排列的0-255浮点型rgb图像


3.3.校准数据转换

 使用以下脚本,将png/jpg格式的图片批量转换为NHWC排列,数据范围0-255.0f,且色彩通道顺序为rgb的无损二进制图像raw文件:

import os
import cv2
import numpy as np
from pathlib import Path

def convert_image(src_image, dst_dir, target_size=(192, 192), to_bgr=False):
    # 1. 读取图像(OpenCV 默认 BGR)
    img = cv2.imread(src_image, cv2.IMREAD_UNCHANGED)
    if img is None:
        print(f"警告:无法读取图片 {src_image},跳过")
        return

    # 2. 统一转为 3 通道
    if len(img.shape) != 3:
        continue

    # 3. 调整尺寸
    img = cv2.resize(img, target_size, interpolation=cv2.INTER_LINEAR)

    # 4. 转换颜色空间(False 表示输出 RGB)
    if not to_bgr:
        img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB)

    # 5. 转为 float32
    img = img.astype(np.float32)

    # 6. 添加 batch 维度并保存为 raw 二进制
    save_path = os.path.join(dst_dir, Path(src_image).stem + '.raw')
    os.makedirs(os.path.dirname(save_path), exist_ok=True)
    img_batch = img[np.newaxis, ...]  # shape (1, 192, 192, 3)
    img_batch.tofile(save_path)       # 纯二进制,无头部
    print(f"已保存 raw: {save_path},形状 {img_batch.shape}")

def list_images(folder):
    valid_exts = ('.jpg', '.jpeg', '.png', '.bmp', '.tiff')
    return [os.path.join(folder, f) for f in os.listdir(folder)
            if f.lower().endswith(valid_exts)]

if __name__ == '__main__':
    dst_dir = # 替换为校准数据输出目录路径
    image_dir = # 替换为校准数据源目录目录
    target_size = (224, 224) # 手掌检测为(192, 192),关键点检测为(224, 244)
    to_bgr = False   # 不输出BGR,而是RGB

    images = list_images(image_dir)
    if not images:
        print(f"在 {image_dir} 中未找到图片")
        exit(1)

    print(f"找到 {len(images)} 张图片,开始处理...")
    for img_path in images:
        convert_image(img_path, dst_dir, target_size, to_bgr)
    print("所有图片处理完成!")

 替换对应路径和尺寸信息,执行脚本,应该会得到诸如下图的数据

 至此,校准数据准备完成


四、量化运行

4.1.准备量化所需的yaml参数文件

 手掌检测yaml文件如下,重要参数已注释说明

  model_parameters:
    # 原始Onnx浮点模型文件
    onnx_model: 'palm_detection_mediapipe_opset11.onnx'
    # 输出模型文件名(不包括.bin)
    output_model_file_prefix: 'palm_det_192_192'
    # 输出目录
    working_dir: './model_output_x5'
    
    march: 'bayes-e'

  input_parameters:
    # 原始onnx模型的输入节点名称
    input_name: "input_1"
    # 原始onnx模型输入色彩通道顺序
    input_type_train: 'rgb'
    # 原始onnx模型的输入数据排布
    input_layout_train: 'NHWC'
    # 原始onnx模型的输入数据尺寸
    input_shape: '1x192x192x3'

    input_batch: 1

    # 量化模型包装的预处理方法:缩放
    norm_type: 'data_scale'
    # 缩放比例=1/255
    scale_value: '0.00392156862745098' 

    # 转换后的输入数据格式:nv12
    input_type_rt: 'nv12'

  calibration_parameters:
    # 校准数据路径
    cal_data_dir: 'calibration_data_rgb_x5/input_1'

    cal_data_type: 'float32'
    calibration_type: 'default'
    optimization: set_model_output_int16

  compiler_parameters:
    compile_mode: 'latency'
    debug: False
    core_num: 1
    optimize_level: 'O3'

 手部关键点检测yaml文件如下,重要参数已说明:

  model_parameters:
    # 原始Onnx浮点模型文件
    onnx_model: 'hand_landmark_full_opset11.onnx'
    # 输出模型文件名(不包括.bin)
    output_model_file_prefix: 'hand_224_224'
    # 输出目录
    working_dir: './model_output_x5'
    
    march: 'bayes-e'

  input_parameters:
    # 原始onnx模型的输入节点名称
    input_name: "input_1"
    # 原始onnx模型输入色彩通道顺序
    input_type_train: 'rgb'
    # 原始onnx模型的输入数据排布
    input_layout_train: 'NHWC'
    # 原始onnx模型的输入数据尺寸
    input_shape: '1x224x224x3'

    input_batch: 1

    # 量化模型包装的预处理方法:缩放
    norm_type: 'data_scale'
    # 缩放比例=1/255
    scale_value: '0.00392156862745098' 

    # 转换后的输入数据格式:nv12
    input_type_rt: 'nv12'

  calibration_parameters:
    # 校准数据路径
    cal_data_dir: 'calibration_data_rgb_x5/input_1'

    cal_data_type: 'float32'
    calibration_type: 'default'
    optimization: set_model_output_int16

  compiler_parameters:
    compile_mode: 'latency'
    debug: False
    core_num: 1
    optimize_level: 'O3'
    
    # 指定输入源为resizer    
    input_source: {"input_1": "resizer"}

4.2.启动量化

 使用以下命令可以启动量化

hb_mapper makertbin --config conf_x5.yaml --model-type onnx

4.3.量化结果

 手掌检测模型量化精度数据如下:

=============================================================================
Output      Cosine Similarity  L1 Distance  L2 Distance  Chebyshev Distance
-----------------------------------------------------------------------------
Identity    0.990005           2.211411     0.015992     16.641747
Identity_1  0.996859           0.724158     0.020574     3.100387

 手部关键点模型量化精度数据如下

=============================================================================
Output      Cosine Similarity  L1 Distance  L2 Distance  Chebyshev Distance  
-----------------------------------------------------------------------------
Identity    0.999030           3.263987     0.512803     8.718201            
Identity_1  1.000000           0.002861     0.002861     0.002861            
Identity_2  1.000000           0.122190     0.122190     0.122190            
Identity_3  0.922014           0.005847     0.000955     0.027674

 余弦相似度在0.99以上基本证明量化成功了,手部关键点模型的Identity_3输出头我们不使用,这个输出头本身也有问题,所以可以不管它


4.4.量化模型测试

 使用量化输出目录下的xxx_quantized_model.onnx模型进行推理,其精度是和.bin模型一样的,用于在PC端快速验证量化结果


 在工具链环境使用以下脚本验证手掌检测效果:

import copy
import numpy as np
import cv2
from pathlib import Path
from anchor import _load_anchors
from horizon_tc_ui import HB_ONNXRuntime as HBRuntime

def bgr_to_nv12(bgr_img):
    # 转换为 YUV_I420 (YUV420P)
    yuv_i420 = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2YUV_I420)
    h, w = bgr_img.shape[:2]

    y_plane = yuv_i420[:h]

    u_plane = yuv_i420[h : h + h // 4].reshape(-1, w // 2)
    v_plane = yuv_i420[h + h // 4 :].reshape(-1, w // 2)

    uv_plane = np.stack((u_plane, v_plane)).transpose(1, 2, 0)
    # uv_plane = uv_plane.reshape(-1, w // 2, 2)

    # uv_plane = np.zeros((h // 2, w // 2, 2), dtype=np.uint8)
    # uv_plane[..., 0] = u_plane  # U channel
    # uv_plane[..., 1] = v_plane  # V channel

    return y_plane, uv_plane

sess = HBRuntime("./model_output_x5/palm_det_192_192_quantized_model.onnx")

input_names = sess.input_names
output_names = sess.output_names

ori_image = cv2.imread('./2.png')
original_shape = ori_image.shape

image = copy.deepcopy(ori_image)
image = cv2.resize(image, (192, 192))
# image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
image = cv2.cvtColor(image, cv2.COLOR_BGR2YUV)

outputs = sess.run(output_names, {input_names[0]: image[np.newaxis, ...]})

anchors = _load_anchors()

score = outputs[1][0, :, 0]
box_delta = outputs[0][0, :, 0:4]
landmark_delta = outputs[0][0, :, 4:]
scale_x = original_shape[1]
scale_y = original_shape[0]
scale = np.array([scale_x, scale_y])

# get scores
score = score.astype(np.float64)
score = 1 / (1 + np.exp(-score))

# get boxes
cxy_delta = box_delta[:, :2] / 192
wh_delta = box_delta[:, 2:] / 192
xy1 = (cxy_delta - wh_delta / 2 + anchors) * scale
xy2 = (cxy_delta + wh_delta / 2 + anchors) * scale
boxes = np.concatenate([xy1, xy2], axis=1)
keep_idx = cv2.dnn.NMSBoxes(boxes, score, 0.5, 0.75, top_k=10)
if len(keep_idx) == 0:
    breakpoint()
score = score[keep_idx]
boxes = boxes[keep_idx]

# get landmarks
selected_landmarks = landmark_delta[keep_idx].reshape(-1, 7, 2)
selected_landmarks = selected_landmarks / 192
selected_anchors = anchors[keep_idx]
for idx, landmark in enumerate(selected_landmarks):
    landmark += selected_anchors[idx]
selected_landmarks *= scale

for i, palm in enumerate(boxes):
    if score[i] > 0.5:
        x1, y1, x2, y2 = palm[0:4]
        print(f"Palm {i}: Score={score[i]:.4f}, Box=({x1:.1f}, {y1:.1f}, {x2:.1f}, {y2:.1f})")
        cv2.rectangle(ori_image, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2)
        for j, landmark in enumerate(selected_landmarks[i]):
            x, y = landmark
            cv2.circle(ori_image, (int(x), int(y)), 3, (255, 0, 0), -1)
            cv2.putText(ori_image, str(j), (int(x), int(y)), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)

# 保存图片
cv2.imwrite("./output_image.png", ori_image)

 左图为量化前,右图为量化后


 在工具链环境使用以下脚本验证手部关键点识别效果:

import numpy as np
import cv2
import copy
from pathlib import Path
# 加载地平线依赖库
from horizon_tc_ui import HB_ONNXRuntime as HBRuntime

def bgr_to_nv12(bgr_img):
    # 转换为 YUV_I420 (YUV420P)
    yuv_i420 = cv2.cvtColor(bgr_img, cv2.COLOR_BGR2YUV_I420)
    h, w = bgr_img.shape[:2]

    y_plane = yuv_i420[:h]

    u_plane = yuv_i420[h : h + h // 4].reshape(-1, w // 2)
    v_plane = yuv_i420[h + h // 4 :].reshape(-1, w // 2)

    uv_plane = np.stack((u_plane, v_plane)).transpose(1, 2, 0)

    return y_plane, uv_plane

# sess = HBRuntime("./model_output_x5/hand_224_224_original_float_model.onnx")
sess = HBRuntime("./model_output_x5/hand_224_224_quantized_model.onnx")

input_names = sess.input_names
output_names = sess.output_names

ori_image = cv2.imread('./p5.png')
original_shape = ori_image.shape

image = copy.deepcopy(ori_image)
image = cv2.resize(image, (224, 224))
# image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
image = cv2.cvtColor(image, cv2.COLOR_BGR2YUV)

output = sess.run(output_names, {input_names[0]: image[np.newaxis, ...]})

# if suffix == '.onnx':
#     image = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
#     image = image.astype(np.float32) / 255
# elif suffix == '.hbm' or suffix == '.bc':
#     scale = 1/255
#     zero_point = -128

#     image = image.astype(np.float32) / 255
    # image = np.round(image / scale + zero_point).astype(np.int8)
    # image = image.astype(np.float32) - 128
    # image = image.astype(np.int8)

# input = np.load('kps/aircanvas/calibration_data_bgr_s100/input_1/IMG_20220430_181047.npy')

keypoints = output[0].reshape(-1, 3)
score = output[1][0][0]

score2 = output[2][0][0]

print(f'{score=},{score2=}')

# 在原始图片上绘制关键点
for i, (x, y, z) in enumerate(keypoints):
    x = int(x * ori_image.shape[1] / 224)
    y = int(y * ori_image.shape[0] / 224)
    cv2.circle(ori_image, (x, y), 5, (0, 255, 0), -1)
    cv2.putText(ori_image, str(i), (x, y), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (0, 0, 255), 1, cv2.LINE_AA)

# 保存图片
cv2.imwrite("./output_5_.png", ori_image)

 左图为量化前,右图为量化后


五、常见问题

 在量化过程中有许多操作失误会导致量化精度不佳,本次量化初期也遇到类似问题,验证量化效果时,输出的检测框坐标如下:

 这已经与精度问题无关了,是输入数据的格式与归一化验证错误导致的数据范围严重离散


 排查过后发现是产生校准数据集时,存储的图片范围是0.0f-1.0f,而yaml配置中的缩放系数依然是1/255,也就是说,在量化过程中,模型将1.0f范围的图像再次缩放了255倍,形成了数据范围在0-1/255的数据给量化做校准,这样的数据对于模型来说就几乎是全黑的,自然无法起到校准效果

 由于需要适配8位的nv12数据(数据范围0-255),yaml中的缩放系数不能改变,将校准数据集的数据范围还原扩大的0-255后再次量化,就解决问题了


 其他量化问题可以参考手册: 7.4 算法工具链开发指南 | RDK DOC


六、上板运行

 手部关键点的pipeline比较复杂,这里不赘述,具体模型的上板运行可以参考手册:hand_lmk_gesture_mediapipe,该仓库之后也会适配X5


七、总结

 至此,我们经过了ONNX模型准备、校准数据准备、量化yaml配置、量化执行、量化结果分析等步骤,完成了mediapipe模型到X5的量化部署


八、资源汇总

原生模型:
hand_landmarker.task.zip (7.5 MB)
转换为opset 11的onnx模型:
hand_landmarker_onnx_opset11.zip (7.4 MB)
校准数据集png格式:
test_images.zip (1.1 MB)

2 个赞