重组为 backend/clients/docs 三层结构,并清理 git 污染。

将后端迁入 backend/,完善根目录 .gitignore,删除误提交的 .mypy_cache 缓存文件。

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Kevin
2026-05-21 16:02:25 +08:00
parent 6bc6801df9
commit 1af442481e
142 changed files with 175 additions and 212 deletions

View File

@@ -0,0 +1 @@
"""Optional API routers."""

417
backend/app/routers/demo_orch.py Executable file
View File

@@ -0,0 +1,417 @@
"""Dev-only: upload 14 videos, start synthetic RTSP, write RTSP URL file, then start surgery."""
from __future__ import annotations
import json
import shutil
import tempfile
from pathlib import Path
from typing import Annotated
import anyio
from fastapi import APIRouter, BackgroundTasks, Depends, File, Form, HTTPException, UploadFile, status
from fastapi.responses import FileResponse
from loguru import logger
from pydantic import BaseModel
from app.config import settings
from app.consumable_catalog import normalize_candidate_consumables_raw
from app.dependencies import get_surgery_pipeline, get_voice_terminal_hub
from app.schemas import SurgeryApiResponse, SurgeryStartRequest
from app.or_site_config import merge_video_rtsp_urls_into_file
from app.services.synthetic_rtsp import StreamSpec, SyntheticRtspManager
from app.services.surgery_pipeline import SurgeryPipeline
from app.services.video_batch_runner import VideoBatchRunner, VideoBatchRunResult
from app.services.voice_terminal_hub import (
VoiceTerminalHub,
assign_voice_terminal_after_recording_started,
)
from app.surgery_errors import SurgeryPipelineError
router = APIRouter(prefix="/internal/demo", tags=["demo"])
# Bumped when video-batch flow changes; grep this string in logs after restart to confirm new code.
VIDEO_BATCH_FLOW_MARKER = "early-save+background-vis-v3"
def _background_finalize_visualization(
runner: VideoBatchRunner,
result: VideoBatchRunResult,
surgery_id: str,
) -> None:
try:
runner.finalize_visualization(result, surgery_id=surgery_id)
except Exception:
logger.exception("video batch background visualization failed surgery_id={}", surgery_id)
class VideoBatchSurgeryResponse(BaseModel):
surgery_id: str
status: str
message: str
visualization_url: str
doctor_name: str | None = None
doctor_id: str | None = None
doctor_display: str | None = None
def _orchestrate_write_rtsp_host() -> str:
"""Write JSON 里用于 RTSP 的主机名。
一键在本进程起 MediaMTX端口映射在**本机网络命名空间**的 127.0.0.1并拉流OpenCV
必须连 ``rtsp://127.0.0.1:port/...``。若改写成 ``host.docker.internal``,会指到
宿主机上的同端口,通常没有这路流,故 DESCRIBE 返回 404。
`DEMO_ORCHESTRATOR_RTSP_JSON_HOST` 对此路由无效;手填假流+仅改 JSON 的拓扑仍可用该配置。
"""
return "127.0.0.1"
@router.post(
"/video-batch-surgery",
response_model=VideoBatchSurgeryResponse,
summary="非实时精确模式:上传单路 MP4 并跑配置引用包 batch",
description=(
"仅当 DEMO_ORCHESTRATOR_ENABLED=true。保存上传视频调用配置引用包 main.py默认 refs/5.15"
"解析 TSV 后写入最终结果,并调用 visualize_result_video.py 生成带标签视频。"
),
)
async def video_batch_surgery(
background_tasks: BackgroundTasks,
surgery_id: Annotated[str, Form()],
video1: Annotated[UploadFile, File(description="单路完整 MP4")],
candidate_consumables_json: Annotated[str, Form()] = "[]",
pipeline: SurgeryPipeline = Depends(get_surgery_pipeline),
) -> SurgeryApiResponse:
if len(surgery_id) != 6 or not surgery_id.isdigit():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="surgery_id must be exactly 6 digits",
)
if not settings.demo_orchestrator_enabled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Demo orchestrator disabled (set DEMO_ORCHESTRATOR_ENABLED=true).",
)
try:
candidates = json.loads(candidate_consumables_json)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"invalid candidate_consumables_json: {exc}",
) from exc
if not isinstance(candidates, list):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="candidate_consumables_json must be a JSON array",
)
candidates = normalize_candidate_consumables_raw(candidates)
raw = await video1.read()
if not raw:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="video1 is empty",
)
logger.info(
"video batch request surgery_id={} flow={}",
surgery_id,
VIDEO_BATCH_FLOW_MARKER,
)
runner = VideoBatchRunner()
suffix = Path(video1.filename or "video.mp4").suffix or ".mp4"
work_root = runner.root_dir / surgery_id / "upload"
work_root.mkdir(parents=True, exist_ok=True)
uploaded = work_root / f"upload{suffix}"
try:
uploaded.write_bytes(raw)
except OSError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"failed to save upload: {exc}",
) from exc
try:
result = await anyio.to_thread.run_sync(
lambda: runner.run(
surgery_id=surgery_id,
uploaded_video_path=uploaded,
original_filename=video1.filename or "video.mp4",
candidate_consumables=candidates,
include_visualization=False,
)
)
except (FileNotFoundError, RuntimeError, OSError, ValueError) as exc:
logger.exception("video batch failed surgery_id={}: {}", surgery_id, exc)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"video batch failed: {exc}",
) from exc
await pipeline.save_video_batch_result(surgery_id, result.details)
logger.info(
"video batch result saved to database surgery_id={} rows={} (query GET /client/surgeries/{}/result now)",
surgery_id,
len(result.details),
surgery_id,
)
background_tasks.add_task(_background_finalize_visualization, runner, result, surgery_id)
visualization_url = f"/internal/demo/video-batch-surgery/{surgery_id}/visualization"
doctor = result.doctor
doctor_suffix = ""
if doctor is not None and doctor.display:
doctor_suffix = f";医生={doctor.display}"
vis_suffix = ";标注视频后台生成中(完成后刷新 visualization URL"
return VideoBatchSurgeryResponse(
surgery_id=surgery_id,
status="accepted",
message=(
"非实时精确视频处理完成;"
f"rows={len(result.details)} cache={'hit' if result.reused_cache else 'miss'}"
f"{doctor_suffix}{vis_suffix}"
),
visualization_url=visualization_url,
doctor_name=doctor.doctor_name if doctor is not None else None,
doctor_id=doctor.doctor_id if doctor is not None else None,
doctor_display=doctor.display if doctor is not None else None,
)
@router.get(
"/video-batch-surgery/{surgery_id}/visualization",
summary="获取非实时精确模式生成的带标签视频",
)
async def video_batch_visualization(surgery_id: str) -> FileResponse:
if len(surgery_id) != 6 or not surgery_id.isdigit():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="surgery_id must be exactly 6 digits",
)
if not settings.demo_orchestrator_enabled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Demo orchestrator disabled (set DEMO_ORCHESTRATOR_ENABLED=true).",
)
runner = VideoBatchRunner()
path = runner.latest_visualization_path(surgery_id)
if path is None:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="video batch visualization not found; run non-realtime batch first.",
)
return FileResponse(
path,
media_type="video/mp4",
filename=f"{surgery_id}_result_vis.mp4",
headers={"Accept-Ranges": "bytes", "Cache-Control": "no-cache"},
)
@router.post(
"/orchestrate-and-start",
response_model=SurgeryApiResponse,
summary="一键联调:上传 14 路视频并开录",
description=(
"仅当 DEMO_ORCHESTRATOR_ENABLED=true。保存一路或多路视频、启动 MediaMTX+ffmpeg、"
"将 RTSP 映射合并写入 OR_SITE_CONFIG_JSON_FILE 的 video_rtsp_urls再执行与 /client/surgeries/start 相同的开录逻辑"
"(含按 voice_or_room_bindings 解析并 WebSocket 推送语音终端指派)。"
),
)
async def orchestrate_and_start(
surgery_id: Annotated[str, Form()],
video1: Annotated[UploadFile, File(description="第 1 路视频(必填,至少一路)")],
video2: Annotated[UploadFile | None, File(description="第 2 路视频(可选)")] = None,
video3: Annotated[UploadFile | None, File(description="第 3 路视频(可选)")] = None,
video4: Annotated[UploadFile | None, File(description="第 4 路视频(可选)")] = None,
camera_1: Annotated[str, Form()] = "or-cam-01",
camera_2: Annotated[str, Form()] = "or-cam-02",
camera_3: Annotated[str, Form()] = "or-cam-03",
camera_4: Annotated[str, Form()] = "or-cam-04",
rtsp_path_1: Annotated[str, Form()] = "demo1",
rtsp_path_2: Annotated[str, Form()] = "demo2",
rtsp_path_3: Annotated[str, Form()] = "demo3",
rtsp_path_4: Annotated[str, Form()] = "demo4",
candidate_consumables_json: Annotated[str, Form()] = "[]",
pipeline: SurgeryPipeline = Depends(get_surgery_pipeline),
voice_hub: VoiceTerminalHub = Depends(get_voice_terminal_hub),
) -> SurgeryApiResponse:
logger.info(
"demo orchestrate-and-start: surgery_id={} cameras={} rpaths={}",
surgery_id,
(camera_1, camera_2, camera_3, camera_4),
(rtsp_path_1, rtsp_path_2, rtsp_path_3, rtsp_path_4),
)
if not settings.demo_orchestrator_enabled:
raise HTTPException(
status_code=status.HTTP_404_NOT_FOUND,
detail="Demo orchestrator disabled (set DEMO_ORCHESTRATOR_ENABLED=true).",
)
path_raw = (settings.or_site_config_json_file or "").strip()
if not path_raw:
raise HTTPException(
status_code=status.HTTP_400_BAD_REQUEST,
detail=(
"OR_SITE_CONFIG_JSON_FILE must be set to a writable path "
"(strict site JSON with video_rtsp_urls + voice_or_room_bindings); "
"in Docker, bind-mount a host file to this path."
),
)
json_path = Path(path_raw).expanduser()
try:
candidates = json.loads(candidate_consumables_json)
except json.JSONDecodeError as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=f"invalid candidate_consumables_json: {exc}",
) from exc
if not isinstance(candidates, list):
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="candidate_consumables_json must be a JSON array",
)
candidates = normalize_candidate_consumables_raw(candidates)
default_rtsp = ("demo1", "demo2", "demo3", "demo4")
async def _bytes_and_suffix(u: UploadFile) -> tuple[bytes, str]:
raw = await u.read()
ext = Path(u.filename or "clip.mp4").suffix or ".mp4"
return raw, ext
slot_uploads = (video1, video2, video3, video4)
slot_cameras = (
camera_1.strip(),
camera_2.strip(),
camera_3.strip(),
camera_4.strip(),
)
slot_rpaths = (
rtsp_path_1.strip(),
rtsp_path_2.strip(),
rtsp_path_3.strip(),
rtsp_path_4.strip(),
)
gathered: list[tuple[bytes, str, str, str]] = []
for idx, u in enumerate(slot_uploads):
if u is None:
break
raw, ext = await _bytes_and_suffix(u)
if not raw:
break
cam = slot_cameras[idx] or f"or-cam-0{idx + 1}"
rp = slot_rpaths[idx] or default_rtsp[idx]
gathered.append((raw, ext, cam, rp))
if not gathered:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="至少需要一路非空视频video1",
)
if len(gathered) > 4:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail="最多 4 路视频",
)
try:
body = SurgeryStartRequest(
surgery_id=surgery_id,
camera_ids=[g[2] for g in gathered],
candidate_consumables=candidates,
)
except Exception as exc:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_CONTENT,
detail=str(exc),
) from exc
work_root = Path(tempfile.mkdtemp(prefix="orm-orch-"))
try:
def _save_files() -> None:
for i, (raw, ext, _cam, _rp) in enumerate(gathered):
fp = work_root / f"v{i + 1}{ext}"
fp.write_bytes(raw)
await anyio.to_thread.run_sync(_save_files)
except OSError as exc:
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"failed to save uploads: {exc}",
) from exc
streams = [
StreamSpec(
camera_id=g[2],
file_path=work_root / f"v{i + 1}{g[1]}",
rtsp_path=g[3],
)
for i, g in enumerate(gathered)
]
port = int(settings.demo_orchestrator_rtsp_port)
try:
def _start_synth() -> dict[str, str]:
mgr = SyntheticRtspManager.get()
_run, url_map = mgr.start(streams, host_port=port, work_dir=work_root)
return url_map
url_map_host = await anyio.to_thread.run_sync(_start_synth)
except (FileNotFoundError, OSError, ValueError, RuntimeError) as exc:
logger.exception("synthetic RTSP start failed: {}", exc)
await anyio.to_thread.run_sync(SyntheticRtspManager.stop_active)
shutil.rmtree(work_root, ignore_errors=True)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail=f"synthetic RTSP failed: {exc}",
) from exc
host_for_json = _orchestrate_write_rtsp_host()
try:
def _write() -> None:
merge_video_rtsp_urls_into_file(
json_path,
url_map_host,
replace_host=host_for_json,
)
await anyio.to_thread.run_sync(_write)
except OSError as exc:
await anyio.to_thread.run_sync(SyntheticRtspManager.stop_active)
raise HTTPException(
status_code=status.HTTP_500_INTERNAL_SERVER_ERROR,
detail=f"failed to write RTSP JSON file: {exc}",
) from exc
await anyio.sleep(0.2)
try:
await pipeline.start_recording(
body.surgery_id,
list(body.camera_ids),
list(body.candidate_consumables),
)
except SurgeryPipelineError as exc:
await anyio.to_thread.run_sync(SyntheticRtspManager.stop_active)
raise HTTPException(
status_code=status.HTTP_503_SERVICE_UNAVAILABLE,
detail={"code": exc.code, "message": exc.message, "surgery_id": body.surgery_id},
) from exc
await assign_voice_terminal_after_recording_started(
voice_hub,
surgery_id=body.surgery_id,
camera_ids=list(body.camera_ids),
set_voice_terminal_id=pipeline.set_voice_terminal_id,
)
return SurgeryApiResponse(
surgery_id=body.surgery_id,
status="accepted",
message="假 RTSP 已起;映射已写入;摄像头录制已开始。",
)