update minio port

This commit is contained in:
Kevin
2026-05-22 09:35:41 +08:00
parent 153c91f8ff
commit 62b14d7386
22 changed files with 1256 additions and 1170 deletions

View File

@@ -0,0 +1,41 @@
"""Shared fixtures for minimal reference bundle trees in batch/subprocess tests."""
from __future__ import annotations
from pathlib import Path
import yaml
def complete_result_tsv_body() -> str:
return (
"rank\tstart_sec\tend_sec\tproduct_id_top1\ttop1_name\ttop1_conf\n"
"1\t0\t1\tP1\t耗材1\t1.0\n"
"医生信息:测试医生 (id=123, conf=0.99)\n"
)
def write_minimal_reference_bundle(bundle: Path) -> None:
bundle.mkdir(parents=True)
(bundle / "main.py").write_text("# fake\n", encoding="utf-8")
(bundle / "code").mkdir()
(bundle / "code" / "repo_root.py").write_text("# fake\n", encoding="utf-8")
(bundle / "configs").mkdir()
(bundle / "configs" / "default_config.yaml").write_text(
yaml.safe_dump(
{
"io": {"video": "", "excel": "", "out": "", "whitelist_json": None},
"weights": {},
"runtime": {"work_dir": None, "keep_work_dir": False, "python": None},
"device": {},
"phase1": {},
"phase2": {},
"classification": {},
"tear_merge": {},
"output": {},
},
allow_unicode=True,
sort_keys=False,
),
encoding="utf-8",
)

View File

