Spaces:
Sleeping
Sleeping
| """ | |
| 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 ─────────────────────────────────────────────────────────────── | |
| 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) | |