stroke-viewer-frontend / docs /specs /frontend /36-frontend-without-gradio-hf-spaces.md
VibecoderMcSwaggins's picture
feat(frontend): React + Vite + NiiVue frontend (replaces Gradio) (#32)
e4daa3b unverified
|
raw
history blame
30 kB

Spec 36: React Frontend + FastAPI Backend for HuggingFace Spaces

Status: APPROVED PLAN Date: 2025-12-11 Goal: Replace Gradio with React frontend for NiiVue, FastAPI backend for DeepISLES


Security Note: CVE-2025-55182 Does NOT Affect This App

CVE-2025-55182 (React2Shell) is a critical RCE vulnerability disclosed December 3, 2025.

What Status
React 19.x with RSC VULNERABLE if using Server Components
React 19.x client-only SAFE - no Server Components = no vulnerability
React 18.x NOT AFFECTED - no Server Components

We use React 19.2.0 which is safe for our use case because:

  • CVE-2025-55182 only affects React Server Components (RSC)
  • Our app is client-only (Static Space = no server-side rendering)
  • We do not use React Server Components
  • The vulnerability requires SSR/RSC to be exploitable

Sources:


The Stack

Component Technology Version Purpose
Frontend Framework React 19.2.0 UI components (client-only, see security note)
Type Safety TypeScript 5.9.3 Type checking
Build Tool Vite 7.2.4 Fast builds, HMR
CSS Framework Tailwind CSS 4.1.17 Utility-first styling
3D Viewer @niivue/niivue 0.65.0 WebGL2 NIfTI viewer
Testing Vitest + Playwright 4.0.15 / 1.57.0 Unit, integration, E2E tests
Backend Framework FastAPI 0.124.2 Python REST API
ML Pipeline DeepISLES existing Stroke segmentation

Architecture: Two HuggingFace Spaces

You need both because:

  • Static Space = JavaScript only (React, NiiVue) - cannot run Python
  • Docker Space = Python runtime (FastAPI, DeepISLES, PyTorch)
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  HuggingFace Static Space           β”‚
β”‚  stroke-viewer-frontend             β”‚
β”‚                                     β”‚
β”‚  React 19 + TypeScript + Tailwind   β”‚
β”‚  @niivue/niivue for 3D viewing      β”‚
β”‚                                     β”‚
β”‚  Serves: index.html, JS, CSS        β”‚
β”‚  Always on, never sleeps            β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜
               β”‚ HTTPS API calls
               β–Ό
β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚  HuggingFace Docker Space           β”‚
β”‚  stroke-viewer-api                  β”‚
β”‚                                     β”‚
β”‚  FastAPI + DeepISLES + PyTorch      β”‚
β”‚                                     β”‚
β”‚  Endpoints:                         β”‚
β”‚  - GET  /api/cases                  β”‚
β”‚  - POST /api/segment                β”‚
β”‚  - GET  /api/files/{path}           β”‚
β”‚                                     β”‚
β”‚  Sleeps after 48h inactivity        β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

Project Structure

stroke-viewer/
β”œβ”€β”€ frontend/                    # Static Space
β”‚   β”œβ”€β”€ src/
β”‚   β”‚   β”œβ”€β”€ components/
β”‚   β”‚   β”‚   β”œβ”€β”€ NiiVueViewer.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ CaseSelector.tsx
β”‚   β”‚   β”‚   β”œβ”€β”€ MetricsPanel.tsx
β”‚   β”‚   β”‚   └── Layout.tsx
β”‚   β”‚   β”œβ”€β”€ hooks/
β”‚   β”‚   β”‚   └── useSegmentation.ts
β”‚   β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”‚   └── client.ts
β”‚   β”‚   β”œβ”€β”€ types/
β”‚   β”‚   β”‚   └── index.ts
β”‚   β”‚   β”œβ”€β”€ App.tsx
β”‚   β”‚   β”œβ”€β”€ main.tsx
β”‚   β”‚   └── index.css
β”‚   β”œβ”€β”€ public/
β”‚   β”œβ”€β”€ index.html
β”‚   β”œβ”€β”€ vite.config.ts
β”‚   β”œβ”€β”€ tsconfig.json
β”‚   β”œβ”€β”€ package.json
β”‚   └── README.md                # HF Spaces YAML config
β”‚
β”œβ”€β”€ backend/                     # Docker Space
β”‚   β”œβ”€β”€ api/
β”‚   β”‚   β”œβ”€β”€ __init__.py
β”‚   β”‚   β”œβ”€β”€ main.py              # FastAPI app
β”‚   β”‚   β”œβ”€β”€ routes.py            # API endpoints
β”‚   β”‚   └── schemas.py           # Pydantic models
β”‚   β”œβ”€β”€ Dockerfile
β”‚   β”œβ”€β”€ requirements.txt
β”‚   └── README.md                # HF Spaces YAML config
β”‚
└── README.md                    # Project overview

Frontend Implementation

package.json

{
  "name": "frontend",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "dev": "vite",
    "build": "tsc -b && vite build",
    "preview": "vite preview",
    "lint": "eslint .",
    "test": "vitest",
    "test:coverage": "vitest run --coverage",
    "test:e2e": "playwright test"
  },
  "dependencies": {
    "@niivue/niivue": "^0.65.0",
    "react": "^19.2.0",
    "react-dom": "^19.2.0"
  },
  "devDependencies": {
    "@playwright/test": "^1.57.0",
    "@tailwindcss/vite": "^4.1.17",
    "@testing-library/jest-dom": "^6.6.3",
    "@testing-library/react": "^16.3.0",
    "@vitejs/plugin-react": "^5.1.1",
    "@vitest/coverage-v8": "^4.0.15",
    "eslint": "^9.39.1",
    "tailwindcss": "^4.1.17",
    "typescript": "~5.9.3",
    "vite": "^7.2.4",
    "vitest": "^4.0.15"
  }
}

