4354|3

11

帖子

0

TA的资源

一粒金砂(中级)

楼主
 

#AI挑战营终点站#基于RV1106手写数字识别部署 [复制链接]

本帖最后由 xianhangCheng 于 2024-5-30 15:57 编辑

实物图片


上图包括RV1106开发板+SC3336摄像头,这板子挺精致,自带的 SPI NAND FLASH,并在出厂时搭载了测试系统,收到板子后需要自行烧录网盘系统。
这个价格与性能来说,性价比很高,如此小巧的板子,板载256MB DDR3L,还可以直接运行yolov5。唯一的缺点就是在运行推理时发热挺严重的。

 

准备


参考手册:Luckfox官网教程SPI NAND Flash 镜像烧录 | LUCKFOX WIKI

提前准备工具:

        1.下载RK驱动助手 DriverAssitant(戳我下载)。

        2.下载和解压烧录工具(戳我下载)。

        3.下载镜像文件

链接已隐藏,如需查看请登录或者注册

        4.下载VLC media player官方 VLC 媒體播放器下載,最好的開放原始碼播放器 - VideoLAN

注意:在下载RK驱动助手和烧录工具时最好关闭防火墙和病毒与威胁防护,否则可能导致下述状况:

  1. 驱动程序无法使用;
  2. 无法识别出板子。

如下图:


开启板子和摄像头

烧录镜像:

参考:SPI NAND Flash 镜像烧录 | LUCKFOX WIKI

首先我们要对板子进行烧录,官方提供了linux的镜像。提供的有ubuntu的镜像和buildroot镜像。这里我们选择官方推荐的buildroot镜像,活动的板子是Max系列,我们选择luckfox_pico_pro_max_image镜像。

登录:

参考文件:SSH/Telnet 登录 | LUCKFOX WIKI

Luckfox Pico 系列的最新固件默认启用了 SSH。这里我们通过 USB 连接使用静态 IP 进行登录。

然后我们关闭防火墙,配置电脑 rndis网口的ip地址 为172.32.0.100 子网掩码 255.255.0.0,此时可以通过ssh 访问 172.32.0.93。用自带的 Powershell 终端输入口令进行直接登录。格式是 ssh 客户端用户名@服务器ip地址

ssh root@172.32.0.93

登录账号:root
登录密码:luckfox
静态IP地址:172.32.0.93

通过ls命令查看都有哪些文件。

 

系统将自动识别摄像头,生成 rkipc.ini 文件。
  1. 下载并安装 VLC media player 。

  2. 打开 VLC media player 软件,在媒体—>打开网络串流,输入默认的 IP 地址:rtsp://172.32.0.93/live/0

这样子就可以看到摄像头拍摄的画面了。

注意:为了后续的部署,我们需要关闭系统默认 rkipc 程序,执行 RkLunch-stop.sh 命令。

配置开发环境:

参考文件:SDK 环境部署(PC端) | LUCKFOX WIKI

这里较为复杂,我也不懂,跟着教程照搬就可以了。

编译与运行:

export LUCKFOX_SDK_PATH=<Your Luckfox-pico Sdk Path>
mkdir build
cd build
cmake ..
make && make install

将编译生成的luckfox_rtsp_opencv_demo/luckfox_rtsp_opencv 和、lib目录以及模型权重model.rknn都上传到 luckfox-pico 上,进入文件夹运行.

文件传输命令:

# 传输文件
scp model.rknn root@172.32.0.93:/root
# 传输文件夹
scp -r luckfox_rtsp_opencv_demo root@172.32.0.93:/root

注意:首次连接需要输入 yes 确认,然后输入密码 luckfox 开始传输
运行目录下应有 luckfox_rtsp_opencv lib model.rknn 三个文件.

赋予权限并运行。
chmod 755 luckfox_rtsp_opencv
./luckfox_rtsp_opencv ./model.rknn

代码实现细节:


定义结构体存储预测的数字及其概率

// 定义一个结构体储存预测到的数字和其对应的概率
struct Prediction
{
	int digit;
	float probability;
};


// 定义全局变量简单队列用于存储预测到的数字和其对应的概率
std::vector<Prediction> predictions_queue;

定义了加载模型的函数,用于从指定的二进制文件中加载模型数据,并确保在操作完成后释放资源。

// 函数名称: load_model
// 输入参数: filename - 要加载的模型文件名,model_size - 用于存储模型大小的指针
// 返回值: 加载成功则返回模型数据指针,失败则返回NULL
static unsigned char *load_model(const char *filename, int *model_size)
{
    // 以二进制模式打开文件
    FILE *fp = fopen(filename, "rb");
    if (fp == nullptr)
    {
        // 打开文件失败,输出错误信息并返回NULL
        printf("fopen %s fail!\\n", filename);
        return NULL;
    }

    // 获取文件长度
    fseek(fp, 0, SEEK_END);
    int model_len = ftell(fp);

    // 分配model_len字节大小的内存,类型为unsigned char
    unsigned char *model = (unsigned char *)malloc(model_len);

    // 重新定位文件指针到文件开始
    fseek(fp, 0, SEEK_SET);

    // 读取文件中的内容到分配的内存中
    if (model_len != fread(model, 1, model_len, fp))
    {
        // 读取失败,输出错误信息并释放内存
        printf("fread %s fail!\\n", filename);
        free(model);
        return NULL;
    }

    // 将实际读取到的模型长度赋给*model_size
    *model_size = model_len;

    // 关闭文件
    if (fp)
    {
        fclose(fp);
    }

    // 返回模型数据的内存地址
    return model;
}

定义函数find_digit_contour,在输入图像中查找数字的轮廓,并进行抖动减小和形状过滤。

// 函数名称:find_digit_contour
// 功能:在输入图像中查找数字的轮廓,并进行抖动减小和形状过滤
// 输入参数:image - 输入图像
// 返回值:bounding_box - 数字的边界框
cv::Rect find_digit_contour(const cv::Mat &image) {
   
   // 预处理图像
   cv::Mat gray, blurred, edged;
   cv::cvtColor(image, gray, cv::COLOR_BGR2GRAY); // 转换为灰度图像
   cv::GaussianBlur(gray, blurred, cv::Size(5, 5), 0); // 高斯模糊去噪
   cv::Canny(blurred, edged, 30, 150); // 边缘检测

   // 应用形态学操作
   cv::Mat kernel = cv::getStructuringElement(cv::MORPH_RECT, cv::Size(5, 5));
   cv::dilate(edged, edged, kernel); // 膨胀操作
   cv::erode(edged, edged, kernel); // 腐蚀操作

   // 查找轮廓
   std::vector<std::vector<cv::Point>> contours;
   cv::findContours(edged, contours, cv::RETR_EXTERNAL, cv::CHAIN_APPROX_SIMPLE);

   if (contours.empty()) {
       return cv::Rect();
   }

   // 找到最大的轮廓
   auto largest_contour = std::max_element(contours.begin(), contours.end(),
                                           [](const std::vector<cv::Point>& a, const std::vector<cv::Point>& b) {
                                               return cv::contourArea(a) < cv::contourArea(b);
                                           });

   // 轮廓面积过滤
   if (cv::contourArea(*largest_contour) < 10) {
       return cv::Rect();
   }

   // 轮廓形状过滤
   cv::Rect bounding_box = cv::boundingRect(*largest_contour);
   float aspect_ratio = static_cast<float>(bounding_box.width) / bounding_box.height;
   if (aspect_ratio < 0.2 || aspect_ratio > 3) {
       return cv::Rect();
   }

   // 轮廓稳定性检测
   static std::vector<cv::Rect> prev_bounding_boxes;
   if (prev_bounding_boxes.size() > 5) {
       prev_bounding_boxes.erase(prev_bounding_boxes.begin());
   }
   prev_bounding_boxes.push_back(bounding_box);
   if (prev_bounding_boxes.size() == 5) {
       float avg_width = 0.0;
       float avg_height = 0.0;
       for (const auto& box : prev_bounding_boxes) {
           avg_width += box.width;
           avg_height += box.height;
       }
       avg_width /= prev_bounding_boxes.size();
       avg_height /= prev_bounding_boxes.size();
       float width_diff = std::abs(bounding_box.width - avg_width) / avg_width;
       float height_diff = std::abs(bounding_box.height - avg_height) / avg_height;
       if (width_diff > 0.1 || height_diff > 0.1) {
           return cv::Rect();
       }
   }

   // 扩大边界框,以包含更多相关像素
   bounding_box.x = std::max(0, bounding_box.x - 15);
   bounding_box.y = std::max(0, bounding_box.y - 15);
   bounding_box.width = std::min(image.cols - bounding_box.x, bounding_box.width + 30);
   bounding_box.height = std::min(image.rows - bounding_box.y, bounding_box.height + 30);

   // 返回最大轮廓的边界框
   return bounding_box;
}

