feat: 手术视频消耗、待确认与持久化改造

- 新增 Alembic 初始迁移、领域明细模型及归档持久化与重试链路\n- 拆分视频会话注册表、分类处理、推理时间窗聚合与流处理\n- 消耗日志:TSV/Markdown 含 top2/top3;item_id 优先产品编码;待确认记「待确认」行,语音确认后落正式行并更新汇总\n- 待确认时内存/DB 明细为占位行,确认后替换;拒绝时移除占位\n- 分类 probs 先 detach/cpu 再转 NumPy,修复 MPS/CUDA 上推理被静默跳过\n- 补充集成测试、归档与设备张量等单测

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-23 20:42:21 +08:00
parent 69980d8073
commit 3d7bd70355
55 changed files with 4544 additions and 2050 deletions

View File

@@ -20,7 +20,11 @@ from ultralytics import YOLO
from app.config import Settings, settings
os.environ["YOLO_CONFIG_DIR"] = "/tmp"
def _ensure_yolo_config_dir() -> None:
"""Ultralytics 需要可写 YOLO_CONFIG_DIR仅在未设置时给一个安全默认不覆盖用户配置。"""
if not os.environ.get("YOLO_CONFIG_DIR"):
os.environ["YOLO_CONFIG_DIR"] = "/tmp"
def resolve_inference_device(explicit: str) -> str | None:
@@ -184,40 +188,62 @@ def pad_box(
return nx1, ny1, nx2, ny2
def _probs_data_to_numpy1d(raw) -> np.ndarray:
"""分类 logits/probs 向量 → 1D float64 NumPy 数组。
PyTorch 张量若在 ``cuda``、``mps`` 等设备上,**必须先** ``.cpu()`` 再转 NumPy
NumPy 只支持 CPU主机内存没有 CUDA/MPS 后端;``np.asarray(cuda_tensor)`` /
``tensor.numpy()``(设备上)都会失败。``.cpu()`` 会做一次设备→主机的拷贝(已是 CPU
时开销很小),因此 CUDA 与 MPS 共用同一路径即可。
"""
if raw is None:
return np.zeros((0,), dtype=np.float64)
x = raw
if hasattr(x, "detach"):
x = x.detach()
if hasattr(x, "cpu"):
x = x.cpu()
if hasattr(x, "numpy"):
# torch.Tensor / ultralytics BaseTensor 等
x = x.numpy()
return np.asarray(x, dtype=np.float64).reshape(-1)
def cls_top3_from_result(
cls: YOLO, r, name_to_code: dict[str, str]
) -> ClsTop3 | None:
pr = r[0].probs
if pr is None or not hasattr(pr, "top5") or not pr.top5:
if pr is None:
return None
t5i = list(pr.top5)
tc = pr.top5conf
if tc is None:
arr = _probs_data_to_numpy1d(pr.data)
if arr.size == 0:
return None
order = np.argsort(-arr, kind="stable")
t5i = [int(order[i]) for i in range(min(5, int(order.size)))]
def _ci(i: int) -> float:
if i < 0 or i >= len(tc):
def _conf_for_idx(idx: int) -> float:
if idx < 0 or idx >= arr.size:
return 0.0
try:
v = tc[i]
v = arr[idx]
return float(v.item() if hasattr(v, "item") else v)
except (IndexError, ValueError, TypeError):
return 0.0
t1i = int(pr.top1)
c1 = _ci(0) if t5i and int(t5i[0]) == t1i else float(
pr.top1conf.item() if hasattr(pr.top1conf, "item") else pr.top1conf
)
t1i = int(t5i[0])
c1 = _conf_for_idx(t1i)
n1 = str(cls.names.get(t1i, "")).strip()
n2 = n3 = ""
c2 = c3 = 0.0
if len(t5i) > 1:
n2 = str(cls.names.get(int(t5i[1]), "")).strip()
c2 = _ci(1)
i2 = int(t5i[1])
n2 = str(cls.names.get(i2, "")).strip()
c2 = _conf_for_idx(i2)
if len(t5i) > 2:
n3 = str(cls.names.get(int(t5i[2]), "")).strip()
c3 = _ci(2)
i3 = int(t5i[2])
n3 = str(cls.names.get(i3, "")).strip()
c3 = _conf_for_idx(i3)
def _pid(label: str) -> str:
lb = (label or "").strip()
@@ -283,12 +309,50 @@ class ConsumableVisionAlgorithmService:
"""手部检测(可选)+ 耗材分类;供 CameraSessionManager 在视频线程中调用。"""
def __init__(self, app_settings: Settings | None = None) -> None:
_ensure_yolo_config_dir()
self._s = app_settings or settings
self._det: YOLO | None = None
self._cls: YOLO | None = None
self._det_lock = Lock()
self._cls_lock = Lock()
def effective_candidate_consumables(self, requested: list[str]) -> list[str]:
"""请求体中的耗材子集;未提供(缺省或仅空白)时用目录 Excel 全部商品名,无目录则用分类模型全部类名。"""
out: list[str] = []
seen: set[str] = set()
for c in requested:
n = _norm_product_name((c or "").strip())
if not n or n in seen:
continue
seen.add(n)
out.append(n)
if out:
return out
xlsx_raw = (self._s.consumable_catalog_xlsx_path or "").strip()
if xlsx_raw:
path = Path(xlsx_raw).expanduser()
if path.is_file():
try:
full = load_name_to_product_code(path)
except Exception as exc:
logger.warning("读取耗材目录 Excel 失败,回退到模型类名: {}", exc)
else:
if full:
return sorted(full.keys())
logger.warning("耗材目录 Excel 无有效行,回退到模型类名")
else:
logger.warning(
"耗材目录 Excel 路径已配置但文件不存在: {},回退到模型类名",
path,
)
cls_model = self._get_cls()
labels = sorted(
{str(v).strip() for v in cls_model.names.values() if str(v).strip()}
)
return labels
def build_name_mapping(
self, candidate_consumables: list[str]
) -> dict[str, str]: