patholens 2.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- patholens-2.0.0.dist-info/METADATA +40 -0
- patholens-2.0.0.dist-info/RECORD +45 -0
- patholens-2.0.0.dist-info/WHEEL +4 -0
- patholens-2.0.0.dist-info/entry_points.txt +2 -0
- patholens-backend/app/database.py +24 -0
- patholens-backend/app/main.py +541 -0
- patholens-backend/app/models.py +17 -0
- patholens-backend/requirements.txt +11 -0
- patholens-ui/.gitignore +41 -0
- patholens-ui/OncoGemma-logo-no-bg_low.png +0 -0
- patholens-ui/OncoGemma-no-bg-high_gemini.png +0 -0
- patholens-ui/README.md +36 -0
- patholens-ui/app/dashboard/page.tsx +245 -0
- patholens-ui/app/favicon.ico +0 -0
- patholens-ui/app/globals.css +80 -0
- patholens-ui/app/layout.tsx +35 -0
- patholens-ui/app/lib/auth.ts +34 -0
- patholens-ui/app/lib/config.ts +5 -0
- patholens-ui/app/login/page.tsx +112 -0
- patholens-ui/app/page.tsx +5 -0
- patholens-ui/app/viewer/page.tsx +310 -0
- patholens-ui/components/AIResults.tsx +180 -0
- patholens-ui/components/ClinicalChat.tsx +74 -0
- patholens-ui/components/OncoLLM.tsx +176 -0
- patholens-ui/components/WSIViewer.tsx +104 -0
- patholens-ui/eslint.config.mjs +18 -0
- patholens-ui/next.config.ts +10 -0
- patholens-ui/package-lock.json +7391 -0
- patholens-ui/package.json +34 -0
- patholens-ui/postcss.config.mjs +7 -0
- patholens-ui/public/file.svg +1 -0
- patholens-ui/public/globe.svg +1 -0
- patholens-ui/public/hawkfranklin_logo.png +0 -0
- patholens-ui/public/next.svg +1 -0
- patholens-ui/public/onco-gemma-logo.png +0 -0
- patholens-ui/public/oncogemma_full_high_crop.png +0 -0
- patholens-ui/public/oncogemma_logo_only.png +0 -0
- patholens-ui/public/oncogemma_text_only.png +0 -0
- patholens-ui/public/pathogemma_icon.png +0 -0
- patholens-ui/public/vercel.svg +1 -0
- patholens-ui/public/window.svg +1 -0
- patholens-ui/tsconfig.json +34 -0
- patholens-ui/types.d.ts +106 -0
- patholens_cli/__init__.py +2 -0
- patholens_cli/main.py +348 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: patholens
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: A command-line tool to manage, deploy, and launch PathoLens with Google Colab or local backends.
|
|
5
|
+
Requires-Python: >=3.10
|
|
6
|
+
Requires-Dist: click>=8.0.0
|
|
7
|
+
Requires-Dist: fastapi>=0.110.0
|
|
8
|
+
Requires-Dist: google-colab-cli>=0.1.0
|
|
9
|
+
Requires-Dist: httpx>=0.24.0
|
|
10
|
+
Requires-Dist: numpy>=1.24.0
|
|
11
|
+
Requires-Dist: openslide-bin>=4.0.1.1
|
|
12
|
+
Requires-Dist: openslide-python>=1.3.0
|
|
13
|
+
Requires-Dist: pillow>=10.0.0
|
|
14
|
+
Requires-Dist: pydantic>=2.0.0
|
|
15
|
+
Requires-Dist: pyjwt[crypto]>=2.8.0
|
|
16
|
+
Requires-Dist: python-multipart>=0.0.9
|
|
17
|
+
Requires-Dist: sqlalchemy>=2.0.0
|
|
18
|
+
Requires-Dist: uvicorn[standard]>=0.20.0
|
|
19
|
+
Requires-Dist: websockets>=12.0
|
|
20
|
+
Description-Content-Type: text/markdown
|
|
21
|
+
|
|
22
|
+
# PathoLens
|
|
23
|
+
|
|
24
|
+
PathoLens is a digital pathology workstation with a CLI launcher for local or Google Colab-backed execution.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
uv tool install patholens
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## Usage
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
patholens launch
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
The launcher can run the backend locally or package and deploy the application to a Google Colab VM. Local launch builds the bundled Next.js UI if a static export is not already present.
|
|
39
|
+
|
|
40
|
+
For whole-slide image viewing, the backend uses OpenSlide.
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
patholens_cli/__init__.py,sha256=DjC2PAALKXLXdE-k7KEZE_2cTJ1Onz7ZQ462lvZe0Qk,45
|
|
2
|
+
patholens_cli/main.py,sha256=M1Q4FY9XcMWd0lNDC7nL4pwgKCpPMq8D4hnxZL4hBeg,14813
|
|
3
|
+
patholens-backend/requirements.txt,sha256=MsESWdUH1MlLjs2laxGDRfRvM7yaQ0Z9HAW08B8vQhY,116
|
|
4
|
+
patholens-backend/app/database.py,sha256=38Sp4NnyCqa8xYOQT6HT5HU677QNzq0IUIymOkaSxpk,612
|
|
5
|
+
patholens-backend/app/main.py,sha256=9N27gLdjBBTRvpLALzQZJXGd5HKPGT9IZMZn_7N74rc,20394
|
|
6
|
+
patholens-backend/app/models.py,sha256=RH6fukqLrrTSD_s9zOcQIYPRxAwRnhR4d2m--i5eDX0,783
|
|
7
|
+
patholens-ui/.gitignore,sha256=IH4mX_SQH5rZ-W2M4IUw4E-fxgCBVHKmbQpEYJbWVM0,480
|
|
8
|
+
patholens-ui/OncoGemma-logo-no-bg_low.png,sha256=F0Sv8De15WgZwEDfTwqkiG4DJN1ni71IQ4uXBl1sWpc,80575
|
|
9
|
+
patholens-ui/OncoGemma-no-bg-high_gemini.png,sha256=8yxHTdJl80v0Z11A5_YpnfUbt201aFZ478Bmbw3Mp-4,4690363
|
|
10
|
+
patholens-ui/README.md,sha256=YLVf9995r3JZD5UkII5GZCvDK9wXXNrUE0loHA4vlY8,1450
|
|
11
|
+
patholens-ui/eslint.config.mjs,sha256=hw8a3M7PMFHLzZ_TB871HXYzz1EJecGBqB9LF5cnNJM,465
|
|
12
|
+
patholens-ui/next.config.ts,sha256=mjm5jrSGX-oaxtqnHLZWYDdWJoVx_BjMwr6PxNQcSdA,165
|
|
13
|
+
patholens-ui/package-lock.json,sha256=hbndqurR7FrT3Fg9z-bsRnloqA0l7wRWKNc6_T6Y8_8,263654
|
|
14
|
+
patholens-ui/package.json,sha256=-CMJZWDGneIsBcSgs_-rc9iVX1WN6A1N71VOixVgGB4,772
|
|
15
|
+
patholens-ui/postcss.config.mjs,sha256=36x6wthtMmoOWtsCTnlDwYE5PtF6X8uPAxWyTH2m3d4,94
|
|
16
|
+
patholens-ui/tsconfig.json,sha256=vhhSOyO3i24ah23dEH0zDwFovd8J11G1fgte3OacZvM,666
|
|
17
|
+
patholens-ui/types.d.ts,sha256=0k2ku3FaCTnxalkLwihb3SLFYGwWzupkmyR3ZGwFySo,2088
|
|
18
|
+
patholens-ui/app/favicon.ico,sha256=K4rS0zRVqPc2_DqOv48L3qiEitTA20iigzvQ-c13WTI,25931
|
|
19
|
+
patholens-ui/app/globals.css,sha256=K7J-fRdhfeSsQXUdF4uZQ4_SdgkSc7XWloPxGQFtYJg,1634
|
|
20
|
+
patholens-ui/app/layout.tsx,sha256=RpaajHuKYeCyUwEEYTf3lQ0YTyillt6HvyuEwyaJU9E,757
|
|
21
|
+
patholens-ui/app/page.tsx,sha256=kWMNqbDdoeT5cHDxHYDM_0Lv92IXXCXSUfdHzJd4Oo0,102
|
|
22
|
+
patholens-ui/app/dashboard/page.tsx,sha256=qgmVpyXEQL4Zj05prrQvrZ38P3CiWyM-AYn_ujo4qCs,14883
|
|
23
|
+
patholens-ui/app/lib/auth.ts,sha256=DadfudKAkDNewK_I-pcsHjT20GtQCZLVfH0xcibph3s,1140
|
|
24
|
+
patholens-ui/app/lib/config.ts,sha256=f2Lu7p9iyhOBEvxxFrMTdFE50vg8iSeu94t_pnmiDbQ,215
|
|
25
|
+
patholens-ui/app/login/page.tsx,sha256=ffuO5za0CrCCeFGSNfjIbzLcOXb2JCs1Bm3pNOA0D8A,5516
|
|
26
|
+
patholens-ui/app/viewer/page.tsx,sha256=MCR5Zwfq9QjIumDv2qLwuwqqBBe7jYonUBTlCGeEHLA,16926
|
|
27
|
+
patholens-ui/components/AIResults.tsx,sha256=9oSyz6tkTrQSrdeiFPsoiuROvEjal513Wht2__tOKQk,8896
|
|
28
|
+
patholens-ui/components/ClinicalChat.tsx,sha256=SoDC6Pa0ovnuQl-elBIYHsZ2rxrJYTqSu77wXZy6Vb8,3128
|
|
29
|
+
patholens-ui/components/OncoLLM.tsx,sha256=LT3iej7X0JOoam9UNQlaTplQDtCaOaPGYzB1L4Cm3fI,8108
|
|
30
|
+
patholens-ui/components/WSIViewer.tsx,sha256=71-9EK_CYZcp-_ShhWAABe2ZTETdToY4dv-wJDq0KlQ,4142
|
|
31
|
+
patholens-ui/public/file.svg,sha256=K2eBLDJcGZoCU2zb7qDFk6cvcH0yO3LuPgjbqwZ1O9Q,391
|
|
32
|
+
patholens-ui/public/globe.svg,sha256=thS5vxg5JZV2YayFFJj-HYAp_UOmL7_thvniYkpX588,1035
|
|
33
|
+
patholens-ui/public/hawkfranklin_logo.png,sha256=JoICo2eNAgJvgSNzCBwqQFT4Dwh2h_1JR3La_lFcXyQ,1363659
|
|
34
|
+
patholens-ui/public/next.svg,sha256=VZld-tbstJRaHoVt3KA8XhaqW_E_0htN9qdK55NXvPw,1375
|
|
35
|
+
patholens-ui/public/onco-gemma-logo.png,sha256=OZFJ6KweMWYRNHhWF0NTsXIzUC7PMoE2KSJSg50b_W8,2110224
|
|
36
|
+
patholens-ui/public/oncogemma_full_high_crop.png,sha256=41rIKPUTkouVUgFmjj-zMPqOahODmZuZQhuqeEcinw8,1974556
|
|
37
|
+
patholens-ui/public/oncogemma_logo_only.png,sha256=ldo4ISCtJ8ucnCIyqWir8JAxsDzLVa4p4uvtx-WD8Vs,637917
|
|
38
|
+
patholens-ui/public/oncogemma_text_only.png,sha256=lXZLJs-TEmLhVGfBbGaqkkBO-EJFkHJkhxjKoBhuF28,389460
|
|
39
|
+
patholens-ui/public/pathogemma_icon.png,sha256=3NYUwgE-ESqjkkG_QqwDrtKVrENp4Txs0KGp4eNxiEg,303158
|
|
40
|
+
patholens-ui/public/vercel.svg,sha256=8IEzey_uY1tFW2MnVAaj5_OdagFOJa2Q2rWmfmKhKsQ,128
|
|
41
|
+
patholens-ui/public/window.svg,sha256=ZEdoxKrrR2e84pM0TusMEl-4BKlNgBRAQkByIC2F46E,385
|
|
42
|
+
patholens-2.0.0.dist-info/METADATA,sha256=rQ9i-CXIxvY2LeT47oq74n4dNJN30Gkwacgvx4h7wxs,1161
|
|
43
|
+
patholens-2.0.0.dist-info/WHEEL,sha256=mffPy8wBnZQn2VnJUU5jE99KsxaSfiyMHV9Yt0aLVxs,87
|
|
44
|
+
patholens-2.0.0.dist-info/entry_points.txt,sha256=DBH6XakxMTt4lHdHV8nCNvd-j1OdZdIj3viJrd5uxcY,53
|
|
45
|
+
patholens-2.0.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import os
|
|
2
|
+
from sqlalchemy import create_engine
|
|
3
|
+
from sqlalchemy.orm import declarative_base, sessionmaker
|
|
4
|
+
|
|
5
|
+
DATABASE_URL = os.getenv("DATABASE_URL", "sqlite:///./patholens.db")
|
|
6
|
+
|
|
7
|
+
# For SQLite, we need to allow multithreaded access
|
|
8
|
+
if DATABASE_URL.startswith("sqlite"):
|
|
9
|
+
engine = create_engine(
|
|
10
|
+
DATABASE_URL, connect_args={"check_same_thread": False}
|
|
11
|
+
)
|
|
12
|
+
else:
|
|
13
|
+
engine = create_engine(DATABASE_URL)
|
|
14
|
+
|
|
15
|
+
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
|
16
|
+
|
|
17
|
+
Base = declarative_base()
|
|
18
|
+
|
|
19
|
+
def get_db():
|
|
20
|
+
db = SessionLocal()
|
|
21
|
+
try:
|
|
22
|
+
yield db
|
|
23
|
+
finally:
|
|
24
|
+
db.close()
|
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import io
|
|
3
|
+
import json
|
|
4
|
+
import logging
|
|
5
|
+
import os
|
|
6
|
+
import random
|
|
7
|
+
import urllib.request
|
|
8
|
+
from typing import List, Optional
|
|
9
|
+
|
|
10
|
+
import httpx
|
|
11
|
+
import openslide
|
|
12
|
+
import pydantic
|
|
13
|
+
import jwt
|
|
14
|
+
from fastapi import FastAPI, HTTPException, WebSocket, Depends, Security
|
|
15
|
+
from fastapi.middleware.cors import CORSMiddleware
|
|
16
|
+
from fastapi.responses import FileResponse, JSONResponse, StreamingResponse
|
|
17
|
+
from fastapi.staticfiles import StaticFiles
|
|
18
|
+
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
|
|
19
|
+
from PIL import Image
|
|
20
|
+
|
|
21
|
+
from .database import engine, Base, get_db
|
|
22
|
+
from .models import User, Slide
|
|
23
|
+
|
|
24
|
+
# Create DB tables on startup
|
|
25
|
+
Base.metadata.create_all(bind=engine)
|
|
26
|
+
|
|
27
|
+
try:
|
|
28
|
+
import litert_lm
|
|
29
|
+
LITERT_LM_AVAILABLE = True
|
|
30
|
+
except ImportError:
|
|
31
|
+
LITERT_LM_AVAILABLE = False
|
|
32
|
+
logger = logging.getLogger("patholens-backend")
|
|
33
|
+
logger.warning("litert_lm package not found. LiteRT model local inference is disabled.")
|
|
34
|
+
|
|
35
|
+
LITERT_ENGINE = None
|
|
36
|
+
|
|
37
|
+
def get_litert_engine():
|
|
38
|
+
global LITERT_ENGINE
|
|
39
|
+
if not LITERT_LM_AVAILABLE:
|
|
40
|
+
raise HTTPException(status_code=500, detail="litert_lm package is not installed on this server")
|
|
41
|
+
if LITERT_ENGINE is None:
|
|
42
|
+
model_path = os.getenv("LITERT_MODEL_PATH")
|
|
43
|
+
if not model_path:
|
|
44
|
+
raise HTTPException(status_code=500, detail="LITERT_MODEL_PATH env var is not configured")
|
|
45
|
+
backend_str = os.getenv("LITERT_BACKEND", "cpu").lower()
|
|
46
|
+
backend = litert_lm.Backend.GPU if backend_str == "gpu" else litert_lm.Backend.CPU
|
|
47
|
+
logger.info("Initializing LiteRT-LM Engine with model: %s, backend: %s", model_path, backend_str)
|
|
48
|
+
LITERT_ENGINE = litert_lm.Engine(model_path, backend=backend)
|
|
49
|
+
return LITERT_ENGINE
|
|
50
|
+
|
|
51
|
+
logging.basicConfig(level=logging.INFO)
|
|
52
|
+
logger = logging.getLogger("patholens-backend")
|
|
53
|
+
|
|
54
|
+
ALLOWED_ORIGINS = [
|
|
55
|
+
origin.strip()
|
|
56
|
+
for origin in os.getenv(
|
|
57
|
+
"CORS_ALLOW_ORIGINS",
|
|
58
|
+
"http://localhost:3000,http://127.0.0.1:3000",
|
|
59
|
+
).split(",")
|
|
60
|
+
if origin.strip()
|
|
61
|
+
]
|
|
62
|
+
if not ALLOWED_ORIGINS:
|
|
63
|
+
ALLOWED_ORIGINS = ["*"]
|
|
64
|
+
ALLOW_CREDENTIALS = not (len(ALLOWED_ORIGINS) == 1 and ALLOWED_ORIGINS[0] == "*")
|
|
65
|
+
|
|
66
|
+
BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
|
|
67
|
+
DEFAULT_SLIDE_DIR = os.path.join(os.path.dirname(BASE_DIR), "cptac_samples")
|
|
68
|
+
SLIDE_DIR = os.path.abspath(os.getenv("SAMPLE_DATA_PATH") or DEFAULT_SLIDE_DIR)
|
|
69
|
+
|
|
70
|
+
STATIC_FRONTEND_DIR = os.getenv("STATIC_FRONTEND_DIR")
|
|
71
|
+
STATIC_INDEX = os.path.join(STATIC_FRONTEND_DIR, "index.html") if STATIC_FRONTEND_DIR else None
|
|
72
|
+
|
|
73
|
+
INFERENCE_API_URL = os.getenv("MEDGEMMA_API_URL") or os.getenv("INFERENCE_API_URL")
|
|
74
|
+
INFERENCE_API_TOKEN = os.getenv("HF_TOKEN") or os.getenv("INFERENCE_API_TOKEN")
|
|
75
|
+
INFERENCE_TIMEOUT = float(os.getenv("INFERENCE_TIMEOUT_SECONDS", "60"))
|
|
76
|
+
SAMPLE_DEMO_URL = os.getenv("SAMPLE_DEMO_URL")
|
|
77
|
+
SAMPLE_DEMO_FILENAME = os.getenv("SAMPLE_DEMO_FILENAME", "demo.svs")
|
|
78
|
+
|
|
79
|
+
# Authentication configurations
|
|
80
|
+
JWT_VERIFICATION_ENABLED = os.getenv("JWT_VERIFICATION_ENABLED", "false").lower() == "true"
|
|
81
|
+
FIREBASE_PROJECT_ID = os.getenv("FIREBASE_PROJECT_ID")
|
|
82
|
+
GOOGLE_PUBLIC_KEYS = {}
|
|
83
|
+
|
|
84
|
+
async def fetch_google_public_keys():
|
|
85
|
+
global GOOGLE_PUBLIC_KEYS
|
|
86
|
+
try:
|
|
87
|
+
async with httpx.AsyncClient() as client:
|
|
88
|
+
res = await client.get("https://www.googleapis.com/robot/v1/metadata/x509/securetoken@system.gserviceaccount.com")
|
|
89
|
+
if res.status_code == 200:
|
|
90
|
+
GOOGLE_PUBLIC_KEYS = res.json()
|
|
91
|
+
except Exception as e:
|
|
92
|
+
logger.error("Failed to fetch Google public keys: %s", e)
|
|
93
|
+
|
|
94
|
+
async def verify_firebase_token(token: str) -> dict:
|
|
95
|
+
if not GOOGLE_PUBLIC_KEYS:
|
|
96
|
+
await fetch_google_public_keys()
|
|
97
|
+
|
|
98
|
+
try:
|
|
99
|
+
unverified_header = jwt.get_unverified_header(token)
|
|
100
|
+
kid = unverified_header.get("kid")
|
|
101
|
+
if not kid or kid not in GOOGLE_PUBLIC_KEYS:
|
|
102
|
+
raise HTTPException(status_code=401, detail="Invalid token header kid")
|
|
103
|
+
|
|
104
|
+
cert = GOOGLE_PUBLIC_KEYS[kid]
|
|
105
|
+
decoded = jwt.decode(
|
|
106
|
+
token,
|
|
107
|
+
cert,
|
|
108
|
+
algorithms=["RS256"],
|
|
109
|
+
audience=FIREBASE_PROJECT_ID,
|
|
110
|
+
issuer=f"https://securetoken.google.com/{FIREBASE_PROJECT_ID}"
|
|
111
|
+
)
|
|
112
|
+
return decoded
|
|
113
|
+
except Exception as e:
|
|
114
|
+
logger.error("Token verification failed: %s", e)
|
|
115
|
+
raise HTTPException(status_code=401, detail=f"Token verification failed: {str(e)}")
|
|
116
|
+
|
|
117
|
+
security = HTTPBearer(auto_error=False)
|
|
118
|
+
|
|
119
|
+
async def get_current_user(
|
|
120
|
+
credentials: Optional[HTTPAuthorizationCredentials] = Security(security),
|
|
121
|
+
token: Optional[str] = None
|
|
122
|
+
) -> dict:
|
|
123
|
+
if not JWT_VERIFICATION_ENABLED:
|
|
124
|
+
return {"user_id": "mock-user-id", "email": "doctor.sarah@oncogemma.com"}
|
|
125
|
+
|
|
126
|
+
auth_token = None
|
|
127
|
+
if credentials:
|
|
128
|
+
auth_token = credentials.credentials
|
|
129
|
+
elif token:
|
|
130
|
+
auth_token = token
|
|
131
|
+
|
|
132
|
+
if not auth_token:
|
|
133
|
+
raise HTTPException(status_code=401, detail="Authentication token required")
|
|
134
|
+
|
|
135
|
+
decoded = await verify_firebase_token(auth_token)
|
|
136
|
+
return {
|
|
137
|
+
"user_id": decoded.get("sub"),
|
|
138
|
+
"email": decoded.get("email")
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
def ensure_slide_storage():
|
|
142
|
+
os.makedirs(SLIDE_DIR, exist_ok=True)
|
|
143
|
+
|
|
144
|
+
def maybe_download_demo_slide():
|
|
145
|
+
if not SAMPLE_DEMO_URL:
|
|
146
|
+
return
|
|
147
|
+
dest = os.path.join(SLIDE_DIR, SAMPLE_DEMO_FILENAME)
|
|
148
|
+
if os.path.exists(dest):
|
|
149
|
+
return
|
|
150
|
+
try:
|
|
151
|
+
logger.info("Downloading demo slide to %s", dest)
|
|
152
|
+
urllib.request.urlretrieve(SAMPLE_DEMO_URL, dest)
|
|
153
|
+
except Exception as exc:
|
|
154
|
+
logger.warning("Could not download demo slide: %s", exc)
|
|
155
|
+
|
|
156
|
+
ensure_slide_storage()
|
|
157
|
+
maybe_download_demo_slide()
|
|
158
|
+
|
|
159
|
+
app = FastAPI()
|
|
160
|
+
|
|
161
|
+
# CORS configuration
|
|
162
|
+
app.add_middleware(
|
|
163
|
+
CORSMiddleware,
|
|
164
|
+
allow_origins=ALLOWED_ORIGINS,
|
|
165
|
+
allow_credentials=ALLOW_CREDENTIALS,
|
|
166
|
+
allow_methods=["*"],
|
|
167
|
+
allow_headers=["*"],
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
if STATIC_FRONTEND_DIR and os.path.isdir(STATIC_FRONTEND_DIR):
|
|
171
|
+
app.mount("/_static", StaticFiles(directory=STATIC_FRONTEND_DIR), name="frontend-static")
|
|
172
|
+
|
|
173
|
+
def get_user_slide_path(filename: str, user_id: str) -> str:
|
|
174
|
+
# Check in user-specific folder (created via GCS FUSE mount or local)
|
|
175
|
+
path_user = os.path.join(SLIDE_DIR, "users", user_id, "slides", filename)
|
|
176
|
+
if os.path.exists(path_user):
|
|
177
|
+
return path_user
|
|
178
|
+
|
|
179
|
+
# Fallback to local root (default slides/demo)
|
|
180
|
+
path_fallback = os.path.join(SLIDE_DIR, filename)
|
|
181
|
+
return path_fallback
|
|
182
|
+
|
|
183
|
+
class SlideManager:
|
|
184
|
+
def __init__(self):
|
|
185
|
+
# Maps user_id -> {'slide': OpenSlide, 'dz': DeepZoomGenerator, 'name': filename}
|
|
186
|
+
self.active_sessions = {}
|
|
187
|
+
|
|
188
|
+
def get_session(self, user_id: str):
|
|
189
|
+
return self.active_sessions.get(user_id)
|
|
190
|
+
|
|
191
|
+
def load(self, filename: str, user_id: str):
|
|
192
|
+
path = get_user_slide_path(filename, user_id)
|
|
193
|
+
if not os.path.exists(path):
|
|
194
|
+
raise HTTPException(status_code=404, detail=f"Slide not found: {filename}")
|
|
195
|
+
|
|
196
|
+
try:
|
|
197
|
+
slide = openslide.OpenSlide(path)
|
|
198
|
+
from openslide.deepzoom import DeepZoomGenerator
|
|
199
|
+
dz = DeepZoomGenerator(slide, tile_size=256, overlap=0, limit_bounds=False)
|
|
200
|
+
self.active_sessions[user_id] = {
|
|
201
|
+
'slide': slide,
|
|
202
|
+
'dz': dz,
|
|
203
|
+
'name': filename
|
|
204
|
+
}
|
|
205
|
+
logger.info("Loaded slide for user %s: %s", user_id, path)
|
|
206
|
+
return True
|
|
207
|
+
except Exception as e:
|
|
208
|
+
logger.exception("Error loading slide: %s", e)
|
|
209
|
+
raise HTTPException(status_code=500, detail=f"Failed to load slide: {str(e)}")
|
|
210
|
+
|
|
211
|
+
slide_manager = SlideManager()
|
|
212
|
+
|
|
213
|
+
# Load default if available (fallback compatibility for local testing)
|
|
214
|
+
try:
|
|
215
|
+
if os.path.exists(SLIDE_DIR):
|
|
216
|
+
files = [f for f in os.listdir(SLIDE_DIR) if f.endswith('.svs')]
|
|
217
|
+
if files:
|
|
218
|
+
slide_manager.load(files[0], "mock-user-id")
|
|
219
|
+
logger.info("Loaded default slide %s for mock-user-id", files[0])
|
|
220
|
+
except Exception:
|
|
221
|
+
pass
|
|
222
|
+
|
|
223
|
+
@app.get("/slides")
|
|
224
|
+
async def list_slides(current_user: dict = Depends(get_current_user)):
|
|
225
|
+
"""List all available SVS slides for the current user."""
|
|
226
|
+
user_id = current_user["user_id"]
|
|
227
|
+
user_slide_dir = os.path.join(SLIDE_DIR, "users", user_id, "slides")
|
|
228
|
+
|
|
229
|
+
slides = []
|
|
230
|
+
if os.path.exists(user_slide_dir):
|
|
231
|
+
slides = [f for f in os.listdir(user_slide_dir) if f.endswith('.svs')]
|
|
232
|
+
|
|
233
|
+
if os.path.exists(SLIDE_DIR):
|
|
234
|
+
root_slides = [f for f in os.listdir(SLIDE_DIR) if f.endswith('.svs')]
|
|
235
|
+
for s in root_slides:
|
|
236
|
+
if s not in slides:
|
|
237
|
+
slides.append(s)
|
|
238
|
+
|
|
239
|
+
session = slide_manager.get_session(user_id)
|
|
240
|
+
return {
|
|
241
|
+
"slides": slides,
|
|
242
|
+
"current": session['name'] if session else None
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
class LoadSlideRequest(pydantic.BaseModel):
|
|
246
|
+
filename: str
|
|
247
|
+
|
|
248
|
+
@app.post("/load_slide")
|
|
249
|
+
async def load_slide(request: LoadSlideRequest, current_user: dict = Depends(get_current_user)):
|
|
250
|
+
"""Load a specific slide for the current user."""
|
|
251
|
+
user_id = current_user["user_id"]
|
|
252
|
+
slide_manager.load(request.filename, user_id)
|
|
253
|
+
return {"status": "success", "filename": request.filename}
|
|
254
|
+
|
|
255
|
+
@app.get("/")
|
|
256
|
+
async def root():
|
|
257
|
+
if STATIC_INDEX and os.path.isfile(STATIC_INDEX):
|
|
258
|
+
return FileResponse(STATIC_INDEX)
|
|
259
|
+
return {"message": "PathoLens Backend is running"}
|
|
260
|
+
|
|
261
|
+
@app.get("/metadata")
|
|
262
|
+
async def get_metadata(current_user: dict = Depends(get_current_user)):
|
|
263
|
+
user_id = current_user["user_id"]
|
|
264
|
+
session = slide_manager.get_session(user_id)
|
|
265
|
+
if not session or not session.get('slide'):
|
|
266
|
+
raise HTTPException(status_code=404, detail="Slide not loaded for current user")
|
|
267
|
+
|
|
268
|
+
slide = session['slide']
|
|
269
|
+
return {
|
|
270
|
+
"width": slide.dimensions[0],
|
|
271
|
+
"height": slide.dimensions[1],
|
|
272
|
+
"level_count": slide.level_count,
|
|
273
|
+
"level_dimensions": slide.level_dimensions,
|
|
274
|
+
"level_downsamples": slide.level_downsamples,
|
|
275
|
+
"filename": session['name']
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@app.get("/tiles/{level}/{x}/{y}")
|
|
279
|
+
async def get_tile(level: int, x: int, y: int, current_user: dict = Depends(get_current_user)):
|
|
280
|
+
user_id = current_user["user_id"]
|
|
281
|
+
session = slide_manager.get_session(user_id)
|
|
282
|
+
if not session or not session.get('dz'):
|
|
283
|
+
raise HTTPException(status_code=404, detail="Slide not loaded for current user")
|
|
284
|
+
|
|
285
|
+
try:
|
|
286
|
+
dz = session['dz']
|
|
287
|
+
if level < 0 or level >= dz.level_count:
|
|
288
|
+
raise HTTPException(status_code=404, detail="Level out of bounds")
|
|
289
|
+
|
|
290
|
+
tile = dz.get_tile(level, (x, y))
|
|
291
|
+
|
|
292
|
+
img_byte_arr = io.BytesIO()
|
|
293
|
+
tile.save(img_byte_arr, format='JPEG')
|
|
294
|
+
img_byte_arr.seek(0)
|
|
295
|
+
|
|
296
|
+
return StreamingResponse(img_byte_arr, media_type="image/jpeg")
|
|
297
|
+
|
|
298
|
+
except ValueError:
|
|
299
|
+
raise HTTPException(status_code=404, detail="Tile out of bounds")
|
|
300
|
+
except Exception as e:
|
|
301
|
+
logger.exception("Error serving tile: %s", e)
|
|
302
|
+
raise HTTPException(status_code=500, detail=str(e))
|
|
303
|
+
|
|
304
|
+
@app.get("/dzi")
|
|
305
|
+
async def get_dzi(current_user: dict = Depends(get_current_user)):
|
|
306
|
+
"""
|
|
307
|
+
Return DZI metadata for OpenSeadragon for current user session.
|
|
308
|
+
"""
|
|
309
|
+
user_id = current_user["user_id"]
|
|
310
|
+
session = slide_manager.get_session(user_id)
|
|
311
|
+
if not session or not session.get('dz'):
|
|
312
|
+
raise HTTPException(status_code=404, detail="Slide not loaded for current user")
|
|
313
|
+
|
|
314
|
+
dz = session['dz']
|
|
315
|
+
|
|
316
|
+
return {
|
|
317
|
+
"Image": {
|
|
318
|
+
"xmlns": "http://schemas.microsoft.com/deepzoom/2008",
|
|
319
|
+
"Format": "jpeg",
|
|
320
|
+
"Overlap": "0",
|
|
321
|
+
"TileSize": "256",
|
|
322
|
+
"Size": {
|
|
323
|
+
"Width": str(dz.level_dimensions[-1][0]),
|
|
324
|
+
"Height": str(dz.level_dimensions[-1][1])
|
|
325
|
+
}
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
# Inference proxy (to external endpoints such as HF Inference Endpoint, Gradio Space, or custom vLLM/TGI).
|
|
330
|
+
class InferenceRequest(pydantic.BaseModel):
|
|
331
|
+
prompt: str
|
|
332
|
+
images: Optional[List[str]] = None
|
|
333
|
+
parameters: dict = {}
|
|
334
|
+
|
|
335
|
+
async def forward_inference(payload: dict, endpoint: Optional[str] = None):
|
|
336
|
+
target = endpoint or INFERENCE_API_URL
|
|
337
|
+
if not target:
|
|
338
|
+
raise HTTPException(status_code=503, detail="Inference endpoint not configured")
|
|
339
|
+
|
|
340
|
+
headers = {"Content-Type": "application/json"}
|
|
341
|
+
if INFERENCE_API_TOKEN:
|
|
342
|
+
headers["Authorization"] = f"Bearer {INFERENCE_API_TOKEN}"
|
|
343
|
+
|
|
344
|
+
try:
|
|
345
|
+
async with httpx.AsyncClient(timeout=INFERENCE_TIMEOUT) as client:
|
|
346
|
+
response = await client.post(target, json=payload, headers=headers)
|
|
347
|
+
response.raise_for_status()
|
|
348
|
+
except httpx.HTTPStatusError as exc:
|
|
349
|
+
detail = exc.response.text if exc.response else str(exc)
|
|
350
|
+
logger.error("Inference endpoint returned an error: %s", detail)
|
|
351
|
+
raise HTTPException(
|
|
352
|
+
status_code=exc.response.status_code if exc.response else 502,
|
|
353
|
+
detail=f"Inference failed: {detail}",
|
|
354
|
+
) from exc
|
|
355
|
+
except httpx.HTTPError as exc:
|
|
356
|
+
logger.error("Inference request failed: %s", exc)
|
|
357
|
+
raise HTTPException(status_code=502, detail=f"Inference request failed: {exc}") from exc
|
|
358
|
+
|
|
359
|
+
try:
|
|
360
|
+
return response.json()
|
|
361
|
+
except Exception:
|
|
362
|
+
return {"raw": response.text}
|
|
363
|
+
|
|
364
|
+
@app.post("/api/ai/diagnose")
|
|
365
|
+
async def proxy_diagnose(request: InferenceRequest, current_user: dict = Depends(get_current_user)):
|
|
366
|
+
"""
|
|
367
|
+
Proxy AI analysis to an external inference endpoint.
|
|
368
|
+
"""
|
|
369
|
+
payload = {"prompt": request.prompt}
|
|
370
|
+
if request.images:
|
|
371
|
+
payload["images"] = request.images
|
|
372
|
+
if request.parameters:
|
|
373
|
+
payload["parameters"] = request.parameters
|
|
374
|
+
|
|
375
|
+
return await forward_inference(payload)
|
|
376
|
+
|
|
377
|
+
# Dummy AI Endpoints
|
|
378
|
+
|
|
379
|
+
@app.get("/ai/summary")
|
|
380
|
+
async def get_global_summary(current_user: dict = Depends(get_current_user)):
|
|
381
|
+
await asyncio.sleep(1) # Simulate latency
|
|
382
|
+
return {
|
|
383
|
+
"description": "The slide shows sections of squamous cell carcinoma with moderate differentiation. "
|
|
384
|
+
"There is significant keratin pearl formation and intercellular bridges. "
|
|
385
|
+
"The stroma shows a heavy inflammatory infiltrate, primarily lymphocytic. "
|
|
386
|
+
"Tumor margins appear infiltrative.",
|
|
387
|
+
"confidence": 0.92
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
@app.get("/ai/risk")
|
|
391
|
+
async def get_risk_score(current_user: dict = Depends(get_current_user)):
|
|
392
|
+
await asyncio.sleep(1.5)
|
|
393
|
+
return {
|
|
394
|
+
"score": 0.87,
|
|
395
|
+
"risk_level": "High",
|
|
396
|
+
"factors": [
|
|
397
|
+
{"name": "Tumor Budding", "value": "High"},
|
|
398
|
+
{"name": "Stromal Reaction", "value": "Desmoplastic"},
|
|
399
|
+
{"name": "Lymphocytic Infiltration", "value": "Moderate"}
|
|
400
|
+
]
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
@app.get("/ai/biomarkers")
|
|
404
|
+
async def get_biomarkers(current_user: dict = Depends(get_current_user)):
|
|
405
|
+
await asyncio.sleep(0.5)
|
|
406
|
+
return {
|
|
407
|
+
"detected": [
|
|
408
|
+
{"name": "EGFR", "status": "Overexpression", "location": [10000, 10000]},
|
|
409
|
+
{"name": "TP53", "status": "Mutated", "location": [15000, 12000]},
|
|
410
|
+
{"name": "CD274 (PD-L1)", "status": "Positive", "location": [5000, 8000]}
|
|
411
|
+
]
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
@app.websocket("/ws")
|
|
415
|
+
async def websocket_endpoint(websocket: WebSocket):
|
|
416
|
+
await websocket.accept()
|
|
417
|
+
try:
|
|
418
|
+
while True:
|
|
419
|
+
data = await websocket.receive_text()
|
|
420
|
+
if "analyze_region" in data:
|
|
421
|
+
await asyncio.sleep(0.5)
|
|
422
|
+
await websocket.send_json({
|
|
423
|
+
"type": "analysis_result",
|
|
424
|
+
"content": {
|
|
425
|
+
"cell_count": random.randint(50, 200),
|
|
426
|
+
"tissue_type": random.choice(["Tumor", "Stroma", "Necrosis", "Normal"]),
|
|
427
|
+
"mitotic_index": random.choice(["Low", "Moderate", "High"])
|
|
428
|
+
}
|
|
429
|
+
})
|
|
430
|
+
except Exception:
|
|
431
|
+
pass
|
|
432
|
+
|
|
433
|
+
# PathoLens 2.0 Mock Endpoints
|
|
434
|
+
|
|
435
|
+
@app.get("/ai/tumor_burden")
|
|
436
|
+
async def get_tumor_burden(current_user: dict = Depends(get_current_user)):
|
|
437
|
+
await asyncio.sleep(0.8)
|
|
438
|
+
return {
|
|
439
|
+
"percentage": 68.5,
|
|
440
|
+
"description": "High tumor burden observed in ROI.",
|
|
441
|
+
"location_count": 12
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
@app.get("/ai/subtyping")
|
|
445
|
+
async def get_subtyping(current_user: dict = Depends(get_current_user)):
|
|
446
|
+
await asyncio.sleep(1.2)
|
|
447
|
+
return [
|
|
448
|
+
{"name": "Invasive Ductal Carcinoma", "probability": 0.85, "description": "Most likely subtype based on glandular patterns."},
|
|
449
|
+
{"name": "Invasive Lobular Carcinoma", "probability": 0.10, "description": "Secondary possibility."},
|
|
450
|
+
{"name": "Ductal Carcinoma In Situ", "probability": 0.05, "description": "Minor component observed."}
|
|
451
|
+
]
|
|
452
|
+
|
|
453
|
+
@app.get("/ai/toxicity")
|
|
454
|
+
async def get_toxicity(current_user: dict = Depends(get_current_user)):
|
|
455
|
+
await asyncio.sleep(1.0)
|
|
456
|
+
return [
|
|
457
|
+
{
|
|
458
|
+
"treatment_type": "Chemotherapy",
|
|
459
|
+
"risk_level": "Moderate",
|
|
460
|
+
"probability": 0.45,
|
|
461
|
+
"potential_side_effects": ["Neutropenia", "Fatigue"]
|
|
462
|
+
},
|
|
463
|
+
{
|
|
464
|
+
"treatment_type": "Immunotherapy",
|
|
465
|
+
"risk_level": "Low",
|
|
466
|
+
"probability": 0.15,
|
|
467
|
+
"potential_side_effects": ["Mild Rash"]
|
|
468
|
+
}
|
|
469
|
+
]
|
|
470
|
+
|
|
471
|
+
class OncoLLMQuery(pydantic.BaseModel):
|
|
472
|
+
query: str
|
|
473
|
+
context: dict = {}
|
|
474
|
+
|
|
475
|
+
@app.post("/ai/oncollm/query")
|
|
476
|
+
async def query_oncollm(request: OncoLLMQuery, current_user: dict = Depends(get_current_user)):
|
|
477
|
+
model_path = os.getenv("LITERT_MODEL_PATH")
|
|
478
|
+
if model_path:
|
|
479
|
+
try:
|
|
480
|
+
engine = get_litert_engine()
|
|
481
|
+
with engine.create_conversation() as conversation:
|
|
482
|
+
response = conversation.send_message(request.query)
|
|
483
|
+
|
|
484
|
+
# Safe attribute check on the conversation response
|
|
485
|
+
raw_text = ""
|
|
486
|
+
if hasattr(response, 'text'):
|
|
487
|
+
raw_text = response.text
|
|
488
|
+
elif hasattr(response, 'content'):
|
|
489
|
+
raw_text = response.content
|
|
490
|
+
else:
|
|
491
|
+
raw_text = str(response)
|
|
492
|
+
|
|
493
|
+
return {
|
|
494
|
+
"content": raw_text,
|
|
495
|
+
"references": []
|
|
496
|
+
}
|
|
497
|
+
except Exception as e:
|
|
498
|
+
logger.error("LiteRT-LM local inference failed: %s", e)
|
|
499
|
+
raise HTTPException(status_code=500, detail=f"LiteRT-LM local inference failed: {str(e)}")
|
|
500
|
+
|
|
501
|
+
await asyncio.sleep(2.0)
|
|
502
|
+
query_lower = request.query.lower()
|
|
503
|
+
|
|
504
|
+
if "treatment" in query_lower:
|
|
505
|
+
return {
|
|
506
|
+
"content": f"Hello {current_user.get('email', 'Doctor')}. Based on the high tumor burden (68.5%) and EGFR overexpression, a combination of chemotherapy and targeted therapy (e.g., Cetuximab) is recommended. However, note the moderate risk of chemotherapy-induced neutropenia.",
|
|
507
|
+
"references": [
|
|
508
|
+
{"title": "EGFR Inhibition in Squamous Cell Carcinoma", "authors": "Smith et al.", "year": 2023, "url": "#", "relevance_score": 0.95},
|
|
509
|
+
{"title": "Managing Neutropenia in High-Burden Cases", "authors": "Doe et al.", "year": 2022, "url": "#", "relevance_score": 0.88}
|
|
510
|
+
]
|
|
511
|
+
}
|
|
512
|
+
elif "prognosis" in query_lower:
|
|
513
|
+
return {
|
|
514
|
+
"content": f"For the patient case under review, the prognosis is guarded due to the 'High Risk' classification and infiltrative margins. The 5-year survival rate for this subtype with similar biomarkers is approximately 60% without aggressive intervention.",
|
|
515
|
+
"references": [
|
|
516
|
+
{"title": "Prognostic Factors in Invasive Carcinoma", "authors": "Lee et al.", "year": 2024, "url": "#", "relevance_score": 0.92}
|
|
517
|
+
]
|
|
518
|
+
}
|
|
519
|
+
else:
|
|
520
|
+
return {
|
|
521
|
+
"content": "I've analyzed the slide context. The tissue shows clear signs of malignancy with significant inflammatory infiltration. Would you like to explore specific treatment options or biomarker implications?",
|
|
522
|
+
"references": []
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
@app.get("/{full_path:path}")
|
|
526
|
+
async def serve_frontend(full_path: str):
|
|
527
|
+
"""
|
|
528
|
+
Serve the exported Next.js frontend when running in a single-container setup.
|
|
529
|
+
"""
|
|
530
|
+
if not STATIC_FRONTEND_DIR or not os.path.isdir(STATIC_FRONTEND_DIR):
|
|
531
|
+
raise HTTPException(status_code=404, detail="Not Found")
|
|
532
|
+
|
|
533
|
+
candidate = os.path.join(STATIC_FRONTEND_DIR, full_path)
|
|
534
|
+
if os.path.isfile(candidate):
|
|
535
|
+
return FileResponse(candidate)
|
|
536
|
+
|
|
537
|
+
if STATIC_INDEX and os.path.isfile(STATIC_INDEX):
|
|
538
|
+
return FileResponse(STATIC_INDEX)
|
|
539
|
+
|
|
540
|
+
raise HTTPException(status_code=404, detail="Not Found")
|
|
541
|
+
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
from sqlalchemy import Column, String, ForeignKey, DateTime, Integer
|
|
2
|
+
from sqlalchemy.sql import func
|
|
3
|
+
from .database import Base
|
|
4
|
+
|
|
5
|
+
class User(Base):
|
|
6
|
+
__tablename__ = 'users'
|
|
7
|
+
id = Column(String, primary_key=True) # Matches Auth provider UID (Firebase/Identity Platform)
|
|
8
|
+
email = Column(String, unique=True, nullable=False)
|
|
9
|
+
created_at = Column(DateTime, server_default=func.now())
|
|
10
|
+
|
|
11
|
+
class Slide(Base):
|
|
12
|
+
__tablename__ = 'slides'
|
|
13
|
+
id = Column(Integer, primary_key=True, autoincrement=True)
|
|
14
|
+
filename = Column(String, nullable=False)
|
|
15
|
+
gcs_uri = Column(String, nullable=True) # Storage path in GCS if applicable
|
|
16
|
+
owner_id = Column(String, ForeignKey('users.id', ondelete='CASCADE'), nullable=False)
|
|
17
|
+
uploaded_at = Column(DateTime, server_default=func.now())
|