水下鱼类检测现在是个挺热闹的方向,生态监测、水产养殖、海洋科研都在用。但真做过的朋友都知道,模型训练出来只是第一步,怎么把它包装成一个“别人能用的东西”才是麻烦事。这个项目标题其实已经把所有关键点都列出来了——YOLOv11做识别、Django做后端服务、DeepSeek做智能分析、配上登录界面和Web前端,最后用ONNX解决部署问题。整套链路走下来,才算是一个能给别人演示、能落地跑的系统,而不是一个躺在Jupyter里的Demo。
我打算按实际开发顺序来拆解这套系统,把每一步怎么想、怎么做、踩了哪些坑,都讲清楚。无论是想复现这个项目的同学,还是打算自己做类似“模型+Web”应用的朋友,这篇文章应该都能帮你省不少时间。
1. 整体方案设计与技术选型拆解
1.1 这套系统到底解决什么问题
先说个实际场景。假设你要给某海洋研究所做一套水下鱼类监测工具,需求大概是:研究人员登录系统,上传一段水下视频或者图片,系统自动识别画面里的鱼种、统计数量、标出位置,最后生成一份带分析结论的报告。如果全靠人工看视频数鱼,一小时素材能看一整天,而且不同人统计口径还不一样。这就是这个项目要解决的核心痛点——把“人眼数鱼”变成“算法检测+大模型分析”。
拆开看,系统其实需要四个能力:一是目标检测,能从水下画面里把鱼找出来;二是Web服务,得让用户通过浏览器访问,不能让大家装Python环境;三是智能分析,单纯画个框还不够,最好能生成“这张图里发现了3种鱼类,总共12条,其中某种鱼占比最高”这种结论;四是访问控制,系统不能裸奔,得有登录功能。
标题里选型其实都对应得挺准:YOLOv11负责目标检测,Django搭Web框架,DeepSeek做语义分析,ONNX管模型部署。这四个环节各司其职,拼起来就是一条完整的产品链路。
1.2 为什么是YOLOv11、Django、DeepSeek和ONNX这个组合
先说YOLOv11。相比前代版本,它在C3K2模块和SPPF结构上做了优化,检测头也调整了。最关键的是它在保持高精度的同时,模型体积和推理速度控制得不错。对于水下检测这种需要部署到边缘设备或普通服务器的场景,YOLOv11的性价比很合适。如果你以前用的是YOLOv8,迁移成本其实很低,配置文件和数据格式基本通用。选它还有一个现实原因——社区生态成熟,训练、导出、部署的资料特别多,遇到问题搜起来方便。做项目最怕的是选了个小众模型,报错了连个参考都没有。
然后是Django。说实话,做这种带管理后台、带登录鉴权的系统,Django简直是开挂。它自带Admin后台、用户认证、Session管理、CSRF防护,你不用自己写登录逻辑,直接用django.contrib.auth就能搞定。ORM用起来也省心,检测记录、用户信息直接映射成表。对于不熟悉前端的技术同学来说,Django的模板系统能把数据直接渲染成页面,省掉搭建前后端分离工程的复杂度。
DeepSeek在这里不是用来做检测的,而是接管“检测之后的语义分析”。为什么要加一个大模型进来?因为光画框不是终点,用户更想要结论。把YOLOv11检测到的鱼类名称、数量、置信度打包成文本,喂给DeepSeek,让它生成一份自然语言的分析报告。这一下就让系统从“工具”变成了“助手”。而且DeepSeek的接口调用成本和国内访问体验都挺实用,不需要像ChatGPT那样折腾网络配置。
最后说ONNX。训练好的PyTorch模型不能直接扔到生产环境,用户电脑上未必装了PyTorch。ONNX是一种中间格式,可以脱离PyTorch环境,配合ONNX Runtime跑推理。在CPU上的速度比PyTorch直接跑好不少,显存占用也更低。这个项目里把模型转成ONNX,就是为了部署时省心。
1.3 系统架构分层
从工程角度看,这套系统的代码组织大致分成三层:
- 模型层:训练好的YOLOv11权重导出为ONNX,封装成推理服务,输入图像输出检测结果(类别、坐标、置信度)。
- 服务层:Django应用,负责用户认证、文件上传、调用推理、调用DeepSeek生成报告、返回JSON数据给前端。
- 展示层:HTML模板+JavaScript,负责上传交互、结果可视化(图片上画框)、报告展示。
这三个层级之间通过JSON数据通信。前端拿到检测结果后,用Canvas在原图上绘制矩形框和标签,不需要后端生成标注图片,这样响应更快,也节省存储空间。
2. 模型侧:从训练到ONNX导出的核心细节
2.1 数据集准备与训练配置
水下鱼类检测的训练数据集,网上有公开的鱼类数据集(比如某些海洋生物图像库),但实际使用时通常要自己补充标注。水下画面和常规图像差异很大——光线偏蓝、对比度低、有悬浮颗粒、鱼群密集遮挡多。直接用通用目标检测数据集训练出来的模型,在水下场景识别效果往往不理想,这就是我前面说的“模型训练出来但不好用”的典型原因。
标注格式上,YOLO系列要求每张图配一个同名txt文件,每行格式是:类别id 中心点x 中心点y 宽 高,坐标值都归一化到0~1之间。如果你手里是COCO格式的JSON标注,记得先写脚本转成YOLO格式再训练。训练集和验证集按照8:2或9:1划分,每个类别至少要有几百张实例图,否则类别不平衡会让模型严重偏向多数的那个类。
训练配置建议参考以下基准:
| 参数名 | 推荐值 | 说明 |
|---|---|---|
| 输入尺寸 | 640×640 | 常规选择,精度和速度平衡 |
| batch size | 16(视显存调整) | 显存不够就降到8,配合梯度累积 |
| epochs | 100~200 | 用早停机制,看验证集mAP不再提升就停 |
| 优化器 | SGD或AdamW | YOLOv11官方默认SGD+momentum即可 |
| 学习率 | 0.01(配合预热) | 前几个epoch做warmup,避免震荡 |
| 数据增强 | mosaic、翻转、HSV扰动、随机仿射 | 提升模型对水下颜色偏移的鲁棒性 |
训练过程中需要注意,水下图像的标注质量直接影响模型上限。你标注的鱼如果边缘不清晰、框不贴合,模型学到的特征就是模糊的。建议所有标完的数据做一次人工复核,特别检查被遮挡和密集场景下的标注是否准确。训练完成看两个指标:验证集mAP50和mAP50-95,mAP50达到0.8以上基本够用,mAP50-95看类别难度到0.5以上算正常。
2.2 ONNX导出与推理封装
训练完得到best.pt权重文件,导出ONNX非常简单。Ultralytics官方包自带导出方法:
from ultralytics import YOLO model = YOLO("runs/detect/train/weights/best.pt") model.export(format="onnx", dynamic=True, simplify=True, opset=12)这里有几个参数值得说清楚。dynamic=True表示动态输入尺寸,因为用户上传的图片不一定是正方形,如果固定输入尺寸,推理时多一步强行缩放,精度会受影响。simplify=True会调用ONNX Simplifier做计算图优化,去掉一些冗余算子,推理速度能提升10%到20%。opset版本建议12或更高,版本太低某些新算子不支持。
导出完成后,用ONNX Runtime加载模型推理:
import numpy as np import onnxruntime as ort session = ort.InferenceSession("best.onnx", providers=["CUDAExecutionProvider", "CPUExecutionProvider"]) def preprocess(img, size=640): # 等比例缩放 + 填充到640x640 h, w = img.shape[:2] scale = min(size / h, size / w) new_h, new_w = int(h * scale), int(w * scale) resized = cv2.resize(img, (new_w, new_h)) canvas = np.full((size, size, 3), 114, dtype=np.uint8) canvas[:new_h, :new_w] = resized # BGR转RGB、归一化、转CHW、加batch维度 blob = canvas[:, :, ::-1].transpose(2, 0, 1).astype(np.float32) / 255.0 return blob[None, ...], scale, new_w, new_h预处理有个特别容易踩的坑:YOLO训练时用的是RGB顺序、归一化到0~1,但OpenCV读图出来是BGR、0~255。如果你忘了转通道顺序,检测效果会变得非常奇怪——鱼全都检测不到,或者置信度极低。转成CHW维度是为了配合ONNX模型的输入格式。推理结果里的坐标是相对于640×640填充图的,需要根据缩放比例映射回原图坐标,这个换算逻辑在画框时要用到。
def postprocess(outputs, scale, pad_w, pad_h, orig_w, orig_h, conf_thres=0.35): # outputs shape: [1, 84, 8400] -> [batch, 5+类别数, 候选框数] preds = outputs[0].squeeze(0).transpose(1, 0) # [8400, 84] boxes = preds[:, :4] scores = preds[:, 4:] class_ids = np.argmax(scores, axis=1) confs = scores[np.arange(len(scores)), class_ids] keep = confs > conf_thres boxes, class_ids, confs = boxes[keep], class_ids[keep], confs[keep] # NMS抑制重复框 indices = cv2.dnn.NMSBoxes( boxes.tolist(), confs.tolist(), conf_thres, nms_threshold=0.45 ) results = [] for i in indices.flatten(): x1, y1, x2, y2 = boxes[i] # 还原到原图坐标 x1 = (x1 - pad_w) / scale y1 = (y1 - pad_h) / scale x2 = (x2 - pad_w) / scale y2 = (y2 - pad_h) / scale results.append({ "bbox": [float(x1), float(y1), float(x2), float(y2)], "class_id": int(class_ids[i]), "confidence": float(confs[i]), }) return resultsNMS(非极大值抑制)这个步骤必不可少。同一群鱼可能被模型输出好几个重叠框,NMS会保留置信度最高的那个,把重叠度高的相近框去掉。如果省了这一步,前端画框时画面会非常乱,同一位置叠着三四个框。
推理封装建议写成单例模式,整个Django进程只加载一次ONNX模型。因为模型加载到内存里要几百MB,每来一个请求重新加载一次,系统直接卡死。我见过不少新手在视图函数里加载模型,并发一上来就内存溢出。正确的做法是在应用启动时预加载模型,或者用模块级全局变量缓存session对象。
3. Django后端:登录鉴权与检测API实现
3.1 项目结构划分
Django项目如果所有代码堆在views.py里,项目一大就是灾难。我习惯按功能拆分,类似这样:
fish_detection/ ├── manage.py ├── config/ # 项目配置 │ ├── settings.py │ └── urls.py ├── apps/ │ ├── accounts/ # 用户登录、注册、登出 │ ├── detection/ # 检测核心逻辑、模型推理调用 │ ├── analysis/ # DeepSeek报告生成 │ └── dashboard/ # 页面渲染 ├── static/ # JS、CSS、图片 ├── templates/ # HTML模板 ├── models/ # 存放ONNX权重等模型文件 └── utils/ # 通用工具,如图像预处理之所以拆成多个app,是为了让检测逻辑和用户逻辑互不干扰。后面如果你想加一个“检测历史记录”功能,直接在detection里加两张表就行,不用动accounts的代码。
3.2 登录鉴权实现细节
Django自带认证体系,直接用最稳妥:
# settings.py INSTALLED_APPS = [ ... "django.contrib.auth", "django.contrib.sessions", ] # 默认登录后跳转地址,可以自定义 LOGIN_URL = "/login/" LOGIN_REDIRECT_URL = "/dashboard/"在视图里,用装饰器保护需要登录的页面:
from django.contrib.auth.decorators import login_required from django.contrib.auth import authenticate, login, logout from django.shortcuts import render, redirect def user_login(request): if request.method == "POST": username = request.POST["username"] password = request.POST["password"] user = authenticate(request, username=username, password=password) if user is not None: login(request, user) return redirect("/dashboard/") else: return render(request, "login.html", {"error": "用户名或密码错误"}) return render(request, "login.html") def user_logout(request): logout(request) return redirect("/login/") @login_required def dashboard(request): return render(request, "dashboard.html")有人可能会问,为什么不自己写密码校验和Session管理?因为Django自带的方案经过了大量安全检查,包括常见的暴力破解防护扩展和Session固定攻击防护。自己写的登录逻辑很容易漏掉安全细节,比如CSRF攻击、密码存储明文等问题。做实际项目,能站在成熟框架的肩膀上就别自己造轮子。
前端登录页面需要把CSRF token放到表单里:
<form method="post" action="/login/"> {% csrf_token %} <input type="text" name="username" placeholder="用户名" required> <input type="password" name="password" placeholder="密码" required> <button type="submit">登 录</button> </form>如果去掉这个csrf_token,表单提交会被Django拦截,报403错误。这个很多人第一次接触Django时会困惑,其实是为了防止恶意网站伪造请求。
3.3 检测API与文件上传处理
图片上传接口的核心逻辑是接收文件、保存到临时目录、调用推理服务、返回结果。用Django的request.FILES来处理十分方便:
import os from django.conf import settings from django.http import JsonResponse from django.views.decorators.http import require_POST from utils.inference import ObjectDetector detector = ObjectDetector() # 模块加载时初始化一次 @login_required @require_POST def detect_image(request): upload_file = request.FILES.get("image") if not upload_file: return JsonResponse({"code": 400, "msg": "未接收到文件"}, status=400) # 限制文件大小,比如最大10MB if upload_file.size > 10 * 1024 * 1024: return JsonResponse({"code": 400, "msg": "图片大小不能超过10MB"}, status=400) # 保存临时文件 temp_path = os.path.join(settings.MEDIA_ROOT, "temp", upload_file.name) with open(temp_path, "wb") as f: for chunk in upload_file.chunks(): f.write(chunk) # 调用模型推理 img = cv2.imread(temp_path) detections = detector.detect(img) # 清理临时文件 os.remove(temp_path) return JsonResponse({"code": 200, "data": detections})文件上传有两个坑必须提一下。第一,一定要用upload_file.chunks()而不是一次性读入内存。用户传个大视频文件时,一次性读入直接能把服务器的内存撑爆。第二,临时文件用完要清理,否则用不了多久,服务器磁盘全是垃圾文件。如果你希望保留用户的历史记录,就在数据库里记录文件路径和检测结果,而不是把原图一直堆在服务器上。
视频检测和图片检测不太一样。视频是多帧连续画面,如果每帧都丢给DeepSeek做分析,成本太高。实际做法是抽帧处理——每隔几帧检测一次,然后汇总整段视频里出现的鱼类种类和大致数量。这个逻辑可以根据需求灵活调整,但基本思路是“抽帧检测+结果聚合”。
3.4 DeepSeek大模型接口集成
DeepSeek在这里扮演的角色是“语义分析层”。检测结果出来以后,只是一堆坐标和类别id,对用户来说不够友好。我们需要让系统生成一段人话,告诉他“这张图里有3条石斑鱼、2条小丑鱼,总体看鱼群密度中等”。这就是DeepSeek的活儿。
调用方式很直接,用HTTP请求打它的API接口:
import requests import json def generate_report(detections, total_count): if detections: # 统计各类别数量 species_summary = {} for d in detections: label = d["class_name"] species_summary[label] = species_summary.get(label, 0) + 1 summary_text = ", ".join([f"{k} {v}条" for k, v in species_summary.items()]) prompt = ( f"你是一个海洋生态分析助手。以下是水下图像的目标检测结果:" f"共检测到{total_count}条鱼,其中{summary_text}。" f"请根据这些数据生成一段简洁的中文生态观察报告," f"包括鱼种组成、可能的生活习性、对水质环境的推测。不要编造检测结果中不存在的数据。" ) else: prompt = "你是一个海洋生态分析助手。这张水下图像中没有检测到鱼类。请用一句话说明可能的原因。" try: resp = requests.post( "https://api.deepseek.com/v1/chat/completions", headers={ "Authorization": f"Bearer {settings.DEEPSEEK_API_KEY}", "Content-Type": "application/json", }, json={ "model": "deepseek-chat", "messages": [{"role": "user", "content": prompt}], "temperature": 0.5, "max_tokens": 300, }, timeout=30, ) resp.raise_for_status() return resp.json()["choices"][0]["message"]["content"] except Exception as e: return "报告生成失败:模型接口暂时不可用。"这里有几个设计要点。温度参数调到0.5左右,是为了让大模型既不过于发散也不过于保守。不要把temperature调太高,否则报告可能胡编乱造出检测结果之外的鱼种。Prompt里必须明确约束“不要编造检测结果中不存在的数据”,这一步可以很大程度上防止大模型幻觉。
至于性能,调用DeepSeek接口一般需要2到6秒。在Web层面不能让用户干等,可以选两种方案:一是同步等待,接口返回后一起把检测框和报告展示出来;二是异步处理,先返回检测框,报告在后台生成后通过轮询接口获取。如果追求体验,异步方案更好,但代码复杂度高一些。对于自己用或演示的项目,同步方案够用。我这个项目实现的是同步返回,但设置了超时保护,避免接口故障时整个请求挂死。
4. 前端交互与精美界面实现思路
4.1 页面整体布局
这个项目的界面设计目标很明确——让用户第一眼就知道系统是干嘛的,操作路径清晰。整体页面分成登录页、控制台、检测页和检测结果展示四个核心区域。
登录页走的是简洁路线:居中卡片、系统Logo、用户名密码输入框,背景用一张深海蓝调图片营造主题氛围。控制台展示最近检测记录、检测总量统计、各类别出现次数等汇总信息。检测页是核心操作区:左侧是文件上传区(支持拖拽上传),右侧是结果展示区。用户上传图片后,页面即时显示标注后的图像和DeepSeek生成的报告。
4.2 前端实现与Canvas标注
画检测框用Canvas实现,思路是把原图画到Canvas上,然后根据后端返回的坐标信息在Canvas上再叠加绘制矩形框和文字标签。这样可以不动原图文件,纯前端完成标注展示。
function drawDetections(image, detections) { const canvas = document.getElementById("result-canvas"); const ctx = canvas.getContext("2d"); canvas.width = image.width; canvas.height = image.height; // 按原图大小绘制 ctx.drawImage(image, 0, 0); // 遍历检测结果画框 detections.forEach(det => { const [x1, y1, x2, y2] = det.bbox; ctx.strokeStyle = "#00ffcc"; ctx.lineWidth = 3; ctx.strokeRect(x1, y1, x2 - x1, y2 - y1); // 绘制标签背景 ctx.fillStyle = "rgba(0, 255, 204, 0.7)"; ctx.fillRect(x1, y1 - 25, 140, 25); ctx.fillStyle = "#000"; ctx.font = "bold 16px Arial"; ctx.fillText(det.class_name + " " + det.confidence.toFixed(2), x1 + 5, y1 - 5); }); }画框有个细节需要注意:字体大小和框的尺寸不是固定值,大屏幕上画出来的框标签文字如果太小会看不清。经验做法是标签高度可以随框大小微调,或者置顶一个固定字号。另外,置信度保留两位小数就好,不要显示一大串小数,干扰视觉。
4.3 异步上传与进度反馈
用户体验上,最怕的是点击上传后页面卡住,用户以为系统挂了。优化方案是采用Ajax异步上传,加上Loading动画和进度条。
前端用fetch发送FormData到后端接口,然后接收JSON响应:
async function uploadAndDetect(file) { const formData = new FormData(); formData.append("image", file); showLoading(true); try { const response = await fetch("/api/detect-image/", { method: "POST", body: formData, headers: { "X-CSRFToken": getCookie("csrftoken") } }); const result = await response.json(); if (result.code === 200) { renderResult(result.data); } else { showError(result.msg); } } catch (err) { showError("网络请求失败,请稍后重试"); } finally { showLoading(false); } }Ajax请求里同样要带上CSRF token,否则Django会拒绝请求。这也是个容易踩的坑——测试接口用Postman没问题,浏览器里Ajax却一直403,多半就是没带token。
视频检测的交互比图片麻烦一些。视频需要先上传到后端,后端抽帧检测,然后再把标注后的视频返回到前端。如果视频比较大,这个过程可能要十几秒。前端就得显示进度百分比。简单方案是后端每处理完一帧,就把进度写入一个临时状态表,前端用轮询接口去查进度。复杂方案是用WebSocket实时推送进度,但工程复杂度会明显上升。我的建议是先从轮询做起,等性能瓶颈真的出现了再考虑升级。我自己做的时候,视频处理用的是两步:上传后返回一个任务ID,前端每2秒请求一次该任务的检测结果,检测完成就返回数据,没完成就返回进度百分比。
4.4 中文字体与界面美化注意事项
Canvas绘制检测标签时,默认字体可能不支持中文,ctx.fillText画出来全是方块。解决方式是加载自定义字体,或者直接使用系统已有中文字体:
ctx.font = "bold 16px 'Microsoft YaHei', 'PingFang SC', sans-serif";如果要在Django服务端直接用OpenCV画框输出图片,也存在同样问题。cv2.putText默认用的Hershey字体不支持中文,你需要用PIL加载中文字体再画文字。这也是我在调试时报错最多的地方,做完这个项目我才意识到中文字体兼容性是做中文场景Web应用的隐藏坑。
后端画框示例:
from PIL import Image, ImageDraw, ImageFont import numpy as np font = ImageFont.truetype("static/fonts/simhei.ttf", 20) def draw_boxes_cn(img, detections): """OpenCV图像转PIL绘制中文字体标签""" img_rgb = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) pil_img = Image.fromarray(img_rgb) draw = ImageDraw.Draw(pil_img) for det in detections: x1, y1, x2, y2 = [int(v) for v in det["bbox"]] draw.rectangle([x1, y1, x2, y2], outline=(0, 255, 204), width=3) label = f"{det['class_name']} {det['confidence']:.2f}" draw.text((x1, max(0, y1 - 25)), label, font=font, fill=(0, 255, 204)) return cv2.cvtColor(np.array(pil_img), cv2.COLOR_RGB2BGR)5. 常见问题排查与调优实战记录
5.1 ONNX推理结果与PyTorch不一致
最常见的坑之一:model.predict()效果很好,但ONNX Runtime推理结果差很多甚至完全检测不到目标。我排查了一圈,发现问题几乎都出在预处理不一致上。PyTorch推理时,Ultralytics内部会自动做BGR转RGB、归一化、缩放填充;而用ONNX Runtime推理时,这些预处理必须自己写,一旦遗漏了通道转换,模型输入分布就不对了。
还有一个隐蔽问题是letterbox填充值。YOLOv5及之后的版本,缩放填充的默认填充色是灰色114,也就是(114, 114, 114)。如果用0填充,模型对填充区域的响应就会异常,检测效果明显变差。这个细节我第一次没注意到,调试了整整半天才发现是填充值的问题。
注意:用ONNX Runtime替换PyTorch推理后,务必用同一张测试图做对比验证,逐项检查预处理差异。别只跑一张图说结果正常,要多测几张不同尺寸的图。
5.2 视频检测时内存暴涨
视频检测比图片吃内存得多。有人直接把整个视频文件读进内存再逐帧处理,视频稍微一大,服务进程内存直接跑到2GB以上,最终被系统杀掉。正确做法是流式读取视频帧:
import cv2 cap = cv2.VideoCapture(video_path) frame_count = 0 while True: ret, frame = cap.read() if not ret: break if frame_count % interval == 0: # 每隔N帧检测一次 detections = detector.detect(frame) # 汇总结果 frame_count += 1 cap.release()此外,检测完的视频或者大量临时帧不要全部保存在内存里,要么处理完立即释放,要么写到本地磁盘。这个问题的核心是:批量数据会用完即弃,而不是全局持有。
5.3 DeepSeek接口超时与限流
大模型接口虽然好用,但稳定性受网络影响较大。排查过程中我遇到的典型错误有几种:requests超时、HTTP 429限流、返回数据里没有choices字段。解决方案是调用必须设置timeout参数,同时对返回结果做完整的结构校验。另外,建议在项目里加一个简单的重试机制:第一次失败,间隔2秒重试一次,最多重试三次。
关于限流,DeepSeek接口有每分钟请求次数限制,具体以官方文档为准。如果多个用户同时使用系统,每秒并发请求可能触发限流。一个简单的缓解方案是加内存队列或者Redis队列,把分析请求排队处理,避免一次性打满接口限额。不过如果只是单人使用或内部演示,这块可以暂时不做复杂处理。
5.4 Django并发与性能优化
Django自带开发服务器runserver性能很差,只能用于本地调试。如果部署到服务器,必须用gunicorn或uWSGI。这里有个容易踩的坑:gunicorn默认每个worker只处理一个请求,如果模型推理耗时2秒,4个worker的情况下并发能力也才4个请求。建议调大worker数和线程数,比如gunicorn config.wsgi:application -w 4 --threads 8,让每个worker支持多线程并发。
模型推理是CPU密集或GPU密集操作,如果跑在CPU上,多线程反而可能因为GIL限制导致推理速度下降,因为ONNX Runtime内部有自己的线程池。一个比较实用的配置是:ONNX Runtime用intra_op_num_threads=4控制CPU线程数,Django worker数不要开太大,避免上下文切换开销。我做性能压测时发现,CPU环境4个worker是最优平衡点,再往上性能反而下降了。
5.5 常见问题速查表
| 问题现象 | 可能原因 | 解决方案 |
|---|---|---|
| 登录后访问任意页面跳回登录页 | Session过期或请求未携带Session Cookie | 检查浏览器Cookie是否被禁用,确认Django的SESSION_COOKIE_AGE配置 |
| Ajax上传图片返回403 | 请求头缺少X-CSRFToken | 在Ajax请求中携带CSRF token,或使用csrf_exempt(不推荐) |
| ONNX推理检测不到任何鱼 | 预处理通道顺序不对或letterbox填充值不正确 | 复核BGR转RGB、归一化、填充值114 |
| 模型加载占满服务器内存 | 每个请求都重新加载模型 | 改为模块级单例加载,启动时预加载一次 |
| DeepSeek报告出现编造的鱼种 | 大模型幻觉或temperature过高 | Prompt中明确约束,降低temperature到0.5以下 |
| 画框文字全是方块 | Canvas字体不支持中文 | 指定中文字体,如Microsoft YaHei、PingFang SC |
| 视频检测卡死 | 内存溢出或一次性读入视频 | 改为流式读取,逐帧处理,及时释放资源 |
6. 部署上线与项目扩展方向
6.1 生产环境部署流程
Django项目部署其实有个固定的套路。用gunicorn跑Django应用,前面再挂Nginx做反向代理和静态文件服务。
# 安装依赖 pip install gunicorn django onnxruntime requests # 启动gunicorn,绑定时指定端口 gunicorn config.wsgi:application -w 4 --threads 8 -b 0.0.0.0:8000Nginx配置片段:
server { listen 80; server_name your_domain; # 替换为实际域名或IP # 静态文件 location /static/ { alias /path/to/project/static/; } # 媒体文件(上传的图片等) location /media/ { alias /path/to/project/media/; } location / { proxy_pass http://127.0.0.1:8000; proxy_set_header Host $host; proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; } }部署时还有两件事别漏掉。第一,DEBUG模式一定设为False,否则Django会把详细的报错信息直接暴露给用户,非常危险。第二,启动前先执行python manage.py collectstatic,把静态文件收集到一个目录,否则Nginx找不到CSS和JS文件,页面样式会全部丢失。
6.2 关于“支持RTSP视频流接入”的思考
项目做完后,当前版本以图片和本地视频为主。如果要完全贴合水下监测的实时场景,扩展方向最值得做的就是接入RTSP视频流,也就是把水下摄像头的实时画面直接送进检测系统里。这个的需求匹配度非常高。实现思路是FFmpeg拉流,然后逐帧喂给ONNX模型。Django后端可以切换到用外部的任务队列来处理流数据,每个摄像头对应一个后台线程或进程,检测结果通过WebSocket推送到前端。如果你用的是USB摄像头,测试阶段直接用OpenCV读取本地摄像头画面验证明白,之后再切到RTSP同样改一行URL就能搞定。
6.3 检测结果入库与统计分析
现在检测完的结果如果不记录,就浪费了一部分数据价值。我建议加两张表:一张存检测任务,包括用户、上传时间、图片路径、检测状态;另一张存检测明细,包括任务ID、类别、置信度、坐标。这样在控制台就能做统计:某段时间内哪个鱼种出现最多、检测总量趋势图等。Django的ORM做这类统计聚合很方便。
from django.db.models import Count from apps.detection.models import DetectionRecord def get_species_statistics(user): # 按类别统计当前用户检测到的鱼类数量 stats = ( DetectionRecord.objects.filter(user=user) .values("class_name") .annotate(total=Count("id")) .order_by("-total") ) return list(stats)有了历史数据积累,后续还能做更多事情,比如异常检测——某天鱼群数量突然大幅下降,系统主动提醒用户注意水质变化,这就是从“识别工具”到“分析平台”的进化方向。
最后再分享一个我个人的体会。做这种“算法+Web”的完整项目,最耗时间的不是算法本身,反而是那些不起眼的工程细节——模型导出格式对不上、前端字体乱码、Ajax请求被CSRF拦截、服务器并发一高就内存溢出。每一个坑单看都不难,凑在一起却让人挠头。建议大家在动手前先列一个完整的技术栈清单,把部署环境的版本、依赖项都固定下来,少在环境问题上耗精力。系统跑通的成就感还是很值钱的,尤其是当你把源码、模型、Web界面打包完整,丢给别人就能直接用的时候。