Why these versions:

  • react / react-dom 19.2.0: Latest React 19 - client-only so CVE-2025-55182 doesn't apply
  • @niivue/niivue 0.65.0: Latest stable (Dec 2025)
  • vite 7.2.4: Latest stable v7
  • vitest 4.0.15: Fast unit testing with React Testing Library
  • @playwright/test 1.57.0: E2E browser testing
  • tailwindcss 4.1.17: Latest stable v4
  • typescript 5.9.3: Latest stable
  • ESLint included for code quality in CI

vite.config.ts

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import tailwindcss from '@tailwindcss/vite'

export default defineConfig({
  plugins: [react(), tailwindcss()],
  build: {
    outDir: 'dist',
  },
})

tsconfig.json

{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}

src/index.css

@import "tailwindcss";

src/main.tsx

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import App from './App'
import './index.css'

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <App />
  </StrictMode>,
)

src/App.tsx

import { useState } from 'react'
import { Layout } from './components/Layout'
import { CaseSelector } from './components/CaseSelector'
import { NiiVueViewer } from './components/NiiVueViewer'
import { MetricsPanel } from './components/MetricsPanel'
import { useSegmentation } from './hooks/useSegmentation'

export default function App() {
  const [selectedCase, setSelectedCase] = useState<string | null>(null)
  const { result, isLoading, error, runSegmentation } = useSegmentation()

  const handleRunSegmentation = async () => {
    if (selectedCase) {
      await runSegmentation(selectedCase)
    }
  }

  return (
    <Layout>
      <div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
        {/* Left Panel: Controls */}
        <div className="space-y-4">
          <CaseSelector
            selectedCase={selectedCase}
            onSelectCase={setSelectedCase}
          />
          <button
            onClick={handleRunSegmentation}
            disabled={!selectedCase || isLoading}
            className="w-full bg-blue-600 hover:bg-blue-700 disabled:bg-gray-400
                       text-white font-medium py-3 px-4 rounded-lg transition"
          >
            {isLoading ? 'Processing...' : 'Run Segmentation'}
          </button>
          {error && (
            <div className="bg-red-100 text-red-700 p-3 rounded-lg">
              {error}
            </div>
          )}
          {result && <MetricsPanel metrics={result.metrics} />}
        </div>

        {/* Right Panel: Viewer */}
        <div className="lg:col-span-2">
          {result ? (
            <NiiVueViewer
              backgroundUrl={result.dwiUrl}
              overlayUrl={result.predictionUrl}
            />
          ) : (
            <div className="bg-gray-900 rounded-lg h-[500px] flex items-center justify-center">
              <p className="text-gray-400">
                Select a case and run segmentation to view results
              </p>
            </div>
          )}
        </div>
      </div>
    </Layout>
  )
}

