Skip to content

node(electron)环境中使用 onnx 模型

使用 onnx 图片识别大致流程

js
import ort from 'onnxruntime-node';
import MODEL_FILEPATH_PROD from '../../resources/demo.onnx?asset';
//1. 创建会话文件
let session = ort.InferenceSession.create(MODEL_FILEPATH_PROD)
//2. 明确模型inputName和outputName
console.log(session.inputName,seesion.outputName)
//3. 处理图片 使用sharp库
 const { data, info } = await sharp(img)
    .jpeg({ quality: 100 }) //高质量输出图片
    .resize(640, 640) // 调整图像大小以匹配模型输入大小
    .raw()//获取图片的原始数据
    .toBuffer({ resolveWithObject: true });//输入为buffer及文件流
 ....一般情况还需要对数据还要进行操作 比如归一化 将图片数据转换为NCHW格式等等 具体看你模型的要求
//4. 创建张量tensor
let float32Arrary = new float32Arrary(data.length)
const tensor = new ort.Tensor('float32', float32Arrary, [1, info.channels, info.width, info.height]);
//5. 运行模型
let output = await session.run({ session.inputName: tensor });

详细操作

js
import ort from 'onnxruntime-node';
import MODEL_FILEPATH_PROD from '../../resources/yolov8s.onnx?asset';
import { is } from '@electron-toolkit/utils';
import sharp from 'sharp';
let session;
/**
 * @description 初始化onnx
 */
export async function initOnnx() {
  //创建session会话文件
  session = is.dev
    ? await ort.InferenceSession.create(MODEL_FILEPATH_PROD)
    : ort.InferenceSession.create(
        MODEL_FILEPATH_PROD.replace('app.asar', 'app.asar.unpacked')
      ); //生产环境替换文件路径因为squeezenet1_1.onnx没有被assar
}

/**
 * @description 模型推理
 * @param {string} img 图片路径
 */
export async function runModel(img) {
  const { data, info } = await sharp(img)
    .jpeg({ quality: 100 }) //高质量输出图片
    .resize(640, 640) // 调整图像大小以匹配模型输入大小
    .raw() //输出图片原始数据
    .toBuffer({ resolveWithObject: true });
  const tensor = createTensor({ data, info }); //data是图片的buffer数据,info有图片的信息 主要用到是图片的宽度(info.width)、高度(info.height)、通道数(info.channcels)
  let output = await session.run({ images: tensor }); //我用的模型iutputName是images所以输入参数的key就是images。有的模型inputName可能是多个,那么参数就是{inputName[0]:tensor,inputNmae[1]:tensor1,...}
  let boxes = postprocess(output.output0); //得到模型结果 我们还需要对模型数据进行处理
  return boxes;
}
/**
 * @description 创建张量
 * @param {object} data 图片的信息
 * @param {object} data.info 图片的信息
 * @param {buffer} data.data 图片buffer
 */
function createTensor({ data, info }) {
  const { width, height, channels } = info; //图片的宽、高、通道数
  const floatArray = new Float32Array(data.length); //转成模型想要的数据类型,我这里的模型需要的是float32Arrary的数据
  for (let i = 0; i < data.length; i++) {
    //数据归一化,也是具体看模型需求,有的需要,有的不需要
    floatArray[i] = data[i] / 255.0; //rgb的色值是0-255所以这里除以255
  }
  // 将图片数据转换为NCHW格式
  const [r, g, b] = [
    new Float32Array(width * height),
    new Float32Array(width * height),
    new Float32Array(width * height),
  ];
  for (let i = 0; i < width * height; i++) {
    r[i] = floatArray[i * 3];
    g[i] = floatArray[i * 3 + 1];
    b[i] = floatArray[i * 3 + 2];
  }
  const nchwData = new Float32Array(3 * width * height);
  nchwData.set(r, 0);
  nchwData.set(g, width * height);
  nchwData.set(b, 2 * width * height);
  const tensor = new ort.Tensor('float32', nchwData, [
    1,
    channels,
    width,
    height,
  ]); //1表示模型一次性处理图片的数量
  return tensor;
}
/**
 * @description 获取模型的预测结果
 * @param {object} output 模型的输出
 */