@@ -1,3 +1,5 @@
"""Tests for offline batch orchestration (app.algo_host)."""
from __future__ import annotations
import json
@@ -12,73 +14,42 @@ import yaml
from fastapi import FastAPI
from fastapi.testclient import TestClient
from app.algorithm_runner import reference_bundle_runtime
from app.algo_host import bundle as bundle_runtime
from app.algo_host.batch_service import BatchAlgorithmService, BatchRunResult
from app.algo_host.job_workspace import build_job_config
from app.algo_host.result_adapter import (
doctor_id_for_consumption_rows,
is_reference_result_complete,
parse_reference_doctor_info,
parse_reference_tsv,
)
from app.algo_host.subprocess_runner import (
build_batch_main_command,
build_visualization_command,
describe_batch_returncode,
format_batch_failure,
)
from app.algo_host.transcode import (
VISUALIZATION_MAX_WIDTH,
batch_input_needs_normalize,
browser_transcode_tmp_path,
ensure_batch_pipeline_input_video,
is_browser_compatible_mp4,
transcode_visualization_for_browser,
)
from app.api import router as api_router
from app.dependencies import get_surgery_pipeline
from app.domain.consumption import SurgeryConsumptionStored
from app.routers import recording_demo
from app.schemas import SurgeryConsumptionDetail
from app.services.video_batch_runner import (
VideoBatchRunResult,
VideoBatchRunner,
_batch_input_needs_normalize,
_is_browser_compatible_mp4,
_transcode_visualization_for_browser,
browser_transcode_tmp_path,
build_reference_command,
VISUALIZATION_MAX_WIDTH,
build_reference_config,
build_reference_visualization_command,
describe_batch_returncode,
doctor_id_for_consumption_rows,
ensure_batch_pipeline_input_video,
ensure_reference_actionformer_nms_patch,
format_batch_failure,
is_reference_result_complete,
parse_reference_doctor_info,
parse_reference_tsv,
)
from app.services.video_batch_cleanup import VISUALIZATION_FILENAME, visualization_output_path
from tests.reference_bundle_fixtures import complete_result_tsv_body, write_minimal_reference_bundle
def _complete_result_tsv_body() -> str:
return (
"rank\tstart_sec\tend_sec\tproduct_id_top1\ttop1_name\ttop1_conf\n"
"1\t0\t1\tP1\t耗材1\t1.0\n"
"医生信息:测试医生 (id=123, conf=0.99)\n"
)
def _write_minimal_reference_bundle(bundle: Path) -> None:
bundle.mkdir(parents=True)
(bundle / "main.py").write_text("# fake\n", encoding="utf-8")
(bundle / "code").mkdir()
(bundle / "code" / "repo_root.py").write_text("# fake\n", encoding="utf-8")
(bundle / "configs").mkdir()
(bundle / "configs" / "default_config.yaml").write_text(
yaml.safe_dump(
{
"io": {"video": "", "excel": "", "out": "", "whitelist_json": None},
"weights": {},
"runtime": {"work_dir": None, "keep_work_dir": False, "python": None},
"device": {},
"phase1": {},
"phase2": {},
"classification": {},
"tear_merge": {},
"output": {},
},
allow_unicode=True,
sort_keys=False,
),
encoding="utf-8",
)
def test_build_reference_config_does_not_keep_work_dir(tmp_path: Path) -> None:
def test_build_job_config_does_not_keep_work_dir(tmp_path: Path) -> None:
bundle = tmp_path / "bundle"
_write_minimal_reference_bundle(bundle)
cfg = build_reference_config(
write_minimal_reference_bundle(bundle)
cfg = build_job_config(
bundle_dir=bundle,
video_path=tmp_path / "input.mp4",
output_path=tmp_path / "out.tsv",
@@ -91,7 +62,7 @@ def test_build_reference_config_does_not_keep_work_dir(tmp_path: Path) -> None:
def test_latest_visualization_path_uses_vis_directory(tmp_path: Path) -> None:
root = tmp_path / "batch"
runner = VideoBatchRunner(root_dir=root)
runner = BatchAlgorithmService(root_dir=root)
assert runner.latest_visualization_path("100001") is None
vis_path = visualization_output_path(root, "100001")
@@ -102,7 +73,7 @@ def test_latest_visualization_path_uses_vis_directory(tmp_path: Path) -> None:
def test_is_reference_result_complete_requires_footer_and_rows(tmp_path: Path) -> None:
complete = tmp_path / "complete.tsv"
complete.write_text(_complete_result_tsv_body(), encoding="utf-8")
complete.write_text(complete_result_tsv_body(), encoding="utf-8")
partial = tmp_path / "partial.tsv"
partial.write_text(
"rank\tstart_sec\tend_sec\tproduct_id_top1\ttop1_name\ttop1_conf\n"
@@ -225,11 +196,11 @@ def test_ensure_batch_pipeline_input_video_normalizes_non_h264(tmp_path: Path) -
text=True,
)
assert proc.returncode == 0, proc.stderr
assert _batch_input_needs_normalize(source)
assert batch_input_needs_normalize(source)
ensure_batch_pipeline_input_video(source_path=source, dest_path=dest)
assert dest.is_file()
assert _is_browser_compatible_mp4(dest)
assert not _batch_input_needs_normalize(dest)
assert is_browser_compatible_mp4(dest)
assert not batch_input_needs_normalize(dest)
@pytest.mark.skipif(shutil.which("ffmpeg") is None, reason="ffmpeg not installed")
@@ -259,28 +230,27 @@ def test_transcode_visualization_for_browser_writes_h264_mp4(tmp_path: Path) ->
text=True,
)
assert proc.returncode == 0, proc.stderr
assert _transcode_visualization_for_browser(source, output)
assert transcode_visualization_for_browser(source, output)
assert output.is_file()
assert output.stat().st_size > 0
assert not browser_transcode_tmp_path(output).exists()
assert _is_browser_compatible_mp4(output)
assert is_browser_compatible_mp4(output)
def test_build_reference_visualization_command_uses_hand_model_and_result_tsv(
def test_build_visualization_command_uses_hand_model_and_result_tsv(
tmp_path: Path,
) -> None:
bundle = tmp_path / "bundle"
_write_minimal_reference_bundle(bundle)
write_minimal_reference_bundle(bundle)
(bundle / "weights").mkdir()
(bundle / "weights" / "hand_detect.pt").write_bytes(b"fake")
(bundle / "visualize_result_video.py").write_text("# fake\n", encoding="utf-8")
cfg_path = bundle / "configs" / "default_config.yaml"
cfg = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
cfg["weights"]["hand"] = "weights/hand_detect.pt"
cfg["phase2"]["det_conf"] = 0.55
cfg_path.write_text(yaml.safe_dump(cfg, allow_unicode=True, sort_keys=False), encoding="utf-8")
cmd = build_reference_visualization_command(
cmd = build_visualization_command(
bundle_dir=bundle,
video_path=tmp_path / "input.mp4",
result_path=tmp_path / "result.tsv",
@@ -291,14 +261,13 @@ def test_build_reference_visualization_command_uses_hand_model_and_result_tsv(
assert str(tmp_path / "result.tsv") in cmd
assert "--hand-model" in cmd
assert str(bundle / "weights" / "hand_detect.pt") in cmd
assert "--det-conf" in cmd
assert cmd[cmd.index("--det-conf") + 1] == "0.55"
assert "--det-conf" not in cmd
assert "--max-width" in cmd
assert cmd[cmd.index("--max-width") + 1] == str(VISUALIZATION_MAX_WIDTH)
def test_build_reference_command_uses_5_15_main_py(tmp_path: Path) -> None:
cmd = build_reference_command(
def test_build_batch_main_command_uses_5_15_main_py(tmp_path: Path) -> None:
cmd = build_batch_main_command(
bundle_dir=tmp_path / "algorithm_subprocesses" / "5.15",
config_path=tmp_path / "config.yaml",
)
@@ -309,12 +278,12 @@ def test_build_reference_command_uses_5_15_main_py(tmp_path: Path) -> None:
assert cmd[6:] == ["--config", str(tmp_path / "config.yaml")]
def test_video_batch_runner_uses_reference_bundle_relative_env_override(
def test_batch_service_respects_reference_bundle_relative_env(
tmp_path: Path,
monkeypatch,
) -> None:
bundle = tmp_path / "algorithm_subprocesses" / "custom"
_write_minimal_reference_bundle(bundle)
write_minimal_reference_bundle(bundle)
video = tmp_path / "case.mp4"
video.write_bytes(b"same-video")
calls: list[list[str]] = []
@@ -329,14 +298,14 @@ def test_video_batch_runner_uses_reference_bundle_relative_env_override(
config = yaml.safe_load(Path(cmd[cmd.index("--config") + 1]).read_text(encoding="utf-8"))
output = Path(config["io"]["out"])
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(_complete_result_tsv_body(), encoding="utf-8")
output.write_text(complete_result_tsv_body(), encoding="utf-8")
return _Proc()
monkeypatch.setenv("REFERENCE_BUNDLE_RELATIVE", "algorithm_subprocesses/custom")
monkeypatch.setattr(reference_bundle_runtime, "REPO_ROOT", tmp_path)
monkeypatch.setattr("app.services.video_batch_runner.subprocess.run", fake_run)
monkeypatch.setattr(bundle_runtime, "REPO_ROOT", tmp_path)
monkeypatch.setattr("app.algo_host.subprocess_runner.subprocess.run", fake_run)
runner = VideoBatchRunner(root_dir=tmp_path / "batch")
runner = BatchAlgorithmService(root_dir=tmp_path / "batch")
result = runner.run(
surgery_id="100001",
uploaded_video_path=video,
@@ -349,7 +318,7 @@ def test_video_batch_runner_uses_reference_bundle_relative_env_override(
assert result.details[0].item_name == "耗材1"
def test_video_batch_runner_reuses_cache_within_same_surgery(
def test_batch_service_reuses_cache_on_repeat_run(
tmp_path: Path,
monkeypatch,
) -> None:
@@ -423,16 +392,16 @@ def test_video_batch_runner_reuses_cache_within_same_surgery(
config = yaml.safe_load(Path(cmd[cmd.index("--config") + 1]).read_text(encoding="utf-8"))
output = Path(config["io"]["out"])
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(_complete_result_tsv_body(), encoding="utf-8")
output.write_text(complete_result_tsv_body(), encoding="utf-8")
return _Proc()
monkeypatch.setattr("app.services.video_batch_runner.subprocess.run", fake_run)
monkeypatch.setattr("app.algo_host.subprocess_runner.subprocess.run", fake_run)
monkeypatch.setattr(
"app.services.video_batch_runner.VideoBatchRunner._generate_visualization",
"app.algo_host.batch_service.BatchAlgorithmService._generate_visualization",
lambda *_a, **_k: None,
)
runner = VideoBatchRunner(bundle_dir=bundle, root_dir=tmp_path / "batch")
runner = BatchAlgorithmService(bundle_dir=bundle, root_dir=tmp_path / "batch")
first = runner.run(
surgery_id="100001",
uploaded_video_path=video,
@@ -459,7 +428,7 @@ def test_video_batch_runner_reuses_cache_within_same_surgery(
assert whitelist == {"allowed_names": ["耗材1"]}
def test_video_batch_runner_does_not_share_cache_across_surgeries(
def test_batch_service_shares_cache_across_surgeries_for_same_video(
tmp_path: Path,
monkeypatch,
) -> None:
@@ -501,40 +470,27 @@ def test_video_batch_runner_does_not_share_cache_across_surgeries(
config = yaml.safe_load(Path(cmd[cmd.index("--config") + 1]).read_text(encoding="utf-8"))
output = Path(config["io"]["out"])
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(_complete_result_tsv_body(), encoding="utf-8")
output.write_text(complete_result_tsv_body(), encoding="utf-8")
return _Proc()
monkeypatch.setattr("app.services.video_batch_runner.subprocess.run", fake_run)
monkeypatch.setattr("app.algo_host.subprocess_runner.subprocess.run", fake_run)
monkeypatch.setattr(
"app.services.video_batch_runner.VideoBatchRunner._generate_visualization",
"app.algo_host.batch_service.BatchAlgorithmService._generate_visualization",
lambda *_a, **_k: None,
)
runner = VideoBatchRunner(bundle_dir=bundle, root_dir=tmp_path / "batch")
runner = BatchAlgorithmService(bundle_dir=bundle, root_dir=tmp_path / "batch")
first = runner.run(surgery_id="100001", uploaded_video_path=video, original_filename="case.mp4", candidate_consumables=[])
second = runner.run(surgery_id="100002", uploaded_video_path=video, original_filename="case.mp4", candidate_consumables=[])
assert len(calls) == 2
assert len(calls) == 1
assert first.reused_cache is False
assert second.reused_cache is False
assert second.reused_cache is True
assert first.video_sha256 == second.video_sha256
assert "100001" in str(first.output_path)
assert "100002" in str(second.output_path)
def test_ensure_reference_actionformer_nms_patch_replaces_compiled_extension_import(
tmp_path: Path,
) -> None:
bundle = tmp_path / "bundle"
_write_minimal_reference_bundle(bundle)
nms_path = bundle / "code" / "actionformer_release" / "libs" / "utils" / "nms.py"
nms_path.parent.mkdir(parents=True)
nms_path.write_text("from . import nms_1d_cpu\n", encoding="utf-8")
assert ensure_reference_actionformer_nms_patch(bundle) is True
patched = nms_path.read_text(encoding="utf-8")
assert "from . import nms_1d_cpu" not in patched
assert "pure-PyTorch" in patched
assert first.output_path == second.output_path
assert "/cache/" in str(first.output_path)
assert "100001" not in str(first.output_path)
assert "100002" not in str(second.output_path)
def test_batch_failure_message_keeps_stdout_stderr_and_decodes_245(tmp_path: Path) -> None:
@@ -575,19 +531,19 @@ def test_demo_video_batch_endpoint_writes_queryable_result(
def __init__(self) -> None:
self.root_dir = root_dir
def run(self, **kwargs: Any) -> VideoBatchRunResult:
def run(self, **kwargs: Any) -> BatchRunResult:
assert kwargs["surgery_id"] == "100001"
assert kwargs["uploaded_video_path"].is_file()
assert kwargs["candidate_consumables"] == ["耗材1"]
assert kwargs.get("include_visualization") is False
cache_dir = root_dir / "cache" / "100001" / ("a" * 64) / "c1"
cache_dir = root_dir / "cache" / ("a" * 64) / "c1"
cache_input = cache_dir / "input" / "input.mp4"
cache_input.parent.mkdir(parents=True)
cache_input.write_bytes(b"pipeline-input")
output_path = cache_dir / "output" / "result.tsv"
output_path.parent.mkdir(parents=True)
output_path.write_text(_complete_result_tsv_body(), encoding="utf-8")
return VideoBatchRunResult(
output_path.write_text(complete_result_tsv_body(), encoding="utf-8")
return BatchRunResult(
video_sha256="a" * 64,
candidate_cache_key="c1",
input_path=root_dir / "100001" / "input" / "saved.mp4",
@@ -629,7 +585,7 @@ def test_demo_video_batch_endpoint_writes_queryable_result(
for r in rows
]
monkeypatch.setattr(recording_demo, "VideoBatchRunner", _FakeRunner)
monkeypatch.setattr(recording_demo, "BatchAlgorithmService", _FakeRunner)
pipeline = _FakePipeline()
app = FastAPI()
@@ -648,7 +604,7 @@ def test_demo_video_batch_endpoint_writes_queryable_result(
assert body["status"] == "accepted"
assert body["visualization_url"] is None
assert vis_calls == []
assert not (root_dir / "cache" / "100001").exists()
assert not (root_dir / "cache" / ("a" * 64)).exists()
assert not (root_dir / "100001").exists()
got = client.get("/client/surgeries/100001/result")
@@ -679,15 +635,15 @@ def test_demo_video_batch_endpoint_stages_vis_and_purges_cache_when_requested(
def __init__(self) -> None:
self.root_dir = root_dir
def run(self, **kwargs: Any) -> VideoBatchRunResult:
cache_dir = root_dir / "cache" / "100001" / ("b" * 64) / "c1"
def run(self, **kwargs: Any) -> BatchRunResult:
cache_dir = root_dir / "cache" / ("b" * 64) / "c1"
cache_input = cache_dir / "input" / "input.mp4"
cache_input.parent.mkdir(parents=True)
cache_input.write_bytes(b"pipeline-input")
output_path = cache_dir / "output" / "result.tsv"
output_path.parent.mkdir(parents=True)
output_path.write_text(_complete_result_tsv_body(), encoding="utf-8")
return VideoBatchRunResult(
output_path.write_text(complete_result_tsv_body(), encoding="utf-8")
return BatchRunResult(
video_sha256="b" * 64,
candidate_cache_key="c1",
input_path=root_dir / "100001" / "input" / "saved.mp4",
@@ -708,7 +664,7 @@ def test_demo_video_batch_endpoint_stages_vis_and_purges_cache_when_requested(
) -> None:
return None
monkeypatch.setattr(recording_demo, "VideoBatchRunner", _FakeRunner)
monkeypatch.setattr(recording_demo, "BatchAlgorithmService", _FakeRunner)
app = FastAPI()
app.include_router(recording_demo.router)
app.dependency_overrides[get_surgery_pipeline] = lambda: _FakePipeline()
@@ -727,7 +683,7 @@ def test_demo_video_batch_endpoint_stages_vis_and_purges_cache_when_requested(
body = res.json()
assert body["visualization_url"] == "/internal/demo/offline-batch/100001/visualization"
assert vis_calls == ["100001"]
assert not (root_dir / "cache" / "100001").exists()
assert not (root_dir / "cache" / ("b" * 64)).exists()
pending_input = root_dir / "vis_pending" / "100001" / "input.mp4"
pending_tsv = root_dir / "vis_pending" / "100001" / "result.tsv"
assert pending_input.read_bytes() == b"pipeline-input"

View File

@@ -1,7 +1,7 @@
"""FastAPI → 算法子进程调用链单元测试。
覆盖两条生产路径:
1. ``POST /internal/demo/offline-batch`` → ``VideoBatchRunner`` → ``subprocess.run``reference bundle ``main.py``
1. ``POST /internal/demo/offline-batch`` → ``BatchAlgorithmService`` → ``subprocess.run``reference bundle ``main.py``
2. ``POST /client/surgeries/start`` → ``CameraSessionManager`` → ``asyncio.create_subprocess_exec````python -m app.algorithm_runner``
"""
@@ -24,8 +24,9 @@ from app.config import Settings
from app.dependencies import build_container, get_surgery_pipeline, get_voice_terminal_hub
from app.routers import recording_demo
from app.services.video.session_manager import CameraSessionManager
from app.services.video_batch_runner import VideoBatchRunner, build_reference_command
from tests.test_video_batch_runner import _complete_result_tsv_body, _write_minimal_reference_bundle
from app.algo_host.batch_service import BatchAlgorithmService
from app.algo_host.subprocess_runner import build_batch_main_command
from tests.reference_bundle_fixtures import complete_result_tsv_body, write_minimal_reference_bundle
def _fake_reference_subprocess_run(captured: list[dict[str, Any]]):
@@ -46,7 +47,7 @@ def _fake_reference_subprocess_run(captured: list[dict[str, Any]]):
)
output = Path(config["io"]["out"])
output.parent.mkdir(parents=True, exist_ok=True)
output.write_text(_complete_result_tsv_body(), encoding="utf-8")
output.write_text(complete_result_tsv_body(), encoding="utf-8")
return _Proc()
return _run
@@ -55,7 +56,7 @@ def _fake_reference_subprocess_run(captured: list[dict[str, Any]]):
@pytest.fixture
def reference_bundle(tmp_path: Path) -> Path:
bundle = tmp_path / "algorithm_subprocesses" / "5.15"
_write_minimal_reference_bundle(bundle)
write_minimal_reference_bundle(bundle)
return bundle
@@ -67,17 +68,13 @@ def test_video_batch_endpoint_invokes_reference_bundle_subprocess(
) -> None:
monkeypatch.setattr(recording_demo.settings, "demo_orchestrator_enabled", True)
monkeypatch.setattr(
"app.services.video_batch_runner.resolve_reference_bundle_dir",
"app.algo_host.bundle.resolve_reference_bundle_dir",
lambda _override=None: reference_bundle.resolve(),
)
monkeypatch.setattr(
"app.services.video_batch_runner.ensure_reference_actionformer_nms_patch",
lambda _bundle: True,
)
monkeypatch.setattr(
recording_demo,
"VideoBatchRunner",
lambda: VideoBatchRunner(
"BatchAlgorithmService",
lambda: BatchAlgorithmService(
bundle_dir=reference_bundle,
root_dir=tmp_path / "video_batch",
),
@@ -85,7 +82,7 @@ def test_video_batch_endpoint_invokes_reference_bundle_subprocess(
captured: list[dict[str, Any]] = []
monkeypatch.setattr(
"app.services.video_batch_runner.subprocess.run",
"app.algo_host.subprocess_runner.subprocess.run",
_fake_reference_subprocess_run(captured),
)
@@ -111,7 +108,7 @@ def test_video_batch_endpoint_invokes_reference_bundle_subprocess(
cmd: list[str] = call["cmd"]
kwargs: dict[str, Any] = call["kwargs"]
expected = build_reference_command(
expected = build_batch_main_command(
bundle_dir=reference_bundle,
config_path=Path(cmd[cmd.index("--config") + 1]),
)

View File

@@ -36,7 +36,7 @@ def test_purge_batch_artifacts_removes_cache_and_uploads(tmp_path: Path) -> None
surgery_id = "100001"
digest = "d" * 64
candidate_key = "c1"
cache_entry = root / "cache" / surgery_id / digest / candidate_key
cache_entry = root / "cache" / digest / candidate_key
(cache_entry / "input").mkdir(parents=True)
(cache_entry / "input" / "input.mp4").write_bytes(b"x" * 100)
(cache_entry / "output").mkdir(parents=True)