檢測(cè)系統(tǒng)設(shè)計(jì)與聯(lián)調(diào))
簡(jiǎn)介面向計(jì)算機(jī)視覺(jué)、深度學(xué)習(xí)方向的學(xué)習(xí)者與畢業(yè)設(shè)計(jì)開(kāi)發(fā)者這套工程給出基于SpringBoot、Flask、Vue與YOLOv5的前后端分離式目標(biāo)檢測(cè)系統(tǒng)可用于課程設(shè)計(jì)、項(xiàng)目復(fù)現(xiàn)和二次開(kāi)發(fā)。系統(tǒng)按三端拆分職責(zé)Flask端加載YOLOv5權(quán)重完成圖像推理并封裝檢測(cè)接口SpringBoot端承接業(yè)務(wù)請(qǐng)求、串聯(lián)檢測(cè)結(jié)果Vue端實(shí)現(xiàn)圖像上傳、結(jié)果展示與頁(yè)面交互配合yaml配置與說(shuō)明文檔能幫助讀者理解跨語(yǔ)言服務(wù)協(xié)作、模型調(diào)用和前后端聯(lián)調(diào)流程。壓縮包共60個(gè)文件約82.63MB以28個(gè)py業(yè)務(wù)與推理腳本、24個(gè)yaml配置、2個(gè)md說(shuō)明文檔為主另有pyc緩存和分端zip包目錄按flask端、web端、springboot端組織便于按模塊查閱。已有425人學(xué)習(xí)適合作為可運(yùn)行、可拆解的目標(biāo)檢測(cè)全棧范例用于梳理接口邊界與工程結(jié)構(gòu)。1. 需求邊界與選型為什么把 SpringBoot、Flask、Vue、YOLOv5 拆成四段做目標(biāo)檢測(cè)畢設(shè)的人常遇到一個(gè)尷尬Vue 頁(yè)面已經(jīng)能傳圖SpringBoot 接口也寫(xiě)好了YOLOv5 卻還在另一個(gè) Python 進(jìn)程里三邊靠手動(dòng)復(fù)制文件對(duì)接。這套基于 SpringBoot、Flask、Vue、YOLOv5 的前后端分離式目標(biāo)檢測(cè)系統(tǒng)解決的不是“模型能不能跑”而是模型服務(wù)怎么被業(yè)務(wù)系統(tǒng)管起來(lái)誰(shuí)存任務(wù)、誰(shuí)調(diào)推理、誰(shuí)把框畫(huà)回頁(yè)面。適合要交課程設(shè)計(jì)、畢設(shè)或要給內(nèi)部演示做一套目標(biāo)檢測(cè)系統(tǒng)的人。把四段拆開(kāi)是因?yàn)?Java 擅長(zhǎng)用戶、權(quán)限、任務(wù)表和文件記錄Python 擅長(zhǎng)加載深度學(xué)習(xí)權(quán)重Vue 負(fù)責(zé)工作臺(tái)交互。硬把 YOLOv5 塞進(jìn) SpringBoot 里JVM 調(diào) Python 的鏈路會(huì)變成黑匣子全用 Flask 寫(xiě)業(yè)務(wù)課程設(shè)計(jì)里的鑒權(quán)和任務(wù)管理又容易寫(xiě)薄。注意這套方案默認(rèn)輸入是圖片視頻抽幀和攝像頭流可以后續(xù)加權(quán)重和類別名按你的數(shù)據(jù)集替換接口契約不變。2. 總體架構(gòu)設(shè)計(jì)四層鏈路與三個(gè)進(jìn)程的職責(zé)劃分把系統(tǒng)拆成三個(gè)進(jìn)程不是擺樣子而是讓模型重啟不拖垮業(yè)務(wù)接口。課程設(shè)計(jì)里最常見(jiàn)的寫(xiě)法是 SpringBoot 上傳文件后直接Runtime.exec調(diào) Python 腳本演示時(shí)能跑一到連續(xù)檢測(cè)就翻車(chē)腳本路徑、臨時(shí)文件、錯(cuò)誤輸出全混在一起出了問(wèn)題只能看控制臺(tái)。前后端分離式目標(biāo)檢測(cè)系統(tǒng)要把“業(yè)務(wù)狀態(tài)”和“推理狀態(tài)”分開(kāi)SpringBoot 管任務(wù)生命周期Flask 只管一張圖進(jìn)、一組框出Vue 只管交互和展示。2.1 四層職責(zé)與進(jìn)程邊界我一般把系統(tǒng)劃成四層展示層、業(yè)務(wù)層、推理層、數(shù)據(jù)層。展示層是 Vue 工作臺(tái)負(fù)責(zé)上傳、輪詢、畫(huà)框和歷史回放業(yè)務(wù)層是 SpringBoot負(fù)責(zé)登錄鑒權(quán)、文件校驗(yàn)、任務(wù)表、調(diào)用 Flask、保存結(jié)果推理層是 Flask YOLOv5負(fù)責(zé)加載權(quán)重、預(yù)處理、推理、后處理數(shù)據(jù)層是 MySQL 加本地文件目錄任務(wù)元數(shù)據(jù)進(jìn)表原圖和結(jié)果 JSON 按任務(wù)號(hào)歸檔。模塊主要職責(zé)輸入輸出失敗時(shí)怎么辦Vue 工作臺(tái)上傳圖片、展示進(jìn)度、畫(huà)檢測(cè)框、查歷史用戶選擇的圖片檢測(cè)結(jié)果視圖提示任務(wù)失敗保留原圖預(yù)覽SpringBoot 業(yè)務(wù)層鑒權(quán)、文件校驗(yàn)、建任務(wù)、調(diào) Flask、落庫(kù)multipart 文件、conf 參數(shù)taskId、任務(wù)狀態(tài)、結(jié)果 JSON把任務(wù)置為 FAILED寫(xiě) error_msgFlask 推理層加載 YOLOv5、推理、序列化檢測(cè)框圖片字節(jié)流、conf、ioudetections、原圖寬高、耗時(shí)返回 4xx/5xx 和錯(cuò)誤信息數(shù)據(jù)層存任務(wù)、文件路徑、結(jié)果、時(shí)間業(yè)務(wù)層寫(xiě)入歷史查詢文件缺失時(shí)任務(wù)標(biāo)記異常這張表看著簡(jiǎn)單真正落地時(shí)邊界要卡死Flask 不做用戶登錄不查數(shù)據(jù)庫(kù)不關(guān)心前端是誰(shuí)SpringBoot 不加載best.pt不碰 torch不解析模型輸出以外的業(yè)務(wù)規(guī)則Vue 不直接請(qǐng)求 Flask 的 5001 端口。這樣模型換權(quán)重、換設(shè)備、換 Python 依賴只動(dòng)推理層頁(yè)面改布局只動(dòng)展示層。黑匣子被拆成三段每段都有日志可查。2.2 請(qǐng)求鏈路與任務(wù)狀態(tài)機(jī)一次檢測(cè)的鏈路通常是Vue 把圖片傳到POST /api/detectSpringBoot 校驗(yàn)后綴和大小把文件存到uploads/日期/任務(wù)號(hào).后綴往detect_task插一條 PENDING業(yè)務(wù)層用線程池把任務(wù)提交給 Flask 的POST /predictFlask 加載模型并推理返回檢測(cè)框 JSONSpringBoot 把狀態(tài)改成 SUCCESS把結(jié)果寫(xiě)進(jìn)result_jsonVue 每隔 1.5 秒查一次GET /api/tasks/{taskId}拿到 SUCCESS 后畫(huà)框。任務(wù)狀態(tài)只留四個(gè)PENDING、RUNNING、SUCCESS、FAILED。不要加“處理中 30%”這種假進(jìn)度模型推理沒(méi)有真實(shí)百分比前端顯示“檢測(cè)中”加輪詢次數(shù)就夠了。失敗必須寫(xiě)error_msg否則答辯時(shí)老師問(wèn)“這張為什么沒(méi)框”你只能重新跑一遍。{ 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 按這個(gè)寬高設(shè)置CSS 只負(fù)責(zé)縮放顯示。costMs是 Flask 推理耗時(shí)驗(yàn)收時(shí)看它判斷是模型慢還是網(wǎng)絡(luò)慢。label用類別名而不是類別 id前端不用再維護(hù)一份映射表如果要做多語(yǔ)言再在業(yè)務(wù)層轉(zhuǎn)換。2.3 部署拓?fù)渑c端口約定課程設(shè)計(jì)階段可以三端同機(jī)跑Vue 開(kāi)發(fā)服務(wù)器 5173SpringBoot 8080Flask 5001MySQL 3306。正式演示時(shí) Vue 執(zhí)行構(gòu)建靜態(tài)文件交給靜態(tài)服務(wù)器SpringBoot 只暴露/apiFlask 只監(jiān)聽(tīng)本機(jī)回環(huán)地址由 SpringBoot 調(diào)用不對(duì)瀏覽器開(kāi)放。這樣文件校驗(yàn)、權(quán)限、任務(wù)記錄都在一個(gè)入口前端也不會(huì)因?yàn)榭缬蚺渲寐┑?5001 而翻車(chē)。組件開(kāi)發(fā)端口啟動(dòng)方式誰(shuí)訪問(wèn)它Vue5173npm run dev瀏覽器SpringBoot8080IDE 或java -jar瀏覽器、FlaskFlask5001python app.py僅 SpringBootMySQL3306本機(jī)服務(wù)SpringBoot提示Flask 的host寫(xiě)127.0.0.1不要圖省事寫(xiě)0.0.0.0再讓瀏覽器直連。所有檢測(cè)請(qǐng)求走 SpringBoot 的/api/detect模型端口不暴露后面加鑒權(quán)、限流、文件白名單都只改一處。3. 前后端業(yè)務(wù)閉環(huán)SpringBoot 接口與 Vue 工作臺(tái)的落地這一章把業(yè)務(wù)層和展示層接起來(lái)。很多方案寫(xiě)到“前端上傳、后端調(diào)用模型”就停了真到聯(lián)調(diào)時(shí)卡在數(shù)據(jù)庫(kù)字段、文件路徑、輪詢清理上。我的做法是先定表再定接口最后寫(xiě) Vue 頁(yè)面表結(jié)構(gòu)定錯(cuò)后面接口和頁(yè)面都要返工。3.1 任務(wù)表與文件記錄任務(wù)表只存檢測(cè)任務(wù)需要的字段不要把圖片二進(jìn)制塞進(jìn)數(shù)據(jù)庫(kù)。原圖放uploads表里存相對(duì)路徑結(jié)果 JSON 放result_json課程設(shè)計(jì)查詢方便結(jié)果特別大時(shí)再改成存文件路徑。CREATE TABLE detect_task ( id BIGINT PRIMARY KEY AUTO_INCREMENT, task_no VARCHAR(64) NOT NULL UNIQUE COMMENT 任務(wù)號(hào)前端輪詢用, original_name VARCHAR(255) NOT NULL COMMENT 原始文件名, stored_path VARCHAR(500) NOT NULL COMMENT 相對(duì) uploads 的路徑, status VARCHAR(16) NOT NULL DEFAULT PENDING COMMENT PENDING/RUNNING/SUCCESS/FAILED, model_name VARCHAR(128) DEFAULT NULL COMMENT 權(quán)重文件名便于對(duì)比實(shí)驗(yàn), conf_thres DECIMAL(4,3) DEFAULT 0.250 COMMENT 本次檢測(cè)的置信度閾值, result_json LONGTEXT DEFAULT NULL COMMENT 檢測(cè)框 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)這個(gè)號(hào)不暴露自增 id。stored_path存相對(duì)路徑換機(jī)器時(shí)只改app.upload-dir歷史數(shù)據(jù)還能用。model_name和conf_thres看起來(lái)多余答辯時(shí)很有用同一張圖換yolov5s和自訓(xùn)練權(quán)重能直接對(duì)比結(jié)果老師問(wèn)閾值多少表里有記錄不用翻代碼。finished_at用來(lái)算耗時(shí)后面驗(yàn)收表會(huì)用到。3.2 上傳與任務(wù)下發(fā)接口上傳接口只做三件事校驗(yàn)、存文件、建任務(wù)并提交異步推理。不要在 Controller 里等 Flask 返回首次加載模型可能十幾秒同步接口很容易超時(shí)。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(圖片不能超過(guò) 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)鍵不在語(yǔ)法在順序先校驗(yàn)后綴和大小再落盤(pán)再建任務(wù)最后提交線程池。submit方法加Async內(nèi)部用RestTemplate調(diào) Flask調(diào)用失敗要catch住把任務(wù)置為 FAILED 并寫(xiě)error_msg不能只打日志。conf從請(qǐng)求參數(shù)進(jìn)默認(rèn) 0.25前端調(diào)參時(shí)不用改后端。文件落盤(pán)用絕對(duì)路徑transferTo對(duì)相對(duì)路徑在不同容器里行為不一致這個(gè)坑我踩過(guò)。3.3 Vue 工作臺(tái)的上傳、輪詢與畫(huà)框Vue 頁(yè)面不用做太花三個(gè)區(qū)域夠用上傳區(qū)、結(jié)果畫(huà)布、歷史列表。上傳成功后拿taskId輪詢組件卸載時(shí)清定時(shí)器畫(huà)框用 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 秒是折中太快給后端壓力太慢演示時(shí)顯得卡。onBeforeUnmount必須清定時(shí)器否則切換頁(yè)面后請(qǐng)求還在發(fā)控制臺(tái)一堆重復(fù)請(qǐng)求老師一眼就能看出來(lái)。畫(huà)框函數(shù)只認(rèn)原圖坐標(biāo)canvas.width和naturalWidth對(duì)齊CSS 里寫(xiě)width: 100%框不會(huì)偏。如果圖片有 EXIF 旋轉(zhuǎn)Flask 側(cè)統(tǒng)一轉(zhuǎn)正前端不要自己轉(zhuǎn)否則框和原圖對(duì)不上。3.4 聯(lián)調(diào)參數(shù)與跨域配置聯(lián)調(diào)階段最耗時(shí)的不是模型是 10MB 限制、跨域預(yù)檢和讀超時(shí)。參數(shù)集中寫(xiě)在application.yml改的時(shí)候只改一處。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 推理的余量如果每次檢測(cè)都接近 60 秒說(shuō)明模型太大或設(shè)備太慢改用更小權(quán)重或降低imgsz。pool-size不要超過(guò) Flask 能承受的并發(fā)課程設(shè)計(jì)給 2 就夠兩個(gè)請(qǐng)求同時(shí)進(jìn)來(lái)不會(huì)串結(jié)果。max-file-size和前端提示保持一致前端限制 10MB后端也限制 10MB避免前端放行、后端 500??缬蛴肅orsFilter允許http://localhost:5173方法包含 GET、POST、OPTIONS。不要圖省事寫(xiě)allowedOrigins(*)又開(kāi)allowCredentials(true)瀏覽器會(huì)直接拒絕。Spring Security 如果開(kāi)了要放行 OPTIONS否則預(yù)檢請(qǐng)求 403POST 還沒(méi)進(jìn) Controller 就被攔住。4. 模型服務(wù)實(shí)現(xiàn)Flask 封裝 YOLOv5 的推理接口與參數(shù)推理層只做一件事接收一張圖返回檢測(cè)框。不要在這里寫(xiě)用戶體系也不要在這里查任務(wù)表。Flask 服務(wù)啟動(dòng)時(shí)預(yù)加載模型接口里只做預(yù)處理、推理、后處理這樣 SpringBoot 第一次調(diào)用不會(huì)卡在權(quán)重加載上。4.1 Flask 服務(wù)骨架與健康檢查服務(wù)至少兩個(gè)接口/health給 SpringBoot 和驗(yàn)收腳本探活/predict做推理。啟動(dòng)時(shí)調(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)重路徑按實(shí)際工程替換 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() # 啟動(dòng)即加載避免首請(qǐng)求超時(shí) app.run(host127.0.0.1, port5001, threadedTrue)host127.0.0.1表示只給本機(jī)業(yè)務(wù)層調(diào)用瀏覽器直連不到 5001。threadedTrue讓 Flask 能接并發(fā)請(qǐng)求但 YOLOv5 的model對(duì)象會(huì)在推理時(shí)讀conf、iou多個(gè)請(qǐng)求同時(shí)改會(huì)串參數(shù)所以加model_lock把推理段鎖住。演示環(huán)境這樣最省心真要提吞吐再起多個(gè) Flask 實(shí)例或做任務(wù)隊(duì)列不要在一個(gè)進(jìn)程里開(kāi)多 worker 重復(fù)加載權(quán)重。out.pandas().xyxy[0]依賴 pandas環(huán)境里要裝不想裝 pandas 可以遍歷out.xyxy[0]自己取列但課程設(shè)計(jì)里 pandas 寫(xiě)法最直觀。4.2 推理參數(shù)怎么調(diào)YOLOv5 的參數(shù)不多但每個(gè)都影響演示效果。我一般先固定一組能跑通的參數(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但顯存和耗時(shí)上升max_det300單圖最多保留框數(shù)密集場(chǎng)景調(diào)大普通演示 300 夠用devicecpu / 0推理設(shè)備有 GPU 用 0沒(méi)有就 cpu別在代碼里硬編碼conf和iou從 Flask 表單參數(shù)進(jìn)SpringBoot 透?jìng)髑岸诉x擇的值這樣同一張圖可以現(xiàn)場(chǎng)演示不同閾值的效果。imgsz不建議每次請(qǐng)求改改一次模型會(huì)重新調(diào)整輸入耗時(shí)波動(dòng)大要在啟動(dòng)參數(shù)里定。device用torch.cuda.is_available()判斷有卡走 GPU沒(méi)卡走 CPU答辯換機(jī)器不用改代碼。4.3 并發(fā)、顯存與超時(shí)控制課程設(shè)計(jì)常見(jiàn)場(chǎng)景是老師連續(xù)點(diǎn)兩次檢測(cè)或者前端輪詢沒(méi)停又點(diǎn)了一次。SpringBoot 線程池給 2Flask 側(cè)加鎖串行推理結(jié)果不會(huì)串但第二個(gè)請(qǐng)求要等第一個(gè)跑完。如果設(shè)備是 CPU一張圖幾百毫秒到幾秒等待還能接受如果模型很大等待會(huì)超過(guò)前端輪詢次數(shù)看起來(lái)像“卡死”。我的處理方式是前端在loading為 true 時(shí)禁用按鈕SpringBoot 線程池隊(duì)列設(shè)小一點(diǎn)滿了直接返回“當(dāng)前檢測(cè)任務(wù)較多”Flask 的read-timeout給足但不要讓前端無(wú)限等。顯存方面imgsz640的單圖推理一般比訓(xùn)練省很多真正容易爆的是批量推理。課程設(shè)計(jì)先不做 batch一次一張驗(yàn)收時(shí)用costMs和nvidia-smi看占用再?zèng)Q定要不要加批處理。5. 常見(jiàn)問(wèn)題與排查四個(gè)讓檢測(cè)系統(tǒng)聯(lián)調(diào)翻車(chē)的細(xì)節(jié)這一章按“現(xiàn)象 → 原因 → 解決”寫(xiě)都是前后端分離式目標(biāo)檢測(cè)系統(tǒng)里最容易踩的坑。答辯前把這幾條過(guò)一遍能省很多重新打包的時(shí)間。5.1 檢測(cè)框整體偏移或縮放后錯(cuò)位現(xiàn)象圖片縮略圖上看框還行點(diǎn)開(kāi)大圖框偏左上或者前端換了顯示寬度框跟著偏。原因通常有三個(gè)Flask 返回的是 letterbox 后的坐標(biāo)沒(méi)有還原到原圖前端 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時(shí)指定方向。排查時(shí)先看返回 JSON 的imageWidth是否等于原圖寬不等就是后端讀圖環(huán)節(jié)出了問(wèn)題相等再檢查前端 canvas 寬高。這個(gè)坑很玄學(xué)但按這兩個(gè)數(shù)一對(duì)很快能定位。5.2 第一次檢測(cè)超時(shí)第二次正?,F(xiàn)象啟動(dòng)服務(wù)后第一張圖等很久SpringBoot 報(bào)Read timed out再點(diǎn)一次幾秒就出結(jié)果。原因模型懶加載放在/predict里第一次請(qǐng)求才加載權(quán)重torch.hub.load還要找本地 yolov5 目錄路徑不對(duì)時(shí)會(huì)在請(qǐng)求線程里卡住。解決Flask 啟動(dòng)時(shí)先get_model()加載完再app.runSpringBoot 的read-timeout給 60 秒驗(yàn)收腳本先調(diào)/health返回 ok 再上傳圖片。如果/health很快但/predict第一次慢說(shuō)明模型沒(méi)預(yù)加載如果/health就慢說(shuō)明啟動(dòng)腳本或權(quán)重路徑有問(wèn)題。我一般把python app.py的輸出重定向到日志文件啟動(dòng)后先curl /health確認(rèn)設(shè)備是 cpu 還是 cuda再打開(kāi)前端。5.3 跨域預(yù)檢 403POST 根本沒(méi)到接口現(xiàn)象瀏覽器控制臺(tái)報(bào) CORS 錯(cuò)誤Network 里 OPTIONS 請(qǐng)求 403POST 顯示 failed。原因Spring Security 默認(rèn)攔截 OPTIONS本文還有配套的精品資源點(diǎn)擊獲取