- 资产集市
- 教学
- 实践
- AI说
- 案例库
- 生态合作
- 专区
中国站
简体中文发布于30个月以前
本文将介绍另一类特殊的功能单元——条件功能单元,我们将在手部检测应用基础上,使用ModelBox
开发一个单手关键点识别的AI应用:读取摄像头,解码出视频帧,在画面中检测最大的手部并识别出手上的21个关键点,再将关键点结果输出画面到本地屏幕。读者将了解到ModelBox
中条件功能单元的基本结构和开发流程。
对于这样的应用,除了增加手部关键点识别模型外,程序中还需要增加一些逻辑判断,伪代码如下:
import cv2
import onnx_model
# 初始化摄像头,参数0表示设备的第一个摄像头
cap = cv2.VideoCapture(0)
# 判断初始化是否成功
if not cap.isOpened():
print('failed to open camera 0')
exit()
# 初始化手部检测模型
hand_det = onnx_model('./hand_det.onnx')
# 初始化手部关键点检测模型
hand_pose = onnx_model('./hand_pose.onnx')
while True:
# 读取一帧视频图像,ret表示读取是否成功
ret, frame = cap.read()
# 图像预处理
img_pre = hand_det.preprocess(frame)
# 手部检测推理
output = hand_det.infer(img_pre)
# 检测结果后处理
hand_bboxes = hand_det.postprocess(output)
# 如果没检测到手,直接显示原始图像
if not hand_bboxes:
cv2.imshow('hand_pose', frame)
continue
# 如果检测到手,裁剪出画面中最大的手
hand_img = crop_largest_hand(frame, hand_bboxes)
# 手部图像预处理
img_pre2 = hand_pose.preprocess(hand_img)
# 手部关键点检测推理
output2 = hand_pose.infer(img_pre2)
# 关键点检测结果后处理
hand_landmarks = hand_pose.postprocess(output2)
# 在原始图像上画出手部关键点信息
draw_hand_landmarks(frame, hand_landmarks)
# 打开一个名为hand_pose的窗口,显示图像
cv2.imshow('hand_pose', frame)
# 阻塞等待键盘响应1ms,如果按下q键则跳出循环
if cv2.waitKey(1) & 0xFF == ord('q'):
break
# 释放摄像头资源
cap.release()
# 关闭所有窗口
cv2.destroyAllWindows()
可以看到,涉及到多模型推理以及多个分支,应用已经变得较为复杂,不同分支的选择是靠语言中的条件判断语句实现的。
对于这类含有多分支的应用,ModelBox
设计了一类特殊的功能单元——条件功能单元来处理。接下来我们看看使用ModelBox
如何构建这一应用。
这个应用对应的ModelBox
版本已经做成模板放在华为云OBS中,可以用sdk中的solution.bat
工具下载,接下来我们给出该应用在ModelBox
中的完整开发过程:
执行.\solution.bat -l
可看到当前公开的技能模板:
PS ███> .\solution.bat -l
...
Solutions name:
mask_det_yolo3
...
single_hand_pose_yolox_mbv2
multi_hand_pose_yolox_mbv2
结果中的single_hand_pose_yolox_mbv2即为单手关键点识别应用模板,可使用如下命令下载模板:
PS ███> .\solution.bat -s single_hand_pose_yolox_mbv2
...
solution.bat
工具的参数中,-l
代表list
,即列出当前已有的模板名称;-s
代表solution-name
,即下载对应名称的模板。下载下来的模板资源,将存放在ModelBox
核心库的solution
目录下。
在ModelBox
sdk目录下使用create.bat
创建single_hand_pose
工程
PS ███> .\create.bat -t server -n single_hand_pose -s single_hand_pose_yolox_mbv2
sdk version is modelbox-xxx
success: create single_hand_pose in ███\modelbox\workspace
与之前创建工程的命令相比,末尾多了-s
参数,表示将使用后面参数值代表的模板创建工程,而不是创建空的工程。
workspace
目录下将创建出single_hand_pose
工程,工程内容如下所示:
single_hand_pose
|--bin
│ |--main.bat:应用执行入口
│ |--mock_task.toml:应用在本地执行时的输入输出配置,此应用默认使用本地视频文件为输入源,最终结果输出到另一本地视频文件,可根据需要修改
|--CMake:存放一些自定义CMake函数
|--data:存放应用运行所需要的图片、视频、文本、配置等数据
│ |--hand_test_video.mp4:单手关键点识别测试用视频文件
|--dependence
│ |--modelbox_requirements.txt:应用运行依赖的外部库在此文件定义
|--etc
│ |--flowunit:应用所需的功能单元存放在此目录
│ │ |--cpp:存放C++功能单元编译后的动态链接库,此应用没有C++功能单元
│ │ |--crop_hand_image:裁剪手部框小图功能单元
│ │ |--draw_hand_landmarks:手部关键点画图功能单元
│ │ |--hand_condition:条件功能单元,根据图中是否检测到手输出不同的分支
│ │ |--landmarks_post:手部关键点检测模型的后处理功能单元
│ │ |--yolox_post:手部检测使用的是YOLOX模型,此处即为后处理功能单元
|--flowunit_cpp:存放C++功能单元的源代码,此应用没有C++功能单元
|--graph:存放流程图
│ |--single_hand_pose.toml:默认流程图,使用本地视频文件作为输入源
│ |--single_hand_pose_camera.toml:摄像头输入对应的流程图
│ |--modelbox.conf:modelbox相关配置
|--hilens_data_dir:存放应用输出的结果文件、日志、性能统计信息
|--model:推理功能单元目录
│ |--detect_hand:手部检测推理功能单元
│ │ |--detect_hand.toml:手部检测推理功能单元的配置文件
│ │ |--yolox_hand.onnx:手部检测onnx模型
│ |--detect_landmarks:手部关键点检测推理功能单元
│ │ |--detect_landmarks.toml:手部关键点检测推理功能单元的配置文件
│ │ |--pose_mbv2.onnx:手部关键点检测onnx模型
|--build_project.sh:应用构建脚本
|--CMakeLists.txt
|--rpm:打包rpm时生成的目录,将存放rpm包所需数据
|--rpm_copyothers.sh:rpm打包时的辅助脚本
手部检测之后,single_hand_pose
需要根据检测结果选择不同的分支进行后续操作:如果没有检测到手,直接输出原始图像;如果检测到手,需要进行手部关键点检测。因此,single_hand_pose
需要一个条件功能单元hand_condition。
条件功能单元的开发流程与通用功能单元是相同的:
本应用已经包含了条件功能单元,如果需要创建新的条件功能单元,创建操作与通用功能单元相同,功能单元目录结构也完全一样(以Python版本的条件功能单元为例):
[flowunit_name]
|--[flowunit_name].toml # 功能单元属性配置文件
|--[flowunit_name].py # 功能单元接口实现文件
打开条件功能单元的配置文件hand_condition.toml
,看到其内容为:
# 基础配置
[base]
name = "hand_condition" # 功能单元名称
device = "cpu" # 功能单元运行的设备类型,Python功能单元仅支持cpu类型
version = "1.0.0" # 功能单元版本号
type = "python" # 功能单元类型,Python功能单元此处为固定值python
group_type = "generic" # 功能单元分组信息, 包括input/output/image/video/inference/generic等,默认为generic
description = "whether hand detected in this frame" # 功能单元的功能描述信息
entry = "hand_condition@hand_conditionFlowUnit" # Python功能单元入口
# 工作模式,以下配置项默认全为false,表示通用功能单元;且配置项之前互斥,即最多只能设置其中一个为true
stream = false # 是否是Stream类型功能单元
condition = true # 是否是条件功能单元,此处设置为true,即表示hand_condition是一个条件功能单元
collapse = false # 是否是合并功能单元
expand = false # 是否是展开功能单元
# 输入端口描述
[input]
[input.input1] # 输入数据1
name = "in_image" # 原图
type = "uint8" # 原图数据格式为 uint8
[input.input2] # 输入数据2
name = "in_bbox" # 手部检测框
type = "string" # 检测框数据格式为json字符串
# 输出端口描述
[output]
[output.output1] # 检测到手部时的输出数据
name = "has_hand" # 原图 + 手部检测框(检测框以属性方式附加在原图上)
type = "uint8" # 原图数据格式为 uint8
[output.output2] # 未检测到手部时的输出数据
name = "no_hand" # 原图
type = "uint8" # 原图数据格式为 uint8
可以看到,与通用功能单元不同的是,条件功能单元的base.condition属性设置成了true,且一定拥有多个输出端口。hand_condition条件功能单元有两个输出端口has_hand和no_hand,分别代表检测到手和未检测到手这两个分支。
通用功能单元
通用功能单元在处理完数据后,会产生一到多个输出,后续功能单元在承接时,必须接收它的所有输出数据(当然接收后可以只处理其中一部分输出);另外,通用功能单元一次可处理多个Buffer(即batch size可以大于1),数据按batch size设置分成多组,多组数据并发处理,生成的结果数据与输入一致。下图展示了通用功能单元的数据流图:
条件功能单元
条件功能单元一定会产生多个输出,但是多个输出必须对接到不同的功能单元;另外,功能单元一次只处理一个Buffer(即batch size限制为1),多个Buffer并发处理,每个输出Buffer选择一个端口,输出的总量与单端口输入的数量一致。下图展示了条件功能单元的数据流图:
条件功能单元需要实现FlowUnit::open,FlowUnit::close,FlowUnit::process三个接口,与通用功能单元相同。
FlowUnit::open接口
open接口中通常用于配置参数的初始化,此功能单元不涉及。
FlowUnit::process接口
条件功能单元的process接口实现逻辑与通用功能单元基本一致,只在最后输出Buffer设置上有所不同:
def process(self, data_context):
# 1. 从DataContext中获取输入输出BufferList对象
in_image = data_context.input("in_image")
in_bbox = data_context.input("in_bbox")
has_hand = data_context.output("has_hand")
no_hand = data_context.output("no_hand")
# 2. 循环处理每一个输入Buffer数据(实际上条件功能单元的batch size为1,此处循环中只有1条数据)
for buffer_img, buffer_bbox in zip(in_image, in_bbox):
# 3.1 获取输入图像Buffer的宽、高、通道数等属性信息
width = buffer_img.get('width')
height = buffer_img.get('height')
channel = buffer_img.get('channel')
# 3.2 将输入Buffer转换为numpy对象
img_data = np.array(buffer_img.as_object(), copy=False)
img_data = img_data.reshape((height, width, channel))
# 字符串数据可以直接用as_object函数转换
bbox_str = buffer_bbox.as_object()
# 3.3 业务处理:解码出手部检测框数据
hand_bboxes = self.decode_hand_bboxes(bbox_str)
# 4/5. 此处是将输入Buffer直接作为输出Buffer向后传递
# 此时Buffer的Data、Meta等全部内容都将保留,无需构建Buffer、设置Meta
if hand_bboxes: # 6.1 检测到手部时的输出分支
max_roi = self.get_max_roi(hand_bboxes, img_data)
buffer_img.set("bbox", max_roi) # 将手部检测框作为属性附在输出Buffer上
has_hand.push_back(buffer_img)
else: # 6.2 未检测到手部时的输出分支
no_hand.push_back(buffer_img)
# 7. 返回成功标志,ModelBox框架会将数据发送到后续的功能单元
return modelbox.Status.StatusCode.STATUS_SUCCESS
这里也可以看到一个输出数据的设置技巧,在检测到手部时,我们是将检测框作为一个属性附在图片上,这样这个分支只需要一个输出数据,不需要原始图片+检测框两个输出数据(后续画图需要这两个数据)。
本应用还增加了另一个推理功能单元detect_landmarks,用来检测手部关键点,模型采用 MobileNetV2 作为 backbone,在开源数据集 CMU Panoptic HandDB 上进行训练,在ModelArts
的Notebook
环境中训练后,再转换成对应平台的模型格式:onnx格式可以用在Windows设备上,RK系列设备上需要转换为rknn格式。
模型的训练与转换教程已经开放在AI Gallery
中,其中包含训练数据、训练代码、模型转换脚本,以及详细的指导文档。开发者如果希望尝试自己训练模型,或者对模板中提供的模型效果不满意,可以进入 Mobilenetv2 Heatmap手部关键点检测模型训练与转换 页面,点击右上角的Run in ModelArts按钮,跟随教程一步步操作,也可以修改其中的代码、更换新的数据集训练出自己的模型。
single_hand_pose
工程graph
目录下包含两个流程图,其中与工程同名的single_hand_pose.toml
流程图,其内容为(以Windows版ModelBox
为例):
[driver]
# 功能单元的扫描路径,包含在[]中,多个路径使用,分隔
# ${HILENS_APP_ROOT} 表示当前应用的实际路径
# ${HILENS_MB_SDK_PATH} 表示ModelBox核心库的实际路径
dir = [
"${HILENS_APP_ROOT}/etc/flowunit",
"${HILENS_APP_ROOT}/etc/flowunit/cpp",
"${HILENS_APP_ROOT}/model",
"${HILENS_MB_SDK_PATH}/flowunit",
]
skip-default = true
[profile]
# 通过配置profile和trace开关启用应用的性能统计
profile = false # 是否记录profile信息,每隔60s记录一次统计信息
trace = false # 是否记录trace信息,在任务执行过程中和结束时,输出统计信息
dir = "${HILENS_DATA_DIR}/mb_profile" # profile/trace信息的保存位置
[flow]
desc = "single hand pose estimation example using yolox and mobilenet-v2 for local video or rtsp video stream" # 应用的简单描述
[graph]
format = "graphviz" # 流程图的格式,当前仅支持graphviz
graphconf = """digraph single_hand_pose {
node [shape=Mrecord]
queue_size = 4
batch_size = 1
# 定义节点,即功能单元及其属性
input1[type=input, flowunit=input, device=cpu, deviceid=0]
data_source_parser[type=flowunit, flowunit=data_source_parser, device=cpu, deviceid=0]
video_demuxer[type=flowunit, flowunit=video_demuxer, device=cpu, deviceid=0]
video_decoder[type=flowunit, flowunit=video_decoder, device=cpu, deviceid=0, pix_fmt="bgr"]
image_resize[type=flowunit, flowunit=resize, device=cpu, deviceid=0, image_width=320, image_height=320]
color_transpose[type=flowunit flowunit=packed_planar_transpose device=cpu deviceid="0"]
normalize[type=flowunit flowunit=normalize device=cpu deviceid="0" standard_deviation_inverse="1.,1.,1."]
detect_hand[type=flowunit, flowunit=detect_hand, device=cpu, deviceid=0]
yolox_post[type=flowunit, flowunit=yolox_post, device=cpu, deviceid=0]
hand_condition[type=flowunit, flowunit=hand_condition, device=cpu, deviceid=0]
crop_hand_image[type=flowunit, flowunit=crop_hand_image, device=cpu, deviceid=0]
image_resize2[type=flowunit, flowunit=resize, device=cpu, deviceid=0, image_width=256, image_height=256]
color_transpose2[type=flowunit flowunit=packed_planar_transpose device=cpu deviceid="0"]
mean[type=flowunit flowunit=mean device=cpu deviceid="0" mean="116.28,103.53,123.68"]
normalize2[type=flowunit flowunit=normalize device=cpu deviceid="0" standard_deviation_inverse="0.0175070,0.01742919,0.01712475"]
detect_landmarks[type=flowunit, flowunit=detect_landmarks, device=cpu, deviceid=0]
landmarks_post[type=flowunit, flowunit=landmarks_post, device=cpu, deviceid=0]
draw_hand_landmarks[type=flowunit, flowunit=draw_hand_landmarks, device=cpu, deviceid=0]
video_out[type=flowunit, flowunit=video_out, device=cpu, deviceid=0]
# 定义边,即功能间的数据传递关系
input1:input -> data_source_parser:in_data
data_source_parser:out_video_url -> video_demuxer:in_video_url
video_demuxer:out_video_packet -> video_decoder:in_video_packet
video_decoder:out_video_frame -> image_resize:in_image
image_resize:out_image -> color_transpose:in_image
color_transpose:out_image -> normalize:in_data
normalize:out_data -> detect_hand:input
detect_hand:output -> yolox_post:in_feat
yolox_post:out_data -> hand_condition:in_bbox
video_decoder:out_video_frame -> hand_condition:in_image
hand_condition:no_hand -> video_out:in_video_frame
hand_condition:has_hand -> crop_hand_image:in_data
crop_hand_image:roi_image -> image_resize2:in_image
image_resize2:out_image -> color_transpose2:in_image
color_transpose2:out_image -> mean:in_data
mean:out_data -> normalize2:in_data
normalize2:out_data -> detect_landmarks:input
detect_landmarks:output -> landmarks_post:in_feat
landmarks_post:out_data -> draw_hand_landmarks:in_landmarks
hand_condition:has_hand -> draw_hand_landmarks:in_image
draw_hand_landmarks:out_image -> video_out:in_video_frame
}"""
图中,可以看到条件功能单元hand_condition的两个输出分别对接到不同的功能单元,在未检测到手部时,no_hand分支直接对接到video_out进行视频编码;检测到手部时,has_hand对接到crop_hand_image以及之后的功能单元做手部关键点识别。
此外外,应用还增加了其他通用功能单元crop_hand_image、landmarks_post、draw_hand_landmarks等,它们的实现方式前面教程已有介绍,此处不再赘述。
另外,可以看到预处理功能单元resize、packed_planar_transpose、normalize等分别使用了两次(两次的属性不同),每种功能单元在图中也相应的定义了两个实例,使用不同的节点名称进行区分。
查看任务配置文件bin/mock_task.toml
,可以看到其中的任务输入和任务输出配置为如下内容::
[input]
type = "url"
url = "${HILENS_APP_ROOT}/data/hand_test_video.mp4" # 表示输入源为本地视频文件
[output]
type = "local"
url = "${HILENS_APP_ROOT}/hilens_data_dir/hand_test_result.mp4" # 表示输出为本地视频文件
即,使用本地视频文件data/hand_test_video.mp4
作为输入,解码、预处理、手部检测、后处理、手部关键点检测后,画图输出到本地视频文件data/hand_test_result.mp4
中。
启动应用前执行.\build_project.sh
进行工程构建,该脚本将编译自定义的C++功能单元(本应用不涉及)、将应用运行时会用到的配置文件转码为Unix格式(防止执行过程中的格式错误):
PS ███> .\build_project.sh
...
PS ███>
然后执行.\bin\main.bat
运行应用:
PS ███> .\bin\main.bat
...
运行结束后在hilens_data_dir
目录下生成了hand_test_result.mp4
文件,可以打开查看:
除了测试视频文件外,我们还可以使用PC自带或者外接的USB摄像头进行手部关键点检测,对应的流程图为single_hand_pose_camera.toml
,其内容如下:
[driver]
# 功能单元的扫描路径,包含在[]中,多个路径使用,分隔
# ${HILENS_APP_ROOT} 表示当前应用的实际路径
# ${HILENS_MB_SDK_PATH} 表示ModelBox核心库的实际路径
dir = [
"${HILENS_APP_ROOT}/etc/flowunit",
"${HILENS_APP_ROOT}/etc/flowunit/cpp",
"${HILENS_APP_ROOT}/model",
"${HILENS_MB_SDK_PATH}/flowunit",
]
skip-default = true
[profile]
# 通过配置profile和trace开关启用应用的性能统计
profile = false # 是否记录profile信息,每隔60s记录一次统计信息
trace = false # 是否记录trace信息,在任务执行过程中和结束时,输出统计信息
dir = "${HILENS_DATA_DIR}/mb_profile" # profile/trace信息的保存位置
[flow]
desc = "single hand pose estimation example using yolox and mobilenet-v2 for local usb camera" # 应用的简单描述
[graph]
format = "graphviz" # 流程图的格式,当前仅支持graphviz
graphconf = """digraph single_hand_pose {
node [shape=Mrecord]
queue_size = 4
batch_size = 1
# 定义节点,即功能单元及其属性
input1[type=input, flowunit=input, device=cpu, deviceid=0]
data_source_parser[type=flowunit, flowunit=data_source_parser, device=cpu, deviceid=0]
local_camera[type=flowunit, flowunit=local_camera, device=cpu, deviceid=0, pix_fmt="bgr", cam_width=1280, cam_height=720]
image_resize[type=flowunit, flowunit=resize, device=cpu, deviceid=0, image_width=320, image_height=320]
color_transpose[type=flowunit flowunit=packed_planar_transpose device=cpu deviceid="0"]
normalize[type=flowunit flowunit=normalize device=cpu deviceid="0" standard_deviation_inverse="1.,1.,1."]
detect_hand[type=flowunit, flowunit=detect_hand, device=cpu, deviceid=0]
yolox_post[type=flowunit, flowunit=yolox_post, device=cpu, deviceid=0]
hand_condition[type=flowunit, flowunit=hand_condition, device=cpu, deviceid=0]
crop_hand_image[type=flowunit, flowunit=crop_hand_image, device=cpu, deviceid=0]
image_resize2[type=flowunit, flowunit=resize, device=cpu, deviceid=0, image_width=256, image_height=256]
color_transpose2[type=flowunit flowunit=packed_planar_transpose device=cpu deviceid="0"]
mean[type=flowunit flowunit=mean device=cpu deviceid="0" mean="116.28,103.53,123.68"]
normalize2[type=flowunit flowunit=normalize device=cpu deviceid="0" standard_deviation_inverse="0.0175070,0.01742919,0.01712475"]
detect_landmarks[type=flowunit, flowunit=detect_landmarks, device=cpu, deviceid=0]
landmarks_post[type=flowunit, flowunit=landmarks_post, device=cpu, deviceid=0]
draw_hand_landmarks[type=flowunit, flowunit=draw_hand_landmarks, device=cpu, deviceid=0]
video_out[type=flowunit, flowunit=video_out, device=cpu, deviceid=0]
# 定义边,即功能间的数据传递关系
input1:input -> data_source_parser:in_data
data_source_parser:out_video_url -> local_camera:in_camera_packet
local_camera:out_camera_frame -> image_resize:in_image
image_resize:out_image -> color_transpose:in_image
color_transpose:out_image -> normalize:in_data
normalize:out_data -> detect_hand:input
detect_hand:output -> yolox_post:in_feat
yolox_post:out_data -> hand_condition:in_bbox
local_camera:out_camera_frame -> hand_condition:in_image
hand_condition:no_hand -> video_out:in_video_frame
hand_condition:has_hand -> crop_hand_image:in_data
crop_hand_image:roi_image -> image_resize2:in_image
image_resize2:out_image -> color_transpose2:in_image
color_transpose2:out_image -> mean:in_data
mean:out_data -> normalize2:in_data
normalize2:out_data -> detect_landmarks:input
detect_landmarks:output -> landmarks_post:in_feat
landmarks_post:out_data -> draw_hand_landmarks:in_landmarks
hand_condition:has_hand -> draw_hand_landmarks:in_image
draw_hand_landmarks:out_image -> video_out:in_video_frame
}"""
与single_hand_pose.toml
相比,这个流程图使用local_camera替换了video_demuxer和video_decoder功能单元,其他部分是一致的。
打开工程目录下bin/mock_task.toml
文件,修改其中的任务输入和任务输出配置为如下内容:
[input]
type = "url"
url = "0" # 表示0号摄像头,即PC自带摄像头,若PC无摄像头需外接USB摄像头
[output]
type = "local"
url = "0:single_hand_pose" # 表示名为```single_hand_pose```的本地窗口
即使用编号为0的摄像头(默认为PC自带的摄像头),输出画面显示到名为single_hand_pose
的本地屏幕窗口中。
执行.\bin\main.bat camera
运行应用,将会自动弹出实时的手部关键点检测画面:
通过本教程,我们学习了ModelBox
中条件功能单元的开发,条件功能单元类似于编程语言中的条件判断语句,当应用中涉及到多分支结构时,可以使应用根据不同分支实现不同逻辑。另外,使用手部关键点检测也可以做出很多有趣的应用,感兴趣的朋友可以看看 隔空作画 这个案例,使用🖐手势在屏幕上画出好玩的图形。
根据本次课程学习的内容,在自己的环境进行复现,并将复现的程序运行界面,连同网页右上方的华为云账号截图发至打卡贴上,详细打卡规则参见活动页面或参考2022ModelBox实战营活动打卡 (huaweicloud.com)