将后端迁入 backend/,完善根目录 .gitignore,删除误提交的 .mypy_cache 缓存文件。 Co-authored-by: Cursor <cursoragent@cursor.com>
978 lines
33 KiB
Python
978 lines
33 KiB
Python
"""Batch video mode: run the configured reference bundle on a complete MP4.
|
||
|
||
This path generates the YAML/Excel/whitelist inputs expected by
|
||
the reference bundle's ``main.py``. Intermediate files are cached per
|
||
surgery_id, video sha256, and candidate list, never shared across different
|
||
surgeries.
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import csv
|
||
import copy
|
||
import hashlib
|
||
import json
|
||
import os
|
||
import re
|
||
import signal
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta, timezone
|
||
from pathlib import Path
|
||
|
||
import yaml
|
||
from loguru import logger
|
||
|
||
from app.algorithm_runner.reference_bundle_runtime import (
|
||
default_reference_bundle_dir,
|
||
ensure_reference_nms_patch,
|
||
load_reference_default_config,
|
||
resolve_reference_bundle_dir,
|
||
)
|
||
from app.baked import pipeline as bp
|
||
from app.consumable_catalog import build_name_mapping, effective_candidate_consumables, normalize_candidate_consumables_raw
|
||
from app.domain.consumption import SurgeryConsumptionStored
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ReferenceDoctorInfo:
|
||
"""Parsed from refs/5.15 result footer line ``医生信息:...``."""
|
||
|
||
doctor_id: str
|
||
doctor_name: str | None
|
||
display: str
|
||
raw_line: str
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class VideoBatchRunResult:
|
||
video_sha256: str
|
||
candidate_cache_key: str
|
||
input_path: Path
|
||
work_dir: Path
|
||
output_path: Path
|
||
details: list[SurgeryConsumptionStored]
|
||
reused_cache: bool
|
||
doctor: ReferenceDoctorInfo | None = None
|
||
visualization_path: Path | None = None
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class ReferenceRunFiles:
|
||
config_path: Path
|
||
excel_path: Path
|
||
whitelist_path: Path
|
||
|
||
|
||
VISUALIZATION_FILENAME = "result_vis.mp4"
|
||
RAW_VISUALIZATION_FILENAME = "result_vis_source.mp4"
|
||
# 标注视频最长边上限(宽 1920 ≈ 1080p),绘制与转码共用,避免 4K 逐帧 YOLO。
|
||
VISUALIZATION_MAX_WIDTH = 1920
|
||
|
||
|
||
def _visualization_ffmpeg_scale_filter() -> str:
|
||
return f"scale='min({VISUALIZATION_MAX_WIDTH},iw)':-2"
|
||
|
||
|
||
def browser_transcode_tmp_path(output_path: Path) -> Path:
|
||
"""Temp file for atomic replace; must end in ``.mp4`` so ffmpeg picks the muxer."""
|
||
|
||
return output_path.with_name(f"{output_path.stem}.part{output_path.suffix}")
|
||
|
||
|
||
def _log_subprocess_output(prefix: str, stdout: str, stderr: str, *, max_lines: int = 40) -> None:
|
||
"""Emit captured child-process lines at INFO (used after visualize / transcode)."""
|
||
|
||
for label, text in (("stdout", stdout), ("stderr", stderr)):
|
||
lines = [ln for ln in (text or "").splitlines() if ln.strip()]
|
||
if not lines:
|
||
continue
|
||
tail = lines[-max_lines:] if len(lines) > max_lines else lines
|
||
for line in tail:
|
||
logger.info("{} {}", prefix, line)
|
||
|
||
|
||
def sha256_file(path: Path) -> str:
|
||
h = hashlib.sha256()
|
||
with path.open("rb") as f:
|
||
for chunk in iter(lambda: f.read(1024 * 1024), b""):
|
||
h.update(chunk)
|
||
return h.hexdigest()
|
||
|
||
|
||
def build_reference_command(
|
||
*,
|
||
bundle_dir: Path,
|
||
config_path: Path,
|
||
) -> list[str]:
|
||
return [
|
||
"uv",
|
||
"run",
|
||
"python",
|
||
"-X",
|
||
"faulthandler",
|
||
str(bundle_dir / "main.py"),
|
||
"--config",
|
||
str(config_path),
|
||
]
|
||
|
||
|
||
def build_reference_env() -> dict[str, str]:
|
||
env = os.environ.copy()
|
||
env["PYTHONFAULTHANDLER"] = "1"
|
||
env["PYTHONUNBUFFERED"] = "1"
|
||
return env
|
||
|
||
|
||
def build_reference_visualization_command(
|
||
*,
|
||
bundle_dir: Path,
|
||
video_path: Path,
|
||
result_path: Path,
|
||
output_video_path: Path,
|
||
) -> list[str]:
|
||
cfg = load_reference_default_config(bundle_dir)
|
||
weights = cfg.get("weights") if isinstance(cfg.get("weights"), dict) else {}
|
||
phase2 = cfg.get("phase2") if isinstance(cfg.get("phase2"), dict) else {}
|
||
device_cfg = cfg.get("device") if isinstance(cfg.get("device"), dict) else {}
|
||
hand_raw = str((weights or {}).get("hand") or "weights/hand_detect.pt").strip()
|
||
hand_model = Path(hand_raw)
|
||
if not hand_model.is_absolute():
|
||
hand_model = (bundle_dir / hand_model).resolve()
|
||
return [
|
||
sys.executable,
|
||
"-X",
|
||
"faulthandler",
|
||
str((bundle_dir / "visualize_result_video.py").resolve()),
|
||
"--video",
|
||
str(video_path.resolve()),
|
||
"--result-txt",
|
||
str(result_path.resolve()),
|
||
"--hand-model",
|
||
str(hand_model),
|
||
"--out-video",
|
||
str(output_video_path.resolve()),
|
||
"--device",
|
||
str(device_cfg.get("type") or "cuda"),
|
||
"--det-conf",
|
||
str(phase2.get("det_conf", 0.6)),
|
||
"--imgsz-det",
|
||
str(phase2.get("imgsz_det", 640)),
|
||
"--pad-ratio",
|
||
str(phase2.get("pad_ratio", 0.2)),
|
||
"--max-width",
|
||
str(VISUALIZATION_MAX_WIDTH),
|
||
]
|
||
|
||
|
||
def _is_readable_mp4(path: Path) -> bool:
|
||
ffprobe = shutil.which("ffprobe")
|
||
if ffprobe is None or not path.is_file() or path.stat().st_size < 4096:
|
||
return False
|
||
proc = subprocess.run(
|
||
[ffprobe, "-v", "error", "-select_streams", "v:0", "-show_entries", "stream=codec_name", "-of", "csv=p=0", str(path)],
|
||
check=False,
|
||
text=True,
|
||
capture_output=True,
|
||
)
|
||
return proc.returncode == 0 and bool((proc.stdout or "").strip())
|
||
|
||
|
||
def _ffprobe_fields(path: Path, entries: str) -> dict[str, str]:
|
||
ffprobe = shutil.which("ffprobe")
|
||
if ffprobe is None or not path.is_file():
|
||
return {}
|
||
proc = subprocess.run(
|
||
[
|
||
ffprobe,
|
||
"-v",
|
||
"error",
|
||
"-select_streams",
|
||
"v:0",
|
||
"-show_entries",
|
||
entries,
|
||
"-of",
|
||
"default=noprint_wrappers=1",
|
||
str(path),
|
||
],
|
||
check=False,
|
||
text=True,
|
||
capture_output=True,
|
||
)
|
||
if proc.returncode != 0:
|
||
return {}
|
||
fields: dict[str, str] = {}
|
||
for line in proc.stdout.splitlines():
|
||
if "=" not in line:
|
||
continue
|
||
key, value = line.split("=", 1)
|
||
fields[key.strip().lower()] = value.strip().lower()
|
||
return fields
|
||
|
||
|
||
def _ffprobe_container_format(path: Path) -> str:
|
||
ffprobe = shutil.which("ffprobe")
|
||
if ffprobe is None or not path.is_file():
|
||
return ""
|
||
proc = subprocess.run(
|
||
[
|
||
ffprobe,
|
||
"-v",
|
||
"error",
|
||
"-show_entries",
|
||
"format=format_name",
|
||
"-of",
|
||
"default=noprint_wrappers=1:nokey=1",
|
||
str(path),
|
||
],
|
||
check=False,
|
||
text=True,
|
||
capture_output=True,
|
||
)
|
||
if proc.returncode != 0:
|
||
return ""
|
||
return (proc.stdout or "").strip().lower()
|
||
|
||
|
||
def _is_browser_compatible_mp4(path: Path) -> bool:
|
||
fields = _ffprobe_fields(path, "stream=codec_name,pix_fmt")
|
||
return fields.get("codec_name") == "h264" and fields.get("pix_fmt") in {"yuv420p", "yuvj420p"}
|
||
|
||
|
||
def _batch_input_needs_normalize(path: Path) -> bool:
|
||
"""True when the upload is likely to crash OpenCV/VideoSwin (HEVC, MPEG-PS, >1080p)."""
|
||
|
||
if not _is_readable_mp4(path):
|
||
return True
|
||
if not _is_browser_compatible_mp4(path):
|
||
return True
|
||
container = _ffprobe_container_format(path)
|
||
if container and "mpeg" in container:
|
||
return True
|
||
fields = _ffprobe_fields(path, "stream=codec_name,width,height")
|
||
try:
|
||
width = int(fields.get("width") or "0")
|
||
except ValueError:
|
||
width = 0
|
||
return width > 1920
|
||
|
||
|
||
def _normalize_batch_input_video(source_path: Path, output_path: Path) -> bool:
|
||
"""Remux/transcode DVR uploads to H.264 MP4 (<=1080p) for stable feature extraction."""
|
||
|
||
ffmpeg = shutil.which("ffmpeg")
|
||
if ffmpeg is None or not source_path.is_file():
|
||
return False
|
||
if not _is_readable_mp4(source_path):
|
||
logger.warning("skip batch input normalize: unreadable source {}", source_path)
|
||
return False
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp_path = browser_transcode_tmp_path(output_path)
|
||
if tmp_path.exists():
|
||
tmp_path.unlink()
|
||
logger.info(
|
||
"ffmpeg batch input normalize starting: {} -> {}",
|
||
source_path,
|
||
output_path,
|
||
)
|
||
proc = subprocess.run(
|
||
[
|
||
ffmpeg,
|
||
"-y",
|
||
"-hide_banner",
|
||
"-loglevel",
|
||
"error",
|
||
"-fflags",
|
||
"+genpts",
|
||
"-i",
|
||
str(source_path),
|
||
"-map",
|
||
"0:v:0",
|
||
"-an",
|
||
"-f",
|
||
"mp4",
|
||
"-c:v",
|
||
"libx264",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
"-preset",
|
||
"veryfast",
|
||
"-crf",
|
||
"23",
|
||
"-vf",
|
||
"scale='min(1920,iw)':-2",
|
||
"-movflags",
|
||
"+faststart",
|
||
str(tmp_path),
|
||
],
|
||
check=False,
|
||
text=True,
|
||
capture_output=True,
|
||
)
|
||
if proc.returncode != 0:
|
||
stderr = (proc.stderr or "").strip()
|
||
logger.warning("ffmpeg batch input normalize failed: {}", stderr[-3000:])
|
||
if tmp_path.exists():
|
||
tmp_path.unlink()
|
||
return False
|
||
if not tmp_path.is_file() or tmp_path.stat().st_size <= 0:
|
||
logger.warning("ffmpeg batch input normalize produced empty file: {}", tmp_path)
|
||
if tmp_path.exists():
|
||
tmp_path.unlink()
|
||
return False
|
||
tmp_path.replace(output_path)
|
||
if not _is_browser_compatible_mp4(output_path):
|
||
logger.warning("ffmpeg batch input normalize output not h264/yuv420p: {}", output_path)
|
||
output_path.unlink(missing_ok=True)
|
||
return False
|
||
logger.info(
|
||
"ffmpeg batch input normalize complete: {} ({} bytes)",
|
||
output_path,
|
||
output_path.stat().st_size,
|
||
)
|
||
return True
|
||
|
||
|
||
def ensure_batch_pipeline_input_video(*, source_path: Path, dest_path: Path) -> None:
|
||
"""Write a pipeline-ready MP4 at dest_path (normalize or copy)."""
|
||
|
||
dest_path.parent.mkdir(parents=True, exist_ok=True)
|
||
if dest_path.is_file() and dest_path.stat().st_size > 0 and not _batch_input_needs_normalize(dest_path):
|
||
return
|
||
if _batch_input_needs_normalize(source_path):
|
||
if _normalize_batch_input_video(source_path, dest_path):
|
||
return
|
||
logger.warning(
|
||
"batch input normalize failed, falling back to raw copy: {} -> {}",
|
||
source_path,
|
||
dest_path,
|
||
)
|
||
if not dest_path.is_file():
|
||
shutil.copy2(source_path, dest_path)
|
||
|
||
|
||
_DOCTOR_NAME_ID_RE = re.compile(
|
||
r"^(?P<name>.+?)\s*\(id=(?P<id>[^,\s)]+)(?:,\s*conf=[\d.]+)?\)\s*(?:\[低置信度\])?\s*$"
|
||
)
|
||
_DOCTOR_ID_ONLY_RE = re.compile(
|
||
r"^doctor_id=(?P<id>[^\s(]+)(?:\s*\(conf=[\d.]+\))?\s*(?:\[低置信度\])?\s*$"
|
||
)
|
||
|
||
|
||
def parse_reference_doctor_info(path: Path) -> ReferenceDoctorInfo | None:
|
||
"""Read ``医生信息:姓名 (id=...)`` footer appended by refs/5.15 orchestrator."""
|
||
|
||
if not path.is_file():
|
||
return None
|
||
raw_line = ""
|
||
for line in path.read_text(encoding="utf-8").splitlines():
|
||
stripped = line.strip()
|
||
if stripped.startswith("医生信息:") or stripped.startswith("医生信息:"):
|
||
raw_line = stripped
|
||
break
|
||
if not raw_line:
|
||
return None
|
||
|
||
body = raw_line.split(":", 1)[-1].split(":", 1)[-1].strip()
|
||
if not body or body == "未启用":
|
||
return ReferenceDoctorInfo(
|
||
doctor_id=bp.VIDEO_RESULT_DOCTOR_ID,
|
||
doctor_name=None,
|
||
display=body or "未启用",
|
||
raw_line=raw_line,
|
||
)
|
||
if body.startswith("识别失败"):
|
||
return ReferenceDoctorInfo(
|
||
doctor_id=bp.VIDEO_RESULT_DOCTOR_ID,
|
||
doctor_name=None,
|
||
display=body,
|
||
raw_line=raw_line,
|
||
)
|
||
|
||
match = _DOCTOR_NAME_ID_RE.match(body)
|
||
if match:
|
||
name = match.group("name").strip()
|
||
did = match.group("id").strip()
|
||
return ReferenceDoctorInfo(
|
||
doctor_id=did,
|
||
doctor_name=name,
|
||
display=f"{name} ({did})",
|
||
raw_line=raw_line,
|
||
)
|
||
|
||
match = _DOCTOR_ID_ONLY_RE.match(body)
|
||
if match:
|
||
did = match.group("id").strip()
|
||
return ReferenceDoctorInfo(
|
||
doctor_id=did,
|
||
doctor_name=None,
|
||
display=did,
|
||
raw_line=raw_line,
|
||
)
|
||
|
||
return ReferenceDoctorInfo(
|
||
doctor_id=bp.VIDEO_RESULT_DOCTOR_ID,
|
||
doctor_name=None,
|
||
display=body,
|
||
raw_line=raw_line,
|
||
)
|
||
|
||
|
||
def is_reference_result_complete(path: Path) -> bool:
|
||
"""True when refs/5.15 orchestrator has finished writing the TSV (incl. footer)."""
|
||
|
||
if not path.is_file() or path.stat().st_size <= 0:
|
||
return False
|
||
lines = [line.strip() for line in path.read_text(encoding="utf-8").splitlines() if line.strip()]
|
||
if not any(line.lower().startswith("rank\t") for line in lines):
|
||
return False
|
||
has_doctor_footer = any(
|
||
line.startswith("医生信息:") or line.startswith("医生信息:") for line in lines
|
||
)
|
||
has_segment_row = False
|
||
for line in lines:
|
||
if line.lower().startswith("rank\t"):
|
||
continue
|
||
if line.startswith("医生信息"):
|
||
continue
|
||
parts = line.split("\t")
|
||
if len(parts) >= 5 and parts[0].strip().isdigit():
|
||
has_segment_row = True
|
||
break
|
||
return has_doctor_footer and has_segment_row
|
||
|
||
|
||
def doctor_id_for_consumption_rows(doctor: ReferenceDoctorInfo | None) -> str:
|
||
if doctor is None:
|
||
return bp.VIDEO_RESULT_DOCTOR_ID
|
||
if doctor.doctor_name:
|
||
return f"{doctor.doctor_name} ({doctor.doctor_id})"
|
||
if doctor.doctor_id and doctor.doctor_id != bp.VIDEO_RESULT_DOCTOR_ID:
|
||
return doctor.doctor_id
|
||
return bp.VIDEO_RESULT_DOCTOR_ID
|
||
|
||
|
||
def _transcode_visualization_for_browser(source_path: Path, output_path: Path) -> bool:
|
||
ffmpeg = shutil.which("ffmpeg")
|
||
if ffmpeg is None or not source_path.is_file():
|
||
return False
|
||
if not _is_readable_mp4(source_path):
|
||
logger.warning("skip visualization transcode: unreadable source {}", source_path)
|
||
return False
|
||
output_path.parent.mkdir(parents=True, exist_ok=True)
|
||
tmp_path = browser_transcode_tmp_path(output_path)
|
||
if tmp_path.exists():
|
||
tmp_path.unlink()
|
||
logger.info(
|
||
"ffmpeg visualization transcode starting: {} -> {}",
|
||
source_path,
|
||
output_path,
|
||
)
|
||
proc = subprocess.run(
|
||
[
|
||
ffmpeg,
|
||
"-y",
|
||
"-hide_banner",
|
||
"-loglevel",
|
||
"error",
|
||
"-i",
|
||
str(source_path),
|
||
"-map",
|
||
"0:v:0",
|
||
"-an",
|
||
"-f",
|
||
"mp4",
|
||
"-c:v",
|
||
"libx264",
|
||
"-pix_fmt",
|
||
"yuv420p",
|
||
"-preset",
|
||
"ultrafast",
|
||
"-crf",
|
||
"23",
|
||
"-vf",
|
||
_visualization_ffmpeg_scale_filter(),
|
||
"-movflags",
|
||
"+faststart",
|
||
str(tmp_path),
|
||
],
|
||
check=False,
|
||
text=True,
|
||
capture_output=True,
|
||
)
|
||
if proc.returncode != 0:
|
||
stderr = (proc.stderr or "").strip()
|
||
logger.warning("ffmpeg visualization transcode failed: {}", stderr[-3000:])
|
||
if tmp_path.exists():
|
||
tmp_path.unlink()
|
||
return False
|
||
if not tmp_path.is_file() or tmp_path.stat().st_size <= 0:
|
||
logger.warning("ffmpeg visualization transcode produced empty file: {}", tmp_path)
|
||
if tmp_path.exists():
|
||
tmp_path.unlink()
|
||
return False
|
||
tmp_path.replace(output_path)
|
||
if not _is_browser_compatible_mp4(output_path):
|
||
logger.warning("ffmpeg output is not browser-compatible h264/yuv420p: {}", output_path)
|
||
output_path.unlink(missing_ok=True)
|
||
return False
|
||
logger.info(
|
||
"ffmpeg visualization transcode complete: {} ({} bytes)",
|
||
output_path,
|
||
output_path.stat().st_size,
|
||
)
|
||
return True
|
||
|
||
|
||
def ensure_reference_actionformer_nms_patch(bundle_dir: Path) -> bool:
|
||
"""Make the reference bundle use our pure-PyTorch ActionFormer NMS.
|
||
|
||
The upstream bundle imports a compiled ``nms_1d_cpu`` extension during eval.
|
||
That extension is not present in deployment, so batch mode must use the same
|
||
runtime-safe NMS implementation as the online ActionFormer path.
|
||
"""
|
||
|
||
return ensure_reference_nms_patch(bundle_dir)
|
||
|
||
|
||
def _signal_name(signum: int) -> str:
|
||
try:
|
||
return signal.Signals(signum).name
|
||
except ValueError:
|
||
return f"signal {signum}"
|
||
|
||
|
||
def describe_batch_returncode(returncode: int) -> str:
|
||
if returncode < 0:
|
||
signum = -returncode
|
||
return f"terminated by {_signal_name(signum)} ({signum})"
|
||
if returncode > 128:
|
||
wrapped = returncode - 256
|
||
if wrapped < 0:
|
||
signum = -wrapped
|
||
return f"exit={returncode} (possibly propagated {wrapped}/{_signal_name(signum)})"
|
||
return f"exit={returncode}"
|
||
|
||
|
||
def format_batch_failure(returncode: int, *, stdout: str, stderr: str, work_dir: Path, output_path: Path) -> str:
|
||
chunks: list[str] = [describe_batch_returncode(returncode), f"work_dir={work_dir}", f"output={output_path}"]
|
||
stdout = stdout.strip()
|
||
stderr = stderr.strip()
|
||
if stdout:
|
||
chunks.append(f"stdout:\n{stdout[-3000:]}")
|
||
if stderr:
|
||
chunks.append(f"stderr:\n{stderr[-3000:]}")
|
||
return "\n".join(chunks)
|
||
|
||
|
||
def parse_reference_tsv(
|
||
path: Path,
|
||
*,
|
||
base_timestamp: datetime | None = None,
|
||
doctor: ReferenceDoctorInfo | None = None,
|
||
) -> list[SurgeryConsumptionStored]:
|
||
if base_timestamp is None:
|
||
base_timestamp = datetime.now(timezone.utc)
|
||
if doctor is None:
|
||
doctor = parse_reference_doctor_info(path)
|
||
row_doctor_id = doctor_id_for_consumption_rows(doctor)
|
||
out: list[SurgeryConsumptionStored] = []
|
||
with path.open("r", encoding="utf-8", newline="") as f:
|
||
reader = csv.DictReader(f, delimiter="\t")
|
||
for row in reader:
|
||
name = (row.get("top1_name") or "").strip()
|
||
if not name or name.startswith("("):
|
||
continue
|
||
if name.startswith("医生信息"):
|
||
continue
|
||
item_id = (row.get("product_id_top1") or "").strip() or name
|
||
try:
|
||
start_sec = float((row.get("start_sec") or "0").strip() or 0.0)
|
||
except ValueError:
|
||
start_sec = 0.0
|
||
out.append(
|
||
SurgeryConsumptionStored(
|
||
item_id=item_id,
|
||
item_name=name,
|
||
qty=1,
|
||
doctor_id=row_doctor_id,
|
||
timestamp=base_timestamp + timedelta(seconds=max(0.0, start_sec)),
|
||
source="video_batch",
|
||
)
|
||
)
|
||
return out
|
||
|
||
|
||
def _candidate_cache_key(candidate_consumables: list[str]) -> str:
|
||
raw = "\n".join(candidate_consumables).encode("utf-8")
|
||
return hashlib.sha256(raw).hexdigest()[:12]
|
||
|
||
|
||
def resolve_reference_candidates(candidate_consumables: list[str] | None) -> list[str]:
|
||
requested = normalize_candidate_consumables_raw(list(candidate_consumables or []))
|
||
return effective_candidate_consumables(requested)
|
||
|
||
|
||
def write_reference_catalog_excel(
|
||
path: Path,
|
||
*,
|
||
candidate_consumables: list[str],
|
||
) -> None:
|
||
import pandas as pd
|
||
|
||
name_to_code = build_name_mapping(candidate_consumables)
|
||
rows = [
|
||
{
|
||
"序号": idx,
|
||
"产品编码": name_to_code.get(name, name),
|
||
"商品名称": name,
|
||
}
|
||
for idx, name in enumerate(candidate_consumables, start=1)
|
||
]
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
pd.DataFrame(rows, columns=["序号", "产品编码", "商品名称"]).to_excel(path, index=False)
|
||
|
||
|
||
def write_reference_whitelist_json(path: Path, *, candidate_consumables: list[str]) -> None:
|
||
path.parent.mkdir(parents=True, exist_ok=True)
|
||
path.write_text(
|
||
json.dumps({"allowed_names": candidate_consumables}, ensure_ascii=False, indent=2),
|
||
encoding="utf-8",
|
||
)
|
||
|
||
|
||
def build_reference_config(
|
||
*,
|
||
bundle_dir: Path,
|
||
video_path: Path,
|
||
output_path: Path,
|
||
work_dir: Path,
|
||
excel_path: Path,
|
||
whitelist_path: Path,
|
||
) -> dict:
|
||
cfg = copy.deepcopy(load_reference_default_config(bundle_dir))
|
||
cfg["io"]["video"] = str(video_path.resolve())
|
||
cfg["io"]["excel"] = str(excel_path.resolve())
|
||
cfg["io"]["out"] = str(output_path.resolve())
|
||
cfg["io"]["whitelist_json"] = str(whitelist_path.resolve())
|
||
cfg["runtime"]["work_dir"] = str(work_dir.resolve())
|
||
cfg["runtime"]["keep_work_dir"] = True
|
||
return cfg
|
||
|
||
|
||
def prepare_reference_run_files(
|
||
*,
|
||
bundle_dir: Path,
|
||
video_path: Path,
|
||
output_path: Path,
|
||
work_dir: Path,
|
||
config_path: Path,
|
||
excel_path: Path,
|
||
whitelist_path: Path,
|
||
candidate_consumables: list[str],
|
||
) -> ReferenceRunFiles:
|
||
write_reference_catalog_excel(excel_path, candidate_consumables=candidate_consumables)
|
||
write_reference_whitelist_json(whitelist_path, candidate_consumables=candidate_consumables)
|
||
config = build_reference_config(
|
||
bundle_dir=bundle_dir,
|
||
video_path=video_path,
|
||
output_path=output_path,
|
||
work_dir=work_dir,
|
||
excel_path=excel_path,
|
||
whitelist_path=whitelist_path,
|
||
)
|
||
config_path.parent.mkdir(parents=True, exist_ok=True)
|
||
config_path.write_text(
|
||
yaml.safe_dump(config, allow_unicode=True, sort_keys=False),
|
||
encoding="utf-8",
|
||
)
|
||
return ReferenceRunFiles(
|
||
config_path=config_path,
|
||
excel_path=excel_path,
|
||
whitelist_path=whitelist_path,
|
||
)
|
||
|
||
|
||
def _read_reference_video_path_from_config(config_path: Path) -> Path | None:
|
||
if not config_path.is_file():
|
||
return None
|
||
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||
if not isinstance(data, dict):
|
||
return None
|
||
raw = (((data.get("io") or {}) if isinstance(data.get("io"), dict) else {}).get("video") or "").strip()
|
||
if not raw:
|
||
return None
|
||
return Path(raw).expanduser().resolve()
|
||
|
||
|
||
class VideoBatchRunner:
|
||
def __init__(
|
||
self,
|
||
*,
|
||
bundle_dir: Path | None = None,
|
||
root_dir: Path | None = None,
|
||
) -> None:
|
||
repo_root = Path(__file__).resolve().parents[2]
|
||
self._bundle_dir_override = bundle_dir
|
||
self._root_dir = root_dir or (repo_root / "logs" / "video_batch")
|
||
|
||
@property
|
||
def bundle_dir(self) -> Path:
|
||
if self._bundle_dir_override is not None:
|
||
return Path(self._bundle_dir_override).expanduser().resolve()
|
||
return default_reference_bundle_dir()
|
||
|
||
@property
|
||
def root_dir(self) -> Path:
|
||
return self._root_dir
|
||
|
||
def _generate_visualization(
|
||
self,
|
||
*,
|
||
bundle_dir: Path,
|
||
video_path: Path,
|
||
result_path: Path,
|
||
output_video_path: Path,
|
||
) -> Path | None:
|
||
raw_video_path = output_video_path.with_name(RAW_VISUALIZATION_FILENAME)
|
||
script_path = bundle_dir / "visualize_result_video.py"
|
||
if not script_path.is_file():
|
||
logger.warning("reference visualization script not found: {}", script_path)
|
||
return None
|
||
if not video_path.is_file() or not result_path.is_file():
|
||
return None
|
||
if output_video_path.is_file() and _is_browser_compatible_mp4(output_video_path):
|
||
return output_video_path
|
||
if raw_video_path.is_file() and not _is_readable_mp4(raw_video_path):
|
||
raw_video_path.unlink(missing_ok=True)
|
||
if output_video_path.is_file() and not _is_browser_compatible_mp4(output_video_path):
|
||
output_video_path.unlink(missing_ok=True)
|
||
if raw_video_path.is_file() and _is_readable_mp4(raw_video_path):
|
||
logger.info(
|
||
"reusing existing visualization source for transcode: {}",
|
||
raw_video_path,
|
||
)
|
||
if _transcode_visualization_for_browser(raw_video_path, output_video_path):
|
||
return output_video_path
|
||
logger.warning(
|
||
"transcode from existing source failed; regenerating visualization: {}",
|
||
raw_video_path,
|
||
)
|
||
raw_video_path.unlink(missing_ok=True)
|
||
cmd = build_reference_visualization_command(
|
||
bundle_dir=bundle_dir,
|
||
video_path=video_path.resolve(),
|
||
result_path=result_path.resolve(),
|
||
output_video_path=raw_video_path.resolve(),
|
||
)
|
||
logger.info(
|
||
"reference visualization script starting: {}",
|
||
" ".join(cmd),
|
||
)
|
||
proc = subprocess.run(
|
||
cmd,
|
||
cwd=str(bundle_dir),
|
||
check=False,
|
||
text=True,
|
||
capture_output=True,
|
||
env=build_reference_env(),
|
||
)
|
||
if proc.returncode != 0:
|
||
msg = format_batch_failure(
|
||
proc.returncode,
|
||
stdout=proc.stdout or "",
|
||
stderr=proc.stderr or "",
|
||
work_dir=output_video_path.parent.resolve(),
|
||
output_path=output_video_path.resolve(),
|
||
)
|
||
logger.error("reference visualization failed: {}", msg)
|
||
_log_subprocess_output("visualize", proc.stdout or "", proc.stderr or "")
|
||
return None
|
||
_log_subprocess_output("visualize", proc.stdout or "", proc.stderr or "")
|
||
if not _is_readable_mp4(raw_video_path):
|
||
logger.error("reference visualization produced unreadable mp4: {}", raw_video_path)
|
||
return None
|
||
if _transcode_visualization_for_browser(raw_video_path, output_video_path):
|
||
return output_video_path
|
||
logger.error("reference visualization transcode to browser mp4 failed: {}", output_video_path)
|
||
return None
|
||
|
||
def finalize_visualization(self, result: VideoBatchRunResult, *, surgery_id: str) -> Path | None:
|
||
"""Run hand-overlay visualization after batch text result is already persisted."""
|
||
|
||
logger.info(
|
||
"video batch visualization starting for surgery_id={} (visualize_result_video.py)",
|
||
surgery_id,
|
||
)
|
||
if not is_reference_result_complete(result.output_path):
|
||
logger.warning("skip visualization: incomplete result {}", result.output_path)
|
||
return None
|
||
bundle_dir = resolve_reference_bundle_dir(self._bundle_dir_override)
|
||
cache_input = result.output_path.parent.parent / "input"
|
||
video_path = next((p for p in sorted(cache_input.glob("*")) if p.is_file()), None)
|
||
if video_path is None:
|
||
logger.warning("skip visualization: missing cache input video under {}", cache_input)
|
||
return None
|
||
vis_path = self._generate_visualization(
|
||
bundle_dir=bundle_dir,
|
||
video_path=video_path,
|
||
result_path=result.output_path,
|
||
output_video_path=result.output_path.with_name(VISUALIZATION_FILENAME),
|
||
)
|
||
if vis_path is not None:
|
||
logger.info(
|
||
"video batch visualization complete for surgery_id={} ({})",
|
||
surgery_id,
|
||
vis_path,
|
||
)
|
||
else:
|
||
logger.warning("video batch visualization failed for surgery_id={}", surgery_id)
|
||
return vis_path
|
||
|
||
def latest_visualization_path(self, surgery_id: str) -> Path | None:
|
||
"""Return an already-generated browser mp4; does not run visualization."""
|
||
|
||
surgery_cache_dir = self._root_dir / "cache" / surgery_id
|
||
if not surgery_cache_dir.is_dir():
|
||
return None
|
||
|
||
candidates = [
|
||
p
|
||
for p in surgery_cache_dir.rglob(f"output/{VISUALIZATION_FILENAME}")
|
||
if p.is_file() and p.stat().st_size > 0 and _is_browser_compatible_mp4(p)
|
||
]
|
||
if not candidates:
|
||
return None
|
||
candidates.sort(key=lambda p: p.stat().st_mtime, reverse=True)
|
||
return candidates[0]
|
||
|
||
def run(
|
||
self,
|
||
*,
|
||
surgery_id: str,
|
||
uploaded_video_path: Path,
|
||
original_filename: str = "video.mp4",
|
||
candidate_consumables: list[str] | None = None,
|
||
include_visualization: bool = False,
|
||
) -> VideoBatchRunResult:
|
||
bundle_dir = resolve_reference_bundle_dir(self._bundle_dir_override)
|
||
uploaded_video_path = uploaded_video_path.resolve()
|
||
digest = sha256_file(uploaded_video_path)
|
||
candidates = resolve_reference_candidates(candidate_consumables)
|
||
candidate_key = _candidate_cache_key(candidates)
|
||
|
||
surgery_input_dir = self._root_dir / surgery_id / "input"
|
||
surgery_input_dir.mkdir(parents=True, exist_ok=True)
|
||
surgery_input = surgery_input_dir / f"{digest[:12]}.mp4"
|
||
ensure_batch_pipeline_input_video(
|
||
source_path=uploaded_video_path,
|
||
dest_path=surgery_input,
|
||
)
|
||
|
||
cache_dir = self._root_dir / "cache" / surgery_id / digest / candidate_key
|
||
cache_input_dir = cache_dir / "input"
|
||
cache_output_dir = cache_dir / "output"
|
||
cache_work_dir = cache_dir / "work"
|
||
cache_config_dir = cache_dir / "config"
|
||
cache_input_dir.mkdir(parents=True, exist_ok=True)
|
||
cache_output_dir.mkdir(parents=True, exist_ok=True)
|
||
cache_work_dir.mkdir(parents=True, exist_ok=True)
|
||
cache_config_dir.mkdir(parents=True, exist_ok=True)
|
||
cache_input = cache_input_dir / "input.mp4"
|
||
ensure_batch_pipeline_input_video(
|
||
source_path=uploaded_video_path,
|
||
dest_path=cache_input,
|
||
)
|
||
output_path = cache_output_dir / "result.tsv"
|
||
run_files = prepare_reference_run_files(
|
||
bundle_dir=bundle_dir,
|
||
video_path=cache_input.resolve(),
|
||
output_path=output_path.resolve(),
|
||
work_dir=cache_work_dir.resolve(),
|
||
config_path=cache_config_dir / "config.yaml",
|
||
excel_path=cache_config_dir / "商品信息表.xlsx",
|
||
whitelist_path=cache_config_dir / "whitelist.json",
|
||
candidate_consumables=candidates,
|
||
)
|
||
|
||
reused_cache = output_path.is_file() and is_reference_result_complete(output_path)
|
||
if reused_cache:
|
||
logger.info(
|
||
"reference batch cache hit for surgery_id={} ({})",
|
||
surgery_id,
|
||
output_path,
|
||
)
|
||
else:
|
||
ensure_reference_actionformer_nms_patch(bundle_dir)
|
||
cmd = build_reference_command(
|
||
bundle_dir=bundle_dir,
|
||
config_path=run_files.config_path.resolve(),
|
||
)
|
||
logger.info(
|
||
"reference batch starting for surgery_id={} (refs main.py, work_dir={})",
|
||
surgery_id,
|
||
cache_work_dir,
|
||
)
|
||
proc = subprocess.run(
|
||
cmd,
|
||
cwd=str(bundle_dir),
|
||
check=False,
|
||
text=True,
|
||
capture_output=True,
|
||
env=build_reference_env(),
|
||
)
|
||
if proc.returncode != 0:
|
||
msg = format_batch_failure(
|
||
proc.returncode,
|
||
stdout=proc.stdout or "",
|
||
stderr=proc.stderr or "",
|
||
work_dir=cache_work_dir.resolve(),
|
||
output_path=output_path.resolve(),
|
||
)
|
||
raise RuntimeError(f"reference bundle batch run failed {msg}")
|
||
if not is_reference_result_complete(output_path):
|
||
raise RuntimeError(
|
||
f"reference bundle finished but result.tsv is incomplete: {output_path}"
|
||
)
|
||
logger.info(
|
||
"reference batch complete for surgery_id={} ({})",
|
||
surgery_id,
|
||
output_path,
|
||
)
|
||
|
||
doctor = parse_reference_doctor_info(output_path)
|
||
details = parse_reference_tsv(output_path, doctor=doctor)
|
||
batch_result = VideoBatchRunResult(
|
||
video_sha256=digest,
|
||
candidate_cache_key=candidate_key,
|
||
input_path=surgery_input,
|
||
work_dir=cache_work_dir,
|
||
output_path=output_path,
|
||
details=details,
|
||
reused_cache=reused_cache,
|
||
doctor=doctor,
|
||
visualization_path=None,
|
||
)
|
||
if not include_visualization:
|
||
return batch_result
|
||
vis_path = self.finalize_visualization(batch_result, surgery_id=surgery_id)
|
||
if vis_path is None:
|
||
logger.warning(
|
||
"video batch visualization missing for surgery_id={} after complete result",
|
||
surgery_id,
|
||
)
|
||
return batch_result
|
||
return VideoBatchRunResult(
|
||
video_sha256=batch_result.video_sha256,
|
||
candidate_cache_key=batch_result.candidate_cache_key,
|
||
input_path=batch_result.input_path,
|
||
work_dir=batch_result.work_dir,
|
||
output_path=batch_result.output_path,
|
||
details=batch_result.details,
|
||
reused_cache=batch_result.reused_cache,
|
||
doctor=batch_result.doctor,
|
||
visualization_path=vis_path,
|
||
)
|