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

Made-with: Cursor
2026-04-23 20:42:21 +08:00

212 lines
6.9 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""视觉分类结果处理:把 ``PredictionResult`` 转成自动记账 or 待人工确认。
从 ``CameraSessionManager`` 抽出,保持原先行为:
- 置信度低于 ``video_voice_confirm_min_confidence`` → 丢弃。
- 会话状态中候选清单为空 → 丢弃(开录时通常会由空请求解析为全量目录/模型类名)。
- 置信度 ≥ ``video_auto_confirm_confidence`` 且 Top1 在候选内 → 自动追加 vision 明细,并写消耗 TSV记具体耗材
- 置信度 ≥ 自动阈值但 Top1 不在候选内 → 视 voice_confirmation_enabled 入 pending。
- 中等置信度 → 入 pending若有可展示候选项
需医生确认时:消耗 TSV / 内存明细记「待确认」(不写模型 top1 商品名);语音确认后再落最终耗材并更新汇总。
"""
from __future__ import annotations
from loguru import logger
from app.config import Settings
from app.services.consumable_vision_algorithm import (
PredictionCandidate,
PredictionResult,
)
from app.services.consumption_tsv_log import (
append_consumption_pending_window,
append_consumption_window,
resolve_consumption_item_id,
)
from app.services.video.inference_aggregator import WindowInferenceReady
from app.services.video.session_registry import (
SurgerySessionRegistry,
SurgerySessionState,
)
def rank_topk_for_candidates(
topk: list[PredictionCandidate],
ordered_candidates: list[str],
*,
limit: int = 5,
) -> list[PredictionCandidate]:
if not topk:
return []
stripped_order = [c.strip() for c in ordered_candidates if c.strip()]
if not stripped_order:
return topk[:limit]
order_index = {name: i for i, name in enumerate(stripped_order)}
picked = [c for c in topk if c.label.strip() in order_index]
picked.sort(key=lambda c: order_index[c.label.strip()])
return picked[:limit]
class VisionClassificationHandler:
"""把分类结果转化为 registry 上的状态变更(追加明细 / 入队待确认)。"""
def __init__(
self,
*,
settings: Settings,
registry: SurgerySessionRegistry,
) -> None:
self._s = settings
self._registry = registry
def _append_vision_consumption_window_if_ready(
self,
state: SurgerySessionState,
ready: WindowInferenceReady | None,
surgery_id: str,
camera_id: str,
) -> None:
if (
ready is None
or not surgery_id
or not camera_id
or (
not self._s.consumption_tsv_log_enabled
and not self._s.consumption_log_markdown_terminal
)
):
return
append_consumption_window(
surgery_id=surgery_id,
name_to_code=state.name_to_code,
best=ready.best,
doctor_id=self._s.video_result_doctor_id,
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(
self,
*,
state: SurgerySessionState,
cls_res: PredictionResult,
ready: WindowInferenceReady | None = None,
surgery_id: str = "",
camera_id: str = "",
) -> None:
conf = cls_res.confidence
label = (cls_res.label or "").strip()
item_id = resolve_consumption_item_id(label, "", state.name_to_code)
voice_floor = self._s.video_voice_confirm_min_confidence
if conf < voice_floor:
return
cand_order = [c.strip() for c in state.candidate_consumables if c.strip()]
if not cand_order:
return
cand_set = set(cand_order)
ranked = rank_topk_for_candidates(cls_res.topk, cand_order)
auto_th = self._s.video_auto_confirm_confidence
def in_allowed(name: str) -> bool:
return name in cand_set
if conf >= auto_th and in_allowed(label):
self._append_vision_consumption_window_if_ready(
state, ready, surgery_id, camera_id
)
await self._registry.append_confirmed_detail(
state=state,
item_id=item_id or "unknown",
item_name=label or "unknown",
doctor_id=self._s.video_result_doctor_id,
source="vision",
)
return
if conf >= auto_th and not in_allowed(label):
if ranked and self._s.voice_confirmation_enabled:
await self._enqueue(
state,
ranked,
label,
conf,
ready=ready,
surgery_id=surgery_id,
camera_id=camera_id,
)
return
if not self._s.voice_confirmation_enabled:
return
if ranked:
await self._enqueue(
state,
ranked,
label,
conf,
ready=ready,
surgery_id=surgery_id,
camera_id=camera_id,
)
elif in_allowed(label):
await self._enqueue(
state,
[PredictionCandidate(label=label, confidence=conf)],
label,
conf,
ready=ready,
surgery_id=surgery_id,
camera_id=camera_id,
)
async def _enqueue(
self,
state: SurgerySessionState,
ranked: list[PredictionCandidate],
top_key: str,
top_confidence: float,
*,
ready: WindowInferenceReady | None = None,
surgery_id: str = "",
camera_id: str = "",
) -> None:
cid = await self._registry.enqueue_pending_confirmation(
state,
ranked,
top_key=top_key,
top_confidence=top_confidence,
)
if cid is None:
return
logger.info(
"Enqueued pending consumable confirmation id={} top_key={}",
cid,
top_key,
)
if ready is not None and surgery_id and camera_id and (
self._s.consumption_tsv_log_enabled
or self._s.consumption_log_markdown_terminal
):
append_consumption_pending_window(
surgery_id=surgery_id,
confirmation_id=cid,
model_snap=ready.best,
doctor_id=self._s.video_result_doctor_id,
camera_id=camera_id,
wall_start_epoch=ready.wall_lo,
wall_end_epoch=ready.wall_hi,
tsv_enabled=self._s.consumption_tsv_log_enabled,
markdown_terminal=self._s.consumption_log_markdown_terminal,
)
await self._registry.append_pending_consumption_detail(
state=state,
confirmation_id=cid,
doctor_id=self._s.video_result_doctor_id,
)