← Volver a OsteoLab ← → avanzar · N notas · P ponente · O índice · F pantalla completa · T cronómetro

PyConES 2026 · Barcelona

¿De quién es este hueso?

Clasificación zooarqueológica con Python, Machine Learning y datos sintéticos

Francisco Javier Luengo Gutiérrez Arqueólogo y Machine Learning Engineer

Antes de empezar

Qué te llevas de esta charla

🎨

Datos sintéticos con Python

Blender es un intérprete de Python. Mallas 3D → miles de imágenes etiquetadas gratis.

📐

Forma + textura

Medidas clásicas con OpenCV/NumPy concatenadas a un embedding de PyTorch.

⚖️

Evaluar sin engañarte

Fuga por sujeto, balanced accuracy, semillas y tests pareados con n pequeño.

🚀

De notebook a servicio

FastAPI, MLflow, DVC y Docker Compose en un proyecto de una persona.

El problema

Miles de huesos, muy pocos especialistas

  • En una excavación salen bolsas y bolsas de restos óseos.
  • Identificarlos exige un zooarqueólogo y una colección de referencia.
  • Ese especialista es el cuello de botella: el material espera meses en el almacén.
  • Pregunta: ¿puede una foto dar una primera identificación en segundos?

Objetivo del proyecto

Una herramienta de apoyo al criterio experto,
no un sustituto.

Tria rápido, prioriza, y deja las decisiones finas a la persona.

Juego rápido

¿Qué hueso es este?

Fémur humano expuesto en una vitrina de museo, con reflejos en el cristal y otros objetos alrededor
Foto real, CC0 — colecciones antropológicas, Charles University (Wikimedia Commons)
Fémur humano. Y fíjate en todo lo demás: cristal, reflejos, soporte, otros objetos, desenfoque. Así son las fotos reales.

Por qué es difícil

Pocos datos, muchas clases

122mallas 3D de huesos reales
1–2especímenes por hueso concreto
21huesos objetivo × varias especies
8.5×desbalance entre clases

Primera decisión de ML — antes de escribir un modelo

No predecir hueso exacto ni especie. Predecir grupo morfológico:

cráneomandíbula/maxilarhueso largohueso plano pelvissacrovértebracostillahueso pequeño

La solución en una slide

De la foto al hueso

📷Foto
🔍CLIP
¿es un hueso?
✂️Grounded-SAM
segmenta
📐OpenCV
medidas de forma
🧠ResNet18
embedding
🧬Híbrido
9 grupos
🎯Etapa 2
hueso / taxón
🦴Malla 3D
Sketchfab, MorphoSource, Smithsonian
🎨Blender + bpy
render de laboratorio
🖼️+ fotos reales
curadas a mano
📊CSV de features
+ recortes

Todo Python: bpy · opencv · transformers · timm · torch · scikit-learn · fastapi · mlflow · dvc

Datos sintéticos

El mismo fémur, cuatro generaciones de render

Malla: Human Femur, Eric Bauer · Elon University (Sketchfab, CC BY). Cada render trae su máscara ground-truth: etiqueta y segmentación gratis.

Datos sintéticos

Blender es un intérprete de Python

blender --background --python render_bone_lab.py -- \
    --mesh femur.glb --n-drops 2 --views-per-drop 3 \
    --light-preset calido_suave
import bpy

def add_bone_rigid_body(obj):
    bpy.context.view_layer.objects.active = obj
    bpy.ops.rigidbody.object_add(type="ACTIVE")
    # MESH exacto: una costilla rueda como una mecedora
    obj.rigid_body.collision_shape = "CONVEX_HULL"
    obj.rigid_body.friction = 0.6
    obj.rigid_body.angular_damping = 0.45
  • Sin UI: --background, un proceso por espécimen lanzado con subprocess.run.
  • Física real: el hueso se suelta sobre la mesa; su postura es la que tendría en la vida real.
  • 121/121 especímenes sin fallos → 726 imágenes con máscara.

Datos sintéticos

