Files
operating-room-monitor-server/app/config.py
Kevin 0c05463617 feat: 语音确认、联调与运维增强
- 语音:序数解析(第一个/第二个等)、解析失败计数与 API detail.retry_remaining;
  百度 ASR 固定 dev_pid 为普通话;SurgeryPipelineError 支持 extra 并入 HTTP detail。
- Demo:demo 路由与假 RTSP、客户端 index 与 README;BackendResolver 与配置调整。
- 可观测:消耗 TSV 日志、语音文件日志、终端 Markdown 辅助;相关测试与依赖更新。
- 注意:.env 仍被 gitignore,本地密钥不会进入本提交。

Made-with: Cursor
2026-04-23 14:24:20 +08:00

285 lines
15 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import json
from pathlib import Path
from urllib.parse import quote_plus
from typing import Any, Literal
from pydantic import Field, field_validator
from pydantic_settings import BaseSettings, SettingsConfigDict
_PACKAGE_DIR = Path(__file__).resolve().parent
# 仓库根目录(含 .env。用绝对路径读 .env避免从子目录/IDE 启动时 cwd 不同导致联调项未生效。
_REPO_ROOT = _PACKAGE_DIR.parent
_DEFAULT_ENV_FILE = _REPO_ROOT / ".env"
def _default_consumable_classifier_weights() -> str:
"""耗材识别与分类YOLO-cls`app/resources/consumable_classifier.pt`。"""
return str(_PACKAGE_DIR / "resources" / "consumable_classifier.pt")
def _default_camera_rtsp_urls_sample_path() -> str:
"""示例映射路径(可复制为自有 `camera_rtsp_urls.json` 后在环境变量中引用)。"""
return str(_PACKAGE_DIR / "resources" / "camera_rtsp_urls.sample.json")
class Settings(BaseSettings):
"""Application configuration loaded from environment / .env."""
database_url: str | None = None
postgres_user: str = "postgres"
postgres_password: str = "postgres"
postgres_db: str = "operation_room"
postgres_host: str = "localhost"
postgres_port: int = 35432
consumable_classifier_weights: str | None = None
consumable_classifier_imgsz: int = 224
#: 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
#: 耗材分类 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)
#: 两次尝试之间的等待秒数。
surgery_recording_retry_delay_seconds: float = Field(default=1.0, ge=0.0, le=60.0)
# --- 视频RTSP / 海康 SDK 双后端 ---
#: 默认后端:`rtsp` | `hikvision_sdk` | `auto`autoSDK 动态库可用且 HIKVISION_SDK_ENABLED 时优先 SDK
video_default_backend: Literal["rtsp", "hikvision_sdk", "auto"] = "rtsp"
#: 按摄像头覆盖后端JSON 对象,例如 `{"or-cam-01":"rtsp","or-cam-02":"hikvision_sdk"}`。
video_camera_backend_overrides_json: str = ""
#: 单 URL 模板,例如 `rtsp://user:pass@192.168.1.64:554/Streaming/Channels/101`(可用 `{camera_id}`)。
video_rtsp_url_template: str = ""
#: 每路 RTSP 完整 URLJSON 对象;与 `video_rtsp_urls_json_file` 合并时,**本字段覆盖同键**。
video_rtsp_urls_json: str = ""
#: 从文件加载 camera_id -> rtsp_urlUTF-8 JSON 对象)。示例见 app/resources/camera_rtsp_urls.sample.json。
video_rtsp_urls_json_file: str = ""
#: 打开 RTSP 并读到首帧的超时(秒)。
video_open_timeout_sec: float = Field(default=15.0, ge=1.0, le=120.0)
#: 连续读帧失败达到该次数后释放连接并尝试重连。
video_read_failure_reconnect_threshold: int = Field(default=15, ge=1, le=500)
#: 重连前等待秒数(亦用于 open 失败后的退避)。
video_reconnect_backoff_seconds: float = Field(default=1.0, ge=0.1, le=60.0)
#: 推理抽帧间隔(秒)。
video_inference_interval_sec: float = Field(default=2.0, ge=0.2, le=60.0)
#: 分类置信度阈值(兼容旧逻辑):低于 `video_voice_confirm_min_confidence` 的帧不参与自动确认或语音追问。
video_inference_confidence_threshold: float = Field(
default=0.35, ge=0.0, le=1.0
)
#: 达到或超过该置信度且 Top1 在候选内时,自动记一条 vision 消耗;低于该值时走待确认(不低于 video_voice_confirm_min 且可展示候选项时)。默认 0.9:不足 0.9 的需人工确认。
video_auto_confirm_confidence: float = Field(default=0.9, ge=0.0, le=1.0)
#: 低于本值的帧不进入自动/待确认逻辑(与 `video_auto_confirm_confidence` 下沿之间的区间可入队待确认)。
video_voice_confirm_min_confidence: float = Field(default=0.35, ge=0.0, le=1.0)
#: 是否启用低置信度时的人工确认(客户端拉取待确认项并回传结果;不依赖服务端麦克风/扬声器)。
voice_confirmation_enabled: bool = True
#: 语音确认记帐时的 doctor_id。
video_voice_confirm_doctor_id: str = "voice"
#: (已弃用)服务端本机录音秒数;当前闭环由客户端采集语音,此项仅保留兼容旧配置。
voice_record_seconds: float = Field(default=5.0, ge=1.0, le=30.0)
#: (已弃用)服务端 ffmpeg 音频输入;当前闭环不依赖服务端录音。
voice_ffmpeg_input: str = ""
#: 手术结束后归档写库失败时,后台重试落库的间隔(秒)。
archive_persist_retry_interval_seconds: float = Field(
default=30.0, ge=5.0, le=3600.0
)
#: 同一物品重复记一条消耗的最短间隔(秒)。
video_detail_cooldown_sec: float = Field(default=15.0, ge=0.0, le=3600.0)
#: 送模型 JPEG 质量。
video_jpeg_quality: int = Field(default=85, ge=40, le=100)
#: 写入消耗明细时的 doctor_id无外部医生 ID 来源时的占位)。
video_result_doctor_id: str = "vision"
#: 为 true 时,每次单帧分类得到 top1 等结果会打一条 INFO 日志(联调用;高流量时建议关)。
video_log_inference_results: bool = False
#: 为 true 时,将时间窗级识别写入文本日志(`start_surgery` 时按手术截断/初始化窗内结果追加Top2/3 仅名称;数量恒 1
consumption_tsv_log_enabled: bool = True
#: 路径模板,须含 `{surgery_id}`(每例手术独立文件)。不含占位时自动在扩展名前追加 `_<surgery_id>`。
consumption_tsv_log_path: str = "logs/consumption_{surgery_id}.txt"
#: 为 true 时,同一时间窗结果在终端以 Markdown 表格打印Top13 分列 id / 名称 / 置信度)。
consumption_log_markdown_terminal: bool = True
#: 消耗日志「时间戳」列的时区IANA 名如 `Asia/Shanghai`;空串则使用「当前系统时区」。
consumption_log_timezone: str = ""
#: 为 true 时语音确认WAV/文本)的 ASR/解析结果写 TSV 文件,并在终端打 `VoiceConfirm` 行;`start_surgery` 时与消耗日志同寿命截断初始化。
voice_file_log_enabled: bool = True
#: 路径模板,须含 `{surgery_id}`,与 `consumption_tsv_log_path` 规则相同。
voice_file_log_path: str = "logs/voice_{surgery_id}.txt"
#: 海康 SDK `.so` 所在目录(容器内可挂载 `/opt/hikvision/lib`)。
hikvision_lib_dir: str = "/opt/hikvision/lib"
#: 为 true 时 `auto` 模式才会优先走 SDK亦为 SDK 登录的前提之一。
hikvision_sdk_enabled: bool = False
hikvision_device_ip: str = ""
hikvision_device_port: int = Field(default=8000, ge=1, le=65535)
hikvision_user: str = ""
hikvision_password: str = ""
#: 预览 URL 模板中的通道号等(如 101 主码流常写作 channel 拼接)。
hikvision_channel: int = Field(default=1, ge=1, le=512)
#: SDK 登录成功后用于拉流的 RTSP 模板;占位符如 `{ip} {user} {password} {channel} {camera_id}`。
hikvision_preview_rtsp_template: str = ""
#: 与 VIDEO_RTSP_URLS_JSON 类似,按 camera_id 指定 SDK 路径下的预览 RTSP。
hikvision_camera_rtsp_urls_json: str = ""
#: SDK 登录失败时是否仍尝试用通用 RTSP 映射拉流(仅当能解析到 RTSP URL 时)。
hikvision_sdk_fallback_to_rtsp: bool = True
#: 百度语音(`baidu-aip` AipSpeech短语音识别 + 在线合成)。在控制台创建应用后填写。
baidu_speech_app_id: str = ""
baidu_speech_api_key: str = ""
baidu_speech_secret_key: str = ""
#: 建立连接超时(毫秒)。未设置则使用 SDK 默认。
baidu_speech_connection_timeout_ms: int | None = None
#: 传输数据超时(毫秒)。未设置则使用 SDK 默认。
baidu_speech_socket_timeout_ms: int | None = None
#: 百度短语音识别 `dev_pid`**始终**用于 ASR调用方传入的 options 不会覆盖。1537=普通话通用(与百度控制台一致;勿用 1737 英语、1837 粤语等)。
baidu_speech_asr_dev_pid: int = Field(default=1537, ge=1000, le=99999)
# --- MinIO语音确认原始 WAV 追溯存储 ---
#: 为空则视为未配置 MinIO语音确认接口将返回业务错误联调需配置
minio_endpoint: str = ""
minio_access_key: str = ""
minio_secret_key: str = ""
minio_bucket: str = "operation-room-voice"
#: 是否使用 HTTPSMinIO 常见为 false走 9000 明文或 TLS
minio_secure: bool = False
#: 可选区域(部分 S3 兼容实现需要)。
minio_region: str = ""
#: 上传医生语音 WAV 的最大字节数(默认 10MB
voice_upload_max_bytes: int = Field(default=10 * 1024 * 1024, ge=64, le=50 * 1024 * 1024)
#: 同一条待确认在 ASR/文本解析为选项或耗材名失败时,计数的最大失败轮数。默认 2 表示首败后再允许 1 次「显式重试」语义API 的 retry_remaining 首轮为 1、再败为 0不阻止后续继续上传直至成功或否认。
voice_confirm_max_failed_parse_rounds: int = Field(default=2, ge=1, le=20)
# --- Demo 客户端跨源(仅用于 scripts/demo_client 联调;生产置 false ---
#: 为 true 时挂载 CORSMiddleware便于浏览器 demo 从另一个端口访问本服务。
demo_cors_enabled: bool = True
#: 逗号分隔的允许来源;`*` 表示允许全部来源demo/联调用,生产应显式指定)。
demo_cors_origins: str = "*"
# --- 一键联调:上传视频 → 起假 RTSP → 写 VIDEO_RTSP_URLS_JSON_FILE → 开始手术(仅开发;生产必须 false ---
#: 为 true 时注册 `POST /internal/demo/orchestrate-and-start`。
demo_orchestrator_enabled: bool = False
#: 假 RTSPMediaMTX在宿主机上映射的端口与 scripts/demo_client 默认一致)。
demo_orchestrator_rtsp_port: int = Field(default=18554, ge=1, le=65535)
#: 手配假流时:写入 JSON 可把 `rtsp://127.0.0.1` 换成此主机,便于**别一进程**(如仅容器内的监控)访问宿主机推流。
#: `POST /internal/demo/orchestrate-and-start` 在本进程起流+拉流,始终写 `127.0.0.1`**不读**此字段。
demo_orchestrator_rtsp_json_host: str = "host.docker.internal"
def parsed_demo_cors_origins(self) -> list[str]:
raw = (self.demo_cors_origins or "").strip()
if not raw:
return []
if raw == "*":
return ["*"]
return [item.strip() for item in raw.split(",") if item.strip()]
@field_validator("consumable_classifier_weights", mode="before")
@classmethod
def consumable_classifier_weights_default(cls, value: object) -> str:
if value is None or value == "":
return _default_consumable_classifier_weights()
return str(value)
model_config = SettingsConfigDict(
env_file=(str(_DEFAULT_ENV_FILE),),
env_file_encoding="utf-8",
extra="ignore",
)
@property
def sqlalchemy_database_url(self) -> str:
component_values = (
self.postgres_user,
self.postgres_password,
self.postgres_db,
self.postgres_host,
self.postgres_port,
)
default_component_values = (
"postgres",
"postgres",
"operation_room",
"localhost",
35432,
)
if component_values != default_component_values or not self.database_url:
user = quote_plus(self.postgres_user)
password = quote_plus(self.postgres_password)
database = quote_plus(self.postgres_db)
return (
"postgresql+asyncpg://"
f"{user}:{password}@{self.postgres_host}:{self.postgres_port}/{database}"
)
return self.database_url
@property
def baidu_speech_configured(self) -> bool:
return bool(
self.baidu_speech_app_id.strip()
and self.baidu_speech_api_key.strip()
and self.baidu_speech_secret_key.strip()
)
@property
def minio_configured(self) -> bool:
return bool(
self.minio_endpoint.strip()
and self.minio_access_key.strip()
and self.minio_secret_key.strip()
and self.minio_bucket.strip()
)
@staticmethod
def _parse_rtsp_urls_object(raw: str) -> dict[str, str]:
raw = (raw or "").strip()
if not raw:
return {}
try:
data: Any = json.loads(raw)
except json.JSONDecodeError as exc:
raise ValueError(f"Invalid VIDEO_RTSP_URLS_JSON: {exc}") from exc
if not isinstance(data, dict):
raise ValueError("VIDEO_RTSP_URLS_JSON must be a JSON object")
return {str(k): str(v) for k, v in data.items()}
def video_rtsp_url_map(self) -> dict[str, str]:
"""合并文件与内联 JSON内联键覆盖文件。"""
merged: dict[str, str] = {}
path_raw = (self.video_rtsp_urls_json_file or "").strip()
if path_raw:
path = Path(path_raw).expanduser()
if not path.is_file():
raise ValueError(
f"VIDEO_RTSP_URLS_JSON_FILE is set but file not found: {path}"
)
try:
file_obj: Any = json.loads(path.read_text(encoding="utf-8"))
except json.JSONDecodeError as exc:
raise ValueError(
f"Invalid JSON in VIDEO_RTSP_URLS_JSON_FILE {path}: {exc}"
) from exc
if not isinstance(file_obj, dict):
raise ValueError(
f"VIDEO_RTSP_URLS_JSON_FILE must contain a JSON object: {path}"
)
merged = {str(k): str(v) for k, v in file_obj.items()}
merged.update(self._parse_rtsp_urls_object(self.video_rtsp_urls_json))
return merged
@property
def camera_rtsp_urls_sample_path(self) -> str:
"""仓库内示例映射路径(供文档与联调引用)。"""
return _default_camera_rtsp_urls_sample_path()
settings = Settings()