agerhund commited on
Commit
42e443a
·
verified ·
1 Parent(s): 5222b98

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +427 -412
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,77 +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
-
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
 
140
  # --- INTERFAZ PRINCIPAL ---
141
  st.title("Recomendador de Muebles Inteligente")
@@ -144,52 +161,50 @@ st.markdown("Sube una panorámica, detecta el espacio y obtén el diseño ideal
144
  # --- PASO 1: CARGA DE IMAGEN Y DETECCIÓN ---
145
  st.header("1. Escaneo de Habitación")
146
 
147
- # --- LÓGICA DE SELECCIÓN DE EJEMPLOS EN EL CUERPO PRINCIPAL ---
148
  examples_dir = os.path.join(os.path.dirname(__file__), "examples")
149
  image_placeholder = st.empty() # Placeholder para la imagen (Estabiliza el temblor)
150
 
151
  if os.path.exists(examples_dir):
152
- example_files = [f for f in os.listdir(examples_dir) if f.endswith(('.jpg', '.png'))]
153
- example_files.sort()
154
-
155
- col_select, col_download = st.columns([3, 1])
156
-
157
- with col_select:
158
- # Añadimos la opción de carga manual como la principal
159
- display_files = ["--- Cargar imagen manualmente (prioridad) ---"] + example_files
160
-
161
- selected_example_name = st.selectbox(
162
- "O usa una imagen de ejemplo:",
163
- display_files,
164
- key='example_select_box'
165
- )
166
-
167
- # Manejo de la selección del ejemplo
168
- if selected_example_name != display_files[0]:
169
- file_path = os.path.join(examples_dir, selected_example_name)
170
- st.session_state.source_file_path = file_path
171
- st.session_state.is_example = True
172
-
173
- with col_download:
174
- st.markdown(" ")
175
- with open(file_path, "rb") as file:
176
- st.download_button(
177
- label=f"⬇️ Descargar: {selected_example_name}",
178
- data=file,
179
- file_name=selected_example_name,
180
- mime="image/jpeg"
181
- )
182
-
183
- # Mostrar miniatura del ejemplo en el placeholder
184
- with image_placeholder.container():
185
- st.image(file_path, caption="Vista previa del ejemplo", use_container_width=True)
186
-
187
- else:
188
- # Limpiar las variables si se selecciona la opción nula
189
- if 'source_file_path' in st.session_state:
190
- del st.session_state.source_file_path
191
- st.session_state.is_example = False
192
- image_placeholder.empty() # Limpiar el placeholder si no hay selección de ejemplo
193
 
194
 
195
  # 1. Selector de carga manual (Se mantiene aquí, pero solo para subir el archivo)
@@ -200,296 +215,296 @@ source_file_path = st.session_state.get('source_file_path', None)
200
  is_example = st.session_state.get('is_example', False)
201
 
202
  if uploaded_file is not None:
203
- source_file = uploaded_file
204
- file_caption = 'Imagen subida'
205
- is_example = False
206
- st.session_state.is_example = False # Resetear por seguridad
207
- image_placeholder.empty() # Limpiar el placeholder si hay una subida manual
208
  elif source_file_path is not None and is_example == True:
209
- source_file = source_file_path
210
- file_caption = f'Imagen de ejemplo: {source_file_path.split(os.sep)[-1]}'
211
  else:
212
- source_file = None
213
 
214
 
215
  if source_file is not None:
216
- # Abrir la imagen
217
- if is_example:
218
- image = Image.open(source_file)
219
- else:
220
- image = Image.open(source_file)
221
-
222
- # Mostrar la imagen subida (solo si es subida, ya que el ejemplo se mostró arriba)
223
- if uploaded_file is not None:
224
- with image_placeholder.container():
225
- st.image(image, caption=file_caption, use_container_width=True)
226
-
227
- if st.button("Analizar la habitación"):
228
- with st.spinner("Detectando la geometría..."):
229
-
230
- # --- MANEJO DEL ARCHIVO TEMPORAL PARA EL DETECTOR ---
231
- if is_example:
232
- temp_file_path = source_file
233
- else:
234
- temp_file_path = "temp_pano.jpg"
235
- with open(temp_file_path, "wb") as f:
236
- f.write(source_file.getbuffer())
237
- # --- FIN MANEJO ARCHIVO TEMPORAL ---
238
-
239
-
240
- # Instanciar y ejecutar el detector de layout (HorizonNet)
241
- try:
242
- detector = logic.RoomLayoutDetector(st.session_state.horizon_model_path)
243
- room_data = detector.detect_layout(temp_file_path)
244
-
245
- # Validación de datos y manejo de fallos
246
- if room_data is None or not isinstance(room_data, dict) or 'width' not in room_data:
247
- 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.")
248
- st.session_state.room_data = None
249
- st.session_state.stage = 0
250
- else:
251
- st.session_state.room_data = room_data
252
- st.session_state.stage = 1
253
-
254
- st.toast("Análisis completado", icon="✅")
255
-
256
- # Mostrar resultado de la detección de HorizonNet
257
- st.header("Resultado del análisis visual")
258
- annotated_image = logic.dibujar_layout_sobre_imagen(temp_file_path, room_data)
259
- st.image(annotated_image, caption='Análisis de HorizonNet (Vértices, Puertas y Ventanas)', use_container_width=True)
260
-
261
- except Exception as e:
262
- st.error(f"Error en detección: {e}. Revisa la configuración del modelo HorizonNet.")
263
- st.session_state.stage = 0
264
 
265
  # --- PASO 2: VERIFICACIÓN Y EDICIÓN DE GEOMETRÍA/OBSTÁCULOS ---
266
  if st.session_state.stage >= 1 and st.session_state.room_data:
267
- st.header("2. Verificación de Geometría")
268
-
269
- # Mostrar dimensiones detectadas
270
- w_m = st.session_state.room_data.get('width', 0.0)
271
- l_m = st.session_state.room_data.get('length', 0.0)
272
-
273
- col1, col2 = st.columns(2)
274
- with col1:
275
- st.metric("Ancho (m)", f"{w_m:.2f}")
276
- with col2:
277
- st.metric("Largo (m)", f"{l_m:.2f}")
278
-
279
- # Mostrar el diagrama de planta
280
- st.subheader("Planta de la habitación")
281
- floor_plan_fig = logic.generar_diagrama_planta(st.session_state.room_data)
282
- st.pyplot(floor_plan_fig)
283
-
284
- # Formulario para añadir puertas y ventanas manualmente
285
- st.subheader("Añadir puertas y ventanas manualmente")
286
-
287
- polygon_points = st.session_state.room_data.get('polygon_points', [])
288
- num_walls = len(polygon_points) if polygon_points is not None else 0
289
-
290
- if num_walls > 0:
291
- col_a, col_b, col_c, col_d = st.columns(4)
292
-
293
- with col_a:
294
- wall_options = [f"Pared {i+1}" for i in range(num_walls)]
295
- selected_wall = st.selectbox("Seleccionar Pared", wall_options, key="wall_select")
296
- wall_idx = int(selected_wall.split()[1]) - 1
297
-
298
- with col_b:
299
- obstacle_type = st.radio("Tipo", ["Puerta", "Ventana"], key="obs_type")
300
-
301
- with col_c:
302
- # Posición normalizada [0.0, 1.0]
303
- position_pct = st.number_input("Posición (%)", min_value=0.0, max_value=100.0, value=50.0, step=5.0, key="obs_pos")
304
-
305
- with col_d:
306
- width_m = st.number_input("Ancho (m)", min_value=0.1, max_value=5.0, value=0.9, step=0.1, key="obs_width")
307
-
308
- if st.button("Añadir elemento"):
309
- # Los datos de centro están normalizados
310
- new_obstacle = {
311
- 'center': [position_pct / 100.0, wall_idx / max(1, num_walls)],
312
- 'width': width_m
313
- }
314
-
315
- if obstacle_type == "Puerta":
316
- st.session_state.room_data['doors'].append(new_obstacle)
317
- else:
318
- st.session_state.room_data['windows'].append(new_obstacle)
319
-
320
- st.success(f"{obstacle_type} añadida a {selected_wall}")
321
- st.rerun()
322
- else:
323
- st.warning("No se detectaron paredes en el polígono.")
324
-
325
- # Editor de datos para modificar obstáculos detectados/añadidos
326
- st.subheader("Editar elementos (puertas y ventanas)")
327
- st.info("Ajusta las coordenadas de los obstáculos. Los valores X/Y están normalizados [0.0, 1.0].")
328
-
329
- # Preparar datos para st.data_editor
330
- doors_data = []
331
- for i, d in enumerate(st.session_state.room_data.get('doors', [])):
332
- center_y = d['center'][1] if len(d['center']) > 1 else 0
333
- doors_data.append({"ID": f"P{i}", "Tipo": "Puerta", "Centro X (Norm.)": d['center'][0], "Centro Y (Norm.)": center_y, "Ancho (m)": d['width']})
334
-
335
- windows_data = []
336
- for i, w in enumerate(st.session_state.room_data.get('windows', [])):
337
- center_y = w['center'][1] if len(w['center']) > 1 else 0
338
- windows_data.append({"ID": f"V{i}", "Tipo": "Ventana", "Centro X (Norm.)": w['center'][0], "Centro Y (Norm.)": center_y, "Ancho (m)": w['width']})
339
-
340
- all_obstacles = doors_data + windows_data
341
- df_obs = pd.DataFrame(all_obstacles)
342
-
343
- col_config = {
344
- "Centro X (Norm.)": st.column_config.NumberColumn("Centro X (Norm.)", help="Posición horizontal normalizada [0.0, 1.0]", format="%.2f"),
345
- "Centro Y (Norm.)": st.column_config.NumberColumn("Centro Y (Norm.)", help="Posición vertical normalizada [0.0, 1.0]", format="%.2f"),
346
- "Ancho (m)": st.column_config.NumberColumn("Ancho (m)", help="Ancho del obstáculo en metros", format="%.2f"),
347
- }
348
-
349
- edited_df = st.data_editor(df_obs, num_rows="dynamic", use_container_width=True, column_config=col_config)
350
-
351
- if st.button("Confirmar geometría"):
352
- # Reconstruir el diccionario room_data a partir del DataFrame editado
353
- new_doors = []
354
- new_windows = []
355
- for index, row in edited_df.iterrows():
356
- obj = {'center': [row['Centro X (Norm.)'], row['Centro Y (Norm.)']], 'width': row['Ancho (m)']}
357
- if row['Tipo'] == 'Puerta': new_doors.append(obj)
358
- else: new_windows.append(obj)
359
-
360
- st.session_state.room_data['doors'] = new_doors
361
- st.session_state.room_data['windows'] = new_windows
362
- st.session_state.stage = 2
363
- st.rerun()
364
 
365
  # --- PASO 3: PRESUPUESTO Y GENERACIÓN DE LAYOUT/RECOMENDACIÓN ---
366
  if st.session_state.stage >= 2:
367
- st.header("3. Presupuesto y generación")
368
-
369
- presupuesto = st.number_input("Presupuesto Máximo (€)", min_value=100.0, value=1000.0, step=100.0)
370
-
371
- if st.button("Generar diseño"):
372
- with st.spinner("Calculando distribución óptima y seleccionando muebles..."):
373
- # Convertir dimensiones de m a cm para el LayoutEngine
374
- w_cm = st.session_state.room_data.get('width', 0.0) * 100
375
- l_cm = st.session_state.room_data.get('length', 0.0) * 100
376
-
377
- if w_cm < 200 or l_cm < 200:
378
- st.error("Las dimensiones de la habitación son demasiado pequeñas (mínimo 2x2m) o no fueron capturadas correctamente.")
379
- else:
380
- # 1. Inicializar motores
381
- layout_engine = logic.LayoutEngine(st.session_state.data_manager.dimensiones_promedio)
382
- recommender = logic.Recommender(st.session_state.muebles_df)
383
-
384
- # 2. Sugerir el pack de muebles base
385
- pack_sugerido = layout_engine.sugerir_pack(w_cm, l_cm)
386
-
387
- # 3. Convertir obstáculos a polígonos para el motor
388
- obs_layout = layout_engine.convertir_obstaculos(
389
- st.session_state.room_data,
390
- w_cm, l_cm,
391
- polygon_points=st.session_state.room_data.get('polygon_points')
392
- )
393
-
394
- # 4. Generar el Layout
395
- layout_plan, constraints, log_msgs = layout_engine.generar_layout(
396
- w_cm, l_cm,
397
- pack_sugerido,
398
- obs_layout,
399
- polygon_points=st.session_state.room_data.get('polygon_points')
400
- )
401
-
402
- # Mostrar Log de Generación
403
- with st.expander("📝 Detalles de la Generación del Layout", expanded=False):
404
- for msg in log_msgs:
405
- if "✅" in msg: st.success(msg)
406
- elif "❌" in msg: st.error(msg)
407
- elif "⚠️" in msg: st.warning(msg)
408
- else: st.text(msg)
409
-
410
- if not layout_plan:
411
- st.error("No se pudo generar una distribución válida para este espacio (demasiado pequeño o muchos obstáculos).")
412
- else:
413
- # 5. Recomendar productos (Knapsack para optimización de precio/estilo)
414
- best_combo = recommender.buscar_combinacion(constraints, presupuesto, top_n=1)
415
-
416
- if not best_combo:
417
- st.error("No se encontraron muebles que se ajusten al presupuesto y restricciones.")
418
- else:
419
- st.session_state.result_layout = layout_plan
420
- st.session_state.result_items = best_combo[0]['items']
421
- st.session_state.result_total = best_combo[0]['precio_total']
422
- st.session_state.result_score = best_combo[0]['score']
423
- st.session_state.stage = 3
424
 
425
  # --- PASO 4: RESULTADOS Y VISUALIZACIÓN FINAL ---
426
  if st.session_state.stage == 3:
427
- st.divider()
428
- st.header("Tu salón ideal")
429
-
430
- # --- VISUALIZACIÓN 3D Interactiva (Plotly) ---
431
- st.subheader("Visualización 3D Interactiva")
432
-
433
- # Generar la figura 3D
434
- fig_plotly = logic.generar_figura_3d_plotly(
435
- st.session_state.result_layout,
436
- st.session_state.room_data,
437
- st.session_state.result_items
438
- )
439
-
440
- # Renderizar la figura de Plotly
441
- st.plotly_chart(fig_plotly, use_container_width=True, theme="streamlit")
442
-
443
- st.info("💡 Usa el ratón: Clic izquierdo para rotar, rueda para zoom.")
444
-
445
- st.divider()
446
-
447
- # --- LISTA DE COMPRA ---
448
- st.subheader("Lista de Compra")
449
-
450
- # Totales y Score de Diseño
451
- c_tot1, c_tot2 = st.columns([2, 1])
452
- with c_tot1:
453
- st.markdown("### Total Estimado")
454
- st.caption(f"Score de Diseño (Estilo + Puntuación Base): {st.session_state.result_score:.2f}/1.0")
455
- with c_tot2:
456
- st.markdown(f"### {st.session_state.result_total:.2f}€")
457
-
458
- st.markdown("---")
459
-
460
- # Listado de productos
461
- for item in st.session_state.result_items:
462
- with st.container():
463
- c_img, c_info, c_price, c_link = st.columns([1, 2, 1, 1])
464
-
465
- url = f"https://www.ikea.com/es/es/p/{item.get('Enlace_producto', '')}-{item.get('ID', '')}"
466
- img_src = item.get('Imagen_principal', '')
467
- nombre = item['Nombre']
468
- tipo = item['Tipo_mueble']
469
- precio = float(item['Precio'])
470
-
471
- with c_img:
472
- if img_src:
473
- st.image(img_src, width=150)
474
- else:
475
- st.text("Sin imagen")
476
-
477
- with c_info:
478
- st.subheader(nombre)
479
- st.caption(tipo)
480
- st.text(item.get('Descripcion', '')[:100] + '...')
481
-
482
- with c_price:
483
- st.markdown(f"### {precio:.2f} €")
484
-
485
- with c_link:
486
- st.link_button("Ver en IKEA", url)
487
-
488
- st.divider()
489
-
490
- if st.button("Reiniciar"):
491
- for key in ['room_data', 'result_layout', 'result_items', 'result_total', 'result_score']:
492
- if key in st.session_state:
493
- del st.session_state[key]
494
- st.session_state.stage = 0
495
- 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 = [] # 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")
 
161
  # --- PASO 1: CARGA DE IMAGEN Y DETECCIÓN ---
162
  st.header("1. Escaneo de Habitación")
163
 
164
+ # --- LÓGICA DE SELECCIÓN DE EJEMPLOS ESTABLE ---
165
  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
  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()