src/components/Layout.tsx

import { ReactNode } from 'react'

interface LayoutProps {
  children: ReactNode
}

export function Layout({ children }: LayoutProps) {
  return (
    <div className="min-h-screen bg-gray-950 text-white">
      <header className="border-b border-gray-800 py-4">
        <div className="container mx-auto px-4">
          <h1 className="text-2xl font-bold">Stroke Lesion Segmentation</h1>
          <p className="text-gray-400 text-sm mt-1">
            DeepISLES segmentation on ISLES24 dataset
          </p>
        </div>
      </header>
      <main className="container mx-auto px-4 py-6">
        {children}
      </main>
    </div>
  )
}

src/components/NiiVueViewer.tsx

import { useRef, useEffect } from 'react'
import { Niivue } from '@niivue/niivue'

interface NiiVueViewerProps {
  backgroundUrl: string
  overlayUrl?: string
}

export function NiiVueViewer({ backgroundUrl, overlayUrl }: NiiVueViewerProps) {
  const canvasRef = useRef<HTMLCanvasElement>(null)
  const nvRef = useRef<Niivue | null>(null)

  useEffect(() => {
    if (!canvasRef.current) return

    // Only instantiate NiiVue once; reuse for volume reloads
    let nv = nvRef.current
    if (!nv) {
      nv = new Niivue({
        backColor: [0.05, 0.05, 0.05, 1],
        show3Dcrosshair: true,
        crosshairColor: [1, 0, 0, 0.5],
      })
      nv.attachToCanvas(canvasRef.current)
      nvRef.current = nv
    }

    // Build volumes array - always reload when URLs change
    const volumes: Array<{ url: string; colormap: string; opacity: number }> = [
      { url: backgroundUrl, colormap: 'gray', opacity: 1 },
    ]

    if (overlayUrl) {
      volumes.push({
        url: overlayUrl,
        colormap: 'red',
        opacity: 0.5,
      })
    }

    // Load volumes (async but we don't await - just fire off)
    void nv.loadVolumes(volumes)

    // Cleanup on unmount - CRITICAL: Release WebGL context
    // Browsers limit WebGL contexts (~16 in Chrome). Without cleanup,
    // navigating between results will exhaust contexts and break the viewer.
    return () => {
      if (nvRef.current) {
        // Capture gl BEFORE cleanup (cleanup may null internal state)
        const gl = nvRef.current.gl
        try {
          // NiiVue's cleanup() releases event listeners and observers
          // See: https://niivue.github.io/niivue/devdocs/classes/Niivue.html#cleanup
          nvRef.current.cleanup()
          // Force WebGL context loss to free GPU memory immediately
          if (gl) {
            const ext = gl.getExtension('WEBGL_lose_context')
            ext?.loseContext()
          }
        } catch {
          // Ignore cleanup errors
        }
        nvRef.current = null
      }
    }
  }, [backgroundUrl, overlayUrl])

  return (
    <div className="bg-gray-900 rounded-lg p-2">
      <canvas
        ref={canvasRef}
        className="w-full h-[500px] rounded"
      />
      <div className="flex gap-4 mt-2 text-xs text-gray-400">
        <span>Scroll: Navigate slices</span>
        <span>Drag: Adjust contrast</span>
        <span>Right-click: Pan</span>
      </div>
    </div>
  )
}

src/components/CaseSelector.tsx

import { useEffect, useState } from 'react'
import { apiClient } from '../api/client'

interface CaseSelectorProps {
  selectedCase: string | null
  onSelectCase: (caseId: string) => void
}

