diff --git a/.env.example b/.env.example index c1cdfb5..060bca0 100644 --- a/.env.example +++ b/.env.example @@ -31,9 +31,8 @@ CONSUMABLE_CLASSIFIER_TOPK=5 # CONSUMABLE_MIN_CLS_CONFIDENCE=0.5 # 时间窗(秒):窗内多次推理取众数后再走自动记账 / 待确认。 # CONSUMABLE_VISION_WINDOW_SEC=15 -# 可选:Excel「商品名称」「产品编码」表;空则物品 id 用名称。 -# 开始手术请求体 candidate_consumables 缺省或 [] 时,优先用本表全部商品名参与推理;未配置则用分类模型全部类名。 -# CONSUMABLE_CATALOG_XLSX_PATH=/path/to/视频中的商品信息表.xlsx +# 业务物品 id 与开录时「空 candidate」的类名全表:app/resources/consumable_classifier_labels.yaml(names + label_id;同名多规格为 id1/id2/...)。未设或空则默认该包内文件。 +# CONSUMABLE_CLASSIFIER_LABELS_YAML_PATH=/app/app/resources/consumable_classifier_labels.yaml # HAND_DETECTION_WEIGHTS=/absolute/path/to/hand_detect.pt # HAND_DETECTION_IMGSZ=640 # HAND_DETECTION_CONF=0.25 diff --git a/app/config.py b/app/config.py index 1e47942..717f8c6 100644 --- a/app/config.py +++ b/app/config.py @@ -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", diff --git a/app/resources/consumable_classifier_labels.yaml b/app/resources/consumable_classifier_labels.yaml index 3b92b3f..5ddf359 100644 --- a/app/resources/consumable_classifier_labels.yaml +++ b/app/resources/consumable_classifier_labels.yaml @@ -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 diff --git a/app/schemas.py b/app/schemas.py index cd79a49..dd181b5 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -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="物品名称(分类或确认后的展示名)。") diff --git a/app/services/consumable_vision_algorithm.py b/app/services/consumable_vision_algorithm.py index 9a3f827..af77bb6 100644 --- a/app/services/consumable_vision_algorithm.py +++ b/app/services/consumable_vision_algorithm.py @@ -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() diff --git a/app/services/consumption_tsv_log.py b/app/services/consumption_tsv_log.py index 327f511..2905a80 100644 --- a/app/services/consumption_tsv_log.py +++ b/app/services/consumption_tsv_log.py @@ -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() diff --git a/app/services/video/session_manager.py b/app/services/video/session_manager.py index 573dd69..be2ca67 100644 --- a/app/services/video/session_manager.py +++ b/app/services/video/session_manager.py @@ -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( diff --git a/app/services/video/session_registry.py b/app/services/video/session_registry.py index b5adfa8..c68e4da 100644 --- a/app/services/video/session_registry.py +++ b/app/services/video/session_registry.py @@ -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) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 15ce272..fa9b821 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -38,7 +38,6 @@ services: CONSUMABLE_CLASSIFIER_TOPK: ${CONSUMABLE_CLASSIFIER_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:-} diff --git a/docs/staging-regression-checklist.md b/docs/staging-regression-checklist.md index 97d24be..611a9f4 100644 --- a/docs/staging-regression-checklist.md +++ b/docs/staging-regression-checklist.md @@ -12,7 +12,7 @@ ## 主流程 1. **开始手术** `POST /client/surgeries/start` - - 请求体含 6 位 `surgery_id`、`camera_ids`;`candidate_consumables` 可空(空则全量目录/模型类名) + - 请求体含 6 位 `surgery_id`、`camera_ids`;`candidate_consumables` 可空(空则 yaml 全量类名/模型类名) - 返回 `200`,日志中各路 RTSP 首帧就绪 2. **进行中查询(可选)** `GET /client/surgeries/{id}/result` - 至少一条明细时 `200`;无明细(开录后尚无、已归档但零消耗等)为 `503 RESULT_NOT_READY` diff --git a/docs/video-backends.md b/docs/video-backends.md index b7b5fcb..397e0a9 100644 --- a/docs/video-backends.md +++ b/docs/video-backends.md @@ -33,7 +33,7 @@ SDK **不作为构建期依赖**:将厂商提供的 Linux x86_64 动态库挂 ## 推理与结果查询 - 开录后按 `VIDEO_INFERENCE_INTERVAL_SEC` 抽帧,依次调用耗材分类与撕扯动作模型。 -- **候选耗材清单**(`candidate_consumables`):非空时**仅**清单内名称参与自动记账与待确认;**缺省或 `[]`** 时,用耗材目录 Excel **全部商品名**作为候选;无目录则用分类模型**全部类名**。 +- **候选耗材清单**(`candidate_consumables`):非空时**仅**清单内名称参与自动记账与待确认;**缺省或 `[]`** 时,用 `consumable_classifier_labels.yaml` 的 **全部类名**作为候选;yaml 无有效 `names` 时用分类模型**全部类名**。 - 当分类 Top1 置信度 **≥** `VIDEO_AUTO_CONFIRM_CONFIDENCE`(**默认 0.9**)且标签在候选清单内时,自动写入一条 `source=vision` 的消耗明细;**低于**该线的识别需人工确认(在语音下沿之上且能展示候选项时入队)。 - 置信度在 `VIDEO_VOICE_CONFIRM_MIN_CONFIDENCE`, `VIDEO_AUTO_CONFIRM_CONFIDENCE` 等区间且存在可向医生展示的候选时,会生成**待确认**任务;客户端 `GET /client/surgeries/{surgery_id}/pending-confirmation`,确认后 `POST .../pending-confirmation/{id}/resolve` 等。 - `GET /client/surgeries/{surgery_id}/result` 仅在存在**至少一条**消耗明细时返回 200;无明细(已开录但尚未记账、已结束但零消耗、或尚无归档等)返回 503 `RESULT_NOT_READY`。 diff --git a/docs/客户端手术通信接口说明.md b/docs/客户端手术通信接口说明.md index 7fd97f6..dfd8028 100644 --- a/docs/客户端手术通信接口说明.md +++ b/docs/客户端手术通信接口说明.md @@ -1,180 +1,47 @@ - - # 手术室监控服务:客户端手术通信接口说明 -面向对接本 FastAPI 服务的客户端(HIS、手麻、工作站等)。字段、状态码与返回模型以 `/docs` 和 `/openapi.json` 为准,本文用于摘要和流程说明。 - -> [!summary] 常用响应模型 -> - `SurgeryApiResponse` -> - `SurgeryResultResponse` -> - `SurgeryPendingConfirmationResponse` -> - 业务错误外形见 `SurgeryClientErrorResponse` -> - 内部演示页:`scripts/demo_client/`(仅供演示,不作为对外契约) - ## 能力概览 -- 探活:`GET /health`,用于检查进程和数据库状态,详见 5.1 节。 -- 开始手术:`POST /client/surgeries/start`,只有在开录确认成功后才返回 `200`。 -- 结束手术:`POST /client/surgeries/end`,只有在停录确认成功后才返回 `200`。 -- 查询结果:`GET /client/surgeries/{surgery_id}/result`,至少存在一条消耗明细时返回 `200`;否则返回 `503`,常见错误码为 `RESULT_NOT_READY`。 -- 待确认播报:`GET /client/surgeries/{surgery_id}/pending-confirmation`,拉取队首低置信度任务,返回话术文本和 MP3 Base64。 -- 待确认答复:`POST /client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve`,上传医生答复的 WAV 录音,服务端完成 ASR 后入账或关闭。该录音与播报音频无关。 - -> [!important] HTTP 约定 -> - `start` 和 `end` 使用 `POST` + `application/json` -> - `result` 使用 `GET` -> - `resolve` 使用 `POST` + `multipart/form-data` -> - `surgery_id` 固定为 6 位数字,正则为 `^\d{6}$` -> - `resolve` 路径中的 `confirmation_id` 必须与待确认接口返回值一致 -> - `camera_ids` 必须与第 2 节清单及运维配置完全一致 +| **能力** | **说明** | +| --------- | -------------------------------------------------------------------------------------------------------------------------------- | +| **探活** | `GET /health`,用于检查进程和数据库状态,详见 5.1 节。 | +| **开始手术** | `POST /client/surgeries/start`,只有在开录确认成功后才返回 `200`。 | +| **结束手术** | `POST /client/surgeries/end`,只有在停录确认成功后才返回 `200`。 | +| **查询结果** | `GET /client/surgeries/{surgery_id}/result`,至少存在一条消耗明细时返回 `200`;否则返回 `503`,常见错误码为 `RESULT_NOT_READY`。 | +| **待确认播报** | `GET /client/surgeries/{surgery_id}/pending-confirmation`,拉取队首低置信度任务,返回话术文本和 MP3 Base64。 | +| **待确认答复** | `POST /client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve`,上传医生答复的 WAV 录音,服务端完成 ASR 后入账或关闭。该录音与播报音频无关。 | ## 1. 服务与基础信息 -- 协议:`HTTP/HTTPS` -- 端口:`38080`,生产环境以实际入口为准 -- 路由:无全局前缀;业务接口位于 `/client/...`,健康检查位于 `/health` -- `start` / `end` 请求体:JSON -- `resolve` 请求体:`multipart/form-data`,字段名为 `audio` -- 在线文档:`/docs`、`/redoc` +| **项目** | **说明** | +| ----------------------- | ------------------------------------------- | +| **协议** | `HTTP/HTTPS` | +| **端口** | `38080`,生产环境以实际入口为准 | +| **路由** | 无全局前缀;业务接口位于 `/client/...`,健康检查位于 `/health` | +| **`start` / `end` 请求体** | JSON | +| **`resolve` 请求体** | `multipart/form-data`,字段名为 `audio` | +| **在线文档** | `/docs`、`/redoc` | ## 2. 摄像头 ID 与 RTSP -RTSP 地址、账号、口令等由客户端对接工程师提供给服务端运维,运维再写入服务端环境(例如 JSON 映射或环境变量)。业务程序不在客户端保存 RTSP,客户端只在 `POST /client/surgeries/start` 中传 `camera_ids`。 +RTSP 地址、账号、口令等由客户端对接工程师提供给服务端运维,运维再写入服务端环境。客户端只在 `POST /client/surgeries/start` 中传 `camera_ids`。 -配置格式示例见 `app/resources/camera_rtsp_urls.sample.json`。配置项细节见 `.env.example` 与 `docs/video-backends.md`。 - -**摄像头映射示例** - -- `or-cam-01` - - RTSP:`rtsp://...`(由现场或 NVR 文档整理后交给运维) - - 备注:术间、机位 -- `or-cam-02` - - RTSP:`...` - - 备注:`...` - -> [!warning] 对接要求 -> - `camera_ids` 必须与运维配置中的 key 完全一致 -> - RTSP 不应硬编码在客户端业务程序中 - -> [!tip] 联调建议 -> - 运维配置完成后,客户端使用上面清单中的 `camera_id` 调用 `start` 验证是否返回 `200` -> - 若返回 `503` 且 `detail.code = RECORDING_CANNOT_START`,优先核对 ID 拼写以及监控服务器侧网络连通性 +| **camera_id** | **RTSP** | **备注** | +| ------------- | -------------------------------- | ------ | +| `or-cam-01` | `rtsp://...`(由现场或 NVR 文档整理后交给运维) | 术间、机位 | +| `or-cam-02` | `...` | `...` | ## 3. HTTP 路由一览 -1. `GET /health`:探活 -2. `POST /client/surgeries/start`:开始手术 -3. `POST /client/surgeries/end`:结束手术 -4. `GET /client/surgeries/{surgery_id}/result`:查询手术结果 -5. `GET /client/surgeries/{surgery_id}/pending-confirmation`:拉取待确认耗材 -6. `POST /client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve`:提交待确认结果(WAV) +| **序号** | **方法** | **路径** | **说明** | +| ------ | ------ | ------------------------------------------------------------------------------- | ------- | +| 1 | `GET` | `/health` | 探活 | +| 2 | `POST` | `/client/surgeries/start` | 开始手术 | +| 3 | `POST` | `/client/surgeries/end` | 结束手术 | +| 4 | `GET` | `/client/surgeries/{surgery_id}/result` | 查询手术结果 | +| 5 | `GET` | `/client/surgeries/{surgery_id}/pending-confirmation` | 拉取待确认耗材 | +| 6 | `POST` | `/client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve` | 提交医生答复 | +| | | | | ## 4. 流程 @@ -182,34 +49,34 @@ RTSP 地址、账号、口令等由客户端对接工程师提供给服务端运 ```mermaid sequenceDiagram - participant Client as 客户系统 - participant API as 监控服务 API + participant Client as 客户端 + participant Server as 服务端 - Client->>API: POST /client/surgeries/start - Note over Client,API: body: surgery_id, camera_ids, candidate_consumables - API-->>Client: 200 accepted(开录已确认) + Client->>Server: POST /client/surgeries/start + Note over Client,Server: body: surgery_id, camera_ids, candidate_consumables + Server-->>Client: 200 accepted(开录已确认) par 术中 loop 轮询结果 - Client->>API: GET .../result - API-->>Client: 200 或 503 RESULT_NOT_READY + Client->>Server: GET .../result + Server-->>Client: 200 或 503 RESULT_NOT_READY end - loop 轮询待确认(若启用) - Client->>API: GET .../pending-confirmation - API-->>Client: 200 或 404 + loop 轮询待确认 + Client->>Server: GET .../pending-confirmation + Server-->>Client: 200 或 404 opt 有待确认 Client->>Client: 播放 prompt_audio_mp3_base64 - Client->>API: POST .../resolve(multipart audio) - API-->>Client: 200 accepted + Client->>Server: POST .../resolve(multipart audio) + Server-->>Client: 200 accepted end end end - Client->>API: POST /client/surgeries/end - API-->>Client: 200 accepted(停录已确认) + Client->>Server: POST /client/surgeries/end + Server-->>Client: 200 accepted(停录已确认) - Client->>API: GET .../result - API-->>Client: 200(持久化后可查时返回) + Client->>Server: GET .../result + Server-->>Client: 200(持久化后可查时返回) ``` ### 4.2 状态图 @@ -226,21 +93,20 @@ flowchart LR ## 5. 接口详情 -以下按“基本信息 -> 请求 -> 响应 -> 状态码”组织,与 OpenAPI 中 `tags: client` 和 `health` 一致。 -### 5.0 通用约定 +**路径参数 `surgery_id`** -**路径参数 `surgery_id`** - -- 长度:固定 `6` -- 字符集:仅数字 -- 正则:`^\d{6}$` +| **约束项** | **说明** | +| ------- | --------- | +| **长度** | 固定 `6` | +| **字符集** | 仅数字 | +| **正则** | `^\d{6}$` | **业务错误响应** -多数业务失败在 `4xx` 或 `5xx` 下返回如下 JSON: +多数业务失败在 `4xx` 或 `5xx` 下返回如下 JSON: -```json +``` { "detail": { "code": "错误码字符串", @@ -254,69 +120,63 @@ flowchart LR **基本信息** -- 方法:`GET` -- 路径:`/health` -- 请求体:无 +| **项目** | **内容** | +| ------- | --------- | +| **方法** | `GET` | +| **路径** | `/health` | +| **请求体** | 无 | **响应说明** -- `200` - - 说明:进程正常且数据库可连通 - - 响应体示例:`{"status":"ok","database":"connected"}` -- `503` - - 说明:数据库不可用(降级) - - 响应体示例:`{"status":"degraded","database":"unavailable"}` +| **HTTP** | **说明** | **响应体示例** | +| -------- | ----------- | ------------------------------------------------ | +| `200` | 进程正常且数据库可连通 | `{"status":"ok","database":"connected"}` | +| `503` | 数据库不可用(降级) | `{"status":"degraded","database":"unavailable"}` | ### 5.2 开始手术 **基本信息** -- 方法:`POST` -- 路径:`/client/surgeries/start` -- Content-Type:`application/json; charset=utf-8` +| **项目** | **内容** | +| ---------------- | --------------------------------- | +| **方法** | `POST` | +| **路径** | `/client/surgeries/start` | +| **Content-Type** | `application/json; charset=utf-8` | **业务说明** -- 服务端会为 `camera_ids` 中的每个摄像头建立拉流与推理任务,只有在确认开录成功(如首帧就绪)后才返回 HTTP `200` -- 若同一 `surgery_id` 存在尚未落库的历史归档,服务端会先尝试写入数据库;失败时可能返回 `503`(如 `RECORDING_CANNOT_START`),以避免静默丢数据 -- `candidate_consumables` 为空时,服务端会展开为目录 Excel 中的全部商品名,或在未配置目录时展开为分类模型的全部类名 +- 服务端会为 `camera_ids` 中的每个摄像头建立拉流与推理任务,只有在确认开录成功(如首帧就绪)后才返回 HTTP `200`。 + +- `candidate_consumables` 为空时,服务端会展开为目录中的全部耗材名。 + **请求体(JSON)** -- `surgery_id` - - 类型:`string` - - 必填:是 - - 说明:6 位数字,与路径规则一致 -- `camera_ids` - - 类型:`string[]` - - 必填:是 - - 说明:至少 1 个;必须与运维配置的摄像头 ID 完全一致,见第 2 节 -- `candidate_consumables` - - 类型:`string[]` - - 必填:否 - - 说明:非空时仅这些名称参与自动记账与待确认;缺省或 `[]` 时使用全部候选 +| **字段** | **类型** | **必填** | **说明** | +| ----------------------- | ---------- | ------ | ----------------------------------- | +| `surgery_id` | `string` | 是 | 6 位数字 | +| `camera_ids` | `string[]` | 是 | 至少 1 个;必须与运维配置的摄像头 ID 完全一致,见第 2 节 | +| `candidate_consumables` | `string[]` | 否 | 非空时仅这些名称参与自动记账与待确认;缺省或 `[]` 时使用全部候选 | **响应体(200)** -- `surgery_id` - - 类型:`string` - - 说明:与请求一致 -- `status` - - 类型:`string` - - 说明:成功时通常为 `accepted` -- `message` - - 类型:`string` - - 说明:说明文案 +| **字段** | **类型** | **说明** | +| ------------ | -------- | ----------------- | +| `surgery_id` | `string` | 与请求一致 | +| `status` | `string` | 成功时通常为 `accepted` | +| `message` | `string` | 说明文案 | **状态码** -- `200`:开录已确认 -- `422`:参数校验失败,例如 `surgery_id` 非 6 位或 `camera_ids` 为空数组 -- `503`:开录未确认或录制子系统故障;`detail.code` 常见为 `RECORDING_CANNOT_START` +| **HTTP** | **说明** | +| -------- | -------------------------------------------------------- | +| `200` | 开录已确认 | +| `422` | 参数校验失败,例如 `surgery_id` 非 6 位或 `camera_ids` 为空数组 | +| `503` | 开录未确认或录制子系统故障;`detail.code` 常见为 `RECORDING_CANNOT_START` | **请求示例** -```json +``` { "surgery_id": "123456", "camera_ids": ["or-cam-01", "or-cam-02"], @@ -326,7 +186,7 @@ flowchart LR **响应示例(200)** -```json +``` { "surgery_id": "123456", "status": "accepted", @@ -338,34 +198,37 @@ flowchart LR **基本信息** -- 方法:`POST` -- 路径:`/client/surgeries/end` -- Content-Type:`application/json; charset=utf-8` +| **项目** | **内容** | +| ---------------- | --------------------------------- | +| **方法** | `POST` | +| **路径** | `/client/surgeries/end` | +| **Content-Type** | `application/json; charset=utf-8` | **业务说明** -停止该 `surgery_id` 关联的全部摄像头任务,只有在确认停录完成后才返回 `200`。 +停止该 `surgery_id` 关联的全部摄像头任务,只有在确认停录完成后才返回 `200`。 **请求体(JSON)** -- `surgery_id` - - 类型:`string` - - 必填:是 - - 说明:6 位数字 +| **字段** | **类型** | **必填** | **说明** | +| ------------ | -------- | ------ | ------ | +| `surgery_id` | `string` | 是 | 6 位数字 | **响应体(200)** -字段含义与 5.2 节一致,`message` 示例为 `摄像头录制已停止,手术已结束。` +字段含义与 5.2 节一致,`message` 示例为 `摄像头录制已停止,手术已结束。` **状态码** -- `200`:停录已确认 -- `422`:参数校验失败 -- `503`:停录未确认或故障;`detail.code` 常见为 `RECORDING_NOT_STOPPED` +| **HTTP** | **说明** | +| -------- | -------------------------------------------------- | +| `200` | 停录已确认 | +| `422` | 参数校验失败 | +| `503` | 停录未确认或故障;`detail.code` 常见为 `RECORDING_NOT_STOPPED` | **请求示例** -```json +``` { "surgery_id": "123456" } @@ -375,91 +238,78 @@ flowchart LR **基本信息** -- 方法:`GET` -- 路径:`/client/surgeries/{surgery_id}/result` -- 路径参数:`surgery_id` -- 请求体:无 +| **项目** | **内容** | +| -------- | --------------------------------------- | +| **方法** | `GET` | +| **路径** | `/client/surgeries/{surgery_id}/result` | +| **路径参数** | `surgery_id` | +| **请求体** | 无 | **业务说明** -- 仅当存在至少一条消耗明细时返回 `200` -- 无明细(包括已归档但零消耗)、手术未开始、未成功开录或当前尚不可查时,返回 `503` -- 上述 `503` 场景的常见错误码为 `RESULT_NOT_READY` +- 仅当存在至少一条消耗明细时返回 `200`。 + +- 无明细(包括已归档但零消耗)、手术未开始、未成功开录或当前尚不可查时,返回 `503`。 + +- 上述 `503` 场景的常见错误码为 `RESULT_NOT_READY`。 + **响应体(200)** -- `surgery_id` - - 类型:`string` - - 说明:手术号 -- `status` - - 类型:`string` - - 说明:成功时通常为 `completed` -- `message` - - 类型:`string` - - 说明:说明 -- `details` - - 类型:`array` - - 说明:消耗明细列表,字段见下文 -- `summary` - - 类型:`array` - - 说明:按 `item_id` 汇总的结果,字段见下文 +| **字段** | **类型** | **说明** | +| ------------ | -------- | ----------------------- | +| `surgery_id` | `string` | 手术号 | +| `status` | `string` | 成功时通常为 `completed` | +| `message` | `string` | 说明 | +| `details` | `array` | 消耗明细列表,字段见下文 | +| `summary` | `array` | 按 `item_id` 汇总的结果,字段见下文 | -**`details[]` 元素** +**`details[]` 元素** -- `item_id` - - 类型:`string` - - 说明:物品 ID;有目录时多为产品编码,否则通常与名称或模型类名一致 -- `item_name` - - 类型:`string` - - 说明:物品名称 -- `qty` - - 类型:`integer` - - 说明:本条记录数量,当前恒为 `1`;一次识别或一次人工确认只追加一条明细 -- `doctor_id` - - 类型:`string` - - 说明:记账关联的医生或系统标识 -- `timestamp` - - 类型:`string` - - 说明:ISO 8601 时间(`date-time`) +| **字段** | **类型** | **说明** | +| ----------- | --------- | ---------------------------------- | +| `item_id` | `string` | 物品 ID;有目录时多为产品编码,否则通常与名称或模型类名一致 | +| `item_name` | `string` | 物品名称 | +| `qty` | `integer` | 本条记录数量,当前恒为 `1`;一次识别或一次人工确认只追加一条明细 | +| `doctor_id` | `string` | 记账关联的医生或系统标识 | +| `timestamp` | `string` | ISO 8601 时间(`date-time`) | -**`summary[]` 元素** +**`summary[]` 元素** -- `item_id` - - 类型:`string` - - 说明:与明细一致 -- `item_name` - - 类型:`string` - - 说明:名称,通常取该 `item_id` 首条明细中的名称 -- `total_quantity` - - 类型:`integer` - - 说明:该物品在本台手术中的合计数量,`>= 0` +| **字段** | **类型** | **说明** | +| ---------------- | --------- | -------------------------- | +| `item_id` | `string` | 与明细一致 | +| `item_name` | `string` | 名称,通常取该 `item_id` 首条明细中的名称 | +| `total_quantity` | `integer` | 该物品在本台手术中的合计数量,`>= 0` | **状态码** -- `200`:至少有一条明细 -- `422`:`surgery_id` 路径不符合约束 -- `503`:`RESULT_NOT_READY`,当前无可用明细或不可查 +| **HTTP** | **说明** | +| -------- | ------------------------------ | +| `200` | 至少有一条明细 | +| `422` | `surgery_id` 路径不符合约束 | +| `503` | `RESULT_NOT_READY`,当前无可用明细或不可查 | **响应示例(200)** -```json +``` { "surgery_id": "123456", "status": "completed", "message": "查询成功。", "details": [ { - "item_id": "HC001", - "item_name": "纱布", + "item_id": "19246-3-14", + "item_name": "医用纱布敷料", "qty": 1, - "doctor_id": "D1001", + "doctor_id": "6611", "timestamp": "2026-04-21T10:30:00+08:00" } ], "summary": [ { - "item_id": "HC001", - "item_name": "纱布", + "item_id": "19246-3-14", + "item_name": "医用纱布敷料", "total_quantity": 1 } ] @@ -470,83 +320,73 @@ flowchart LR **基本信息** -- 方法:`GET` -- 路径:`/client/surgeries/{surgery_id}/pending-confirmation` -- 路径参数:`surgery_id` -- 请求体:无 +| **项目** | **内容** | +| -------- | ----------------------------------------------------- | +| **方法** | `GET` | +| **路径** | `/client/surgeries/{surgery_id}/pending-confirmation` | +| **路径参数** | `surgery_id` | +| **请求体** | 无 | **业务说明** -- 返回当前 FIFO 队首的一条低置信度识别任务 -- `prompt_audio_mp3_base64` 与 `prompt_text` 内容一致,为标准 Base64 的 MP3 字符串(无换行) -- 客户端解码后应按 `audio/mpeg` 播放 +- 返回当前 FIFO 队首的一条低置信度识别任务。 + +- `prompt_audio_mp3_base64` 与 `prompt_text` 内容一致,为标准 Base64 的 MP3 字符串(无换行)。 + +- 客户端解码后应按 `audio/mpeg` 播放。 + **响应体(200)** -- `surgery_id` - - 类型:`string` - - 说明:手术号 -- `confirmation_id` - - 类型:`string` - - 说明:待确认项 ID;提交 5.6 节接口时原样放入路径 -- `prompt_text` - - 类型:`string` - - 说明:播报或展示用语,与 MP3 内容一致 -- `prompt_audio_mp3_base64` - - 类型:`string` - - 说明:MP3 的 Base64 -- `options` - - 类型:`array` - - 说明:候选项列表,字段见下文 -- `model_top1_label` - - 类型:`string` - - 说明:模型原始 Top1 类名,可能不在本台候选内 -- `model_top1_confidence` - - 类型:`number` - - 说明:Top1 置信度 -- `created_at` - - 类型:`string` - - 说明:创建时间(ISO 8601) +| **字段** | **类型** | **说明** | +| ------------------------- | -------- | ------------------------- | +| `surgery_id` | `string` | 手术号 | +| `confirmation_id` | `string` | 待确认项 ID;提交 5.6 节接口时原样放入路径 | +| `prompt_text` | `string` | 播报或展示用语,与 MP3 内容一致 | +| `prompt_audio_mp3_base64` | `string` | MP3 的 Base64 | +| `options` | `array` | 候选项列表,字段见下文 | +| `model_top1_label` | `string` | 模型原始 Top1 类名,可能不在本台候选内 | +| `model_top1_confidence` | `number` | Top1 置信度 | +| `created_at` | `string` | 创建时间(ISO 8601) | -**`options[]` 元素** +**`options[]` 元素** -- `label` - - 类型:`string` - - 说明:展示给医生的选项名称 -- `confidence` - - 类型:`number` - - 说明:该选项对应的置信度 +| **字段** | **类型** | **说明** | +| ------------ | -------- | ---------- | +| `label` | `string` | 展示给医生的选项名称 | +| `confidence` | `number` | 该选项对应的置信度 | **状态码** -- `200`:当前有一条待确认 -- `404`:无待确认或手术未活跃;常见错误码为 `NO_PENDING_CONFIRMATION` -- `422`:例如话术为空导致无法 TTS;错误码见响应,如 `TTS_TEXT_EMPTY` -- `503`:语音服务未配置或 TTS 失败;例如 `BAIDU_NOT_CONFIGURED`、`TTS_ERROR` +| **HTTP** | **说明** | +| -------- | ----------------------------------------------------- | +| `200` | 当前有一条待确认 | +| `404` | 无待确认或手术未活跃;常见错误码为 `NO_PENDING_CONFIRMATION` | +| `422` | 例如话术为空导致无法 TTS;错误码见响应,如 `TTS_TEXT_EMPTY` | +| `503` | 语音服务未配置或 TTS 失败;例如 `BAIDU_NOT_CONFIGURED`、`TTS_ERROR` | ### 5.6 提交待确认结果(医生语音) **基本信息** -- 方法:`POST` -- 路径:`/client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve` -- Content-Type:`multipart/form-data` +| **项目** | **内容** | +| ---------------- | ------------------------------------------------------------------------------- | +| **方法** | `POST` | +| **路径** | `/client/surgeries/{surgery_id}/pending-confirmation/{confirmation_id}/resolve` | +| **Content-Type** | `multipart/form-data` | **路径参数** -- `surgery_id` - - 约束:6 位数字 - - 说明:同 5.0 节 -- `confirmation_id` - - 约束:长度 1 到 128 - - 说明:与 5.5 节响应中的 `confirmation_id` 一致 +| **参数** | **约束** | **说明** | +| ----------------- | ---------- | -------------------------------- | +| `surgery_id` | 6 位数字 | 同 5.0 节 | +| `confirmation_id` | 长度 1 到 128 | 与 5.5 节响应中的 `confirmation_id` 一致 | **请求体(multipart)** -- `audio` - - 类型:`file` - - 必填:是 - - 说明:单个 `.wav` 文件;建议使用 16 kHz 单声道 PCM;非 `.wav` 扩展名会返回 `422` +| **字段名** | **类型** | **必填** | **说明** | +| ------- | ------ | ------ | ------------------------------------------------------ | +| `audio` | `file` | 是 | 单个 `.wav` 文件;建议使用 16 kHz 单声道 PCM;非 `.wav` 扩展名会返回 `422` | **业务说明** @@ -554,43 +394,31 @@ flowchart LR **响应体(200)** -- `surgery_id` - - 类型:`string` - - 说明:手术号 -- `confirmation_id` - - 类型:`string` - - 说明:待确认 ID -- `status` - - 类型:`string` - - 说明:成功时为 `accepted` -- `message` - - 类型:`string` - - 说明:说明 -- `resolved_label` - - 类型:`string | null` - - 说明:确认后的耗材名称;否认全部候选时为 `null` -- `rejected` - - 类型:`boolean` - - 说明:是否否认全部候选,不记消耗时为 `true` -- `asr_text` - - 类型:`string | null` - - 说明:语音识别文本 -- `audio_object_key` - - 类型:`string | null` - - 说明:对象存储中的原始 WAV 键,便于追溯 +| **字段** | **类型** | **说明** | +| ------------------ | ---------------- | ------------------------ | +| `surgery_id` | `string` | 手术号 | +| `confirmation_id` | `string` | 待确认 ID | +| `status` | `string` | 成功时为 `accepted` | +| `message` | `string` | 说明 | +| `resolved_label` | `string \| null` | 确认后的耗材名称;否认全部候选时为 `null` | +| `rejected` | `boolean` | 是否否认全部候选,不记消耗时为 `true` | +| `asr_text` | `string \| null` | 语音识别文本 | +| `audio_object_key` | `string \| null` | 对象存储中的原始 WAV 键,便于追溯 | **状态码** -- `200`:已受理并完成解析 -- `404`:确认项不存在或手术未活跃;例如 `CONFIRMATION_NOT_FOUND` -- `409`:当前确认项已处理过;例如 `CONFIRMATION_ALREADY_RESOLVED` -- `422`:空文件、非 `.wav`、`VOICE_AUDIO_INVALID`、ASR/解析失败等,具体错误码见响应 -- `503`:MinIO、百度等依赖不可用;例如 `MINIO_NOT_CONFIGURED`、`MINIO_UPLOAD_FAILED`、`BAIDU_NOT_CONFIGURED` +| **HTTP** | **说明** | +| -------- | ------------------------------------------------------------------------------------- | +| `200` | 已受理并完成解析 | +| `404` | 确认项不存在或手术未活跃;例如 `CONFIRMATION_NOT_FOUND` | +| `409` | 当前确认项已处理过;例如 `CONFIRMATION_ALREADY_RESOLVED` | +| `422` | 空文件、非 `.wav`、`VOICE_AUDIO_INVALID`、ASR/解析失败等,具体错误码见响应 | +| `503` | MinIO、百度等依赖不可用;例如 `MINIO_NOT_CONFIGURED`、`MINIO_UPLOAD_FAILED`、`BAIDU_NOT_CONFIGURED` | **cURL 示例** -```bash +``` curl -sS -X POST \ "http://<主机>:38080/client/surgeries/123456/pending-confirmation//resolve" \ -F "audio=@/path/to/voice.wav;type=audio/wav" -``` +``` \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index a75c5e1..3cc0fdc 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -11,7 +11,6 @@ dependencies = [ "chardet>=7.4.3", "fastapi>=0.136.0", "loguru>=0.7.3", - "openpyxl>=3.1.5", "pillow>=12.2.0", "pydantic-settings>=2.13.1", "python-multipart>=0.0.26", @@ -19,6 +18,7 @@ dependencies = [ "ultralytics>=8.4.40", "uvicorn[standard]>=0.44.0", "rich>=15.0.0", + "pyyaml>=6.0.3", ] [project.scripts] diff --git a/tests/test_effective_candidate_consumables.py b/tests/test_effective_candidate_consumables.py index 324d172..8966e3a 100644 --- a/tests/test_effective_candidate_consumables.py +++ b/tests/test_effective_candidate_consumables.py @@ -1,4 +1,4 @@ -"""effective_candidate_consumables:空请求时回退到目录或模型类名。""" +"""effective_candidate_consumables / build_name_mapping:YAML 与分类模型,无 Excel。""" from __future__ import annotations @@ -6,7 +6,6 @@ from pathlib import Path from unittest.mock import MagicMock import pytest -from openpyxl import Workbook from app.config import Settings from app.services.consumable_vision_algorithm import ConsumableVisionAlgorithmService @@ -18,34 +17,64 @@ def test_effective_preserves_non_empty_request() -> None: assert got == ["纱布", "缝线"] -def test_effective_empty_uses_model_class_names(monkeypatch: pytest.MonkeyPatch) -> None: - svc = ConsumableVisionAlgorithmService(Settings()) +def test_effective_empty_uses_model_when_yaml_has_no_names( + tmp_path: Path, monkeypatch: pytest.MonkeyPatch +) -> None: + yml = tmp_path / "empty.yaml" + yml.write_text("names: {}\nlabel_id: {}\n", encoding="utf-8") + s = Settings(consumable_classifier_labels_yaml_path=str(yml)) + svc = ConsumableVisionAlgorithmService(s) mock_cls = MagicMock() mock_cls.names = {0: "ban", 1: "apple"} monkeypatch.setattr(svc, "_get_cls", lambda: mock_cls) assert svc.effective_candidate_consumables([]) == ["apple", "ban"] -def test_effective_empty_prefers_catalog_xlsx(tmp_path: Path) -> None: - xlsx = tmp_path / "cat.xlsx" - wb = Workbook() - ws = wb.active - ws.append(["产品编码", "商品名称"]) - ws.append(["C1", "商品乙"]) - ws.append(["C2", "商品甲"]) - wb.save(xlsx) - - settings = Settings(consumable_catalog_xlsx_path=str(xlsx)) - svc = ConsumableVisionAlgorithmService(settings) - got = svc.effective_candidate_consumables([]) - assert got == ["商品乙", "商品甲"] +def test_effective_empty_prefers_yaml_class_names(tmp_path: Path) -> None: + yml = tmp_path / "lab.yaml" + yml.write_text( + "names:\n 0: 商品甲\n 1: 商品乙\nlabel_id:\n 0: a\n 1: b\n", + encoding="utf-8", + ) + s = Settings(consumable_classifier_labels_yaml_path=str(yml)) + svc = ConsumableVisionAlgorithmService(s) + assert svc.effective_candidate_consumables([]) == ["商品甲", "商品乙"] def test_effective_whitespace_only_treated_as_empty( - monkeypatch: pytest.MonkeyPatch, + tmp_path: Path, monkeypatch: pytest.MonkeyPatch ) -> None: - svc = ConsumableVisionAlgorithmService(Settings()) + yml = tmp_path / "empty.yaml" + yml.write_text("names: {}\nlabel_id: {}\n", encoding="utf-8") + s = Settings(consumable_classifier_labels_yaml_path=str(yml)) + svc = ConsumableVisionAlgorithmService(s) mock_cls = MagicMock() mock_cls.names = {0: "x"} monkeypatch.setattr(svc, "_get_cls", lambda: mock_cls) assert svc.effective_candidate_consumables(["", " "]) == ["x"] + + +def test_build_name_mapping_from_label_id(tmp_path: Path) -> None: + yml = tmp_path / "lab.yaml" + yml.write_text( + "names:\n 0: 商品A\nlabel_id:\n 0: y1/y2\n", + encoding="utf-8", + ) + s = Settings(consumable_classifier_labels_yaml_path=str(yml)) + svc = ConsumableVisionAlgorithmService(s) + m = svc.build_name_mapping(["商品A"]) + assert m["商品A"] == "y1/y2" + + +def test_build_name_mapping_uses_name_when_no_id_in_yaml( + tmp_path: Path, +) -> None: + yml = tmp_path / "lab.yaml" + yml.write_text( + "names:\n 0: 仅表内有的\nlabel_id: {}\n", + encoding="utf-8", + ) + s = Settings(consumable_classifier_labels_yaml_path=str(yml)) + svc = ConsumableVisionAlgorithmService(s) + m = svc.build_name_mapping(["仅表内有的"]) + assert m["仅表内有的"] == "仅表内有的" diff --git a/uv.lock b/uv.lock index f441e58..a774c76 100644 --- a/uv.lock +++ b/uv.lock @@ -302,7 +302,7 @@ name = "colorama" version = "0.4.6" source = { registry = "https://download.pytorch.org/whl/cpu" } wheels = [ - { url = "https://download.pytorch.org/whl/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6" }, + { url = "https://download.pytorch.org/whl/colorama-0.4.6-py2.py3-none-any.whl", hash = "sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6", upload-time = "2023-10-05T23:50:34Z" }, ] [[package]] @@ -369,15 +369,6 @@ 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" @@ -567,7 +558,7 @@ dependencies = [ { name = "markupsafe" }, ] wheels = [ - { url = "https://download.pytorch.org/whl/jinja2-3.1.6-py3-none-any.whl" }, + { url = "https://download.pytorch.org/whl/jinja2-3.1.6-py3-none-any.whl", upload-time = "2025-10-14T18:38:59Z" }, ] [[package]] @@ -679,22 +670,22 @@ name = "markupsafe" version = "3.0.3" source = { registry = "https://download.pytorch.org/whl/cpu" } wheels = [ - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314-win_amd64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl" }, - { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313-macosx_11_0_arm64.whl", upload-time = "2026-03-27T13:54:35Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = "2026-03-27T13:54:37Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = "2026-03-27T13:54:37Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313-win_amd64.whl", upload-time = "2026-03-27T13:54:39Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313t-macosx_11_0_arm64.whl", upload-time = "2026-03-27T13:54:39Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = "2026-03-27T13:54:39Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = "2026-03-27T13:54:41Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp313-cp313t-win_amd64.whl", upload-time = "2026-03-27T13:54:41Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314-macosx_11_0_arm64.whl", upload-time = "2026-02-23T16:46:37Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = "2026-02-23T16:46:37Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = "2026-02-23T16:46:37Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314-win_amd64.whl", upload-time = "2026-02-23T16:46:37Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314t-macosx_11_0_arm64.whl", upload-time = "2026-02-23T16:46:39Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", upload-time = "2026-02-23T16:46:41Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", upload-time = "2026-02-23T16:46:41Z" }, + { url = "https://download.pytorch.org/whl/markupsafe-3.0.3-cp314-cp314t-win_amd64.whl", upload-time = "2026-02-23T16:46:41Z" }, ] [[package]] @@ -855,18 +846,6 @@ 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" @@ -879,10 +858,10 @@ dependencies = [ { name = "greenlet" }, { name = "loguru" }, { name = "minio" }, - { name = "openpyxl" }, { name = "pillow" }, { name = "pydantic-settings" }, { name = "python-multipart" }, + { name = "pyyaml" }, { name = "rich" }, { name = "sqlalchemy" }, { name = "ultralytics" }, @@ -907,10 +886,10 @@ 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 = "pillow", specifier = ">=12.2.0" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "python-multipart", specifier = ">=0.0.26" }, + { name = "pyyaml", specifier = ">=6.0.3" }, { name = "rich", specifier = ">=15.0.0" }, { name = "sqlalchemy", specifier = ">=2.0.49" }, { name = "ultralytics", specifier = ">=8.4.40" }, @@ -1472,10 +1451,10 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:442ec9dc78592564fdad69cf0beaa9da2f82ab810ccb4f13903869a90bf3f15d" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cc3a195701bba2239c313ee311487f80f8aaebe9e89b9073dddbcf2f93b5a0ba" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:072a0d6e4865e8b0dc0dbfe6ebed68fae235124222835ef03e5814d414d8c012" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23ec7789017da9d95b6d543d790814785e6f30905c5443efa8257d1490d73f79" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:442ec9dc78592564fdad69cf0beaa9da2f82ab810ccb4f13903869a90bf3f15d", upload-time = "2026-03-23T15:17:02Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:cc3a195701bba2239c313ee311487f80f8aaebe9e89b9073dddbcf2f93b5a0ba", upload-time = "2026-03-23T15:17:06Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:072a0d6e4865e8b0dc0dbfe6ebed68fae235124222835ef03e5814d414d8c012", upload-time = "2026-03-23T15:17:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:23ec7789017da9d95b6d543d790814785e6f30905c5443efa8257d1490d73f79", upload-time = "2026-03-23T15:17:14Z" }, ] [[package]] @@ -1498,22 +1477,22 @@ dependencies = [ { name = "typing-extensions", marker = "sys_platform != 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-linux_s390x.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-linux_s390x.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-linux_s390x.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-win_amd64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-linux_s390x.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-win_amd64.whl" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-linux_s390x.whl", upload-time = "2026-03-23T14:59:04Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:04Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:05Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313-win_amd64.whl", upload-time = "2026-03-23T14:59:06Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-linux_s390x.whl", upload-time = "2026-03-23T14:59:07Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:07Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:07Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp313-cp313t-win_amd64.whl", upload-time = "2026-03-23T14:59:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-linux_s390x.whl", upload-time = "2026-03-23T14:59:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:10Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:11Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314-win_amd64.whl", upload-time = "2026-03-23T14:59:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-linux_s390x.whl", upload-time = "2026-03-23T14:59:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", upload-time = "2026-03-23T14:59:12Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", upload-time = "2026-03-23T14:59:13Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torch-2.11.0%2Bcpu-cp314-cp314t-win_amd64.whl", upload-time = "2026-03-23T14:59:15Z" }, ] [[package]] @@ -1530,10 +1509,10 @@ dependencies = [ { name = "torch", version = "2.11.0", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform == 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5d63dd43162691258b1b3529b9041bac7d54caa37eae0925f997108268cbf7c4" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:358fc4726d0c08615b6d83b3149854f11efb2a564ed1acb6fce882e151412d23" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:eb61804eb9dbe88c5a2a6c4da8dec1d80d2d0a6f18c999c524e32266cb1ebcd3" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:b7d3e295624a28b3b1769228ce1345d94cf4d390dd31136766f76f2d20f718da" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0-cp313-cp313-macosx_12_0_arm64.whl", hash = "sha256:5d63dd43162691258b1b3529b9041bac7d54caa37eae0925f997108268cbf7c4", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0-cp313-cp313t-macosx_12_0_arm64.whl", hash = "sha256:358fc4726d0c08615b6d83b3149854f11efb2a564ed1acb6fce882e151412d23", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0-cp314-cp314-macosx_12_0_arm64.whl", hash = "sha256:eb61804eb9dbe88c5a2a6c4da8dec1d80d2d0a6f18c999c524e32266cb1ebcd3", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0-cp314-cp314t-macosx_12_0_arm64.whl", hash = "sha256:b7d3e295624a28b3b1769228ce1345d94cf4d390dd31136766f76f2d20f718da", upload-time = "2026-03-23T15:36:09Z" }, ] [[package]] @@ -1552,18 +1531,18 @@ dependencies = [ { name = "torch", version = "2.11.0+cpu", source = { registry = "https://download.pytorch.org/whl/cpu" }, marker = "sys_platform != 'darwin'" }, ] wheels = [ - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2e932af123a39137815dfd152c64cc683fa7cbd327c965e807c9728c7aa4971a" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:16c4f11eda096dc377e82238c8ebb26c7013622c0f1b2c4dcf85fc70f96c0ea7" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:34ac55a1f614baca2e0f5cef20ddb36184ee3503423871260e1ddd72caf9cb5f" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:3d30ce3444698807d4b18b199645cd7a95e0b16a4cd0909b8aab47c562a7673a" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:870a97101168d4da68039d3d51f0c781047065e82dc4c19b2eb0ddff08486180" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:050aaf28cff9c2981ec72dc3f9b4ef77bcf9c9c99330ce426cb06c5bb9e6e726" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:78576c8d5a8665de6caaa6e7c3a3fb7caa5dc112032ba60e129a9e78a446a03b" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:78e88d0a57bfadcd17042aa92fe4dd1059e48fcaa2e54a10ac7f438c2eca10d5" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:93144d0997c51b27996c8305df4d9104efb0d38c9a9b6b05c8bc20ebdf7193b5" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:93a11b159613ad920b1d42c4eb4e585f48e5dff895f3e08f517ef482fe84e130" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:99f86ec0a83b9e4b5428a452bf667f99a9ae27d4c32bd4b2081fe917303e7710" }, - { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:6139108231a29ffb607931360ee24594553a939467c65530f734a2ed9918f011" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:2e932af123a39137815dfd152c64cc683fa7cbd327c965e807c9728c7aa4971a", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:16c4f11eda096dc377e82238c8ebb26c7013622c0f1b2c4dcf85fc70f96c0ea7", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313-win_amd64.whl", hash = "sha256:34ac55a1f614baca2e0f5cef20ddb36184ee3503423871260e1ddd72caf9cb5f", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313t-manylinux_2_28_aarch64.whl", hash = "sha256:3d30ce3444698807d4b18b199645cd7a95e0b16a4cd0909b8aab47c562a7673a", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313t-manylinux_2_28_x86_64.whl", hash = "sha256:870a97101168d4da68039d3d51f0c781047065e82dc4c19b2eb0ddff08486180", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp313-cp313t-win_amd64.whl", hash = "sha256:050aaf28cff9c2981ec72dc3f9b4ef77bcf9c9c99330ce426cb06c5bb9e6e726", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:78576c8d5a8665de6caaa6e7c3a3fb7caa5dc112032ba60e129a9e78a446a03b", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:78e88d0a57bfadcd17042aa92fe4dd1059e48fcaa2e54a10ac7f438c2eca10d5", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314-win_amd64.whl", hash = "sha256:93144d0997c51b27996c8305df4d9104efb0d38c9a9b6b05c8bc20ebdf7193b5", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314t-manylinux_2_28_aarch64.whl", hash = "sha256:93a11b159613ad920b1d42c4eb4e585f48e5dff895f3e08f517ef482fe84e130", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314t-manylinux_2_28_x86_64.whl", hash = "sha256:99f86ec0a83b9e4b5428a452bf667f99a9ae27d4c32bd4b2081fe917303e7710", upload-time = "2026-03-23T15:36:09Z" }, + { url = "https://download-r2.pytorch.org/whl/cpu/torchvision-0.26.0%2Bcpu-cp314-cp314t-win_amd64.whl", hash = "sha256:6139108231a29ffb607931360ee24594553a939467c65530f734a2ed9918f011", upload-time = "2026-03-23T15:36:09Z" }, ] [[package]]