RADIA — Arquitectura
Última actualización: 2026-05-19
Visión general
┌─────────────────────────────────────────────────────────┐
│ Cloudflare Pages │
│ ┌──────────────┐ ┌──────────────┐ ┌───────────────┐ │
│ │ Astro SSG │ │ React SPA │ │ Workers API │ │
│ │ (páginas) │ │ (componentes)│ │ (functions/) │ │
│ └──────────────┘ └──────────────┘ └───────┬───────┘ │
│ │ │
│ ┌────────────────────────────┼────┐ │
│ │ Bindings │ │ │
│ │ ┌─────┐ ┌──────┐ ┌─────┐│ │ │
│ │ │ D1 │ │ R2 │ │ KV ││ │ │
│ │ │(SQL)│ │(blob)│ │(opt)││ │ │
│ │ └─────┘ └──────┘ └─────┘│ │ │
│ └────────────────────────────┘ │ │
└─────────────────────────────────────────────────────────┘
│
┌──────────────┴──────────────┐
▼ ▼
┌──────────┐ ┌──────────┐
│ Gemini │ │ Groq │
│ 2.5 Flash│ │ Llama 4 │
└──────────┘ └──────────┘
Capas
1. Frontend (Astro + React)
Astro genera páginas estáticas (SSG) que montan componentes React interactivos. No hay SSR — toda la lógica dinámica se ejecuta en el cliente o en Workers.
| Página | Componente principal | Descripción |
|---|---|---|
/ |
— (HTML estático) | Landing page |
/study?id=xxx |
StudyExplorer |
Visor principal completo |
/dashboard |
Script inline + modal | Lista de estudios. Botón obvio + Nuevo estudio abre un modal (bottom-sheet en móvil, centrado en desktop) con accesos a LiDAR, Sujetos y al DicomUploader. |
/shared?token=xxx |
SharedViewer / PatientSharedViewer |
Visor compartido. Modo según share_mode (médico vs paciente). |
/collab?token=xxx |
Script inline | Visor colaborador autenticado (JWT + share token) |
/upgrade |
Script inline | Planes y gestión de suscripción |
/profile |
Script inline | Perfil + preferencias de explicación al paciente |
/report?id=xxx |
ReportViewer |
Informe imprimible (con SignaturePad) |
/lidar/upload |
Script inline | Subida de mesh LiDAR + fotos clínicas |
/lidar/view?id=xxx |
MeshViewer + MeshAROverlay |
Visor mesh 3D + overlay AR con rejilla de alineación |
/lidar/diff/upload · /lidar/diff/view |
Script inline + MeshViewer |
Crear y visualizar diff 3D longitudinal |
/subjects · /subjects/[id] |
Script inline | Gestión de sujetos (paciente/animal/planta) reutilizables |
/ayuda/* |
Estático | Centro de ayuda |
/admin/* |
Script inline | Panel interno |
2. Backend (Cloudflare Workers)
Cada archivo en functions/api/ se convierte automáticamente en un Worker endpoint gracias a Cloudflare Pages Functions.
Autenticación: JWT HS256 en cabecera Authorization: Bearer <token>. Google OAuth genera el JWT tras verificar el id_token de Google con JWKS.
Patrón de respuesta: Todos los endpoints usan json(), error(), unauthorized() de lib/response.js con CORS abierto.
3. Base de datos (D1)
Tablas en una única base D1 compartida (projectos-db, prefijo radia_):
radia_users ──< radia_studies ──< radia_findings ──< radia_finding_rads
│ │──< radia_measurements
│ └──< radia_finding_tooth_fdi
│──< radia_chat_messages
│──< radia_analysis_jobs
│──< radia_analysis_runs
│──< radia_share_links ──< radia_share_view_tracking
│──< radia_reports
│──< radia_patient_explanations
│──< radia_study_instances (capas/secuencias dentro del estudio)
│──< radia_study_rads (clasificación agregada)
└──< radia_rads_events (telemetría RADS)
radia_users ──< radia_subjects ──< radia_studies (sujetos reutilizables)
radia_users ──< radia_patient_preferences (tono/idioma de explicación)
Migraciones acumuladas en migrations/001..015 — ver el árbol del README. Cambios destacables:
014_surface3d_lidar.sqlañaderadia_findings.viewport_image_key(R2 key de la foto/render representativo) y normaliza losstudy_type = surface3d_*.015_radia_subjects.sqlañade la tablaradia_subjects(paciente/animal/planta) y el FK opcional desderadia_studies.009/010/011_rads_*introducen las clasificaciones estructuradas y su telemetría.013_study_instances.sqlpermite múltiples secuencias/series dentro de un mismo estudio.
Todas las relaciones usan ON DELETE CASCADE para limpieza automática.
4. Almacenamiento (R2)
Estructura de keys en el bucket radia-dicom (binding DICOM_STORAGE):
{user_id}/{study_id}/
├── slices/
│ ├── 0000.dcm # DICOM originales
│ ├── 0001.dcm
│ └── ...
├── thumbs/
│ ├── 0000.png # Thumbnails PNG (generados en upload)
│ └── ...
├── viewports/
│ └── {capture_id}.png # Capturas 3D/MPR
├── mesh/ # Captura LiDAR (study_type = surface3d_lidar)
│ ├── model.usdz # Mesh nativo iOS (AR Quick Look)
│ ├── model.glb # Mesh portable
│ └── ... # .obj, .mtl, texturas opcionales
├── renders/ # Renders del mesh (cliente o servidor)
│ ├── 00.png
│ └── ...
├── photos/ # Fotos clínicas complementarias (cámara real)
│ ├── 00.jpg
│ └── ...
└── diff/ # Diff longitudinal (study_type = surface3d_diff)
├── delta.glb
├── renders/*.png # Renders coloreados por Δ
└── metrics.json # Δ%, vol mm³, axes
Nota sobre nomenclatura: los backends de surface3d.js y surface3d-diff.js usan variables photoUrls/renderUrls pero el contenido son R2 keys, no URLs absolutas. El frontend resuelve la presigned URL vía /api/dicom/{key}.
Flujos de datos
Subida de estudio
1. Usuario arrastra ZIP → DicomUploader
2. Client-side: JSZip extrae → dicomParser parsea headers
3. POST /api/studies/upload (metadata)
4. Loop: PUT /api/dicom/{key} (binarios, 5 paralelos)
5. POST /api/studies/{id} action=finalize
6. Redirect a /study?id=xxx
Scan 360°
1. Usuario pulsa botón Scan → Se abre Diálogo de Pre-análisis
→ Modos: fresh / complement / review / delete
→ Toggle Asteroide, campo razón opcional
→ Usuario confirma
2. POST /api/analysis/scan360 action=init
→ Recibe reason (con prefijo [COMPLEMENT]/[REVIEW] según modo)
→ Si modo complement/review: preserva hallazgos existentes
→ Si modo fresh/delete: elimina hallazgos previos
→ Calcula sliceIndices (sampleRate=8, o =2 en Asteroide)
→ Crea job en radia_analysis_jobs
→ Retorna: { jobId, sliceIndices, totalBatches }
3. Loop por cada batch (frontend controla):
POST /api/analysis/scan360 action=batch
→ Lee PNG de R2 por cada slice del batch
→ Envía a Gemini 2.5 Flash (+ Groq Specialist en Asteroide)
→ Parsea JSON de hallazgos
→ INSERT INTO radia_findings
→ Retorna: { batchFindings, processedSlices }
4. POST /api/analysis/scan360 action=finalize
→ Genera impresión global con todos los hallazgos
→ (Asteroide) Auto deep-analysis en top 5 hallazgos
→ Actualiza status estudio → 'analyzed'
→ Retorna: { totalFindings, impression }
Chat multimodal
1. POST /api/chat { studyId, messages, imageBase64? }
2. Backend construye context: findings + study metadata
3. Si hay imagen: Gemini vision con base64
4. Si no: Gemini text con historial
5. Guarda mensajes en radia_chat_messages
6. Retorna respuesta + metadata
Compartir estudio
1. POST /api/share { studyId, mode: 'medico'|'paciente', expiresInDays? }
→ Genera token único + share_mode, guarda en radia_share_links
2. Frontend construye URL: /shared?token=xxx
3. SharedViewer / PatientSharedViewer (según share_mode):
GET /api/public/study?token=xxx
4. DICOM / fotos / mesh: GET /api/public/dicom/{key}?token=xxx
5. Sin JWT requerido — solo token de share
6. Cada apertura inserta en radia_share_view_tracking (IP+UA hash)
Captura 3D / LiDAR
1. Móvil (Cadences Capture o PWA): genera mesh USDZ/GLB con ARKit
2. POST /api/lidar/upload (multipart: mesh + fotos opcionales)
→ Asigna study_id, guarda en R2 mesh/, photos/, renders/
→ INSERT radia_studies (study_type = 'surface3d_lidar')
3. /lidar/view monta MeshViewer (`model-viewer` 3.5 vía CDN)
→ MeshAROverlay: cámara real + render + (modo guiado) shot-list + inclinómetro + auto-shutter
→ En WOUND/VET/BOTANY el shot-list lleva auto-tag (role+hasScale)
→ Cada toma POSTea a /api/studies/:id/ar-photo (PNG + sidecar pose+role)
4. POST /api/analysis/surface3d { studyId }
→ Envía a Gemini renders + fotos + prompt sectorial
→ INSERT 1 hallazgo con:
location = "Superficie 3D · N renders del mesh + M fotos clínicas"
viewport_image_key = photoKeys[0] || renderKeys[0]
5. Para longitudinal: POST /api/analysis/surface3d-diff con baseStudyId + currentStudyId
→ Crea study_type = 'surface3d_diff' + hallazgo con métricas Δ
Informe PDF
Dos formatos en src/services/pdfReport.ts:
| Función | Tamaño | Uso |
|---|---|---|
generateRadiaReport |
A4 (jsPDF) | Informe profesional médico, firma, RADS, medidas |
generateMobilePatientReport |
105×185 mm | Informe móvil tipo ticket para WhatsApp/paciente |
Ambas funciones reciben un Map<findingId, dataUrl> (viewportImages) con los thumbs ya descargados — incluye fotos clínicas (viewport_image_key) y, para mesh, renders/fotos recuperados con legacyMeshKey() desde location JSON cuando el backend antiguo no rellenó la columna.
El gate previo f.viewport_image_key && viewportImages.has(f.id) se relajó a sólo viewportImages.has(f.id) para no perder imágenes de mesh legacy.
Componentes React — Responsabilidades
StudyExplorer (orquestador)
El componente central que maneja:
- Estado global del visor (study, findings, currentSlice, modo 3D/MPR)
- Todas las interacciones de IA (scan360, deep, pointask, viewport, suggestions)
- Tabs: viewer / findings / chat / reports
- Toolbar: modo visor, controles 3D, botón Asteroide
- Navegación entre vistas (DicomViewer ↔ Volume3D ↔ MPR)
- Diálogo de pre-análisis — Antes de ejecutar Scan 360°, presenta un diálogo con modos:
fresh(nuevo análisis),complement(añadir hallazgos preservando los existentes),review(revisión crítica que preserva hallazgos),delete(borrar hallazgos y re-analizar). Incluye toggle de Asteroide y campo de razón. - Gestión de hallazgos — Eliminar hallazgos individuales o grupos deduplicados, ocultar/mostrar hallazgos del informe (
user_confirmed = -1). - Modo Navegar — Al activar, click en el visor navega al hallazgo más cercano por distancia euclídea.
- Deep filter — Filtro de chip para mostrar solo hallazgos con deep analysis.
- Hallazgos agrupados (dedup) — Hallazgos en cortes adyacentes con misma categoría/ubicación se agrupan como
GroupedFindingconsliceRangeStart/EndygroupedIds. - Badge Asteroide — Indicador visual en el header del estudio si fue analizado con Modo Asteroide.
DicomViewer
- Carga lazy de slices PNG (prefetch ±5)
- Canvas rendering con window/level
- Zoom (Ctrl+scroll), pan (drag), crosshair
- Anotaciones DICOM en esquinas
- Point-Ask: click derecho → coordenadas → API
- Modo Navegar: crosshair cyan, click navega al hallazgo más cercano
Volume3DViewer
- WebGL2 ray marching (shaders GLSL inline)
- 3 modos: Bone (transfer function), MIP, X-Ray
- Reconstrucción de volumen 3D desde PNGs
- Cache en memoria + IndexedDB (7 días)
- Marcadores de hallazgos proyectados 3D→2D
- Popup interactivo por hallazgo con navegación
- Rotación libre / bloqueada por eje (X/Y/Z)
MPRViewer
- Grid 2×2 siempre (responsive mobile)
- Panels: Axial, 3D, Coronal, Sagital
- Crosshair sincronizado entre vistas
- Click en panel → expandir a pantalla completa
MeshViewer
- Wrapper de Google
model-viewer3.5 cargado por CDN dinámico. - Soporta
.glb,.usdz(AR Quick Look en iOS),.obj+.mtl. - Hot-swap entre baseline/current para
surface3d_diffcon leyenda de Δ.
MeshAROverlay
- Stream de cámara trasera (
getUserMedia) renderizado bajo<model-viewer>con fondo transparente. NO usa WebXR/Scene Viewer (esos no permiten capturar el passthrough desde web). - Modo guiado (
body_part ∈ {WOUND, VET, BOTANY}): shot-list desrc/lib/shotProtocol.ts— fuente única compartida conStudyExplorer. Cada rol definecameraOrbit(preset del visor para mostrar la silueta de ese ángulo),tiltTargetDeg/tiltToleranceDeg(objetivo deDeviceOrientationEvent.beta) yautoScale(pre-marca el checkbox "incluye regla"). - Inclinómetro con
requestPermission()para iOS 13+. Indicador verde cuando|beta − target| ≤ tolcon distancia angular circular (wrap correcto en 0°/360°). - Auto-shutter: tras
ALIGN_HOLD_MS = 1200continuos alineado, cuenta atrás 3‑2‑1 (setInterval 700 ms) y dispara. La desalineación o cambio de toma cancela vía cleanup del effect. - Quality gate: varianza laplaciana sobre patch central de 256² (luminancia BT.601, kernel 4-vecinos). Umbral σ²<50 → aviso ámbar 6 s, no bloquea.
- Cada captura POSTea a
/api/studies/:id/ar-photocon sidecar JSON (pose model-viewer +role+hasScale). En modo guiado avanza a la siguiente toma; al completar todas, toast 900 ms y cierra → abre el modal de análisis con las fotos pre-etiquetadas. - Modo libre (
GENERICo sinbody_part): comportamiento legacy — rejilla de alineación (off/thirds/fine), captura única, sin auto-tag.
PatientSharedViewer
- UI específica para paciente: explicación en lenguaje natural (
/api/analysis/explain-to-patient), thumbs viewport con fallback aviewportThumbslazy-cargado, location traducida concleanLocation()(JSON → frase humana). - Reusa
pdfReport.generateMobilePatientReportpara descarga PDF formato móvil.
RadsModule
- Picker estructurado por sistema (Lung-RADS, BI-RADS, Bone-RADS, etc.) con clasificación por hallazgo y agregada por estudio.
- Persiste en
radia_finding_radsyradia_study_rads; telemetría enradia_rads_events(medir adopción).
ChatPanel
- Chat tipo mensajería con markdown rendering
- Captura automática de viewport 3D si está visible
- Quick prompts contextuales
- Soporte de imagen pegada (paste/file)
- Guardar hallazgos desde respuestas del chat
Modelos de IA — Cadena de prioridad
Petición de visión:
1. Gemini 2.5 Flash (GEMINI_API_KEY) ← primario
2. Groq Llama 4 Scout (GROQ_API_KEY) ← fallback
Modo Asteroide (scan360):
→ Gemini (generalista) + Groq Specialist (segunda opinión) en paralelo
→ Cross-reference por category+location+slice
→ Consenso = ai_model con "+" (ej: "gemini-2.5-flash+llama-4-scout-specialist")
→ Confidence boost ×1.15 para consenso
Patrones UI
Modales iPhone-safe
Todos los modales del visor (StudyExplorer.tsx) usan un patrón unificado para evitar que Safari iOS recorte botones tras la barra de URL o el home-indicator:
- Overlay:
fixed inset-0 z-50 flex items-stretch sm:items-center justify-center bg-black/60 backdrop-blur-sm p-0 sm:p-4— edge-to-edge en móvil, centrado con padding en desktop. - Caja modal:
sm:rounded-xl p-4 sm:p-6 max-h-[100dvh] sm:max-h-[90vh] overflow-y-auto pb-[max(1rem,env(safe-area-inset-bottom))]— sin esquinas redondeadas a pantalla completa en móvil, usa100dvh(dynamic viewport, descuenta barra de Safari) y respetasafe-area-inset-bottomdel notch/home-indicator. - Modal complejo con acciones críticas (Report Builder, ~L4801): variante sticky-split con
flex flex-col max-h-[100dvh] sm:max-h-[90vh] overflow-hidden, header (shrink-0 border-b), body (overflow-y-auto flex-1 min-h-0) y footer (shrink-0 border-t+ safe-area pb). Garantiza que los botones de acción nunca se desplacen fuera del viewport.
Cobertura actual: Report Builder, Pre-análisis, Demographics, AI Suggestions (lectura), Suggestions Dialog (modo), Viewport Analysis, Mesh Point, Clinical Council.
Decisiones técnicas
| Decisión | Razón |
|---|---|
| Astro SSG (no SSR) | Máximo rendimiento, Cloudflare Pages free tier |
| D1 compartida | Una sola DB para todo ProjectOS, prefijo radia_ |
| R2 para DICOM | Almacenamiento blob ilimitado, costo mínimo |
| PNG thumbnails | Evita parsear DICOM en cada vista, cache-friendly |
| WebGL2 inline shaders | Sin dependencias 3D pesadas (Three.js = 400KB+) |
| JWT sin refresh | Simplicidad, sesión de 24h, re-login con Google |
| Client-side DICOM parsing | Reduce carga del Worker, mejor UX |
| Batch scan (frontend-driven) | Evita timeouts de Workers (30s free tier) |