Centrar el objeto, repartir cámaras, mirar al centro

def fibonacci_sphere(n, radius):
    """Direcciones bien repartidas en la esfera -- no
    vistas al azar, que se amontonan en los polos."""
    points = []
    phi = math.pi * (3.0 - math.sqrt(5.0))
    for i in range(n):
        y = 1 - (i / max(n - 1, 1)) * 2
        r = math.sqrt(max(0.0, 1 - y * y))
        theta = phi * i
        points.append(Vector((math.cos(theta)*r, y,
                               math.sin(theta)*r)) * radius)
    return points

def look_at_matrix(cam_location, target):
    direction = target - cam_location
    rot = direction.to_track_quat("-Z", "Y")
    mat = rot.to_matrix().to_4x4()
    mat.translation = cam_location
    return mat

# centro en espacio de MUNDO: no se mueve el objeto
# (evita mover padre+hijos del glTF dos veces)
center = Vector(all_verts_world.mean(axis=0))

for i, direction in enumerate(fibonacci_sphere(n_views, 1.0)):
    cam_location = center + direction * distance
    cam.matrix_world = look_at_matrix(cam_location, center)
  • Centro en mundo, no en el objeto: la jerarquía del glTF tiene padres e hijos; mover el objeto los desplaza dos veces.
  • Fibonacci sphere: vistas repartidas uniformemente, no un muestreo al azar que amontona cámaras en los polos.
  • Distancia por focal: 24-70mm variable → cada vista recalcula la distancia para que el hueso llene el fill objetivo del encuadre.
  • Entre vista y vista también cambia el HDRI y se reaplica la suciedad procedural: "dar vueltas" es también cambiar la escena, no solo la cámara.

Datos sintéticos

La física falla en silencio

def normalize_scale(obj):
    """Las mallas NO comparten escala (costilla r≈2,
    fémur≈12, escápula≈106). A escala grande el solver
    falla EN SILENCIO: el hueso se queda flotando."""
    _, radius = local_bbox_radius(obj)
    factor = TARGET_RADIUS / radius
    obj.scale = (factor, factor, factor)
    bpy.context.view_layer.objects.active = obj
    bpy.ops.object.transform_apply(
        location=False, rotation=False, scale=True)
SíntomaCausa
Hueso flotando a media caídaescala 2 – 106 unidades
Costilla rodando sin pararcolisión MESH inestable
Sin sombra de contactomargen de colisión del solver
Imagen plana, sin volumenluces iguales se cancelan
Hueso "de plástico"rugosidad baja en el PBR

Ningún test lo detectaba. Lo detectó mirar las imágenes: un visor en Streamlit y un contact sheet por lote.

El hueco sintético → real

El modelo aprendió a reconocer renders

Render limpio 77% accuracy en renders
vs
Foto real en vitrina 27% accuracy en fotos reales

Si tu test comparte distribución con el entrenamiento, tu test es optimista por construcción. Necesitas un examen con datos del mundo real, apartado desde el día 1.

El mayor salto

12 fotos reales pesaron más que toda la síntesis

0.3%del dataset eran fotos reales… y movieron más la aguja que cualquier mejora de render.

Problema: dentro de cada grupo, lo real es minoría (costilla: 5% de sus filas). Solución con PyTorch:

from torch.utils.data import WeightedRandomSampler

is_real = (train_df["source_path"]
           .str.startswith("data/real_photos_accepted/")
           .to_numpy())
weights = np.where(is_real, args.real_photo_boost, 1.0)

sampler = WeightedRandomSampler(
    weights=torch.as_tensor(weights, dtype=torch.double),
    num_samples=len(train_dataset),
    replacement=True,
)
# el balance ENTRE grupos sigue en la pérdida:
# class_weight="balanced"

Sintético para arrancar, real para llegar. Hoy: 357+ fotos reales curadas, con un examen fijo aparte que nunca entrena.

Features clásicas

Medir la forma con NumPy

def _axis_shape_features(centerline):
    pts = np.asarray(centerline, dtype=np.float64)
    chord = np.linalg.norm(pts[-1] - pts[0])
    arc = np.sum(np.linalg.norm(np.diff(pts, axis=0), axis=1))
    tortuosity = arc / chord          # 1.0 = recto

    angles = [_signed_turning_angle(*pts[i - 1:i + 2])
              for i in range(1, len(pts) - 1)]
    total = sum(abs(a) for a in angles)
    # 1.0 = gira siempre al mismo lado (arco, costilla)
    # 0.0 = se cancela (recta con ruido)
    consistency = abs(sum(angles)) / total
    return tortuosity, total, consistency

Del contorno (OpenCV) salen medidas invariantes a escala:

elongacióncircularidadsolidezmomentos de Hu perfil de anchuraagujerostortuosidadajuste a arco

→ RandomForestClassifier: rápido, explicable, y la base del híbrido.

Costilla vs hueso largo: los dos son "alargados". La diferencia es que una se curva siempre hacia el mismo lado.

Segmentación

El problema no era medir: era encontrar el hueso

antes

Umbral tipo Otsu

Funciona en fondo blanco. En una vitrina con reflejos, segmenta el cristal.

después

Grounded-SAM

Texto "bone" → caja con Grounding DINO → máscara con SAM. Mejora notable en geométrico e híbrido.

multi-instancia

Una foto, 24 costillas

Todas las cajas → una máscara por hueso → resto en gris. 42 recortes, 28 limpios.

recall de costilla 0.25→0.89

Deep learning

Primera CNN

9 grupos
  1. Cráneo
  2. Costillas
  3. Vértebras
  4. Huesos largos
  5. Pelvis
  6. Sacro
  7. Huesos cortos
  8. Huesos planos
  9. Mandíbula-maxilar
Dataset
3D
↓
3653 imágenes
sintéticas (122 huesos 3D)
+
75 imágenes
reales (examen, 0% en train)
↓↓
total = 3728 imágenes
Red Neuronal
224²
×3 canales
↓
resnet18
Lineal 512→128
+ ReLU + Dropout
Lineal 128→9
Resultado
Acc0.671
Recall0.47
F10.46
Precision0.50
Matriz de confusión

Deep learning · reproducido con código y dataset originales (commit 53fe6e3)

El primer experimento, completo

Primera vez que se mete una red neuronal: resnet18 preentrenada + cabeza propia, entrenada en 2 fases desde el primer commit. A la derecha, el resultado desglosado por clase — no solo el agregado que quedó escrito en su momento.

Arquitectura: backbone congelado primero (no destrozar los pesos de ImageNet con una cabeza sin entrenar), luego descongelado a LR muy bajo.

class BoneClassifier(nn.Module):
    def __init__(self, backbone="resnet18", n_classes=9):
        self.backbone = timm.create_model(
            backbone, pretrained=True, num_classes=0)   # 512-d
        self.head = nn.Sequential(
            nn.Linear(512, 128), nn.ReLU(), nn.Dropout(0.3),
            nn.Linear(128, n_classes))

    def forward(self, image):
        return self.head(self.backbone(image))  # solo píxeles

# Fase 1 (8 ép.): backbone congelado, AdamW(head, lr=1e-3)
# Fase 2 (15 ép.): backbone descongelado, lr=1e-5 + head lr=1e-3
# torch + torchvision + timm · PyTorch · GPU RTX 4080, ~3-4 min
DatasetEspecím. 3DImgs.Filas OK
Renders limpios~12227602544
Renders con ruido12212201109
Train+val total110—3653
↳ train88—2981
↳ val (espéc. fuera)22—672
Banco limpio (aparte)9—216
Fotos reales (examen)75 fotos—0% train

100% sintético en entrenamiento — las fotos reales nunca entran, solo examinan al final. Split por espécimen (GroupShuffleSplit), no por fila: ningún hueso físico repite entre train y val.

Validación (672 filas, especímenes nunca entrenados): desglose por clase que el log original de 2026-07-25 no guardó, solo el agregado.

