File size: 6,482 Bytes
816a70e
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
729ee5e
816a70e
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
"""Vast.ai Serverless handler: same intake + vendored chain, FastAPI surface.

Not wired to traffic (INFERENCE_BACKEND stays zerogpu). When activation is
scheduled: build the image below, create the Serverless endpoint from
vast-config.json, then flip Pages envs — frontend needs zero code change.

Response shape matches frontend/src/lib/radar.ts vastai branch:
{"scores": {finding: score}, "rows": [{finding, score}]} sorted desc.
Error strings mirror intake.py/app.py; surfaced as HTTP 400 {"detail": msg}.
"""

import os
import re
import shutil
import sys
import tempfile
from contextlib import asynccontextmanager

from fastapi import FastAPI, HTTPException, UploadFile  # noqa: E402
import gradio as gr  # noqa: E402 — reused for intake's error literals
from huggingface_hub import snapshot_download  # noqa: E402

HERE = os.path.dirname(os.path.abspath(__file__))

def _vendor_path() -> str:
    """Locate vendored RADAR_inference in repo checkout or image layout (/srv)."""
    ancestors = [HERE]
    parent = HERE
    for _ in range(3):
        parent = os.path.dirname(parent)
        ancestors.append(parent)
    for base in ancestors + ["/srv", os.getcwd()]:
        if os.path.isdir(os.path.join(base, "RADAR_inference")):
            return os.path.join(base, "RADAR_inference")
    return os.path.join(HERE, "..", "..", "..", "RADAR_inference")

sys.path.insert(0, _vendor_path())
from intake import (  # noqa: E402
    HEADER_SUFFIXES,
    VOLUME_SUFFIXES,
    _series_to_nifti,
    _stage_uploads,
    _to_nifti,
)
from inference_demo import evaluate, initialize  # noqa: E402

REPO_ID = "radar-generalist/RADAR"
CHECKPOINT_NAME = "checkpoint_radar_pretrain.pth"

_state: dict = {}


def _english_name(column: str) -> str:
    # Same mapping as app.py; duplicated (not imported) so this service never
    # imports the Space module, whose import has CUDA/GPU side effects.
    m = re.search(r"\((.+)\)\s*$", column)
    return m.group(1) if m else column


def _ensure_ckpts() -> None:
    # Mirrors app.py _ensure_ckpts patterns: checkpoint + tokenizer both land
    # in MODEL_ROOT. infer_text_embedding_radar.pt ships in ckpt/ (git LFS —
    # run `git lfs pull` before docker build or it bakes the pointer).
    model_root = os.getenv("MODEL_ROOT", "/srv/ckpt")
    snapshot_download(
        REPO_ID,
        allow_patterns=[CHECKPOINT_NAME, "bert-base-chinese/*"],
        local_dir=model_root,
    )


@asynccontextmanager
async def lifespan(app: FastAPI):
    _ensure_ckpts()
    pad_func, model = initialize()  # once; per-request work stays in /score
    _state["pad_func"] = pad_func
    _state["model"] = model
    yield
    _state.clear()


app = FastAPI(title="RADAR Vast.ai scoring", lifespan=lifespan)


@app.post("/score")
async def score(ct_files: list[UploadFile]):
    """Stage uploads (intake.py) then run the vendored chain once."""
    tmpdir = tempfile.mkdtemp(prefix="radar_case_")
    outdir = tempfile.mkdtemp(prefix="radar_out_")
    incoming = tempfile.mkdtemp(prefix="radar_incoming_")
    staged = tempfile.mkdtemp(prefix="radar_dcm_")
    try:
        try:
            # Raw uploads land in incoming/; _stage_uploads alone populates
            # staged/ (it also sanitizes names and expands zips).
            paths: list[str] = []
            for i, upload in enumerate(ct_files or []):
                raw = ((upload.filename or "").strip() or f"upload_{i}").replace("\\", "/")
                safe = os.path.basename(raw) or f"upload_{i}"
                dest = os.path.join(incoming, f"{i}_{safe}")
                with open(dest, "wb") as dst:
                    dst.write(await upload.read())
                paths.append(dest)

            singles = [p for p in paths if p.lower().endswith(VOLUME_SUFFIXES)]
            rest = [p for p in paths if p not in singles]
            if singles and rest:
                raise gr.Error("upload either one volume file or one DICOM series, not both")
            if len(singles) > 1:
                raise gr.Error("upload a single volume file")
            if singles:
                src = _to_nifti(singles[0], tmpdir)
                fname = os.path.basename(src)
                if src != os.path.join(tmpdir, fname):
                    if not fname.endswith((".nii", ".nii.gz")):
                        fname += ".nii.gz"
                    shutil.copy(src, os.path.join(tmpdir, fname))
            else:
                if len(paths) == 1 and paths[0].lower().endswith(".dcm"):
                    raise gr.Error(
                        "a single .dcm is one slice, not a volume: upload the full series"
                    )
                headers = [p for p in paths if p.lower().endswith(HEADER_SUFFIXES)]
                if headers:
                    raise gr.Error(
                        f"{os.path.basename(headers[0])} needs its raw pair: "
                        "convert to .nii.gz/.nrrd/.mha first"
                    )
                _stage_uploads(paths, staged)
                _series_to_nifti(staged, tmpdir)

            import pandas as pd

            try:
                evaluate(_state["pad_func"], _state["model"], tmpdir, outdir, "vast")
            except (OSError, ValueError, RuntimeError) as exc:
                raise gr.Error(f"could not process this volume: {exc}")
            import glob

            matches = glob.glob(os.path.join(outdir, "RADAR_infer_results_vast.csv"))
            if not matches:
                raise gr.Error("model skipped this volume (check dimensions/spacing)")
            df = pd.read_csv(matches[0], encoding="utf-8-sig")
            if df.empty:
                raise gr.Error("model skipped this volume (check dimensions/spacing)")
            row = df.iloc[0]
            scores = {
                _english_name(col): float(row[col])
                for col in df.columns[1:]
                if pd.notna(row[col]) and str(row[col]).strip() != ""
            }
            rows = [
                {"finding": k, "score": v}
                for k, v in sorted(scores.items(), key=lambda kv: kv[1], reverse=True)
            ]
            return {"scores": scores, "rows": rows}
        except gr.Error as exc:
            raise HTTPException(status_code=400, detail=str(exc))
    finally:
        shutil.rmtree(tmpdir, ignore_errors=True)
        shutil.rmtree(outdir, ignore_errors=True)
        shutil.rmtree(incoming, ignore_errors=True)
        shutil.rmtree(staged, ignore_errors=True)