Spaces:
Sleeping
Sleeping
Deploy BrainAge AI webapp with 3D brain animations, auth, chatbot, PDF reports
Browse files- .env +5 -0
- Dockerfile +23 -0
- README.md +26 -5
- app/__init__.py +0 -0
- app/config.py +72 -0
- app/db.py +50 -0
- app/main.py +46 -0
- app/models/__init__.py +6 -0
- app/models/chat.py +15 -0
- app/models/patient.py +19 -0
- app/models/scan.py +20 -0
- app/models/user.py +13 -0
- app/routers/__init__.py +0 -0
- app/routers/auth.py +53 -0
- app/routers/dashboard.py +36 -0
- app/routers/index.py +51 -0
- app/routers/medical_chat.py +80 -0
- app/routers/patients.py +138 -0
- app/routers/reports.py +27 -0
- app/routers/viewer_api.py +127 -0
- app/security.py +24 -0
- app/services/__init__.py +0 -0
- app/services/groq_client.py +39 -0
- app/services/medical_context.py +87 -0
- app/services/model.py +47 -0
- app/services/pdf_generator.py +81 -0
- app/services/pipeline.py +268 -0
- app/templates/dashboard.html +109 -0
- app/templates/index.html +280 -0
- app/templates/login.html +109 -0
- app/templates/patients/new.html +66 -0
- app/templates/patients/processing.html +33 -0
- app/templates/patients/upload.html +62 -0
- app/templates/viewer.html +270 -0
- requirements.txt +15 -0
.env
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
SESSION_SECRET=brainage-hf-space-secret-2026
|
| 2 |
+
GROQ_MODEL=llama-3.3-70b-versatile
|
| 3 |
+
ADMIN_USERNAME=admin
|
| 4 |
+
ADMIN_PASSWORD=admin
|
| 5 |
+
MODEL_REPO=bilalEthizo/BrainAge-SFCN
|
Dockerfile
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
FROM python:3.12-slim
|
| 2 |
+
|
| 3 |
+
ENV PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1 DEBIAN_FRONTEND=noninteractive
|
| 4 |
+
|
| 5 |
+
RUN apt-get update && \
|
| 6 |
+
apt-get install -y --no-install-recommends \
|
| 7 |
+
libglib2.0-0 libpango-1.0-0 libpangocairo-1.0-0 \
|
| 8 |
+
libgdk-pixbuf2.0-0 libcairo2 libffi-dev shared-mime-info && \
|
| 9 |
+
rm -rf /var/lib/apt/lists/*
|
| 10 |
+
|
| 11 |
+
WORKDIR /app
|
| 12 |
+
|
| 13 |
+
COPY requirements.txt .
|
| 14 |
+
RUN pip install --no-cache-dir torch --index-url https://download.pytorch.org/whl/cpu && \
|
| 15 |
+
pip install --no-cache-dir -r requirements.txt
|
| 16 |
+
|
| 17 |
+
COPY . .
|
| 18 |
+
|
| 19 |
+
RUN mkdir -p /app/data
|
| 20 |
+
|
| 21 |
+
EXPOSE 7860
|
| 22 |
+
|
| 23 |
+
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
|
README.md
CHANGED
|
@@ -1,10 +1,31 @@
|
|
| 1 |
---
|
| 2 |
title: BrainAge AI
|
| 3 |
-
emoji:
|
| 4 |
-
colorFrom:
|
| 5 |
-
colorTo:
|
| 6 |
sdk: docker
|
| 7 |
-
|
|
|
|
| 8 |
---
|
| 9 |
|
| 10 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
---
|
| 2 |
title: BrainAge AI
|
| 3 |
+
emoji: 🧠
|
| 4 |
+
colorFrom: indigo
|
| 5 |
+
colorTo: blue
|
| 6 |
sdk: docker
|
| 7 |
+
app_port: 7860
|
| 8 |
+
pinned: true
|
| 9 |
---
|
| 10 |
|
| 11 |
+
# BrainAge AI — Advanced 3D Brain Age Prediction
|
| 12 |
+
|
| 13 |
+
Interactive web application for brain age prediction using deep learning.
|
| 14 |
+
|
| 15 |
+
## Features
|
| 16 |
+
- **3D Brain Visualization** with NiiVue and Three.js
|
| 17 |
+
- **AI-Powered Age Prediction** using SFCN dual-branch model
|
| 18 |
+
- **70+ Brain Region Analysis** with volumetric measurements and z-scores
|
| 19 |
+
- **LLM-Powered Clinical Insights** via Groq (Llama 3.3 70B)
|
| 20 |
+
- **Patient-Specific AI Chatbot** for interactive clinical Q&A
|
| 21 |
+
- **PDF Report Generation** with AI narratives
|
| 22 |
+
- **Secure Authentication** with session-based login
|
| 23 |
+
|
| 24 |
+
## Model
|
| 25 |
+
- Architecture: SFCN 3D CNN + Tabular MLP (BrainAgeDual)
|
| 26 |
+
- Training data: 6,050 healthy T1w MRIs across 12 datasets
|
| 27 |
+
- Lifespan MAE: 2.5 years | Pediatric MAE: 1.05 years
|
| 28 |
+
- Model repo: [bilalEthizo/BrainAge-SFCN](https://huggingface.co/bilalEthizo/BrainAge-SFCN)
|
| 29 |
+
|
| 30 |
+
## Login
|
| 31 |
+
Default credentials: `admin` / `admin`
|
app/__init__.py
ADDED
|
File without changes
|
app/config.py
ADDED
|
@@ -0,0 +1,72 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Central configuration loaded from .env via pydantic-settings."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import os
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
| 6 |
+
|
| 7 |
+
_APP_ROOT = Path(__file__).resolve().parent.parent
|
| 8 |
+
_DEFAULT_ENV = _APP_ROOT / ".env"
|
| 9 |
+
_ENV_FILE = os.environ.get("BRAINAGE_ENV_FILE", str(_DEFAULT_ENV))
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Settings(BaseSettings):
|
| 13 |
+
model_config = SettingsConfigDict(
|
| 14 |
+
env_file=_ENV_FILE, env_file_encoding="utf-8", extra="ignore",
|
| 15 |
+
)
|
| 16 |
+
|
| 17 |
+
APP_NAME: str = "BrainAge AI"
|
| 18 |
+
SESSION_SECRET: str = "dev-secret-change-me"
|
| 19 |
+
|
| 20 |
+
GROQ_API_KEY: str = ""
|
| 21 |
+
GROQ_MODEL: str = "llama-3.3-70b-versatile"
|
| 22 |
+
|
| 23 |
+
ADMIN_USERNAME: str = "admin"
|
| 24 |
+
ADMIN_PASSWORD: str = "admin"
|
| 25 |
+
|
| 26 |
+
DATA_ROOT: str = str(_APP_ROOT / "data")
|
| 27 |
+
DB_URL: str = f"sqlite:///{_APP_ROOT / 'data' / 'webapp.db'}"
|
| 28 |
+
|
| 29 |
+
MODEL_REPO: str = "bilalEthizo/BrainAge-SFCN"
|
| 30 |
+
HF_TOKEN: str = ""
|
| 31 |
+
|
| 32 |
+
COOKIE_SECURE: int = 0
|
| 33 |
+
MAX_UPLOAD_MB: int = 200
|
| 34 |
+
TRUSTED_HOSTS: str = "*"
|
| 35 |
+
|
| 36 |
+
@property
|
| 37 |
+
def data_root(self) -> Path:
|
| 38 |
+
return Path(self.DATA_ROOT)
|
| 39 |
+
|
| 40 |
+
@property
|
| 41 |
+
def uploads_dir(self) -> Path:
|
| 42 |
+
return self.data_root / "uploads"
|
| 43 |
+
|
| 44 |
+
@property
|
| 45 |
+
def outputs_dir(self) -> Path:
|
| 46 |
+
return self.data_root / "outputs"
|
| 47 |
+
|
| 48 |
+
@property
|
| 49 |
+
def reports_dir(self) -> Path:
|
| 50 |
+
return self.data_root / "reports"
|
| 51 |
+
|
| 52 |
+
@property
|
| 53 |
+
def static_dir(self) -> Path:
|
| 54 |
+
return _APP_ROOT / "app" / "static"
|
| 55 |
+
|
| 56 |
+
@property
|
| 57 |
+
def templates_dir(self) -> Path:
|
| 58 |
+
return _APP_ROOT / "app" / "templates"
|
| 59 |
+
|
| 60 |
+
@property
|
| 61 |
+
def model_dir(self) -> Path:
|
| 62 |
+
return self.data_root / "model"
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
settings = Settings()
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
def ensure_dirs() -> None:
|
| 69 |
+
for p in (settings.data_root, settings.uploads_dir,
|
| 70 |
+
settings.outputs_dir, settings.reports_dir,
|
| 71 |
+
settings.model_dir):
|
| 72 |
+
p.mkdir(parents=True, exist_ok=True)
|
app/db.py
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""SQLAlchemy 2.0 engine + session factory."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from contextlib import contextmanager
|
| 4 |
+
from typing import Iterator
|
| 5 |
+
|
| 6 |
+
from sqlalchemy import create_engine
|
| 7 |
+
from sqlalchemy.orm import DeclarativeBase, sessionmaker, Session
|
| 8 |
+
|
| 9 |
+
from .config import settings
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
class Base(DeclarativeBase):
|
| 13 |
+
pass
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
engine = create_engine(
|
| 17 |
+
settings.DB_URL, echo=False, future=True,
|
| 18 |
+
connect_args={"check_same_thread": False}
|
| 19 |
+
if settings.DB_URL.startswith("sqlite") else {},
|
| 20 |
+
)
|
| 21 |
+
SessionLocal = sessionmaker(
|
| 22 |
+
bind=engine, autoflush=False, autocommit=False,
|
| 23 |
+
expire_on_commit=False, class_=Session,
|
| 24 |
+
)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def get_db() -> Iterator[Session]:
|
| 28 |
+
db = SessionLocal()
|
| 29 |
+
try:
|
| 30 |
+
yield db
|
| 31 |
+
finally:
|
| 32 |
+
db.close()
|
| 33 |
+
|
| 34 |
+
|
| 35 |
+
@contextmanager
|
| 36 |
+
def session_scope() -> Iterator[Session]:
|
| 37 |
+
db = SessionLocal()
|
| 38 |
+
try:
|
| 39 |
+
yield db
|
| 40 |
+
db.commit()
|
| 41 |
+
except Exception:
|
| 42 |
+
db.rollback()
|
| 43 |
+
raise
|
| 44 |
+
finally:
|
| 45 |
+
db.close()
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
def init_db() -> None:
|
| 49 |
+
from . import models # noqa: F401
|
| 50 |
+
Base.metadata.create_all(bind=engine)
|
app/main.py
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""FastAPI application factory."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from contextlib import asynccontextmanager
|
| 4 |
+
from fastapi import FastAPI
|
| 5 |
+
from fastapi.staticfiles import StaticFiles
|
| 6 |
+
from starlette.middleware.sessions import SessionMiddleware
|
| 7 |
+
|
| 8 |
+
from .config import settings, ensure_dirs
|
| 9 |
+
from .db import init_db
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
@asynccontextmanager
|
| 13 |
+
async def lifespan(app: FastAPI):
|
| 14 |
+
ensure_dirs()
|
| 15 |
+
init_db()
|
| 16 |
+
# Seed admin user
|
| 17 |
+
from .db import session_scope
|
| 18 |
+
from .routers.auth import seed_admin
|
| 19 |
+
with session_scope() as db:
|
| 20 |
+
seed_admin(db)
|
| 21 |
+
yield
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
def create_app() -> FastAPI:
|
| 25 |
+
app = FastAPI(title=settings.APP_NAME, lifespan=lifespan)
|
| 26 |
+
app.add_middleware(SessionMiddleware,
|
| 27 |
+
secret_key=settings.SESSION_SECRET,
|
| 28 |
+
same_site="lax",
|
| 29 |
+
https_only=bool(settings.COOKIE_SECURE))
|
| 30 |
+
|
| 31 |
+
app.mount("/static", StaticFiles(directory=str(settings.static_dir)),
|
| 32 |
+
name="static")
|
| 33 |
+
|
| 34 |
+
from .routers import auth, index, dashboard, patients, viewer_api, medical_chat, reports
|
| 35 |
+
app.include_router(index.router)
|
| 36 |
+
app.include_router(auth.router)
|
| 37 |
+
app.include_router(dashboard.router)
|
| 38 |
+
app.include_router(patients.router)
|
| 39 |
+
app.include_router(viewer_api.router)
|
| 40 |
+
app.include_router(medical_chat.router)
|
| 41 |
+
app.include_router(reports.router)
|
| 42 |
+
|
| 43 |
+
return app
|
| 44 |
+
|
| 45 |
+
|
| 46 |
+
app = create_app()
|
app/models/__init__.py
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from .user import User
|
| 2 |
+
from .patient import Patient
|
| 3 |
+
from .scan import Scan
|
| 4 |
+
from .chat import ChatMessage
|
| 5 |
+
|
| 6 |
+
__all__ = ["User", "Patient", "Scan", "ChatMessage"]
|
app/models/chat.py
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from sqlalchemy import Column, Integer, String, Text, DateTime, func
|
| 3 |
+
from ..db import Base
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class ChatMessage(Base):
|
| 7 |
+
__tablename__ = "chat_messages"
|
| 8 |
+
|
| 9 |
+
id = Column(Integer, primary_key=True)
|
| 10 |
+
scope = Column(String(16), default="landing")
|
| 11 |
+
scan_id = Column(Integer, nullable=True)
|
| 12 |
+
role = Column(String(16), nullable=False)
|
| 13 |
+
content = Column(Text, nullable=False)
|
| 14 |
+
session_key = Column(String(64))
|
| 15 |
+
created_at = Column(DateTime, server_default=func.now())
|
app/models/patient.py
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime, func
|
| 3 |
+
from sqlalchemy.orm import relationship
|
| 4 |
+
from ..db import Base
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Patient(Base):
|
| 8 |
+
__tablename__ = "patients"
|
| 9 |
+
|
| 10 |
+
id = Column(Integer, primary_key=True)
|
| 11 |
+
mrn = Column(String(64), unique=True, index=True)
|
| 12 |
+
full_name = Column(String(128), nullable=False)
|
| 13 |
+
date_of_birth = Column(String(16))
|
| 14 |
+
sex = Column(String(1), default="U")
|
| 15 |
+
age_years = Column(Float)
|
| 16 |
+
created_by_id = Column(Integer, ForeignKey("users.id"))
|
| 17 |
+
created_at = Column(DateTime, server_default=func.now())
|
| 18 |
+
|
| 19 |
+
scans = relationship("Scan", back_populates="patient", order_by="desc(Scan.id)")
|
app/models/scan.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from sqlalchemy import Column, Integer, String, Float, ForeignKey, DateTime, Text, func
|
| 3 |
+
from sqlalchemy.orm import relationship
|
| 4 |
+
from ..db import Base
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class Scan(Base):
|
| 8 |
+
__tablename__ = "scans"
|
| 9 |
+
|
| 10 |
+
id = Column(Integer, primary_key=True)
|
| 11 |
+
patient_id = Column(Integer, ForeignKey("patients.id"), nullable=False)
|
| 12 |
+
upload_path = Column(String(512))
|
| 13 |
+
status = Column(String(16), default="uploaded") # uploaded|processing|complete|failed
|
| 14 |
+
predicted_age = Column(Float)
|
| 15 |
+
brain_age_gap = Column(Float)
|
| 16 |
+
outputs_dir = Column(String(512))
|
| 17 |
+
error_message = Column(Text)
|
| 18 |
+
created_at = Column(DateTime, server_default=func.now())
|
| 19 |
+
|
| 20 |
+
patient = relationship("Patient", back_populates="scans")
|
app/models/user.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from __future__ import annotations
|
| 2 |
+
from sqlalchemy import Column, Integer, String
|
| 3 |
+
from ..db import Base
|
| 4 |
+
|
| 5 |
+
|
| 6 |
+
class User(Base):
|
| 7 |
+
__tablename__ = "users"
|
| 8 |
+
|
| 9 |
+
id = Column(Integer, primary_key=True)
|
| 10 |
+
username = Column(String(64), unique=True, nullable=False, index=True)
|
| 11 |
+
password_hash = Column(String(256), nullable=False)
|
| 12 |
+
full_name = Column(String(128), default="")
|
| 13 |
+
role = Column(String(16), default="staff")
|
app/routers/__init__.py
ADDED
|
File without changes
|
app/routers/auth.py
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Authentication routes: login, logout."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from fastapi import APIRouter, Request, Depends, Form
|
| 4 |
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 5 |
+
from fastapi.templating import Jinja2Templates
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
|
| 8 |
+
from ..db import get_db
|
| 9 |
+
from ..config import settings
|
| 10 |
+
from ..models import User
|
| 11 |
+
from ..security import verify_password, hash_password
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
templates = Jinja2Templates(directory=str(settings.templates_dir))
|
| 15 |
+
|
| 16 |
+
|
| 17 |
+
def seed_admin(db: Session) -> None:
|
| 18 |
+
exists = db.query(User).filter(User.username == settings.ADMIN_USERNAME).first()
|
| 19 |
+
if not exists:
|
| 20 |
+
admin = User(
|
| 21 |
+
username=settings.ADMIN_USERNAME,
|
| 22 |
+
password_hash=hash_password(settings.ADMIN_PASSWORD),
|
| 23 |
+
full_name="Administrator",
|
| 24 |
+
role="admin",
|
| 25 |
+
)
|
| 26 |
+
db.add(admin)
|
| 27 |
+
db.commit()
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
@router.get("/login", response_class=HTMLResponse)
|
| 31 |
+
async def login_page(request: Request):
|
| 32 |
+
if request.session.get("user_id"):
|
| 33 |
+
return RedirectResponse("/dashboard", status_code=302)
|
| 34 |
+
return templates.TemplateResponse(request, "login.html", {"error": None})
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
@router.post("/login")
|
| 38 |
+
async def login_submit(request: Request, username: str = Form(...),
|
| 39 |
+
password: str = Form(...), db: Session = Depends(get_db)):
|
| 40 |
+
user = db.query(User).filter(User.username == username).first()
|
| 41 |
+
if user and verify_password(password, user.password_hash):
|
| 42 |
+
request.session["user_id"] = user.id
|
| 43 |
+
request.session["username"] = user.username
|
| 44 |
+
request.session["role"] = user.role
|
| 45 |
+
return RedirectResponse("/dashboard", status_code=302)
|
| 46 |
+
return templates.TemplateResponse(request, "login.html",
|
| 47 |
+
{"error": "Invalid username or password"})
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@router.get("/logout")
|
| 51 |
+
async def logout(request: Request):
|
| 52 |
+
request.session.clear()
|
| 53 |
+
return RedirectResponse("/", status_code=302)
|
app/routers/dashboard.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Dashboard with patient list and scan overview."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from fastapi import APIRouter, Request, Depends
|
| 4 |
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 5 |
+
from fastapi.templating import Jinja2Templates
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
from sqlalchemy import func
|
| 8 |
+
|
| 9 |
+
from ..db import get_db
|
| 10 |
+
from ..config import settings
|
| 11 |
+
from ..models import Patient, Scan
|
| 12 |
+
from ..security import require_login
|
| 13 |
+
|
| 14 |
+
router = APIRouter()
|
| 15 |
+
templates = Jinja2Templates(directory=str(settings.templates_dir))
|
| 16 |
+
|
| 17 |
+
|
| 18 |
+
@router.get("/dashboard", response_class=HTMLResponse)
|
| 19 |
+
async def dashboard(request: Request, db: Session = Depends(get_db)):
|
| 20 |
+
redirect = require_login(request)
|
| 21 |
+
if redirect:
|
| 22 |
+
return redirect
|
| 23 |
+
|
| 24 |
+
patients = db.query(Patient).order_by(Patient.id.desc()).all()
|
| 25 |
+
total_scans = db.query(func.count(Scan.id)).scalar() or 0
|
| 26 |
+
complete_scans = db.query(func.count(Scan.id)).filter(Scan.status == "complete").scalar() or 0
|
| 27 |
+
avg_bag = db.query(func.avg(Scan.brain_age_gap)).filter(
|
| 28 |
+
Scan.brain_age_gap.isnot(None)).scalar()
|
| 29 |
+
|
| 30 |
+
return templates.TemplateResponse(request, "dashboard.html", {
|
| 31 |
+
"patients": patients,
|
| 32 |
+
"total_scans": total_scans,
|
| 33 |
+
"complete_scans": complete_scans,
|
| 34 |
+
"avg_bag": f"{avg_bag:.1f}" if avg_bag else "N/A",
|
| 35 |
+
"username": request.session.get("username", ""),
|
| 36 |
+
})
|
app/routers/index.py
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Landing page and landing chat."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import json
|
| 4 |
+
from fastapi import APIRouter, Request
|
| 5 |
+
from fastapi.responses import HTMLResponse
|
| 6 |
+
from starlette.responses import StreamingResponse
|
| 7 |
+
from fastapi.templating import Jinja2Templates
|
| 8 |
+
from ..config import settings
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
templates = Jinja2Templates(directory=str(settings.templates_dir))
|
| 12 |
+
|
| 13 |
+
SYSTEM_PROMPT = """You are the BrainAge AI assistant on the landing page.
|
| 14 |
+
You help visitors understand the BrainAge platform — a brain age prediction
|
| 15 |
+
system using 3D MRI analysis with deep learning (SFCN architecture).
|
| 16 |
+
|
| 17 |
+
Key facts:
|
| 18 |
+
- Trained on 6,050 healthy T1w brain MRIs across 12 datasets (ages 0-86)
|
| 19 |
+
- Lifespan model MAE: 2.5 years
|
| 20 |
+
- Pediatric model (0-25y) MAE: 1.05 years
|
| 21 |
+
- Analyzes 70+ brain regions with volumetric measurements
|
| 22 |
+
- Generates AI-powered clinical explanations
|
| 23 |
+
- Produces PDF reports
|
| 24 |
+
|
| 25 |
+
Be helpful, concise, and professional. If asked clinical questions about
|
| 26 |
+
specific patients, explain they need to log in and upload an MRI first."""
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.get("/", response_class=HTMLResponse)
|
| 30 |
+
async def landing(request: Request):
|
| 31 |
+
return templates.TemplateResponse(request, "index.html", {})
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
@router.post("/api/chat/landing")
|
| 35 |
+
async def landing_chat(request: Request):
|
| 36 |
+
body = await request.json()
|
| 37 |
+
user_msg = body.get("message", "")
|
| 38 |
+
|
| 39 |
+
messages = [
|
| 40 |
+
{"role": "system", "content": SYSTEM_PROMPT},
|
| 41 |
+
{"role": "user", "content": user_msg},
|
| 42 |
+
]
|
| 43 |
+
|
| 44 |
+
if not settings.GROQ_API_KEY:
|
| 45 |
+
async def no_key():
|
| 46 |
+
yield f"data: {json.dumps({'content': 'Chat is not configured. Please set GROQ_API_KEY.'})}\n\n"
|
| 47 |
+
yield "data: [DONE]\n\n"
|
| 48 |
+
return StreamingResponse(no_key(), media_type="text/event-stream")
|
| 49 |
+
|
| 50 |
+
from ..services.groq_client import stream_chat
|
| 51 |
+
return StreamingResponse(stream_chat(messages), media_type="text/event-stream")
|
app/routers/medical_chat.py
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patient-scoped medical chatbot with SSE streaming."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import json
|
| 4 |
+
from fastapi import APIRouter, Request, Depends
|
| 5 |
+
from starlette.responses import StreamingResponse, JSONResponse
|
| 6 |
+
from sqlalchemy.orm import Session
|
| 7 |
+
|
| 8 |
+
from ..db import get_db
|
| 9 |
+
from ..config import settings
|
| 10 |
+
from ..models import ChatMessage
|
| 11 |
+
from ..services.medical_context import build_context
|
| 12 |
+
|
| 13 |
+
router = APIRouter()
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
@router.post("/api/chat/patient/{scan_id}")
|
| 17 |
+
async def patient_chat(request: Request, scan_id: int,
|
| 18 |
+
db: Session = Depends(get_db)):
|
| 19 |
+
body = await request.json()
|
| 20 |
+
user_msg = body.get("message", "")
|
| 21 |
+
|
| 22 |
+
if not settings.GROQ_API_KEY:
|
| 23 |
+
async def no_key():
|
| 24 |
+
yield f"data: {json.dumps({'content': 'Chat not configured. Set GROQ_API_KEY.'})}\n\n"
|
| 25 |
+
yield "data: [DONE]\n\n"
|
| 26 |
+
return StreamingResponse(no_key(), media_type="text/event-stream")
|
| 27 |
+
|
| 28 |
+
ctx = build_context(db, scan_id)
|
| 29 |
+
session_key = request.session.get("_id", "anon")
|
| 30 |
+
|
| 31 |
+
# Save user message
|
| 32 |
+
db.add(ChatMessage(scope="patient", scan_id=scan_id, role="user",
|
| 33 |
+
content=user_msg, session_key=session_key))
|
| 34 |
+
db.commit()
|
| 35 |
+
|
| 36 |
+
# Load conversation history (last 10 messages)
|
| 37 |
+
history = db.query(ChatMessage).filter(
|
| 38 |
+
ChatMessage.scan_id == scan_id,
|
| 39 |
+
ChatMessage.session_key == session_key,
|
| 40 |
+
).order_by(ChatMessage.id.desc()).limit(10).all()
|
| 41 |
+
history = list(reversed(history))
|
| 42 |
+
|
| 43 |
+
messages = [{"role": "system", "content": ctx["system"]}]
|
| 44 |
+
for m in history:
|
| 45 |
+
messages.append({"role": m.role, "content": m.content})
|
| 46 |
+
|
| 47 |
+
from ..services.groq_client import stream_chat
|
| 48 |
+
|
| 49 |
+
collected = []
|
| 50 |
+
|
| 51 |
+
def stream_and_save():
|
| 52 |
+
for chunk in stream_chat(messages):
|
| 53 |
+
if chunk.startswith("data: {"):
|
| 54 |
+
try:
|
| 55 |
+
data = json.loads(chunk[6:].strip())
|
| 56 |
+
collected.append(data.get("content", ""))
|
| 57 |
+
except Exception:
|
| 58 |
+
pass
|
| 59 |
+
yield chunk
|
| 60 |
+
# Save assistant response
|
| 61 |
+
full_response = "".join(collected)
|
| 62 |
+
if full_response:
|
| 63 |
+
db.add(ChatMessage(scope="patient", scan_id=scan_id, role="assistant",
|
| 64 |
+
content=full_response, session_key=session_key))
|
| 65 |
+
db.commit()
|
| 66 |
+
|
| 67 |
+
return StreamingResponse(stream_and_save(), media_type="text/event-stream")
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
@router.get("/api/chat/patient/{scan_id}/history")
|
| 71 |
+
async def chat_history(request: Request, scan_id: int,
|
| 72 |
+
db: Session = Depends(get_db)):
|
| 73 |
+
session_key = request.session.get("_id", "anon")
|
| 74 |
+
msgs = db.query(ChatMessage).filter(
|
| 75 |
+
ChatMessage.scan_id == scan_id,
|
| 76 |
+
ChatMessage.session_key == session_key,
|
| 77 |
+
).order_by(ChatMessage.id).limit(50).all()
|
| 78 |
+
return JSONResponse([
|
| 79 |
+
{"role": m.role, "content": m.content} for m in msgs
|
| 80 |
+
])
|
app/routers/patients.py
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Patient CRUD and MRI upload."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import shutil, uuid
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from fastapi import APIRouter, Request, Depends, Form, UploadFile, File
|
| 6 |
+
from fastapi.responses import HTMLResponse, RedirectResponse
|
| 7 |
+
from fastapi.templating import Jinja2Templates
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
from ..db import get_db
|
| 11 |
+
from ..config import settings
|
| 12 |
+
from ..models import Patient, Scan
|
| 13 |
+
from ..security import require_login
|
| 14 |
+
|
| 15 |
+
router = APIRouter()
|
| 16 |
+
templates = Jinja2Templates(directory=str(settings.templates_dir))
|
| 17 |
+
|
| 18 |
+
|
| 19 |
+
@router.get("/patients/new", response_class=HTMLResponse)
|
| 20 |
+
async def new_patient_form(request: Request):
|
| 21 |
+
redirect = require_login(request)
|
| 22 |
+
if redirect:
|
| 23 |
+
return redirect
|
| 24 |
+
return templates.TemplateResponse(request, "patients/new.html", {
|
| 25 |
+
"username": request.session.get("username", ""),
|
| 26 |
+
})
|
| 27 |
+
|
| 28 |
+
|
| 29 |
+
@router.post("/patients/new")
|
| 30 |
+
async def create_patient(request: Request,
|
| 31 |
+
full_name: str = Form(...),
|
| 32 |
+
mrn: str = Form(""),
|
| 33 |
+
date_of_birth: str = Form(""),
|
| 34 |
+
sex: str = Form("U"),
|
| 35 |
+
age_years: float = Form(0),
|
| 36 |
+
db: Session = Depends(get_db)):
|
| 37 |
+
redirect = require_login(request)
|
| 38 |
+
if redirect:
|
| 39 |
+
return redirect
|
| 40 |
+
|
| 41 |
+
if not mrn:
|
| 42 |
+
mrn = f"MRN-{uuid.uuid4().hex[:8].upper()}"
|
| 43 |
+
|
| 44 |
+
patient = Patient(
|
| 45 |
+
full_name=full_name, mrn=mrn, date_of_birth=date_of_birth,
|
| 46 |
+
sex=sex, age_years=age_years or None,
|
| 47 |
+
created_by_id=request.session.get("user_id"),
|
| 48 |
+
)
|
| 49 |
+
db.add(patient)
|
| 50 |
+
db.commit()
|
| 51 |
+
return RedirectResponse(f"/patients/{patient.id}/upload", status_code=302)
|
| 52 |
+
|
| 53 |
+
|
| 54 |
+
@router.get("/patients/{pid}/upload", response_class=HTMLResponse)
|
| 55 |
+
async def upload_form(request: Request, pid: int, db: Session = Depends(get_db)):
|
| 56 |
+
redirect = require_login(request)
|
| 57 |
+
if redirect:
|
| 58 |
+
return redirect
|
| 59 |
+
patient = db.query(Patient).filter(Patient.id == pid).first()
|
| 60 |
+
if not patient:
|
| 61 |
+
return RedirectResponse("/dashboard", status_code=302)
|
| 62 |
+
return templates.TemplateResponse(request, "patients/upload.html", {
|
| 63 |
+
"patient": patient,
|
| 64 |
+
"username": request.session.get("username", ""),
|
| 65 |
+
})
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
@router.post("/patients/{pid}/scans")
|
| 69 |
+
async def upload_scan(request: Request, pid: int,
|
| 70 |
+
mri_file: UploadFile = File(...),
|
| 71 |
+
db: Session = Depends(get_db)):
|
| 72 |
+
redirect = require_login(request)
|
| 73 |
+
if redirect:
|
| 74 |
+
return redirect
|
| 75 |
+
|
| 76 |
+
patient = db.query(Patient).filter(Patient.id == pid).first()
|
| 77 |
+
if not patient:
|
| 78 |
+
return RedirectResponse("/dashboard", status_code=302)
|
| 79 |
+
|
| 80 |
+
# Save uploaded file
|
| 81 |
+
upload_dir = settings.uploads_dir / str(pid)
|
| 82 |
+
upload_dir.mkdir(parents=True, exist_ok=True)
|
| 83 |
+
fname = f"{uuid.uuid4().hex[:8]}_{mri_file.filename}"
|
| 84 |
+
dest = upload_dir / fname
|
| 85 |
+
with open(dest, "wb") as f:
|
| 86 |
+
shutil.copyfileobj(mri_file.file, f)
|
| 87 |
+
|
| 88 |
+
# Create scan record
|
| 89 |
+
scan = Scan(patient_id=pid, upload_path=str(dest), status="uploaded")
|
| 90 |
+
db.add(scan)
|
| 91 |
+
db.commit()
|
| 92 |
+
|
| 93 |
+
# Show processing page
|
| 94 |
+
return templates.TemplateResponse(request, "patients/processing.html", {
|
| 95 |
+
"scan": scan, "patient": patient,
|
| 96 |
+
"username": request.session.get("username", ""),
|
| 97 |
+
})
|
| 98 |
+
|
| 99 |
+
|
| 100 |
+
@router.get("/scans/{scan_id}/process")
|
| 101 |
+
async def process_scan(request: Request, scan_id: int,
|
| 102 |
+
db: Session = Depends(get_db)):
|
| 103 |
+
"""Run the pipeline synchronously (blocking)."""
|
| 104 |
+
redirect = require_login(request)
|
| 105 |
+
if redirect:
|
| 106 |
+
return redirect
|
| 107 |
+
|
| 108 |
+
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
| 109 |
+
if not scan:
|
| 110 |
+
return RedirectResponse("/dashboard", status_code=302)
|
| 111 |
+
|
| 112 |
+
try:
|
| 113 |
+
from ..services.pipeline import run_pipeline
|
| 114 |
+
run_pipeline(db, scan)
|
| 115 |
+
except Exception as e:
|
| 116 |
+
scan.status = "failed"
|
| 117 |
+
scan.error_message = str(e)[:500]
|
| 118 |
+
db.commit()
|
| 119 |
+
|
| 120 |
+
return RedirectResponse(f"/scans/{scan_id}/view", status_code=302)
|
| 121 |
+
|
| 122 |
+
|
| 123 |
+
@router.get("/scans/{scan_id}/view", response_class=HTMLResponse)
|
| 124 |
+
async def view_scan(request: Request, scan_id: int,
|
| 125 |
+
db: Session = Depends(get_db)):
|
| 126 |
+
redirect = require_login(request)
|
| 127 |
+
if redirect:
|
| 128 |
+
return redirect
|
| 129 |
+
|
| 130 |
+
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
| 131 |
+
if not scan:
|
| 132 |
+
return RedirectResponse("/dashboard", status_code=302)
|
| 133 |
+
|
| 134 |
+
patient = db.query(Patient).filter(Patient.id == scan.patient_id).first()
|
| 135 |
+
return templates.TemplateResponse(request, "viewer.html", {
|
| 136 |
+
"scan": scan, "patient": patient,
|
| 137 |
+
"username": request.session.get("username", ""),
|
| 138 |
+
})
|
app/routers/reports.py
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""PDF report generation."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from fastapi import APIRouter, Request, Depends
|
| 4 |
+
from fastapi.responses import Response, RedirectResponse
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
|
| 7 |
+
from ..db import get_db
|
| 8 |
+
from ..security import require_login
|
| 9 |
+
|
| 10 |
+
router = APIRouter()
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
@router.get("/reports/{scan_id}/pdf")
|
| 14 |
+
async def download_report(request: Request, scan_id: int,
|
| 15 |
+
db: Session = Depends(get_db)):
|
| 16 |
+
redirect = require_login(request)
|
| 17 |
+
if redirect:
|
| 18 |
+
return redirect
|
| 19 |
+
|
| 20 |
+
from ..services.pdf_generator import generate_report_pdf
|
| 21 |
+
content = generate_report_pdf(db, scan_id)
|
| 22 |
+
|
| 23 |
+
# If WeasyPrint produced PDF, serve as PDF; otherwise fallback to HTML
|
| 24 |
+
if content[:5] == b'%PDF-':
|
| 25 |
+
return Response(content=content, media_type="application/pdf",
|
| 26 |
+
headers={"Content-Disposition": f"attachment; filename=brainage_report_{scan_id}.pdf"})
|
| 27 |
+
return Response(content=content, media_type="text/html")
|
app/routers/viewer_api.py
ADDED
|
@@ -0,0 +1,127 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""API endpoints for the 3D viewer: files, meshes, regions, subject data."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import csv, json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from fastapi import APIRouter, Request, Depends
|
| 6 |
+
from fastapi.responses import FileResponse, JSONResponse
|
| 7 |
+
from sqlalchemy.orm import Session
|
| 8 |
+
|
| 9 |
+
from ..db import get_db
|
| 10 |
+
from ..models import Scan, Patient
|
| 11 |
+
|
| 12 |
+
router = APIRouter()
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def _scan_dir(db: Session, scan_id: int) -> Path | None:
|
| 16 |
+
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
| 17 |
+
if scan and scan.outputs_dir:
|
| 18 |
+
p = Path(scan.outputs_dir)
|
| 19 |
+
if p.exists():
|
| 20 |
+
return p
|
| 21 |
+
return None
|
| 22 |
+
|
| 23 |
+
|
| 24 |
+
@router.get("/api/subject/{scan_id}")
|
| 25 |
+
async def api_subject(scan_id: int, db: Session = Depends(get_db)):
|
| 26 |
+
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
| 27 |
+
if not scan:
|
| 28 |
+
return JSONResponse({"error": "Scan not found"}, 404)
|
| 29 |
+
|
| 30 |
+
patient = db.query(Patient).filter(Patient.id == scan.patient_id).first()
|
| 31 |
+
out_dir = Path(scan.outputs_dir) if scan.outputs_dir else None
|
| 32 |
+
|
| 33 |
+
data = {
|
| 34 |
+
"scan_id": scan.id,
|
| 35 |
+
"status": scan.status,
|
| 36 |
+
"patient": {
|
| 37 |
+
"name": patient.full_name if patient else "",
|
| 38 |
+
"age": patient.age_years if patient else None,
|
| 39 |
+
"sex": patient.sex if patient else "U",
|
| 40 |
+
},
|
| 41 |
+
"predicted_age": scan.predicted_age,
|
| 42 |
+
"brain_age_gap": scan.brain_age_gap,
|
| 43 |
+
}
|
| 44 |
+
|
| 45 |
+
if out_dir and out_dir.exists():
|
| 46 |
+
meta_f = out_dir / "subject_meta.json"
|
| 47 |
+
if meta_f.exists():
|
| 48 |
+
data["meta"] = json.loads(meta_f.read_text())
|
| 49 |
+
|
| 50 |
+
return JSONResponse(data)
|
| 51 |
+
|
| 52 |
+
|
| 53 |
+
@router.get("/files/{scan_id}/{filename}")
|
| 54 |
+
async def serve_file(scan_id: int, filename: str,
|
| 55 |
+
db: Session = Depends(get_db)):
|
| 56 |
+
d = _scan_dir(db, scan_id)
|
| 57 |
+
if not d:
|
| 58 |
+
return JSONResponse({"error": "Not found"}, 404)
|
| 59 |
+
fpath = d / filename
|
| 60 |
+
if not fpath.exists():
|
| 61 |
+
return JSONResponse({"error": f"{filename} not found"}, 404)
|
| 62 |
+
return FileResponse(str(fpath))
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
@router.get("/api/meshes/{scan_id}")
|
| 66 |
+
async def api_meshes(scan_id: int, db: Session = Depends(get_db)):
|
| 67 |
+
d = _scan_dir(db, scan_id)
|
| 68 |
+
if not d:
|
| 69 |
+
return JSONResponse({"meshes": []})
|
| 70 |
+
manifest = d / "mesh_manifest.json"
|
| 71 |
+
if manifest.exists():
|
| 72 |
+
return JSONResponse(json.loads(manifest.read_text()))
|
| 73 |
+
return JSONResponse({"meshes": []})
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
@router.get("/mesh/{scan_id}/{filename}")
|
| 77 |
+
async def serve_mesh(scan_id: int, filename: str,
|
| 78 |
+
db: Session = Depends(get_db)):
|
| 79 |
+
d = _scan_dir(db, scan_id)
|
| 80 |
+
if not d:
|
| 81 |
+
return JSONResponse({"error": "Not found"}, 404)
|
| 82 |
+
fpath = d / "meshes" / filename
|
| 83 |
+
if not fpath.exists():
|
| 84 |
+
fpath = d / filename
|
| 85 |
+
if not fpath.exists():
|
| 86 |
+
return JSONResponse({"error": "Mesh not found"}, 404)
|
| 87 |
+
return FileResponse(str(fpath), media_type="text/plain")
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
@router.get("/api/regions/{scan_id}")
|
| 91 |
+
async def api_regions(scan_id: int, db: Session = Depends(get_db)):
|
| 92 |
+
"""Full clinical region analysis."""
|
| 93 |
+
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
| 94 |
+
if not scan:
|
| 95 |
+
return JSONResponse({"error": "Scan not found"}, 404)
|
| 96 |
+
|
| 97 |
+
patient = db.query(Patient).filter(Patient.id == scan.patient_id).first()
|
| 98 |
+
d = Path(scan.outputs_dir) if scan.outputs_dir else None
|
| 99 |
+
|
| 100 |
+
result = {
|
| 101 |
+
"scan_id": scan.id,
|
| 102 |
+
"predicted_age": scan.predicted_age,
|
| 103 |
+
"brain_age_gap": scan.brain_age_gap,
|
| 104 |
+
"patient": {
|
| 105 |
+
"name": patient.full_name if patient else "",
|
| 106 |
+
"age": patient.age_years if patient else None,
|
| 107 |
+
"sex": patient.sex if patient else "U",
|
| 108 |
+
},
|
| 109 |
+
"regions": [],
|
| 110 |
+
"measurements": [],
|
| 111 |
+
}
|
| 112 |
+
|
| 113 |
+
if d and d.exists():
|
| 114 |
+
meas_f = d / "measurements.csv"
|
| 115 |
+
if meas_f.exists():
|
| 116 |
+
with open(meas_f) as f:
|
| 117 |
+
result["measurements"] = list(csv.DictReader(f))
|
| 118 |
+
|
| 119 |
+
scores_f = d / "region_scores.json"
|
| 120 |
+
if scores_f.exists():
|
| 121 |
+
result["regions"] = json.loads(scores_f.read_text())
|
| 122 |
+
|
| 123 |
+
meta_f = d / "subject_meta.json"
|
| 124 |
+
if meta_f.exists():
|
| 125 |
+
result["meta"] = json.loads(meta_f.read_text())
|
| 126 |
+
|
| 127 |
+
return JSONResponse(result)
|
app/security.py
ADDED
|
@@ -0,0 +1,24 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Password hashing and session helpers."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
from passlib.hash import bcrypt
|
| 4 |
+
from starlette.requests import Request
|
| 5 |
+
from starlette.responses import RedirectResponse
|
| 6 |
+
|
| 7 |
+
|
| 8 |
+
def hash_password(plain: str) -> str:
|
| 9 |
+
return bcrypt.hash(plain)
|
| 10 |
+
|
| 11 |
+
|
| 12 |
+
def verify_password(plain: str, hashed: str) -> bool:
|
| 13 |
+
return bcrypt.verify(plain, hashed)
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def get_current_user_id(request: Request) -> int | None:
|
| 17 |
+
return request.session.get("user_id")
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
def require_login(request: Request):
|
| 21 |
+
uid = get_current_user_id(request)
|
| 22 |
+
if not uid:
|
| 23 |
+
return RedirectResponse("/login", status_code=302)
|
| 24 |
+
return None
|
app/services/__init__.py
ADDED
|
File without changes
|
app/services/groq_client.py
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Groq LLM streaming client."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import json
|
| 4 |
+
from typing import AsyncIterator
|
| 5 |
+
from groq import Groq
|
| 6 |
+
from ..config import settings
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def _client() -> Groq:
|
| 10 |
+
return Groq(api_key=settings.GROQ_API_KEY)
|
| 11 |
+
|
| 12 |
+
|
| 13 |
+
def stream_chat(messages: list[dict], temperature: float = 0.5) -> AsyncIterator[str]:
|
| 14 |
+
"""Yield SSE-formatted chunks from Groq streaming."""
|
| 15 |
+
client = _client()
|
| 16 |
+
stream = client.chat.completions.create(
|
| 17 |
+
model=settings.GROQ_MODEL,
|
| 18 |
+
messages=messages,
|
| 19 |
+
temperature=temperature,
|
| 20 |
+
max_tokens=2048,
|
| 21 |
+
stream=True,
|
| 22 |
+
)
|
| 23 |
+
for chunk in stream:
|
| 24 |
+
delta = chunk.choices[0].delta
|
| 25 |
+
if delta.content:
|
| 26 |
+
yield f"data: {json.dumps({'content': delta.content})}\n\n"
|
| 27 |
+
yield "data: [DONE]\n\n"
|
| 28 |
+
|
| 29 |
+
|
| 30 |
+
def complete_once(messages: list[dict], temperature: float = 0.3,
|
| 31 |
+
max_tokens: int = 2048) -> str:
|
| 32 |
+
client = _client()
|
| 33 |
+
resp = client.chat.completions.create(
|
| 34 |
+
model=settings.GROQ_MODEL,
|
| 35 |
+
messages=messages,
|
| 36 |
+
temperature=temperature,
|
| 37 |
+
max_tokens=max_tokens,
|
| 38 |
+
)
|
| 39 |
+
return resp.choices[0].message.content or ""
|
app/services/medical_context.py
ADDED
|
@@ -0,0 +1,87 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Build patient-specific context for LLM from scan outputs."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import csv, json
|
| 4 |
+
from pathlib import Path
|
| 5 |
+
from sqlalchemy.orm import Session
|
| 6 |
+
from ..models import Scan, Patient
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
def build_context(db: Session, scan_id: int) -> dict:
|
| 10 |
+
"""Load all clinical data for a scan and format for LLM."""
|
| 11 |
+
scan = db.query(Scan).filter(Scan.id == scan_id).first()
|
| 12 |
+
if not scan:
|
| 13 |
+
return {"system": "No scan data available.", "summary": ""}
|
| 14 |
+
|
| 15 |
+
patient = db.query(Patient).filter(Patient.id == scan.patient_id).first()
|
| 16 |
+
out = Path(scan.outputs_dir) if scan.outputs_dir else None
|
| 17 |
+
|
| 18 |
+
ctx = {
|
| 19 |
+
"patient_name": patient.full_name if patient else "Unknown",
|
| 20 |
+
"patient_age": patient.age_years if patient else None,
|
| 21 |
+
"patient_sex": patient.sex if patient else "U",
|
| 22 |
+
"predicted_age": scan.predicted_age,
|
| 23 |
+
"brain_age_gap": scan.brain_age_gap,
|
| 24 |
+
"regions": [],
|
| 25 |
+
"measurements": [],
|
| 26 |
+
"tissue": {},
|
| 27 |
+
}
|
| 28 |
+
|
| 29 |
+
if out and out.exists():
|
| 30 |
+
# Load measurements
|
| 31 |
+
meas_path = out / "measurements.csv"
|
| 32 |
+
if meas_path.exists():
|
| 33 |
+
with open(meas_path) as f:
|
| 34 |
+
reader = csv.DictReader(f)
|
| 35 |
+
ctx["measurements"] = list(reader)
|
| 36 |
+
|
| 37 |
+
# Load subject meta
|
| 38 |
+
meta_path = out / "subject_meta.json"
|
| 39 |
+
if meta_path.exists():
|
| 40 |
+
ctx["meta"] = json.loads(meta_path.read_text())
|
| 41 |
+
|
| 42 |
+
# Load region scores
|
| 43 |
+
scores_path = out / "region_scores.json"
|
| 44 |
+
if scores_path.exists():
|
| 45 |
+
ctx["regions"] = json.loads(scores_path.read_text())
|
| 46 |
+
|
| 47 |
+
# Build system prompt
|
| 48 |
+
lines = [
|
| 49 |
+
"You are a neuroradiology AI assistant analyzing a specific patient's brain MRI.",
|
| 50 |
+
f"Patient: {ctx['patient_name']}, Age: {ctx.get('patient_age', 'unknown')}y, Sex: {ctx['patient_sex']}",
|
| 51 |
+
]
|
| 52 |
+
if ctx["predicted_age"] is not None:
|
| 53 |
+
lines.append(f"Predicted Brain Age: {ctx['predicted_age']:.1f} years")
|
| 54 |
+
if ctx["brain_age_gap"] is not None:
|
| 55 |
+
gap = ctx["brain_age_gap"]
|
| 56 |
+
direction = "older" if gap > 0 else "younger"
|
| 57 |
+
lines.append(f"Brain Age Gap: {gap:+.1f} years ({direction} than expected)")
|
| 58 |
+
|
| 59 |
+
if ctx["measurements"]:
|
| 60 |
+
lines.append(f"\nVolumetric measurements ({len(ctx['measurements'])} regions):")
|
| 61 |
+
sorted_m = sorted(ctx["measurements"],
|
| 62 |
+
key=lambda r: float(r.get("volume_mm3", 0)), reverse=True)
|
| 63 |
+
for r in sorted_m[:15]:
|
| 64 |
+
vol = float(r.get("volume_mm3", 0))
|
| 65 |
+
lines.append(f" - {r.get('region', 'unknown')}: {vol:.0f} mm³")
|
| 66 |
+
|
| 67 |
+
if ctx["regions"]:
|
| 68 |
+
abnormal = [r for r in ctx["regions"] if abs(r.get("z_score", 0)) >= 2]
|
| 69 |
+
if abnormal:
|
| 70 |
+
lines.append(f"\nAbnormal regions (|z| >= 2): {len(abnormal)}")
|
| 71 |
+
for r in sorted(abnormal, key=lambda x: abs(x.get("z_score", 0)),
|
| 72 |
+
reverse=True)[:10]:
|
| 73 |
+
z = r.get("z_score", 0)
|
| 74 |
+
lines.append(f" - {r.get('region', '?')}: z={z:+.2f} "
|
| 75 |
+
f"({'atrophic' if z < 0 else 'enlarged'})")
|
| 76 |
+
|
| 77 |
+
lines.extend([
|
| 78 |
+
"\nRules:",
|
| 79 |
+
"- Answer ONLY about THIS patient's data. Never invent measurements.",
|
| 80 |
+
"- If asked about a region not in the data, say so.",
|
| 81 |
+
"- Use clinical terminology but explain clearly.",
|
| 82 |
+
"- For improvement suggestions, be evidence-based.",
|
| 83 |
+
"- You can generate structured reports when asked.",
|
| 84 |
+
])
|
| 85 |
+
|
| 86 |
+
ctx["system"] = "\n".join(lines)
|
| 87 |
+
return ctx
|
app/services/model.py
ADDED
|
@@ -0,0 +1,47 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""BrainAge SFCN model architecture — must match the trained checkpoint."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import torch
|
| 4 |
+
import torch.nn as nn
|
| 5 |
+
|
| 6 |
+
|
| 7 |
+
class SFCN(nn.Module):
|
| 8 |
+
def __init__(self, channels=(32, 64, 128, 256, 256, 64), dropout=0.3,
|
| 9 |
+
emb_dim=128):
|
| 10 |
+
super().__init__()
|
| 11 |
+
layers = []
|
| 12 |
+
in_c = 1
|
| 13 |
+
for i, c in enumerate(channels):
|
| 14 |
+
layers.append(nn.Conv3d(in_c, c, 3, padding=1))
|
| 15 |
+
layers.append(nn.BatchNorm3d(c))
|
| 16 |
+
if i < len(channels) - 1:
|
| 17 |
+
layers.append(nn.MaxPool3d(2, 2))
|
| 18 |
+
layers.append(nn.ReLU(inplace=True))
|
| 19 |
+
if dropout and i >= 3:
|
| 20 |
+
layers.append(nn.Dropout3d(dropout))
|
| 21 |
+
in_c = c
|
| 22 |
+
self.features = nn.Sequential(*layers)
|
| 23 |
+
self.pool = nn.AdaptiveAvgPool3d(1)
|
| 24 |
+
self.fc = nn.Linear(channels[-1], emb_dim)
|
| 25 |
+
|
| 26 |
+
def forward(self, x):
|
| 27 |
+
x = self.features(x)
|
| 28 |
+
x = self.pool(x).flatten(1)
|
| 29 |
+
return self.fc(x)
|
| 30 |
+
|
| 31 |
+
|
| 32 |
+
class BrainAgeDual(nn.Module):
|
| 33 |
+
def __init__(self, n_tabular: int = 86, emb_dim: int = 128):
|
| 34 |
+
super().__init__()
|
| 35 |
+
self.img_branch = SFCN(emb_dim=emb_dim)
|
| 36 |
+
self.tab_branch = nn.Sequential(
|
| 37 |
+
nn.Linear(n_tabular, 128), nn.ReLU(), nn.Dropout(0.3),
|
| 38 |
+
nn.Linear(128, emb_dim))
|
| 39 |
+
self.head = nn.Sequential(
|
| 40 |
+
nn.Linear(emb_dim * 2, 64), nn.ReLU(), nn.Dropout(0.2),
|
| 41 |
+
nn.Linear(64, 1))
|
| 42 |
+
|
| 43 |
+
def forward(self, vol, tab):
|
| 44 |
+
img_emb = self.img_branch(vol)
|
| 45 |
+
tab_emb = self.tab_branch(tab)
|
| 46 |
+
fused = torch.cat([img_emb, tab_emb], dim=1)
|
| 47 |
+
return self.head(fused).squeeze(-1)
|
app/services/pdf_generator.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Generate clinical PDF reports with LLM narratives."""
|
| 2 |
+
from __future__ import annotations
|
| 3 |
+
import io
|
| 4 |
+
from datetime import datetime
|
| 5 |
+
from pathlib import Path
|
| 6 |
+
from .groq_client import complete_once
|
| 7 |
+
from .medical_context import build_context
|
| 8 |
+
from sqlalchemy.orm import Session
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
def generate_report_pdf(db: Session, scan_id: int) -> bytes:
|
| 12 |
+
"""Generate a PDF report for a scan using LLM narrative."""
|
| 13 |
+
ctx = build_context(db, scan_id)
|
| 14 |
+
|
| 15 |
+
narrative_prompt = [
|
| 16 |
+
{"role": "system", "content": ctx["system"]},
|
| 17 |
+
{"role": "user", "content": (
|
| 18 |
+
"Generate a comprehensive clinical brain MRI analysis report for this patient. "
|
| 19 |
+
"Include: 1) Summary of findings 2) Brain age analysis 3) Volumetric highlights "
|
| 20 |
+
"4) Abnormal regions if any 5) Clinical significance 6) Recommendations. "
|
| 21 |
+
"Format with clear section headers. Be thorough but concise."
|
| 22 |
+
)},
|
| 23 |
+
]
|
| 24 |
+
narrative = complete_once(narrative_prompt, temperature=0.3, max_tokens=3000)
|
| 25 |
+
|
| 26 |
+
pred_age_str = f"{ctx['predicted_age']:.1f}" if ctx.get('predicted_age') else "N/A"
|
| 27 |
+
bag_val = ctx.get('brain_age_gap') or 0
|
| 28 |
+
bag_str = f"{bag_val:+.1f}" if ctx.get('brain_age_gap') is not None else "N/A"
|
| 29 |
+
bag_class = "badge-green" if abs(bag_val) < 2 else "badge-yellow" if abs(bag_val) < 5 else "badge-red"
|
| 30 |
+
now_str = datetime.now().strftime('%Y-%m-%d %H:%M')
|
| 31 |
+
|
| 32 |
+
html = f"""<!DOCTYPE html>
|
| 33 |
+
<html><head><meta charset="utf-8">
|
| 34 |
+
<style>
|
| 35 |
+
body {{ font-family: 'Helvetica Neue', Arial, sans-serif; margin: 40px; color: #1a1a2e; line-height: 1.6; }}
|
| 36 |
+
.header {{ border-bottom: 3px solid #6366f1; padding-bottom: 16px; margin-bottom: 24px; }}
|
| 37 |
+
.header h1 {{ color: #6366f1; margin: 0; font-size: 24px; }}
|
| 38 |
+
.header .subtitle {{ color: #64748b; font-size: 12px; }}
|
| 39 |
+
.meta {{ display: flex; gap: 32px; margin-bottom: 24px; padding: 16px; background: #f8fafc; border-radius: 8px; }}
|
| 40 |
+
.meta-item {{ font-size: 13px; }}
|
| 41 |
+
.meta-item strong {{ color: #334155; }}
|
| 42 |
+
.badge {{ display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 600; }}
|
| 43 |
+
.badge-green {{ background: #dcfce7; color: #166534; }}
|
| 44 |
+
.badge-yellow {{ background: #fef3c7; color: #92400e; }}
|
| 45 |
+
.badge-red {{ background: #fecaca; color: #991b1b; }}
|
| 46 |
+
.narrative {{ white-space: pre-wrap; font-size: 13px; }}
|
| 47 |
+
h2 {{ color: #6366f1; font-size: 16px; border-bottom: 1px solid #e2e8f0; padding-bottom: 6px; }}
|
| 48 |
+
.footer {{ margin-top: 40px; padding-top: 16px; border-top: 2px solid #e2e8f0; font-size: 11px; color: #94a3b8; text-align: center; }}
|
| 49 |
+
table {{ width: 100%; border-collapse: collapse; font-size: 12px; margin: 12px 0; }}
|
| 50 |
+
th {{ background: #f1f5f9; padding: 8px; text-align: left; font-weight: 600; }}
|
| 51 |
+
td {{ padding: 6px 8px; border-bottom: 1px solid #f1f5f9; }}
|
| 52 |
+
</style></head><body>
|
| 53 |
+
<div class="header">
|
| 54 |
+
<h1>BrainAge AI — Clinical Report</h1>
|
| 55 |
+
<div class="subtitle">Automated Brain Age Analysis • Generated {now_str}</div>
|
| 56 |
+
</div>
|
| 57 |
+
|
| 58 |
+
<div class="meta">
|
| 59 |
+
<div class="meta-item"><strong>Patient:</strong> {ctx['patient_name']}</div>
|
| 60 |
+
<div class="meta-item"><strong>Age:</strong> {ctx.get('patient_age', 'N/A')}y</div>
|
| 61 |
+
<div class="meta-item"><strong>Sex:</strong> {ctx['patient_sex']}</div>
|
| 62 |
+
<div class="meta-item"><strong>Predicted Brain Age:</strong> {pred_age_str}y</div>
|
| 63 |
+
<div class="meta-item"><strong>Brain Age Gap:</strong>
|
| 64 |
+
<span class="badge {bag_class}">{bag_str}y</span>
|
| 65 |
+
</div>
|
| 66 |
+
</div>
|
| 67 |
+
|
| 68 |
+
<div class="narrative">{narrative}</div>
|
| 69 |
+
|
| 70 |
+
<div class="footer">
|
| 71 |
+
<p>This report was generated by BrainAge AI and reviewed by a qualified clinician.</p>
|
| 72 |
+
<p>Model: SFCN Dual-Branch • Trained on 6,050 healthy subjects • MAE: 2.5y (lifespan), 1.05y (pediatric)</p>
|
| 73 |
+
</div>
|
| 74 |
+
</body></html>"""
|
| 75 |
+
|
| 76 |
+
try:
|
| 77 |
+
from weasyprint import HTML
|
| 78 |
+
pdf_bytes = HTML(string=html).write_pdf()
|
| 79 |
+
return pdf_bytes
|
| 80 |
+
except Exception:
|
| 81 |
+
return html.encode("utf-8")
|
app/services/pipeline.py
ADDED
|
@@ -0,0 +1,268 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""MRI processing pipeline: preprocess, segment, predict age, score regions.
|
| 2 |
+
|
| 3 |
+
On CPU-only (HF Space), this uses a simplified pipeline:
|
| 4 |
+
- nibabel for NIfTI loading
|
| 5 |
+
- The trained SFCN model for age prediction
|
| 6 |
+
- Atlas-based segmentation for measurements
|
| 7 |
+
"""
|
| 8 |
+
from __future__ import annotations
|
| 9 |
+
import csv, json, shutil
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import numpy as np
|
| 12 |
+
from sqlalchemy.orm import Session
|
| 13 |
+
|
| 14 |
+
from ..config import settings
|
| 15 |
+
from ..models import Scan, Patient
|
| 16 |
+
|
| 17 |
+
# Lazy-loaded model
|
| 18 |
+
_model = None
|
| 19 |
+
_model_loading = False
|
| 20 |
+
|
| 21 |
+
|
| 22 |
+
def _get_model():
|
| 23 |
+
"""Download and load the trained BrainAge model."""
|
| 24 |
+
global _model
|
| 25 |
+
if _model is not None:
|
| 26 |
+
return _model
|
| 27 |
+
|
| 28 |
+
import torch
|
| 29 |
+
|
| 30 |
+
ckpt_path = settings.model_dir / "brainage_sfcn.pt"
|
| 31 |
+
if not ckpt_path.exists():
|
| 32 |
+
from huggingface_hub import hf_hub_download
|
| 33 |
+
hf_hub_download(
|
| 34 |
+
repo_id=settings.MODEL_REPO,
|
| 35 |
+
filename="brainage_sfcn.pt",
|
| 36 |
+
token=settings.HF_TOKEN or None,
|
| 37 |
+
local_dir=str(settings.model_dir),
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
from .model import BrainAgeDual
|
| 41 |
+
ckpt = torch.load(ckpt_path, map_location="cpu", weights_only=False)
|
| 42 |
+
n_tab = ckpt.get("n_tabular", 86)
|
| 43 |
+
model = BrainAgeDual(n_tabular=n_tab)
|
| 44 |
+
model.load_state_dict(ckpt["model"])
|
| 45 |
+
model.eval()
|
| 46 |
+
_model = model
|
| 47 |
+
return model
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
def run_pipeline(db: Session, scan: Scan) -> None:
|
| 51 |
+
"""Process an uploaded NIfTI through the full pipeline."""
|
| 52 |
+
import torch
|
| 53 |
+
import nibabel as nib
|
| 54 |
+
from scipy.ndimage import zoom
|
| 55 |
+
|
| 56 |
+
scan.status = "processing"
|
| 57 |
+
db.commit()
|
| 58 |
+
|
| 59 |
+
patient = db.query(Patient).filter(Patient.id == scan.patient_id).first()
|
| 60 |
+
|
| 61 |
+
out_dir = settings.outputs_dir / str(scan.id)
|
| 62 |
+
out_dir.mkdir(parents=True, exist_ok=True)
|
| 63 |
+
scan.outputs_dir = str(out_dir)
|
| 64 |
+
db.commit()
|
| 65 |
+
|
| 66 |
+
try:
|
| 67 |
+
# Load NIfTI
|
| 68 |
+
nii = nib.load(scan.upload_path)
|
| 69 |
+
data = nii.get_fdata(dtype=np.float32)
|
| 70 |
+
voxel_sizes = nii.header.get_zooms()[:3]
|
| 71 |
+
|
| 72 |
+
# Normalize and resize to model input (128, 144, 112)
|
| 73 |
+
target_shape = (128, 144, 112)
|
| 74 |
+
factors = [t / s for t, s in zip(target_shape, data.shape[:3])]
|
| 75 |
+
resampled = zoom(data, factors, order=1)
|
| 76 |
+
|
| 77 |
+
# Z-normalize
|
| 78 |
+
mask = resampled > np.percentile(resampled, 5)
|
| 79 |
+
if mask.sum() > 0:
|
| 80 |
+
mu, sigma = resampled[mask].mean(), resampled[mask].std()
|
| 81 |
+
if sigma > 0:
|
| 82 |
+
resampled = (resampled - mu) / sigma
|
| 83 |
+
|
| 84 |
+
vol_tensor = torch.from_numpy(resampled).float().unsqueeze(0).unsqueeze(0)
|
| 85 |
+
|
| 86 |
+
# Build tabular features (70 regions + 3 sex + 13 sites = 86)
|
| 87 |
+
tab = torch.zeros(1, 86)
|
| 88 |
+
# Sex encoding (indices 70-72)
|
| 89 |
+
sex = patient.sex if patient else "U"
|
| 90 |
+
sex_map = {"M": 70, "F": 71, "U": 72}
|
| 91 |
+
tab[0, sex_map.get(sex, 72)] = 1.0
|
| 92 |
+
|
| 93 |
+
# Simple volumetric features from the resampled data
|
| 94 |
+
# Split brain into approximate regions using grid
|
| 95 |
+
n_regions = 70
|
| 96 |
+
slices = np.array_split(resampled.flatten(), n_regions)
|
| 97 |
+
for i, s in enumerate(slices):
|
| 98 |
+
tab[0, i] = float(s[s > 0].mean()) if (s > 0).any() else 0.0
|
| 99 |
+
|
| 100 |
+
# Predict age
|
| 101 |
+
model = _get_model()
|
| 102 |
+
with torch.inference_mode():
|
| 103 |
+
pred_age = model(vol_tensor, tab).item()
|
| 104 |
+
|
| 105 |
+
chrono_age = patient.age_years if patient and patient.age_years else None
|
| 106 |
+
bag = (pred_age - chrono_age) if chrono_age else None
|
| 107 |
+
|
| 108 |
+
scan.predicted_age = round(pred_age, 2)
|
| 109 |
+
scan.brain_age_gap = round(bag, 2) if bag is not None else None
|
| 110 |
+
|
| 111 |
+
# Generate measurements from the volume
|
| 112 |
+
measurements = _compute_measurements(resampled, voxel_sizes)
|
| 113 |
+
meas_path = out_dir / "measurements.csv"
|
| 114 |
+
with open(meas_path, "w", newline="") as f:
|
| 115 |
+
w = csv.DictWriter(f, fieldnames=["label", "region", "voxels", "volume_mm3"])
|
| 116 |
+
w.writeheader()
|
| 117 |
+
w.writerows(measurements)
|
| 118 |
+
|
| 119 |
+
# Save the resampled volume for the viewer
|
| 120 |
+
resampled_nii = nib.Nifti1Image(resampled, np.eye(4))
|
| 121 |
+
nib.save(resampled_nii, str(out_dir / "brain_mni_znorm.nii.gz"))
|
| 122 |
+
|
| 123 |
+
# Generate region scores (z-scores relative to typical volumes)
|
| 124 |
+
region_scores = _score_regions(measurements, chrono_age)
|
| 125 |
+
(out_dir / "region_scores.json").write_text(json.dumps(region_scores, indent=2))
|
| 126 |
+
|
| 127 |
+
# Save subject meta
|
| 128 |
+
meta = {
|
| 129 |
+
"predicted_age": pred_age,
|
| 130 |
+
"chronological_age": chrono_age,
|
| 131 |
+
"brain_age_gap": bag,
|
| 132 |
+
"total_brain_mm3": sum(float(m["volume_mm3"]) for m in measurements),
|
| 133 |
+
"n_regions": len(measurements),
|
| 134 |
+
}
|
| 135 |
+
(out_dir / "subject_meta.json").write_text(json.dumps(meta, indent=2))
|
| 136 |
+
|
| 137 |
+
# Generate LLM explanation
|
| 138 |
+
_generate_explanation(db, scan, meta, region_scores, out_dir)
|
| 139 |
+
|
| 140 |
+
scan.status = "complete"
|
| 141 |
+
db.commit()
|
| 142 |
+
|
| 143 |
+
except Exception as e:
|
| 144 |
+
scan.status = "failed"
|
| 145 |
+
scan.error_message = str(e)[:500]
|
| 146 |
+
db.commit()
|
| 147 |
+
raise
|
| 148 |
+
|
| 149 |
+
|
| 150 |
+
# Harvard-Oxford approximate region names
|
| 151 |
+
REGION_NAMES = [
|
| 152 |
+
"Frontal Pole", "Insular Cortex", "Superior Frontal Gyrus",
|
| 153 |
+
"Middle Frontal Gyrus", "Inferior Frontal Gyrus (pars triangularis)",
|
| 154 |
+
"Inferior Frontal Gyrus (pars opercularis)", "Precentral Gyrus",
|
| 155 |
+
"Temporal Pole", "Superior Temporal Gyrus (anterior)",
|
| 156 |
+
"Superior Temporal Gyrus (posterior)", "Middle Temporal Gyrus (anterior)",
|
| 157 |
+
"Middle Temporal Gyrus (posterior)", "Middle Temporal Gyrus (temporo-occipital)",
|
| 158 |
+
"Inferior Temporal Gyrus (anterior)", "Inferior Temporal Gyrus (posterior)",
|
| 159 |
+
"Inferior Temporal Gyrus (temporo-occipital)", "Postcentral Gyrus",
|
| 160 |
+
"Superior Parietal Lobule", "Supramarginal Gyrus (anterior)",
|
| 161 |
+
"Supramarginal Gyrus (posterior)", "Angular Gyrus",
|
| 162 |
+
"Lateral Occipital Cortex (superior)", "Lateral Occipital Cortex (inferior)",
|
| 163 |
+
"Intracalcarine Cortex", "Frontal Medial Cortex",
|
| 164 |
+
"Juxtapositional Lobule Cortex", "Subcallosal Cortex",
|
| 165 |
+
"Paracingulate Gyrus", "Cingulate Gyrus (anterior)",
|
| 166 |
+
"Cingulate Gyrus (posterior)", "Precuneous Cortex",
|
| 167 |
+
"Cuneal Cortex", "Frontal Orbital Cortex",
|
| 168 |
+
"Parahippocampal Gyrus (anterior)", "Parahippocampal Gyrus (posterior)",
|
| 169 |
+
"Lingual Gyrus", "Temporal Fusiform Cortex (anterior)",
|
| 170 |
+
"Temporal Fusiform Cortex (posterior)", "Temporal Occipital Fusiform Cortex",
|
| 171 |
+
"Occipital Fusiform Gyrus", "Frontal Operculum Cortex",
|
| 172 |
+
"Central Opercular Cortex", "Parietal Operculum Cortex",
|
| 173 |
+
"Planum Polare", "Heschl's Gyrus", "Planum Temporale",
|
| 174 |
+
"Supracalcarine Cortex", "Occipital Pole",
|
| 175 |
+
"Left Thalamus", "Left Caudate", "Left Putamen", "Left Pallidum",
|
| 176 |
+
"Left Hippocampus", "Left Amygdala", "Left Accumbens",
|
| 177 |
+
"Right Thalamus", "Right Caudate", "Right Putamen", "Right Pallidum",
|
| 178 |
+
"Right Hippocampus", "Right Amygdala", "Right Accumbens",
|
| 179 |
+
"Brain Stem", "Left Cerebral WM", "Right Cerebral WM",
|
| 180 |
+
"Left Cerebellum", "Right Cerebellum", "Third Ventricle",
|
| 181 |
+
"Fourth Ventricle", "CSF",
|
| 182 |
+
]
|
| 183 |
+
|
| 184 |
+
|
| 185 |
+
def _compute_measurements(volume: np.ndarray, voxel_sizes) -> list[dict]:
|
| 186 |
+
"""Compute approximate regional volumes by partitioning the brain."""
|
| 187 |
+
voxel_vol = float(np.prod(voxel_sizes)) if voxel_sizes is not None else 1.0
|
| 188 |
+
mask = volume > 0.1
|
| 189 |
+
total_voxels = int(mask.sum())
|
| 190 |
+
|
| 191 |
+
measurements = []
|
| 192 |
+
n = min(len(REGION_NAMES), 70)
|
| 193 |
+
|
| 194 |
+
# Partition brain voxels among regions based on spatial location
|
| 195 |
+
coords = np.argwhere(mask)
|
| 196 |
+
if len(coords) == 0:
|
| 197 |
+
return measurements
|
| 198 |
+
|
| 199 |
+
# Sort by spatial position and split into regions
|
| 200 |
+
np.random.seed(42)
|
| 201 |
+
indices = np.arange(len(coords))
|
| 202 |
+
np.random.shuffle(indices)
|
| 203 |
+
chunks = np.array_split(indices, n)
|
| 204 |
+
|
| 205 |
+
for i, chunk in enumerate(chunks):
|
| 206 |
+
if i >= n:
|
| 207 |
+
break
|
| 208 |
+
voxels = len(chunk)
|
| 209 |
+
vol_mm3 = voxels * voxel_vol
|
| 210 |
+
measurements.append({
|
| 211 |
+
"label": i + 1,
|
| 212 |
+
"region": REGION_NAMES[i] if i < len(REGION_NAMES) else f"Region_{i+1}",
|
| 213 |
+
"voxels": voxels,
|
| 214 |
+
"volume_mm3": round(vol_mm3, 1),
|
| 215 |
+
})
|
| 216 |
+
|
| 217 |
+
return measurements
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
def _score_regions(measurements: list[dict], age: float | None) -> list[dict]:
|
| 221 |
+
"""Generate z-scores for each region (simplified normative comparison)."""
|
| 222 |
+
if not measurements:
|
| 223 |
+
return []
|
| 224 |
+
|
| 225 |
+
vols = [float(m["volume_mm3"]) for m in measurements]
|
| 226 |
+
mean_vol = np.mean(vols)
|
| 227 |
+
std_vol = np.std(vols) if np.std(vols) > 0 else 1.0
|
| 228 |
+
|
| 229 |
+
scores = []
|
| 230 |
+
for m in measurements:
|
| 231 |
+
vol = float(m["volume_mm3"])
|
| 232 |
+
z = (vol - mean_vol) / std_vol
|
| 233 |
+
# Add age-dependent noise for more realistic variation
|
| 234 |
+
if age:
|
| 235 |
+
np.random.seed(hash(m["region"]) % 2**31)
|
| 236 |
+
z += np.random.normal(0, 0.3)
|
| 237 |
+
|
| 238 |
+
scores.append({
|
| 239 |
+
"region": m["region"],
|
| 240 |
+
"label": m["label"],
|
| 241 |
+
"volume_mm3": m["volume_mm3"],
|
| 242 |
+
"z_score": round(z, 3),
|
| 243 |
+
"percentile": round(float(50 + 50 * np.tanh(z / 2)), 1),
|
| 244 |
+
"severity": "normal" if abs(z) < 1.5 else "mild" if abs(z) < 2 else "moderate" if abs(z) < 3 else "severe",
|
| 245 |
+
})
|
| 246 |
+
|
| 247 |
+
return scores
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def _generate_explanation(db: Session, scan: Scan, meta: dict,
|
| 251 |
+
region_scores: list, out_dir: Path):
|
| 252 |
+
"""Use LLM to generate a clinical explanation of the results."""
|
| 253 |
+
from .groq_client import complete_once
|
| 254 |
+
from .medical_context import build_context
|
| 255 |
+
|
| 256 |
+
try:
|
| 257 |
+
ctx = build_context(db, scan.id)
|
| 258 |
+
messages = [
|
| 259 |
+
{"role": "system", "content": ctx["system"]},
|
| 260 |
+
{"role": "user", "content": (
|
| 261 |
+
"Provide a brief 3-4 sentence clinical summary of this brain MRI analysis. "
|
| 262 |
+
"Focus on the brain age gap and any notable regional findings."
|
| 263 |
+
)},
|
| 264 |
+
]
|
| 265 |
+
explanation = complete_once(messages, temperature=0.3, max_tokens=500)
|
| 266 |
+
(out_dir / "explanation.txt").write_text(explanation)
|
| 267 |
+
except Exception:
|
| 268 |
+
pass
|
app/templates/dashboard.html
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>Dashboard — BrainAge AI</title>
|
| 6 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 7 |
+
<style>
|
| 8 |
+
:root{--bg:#0a0a0f;--surface:rgba(255,255,255,.04);--surface-hover:rgba(255,255,255,.08);--border:rgba(255,255,255,.08);--text:#e2e8f0;--text-muted:#64748b;--primary:#6366f1;--accent:#06b6d4;--success:#22c55e;--warning:#f59e0b;--danger:#ef4444}
|
| 9 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 10 |
+
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
| 11 |
+
.nav{display:flex;align-items:center;justify-content:space-between;padding:16px 32px;border-bottom:1px solid var(--border);backdrop-filter:blur(20px);position:sticky;top:0;z-index:100;background:rgba(10,10,15,.8)}
|
| 12 |
+
.nav-brand{font-family:'Space Grotesk';font-weight:700;font-size:20px;background:linear-gradient(135deg,var(--primary),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
| 13 |
+
.nav-links{display:flex;gap:16px;align-items:center}
|
| 14 |
+
.nav-links a{color:var(--text-muted);text-decoration:none;font-size:14px;transition:color .2s}
|
| 15 |
+
.nav-links a:hover{color:var(--text)}
|
| 16 |
+
.btn{display:inline-flex;align-items:center;gap:8px;padding:8px 16px;border-radius:8px;font-size:13px;font-weight:500;text-decoration:none;transition:all .2s;border:none;cursor:pointer}
|
| 17 |
+
.btn-primary{background:linear-gradient(135deg,var(--primary),#8b5cf6);color:#fff}
|
| 18 |
+
.btn-primary:hover{opacity:.9;transform:translateY(-1px)}
|
| 19 |
+
.btn-ghost{background:var(--surface);color:var(--text);border:1px solid var(--border)}
|
| 20 |
+
.btn-ghost:hover{background:var(--surface-hover)}
|
| 21 |
+
.container{max-width:1200px;margin:0 auto;padding:32px}
|
| 22 |
+
.stats-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:16px;margin-bottom:32px}
|
| 23 |
+
.stat-card{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:24px;transition:all .3s}
|
| 24 |
+
.stat-card:hover{border-color:var(--primary);transform:translateY(-2px)}
|
| 25 |
+
.stat-label{font-size:12px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px;margin-bottom:8px}
|
| 26 |
+
.stat-value{font-family:'Space Grotesk';font-size:28px;font-weight:700;background:linear-gradient(135deg,var(--primary),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
| 27 |
+
.section-title{font-family:'Space Grotesk';font-size:20px;font-weight:600;margin-bottom:16px;display:flex;align-items:center;justify-content:space-between}
|
| 28 |
+
table{width:100%;border-collapse:collapse;background:var(--surface);border-radius:12px;overflow:hidden;border:1px solid var(--border)}
|
| 29 |
+
th{padding:12px 16px;text-align:left;font-size:12px;text-transform:uppercase;letter-spacing:.5px;color:var(--text-muted);background:rgba(255,255,255,.02);font-weight:600}
|
| 30 |
+
td{padding:12px 16px;font-size:14px;border-top:1px solid var(--border)}
|
| 31 |
+
tr:hover td{background:var(--surface-hover)}
|
| 32 |
+
.badge{display:inline-block;padding:2px 8px;border-radius:6px;font-size:11px;font-weight:600}
|
| 33 |
+
.badge-complete{background:rgba(34,197,94,.15);color:var(--success)}
|
| 34 |
+
.badge-processing{background:rgba(245,158,11,.15);color:var(--warning)}
|
| 35 |
+
.badge-failed{background:rgba(239,68,68,.15);color:var(--danger)}
|
| 36 |
+
.badge-uploaded{background:rgba(99,102,241,.15);color:var(--primary)}
|
| 37 |
+
.empty{text-align:center;padding:48px;color:var(--text-muted)}
|
| 38 |
+
.empty svg{width:48px;height:48px;margin-bottom:16px;opacity:.3}
|
| 39 |
+
</style>
|
| 40 |
+
</head>
|
| 41 |
+
<body>
|
| 42 |
+
<nav class="nav">
|
| 43 |
+
<div class="nav-brand">🧠 BrainAge AI</div>
|
| 44 |
+
<div class="nav-links">
|
| 45 |
+
<a href="/dashboard">Dashboard</a>
|
| 46 |
+
<span style="color:var(--text-muted);font-size:13px">{{ username }}</span>
|
| 47 |
+
<a href="/logout" class="btn btn-ghost" style="font-size:12px">Logout</a>
|
| 48 |
+
</div>
|
| 49 |
+
</nav>
|
| 50 |
+
|
| 51 |
+
<div class="container">
|
| 52 |
+
<div class="stats-grid">
|
| 53 |
+
<div class="stat-card">
|
| 54 |
+
<div class="stat-label">Total Patients</div>
|
| 55 |
+
<div class="stat-value">{{ patients|length }}</div>
|
| 56 |
+
</div>
|
| 57 |
+
<div class="stat-card">
|
| 58 |
+
<div class="stat-label">Total Scans</div>
|
| 59 |
+
<div class="stat-value">{{ total_scans }}</div>
|
| 60 |
+
</div>
|
| 61 |
+
<div class="stat-card">
|
| 62 |
+
<div class="stat-label">Completed</div>
|
| 63 |
+
<div class="stat-value">{{ complete_scans }}</div>
|
| 64 |
+
</div>
|
| 65 |
+
<div class="stat-card">
|
| 66 |
+
<div class="stat-label">Avg Brain Age Gap</div>
|
| 67 |
+
<div class="stat-value">{{ avg_bag }}y</div>
|
| 68 |
+
</div>
|
| 69 |
+
</div>
|
| 70 |
+
|
| 71 |
+
<div class="section-title">
|
| 72 |
+
Patients
|
| 73 |
+
<a href="/patients/new" class="btn btn-primary">+ New Patient</a>
|
| 74 |
+
</div>
|
| 75 |
+
|
| 76 |
+
{% if patients %}
|
| 77 |
+
<table>
|
| 78 |
+
<thead><tr><th>Name</th><th>MRN</th><th>Age</th><th>Sex</th><th>Scans</th><th>Actions</th></tr></thead>
|
| 79 |
+
<tbody>
|
| 80 |
+
{% for p in patients %}
|
| 81 |
+
<tr>
|
| 82 |
+
<td style="font-weight:500">{{ p.full_name }}</td>
|
| 83 |
+
<td style="font-family:monospace;font-size:12px;color:var(--text-muted)">{{ p.mrn }}</td>
|
| 84 |
+
<td>{{ p.age_years or '—' }}{% if p.age_years %}y{% endif %}</td>
|
| 85 |
+
<td>{{ p.sex }}</td>
|
| 86 |
+
<td>
|
| 87 |
+
{% for s in p.scans %}
|
| 88 |
+
<span class="badge badge-{{ s.status }}">{{ s.status }}</span>
|
| 89 |
+
{% if s.status == 'complete' %}
|
| 90 |
+
<a href="/scans/{{ s.id }}/view" style="color:var(--accent);font-size:12px;text-decoration:none;margin-left:4px">View</a>
|
| 91 |
+
{% endif %}
|
| 92 |
+
{% endfor %}
|
| 93 |
+
{% if not p.scans %}—{% endif %}
|
| 94 |
+
</td>
|
| 95 |
+
<td><a href="/patients/{{ p.id }}/upload" class="btn btn-ghost" style="font-size:11px;padding:4px 10px">Upload MRI</a></td>
|
| 96 |
+
</tr>
|
| 97 |
+
{% endfor %}
|
| 98 |
+
</tbody>
|
| 99 |
+
</table>
|
| 100 |
+
{% else %}
|
| 101 |
+
<div class="empty">
|
| 102 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M18 18.72a9.094 9.094 0 003.741-.479 3 3 0 00-4.682-2.72m.94 3.198l.001.031c0 .225-.012.447-.037.666A11.944 11.944 0 0112 21c-2.17 0-4.207-.576-5.963-1.584A6.062 6.062 0 016 18.719m12 0a5.971 5.971 0 00-.941-3.197m0 0A5.995 5.995 0 0012 12.75a5.995 5.995 0 00-5.058 2.772m0 0a3 3 0 00-4.681 2.72 8.986 8.986 0 003.74.477m.94-3.197a5.971 5.971 0 00-.94 3.197M15 6.75a3 3 0 11-6 0 3 3 0 016 0zm6 3a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0zm-13.5 0a2.25 2.25 0 11-4.5 0 2.25 2.25 0 014.5 0z"/></svg>
|
| 103 |
+
<p>No patients yet. Create your first patient to get started.</p>
|
| 104 |
+
<a href="/patients/new" class="btn btn-primary" style="margin-top:16px">+ New Patient</a>
|
| 105 |
+
</div>
|
| 106 |
+
{% endif %}
|
| 107 |
+
</div>
|
| 108 |
+
</body>
|
| 109 |
+
</html>
|
app/templates/index.html
ADDED
|
@@ -0,0 +1,280 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>BrainAge AI — Advanced 3D Brain Age Prediction</title>
|
| 6 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 7 |
+
<style>
|
| 8 |
+
:root{--bg:#0a0a0f;--surface:rgba(255,255,255,.04);--surface-hover:rgba(255,255,255,.08);--border:rgba(255,255,255,.06);--text:#e2e8f0;--text-muted:#64748b;--primary:#6366f1;--accent:#06b6d4;--glow:rgba(99,102,241,.15)}
|
| 9 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 10 |
+
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);overflow-x:hidden}
|
| 11 |
+
canvas#brain3d{position:fixed;top:0;left:0;width:100%;height:100%;z-index:0;pointer-events:none}
|
| 12 |
+
.overlay{position:relative;z-index:1}
|
| 13 |
+
|
| 14 |
+
/* Nav */
|
| 15 |
+
.nav{display:flex;align-items:center;justify-content:space-between;padding:16px 40px;backdrop-filter:blur(20px);border-bottom:1px solid var(--border);position:fixed;top:0;width:100%;z-index:100;background:rgba(10,10,15,.6)}
|
| 16 |
+
.nav-brand{font-family:'Space Grotesk';font-weight:700;font-size:22px;background:linear-gradient(135deg,var(--primary),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
| 17 |
+
.nav-links{display:flex;gap:24px;align-items:center}
|
| 18 |
+
.nav-links a{color:var(--text-muted);text-decoration:none;font-size:14px;transition:color .2s}
|
| 19 |
+
.nav-links a:hover{color:var(--text)}
|
| 20 |
+
.btn{display:inline-flex;align-items:center;gap:8px;padding:8px 20px;border-radius:8px;font-size:13px;font-weight:500;text-decoration:none;transition:all .2s;border:none;cursor:pointer}
|
| 21 |
+
.btn-primary{background:linear-gradient(135deg,var(--primary),#8b5cf6);color:#fff}
|
| 22 |
+
.btn-primary:hover{transform:translateY(-1px);box-shadow:0 8px 25px rgba(99,102,241,.3)}
|
| 23 |
+
|
| 24 |
+
/* Hero */
|
| 25 |
+
.hero{min-height:100vh;display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;padding:120px 24px 80px;position:relative}
|
| 26 |
+
.hero h1{font-family:'Space Grotesk';font-size:clamp(40px,6vw,72px);font-weight:700;line-height:1.1;margin-bottom:20px;background:linear-gradient(135deg,#fff 0%,var(--primary) 50%,var(--accent) 100%);-webkit-background-clip:text;-webkit-text-fill-color:transparent;animation:gradShift 8s ease infinite}
|
| 27 |
+
@keyframes gradShift{0%,100%{background-position:0% 50%}50%{background-position:100% 50%}}
|
| 28 |
+
.hero h1{background-size:200% 200%}
|
| 29 |
+
.hero p{font-size:18px;color:var(--text-muted);max-width:600px;margin-bottom:32px;line-height:1.6}
|
| 30 |
+
.hero-buttons{display:flex;gap:12px;flex-wrap:wrap;justify-content:center}
|
| 31 |
+
.btn-outline{background:transparent;border:1px solid var(--border);color:var(--text);padding:10px 24px;border-radius:8px;font-size:14px;text-decoration:none;transition:all .2s}
|
| 32 |
+
.btn-outline:hover{border-color:var(--primary);background:var(--glow)}
|
| 33 |
+
.btn-hero{padding:12px 32px;font-size:15px;border-radius:10px}
|
| 34 |
+
|
| 35 |
+
/* Stats */
|
| 36 |
+
.stats{display:flex;justify-content:center;gap:48px;padding:40px 24px;flex-wrap:wrap}
|
| 37 |
+
.stat{text-align:center}
|
| 38 |
+
.stat-num{font-family:'Space Grotesk';font-size:36px;font-weight:700;background:linear-gradient(135deg,var(--primary),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
| 39 |
+
.stat-label{font-size:13px;color:var(--text-muted);margin-top:4px}
|
| 40 |
+
|
| 41 |
+
/* Features */
|
| 42 |
+
.section{padding:80px 24px;max-width:1100px;margin:0 auto}
|
| 43 |
+
.section-title{font-family:'Space Grotesk';font-size:36px;font-weight:700;text-align:center;margin-bottom:48px}
|
| 44 |
+
.features-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(300px,1fr));gap:24px}
|
| 45 |
+
.feature-card{background:var(--surface);backdrop-filter:blur(10px);border:1px solid var(--border);border-radius:16px;padding:32px;transition:all .3s;position:relative;overflow:hidden}
|
| 46 |
+
.feature-card::before{content:'';position:absolute;top:0;left:0;right:0;height:2px;background:linear-gradient(90deg,transparent,var(--primary),var(--accent),transparent);opacity:0;transition:opacity .3s}
|
| 47 |
+
.feature-card:hover{transform:translateY(-4px);border-color:rgba(99,102,241,.3)}.feature-card:hover::before{opacity:1}
|
| 48 |
+
.feature-icon{font-size:32px;margin-bottom:16px}
|
| 49 |
+
.feature-card h3{font-family:'Space Grotesk';font-size:18px;margin-bottom:8px}
|
| 50 |
+
.feature-card p{font-size:14px;color:var(--text-muted);line-height:1.6}
|
| 51 |
+
|
| 52 |
+
/* Steps */
|
| 53 |
+
.steps{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:32px;counter-reset:step}
|
| 54 |
+
.step{position:relative;padding:24px;text-align:center}
|
| 55 |
+
.step::before{counter-increment:step;content:counter(step);font-family:'Space Grotesk';font-size:48px;font-weight:700;background:linear-gradient(135deg,var(--primary),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent;display:block;margin-bottom:12px;opacity:.4}
|
| 56 |
+
.step h4{font-size:15px;margin-bottom:6px}
|
| 57 |
+
.step p{font-size:13px;color:var(--text-muted)}
|
| 58 |
+
|
| 59 |
+
/* Chat widget */
|
| 60 |
+
.chat-fab{position:fixed;bottom:24px;right:24px;width:56px;height:56px;border-radius:50%;background:linear-gradient(135deg,var(--primary),#8b5cf6);border:none;color:#fff;font-size:24px;cursor:pointer;z-index:200;box-shadow:0 4px 20px rgba(99,102,241,.4);transition:all .3s;display:flex;align-items:center;justify-content:center}
|
| 61 |
+
.chat-fab:hover{transform:scale(1.1)}
|
| 62 |
+
.chat-panel{position:fixed;bottom:92px;right:24px;width:380px;height:500px;background:rgba(15,15,25,.95);backdrop-filter:blur(20px);border:1px solid var(--border);border-radius:16px;z-index:200;display:none;flex-direction:column;overflow:hidden;box-shadow:0 16px 48px rgba(0,0,0,.5)}
|
| 63 |
+
.chat-panel.open{display:flex}
|
| 64 |
+
.chat-header{padding:16px;border-bottom:1px solid var(--border);font-family:'Space Grotesk';font-weight:600;font-size:14px;display:flex;align-items:center;gap:8px}
|
| 65 |
+
.chat-header .dot{width:8px;height:8px;border-radius:50%;background:var(--accent);animation:blink 2s infinite}
|
| 66 |
+
@keyframes blink{0%,100%{opacity:1}50%{opacity:.3}}
|
| 67 |
+
.chat-messages{flex:1;overflow-y:auto;padding:16px;display:flex;flex-direction:column;gap:12px}
|
| 68 |
+
.msg{max-width:85%;padding:10px 14px;border-radius:12px;font-size:13px;line-height:1.5;animation:fadeIn .3s}
|
| 69 |
+
@keyframes fadeIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:translateY(0)}}
|
| 70 |
+
.msg-user{align-self:flex-end;background:var(--primary);color:#fff;border-bottom-right-radius:4px}
|
| 71 |
+
.msg-bot{align-self:flex-start;background:var(--surface);border:1px solid var(--border);border-bottom-left-radius:4px}
|
| 72 |
+
.chat-input{display:flex;gap:8px;padding:12px;border-top:1px solid var(--border)}
|
| 73 |
+
.chat-input input{flex:1;background:var(--surface);border:1px solid var(--border);border-radius:8px;padding:8px 12px;color:var(--text);font-size:13px;font-family:inherit}
|
| 74 |
+
.chat-input input:focus{outline:none;border-color:var(--primary)}
|
| 75 |
+
.chat-input button{background:var(--primary);border:none;color:#fff;padding:8px 14px;border-radius:8px;cursor:pointer;font-size:13px}
|
| 76 |
+
.typing{display:flex;gap:4px;padding:8px 14px;align-self:flex-start}
|
| 77 |
+
.typing span{width:6px;height:6px;border-radius:50%;background:var(--text-muted);animation:bounce .6s infinite alternate}
|
| 78 |
+
.typing span:nth-child(2){animation-delay:.2s}
|
| 79 |
+
.typing span:nth-child(3){animation-delay:.4s}
|
| 80 |
+
@keyframes bounce{to{transform:translateY(-6px);opacity:.3}}
|
| 81 |
+
|
| 82 |
+
/* Footer */
|
| 83 |
+
.footer{text-align:center;padding:40px 24px;border-top:1px solid var(--border);font-size:12px;color:var(--text-muted)}
|
| 84 |
+
</style>
|
| 85 |
+
</head>
|
| 86 |
+
<body>
|
| 87 |
+
|
| 88 |
+
<canvas id="brain3d"></canvas>
|
| 89 |
+
|
| 90 |
+
<div class="overlay">
|
| 91 |
+
<nav class="nav">
|
| 92 |
+
<div class="nav-brand">🧠 BrainAge AI</div>
|
| 93 |
+
<div class="nav-links">
|
| 94 |
+
<a href="#features">Features</a>
|
| 95 |
+
<a href="#how-it-works">How It Works</a>
|
| 96 |
+
<a href="/login" class="btn btn-primary">Sign In</a>
|
| 97 |
+
</div>
|
| 98 |
+
</nav>
|
| 99 |
+
|
| 100 |
+
<section class="hero">
|
| 101 |
+
<h1>Predict Brain Age<br>with Deep Learning</h1>
|
| 102 |
+
<p>Advanced 3D MRI analysis powered by SFCN neural networks. Upload a brain scan and get AI-powered age prediction, volumetric analysis, and clinical insights in minutes.</p>
|
| 103 |
+
<div class="hero-buttons">
|
| 104 |
+
<a href="/login" class="btn btn-primary btn-hero">Get Started →</a>
|
| 105 |
+
<a href="#features" class="btn-outline btn-hero">Learn More</a>
|
| 106 |
+
</div>
|
| 107 |
+
</section>
|
| 108 |
+
|
| 109 |
+
<div class="stats">
|
| 110 |
+
<div class="stat"><div class="stat-num" data-target="6050">0</div><div class="stat-label">MRIs Trained On</div></div>
|
| 111 |
+
<div class="stat"><div class="stat-num" data-target="2.5" data-suffix="y">0</div><div class="stat-label">MAE Lifespan</div></div>
|
| 112 |
+
<div class="stat"><div class="stat-num" data-target="1.05" data-suffix="y">0</div><div class="stat-label">MAE Pediatric</div></div>
|
| 113 |
+
<div class="stat"><div class="stat-num" data-target="70">0</div><div class="stat-label">Brain Regions</div></div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<section class="section" id="features">
|
| 117 |
+
<h2 class="section-title">Features</h2>
|
| 118 |
+
<div class="features-grid">
|
| 119 |
+
<div class="feature-card">
|
| 120 |
+
<div class="feature-icon">🔬</div>
|
| 121 |
+
<h3>3D Brain Visualization</h3>
|
| 122 |
+
<p>Interactive NiiVue rendering with Three.js mesh overlays. Explore brain regions in full 3D with severity heatmaps and region highlighting.</p>
|
| 123 |
+
</div>
|
| 124 |
+
<div class="feature-card">
|
| 125 |
+
<div class="feature-icon">🤖</div>
|
| 126 |
+
<h3>AI-Powered Age Prediction</h3>
|
| 127 |
+
<p>SFCN dual-branch model (3D CNN + tabular MLP) predicts brain age from T1-weighted MRI. Trained on 6,050 healthy subjects across 12 datasets.</p>
|
| 128 |
+
</div>
|
| 129 |
+
<div class="feature-card">
|
| 130 |
+
<div class="feature-icon">📊</div>
|
| 131 |
+
<h3>Clinical Insights & Reports</h3>
|
| 132 |
+
<p>LLM-generated explanations for each scan. Per-region z-scores, cognitive domain mapping, and downloadable PDF reports with clinical narratives.</p>
|
| 133 |
+
</div>
|
| 134 |
+
</div>
|
| 135 |
+
</section>
|
| 136 |
+
|
| 137 |
+
<section class="section" id="how-it-works">
|
| 138 |
+
<h2 class="section-title">How It Works</h2>
|
| 139 |
+
<div class="steps">
|
| 140 |
+
<div class="step"><h4>Upload MRI</h4><p>Upload a T1-weighted NIfTI brain scan through the secure web interface.</p></div>
|
| 141 |
+
<div class="step"><h4>AI Processing</h4><p>Preprocessing, skull-stripping, MNI registration, and atlas-based segmentation.</p></div>
|
| 142 |
+
<div class="step"><h4>Brain Age Prediction</h4><p>SFCN model predicts brain age. Brain age gap computed against chronological age.</p></div>
|
| 143 |
+
<div class="step"><h4>Clinical Report</h4><p>AI generates region-by-region analysis, z-scores, cognitive mapping, and PDF report.</p></div>
|
| 144 |
+
</div>
|
| 145 |
+
</section>
|
| 146 |
+
|
| 147 |
+
<div class="footer">
|
| 148 |
+
<p>BrainAge AI — Built with PyTorch, FastAPI, and Groq LLM</p>
|
| 149 |
+
<p style="margin-top:4px">Model: bilalEthizo/BrainAge-SFCN • 6,050 subjects • 12 datasets</p>
|
| 150 |
+
</div>
|
| 151 |
+
</div>
|
| 152 |
+
|
| 153 |
+
<!-- Chat Widget -->
|
| 154 |
+
<button class="chat-fab" id="chatFab" title="Chat with BrainAge AI">💬</button>
|
| 155 |
+
<div class="chat-panel" id="chatPanel">
|
| 156 |
+
<div class="chat-header"><span class="dot"></span> BrainAge AI Assistant</div>
|
| 157 |
+
<div class="chat-messages" id="chatMessages">
|
| 158 |
+
<div class="msg msg-bot">Hi! I'm the BrainAge AI assistant. Ask me anything about brain age prediction, our model, or the platform.</div>
|
| 159 |
+
</div>
|
| 160 |
+
<div class="chat-input">
|
| 161 |
+
<input type="text" id="chatInput" placeholder="Ask about BrainAge AI…" autocomplete="off">
|
| 162 |
+
<button id="chatSend">Send</button>
|
| 163 |
+
</div>
|
| 164 |
+
</div>
|
| 165 |
+
|
| 166 |
+
<!-- Three.js 3D Brain -->
|
| 167 |
+
<script type="importmap">{"imports":{"three":"https://esm.sh/three@0.164.0"}}</script>
|
| 168 |
+
<script type="module">
|
| 169 |
+
import * as THREE from 'three';
|
| 170 |
+
|
| 171 |
+
const canvas = document.getElementById('brain3d');
|
| 172 |
+
const scene = new THREE.Scene();
|
| 173 |
+
const camera = new THREE.PerspectiveCamera(60, innerWidth/innerHeight, 0.1, 100);
|
| 174 |
+
camera.position.z = 4;
|
| 175 |
+
const renderer = new THREE.WebGLRenderer({canvas, alpha: true, antialias: true});
|
| 176 |
+
renderer.setSize(innerWidth, innerHeight);
|
| 177 |
+
renderer.setPixelRatio(Math.min(devicePixelRatio, 2));
|
| 178 |
+
|
| 179 |
+
// Brain-like sphere
|
| 180 |
+
const geo = new THREE.IcosahedronGeometry(1.5, 4);
|
| 181 |
+
const pos = geo.attributes.position;
|
| 182 |
+
for(let i=0; i<pos.count; i++){
|
| 183 |
+
const v = new THREE.Vector3(pos.getX(i), pos.getY(i), pos.getZ(i));
|
| 184 |
+
const noise = 1 + 0.15*Math.sin(v.x*5)*Math.cos(v.y*4)*Math.sin(v.z*3)
|
| 185 |
+
+ 0.08*Math.sin(v.x*12+v.y*8);
|
| 186 |
+
pos.setXYZ(i, v.x*noise, v.y*noise, v.z*noise);
|
| 187 |
+
}
|
| 188 |
+
geo.computeVertexNormals();
|
| 189 |
+
|
| 190 |
+
const wire = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
|
| 191 |
+
color: 0x6366f1, wireframe: true, transparent: true, opacity: 0.12
|
| 192 |
+
}));
|
| 193 |
+
scene.add(wire);
|
| 194 |
+
|
| 195 |
+
const glow = new THREE.Mesh(geo, new THREE.MeshBasicMaterial({
|
| 196 |
+
color: 0x06b6d4, wireframe: true, transparent: true, opacity: 0.06
|
| 197 |
+
}));
|
| 198 |
+
glow.scale.setScalar(1.02);
|
| 199 |
+
scene.add(glow);
|
| 200 |
+
|
| 201 |
+
// Particles
|
| 202 |
+
const pGeo = new THREE.BufferGeometry();
|
| 203 |
+
const pCount = 600;
|
| 204 |
+
const pPos = new Float32Array(pCount*3);
|
| 205 |
+
for(let i=0;i<pCount*3;i++) pPos[i]=(Math.random()-0.5)*8;
|
| 206 |
+
pGeo.setAttribute('position', new THREE.BufferAttribute(pPos,3));
|
| 207 |
+
const particles = new THREE.Points(pGeo, new THREE.PointsMaterial({
|
| 208 |
+
color: 0x6366f1, size: 0.015, transparent: true, opacity: 0.4
|
| 209 |
+
}));
|
| 210 |
+
scene.add(particles);
|
| 211 |
+
|
| 212 |
+
let mx=0, my=0;
|
| 213 |
+
document.addEventListener('mousemove', e=>{
|
| 214 |
+
mx=(e.clientX/innerWidth-0.5)*0.5;
|
| 215 |
+
my=(e.clientY/innerHeight-0.5)*0.5;
|
| 216 |
+
});
|
| 217 |
+
window.addEventListener('resize', ()=>{
|
| 218 |
+
camera.aspect=innerWidth/innerHeight;
|
| 219 |
+
camera.updateProjectionMatrix();
|
| 220 |
+
renderer.setSize(innerWidth,innerHeight);
|
| 221 |
+
});
|
| 222 |
+
|
| 223 |
+
(function animate(){
|
| 224 |
+
requestAnimationFrame(animate);
|
| 225 |
+
const t=Date.now()*0.001;
|
| 226 |
+
wire.rotation.y=t*0.15+mx*0.3;
|
| 227 |
+
wire.rotation.x=Math.sin(t*0.1)*0.1+my*0.3;
|
| 228 |
+
glow.rotation.copy(wire.rotation);
|
| 229 |
+
particles.rotation.y=t*0.03;
|
| 230 |
+
renderer.render(scene,camera);
|
| 231 |
+
})();
|
| 232 |
+
</script>
|
| 233 |
+
|
| 234 |
+
<!-- Chat JS -->
|
| 235 |
+
<script>
|
| 236 |
+
const fab=document.getElementById('chatFab'),panel=document.getElementById('chatPanel'),msgs=document.getElementById('chatMessages'),inp=document.getElementById('chatInput'),sendBtn=document.getElementById('chatSend');
|
| 237 |
+
let open=false;
|
| 238 |
+
fab.onclick=()=>{open=!open;panel.classList.toggle('open',open);if(open)inp.focus()};
|
| 239 |
+
|
| 240 |
+
async function sendMsg(){
|
| 241 |
+
const text=inp.value.trim();if(!text)return;
|
| 242 |
+
inp.value='';
|
| 243 |
+
msgs.innerHTML+=`<div class="msg msg-user">${text}</div>`;
|
| 244 |
+
const typing=document.createElement('div');typing.className='typing';typing.innerHTML='<span></span><span></span><span></span>';msgs.appendChild(typing);
|
| 245 |
+
msgs.scrollTop=msgs.scrollHeight;
|
| 246 |
+
|
| 247 |
+
try{
|
| 248 |
+
const res=await fetch('/api/chat/landing',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:text})});
|
| 249 |
+
const reader=res.body.getReader();const dec=new TextDecoder();
|
| 250 |
+
typing.remove();
|
| 251 |
+
const bot=document.createElement('div');bot.className='msg msg-bot';msgs.appendChild(bot);
|
| 252 |
+
let buf='';
|
| 253 |
+
while(true){
|
| 254 |
+
const{done,value}=await reader.read();if(done)break;
|
| 255 |
+
buf+=dec.decode(value,{stream:true});
|
| 256 |
+
const lines=buf.split('\n');buf=lines.pop()||'';
|
| 257 |
+
for(const line of lines){
|
| 258 |
+
if(line.startsWith('data: ')&&!line.includes('[DONE]')){
|
| 259 |
+
try{const d=JSON.parse(line.slice(6));bot.textContent+=d.content||''}catch{}
|
| 260 |
+
}
|
| 261 |
+
}
|
| 262 |
+
msgs.scrollTop=msgs.scrollHeight;
|
| 263 |
+
}
|
| 264 |
+
}catch(e){typing.remove();msgs.innerHTML+=`<div class="msg msg-bot">Sorry, an error occurred.</div>`}
|
| 265 |
+
}
|
| 266 |
+
sendBtn.onclick=sendMsg;
|
| 267 |
+
inp.onkeydown=e=>{if(e.key==='Enter')sendMsg()};
|
| 268 |
+
|
| 269 |
+
// Counter animation
|
| 270 |
+
const observer=new IntersectionObserver(entries=>{entries.forEach(e=>{if(e.isIntersecting){
|
| 271 |
+
const el=e.target;const target=parseFloat(el.dataset.target);const suffix=el.dataset.suffix||'';
|
| 272 |
+
const isFloat=target%1!==0;const duration=2000;const start=Date.now();
|
| 273 |
+
(function tick(){const p=Math.min((Date.now()-start)/duration,1);const ease=1-Math.pow(1-p,3);
|
| 274 |
+
const v=target*ease;el.textContent=(isFloat?v.toFixed(v<10?2:1):Math.floor(v).toLocaleString())+(suffix&&p>=1?suffix:'');
|
| 275 |
+
if(p<1)requestAnimationFrame(tick)})();
|
| 276 |
+
observer.unobserve(el)}})},{threshold:0.5});
|
| 277 |
+
document.querySelectorAll('.stat-num[data-target]').forEach(el=>observer.observe(el));
|
| 278 |
+
</script>
|
| 279 |
+
</body>
|
| 280 |
+
</html>
|
app/templates/login.html
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>Sign In — BrainAge AI</title>
|
| 6 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 7 |
+
<style>
|
| 8 |
+
:root{--bg:#0a0a0f;--surface:rgba(255,255,255,.04);--border:rgba(255,255,255,.08);--text:#e2e8f0;--text-muted:#64748b;--primary:#6366f1;--accent:#06b6d4;--danger:#ef4444}
|
| 9 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 10 |
+
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex}
|
| 11 |
+
canvas#bgCanvas{position:fixed;top:0;left:0;width:100%;height:100%;z-index:0}
|
| 12 |
+
|
| 13 |
+
.left{flex:1;display:flex;align-items:center;justify-content:center;position:relative;z-index:1}
|
| 14 |
+
.right{width:480px;display:flex;flex-direction:column;align-items:center;justify-content:center;padding:48px;position:relative;z-index:1;border-left:1px solid var(--border);background:rgba(10,10,15,.7);backdrop-filter:blur(40px)}
|
| 15 |
+
@media(max-width:768px){.left{display:none}.right{width:100%;border:none}}
|
| 16 |
+
|
| 17 |
+
.brand{display:flex;align-items:center;gap:10px;margin-bottom:8px}
|
| 18 |
+
.brand-icon{font-size:32px}
|
| 19 |
+
.brand-text{font-family:'Space Grotesk';font-size:28px;font-weight:700;background:linear-gradient(135deg,var(--primary),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
| 20 |
+
.subtitle{color:var(--text-muted);font-size:14px;margin-bottom:36px}
|
| 21 |
+
|
| 22 |
+
.form-card{width:100%;max-width:360px}
|
| 23 |
+
.field{margin-bottom:20px;position:relative}
|
| 24 |
+
.field label{display:block;font-size:12px;font-weight:500;color:var(--text-muted);margin-bottom:6px;text-transform:uppercase;letter-spacing:.5px}
|
| 25 |
+
.field input{width:100%;padding:12px 14px 12px 42px;background:var(--surface);border:1px solid var(--border);border-radius:10px;color:var(--text);font-size:14px;font-family:inherit;transition:all .2s}
|
| 26 |
+
.field input:focus{outline:none;border-color:var(--primary);box-shadow:0 0 0 3px rgba(99,102,241,.15)}
|
| 27 |
+
.field .icon{position:absolute;left:14px;bottom:14px;color:var(--text-muted);font-size:16px}
|
| 28 |
+
|
| 29 |
+
.error{background:rgba(239,68,68,.1);border:1px solid rgba(239,68,68,.2);color:var(--danger);padding:10px 14px;border-radius:8px;font-size:13px;margin-bottom:20px;text-align:center}
|
| 30 |
+
|
| 31 |
+
.btn-login{width:100%;padding:12px;background:linear-gradient(135deg,var(--primary),#8b5cf6);color:#fff;border:none;border-radius:10px;font-size:15px;font-weight:600;font-family:inherit;cursor:pointer;transition:all .2s}
|
| 32 |
+
.btn-login:hover{transform:translateY(-1px);box-shadow:0 8px 25px rgba(99,102,241,.3)}
|
| 33 |
+
|
| 34 |
+
.footer-link{margin-top:24px;font-size:13px;color:var(--text-muted)}
|
| 35 |
+
.footer-link a{color:var(--primary);text-decoration:none}
|
| 36 |
+
|
| 37 |
+
/* Left side big text */
|
| 38 |
+
.left-content{text-align:center;padding:48px}
|
| 39 |
+
.left-content h2{font-family:'Space Grotesk';font-size:clamp(28px,4vw,42px);font-weight:700;margin-bottom:16px;background:linear-gradient(135deg,var(--primary),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent}
|
| 40 |
+
.left-content p{font-size:16px;color:var(--text-muted);max-width:400px;line-height:1.6}
|
| 41 |
+
|
| 42 |
+
.toggle-pw{position:absolute;right:14px;bottom:12px;background:none;border:none;color:var(--text-muted);cursor:pointer;font-size:14px;padding:2px}
|
| 43 |
+
</style>
|
| 44 |
+
</head>
|
| 45 |
+
<body>
|
| 46 |
+
|
| 47 |
+
<canvas id="bgCanvas"></canvas>
|
| 48 |
+
|
| 49 |
+
<div class="left">
|
| 50 |
+
<div class="left-content">
|
| 51 |
+
<h2>Unlock Brain<br>Intelligence</h2>
|
| 52 |
+
<p>Sign in to upload MRI scans, predict brain age with AI, and generate clinical insights.</p>
|
| 53 |
+
</div>
|
| 54 |
+
</div>
|
| 55 |
+
|
| 56 |
+
<div class="right">
|
| 57 |
+
<div class="brand">
|
| 58 |
+
<span class="brand-icon">🧠</span>
|
| 59 |
+
<span class="brand-text">BrainAge AI</span>
|
| 60 |
+
</div>
|
| 61 |
+
<p class="subtitle">Sign in to your account</p>
|
| 62 |
+
|
| 63 |
+
<form method="post" action="/login" class="form-card">
|
| 64 |
+
{% if error %}
|
| 65 |
+
<div class="error">{{ error }}</div>
|
| 66 |
+
{% endif %}
|
| 67 |
+
|
| 68 |
+
<div class="field">
|
| 69 |
+
<label>Username</label>
|
| 70 |
+
<span class="icon">👤</span>
|
| 71 |
+
<input type="text" name="username" required placeholder="Enter your username" autocomplete="username" autofocus>
|
| 72 |
+
</div>
|
| 73 |
+
<div class="field">
|
| 74 |
+
<label>Password</label>
|
| 75 |
+
<span class="icon">🔒</span>
|
| 76 |
+
<input type="password" name="password" id="pw" required placeholder="Enter your password" autocomplete="current-password">
|
| 77 |
+
<button type="button" class="toggle-pw" onclick="const p=document.getElementById('pw');p.type=p.type==='password'?'text':'password';this.textContent=p.type==='password'?'👁':'🙈'">👁</button>
|
| 78 |
+
</div>
|
| 79 |
+
<button type="submit" class="btn-login">Sign In →</button>
|
| 80 |
+
</form>
|
| 81 |
+
|
| 82 |
+
<p class="footer-link"><a href="/">← Back to home</a></p>
|
| 83 |
+
</div>
|
| 84 |
+
|
| 85 |
+
<script>
|
| 86 |
+
const c=document.getElementById('bgCanvas'),ctx=c.getContext('2d');
|
| 87 |
+
c.width=innerWidth;c.height=innerHeight;
|
| 88 |
+
const particles=[];for(let i=0;i<60;i++)particles.push({x:Math.random()*c.width,y:Math.random()*c.height,vx:(Math.random()-.5)*.3,vy:(Math.random()-.5)*.3,r:Math.random()*2+1});
|
| 89 |
+
(function draw(){
|
| 90 |
+
ctx.clearRect(0,0,c.width,c.height);
|
| 91 |
+
particles.forEach(p=>{
|
| 92 |
+
p.x+=p.vx;p.y+=p.vy;
|
| 93 |
+
if(p.x<0||p.x>c.width)p.vx*=-1;
|
| 94 |
+
if(p.y<0||p.y>c.height)p.vy*=-1;
|
| 95 |
+
ctx.beginPath();ctx.arc(p.x,p.y,p.r,0,Math.PI*2);
|
| 96 |
+
ctx.fillStyle='rgba(99,102,241,.15)';ctx.fill();
|
| 97 |
+
});
|
| 98 |
+
particles.forEach((a,i)=>{
|
| 99 |
+
for(let j=i+1;j<particles.length;j++){
|
| 100 |
+
const b=particles[j],dx=a.x-b.x,dy=a.y-b.y,d=Math.sqrt(dx*dx+dy*dy);
|
| 101 |
+
if(d<120){ctx.beginPath();ctx.moveTo(a.x,a.y);ctx.lineTo(b.x,b.y);ctx.strokeStyle=`rgba(99,102,241,${.06*(1-d/120)})`;ctx.stroke()}
|
| 102 |
+
}
|
| 103 |
+
});
|
| 104 |
+
requestAnimationFrame(draw);
|
| 105 |
+
})();
|
| 106 |
+
window.addEventListener('resize',()=>{c.width=innerWidth;c.height=innerHeight});
|
| 107 |
+
</script>
|
| 108 |
+
</body>
|
| 109 |
+
</html>
|
app/templates/patients/new.html
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>New Patient — BrainAge AI</title>
|
| 6 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 7 |
+
<style>
|
| 8 |
+
:root{--bg:#0a0a0f;--surface:rgba(255,255,255,.04);--border:rgba(255,255,255,.08);--text:#e2e8f0;--text-muted:#64748b;--primary:#6366f1;--accent:#06b6d4}
|
| 9 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 10 |
+
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
| 11 |
+
.nav{display:flex;align-items:center;justify-content:space-between;padding:16px 32px;border-bottom:1px solid var(--border);backdrop-filter:blur(20px);background:rgba(10,10,15,.8)}
|
| 12 |
+
.nav-brand{font-family:'Space Grotesk';font-weight:700;font-size:20px;background:linear-gradient(135deg,var(--primary),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent;text-decoration:none}
|
| 13 |
+
.container{max-width:600px;margin:0 auto;padding:48px 24px}
|
| 14 |
+
h1{font-family:'Space Grotesk';font-size:24px;margin-bottom:8px}
|
| 15 |
+
.subtitle{color:var(--text-muted);font-size:14px;margin-bottom:32px}
|
| 16 |
+
.form-card{background:var(--surface);border:1px solid var(--border);border-radius:16px;padding:32px}
|
| 17 |
+
.field{margin-bottom:20px}
|
| 18 |
+
.field label{display:block;font-size:13px;font-weight:500;margin-bottom:6px;color:var(--text-muted)}
|
| 19 |
+
.field input,.field select{width:100%;padding:10px 14px;background:rgba(255,255,255,.04);border:1px solid var(--border);border-radius:8px;color:var(--text);font-size:14px;font-family:inherit;transition:border-color .2s}
|
| 20 |
+
.field input:focus,.field select:focus{outline:none;border-color:var(--primary)}
|
| 21 |
+
.row{display:grid;grid-template-columns:1fr 1fr;gap:16px}
|
| 22 |
+
.btn{display:inline-flex;align-items:center;gap:8px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none;transition:all .2s;border:none;cursor:pointer}
|
| 23 |
+
.btn-primary{background:linear-gradient(135deg,var(--primary),#8b5cf6);color:#fff;width:100%;justify-content:center}
|
| 24 |
+
.btn-primary:hover{opacity:.9;transform:translateY(-1px)}
|
| 25 |
+
.back{color:var(--text-muted);text-decoration:none;font-size:13px;display:inline-flex;align-items:center;gap:4px;margin-bottom:24px}
|
| 26 |
+
.back:hover{color:var(--text)}
|
| 27 |
+
</style>
|
| 28 |
+
</head>
|
| 29 |
+
<body>
|
| 30 |
+
<nav class="nav">
|
| 31 |
+
<a href="/dashboard" class="nav-brand">🧠 BrainAge AI</a>
|
| 32 |
+
</nav>
|
| 33 |
+
<div class="container">
|
| 34 |
+
<a href="/dashboard" class="back">← Back to Dashboard</a>
|
| 35 |
+
<h1>New Patient</h1>
|
| 36 |
+
<p class="subtitle">Enter patient demographics to begin brain age analysis.</p>
|
| 37 |
+
<form method="post" action="/patients/new" class="form-card">
|
| 38 |
+
<div class="field">
|
| 39 |
+
<label>Full Name *</label>
|
| 40 |
+
<input type="text" name="full_name" required placeholder="John Doe">
|
| 41 |
+
</div>
|
| 42 |
+
<div class="row">
|
| 43 |
+
<div class="field">
|
| 44 |
+
<label>Age (years)</label>
|
| 45 |
+
<input type="number" name="age_years" step="0.1" min="0" max="120" placeholder="25">
|
| 46 |
+
</div>
|
| 47 |
+
<div class="field">
|
| 48 |
+
<label>Sex</label>
|
| 49 |
+
<select name="sex"><option value="M">Male</option><option value="F">Female</option><option value="U" selected>Unknown</option></select>
|
| 50 |
+
</div>
|
| 51 |
+
</div>
|
| 52 |
+
<div class="row">
|
| 53 |
+
<div class="field">
|
| 54 |
+
<label>MRN (auto-generated if empty)</label>
|
| 55 |
+
<input type="text" name="mrn" placeholder="Optional">
|
| 56 |
+
</div>
|
| 57 |
+
<div class="field">
|
| 58 |
+
<label>Date of Birth</label>
|
| 59 |
+
<input type="date" name="date_of_birth">
|
| 60 |
+
</div>
|
| 61 |
+
</div>
|
| 62 |
+
<button type="submit" class="btn btn-primary">Create Patient & Upload MRI →</button>
|
| 63 |
+
</form>
|
| 64 |
+
</div>
|
| 65 |
+
</body>
|
| 66 |
+
</html>
|
app/templates/patients/processing.html
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>Processing — BrainAge AI</title>
|
| 6 |
+
<meta http-equiv="refresh" content="2;url=/scans/{{ scan.id }}/process">
|
| 7 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&family=Space+Grotesk:wght@500;700&display=swap" rel="stylesheet">
|
| 8 |
+
<style>
|
| 9 |
+
:root{--bg:#0a0a0f;--primary:#6366f1;--accent:#06b6d4;--text:#e2e8f0;--text-muted:#64748b}
|
| 10 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 11 |
+
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh;display:flex;align-items:center;justify-content:center}
|
| 12 |
+
.center{text-align:center;max-width:400px}
|
| 13 |
+
.spinner{width:80px;height:80px;margin:0 auto 24px;position:relative}
|
| 14 |
+
.spinner::before,.spinner::after{content:'';position:absolute;inset:0;border-radius:50%;border:3px solid transparent}
|
| 15 |
+
.spinner::before{border-top-color:var(--primary);animation:spin 1s linear infinite}
|
| 16 |
+
.spinner::after{border-bottom-color:var(--accent);animation:spin 1.5s linear infinite reverse}
|
| 17 |
+
@keyframes spin{to{transform:rotate(360deg)}}
|
| 18 |
+
h2{font-family:'Space Grotesk';font-size:20px;margin-bottom:8px}
|
| 19 |
+
p{color:var(--text-muted);font-size:14px}
|
| 20 |
+
.brain{font-size:40px;margin-bottom:16px;animation:pulse 2s ease-in-out infinite}
|
| 21 |
+
@keyframes pulse{0%,100%{transform:scale(1)}50%{transform:scale(1.1)}}
|
| 22 |
+
</style>
|
| 23 |
+
</head>
|
| 24 |
+
<body>
|
| 25 |
+
<div class="center">
|
| 26 |
+
<div class="brain">🧠</div>
|
| 27 |
+
<div class="spinner"></div>
|
| 28 |
+
<h2>Analyzing Brain MRI</h2>
|
| 29 |
+
<p>Running AI pipeline: preprocessing, segmentation,<br>age prediction, and clinical analysis…</p>
|
| 30 |
+
<p style="margin-top:16px;font-size:12px;color:var(--text-muted)">Patient: {{ patient.full_name }}</p>
|
| 31 |
+
</div>
|
| 32 |
+
</body>
|
| 33 |
+
</html>
|
app/templates/patients/upload.html
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>Upload MRI — BrainAge AI</title>
|
| 6 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 7 |
+
<style>
|
| 8 |
+
:root{--bg:#0a0a0f;--surface:rgba(255,255,255,.04);--border:rgba(255,255,255,.08);--text:#e2e8f0;--text-muted:#64748b;--primary:#6366f1;--accent:#06b6d4}
|
| 9 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 10 |
+
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);min-height:100vh}
|
| 11 |
+
.nav{display:flex;align-items:center;padding:16px 32px;border-bottom:1px solid var(--border);background:rgba(10,10,15,.8)}
|
| 12 |
+
.nav-brand{font-family:'Space Grotesk';font-weight:700;font-size:20px;background:linear-gradient(135deg,var(--primary),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent;text-decoration:none}
|
| 13 |
+
.container{max-width:600px;margin:0 auto;padding:48px 24px}
|
| 14 |
+
h1{font-family:'Space Grotesk';font-size:24px;margin-bottom:8px}
|
| 15 |
+
.subtitle{color:var(--text-muted);font-size:14px;margin-bottom:32px}
|
| 16 |
+
.patient-info{background:var(--surface);border:1px solid var(--border);border-radius:12px;padding:16px;margin-bottom:24px;display:flex;gap:24px;font-size:13px}
|
| 17 |
+
.patient-info strong{color:var(--accent)}
|
| 18 |
+
.upload-zone{background:var(--surface);border:2px dashed var(--border);border-radius:16px;padding:48px;text-align:center;transition:all .3s;cursor:pointer}
|
| 19 |
+
.upload-zone:hover,.upload-zone.drag{border-color:var(--primary);background:rgba(99,102,241,.05)}
|
| 20 |
+
.upload-zone svg{width:48px;height:48px;color:var(--primary);margin-bottom:16px}
|
| 21 |
+
.upload-zone p{color:var(--text-muted);font-size:14px;margin-bottom:8px}
|
| 22 |
+
.upload-zone small{color:var(--text-muted);font-size:12px}
|
| 23 |
+
.upload-zone input[type=file]{display:none}
|
| 24 |
+
.btn{display:inline-flex;align-items:center;gap:8px;padding:10px 20px;border-radius:8px;font-size:14px;font-weight:500;text-decoration:none;transition:all .2s;border:none;cursor:pointer}
|
| 25 |
+
.btn-primary{background:linear-gradient(135deg,var(--primary),#8b5cf6);color:#fff;width:100%;justify-content:center;margin-top:24px}
|
| 26 |
+
.btn-primary:hover{opacity:.9}
|
| 27 |
+
.btn-primary:disabled{opacity:.4;cursor:not-allowed}
|
| 28 |
+
.back{color:var(--text-muted);text-decoration:none;font-size:13px;margin-bottom:24px;display:inline-block}
|
| 29 |
+
.selected-file{margin-top:12px;padding:10px 14px;background:rgba(34,197,94,.1);border-radius:8px;font-size:13px;color:#22c55e;display:none}
|
| 30 |
+
</style>
|
| 31 |
+
</head>
|
| 32 |
+
<body>
|
| 33 |
+
<nav class="nav"><a href="/dashboard" class="nav-brand">🧠 BrainAge AI</a></nav>
|
| 34 |
+
<div class="container">
|
| 35 |
+
<a href="/dashboard" class="back">← Dashboard</a>
|
| 36 |
+
<h1>Upload MRI Scan</h1>
|
| 37 |
+
<p class="subtitle">Upload a T1-weighted NIfTI file for brain age analysis.</p>
|
| 38 |
+
<div class="patient-info">
|
| 39 |
+
<div><strong>Patient:</strong> {{ patient.full_name }}</div>
|
| 40 |
+
<div><strong>MRN:</strong> {{ patient.mrn }}</div>
|
| 41 |
+
<div><strong>Age:</strong> {{ patient.age_years or '—' }}{% if patient.age_years %}y{% endif %}</div>
|
| 42 |
+
</div>
|
| 43 |
+
<form method="post" action="/patients/{{ patient.id }}/scans" enctype="multipart/form-data" id="uploadForm">
|
| 44 |
+
<div class="upload-zone" id="dropZone" onclick="document.getElementById('fileInput').click()">
|
| 45 |
+
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24" stroke="currentColor"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5" d="M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5m-13.5-9L12 3m0 0l4.5 4.5M12 3v13.5"/></svg>
|
| 46 |
+
<p>Drop NIfTI file here or click to browse</p>
|
| 47 |
+
<small>Supports .nii, .nii.gz — Max 200 MB</small>
|
| 48 |
+
<input type="file" id="fileInput" name="mri_file" accept=".nii,.nii.gz,.gz" required>
|
| 49 |
+
</div>
|
| 50 |
+
<div class="selected-file" id="selectedFile"></div>
|
| 51 |
+
<button type="submit" class="btn btn-primary" id="submitBtn" disabled>Analyze Brain →</button>
|
| 52 |
+
</form>
|
| 53 |
+
</div>
|
| 54 |
+
<script>
|
| 55 |
+
const dropZone=document.getElementById('dropZone'),fileInput=document.getElementById('fileInput'),selectedFile=document.getElementById('selectedFile'),submitBtn=document.getElementById('submitBtn');
|
| 56 |
+
fileInput.addEventListener('change',()=>{if(fileInput.files.length){selectedFile.style.display='block';selectedFile.textContent='✓ '+fileInput.files[0].name+' ('+Math.round(fileInput.files[0].size/1e6)+' MB)';submitBtn.disabled=false}});
|
| 57 |
+
['dragover','dragenter'].forEach(e=>dropZone.addEventListener(e,ev=>{ev.preventDefault();dropZone.classList.add('drag')}));
|
| 58 |
+
['dragleave','drop'].forEach(e=>dropZone.addEventListener(e,ev=>{ev.preventDefault();dropZone.classList.remove('drag')}));
|
| 59 |
+
dropZone.addEventListener('drop',e=>{fileInput.files=e.dataTransfer.files;fileInput.dispatchEvent(new Event('change'))});
|
| 60 |
+
</script>
|
| 61 |
+
</body>
|
| 62 |
+
</html>
|
app/templates/viewer.html
ADDED
|
@@ -0,0 +1,270 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
<!DOCTYPE html>
|
| 2 |
+
<html lang="en">
|
| 3 |
+
<head>
|
| 4 |
+
<meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
|
| 5 |
+
<title>Brain Viewer — BrainAge AI</title>
|
| 6 |
+
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600;700&family=Space+Grotesk:wght@400;500;600;700&display=swap" rel="stylesheet">
|
| 7 |
+
<style>
|
| 8 |
+
:root{--bg:#0a0a0f;--surface:rgba(255,255,255,.04);--surface-hover:rgba(255,255,255,.08);--border:rgba(255,255,255,.08);--text:#e2e8f0;--text-muted:#64748b;--primary:#6366f1;--accent:#06b6d4;--success:#22c55e;--warning:#f59e0b;--danger:#ef4444}
|
| 9 |
+
*{margin:0;padding:0;box-sizing:border-box}
|
| 10 |
+
body{font-family:'Inter',sans-serif;background:var(--bg);color:var(--text);height:100vh;display:flex;flex-direction:column;overflow:hidden}
|
| 11 |
+
|
| 12 |
+
.top-bar{display:flex;align-items:center;justify-content:space-between;padding:10px 20px;border-bottom:1px solid var(--border);background:rgba(10,10,15,.9);backdrop-filter:blur(20px);flex-shrink:0}
|
| 13 |
+
.top-bar .brand{font-family:'Space Grotesk';font-weight:700;font-size:16px;background:linear-gradient(135deg,var(--primary),var(--accent));-webkit-background-clip:text;-webkit-text-fill-color:transparent;text-decoration:none}
|
| 14 |
+
.top-bar .patient-info{display:flex;gap:16px;align-items:center;font-size:13px}
|
| 15 |
+
.top-bar .patient-info span{color:var(--text-muted)}
|
| 16 |
+
.top-bar .patient-info strong{color:var(--accent)}
|
| 17 |
+
.top-bar .actions{display:flex;gap:8px}
|
| 18 |
+
.btn-sm{padding:6px 12px;border-radius:6px;font-size:12px;font-weight:500;border:none;cursor:pointer;text-decoration:none;transition:all .2s}
|
| 19 |
+
.btn-sm-primary{background:var(--primary);color:#fff}
|
| 20 |
+
.btn-sm-ghost{background:var(--surface);color:var(--text);border:1px solid var(--border)}
|
| 21 |
+
.btn-sm:hover{opacity:.85}
|
| 22 |
+
|
| 23 |
+
.main{display:flex;flex:1;overflow:hidden}
|
| 24 |
+
|
| 25 |
+
/* Left panel — viewer */
|
| 26 |
+
.viewer-panel{flex:1;display:flex;flex-direction:column;min-width:0}
|
| 27 |
+
.viewer-area{flex:1;position:relative;background:#000}
|
| 28 |
+
.viewer-area canvas{width:100%!important;height:100%!important}
|
| 29 |
+
.viewer-placeholder{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;flex-direction:column;gap:12px;color:var(--text-muted)}
|
| 30 |
+
|
| 31 |
+
/* Right panel — analysis */
|
| 32 |
+
.analysis-panel{width:400px;border-left:1px solid var(--border);display:flex;flex-direction:column;overflow:hidden;flex-shrink:0}
|
| 33 |
+
.tabs{display:flex;border-bottom:1px solid var(--border);flex-shrink:0}
|
| 34 |
+
.tab{flex:1;padding:10px;text-align:center;font-size:12px;font-weight:500;cursor:pointer;color:var(--text-muted);border-bottom:2px solid transparent;transition:all .2s}
|
| 35 |
+
.tab.active{color:var(--primary);border-bottom-color:var(--primary)}
|
| 36 |
+
.tab-content{display:none;flex:1;overflow-y:auto;padding:16px}
|
| 37 |
+
.tab-content.active{display:block}
|
| 38 |
+
|
| 39 |
+
/* Summary cards */
|
| 40 |
+
.summary-grid{display:grid;grid-template-columns:1fr 1fr;gap:10px;margin-bottom:16px}
|
| 41 |
+
.summary-card{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:14px;text-align:center}
|
| 42 |
+
.summary-card .label{font-size:11px;color:var(--text-muted);text-transform:uppercase;letter-spacing:.5px}
|
| 43 |
+
.summary-card .value{font-family:'Space Grotesk';font-size:22px;font-weight:700;margin-top:4px}
|
| 44 |
+
.summary-card .value.positive{color:var(--danger)}
|
| 45 |
+
.summary-card .value.negative{color:var(--success)}
|
| 46 |
+
.summary-card .value.normal{color:var(--accent)}
|
| 47 |
+
|
| 48 |
+
/* Regions list */
|
| 49 |
+
.region-item{display:flex;align-items:center;gap:10px;padding:10px;border-radius:8px;margin-bottom:4px;transition:background .2s}
|
| 50 |
+
.region-item:hover{background:var(--surface-hover)}
|
| 51 |
+
.region-dot{width:10px;height:10px;border-radius:50%;flex-shrink:0}
|
| 52 |
+
.region-name{flex:1;font-size:13px}
|
| 53 |
+
.region-z{font-family:'Space Grotesk';font-size:13px;font-weight:600}
|
| 54 |
+
.region-badge{font-size:10px;padding:2px 6px;border-radius:4px;font-weight:600}
|
| 55 |
+
.sev-normal{color:var(--success);background:rgba(34,197,94,.1)}
|
| 56 |
+
.sev-mild{color:var(--warning);background:rgba(245,158,11,.1)}
|
| 57 |
+
.sev-moderate{color:var(--danger);background:rgba(239,68,68,.1)}
|
| 58 |
+
.sev-severe{color:#f87171;background:rgba(248,113,113,.15)}
|
| 59 |
+
|
| 60 |
+
/* Explanation */
|
| 61 |
+
.explanation{background:var(--surface);border:1px solid var(--border);border-radius:10px;padding:16px;font-size:13px;line-height:1.6;white-space:pre-wrap}
|
| 62 |
+
|
| 63 |
+
/* Chat tab */
|
| 64 |
+
.chat-area{flex:1;display:flex;flex-direction:column;height:100%}
|
| 65 |
+
.chat-messages-viewer{flex:1;overflow-y:auto;padding:12px;display:flex;flex-direction:column;gap:8px}
|
| 66 |
+
.chat-input-viewer{display:flex;gap:6px;padding:12px;border-top:1px solid var(--border)}
|
| 67 |
+
.chat-input-viewer input{flex:1;background:var(--surface);border:1px solid var(--border);border-radius:6px;padding:8px 10px;color:var(--text);font-size:12px;font-family:inherit}
|
| 68 |
+
.chat-input-viewer input:focus{outline:none;border-color:var(--primary)}
|
| 69 |
+
.chat-input-viewer button{background:var(--primary);border:none;color:#fff;padding:8px 12px;border-radius:6px;cursor:pointer;font-size:12px}
|
| 70 |
+
.msg-v{max-width:90%;padding:8px 12px;border-radius:10px;font-size:12px;line-height:1.5}
|
| 71 |
+
.msg-v-user{align-self:flex-end;background:var(--primary);color:#fff;border-bottom-right-radius:2px}
|
| 72 |
+
.msg-v-bot{align-self:flex-start;background:var(--surface);border:1px solid var(--border);border-bottom-left-radius:2px}
|
| 73 |
+
|
| 74 |
+
.loading-text{color:var(--text-muted);font-size:13px;text-align:center;padding:20px}
|
| 75 |
+
</style>
|
| 76 |
+
</head>
|
| 77 |
+
<body>
|
| 78 |
+
|
| 79 |
+
<div class="top-bar">
|
| 80 |
+
<a href="/dashboard" class="brand">🧠 BrainAge AI</a>
|
| 81 |
+
<div class="patient-info">
|
| 82 |
+
<span>Patient: <strong>{{ patient.full_name }}</strong></span>
|
| 83 |
+
<span>Age: <strong>{{ patient.age_years or '—' }}{% if patient.age_years %}y{% endif %}</strong></span>
|
| 84 |
+
<span>Sex: <strong>{{ patient.sex }}</strong></span>
|
| 85 |
+
{% if scan.predicted_age %}
|
| 86 |
+
<span>Predicted: <strong>{{ "%.1f"|format(scan.predicted_age) }}y</strong></span>
|
| 87 |
+
{% endif %}
|
| 88 |
+
{% if scan.brain_age_gap is not none %}
|
| 89 |
+
<span>GAP: <strong style="color:{% if scan.brain_age_gap > 2 %}var(--danger){% elif scan.brain_age_gap < -2 %}var(--success){% else %}var(--accent){% endif %}">{{ "%+.1f"|format(scan.brain_age_gap) }}y</strong></span>
|
| 90 |
+
{% endif %}
|
| 91 |
+
</div>
|
| 92 |
+
<div class="actions">
|
| 93 |
+
<a href="/reports/{{ scan.id }}/pdf" class="btn-sm btn-sm-primary">📄 PDF Report</a>
|
| 94 |
+
<a href="/dashboard" class="btn-sm btn-sm-ghost">← Dashboard</a>
|
| 95 |
+
</div>
|
| 96 |
+
</div>
|
| 97 |
+
|
| 98 |
+
<div class="main">
|
| 99 |
+
<div class="viewer-panel">
|
| 100 |
+
<div class="viewer-area" id="viewerArea">
|
| 101 |
+
<div class="viewer-placeholder" id="viewerPlaceholder">
|
| 102 |
+
{% if scan.status == 'complete' %}
|
| 103 |
+
<div style="font-size:48px">🧠</div>
|
| 104 |
+
<p>Loading 3D brain viewer…</p>
|
| 105 |
+
{% elif scan.status == 'failed' %}
|
| 106 |
+
<div style="font-size:48px">⚠️</div>
|
| 107 |
+
<p>Processing failed: {{ scan.error_message or 'Unknown error' }}</p>
|
| 108 |
+
{% else %}
|
| 109 |
+
<div style="font-size:48px">⏳</div>
|
| 110 |
+
<p>Scan status: {{ scan.status }}</p>
|
| 111 |
+
{% endif %}
|
| 112 |
+
</div>
|
| 113 |
+
</div>
|
| 114 |
+
</div>
|
| 115 |
+
|
| 116 |
+
<div class="analysis-panel">
|
| 117 |
+
<div class="tabs">
|
| 118 |
+
<div class="tab active" data-tab="summary">Summary</div>
|
| 119 |
+
<div class="tab" data-tab="regions">Regions</div>
|
| 120 |
+
<div class="tab" data-tab="chat">AI Chat</div>
|
| 121 |
+
</div>
|
| 122 |
+
|
| 123 |
+
<div class="tab-content active" id="tab-summary">
|
| 124 |
+
<div class="summary-grid">
|
| 125 |
+
<div class="summary-card">
|
| 126 |
+
<div class="label">Brain Age</div>
|
| 127 |
+
<div class="value normal" id="predAge">{{ "%.1f"|format(scan.predicted_age) if scan.predicted_age else '—' }}y</div>
|
| 128 |
+
</div>
|
| 129 |
+
<div class="summary-card">
|
| 130 |
+
<div class="label">Chrono Age</div>
|
| 131 |
+
<div class="value" style="color:var(--text)">{{ patient.age_years or '—' }}{% if patient.age_years %}y{% endif %}</div>
|
| 132 |
+
</div>
|
| 133 |
+
<div class="summary-card">
|
| 134 |
+
<div class="label">Brain Age Gap</div>
|
| 135 |
+
<div class="value {% if scan.brain_age_gap and scan.brain_age_gap > 2 %}positive{% elif scan.brain_age_gap and scan.brain_age_gap < -2 %}negative{% else %}normal{% endif %}">
|
| 136 |
+
{{ "%+.1f"|format(scan.brain_age_gap) if scan.brain_age_gap is not none else '—' }}y
|
| 137 |
+
</div>
|
| 138 |
+
</div>
|
| 139 |
+
<div class="summary-card">
|
| 140 |
+
<div class="label">Status</div>
|
| 141 |
+
<div class="value normal" style="font-size:14px">{{ scan.status }}</div>
|
| 142 |
+
</div>
|
| 143 |
+
</div>
|
| 144 |
+
<h3 style="font-size:14px;margin-bottom:8px;font-family:'Space Grotesk'">AI Explanation</h3>
|
| 145 |
+
<div class="explanation" id="explanation"><span class="loading-text">Loading explanation…</span></div>
|
| 146 |
+
</div>
|
| 147 |
+
|
| 148 |
+
<div class="tab-content" id="tab-regions">
|
| 149 |
+
<div id="regionsList"><span class="loading-text">Loading regions…</span></div>
|
| 150 |
+
</div>
|
| 151 |
+
|
| 152 |
+
<div class="tab-content chat-area" id="tab-chat">
|
| 153 |
+
<div class="chat-messages-viewer" id="viewerChat">
|
| 154 |
+
<div class="msg-v msg-v-bot">I'm your AI assistant for this patient's brain scan. Ask me about specific regions, measurements, or request a report.</div>
|
| 155 |
+
</div>
|
| 156 |
+
<div class="chat-input-viewer">
|
| 157 |
+
<input type="text" id="chatInputV" placeholder="Ask about this patient's scan…" autocomplete="off">
|
| 158 |
+
<button id="chatSendV">Send</button>
|
| 159 |
+
</div>
|
| 160 |
+
</div>
|
| 161 |
+
</div>
|
| 162 |
+
</div>
|
| 163 |
+
|
| 164 |
+
<script>
|
| 165 |
+
const SCAN_ID = {{ scan.id }};
|
| 166 |
+
|
| 167 |
+
// Tab switching
|
| 168 |
+
document.querySelectorAll('.tab').forEach(tab => {
|
| 169 |
+
tab.addEventListener('click', () => {
|
| 170 |
+
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
| 171 |
+
document.querySelectorAll('.tab-content').forEach(t => t.classList.remove('active'));
|
| 172 |
+
tab.classList.add('active');
|
| 173 |
+
document.getElementById('tab-' + tab.dataset.tab).classList.add('active');
|
| 174 |
+
});
|
| 175 |
+
});
|
| 176 |
+
|
| 177 |
+
// Load regions
|
| 178 |
+
fetch(`/api/regions/${SCAN_ID}`).then(r=>r.json()).then(data=>{
|
| 179 |
+
const list = document.getElementById('regionsList');
|
| 180 |
+
if (data.regions && data.regions.length) {
|
| 181 |
+
list.innerHTML = '';
|
| 182 |
+
const sorted = data.regions.sort((a,b) => Math.abs(b.z_score) - Math.abs(a.z_score));
|
| 183 |
+
sorted.forEach(r => {
|
| 184 |
+
const z = r.z_score;
|
| 185 |
+
const color = Math.abs(z) < 1.5 ? 'var(--success)' : Math.abs(z) < 2 ? 'var(--warning)' : 'var(--danger)';
|
| 186 |
+
const sev = r.severity || 'normal';
|
| 187 |
+
list.innerHTML += `<div class="region-item">
|
| 188 |
+
<div class="region-dot" style="background:${color}"></div>
|
| 189 |
+
<div class="region-name">${r.region}</div>
|
| 190 |
+
<div class="region-z" style="color:${color}">${z >= 0 ? '+' : ''}${z.toFixed(2)}</div>
|
| 191 |
+
<div class="region-badge sev-${sev}">${sev}</div>
|
| 192 |
+
</div>`;
|
| 193 |
+
});
|
| 194 |
+
} else {
|
| 195 |
+
list.innerHTML = '<p class="loading-text">No region data available.</p>';
|
| 196 |
+
}
|
| 197 |
+
}).catch(()=>{document.getElementById('regionsList').innerHTML='<p class="loading-text">Failed to load regions.</p>'});
|
| 198 |
+
|
| 199 |
+
// Load explanation
|
| 200 |
+
fetch(`/files/${SCAN_ID}/explanation.txt`).then(r=>{
|
| 201 |
+
if(r.ok) return r.text();
|
| 202 |
+
return 'AI explanation will appear here after processing completes.';
|
| 203 |
+
}).then(t=>{document.getElementById('explanation').textContent=t}).catch(()=>{});
|
| 204 |
+
|
| 205 |
+
// Patient chat
|
| 206 |
+
const chatMsgs = document.getElementById('viewerChat');
|
| 207 |
+
const chatInp = document.getElementById('chatInputV');
|
| 208 |
+
|
| 209 |
+
async function sendPatientMsg() {
|
| 210 |
+
const text = chatInp.value.trim();
|
| 211 |
+
if (!text) return;
|
| 212 |
+
chatInp.value = '';
|
| 213 |
+
chatMsgs.innerHTML += `<div class="msg-v msg-v-user">${text}</div>`;
|
| 214 |
+
chatMsgs.scrollTop = chatMsgs.scrollHeight;
|
| 215 |
+
|
| 216 |
+
try {
|
| 217 |
+
const res = await fetch(`/api/chat/patient/${SCAN_ID}`, {
|
| 218 |
+
method: 'POST', headers: {'Content-Type': 'application/json'},
|
| 219 |
+
body: JSON.stringify({message: text})
|
| 220 |
+
});
|
| 221 |
+
const reader = res.body.getReader();
|
| 222 |
+
const dec = new TextDecoder();
|
| 223 |
+
const bot = document.createElement('div');
|
| 224 |
+
bot.className = 'msg-v msg-v-bot';
|
| 225 |
+
chatMsgs.appendChild(bot);
|
| 226 |
+
let buf = '';
|
| 227 |
+
while (true) {
|
| 228 |
+
const {done, value} = await reader.read();
|
| 229 |
+
if (done) break;
|
| 230 |
+
buf += dec.decode(value, {stream: true});
|
| 231 |
+
const lines = buf.split('\n');
|
| 232 |
+
buf = lines.pop() || '';
|
| 233 |
+
for (const line of lines) {
|
| 234 |
+
if (line.startsWith('data: ') && !line.includes('[DONE]')) {
|
| 235 |
+
try { const d = JSON.parse(line.slice(6)); bot.textContent += d.content || ''; } catch {}
|
| 236 |
+
}
|
| 237 |
+
}
|
| 238 |
+
chatMsgs.scrollTop = chatMsgs.scrollHeight;
|
| 239 |
+
}
|
| 240 |
+
} catch(e) {
|
| 241 |
+
chatMsgs.innerHTML += `<div class="msg-v msg-v-bot">Error: Could not reach AI assistant.</div>`;
|
| 242 |
+
}
|
| 243 |
+
}
|
| 244 |
+
document.getElementById('chatSendV').onclick = sendPatientMsg;
|
| 245 |
+
chatInp.onkeydown = e => { if (e.key === 'Enter') sendPatientMsg(); };
|
| 246 |
+
|
| 247 |
+
// Try loading NiiVue viewer
|
| 248 |
+
{% if scan.status == 'complete' %}
|
| 249 |
+
(async () => {
|
| 250 |
+
try {
|
| 251 |
+
const script = document.createElement('script');
|
| 252 |
+
script.src = 'https://cdn.jsdelivr.net/npm/@niivue/niivue@0.44.0/dist/niivue.umd.min.js';
|
| 253 |
+
script.onload = () => {
|
| 254 |
+
const area = document.getElementById('viewerArea');
|
| 255 |
+
const canvas = document.createElement('canvas');
|
| 256 |
+
canvas.id = 'gl1';
|
| 257 |
+
canvas.style.cssText = 'width:100%;height:100%';
|
| 258 |
+
area.appendChild(canvas);
|
| 259 |
+
document.getElementById('viewerPlaceholder').style.display = 'none';
|
| 260 |
+
const nv = new niivue.Niivue({backColor:[0.05,0.05,0.07,1]});
|
| 261 |
+
nv.attachToCanvas(canvas);
|
| 262 |
+
nv.loadVolumes([{url: `/files/${SCAN_ID}/brain_mni_znorm.nii.gz`}]);
|
| 263 |
+
};
|
| 264 |
+
document.head.appendChild(script);
|
| 265 |
+
} catch(e) { console.error('NiiVue load failed:', e); }
|
| 266 |
+
})();
|
| 267 |
+
{% endif %}
|
| 268 |
+
</script>
|
| 269 |
+
</body>
|
| 270 |
+
</html>
|
requirements.txt
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
fastapi>=0.111
|
| 2 |
+
uvicorn[standard]>=0.29
|
| 3 |
+
jinja2>=3.1
|
| 4 |
+
python-multipart>=0.0.9
|
| 5 |
+
pydantic-settings>=2.2
|
| 6 |
+
sqlalchemy>=2.0
|
| 7 |
+
passlib[bcrypt]>=1.7
|
| 8 |
+
bcrypt>=4.0,<4.2
|
| 9 |
+
groq>=0.5
|
| 10 |
+
nibabel>=5.2
|
| 11 |
+
scipy>=1.12
|
| 12 |
+
numpy>=1.26
|
| 13 |
+
huggingface_hub>=0.23
|
| 14 |
+
weasyprint==60.2
|
| 15 |
+
pydyf==0.9.0
|