function postprocess(output) {
  const [batchSize, numBoxes, numAttributes] = output.dims; //dims表示结果数据的形状
  const data = output.data;
  let arr = reshapeArray(data, numBoxes, numAttributes);
  let result = transposeArray(arr);
  const boxes = isOverThreshold(result, 0.5);
  return boxes;
}
/**
 * @description 将一维数组转换为二维数组将(1, 84, 8400)的形状转换为(84, 8400)的形状
 * @param {array} arr 一维数组
 * @param {number} rows 二维数组的行数
 * @param {number} cols 二维数组的列数
 * @returns {array} 二维数组
 */
function reshapeArray(arr, rows, cols) {
  if (arr.length !== rows * cols) {
    throw new Error(
      'The total size of the new array must be the same as the original array.'
    );
  }
  let reshapedArray = [];
  for (let i = 0; i < rows; i++) {
    let row = arr.slice(i * cols, i * cols + cols);
    reshapedArray.push(row);
  }
  return reshapedArray;
}
/**
 * @description 将二维数组转置为(8400, 84)的形状
 * @param {array} array 二维数组
 * @returns {array} 二维数组
 */
function transposeArray(array) {
  // 创建一个新数组
  let newArray = [];
  // 遍历原数组的列
  for (let i = 0; i < array[0].length; i++) {
    newArray[i] = [];
    // 遍历原数组的行
    for (let j = 0; j < array.length; j++) {
      // 将原数组的行列数据交换到新数组的对应位置
      newArray[i][j] = array[j][i];
    }
  }
  return newArray;
}
/**
 * @description 判断二维数组的第四个元素到最后一个元素是否大于某一个值
 * @param {array} arr 二维数组
 * @param {number} threshold 阈值
 * @returns {array} 最终结果
 */
function isOverThreshold(arr, threshold) {
  //过滤第四列到最后一列中有比阈值大的元素
  let filteredRows = arr.filter((row) =>
    row.slice(4).some((cell) => cell > threshold)
  );
  let result = filteredRows.map((i) => {
    let slice = i.slice(4);
    // 从slice数组中找到最大值以及下标
    let tag = slice.reduce(
      (acc, cur, index) => {
        if (cur > acc.confidence) {
          return { confidence: cur, classId: index };
        } else {
          return acc;
        }
      },
      { confidence: slice[0], classId: 0 }
    );
    return { point: i.slice(0, 4), ...tag };
  });
  //去重 并且保留最大的置信度
  let RemoveDuplicates = result.reduce((ac, cur, index) => {
    let tag = ac.findIndex((i) => i.classId === cur.classId);
    if (tag === -1) {
      ac.push(cur);
    } else {
      if (ac[tag].confidence < cur.confidence) {
        ac[tag] = cur;
      }
    }
    return ac;
  }, []);
  return RemoveDuplicates;
}

名词解释和操作解释

张量:可以理解是个多维数组

buffer:文件流,就是文件的原始数据

channcels 通道数:一般图片的通道是 r g b 三个通道,但是有的图片的通道数可能不同

dims:模型推理结果的数据形状;比如返回结果的 dims:[1,84,8400];84 表示模型能识别的结果数是 80,为什么是 80 个,因为我使用的模型是图片识别,会但会识别结果的在途中的中心坐标和宽高,比如车,门,狗...。8400 表示模型推理出来 8400 个推测,每一条推测的数组长度都是 84,前四个中心点坐标和宽高,后面 80 都是每一个模型能识别的结果对应的置信度,下标减 4 就是识别结果对应的 id,通过 id 可以从结果参照表获取对应名称。

置信度:比如置信度是 0.1 就代表百分之 10 的可能性是目标结果

相关依赖

模型依赖:onnxruntime-node

图片处理库:sharp(node 图片高性能图片处理库)

图片处理

因为 onnx 模型需要对图片的尺寸有要求,一般我们都需要对图片进行处理后再放在模型中识别,node 中可以用 canvas 或者 sharp 库处理图片

通常的处理步骤(使用 sharp):

1.裁剪图片,并将图片转成 buffer 原始数据

test

js
let orimg = sharp(img); //img-图片路径
//data为图片的buffer原始数据,info记录图片信息比如宽、高
const { data, info } = await orimg
  .clone()
  .jpeg({ quality: 100 }) //高质量输出图片
  .resize(640, 640, {
    fit: 'contain',
  }) // 调整图像大小以匹配模型输入大小
  .raw()
  .toBuffer({ resolveWithObject: true });

test2_combined4

2.创建张量(tensor)先将图片转成 float32array(具体看模型需要的,有的可能是 int 类型的),再使用 onnxruntime 提供的方法创建张量,

js
/**
 * @description 创建张量
 * @param {object} data 图片的信息
 * @param {object} data.info 图片的信息
 * @param {buffer} data.data 图片buffer
 */
function createTensor2({ data, info }) {
  const { width, height, channels } = info;
  const floatArray = new Float32Array(data.length);
  for (let i = 0; i < data.length; i++) {
    floatArray[i] = data[i] / 255.0;
  }
  // 将图片数据转换为NCHW格式
  const [r, g, b] = [
    new Float32Array(width * height),
    new Float32Array(width * height),
    new Float32Array(width * height),
  ];
  for (let i = 0; i < width * height; i++) {
    r[i] = floatArray[i * 3];
    g[i] = floatArray[i * 3 + 1];
    b[i] = floatArray[i * 3 + 2];
  }
  const nchwData = new Float32Array(3 * width * height);
  nchwData.set(r, 0);
  nchwData.set(g, width * height);
  nchwData.set(b, 2 * width * height);
  const tensor = new ort.Tensor('float32', nchwData, [
    1,
    channels,
    width,
    height,
  ]);
  return tensor;
}

3.创建完张量后放在模型中运行得到结果,我的模型得到的结果再经过一系列处理得到一下:

js
//point[0],point[1]代表的是识别的框再图片的中心点的位置,point[2],point[3]表示的识别框的宽高
[
  {
    point: [
      417.3835144042969, 254.631591796875, 241.99420166015625, 179.7368621826172,
    ],
    confidence: 0.9593521952629089,
    classId: 21,
    className: 'bear',
  },
];

4.这是裁剪后图片识别的数据主要就是 point 识别框的位置,现在需要将识别框的还原到原始图片的位置(注意这种方式是sharp.resize的 fit 是 contian 模式)

contain 模式test2_combined5

js
/**
 * @description 计算裁剪后图片上的识别框在原始图片上的位置图片裁剪模式contain
 * @param {object} originalInfo - 原始图片的信息
 * @param {object} resizeInfo - 裁剪图片的信息
 * @param {array} boxes - 识别结果
 */
function getBoxesPosition_contain(originalInfo, resizeInfo, boxes) {
  const { width: originalWidth, height: originalHeight } = originalInfo.info;
  const { width: resizeWidth, height: resizeHeight } = resizeInfo.info;
  const originalAspectRatio = originalWidth / originalHeight;
  const targetAspectRatio = resizeWidth / resizeHeight;
  let offsetX = 0,
    offsetY = 0;
  let scale = 1;
  if (originalAspectRatio >= targetAspectRatio) {
    scale = resizeWidth / originalWidth;
    offsetY = (resizeHeight / scale - originalHeight) / 2;
  } else {
    scale = resizeHeight / originalHeight;
    offsetX = (resizeWidth / scale - originalWidth) / 2;
  }
  boxes.forEach((item) => {
    item.point[0] = Math.round(item.point[0] / scale - offsetX);
    item.point[1] = Math.round(item.point[1] / scale - offsetY);
    item.point[2] = Math.round(item.point[2] / scale);
    item.point[3] = Math.round(item.point[3] / scale);
  });
  return boxes;
}

test2_combined3

conver 模式

