Haziel1357 commited on
Commit
a294091
·
verified ·
1 Parent(s): a7ff7be

Scripts, CSV de EMMa y guia de peso

Browse files
.gitattributes CHANGED
@@ -34,3 +34,4 @@ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  modelos_entrenados/models--facebook--sam3/snapshots/image.png filter=lfs diff=lfs merge=lfs -text
 
 
34
  *.zst filter=lfs diff=lfs merge=lfs -text
35
  *tfevents* filter=lfs diff=lfs merge=lfs -text
36
  modelos_entrenados/models--facebook--sam3/snapshots/image.png filter=lfs diff=lfs merge=lfs -text
37
+ datos/externos/emma_mat.csv filter=lfs diff=lfs merge=lfs -text
datos/externos/emma_mat.csv ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:5a5d59d31f118cc50905007780f77a93637eaa16bc12de0ab9d51f1e9c805ce0
3
+ size 37568651
datos/peso/caracteristicas.csv ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ foto,sesion,tipo_objeto,material,n_piezas,area_cm2_total,largo_cm_medio,ancho_cm_medio,largo_cm_max,peso_real_g,espesor_cm
2
+ img_001.jpg,s1,barra_acero_12mm,metal,1,120,100,1.2,101,888,1.2
3
+ img_002.jpg,s1,barra_acero_12mm,metal,2,240,100,1.2,101,1780,1.2
4
+ img_003.jpg,s1,barra_acero_12mm,metal,3,360,100,1.2,101,2650,1.2
5
+ img_004.jpg,s1,barra_acero_12mm,metal,5,600,100,1.2,101,4450,1.2
6
+ img_005.jpg,s2,barra_acero_12mm,metal,1,120,100,1.2,101,895,1.2
7
+ img_006.jpg,s2,barra_acero_12mm,metal,2,240,100,1.2,101,1770,1.2
8
+ img_007.jpg,s2,barra_acero_12mm,metal,3,360,100,1.2,101,2680,1.2
9
+ img_008.jpg,s2,barra_acero_12mm,metal,5,600,100,1.2,101,4430,1.2
10
+ img_009.jpg,s3,barra_acero_12mm,metal,1,120,100,1.2,101,885,1.2
11
+ img_010.jpg,s3,barra_acero_12mm,metal,2,240,100,1.2,101,1790,1.2
12
+ img_011.jpg,s3,barra_acero_12mm,metal,3,360,100,1.2,101,2660,1.2
13
+ img_012.jpg,s3,barra_acero_12mm,metal,5,600,100,1.2,101,4460,1.2
datos/peso/externo.csv ADDED
The diff for this file is too large to render. See raw diff
 
