update the codebase poc ver1

This commit is contained in:
DatTT127
2026-07-07 15:54:17 +07:00
parent fed5f277f4
commit 1622dc8fc5
452 changed files with 83999 additions and 66328 deletions

View File

@@ -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()

View File

@@ -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")

View File

@@ -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

View File

@@ -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())

View File

@@ -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()

Binary file not shown.

After

Width:  |  Height:  |  Size: 85 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 109 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

View File

@@ -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"
}
]
}
]
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 261 KiB

View File

@@ -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 (01 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"])

View File

@@ -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())