Pablo Gómez, Gonzalo Maroto y Pablo Melero
Introducción
El deterioro de las infraestructuras viarias representa un problema de seguridad y coste económico de primer orden. La detección temprana y clasificación automática de grietas en pavimentos permite priorizar labores de mantenimiento antes de que el daño se vuelva crítico. En este trabajo exploramos el uso de modelos transformer preentrenados de la familia DINO como extractores de características visuales, combinados con clasificadores clásicos, para clasificar tipos de grietas en imágenes de carretera sin necesidad de entrenar una red desde cero.
El objetivo es doble: comparar la calidad de las representaciones producidas por distintas versiones de DINO sobre el mismo clasificador, y analizar visualmente qué regiones de la imagen determinan las predicciones mediante técnicas de explicabilidad.
Estado del arte
La detección automática de daños en carreteras ha evolucionado desde enfoques basados en visión clásica con filtros de Sobel y transformadas de Hough hacia arquitecturas de aprendizaje profundo. Los trabajos pioneros de Arya et al. (2018, 2022) establecieron los datasets de referencia para el campo, siendo RDD2022 el más completo con más de 47.000 imágenes anotadas en siete países.
En paralelo, los modelos de visión preentrenados mediante self-supervised learning han demostrado producir representaciones visuales de alta calidad sin necesidad de etiquetas. DINO (Caron et al., 2021) introdujo la destilación knowledge entre redes estudiante-profesor obteniendo features con propiedades emergentes de segmentación semántica. DINOv2 (Oquab et al., 2023) escaló este enfoque con un dataset curado de 142 millones de imágenes. DINOv3 (Meta AI, 2025) extiende la familia con 1.689 millones de imágenes y nuevas arquitecturas ConvNeXt entrenadas por destilación desde un ViT-7B.
Materiales y métodos
Dataset
Se utiliza RDD2022, filtrado mediante una limpieza sistemática: eliminación de imágenes aéreas (China_Drone), imágenes sin anotaciones, imágenes con únicamente la clase D40 (baches), y submuestreo estratificado por país. El resultado es un dataset de 8.797 imágenes con tres clases objetivo en esquema multilabel:
📄 Código: clean_rdd2022.py

| Clase | Descripción | Frecuencia |
|---|---|---|
| D00 | Grietas longitudinales | 66,5% |
| D10 | Grietas transversales | 33,7% |
| D20 | Grietas en malla (aligator) | 34,8% |
El dataset se divide en las siguientes proporciones siendo el número de imágenes suficiente a primera vista para comenzar el análisis.
| Split | Imágenes | Proporción |
|---|---|---|
| Train | 6.159 | 70% |
| Validación | 1.319 | 15% |
| Test | 1.319 | 15% |
📄 Código: análisis_dataset.ipynb
Extracción de características
Para cada imagen, el transformer produce una secuencia de tokens. El vector de features final concatena el CLS token con la media de los patch tokens, resultando en un vector de 1.536 dimensiones para los modelos ViT-B. Para DINOv3-ViT se descartan los 4 register tokens antes del pooling. Para DINOv3-ConvNext se concatenan los avg pools de las 4 etapas jerárquicas produciendo un vector de 1.440 dimensiones.
📄 Código: extract_features.py
# CLS token + media de patch tokens → vector 1536 dims
out = model.forward_features(image)
cls_feat = out["x_norm_clstoken"]
patch_feat = out["x_norm_patchtokens"].mean(dim=1)
features = torch.cat([cls_feat, patch_feat], dim=1)
# DINOv3-ViT: descartar 4 register tokens
cls_feat = last_hidden[:, 0, :]
patch_feat = last_hidden[:, 5:, :].mean(dim=1)
features = torch.cat([cls_feat, patch_feat], dim=1)
# DINOv3-ConvNext: avg pool 4 etapas → 1440 dims
features = torch.cat([
stage.mean(dim=[2, 3])
for stage in out.hidden_states
], dim=1)
Clasificadores
Sobre las features se entrenan tres clasificadores en esquema multilabel con umbral 0.5 por clase. El desbalance se compensa calculando un peso por clase inversamente proporcional a su frecuencia. Se evalúan MLP (tres capas con BatchNorm, ReLU y Dropout), SVM (LinearSVC con StandardScaler) y LightGBM (clasificador binario por clase con early stopping).
📄 Código: train_MLP.py · train_SVM_LightGBM.py
Resultados
Primeros Resultados — Extractor fijo
En primer lugar se extraen características con DINOv1 y se entrena un MLP con distintos hiperparámetros. Las curvas de aprendizaje y métricas de evaluación obtenidas se muestran a continuación.


Se compara a continuación el desempeño de DINOv1 y DINOv2 usando los mejores hiperparámetros obtenidos previamente, ambos con el mismo MLP

Posteriormente se extiende el análisis al resto de extractores DINO seleccionados, evaluando su desempeño de nuevo con el entrenamiento del mismo modelo.


Los resultados muestran un patrón consistente. D00 se clasifica bien en todos los casos (F1: 0.80–0.85) gracias a su mayor representación en el dataset. D20 mejora progresivamente con extractores más potentes (F1: 0.65–0.77 con LightGBM). D10 es sistemáticamente la clase más difícil (F1: 0.54–0.62), lo que indica que las grietas transversales son visualmente similares a las longitudinales a nivel de imagen completa. LightGBM supera consistentemente a SVM en todas las combinaciones.
Análisis de explicabilidad — diagnóstico del fallo
Para entender por qué D10 presenta resultados tan pobres, se aplicaron dos técnicas de explicabilidad sobre DINOv2 sin fine-tuning.
Attention Rollout combina los mapas de atención de todas las capas del transformer acumulativamente, modelando las conexiones residuales mediante la suma con la matriz identidad en cada capa. El mapa resultante indica qué regiones de la imagen han contribuido más a la representación final del CLS token.

