很多人把YOLOv11模型训练完就以为万事大吉了,结果一到部署环节才发现,真正折磨人的才刚刚开始。我见过不少同学拿着训练好的权重文件,在本地跑demo的时候一切正常,一换到服务器就各种莫名其妙的问题:环境冲突、内存爆炸、推理速度惨不忍睹。这一篇聚焦的就是从“训练完成”到“对外提供服务”这最后一公里:用OpenCV的DNN模块加载YOLOv11模型,再用FastAPI把它包成一个真正的推理服务。整个过程我会把每一个环节的原理、代码和踩过的坑都摊开讲清楚,保证你照着做就能跑通。
这篇内容适合谁?如果你已经会训练YOLOv11,但对部署还停留在“能跑demo就行”的阶段;或者你想把检测能力提供给其他人调用——比如给前端、给其他后端服务、给移动端做支撑——那这篇文章就是给你准备的。不需要你有深厚的C++功底,不需要你懂CUDA优化,只要Python基础扎实,就能跟着搭出一套能用的推理服务。
1. 部署链路全貌与方案选型
1.1 为什么选择OpenCV DNN而不是直接跑PyTorch
先说一个很多人都会问的问题:模型明明是在PyTorch里训练的,为什么部署时不直接把PyTorch环境搬到服务器上?原因其实很实际。PyTorch的推理环境非常“重”:基础runtime体积动辄几百MB起步,加上模型权重、各类依赖库,动辄上GB。而且如果你训练时用了GPU,部署机器也得配GPU、装CUDA、配cuDNN,版本之间稍有不对,直接跑不起来。
更要命的是,PyTorch推理有很明显的“解释执行”开销。哪怕模型本身不大,在CPU上跑一轮推理也要经历Python字节码解释、张量操作调度、autograd图构建(虽然推理时可以关掉,但框架本身的开销还是在)。相比之下,OpenCV的DNN模块直接读取ONNX格式的模型文件,底层调用的是优化过的计算图执行引擎,在CPU上的速度往往比PyTorch快一个档次,尤其对于单张图片的推理场景。
我实测过一组数据,在一台普通服务器上跑YOLOv11s模型,输入640x640,PyTorch CPU推理大概是每张120毫秒左右,而OpenCV DNN加载同一个ONNX模型能压到70毫秒上下。这个差距在批量处理时更明显,因为OpenCV DNN可以自己管理内存池和线程调度,不需要经过Python层的层层转发。
当然,OpenCV DNN也不是万能的。它对一些新型算子的支持会有滞后,像YOLOv11这种新模型,旧版本OpenCV可能根本加载不了。这个问题我在后面专门讲,先记住一个结论:选OpenCV DNN要满足三个条件——模型能导出成ONNX、ONNX里没有太新的算子、你愿意针对它的特性优化预处理和后处理。满足这三点,它就是你部署场景里性价比最高的选择。
1.2 三种主流部署方案对比:ONNX Runtime、TensorRT、OpenCV DNN
部署YOLOv11模型,市面上主流方案其实就三条路:ONNX Runtime、TensorRT、OpenCV DNN。我先把它们的差别摆出来,这样你选型时心里有数。
| 方案 | 推理硬件 | 部署难度 | 性能表现 | 典型场景 |
|---|---|---|---|---|
| ONNX Runtime | CPU/GPU均可 | 中等 | CPU较快,GPU需配CUDA | 跨平台通用服务 |
| TensorRT | 仅NVIDIA GPU | 较高 | 性能最强 | 高吞吐生产环境 |
| OpenCV DNN | CPU为主 | 较低 | CPU够用,GPU优化弱 | 轻量级服务、边缘设备 |
ONNX Runtime的好处是兼容性极强,几乎任何能导出成ONNX的模型它都能跑,而且有CPU、GPU、TensorRT多种执行后端可以切换。坏处是它本身是一个额外的运行时依赖,部署环境里又要多一个大件。
TensorRT的推理性能一骑绝尘,做高并发生产环境基本是首选。但它的代价也明显:只支持NVIDIA显卡,模型需要专门的engine转换步骤,转换时间按小时算也是常事,而且每次换显卡驱动版本都可能要重新转。
OpenCV DNN在这三者里姿态最低,但反而最适合我们这种“先把服务跑起来”的场景。它不需要额外的推理运行时,因为服务器上一般本来就有OpenCV。它的CPU推理性能虽然没有TensorRT那么夸张,但说实话对于大多数业务场景——比如给几百个用户提供检测服务、每天处理几千张图——已经完全够用了。而且OpenCV DNN的生态特别稳,只要OpenCV版本不太老,基本不会出现那种“换个机器就跑不了”的玄学问题。
1.3 整体服务架构:从图片进来到结果返回的完整链路
我搭的这套服务链路是这样的:客户端通过HTTP请求把图片传上来 → FastAPI接收并解析 → 调用OpenCV读取图像 → 经过letterbox预处理 → DNN forward推理 → 后处理解码得到检测框 → 坐标还原到原图 → 拼接结果返回给客户端。
每个环节都有讲究。FastAPI负责的是接口层,你要考虑并发、超时、请求体大小限制这些事情;OpenCV负责的是图像处理层,要为DNN准备好特定尺寸和格式的输入;DNN推理是最核心的环节,它的输出是一堆原始的预测张量,需要做置信度过滤、NMS去重;后处理解码则是把模型坐标系里的检测框映射回原图的真实位置。
我强烈建议在动手写代码之前先把这条链路画一遍,标清楚每一步的输入输出是什么。我踩过最大的坑就是后处理环节坐标系搞混,明明模型框已经正确框住了目标,还原到原图之后位置却偏了,排查了半天才发现是letterbox填充的偏移量没有补偿。这种问题一旦出错,不是调试代码能解决的,得回到原理层面重新捋一遍。
2. 模型导出与预处理细节
2.1 从PyTorch权重到ONNX:导出流程与关键参数
YOLOv11没法直接把PyTorch权重文件丢给OpenCV用,中间必须经过ONNX这个桥。导出这一步看似简单,一行torch.onnx.export就完事,但里面有几个坑不处理干净,后面推理阶段会非常痛苦。
我在自己的项目里建议用YOLO官方仓库自带的一套导出脚本,但你得手动加上一些关键参数。最核心的是opset版本——不要为了追求新功能把opset设得过高,OpenCV DNN对旧opset的支持更稳。我当时用的组合是ONNX opset 12,配OpenCV 4.8.0,跑得很顺畅。opset设到13以上,某些OpenCV版本就开始报不认识的算子错误了。
另外导出时还有一个非常容易忽略的点:dynamic参数。如果你设置的输入尺寸是固定的640x640,那导出的模型输入也固定是[1,3,640,640],后续推理时OpenCV里的blob尺寸也必须完全匹配。如果你设置了动态轴(比如高度宽度可变),虽然灵活了,但OpenCV DNN对动态输入的兼容性会变差,有些模型甚至会直接崩。我的建议是:导出时固定输入尺寸,部署阶段预处理时把图像统一缩放到这个尺寸。这样模型更稳定,代码逻辑也更简单。
简单版本的导出流程:
# 假设你有一个训练好的模型权重文件,用官方工具导出 python export.py --weights model.pt --include onnx --opset 12 --simplify --dynamic=False这里--simplify也很关键,它会把计算图里一堆冗余节点去掉,让ONNX文件更小、推理更快。有些模型的ONNX导出文件比权重文件还大,就是因为没做简化,里面全是无用节点。
2.2 输入预处理的核心:Letterbox操作详解
Letterbox是YOLO系列模型输入处理的标准动作。它的思路很简单:把原始图像等比缩放到能放进目标尺寸(比如640x640)的范围内,剩余的空间用灰色(通常是114、127或0)填充,而不是直接把图拉伸变形。
为什么不能用普通resize?因为YOLOv11在训练时就是用的letterbox方式,推理时如果不保持一致,目标的长宽比例会被破坏,检测精度会显著下降。你可以想象一下一个正方形物体被压扁成长方形,模型训练时没见过这种形状,检测框自然就不准了。
具体实现里有一个细节必须注意:letterbox的填充量不是对称的。假设原图是1280x720,你要缩放到640x640,先计算缩放比例min(640/1280, 640/720) = 0.5,缩放后尺寸是640x360,那么需要在上下各填充(640-360)/2 = 140像素。但如果原图缩放后的高度是640,宽度不够,那就是左右填充。关键要保存好填充的偏移量,后处理时要把这个偏移减回来。
我用的实现是自定义的一个函数,返回缩放后的图、缩放比例、填充尺寸三元组:
def letterbox(img, new_shape=(640, 640), color=(114, 114, 114), auto=False): shape = img.shape[:2] if isinstance(new_shape, int): new_shape = (new_shape, new_shape) r = min(new_shape[0] / shape[0], new_shape[1] / shape[1]) new_unpad = (int(round(shape[1] * r)), int(round(shape[0] * r))) dw = new_shape[1] - new_unpad[0] dh = new_shape[0] - new_unpad[1] if auto: dw, dh = dw % 32, dh % 32 dw /= 2 dh /= 2 if shape[::-1] != new_unpad: img = cv2.resize(img, new_unpad, interpolation=cv2.INTER_LINEAR) top, bottom = int(round(dh - 0.1)), int(round(dh + 0.1)) left, right = int(round(dw - 0.1)), int(round(dw + 0.1)) img = cv2.copyMakeBorder(img, top, bottom, left, right, cv2.BORDER_CONSTANT, value=color) return img, r, (dw, dh)注意auto=True的时候会把填充量往32的倍数上靠——这是为了在某些硬件加速场景下对齐内存,如果你的部署环境没有这个需求,保持auto=False即可。
2.3 ONNX模型简化与节点检查
导出ONNX之后,我强烈建议你别急着扔进OpenCV,先用onnx库检查一下模型结构。很多时候导出过程看起来成功了,但里面藏着一些OpenCV不支持的节点。
检查方法很简单,写个脚本读取模型,遍历所有节点的op类型,看看有没有不在白名单里的。常见的坑是Crop、Resize、NonMaxSuppression这些节点在某些opset版本下的行为变化。我就遇到过ONNX里出现了一个旧版OpenCV不认识的Resize模式,导致整个模型在DNN模块里根本不加载。
如果你发现模型里有问题节点,两条路:第一,降低opset版本重新导出;第二,用onnx-simplifier工具做简化,它能自动融合一些子图,往往能把有问题的节点给消掉。
import onnx model = onnx.load("model.onnx") onnx.checker.check_model(model) print("节点总数:", len(model.graph.node)) for node in model.graph.node: print(node.op_type)这一步花不了几分钟,但能省掉后面好几个小时的排查时间。
3. OpenCV加载与推理实现
3.1 初始化DNN模型:从文件加载到内存管理
OpenCV DNN加载ONNX模型就两行代码:
import cv2 net = cv2.dnn.readNetFromONNX("model.onnx") net.setPreferableBackend(cv2.dnn.DNN_BACKEND_OPENCV) net.setPreferableTarget(cv2.dnn.DNN_TARGET_CPU)不过这两行代码后面藏着一个重要的内存管理逻辑:net对象一旦创建,就会把整个模型加载到内存里。如果你在FastAPI里每个请求都重新加载一次模型,内存瞬间就会被撑爆,尤其当模型文件本身就有几百MB的时候。
正确的做法是设计一个全局单例。服务启动时加载一次模型,之后所有请求都复用同一个net实例。这里有一个OpenCV DNN的特性和PyTorch不同需要注意:net对象不是线程安全的,多个线程同时执行forward是不安全的。所以实际编码时要加锁,或者用OpenCV文档里提到的net复制机制——每个工作线程持有一个独立的模型副本。
我在实际项目里的做法是:为每个并发worker预创建一个独立的net实例,放进一个池子里,请求来了就从池子取一个,用完了再还回去。这样既避免了线程竞争,又不会因为每个请求重新加载而拖慢速度。
class ModelPool: def __init__(self, model_path, pool_size=4): self.pool = [cv2.dnn.readNetFromONNX(model_path) for _ in range(pool_size)] self.lock = threading.Lock() self.available = list(range(pool_size)) def acquire(self): with self.lock: if not self.available: idx = None else: idx = self.available.pop() return idx def release(self, idx): with self.lock: self.available.append(idx)3.2 预处理与推理执行:blobFromImage和forward
图像预处理完以后,要转成OpenCV DNN要求的blob格式。这中间包含几个步骤:通道顺序从BGR转RGB、像素值归一化到0到1之间、调整为[1,3,640,640]的形状。OpenCV的blobFromImage函数全给你包了:
blob = cv2.dnn.blobFromImage(img_preprocessed, 1/255.0, (640, 640), (0, 0, 0), swapRB=True, crop=False)注意这里的swapRB=True,因为YOLOv11模型是用RGB顺序训练的,而OpenCV读出来的图是BGR顺序。如果忘了这行,检测结果会全面错乱,出现大量错误框。这个问题我遇到过不止一次,每次换人接手代码都会有人栽在这里。
接着把blob塞进网络执行推理:
net.setInput(blob) outputs = net.forward()outputs是一个形状为[1, 84, 8400]的张量。怎么理解这个形状?84 = 4个框坐标信息 + 80个类别置信度(COCO数据集80类,YOLOv11默认就是80类目标)。8400是YOLOv11在不同尺度特征图上预测的候选框总数——大目标、中目标、小目标各对应不同数量的预测框。
这个结果解读是整个推理环节最需要吃透的部分。每一列代表一个候选框,前4个数是框的坐标,注意YOLO风格的坐标表示是(cx, cy, w, h),即中心点x、中心点y、宽度、高度,而且全部是归一化到0到1的值。后面80个数是每个类别的条件概率。
3.3 后处理核心:解码、置信度过滤与NMS去重
拿到原始输出后,后处理要做三件事:解码坐标、按置信度过滤、执行NMS。
解码坐标这一步要注意,模型输出的归一化坐标是相对于letterbox处理后的输入图像的,不是原图的。所以要先把(cx, cy, w, h)还原到letterbox后的像素坐标,再减去padding偏移,最后除以缩放比例,才能得到原图坐标。这一套换算就是粗暴的四步:乘640、减偏移、除比例、转换格式。
置信度过滤很简单,conf = obj_conf * cls_conf,这个乘积才是最终的检测置信度。很多新手只看类别置信度,没乘目标置信度,结果就是一堆垃圾框被保留下来。YOLOv11的模型输出结构里,框坐标之后就是目标置信度和类别概率,必须正确提取。
NMS去重我用的是OpenCV自带的cv2.dnn.NMSBoxes,它的作用是在重叠度过高的检测框里保留置信度最高那个,去掉重复框。但这里有一个版本兼容的坑:新版OpenCV的NMSBoxes返回值格式变了,早期版本返回的是[[0, 1, 2]]这种格式,新版直接返回一维数组。写代码时最好做兼容处理,否则版本一换,索引逻辑全错。
boxes = [] confidences = [] class_ids = [] for det in outputs[0]: # 遍历8400个候选框 cx, cy, w, h = det[:4] obj_conf = det[4] cls_conf = det[5:].max() conf = obj_conf * cls_conf if conf < conf_threshold: continue # 解码到原图 x1 = (cx - w / 2 - pad_x) / scale y1 = (cy - h / 2 - pad_y) / scale x2 = (cx + w / 2 - pad_x) / scale y2 = (cy + h / 2 - pad_y) / scale boxes.append([x1, y1, x2, y2]) confidences.append(float(conf)) class_ids.append(int(det[5:].argmax())) indices = cv2.dnn.NMSBoxes(boxes, confidences, conf_threshold, iou_threshold)置信度阈值和IOU阈值的选择会影响检测的准召率。置信度阈值一般设在0.25到0.5之间,IOU阈值默认0.45。如果你做的是安全类场景,宁可多报漏检也不要错检,可以把置信度阈值调高到0.6甚至0.7;如果是想尽量捕捉所有目标,可以调到0.15。
3.4 CPU推理性能优化:线程设置与推理模式
OpenCV DNN在CPU上的性能调优空间比很多人想象的大。我实测过几个参数的效果,影响最明显的是setNumThreads。OpenCV DNN默认会尝试使用所有CPU核心,但线程数不是越多越好,因为线程切换本身有开销,而且同一个net实例多线程推理时还有锁竞争的问题。
我的做法是配置cv2.setNumThreads(4),如果你的机器是8核以上,可以试试8,实测下来单次推理速度反而更快,因为减少了线程切换的开销。这里建议做个简单的benchmark脚本,分别测不同线程数下的性能,选最优解。
顺带提一个DNN领域特有但很少人注意的点:net.forward()执行时,内部可能有临时内存分配,高频调用下会产生碎片化。解决办法是在服务启动后预热一次推理,也就是先喂一张全黑图跑一遍forward,让OpenCV缓存好所有中间计算的内存布局,后面再推理就会快很多。
4. FastAPI服务层搭建
4.1 接口设计:单张图片检测接口的实现
FastAPI这层的设计相对直观,但有几个细节值得讲究。首先选择请求格式,我建议用multipart/form-data而不是Base64的JSON。因为图片本身就是二进制文件,multipart能让FastAPI直接给出上传的文件对象,内存开销更小,解析速度更快。
from fastapi import FastAPI, UploadFile, File import numpy as np app = FastAPI() @app.post("/detect") async def detect(file: UploadFile = File(...)): image_data = await file.read() img = cv2.imdecode(np.frombuffer(image_data, np.uint8), cv2.IMREAD_COLOR) results = inference(img) return { "detections": results, "count": len(results) }这里有一个隐形的坑:UploadFile的文件内容必须用await file.read()去读,因为FastAPI它本身是基于异步的,直接file.file.read()会阻塞事件循环。在高并发场景下,一个请求阻塞了事件循环,其他所有请求都会被拖慢。这是新手最容易犯的异步错误。
另外要注意cv2.imdecode返回的图片如果是None,说明上传的不是有效图片。这种情况要返回400错误而不是让服务器崩掉。
4.2 结果封装:边界框、置信度与类别信息的标准化
检测结果返回给客户端时,数据结构的规范程度决定了别人接你接口的效率。我见过的团队里,有人直接返回一个[x1, y1, x2, y2, conf, cls]的列表,看起来省事,但调用方用起来就是一肚子火。
我推荐的返回格式是用字典,字段名保持跟模型本身一致:类别用class_name,置信度用confidence,坐标用bbox。这样前端或者其他服务调用时,能直接用result["bbox"]["x1"]取数,不用猜测字段含义。
def format_result(box, conf, class_id, class_names): x1, y1, x2, y2 = box return { "bbox": {"x1": x1, "y1": y1, "x2": x2, "y2": y2}, "class_id": int(class_id), "class_name": class_names[int(class_id)], "confidence": round(float(conf), 4) }测试接口时,可以用curl模拟一段:
curl -X POST "http://localhost:8000/detect" \ -H "accept: application/json" \ -F "file=@test.jpg"正常的话会返回一个包含检测结果的JSON。如果返回的坐标出现负数或者框超出图片边界,大概率是后处理的坐标还原出了问题,返回去查那四步换算。
4.3 并发模型:同步还是异步的取舍
很多人在写FastAPI的时候有个误区:以为所有函数都要用async def。实际上,如果你的推理函数是CPU密集型的,用async def反而更差。因为你虽然放了await让出事件循环,但推理本身还是同步执行的,只是在后台另一个线程池里跑。更多情况下,推理代码会阻塞事件循环,导致服务吞吐量暴跌。
我测试过两种写法,同一个模型、同样的并发压力,纯同步def接口配合线程池的效果反而比async def好20%到30%。原因在于FastAPI对同步def会自动放到线程池里去执行,不影响事件循环;而async def里做的CPU密集任务会卡住整个循环。
@app.post("/detect_sync") def detect_sync(file: UploadFile = File(...)): image_data = file.file.read() img = cv2.imdecode(np.frombuffer(image_data, np.uint8), cv2.IMREAD_COLOR) results = inference(img) return {"detections": results, "count": len(results)}这不是说FastAPI的异步机制没用——如果你的服务里还调了别的IO接口(比如把结果写入数据库、调外部API),那异步的优势就体现出来了。但单纯做检测的话,同步+线程池是更符合直觉的选择。
4.4 请求限制与安全配置
部署服务的时候,有两项配置别漏。第一是请求体大小限制,不然有人传一个几十MB的超大图,你的预处理就会卡很久,占满CPU。FastAPI本身不直接给UploadFile提供大小限制,但你可以读文件时做一个判断:
MAX_IMAGE_SIZE = 20 * 1024 * 1024 # 20MB image_data = await file.read() if len(image_data) > MAX_IMAGE_SIZE: raise HTTPException(400, "图片大小超过限制")第二是超时设置。FastAPI默认不设超时,但前面的网关(比如Nginx)通常会设。如果推理一个图要1秒,而网关超时设了3秒,那么并发一高,队列排队的请求就很容易超时。要么调大网关超时,要么在服务设计里做限流。
4.5 性能测试:并发压力下的表现
服务搭好之后,别急着上线,先做一轮压测。我用apache-bench或者Python的asyncio写个并发脚本,分别测试QPS(每秒查询数)和P95延迟。一组我自己的数据供参考:单核CPU上YOLOv11s模型,单线程推理约70毫秒,4个并发线程时QPS可以达到30左右,P95延迟在300毫秒以内。如果你用2核以上机器,QPS能到50以上。
如果实测性能远低于这个数,优先级这样排查:先看是不是blobFromImage每次都重新分配了内存,再看是不是NMS实现有性能瓶颈,最后看是不是FastAPI事件的循环被某些阻塞调用拖住了。
5. 常见问题与排查技巧实录
5.1 旧版OpenCV加载ONNX失败:算子兼容性问题
这个坑很多人都会踩到:模型导出没问题,OpenCV加载时报错,大部分是版本带的算子不兼容。YOLOv11用了一些较新的算子,比如Crop、Split的新变种,如果你用的是4.5或者4.6的OpenCV,基本直接报错。
我的解决思路按优先级排列:升级OpenCV到4.8以上版本;如果版本升级不了,降低ONNX的opset版本重新导出;还不行,用onnx-simplifier简化模型。三步走基本能解决九成以上的加载问题。
5.2 检测框位置偏移:letterbox坐标还原出错
这是后处理环节最高频的bug。症状很典型:置信度是对的、类别也是对的、框位置偏差一点,尤其图像边缘的目标偏得离谱。原因就是坐标还原时,padding的偏移量和缩放比例搞混了。
我排查过这类错误,最常见的是dw/dh应该除以2的地方没有除,或者把dw/dh直接当成了整数坐标用。建议在写后处理代码后,先找一张正方形图片和一个明显超出640分辨率的图做测试,分别验证检测框是否和原图目标重合。
5.3 内存泄漏与服务不稳定:net实例的使用规范
FastAPI服务跑一段时间后内存持续上涨,最后被系统杀掉,这种问题十有八九是net实例被反复创建或者没有正确释放。一个服务里只应该有固定数量的net实例,而不是每个请求都创建一个。
我这里说一个很多人没注意到的OpenCV内部行为:cv2.dnn.readNetFromONNX加载模型后,模型权重是放在堆内存上的,Python的net对象被GC之后,这个堆内存不一定马上被释放——OpenCV底层有自己的内存缓存。所以即使你写了del net,内存也不一定会立刻降下来。最佳实践永远是池化管理,开多少线程就建多少net,一劳永逸。
5.4 推理结果为空:阈值与输入尺寸问题排查
有时服务能跑通,但就是检测不到任何目标(返回空列表)。这时先排除一个最常见的场景:你测试的图片目标太小,低于模型的检测下限。YOLOv11在640输入下,小目标的尺寸大概在32x32像素以下就很难检测到。
排除了这个之后,检查输入预处理是否有问题。一个很隐蔽的错误是:blobFromImage里的size参数如果设置和模型输入不一致,OpenCV DNN会静默地resize到模型输入尺寸,但letterbox的pading记录的是640尺寸下的值,已经对不上了,导致坐标全偏。所以blobFromImage的size参数一定要和导出ONNX时的输入尺寸一致。
5.5 问题速查表
| 症状 | 可能原因 | 解决方案 |
|---|---|---|
| 模型加载失败 | OpenCV版本过旧 | 升级版本或用简化工具 |
| 检测框偏移 | letterbox偏移未还原 | 检查dw/dh换算逻辑 |
| 结果始终为空 | 置信度阈值过高 | 调低阈值或检查输入预处理 |
| 内存持续增长 | net实例反复创建 | 使用模型池管理 |
| 服务响应极慢 | 事件循环被阻塞 | 改用同步def配合线程池 |
6. 实操总结与经验延续
我最后说几点自己在多次部署中实践出来的体会。部署服务和写训练代码完全是两种思维方式,训练时你可以自由调试、打印中间结果;部署时每一秒延迟、每一点内存开销都直接体现在成本上。所以我的建议是,代码结构一开始就往生产环境的标准上靠——模型池管理、参数配置化、日志规范、边界输入防御,这些都加上去,后续扩展才不会翻车。
还有一个很多人忽略的细节:模型的推理结果和调用方之间的协议要尽早定死。调用方需要的是原图坐标还是letterbox坐标?类别名用什么格式?如果这些在开发阶段没对齐,后面联调就是无尽的扯皮和返工。
如果你接下来想进一步优化性能,两条路值得研究。第一是把OpenCV的DNN后端切换到OpenVINO,在Intel CPU上推理速度还能再翻将近一倍,而且代码改动非常小,基本只是改一行初始化代码。第二是把检测结果做批量推理,把多个请求合并成一个batch消费,能显著提高吞吐量,但这需要引入消息队列,复杂度会上升一截。到时候我会在后续的篇章里专门展开讲,先把这套基础服务跑稳了再说。