笔者按:

最近接了一个项目,要把别人训练的YOLOv7-tiny模型,移植到华为Atlas平台,利用Ascend昇腾NPU做推理,同时实现了python和c++的应用。由于笔者是第一次接触华为这个平台,移植过程中还是踩了不少坑的,这里开一篇文章,介绍整个移植的流程、踩过的坑和debug的思路。

整个项目大约花费了我2周时间,这是在我有一定的python、c++和工程经验的基础上。另外当然生成式AI在这个过程中也帮我做了不少事情,不得不感慨科技的进步。

这里不得不吐槽一下,华为Ascend平台的文档和Sample虽然很全面,但是作为一个初上手的人,真的很难知道从哪入手。直接啃API文档肯定不现实,所以肯定还是要找一个和自己的应用比较类似的Sample,从这个sample入手,修改代码,实现自己的目的。但Sample也各有不同,在硬件文档中、cann文档中,都有各自的sample;在官方sample中,有python的、c++的,c++中还有基于acl的和acllite的,这acllite和acl有啥区别?真的很难弄懂……

开这篇文章主要是记录一下我的历程,所以比较偏向记录,不喜勿喷……

Ascend的Sample地址:https://gitee.com/Ascend/samples

本文参考的Sample:
samples/inference/modelInference/sampleYOLOV7

samples/inference/modelInference/sampleResnetQuickStart

samples/inference/modelInference/sampleResnetAIPP

samples/inference/acllite/cplusplus

以及Atlas 200i DK A2文档中的目标检测sample

目录

0 Start Up

1 Python移植和模型转换

2 尝试CPP移植

读取数据

推理

后处理

验证

3 ACLLITE,我的救命稻草

输入整理

数据迁移到device

推理

后处理和NMS

4 精度对齐

5 模型迁移 & 部署


0 Start Up

拿到项目,我首先确定了Python移植 -> C++移植的路线,这肯定是出于Python的debug易用性考量的。

基础的配环境的部分就不讲了,主要安装了CANN(对应CUDA)和MindStudio(IDE),配了环境变量,还装了如OpenCV等一系列库。注意为了避免错误,这些库我都是下载的源码,然后手动编译的。Atlas平台的cpu架构是ARM指令集的,所以各种包编译的时候也要编译ARM的版本。

最后是选择一个可以快速启动的sample。在Atlas A200i DK平台的官方文档中就有一个目标检测的sample,这里自然是以它为基础,开始我们的移植。

1 Python移植和模型转换

这个sample是以YOLOV5为基础的一个例子。简单阅读代码,发现主要需要操作的部分都在YOLOV5.infer()这个函数里面。

    def infer(self, img_bgr):

        labels_dict = get_labels_from_txt('./coco_names.txt')  # 得到类别信息,返回序号与类别对应的字典

        # 数据前处理
        img, scale_ratio, pad_size = letterbox(img_bgr, new_shape=[640, 640])  # 对图像进行缩放与填充
        img = img[:, :, ::-1].transpose(2, 0, 1)  # BGR to RGB, HWC to CHW
        img = np.ascontiguousarray(img, dtype=np.float32) / 255.0  # 转换为内存连续存储的数组
        
        # 模型推理, 得到模型输出
        output = self.execute([img, ])[0]

        # 后处理
        boxout = nms(torch.tensor(output), conf_thres=0.4, iou_thres=0.5)  # 利用非极大值抑制处理模型输出,conf_thres 为置信度阈值,iou_thres 为iou阈值
        pred_all = boxout[0].numpy()  # 转换为numpy数组
        scale_coords([640, 640], pred_all[:, :4], img_bgr.shape, ratio_pad=(scale_ratio, pad_size))  # 将推理结果缩放到原始图片大小
        img_dw = draw_bbox(pred_all, img_bgr, (0, 255, 0), 2, labels_dict)  # 画出检测框、类别、概率
        return img_dw

整个流程就是:读图像,重整,推理,后处理,就这么简单。

那就好说了,在主程序中把模型换成自己的模型,修改输入长宽……等等,输入的模型是.om格式的?没见过啊?