🖼️ Imagen: attention_rollout.py
Patch Importance SHAP proyecta los SHAP values del clasificador LightGBM de vuelta al espacio de patches, generando un mapa de calor por clase que indica qué regiones han contribuido a predecir D00, D10 y D20 respectivamente.

🖼️ Imagen: patch_importance.py
Ambos análisis revelan el mismo problema: DINOv2 sin fine-tuning no atiende a las grietas sino a regiones genéricas de alto contraste. Los mapas son difusos y no discriminan entre zonas con y sin grieta. Esto ocurre porque DINO fue preentrenado con imágenes generales y sus representaciones no están adaptadas al dominio específico del pavimento. Este diagnóstico explica directamente el techo de F1 en D10: si el extractor no distingue la orientación de las grietas, ningún clasificador encima puede hacerlo.
Fase 2 — Fine-tuning de DINOv2
Como respuesta al fallo detectado en la explicabilidad, se aplica fine-tuning de nivel 2 sobre DINOv2: las capas 0-9 permanecen congeladas y las últimas 2 capas (10-11) junto con la cabeza clasificadora se descongelan y entrenan con learning rate diferencial: LR backbone = 10⁻⁵ (ajuste suave) y LR cabeza = 10⁻³ (aprendizaje normal). Se usa CosineAnnealingLR y gradient clipping (norma máxima 1.0) para estabilidad.
El fine-tuning de clasificación mejora ligeramente las métricas respecto al extractor fijo, pero los mapas de Attention Rollout siguen siendo igual de difusos. La razón es directa: una loss de clasificación le dice al modelo si hay grietas en la imagen, pero no dónde están. El gradiente no contiene información espacial, por lo que las capas descongeladas no aprenden a localizar las grietas sino a discriminar su presencia a nivel global.
📄 Código clasificación: finetune_dinov2.py
Dado este resultado, se implementa un segundo fine-tuning orientado a detección, añadiendo una head tipo DETR con 20 queries aprendibles, Hungarian matching y pérdidas combinadas de clasificación, L1 de bounding box y GIoU.
📄 Código detección: En este caso el gradiente sí fluye desde la localización de los bounding boxes, por lo que en teoría el modelo debería aprender a prestar atención a las regiones con grietas. Sin embargo, los mapas de atención obtenidos son visualmente indistinguibles de los del fine-tuning de clasificación, y el mAP@0.5 es igualmente bajo. Esto tiene sentido: arquitecturas tipo DETR requieren típicamente 300 épocas sobre datasets de cientos de miles de imágenes para converger, y aquí se entrena con 50 épocas y 6.000 imágenes. La señal de los bounding boxes no es suficiente para redirigir la atención del transformer de forma visible en tan pocas iteraciones, lo que establece el límite claro del enfoque actual y motiva el trabajo futuro. 🖼️ Imagen: Conclusiones El experimento de explicabilidad ha sido más informativo que los propios resultados de clasificación. Sin fine-tuning, DINO actúa como un extractor genérico que no distingue la orientación de las grietas, lo que explica directamente el techo de F1 en D10. Ni el fine-tuning de clasificación ni el de detección producen mapas de atención interpretables, y en ambos casos el entrenamiento dejó de mejorar a partir de la época 3: con 6.000 imágenes el modelo converge rápidamente hacia un mínimo local del que no puede escapar por falta de variabilidad en los datos. En cuanto a clasificación, LightGBM supera consistentemente a SVM y MLP gracias a su manejo del desbalance de clases. El techo de F1 en D10 es consistente en todos los extractores y clasificadores probados, confirmando que el problema no es el clasificador sino la pérdida de información de orientación al hacer pooling global de los patch tokens. Como trabajo futuro, arquitecturas más ligeras que DETR como RT-DETR o YOLO-World convergerían con datasets pequeños y podrían producir mapas interpretables. Ampliar el dataset con más imágenes anotadas con bounding boxes es la otra vía natural para obtener la supervisión espacial que el modelo necesita. 🔗 Código completo disponible en: Blog Pasd Referenciasfinetune_dinov2_detection.py# 20 queries aprendibles que atienden a los patch tokens del backboneself.queries = nn.Embedding(20, d_model)
decoded = self.decoder(queries, patch_tokens) # transformer decoder
logits = self.class_head(decoded) # clase: D00 / D10 / D20 / fondo
boxes = self.bbox_head(decoded) # bbox: (cx, cy, w, h) en [0,1]
# Hungarian matching — asignación óptima predicciones vs ground truth
cost = LAMBDA_CLASS * class_cost + LAMBDA_BBOX * bbox_cost + LAMBDA_GIOU * giou_cost
pred_idx, gt_idx = linear_sum_assignment(cost.detach().cpu().numpy())
# Loss combinada: clasificación + L1 bounding box + GIoU
bbox_loss = F.l1_loss(pred_boxes[pred_idx], gt_boxes[gt_idx])
giou_loss = (1 - generalized_iou(pred_boxes[pred_idx], gt_boxes[gt_idx])).mean()
loss = LAMBDA_CLASS * class_loss + LAMBDA_BBOX * bbox_loss + LAMBDA_GIOU * giou_loss

attention_rollout_fine_tunning.py