feat: 修复 AI 风险语义并隔离 Mock 数据

This commit is contained in:
weijuesen
2026-08-14 01:16:50 +08:00
parent 839ba91354
commit 74d1948d68
29 changed files with 650 additions and 113 deletions
+4 -2
View File
@@ -5,7 +5,7 @@ FastAPI + ONNX Runtime 的蚕病检测推理服务。YOLO 训练挂起期间以
## 接口
- `GET /health``{"status":"ok","model":"mock|onnx"}`
- `POST /detect`multipart 字段 `file`)→ `{"model":"mock","detections":[{"bbox":{x,y,w,h},"class":"healthy|sick","confidence":0.95}]}`
- `POST /detect`multipart 字段 `file`)→ `{"model":"mock","modelVersion":"...","isMock":true,"status":"healthy|abnormal|unknown","abnormalProbability":0,"detections":[{"bbox":{x,y,w,h},"class":"healthy|sick","confidence":0.95}]}`
- `POST /internal/stream-tasks`(需 `X-Internal-Key`)→ 创建受限拉流任务,不再接受客户端任意 URL
## 本地运行
@@ -33,6 +33,7 @@ powershell -ExecutionPolicy Bypass -File scripts/verify.ps1
| `MODEL_MODE` | `mock` | `mock` / `onnx` |
| `MODEL_PATH` | `models/best.onnx` | ONNX 模型路径 |
| `MODEL_LABELS` | `healthy,sick` | 类别列表(逗号分隔) |
| `MODEL_VERSION` | `mock-2026.08.14` / `best.onnx` | 模型版本;真实模型上线时必须显式配置 |
| `MOCK_CLASS` | `healthy` | mock 返回类别 |
| `MOCK_CONFIDENCE` | `0.95` | mock 返回置信度 |
| `INTERNAL_API_KEY` | `silk-internal-2026` | 内部接口认证密钥 |
@@ -42,5 +43,6 @@ powershell -ExecutionPolicy Bypass -File scripts/verify.ps1
## 说明
- 只做检测风险评分在 Go 后端计算(#90.5×AI 置信度 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度)。
- 只做检测并返回 `abnormalProbability`风险评分在 Go 后端计算(#90.5×AI 异常概率 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度,缺失项归一化)。
- `healthy` 高置信度不贡献异常概率;空检测或 `unknown` 返回 `unknown`,不自动视为健康。
- ONNX 后处理按 YOLOv8 常见输出格式实现(含 letterbox 与 NMS),训练产物出来后需用真实模型校准验证。
+1
View File
@@ -5,6 +5,7 @@ MODEL_MODE = os.getenv("MODEL_MODE", "mock")
MODEL_PATH = os.getenv("MODEL_PATH", "models/best.onnx")
# YOLO 类别(二分类训练基线:healthy/sick;7 类病种扩展后再调整)
MODEL_LABELS = os.getenv("MODEL_LABELS", "healthy,sick").split(",")
MODEL_VERSION = os.getenv("MODEL_VERSION", "mock-2026.08.14" if MODEL_MODE == "mock" else "best.onnx")
# mock 模式返回的固定结果
MOCK_CLASS = os.getenv("MOCK_CLASS", "healthy")
MOCK_CONFIDENCE = float(os.getenv("MOCK_CONFIDENCE", "0.95"))
+46
View File
@@ -5,6 +5,52 @@ from abc import ABC, abstractmethod
from PIL import Image
def _label_set(labels) -> set[str]:
return {label.strip().lower() for label in labels if label.strip()}
def abnormal_probability(detections: list[dict], labels: tuple[str, ...] | list[str]) -> float:
"""单帧异常概率:取异常类检测的最高置信度,避免多框求和造成虚高。"""
abnormal = _label_set(labels) - {"healthy"}
best = 0.0
for detection in detections:
class_name = str(detection.get("class_name", "")).strip().lower()
if class_name in ("", "healthy", "unknown"):
continue
if abnormal and class_name not in abnormal:
continue
try:
confidence = float(detection.get("confidence", 0))
except (TypeError, ValueError):
confidence = 0.0
best = max(best, min(1.0, max(0.0, confidence)))
return best
def detection_status(detections: list[dict], labels: tuple[str, ...] | list[str]) -> str:
"""空检测或 unknown 类不再被当作 healthy。"""
if not detections:
return "unknown"
abnormal = _label_set(labels) - {"healthy"}
healthy_seen = False
unknown_seen = False
for detection in detections:
class_name = str(detection.get("class_name", "")).strip().lower()
if abnormal and class_name in abnormal:
return "abnormal"
if not abnormal and class_name not in ("", "healthy", "unknown"):
return "abnormal"
if class_name == "healthy":
healthy_seen = True
elif class_name in ("", "unknown"):
unknown_seen = True
else:
unknown_seen = True
if healthy_seen and not unknown_seen:
return "healthy"
return "unknown"
class Detector(ABC):
@abstractmethod
def detect(self, image_bytes: bytes) -> list[dict]:
+13 -2
View File
@@ -6,7 +6,7 @@ import time
from fastapi import FastAPI, File, HTTPException, Request, UploadFile
from . import config
from .detector import MockDetector, ONNXDetector
from .detector import MockDetector, ONNXDetector, abnormal_probability, detection_status
from .stream_tasks import StreamTaskWorker, is_allowed_stream_ref
app = FastAPI(title="Silk AI Service", version="0.1.0")
@@ -36,7 +36,12 @@ else:
@app.get("/health")
def health():
return {"status": "ok", "model": config.MODEL_MODE}
return {
"status": "ok",
"model": config.MODEL_MODE,
"modelVersion": config.MODEL_VERSION,
"isMock": isinstance(detector, MockDetector),
}
@app.post("/detect")
@@ -50,6 +55,10 @@ async def detect(file: UploadFile = File(...)):
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {
"model": config.MODEL_MODE,
"modelVersion": config.MODEL_VERSION,
"isMock": isinstance(detector, MockDetector),
"status": detection_status(detections, config.MODEL_LABELS),
"abnormalProbability": abnormal_probability(detections, config.MODEL_LABELS),
"detections": [
{
"bbox": d["bbox"],
@@ -112,6 +121,8 @@ def metrics():
gpu = None
return {
"model": config.MODEL_MODE,
"modelVersion": config.MODEL_VERSION,
"isMock": isinstance(detector, MockDetector),
"uptimeSeconds": int(time.time() - START_TIME),
"requests": reqs,
"avgLatencyMs": round(avg, 2),
+30
View File
@@ -6,7 +6,9 @@ os.environ.setdefault("ALLOWED_STREAM_HOSTS", "localhost,127.0.0.1,100.83.103.1"
from fastapi.testclient import TestClient
from app import main as main_module
from app.main import app
from app.detector import MockDetector
# 1x1 透明 PNG
TINY_PNG = base64.b64decode(
@@ -22,6 +24,8 @@ def test_health():
body = r.json()
assert body["status"] == "ok"
assert body["model"] in ("mock", "onnx")
assert body["modelVersion"]
assert body["isMock"] is True
def test_detect_ok():
@@ -29,6 +33,10 @@ def test_detect_ok():
assert r.status_code == 200
body = r.json()
assert body["model"] == "mock"
assert body["modelVersion"]
assert body["isMock"] is True
assert body["status"] == "healthy"
assert body["abnormalProbability"] == 0
assert len(body["detections"]) >= 1
d = body["detections"][0]
assert d["class"] in ("healthy", "sick")
@@ -36,6 +44,28 @@ def test_detect_ok():
assert d["bbox"]["w"] > 0
def test_detect_uses_abnormal_class_confidence(monkeypatch):
monkeypatch.setattr(main_module, "detector", MockDetector(class_name="sick", confidence=0.92))
r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")})
assert r.status_code == 200
body = r.json()
assert body["status"] == "abnormal"
assert body["abnormalProbability"] == 0.92
def test_detect_empty_result_is_unknown(monkeypatch):
class EmptyDetector:
def detect(self, image_bytes):
return []
monkeypatch.setattr(main_module, "detector", EmptyDetector())
r = client.post("/detect", files={"file": ("a.png", TINY_PNG, "image/png")})
assert r.status_code == 200
body = r.json()
assert body["status"] == "unknown"
assert body["abnormalProbability"] == 0
def test_detect_empty_file_rejected():
r = client.post("/detect", files={"file": ("a.png", b"", "image/png")})
assert r.status_code == 400
+20 -1
View File
@@ -2,7 +2,7 @@ import base64
import pytest
from app.detector import MockDetector
from app.detector import MockDetector, abnormal_probability, detection_status
# 1x1 透明 PNG
TINY_PNG = base64.b64decode(
@@ -23,3 +23,22 @@ def test_mock_detector_rejects_invalid_image():
det = MockDetector()
with pytest.raises(ValueError):
det.detect(b"not an image")
def test_abnormal_probability_ignores_healthy_and_unknown():
detections = [
{"class_name": "healthy", "confidence": 0.95},
{"class_name": "unknown", "confidence": 0.8},
{"class_name": "sick", "confidence": 0.72},
]
assert abnormal_probability(detections, ("healthy", "sick")) == 0.72
def test_detection_status_returns_unknown_for_empty_or_unknown():
assert detection_status([], ("healthy", "sick")) == "unknown"
assert detection_status([{"class_name": "unknown", "confidence": 0.8}], ("healthy", "sick")) == "unknown"
assert detection_status([{"class_name": "healthy", "confidence": 0.95}], ("healthy", "sick")) == "healthy"
assert detection_status(
[{"class_name": "white_muscardine", "confidence": 0.7}],
("healthy", "white_muscardine", "nuclear_polyhedrosis"),
) == "abnormal"