赶紧在文档里翻了翻,看到了.om模型是Atlas平台的acl推理工具支持的输入模型。好在华为提供了官方的工具来将模型转换为.om模型。但蛋疼的是,这个模型支持caffee,tensorflow,onnx等等,却唯独不支持pytorch……

所以我们唯一的方法就是,将pytorch模型转换到onnx,再用Atlas平台的ATC工具,将onnx模型转换到om格式。

好在YOLO模型的仓库中都提供了export.py的脚本文件,可以将YOLO模型转换到ONNX格式输出,于是运行

python3 export.py

当然,模型转换没这么容易成功。要不就是导出onnx不能成功,要不就是onnx转换om不能成功,要么就是转换都成功了推理输出不对。

这个时候,要仔细观察两边的代码。在yolo的推理代码中,可以看到,是先通过模型执行了推理,然后再进行了nms操作:

而在我们的sample中,也是这么个流程

那我们只要保证模型的输出out和output的shape一致,基本上就对了。

通过对原模型的推理脚本设置断点,我们可以看到,out.shape=(batch, 25200, cls+5)

那么,我们要在export.py的脚本中,逐步去debug,要到在这个脚本找到,哪个步骤输出的tensor,是和上述形状一致的。

export.py脚本的输入参数非常多:

部分参数的说明也不太清楚,这里呢就是一个不断尝试的过程了。具体的方法就是在test.py脚本和export.py脚本中同时设置断点,要将test.py中的模型输出和export.py中的模型输出对齐。

上面说过,由于我们的NMS部分是独立于推理过程的,所以有关于nms的部分都不要设置,这包括"max_wh","topk_all", "iou_thresh", "conf_thresh"和"include-nms"。至于这个“end2end”参数,我最终也没太清楚它是干嘛的。另外,dynamic相关的参数,用于控制模型输入的batch和尺寸是否不变,这里先都不要设置。

总之,我这里最终输出合适的模型的命令是:

python export.py --weights xxxx.pt --grid

导出onnx模型后,将其传到Atlas平台上,通过atc命令转换为om模型:

atc --model=xxxx.onnx --framework=5 --output=xxxx --soc_version=<soc_version>  

这里的<soc_version>需要用命令

npu-smi info

来查看,会给出一个类似

的输出,其中红框框出来的部分就是soc_version参数了。注意这里不能简单写310B4,而应该写Ascend310B4。

在拿到输出模型后,把sample中的模型路径换成输出的模型,debug,发现模型的推理输出变成了一个长度为4的list,其中这个list[0].shape=(1, 25200, cls+5),那么这个就是我们需要拿来nms的输出了。

sample中给出的nms函数和我们原本的测试脚本肯定不太一致,这里直接把原脚本中的nms函数复制过来,替换本身的sample中的nms函数,对输出进行处理。

简单找了两张图像进行验证,再写一个画target的函数。OK,输出没问题,感觉良好!

2 尝试CPP移植

为了实现更好的速度和内存控制,项目方还要求通过纯c++进行部署。我们这里还是一样,先找一个sample,然后在这个sample的基础上进行修改。

上面给出的sample中,modelInference中有很多,我们可以从其中的sampleResnetQuickStart开始,看一下在C++中整个推理流程是怎么走的。

代码放在src/sampleResnetQuickStart.cpp中,整个流程是初始化->读取数据->前处理->推理->拿结果。

其中初始化的部分我们通常不需要关心,只要从读取数据的部分开始看就好。

读取数据

读取数据的部分比较简单。

