diff --git a/.env.example b/.env.example index 060bca0..d18661b 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,7 @@ -# Copy to `.env` and adjust. Loaded by pydantic-settings (field names → UPPER_SNAKE_CASE env vars). -# Used by local `start.sh` and Docker Compose. See also docs/video-backends.md. +# 复制为 `.env` 并按环境填写。pydantic-settings 将字段名映射为大写下划线环境变量。 +# 启动前须执行 `alembic upgrade head`(`start.sh` / `start_fresh.sh` 已包含)。 +# 算法、管线、归档路径、视觉/语音日志等非部署项见 `app/baked/algorithm.py` 与 `app/baked/pipeline.py`。 +# 详细说明见 docs/video-backends.md。 # --- PostgreSQL --- POSTGRES_USER=postgres @@ -7,165 +9,53 @@ POSTGRES_PASSWORD=postgres POSTGRES_DB=operation_room POSTGRES_HOST=localhost POSTGRES_PORT=35432 - -# Optional: full async SQLAlchemy URL (overrides POSTGRES_* when set and matches defaults logic — see Settings). +# 可选:整串 async DSN(会覆盖与默认一致的 POSTGRES_* 组合时的逻辑,见 Settings) # DATABASE_URL=postgresql+asyncpg://postgres:postgres@localhost:35432/operation_room -# 开发/首次部署:启动时执行 Base.metadata.create_all 确保表存在(默认 true)。 -# 生产请置 false,并通过 `alembic upgrade head` 应用迁移(见 alembic/ 目录)。 -# AUTO_CREATE_SCHEMA=true - -# --- Uvicorn / API server --- +# --- HTTP(python -m main / 容器等入口)--- # SERVER_HOST=0.0.0.0 # SERVER_PORT=38080 -# 生产必须 false;本地开发调试可改 true(等价于旧版 reload=True)。 -# SERVER_RELOAD=false -# --- 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 -# CONSUMABLE_MIN_CLS_CONFIDENCE=0.5 -# 时间窗(秒):窗内多次推理取众数后再走自动记账 / 待确认。 -# CONSUMABLE_VISION_WINDOW_SEC=15 -# 业务物品 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 -# 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 --- -# SURGERY_RECORDING_MAX_ATTEMPTS=3 -# SURGERY_RECORDING_RETRY_DELAY_SECONDS=1.0 - -# --- Video: RTSP + optional Hikvision HCNetSDK (Linux x86_64 + glibc recommended) --- -# Client `camera_ids` must match keys in your RTSP map (sample IDs: or-cam-01, or-cam-02). -# +# --- Video:RTSP 与按路后端(须与客户端 camera_ids 一致)--- # VIDEO_DEFAULT_BACKEND=rtsp -# Values: rtsp | hikvision_sdk | auto (auto: SDK .so loaded and HIKVISION_SDK_ENABLED=true → prefer SDK) -# -# Per-camera backend override (JSON object): # VIDEO_CAMERA_BACKEND_OVERRIDES_JSON={"or-cam-01":"rtsp","or-cam-02":"hikvision_sdk"} -# -# RTSP URL resolution (first match wins per camera_id): -# 1) VIDEO_RTSP_URLS_JSON_FILE — JSON file: {"or-cam-01":"rtsp://...","or-cam-02":"rtsp://..."} -# 2) merged with VIDEO_RTSP_URLS_JSON (inline JSON string; overrides same keys from file) -# 3) else VIDEO_RTSP_URL_TEMPLATE with {camera_id} -# Example file (committed): app/resources/camera_rtsp_urls.sample.json # VIDEO_RTSP_URLS_JSON_FILE=app/resources/camera_rtsp_urls.sample.json -# In Docker (WORKDIR /app): VIDEO_RTSP_URLS_JSON_FILE=/app/app/resources/camera_rtsp_urls.sample.json -# WARNING: if VIDEO_RTSP_URLS_JSON_FILE is set but the path is missing, the app will fail to start. -# -# VIDEO_RTSP_URL_TEMPLATE=rtsp://user:pass@192.168.1.64:554/Streaming/Channels/101 -# VIDEO_RTSP_URLS_JSON={"or-cam-01":"rtsp://user:pass@192.168.1.101:554/Streaming/Channels/101","or-cam-02":"rtsp://user:pass@192.168.1.102:554/Streaming/Channels/101"} -# -# VIDEO_OPEN_TIMEOUT_SEC=15 -# 连续读帧失败次数达到阈值后释放 RTSP 并重连。 -# VIDEO_READ_FAILURE_RECONNECT_THRESHOLD=15 -# VIDEO_RECONNECT_BACKOFF_SECONDS=1.0 -# VIDEO_INFERENCE_INTERVAL_SEC=2 -# VIDEO_INFERENCE_CONFIDENCE_THRESHOLD=0.35 -# 置信度 >= 此值且命中候选清单时自动 vision 记账。提高到 0.9 可减少自动记账、更多走待确认。 -# 默认 0.9:Top1 置信度不足该值时入队待确认;达到且标签在 candidate_consumables 内则直接记 vision。 -# VIDEO_AUTO_CONFIRM_CONFIDENCE=0.9 -# 与 VIDEO_VOICE_CONFIRM_MIN_CONFIDENCE 共同决定何时自动 / 待确认(见 app/config 注释)。 -# VIDEO_VOICE_CONFIRM_MIN_CONFIDENCE=0.35 -# 待确认话术由服务端生成(prompt_text),TTS 一般在客户端播放;医生 WAV 上传后服务端 ASR 解析。 -# 解析顺序:① pending 里展示的 topk(序号/名称);② 仍不匹配时,对「开始手术」请求体中的 candidate_consumables 全文做名称子串匹配——医生报清单内其它耗材也以医生为准入账。 -# 是否启用低置信度人工确认(客户端播报 + resolve 回传;服务端无麦克风/扬声器要求)。 -# VOICE_CONFIRMATION_ENABLED=true -# 同一条待确认在语音/文本「解析失败」时累计的允许失败轮次(默认 2:首败后再给 1 次重试提示;见 422 的 detail.retry_remaining)。 -# VOICE_CONFIRM_MAX_FAILED_PARSE_ROUNDS=2 -# VIDEO_VOICE_CONFIRM_DOCTOR_ID=voice -# (已弃用)服务端本机录音 / ffmpeg 音频输入;当前闭环不依赖。 -# VOICE_RECORD_SECONDS=5 -# VOICE_FFMPEG_INPUT= -# 停录后写库失败时,后台重试落库间隔(秒)。 -# ARCHIVE_PERSIST_RETRY_INTERVAL_SECONDS=30 -# VIDEO_DETAIL_COOLDOWN_SEC=15 -# VIDEO_JPEG_QUALITY=85 -# VIDEO_RESULT_DOCTOR_ID=vision -# 每次单帧分类得到 top1~3 时打一条 INFO(联调开;生产建议 false) -# VIDEO_LOG_INFERENCE_RESULTS=true -# 时间窗级消耗文本日志(制表符列;每例手术 start 时截断+表头,窗内结果追加;终端 Rich 为可读时间戳,文件内为 ISO 时间戳列) -# CONSUMPTION_TSV_LOG_ENABLED=true -# 须含 {surgery_id},如 logs/consumption_{surgery_id}.txt -# CONSUMPTION_TSV_LOG_PATH=logs/consumption_{surgery_id}.txt -# 同一时间窗结果在终端以 Markdown 表格打印(Top1~3 分列 id / 名称 / 置信度) -# CONSUMPTION_LOG_MARKDOWN_TERMINAL=true -# 消耗日志时间戳列的时区(IANA,如 Asia/Shanghai);不设置则用运行环境的系统时区 -# CONSUMPTION_LOG_TIMEZONE=Asia/Shanghai -# -# 语音确认:stderr 中可 grep 的 `VoiceConfirm ...` 行 + 每例手术 TSV(与 `start_surgery` 同次截断写表头;成功/ASR/解析失败均追加一行) -# VOICE_FILE_LOG_ENABLED=true -# 须含 {surgery_id},如 logs/voice_{surgery_id}.txt -# VOICE_FILE_LOG_PATH=logs/voice_{surgery_id}.txt +# VIDEO_RTSP_URLS_JSON={"or-cam-01":"rtsp://..."} +# VIDEO_RTSP_URL_TEMPLATE=rtsp://user:pass@host:554/path/{camera_id} -# --- Hikvision: mount vendor Linux x86_64 .so at runtime (do not commit proprietary binaries) --- +# --- 海康 SDK(Linux x86_64;二进制勿提交仓库)--- # HIKVISION_LIB_DIR=/opt/hikvision/lib -# Optional: single library path (overrides directory search in code) +# 可选:单一路径,见 app/services/video/hikvision_runtime.py 直读 HIKVISION_LIB_PATH # HIKVISION_LIB_PATH= # HIKVISION_SDK_ENABLED=false # HIKVISION_DEVICE_IP= -# HIKVISION_DEVICE_PORT=8000 # HIKVISION_USER= # HIKVISION_PASSWORD= # HIKVISION_CHANNEL=1 -# After SDK login, OpenCV still pulls frames via RTSP; template placeholders: {ip} {user} {password} {channel} {camera_id} # HIKVISION_PREVIEW_RTSP_TEMPLATE=rtsp://{user}:{password}@{ip}:554/Streaming/Channels/101 -# Per-camera RTSP when using SDK path (same shape as VIDEO_RTSP_URLS_JSON): -# HIKVISION_CAMERA_RTSP_URLS_JSON={"or-cam-01":"rtsp://...","or-cam-02":"rtsp://..."} +# HIKVISION_CAMERA_RTSP_URLS_JSON={} # HIKVISION_SDK_FALLBACK_TO_RTSP=true -# --- Baidu Speech(可选,遗留;当前手术闭环由客户端完成 TTS/ASR,服务端可不配置)--- -# BAIDU_SPEECH_APP_ID= -# BAIDU_SPEECH_API_KEY= -# BAIDU_SPEECH_SECRET_KEY= -# BAIDU_SPEECH_CONNECTION_TIMEOUT_MS= -# BAIDU_SPEECH_SOCKET_TIMEOUT_MS= -# 短语音识别模型:固定普通话(默认 1537;勿用 1737 英语等)。代码会始终带上此 dev_pid。 -# BAIDU_SPEECH_ASR_DEV_PID=1537 +# --- 百度(语音:短语音识别 AipSpeech + 在线合成;控制台开通对应能力)--- +# BAIDU_APP_ID= +# BAIDU_API_KEY= +# BAIDU_SECRET_KEY= +# 可选 +# BAIDU_CONNECTION_TIMEOUT_MS= +# BAIDU_SOCKET_TIMEOUT_MS= +# BAIDU_ASR_DEV_PID=1537 -# --- Baidu Face(可选;仅 `scripts/baidu_face_1n_search.py` 批量人脸 1:N 搜索;需在控制台创建应用并开通人脸识别)--- -# BAIDU_FACE_APP_ID= -# BAIDU_FACE_API_KEY= -# BAIDU_FACE_SECRET_KEY= -# 搜索的人脸组 id,逗号分隔,最多 10 个;未传命令行 --groups 时使用此项 -# 仅允许英文/数字/下划线(与控制台「用户组 id」一致),不能中文;否则 API 会报 222005 -# BAIDU_FACE_GROUP_ID_LIST=my_group_1 -# BAIDU_FACE_MAX_USER_NUM=1 -# BAIDU_FACE_MATCH_THRESHOLD=80 -# BAIDU_FACE_QUALITY_CONTROL=NONE -# BAIDU_FACE_LIVENESS_CONTROL=NONE -# BAIDU_FACE_CONNECTION_TIMEOUT_MS= -# BAIDU_FACE_SOCKET_TIMEOUT_MS= - -# --- MinIO(语音 WAV 存桶;`docker-compose.dev.yml` 内已含 `minio` 服务;本机只跑 API 时填 127.0.0.1:9000)--- -# docker compose -f docker-compose.dev.yml up -d minio +# --- MinIO(语音 WAV)--- # MINIO_ENDPOINT=127.0.0.1:9000 -# MINIO_ACCESS_KEY=minioadmin -# MINIO_SECRET_KEY=minioadmin +# MINIO_ACCESS_KEY= +# MINIO_SECRET_KEY= # MINIO_BUCKET=operation-room-voice # MINIO_SECURE=false -# optional: MINIO_REGION= +# MINIO_REGION= -# --- Demo 浏览器客户端 / 一键联调假 RTSP(仅开发;生产关)--- -# demo_client/index.html 跨源访问本服务 / 一键开录 +# --- Demo 客户端 / 一键联调(生产关闭)--- # DEMO_CORS_ENABLED=true # DEMO_CORS_ORIGINS=* -# 为 true 时提供 POST /internal/demo/orchestrate-and-start;需能执行 docker+ffmpeg 的**同一进程**内起 MediaMTX(通常=在宿主机直接跑 main.py,或容器挂载 /var/run/docker.sock) # DEMO_ORCHESTRATOR_ENABLED=false -# VIDEO_RTSP_URLS_JSON_FILE 必须设成**可写**的 JSON 文件;Docker 中请 bind-mount 宿主机文件,与一键覆盖写入的映射一致 # DEMO_ORCHESTRATOR_RTSP_PORT=18554 -# 手配假流、只改 JSON 给「另一进程」用时:可把 127.0.0.1 换成 host.docker.internal 等。 -# 一键联调 orchestrate-and-start 在本进程起流+拉流,固定写 127.0.0.1,不读此项。 # DEMO_ORCHESTRATOR_RTSP_JSON_HOST=host.docker.internal -# 一键起 MediaMTX 后,等待本机 RTSP 端口可连接的最长时间(秒) -# MEDIAMTX_TCP_READY_SEC=30 diff --git a/.gitignore b/.gitignore index 77ca04e..fc3d562 100644 --- a/.gitignore +++ b/.gitignore @@ -9,7 +9,7 @@ wheels/ # Virtual environments .venv -# Local reference / scratch content +# Local reference / scratch content (weights, videos, demo outputs) refs/ # Runtime consumption TSV (开发联调) diff --git a/alembic/env.py b/alembic/env.py index b3d247e..9f4117d 100644 --- a/alembic/env.py +++ b/alembic/env.py @@ -1,8 +1,9 @@ """Alembic environment. -生产请用 `alembic upgrade head`;开发/测试可让 ``Settings.auto_create_schema`` 调用 -``init_db_schema()``。本文件读取 `app.config.settings`,把 asyncpg URL 转为同步 -`psycopg` URL 供 Alembic 使用(仅迁移期间)。 +开发与生产均通过 `alembic upgrade head` 应用表结构;应用启动时不再自动建表。 +本文件读取 `app.config.settings`,把 asyncpg URL 转为同步 +``postgresql+psycopg://``(psycopg3)供 Alembic 使用(仅迁移期间)。 +需安装 `psycopg` 包,见 `pyproject.toml`。 """ from __future__ import annotations @@ -26,7 +27,7 @@ target_metadata = Base.metadata def _sync_database_url() -> str: - """把 asyncpg URL 转为同步 psycopg2 URL,避免 Alembic 强依赖 async 驱动。""" + """把 asyncpg URL 转为同步 psycopg3 URL,避免 Alembic 强依赖 async 驱动。""" url = settings.sqlalchemy_database_url return url.replace("postgresql+asyncpg://", "postgresql+psycopg://", 1) diff --git a/app/api.py b/app/api.py index 2d4ccbd..3fd7d98 100644 --- a/app/api.py +++ b/app/api.py @@ -7,6 +7,7 @@ from fastapi.responses import JSONResponse from loguru import logger from sqlalchemy.exc import SQLAlchemyError +from app.baked import pipeline as bp from app.config import settings from app.database import check_database from app.dependencies import get_surgery_pipeline @@ -173,8 +174,8 @@ async def start_surgery( await _call_recording_with_retries( _start, - max_attempts=settings.surgery_recording_max_attempts, - delay_seconds=settings.surgery_recording_retry_delay_seconds, + max_attempts=bp.SURGERY_RECORDING_MAX_ATTEMPTS, + delay_seconds=bp.SURGERY_RECORDING_RETRY_DELAY_SECONDS, log_prefix=f"Start surgery {payload.surgery_id}", ) except SurgeryPipelineError as exc: @@ -216,8 +217,8 @@ async def end_surgery( await _call_recording_with_retries( _stop, - max_attempts=settings.surgery_recording_max_attempts, - delay_seconds=settings.surgery_recording_retry_delay_seconds, + max_attempts=bp.SURGERY_RECORDING_MAX_ATTEMPTS, + delay_seconds=bp.SURGERY_RECORDING_RETRY_DELAY_SECONDS, log_prefix=f"End surgery {payload.surgery_id}", ) except SurgeryPipelineError as exc: @@ -310,7 +311,7 @@ async def get_surgery_result( description=( "返回当前 FIFO 队首的一条低置信度识别;" "响应内 `prompt_audio_mp3_base64` 为与 `prompt_text` 一致的 MP3(Base64),客户端可直接解码播放。" - "无待确认项时返回 404;合成失败或未配置语音服务时返回 422/503(见错误码)。" + "无待确认项时返回 404;提示文本为空为 422;未配置百度或 TTS 失败为 503(不返回空音频兜底)。" "医生确认后请使用 `POST .../resolve` 上传 WAV。" ), ) diff --git a/app/baked/__init__.py b/app/baked/__init__.py new file mode 100644 index 0000000..0263138 --- /dev/null +++ b/app/baked/__init__.py @@ -0,0 +1,5 @@ +"""编译进代码的默认参数(非部署差异);见 `algorithm` 与 `pipeline` 子模块。""" + +from app.baked import algorithm, pipeline + +__all__ = ["algorithm", "pipeline"] diff --git a/app/baked/algorithm.py b/app/baked/algorithm.py new file mode 100644 index 0000000..eea5f71 --- /dev/null +++ b/app/baked/algorithm.py @@ -0,0 +1,63 @@ +"""YOLO 耗材/手部与撕段四模型:路径与超参写死,与业务线部署解耦。""" + +from __future__ import annotations + +from pathlib import Path + +_PACKAGE_DIR = Path(__file__).resolve().parent.parent + + +def default_consumable_classifier_weights_path() -> str: + return str(_PACKAGE_DIR / "resources" / "consumable_classifier.pt") + + +def default_consumable_classifier_labels_yaml_path() -> str: + return str(_PACKAGE_DIR / "resources" / "consumable_classifier_labels.yaml") + + +def default_camera_rtsp_urls_sample_path() -> str: + return str(_PACKAGE_DIR / "resources" / "camera_rtsp_urls.sample.json") + + +# --- 耗材分类(YOLO-cls)--- +CONSUMABLE_CLASSIFIER_WEIGHTS: str = default_consumable_classifier_weights_path() +CONSUMABLE_CLASSIFIER_IMGSZ: int = 224 +CONSUMABLE_CLASSIFIER_DEVICE: str = "" +CONSUMABLE_CLASSIFIER_TOPK: int = 5 +CONSUMABLE_MIN_CLS_CONFIDENCE: float = 0.5 +CONSUMABLE_CLASSIFIER_LABELS_YAML_PATH: str = default_consumable_classifier_labels_yaml_path() +CONSUMABLE_VISION_WINDOW_SEC: float = 15.0 + +# --- 手部检测;全空路径则整帧分类 --- +HAND_DETECTION_WEIGHTS: str = "" +HAND_DETECTION_IMGSZ: int = 640 +HAND_DETECTION_CONF: float = 0.25 +HAND_DETECTION_PAD_RATIO: float = 0.30 +HAND_DETECTION_MIN_CROP_PX: int = 64 +HAND_DETECTION_DEVICE: str = "" + +# --- 撕段四模型(与 haocai_consumption demo 同构);无权重文件时勿开启 TEAR_SEGMENT_ENABLED --- +TEAR_SEGMENT_ENABLED: bool = False +TEAR_SEGMENT_PRIMARY_CAMERA_ID: str = "" +TEAR_SEGMENT_HAND_DET_WEIGHTS: str = "" +TEAR_SEGMENT_TEAR_WEIGHTS: str = "" +TEAR_SEGMENT_GOODBAD_WEIGHTS: str = "" +TEAR_SEGMENT_HAOCAI_WEIGHTS: str = "" +TEAR_SEGMENT_LABELS_YAML_PATH: str = "" +TEAR_SEGMENT_ASSUMED_FPS: float = 25.0 +TEAR_SEGMENT_DET_CONF: float = 0.25 +TEAR_SEGMENT_PAD_RATIO: float = 0.30 +TEAR_SEGMENT_TEAR_CONF: float = 0.35 +TEAR_SEGMENT_TEAR_SMOOTH: int = 5 +TEAR_SEGMENT_GAP_RATIO: float = 1.5 +TEAR_SEGMENT_MIN_TEAR_SEC: float = 1.2 +TEAR_SEGMENT_MIN_GAP_SEC: float = 1.0 +TEAR_SEGMENT_DET_IMGSZ: int = 640 +TEAR_SEGMENT_TEAR_IMGSZ: int = 224 +TEAR_SEGMENT_GOODBAD_IMGSZ: int = 224 +TEAR_SEGMENT_HAOCAI_IMGSZ: int = 224 +TEAR_SEGMENT_TEAR_DEVICE: str = "" +TEAR_SEGMENT_GOODBAD_DEVICE: str = "" +TEAR_SEGMENT_HAOCAI_DEVICE: str = "" +TEAR_SEGMENT_LOG_TXT: bool = False +TEAR_SEGMENT_LOG_TXT_PATH: str = "logs/tear_segment_{surgery_id}.txt" diff --git a/app/baked/pipeline.py b/app/baked/pipeline.py new file mode 100644 index 0000000..d6e58ec --- /dev/null +++ b/app/baked/pipeline.py @@ -0,0 +1,39 @@ +"""视频流、待确认、归档与可观测性默认可调参数(非 env)。""" + +# --- 手术录制 API 重试 --- +SURGERY_RECORDING_MAX_ATTEMPTS: int = 3 +SURGERY_RECORDING_RETRY_DELAY_SECONDS: float = 1.0 + +# --- RTSP 连接与抽帧、推理门控(不含 URL,URL 在 Settings)--- +VIDEO_OPEN_TIMEOUT_SEC: float = 15.0 +VIDEO_READ_FAILURE_RECONNECT_THRESHOLD: int = 15 +VIDEO_RECONNECT_BACKOFF_SECONDS: float = 1.0 +VIDEO_INFERENCE_INTERVAL_SEC: float = 2.0 +VIDEO_INFERENCE_CONFIDENCE_THRESHOLD: float = 0.35 +VIDEO_AUTO_CONFIRM_CONFIDENCE: float = 0.9 +VIDEO_VOICE_CONFIRM_MIN_CONFIDENCE: float = 0.35 +VOICE_CONFIRMATION_ENABLED: bool = True +VIDEO_VOICE_CONFIRM_DOCTOR_ID: str = "voice" +VIDEO_DETAIL_COOLDOWN_SEC: float = 15.0 +VIDEO_JPEG_QUALITY: int = 85 +VIDEO_RESULT_DOCTOR_ID: str = "vision" +VIDEO_LOG_INFERENCE_RESULTS: bool = False + +# --- 停录后归档重试 + durable fallback --- +ARCHIVE_PERSIST_RETRY_INTERVAL_SECONDS: float = 30.0 +ARCHIVE_PERSIST_MAX_RETRIES: int = 12 +ARCHIVE_PERSIST_BACKOFF_CAP_SECONDS: float = 900.0 +ARCHIVE_PERSIST_DURABLE_FALLBACK_DIR: str = "logs/pending_archive" +ARCHIVE_PERSIST_DURABLE_FALLBACK_ENABLED: bool = True + +# --- 消耗/语音 文本日志与终端展示 --- +CONSUMPTION_TSV_LOG_ENABLED: bool = True +CONSUMPTION_TSV_LOG_PATH: str = "logs/consumption_{surgery_id}.txt" +CONSUMPTION_LOG_MARKDOWN_TERMINAL: bool = True +CONSUMPTION_LOG_TIMEZONE: str = "" + +VOICE_FILE_LOG_ENABLED: bool = True +VOICE_FILE_LOG_PATH: str = "logs/voice_{surgery_id}.txt" + +VOICE_UPLOAD_MAX_BYTES: int = 10 * 1024 * 1024 +VOICE_CONFIRM_MAX_FAILED_PARSE_ROUNDS: int = 2 diff --git a/app/config.py b/app/config.py index 8002eae..2cdf7eb 100644 --- a/app/config.py +++ b/app/config.py @@ -3,16 +3,14 @@ from pathlib import Path from urllib.parse import quote_plus from typing import Any, Literal -from pydantic import Field, field_validator +from pydantic import Field from pydantic_settings import BaseSettings, SettingsConfigDict +from app.baked import algorithm as baked_algorithm + class _SettingsGroup: - """按主题分组的 Settings 视图;属性访问代理回主 Settings 实例。 - - 主 Settings 保留所有原始平坦字段作为事实来源;此类仅提供 ``settings.video.xxx`` - 之类的分组读写入口,减少跨文件的耦合面,同时保持向后兼容。 - """ + """按主题分组的 Settings 视图;属性访问代理回主 Settings 实例。""" _FIELDS: tuple[str, ...] = () @@ -35,59 +33,19 @@ class _SettingsGroup: class _VideoGroup(_SettingsGroup): + """仅含 RTSP 解析与按路后端;抽帧/推理/耗材等见 app.baked.pipeline / algorithm。""" + _FIELDS = ( "video_default_backend", "video_camera_backend_overrides_json", "video_rtsp_url_template", "video_rtsp_urls_json", "video_rtsp_urls_json_file", - "video_open_timeout_sec", - "video_read_failure_reconnect_threshold", - "video_reconnect_backoff_seconds", - "video_inference_interval_sec", - "video_inference_confidence_threshold", - "video_auto_confirm_confidence", - "video_voice_confirm_min_confidence", - "video_voice_confirm_doctor_id", - "video_detail_cooldown_sec", - "video_jpeg_quality", - "video_result_doctor_id", - "video_log_inference_results", - "consumable_classifier_weights", - "consumable_classifier_imgsz", - "consumable_classifier_device", - "consumable_classifier_topk", - "consumable_min_cls_confidence", - "consumable_classifier_labels_yaml_path", - "consumable_vision_window_sec", - "hand_detection_weights", - "hand_detection_imgsz", - "hand_detection_conf", - "hand_detection_pad_ratio", - "hand_detection_min_crop_px", - "hand_detection_device", - "surgery_recording_max_attempts", - "surgery_recording_retry_delay_seconds", - "archive_persist_retry_interval_seconds", - "archive_persist_max_retries", - "archive_persist_backoff_cap_seconds", - "archive_persist_durable_fallback_dir", - "archive_persist_durable_fallback_enabled", - "consumption_tsv_log_enabled", - "consumption_tsv_log_path", - "consumption_log_markdown_terminal", - "consumption_log_timezone", ) class _VoiceGroup(_SettingsGroup): - _FIELDS = ( - "voice_confirmation_enabled", - "voice_upload_max_bytes", - "voice_confirm_max_failed_parse_rounds", - "voice_file_log_enabled", - "voice_file_log_path", - ) + _FIELDS = () class _HikvisionGroup(_SettingsGroup): @@ -145,7 +103,6 @@ class _DatabaseGroup(_SettingsGroup): "postgres_db", "postgres_host", "postgres_port", - "auto_create_schema", ) @@ -153,32 +110,17 @@ class _ServerGroup(_SettingsGroup): _FIELDS = ( "server_host", "server_port", - "server_reload", ) _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") - - -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.""" + """Application configuration loaded from environment / .env. + 算法与管线默认可调项见 ``app.baked.algorithm`` / ``app.baked.pipeline``。 + """ database_url: str | None = None postgres_user: str = "postgres" @@ -186,162 +128,51 @@ class Settings(BaseSettings): postgres_db: str = "operation_room" postgres_host: str = "localhost" postgres_port: int = 35432 - #: 为 true 时,lifespan 启动会调用 Base.metadata.create_all 确保表存在(开发/测试用)。 - #: 生产请置 false,并通过 ``alembic upgrade head`` 进行版本化迁移。 - auto_create_schema: bool = True - # --- Uvicorn / API server --- - #: `uvicorn.run` 绑定的地址;默认监听所有接口(开发/容器联调常用)。 server_host: str = "0.0.0.0" - #: HTTP 端口;生产请按部署策略显式设置。 server_port: int = Field(default=38080, ge=1, le=65535) - #: 是否启用 `--reload`(仅本地开发;生产必须 false)。 - server_reload: bool = False - 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) - #: 分类类名 + 业务 `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 权重;空或文件不存在时退化为「全帧送分类器」(兼容仅有关分类权重的环境)。 - 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`(auto:SDK 动态库可用且 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 完整 URL,JSON 对象;与 `video_rtsp_urls_json_file` 合并时,**本字段覆盖同键**。 video_rtsp_urls_json: str = "" - #: 从文件加载 camera_id -> rtsp_url(UTF-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" - #: 手术结束后归档写库失败时,后台重试落库的间隔(秒),用作指数退避的基数。 - archive_persist_retry_interval_seconds: float = Field( - default=30.0, ge=5.0, le=3600.0 - ) - #: 单条归档允许的最大连续重试次数。达到上限后保持 durable fallback,直到进程重启或手动介入。 - archive_persist_max_retries: int = Field(default=12, ge=1, le=10000) - #: 指数退避上限(秒),防止间隔被放大到不切实际的值。 - archive_persist_backoff_cap_seconds: float = Field( - default=900.0, ge=5.0, le=86400.0 - ) - #: 归档 durable fallback 的磁盘目录;启动/重试时会扫描其中 `*.json` 尝试恢复。 - archive_persist_durable_fallback_dir: str = "logs/pending_archive" - #: 为 true 时,首次写库失败后立即把归档写到 durable fallback 目录,避免进程重启丢数据。 - archive_persist_durable_fallback_enabled: bool = True - #: 同一物品重复记一条消耗的最短间隔(秒)。 - 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` 时按手术截断/初始化;每行 tab:item_id、item_name、qty、doctor_id、timestamp;停录后按内存明细与查结果 API 同口径追加汇总块 item_id、item_name、qty)。 - consumption_tsv_log_enabled: bool = True - #: 路径模板,须含 `{surgery_id}`(每例手术独立文件)。不含占位时自动在扩展名前追加 `_`。 - consumption_tsv_log_path: str = "logs/consumption_{surgery_id}.txt" - #: 为 true 时,同一时间窗结果在终端以 Markdown 表格打印(Top1~3 分列 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,语音确认接口将返回业务错误(联调需配置)。 + baidu_speech_app_id: str = Field(default="", validation_alias="BAIDU_APP_ID") + baidu_speech_api_key: str = Field(default="", validation_alias="BAIDU_API_KEY") + baidu_speech_secret_key: str = Field(default="", validation_alias="BAIDU_SECRET_KEY") + baidu_speech_connection_timeout_ms: int | None = Field( + default=None, validation_alias="BAIDU_CONNECTION_TIMEOUT_MS" + ) + baidu_speech_socket_timeout_ms: int | None = Field( + default=None, validation_alias="BAIDU_SOCKET_TIMEOUT_MS" + ) + baidu_speech_asr_dev_pid: int = Field( + default=1537, ge=1000, le=99999, validation_alias="BAIDU_ASR_DEV_PID" + ) + minio_endpoint: str = "" minio_access_key: str = "" minio_secret_key: str = "" minio_bucket: str = "operation-room-voice" - #: 是否使用 HTTPS(MinIO 常见为 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 - #: 假 RTSP(MediaMTX)在宿主机上映射的端口(与 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]: @@ -352,24 +183,12 @@ class Settings(BaseSettings): 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) - - @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", extra="ignore", + env_ignore_empty=True, + populate_by_name=True, ) @property @@ -431,7 +250,6 @@ class Settings(BaseSettings): 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: @@ -456,8 +274,7 @@ class Settings(BaseSettings): @property def camera_rtsp_urls_sample_path(self) -> str: - """仓库内示例映射路径(供文档与联调引用)。""" - return _default_camera_rtsp_urls_sample_path() + return baked_algorithm.default_camera_rtsp_urls_sample_path() @property def video(self) -> _VideoGroup: diff --git a/app/dependencies.py b/app/dependencies.py index ac942f0..ccfc3d9 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -11,6 +11,7 @@ from fastapi import Request from loguru import logger from sqlalchemy.ext.asyncio import async_sessionmaker +from app.baked import algorithm as baked_algorithm from app.config import Settings from app.config import settings as _default_settings from app.database import AsyncSessionLocal @@ -18,6 +19,7 @@ from app.repositories.surgery_results import SurgeryResultRepository from app.repositories.voice_audits import VoiceAuditRepository from app.services.baidu_speech import BaiduSpeechService from app.services.consumable_vision_algorithm import ConsumableVisionAlgorithmService +from app.services.tear_gated_segment_consumption.runner import TearGatedSegmentModelBundle from app.services.minio_audio_storage import MinioAudioStorageService from app.services.surgery_pipeline import SurgeryPipeline from app.services.video.hikvision_runtime import HikvisionRuntime @@ -37,6 +39,7 @@ class AppContainer: baidu_speech_service: BaiduSpeechService minio_audio_storage_service: MinioAudioStorageService camera_session_manager: CameraSessionManager + tear_segment_model_bundle: TearGatedSegmentModelBundle | None voice_confirmation_service: VoiceConfirmationService surgery_pipeline: SurgeryPipeline @@ -55,7 +58,7 @@ def build_container( """基于 Settings 显式装配所有服务;不做任何 import-time 副作用。""" s = app_settings or _default_settings sf: async_sessionmaker = session_factory or AsyncSessionLocal - vision = ConsumableVisionAlgorithmService(app_settings=s) + vision = ConsumableVisionAlgorithmService() hik_runtime = HikvisionRuntime.try_load(s.hikvision_lib_dir) if s.hikvision_sdk_enabled and hik_runtime is None: logger.warning( @@ -66,12 +69,18 @@ def build_container( voice_audit_repo = VoiceAuditRepository() baidu = BaiduSpeechService(app_settings=s) minio = MinioAudioStorageService(s) + tear_bundle = ( + TearGatedSegmentModelBundle() + if baked_algorithm.TEAR_SEGMENT_ENABLED + else None + ) camera_mgr = CameraSessionManager( settings=s, vision_algorithm=vision, hikvision_runtime=hik_runtime, result_repository=surgery_repo, session_factory=sf, + tear_segment_models=tear_bundle, ) voice = VoiceConfirmationService( settings=s, @@ -96,6 +105,7 @@ def build_container( baidu_speech_service=baidu, minio_audio_storage_service=minio, camera_session_manager=camera_mgr, + tear_segment_model_bundle=tear_bundle, voice_confirmation_service=voice, surgery_pipeline=pipeline, ) diff --git a/app/schemas.py b/app/schemas.py index dd181b5..cd97d73 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -160,8 +160,9 @@ class SurgeryPendingConfirmationResponse(BaseModel): prompt_text: str = Field(description="可直接用于展示或无障碍朗读的话术(与 MP3 内容一致)。") prompt_audio_mp3_base64: str = Field( description=( - "与 prompt_text 对应的百度 TTS 音频(MP3)的标准 Base64 字符串(无换行);" - "客户端解码为二进制后以 audio/mpeg 播放。" + "与 prompt_text 一致的百度在线语音合成(MP3)的标准 Base64 字符串(无换行);" + "成功响应时非空,客户端以 audio/mpeg 解码播放。" + "未配置百度语音或合成失败时本接口以 HTTP 4xx/503 返回,见错误码,不返回半套数据。" ), ) options: list[PendingConfirmationOption] diff --git a/app/services/baidu_speech.py b/app/services/baidu_speech.py index ecf8ff0..efad1e1 100644 --- a/app/services/baidu_speech.py +++ b/app/services/baidu_speech.py @@ -9,7 +9,7 @@ from app.config import Settings, settings as _default_settings class BaiduSpeechNotConfiguredError(RuntimeError): - """未配置 BAIDU_SPEECH_APP_ID / API_KEY / SECRET_KEY 时调用接口会抛出。""" + """未配置 `BAIDU_APP_ID` / `BAIDU_API_KEY` / `BAIDU_SECRET_KEY` 时调用接口会抛出。""" class BaiduSpeechService: @@ -27,8 +27,7 @@ class BaiduSpeechService: def _client_or_raise(self) -> AipSpeech: if not self.configured: raise BaiduSpeechNotConfiguredError( - "百度语音未配置:请设置 BAIDU_SPEECH_APP_ID、BAIDU_SPEECH_API_KEY、" - "BAIDU_SPEECH_SECRET_KEY" + "百度语音未配置:请设置 BAIDU_APP_ID、BAIDU_API_KEY、BAIDU_SECRET_KEY。" ) with self._lock: if self._client is None: diff --git a/app/services/consumable_vision_algorithm.py b/app/services/consumable_vision_algorithm.py index 39a79aa..1d39471 100644 --- a/app/services/consumable_vision_algorithm.py +++ b/app/services/consumable_vision_algorithm.py @@ -19,7 +19,7 @@ import yaml from loguru import logger from ultralytics import YOLO -from app.config import Settings, settings +from app.baked import algorithm as ba def _ensure_yolo_config_dir() -> None: @@ -361,14 +361,20 @@ def window_bucket_to_best_snap( class ConsumableVisionAlgorithmService: """手部检测(可选)+ 耗材分类;供 CameraSessionManager 在视频线程中调用。""" - def __init__(self, app_settings: Settings | None = None) -> None: + def __init__(self, *, labels_yaml_path: str | None = None) -> None: _ensure_yolo_config_dir() - self._s = app_settings or settings + self._labels_yaml_path = labels_yaml_path self._det: YOLO | None = None self._cls: YOLO | None = None self._det_lock = Lock() self._cls_lock = Lock() + def _labels_path(self) -> Path: + raw = self._labels_yaml_path + if raw is not None and str(raw).strip(): + return Path(str(raw).strip()).expanduser() + return Path(ba.CONSUMABLE_CLASSIFIER_LABELS_YAML_PATH).expanduser() + def effective_candidate_consumables(self, requested: list[str]) -> list[str]: """请求体中的耗材子集;未提供(缺省或仅空白)时先用 ``consumable_classifier_labels.yaml`` 的 ``names``,无有效 YAML 则分类模型类名。""" out: list[str] = [] @@ -382,7 +388,7 @@ class ConsumableVisionAlgorithmService: if out: return out - yaml_path = Path(self._s.consumable_classifier_labels_yaml_path).expanduser() + yaml_path = self._labels_path() if yaml_path.is_file(): ylist = list_sorted_class_names_from_yaml(yaml_path) if ylist: @@ -404,7 +410,7 @@ class ConsumableVisionAlgorithmService: if not candidates_norm: return {} - yaml_path = Path(self._s.consumable_classifier_labels_yaml_path).expanduser() + yaml_path = self._labels_path() yaml_map: dict[str, str] = {} if yaml_path.is_file(): try: @@ -420,19 +426,14 @@ class ConsumableVisionAlgorithmService: return out def _det_weights(self) -> Path | None: - raw = (self._s.hand_detection_weights or "").strip() + raw = (ba.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() + p = Path(ba.CONSUMABLE_CLASSIFIER_WEIGHTS).expanduser().resolve() if not p.is_file(): raise ModelNotConfiguredError(f"耗材分类权重不存在: {p}") return p @@ -468,7 +469,7 @@ class ConsumableVisionAlgorithmService: imgsz_det: int, ) -> np.ndarray | None: h, w = frame.shape[:2] - device = resolve_inference_device(self._s.hand_detection_device) + device = resolve_inference_device(ba.HAND_DETECTION_DEVICE) results = det_model.predict( frame, conf=det_conf, @@ -499,28 +500,28 @@ class ConsumableVisionAlgorithmService: 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, + det_conf=ba.HAND_DETECTION_CONF, + pad_ratio=ba.HAND_DETECTION_PAD_RATIO, + min_crop_px=ba.HAND_DETECTION_MIN_CROP_PX, + imgsz_det=ba.HAND_DETECTION_IMGSZ, ) if crop is None: return None else: crop = frame - device = resolve_inference_device(self._s.consumable_classifier_device) + device = resolve_inference_device(ba.CONSUMABLE_CLASSIFIER_DEVICE) try: r = cls_model.predict( crop, - imgsz=self._s.consumable_classifier_imgsz, + imgsz=ba.CONSUMABLE_CLASSIFIER_IMGSZ, device=device, verbose=False, ) except Exception as exc: raise PredictionError(f"耗材分类推理失败: {exc}") from exc - yp = Path(self._s.consumable_classifier_labels_yaml_path).expanduser() + yp = self._labels_path() if yp.is_file(): st = yp.stat() index_to_label_id = _cached_index_to_label_id( @@ -537,7 +538,7 @@ class ConsumableVisionAlgorithmService: ) if snap is None: return None - if snap.t1_conf < self._s.consumable_min_cls_confidence: + if snap.t1_conf < ba.CONSUMABLE_MIN_CLS_CONFIDENCE: return None pname = snap.t1_name if not pname: diff --git a/app/services/consumption_tsv_log.py b/app/services/consumption_tsv_log.py index f6a64f7..06fa6f5 100644 --- a/app/services/consumption_tsv_log.py +++ b/app/services/consumption_tsv_log.py @@ -15,7 +15,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from loguru import logger -from app.config import settings +from app.baked import pipeline as bp from app.services.consumable_vision_algorithm import ClsTop3, _norm_product_name from app.terminal_markdown import print_markdown_stderr @@ -32,7 +32,7 @@ _lock = threading.Lock() def _consumption_tzinfo(): - raw = (settings.consumption_log_timezone or "").strip() + raw = (bp.CONSUMPTION_LOG_TIMEZONE or "").strip() if not raw: lt = datetime.now().astimezone().tzinfo return lt if lt is not None else timezone.utc @@ -166,7 +166,7 @@ def _safe_surgery_path_segment(surgery_id: str) -> str: def resolved_consumption_log_path(surgery_id: str) -> Path: - raw = (settings.consumption_tsv_log_path or "logs/consumption_{surgery_id}.txt").strip() + raw = (bp.CONSUMPTION_TSV_LOG_PATH or "logs/consumption_{surgery_id}.txt").strip() safe = _safe_surgery_path_segment(surgery_id) if "{surgery_id}" in raw: raw = raw.replace("{surgery_id}", safe) @@ -184,7 +184,7 @@ def resolved_consumption_log_path(surgery_id: str) -> Path: def init_consumption_log_file(surgery_id: str) -> None: """新手术开始:截断该手术对应文件并写入表头(一次)。""" - if not settings.consumption_tsv_log_enabled: + if not bp.CONSUMPTION_TSV_LOG_ENABLED: return path = resolved_consumption_log_path(surgery_id) path.parent.mkdir(parents=True, exist_ok=True) @@ -194,7 +194,7 @@ def init_consumption_log_file(surgery_id: str) -> None: def append_consumption_tsv_line(surgery_id: str, line: str) -> None: - if not settings.consumption_tsv_log_enabled: + if not bp.CONSUMPTION_TSV_LOG_ENABLED: return path = resolved_consumption_log_path(surgery_id) path.parent.mkdir(parents=True, exist_ok=True) @@ -322,9 +322,9 @@ def append_consumption_pending_window( markdown_terminal: bool | None = None, ) -> None: """需医生确认的时间窗:落盘/终端记「待确认」,top2/3 仍保留模型提示;不更新消耗汇总。""" - en_tsv = settings.consumption_tsv_log_enabled if tsv_enabled is None else tsv_enabled + en_tsv = bp.CONSUMPTION_TSV_LOG_ENABLED if tsv_enabled is None else tsv_enabled en_md = ( - settings.consumption_log_markdown_terminal + bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL if markdown_terminal is None else markdown_terminal ) @@ -411,7 +411,7 @@ def replace_pending_line_with_voice_resolution( 未找到 ``pending:{confirmation_id}`` 时回退为追加(兼容旧文件或行缺失)。读改写全程持 :data:`_lock`,避免与并发的 append 交错。 """ - en = settings.consumption_tsv_log_enabled if tsv_enabled is None else tsv_enabled + en = bp.CONSUMPTION_TSV_LOG_ENABLED if tsv_enabled is None else tsv_enabled if not en: return if not (chosen_label or "").strip() or not (confirmation_id or "").strip(): @@ -485,7 +485,7 @@ def append_consumption_log_summary( totals: dict[str, tuple[str, int]], ) -> None: """在明细行之后追加汇总块(表头 + 每物品一行)。""" - if not settings.consumption_tsv_log_enabled or not totals: + if not bp.CONSUMPTION_TSV_LOG_ENABLED or not totals: return path = resolved_consumption_log_path(surgery_id) if not path.is_file(): @@ -505,7 +505,7 @@ def append_consumption_log_summary( def print_consumption_summary_markdown( totals: dict[str, tuple[str, int]], ) -> None: - if not settings.consumption_log_markdown_terminal or not totals: + if not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL or not totals: return lines = [ "## 消耗汇总", @@ -527,11 +527,11 @@ class ConsumptionTsvWriter: 行为与模块级函数完全一致;保留模块级函数以维持旧调用点的兼容期。 """ - def __init__(self, app_settings) -> None: - self._s = app_settings + def __init__(self, app_settings=None) -> None: + _ = app_settings def init_file(self, surgery_id: str) -> None: - if not self._s.consumption_tsv_log_enabled: + if not bp.CONSUMPTION_TSV_LOG_ENABLED: return path = resolved_consumption_log_path(surgery_id) path.parent.mkdir(parents=True, exist_ok=True) @@ -550,9 +550,9 @@ class ConsumptionTsvWriter: wall_start_epoch: float, wall_end_epoch: float, ) -> None: - if not self._s.consumption_tsv_log_enabled and not self._s.consumption_log_markdown_terminal: + if not bp.CONSUMPTION_TSV_LOG_ENABLED and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL: return - if self._s.consumption_tsv_log_enabled: + if bp.CONSUMPTION_TSV_LOG_ENABLED: line = build_tsv_line( name_to_code=name_to_code, best=best, @@ -562,7 +562,7 @@ class ConsumptionTsvWriter: wall_end_epoch=wall_end_epoch, ) append_consumption_tsv_line(surgery_id, line) - if self._s.consumption_log_markdown_terminal: + if bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL: print_markdown_stderr( build_consumption_markdown( name_to_code=name_to_code, @@ -579,7 +579,7 @@ class ConsumptionTsvWriter: surgery_id: str, totals: dict[str, tuple[str, int]], ) -> None: - if not self._s.consumption_tsv_log_enabled or not totals: + if not bp.CONSUMPTION_TSV_LOG_ENABLED or not totals: return path = resolved_consumption_log_path(surgery_id) if not path.is_file(): @@ -596,7 +596,7 @@ class ConsumptionTsvWriter: f.write(body) def print_summary_markdown(self, totals: dict[str, tuple[str, int]]) -> None: - if not self._s.consumption_log_markdown_terminal or not totals: + if not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL or not totals: return lines = [ "## 消耗汇总", @@ -622,9 +622,9 @@ def append_consumption_window( wall_start_epoch: float, wall_end_epoch: float, ) -> None: - if not settings.consumption_tsv_log_enabled and not settings.consumption_log_markdown_terminal: + if not bp.CONSUMPTION_TSV_LOG_ENABLED and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL: return - if settings.consumption_tsv_log_enabled: + if bp.CONSUMPTION_TSV_LOG_ENABLED: line = build_tsv_line( name_to_code=name_to_code, best=best, @@ -634,7 +634,7 @@ def append_consumption_window( wall_end_epoch=wall_end_epoch, ) append_consumption_tsv_line(surgery_id, line) - if settings.consumption_log_markdown_terminal: + if bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL: print_markdown_stderr( build_consumption_markdown( name_to_code=name_to_code, diff --git a/app/services/tear_gated_segment_consumption/__init__.py b/app/services/tear_gated_segment_consumption/__init__.py new file mode 100644 index 0000000..9acebc7 --- /dev/null +++ b/app/services/tear_gated_segment_consumption/__init__.py @@ -0,0 +1,13 @@ +"""撕段门控 + 41 类耗材:与 `refs/haocai_consumption_demo_pack` 同构,输入为 RTSP。""" + +from app.services.tear_gated_segment_consumption.runner import ( + TearGatedSegmentModelBundle, + TearGatedSegmentRecord, + TearGatedSegmentRunner, +) + +__all__ = [ + "TearGatedSegmentModelBundle", + "TearGatedSegmentRecord", + "TearGatedSegmentRunner", +] diff --git a/app/services/tear_gated_segment_consumption/geometry.py b/app/services/tear_gated_segment_consumption/geometry.py new file mode 100644 index 0000000..55d7c1b --- /dev/null +++ b/app/services/tear_gated_segment_consumption/geometry.py @@ -0,0 +1,92 @@ +"""手部框、撕动作几何与概率(从离线 tear 脚本抽离,不依赖 OpenCV 绘制)。""" + +from __future__ import annotations + +from itertools import combinations +from typing import Any + +import numpy as np +from ultralytics import YOLO + + +def union_boxes(boxes: list[list[float]]) -> list[float]: + x1 = min(b[0] for b in boxes) + y1 = min(b[1] for b in boxes) + x2 = max(b[2] for b in boxes) + y2 = max(b[3] for b in boxes) + return [x1, y1, x2, y2] + + +def pad_box( + xyxy: list[float], + img_w: int, + img_h: int, + pad_ratio: float = 0.30, +) -> tuple[int, int, int, int]: + x1, y1, x2, y2 = xyxy + bw, bh = x2 - x1, y2 - y1 + px, py = bw * pad_ratio, bh * pad_ratio + return ( + max(0, int(x1 - px)), + max(0, int(y1 - py)), + min(img_w, int(x2 + px)), + min(img_h, int(y2 + py)), + ) + + +def collect_hand_boxes(det_model: YOLO, boxes) -> list[list[float]]: + names = det_model.names + out: list[list[float]] = [] + for box in boxes: + cid = int(box.cls[0]) + label = names.get(cid, "") + if label == "hand": + out.append(box.xyxy[0].tolist()) + return out + + +def box_edge_distance(a: list[float], b: list[float]) -> float: + dx = max(0, max(a[0], b[0]) - min(a[2], b[2])) + dy = max(0, max(a[1], b[1]) - min(a[3], b[3])) + return float((dx**2 + dy**2) ** 0.5) + + +def box_avg_width(boxes: list[list[float]]) -> float: + if not boxes: + return 0.0 + return sum(b[2] - b[0] for b in boxes) / len(boxes) + + +def find_tearing_pair( + hand_boxes: list[list[float]], + gap_ratio: float = 1.5, +) -> tuple[list[float], list[float]] | None: + if len(hand_boxes) < 2: + return None + avg_w = box_avg_width(hand_boxes) + threshold = avg_w * gap_ratio + best_dist = float("inf") + best_pair: tuple[list[float], list[float]] | None = None + for a, b in combinations(hand_boxes, 2): + d = box_edge_distance(a, b) + if d < best_dist: + best_dist = d + best_pair = (a, b) + if best_pair is not None and best_dist <= threshold: + return best_pair + return None + + +def prob_tearing(tprobs, tear_names: dict[Any, str]) -> float: + if tprobs is None: + return 0.0 + data = tprobs.data + if data is None: + return 0.0 + d = data.detach().float().cpu().numpy().ravel() + for i, name in tear_names.items(): + if name == "tearing": + ii = int(i) + if 0 <= ii < len(d): + return float(d[ii]) + return 0.0 diff --git a/app/services/tear_gated_segment_consumption/product_map.py b/app/services/tear_gated_segment_consumption/product_map.py new file mode 100644 index 0000000..ce0528c --- /dev/null +++ b/app/services/tear_gated_segment_consumption/product_map.py @@ -0,0 +1,27 @@ +"""撕段段级结果中「类名 -> 业务物品 id」:与现网一致使用 ``consumable_classifier_labels.yaml``。""" + +from __future__ import annotations + +from pathlib import Path + +from loguru import logger + +from app.baked import algorithm as ba +from app.services.consumable_vision_algorithm import load_name_to_label_id_from_yaml + + +def resolve_tear_segment_labels_yaml_path() -> Path: + """非空 `TEAR_SEGMENT_LABELS_YAML_PATH` 优先;否则与耗材管线共用默认 labels。""" + override = (ba.TEAR_SEGMENT_LABELS_YAML_PATH or "").strip() + if override: + return Path(override).expanduser().resolve() + return Path(ba.CONSUMABLE_CLASSIFIER_LABELS_YAML_PATH).expanduser().resolve() + + +def load_tear_segment_name_to_id() -> dict[str, str]: + """`names` + `label_id` 与 ``ConsumableVisionAlgorithmService`` 同口径(含 `_norm_product_name` 键)。""" + p = resolve_tear_segment_labels_yaml_path() + m = load_name_to_label_id_from_yaml(p) + if not m: + logger.warning("撕段 name→id 映射为空,请检查 YAML: {}", p) + return m diff --git a/app/services/tear_gated_segment_consumption/report.py b/app/services/tear_gated_segment_consumption/report.py new file mode 100644 index 0000000..06e057f --- /dev/null +++ b/app/services/tear_gated_segment_consumption/report.py @@ -0,0 +1,47 @@ +"""与离线 demo main.py 同结构的段级文本报告(可选落盘)。""" + +from __future__ import annotations + +from pathlib import Path + +from app.services.tear_gated_segment_consumption.runner import TearGatedSegmentRecord + + +def write_tear_segment_txt( + *, + path: Path, + surgery_id: str, + camera_id: str, + labels_source: str, + records: list[TearGatedSegmentRecord], +) -> None: + path.parent.mkdir(parents=True, exist_ok=True) + lines: list[str] = [] + lines.append("耗材消耗推断(撕段模式 / FastAPI)") + lines.append(f"手术: {surgery_id} camera: {camera_id}") + lines.append(f"类名→label_id YAML: {labels_source}") + lines.append( + "说明: 人手检测 -> 撕二分类 -> 好帧门控(坏帧不判耗材) -> 41 类; " + "段内为概率向量平均; 众数作展示对照" + ) + lines.append("") + for rec in records: + line = "\t".join( + [ + f"段{rec.segment_index}", + f"时间戳(秒)={rec.mid_stream_sec:.3f};范围={rec.start_sec:.2f}~{rec.end_sec:.2f}", + f"物品id={rec.item_id}", + f"物品名称(模型top1)={rec.item_name}", + f"top1置信度(段内平均)={rec.top1_conf:.4f}", + f"top2={rec.top2_name}", + f"top2_conf={rec.top2_conf:.4f}", + f"top3={rec.top3_name}", + f"top3_conf={rec.top3_conf:.4f}", + f"段内众数(参考)={rec.majority_ref}", + f"消耗数量=1", + f"医生id=暂无", + ] + ) + lines.append(line) + lines.append("") + path.write_text("\n".join(lines) + "\n", encoding="utf-8") diff --git a/app/services/tear_gated_segment_consumption/runner.py b/app/services/tear_gated_segment_consumption/runner.py new file mode 100644 index 0000000..a9ae2f5 --- /dev/null +++ b/app/services/tear_gated_segment_consumption/runner.py @@ -0,0 +1,334 @@ +"""有状态逐帧处理 + 停录时段级汇总(与 haocai_consumption demo main.run 同构)。""" + +from __future__ import annotations + +import time +from collections import Counter +from dataclasses import dataclass +from pathlib import Path +from threading import Lock +import numpy as np +from loguru import logger +from ultralytics import YOLO + +from app.baked import algorithm as ba +from app.services.consumable_vision_algorithm import ( + _norm_product_name, + resolve_inference_device, +) +from app.services.tear_gated_segment_consumption.geometry import ( + collect_hand_boxes, + find_tearing_pair, + pad_box, + prob_tearing, + union_boxes, +) +from app.services.tear_gated_segment_consumption.segments import merge_tear_segments + + +@dataclass(frozen=True) +class TearGatedSegmentRecord: + """单段输出,与离线 txt 一行语义一致。""" + + segment_index: int + start_sec: float + end_sec: float + mid_stream_sec: float + item_id: str + item_name: str + top1_conf: float + top2_name: str + top2_conf: float + top3_name: str + top3_conf: float + majority_ref: str + + +def is_good_frame( + gb_model: YOLO, crop: np.ndarray, gb_names: dict, imgsz: int, device: str | None +) -> bool: + if crop.size == 0: + return False + r = gb_model.predict(crop, imgsz=imgsz, verbose=False, device=device)[0] + if r.probs is None: + return False + tid = int(r.probs.top1) + label = str(gb_names.get(tid, "")) + return label == "good" + + +def haocai_mean_topk( + probs_list: list[np.ndarray], + names: dict, +) -> tuple[str, float, str, float, str, float]: + if not probs_list: + return "(无有效帧)", 0.0, "", 0.0, "", 0.0 + p = np.mean(np.stack(probs_list, axis=0), axis=0) + order = np.argsort(-p) + t1, t2, t3 = (int(order[0]), int(order[1]), int(order[2])) + return ( + str(names.get(t1, str(t1))), + float(p[t1]), + str(names.get(t2, str(t2))), + float(p[t2]), + str(names.get(t3, str(t3))), + float(p[t3]), + ) + + +class TearGatedSegmentRunner: + """从首帧到 finalize:累积 timeline,停录时合并撕段并生成记录。""" + + def __init__( + self, + *, + det_m: YOLO, + tear_m: YOLO, + gb_m: YOLO, + haoc_m: YOLO, + name_to_id: dict[str, str], + ) -> None: + self._det_m = det_m + self._tear_m = tear_m + self._gb_m = gb_m + self._haoc_m = haoc_m + self._tear_names = tear_m.names + self._gb_names = gb_m.names + self._haoc_names = haoc_m.names + self._n_h = len(self._haoc_names) if isinstance(self._haoc_names, dict) else 41 + self._name_to_id = name_to_id + self._lock = Lock() + self._frame_idx = 0 + self._tear_buf: list[float] = [] + self._timeline: list[tuple[int, float, bool, str]] = [] + self._frame_probs: list[np.ndarray | None] = [] + self._wall_t0: float | None = None + self._start_seconds = 0.0 + + def _effective_fps(self) -> float: + raw = float(ba.TEAR_SEGMENT_ASSUMED_FPS) + return raw if raw > 0 else 25.0 + + def process_frame_bgr(self, frame: np.ndarray) -> None: + """处理单帧 BGR(与 demo run() 主循环体一致)。""" + with self._lock: + if self._wall_t0 is None: + self._wall_t0 = time.time() + det_conf = ba.TEAR_SEGMENT_DET_CONF + pad_ratio = ba.TEAR_SEGMENT_PAD_RATIO + tear_conf = ba.TEAR_SEGMENT_TEAR_CONF + tear_smooth = ba.TEAR_SEGMENT_TEAR_SMOOTH + gap_ratio = ba.TEAR_SEGMENT_GAP_RATIO + fps = self._effective_fps() + w = int(frame.shape[1]) + h = int(frame.shape[0]) + start_seconds = self._start_seconds + fidx = self._frame_idx + t_abs = start_seconds + fidx / fps + + r = self._det_m.predict( + frame, + conf=det_conf, + imgsz=ba.TEAR_SEGMENT_DET_IMGSZ, + device=resolve_inference_device(ba.HAND_DETECTION_DEVICE), + verbose=False, + ) + hand_xyxys = collect_hand_boxes(self._det_m, r[0].boxes) + geom = ( + len(hand_xyxys) >= 2 + and find_tearing_pair(hand_xyxys, gap_ratio=gap_ratio) is not None + ) + f_probs: np.ndarray | None = None + rec_label = "" + is_tear = False + max_p = 0.0 + if len(hand_xyxys) >= 1: + merged = union_boxes(hand_xyxys) + cx1, cy1, cx2, cy2 = pad_box(merged, w, h, pad_ratio) + gb_dev = resolve_inference_device(ba.TEAR_SEGMENT_GOODBAD_DEVICE) + haoc_dev = resolve_inference_device(ba.TEAR_SEGMENT_HAOCAI_DEVICE) + for hbox in hand_xyxys: + hx1, hy1, hx2, hy2 = pad_box(hbox, w, h, pad_ratio) + hc = frame[hy1:hy2, hx1:hx2] + if hc.size > 0: + tr = self._tear_m.predict( + hc, + imgsz=ba.TEAR_SEGMENT_TEAR_IMGSZ, + verbose=False, + device=resolve_inference_device( + ba.TEAR_SEGMENT_TEAR_DEVICE + ), + ) + max_p = max( + max_p, prob_tearing(tr[0].probs, self._tear_names) + ) + if tear_smooth > 0: + self._tear_buf.append(max_p) + if len(self._tear_buf) > tear_smooth: + self._tear_buf.pop(0) + p_eff = sum(self._tear_buf) / len(self._tear_buf) + else: + p_eff = max_p + eff = tear_conf * 0.55 if geom else tear_conf + is_tear = p_eff >= eff + + if is_tear: + cls_c = frame[cy1:cy2, cx1:cx2] + if cls_c.size > 0 and is_good_frame( + self._gb_m, + cls_c, + self._gb_names, + ba.TEAR_SEGMENT_GOODBAD_IMGSZ, + gb_dev, + ): + h_r = self._haoc_m.predict( + cls_c, + imgsz=ba.TEAR_SEGMENT_HAOCAI_IMGSZ, + verbose=False, + device=haoc_dev, + )[0] + pr = h_r.probs + if pr is not None and pr.data is not None: + v = pr.data.detach().float().cpu().numpy().ravel() + n_exp = self._n_h + if v.size < n_exp: + v = np.resize(v, n_exp) + v = v[:n_exp] + s = v.sum() + f_probs = (v / s) if s > 0 else v + tid = int(np.argmax(f_probs)) + rec_label = str(self._haoc_names.get(tid, str(tid))) + else: + self._tear_buf.clear() + + self._timeline.append((fidx, t_abs, is_tear, rec_label)) + self._frame_probs.append(f_probs) + self._frame_idx += 1 + if self._frame_idx % 200 == 0: + logger.info( + "tear_segment: processed {} frames (surgery stream)", + self._frame_idx, + ) + + def finalize(self) -> list[TearGatedSegmentRecord]: + """段合并 + 段内 topK 与 YAML 类名→label_id 映射;RTSP 无片尾,以停录为界。""" + with self._lock: + timeline = self._timeline + frame_probs = self._frame_probs + haoc_names = self._haoc_names + name_to_id = self._name_to_id + + if not timeline: + return [] + + segs = merge_tear_segments( + timeline, + min_tear_sec=ba.TEAR_SEGMENT_MIN_TEAR_SEC, + min_gap_sec=ba.TEAR_SEGMENT_MIN_GAP_SEC, + ) + out: list[TearGatedSegmentRecord] = [] + for s in segs: + f0, f1 = s["start_frame"], s["end_frame"] + probs_ok: list[np.ndarray] = [] + lbs: list[str] = [] + for fi in range(f0, f1 + 1): + if 0 <= fi < len(frame_probs) and frame_probs[fi] is not None: + probs_ok.append(frame_probs[fi]) + for fi in range(f0, f1 + 1): + if 0 <= fi < len(timeline): + _, __, it, lab = timeline[fi] + if it and lab: + lbs.append(lab) + if lbs: + majority = Counter(lbs).most_common(1)[0][0] + else: + majority = "(本段无好帧+耗材)" + + t1, c1, t2, c2, t3, c3 = haocai_mean_topk(probs_ok, haoc_names) + use_name = t1 + if use_name in ("", "(无有效帧)"): + use_name = majority + if use_name.startswith("(") or use_name == "(本段无好帧+耗材)": + item_id = "(无)" + else: + key = _norm_product_name(use_name) + item_id = name_to_id.get(key, "(无匹配编码)") + t_mid = 0.5 * (s["start_sec"] + s["end_sec"]) + out.append( + TearGatedSegmentRecord( + segment_index=s["index"], + start_sec=s["start_sec"], + end_sec=s["end_sec"], + mid_stream_sec=t_mid, + item_id=item_id, + item_name=use_name, + top1_conf=c1, + top2_name=t2, + top2_conf=c2, + top3_name=t3, + top3_conf=c3, + majority_ref=majority, + ) + ) + return out + + def wall_time_for_record(self, rec: TearGatedSegmentRecord) -> float: + """段中点对应的 Unix 时间(秒),用于落库时间戳。""" + with self._lock: + t0w = self._wall_t0 + if t0w is None: + return time.time() + return t0w + rec.mid_stream_sec + + +class TearGatedSegmentModelBundle: + """四模型只加载一次,供多例 Runner 复用。""" + + def __init__(self) -> None: + self._lock = Lock() + self._det: YOLO | None = None + self._tear: YOLO | None = None + self._gb: YOLO | None = None + self._haoc: YOLO | None = None + + def _p(self, key: str) -> Path: + return Path((key or "").strip()).expanduser().resolve() + + def _load(self) -> None: + with self._lock: + if self._det is not None: + return + dp = self._p(ba.TEAR_SEGMENT_HAND_DET_WEIGHTS) + tp = self._p(ba.TEAR_SEGMENT_TEAR_WEIGHTS) + gp = self._p(ba.TEAR_SEGMENT_GOODBAD_WEIGHTS) + hp = self._p(ba.TEAR_SEGMENT_HAOCAI_WEIGHTS) + for p, label in ( + (dp, "hand det"), + (tp, "tear"), + (gp, "good/bad"), + (hp, "haocai 41"), + ): + if not p.is_file(): + raise FileNotFoundError(f"tear_segment {label} 权重不存在: {p}") + logger.info("加载撕段四模型: {} {} {} {}", dp, tp, gp, hp) + self._det = YOLO(str(dp)) + self._tear = YOLO(str(tp)) + self._gb = YOLO(str(gp)) + self._haoc = YOLO(str(hp)) + + def ensure_loaded(self) -> None: + self._load() + + def create_runner(self, name_to_id: dict[str, str]) -> TearGatedSegmentRunner: + self.ensure_loaded() + assert self._det is not None + assert self._tear is not None + assert self._gb is not None + assert self._haoc is not None + return TearGatedSegmentRunner( + det_m=self._det, + tear_m=self._tear, + gb_m=self._gb, + haoc_m=self._haoc, + name_to_id=name_to_id, + ) diff --git a/app/services/tear_gated_segment_consumption/segments.py b/app/services/tear_gated_segment_consumption/segments.py new file mode 100644 index 0000000..6040c9c --- /dev/null +++ b/app/services/tear_gated_segment_consumption/segments.py @@ -0,0 +1,78 @@ +"""撕段时间线合并(与离线 demo 一致)。""" + +from __future__ import annotations + +from collections import Counter + + +def merge_tear_segments( + rows: list[tuple[int, float, bool, str]], + min_tear_sec: float = 3.0, + min_gap_sec: float = 1.5, +) -> list[dict]: + """ + rows: (frame_idx_in_clip, abs_time_sec, is_tear, consumable_label_or_empty) + """ + raw_segs: list[dict] = [] + cur: dict | None = None + + for frame_idx, t, is_tear, label in rows: + if is_tear: + if cur is None: + cur = { + "t0": t, + "t1": t, + "f0": frame_idx, + "f1": frame_idx, + "labels": [], + } + else: + gap = t - cur["t1"] + if gap > min_gap_sec: + raw_segs.append(cur) + cur = { + "t0": t, + "t1": t, + "f0": frame_idx, + "f1": frame_idx, + "labels": [], + } + cur["t1"] = t + cur["f1"] = frame_idx + if label: + cur["labels"].append(label) + else: + if cur is not None: + gap = t - cur["t1"] + if gap > min_gap_sec: + raw_segs.append(cur) + cur = None + + if cur is not None: + raw_segs.append(cur) + + valid: list[dict] = [] + for s in raw_segs: + dur = s["t1"] - s["t0"] + if dur >= min_tear_sec - 1e-9: + valid.append(s) + + out: list[dict] = [] + for i, s in enumerate(valid, 1): + top_label = ( + Counter(s["labels"]).most_common(1)[0][0] + if s["labels"] + else "(未识别耗材)" + ) + out.append( + { + "index": i, + "start_sec": s["t0"], + "end_sec": s["t1"], + "duration_sec": s["t1"] - s["t0"], + "consumable": top_label, + "start_frame": s["f0"], + "end_frame": s["f1"], + } + ) + return out diff --git a/app/services/video/archive_persister.py b/app/services/video/archive_persister.py index af5f3fe..1c4c705 100644 --- a/app/services/video/archive_persister.py +++ b/app/services/video/archive_persister.py @@ -22,7 +22,7 @@ from typing import TYPE_CHECKING from loguru import logger from sqlalchemy.ext.asyncio import async_sessionmaker -from app.config import Settings +from app.baked import pipeline as bp from app.domain.consumption import SurgeryConsumptionStored if TYPE_CHECKING: @@ -86,11 +86,9 @@ class ArchivePersister: def __init__( self, *, - settings: Settings, repository: "SurgeryResultRepository | None", session_factory: async_sessionmaker, ) -> None: - self._s = settings self._repo = repository self._session_factory = session_factory self._archive: dict[str, _ArchiveEntry] = {} @@ -139,7 +137,7 @@ class ArchivePersister: if await self._write_to_db(surgery_id, details): return True entry = _ArchiveEntry(details=list(details)) - if self._s.archive_persist_durable_fallback_enabled: + if bp.ARCHIVE_PERSIST_DURABLE_FALLBACK_ENABLED: entry.durable_path = self._write_durable(surgery_id, details) async with self._lock: self._archive[surgery_id] = entry @@ -193,9 +191,9 @@ class ArchivePersister: async def recover_from_durable_fallback(self) -> int: """进程启动时调用:从 durable 目录把未写库的归档读回内存。""" - if not self._s.archive_persist_durable_fallback_enabled: + if not bp.ARCHIVE_PERSIST_DURABLE_FALLBACK_ENABLED: return 0 - directory = Path(self._s.archive_persist_durable_fallback_dir) + directory = Path(bp.ARCHIVE_PERSIST_DURABLE_FALLBACK_DIR) if not directory.exists(): return 0 loaded = 0 @@ -250,7 +248,7 @@ class ArchivePersister: surgery_id: str, details: list[SurgeryConsumptionStored], ) -> Path | None: - directory = Path(self._s.archive_persist_durable_fallback_dir) + directory = Path(bp.ARCHIVE_PERSIST_DURABLE_FALLBACK_DIR) try: directory.mkdir(parents=True, exist_ok=True) except Exception as exc: @@ -281,15 +279,15 @@ class ArchivePersister: logger.debug("remove durable archive {} failed: {}", path, exc) def _next_backoff_seconds(self, attempts: int) -> float: - base = float(self._s.archive_persist_retry_interval_seconds) - cap = float(self._s.archive_persist_backoff_cap_seconds) + base = float(bp.ARCHIVE_PERSIST_RETRY_INTERVAL_SECONDS) + cap = float(bp.ARCHIVE_PERSIST_BACKOFF_CAP_SECONDS) # 指数退避:base * 2^(attempts-1),首个间隔即 base。 exp = max(0, attempts - 1) return min(cap, base * (2**exp)) async def _retry_loop(self) -> None: - base = float(self._s.archive_persist_retry_interval_seconds) - max_attempts = int(self._s.archive_persist_max_retries) + base = float(bp.ARCHIVE_PERSIST_RETRY_INTERVAL_SECONDS) + max_attempts = int(bp.ARCHIVE_PERSIST_MAX_RETRIES) while not self._retry_stop.is_set(): try: await asyncio.wait_for(self._retry_stop.wait(), timeout=base) diff --git a/app/services/video/classification_handler.py b/app/services/video/classification_handler.py index 5c64a56..da4cb4d 100644 --- a/app/services/video/classification_handler.py +++ b/app/services/video/classification_handler.py @@ -14,7 +14,7 @@ from __future__ import annotations from loguru import logger -from app.config import Settings +from app.baked import pipeline as bp from app.services.consumable_vision_algorithm import ( PredictionCandidate, PredictionResult, @@ -54,10 +54,8 @@ class VisionClassificationHandler: def __init__( self, *, - settings: Settings, registry: SurgerySessionRegistry, ) -> None: - self._s = settings self._registry = registry def _append_vision_consumption_window_if_ready( @@ -72,8 +70,8 @@ class VisionClassificationHandler: or not surgery_id or not camera_id or ( - not self._s.consumption_tsv_log_enabled - and not self._s.consumption_log_markdown_terminal + not bp.CONSUMPTION_TSV_LOG_ENABLED + and not bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL ) ): return @@ -81,7 +79,7 @@ class VisionClassificationHandler: surgery_id=surgery_id, name_to_code=state.name_to_code, best=ready.best, - doctor_id=self._s.video_result_doctor_id, + doctor_id=bp.VIDEO_RESULT_DOCTOR_ID, camera_id=camera_id, wall_start_epoch=ready.wall_lo, wall_end_epoch=ready.wall_hi, @@ -100,7 +98,7 @@ class VisionClassificationHandler: label = (cls_res.label or "").strip() t1_pid = (ready.best.t1_pid if ready is not None else "") item_id = resolve_consumption_item_id(label, t1_pid, state.name_to_code) - voice_floor = self._s.video_voice_confirm_min_confidence + voice_floor = bp.VIDEO_VOICE_CONFIRM_MIN_CONFIDENCE if conf < voice_floor: return @@ -110,7 +108,7 @@ class VisionClassificationHandler: cand_set = set(cand_order) ranked = rank_topk_for_candidates(cls_res.topk, cand_order) - auto_th = self._s.video_auto_confirm_confidence + auto_th = bp.VIDEO_AUTO_CONFIRM_CONFIDENCE def in_allowed(name: str) -> bool: return name in cand_set @@ -123,13 +121,13 @@ class VisionClassificationHandler: state=state, item_id=item_id or "unknown", item_name=label or "unknown", - doctor_id=self._s.video_result_doctor_id, + doctor_id=bp.VIDEO_RESULT_DOCTOR_ID, source="vision", ) return if conf >= auto_th and not in_allowed(label): - if ranked and self._s.voice_confirmation_enabled: + if ranked and bp.VOICE_CONFIRMATION_ENABLED: await self._enqueue( state, ranked, @@ -141,7 +139,7 @@ class VisionClassificationHandler: ) return - if not self._s.voice_confirmation_enabled: + if not bp.VOICE_CONFIRMATION_ENABLED: return if ranked: @@ -190,22 +188,22 @@ class VisionClassificationHandler: top_key, ) if ready is not None and surgery_id and camera_id and ( - self._s.consumption_tsv_log_enabled - or self._s.consumption_log_markdown_terminal + bp.CONSUMPTION_TSV_LOG_ENABLED + or bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL ): append_consumption_pending_window( surgery_id=surgery_id, confirmation_id=cid, model_snap=ready.best, - doctor_id=self._s.video_result_doctor_id, + doctor_id=bp.VIDEO_RESULT_DOCTOR_ID, camera_id=camera_id, wall_start_epoch=ready.wall_lo, wall_end_epoch=ready.wall_hi, - tsv_enabled=self._s.consumption_tsv_log_enabled, - markdown_terminal=self._s.consumption_log_markdown_terminal, + tsv_enabled=bp.CONSUMPTION_TSV_LOG_ENABLED, + markdown_terminal=bp.CONSUMPTION_LOG_MARKDOWN_TERMINAL, ) await self._registry.append_pending_consumption_detail( state=state, confirmation_id=cid, - doctor_id=self._s.video_result_doctor_id, + doctor_id=bp.VIDEO_RESULT_DOCTOR_ID, ) diff --git a/app/services/video/inference_aggregator.py b/app/services/video/inference_aggregator.py index be1326c..00a47dd 100644 --- a/app/services/video/inference_aggregator.py +++ b/app/services/video/inference_aggregator.py @@ -10,7 +10,7 @@ from __future__ import annotations import time from dataclasses import dataclass -from app.config import Settings +from app.baked import algorithm as ba from app.services.consumable_vision_algorithm import ( ClsTop3, PredictionResult, @@ -40,8 +40,8 @@ class WindowInferenceAggregator: 便于与原逻辑保持一致;调用方在持有 ``state.lock`` 时调用下面的方法。 """ - def __init__(self, *, settings: Settings) -> None: - self._s = settings + def __init__(self) -> None: + pass def ingest_snapshot_and_collect_ready( self, @@ -57,7 +57,7 @@ class WindowInferenceAggregator: """ _ = surgery_id _ = camera_id - wsec = self._s.consumable_vision_window_sec + wsec = ba.CONSUMABLE_VISION_WINDOW_SEC ready: list[WindowInferenceReady] = [] cis = state.camera_infer.setdefault(camera_id, CameraStreamInferState()) if cis.stream_t0 is None: diff --git a/app/services/video/session_manager.py b/app/services/video/session_manager.py index ef5240d..1838f0b 100644 --- a/app/services/video/session_manager.py +++ b/app/services/video/session_manager.py @@ -2,9 +2,13 @@ from __future__ import annotations import asyncio import time +from datetime import datetime, timezone +from pathlib import Path from loguru import logger +from app.baked import algorithm as ba +from app.baked import pipeline as bp from app.config import Settings from sqlalchemy.ext.asyncio import async_sessionmaker @@ -27,6 +31,15 @@ from app.services.video.session_registry import ( SurgerySessionRegistry, SurgerySessionState, ) +from app.services.tear_gated_segment_consumption.product_map import ( + load_tear_segment_name_to_id, + resolve_tear_segment_labels_yaml_path, +) +from app.services.tear_gated_segment_consumption.report import write_tear_segment_txt +from app.services.tear_gated_segment_consumption.runner import ( + TearGatedSegmentModelBundle, + TearGatedSegmentRunner, +) from app.services.video.stream_worker import CameraStreamWorker, redact_rtsp_url from app.services.video.types import VideoBackendKind from app.schemas import SurgeryConsumptionDetail, build_consumption_summary @@ -69,21 +82,21 @@ class CameraSessionManager: session_factory: async_sessionmaker | None = None, registry: SurgerySessionRegistry | None = None, archive_persister: ArchivePersister | None = None, + tear_segment_models: TearGatedSegmentModelBundle | None = None, ) -> None: self._s = settings self._vision = vision_algorithm self._hik = hikvision_runtime + self._tear_models = tear_segment_models self._session_factory: async_sessionmaker = session_factory or AsyncSessionLocal self._resolver = BackendResolver(settings, hikvision_runtime=hikvision_runtime) - self._registry = registry or SurgerySessionRegistry(settings=settings) + self._registry = registry or SurgerySessionRegistry() self._archive = archive_persister or ArchivePersister( - settings=settings, repository=result_repository, session_factory=self._session_factory, ) - self._aggregator = WindowInferenceAggregator(settings=settings) + self._aggregator = WindowInferenceAggregator() self._classifier_handler = VisionClassificationHandler( - settings=settings, registry=self._registry, ) @@ -157,7 +170,7 @@ class CameraSessionManager: stop_event = asyncio.Event() readies = [asyncio.Event() for _ in camera_ids] tasks: list[asyncio.Task[None]] = [] - open_timeout = self._s.video_open_timeout_sec + 5.0 + open_timeout = bp.VIDEO_OPEN_TIMEOUT_SEC + 5.0 for cam_id, ready in zip(camera_ids, readies, strict=True): tasks.append( @@ -175,9 +188,18 @@ class CameraSessionManager: run = RunningSurgery(stop_event=stop_event, state=state, tasks=tasks) init_consumption_log_file(surgery_id) - init_voice_log_file(surgery_id, self._s) + init_voice_log_file(surgery_id) await self._registry.register(surgery_id, run) + if ba.TEAR_SEGMENT_ENABLED: + primary = (ba.TEAR_SEGMENT_PRIMARY_CAMERA_ID or "").strip() + if primary and primary not in camera_ids: + logger.warning( + "撕段算法已开启但主摄 id={!r} 不在本台开录 camera_ids={} 中,该路不会跑撕段流水线", + primary, + camera_ids, + ) + try: await asyncio.wait_for( asyncio.gather(*(r.wait() for r in readies)), @@ -292,6 +314,45 @@ class CameraSessionManager: # ------------------------------------------------------------------ # Camera worker(拉流 + 推理节流 + 时间窗分桶 + 分类结果处理) # ------------------------------------------------------------------ + async def _finalize_tear_segment_runner( + self, + *, + surgery_id: str, + camera_id: str, + state: SurgerySessionState, + runner: TearGatedSegmentRunner, + ) -> None: + recs = runner.finalize() + for rec in recs: + wall_ts = runner.wall_time_for_record(rec) + detail_ts = datetime.fromtimestamp(wall_ts, tz=timezone.utc) + await self._registry.append_confirmed_detail( + state=state, + item_id=rec.item_id, + item_name=rec.item_name, + doctor_id=bp.VIDEO_RESULT_DOCTOR_ID, + source="tear_segment", + cooldown_key=f"{surgery_id}:tear_seg:{rec.segment_index}", + detail_timestamp=detail_ts, + ) + if ba.TEAR_SEGMENT_LOG_TXT and recs: + raw_tpl = (ba.TEAR_SEGMENT_LOG_TXT_PATH or "").strip() + if raw_tpl and "{surgery_id}" in raw_tpl: + p = Path(raw_tpl.format(surgery_id=surgery_id)).expanduser() + elif raw_tpl: + p = Path(raw_tpl).expanduser() + else: + p = Path(f"logs/tear_segment_{surgery_id}.txt") + labels_src = str(resolve_tear_segment_labels_yaml_path()) + write_tear_segment_txt( + path=p, + surgery_id=surgery_id, + camera_id=camera_id, + labels_source=labels_src, + records=recs, + ) + logger.info("撕段报告已写: {}", p) + async def _camera_worker( self, *, @@ -311,74 +372,119 @@ class CameraSessionManager: ) assert url is not None - last_infer = 0.0 - - async def _frame_handler(frame: object) -> None: - nonlocal last_infer - now = time.monotonic() - if now - last_infer < self._s.video_inference_interval_sec: - await asyncio.sleep(0.01) - return - last_infer = now + primary = (ba.TEAR_SEGMENT_PRIMARY_CAMERA_ID or "").strip() + use_tear_req = ( + ba.TEAR_SEGMENT_ENABLED + and self._tear_models is not None + and primary + and camera_id == primary + ) + runner: TearGatedSegmentRunner | None = None + if use_tear_req: + name_to_id = load_tear_segment_name_to_id() try: - snap = await asyncio.to_thread( - self._vision.infer_frame_bgr, - frame, - state.name_to_code, - ) + self._tear_models.ensure_loaded() + runner = self._tear_models.create_runner(name_to_id) except Exception as exc: - logger.debug( - "Inference skip camera={} surgery={}: {}", + logger.exception( + "撕段模型未就绪,本路回退为原时间窗算法 camera={} surgery={}: {}", camera_id, surgery_id, exc, ) - return + runner = None - if snap is None: - return + if runner is not None: - if self._s.video_log_inference_results: - logger.info( - "Vision result surgery={} camera={} top1={}({:.3f}) top2={}({:.3f}) top3={}({:.3f})", - surgery_id, - camera_id, - snap.t1_name, - snap.t1_conf, - snap.t2_name, - snap.t2_conf, - snap.t3_name, - snap.t3_conf, + async def _frame_handler_tear(frame: object) -> None: + await asyncio.to_thread(runner.process_frame_bgr, frame) + + w_tear = CameraStreamWorker( + surgery_id=surgery_id, + camera_id=camera_id, + url=url, + ) + try: + await w_tear.run( + stream_ready=stream_ready, + stop_event=stop_event, + frame_handler=_frame_handler_tear, ) - - async with state.lock: - ready_windows = self._aggregator.ingest_snapshot_and_collect_ready( + finally: + await self._finalize_tear_segment_runner( surgery_id=surgery_id, camera_id=camera_id, - snap=snap, state=state, + runner=runner, ) + else: + last_infer = 0.0 - for win in ready_windows: - await self._classifier_handler.handle( - state=state, - cls_res=win.prediction, - ready=win, - surgery_id=surgery_id, - camera_id=camera_id, - ) + async def _frame_handler(frame: object) -> None: + nonlocal last_infer + now = time.monotonic() + if now - last_infer < bp.VIDEO_INFERENCE_INTERVAL_SEC: + await asyncio.sleep(0.01) + return + last_infer = now + try: + snap = await asyncio.to_thread( + self._vision.infer_frame_bgr, + frame, + state.name_to_code, + ) + except Exception as exc: + logger.debug( + "Inference skip camera={} surgery={}: {}", + camera_id, + surgery_id, + exc, + ) + return - worker = CameraStreamWorker( - settings=self._s, - surgery_id=surgery_id, - camera_id=camera_id, - url=url, - ) - await worker.run( - stream_ready=stream_ready, - stop_event=stop_event, - frame_handler=_frame_handler, - ) + if snap is None: + return + + if bp.VIDEO_LOG_INFERENCE_RESULTS: + logger.info( + "Vision result surgery={} camera={} top1={}({:.3f}) top2={}({:.3f}) top3={}({:.3f})", + surgery_id, + camera_id, + snap.t1_name, + snap.t1_conf, + snap.t2_name, + snap.t2_conf, + snap.t3_name, + snap.t3_conf, + ) + + async with state.lock: + ready_windows = self._aggregator.ingest_snapshot_and_collect_ready( + surgery_id=surgery_id, + camera_id=camera_id, + snap=snap, + state=state, + ) + + for win in ready_windows: + await self._classifier_handler.handle( + state=state, + cls_res=win.prediction, + ready=win, + surgery_id=surgery_id, + camera_id=camera_id, + ) + + worker = CameraStreamWorker( + surgery_id=surgery_id, + camera_id=camera_id, + url=url, + ) + await worker.run( + stream_ready=stream_ready, + stop_event=stop_event, + frame_handler=_frame_handler, + ) finally: if hik_user_id is not None and self._hik is not None: await asyncio.to_thread(self._hik.logout, hik_user_id) diff --git a/app/services/video/session_registry.py b/app/services/video/session_registry.py index 4bf207f..9b43bcb 100644 --- a/app/services/video/session_registry.py +++ b/app/services/video/session_registry.py @@ -14,7 +14,7 @@ from dataclasses import dataclass, field from datetime import datetime, timezone from typing import Literal -from app.config import Settings +from app.baked import pipeline as bp from app.domain.consumption import SurgeryConsumptionStored from app.services.consumable_vision_algorithm import ( ClsTop3, @@ -89,8 +89,7 @@ class SurgerySessionRegistry: 生命周期归 ``CameraSessionManager`` 负责,新增/停止会话都走本类。 """ - def __init__(self, *, settings: Settings) -> None: - self._s = settings + def __init__(self) -> None: self._active: dict[str, RunningSurgery] = {} self._manager_lock = asyncio.Lock() @@ -168,7 +167,7 @@ class SurgerySessionRegistry: if run is None: return 0, 0 st = run.state - max_r = int(self._s.voice_confirm_max_failed_parse_rounds) + max_r = int(bp.VOICE_CONFIRM_MAX_FAILED_PARSE_ROUNDS) async with st.lock: p = st.pending_by_id.get(confirmation_id) if p is None or p.status != "pending": @@ -250,7 +249,7 @@ class SurgerySessionRegistry: item_id=item_id, item_name=label, qty=1, - doctor_id=self._s.video_voice_confirm_doctor_id, + doctor_id=bp.VIDEO_VOICE_CONFIRM_DOCTOR_ID, timestamp=datetime.now(timezone.utc), source="voice", pending_confirmation_id=None, @@ -272,7 +271,7 @@ class SurgerySessionRegistry: state=st, item_id=item_id, item_name=label, - doctor_id=self._s.video_voice_confirm_doctor_id, + doctor_id=bp.VIDEO_VOICE_CONFIRM_DOCTOR_ID, source="voice", ) self._finalize_voice_confirmed_consumption_log( @@ -305,9 +304,9 @@ class SurgerySessionRegistry: confirmation_id=confirmation_id, name_to_code=state.name_to_code, chosen_label=cl, - doctor_id=self._s.video_voice_confirm_doctor_id, + doctor_id=bp.VIDEO_VOICE_CONFIRM_DOCTOR_ID, wall_epoch=time.time(), - tsv_enabled=self._s.consumption_tsv_log_enabled, + tsv_enabled=bp.CONSUMPTION_TSV_LOG_ENABLED, ) def _append_confirmed_detail_locked( @@ -318,21 +317,29 @@ class SurgerySessionRegistry: item_name: str, doctor_id: str, source: str, + cooldown_key: str | None = None, + detail_timestamp: datetime | None = None, ) -> None: - """在已持有 ``state.lock`` 时追加一条消耗明细。""" + """在已持有 ``state.lock`` 时追加一条消耗明细。 + + ``cooldown_key``:非空时用于 `video_detail_cooldown_sec` 去重(例如撕段每段独立键,避免同 SKU 多段被吞)。 + ``detail_timestamp``:非空时写入该 UTC 时刻,否则为当前时间。 + """ + dedupe = cooldown_key if cooldown_key is not None else item_id now_m = time.monotonic() - cooldown = self._s.video_detail_cooldown_sec - prev = state.last_detail_monotonic.get(item_id) + cooldown = bp.VIDEO_DETAIL_COOLDOWN_SEC + prev = state.last_detail_monotonic.get(dedupe) if prev is not None and (now_m - prev) < cooldown: return - state.last_detail_monotonic[item_id] = now_m + state.last_detail_monotonic[dedupe] = now_m + ts = detail_timestamp if detail_timestamp is not None else datetime.now(timezone.utc) state.details.append( SurgeryConsumptionStored( item_id=item_id, item_name=item_name, qty=1, doctor_id=doctor_id, - timestamp=datetime.now(timezone.utc), + timestamp=ts, source=source, pending_confirmation_id=None, ) @@ -380,6 +387,8 @@ class SurgerySessionRegistry: item_name: str, doctor_id: str, source: str, + cooldown_key: str | None = None, + detail_timestamp: datetime | None = None, ) -> None: async with state.lock: self._append_confirmed_detail_locked( @@ -388,6 +397,8 @@ class SurgerySessionRegistry: item_name=item_name, doctor_id=doctor_id, source=source, + cooldown_key=cooldown_key, + detail_timestamp=detail_timestamp, ) async def enqueue_pending_confirmation( @@ -403,7 +414,7 @@ class SurgerySessionRegistry: if not opts: return None now_m = time.monotonic() - cooldown = self._s.video_detail_cooldown_sec + cooldown = bp.VIDEO_DETAIL_COOLDOWN_SEC dedupe_key = f"pending_confirm:{top_key}:{opts[0][0]}" async with state.lock: prev = state.last_detail_monotonic.get(dedupe_key) diff --git a/app/services/video/stream_worker.py b/app/services/video/stream_worker.py index 30586de..58abecb 100644 --- a/app/services/video/stream_worker.py +++ b/app/services/video/stream_worker.py @@ -16,7 +16,7 @@ from typing import Awaitable, Callable from loguru import logger -from app.config import Settings +from app.baked import pipeline as bp from app.services.video.rtsp_capture import RtspCapture @@ -38,12 +38,10 @@ class CameraStreamWorker: def __init__( self, *, - settings: Settings, surgery_id: str, camera_id: str, url: str, ) -> None: - self._s = settings self._surgery_id = surgery_id self._camera_id = camera_id self._url = url @@ -65,7 +63,7 @@ class CameraStreamWorker: if cap is None: try: cap = RtspCapture( - self._url, open_timeout_sec=self._s.video_open_timeout_sec + self._url, open_timeout_sec=bp.VIDEO_OPEN_TIMEOUT_SEC ) await asyncio.to_thread(cap.open) consecutive_failures = 0 @@ -89,7 +87,7 @@ class CameraStreamWorker: if cap is not None: await asyncio.to_thread(cap.release) cap = None - await asyncio.sleep(self._s.video_reconnect_backoff_seconds) + await asyncio.sleep(bp.VIDEO_RECONNECT_BACKOFF_SECONDS) continue ok, frame = await asyncio.to_thread(cap.read) @@ -97,7 +95,7 @@ class CameraStreamWorker: consecutive_failures += 1 if ( consecutive_failures - >= self._s.video_read_failure_reconnect_threshold + >= bp.VIDEO_READ_FAILURE_RECONNECT_THRESHOLD ): logger.warning( "RTSP reconnect camera={} surgery={} url={} after {} read failures", @@ -109,7 +107,7 @@ class CameraStreamWorker: await asyncio.to_thread(cap.release) cap = None consecutive_failures = 0 - await asyncio.sleep(self._s.video_reconnect_backoff_seconds) + await asyncio.sleep(bp.VIDEO_RECONNECT_BACKOFF_SECONDS) else: await asyncio.sleep(0.05) continue diff --git a/app/services/voice_audit_emitter.py b/app/services/voice_audit_emitter.py index 8416b6d..d407a05 100644 --- a/app/services/voice_audit_emitter.py +++ b/app/services/voice_audit_emitter.py @@ -116,7 +116,6 @@ class VoiceAuditEmitter: logger.debug("session trace recorder failed: {}", exc) if emit_trace: emit_voice_event( - self._s, surgery_id=surgery_id, source=source, status=status, @@ -154,7 +153,6 @@ class VoiceAuditEmitter: error_message=None, ) emit_voice_event( - self._s, surgery_id=surgery_id, source=source, status=status, diff --git a/app/services/voice_file_log.py b/app/services/voice_file_log.py index 010804b..9bfb743 100644 --- a/app/services/voice_file_log.py +++ b/app/services/voice_file_log.py @@ -10,7 +10,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError from loguru import logger -from app.config import Settings +from app.baked import pipeline as bp _lock = threading.Lock() @@ -28,8 +28,8 @@ def _encode_cell(value: str) -> str: return (value or "").replace("\r", " ").replace("\n", " ").replace("\t", " ") -def _log_tz_info(settings: Settings) -> object: - raw = (settings.consumption_log_timezone or "").strip() +def _log_tz_info() -> object: + raw = (bp.CONSUMPTION_LOG_TIMEZONE or "").strip() if not raw: lt = datetime.now().astimezone().tzinfo return lt if lt is not None else timezone.utc @@ -39,8 +39,8 @@ def _log_tz_info(settings: Settings) -> object: return timezone.utc -def _ts_local_for_display(settings: Settings) -> str: - tz = _log_tz_info(settings) +def _ts_local_for_display() -> str: + tz = _log_tz_info() return datetime.now(tz).isoformat(timespec="milliseconds") @@ -50,8 +50,8 @@ def _safe_surgery_path_segment(surgery_id: str) -> str: return s[:200] if len(s) > 200 else s -def resolved_voice_log_path(surgery_id: str, settings: Settings) -> Path: - raw = (settings.voice_file_log_path or "logs/voice_{surgery_id}.txt").strip() +def resolved_voice_log_path(surgery_id: str) -> Path: + raw = (bp.VOICE_FILE_LOG_PATH or "logs/voice_{surgery_id}.txt").strip() safe = _safe_surgery_path_segment(surgery_id) if "{surgery_id}" in raw: raw = raw.replace("{surgery_id}", safe) @@ -67,21 +67,21 @@ def resolved_voice_log_path(surgery_id: str, settings: Settings) -> Path: return p -def init_voice_log_file(surgery_id: str, settings: Settings) -> None: +def init_voice_log_file(surgery_id: str) -> None: """与 `init_consumption_log_file` 同生命周期:`start_surgery` 时截断并写表头。""" - if not settings.voice_file_log_enabled: + if not bp.VOICE_FILE_LOG_ENABLED: return - path = resolved_voice_log_path(surgery_id, settings) + path = resolved_voice_log_path(surgery_id) path.parent.mkdir(parents=True, exist_ok=True) with _lock: with path.open("w", encoding="utf-8") as f: f.write(HEADER) -def append_voice_tsv_line(surgery_id: str, line: str, settings: Settings) -> None: - if not settings.voice_file_log_enabled: +def append_voice_tsv_line(surgery_id: str, line: str) -> None: + if not bp.VOICE_FILE_LOG_ENABLED: return - path = resolved_voice_log_path(surgery_id, settings) + path = resolved_voice_log_path(surgery_id) path.parent.mkdir(parents=True, exist_ok=True) with _lock: with path.open("a", encoding="utf-8") as f: @@ -89,16 +89,13 @@ def append_voice_tsv_line(surgery_id: str, line: str, settings: Settings) -> Non class VoiceTextLogWriter: - """注入式 voice 日志写入器,封装 `init_file` / `emit_event`。 + """注入式 voice 日志写入器,封装 `init_file` / `emit_event`。""" - 行为等价于模块级函数;保留模块级函数以兼容既有调用点。 - """ - - def __init__(self, app_settings: Settings) -> None: - self._s = app_settings + def __init__(self) -> None: + pass def init_file(self, surgery_id: str) -> None: - init_voice_log_file(surgery_id, self._s) + init_voice_log_file(surgery_id) def emit_event( self, @@ -114,7 +111,6 @@ class VoiceTextLogWriter: audio_object_key: str | None = None, ) -> None: emit_voice_event( - self._s, surgery_id=surgery_id, source=source, status=status, @@ -128,7 +124,6 @@ class VoiceTextLogWriter: def emit_voice_event( - settings: Settings, *, surgery_id: str, source: str, @@ -140,12 +135,6 @@ def emit_voice_event( error_message: str | None = None, audio_object_key: str | None = None, ) -> None: - """ - 终端:单条可 grep 的 VoiceConfirm 行;文件:TSV 一行(与启用的 `voice_file_log_enabled` 一致)。 - - :param source: `wav` | `text` | `n/a` - :param status: 与审计 `status` 或 `minio_not_configured` 等说明型状态一致 - """ rj: str if rejected is None: rj = "" @@ -155,7 +144,7 @@ def emit_voice_event( rj = str(rejected) ts_utc = _ts_iso_utc() - local_hint = _ts_local_for_display(settings) + local_hint = _ts_local_for_display() if status in ("recognized", "rejected"): logger.info( "VoiceConfirm local_ts={!r} surgery_id={} source={} status={} " @@ -189,7 +178,7 @@ def emit_voice_event( audio_object_key, ) - if not settings.voice_file_log_enabled: + if not bp.VOICE_FILE_LOG_ENABLED: return row = [ _encode_cell(ts_utc), @@ -203,4 +192,4 @@ def emit_voice_event( _encode_cell("" if audio_object_key is None else audio_object_key), ] line = "\t".join(row) + "\n" - append_voice_tsv_line(surgery_id, line, settings) + append_voice_tsv_line(surgery_id, line) diff --git a/app/services/voice_resolution.py b/app/services/voice_resolution.py index f651250..7a194f5 100644 --- a/app/services/voice_resolution.py +++ b/app/services/voice_resolution.py @@ -14,6 +14,7 @@ from loguru import logger from sqlalchemy.ext.asyncio import async_sessionmaker +from app.baked import pipeline as bp from app.config import Settings from app.database import AsyncSessionLocal from app.repositories.voice_audits import VoiceAuditRepository @@ -107,13 +108,13 @@ class VoiceConfirmationService: _ = filename # reserved for future MIME sniff # 1) validate_size - if len(wav_bytes) > self._s.voice_upload_max_bytes: + if len(wav_bytes) > bp.VOICE_UPLOAD_MAX_BYTES: raise await self._emitter.fail( source="wav", status="invalid_audio", code="VOICE_AUDIO_INVALID", message=( - f"音频大小超过限制(最大 {self._s.voice_upload_max_bytes} 字节)。" + f"音频大小超过限制(最大 {bp.VOICE_UPLOAD_MAX_BYTES} 字节)。" ), surgery_id=surgery_id, confirmation_id=confirmation_id, diff --git a/docs/staging-regression-checklist.md b/docs/staging-regression-checklist.md index 611a9f4..6598949 100644 --- a/docs/staging-regression-checklist.md +++ b/docs/staging-regression-checklist.md @@ -2,12 +2,14 @@ 在具备 **Postgres**、**MinIO**、可访问 **RTSP**(或海康 SDK 环境)、**百度语音** 的条件下,按下列顺序手工或脚本验证核心闭环。自动化测试见 `tests/`。 +**部署前**须对目标库执行 `alembic upgrade head`(`start.sh` / 生产发布脚本应包含;空库起服务前必备)。 + ## 环境 - `GET /health` 返回 `200`,`database: connected` - 环境变量:`VIDEO_RTSP_URLS_JSON` 或 `VIDEO_RTSP_URLS_JSON_FILE` 与客户端 `camera_ids` 一致 -- `MINIO_`*、`BAIDU_SPEECH_*` 已配置(语音确认链路) -- 模型权重路径可读(容器内挂载 `app/resources/*.pt`) +- `MINIO_*`、`BAIDU_APP_ID` / `BAIDU_API_KEY` / `BAIDU_SECRET_KEY` 已配置(语音确认链路) +- 模型权重在镜像/挂载中可读(默认路径见 `app/baked/algorithm.py` 与仓库 `app/resources/*.pt`) ## 主流程 diff --git a/main.py b/main.py index 5ffa842..43bdb5b 100644 --- a/main.py +++ b/main.py @@ -8,7 +8,7 @@ from loguru import logger from app.api import router as api_router from app.config import settings -from app.database import check_database, engine, init_db_schema +from app.database import check_database, engine from app.dependencies import build_container @@ -28,14 +28,10 @@ def configure_logging() -> None: @asynccontextmanager async def lifespan(app: FastAPI): await check_database() - if settings.auto_create_schema: - await init_db_schema() - logger.info("Database connection verified; schema auto-created (dev mode)") - else: - logger.info( - "Database connection verified; auto_create_schema=false, " - "expecting 'alembic upgrade head' to have run" - ) + logger.info( + "Database connection verified; ensure schema is applied with " + "`alembic upgrade head` before serving traffic" + ) container = build_container(settings) app.state.container = container await container.start() @@ -89,7 +85,7 @@ def main() -> None: "main:app", host=settings.server_host, port=settings.server_port, - reload=settings.server_reload, + reload=False, ) diff --git a/pyproject.toml b/pyproject.toml index 3cc0fdc..f21c499 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -5,6 +5,7 @@ description = "Operation room monitor API server" requires-python = ">=3.13" dependencies = [ "asyncpg>=0.31.0", + "psycopg[binary]>=3.2.0", "greenlet>=3.1.0", "minio>=7.2.15", "baidu-aip>=4.16.13", diff --git a/scripts/baidu_face_1n_search.py b/scripts/baidu_face_1n_search.py index ad31c8d..63a3b67 100644 --- a/scripts/baidu_face_1n_search.py +++ b/scripts/baidu_face_1n_search.py @@ -11,8 +11,8 @@ 配置从环境变量读取;启动时会从**仓库根目录**下的 `.env` 与**当前工作目录**下的 `.env` 加载(需已安装 `python-dotenv`,随 pydantic-settings 提供)。 -主要环境变量(详见仓库 `.env.example` 中 Baidu Face 节): - BAIDU_FACE_APP_ID、BAIDU_FACE_API_KEY、BAIDU_FACE_SECRET_KEY(必填) +主要环境变量: + BAIDU_APP_ID、BAIDU_API_KEY、BAIDU_SECRET_KEY(与 API 服务共用) BAIDU_FACE_GROUP_ID_LIST(与命令行 --groups 二选一;格式以百度人脸库文档为准,非法值由接口返回错误码) 用法示例(输入为**文件夹**,遍历其下所有支持的图片并打印识别日志): @@ -68,21 +68,19 @@ def _env_int(name: str, default: int) -> int: def _face_client() -> AipFace: - app_id = _env("BAIDU_FACE_APP_ID") - api_key = _env("BAIDU_FACE_API_KEY") - secret = _env("BAIDU_FACE_SECRET_KEY") + app_id = _env("BAIDU_APP_ID") + api_key = _env("BAIDU_API_KEY") + secret = _env("BAIDU_SECRET_KEY") if not app_id or not api_key or not secret: print( - "错误:未配置百度人脸凭据。\n" - "请在 `.env` 或环境中设置:BAIDU_FACE_APP_ID、BAIDU_FACE_API_KEY、" - "BAIDU_FACE_SECRET_KEY\n" - "(参考仓库 `.env.example` 中 Baidu Face 节;与语音 BAIDU_SPEECH_* 可为不同应用。)", + "错误:未配置百度凭据。\n" + "请在 `.env` 或环境中设置:BAIDU_APP_ID、BAIDU_API_KEY、BAIDU_SECRET_KEY。\n", file=sys.stderr, ) sys.exit(2) client = AipFace(app_id, api_key, secret) - conn_ms = _env("BAIDU_FACE_CONNECTION_TIMEOUT_MS") - sock_ms = _env("BAIDU_FACE_SOCKET_TIMEOUT_MS") + conn_ms = _env("BAIDU_CONNECTION_TIMEOUT_MS") + sock_ms = _env("BAIDU_SOCKET_TIMEOUT_MS") if conn_ms.isdigit(): client.setConnectionTimeoutInMillis(int(conn_ms)) if sock_ms.isdigit(): diff --git a/scripts/start_fresh.py b/scripts/start_fresh.py index e28062d..f76d2ad 100644 --- a/scripts/start_fresh.py +++ b/scripts/start_fresh.py @@ -20,7 +20,7 @@ if _REPO_ROOT not in sys.path: from sqlalchemy import text from app.config import settings -from app.database import engine, init_db_schema +from app.database import engine # 与 app/db/models.py 一致;有 FK 时子表排前面 @@ -38,8 +38,6 @@ _TRUNCATE_SQL = text( async def _run() -> None: - # 确保新库也有表 - await init_db_schema() async with engine.begin() as conn: await conn.execute(_TRUNCATE_SQL) dsn = settings.sqlalchemy_database_url diff --git a/start.sh b/start.sh index 29af451..bf93150 100755 --- a/start.sh +++ b/start.sh @@ -35,4 +35,7 @@ export POSTGRES_DB="${POSTGRES_DB:-operation_room}" export POSTGRES_HOST="${POSTGRES_HOST:-localhost}" export POSTGRES_PORT="${POSTGRES_PORT:-35432}" +echo "Applying database migrations (alembic upgrade head)..." +uv run alembic upgrade head + exec uv run uvicorn main:app --host "${HOST:-0.0.0.0}" --port "${PORT:-38080}" --reload diff --git a/start_fresh.sh b/start_fresh.sh index 38ca9b1..7239556 100755 --- a/start_fresh.sh +++ b/start_fresh.sh @@ -35,6 +35,9 @@ export POSTGRES_DB="${POSTGRES_DB:-operation_room}" export POSTGRES_HOST="${POSTGRES_HOST:-localhost}" export POSTGRES_PORT="${POSTGRES_PORT:-35432}" +echo "Applying database migrations (alembic upgrade head)..." +uv run alembic upgrade head + echo "start_fresh: clearing app tables (TRUNCATE)..." uv run python scripts/start_fresh.py diff --git a/tests/test_app_integration.py b/tests/test_app_integration.py index 9d29d5a..8f5aa86 100644 --- a/tests/test_app_integration.py +++ b/tests/test_app_integration.py @@ -26,6 +26,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn import app.db.models # noqa: F401 # register ORM tables on Base.metadata import main as main_module +from app.baked import pipeline as bp from app.db.base import Base from app.dependencies import AppContainer, build_container from app.domain.consumption import SurgeryConsumptionStored @@ -165,7 +166,6 @@ def integration_client( return None monkeypatch.setattr(main_module, "check_database", _noop) - monkeypatch.setattr(main_module, "init_db_schema", _noop) class _FakeEngine: async def dispose(self) -> None: @@ -176,11 +176,10 @@ def integration_client( from app.config import settings as real_settings monkeypatch.setattr( - real_settings, - "archive_persist_durable_fallback_dir", + bp, + "ARCHIVE_PERSIST_DURABLE_FALLBACK_DIR", str(tmp_path / "pending_archive"), ) - monkeypatch.setattr(real_settings, "auto_create_schema", False) def _stubbed_build_container(*args, **kwargs) -> AppContainer: container = build_container(real_settings, session_factory=sqlite_factory) diff --git a/tests/test_archive_persister.py b/tests/test_archive_persister.py index 8b1adca..c28855a 100644 --- a/tests/test_archive_persister.py +++ b/tests/test_archive_persister.py @@ -8,7 +8,7 @@ from datetime import datetime, timezone import pytest from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker -from app.config import Settings +from app.baked import pipeline as bp from app.domain.consumption import SurgeryConsumptionStored from app.repositories.surgery_results import SurgeryResultRepository from app.services.video.archive_persister import ArchivePersister @@ -39,12 +39,12 @@ def _detail(item_id: str = "纱布") -> SurgeryConsumptionStored: async def test_persist_or_archive_writes_durable_fallback( tmp_path, sqlite_session_factory: async_sessionmaker[AsyncSession], + monkeypatch: pytest.MonkeyPatch, ) -> None: fallback_dir = tmp_path / "pending_archive" - settings = Settings(archive_persist_durable_fallback_dir=str(fallback_dir)) + monkeypatch.setattr(bp, "ARCHIVE_PERSIST_DURABLE_FALLBACK_DIR", str(fallback_dir)) repo = _AlwaysFailRepo() persister = ArchivePersister( - settings=settings, repository=repo, session_factory=sqlite_session_factory, ) @@ -62,6 +62,7 @@ async def test_persist_or_archive_writes_durable_fallback( async def test_recover_from_durable_fallback_reloads_pending_archive( tmp_path, sqlite_session_factory: async_sessionmaker[AsyncSession], + monkeypatch: pytest.MonkeyPatch, ) -> None: fallback_dir = tmp_path / "pending_archive" fallback_dir.mkdir() @@ -82,9 +83,8 @@ async def test_recover_from_durable_fallback_reloads_pending_archive( (fallback_dir / "recov01.json").write_text( json.dumps(payload, ensure_ascii=False), encoding="utf-8" ) - settings = Settings(archive_persist_durable_fallback_dir=str(fallback_dir)) + monkeypatch.setattr(bp, "ARCHIVE_PERSIST_DURABLE_FALLBACK_DIR", str(fallback_dir)) persister = ArchivePersister( - settings=settings, repository=SurgeryResultRepository(), session_factory=sqlite_session_factory, ) diff --git a/tests/test_archive_restart_recovery.py b/tests/test_archive_restart_recovery.py index 026d7d6..6bf18db 100644 --- a/tests/test_archive_restart_recovery.py +++ b/tests/test_archive_restart_recovery.py @@ -20,6 +20,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_asyn import app.db.models # noqa: F401 register ORM tables import main as main_module +from app.baked import pipeline as bp from app.db.base import Base from app.dependencies import AppContainer, build_container from app.domain.consumption import SurgeryConsumptionStored @@ -79,7 +80,6 @@ def test_durable_fallback_recovers_on_startup_and_persists( return None monkeypatch.setattr(main_module, "check_database", _noop) - monkeypatch.setattr(main_module, "init_db_schema", _noop) class _FakeEngine: async def dispose(self) -> None: @@ -90,10 +90,11 @@ def test_durable_fallback_recovers_on_startup_and_persists( from app.config import settings as real_settings monkeypatch.setattr( - real_settings, "archive_persist_durable_fallback_dir", str(durable_dir) + bp, + "ARCHIVE_PERSIST_DURABLE_FALLBACK_DIR", + str(durable_dir), ) - monkeypatch.setattr(real_settings, "auto_create_schema", False) - monkeypatch.setattr(real_settings, "archive_persist_retry_interval_seconds", 5.0) + monkeypatch.setattr(bp, "ARCHIVE_PERSIST_RETRY_INTERVAL_SECONDS", 5.0) def _build(*_a, **_kw) -> AppContainer: return build_container(real_settings, session_factory=sqlite_factory) diff --git a/tests/test_baidu_unified_env.py b/tests/test_baidu_unified_env.py new file mode 100644 index 0000000..13ef02e --- /dev/null +++ b/tests/test_baidu_unified_env.py @@ -0,0 +1,20 @@ +"""百度凭据:仅从环境变量 BAIDU_APP_ID / BAIDU_API_KEY / BAIDU_SECRET_KEY 读入。""" + +import os +from unittest.mock import patch + +from app.config import Settings + + +def test_speech_creds_from_baidu_env_triplet() -> None: + extra = { + "BAIDU_APP_ID": "app-x", + "BAIDU_API_KEY": "key-x", + "BAIDU_SECRET_KEY": "sec-x", + } + with patch.dict(os.environ, extra, clear=False): + s = Settings() + assert s.baidu_speech_app_id == "app-x" + assert s.baidu_speech_api_key == "key-x" + assert s.baidu_speech_secret_key == "sec-x" + assert s.baidu_speech_configured is True diff --git a/tests/test_consumption_tsv_log.py b/tests/test_consumption_tsv_log.py index 0558ae1..9661bb8 100644 --- a/tests/test_consumption_tsv_log.py +++ b/tests/test_consumption_tsv_log.py @@ -2,7 +2,7 @@ import pytest -from app.config import settings +from app.baked import pipeline as bp from app.services.consumable_vision_algorithm import ClsTop3 from app.services.consumption_tsv_log import ( HEADER, @@ -25,7 +25,7 @@ def test_short_camera_label() -> None: def test_build_tsv_line_matches_sample_shape(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "consumption_log_timezone", "UTC") + monkeypatch.setattr(bp, "CONSUMPTION_LOG_TIMEZONE", "UTC") best = ClsTop3( t1_name="一次性医用灭菌棉签", t1_conf=0.9997, @@ -90,11 +90,11 @@ def test_replace_pending_line_with_voice_resolution_rewrites_one_row( monkeypatch: pytest.MonkeyPatch, ) -> None: """语音确认后应替换 pending 行,而不是再多一行。""" - monkeypatch.setattr(settings, "consumption_tsv_log_enabled", True) - monkeypatch.setattr(settings, "consumption_log_timezone", "UTC") + monkeypatch.setattr(bp, "CONSUMPTION_TSV_LOG_ENABLED", True) + monkeypatch.setattr(bp, "CONSUMPTION_LOG_TIMEZONE", "UTC") monkeypatch.setattr( - settings, - "consumption_tsv_log_path", + bp, + "CONSUMPTION_TSV_LOG_PATH", str(tmp_path / "{surgery_id}.txt"), ) init_consumption_log_file("SURG01") @@ -126,10 +126,10 @@ def test_per_surgery_file_init_and_append( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(settings, "consumption_tsv_log_enabled", True) + monkeypatch.setattr(bp, "CONSUMPTION_TSV_LOG_ENABLED", True) monkeypatch.setattr( - settings, - "consumption_tsv_log_path", + bp, + "CONSUMPTION_TSV_LOG_PATH", str(tmp_path / "{surgery_id}.txt"), ) init_consumption_log_file("or-001") @@ -145,10 +145,10 @@ def test_append_consumption_log_summary_appends_three_column_block( tmp_path, monkeypatch: pytest.MonkeyPatch, ) -> None: - monkeypatch.setattr(settings, "consumption_tsv_log_enabled", True) + monkeypatch.setattr(bp, "CONSUMPTION_TSV_LOG_ENABLED", True) monkeypatch.setattr( - settings, - "consumption_tsv_log_path", + bp, + "CONSUMPTION_TSV_LOG_PATH", str(tmp_path / "{surgery_id}.txt"), ) init_consumption_log_file("s1") @@ -167,7 +167,7 @@ def test_append_consumption_log_summary_appends_three_column_block( def test_build_consumption_markdown_top123_columns(monkeypatch: pytest.MonkeyPatch) -> None: - monkeypatch.setattr(settings, "consumption_log_timezone", "UTC") + monkeypatch.setattr(bp, "CONSUMPTION_LOG_TIMEZONE", "UTC") best = ClsTop3( t1_name="一次性医用灭菌棉签", t1_conf=0.9997, diff --git a/tests/test_effective_candidate_consumables.py b/tests/test_effective_candidate_consumables.py index 8966e3a..dc80137 100644 --- a/tests/test_effective_candidate_consumables.py +++ b/tests/test_effective_candidate_consumables.py @@ -7,12 +7,11 @@ from unittest.mock import MagicMock import pytest -from app.config import Settings from app.services.consumable_vision_algorithm import ConsumableVisionAlgorithmService def test_effective_preserves_non_empty_request() -> None: - svc = ConsumableVisionAlgorithmService(Settings()) + svc = ConsumableVisionAlgorithmService() got = svc.effective_candidate_consumables([" 纱布 ", "缝线", "纱布"]) assert got == ["纱布", "缝线"] @@ -22,8 +21,7 @@ def test_effective_empty_uses_model_when_yaml_has_no_names( ) -> 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) + svc = ConsumableVisionAlgorithmService(labels_yaml_path=str(yml)) mock_cls = MagicMock() mock_cls.names = {0: "ban", 1: "apple"} monkeypatch.setattr(svc, "_get_cls", lambda: mock_cls) @@ -36,8 +34,7 @@ def test_effective_empty_prefers_yaml_class_names(tmp_path: Path) -> None: "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) + svc = ConsumableVisionAlgorithmService(labels_yaml_path=str(yml)) assert svc.effective_candidate_consumables([]) == ["商品甲", "商品乙"] @@ -46,8 +43,7 @@ def test_effective_whitespace_only_treated_as_empty( ) -> 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) + svc = ConsumableVisionAlgorithmService(labels_yaml_path=str(yml)) mock_cls = MagicMock() mock_cls.names = {0: "x"} monkeypatch.setattr(svc, "_get_cls", lambda: mock_cls) @@ -60,8 +56,7 @@ def test_build_name_mapping_from_label_id(tmp_path: Path) -> None: "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) + svc = ConsumableVisionAlgorithmService(labels_yaml_path=str(yml)) m = svc.build_name_mapping(["商品A"]) assert m["商品A"] == "y1/y2" @@ -74,7 +69,6 @@ def test_build_name_mapping_uses_name_when_no_id_in_yaml( "names:\n 0: 仅表内有的\nlabel_id: {}\n", encoding="utf-8", ) - s = Settings(consumable_classifier_labels_yaml_path=str(yml)) - svc = ConsumableVisionAlgorithmService(s) + svc = ConsumableVisionAlgorithmService(labels_yaml_path=str(yml)) m = svc.build_name_mapping(["仅表内有的"]) assert m["仅表内有的"] == "仅表内有的" diff --git a/tests/test_session_manager_unit.py b/tests/test_session_manager_unit.py index b32b097..6b17c3f 100644 --- a/tests/test_session_manager_unit.py +++ b/tests/test_session_manager_unit.py @@ -6,6 +6,7 @@ from unittest.mock import MagicMock import pytest +from app.baked import pipeline as bp from app.config import Settings from app.services.consumable_vision_algorithm import ( PredictionCandidate, @@ -178,9 +179,11 @@ async def test_archive_retry_loop_starts() -> None: @pytest.mark.asyncio -async def test_handle_skips_below_voice_floor() -> None: +async def test_handle_skips_below_voice_floor( + monkeypatch: pytest.MonkeyPatch, +) -> None: settings = Settings() - settings.video_voice_confirm_min_confidence = 0.5 + monkeypatch.setattr(bp, "VIDEO_VOICE_CONFIRM_MIN_CONFIDENCE", 0.5) mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), @@ -248,10 +251,12 @@ async def test_handle_high_conf_top1_not_in_candidates_enqueues_pending() -> Non @pytest.mark.asyncio -async def test_handle_mid_confidence_enqueues_pending() -> None: +async def test_handle_mid_confidence_enqueues_pending( + monkeypatch: pytest.MonkeyPatch, +) -> None: settings = Settings() - settings.video_auto_confirm_confidence = 0.8 - settings.video_voice_confirm_min_confidence = 0.3 + monkeypatch.setattr(bp, "VIDEO_AUTO_CONFIRM_CONFIDENCE", 0.8) + monkeypatch.setattr(bp, "VIDEO_VOICE_CONFIRM_MIN_CONFIDENCE", 0.3) mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), @@ -274,10 +279,12 @@ async def test_handle_mid_confidence_enqueues_pending() -> None: @pytest.mark.asyncio -async def test_handle_voice_disabled_no_pending_for_mid_conf() -> None: +async def test_handle_voice_disabled_no_pending_for_mid_conf( + monkeypatch: pytest.MonkeyPatch, +) -> None: settings = Settings() - settings.voice_confirmation_enabled = False - settings.video_auto_confirm_confidence = 0.8 + monkeypatch.setattr(bp, "VOICE_CONFIRMATION_ENABLED", False) + monkeypatch.setattr(bp, "VIDEO_AUTO_CONFIRM_CONFIDENCE", 0.8) mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), @@ -296,9 +303,11 @@ async def test_handle_voice_disabled_no_pending_for_mid_conf() -> None: @pytest.mark.asyncio -async def test_handle_vision_cooldown_skips_duplicate() -> None: +async def test_handle_vision_cooldown_skips_duplicate( + monkeypatch: pytest.MonkeyPatch, +) -> None: settings = Settings() - settings.video_detail_cooldown_sec = 3600.0 + monkeypatch.setattr(bp, "VIDEO_DETAIL_COOLDOWN_SEC", 3600.0) mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), @@ -317,9 +326,11 @@ async def test_handle_vision_cooldown_skips_duplicate() -> None: @pytest.mark.asyncio -async def test_handle_pending_dedupe_cooldown() -> None: +async def test_handle_pending_dedupe_cooldown( + monkeypatch: pytest.MonkeyPatch, +) -> None: settings = Settings() - settings.video_detail_cooldown_sec = 3600.0 + monkeypatch.setattr(bp, "VIDEO_DETAIL_COOLDOWN_SEC", 3600.0) mgr = CameraSessionManager( settings=settings, vision_algorithm=MagicMock(), diff --git a/tests/test_surgery_pipeline_persistence.py b/tests/test_surgery_pipeline_persistence.py index 41ec575..8d49151 100644 --- a/tests/test_surgery_pipeline_persistence.py +++ b/tests/test_surgery_pipeline_persistence.py @@ -9,6 +9,7 @@ from unittest.mock import MagicMock import pytest from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker +from app.baked import pipeline as bp from app.config import Settings from app.domain.consumption import SurgeryConsumptionStored from app.repositories.surgery_results import SurgeryResultRepository @@ -86,8 +87,11 @@ async def test_stop_surgery_failed_persist_goes_to_archive_then_retry_persists( monkeypatch: pytest.MonkeyPatch, ) -> None: repo = _FlakyResultRepo() - settings = Settings( - archive_persist_durable_fallback_dir=str(tmp_path / "pending_archive") + settings = Settings() + monkeypatch.setattr( + bp, + "ARCHIVE_PERSIST_DURABLE_FALLBACK_DIR", + str(tmp_path / "pending_archive"), ) mgr = CameraSessionManager( settings=settings, diff --git a/tests/test_tear_gated_segment_consumption.py b/tests/test_tear_gated_segment_consumption.py new file mode 100644 index 0000000..0c869ad --- /dev/null +++ b/tests/test_tear_gated_segment_consumption.py @@ -0,0 +1,70 @@ +"""撕段门控 + 段合并的纯逻辑单测(不加载 YOLO)。""" + +from __future__ import annotations + +import numpy as np +import pytest + +from app.services.tear_gated_segment_consumption.product_map import ( + load_tear_segment_name_to_id, + resolve_tear_segment_labels_yaml_path, +) +from app.services.tear_gated_segment_consumption.runner import haocai_mean_topk +from app.services.tear_gated_segment_consumption.segments import merge_tear_segments + + +def test_merge_tear_segments_one_valid() -> None: + dt = 0.04 + rows: list[tuple[int, float, bool, str]] = [ + (i, i * dt, True, "A") for i in range(40) + ] + segs = merge_tear_segments( + rows, + min_tear_sec=1.2, + min_gap_sec=1.0, + ) + assert len(segs) == 1 + assert segs[0]["start_frame"] == 0 + assert segs[0]["end_frame"] == 39 + + +def test_haocai_mean_topk() -> None: + names = {0: "A", 1: "B"} + a = np.array([0.2, 0.8, 0.0], dtype=np.float32) + b = np.array([0.2, 0.8, 0.0], dtype=np.float32) + t1, c1, t2, c2, t3, c3 = haocai_mean_topk([a, b], names) + assert t1 == "B" + assert abs(c1 - 0.8) < 1e-5 + + +def test_load_tear_segment_name_to_id_uses_package_yaml() -> None: + p = resolve_tear_segment_labels_yaml_path() + assert p.name == "consumable_classifier_labels.yaml" + m = load_tear_segment_name_to_id() + assert "一次性使用灭菌橡胶外科手套" in m or len(m) >= 1 + + +@pytest.mark.asyncio +async def test_append_confirmed_detail_tear_cooldown_keys() -> None: + """同 item_id 多段在独立 cooldown_key 下应都能写入。""" + from app.services.video.session_registry import SurgerySessionRegistry, SurgerySessionState + + reg = SurgerySessionRegistry() + st = SurgerySessionState(candidate_consumables=["X"], name_to_code={"X": "id1"}) + await reg.append_confirmed_detail( + state=st, + item_id="SAME", + item_name="A", + doctor_id="d", + source="tear_segment", + cooldown_key="s1:seg:1", + ) + await reg.append_confirmed_detail( + state=st, + item_id="SAME", + item_name="A", + doctor_id="d", + source="tear_segment", + cooldown_key="s1:seg:2", + ) + assert len(st.details) == 2 diff --git a/tests/test_voice_file_log.py b/tests/test_voice_file_log.py index c249a4e..f8b4c85 100644 --- a/tests/test_voice_file_log.py +++ b/tests/test_voice_file_log.py @@ -5,7 +5,9 @@ from __future__ import annotations import tempfile from pathlib import Path -from app.config import Settings +import pytest + +from app.baked import pipeline as bp from app.services.voice_file_log import ( append_voice_tsv_line, emit_voice_event, @@ -14,38 +16,46 @@ from app.services.voice_file_log import ( ) -def test_resolved_voice_log_path_replaces_surgery_id() -> None: - s = Settings() - s.voice_file_log_path = "logs/voice_{surgery_id}.txt" - p = resolved_voice_log_path("123456", s) +def test_resolved_voice_log_path_replaces_surgery_id( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(bp, "VOICE_FILE_LOG_PATH", "logs/voice_{surgery_id}.txt") + p = resolved_voice_log_path("123456") assert p.name == "voice_123456.txt" assert "logs" in str(p) -def test_init_and_append_tsv() -> None: +def test_init_and_append_tsv(monkeypatch: pytest.MonkeyPatch) -> None: with tempfile.TemporaryDirectory() as d: base = Path(d) - s = Settings() - s.voice_file_log_enabled = True - s.voice_file_log_path = str((base / "v_{surgery_id}.txt").resolve()) - init_voice_log_file("999999", s) - p = resolved_voice_log_path("999999", s) + monkeypatch.setattr(bp, "VOICE_FILE_LOG_ENABLED", True) + monkeypatch.setattr( + bp, + "VOICE_FILE_LOG_PATH", + str((base / "v_{surgery_id}.txt").resolve()), + ) + init_voice_log_file("999999") + p = resolved_voice_log_path("999999") assert p.exists() h = p.read_text(encoding="utf-8") assert "来源" in h and "confirmation_id" in h line = "ts\ttest\trecognized\tcid1\t同\t品\tfalse\t\tk.wav\n" - append_voice_tsv_line("999999", line, s) + append_voice_tsv_line("999999", line) assert p.read_text(encoding="utf-8").endswith(line) -def test_emit_voice_event_writes_when_enabled() -> None: - s = Settings() - s.voice_file_log_enabled = True +def test_emit_voice_event_writes_when_enabled( + monkeypatch: pytest.MonkeyPatch, +) -> None: + monkeypatch.setattr(bp, "VOICE_FILE_LOG_ENABLED", True) with tempfile.TemporaryDirectory() as d: - s.voice_file_log_path = str((Path(d) / "v_{surgery_id}.txt").resolve()) - init_voice_log_file("111111", s) + monkeypatch.setattr( + bp, + "VOICE_FILE_LOG_PATH", + str((Path(d) / "v_{surgery_id}.txt").resolve()), + ) + init_voice_log_file("111111") emit_voice_event( - s, surgery_id="111111", source="wav", status="recognized", @@ -55,7 +65,7 @@ def test_emit_voice_event_writes_when_enabled() -> None: rejected=False, audio_object_key="k.wav", ) - p = resolved_voice_log_path("111111", s) + p = resolved_voice_log_path("111111") body = p.read_text(encoding="utf-8") assert "纱布" in body assert "recognized" in body diff --git a/tests/test_voice_resolution_service.py b/tests/test_voice_resolution_service.py index 0f8ce5c..045ea97 100644 --- a/tests/test_voice_resolution_service.py +++ b/tests/test_voice_resolution_service.py @@ -11,6 +11,7 @@ from unittest.mock import MagicMock import pytest from sqlalchemy import func, select +from app.baked import pipeline as bp from app.config import Settings from app.db.models import VoiceConfirmationAudit from app.repositories.voice_audits import VoiceAuditRepository @@ -105,7 +106,7 @@ async def test_resolve_recognized_appends_voice_detail_and_audit( monkeypatch: pytest.MonkeyPatch, ) -> None: settings = Settings() - settings.voice_upload_max_bytes = 10 * 1024 * 1024 + monkeypatch.setattr(bp, "VOICE_UPLOAD_MAX_BYTES", 10 * 1024 * 1024) sessions, cid = _active_session_with_pending() minio = MagicMock() minio.configured = True @@ -245,7 +246,7 @@ async def test_audio_too_large_audit( monkeypatch: pytest.MonkeyPatch, ) -> None: settings = Settings() - settings.voice_upload_max_bytes = 10 + monkeypatch.setattr(bp, "VOICE_UPLOAD_MAX_BYTES", 10) sessions, cid = _active_session_with_pending() minio = MagicMock() minio.configured = True diff --git a/uv.lock b/uv.lock index a774c76..a17f696 100644 --- a/uv.lock +++ b/uv.lock @@ -859,6 +859,7 @@ dependencies = [ { name = "loguru" }, { name = "minio" }, { name = "pillow" }, + { name = "psycopg", extra = ["binary"] }, { name = "pydantic-settings" }, { name = "python-multipart" }, { name = "pyyaml" }, @@ -887,6 +888,7 @@ requires-dist = [ { name = "loguru", specifier = ">=0.7.3" }, { name = "minio", specifier = ">=7.2.15" }, { name = "pillow", specifier = ">=12.2.0" }, + { name = "psycopg", extras = ["binary"], specifier = ">=3.2.0" }, { name = "pydantic-settings", specifier = ">=2.13.1" }, { name = "python-multipart", specifier = ">=0.0.26" }, { name = "pyyaml", specifier = ">=6.0.3" }, @@ -1037,6 +1039,52 @@ wheels = [ { url = "https://files.pythonhosted.org/packages/8c/c7/7bb2e321574b10df20cbde462a94e2b71d05f9bbda251ef27d104668306a/psutil-7.2.2-cp37-abi3-win_arm64.whl", hash = "sha256:8c233660f575a5a89e6d4cb65d9f938126312bca76d8fe087b947b3a1aaac9ee", size = 134617, upload-time = "2026-01-28T18:15:36.514Z" }, ] +[[package]] +name = "psycopg" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +dependencies = [ + { name = "tzdata", marker = "sys_platform == 'win32'" }, +] +sdist = { url = "https://files.pythonhosted.org/packages/d3/b6/379d0a960f8f435ec78720462fd94c4863e7a31237cf81bf76d0af5883bf/psycopg-3.3.3.tar.gz", hash = "sha256:5e9a47458b3c1583326513b2556a2a9473a1001a56c9efe9e587245b43148dd9", size = 165624, upload-time = "2026-02-18T16:52:16.546Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/c8/5b/181e2e3becb7672b502f0ed7f16ed7352aca7c109cfb94cf3878a9186db9/psycopg-3.3.3-py3-none-any.whl", hash = "sha256:f96525a72bcfade6584ab17e89de415ff360748c766f0106959144dcbb38c698", size = 212768, upload-time = "2026-02-18T16:46:27.365Z" }, +] + +[package.optional-dependencies] +binary = [ + { name = "psycopg-binary", marker = "implementation_name != 'pypy'" }, +] + +[[package]] +name = "psycopg-binary" +version = "3.3.3" +source = { registry = "https://pypi.org/simple" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/03/0a/cac9fdf1df16a269ba0e5f0f06cac61f826c94cadb39df028cdfe19d3a33/psycopg_binary-3.3.3-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:05f32239aec25c5fb15f7948cffdc2dc0dac098e48b80a140e4ba32b572a2e7d", size = 4590414, upload-time = "2026-02-18T16:50:01.441Z" }, + { url = "https://files.pythonhosted.org/packages/9c/c0/d8f8508fbf440edbc0099b1abff33003cd80c9e66eb3a1e78834e3fb4fb9/psycopg_binary-3.3.3-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:7c84f9d214f2d1de2fafebc17fa68ac3f6561a59e291553dfc45ad299f4898c1", size = 4669021, upload-time = "2026-02-18T16:50:08.803Z" }, + { url = "https://files.pythonhosted.org/packages/04/05/097016b77e343b4568feddf12c72171fc513acef9a4214d21b9478569068/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:e77957d2ba17cada11be09a5066d93026cdb61ada7c8893101d7fe1c6e1f3925", size = 5467453, upload-time = "2026-02-18T16:50:14.985Z" }, + { url = "https://files.pythonhosted.org/packages/91/23/73244e5feb55b5ca109cede6e97f32ef45189f0fdac4c80d75c99862729d/psycopg_binary-3.3.3-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:42961609ac07c232a427da7c87a468d3c82fee6762c220f38e37cfdacb2b178d", size = 5151135, upload-time = "2026-02-18T16:50:24.82Z" }, + { url = "https://files.pythonhosted.org/packages/11/49/5309473b9803b207682095201d8708bbc7842ddf3f192488a69204e36455/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae07a3114313dd91fce686cab2f4c44af094398519af0e0f854bc707e1aeedf1", size = 6737315, upload-time = "2026-02-18T16:50:35.106Z" }, + { url = "https://files.pythonhosted.org/packages/d4/5d/03abe74ef34d460b33c4d9662bf6ec1dd38888324323c1a1752133c10377/psycopg_binary-3.3.3-cp313-cp313-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:d257c58d7b36a621dcce1d01476ad8b60f12d80eb1406aee4cf796f88b2ae482", size = 4979783, upload-time = "2026-02-18T16:50:42.067Z" }, + { url = "https://files.pythonhosted.org/packages/f0/6c/3fbf8e604e15f2f3752900434046c00c90bb8764305a1b81112bff30ba24/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:07c7211f9327d522c9c47560cae00a4ecf6687f4e02d779d035dd3177b41cb12", size = 4509023, upload-time = "2026-02-18T16:50:50.116Z" }, + { url = "https://files.pythonhosted.org/packages/9c/6b/1a06b43b7c7af756c80b67eac8bfaa51d77e68635a8a8d246e4f0bb7604a/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:8e7e9eca9b363dbedeceeadd8be97149d2499081f3c52d141d7cd1f395a91f83", size = 4185874, upload-time = "2026-02-18T16:50:55.97Z" }, + { url = "https://files.pythonhosted.org/packages/2b/d3/bf49e3dcaadba510170c8d111e5e69e5ae3f981c1554c5bb71c75ce354bb/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:cb85b1d5702877c16f28d7b92ba030c1f49ebcc9b87d03d8c10bf45a2f1c7508", size = 3925668, upload-time = "2026-02-18T16:51:03.299Z" }, + { url = "https://files.pythonhosted.org/packages/f8/92/0aac830ed6a944fe334404e1687a074e4215630725753f0e3e9a9a595b62/psycopg_binary-3.3.3-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:4d4606c84d04b80f9138d72f1e28c6c02dc5ae0c7b8f3f8aaf89c681ce1cd1b1", size = 4234973, upload-time = "2026-02-18T16:51:09.097Z" }, + { url = "https://files.pythonhosted.org/packages/2e/96/102244653ee5a143ece5afe33f00f52fe64e389dfce8dbc87580c6d70d3d/psycopg_binary-3.3.3-cp313-cp313-win_amd64.whl", hash = "sha256:74eae563166ebf74e8d950ff359be037b85723d99ca83f57d9b244a871d6c13b", size = 3551342, upload-time = "2026-02-18T16:51:13.892Z" }, + { url = "https://files.pythonhosted.org/packages/a2/71/7a57e5b12275fe7e7d84d54113f0226080423a869118419c9106c083a21c/psycopg_binary-3.3.3-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:497852c5eaf1f0c2d88ab74a64a8097c099deac0c71de1cbcf18659a8a04a4b2", size = 4607368, upload-time = "2026-02-18T16:51:19.295Z" }, + { url = "https://files.pythonhosted.org/packages/c7/04/cb834f120f2b2c10d4003515ef9ca9d688115b9431735e3936ae48549af8/psycopg_binary-3.3.3-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:258d1ea53464d29768bf25930f43291949f4c7becc706f6e220c515a63a24edd", size = 4687047, upload-time = "2026-02-18T16:51:23.84Z" }, + { url = "https://files.pythonhosted.org/packages/40/e9/47a69692d3da9704468041aa5ed3ad6fc7f6bb1a5ae788d261a26bbca6c7/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.whl", hash = "sha256:111c59897a452196116db12e7f608da472fbff000693a21040e35fc978b23430", size = 5487096, upload-time = "2026-02-18T16:51:29.645Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b6/0e0dd6a2f802864a4ae3dbadf4ec620f05e3904c7842b326aafc43e5f464/psycopg_binary-3.3.3-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:17bb6600e2455993946385249a3c3d0af52cd70c1c1cdbf712e9d696d0b0bf1b", size = 5168720, upload-time = "2026-02-18T16:51:36.499Z" }, + { url = "https://files.pythonhosted.org/packages/6f/0d/977af38ac19a6b55d22dff508bd743fd7c1901e1b73657e7937c7cccb0a3/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:642050398583d61c9856210568eb09a8e4f2fe8224bf3be21b67a370e677eead", size = 6762076, upload-time = "2026-02-18T16:51:43.167Z" }, + { url = "https://files.pythonhosted.org/packages/34/40/912a39d48322cf86895c0eaf2d5b95cb899402443faefd4b09abbba6b6e1/psycopg_binary-3.3.3-cp314-cp314-manylinux_2_38_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:533efe6dc3a7cba5e2a84e38970786bb966306863e45f3db152007e9f48638a6", size = 4997623, upload-time = "2026-02-18T16:51:47.707Z" }, + { url = "https://files.pythonhosted.org/packages/98/0c/c14d0e259c65dc7be854d926993f151077887391d5a081118907a9d89603/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:5958dbf28b77ce2033482f6cb9ef04d43f5d8f4b7636e6963d5626f000efb23e", size = 4532096, upload-time = "2026-02-18T16:51:51.421Z" }, + { url = "https://files.pythonhosted.org/packages/39/21/8b7c50a194cfca6ea0fd4d1f276158307785775426e90700ab2eba5cd623/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:a6af77b6626ce92b5817bf294b4d45ec1a6161dba80fc2d82cdffdd6814fd023", size = 4208884, upload-time = "2026-02-18T16:51:57.336Z" }, + { url = "https://files.pythonhosted.org/packages/c7/2c/a4981bf42cf30ebba0424971d7ce70a222ae9b82594c42fc3f2105d7b525/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:47f06fcbe8542b4d96d7392c476a74ada521c5aebdb41c3c0155f6595fc14c8d", size = 3944542, upload-time = "2026-02-18T16:52:04.266Z" }, + { url = "https://files.pythonhosted.org/packages/60/e9/b7c29b56aa0b85a4e0c4d89db691c1ceef08f46a356369144430c155a2f5/psycopg_binary-3.3.3-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:e7800e6c6b5dc4b0ca7cc7370f770f53ac83886b76afda0848065a674231e856", size = 4254339, upload-time = "2026-02-18T16:52:10.444Z" }, + { url = "https://files.pythonhosted.org/packages/98/5a/291d89f44d3820fffb7a04ebc8f3ef5dda4f542f44a5daea0c55a84abf45/psycopg_binary-3.3.3-cp314-cp314-win_amd64.whl", hash = "sha256:165f22ab5a9513a3d7425ffb7fcc7955ed8ccaeef6d37e369d6cc1dff1582383", size = 3652796, upload-time = "2026-02-18T16:52:14.02Z" }, +] + [[package]] name = "pycparser" version = "3.0" @@ -1565,6 +1613,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"