grupo (val)prec.rec.F1n
costilla0.910.430.59136
cranio0.840.780.8178
hueso_largo0.510.830.6354
hueso_pequeno0.820.880.85235
hueso_plano0.460.530.49101
mandibula_maxilar0.400.060.1034
pelvis0 filas en este split0
sacro0 filas en este split0
vertebra0.600.740.6634
accuracy0.671672
macro avg0.500.470.46672
weighted avg0.730.670.67672
banco limpio (9 espec. nunca vistos)prec.rec.F1
accuracy global0.731 (216 filas, 24/grupo)
mejor: hueso_pequeno0.771.000.87
peor: costilla0.420.330.37

Confusión principal: mandibula_maxilar→sacro 14/34 (41%) — la más rara, sin relación morfológica; hueso_plano↔hueso_pequeno se pisan en ambas direcciones (24 y 12 filas), probable falta de escala de referencia en el recorte.

Reentrenado hoy con el código y los 3653 renders originales (commit 53fe6e3, MLflow+DVC) para sacar la matriz de confusión y el F1 que en 2026-07-25 solo quedaron como recall agregado.

Deep learning

La primera CNN: ¿ayuda de verdad?

class BoneClassifier(nn.Module):
    def __init__(self, backbone_name, num_classes):
        super().__init__()
        self.backbone = timm.create_model(
            backbone_name, pretrained=True, num_classes=0)
        embed_dim = self.backbone.num_features
        self.head = nn.Sequential(
            nn.Linear(embed_dim, 128), nn.ReLU(),
            nn.Dropout(0.3), nn.Linear(128, num_classes),
        )

    def forward(self, image, geom=None):
        embedding = self.backbone(image)
        return self.head(embedding)   # solo píxeles
  • resnet18, transfer learning en 2 fases: cabeza congelada 8 épocas, fine-tune 15 más a LR bajo.
  • GPU RTX 4080: 23 épocas en ~3-4 min.
  • Mismo split espécimen-fuera y mismos 3 bancos que el modelo geométrico, para comparar de verdad.
InternoBanco limpioFotos reales
Geométrico0.6450.7730.267
CNN sola0.6830.7550.493
Híbrido (concat)0.7010.8100.133

El híbrido ingenuo gana en los dos bancos sintéticos y se desploma en fotos reales (predice sacro casi siempre): aprendió a fiarse de las features geométricas, limpias en render, distorsionadas en foto real.

Deep learning

Forma + textura: el modelo híbrido

class BoneClassifier(nn.Module):
    def __init__(self, backbone_name, num_classes, n_geom):
        super().__init__()
        self.backbone = timm.create_model(
            backbone_name, pretrained=True, num_classes=0)
        embed_dim = self.backbone.num_features   # 512
        self.head = nn.Sequential(
            nn.Linear(embed_dim + n_geom, 128), nn.ReLU(),
            nn.Dropout(0.3), nn.Linear(128, num_classes),
        )

    def forward(self, image, geom):
        embedding = self.backbone(image)
        return self.head(torch.cat([embedding, geom], dim=1))
  • ResNet18 preentrenada con timm, sobre el recorte segmentado.
  • Fase 1: backbone congelado, solo la cabeza.
  • Fase 2: fine-tuning completo con learning rate bajo.
  • Las features geométricas se escalan con StandardScaler antes del cat.
recallformatextura
hueso largo0.880.38
vértebra0.140.71
cráneo0.000.57

Bajar de nivel

¿Fémur o húmero? Cascada, no más clases

# grupo de etapa 1 → modelo que afina dentro del grupo
STAGE2_MODELS = {
    "hueso_largo":   "stage2_hueso_largo",   # fémur/húmero/otro
    "hueso_pequeno": "stage2_hueso_pequeno", # astrágalo/otro
    "vertebra":      "stage2_vertebra",      # cervical/torácica/lumbar
}
STAGE2_TAXON_MODELS = {
    "cranio": "stage2_taxon_cranio",  # humano/carnívoro/ungulado/roedor
    "mandibula_maxilar": "stage2_taxon_mandibula_maxilar",
}
Plano
12 clases
Cascada
2 etapas
Accuracy0.7530.818
Balanced acc.0.6630.736
hueso plano (recall)0.000.25
húmero (recall)0.430.86

