3.17. Sunrise camera 开发说明

3.17.1. Sunrise camera 系统设计

3.17.1.1. 系统框图

Sunrise camera 实现智能摄像机、智能分析多种应用方案。

Sunrise camera 源码包括用户操作的 WebPages、通信模块层、功能模块层;本文档主要介绍三个模块设计。

Hal 层模块包括多媒体相关模块调用接口库, BPU 模块推理等;

Kernel 版本包含标准驱动基础上,系统 BSP。

软件框图如下所示:

image-20220307205702988

3.17.1.2. 微核设计

微核架构( microkernel architecture )又称为“插件架构”( plug-in architecture ),指软件内核相对小,主要功能业务逻辑通过插件实现。

内核( core )通常包含系统运行最小功能。插件互相独立的,插件之间通信,应该减少最低,避免出现互相依赖问题。

3.17.1.3. 架构优缺点

优点

良好功能延伸性,需要什么功能,开发插件即可。

功能之间隔离的,插件可以独立加载卸载,容易部署。

定制性高,适应不同开发需要。

可以渐进式开发,逐步添加功能。

缺点

扩展性差,内核通常一个独立单元,不容易做成分布式。

开发难度相对高,因为涉及插件内核通信,以及插件登记。

3.17.2. Sunrise camera 架构视图

3.17.2.1. 模块划分

模块 目录 描述
事件总线模块 communicate 实现模块事件注册、事件接收、事件分发
公共模块 common 公共操作函数, log/lock,内存环形缓冲区,线程操作,队列操作
Camera 模块 Platform 芯片平台相关代码,实现硬件差异部分封装
对外交互模块 Transport 设备外接交互部分, rtspserver、 websocket 等
主程序入口 Main Main 函数入口

顶层代码结构

.
├── build.sh			# 当本源码放到 BPS 的 PlatformSDK/unittest 目录下,在 lunch 编译环境后,可以使用本编译脚本
├── common				# 公共库模块代码
├── communicate			# 事件总线模块
├── config				# 编译配置目录
├── docs				# 用户使用文档和开发文档
├── main				# 主入口程序
├── Makefile			# 编译脚本
├── makefile.param		# 编译配置
├── Platform			# Camera 模块,平台、应用场景代码,芯片 IP 相关代码都在本目录下实现
├── start_app.sh	    # 设备上的启动脚本,可以配置成上电自启动
├── Transport			# rtspserver 和 websocket 模块代码实现
└── WebServer			# lighttpd 程序、配置和 web 页面

编译

  1. 检查是否已经安装对应交叉编译工具链,一般放在 BSP 资料的 toolchain 目录下,详细配置参考 BSP 开发手册。

  2. 安装交叉编译工具链后,可任意目录执行 make 编译。在源码当前目录生成 sunrise_camera 目录。打包 sunrise_camera 目录, WebServer 目录 和 start_app.sh 三个文件下载设备运行。

3.17.2.2. 事件总线模块( communicate )

概述

事件总线模块,最小运行单位;根据编译选项调用不同模块注册接口函数,并且完成不同模块 CMD 的接收分发。

模块交互时,接收的 CMD 如果已经注册使能,则中转受理模块处理,处理完成请求模块返回处理结果。

模块交互时,接收的 CMD 没有注册或者未使能,则 CMD 调用失败。

功能描述

  1. 模块插件静态插拔控制

  2. 模块 CMD 指令中转

image-20220307211044226

示例:

camera 子模块定义了 SDK_CMD_CAMERA_GET_CHIP_TYPE 命令,调用 camera_cmd_register 函数注册该 CMD 后,当 websocket 子模块收到 web 页面请求获取芯片类型时, websocket 模块可以通过以下代码调用 camera 子模块接口。

整个过程如下所示:

image-20220307211103942

模块代码结构

.
├── include
│   ├── sdk_common_cmd.h			# 定义系统中所有子模块的 CMD   ├── sdk_common_struct.h		    # 定义每个 CMD 对应使用到的数据结构   └── sdk_communicate.h			# 定义本模块接口函数
├── Makefile
└── src
    └── sdk_communicate.c			# 接口代码实现