这段代码的主要功能是在输入图像中查找数字的轮廓,并进行抖动减小和形状过滤。主要包括以下步骤:

  1. 图像被预处理,包括灰度化、高斯模糊和边缘检测。
  2. 形态学操作(膨胀和腐蚀)用于去除噪声和连接轮廓。
  3. 查找所有外部轮廓,并选择最大的轮廓。对于最大的轮廓,进行面积和形状过滤,以减少不必要的小轮廓。
  4. 对图像边框每个方向扩大15个像素,以包含更多相关像素。
  5. 返回最终的边界框。

定义图像预处理函数,其目的是对输入的数字区域图像进行预处理,以便于后续的数字识别。

// 函数名称:preprocess_digit_region
// 功能:对输入的数字区域图像进行预处理,包括灰度转换、二值化、颜色反转和大小调整
// 输入参数:region - 输入的数字区域图像
// 返回值:resized - 预处理后的图像,大小为28x28,像素值归一化为0到1之间的浮点数
cv::Mat preprocess_digit_region(const cv::Mat ®ion) {
    // 将输入图像转换为灰度图像
    cv::Mat gray;
    cv::cvtColor(region, gray, cv::COLOR_BGR2GRAY);

    // 使用OTSU方法自动确定阈值,将灰度图像二值化
    cv::threshold(gray, gray, 0, 255, cv::THRESH_BINARY | cv::THRESH_OTSU);

    // 再次应用阈值处理,将灰度值低于127的像素设置为0,高于200的像素设置为255
    cv::threshold(gray, gray, 127, 255, cv::THRESH_BINARY_INV);

    // 创建一个与gray相同大小的Mat对象,用于存储颜色反转后的图像
    cv::Mat bitwized = cv::Mat::zeros(gray.size(), gray.type());

    // 对图像进行颜色反转,将黑色变成白色,白色变成黑色
    cv::bitwise_not(gray, bitwized);

    // 手动实现颜色反转,以验证bitwise_not函数的效果
    for (int i = 0; i < bitwized.rows; i++) {
        for (int j = 0; j < bitwized.cols; j++) {
            bitwized.at<uchar>(i, j) = 255 - bitwized.at<uchar>(i, j);
        }
    }

    // 将处理后的图像大小调整为28x28,使用INTER_AREA插值方法以保持图像细节
    cv::Mat resized;
    cv::resize(bitwized, resized, cv::Size(28, 28), 0, 0, cv::INTER_AREA);

    // 返回调整大小后的图像
    return resized;
}

这段代码的主要步骤包括:

  1. 将输入的彩色图像转换为灰度图像。
  2. 使用OTSU方法自动确定阈值,将灰度图像二值化。
  3. 再次应用阈值处理,调整图像的颜色分布。
  4. 对二值化后的图像进行颜色反转。
  5. 手动验证颜色反转的效果。
  6. 将图像大小调整为28x28,使用INTER_AREA插值方法以保持图像细节。
  7. 返回预处理后的图像

后处理阶段:

deqnt_affine_to_f32函数:将量化后的INT8数据转化为浮点数。

output_normalization函数:将模型的输出进行归一化,并计算输出的概率分布。

// 将量化的INT8数据转换为浮点数
// Parameters:
//   qnt: 量化后的整数数据
//   zp: 零点(zero point)值,用于零点偏移(zero-point offset)
//   scale: 缩放因子,用于缩放量化后的整数数据到浮点数范围
// Returns:
//   浮点数,表示经过反量化(dequantization)后的数据
static float deqnt_affine_to_f32(int8_t qnt, int32_t zp, float scale) {
    // 返回((float)qnt - (float)zp) * scale,即量化值减去零点值后乘以缩放因子
    return ((float)qnt - (float)zp) * scale;
}