Meter todo en un clasificador plano hizo que clases no relacionadas se pisaran: hueso plano colapsó a 0 sin tocar sus datos.

Evaluar sin engañarte · 1

24 vistas del mismo fémur = examen con chuleta

from sklearn.model_selection import StratifiedGroupKFold

def subject_ids(df):
    # "specimen" solo puede colisionar entre colecciones
    return (df["species"].astype(str).str.strip() + "::"
            + df["specimen"].astype(str).str.strip()).to_numpy()

def subject_holdout(df, target_col, test_size=0.2, random_state=42):
    splitter = StratifiedGroupKFold(
        n_splits=round(1 / test_size), shuffle=True,
        random_state=random_state)
    train_idx, val_idx = next(splitter.split(
        df, y=df[target_col], groups=subject_ids(df)))
    train_df, val_df = df.iloc[train_idx], df.iloc[val_idx]
    overlap = set(subject_ids(train_df)) & set(subject_ids(val_df))
    if overlap:
        raise RuntimeError(f"Fuga de sujeto: {sorted(overlap)}")
    return train_df, val_df
  • train_test_split por fila reparte vistas del mismo hueso a ambos lados.
  • Agrupa por sujeto, estratifica por clase: StratifiedGroupKFold.
  • Falla ruidosamente: un RuntimeError vale más que un aviso en un log.
  • Con tests en pytest. 4343 filas, 418 sujetos, 0 compartidos.

Evaluar sin engañarte · 2

Accuracy esconde clases enteras

Geométrico
Accuracy0.544
Macro-F10.47
Recall de costilla0.00

Con 8.5× de desbalance, puedes acertar mucho en las clases grandes e ignorar una entera.

from sklearn.metrics import balanced_accuracy_score
# media del recall de cada clase
balanced_accuracy_score(y_true, y_pred)

🐛 Bug de regalo: la clase fantasma

# guardarraíl: ninguna clase con recall 0
recall_score(y_val, preds, labels=range(n_classes),
             average=None, zero_division=0)
# clase sin filas en val → recall 0 SIEMPRE
# → el guardarraíl saltaba en todas las epochs

recall_score(y_val, preds, labels=np.unique(y_val),
             average=None, zero_division=0)   # ✅

mandíbula/maxilar: 11 especímenes, ninguno cayó en val con esa semilla.

Evaluar sin engañarte · 3

0.844 → 0.870. ¿Lo celebramos?

from scipy.stats import binomtest
# McNemar exacto: solo cuentan las fotos donde discrepan
mejora, empeora = 5, 3            # de 77 fotos
binomtest(empeora, mejora + empeora, 0.5).pvalue
# 0.727  → no significativo

Bootstrap pareado (10 000 remuestreos): IC 95% de la diferencia [−3.9%, +9.1%]. Cruza el cero.

Lo correcto: 3 semillas contra 3 semillas, balanced accuracy:

semillaanteriorrender lab
420.7960.832
10.7930.831
70.8040.824

Accuracy cruda: rangos solapados. Balanced: sin solape. Ahí sí hay mejora.

¿Lo que aprende con imágenes sintéticas funciona con fotografías reales?

El espacio de embeddings mezcla dominio, pero organiza anatomía