接口描述

sdk_globle_prerare

各子模块的 xxx_cmd_register() 函数集中放到这个函数中,主程序启动时,通过调用接口所有模块需要注册使的 CMD 注册子系统中。

每个模块实现 xxx_cmd_register(),在函数实现模块 CMD 注册。这是整个系统能够正常运行基本前提。

示例:

image-20220307211216299

sdk_cmd_register

CMD 注册接口。

sdk_cmd_unregister

CMD 注销接口。

sdk_cmd_impl

模块通过调用接口实现调用其他模块实现接口功能。

3.17.2.3. 公共模块( common )

概述

程序公共库类,包含限于日志操作、锁操作、线程封装、环形缓冲区操作、 cJSON、 base64 ;

模块主要编程使用公共类、公共函数进行封装;避免相同操作函数实现多处出现。

模块更新影响所有模块,需要谨慎操作。

功能描述

模块代码结构

.
├── Makefile					# 编译脚本
├── makefile.param
└── utils
    ├── include				    # 头文件
       ├── aes256.h
       ├── base64.h
       ├── cJSON_Direct.h
       ├── cJSON.h
       ├── cmap.h
       ├── common_utils.h
       ├── cqueue.h
       ├── gen_rand.h
       ├── lock_utils.h
       ├── mqueue.h
       ├── mthread.h
       ├── nalu_utils.h
       ├── sha256.h
       ├── stream_define.h
       ├── stream_manager.h
       └── utils_log.h
    ├── Makefile
    └── src                      # 实现源码
        ├── aes256.c
        ├── base64.c
        ├── cJSON.c
        ├── cJSON_Direct.c
        ├── cmap.c
        ├── common_utils.c
        ├── cqueue.c
        ├── gen_rand.c
        ├── lock_utils.c
        ├── mqueue.c
        ├── mthread.c
        ├── nalu_utils.c
        ├── sha256.c
        ├── stream_manager.c
        └── utils_log.c

3.17.2.4. Platform 模块

概述

模块主要包括:视频编码、 ISP 控制、图像控制、 OSD 水印、抓拍、视频输出、算法运算等。

模块内部结构如下:

api_vpp 作为模块入口,定义支持的 CMD 命令集;

solution_handle 完成应用配置读写、场景接口赋值;

vpp_camera_impl、 vpp_box_impl 实现应用场景功能;

vp_wrap 实现多媒体模块接口封装;

bpu_wrap 模块实现算法推理接口后处理方法封装。

image-20220307211420601

功能描述

新增一个应用场景实现,只要实现 vpp_ops_t 结构定义接口即可。

typedef struct vpp_ops {
	int (*init_param)(void); // 初始化 VIN、 VSE、 VENC、 BPU 等模块的配置参数
	int (*init)(void); // sdk 初始化,根据配置初始化
	int (*uninit)(void); // 反初始化
	int (*start)(void); // 启动媒体相关的各个模块
	int (*stop)(void); // 停止
	// 本模块支持的 CMD 都通过以下两个接口简直实现
	int (*param_set)(SOLUTION_PARAM_E type, char* val, unsigned int length);
	int (*param_get)(SOLUTION_PARAM_E type, char* val, unsigned int* length);
} vpp_ops_t;

启动一个应用方案(以启动 vpp_camera 为例)的流程如下:

image-20220307211524720

其他模块初始化、启动流程可以参考流程图。

模块代码结构

代码路径: Platform/x5

.
├── api                                   # CMD 注册
├── bpu_wrap                              # bpu 算法接口使用封装
├── main                                  # CMD 注册的实际功能接口实现
├── Makefile                              # 编译脚本
├── makefile.param                        # 编译配置
├── model_zoom                            # 算法模型仓库
├── test_data                             # 存放测试用的视频码流文件和程序配置文件
├── vpp_impl                              # 应用方案的功能实现
├── vp_sensors -> ../../../vp_sensors/    # Camera Sensor 配置代码,本目录下的代码与其他 sample 模块共用
└── vp_wrap                               # 多媒体接口的封装

3.17.2.5. 对外交互模块( Transport )

概述

遵循传输协议终端平台交互具体模块;包含通过网络、 rtspserver 和 websocket 通信模块;

交互模块模块交互部分,需要严格遵守设计约定。在其他模块请求数据通过定义模块 CMD 进行处理。

Media Server 模块

模块对 MediaServer 的封装实现,把 MediaServer 封装成 init、 create、push_data、destroy 和 unint 等几个简单接口。目前支持 RTSP 协议。

模块启动使用可以参考 主程序入口 章节流程介绍。

Wesocket Server 模块

模块完成与 web 上操作交互,在 web 上进行相应操作后, websocket server 接收相应 kind 的命令参数,在代码 handle_user_massage.c 的 handle_user_msg 函数处理进行相应功能处理,如果添加交互命令,请函数增加。

目前支持交互命令:场景切换、场景参数获取设置、获取芯片类型、 h264 码率设置、系统时间同步、 websocket 码流拉流停止等。

3.17.2.6. 主程序入口( main )

概述

主程序入口,模块启动。

当前基本模块启动顺序如下,需要注意模块启动顺序需要根据模块依赖关系顺序启动。

执行流程

image-20220307211837929

3.17.2.7. WebServer

概述

模块提供 lighttpd 实现 web httpd 服务,让用户可以直接通过浏览器预览视频配置应用场景。

功能描述

提供 lighttpd 编译说明,依赖库,编译开发板执行程序,并且提供一份配置配置文件,在 lighttpd/webpages 目录存放 web 页面、 css、 js 程序。

模块代码结构

.
├── fcgi					# fcgi 模块库   ├── include
│   ├── lib
│   └── version.txt
├── sc_lighttpd				# lighttpd   ├── cache
│   ├── cgi-bin
│   ├── config				# 能直接使用的配置文件   ├── lib
│   ├── log
│   ├── sbin
│   ├── server.pem
│   ├── share
│   ├── socktes
│   ├── upload
│   ├── vhosts
│   └── webpages			# web 页面、 css、 js 文件
├── pcre					# lighttpd 对其有依赖   ├── include
│   ├── lib
│   └── version.txt
├── README.txt
└── start_lighttpd.sh		# 单独启动 WebServer 的脚本

3.17.3. 使用 BPU 进行算法推理

3.17.3.1. 概述

模块完成算法模型加载、数据处理、推理、算法后处理返回 json 格式结果。

模块运行时序如下:

image-20220307213539978

3.17.3.2. 添加一个模型流程

当前 sunrise_camera 仅支持少量算法模型运行,在实际应用不可避免其他模型测试效果,本描述新增一个算法模型基本步骤。

项目 源码文件 说明
准备算法模型 放到 Platform/x5/model_zoom 目录下(.bin, .hbm) 目录添加可以开发板运行定点算法模型
添加模型配置 bpu_wrap.c 在 bpu_models 中添加模型名称、指定算法模型文件,推理处理函数接口
推理线程处理函数 bpu_wrap.c 处理函数准备输出 tensor,调用 hbDNNInfer 推理,得到结果后,把结果放入 output 队列。示例:inference_yolov5s
后处理线程函数 bpu_wrap.c 从 output 队列取出算法结果,调用后处理方法进行处理,得到 json 格式结果字符串。如果设置回调函数,则调用回调。示例:post_process_yolov5s
后处理代码 yolov5_post_process.cpp 算法模型对应后处理方法,比如分类模型返回的 id 和类型对应起来,检测模型检测映射原始图像位置上。
Web 页面增加渲染处理 index.js 必须

准备算法模型

开发板支持运行算法模型两种后缀名, bin 文件和 hbm 文件:

  1. bin 模型:通过算法工具链转换( PTQ )得到模型,以 bin 作为后缀

  2. hbm 模型:通过定点模型训练框架( QAT )直接训练得到算法模型

算法模型详细开发说明参考《量化工具链开发指南》文档。

添加初始化过程

在 bpu_wrap.c 中的 bpu_models 数组定义新算法模型,添加模型名称、指定算法模型文件,推理处理函数接口:

bpu_model_descriptor bpu_models[] = {
	{
		.model_name = "yolov5s",                                   // 算法名称, web 客户端上会显示这个名称给用户选择
		.model_path = "../model_zoom/yolov5s_672x672_nv12.bin",    // 算法模型文件
		.inference_func = inference_yolov5s,                       // 推理函数
		.post_proc_func = post_process_yolov5s                     // 后处理函数,如果这部分比较简单,可以合并到推理函数中一起处理
	},
	... ( 省略 ) ...
};

算法任务启动时,根据 model_name 启动相应推理线程算法后处理线程。

推理线程处理函数

推理线程实现输出结果 tensor 的准备;从 yuv 队列取出 yuv 数据,调用 HB_BPU_runModel 推理得到算法结果;再算法结果推进 output Queue,供后处理使用。

static void *inference_yolov5s(void *ptr)
{
	// 准备模型输出节点 tensor, 5 组输出 buff 轮转,简单处理,理论上后处理的速度是要比算法推理更快的
	hbDNNTensor output_tensors[5][3];
	int32_t cur_ouput_buf_idx = 0;
	for (i = 0; i < 5; i++) {
		ret = prepare_output_tensor(output_tensors[i], dnn_handle);
		if (ret) {
			SC_LOGE("prepare model output tensor failed");
			return NULL;
		}
	}

	while (privThread->eState == E_THREAD_RUNNING) {
		// 获取需要进行算法运算的图像数据,格式基本都是 NV12 的 yuv
		if (mQueueDequeueTimed(&bpu_handle->m_input_queue, 100, (void**)&input_tensor) != E_QUEUE_OK)
			continue;

        // 模型推理 infer
		hbDNNInferCtrlParam infer_ctrl_param;
		HB_DNN_INITIALIZE_INFER_CTRL_PARAM(&infer_ctrl_param);
		ret = hbDNNInfer(&task_handle,
				&output,
				&input_tensor->m_dnn_tensor,
				dnn_handle,
				&infer_ctrl_param);

		// 后处理数据入队
		Yolo5PostProcessInfo_t *post_info;
		post_info = (Yolo5PostProcessInfo_t *)malloc(sizeof(Yolo5PostProcessInfo_t));
		… …
		mQueueEnqueue(&bpu_handle->m_output_queue, post_info);
		cur_ouput_buf_idx++;
		cur_ouput_buf_idx %= 5;
}
}

后处理线程函数

后处理线程实现从 output queue 中获取算法结果;调用处理函数;调用算法任务回调函数处理算法结果(当前回调作用直接发送给 web,在 web 上渲染算法结果)。


static void *post_process_yolov5s(void *ptr)
{
	tsThread *privThread = (tsThread*)ptr;
	Yolov5PostProcessInfo_t *post_info;

	mThreadSetName(privThread, __func__);

	bpu_handle_t *bpu_handle = (bpu_handle_t *)privThread->pvThreadData;
	while (privThread->eState == E_THREAD_RUNNING) {
		// 从后处理数据队列中获取数据
		if (mQueueDequeueTimed(&bpu_handle->m_output_queue, 100, (void**)&post_info) != E_QUEUE_OK)
			continue;

		char *results = Yolov5PostProcess(post_info); // 进行后处理,比如得到检测框、过滤低置信度的结果、把检测框的宽高缩放为显示视频的宽高等

		if (results) {
			if (NULL != bpu_handle->callback) {
				// 算法任务结果回调,当前的应用场景是把算法结果通过 websocket 发送给浏览器
				bpu_handle->callback(results, bpu_handle->m_userdata);
			} else {
				SC_LOGI("%s", results);
			}
			free(results);
		}
		if (post_info) {
			free(post_info);
			post_info = NULL;
		}
	}
	mThreadFinish(privThread);
	return NULL;
}

后处理代码

每个算法模型建议添加一个后处理方法:

  • yolov5 : yolo5_post_process.cpp

  • mobilenet_v2 :分类模型处理简单,就是把 id 和类型进行对应

