feat: 修复 AI 风险语义并隔离 Mock 数据
This commit is contained in:
@@ -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 后端计算(#9:0.5×AI 置信度 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度)。
|
||||
- 只做检测并返回 `abnormalProbability`;风险评分在 Go 后端计算(#9:0.5×AI 异常概率 + 0.2×环境 + 0.15×阶段 + 0.15×整齐度,缺失项归一化)。
|
||||
- `healthy` 高置信度不贡献异常概率;空检测或 `unknown` 返回 `unknown`,不自动视为健康。
|
||||
- ONNX 后处理按 YOLOv8 常见输出格式实现(含 letterbox 与 NMS),训练产物出来后需用真实模型校准验证。
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user