Scripts, CSV de EMMa y guia de peso
Browse files- .gitattributes +1 -0
- datos/externos/emma_mat.csv +3 -0
- datos/peso/caracteristicas.csv +13 -0
- datos/peso/externo.csv +0 -0
- entrenamiento/README.md +72 -0
- entrenamiento/__init__.py +8 -0
- entrenamiento/afinar_completo.py +260 -0
- entrenamiento/buscar_roboflow.py +85 -0
- entrenamiento/caracteristicas.py +123 -0
- entrenamiento/colab_entrenamiento.ipynb +137 -0
- entrenamiento/descargar.py +230 -0
- entrenamiento/empaquetar_colab.py +40 -0
- entrenamiento/entrenar.py +460 -0
- entrenamiento/entrenar_peso.py +85 -0
- entrenamiento/evaluar_conteo.py +136 -0
- entrenamiento/fuentes.py +311 -0
- entrenamiento/hf_sync.py +32 -0
- entrenamiento/hf_sync_datos.py +28 -0
- entrenamiento/importar_emma.py +59 -0
- entrenamiento/importar_emma_mat.py +73 -0
- entrenamiento/importar_externos.py +63 -0
- entrenamiento/indexar_emma.py +53 -0
- entrenamiento/limpiar_emma.py +15 -0
- entrenamiento/mapeo.py +58 -0
- entrenamiento/preparar.py +347 -0
- entrenamiento/probar_foto.py +37 -0
- entrenamiento/probar_peso.py +39 -0
- entrenamiento/rutas.py +62 -0
- entrenamiento/splits_emma.py +11 -0
- entrenamiento/ver_emma.py +26 -0
.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
|