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:
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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="物品名称(分类或确认后的展示名)。")
|
||||
|
||||
@@ -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]:
|
||||
"""分类标签 -> 业务物品 id(Excel 产品编码;无表时用名称自身)。"""
|
||||
"""分类类名(归一化) -> 业务 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()
|
||||
|
||||
@@ -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 只写与展示名不同的业务 id(label_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()
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -59,7 +59,7 @@ class CameraStreamInferState:
|
||||
@dataclass
|
||||
class SurgerySessionState:
|
||||
candidate_consumables: list[str]
|
||||
#: 分类类名(归一化) -> 业务物品 id(Excel 产品编码或名称)。
|
||||
#: 分类类名(归一化) -> 业务物品 id(YAML 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)
|
||||
|
||||
Reference in New Issue
Block a user