檢測系統(tǒng)設(shè)計與聯(lián)調(diào))
簡介面向計算機視覺、深度學(xué)習(xí)方向的學(xué)習(xí)者與畢業(yè)設(shè)計開發(fā)者這套工程給出基于SpringBoot、Flask、Vue與YOLOv5的前后端分離式目標(biāo)檢測系統(tǒng)可用于課程設(shè)計、項目復(fù)現(xiàn)和二次開發(fā)。系統(tǒng)按三端拆分職責(zé)Flask端加載YOLOv5權(quán)重完成圖像推理并封裝檢測接口SpringBoot端承接業(yè)務(wù)請求、串聯(lián)檢測結(jié)果Vue端實現(xiàn)圖像上傳、結(jié)果展示與頁面交互配合yaml配置與說明文檔能幫助讀者理解跨語言服務(wù)協(xié)作、模型調(diào)用和前后端聯(lián)調(diào)流程。壓縮包共60個文件約82.63MB以28個py業(yè)務(wù)與推理腳本、24個yaml配置、2個md說明文檔為主另有pyc緩存和分端zip包目錄按flask端、web端、springboot端組織便于按模塊查閱。已有425人學(xué)習(xí)適合作為可運行、可拆解的目標(biāo)檢測全棧范例用于梳理接口邊界與工程結(jié)構(gòu)。1. 需求邊界與選型為什么把 SpringBoot、Flask、Vue、YOLOv5 拆成四段做目標(biāo)檢測畢設(shè)的人常遇到一個尷尬Vue 頁面已經(jīng)能傳圖SpringBoot 接口也寫好了YOLOv5 卻還在另一個 Python 進(jìn)程里三邊靠手動復(fù)制文件對接。這套基于 SpringBoot、Flask、Vue、YOLOv5 的前后端分離式目標(biāo)檢測系統(tǒng)解決的不是“模型能不能跑”而是模型服務(wù)怎么被業(yè)務(wù)系統(tǒng)管起來誰存任務(wù)、誰調(diào)推理、誰把框畫回頁面。適合要交課程設(shè)計、畢設(shè)或要給內(nèi)部演示做一套目標(biāo)檢測系統(tǒng)的人。把四段拆開是因為 Java 擅長用戶、權(quán)限、任務(wù)表和文件記錄Python 擅長加載深度學(xué)習(xí)權(quán)重Vue 負(fù)責(zé)工作臺交互。硬把 YOLOv5 塞進(jìn) SpringBoot 里JVM 調(diào) Python 的鏈路會變成黑匣子全用 Flask 寫業(yè)務(wù)課程設(shè)計里的鑒權(quán)和任務(wù)管理又容易寫薄。注意這套方案默認(rèn)輸入是圖片視頻抽幀和攝像頭流可以后續(xù)加權(quán)重和類別名按你的數(shù)據(jù)集替換接口契約不變。2. 總體架構(gòu)設(shè)計四層鏈路與三個進(jìn)程的職責(zé)劃分把系統(tǒng)拆成三個進(jìn)程不是擺樣子而是讓模型重啟不拖垮業(yè)務(wù)接口。課程設(shè)計里最常見的寫法是 SpringBoot 上傳文件后直接Runtime.exec調(diào) Python 腳本演示時能跑一到連續(xù)檢測就翻車腳本路徑、臨時文件、錯誤輸出全混在一起出了問題只能看控制臺。前后端分離式目標(biāo)檢測系統(tǒng)要把“業(yè)務(wù)狀態(tài)”和“推理狀態(tài)”分開SpringBoot 管任務(wù)生命周期Flask 只管一張圖進(jìn)、一組框出Vue 只管交互和展示。2.1 四層職責(zé)與進(jìn)程邊界我一般把系統(tǒng)劃成四層展示層、業(yè)務(wù)層、推理層、數(shù)據(jù)層。展示層是 Vue 工作臺負(fù)責(zé)上傳、輪詢、畫框和歷史回放業(yè)務(wù)層是 SpringBoot負(fù)責(zé)登錄鑒權(quán)、文件校驗、任務(wù)表、調(diào)用 Flask、保存結(jié)果推理層是 Flask YOLOv5負(fù)責(zé)加載權(quán)重、預(yù)處理、推理、后處理數(shù)據(jù)層是 MySQL 加本地文件目錄任務(wù)元數(shù)據(jù)進(jìn)表原圖和結(jié)果 JSON 按任務(wù)號歸檔。模塊主要職責(zé)輸入輸出失敗時怎么辦Vue 工作臺上傳圖片、展示進(jìn)度、畫檢測框、查歷史用戶選擇的圖片檢測結(jié)果視圖提示任務(wù)失敗保留原圖預(yù)覽SpringBoot 業(yè)務(wù)層鑒權(quán)、文件校驗、建任務(wù)、調(diào) Flask、落庫multipart 文件、conf 參數(shù)taskId、任務(wù)狀態(tài)、結(jié)果 JSON把任務(wù)置為 FAILED寫 error_msgFlask 推理層加載 YOLOv5、推理、序列化檢測框圖片字節(jié)流、conf、ioudetections、原圖寬高、耗時返回 4xx/5xx 和錯誤信息數(shù)據(jù)層存任務(wù)、文件路徑、結(jié)果、時間業(yè)務(wù)層寫入歷史查詢文件缺失時任務(wù)標(biāo)記異常這張表看著簡單真正落地時邊界要卡死Flask 不做用戶登錄不查數(shù)據(jù)庫不關(guān)心前端是誰SpringBoot 不加載best.pt不碰 torch不解析模型輸出以外的業(yè)務(wù)規(guī)則Vue 不直接請求 Flask 的 5001 端口。這樣模型換權(quán)重、換設(shè)備、換 Python 依賴只動推理層頁面改布局只動展示層。黑匣子被拆成三段每段都有日志可查。2.2 請求鏈路與任務(wù)狀態(tài)機一次檢測的鏈路通常是Vue 把圖片傳到POST /api/detectSpringBoot 校驗后綴和大小把文件存到uploads/日期/任務(wù)號.后綴往detect_task插一條 PENDING業(yè)務(wù)層用線程池把任務(wù)提交給 Flask 的POST /predictFlask 加載模型并推理返回檢測框 JSONSpringBoot 把狀態(tài)改成 SUCCESS把結(jié)果寫進(jìn)result_jsonVue 每隔 1.5 秒查一次GET /api/tasks/{taskId}拿到 SUCCESS 后畫框。任務(wù)狀態(tài)只留四個PENDING、RUNNING、SUCCESS、FAILED。不要加“處理中 30%”這種假進(jìn)度模型推理沒有真實百分比前端顯示“檢測中”加輪詢次數(shù)就夠了。失敗必須寫error_msg否則答辯時老師問“這張為什么沒框”你只能重新跑一遍。{ taskId: a3f1c9e2b7d84f6c, status: SUCCESS, imageWidth: 1280, imageHeight: 720, detections: [ { label: person, score: 0.9123, x1: 214.5, y1: 96.2, x2: 438.8, y2: 601.4 } ], costMs: 186 }這段 JSON 是前后端的契約坐標(biāo)統(tǒng)一用原圖像素不用百分比也不在前端二次換算。imageWidth和imageHeight必須和原圖一致前端 canvas 按這個寬高設(shè)置CSS 只負(fù)責(zé)縮放顯示。costMs是 Flask 推理耗時驗收時看它判斷是模型慢還是網(wǎng)絡(luò)慢。label用類別名而不是類別 id前端不用再維護一份映射表如果要做多語言再在業(yè)務(wù)層轉(zhuǎn)換。2.3 部署拓?fù)渑c端口約定課程設(shè)計階段可以三端同機跑Vue 開發(fā)服務(wù)器 5173SpringBoot 8080Flask 5001MySQL 3306。正式演示時 Vue 執(zhí)行構(gòu)建靜態(tài)文件交給靜態(tài)服務(wù)器SpringBoot 只暴露/apiFlask 只監(jiān)聽本機回環(huán)地址由 SpringBoot 調(diào)用不對瀏覽器開放。這樣文件校驗、權(quán)限、任務(wù)記錄都在一個入口前端也不會因為跨域配置漏掉 5001 而翻車。組件開發(fā)端口啟動方式誰訪問它Vue5173npm run dev瀏覽器SpringBoot8080IDE 或java -jar瀏覽器、FlaskFlask5001python app.py僅 SpringBootMySQL3306本機服務(wù)SpringBoot提示Flask 的host寫127.0.0.1不要圖省事寫0.0.0.0再讓瀏覽器直連。所有檢測請求走 SpringBoot 的/api/detect模型端口不暴露后面加鑒權(quán)、限流、文件白名單都只改一處。3. 前后端業(yè)務(wù)閉環(huán)SpringBoot 接口與 Vue 工作臺的落地這一章把業(yè)務(wù)層和展示層接起來。很多方案寫到“前端上傳、后端調(diào)用模型”就停了真到聯(lián)調(diào)時卡在數(shù)據(jù)庫字段、文件路徑、輪詢清理上。我的做法是先定表再定接口最后寫 Vue 頁面表結(jié)構(gòu)定錯后面接口和頁面都要返工。3.1 任務(wù)表與文件記錄任務(wù)表只存檢測任務(wù)需要的字段不要把圖片二進(jìn)制塞進(jìn)數(shù)據(jù)庫。原圖放uploads表里存相對路徑結(jié)果 JSON 放result_json課程設(shè)計查詢方便結(jié)果特別大時再改成存文件路徑。CREATE TABLE detect_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_no VARCHAR(64) NOT NULL UNIQUE COMMENT 任務(wù)號前端輪詢用, original_name VARCHAR(255) NOT NULL COMMENT 原始文件名, stored_path VARCHAR(500) NOT NULL COMMENT 相對 uploads 的路徑, status VARCHAR(16) NOT NULL DEFAULT PENDING COMMENT PENDING/RUNNING/SUCCESS/FAILED, model_name VARCHAR(128) DEFAULT NULL COMMENT 權(quán)重文件名便于對比實驗, conf_thres DECIMAL(4,3) DEFAULT 0.250 COMMENT 本次檢測的置信度閾值, result_json LONGTEXT DEFAULT NULL COMMENT 檢測框 JSON, error_msg VARCHAR(1000) DEFAULT NULL COMMENT 失敗原因, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, finished_at DATETIME DEFAULT NULL, KEY idx_status_created (status, created_at) ) ENGINEInnoDB DEFAULT CHARSETutf8mb4;task_no用 UUID 去橫線前端拿到后只認(rèn)這個號不暴露自增 id。stored_path存相對路徑換機器時只改app.upload-dir歷史數(shù)據(jù)還能用。model_name和conf_thres看起來多余答辯時很有用同一張圖換yolov5s和自訓(xùn)練權(quán)重能直接對比結(jié)果老師問閾值多少表里有記錄不用翻代碼。finished_at用來算耗時后面驗收表會用到。3.2 上傳與任務(wù)下發(fā)接口上傳接口只做三件事校驗、存文件、建任務(wù)并提交異步推理。不要在 Controller 里等 Flask 返回首次加載模型可能十幾秒同步接口很容易超時。RestController RequestMapping(/api) public class DetectController { Value(${app.upload-dir}) private String uploadDir; private final DetectTaskService taskService; public DetectController(DetectTaskService taskService) { this.taskService taskService; } PostMapping(/detect) public ResultMapString, Object detect( RequestParam(file) MultipartFile file, RequestParam(defaultValue 0.25) float conf) throws IOException { String original file.getOriginalFilename(); String ext original null ? : original.substring(original.lastIndexOf(.) 1).toLowerCase(); if (!Set.of(jpg, jpeg, png, bmp).contains(ext)) { return Result.fail(只支持 jpg/jpeg/png/bmp); } if (file.getSize() 10 * 1024 * 1024) { return Result.fail(圖片不能超過 10MB); } String date LocalDate.now().format(DateTimeFormatter.BASIC_ISO_DATE); Path dir Paths.get(uploadDir, date).toAbsolutePath().normalize(); Files.createDirectories(dir); String stored date / UUID.randomUUID().toString().replace(-, ) . ext; file.transferTo(dir.resolve(Path.of(stored).getFileName()).toFile()); Long taskId taskService.createTask(original, stored, conf); taskService.submit(taskId, stored, conf); return Result.ok(Map.of(taskId, taskId)); } }這段代碼的關(guān)鍵不在語法在順序先校驗后綴和大小再落盤再建任務(wù)最后提交線程池。submit方法加Async內(nèi)部用RestTemplate調(diào) Flask調(diào)用失敗要catch住把任務(wù)置為 FAILED 并寫error_msg不能只打日志。conf從請求參數(shù)進(jìn)默認(rèn) 0.25前端調(diào)參時不用改后端。文件落盤用絕對路徑transferTo對相對路徑在不同容器里行為不一致這個坑我踩過。3.3 Vue 工作臺的上傳、輪詢與畫框Vue 頁面不用做太花三個區(qū)域夠用上傳區(qū)、結(jié)果畫布、歷史列表。上傳成功后拿taskId輪詢組件卸載時清定時器畫框用 canvas寬高設(shè)成原圖尺寸CSS 控制顯示寬度。script setup import { ref, onBeforeUnmount } from vue import axios from axios const file ref(null) const preview ref() const task ref(null) const loading ref(false) let timer null function onFile(e) { file.value e.target.files[0] preview.value URL.createObjectURL(file.value) } async function startDetect() { if (!file.value) return loading.value true const fd new FormData() fd.append(file, file.value) const { data } await axios.post(/api/detect, fd) poll(data.data.taskId) } function poll(taskId) { timer setInterval(async () { const { data } await axios.get(/api/tasks/${taskId}) task.value data.data if ([SUCCESS, FAILED].includes(task.value.status)) { clearInterval(timer) loading.value false } }, 1500) } function drawBoxes(canvas, img, dets) { const ctx canvas.getContext(2d) canvas.width img.naturalWidth canvas.height img.naturalHeight ctx.drawImage(img, 0, 0) ctx.lineWidth 3 ctx.strokeStyle #ff3b30 ctx.fillStyle #ff3b30 dets.forEach(d { ctx.strokeRect(d.x1, d.y1, d.x2 - d.x1, d.y2 - d.y1) ctx.fillText(${d.label} ${d.score.toFixed(2)}, d.x1 4, d.y1 - 6) }) } onBeforeUnmount(() clearInterval(timer)) /script輪詢間隔 1.5 秒是折中太快給后端壓力太慢演示時顯得卡。onBeforeUnmount必須清定時器否則切換頁面后請求還在發(fā)控制臺一堆重復(fù)請求老師一眼就能看出來。畫框函數(shù)只認(rèn)原圖坐標(biāo)canvas.width和naturalWidth對齊CSS 里寫width: 100%框不會偏。如果圖片有 EXIF 旋轉(zhuǎn)Flask 側(cè)統(tǒng)一轉(zhuǎn)正前端不要自己轉(zhuǎn)否則框和原圖對不上。3.4 聯(lián)調(diào)參數(shù)與跨域配置聯(lián)調(diào)階段最耗時的不是模型是 10MB 限制、跨域預(yù)檢和讀超時。參數(shù)集中寫在application.yml改的時候只改一處。server: port: 8080 spring: servlet: multipart: max-file-size: 10MB max-request-size: 12MB app: upload-dir: ./uploads flask: base-url: http://127.0.0.1:5001 connect-timeout: 3000 read-timeout: 60000 task: pool-size: 2read-timeout給 60 秒是留給模型首次加載和 CPU 推理的余量如果每次檢測都接近 60 秒說明模型太大或設(shè)備太慢改用更小權(quán)重或降低imgsz。pool-size不要超過 Flask 能承受的并發(fā)課程設(shè)計給 2 就夠兩個請求同時進(jìn)來不會串結(jié)果。max-file-size和前端提示保持一致前端限制 10MB后端也限制 10MB避免前端放行、后端 500??缬蛴肅orsFilter允許http://localhost:5173方法包含 GET、POST、OPTIONS。不要圖省事寫allowedOrigins(*)又開allowCredentials(true)瀏覽器會直接拒絕。Spring Security 如果開了要放行 OPTIONS否則預(yù)檢請求 403POST 還沒進(jìn) Controller 就被攔住。4. 模型服務(wù)實現(xiàn)Flask 封裝 YOLOv5 的推理接口與參數(shù)推理層只做一件事接收一張圖返回檢測框。不要在這里寫用戶體系也不要在這里查任務(wù)表。Flask 服務(wù)啟動時預(yù)加載模型接口里只做預(yù)處理、推理、后處理這樣 SpringBoot 第一次調(diào)用不會卡在權(quán)重加載上。4.1 Flask 服務(wù)骨架與健康檢查服務(wù)至少兩個接口/health給 SpringBoot 和驗收腳本探活/predict做推理。啟動時調(diào)一次get_model()把權(quán)重加載進(jìn)內(nèi)存再app.run。import io import time import threading import torch from flask import Flask, request, jsonify from PIL import Image app Flask(__name__) model None model_lock threading.Lock() def get_model(): global model if model is None: # yolov5 源碼目錄和權(quán)重路徑按實際工程替換 model torch.hub.load(./yolov5, custom, pathweights/best.pt, sourcelocal) model.conf 0.25 model.iou 0.45 model.max_det 300 model.eval() return model app.get(/health) def health(): return jsonify({ status: ok, device: cuda if torch.cuda.is_available() else cpu }) app.post(/predict) def predict(): if file not in request.files: return jsonify({error: file required}), 400 conf float(request.form.get(conf, 0.25)) iou float(request.form.get(iou, 0.45)) img Image.open(io.BytesIO(request.files[file].read())).convert(RGB) m get_model() t0 time.time() with model_lock: m.conf conf m.iou iou out m(img, size640) cost_ms int((time.time() - t0) * 1000) detections [] for _, row in out.pandas().xyxy[0].iterrows(): detections.append({ label: row[name], score: round(float(row[confidence]), 4), x1: float(row[xmin]), y1: float(row[ymin]), x2: float(row[xmax]), y2: float(row[ymax]) }) return jsonify({ detections: detections, imageWidth: img.width, imageHeight: img.height, costMs: cost_ms }) if __name__ __main__: get_model() # 啟動即加載避免首請求超時 app.run(host127.0.0.1, port5001, threadedTrue)host127.0.0.1表示只給本機業(yè)務(wù)層調(diào)用瀏覽器直連不到 5001。threadedTrue讓 Flask 能接并發(fā)請求但 YOLOv5 的model對象會在推理時讀conf、iou多個請求同時改會串參數(shù)所以加model_lock把推理段鎖住。演示環(huán)境這樣最省心真要提吞吐再起多個 Flask 實例或做任務(wù)隊列不要在一個進(jìn)程里開多 worker 重復(fù)加載權(quán)重。out.pandas().xyxy[0]依賴 pandas環(huán)境里要裝不想裝 pandas 可以遍歷out.xyxy[0]自己取列但課程設(shè)計里 pandas 寫法最直觀。4.2 推理參數(shù)怎么調(diào)YOLOv5 的參數(shù)不多但每個都影響演示效果。我一般先固定一組能跑通的參數(shù)再根據(jù)漏檢和誤檢微調(diào)。參數(shù)常用值作用調(diào)整建議conf0.25置信度閾值漏檢多降到 0.15~0.20誤檢多升到 0.35~0.45iou0.45NMS 重疊閾值框重疊嚴(yán)重升到 0.50~0.60同類挨得近可降到 0.35imgsz640推理輸入尺寸小目標(biāo)多升到 960但顯存和耗時上升max_det300單圖最多保留框數(shù)密集場景調(diào)大普通演示 300 夠用devicecpu / 0推理設(shè)備有 GPU 用 0沒有就 cpu別在代碼里硬編碼conf和iou從 Flask 表單參數(shù)進(jìn)SpringBoot 透傳前端選擇的值這樣同一張圖可以現(xiàn)場演示不同閾值的效果。imgsz不建議每次請求改改一次模型會重新調(diào)整輸入耗時波動大要在啟動參數(shù)里定。device用torch.cuda.is_available()判斷有卡走 GPU沒卡走 CPU答辯換機器不用改代碼。4.3 并發(fā)、顯存與超時控制課程設(shè)計常見場景是老師連續(xù)點兩次檢測或者前端輪詢沒停又點了一次。SpringBoot 線程池給 2Flask 側(cè)加鎖串行推理結(jié)果不會串但第二個請求要等第一個跑完。如果設(shè)備是 CPU一張圖幾百毫秒到幾秒等待還能接受如果模型很大等待會超過前端輪詢次數(shù)看起來像“卡死”。我的處理方式是前端在loading為 true 時禁用按鈕SpringBoot 線程池隊列設(shè)小一點滿了直接返回“當(dāng)前檢測任務(wù)較多”Flask 的read-timeout給足但不要讓前端無限等。顯存方面imgsz640的單圖推理一般比訓(xùn)練省很多真正容易爆的是批量推理。課程設(shè)計先不做 batch一次一張驗收時用costMs和nvidia-smi看占用再決定要不要加批處理。5. 常見問題與排查四個讓檢測系統(tǒng)聯(lián)調(diào)翻車的細(xì)節(jié)這一章按“現(xiàn)象 → 原因 → 解決”寫都是前后端分離式目標(biāo)檢測系統(tǒng)里最容易踩的坑。答辯前把這幾條過一遍能省很多重新打包的時間。5.1 檢測框整體偏移或縮放后錯位現(xiàn)象圖片縮略圖上看框還行點開大圖框偏左上或者前端換了顯示寬度框跟著偏。原因通常有三個Flask 返回的是 letterbox 后的坐標(biāo)沒有還原到原圖前端 canvas 用了 CSS 顯示尺寸而不是原圖尺寸圖片 EXIF 帶旋轉(zhuǎn)瀏覽器顯示轉(zhuǎn)正了模型讀到的還是旋轉(zhuǎn)前。解決Flask 側(cè)統(tǒng)一用原圖坐標(biāo)返回YOLOv5 的out.pandas().xyxy[0]本身就是原圖坐標(biāo)別自己再乘縮放比前端canvas.width img.naturalWidthCSS 只控制width: 100%上傳后在后端做一次 EXIF 轉(zhuǎn)正或者前端用createImageBitmap時指定方向。排查時先看返回 JSON 的imageWidth是否等于原圖寬不等就是后端讀圖環(huán)節(jié)出了問題相等再檢查前端 canvas 寬高。這個坑很玄學(xué)但按這兩個數(shù)一對很快能定位。5.2 第一次檢測超時第二次正常現(xiàn)象啟動服務(wù)后第一張圖等很久SpringBoot 報Read timed out再點一次幾秒就出結(jié)果。原因模型懶加載放在/predict里第一次請求才加載權(quán)重torch.hub.load還要找本地 yolov5 目錄路徑不對時會在請求線程里卡住。解決Flask 啟動時先get_model()加載完再app.runSpringBoot 的read-timeout給 60 秒驗收腳本先調(diào)/health返回 ok 再上傳圖片。如果/health很快但/predict第一次慢說明模型沒預(yù)加載如果/health就慢說明啟動腳本或權(quán)重路徑有問題。我一般把python app.py的輸出重定向到日志文件啟動后先curl /health確認(rèn)設(shè)備是 cpu 還是 cuda再打開前端。5.3 跨域預(yù)檢 403POST 根本沒到接口現(xiàn)象瀏覽器控制臺報 CORS 錯誤Network 里 OPTIONS 請求 403POST 顯示 failed。原因Spring Security 默認(rèn)攔截 OPTIONS本文還有配套的精品資源點擊獲取