agerhund commited on
Commit
693e307
·
verified ·
1 Parent(s): 42e443a

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +422 -422
app.py CHANGED
@@ -3,15 +3,15 @@ import pandas as pd
3
  import numpy as np
4
  from PIL import Image
5
  import os
6
- from huggingface_hub import hf_hub_download 
7
- import logic 
8
 
9
  # --- CONSTANTES Y RUTAS DE RECURSOS ---
10
  csv_path = "data/furniture_data.csv"
11
- cache_path = "vectores_cache.pkl" 
12
 
13
  # --- CONSTANTES DE DESCARGA DE MODELOS ---
14
- MODEL_REPO_ID = "agerhund/DesignIA_models" 
15
  MODEL_FILE_BERT = "bert_style_encoder.pth"
16
  MODEL_FILE_HORIZON = "horizonnet_model.pth"
17
 
@@ -20,40 +20,40 @@ st.set_page_config(page_title="DesignIA - Recomendador de Muebles inteligente",
20
 
21
  # --- CARGAR ESTILOS CSS ---
22
  def cargar_estilo():
23
-     """Define y aplica estilos CSS para la UI de Streamlit."""
24
-     st.markdown("""
25
-         <style>
26
-         /* Ocultar elementos de sistema de Streamlit */
27
-         #MainMenu {visibility: hidden;}
28
-         footer {visibility: hidden;}
29
-         header {visibility: hidden;}
30
-         
31
-         /* Estilo de Botones (Azul IKEA) */
32
-         div.stButton > button:first-child {
33
-             background-color: #0051ba; 
34
-             color: white; 
35
-             border-radius: 8px; 
36
-             font-weight: bold; 
37
-             border: none; 
38
-             padding: 0.5rem 1rem;
39
-         }
40
-         div.stButton > button:first-child:hover { 
41
-             background-color: #003e8f; 
42
-             border: none; 
43
-         }
44
-         
45
-         /* Estilo de las Tarjetas de Producto */
46
-         .product-card {
47
-             background-color: white; 
48
-             padding: 15px; 
49
-             border-radius: 10px;
50
-             box-shadow: 0 2px 5px rgba(0,0,0,0.05); 
51
-             margin-bottom: 15px; 
52
-             border: 1px solid #eee;
53
-             color: black !important; 
54
-         }
55
-         </style>
56
-     """, unsafe_allow_html=True)
57
 
58
  cargar_estilo()
59
 
@@ -65,94 +65,94 @@ if 'data_manager' not in st.session_state: st.session_state.data_manager = None
65
  if 'horizon_model_path' not in st.session_state: st.session_state.horizon_model_path = None
66
  if 'source_file_path' not in st.session_state: st.session_state.source_file_path = None
67
  if 'is_example' not in st.session_state: st.session_state.is_example = False
68
- if 'display_files_list' not in st.session_state: st.session_state.display_files_list = [] # Necesario para el callback
69
 
70
  # --- FUNCIONES DE CARGA CON CACHE ---
71
 
72
  @st.cache_resource
73
  def download_models():
74
-     """Descarga ambos modelos grandes desde el Model Repository y devuelve las rutas locales temporales."""
75
-     bert_path = hf_hub_download(repo_id=MODEL_REPO_ID, filename=MODEL_FILE_BERT)
76
-     horizon_path = hf_hub_download(repo_id=MODEL_REPO_ID, filename=MODEL_FILE_HORIZON)
77
 
78
-     return bert_path, horizon_path
79
 
80
  @st.cache_resource
81
  def init_backend(csv, cache, bert_model_path):
82
-     """Inicializa DataManager y carga el DataFrame de muebles, usando la ruta local del modelo BERT."""
83
-     dm = logic.DataManager(csv, cache, bert_model_path)
84
-     df = dm.cargar_datos()
85
-     return dm, df
86
 
87
  # --- 1. CARGA DE DATOS Y MODELOS (Cacheado y estabilizado) ---
88
  try:
89
-     if st.session_state.data_manager is None:
90
-         
91
-         # 1. Usar contenedor para el mensaje de descarga (Estabiliza el salto)
92
-         status_message = st.empty()
93
-         status_message.info("Descargando modelos grandes desde Hugging Face Hub (¡Solo la primera vez!)...")
94
-         
95
-         bert_path_downloaded, horizon_path_downloaded = download_models()
96
-         
97
-         # 2. Limpiar el placeholder de descarga y mostrar el spinner de carga de datos
98
-         status_message.empty() 
99
-         with st.spinner("Cargando base de datos de muebles y modelos IA..."):
100
-             
101
-             dm, df = init_backend(csv_path, cache_path, bert_path_downloaded)
102
-             
103
-             st.session_state.data_manager = dm
104
-             st.session_state.muebles_df = df
105
-             st.session_state.horizon_model_path = horizon_path_downloaded 
106
-             
107
-         st.toast("Modelos y datos cargados con éxito.", icon="✅") 
108
-         
109
-     st.sidebar.success(f"Base de datos cargada: {len(st.session_state.muebles_df)} items")
110
 
111
  except Exception as e:
112
-     st.error(f"Error cargando datos: {e}")
113
-     st.stop()
114
 
115
 
116
  # --- SIDEBAR: INFORMACIÓN DEL PROYECTO (SOLO INFORMACIÓN ESTÁTICA) ---
117
  with st.sidebar:
118
-     st.title("DesignIA - Asistente de Diseño")
119
-     st.markdown("---")
120
-     st.markdown("**Trabajo de fin de Máster**")
121
-     st.caption("Máster de Data Science, Business Analytics y Big Data")
122
-     st.caption("Universidad Complutense de Madrid")
123
-     st.markdown("---")
124
-     st.markdown("Desarrollado por **Andrés Gerlotti Slusnys**")
125
-     st.markdown("© 2025")
126
-     
127
-     # Indicador de estado 
128
-     with st.expander("Estado del Sistema", expanded=False):
129
-         st.success("Motor Gráfico: Activo")
130
-         st.success("Modelo NLP (BERT): Cargado")
131
-         if 'horizon_model_path' in st.session_state:
132
-             st.success("HorizonNet: Conectado (Remoto)")
133
-         else:
134
-             st.warning("HorizonNet: Pendiente de descarga/inicialización")
135
-     
136
-     st.markdown("---")
137
-     st.info("El selector de imágenes de ejemplo se encuentra en el Paso 1 de la página principal.")
138
 
139
  # --- FUNCIONES DE CALLBACK (Para estabilizar la UI) ---
140
 
141
  def actualizar_estado_ejemplo():
142
-     """Actualiza el estado de sesión SÓLO cuando cambia el selectbox."""
143
-     selected_name = st.session_state.example_select_box
144
-     display_files = st.session_state.display_files_list
145
-     examples_dir = os.path.join(os.path.dirname(__file__), "examples")
146
-    
147
-     if selected_name != display_files[0]:
148
-         file_path = os.path.join(examples_dir, selected_name)
149
-         if st.session_state.get('source_file_path') != file_path:
150
-             st.session_state.source_file_path = file_path
151
-             st.session_state.is_example = True
152
-     else:
153
-         if 'source_file_path' in st.session_state:
154
-             del st.session_state.source_file_path
155
-             st.session_state.is_example = False
156
 
157
  # --- INTERFAZ PRINCIPAL ---
158
  st.title("Recomendador de Muebles Inteligente")
@@ -166,45 +166,45 @@ examples_dir = os.path.join(os.path.dirname(__file__), "examples")
166
  image_placeholder = st.empty() # Placeholder para la imagen (Estabiliza el temblor)
167
 
168
  if os.path.exists(examples_dir):
169
-     example_files = [f for f in os.listdir(examples_dir) if f.endswith(('.jpg', '.png'))]
170
-     example_files.sort()
171
-     
172
-     col_select, col_download = st.columns([3, 1])
173
-
174
-     # Generar y almacenar la lista de opciones para el callback
175
-     display_files = ["--- Cargar imagen manualmente (prioridad) ---"] + example_files 
176
-     st.session_state.display_files_list = display_files
177
-     
178
-     with col_select:
179
-         # El callback on_change maneja la lógica de estado de forma estable
180
-         selected_example_name = st.selectbox(
181
-             "O usa una imagen de ejemplo:", 
182
-             display_files,
183
-             key='example_select_box',
184
-             on_change=actualizar_estado_ejemplo
185
-         )
186
-
187
-     # Manejo de la visualización del ejemplo (basado en el estado de sesión)
188
-     if st.session_state.get('source_file_path') and st.session_state.is_example:
189
-         file_path = st.session_state.source_file_path
190
-         selected_example_name = file_path.split(os.sep)[-1]
191
-         
192
-         with col_download:
193
-             st.markdown(" ") # Espacio para alinear el botón
194
-             with open(file_path, "rb") as file:
195
-                 st.download_button(
196
-                     label=f"⬇️ Descargar: {selected_example_name}",
197
-                     data=file,
198
-                     file_name=selected_example_name,
199
-                     mime="image/jpeg"
200
-                 )
201
-         
202
-         with image_placeholder.container():
203
-             st.image(file_path, caption="Vista previa del ejemplo", use_container_width=True)
204
-
205
-     else:
206
-         # Si no hay selección de ejemplo, el placeholder está vacío o se usará para la subida manual.
207
-         pass
208
 
209
 
210
  # 1. Selector de carga manual (Se mantiene aquí, pero solo para subir el archivo)
@@ -215,296 +215,296 @@ source_file_path = st.session_state.get('source_file_path', None)
215
  is_example = st.session_state.get('is_example', False)
216
 
217
  if uploaded_file is not None:
218
-     source_file = uploaded_file
219
-     file_caption = 'Imagen subida'
220
-     is_example = False
221
-     st.session_state.is_example = False # Resetear por seguridad
222
-     image_placeholder.empty() # Limpiar el placeholder si hay una subida manual
223
  elif source_file_path is not None and is_example == True:
224
-     source_file = source_file_path
225
-     file_caption = f'Imagen de ejemplo: {source_file_path.split(os.sep)[-1]}'
226
  else:
227
-     source_file = None
228
 
229
 
230
  if source_file is not None:
231
-     # Abrir la imagen
232
-     if is_example:
233
-         image = Image.open(source_file)
234
-     else:
235
-         image = Image.open(source_file)
236
-
237
-     # Mostrar la imagen subida (solo si es subida, ya que el ejemplo se mostró arriba)
238
-     if uploaded_file is not None:
239
-         with image_placeholder.container():
240
-              st.image(image, caption=file_caption, use_container_width=True)
241
-     
242
-     if st.button("Analizar la habitación"):
243
-         with st.spinner("Detectando la geometría..."):
244
-             
245
-             # --- MANEJO DEL ARCHIVO TEMPORAL PARA EL DETECTOR ---
246
-             if is_example:
247
-                 temp_file_path = source_file
248
-             else:
249
-                 temp_file_path = "temp_pano.jpg"
250
-                 with open(temp_file_path, "wb") as f:
251
-                     f.write(source_file.getbuffer())
252
-             # --- FIN MANEJO ARCHIVO TEMPORAL ---
253
-
254
-
255
-             # Instanciar y ejecutar el detector de layout (HorizonNet)
256
-             try:
257
-                 detector = logic.RoomLayoutDetector(st.session_state.horizon_model_path) 
258
-                 room_data = detector.detect_layout(temp_file_path)
259
-                 
260
-                 # Validación de datos y manejo de fallos
261
-                 if room_data is None or not isinstance(room_data, dict) or 'width' not in room_data:
262
-                     st.error("**Detección fallida.** El modelo de Computer Vision no pudo extraer las dimensiones ni los obstáculos. Asegúrate de que el modelo HorizonNet está configurado y funcionando correctamente.")
263
-                     st.session_state.room_data = None
264
-                     st.session_state.stage = 0
265
-                 else:
266
-                     st.session_state.room_data = room_data
267
-                     st.session_state.stage = 1
268
-                     
269
-                     st.toast("Análisis completado", icon="✅") 
270
-
271
-                     # Mostrar resultado de la detección de HorizonNet
272
-                     st.header("Resultado del análisis visual")
273
-                     annotated_image = logic.dibujar_layout_sobre_imagen(temp_file_path, room_data)
274
-                     st.image(annotated_image, caption='Análisis de HorizonNet (Vértices, Puertas y Ventanas)', use_container_width=True)
275
-                 
276
-             except Exception as e:
277
-                 st.error(f"Error en detección: {e}. Revisa la configuración del modelo HorizonNet.")
278
-                 st.session_state.stage = 0
279
 
280
  # --- PASO 2: VERIFICACIÓN Y EDICIÓN DE GEOMETRÍA/OBSTÁCULOS ---
281
  if st.session_state.stage >= 1 and st.session_state.room_data:
282
-     st.header("2. Verificación de Geometría")
283
-     
284
-     # Mostrar dimensiones detectadas
285
-     w_m = st.session_state.room_data.get('width', 0.0)
286
-     l_m = st.session_state.room_data.get('length', 0.0)
287
-     
288
-     col1, col2 = st.columns(2)
289
-     with col1:
290
-         st.metric("Ancho (m)", f"{w_m:.2f}")
291
-     with col2:
292
-         st.metric("Largo (m)", f"{l_m:.2f}")
293
-     
294
-     # Mostrar el diagrama de planta
295
-     st.subheader("Planta de la habitación")
296
-     floor_plan_fig = logic.generar_diagrama_planta(st.session_state.room_data)
297
-     st.pyplot(floor_plan_fig)
298
-     
299
-     # Formulario para añadir puertas y ventanas manualmente
300
-     st.subheader("Añadir puertas y ventanas manualmente")
301
-     
302
-     polygon_points = st.session_state.room_data.get('polygon_points', [])
303
-     num_walls = len(polygon_points) if polygon_points is not None else 0
304
-     
305
-     if num_walls > 0:
306
-         col_a, col_b, col_c, col_d = st.columns(4)
307
-         
308
-         with col_a:
309
-             wall_options = [f"Pared {i+1}" for i in range(num_walls)]
310
-             selected_wall = st.selectbox("Seleccionar Pared", wall_options, key="wall_select")
311
-             wall_idx = int(selected_wall.split()[1]) - 1
312
-         
313
-         with col_b:
314
-             obstacle_type = st.radio("Tipo", ["Puerta", "Ventana"], key="obs_type")
315
-         
316
-         with col_c:
317
-             # Posición normalizada [0.0, 1.0]
318
-             position_pct = st.number_input("Posición (%)", min_value=0.0, max_value=100.0, value=50.0, step=5.0, key="obs_pos")
319
-         
320
-         with col_d:
321
-             width_m = st.number_input("Ancho (m)", min_value=0.1, max_value=5.0, value=0.9, step=0.1, key="obs_width")
322
-         
323
-         if st.button("Añadir elemento"):
324
-             # Los datos de centro están normalizados
325
-             new_obstacle = {
326
-                 'center': [position_pct / 100.0, wall_idx / max(1, num_walls)],
327
-                 'width': width_m
328
-             }
329
-             
330
-             if obstacle_type == "Puerta":
331
-                 st.session_state.room_data['doors'].append(new_obstacle)
332
-             else:
333
-                 st.session_state.room_data['windows'].append(new_obstacle)
334
-             
335
-             st.success(f"{obstacle_type} añadida a {selected_wall}")
336
-             st.rerun()
337
-     else:
338
-         st.warning("No se detectaron paredes en el polígono.")
339
-     
340
-     # Editor de datos para modificar obstáculos detectados/añadidos
341
-     st.subheader("Editar elementos (puertas y ventanas)")
342
-     st.info("Ajusta las coordenadas de los obstáculos. Los valores X/Y están normalizados [0.0, 1.0].")
343
-
344
-     # Preparar datos para st.data_editor
345
-     doors_data = []
346
-     for i, d in enumerate(st.session_state.room_data.get('doors', [])):
347
-         center_y = d['center'][1] if len(d['center']) > 1 else 0 
348
-         doors_data.append({"ID": f"P{i}", "Tipo": "Puerta", "Centro X (Norm.)": d['center'][0], "Centro Y (Norm.)": center_y, "Ancho (m)": d['width']})
349
-     
350
-     windows_data = []
351
-     for i, w in enumerate(st.session_state.room_data.get('windows', [])):
352
-         center_y = w['center'][1] if len(w['center']) > 1 else 0
353
-         windows_data.append({"ID": f"V{i}", "Tipo": "Ventana", "Centro X (Norm.)": w['center'][0], "Centro Y (Norm.)": center_y, "Ancho (m)": w['width']})
354
-
355
-     all_obstacles = doors_data + windows_data
356
-     df_obs = pd.DataFrame(all_obstacles)
357
-     
358
-     col_config = {
359
-         "Centro X (Norm.)": st.column_config.NumberColumn("Centro X (Norm.)", help="Posición horizontal normalizada [0.0, 1.0]", format="%.2f"),
360
-         "Centro Y (Norm.)": st.column_config.NumberColumn("Centro Y (Norm.)", help="Posición vertical normalizada [0.0, 1.0]", format="%.2f"),
361
-         "Ancho (m)": st.column_config.NumberColumn("Ancho (m)", help="Ancho del obstáculo en metros", format="%.2f"),
362
-     }
363
-     
364
-     edited_df = st.data_editor(df_obs, num_rows="dynamic", use_container_width=True, column_config=col_config)
365
-
366
-     if st.button("Confirmar geometría"):
367
-         # Reconstruir el diccionario room_data a partir del DataFrame editado
368
-         new_doors = []
369
-         new_windows = []
370
-         for index, row in edited_df.iterrows():
371
-             obj = {'center': [row['Centro X (Norm.)'], row['Centro Y (Norm.)']], 'width': row['Ancho (m)']}
372
-             if row['Tipo'] == 'Puerta': new_doors.append(obj)
373
-             else: new_windows.append(obj)
374
-             
375
-         st.session_state.room_data['doors'] = new_doors
376
-         st.session_state.room_data['windows'] = new_windows
377
-         st.session_state.stage = 2
378
-         st.rerun()
379
 
380
  # --- PASO 3: PRESUPUESTO Y GENERACIÓN DE LAYOUT/RECOMENDACIÓN ---
381
  if st.session_state.stage >= 2:
382
-     st.header("3. Presupuesto y generación")
383
-     
384
-     presupuesto = st.number_input("Presupuesto Máximo (€)", min_value=100.0, value=1000.0, step=100.0)
385
-     
386
-     if st.button("Generar diseño"):
387
-         with st.spinner("Calculando distribución óptima y seleccionando muebles..."):
388
-             # Convertir dimensiones de m a cm para el LayoutEngine
389
-             w_cm = st.session_state.room_data.get('width', 0.0) * 100
390
-             l_cm = st.session_state.room_data.get('length', 0.0) * 100
391
-             
392
-             if w_cm < 200 or l_cm < 200:
393
-                 st.error("Las dimensiones de la habitación son demasiado pequeñas (mínimo 2x2m) o no fueron capturadas correctamente.")
394
-             else:
395
-                 # 1. Inicializar motores
396
-                 layout_engine = logic.LayoutEngine(st.session_state.data_manager.dimensiones_promedio)
397
-                 recommender = logic.Recommender(st.session_state.muebles_df)
398
-                 
399
-                 # 2. Sugerir el pack de muebles base
400
-                 pack_sugerido = layout_engine.sugerir_pack(w_cm, l_cm)
401
-                 
402
-                 # 3. Convertir obstáculos a polígonos para el motor
403
-                 obs_layout = layout_engine.convertir_obstaculos(
404
-                     st.session_state.room_data, 
405
-                     w_cm, l_cm, 
406
-                     polygon_points=st.session_state.room_data.get('polygon_points')
407
-                 )
408
-                 
409
-                 # 4. Generar el Layout
410
-                 layout_plan, constraints, log_msgs = layout_engine.generar_layout(
411
-                     w_cm, l_cm, 
412
-                     pack_sugerido, 
413
-                     obs_layout,
414
-                     polygon_points=st.session_state.room_data.get('polygon_points')
415
-                 )
416
-
417
-                 # Mostrar Log de Generación
418
-                 with st.expander("📝 Detalles de la Generación del Layout", expanded=False):
419
-                     for msg in log_msgs:
420
-                         if "✅" in msg: st.success(msg)
421
-                         elif "❌" in msg: st.error(msg)
422
-                         elif "⚠️" in msg: st.warning(msg)
423
-                         else: st.text(msg)
424
-                 
425
-                 if not layout_plan:
426
-                     st.error("No se pudo generar una distribución válida para este espacio (demasiado pequeño o muchos obstáculos).")
427
-                 else:
428
-                     # 5. Recomendar productos (Knapsack para optimización de precio/estilo)
429
-                     best_combo = recommender.buscar_combinacion(constraints, presupuesto, top_n=1)
430
-                     
431
-                     if not best_combo:
432
-                         st.error("No se encontraron muebles que se ajusten al presupuesto y restricciones.")
433
-                     else:
434
-                         st.session_state.result_layout = layout_plan
435
-                         st.session_state.result_items = best_combo[0]['items']
436
-                         st.session_state.result_total = best_combo[0]['precio_total']
437
-                         st.session_state.result_score = best_combo[0]['score']
438
-                         st.session_state.stage = 3
439
 
440
  # --- PASO 4: RESULTADOS Y VISUALIZACIÓN FINAL ---
441
  if st.session_state.stage == 3:
442
-     st.divider()
443
-     st.header("Tu salón ideal")
444
-     
445
-     # --- VISUALIZACIÓN 3D Interactiva (Plotly) ---
446
-     st.subheader("Visualización 3D Interactiva")
447
-     
448
-     # Generar la figura 3D
449
-     fig_plotly = logic.generar_figura_3d_plotly(
450
-         st.session_state.result_layout, 
451
-         st.session_state.room_data,
452
-         st.session_state.result_items
453
-     )
454
-     
455
-     # Renderizar la figura de Plotly
456
-     st.plotly_chart(fig_plotly, use_container_width=True, theme="streamlit")
457
-     
458
-     st.info("💡 Usa el ratón: Clic izquierdo para rotar, rueda para zoom.")
459
-         
460
-     st.divider()
461
-
462
-     # --- LISTA DE COMPRA ---
463
-     st.subheader("Lista de Compra")
464
-     
465
-     # Totales y Score de Diseño
466
-     c_tot1, c_tot2 = st.columns([2, 1])
467
-     with c_tot1:
468
-         st.markdown("### Total Estimado")
469
-         st.caption(f"Score de Diseño (Estilo + Puntuación Base): {st.session_state.result_score:.2f}/1.0")
470
-     with c_tot2:
471
-         st.markdown(f"### {st.session_state.result_total:.2f}€")
472
-     
473
-     st.markdown("---")
474
-     
475
-     # Listado de productos
476
-     for item in st.session_state.result_items:
477
-         with st.container():
478
-             c_img, c_info, c_price, c_link = st.columns([1, 2, 1, 1])
479
-             
480
-             url = f"https://www.ikea.com/es/es/p/{item.get('Enlace_producto', '')}-{item.get('ID', '')}"
481
-             img_src = item.get('Imagen_principal', '')
482
-             nombre = item['Nombre']
483
-             tipo = item['Tipo_mueble']
484
-             precio = float(item['Precio'])
485
-             
486
-             with c_img:
487
-                 if img_src:
488
-                     st.image(img_src, width=150)
489
-                 else:
490
-                     st.text("Sin imagen")
491
-             
492
-             with c_info:
493
-                 st.subheader(nombre)
494
-                 st.caption(tipo)
495
-                 st.text(item.get('Descripcion', '')[:100] + '...')
496
-             
497
-             with c_price:
498
-                 st.markdown(f"### {precio:.2f} €")
499
-             
500
-             with c_link:
501
-                 st.link_button("Ver en IKEA", url)
502
-             
503
-             st.divider()
504
-     
505
-     if st.button("Reiniciar"):
506
-         for key in ['room_data', 'result_layout', 'result_items', 'result_total', 'result_score']:
507
-             if key in st.session_state:
508
-                 del st.session_state[key]
509
-         st.session_state.stage = 0
510
-         st.rerun()
 
3
  import numpy as np
4
  from PIL import Image
5
  import os
6
+ from huggingface_hub import hf_hub_download
7
+ import logic
8
 
9
  # --- CONSTANTES Y RUTAS DE RECURSOS ---
10
  csv_path = "data/furniture_data.csv"
11
+ cache_path = "vectores_cache.pkl"
12
 
13
  # --- CONSTANTES DE DESCARGA DE MODELOS ---
14
+ MODEL_REPO_ID = "agerhund/DesignIA_models"
15
  MODEL_FILE_BERT = "bert_style_encoder.pth"
16
  MODEL_FILE_HORIZON = "horizonnet_model.pth"
17
 
 
20
 
21
  # --- CARGAR ESTILOS CSS ---
22
  def cargar_estilo():
23
+ """Define y aplica estilos CSS para la UI de Streamlit."""
24
+ st.markdown("""
25
+ <style>
26
+ /* Ocultar elementos de sistema de Streamlit */
27
+ #MainMenu {visibility: hidden;}
28
+ footer {visibility: hidden;}
29
+ header {visibility: hidden;}
30
+
31
+ /* Estilo de Botones (Azul IKEA) */
32
+ div.stButton > button:first-child {
33
+ background-color: #0051ba;
34
+ color: white;
35
+ border-radius: 8px;
36
+ font-weight: bold;
37
+ border: none;
38
+ padding: 0.5rem 1rem;
39
+ }
40
+ div.stButton > button:first-child:hover {
41
+ background-color: #003e8f;
42
+ border: none;
43
+ }
44
+
45
+ /* Estilo de las Tarjetas de Producto */
46
+ .product-card {
47
+ background-color: white;
48
+ padding: 15px;
49
+ border-radius: 10px;
50
+ box-shadow: 0 2px 5px rgba(0,0,0,0.05);
51
+ margin-bottom: 15px;
52
+ border: 1px solid #eee;
53
+ color: black !important;
54
+ }
55
+ </style>
56
+ """, unsafe_allow_html=True)
57
 
58
  cargar_estilo()
59
 
 
65
  if 'horizon_model_path' not in st.session_state: st.session_state.horizon_model_path = None
66
  if 'source_file_path' not in st.session_state: st.session_state.source_file_path = None
67
  if 'is_example' not in st.session_state: st.session_state.is_example = False
68
+ if 'display_files_list' not in st.session_state: st.session_state.display_files_list = []
69
 
70
  # --- FUNCIONES DE CARGA CON CACHE ---
71
 
72
  @st.cache_resource
73
  def download_models():
74
+ """Descarga ambos modelos grandes desde el Model Repository y devuelve las rutas locales temporales."""
75
+ bert_path = hf_hub_download(repo_id=MODEL_REPO_ID, filename=MODEL_FILE_BERT)
76
+ horizon_path = hf_hub_download(repo_id=MODEL_REPO_ID, filename=MODEL_FILE_HORIZON)
77
 
78
+ return bert_path, horizon_path
79
 
80
  @st.cache_resource
81
  def init_backend(csv, cache, bert_model_path):
82
+ """Inicializa DataManager y carga el DataFrame de muebles, usando la ruta local del modelo BERT."""
83
+ dm = logic.DataManager(csv, cache, bert_model_path)
84
+ df = dm.cargar_datos()
85
+ return dm, df
86
 
87
  # --- 1. CARGA DE DATOS Y MODELOS (Cacheado y estabilizado) ---
88
  try:
89
+ if st.session_state.data_manager is None:
90
+
91
+ # 1. Usar contenedor para el mensaje de descarga (Estabiliza el salto)
92
+ status_message = st.empty()
93
+ status_message.info("Descargando modelos grandes desde Hugging Face Hub (¡Solo la primera vez!)...")
94
+
95
+ bert_path_downloaded, horizon_path_downloaded = download_models()
96
+
97
+ # 2. Limpiar el placeholder de descarga y mostrar el spinner de carga de datos
98
+ status_message.empty()
99
+ with st.spinner("Cargando base de datos de muebles y modelos IA..."):
100
+
101
+ dm, df = init_backend(csv_path, cache_path, bert_path_downloaded)
102
+
103
+ st.session_state.data_manager = dm
104
+ st.session_state.muebles_df = df
105
+ st.session_state.horizon_model_path = horizon_path_downloaded
106
+
107
+ st.toast("Modelos y datos cargados con éxito.", icon="✅")
108
+
109
+ st.sidebar.success(f"Base de datos cargada: {len(st.session_state.muebles_df)} items")
110
 
111
  except Exception as e:
112
+ st.error(f"Error cargando datos: {e}")
113
+ st.stop()
114
 
115
 
116
  # --- SIDEBAR: INFORMACIÓN DEL PROYECTO (SOLO INFORMACIÓN ESTÁTICA) ---
117
  with st.sidebar:
118
+ st.title("DesignIA - Asistente de Diseño")
119
+ st.markdown("---")
120
+ st.markdown("**Trabajo de fin de Máster**")
121
+ st.caption("Máster de Data Science, Business Analytics y Big Data")
122
+ st.caption("Universidad Complutense de Madrid")
123
+ st.markdown("---")
124
+ st.markdown("Desarrollado por **Andrés Gerlotti Slusnys**")
125
+ st.markdown("© 2025")
126
+
127
+ # Indicador de estado
128
+ with st.expander("Estado del Sistema", expanded=False):
129
+ st.success("Motor Gráfico: Activo")
130
+ st.success("Modelo NLP (BERT): Cargado")
131
+ if 'horizon_model_path' in st.session_state:
132
+ st.success("HorizonNet: Conectado (Remoto)")
133
+ else:
134
+ st.warning("HorizonNet: Pendiente de descarga/inicialización")
135
+
136
+ st.markdown("---")
137
+ st.info("El selector de imágenes de ejemplo se encuentra en el Paso 1 de la página principal.")
138
 
139
  # --- FUNCIONES DE CALLBACK (Para estabilizar la UI) ---
140
 
141
  def actualizar_estado_ejemplo():
142
+ """Actualiza el estado de sesión SÓLO cuando cambia el selectbox."""
143
+ selected_name = st.session_state.example_select_box
144
+ display_files = st.session_state.display_files_list
145
+ examples_dir = os.path.join(os.path.dirname(__file__), "examples")
146
+
147
+ if selected_name != display_files[0]:
148
+ file_path = os.path.join(examples_dir, selected_name)
149
+ if st.session_state.get('source_file_path') != file_path:
150
+ st.session_state.source_file_path = file_path
151
+ st.session_state.is_example = True
152
+ else:
153
+ if 'source_file_path' in st.session_state:
154
+ del st.session_state.source_file_path
155
+ st.session_state.is_example = False
156
 
157
  # --- INTERFAZ PRINCIPAL ---
158
  st.title("Recomendador de Muebles Inteligente")
 
166
  image_placeholder = st.empty() # Placeholder para la imagen (Estabiliza el temblor)
167
 
168
  if os.path.exists(examples_dir):
169
+ example_files = [f for f in os.listdir(examples_dir) if f.endswith(('.jpg', '.png'))]
170
+ example_files.sort()
171
+
172
+ col_select, col_download = st.columns([3, 1])
173
+
174
+ # Generar y almacenar la lista de opciones para el callback
175
+ display_files = ["--- Cargar imagen manualmente (prioridad) ---"] + example_files
176
+ st.session_state.display_files_list = display_files
177
+
178
+ with col_select:
179
+ # El callback on_change maneja la lógica de estado de forma estable
180
+ selected_example_name = st.selectbox(
181
+ "O usa una imagen de ejemplo:",
182
+ display_files,
183
+ key='example_select_box',
184
+ on_change=actualizar_estado_ejemplo
185
+ )
186
+
187
+ # Manejo de la visualización del ejemplo (basado en el estado de sesión)
188
+ if st.session_state.get('source_file_path') and st.session_state.is_example:
189
+ file_path = st.session_state.source_file_path
190
+ selected_example_name = file_path.split(os.sep)[-1]
191
+
192
+ with col_download:
193
+ st.markdown(" ") # Espacio para alinear el botón
194
+ with open(file_path, "rb") as file:
195
+ st.download_button(
196
+ label=f"⬇️ Descargar: {selected_example_name}",
197
+ data=file,
198
+ file_name=selected_example_name,
199
+ mime="image/jpeg"
200
+ )
201
+
202
+ with image_placeholder.container():
203
+ st.image(file_path, caption="Vista previa del ejemplo", use_container_width=True)
204
+
205
+ else:
206
+ # Si no hay selección de ejemplo, el placeholder está vacío o se usará para la subida manual.
207
+ pass
208
 
209
 
210
  # 1. Selector de carga manual (Se mantiene aquí, pero solo para subir el archivo)
 
215
  is_example = st.session_state.get('is_example', False)
216
 
217
  if uploaded_file is not None:
218
+ source_file = uploaded_file
219
+ file_caption = 'Imagen subida'
220
+ is_example = False
221
+ st.session_state.is_example = False # Resetear por seguridad
222
+ image_placeholder.empty() # Limpiar el placeholder si hay una subida manual
223
  elif source_file_path is not None and is_example == True:
224
+ source_file = source_file_path
225
+ file_caption = f'Imagen de ejemplo: {source_file_path.split(os.sep)[-1]}'
226
  else:
227
+ source_file = None
228
 
229
 
230
  if source_file is not None:
231
+ # Abrir la imagen
232
+ if is_example:
233
+ image = Image.open(source_file)
234
+ else:
235
+ image = Image.open(source_file)
236
+
237
+ # Mostrar la imagen subida (solo si es subida, ya que el ejemplo se mostró arriba)
238
+ if uploaded_file is not None:
239
+ with image_placeholder.container():
240
+ st.image(image, caption=file_caption, use_container_width=True)
241
+
242
+ if st.button("Analizar la habitación"):
243
+ with st.spinner("Detectando la geometría..."):
244
+
245
+ # --- MANEJO DEL ARCHIVO TEMPORAL PARA EL DETECTOR ---
246
+ if is_example:
247
+ temp_file_path = source_file
248
+ else:
249
+ temp_file_path = "temp_pano.jpg"
250
+ with open(temp_file_path, "wb") as f:
251
+ f.write(source_file.getbuffer())
252
+ # --- FIN MANEJO ARCHIVO TEMPORAL ---
253
+
254
+
255
+ # Instanciar y ejecutar el detector de layout (HorizonNet)
256
+ try:
257
+ detector = logic.RoomLayoutDetector(st.session_state.horizon_model_path)
258
+ room_data = detector.detect_layout(temp_file_path)
259
+
260
+ # Validación de datos y manejo de fallos
261
+ if room_data is None or not isinstance(room_data, dict) or 'width' not in room_data:
262
+ st.error("**Detección fallida.** El modelo de Computer Vision no pudo extraer las dimensiones ni los obstáculos. Asegúrate de que el modelo HorizonNet está configurado y funcionando correctamente.")
263
+ st.session_state.room_data = None
264
+ st.session_state.stage = 0
265
+ else:
266
+ st.session_state.room_data = room_data
267
+ st.session_state.stage = 1
268
+
269
+ st.toast("Análisis completado", icon="✅")
270
+
271
+ # Mostrar resultado de la detección de HorizonNet
272
+ st.header("Resultado del análisis visual")
273
+ annotated_image = logic.dibujar_layout_sobre_imagen(temp_file_path, room_data)
274
+ st.image(annotated_image, caption='Análisis de HorizonNet (Vértices, Puertas y Ventanas)', use_container_width=True)
275
+
276
+ except Exception as e:
277
+ st.error(f"Error en detección: {e}. Revisa la configuración del modelo HorizonNet.")
278
+ st.session_state.stage = 0
279
 
280
  # --- PASO 2: VERIFICACIÓN Y EDICIÓN DE GEOMETRÍA/OBSTÁCULOS ---
281
  if st.session_state.stage >= 1 and st.session_state.room_data:
282
+ st.header("2. Verificación de Geometría")
283
+
284
+ # Mostrar dimensiones detectadas
285
+ w_m = st.session_state.room_data.get('width', 0.0)
286
+ l_m = st.session_state.room_data.get('length', 0.0)
287
+
288
+ col1, col2 = st.columns(2)
289
+ with col1:
290
+ st.metric("Ancho (m)", f"{w_m:.2f}")
291
+ with col2:
292
+ st.metric("Largo (m)", f"{l_m:.2f}")
293
+
294
+ # Mostrar el diagrama de planta
295
+ st.subheader("Planta de la habitación")
296
+ floor_plan_fig = logic.generar_diagrama_planta(st.session_state.room_data)
297
+ st.pyplot(floor_plan_fig)
298
+
299
+ # Formulario para añadir puertas y ventanas manualmente
300
+ st.subheader("Añadir puertas y ventanas manualmente")
301
+
302
+ polygon_points = st.session_state.room_data.get('polygon_points', [])
303
+ num_walls = len(polygon_points) if polygon_points is not None else 0
304
+
305
+ if num_walls > 0:
306
+ col_a, col_b, col_c, col_d = st.columns(4)
307
+
308
+ with col_a:
309
+ wall_options = [f"Pared {i+1}" for i in range(num_walls)]
310
+ selected_wall = st.selectbox("Seleccionar Pared", wall_options, key="wall_select")
311
+ wall_idx = int(selected_wall.split()[1]) - 1
312
+
313
+ with col_b:
314
+ obstacle_type = st.radio("Tipo", ["Puerta", "Ventana"], key="obs_type")
315
+
316
+ with col_c:
317
+ # Posición normalizada [0.0, 1.0]
318
+ position_pct = st.number_input("Posición (%)", min_value=0.0, max_value=100.0, value=50.0, step=5.0, key="obs_pos")
319
+
320
+ with col_d:
321
+ width_m = st.number_input("Ancho (m)", min_value=0.1, max_value=5.0, value=0.9, step=0.1, key="obs_width")
322
+
323
+ if st.button("Añadir elemento"):
324
+ # Los datos de centro están normalizados
325
+ new_obstacle = {
326
+ 'center': [position_pct / 100.0, wall_idx / max(1, num_walls)],
327
+ 'width': width_m
328
+ }
329
+
330
+ if obstacle_type == "Puerta":
331
+ st.session_state.room_data['doors'].append(new_obstacle)
332
+ else:
333
+ st.session_state.room_data['windows'].append(new_obstacle)
334
+
335
+ st.success(f"{obstacle_type} añadida a {selected_wall}")
336
+ st.rerun()
337
+ else:
338
+ st.warning("No se detectaron paredes en el polígono.")
339
+
340
+ # Editor de datos para modificar obstáculos detectados/añadidos
341
+ st.subheader("Editar elementos (puertas y ventanas)")
342
+ st.info("Ajusta las coordenadas de los obstáculos. Los valores X/Y están normalizados [0.0, 1.0].")
343
+
344
+ # Preparar datos para st.data_editor
345
+ doors_data = []
346
+ for i, d in enumerate(st.session_state.room_data.get('doors', [])):
347
+ center_y = d['center'][1] if len(d['center']) > 1 else 0
348
+ doors_data.append({"ID": f"P{i}", "Tipo": "Puerta", "Centro X (Norm.)": d['center'][0], "Centro Y (Norm.)": center_y, "Ancho (m)": d['width']})
349
+
350
+ windows_data = []
351
+ for i, w in enumerate(st.session_state.room_data.get('windows', [])):
352
+ center_y = w['center'][1] if len(w['center']) > 1 else 0
353
+ windows_data.append({"ID": f"V{i}", "Tipo": "Ventana", "Centro X (Norm.)": w['center'][0], "Centro Y (Norm.)": center_y, "Ancho (m)": w['width']})
354
+
355
+ all_obstacles = doors_data + windows_data
356
+ df_obs = pd.DataFrame(all_obstacles)
357
+
358
+ col_config = {
359
+ "Centro X (Norm.)": st.column_config.NumberColumn("Centro X (Norm.)", help="Posición horizontal normalizada [0.0, 1.0]", format="%.2f"),
360
+ "Centro Y (Norm.)": st.column_config.NumberColumn("Centro Y (Norm.)", help="Posición vertical normalizada [0.0, 1.0]", format="%.2f"),
361
+ "Ancho (m)": st.column_config.NumberColumn("Ancho (m)", help="Ancho del obstáculo en metros", format="%.2f"),
362
+ }
363
+
364
+ edited_df = st.data_editor(df_obs, num_rows="dynamic", use_container_width=True, column_config=col_config)
365
+
366
+ if st.button("Confirmar geometría"):
367
+ # Reconstruir el diccionario room_data a partir del DataFrame editado
368
+ new_doors = []
369
+ new_windows = []
370
+ for index, row in edited_df.iterrows():
371
+ obj = {'center': [row['Centro X (Norm.)'], row['Centro Y (Norm.)']], 'width': row['Ancho (m)']}
372
+ if row['Tipo'] == 'Puerta': new_doors.append(obj)
373
+ else: new_windows.append(obj)
374
+
375
+ st.session_state.room_data['doors'] = new_doors
376
+ st.session_state.room_data['windows'] = new_windows
377
+ st.session_state.stage = 2
378
+ st.rerun()
379
 
380
  # --- PASO 3: PRESUPUESTO Y GENERACIÓN DE LAYOUT/RECOMENDACIÓN ---
381
  if st.session_state.stage >= 2:
382
+ st.header("3. Presupuesto y generación")
383
+
384
+ presupuesto = st.number_input("Presupuesto Máximo (€)", min_value=100.0, value=1000.0, step=100.0)
385
+
386
+ if st.button("Generar diseño"):
387
+ with st.spinner("Calculando distribución óptima y seleccionando muebles..."):
388
+ # Convertir dimensiones de m a cm para el LayoutEngine
389
+ w_cm = st.session_state.room_data.get('width', 0.0) * 100
390
+ l_cm = st.session_state.room_data.get('length', 0.0) * 100
391
+
392
+ if w_cm < 200 or l_cm < 200:
393
+ st.error("Las dimensiones de la habitación son demasiado pequeñas (mínimo 2x2m) o no fueron capturadas correctamente.")
394
+ else:
395
+ # 1. Inicializar motores
396
+ layout_engine = logic.LayoutEngine(st.session_state.data_manager.dimensiones_promedio)
397
+ recommender = logic.Recommender(st.session_state.muebles_df)
398
+
399
+ # 2. Sugerir el pack de muebles base
400
+ pack_sugerido = layout_engine.sugerir_pack(w_cm, l_cm)
401
+
402
+ # 3. Convertir obstáculos a polígonos para el motor
403
+ obs_layout = layout_engine.convertir_obstaculos(
404
+ st.session_state.room_data,
405
+ w_cm, l_cm,
406
+ polygon_points=st.session_state.room_data.get('polygon_points')
407
+ )
408
+
409
+ # 4. Generar el Layout
410
+ layout_plan, constraints, log_msgs = layout_engine.generar_layout(
411
+ w_cm, l_cm,
412
+ pack_sugerido,
413
+ obs_layout,
414
+ polygon_points=st.session_state.room_data.get('polygon_points')
415
+ )
416
+
417
+ # Mostrar Log de Generación
418
+ with st.expander("📝 Detalles de la Generación del Layout", expanded=False):
419
+ for msg in log_msgs:
420
+ if "✅" in msg: st.success(msg)
421
+ elif "❌" in msg: st.error(msg)
422
+ elif "⚠️" in msg: st.warning(msg)
423
+ else: st.text(msg)
424
+
425
+ if not layout_plan:
426
+ st.error("No se pudo generar una distribución válida para este espacio (demasiado pequeño o muchos obstáculos).")
427
+ else:
428
+ # 5. Recomendar productos (Knapsack para optimización de precio/estilo)
429
+ best_combo = recommender.buscar_combinacion(constraints, presupuesto, top_n=1)
430
+
431
+ if not best_combo:
432
+ st.error("No se encontraron muebles que se ajusten al presupuesto y restricciones.")
433
+ else:
434
+ st.session_state.result_layout = layout_plan
435
+ st.session_state.result_items = best_combo[0]['items']
436
+ st.session_state.result_total = best_combo[0]['precio_total']
437
+ st.session_state.result_score = best_combo[0]['score']
438
+ st.session_state.stage = 3
439
 
440
  # --- PASO 4: RESULTADOS Y VISUALIZACIÓN FINAL ---
441
  if st.session_state.stage == 3:
442
+ st.divider()
443
+ st.header("Tu salón ideal")
444
+
445
+ # --- VISUALIZACIÓN 3D Interactiva (Plotly) ---
446
+ st.subheader("Visualización 3D Interactiva")
447
+
448
+ # Generar la figura 3D
449
+ fig_plotly = logic.generar_figura_3d_plotly(
450
+ st.session_state.result_layout,
451
+ st.session_state.room_data,
452
+ st.session_state.result_items
453
+ )
454
+
455
+ # Renderizar la figura de Plotly
456
+ st.plotly_chart(fig_plotly, use_container_width=True, theme="streamlit")
457
+
458
+ st.info("���� Usa el ratón: Clic izquierdo para rotar, rueda para zoom.")
459
+
460
+ st.divider()
461
+
462
+ # --- LISTA DE COMPRA ---
463
+ st.subheader("Lista de Compra")
464
+
465
+ # Totales y Score de Diseño
466
+ c_tot1, c_tot2 = st.columns([2, 1])
467
+ with c_tot1:
468
+ st.markdown("### Total Estimado")
469
+ st.caption(f"Score de Diseño (Estilo + Puntuación Base): {st.session_state.result_score:.2f}/1.0")
470
+ with c_tot2:
471
+ st.markdown(f"### {st.session_state.result_total:.2f}€")
472
+
473
+ st.markdown("---")
474
+
475
+ # Listado de productos
476
+ for item in st.session_state.result_items:
477
+ with st.container():
478
+ c_img, c_info, c_price, c_link = st.columns([1, 2, 1, 1])
479
+
480
+ url = f"https://www.ikea.com/es/es/p/{item.get('Enlace_producto', '')}-{item.get('ID', '')}"
481
+ img_src = item.get('Imagen_principal', '')
482
+ nombre = item['Nombre']
483
+ tipo = item['Tipo_mueble']
484
+ precio = float(item['Precio'])
485
+
486
+ with c_img:
487
+ if img_src:
488
+ st.image(img_src, width=150)
489
+ else:
490
+ st.text("Sin imagen")
491
+
492
+ with c_info:
493
+ st.subheader(nombre)
494
+ st.caption(tipo)
495
+ st.text(item.get('Descripcion', '')[:100] + '...')
496
+
497
+ with c_price:
498
+ st.markdown(f"### {precio:.2f} €")
499
+
500
+ with c_link:
501
+ st.link_button("Ver en IKEA", url)
502
+
503
+ st.divider()
504
+
505
+ if st.button("Reiniciar"):
506
+ for key in ['room_data', 'result_layout', 'result_items', 'result_total', 'result_score']:
507
+ if key in st.session_state:
508
+ del st.session_state[key]
509
+ st.session_state.stage = 0
510
+ st.rerun()