js
function getBoxesPosition_cover(originalInfo, resizeInfo, boxes) {
  const { width: originalWidth, height: originalHeight } = originalInfo.info;
  const { width: resizeWidth, height: resizeHeight } = resizeInfo.info;
  const originalAspectRatio = originalWidth / originalHeight;
  const targetAspectRatio = resizeWidth / resizeHeight;
  let offsetX,
    offsetY = 0;
  let scale;
  //判断裁剪后的是放大/缩小原始图片
  if (originalAspectRatio > targetAspectRatio) {
    scale = resizeHeight / originalHeight;
    offsetX = (originalWidth - resizeWidth / scale) / 2;
  } else {
    scale = resizeWidth / originalWidth;
    offsetY = (originalHeight - resizeHeight / scale) / 2;
  }
  boxes.forEach((item) => {
    item.point[0] = Math.round(item.point[0] / scale + offsetX);
    item.point[1] = Math.round(item.point[1] / scale + offsetY);
    item.point[2] = Math.round(item.point[2] / scale);
    item.point[3] = Math.round(item.point[3] / scale);
  });
  return boxes;
}

5.在原始图片上绘制识别框,一下是绘制的面状识别框,如果像用线状的识别框,可将下面注释的代码解开,但是需要调整数据,因为一张图片可能会出现多个识别框。(注意 sharp 中合成图片composite中识别框的位置,宽高数据都必须是整数,否则会报错)

js
/**
 * @description - 绘制识别结果图
 * @param {string} url - 图片路径
 * @param {Array<number>} point - 识别选框的位置[x,y,width,height]x y表示中心点坐标 四个值都是正整数
 * @param {string>} targetPath - 保存路径
 * @param {string} [line] - 识别框的粗细
 * @returns {Promise<string>} - 绘制识别结果图的路径
 */
export async function rectangle(img, info) {
  let rectangleList = [];
  for (let i = 0; i < info.length; i++) {
    if (!info[i].point.length) continue;
    let e = info[i];
    rectangleList.push({
      input: {
        create: {
          width: e.point[2],
          height: e.point[3],
          channels: 4,
          background: { r: 255, g: 0, b: 0, alpha: 0.2 },
        },
      },
      left: e.point[0] - Math.round(e.point[2] / 2),
      top: e.point[1] - Math.round(e.point[3] / 2),
    });
    rectangleList.push({
      input: {
        text: {
          width: 1000,
          height: 50,
          rgba: true,
          align: 'left',
          text: `<span foreground="red">${
            e.className + ' ' + (e.confidence * 100).toFixed(2) + '%'
          }</span>`,
        },
      },
      left: e.point[0] - Math.round(e.point[2] / 2),
      top: e.point[1] - Math.round(e.point[3] / 2) - 55,
    });
  }
  let data = await sharp(img).composite(rectangleList).toBuffer();
  // .composite([
  //   // {
  //   //   //左边竖线
  //   //   input: {
  //   //     create: {
  //   //       width: info.point[2],
  //   //       height: info.point[3],
  //   //       channels: 4,
  //   //       background: { r: 255, g: 0, b: 0, alpha: 0.2 },
  //   //     },
  //   //   },
  //   //   left: info.point[0] - Math.round(info.point[2] / 2),
  //   //   top: info.point[1] - Math.round(info.point[3] / 2),
  //   // },
  //   // {
  //   //   //右边竖线
  //   //   input: {
  //   //     create: {
  //   //       width: line,
  //   //       height: info.point[3],
  //   //       channels: 4,
  //   //       background: { r: 255, g: 0, b: 0, alpha: 1 },
  //   //     },
  //   //   },
  //   //   left: info.point[0] + Math.round(info.point[2] / 2),
  //   //   top: info.point[1] - Math.round(info.point[3] / 2),
  //   // },
  //   // {
  //   //   //下边横线
  //   //   input: {
  //   //     create: {
  //   //       width: info.point[2],
  //   //       height: line,
  //   //       channels: 4,
  //   //       background: { r: 255, g: 0, b: 0, alpha: 1 },
  //   //     },
  //   //   },
  //   //   left: info.point[0] - Math.round(info.point[2] / 2),
  //   //   top: info.point[1] + Math.round(info.point[3] / 2),
  //   // },
  //   // {
  //   //   //上边横线
  //   //   input: {
  //   //     create: {
  //   //       width: info.point[2],
  //   //       height: line,
  //   //       channels: 4,
  //   //       background: { r: 255, g: 0, b: 0, alpha: 1 },
  //   //     },
  //   //   },

  //   //   left: info.point[0] - Math.round(info.point[2] / 2),
  //   //   top: info.point[1] - Math.round(info.point[3] / 2),
  //   // },
  //   // {
  //   //   //文字
  //   //   input: {
  //   //     text: {
  //   //       width: 1000,
  //   //       height: 25,
  //   //       rgba: true,
  //   //       align: 'left',
  //   //       text: `<span foreground="red">${info.className + ' ' + (info.confidence * 100).toFixed(2) + '%'}</span>`,
  //   //     },
  //   //   },
  //   //   left: info.point[0] - Math.round(info.point[2] / 2),
  //   //   top: info.point[1] - Math.round(info.point[3] / 2) - 30,
  //   // },
  // ])

  const base64 = data.toString('base64');
  const dataUrl = `data:image/jpeg;base64,${base64}`;
  // .toFile(targetPath); //'./combined.jpg'
  return dataUrl;
}

