From 132702aea9f6663c72cc850535c4f98773bdd7ca Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 22 Apr 2026 16:31:12 +0800 Subject: [PATCH] =?UTF-8?q?refactor:=20=E7=BB=9F=E4=B8=80=E8=80=97?= =?UTF-8?q?=E6=9D=90=E8=A7=86=E8=A7=89=E7=AE=97=E6=B3=95=E5=B9=B6=E6=89=A9?= =?UTF-8?q?=E5=B1=95=E8=AF=AD=E9=9F=B3=E7=A1=AE=E8=AE=A4=E8=87=B3=E5=85=A8?= =?UTF-8?q?=E9=87=8F=E5=80=99=E9=80=89=E6=B8=85=E5=8D=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 以 ConsumableVisionAlgorithmService 替代 consumable_classifier 与 tear_action; 可选手部检测权重,未配置时全帧分类;时间窗众数与 Excel 白名单配置。 - 语音待确认:ASR 先匹配 pending topk,再匹配本台 candidate_consumables; 记账 item_id 与 vision 一致使用 name_to_code。 - 更新 config、Compose、.env.example、依赖(pandas/openpyxl)与测试。 Made-with: Cursor --- .env.example | 24 +- app/config.py | 29 +- app/dependencies.py | 17 +- app/services/consumable_classifier.py | 197 ---------- app/services/consumable_vision_algorithm.py | 392 ++++++++++++++++++++ app/services/tear_action.py | 155 -------- app/services/video/session_manager.py | 104 ++++-- app/services/voice_confirm.py | 24 +- app/services/voice_resolution.py | 18 +- docker-compose.dev.yml | 9 +- docker-compose.prod.yml | 9 +- pyproject.toml | 2 + tests/test_session_manager_unit.py | 111 +++--- tests/test_session_rank.py | 2 +- tests/test_surgery_pipeline_persistence.py | 9 +- tests/test_voice_confirm.py | 26 +- tests/test_voice_resolution_service.py | 61 ++- uv.lock | 78 ++++ 18 files changed, 791 insertions(+), 476 deletions(-) delete mode 100644 app/services/consumable_classifier.py create mode 100644 app/services/consumable_vision_algorithm.py delete mode 100644 app/services/tear_action.py diff --git a/.env.example b/.env.example index cc6eda9..578e730 100644 --- a/.env.example +++ b/.env.example @@ -11,16 +11,24 @@ POSTGRES_PORT=35432 # Optional: full async SQLAlchemy URL (overrides POSTGRES_* when set and matches defaults logic — see Settings). # DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:35432/operation_room -# --- YOLO inference (internal, not exposed as HTTP) --- -# Weights default to bundled paths under app/resources/ if unset. +# --- YOLO 视觉推理(内部调用,无独立 HTTP)--- +# 耗材分类权重默认 app/resources/consumable_classifier.pt;手部检测为空时退化为全帧分类。 # CONSUMABLE_CLASSIFIER_WEIGHTS=/absolute/path/to/consumable_classifier.pt CONSUMABLE_CLASSIFIER_IMGSZ=224 CONSUMABLE_CLASSIFIER_DEVICE= +# 视觉待确认 options 最多为模型 top3 与 candidate_consumables 的交集(非本变量)。 CONSUMABLE_CLASSIFIER_TOPK=5 -# TEAR_ACTION_WEIGHTS=/absolute/path/to/tear_action.pt -TEAR_ACTION_IMGSZ=224 -TEAR_ACTION_DEVICE= -TEAR_ACTION_TOPK=5 +# CONSUMABLE_MIN_CLS_CONFIDENCE=0.5 +# 时间窗(秒):窗内多次推理取众数后再走自动记账 / 待确认。 +# CONSUMABLE_VISION_WINDOW_SEC=15 +# 可选:Excel「商品名称」「产品编码」表;空则物品 id 用名称。 +# CONSUMABLE_CATALOG_XLSX_PATH=/path/to/视频中的商品信息表.xlsx +# HAND_DETECTION_WEIGHTS=/absolute/path/to/hand_detect.pt +# HAND_DETECTION_IMGSZ=640 +# HAND_DETECTION_CONF=0.25 +# HAND_DETECTION_PAD_RATIO=0.30 +# HAND_DETECTION_MIN_CROP_PX=64 +# HAND_DETECTION_DEVICE= # Device: empty → auto (macOS MPS if available; Linux CUDA if available). Docker image uses CPU torch unless you change it. # --- Surgery recording API retries --- @@ -54,10 +62,12 @@ TEAR_ACTION_TOPK=5 # VIDEO_RECONNECT_BACKOFF_SECONDS=1.0 # VIDEO_INFERENCE_INTERVAL_SEC=2 # VIDEO_INFERENCE_CONFIDENCE_THRESHOLD=0.35 -# 置信度 >= 此值且命中候选清单时自动记账(vision)。 +# 置信度 >= 此值且命中候选清单时自动 vision 记账。提高到 0.9 可减少自动记账、更多走待确认。 # VIDEO_AUTO_CONFIRM_CONFIDENCE=0.55 # 置信度处于 [VIDEO_VOICE_CONFIRM_MIN_CONFIDENCE, VIDEO_AUTO_CONFIRM_CONFIDENCE) 时入队待确认(客户端拉取 pending-confirmation)。 # VIDEO_VOICE_CONFIRM_MIN_CONFIDENCE=0.35 +# 待确认话术由服务端生成(prompt_text),TTS 一般在客户端播放;医生 WAV 上传后服务端 ASR 解析。 +# 解析顺序:① pending 里展示的 topk(序号/名称);② 仍不匹配时,对「开始手术」请求体中的 candidate_consumables 全文做名称子串匹配——医生报清单内其它耗材也以医生为准入账。 # 是否启用低置信度人工确认(客户端播报 + resolve 回传;服务端无麦克风/扬声器要求)。 # VOICE_CONFIRMATION_ENABLED=true # VIDEO_VOICE_CONFIRM_DOCTOR_ID=voice diff --git a/app/config.py b/app/config.py index ab9e2e1..1c97ce2 100644 --- a/app/config.py +++ b/app/config.py @@ -14,11 +14,6 @@ def _default_consumable_classifier_weights() -> str: return str(_PACKAGE_DIR / "resources" / "consumable_classifier.pt") -def _default_tear_action_weights() -> str: - """撕扯耗材动作识别:`app/resources/tear_action.pt`。""" - return str(_PACKAGE_DIR / "resources" / "tear_action.pt") - - def _default_camera_rtsp_urls_sample_path() -> str: """示例映射路径(可复制为自有 `camera_rtsp_urls.json` 后在环境变量中引用)。""" return str(_PACKAGE_DIR / "resources" / "camera_rtsp_urls.sample.json") @@ -38,10 +33,19 @@ class Settings(BaseSettings): #: Explicit Ultralytics device (e.g. cpu, mps, cuda:0). Empty -> macOS prefers MPS; Linux prefers CUDA if available. consumable_classifier_device: str = "" consumable_classifier_topk: int = 5 - tear_action_weights: str | None = None - tear_action_imgsz: int = 224 - tear_action_device: str = "" - tear_action_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 = "" + #: 与离线脚本一致的时间窗(秒);窗内多次推理取众数后再走自动记账 / 语音追问逻辑。 + consumable_vision_window_sec: float = Field(default=15.0, ge=0.5, le=600.0) + #: 手部检测 YOLO 权重;空或文件不存在时退化为「全帧送分类器」(兼容仅有关分类权重的环境)。 + hand_detection_weights: str = "" + hand_detection_imgsz: int = Field(default=640, ge=32, le=4096) + hand_detection_conf: float = Field(default=0.25, ge=0.0, le=1.0) + hand_detection_pad_ratio: float = Field(default=0.30, ge=0.0, le=2.0) + hand_detection_min_crop_px: int = Field(default=64, ge=8, le=4096) + hand_detection_device: str = "" #: 开始/结束手术时调用录制流水线的最大尝试次数(含首次)。 surgery_recording_max_attempts: int = Field(default=3, ge=1, le=20) #: 两次尝试之间的等待秒数。 @@ -138,13 +142,6 @@ class Settings(BaseSettings): return _default_consumable_classifier_weights() return str(value) - @field_validator("tear_action_weights", mode="before") - @classmethod - def tear_action_weights_default(cls, value: object) -> str: - if value is None or value == "": - return _default_tear_action_weights() - return str(value) - model_config = SettingsConfigDict( env_file=".env", env_file_encoding="utf-8", diff --git a/app/dependencies.py b/app/dependencies.py index 6df4520..513a069 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -3,17 +3,15 @@ from loguru import logger from app.config import settings from app.repositories.surgery_results import SurgeryResultRepository from app.repositories.voice_audits import VoiceAuditRepository -from app.services.consumable_classifier import ConsumableClassifierService from app.services.baidu_speech import BaiduSpeechService +from app.services.consumable_vision_algorithm import ConsumableVisionAlgorithmService from app.services.minio_audio_storage import MinioAudioStorageService from app.services.surgery_pipeline import SurgeryPipeline from app.services.voice_resolution import VoiceConfirmationService -from app.services.tear_action import TearActionService from app.services.video.hikvision_runtime import HikvisionRuntime from app.services.video.session_manager import CameraSessionManager -consumable_classifier_service = ConsumableClassifierService() -tear_action_service = TearActionService() +consumable_vision_algorithm_service = ConsumableVisionAlgorithmService() hikvision_runtime = HikvisionRuntime.try_load(settings.hikvision_lib_dir) if settings.hikvision_sdk_enabled and hikvision_runtime is None: @@ -29,8 +27,7 @@ minio_audio_storage_service = MinioAudioStorageService(settings) camera_session_manager = CameraSessionManager( settings=settings, - consumable_classifier=consumable_classifier_service, - tear_action=tear_action_service, + vision_algorithm=consumable_vision_algorithm_service, hikvision_runtime=hikvision_runtime, result_repository=surgery_result_repository, ) @@ -49,12 +46,8 @@ surgery_pipeline = SurgeryPipeline( ) -def get_consumable_classifier_service() -> ConsumableClassifierService: - return consumable_classifier_service - - -def get_tear_action_service() -> TearActionService: - return tear_action_service +def get_consumable_vision_algorithm_service() -> ConsumableVisionAlgorithmService: + return consumable_vision_algorithm_service def get_surgery_pipeline() -> SurgeryPipeline: diff --git a/app/services/consumable_classifier.py b/app/services/consumable_classifier.py deleted file mode 100644 index d49431d..0000000 --- a/app/services/consumable_classifier.py +++ /dev/null @@ -1,197 +0,0 @@ -from __future__ import annotations - -from dataclasses import dataclass -from io import BytesIO -import os -import sys -from pathlib import Path -from threading import Lock - -import numpy as np -from fastapi.concurrency import run_in_threadpool -from loguru import logger -from PIL import Image, UnidentifiedImageError - -os.environ["YOLO_CONFIG_DIR"] = "/tmp" - -from ultralytics import YOLO - -from app.config import settings - - -def resolve_classifier_inference_device(explicit: str) -> str | None: - """Ultralytics `device` string. If unset: macOS prefers MPS; Linux/Windows prefer CUDA when available.""" - configured = (explicit or "").strip() - if configured: - return configured - try: - import torch - except Exception: - return None - if sys.platform == "darwin": - if torch.backends.mps.is_available(): - return "mps" - return None - if torch.cuda.is_available(): - return "cuda:0" - return None - - -@dataclass(frozen=True) -class PredictionCandidate: - label: str - confidence: float - - -@dataclass(frozen=True) -class PredictionResult: - label: str - confidence: float - topk: list[PredictionCandidate] - - -class ModelNotConfiguredError(RuntimeError): - """Raised when the model weights are not configured or missing.""" - - -class InvalidImageError(ValueError): - """Raised when uploaded bytes cannot be decoded as an image.""" - - -class PredictionError(RuntimeError): - """Raised when the model cannot produce a prediction.""" - - -class ConsumableClassifierService: - """耗材识别与分类(YOLO-cls):判断画面中的耗材类别;与撕扯动作模型 `TearActionService` 分离。内部流水线调用,不对外 HTTP。""" - - def __init__(self) -> None: - self._model: YOLO | None = None - self._model_lock = Lock() - - @property - def weights_path(self) -> Path | None: - if not settings.consumable_classifier_weights: - return None - return Path(settings.consumable_classifier_weights).expanduser() - - @property - def configured(self) -> bool: - return self.weights_path is not None - - @property - def weights_found(self) -> bool: - path = self.weights_path - return path is not None and path.is_file() - - @property - def model_loaded(self) -> bool: - return self._model is not None - - async def predict_image_bytes( - self, - payload: bytes, - *, - topk: int | None = None, - ) -> PredictionResult: - return await run_in_threadpool(self._predict_image_bytes, payload, topk) - - def _predict_image_bytes( - self, - payload: bytes, - topk: int | None, - ) -> PredictionResult: - model = self._get_model() - image = self._decode_image(payload) - - try: - result = model.predict( - image, - imgsz=settings.consumable_classifier_imgsz, - device=resolve_classifier_inference_device( - settings.consumable_classifier_device - ), - verbose=False, - )[0] - except Exception as exc: # pragma: no cover - ultralytics runtime errors vary. - raise PredictionError( - f"Failed to run consumable classifier inference: {exc}" - ) from exc - - return self._build_prediction_result(result, model, topk=topk) - - def _get_model(self) -> YOLO: - path = self.weights_path - if path is None: - raise ModelNotConfiguredError( - "Consumable classifier weights are not configured. " - "Set CONSUMABLE_CLASSIFIER_WEIGHTS." - ) - - path = path.resolve() - if not path.is_file(): - raise ModelNotConfiguredError( - f"Consumable classifier weights not found: {path}" - ) - - if self._model is None: - with self._model_lock: - if self._model is None: - logger.info("Loading consumable classifier weights from {}", path) - self._model = YOLO(str(path)) - - return self._model - - def _decode_image(self, payload: bytes) -> np.ndarray: - if not payload: - raise InvalidImageError("Uploaded image is empty.") - - try: - with Image.open(BytesIO(payload)) as image: - return np.asarray(image.convert("RGB")) - except (UnidentifiedImageError, OSError) as exc: - raise InvalidImageError("Uploaded file is not a valid image.") from exc - - def _build_prediction_result( - self, - result: object, - model: YOLO, - *, - topk: int | None, - ) -> PredictionResult: - probs = getattr(result, "probs", None) - data = getattr(probs, "data", None) - if probs is None or data is None: - raise PredictionError("Model did not return classification probabilities.") - - scores = data.tolist() - if not isinstance(scores, list): - scores = [float(scores)] - - names = self._names(model) - limit = max(1, topk or settings.consumable_classifier_topk) - ranked = sorted( - ((index, float(score)) for index, score in enumerate(scores)), - key=lambda item: item[1], - reverse=True, - )[:limit] - - if not ranked: - raise PredictionError("Model returned an empty prediction result.") - - candidates = [ - PredictionCandidate( - label=names.get(index, str(index)), - confidence=confidence, - ) - for index, confidence in ranked - ] - return PredictionResult( - label=candidates[0].label, - confidence=candidates[0].confidence, - topk=candidates, - ) - - def _names(self, model: YOLO) -> dict[int, str]: - raw = getattr(model.model, "names", None) or {} - return {int(key): str(value) for key, value in raw.items()} diff --git a/app/services/consumable_vision_algorithm.py b/app/services/consumable_vision_algorithm.py new file mode 100644 index 0000000..5dfc7af --- /dev/null +++ b/app/services/consumable_vision_algorithm.py @@ -0,0 +1,392 @@ +"""手术室耗材视觉算法:可选手部检测 ROI + YOLO-cls(原离线双机位流水线核心逻辑)。 + +作为 FastAPI 内唯一的视频推理入口;撕扯动作分类已移除,由手部检测 + 耗材分类替代。 +""" + +from __future__ import annotations + +import os +import sys +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from threading import Lock + +import numpy as np +import pandas as pd +from loguru import logger +from ultralytics import YOLO + +from app.config import Settings, settings + +os.environ["YOLO_CONFIG_DIR"] = "/tmp" + + +def resolve_inference_device(explicit: str) -> str | None: + """Ultralytics `device`;空则 macOS 优先 MPS,Linux/Windows 优先 CUDA。""" + configured = (explicit or "").strip() + if configured: + return configured + try: + import torch + except Exception: + return None + if sys.platform == "darwin": + if torch.backends.mps.is_available(): + return "mps" + return None + if torch.cuda.is_available(): + return "cuda:0" + return None + + +@dataclass(frozen=True) +class PredictionCandidate: + label: str + confidence: float + + +@dataclass(frozen=True) +class PredictionResult: + label: str + confidence: float + topk: list[PredictionCandidate] + + +class ModelNotConfiguredError(RuntimeError): + """权重未配置或文件不存在。""" + + +class PredictionError(RuntimeError): + """推理失败。""" + + +@dataclass +class ClsTop3: + t1_name: str + t1_conf: float + t2_name: str + t2_conf: float + t3_name: str + t3_conf: float + t1_pid: str + t2_pid: str + t3_pid: str + + +def _find_col(df: pd.DataFrame, want: str) -> str | None: + want = want.strip() + for c in df.columns: + if str(c).strip() == want: + return str(c) + return None + + +def _norm_product_name(name: str) -> str: + s = (name or "").strip() + if s == "一次性医用垫单": + return "一次性使用手术单(一次性医用垫单)" + return s + + +def load_name_to_product_code(xlsx: Path) -> dict[str, str]: + """商品名称 -> 产品编码(白名单键为归一化后的名称)。""" + df = pd.read_excel(xlsx, sheet_name=0) + c_code = _find_col(df, "产品编码") + c_name = _find_col(df, "商品名称") + if c_code is None or c_name is None: + raise ValueError("Excel 缺少「产品编码」或「商品名称」列") + m: dict[str, str] = {} + dups: set[str] = set() + for _, row in df.iterrows(): + raw = row.get(c_name) + if raw is None or (isinstance(raw, float) and pd.isna(raw)): + continue + n = _norm_product_name(str(raw).strip()) + if not n: + continue + code = row.get(c_code) + if code is None or (isinstance(code, float) and pd.isna(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 + if dups: + logger.warning( + "Excel 中以下商品名称对应多组产品编码,已保留首次映射: {}", + ";".join(sorted(dups)[:12]) + (" …" if len(dups) > 12 else ""), + ) + return m + + +def collect_hand_boxes(model: YOLO, boxes) -> list[tuple[float, float, float, float]]: + if boxes is None or len(boxes) == 0: + return [] + xyxy = boxes.xyxy.cpu().numpy() + cls_ids = boxes.cls.cpu().numpy().astype(int) + names = model.names + out: list[tuple[float, float, float, float]] = [] + for i, c in enumerate(cls_ids): + label = str(names.get(int(c), "")).strip().lower() + if "hand" in label or label in {"手", "手部"}: + out.append(tuple(float(x) for x in xyxy[i])) + if not out and len(xyxy) > 0: + # 单类检测模型:无 hand 字样时保留全部框 + for row in xyxy: + out.append(tuple(float(x) for x in row)) + return out + + +def union_boxes( + boxes: list[tuple[float, float, float, float]], +) -> tuple[float, float, float, float]: + xs1, ys1, xs2, ys2 = zip(*boxes, strict=True) + return min(xs1), min(ys1), max(xs2), max(ys2) + + +def pad_box( + box: tuple[float, float, float, float], + w: int, + h: int, + pad_ratio: float, +) -> tuple[int, int, int, int]: + x1, y1, x2, y2 = box + bw, bh = x2 - x1, y2 - y1 + pad_w, pad_h = bw * pad_ratio, bh * pad_ratio + nx1 = int(max(0, x1 - pad_w)) + ny1 = int(max(0, y1 - pad_h)) + nx2 = int(min(w, x2 + pad_w)) + ny2 = int(min(h, y2 + pad_h)) + return nx1, ny1, nx2, ny2 + + +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: + return None + t5i = list(pr.top5) + tc = pr.top5conf + if tc is None: + return None + + def _ci(i: int) -> float: + if i < 0 or i >= len(tc): + return 0.0 + try: + v = tc[i] + 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 + ) + 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) + if len(t5i) > 2: + n3 = str(cls.names.get(int(t5i[2]), "")).strip() + c3 = _ci(2) + + return ClsTop3( + t1_name=n1, + t1_conf=c1, + t2_name=n2, + t2_conf=c2, + t3_name=n3, + t3_conf=c3, + t1_pid=name_to_code.get(n1, ""), + t2_pid=name_to_code.get(n2, ""), + t3_pid=name_to_code.get(n3, ""), + ) + + +def cls_top3_to_prediction_result(snap: ClsTop3) -> PredictionResult: + topk: list[PredictionCandidate] = [] + if snap.t1_name: + topk.append(PredictionCandidate(snap.t1_name, snap.t1_conf)) + if snap.t2_name: + topk.append(PredictionCandidate(snap.t2_name, snap.t2_conf)) + if snap.t3_name: + topk.append(PredictionCandidate(snap.t3_name, snap.t3_conf)) + if not topk: + topk = [PredictionCandidate("", 0.0)] + return PredictionResult( + label=snap.t1_name, + confidence=snap.t1_conf, + topk=topk, + ) + + +def _mode_lex(names: list[str]) -> str | None: + if not names: + return None + c = Counter(names) + best = max(c.values()) + pool = [n for n, k in c.items() if k == best] + return min(pool) + + +def window_bucket_to_best_snap( + bucket_pts: list[tuple[str, ClsTop3]], +) -> ClsTop3 | None: + """单个时间窗内:众数类名 + 该类下 top1 置信度最大的快照。""" + pick = _mode_lex([a for a, _ in bucket_pts]) + if pick is None: + return None + best: ClsTop3 | None = None + for pname, sn in bucket_pts: + if pname == pick and (best is None or sn.t1_conf > best.t1_conf): + best = sn + return best + + +class ConsumableVisionAlgorithmService: + """手部检测(可选)+ 耗材分类;供 CameraSessionManager 在视频线程中调用。""" + + def __init__(self, app_settings: Settings | None = None) -> None: + 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 build_name_mapping( + self, candidate_consumables: list[str] + ) -> dict[str, str]: + """分类标签 -> 业务物品 id(Excel 产品编码;无表时用名称自身)。""" + 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} + + def _det_weights(self) -> Path | None: + raw = (self._s.hand_detection_weights or "").strip() + if not raw: + return None + p = Path(raw).expanduser() + return p if p.is_file() else None + + def _cls_weights(self) -> Path: + raw = (self._s.consumable_classifier_weights or "").strip() + if not raw: + raise ModelNotConfiguredError( + "未配置耗材分类权重。请设置 CONSUMABLE_CLASSIFIER_WEIGHTS。" + ) + p = Path(raw).expanduser().resolve() + if not p.is_file(): + raise ModelNotConfiguredError(f"耗材分类权重不存在: {p}") + return p + + def _get_det(self) -> YOLO | None: + path = self._det_weights() + if path is None: + return None + if self._det is None: + with self._det_lock: + if self._det is None: + logger.info("加载手部检测权重: {}", path) + self._det = YOLO(str(path)) + return self._det + + def _get_cls(self) -> YOLO: + if self._cls is None: + with self._cls_lock: + if self._cls is None: + path = self._cls_weights() + logger.info("加载耗材分类权重: {}", path) + self._cls = YOLO(str(path)) + return self._cls + + def hand_crop( + self, + frame: np.ndarray, + det_model: YOLO, + *, + det_conf: float, + pad_ratio: float, + min_crop_px: int, + imgsz_det: int, + ) -> np.ndarray | None: + h, w = frame.shape[:2] + device = resolve_inference_device(self._s.hand_detection_device) + results = det_model.predict( + frame, + conf=det_conf, + imgsz=imgsz_det, + device=device, + verbose=False, + ) + hand_xyxys = collect_hand_boxes(det_model, results[0].boxes) + if not hand_xyxys: + return None + merged = union_boxes(hand_xyxys) + cx1, cy1, cx2, cy2 = pad_box(merged, w, h, pad_ratio) + if (cx2 - cx1) < min_crop_px or (cy2 - cy1) < min_crop_px: + return None + return frame[cy1:cy2, cx1:cx2] + + def infer_frame_bgr( + self, + frame: np.ndarray, + name_to_code: dict[str, str], + ) -> ClsTop3 | None: + """单帧 BGR;仅当 top1 通过置信度且落在白名单(name_to_code 键)时返回。""" + whitelist = set(name_to_code.keys()) + det_model = self._get_det() + cls_model = self._get_cls() + + if det_model is not None: + crop = self.hand_crop( + frame, + det_model, + det_conf=self._s.hand_detection_conf, + pad_ratio=self._s.hand_detection_pad_ratio, + min_crop_px=self._s.hand_detection_min_crop_px, + imgsz_det=self._s.hand_detection_imgsz, + ) + if crop is None: + return None + else: + crop = frame + + device = resolve_inference_device(self._s.consumable_classifier_device) + try: + r = cls_model.predict( + crop, + imgsz=self._s.consumable_classifier_imgsz, + device=device, + verbose=False, + ) + except Exception as exc: + raise PredictionError(f"耗材分类推理失败: {exc}") from exc + + snap = cls_top3_from_result(cls_model, r, name_to_code) + 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: + return None + return snap diff --git a/app/services/tear_action.py b/app/services/tear_action.py deleted file mode 100644 index 3fd2a77..0000000 --- a/app/services/tear_action.py +++ /dev/null @@ -1,155 +0,0 @@ -from __future__ import annotations - -from io import BytesIO -import os -from pathlib import Path -from threading import Lock - -import numpy as np -from fastapi.concurrency import run_in_threadpool -from loguru import logger -from PIL import Image, UnidentifiedImageError - -os.environ["YOLO_CONFIG_DIR"] = "/tmp" - -from ultralytics import YOLO - -from app.config import settings -from app.services.consumable_classifier import ( - InvalidImageError, - ModelNotConfiguredError, - PredictionCandidate, - PredictionError, - PredictionResult, - resolve_classifier_inference_device, -) - - -class TearActionService: - """撕扯耗材动作识别(独立权重):判断是否存在/如何撕扯耗材等行为;与耗材分类 `ConsumableClassifierService` 分离。内部流水线调用,不对外 HTTP。""" - - def __init__(self) -> None: - self._model: YOLO | None = None - self._model_lock = Lock() - - @property - def weights_path(self) -> Path | None: - if not settings.tear_action_weights: - return None - return Path(settings.tear_action_weights).expanduser() - - @property - def configured(self) -> bool: - return self.weights_path is not None - - @property - def weights_found(self) -> bool: - path = self.weights_path - return path is not None and path.is_file() - - @property - def model_loaded(self) -> bool: - return self._model is not None - - async def predict_image_bytes( - self, - payload: bytes, - *, - topk: int | None = None, - ) -> PredictionResult: - return await run_in_threadpool(self._predict_image_bytes, payload, topk) - - def _predict_image_bytes( - self, - payload: bytes, - topk: int | None, - ) -> PredictionResult: - model = self._get_model() - image = self._decode_image(payload) - - try: - result = model.predict( - image, - imgsz=settings.tear_action_imgsz, - device=resolve_classifier_inference_device(settings.tear_action_device), - verbose=False, - )[0] - except Exception as exc: # pragma: no cover - raise PredictionError( - f"Failed to run tear-action inference: {exc}" - ) from exc - - return self._build_prediction_result(result, model, topk=topk) - - def _get_model(self) -> YOLO: - path = self.weights_path - if path is None: - raise ModelNotConfiguredError( - "Tear-action weights are not configured. Set TEAR_ACTION_WEIGHTS." - ) - - path = path.resolve() - if not path.is_file(): - raise ModelNotConfiguredError(f"Tear-action weights not found: {path}") - - if self._model is None: - with self._model_lock: - if self._model is None: - logger.info("Loading tear-action weights from {}", path) - self._model = YOLO(str(path)) - - return self._model - - def _decode_image(self, payload: bytes) -> np.ndarray: - if not payload: - raise InvalidImageError("Uploaded image is empty.") - - try: - with Image.open(BytesIO(payload)) as image: - return np.asarray(image.convert("RGB")) - except (UnidentifiedImageError, OSError) as exc: - raise InvalidImageError("Uploaded file is not a valid image.") from exc - - def _build_prediction_result( - self, - result: object, - model: YOLO, - *, - topk: int | None, - ) -> PredictionResult: - probs = getattr(result, "probs", None) - data = getattr(probs, "data", None) - if probs is None or data is None: - raise PredictionError("Model did not return classification probabilities.") - - scores = data.tolist() - if not isinstance(scores, list): - scores = [float(scores)] - - names = self._names(model) - limit = max(1, topk or settings.tear_action_topk) - ranked = sorted( - ((index, float(score)) for index, score in enumerate(scores)), - key=lambda item: item[1], - reverse=True, - )[:limit] - - if not ranked: - raise PredictionError("Model returned an empty prediction result.") - - candidates = [ - PredictionCandidate( - label=names.get(index, str(index)), - confidence=confidence, - ) - for index, confidence in ranked - ] - return PredictionResult( - label=candidates[0].label, - confidence=candidates[0].confidence, - topk=candidates, - ) - - def _names(self, model: YOLO) -> dict[int, str]: - raw = getattr(model.model, "names", None) or {} - return {int(key): str(value) for key, value in raw.items()} diff --git a/app/services/video/session_manager.py b/app/services/video/session_manager.py index 2fb42b7..1359c14 100644 --- a/app/services/video/session_manager.py +++ b/app/services/video/session_manager.py @@ -13,14 +13,16 @@ from app.config import Settings from app.database import AsyncSessionLocal from app.repositories.surgery_results import SurgeryResultRepository from app.schemas import SurgeryConsumptionDetail -from app.services.consumable_classifier import ( - ConsumableClassifierService, +from app.services.consumable_vision_algorithm import ( + ClsTop3, + ConsumableVisionAlgorithmService, PredictionCandidate, PredictionResult, + _norm_product_name, + cls_top3_to_prediction_result, + window_bucket_to_best_snap, ) -from app.services.tear_action import TearActionService from app.services.video.backend_resolver import BackendResolver -from app.services.video.frame_encode import frame_to_jpeg_bytes from app.services.video.hikvision_runtime import HikvisionInitRefCount, HikvisionRuntime from app.services.video.rtsp_capture import RtspCapture from app.services.video.types import VideoBackendKind @@ -41,9 +43,21 @@ class PendingConsumableConfirmation: model_top1_confidence: float +@dataclass +class CameraStreamInferState: + """单路视频上的时间窗投票(与离线算法一致)。""" + + votes: list[tuple[float, str, ClsTop3]] = field(default_factory=list) + stream_t0: float | None = None + next_bucket: int = 0 + + @dataclass class SurgerySessionState: candidate_consumables: list[str] + #: 分类类名(归一化) -> 业务物品 id(Excel 产品编码或名称)。 + name_to_code: dict[str, str] = field(default_factory=dict) + camera_infer: dict[str, CameraStreamInferState] = field(default_factory=dict) details: list[SurgeryConsumptionDetail] = field(default_factory=list) lock: asyncio.Lock = field(default_factory=asyncio.Lock) ready: asyncio.Event = field(default_factory=asyncio.Event) @@ -94,14 +108,12 @@ class CameraSessionManager: self, *, settings: Settings, - consumable_classifier: ConsumableClassifierService, - tear_action: TearActionService, + vision_algorithm: ConsumableVisionAlgorithmService, hikvision_runtime: HikvisionRuntime | None, result_repository: SurgeryResultRepository | None = None, ) -> None: self._s = settings - self._classifier = consumable_classifier - self._tear = tear_action + self._vision = vision_algorithm self._hik = hikvision_runtime self._repo = result_repository self._resolver = BackendResolver(settings, hikvision_runtime=hikvision_runtime) @@ -221,8 +233,10 @@ class CameraSessionManager: "该手术号存在尚未写入数据库的历史结果,请修复数据库或等待自动重试成功后再开始。", ) + name_to_code = self._vision.build_name_mapping(candidate_consumables) state = SurgerySessionState( candidate_consumables=list(candidate_consumables), + name_to_code=name_to_code, ) stop_event = asyncio.Event() readies = [asyncio.Event() for _ in camera_ids] @@ -388,6 +402,12 @@ class CameraSessionManager: return None return p + def get_surgery_candidate_consumables(self, surgery_id: str) -> list[str]: + """本台手术开始手术时传入的耗材候选清单(语音可任选其中一项,不限于模型 topk)。""" + if surgery_id not in self._active: + return [] + return list(self._active[surgery_id].state.candidate_consumables) + def next_pending_confirmation( self, surgery_id: str ) -> PendingConsumableConfirmation | None: @@ -436,20 +456,23 @@ class CameraSessionManager: "CONFIRMATION_INVALID", "请提供 chosen_label 或设置 rejected=true。", ) - allowed = {lbl.strip() for lbl, _ in pending.options if lbl.strip()} + allowed_pending = {lbl.strip() for lbl, _ in pending.options if lbl.strip()} + allowed_surgery = {c.strip() for c in st.candidate_consumables if c.strip()} if rejected: pending.status = "rejected" else: label = chosen_label.strip() if chosen_label else "" - if label not in allowed: + if label not in allowed_pending and label not in allowed_surgery: raise SurgeryPipelineError( "CONFIRMATION_INVALID", - f"所选耗材不在候选列表中:{chosen_label!r}", + f"所选耗材不在本台手术候选清单或本次追问选项中:{chosen_label!r}", ) pending.status = "confirmed" + norm = _norm_product_name(label) + item_id = st.name_to_code.get(norm, label) self._append_confirmed_detail_locked( state=st, - item_id=label, + item_id=item_id, item_name=label, doctor_id=self._s.video_voice_confirm_doctor_id, source="voice", @@ -582,13 +605,11 @@ class CameraSessionManager: continue last_infer = now try: - jpeg = await asyncio.to_thread( - frame_to_jpeg_bytes, + snap = await asyncio.to_thread( + self._vision.infer_frame_bgr, frame, - quality=self._s.video_jpeg_quality, + state.name_to_code, ) - cls_res = await self._classifier.predict_image_bytes(jpeg) - tear_res = await self._tear.predict_image_bytes(jpeg) except Exception as exc: logger.debug( "Inference skip camera={} surgery={}: {}", @@ -598,11 +619,45 @@ class CameraSessionManager: ) continue - await self._handle_classification_result( - state=state, - cls_res=cls_res, - tear_label=tear_res.label, - ) + if snap is None: + continue + + wsec = self._s.consumable_vision_window_sec + pending_preds: list[PredictionResult] = [] + async with state.lock: + cis = state.camera_infer.setdefault( + camera_id, CameraStreamInferState() + ) + if cis.stream_t0 is None: + cis.stream_t0 = time.monotonic() + t_rel = time.monotonic() - cis.stream_t0 + cis.votes.append((t_rel, snap.t1_name, snap)) + current_b = int(t_rel // wsec) + while cis.next_bucket < current_b: + b = cis.next_bucket + cis.next_bucket += 1 + lo, hi = b * wsec, (b + 1) * wsec + bucket_pts = [ + (p, sn) for (t, p, sn) in cis.votes if lo <= t < hi + ] + cis.votes = [ + (t, p, sn) + for (t, p, sn) in cis.votes + if not (lo <= t < hi) + ] + if not bucket_pts: + continue + best = window_bucket_to_best_snap(bucket_pts) + if best is not None: + pending_preds.append( + cls_top3_to_prediction_result(best) + ) + + for cls_res in pending_preds: + await self._handle_classification_result( + state=state, + cls_res=cls_res, + ) finally: if cap is not None: await asyncio.to_thread(cap.release) @@ -616,11 +671,10 @@ class CameraSessionManager: *, state: SurgerySessionState, cls_res: PredictionResult, - tear_label: str, ) -> None: - _ = tear_label conf = cls_res.confidence label = (cls_res.label or "").strip() + item_id = state.name_to_code.get(label, label) voice_floor = self._s.video_voice_confirm_min_confidence if conf < voice_floor: return @@ -639,7 +693,7 @@ class CameraSessionManager: if conf >= auto_th and in_allowed(label): await self._append_confirmed_detail( state=state, - item_id=label or "unknown", + item_id=item_id or label or "unknown", item_name=label or "unknown", doctor_id=self._s.video_result_doctor_id, source="vision", diff --git a/app/services/voice_confirm.py b/app/services/voice_confirm.py index b568153..2252036 100644 --- a/app/services/voice_confirm.py +++ b/app/services/voice_confirm.py @@ -78,6 +78,25 @@ def parse_voice_choice(asr_text: str, options: list[str]) -> str | None: return None +def match_voice_choice_against_candidates( + asr_text: str, candidates: list[str] +) -> str | None: + """ + 在未匹配 pending 展示的 topk 话术时,按本台手术「候选耗材清单」做名称子串匹配。 + 长名优先,减少短名误命中(如「纱」同时匹配多种耗材时优先更长全称)。 + """ + raw = (asr_text or "").strip() + if not raw: + return None + stripped = [c.strip() for c in candidates if c and str(c).strip()] + if not stripped: + return None + for c in sorted(stripped, key=len, reverse=True): + if c in raw: + return c + return None + + def is_rejection_phrase(asr_text: str) -> bool: """医生明确否认全部候选时返回 True(须在 parse_voice_choice 之前调用)。""" raw = (asr_text or "").strip() @@ -88,7 +107,10 @@ def is_rejection_phrase(asr_text: str) -> bool: def build_prompt_text(options: list[tuple[str, float]]) -> str: - parts = ["请确认刚才使用的耗材是下面哪一项,可以说序号或名称。"] + parts = [ + "请确认刚才使用的耗材是下面哪一项,可以说序号或名称;" + "若是清单内其它耗材,也可以直接说该耗材名称。" + ] for i, (name, _conf) in enumerate(options, start=1): parts.append(f"第{i}个,{name}。") parts.append("若都不是请说不是。") diff --git a/app/services/voice_resolution.py b/app/services/voice_resolution.py index 9cb4cab..262562d 100644 --- a/app/services/voice_resolution.py +++ b/app/services/voice_resolution.py @@ -15,7 +15,11 @@ from app.services.audio_wav import WavDecodeError, wav_bytes_to_pcm16k_mono_s16l from app.services.baidu_speech import BaiduSpeechNotConfiguredError, BaiduSpeechService from app.services.minio_audio_storage import MinioAudioStorageService, StoredAudio from app.services.video.session_manager import CameraSessionManager -from app.services.voice_confirm import is_rejection_phrase, parse_voice_choice +from app.services.voice_confirm import ( + is_rejection_phrase, + match_voice_choice_against_candidates, + parse_voice_choice, +) from app.surgery_errors import SurgeryPipelineError @@ -256,9 +260,19 @@ class VoiceConfirmationService: chosen: str | None = None if not rejected: chosen = parse_voice_choice(text, option_labels) + if chosen is None: + surgery_candidates = self._sessions.get_surgery_candidate_consumables( + surgery_id + ) + chosen = match_voice_choice_against_candidates( + text, surgery_candidates + ) if not rejected and not chosen: - msg = "无法从语音中匹配候选项,请重试或说「不是」否认全部" + msg = ( + "无法从语音中匹配候选项或本台手术候选清单中的耗材名称," + "请重试或说「不是」否认全部" + ) await self._persist_audit( surgery_id=surgery_id, confirmation_id=confirmation_id, diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index e685897..29e5d55 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -36,9 +36,12 @@ services: CONSUMABLE_CLASSIFIER_IMGSZ: ${CONSUMABLE_CLASSIFIER_IMGSZ:-224} CONSUMABLE_CLASSIFIER_DEVICE: ${CONSUMABLE_CLASSIFIER_DEVICE:-} CONSUMABLE_CLASSIFIER_TOPK: ${CONSUMABLE_CLASSIFIER_TOPK:-5} - TEAR_ACTION_IMGSZ: ${TEAR_ACTION_IMGSZ:-224} - TEAR_ACTION_DEVICE: ${TEAR_ACTION_DEVICE:-} - TEAR_ACTION_TOPK: ${TEAR_ACTION_TOPK:-5} + CONSUMABLE_MIN_CLS_CONFIDENCE: ${CONSUMABLE_MIN_CLS_CONFIDENCE:-0.5} + CONSUMABLE_VISION_WINDOW_SEC: ${CONSUMABLE_VISION_WINDOW_SEC:-15} + CONSUMABLE_CATALOG_XLSX_PATH: ${CONSUMABLE_CATALOG_XLSX_PATH:-} + HAND_DETECTION_WEIGHTS: ${HAND_DETECTION_WEIGHTS:-} + HAND_DETECTION_IMGSZ: ${HAND_DETECTION_IMGSZ:-640} + HAND_DETECTION_DEVICE: ${HAND_DETECTION_DEVICE:-} VIDEO_DEFAULT_BACKEND: ${VIDEO_DEFAULT_BACKEND:-rtsp} VIDEO_RTSP_URL_TEMPLATE: ${VIDEO_RTSP_URL_TEMPLATE:-} VIDEO_RTSP_URLS_JSON_FILE: ${VIDEO_RTSP_URLS_JSON_FILE:-} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index abab7eb..15ce272 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -36,9 +36,12 @@ services: CONSUMABLE_CLASSIFIER_IMGSZ: ${CONSUMABLE_CLASSIFIER_IMGSZ:-224} CONSUMABLE_CLASSIFIER_DEVICE: ${CONSUMABLE_CLASSIFIER_DEVICE:-} CONSUMABLE_CLASSIFIER_TOPK: ${CONSUMABLE_CLASSIFIER_TOPK:-5} - TEAR_ACTION_IMGSZ: ${TEAR_ACTION_IMGSZ:-224} - TEAR_ACTION_DEVICE: ${TEAR_ACTION_DEVICE:-} - TEAR_ACTION_TOPK: ${TEAR_ACTION_TOPK:-5} + CONSUMABLE_MIN_CLS_CONFIDENCE: ${CONSUMABLE_MIN_CLS_CONFIDENCE:-0.5} + CONSUMABLE_VISION_WINDOW_SEC: ${CONSUMABLE_VISION_WINDOW_SEC:-15} + CONSUMABLE_CATALOG_XLSX_PATH: ${CONSUMABLE_CATALOG_XLSX_PATH:-} + HAND_DETECTION_WEIGHTS: ${HAND_DETECTION_WEIGHTS:-} + HAND_DETECTION_IMGSZ: ${HAND_DETECTION_IMGSZ:-640} + HAND_DETECTION_DEVICE: ${HAND_DETECTION_DEVICE:-} # Video backends (RTSP / optional Hikvision SDK) — see docs/video-backends.md VIDEO_DEFAULT_BACKEND: ${VIDEO_DEFAULT_BACKEND:-rtsp} VIDEO_RTSP_URL_TEMPLATE: ${VIDEO_RTSP_URL_TEMPLATE:-} diff --git a/pyproject.toml b/pyproject.toml index bc71d38..25d8203 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,6 +11,8 @@ dependencies = [ "chardet>=7.4.3", "fastapi>=0.136.0", "loguru>=0.7.3", + "openpyxl>=3.1.5", + "pandas>=2.3.0", "pillow>=12.2.0", "pydantic-settings>=2.13.1", "python-multipart>=0.0.26", diff --git a/tests/test_session_manager_unit.py b/tests/test_session_manager_unit.py index 0aa9a1b..152bb91 100644 --- a/tests/test_session_manager_unit.py +++ b/tests/test_session_manager_unit.py @@ -7,7 +7,10 @@ from unittest.mock import MagicMock import pytest from app.config import Settings -from app.services.consumable_classifier import PredictionCandidate, PredictionResult +from app.services.consumable_vision_algorithm import ( + PredictionCandidate, + PredictionResult, +) from app.surgery_errors import SurgeryPipelineError from app.services.video.session_manager import ( CameraSessionManager, @@ -21,8 +24,7 @@ def test_live_consumption_requires_non_empty_details() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -33,13 +35,50 @@ def test_live_consumption_requires_non_empty_details() -> None: assert mgr.live_consumption_if_active("123456") is None +@pytest.mark.asyncio +async def test_resolve_voice_accepts_label_on_surgery_list_not_in_topk_options() -> None: + settings = Settings() + mgr = CameraSessionManager( + settings=settings, + vision_algorithm=MagicMock(), + hikvision_runtime=None, + result_repository=None, + ) + st = SurgerySessionState( + candidate_consumables=["纱布", "止血钳"], + name_to_code={"纱布": "P1", "止血钳": "P2"}, + ) + pid = "test-confirm-id" + st.pending_by_id[pid] = PendingConsumableConfirmation( + id=pid, + status="pending", + options=[("纱布", 0.4)], + prompt_text="请确认", + created_at=datetime.now(timezone.utc), + model_top1_label="unknown", + model_top1_confidence=0.41, + ) + st.pending_fifo.append(pid) + mgr._active["123456"] = RunningSurgery( + stop_event=asyncio.Event(), state=st, tasks=[] + ) + + await mgr.resolve_pending_confirmation( + "123456", pid, chosen_label="止血钳", rejected=False + ) + + assert len(st.details) == 1 + assert st.details[0].item_id == "P2" + assert st.details[0].item_name == "止血钳" + assert st.details[0].source == "voice" + + @pytest.mark.asyncio async def test_resolve_pending_appends_voice_detail() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -74,8 +113,7 @@ async def test_resolve_reject_closes_without_detail() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -108,8 +146,7 @@ async def test_handle_skips_when_candidate_list_empty() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -120,7 +157,7 @@ async def test_handle_skips_when_candidate_list_empty() -> None: topk=[PredictionCandidate(label="纱布", confidence=0.99)], ) await mgr._handle_classification_result( - state=state, cls_res=res, tear_label="" + state=state, cls_res=res ) assert state.details == [] assert state.pending_fifo == [] @@ -131,8 +168,7 @@ async def test_archive_retry_loop_starts() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -153,8 +189,7 @@ async def test_handle_skips_below_voice_floor() -> None: settings.video_voice_confirm_min_confidence = 0.5 mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -165,7 +200,7 @@ async def test_handle_skips_below_voice_floor() -> None: topk=[PredictionCandidate(label="纱布", confidence=0.4)], ) await mgr._handle_classification_result( - state=state, cls_res=res, tear_label="" + state=state, cls_res=res ) assert state.details == [] assert state.pending_fifo == [] @@ -176,8 +211,7 @@ async def test_handle_auto_vision_confirm() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -188,7 +222,7 @@ async def test_handle_auto_vision_confirm() -> None: topk=[PredictionCandidate(label="纱布", confidence=0.99)], ) await mgr._handle_classification_result( - state=state, cls_res=res, tear_label="" + state=state, cls_res=res ) assert len(state.details) == 1 assert state.details[0].source == "vision" @@ -200,8 +234,7 @@ async def test_handle_high_conf_top1_not_in_candidates_enqueues_pending() -> Non settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -215,7 +248,7 @@ async def test_handle_high_conf_top1_not_in_candidates_enqueues_pending() -> Non ], ) await mgr._handle_classification_result( - state=state, cls_res=res, tear_label="" + state=state, cls_res=res ) assert state.details == [] assert len(state.pending_fifo) == 1 @@ -230,8 +263,7 @@ async def test_handle_mid_confidence_enqueues_pending() -> None: settings.video_voice_confirm_min_confidence = 0.3 mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -245,7 +277,7 @@ async def test_handle_mid_confidence_enqueues_pending() -> None: ], ) await mgr._handle_classification_result( - state=state, cls_res=res, tear_label="" + state=state, cls_res=res ) assert len(state.pending_fifo) == 1 @@ -257,8 +289,7 @@ async def test_handle_voice_disabled_no_pending_for_mid_conf() -> None: settings.video_auto_confirm_confidence = 0.8 mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -269,7 +300,7 @@ async def test_handle_voice_disabled_no_pending_for_mid_conf() -> None: topk=[PredictionCandidate(label="纱布", confidence=0.5)], ) await mgr._handle_classification_result( - state=state, cls_res=res, tear_label="" + state=state, cls_res=res ) assert state.pending_fifo == [] assert state.details == [] @@ -281,8 +312,7 @@ async def test_handle_vision_cooldown_skips_duplicate() -> None: settings.video_detail_cooldown_sec = 3600.0 mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -293,10 +323,10 @@ async def test_handle_vision_cooldown_skips_duplicate() -> None: topk=[PredictionCandidate(label="纱布", confidence=0.99)], ) await mgr._handle_classification_result( - state=state, cls_res=res, tear_label="" + state=state, cls_res=res ) await mgr._handle_classification_result( - state=state, cls_res=res, tear_label="" + state=state, cls_res=res ) assert len(state.details) == 1 @@ -307,8 +337,7 @@ async def test_handle_pending_dedupe_cooldown() -> None: settings.video_detail_cooldown_sec = 3600.0 mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -322,10 +351,10 @@ async def test_handle_pending_dedupe_cooldown() -> None: ], ) await mgr._handle_classification_result( - state=state, cls_res=res, tear_label="" + state=state, cls_res=res ) await mgr._handle_classification_result( - state=state, cls_res=res, tear_label="" + state=state, cls_res=res ) assert len(state.pending_fifo) == 1 @@ -335,8 +364,7 @@ async def test_resolve_invalid_chosen_label() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -367,8 +395,7 @@ async def test_resolve_not_active() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -384,8 +411,7 @@ async def test_resolve_second_time_not_found() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) @@ -419,8 +445,7 @@ async def test_resolve_already_resolved_status() -> None: settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) diff --git a/tests/test_session_rank.py b/tests/test_session_rank.py index 1766823..3cc103e 100644 --- a/tests/test_session_rank.py +++ b/tests/test_session_rank.py @@ -1,4 +1,4 @@ -from app.services.consumable_classifier import PredictionCandidate +from app.services.consumable_vision_algorithm import PredictionCandidate from app.services.video.session_manager import _rank_topk_for_candidates diff --git a/tests/test_surgery_pipeline_persistence.py b/tests/test_surgery_pipeline_persistence.py index 4bb52a1..e6f6aa7 100644 --- a/tests/test_surgery_pipeline_persistence.py +++ b/tests/test_surgery_pipeline_persistence.py @@ -46,8 +46,7 @@ async def test_stop_surgery_persists_final_result( settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=repo, ) @@ -101,8 +100,7 @@ async def test_stop_surgery_failed_persist_goes_to_archive_then_retry_persists( settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=repo, ) @@ -149,8 +147,7 @@ async def test_pipeline_prefers_live_then_db_then_archive( settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=repo, ) diff --git a/tests/test_voice_confirm.py b/tests/test_voice_confirm.py index 12471c7..e882f91 100644 --- a/tests/test_voice_confirm.py +++ b/tests/test_voice_confirm.py @@ -1,4 +1,8 @@ -from app.services.voice_confirm import build_prompt_text, parse_voice_choice +from app.services.voice_confirm import ( + build_prompt_text, + match_voice_choice_against_candidates, + parse_voice_choice, +) def test_parse_voice_choice_substring() -> None: @@ -17,3 +21,23 @@ def test_build_prompt_contains_options() -> None: text = build_prompt_text([("纱布", 0.4), ("缝线", 0.3)]) assert "纱布" in text assert "缝线" in text + + +def test_match_voice_against_full_candidate_list() -> None: + assert ( + match_voice_choice_against_candidates( + "刚才用的是止血钳", + ["纱布", "缝线", "止血钳"], + ) + == "止血钳" + ) + + +def test_match_voice_longest_candidate_first() -> None: + assert ( + match_voice_choice_against_candidates( + "拿的一次性止血钳", + ["止血钳", "一次性止血钳"], + ) + == "一次性止血钳" + ) diff --git a/tests/test_voice_resolution_service.py b/tests/test_voice_resolution_service.py index e227245..e6bbd65 100644 --- a/tests/test_voice_resolution_service.py +++ b/tests/test_voice_resolution_service.py @@ -61,20 +61,24 @@ def _make_service( def _active_session_with_pending( surgery_id: str = "123456", confirmation_id: str = "cid-a", + *, + candidate_consumables: list[str] | None = None, + pending_options: list[tuple[str, float]] | None = None, ) -> tuple[CameraSessionManager, str]: settings = Settings() mgr = CameraSessionManager( settings=settings, - consumable_classifier=MagicMock(), - tear_action=MagicMock(), + vision_algorithm=MagicMock(), hikvision_runtime=None, result_repository=None, ) - st = SurgerySessionState(candidate_consumables=["纱布", "缝线"]) + cands = candidate_consumables or ["纱布", "缝线"] + opts = pending_options or [("纱布", 0.4), ("缝线", 0.3)] + st = SurgerySessionState(candidate_consumables=cands) st.pending_by_id[confirmation_id] = PendingConsumableConfirmation( id=confirmation_id, status="pending", - options=[("纱布", 0.4), ("缝线", 0.3)], + options=opts, prompt_text="请确认", created_at=datetime.now(timezone.utc), model_top1_label="x", @@ -193,6 +197,55 @@ async def test_resolve_rejected_audit( assert row.status == "rejected" +@pytest.mark.asyncio +async def test_resolve_recognizes_label_not_in_topk_but_in_surgery_candidates( + sqlite_session_factory, + monkeypatch: pytest.MonkeyPatch, +) -> None: + """医生说出候选清单中的耗材(未出现在本次 pending 的模型 topk 里)也应记账。""" + settings = Settings() + sessions, cid = _active_session_with_pending( + candidate_consumables=["纱布", "缝线", "止血钳"], + pending_options=[("纱布", 0.4), ("缝线", 0.3)], + ) + minio = MagicMock() + minio.configured = True + minio.ensure_bucket = MagicMock() + minio.upload_voice_wav = MagicMock( + return_value=StoredAudio( + object_key="k2.wav", + sha256_hex="d" * 64, + size_bytes=10, + ) + ) + baidu = MagicMock() + baidu.configured = True + baidu.asr = MagicMock( + return_value={"err_no": 0, "result": ["刚才用的是止血钳"]} + ) + svc = _make_service( + settings=settings, + sessions=sessions, + minio=minio, + baidu=baidu, + sqlite_factory=sqlite_session_factory, + monkeypatch=monkeypatch, + ) + result = await svc.resolve_from_wav( + surgery_id="123456", + confirmation_id=cid, + wav_bytes=_minimal_wav_16k_mono(), + filename="a.wav", + content_type="audio/wav", + ) + assert result.rejected is False + assert result.resolved_label == "止血钳" + st = sessions._active["123456"].state + assert len(st.details) == 1 + assert st.details[0].item_name == "止血钳" + assert st.details[0].source == "voice" + + @pytest.mark.asyncio async def test_audio_too_large_audit( sqlite_session_factory, diff --git a/uv.lock b/uv.lock index 88ad83b..d9652df 100644 --- a/uv.lock +++ b/uv.lock @@ -355,6 +355,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e7/05/c19819d5e3d95294a6f5947fb9b9629efb316b96de511b418c53d245aae6/cycler-0.12.1-py3-none-any.whl", hash = "sha256:85cef7cff222d8644161529808465972e51340599459b8ac3ccbac5a854e0d30", size = 8321, upload-time = "2023-10-07T05:32:16.783Z" }, ] +[[package]] +name = "et-xmlfile" +version = "2.0.0" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/d3/38/af70d7ab1ae9d4da450eeec1fa3918940a5fafb9055e934af8d6eb0c2313/et_xmlfile-2.0.0.tar.gz", hash = "sha256:dab3f4764309081ce75662649be815c4c9081e88f0837825f90fd28317d4da54", size = 17234, upload-time = "2024-10-25T17:25:40.039Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c1/8b/5fe2cc11fee489817272089c4203e679c63b570a5aaeb18d852ae3cbba6a/et_xmlfile-2.0.0-py3-none-any.whl", hash = "sha256:7a91720bc756843502c3b7504c77b8fe44217c85c537d85037f0f536151b2caa", size = 18059, upload-time = "2024-10-25T17:25:39.051Z" }, +] + [[package]] name = "fastapi" version = "0.136.0" @@ -799,6 +808,18 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" }, ] +[[package]] +name = "openpyxl" +version = "3.1.5" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "et-xmlfile" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/3d/f9/88d94a75de065ea32619465d2f77b29a0469500e99012523b91cc4141cd1/openpyxl-3.1.5.tar.gz", hash = "sha256:cf0e3cf56142039133628b5acffe8ef0c12bc902d2aadd3e0fe5878dc08d1050", size = 186464, upload-time = "2024-06-28T14:03:44.161Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c0/da/977ded879c29cbd04de313843e76868e6e13408a94ed6b987245dc7c8506/openpyxl-3.1.5-py2.py3-none-any.whl", hash = "sha256:5282c12b107bffeef825f4617dc029afaf41d0ea60823bbb665ef3079dc79de2", size = 250910, upload-time = "2024-06-28T14:03:41.161Z" }, +] + [[package]] name = "operation-room-monitor-server" version = "0.1.0" @@ -811,6 +832,8 @@ dependencies = [ { name = "greenlet" }, { name = "loguru" }, { name = "minio" }, + { name = "openpyxl" }, + { name = "pandas" }, { name = "pillow" }, { name = "pydantic-settings" }, { name = "python-multipart" }, @@ -836,6 +859,8 @@ requires-dist = [ { name = "greenlet", specifier = ">=3.1.0" }, { name = "loguru", specifier = ">=0.7.3" }, { name = "minio", specifier = ">=7.2.15" }, + { name = "openpyxl", specifier = ">=3.1.5" }, + { name = "pandas", specifier = ">=2.3.0" }, { name = "pillow", specifier = ">=12.2.0" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "python-multipart", specifier = ">=0.0.26" }, @@ -861,6 +886,50 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, ] +[[package]] +name = "pandas" +version = "3.0.2" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "numpy" }, + { name = "python-dateutil" }, + { name = "tzdata", marker = "sys_platform == 'emscripten' or sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/da/99/b342345300f13440fe9fe385c3c481e2d9a595ee3bab4d3219247ac94e9a/pandas-3.0.2.tar.gz", hash = "sha256:f4753e73e34c8d83221ba58f232433fca2748be8b18dbca02d242ed153945043", size = 4645855, upload-time = "2026-03-31T06:48:30.816Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/bf/ca/3e639a1ea6fcd0617ca4e8ca45f62a74de33a56ae6cd552735470b22c8d3/pandas-3.0.2-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b5918ba197c951dec132b0c5929a00c0bf05d5942f590d3c10a807f6e15a57d3", size = 10321105, upload-time = "2026-03-31T06:46:57.327Z" }, + { url = "https://files.pythonhosted.org/packages/0b/77/dbc82ff2fb0e63c6564356682bf201edff0ba16c98630d21a1fb312a8182/pandas-3.0.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:d606a041c89c0a474a4702d532ab7e73a14fe35c8d427b972a625c8e46373668", size = 9864088, upload-time = "2026-03-31T06:46:59.935Z" }, + { url = "https://files.pythonhosted.org/packages/5c/2b/341f1b04bbca2e17e13cd3f08c215b70ef2c60c5356ef1e8c6857449edc7/pandas-3.0.2-cp313-cp313-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:710246ba0616e86891b58ab95f2495143bb2bc83ab6b06747c74216f583a6ac9", size = 10369066, upload-time = "2026-03-31T06:47:02.792Z" }, + { url = "https://files.pythonhosted.org/packages/12/c5/cbb1ffefb20a93d3f0e1fdcda699fb84976210d411b008f97f48bf6ce27e/pandas-3.0.2-cp313-cp313-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5d3cfe227c725b1f3dff4278b43d8c784656a42a9325b63af6b1492a8232209e", size = 10876780, upload-time = "2026-03-31T06:47:06.205Z" }, + { url = "https://files.pythonhosted.org/packages/98/fe/2249ae5e0a69bd0ddf17353d0a5d26611d70970111f5b3600cdc8be883e7/pandas-3.0.2-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:c3b723df9087a9a9a840e263ebd9f88b64a12075d1bf2ea401a5a42f254f084d", size = 11375181, upload-time = "2026-03-31T06:47:09.383Z" }, + { url = "https://files.pythonhosted.org/packages/de/64/77a38b09e70b6464883b8d7584ab543e748e42c1b5d337a2ee088e0df741/pandas-3.0.2-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:a3096110bf9eac0070b7208465f2740e2d8a670d5cb6530b5bb884eca495fd39", size = 11928899, upload-time = "2026-03-31T06:47:12.686Z" }, + { url = "https://files.pythonhosted.org/packages/5e/52/42855bf626868413f761addd574acc6195880ae247a5346477a4361c3acb/pandas-3.0.2-cp313-cp313-win_amd64.whl", hash = "sha256:07a10f5c36512eead51bc578eb3354ad17578b22c013d89a796ab5eee90cd991", size = 9746574, upload-time = "2026-03-31T06:47:15.64Z" }, + { url = "https://files.pythonhosted.org/packages/88/39/21304ae06a25e8bf9fc820d69b29b2c495b2ae580d1e143146c309941760/pandas-3.0.2-cp313-cp313-win_arm64.whl", hash = "sha256:5fdbfa05931071aba28b408e59226186b01eb5e92bea2ab78b65863ca3228d84", size = 9047156, upload-time = "2026-03-31T06:47:18.595Z" }, + { url = "https://files.pythonhosted.org/packages/72/20/7defa8b27d4f330a903bb68eea33be07d839c5ea6bdda54174efcec0e1d2/pandas-3.0.2-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:dbc20dea3b9e27d0e66d74c42b2d0c1bed9c2ffe92adea33633e3bedeb5ac235", size = 10756238, upload-time = "2026-03-31T06:47:22.012Z" }, + { url = "https://files.pythonhosted.org/packages/e9/95/49433c14862c636afc0e9b2db83ff16b3ad92959364e52b2955e44c8e94c/pandas-3.0.2-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:b75c347eff42497452116ce05ef461822d97ce5b9ff8df6edacb8076092c855d", size = 10408520, upload-time = "2026-03-31T06:47:25.197Z" }, + { url = "https://files.pythonhosted.org/packages/3b/f8/462ad2b5881d6b8ec8e5f7ed2ea1893faa02290d13870a1600fe72ad8efc/pandas-3.0.2-cp313-cp313t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d1478075142e83a5571782ad007fb201ed074bdeac7ebcc8890c71442e96adf7", size = 10324154, upload-time = "2026-03-31T06:47:28.097Z" }, + { url = "https://files.pythonhosted.org/packages/0a/65/d1e69b649cbcddda23ad6e4c40ef935340f6f652a006e5cbc3555ac8adb3/pandas-3.0.2-cp313-cp313t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:5880314e69e763d4c8b27937090de570f1fb8d027059a7ada3f7f8e98bdcb677", size = 10714449, upload-time = "2026-03-31T06:47:30.85Z" }, + { url = "https://files.pythonhosted.org/packages/47/a4/85b59bc65b8190ea3689882db6cdf32a5003c0ccd5a586c30fdcc3ffc4fc/pandas-3.0.2-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:b5329e26898896f06035241a626d7c335daa479b9bbc82be7c2742d048e41172", size = 11338475, upload-time = "2026-03-31T06:47:34.026Z" }, + { url = "https://files.pythonhosted.org/packages/1e/c4/bc6966c6e38e5d9478b935272d124d80a589511ed1612a5d21d36f664c68/pandas-3.0.2-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:81526c4afd31971f8b62671442a4b2b51e0aa9acc3819c9f0f12a28b6fcf85f1", size = 11786568, upload-time = "2026-03-31T06:47:36.941Z" }, + { url = "https://files.pythonhosted.org/packages/e8/74/09298ca9740beed1d3504e073d67e128aa07e5ca5ca2824b0c674c0b8676/pandas-3.0.2-cp313-cp313t-win_amd64.whl", hash = "sha256:7cadd7e9a44ec13b621aec60f9150e744cfc7a3dd32924a7e2f45edff31823b0", size = 10488652, upload-time = "2026-03-31T06:47:40.612Z" }, + { url = "https://files.pythonhosted.org/packages/bb/40/c6ea527147c73b24fc15c891c3fcffe9c019793119c5742b8784a062c7db/pandas-3.0.2-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:db0dbfd2a6cdf3770aa60464d50333d8f3d9165b2f2671bcc299b72de5a6677b", size = 10326084, upload-time = "2026-03-31T06:47:43.834Z" }, + { url = "https://files.pythonhosted.org/packages/95/25/bdb9326c3b5455f8d4d3549fce7abcf967259de146fe2cf7a82368141948/pandas-3.0.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:0555c5882688a39317179ab4a0ed41d3ebc8812ab14c69364bbee8fb7a3f6288", size = 9914146, upload-time = "2026-03-31T06:47:46.67Z" }, + { url = "https://files.pythonhosted.org/packages/8d/77/3a227ff3337aa376c60d288e1d61c5d097131d0ac71f954d90a8f369e422/pandas-3.0.2-cp314-cp314-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:01f31a546acd5574ef77fe199bc90b55527c225c20ccda6601cf6b0fd5ed597c", size = 10444081, upload-time = "2026-03-31T06:47:49.681Z" }, + { url = "https://files.pythonhosted.org/packages/15/88/3cdd54fa279341afa10acf8d2b503556b1375245dccc9315659f795dd2e9/pandas-3.0.2-cp314-cp314-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:deeca1b5a931fdf0c2212c8a659ade6d3b1edc21f0914ce71ef24456ca7a6535", size = 10897535, upload-time = "2026-03-31T06:47:53.033Z" }, + { url = "https://files.pythonhosted.org/packages/06/9d/98cc7a7624f7932e40f434299260e2917b090a579d75937cb8a57b9d2de3/pandas-3.0.2-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:0f48afd9bb13300ffb5a3316973324c787054ba6665cda0da3fbd67f451995db", size = 11446992, upload-time = "2026-03-31T06:47:56.193Z" }, + { url = "https://files.pythonhosted.org/packages/9a/cd/19ff605cc3760e80602e6826ddef2824d8e7050ed80f2e11c4b079741dc3/pandas-3.0.2-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:6c4d8458b97a35717b62469a4ea0e85abd5ed8687277f5ccfc67f8a5126f8c53", size = 11968257, upload-time = "2026-03-31T06:47:59.137Z" }, + { url = "https://files.pythonhosted.org/packages/db/60/aba6a38de456e7341285102bede27514795c1eaa353bc0e7638b6b785356/pandas-3.0.2-cp314-cp314-win_amd64.whl", hash = "sha256:b35d14bb5d8285d9494fe93815a9e9307c0876e10f1e8e89ac5b88f728ec8dcf", size = 9865893, upload-time = "2026-03-31T06:48:02.038Z" }, + { url = "https://files.pythonhosted.org/packages/08/71/e5ec979dd2e8a093dacb8864598c0ff59a0cee0bbcdc0bfec16a51684d4f/pandas-3.0.2-cp314-cp314-win_arm64.whl", hash = "sha256:63d141b56ef686f7f0d714cfb8de4e320475b86bf4b620aa0b7da89af8cbdbbb", size = 9188644, upload-time = "2026-03-31T06:48:05.045Z" }, + { url = "https://files.pythonhosted.org/packages/f1/6c/7b45d85db19cae1eb524f2418ceaa9d85965dcf7b764ed151386b7c540f0/pandas-3.0.2-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:140f0cffb1fa2524e874dde5b477d9defe10780d8e9e220d259b2c0874c89d9d", size = 10776246, upload-time = "2026-03-31T06:48:07.789Z" }, + { url = "https://files.pythonhosted.org/packages/a8/3e/7b00648b086c106e81766f25322b48aa8dfa95b55e621dbdf2fdd413a117/pandas-3.0.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:ae37e833ff4fed0ba352f6bdd8b73ba3ab3256a85e54edfd1ab51ae40cca0af8", size = 10424801, upload-time = "2026-03-31T06:48:10.897Z" }, + { url = "https://files.pythonhosted.org/packages/da/6e/558dd09a71b53b4008e7fc8a98ec6d447e9bfb63cdaeea10e5eb9b2dabe8/pandas-3.0.2-cp314-cp314t-manylinux_2_24_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4d888a5c678a419a5bb41a2a93818e8ed9fd3172246555c0b37b7cc27027effd", size = 10345643, upload-time = "2026-03-31T06:48:13.7Z" }, + { url = "https://files.pythonhosted.org/packages/be/e3/921c93b4d9a280409451dc8d07b062b503bbec0531d2627e73a756e99a82/pandas-3.0.2-cp314-cp314t-manylinux_2_24_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:b444dc64c079e84df91baa8bf613d58405645461cabca929d9178f2cd392398d", size = 10743641, upload-time = "2026-03-31T06:48:16.659Z" }, + { url = "https://files.pythonhosted.org/packages/56/ca/fd17286f24fa3b4d067965d8d5d7e14fe557dd4f979a0b068ac0deaf8228/pandas-3.0.2-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:4544c7a54920de8eeacaa1466a6b7268ecfbc9bc64ab4dbb89c6bbe94d5e0660", size = 11361993, upload-time = "2026-03-31T06:48:19.475Z" }, + { url = "https://files.pythonhosted.org/packages/e4/a5/2f6ed612056819de445a433ca1f2821ac3dab7f150d569a59e9cc105de1d/pandas-3.0.2-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:734be7551687c00fbd760dc0522ed974f82ad230d4a10f54bf51b80d44a08702", size = 11815274, upload-time = "2026-03-31T06:48:22.695Z" }, + { url = "https://files.pythonhosted.org/packages/00/2f/b622683e99ec3ce00b0854bac9e80868592c5b051733f2cf3a868e5fea26/pandas-3.0.2-cp314-cp314t-win_amd64.whl", hash = "sha256:57a07209bebcbcf768d2d13c9b78b852f9a15978dac41b9e6421a81ad4cdd276", size = 10888530, upload-time = "2026-03-31T06:48:25.806Z" }, + { url = "https://files.pythonhosted.org/packages/cb/2b/f8434233fab2bd66a02ec014febe4e5adced20e2693e0e90a07d118ed30e/pandas-3.0.2-cp314-cp314t-win_arm64.whl", hash = "sha256:5371b72c2d4d415d08765f32d689217a43227484e81b2305b52076e328f6f482", size = 9455341, upload-time = "2026-03-31T06:48:28.418Z" }, +] + [[package]] name = "pillow" version = "12.2.0" @@ -1499,6 +1568,15 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/dc/9b/47798a6c91d8bdb567fe2698fe81e0c6b7cb7ef4d13da4114b41d239f65d/typing_inspection-0.4.2-py3-none-any.whl", hash = "sha256:4ed1cacbdc298c220f1bd249ed5287caa16f34d44ef4e9c3d0cbad5b521545e7", size = 14611, upload-time = "2025-10-01T02:14:40.154Z" }, ] +[[package]] +name = "tzdata" +version = "2026.1" +source = { registry = "https://pypi.org/simple" } +sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, +] + [[package]] name = "ultralytics" version = "8.4.40"