Result SampleResnetQuickStart::ProcessInput(const string testImgPath)
{
    // read image from file by cv
    imagePath = testImgPath;
    srcImage = imread(testImgPath);
    Mat resizedImage;

    // zoom image to modelWidth_ * modelHeight_
    resize(srcImage, resizedImage, Size(modelWidth_, modelHeight_));

    // get properties of image
    int32_t channel = resizedImage.channels();
    int32_t resizeHeight = resizedImage.rows;
    int32_t resizeWeight = resizedImage.cols;

    // data standardization
    float meanRgb[3] = {min_chn_2, min_chn_1, min_chn_0};
    float stdRgb[3]  = {var_reci_chn_2, var_reci_chn_1, var_reci_chn_0};

    // create malloc of image, which is shape with NCHW
    imageBytes = (float*)malloc(channel * resizeHeight * resizeWeight * sizeof(float));
    memset(imageBytes, 0, channel * resizeHeight * resizeWeight * sizeof(float));

    uint8_t bgrToRgb=2;
    // image to bytes with shape HWC to CHW, and switch channel BGR to RGB
    for (int c = 0; c < channel; ++c)
    {
        for (int h = 0; h < resizeHeight; ++h)
        {
            for (int w = 0; w < resizeWeight; ++w)
            {
                int dstIdx = (bgrToRgb - c) * resizeHeight * resizeWeight + h * resizeWeight + w;
                imageBytes[dstIdx] =  static_cast<float>((resizedImage.at<cv::Vec3b>(h, w)[c] -
                                                         1.0f*meanRgb[c]) * 1.0f*stdRgb[c] );
            }
        }
    }
    return SUCCESS;
}

可以看到,代码首先通过opencv的方法读入了图像,并且resize到模型要求的输入长宽。随后申请了一块channel * resizeHeight * resizeWeight * sizeof(float)类型的内存,用于存放图像数据。

随后,将数据逐个放到内存的指定位置。这里需要注意,代码中用了三个技巧:

- 代码将BGR的通道排列顺序倒转成了RGB。因为在用OpenCV读取的图像,其通道顺序默认是BGR排列的。而用Pytorch推理时,则通常用到的是RGB顺序。

- 代码对图像值进行了normalization处理。这部分其实要根据自己的模型来看,如果你的模型训练的时候就是输入的标准化数据,那这样做也无妨。如果训练时输入的是0~1的归一化数据,那么这里用。从性能上来说,用标准化的数据输入,应该对性能有一定帮助?

- 代码将data的HWC的维度顺序调整到了CHW的顺序,这更符合PyTorch中的输入格式。在OpenCV中,读入的数据在data指针中是按照通道 -> 列 -> 行的顺序来存储的,即,首先是第一行第一列的R、G、B数据,然后是第一行第二列的R、G、B数据,以此类推。转换成CHW顺序即,首先是R通道第一行从左到右的顺序,然后是第二行从左到右的顺序……遍历完R通道后,再到G通道,以此类推。