export function CaseSelector({ selectedCase, onSelectCase }: CaseSelectorProps) {
  const [cases, setCases] = useState<string[]>([])
  const [loading, setLoading] = useState(true)
  const [error, setError] = useState<string | null>(null)

  useEffect(() => {
    const fetchCases = async () => {
      try {
        const data = await apiClient.getCases()
        setCases(data.cases)
      } catch (err) {
        setError('Failed to load cases')
        console.error(err)
      } finally {
        setLoading(false)
      }
    }
    fetchCases()
  }, [])

  if (loading) {
    return (
      <div className="bg-gray-800 rounded-lg p-4">
        <p className="text-gray-400">Loading cases...</p>
      </div>
    )
  }

  if (error) {
    return (
      <div className="bg-red-900 rounded-lg p-4">
        <p className="text-red-300">{error}</p>
      </div>
    )
  }

  return (
    <div className="bg-gray-800 rounded-lg p-4">
      <label className="block text-sm font-medium mb-2">
        Select Case
      </label>
      <select
        value={selectedCase || ''}
        onChange={(e) => onSelectCase(e.target.value)}
        className="w-full bg-gray-700 border border-gray-600 rounded-lg px-3 py-2
                   text-white focus:ring-2 focus:ring-blue-500 focus:border-transparent"
      >
        <option value="">Choose a case...</option>
        {cases.map((caseId) => (
          <option key={caseId} value={caseId}>
            {caseId}
          </option>
        ))}
      </select>
    </div>
  )
}

src/components/MetricsPanel.tsx

interface Metrics {
  caseId: string
  diceScore: number | null
  volumeMl: number | null
  elapsedSeconds: number
}

interface MetricsPanelProps {
  metrics: Metrics
}

export function MetricsPanel({ metrics }: MetricsPanelProps) {
  return (
    <div className="bg-gray-800 rounded-lg p-4 space-y-3">
      <h3 className="font-medium text-lg">Results</h3>

      <div className="grid grid-cols-2 gap-3 text-sm">
        <div>
          <span className="text-gray-400">Case:</span>
          <span className="ml-2 font-mono">{metrics.caseId}</span>
        </div>

        {metrics.diceScore !== null && (
          <div>
            <span className="text-gray-400">Dice Score:</span>
            <span className="ml-2 font-mono text-green-400">
              {metrics.diceScore.toFixed(3)}
            </span>
          </div>
        )}

        {metrics.volumeMl !== null && (
          <div>
            <span className="text-gray-400">Volume:</span>
            <span className="ml-2 font-mono">{metrics.volumeMl.toFixed(2)} mL</span>
          </div>
        )}

        <div>
          <span className="text-gray-400">Time:</span>
          <span className="ml-2 font-mono">{metrics.elapsedSeconds.toFixed(1)}s</span>
        </div>
      </div>
    </div>
  )
}

src/api/client.ts

// API base URL - configure via environment variable
const API_BASE = import.meta.env.VITE_API_URL || 'https://your-backend.hf.space'

interface CasesResponse {
  cases: string[]
}

interface SegmentResponse {
  caseId: string
  diceScore: number | null
  volumeMl: number | null
  elapsedSeconds: number
  dwiUrl: string
  predictionUrl: string
}

class ApiClient {
  private baseUrl: string

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl
  }

  async getCases(): Promise<CasesResponse> {
    const response = await fetch(`${this.baseUrl}/api/cases`)
    if (!response.ok) {
      throw new Error(`Failed to fetch cases: ${response.statusText}`)
    }
    return response.json()
  }

  async runSegmentation(caseId: string, fastMode = true): Promise<SegmentResponse> {
    const response = await fetch(`${this.baseUrl}/api/segment`, {
      method: 'POST',
      headers: {
        'Content-Type': 'application/json',
      },
      body: JSON.stringify({
        case_id: caseId,
        fast_mode: fastMode,
      }),
    })
    if (!response.ok) {
      throw new Error(`Segmentation failed: ${response.statusText}`)
    }
    return response.json()
  }
}

export const apiClient = new ApiClient(API_BASE)

src/hooks/useSegmentation.ts

import { useState, useCallback } from 'react'
import { apiClient } from '../api/client'

interface SegmentationResult {
  dwiUrl: string
  predictionUrl: string
  metrics: {
    caseId: string
    diceScore: number | null
    volumeMl: number | null
    elapsedSeconds: number
  }
}

