做安全锥检测这个项目,其实是去年下半年接的一个公路养护单位的智能化巡检需求。他们每天派巡检员开车跑高速,靠肉眼数施工区摆放了多少安全锥、有没有倒伏、间距是否合规,一天下来上百公里,既累又容易漏。当时我就在想,这事完全可以让计算机视觉来干:车前装个摄像头,图像实时传入系统,用YOLO把安全锥框出来,再结合大模型生成一份巡检结论,最后在网页上调出来看。这个思路落地之后,效果比预期好很多,今天就把整套系统的技术方案和实操过程完整写出来,包含数据集处理、YOLO模型训练、SpringBoot后端、千问和DeepSeek接入、前后端分离的Web交互界面,给正在做类似巡检类项目的朋友一个可以直接参考的路径。
1. 项目全貌:安全锥检测到底要做什么
1.1 这个系统的真实应用场景
安全锥的学名叫交通锥,北方也叫路锥,民间叫雪糕筒,它算得上道路施工、事故处理、临时管制场景里出现频率最高的设备。传统巡检靠人工,不仅效率低,而且很难做量化管理——比如某个施工路段应该摆20个安全锥,实际只摆了17个,哪个桩号位置缺了、哪个倒在地上,这些问题人工肉眼扫过去很难在第一时间发现。
我把这个问题拆解成三块:识别、统计、分析。识别靠目标检测模型,把图像或视频流里的安全锥逐帧找出来;统计是在识别基础上做计数、定位、条数对比;分析则是把检测结果结构化之后,交给大模型生成自然语言的巡检结论,比如“当前路段共有安全锥23个,其中1个疑似倒伏,建议立即处理”。这三层做完,再套一个Web界面,整个系统就立体了,现场人员不用懂AI,打开网页上传图片或者看实时视频流就能用。
1.2 技术栈组合背后的思路
项目核心的技术选型是“YOLO + SpringBoot + 大模型API”。为什么不选TensorFlow或者更传统的Faster R-CNN?因为巡检场景对实时性和部署复杂度很敏感,YOLO系列本身就是工业部署的主流选择,YOLOv8之后更是把训练、导出、推理这一套流程工具链做得很完善,数据标注好之后几乎可以一键开训。SpringBoot则是后端服务的稳妥选项,Java生态在做接口、权限、数据库这块太成熟了,尤其是这种偏企业级的养护系统,甲方往往对SpringBoot有明确要求。大模型部分用了通义千问和DeepSeek,主要原因有两点:一是接入成本低,官方API按量付费,不需要自己部署计算资源;二是中文理解能力强,对公路巡检这类中文报告场景,千问和DeepSeek生成的分析文本质量实测比某些通用模型更靠谱。
再单独说一下为什么采用前后端分离架构。这个项目从第一天起就预期会有多个使用端——Web管理后台、现场大屏、以后可能还有移动端。如果做单体模板渲染,每一次加端都得动后端代码,非常痛苦。前后端分离之后,后端只负责出接口,前端独立部署,未来不管加什么客户端,后端逻辑基本不用改。这个决策在后期联调阶段给我省了大量时间。
2. 数据优先:YOLO训练数据的准备与格式转换
2.1 数据来源与标注工具
做目标检测项目,数据永远是最耗时的环节。安全锥这个类别并不冷门,先从公开渠道找到了一批包含安全锥的交通场景图片,真实场景中还包括行人和各类车辆,正好顺带把模型做成多类别检测。但这部分数据集直接拿来用会有一个问题:标注框的风格差异大,有些标注得比较宽松,有些又框得很紧,统一性不足。我的建议是把公开数据当作预训练基础,必须标注一批自己业务现场的真实数据来兜底。我当时找合作单位要了几段养护作业的监控视频,抽帧之后筛出不同光照、不同角度、有遮挡的图片,补标了约800张,数量和公开数据合并后大概有3000多张,这个规模对单类别检测已经够用了。
标注工具推荐用LabelImg或者X-AnyLabeling。LabelImg是老牌工具,操作简单;X-AnyLabeling支持半自动标注,预加载一个YOLO模型,你只需要微调,效率能提高不少。标注时特别注意一点:凡是露出一半以上锥体的都要标,倒伏的、部分遮挡的、远处模糊的,宁标勿漏,否则模型推理时会把没见过的形态当作背景直接忽略。
2.2 KITTI标注转YOLO格式实操
很多开源自动驾驶数据集用的是KITTI标注格式,比如Pedestrian 0.00 0 -0.20 712.40 143.00 810.73 307.92 ...,前面是类别名称,后面是目标框坐标。但YOLO训练需要的是归一化后的txt文件,一行代表一个目标,格式是class_id x_center y_center width height,而且全部归一化到0到1之间。这俩格式不互通,直接拿KITTI标注喂给YOLO,训练出来的模型必然混乱。所以第一步就是写一个转换脚本。
import os import shutil def kitti_to_yolo(kitti_txt_path, img_width, img_height, class_map=None): """ 将KITTI格式的标注转为YOLO格式 class_map: {"Car": 0, "Pedestrian": 1, "Cone": 2} """ yolo_lines = [] with open(kitti_txt_path, "r", encoding="utf-8") as f: for line in f: parts = line.strip().split() if len(parts) < 15: # 某些KITTI标注行可能缺后面的一串属性,跳过即可 continue cls_name = parts[0] left = float(parts[4]) top = float(parts[5]) right = float(parts[6]) bottom = float(parts[7]) # 过滤掉无效框 if right <= left or bottom <= top: continue x_center = (left + right) / 2.0 / img_width y_center = (top + bottom) / 2.0 / img_height w = (right - left) / img_width h = (bottom - top) / img_height # 归一化后做一次边界裁剪,防止出现超出0~1的数值 x_center = max(0.0, min(1.0, x_center)) y_center = max(0.0, min(1.0, y_center)) w = max(0.0, min(1.0, w)) h = max(0.0, min(1.0, h)) cls_id = class_map.get(cls_name, 0) yolo_lines.append(f"{cls_id} {x_center:.6f} {y_center:.6f} {w:.6f} {h:.6f}") return yolo_lines # 使用示例 lines = kitti_to_yolo("000001.txt", img_width=1280, img_height=720, class_map={"Cone": 0}) with open("000001.txt", "w", encoding="utf-8") as f: f.write("\n".join(lines))拿到YOLO格式标注之后,还要把数据集整理成目录结构。我的习惯是直接按YOLO官方风格建images和labels两个根目录,下面再分train和val:
datasets/cone/ ├── images/ │ ├── train/ │ │ ├── 000001.jpg │ │ └── ... │ └── val/ │ ├── 001200.jpg │ └── ... ├── labels/ │ ├── train/ │ │ ├── 000001.txt │ │ └── ... │ └── val/ │ └── ... └── data.yaml这里有个很多人会忽略的问题:图片和标注文件必须同名同前缀。YOLO训练时是按文件名找对应标注的,一旦名字对不上,那张图就会被当作无目标图片处理,轻则影响训练,重则导致模型学不到内容。我第一次整理数据时就吃过这个亏,后来写了一个校验脚本,把没有对应标注的图全部筛出来清理掉了。
2.3 数据增强与小目标漏检的应对
安全锥在画面里往往是很小的目标,尤其是几百米外的安全锥,可能只有十几个像素,这属于典型的小目标检测难题。模型容易漏检,本质上是因为COCO预训练模型对8x8像素以下的物体几乎无感。当时我试过几种解决办法:第一种是提升输入分辨率,把imgsz从默认的640提到960,小目标特征保留明显变多,但训练和推理时间上涨明显;第二种是给训练加Mosaic和MixUp数据增强,让模型见过更多遮挡、拼接的情况;第三种是在数据集里有意多放一些安全锥占比小的全景图,强迫模型去学习小目标特征。
我的实际结论是:项目优先保证中等距离目标的检测精度,近处目标因为尺寸大,什么模型都能检测到,真正影响用户体验的是远距离漏检。所以最终采用的方案是imgsz=960,关闭了部分过于激进的增强策略(如90度随机旋转),因为安全锥有显著的上下方向特征,旋转过度会导致语义错乱。
3. 模型训练与版本选型
3.1 YOLOv8/v10/v11/v12怎么选
标题里列了四个版本的YOLO,很多朋友会纠结到底用哪个,其实它们各自的应用场景差异比较大。我实际测试下来的体验是:
- YOLOv8:最稳的版本,文档最全、社区最大、导出的ONNX和TensorRT方案非常成熟。如果你的项目处于快速验证阶段,优先用v8。
- YOLOv10:主打无NMS推理,去掉了Non-Maximum Suppression环节,推理延迟理论更低,部署时少一个后处理步骤。但训练得到的模型在密集目标场景里偶尔会出现重复框,需要额外留意。
- YOLOv11:在v8基础上优化了Backbone和C3K2模块,精度小幅提升,推理速度基本持平,属于小步快跑的升级,可以无缝迁移v8的训练经验。
- YOLOv12:引入了注意力机制,对复杂背景的抗干扰能力更强,不过训练速度偏慢,需要仔细调学习率。
我的建议很直接:如果你的推理设备算力有限,用v8n或者v8s就够了;如果服务器算力充足且对精度要求高,直接上v11m或v12m。对于安全锥检测这种目标类别少、形态相对固定的任务,没必要追求最大的模型。小模型推理速度快,实时性更好,日常巡检完全够用。我最终在服务端部署的是YOLOv11s,精度和速度的平衡最理想。
3.2 训练配置与损失函数理解
确定数据集和模型之后,训练配置是下一步重点。我用的YOLOv11配置文件大致如下:
# cone.yaml path: ./datasets/cone train: images/train val: images/val nc: 1 names: 0: cone训练命令:
yolo detect train \ data=cone.yaml \ model=yolo11s.pt \ epochs=200 \ imgsz=960 \ batch=16 \ device=0 \ patience=30 \ project=runs/cone \ name=exp1 \ pretrained=True \ cache=True有几个参数值得重点解释。patience=30是早停策略,连续30个epoch在验证集上mAP没有提升就停止训练,避免过拟合。cache=True会把数据集缓存到内存,显著减少训练时磁盘IO的等待时间,如果你的机器内存够大(16G以上)建议打开。pretrained=True表示加载COCO预训练权重,对收敛速度帮助非常大,尤其适合数据集规模只有几千张的场景。
说到损失函数,很多人容易把它当成一个黑盒。YOLO的损失由三部分组成:box_loss(边框回归损失)、cls_loss(分类损失)、dfl_loss(分布焦点损失)。box_loss衡量预测框和真实框的差异,用的是CIoU或者WIoU这类损失,让模型不只是框得“差不多”,而是框得“准”。cls_loss是二值交叉熵,负责让类别预测更准确。dfl_loss是YOLOv8之后新加的,它把边框的坐标预测当成一个分布问题来优化,对小目标定位的提升比较明显。训练时如果发现最终模型边界框偏大或者偏小,可以考虑调整box_loss的权重系数,不过对安全锥这种框体一致性较强的目标来说,默认权重就够用。
3.3 评估指标与训练结果解读
训练完之后别急着部署,先用验证集做一轮评估。YOLO训练过程中会在验证集上持续输出mAP50、mAP50-95、precision、recall这几个指标。安全锥检测项目我最关注的是mAP50和recall。前者衡量的是实例检测的“框得准不准”,后者则更聚焦“有没有漏掉目标”。当时我的模型最终停在epoch 160左右,早停触发,mAP50到了0.93,recall 0.90,这个成绩对单类别检测来说已经是可用状态了。
还有一件事千万别省:把验证集的预测结果图翻出来一张一张看。训练日志里的数值只能说明模型“总体上还行”,真正暴露问题的是具体图片。比如我当时发现远距离小目标还有明显的漏检,后来回去核对了一下数据分布,发现训练集里小目标图片占比偏低,于是补了一批远景图重新训练,mAP50勉强到了0.94,但实际的远距离漏检率下降了大概三分之一。所以我的结论是:训练完多花一个小时翻图,比盲目调参一整天更有效。
4. 模型部署与推理服务
4.1 部署形态选择:为什么单独拆一个Python服务
模型训练好后,部署时面临一个技术选型问题:是直接把模型集成进SpringBoot(通过ONNX Runtime Java API或者Deep Java Library),还是把模型单独部署成一个Python推理服务,再让SpringBoot通过HTTP调用?
我的实践结论是:单独拆一个Python推理服务。原因有几个层面。一是生态问题,YOLO官方对Python的支持最完善,很多新特性比如TensorRT导出、SAHI切片推理,Python侧的支持永远是最及时的,Java侧的兼容性不确定性太高。二是资源隔离,目标检测推理是典型的CPU/GPU密集型任务,单独部署可以独立扩缩容,不会和业务接口互相抢占资源。三是维护成本,模型更新非常频繁,今天调个权重、明天换个版本,如果集成在Java进程里,每次更新都要重新编译打包,而独立服务只需要替换模型文件再重启,省事太多。
4.2 模型导出与加速
YOLO训练出来的原生权重是PyTorch格式(.pt),生产环境直接用这个文件推理速度太慢。我当时把它导出成两种格式:CPU服务器上用ONNX,有GPU卡的环境用TensorRT引擎。
# 导出ONNX yolo export model=runs/cone/exp1/weights/best.pt format=onnx imgsz=960 # 导出TensorRT(需要NVIDIA GPU) yolo export model=runs/cone/exp1/weights/best.pt format=engine device=0 imgsz=960ONNX导出后,推理框架我用的是ONNX Runtime,比直接用PyTorch快不少。TensorRT引擎要针对具体显卡型号和CUDA版本生成,换机器之后得重新导出,这也是它最大的不便之处。但换来的是推理速度几乎翻倍,如果你手头有可用的GPU卡,值得花这个时间。
CPU下推理性能也要关注。我当时在一台2核4G的云服务器上测试,ONNX模型跑960分辨率输入,单帧推理耗时大概220毫秒;换TensorRT之后同一台机器(如果没GPU的话只能靠CPU推理)也就到180毫秒左右。如果后续要做实时视频流检测,我建议把输入尺寸降到640,或者用SAHI切片推理——把大图切成若干小块分别检测再合并结果,虽然总耗时上升,但小目标的召回率会好很多。
4.3 推理接口封装
推理服务我用FastAPI写的,接口设计得很简单,一个健康检查接口,一个图片上传检测接口,一个Base64直传接口。关键代码如下:
from fastapi import FastAPI, UploadFile, File from PIL import Image import numpy as np import cv2 from ultralytics import YOLO app = FastAPI() model = YOLO("cone_best.onnx") # 加载ONNX格式模型 @app.post("/detect") async def detect(file: UploadFile = File(...)): # 读图 image_bytes = await file.read() img = cv2.imdecode(np.frombuffer(image_bytes, np.uint8), cv2.IMREAD_COLOR) # YOLO推理 results = model.predict(img, conf=0.4, iou=0.45, imgsz=960, verbose=False) # 整理成JSON结果 detections = [] for r in results: boxes = r.boxes.xyxy.cpu().numpy().tolist() scores = r.boxes.conf.cpu().numpy().tolist() classes = r.boxes.cls.cpu().numpy().astype(int).tolist() for box, score, cls in zip(boxes, scores, classes): detections.append({ "bbox": [round(v, 2) for v in box], "score": round(score, 4), "class_id": cls, "class_name": model.names[cls] }) return { "success": True, "count": len(detections), "detections": detections }这里的conf=0.4阈值是调出来的。阈值太高容易漏检,太低又会出现大量误报。安全锥颜色醒目、形态特征强,0.4到0.5之间是一个比较可靠的范围。处理视频流时,还可以加一个轻量的目标追踪逻辑,把相邻帧的同一个锥连接起来,避免重复计数。
5. SpringBoot后端开发
5.1 后端模块与数据库设计
推理服务就位后,SpringBoot后端要承担起业务编排的角色。我按功能拆分成这几个模块:用户模块(登录鉴权)、巡检任务模块(创建、查询、状态流转)、检测记录模块(存储每次检测的图片、结果JSON、报告)、大模型分析模块(调用千问和DeepSeek的封装)、系统配置模块(API Key管理、阈值设置)。
数据库方面,MySQL是主力存储。第一次建表时我直接用项目的SQL脚本初始化,后来发现开发环境来回换,表结构经常变,干脆引入了自动建表机制。Spring Boot整合MyBatis-Plus后配置mybatis-plus.global-config.db-config.table-auto-create或者用Flyway做版本化迁移都可以,实测Flyway的迁移能力更优雅,每次脚本变更都有版本记录,团队协作时不会被本地库差异坑到。
存储检测结果时,有个细节要提一下:目标检测返回的bbox、类别、置信度列表,如果做多字段表存储会很痛苦,而且后期调整检测逻辑时还要改表结构。我的做法是直接存一个JSON类型的字段,把Python推理服务返回的detections数组原样塞进去。查询时按任务ID过滤即可,报表统计时用JSON_EXTRACT(MySQL 5.7+)也能很方便地取出数量。
5.2 核心接口实现
接口设计遵循RESTful风格,主要端点大概是这样:
POST /api/task 创建巡检任务 POST /api/task/{id}/upload 上传巡检图片/视频 POST /api/task/{id}/detect 触发检测并保存结果 GET /api/task/{id}/result 获取检测结果 GET /api/task/{id}/report 获取大模型分析报告 GET /api/task/history 历史巡检记录列表 GET /api/stats/overview 统计面板数据上传+检测这个链路有一个设计决策值得说说:我把上传和检测拆成两步。原因是巡检场景经常面临弱网环境,巡检员拍完照片之后可能要先上传,之后网络稳定了再批量触发检测。如果上传接口内部直接同步调用推理服务,碰到大图或者多图请求时,前端等一个HTTP响应的体验会比较差。所以上传接口只做文件存储(存OSS或本地磁盘),返回文件ID;检测接口则把待检测文件ID列表传进来,后端同步调用Python推理服务完成检测。
Java侧调用推理服务时要注意超时设置。当时我踩过一次坑:视频抽帧检测的任务耗时比较长,Python服务要处理几十帧图像,结果超过默认的5秒超时时间,SpringBoot直接抛了Read Timeout异常。后来我用RestTemplate或者WebClient设置连接超时10秒、读取超时60秒,瓶颈问题就解决了。更复杂的场景建议上消息队列(RabbitMQ或Kafka),异步处理长任务。
5.3 SSE流式输出与配置安全
大模型生成分析报告是有“思考时间”的,可能持续几秒到几十秒。如果前端一直都在转圈等待一个完整的JSON返回,体验很差。我采用的方案是SSE(Server-Sent Events,服务器推送事件),后端把大模型返回的文本按token拆包推送给前端,前端逐字渲染,用户看起来就像AI在做实时打字,体验一下就好很多。
Spring Boot实现SSE非常简单,用SseEmitter:
@RestController @RequestMapping("/api/task") public class ReportController { @GetMapping("/{id}/report/stream") public SseEmitter streamReport(@PathVariable Long id) { SseEmitter emitter = new SseEmitter(60_000L); // 异步调用大模型服务,每拿到一段文本就send一次 executorService.execute(() -> { try { String fullReport = aiAnalysisService.analyze(id, chunk -> { try { emitter.send(SseEmitter.event().data(chunk)); } catch (IOException e) { emitter.completeWithError(e); } }); emitter.send(SseEmitter.event().data("[DONE]")); emitter.complete(); } catch (Exception e) { emitter.completeWithError(e); } }); return emitter; } }配置安全这块,API Key不能明文写在application.yml里,尤其是项目要推给甲方或者放到Git仓库的时候。我当时用Jasypt做配置文件加密,把spring.datasource.password、dashscope.api-key、deepseek.api-key全部加密存储,启动时通过环境变量注入解密密码。在application.yml里只能看到一串密文,即使仓库泄露,数据库和大模型的密钥也不会被人直接拿走。
6. 千问+DeepSeek接入实现智能分析
6.1 大模型API接入
千问和DeepSeek都提供了兼容OpenAI格式的HTTP接口,这点很关键,意味着你可以用同一个SDK或者同一套调用代码,只需要换base_url和api_key。千问走的是DashScope服务,DeepSeek走的是其官方开放平台。它们都支持gpt-3.5-turbo风格的chat/completions接口,从代码层面看,换个配置就能切换底座。
我当时的封装思路是:定义一个LlmProvider接口,包含chat(messages, callback)方法,然后分别实现QwenProvider和DeepSeekProvider,业务层通过@Value注入当前启用的provider。这样不仅支持双模型切换,还能做简单的负载分发——比如分析报告用千问,因为它的中文Summarization能力在同级别里较好;日常问答和短文本处理用DeepSeek,性价比更高。
实际调用代码大致如下:
// 以千问DashScope为例 public String callQwen(String prompt, Consumer<String> onChunk) { // 使用OpenAI SDK,设置baseUrl为DashScope的兼容地址 OpenAIClient client = OpenAIClient.builder() .baseUrl("https://dashscope.aliyuncs.com/compatible-mode/v1") .apiKey(qwenApiKey) .build(); ChatCompletionRequest request = ChatCompletionRequest.builder() .model("qwen-max") .messages(List.of( Message.system("你是一名道路养护巡检专家..."), Message.user(prompt) )) .stream(true) .build(); StringBuilder sb = new StringBuilder(); client.chatCompletionStream(request) .doOnNext(resp -> { String delta = resp.getChoices().get(0).getMessage().getContent(); if (delta != null) { onChunk.accept(delta); sb.append(delta); } }) .blockingSubscribe(); return sb.toString(); }不过有个细节必须提醒:流式API的HTTP连接不能被代理或者网关切断。我们当时在排查一个问题时发现,通过Nginx反代SSE接口时,如果Nginx没开proxy_buffering off,大模型返回的内容会一直被缓冲,前端迟迟收不到增量数据,表现就是页面一直没文字输出。这个问题困扰了我们大半天,最后定位到是Nginx配置缓存造成的,把缓冲关掉后立刻正常了。
6.2 提示词设计与结构化输出
同样是调用大模型,不同提示词拿到的结果天差地别。为了让大模型的分析能真正投入巡检业务,我把提示词设计成了“结构化输入+约束输出”的模式。
调接口时传入的检测数据长这样:
{ "taskId": "T20240612001", "roadSection": "G2高速K128+500至K129+000", "detectedCount": 23, "expectedCount": 25, "detections": [ {"bbox": [523, 410, 572, 478], "score": 0.91, "class_id": 0, "class_name": "cone"}, {"bbox": [1020, 386, 1158, 442], "score": 0.82, "class_id": 0, "class_name": "cone"} ], "abnormalFlags": ["count_mismatch", "low_confidence_near_km128_800"] }提示词的模板我写了很长一段,核心是下面几层指令:
- 角色设定:你是具有十年高速公路养护经验的安全巡检工程师。
- 任务指令:根据检测JSON生成一份巡检简报,必须包含总体判断、问题清单、处置建议三部分。
- 约束条件:只基于给定的检测数据进行判断,不要编造未检测到的信息;使用简明中文;如无异常直接说明;输出格式用固定的Markdown序号结构。
- 边界处理:如果检测出的数量与预期不符,优先给出人工复核建议,而不是直接下结论说现场违规。
实测下来,这个提示词设计让千问和DeepSeek返回的分析报告质量非常稳定。之前有人用很简单的“帮我分析一下这些数据”之类的提示词,大模型容易发散,甚至生成一堆无关痛痒的废话。提示词里带上精确的数字和明确的三段式结构,输出质量立刻上了一个台阶。
这里顺带说一下Postman调试技巧。很多人在调大模型API时直接在代码里试错,效率很低。其实这类兼容OpenAI的接口完全可以用Postman先跑通:新建一个POST请求,填上https://dashscope.aliyuncs.com/compatible-mode/v1/chat/completions,Headers里加Authorization: Bearer <你的Key>,Body里按JSON格式把model、messages、stream填好,点Send就能看到完整响应。先把接口在Postman里调通,再回Java代码写封装,出问题时的排查范围会小很多。
7. Web前端与前后端联调实战
7.1 前端页面结构
前端我选的是Vue3 + Vite + Element Plus + ECharts,这套组合做中后台管理系统非常顺手。页面拆成四个核心视图:
- 巡检大屏:展示当前巡检任务的实时推流画面(如有)或最近检测的图片,左侧安全锥计数卡片,右侧检测框叠加层,底部事故等级预警。
- 检测记录:按时间倒序展示历史检测记录,点开一张图能看到原始图像、YOLO标注框、检测结果表格、大模型分析报告。
- 统计分析:用ECharts画安全锥数量趋势、倒伏锥比例、各路段检出量对比柱状图,甲方汇报时直接截这个页面就行。
- 系统配置:模型置信度阈值、大模型API Key、任务预期数量等参数的动态配置。
前端调用接口时统一封装了request.js,基于Axios,拦截器里塞Token、统一处理错误码。大模型流式部分用EventSource或者fetch的ReadableStream实现。需要注意一件事:EventSource只能发GET请求,如果要带比较复杂的请求体,建议改用fetch+ReadableStream,自由度更高。
7.2 联调中的常见问题
前后端联调阶段,我遇到最多的问题是CORS跨域。SpringBoot默认不允许跨域,前端在开发模式下访问http://localhost:8080,而后端跑在http://localhost:9999,直接被浏览器拦截。解决办法两种:一种是在后端写一个CorsFilter配置类统一放行;另一种是用Vite的代理,开发时把/api前缀代理到后端地址,生产环境用Nginx做同源转发。我推荐第二种,因为生产环境本来就需要Nginx挂前端静态资源和反向代理后端,开代理之后连CORS都不用管了。
开发代理配置很简单:
// vite.config.js export default defineConfig({ server: { proxy: { '/api': { target: 'http://localhost:9999', changeOrigin: true, rewrite: (path) => path.replace(/^\/api/, '/api') } } } })联调时另一个常见问题是前端展示的图片路径不对。SpringBoot把图片存到本地磁盘的/data/upload/目录后,前端拿到的是/files/xxx.jpg,需要后端做一个静态资源映射,把http://localhost:9999/files/**映射到磁盘目录。用WebMvcConfigurer的addResourceHandlers方法即可。如果图片存的是OSS,那只需要返回完整URL,注意URL签名过期时间的问题即可。
还有一个很隐蔽的坑:请求体大小限制。SpringBoot默认spring.servlet.multipart.max-file-size是1MB,而巡检现场拍的高清图动辄5MB以上。第一次联调上传大图时,后端直接抛FileSizeLimitExceededException,前端却显示请求成功但数据为空,排查了好久。后来在application.yml里调大:
spring: servlet: multipart: max-file-size: 50MB max-request-size: 100MB8. 部署与常见问题排查
8.1 服务器部署与硬件兼容
整个系统我最终部署在一台云服务器上,前端用Nginx托管打包后的静态文件,后端SpringBoot打成jar包用systemd守护进程管理,Python推理服务单独跑在另一个端口上。数据库一开始就用云数据库,不需要自己运维MySQL。
如果你要把项目部署到阿里云这类云平台,流程基本是:前端npm run build生成dist,把dist传到服务器并配置Nginx指向,Java进程用nohup java -jar或者systemd跑起来,Python推理服务用uvicorn启动并配置systemd。需要注意云服务器的安全组规则,9999、8000这类非标准端口默认不开,需要在控制台手动添加规则放行,否则外部访问不到。
这里要重点说一下硬件兼容性问题。很多同学私信问我:AMD的RX 580显卡能不能跑YOLO训练?我的回答是:可以装环境,但很折腾。RX 580对CUDA生态不友好,YOLO官方在Linux下主要是靠NVIDIA CUDA加速,AMD显卡需要转向ROCm方案,但RX 580对ROCm的支持非常有限,实测性能也远不如同价位的NVIDIA显卡,而且经常遇到库版本兼容问题。如果你手里只有AMD显卡,建议训练阶段先租一块云GPU(比如阿里云的A10或T4实例),本地用CPU跑些小数据的debug,流程通了再上云训练。如果坚持本机训练,可以试试DirectML这个方向,Windows下能调用AMD显卡做推理加速,但训练速度依然不乐观。总结一句话:目标检测训练这件事,N卡仍然是当前最省心的选择。
8.2 高频问题速查表
最后整理一份我在整个项目周期里踩过的高频问题清单,按出现频率排序:
| # | 问题现象 | 根因分析 | 解决方案 |
|---|---|---|---|
| 1 | SpringBoot启动报错,依赖冲突 | SpringBoot 3.x版本过高,部分第三方starter还未适配 | 生产环境先固定在2.7.x稳定版本,或用3.2+并逐项检查依赖 |
| 2 | 上传大图后接口超时 | multipart限制或Python服务推理耗时过长 | 调大文件限制,设置RestTemplate合理超时时间 |
| 3 | YOLO预测结果在远处频繁漏检 | 小目标特征不足,可视化阈值过高 | 提升imgsz到960,合理设置conf阈值,考虑SAHI切片 |
| 4 | SSE前端一直不显示内容 | Nginx缓冲未关闭 | 在Nginx location配置proxy_buffering off、X-Accel-Buffering: no |
| 5 | 训练时显存不足 | batch或imgsz过大 | 调小batch,或开启cache并将数据放进内存降低IO压力 |
| 6 | KITTI标注转YOLO后训练报错 | 坐标归一化后出现负值或大于1 | 写转换脚本时对归一化结果做裁剪,过滤掉无效标注框 |
| 7 | MyBatis说表不存在 | 项目换了新库但没初始化表 | 引入Flyway迁移,或用MyBatis-Plus的自动建表配置 |
| 8 | CORS跨域报错 | 前后端端口不一致,且未配置CORS | 开发环境用Vite代理,生产环境用Nginx转发 |
表格里最让我印象深刻的是第一个SpringBoot版本问题。当时图新,直接用了SpringBoot 3.3.0,结果接工作流引擎的时候发现兼容性很差,一些老牌库只适配到Spring Boot 2.7。后来我把项目降级到2.7.18才彻底消停。这个教训让我后来做项目选型时,不再盲目追求最新版本,尤其是企业项目和甲方系统对接时,稳定优先、版本尽量跟主流才是正道。
最后再分享一点个人的经验
整套系统从数据标注到上线,前后花了不到三周。回头看,最核心的经验可以浓缩成三句话。第一,数据永远比模型重要,安全锥检测这个任务你花三天精标数据,比花三天调任何模型参数的收益都大。第二,系统集成比单个模型更费时间,YOLO模型训练其实是这个项目里最顺利的一环,真正卡壳的地方全在SpringBoot和前端联调、大模型流式输出、服务器部署这些环节,所以做项目时一定要给集成阶段预留足够的时间。第三,大模型接入看似简单,提示词和流式体验才是关键,你给模型的数据结构和约束指令写得越明确,生成报告的质量就越稳定。这个项目后续我还打算加上骨密度检测之类的新品类,理论上只要换一个训练好的检测模型,再改改提示词模板,整套系统架构完全不用动。技术选型一旦做对,后面扩展就是水到渠成的事。