Este proyecto se enfoca en el desarrollo de un sistema avanzado para la identificación y clasificación inteligente de la madurez de plátanos, utilizando tecnologías de aprendizaje profundo y desarrollo web. La base del sistema es la serie de modelos de detección de objetos YOLO (incluyendo YOLOv8, v10, v11 y v12), que permiten un análisis rápido y preciso de imágenes de plátanos para determinar su estado de madurez. La arquitectura backend se construye con el framework Spring Boot, ofreciendo una API RESTful robusta, mientras que el frontend se implementa de manera desacoplada para una experiencia de usuario intuitiva.
El sistema incorpora funcionalidades comlpetas como autenticación de usuarios, selección dinámica de modelos de IA, diferentes modos de detección (imágenes estáticas, videos y cámaras en tiempo real), gestión de registros de detección, visualización de datos y un panel de administración. Complementado con capacidades de análisis inteligente, el sistema no solo es una aplicación eficiente de visión por computadora, sino también una plataforma integral y escalable para la gestión agrícola inteligente.
1. Introducción
En el contexto de la agricultura moderna y la clasificación inteligente de productos, existe una creciente demanda de métodos de inspección automatizados y no destructivos para evaluar la calidad de los productos agrícolas. El plátano, una fruta climatérica, requiere una clasificación precisa de su madurez para maximizar su valor comercial y dirigir su distribución. Los métodos manuales tradicionales son ineficientes, subjetivos y costosos, lo que subraya la necesidad de un sistema automático y preciso para determinar la madurez del plátano.
Para abordar esta necesidad, hemos desarrollado este sistema de reconocimiento de madurez de plátanos. Combina los algoritmos de detección de objetos YOLO, líderes en visión por computadora, con un stack de desarrollo web moderno para crear una solución desacoplada de frontend y backend. Este sistema no solo proporciona una potente funcionalidad de detección, sino que también garantiza facilidad de uso, capacidad de gestión y escalabilidad a través de una interfaz de usuario cuidadosamente diseñada y un módulo de administración, ofreciendo una solución completa para el monitoreo de calidad y la clasificación inteligente de plátanos.
2. Contexto del Proyecto
2.1. Contexto Tecnológico
- Aplicaciones de Aprendizaje Profundo en la Agricultura: En los últimos años, las redes neuronales convolucionales (CNN), especialmente los modelos de detección de objetos de una sola etapa (como la serie YOLO), han demostrado un rendimiento excepcional en tareas de clasificación de imágenes y detección de objetos. Esto ha llevado a su adopción generalizada en escenarios agrícolas para la identificación de enfermedades de plantas, el recuento de frutas y la determinación de la madurez.
- Evolución de los Algoritmos YOLO: Desde YOLOv8, ha habido una actividad comunitaria significativa, resultando en varias versiones mejoradas (por ejemplo, el diseño sin NMS de YOLOv10, y las continuas optimizaciones de precisión y velocidad en v11/v12). Esto proporciona a los desarrolladores una amplia gama de modelos para elegir, adaptándose a diversas necesidades de precisión y velocidad.
- Madurez de las Tecnologías Web: Spring Boot, el framework backend más popular en el ecosistema Java, es ideal para construir microservicios y APIs estables debido a su simplicidad y eficiencia. La arquitectura de frontend y backend desacoplada mejora la eficiencia del desarrollo y la mantenibilidad del sistema al separar la interfaz de usuario de la lógica de negocio.
2.2. Contexto de Negocio
En las etapas de cosecha, almacenamiento, logística y venta de plátanos, la clasificación según su madurez es crucial para mantener la calidad del producto y minimizar las pérdidas. Un sistema de identificación automatizado puede:
- Incrementar la Eficiencia: Sustituir el trabajo repetitivo de clasificación manual.
- Estandarizar Criterios: Eliminar las inconsistencias en la clasificación causadas por juicios humanos variables.
- Facilitar la Gestión de Datos: Almacenar los resultados de detección para facilitar la trazabilidad de la calidad y el análisis de datos.
3. Descripción del Conjunto de Datos
El éxito de este proyecto reside en entrenar un modelo que pueda identificar con precisión la madurez del plátano. Por lo tanto, un conjunto de datos de alta calidad y con anotaciones precisas es fundamental.
3.1. Origen y Composición del Conjunto de Datos
El conjunto de datos utilizado en este proyecto es una colección de imágenes específicamente compilada para la tarea de detección de madurez de plátanos. Incluye imágenes de plátanos capturadas bajo diversas condiciones de iluminación, ángulos y fondos, asegurando una fuerte capacidad de generalización del modelo. Los plátanos en las imágenes están meticulosamente anotados en las siguientes categorías, que representan distintos grados de madurez:
- Inmaduro: La piel del plátano es verde y su textura es firme.
- Semi-maduro: La piel del plátano muestra una mezcla de colores amarillo y verde, y comienza a ablandarse.
- Maduro: La piel del plátano es de un amarillo brillante y su textura es suave, ideal para el consumo.
- Sobre-maduro: La piel del plátano presenta numerosas manchas marrones o se ha vuelto completamente marrón, y comienza a deteriorarse.
3.2. Anotación y Formato de Datos
Todas las imágenes fueron anotadas con cuadros delimitadores utilizando herramientas profesionales (como LabelImg). Los archivos de anotación están en el formato estándar YOLO (.txt), donde cada archivo contiene el índice de clase y las coordenadas del cuadro delimitador normalizadas (<class_id> <x_center> <y_center> <width> <height>) para todas las instancias de plátanos en la imagen correspondiente. Este formato es totalmente compatible con los requisitos de entrenamiento de los modelos YOLOv8/v10/v11/v12 que empleamos.
Módulos de Funcionalidad
- Gestión de Usuarios (Login y Registro) con validación de credenciales y almacenamiento en MySQL.
- Soporte para cambio entre cuatro modelos YOLO: YOLOv8, YOLOv10, YOLOv11 y YOLOv12.
- Visualización de infomración y datos.
- Funcionalidad de análisis con IA (DeepSeek) para detección de imágenes.
- Modos de detección: imágenes, videos y tiempo real con cámara. Los resultados se guardan en MySQL.
- Administración de registros de detección para imágenes, videos y cámara.
- Módulo de gestión de usuarios para que los administradores realicen operaciones CRUD.
- Sección de perfil personal para modificar información, contraseña y avatar.
- Personalización del color de fondo de la barra de navegación.
Módulo de Detección de Imágenes
- Integración de modelos YOLO (v8/v10/v11/v12)
- Análisis multimodal con DeepSeek
- Soporte de formatos: JPG/PNG/MP4/RTSP
Diseño de Tablas MySQL
users- Tabla de información de usuariosimgrecords- Tabla de registros de detección de imágenesvideorecords- Tabla de registros de detección de videoscamerarecords- Tabla de registros de detección por cámara
Resultados del Entrenamiento del Modelo
El siguiente script Python ilustra un ejemplo de configuración para el entrenamiento de un modelo YOLLO con la librería Ultralytics. Permite especificar la ruta del modelo pre-entrenado o de configuración, la ruta del conjunto de datos y los parámetros clave para el proceso de entrenamiento.
# -*- coding: utf-8 -*-
# Script de entrenamiento para modelos YOLO con Ultralytics.
# Configure el modelo según los requerimientos de su aplicación (ej. velocidad vs. precisión).
# Opciones comunes de modelos YOLO y sus características:
# - yolon.yaml (nano): Modelo muy ligero, óptimo para dispositivos embebidos. Rápido, pero menos preciso.
# - yolos.yaml (small): Modelo pequeño, adecuado para tareas que requieren procesamiento en tiempo real.
# - yolom.yaml (medium): Modelo de tamaño intermedio, que ofrece un buen balance entre velocidad y precisión.
# - yolob.yaml (base): La versión estándar del modelo, funcional para la mayoría de los casos de uso.
# - yolol.yaml (large): Un modelo más grande, diseñado para aplicaciones donde la alta precisión es crítica.
from ultralytics import YOLO
# Rutas de configuración para el modelo y el conjunto de datos
ruta_del_modelo = 'pt/yolo12s.pt' # Ubicación del archivo de pesos o configuración del modelo
ruta_del_dataset = 'data.yaml' # Archivo de configuración del conjunto de datos
if __name__ == '__main__':
# Inicialización de la instancia del modelo YOLO
instancia_yolo = YOLO(ruta_del_modelo)
# Inicio del proceso de entrenamiento del modelo
historial_entrenamiento = instancia_yolo.train(
data=ruta_del_dataset, # Ruta al archivo YAML que describe el dataset
epochs=500, # Número total de épocas de entrenamiento
batch=64, # Tamaño del lote de datos procesado en cada iteración
device='0', # Identificador del dispositivo de cómputo (ej. '0' para la primera GPU)
workers=0, # Número de subprocesos para la carga de datos (0 = carga en hilo principal)
project='runs_entrenamiento', # Directorio raíz donde se guardarán los resultados
name='exp_madurez_banana' # Nombre específico de esta ejecución de entrenamiento
)
Fragmento de Código Frontend
El siguiente fragmento de código Vue.js muestra una sección de la interfaz de usuario para la visualización de registros de detección y estadísticas. Utiliza componentes de Element Plus para tablas, paginación, diálogos y gráficos ECharts para presentar datos de manera interactiva.
<template>
<div class="contenedor-principal espaciado-diseño">
<el-row :gutter="15" class="tarjeta-principal-dos margen-inferior-15">
<el-col :xs="24" :sm="14" :md="14" :lg="16" :xl="16">
<div class="elemento-tarjeta">
<div style="height: 100%" ref="refGraficoBarras"></div>
</div>
</el-col>
<el-col :xs="24" :sm="10" :md="10" :lg="8" :xl="8" class="medios-principal">
<div class="elemento-tarjeta">
<div style="height: 100%" ref="refGraficoCircular"></div>
</div>
</el-col>
</el-row>
<el-row :gutter="15" class="tarjeta-principal-tres">
<el-col :xs="24" :sm="14" :md="14" :lg="8" :xl="8" class="medios-principal">
<div class="elemento-tarjeta">
<div style="height: 100%" ref="refGraficoRadar"></div>
</div>
</el-col>
<el-col :xs="24" :sm="10" :md="10" :lg="16" :xl="16">
<div class="elemento-tarjeta">
<div class="titulo-elemento-tarjeta">Registros de Detección en Tiempo Real</div>
<div class="monitor-principal">
<div class="envoltura-flexible">
<el-table :data="estado_componente.datosPaginados" style="width: 100%" height="360" v-loading="estado_componente.cargando">
<el-table-column prop="username" label="Usuario" align="center" width="120" />
<el-table-column prop="label" label="Resultado" align="center" width="120">
<template #default="scope">
<el-tag
:type="obtenerTipoResultado(scope.row.label)"
effect="light"
>
{{ formatearEtiqueta(scope.row.label) }}
</el-tag>
</template>
</el-table-column>
<el-table-column prop="confidence" label="Confianza" align="center" width="120">
<template #default="scope">
{{ formatearConfianza(scope.row.confidence) }}
</template>
</el-table-column>
<el-table-column prop="weight" label="Peso del Modelo" align="center" width="120" />
<el-table-column prop="conf" label="Umbral de Detección" align="center" width="120" />
<el-table-column prop="startTime" label="Fecha/Hora de Detección" align="center" width="180" />
<el-table-column label="Acciones" align="center" width="100">
<template #default="scope">
<el-button link type="primary" size="small" @click="manejarVistaDetalle(scope.row)">
Detalles
</el-button>
</template>
</el-table-column>
</el-table>
<div class="contenedor-paginacion">
<el-pagination
v-model:current-page="estado_componente.paginaActual"
v-model:page-size="estado_componente.tamanoPagina"
:page-sizes="[10, 20, 50, 100]"
:small="true"
:layout="disposicionPaginacion"
:total="estado_componente.totalRegistros"
@size-change="manejarCambioTamanoPagina"
@current-change="manejarCambioPaginaActual"
/>
</div>
</div>
</div>
</div>
</el-col>
</el-row>
<el-row :gutter="15" class="tarjeta-principal-tres" style="margin-top: 15px;">
<el-col :lg="24" :md="24" :sm="24" :xl="24" :xs="24">
<div class="elemento-tarjeta">
<div ref="refGraficoLineas" style="height: 100%"></div>
</div>
</el-col>
</el-row>
<!-- Diálogo de Detalles -->
<el-dialog
v-model="estado_componente.dialogoDetallesVisible"
:title="`Detalles de Registro de Detección - ${estado_componente.registroSeleccionado?.username || ''}`"
width="80%"
:close-on-click-modal="false"
:close-on-press-escape="false"
center
>
<div class="contenedor-detalles" v-loading="estado_componente.cargandoDetalles">
<el-row :gutter="20">
<!-- Imagen Detectada -->
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<div class="seccion-detalles">
<h3 class="titulo-detalles">Imagen Original</h3>
<div class="contenedor-imagen">
<div class="envoltura-img" @click="previsualizarImagen(obtenerUrlImagen(estado_componente.registroSeleccionado?.inputImg), 'Imagen Original')">
<img
:src="obtenerUrlImagen(estado_componente.registroSeleccionado?.inputImg)"
alt="Imagen Original"
class="imagen-deteccion"
v-if="estado_componente.registroSeleccionado?.inputImg"
/>
<div class="superposicion-img" v-if="estado_componente.registroSeleccionado?.inputImg">
<el-icon><View /></el-icon>
</div>
<div v-else class="marcador-posicion-imagen">
<el-icon><Picture /></el-icon>
<span>No hay imagen original</span>
</div>
</div>
</div>
</div>
</el-col>
<!-- Información de Detección -->
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<div class="seccion-detalles">
<h3 class="titulo-detalles">Información de Detección</h3>
<el-descriptions :column="1" border>
<el-descriptions-item label="Usuario">
{{ estado_componente.registroSeleccionado?.username || 'Desconocido' }}
</el-descriptions-item>
<el-descriptions-item label="Resultado">
<el-tag
:type="obtenerTipoResultado(estado_componente.registroSeleccionado?.label || '')"
effect="light"
>
{{ formatearEtiqueta(estado_componente.registroSeleccionado?.label || '') }}
</el-tag>
</el-descriptions-item>
<el-descriptions-item label="Confianza">
{{ formatearConfianza(estado_componente.registroSeleccionado?.confidence || '') }}
</el-descriptions-item>
<el-descriptions-item label="Peso del Modelo">
{{ estado_componente.registroSeleccionado?.weight || 'Desconocido' }}
</el-descriptions-item>
<el-descriptions-item label="Umbral de Detección">
{{ estado_componente.registroSeleccionado?.conf || 'Desconocido' }}
</el-descriptions-item>
<el-descriptions-item label="Fecha/Hora de Detección">
{{ estado_componente.registroSeleccionado?.startTime || 'Desconocido' }}
</el-descriptions-item>
<el-descriptions-item label="Detalles de Detección" v-if="tieneDetallesDeteccion">
<div class="detalles-deteccion">
<div
v-for="(item, index) in obtenerDetallesDeteccion()"
:key="index"
class="item-detalle"
>
<span class="etiqueta-detalle">{{ item.label }}:</span>
<span class="valor-detalle">{{ item.confidence }}</span>
</div>
</div>
</el-descriptions-item>>
<!-- Comparación de Imagen Original vs. Resultado de Detección -->
<el-row :gutter="20" v-if="estado_componente.registroSeleccionado?.inputImg || estado_componente.registroSeleccionado?.outImg">
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<div class="seccion-detalles">
<h3 class="titulo-detalles">Imagen Original</h3>
<div class="contenedor-imagen">
<div class="envoltura-img" @click="previsualizarImagen(obtenerUrlImagen(estado_componente.registroSeleccionado.inputImg), 'Imagen Original')">
<img
:src="obtenerUrlImagen(estado_componente.registroSeleccionado.inputImg)"
alt="Imagen Original"
class="imagen-deteccion"
v-if="estado_componente.registroSeleccionado?.inputImg"
/>
<div class="superposicion-img" v-if="estado_componente.registroSeleccionado?.inputImg">
<el-icon><View /></el-icon>
</div>
<div v-else class="marcador-posicion-imagen">
<el-icon><Picture /></el-icon>
<span>No hay imagen original</span>
</div>
</div>
</div>
</div>
</el-col>
<el-col :xs="24" :sm="12" :md="12" :lg="12" :xl="12">
<div class="seccion-detalles">
<h3 class="titulo-detalles">Imagen de Predicción</h3>
<div class="contenedor-imagen">
<div class="envoltura-img" @click="previsualizarImagen(obtenerUrlImagen(estado_componente.registroSeleccionado.outImg), 'Imagen de Predicción')">
<img
:src="obtenerUrlImagen(estado_componente.registroSeleccionado.outImg)"
alt="Imagen de Predicción"
class="imagen-deteccion"
v-if="estado_componente.registroSeleccionado?.outImg"
/>
<div class="superposicion-img" v-if="estado_componente.registroSeleccionado?.outImg">
<el-icon><View /></el-icon>
</div>
<div v-else class="marcador-posicion-imagen">
<el-icon><Picture /></el-icon>
<span>No hay imagen predicha</span>
</div>
</div>
</div>
</div>
</el-col>
</el-row>
</div>
<template #footer>
<span class="pie-dialogo">
<el-button @click="estado_componente.dialogoDetallesVisible = false">Cerrar</el-button>
<el-button type="primary" @click="manejarDescargaImagen" :disabled="!estado_componente.registroSeleccionado?.inputImg">
<el-icon><Download /></el-icon>
Descargar Imagen Detectada
</el-button>
</span>
</template>
</el-dialog>
<!-- Diálogo de Previsualización de Imagen -->
<el-dialog
v-model="estado_componente.dialogoPrevisualizacion.visible"
:title="estado_componente.dialogoPrevisualizacion.titulo"
width="60%"
align-center
class="dialogo-previsualizacion-imagen">
<div class="contenido-previsualizacion">
<img :src="estado_componente.dialogoPrevisualizacion.urlImagen" :alt="estado_componente.dialogoPrevisualizacion.titulo" class="imagen-previsualizacion" />
</div>
</el-dialog>
</div>
</template>
<script setup lang="ts" name="home">
import { reactive, onMounted, ref, watch, nextTick, onActivated, markRaw, computed } from 'vue';
import * as echarts from 'echarts';
import { storeToRefs } from 'pinia';
import { useThemeConfig } from '/@/stores/themeConfig';
import { useTagsViewRoutes } from '/@/stores/tagsViewRoutes';
import { ElMessage } from 'element-plus';
import { Picture, Download, View } from '@element-plus/icons-vue';
import request from '/@/utils/request';
// Definición de variables reactivas y referencias
const refGraficoLineas = ref();
const refGraficoCircular = ref();
const refGraficoBarras = ref();
const refGraficoRadar = ref();
const almacenRutasTags = useTagsViewRoutes();
const almacenConfiguracionTema = useThemeConfig();
const { themeConfig } = storeToRefs(almacenConfiguracionTema);
const { isTagsViewCurrenFull } = storeToRefs(almacenRutasTags);
const estado_componente = reactive({
datos: [] as any,
datosPaginados: [] as any,
cargando: false,
paginaActual: 1,
tamanoPagina: 10,
totalRegistros: 0,
globales: {
graficoLineasInstancia: null,
graficoCircularInstancia: null,
graficoBarrasInstancia: null,
graficoRadarInstancia: null,
disposiciones: [null, '', undefined],
} as any,
misGraficos: [] as EmptyArrayType,
opcionesGraficos: {
tema: '',
colorFondo: '',
colorTexto: '#303133',
},
// Relacionado con el diálogo de detalles
dialogoDetallesVisible: false,
cargandoDetalles: false,
registroSeleccionado: null as any,
// Relacionado con el diálogo de previsualización de imagen
dialogoPrevisualizacion: {
visible: false,
titulo: '',
urlImagen: '',
},
});
const clases_platano = ['fresco_maduro', 'fresco_inmaduro', 'sobre_maduro', 'maduro', 'podrido', 'inmaduro'];
// Mapeo de madurez de plátano a español
const mapa_clases_platano = {
'fresco_maduro': 'Fresco Maduro',
'fresco_inmaduro': 'Fresco Inmaduro',
'sobre_maduro': 'Sobre Maduro',
'maduro': 'Maduro',
'podrido': 'Podrido',
'inmaduro': 'Inmaduro'
};
// Mapeo de colores para la madurez del plátano
const colores_clases_platano = {
'fresco_maduro': '#4CAF50', // Verde
'fresco_inmaduro': '#2196F3', // Azul
'sobre_maduro': '#FF9800', // Naranja
'maduro': '#FFC107', // Amarillo
'podrido': '#F44336', // Rojo
'inmaduro': '#9C27B0' // Púrpura
};
// Datos de paginación responsivos
const disposicionPaginacion = computed(() => {
return window.innerWidth < 768 ? 'prev, pager, next' : 'total, sizes, prev, pager, next, jumper';
});
// Obtener URL de imagen - consistencia con la página de registros de imágenes
const obtenerUrlImagen = (rutaImagen: string) => {
if (!rutaImagen) return '';
if (rutaImagen.startsWith('http')) return rutaImagen;
return `/api${rutaImagen.startsWith('/') ? '' : '/'}${rutaImagen}`;
};
// Verificar si hay detalles de detección
const tieneDetallesDeteccion = computed(() => {
if (!estado_componente.registroSeleccionado) return false;
try {
const etiquetas = JSON.parse(estado_componente.registroSeleccionado.label || '[]');
const confianzas = JSON.parse(estado_componente.registroSeleccionado.confidence || '[]');
return etiquetas.length > 0 && confianzas.length > 0;
} catch {
return false;
}
});
// Obtener detalles de detección
const obtenerDetallesDeteccion = () => {
if (!estado_componente.registroSeleccionado) return [];
try {
const etiquetas = JSON.parse(estado_componente.registroSeleccionado.label || '[]');
const confianzas = JSON.parse(estado_componente.registroSeleccionado.confidence || '[]');
return etiquetas.map((etiqueta: string, indice: number) => ({
label: mapa_clases_platano[etiqueta as keyof typeof mapa_clases_platano] || etiqueta,
confidence: confianzas[indice] ? `${(parseFloat(confianzas[indice]) * 100).toFixed(1)}%` : '0%'
}));
} catch {
return [];
}
};
// Previsualización de imagen
const previsualizarImagen = (urlImagen: string, titulo: string) => {
if (!urlImagen) {
ElMessage.warning('No hay imagen disponible para previsualizar');
return;
}
estado_componente.dialogoPrevisualizacion.urlImagen = urlImagen;
estado_componente.dialogoPrevisualizacion.titulo = titulo;
estado_componente.dialogoPrevisualizacion.visible = true;
};
// Manejo de cambio de tamaño de página
const manejarCambioTamanoPagina = (valor: number) => {
estado_componente.tamanoPagina = valor;
estado_componente.paginaActual = 1;
actualizarDatosPaginados();
};
const manejarCambioPaginaActual = (valor: number) => {
estado_componente.paginaActual = valor;
actualizarDatosPaginados();
};
const actualizarDatosPaginados = () => {
const inicio = (estado_componente.paginaActual - 1) * estado_componente.tamanoPagina;
const fin = inicio + estado_componente.tamanoPagina;
estado_componente.datosPaginados = estado_componente.datos.slice(inicio, fin);
};
// Formatear visualización de etiqueta
const formatearEtiqueta = (etiqueta: string) => {
try {
const etiquetas = JSON.parse(etiqueta);
if (etiquetas.length === 0) return 'No detectado';
const etiquetas_espanol = etiquetas.map((l: string) =>
mapa_clases_platano[l as keyof typeof mapa_clases_platano] || l
);
return etiquetas_espanol.join(', ');
} catch {
return mapa_clases_platano[etiqueta as keyof typeof mapa_clases_platano] || etiqueta || 'No detectado';
}
};
// Establecer tipo de etiqueta según el resultado de detección
const obtenerTipoResultado = (etiqueta: string) => {
try {
const etiquetas = JSON.parse(etiqueta);
if (etiquetas.includes('podrido')) {
return 'danger';
}
if (etiquetas.includes('sobre_maduro')) {
return 'warning';
}
if (etiquetas.includes('maduro') || etiquetas.includes('fresco_maduro')) {
return 'success';
}
if (etiquetas.length > 0) {
return 'info';
}
return 'info';
} catch {
if (etiqueta.includes('podrido')) {
return 'danger';
}
if (etiqueta.includes('sobre_maduro')) {
return 'warning';
}
if (etiqueta.includes('maduro') || etiqueta.includes('fresco_maduro')) {
return 'success';
}
return 'info';
}
};
// Formatear visualización de confianza
const formatearConfianza = (confianza: string) => {
try {
const confianzas = JSON.parse(confianza);
if (confianzas.length === 0) return '0%';
const maximaConfianza = Math.max(...confianzas.map((conf: any) => {
if (typeof conf === 'number') return conf * 100;
if (typeof conf === 'string') {
const num = parseFloat(conf.replace('%', ''));
return isNaN(num) ? 0 : num;
}
return 0;
}));
return `${maximaConfianza.toFixed(1)}%`;
} catch {
if (typeof confianza === 'number') {
return `${(confianza * 100).toFixed(1)}%`;
}
return confianza || '0%';
}
};
// Ver detalles
const manejarVistaDetalle = async (fila: any) => {
estado_componente.registroSeleccionado = fila;
estado_componente.dialogoDetallesVisible = true;
estado_componente.cargandoDetalles = true;
try {
const res = await request.get(`/api/imgRecords/${fila.id}`);
if (res.code == 0) {
const registro = res.data;
estado_componente.registroSeleccionado = {
...registro,
inputImg: registro.inputImg || registro.imagePath,
outImg: registro.outImg || registro.resultImagePath
};
}
} catch (error) {
console.error('Fallo al obtener detalles:', error);
estado_componente.registroSeleccionado = fila;
} finally {
estado_componente.cargandoDetalles = false;
}
};
// Descargar imagen
const manejarDescargaImagen = async () => {
if (!estado_componente.registroSeleccionado?.inputImg) {
ElMessage.warning('No hay imagen disponible para descargar');
return;
}
try {
const urlImagen = obtenerUrlImagen(estado_componente.registroSeleccionado.inputImg);
const respuesta = await fetch(urlImagen);
const blob = await respuesta.blob();
const url = window.URL.createObjectURL(blob);
const enlace = document.createElement('a');
enlace.href = url;
const nombreArchivo = estado_componente.registroSeleccionado.inputImg.split('/').pop() ||
`deteccion_${estado_componente.registroSeleccionado.username}_${estado_componente.registroSeleccionado.startTime?.replace(/[: ]/g, '-') || 'desconocido'}.jpg`;
enlace.download = nombreArchivo;
document.body.appendChild(enlace);
enlace.click();
window.URL.revokeObjectURL(url);
document.body.removeChild(enlace);
ElMessage.success('Imagen descargada exitosamente');
} catch (error) {
console.error('Fallo al descargar imagen:', error);
ElMessage.error('Fallo al descargar la imagen');
}
};
// Gráfico de líneas - Tendencia de detecciones en los últimos diez días
const inicializarGraficoLineas = () => {
if (!estado_componente.globales.disposiciones.some((b: any) => b === estado_componente.globales.graficoLineasInstancia)) estado_componente.globales.graficoLineasInstancia?.dispose();
estado_componente.globales.graficoLineasInstancia = markRaw(echarts.init(refGraficoLineas.value, estado_componente.opcionesGraficos.tema));
const conteo_diario: Record<string number=""> = {};
estado_componente.datos.forEach((prediccion: any) => {
if (prediccion.startTime) {
const fecha = prediccion.startTime.split(' ')[0];
conteo_diario[fecha] = (conteo_diario[fecha] || 0) + 1;
}
});
const fechasOrdenadasDesc = Object.keys(conteo_diario).sort((a, b) => b.localeCompare(a));
const ultimasFechasDesc = fechasOrdenadasDesc.slice(0, 10);
const ultimasFechas = ultimasFechasDesc.sort((a, b) => a.localeCompare(b));
const resultado = {
datosFechas: ultimasFechas,
datosValores: ultimasFechas.map(fecha => conteo_diario[fecha])
};
const opciones = {
backgroundColor: estado_componente.opcionesGraficos.colorFondo,
title: {
text: 'Tendencia de Detecciones en los Últimos Diez Días',
x: 'left',
textStyle: { fontSize: 15, color: estado_componente.opcionesGraficos.colorTexto },
},
grid: { top: 70, right: 20, bottom: 30, left: 30 },
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const datos = params[0];
return `${datos.name}<br/>Detecciones: ${datos.value}`;
}
},
xAxis: {
data: resultado.datosFechas,
axisLabel: {
color: estado_componente.opcionesGraficos.colorTexto,
rotate: 45
},
},
yAxis: [
{
type: 'value',
name: 'Cantidad de Detecciones',
splitLine: { show: true, lineStyle: { type: 'dashed', color: estado_componente.opcionesGraficos.tema === 'dark' ? '#444' : '#f5f5f5' } },
axisLabel: {
color: estado_componente.opcionesGraficos.colorTexto,
},
},
],
series: [
{
name: 'Cantidad de Detecciones',
type: 'line',
symbolSize: 6,
symbol: 'circle',
smooth: true,
data: resultado.datosValores,
lineStyle: { color: '#FF6B6B' },
itemStyle: { color: '#FF6B6B', borderColor: '#FF6B6B' },
areaStyle: {
color: new echarts.graphic.LinearGradient(0, 0, 0, 1, [
{ offset: 0, color: '#FF6B6Bb3' },
{ offset: 1, color: '#FF6B6B03' },
]),
},
},
],
};
estado_componente.globales.graficoLineasInstancia.setOption(opciones);
estado_componente.misGraficos.push(estado_componente.globales.graficoLineasInstancia);
};
// Gráfico circular - Distribución de detecciones por usuario
const inicializarGraficoCircular = () => {
if (!estado_componente.globales.disposiciones.some((b: any) => b === estado_componente.globales.graficoCircularInstancia)) estado_componente.globales.graficoCircularInstancia?.dispose();
estado_componente.globales.graficoCircularInstancia = markRaw(echarts.init(refGraficoCircular.value, estado_componente.opcionesGraficos.tema));
const conteo_usuarios: Record<string number=""> = {};
estado_componente.datos.forEach((prediccion: any) => {
const nombre_usuario = prediccion.username || 'Usuario Desconocido';
conteo_usuarios[nombre_usuario] = (conteo_usuarios[nombre_usuario] || 0) + 1;
});
const usuariosOrdenados = Object.keys(conteo_usuarios).sort((a, b) => conteo_usuarios[b] - conteo_usuarios[a]);
const principalesUsuarios = usuariosOrdenados.slice(0, 6);
const principalesValores = principalesUsuarios.map(u => conteo_usuarios[u]);
const datosCircular = principalesUsuarios.map((nombre_usuario, i) => ({
name: nombre_usuario,
value: principalesValores[i]
}));
const opciones = {
backgroundColor: estado_componente.opcionesGraficos.colorFondo,
title: {
text: 'Distribución de Detecciones por Usuario',
x: 'left',
textStyle: { fontSize: '15', color: estado_componente.opcionesGraficos.colorTexto },
},
legend: {
top: 'bottom',
textStyle: {
color: estado_componente.opcionesGraficos.colorTexto
}
},
tooltip: {
trigger: 'item',
formatter: '{a} <br/>{b}: {c} ({d}%)'
},
series: [
{
type: 'pie',
radius: ['40%', '70%'],
center: ['50%', '50%'],
avoidLabelOverlap: true,
itemStyle: {
borderRadius: 10,
borderColor: estado_componente.opcionesGraficos.colorFondo,
borderWidth: 2
},
label: {
show: true,
formatter: '{b}: {c} veces',
color: estado_componente.opcionesGraficos.colorTexto
},
emphasis: {
label: {
show: true,
fontSize: '14',
fontWeight: 'bold'
}
},
data: datosCircular
}
]
};
estado_componente.globales.graficoCircularInstancia.setOption(opciones);
estado_componente.misGraficos.push(estado_componente.globales.graficoCircularInstancia);
};
// Gráfico de radar - Análisis de confianza por usuario
const inicializarGraficoRadar = () => {
if (!estado_componente.globales.disposiciones.some((b: any) => b === estado_componente.globales.graficoRadarInstancia)) estado_componente.globales.graficoRadarInstancia?.dispose();
estado_componente.globales.graficoRadarInstancia = markRaw(echarts.init(refGraficoRadar.value, estado_componente.opcionesGraficos.tema));
const estadisticas_conf_por_usuario: Record<string count:="" number="" total:=""> = {};
estado_componente.datos.forEach((prediccion: any) => {
const nombre_usuario = prediccion.username || 'Usuario Desconocido';
let valorConfianza = 0;
try {
const confianzas = JSON.parse(prediccion.confidence || '[]');
if (confianzas.length > 0) {
valorConfianza = Math.max(...confianzas.map((conf: any) => {
if (typeof conf === 'number') return conf;
if (typeof conf === 'string') {
const num = parseFloat(conf.replace('%', '')) / 100;
return isNaN(num) ? 0 : num;
}
return 0;
}));
}
} catch {
if (typeof prediccion.confidence === 'number') {
valorConfianza = prediccion.confidence;
}
}
if (!estadisticas_conf_por_usuario[nombre_usuario]) {
estadisticas_conf_por_usuario[nombre_usuario] = { total: valorConfianza, count: 1 };
} else {
estadisticas_conf_por_usuario[nombre_usuario].total += valorConfianza;
estadisticas_conf_por_usuario[nombre_usuario].count += 1;
}
});
const datosConfianzaPromedio = Object.keys(estadisticas_conf_por_usuario).map(nombre_usuario => ({
nombre_usuario,
confianzaPromedio: estadisticas_conf_por_usuario[nombre_usuario].total / estadisticas_conf_por_usuario[nombre_usuario].count,
}));
const principalesDatosConfianza = datosConfianzaPromedio.slice(0, 7);
const datosGrafico = principalesDatosConfianza.map(item => Number((item.confianzaPromedio * 100).toFixed(2)));
const nombresIndicadores = principalesDatosConfianza.map(item => item.nombre_usuario);
const indicadores = nombresIndicadores.map((name) => ({
name,
max: 100
}));
const opciones = {
backgroundColor: estado_componente.opcionesGraficos.colorFondo,
title: {
text: 'Análisis de Confianza de Detección por Usuario',
x: 'left',
textStyle: { fontSize: '15', color: estado_componente.opcionesGraficos.colorTexto },
},
tooltip: {
formatter: (params: any) => {
return `${params.name}: ${params.value}%`;
}
},
radar: {
radius: '65%',
splitNumber: 4,
indicator: indicadores,
axisName: {
color: estado_componente.opcionesGraficos.colorTexto,
fontSize: 12
},
splitArea: {
areaStyle: {
color: ['rgba(255,107,107,0.1)', 'rgba(255,107,107,0.05)'],
}
},
splitLine: {
lineStyle: {
color: 'rgba(255,107,107,0.3)'
}
},
axisLine: {
lineStyle: {
color: 'rgba(255,107,107,0.5)'
}
}
},
series: [{
type: 'radar',
data: [{
value: datosGrafico,
name: 'Confianza',
areaStyle: {
color: 'rgba(255,107,107,0.3)'
},
lineStyle: {
color: '#FF6B6B'
},
itemStyle: {
color: '#FF6B6B'
},
label: {
show: true,
formatter: (params: any) => {
return params.value + '%';
}
}
}]
}]
};
estado_componente.globales.graficoRadarInstancia.setOption(opciones);
estado_componente.misGraficos.push(estado_componente.globales.graficoRadarInstancia);
};
// Gráfico de barras - Distribución de detección de madurez de plátanos
const inicializarGraficoBarras = () => {
if (!estado_componente.globales.disposiciones.some((b: any) => b === estado_componente.globales.graficoBarrasInstancia)) estado_componente.globales.graficoBarrasInstancia?.dispose();
estado_componente.globales.graficoBarrasInstancia = markRaw(echarts.init(refGraficoBarras.value, estado_componente.opcionesGraficos.tema));
const categorias = clases_platano.map(cls => mapa_clases_platano[cls as keyof typeof mapa_clases_platano]);
const conteos: Record<string number=""> = {};
clases_platano.forEach(cls => {
conteos[cls] = 0;
});
estado_componente.datos.forEach((item: any) => {
let clasesDetectadas: string[] = [];
try {
clasesDetectadas = JSON.parse(item.label || '[]');
} catch {
const etiqueta = item.label || '';
if (etiqueta) {
clasesDetectadas = etiqueta.split(',').map((l: string) => l.trim());
}
}
if (clasesDetectadas.length > 0) {
clasesDetectadas.forEach((cls: string) => {
const claseNormalizada = cls.toLowerCase().trim();
if (clases_platano.includes(claseNormalizada)) {
conteos[claseNormalizada]++;
} else {
for (const clasePlatano of clases_platano) {
if (claseNormalizada.includes(clasePlatano) || clasePlatano.includes(claseNormalizada)) {
conteos[clasePlatano]++;
break;
}
}
}
});
} else {
conteos['inmaduro'] = (conteos['inmaduro'] || 0) + 1;
}
});
const datosConteo = clases_platano.map(cls => conteos[cls]);
const colores = clases_platano.map(cls => colores_clases_platano[cls as keyof typeof colores_clases_platano]);
const opciones = {
backgroundColor: estado_componente.opcionesGraficos.colorFondo,
title: {
text: 'Distribución de Detección de Madurez de Plátanos',
x: 'left',
textStyle: { fontSize: '15', color: estado_componente.opcionesGraficos.colorTexto },
},
tooltip: {
trigger: 'axis',
formatter: (params: any) => {
const datos = params[0];
return `${datos.name}<br/>Cantidad: ${datos.value}`;
}
},
grid: { top: 70, right: 30, bottom: 30, left: 50 },
xAxis: [
{
type: 'category',
data: categorias,
axisTick: { show: false },
axisLabel: {
color: estado_componente.opcionesGraficos.colorTexto,
rotate: 45,
},
},
],
yAxis: [
{
type: 'value',
name: 'Cantidad de Detecciones',
splitLine: {
show: true,
lineStyle: {
type: 'dashed',
color: estado_componente.opcionesGraficos.tema === 'dark' ? '#444' : '#f5f5f5'
}
},
axisLabel: {
color: estado_componente.opcionesGraficos.colorTexto,
},
},
],
series: [
{
name: 'Cantidad de Detecciones',
type: 'bar',
barWidth: 40,
itemStyle: {
color: (params: any) => colores[params.dataIndex],
borderRadius: [4, 4, 0, 0],
},
label: {
show: true,
position: 'top',
color: estado_componente.opcionesGraficos.colorTexto
},
data: datosConteo,
},
],
};
estado_componente.globales.graficoBarrasInstancia.setOption(opciones);
estado_componente.misGraficos.push(estado_componente.globales.graficoBarrasInstancia);
};
// Ajustar el tamaño de todos los gráficos ECharts
const ajustarTamanoEcharts = () => {
nextTick(() => {
for (let i = 0; i < estado_componente.misGraficos.length; i++) {
setTimeout(() => {
estado_componente.misGraficos[i]?.resize();
}, i * 1000);
}
});
};
const iniciarEscuchaRedimension = () => {
window.addEventListener('resize', ajustarTamanoEcharts);
};
// Cargar datos
const cargarDatos = async () => {
estado_componente.cargando = true;
try {
const res = await request.get('/api/imgRecords/all');
if (res.code == 0) {
estado_componente.datos = res.data.map((registro: any, index: number) => {
const registroTransformado = {
id: registro.id,
num: index + 1,
inputImg: registro.inputImg || registro.imagePath,
outImg: registro.outImg || registro.resultImagePath,
weight: registro.weight,
conf: registro.conf,
ai: registro.ai,
suggestion: registro.suggestion,
startTime: registro.startTime,
username: registro.username,
label: registro.label,
confidence: registro.confidence,
family: registro.family || []
};
if (!registroTransformado.family || registroTransformado.family.length === 0) {
try {
const etiquetas = JSON.parse(registro.label || '[]');
const confianzas = JSON.parse(registro.confidence || '[]');
registroTransformado.family = etiquetas.map((label: string, idx: number) => ({
label: label,
confidence: confianzas[idx] || 0,
startTime: registro.startTime
}));
} catch (error) {
console.error('Fallo al construir el campo family:', error);
registroTransformado.family = [];
}
}
return registroTransformado;
}).reverse();
estado_componente.totalRegistros = estado_componente.datos.length;
actualizarDatosPaginados();
setTimeout(() => {
inicializarGraficoLineas();
inicializarGraficoRadar();
inicializarGraficoCircular();
inicializarGraficoBarras();
}, 100);
} else {
ElMessage.error(res.msg || 'Fallo al cargar los datos');
}
} catch (error) {
console.error('Fallo al cargar los datos:', error);
ElMessage.error('Fallo al cargar los datos, por favor verifique su conexión a la red');
} finally {
estado_componente.cargando = false;
}
};
// Al cargar la página
onMounted(() => {
cargarDatos();
iniciarEscuchaRedimension();
});
// Debido a la caché de página (keep-alive)
onActivated(() => {
ajustarTamanoEcharts();
});
// Observar cambios de estado relevantes
watch(
() => isTagsViewCurrenFull.value,
() => {
ajustarTamanoEcharts();
}
);
watch(
() => themeConfig.value.isIsDark,
(isIsDark) => {
nextTick(() => {
estado_componente.opcionesGraficos.tema = isIsDark ? 'dark' : '';
estado_componente.opcionesGraficos.colorFondo = isIsDark ? 'transparent' : '';
estado_componente.opcionesGraficos.colorTexto = isIsDark ? '#dadada' : '#303133';
setTimeout(() => {
inicializarGraficoLineas();
inicializarGraficoRadar();
inicializarGraficoCircular();
inicializarGraficoBarras();
}, 500);
});
},
{
deep: true,
immediate: true,
}
);
</script>
<style scoped lang="scss">
$longitudNavPrincipal: 8;
.contenedor-principal {
overflow: hidden;
.tarjeta-principal-uno,
.tarjeta-principal-dos,
.tarjeta-principal-tres {
.elemento-tarjeta {
width: 100%;
height: 130px;
border-radius: 4px;
transition: all ease 0.3s;
padding: 20px;
overflow: hidden;
background: var(--el-color-white);
color: var(--el-text-color-primary);
border: 1px solid var(--next-border-color-light);
&:hover {
box-shadow: 0 2px 12px var(--next-color-dark-hover);
transition: all ease 0.3s;
}
&-icono {
width: 70px;
height: 70px;
border-radius: 100%;
flex-shrink: 1;
i {
color: var(--el-text-color-placeholder);
}
}
.titulo-elemento-tarjeta {
font-size: 15px;
font-weight: bold;
height: 30px;
margin-bottom: 15px;
color: var(--el-text-color-primary);
border-bottom: 1px solid var(--next-border-color-light);
padding-bottom: 10px;
}
}
}
.tarjeta-principal-dos,
.tarjeta-principal-tres {
.elemento-tarjeta {
height: 400px;
width: 100%;
overflow: hidden;
.monitor-principal {
height: 100%;
.envoltura-flexible-item {
width: 25%;
height: 111px;
display: flex;
.caja-envoltura-flexible-item {
margin: auto;
text-align: center;
color: var(--el-text-color-primary);
display: flex;
border-radius: 5px;
background: var(--next-bg-color);
cursor: pointer;
transition: all 0.3s ease;
&:hover {
background: var(--el-color-primary-light-9);
transition: all 0.3s ease;
}
}
@for $i from 0 through $longitudNavPrincipal {
.animacion-principal#{$i} {
opacity: 0;
animation-name: error-num;
animation-duration: 0.5s;
animation-fill-mode: forwards;
animation-delay: calc($i/10) + s;
}
}
}
}
}
}
}
.contenedor-paginacion {
display: flex;
justify-content: flex-end;
margin-top: 15px;
padding: 10px 0;
}
/* Estilos para el diálogo de detalles */
.contenedor-detalles {
padding: 10px 0;
}
.seccion-detalles {
margin-bottom: 20px;
}
.titulo-detalles {
font-size: 16px;
font-weight: bold;
margin-bottom: 15px;
color: var(--el-text-color-primary);
border-left: 4px solid var(--el-color-primary);
padding-left: 10px;
}
.contenedor-imagen {
width: 100%;
height: 300px;
display: flex;
justify-content: center;
align-items: center;
border: 1px solid var(--next-border-color-light);
border-radius: 8px;
overflow: hidden;
background-color: var(--el-fill-color-light);
margin-bottom: 10px;
}
.envoltura-img {
position: relative;
display: flex;
justify-content: center;
align-items: center;
cursor: pointer;
border-radius: 6px;
overflow: hidden;
height: 100%;
width: 100%;
&:hover {
.superposicion-img {
opacity: 1;
}
.imagen-deteccion {
transform: scale(1.05);
}
}
.imagen-deteccion {
width: 100%;
height: 100%;
object-fit: cover;
border-radius: 4px;
border: 1px solid var(--next-border-color-light);
transition: transform 0.3s ease;
}
.superposicion-img {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
display: flex;
justify-content: center;
align-items: center;
opacity: 0;
transition: opacity 0.3s ease;
.el-icon {
color: white;
font-size: 24px;
}
}
}
.imagen-error,
.marcador-posicion-imagen {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
color: var(--el-text-color-secondary);
.el-icon {
font-size: 48px;
margin-bottom: 10px;
}
}
.acciones-imagen {
display: flex;
gap: 10px;
justify-content: center;
}
.detalles-deteccion {
display: flex;
flex-direction: column;
gap: 8px;
}
.item-detalle {
display: flex;
justify-content: space-between;
align-items: center;
padding: 4px 0;
}
.etiqueta-detalle {
font-weight: 500;
color: var(--el-text-color-primary);
}
.valor-detalle {
color: var(--el-text-color-regular);
}
.pie-dialogo {
display: flex;
justify-content: flex-end;
gap: 10px;
}
// Estilos para el diálogo de previsualización de imagen
.dialogo-previsualizacion-imagen {
.contenido-previsualizacion {
display: flex;
justify-content: center;
align-items: center;
.imagen-previsualizacion {
max-width: 100%;
max-height: 70vh;
object-fit: contain;
border-radius: 8px;
}
}
}
/* Ajustes responsivos */
@media (max-width: 768px) {
.medios-principal {
margin-top: 15px;
}
.contenedor-paginacion {
justify-content: center;
}
.contenedor-imagen {
height: 250px;
}
.seccion-detalles {
margin-bottom: 15px;
}
.acciones-imagen {
flex-direction: column;
}
}
@keyframes error-num {
0% {
opacity: 0;
transform: translateY(20px);
}
100% {
opacity: 1;
transform: translateY(0);
}
}
</style>
</string></string></string></string>