update the codebase poc ver1
@@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Stratified sampling of test_images into per-patient scan profiles.
|
||||
|
||||
Each patient receives the same number of frames: (images_per_stratum × 5 strata).
|
||||
Strata = folder names under backend/tests/test_images/:
|
||||
- sup-up-long_positive
|
||||
- sup-up-long_negative
|
||||
- post_trans_positive
|
||||
- post_trans_negative
|
||||
- other_angle
|
||||
|
||||
Sampling is with replacement within each stratum (per patient).
|
||||
|
||||
Outputs:
|
||||
- backend/tests/test_images/profiles/manifest.json
|
||||
- frontend/implementation/public/assets/patient-profiles/{patient_id}/...
|
||||
- frontend/implementation/src/data/patientScanProfiles.generated.ts
|
||||
|
||||
Run from CODEBASE root:
|
||||
|
||||
python backend/tests/sample_patient_profiles.py
|
||||
python backend/tests/sample_patient_profiles.py --per-stratum 2 --seed 42
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import random
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
CODEBASE_ROOT = Path(__file__).resolve().parents[2]
|
||||
TEST_IMAGES_ROOT = CODEBASE_ROOT / "backend/tests/test_images"
|
||||
PROFILES_MANIFEST = TEST_IMAGES_ROOT / "profiles" / "manifest.json"
|
||||
PUBLIC_PROFILES_ROOT = CODEBASE_ROOT / "frontend/implementation/public/assets/patient-profiles"
|
||||
GENERATED_TS = CODEBASE_ROOT / "frontend/implementation/src/data/patientScanProfiles.generated.ts"
|
||||
|
||||
IMAGE_SUFFIXES = {".png", ".jpg", ".jpeg", ".webp", ".bmp"}
|
||||
|
||||
STRATA: list[str] = [
|
||||
"sup-up-long_positive",
|
||||
"sup-up-long_negative",
|
||||
"post_trans_positive",
|
||||
"post_trans_negative",
|
||||
"other_angle",
|
||||
]
|
||||
|
||||
STRATUM_SLUG: dict[str, str] = {
|
||||
"sup-up-long_positive": "sup-long-pos",
|
||||
"sup-up-long_negative": "sup-long-neg",
|
||||
"post_trans_positive": "post-trans-pos",
|
||||
"post_trans_negative": "post-trans-neg",
|
||||
"other_angle": "other",
|
||||
}
|
||||
|
||||
STRATUM_LABEL_VI: dict[str, str] = {
|
||||
"sup-up-long_positive": "Sup dọc — viêm (+)",
|
||||
"sup-up-long_negative": "Sup dọc — không viêm (−)",
|
||||
"post_trans_positive": "Sau ngang — viêm (+)",
|
||||
"post_trans_negative": "Sau ngang — không viêm (−)",
|
||||
"other_angle": "Góc khác (med-lat / sup-trans-flex)",
|
||||
}
|
||||
|
||||
EXPECTED_ANGLE_BY_STRATUM: dict[str, str | None] = {
|
||||
"sup-up-long_positive": "sup-up-long",
|
||||
"sup-up-long_negative": "sup-up-long",
|
||||
"post_trans_positive": "post-trans",
|
||||
"post_trans_negative": "post-trans",
|
||||
"other_angle": None,
|
||||
}
|
||||
|
||||
PATIENTS: list[dict[str, str]] = [
|
||||
{"id": "p-001", "name": "Nguyễn Văn An", "mrn": "BN-2024-1847"},
|
||||
{"id": "p-002", "name": "Trần Thị Bích", "mrn": "BN-2024-1923"},
|
||||
{"id": "p-003", "name": "Lê Hoàng Minh", "mrn": "BN-2024-2011"},
|
||||
{"id": "p-004", "name": "Phạm Thu Hà", "mrn": "BN-2024-2088"},
|
||||
]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PoolImage:
|
||||
path: Path
|
||||
stratum: str
|
||||
|
||||
|
||||
def _list_images(folder: Path) -> list[Path]:
|
||||
if not folder.is_dir():
|
||||
return []
|
||||
files = [
|
||||
p
|
||||
for p in sorted(folder.iterdir())
|
||||
if p.is_file() and p.suffix.lower() in IMAGE_SUFFIXES
|
||||
]
|
||||
return files
|
||||
|
||||
|
||||
def _infer_other_angle_class(filename: str) -> str:
|
||||
lower = filename.lower()
|
||||
if "trans" in lower and "flex" in lower:
|
||||
return "sup-trans-flex"
|
||||
if "med" in lower and "lat" in lower:
|
||||
return "med-lat"
|
||||
return "med-lat"
|
||||
|
||||
|
||||
def _build_pools() -> dict[str, list[PoolImage]]:
|
||||
pools: dict[str, list[PoolImage]] = {}
|
||||
for stratum in STRATA:
|
||||
folder = TEST_IMAGES_ROOT / stratum
|
||||
images = _list_images(folder)
|
||||
if not images:
|
||||
raise FileNotFoundError(f"No images in stratum folder: {folder}")
|
||||
pools[stratum] = [PoolImage(path=p, stratum=stratum) for p in images]
|
||||
return pools
|
||||
|
||||
|
||||
def _sample_profile(
|
||||
patient: dict[str, str],
|
||||
pools: dict[str, list[PoolImage]],
|
||||
*,
|
||||
per_stratum: int,
|
||||
rng: random.Random,
|
||||
) -> list[dict]:
|
||||
frames: list[dict] = []
|
||||
for stratum in STRATA:
|
||||
pool = pools[stratum]
|
||||
slug = STRATUM_SLUG[stratum]
|
||||
for index in range(per_stratum):
|
||||
chosen = rng.choice(pool)
|
||||
source_name = chosen.path.name
|
||||
expected = EXPECTED_ANGLE_BY_STRATUM[stratum]
|
||||
if expected is None:
|
||||
expected = _infer_other_angle_class(source_name)
|
||||
|
||||
frame_id = f"{patient['id']}-{slug}-{index}"
|
||||
ext = chosen.path.suffix.lower()
|
||||
asset_name = f"{slug}-{index}{ext}"
|
||||
rel_asset = f"/assets/patient-profiles/{patient['id']}/{asset_name}"
|
||||
|
||||
frames.append(
|
||||
{
|
||||
"id": frame_id,
|
||||
"patient_id": patient["id"],
|
||||
"stratum": stratum,
|
||||
"stratum_index": index,
|
||||
"label": f"{STRATUM_LABEL_VI[stratum]} · #{index + 1}",
|
||||
"expected_angle_class": expected,
|
||||
"source_path": str(chosen.path.relative_to(CODEBASE_ROOT)),
|
||||
"source_filename": source_name,
|
||||
"asset_path": rel_asset,
|
||||
"asset_filename": asset_name,
|
||||
}
|
||||
)
|
||||
return frames
|
||||
|
||||
|
||||
def _materialize_assets(patient_id: str, frames: list[dict]) -> None:
|
||||
out_dir = PUBLIC_PROFILES_ROOT / patient_id
|
||||
if out_dir.exists():
|
||||
shutil.rmtree(out_dir)
|
||||
out_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
for frame in frames:
|
||||
src = CODEBASE_ROOT / frame["source_path"]
|
||||
dst = out_dir / frame["asset_filename"]
|
||||
shutil.copy2(src, dst)
|
||||
|
||||
|
||||
def _write_manifest(payload: dict) -> None:
|
||||
PROFILES_MANIFEST.parent.mkdir(parents=True, exist_ok=True)
|
||||
PROFILES_MANIFEST.write_text(
|
||||
json.dumps(payload, indent=2, ensure_ascii=False) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
def _ts_string(value: str) -> str:
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
|
||||
|
||||
def _write_generated_ts(payload: dict) -> None:
|
||||
lines = [
|
||||
"/** Auto-generated by backend/tests/sample_patient_profiles.py — do not edit. */",
|
||||
"import type { ScanFrame } from './scanFrames';",
|
||||
"",
|
||||
"export interface PatientScanProfileFrame extends ScanFrame {",
|
||||
" stratum: string;",
|
||||
" expectedAngleClass?: string;",
|
||||
" sourcePath: string;",
|
||||
"}",
|
||||
"",
|
||||
"export const PATIENT_SCAN_PROFILES: Record<string, PatientScanProfileFrame[]> = {",
|
||||
]
|
||||
|
||||
for patient in payload["patients"]:
|
||||
pid = patient["id"]
|
||||
lines.append(f" {_ts_string(pid)}: [")
|
||||
for frame in patient["frames"]:
|
||||
lines.append(
|
||||
" {"
|
||||
f" id: {_ts_string(frame['id'])},"
|
||||
f" src: {_ts_string(frame['asset_path'])},"
|
||||
f" label: {_ts_string(frame['label'])},"
|
||||
f" stratum: {_ts_string(frame['stratum'])},"
|
||||
f" expectedAngleClass: {_ts_string(frame['expected_angle_class'])},"
|
||||
f" sourcePath: {_ts_string(frame['source_path'])},"
|
||||
" },"
|
||||
)
|
||||
lines.append(" ],")
|
||||
|
||||
lines.extend(
|
||||
[
|
||||
"};",
|
||||
"",
|
||||
"export function getScanFramesForPatient(patientId: string): PatientScanProfileFrame[] {",
|
||||
" return PATIENT_SCAN_PROFILES[patientId] ?? PATIENT_SCAN_PROFILES['p-001'] ?? [];",
|
||||
"}",
|
||||
"",
|
||||
f"export const FRAMES_PER_PATIENT = {payload['frames_per_patient']};",
|
||||
f"export const IMAGES_PER_STRATUM = {payload['images_per_stratum']};",
|
||||
"",
|
||||
]
|
||||
)
|
||||
|
||||
GENERATED_TS.write_text("\n".join(lines), encoding="utf-8")
|
||||
|
||||
|
||||
def main() -> None:
|
||||
parser = argparse.ArgumentParser(description="Sample stratified ultrasound frames per patient profile.")
|
||||
parser.add_argument(
|
||||
"--per-stratum",
|
||||
type=int,
|
||||
default=1,
|
||||
help="Images sampled per stratum folder per patient (default: 1 → 5 frames/patient).",
|
||||
)
|
||||
parser.add_argument("--seed", type=int, default=2026, help="RNG seed for reproducible sampling.")
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.per_stratum < 1:
|
||||
raise SystemExit("--per-stratum must be >= 1")
|
||||
|
||||
rng = random.Random(args.seed)
|
||||
pools = _build_pools()
|
||||
|
||||
print("Stratum pool sizes:")
|
||||
for stratum in STRATA:
|
||||
print(f" {stratum}: {len(pools[stratum])} images")
|
||||
|
||||
profiles: list[dict] = []
|
||||
for patient in PATIENTS:
|
||||
frames = _sample_profile(patient, pools, per_stratum=args.per_stratum, rng=rng)
|
||||
_materialize_assets(patient["id"], frames)
|
||||
profiles.append({**patient, "frames": frames})
|
||||
print(f"Patient {patient['id']}: {len(frames)} frames")
|
||||
|
||||
frames_per_patient = args.per_stratum * len(STRATA)
|
||||
payload = {
|
||||
"seed": args.seed,
|
||||
"images_per_stratum": args.per_stratum,
|
||||
"frames_per_patient": frames_per_patient,
|
||||
"strata": STRATA,
|
||||
"patients": profiles,
|
||||
}
|
||||
|
||||
_write_manifest(payload)
|
||||
_write_generated_ts(payload)
|
||||
|
||||
print(f"\nWrote manifest: {PROFILES_MANIFEST.relative_to(CODEBASE_ROOT)}")
|
||||
print(f"Wrote assets: {PUBLIC_PROFILES_ROOT.relative_to(CODEBASE_ROOT)}/")
|
||||
print(f"Wrote TS: {GENERATED_TS.relative_to(CODEBASE_ROOT)}")
|
||||
print(f"Total: {len(PATIENTS)} patients × {frames_per_patient} frames = {len(PATIENTS) * frames_per_patient} images")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
@@ -0,0 +1,71 @@
|
||||
"""
|
||||
Minimal BFF for agent-tool smoke tests (no GCP secrets, no Redis).
|
||||
|
||||
Mounts only:
|
||||
POST /api/v1/embed
|
||||
POST /api/v1/agent/tools/exa/search
|
||||
POST /api/v1/agent/tools/supabase/query
|
||||
|
||||
Run from CODEBASE root:
|
||||
# loads PILOT_PROJECT/secrets/aws_secret/.env if present
|
||||
PYTHONPATH=. python backend/tests/smoke_agent_tools_server.py
|
||||
|
||||
Then:
|
||||
cd ml/tests/agent_tools && npm run smoke:bff
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
import uvicorn
|
||||
from fastapi import FastAPI
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
|
||||
CODEBASE_ROOT = Path(__file__).resolve().parents[2]
|
||||
if str(CODEBASE_ROOT) not in sys.path:
|
||||
sys.path.insert(0, str(CODEBASE_ROOT))
|
||||
|
||||
SECRETS_ENV = CODEBASE_ROOT.parents[2] / "secrets" / "aws_secret" / ".env"
|
||||
|
||||
|
||||
def _load_dotenv_file(path: Path) -> None:
|
||||
if not path.exists():
|
||||
return
|
||||
for line in path.read_text(encoding="utf-8").splitlines():
|
||||
line = line.strip()
|
||||
if not line or line.startswith("#") or "=" not in line:
|
||||
continue
|
||||
key, _, value = line.partition("=")
|
||||
key = key.strip()
|
||||
value = value.strip().strip('"').strip("'")
|
||||
os.environ.setdefault(key, value)
|
||||
|
||||
|
||||
_load_dotenv_file(SECRETS_ENV)
|
||||
os.environ.setdefault("EMBED_QUERY_MOCK", "1")
|
||||
|
||||
from backend.routers import agent_tools # noqa: E402
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
app = FastAPI(title="Agent Tools Smoke BFF", version="0.1.0")
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["*"],
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
app.include_router(agent_tools.router)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
host = os.getenv("SMOKE_BFF_HOST", "127.0.0.1")
|
||||
port = int(os.getenv("SMOKE_BFF_PORT", "8000"))
|
||||
logging.basicConfig(level=logging.INFO)
|
||||
logger.info("Agent tools smoke BFF on http://%s:%s", host, port)
|
||||
logger.info("Secrets env: %s (%s)", SECRETS_ENV, "found" if SECRETS_ENV.exists() else "missing")
|
||||
uvicorn.run(app, host=host, port=port, log_level="info")
|
||||
@@ -0,0 +1,77 @@
|
||||
"""HTTP layer tests for the CV inference FastAPI router."""
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from PIL import Image
|
||||
|
||||
CODEBASE_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CODEBASE_ROOT))
|
||||
|
||||
from backend.cv_inference_server import create_app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client() -> TestClient:
|
||||
return TestClient(create_app())
|
||||
|
||||
|
||||
def _png_bytes() -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.new("RGB", (32, 24), color=(100, 120, 140)).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def test_health_route(client: TestClient):
|
||||
with patch(
|
||||
"backend.routers.cv_inference.TritonAdapter.model_ready",
|
||||
new=AsyncMock(return_value=True),
|
||||
):
|
||||
response = client.get("/api/test/health")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["service"] == "cv-inference"
|
||||
assert body["status"] == "ok"
|
||||
|
||||
|
||||
def test_analyze_batch_route(client: TestClient):
|
||||
mock_result = type(
|
||||
"Batch",
|
||||
(),
|
||||
{
|
||||
"results": [{"success": True, "frame_id": "f1", "angle": {"class": "med-lat"}}],
|
||||
"triton_infer_calls": 1,
|
||||
"triton_infer_modes": ["angle:batched"],
|
||||
},
|
||||
)()
|
||||
|
||||
with patch(
|
||||
"backend.routers.cv_inference.run_batch",
|
||||
new=AsyncMock(return_value=mock_result),
|
||||
):
|
||||
response = client.post(
|
||||
"/api/test/analyze/batch",
|
||||
data={"frame_ids": json.dumps(["f1"])},
|
||||
files=[("images", ("f1.png", _png_bytes(), "image/png"))],
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["success"] is True
|
||||
assert body["image_count"] == 1
|
||||
assert body["results"][0]["frame_id"] == "f1"
|
||||
|
||||
|
||||
def test_legacy_segment_route_returns_410(client: TestClient):
|
||||
response = client.post(
|
||||
"/api/test/segment/batch",
|
||||
data={"frame_ids": json.dumps(["f1"]), "angle_type": "sup"},
|
||||
files=[("images", ("f1.png", _png_bytes(), "image/png"))],
|
||||
)
|
||||
assert response.status_code == 410
|
||||
@@ -0,0 +1,255 @@
|
||||
"""Tests for backend.services.cv_inference_service — structure, gating, cache keys."""
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from unittest.mock import AsyncMock, patch
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
CODEBASE_ROOT = Path(__file__).resolve().parents[2]
|
||||
sys.path.insert(0, str(CODEBASE_ROOT))
|
||||
|
||||
MANIFEST_PATH = Path(__file__).resolve().parent / "test_images" / "profiles" / "manifest.json"
|
||||
|
||||
REQUIRED_RESULT_KEYS = {
|
||||
"success",
|
||||
"angle",
|
||||
"segmentation",
|
||||
"severity",
|
||||
"images",
|
||||
"models_used",
|
||||
}
|
||||
|
||||
|
||||
def _load_manifest_frames() -> list[dict]:
|
||||
if not MANIFEST_PATH.exists():
|
||||
return []
|
||||
data = json.loads(MANIFEST_PATH.read_text(encoding="utf-8"))
|
||||
frames: list[dict] = []
|
||||
for patient in data.get("patients", []):
|
||||
frames.extend(patient.get("frames", []))
|
||||
return frames
|
||||
|
||||
|
||||
def _frame_image_path(frame: dict) -> Path:
|
||||
return CODEBASE_ROOT / frame["source_path"]
|
||||
|
||||
|
||||
def _make_angle_logits(class_index: int, num_classes: int = 4) -> np.ndarray:
|
||||
row = np.full(num_classes, -2.0, dtype=np.float32)
|
||||
row[class_index] = 5.0
|
||||
return row
|
||||
|
||||
|
||||
ANGLE_CLASS_INDEX = {
|
||||
"med-lat": 0,
|
||||
"post-trans": 1,
|
||||
"sup-trans-flex": 2,
|
||||
"sup-up-long": 3,
|
||||
}
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def sample_rgb_image() -> Image.Image:
|
||||
return Image.new("RGB", (128, 96), color=(80, 120, 160))
|
||||
|
||||
|
||||
def test_analyze_batch_cache_key_stable_order():
|
||||
from backend.services.cv_result_cache import analyze_batch_cache_key
|
||||
|
||||
key_a = analyze_batch_cache_key(
|
||||
["frame-b", "frame-a"],
|
||||
["hash-b", "hash-a"],
|
||||
)
|
||||
key_b = analyze_batch_cache_key(
|
||||
["frame-a", "frame-b"],
|
||||
["hash-a", "hash-b"],
|
||||
)
|
||||
assert key_a == key_b
|
||||
assert key_a.startswith("analyze|")
|
||||
|
||||
|
||||
def test_cv_result_cache_coalesces_inflight():
|
||||
from backend.services import cv_result_cache
|
||||
|
||||
calls = 0
|
||||
|
||||
async def slow_compute():
|
||||
nonlocal calls
|
||||
calls += 1
|
||||
await asyncio.sleep(0.05)
|
||||
return {"ok": True}
|
||||
|
||||
async def run():
|
||||
return await asyncio.gather(
|
||||
cv_result_cache.with_result_cache("test-key-coalesce", slow_compute),
|
||||
cv_result_cache.with_result_cache("test-key-coalesce", slow_compute),
|
||||
)
|
||||
|
||||
results = asyncio.run(run())
|
||||
assert results == [{"ok": True}, {"ok": True}]
|
||||
assert calls == 1
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"angle_class,inflammation_detected,expect_seg_performed",
|
||||
[
|
||||
("med-lat", False, False),
|
||||
("sup-trans-flex", False, False),
|
||||
("post-trans", False, False),
|
||||
("post-trans", True, True),
|
||||
("sup-up-long", False, False),
|
||||
("sup-up-long", True, True),
|
||||
],
|
||||
)
|
||||
def test_run_single_gating_logic(
|
||||
sample_rgb_image: Image.Image,
|
||||
angle_class: str,
|
||||
inflammation_detected: bool,
|
||||
expect_seg_performed: bool,
|
||||
):
|
||||
from backend.services.cv_inference_service import CvInferenceOptions, run_single
|
||||
|
||||
angle_idx = ANGLE_CLASS_INDEX[angle_class]
|
||||
inflam_logits = _make_angle_logits(1 if inflammation_detected else 0, num_classes=2)
|
||||
seg_logits = np.zeros((1, 7, 64, 64), dtype=np.float32)
|
||||
|
||||
async def mock_angle_single(image, model_name):
|
||||
return _make_angle_logits(angle_idx), "angle:batched", 1
|
||||
|
||||
async def mock_inflam_single(image, model_name):
|
||||
return inflam_logits, "inflam:batched", 1
|
||||
|
||||
async def mock_seg_single(image, model_name):
|
||||
return seg_logits[0], "seg:batched", 1
|
||||
|
||||
with (
|
||||
patch(
|
||||
"backend.services.cv_inference_service.triton_runtime.infer_angle_logits_single",
|
||||
new=AsyncMock(side_effect=mock_angle_single),
|
||||
),
|
||||
patch(
|
||||
"backend.services.cv_inference_service.triton_runtime.infer_inflammation_logits_single",
|
||||
new=AsyncMock(side_effect=mock_inflam_single),
|
||||
),
|
||||
patch(
|
||||
"backend.services.cv_inference_service.triton_runtime.infer_segmentation_logits_single",
|
||||
new=AsyncMock(side_effect=mock_seg_single),
|
||||
),
|
||||
):
|
||||
result = asyncio.run(
|
||||
run_single(
|
||||
sample_rgb_image,
|
||||
frame_id="test-frame",
|
||||
options=CvInferenceOptions(use_cache=False),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert REQUIRED_RESULT_KEYS.issubset(result.keys())
|
||||
assert result["angle"]["class"] == angle_class
|
||||
assert result["segmentation"]["performed"] is expect_seg_performed
|
||||
if expect_seg_performed:
|
||||
assert "segmented" in result["images"]
|
||||
assert "inflammation" in result["models_used"]
|
||||
assert "segmentation" in result["models_used"]
|
||||
elif angle_class in {"post-trans", "sup-up-long"}:
|
||||
assert result["inflammation"]["detected"] is inflammation_detected
|
||||
assert result["severity"]["level"] == 0
|
||||
|
||||
|
||||
def test_run_batch_result_shape(sample_rgb_image: Image.Image):
|
||||
from backend.services.cv_inference_service import CvInferenceOptions, run_batch
|
||||
|
||||
async def mock_angle_single(image, model_name):
|
||||
return _make_angle_logits(ANGLE_CLASS_INDEX["med-lat"]), "angle:batched", 1
|
||||
|
||||
with patch(
|
||||
"backend.services.cv_inference_service.triton_runtime.infer_angle_logits_single",
|
||||
new=AsyncMock(side_effect=mock_angle_single),
|
||||
):
|
||||
batch = asyncio.run(
|
||||
run_batch(
|
||||
[sample_rgb_image, sample_rgb_image],
|
||||
["f1", "f2"],
|
||||
options=CvInferenceOptions(use_cache=False),
|
||||
)
|
||||
)
|
||||
|
||||
assert len(batch.results) == 2
|
||||
assert batch.triton_infer_calls == 2
|
||||
assert len(batch.triton_infer_modes) == 2
|
||||
for item in batch.results:
|
||||
assert REQUIRED_RESULT_KEYS.issubset(item.keys())
|
||||
assert item["success"] is True
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv("RUN_CV_INTEGRATION"),
|
||||
reason="Set RUN_CV_INTEGRATION=1 to call live Modal Triton",
|
||||
)
|
||||
def test_run_single_live_other_angle_frame():
|
||||
frames = _load_manifest_frames()
|
||||
other_frames = [f for f in frames if f.get("stratum") == "other_angle"]
|
||||
if not other_frames:
|
||||
pytest.skip("No other_angle frames in manifest")
|
||||
|
||||
frame = other_frames[0]
|
||||
image_path = _frame_image_path(frame)
|
||||
if not image_path.exists():
|
||||
pytest.skip(f"Test image missing: {image_path}")
|
||||
|
||||
from backend.services.cv_inference_service import CvInferenceOptions, run_single
|
||||
|
||||
image = Image.open(image_path).convert("RGB")
|
||||
result = asyncio.run(
|
||||
run_single(
|
||||
image,
|
||||
frame_id=frame["id"],
|
||||
options=CvInferenceOptions(use_cache=False),
|
||||
)
|
||||
)
|
||||
|
||||
assert result["success"] is True
|
||||
assert REQUIRED_RESULT_KEYS.issubset(result.keys())
|
||||
assert result["segmentation"]["performed"] is False
|
||||
assert result["severity"]["level"] == 0
|
||||
assert "enhanced" in result["images"]
|
||||
|
||||
|
||||
@pytest.mark.skipif(
|
||||
not os.getenv("RUN_CV_INTEGRATION"),
|
||||
reason="Set RUN_CV_INTEGRATION=1 to call live Modal Triton",
|
||||
)
|
||||
def test_run_batch_live_from_manifest():
|
||||
frames = _load_manifest_frames()
|
||||
if not frames:
|
||||
pytest.skip("Manifest not found or empty")
|
||||
|
||||
selected = frames[:2]
|
||||
images: list[Image.Image] = []
|
||||
frame_ids: list[str] = []
|
||||
for frame in selected:
|
||||
path = _frame_image_path(frame)
|
||||
if not path.exists():
|
||||
pytest.skip(f"Test image missing: {path}")
|
||||
images.append(Image.open(path).convert("RGB"))
|
||||
frame_ids.append(frame["id"])
|
||||
|
||||
from backend.services.cv_inference_service import CvInferenceOptions, run_batch
|
||||
|
||||
batch = asyncio.run(
|
||||
run_batch(images, frame_ids, options=CvInferenceOptions(use_cache=False))
|
||||
)
|
||||
|
||||
assert len(batch.results) == len(images)
|
||||
assert batch.triton_infer_calls >= len(images)
|
||||
for item in batch.results:
|
||||
assert item["success"] is True
|
||||
assert REQUIRED_RESULT_KEYS.issubset(item.keys())
|
||||
@@ -0,0 +1,21 @@
|
||||
"""
|
||||
Backward-compatible launcher for the CV inference FastAPI service.
|
||||
|
||||
Prefer:
|
||||
|
||||
PYTHONPATH=. python -m backend.cv_inference_server
|
||||
|
||||
This module remains so existing docs/scripts that invoke
|
||||
`backend/tests/test_fast_api_proxy.py` keep working.
|
||||
"""
|
||||
import os
|
||||
|
||||
os.environ.setdefault(
|
||||
"TRITON_ENDPOINT",
|
||||
"https://dtj-tran--triton-s3-service-unified-triton-server.modal.run",
|
||||
)
|
||||
|
||||
from backend.cv_inference_server import main
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
|
After Width: | Height: | Size: 85 KiB |
|
After Width: | Height: | Size: 109 KiB |
|
After Width: | Height: | Size: 98 KiB |
|
After Width: | Height: | Size: 122 KiB |
|
After Width: | Height: | Size: 95 KiB |
|
After Width: | Height: | Size: 138 KiB |
|
After Width: | Height: | Size: 111 KiB |
@@ -0,0 +1,282 @@
|
||||
{
|
||||
"seed": 2026,
|
||||
"images_per_stratum": 1,
|
||||
"frames_per_patient": 5,
|
||||
"strata": [
|
||||
"sup-up-long_positive",
|
||||
"sup-up-long_negative",
|
||||
"post_trans_positive",
|
||||
"post_trans_negative",
|
||||
"other_angle"
|
||||
],
|
||||
"patients": [
|
||||
{
|
||||
"id": "p-001",
|
||||
"name": "Nguyễn Văn An",
|
||||
"mrn": "BN-2024-1847",
|
||||
"frames": [
|
||||
{
|
||||
"id": "p-001-sup-long-pos-0",
|
||||
"patient_id": "p-001",
|
||||
"stratum": "sup-up-long_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — viêm (+) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_positive/58e7a7ef-de3e-11ee-97e2-0a580a5f5b60_11.png",
|
||||
"source_filename": "58e7a7ef-de3e-11ee-97e2-0a580a5f5b60_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-001/sup-long-pos-0.png",
|
||||
"asset_filename": "sup-long-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-001-sup-long-neg-0",
|
||||
"patient_id": "p-001",
|
||||
"stratum": "sup-up-long_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — không viêm (−) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_negative/58e7a90f-de3e-11ee-97e2-0a580a5f5b60_11.png",
|
||||
"source_filename": "58e7a90f-de3e-11ee-97e2-0a580a5f5b60_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-001/sup-long-neg-0.png",
|
||||
"asset_filename": "sup-long-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-001-post-trans-pos-0",
|
||||
"patient_id": "p-001",
|
||||
"stratum": "post_trans_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — viêm (+) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_positive/72bb41ed-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb41ed-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-001/post-trans-pos-0.png",
|
||||
"asset_filename": "post-trans-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-001-post-trans-neg-0",
|
||||
"patient_id": "p-001",
|
||||
"stratum": "post_trans_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — không viêm (−) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-001/post-trans-neg-0.png",
|
||||
"asset_filename": "post-trans-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-001-other-0",
|
||||
"patient_id": "p-001",
|
||||
"stratum": "other_angle",
|
||||
"stratum_index": 0,
|
||||
"label": "Góc khác (med-lat / sup-trans-flex) · #1",
|
||||
"expected_angle_class": "sup-trans-flex",
|
||||
"source_path": "backend/tests/test_images/other_angle/trans_flex.png",
|
||||
"source_filename": "trans_flex.png",
|
||||
"asset_path": "/assets/patient-profiles/p-001/other-0.png",
|
||||
"asset_filename": "other-0.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p-002",
|
||||
"name": "Trần Thị Bích",
|
||||
"mrn": "BN-2024-1923",
|
||||
"frames": [
|
||||
{
|
||||
"id": "p-002-sup-long-pos-0",
|
||||
"patient_id": "p-002",
|
||||
"stratum": "sup-up-long_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — viêm (+) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_positive/72bb1ac0-f020-11ed-b527-0a580a5f736a_11.png",
|
||||
"source_filename": "72bb1ac0-f020-11ed-b527-0a580a5f736a_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-002/sup-long-pos-0.png",
|
||||
"asset_filename": "sup-long-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-002-sup-long-neg-0",
|
||||
"patient_id": "p-002",
|
||||
"stratum": "sup-up-long_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — không viêm (−) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_negative/72bb0a8a-f020-11ed-b527-0a580a5f736a_11.png",
|
||||
"source_filename": "72bb0a8a-f020-11ed-b527-0a580a5f736a_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-002/sup-long-neg-0.png",
|
||||
"asset_filename": "sup-long-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-002-post-trans-pos-0",
|
||||
"patient_id": "p-002",
|
||||
"stratum": "post_trans_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — viêm (+) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_positive/72bb4c47-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb4c47-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-002/post-trans-pos-0.png",
|
||||
"asset_filename": "post-trans-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-002-post-trans-neg-0",
|
||||
"patient_id": "p-002",
|
||||
"stratum": "post_trans_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — không viêm (−) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_negative/72bb322d-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb322d-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-002/post-trans-neg-0.png",
|
||||
"asset_filename": "post-trans-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-002-other-0",
|
||||
"patient_id": "p-002",
|
||||
"stratum": "other_angle",
|
||||
"stratum_index": 0,
|
||||
"label": "Góc khác (med-lat / sup-trans-flex) · #1",
|
||||
"expected_angle_class": "sup-trans-flex",
|
||||
"source_path": "backend/tests/test_images/other_angle/trans_flex.png",
|
||||
"source_filename": "trans_flex.png",
|
||||
"asset_path": "/assets/patient-profiles/p-002/other-0.png",
|
||||
"asset_filename": "other-0.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p-003",
|
||||
"name": "Lê Hoàng Minh",
|
||||
"mrn": "BN-2024-2011",
|
||||
"frames": [
|
||||
{
|
||||
"id": "p-003-sup-long-pos-0",
|
||||
"patient_id": "p-003",
|
||||
"stratum": "sup-up-long_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — viêm (+) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_positive/72bb1aaf-f020-11ed-b527-0a580a5f736a_11.png",
|
||||
"source_filename": "72bb1aaf-f020-11ed-b527-0a580a5f736a_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-003/sup-long-pos-0.png",
|
||||
"asset_filename": "sup-long-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-003-sup-long-neg-0",
|
||||
"patient_id": "p-003",
|
||||
"stratum": "sup-up-long_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — không viêm (−) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_negative/53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png",
|
||||
"source_filename": "53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-003/sup-long-neg-0.png",
|
||||
"asset_filename": "sup-long-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-003-post-trans-pos-0",
|
||||
"patient_id": "p-003",
|
||||
"stratum": "post_trans_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — viêm (+) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_positive/72bb41ed-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb41ed-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-003/post-trans-pos-0.png",
|
||||
"asset_filename": "post-trans-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-003-post-trans-neg-0",
|
||||
"patient_id": "p-003",
|
||||
"stratum": "post_trans_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — không viêm (−) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-003/post-trans-neg-0.png",
|
||||
"asset_filename": "post-trans-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-003-other-0",
|
||||
"patient_id": "p-003",
|
||||
"stratum": "other_angle",
|
||||
"stratum_index": 0,
|
||||
"label": "Góc khác (med-lat / sup-trans-flex) · #1",
|
||||
"expected_angle_class": "med-lat",
|
||||
"source_path": "backend/tests/test_images/other_angle/med-lat_1.png",
|
||||
"source_filename": "med-lat_1.png",
|
||||
"asset_path": "/assets/patient-profiles/p-003/other-0.png",
|
||||
"asset_filename": "other-0.png"
|
||||
}
|
||||
]
|
||||
},
|
||||
{
|
||||
"id": "p-004",
|
||||
"name": "Phạm Thu Hà",
|
||||
"mrn": "BN-2024-2088",
|
||||
"frames": [
|
||||
{
|
||||
"id": "p-004-sup-long-pos-0",
|
||||
"patient_id": "p-004",
|
||||
"stratum": "sup-up-long_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — viêm (+) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_positive/72bb0b3a-f020-11ed-b527-0a580a5f736a_21.png",
|
||||
"source_filename": "72bb0b3a-f020-11ed-b527-0a580a5f736a_21.png",
|
||||
"asset_path": "/assets/patient-profiles/p-004/sup-long-pos-0.png",
|
||||
"asset_filename": "sup-long-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-004-sup-long-neg-0",
|
||||
"patient_id": "p-004",
|
||||
"stratum": "sup-up-long_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sup dọc — không viêm (−) · #1",
|
||||
"expected_angle_class": "sup-up-long",
|
||||
"source_path": "backend/tests/test_images/sup-up-long_negative/53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png",
|
||||
"source_filename": "53a31572-4380-11ee-9e9a-0a580a5f5f0e_11.png",
|
||||
"asset_path": "/assets/patient-profiles/p-004/sup-long-neg-0.png",
|
||||
"asset_filename": "sup-long-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-004-post-trans-pos-0",
|
||||
"patient_id": "p-004",
|
||||
"stratum": "post_trans_positive",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — viêm (+) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_positive/72bb4c47-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb4c47-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-004/post-trans-pos-0.png",
|
||||
"asset_filename": "post-trans-pos-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-004-post-trans-neg-0",
|
||||
"patient_id": "p-004",
|
||||
"stratum": "post_trans_negative",
|
||||
"stratum_index": 0,
|
||||
"label": "Sau ngang — không viêm (−) · #1",
|
||||
"expected_angle_class": "post-trans",
|
||||
"source_path": "backend/tests/test_images/post_trans_negative/72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"source_filename": "72bb1476-f020-11ed-b527-0a580a5f736a_17.png",
|
||||
"asset_path": "/assets/patient-profiles/p-004/post-trans-neg-0.png",
|
||||
"asset_filename": "post-trans-neg-0.png"
|
||||
},
|
||||
{
|
||||
"id": "p-004-other-0",
|
||||
"patient_id": "p-004",
|
||||
"stratum": "other_angle",
|
||||
"stratum_index": 0,
|
||||
"label": "Góc khác (med-lat / sup-trans-flex) · #1",
|
||||
"expected_angle_class": "sup-trans-flex",
|
||||
"source_path": "backend/tests/test_images/other_angle/trans_flex.png",
|
||||
"source_filename": "trans_flex.png",
|
||||
"asset_path": "/assets/patient-profiles/p-004/other-0.png",
|
||||
"asset_filename": "other-0.png"
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 130 KiB |
|
After Width: | Height: | Size: 137 KiB |
|
After Width: | Height: | Size: 128 KiB |
|
After Width: | Height: | Size: 121 KiB |
|
After Width: | Height: | Size: 127 KiB |
|
After Width: | Height: | Size: 114 KiB |
|
After Width: | Height: | Size: 113 KiB |
|
After Width: | Height: | Size: 112 KiB |
|
After Width: | Height: | Size: 261 KiB |
@@ -0,0 +1,297 @@
|
||||
import os
|
||||
import sys
|
||||
import pytest
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Add the project root to sys.path
|
||||
PROJECT_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
sys.path.insert(0, PROJECT_ROOT)
|
||||
|
||||
# Test config module
|
||||
def test_config():
|
||||
from backend.implementation.config import get_model_name, get_angle_type, get_segmentation_model
|
||||
|
||||
# Test default model names
|
||||
assert get_model_name("angle", None) == "angle_classify_convnext_tiny"
|
||||
assert get_model_name("inflammation", None) == "inflammation_model_efficientnet_b0_ultrasound_2_cls"
|
||||
assert get_model_name("segmentation_sup", None) == "segmentation_model_unet_resnet101"
|
||||
assert get_model_name("segmentation_post", None) == "segmentation_model_post_deeplabv3_resnet101"
|
||||
|
||||
# Test model_versions override
|
||||
custom = {"angle": "custom_angle_model"}
|
||||
assert get_model_name("angle", custom) == "custom_angle_model"
|
||||
assert get_model_name("inflammation", None) == "inflammation_model_efficientnet_b0_ultrasound_2_cls" # unchanged
|
||||
|
||||
# Test angle type
|
||||
assert get_angle_type("med-lat") == "other"
|
||||
assert get_angle_type("post-trans") == "post"
|
||||
assert get_angle_type("sup-trans-flex") == "sup"
|
||||
assert get_angle_type("sup-up-long") == "sup"
|
||||
|
||||
# Test segmentation model selection for angles that actually get segmentation
|
||||
# Only post-trans and sup-up-long trigger inflammation->segmentation
|
||||
assert get_segmentation_model("post-trans", None) == "segmentation_model_post_deeplabv3_resnet101" # post
|
||||
assert get_segmentation_model("sup-up-long", None) == "segmentation_model_unet_resnet101" # sup
|
||||
assert get_segmentation_model("sup-trans-flex", None) == "segmentation_model_unet_resnet101" # sup
|
||||
|
||||
# For other angles, the function still works but result isn't used in practice
|
||||
assert get_segmentation_model("med-lat", None) == "segmentation_model_post_deeplabv3_resnet101" # defaults to post
|
||||
|
||||
# Test transforms module
|
||||
def test_transforms():
|
||||
from backend.implementation.preprocessing.transforms import Resize, Normalize
|
||||
import numpy as np
|
||||
from PIL import Image
|
||||
|
||||
# Create test image
|
||||
img = Image.new('RGB', (100, 50), color='red')
|
||||
|
||||
# Test resize
|
||||
resizer = Resize((50, 25))
|
||||
resized = resizer(img)
|
||||
assert resized.size == (50, 25)
|
||||
|
||||
# Test normalize
|
||||
normalizer = Normalize(mean=[0.5, 0.5, 0.5], std=[0.2, 0.2, 0.2])
|
||||
arr = np.array(img).astype(np.float32) / 255.0
|
||||
normalized = normalizer(img)
|
||||
expected = (arr - 0.5) / 0.2
|
||||
np.testing.assert_allclose(normalized, expected)
|
||||
|
||||
# Test tensor prep
|
||||
def test_tensor_prep():
|
||||
from backend.implementation.preprocessing.tensor_prep import (
|
||||
prepare_angle_tensor, prepare_inflammation_tensor, prepare_segmentation_tensor
|
||||
)
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
# Create test image
|
||||
img = Image.new('RGB', (64, 64), color=(100, 150, 200))
|
||||
|
||||
# Test angle tensor
|
||||
angle_tensor = prepare_angle_tensor(img)
|
||||
assert angle_tensor.shape == (1, 3, 224, 224)
|
||||
assert angle_tensor.dtype == np.float32
|
||||
|
||||
# Test inflammation tensor
|
||||
inflam_tensor = prepare_inflammation_tensor(img)
|
||||
assert inflam_tensor.shape == (1, 3, 224, 224)
|
||||
assert inflam_tensor.dtype == np.float32
|
||||
|
||||
# Test segmentation tensor (0–1 normalized — matches infra preprocess_512 / Triton training)
|
||||
seg_tensor = prepare_segmentation_tensor(img)
|
||||
assert seg_tensor.shape == (1, 3, 512, 512)
|
||||
assert seg_tensor.dtype == np.float32
|
||||
assert seg_tensor.max() <= 1.0
|
||||
assert seg_tensor.min() >= 0.0
|
||||
|
||||
# Test measurement module
|
||||
def test_measurement():
|
||||
from backend.implementation.postprocessing.measurement import (
|
||||
calculate_thickness, get_mask_bounding_box, find_max_continuous_segment
|
||||
)
|
||||
import numpy as np
|
||||
|
||||
# Test find_max_continuous_segment
|
||||
arr = np.array([0, 0, 1, 1, 1, 0, 1, 1, 0, 0])
|
||||
length, start, end = find_max_continuous_segment(arr)
|
||||
assert length == 3
|
||||
assert start == 2
|
||||
assert end == 5 # end is exclusive (like Python slicing)
|
||||
|
||||
# Test get_mask_bounding_box with simple square
|
||||
mask = np.zeros((14, 14), dtype=np.uint8)
|
||||
# 10x10 square (leaving 2-pixel border) to ensure it survives morphology operations with area >= 50
|
||||
mask[2:12, 2:12] = 1 # 10x10 square at (2,2) to (11,11)
|
||||
bbox = get_mask_bounding_box(mask)
|
||||
assert bbox is not None
|
||||
# Should be (2, 2, 10, 10) - x, y, width, height
|
||||
x, y, w, h = bbox
|
||||
assert x == 2 and y == 2 and w == 10 and h == 10
|
||||
|
||||
# Test calculate_thickness with horizontal bar
|
||||
masks = {
|
||||
'fat': np.zeros((14, 14), dtype=np.uint8),
|
||||
'tendon': np.zeros((14, 14), dtype=np.uint8)
|
||||
}
|
||||
# Make a 6-pixel wide horizontal bar at row 6-11 in FAT (class 1)
|
||||
masks['fat'][6:12, :] = 1
|
||||
thickness = calculate_thickness(masks, (14, 14), measure_ids=[1]) # fat is class 1 in POST
|
||||
assert thickness is not None
|
||||
# Should detect approximately 6 pixels width (allowing for some variation)
|
||||
assert thickness['thickness_px'] >= 4
|
||||
# Note: thickness_mm calculation uses the pixel count directly
|
||||
assert thickness['thickness_mm'] == round(6 * 45.0 / 655.0, 2)
|
||||
|
||||
# Test severity module
|
||||
def test_severity():
|
||||
from backend.implementation.postprocessing.severity import calculate_severity
|
||||
import numpy as np
|
||||
|
||||
# Test empty masks
|
||||
result = calculate_severity({}, (100, 100))
|
||||
assert result is None
|
||||
|
||||
# Test low severity
|
||||
masks = {
|
||||
'effusion': np.zeros((100, 100), dtype=np.uint8),
|
||||
'synovium': np.zeros((100, 100), dtype=np.uint8)
|
||||
}
|
||||
# Very small effusion: 5 pixels in a column (thickness=5)
|
||||
masks['effusion'][40:45, 50] = 1
|
||||
# Very small synovium: 5x5 square = 25 pixels
|
||||
masks['synovium'][40:45, 40:45] = 1
|
||||
result = calculate_severity(masks, (100, 100))
|
||||
# Debug: print values
|
||||
# print(f"effusion_pixels: {np.sum(masks['effusion'])}")
|
||||
# print(f"synovium_pixels: {np.sum(masks['synovium'])}")
|
||||
assert result is not None
|
||||
# With minimal effusion and synovium, should be low severity (level 1)
|
||||
assert result['level'] == 1 # Should be mild
|
||||
|
||||
# Test high severity
|
||||
masks['effusion'][:, :] = 1 # Full effusion
|
||||
masks['synovium'][:, :] = 1 # Full synovium
|
||||
result = calculate_severity(masks, (100, 100))
|
||||
assert result['level'] == 3 # Severe
|
||||
assert result['severity'] == "Nặng"
|
||||
|
||||
# Test overlay module
|
||||
def test_overlay():
|
||||
from backend.implementation.postprocessing.overlay import create_overlay
|
||||
from PIL import Image, ImageDraw
|
||||
import numpy as np
|
||||
|
||||
# Create test image
|
||||
img = Image.new('RGB', (100, 100), color='white')
|
||||
draw = ImageDraw.Draw(img)
|
||||
draw.rectangle([20, 20, 80, 80], fill='gray') # Add a gray square
|
||||
|
||||
# Create simple masks
|
||||
masks = {
|
||||
'background': np.zeros((100, 100), dtype=np.uint8),
|
||||
'effusion': np.zeros((100, 100), dtype=np.uint8),
|
||||
'fat': np.zeros((100, 100), dtype=np.uint8)
|
||||
}
|
||||
# Make a red blob in the center
|
||||
masks['effusion'][40:60, 40:60] = 1
|
||||
|
||||
# Test without measurement
|
||||
overlay = create_overlay(img, masks, None, angle_type='sup')
|
||||
assert overlay.size == img.size
|
||||
assert overlay.mode == 'RGB'
|
||||
|
||||
# Test with measurement
|
||||
measurement = {
|
||||
'x': 50,
|
||||
'y_start': 30,
|
||||
'y_end': 70,
|
||||
'thickness_mm': 2.5,
|
||||
'roi_start': 20,
|
||||
'roi_end': 80,
|
||||
'bbox': {'x': 10, 'y': 10, 'w': 80, 'h': 80}
|
||||
}
|
||||
overlay_with_meas = create_overlay(img, masks, measurement, angle_type='sup')
|
||||
assert overlay_with_meas.size == img.size
|
||||
|
||||
# Test CLAHE module (requires cv2)
|
||||
def test_clahe():
|
||||
pytest.importorskip("cv2")
|
||||
from backend.implementation.preprocessing.clahe import apply_clahe
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
|
||||
# Create test image with low contrast
|
||||
img = Image.new('RGB', (50, 50), color=(128, 128, 128))
|
||||
# Add some variation
|
||||
pixels = []
|
||||
for y in range(50):
|
||||
for x in range(50):
|
||||
v = 128 + int(20 * np.sin(x/10.0) * np.cos(y/10.0))
|
||||
pixels.append((v, v, v))
|
||||
img.putdata(pixels)
|
||||
|
||||
# Apply CLAHE
|
||||
enhanced = apply_clahe(img)
|
||||
assert enhanced.size == img.size
|
||||
assert enhanced.mode == 'RGB'
|
||||
# Enhanced image should have different pixel values (not identical)
|
||||
orig_arr = np.array(img)
|
||||
enh_arr = np.array(enhanced)
|
||||
# Not exactly equal due to CLAHE processing
|
||||
assert not np.array_equal(orig_arr, enh_arr)
|
||||
|
||||
|
||||
# Test calibration module
|
||||
def test_calibration():
|
||||
from backend.implementation.postprocessing.calibration import (
|
||||
CalibrationConfig,
|
||||
interpret_angle_logits,
|
||||
interpret_inflammation_logits,
|
||||
normalized_entropy,
|
||||
temperature_scaled_softmax,
|
||||
)
|
||||
import numpy as np
|
||||
|
||||
logits = np.array([3.0, 1.0, 0.5, 0.2], dtype=np.float32)
|
||||
result = interpret_angle_logits(logits)
|
||||
assert result["class"] == "med-lat"
|
||||
assert "calibration" in result
|
||||
cal = result["calibration"]
|
||||
assert len(cal["raw_logits"]) == 4
|
||||
assert len(cal["class_probabilities"]) == 4
|
||||
assert cal["class_probabilities"]["med-lat"] > cal["class_probabilities"]["post-trans"]
|
||||
assert 0 <= cal["normalized_entropy"] <= 1
|
||||
assert cal["decision_state"] in ("confident", "ambiguous", "ood_warning")
|
||||
|
||||
flat = interpret_angle_logits(np.array([0.1, 0.1, 0.1, 0.1]))
|
||||
assert flat["calibration"]["normalized_entropy"] > 0.9
|
||||
assert flat["calibration"]["decision_state"] == "ood_warning"
|
||||
|
||||
screening = interpret_angle_logits(
|
||||
logits,
|
||||
CalibrationConfig(temperature=2.2),
|
||||
)
|
||||
aggressive = interpret_angle_logits(
|
||||
logits,
|
||||
CalibrationConfig(temperature=0.7),
|
||||
)
|
||||
aggressive_probs = aggressive["calibration"]["class_probabilities"]
|
||||
screening_probs = screening["calibration"]["class_probabilities"]
|
||||
assert aggressive_probs["med-lat"] > screening_probs["med-lat"]
|
||||
|
||||
inflam = interpret_inflammation_logits(np.array([-1.0, 2.0]))
|
||||
assert inflam["detected"] is True
|
||||
assert inflam["calibration"]["class_probabilities"]["inflammation"] > 50
|
||||
|
||||
probs = temperature_scaled_softmax(logits, 1.0)
|
||||
assert abs(float(np.sum(probs)) - 1.0) < 1e-5
|
||||
assert normalized_entropy(probs) < normalized_entropy(np.full(4, 0.25))
|
||||
|
||||
|
||||
def test_triton_batch_chunking():
|
||||
from backend.implementation.triton_batch import (
|
||||
TRITON_MAX_BATCH_SIZE,
|
||||
batch_count,
|
||||
chunk_sequence,
|
||||
)
|
||||
|
||||
assert TRITON_MAX_BATCH_SIZE == 8
|
||||
assert batch_count(0) == 0
|
||||
assert batch_count(4) == 1
|
||||
assert batch_count(8) == 1
|
||||
assert batch_count(10) == 2
|
||||
assert batch_count(11) == 2
|
||||
assert batch_count(16) == 2
|
||||
assert batch_count(17) == 3
|
||||
|
||||
chunks = list(chunk_sequence(list(range(10))))
|
||||
assert len(chunks) == 2
|
||||
assert chunks[0] == list(range(8))
|
||||
assert chunks[1] == [8, 9]
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
pytest.main([__file__, "-v"])
|
||||
@@ -0,0 +1,87 @@
|
||||
import asyncio
|
||||
from backend.implementation.adapters.triton_adapter import TritonAdapter
|
||||
from infra.tests.test_1_model import preprocess_224, preprocess_512, load_image
|
||||
from pathlib import Path
|
||||
|
||||
inference_server = "https://dtj-tran--triton-s3-service-unified-triton-server.modal.run"
|
||||
endpoint_url = f"{inference_server}"
|
||||
adapter = TritonAdapter(endpoint_url=endpoint_url)
|
||||
|
||||
test_img_path = "/Users/davestran/Downloads/vkist_internship/PILOT_PROJECT/workspace/sprint_1_2/CODEBASE/infra/tests/test_images/other_angle/med_lat_2.png"
|
||||
|
||||
|
||||
async def main():
|
||||
img = load_image(Path(test_img_path))
|
||||
preprocessed_img_224 = preprocess_224(img)
|
||||
|
||||
# Test 4: list_models
|
||||
models = await adapter.list_models()
|
||||
assert isinstance(models, list)
|
||||
print(f"[OK] list_models count={len(models)}")
|
||||
|
||||
# Test 3: model_ready
|
||||
ready = await adapter.model_ready("angle_classify_convnext_tiny")
|
||||
print(f"[OK] model_ready={ready}")
|
||||
|
||||
# Test 3: model_ready
|
||||
ready = await adapter.model_ready("msk_vision_pipeline_ensemble")
|
||||
print(f"[OK] model_ready={ready}")
|
||||
|
||||
# Test 1: single model, infer without output filter
|
||||
result = await adapter.infer(
|
||||
model_name="angle_classify_convnext_tiny",
|
||||
inputs={
|
||||
"input_image": {
|
||||
"data": preprocessed_img_224.tolist(),
|
||||
"shape": list(preprocessed_img_224.shape),
|
||||
"datatype": "FP32",
|
||||
}
|
||||
},
|
||||
)
|
||||
assert isinstance(result, dict), f"Expected dict, got {type(result)}"
|
||||
assert "logits" in result, f"Expected 'logits' key, got keys: {list(result.keys())}"
|
||||
assert isinstance(result["logits"], list), "logits should be a list"
|
||||
print(f"[OK] single model: logits={result['logits']}")
|
||||
|
||||
preprocessed_img_512 = preprocess_512(img)
|
||||
|
||||
# Test 2: ensemble with all outputs (Triton ensemble requires all outputs to avoid deadlock)
|
||||
result2 = await adapter.infer(
|
||||
model_name="msk_vision_pipeline_ensemble",
|
||||
inputs={
|
||||
"input_224": {
|
||||
"data": preprocessed_img_224.tolist(),
|
||||
"shape": list(preprocessed_img_224.shape),
|
||||
"datatype": "FP32",
|
||||
},
|
||||
"input_512": {
|
||||
"data": preprocessed_img_512.tolist(),
|
||||
"shape": list(preprocessed_img_512.shape),
|
||||
"datatype": "FP32",
|
||||
},
|
||||
},
|
||||
outputs=[
|
||||
"angle_classify_convnext_tiny_logits",
|
||||
"angle_classify_resnet50_logits",
|
||||
"angle_classify_swin_v2_s_logits",
|
||||
"angle_classify_densenet_logits",
|
||||
"angle_classify_efficientnet_logits",
|
||||
"inflammation_model_efficientnet_b0_ultrasound_2_cls_logits",
|
||||
"segmentation_model_unet_resnet101_logits",
|
||||
"segmentation_model_unet3plus_att_logits",
|
||||
"segmentation_model_post_deeplabv3_resnet101_logits",
|
||||
"segmentation_model_post_deeplabv3_logits",
|
||||
"segmentation_model_post_efficientfeedback_logits",
|
||||
],
|
||||
)
|
||||
assert "angle_classify_convnext_tiny_logits" in result2
|
||||
assert "segmentation_model_unet_resnet101_logits" in result2
|
||||
print(f"[OK] ensemble: {list(result2.keys())}")
|
||||
for elements in result2:
|
||||
print(elements, ":", result2[elements].shape)
|
||||
|
||||
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
asyncio.run(main())
|
||||