UMAP de embeddings DINOv2 (128-d), coloreado por dominio: sintético y real aparecen mezclados dentro de las mismas islas, sin dos nubes separadas. Mixing score 0.631 (vecinos del mismo dominio entre los 10 más cercanos) frente a 0.501 esperado si estuviera perfectamente mezclado
Coloreado por dominio. Sintético y real se mezclan dentro de cada isla — no hay dos nubes separadas. Mixing score 0.631 vs 0.501 esperado si mezcla perfecta: queda gap, pero no domina la estructura.
El mismo UMAP coloreado por grupo osteológico: cráneo y mandíbula forman una isla propia arriba a la izquierda, vértebra/sacro/pelvis se agrupan en el centro, costilla/hueso_largo/hueso_pequeño/hueso_plano a la derecha — las 9 clases se organizan en regiones reconocibles
Mismo embedding, coloreado por grupo óseo. Las 9 clases se organizan en regiones reconocibles — el espacio aprende anatomía, no solo "¿render o foto?".
ConjuntoAccuracyBalanced acc.
Validación sintética0.786—
Test real no visto (n=48)0.8960.876

Mismo modelo (hybrid_lab_dinov2s_seed7, producción). No es caída de dominio — en este examen concreto el modelo rinde mejor en real que en su propia validación sintética. Balanced accuracy sintética y macro-F1 de ambos: no quedaron logueados, no se inventan aquí.

¿Cuánto acierta y dónde falla?

Sistema completo en producción, contra 48 fotos externas nunca vistas

AccuracyBalanced acc.Macro-F1Top-3
0.8540.8410.840.957

Pipeline entero (segmentación + clasificación), banco externo real_world_benchmark, no el dataset de entrenamiento.

Matriz de confusión normalizada por clase real: diagonal fuerte, los errores residuales caen entre costilla/hueso_pequeño, hueso_largo/hueso_pequeño, pelvis/hueso_plano y vértebra/cráneo-mandíbula
Matriz normalizada por clase real. Diagonal fuerte; n pequeño en costilla/hueso_pequeño (2 cada una) hace que un solo fallo pese 50%.
F1 por grupo óseo ordenado de peor a mejor, con n de cada clase: sacro 1.00, hueso_plano 0.93, pelvis y hueso_largo 0.92, cráneo 0.91, mandíbula 0.86, vértebra/hueso_pequeño/costilla 0.67
F1 por grupo, peor a mejor. Las tres colas rojas tienen n=2 o n=6 — el dato que falta importa tanto como el modelo.
Tres fotos reales mal clasificadas: una costilla confundida con hueso_pequeño al 100% de confianza, una pelvis con overlay de texto confundida con hueso_plano al 27% (empate técnico), y una vértebra sobre un poste confundida con cráneo al 96%
Tres fallos reales: dos con confianza alta (el modelo se equivoca seguro de sí mismo), uno con probabilidades casi empatadas (duda real).

¿Qué mejoró de verdad: datos, geometría o backbone?

Aislando cada pieza del sistema

Accuracy en examen de fotos reales: solo real 0.674, solo sintético 0.652, real+sintético 0.778 — el mixto gana claramente
Mismo examen (fotos reales), DINOv2. Solo sintético no basta por sí solo, pero sumado a lo real sube 10 puntos sobre solo-real.
117 fotos reales, arquitectura híbrida corregida (no la naive): solo visual accuracy 0.7094 / balanced 0.6755, híbrido con geometría accuracy 0.8120 / balanced 0.8098 — la geometría suma 10 puntos de accuracy y 13 de balanced accuracy
Arquitectura actual (fusión corregida, no la naive que degeneraba en sacro): con geometría +10 puntos de accuracy y +13 de balanced accuracy sobre solo-visual, en 117 fotos reales.
Screening a 1 semilla contra benchmark/selection (n=48): ResNet18a1 0.75, ResNet50 0.8125, ConvNeXtV2-tiny 0.7708, EVA02-small 0.75, DINOv2-small 0.75, DINOv2-reg4 0.875 (el mejor de los seis en esta sola semilla)
6 backbones, 1 semilla. DINOv2-reg4 (con "registers") parece ganar claro: +12.5 puntos sobre DINOv2-small normal.
Confirmación con 3 semillas: ResNet18 media 0.752 (0.7735/0.7418/0.7418), DINOv2-small media 0.820 (0.7608/0.8243/0.8757, ganador), DINOv2-reg4 media 0.810 (0.8624/0.8180/0.7492, descartado) — el seed0 favorable de reg4 no se sostiene
Con 3 semillas la historia cambia: reg4 cae a 0.810 (su seed0 era ruido de init), DINOv2-small normal gana con 0.820. Por eso no basta un solo run para decidir backbone.
Fusión (77 fotos reales)AccuracyBalanced acc.
Concatenación simple0.8440.796
Concat. + fondo negro0.8440.810
Puerta adaptativa0.8050.776