// 将模型输出进行归一化,并计算输出的概率分布
// Parameters:
//   output_attrs: 输出张量属性,包含了零点(zero point)值和缩放因子等信息
//   output: 模型输出的数据,以INT8格式存储
//   out_fp32: 存储归一化后的浮点数输出数据
static void output_normalization(rknn_tensor_attr* output_attrs, uint8_t *output, float *out_fp32)
{
    int32_t zp = output_attrs->zp; // 从输出张量属性中获取零点值
    float scale = output_attrs->scale; // 从输出张量属性中获取缩放因子

    // 将INT8格式的输出数据进行反量化为浮点数,并进行存储
    for(int i = 0; i < 10; i ++)
        out_fp32[i] = deqnt_affine_to_f32(output[i],zp,scale);

    // 计算输出数据的L2范数
    float sum = 0;
    for(int i = 0; i < 10; i++)
        sum += out_fp32[i] * out_fp32[i];

    // 对归一化后的浮点数输出进行归一化处理,确保输出数据的范围在[0,1]之间
    float norm = sqrt(sum);
    for(int i = 0; i < 10; i++)
        out_fp32[i] /= norm;

    // 打印输出数据的值
    printf("\\n===================Output data values:===================\\n");
    for (int i = 0; i < 10; ++i)
    {
        printf("%f ", out_fp32[i]);
    }
    printf("\\n");

    // 找出最大概率对应的数字,并记录最大概率及其对应的数字
    float max_prob = -1.0;
    int predicted_digit = -1;
    // 计算最大值的索引
    for (int i = 0; i < 10; ++i)
    {
        if (out_fp32[i] > max_prob)
        {
            max_prob = out_fp32[i];
            predicted_digit = i;
        }
    }

    // 将预测的数字及其对应的概率记录到队列中
    predictions_queue.push_back({predicted_digit, max_prob});

    // 打印预测的数字与其对应的概率
    printf("========Predicted digit: %d, Probability: %.2f========\\n\\n", predicted_digit, max_prob);
}
  1. 使用deqnt_affine_to_f32函数将INT8格式的数据转换为浮点数。
  2. 在output_normalization函数中,对转换后的浮点数数据进行归一化处理,使其概率分布的总和为1。计算并打印每个数字的概率值,找出最大概率对应的数字,并将其预测结果和概率记录到队列中。

定义推理函数,主要用于处理输入的图像数据,将其转换为模型所需的格式,并进行推理以获取输出结果。包括以下功能:

  1. 获取模型的输入输出属性。
  2. 处理输入图像,将其转换为模型所需的格式。
  3. 创建并设置输入输出张量的内存。
  4. 运行模型推理。
  5. 处理输出结果,获取预测的数字。
  6. 释放分配的内存。