export function useSegmentation() {
  const [result, setResult] = useState<SegmentationResult | null>(null)
  const [isLoading, setIsLoading] = useState(false)
  const [error, setError] = useState<string | null>(null)

  const runSegmentation = useCallback(async (caseId: string) => {
    setIsLoading(true)
    setError(null)

    try {
      const data = await apiClient.runSegmentation(caseId)
      setResult({
        dwiUrl: data.dwiUrl,
        predictionUrl: data.predictionUrl,
        metrics: {
          caseId: data.caseId,
          diceScore: data.diceScore,
          volumeMl: data.volumeMl,
          elapsedSeconds: data.elapsedSeconds,
        },
      })
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Unknown error')
      setResult(null)
    } finally {
      setIsLoading(false)
    }
  }, [])

  return { result, isLoading, error, runSegmentation }
}

src/types/index.ts

export interface Case {
  id: string
  name: string
}

export interface Metrics {
  caseId: string
  diceScore: number | null
  volumeMl: number | null
  elapsedSeconds: number
}

export interface SegmentationResult {
  dwiUrl: string
  predictionUrl: string
  metrics: Metrics
}

index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <link rel="icon" type="image/svg+xml" href="/vite.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Stroke Lesion Segmentation</title>
  </head>
  <body>
    <div id="root"></div>
    <script type="module" src="/src/main.tsx"></script>
  </body>
</html>

frontend/README.md (HuggingFace Spaces Config)

---
title: Stroke Lesion Viewer
emoji: 🧠
colorFrom: blue
colorTo: purple
sdk: static
app_file: dist/index.html
app_build_command: npm run build
# CRITICAL: Vite 6 requires Node.js >= 20. HF Spaces defaults to Node 18.
# Without this, the build will fail or produce warnings.
nodejs_version: "20"
pinned: false
---

# Stroke Lesion Segmentation Viewer

Interactive 3D viewer for stroke lesion segmentation results using NiiVue.

Built with React, TypeScript, Tailwind CSS, and Vite.

Backend Implementation

requirements.txt

fastapi==0.124.2
uvicorn[standard]==0.34.0
pydantic==2.10.4
python-multipart>=0.0.18

# Existing project dependencies
stroke-deepisles-demo @ file:.

Why these exact versions (Dec 2025):

  • fastapi 0.124.2: Latest stable (Dec 10, 2025)
  • uvicorn[standard] 0.34.0: Latest stable
  • pydantic 2.10.4: Latest stable
  • python-multipart >=0.0.18: Required by FastAPI 0.124.x

backend/api/main.py

"""FastAPI backend for stroke segmentation."""

import os
import re

from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles

from api.routes import router

app = FastAPI(
    title="Stroke Segmentation API",
    description="DeepISLES stroke lesion segmentation",
    version="1.0.0",
)

# CORS for frontend - HF Spaces use dashed hostnames: {org}--{space}.hf.space
# Also supports PR previews: pr-{n}--{org}--{space}.hf.space
FRONTEND_ORIGIN = os.environ.get("FRONTEND_ORIGIN", "")
CORS_ORIGINS = [
    "http://localhost:5173",  # Local Vite dev server
    "http://localhost:3000",  # Alternative local port
]
if FRONTEND_ORIGIN:
    CORS_ORIGINS.append(FRONTEND_ORIGIN)

app.add_middleware(
    CORSMiddleware,
    allow_origins=CORS_ORIGINS,
    # Regex matches HuggingFace Spaces origins:
    # - Production: https://{org}--stroke-viewer-frontend.hf.space
    # - PR preview: https://{org}--stroke-viewer-frontend--pr-{N}.hf.space
    # - Branch:     https://{org}--stroke-viewer-frontend--{branch}.hf.space
    # Pattern: anything--stroke-viewer-frontend, optionally followed by --anything
    allow_origin_regex=r"https://.*--stroke-viewer-frontend(--.*)?\.hf\.space",
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# API routes
app.include_router(router, prefix="/api")

# Serve NIfTI files from results directory
# Files are stored as /tmp/stroke-results/{run_id}/{case_id}/{filename}
app.mount("/files", StaticFiles(directory="/tmp/stroke-results"), name="files")


@app.get("/")
async def root():
    return {"status": "healthy", "service": "stroke-segmentation-api"}

backend/api/routes.py

"""API route handlers."""

import os
import uuid
from pathlib import Path

from fastapi import APIRouter, HTTPException, Request
from api.schemas import SegmentRequest, SegmentResponse, CasesResponse

from stroke_deepisles_demo.data import list_case_ids
from stroke_deepisles_demo.pipeline import run_pipeline_on_case
from stroke_deepisles_demo.metrics import compute_volume_ml

router = APIRouter()

# Base directory for results (must match StaticFiles mount in main.py)
RESULTS_BASE = Path("/tmp/stroke-results")


def get_backend_base_url(request: Request) -> str:
    """Get the backend's public URL for building absolute file URLs.

    Priority:
    1. BACKEND_PUBLIC_URL env var (for production HF Spaces)
    2. Request's base URL (for local development)
    """
    env_url = os.environ.get("BACKEND_PUBLIC_URL", "").rstrip("/")
    if env_url:
        return env_url
    # Fall back to request origin (works for local dev)
    return str(request.base_url).rstrip("/")


@router.get("/cases", response_model=CasesResponse)
async def get_cases():
    """List available cases from dataset."""
    try:
        cases = list_case_ids()
        return CasesResponse(cases=cases)
    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))