La puerta de fusión aprendida (gated) no supera a la concatenación simple — más complejidad, peor resultado. No se adoptó.

¿Hasta dónde podemos identificar un hueso?

Los expertos van bien; el cuello de botella es el router

Recall por taxón en el experto de cráneo (validación sintética): antes de sumar fotos reales, ungulado 0.00, humano 0.938, carnívoro 0.722, otro_primate 0.571, roedor 1.000; después de sumar 20 fotos reales de Openverse, ungulado 0.60, humano 0.70, carnívoro 0.688, otro_primate 0.784, roedor 0.75 — ungulado deja de estar colapsado, el resto se nivela
Taxón de cráneo. Ungulado sale del colapso (0.00→0.60), pero humano y roedor bajan — trade-off del guardarraíl anti-clase-cero, no mejora limpia.
Macro-F1 de los 5 expertos de etapa 2 (DINOv2-small, val real): hueso pequeño 0.892, taxón cráneo 0.857, vértebra 0.788, mandíbula taxón 0.481, hueso largo 0.489 — rendimiento muy desigual entre expertos
5 expertos, una semilla cada uno. Hueso pequeño y taxón cráneo rinden bien; hueso largo y mandíbula-taxón se quedan cortos — y estos dos últimos con n de especímenes muy bajo (25 y 2).
Matriz de confusión del experto de hueso largo (fémur/húmero/hueso_largo_otro), normalizada por fila: fémur recall 0.78 pero con solo 9 muestras de test, húmero 0.55, hueso_largo_otro 0.56 — el experto peor evaluado del proyecto, confunde húmero y hueso_largo_otro casi por igual
El experto peor evaluado del proyecto (macro-F1 0.49). Húmero y hueso_largo_otro se confunden casi por igual entre sí — la rama con menos datos de las cinco.
Comparación sobre las mismas 48 fotos con etiqueta fina: enrutamiento ideal (grupo óseo real) vs router real de etapa 1 — hueso fino 0.583 ideal vs 0.444 con router real, taxón 0.95 ideal vs 0.90 con router real
Mismas fotos, dos pasadas: con el grupo óseo real (ideal) y con lo que predice el router de producción. El router de etapa 1 (resnet18) sigue siendo el cuello de botella de la cascada completa.

n=48 fotos reales con etiqueta fina/taxón (de real_photos_accepted/test) — es un subconjunto pequeño porque la mayoría del banco solo tiene etiqueta de grupo óseo, no hueso/taxón concreto. Confirma, con más dato que antes, lo que ya se veía con n=6: el router pierde más que los expertos.

¿Conseguimos lo que nos propusimos?

Balance honesto: demostrado, parcial, pendiente

9grupos osteológicos clasificados
+10,4puntos de accuracy al sumar fotos reales a lo sintético (DINOv2, mismo examen)
0,82accuracy media DINOv2-small, comparativa de backbones (examen distinto al anterior)
Tabla balance de objetivos: clasificar 9 grupos osteológicos — demostrado; aprovechar imágenes sintéticas — evidencia positiva (0.674→0.778); identificar hueso concreto dentro del grupo — evidencia positiva (cascada 2 etapas); identificar taxón/especie — pendiente de verificar (n pequeño); apoyar trabajo arqueológico real — pendiente

"Demostrado" = hay evidencia experimental de la capacidad, no que el sistema sea perfecto ni esté validado para un yacimiento concreto. Estados provisionales: este proyecto no tiene un documento formal de objetivos/criterios de aceptación de partida con el que contrastar.