// 定义函数run_inference,接收一个cv::Mat类型的图像帧作为输入
int run_inference(cv::Mat &frame)
{
    int ret = 0; // 初始化返回值为0
    rknn_input_output_num io_num; // 定义结构体用于存储输入输出通道数

    // 获取模型的输入输出通道数
    rknn_query(ctx, RKNN_QUERY_IN_OUT_NUM, &io_num, sizeof(io_num));

    // 初始化输入属性数组
    rknn_tensor_attr input_attrs[io_num.n_input];
    memset(input_attrs, 0, io_num.n_input * sizeof(rknn_tensor_attr));
    for (uint32_t i = 0; i < io_num.n_input; i++)
    {
        input_attrs[i].index = i; // 设置输入属性的索引
        // 查询输入属性的详细信息
        ret = rknn_query(ctx, RKNN_QUERY_INPUT_ATTR, &(input_attrs[i]), sizeof(rknn_tensor_attr));
        if (ret < 0)
        {
            printf("rknn_init error! ret=%d\\n", ret);
            return -1; // 如果查询失败,返回错误
        }
        dump_tensor_attr(&input_attrs[i]); // 打印输入属性信息
    }

    printf("output tensors:\\n");
    // 初始化输出属性数组
    rknn_tensor_attr output_attrs[io_num.n_output];
    memset(output_attrs, 0, io_num.n_output * sizeof(rknn_tensor_attr));
    for (uint32_t i = 0; i < io_num.n_output; i++)
    {
        output_attrs[i].index = i; // 设置输出属性的索引
        // 查询输出属性的详细信息
        ret = rknn_query(ctx, RKNN_QUERY_NATIVE_OUTPUT_ATTR, &(output_attrs[i]), sizeof(rknn_tensor_attr));
        if (ret != RKNN_SUCC)
        {
            printf("rknn_query fail! ret=%d\\n", ret);
            return -1; // 如果查询失败,返回错误
        }
        dump_tensor_attr(&output_attrs[i]); // 打印输出属性信息
    }

    printf("Gray image size: %dx%d\\n", frame.rows, frame.cols);
    printf("Gray image type: %d\\n", frame.type());
    // 计算并分配用于存储调整大小后图像的内存
    int mem_size = MODEL_WIDTH * MODEL_HEIGHT * CHANNEL_NUM;
    unsigned char *resize_buf = (unsigned char *)malloc(mem_size);
    memset(resize_buf, 0, mem_size);

    // 创建输入张量内存
    rknn_tensor_mem *input_mems[1];
    input_attrs[0].type = input_type; // 设置输入类型
    input_attrs[0].fmt = input_layout; // 设置输入格式
    input_mems[0] = rknn_create_mem(ctx, input_attrs[0].size_with_stride);

    // 将输入数据复制到输入张量内存
    int width = input_attrs[0].dims[2];
    int stride = input_attrs[0].w_stride;
    if (width == stride)
    {
        memcpy(input_mems[0]->virt_addr, frame.data, width * input_attrs[0].dims[1] * input_attrs[0].dims[3]);
    }
    else
    {
        int height = input_attrs[0].dims[1];
        int channel = input_attrs[0].dims[3];
        uint8_t *src_ptr = frame.data;
        uint8_t *dst_ptr = (uint8_t *)input_mems[0]->virt_addr;
        int src_wc_elems = width * channel;
        int dst_wc_elems = stride * channel;
        for (int h = 0; h < height; ++h)
        {
            memcpy(dst_ptr, src_ptr, src_wc_elems);
            src_ptr += src_wc_elems;
            dst_ptr += dst_wc_elems;
        }
    }

    // 创建输出张量内存
    rknn_tensor_mem *output_mems[io_num.n_output];
    for (uint32_t i = 0; i < io_num.n_output; ++i)
    {
        output_mems[i] = rknn_create_mem(ctx, output_attrs[i].size_with_stride);
    }

    // 设置输入张量内存
    ret = rknn_set_io_mem(ctx, input_mems[0], &input_attrs[0]);
    if (ret < 0)
    {
        printf("rknn_set_io_mem fail! ret=%d\\n", ret);
        return -1;
    }

    // 设置输出张量内存
    for (uint32_t i = 0; i < io_num.n_output; ++i)
    {
        ret = rknn_set_io_mem(ctx, output_mems[i], &output_attrs[i]);
        if (ret < 0)
        {
            printf("rknn_set_io_mem fail! ret=%d\\n", ret);
            return -1;
        }
    }

    // 运行推理
    ret = rknn_run(ctx, nullptr);
    if (ret < 0)
    {
        printf("rknn_run failed! %s\\n", ret);
        return -1;
    }

    uint8_t  *output= (uint8_t*)malloc(sizeof(uint8_t) * 10); 
    float *out_fp32 = (float*)malloc(sizeof(float) * 10); 
    output = (uint8_t *)output_mems[0]->virt_addr;

    // 获取预测的数字
    output_normalization(&output_attrs[0], output, out_fp32);

    // 释放内存
    rknn_destroy_mem(ctx, input_mems[0]);
    for (uint32_t i = 0; i < io_num.n_output; ++i)
    {
        rknn_destroy_mem(ctx, output_mems[i]);
    }	
}

主函数主要目的是在实时视频流中检测并显示数字,同时将视频编码后发送到rtsp服务器。包括以下步骤:

  1.  初始化必要的模块,如rkaiq、rkmpi、rtsp等。
  2. 通过rkmpi绑定Vi(Video Interface)和VPSS(Video Processing System)模块,用于视频处理和编码。
  3. 初始化VENC(Video Encoder)模块,用于视频编码。
  4. 加载并初始化rknn模型,进行手写数字识别。
  5. 在一个循环中,从VPSS获取帧,进行物体识别和数字预测,然后编码成H264并发送到rtsp流。
  6. 更新帧率(fps)并显示在图像上。
  7. 在每次循环结束时,释放帧资源和内存。
  8. 最后,释放所有模块的资源,销毁rknn模型,并退出程序。