entrenamiento/README.md ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Entrenamiento
2
+
3
+ Crea `modelos_entrenados/material_estado/` (material + estado) y calibra el conteo de SAM 3.
4
+
5
+ ## Credenciales (una vez)
6
+
7
+ | Para | Qué hacer |
8
+ |---|---|
9
+ | Backbone DINOv3 y SAM 3 | Aceptar la licencia en [dinov3-convnext-tiny](https://huggingface.co/facebook/dinov3-convnext-tiny-pretrain-lvd1689m) y [sam3](https://huggingface.co/facebook/sam3); después `uv run hf auth login` |
10
+ | Datasets de Kaggle | Kaggle → Settings → API → *Create New Token*; guardar `kaggle.json` en `C:\Users\<usuario>\.kaggle\` |
11
+ | Datasets de Roboflow | Roboflow → Settings → API Keys; `setx ROBOFLOW_API_KEY "clave"` y abrir una terminal nueva |
12
+
13
+ Sin Kaggle o Roboflow el pipeline funciona igual con menos datos: esas fuentes se omiten
14
+ con aviso. Sin Roboflow no hay madera ni defectos, así que la nota de estado no se valida.
15
+
16
+ ## Pasos
17
+
18
+ ```bash
19
+ uv run python -m entrenamiento.descargar # 1. datasets → datos/crudos/ (minutos)
20
+ uv run python -m entrenamiento.preparar # 2. une, limpia y reparte (minutos)
21
+ uv run python -m entrenamiento.caracteristicas # 3. pasa cada foto por DINOv3 (horas en CPU, reanudable)
22
+ uv run python -m entrenamiento.entrenar # 4. entrena las 2 cabezas y exporta (minutos)
23
+ uv run python -m entrenamiento.evaluar_conteo # 5. mide y calibra el conteo de SAM 3 (~1 h)
24
+ ```
25
+
26
+ El paso 4 escribe `modelos_entrenados/material_estado/informe.md` con las métricas sobre el
27
+ conjunto de prueba (fotos que el modelo nunca vio). El paso 5 escribe
28
+ `modelos_entrenados/conteo/informe.md` y el umbral elegido.
29
+
30
+ ### Mejor precisión: ajuste fino completo en Colab (GPU gratis)
31
+
32
+ Los pasos 3-4 dejan el backbone congelado para que se pueda entrenar sin GPU. Ajustar
33
+ también el backbone suele mejorar varios puntos, sobre todo en defectos finos:
34
+
35
+ ```bash
36
+ uv run python -m entrenamiento.empaquetar_colab # → datos/revalora_colab.zip
37
+ ```
38
+
39
+ Abre [colab_entrenamiento.ipynb](colab_entrenamiento.ipynb) en Google Colab, sube el zip y
40
+ ejecuta las celdas. Descarga `material_estado.zip` y sustituye `modelos_entrenados/material_estado/`.
41
+
42
+ ## Datos
43
+
44
+ Las fuentes están en [fuentes.py](fuentes.py). **Solo se usan datasets con licencia que permite
45
+ uso comercial** (MIT, CC0, CC BY 4.0, ODbL); la licencia se comprueba al descargar y las fuentes
46
+ no comerciales se descartan. El resultado de cada fuente queda en `datos/procesados/fuentes.json`
47
+ y en la tabla final del informe.
48
+
49
+ | Tipo | Ejemplos | Qué enseña |
50
+ |---|---|---|
51
+ | Material | TrashNet, RealWaste, Garbage Classification | qué material es |
52
+ | Defectos | Roboflow: madera (Kodytek), óxido, telas, cajas dañadas, vidrio roto, abolladuras | qué defectos tiene |
53
+
54
+ Las etiquetas de cada dataset se traducen a nuestros defectos en [mapeo.py](mapeo.py)
55
+ ("Dead_Knot" → nudo, "rust" → óxido, "non-damaged" → sin defectos...). Cada imagen solo enseña
56
+ los defectos que su dataset etiqueta; el resto no cuenta.
57
+
58
+ Para **añadir fotos propias** (lo que más mejora el modelo con fotos reales de la plataforma):
59
+ crea una carpeta por material con las fotos dentro, añádela a `fuentes.py` como fuente de
60
+ material con `origen="url"` o cópiala a `datos/crudos/<nombre>/` con un archivo `.completo`, y
61
+ repite los pasos 2-4.
62
+
63
+ ## Decisiones
64
+
65
+ - **ConvNeXt-Tiny DINOv3** (Meta, 2025): misma red y velocidad que el ConvNeXt-Tiny original,
66
+ con mejores pesos de partida. Licencia DINOv3 (uso comercial permitido, sin usos militares).
67
+ - **384 px**: deja ver defectos pequeños; ~0.4 s por imagen en CPU.
68
+ - **Características globales + máximo local**: el máximo conserva un defecto pequeño que el
69
+ promedio diluiría.
70
+ - **Reparto por grupo**: las variantes aumentadas de una misma foto (Roboflow) nunca quedan a la
71
+ vez en entrenamiento y en prueba.
72
+ - **Defectos sin datos suficientes** (< 30 ejemplos con y sin él) no se usan en la nota.
entrenamiento/__init__.py ADDED
@@ -0,0 +1,8 @@
 
 
 
 
 
 
 
 
 
1
+ """Entrenamiento del modelo de material y estado. Ver entrenamiento/README.md."""
2
+
3
+ import sys
4
+
5
+ # La consola de Windows usa cp1252 y falla con tildes o flechas en los mensajes.
6
+ for _flujo in (sys.stdout, sys.stderr):
7
+ if hasattr(_flujo, "reconfigure"):
8
+ _flujo.reconfigure(encoding="utf-8", errors="replace")
entrenamiento/afinar_completo.py ADDED
@@ -0,0 +1,260 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Ajuste fino completo (backbone + cabezas). Pensado para una GPU (Google Colab).
2
+
3
+ python -m entrenamiento.afinar_completo # en Colab, ver colab_entrenamiento.ipynb
4
+
5
+ A diferencia de entrenar.py (backbone congelado, apto para CPU), aquí también se ajusta el
6
+ ConvNeXt, con una tasa de aprendizaje mucho menor que la de las cabezas para no "olvidar"
7
+ lo que aprendió DINOv3. Suele mejorar varios puntos, sobre todo en defectos finos.
8
+
9
+ - Parte de las cabezas de entrenar.py si existen (arranque en caliente).
10
+ - Época 1: solo cabezas. Después: toda la red.
11
+ - Mismas pérdidas, métricas, calibración, informe y formato de salida que entrenar.py, así
12
+ que el servicio carga el resultado igual.
13
+ """
14
+
15
+ import json
16
+ import math
17
+ import time
18
+ from datetime import date
19
+
20
+ import torch
21
+ import torch.nn.functional as F
22
+ from PIL import Image, ImageOps
23
+ from torch.utils.data import DataLoader, Dataset
24
+
25
+ from app.ml.estado.defectos import ORDEN_DEFECTOS
26
+ from app.ml.materiales import ORDEN_MATERIALES
27
+ from app.ml.vision.red_material_estado import (
28
+ CabezasMaterialEstado,
29
+ Metadatos,
30
+ RedMaterialEstado,
31
+ a_tensor,
32
+ cargar,
33
+ dim_caracteristicas,
34
+ guardar,
35
+ )
36
+ from entrenamiento.caracteristicas import AUMENTO
37
+ from entrenamiento.entrenar import (
38
+ DIM_OCULTA,
39
+ DROPOUT,
40
+ PESO_DEFECTOS,
41
+ SUAVIZADO,
42
+ Datos,
43
+ ajustar_temperatura,
44
+ defectos_entrenables,
45
+ escribir_informe,
46
+ estado_por_material,
47
+ metricas_defectos,
48
+ metricas_material,
49
+ peso_positivos,
50
+ pesos_material,
51
+ puntuacion,
52
+ )
53
+ from entrenamiento.fuentes import FUENTES
54
+ from entrenamiento.rutas import (
55
+ BACKBONE,
56
+ IMAGENES,
57
+ INFORME_FUENTES,
58
+ MANIFIESTO,
59
+ SALIDA_MODELO,
60
+ TAMANO_IMAGEN,
61
+ )
62
+
63
+ EPOCAS = 12
64
+ TAMANO_LOTE = 32
65
+ LR_BACKBONE = 2e-5
66
+ LR_CABEZAS = 5e-4
67
+ MATERIAL_DE_FUENTE = {f.nombre: f.material for f in FUENTES}
68
+
69
+
70
+ def etiquetas(registros: list[dict]) -> Datos:
71
+ n, k = len(registros), len(ORDEN_DEFECTOS)
72
+ material = torch.full((n,), -1, dtype=torch.long)
73
+ defectos, mascara = torch.zeros(n, k), torch.zeros(n, k, dtype=torch.bool)
74
+ for i, r in enumerate(registros):
75
+ if r["material"] is not None:
76
+ material[i] = ORDEN_MATERIALES.index(r["material"])
77
+ for codigo, valor in r["defectos"].items():
78
+ j = ORDEN_DEFECTOS.index(codigo)
79
+ defectos[i, j], mascara[i, j] = float(valor), True
80
+ material_estado = [r["material"] or MATERIAL_DE_FUENTE.get(r["fuente"]) for r in registros]
81
+ return Datos(
82
+ [r["id"] for r in registros], torch.empty(0), material, defectos, mascara, material_estado
83
+ )
84
+
85
+
86
+ class Fotos(Dataset):
87
+ def __init__(self, registros: list[dict], aumentar: bool) -> None:
88
+ self.registros, self.aumentar = registros, aumentar
89
+ self.etiquetas = etiquetas(registros)
90
+
91
+ def __len__(self) -> int:
92
+ return len(self.registros)
93
+
94
+ def __getitem__(self, i: int):
95
+ with Image.open(IMAGENES / f"{self.registros[i]['id']}.jpg") as imagen:
96
+ imagen = imagen.convert("RGB")
97
+ vistas = [AUMENTO(imagen)] if self.aumentar else [imagen, ImageOps.mirror(imagen)]
98
+ return (
99
+ a_tensor(vistas, TAMANO_IMAGEN),
100
+ self.etiquetas.material[i],
101
+ self.etiquetas.defectos[i],
102
+ self.etiquetas.mascara[i],
103
+ )
104
+
105
+
106
+ def crear_red() -> RedMaterialEstado:
107
+ from transformers import DINOv3ConvNextModel
108
+
109
+ backbone = DINOv3ConvNextModel.from_pretrained(BACKBONE)
110
+ dim = dim_caracteristicas(backbone.config)
111
+ cabezas = CabezasMaterialEstado(
112
+ dim, len(ORDEN_MATERIALES), len(ORDEN_DEFECTOS), DIM_OCULTA, DROPOUT
113
+ )
114
+ if (SALIDA_MODELO / "modelo.safetensors").is_file():
115
+ previa, _ = cargar(SALIDA_MODELO)
116
+ cabezas.load_state_dict(previa.cabezas.state_dict())
117
+ print("Arranque en caliente con las cabezas de entrenar.py")
118
+ return RedMaterialEstado(backbone, cabezas)
119
+
120
+
121
+ @torch.no_grad()
122
+ def predecir(red, cargador, dispositivo) -> tuple[torch.Tensor, torch.Tensor]:
123
+ red.eval()
124
+ logits_m, prob_d = [], []
125
+ for x, *_ in cargador:
126
+ b, v = x.shape[:2]
127
+ with torch.autocast(dispositivo.type, enabled=dispositivo.type == "cuda"):
128
+ lm, ld = red(x.flatten(0, 1).to(dispositivo))
129
+ logits_m.append(lm.float().reshape(b, v, -1).mean(1).cpu())
130
+ prob_d.append(ld.float().sigmoid().reshape(b, v, -1).mean(1).cpu())
131
+ return torch.cat(logits_m), torch.cat(prob_d)
132
+
133
+
134
+ def main() -> None:
135
+ dispositivo = torch.device("cuda" if torch.cuda.is_available() else "cpu")
136
+ print(f"Dispositivo: {dispositivo}")
137
+ registros = [json.loads(linea) for linea in MANIFIESTO.read_text(encoding="utf-8").splitlines()]
138
+ por_split = {
139
+ s: [r for r in registros if r["split"] == s]
140
+ for s in ("entrenamiento", "validacion", "prueba")
141
+ }
142
+ conjuntos = {s: Fotos(rs, aumentar=s == "entrenamiento") for s, rs in por_split.items()}
143
+ cargadores = {
144
+ s: DataLoader(
145
+ c,
146
+ batch_size=TAMANO_LOTE if s == "entrenamiento" else TAMANO_LOTE // 2,
147
+ shuffle=s == "entrenamiento",
148
+ num_workers=4,
149
+ pin_memory=dispositivo.type == "cuda",
150
+ drop_last=s == "entrenamiento",
151
+ )
152
+ for s, c in conjuntos.items()
153
+ }
154
+ entrenamiento = conjuntos["entrenamiento"].etiquetas
155
+ w_material = pesos_material(entrenamiento.material).to(dispositivo)
156
+ w_positivos = peso_positivos(entrenamiento).to(dispositivo)
157
+
158
+ red = crear_red().to(dispositivo)
159
+ optimizador = torch.optim.AdamW(
160
+ [
161
+ {"params": red.backbone.parameters(), "lr": LR_BACKBONE},
162
+ {"params": red.cabezas.parameters(), "lr": LR_CABEZAS},
163
+ ],
164
+ weight_decay=0.05,
165
+ )
166
+ pasos = EPOCAS * len(cargadores["entrenamiento"])
167
+ planificador = torch.optim.lr_scheduler.LambdaLR(
168
+ optimizador, lambda p: 0.5 * (1 + math.cos(math.pi * min(p, pasos) / pasos))
169
+ )
170
+ escalador = torch.amp.GradScaler(enabled=dispositivo.type == "cuda")
171
+
172
+ mejor, mejor_estado = -1.0, None
173
+ for epoca in range(1, EPOCAS + 1):
174
+ red.train()
175
+ red.backbone.requires_grad_(epoca > 1) # época 1: solo cabezas
176
+ inicio, total = time.time(), 0.0
177
+ for x, y_m, y_d, mascara in cargadores["entrenamiento"]:
178
+ x = x[:, 0].to(dispositivo, non_blocking=True)
179
+ y_m, y_d = y_m.to(dispositivo), y_d.to(dispositivo)
180
+ mascara = mascara.to(dispositivo).float()
181
+ with torch.autocast(dispositivo.type, enabled=dispositivo.type == "cuda"):
182
+ logits_m, logits_d = red(x)
183
+ logits_m, logits_d = logits_m.float(), logits_d.float()
184
+ validos = y_m >= 0
185
+ perdida_m = (
186
+ F.cross_entropy(
187
+ logits_m[validos],
188
+ y_m[validos],
189
+ weight=w_material,
190
+ label_smoothing=SUAVIZADO,
191
+ )
192
+ if validos.any()
193
+ else logits_m.sum() * 0
194
+ )
195
+ perdida_d = (
196
+ F.binary_cross_entropy_with_logits(
197
+ logits_d, y_d, pos_weight=w_positivos, reduction="none"
198
+ )
199
+ * mascara
200
+ ).sum() / mascara.sum().clamp(min=1)
201
+ perdida = perdida_m + PESO_DEFECTOS * perdida_d
202
+ optimizador.zero_grad(set_to_none=True)
203
+ escalador.scale(perdida).backward()
204
+ escalador.step(optimizador)
205
+ escalador.update()
206
+ planificador.step()
207
+ total += perdida.item()
208
+
209
+ logits_m, prob_d = predecir(red, cargadores["validacion"], dispositivo)
210
+ valor = puntuacion(logits_m, prob_d, conjuntos["validacion"].etiquetas)
211
+ print(
212
+ f"época {epoca:2d} pérdida {total / len(cargadores['entrenamiento']):.4f} "
213
+ f"validación {valor:.4f} ({time.time() - inicio:.0f} s)",
214
+ flush=True,
215
+ )
216
+ if valor > mejor:
217
+ mejor = valor
218
+ mejor_estado = {k: v.detach().cpu().clone() for k, v in red.state_dict().items()}
219
+
220
+ red.load_state_dict(mejor_estado)
221
+ red = red.cpu().eval()
222
+ dispositivo = torch.device("cpu")
223
+ logits_val, _ = predecir(red, cargadores["validacion"], dispositivo)
224
+ temperatura = ajustar_temperatura(logits_val, conjuntos["validacion"].etiquetas.material)
225
+
226
+ prueba = conjuntos["prueba"].etiquetas
227
+ logits_m, prob_d = predecir(red, cargadores["prueba"], dispositivo)
228
+ estado = estado_por_material(prob_d, prueba)
229
+ conteo = {s: len(c) for s, c in conjuntos.items()}
230
+ metadatos = Metadatos(
231
+ materiales=list(ORDEN_MATERIALES),
232
+ defectos=list(ORDEN_DEFECTOS),
233
+ tamano_imagen=TAMANO_IMAGEN,
234
+ dim_caracteristicas=dim_caracteristicas(red.backbone.config),
235
+ dim_oculta=DIM_OCULTA,
236
+ backbone_origen=f"{BACKBONE} (ajuste fino completo)",
237
+ temperatura_material=temperatura,
238
+ defectos_entrenados=defectos_entrenables(entrenamiento),
239
+ estado_validado_en=[m for m, e in estado.items() if e["validado"]],
240
+ version="2",
241
+ evaluacion={
242
+ "fecha": date.today().isoformat(),
243
+ "imagenes": conteo,
244
+ "material": metricas_material(logits_m, prueba.material, temperatura),
245
+ "defectos": metricas_defectos(prob_d, prueba),
246
+ "estado_por_material": estado,
247
+ "epocas": EPOCAS,
248
+ },
249
+ )
250
+ guardar(red, metadatos, SALIDA_MODELO)
251
+ fuentes = (
252
+ json.loads(INFORME_FUENTES.read_text(encoding="utf-8")) if INFORME_FUENTES.is_file() else {}
253
+ )
254
+ informe = escribir_informe(metadatos, conteo, fuentes)
255
+ (SALIDA_MODELO / "informe.md").write_text(informe, encoding="utf-8")
256
+ print(informe)
257
+
258
+
259
+ if __name__ == "__main__":
260
+ main()
entrenamiento/buscar_roboflow.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Busca datasets en Roboflow Universe para ampliar fuentes.py.
2
+
3
+ uv run python -m entrenamiento.buscar_roboflow "rust" "corrosion"
4
+ uv run python -m entrenamiento.buscar_roboflow --todo # todas las búsquedas predefinidas
5
+
6
+ Muestra, de mayor a menor número de imágenes, los proyectos con licencia de uso comercial,
7
+ con sus clases, para elegir cuáles añadir a fuentes.py. No descarga nada.
8
+ """
9
+
10
+ import sys
11
+ import time
12
+
13
+ import requests
14
+
15
+ from entrenamiento.fuentes import licencia_comercial
16
+ from entrenamiento.rutas import leer_env_local
17
+
18
+ URL = "https://api.roboflow.com/universe/search"
19
+ MIN_IMAGENES = 150
20
+
21
+ BUSQUEDAS = {
22
+ "óxido (metal)": ["rust", "corrosion", "rusty metal", "steel corrosion"],
23
+ "abolladuras/rayones (metal)": ["dent", "scratch metal", "metal surface defect"],
24
+ "madera (material)": ["wood plank", "timber", "scrap wood", "plywood", "wood pallet"],
25
+ "madera (defectos)": ["wood defect", "wood crack", "lumber defect"],
26
+ "tela (defectos)": ["fabric defect", "textile defect", "stain clothes", "torn clothes"],
27
+ "cartón": ["damaged box", "damaged package", "cardboard box", "wet cardboard"],
28
+ "vidrio": ["broken glass", "cracked glass", "glass bottle"],
29
+ "plástico": ["plastic bottle", "crushed plastic bottle", "plastic waste"],
30
+ "papel": ["paper waste", "crumpled paper", "newspaper"],
31
+ "otro": ["tire", "rubber waste", "e-waste", "ceramic tile defect"],
32
+ }
33
+
34
+
35
+ def buscar(consulta: str, clave: str, paginas: int = 2) -> list[dict]:
36
+ resultados = []
37
+ for pagina in range(1, paginas + 1):
38
+ for espera in (0, 5, 15, 40): # Roboflow limita las peticiones por minuto (429)
39
+ time.sleep(espera or 1)
40
+ respuesta = requests.get(
41
+ URL, params={"api_key": clave, "q": consulta, "page": pagina}, timeout=30
42
+ )
43
+ if respuesta.status_code != 429:
44
+ break
45
+ if respuesta.status_code != 200: # sin raise_for_status: la URL lleva la clave
46
+ print(f" (búsqueda '{consulta}' falló: {respuesta.status_code})")
47
+ break
48
+ lote = respuesta.json().get("results", [])
49
+ resultados += lote
50
+ if len(lote) < 12:
51
+ break
52
+ return resultados
53
+
54
+
55
+ def ref(proyecto: dict) -> str:
56
+ return proyecto["url"].removeprefix("https://universe.roboflow.com/")
57
+
58
+
59
+ def candidatos(consultas: list[str], clave: str) -> list[dict]:
60
+ vistos: dict[str, dict] = {}
61
+ for consulta in consultas:
62
+ for p in buscar(consulta, clave):
63
+ if (
64
+ licencia_comercial(p.get("license") or "")
65
+ and (p.get("images") or 0) >= MIN_IMAGENES
66
+ and p.get("latestVersion")
67
+ ):
68
+ vistos[ref(p)] = p
69
+ return sorted(vistos.values(), key=lambda p: p["images"], reverse=True)
70
+
71
+
72
+ def main(argumentos: list[str]) -> None:
73
+ clave = leer_env_local("ROBOFLOW_API_KEY")
74
+ if not clave:
75
+ raise SystemExit("Falta ROBOFLOW_API_KEY")
76
+ grupos = BUSQUEDAS if argumentos == ["--todo"] else {"búsqueda": argumentos}
77
+ for grupo, consultas in grupos.items():
78
+ print(f"\n## {grupo}")
79
+ for p in candidatos(consultas, clave)[:10]:
80
+ clases = ", ".join(p.get("classes", [])[:8])
81
+ print(f" {p['images']:6d} {p['type']:22} {ref(p):55} [{clases}]")
82
+
83
+
84
+ if __name__ == "__main__":
85
+ main(sys.argv[1:])
entrenamiento/caracteristicas.py ADDED
@@ -0,0 +1,123 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Extrae las características de cada imagen con el ConvNeXt-Tiny DINOv3 congelado.
2
+
3
+ uv run python -m entrenamiento.caracteristicas
4
+
5
+ Entrenar la red completa en CPU tardaría días. En su lugar, el backbone se deja
6
+ congelado (no se modifica) y cada imagen se pasa por él una sola vez; después solo se
7
+ entrenan las cabezas sobre esas características, lo que tarda minutos.
8
+
9
+ Vistas por imagen (cada una es un vector de 1536 números):
10
+ - validación y prueba: original + reflejo (lo mismo que hace el servicio al predecir).
11
+ - entrenamiento: original + VISTAS_AUMENTADAS versiones con recortes, giros y cambios de
12
+ luz y color, para que el modelo no dependa de un encuadre o iluminación concretos.
13
+
14
+ Es reanudable: guarda por bloques en datos/procesados/caracteristicas/ y, si se
15
+ interrumpe, continúa por el último bloque terminado. Con CPU, ~0.4 s por vista.
16
+ """
17
+
18
+ import json
19
+ import sys
20
+ import time
21
+
22
+ import torch
23
+ from PIL import Image, ImageOps
24
+ from torchvision.transforms import v2
25
+
26
+ from app.ml.vision.red_material_estado import a_tensor, resumir_tokens
27
+ from entrenamiento.rutas import BACKBONE, CARACTERISTICAS, IMAGENES, MANIFIESTO, TAMANO_IMAGEN
28
+
29
+ VISTAS_AUMENTADAS = 2
30
+ TAMANO_BLOQUE = 256
31
+ TAMANO_LOTE = 16
32
+ SPLITS = ("validacion", "prueba", "entrenamiento")
33
+
34
+ AUMENTO = v2.Compose(
35
+ [
36
+ v2.RandomResizedCrop(TAMANO_IMAGEN, scale=(0.45, 1.0), ratio=(0.75, 1.33), antialias=True),
37
+ v2.RandomHorizontalFlip(),
38
+ v2.RandomVerticalFlip(p=0.2),
39
+ v2.RandomApply([v2.RandomRotation(20)], p=0.4),
40
+ v2.ColorJitter(brightness=0.35, contrast=0.35, saturation=0.3, hue=0.03),
41
+ v2.RandomGrayscale(p=0.05),
42
+ v2.RandomApply([v2.GaussianBlur(5, sigma=(0.1, 1.5))], p=0.2),
43
+ ]
44
+ )
45
+
46
+
47
+ def vistas(imagen: Image.Image, aumentar: bool) -> list[Image.Image]:
48
+ lista = [imagen, ImageOps.mirror(imagen)] if not aumentar else [imagen]
49
+ if aumentar:
50
+ lista += [AUMENTO(imagen) for _ in range(VISTAS_AUMENTADAS)]
51
+ return lista
52
+
53
+
54
+ def cargar_backbone():
55
+ from transformers import DINOv3ConvNextModel
56
+
57
+ try:
58
+ return DINOv3ConvNextModel.from_pretrained(BACKBONE).eval()
59
+ except OSError as exc:
60
+ raise SystemExit(
61
+ f"No se pudo descargar {BACKBONE}: {exc}\n"
62
+ "Pide acceso en https://huggingface.co/" + BACKBONE + " y ejecuta "
63
+ "`uv run hf auth login`."
64
+ ) from exc
65
+
66
+
67
+ @torch.inference_mode()
68
+ def extraer(backbone, imagenes: list[Image.Image]) -> torch.Tensor:
69
+ salidas = []
70
+ for inicio in range(0, len(imagenes), TAMANO_LOTE):
71
+ lote = a_tensor(imagenes[inicio : inicio + TAMANO_LOTE], TAMANO_IMAGEN)
72
+ salidas.append(resumir_tokens(backbone(pixel_values=lote).last_hidden_state))
73
+ return torch.cat(salidas)
74
+
75
+
76
+ def _cache(split: str) -> dict[str, torch.Tensor]:
77
+ """Características ya extraídas de este reparto, por id de imagen."""
78
+ cache: dict[str, torch.Tensor] = {}
79
+ for archivo in sorted(CARACTERISTICAS.glob(f"cache_{split}_*.pt")):
80
+ parte = torch.load(archivo)
81
+ cache.update(zip(parte["ids"], parte["x"], strict=True))
82
+ return cache
83
+
84
+
85
+ def procesar_split(backbone, split: str, ids: list[str]) -> None:
86
+ aumentar = split == "entrenamiento"
87
+ n_vistas = 1 + VISTAS_AUMENTADAS if aumentar else 2
88
+ cache = _cache(split)
89
+ pendientes = [i for i in ids if i not in cache]
90
+ print(f"{split}: {len(ids)} imágenes, {len(pendientes)} por extraer", flush=True)
91
+ inicio = time.time()
92
+ siguiente = len(list(CARACTERISTICAS.glob(f"cache_{split}_*.pt")))
93
+ for numero in range(0, len(pendientes), TAMANO_BLOQUE):
94
+ bloque = pendientes[numero : numero + TAMANO_BLOQUE]
95
+ torch.manual_seed(int(bloque[0], 16) % 2**31) # aumentos reproducibles
96
+ imagenes = []
97
+ for id_ in bloque:
98
+ with Image.open(IMAGENES / f"{id_}.jpg") as imagen:
99
+ imagenes += vistas(imagen.convert("RGB"), aumentar)
100
+ x = extraer(backbone, imagenes).reshape(len(bloque), n_vistas, -1).half()
101
+ torch.save({"ids": bloque, "x": x}, CARACTERISTICAS / f"cache_{split}_{siguiente:04d}.pt")
102
+ siguiente += 1
103
+ cache.update(zip(bloque, x, strict=True))
104
+ hechos = min(numero + TAMANO_BLOQUE, len(pendientes))
105
+ restante = (time.time() - inicio) / hechos * (len(pendientes) - hechos)
106
+ print(f"{split}: {hechos}/{len(pendientes)} (quedan ~{restante / 60:.0f} min)", flush=True)
107
+ torch.save(
108
+ {"ids": ids, "x": torch.stack([cache[i] for i in ids])}, CARACTERISTICAS / f"{split}.pt"
109
+ )
110
+ print(f"{split}: listo → {CARACTERISTICAS / f'{split}.pt'}")
111
+
112
+
113
+ def main(splits: list[str]) -> None:
114
+ CARACTERISTICAS.mkdir(parents=True, exist_ok=True)
115
+ registros = [json.loads(linea) for linea in MANIFIESTO.read_text(encoding="utf-8").splitlines()]
116
+ backbone = cargar_backbone()
117
+ torch.set_num_threads(max(1, torch.get_num_threads()))
118
+ for split in splits or SPLITS:
119
+ procesar_split(backbone, split, [r["id"] for r in registros if r["split"] == split])
120
+
121
+
122
+ if __name__ == "__main__":
123
+ main(sys.argv[1:])
entrenamiento/colab_entrenamiento.ipynb ADDED
@@ -0,0 +1,137 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "cells": [
3
+ {
4
+ "cell_type": "markdown",
5
+ "metadata": {},
6
+ "source": [
7
+ "# REVALORA · Ajuste fino completo del modelo de material y estado\n",
8
+ "\n",
9
+ "Entrena el ConvNeXt-Tiny DINOv3 completo (backbone + 2 cabezas) con GPU gratuita.\n",
10
+ "\n",
11
+ "**Antes**, en tu equipo: `uv run python -m entrenamiento.empaquetar_colab` → genera `datos/revalora_colab.zip`.\n",
12
+ "\n",
13
+ "**Aquí**: *Entorno de ejecución → Cambiar tipo de entorno → GPU (T4)* y ejecuta las celdas en orden.\n",
14
+ "Tarda ~1-2 h con unas 25 000 imágenes. Al final descarga `material_estado.zip` y descomprímelo en\n",
15
+ "`revalora-ia-service/modelos_entrenados/` (sustituye la carpeta `material_estado`)."
16
+ ]
17
+ },
18
+ {
19
+ "cell_type": "code",
20
+ "execution_count": null,
21
+ "metadata": {},
22
+ "outputs": [],
23
+ "source": [
24
+ "!nvidia-smi --query-gpu=name,memory.total --format=csv"
25
+ ]
26
+ },
27
+ {
28
+ "cell_type": "markdown",
29
+ "metadata": {},
30
+ "source": [
31
+ "## 1. Sube el paquete\n",
32
+ "Opción A (recomendada si pesa más de 500 MB): súbelo a tu Google Drive y monta Drive.\n",
33
+ "Opción B: súbelo directamente (más lento)."
34
+ ]
35
+ },
36
+ {
37
+ "cell_type": "code",
38
+ "execution_count": null,
39
+ "metadata": {},
40
+ "outputs": [],
41
+ "source": [
42
+ "import os\n",
43
+ "\n",
44
+ "USAR_DRIVE = True # False para subirlo directamente\n",
45
+ "\n",
46
+ "if USAR_DRIVE:\n",
47
+ " from google.colab import drive\n",
48
+ "\n",
49
+ " drive.mount(\"/content/drive\")\n",
50
+ " ZIP = (\n",
51
+ " \"/content/drive/MyDrive/revalora_colab.zip\" # ajusta la ruta si lo pusiste en otra carpeta\n",
52
+ " )\n",
53
+ "else:\n",
54
+ " from google.colab import files\n",
55
+ "\n",
56
+ " files.upload()\n",
57
+ " ZIP = \"/content/revalora_colab.zip\"\n",
58
+ "\n",
59
+ "!unzip -q -o \"$ZIP\" -d /content/revalora\n",
60
+ "os.chdir(\"/content/revalora\")\n",
61
+ "!ls"
62
+ ]
63
+ },
64
+ {
65
+ "cell_type": "markdown",
66
+ "metadata": {},
67
+ "source": [
68
+ "## 2. Dependencias y acceso a DINOv3\n",
69
+ "El modelo base tiene acceso restringido: usa un token de Hugging Face de una cuenta que ya tenga acceso a\n",
70
+ "`facebook/dinov3-convnext-tiny-pretrain-lvd1689m`."
71
+ ]
72
+ },
73
+ {
74
+ "cell_type": "code",
75
+ "execution_count": null,
76
+ "metadata": {},
77
+ "outputs": [],
78
+ "source": [
79
+ "!pip install -q \"transformers>=5.0\" safetensors pydantic-settings scikit-learn\n",
80
+ "from huggingface_hub import notebook_login\n",
81
+ "\n",
82
+ "notebook_login()"
83
+ ]
84
+ },
85
+ {
86
+ "cell_type": "markdown",
87
+ "metadata": {},
88
+ "source": [
89
+ "## 3. Entrenamiento\n",
90
+ "Muestra la pérdida y la puntuación de validación por época, y al final el informe con las métricas del conjunto de prueba."
91
+ ]
92
+ },
93
+ {
94
+ "cell_type": "code",
95
+ "execution_count": null,
96
+ "metadata": {},
97
+ "outputs": [],
98
+ "source": [
99
+ "!python -m entrenamiento.afinar_completo"
100
+ ]
101
+ },
102
+ {
103
+ "cell_type": "markdown",
104
+ "metadata": {},
105
+ "source": [
106
+ "## 4. Descarga el modelo"
107
+ ]
108
+ },
109
+ {
110
+ "cell_type": "code",
111
+ "execution_count": null,
112
+ "metadata": {},
113
+ "outputs": [],
114
+ "source": [
115
+ "!cd modelos_entrenados && zip -q -r /content/material_estado.zip material_estado\n",
116
+ "from google.colab import files\n",
117
+ "\n",
118
+ "files.download(\"/content/material_estado.zip\")"
119
+ ]
120
+ }
121
+ ],
122
+ "metadata": {
123
+ "accelerator": "GPU",
124
+ "colab": {
125
+ "provenance": []
126
+ },
127
+ "kernelspec": {
128
+ "display_name": "Python 3",
129
+ "name": "python3"
130
+ },
131
+ "language_info": {
132
+ "name": "python"
133
+ }
134
+ },
135
+ "nbformat": 4,
136
+ "nbformat_minor": 0
137
+ }
entrenamiento/descargar.py ADDED
@@ -0,0 +1,230 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Descarga los datasets de fuentes.py en datos/crudos/<nombre>/.
2
+
3
+ uv run python -m entrenamiento.descargar # todas las fuentes
4
+ uv run python -m entrenamiento.descargar trashnet # solo algunas
5
+
6
+ Es reanudable: una fuente ya descargada (con el archivo .completo) no se vuelve a bajar.
7
+ Las fuentes que necesitan credenciales que no están configuradas se omiten con aviso:
8
+ - Kaggle: C:\\Users\\<usuario>\\.kaggle\\kaggle.json (o KAGGLE_USERNAME/KAGGLE_KEY)
9
+ - Roboflow: variable ROBOFLOW_API_KEY
10
+ - Hugging Face: `uv run hf auth login` (solo para el modelo DINOv3, no para los datos)
11
+ Al terminar escribe datos/procesados/fuentes.json con el resultado de cada fuente.
12
+ """
13
+
14
+ import json
15
+ import os
16
+ import re
17
+ import shutil
18
+ import sys
19
+ import time
20
+ import zipfile
21
+ from dataclasses import asdict
22
+ from pathlib import Path
23
+
24
+ import requests
25
+
26
+ from entrenamiento.fuentes import FUENTES, Fuente, licencia_comercial
27
+ from entrenamiento.rutas import CRUDOS, INFORME_FUENTES, asegurar_carpetas, leer_env_local
28
+
29
+ MARCA = ".completo"
30
+ API_ROBOFLOW = "https://api.roboflow.com"
31
+
32
+
33
+ class FuenteOmitida(Exception):
34
+ """La fuente no se puede usar (credenciales, licencia, no existe...)."""
35
+
36
+
37
+ def _descargar_url(url: str, destino: Path) -> Path:
38
+ destino.parent.mkdir(parents=True, exist_ok=True)
39
+ temporal = destino.with_suffix(destino.suffix + ".parcial")
40
+ with requests.get(url, stream=True, timeout=60) as respuesta:
41
+ respuesta.raise_for_status()
42
+ with temporal.open("wb") as archivo:
43
+ for bloque in respuesta.iter_content(chunk_size=1 << 20):
44
+ archivo.write(bloque)
45
+ temporal.replace(destino)
46
+ return destino
47
+
48
+
49
+ def _extraer(zip_path: Path, carpeta: Path) -> None:
50
+ with zipfile.ZipFile(zip_path) as zf:
51
+ zf.extractall(carpeta)
52
+ zip_path.unlink()
53
+ # Algunos zips traen otros zips dentro (TrashNet en HF).
54
+ for interno in list(carpeta.rglob("*.zip")):
55
+ _extraer(interno, interno.parent)
56
+
57
+
58
+ # ----------------------------------------------------------------------------- orígenes
59
+
60
+
61
+ def _hf(fuente: Fuente, carpeta: Path) -> dict:
62
+ from huggingface_hub import hf_hub_download
63
+
64
+ ruta = hf_hub_download(
65
+ fuente.ref, fuente.archivo, repo_type="dataset", local_dir=str(carpeta / "_hf")
66
+ )
67
+ _extraer(Path(ruta), carpeta)
68
+ shutil.rmtree(carpeta / "_hf", ignore_errors=True)
69
+ return {"licencia": fuente.licencia}
70
+
71
+
72
+ def _url(fuente: Fuente, carpeta: Path) -> dict:
73
+ _extraer(_descargar_url(fuente.ref, carpeta / "descarga.zip"), carpeta)
74
+ return {"licencia": fuente.licencia}
75
+
76
+
77
+ def _kaggle(fuente: Fuente, carpeta: Path) -> dict:
78
+ # La librería lee las variables del proceso; si se guardaron con `setx` y la terminal
79
+ # no se reinició, no las ve: se copian desde las variables de usuario de Windows.
80
+ for variable in ("KAGGLE_API_TOKEN", "KAGGLE_USERNAME", "KAGGLE_KEY"):
81
+ valor = leer_env_local(variable)
82
+ if valor and not os.environ.get(variable):
83
+ os.environ[variable] = valor
84
+ carpeta_kaggle = Path.home() / ".kaggle"
85
+ if not (
86
+ (carpeta_kaggle / "kaggle.json").is_file()
87
+ or (carpeta_kaggle / "access_token").is_file()
88
+ or os.environ.get("KAGGLE_API_TOKEN")
89
+ or (os.environ.get("KAGGLE_USERNAME") and os.environ.get("KAGGLE_KEY"))
90
+ ):
91
+ raise FuenteOmitida("faltan las credenciales de Kaggle (KAGGLE_API_TOKEN o kaggle.json)")
92
+ from kaggle.api.kaggle_api_extended import KaggleApi
93
+
94
+ api = KaggleApi()
95
+ api.authenticate()
96
+ # El nombre completo de la licencia ("Database: Open Database, Contents: © Original
97
+ # Authors") distingue la licencia de la base de datos de la de las fotos.
98
+ propietario, nombre = fuente.ref.split("/")
99
+ licencia = next(
100
+ (
101
+ d.license_name
102
+ for d in api.dataset_list(search=nombre, user=propietario)
103
+ if d.ref == fuente.ref
104
+ ),
105
+ None,
106
+ )
107
+ if not licencia:
108
+ api.dataset_metadata(fuente.ref, path=str(carpeta))
109
+ meta = json.loads((carpeta / "dataset-metadata.json").read_text(encoding="utf-8"))
110
+ info = meta.get("info", meta)
111
+ licencia = ", ".join(lic.get("name", "") for lic in info.get("licenses", []))
112
+ licencia = licencia or "desconocida"
113
+ if not licencia_comercial(licencia):
114
+ raise FuenteOmitida(f"licencia no compatible con uso comercial: {licencia}")
115
+ api.dataset_download_files(fuente.ref, path=str(carpeta), unzip=True, quiet=True)
116
+ return {"licencia": licencia}
117
+
118
+
119
+ def _licencia_roboflow(info: dict, ref: str) -> str:
120
+ for clave in ("license", "licence"):
121
+ if isinstance(info.get("project", {}).get(clave), str):
122
+ return info["project"][clave]
123
+ # La API no siempre la incluye: se lee de la página pública del proyecto.
124
+ html = requests.get(f"https://universe.roboflow.com/{ref}", timeout=30).text
125
+ encontrada = re.search(
126
+ r"(CC BY[- ](?:NC[- ])?(?:SA[- ])?4\.0|CC0[^<\"]{0,12}|Public Domain|MIT|"
127
+ r"BY-NC-SA 4\.0|ODbL[^<\"]{0,8}|Private)",
128
+ html,
129
+ )
130
+ return encontrada.group(1) if encontrada else "desconocida"
131
+
132
+
133
+ def _get_roboflow(url: str, clave: str, timeout: int = 60) -> requests.Response:
134
+ """GET con reintentos si Roboflow limita las peticiones (429)."""
135
+ for espera in (0, 10, 30, 60, 120):
136
+ time.sleep(espera)
137
+ respuesta = requests.get(url, params={"api_key": clave}, timeout=timeout)
138
+ if respuesta.status_code != 429:
139
+ return respuesta
140
+ return respuesta
141
+
142
+
143
+ def _roboflow(fuente: Fuente, carpeta: Path) -> dict:
144
+ clave = leer_env_local("ROBOFLOW_API_KEY")
145
+ if not clave:
146
+ raise FuenteOmitida("falta la variable ROBOFLOW_API_KEY")
147
+ respuesta = _get_roboflow(f"{API_ROBOFLOW}/{fuente.ref}", clave)
148
+ if respuesta.status_code in (401, 403, 404):
149
+ raise FuenteOmitida(f"el proyecto no existe o no es público ({respuesta.status_code})")
150
+ if respuesta.status_code != 200: # sin raise_for_status: su mensaje incluye la clave
151
+ raise FuenteOmitida(f"Roboflow respondió {respuesta.status_code}")
152
+ info = respuesta.json()
153
+ licencia = _licencia_roboflow(info, fuente.ref)
154
+ if not licencia_comercial(licencia):
155
+ raise FuenteOmitida(f"licencia no compatible con uso comercial: {licencia}")
156
+
157
+ proyecto = info.get("project", {})
158
+ versiones = sorted(
159
+ info.get("versions", []), key=lambda v: int(v["id"].rsplit("/", 1)[1]), reverse=True
160
+ )
161
+ if not versiones:
162
+ raise FuenteOmitida("el proyecto no tiene versiones publicadas")
163
+ tipo = proyecto.get("type", "object-detection")
164
+ formato = "folder" if tipo == "classification" else "coco"
165
+ version = versiones[0]["id"].rsplit("/", 1)[1]
166
+
167
+ url_export = f"{API_ROBOFLOW}/{fuente.ref}/{version}/{formato}"
168
+ for _ in range(60): # Roboflow genera el export bajo demanda
169
+ datos = _get_roboflow(url_export, clave, timeout=120).json()
170
+ enlace = datos.get("export", {}).get("link")
171
+ if enlace:
172
+ break
173
+ time.sleep(5)
174
+ else:
175
+ raise FuenteOmitida("Roboflow no terminó de generar la exportación")
176
+ _extraer(_descargar_url(enlace, carpeta / "descarga.zip"), carpeta)
177
+ return {
178
+ "licencia": licencia,
179
+ "tipo": tipo,
180
+ "formato": formato,
181
+ "version": version,
182
+ "clases": list(proyecto.get("classes", {}).keys()),
183
+ "imagenes": versiones[0].get("images"),
184
+ }
185
+
186
+
187
+ ORIGENES = {"hf": _hf, "url": _url, "kaggle": _kaggle, "roboflow": _roboflow}
188
+
189
+
190
+ def descargar(fuente: Fuente) -> dict:
191
+ carpeta = CRUDOS / fuente.nombre
192
+ marca = carpeta / MARCA
193
+ if marca.is_file():
194
+ return {"estado": "descargada", **json.loads(marca.read_text(encoding="utf-8"))}
195
+ shutil.rmtree(carpeta, ignore_errors=True)
196
+ carpeta.mkdir(parents=True, exist_ok=True)
197
+ try:
198
+ meta = ORIGENES[fuente.origen](fuente, carpeta)
199
+ except FuenteOmitida as exc:
200
+ shutil.rmtree(carpeta, ignore_errors=True)
201
+ return {"estado": "omitida", "motivo": str(exc)}
202
+ except Exception as exc: # red, formato inesperado...
203
+ shutil.rmtree(carpeta, ignore_errors=True)
204
+ # Las URLs de Roboflow llevan la clave: nunca se guardan ni se muestran.
205
+ motivo = re.sub(r"api_key=[^&\s]+", "api_key=***", f"{type(exc).__name__}: {exc}")
206
+ return {"estado": "error", "motivo": motivo}
207
+ marca.write_text(json.dumps(meta, ensure_ascii=False), encoding="utf-8")
208
+ return {"estado": "descargada", **meta}
209
+
210
+
211
+ def main(nombres: list[str]) -> None:
212
+ asegurar_carpetas()
213
+ informe = json.loads(INFORME_FUENTES.read_text("utf-8")) if INFORME_FUENTES.is_file() else {}
214
+ for fuente in FUENTES:
215
+ if nombres and fuente.nombre not in nombres:
216
+ continue
217
+ print(f"- {fuente.nombre} ({fuente.origen}: {fuente.ref}) ...", flush=True)
218
+ resultado = descargar(fuente)
219
+ informe[fuente.nombre] = {**asdict(fuente), **resultado}
220
+ print(f" {resultado['estado']}: {resultado.get('motivo', resultado.get('licencia'))}")
221
+ INFORME_FUENTES.write_text(json.dumps(informe, ensure_ascii=False, indent=2), "utf-8")
222
+ resumen = {
223
+ e: sum(1 for r in informe.values() if r["estado"] == e)
224
+ for e in ("descargada", "omitida", "error")
225
+ }
226
+ print(f"\nResumen: {resumen}. Detalle en {INFORME_FUENTES}")
227
+
228
+
229
+ if __name__ == "__main__":
230
+ main(sys.argv[1:])
entrenamiento/empaquetar_colab.py ADDED
@@ -0,0 +1,40 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Empaqueta código y datos para entrenar en Google Colab.
2
+
3
+ uv run python -m entrenamiento.empaquetar_colab
4
+
5
+ Crea datos/revalora_colab.zip con:
6
+ - app/ y entrenamiento/ (el código, para usar exactamente las mismas funciones),
7
+ - datos/procesados/ (manifiesto + imágenes ya reducidas a 512 px),
8
+ - modelos_entrenados/material_estado/ si existe (arranque en caliente).
9
+ Súbelo a Colab y sigue entrenamiento/colab_entrenamiento.ipynb.
10
+ """
11
+
12
+ import zipfile
13
+
14
+ from app.core.config import BASE_DIR
15
+ from entrenamiento.rutas import DATOS, IMAGENES, INFORME_FUENTES, MANIFIESTO, SALIDA_MODELO
16
+
17
+ DESTINO = DATOS / "revalora_colab.zip"
18
+
19
+
20
+ def main() -> None:
21
+ if not MANIFIESTO.is_file():
22
+ raise SystemExit("Ejecuta antes: python -m entrenamiento.preparar")
23
+ archivos = [
24
+ *(BASE_DIR / "app").rglob("*.py"),
25
+ *(BASE_DIR / "app").rglob("*.json"),
26
+ *(BASE_DIR / "entrenamiento").rglob("*.py"),
27
+ MANIFIESTO,
28
+ *([INFORME_FUENTES] if INFORME_FUENTES.is_file() else []),
29
+ *IMAGENES.glob("*.jpg"),
30
+ *(SALIDA_MODELO.glob("*") if SALIDA_MODELO.is_dir() else []),
31
+ ]
32
+ with zipfile.ZipFile(DESTINO, "w", zipfile.ZIP_STORED) as zf:
33
+ for archivo in archivos:
34
+ if "__pycache__" not in archivo.parts:
35
+ zf.write(archivo, archivo.relative_to(BASE_DIR).as_posix())
36
+ print(f"{DESTINO} ({DESTINO.stat().st_size / 1e6:.0f} MB, {len(archivos)} archivos)")
37
+
38
+
39
+ if __name__ == "__main__":
40
+ main()
entrenamiento/entrenar.py ADDED
@@ -0,0 +1,460 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Entrena las dos cabezas (material y defectos), las evalúa y exporta el modelo.
2
+
3
+ uv run python -m entrenamiento.entrenar
4
+
5
+ Requiere haber ejecutado antes descargar → preparar → caracteristicas.
6
+
7
+ - Cabeza de material: entropía cruzada con pesos por clase (las clases con menos fotos
8
+ pesan más) y suavizado de etiquetas.
9
+ - Cabeza de defectos: entropía cruzada binaria con máscara: cada imagen solo enseña los
10
+ defectos que su dataset etiqueta; el resto no cuenta (ni a favor ni en contra).
11
+ - En cada época se usa una vista aleatoria de cada imagen (original o aumentada).
12
+ - Se queda con la época que mejor puntúa en validación y para si deja de mejorar.
13
+ - Calibra la confianza del material (temperatura) con la validación.
14
+ - Evalúa con el conjunto de prueba, que no se usó para nada antes, y escribe el informe.
15
+ """
16
+
17
+ import json
18
+ import math
19
+ from dataclasses import dataclass
20
+ from datetime import date
21
+
22
+ import numpy as np
23
+ import torch
24
+ import torch.nn.functional as F
25
+ from sklearn.metrics import (
26
+ accuracy_score,
27
+ average_precision_score,
28
+ confusion_matrix,
29
+ f1_score,
30
+ precision_recall_fscore_support,
31
+ roc_auc_score,
32
+ )
33
+
34
+ from app.ml.estado.defectos import GRAVEDAD, ORDEN_DEFECTOS
35
+ from app.ml.materiales import ORDEN_MATERIALES
36
+ from app.ml.vision.red_material_estado import (
37
+ CabezasMaterialEstado,
38
+ Metadatos,
39
+ RedMaterialEstado,
40
+ guardar,
41
+ )
42
+ from entrenamiento.fuentes import FUENTES
43
+ from entrenamiento.rutas import (
44
+ BACKBONE,
45
+ CARACTERISTICAS,
46
+ INFORME_FUENTES,
47
+ MANIFIESTO,
48
+ SALIDA_MODELO,
49
+ TAMANO_IMAGEN,
50
+ )
51
+
52
+ DIM_OCULTA = 512
53
+ DROPOUT = 0.3
54
+ EPOCAS = 150
55
+ TAMANO_LOTE = 256
56
+ TASA_APRENDIZAJE = 1e-3
57
+ PACIENCIA = 25
58
+ PESO_DEFECTOS = 1.0
59
+ SUAVIZADO = 0.1
60
+ # Para dar por "validada" la nota de estado de un material en el conjunto de prueba:
61
+ MIN_MUESTRAS_ESTADO = 30
62
+ MIN_POR_CLASE_AUC = 5
63
+ # Un defecto se usa en el servicio solo si tuvo al menos esto de ejemplos con y sin él.
64
+ MIN_EJEMPLOS_DEFECTO = 30
65
+ AUC_MINIMA_ESTADO = 0.80
66
+
67
+ MATERIAL_DE_FUENTE = {f.nombre: f.material for f in FUENTES}
68
+
69
+
70
+ @dataclass
71
+ class Datos:
72
+ ids: list[str]
73
+ x: torch.Tensor # [N, vistas, D]
74
+ material: torch.Tensor # [N] índice o -1 si no se usa
75
+ defectos: torch.Tensor # [N, K] 0/1
76
+ mascara: torch.Tensor # [N, K] True si el defecto es conocido
77
+ material_estado: list[str | None] # material para evaluar el estado (aunque no se entrene)
78
+
79
+
80
+ def cargar_split(split: str, registros: dict[str, dict]) -> Datos:
81
+ guardado = torch.load(CARACTERISTICAS / f"{split}.pt")
82
+ ids = guardado["ids"]
83
+ n, k = len(ids), len(ORDEN_DEFECTOS)
84
+ material = torch.full((n,), -1, dtype=torch.long)
85
+ defectos = torch.zeros(n, k)
86
+ mascara = torch.zeros(n, k, dtype=torch.bool)
87
+ material_estado: list[str | None] = []
88
+ for i, id_ in enumerate(ids):
89
+ r = registros[id_]
90
+ if r["material"] is not None:
91
+ material[i] = ORDEN_MATERIALES.index(r["material"])
92
+ for codigo, valor in r["defectos"].items():
93
+ j = ORDEN_DEFECTOS.index(codigo)
94
+ defectos[i, j] = float(valor)
95
+ mascara[i, j] = True
96
+ material_estado.append(r["material"] or MATERIAL_DE_FUENTE.get(r["fuente"]))
97
+ return Datos(ids, guardado["x"].float(), material, defectos, mascara, material_estado)
98
+
99
+
100
+ def pesos_material(material: torch.Tensor) -> torch.Tensor:
101
+ conteo = torch.bincount(material[material >= 0], minlength=len(ORDEN_MATERIALES)).float()
102
+ pesos = torch.where(conteo > 0, conteo.clamp(min=1).rsqrt(), torch.zeros_like(conteo))
103
+ return pesos / pesos[conteo > 0].mean()
104
+
105
+
106
+ def defectos_entrenables(datos: Datos) -> list[str]:
107
+ """Defectos con ejemplos suficientes con y sin el defecto para aprender algo útil."""
108
+ positivos = (datos.defectos * datos.mascara).sum(0)
109
+ negativos = ((1 - datos.defectos) * datos.mascara).sum(0)
110
+ return [
111
+ codigo
112
+ for j, codigo in enumerate(ORDEN_DEFECTOS)
113
+ if positivos[j] >= MIN_EJEMPLOS_DEFECTO and negativos[j] >= MIN_EJEMPLOS_DEFECTO
114
+ ]
115
+
116
+
117
+ def peso_positivos(datos: Datos) -> torch.Tensor:
118
+ positivos = (datos.defectos * datos.mascara).sum(0)
119
+ negativos = ((1 - datos.defectos) * datos.mascara).sum(0)
120
+ return torch.where(
121
+ positivos > 0, negativos / positivos.clamp(min=1), torch.ones_like(positivos)
122
+ ).clamp(1, 20)
123
+
124
+
125
+ @torch.no_grad()
126
+ def predecir(cabezas: CabezasMaterialEstado, x: torch.Tensor) -> tuple[torch.Tensor, torch.Tensor]:
127
+ """Promedia las vistas, igual que el servicio con la foto y su reflejo."""
128
+ cabezas.eval()
129
+ n, v, d = x.shape
130
+ logits_m, logits_d = cabezas(x.reshape(n * v, d))
131
+ return logits_m.reshape(n, v, -1).mean(1), logits_d.sigmoid().reshape(n, v, -1).mean(1)
132
+
133
+
134
+ # --------------------------------------------------------------------------------- métricas
135
+
136
+
137
+ def metricas_material(logits: torch.Tensor, y: torch.Tensor, temperatura: float = 1.0) -> dict:
138
+ validos = y >= 0
139
+ if not validos.any():
140
+ return {}
141
+ y_real = y[validos].numpy()
142
+ prob = (logits[validos] / temperatura).softmax(-1)
143
+ y_pred = prob.argmax(-1).numpy()
144
+ presentes = sorted(set(y_real))
145
+ precision, recall, f1, soporte = precision_recall_fscore_support(
146
+ y_real, y_pred, labels=presentes, zero_division=0
147
+ )
148
+ confianza = prob.max(-1).values.numpy()
149
+ return {
150
+ "exactitud": float(accuracy_score(y_real, y_pred)),
151
+ "f1_macro": float(f1_score(y_real, y_pred, labels=presentes, average="macro")),
152
+ "confianza_media": float(confianza.mean()),
153
+ "por_clase": {
154
+ ORDEN_MATERIALES[c]: {
155
+ "precision": float(p),
156
+ "recall": float(r),
157
+ "f1": float(f),
158
+ "muestras": int(s),
159
+ }
160
+ for c, p, r, f, s in zip(presentes, precision, recall, f1, soporte, strict=True)
161
+ },
162
+ "matriz_confusion": {
163
+ "clases": [ORDEN_MATERIALES[c] for c in presentes],
164
+ "valores": confusion_matrix(y_real, y_pred, labels=presentes).tolist(),
165
+ },
166
+ }
167
+
168
+
169
+ def _auc(y: np.ndarray, p: np.ndarray) -> float | None:
170
+ if (y == 1).sum() < MIN_POR_CLASE_AUC or (y == 0).sum() < MIN_POR_CLASE_AUC:
171
+ return None
172
+ return float(roc_auc_score(y, p))
173
+
174
+
175
+ def metricas_defectos(prob: torch.Tensor, datos: Datos) -> dict:
176
+ resultado = {}
177
+ for j, codigo in enumerate(ORDEN_DEFECTOS):
178
+ conocidos = datos.mascara[:, j].numpy()
179
+ y = datos.defectos[conocidos, j].numpy()
180
+ p = prob[conocidos, j].numpy()
181
+ auc = _auc(y, p)
182
+ resultado[codigo] = {
183
+ "conocidos": int(conocidos.sum()),
184
+ "positivos": int(y.sum()),
185
+ "auc": auc,
186
+ "precision_media": float(average_precision_score(y, p)) if auc is not None else None,
187
+ "exactitud_0_5": float(((p >= 0.5) == y).mean()) if len(y) else None,
188
+ }
189
+ return resultado
190
+
191
+
192
+ def estado_por_material(prob: torch.Tensor, datos: Datos) -> dict:
193
+ """AUC media de los defectos relevantes de cada material, con las fotos de prueba."""
194
+ resultado = {}
195
+ for material in ORDEN_MATERIALES:
196
+ filas = np.array([m == material for m in datos.material_estado])
197
+ aucs, muestras = {}, 0
198
+ for j, codigo in enumerate(ORDEN_DEFECTOS):
199
+ if GRAVEDAD.get(material, {}).get(codigo, 0) <= 0:
200
+ continue
201
+ conocidos = filas & datos.mascara[:, j].numpy()
202
+ muestras = max(muestras, int(conocidos.sum()))
203
+ auc = _auc(datos.defectos[conocidos, j].numpy(), prob[conocidos, j].numpy())
204
+ if auc is not None:
205
+ aucs[codigo] = round(auc, 4)
206
+ media = float(np.mean(list(aucs.values()))) if aucs else None
207
+ resultado[material] = {
208
+ "muestras": muestras,
209
+ "auc_por_defecto": aucs,
210
+ "auc_media": media,
211
+ "validado": bool(
212
+ media is not None and muestras >= MIN_MUESTRAS_ESTADO and media >= AUC_MINIMA_ESTADO
213
+ ),
214
+ }
215
+ return resultado
216
+
217
+
218
+ def puntuacion(logits_m, prob_d, datos: Datos) -> float:
219
+ material = metricas_material(logits_m, datos.material).get("f1_macro", 0.0)
220
+ aucs = [m["auc"] for m in metricas_defectos(prob_d, datos).values() if m["auc"] is not None]
221
+ return material + (0.5 * float(np.mean(aucs)) if aucs else 0.0)
222
+
223
+
224
+ # ---------------------------------------------------------------------------- entrenamiento
225
+
226
+
227
+ def entrenar(entrenamiento: Datos, validacion: Datos) -> tuple[CabezasMaterialEstado, list[dict]]:
228
+ torch.manual_seed(2026)
229
+ n, n_vistas, dim = entrenamiento.x.shape
230
+ cabezas = CabezasMaterialEstado(
231
+ dim, len(ORDEN_MATERIALES), len(ORDEN_DEFECTOS), DIM_OCULTA, DROPOUT
232
+ )
233
+ optimizador = torch.optim.AdamW(cabezas.parameters(), lr=TASA_APRENDIZAJE, weight_decay=0.05)
234
+ pasos_por_epoca = math.ceil(n / TAMANO_LOTE)
235
+ total = EPOCAS * pasos_por_epoca
236
+ calentamiento = 3 * pasos_por_epoca
237
+ planificador = torch.optim.lr_scheduler.LambdaLR(
238
+ optimizador,
239
+ lambda paso: (
240
+ min(1.0, (paso + 1) / calentamiento)
241
+ * 0.5
242
+ * (1 + math.cos(math.pi * min(paso, total) / total))
243
+ ),
244
+ )
245
+ w_material = pesos_material(entrenamiento.material)
246
+ w_positivos = peso_positivos(entrenamiento)
247
+
248
+ mejor, mejor_estado, sin_mejora, historial = -1.0, None, 0, []
249
+ for epoca in range(1, EPOCAS + 1):
250
+ cabezas.train()
251
+ orden = torch.randperm(n)
252
+ perdida_total = 0.0
253
+ for inicio in range(0, n, TAMANO_LOTE):
254
+ idx = orden[inicio : inicio + TAMANO_LOTE]
255
+ vista = torch.randint(0, n_vistas, (len(idx),))
256
+ x = entrenamiento.x[idx, vista]
257
+ logits_m, logits_d = cabezas(x)
258
+
259
+ y_m = entrenamiento.material[idx]
260
+ validos = y_m >= 0
261
+ perdida_m = (
262
+ F.cross_entropy(
263
+ logits_m[validos], y_m[validos], weight=w_material, label_smoothing=SUAVIZADO
264
+ )
265
+ if validos.any()
266
+ else logits_m.sum() * 0
267
+ )
268
+ mascara = entrenamiento.mascara[idx].float()
269
+ perdida_d = (
270
+ F.binary_cross_entropy_with_logits(
271
+ logits_d, entrenamiento.defectos[idx], pos_weight=w_positivos, reduction="none"
272
+ )
273
+ * mascara
274
+ ).sum() / mascara.sum().clamp(min=1)
275
+ perdida = perdida_m + PESO_DEFECTOS * perdida_d
276
+
277
+ optimizador.zero_grad()
278
+ perdida.backward()
279
+ optimizador.step()
280
+ planificador.step()
281
+ perdida_total += perdida.item() * len(idx)
282
+
283
+ logits_m, prob_d = predecir(cabezas, validacion.x)
284
+ valor = puntuacion(logits_m, prob_d, validacion)
285
+ historial.append({"epoca": epoca, "perdida": perdida_total / n, "validacion": valor})
286
+ if valor > mejor + 1e-4:
287
+ mejor, sin_mejora = valor, 0
288
+ mejor_estado = {k: v.clone() for k, v in cabezas.state_dict().items()}
289
+ else:
290
+ sin_mejora += 1
291
+ if epoca % 10 == 0 or sin_mejora == 0:
292
+ print(
293
+ f" época {epoca:3d} pérdida {perdida_total / n:.4f} validación {valor:.4f}",
294
+ flush=True,
295
+ )
296
+ if sin_mejora >= PACIENCIA:
297
+ print(f" sin mejora en {PACIENCIA} épocas: se para en la {epoca}")
298
+ break
299
+
300
+ cabezas.load_state_dict(mejor_estado)
301
+ return cabezas.eval(), historial
302
+
303
+
304
+ def ajustar_temperatura(logits: torch.Tensor, y: torch.Tensor) -> float:
305
+ """Temperatura que hace que la confianza del material refleje su tasa real de acierto."""
306
+ validos = y >= 0
307
+ if validos.sum() < 20:
308
+ return 1.0
309
+ log_t = torch.zeros(1, requires_grad=True)
310
+ optimizador = torch.optim.LBFGS([log_t], lr=0.1, max_iter=100)
311
+
312
+ def cierre():
313
+ optimizador.zero_grad()
314
+ perdida = F.cross_entropy(logits[validos] / log_t.exp(), y[validos])
315
+ perdida.backward()
316
+ return perdida
317
+
318
+ optimizador.step(cierre)
319
+ return float(log_t.detach().exp().clamp(0.5, 5.0))
320
+
321
+
322
+ # ---------------------------------------------------------------------------------- informe
323
+
324
+
325
+ def _pct(valor: float | None) -> str:
326
+ return "—" if valor is None else f"{valor * 100:.1f} %"
327
+
328
+
329
+ def escribir_informe(metadatos: Metadatos, conteo: dict, fuentes: dict) -> str:
330
+ ev = metadatos.evaluacion
331
+ lineas = [
332
+ "# Informe del modelo de material y estado",
333
+ "",
334
+ f"- Fecha: {ev['fecha']}",
335
+ f"- Backbone: `{metadatos.backbone_origen}` + 2 cabezas (material y defectos)",
336
+ f"- Tamaño de imagen: {metadatos.tamano_imagen} px · Temperatura: {metadatos.temperatura_material:.3f}",
337
+ f"- Imágenes: {conteo['entrenamiento']} entrenamiento · {conteo['validacion']} validación · {conteo['prueba']} prueba",
338
+ "",
339
+ "## Material (conjunto de prueba)",
340
+ "",
341
+ f"**Exactitud: {_pct(ev['material'].get('exactitud'))}** · F1 macro: {_pct(ev['material'].get('f1_macro'))}",
342
+ "",
343
+ "| Material | Precisión | Recall | F1 | Fotos de prueba |",
344
+ "|---|---|---|---|---|",
345
+ ]
346
+ for material in ORDEN_MATERIALES:
347
+ c = ev["material"].get("por_clase", {}).get(material)
348
+ if c:
349
+ lineas.append(
350
+ f"| {material} | {_pct(c['precision'])} | {_pct(c['recall'])} | {_pct(c['f1'])} | {c['muestras']} |"
351
+ )
352
+ else:
353
+ lineas.append(f"| {material} | — | — | — | 0 (sin datos) |")
354
+ lineas += [
355
+ "",
356
+ "## Defectos (conjunto de prueba)",
357
+ "",
358
+ "AUC: probabilidad de que una foto con el defecto puntúe más que una sin él (50 % = azar, 100 % = perfecto).",
359
+ "",
360
+ f"Solo se usan en la nota los defectos con ≥ {MIN_EJEMPLOS_DEFECTO} ejemplos con y sin "
361
+ "el defecto en entrenamiento.",
362
+ "",
363
+ "| Defecto | Se usa | AUC | Precisión media | Positivos / conocidos (prueba) |",
364
+ "|---|---|---|---|---|",
365
+ ]
366
+ for codigo, m in ev["defectos"].items():
367
+ usado = "sí" if codigo in metadatos.defectos_entrenados else "no"
368
+ lineas.append(
369
+ f"| {codigo} | {usado} | {_pct(m['auc'])} | {_pct(m['precision_media'])} | "
370
+ f"{m['positivos']} / {m['conocidos']} |"
371
+ )
372
+ lineas += [
373
+ "",
374
+ "## Nota de estado por material",
375
+ "",
376
+ f"Se marca como validada si hay ≥ {MIN_MUESTRAS_ESTADO} fotos de prueba con defectos etiquetados "
377
+ f"y la AUC media de sus defectos es ≥ {AUC_MINIMA_ESTADO:.0%}. Si no, la API devuelve `fiable: false`.",
378
+ "",
379
+ "| Material | Validada | AUC media | Fotos | AUC por defecto |",
380
+ "|---|---|---|---|---|",
381
+ ]
382
+ for material, m in ev["estado_por_material"].items():
383
+ detalle = ", ".join(f"{d} {_pct(a)}" for d, a in m["auc_por_defecto"].items()) or "—"
384
+ lineas.append(
385
+ f"| {material} | {'sí' if m['validado'] else 'no'} | {_pct(m['auc_media'])} | {m['muestras']} | {detalle} |"
386
+ )
387
+ lineas += [
388
+ "",
389
+ "## Fuentes de datos",
390
+ "",
391
+ "| Fuente | Estado | Licencia | Web |",
392
+ "|---|---|---|---|",
393
+ ]
394
+ for nombre, f in fuentes.items():
395
+ estado = (
396
+ f["estado"] if f["estado"] == "descargada" else f"{f['estado']}: {f.get('motivo', '')}"
397
+ )
398
+ lineas.append(f"| {nombre} | {estado} | {f.get('licencia', '')} | {f['web']} |")
399
+ return "\n".join(lineas) + "\n"
400
+
401
+
402
+ def main() -> None:
403
+ registros = {
404
+ r["id"]: r
405
+ for r in (
406
+ json.loads(linea) for linea in MANIFIESTO.read_text(encoding="utf-8").splitlines()
407
+ )
408
+ }
409
+ datos = {s: cargar_split(s, registros) for s in ("entrenamiento", "validacion", "prueba")}
410
+ conteo = {s: len(d.ids) for s, d in datos.items()}
411
+ print(f"Imágenes: {conteo}")
412
+
413
+ print("Entrenando las cabezas ...")
414
+ cabezas, historial = entrenar(datos["entrenamiento"], datos["validacion"])
415
+
416
+ logits_val, _ = predecir(cabezas, datos["validacion"].x)
417
+ temperatura = ajustar_temperatura(logits_val, datos["validacion"].material)
418
+ print(f"Temperatura de calibración: {temperatura:.3f}")
419
+
420
+ prueba = datos["prueba"]
421
+ logits_m, prob_d = predecir(cabezas, prueba.x)
422
+ estado = estado_por_material(prob_d, prueba)
423
+ evaluacion = {
424
+ "fecha": date.today().isoformat(),
425
+ "imagenes": conteo,
426
+ "material": metricas_material(logits_m, prueba.material, temperatura),
427
+ "defectos": metricas_defectos(prob_d, prueba),
428
+ "estado_por_material": estado,
429
+ "epocas": len(historial),
430
+ }
431
+
432
+ from transformers import DINOv3ConvNextModel
433
+
434
+ backbone = DINOv3ConvNextModel.from_pretrained(BACKBONE).eval()
435
+ metadatos = Metadatos(
436
+ materiales=list(ORDEN_MATERIALES),
437
+ defectos=list(ORDEN_DEFECTOS),
438
+ tamano_imagen=TAMANO_IMAGEN,
439
+ dim_caracteristicas=datos["entrenamiento"].x.shape[-1],
440
+ dim_oculta=DIM_OCULTA,
441
+ backbone_origen=f"{BACKBONE} (congelado)",
442
+ temperatura_material=temperatura,
443
+ defectos_entrenados=defectos_entrenables(datos["entrenamiento"]),
444
+ estado_validado_en=[m for m, e in estado.items() if e["validado"]],
445
+ evaluacion=evaluacion,
446
+ )
447
+ guardar(RedMaterialEstado(backbone, cabezas), metadatos, SALIDA_MODELO)
448
+
449
+ fuentes = (
450
+ json.loads(INFORME_FUENTES.read_text(encoding="utf-8")) if INFORME_FUENTES.is_file() else {}
451
+ )
452
+ informe = escribir_informe(metadatos, conteo, fuentes)
453
+ (SALIDA_MODELO / "informe.md").write_text(informe, encoding="utf-8")
454
+ (SALIDA_MODELO / "historial.json").write_text(json.dumps(historial, indent=1), encoding="utf-8")
455
+ print(f"\nModelo guardado en {SALIDA_MODELO}\n")
456
+ print(informe)
457
+
458
+
459
+ if __name__ == "__main__":
460
+ main()
entrenamiento/entrenar_peso.py ADDED
@@ -0,0 +1,85 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import joblib
3
+ import numpy as np
4
+ import pandas as pd
5
+ from lightgbm import LGBMRegressor
6
+ from sklearn.compose import TransformedTargetRegressor
7
+ from sklearn.linear_model import Ridge
8
+ from sklearn.model_selection import GroupKFold, train_test_split
9
+ from sklearn.pipeline import make_pipeline
10
+ from sklearn.preprocessing import StandardScaler
11
+
12
+ CSV_PROPIO = Path("datos/peso/caracteristicas.csv")
13
+ CSV_EXT = Path("datos/externos/emma_mat.csv")
14
+ SALIDA = Path("modelos_entrenados/peso")
15
+ MAX_EXT = 300000
16
+ W_PROPIO = 20.0
17
+ NUM = ["area_pieza", "largo_cm_medio", "ancho_cm_medio", "largo_cm_max",
18
+ "relacion", "espesor_cm", "volumen_cm3"]
19
+ CAT = ["material"]
20
+
21
+ def preparar(df):
22
+ df = df.copy()
23
+ n = df["n_piezas"].clip(lower=1)
24
+ df["area_pieza"] = df["area_cm2_total"] / n
25
+ df["relacion"] = df["largo_cm_medio"] / df["ancho_cm_medio"]
26
+ df["volumen_cm3"] = df["area_pieza"] * df["espesor_cm"]
27
+ df["pieza_g"] = df["peso_real_g"] / n
28
+ return df
29
+
30
+ propio = preparar(pd.read_csv(CSV_PROPIO)); propio["fuente"] = "propio"
31
+ ext = pd.read_csv(CSV_EXT)
32
+ ext = preparar(ext.sample(min(MAX_EXT, len(ext)), random_state=0)); ext["fuente"] = "externo"
33
+ ext["sesion"] = "ext"
34
+ df = pd.concat([propio, ext], ignore_index=True)
35
+
36
+ X = pd.get_dummies(df[NUM + CAT], columns=CAT).astype(float)
37
+ y = df["pieza_g"]
38
+ n = df["n_piezas"].clip(lower=1)
39
+ es_p = (df["fuente"] == "propio").to_numpy()
40
+ idx_p, idx_e = np.where(es_p)[0], np.where(~es_p)[0]
41
+ print(f"Filas propias: {len(idx_p)} | externas: {len(idx_e)}")
42
+ print("Features:", list(X.columns))
43
+
44
+ def lgbm():
45
+ return TransformedTargetRegressor(
46
+ regressor=LGBMRegressor(n_estimators=600, learning_rate=0.05, num_leaves=63,
47
+ min_child_samples=30, subsample=0.8, subsample_freq=1,
48
+ colsample_bytree=0.9, random_state=0, verbose=-1),
49
+ func=np.log1p, inverse_func=np.expm1)
50
+
51
+ def metricas(pred, real):
52
+ err = np.abs(pred - real)
53
+ return err.mean(), np.median(err / real) * 100
54
+
55
+ tr_e, te_e = train_test_split(idx_e, test_size=0.2, random_state=0)
56
+ m = lgbm().fit(X.iloc[tr_e], y.iloc[tr_e])
57
+ mae, mape = metricas(m.predict(X.iloc[te_e]), y.iloc[te_e].to_numpy())
58
+ print(f"[EMMA holdout] MAE = {mae:.1f} g | error mediano = {mape:.1f} %")
59
+
60
+ sesiones = df.loc[idx_p, "sesion"].nunique()
61
+ if sesiones >= 2:
62
+ cv = GroupKFold(n_splits=min(5, sesiones))
63
+ r = {"lineal (solo propio)": [], "lightgbm (EMMA+propio)": []}
64
+ for tr, te in cv.split(idx_p, groups=df.loc[idx_p, "sesion"]):
65
+ tr_i, te_i = idx_p[tr], idx_p[te]
66
+ lin = make_pipeline(StandardScaler(), Ridge(alpha=1.0)).fit(X.iloc[tr_i], y.iloc[tr_i])
67
+ ids = np.r_[tr_i, tr_e]
68
+ w = np.r_[np.full(len(tr_i), W_PROPIO), np.ones(len(tr_e))]
69
+ gb = lgbm().fit(X.iloc[ids], y.iloc[ids], sample_weight=w)
70
+ real, k = df["peso_real_g"].iloc[te_i].to_numpy(), n.iloc[te_i].to_numpy()
71
+ for nom, mod in (("lineal (solo propio)", lin), ("lightgbm (EMMA+propio)", gb)):
72
+ r[nom].append((np.abs(mod.predict(X.iloc[te_i]) * k - real), real))
73
+ for nom, lst in r.items():
74
+ e = np.concatenate([a for a, _ in lst]); rl = np.concatenate([b for _, b in lst])
75
+ print(f"[propio] {nom:24s} MAE = {e.mean():8.1f} g | error = {(e / rl).mean() * 100:5.1f} %")
76
+ else:
77
+ print("[propio] Menos de 2 sesiones: no se puede validar con tus datos.")
78
+
79
+ w = np.where(es_p, W_PROPIO, 1.0)
80
+ final = lgbm().fit(X, y, sample_weight=w)
81
+ SALIDA.mkdir(parents=True, exist_ok=True)
82
+ joblib.dump({"modelo": final, "columnas": list(X.columns), "cat": CAT}, SALIDA / "lgbm_peso.joblib")
83
+ print("Guardado en", SALIDA / "lgbm_peso.joblib")
84
+
85
+
entrenamiento/evaluar_conteo.py ADDED
@@ -0,0 +1,136 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Mide el error del conteo con SAM 3 y elige el umbral de confianza con datos.
2
+
3
+ uv run python -m entrenamiento.evaluar_conteo # 25 fotos por material
4
+ uv run python -m entrenamiento.evaluar_conteo 60 # 60 fotos por material
5
+
6
+ SAM 3 no se entrena, pero sí se puede medir: se usan datasets de detección donde cada
7
+ recuadro etiquetado es una pieza (Fuente.piezas), así se sabe la cantidad real de cada foto.
8
+
9
+ 1. Se ejecuta SAM 3 con un umbral bajo (0.1) y se guardan todas las detecciones.
10
+ 2. Para cada umbral entre 0.1 y 0.9 se recalcula la cantidad (sin volver a ejecutar SAM 3)
11
+ y se mide el error medio. Se elige el umbral con menor error.
12
+ 3. Se escribe modelos_entrenados/conteo/informe.md y umbral.json; el servicio usa ese
13
+ umbral si existe (CONTEO_UMBRAL del .env tiene prioridad si se define).
14
+ """
15
+
16
+ import json
17
+ import random
18
+ import re
19
+ import sys
20
+ import time
21
+ from collections import defaultdict
22
+ from pathlib import Path
23
+
24
+ import numpy as np
25
+ from PIL import Image, ImageOps
26
+
27
+ from app.core.config import settings
28
+ from app.ml.materiales import MATERIAL_POR_CODIGO
29
+ from app.ml.vision.contador_piezas import ContadorSam3, Pieza, filtrar_piezas
30
+ from entrenamiento.descargar import MARCA
31
+ from entrenamiento.fuentes import FUENTES
32
+ from entrenamiento.rutas import CRUDOS
33
+
34
+ SALIDA = settings.ruta_modelos / "conteo"
35
+ UMBRALES = [round(u, 2) for u in np.arange(0.1, 0.91, 0.05)]
36
+ LADO = 1024
37
+
38
+
39
+ def muestras(por_material: int) -> dict[str, list[tuple[Path, int]]]:
40
+ """(foto, cantidad real) por material, de las fuentes con piezas etiquetadas."""
41
+ rng = random.Random(7)
42
+ resultado: dict[str, list[tuple[Path, int]]] = defaultdict(list)
43
+ for fuente in FUENTES:
44
+ carpeta = CRUDOS / fuente.nombre
45
+ if not fuente.piezas or not (carpeta / MARCA).is_file():
46
+ continue
47
+ candidatas = []
48
+ for archivo in carpeta.rglob("_annotations.coco.json"):
49
+ coco = json.loads(archivo.read_text(encoding="utf-8"))
50
+ ids_pieza = {
51
+ c["id"] for c in coco["categories"] if re.search(fuente.piezas, c["name"], re.I)
52
+ }
53
+ cuenta = defaultdict(int)
54
+ for a in coco["annotations"]:
55
+ if a["category_id"] in ids_pieza:
56
+ cuenta[a["image_id"]] += 1
57
+ for img in coco["images"]:
58
+ ruta = archivo.parent / img["file_name"]
59
+ # Variantes aumentadas de Roboflow fuera: solo una por foto original.
60
+ if ruta.is_file() and ".rf." in ruta.name and 1 <= cuenta[img["id"]] <= 40:
61
+ candidatas.append((ruta, cuenta[img["id"]]))
62
+ vistas: set[str] = set()
63
+ unicas = []
64
+ for ruta, n in candidatas:
65
+ clave = ruta.name.split(".rf.")[0]
66
+ if clave not in vistas:
67
+ vistas.add(clave)
68
+ unicas.append((ruta, n))
69
+ rng.shuffle(unicas)
70
+ # Las fuentes por recortes no tienen `material`: se usa el de sus recortes.
71
+ materiales = {fuente.material} if fuente.material else set(fuente.recortes.values())
72
+ if len(materiales) != 1:
73
+ raise SystemExit(f"{fuente.nombre}: las piezas deben ser de un solo material")
74
+ resultado[materiales.pop()] += unicas
75
+ return {m: lista[:por_material] for m, lista in resultado.items()}
76
+
77
+
78
+ def main(por_material: int) -> None:
79
+ SALIDA.mkdir(parents=True, exist_ok=True)
80
+ datos = muestras(por_material)
81
+ if not datos:
82
+ raise SystemExit("No hay fuentes de conteo descargadas (Fuente.piezas).")
83
+ contador = ContadorSam3(settings.conteo_modelo, settings.ruta_modelos, umbral=0.1)
84
+
85
+ detecciones: dict[str, list[tuple[int, list[Pieza]]]] = defaultdict(list)
86
+ tiempos = []
87
+ for material, lista in datos.items():
88
+ consultas = MATERIAL_POR_CODIGO[material].consultas_conteo
89
+ for i, (ruta, real) in enumerate(lista, 1):
90
+ with Image.open(ruta) as original:
91
+ imagen = ImageOps.exif_transpose(original).convert("RGB")
92
+ imagen.thumbnail((LADO, LADO))
93
+ inicio = time.time()
94
+ piezas = contador.detectar(imagen, consultas)
95
+ tiempos.append(time.time() - inicio)
96
+ detecciones[material].append((real, piezas))
97
+ print(
98
+ f"{material} {i}/{len(lista)}: real {real}, detectadas (≥0.1) {len(piezas)}",
99
+ flush=True,
100
+ )
101
+
102
+ def errores(umbral: float, material: str | None = None) -> np.ndarray:
103
+ filas = [
104
+ abs(len(filtrar_piezas([p for p in piezas if p.confianza >= umbral])) - real)
105
+ for m, lista in detecciones.items()
106
+ if material in (None, m)
107
+ for real, piezas in lista
108
+ ]
109
+ return np.array(filas)
110
+
111
+ mejor = min(UMBRALES, key=lambda u: errores(u).mean())
112
+ lineas = [
113
+ "# Evaluación del conteo (SAM 3, sin entrenar)",
114
+ "",
115
+ f"- Fotos: {sum(len(v) for v in detecciones.values())} · Tiempo medio por foto (CPU): {np.mean(tiempos):.1f} s",
116
+ f"- **Umbral elegido: {mejor}** (menor error medio)",
117
+ "",
118
+ "| Material | Fotos | Error medio (piezas) | Exacto | ±1 pieza |",
119
+ "|---|---|---|---|---|",
120
+ ]
121
+ for material in [*detecciones, None]:
122
+ e = errores(mejor, material)
123
+ nombre = material or "**Total**"
124
+ lineas.append(
125
+ f"| {nombre} | {len(e)} | {e.mean():.2f} | {(e == 0).mean():.0%} | {(e <= 1).mean():.0%} |"
126
+ )
127
+ lineas += ["", "Error medio según el umbral:", "", "| Umbral | Error medio |", "|---|---|"]
128
+ lineas += [f"| {u} | {errores(u).mean():.2f} |" for u in UMBRALES]
129
+ informe = "\n".join(lineas) + "\n"
130
+ (SALIDA / "informe.md").write_text(informe, encoding="utf-8")
131
+ (SALIDA / "umbral.json").write_text(json.dumps({"umbral": mejor}), encoding="utf-8")
132
+ print("\n" + informe)
133
+
134
+
135
+ if __name__ == "__main__":
136
+ main(int(sys.argv[1]) if len(sys.argv) > 1 else 25)
entrenamiento/fuentes.py ADDED
@@ -0,0 +1,311 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Catálogo de datasets públicos usados para entrenar y evaluar.
2
+
3
+ Solo se incluyen datasets cuya licencia permite uso comercial (MIT, CC0, CC BY 4.0...).
4
+ La licencia se vuelve a comprobar al descargar (Roboflow, Kaggle) y la fuente se descarta
5
+ si no es compatible. Para buscar más: `python -m entrenamiento.buscar_roboflow --todo`.
6
+
7
+ Tres formas de aprovechar un dataset:
8
+ - Material por carpetas (`clases`): carpeta/clase → material. Foto entera.
9
+ - Material por recortes (`recortes`): datasets de detección donde cada recuadro es un
10
+ objeto (botella, caja, lata...). Cada objeto se recorta y se usa como una foto de ese
11
+ material; así un dataset de escenas con varios objetos da muchas fotos limpias.
12
+ - Defectos (`material` fijo): cada imagen trae las clases de defecto que contiene. Las
13
+ clases se traducen con mapeo.defectos_de_clase. Para los defectos que el dataset sí
14
+ etiqueta, una imagen sin esa etiqueta cuenta como "no lo tiene"; los demás defectos
15
+ quedan como "desconocidos" y no se entrenan con ella.
16
+ Además, `piezas` marca los datasets cuyos recuadros son piezas contables: sirven para medir
17
+ el conteo de SAM 3 (evaluar_conteo.py).
18
+ """
19
+
20
+ from dataclasses import dataclass, field
21
+ from typing import Literal
22
+
23
+ Origen = Literal["hf", "url", "kaggle", "roboflow"]
24
+
25
+ # Licencias aceptadas (en minúsculas, por subcadena). Se excluye todo lo "NC" (no
26
+ # comercial), "other", "unknown" y las que reservan los derechos de las fotos.
27
+ LICENCIAS_COMERCIALES = (
28
+ "mit",
29
+ "cc0",
30
+ "public domain",
31
+ "cc by 4.0",
32
+ "cc-by-4.0",
33
+ "apache",
34
+ "odbl",
35
+ )
36
+
37
+
38
+ @dataclass(frozen=True)
39
+ class Fuente:
40
+ nombre: str
41
+ origen: Origen
42
+ ref: str # repo de HF, URL, slug de Kaggle o "workspace/proyecto" de Roboflow
43
+ licencia: str
44
+ web: str
45
+ # Material por carpetas: carpeta/clase → código de material (None = descartar la clase).
46
+ clases: dict[str, str | None] = field(default_factory=dict)
47
+ # Material por recortes: expresión regular sobre el nombre de la clase → material.
48
+ recortes: dict[str, str] = field(default_factory=dict)
49
+ # Defectos: material de todas sus imágenes (también se usa para evaluar el estado).
50
+ material: str | None = None
51
+ usar_para_material: bool = True
52
+ # Defectos que se pueden dar por ausentes en todo el dataset (fotos de objetos limpios).
53
+ defectos_ausentes: tuple[str, ...] = ()
54
+ max_por_clase: int = 1500
55
+ archivo: str | None = None # archivo concreto dentro del repo de HF
56
+ # Expresión regular de las clases que son piezas contables (para evaluar el conteo).
57
+ piezas: str | None = None
58
+ # False si el dataset solo sirve para evaluar el conteo (no para entrenar).
59
+ entrenar: bool = True
60
+
61
+ @property
62
+ def es_de_defectos(self) -> bool:
63
+ return not self.clases and not self.recortes
64
+
65
+
66
+ # ------------------------------------------------------------------- material por carpetas
67
+
68
+ TRASHNET = Fuente(
69
+ nombre="trashnet",
70
+ origen="hf",
71
+ ref="garythung/trashnet",
72
+ archivo="dataset-resized.zip",
73
+ licencia="MIT",
74
+ web="https://huggingface.co/datasets/garythung/trashnet",
75
+ clases={
76
+ "cardboard": "carton",
77
+ "glass": "vidrio",
78
+ "metal": "metal",
79
+ "paper": "papel",
80
+ "plastic": "plastico",
81
+ "trash": "otro",
82
+ },
83
+ # Objetos limpios fotografiados sobre fondo blanco.
84
+ defectos_ausentes=("oxido", "podredumbre"),
85
+ )
86
+
87
+ REALWASTE = Fuente(
88
+ nombre="realwaste",
89
+ origen="url",
90
+ ref="https://archive.ics.uci.edu/static/public/908/realwaste.zip",
91
+ licencia="CC BY 4.0",
92
+ web="https://archive.ics.uci.edu/dataset/908/realwaste",
93
+ clases={
94
+ "cardboard": "carton",
95
+ "food organics": None,
96
+ "glass": "vidrio",
97
+ "metal": "metal",
98
+ "miscellaneous trash": "otro",
99
+ "paper": "papel",
100
+ "plastic": "plastico",
101
+ "textile trash": "tela",
102
+ "vegetation": None,
103
+ },
104
+ )
105
+
106
+ # Nota: "mostafaabla/garbage-classification" (Kaggle) se descartó: su licencia ODbL cubre la
107
+ # base de datos, pero las fotos son "© de sus autores" (recogidas de internet, sin fines de lucro).
108
+ GARBAGE_V2 = Fuente(
109
+ nombre="garbage-v2",
110
+ origen="kaggle",
111
+ ref="sumn2u/garbage-classification-v2",
112
+ licencia="(se comprueba al descargar)",
113
+ web="https://www.kaggle.com/datasets/sumn2u/garbage-classification-v2",
114
+ clases={
115
+ "battery": "otro",
116
+ "biological": None,
117
+ "cardboard": "carton",
118
+ "clothes": "tela",
119
+ "glass": "vidrio",
120
+ "metal": "metal",
121
+ "paper": "papel",
122
+ "plastic": "plastico",
123
+ "shoes": "otro",
124
+ "trash": "otro",
125
+ },
126
+ max_por_clase=1200,
127
+ )
128
+
129
+
130
+ def _rf(nombre: str, ref: str, material: str | None = None, **extra) -> Fuente:
131
+ return Fuente(
132
+ nombre=nombre,
133
+ origen="roboflow",
134
+ ref=ref,
135
+ licencia="(se comprueba al descargar)",
136
+ web=f"https://universe.roboflow.com/{ref}",
137
+ material=material,
138
+ **extra,
139
+ )
140
+
141
+
142
+ # ------------------------------------------------------------------- material por recortes
143
+
144
+ RECORTES = (
145
+ _rf(
146
+ "rf-cajas-carton",
147
+ "ece4191-xcxot/cardboard-box-detection-mxqjh",
148
+ recortes={r"box": "carton"},
149
+ piezas=r"box",
150
+ ),
151
+ _rf(
152
+ "rf-plastico-residuos",
153
+ "edwin-daza-saavedra-s-workspace/plastic-waste-ag4eg",
154
+ recortes={r"plastic": "plastico"},
155
+ piezas=r"plastic bottle|plastic cup",
156
+ ),
157
+ _rf(
158
+ "rf-botellas-plastico",
159
+ "fyp-li8zz/plastic-bottle-2.0",
160
+ recortes={r"bottle": "plastico"},
161
+ piezas=r"bottle",
162
+ ),
163
+ _rf(
164
+ "rf-frascos-vidrio",
165
+ "gdp27/glass-jars",
166
+ recortes={r"jar|glass": "vidrio"},
167
+ piezas=r"jar",
168
+ ),
169
+ _rf(
170
+ "rf-vidrio-residuos",
171
+ "waste-seregation/glass-detection-1jobo",
172
+ recortes={r"glass": "vidrio"},
173
+ piezas=r"glass",
174
+ ),
175
+ _rf("rf-latas", "albaath2/can-o9wiu", recortes={r"metal": "metal"}, piezas=r"metal"),
176
+ _rf("rf-tubos", "john-tai-fa0rl/pipe-detector3", recortes={r"pipe": "metal"}, piezas=r"pipe"),
177
+ _rf(
178
+ "rf-chatarra",
179
+ "moses-orjyo/steel-scrap-sowop",
180
+ recortes={r"scrap|steel|rebar": "metal"},
181
+ ),
182
+ _rf("rf-papel", "trashdetection-bhjmn/paper-2jpbv", recortes={r"paper": "papel"}),
183
+ _rf(
184
+ "rf-papel-periodico",
185
+ "mmt-kcsv2/paper-nitaa",
186
+ recortes={r"^newspaper$|^paper$": "papel", r"cardboard": "carton"},
187
+ ),
188
+ _rf(
189
+ "rf-materiales-mixtos",
190
+ "material-identification/cardboard-1hdgz",
191
+ recortes={
192
+ r"^cardboard$": "carton",
193
+ r"^metal$": "metal",
194
+ r"^paper$": "papel",
195
+ r"^plastic$": "plastico",
196
+ r"^glass$": "vidrio",
197
+ },
198
+ ),
199
+ _rf(
200
+ "rf-ropa",
201
+ "grad-proj-nakmt/clothes-liagy",
202
+ recortes={r"dress|outwear|shirt|shorts|trousers|skirt|vest|sling": "tela"},
203
+ piezas=r"dress|outwear|shirt|shorts|trousers|skirt|vest|sling",
204
+ ),
205
+ _rf(
206
+ "rf-tablas-madera",
207
+ "davids-workspace-rc8oh/lumber-board-ident",
208
+ recortes={r"wood|lumber": "madera"},
209
+ piezas=r"wood|lumber",
210
+ ),
211
+ _rf(
212
+ "rf-residuos-electronicos",
213
+ "mehmet-wswds/e-waste-qmxtt",
214
+ recortes={r"battery|keyboard|laptop|phone|monitor|mouse|printer|electronic": "otro"},
215
+ ),
216
+ )
217
+
218
+ # ---------------------------------------------------------------------------- defectos
219
+
220
+ # Madera: material (tablas, estibas, contrachapado) y defectos (grietas, nudos, manchas).
221
+ MADERA = (
222
+ _rf(
223
+ "rf-madera-tablas",
224
+ "masood/wood-w4h97",
225
+ "madera",
226
+ piezas=r"plank|timber|plywood|pallet|wood",
227
+ ),
228
+ _rf("rf-madera-planchas", "rolos/wood_plank_final-69kb7", "madera"),
229
+ _rf("rf-madera-estibas", "pallets-dzqdc/only-wood", "madera"),
230
+ _rf("rf-madera-defectos-kodytek", "tim-4ijf0/wood-surface-defect-detection", "madera"),
231
+ _rf("rf-madera-defectos-11", "alima-aife2/wood-defect-detection-dxzor", "madera"),
232
+ _rf("rf-madera-defectos-5", "detect-detect/new-defects-in-wood", "madera"),
233
+ _rf("rf-madera-defectos-10k", "ye-csex5/wood-defect-o06zb", "madera"),
234
+ _rf(
235
+ "rf-madera-defectos-tarumt",
236
+ "tunku-abdul-rahman-university-of-management-and-technology-ttt1n/wood-defects-8lcdv",
237
+ "madera",
238
+ ),
239
+ _rf("rf-madera-sana", "school-mys25/wood-7ao2p", "madera"),
240
+ _rf("rf-madera-estibas-agujeros", "deep-learning-exxwi/wood-pallet-deep-learning", "madera"),
241
+ )
242
+
243
+ # Metal: óxido; abolladuras, rayones y agujeros (carrocerías: solo para defectos).
244
+ METAL = (
245
+ _rf("rf-oxido-1", "qp-55vgp/rust-2-qtreu", "metal"),
246
+ _rf("rf-oxido-2", "personal-iisqb/rust-detection-xj8bu", "metal"),
247
+ _rf("rf-oxido-3", "workspacesteijn/rust-hbbwg", "metal"),
248
+ _rf("rf-oxido-niveles", "zenil/rust-detection-4cr1b", "metal"),
249
+ _rf("rf-metal-superficie", "university-of-bristol-kxv0a/metal-surface-7ng08", "metal"),
250
+ _rf(
251
+ "rf-metal-superficie-2",
252
+ "vision-oi1kw/metal-surface-defect-detection-n7x0l",
253
+ "metal",
254
+ ),
255
+ _rf("rf-abolladuras-1", "mesors-workspace/dent-ggvqt", "metal", usar_para_material=False),
256
+ _rf(
257
+ "rf-abolladuras-2",
258
+ "roboflow-demo/car-dents-and-scratches",
259
+ "metal",
260
+ usar_para_material=False,
261
+ ),
262
+ )
263
+
264
+ # Tela: agujeros, manchas, arrugas.
265
+ TELA = (
266
+ _rf("rf-tela-defectos-1", "fabric-defect-detection-jq7gf/fabric-defect-detection-v2", "tela"),
267
+ _rf("rf-tela-defectos-3", "datasetannot/fabric-defect-detection-lfyix", "tela"),
268
+ _rf(
269
+ "rf-tela-agujeros", "textile-defect-detection-plje9/textile-defect-detection-yvgmo", "tela"
270
+ ),
271
+ _rf("rf-tela-agujeros-manchas", "iicl-31hjn/textile-ofqd1", "tela"),
272
+ _rf("rf-tela-manchas-arrugas", "zco/textile-defect-detection-ixt2r", "tela"),
273
+ )
274
+
275
+ # Cartón: cajas dañadas (aplastadas, rotas, mojadas) y en buen estado.
276
+ CARTON = (
277
+ _rf("rf-cajas-1", "bekarys0504-gmail-com/damaged_box", "carton"),
278
+ _rf("rf-cajas-3", "project-33xgh/damaged-box-auh4b", "carton"),
279
+ _rf("rf-cajas-4", "robot-arm-xlohb/boxcondition", "carton"),
280
+ _rf("rf-cajas-5", "project-33xgh/damaged-box-detection", "carton"),
281
+ )
282
+
283
+ # Vidrio: roto o agrietado y en buen estado.
284
+ VIDRIO = (
285
+ _rf("rf-vidrio-1", "focus/detect_broken_glass", "vidrio"),
286
+ _rf("rf-vidrio-2", "nhabduls-workspace/cracked-glass-detection", "vidrio"),
287
+ _rf("rf-vidrio-3", "glassdefectsdetection/glassdefectscheckingmodel", "vidrio"),
288
+ )
289
+
290
+ FUENTES: tuple[Fuente, ...] = (
291
+ TRASHNET,
292
+ REALWASTE,
293
+ GARBAGE_V2,
294
+ *RECORTES,
295
+ *MADERA,
296
+ *METAL,
297
+ *TELA,
298
+ *CARTON,
299
+ *VIDRIO,
300
+ )
301
+
302
+
303
+ def licencia_comercial(licencia: str) -> bool:
304
+ texto = licencia.lower()
305
+ no_comercial = "-nc" in texto or " nc" in texto or "noncommercial" in texto
306
+ derechos_reservados = "©" in texto or "original authors" in texto
307
+ return (
308
+ any(lic in texto for lic in LICENCIAS_COMERCIALES)
309
+ and not no_comercial
310
+ and not derechos_reservados
311
+ )
entrenamiento/hf_sync.py ADDED
@@ -0,0 +1,32 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from huggingface_hub import snapshot_download, upload_folder
3
+
4
+ REPO = "EagleDev-Innovation/Modelos-EagleDev-Innovation"
5
+ CARPETA = "modelos_entrenados"
6
+
7
+ def bajar():
8
+ snapshot_download(
9
+ repo_id=REPO,
10
+ repo_type="model",
11
+ allow_patterns=f"{CARPETA}/**",
12
+ local_dir=".",
13
+ )
14
+ print("Modelos descargados en", CARPETA)
15
+
16
+ def subir(mensaje):
17
+ upload_folder(
18
+ repo_id=REPO,
19
+ repo_type="model",
20
+ folder_path=CARPETA,
21
+ path_in_repo=CARPETA,
22
+ commit_message=mensaje,
23
+ ignore_patterns=[".locks/**", ".cache/**", "__pycache__/**", "*.tmp"],
24
+ )
25
+ print("Cambios subidos")
26
+
27
+ if __name__ == "__main__":
28
+ accion = sys.argv[1]
29
+ if accion == "bajar":
30
+ bajar()
31
+ elif accion == "subir":
32
+ subir(sys.argv[2] if len(sys.argv) > 2 else "Actualizar modelos")
entrenamiento/hf_sync_datos.py ADDED
@@ -0,0 +1,28 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from huggingface_hub import snapshot_download, upload_folder
3
+
4
+ REPO = "EagleDev-Innovation/Modelos-EagleDev-Innovation"
5
+ PATRONES = [
6
+ "entrenamiento/*.py", "entrenamiento/*.md", "entrenamiento/*.ipynb",
7
+ "datos/peso/*.csv",
8
+ "datos/externos/emma_mat.csv",
9
+ "datos/externos/emma_clip.*",
10
+ ]
11
+
12
+ def bajar():
13
+ snapshot_download(repo_id=REPO, repo_type="model",
14
+ allow_patterns=PATRONES, local_dir=".")
15
+ print("Código y datos descargados")
16
+
17
+ def subir(mensaje):
18
+ upload_folder(repo_id=REPO, repo_type="model", folder_path=".",
19
+ allow_patterns=PATRONES,
20
+ ignore_patterns=[".venv/**", "**/__pycache__/**"],
21
+ commit_message=mensaje)
22
+ print("Código y datos subidos")
23
+
24
+ if __name__ == "__main__":
25
+ if sys.argv[1] == "bajar":
26
+ bajar()
27
+ elif sys.argv[1] == "subir":
28
+ subir(sys.argv[2] if len(sys.argv) > 2 else "Actualizar código y datos")
entrenamiento/importar_emma.py ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pickle, zlib
2
+ import numpy as np
3
+ import pandas as pd
4
+
5
+ RUTA = "datos/externos/EMMa_v1.1.fdd"
6
+ SALIDA = "datos/externos/emma_peso.csv"
7
+ IN_A_CM = 2.54 # supongo dimensiones en pulgadas
8
+ KG_A_G = 1000 # supongo masa en kg
9
+
10
+ f = open(RUTA, "rb")
11
+ f.seek(-8, 2)
12
+ n = int.from_bytes(f.read(8), "little")
13
+ f.seek(-(8 + n), 2)
14
+ idx = pickle.loads(f.read(n))
15
+ items = sorted(idx.items(), key=lambda kv: kv[1][0]) # lectura secuencial
16
+ print("Registros:", len(items))
17
+
18
+ def leer(ini, largo):
19
+ f.seek(ini)
20
+ b = f.read(largo)
21
+ try:
22
+ return pickle.loads(b)
23
+ except Exception:
24
+ return pickle.loads(zlib.decompress(b))
25
+
26
+ filas = []
27
+ for i, (asin, (ini, largo)) in enumerate(items):
28
+ if i % 200000 == 0:
29
+ print(i, "leidos,", len(filas), "validos")
30
+ try:
31
+ r = leer(ini, largo)
32
+ except Exception:
33
+ continue
34
+ mass = r.get("mass")
35
+ dims = r.get("dimensions")
36
+ if not mass or not dims or len(dims) != 1 or r.get("quantity", 1) != 1:
37
+ continue
38
+ d = sorted(dims[0], reverse=True)
39
+ a, b = d[0] * IN_A_CM, d[1] * IN_A_CM
40
+ mat = r.get("materials")
41
+ material = f"emma_{int(np.argmax(mat))}" if mat is not None else "desconocido"
42
+ filas.append({
43
+ "foto": "externo",
44
+ "sesion": "externo",
45
+ "tipo_objeto": "externo",
46
+ "material": material,
47
+ "n_piezas": 1,
48
+ "area_cm2_total": a * b,
49
+ "largo_cm_medio": a,
50
+ "ancho_cm_medio": b,
51
+ "largo_cm_max": a,
52
+ "peso_real_g": mass * KG_A_G,
53
+ })
54
+
55
+ df = pd.DataFrame(filas)
56
+ df = df[(df.peso_real_g > 1) & (df.peso_real_g < 50000) & (df.largo_cm_medio > 0) & (df.ancho_cm_medio > 0)]
57
+ df.to_csv(SALIDA, index=False)
58
+ print("Filas guardadas:", len(df), "->", SALIDA)
59
+ print(df.describe())
entrenamiento/importar_emma_mat.py ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pickle, zlib, re
2
+ import numpy as np
3
+ import pandas as pd
4
+
5
+ RUTA = "datos/externos/EMMa_v1.1.fdd"
6
+ SALIDA = "datos/externos/emma_mat.csv"
7
+ IN_A_CM, KG_A_G = 2.54, 1000
8
+
9
+ CLAVES = {
10
+ "metal": ["steel", "stainless", "iron", "aluminum", "aluminium", "brass", "copper", "metal"],
11
+ "plastico": ["plastic", "pvc", "abs", "acrylic", "silicone", "rubber"],
12
+ "madera": ["wood", "wooden", "bamboo", "oak", "pine", "plywood"],
13
+ "vidrio": ["glass"],
14
+ "ceramica": ["ceramic", "porcelain", "stoneware"],
15
+ "carton": ["cardboard", "paper", "kraft"],
16
+ "tela": ["cotton", "fabric", "leather", "polyester", "canvas", "wool"],
17
+ }
18
+
19
+ def material_de(texto):
20
+ t = texto.lower()
21
+ hits = {m: sum(len(re.findall(r"\b" + k + r"\b", t)) for k in ks) for m, ks in CLAVES.items()}
22
+ hits = {m: h for m, h in hits.items() if h > 0}
23
+ if len(hits) != 1:
24
+ return None
25
+ return next(iter(hits))
26
+
27
+ f = open(RUTA, "rb")
28
+ f.seek(-8, 2)
29
+ n = int.from_bytes(f.read(8), "little")
30
+ f.seek(-(8 + n), 2)
31
+ idx = pickle.loads(f.read(n))
32
+ items = sorted(idx.items(), key=lambda kv: kv[1][0])
33
+
34
+ def leer(ini, largo):
35
+ f.seek(ini)
36
+ b = f.read(largo)
37
+ try:
38
+ return pickle.loads(b)
39
+ except Exception:
40
+ return pickle.loads(zlib.decompress(b))
41
+
42
+ filas = []
43
+ for i, (asin, (ini, largo)) in enumerate(items):
44
+ if i % 200000 == 0:
45
+ print(i, "leidos,", len(filas), "validos")
46
+ try:
47
+ r = leer(ini, largo)
48
+ except Exception:
49
+ continue
50
+ mass, dims = r.get("mass"), r.get("dimensions")
51
+ if not mass or not dims or len(dims) != 1 or r.get("quantity", 1) != 1:
52
+ continue
53
+ texto = str(r.get("title", "")) + " " + " ".join(map(str, r.get("features") or []))
54
+ mat = material_de(texto)
55
+ if mat is None:
56
+ continue
57
+ d = sorted(dims[0], reverse=True)
58
+ a, b = d[0] * IN_A_CM, d[1] * IN_A_CM
59
+ if not (1 <= a <= 150 and 0.5 <= b <= 150 and d[2] * IN_A_CM >= 0.1):
60
+ continue
61
+ peso = mass * KG_A_G
62
+ if not (5 <= peso <= 30000):
63
+ continue
64
+ filas.append({"material": mat, "n_piezas": 1, "area_cm2_total": a * b,
65
+ "largo_cm_medio": a, "ancho_cm_medio": b, "largo_cm_max": a,
66
+ "espesor_cm": d[2] * IN_A_CM, "peso_real_g": peso})
67
+
68
+ df = pd.DataFrame(filas)
69
+ df.to_csv(SALIDA, index=False)
70
+ print("Filas:", len(df))
71
+ print(df.material.value_counts())
72
+ print(df.groupby("material").peso_real_g.median())
73
+
entrenamiento/importar_externos.py ADDED
@@ -0,0 +1,63 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import numpy as np
3
+ import pandas as pd
4
+
5
+ ENTRADA = Path("datos/externos")
6
+ SALIDA = Path("datos/peso/externo.csv")
7
+
8
+ # EDITA ESTO tras la primera ejecución, cuando veas los nombres de columnas
9
+ COL = {"peso": None, "largo": None, "ancho": None, "material": None}
10
+ A_GRAMOS = 1.0 # libras = 453.592 | kg = 1000 | gramos = 1
11
+ A_CM = 1.0 # pulgadas = 2.54 | cm = 1
12
+
13
+ def leer(p):
14
+ s = p.suffix.lower()
15
+ if s == ".parquet":
16
+ return pd.read_parquet(p)
17
+ if s == ".csv":
18
+ return pd.read_csv(p)
19
+ if s in (".json", ".jsonl"):
20
+ return pd.read_json(p, lines=(s == ".jsonl"))
21
+ return None
22
+
23
+ tablas = []
24
+ for p in sorted(ENTRADA.rglob("*")):
25
+ if p.is_file():
26
+ t = leer(p)
27
+ if t is not None:
28
+ print("Leído:", p, t.shape)
29
+ tablas.append(t)
30
+ if not tablas:
31
+ raise SystemExit("No hay archivos .csv/.parquet/.json en datos/externos")
32
+
33
+ df = pd.concat(tablas, ignore_index=True)
34
+ if not all(COL.values()):
35
+ print("\nColumnas disponibles:", list(df.columns))
36
+ print(df.head(3).T)
37
+ raise SystemExit("\nEdita COL (y las unidades) en este script y vuelve a ejecutarlo.")
38
+
39
+ def primero(v):
40
+ if isinstance(v, str):
41
+ return v.lower()
42
+ if hasattr(v, "__len__") and len(v) > 0:
43
+ return str(v[0]).lower()
44
+ return "otro"
45
+
46
+ peso = pd.to_numeric(df[COL["peso"]], errors="coerce") * A_GRAMOS
47
+ a = pd.to_numeric(df[COL["largo"]], errors="coerce") * A_CM
48
+ b = pd.to_numeric(df[COL["ancho"]], errors="coerce") * A_CM
49
+ out = pd.DataFrame({
50
+ "foto": "externo",
51
+ "sesion": "externo",
52
+ "tipo_objeto": "externo",
53
+ "material": df[COL["material"]].apply(primero),
54
+ "n_piezas": 1,
55
+ "area_cm2_total": a * b,
56
+ "largo_cm_medio": np.maximum(a, b),
57
+ "ancho_cm_medio": np.minimum(a, b),
58
+ "largo_cm_max": np.maximum(a, b),
59
+ "peso_real_g": peso,
60
+ }).replace([np.inf, -np.inf], np.nan).dropna()
61
+ out = out[(out.peso_real_g > 0) & (out.largo_cm_medio > 0) & (out.ancho_cm_medio > 0)]
62
+ out.to_csv(SALIDA, index=False)
63
+ print("Filas guardadas:", len(out), "->", SALIDA)
entrenamiento/indexar_emma.py ADDED
@@ -0,0 +1,53 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pickle, zlib, random
2
+ import numpy as np
3
+ import pandas as pd
4
+ from sentence_transformers import SentenceTransformer
5
+
6
+ RUTA = "datos/externos/EMMa_v1.1.fdd"
7
+ N = 150000
8
+
9
+ f = open(RUTA, "rb")
10
+ f.seek(-8, 2)
11
+ n = int.from_bytes(f.read(8), "little")
12
+ f.seek(-(8 + n), 2)
13
+ idx = pickle.loads(f.read(n))
14
+ items = list(idx.items())
15
+ random.seed(0)
16
+ items = sorted(random.sample(items, 600000), key=lambda kv: kv[1][0])
17
+
18
+ def leer(ini, largo):
19
+ f.seek(ini)
20
+ b = f.read(largo)
21
+ try:
22
+ return pickle.loads(b)
23
+ except Exception:
24
+ return pickle.loads(zlib.decompress(b))
25
+
26
+ filas = []
27
+ for asin, (ini, largo) in items:
28
+ try:
29
+ r = leer(ini, largo)
30
+ except Exception:
31
+ continue
32
+ mass, title = r.get("mass"), str(r.get("title", "")).strip()
33
+ if not mass or not title or r.get("quantity", 1) != 1:
34
+ continue
35
+ peso = mass * 1000
36
+ if not (5 <= peso <= 30000):
37
+ continue
38
+ dims = r.get("dimensions")
39
+ d = ""
40
+ if dims and len(dims) == 1:
41
+ d = " x ".join(f"{x * 2.54:.1f}" for x in sorted(dims[0], reverse=True))
42
+ filas.append({"asin": asin, "title": title, "peso_g": peso, "dims_cm": d})
43
+ if len(filas) >= N:
44
+ break
45
+
46
+ df = pd.DataFrame(filas)
47
+ print("Productos:", len(df))
48
+ model = SentenceTransformer("clip-ViT-B-32")
49
+ emb = model.encode(df["title"].tolist(), batch_size=256, show_progress_bar=True,
50
+ normalize_embeddings=True, convert_to_numpy=True)
51
+ np.save("datos/externos/emma_clip.npy", emb.astype("float16"))
52
+ df.to_csv("datos/externos/emma_clip.csv", index=False)
53
+ print("Listo")
entrenamiento/limpiar_emma.py ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pandas as pd, numpy as np
2
+
3
+ RUTA = "datos/externos/emma_peso.csv"
4
+ df = pd.read_csv(RUTA)
5
+ print("Antes:", len(df))
6
+
7
+ df = df.replace([np.inf, -np.inf], np.nan).dropna()
8
+ df = df[(df.largo_cm_medio >= 1) & (df.largo_cm_medio <= 150)]
9
+ df = df[(df.ancho_cm_medio >= 0.5) & (df.ancho_cm_medio <= 150)]
10
+ df = df[(df.peso_real_g >= 5) & (df.peso_real_g <= 30000)]
11
+ df["area_cm2_total"] = df.largo_cm_medio * df.ancho_cm_medio
12
+
13
+ print("Después:", len(df))
14
+ print(df.describe())
15
+ df.to_csv(RUTA, index=False)
entrenamiento/mapeo.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Traduce las etiquetas de cada dataset a nuestros defectos.
2
+
3
+ Cada dataset nombra sus clases a su manera ("Dead_Knot", "rust", "damaged", "Normal_Glass").
4
+ Aquí se convierten en los códigos de app/ml/estado/defectos.py, o en LIMPIO si la clase
5
+ indica que la pieza está en buen estado. Las clases que no se reconocen se ignoran.
6
+ """
7
+
8
+ import re
9
+
10
+ LIMPIO = "__limpio__"
11
+
12
+ # Clases que significan "sin defecto". Se comparan con el nombre entero (normalizado).
13
+ _SIN_DEFECTO = re.compile(
14
+ r"^(no ?defects?|non ?damaged|not ?damaged|undamaged( ?packages?)?|normal( ?glass)?|"
15
+ r"good( ?box)?|ok|intact|defect ?free|clean|perfect|without ?defects?|sound|healthy)$"
16
+ )
17
+
18
+ # "car no damage", "without defects"... en cualquier parte del nombre.
19
+ _NEGACION = re.compile(r"\b(no|non|not|without) ?(damages?|damaged|defects?|defective)\b")
20
+
21
+ # Una clase puede activar varios defectos ("knot with crack" → nudo + grieta).
22
+ # \b = límite de palabra: "ink" no debe activarse con "wrinkle", ni "rot" con "protection".
23
+ _REGLAS: tuple[tuple[re.Pattern[str], str], ...] = tuple(
24
+ (re.compile(patron), defecto)
25
+ for patron, defecto in (
26
+ (r"crack|fissure|fracture|\bsplit|\bchecks?\b|\bchecked\b|cassure", "grieta"),
27
+ (
28
+ r"broken|\bholes?\b|\btear\b|\btorn\b|\bripped\b|\brip\b|puncture|shatter|"
29
+ r"missing|\bcut\b",
30
+ "rotura",
31
+ ),
32
+ (r"\brust|corros|oxid|pitting", "oxido"),
33
+ (
34
+ r"stain|\bspots?\b|\bspot\b|discolou?r|\bdirt|\bresin\b|\boil\b|\bink\b|\btache\b",
35
+ "mancha",
36
+ ),
37
+ (
38
+ r"\brot\b|\brotten\b|\brotting\b|\bmou?ld|\bfung|\bdecay|\bwet\b|moist|water damage",
39
+ "podredumbre",
40
+ ),
41
+ (r"\bdent|crush|deform|\bbent\b|wrinkl|crease|bulg|damage", "deformacion"),
42
+ (r"scratch|abrasion|scuff|\bwear\b|\bpeel", "rayon"),
43
+ (r"\bknots?\b", "nudo"),
44
+ )
45
+ )
46
+
47
+
48
+ def normalizar(nombre: str) -> str:
49
+ """Minúsculas y palabras separadas por un espacio: "Dead_Knot" → "dead knot"."""
50
+ return re.sub(r"[^a-z0-9]+", " ", nombre.lower()).strip()
51
+
52
+
53
+ def defectos_de_clase(nombre: str) -> set[str]:
54
+ """Defectos que indica una clase (vacío si no se reconoce). Puede devolver {LIMPIO}."""
55
+ texto = normalizar(nombre)
56
+ if _SIN_DEFECTO.match(texto) or _NEGACION.search(texto):
57
+ return {LIMPIO}
58
+ return {defecto for patron, defecto in _REGLAS if patron.search(texto)}
entrenamiento/preparar.py ADDED
@@ -0,0 +1,347 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Une las fuentes descargadas en un solo conjunto de datos.
2
+
3
+ uv run python -m entrenamiento.preparar
4
+
5
+ 1. Lee cada fuente descargada y traduce sus etiquetas a material y defectos.
6
+ 2. Quita imágenes repetidas (mismo contenido aunque vengan de fuentes distintas).
7
+ 3. Limita cuántas imágenes aporta cada clase de cada fuente y cada material, para que
8
+ ninguna domine el entrenamiento.
9
+ 4. Reparte en entrenamiento / validación / prueba (80/10/10) por "grupo": las variantes
10
+ aumentadas de una misma foto (Roboflow las nombra foto_jpg.rf.<hash>.jpg) quedan siempre
11
+ en el mismo lado, para que la evaluación no sea tramposa.
12
+ 5. Guarda una copia reducida (512 px) de cada imagen en datos/procesados/imagenes/ y el
13
+ listado en datos/procesados/manifiesto.jsonl.
14
+ """
15
+
16
+ import hashlib
17
+ import json
18
+ import random
19
+ import re
20
+ from collections import Counter, defaultdict
21
+ from concurrent.futures import ThreadPoolExecutor
22
+ from dataclasses import asdict, dataclass, field
23
+ from pathlib import Path
24
+
25
+ from PIL import Image, ImageOps
26
+
27
+ from app.ml.estado.defectos import ORDEN_DEFECTOS
28
+ from app.ml.materiales import ORDEN_MATERIALES
29
+ from entrenamiento.descargar import MARCA
30
+ from entrenamiento.fuentes import FUENTES, Fuente
31
+ from entrenamiento.mapeo import LIMPIO, defectos_de_clase
32
+ from entrenamiento.rutas import (
33
+ CRUDOS,
34
+ IMAGENES,
35
+ LADO_GUARDADO,
36
+ MANIFIESTO,
37
+ asegurar_carpetas,
38
+ )
39
+
40
+ EXTENSIONES = {".jpg", ".jpeg", ".png", ".webp", ".bmp"}
41
+ MAX_POR_MATERIAL = 4000
42
+ SEMILLA = 2026
43
+ Image.MAX_IMAGE_PIXELS = 60_000_000
44
+
45
+
46
+ @dataclass
47
+ class Registro:
48
+ fuente: str
49
+ ruta: str # archivo original
50
+ material: str | None
51
+ # Solo los defectos conocidos: 1 = presente, 0 = ausente. Los que faltan, desconocidos.
52
+ defectos: dict[str, int] = field(default_factory=dict)
53
+ grupo: str = ""
54
+ id: str = ""
55
+ split: str = ""
56
+ # Recuadro (x1, y1, x2, y2) si el registro es un objeto recortado de una foto mayor.
57
+ recorte: list[int] | None = None
58
+
59
+ @property
60
+ def clave_cupo(self) -> str:
61
+ positivos = sorted(d for d, v in self.defectos.items() if v)
62
+ return f"{self.material}|{'+'.join(positivos) or ('limpio' if self.defectos else '-')}"
63
+
64
+
65
+ def _norm(texto: str) -> str:
66
+ return re.sub(r"[\s_\-]+", " ", texto.lower()).strip()
67
+
68
+
69
+ def _grupo(fuente: str, ruta: Path) -> str:
70
+ # Roboflow: "IMG_123_jpg.rf.abc123.jpg" → todas las variantes comparten "IMG_123_jpg".
71
+ return f"{fuente}/{ruta.name.split('.rf.')[0]}"
72
+
73
+
74
+ def _imagenes(carpeta: Path):
75
+ for ruta in carpeta.rglob("*"):
76
+ if (
77
+ ruta.suffix.lower() in EXTENSIONES
78
+ and "__MACOSX" not in ruta.parts
79
+ and not ruta.name.startswith("._")
80
+ ):
81
+ yield ruta
82
+
83
+
84
+ # ------------------------------------------------------------------------------ lectores
85
+
86
+
87
+ def _leer_carpetas_material(fuente: Fuente, carpeta: Path) -> list[Registro]:
88
+ clases = {_norm(k): v for k, v in fuente.clases.items()}
89
+ ausentes = dict.fromkeys(fuente.defectos_ausentes, 0)
90
+ registros = []
91
+ for ruta in _imagenes(carpeta):
92
+ clase = next((_norm(p) for p in reversed(ruta.parts[:-1]) if _norm(p) in clases), None)
93
+ if clase is None or clases[clase] is None:
94
+ continue
95
+ registros.append(
96
+ Registro(
97
+ fuente.nombre, str(ruta), clases[clase], dict(ausentes), _grupo(fuente.nombre, ruta)
98
+ )
99
+ )
100
+ return registros
101
+
102
+
103
+ def _defectos_conocidos(nombres_clase: list[str]) -> set[str]:
104
+ conocidos: set[str] = set()
105
+ for nombre in nombres_clase:
106
+ conocidos |= defectos_de_clase(nombre) - {LIMPIO}
107
+ return conocidos
108
+
109
+
110
+ def _etiquetar(clases_imagen: set[str], conocidos: set[str]) -> dict[str, int]:
111
+ presentes: set[str] = set()
112
+ for nombre in clases_imagen:
113
+ presentes |= defectos_de_clase(nombre) - {LIMPIO}
114
+ return {d: int(d in presentes) for d in conocidos}
115
+
116
+
117
+ def _leer_coco(fuente: Fuente, carpeta: Path) -> list[Registro]:
118
+ anotaciones = list(carpeta.rglob("_annotations.coco.json"))
119
+ categorias_todas: list[str] = []
120
+ datos = []
121
+ for archivo in anotaciones:
122
+ coco = json.loads(archivo.read_text(encoding="utf-8"))
123
+ categorias = {c["id"]: c["name"] for c in coco.get("categories", [])}
124
+ categorias_todas += list(categorias.values())
125
+ datos.append((archivo.parent, coco, categorias))
126
+ conocidos = _defectos_conocidos(categorias_todas)
127
+
128
+ registros = []
129
+ material = fuente.material if fuente.usar_para_material else None
130
+ for carpeta_split, coco, categorias in datos:
131
+ por_imagen: dict[int, set[str]] = defaultdict(set)
132
+ for anotacion in coco.get("annotations", []):
133
+ por_imagen[anotacion["image_id"]].add(categorias.get(anotacion["category_id"], ""))
134
+ for imagen in coco.get("images", []):
135
+ ruta = carpeta_split / imagen["file_name"]
136
+ if ruta.is_file():
137
+ registros.append(
138
+ Registro(
139
+ fuente.nombre,
140
+ str(ruta),
141
+ material,
142
+ _etiquetar(por_imagen.get(imagen["id"], set()), conocidos),
143
+ _grupo(fuente.nombre, ruta),
144
+ )
145
+ )
146
+ return registros
147
+
148
+
149
+ MAX_RECORTES_POR_IMAGEN = 2
150
+ LADO_MINIMO_RECORTE = 48
151
+ MARGEN_RECORTE = 0.1
152
+
153
+
154
+ def _leer_recortes(fuente: Fuente, carpeta: Path) -> list[Registro]:
155
+ """Un registro por objeto etiquetado (los más grandes de cada foto), con su material."""
156
+ patrones = [(re.compile(p, re.I), m) for p, m in fuente.recortes.items()]
157
+ ausentes = dict.fromkeys(fuente.defectos_ausentes, 0)
158
+ registros = []
159
+ for archivo in carpeta.rglob("_annotations.coco.json"):
160
+ coco = json.loads(archivo.read_text(encoding="utf-8"))
161
+ material_de = {}
162
+ for c in coco.get("categories", []):
163
+ material_de[c["id"]] = next((m for p, m in patrones if p.search(c["name"])), None)
164
+ imagenes = {i["id"]: i for i in coco.get("images", [])}
165
+ por_imagen: dict[int, list[tuple[float, list[int], str]]] = defaultdict(list)
166
+ for a in coco.get("annotations", []):
167
+ material = material_de.get(a["category_id"])
168
+ imagen = imagenes.get(a["image_id"])
169
+ if material is None or imagen is None:
170
+ continue
171
+ x, y, w, h = a["bbox"]
172
+ if min(w, h) < LADO_MINIMO_RECORTE:
173
+ continue
174
+ mx, my = w * MARGEN_RECORTE, h * MARGEN_RECORTE
175
+ caja = [
176
+ max(0, int(x - mx)),
177
+ max(0, int(y - my)),
178
+ min(imagen["width"], int(x + w + mx)),
179
+ min(imagen["height"], int(y + h + my)),
180
+ ]
181
+ por_imagen[a["image_id"]].append((w * h, caja, material))
182
+ for id_imagen, objetos in por_imagen.items():
183
+ ruta = archivo.parent / imagenes[id_imagen]["file_name"]
184
+ if not ruta.is_file():
185
+ continue
186
+ for _, caja, material in sorted(objetos, reverse=True)[:MAX_RECORTES_POR_IMAGEN]:
187
+ registros.append(
188
+ Registro(
189
+ fuente.nombre,
190
+ str(ruta),
191
+ material,
192
+ dict(ausentes),
193
+ _grupo(fuente.nombre, ruta),
194
+ recorte=caja,
195
+ )
196
+ )
197
+ return registros
198
+
199
+
200
+ def _leer_clasificacion_rf(fuente: Fuente, carpeta: Path) -> list[Registro]:
201
+ rutas = [r for r in _imagenes(carpeta)]
202
+ conocidos = _defectos_conocidos(sorted({r.parent.name for r in rutas}))
203
+ material = fuente.material if fuente.usar_para_material else None
204
+ registros = []
205
+ for ruta in rutas:
206
+ clase = ruta.parent.name
207
+ if not defectos_de_clase(clase) and conocidos:
208
+ continue # clase que no dice nada del estado
209
+ registros.append(
210
+ Registro(
211
+ fuente.nombre,
212
+ str(ruta),
213
+ material,
214
+ _etiquetar({clase}, conocidos),
215
+ _grupo(fuente.nombre, ruta),
216
+ )
217
+ )
218
+ return registros
219
+
220
+
221
+ def leer_fuente(fuente: Fuente) -> list[Registro]:
222
+ carpeta = CRUDOS / fuente.nombre
223
+ if not fuente.entrenar or not (carpeta / MARCA).is_file():
224
+ return []
225
+ if fuente.recortes:
226
+ return _leer_recortes(fuente, carpeta)
227
+ if fuente.clases:
228
+ return _leer_carpetas_material(fuente, carpeta)
229
+ if any(carpeta.rglob("_annotations.coco.json")):
230
+ return _leer_coco(fuente, carpeta)
231
+ return _leer_clasificacion_rf(fuente, carpeta)
232
+
233
+
234
+ # ---------------------------------------------------------------------- limpieza y reparto
235
+
236
+
237
+ def _limitar(
238
+ registros: list[Registro], fuentes: dict[str, Fuente], rng: random.Random
239
+ ) -> list[Registro]:
240
+ # 1) cupo por (fuente, material, tipo de defecto)
241
+ por_clave: dict[tuple[str, str], list[Registro]] = defaultdict(list)
242
+ for r in registros:
243
+ por_clave[(r.fuente, r.clave_cupo)].append(r)
244
+ elegidos: list[Registro] = []
245
+ for (fuente, _), lista in por_clave.items():
246
+ rng.shuffle(lista)
247
+ elegidos += lista[: fuentes[fuente].max_por_clase]
248
+
249
+ # 2) cupo por material: se priorizan las imágenes que también enseñan defectos
250
+ por_material: dict[str | None, list[Registro]] = defaultdict(list)
251
+ for r in elegidos:
252
+ por_material[r.material].append(r)
253
+ final: list[Registro] = []
254
+ for material, lista in por_material.items():
255
+ if material is None:
256
+ final += lista
257
+ continue
258
+ con_defectos = [r for r in lista if any(r.defectos.values()) or "limpio" in r.clave_cupo]
259
+ resto = [r for r in lista if r not in con_defectos]
260
+ rng.shuffle(con_defectos)
261
+ rng.shuffle(resto)
262
+ final += (con_defectos + resto)[:MAX_POR_MATERIAL]
263
+ return final
264
+
265
+
266
+ def _split(grupo: str) -> str:
267
+ valor = int(hashlib.md5(grupo.encode()).hexdigest(), 16) % 100
268
+ return "entrenamiento" if valor < 80 else "validacion" if valor < 90 else "prueba"
269
+
270
+
271
+ def _guardar_copia(registro: Registro) -> tuple[Registro, str | None]:
272
+ """Guarda la copia reducida. Devuelve (registro, huella del contenido) o None si falla."""
273
+ try:
274
+ with Image.open(registro.ruta) as original:
275
+ if registro.recorte:
276
+ # Las cajas de COCO se refieren a los píxeles tal como están guardados.
277
+ imagen = original.convert("RGB").crop(tuple(registro.recorte))
278
+ else:
279
+ imagen = ImageOps.exif_transpose(original).convert("RGB")
280
+ if min(imagen.size) < 32:
281
+ return registro, None
282
+ imagen.thumbnail((LADO_GUARDADO, LADO_GUARDADO), Image.Resampling.LANCZOS)
283
+ huella = hashlib.md5(imagen.resize((16, 16)).convert("L").tobytes()).hexdigest()
284
+ imagen.save(IMAGENES / f"{registro.id}.jpg", quality=92)
285
+ return registro, huella
286
+ except Exception:
287
+ return registro, None
288
+
289
+
290
+ def main() -> None:
291
+ asegurar_carpetas()
292
+ rng = random.Random(SEMILLA)
293
+ fuentes = {f.nombre: f for f in FUENTES}
294
+
295
+ registros: list[Registro] = []
296
+ for fuente in FUENTES:
297
+ leidos = leer_fuente(fuente)
298
+ if leidos:
299
+ print(f"- {fuente.nombre}: {len(leidos)} imágenes")
300
+ registros += leidos
301
+ if not registros:
302
+ raise SystemExit(
303
+ "No hay fuentes descargadas. Ejecuta antes: python -m entrenamiento.descargar"
304
+ )
305
+
306
+ registros = _limitar(registros, fuentes, rng)
307
+ for r in registros:
308
+ r.id = hashlib.md5(f"{r.ruta}|{r.recorte}".encode()).hexdigest()[:16]
309
+ r.split = _split(r.grupo)
310
+
311
+ print(f"Guardando {len(registros)} copias reducidas en {IMAGENES} ...")
312
+ vistos: set[str] = set()
313
+ validos: list[Registro] = []
314
+ with ThreadPoolExecutor(max_workers=6) as pool:
315
+ for registro, huella in pool.map(_guardar_copia, registros):
316
+ if huella is None:
317
+ continue
318
+ if huella in vistos: # misma imagen en dos fuentes o repetida
319
+ (IMAGENES / f"{registro.id}.jpg").unlink(missing_ok=True)
320
+ continue
321
+ vistos.add(huella)
322
+ validos.append(registro)
323
+
324
+ with MANIFIESTO.open("w", encoding="utf-8") as salida:
325
+ for r in sorted(validos, key=lambda r: r.id):
326
+ salida.write(json.dumps(asdict(r), ensure_ascii=False) + "\n")
327
+
328
+ print(f"\nManifiesto: {MANIFIESTO} ({len(validos)} imágenes)")
329
+ print("\nMaterial por reparto:")
330
+ tabla = Counter((r.material, r.split) for r in validos)
331
+ for material in (*ORDEN_MATERIALES, None):
332
+ print(
333
+ f" {str(material):10} "
334
+ + " ".join(
335
+ f"{s[:5]}={tabla[(material, s)]:5}"
336
+ for s in ("entrenamiento", "validacion", "prueba")
337
+ )
338
+ )
339
+ print("\nDefectos (positivos / conocidos):")
340
+ for d in ORDEN_DEFECTOS:
341
+ conocidos = sum(1 for r in validos if d in r.defectos)
342
+ positivos = sum(r.defectos.get(d, 0) for r in validos)
343
+ print(f" {d:12} {positivos:5} / {conocidos:5}")
344
+
345
+
346
+ if __name__ == "__main__":
347
+ main()
entrenamiento/probar_foto.py ADDED
@@ -0,0 +1,37 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import joblib
3
+ import pandas as pd
4
+ from pathlib import Path
5
+
6
+ ruta, material = sys.argv[1], sys.argv[2]
7
+ largo, ancho, espesor = map(float, sys.argv[3:6])
8
+ n_piezas = int(sys.argv[6]) if len(sys.argv) > 6 else 1
9
+
10
+ foto = Path(ruta)
11
+ if not foto.exists():
12
+ sys.exit(f"No existe la foto: {foto}")
13
+ try:
14
+ from PIL import Image
15
+ w, h = Image.open(foto).size
16
+ print(f"Foto: {foto.name} ({w}x{h} px)")
17
+ except ImportError:
18
+ print(f"Foto encontrada: {foto.name}")
19
+
20
+ M = joblib.load("modelos_entrenados/peso/lgbm_peso.joblib")
21
+ modelo, columnas = M["modelo"], M["columnas"]
22
+
23
+ area = largo * ancho
24
+ fila = {
25
+ "area_pieza": area, "largo_cm_medio": largo, "ancho_cm_medio": ancho,
26
+ "largo_cm_max": largo * 1.01, "relacion": largo / ancho,
27
+ "espesor_cm": espesor, "volumen_cm3": area * espesor,
28
+ }
29
+ X = pd.DataFrame([fila])
30
+ for c in columnas:
31
+ if c.startswith("material_"):
32
+ X[c] = 1.0 if c == f"material_{material}" else 0.0
33
+ X = X.reindex(columns=columnas, fill_value=0.0)
34
+
35
+ peso = float(modelo.predict(X)[0]) * n_piezas
36
+ print(f"Material: {material} | {largo}x{ancho}x{espesor} cm | piezas: {n_piezas}")
37
+ print(f"Peso estimado: {peso:.0f} g")
entrenamiento/probar_peso.py ADDED
@@ -0,0 +1,39 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ import joblib
3
+ import pandas as pd
4
+
5
+ M = joblib.load("modelos_entrenados/peso/lgbm_peso.joblib")
6
+ modelo, columnas = M["modelo"], M["columnas"]
7
+
8
+ def predecir(material, largo, ancho, espesor, n_piezas=1):
9
+ area = largo * ancho
10
+ fila = {
11
+ "area_pieza": area, "largo_cm_medio": largo, "ancho_cm_medio": ancho,
12
+ "largo_cm_max": largo * 1.01, "relacion": largo / ancho,
13
+ "espesor_cm": espesor, "volumen_cm3": area * espesor,
14
+ }
15
+ X = pd.DataFrame([fila])
16
+ for c in columnas:
17
+ if c.startswith("material_"):
18
+ X[c] = 1.0 if c == f"material_{material}" else 0.0
19
+ X = X.reindex(columns=columnas, fill_value=0.0)
20
+ return float(modelo.predict(X)[0]) * n_piezas
21
+
22
+ if len(sys.argv) >= 5:
23
+ mat, l, a, e = sys.argv[1], *map(float, sys.argv[2:5])
24
+ n = int(sys.argv[5]) if len(sys.argv) > 5 else 1
25
+ print(f"{mat} {l}x{a}x{e} cm, {n} pieza(s): {predecir(mat, l, a, e, n):.0f} g")
26
+ else:
27
+ casos = [
28
+ ("metal", 100, 1.2, 1.2, 1, 888), # tu barra de acero (peso real conocido)
29
+ ("metal", 100, 1.2, 1.2, 3, 2650), # 3 barras
30
+ ("madera", 30, 20, 2, 1, None),
31
+ ("plastico", 20, 10, 5, 1, None),
32
+ ("carton", 40, 30, 20, 1, None),
33
+ ("vidrio", 20, 8, 8, 1, None),
34
+ ]
35
+ for mat, l, a, e, n, real in casos:
36
+ p = predecir(mat, l, a, e, n)
37
+ extra = f" | real: {real} g" if real else ""
38
+ print(f"{mat:9s} {l}x{a}x{e} cm x{n}: {p:7.0f} g{extra}")
39
+
entrenamiento/rutas.py ADDED
@@ -0,0 +1,62 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Rutas y constantes del entrenamiento."""
2
+
3
+ from pathlib import Path
4
+
5
+ from app.core.config import BASE_DIR, settings
6
+
7
+ DATOS = BASE_DIR / "datos"
8
+ CRUDOS = DATOS / "crudos" # descargas tal cual vienen de cada fuente
9
+ PROCESADOS = DATOS / "procesados"
10
+ IMAGENES = PROCESADOS / "imagenes" # copias reducidas y normalizadas (JPEG)
11
+ MANIFIESTO = PROCESADOS / "manifiesto.jsonl"
12
+ CARACTERISTICAS = PROCESADOS / "caracteristicas"
13
+ INFORME_FUENTES = PROCESADOS / "fuentes.json"
14
+
15
+ SALIDA_MODELO = settings.ruta_material_estado
16
+
17
+ # Backbone preentrenado (acceso restringido en Hugging Face: aceptar la licencia de DINOv3).
18
+ BACKBONE = "facebook/dinov3-convnext-tiny-pretrain-lvd1689m"
19
+ TAMANO_IMAGEN = 384
20
+ # Lado máximo de las copias guardadas en IMAGENES (deja margen para recortes aleatorios).
21
+ LADO_GUARDADO = 512
22
+
23
+
24
+ def asegurar_carpetas() -> None:
25
+ for carpeta in (CRUDOS, IMAGENES, CARACTERISTICAS):
26
+ carpeta.mkdir(parents=True, exist_ok=True)
27
+
28
+
29
+ def leer_env_local(nombre: str) -> str | None:
30
+ """Lee una variable del entorno o, en Windows, de las variables de usuario (setx)."""
31
+ import os
32
+
33
+ valor = os.environ.get(nombre)
34
+ if valor:
35
+ return valor
36
+ if os.name == "nt":
37
+ import winreg
38
+
39
+ try:
40
+ with winreg.OpenKey(winreg.HKEY_CURRENT_USER, "Environment") as clave:
41
+ return str(winreg.QueryValueEx(clave, nombre)[0]) or None
42
+ except OSError:
43
+ return None
44
+ return None
45
+
46
+
47
+ __all__ = [
48
+ "BACKBONE",
49
+ "CARACTERISTICAS",
50
+ "CRUDOS",
51
+ "DATOS",
52
+ "IMAGENES",
53
+ "INFORME_FUENTES",
54
+ "LADO_GUARDADO",
55
+ "MANIFIESTO",
56
+ "PROCESADOS",
57
+ "SALIDA_MODELO",
58
+ "TAMANO_IMAGEN",
59
+ "Path",
60
+ "asegurar_carpetas",
61
+ "leer_env_local",
62
+ ]
entrenamiento/splits_emma.py ADDED
@@ -0,0 +1,11 @@
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys
2
+ from freeze_dried_data import RFDD
3
+
4
+ try:
5
+ RFDD(sys.argv[1])
6
+ except KeyError as e:
7
+ tb = e.__traceback__
8
+ while tb.tb_next:
9
+ tb = tb.tb_next
10
+ obj = tb.tb_frame.f_locals["self"]
11
+ print("Splits:", list(obj.split_to_index.keys()))
entrenamiento/ver_emma.py ADDED
@@ -0,0 +1,26 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import sys, pickle, zlib
2
+ f = open(sys.argv[1], "rb")
3
+ f.seek(-8, 2)
4
+ n = int.from_bytes(f.read(8), "little")
5
+ f.seek(-(8 + n), 2)
6
+ idx = pickle.loads(f.read(n))
7
+ print("Registros:", len(idx))
8
+
9
+ def leer(inicio, largo):
10
+ f.seek(inicio)
11
+ b = f.read(largo)
12
+ try:
13
+ return pickle.loads(b)
14
+ except Exception:
15
+ return pickle.loads(zlib.decompress(b))
16
+
17
+ for i, (k, v) in enumerate(idx.items()):
18
+ print("\n--- asin:", k, "pos:", v)
19
+ r = leer(v[0], v[1])
20
+ if isinstance(r, dict):
21
+ for c, x in r.items():
22
+ print(f"{c}: {str(x)[:200]}")
23
+ else:
24
+ print(type(r), str(r)[:500])
25
+ if i >= 2:
26
+ break