Update consumable pipeline, client API docs, and deployment config

- Refine effective candidate consumables and classifier labels
- Adjust vision algorithm, TSV logging, and video session wiring
- Refresh client surgery HTTP contract doc and staging/video docs
- Update settings, docker-compose prod, tests, and uv.lock

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-24 11:05:17 +08:00
parent 3d7bd70355
commit 557fcee803
15 changed files with 529 additions and 636 deletions

View File

@@ -58,7 +58,7 @@ class _VideoGroup(_SettingsGroup):
"consumable_classifier_device",
"consumable_classifier_topk",
"consumable_min_cls_confidence",
"consumable_catalog_xlsx_path",
"consumable_classifier_labels_yaml_path",
"consumable_vision_window_sec",
"hand_detection_weights",
"hand_detection_imgsz",
@@ -172,6 +172,11 @@ def _default_camera_rtsp_urls_sample_path() -> str:
return str(_PACKAGE_DIR / "resources" / "camera_rtsp_urls.sample.json")
def _default_consumable_classifier_labels_yaml() -> str:
"""与分类训练类名、业务 `label_id` 对照的 YAML见 `app/resources/consumable_classifier_labels.yaml`。"""
return str(_PACKAGE_DIR / "resources" / "consumable_classifier_labels.yaml")
class Settings(BaseSettings):
"""Application configuration loaded from environment / .env."""
@@ -199,8 +204,8 @@ class Settings(BaseSettings):
consumable_classifier_topk: int = 5
#: 耗材分类 top1 最低置信度(手部 ROI 或全帧送入分类器后的门槛)。
consumable_min_cls_confidence: float = Field(default=0.5, ge=0.0, le=1.0)
#: 可选:`视频中的商品信息表.xlsx`(含「商品名称」「产品编码」);空则物品 id 用名称本身
consumable_catalog_xlsx_path: str = ""
#: 分类类名 + 业务 `label_id` 对照(与训练 `names` 一致);`build_name_mapping` 将识别出的类名匹配至此得到业务 id。空则使用下方默认包内文件
consumable_classifier_labels_yaml_path: str = ""
#: 与离线脚本一致的时间窗(秒);窗内多次推理取众数后再走自动记账 / 语音追问逻辑。
consumable_vision_window_sec: float = Field(default=15.0, ge=0.5, le=600.0)
#: 手部检测 YOLO 权重;空或文件不存在时退化为「全帧送分类器」(兼容仅有关分类权重的环境)。
@@ -354,6 +359,13 @@ class Settings(BaseSettings):
return _default_consumable_classifier_weights()
return str(value)
@field_validator("consumable_classifier_labels_yaml_path", mode="before")
@classmethod
def consumable_classifier_labels_yaml_path_default(cls, value: object) -> str:
if value is None or str(value).strip() == "":
return _default_consumable_classifier_labels_yaml()
return str(value).strip()
model_config = SettingsConfigDict(
env_file=(str(_DEFAULT_ENV_FILE),),
env_file_encoding="utf-8",

View File

@@ -1,48 +1,93 @@
# 耗材分类器 consumable_classifier.pt 的 YOLO-cls 类别名(与训练 data.yaml 对齐)
# 推理时标签以权重内嵌 names 为准;本文件仅供文档与类别清单对照。
# 与训练 data.yaml 类名一一对应;多规格时 label_id 以 / 连接;业务唯一来源
# 耗材分类器 consumable_classifier.pt 的 YOLO-cls 类别名应与训练 data.yaml 一致;
# 业务 label_id 与 names 下标一一对应;推理时以权重内嵌 names 为准。
names:
0: MCuⅡ功能性宫内节育器
1: 一次性中性电极板
2: 一次性使用乳胶导尿管
3: 一次性使用冲洗袋
4: 一次性使用医疗卫生用品
5: 一次性使用单极手术电极
6: 一次性使用导尿管
7: 一次性使用手术单
8: 一次性使用手术单(一次性医用垫单)
9: 一次性使用手术衣
10: 一次性使用无菌敷贴
11: 一次性使用无菌气管插管Tracheal Tube
12: 一次性使用无菌注射器带针
13: 一次性使用无菌采样拭子
14: 一次性使用气管插管
15: 一次性使用灭菌棉签
16: 一次性使用灭菌橡胶外科手套
17: 一次性使用牙垫
18: 一次性使用精密过滤输液器 带针
19: 一次性使用肛门管
20: 一次性使用胃管
21: 一次性使用血液透析管路
22: 一次性使用输卵管导管
23: 一次性使用雾化器
24: 一次性使用静脉留置针
25: 一次性使用静脉输液针
26: 一次性使用麻醉面罩
27: 一次性内窥镜护套
28: 一次性医用灭菌棉签
29: 一次性无菌喉罩
30: 医用凡士林敷料
31: 医用纱布敷料
32: 医用缝合针
33: 医用脱脂棉纱布块
34: 可吸收性外科缝线
35: 密闭式防针刺伤型静脉留置针
36: 导管固定器
37: 气管切开插管
38: 结扎夹Ligating Clips
39: 自粘性薄膜敷料
40: 血液净化装置的体外循环血路
41: 负压引流
42: 非吸收性外科缝线
43: 非吸收性外科缝线(蚕丝线)
nc: 44
0: 医用纱布敷料
1: 一次性使用无菌注射器带针
2: 一次性使用手术单
3: 一次性使用肛门管
4: 医用缝合针
5: 非吸收性外科缝线(蚕丝线)
6: 一次性中性电极板
7: 一次性使用胃管
8: 一次性使用输卵管导管
9: 医用脱脂棉纱布块
10: 导管固定器
11: 可吸收性外科缝线
12: 一次性使用医疗卫生用品
13: 一次性医用灭菌棉签
14: 一次性使用静脉输液针
15: 非吸收性外科缝线
16: 一次性使用冲洗袋
17: 医用凡士林敷料
18: 一次性使用无菌敷贴
19: 自粘性薄膜敷料
20: 密闭式防针刺伤型静脉留置针
21: 一次性使用静脉留置针
22: 一次性使用手术单(一次性医用垫单)
23: 一次性使用无菌采样拭子
24: 一次性使用手术衣
25: 一次性使用气管插管
26: 一次性使用牙垫
27: 一次性使用血液透析管路
28: 血液净化装置的体外循环血路
29: 一次性使用精密过滤输液器 带针
30: 结扎夹Ligating Clips
31: 一次性使用麻醉面罩
32: 一次性内窥镜护套
33: 一次性使用导尿管
34: 一次性使用灭菌橡胶外科手套
35: 一次性无菌喉罩
36: 一次性使用无菌气管插管Tracheal Tube
37: 负压引流器
38: 一次性使用单极手术电极
39: 一次性使用乳胶导尿管
40: 气管切开插管
41: 一次性使用雾化
42: MCuⅡ功能性宫内节育器
# 与 names 的整数下标一一对应;多规格同名则为 id1/id2/... 。
label_id:
0: 19246-3-14
1: 1531-3-2/1531-3-1/1174-42-4/1174-42-1
2: 14764-2-4
3: 1518-22-4
4: 583039/11207-1-64
5: 654032
6: 4787-2-55
7: 1518-34-17
8: 1380-15-1
9: 8028-4-39
10: 1441340
11: 11765-1-101/1330-49-185
12: 2241272
13: 2237844/10183-1-29
14: 129-5-30
15: 4142-1-46
16: 1644-37-3
17: 10870-25-16
18: 215-93-1
19: 1819-4-1
20: 1281-39-3
21: 12591-1-184
22: 21504-1-1
23: 15026-1-1
24: 8386-24-1
25: 21444-1-2
26: 975961
27: 14730-10-10
28: 739-2-1
29: 2295950
30: 14780-2-12
31: 2003984
32: 521-31-1
33: 735592/14556-4-18
34: 10362-1-4
35: 7386-61-46
36: 14780-3-5
37: 1518-20-8
38: 4805-2-50
39: 7386-10-89
40: 10869-30-7
41: 5019-4-43
42: 740-2-14
nc: 43

View File

@@ -36,8 +36,8 @@ class SurgeryStartRequest(BaseModel):
description=(
"本次手术可能使用到的耗材子集(可选)。"
"非空时仅对该清单内名称做自动记账与待确认追问。"
"缺省或空数组时,使用服务端配置的耗材目录 Excel 全部商品名;"
"未配置目录则使用分类模型全部类名。"
"缺省或空数组时,使用 consumable_classifier_labels.yaml 全部名;"
"无有效 yaml 则使用分类模型全部类名。"
),
)
@@ -96,8 +96,8 @@ class SurgeryConsumptionDetail(BaseModel):
item_id: str = Field(
description=(
"业务物品标识:优先为耗材目录中的产品编码"
"目录键经名称归一化后与分类类名匹配,未命中目录时与模型输出类名一致"
"业务物品标识:来自 consumable_classifier_labels.yaml 的 label_id"
"类名经归一化后匹配,未在 yaml 中配置 label_id 时与模型类名相同"
),
)
item_name: str = Field(description="物品名称(分类或确认后的展示名)。")