@router.post("/segment", response_model=SegmentResponse)
async def run_segmentation(request: Request, body: SegmentRequest):
    """Run DeepISLES segmentation on a case."""
    try:
        # Generate unique run ID to avoid conflicts between concurrent requests
        run_id = str(uuid.uuid4())[:8]
        output_dir = RESULTS_BASE / run_id

        result = run_pipeline_on_case(
            body.case_id,
            output_dir=output_dir,
            fast=body.fast_mode,
            compute_dice=True,
            cleanup_staging=True,
        )

        # Compute volume
        volume_ml = None
        try:
            volume_ml = round(compute_volume_ml(result.prediction_mask, threshold=0.5), 2)
        except Exception:
            pass

        # Build ABSOLUTE file URLs for cross-origin NiiVue loading
        # Files are at: /tmp/stroke-results/{run_id}/{case_id}/{filename}
        # Served at: /files/{run_id}/{case_id}/{filename}
        backend_url = get_backend_base_url(request)
        dwi_filename = result.input_files["dwi"].name
        pred_filename = result.prediction_mask.name

        # URL path: /files/{run_id}/{case_id}/{filename}
        file_path_prefix = f"/files/{run_id}/{result.case_id}"

        return SegmentResponse(
            caseId=result.case_id,
            diceScore=result.dice_score,
            volumeMl=volume_ml,
            elapsedSeconds=round(result.elapsed_seconds, 2),
            dwiUrl=f"{backend_url}{file_path_prefix}/{dwi_filename}",
            predictionUrl=f"{backend_url}{file_path_prefix}/{pred_filename}",
        )

    except Exception as e:
        raise HTTPException(status_code=500, detail=str(e))

backend/api/schemas.py

"""Pydantic schemas for API."""

from pydantic import BaseModel


class CasesResponse(BaseModel):
    cases: list[str]


class SegmentRequest(BaseModel):
    case_id: str
    fast_mode: bool = True


class SegmentResponse(BaseModel):
    caseId: str
    diceScore: float | None
    volumeMl: float | None
    elapsedSeconds: float
    dwiUrl: str
    predictionUrl: str

backend/Dockerfile

# CRITICAL: Must use isleschallenge/deepisles base image
# This image contains:
# - PyTorch with CUDA support
# - Pre-installed DeepISLES model weights (~18GB)
# - All medical imaging dependencies (nibabel, nnunet, etc.)
#
# Using python:3.11-slim would require manually downloading weights
# and reinstalling all CUDA/PyTorch dependencies - not feasible.
FROM isleschallenge/deepisles:latest

WORKDIR /app

# Copy the ENTIRE project (stroke-deepisles-demo package)
# This is required because requirements.txt references "stroke-deepisles-demo @ file:."
COPY pyproject.toml .
COPY src/ src/
COPY README.md .

# Copy API code
COPY backend/api/ api/
COPY backend/requirements.txt .

# Install API dependencies (FastAPI, uvicorn) + local package
# Note: Base image already has torch, nibabel, etc.
RUN pip install --no-cache-dir -r requirements.txt

# Create results directory (used by StaticFiles mount)
RUN mkdir -p /tmp/stroke-results

