YELY_AI_Module / train /finetune_doctr.py
danielxdata's picture
Module IA YELY - CRNN fine-tune, API FastAPI, interface demo
b510add
Raw
History Blame Contribute Delete
10.5 kB
"""
finetune_doctr.py
------------------
Fine-tuning de crnn_vgg16_bn (doctr) sur les valeurs LCD
de pompes à carburant.
Copie de référence pour la reproductibilité (documentation technique).
Ce script attend le dataset généré par `annotator/` à la racine du dépôt
d'origine (../annotator/dataset_doctr/) — il n'est pas autonome dans ce
dossier livrable, qui ne contient volontairement pas les ~150 images
sources annotées. Voir docs/LIMITATIONS.md pour le contexte du dataset.
Dataset attendu (généré par prepare_doctr_dataset.py) :
annotator/dataset_doctr/
train/images/ + labels.json
val/images/ + labels.json
Usage (depuis le dépôt d'origine, dossier train/) :
python finetune_doctr.py
python finetune_doctr.py --epochs 50 --lr 3e-5
python finetune_doctr.py --from-scratch # si pas de connexion internet
"""
import os, json, argparse, time
import torch
from torch.utils.data import DataLoader
from doctr.datasets import RecognitionDataset, VOCABS
from doctr.models import crnn_vgg16_bn
# ─── Chemins (depuis train/) ──────────────────────────────────────────────────
DATASET_DIR = os.path.join("..", "annotator", "dataset_doctr")
OUTPUT_DIR = os.path.join("models", "v2") # ne pas écraser models/crnn_fuel_pump_best.pt (baseline)
VOCAB = VOCABS["french"] # contient 0-9, virgule, point
# ─── Dataloader ───────────────────────────────────────────────────────────────
IMG_H = 32
IMG_W = 256
def _resize_preserve_aspect(img, target_h: int, target_w: int):
"""Redimensionne en conservant le ratio d'aspect (hauteur fixe, largeur au
prorata) puis complète par du padding noir jusqu'à target_w, au lieu
d'étirer l'image. C'est ce que fait déjà `preprocess_crop` à l'inférence
(src/fine_tuned_inference.py) : entraîner avec un étirement forcé alors
que l'inférence préserve le ratio créait un décalage systématique entre
les chiffres vus à l'entraînement (déformés) et en production (non
déformés), ce qui pénalisait la précision du modèle.
"""
import torch.nn.functional as F
c, h, w = img.shape
new_w = max(1, min(target_w, round(target_h * w / max(h, 1))))
resized = F.interpolate(img.unsqueeze(0), size=(target_h, new_w),
mode="bilinear", align_corners=False).squeeze(0)
if new_w < target_w:
pad = torch.zeros(c, target_h, target_w - new_w, dtype=resized.dtype)
resized = torch.cat([resized, pad], dim=2)
elif new_w > target_w:
resized = resized[:, :, :target_w]
return resized
def collate_fn(batch):
"""Redimensionne toutes les images à taille fixe pour le CRNN, en
préservant le ratio d'aspect (voir _resize_preserve_aspect)."""
imgs, targets = zip(*batch)
resized = [_resize_preserve_aspect(img, IMG_H, IMG_W) for img in imgs]
return torch.stack(resized, 0), list(targets)
def build_loaders(batch_size: int):
paths = {
"train_img": os.path.join(DATASET_DIR, "train", "images"),
"train_lbl": os.path.join(DATASET_DIR, "train", "labels.json"),
"val_img": os.path.join(DATASET_DIR, "val", "images"),
"val_lbl": os.path.join(DATASET_DIR, "val", "labels.json"),
}
for k, p in paths.items():
if not os.path.exists(p):
raise FileNotFoundError(
f"❌ Introuvable : {p}\n"
f" Lance d'abord : python prepare_doctr_dataset.py")
train_ds = RecognitionDataset(
img_folder=paths["train_img"], labels_path=paths["train_lbl"])
val_ds = RecognitionDataset(
img_folder=paths["val_img"], labels_path=paths["val_lbl"])
print(f"📦 Dataset : {len(train_ds)} train | {len(val_ds)} val")
trn = DataLoader(train_ds, batch_size=batch_size, shuffle=True,
num_workers=0, collate_fn=collate_fn, drop_last=False)
val = DataLoader(val_ds, batch_size=batch_size, shuffle=False,
num_workers=0, collate_fn=collate_fn)
return trn, val, len(train_ds), len(val_ds)
# ─── Modèle ───────────────────────────────────────────────────────────────────
def build_model(from_scratch: bool):
if from_scratch:
print("⚠️ Mode from-scratch (poids aléatoires).")
return crnn_vgg16_bn(
pretrained=False, pretrained_backbone=False, vocab=VOCAB)
try:
print("⬇️ Chargement des poids pré-entraînés Mindee...")
m = crnn_vgg16_bn(pretrained=True, vocab=VOCAB)
print("✅ Poids pré-entraînés chargés.")
return m
except Exception as e:
print(f"❌ Téléchargement échoué : {e}")
print(" Relance avec --from-scratch si pas de connexion internet.")
raise SystemExit(1)
# ─── Évaluation ───────────────────────────────────────────────────────────────
@torch.no_grad()
def evaluate(model, loader, device):
model.eval()
total_loss = n_ok = n_tot = 0
samples = []
for imgs, targets in loader:
imgs = imgs.to(device)
out = model(imgs, target=targets, return_preds=True)
total_loss += out["loss"].item()
for (pred, _), gt in zip(out["preds"], targets):
n_tot += 1
ok = pred.strip() == gt.strip()
if ok: n_ok += 1
if len(samples) < 8:
samples.append((gt, pred, "✅" if ok else "❌"))
return total_loss / max(len(loader), 1), n_ok / max(n_tot, 1), samples
# ─── Entraînement ─────────────────────────────────────────────────────────────
def train(epochs, lr, batch_size, from_scratch, patience):
device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
print(f"🖥️ Device : {device}")
trn_loader, val_loader, n_trn, n_val = build_loaders(batch_size)
model = build_model(from_scratch)
model.to(device)
# LR faible car fine-tuning — on préserve les poids pré-entraînés
optimizer = torch.optim.Adam(model.parameters(), lr=lr)
scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
optimizer, mode="min", factor=0.5, patience=3)
os.makedirs(OUTPUT_DIR, exist_ok=True)
best_loss = float("inf")
best_acc = 0.0
no_improv = 0
history = []
print(f"\n🚀 Fine-tuning : {epochs} epochs | lr={lr} | batch={batch_size}")
print("─" * 65)
for epoch in range(1, epochs + 1):
model.train()
t0 = time.time()
tr_loss = 0.0
nb = 0
for imgs, targets in trn_loader:
imgs = imgs.to(device)
optimizer.zero_grad()
out = model(imgs, target=targets, return_preds=False)
loss = out["loss"]
loss.backward()
torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
optimizer.step()
tr_loss += loss.item()
nb += 1
avg_tr = tr_loss / max(nb, 1)
vl, acc, samples = evaluate(model, val_loader, device)
scheduler.step(vl)
cur_lr = optimizer.param_groups[0]["lr"]
print(f"Ep {epoch:3d}/{epochs} | "
f"train={avg_tr:.4f} | val={vl:.4f} | "
f"acc={acc:.1%} | lr={cur_lr:.1e} | {time.time()-t0:.0f}s")
# Afficher des exemples de prédictions toutes les 5 epochs
if epoch % 5 == 0 or epoch == 1:
print(" Exemples val :")
for gt, pred, status in samples:
print(f" {status} GT='{gt}' → PRED='{pred}'")
history.append({"epoch": epoch, "train_loss": avg_tr,
"val_loss": vl, "val_acc": acc, "lr": cur_lr})
# Sauvegarder le meilleur modèle. Écrire dans un fichier temporaire
# puis renommer (os.replace, atomique) évite un crash Windows déjà
# observé (RuntimeError code 1224, fichier verrouillé pendant
# l'écriture directe — probablement un scan antivirus).
if vl < best_loss:
best_loss = vl
best_acc = acc
no_improv = 0
best_path = os.path.join(OUTPUT_DIR, "crnn_fuel_pump_best.pt")
tmp_path = best_path + ".tmp"
torch.save(model.state_dict(), tmp_path)
os.replace(tmp_path, best_path)
print(f" 💾 Meilleur modèle → {best_path}")
else:
no_improv += 1
if no_improv >= patience:
print(f"\n⏹ Early stopping ({patience} epochs sans amélioration)")
break
# Sauvegarde finale + historique
final = os.path.join(OUTPUT_DIR, "crnn_fuel_pump_final.pt")
torch.save(model.state_dict(), final)
with open(os.path.join(OUTPUT_DIR, "history.json"), "w") as f:
json.dump({"history": history, "best_val_loss": best_loss,
"best_val_acc": best_acc}, f, indent=2)
print("\n" + "=" * 65)
print(f" ✅ Entraînement terminé")
print(f" Meilleure val_acc : {best_acc:.1%}")
print(f" Meilleur modèle : {best_path}")
print(f" Modèle final : {final}")
print("=" * 65)
print("\n👉 Prochaine étape : python test_model.py")
# ─── Entrée ───────────────────────────────────────────────────────────────────
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--epochs", type=int, default=80)
parser.add_argument("--lr", type=float, default=5e-5)
parser.add_argument("--batch-size", type=int, default=8)
parser.add_argument("--patience", type=int, default=8)
parser.add_argument("--from-scratch", action="store_true")
args = parser.parse_args()
os.chdir(os.path.dirname(os.path.abspath(__file__)))
train(args.epochs, args.lr, args.batch_size,
args.from_scratch, args.patience)