View File

@@ -5,7 +5,7 @@
from __future__ import annotations
import math
from typing import Any
import os
import sys
from collections import Counter
@@ -14,8 +14,8 @@ from pathlib import Path
from threading import Lock
import numpy as np
import yaml
from loguru import logger
from openpyxl import load_workbook
from ultralytics import YOLO
from app.config import Settings, settings
@@ -79,22 +79,6 @@ class ClsTop3:
t3_pid: str
def _find_col_idx(headers: list[object], want: str) -> int | None:
want = want.strip()
for i, h in enumerate(headers):
if str(h).strip() == want:
return i
return None
def _cell_empty(value: object) -> bool:
if value is None:
return True
if isinstance(value, float) and math.isnan(value):
return True
return False
def _norm_product_name(name: str) -> str:
s = (name or "").strip()
if s == "一次性医用垫单":
@@ -102,49 +86,73 @@ def _norm_product_name(name: str) -> str:
return s
def load_name_to_product_code(xlsx: Path) -> dict[str, str]:
"""商品名 -> 产品编码(白名单键为归一化后的名称)。"""
wb = load_workbook(filename=str(xlsx), read_only=True, data_only=True)
def load_name_to_label_id_from_yaml(path: Path) -> dict[str, str]:
"""从 ``consumable_classifier_labels.yaml`` 得到:归一化商品名 -> 业务 label_id可与 ``names`` 下标一一对应;多规格为 ``a/b/...``)。"""
try:
ws = wb.worksheets[0]
rows = ws.iter_rows(values_only=True)
header = next(rows, None)
if header is None:
raise ValueError("Excel 为空")
headers = list(header)
i_code = _find_col_idx(headers, "产品编码")
i_name = _find_col_idx(headers, "商品名称")
if i_code is None or i_name is None:
raise ValueError("Excel 缺少「产品编码」或「商品名称」列")
raw = path.read_text(encoding="utf-8")
except OSError as exc:
logger.warning("无法读取耗材 label YAML {}: {}", path, exc)
return {}
try:
data: Any
data = yaml.safe_load(raw)
except yaml.YAMLError as exc:
logger.warning("解析耗材 label YAML 失败 {}: {}", path, exc)
return {}
if not isinstance(data, dict):
return {}
names_raw = data.get("names")
label_raw = data.get("label_id")
if not isinstance(names_raw, dict) or not isinstance(label_raw, dict):
return {}
out: dict[str, str] = {}
for k, v in names_raw.items():
try:
i = int(k)
except (TypeError, ValueError):
continue
name = str(v).strip() if v is not None else ""
if not name:
continue
lid: Any = None
if i in label_raw:
lid = label_raw[i]
elif str(i) in label_raw:
lid = label_raw[str(i)]
if lid is None or (isinstance(lid, str) and not str(lid).strip()):
continue
id_str = str(lid).strip()
out[_norm_product_name(name)] = id_str
return out
m: dict[str, str] = {}
dups: set[str] = set()
for row in rows:
if not row:
continue
raw = row[i_name] if i_name < len(row) else None
if _cell_empty(raw):
continue
n = _norm_product_name(str(raw).strip())
if not n:
continue
code = row[i_code] if i_code < len(row) else None
if _cell_empty(code):
continue
sc = str(code).strip()
if n in m and m[n] != sc:
dups.add(n)
continue
if n not in m:
m[n] = sc
finally:
wb.close()
if dups:
logger.warning(
"Excel 中以下商品名称对应多组产品编码,已保留首次映射: {}",
"".join(sorted(dups)[:12]) + ("" if len(dups) > 12 else ""),
)
return m
def list_sorted_class_names_from_yaml(path: Path) -> list[str]:
"""自 ``names`` 段按类索引升序取类名字符串(与训练/权重一致)。"""
try:
raw = path.read_text(encoding="utf-8")
except OSError:
return []
try:
data: Any = yaml.safe_load(raw)
except yaml.YAMLError:
return []
if not isinstance(data, dict):
return []
names_raw = data.get("names")
if not isinstance(names_raw, dict):
return []
items: list[tuple[int, str]] = []
for k, v in names_raw.items():
try:
i = int(k)
except (TypeError, ValueError):
continue
s = str(v).strip() if v is not None else ""
if not s:
continue
items.append((i, _norm_product_name(s)))
items.sort(key=lambda t: t[0])
return [n for _, n in items]
def collect_hand_boxes(model: YOLO, boxes) -> list[tuple[float, float, float, float]]:
@@ -317,7 +325,7 @@ class ConsumableVisionAlgorithmService:
self._cls_lock = Lock()
def effective_candidate_consumables(self, requested: list[str]) -> list[str]:
"""请求体中的耗材子集;未提供(缺省或仅空白)时用目录 Excel 全部商品名,无目录则用分类模型全部类名。"""
"""请求体中的耗材子集;未提供(缺省或仅空白)时先用 ``consumable_classifier_labels.yaml`` 的 ``names``,无有效 YAML 则分类模型类名。"""
out: list[str] = []
seen: set[str] = set()
for c in requested:
@@ -329,23 +337,12 @@ class ConsumableVisionAlgorithmService:
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,
)
yaml_path = Path(self._s.consumable_classifier_labels_yaml_path).expanduser()
if yaml_path.is_file():
ylist = list_sorted_class_names_from_yaml(yaml_path)
if ylist:
return ylist
logger.warning("耗材 label YAML 中无有效 names: {}", yaml_path)
cls_model = self._get_cls()
labels = sorted(
@@ -356,21 +353,26 @@ class ConsumableVisionAlgorithmService:
def build_name_mapping(
self, candidate_consumables: list[str]
) -> dict[str, str]:
"""分类标签 -> 业务物品 idExcel 产品编码;无表时用名称自身)"""
"""分类类名(归一化) -> 业务 id:仅 ``consumable_classifier_labels.yaml`` 的 ``label_id``;无映射时用语义类名作 id"""
stripped = [_norm_product_name(c.strip()) for c in candidate_consumables if c.strip()]
candidates_norm = {n: n for n in stripped}
xlsx_raw = (self._s.consumable_catalog_xlsx_path or "").strip()
if xlsx_raw:
path = Path(xlsx_raw).expanduser()
if path.is_file():
full = load_name_to_product_code(path)
out: dict[str, str] = {}
for norm in candidates_norm:
if norm in full:
out[norm] = full[norm]
return out
logger.warning("耗材目录 Excel 路径已配置但文件不存在: {}", path)
return {n: n for n in candidates_norm}
if not candidates_norm:
return {}
yaml_path = Path(self._s.consumable_classifier_labels_yaml_path).expanduser()
yaml_map: dict[str, str] = {}
if yaml_path.is_file():
try:
yaml_map = load_name_to_label_id_from_yaml(yaml_path)
except Exception as exc: # noqa: BLE001
logger.warning("加载耗材 label YAML 失败 {}: {}", yaml_path, exc)
else:
logger.debug("耗材 label YAML 不存在: {}", yaml_path)
out: dict[str, str] = {}
for norm in candidates_norm:
out[norm] = yaml_map.get(norm) or norm
return out
def _det_weights(self) -> Path | None:
raw = (self._s.hand_detection_weights or "").strip()

View File

@@ -20,7 +20,7 @@ from app.services.consumable_vision_algorithm import ClsTop3, _norm_product_name
from app.terminal_markdown import print_markdown_stderr
# 制表符分隔;时间范围用 U+2013 连接;本窗消耗数量恒为 1。
# top2/top3 为模型原始排序未按手术候选重排item_id 仅写产品编码,无编码时留空。
# top2/top3 为模型原始排序未按手术候选重排item_id 只写与展示名不同的业务 idlabel_id与名称相同时留空。
HEADER = (
"item_id\titem_name\tqty\tdoctor_id\ttimestamp\t"
"top2_name\ttop2_conf\ttop3_name\ttop3_conf\n"
@@ -97,7 +97,7 @@ def resolve_consumption_ids(
) -> tuple[str, str]:
"""TSV 第一列 item_id 与内存汇总键。
- ``tsv_item_id``仅产品编码(或模型侧 t1_pid与展示名相同则视为无独立编码,留空。
- ``tsv_item_id``业务 id(或模型侧 t1_pid与展示名相同则视为无独立 id,留空。
- ``totals_key``:汇总用稳定键;无编码时用归一化名称,避免多行空 id 碰撞。
"""
n = (t1_name or "").strip()

View File

@@ -140,7 +140,7 @@ class CameraSessionManager:
if not resolved:
raise SurgeryPipelineError(
"RECORDING_CANNOT_START",
"耗材候选为空:请在请求中传入 candidate_consumables配置耗材目录 Excel / 分类模型。",
"耗材候选为空:请在请求中传入 candidate_consumables提供有效的 consumable_classifier_labels.yaml / 分类模型。",
)
if not any(str(x).strip() for x in candidate_consumables):
logger.info(

View File

@@ -59,7 +59,7 @@ class CameraStreamInferState:
@dataclass
class SurgerySessionState:
candidate_consumables: list[str]
#: 分类类名(归一化) -> 业务物品 idExcel 产品编码或名称)。
#: 分类类名(归一化) -> 业务物品 idYAML label_id 或类名)
name_to_code: dict[str, str] = field(default_factory=dict)
camera_infer: dict[str, CameraStreamInferState] = field(default_factory=dict)
details: list[SurgeryConsumptionStored] = field(default_factory=list)