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:
@@ -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]:
|
||||
|
||||
Reference in New Issue
Block a user