feat: 消费停录汇总与查结果同口径,并更新分类与 TSV

- 停录时由 details 经 build_consumption_summary 写 TSV/终端汇总,移除 consumption_log_totals 与时间窗累加
- 更新 consumable_classifier 权重、YAML 标签与 consumable_vision_algorithm
- 扩展 consumption TSV 相关测试与配置注释

Made-with: Cursor
This commit is contained in:
Kevin
2026-04-24 14:27:56 +08:00
parent 557fcee803
commit b651364877
9 changed files with 318 additions and 155 deletions

View File

@@ -5,8 +5,9 @@
from __future__ import annotations
from typing import Any
import functools
import os
from typing import Any
import sys
from collections import Counter
from dataclasses import dataclass
@@ -126,6 +127,38 @@ def load_name_to_label_id_from_yaml(path: Path) -> dict[str, str]:
return out
def load_index_to_label_id_from_yaml(path: Path) -> dict[int, str]:
"""与 ``label_id`` 段:类索引 -> 业务 id 字符串;类名与 YAML 略有不一致时仍可落盘到正确 id。"""
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 {}
label_raw = data.get("label_id")
if not isinstance(label_raw, dict):
return {}
out: dict[int, str] = {}
for k, v in label_raw.items():
try:
i = int(k)
except (TypeError, ValueError):
continue
if v is None or (isinstance(v, str) and not str(v).strip()):
continue
out[i] = str(v).strip()
return out
@functools.lru_cache(maxsize=8)
def _cached_index_to_label_id(path_resolved: str, mtime_ns: int) -> dict[int, str]:
return load_index_to_label_id_from_yaml(Path(path_resolved))
def list_sorted_class_names_from_yaml(path: Path) -> list[str]:
"""自 ``names`` 段按类索引升序取类名字符串(与训练/权重一致)。"""
try:
@@ -218,7 +251,11 @@ def _probs_data_to_numpy1d(raw) -> np.ndarray:
def cls_top3_from_result(
cls: YOLO, r, name_to_code: dict[str, str]
cls: YOLO,
r,
name_to_code: dict[str, str],
*,
index_to_label_id: dict[int, str] | None = None,
) -> ClsTop3 | None:
pr = r[0].probs
if pr is None:
@@ -244,6 +281,7 @@ def cls_top3_from_result(
n2 = n3 = ""
c2 = c3 = 0.0
i2 = i3 = -1
if len(t5i) > 1:
i2 = int(t5i[1])
n2 = str(cls.names.get(i2, "")).strip()
@@ -253,12 +291,19 @@ def cls_top3_from_result(
n3 = str(cls.names.get(i3, "")).strip()
c3 = _conf_for_idx(i3)
def _pid(label: str) -> str:
idx_extras = index_to_label_id or {}
def _pid(label: str, class_idx: int) -> str:
lb = (label or "").strip()
if not lb:
return ""
norm = _norm_product_name(lb)
return (name_to_code.get(norm) or name_to_code.get(lb) or "").strip()
c = (name_to_code.get(norm) or name_to_code.get(lb) or "").strip()
if c:
return c
if class_idx >= 0 and class_idx in idx_extras:
return idx_extras[class_idx]
return ""
return ClsTop3(
t1_name=n1,
@@ -267,9 +312,9 @@ def cls_top3_from_result(
t2_conf=c2,
t3_name=n3,
t3_conf=c3,
t1_pid=_pid(n1),
t2_pid=_pid(n2),
t3_pid=_pid(n3),
t1_pid=_pid(n1, t1i),
t2_pid=_pid(n2, i2),
t3_pid=_pid(n3, i3),
)
@@ -475,12 +520,31 @@ class ConsumableVisionAlgorithmService:
except Exception as exc:
raise PredictionError(f"耗材分类推理失败: {exc}") from exc
snap = cls_top3_from_result(cls_model, r, name_to_code)
yp = Path(self._s.consumable_classifier_labels_yaml_path).expanduser()
if yp.is_file():
st = yp.stat()
index_to_label_id = _cached_index_to_label_id(
str(yp.resolve()), st.st_mtime_ns
)
else:
index_to_label_id = {}
snap = cls_top3_from_result(
cls_model,
r,
name_to_code,
index_to_label_id=index_to_label_id,
)
if snap is None:
return None
if snap.t1_conf < self._s.consumable_min_cls_confidence:
return None
pname = snap.t1_name
if not pname or pname not in whitelist:
if not pname:
return None
return snap
pnorm = _norm_product_name(pname)
if pnorm in whitelist or pname in whitelist:
return snap
if (snap.t1_pid or "").strip():
return snap
return None

View File

@@ -1,6 +1,6 @@
"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行(仅 item_id, item_name, qty, doctor_id, timestamp。终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。
"""每例手术一个文本文件(制表符列):`start_surgery` 时截断并写表头,每次时间窗识别**追加**一行(仅 item_id, item_name, qty, doctor_id, timestamp待确认行首列为 ``pending:{confirmation_id}``,语音落锤后**整行替换**为与客户端一致的最终真值,不再重复追加。终端 Markdown 时间戳为可读形式;落盘时间戳为 ISO 区间便于程序解析。
手术结束时再追加一节汇总行item_id, item_name, qty无其它列
手术结束时再追加一节汇总行item_id, item_name, qty无其它列;与 HTTP ``summary`` 同算法,由内存 ``details`` 经 ``build_consumption_summary`` 得到,非录制过程中按窗累计
时间戳:在拉流起点记录 `time.time()`,与 `time.monotonic()` 时间窗对齐。直播 RTSP 经 OpenCV 一般无可靠绝对时码,以本机接收时刻为准。
"""
@@ -353,25 +353,15 @@ def append_consumption_pending_window(
)
def append_consumption_voice_resolution_line(
def _build_voice_resolved_tsv_data_line(
*,
surgery_id: str,
name_to_code: dict[str, str],
chosen_label: str,
doctor_id: str,
wall_epoch: float,
tsv_enabled: bool | None = None,
) -> None:
"""语音确认后追加一行最终耗材(医生 ID 多为 voicetop2/3 空列。
待确认流程下,时间窗仅记「待确认」,此处写入医生选定后的正式记录。
"""
en = settings.consumption_tsv_log_enabled if tsv_enabled is None else tsv_enabled
if not en:
return
) -> str:
"""与客户端一致的最终行top2/3 空列timestamp 为单点)。"""
lb = (chosen_label or "").strip()
if not lb:
return
norm = _norm_product_name(lb)
p = (
name_to_code.get(norm) or name_to_code.get(lb) or ""
@@ -387,7 +377,7 @@ def append_consumption_voice_resolution_line(
t2_pid="",
t3_pid="",
)
line = build_tsv_line(
return build_tsv_line(
name_to_code=name_to_code,
best=snap,
doctor_id=doctor_id,
@@ -395,6 +385,98 @@ def append_consumption_voice_resolution_line(
wall_start_epoch=wall_epoch,
wall_end_epoch=wall_epoch,
)
def _data_line_starts_with_pending_id(line: str, confirmation_id: str) -> bool:
s = (line or "").rstrip("\r\n")
if not s or s.startswith("item_id\t"):
return False
want = f"pending:{(confirmation_id or '').strip()}"
first = s.split("\t", 1)[0]
return first == _encode_cell(want) or first == want
def replace_pending_line_with_voice_resolution(
*,
surgery_id: str,
confirmation_id: str,
name_to_code: dict[str, str],
chosen_label: str,
doctor_id: str,
wall_epoch: float,
tsv_enabled: bool | None = None,
) -> None:
"""将同一 ``confirmation_id`` 下先前追加的「待确认」行整行改为最终真值,不再多追加一行。
未找到 ``pending:{confirmation_id}`` 时回退为追加(兼容旧文件或行缺失)。读改写全程持
:data:`_lock`,避免与并发的 append 交错。
"""
en = settings.consumption_tsv_log_enabled if tsv_enabled is None else tsv_enabled
if not en:
return
if not (chosen_label or "").strip() or not (confirmation_id or "").strip():
return
new_line = _build_voice_resolved_tsv_data_line(
name_to_code=name_to_code,
chosen_label=chosen_label,
doctor_id=doctor_id,
wall_epoch=wall_epoch,
)
path = resolved_consumption_log_path(surgery_id)
with _lock:
if not path.is_file():
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as f:
f.write(HEADER)
f.write(new_line)
logger.warning(
"consumption TSV 无文件,无法替换 pending 行,已写表头+最终行: {}",
path,
)
return
text = path.read_text(encoding="utf-8")
lines = text.splitlines(keepends=True)
replaced = False
out: list[str] = []
for line in lines:
if (
not replaced
and _data_line_starts_with_pending_id(line, confirmation_id)
):
out.append(new_line)
replaced = True
else:
out.append(line)
if not replaced:
logger.warning(
"未在 TSV 中找到 pending 行,回退为追加: surgery={} confirm={}",
surgery_id,
confirmation_id,
)
with path.open("a", encoding="utf-8") as f:
f.write(new_line)
return
path.write_text("".join(out), encoding="utf-8")
def append_consumption_voice_resolution_line(
*,
surgery_id: str,
name_to_code: dict[str, str],
chosen_label: str,
doctor_id: str,
wall_epoch: float,
tsv_enabled: bool | None = None,
) -> None:
"""已弃用:请使用 ``replace_pending_line_with_voice_resolution`` 并传 ``confirmation_id``。"""
if not (chosen_label or "").strip():
return
line = _build_voice_resolved_tsv_data_line(
name_to_code=name_to_code,
chosen_label=chosen_label,
doctor_id=doctor_id,
wall_epoch=wall_epoch,
)
append_consumption_tsv_line(surgery_id, line)
@@ -467,19 +549,9 @@ class ConsumptionTsvWriter:
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
running_totals: dict[str, tuple[str, int]] | None = None,
) -> None:
if not self._s.consumption_tsv_log_enabled and not self._s.consumption_log_markdown_terminal:
return
_tsv_id, totals_key = resolve_consumption_ids(
best.t1_name, best.t1_pid, name_to_code
)
iname = (best.t1_name or "").strip()
if running_totals is not None:
if totals_key not in running_totals:
running_totals[totals_key] = (iname, 0)
prev_name, q = running_totals[totals_key]
running_totals[totals_key] = (prev_name, q + 1)
if self._s.consumption_tsv_log_enabled:
line = build_tsv_line(
name_to_code=name_to_code,
@@ -549,19 +621,9 @@ def append_consumption_window(
camera_id: str,
wall_start_epoch: float,
wall_end_epoch: float,
running_totals: dict[str, tuple[str, int]] | None = None,
) -> None:
if not settings.consumption_tsv_log_enabled and not settings.consumption_log_markdown_terminal:
return
_tsv_id, totals_key = resolve_consumption_ids(
best.t1_name, best.t1_pid, name_to_code
)
iname = (best.t1_name or "").strip()
if running_totals is not None:
if totals_key not in running_totals:
running_totals[totals_key] = (iname, 0)
prev_name, q = running_totals[totals_key]
running_totals[totals_key] = (prev_name, q + 1)
if settings.consumption_tsv_log_enabled:
line = build_tsv_line(
name_to_code=name_to_code,

View File

@@ -7,7 +7,7 @@
- 置信度 ≥ 自动阈值但 Top1 不在候选内 → 视 voice_confirmation_enabled 入 pending。
- 中等置信度 → 入 pending若有可展示候选项
需医生确认时:消耗 TSV / 内存明细记「待确认」(不写模型 top1 商品名);语音确认后再落最终耗材并更新汇总
需医生确认时:消耗 TSV / 内存明细记「待确认」(不写模型 top1 商品名);语音确认后**替换**该条 TSV 为最终耗材。停录时 TSV/终端汇总与查结果 API 同口径,由 details 经 ``build_consumption_summary`` 生成
"""
from __future__ import annotations
@@ -85,7 +85,6 @@ class VisionClassificationHandler:
camera_id=camera_id,
wall_start_epoch=ready.wall_lo,
wall_end_epoch=ready.wall_hi,
running_totals=state.consumption_log_totals,
)
async def handle(
@@ -99,7 +98,8 @@ class VisionClassificationHandler:
) -> None:
conf = cls_res.confidence
label = (cls_res.label or "").strip()
item_id = resolve_consumption_item_id(label, "", state.name_to_code)
t1_pid = (ready.best.t1_pid if ready is not None else "")
item_id = resolve_consumption_item_id(label, t1_pid, state.name_to_code)
voice_floor = self._s.video_voice_confirm_min_confidence
if conf < voice_floor:
return

View File

@@ -29,6 +29,7 @@ from app.services.video.session_registry import (
)
from app.services.video.stream_worker import CameraStreamWorker, redact_rtsp_url
from app.services.video.types import VideoBackendKind
from app.schemas import SurgeryConsumptionDetail, build_consumption_summary
from app.services.consumption_tsv_log import (
append_consumption_log_summary,
init_consumption_log_file,
@@ -214,11 +215,21 @@ class CameraSessionManager:
if isinstance(res, BaseException):
logger.warning("surgery task finished with error: {}", res)
totals = dict(run.state.consumption_log_totals)
details = list(run.state.details)
detail_rows = [
SurgeryConsumptionDetail(
item_id=r.item_id,
item_name=r.item_name,
qty=r.qty,
doctor_id=r.doctor_id,
timestamp=r.timestamp,
)
for r in details
]
summ = build_consumption_summary(detail_rows)
totals = {s.item_id: (s.item_name, s.total_quantity) for s in summ}
append_consumption_log_summary(surgery_id, totals)
print_consumption_summary_markdown(totals)
details = list(run.state.details)
await self._archive.persist_or_archive(surgery_id, details)
# ------------------------------------------------------------------

View File

@@ -22,8 +22,7 @@ from app.services.consumable_vision_algorithm import (
_norm_product_name,
)
from app.services.consumption_tsv_log import (
append_consumption_voice_resolution_line,
resolve_consumption_ids,
replace_pending_line_with_voice_resolution,
resolve_consumption_item_id,
)
from app.services.voice_confirm import build_prompt_text
@@ -74,8 +73,6 @@ class SurgerySessionState:
last_asr_text: str | None = None
#: 最近一次语音确认错误说明ASR/解析失败等)。
last_voice_error: str | None = None
#: 视觉时间窗落盘用量累计供停录时写汇总item_id -> 首次名称, 次数)。
consumption_log_totals: dict[str, tuple[str, int]] = field(default_factory=dict)
@dataclass
@@ -281,6 +278,7 @@ class SurgerySessionRegistry:
self._finalize_voice_confirmed_consumption_log(
state=st,
surgery_id=surgery_id,
confirmation_id=confirmation_id,
chosen_label=label,
)
try:
@@ -295,20 +293,16 @@ class SurgerySessionRegistry:
*,
state: SurgerySessionState,
surgery_id: str,
confirmation_id: str,
chosen_label: str,
) -> None:
"""待确认流程在语音落锤后:汇总 +1 最终耗材,并追加 TSV 正式行"""
"""待确认流程在语音落锤后:将 TSV 中原 pending 行替换为最终真值。停录汇总与 HTTP 一致,由 details 经 ``build_consumption_summary`` 得到"""
cl = (chosen_label or "").strip()
if not cl:
return
_, key_chosen = resolve_consumption_ids(cl, "", state.name_to_code)
tot = state.consumption_log_totals
if key_chosen not in tot:
tot[key_chosen] = (cl, 0)
nm, q = tot[key_chosen]
tot[key_chosen] = (nm, q + 1)
append_consumption_voice_resolution_line(
replace_pending_line_with_voice_resolution(
surgery_id=surgery_id,
confirmation_id=confirmation_id,
name_to_code=state.name_to_code,
chosen_label=cl,
doctor_id=self._s.video_voice_confirm_doctor_id,