imageBytes[dstIdx] =  static_cast<float>((resizedImage.at<cv::Vec3b>(h, w)[c] / 255.f);

就行。

推理

这部分代码也没有特别需要我们操作的。过。

后处理

目前我们需要的是先让推理输出对齐。所以可以先把后处理部分给注释掉,只看输出部分。

验证

简单过了一遍代码,并把必要的地方做了修改(模型路径、模型height、width、上述读入的float数据归一化处理)后,尝试编译运行代码,果不其然,代码返回了一个很奇怪的数字,139,并且并没有详细的说明。于是这里就卡住了。

3 ACLLITE,我的救命稻草

经过了一系列的debug后,始终没办法找到确切的错误原因,于是我开始去看其它的sample,看看能不能帮上我的忙。

在sample中,除了上面的QuickStart,我还看到了一个sampleAIPP。它和前面的QuickStart从代码上看是差不多的,所以我简单看了下就没再尝试了。这主要还是因为我在sample中看到了一个sampleYOLOV7。我选择这个sample的原因很简单:

- 我要部署的模型是YOLOV7-tiny,两者很相似。

- 这个sample是基于一个叫做acllite的库,这似乎是对acl本身的功能做了一个二次封装,并且代码是开源的,放在了samples/inference/acllite库中。简单看了下acllite,它其中添加了比较多的LOG,这对debug是非常大的帮助。

我现在的首要过程是,让模型跑起来,然后根据错误信息来进行逐步的DEBUG,于是这次我只对模型路径做了修改(我的模型的输入size和sample是一致的),就尝试编译运行了。

第一次运行的时候,提示Only baseline Jpeg supported,我查了一下,这是由于我的数据集图片是渐进式jpeg。于是我写了个脚本,把所有的渐进式jpeg图像改成了baseline jpeg(非常简单,用ffmpeg批量处理或者用opencv读取再写入就可以)。

输入整理

解决了输入问题后,再次运行代码,给出了非常清晰的LOG(大意):

model requires input size of 4,915,200, but 614,400 provided.

这个数字看起来还是非常有零有整的,结合自己的模型要求的输入算一下

height * width * channel * sizeof(float) = 640 * 640 * 3 * 4 = 4,915,200。这里面每个float类型是四个byte,所以后面*4。

但输入的这个614,400是怎么来的呢?我们观察sample代码中的前处理部分:

Result SampleYOLOV7::ProcessInput(string testImgPath)
{
    // read image from file
    ImageData image;
    AclLiteError ret = ReadJpeg(image, testImgPath);
    if (ret == FAILED) {
        ACLLITE_LOG_ERROR("ReadJpeg failed, errorCode is %d", ret);
        return FAILED;
    }

    // copy image from host to dvpp
    ImageData imageDevice;
    ret = CopyImageToDevice(imageDevice, image, runMode_, MEMORY_DVPP);
    if (ret == FAILED) {
        ACLLITE_LOG_ERROR("CopyImageToDevice failed, errorCode is %d", ret);
        return FAILED;
    }

    // image decoded from JPEG format to YUV
    ImageData yuvImage;
    ret = imageProcess_.JpegD(yuvImage, imageDevice);
    if (ret == FAILED) {
        ACLLITE_LOG_ERROR("Convert jpeg to yuv failed, errorCode is %d", ret);
        return FAILED;
    }

    // zoom image to modelWidth_ * modelHeight_
    ret = imageProcess_.Resize(resizedImage_, yuvImage, modelWidth_, modelHeight_);
    if (ret == FAILED) {
        ACLLITE_LOG_ERROR("Resize image failed, errorCode is %d", ret);
        return FAILED;
    }
    return SUCCESS;
}

其中有一部分的注释写到:    // image decoded from JPEG format to YUV

小学二年级的时候学过,YUV420格式占据的内存空间是:height * width * 1.5 (因为U和V通道都被resize到了原来的0.5倍长宽,那么面积也就是0.25倍。所以加起来是1 + 0.25 + 0.25 = 0.5。取出我们的计算器算一下:

640 * 640 * 1.5 = 614,400

刚好这就对上了。这可以说明,前处理部分的代码,将我的图像变成了uint8类型的YUV420(uint8格式,每一个占1byte的内存),给到了模型。

找到了问题,那我们就逐个去找这个前处理函数做了哪些事,首先是ReadJpeg(),这个函数定义在AclliteUtils.hpp/cpp中:

AclLiteError ReadJpeg(ImageData& image, const std::string& fileName)
{
    uint32_t size = 0;
    void* buf = nullptr;

    ReadBinFile(fileName, buf, size);

    int32_t ch = 0;
    acldvppJpegGetImageInfo(buf, size, &(image.width), &(image.height), &ch);
    if (image.width == 0 || image.height == 0) {
        ACLLITE_LOG_ERROR("unsupported format, only Baseline JPEG");
        return ACLLITE_ERROR;
    }
    image.data.reset((uint8_t *)buf, [](uint8_t* p) { delete[](p); });
    image.size = size;

    return ACLLITE_OK;
}

其中又引入了ReadBinFile(),在同一个文件中:

AclLiteError ReadBinFile(const string& fileName, void*& data, uint32_t& size)
{
    struct stat sBuf;
    int fileStatus = stat(fileName.data(), &sBuf);
    if (fileStatus == -1) {
        ACLLITE_LOG_ERROR("failed to get file");
        return ACLLITE_ERROR_ACCESS_FILE;
    }
    if (S_ISREG(sBuf.st_mode) == 0) {
        ACLLITE_LOG_ERROR("%s is not a file, please enter a file",
                          fileName.c_str());
        return ACLLITE_ERROR_INVALID_FILE;
    }
    std::ifstream binFile(fileName, std::ifstream::binary);
    if (binFile.is_open() == false) {
        ACLLITE_LOG_ERROR("open file %s failed", fileName.c_str());
        return ACLLITE_ERROR_OPEN_FILE;
    }

    binFile.seekg(0, binFile.end);
    uint32_t binFileBufferLen = binFile.tellg();
    if (binFileBufferLen == 0) {
        ACLLITE_LOG_ERROR("binfile is empty, filename is %s", fileName.c_str());
        binFile.close();
        return ACLLITE_ERROR_INVALID_FILE;
    }

    binFile.seekg(0, binFile.beg);

    uint8_t* binFileBufferData = new(std::nothrow) uint8_t[binFileBufferLen];
    if (binFileBufferData == nullptr) {
        ACLLITE_LOG_ERROR("malloc binFileBufferData failed");
        binFile.close();
        return ACLLITE_ERROR_MALLOC;
    }
    binFile.read((char *)binFileBufferData, binFileBufferLen);
    binFile.close();

    data = binFileBufferData;
    size = binFileBufferLen;

    return ACLLITE_OK;

这部分的核心操作就是,读取char格式的文件数据,作为一个uint8类型的智能指针,赋值给image.data。需要注意的是,image是一个ImageData的实例,其data指针定义为一个

std::shared_ptr<uint8_t> data = nullptr;

这部分的操作有些繁琐,如果只是为了读取数据、指针拷贝,我们完全可以写一个类似的函数。并且最关键的,把其中的uint8类型的数据,变为float类型的数据。为此,我们可以写一个ImageDataFloat,再写一个ReadJpegFloat:

struct ImageDataFloat {
    acldvppPixelFormat format;
    uint32_t width = 0;
    uint32_t height = 0;
    uint32_t alignWidth = 0;
    uint32_t alignHeight = 0;
    uint32_t size = 0;
    std::shared_ptr<float> data = nullptr;
};

AclLiteError ReadJpegFloat(ImageDataFloat& image, const std::string& fileName, cv::Size modelSize){

    cout << " Reading:" << fileName << endl;
    cv::Mat imgMat = cv::imread(fileName);
    cv::resize(imgMat, imgMat, cv::Size(640, 640));
    imgMat.convertTo(imgMat, CV_32FC3, 1.f/255.f);
    cv::cvtColor(imgMat, imgMat, cv::COLOR_BGR2RGB);

    // 这里把图像的宽、高、通道数读取出来,存到image中
    image.width = imgMat.cols;
    image.height = imgMat.rows;
    int32_t ch = imgMat.channels();
    image.size = imgMat.total() * imgMat.elemSize(); // 这里size是以uint_8为单位的(Byte)
    if (image.width == 0 || image.height == 0) {
        ACLLITE_LOG_ERROR("unsupported format, only Baseline JPEG");
        return ACLLITE_ERROR;
    }

    float* floatBuf = (float*)imgMat.data;
    float* buf = new float[image.width * image.height * ch];
    uint32_t hw = image.width * image.height;
    // 将图像数据从HWC格式转换为CHW格式,并将通道从BGR转换为RGB
    for (int c = 0; c < ch; ++c) {
        for (int h = 0; h < image.height; ++h) {
            for (int w = 0; w < image.width; ++w) {
                // cout << "c: " << c << ", h: " << h << ", w: " << w << endl;
                buf[c * hw + h * image.width + w] = floatBuf[(h * image.width + w) * 3 + c];
            }
        }
    }

    image.data.reset(buf, [](float* p) { delete[](p); });
    // image.data = buf;
    image.size = image.width * image.height * ch * sizeof(float);

    return ACLLITE_OK;
}

这里,我们借用opencv完成了图像的读取并resize到模型的长宽,并且把图像的分布从BGR转换到RGB,从HWC转换到CHW,并且把数据进行了归一化。最后把size从原来的w*h*c变成了w*h*c*4

数据迁移到device

在processInput()函数中,除了ReadJpeg(),第二个调用的就是CopyImageToDevice()了。由于RGB2YUV的部分我们不需要、resize的部分我们也已经在读取的部分完成,所以我们可以注释掉后面的JpegD函数和Resize函数,只关注CopyImageToDevice()的部分。

这个函数顾名思义,应该就像Pytorch中的to(device),把数据拷贝到推理的设备中。其定义如下:

AclLiteError CopyImageToDevice(ImageData& destImage, ImageData& srcImage,
                               aclrtRunMode curRunMode, MemoryType memType)
{
    void* data = CopyDataToDevice(srcImage.data.get(), srcImage.size,
                                  curRunMode, memType);
    if (data == nullptr) {
        return ACLLITE_ERROR_COPY_DATA;
    }

    destImage.format = srcImage.format;
    destImage.width = srcImage.width;
    destImage.height = srcImage.height;
    destImage.size = srcImage.size;
    destImage.alignWidth = srcImage.alignWidth;
    destImage.alignHeight = srcImage.alignHeight;
    if (memType == MEMORY_DEVICE) {
        destImage.data = SHARED_PTR_DEV_BUF(data);
    } else {
        destImage.data = SHARED_PTR_DVPP_BUF(data);
    }

    return ACLLITE_OK;
}

void* CopyDataToDevice(const void* data, uint32_t size,
                       aclrtRunMode curRunMode, MemoryType memType)
{
    if ((data == nullptr) || (size == 0) ||
        ((curRunMode != ACL_HOST) && (curRunMode != ACL_DEVICE)) ||
        (memType >= MEMORY_INVALID_TYPE) || (memType == MEMORY_HOST)) {
        ACLLITE_LOG_ERROR("Copy data args invalid, data %p, "
                          "size %d, src dev %d, memory type %d",
                          data, size, curRunMode, memType);
        return nullptr;
    }

    aclrtMemcpyKind policy = GetCopyPolicy(curRunMode, TO_DEVICE, memType);

    return CopyData(data, size, policy, memType);
}


void* CopyData(const void* data, uint32_t size,
               aclrtMemcpyKind policy, MemoryType memType)
{
    void* buffer = MallocMemory(size, memType);
    if (buffer == nullptr) {
        return nullptr;
    }

    aclError aclRet = aclrtMemcpy(buffer, size, data, size, policy);
    if (aclRet != ACL_SUCCESS) {
        ACLLITE_LOG_ERROR("Copy data to device failed, aclRet is %d", aclRet);
        FreeMemory(buffer, memType);
        return nullptr;
    }

    return buffer;
}

这里面,其实两个二级和三级函数并没涉及到类型的东西,我们不需要关注,只需要看CopyImageToDevice中,出现了SHARED_PTR_DEV_BUF和SHARED_PTR_DVPP_BUF这两个东西。在ActliteUtils.hpp中,可以看到他们的定义

/**
 * @brief generate shared pointer of dvpp memory
 * @param [in]: buf: memory pointer, malloc by acldvppMalloc
 * @return shared pointer of input buffer
 */
#define SHARED_PTR_DVPP_BUF(buf) (shared_ptr<uint8_t>((uint8_t *)(buf), [](uint8_t* p) { acldvppFree(p); }))

/**
 * @brief generate shared pointer of device memory
 * @param [in]: buf: memory pointer, malloc by acldvppMalloc
 * @return shared pointer of input buffer
 */
#define SHARED_PTR_DEV_BUF(buf) (shared_ptr<uint8_t>((uint8_t *)(buf), [](uint8_t* p) { aclrtFree(p); }))

那么这里比较简单,我们只需要重载这个CopyImageToDevice的函数(用我们前面的ImageDataFloat),并且定义两个相似的宏SHARED_PTR_DVPP_BUF_FLOAT和SHARED_PTR_DEV_BUF_FLOAT,用float代替uint8_t即可:

#define SHARED_PTR_DVPP_BUF_FLOAT(buf) (shared_ptr<float>((float *)(buf), [](float* p) { acldvppFree(p); }))

#define SHARED_PTR_DEV_BUF_FLOAT(buf) (shared_ptr<float>((float *)(buf), [](float* p) { aclrtFree(p); }))

AclLiteError CopyImageToDevice(ImageDataFloat& destImage, ImageDataFloat& srcImage,
                               aclrtRunMode curRunMode, MemoryType memType)
{
    void* data = CopyDataToDevice(srcImage.data.get(), srcImage.size,
                                  curRunMode, memType);
    if (data == nullptr) {
        return ACLLITE_ERROR_COPY_DATA;
    }

    destImage.format = srcImage.format;
    destImage.width = srcImage.width;
    destImage.height = srcImage.height;
    destImage.size = srcImage.size;
    destImage.alignWidth = srcImage.alignWidth;
    destImage.alignHeight = srcImage.alignHeight;
    if (memType == MEMORY_DEVICE) {
        destImage.data = SHARED_PTR_DEV_BUF_FLOAT(data);
    } else {
        destImage.data = SHARED_PTR_DVPP_BUF_FLOAT(data);
    }

    return ACLLITE_OK;
}

这样一来,前处理部分就完成了。

推理

推理部分也是一样,我们要检查一下数据的get等等部分,有没有错误。

在model.Execute()函数中,引用了一个GetOutputItem()方法,这个方法中又引用了一些类似上面的宏定义的指针和一些Copy的方法。我们要用同样的思路将uint8_t的部分修改到float的类型。这里不再赘述。

后处理和NMS

在GetResult()函数中,sample实现了自己的后处理和NMS方法。我们首先看后处理部分。在推理结束后,我们拿到了一个std::vector<InferenceOutput>& inferOutputs。这个inferOutputs其实就类似于我们在Pytorch中得到的推理输出。像我的模型,在Pytorch中的输出是一个len=4的list,那么这里的这个vector的长度就是4。而每个输出的内容,要通过inferOutputs[idx].data.get()来获取指针。

在我的模型输出中,我只用到了list[0]的输出来做后续的nms,所以我这里使用以下方式将这个输出变成了一个cv::Mat的实例:

    uint32_t outputDataBufId = 0;
    float *classBuff = static_cast<float *>(inferOutputs[outputDataBufId].data.get());
    // resize the 201600 float* into 25200 * 8
    // 25200 rows and 8 cols match the data formation(1 * 25200 * 8)
    cv::Mat inferOutputMat(25200, 8, CV_32FC1, classBuff);

由于我已经清楚我的输出的形状,所以这里就直接写死了这个Mat的形状,而data指针引用了前面从推理输出中获得的指针。

后续的confidence threshold和NMS部分就是比较简单的了,不管是用AI工具或者是用其它方法都可以比较简单的完成,在这里不再赘述。

4 精度对齐

在拿到最终的result后,我将输出卸载了一个txt文件里面,并且用cocoeval等工具尝试对模型的性能进行分析。但是我发现模型的AP和AR等指标都有一定的降低(比起移植前降低了约5~10个点),无论怎样尝试confidence threshold和iou threshold,都没法提高指标。

我一度准备放弃,觉得这就是迁移或者模型转换中引入的量化误差。但这是我想起了一个在前面一直被我忽略的细节:

在最开始的Pytorch测试源码中,如果逐步对代码debug,可以看到输入模型的img的size并不是640,而是672。并非是模型设定的输入是672,而是在dataloader的创建过程中,手动通过cv2.copyMakeBorder()为图像的上下都添加了一个灰色边界。也许就是这个边界影响了精度呢?

抱着试一试的态度,我重新按照上述过程转换了一个输入模型要求为672的.om模型,并且把相关的size都进行了改动。在这次输出后,我的精度已经能够和在pytorch下的cuda运行的模型相媲美。

下图为在cuda环境测试的精度:

下图为640的模型的输出精度。可以看到,640模型虽然AP很高,但是mAP很低,部分class的recall甚至降低到了0.8以下。

下图为672的模型输出精度,可以看到,整体精度对比cuda结果,在1%差别上浮动,这才是一个成功的移植结果。

最后,我也跑了一下时间测试,作为参考。我这个模型参数量是10.5G,推理时间是13ms。不得不说,NPU作为专门为推理优化的芯片,其推理算力还是非常强的。

5 模型迁移 & 部署

占坑,晚点更。

Logo

鲲鹏昇腾开发者社区是面向全社会开放的“联接全球计算开发者,聚合华为+生态”的社区,内容涵盖鲲鹏、昇腾资源,帮助开发者快速获取所需的知识、经验、软件、工具、算力,支撑开发者易学、好用、成功,成为核心开发者。

更多推荐