模型的流程图

在这个 ONNX 模型的流程图中,每个标签代表不同的神经网络层或操作符。常见的标签解释如下:

Conv (卷积层)Conv 表示卷积操作,它通常用于提取图像的局部特征。卷积层通过卷积核对输入数据进行滑动操作,从而提取特征映射(feature map)。

Sigmoid (Sigmoid激活函数)Sigmoid 是一种激活函数,通常用于将输出值压缩到 0 和 1 之间。这在分类任务或输出概率值时特别常用。

Mul (乘法)Mul 代表逐元素乘法操作,将两个张量的对应元素相乘。它常用于缩放或者融合特征。

Add (加法)Add 表示逐元素加法操作,将两个张量的对应元素相加。它通常用于残差网络或者融合不同层次的特征。

Relu (修正线性单元)Relu 是一种激活函数,保留输入中的正数部分,抑制负数部分。它有助于引入非线性特征并加快网络训练。

MaxPool (最大池化层)MaxPool 表示最大池化操作,它通过取池化窗口内的最大值来减少特征图的空间维度,从而保留主要特征并减少计算量。

BatchNorm (批量归一化)BatchNorm 用于对每个批次的数据进行归一化操作,从而加速训练并提高模型的稳定性。

Concat (连接)Concat 用于将两个张量在指定维度上连接,通常用于拼接不同来源的特征。

Softmax (Softmax激活函数)Softmax 用于将网络的输出转化为概率分布,特别是在多分类问题中,它将输出归一化为总和为 1 的概率值。

Gemm (广义矩阵乘法)Gemm 是一种广义矩阵乘法操作,通常用于全连接层或者其他矩阵运算。

end2end.onnx

pipeline.json文件

json
{
  "pipeline": {
    "input": ["img"],
    "output": ["post_output"],
    "tasks": [
      {
        "type": "Task",
        "module": "Transform",
        "name": "Preprocess",
        "input": ["img"],
        "output": ["prep_output"],
        "transforms": [
          {
            "type": "LoadImageFromFile",
            "backend_args": null
          },
          {
            "type": "LetterResize",
            "scale": [640, 640],
            "allow_scale_up": false,
            "use_mini_pad": false
          },
          {
            "type": "Normalize",
            "to_rgb": true,
            "mean": [0.0, 0.0, 0.0],
            "std": [255.0, 255.0, 255.0]
          },
          {
            "type": "Pad",
            "size_divisor": 1
          },
          {
            "type": "DefaultFormatBundle"
          },
          {
            "type": "Collect",
            "meta_keys": [
              "img_norm_cfg",
              "ori_shape",
              "img_shape",
              "pad_param",
              "ori_filename",
              "scale_factor",
              "flip",
              "pad_shape",
              "img_path",
              "valid_ratio",
              "img_id",
              "flip_direction",
              "filename"
            ],
            "keys": ["img"]
          }
        ]
      },
      {
        "name": "yolodetector",
        "type": "Task",
        "module": "Net",
        "is_batched": false,
        "input": ["prep_output"],
        "output": ["infer_output"],
        "input_map": {
          "img": "input"
        },
        "output_map": {}
      },
      {
        "type": "Task",
        "module": "mmdet",
        "name": "postprocess",
        "component": "ResizeBBox",
        "params": {
          "max_per_img": 300,
          "multi_label": true,
          "nms": {
            "iou_threshold": 0.7,
            "type": "nms"
          },
          "nms_pre": 30000,
          "score_thr": 0.001
        },
        "output": ["post_output"],
        "input": ["prep_output", "infer_output"]
      }
    ]
  }
}