后处理方法中要完成以下几件事情:

分析输出结果,分类模型完成类型匹配,检测模型完成算法结果框到原始图像坐标映射等;

算法结果处理成 json 格式。为了方便使用,在函数进行 json 格式化,比如传导给 web,这里输出结果可以直接使用。

// Yolov5 输出 tensor 格式
// 3 次下采样得到三组缩小后的 gred,然后对每个 gred 进行三次预测,最后输出结果
char* Yolov5PostProcess(Yolov5PostProcessInfo_t *post_info) {
	hbDNNTensor *tensor = post_info->output_tensor;

	std::vector<Detection> dets;
	std::vector<Detection> det_restuls;
	uint32_t i = 0;
	char *str_dets;

	// 根据置信度过滤检测框
	for (i = 0; i < default_yolov5_config.strides.size(); i++) {
		_postProcess(&tensor[i], post_info, i, dets);
	}
	// 计算交并比来合并检测框,传入交并比阈值 (0.65) 和返回 box 数量 (5000)
	yolov5_nms(dets, post_info->nms_threshold, post_info->nms_top_k, det_restuls, false);
	std::stringstream out_string;

	// 算法结果转换成 json 格式
	out_string << "\"timestamp\": ";
	unsigned long timestamp = post_info->tv.tv_sec * 1000000 + post_info->tv.tv_usec;
	out_string << timestamp;
	out_string << ",\"detection_result\": [";
	for (i = 0; i < det_restuls.size(); i++) {
		auto det_ret = det_restuls[i];
		out_string << det_ret;
		if (i < det_restuls.size() - 1)
		out_string << ",";
	}
	out_string << "]" << std::endl;

	str_dets = (char *)malloc(out_string.str().length() + 1);
	str_dets[out_string.str().length()] = '\0';
	snprintf(str_dets, out_string.str().length(), "%s", out_string.str().c_str());
	return str_dets;
}

Web 页面增加渲染处理

部分必须实现部分,在当前实现中,所有算法结果渲染到 web 页面上,数据流程算法后处理返回 json 格式结果后,通过 websocket 发送结果信息到 web 页面,在 web 实现一个画布,在画布渲染算法结果。

// 通用的算法回调函数,目前都是通过 websocket 想 web 上发送
int32_t bpu_wrap_general_result_handle(char *result, void *userdata)
{
	int32_t ret = 0;
	int32_t pipeline_id = 0;
	char *ws_msg = NULL;

	if (userdata)
		pipeline_id = *(int*)userdata;

	// json 算法结果添加标志信息
	// 分配内存
	ws_msg = malloc(strlen(result) + 32);
	if (NULL == ws_msg) {
		SC_LOGE("Failed to allocate memory for ws_msg");
		return -1;
	}
	sprintf(ws_msg, "{\"kind\":10, \"pipeline\":%d,", pipeline_id + 1);
	strcat(ws_msg, result);
	strcat(ws_msg, "}");

	ret = SDK_Cmd_Impl(SDK_CMD_WEBSOCKET_SEND_MSG, (void*)ws_msg);
	free(ws_msg);
	return ret;
}

WebServer/sc_lighttpd/webpages/js/index.js 文件已经支持通用分类目标检测算法处理逻辑,如果需要渲染类型算法模型结果,需要修改 js 代码。

// web 页面上 websocket 接收数据的处理函数
function handle_ws_recv(params) {
	{
	... ( 省略 ) ...
	} else if (params.kind == REQUEST_TYPES.ALOG_RESULT) {
		// console.log(params);
		// 分类算法算法的帧率计数
		if (params.classification_result) {
			socket.smart_fps[params.pipeline]++;
		}
		// 目标检测算法的帧率计数
		if (params.detection_result) {
			socket.smart_fps[params.pipeline]++;
		}
		// 将 params 放入相应的队列中,在 web 上的视频绘制时会调用根据时间戳来完成视频和算法结果的同步显示
		// 算法结果的渲染请查阅 processVideoFrame 函数的实现
		g_alog_result_queue_array[params.pipeline].push(params);
	}
	... ( 省略 ) ...
}