首先我们需要,定义图像分辨率

int width = 640;
int height = 480;

在这里,我们主要介绍while循环里的逻辑代码

1.opencv获取摄像头帧,并调用find_digit_contour函数在其中查找数字的轮廓,并进行抖动减小和形状过滤。

void *data = RK_MPI_MB_Handle2VirAddr(stVpssFrame.stVFrame.pMbBlk);
cv::Mat frame(height, width, CV_8UC3, data);

cv::Rect digit_rect = find_digit_contour(frame);

2.if (digit_rect.area() > 0),opencv截取数字的区域并进行预处理,然后将预处理后的数据送入run_inference函数进行数据推理。

cv::Mat digit_region = frame(digit_rect);
cv::Mat preprocessed = preprocess_digit_region(digit_region);
int prediction = run_inference(preprocessed);

3.对当前帧上显示识别结果。

// 从predictions_queue中获取预测到的数字和其对应的概率
//检查predictions_queue是否为空,如果不为空,则取出最后一个元素作为当前帧的识别结果。
if (!predictions_queue.empty())
{
	Prediction prediction = predictions_queue.back();
					
	cv::rectangle(frame, digit_rect, cv::Scalar(0, 255, 0), 2);
	// 在图像上显示预测结果,显示字号为1,颜色为红色,粗细为2
	cv::putText(frame, std::to_string(prediction.digit), cv::Point(digit_rect.x, digit_rect.y - 10),
	cv::FONT_HERSHEY_SIMPLEX, 1, cv::Scalar(255, 0, 0), 2);

	// 在图像上显示预测概率
	cv::putText(frame, std::to_string(prediction.probability), cv::Point(digit_rect.x+ 30, digit_rect.y - 10),
	cv::FONT_HERSHEY_SIMPLEX, 0.7, cv::Scalar(230, 0, 0), 2);

	// 从predictions_queue中删除最旧的元素,以便下一次迭代时可以取出新的识别结果
	predictions_queue.pop_back();
}

4.最后当前帧图像数据复制到rtsp帧中

memcpy(data, frame.data, width * height * 3);

 

实际演示效果


没有设备固定摄像头,一边录屏一边手拿摄像头,视频有点晃动。

但是数字识别的效果还是不错的,也没有数字识别错误的情况。数字0~9全部识别正确。

20240530_004503

附参考代码:
链接已隐藏,如需查看请登录或者注册

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 
 
 

 

最新回复

不过这个只能一次识别一个,要是画面中的都能识别就好了   详情 回复 发表于 2024-6-1 15:11
点赞 关注

回复
举报

7175

帖子

2

TA的资源

版主

沙发
 

大佬分析的不错,很有参考价值!

 
 

回复

7175

帖子

2

TA的资源

版主

板凳
 

我运行模型的识别率好低,大佬是如何调整参数的,可以分析一下吗~

 
 
 

回复

6449

帖子

10

TA的资源

版主

4
 

不过这个只能一次识别一个,要是画面中的都能识别就好了

 
个人签名

在爱好的道路上不断前进,在生活的迷雾中播撒光引

 
 

回复
您需要登录后才可以回帖 登录 | 注册

随便看看
查找数据手册?

EEWorld Datasheet 技术支持

相关文章 更多>>
关闭
站长推荐上一条 1/8 下一条

 
EEWorld订阅号

 
EEWorld服务号

 
汽车开发圈

About Us 关于我们 客户服务 联系方式 器件索引 网站地图 最新更新 手机版

站点相关: 国产芯 安防电子 汽车电子 手机便携 工业控制 家用电子 医疗电子 测试测量 网络通信 物联网

北京市海淀区中关村大街18号B座15层1530室 电话:(010)82350740 邮编:100190

电子工程世界版权所有 京B2-20211791 京ICP备10001474号-1 电信业务审批[2006]字第258号函 京公网安备 11010802033920号 Copyright © 2005-2025 EEWORLD.com.cn, Inc. All rights reserved
快速回复 返回顶部 返回列表