前言
在 Hailo 的 AI 芯片上运行神经网络模型时,不能直接使用原始的模型档案,需要使用 Hailo Dataflow Compiler 将模型转换为 Hailo 芯片可执行格式(HEF)。
Dataflow Compiler 负责优化模型结构与数据流,提升推论效率并降低功耗,使训练好的 AI 模型能在 Hailo 硬件上高效运行。
主要功能:
- 支援 TensorFlow、ONNX 模型匯入
- 自动图优化与分割
- 创建 Hailo 可执行文件(HEF)
- 提供资源使用与效能预估报告
-
支持多层模型分析与调校
获得HEF后,下一步就是用 Hailo API 去运行模型。
由于我们是通过NT98336去控 Hailo 硬件,所以必须先以 NT98336 的环境去编译 Hailo 的 driver 和 HailoRT (Hailo 提供的执行时函式库),用于在设备端载入 HEF 文件并进行高效能推论。
HailoRT 里有包含 Hailo 各语言的 API,有 Python、C、C++ 等 ,这篇使用的是 C++。
获取YOLOV8模型
由 Hailo Model Zoo 下载 YOLOV8m pretrained weight,下载下来的文件是 ONNX 格式,另外还有一个 NMS config 文件。
下载页面有附上模型的来源连结,以YOLOV8m为例就是 ultralytics,可以藉由来源知道推论时的参数。
如以下参考范例的前处理部分:
def preprocess(self) -> Tuple[np.ndarray, Tuple[int, int]]:
"""
Preprocess the input image before performing inference.
This method reads the input image, converts its color space, applies letterboxing to maintain aspect ratio,
normalizes pixel values, and prepares the image data for model input.
Returns:
image_data (np.ndarray): Preprocessed image data ready for inference with shape (1, 3, height, width).
pad (Tuple[int, int]): Padding values (top, left) applied during letterboxing.
"""
# Read the input image using OpenCV
self.img = cv2.imread(self.input_image)
# Get the height and width of the input image
self.img_height, self.img_width = self.img.shape[:2]
# Convert the image color space from BGR to RGB
img = cv2.cvtColor(self.img, cv2.COLOR_BGR2RGB)
img, pad = self.letterbox(img, (self.input_width, self.input_height))
# Normalize the image data by dividing it by 255.0
image_data = np.array(img) / 255.0
# Transpose the image to have the channel dimension as the first dimension
image_data = np.transpose(image_data, (2, 0, 1)) # Channel first
# Expand the dimensions of the image data to match the expected input shape
image_data = np.expand_dims(image_data, axis=0).astype(np.float32)
# Return the preprocessed image data
return image_data, pad
由此前处理函数可以得知以下信息:
- 模型輸入的圖片格式是 RGB
- 图片有经过 letterbox 处理,图片不会形变。 Padding value=(114,114,114)。
-
均值减去=0、归一化=255
Hailo Dataflow 编译器
1. ONNX 推理
可以直接使用上面提到的 ONNX inference 范例,或是参考范例去自己撰写推论程序,确认模型预测结果正确再进行下一步的转换。
考虑到 Hailo 内建的前处理没有 padding 的功能,所以需要自行加入。 加入的位置可以分为两种:
前者是在板端取得影像后,用C或其他方式加入padding,再喂给 Hailo 模型。
考虑到用板端 CPU 加入 padding 可能会较慢,所以这边第二种方式 - 将 padding 加在模型中。
模型的原始输入长宽是 W=640, H=360,所以在原图的上下各加 H=140 的 padding,补成 W=640, H=640。
另外原本前处理过程中padding补的值是114,但由于第二种方式是先normalize才padding,所以padding的值也要做normalize - 所以是114/255 = 0.447。
依照上述前处理处理图片,并输入修改过的ONNX模型用onnxrunime做推论,确认预测结果是正确的。
2. 解析
依照 Dataflow Compiler 输出 log 的建议,将模型的输出裁切到指定的六个卷积层(即输出分类分数和预测框坐标的那六个 head)。
之后用到的 Hailo 内置 YOLOv8m 的后处理程序,输入就是这六个卷积层。 下图是经过 parsing 后,模型的最前面和最尾端:
后续的后处理部分(如下图)使用 Python 写完,确认预测结果和原始 ONNX 的输出结果相同。
3. 优化
Optimize 的内容分为两个部分: 修改模型和增加模型精度/压缩模型。
修改模型
前面提到的前处理部分,可以使用 Hailo 的 model script 功能,将前处理加在模型前面。
这边使用 mean=[0.0, 0.0, 0.0]、std=[255.0, 255.0, 255.0],是由前面来源程序前处理得出的信息。
model_script_lines = [
"normalization1 = normalization({}, {})\\n".format(str([0.0, 0.0, 0.0]), str([255.0, 255.0, 255.0])),
]
另外在模型的最后加入 NMS:
momodel_script_lines.append("nms_postprocess('{}', meta_arch=yolov8)\\n".format(nms_cfg_path))
模型的架构选择 yolov8,nms_cfg_path 是和 ONNX 模型一起载下来的 nms config 文件路径,内容如下:
{
"nms_scores_th": 0.2,
"nms_iou_th": 0.6,
"image_dims": [
640,
640
],
"max_proposals_per_class": 100,
"classes": 80,
"regression_length": 16,
"background_removal": false,
"background_removal_index": 0,
"bbox_decoders": [
{
"name": "yolov8m/bbox_decoder57",
"stride": 8,
"reg_layer": "yolov8m_cut/conv57",
"cls_layer": "yolov8m_cut/conv58"
},
{
"name": "yolov8m/bbox_decoder70",
"stride": 16,
"reg_layer": "yolov8m_cut/conv70",
"cls_layer": "yolov8m_cut/conv71"
},
{
"name": "yolov8m/bbox_decoder82",
"stride": 32,
"reg_layer": "yolov8m_cut/conv82",
"cls_layer": "yolov8m_cut/conv83"
}
]
}
其中nms_score_th, nms_iou_th 可以根据需要去调整,而bbox decoder 部分的名称须和 parsing 后模型的输出节点名称一致,后处理和 NMS 才会正确运作。
将 Parsing 后模型存成 har 文件,再解压缩此文件,用 Netron 打开 [model name].hn 后,就可以看到输出节点名称,如下图:
优化模型/压缩模型
Hailo 的模型优化程度有分 0~4 级,越高级,模型优化的程度越大,但也需要比较多的数据去训练,转换模型的时间也会比较久。 如果发现模型精度不够的话,可以选择比较高的优化层级。
Hailo 的模型压缩程度分为 0~4 级,越高级,模型压缩的程度越大,推论的速度会越快,模型档案大小也越小。 但也可能造成模型精度不够,所以建议搭配比较高的优化层级。
这边选择 optimization level = 1,compression level = 0,batch_size=1。 这边的 batch size 是指优化的过程中每次运算几笔,如果用来训练的 GPU 内存不足,可以将 batch size 调小。
model_script_lines.append(
"model_optimization_flavor(optimization_level={}, compression_level={}, batch_size={})\\n".format(1, 0, 1),
)
确认预测结果
在这个阶段确认预测结果是很重要的,因为模型的输入、输出、模型精度和 parsing 时相比,可能会有产生变化。
举例来说,用 python 做图片前处理 → 用 model script 做前处理、用 python 做模型后处理 → 用 model script 做后处理、模型精度由 float32 → int8,所以做推论检查结果是必要的。
模型后处理的部分,由于用了 Hailo 内建针对 YOLOv8 的后处理和 NMS,所以输出不会是原本的 (84, 8400),而是 (80, 5, 100)。
原始输出 / Hailo NMS 后的输出
每个维度的意义如下:
长度 | 意义 | nms config 中的名称 |
80 | 物体类别数量 | “类” |
5 | 依序是 Ymin, XMIN, YMAX, XMAX, Score 這五個值。 坐标的值是 0~1,要自行换算回原本图片大小。 | 无 |
100 | 每个类别最多输出几个框 (得分高的优先) | “max_proposals_per_class” |
4. 编译
确定前面优化后的结果 ok 后,就可以将模型编译为 HEF 文件,也就是可以在 Hailo 硬件上执行的模型文件。
Hailo C++ API
基本概念
Hailo 跑模型的方式,是先建立一个 Vdevice,并使用 HEF 中的数据去设定这个 Vdevice,相当于有了一个 network group,这个 network group 中有将转换好的模型绑定在 Vdevice 上。
auto vdevice = VDevice::create();
auto network_group = configure_network_group(*vdevice.value());
设定输入输出数据流
接下来根据 network group 中模型输入输出数据的 shape、格式,为模型建立对应的 input、output stream。
这边设定格式 = HAILO_FORMAT_TYPE_UINT8,是因为输入数据是 RGB888 的图片,值域是 0~255。 默认的数据顺序是 NHWC,也就是 opencv 读取图片的顺序,如果确定和自己要使用的输入顺序一样,就不用特别修改。
也可以在建立 Vstream 后,印出设定好的参数来确认,例如像图片格式或量化信息。
# get parameters
auto input_vstream_params = network_group.value()->make_input_vstream_params({}, HAILO_FORMAT_TYPE_UINT8, HAILO_DEFAULT_VSTREAM_TIMEOUT_MS, HAILO_DEFAULT_VSTREAM_QUEUE_SIZE);
# set parameters if need
# ...
# default setting of dimension order is HAILO_FORMAT_ORDER_NHWC
# create input stream
auto input_vstreams = VStreamsBuilder::create_input_vstreams(*network_group.value(), *input_vstream_params);
# print parameters
auto quant_info = input_vstreams.value()[0].get_quant_infos();
std::cout << "quant info" << quant_info[0].qp_zp << quant_info[0].qp_scale << std::endl;
auto buf_info = input_vstreams.value()[0].get_user_buffer_format();
std::cout << "buf info" << buf_info.type << buf_info.order << std::endl;
后处理
再来就是板端输出的数据。 和Dataflow compiler输出的形状(80, 5, 100)不同,板端输出的预测数据是按照物体类别。
第一笔数据会是第一个类别预测框的数量。 如果是 0 的话,那下一笔数据就是第二个类别预测框的数量; 如果是 n 的话,代表这个类别预测出 n 个框,就往后取 n*5 笔资料,框的 5 笔资料的顺序是 y min, x min, y max, 分类分数。 重复持续这个步骤直到遍历完所有类别。
另外输入图片前面有经过padding,所以在计算坐标时要将padding的宽高减掉,换算成原本图片的比例,才是正确的坐标。
while (class_id<NUM_CLS)
{
// Lets check how many prediction we have for current class
size_t bbox_num = (size_t)host_data[infer_result_ptr];
if (bbox_num)
{
std::cout << "class id=" << class_id << ", bbox num=" << bbox_num << std::endl;
// point to first bbox of this class
infer_result_ptr++;
// For each box lets obtain its value
for (size_t i = 0; i < bbox_num; i++)
{
float y_min = (host_data[infer_result_ptr++]* MODEL_H - pad_h) / IN_H * IMG_H;
float x_min = (host_data[infer_result_ptr++]* MODEL_W - pad_w) / IN_W * IMG_W;
float y_max = (host_data[infer_result_ptr++]* MODEL_H - pad_h) / IN_H * IMG_H;
float x_max = (host_data[infer_result_ptr++]* MODEL_W - pad_w) / IN_W * IMG_W;
float score = host_data[infer_result_ptr++];
printf("class=%d, score=%.2f, x1y1x2y2=(%.0f,%.0f,%.0f,%.0f)\\n",
class_id, score, x_min, y_min, x_max, y_max);
}
class_id++;
}
else
{
// No box, let's move pointer to point to next class
// std::cout << "class id=" << class_id << ", bbox num=" << bbox_num << std::endl;
infer_result_ptr++;
class_id++;
}
}
板边缘结果
输入的图片是以下这张:
经过 resize 后的图片 / 经过 padding 之后的图片分别是以下这样:
印版导出结果:
root@NVTEVM:/mnt/sd/hailo$ ./vstreams_example
input dimension order:0
quant info01
buf info11
out info:801000
is nms:1
out frame size=160320
in frame size=691200, read count=1, data size=691200
host data size: 160320
class id=0, bbox num=1
class=0, score=0.653584, x1y1x2y2=(411.295105,176.469574,464.450531,335.425629)
class id=56, bbox num=4
class=56, score=0.873893, x1y1x2y2=(361.553802,245.822861,414.377930,359.939880)
class=56, score=0.873893, x1y1x2y2=(293.240021,244.682877,352.341064,361.477722)
class=56, score=0.804128, x1y1x2y2=(406.900665,246.173965,441.395111,350.217346)
class=56, score=0.229641, x1y1x2y2=(605.263062,344.131622,639.606628,401.352020)
class id=58, bbox num=2
class=58, score=0.618550, x1y1x2y2=(225.565735,199.580795,267.379395,240.284622)
class=58, score=0.437059, x1y1x2y2=(333.365936,198.335266,369.140442,258.700653)
class id=60, bbox num=3
class=60, score=0.594344, x1y1x2y2=(319.164978,253.414352,440.177490,361.077820)
class=60, score=0.480047, x1y1x2y2=(460.838989,392.677612,640.098938,474.594635)
class=60, score=0.400020, x1y1x2y2=(321.528564,255.731003,389.850616,362.502838)
class id=62, bbox num=2
class=62, score=0.903268, x1y1x2y2=(5.459776,187.548935,154.427689,295.019165)
class=62, score=0.308433, x1y1x2y2=(557.459534,235.226852,639.649902,320.040375)
class id=72, bbox num=2
class=72, score=0.525070, x1y1x2y2=(489.745239,193.772583,512.715515,322.100586)
class=72, score=0.282730, x1y1x2y2=(445.716278,191.540573,512.652588,321.692108)
class id=74, bbox num=1
class=74, score=0.596326, x1y1x2y2=(448.367432,135.705215,461.649811,159.726837)
class id=75, bbox num=5
class=75, score=0.763001, x1y1x2y2=(549.617371,334.043182,587.285828,452.867737)
class=75, score=0.477802, x1y1x2y2=(241.101868,221.628113,253.103348,239.250519)
class=75, score=0.414836, x1y1x2y2=(167.387421,263.066193,185.631714,302.091888)
class=75, score=0.318535, x1y1x2y2=(351.765869,243.634598,362.438721,260.351318)
class=75, score=0.211122, x1y1x2y2=(360.254456,241.549683,374.494354,260.354523)
Inference finished successfully
root@NVTEVM:/mnt/sd/hailo$
将坐标画到原本的图上: