Spaces:
Sleeping
Sleeping
File size: 10,523 Bytes
b510add | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 | """
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)
|