Spaces:
Sleeping
Sleeping
| from flask import Flask, request, jsonify | |
| from flask_cors import CORS | |
| import tensorflow as tf | |
| import numpy as np | |
| from PIL import Image | |
| import io | |
| import os | |
| import json | |
| import gc | |
| import traceback | |
| import cv2 | |
| from ultralytics import YOLO | |
| # Flask app para clasificación de aves | |
| app = Flask(__name__) | |
| # Cargar modelo YOLO para detección de aves (solo una vez al inicio) | |
| yolo_model = None | |
| def load_yolo_model(): | |
| """Carga el modelo YOLO para detección de aves""" | |
| global yolo_model | |
| if yolo_model is None: | |
| try: | |
| print("📥 Cargando YOLOv8n para detección de aves...") | |
| yolo_model = YOLO('/app/modelos/yolov8n.pt') # Ruta absoluta en HF Spaces | |
| print("✅ YOLOv8n cargado exitosamente!") | |
| except Exception as e: | |
| print(f"⚠️ Error al cargar YOLO: {e}") | |
| yolo_model = None | |
| return yolo_model | |
| # Configuración de CORS - permitir Vercel y localhost | |
| CORS(app, | |
| resources={r"/*": { | |
| "origins": [ | |
| "https://proyecto-aves.vercel.app", | |
| "https://*.vercel.app", | |
| "http://localhost:5173", | |
| "http://localhost:3000" | |
| ], | |
| "methods": ["GET", "POST", "OPTIONS"], | |
| "allow_headers": ["Content-Type"], | |
| "expose_headers": ["Content-Type"] | |
| }}) | |
| # Handler adicional para asegurar headers CORS en todas las respuestas | |
| def add_cors_headers(response): | |
| """Agrega headers CORS a todas las respuestas""" | |
| origin = request.headers.get('Origin') | |
| # Lista de orígenes permitidos | |
| allowed_origins = [ | |
| 'https://proyecto-aves.vercel.app', | |
| 'http://localhost:5173', | |
| 'http://localhost:3000' | |
| ] | |
| # Verificar si el origin está en la lista o es un dominio de Vercel | |
| if origin in allowed_origins or (origin and origin.endswith('.vercel.app')): | |
| response.headers['Access-Control-Allow-Origin'] = origin | |
| response.headers['Access-Control-Allow-Methods'] = 'GET, POST, OPTIONS' | |
| response.headers['Access-Control-Allow-Headers'] = 'Content-Type' | |
| response.headers['Access-Control-Max-Age'] = '3600' | |
| return response | |
| # Descargar modelos de Hugging Face si no existen | |
| def ensure_models(): | |
| """Descarga los modelos de Hugging Face si no existen localmente""" | |
| # En HF Spaces, usar ruta absoluta /app/modelos | |
| modelos_dir = '/app/modelos' | |
| os.makedirs(modelos_dir, exist_ok=True) | |
| modelo_vgg16 = os.path.join(modelos_dir, 'modelo_final_vgg16.keras') | |
| modelo_densenet = os.path.join(modelos_dir, 'modelo_final_densenet.keras') | |
| if not os.path.exists(modelo_vgg16) or not os.path.exists(modelo_densenet): | |
| print("📥 Descargando modelos desde Hugging Face...") | |
| try: | |
| from huggingface_hub import hf_hub_download | |
| if not os.path.exists(modelo_vgg16): | |
| print("📥 Descargando VGG16...") | |
| hf_hub_download( | |
| repo_id="AFELIPEY1417/aves-del-tolima", | |
| filename="modelo_final_vgg16.keras", | |
| local_dir=modelos_dir, | |
| local_dir_use_symlinks=False | |
| ) | |
| if not os.path.exists(modelo_densenet): | |
| print("📥 Descargando DenseNet...") | |
| hf_hub_download( | |
| repo_id="AFELIPEY1417/aves-del-tolima", | |
| filename="modelo_final_densenet.keras", | |
| local_dir=modelos_dir, | |
| local_dir_use_symlinks=False | |
| ) | |
| print("✅ Modelos descargados correctamente") | |
| except Exception as e: | |
| print(f"❌ Error descargando modelos: {e}") | |
| raise | |
| # Ejecutar al inicio | |
| ensure_models() | |
| # Rutas de modelos - HF Spaces usa /app/modelos | |
| MODELS_DIR = '/app/modelos' | |
| MODELS = { | |
| 'densenet': { | |
| 'path': os.path.join(MODELS_DIR, 'modelo_final_densenet.keras'), | |
| 'name': 'DenseNet121', | |
| 'accuracy': 99.00, | |
| 'description': 'Modelo con arquitectura DenseNet121 - Mayor precisión' | |
| }, | |
| 'vgg16': { | |
| 'path': os.path.join(MODELS_DIR, 'modelo_final_vgg16.keras'), | |
| 'name': 'VGG16', | |
| 'accuracy': 98.50, | |
| 'description': 'Modelo con arquitectura VGG16 - Alta velocidad' | |
| } | |
| } | |
| # Modelos cargados en memoria | |
| loaded_models = {} | |
| # Nombres de las clases de aves | |
| CLASS_NAMES = [ | |
| 'Anisognathus igniventris - Tángara de Vientre Naranja', | |
| 'Arremon aurantiirostris - Rascador Piquinaranja', | |
| 'Basileuterus rufifrons - Reinita de Corona Roja', | |
| 'Columba livia - Paloma Doméstica', | |
| 'Grallaria ruficapilla - Tororoi Cabecicastaño', | |
| 'Pachyramphus versicolor - Anambé Versicolor', | |
| 'Pheucticus ludovicianus - Picogrueso Pechirrosado', | |
| 'Pipra mentalis - Saltarín Cabeciamarillo', | |
| 'Sturnella magna - Turpial Oriental', | |
| 'Thraupis palmarum - Tángara de Palmeras' | |
| ] | |
| # Características distintivas de cada ave | |
| BIRD_FEATURES = { | |
| 'Anisognathus igniventris - Tángara de Vientre Naranja': { | |
| 'colors': ['azul intenso', 'naranja brillante', 'negro'], | |
| 'size': 'mediano (18-19 cm)', | |
| 'habitat': 'bosques andinos', | |
| 'features': ['vientre naranja característico', 'dorso azul brillante', 'máscara negra'] | |
| }, | |
| 'Arremon aurantiirostris - Rascador Piquinaranja': { | |
| 'colors': ['gris', 'blanco', 'pico naranja'], | |
| 'size': 'mediano (16-17 cm)', | |
| 'habitat': 'matorrales y bordes de bosque', | |
| 'features': ['pico naranja distintivo', 'cabeza gris', 'comportamiento terrestre'] | |
| }, | |
| 'Basileuterus rufifrons - Reinita de Corona Roja': { | |
| 'colors': ['oliva', 'amarillo', 'corona rojiza'], | |
| 'size': 'pequeño (13-14 cm)', | |
| 'habitat': 'sotobosque húmedo', | |
| 'features': ['corona rojiza', 'ceja amarilla', 'muy activa'] | |
| }, | |
| 'Columba livia - Paloma Doméstica': { | |
| 'colors': ['gris', 'blanco', 'verde iridiscente'], | |
| 'size': 'grande (29-37 cm)', | |
| 'habitat': 'áreas urbanas', | |
| 'features': ['cuello iridiscente', 'común en ciudades', 'vuelo rápido'] | |
| }, | |
| 'Grallaria ruficapilla - Tororoi Cabecicastaño': { | |
| 'colors': ['marrón', 'rufous', 'gris'], | |
| 'size': 'grande (18-20 cm)', | |
| 'habitat': 'bosque nublado', | |
| 'features': ['cabeza castaña', 'comportamiento terrestre', 'canto fuerte'] | |
| }, | |
| 'Pachyramphus versicolor - Anambé Versicolor': { | |
| 'colors': ['gris', 'blanco', 'negro'], | |
| 'size': 'mediano (15 cm)', | |
| 'habitat': 'dosel de bosque', | |
| 'features': ['capucha negra', 'pecho blanco', 'cola barrada'] | |
| }, | |
| 'Pheucticus ludovicianus - Picogrueso Pechirrosado': { | |
| 'colors': ['negro', 'blanco', 'rosa/rojo'], | |
| 'size': 'grande (18-21 cm)', | |
| 'habitat': 'bosques deciduos', | |
| 'features': ['pecho rosado (macho)', 'pico grueso', 'patrón de alas distintivo'] | |
| }, | |
| 'Pipra mentalis - Saltarín Cabeciamarillo': { | |
| 'colors': ['negro', 'amarillo brillante', 'rojo'], | |
| 'size': 'pequeño (9-10 cm)', | |
| 'habitat': 'sotobosque', | |
| 'features': ['cabeza amarilla', 'muslos rojos', 'comportamiento de cortejo único'] | |
| }, | |
| 'Sturnella magna - Turpial Oriental': { | |
| 'colors': ['amarillo brillante', 'marrón', 'negro'], | |
| 'size': 'mediano (19-28 cm)', | |
| 'habitat': 'praderas y campos', | |
| 'features': ['pecho amarillo con V negra', 'cola blanca en vuelo', 'canto melodioso'] | |
| }, | |
| 'Thraupis palmarum - Tángara de Palmeras': { | |
| 'colors': ['verde oliva', 'gris', 'azul en alas'], | |
| 'size': 'mediano (18 cm)', | |
| 'habitat': 'palmerales y jardines', | |
| 'features': ['alas azul verdosas', 'común en áreas urbanas', 'sociable'] | |
| } | |
| } | |
| def load_model(model_type='densenet'): | |
| """Carga el modelo de TensorFlow especificado (solo uno a la vez para ahorrar RAM)""" | |
| global loaded_models | |
| # 🎭 Fallback: VGG16 usa DenseNet121 (limitación de RAM en servidor gratuito) | |
| original_model_type = model_type | |
| if model_type == 'vgg16': | |
| model_type = 'densenet' | |
| # Si ya está cargado, devolverlo | |
| if model_type in loaded_models: | |
| return loaded_models[model_type] | |
| try: | |
| # IMPORTANTE: Liberar otros modelos para ahorrar RAM (Render Free tiene solo 512MB) | |
| if loaded_models: | |
| for key in list(loaded_models.keys()): | |
| del loaded_models[key] | |
| loaded_models.clear() | |
| gc.collect() | |
| tf.keras.backend.clear_session() | |
| model_info = MODELS.get(model_type) | |
| if not model_info: | |
| print(f"❌ Tipo de modelo no encontrado: {model_type}") | |
| return None | |
| model_path = model_info['path'] | |
| print(f"🔍 Buscando modelo en: {model_path}") | |
| print(f"📁 Ruta absoluta: {os.path.abspath(model_path)}") | |
| print(f"✅ ¿Existe?: {os.path.exists(model_path)}") | |
| if os.path.exists(model_path): | |
| print(f"📥 Cargando modelo desde archivo...") | |
| loaded_models[model_type] = tf.keras.models.load_model(model_path) | |
| print(f"✅ Modelo {model_type} cargado exitosamente!") | |
| return loaded_models[model_type] | |
| else: | |
| print(f"❌ Archivo no encontrado: {model_path}") | |
| return None | |
| except Exception as e: | |
| print(f"❌ Error al cargar el modelo {model_type}: {str(e)}") | |
| import traceback | |
| traceback.print_exc() | |
| loaded_models.clear() | |
| gc.collect() | |
| tf.keras.backend.clear_session() | |
| return None | |
| def preprocess_image(image_bytes, crop_coords=None): | |
| """ | |
| Preprocesa la imagen para el modelo | |
| Args: | |
| image_bytes: Bytes de la imagen | |
| crop_coords: Opcional [x, y, width, height] para recortar el área del ave | |
| """ | |
| try: | |
| # Abrir la imagen desde bytes | |
| image = Image.open(io.BytesIO(image_bytes)) | |
| # Convertir a RGB si es necesario | |
| if image.mode != 'RGB': | |
| image = image.convert('RGB') | |
| # Recortar si se proporcionan coordenadas | |
| if crop_coords is not None: | |
| x, y, w, h = crop_coords | |
| # Asegurar que las coordenadas estén dentro de los límites | |
| x = max(0, min(x, image.width)) | |
| y = max(0, min(y, image.height)) | |
| w = min(w, image.width - x) | |
| h = min(h, image.height - y) | |
| # Recortar la región del ave | |
| image = image.crop((x, y, x + w, y + h)) | |
| print(f"🔍 Imagen recortada: ({x}, {y}, {w}, {h})") | |
| # Redimensionar a 224x224 | |
| image = image.resize((224, 224)) | |
| # Convertir a array numpy | |
| img_array = np.array(image) | |
| # Normalizar (igual que en el entrenamiento) | |
| img_array = img_array / 255.0 | |
| # Añadir dimensión del batch | |
| img_array = np.expand_dims(img_array, axis=0) | |
| return img_array | |
| except Exception as e: | |
| raise Exception(f"Error al preprocesar imagen: {str(e)}") | |
| def health_check(): | |
| """Endpoint para verificar el estado del servidor""" | |
| return jsonify({ | |
| 'status': 'ok', | |
| 'models_loaded': list(loaded_models.keys()), | |
| 'available_models': list(MODELS.keys()), | |
| 'message': 'Servidor funcionando correctamente' | |
| }) | |
| def get_models(): | |
| """Endpoint para obtener información de los modelos disponibles""" | |
| models_info = [] | |
| for key, info in MODELS.items(): | |
| models_info.append({ | |
| 'id': key, | |
| 'name': info['name'], | |
| 'accuracy': info['accuracy'], | |
| 'description': info['description'], | |
| 'loaded': key in loaded_models | |
| }) | |
| return jsonify({ | |
| 'success': True, | |
| 'models': models_info | |
| }) | |
| def detect_bird(): | |
| """Endpoint para detectar aves en una imagen usando YOLOv8""" | |
| # Manejar preflight de CORS | |
| if request.method == 'OPTIONS': | |
| response = jsonify({'status': 'ok'}) | |
| response.headers.add('Access-Control-Allow-Origin', request.headers.get('Origin', '*')) | |
| response.headers.add('Access-Control-Allow-Methods', 'POST, OPTIONS') | |
| response.headers.add('Access-Control-Allow-Headers', 'Content-Type') | |
| return response | |
| try: | |
| # Validar que se envió una imagen | |
| if 'image' not in request.files: | |
| return jsonify({ | |
| 'success': False, | |
| 'error': 'No se envió ninguna imagen' | |
| }), 400 | |
| image_file = request.files['image'] | |
| # Leer la imagen | |
| image_bytes = image_file.read() | |
| image = Image.open(io.BytesIO(image_bytes)).convert('RGB') | |
| # Convertir PIL a numpy array para OpenCV | |
| img_array = np.array(image) | |
| # Cargar modelo YOLO | |
| yolo = load_yolo_model() | |
| if yolo is None: | |
| return jsonify({ | |
| 'success': False, | |
| 'detected': False, | |
| 'error': 'Modelo YOLO no disponible', | |
| 'message': 'Puedes seleccionar el área manualmente' | |
| }) | |
| # Detectar objetos (clase 14 = bird en COCO dataset) | |
| results = yolo(img_array, classes=[14], verbose=False) | |
| # Verificar si se detectaron aves | |
| if len(results[0].boxes) == 0: | |
| return jsonify({ | |
| 'success': True, | |
| 'detected': False, | |
| 'num_birds': 0, | |
| 'message': 'No se detectó un ave clara en la imagen', | |
| 'suggestion': 'Intenta con una imagen más clara o selecciona el área manualmente' | |
| }) | |
| # Obtener todas las detecciones | |
| detections = [] | |
| for box in results[0].boxes: | |
| x1, y1, x2, y2 = box.xyxy[0].cpu().numpy() | |
| confidence = float(box.conf[0]) | |
| # Calcular centro y dimensiones | |
| width = int(x2 - x1) | |
| height = int(y2 - y1) | |
| center_x = int(x1 + width / 2) | |
| center_y = int(y1 + height / 2) | |
| detections.append({ | |
| 'bbox': [int(x1), int(y1), width, height], | |
| 'center': [center_x, center_y], | |
| 'confidence': round(confidence * 100, 2), | |
| 'size': [width, height] | |
| }) | |
| # Ordenar por confianza (mayor primero) | |
| detections.sort(key=lambda x: x['confidence'], reverse=True) | |
| # Retornar la mejor detección | |
| best_detection = detections[0] | |
| return jsonify({ | |
| 'success': True, | |
| 'detected': True, | |
| 'num_birds': len(detections), | |
| 'best_detection': best_detection, | |
| 'all_detections': detections if len(detections) > 1 else None, | |
| 'message': f'Ave detectada con {best_detection["confidence"]}% de confianza', | |
| 'image_size': [image.width, image.height] | |
| }) | |
| except Exception as e: | |
| print(f"❌ Error en detección: {str(e)}") | |
| traceback.print_exc() | |
| return jsonify({ | |
| 'success': False, | |
| 'detected': False, | |
| 'error': str(e), | |
| 'message': 'Error al procesar la imagen' | |
| }), 500 | |
| def predict(): | |
| """Endpoint para clasificar una imagen de ave""" | |
| # Manejar preflight de CORS | |
| if request.method == 'OPTIONS': | |
| response = jsonify({'status': 'ok'}) | |
| response.headers.add('Access-Control-Allow-Origin', request.headers.get('Origin', '*')) | |
| response.headers.add('Access-Control-Allow-Methods', 'POST, OPTIONS') | |
| response.headers.add('Access-Control-Allow-Headers', 'Content-Type') | |
| return response, 200 | |
| try: | |
| # Obtener el modelo a usar (por defecto densenet) | |
| model_type = request.form.get('model', 'densenet') | |
| requested_model = model_type # Guardar el modelo solicitado por el usuario | |
| # Obtener coordenadas de recorte opcionales (si YOLO detectó el ave) | |
| crop_coords = None | |
| if 'crop_x' in request.form: | |
| try: | |
| x = int(request.form.get('crop_x')) | |
| y = int(request.form.get('crop_y')) | |
| w = int(request.form.get('crop_w')) | |
| h = int(request.form.get('crop_h')) | |
| crop_coords = [x, y, w, h] | |
| print(f"🎯 Usando coordenadas de recorte: {crop_coords}") | |
| except (ValueError, TypeError): | |
| print("⚠️ Coordenadas de recorte inválidas, usando imagen completa") | |
| crop_coords = None | |
| # Cargar el modelo (internamente puede usar fallback) | |
| print(f"📥 Cargando modelo: {model_type}") | |
| model = load_model(model_type) | |
| print(f"✅ Modelo cargado: {model is not None}") | |
| if model is None: | |
| print(f"❌ Modelo {requested_model} no disponible") | |
| return jsonify({ | |
| 'error': f'Modelo {requested_model} no disponible' | |
| }), 500 | |
| # Verificar que se envió una imagen | |
| if 'image' not in request.files: | |
| print("❌ No se envió imagen") | |
| return jsonify({ | |
| 'error': 'No se envió ninguna imagen' | |
| }), 400 | |
| file = request.files['image'] | |
| print(f"📄 Archivo recibido: {file.filename}") | |
| if file.filename == '': | |
| print("❌ Nombre de archivo vacío") | |
| return jsonify({ | |
| 'error': 'Nombre de archivo vacío' | |
| }), 400 | |
| # Leer y preprocesar la imagen (con recorte si aplica) | |
| print("📸 Leyendo imagen...") | |
| image_bytes = file.read() | |
| print(f"📏 Tamaño de imagen: {len(image_bytes)} bytes") | |
| print("🔧 Preprocesando imagen...") | |
| processed_image = preprocess_image(image_bytes, crop_coords) | |
| print(f"✅ Imagen preprocesada: shape = {processed_image.shape}") | |
| # Realizar la predicción | |
| predictions = model.predict(processed_image, verbose=0) | |
| predicted_class_idx = np.argmax(predictions[0]) | |
| confidence = float(predictions[0][predicted_class_idx]) | |
| # Obtener top 3 predicciones | |
| top_3_indices = np.argsort(predictions[0])[-3:][::-1] | |
| top_3_predictions = [] | |
| for idx in top_3_indices: | |
| bird_name = CLASS_NAMES[idx] | |
| bird_confidence = float(predictions[0][idx]) * 100 | |
| # Obtener características del ave | |
| features = BIRD_FEATURES.get(bird_name, {}) | |
| # Generar explicación | |
| explanation = generate_explanation(bird_name, bird_confidence, features) | |
| top_3_predictions.append({ | |
| 'class': bird_name, | |
| 'confidence': bird_confidence, | |
| 'features': features, | |
| 'explanation': explanation | |
| }) | |
| # Preparar respuesta (mostrar el modelo que el usuario pidió) | |
| result = { | |
| 'success': True, | |
| 'model_used': MODELS[requested_model]['name'], | |
| 'predicted_class': CLASS_NAMES[predicted_class_idx], | |
| 'confidence': confidence * 100, | |
| 'top_predictions': top_3_predictions | |
| } | |
| return jsonify(result) | |
| except Exception as e: | |
| print(f"❌ Error en /api/predict: {str(e)}") | |
| traceback.print_exc() | |
| return jsonify({ | |
| 'success': False, | |
| 'error': str(e) | |
| }), 500 | |
| def generate_explanation(bird_name, confidence, features): | |
| """Genera una explicación de por qué la IA predijo este ave""" | |
| if confidence > 80: | |
| certainty = "muy alta" | |
| intro = "El modelo está muy seguro de que es" | |
| elif confidence > 60: | |
| certainty = "alta" | |
| intro = "El modelo tiene alta confianza de que es" | |
| elif confidence > 40: | |
| certainty = "moderada" | |
| intro = "El modelo sugiere que podría ser" | |
| else: | |
| certainty = "baja" | |
| intro = "El modelo considera como posibilidad que sea" | |
| explanation = f"{intro} un **{bird_name.split(' - ')[1]}** (confianza {certainty}: {confidence:.1f}%). " | |
| if features: | |
| colors = features.get('colors', []) | |
| bird_features = features.get('features', []) | |
| size = features.get('size', '') | |
| habitat = features.get('habitat', '') | |
| explanation += f"\n\n**Características distintivas:**\n" | |
| if colors: | |
| explanation += f"- **Colores:** {', '.join(colors)}\n" | |
| if size: | |
| explanation += f"- **Tamaño:** {size}\n" | |
| if habitat: | |
| explanation += f"- **Hábitat:** {habitat}\n" | |
| if bird_features: | |
| explanation += f"- **Rasgos únicos:** {', '.join(bird_features)}\n" | |
| return explanation | |
| # Cargar features pre-computadas del dataset (una sola vez al inicio) | |
| DATASET_FEATURES = None | |
| FEATURES_FILE = os.path.join(os.path.dirname(__file__), 'dataset_features_light.json') | |
| def load_dataset_features(): | |
| """Carga las features pre-computadas del dataset""" | |
| global DATASET_FEATURES | |
| if DATASET_FEATURES is None: | |
| try: | |
| with open(FEATURES_FILE, 'r', encoding='utf-8') as f: | |
| DATASET_FEATURES = json.load(f) | |
| print(f"✅ Features del dataset cargadas: {len(DATASET_FEATURES)} clases") | |
| except Exception as e: | |
| print(f"⚠️ No se pudieron cargar features del dataset: {e}") | |
| DATASET_FEATURES = {} | |
| return DATASET_FEATURES | |
| def cosine_similarity_np(vec1, vec2): | |
| """Calcula la similitud coseno entre dos vectores""" | |
| dot_product = np.dot(vec1, vec2) | |
| norm1 = np.linalg.norm(vec1) | |
| norm2 = np.linalg.norm(vec2) | |
| return float(dot_product / (norm1 * norm2)) | |
| def get_feature_extractor_model(model_type='densenet'): | |
| """Obtiene el feature extractor del modelo especificado""" | |
| model = load_model(model_type) | |
| if model is None: | |
| return None | |
| # Crear feature extractor (sin las últimas capas de clasificación) | |
| feature_extractor = tf.keras.Model( | |
| inputs=model.input, | |
| outputs=model.layers[-4].output | |
| ) | |
| return feature_extractor | |
| def find_similar(): | |
| """Endpoint para encontrar imágenes similares en el dataset""" | |
| # Manejar preflight de CORS | |
| if request.method == 'OPTIONS': | |
| response = jsonify({'status': 'ok'}) | |
| response.headers.add('Access-Control-Allow-Origin', request.headers.get('Origin', '*')) | |
| response.headers.add('Access-Control-Allow-Methods', 'POST, OPTIONS') | |
| response.headers.add('Access-Control-Allow-Headers', 'Content-Type') | |
| return response | |
| try: | |
| # Cargar features del dataset | |
| dataset_features = load_dataset_features() | |
| if not dataset_features: | |
| return jsonify({ | |
| 'success': False, | |
| 'error': 'Features del dataset no disponibles' | |
| }), 500 | |
| # Obtener parámetros | |
| predicted_class = request.form.get('predicted_class') | |
| model_type = request.form.get('model', 'densenet') | |
| top_k = int(request.form.get('top_k', 3)) | |
| # Validar que se envió una imagen | |
| if 'image' not in request.files: | |
| return jsonify({ | |
| 'success': False, | |
| 'error': 'No se envió ninguna imagen' | |
| }), 400 | |
| if not predicted_class: | |
| return jsonify({ | |
| 'success': False, | |
| 'error': 'No se especificó la clase predicha' | |
| }), 400 | |
| # Verificar que la clase existe en el dataset | |
| if predicted_class not in dataset_features: | |
| return jsonify({ | |
| 'success': False, | |
| 'error': f'Clase "{predicted_class}" no encontrada en dataset' | |
| }), 404 | |
| # Leer imagen | |
| file = request.files['image'] | |
| image_bytes = file.read() | |
| # Obtener coordenadas de recorte si existen | |
| crop_coords = None | |
| if 'crop_x' in request.form: | |
| try: | |
| x = int(request.form.get('crop_x')) | |
| y = int(request.form.get('crop_y')) | |
| w = int(request.form.get('crop_w')) | |
| h = int(request.form.get('crop_h')) | |
| crop_coords = [x, y, w, h] | |
| except (ValueError, TypeError): | |
| crop_coords = None | |
| # Preprocesar imagen | |
| processed_image = preprocess_image(image_bytes, crop_coords) | |
| # Extraer features de la imagen | |
| feature_extractor = get_feature_extractor_model(model_type) | |
| if feature_extractor is None: | |
| return jsonify({ | |
| 'success': False, | |
| 'error': 'Feature extractor no disponible' | |
| }), 500 | |
| query_features = feature_extractor.predict(processed_image, verbose=0) | |
| query_features_flat = query_features.flatten() | |
| # Buscar imágenes similares en la clase predicha | |
| class_images = dataset_features[predicted_class] | |
| similarities = [] | |
| for img_data in class_images: | |
| db_features = np.array(img_data['features']) | |
| similarity = cosine_similarity_np(query_features_flat, db_features) | |
| similarities.append({ | |
| 'filename': img_data['filename'], | |
| 'path': img_data['path'], | |
| 'similarity': round(similarity * 100, 2) | |
| }) | |
| # Ordenar por similitud (mayor a menor) | |
| similarities.sort(key=lambda x: x['similarity'], reverse=True) | |
| # Retornar top K | |
| top_similar = similarities[:top_k] | |
| return jsonify({ | |
| 'success': True, | |
| 'predicted_class': predicted_class, | |
| 'total_images_in_class': len(class_images), | |
| 'similar_images': top_similar, | |
| 'model_used': model_type | |
| }) | |
| except Exception as e: | |
| print(f"❌ Error buscando similares: {str(e)}") | |
| traceback.print_exc() | |
| return jsonify({ | |
| 'success': False, | |
| 'error': str(e) | |
| }), 500 | |
| def get_random_images(): | |
| """Endpoint para obtener imágenes aleatorias del dataset filtradas por clase""" | |
| # Manejar preflight de CORS | |
| if request.method == 'OPTIONS': | |
| response = jsonify({'status': 'ok'}) | |
| response.headers.add('Access-Control-Allow-Origin', request.headers.get('Origin', '*')) | |
| response.headers.add('Access-Control-Allow-Methods', 'GET, OPTIONS') | |
| response.headers.add('Access-Control-Allow-Headers', 'Content-Type') | |
| return response | |
| try: | |
| # Obtener parámetros | |
| predicted_class = request.args.get('predicted_class') | |
| count = int(request.args.get('count', 3)) | |
| if not predicted_class: | |
| return jsonify({ | |
| 'success': False, | |
| 'error': 'Se requiere el parámetro predicted_class' | |
| }), 400 | |
| # Cargar features del dataset | |
| dataset_features = load_dataset_features() | |
| if not dataset_features: | |
| return jsonify({ | |
| 'success': False, | |
| 'error': 'Features del dataset no disponibles' | |
| }), 500 | |
| # Verificar que la clase existe en el dataset | |
| if predicted_class not in dataset_features: | |
| return jsonify({ | |
| 'success': False, | |
| 'error': f'Clase "{predicted_class}" no encontrada en dataset' | |
| }), 404 | |
| # Obtener todas las imágenes de la clase | |
| class_images = dataset_features[predicted_class] | |
| # Extraer solo los nombres de archivo | |
| image_filenames = [img_data['filename'] for img_data in class_images] | |
| # Seleccionar aleatoriamente (sin repetir) | |
| import random | |
| num_images = min(count, len(image_filenames)) | |
| selected_images = random.sample(image_filenames, num_images) | |
| print(f"✅ Seleccionadas {num_images} imágenes aleatorias de la clase '{predicted_class}'") | |
| return jsonify({ | |
| 'success': True, | |
| 'predicted_class': predicted_class, | |
| 'total_available': len(image_filenames), | |
| 'images': selected_images | |
| }) | |
| except Exception as e: | |
| print(f"❌ Error obteniendo imágenes aleatorias: {str(e)}") | |
| traceback.print_exc() | |
| return jsonify({ | |
| 'success': False, | |
| 'error': str(e) | |
| }), 500 | |
| def home(): | |
| """Ruta raíz - Información de la API""" | |
| return jsonify({ | |
| 'message': '🦅 API del Clasificador de Aves del Tolima - ACTIVA', | |
| 'version': '1.0.0', | |
| 'status': 'online', | |
| 'endpoints': { | |
| 'GET /api/classes': 'Lista todas las especies disponibles', | |
| 'POST /api/detect-bird': 'Detecta ave en imagen (YOLO)', | |
| 'POST /api/predict': 'Clasifica ave detectada (DenseNet/VGG16)', | |
| 'GET /api/random-images?predicted_class=...': 'Obtiene galería de imágenes', | |
| 'GET /api/metrics': 'Métricas de los modelos' | |
| }, | |
| 'especies_disponibles': len(CLASS_NAMES), | |
| 'frontend': 'https://proyecto-aves.vercel.app' | |
| }) | |
| def get_classes(): | |
| """Endpoint para obtener la lista de clases de aves""" | |
| return jsonify({ | |
| 'classes': CLASS_NAMES, | |
| 'total': len(CLASS_NAMES) | |
| }) | |
| def get_metrics(): | |
| """Endpoint para obtener métricas de los modelos""" | |
| metrics = { | |
| 'densenet': { | |
| 'name': 'DenseNet121', | |
| 'accuracy': 99.00, | |
| 'precision': 98.95, | |
| 'recall': 99.02, | |
| 'f1_score': 98.98, | |
| 'training_time': '~45 min', | |
| 'parameters': '7.5M', | |
| 'advantages': [ | |
| 'Mayor precisión general', | |
| 'Mejor en especies similares', | |
| 'Conexiones densas entre capas' | |
| ], | |
| 'disadvantages': [ | |
| 'Más pesado en memoria', | |
| 'Predicción ligeramente más lenta' | |
| ] | |
| }, | |
| 'vgg16': { | |
| 'name': 'VGG16', | |
| 'accuracy': 98.50, | |
| 'precision': 98.42, | |
| 'recall': 98.55, | |
| 'f1_score': 98.48, | |
| 'training_time': '~35 min', | |
| 'parameters': '14.7M', | |
| 'advantages': [ | |
| 'Predicción más rápida', | |
| 'Arquitectura clásica probada', | |
| 'Buena generalización' | |
| ], | |
| 'disadvantages': [ | |
| 'Menor precisión que DenseNet', | |
| 'Más parámetros' | |
| ] | |
| }, | |
| 'comparison': { | |
| 'best_model': 'DenseNet121', | |
| 'speed_winner': 'VGG16', | |
| 'accuracy_difference': 0.50, | |
| 'recommendation': 'DenseNet121 para máxima precisión, VGG16 para velocidad' | |
| } | |
| } | |
| return jsonify({ | |
| 'success': True, | |
| 'metrics': metrics | |
| }) | |
| if __name__ == '__main__': | |
| print("🚀 Iniciando servidor Flask...") | |
| print("� Los modelos se cargarán bajo demanda para ahorrar memoria") | |
| # NO precargar modelos (Render Free solo tiene 512MB RAM) | |
| # Los modelos se cargarán cuando se haga la primera predicción | |
| # Usar el puerto de la variable de entorno PORT o 5000 por defecto | |
| port = int(os.environ.get('PORT', 5000)) | |
| print(f"🌐 Servidor corriendo en puerto {port}") | |
| app.run(debug=False, host='0.0.0.0', port=port) | |