dfernandezl12 commited on
Commit
57c87d3
·
verified ·
1 Parent(s): fed05b7

Upload 8 files

Browse files
Dockerfile ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ ENV PYTHONDONTWRITEBYTECODE=1
4
+ ENV PYTHONUNBUFFERED=1
5
+
6
+ RUN useradd -m -u 1000 user
7
+ USER user
8
+ ENV HOME=/home/user \
9
+ PATH=/home/user/.local/bin:$PATH
10
+
11
+ WORKDIR $HOME/app
12
+
13
+ USER root
14
+ RUN apt-get update && apt-get install -y --no-install-recommends \
15
+ build-essential \
16
+ && rm -rf /var/lib/apt/lists/*
17
+ USER user
18
+
19
+ COPY --chown=user requirements.txt .
20
+ RUN pip install --no-cache-dir --upgrade -r requirements.txt
21
+
22
+ COPY --chown=user . .
23
+
24
+ EXPOSE 7860
25
+
26
+ CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "7860"]
main.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from fastapi import FastAPI, File, UploadFile
2
+ from fastapi.responses import JSONResponse
3
+ from fastapi.staticfiles import StaticFiles
4
+ from fastapi.responses import FileResponse
5
+ import numpy as np
6
+ from tensorflow.keras.models import load_model
7
+ from tensorflow.keras.preprocessing.image import load_img, img_to_array
8
+ from PIL import Image
9
+ import io
10
+ import tensorflow as tf
11
+ from tensorflow.keras.layers import Dense
12
+
13
+ app = FastAPI(title="Clasificador de Vehículos")
14
+
15
+ class CompatDense(Dense):
16
+ def __init__(self, *Args, quantization_config=None, **kwargs):
17
+ # Eliminamos el problema de discusión y al padre
18
+ kwargs.pop('quantization_config', None)
19
+ super().__init__(*Args, **kwargs)
20
+
21
+ # Cargar el modelo entrenado (arquitectura + pesos incluidos en modelo.h5)
22
+ model = load_model(
23
+ 'modelo/modelo.h5',
24
+ custom_objects={'Dense': CompatDense}
25
+ )
26
+ # Etiquetas de las clases, en el orden que usó ImageDataGenerator (alfabético)
27
+ CLASS_NAMES = ['airplane', 'car', 'ship'] # 0: aéreo, 1: terrestre, 2: marítimo
28
+
29
+ def preprocess_image(image: Image.Image):
30
+ """
31
+ Preprocesa la imagen para que coincida con el entrenamiento.
32
+ """
33
+ image = image.resize((150, 150)) # tamaño usado en el notebook
34
+ img_array = img_to_array(image) # convierte a array (150,150,3)
35
+ img_array = img_array / 255.0 # normalización (rescale=1./255)
36
+ img_array = np.expand_dims(img_array, axis=0) # añade dimensión batch
37
+ return img_array
38
+
39
+ @app.post("/predict")
40
+ async def predict(file: UploadFile = File(...)):
41
+ """
42
+ Recibe una imagen y devuelve la clase predicha con su confianza.
43
+ """
44
+ # Leer el contenido del archivo
45
+ contents = await file.read()
46
+ try:
47
+ # Convertir a imagen RGB
48
+ image = Image.open(io.BytesIO(contents)).convert('RGB')
49
+ except Exception:
50
+ return JSONResponse(
51
+ content={"error": "No se pudo leer la imagen. Asegúrate de enviar un archivo válido."},
52
+ status_code=400
53
+ )
54
+
55
+ # Preprocesar y predecir
56
+ processed = preprocess_image(image)
57
+ predictions = model.predict(processed)[0] # array de 3 probabilidades
58
+
59
+ predicted_idx = np.argmax(predictions)
60
+ label = CLASS_NAMES[predicted_idx]
61
+ confidence = float(predictions[predicted_idx])
62
+
63
+ # Mapeo legible para el usuario
64
+ label_es = {"airplane": "Aéreo (Avión)", "car": "Terrestre (Coche)", "ship": "Marítimo (Barco)"}
65
+
66
+ return {
67
+ "prediccion": label_es[label],
68
+ "confianza": round(confidence, 4),
69
+ "probabilidades": {
70
+ "airplane": round(float(predictions[0]), 4),
71
+ "car": round(float(predictions[1]), 4),
72
+ "ship": round(float(predictions[2]), 4)
73
+ }
74
+ }
75
+
76
+ app.mount("/static", StaticFiles(directory="static"), name="static")
77
+
78
+ # Endpoint de bienvenida (opcional)
79
+ @app.get("/")
80
+ def read_index():
81
+ return FileResponse("static/index.html")
82
+
83
+ if __name__ == "__main__":
84
+ import uvicorn
85
+ uvicorn.run(app, host="0.0.0.0", port=8000)
modelo/modelo.h5 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:9a9bc227ef84ab70a013e28fab4142324a96180183565c6cd903c9227369f965
3
+ size 269318872
modelo/modelo.weights.h5 ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:a286ad17c86fb09552ad270d0bb1cc1403da59d7cc36494676915634c885bc4b
3
+ size 269313008
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ fastapi>=0.100.0
2
+ uvicorn[standard]>=0.22.0
3
+ python-multipart>=0.0.6
4
+ tensorflow>=2.21.0
5
+ Pillow>=10.0.0
6
+ numpy>=1.24.0
static/app.js ADDED
@@ -0,0 +1,167 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ const API_URL = window.location.origin + '/predict'; // Ajusta si es necesario
2
+
3
+ const uploadArea = document.getElementById('uploadArea');
4
+ const fileInput = document.getElementById('fileInput');
5
+ const previewSection = document.getElementById('previewSection');
6
+ const previewImage = document.getElementById('previewImage');
7
+ const predictBtn = document.getElementById('predictBtn');
8
+ const spinner = document.getElementById('spinner');
9
+ const btnText = document.getElementById('btnText');
10
+ const resultSection = document.getElementById('resultSection');
11
+ const resultIcon = document.getElementById('resultIcon');
12
+ const resultPrediction = document.getElementById('resultPrediction');
13
+ const confidenceBar = document.getElementById('confidenceBar');
14
+ const confidenceValue = document.getElementById('confidenceValue');
15
+ const probabilitiesList = document.getElementById('probabilitiesList');
16
+ const uploadAnotherBtn = document.getElementById('uploadAnotherBtn');
17
+
18
+ let selectedFile = null;
19
+
20
+ // Prevenir comportamientos por defecto para drag and drop
21
+ ['dragenter', 'dragover', 'dragleave', 'drop'].forEach(eventName => {
22
+ uploadArea.addEventListener(eventName, preventDefaults, false);
23
+ });
24
+
25
+ function preventDefaults(e) {
26
+ e.preventDefault();
27
+ e.stopPropagation();
28
+ }
29
+
30
+ ['dragenter', 'dragover'].forEach(eventName => {
31
+ uploadArea.addEventListener(eventName, () => uploadArea.classList.add('dragover'));
32
+ });
33
+
34
+ ['dragleave', 'drop'].forEach(eventName => {
35
+ uploadArea.addEventListener(eventName, () => uploadArea.classList.remove('dragover'));
36
+ });
37
+
38
+ uploadArea.addEventListener('drop', handleDrop);
39
+ uploadArea.addEventListener('click', () => fileInput.click());
40
+ fileInput.addEventListener('change', handleFileSelect);
41
+
42
+ function handleDrop(e) {
43
+ const dt = e.dataTransfer;
44
+ const files = dt.files;
45
+ if (files.length) {
46
+ handleFiles(files);
47
+ }
48
+ }
49
+
50
+ function handleFileSelect(e) {
51
+ handleFiles(e.target.files);
52
+ }
53
+
54
+ function handleFiles(files) {
55
+ if (!files || files.length === 0) return;
56
+ const file = files[0];
57
+ if (!file.type.startsWith('image/')) {
58
+ alert('Por favor selecciona una imagen válida (JPG, PNG, etc.)');
59
+ return;
60
+ }
61
+ selectedFile = file;
62
+
63
+ // Mostrar vista previa
64
+ const reader = new FileReader();
65
+ reader.onload = (e) => {
66
+ previewImage.src = e.target.result;
67
+ previewSection.classList.add('active');
68
+ predictBtn.disabled = false;
69
+ // Ocultar resultado anterior
70
+ resultSection.classList.remove('active');
71
+ uploadAnotherBtn.style.display = 'none';
72
+ };
73
+ reader.readAsDataURL(file);
74
+ }
75
+
76
+ predictBtn.addEventListener('click', classifyImage);
77
+
78
+ async function classifyImage() {
79
+ if (!selectedFile) return;
80
+
81
+ // Estado de carga
82
+ predictBtn.disabled = true;
83
+ spinner.style.display = 'inline-block';
84
+ btnText.textContent = 'Analizando...';
85
+ resultSection.classList.remove('active');
86
+
87
+ const formData = new FormData();
88
+ formData.append('file', selectedFile);
89
+
90
+ try {
91
+ const response = await fetch(API_URL, {
92
+ method: 'POST',
93
+ body: formData
94
+ });
95
+
96
+ if (!response.ok) {
97
+ const errorData = await response.json();
98
+ throw new Error(errorData.error || 'Error en la API');
99
+ }
100
+
101
+ const data = await response.json();
102
+ displayResult(data);
103
+ } catch (error) {
104
+ alert('Error: ' + error.message);
105
+ } finally {
106
+ spinner.style.display = 'none';
107
+ btnText.textContent = '🔍 Analizar imagen';
108
+ predictBtn.disabled = false;
109
+ }
110
+ }
111
+
112
+ function displayResult(data) {
113
+ const classMapping = {
114
+ airplane: { icon: '✈️', label: 'Avión (Aéreo)', color: 'linear-gradient(90deg, #4299e1, #3182ce)' },
115
+ car: { icon: '🚗', label: 'Coche (Terrestre)', color: 'linear-gradient(90deg, #48bb78, #38a169)' },
116
+ ship: { icon: '🚢', label: 'Barco (Marítimo)', color: 'linear-gradient(90deg, #ed8936, #dd6b20)' }
117
+ };
118
+
119
+ // Clase con mayor probabilidad
120
+ const predictedClass = Object.keys(data.probabilidades).reduce((a, b) =>
121
+ data.probabilidades[a] > data.probabilidades[b] ? a : b
122
+ );
123
+
124
+ const confianza = data.probabilidades[predictedClass];
125
+ const classInfo = classMapping[predictedClass];
126
+
127
+ resultIcon.textContent = classInfo.icon;
128
+ resultPrediction.textContent = classInfo.label;
129
+ confidenceBar.style.background = classInfo.color;
130
+
131
+ const percent = Math.round(confianza * 100);
132
+ confidenceBar.style.width = percent + '%';
133
+ confidenceValue.textContent = percent + '%';
134
+
135
+ // Construir lista de probabilidades
136
+ probabilitiesList.innerHTML = '';
137
+ const labels = {
138
+ airplane: '✈️ Avión',
139
+ car: '🚗 Coche',
140
+ ship: '🚢 Barco'
141
+ };
142
+
143
+ for (const [key, prob] of Object.entries(data.probabilidades)) {
144
+ const probPercent = Math.round(prob * 100);
145
+ const item = document.createElement('div');
146
+ item.className = 'prob-item';
147
+ item.innerHTML = `
148
+ <div class="prob-label">
149
+ <span>${labels[key]}</span>
150
+ </div>
151
+ <span class="prob-value">${probPercent}%</span>
152
+ `;
153
+ probabilitiesList.appendChild(item);
154
+ }
155
+
156
+ resultSection.classList.add('active');
157
+ uploadAnotherBtn.style.display = 'inline-block';
158
+ }
159
+
160
+ uploadAnotherBtn.addEventListener('click', () => {
161
+ fileInput.value = '';
162
+ selectedFile = null;
163
+ previewSection.classList.remove('active');
164
+ resultSection.classList.remove('active');
165
+ predictBtn.disabled = true;
166
+ uploadAnotherBtn.style.display = 'none';
167
+ });
static/index.html ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="es">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Clasificador de Vehículos 🚗✈️🚢</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ </head>
9
+ <body>
10
+ <div class="container">
11
+ <h1>🚗 ✈️ 🚢</h1>
12
+ <h1>Clasificador de Vehículos</h1>
13
+ <p class="subtitle">Sube una imagen y descubre si es un coche, un avión o un barco</p>
14
+
15
+ <!-- Zona de subida -->
16
+ <div class="upload-area" id="uploadArea">
17
+ <div class="upload-icon">📷</div>
18
+ <p>Arrastra y suelta una imagen aquí o <span>haz clic para seleccionar</span></p>
19
+ <input type="file" id="fileInput" accept="image/*">
20
+ </div>
21
+
22
+ <!-- Vista previa -->
23
+ <div class="preview-section" id="previewSection">
24
+ <img id="previewImage" class="preview-image" src="" alt="Vista previa">
25
+ </div>
26
+
27
+ <!-- Botón predecir -->
28
+ <button class="btn-predict" id="predictBtn" disabled>
29
+ <span class="spinner" id="spinner"></span>
30
+ <span id="btnText">🔍 Analizar imagen</span>
31
+ </button>
32
+
33
+ <!-- Resultado -->
34
+ <div class="result-section" id="resultSection">
35
+ <div class="result-header">
36
+ <span class="result-icon" id="resultIcon"></span>
37
+ <span class="result-prediction" id="resultPrediction"></span>
38
+ </div>
39
+ <div class="confidence-bar-container">
40
+ <div class="confidence-bar" id="confidenceBar"></div>
41
+ </div>
42
+ <p class="confidence-text">
43
+ Confianza: <strong id="confidenceValue">0%</strong>
44
+ </p>
45
+ <div class="probabilities" id="probabilitiesList"></div>
46
+ <button class="upload-another" id="uploadAnotherBtn">🔄 Subir otra imagen</button>
47
+ </div>
48
+ </div>
49
+
50
+ <script src="/static/app.js"></script>
51
+ </body>
52
+ </html>
static/style.css ADDED
@@ -0,0 +1,251 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ /* Reset y base */
2
+ * {
3
+ box-sizing: border-box;
4
+ margin: 0;
5
+ padding: 0;
6
+ }
7
+
8
+ body {
9
+ font-family: 'Segoe UI', system-ui, sans-serif;
10
+ min-height: 100vh;
11
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
12
+ display: flex;
13
+ justify-content: center;
14
+ align-items: center;
15
+ padding: 20px;
16
+ }
17
+
18
+ .container {
19
+ background: white;
20
+ border-radius: 24px;
21
+ box-shadow: 0 20px 60px rgba(0,0,0,0.2);
22
+ padding: 40px;
23
+ max-width: 600px;
24
+ width: 100%;
25
+ text-align: center;
26
+ transition: all 0.3s ease;
27
+ }
28
+
29
+ h1 {
30
+ font-size: 2rem;
31
+ margin-bottom: 10px;
32
+ color: #2d3748;
33
+ }
34
+
35
+ .subtitle {
36
+ color: #718096;
37
+ margin-bottom: 30px;
38
+ }
39
+
40
+ /* Zona de carga */
41
+ .upload-area {
42
+ border: 3px dashed #cbd5e0;
43
+ border-radius: 16px;
44
+ padding: 40px 20px;
45
+ margin-bottom: 20px;
46
+ cursor: pointer;
47
+ transition: all 0.3s;
48
+ background: #f7fafc;
49
+ }
50
+
51
+ .upload-area:hover,
52
+ .upload-area.dragover {
53
+ border-color: #667eea;
54
+ background: #edf2ff;
55
+ }
56
+
57
+ .upload-icon {
58
+ font-size: 48px;
59
+ margin-bottom: 15px;
60
+ }
61
+
62
+ .upload-area p {
63
+ color: #4a5568;
64
+ font-weight: 500;
65
+ }
66
+
67
+ .upload-area span {
68
+ color: #667eea;
69
+ text-decoration: underline;
70
+ }
71
+
72
+ input[type="file"] {
73
+ display: none;
74
+ }
75
+
76
+ /* Vista previa */
77
+ .preview-section {
78
+ display: none;
79
+ margin-bottom: 20px;
80
+ }
81
+
82
+ .preview-section.active {
83
+ display: block;
84
+ }
85
+
86
+ .preview-image {
87
+ width: 100%;
88
+ height: 300px;
89
+ object-fit: contain;
90
+ border-radius: 16px;
91
+ background: #edf2f7;
92
+ border: 2px solid #e2e8f0;
93
+ }
94
+
95
+ /* Botón predecir */
96
+ .btn-predict {
97
+ background: #667eea;
98
+ color: white;
99
+ border: none;
100
+ padding: 14px 32px;
101
+ border-radius: 12px;
102
+ font-size: 1.1rem;
103
+ font-weight: 600;
104
+ cursor: pointer;
105
+ transition: all 0.2s;
106
+ width: 100%;
107
+ margin-top: 10px;
108
+ display: flex;
109
+ align-items: center;
110
+ justify-content: center;
111
+ gap: 10px;
112
+ }
113
+
114
+ .btn-predict:hover {
115
+ background: #5a67d8;
116
+ transform: translateY(-1px);
117
+ box-shadow: 0 8px 20px rgba(102,126,234,0.3);
118
+ }
119
+
120
+ .btn-predict:disabled {
121
+ background: #a0aec0;
122
+ cursor: not-allowed;
123
+ transform: none;
124
+ box-shadow: none;
125
+ }
126
+
127
+ /* Spinner */
128
+ .spinner {
129
+ display: none;
130
+ border: 3px solid rgba(255,255,255,0.3);
131
+ border-top: 3px solid white;
132
+ border-radius: 50%;
133
+ width: 20px;
134
+ height: 20px;
135
+ animation: spin 1s linear infinite;
136
+ }
137
+
138
+ @keyframes spin {
139
+ to { transform: rotate(360deg); }
140
+ }
141
+
142
+ /* Resultados */
143
+ .result-section {
144
+ display: none;
145
+ background: #f7fafc;
146
+ border-radius: 16px;
147
+ padding: 24px;
148
+ margin-top: 20px;
149
+ text-align: left;
150
+ }
151
+
152
+ .result-section.active {
153
+ display: block;
154
+ }
155
+
156
+ .result-header {
157
+ display: flex;
158
+ align-items: center;
159
+ gap: 12px;
160
+ margin-bottom: 16px;
161
+ }
162
+
163
+ .result-icon {
164
+ font-size: 36px;
165
+ }
166
+
167
+ .result-prediction {
168
+ font-size: 1.5rem;
169
+ font-weight: 700;
170
+ color: #2d3748;
171
+ }
172
+
173
+ .confidence-bar-container {
174
+ background: #e2e8f0;
175
+ border-radius: 12px;
176
+ height: 12px;
177
+ margin-bottom: 12px;
178
+ overflow: hidden;
179
+ }
180
+
181
+ .confidence-bar {
182
+ height: 100%;
183
+ border-radius: 12px;
184
+ width: 0%;
185
+ background: linear-gradient(90deg, #48bb78, #38a169);
186
+ transition: width 0.8s ease;
187
+ }
188
+
189
+ .confidence-text {
190
+ color: #4a5568;
191
+ margin-bottom: 12px;
192
+ }
193
+
194
+ /* Lista de probabilidades */
195
+ .probabilities {
196
+ display: flex;
197
+ flex-direction: column;
198
+ gap: 8px;
199
+ }
200
+
201
+ .prob-item {
202
+ display: flex;
203
+ justify-content: space-between;
204
+ align-items: center;
205
+ padding: 8px 12px;
206
+ background: white;
207
+ border-radius: 8px;
208
+ box-shadow: 0 1px 3px rgba(0,0,0,0.05);
209
+ }
210
+
211
+ .prob-label {
212
+ display: flex;
213
+ align-items: center;
214
+ gap: 8px;
215
+ }
216
+
217
+ .prob-value {
218
+ font-weight: 600;
219
+ }
220
+
221
+ /* Botón "Subir otra imagen" */
222
+ .upload-another {
223
+ display: none;
224
+ background: transparent;
225
+ border: 2px solid #667eea;
226
+ color: #667eea;
227
+ padding: 8px 20px;
228
+ border-radius: 8px;
229
+ font-weight: 600;
230
+ cursor: pointer;
231
+ margin-top: 16px;
232
+ transition: 0.2s;
233
+ }
234
+
235
+ .upload-another:hover {
236
+ background: #667eea;
237
+ color: white;
238
+ }
239
+
240
+ /* Responsive */
241
+ @media (max-width: 500px) {
242
+ .container {
243
+ padding: 24px;
244
+ }
245
+ h1 {
246
+ font-size: 1.5rem;
247
+ }
248
+ .upload-area {
249
+ padding: 24px 16px;
250
+ }
251
+ }