# Environment variables for HuggingFace Spaces
ENV HF_SPACES=1
ENV DEEPISLES_DIRECT_INVOCATION=1

# Expose port (HF Spaces expects 7860)
EXPOSE 7860

# Run FastAPI
CMD ["uvicorn", "api.main:app", "--host", "0.0.0.0", "--port", "7860"]

CRITICAL: GPU Required

DeepISLES requires GPU acceleration. HuggingFace Spaces FREE tier (cpu-basic) will NOT work.

Tier GPU Will Work?
cpu-basic (free) None ❌ No
t4-small NVIDIA T4 (16GB) βœ… Yes
t4-medium NVIDIA T4 (16GB) βœ… Yes
a10g-small NVIDIA A10G (24GB) βœ… Yes

When creating the HF Space, select T4-small or higher.

Note: The Dockerfile copies the full project because requirements.txt has:

stroke-deepisles-demo @ file:.

This PEP 508 local path reference requires the package source to be present.

backend/README.md (HuggingFace Spaces Config)

---
title: Stroke Segmentation API
emoji: 🧠
colorFrom: blue
colorTo: purple
sdk: docker
app_port: 7860
pinned: false
---

# Stroke Segmentation API

FastAPI backend running DeepISLES stroke lesion segmentation.

## Endpoints

- `GET /api/cases` - List available cases
- `POST /api/segment` - Run segmentation
- `GET /files/{filename}` - Download result files

Setup Commands

Frontend (Local Development)

# Create project
npm create vite@latest stroke-viewer-frontend -- --template react-ts
cd stroke-viewer-frontend

# Install dependencies
npm install @niivue/niivue
npm install -D tailwindcss @tailwindcss/vite

# Copy the files from this spec into src/

# Run dev server
npm run dev
# Opens http://localhost:5173

Backend (Local Development)

cd backend

# Create virtual environment
python -m venv venv
source venv/bin/activate

# Install dependencies
pip install -r requirements.txt

# Run server
uvicorn api.main:app --reload --port 7860
# Opens http://localhost:7860

Deploy to HuggingFace

# Frontend (Static Space)
cd frontend
huggingface-cli repo create stroke-viewer-frontend --type space --space-sdk static
huggingface-cli upload stroke-viewer-frontend ./dist . --repo-type space

# Backend (Docker Space)
cd backend
huggingface-cli repo create stroke-viewer-api --type space --space-sdk docker
huggingface-cli upload stroke-viewer-api . . --repo-type space

Environment Variables

Frontend (.env)

VITE_API_URL=https://your-username-stroke-viewer-api.hf.space

Backend

No additional env vars needed - uses existing stroke-deepisles-demo configuration.


Key Differences from Gradio

What Gradio (broken) This Stack
NiiVue JavaScript Blocked by innerHTML Full execution βœ“
WebGL2 context Frozen during hydration Works normally βœ“
Bundle size ~2MB Gradio overhead ~200KB total
Cold start Python + Gradio init Instant (static)
Customization Limited to Gradio components Full React control

Next Steps

  1. Create frontend/ directory with files from this spec
  2. Create backend/ directory with files from this spec
  3. Test locally: npm run dev + uvicorn api.main:app
  4. Create HuggingFace Spaces (one Static, one Docker)
  5. Deploy and test

Dependencies Summary (Verified Dec 11, 2025)

Frontend (npm) - PINNED VERSIONS:

Package Version Notes
react 18.3.1 NOT React 19 (CVE-2025-55182)
react-dom 18.3.1 Must match react version
@niivue/niivue 0.65.0 Latest stable
typescript 5.6.3 Latest 5.6.x
vite 6.0.5 Stable v6 (not v7/v8 beta)
tailwindcss 4.1.7 Latest v4
@tailwindcss/vite 4.1.7 Must match tailwindcss
@vitejs/plugin-react 4.3.4 Latest stable

Backend (pip) - PINNED VERSIONS:

Package Version Notes
fastapi 0.124.2 Latest (Dec 10, 2025)
uvicorn[standard] 0.34.0 Latest stable
pydantic 2.10.4 Latest stable
python-multipart >=0.0.18 Required by FastAPI

Node.js: >= 20.0.0 (required for Vite 6) Python: >= 3.11 (recommended for FastAPI)