ViannyCruz commited on
Commit
50c0cce
·
verified ·
1 Parent(s): 8463ee1

Upload 6 files

Browse files
Files changed (6) hide show
  1. Dockerfile +47 -0
  2. README.md +54 -7
  3. app.py +694 -0
  4. best_model_fold_2.h5 +3 -0
  5. database.py +575 -0
  6. requirements.txt +8 -0
Dockerfile ADDED
@@ -0,0 +1,47 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ── Imagen base con Python 3.10 ────────────────────────────────────────────────
2
+ FROM python:3.10-slim
3
+
4
+ # Variables de entorno
5
+ ENV PYTHONDONTWRITEBYTECODE=1 \
6
+ PYTHONUNBUFFERED=1 \
7
+ TF_CPP_MIN_LOG_LEVEL=2 \
8
+ DB_PATH=/data/medical_app.db \
9
+ PORT=7860
10
+
11
+ # Instalar dependencias del sistema
12
+ RUN apt-get update && apt-get install -y --no-install-recommends \
13
+ libgl1-mesa-glx \
14
+ libglib2.0-0 \
15
+ libsm6 \
16
+ libxrender1 \
17
+ libxext6 \
18
+ && rm -rf /var/lib/apt/lists/*
19
+
20
+ # Directorio de trabajo
21
+ WORKDIR /app
22
+
23
+ # Instalar dependencias Python primero (capa cacheada)
24
+ COPY requirements.txt .
25
+ RUN pip install --no-cache-dir -r requirements.txt
26
+
27
+ # Copiar código fuente
28
+ COPY app.py .
29
+ COPY database.py .
30
+
31
+ # Copiar carpeta web (frontend HTML)
32
+ COPY web/ ./web/
33
+
34
+ # Copiar modelo entrenado
35
+ # ⚠️ Renombra tu archivo .h5 o ajusta esta línea
36
+ COPY *.h5 ./
37
+
38
+ # Crear directorio persistente para la base de datos
39
+ # En HF Spaces, monta un Space Storage en /data para persistencia real
40
+ RUN mkdir -p /data
41
+
42
+ # Puerto que expone la app (HF Spaces usa 7860)
43
+ EXPOSE 7860
44
+
45
+ # Arranque con gunicorn (producción)
46
+ CMD ["gunicorn", "--bind", "0.0.0.0:7860", "--workers", "1", \
47
+ "--timeout", "120", "--preload", "app:app"]
README.md CHANGED
@@ -1,12 +1,59 @@
1
  ---
2
- title: RetinAI
3
- emoji: 👀
4
- colorFrom: yellow
5
- colorTo: gray
6
  sdk: docker
7
  pinned: false
8
- license: other
9
- short_description: 'Sistema de diagnostico de RD asistido por AI '
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Retinopatía Diabética - Sistema de Diagnóstico
3
+ emoji: 👁️
4
+ colorFrom: blue
5
+ colorTo: indigo
6
  sdk: docker
7
  pinned: false
 
 
8
  ---
9
 
10
+ # 👁️ Sistema de Diagnóstico de Retinopatía Diabética
11
+
12
+ Aplicación web médica con IA para detección de retinopatía diabética usando EfficientNetB0 + Grad-CAM.
13
+
14
+ ## 🚀 Despliegue en Hugging Face Spaces
15
+
16
+ ### Paso 1 — Crear el Space
17
+ 1. Ve a [huggingface.co/new-space](https://huggingface.co/new-space)
18
+ 2. Elige **Docker** como SDK
19
+ 3. Visibilidad: **Public** (o Private si prefieres)
20
+
21
+ ### Paso 2 — Subir archivos
22
+ Sube todos estos archivos al Space:
23
+ ```
24
+ ├── app.py
25
+ ├── database.py
26
+ ├── requirements.txt
27
+ ├── Dockerfile
28
+ ├── README.md
29
+ ├── tu_modelo.h5 ← tu archivo de modelo entrenado
30
+ └── web/
31
+ ├── auth-login.html
32
+ ├── index.html
33
+ └── (demás archivos HTML/CSS/JS)
34
+ ```
35
+
36
+ ### Paso 3 — Persistencia de base de datos
37
+ Para que los datos NO se pierdan al reiniciar:
38
+ 1. Ve a **Settings** → **Persistent Storage**
39
+ 2. Activa el almacenamiento persistente y monta en `/data`
40
+ 3. La app guardará la BD en `/data/medical_app.db`
41
+
42
+ ### Paso 4 — Variables de entorno (opcional)
43
+ En **Settings** → **Variables and secrets**:
44
+ - `SECRET_KEY` = (clave secreta aleatoria larga)
45
+ - `DB_PATH` = `/data/medical_app.db` (ya configurado por defecto)
46
+
47
+ ---
48
+
49
+ ## 👤 Credenciales por defecto
50
+ - **Admin**: `admin` / `admin123` ← cámbiala después de entrar
51
+
52
+ ## 🔐 Roles
53
+ | Rol | Puede ver |
54
+ |-----|-----------|
55
+ | **Admin** | Todos los pacientes y consultas de todos los doctores |
56
+ | **Doctor** | Solo sus propios pacientes y consultas |
57
+
58
+ ## ⚠️ Aviso médico
59
+ Esta aplicación es para apoyo diagnóstico únicamente. No reemplaza el criterio médico profesional.
app.py ADDED
@@ -0,0 +1,694 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ APLICACIÓN MÉDICA - BACKEND FLASK
4
+ Retinopatía Diabética - Versión Web para Hugging Face Spaces
5
+ """
6
+ import os
7
+ import base64
8
+ import json
9
+ import uuid
10
+ import numpy as np
11
+ import cv2
12
+ import matplotlib
13
+ matplotlib.use('Agg')
14
+ import matplotlib.pyplot as plt
15
+ from io import BytesIO
16
+ from datetime import datetime, timedelta
17
+ from functools import wraps
18
+ from typing import Optional
19
+ from PIL import Image
20
+
21
+ from flask import Flask, request, jsonify, session, send_from_directory
22
+ import tensorflow as tf
23
+
24
+ from database import DatabaseManager
25
+
26
+ # ------------------------------------------------------------------ #
27
+ # CONFIGURACIÓN
28
+ # ------------------------------------------------------------------ #
29
+ app = Flask(__name__, static_folder='web', static_url_path='')
30
+ app.secret_key = os.environ.get("SECRET_KEY", "medical-app-secret-2024-change-in-prod")
31
+ app.permanent_session_lifetime = timedelta(hours=8)
32
+
33
+ db = DatabaseManager()
34
+ model = None
35
+ CLASS_NAMES = ['Diabetic Retinopathy', 'No Diabetic Retinopathy']
36
+ OPTIMAL_THRESHOLD = 0.28
37
+
38
+ # SciPy opcional
39
+ try:
40
+ from scipy import ndimage
41
+ SCIPY_AVAILABLE = True
42
+ except ImportError:
43
+ SCIPY_AVAILABLE = False
44
+ class _FakeNdimage:
45
+ @staticmethod
46
+ def gaussian_filter(img, sigma):
47
+ k = int(2 * int(3 * sigma) + 1)
48
+ if k % 2 == 0: k += 1
49
+ return cv2.GaussianBlur(img.astype(np.float32), (k, k), sigma)
50
+ @staticmethod
51
+ def label(binary):
52
+ if len(binary.shape) == 3:
53
+ binary = cv2.cvtColor(binary.astype(np.uint8), cv2.COLOR_BGR2GRAY)
54
+ binary = (binary * 255).astype(np.uint8)
55
+ n, labels = cv2.connectedComponents(binary)
56
+ return labels, n - 1
57
+ @staticmethod
58
+ def center_of_mass(binary):
59
+ if len(binary.shape) == 3:
60
+ binary = cv2.cvtColor(binary.astype(np.uint8), cv2.COLOR_BGR2GRAY)
61
+ binary = (binary * 255).astype(np.uint8)
62
+ m = cv2.moments(binary)
63
+ if m['m00'] != 0:
64
+ return (m['m01'] / m['m00'], m['m10'] / m['m00'])
65
+ h, w = binary.shape
66
+ return (h // 2, w // 2)
67
+ ndimage = _FakeNdimage()
68
+
69
+
70
+ # ------------------------------------------------------------------ #
71
+ # DECORADORES DE AUTENTICACIÓN
72
+ # ------------------------------------------------------------------ #
73
+ def login_required(f):
74
+ @wraps(f)
75
+ def decorated(*args, **kwargs):
76
+ if not session.get('is_authenticated'):
77
+ return jsonify({'success': False, 'error': 'No autenticado', 'redirect_to_login': True}), 401
78
+ if datetime.fromisoformat(session.get('expires_at', '2000-01-01')) < datetime.now():
79
+ session.clear()
80
+ return jsonify({'success': False, 'error': 'Sesión expirada', 'redirect_to_login': True}), 401
81
+ return f(*args, **kwargs)
82
+ return decorated
83
+
84
+ def admin_required(f):
85
+ @wraps(f)
86
+ def decorated(*args, **kwargs):
87
+ if not session.get('is_authenticated'):
88
+ return jsonify({'success': False, 'error': 'No autenticado'}), 401
89
+ if session.get('role') != 'Admin':
90
+ return jsonify({'success': False, 'error': 'Acceso denegado: Solo administradores'}), 403
91
+ return f(*args, **kwargs)
92
+ return decorated
93
+
94
+
95
+ # ------------------------------------------------------------------ #
96
+ # MODELO
97
+ # ------------------------------------------------------------------ #
98
+ def load_model():
99
+ global model
100
+ model_files = [f for f in os.listdir('.') if f.endswith('.h5')]
101
+ if not model_files:
102
+ print("ERROR: No hay archivos .h5 en el directorio")
103
+ return False
104
+ model_path = model_files[0]
105
+ print(f"Cargando modelo: {model_path}")
106
+ try:
107
+ from tensorflow.keras.applications import EfficientNetB0
108
+ from tensorflow.keras.layers import Dense, GlobalAveragePooling2D, Dropout, BatchNormalization
109
+ from tensorflow.keras.regularizers import l2
110
+ from tensorflow.keras.models import Model
111
+
112
+ base = EfficientNetB0(weights='imagenet', include_top=False, input_shape=(224, 224, 3))
113
+ base.trainable = False
114
+ inputs = tf.keras.Input(shape=(224, 224, 3))
115
+ x = tf.keras.applications.efficientnet.preprocess_input(inputs)
116
+ x = base(x, training=False)
117
+ x = GlobalAveragePooling2D()(x)
118
+ x = BatchNormalization()(x)
119
+ x = Dropout(0.6)(x)
120
+ x = Dense(64, activation='relu', kernel_regularizer=l2(0.01))(x)
121
+ x = Dropout(0.5)(x)
122
+ outputs = Dense(1, activation='sigmoid', name='predictions')(x)
123
+ model = Model(inputs, outputs)
124
+ model.load_weights(model_path)
125
+
126
+ test = np.random.random((1, 224, 224, 3)).astype(np.float32) * 255
127
+ model.predict(test, verbose=0)
128
+ print(f"Modelo cargado exitosamente: {model_path}")
129
+ return True
130
+ except Exception as e:
131
+ print(f"Error cargando modelo: {e}")
132
+ return False
133
+
134
+ def preprocess_image(image_bytes) -> Optional[np.ndarray]:
135
+ try:
136
+ img = Image.open(BytesIO(image_bytes)).convert('RGB')
137
+ img = img.resize((224, 224), Image.Resampling.LANCZOS)
138
+ arr = np.array(img, dtype=np.float32)
139
+ return np.expand_dims(arr, axis=0)
140
+ except Exception as e:
141
+ print(f"Error en preprocesamiento: {e}")
142
+ return None
143
+
144
+
145
+ # ------------------------------------------------------------------ #
146
+ # GRAD-CAM
147
+ # ------------------------------------------------------------------ #
148
+ class SimpleGradCAM:
149
+ def __init__(self, model_, threshold=0.28):
150
+ self.model = model_
151
+ self.threshold = threshold
152
+
153
+ def generate(self, img_tensor):
154
+ try:
155
+ with tf.GradientTape() as tape:
156
+ tape.watch(img_tensor)
157
+ preds = self.model(img_tensor, training=False)
158
+ loss = preds[0, 0] if preds.shape[-1] == 1 else preds[0, tf.argmax(preds[0])]
159
+ grads = tape.gradient(loss, img_tensor)
160
+ if grads is not None:
161
+ heatmap = tf.squeeze(tf.reduce_mean(tf.abs(grads), axis=-1))
162
+ heatmap = tf.maximum(heatmap, 0)
163
+ if tf.reduce_max(heatmap) > 0:
164
+ heatmap = heatmap / tf.reduce_max(heatmap)
165
+ return heatmap.numpy(), preds[0].numpy()
166
+ except Exception as e:
167
+ print(f"GradCAM error: {e}")
168
+ return self._attention(img_tensor)
169
+
170
+ def _attention(self, img_tensor):
171
+ preds = self.model(img_tensor, training=False)
172
+ gray = tf.reduce_mean(img_tensor[0], axis=-1)
173
+ k = tf.ones((5, 5, 1, 1)) / 25.0
174
+ smooth = tf.nn.conv2d(tf.expand_dims(tf.expand_dims(gray, -1), 0), k, [1,1,1,1], 'SAME')
175
+ edges = tf.abs(tf.expand_dims(gray, 0) - tf.squeeze(smooth))
176
+ att = (gray + edges) / 2.0
177
+ att = tf.maximum(att, 0)
178
+ if tf.reduce_max(att) > 0:
179
+ att = att / tf.reduce_max(att)
180
+ return att.numpy(), preds[0].numpy()
181
+
182
+ def find_critical_region(heatmap, zoom_factor=2.2, min_size=60):
183
+ h, w = heatmap.shape
184
+ max_y, max_x = np.unravel_index(np.argmax(heatmap), heatmap.shape)
185
+ thresh = max(0.7, np.percentile(heatmap, 95))
186
+ smooth = ndimage.gaussian_filter(heatmap, sigma=1.0)
187
+ mask = smooth > thresh
188
+ center_y, center_x = max_y, max_x
189
+ if np.sum(mask) > 0:
190
+ labeled, n = ndimage.label(mask)
191
+ if n > 0:
192
+ lbl = labeled[max_y, max_x]
193
+ if lbl > 0:
194
+ cy, cx = ndimage.center_of_mass(labeled == lbl)
195
+ center_y, center_x = int(cy), int(cx)
196
+ zh, zw = max(int(h / zoom_factor), min_size), max(int(w / zoom_factor), min_size)
197
+ y0 = max(0, min(center_y - zh // 2, h - zh))
198
+ x0 = max(0, min(center_x - zw // 2, w - zw))
199
+ return y0, y0 + zh, x0, x0 + zw, center_y, center_x
200
+
201
+
202
+ # ------------------------------------------------------------------ #
203
+ # RUTAS - SERVIR FRONTEND
204
+ # ------------------------------------------------------------------ #
205
+ @app.route('/')
206
+ def index():
207
+ return send_from_directory('web', 'auth-login.html')
208
+
209
+ @app.route('/<path:path>')
210
+ def static_files(path):
211
+ return send_from_directory('web', path)
212
+
213
+
214
+ # ------------------------------------------------------------------ #
215
+ # RUTAS - AUTENTICACIÓN
216
+ # ------------------------------------------------------------------ #
217
+ @app.route('/api/login', methods=['POST'])
218
+ def login():
219
+ data = request.json
220
+ user = db.authenticate_user(data.get('username', ''), data.get('password', ''))
221
+ if user:
222
+ session.permanent = True
223
+ session['user_id'] = user['userID']
224
+ session['username'] = user['username']
225
+ session['role'] = user['role']
226
+ session['is_authenticated'] = True
227
+ session['expires_at'] = (datetime.now() + timedelta(hours=8)).isoformat()
228
+ return jsonify({'success': True, 'user': user, 'message': f'Bienvenido, {user["username"]}'})
229
+ return jsonify({'success': False, 'message': 'Usuario o contraseña incorrectos'}), 401
230
+
231
+ @app.route('/api/logout', methods=['POST'])
232
+ def logout():
233
+ session.clear()
234
+ return jsonify({'success': True})
235
+
236
+ @app.route('/api/session', methods=['GET'])
237
+ @login_required
238
+ def get_session():
239
+ return jsonify({
240
+ 'success': True,
241
+ 'user': {
242
+ 'userID': session['user_id'],
243
+ 'username': session['username'],
244
+ 'role': session['role']
245
+ }
246
+ })
247
+
248
+
249
+ # ------------------------------------------------------------------ #
250
+ # RUTAS - USUARIOS (solo Admin)
251
+ # ------------------------------------------------------------------ #
252
+ @app.route('/api/users', methods=['GET'])
253
+ @login_required
254
+ @admin_required
255
+ def get_users():
256
+ return jsonify({'success': True, 'users': db.get_all_users()})
257
+
258
+ @app.route('/api/users', methods=['POST'])
259
+ @login_required
260
+ @admin_required
261
+ def create_user():
262
+ data = request.json
263
+ username = data.get('username', '').strip()
264
+ password = data.get('password', '')
265
+ role = data.get('role', 'Doctor')
266
+ if not username or not password:
267
+ return jsonify({'success': False, 'message': 'Usuario y contraseña requeridos'}), 400
268
+ if len(password) < 6:
269
+ return jsonify({'success': False, 'message': 'Contraseña mínimo 6 caracteres'}), 400
270
+ if role not in ['Doctor', 'Admin']:
271
+ return jsonify({'success': False, 'message': 'Rol inválido'}), 400
272
+ ok = db.create_user(username, password, role)
273
+ if ok:
274
+ return jsonify({'success': True, 'message': f'Usuario {username} creado'})
275
+ return jsonify({'success': False, 'message': 'El usuario ya existe'}), 409
276
+
277
+ @app.route('/api/users/<int:user_id>', methods=['PUT'])
278
+ @login_required
279
+ @admin_required
280
+ def update_user(user_id):
281
+ data = request.json
282
+ if not db.get_user(user_id):
283
+ return jsonify({'success': False, 'message': 'Usuario no encontrado'}), 404
284
+ db.update_user(user_id,
285
+ username=data.get('username'),
286
+ role=data.get('role'),
287
+ password=data.get('password') or None)
288
+ return jsonify({'success': True, 'message': 'Usuario actualizado'})
289
+
290
+ @app.route('/api/users/<int:user_id>', methods=['DELETE'])
291
+ @login_required
292
+ @admin_required
293
+ def delete_user(user_id):
294
+ if user_id == session['user_id']:
295
+ return jsonify({'success': False, 'message': 'No puedes eliminar tu propia cuenta'}), 400
296
+ all_users = db.get_all_users()
297
+ admins = [u for u in all_users if u['role'] == 'Admin']
298
+ target = db.get_user(user_id)
299
+ if target and target['role'] == 'Admin' and len(admins) <= 1:
300
+ return jsonify({'success': False, 'message': 'No se puede eliminar el último Admin'}), 400
301
+ db.delete_user(user_id)
302
+ return jsonify({'success': True, 'message': 'Usuario eliminado'})
303
+
304
+
305
+ # ------------------------------------------------------------------ #
306
+ # RUTAS - PACIENTES
307
+ # ------------------------------------------------------------------ #
308
+ @app.route('/api/patients', methods=['GET'])
309
+ @login_required
310
+ def get_patients():
311
+ search = request.args.get('search', '').strip()
312
+ uid, role = session['user_id'], session['role']
313
+ if search:
314
+ patients = db.search_patients(search, uid, role)
315
+ else:
316
+ patients = db.get_patients(uid, role)
317
+ return jsonify({'success': True, 'patients': patients})
318
+
319
+ @app.route('/api/patients', methods=['POST'])
320
+ @login_required
321
+ def create_patient():
322
+ data = request.json
323
+ name = (data.get('name') or '').strip()
324
+ if not name:
325
+ return jsonify({'success': False, 'message': 'Nombre requerido'}), 400
326
+ pid = db.create_patient(
327
+ created_by_user_id=session['user_id'],
328
+ name=name,
329
+ birth_date=data.get('birthDate'),
330
+ gender=data.get('gender'),
331
+ diabetes_type=data.get('diabetesType')
332
+ )
333
+ if pid:
334
+ patient = db.get_patient(pid)
335
+ return jsonify({'success': True, 'patient': patient})
336
+ return jsonify({'success': False, 'message': 'Error creando paciente'}), 500
337
+
338
+ @app.route('/api/patients/<int:patient_id>', methods=['GET'])
339
+ @login_required
340
+ def get_patient(patient_id):
341
+ patient = db.get_patient(patient_id)
342
+ if not patient:
343
+ return jsonify({'success': False, 'message': 'Paciente no encontrado'}), 404
344
+ # Doctors can only see their own patients
345
+ if session['role'] != 'Admin' and patient['createdByUserID'] != session['user_id']:
346
+ return jsonify({'success': False, 'message': 'Acceso denegado'}), 403
347
+ consultations = db.get_patient_consultations(patient_id)
348
+ risk_factors = db.get_patient_risk_factors(patient_id)
349
+ return jsonify({'success': True, 'patient': patient,
350
+ 'consultations': consultations, 'risk_factors': risk_factors})
351
+
352
+ @app.route('/api/patients/<int:patient_id>', methods=['PUT'])
353
+ @login_required
354
+ def update_patient(patient_id):
355
+ data = request.json
356
+ patient = db.get_patient(patient_id)
357
+ if not patient:
358
+ return jsonify({'success': False, 'message': 'Paciente no encontrado'}), 404
359
+ if session['role'] != 'Admin' and patient['createdByUserID'] != session['user_id']:
360
+ return jsonify({'success': False, 'message': 'Acceso denegado'}), 403
361
+ db.update_patient(patient_id,
362
+ name=data.get('name'),
363
+ birthDate=data.get('birthDate'),
364
+ gender=data.get('gender'),
365
+ diabetesType=data.get('diabetesType'))
366
+ return jsonify({'success': True, 'patient': db.get_patient(patient_id)})
367
+
368
+ @app.route('/api/patients/<int:patient_id>', methods=['DELETE'])
369
+ @login_required
370
+ def delete_patient(patient_id):
371
+ patient = db.get_patient(patient_id)
372
+ if not patient:
373
+ return jsonify({'success': False, 'message': 'Paciente no encontrado'}), 404
374
+ if session['role'] != 'Admin' and patient['createdByUserID'] != session['user_id']:
375
+ return jsonify({'success': False, 'message': 'Acceso denegado'}), 403
376
+ db.delete_patient(patient_id)
377
+ return jsonify({'success': True})
378
+
379
+ # Factores de riesgo
380
+ @app.route('/api/risk-factors', methods=['GET'])
381
+ @login_required
382
+ def get_risk_factors():
383
+ return jsonify({'success': True, 'risk_factors': db.get_all_risk_factors()})
384
+
385
+ @app.route('/api/patients/<int:patient_id>/risk-factors', methods=['POST'])
386
+ @login_required
387
+ def add_risk_factor(patient_id):
388
+ data = request.json
389
+ db.add_patient_risk_factor(patient_id, data['riskFactorID'])
390
+ return jsonify({'success': True})
391
+
392
+ @app.route('/api/patients/<int:patient_id>/risk-factors/<int:rf_id>', methods=['DELETE'])
393
+ @login_required
394
+ def remove_risk_factor(patient_id, rf_id):
395
+ db.remove_patient_risk_factor(patient_id, rf_id)
396
+ return jsonify({'success': True})
397
+
398
+
399
+ # ------------------------------------------------------------------ #
400
+ # RUTAS - PREDICCIÓN / IA
401
+ # ------------------------------------------------------------------ #
402
+ @app.route('/api/predict', methods=['POST'])
403
+ @login_required
404
+ def predict():
405
+ global model
406
+ if model is None:
407
+ return jsonify({'success': False, 'error': 'Modelo no cargado'}), 503
408
+
409
+ data = request.json
410
+ image_data = data.get('imageData', '')
411
+ filename = data.get('filename', 'image.jpg')
412
+
413
+ if 'base64,' in image_data:
414
+ image_data = image_data.split('base64,')[1]
415
+
416
+ try:
417
+ image_bytes = base64.b64decode(image_data)
418
+ processed = preprocess_image(image_bytes)
419
+ if processed is None:
420
+ return jsonify({'success': False, 'error': 'Error procesando imagen'}), 400
421
+
422
+ prediction = model.predict(processed, verbose=0)
423
+ raw = float(prediction[0][0])
424
+
425
+ if raw > OPTIMAL_THRESHOLD:
426
+ predicted_class = 0
427
+ confidence = raw * 100
428
+ else:
429
+ predicted_class = 1
430
+ confidence = (1 - raw) * 100
431
+
432
+ result = {
433
+ 'success': True,
434
+ 'prediction': {
435
+ 'class': CLASS_NAMES[predicted_class],
436
+ 'class_index': predicted_class,
437
+ 'confidence': round(confidence, 2),
438
+ 'raw_output': round(raw, 6)
439
+ },
440
+ 'timestamp': datetime.now().strftime('%Y-%m-%d %H:%M:%S'),
441
+ 'filename': filename
442
+ }
443
+ return jsonify(result)
444
+
445
+ except Exception as e:
446
+ return jsonify({'success': False, 'error': str(e)}), 500
447
+
448
+
449
+ @app.route('/api/gradcam', methods=['POST'])
450
+ @login_required
451
+ def gradcam():
452
+ global model
453
+ if model is None:
454
+ return jsonify({'success': False, 'error': 'Modelo no cargado'}), 503
455
+
456
+ data = request.json
457
+ image_data = data.get('imageData', '')
458
+ filename = data.get('filename', 'image.jpg')
459
+ prediction_result = data.get('predictionResult', {})
460
+
461
+ if prediction_result.get('prediction', {}).get('class_index', 0) != 1:
462
+ return jsonify({'success': False,
463
+ 'error': 'Grad-CAM solo para casos positivos de retinopatía'}), 400
464
+
465
+ if 'base64,' in image_data:
466
+ image_data = image_data.split('base64,')[1]
467
+
468
+ try:
469
+ image_bytes = base64.b64decode(image_data)
470
+ img_pil = Image.open(BytesIO(image_bytes)).convert('RGB')
471
+ orig_w, orig_h = img_pil.size
472
+
473
+ img_224 = img_pil.resize((224, 224), Image.Resampling.LANCZOS)
474
+ img_arr = np.array(img_224, dtype=np.float32)
475
+ orig_arr = np.array(img_pil, dtype=np.uint8)
476
+
477
+ img_tensor = tf.convert_to_tensor(np.expand_dims(img_arr, 0), dtype=tf.float32)
478
+ gcam = SimpleGradCAM(model, OPTIMAL_THRESHOLD)
479
+ heatmap, _ = gcam.generate(img_tensor)
480
+
481
+ y0, y1, x0, x1, cy, cx = find_critical_region(heatmap)
482
+
483
+ sx, sy = orig_w / 224.0, orig_h / 224.0
484
+ x0h, x1h = int(x0 * sx), int(x1 * sx)
485
+ y0h, y1h = int(y0 * sy), int(y1 * sy)
486
+
487
+ zoom_region = orig_arr[y0h:y1h, x0h:x1h]
488
+
489
+ plt.figure(figsize=(10, 10))
490
+ if zoom_region.size > 0:
491
+ plt.imshow(zoom_region)
492
+ zoom_heat = heatmap[y0:y1, x0:x1]
493
+ max_act = float(np.max(zoom_heat))
494
+ avg_act = float(np.mean(zoom_heat))
495
+ high_pct = float(np.sum(zoom_heat > 0.6) / zoom_heat.size * 100)
496
+ plt.title(f'Zona Crítica HD ({x1h-x0h}×{y1h-y0h}px)\n'
497
+ f'Activación: máx={max_act:.3f}, prom={avg_act:.3f}',
498
+ fontsize=12, pad=20)
499
+ else:
500
+ zoom_region = img_arr[y0:y1, x0:x1].astype(np.uint8)
501
+ plt.imshow(zoom_region)
502
+ plt.title('Zona Crítica', fontsize=12)
503
+ high_pct, max_act, avg_act = 0.0, 0.0, 0.0
504
+
505
+ plt.axis('off')
506
+ plt.tight_layout()
507
+ buf = BytesIO()
508
+ plt.savefig(buf, format='png', dpi=150, bbox_inches='tight',
509
+ facecolor='white', edgecolor='none')
510
+ buf.seek(0)
511
+ img_b64 = base64.b64encode(buf.getvalue()).decode()
512
+ plt.close()
513
+
514
+ if high_pct > 20:
515
+ clinical_info = f"Lesión focal intensa ({high_pct:.1f}% activación alta)"
516
+ elif high_pct > 10:
517
+ clinical_info = f"Cambios moderados en región focal ({high_pct:.1f}%)"
518
+ else:
519
+ clinical_info = "Cambios sutiles de DR detectados"
520
+
521
+ return jsonify({
522
+ 'success': True,
523
+ 'gradcam_image': f"data:image/png;base64,{img_b64}",
524
+ 'analysis': {
525
+ 'max_activation': max_act,
526
+ 'avg_activation': avg_act,
527
+ 'high_activation_pct': high_pct,
528
+ 'clinical_info': clinical_info,
529
+ 'zoom_region_hd': (x0h, y0h, x1h, y1h)
530
+ }
531
+ })
532
+ except Exception as e:
533
+ import traceback; traceback.print_exc()
534
+ return jsonify({'success': False, 'error': str(e)}), 500
535
+
536
+
537
+ # ------------------------------------------------------------------ #
538
+ # RUTAS - CONSULTAS
539
+ # ------------------------------------------------------------------ #
540
+ @app.route('/api/consultations', methods=['GET'])
541
+ @login_required
542
+ def get_consultations():
543
+ page = int(request.args.get('page', 1))
544
+ per_page = int(request.args.get('per_page', 10))
545
+ search = request.args.get('search', '')
546
+ filter_type = request.args.get('filter', 'all')
547
+ result = db.get_consultations(session['user_id'], session['role'],
548
+ page, per_page, search, filter_type)
549
+ return jsonify(result)
550
+
551
+ @app.route('/api/consultations/<int:consultation_id>', methods=['DELETE'])
552
+ @login_required
553
+ def delete_consultation(consultation_id):
554
+ conn = db.get_connection()
555
+ try:
556
+ row = conn.execute(
557
+ "SELECT createdByUserID FROM Consultations WHERE consultationID=?",
558
+ (consultation_id,)
559
+ ).fetchone()
560
+ if not row:
561
+ return jsonify({'success': False, 'message': 'Consulta no encontrada'}), 404
562
+ if session['role'] != 'Admin' and row['createdByUserID'] != session['user_id']:
563
+ return jsonify({'success': False, 'message': 'Acceso denegado'}), 403
564
+ conn.execute("DELETE FROM Consultations WHERE consultationID=?", (consultation_id,))
565
+ conn.commit()
566
+ return jsonify({'success': True})
567
+ except Exception as e:
568
+ return jsonify({'success': False, 'error': str(e)}), 500
569
+ finally:
570
+ conn.close()
571
+
572
+ @app.route('/api/consultations', methods=['POST'])
573
+ @login_required
574
+ def save_consultation():
575
+ data = request.json
576
+ patient_id = data.get('patientId')
577
+ if not patient_id:
578
+ return jsonify({'success': False, 'message': 'patientId requerido'}), 400
579
+
580
+ patient = db.get_patient(patient_id)
581
+ if not patient:
582
+ return jsonify({'success': False, 'message': 'Paciente no encontrado'}), 404
583
+ if session['role'] != 'Admin' and patient['createdByUserID'] != session['user_id']:
584
+ return jsonify({'success': False, 'message': 'Acceso denegado'}), 403
585
+
586
+ right = data.get('rightEye', {})
587
+ left = data.get('leftEye', {})
588
+ notes = data.get('notes', '')
589
+
590
+ if right.get('hasAnalysis') and left.get('hasAnalysis'):
591
+ has_dr = right['diagnosis'] or left['diagnosis']
592
+ confidence = (right['confidence'] + left['confidence']) / 2
593
+ raw_output = (right.get('rawOutput', 0) + left.get('rawOutput', 0)) / 2
594
+ detailed_notes = (
595
+ f"BILATERAL - OD: {'Positivo' if right['diagnosis'] else 'Negativo'} "
596
+ f"({right['confidence']:.1f}%) | "
597
+ f"OI: {'Positivo' if left['diagnosis'] else 'Negativo'} "
598
+ f"({left['confidence']:.1f}%)\n{notes}"
599
+ )
600
+ elif right.get('hasAnalysis'):
601
+ has_dr = right['diagnosis']
602
+ confidence = right['confidence']
603
+ raw_output = right.get('rawOutput', 0)
604
+ detailed_notes = f"OJO DERECHO: {'Positivo' if has_dr else 'Negativo'} ({confidence:.1f}%)\n{notes}"
605
+ elif left.get('hasAnalysis'):
606
+ has_dr = left['diagnosis']
607
+ confidence = left['confidence']
608
+ raw_output = left.get('rawOutput', 0)
609
+ detailed_notes = f"OJO IZQUIERDO: {'Positivo' if has_dr else 'Negativo'} ({confidence:.1f}%)\n{notes}"
610
+ else:
611
+ return jsonify({'success': False, 'message': 'Sin análisis de imagen'}), 400
612
+
613
+ cid = db.create_consultation(patient_id, session['user_id'],
614
+ has_dr, confidence, raw_output, detailed_notes)
615
+ if cid:
616
+ return jsonify({'success': True, 'consultationID': cid,
617
+ 'message': 'Consulta guardada exitosamente'})
618
+ return jsonify({'success': False, 'message': 'Error guardando consulta'}), 500
619
+
620
+
621
+ # ------------------------------------------------------------------ #
622
+ # RUTAS - DASHBOARD
623
+ # ------------------------------------------------------------------ #
624
+ @app.route('/api/dashboard/stats', methods=['GET'])
625
+ @login_required
626
+ def dashboard_stats():
627
+ result = db.get_dashboard_stats(session['user_id'], session['role'])
628
+ # Add legacy field aliases for frontend compatibility
629
+ if result.get('success') and result.get('stats'):
630
+ s = result['stats']
631
+ s['total_unique_patients'] = s.get('total_patients', 0)
632
+ s['patients_with_rd'] = s.get('positive_cases', 0)
633
+ s['patients_without_rd'] = s.get('negative_cases', 0)
634
+ s['summary_stats'] = {
635
+ 'total_consultations': s.get('total_consultations', 0),
636
+ 'positive_cases': s.get('positive_cases', 0),
637
+ 'negative_cases': s.get('negative_cases', 0),
638
+ 'unique_patients': s.get('total_patients', 0),
639
+ }
640
+ return jsonify(result)
641
+
642
+ @app.route('/api/model/info', methods=['GET'])
643
+ @login_required
644
+ def model_info():
645
+ if model is None:
646
+ return jsonify({'loaded': False, 'error': 'Modelo no cargado'})
647
+ return jsonify({
648
+ 'loaded': True,
649
+ 'model_name': 'EfficientNetB0 - Diabetic Retinopathy Classifier',
650
+ 'input_shape': str(model.input_shape),
651
+ 'classes': CLASS_NAMES,
652
+ 'total_params': int(model.count_params()),
653
+ 'tensorflow_version': tf.__version__
654
+ })
655
+
656
+
657
+ # ------------------------------------------------------------------ #
658
+ # RUTAS - TAREAS (por usuario)
659
+ # ------------------------------------------------------------------ #
660
+ @app.route('/api/tasks', methods=['GET'])
661
+ @login_required
662
+ def get_tasks():
663
+ return jsonify({'success': True, 'tasks': db.get_tasks(session['user_id'])})
664
+
665
+ @app.route('/api/tasks', methods=['POST'])
666
+ @login_required
667
+ def add_task():
668
+ text = (request.json.get('text') or '').strip()
669
+ if not text:
670
+ return jsonify({'success': False, 'message': 'Texto requerido'}), 400
671
+ task = db.add_task(session['user_id'], text)
672
+ return jsonify({'success': True, 'task': task})
673
+
674
+ @app.route('/api/tasks/<int:task_id>/toggle', methods=['POST'])
675
+ @login_required
676
+ def toggle_task(task_id):
677
+ db.toggle_task(task_id, session['user_id'])
678
+ return jsonify({'success': True})
679
+
680
+ @app.route('/api/tasks/<int:task_id>', methods=['DELETE'])
681
+ @login_required
682
+ def delete_task(task_id):
683
+ db.delete_task(task_id, session['user_id'])
684
+ return jsonify({'success': True})
685
+
686
+
687
+ # ------------------------------------------------------------------ #
688
+ # ARRANQUE
689
+ # ------------------------------------------------------------------ #
690
+ if __name__ == '__main__':
691
+ print("Cargando modelo de TensorFlow...")
692
+ load_model()
693
+ port = int(os.environ.get('PORT', 7860))
694
+ app.run(host='0.0.0.0', port=port, debug=False)
best_model_fold_2.h5 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:f96845ef4bcf7e4296cc8692b4071815e99b7bb588868c65bd0ce3c51fd44147
3
+ size 17713136
database.py ADDED
@@ -0,0 +1,575 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ BASE DE DATOS SQLITE - APLICACIÓN MÉDICA
4
+ Retinopatía Diabética - Sistema de Diagnóstico
5
+ Versión Web (Flask) - Con aislamiento de datos por usuario
6
+ """
7
+ import sqlite3
8
+ import os
9
+ import hashlib
10
+ from datetime import datetime
11
+ from typing import Optional, List, Dict
12
+
13
+
14
+ DB_PATH = os.environ.get("DB_PATH", "/data/medical_app.db")
15
+
16
+
17
+ class DatabaseManager:
18
+ def __init__(self, db_path: str = DB_PATH):
19
+ self.db_path = db_path
20
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
21
+ self.init_database()
22
+
23
+ def get_connection(self) -> sqlite3.Connection:
24
+ conn = sqlite3.connect(self.db_path)
25
+ conn.row_factory = sqlite3.Row
26
+ conn.execute("PRAGMA foreign_keys = ON")
27
+ return conn
28
+
29
+ def init_database(self):
30
+ conn = self.get_connection()
31
+ try:
32
+ conn.execute('''
33
+ CREATE TABLE IF NOT EXISTS Users (
34
+ userID INTEGER PRIMARY KEY AUTOINCREMENT,
35
+ username VARCHAR(30) NOT NULL UNIQUE,
36
+ password VARCHAR(255) NOT NULL,
37
+ role VARCHAR(20) DEFAULT 'Doctor',
38
+ creationDate DATETIME DEFAULT CURRENT_TIMESTAMP
39
+ )
40
+ ''')
41
+
42
+ conn.execute('''
43
+ CREATE TABLE IF NOT EXISTS Patients (
44
+ patientID INTEGER PRIMARY KEY AUTOINCREMENT,
45
+ createdByUserID INTEGER NOT NULL,
46
+ name VARCHAR(50) NOT NULL,
47
+ birthDate DATE,
48
+ gender VARCHAR(1),
49
+ diabetesType VARCHAR(20),
50
+ creationDate DATETIME DEFAULT CURRENT_TIMESTAMP,
51
+ FOREIGN KEY (createdByUserID) REFERENCES Users(userID)
52
+ )
53
+ ''')
54
+
55
+ conn.execute('''
56
+ CREATE TABLE IF NOT EXISTS RiskFactors (
57
+ riskFactorID INTEGER PRIMARY KEY AUTOINCREMENT,
58
+ name VARCHAR(30) NOT NULL,
59
+ description TEXT,
60
+ creationDate DATETIME DEFAULT CURRENT_TIMESTAMP
61
+ )
62
+ ''')
63
+
64
+ conn.execute('''
65
+ CREATE TABLE IF NOT EXISTS PatientsRiskFactors (
66
+ patientID INTEGER NOT NULL,
67
+ riskFactorID INTEGER NOT NULL,
68
+ creationDate DATETIME DEFAULT CURRENT_TIMESTAMP,
69
+ PRIMARY KEY (patientID, riskFactorID),
70
+ FOREIGN KEY (patientID) REFERENCES Patients(patientID) ON DELETE CASCADE,
71
+ FOREIGN KEY (riskFactorID) REFERENCES RiskFactors(riskFactorID) ON DELETE CASCADE
72
+ )
73
+ ''')
74
+
75
+ conn.execute('''
76
+ CREATE TABLE IF NOT EXISTS Consultations (
77
+ consultationID INTEGER PRIMARY KEY AUTOINCREMENT,
78
+ patientID INTEGER NOT NULL,
79
+ createdByUserID INTEGER NOT NULL,
80
+ diabeticRetinopathy BOOLEAN DEFAULT FALSE,
81
+ notes TEXT,
82
+ consultationDate DATETIME DEFAULT CURRENT_TIMESTAMP,
83
+ imagePath TEXT,
84
+ confidence REAL,
85
+ rawOutput REAL,
86
+ FOREIGN KEY (patientID) REFERENCES Patients(patientID) ON DELETE CASCADE,
87
+ FOREIGN KEY (createdByUserID) REFERENCES Users(userID)
88
+ )
89
+ ''')
90
+
91
+ conn.execute('''
92
+ CREATE TABLE IF NOT EXISTS Tasks (
93
+ taskID INTEGER PRIMARY KEY AUTOINCREMENT,
94
+ userID INTEGER NOT NULL,
95
+ text TEXT NOT NULL,
96
+ completed BOOLEAN DEFAULT FALSE,
97
+ creationDate DATETIME DEFAULT CURRENT_TIMESTAMP,
98
+ FOREIGN KEY (userID) REFERENCES Users(userID) ON DELETE CASCADE
99
+ )
100
+ ''')
101
+
102
+ conn.commit()
103
+ self._insert_default_data(conn)
104
+ print("Base de datos inicializada correctamente")
105
+ except Exception as e:
106
+ print(f"Error inicializando base de datos: {e}")
107
+ conn.rollback()
108
+ finally:
109
+ conn.close()
110
+
111
+ def _insert_default_data(self, conn):
112
+ try:
113
+ cursor = conn.execute("SELECT COUNT(*) FROM Users WHERE username = 'admin'")
114
+ if cursor.fetchone()[0] == 0:
115
+ admin_password = self.hash_password("admin123")
116
+ conn.execute(
117
+ "INSERT INTO Users (username, password, role) VALUES (?, ?, ?)",
118
+ ("admin", admin_password, "Admin")
119
+ )
120
+ print("Usuario administrador creado: admin / admin123")
121
+
122
+ cursor = conn.execute("SELECT COUNT(*) FROM RiskFactors")
123
+ if cursor.fetchone()[0] == 0:
124
+ risk_factors = [
125
+ ("Hipertensión", "Presión arterial alta"),
126
+ ("Diabetes Tipo 1", "Diabetes mellitus dependiente de insulina"),
127
+ ("Diabetes Tipo 2", "Diabetes mellitus no dependiente de insulina"),
128
+ ("Obesidad", "Índice de masa corporal elevado"),
129
+ ("Tabaquismo", "Consumo de cigarrillos o tabaco"),
130
+ ("Sedentarismo", "Falta de actividad física regular"),
131
+ ("Antecedentes Familiares", "Historia familiar de diabetes o cardiovascular"),
132
+ ("Edad Avanzada", "Mayor de 65 años"),
133
+ ("Colesterol Alto", "Niveles elevados de colesterol"),
134
+ ("Nefropatía", "Enfermedad renal relacionada con diabetes"),
135
+ ]
136
+ conn.executemany(
137
+ "INSERT INTO RiskFactors (name, description) VALUES (?, ?)",
138
+ risk_factors
139
+ )
140
+ print("Factores de riesgo insertados")
141
+
142
+ conn.commit()
143
+ except Exception as e:
144
+ print(f"Error insertando datos por defecto: {e}")
145
+
146
+ # ------------------------------------------------------------------ #
147
+ # UTILIDADES
148
+ # ------------------------------------------------------------------ #
149
+ @staticmethod
150
+ def hash_password(password: str) -> str:
151
+ return hashlib.sha256(password.encode()).hexdigest()
152
+
153
+ @staticmethod
154
+ def _serialize(row) -> Dict:
155
+ """Convierte sqlite3.Row a dict y serializa fechas."""
156
+ d = dict(row)
157
+ for key, val in d.items():
158
+ if isinstance(val, (datetime,)):
159
+ d[key] = str(val)
160
+ return d
161
+
162
+ # ------------------------------------------------------------------ #
163
+ # USUARIOS
164
+ # ------------------------------------------------------------------ #
165
+ def authenticate_user(self, username: str, password: str) -> Optional[Dict]:
166
+ conn = self.get_connection()
167
+ try:
168
+ hashed = self.hash_password(password)
169
+ cursor = conn.execute(
170
+ "SELECT userID, username, role, creationDate FROM Users WHERE username=? AND password=?",
171
+ (username, hashed)
172
+ )
173
+ row = cursor.fetchone()
174
+ return self._serialize(row) if row else None
175
+ finally:
176
+ conn.close()
177
+
178
+ def create_user(self, username: str, password: str, role: str = "Doctor") -> bool:
179
+ conn = self.get_connection()
180
+ try:
181
+ conn.execute(
182
+ "INSERT INTO Users (username, password, role) VALUES (?, ?, ?)",
183
+ (username, self.hash_password(password), role)
184
+ )
185
+ conn.commit()
186
+ return True
187
+ except sqlite3.IntegrityError:
188
+ return False
189
+ finally:
190
+ conn.close()
191
+
192
+ def get_all_users(self) -> List[Dict]:
193
+ conn = self.get_connection()
194
+ try:
195
+ rows = conn.execute(
196
+ "SELECT userID, username, role, creationDate FROM Users ORDER BY creationDate DESC"
197
+ ).fetchall()
198
+ return [self._serialize(r) for r in rows]
199
+ finally:
200
+ conn.close()
201
+
202
+ def get_user(self, user_id: int) -> Optional[Dict]:
203
+ conn = self.get_connection()
204
+ try:
205
+ row = conn.execute(
206
+ "SELECT userID, username, role, creationDate FROM Users WHERE userID=?",
207
+ (user_id,)
208
+ ).fetchone()
209
+ return self._serialize(row) if row else None
210
+ finally:
211
+ conn.close()
212
+
213
+ def update_user(self, user_id: int, username: str = None, role: str = None, password: str = None) -> bool:
214
+ conn = self.get_connection()
215
+ try:
216
+ if password:
217
+ conn.execute(
218
+ "UPDATE Users SET username=?, role=?, password=? WHERE userID=?",
219
+ (username, role, self.hash_password(password), user_id)
220
+ )
221
+ else:
222
+ conn.execute(
223
+ "UPDATE Users SET username=?, role=? WHERE userID=?",
224
+ (username, role, user_id)
225
+ )
226
+ conn.commit()
227
+ return True
228
+ except Exception:
229
+ return False
230
+ finally:
231
+ conn.close()
232
+
233
+ def delete_user(self, user_id: int) -> bool:
234
+ conn = self.get_connection()
235
+ try:
236
+ conn.execute("DELETE FROM Users WHERE userID=?", (user_id,))
237
+ conn.commit()
238
+ return True
239
+ except Exception:
240
+ return False
241
+ finally:
242
+ conn.close()
243
+
244
+ # ------------------------------------------------------------------ #
245
+ # PACIENTES (con filtrado por usuario según rol)
246
+ # ------------------------------------------------------------------ #
247
+ def create_patient(self, created_by_user_id: int, name: str,
248
+ birth_date: str = None, gender: str = None,
249
+ diabetes_type: str = None) -> Optional[int]:
250
+ conn = self.get_connection()
251
+ try:
252
+ cursor = conn.execute(
253
+ "INSERT INTO Patients (createdByUserID, name, birthDate, gender, diabetesType) VALUES (?,?,?,?,?)",
254
+ (created_by_user_id, name, birth_date, gender, diabetes_type)
255
+ )
256
+ conn.commit()
257
+ return cursor.lastrowid
258
+ except Exception as e:
259
+ print(f"Error creando paciente: {e}")
260
+ return None
261
+ finally:
262
+ conn.close()
263
+
264
+ def get_patients(self, user_id: int, role: str) -> List[Dict]:
265
+ """Admin ve todos; Doctor ve solo los suyos."""
266
+ conn = self.get_connection()
267
+ try:
268
+ if role == "Admin":
269
+ rows = conn.execute(
270
+ """SELECT p.*, u.username as doctorName
271
+ FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
272
+ ORDER BY p.creationDate DESC"""
273
+ ).fetchall()
274
+ else:
275
+ rows = conn.execute(
276
+ """SELECT p.*, u.username as doctorName
277
+ FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
278
+ WHERE p.createdByUserID = ?
279
+ ORDER BY p.creationDate DESC""",
280
+ (user_id,)
281
+ ).fetchall()
282
+ return [self._serialize(r) for r in rows]
283
+ finally:
284
+ conn.close()
285
+
286
+ def get_patient(self, patient_id: int) -> Optional[Dict]:
287
+ conn = self.get_connection()
288
+ try:
289
+ row = conn.execute(
290
+ """SELECT p.*, u.username as doctorName
291
+ FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
292
+ WHERE p.patientID = ?""",
293
+ (patient_id,)
294
+ ).fetchone()
295
+ return self._serialize(row) if row else None
296
+ finally:
297
+ conn.close()
298
+
299
+ def search_patients(self, search_term: str, user_id: int, role: str) -> List[Dict]:
300
+ conn = self.get_connection()
301
+ try:
302
+ like = f"%{search_term}%"
303
+ if role == "Admin":
304
+ rows = conn.execute(
305
+ """SELECT p.*, u.username as doctorName
306
+ FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
307
+ WHERE p.name LIKE ?
308
+ ORDER BY p.name""",
309
+ (like,)
310
+ ).fetchall()
311
+ else:
312
+ rows = conn.execute(
313
+ """SELECT p.*, u.username as doctorName
314
+ FROM Patients p JOIN Users u ON p.createdByUserID = u.userID
315
+ WHERE p.name LIKE ? AND p.createdByUserID = ?
316
+ ORDER BY p.name""",
317
+ (like, user_id)
318
+ ).fetchall()
319
+ return [self._serialize(r) for r in rows]
320
+ finally:
321
+ conn.close()
322
+
323
+ def update_patient(self, patient_id: int, **kwargs) -> bool:
324
+ conn = self.get_connection()
325
+ try:
326
+ fields = {k: v for k, v in kwargs.items() if v is not None}
327
+ if not fields:
328
+ return False
329
+ set_clause = ", ".join(f"{k}=?" for k in fields)
330
+ conn.execute(
331
+ f"UPDATE Patients SET {set_clause} WHERE patientID=?",
332
+ list(fields.values()) + [patient_id]
333
+ )
334
+ conn.commit()
335
+ return True
336
+ except Exception:
337
+ return False
338
+ finally:
339
+ conn.close()
340
+
341
+ def delete_patient(self, patient_id: int) -> bool:
342
+ conn = self.get_connection()
343
+ try:
344
+ conn.execute("DELETE FROM Patients WHERE patientID=?", (patient_id,))
345
+ conn.commit()
346
+ return True
347
+ except Exception:
348
+ return False
349
+ finally:
350
+ conn.close()
351
+
352
+ # ------------------------------------------------------------------ #
353
+ # FACTORES DE RIESGO
354
+ # ------------------------------------------------------------------ #
355
+ def get_all_risk_factors(self) -> List[Dict]:
356
+ conn = self.get_connection()
357
+ try:
358
+ rows = conn.execute("SELECT * FROM RiskFactors ORDER BY name").fetchall()
359
+ return [self._serialize(r) for r in rows]
360
+ finally:
361
+ conn.close()
362
+
363
+ def get_patient_risk_factors(self, patient_id: int) -> List[Dict]:
364
+ conn = self.get_connection()
365
+ try:
366
+ rows = conn.execute(
367
+ """SELECT rf.* FROM RiskFactors rf
368
+ JOIN PatientsRiskFactors prf ON rf.riskFactorID = prf.riskFactorID
369
+ WHERE prf.patientID = ?""",
370
+ (patient_id,)
371
+ ).fetchall()
372
+ return [self._serialize(r) for r in rows]
373
+ finally:
374
+ conn.close()
375
+
376
+ def add_patient_risk_factor(self, patient_id: int, risk_factor_id: int) -> bool:
377
+ conn = self.get_connection()
378
+ try:
379
+ conn.execute(
380
+ "INSERT OR IGNORE INTO PatientsRiskFactors (patientID, riskFactorID) VALUES (?,?)",
381
+ (patient_id, risk_factor_id)
382
+ )
383
+ conn.commit()
384
+ return True
385
+ except Exception:
386
+ return False
387
+ finally:
388
+ conn.close()
389
+
390
+ def remove_patient_risk_factor(self, patient_id: int, risk_factor_id: int) -> bool:
391
+ conn = self.get_connection()
392
+ try:
393
+ conn.execute(
394
+ "DELETE FROM PatientsRiskFactors WHERE patientID=? AND riskFactorID=?",
395
+ (patient_id, risk_factor_id)
396
+ )
397
+ conn.commit()
398
+ return True
399
+ except Exception:
400
+ return False
401
+ finally:
402
+ conn.close()
403
+
404
+ # ------------------------------------------------------------------ #
405
+ # CONSULTAS
406
+ # ------------------------------------------------------------------ #
407
+ def create_consultation(self, patient_id: int, created_by_user_id: int,
408
+ has_dr: bool, confidence: float, raw_output: float,
409
+ notes: str = "") -> Optional[int]:
410
+ conn = self.get_connection()
411
+ try:
412
+ cursor = conn.execute(
413
+ """INSERT INTO Consultations
414
+ (patientID, createdByUserID, diabeticRetinopathy, notes, confidence, rawOutput)
415
+ VALUES (?,?,?,?,?,?)""",
416
+ (patient_id, created_by_user_id, has_dr, notes, confidence, raw_output)
417
+ )
418
+ conn.commit()
419
+ return cursor.lastrowid
420
+ except Exception as e:
421
+ print(f"Error creando consulta: {e}")
422
+ return None
423
+ finally:
424
+ conn.close()
425
+
426
+ def get_patient_consultations(self, patient_id: int) -> List[Dict]:
427
+ conn = self.get_connection()
428
+ try:
429
+ rows = conn.execute(
430
+ """SELECT c.*, u.username as doctorName
431
+ FROM Consultations c JOIN Users u ON c.createdByUserID = u.userID
432
+ WHERE c.patientID = ?
433
+ ORDER BY c.consultationDate DESC""",
434
+ (patient_id,)
435
+ ).fetchall()
436
+ return [self._serialize(r) for r in rows]
437
+ finally:
438
+ conn.close()
439
+
440
+ def get_consultations(self, user_id: int, role: str,
441
+ page: int = 1, per_page: int = 10,
442
+ search: str = "", filter_type: str = "all") -> Dict:
443
+ conn = self.get_connection()
444
+ try:
445
+ base = """FROM Consultations c
446
+ JOIN Patients p ON c.patientID = p.patientID
447
+ JOIN Users u ON c.createdByUserID = u.userID"""
448
+ conditions = []
449
+ params = []
450
+
451
+ if role != "Admin":
452
+ conditions.append("c.createdByUserID = ?")
453
+ params.append(user_id)
454
+
455
+ if search.strip():
456
+ conditions.append("(p.name LIKE ? OR c.notes LIKE ?)")
457
+ params.extend([f"%{search}%", f"%{search}%"])
458
+
459
+ if filter_type == "positive":
460
+ conditions.append("c.diabeticRetinopathy = 1")
461
+ elif filter_type == "negative":
462
+ conditions.append("c.diabeticRetinopathy = 0")
463
+
464
+ where = ("WHERE " + " AND ".join(conditions)) if conditions else ""
465
+
466
+ total = conn.execute(f"SELECT COUNT(*) {base} {where}", params).fetchone()[0]
467
+ total_pages = max(1, (total + per_page - 1) // per_page)
468
+ offset = (page - 1) * per_page
469
+
470
+ rows = conn.execute(
471
+ f"""SELECT c.*, p.name as patientName, u.username as doctorName
472
+ {base} {where}
473
+ ORDER BY c.consultationDate DESC
474
+ LIMIT ? OFFSET ?""",
475
+ params + [per_page, offset]
476
+ ).fetchall()
477
+
478
+ consultations = [self._serialize(r) for r in rows]
479
+ return {
480
+ "success": True,
481
+ "consultations": consultations,
482
+ "pagination": {
483
+ "current_page": page,
484
+ "per_page": per_page,
485
+ "total_pages": total_pages,
486
+ "total_records": total,
487
+ "has_previous": page > 1,
488
+ "has_next": page < total_pages
489
+ }
490
+ }
491
+ except Exception as e:
492
+ return {"success": False, "error": str(e), "consultations": [], "pagination": {}}
493
+ finally:
494
+ conn.close()
495
+
496
+ def get_dashboard_stats(self, user_id: int, role: str) -> Dict:
497
+ conn = self.get_connection()
498
+ try:
499
+ filter_clause = "" if role == "Admin" else "AND c.createdByUserID = ?"
500
+ params = [] if role == "Admin" else [user_id]
501
+
502
+ today = datetime.now().strftime('%Y-%m-%d')
503
+
504
+ stats = conn.execute(f"""
505
+ SELECT
506
+ COUNT(DISTINCT c.patientID) as total_patients,
507
+ COUNT(*) as total_consultations,
508
+ SUM(CASE WHEN c.diabeticRetinopathy=1 THEN 1 ELSE 0 END) as positive_cases,
509
+ SUM(CASE WHEN c.diabeticRetinopathy=0 THEN 1 ELSE 0 END) as negative_cases,
510
+ SUM(CASE WHEN date(c.consultationDate)=? THEN 1 ELSE 0 END) as today_consultations
511
+ FROM Consultations c
512
+ WHERE 1=1 {filter_clause}
513
+ """, [today] + params).fetchone()
514
+
515
+ row = self._serialize(stats)
516
+ total = row.get("total_consultations") or 0
517
+ pos = row.get("positive_cases") or 0
518
+ row["positivity_rate"] = round((pos / total * 100), 1) if total > 0 else 0
519
+ return {"success": True, "stats": row}
520
+ except Exception as e:
521
+ return {"success": False, "error": str(e)}
522
+ finally:
523
+ conn.close()
524
+
525
+ # ------------------------------------------------------------------ #
526
+ # TAREAS (por usuario)
527
+ # ------------------------------------------------------------------ #
528
+ def get_tasks(self, user_id: int) -> List[Dict]:
529
+ conn = self.get_connection()
530
+ try:
531
+ rows = conn.execute(
532
+ "SELECT * FROM Tasks WHERE userID=? ORDER BY completed, creationDate DESC",
533
+ (user_id,)
534
+ ).fetchall()
535
+ return [self._serialize(r) for r in rows]
536
+ finally:
537
+ conn.close()
538
+
539
+ def add_task(self, user_id: int, text: str) -> Optional[Dict]:
540
+ conn = self.get_connection()
541
+ try:
542
+ cursor = conn.execute(
543
+ "INSERT INTO Tasks (userID, text) VALUES (?,?)",
544
+ (user_id, text)
545
+ )
546
+ conn.commit()
547
+ row = conn.execute("SELECT * FROM Tasks WHERE taskID=?", (cursor.lastrowid,)).fetchone()
548
+ return self._serialize(row)
549
+ finally:
550
+ conn.close()
551
+
552
+ def toggle_task(self, task_id: int, user_id: int) -> bool:
553
+ conn = self.get_connection()
554
+ try:
555
+ conn.execute(
556
+ "UPDATE Tasks SET completed = NOT completed WHERE taskID=? AND userID=?",
557
+ (task_id, user_id)
558
+ )
559
+ conn.commit()
560
+ return True
561
+ except Exception:
562
+ return False
563
+ finally:
564
+ conn.close()
565
+
566
+ def delete_task(self, task_id: int, user_id: int) -> bool:
567
+ conn = self.get_connection()
568
+ try:
569
+ conn.execute("DELETE FROM Tasks WHERE taskID=? AND userID=?", (task_id, user_id))
570
+ conn.commit()
571
+ return True
572
+ except Exception:
573
+ return False
574
+ finally:
575
+ conn.close()
requirements.txt ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ flask==3.0.3
2
+ tensorflow==2.13.0
3
+ Pillow==10.3.0
4
+ numpy==1.24.3
5
+ opencv-python-headless==4.9.0.80
6
+ matplotlib==3.7.5
7
+ scipy==1.11.4
8
+ gunicorn==22.0.0