Tabla limitaciones conocidas: pocos ejemplos reales en grupos/taxones concretos (costilla 2 fotos, ungulado n=1 en test) — ampliar fotos, individuos y procedencias; salto de dominio sintético-real — validación externa y más diversidad de imagen; identificación taxonómica/cascada sin validar con especímenes independientes — evaluar expertos y cascada fuera del banco actual

Hemos demostrado la viabilidad de la clasificación osteológica asistida por datos sintéticos. El siguiente reto es validar su generalización, su precisión taxonómica y su utilidad en contextos arqueológicos reales.

La pregunta que guía el proyecto

No todo error se arregla optimizando

Antes de cambiar arquitectura o hiperparámetros, miramos qué clase de evidencia nos está dando el experimento.

🖼️

Falla en fotos reales
pero aprueba renders

→

recoge fotos reales

✂️

Falla al recortar
el objeto correcto

→

mejora segmentación

🦴

Una clase falla
con todos los modelos

→

prioriza esa clase al curar

📏

La mejora cabe
en el ruido del test

→

no promociones el cambio

Optimizar es una opción. Reformular, medir mejor, revisar imágenes y conseguir el dato que falta suelen ser decisiones más valiosas.

Resultado actual

Sobre 77 fotos reales que nunca vio

0.87accuracy
0.83balanced accuracy
3semillas, sin solape con el modelo anterior

✅ Muy bien

costilla, pelvis y sacro perfectos · hueso largo 0.94 · vértebra 0.89 · cráneo 0.86

⚠️ Todavía flojo

hueso plano (recall 0.25–0.67 según semilla): en 6 corridas, con 3 arquitecturas y 2 datasets. No es el modelo: faltan fotos reales.

Honestidad

Lo que no funcionó

  • ✗
    Mezcla de expertos con puerta aprendida (gated_hybrid): más parámetros, pérdidas auxiliares… y peor (0.805 vs 0.844).
  • ✗
    BlenderProc sustituyendo al render anterior: mejor en sintético, peor en real. Combinarlos sí ayudó.
  • ✗
    Elegir checkpoint con 12 fotos reales: el proxy subió, el examen bajó, y regaló una clase entera.
  • ✗
    Buscar "skull" en Openverse: diagramas, esculturas y… murciélagos llamados flying fox. Revisa antes de entrenar.

De notebook a servicio

Un proyecto de una persona, con MLOps de verdad

@app.post("/hybrid/classify")
async def hybrid_classification(file: UploadFile = File(...)):
    if not file.content_type.startswith("image/"):
        raise HTTPException(400, "El archivo debe ser una imagen")
    try:
        return classify_image_two_stage(file.file)
    except FileNotFoundError as e:   # modelo no entrenado
        raise HTTPException(503, str(e))

# la web de producto se sirve desde la misma app
app.mount("/", StaticFiles(directory=STATIC_DIR, html=True))
FastAPI API + web MLflow + Postgres, cada entrenamiento DVC datos versionados (6040 ficheros) Docker Compose ./run.sh Streamlit app "Tinder" de revisión de fotos pytest + GitHub Actions
▶ Demo en vivo Sube una foto → CLIP → geométrico + híbrido + VLM en paralelo

Para llevarse

Cinco lecciones que no dependen de los huesos

  1. Diagnostica el cuello de botella antes de optimizar el modelo.
  2. Sintético para arrancar, real para llegar. Unas pocas fotos reales valen oro.
  3. Separa por sujeto, nunca por fila. Y que falle con excepción.
  4. Balanced accuracy, varias semillas y test pareado antes de celebrar.
  5. Mira tus datos. Visores y apps de revisión baratas encuentran lo que los tests no ven.

Gracias

¿Preguntas?

¿De quién es este hueso? — OsteoLab

Francisco Javier Luengo Gutiérrez Arqueólogo y Machine Learning Engineer

Slides y demo en esta misma web · 2026.es.pycon.org