- 新增 mingxi-capture(PyQt5桌面程序)目录骨架 - 新增 mingxi-vision(FastAPI推理服务)目录骨架 - 新增 mingxi-backend(Django业务后端)目录骨架 - 新增 mingxi-frontend(Vue3管理后台)目录骨架 - 迁入 docs/(技术设计文档和PCB调研资料) - 补充 .gitignore(模型文件、Node.js依赖、运行时数据库) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
26 KiB
26 KiB
mingxi-vision 技术架构与设计分析
版本:v1.0 · 日期:2026-05-24
硬件约束:联想 Y7000P · RTX 4060 Laptop 8GB · Python 3.8 · Windows 11
上游依赖:yolo_classification_system/yolo8/inference.py(迁移基础)
1. 定位与职责边界
mingxi-vision 是整个明析平台的推理微服务,职责单一:
接收图像 → 前处理 → 模型推理 → 后处理 → 返回结构化缺陷JSON
它不做的事:
- 不持久化数据(无数据库依赖)
- 不做鉴权(内网服务,由 backend 代理)
- 不管理产线/批次业务(backend 的职责)
- 不直接控制相机(capture 的职责)
2. 框架选型:FastAPI
为什么不用 Django
现有系统用 Django,但推理服务有不同的特征:
| 维度 | Django(现有) | FastAPI(选用) |
|---|---|---|
| 定位 | 全功能Web框架 | 轻量API框架 |
| 启动时间 | ~3-5s | ~0.5s |
| ORM/DB | 必须配置 | 无需数据库 |
| 异步支持 | 有限(Django 4+ 部分支持) | 原生 async/await |
| 自动文档 | 需要额外配置 | 内置 /docs (Swagger) |
| 推理场景适配 | 过重 | 刚好合适 |
Python 3.8 的约束
FastAPI 在 Python 3.8 下完全可用,需注意:
- 类型注解用
Optional[X]而非X | None(3.10+ 语法) from __future__ import annotations可缓解部分问题- Pydantic v1(随 FastAPI 早期版本)在 3.8 稳定
# requirements.txt 核心版本锁定
fastapi==0.104.1 # 3.8 兼容的最后稳定版系列
uvicorn[standard]==0.24.0
pydantic==1.10.13 # v1,Python 3.8 最稳定
ultralytics==8.0.235 # 训练阶段用
onnxruntime-gpu==1.16.3 # 推理部署阶段用
opencv-python-headless==4.8.1.78
numpy==1.24.4 # 3.8 + torch 2.0 兼容版本
python-multipart==0.0.6 # FastAPI 文件上传必须
3. 双运行时策略
这是 mingxi-vision 最核心的设计决策。
问题
- 训练阶段:用
ultralytics的.pt格式,便于迭代和验证 - 部署阶段:用
.onnx格式,跨平台、无需安装 PyTorch、性能更稳定 - 两套格式的推理 API 有差异,需要统一抽象
解决方案:运行时适配器(Adapter 模式)
┌─────────────────────────────┐
│ InferenceEngine │ ← 统一接口
│ detect(image) → [Defect] │
└──────────┬──────────────────┘
│ 根据配置选择
┌───────────────┴────────────────┐
▼ ▼
UltralyticsAdapter OnnxRuntimeAdapter
(.pt 文件,训练验证用) (.onnx 文件,生产部署用)
ultralytics.YOLO onnxruntime.InferenceSession
代码设计
# engine/base.py
from abc import ABC, abstractmethod
from dataclasses import dataclass
from typing import List
import numpy as np
@dataclass
class DefectBox:
class_id: int
class_name: str
class_name_zh: str
confidence: float
severity: str # fatal / major / minor / rework / none
box_xyxy: List[float] # [x1, y1, x2, y2],像素坐标
@dataclass
class DetectResult:
defects: List[DefectBox]
duration_ms: float
image_width: int
image_height: int
model_version: str
@property
def defect_count(self) -> int:
return len(self.defects)
@property
def max_severity(self) -> str:
order = ["fatal", "major", "minor", "rework", "none"]
found = {d.severity for d in self.defects}
for s in order:
if s in found:
return s
return "none"
@property
def avg_confidence(self):
if not self.defects:
return None
return round(sum(d.confidence for d in self.defects) / len(self.defects), 4)
class BaseInferenceEngine(ABC):
@abstractmethod
def detect(self, image: np.ndarray, conf: float) -> DetectResult:
...
@abstractmethod
def warmup(self) -> None:
"""启动时预热,避免第一次推理延迟"""
...
# engine/ultralytics_adapter.py
import time
import numpy as np
from .base import BaseInferenceEngine, DefectBox, DetectResult
from .defect_classes import DEFECT_CLASSES
class UltralyticsAdapter(BaseInferenceEngine):
"""开发/训练验证阶段使用"""
def __init__(self, weights_path: str):
from ultralytics import YOLO
self._model = YOLO(weights_path)
self._weights_path = weights_path
def warmup(self):
dummy = np.zeros((640, 640, 3), dtype=np.uint8)
self._model.predict(source=dummy, verbose=False, conf=0.01)
def detect(self, image: np.ndarray, conf: float = 0.45) -> DetectResult:
h, w = image.shape[:2]
t0 = time.perf_counter()
results = self._model.predict(source=image, verbose=False, conf=conf)
duration_ms = (time.perf_counter() - t0) * 1000
defects = []
result = results[0]
if result.boxes is not None and len(result.boxes):
xyxy = result.boxes.xyxy.cpu().numpy()
confs = result.boxes.conf.cpu().numpy()
clsids = result.boxes.cls.cpu().numpy()
for i in range(len(xyxy)):
cid = int(clsids[i])
meta = DEFECT_CLASSES.get(cid, {
"name": str(cid), "zh": str(cid), "severity": "minor"
})
defects.append(DefectBox(
class_id=cid,
class_name=meta["name"],
class_name_zh=meta["zh"],
confidence=round(float(confs[i]), 4),
severity=meta["severity"],
box_xyxy=[round(float(x), 1) for x in xyxy[i]],
))
return DetectResult(
defects=defects,
duration_ms=round(duration_ms, 1),
image_width=w,
image_height=h,
model_version=str(self._weights_path),
)
# engine/onnx_adapter.py
import time
import cv2
import numpy as np
import onnxruntime as ort
from .base import BaseInferenceEngine, DefectBox, DetectResult
from .defect_classes import DEFECT_CLASSES
class OnnxRuntimeAdapter(BaseInferenceEngine):
"""生产部署阶段使用,无 PyTorch 依赖"""
def __init__(self, model_path: str, device: str = "cuda"):
providers = (
["CUDAExecutionProvider", "CPUExecutionProvider"]
if device == "cuda"
else ["CPUExecutionProvider"]
)
self._session = ort.InferenceSession(model_path, providers=providers)
self._input_name = self._session.get_inputs()[0].name
self._input_shape = self._session.get_inputs()[0].shape # [1,3,640,640]
self._imgsz = self._input_shape[2] # 通常 640
self._model_path = model_path
def warmup(self):
dummy = np.zeros(
(1, 3, self._imgsz, self._imgsz), dtype=np.float32
)
self._session.run(None, {self._input_name: dummy})
def _preprocess(self, image: np.ndarray):
"""BGR → RGB → letterbox → NCHW float32 [0,1]"""
img = cv2.cvtColor(image, cv2.COLOR_BGR2RGB)
img, ratio, (dw, dh) = self._letterbox(img, self._imgsz)
img = img.astype(np.float32) / 255.0
img = np.transpose(img, (2, 0, 1)) # HWC → CHW
img = np.expand_dims(img, 0) # CHW → NCHW
return img, ratio, dw, dh
@staticmethod
def _letterbox(img, new_size=640):
h, w = img.shape[:2]
ratio = min(new_size / h, new_size / w)
nh, nw = int(h * ratio), int(w * ratio)
img = cv2.resize(img, (nw, nh), interpolation=cv2.INTER_LINEAR)
dw = (new_size - nw) / 2
dh = (new_size - nh) / 2
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=(114, 114, 114))
return img, ratio, dw, dh
def _postprocess(self, outputs, orig_h, orig_w, ratio, dw, dh, conf_thres):
"""YOLOv8 ONNX 输出解码:[1, 84, 8400] → DefectBox列表"""
pred = outputs[0][0] # [84, 8400]
pred = pred.T # [8400, 84]
boxes = pred[:, :4] # cx,cy,w,h
scores = pred[:, 4:] # [8400, num_classes]
class_ids = np.argmax(scores, axis=1)
confidences = scores[np.arange(len(scores)), class_ids]
mask = confidences > conf_thres
boxes, class_ids, confidences = (
boxes[mask], class_ids[mask], confidences[mask]
)
if len(boxes) == 0:
return []
# cx,cy,w,h → x1,y1,x2,y2
x1 = boxes[:, 0] - boxes[:, 2] / 2
y1 = boxes[:, 1] - boxes[:, 3] / 2
x2 = boxes[:, 0] + boxes[:, 2] / 2
y2 = boxes[:, 1] + boxes[:, 3] / 2
# 去除 letterbox padding,还原到原始坐标
x1 = np.clip((x1 - dw) / ratio, 0, orig_w)
y1 = np.clip((y1 - dh) / ratio, 0, orig_h)
x2 = np.clip((x2 - dw) / ratio, 0, orig_w)
y2 = np.clip((y2 - dh) / ratio, 0, orig_h)
# NMS
nms_ids = cv2.dnn.NMSBoxes(
np.stack([x1, y1, x2 - x1, y2 - y1], axis=1).tolist(),
confidences.tolist(), conf_thres, iou_threshold=0.45
)
if len(nms_ids) == 0:
return []
defects = []
for idx in nms_ids.flatten():
cid = int(class_ids[idx])
meta = DEFECT_CLASSES.get(cid, {
"name": str(cid), "zh": str(cid), "severity": "minor"
})
defects.append(DefectBox(
class_id=cid,
class_name=meta["name"],
class_name_zh=meta["zh"],
confidence=round(float(confidences[idx]), 4),
severity=meta["severity"],
box_xyxy=[
round(float(x1[idx]), 1), round(float(y1[idx]), 1),
round(float(x2[idx]), 1), round(float(y2[idx]), 1),
],
))
return defects
def detect(self, image: np.ndarray, conf: float = 0.45) -> DetectResult:
orig_h, orig_w = image.shape[:2]
inp, ratio, dw, dh = self._preprocess(image)
t0 = time.perf_counter()
outputs = self._session.run(None, {self._input_name: inp})
duration_ms = (time.perf_counter() - t0) * 1000
defects = self._postprocess(outputs, orig_h, orig_w, ratio, dw, dh, conf)
return DetectResult(
defects=defects,
duration_ms=round(duration_ms, 1),
image_width=orig_w,
image_height=orig_h,
model_version=str(self._model_path),
)
4. 模型管理:单例 + 启动加载
问题根源
现有 inference.py 的 _MODEL_CACHE 是进程级字典,首次请求时才加载模型(懒加载)。对推理服务来说,这会导致第一个请求有数秒延迟,在演示现场是灾难性的。
解决方案:FastAPI 生命周期钩子
# engine/loader.py
from typing import Optional
from .base import BaseInferenceEngine
from config import settings
_engine: Optional[BaseInferenceEngine] = None
def get_engine() -> BaseInferenceEngine:
if _engine is None:
raise RuntimeError("推理引擎未初始化,请检查启动日志")
return _engine
def init_engine() -> BaseInferenceEngine:
global _engine
if settings.runtime == "onnx":
from .onnx_adapter import OnnxRuntimeAdapter
_engine = OnnxRuntimeAdapter(
model_path=settings.model_path,
device=settings.device,
)
else:
from .ultralytics_adapter import UltralyticsAdapter
_engine = UltralyticsAdapter(weights_path=settings.model_path)
_engine.warmup() # 预热,消除第一次推理的延迟
return _engine
# main.py
from contextlib import asynccontextmanager
from fastapi import FastAPI
from engine.loader import init_engine
from api.detect import router as detect_router
from api.health import router as health_router
@asynccontextmanager
async def lifespan(app: FastAPI):
# 启动时加载模型
engine = init_engine()
print(f"[mingxi-vision] 引擎就绪: {engine.__class__.__name__}")
yield
# 关闭时释放资源(ONNX session会自动GC)
app = FastAPI(
title="明析推理服务",
description="PCB缺陷检测推理接口",
version="1.0.0",
lifespan=lifespan,
)
app.include_router(detect_router, prefix="/api")
app.include_router(health_router, prefix="/api")
5. 图像接收与前处理
接收来源
mingxi-vision 接受两种图像来源,用同一个端点处理:
# api/detect.py
import cv2
import numpy as np
from fastapi import APIRouter, File, Form, UploadFile, HTTPException
from fastapi.responses import JSONResponse
from engine.loader import get_engine
from engine.annotator import draw_boxes
from schema import DetectResponse
import base64, uuid
router = APIRouter()
@router.post("/detect", response_model=DetectResponse)
async def detect(
image: UploadFile = File(...),
conf: float = Form(default=0.45, ge=0.01, le=0.99),
line_id: str = Form(default=""),
batch_id: str = Form(default=""),
return_annotated: bool = Form(default=False),
):
# 1. 读取图像字节
raw = await image.read()
if len(raw) > 20 * 1024 * 1024: # 20MB 上限
raise HTTPException(status_code=413, detail="图像文件过大(上限20MB)")
# 2. 解码为 numpy BGR
arr = np.frombuffer(raw, dtype=np.uint8)
img = cv2.imdecode(arr, cv2.IMREAD_COLOR)
if img is None:
raise HTTPException(status_code=422, detail="无法解码图像,请检查文件格式")
# 3. 推理
engine = get_engine()
result = engine.detect(img, conf=conf)
# 4. 可选:返回标注图
annotated_b64 = None
if return_annotated:
annotated = draw_boxes(img.copy(), result.defects)
_, buf = cv2.imencode(".jpg", annotated, [cv2.IMWRITE_JPEG_QUALITY, 85])
annotated_b64 = base64.b64encode(buf.tobytes()).decode()
return DetectResponse(
task_id=str(uuid.uuid4()),
line_id=line_id,
batch_id=batch_id,
duration_ms=result.duration_ms,
image_width=result.image_width,
image_height=result.image_height,
defect_count=result.defect_count,
max_severity=result.max_severity,
avg_confidence=result.avg_confidence,
defects=[d.__dict__ for d in result.defects],
annotated_image_b64=annotated_b64,
model_version=result.model_version,
)
中文路径问题(继承自现有代码)
现有 inference.py 已有 _copy_to_ascii_temp_input() 解决中文路径导致 OpenCV 无法读图的问题。mingxi-vision 改用 内存读取(np.frombuffer + cv2.imdecode),从源头消除路径问题,不再需要这个 workaround。
6. 后处理:标注图生成
# engine/annotator.py
import cv2
import numpy as np
from typing import List
from .base import DefectBox
SEVERITY_COLORS = {
"fatal": (0, 0, 220), # 红(BGR)
"major": (0, 128, 255), # 橙
"minor": (0, 215, 255), # 黄
"rework": (255, 165, 0 ), # 蓝
"none": (180, 180, 180), # 灰
}
def draw_boxes(image: np.ndarray, defects: List[DefectBox]) -> np.ndarray:
for d in defects:
x1, y1, x2, y2 = [int(v) for v in d.box_xyxy]
color = SEVERITY_COLORS.get(d.severity, (180, 180, 180))
cv2.rectangle(image, (x1, y1), (x2, y2), color, 2)
label = f"{d.class_name_zh} {d.confidence:.2f}"
(tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.55, 1)
cv2.rectangle(image, (x1, y1 - th - 6), (x1 + tw + 4, y1), color, -1)
cv2.putText(
image, label, (x1 + 2, y1 - 4),
cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 1, cv2.LINE_AA
)
return image
7. 缺陷类别定义
# engine/defect_classes.py
# 与 HRIPCB 数据集的 6 类对齐,扩展至 10 类(含真实产线样本迁移学习后)
DEFECT_CLASSES = {
# ── HRIPCB 基础 6 类(公开数据集,开箱即用)──
0: {"name": "missing_hole", "zh": "缺孔", "severity": "fatal"},
1: {"name": "mouse_bite", "zh": "鼠咬", "severity": "major"},
2: {"name": "open_circuit", "zh": "断路", "severity": "fatal"},
3: {"name": "short_circuit", "zh": "短路", "severity": "fatal"},
4: {"name": "spur", "zh": "毛刺", "severity": "minor"},
5: {"name": "spurious_copper", "zh": "余铜", "severity": "major"},
# ── 扩展类(真实产线样本微调后启用)──
6: {"name": "oxidation", "zh": "氧化", "severity": "minor"},
7: {"name": "solder_ball", "zh": "锡珠", "severity": "rework"},
8: {"name": "scratch", "zh": "划痕", "severity": "minor"},
9: {"name": "label_error", "zh": "标签错贴","severity": "rework"},
}
SEVERITY_ORDER = ["fatal", "major", "minor", "rework", "none"]
8. 配置管理
# config.py
import os
from typing import Literal
from pydantic import BaseSettings # pydantic v1
class Settings(BaseSettings):
# 运行时选择
runtime: Literal["ultralytics", "onnx"] = "ultralytics"
model_path: str = "./models/pcb_defect_v1.pt"
device: Literal["cuda", "cpu"] = "cuda"
# 推理默认参数
default_conf: float = 0.45
max_image_size_mb: int = 20
# 服务配置
host: str = "0.0.0.0"
port: int = 8001
workers: int = 1 # 推理服务单 worker,GPU 不支持多进程共享
class Config:
env_file = ".env"
env_file_encoding = "utf-8"
settings = Settings()
.env 文件示例:
# 开发阶段(ultralytics .pt)
RUNTIME=ultralytics
MODEL_PATH=./models/pcb_defect_v1.pt
DEVICE=cuda
# 生产阶段(ONNX Runtime)
# RUNTIME=onnx
# MODEL_PATH=./models/pcb_defect_v1.onnx
# DEVICE=cuda
9. 健康检查与诊断接口
# api/health.py
import platform
from fastapi import APIRouter
from engine.loader import get_engine
router = APIRouter()
@router.get("/health")
def health():
try:
engine = get_engine()
return {
"status": "ok",
"runtime": engine.__class__.__name__,
"platform": platform.system(),
}
except RuntimeError as e:
return {"status": "error", "detail": str(e)}
@router.get("/health/gpu")
def gpu_info():
"""开发调试用,确认 GPU 是否被正确使用"""
info = {"cuda_available": False, "onnx_providers": []}
try:
import torch
info["cuda_available"] = torch.cuda.is_available()
if torch.cuda.is_available():
info["gpu_name"] = torch.cuda.get_device_name(0)
info["vram_total_gb"] = round(
torch.cuda.get_device_properties(0).total_memory / 1e9, 1
)
except ImportError:
pass
try:
import onnxruntime as ort
info["onnx_providers"] = ort.get_available_providers()
except ImportError:
pass
return info
10. 完整目录结构
mingxi-vision/
├── api/
│ ├── __init__.py
│ ├── detect.py # POST /api/detect(核心推理接口)
│ └── health.py # GET /api/health, /api/health/gpu
├── engine/
│ ├── __init__.py
│ ├── base.py # DefectBox, DetectResult, BaseInferenceEngine
│ ├── loader.py # 单例管理 + init_engine()
│ ├── ultralytics_adapter.py # .pt 推理(开发阶段)
│ ├── onnx_adapter.py # .onnx 推理(生产阶段)
│ ├── annotator.py # 在图像上绘制标注框
│ └── defect_classes.py # 缺陷类别 + 等级映射表
├── models/
│ ├── pcb_defect_v1.pt # ultralytics 训练产物(gitignore)
│ └── pcb_defect_v1.onnx # 导出的 ONNX 模型(gitignore)
├── scripts/
│ ├── export_onnx.py # pt → onnx 导出脚本
│ └── benchmark.py # 本地推理性能测试
├── tests/
│ ├── test_detect_api.py
│ └── fixtures/ # 测试用 PCB 图片
├── schema.py # Pydantic 响应模型
├── config.py # Settings(pydantic BaseSettings)
├── main.py # FastAPI app + lifespan
├── requirements.txt
├── .env.example
└── README.md
11. 响应 Schema
# schema.py
from typing import List, Optional
from pydantic import BaseModel
class DefectItem(BaseModel):
class_id: int
class_name: str
class_name_zh: str
confidence: float
severity: str # fatal / major / minor / rework / none
box_xyxy: List[float] # [x1, y1, x2, y2]
class DetectResponse(BaseModel):
task_id: str
line_id: str
batch_id: str
duration_ms: float
image_width: int
image_height: int
defect_count: int
max_severity: str # 本次检测中最高等级缺陷
avg_confidence: Optional[float]
defects: List[DefectItem]
annotated_image_b64: Optional[str] # 仅 return_annotated=true 时有值
model_version: str
实际响应示例:
{
"task_id": "a3f1c2d4-...",
"line_id": "LINE-01",
"batch_id": "BAT-20260524-001",
"duration_ms": 41.3,
"image_width": 3072,
"image_height": 2048,
"defect_count": 2,
"max_severity": "fatal",
"avg_confidence": 0.8762,
"defects": [
{
"class_id": 2,
"class_name": "open_circuit",
"class_name_zh": "断路",
"confidence": 0.9134,
"severity": "fatal",
"box_xyxy": [234.5, 891.2, 312.8, 943.7]
},
{
"class_id": 4,
"class_name": "spur",
"class_name_zh": "毛刺",
"confidence": 0.8390,
"severity": "minor",
"box_xyxy": [1204.1, 456.3, 1251.9, 489.0]
}
],
"annotated_image_b64": null,
"model_version": "./models/pcb_defect_v1.pt"
}
12. 性能分析(Y7000P RTX 4060)
推理延迟预估
| 运行时 | 模型 | 分辨率 | 单次推理 | 含预处理+后处理 |
|---|---|---|---|---|
| ultralytics | YOLOv8n | 640×640 | ~12ms | ~20ms |
| ultralytics | YOLOv8s | 640×640 | ~20ms | ~30ms |
| ONNX Runtime GPU | YOLOv8n | 640×640 | ~8ms | ~15ms |
| ONNX Runtime CPU | YOLOv8n | 640×640 | ~80ms | ~100ms |
数据来源:
pcb缺陷检测初步方案.md引用的 Y7000P 实测基准
PCB 后道复判场景需求:触发模式,每块板约 1-3 秒间隔,15ms 的推理延迟完全满足。
显存占用
| 模型 | 显存占用 |
|---|---|
| YOLOv8n(ultralytics) | ~1.2GB |
| YOLOv8s(ultralytics) | ~2.4GB |
| YOLOv8n(ONNX CUDA EP) | ~0.8GB |
RTX 4060 8GB 剩余 6GB+ 可用,不构成瓶颈。
并发限制
# main.py 启动参数
# workers=1:GPU 不支持多进程共享同一 CUDA Context
# 推理天然串行,配合 FastAPI 的 async 处理并发等待队列
uvicorn main:app --host 0.0.0.0 --port 8001 --workers 1
PCB 产线触发模式下,单工位请求天然串行,workers=1 完全够用。若需多产线并发,在 mingxi-backend 侧做请求队列即可。
13. 模型导出(pt → onnx)
# scripts/export_onnx.py
from ultralytics import YOLO
model = YOLO("./models/pcb_defect_v1.pt")
model.export(
format="onnx",
imgsz=640,
opset=12, # onnxruntime 1.16 兼容
simplify=True, # onnx-simplifier 优化计算图
dynamic=False, # 固定 batch=1,推理服务不需要动态 batch
)
# 产物:pcb_defect_v1.onnx
切换到 ONNX 的时机:
1. YOLOv8n 在验证集 mAP@0.5 > 0.90 → 导出 ONNX
2. 运行 scripts/benchmark.py 对比两个运行时延迟
3. 确认输出一致后,修改 .env 的 RUNTIME=onnx
4. 重启服务,观察 /api/health 确认切换成功
14. 与现有代码的对应关系
现有 inference.py 函数 |
mingxi-vision 对应位置 | 变化 |
|---|---|---|
_MODEL_CACHE |
engine/loader.py 单例 |
从懒加载改为启动时加载 |
_get_model() |
loader.init_engine() |
新增 ONNX 分支 |
_copy_to_ascii_temp_input() |
已删除 | 改用内存读图,无需文件路径 |
_parse_result() |
ultralytics_adapter.detect() |
增加 severity 映射 |
run_image_inference() |
engine/ultralytics_adapter.py + engine/onnx_adapter.py |
拆成两个运行时 |
run_camera_inference() |
不迁移 | 由 mingxi-capture 负责 |
run_video_inference() |
暂不迁移 | 此版本不需要 |
Django MEDIA_ROOT 文件落盘 |
已删除 | 推理服务无状态,不落盘 |
15. 关键设计决策汇总
| 决策 | 选择 | 理由 |
|---|---|---|
| Web 框架 | FastAPI | 轻量、无需 DB、原生 async、自动文档 |
| 模型运行时 | 双运行时(适配器模式) | 开发用 .pt 方便迭代,生产用 .onnx 稳定 |
| 模型加载时机 | 启动时加载 + warmup | 消除首次推理延迟,路演不卡顿 |
| 图像读取 | 内存读取(np.frombuffer) | 彻底消除中文路径问题 |
| 标注图返回 | 按需(return_annotated=true) | 默认不返回,减少响应体积和编码开销 |
| 并发模型 | workers=1,单进程 | GPU CUDA Context 不支持多进程共享 |
| 鉴权 | 无 | 内网服务,由 backend 代理调用 |
| 数据持久化 | 无 | 无状态推理服务,落库交给 backend |
| NMS 后处理 | ONNX 适配器自实现,ultralytics 自带 | 两路输出语义一致 |