工业视觉检测边缘推理方案全记录:从光源选型到缺陷分类模型部署的完整工序分析
工业视觉检测边缘推理方案全记录从光源选型到缺陷分类模型部署的完整工序分析一、引言在 3C 电子制造产线中一块 PCB 板从 SMT 贴片到成品出厂通常要经过 20 道以上的视觉检测工序。传统方式依赖工控机 独立显卡的 PC-based 视觉方案单工位硬件成本在 1.5 万以上且工控机在产线振动、油污、高温环境下故障率偏高。近两年边缘推理芯片算力爆发基于 ARM NPU 的嵌入式视觉方案逐渐落地本文基于某 SMT 产线电容极性检测项目完整复盘从光源选型、图像预处理、模型训练量化、到 RK3588 端侧部署的全链路方案。二、原理剖析工业视觉检测不同于通用物体识别其核心挑战在于缺陷样本极度不均衡正常品与缺陷品比例通常达到 1000:1 甚至更高且缺陷形态多样。传统阈值分割 模板匹配在光照变化时虚警率急剧上升因此方案选型上采用深度学习分类 异常检测双模型融合。核心思路是先用传统算子做粗筛如面积、圆度、灰度均值等几何特征过滤掉 95% 以上的明显缺陷剩余疑似样本送入轻量 CNN 做精确分类。这样做的好处是大幅降低 NPU 推理负载——产线节拍 0.8s/件NPU 推理窗口只有约 300ms。分类模型骨架选用 MobileNetV3-Small输入分辨率 224×224在 Rockchip RK3588 的 NPU6 TOPS INT8上实测推理耗时 8.7ms。量化方案采用训练后量化 (PTQ)校准数据集 2000 张产线实拍图INT8 量化后精度损失控制在 0.3% 以内。三、代码实现以下为 RK3588 端侧推理的核心实现代码基于 Rockchip 的 RKNN 推理框架。/** * file infer_cap_polarity.c * brief 电容极性检测推理模块 - RK3588 NPU 端侧推理 * note RKNN API 版本: 1.5.2, NPU 驱动版本: 0.9.6 */ #include stdio.h #include stdlib.h #include string.h #include rknn_api.h /* NPU 推理上下文结构体 */ typedef struct { rknn_context ctx; /* RKNN 模型上下文句柄 */ rknn_input inputs[1]; /* 模型输入张量单输入 */ rknn_output outputs[1]; /* 模型输出张量 */ rknn_tensor_attr input_attr; /* 输入属性shape/NHWC/量化参数 */ rknn_tensor_attr output_attr; /* 输出属性 */ unsigned char *model_data; /* 模型文件加载缓冲区 */ int model_size; /* 模型文件大小 */ } polarity_infer_t; /* 分类阈值——NG 品 0.85 判定为极性反 */ #define NG_THRESHOLD 0.85f /* NPU 推理超时时间单位 ms */ #define INFER_TIMEOUT 100 /* 输入图像宽高需与训练时保持一致 */ #define IMG_WIDTH 224 #define IMG_HEIGHT 224 #define IMG_CHANNELS 3 /** * brief 从文件读取 RKNN 模型返回加载到内存的 buffer * param path 模型文件路径 * param size 输出参数返回模型文件字节数 * return 成功返回 malloc 分配的 buffer调用方负责释放失败返回 NULL */ static unsigned char *load_model_file(const char *path, int *size) { FILE *fp fopen(path, rb); if (fp NULL) { fprintf(stderr, [ERROR] 无法打开模型文件: %s\n, path); return NULL; } /* 获取文件尺寸 */ fseek(fp, 0, SEEK_END); long file_size ftell(fp); if (file_size 0) { fprintf(stderr, [ERROR] 模型文件大小为 0 或读取失败: %s\n, path); fclose(fp); return NULL; } fseek(fp, 0, SEEK_SET); /* 分配 buffer 并读取 */ unsigned char *buf (unsigned char *)malloc(file_size); if (buf NULL) { fprintf(stderr, [ERROR] 模型文件内存分配失败, 请求 %ld 字节\n, file_size); fclose(fp); return NULL; } size_t read_bytes fread(buf, 1, file_size, fp); if (read_bytes ! (size_t)file_size) { fprintf(stderr, [ERROR] 模型文件读取不完整, 期望 %ld 实际 %zu 字节\n, file_size, read_bytes); free(buf); fclose(fp); return NULL; } fclose(fp); *size (int)file_size; printf([INFO] 模型文件加载成功: %s (%d bytes)\n, path, *size); return buf; } /** * brief 初始化推理引擎加载模型并查询 I/O 属性 * param infer 推理上下文指针 * param model_path RKNN 模型文件路径 * return 0 成功, -1 失败 */ int polarity_infer_init(polarity_infer_t *infer, const char *model_path) { if (infer NULL || model_path NULL) { fprintf(stderr, [ERROR] 无效参数: infer%p, model_path%p\n, (void *)infer, (void *)model_path); return -1; } memset(infer, 0, sizeof(polarity_infer_t)); /* Step 1: 加载模型文件到内存 */ infer-model_data load_model_file(model_path, infer-model_size); if (infer-model_data NULL) { return -1; } /* Step 2: 初始化 RKNN 上下文 */ int ret rknn_init(infer-ctx, infer-model_data, infer-model_size, 0, NULL); if (ret 0) { fprintf(stderr, [ERROR] rknn_init 失败, 返回值: %d\n, ret); /* 避免重复释放未初始化的上下文 */ rknn_destroy(infer-ctx); free(infer-model_data); return -1; } printf([INFO] RKNN 上下文初始化成功\n); /* Step 3: 查询输入属性 */ infer-input_attr.index 0; ret rknn_query(infer-ctx, RKNN_QUERY_INPUT_ATTR, infer-input_attr, sizeof(infer-input_attr)); if (ret 0) { fprintf(stderr, [ERROR] 查询输入属性失败: %d\n, ret); rknn_destroy(infer-ctx); free(infer-model_data); return -1; } printf([INFO] 模型输入: fmt%d, dims[%d,%d,%d,%d]\n, infer-input_attr.fmt, infer-input_attr.dims[0], infer-input_attr.dims[1], infer-input_attr.dims[2], infer-input_attr.dims[3]); /* Step 4: 查询输出属性 */ infer-output_attr.index 0; ret rknn_query(infer-ctx, RKNN_QUERY_OUTPUT_ATTR, infer-output_attr, sizeof(infer-output_attr)); if (ret 0) { fprintf(stderr, [ERROR] 查询输出属性失败: %d\n, ret); rknn_destroy(infer-ctx); free(infer-model_data); return -1; } return 0; } /** * brief 执行单次推理输入 BGR 图像数据输出分类置信度 * param infer 已初始化的推理上下文 * param bgr_img 输入 BGR888 图像数据 (224×224×3) * param confidence 输出参数返回 NG 类别的置信度 [0.0, 1.0] * return 0 成功, -1 失败 */ int polarity_infer_run(polarity_infer_t *infer, const unsigned char *bgr_img, float *confidence) { if (infer NULL || bgr_img NULL || confidence NULL) { fprintf(stderr, [ERROR] 推理参数无效\n); return -1; } /* 设置输入张量BGR888, NHWC 布局, 不传 size 由框架从属性推断 */ infer-inputs[0].index 0; infer-inputs[0].type RKNN_TENSOR_UINT8; infer-inputs[0].fmt RKNN_TENSOR_NHWC; infer-inputs[0].buf (void *)bgr_img; infer-inputs[0].size IMG_WIDTH * IMG_HEIGHT * IMG_CHANNELS; infer-inputs[0].pass_through 0; int ret rknn_inputs_set(infer-ctx, 1, infer-inputs); if (ret 0) { fprintf(stderr, [ERROR] rknn_inputs_set 失败: %d\n, ret); return -1; } /* 执行推理 */ ret rknn_run(infer-ctx, NULL); if (ret 0) { fprintf(stderr, [ERROR] rknn_run 失败: %d\n, ret); return -1; } /* 获取输出 */ infer-outputs[0].want_float 1; /* 要求框架输出 float32 */ infer-outputs[0].is_prealloc 0; ret rknn_outputs_get(infer-ctx, 1, infer-outputs, NULL); if (ret 0) { fprintf(stderr, [ERROR] rknn_outputs_get 失败: %d\n, ret); return -1; } /* 解析输出取 softmax 后 NG 类 (index1) 的概率OK 类为 index0 */ float *output_data (float *)infer-outputs[0].buf; *confidence output_data[1]; /* NG 置信度 */ /* 释放输出 buffer */ rknn_outputs_release(infer-ctx, 1, infer-outputs); return 0; } /** * brief 释放推理引擎资源 * param infer 推理上下文指针 */ void polarity_infer_deinit(polarity_infer_t *infer) { if (infer NULL) return; if (infer-ctx ! 0) { rknn_destroy(infer-ctx); infer-ctx 0; } if (infer-model_data ! NULL) { free(infer-model_data); infer-model_data NULL; } printf([INFO] 推理引擎资源已释放\n); } /* 使用示例集成到产线主循环 */ /* int main(void) { polarity_infer_t infer; float conf 0.0f; unsigned char img_buf[IMG_WIDTH * IMG_HEIGHT * IMG_CHANNELS]; // 初始化 if (polarity_infer_init(infer, /oem/model/cap_polarity.rknn) ! 0) { printf([FATAL] 推理引擎初始化失败, 进入降级模式\n); return -1; } // 产线循环 while (1) { // 从相机驱动读取一帧数据填充 img_buf略 // camera_capture(img_buf); if (polarity_infer_run(infer, img_buf, conf) 0) { if (conf NG_THRESHOLD) { printf([NG] 极性反, 置信度%.3f\n, conf); // 触发剔除气缸 MES 上报 } else { // OK 品放行 } } } polarity_infer_deinit(infer); return 0; } */四、边界分析本方案在实际产线运行一年后积累了一些需要正视的边界问题1. 光源衰减的模型漂移LED 环形光源在连续工作约 6000 小时后亮度衰减约 8%~12%。这导致输入图像的灰度分布发生偏移模型 NG 品检出率从初期的 99.2% 下降到约 97.5%。对策是在每周保养周期中执行光源照度校准并将校准后的图像重新送入训练集做增量微调。为此我们在产线上加入了一路光照传感器BH1750当照度偏离标定值超过 ±15% 时触发告警。2. 新料号泛化失效当产线切换新 PCB 料号时焊盘颜色、丝印形态可能完全不同原有模型直接推理的虚警率可能飙升至 30% 以上。本方案的应对策略是每个料号维护独立的 INT8 模型文件单个模型约 800KB上位机 MES 下发工单时携带料号编码嵌入式端根据料号动态加载对应模型rknn_init 耗时约 120ms在产线换线间隙内完全可以接受。3. NPU 与 CPU 并发争抢 DDR 带宽RK3588 的 NPU 和 CPU 共享 DDR 控制器当推理期间 CPU 同时执行大块内存拷贝如相机帧缓存 DMA时推理耗时可能从 8.7ms 抖动到 15ms。实测通过将相机帧缓存放置在 NPU 专用 SRAM (1MB) 内以及设置 RKNN 推理的 core_mask 为 0b11绑定 NPU Core0Core1可将抖动控制在 ±0.5ms。4. 温度对 NPU 频率的影响环境温度超过 65℃ 时RK3588 会自动对 NPU 降频从 1GHz 降至 800MHz推理耗时增加约 20%。在密闭电柜安装场景中需要确认散热片面积和风道设计。五、总结从光源选型到模型部署工业视觉检测的落地不是单一算法问题而是一套光学-电气-算法-工程的复合系统工程。光源衰减、镜头畸变、散热设计、DDR 带宽这些非 AI 因素往往成为最终良率的天花板。本文方案在 SMT 产线上已稳定运行超过 8000 小时检出率达 99.2%误判率过杀率控制在 1.8%当前仍在持续针对新料号做模型迭代。