这个 pipeline.json 文件定义了一个图像处理流水线,通常用于物体检测任务。它描述了从图像输入到最终输出的步骤和每个步骤的具体操作。下面是详细的解释:

1. Pipeline 概述

  • input: 输入是一个名为 img 的变量,代表输入的图像。
  • output: 输出是名为 post_output 的变量,代表处理后的结果。
  • tasks: tasks 列表定义了流水线中的不同任务,每个任务负责完成一个处理步骤。

2. Task 1: Preprocess

  • type: Task,表示这是一个任务。
  • module: Transform,表示这是一个预处理模块,执行数据转换任务。
  • name: Preprocess,任务的名称。
  • input: 输入是 img,表示从输入图像开始。
  • output: 输出是 prep_output,表示预处理后的输出。

预处理步骤:

  1. LoadImageFromFile:
    • 作用是从文件中加载图像。
  2. LetterResize:
    • 将图像缩放到 640x640,保持原始图像的纵横比。
    • allow_scale_up: 如果 false,则不会将小于目标尺寸的图像放大。
    • use_mini_pad: 如果 false,图像将被调整大小而不使用小填充。
  3. Normalize:
    • 归一化图像像素值,使像素值在 0 到 1 之间。
    • to_rgb: 如果为 true,表示将图像从 BGR 转换为 RGB。
    • meanstd: 使用的均值和标准差分别是 [0.0, 0.0, 0.0] 和 [255.0, 255.0, 255.0],这意味着图像在 0 到 255 范围内直接被除以 255 进行归一化。
  4. Pad:
    • 使用 size_divisor 参数对图像进行填充。size_divisor: 1 表示按 1 的倍数进行填充。
  5. DefaultFormatBundle:
    • 负责将图像转换为张量格式,以适配后续神经网络的输入要求。
  6. Collect:
    • 收集一些元数据(如图像归一化配置、原始形状、缩放比例等),并指定 img 作为模型输入。

3. Task 2: yolodetector

  • name: yolodetector,表示这是 YOLO 模型用于物体检测的任务。
  • type: Task,属于检测网络的任务。
  • module: Net,指定为神经网络模块。
  • is_batched: false,表示单张图像推理,而不是批处理。
  • input: 任务输入是 prep_output,即预处理的输出图像。
  • output: 输出是 infer_output,表示 YOLO 模型的推理结果。
  • input_map: 这里将 img 作为 YOLO 模型的输入。
  • output_map: 这里没有指定输出映射,因为输出是直接返回推理结果。

4. Task 3: postprocess

  • type: Task,是后处理任务。

  • module: mmdet,表示该任务使用的是 MMDetection 框架。

  • name: postprocess,任务名称是后处理。

  • component: ResizeBBox,表示对检测出的边界框进行缩放。

  • params:

    • max_per_img: 300,表示每张图像最多保留 300 个检测框。

    • multi_label: true,表示每个检测框可以属于多个类别。

    • nms

      : 定义了非极大值抑制(NMS)的参数:

      • iou_threshold: 0.7,表示在 NMS 中,如果两个边界框的 IOU 大于 0.7,则去除分数较低的框。
      • type: nms,表示使用标准的 NMS 算法。
    • nms_pre: 30000,表示在进行 NMS 之前,最多考虑 30000 个候选框。

    • score_thr: 0.001,表示只保留得分大于 0.001 的检测框。

  • output: 输出是 post_output,即后处理的输出结果。

  • input: 后处理任务的输入是 prep_outputinfer_output,即预处理的图像和推理的输出结果。

总结:

这段 pipeline 描述了一个从图像输入到物体检测和后处理的完整流程。具体步骤包括:

  1. 预处理:对图像进行缩放、归一化、填充等操作,确保其能够被神经网络正确处理。
  2. 推理:通过 YOLO 神经网络执行物体检测。
  3. 后处理:对检测出的边界框进行缩放、过滤(基于置信度阈值)和非极大值抑制,以得到最终的检测结果