
简介面向Python与计算机视觉方向的开发者这套仓库货位识别系统设计实例完整覆盖了摄像头图像采集、图像预处理、YOLO目标检测、OCR文字识别与货位映射流程。通过透视变换校正倾斜图像结合几何映射将像素坐标转换为A区R03货架第2层第5列这类业务编码并引入多帧确认、置信度融合与人工复核机制可应对复杂光照、遮挡和OCR误识别等工程问题适用于制造业原材料仓、电商物流分拨中心、冷链仓库和自动化立体库用于提升仓储作业效率、强化库存准确性。资源包仅含1个docx文档大小101KB文档按项目背景、挑战及解决方案、模型架构、代码示例和应用领域组织附有依赖配置、识别服务接口、数据库建表语句及部署指南能帮助读者复现从数据采集、透视变换到前后端联调的全流程并为扩展WMS/ERP系统集成及智能盘点奠定基础。目前已有74人浏览学习适合高校学生及初级至中级研发工程师作为智能仓储视觉项目的实践参考。1. 仓库货位识别系统为什么我建议先用规则识别把闭环跑通仓库货位识别的典型场景是在货架前用摄像头拍一张或连续几帧画面程序自动找到货位标签读出类似“A-03-02-04”的编号再把结果写进数据库方便后续做入库、出库和盘点。很多人听到“识别”第一反应是训练目标检测模型但实际落地时货位标签本身是印刷体、固定格式、位置相对固定用 OpenCV 做轮廓检测配合 OCR几小时就能把最小闭环跑起来。这套方案选 Python 主要是生态省事OpenCV 管图像预处理和轮廓查找PaddleOCR 或 Tesseract 管文字识别SQLite 做本地台账PyQt5 搭 GUI 把识别结果和库存记录放到一个窗口里。对计算机视觉大作业、仓库管理系统原型以及小规模仓库自建工具来说它比训练模型便宜得多也好维护得多。下面按我实际搭这套系统的顺序来讲先选型再写识别核心然后接数据库和 GUI最后把最容易翻车的几个问题一次性列清楚。2. 先定方案再写代码仓库货位识别系统的技术选型与数据流设计动手写代码前最怕的是把各个环节的目标搞混。仓库货位识别系统不是“一个识别程序”而是由图像采集、货位定位、编号识别、结果落库、界面展示五部分组成的完整链路。这五部分的技术选型会互相影响比如 OCR 需要什么样的图像质量就决定了前面预处理要做到什么程度。2.1 仓库货位识别系统要解决的三个问题第一货位在哪。摄像头画面里除了货位标签还有货架横梁、货物外箱、反光条甚至来往的人。程序必须先锁定“哪一块是货位标签”这就是定位。第二编号是什么。货位标签上通常是一串字母和数字比如“A-03-02-04”需要 OCR 把它读出来还要判断读出来的内容是不是合法货位编号。第三记录下来干什么。识别结果如果只是显示在屏幕上价值很低必须写进数据库跟货位状态、库存数量、操作时间关联起来才能形成台账。这里要注意货位识别和机器学习里的通用物体检测不是一回事。货位标签是小尺寸、高对比度、规则矩形的印刷体传统计算机视觉方法完全能解决。先做轮廓检测命中率不够再考虑训练模型这个顺序能避开“为了用深度学习而用深度学习”的坑。2.2 货位框选为什么先用 OpenCV 轮廓检测而不是直接上 YOLO货位标签在图像里的特征非常明确它通常是一块白底或浅黄底的矩形牌子四周有深色边框上面印着规整的编号。这种目标用 OpenCV 的 Canny 边缘检测加轮廓筛选就能稳定找到核心判断条件有三个轮廓面积不能太小、轮廓形状接近四边形、宽高比符合货位牌子的比例。用 YOLO 也不是不行但需要先收集几百张标注好的货位图片还要训练、转模型、写推理代码。对大多数仓库现场来说样本量不足是硬伤而且货位标签的样式变化远小于行人、车辆这类目标。先用 OpenCV 把识别链路跑通如果后面发现货位标签样式特别杂乱再拿这套流程积累的数据去训模型是投入产出比最高的路线。2.3 OCR 引擎选型Tesseract 与 PaddleOCR 的取舍货位编号大多是“字母 数字 短横线”的组合中文出现得少。Tesseract 的好处是轻量安装方便识别纯英文数字够用但一旦标签上有中文提示文字、字体偏细、或者图像带一点透视变形它的识别结果就会开始乱。PaddleOCR 的模型对中文和印刷体混合场景更友好自带方向分类器能做到一定程度的倾斜纠正。我的选择是默认用 PaddleOCRlang 参数设为 “ch”。它虽然重一些但识别“B区-02-03-04”这类带中文和字母数字混排的文本时准确率明显高。如果部署环境内存紧张再降级到 Tesseract。无论如何OCR 结果后面必须跟一层正则校验不能直接入库。2.4 数据库与 GUI 的边界先打通最小闭环数据库选 SQLite 还是 MySQL取决于同时几个人用。单机版、管理员一个人操作SQLite 完全够要接 WMS 系统或者多台电脑同时查台账再用 MySQL。最小闭环先跑 SQLite后面迁移 MySQL 只需要改数据库连接层。GUI 用 PyQt5因为它的 QThread 和信号槽机制适合把识别这种耗时操作放到后台线程。先看整个系统的最小流程骨架# system_pipeline.py import cv2 def recognize_location(frame): # 1. 在画面里找到货位标签区域返回矫正后的图像 roi detect_location_roi(frame) if roi is None: return None # 2. 对 ROI 做 OCR返回文本列表 raw_text ocr_read(roi) # 3. 用正则校验并规范化货位编号 code normalize_location_code(raw_text) return code if __name__ __main__: cap cv2.VideoCapture(0) ret, frame cap.read() if ret: print(recognize_location(frame)) cap.release()这段代码定义了整个系统的主干。detect_location_roi 负责图像预处理和轮廓筛选ocr_read 负责文字识别normalize_location_code 负责把 OCR 结果转成标准货位编号。三个函数各自独立后面调参数不会互相污染。先把这条链路跑通再去考虑数据库和 GUI。3. 用 OpenCV 找到货位标签轮廓筛选、透视矫正与 PaddleOCR 识别这一章是整个系统的核心。如果图像预处理做得不好后面 OCR 再怎么调都白搭。我在现场的经验是识别率下降绝大多数时候不是 OCR 引擎的问题而是给到 OCR 的图太差。3.1 从摄像头帧里框出货位Canny 边缘检测与四边缘轮廓筛选先写货位定位函数。输入是摄像头的一帧彩色图输出是候选货位标签的轮廓点后续用它做透视矫正。# locate.py import cv2 def detect_location_roi(frame, min_area800, min_ratio1.6): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) blur cv2.GaussianBlur(gray, (5, 5), 0) # Canny 边缘检测低阈值 60高阈值 120 edges cv2.Canny(blur, 60, 120) contours, _ cv2.findContours(edges, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) candidates [] for contour in contours: area cv2.contourArea(contour) if area min_area: continue perimeter cv2.arcLength(contour, True) approx cv2.approxPolyDP(contour, 0.02 * perimeter, True) # 货位标签通常是四边形 if len(approx) ! 4: continue x, y, w, h cv2.boundingRect(contour) ratio w / float(h) # 宽高比过滤横向长条标签常见竖放标签需要调整这个值 if ratio min_ratio: continue candidates.append((area, approx)) if not candidates: return None candidates.sort(keylambda item: item[0], reverseTrue) return candidates[0][1]Canny 的阈值需要根据现场光线调。阈值设太低货架上的纹理全变成边缘轮廓会非常碎设太高货位标签框的边又断了。我一般从 60 和 120 起步白天窗户光强的时候往上抬灯光较暗的仓库往下压。min_area 也要跟着摄像头分辨率走1080p 下标签占的画面双可以提高到 1500 到 2000。这套筛选规则把大量商品纸箱、货架立柱的干扰轮廓挡在外面。3.2 光照不均处理灰度图上的 CLAHE 与自适应阈值仓库的光照是最难控的变量。白天自然光从窗户进来货架不同位置的亮度相差很大到了晚上LED 灯还会在标签表面形成反光带。直接对原图做全局二值化亮的地方一片白暗的地方一片黑OCR 基本读不出来。在进入 Canny 之前先把灰度图做一次 CLAHE 对比度增强能明显缓解这个问题# preprocess.py import cv2 def normalize_illumination(gray): # clipLimit 控制对比度增强强度tileGridSize 是局部块大小 clahe cv2.createCLAHE(clipLimit3.0, tileGridSize(8, 8)) return clahe.apply(gray) def preprocess_frame(frame): gray cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) enhanced normalize_illumination(gray) blur cv2.GaussianBlur(enhanced, (5, 5), 0) return blurCLAHE 的原理是把图像分成小块在每块内部做直方图均衡再把块之间的边界平滑掉。这样能压住反光造成的局部过曝也能把暗处的标签底纹提亮。clipLimit 默认给 2 到 3调太大容易出现块状噪声tileGridSize 默认 8 × 8标签区域占画面比较小时可以改成 4 × 4让增强更局部化。3.3 透视矫正把倾斜的货位标签拉成一个正面矩形摄像头很少正对着货位拍。人站在巷道里镜头有俯仰角货位标签在画面里就是一个斜的四边形。这个角度 OCR 也能读但置信度会掉。我先对四边形的四个点做排序再透视变换到正面矩形# transform.py import cv2 import numpy as np def order_points(pts): rect np.zeros((4, 2), dtypefloat32) s pts.sum(axis1) rect[0] pts[np.argmin(s)] # 左上角x y 最小 rect[2] pts[np.argmax(s)] # 右下角x y 最大 diff np.diff(pts, axis1) rect[1] pts[np.argmin(diff)] # 右上角x - y 最小 rect[3] pts[np.argmax(diff)] # 左下角x - y 最大 return rect def four_point_transform(image, pts): rect order_points(pts.reshape(4, 2)) tl, tr, br, bl rect # 分别取上边和下边的宽度取最大值作为输出宽度 width_top np.linalg.norm(tr - tl) width_bottom np.linalg.norm(br - bl) max_width max(int(width_top), int(width_bottom)) # 左右两边的高度同理 height_left np.linalg.norm(tl - bl) height_right np.linalg.norm(tr - br) max_height max(int(height_left), int(height_right)) dst np.array([ [0, 0], [max_width - 1, 0], [max_width - 1, max_height - 1], [0, max_height - 1] ], dtypefloat32) matrix cv2.getPerspectiveTransform(rect, dst) warped cv2.warpPerspective(image, matrix, (max_width, max_height)) return warpedorder_points 的核心是确定四个角点的空间位置。透视矫正后货位编号那行文字变成水平方向OCR 的识别难度大幅下降。如果标签在画面里本身已经接近正对镜头透视变换影响不大但多这一步能让识别结果稳定很多。3.4 OCR 识别与正则解析识别结果不校验不能入库ROI 拿到之后先做一次放大再送进 PaddleOCR。标签上的编号一般不大放大两倍能让小字更完整地进入识别器。# ocr_engine.py from paddleocr import PaddleOCR import cv2 # 2.x 版本的初始化写法3.x 参数有变化跑一次打印 result 确认结构 ocr PaddleOCR(use_angle_clsTrue, langch, show_logFalse) def ocr_read(warped): scale cv2.resize(warped, None, fx2.0, fy2.0, interpolationcv2.INTER_CUBIC) result ocr.ocr(scale, clsTrue) if not result or not result[0]: return # 2.x 返回格式[[box, (text, confidence)], ...] texts [] for line in result[0]: texts.append(line[1][0]) return .join(texts)这里有个常见的坑PaddleOCR 不同版本返回值结构不一样。2.x 的 ocr.ocr 返回嵌套列表3.x 开始改成 dict。第一次接的时候先 print(result) 看结构再写解析别照着旧博客抄。放大插值用 INTER_CUBIC 对文本边缘更平滑用 INTER_LINEAR 速度快一点但锯齿明显。OCR 输出经常带空格、错字或者把条码数字一起读出来。必须用正则校验# code_check.py import re LOCATION_PATTERN re.compile(r^[A-Z]{1,2}-\d{2,3}-\d{2,3}-\d{2,3}$) def normalize_location_code(raw_text): if not raw_text: return None text raw_text.strip().replace( , ).replace(O, 0) # 货位编号格式区域字母 巷道 货架 层 列 if LOCATION_PATTERN.match(text): return text return None正则里 O 转 0 是血泪经验很多 OCR 会把数字 0 识别成字母 O。具体替换规则要根据自己仓库的编号习惯来写不要贪多。校验通过才返回编号否则返回 None由上层决定是重新识别还是提示人工确认。4. 数据库与 PyQt5 GUI 设计让识别结果变成能查的仓库台账识别出货位编号只是第一步接下来的问题是这个编号代表什么现在里面放的是什么什么时候拍的这些问题都要靠数据库和 GUI 来回答。4.1 三张表的设计货位台账、识别日志、库存事件我一般建三张表location_ledger 存货位基本信息recognize_log 存每次识别记录inventory_event 存库存变动。三张表的职责分开查询和分析都不用来回拼接CREATE TABLE IF NOT EXISTS location_ledger ( location_code TEXT PRIMARY KEY, zone TEXT, aisle TEXT, shelf TEXT, layer_num TEXT, col_num TEXT, current_sku TEXT, status TEXT DEFAULT EMPTY, updated_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS recognize_log ( id INTEGER PRIMARY KEY AUTOINCREMENT, location_code TEXT, raw_ocr_text TEXT, confidence REAL, image_path TEXT, created_at TEXT NOT NULL ); CREATE TABLE IF NOT EXISTS inventory_event ( id INTEGER PRIMARY KEY AUTOINCREMENT, location_code TEXT NOT NULL, sku TEXT, quantity INTEGER NOT NULL DEFAULT 0, action TEXT CHECK(action IN (IN, OUT, MOVE)), operator TEXT, created_at TEXT NOT NULL );location_ledger 的主键是 location_code一个货位一条记录。zone、aisle、shelf 这些字段从货位编号里解析出来方便按区域查询。recognize_log 记录原始 OCR 文本和置信度识别错了还能回头排查是图像问题还是引擎问题。inventory_event 记录入库、出库、移库操作查历史库存变化就靠它。4.2 写入策略UPSERT 避免重复货位记录识别程序每一帧都可能写到同一个货位如果每次都用 INSERTlocation_ledger 里会出现大量重复货位。货位台账的写入必须用 UPSERT存在就更新不存在才插入# db.py import sqlite3 class WarehouseDB: def __init__(self, db_pathwarehouse.db): self.conn sqlite3.connect(db_path) self.conn.row_factory sqlite3.Row self._init_db() def _init_db(self): # 执行上面三张表的 CREATE TABLE 语句 self.conn.executescript( CREATE TABLE IF NOT EXISTS location_ledger (...); CREATE TABLE IF NOT EXISTS recognize_log (...); CREATE TABLE IF NOT EXISTS inventory_event (...); ) self.conn.commit() def upsert_location(self, location_code, zone, aisle, shelf, layer_num, col_num): self.conn.execute( INSERT INTO location_ledger (location_code, zone, aisle, shelf, layer_num, col_num, updated_at) VALUES (?, ?, ?, ?, ?, ?, datetime(now, localtime)) ON CONFLICT(location_code) DO UPDATE SET zone excluded.zone, aisle excluded.aisle, shelf excluded.shelf, layer_num excluded.layer_num, col_num excluded.col_num, updated_at datetime(now, localtime) , (location_code, zone, aisle, shelf, layer_num, col_num)) self.conn.commit() def log_recognize(self, location_code, raw_text, confidence, image_path): self.conn.execute( INSERT INTO recognize_log (location_code, raw_ocr_text, confidence, image_path, created_at) VALUES (?, ?, ?, ?, datetime(now, localtime)) , (location_code, raw_text, confidence, image_path)) self.conn.commit()ON CONFLICT 是 SQLite 的 UPSERT 写法。如果以后迁到 MySQL改成 ON DUPLICATE KEY UPDATE 就可以其他逻辑不变。识别日志不需要 UPSERT每次识别都留一条方便追溯现场情况。4.3 PyQt5 界面布局预览区、识别区、台账区、查询区GUI 设计的原则是“一屏看完所有信息”。预览区显示摄像头画面识别区显示刚才读出的货位编号台账区用表格列出最近识别记录查询区提供按区域和状态筛选。下面是一个最小可用的窗口骨架# gui_main.py import sys from PyQt5.QtWidgets import ( QWidget, QLabel, QPushButton, QLineEdit, QTableWidget, QTableWidgetItem, QVBoxLayout, QHBoxLayout ) class MainWindow(QWidget): def __init__(self): super().__init__() self.setWindowTitle(仓库货位识别系统) self.camera_label QLabel(摄像头画面) self.camera_label.setMinimumSize(640, 360) self.result_label QLabel(识别结果未识别) self.edit_operator QLineEdit() self.edit_operator.setPlaceholderText(操作员) self.btn_capture QPushButton(单帧识别) self.btn_query QPushButton(查询台账) self.table QTableWidget(0, 4) self.table.setHorizontalHeaderLabels( [货位编号, SKU, 操作员, 识别时间] ) top_layout QVBoxLayout() top_layout.addWidget(self.camera_label) top_layout.addWidget(self.result_label) btn_layout QHBoxLayout() btn_layout.addWidget(self.edit_operator) btn_layout.addWidget(self.btn_capture) btn_layout.addWidget(self.btn_query) layout QVBoxLayout() layout.addLayout(top_layout) layout.addLayout(btn_layout) layout.addWidget(self.table) self.setLayout(layout) if __name__ __main__: from PyQt5.QtWidgets import QApplication app QApplication(sys.argv) window MainWindow() window.show() sys.exit(app.exec_())QLineEdit 用来记录当前操作员识别结果入库存事件时带上这个人名。QTableWidget 比 QListView 更适合这种固定列数的台账展示。界面不用花哨仓库现场用鼠标操作按钮大一点、信息层级清楚比配色重要得多。4.4 识别线程放后台QThread 与信号槽OCR 一次要几百毫秒到一两秒如果直接在按钮回调里同步跑窗口会整个冻住。PyQt5 里把识别放到 QThread用 signal 把结果传回主线程# worker.py from PyQt5.QtCore import QThread, pyqtSignal class RecognizeWorker(QThread): result_ready pyqtSignal(dict) def __init__(self, frame): super().__init__() self.frame frame def run(self): try: code, confidence, debug_img core_recognize(self.frame) self.result_ready.emit({ code: code, confidence: confidence, debug_image: debug_img }) except Exception as exc: self.result_ready.emit({error: str(exc)})按钮点击时先取当前帧然后创建 worker信号连到界面的更新函数再 start。注意 worker 不能作为局部变量直接抛掉要在窗口对象上保存引用否则会被回收。信号里传 numpy 数组这种大对象时Qt 会做一次拷贝调试图上万像素也没压力。5. 仓库货位识别系统常见问题排查五个现场翻车点与处理办法系统能跑通不难但要稳定跑难的是现场那些“偶尔出一次”的问题。下面五个问题是我在不同仓库环境里都遇到过的每条按现象、原因、解决来说。5.1 反光导致识别率骤降从 92% 掉到 60%现象上午识别率正常下午阳光斜照进仓库货位标签表面出现一道亮斑OCR 开始把编号读断比如把 A-03-02-04 读成 A-03-0-04。原因货位标签表面通常覆膜反光区域在灰度图里变成过曝白块Canny 检测不到那一块的边缘字符粘连在一起。全局直方图均衡解决不了局部过曝。解决在灰度图进 Canny 之前加 CLAHE把反光区域的动态范围压下来。另外把摄像头的曝光模式改成手动固定快门和增益避免画面亮度来回跳。最有效的还是调整摄像头角度让标签表面和镜头光轴垂直反光面积会小很多。5.2 商品条码和货位编号被一起识别进去现象货位标签旁边贴着商品条码OCR 输出的文本变成类似“A-03-02-04”后面跟着一串数字正则校验失败系统一直报“未识别”。原因货位标签轮廓和旁边的商品条码连成一整块一个 contour 里既有货位编号又有条码数字。OCR 会把整块内容全读出来。解决在轮廓筛选阶段提高宽高比过滤条件。商品条码是细长条货位标签宽高比通常在 1.6 到 3 之间把 min_ratio 调到 1.8 能滤掉不少条码。另外 OCR 拿到文本后不要直接拼接全部识别行先逐条做正则匹配匹配不到就走候选补充逻辑。5.3 cv2.imread 读中文路径返回 None后面全崩现象照片存在“D:\仓库现场\2024-09-拍摄\B区.jpg”imread 返回 None程序不报错但后续 cv2.contourArea 直接抛错或者读到空图。原因OpenCV 的 imread 对中文路径支持不完整Windows 上尤其明显。返回 None 而不是抛异常坑了很多新手。解决用 imdecode 读文件字节流写文件用 imencode。数据入库时存的 image_path 也统一用英文命名的子目录减少这个问题的触发面。import cv2 import numpy as np def imread_unicode(path): data np.fromfile(path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR) def imwrite_unicode(path, image): ext . path.rsplit(., 1)[-1] result, encoded cv2.imencode(ext, image) if result: encoded.tofile(path)5.4 点击开始识别后 GUI 卡死现象窗口标题栏变成“未响应”过几秒才恢复期间画面完全冻结。原因识别逻辑直接写在按钮回调里PaddleOCR 初始化加推理占用了主线程Qt 事件循环被阻塞界面就无法刷新和响应点击。解决把识别放到 QThread长期运行的视频流识别放到独立进程更稳妥。GUI 主线程只负责显示结果和接收信号。另外PaddleOCR 模型初始化耗时长建议程序启动时加载一次不要每次识别都重新初始化。5.5 同一货位被重复插入多条台账现象连续识别同一个货位location_ledger 表里出现多行相同 location_code查询台账时数据翻倍。原因写入用了 INSERT没有做唯一性约束处理。解决location_code 设为主键写入用 UPSERT。识别日志表保留多行没问题但货位台账必须一货位一行。如果需要在 GUI 里显示最近识别历史就查 recognize_log不要查 location_ledger。6. 进阶识别率回归测试与参数调优的收尾习惯系统上线后最怕的不是改代码而是改完不知道识别率是升了还是降了。我给自己定了个习惯每次调参数前先准备一组固定测试图跑一遍回归脚本量化识别率变化。测试图要覆盖几个典型场景正对拍摄、斜角度拍摄、反光局部过曝、暗光、货位标签旁边有商品条码。我把这些图和期望结果放在一个 JSON 里脚本统一调用识别函数。# regression.py import json import cv2 import numpy as np def load_unicode_image(path): data np.fromfile(path, dtypenp.uint8) return cv2.imdecode(data, cv2.IMREAD_COLOR) def run_regression(manifest_patheval_set.json): with open(manifest_path, r, encodingutf-8) as f: cases json.load(f) hit 0 for case in cases: image load_unicode_image(case[image]) code recognize_location(image) if code case[expected]: hit 1 else: print(f失败{case[image]} 期望 {case[expected]} 实际 {code}) total len(cases) print(f识别率{hit}/{total} {hit / total:.1%})回归脚本的数据集不需要很大一百张到两百张就够。每次改预处理参数或换 OCR 模型后先跑回归识别率低于 95% 就不上现场。参数调优时按优先级来先保证 Canny 能稳定框出货位标签再看透视矫正后的图像是否水平最后才动 OCR 的缩放倍数和正则规则。一个参数一个参数地改不要同时调三个变量否则出了问题根本定位不到原因。这套规则识别方案适合百分之八十的仓库货位场景。真遇到标签样式差异特别大、或者环境光照极端不稳定的情况再结合标注数据训练专用模型也不迟。先把图像采集、数据库、GUI 这条链路做好识别引擎反而是最容易替换的部分。这是我在这个项目里最大的体会希望帮到你。本文还有配套的精品资源点击获取