danielxdata commited on
Commit
b510add
·
0 Parent(s):

Module IA YELY - CRNN fine-tune, API FastAPI, interface demo

Browse files
.dockerignore ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ __pycache__/
2
+ *.pyc
3
+ .pytest_cache/
4
+ tests/
5
+ logs/
6
+ photos/
7
+ train/
8
+ docs/
9
+ .env/
.gitattributes ADDED
@@ -0,0 +1,35 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ *.7z filter=lfs diff=lfs merge=lfs -text
2
+ *.arrow filter=lfs diff=lfs merge=lfs -text
3
+ *.bin filter=lfs diff=lfs merge=lfs -text
4
+ *.bz2 filter=lfs diff=lfs merge=lfs -text
5
+ *.ckpt filter=lfs diff=lfs merge=lfs -text
6
+ *.ftz filter=lfs diff=lfs merge=lfs -text
7
+ *.gz filter=lfs diff=lfs merge=lfs -text
8
+ *.h5 filter=lfs diff=lfs merge=lfs -text
9
+ *.joblib filter=lfs diff=lfs merge=lfs -text
10
+ *.lfs.* filter=lfs diff=lfs merge=lfs -text
11
+ *.mlmodel filter=lfs diff=lfs merge=lfs -text
12
+ *.model filter=lfs diff=lfs merge=lfs -text
13
+ *.msgpack filter=lfs diff=lfs merge=lfs -text
14
+ *.npy filter=lfs diff=lfs merge=lfs -text
15
+ *.npz filter=lfs diff=lfs merge=lfs -text
16
+ *.onnx filter=lfs diff=lfs merge=lfs -text
17
+ *.ot filter=lfs diff=lfs merge=lfs -text
18
+ *.parquet filter=lfs diff=lfs merge=lfs -text
19
+ *.pb filter=lfs diff=lfs merge=lfs -text
20
+ *.pickle filter=lfs diff=lfs merge=lfs -text
21
+ *.pkl filter=lfs diff=lfs merge=lfs -text
22
+ *.pt filter=lfs diff=lfs merge=lfs -text
23
+ *.pth filter=lfs diff=lfs merge=lfs -text
24
+ *.rar filter=lfs diff=lfs merge=lfs -text
25
+ *.safetensors filter=lfs diff=lfs merge=lfs -text
26
+ saved_model/**/* filter=lfs diff=lfs merge=lfs -text
27
+ *.tar.* filter=lfs diff=lfs merge=lfs -text
28
+ *.tar filter=lfs diff=lfs merge=lfs -text
29
+ *.tflite filter=lfs diff=lfs merge=lfs -text
30
+ *.tgz filter=lfs diff=lfs merge=lfs -text
31
+ *.wasm filter=lfs diff=lfs merge=lfs -text
32
+ *.xz filter=lfs diff=lfs merge=lfs -text
33
+ *.zip filter=lfs diff=lfs merge=lfs -text
34
+ *.zst filter=lfs diff=lfs merge=lfs -text
35
+ *tfevents* filter=lfs diff=lfs merge=lfs -text
.gitignore ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ .pytest_cache/
2
+ photos/
3
+ logs/
4
+ __pycache__/
5
+ *.pyc
6
+ *.tmp
Dockerfile ADDED
@@ -0,0 +1,20 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # libgl1/libglib nécessaires au runtime d'opencv-python-headless sur Debian slim
6
+ RUN apt-get update && apt-get install -y --no-install-recommends \
7
+ libgl1 libglib2.0-0 \
8
+ && rm -rf /var/lib/apt/lists/*
9
+
10
+ COPY requirements.txt .
11
+ # CPU-only : évite de télécharger les wheels PyTorch CUDA (inutiles sur Hugging Face Spaces gratuit)
12
+ RUN pip install --no-cache-dir --extra-index-url https://download.pytorch.org/whl/cpu -r requirements.txt
13
+
14
+ COPY app/ app/
15
+ COPY models/ models/
16
+
17
+ # 7860 = port par défaut attendu par Hugging Face Spaces (Docker SDK)
18
+ EXPOSE 7860
19
+
20
+ CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "7860"]
README.md ADDED
@@ -0,0 +1,119 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: YELY AI Module
3
+ emoji: ⛽
4
+ colorFrom: blue
5
+ colorTo: green
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ app_port: 7860
10
+ short_description: Solution d'IA basée sur la vision par ordinateur
11
+ ---
12
+
13
+ # YELY — Module IA de vérification pompiste
14
+
15
+ Module autonome livré pour le cahier des charges YELY : lecture automatique
16
+ du terminal de pompe (prix, volume, prix du litre), calcul et vérification
17
+ de cohérence, exposés via une API.
18
+
19
+ Contrairement à la version précédente du prototype (qui s'appuyait sur
20
+ PaddleOCR/EasyOCR, des OCR génériques), ce module utilise un **modèle CRNN
21
+ entraîné sur nos propres données** (`models/crnn_fuel_pump_best.pt`) —
22
+ voir `docs/LIMITATIONS.md` pour un bilan honnête de sa précision actuelle
23
+ et des pistes d'amélioration.
24
+
25
+ ## Structure
26
+
27
+ ```
28
+ yely_ai_module/
29
+ ├── app/
30
+ │ ├── main.py # API FastAPI (/analyze)
31
+ │ ├── preprocessing.py # détection écran + découpage en lignes
32
+ │ ├── recognizer.py # chargement du CRNN + inférence
33
+ │ ├── postprocess.py # normalisation numérique, calculs, cohérence
34
+ │ ├── rules.py # moteur de règles métier (blocage/succès)
35
+ │ └── config.py # seuils configurables (config.yaml)
36
+ ├── models/
37
+ │ └── crnn_fuel_pump_best.pt # modèle sérialisé (livrable)
38
+ ├── train/ # scripts d'entraînement (reproductibilité)
39
+ ├── tests/ # tests unitaires (postprocess, règles)
40
+ ├── web/ # interface web de démo (déploiement Vercel)
41
+ ├── docs/ # documentation par module + limites identifiées
42
+ ├── Dockerfile # déploiement Hugging Face Spaces
43
+ └── requirements.txt
44
+ ```
45
+
46
+ ## Installation
47
+
48
+ ```bash
49
+ cd yely_ai_module
50
+ pip install -r requirements.txt
51
+ ```
52
+
53
+ ## Lancer l'API
54
+
55
+ ```bash
56
+ uvicorn app.main:app --host 0.0.0.0 --port 8000
57
+ ```
58
+
59
+ Ne pas utiliser `--reload` en usage normal : ça redémarre tout le worker
60
+ (et donc recharge le modèle CRNN, ~30-60s) à chaque modification de fichier.
61
+
62
+ ## Appeler l'API
63
+
64
+ ```bash
65
+ curl -X POST http://127.0.0.1:8000/analyze \
66
+ -F "image=@photo_pompe.jpg" \
67
+ -F "fuel_price=700" \
68
+ -F "driver_id=chauffeur-42" \
69
+ -F "pompiste_id=pompiste-7" \
70
+ -F "station_id=station-3"
71
+ ```
72
+
73
+ ### Réponse
74
+
75
+ ```json
76
+ {
77
+ "success": true,
78
+ "image_quality": "valid",
79
+ "detected_liters": 14.28,
80
+ "detected_amount": 10000.0,
81
+ "fuel_price": 700.0,
82
+ "calculated_amount": 9996.0,
83
+ "calculated_liters": null,
84
+ "is_consistent": true,
85
+ "confidence_score": 0.93,
86
+ "message": "Données vérifiées avec succès.",
87
+ "driver_id": "chauffeur-42",
88
+ "pompiste_id": "pompiste-7",
89
+ "station_id": "station-3",
90
+ "transaction_datetime": "2026-07-05T20:00:00+00:00",
91
+ "photo_reference": "<uuid>.jpg"
92
+ }
93
+ ```
94
+
95
+ Champs conformes au §13 du cahier des charges. Le `fuel_price` fourni par
96
+ l'appelant (système YELY) fait toujours autorité sur un prix lu à l'écran
97
+ (règle métier n°1).
98
+
99
+ ## Tests
100
+
101
+ ```bash
102
+ pytest tests/
103
+ ```
104
+
105
+ ## Ré-entraîner le modèle
106
+
107
+ ```bash
108
+ cd train
109
+ python prepare_doctr_dataset.py # si le dataset a changé
110
+ python finetune_doctr.py --epochs 60
111
+ ```
112
+
113
+ Voir `docs/LIMITATIONS.md` avant de ré-entraîner : le principal facteur
114
+ limitant est la diversité du jeu de données, pas les hyperparamètres.
115
+
116
+ ## Déploiement
117
+
118
+ Voir `docs/WORKFLOW.md` §6 : frontend (`web/`) sur Vercel, API sur
119
+ Hugging Face Spaces (ce dépôt, via le `Dockerfile` à la racine).
app/__init__.py ADDED
File without changes
app/config.py ADDED
@@ -0,0 +1,48 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Seuils métier configurables via config.yaml (optionnel) ou variables d'environnement.
2
+
3
+ Recherche, dans l'ordre : argument explicite -> variable d'env CONFIG_PATH ->
4
+ config.yaml à la racine du module -> valeurs par défaut.
5
+ """
6
+ from dataclasses import dataclass
7
+ from pathlib import Path
8
+ import logging
9
+ import os
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+ MODULE_ROOT = Path(__file__).resolve().parent.parent
14
+
15
+
16
+ @dataclass
17
+ class RuleConfig:
18
+ consistency_tolerance: float = 0.02
19
+ match_tolerance: float = 0.03
20
+ liters_upper_bound: float = 500.0
21
+ price_min: float = 200.0
22
+ price_max: float = 2000.0
23
+ business_confidence_threshold: float = 0.5
24
+ blur_threshold: float = 80.0
25
+ dark_threshold: float = 40.0
26
+ bright_threshold: float = 240.0
27
+
28
+
29
+ def load_config(path: str | None = None) -> RuleConfig:
30
+ candidate = path or os.environ.get("CONFIG_PATH") or str(MODULE_ROOT / "config.yaml")
31
+ candidate_path = Path(candidate)
32
+
33
+ if not candidate_path.is_file():
34
+ return RuleConfig()
35
+
36
+ try:
37
+ import yaml
38
+ with open(candidate_path, "r", encoding="utf-8") as f:
39
+ data = yaml.safe_load(f) or {}
40
+ except Exception as e:
41
+ logger.warning(f"Impossible de charger {candidate_path} : {e}. Utilisation des valeurs par défaut.")
42
+ return RuleConfig()
43
+
44
+ rules_data = data.get("rules", data) if isinstance(data, dict) else {}
45
+ defaults = RuleConfig()
46
+ known_fields = defaults.__dataclass_fields__.keys()
47
+ kwargs = {k: v for k, v in rules_data.items() if k in known_fields}
48
+ return RuleConfig(**{**defaults.__dict__, **kwargs})
app/main.py ADDED
@@ -0,0 +1,159 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """API YELY — module IA de vérification pompiste (modèle CRNN fine-tuné).
2
+
3
+ Endpoint unique : POST /analyze
4
+ - image (fichier, requis)
5
+ - fuel_price (float, optionnel) : prix du litre configuré côté YELY
6
+ - driver_id, pompiste_id, station_id (str, optionnels) : traçabilité
7
+
8
+ Réponse conforme au §9/§13 du cahier des charges YELY.
9
+ """
10
+ import json
11
+ import logging
12
+ import os
13
+ import shutil
14
+ import tempfile
15
+ import uuid
16
+ from datetime import datetime, timezone
17
+ from pathlib import Path
18
+
19
+ import cv2
20
+ from fastapi import FastAPI, File, UploadFile, Form, HTTPException
21
+ from fastapi.middleware.cors import CORSMiddleware
22
+ from fastapi.responses import JSONResponse
23
+
24
+ from .config import load_config
25
+ from .preprocessing import detect_screen_region
26
+ from .quality import estimate_image_quality
27
+ from .recognizer import get_device, load_model, recognize_screen
28
+ from .postprocess import process as postprocess_results
29
+ from .rules import evaluate as evaluate_rules
30
+
31
+ MODULE_ROOT = Path(__file__).resolve().parent.parent
32
+ LOG_DIR = MODULE_ROOT / "logs"
33
+ LOG_DIR.mkdir(parents=True, exist_ok=True)
34
+ PHOTOS_DIR = MODULE_ROOT / "photos"
35
+ PHOTOS_DIR.mkdir(parents=True, exist_ok=True)
36
+
37
+ logger = logging.getLogger("yely_ai_module")
38
+ logger.setLevel(logging.INFO)
39
+ _file_handler = logging.FileHandler(LOG_DIR / "api.log", encoding="utf-8")
40
+ _file_handler.setFormatter(logging.Formatter("%(asctime)s | %(levelname)s | %(message)s"))
41
+ logger.addHandler(_file_handler)
42
+
43
+ app = FastAPI(title="YELY — Module IA pompiste (CRNN)")
44
+
45
+ # Le frontend (Vercel) et l'API (Hugging Face Spaces) sont sur des domaines
46
+ # différents : sans CORS, le navigateur bloquerait la lecture de la réponse
47
+ # même si la requête aboutit côté serveur. ALLOWED_ORIGINS est une liste
48
+ # d'origines séparées par des virgules (ex. "https://yely-demo.vercel.app").
49
+ _allowed_origins = os.environ.get("ALLOWED_ORIGINS", "https://yely-demo.vercel.app")
50
+ app.add_middleware(
51
+ CORSMiddleware,
52
+ allow_origins=["*"] if _allowed_origins == "*" else _allowed_origins.split(","),
53
+ allow_methods=["POST"],
54
+ allow_headers=["*"],
55
+ )
56
+
57
+ SUPPORTED_IMAGE_TYPES = {"image/jpeg", "image/png", "image/bmp", "image/webp", "image/tiff"}
58
+
59
+ cfg = load_config()
60
+
61
+ _model = None
62
+ _device = None
63
+
64
+
65
+ def _get_model():
66
+ global _model, _device
67
+ if _model is None:
68
+ _device = get_device()
69
+ _model = load_model(device=_device)
70
+ logger.info(f"Modèle CRNN chargé sur {_device}.")
71
+ return _model, _device
72
+
73
+
74
+ @app.post("/analyze")
75
+ async def analyze(
76
+ image: UploadFile = File(...),
77
+ fuel_price: float = Form(None),
78
+ driver_id: str = Form(None),
79
+ pompiste_id: str = Form(None),
80
+ station_id: str = Form(None),
81
+ ):
82
+ if image.content_type not in SUPPORTED_IMAGE_TYPES:
83
+ raise HTTPException(status_code=400, detail="Type d'image non supporté")
84
+
85
+ transaction_id = str(uuid.uuid4())
86
+ transaction_datetime = datetime.now(timezone.utc).isoformat()
87
+
88
+ with tempfile.NamedTemporaryFile(delete=False, suffix=Path(image.filename).suffix) as tmp:
89
+ tmp.write(await image.read())
90
+ tmp_path = tmp.name
91
+
92
+ try:
93
+ img_bgr = cv2.imread(tmp_path)
94
+ if img_bgr is None:
95
+ raise HTTPException(status_code=400, detail="Impossible de lire l'image")
96
+
97
+ try:
98
+ crop, (sx, sy, sw, sh) = detect_screen_region(img_bgr)
99
+ img_to_process = crop if sw < img_bgr.shape[1] * 0.95 else img_bgr
100
+
101
+ model, device = _get_model()
102
+ recognized = recognize_screen(model, img_to_process, device)
103
+
104
+ parsed = postprocess_results(recognized, fuel_price=fuel_price, cfg=cfg)
105
+ quality = estimate_image_quality(img_to_process, ocr_results=recognized, cfg=cfg)
106
+ gate = evaluate_rules(parsed, quality, fuel_price, cfg)
107
+ except HTTPException:
108
+ raise
109
+ except Exception:
110
+ logger.exception("Erreur interne lors du traitement de l'image")
111
+ return JSONResponse(status_code=500, content={
112
+ "success": False,
113
+ "message": "Erreur interne de traitement",
114
+ "transaction_datetime": transaction_datetime,
115
+ })
116
+
117
+ photo_reference = None
118
+ try:
119
+ photo_reference = f"{transaction_id}{Path(image.filename).suffix}"
120
+ shutil.copyfile(tmp_path, PHOTOS_DIR / photo_reference)
121
+ except Exception:
122
+ logger.warning(f"Impossible de sauvegarder la photo pour la transaction {transaction_id}", exc_info=True)
123
+ photo_reference = None
124
+
125
+ response = {
126
+ "success": gate["success"],
127
+ "image_quality": quality["image_quality"],
128
+ "detected_liters": parsed["detected_liters"],
129
+ "detected_amount": parsed["detected_amount"],
130
+ "fuel_price": parsed["fuel_price"],
131
+ "calculated_amount": parsed["calculated_amount"],
132
+ "calculated_liters": parsed["calculated_liters"],
133
+ "is_consistent": parsed["is_consistent"],
134
+ "confidence_score": gate["confidence_score"],
135
+ "message": gate["message"],
136
+ "driver_id": driver_id,
137
+ "pompiste_id": pompiste_id,
138
+ "station_id": station_id,
139
+ "transaction_datetime": transaction_datetime,
140
+ "photo_reference": photo_reference,
141
+ }
142
+
143
+ logger.info(json.dumps({
144
+ "request": {
145
+ "filename": image.filename,
146
+ "fuel_price": fuel_price,
147
+ "driver_id": driver_id,
148
+ "pompiste_id": pompiste_id,
149
+ "station_id": station_id,
150
+ },
151
+ "response": response,
152
+ }, ensure_ascii=False))
153
+
154
+ return JSONResponse(content=response)
155
+ finally:
156
+ try:
157
+ os.remove(tmp_path)
158
+ except Exception:
159
+ pass
app/postprocess.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Post-traitement métier des lectures du CRNN : normalisation numérique,
2
+ calcul du montant/litres manquant, vérification de cohérence.
3
+
4
+ Contrairement au pipeline OCR générique (qui doit deviner quel nombre
5
+ correspond à quel champ par proximité de libellé ou par magnitude), le CRNN
6
+ étiquette déjà chaque ligne lue ("prix" = montant, "volume" = litres,
7
+ "prix_litre" = prix unitaire affiché) grâce au découpage positionnel — il
8
+ n'y a donc pas d'ambiguïté champ/valeur à résoudre ici.
9
+ """
10
+ from typing import Any, Dict, List, Optional
11
+ import re
12
+
13
+ from .config import RuleConfig, load_config
14
+
15
+ NUMBER_RE = re.compile(r"[-+]?[0-9]+[\.,]?[0-9]*")
16
+
17
+
18
+ def _norm_number(s: str) -> Optional[float]:
19
+ """Normalise un nombre lu par le CRNN (virgule/point, espaces) en float."""
20
+ if not s:
21
+ return None
22
+ s = s.strip().replace(" ", "").replace("\xa0", "")
23
+ m = NUMBER_RE.search(s)
24
+ if not m:
25
+ return None
26
+ s = m.group(0)
27
+ if "," in s and "." in s:
28
+ s = s.replace(",", "")
29
+ elif s.count(",") == 1 and s.count(".") == 0:
30
+ s = s.replace(",", ".")
31
+ try:
32
+ return float(s)
33
+ except ValueError:
34
+ return None
35
+
36
+
37
+ def evaluate_consistency(liters: Optional[float], amount: Optional[float],
38
+ price: Optional[float], tol: Optional[float] = None,
39
+ cfg: Optional[RuleConfig] = None) -> Dict[str, Any]:
40
+ """Vérifie montant == litres x prix (§5.5/§10 du cahier des charges)."""
41
+ if cfg is None:
42
+ cfg = load_config()
43
+ if tol is None:
44
+ tol = cfg.consistency_tolerance
45
+
46
+ out = {"is_consistent": None, "calculated_amount": None, "calculated_liters": None}
47
+ if liters is not None and price is not None:
48
+ calc_amt = liters * price
49
+ out["calculated_amount"] = round(calc_amt, 2)
50
+ if amount is not None:
51
+ out["is_consistent"] = abs(amount - calc_amt) / max(1.0, calc_amt) <= tol
52
+ if amount is not None and price is not None and liters is None:
53
+ calc_l = amount / price if price != 0 else None
54
+ out["calculated_liters"] = round(calc_l, 2) if calc_l is not None else None
55
+ return out
56
+
57
+
58
+ def process(recognized_fields: List[Dict[str, Any]],
59
+ fuel_price: Optional[float] = None,
60
+ cfg: Optional[RuleConfig] = None) -> Dict[str, Any]:
61
+ """Convertit les lignes lues par le CRNN en résultat métier structuré.
62
+
63
+ Args:
64
+ recognized_fields: sortie de `recognizer.recognize_screen` :
65
+ liste de {'field': 'prix'|'volume'|'prix_litre', 'text', 'confidence'}
66
+ fuel_price: prix du litre configuré côté YELY (fait toujours autorité
67
+ sur un prix lu à l'écran — règle métier n°1 du cahier des charges).
68
+ """
69
+ if cfg is None:
70
+ cfg = load_config()
71
+
72
+ by_field = {f["field"]: f for f in recognized_fields if f.get("field")}
73
+
74
+ amount_val = _norm_number(by_field.get("prix", {}).get("text", ""))
75
+ liters_val = _norm_number(by_field.get("volume", {}).get("text", ""))
76
+ screen_price_val = _norm_number(by_field.get("prix_litre", {}).get("text", ""))
77
+
78
+ # Le prix configuré côté YELY fait autorité ; le prix lu à l'écran n'est
79
+ # utilisé que si l'appelant n'en a fourni aucun.
80
+ price_val = fuel_price if fuel_price is not None else screen_price_val
81
+
82
+ evalr = evaluate_consistency(liters_val, amount_val, price_val, cfg=cfg)
83
+
84
+ confidences = [f["confidence"] for f in recognized_fields if f.get("confidence") is not None]
85
+ ocr_confidence = (sum(confidences) / len(confidences)) if confidences else None
86
+
87
+ raw_numbers = [(f["text"], v) for f, v in (
88
+ (by_field.get("prix", {}), amount_val),
89
+ (by_field.get("volume", {}), liters_val),
90
+ (by_field.get("prix_litre", {}), screen_price_val),
91
+ ) if v is not None]
92
+
93
+ return {
94
+ "detected_liters": liters_val,
95
+ "detected_amount": amount_val,
96
+ "fuel_price": price_val,
97
+ "calculated_amount": evalr["calculated_amount"],
98
+ "calculated_liters": evalr["calculated_liters"],
99
+ "is_consistent": evalr["is_consistent"],
100
+ "ocr_confidence": round(ocr_confidence, 2) if ocr_confidence is not None else None,
101
+ "field_confidences": {
102
+ "liters": by_field.get("volume", {}).get("confidence"),
103
+ "amount": by_field.get("prix", {}).get("confidence"),
104
+ "price": by_field.get("prix_litre", {}).get("confidence"),
105
+ },
106
+ "raw_numbers": raw_numbers,
107
+ }
app/preprocessing.py ADDED
@@ -0,0 +1,109 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Prétraitement image : détection de l'écran LCD puis découpage en lignes
2
+ de valeurs (prix / volume / prix du litre), avant reconnaissance CRNN.
3
+
4
+ Cette étape reste indépendante du modèle de reconnaissance : elle correspond
5
+ au travail de prétraitement/détection d'écran attendu par le cahier des
6
+ charges (§6.1, §8.2/8.3), et reste identique quel que soit le moteur de
7
+ lecture des chiffres utilisé en aval.
8
+ """
9
+ import cv2
10
+ import numpy as np
11
+ from typing import List, Tuple
12
+
13
+
14
+ FIELD_NAMES = ["prix", "volume", "prix_litre"]
15
+
16
+
17
+ def detect_screen_region(img: np.ndarray) -> Tuple[np.ndarray, Tuple[int, int, int, int]]:
18
+ """Tente de localiser la zone de l'écran LCD dans l'image.
19
+
20
+ Retourne (crop, (x, y, w, h)) ou (img, (0,0,W,H)) si rien de convaincant
21
+ n'a été trouvé (l'appelant doit alors traiter l'image entière).
22
+ """
23
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img.copy()
24
+ blurred = cv2.GaussianBlur(gray, (5, 5), 0)
25
+ edges = cv2.Canny(blurred, 30, 100)
26
+ kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (15, 5))
27
+ closed = cv2.morphologyEx(edges, cv2.MORPH_CLOSE, kernel)
28
+ contours, _ = cv2.findContours(closed, cv2.RETR_EXTERNAL,
29
+ cv2.CHAIN_APPROX_SIMPLE)
30
+ h_img, w_img = img.shape[:2]
31
+ best_rect, best_area = None, 0
32
+ for cnt in contours:
33
+ x, y, w, h = cv2.boundingRect(cnt)
34
+ area = w * h
35
+ if area > 0.02 * h_img * w_img and 1.0 < (w / max(h, 1)) < 8.0:
36
+ if area > best_area:
37
+ best_area = area
38
+ best_rect = (x, y, w, h)
39
+ if best_rect:
40
+ x, y, w, h = best_rect
41
+ pad = 10
42
+ x1 = max(0, x - pad); y1 = max(0, y - pad)
43
+ x2 = min(w_img, x + w + pad); y2 = min(h_img, y + h + pad)
44
+ return img[y1:y2, x1:x2], (x1, y1, x2 - x1, y2 - y1)
45
+ return img, (0, 0, w_img, h_img)
46
+
47
+
48
+ def split_lcd_lines(lcd_crop: np.ndarray,
49
+ n_lines: int = 3) -> Tuple[List[np.ndarray], List[Tuple[int, int]]]:
50
+ """Découpe le crop d'écran en `n_lines` bandes horizontales (une par
51
+ valeur affichée : prix, volume, prix du litre), par projection
52
+ horizontale des pixels sombres, avec repli sur un découpage proportionnel
53
+ si la projection ne trouve pas exactement le bon nombre de bandes.
54
+
55
+ NB : `n_lines=3` par défaut (et non 2) car la majorité des écrans du jeu
56
+ d'annotation affichent les trois valeurs (prix, volume, prix/litre) —
57
+ un défaut à 2 aurait systématiquement ignoré la troisième ligne.
58
+ """
59
+ gray = cv2.cvtColor(lcd_crop, cv2.COLOR_BGR2GRAY)
60
+ h, w = gray.shape
61
+
62
+ clahe = cv2.createCLAHE(clipLimit=3.0, tileGridSize=(8, 8))
63
+ enh = clahe.apply(gray)
64
+ _, bin_img = cv2.threshold(enh, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU)
65
+ if np.mean(bin_img) > 128:
66
+ bin_img = cv2.bitwise_not(bin_img)
67
+
68
+ row_sums = np.sum(bin_img == 255, axis=1).astype(np.float32)
69
+ smoothed = cv2.GaussianBlur(
70
+ row_sums.reshape(-1, 1), (1, max(3, h // 30) * 2 + 1), 0
71
+ ).flatten()
72
+ threshold = max(smoothed.max() * 0.08, 2)
73
+ active = smoothed > threshold
74
+
75
+ bands: List[Tuple[int, int]] = []
76
+ in_band = False
77
+ start = 0
78
+ for y in range(h):
79
+ if active[y] and not in_band:
80
+ start, in_band = y, True
81
+ elif not active[y] and in_band:
82
+ bands.append((start, y))
83
+ in_band = False
84
+ if in_band:
85
+ bands.append((start, h))
86
+
87
+ min_h = h * 0.06
88
+ bands = [(s, e) for s, e in bands if e - s >= min_h]
89
+ merged: List[Tuple[int, int]] = []
90
+ for band in bands:
91
+ if merged and band[0] - merged[-1][1] < h * 0.05:
92
+ merged[-1] = (merged[-1][0], band[1])
93
+ else:
94
+ merged.append(band)
95
+ bands = [tuple(band) for band in merged]
96
+
97
+ if len(bands) != n_lines:
98
+ band_h = h / n_lines
99
+ bands = [(int(i * band_h), int((i + 1) * band_h)) for i in range(n_lines)]
100
+
101
+ crops: List[np.ndarray] = []
102
+ normalized_bands: List[Tuple[int, int]] = []
103
+ for start_y, end_y in bands:
104
+ pad = max(2, int(h * 0.02))
105
+ y0 = max(0, start_y - pad)
106
+ y1 = min(h, end_y + pad)
107
+ crops.append(lcd_crop[y0:y1, :])
108
+ normalized_bands.append((y0, y1 - y0))
109
+ return crops, normalized_bands
app/quality.py ADDED
@@ -0,0 +1,58 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Détection de la qualité de l'image (floue / sombre / surexposée / valide).
2
+
3
+ Fonction indépendante du moteur de reconnaissance : elle s'applique à
4
+ l'image (ou au crop d'écran) avant toute tentative de lecture des chiffres,
5
+ conformément au §6.2 du cahier des charges.
6
+ """
7
+ import cv2
8
+ import numpy as np
9
+ from typing import Dict, Any, Optional
10
+
11
+ from .config import RuleConfig, load_config
12
+
13
+
14
+ def estimate_image_quality(img: np.ndarray,
15
+ ocr_results: Optional[list] = None,
16
+ cfg: Optional[RuleConfig] = None) -> Dict[str, Any]:
17
+ """Estime la qualité de l'image et retourne un label + un score.
18
+
19
+ Heuristiques : variance du Laplacien (flou) et luminosité moyenne.
20
+ """
21
+ if cfg is None:
22
+ cfg = load_config()
23
+
24
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) if len(img.shape) == 3 else img.copy()
25
+ blur = cv2.Laplacian(gray, cv2.CV_64F).var()
26
+ mean_brightness = float(np.mean(gray))
27
+
28
+ # Le flou/la luminosité sont des propriétés intrinsèques de l'image et
29
+ # doivent être vérifiés avant le repli "unclear" (nombre de résultats),
30
+ # sinon une image nette et bien éclairée mais avec peu de détections
31
+ # serait à tort classée "unclear" plutôt que diagnostiquée correctement.
32
+ if blur < cfg.blur_threshold:
33
+ label = "blurry"
34
+ elif mean_brightness < cfg.dark_threshold:
35
+ label = "dark"
36
+ elif mean_brightness > cfg.bright_threshold:
37
+ label = "bright"
38
+ elif ocr_results is not None and len(ocr_results) < 2:
39
+ label = "unclear"
40
+ else:
41
+ label = "valid"
42
+
43
+ quality_score = 0.0
44
+ if label == "valid":
45
+ quality_score = 0.95
46
+ elif label == "unclear":
47
+ quality_score = 0.20
48
+ elif label in ("blurry", "dark", "bright"):
49
+ quality_score = 0.35
50
+ else:
51
+ quality_score = 0.50
52
+
53
+ return {
54
+ "image_quality": label,
55
+ "quality_score": round(min(1.0, quality_score), 2),
56
+ "blur_variance": round(blur, 2),
57
+ "brightness": round(mean_brightness, 2),
58
+ }
app/recognizer.py ADDED
@@ -0,0 +1,107 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Chargement et inférence du modèle CRNN fine-tuné sur les écrans de pompe.
2
+
3
+ C'est le "modèle IA développé" au sens du §8.4 du cahier des charges :
4
+ un CRNN (crnn_vgg16_bn, doctr) fine-tuné sur nos propres données annotées
5
+ (voir train/)
6
+ """
7
+ import os
8
+ from typing import Any, Dict, List, Optional, Tuple
9
+
10
+ import cv2
11
+ import numpy as np
12
+ import torch
13
+ import torch.nn.functional as F
14
+
15
+ from .preprocessing import FIELD_NAMES, split_lcd_lines
16
+
17
+ try:
18
+ from doctr.datasets import VOCABS
19
+ from doctr.models import crnn_vgg16_bn
20
+ except Exception as exc: # pragma: no cover - dépend de l'environnement
21
+ VOCABS = None
22
+ crnn_vgg16_bn = None
23
+ _DOCTR_IMPORT_ERROR = exc
24
+ else:
25
+ _DOCTR_IMPORT_ERROR = None
26
+
27
+ VOCAB = VOCABS["french"] if VOCABS is not None else None
28
+ IMG_H = 32
29
+ MODULE_ROOT = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
30
+ DEFAULT_MODEL_PATH = os.path.join(MODULE_ROOT, "models", "crnn_fuel_pump_best.pt")
31
+
32
+
33
+ def get_device() -> torch.device:
34
+ return torch.device("cuda" if torch.cuda.is_available() else "cpu")
35
+
36
+
37
+ def load_model(model_path: Optional[str] = None, device: Optional[torch.device] = None):
38
+ if model_path is None:
39
+ model_path = DEFAULT_MODEL_PATH
40
+
41
+ if not os.path.exists(model_path):
42
+ raise FileNotFoundError(f"Modèle introuvable : {model_path}")
43
+
44
+ if crnn_vgg16_bn is None or VOCAB is None:
45
+ raise ImportError(
46
+ "python-doctr n'est pas installé (voir requirements.txt)."
47
+ ) from _DOCTR_IMPORT_ERROR
48
+
49
+ if device is None:
50
+ device = get_device()
51
+
52
+ model = crnn_vgg16_bn(pretrained=False, pretrained_backbone=False, vocab=VOCAB)
53
+ model.load_state_dict(torch.load(model_path, map_location=device))
54
+ return model.to(device).eval()
55
+
56
+
57
+ def _resize_preserve_aspect(tensor: torch.Tensor, img_h: int) -> torch.Tensor:
58
+ """Redimensionne en conservant le ratio d'aspect (hauteur fixe). Doit
59
+ rester cohérent avec le prétraitement utilisé à l'entraînement
60
+ (train/finetune_doctr.py::_resize_preserve_aspect) : un écart entre les
61
+ deux dégrade silencieusement la précision du modèle.
62
+ """
63
+ _, h, w = tensor.shape
64
+ new_w = max(16, int(img_h * w / max(h, 1)))
65
+ resized = F.interpolate(
66
+ tensor.unsqueeze(0), size=(img_h, new_w),
67
+ mode="bilinear", align_corners=False,
68
+ ).squeeze(0)
69
+ mn, mx = resized.min(), resized.max()
70
+ if mx - mn > 0.01:
71
+ resized = (resized - mn) / (mx - mn)
72
+ return resized.unsqueeze(0)
73
+
74
+
75
+ def preprocess_crop(crop_bgr: np.ndarray, img_h: int = IMG_H) -> torch.Tensor:
76
+ img = cv2.cvtColor(crop_bgr, cv2.COLOR_BGR2RGB).astype(np.float32) / 255.0
77
+ tensor = torch.from_numpy(img).permute(2, 0, 1)
78
+ return _resize_preserve_aspect(tensor, img_h)
79
+
80
+
81
+ @torch.no_grad()
82
+ def predict_line(model, crop_bgr: np.ndarray, device: torch.device) -> Tuple[str, float]:
83
+ tensor = preprocess_crop(crop_bgr)
84
+ out = model(tensor.to(device), return_preds=True)
85
+ preds = out.get("preds", [])
86
+ if not preds:
87
+ return "", 0.0
88
+ text, confidence = preds[0]
89
+ return text, max(float(confidence), 0.0)
90
+
91
+
92
+ def recognize_screen(model, screen_crop: np.ndarray, device: torch.device,
93
+ n_lines: int = 3) -> List[Dict[str, Any]]:
94
+ """Découpe le crop d'écran en lignes puis lit chaque valeur avec le CRNN.
95
+
96
+ Retourne une liste de {'field', 'text', 'confidence'} — un élément par
97
+ ligne détectée (prix / volume / prix_litre), dans l'ordre d'affichage.
98
+ """
99
+ line_crops, _ = split_lcd_lines(screen_crop, n_lines=n_lines)
100
+ results = []
101
+ for idx, crop in enumerate(line_crops):
102
+ if crop.size == 0:
103
+ continue
104
+ text, confidence = predict_line(model, crop, device)
105
+ field = FIELD_NAMES[idx] if idx < len(FIELD_NAMES) else f"ligne_{idx}"
106
+ results.append({"field": field, "text": text, "confidence": confidence})
107
+ return results
app/rules.py ADDED
@@ -0,0 +1,98 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Porte de décision métier unifiée.
2
+
3
+ Combine la sortie du parsing (`app.postprocess.process`) et de la qualité
4
+ image (`app.quality.estimate_image_quality`) pour décider si une lecture
5
+ peut être validée (règles 3/4/5/7 du cahier des charges) : un paiement ne
6
+ doit jamais être validé si la photo est floue/illisible, si litres et
7
+ montant sont incohérents, ou si la confiance du modèle est trop faible.
8
+ """
9
+ from typing import Any, Dict, Optional
10
+
11
+ from .config import RuleConfig, load_config
12
+
13
+
14
+ def _compute_confidence_score(parsed: Dict[str, Any], quality: Dict[str, Any]) -> float:
15
+ ocr_confidence = parsed.get("ocr_confidence")
16
+ if ocr_confidence is None:
17
+ ocr_confidence = min(1.0, len(parsed.get("raw_numbers", [])) / 3.0)
18
+
19
+ quality_score = quality.get("quality_score", 0.0)
20
+ return round(min(1.0, ocr_confidence * 0.6 + quality_score * 0.4), 2)
21
+
22
+
23
+ def evaluate(parsed: Dict[str, Any], quality: Dict[str, Any],
24
+ fuel_price: Optional[float] = None,
25
+ cfg: Optional[RuleConfig] = None) -> Dict[str, Any]:
26
+ """Retourne {'success': bool, 'message': str, 'confidence_score': float}.
27
+
28
+ Ordre des vérifications (le premier échec l'emporte) :
29
+ 1. image non "valid" (floue/sombre/surexposée/peu exploitable) -> bloqué
30
+ 2. aucune donnée numérique détectée -> bloqué
31
+ 3. litres ET montant manquants, ou prix manquant -> bloqué
32
+ 4. is_consistent est False ou None (quand vérifiable) -> bloqué
33
+ 5. confidence_score sous le seuil -> bloqué
34
+ 6. sinon -> succès
35
+ """
36
+ if cfg is None:
37
+ cfg = load_config()
38
+
39
+ confidence_score = _compute_confidence_score(parsed, quality)
40
+
41
+ image_quality = quality.get("image_quality")
42
+ if image_quality != "valid":
43
+ messages = {
44
+ "blurry": "Photo floue, veuillez reprendre la photo.",
45
+ "dark": "Image trop sombre, veuillez reprendre la photo.",
46
+ "bright": "Image surexposée (reflet), veuillez reprendre la photo.",
47
+ "unclear": "Image peu exploitable, veuillez reprendre la photo.",
48
+ }
49
+ return {
50
+ "success": False,
51
+ "message": messages.get(image_quality, "Image non exploitable, veuillez reprendre la photo."),
52
+ "confidence_score": confidence_score,
53
+ }
54
+
55
+ if not parsed.get("raw_numbers"):
56
+ return {
57
+ "success": False,
58
+ "message": "Aucune donnée détectée sur l'écran, veuillez reprendre la photo.",
59
+ "confidence_score": confidence_score,
60
+ }
61
+
62
+ liters = parsed.get("detected_liters")
63
+ amount = parsed.get("detected_amount")
64
+ price = parsed.get("fuel_price")
65
+ if liters is None and amount is None:
66
+ return {
67
+ "success": False,
68
+ "message": "Données insuffisantes (litres et montant non lisibles).",
69
+ "confidence_score": confidence_score,
70
+ }
71
+ if price is None:
72
+ return {
73
+ "success": False,
74
+ "message": "Prix du litre manquant, impossible de vérifier la cohérence.",
75
+ "confidence_score": confidence_score,
76
+ }
77
+
78
+ if liters is not None and amount is not None:
79
+ is_consistent = parsed.get("is_consistent")
80
+ if is_consistent is not True:
81
+ return {
82
+ "success": False,
83
+ "message": "Incohérence détectée entre montant, litres et prix.",
84
+ "confidence_score": confidence_score,
85
+ }
86
+
87
+ if confidence_score < cfg.business_confidence_threshold:
88
+ return {
89
+ "success": False,
90
+ "message": "Confiance de lecture insuffisante, vérification manuelle requise.",
91
+ "confidence_score": confidence_score,
92
+ }
93
+
94
+ return {
95
+ "success": True,
96
+ "message": "Données vérifiées avec succès.",
97
+ "confidence_score": confidence_score,
98
+ }
docs/API.md ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # API (`app/main.py`)
2
+
3
+ ## `POST /analyze`
4
+
5
+ ### Requête (`multipart/form-data`)
6
+
7
+ | Champ | Type | Requis | Description |
8
+ |---|---|---|---|
9
+ | `image` | fichier | oui | photo du terminal de pompe (jpeg/png/bmp/webp/tiff) |
10
+ | `fuel_price` | float | non | prix du litre configuré côté YELY — fait autorité sur celui lu à l'écran |
11
+ | `driver_id` | string | non | identifiant chauffeur (issu du scan QR côté YELY) |
12
+ | `pompiste_id` | string | non | identifiant pompiste |
13
+ | `station_id` | string | non | identifiant station |
14
+
15
+ Volontairement **pas** de paramètres techniques (seuil de confiance OCR,
16
+ activer/désactiver la détection d'écran...) — contrairement au prototype
17
+ précédent, ce endpoint n'expose que les champs métier attendus par le
18
+ cahier des charges.
19
+
20
+ ### Réponse (`200`)
21
+
22
+ Voir `README.md` pour un exemple complet. Tous les champs correspondent
23
+ au §13 du cahier des charges — voir `docs/POSTPROCESS_RULES.md` pour le
24
+ détail du calcul de chaque valeur, `docs/ARCHITECTURE.md` pour le
25
+ pipeline complet.
26
+
27
+ ### Erreurs
28
+
29
+ | Statut | Cas |
30
+ |---|---|
31
+ | `400` | type d'image non supporté, ou fichier illisible |
32
+ | `500` | erreur interne inattendue pendant le traitement (journalisée dans `logs/api.log` avec la stack trace complète) |
33
+
34
+ ## Chargement du modèle
35
+
36
+ Le CRNN est chargé **une seule fois** au premier appel (singleton
37
+ `_get_model()`), pas à chaque requête — le chargement prend 30-90s sur CPU.
38
+ Conséquence pratique : **ne pas lancer `uvicorn --reload` en usage normal**,
39
+ chaque rechargement de code redémarre le worker et donc le modèle.
40
+
41
+ ## CORS
42
+
43
+ `ALLOWED_ORIGINS` (variable d'environnement, origines séparées par des
44
+ virgules) contrôle quels domaines peuvent appeler l'API depuis un
45
+ navigateur — nécessaire car le frontend (Vercel) et l'API (Hugging Face
46
+ Spaces) sont sur des domaines différents. Par défaut `"*"` (permissif,
47
+ adapté à une démo) ; à restreindre au domaine Vercel réel en production.
48
+
49
+ ## Journalisation
50
+
51
+ Chaque appel écrit une ligne JSON dans `logs/api.log` : requête (sans
52
+ l'image elle-même) + réponse complète. C'est la base du suivi de
53
+ performance — voir `docs/MONITORING.md`.
54
+
55
+ ## Traçabilité
56
+
57
+ Chaque photo reçue est sauvegardée dans `photos/<transaction_id>.jpg`
58
+ et référencée dans la réponse (`photo_reference`) — nécessaire pour
59
+ qu'un contrôle a posteriori (station, YELY) puisse revérifier une
60
+ transaction contestée, et c'est aussi la matière première de la boucle
61
+ d'apprentissage continu (`docs/MONITORING.md`).
docs/ARCHITECTURE.md ADDED
@@ -0,0 +1,73 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Architecture du module IA
2
+
3
+ ## Vue d'ensemble
4
+
5
+ ```
6
+ Photo du terminal
7
+ │
8
+ ▼
9
+ ┌─────────────────────┐
10
+ │ preprocessing.py │ détection de l'écran LCD (contours) puis
11
+ │ │ découpage en 3 lignes (prix / volume / prix_litre)
12
+ └─────────┬────────────┘
13
+ ▼
14
+ ┌─────────────────────┐
15
+ │ recognizer.py │ CRNN fine-tuné : lit chaque ligne, renvoie
16
+ │ (models/*.pt) │ {field, text, confidence} par valeur
17
+ └─────────┬────────────┘
18
+ ▼
19
+ ┌─────────────────────┐
20
+ │ postprocess.py │ normalise les nombres (virgule/point),
21
+ │ │ calcule la valeur manquante, vérifie
22
+ │ │ montant = litres × prix
23
+ └─────────┬────────────┘
24
+ ▼
25
+ ┌─────────────────────┐ ┌─────────────────┐
26
+ │ rules.py │◄──────│ quality.py │ flou / luminosité
27
+ │ décide success/blocage │ └─────────────────┘
28
+ └─────────┬────────────┘
29
+ ▼
30
+ réponse API (main.py)
31
+ ```
32
+
33
+ ## Pourquoi ce découpage en modules séparés
34
+
35
+ Chaque étage a une responsabilité et une durée de vie différentes :
36
+
37
+ - **preprocessing** et **quality** : de la vision par ordinateur classique
38
+ (OpenCV), aucune dépendance au modèle de reconnaissance. Réutilisable même
39
+ si on change de modèle IA demain.
40
+ - **recognizer** : la seule brique qui dépend du modèle entraîné. Isolée
41
+ pour pouvoir la remplacer (nouvelle version du CRNN, ou un autre modèle)
42
+ sans toucher au reste.
43
+ - **postprocess** : logique métier pure (aucune I/O, aucun modèle) — donc
44
+ entièrement testable sans charger le CRNN (voir `tests/test_postprocess.py`,
45
+ instantané).
46
+ - **rules** : la décision finale (bloquer/valider) séparée du calcul, pour
47
+ pouvoir ajuster les seuils (`config.py`/`config.yaml`) sans toucher à la
48
+ logique de calcul.
49
+
50
+ ## Différence avec le prototype précédent (OCR générique)
51
+
52
+ L'ancienne version du projet (racine du dépôt, `src/`, `api/`) utilisait
53
+ PaddleOCR/EasyOCR : des modèles pré-entraînés génériques, avec toute une
54
+ mécanique de variantes d'image et de repli entre moteurs pour compenser
55
+ leur manque de spécialisation. Ce module utilise à la place un modèle
56
+ **entraîné sur nos propres données** (voir `train/`), ce qui simplifie le
57
+ post-traitement : le CRNN sait déjà quelle ligne correspond à quel champ
58
+ (par position), il n'y a donc plus besoin d'heuristique de proximité de
59
+ libellé ni de deviner "quel nombre est le montant" par magnitude — la
60
+ principale source d'erreur du prototype précédent.
61
+
62
+ ## Fichiers clés
63
+
64
+ | Fichier | Rôle |
65
+ |---|---|
66
+ | `app/preprocessing.py` | détection écran + découpage lignes |
67
+ | `app/recognizer.py` | chargement CRNN + inférence |
68
+ | `app/postprocess.py` | normalisation, calculs, cohérence |
69
+ | `app/rules.py` | porte de décision succès/blocage |
70
+ | `app/config.py` | seuils configurables |
71
+ | `app/main.py` | API FastAPI |
72
+ | `train/finetune_doctr.py` | entraînement du CRNN |
73
+ | `docs/LIMITATIONS.md` | limites connues du modèle actuel |
docs/LIMITATIONS.md ADDED
@@ -0,0 +1,79 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Limites identifiées — modèle CRNN (§15 du cahier des charges)
2
+
3
+ ## Précision actuelle
4
+
5
+ Le modèle CRNN (`crnn_vgg16_bn`, fine-tuné à partir des poids pré-entraînés
6
+ Mindee) livré dans `models/crnn_fuel_pump_best.pt` atteint **70.2% de
7
+ précision exacte par ligne** en validation (epoch 32, voir
8
+ `train/models/v2/history.json`) — en progression depuis la version initiale
9
+ à ~55% (`train/models/history.json`), grâce au correctif de redimensionnement
10
+ (voir point 3) et à l'ajout de 3 images avec des valeurs inédites au jeu
11
+ d'annotation. C'est encore en dessous du seuil de 90% visé initialement
12
+ (`IMPLEMENTATION_PLAN.md`), mais un progrès net et mesuré sur un vrai
13
+ jeu de validation (sans fuite train/val — voir point 4).
14
+
15
+ ## Causes identifiées
16
+
17
+ 1. **Diversité des valeurs quasi nulle dans le jeu de données.** 310
18
+ échantillons au total (155 images sources découpées en lignes), mais
19
+ seulement **30 valeurs distinctes** — 8 valeurs à elles seules
20
+ représentent ~89% du dataset (probablement des photos en rafale des
21
+ mêmes transactions). Le modèle a donc très peu d'occasions d'apprendre à
22
+ généraliser la lecture de chiffres inédits ; il est plus proche de la
23
+ mémorisation d'un nombre restreint de motifs que d'une reconnaissance de
24
+ caractères robuste.
25
+
26
+ 2. **Découpage ligne-par-ligne imprécis pour 86% des images sources.**
27
+ `annotator/dataset_lines/split_report.json` montre que seulement 22/154
28
+ images ont utilisé la détection précise par projection de texte ; 132/154
29
+ sont tombées sur le repli "proportionnel" (division en bandes de hauteur
30
+ égale), qui peut couper à travers les chiffres ou inclure du bruit —
31
+ corrompant l'alignement image↔label pour la majorité de l'entraînement.
32
+
33
+ 3. **Incohérence corrigée entre entraînement et inférence (resize).**
34
+ À l'entraînement, chaque crop était étiré de force à une largeur fixe de
35
+ 256px sans préserver le ratio d'aspect (`train/finetune_doctr.py`,
36
+ `collate_fn`), alors qu'à l'inférence le ratio d'aspect était préservé
37
+ (`preprocess_crop`). Le modèle apprenait donc sur des chiffres déformés
38
+ différemment de ce qu'il voit réellement en production. **Corrigé** :
39
+ le redimensionnement d'entraînement préserve maintenant le ratio d'aspect
40
+ et complète par du padding, comme à l'inférence.
41
+
42
+ 4. **Fuite train/val corrigée.** Relancer `split_lines.py` après avoir
43
+ ajouté des annotations réassignait des images entre train et val (le
44
+ split dépend d'un tirage aléatoire sur la liste complète), mais les
45
+ anciens fichiers n'étaient pas supprimés avant d'écrire les nouveaux —
46
+ une même image pouvait donc se retrouver à la fois dans train ET val,
47
+ gonflant artificiellement l'accuracy de validation. **Corrigé** :
48
+ `split_lines.py` et `prepare_doctr_dataset.py` nettoient maintenant les
49
+ dossiers de sortie avant de régénérer. Le 70.2% actuel est mesuré après
50
+ ce correctif — donc fiable.
51
+
52
+ ## Toujours vrai malgré l'amélioration à 70.2%
53
+
54
+ Le facteur limitant reste le même : le dataset est petit et peu diversifié
55
+ en valeurs. 70.2% de lignes exactement correctes n'est pas suffisant pour
56
+ une mise en production telle quelle — voir §"Recommandations" ci-dessous,
57
+ qui restent d'actualité.
58
+
59
+ ## Recommandations pour dépasser 70%
60
+
61
+ - **Collecter plus de transactions distinctes** (valeurs de prix/volume
62
+ variées, pas seulement plus de photos des mêmes tickets) — c'est le levier
63
+ le plus impactant, mais aussi le plus long à mettre en œuvre.
64
+ - **Revoir le découpage des lignes** : ajuster les seuils de
65
+ `annotator/split_lines.py::_detect_text_bands` (actuellement 86% des
66
+ images tombent sur le repli proportionnel), ou annoter directement les
67
+ bounding box par valeur plutôt que par écran complet.
68
+ - **Vérifier visuellement** `annotator/dataset_lines/review/review_sheet.jpg`
69
+ avant tout nouvel entraînement, pour écarter les crops mal découpés.
70
+ - Envisager de la **data augmentation** (légères rotations, variations de
71
+ luminosité/contraste) pour compenser partiellement le manque de diversité.
72
+
73
+ ## Ce qui reste fiable indépendamment du modèle
74
+
75
+ La détection d'écran, le contrôle qualité (flou/luminosité), le moteur de
76
+ règles métier (cohérence montant/litres/prix, seuil de confiance, blocage)
77
+ et l'API ne dépendent pas de la précision du CRNN — ce sont des composants
78
+ séparés et testés indépendamment (voir `tests/`). Une amélioration future du
79
+ modèle de reconnaissance s'intègre donc sans changer le reste du pipeline.
docs/POSTPROCESS_RULES.md ADDED
@@ -0,0 +1,77 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Post-traitement et moteur de règles (`app/postprocess.py`, `app/rules.py`)
2
+
3
+ ## `postprocess.py` — du texte brut aux valeurs métier
4
+
5
+ ### `_norm_number(s)`
6
+
7
+ Normalise un nombre lu par le CRNN en `float` : retire les espaces,
8
+ gère la virgule décimale française ("14,28" → 14.28) et les séparateurs de
9
+ milliers quand virgule ET point sont présents ("17.500,00" → 17500.00).
10
+
11
+ ### `process(recognized_fields, fuel_price=None, cfg=None)`
12
+
13
+ Point d'entrée principal. Contrairement à l'ancien pipeline OCR générique
14
+ (`src/postprocess.py` à la racine du dépôt), **pas d'heuristique de
15
+ classification** ici : le CRNN a déjà étiqueté chaque ligne par position
16
+ (`field: "prix"|"volume"|"prix_litre"`), donc on normalise et calcule
17
+ directement.
18
+
19
+ **Règle la plus importante du fichier** :
20
+
21
+ ```python
22
+ price_val = fuel_price if fuel_price is not None else screen_price_val
23
+ ```
24
+
25
+ Le prix du litre **configuré côté YELY** (paramètre `fuel_price` de l'appelant)
26
+ fait toujours autorité sur celui lu à l'écran. C'est la règle métier n°1 du
27
+ cahier des charges ("le prix du litre doit être configurable depuis le
28
+ système YELY"), et ça corrige un bug réel rencontré en session : sur
29
+ certaines pompes, le libellé "Prix" désigne en fait le **montant total**,
30
+ pas le prix unitaire — un ancien pipeline qui faisait confiance à la valeur
31
+ lue à l'écran pour le prix unitaire pouvait donc confondre montant et prix,
32
+ et bloquer un paiement pourtant cohérent.
33
+
34
+ ### `evaluate_consistency(liters, amount, price, tol, cfg)`
35
+
36
+ Implémente la règle centrale du §5.5 : `montant = litres × prix`, avec une
37
+ tolérance (`cfg.consistency_tolerance`, 2% par défaut — pour absorber les
38
+ arrondis d'affichage pompe). Calcule aussi la valeur manquante quand une
39
+ seule des deux (litres ou montant) est lisible (§5.4/§6.4/§6.5).
40
+
41
+ ## `rules.py` — décider succès ou blocage
42
+
43
+ `evaluate(parsed, quality, fuel_price, cfg)` applique les vérifications du
44
+ §5.6/§10 **dans un ordre précis, la première qui échoue l'emporte** :
45
+
46
+ 1. Qualité image non "valid" → bloqué (photo floue/sombre/surexposée)
47
+ 2. Aucune donnée numérique détectée → bloqué
48
+ 3. Litres ET montant manquants, ou prix manquant → bloqué
49
+ 4. Incohérence détectée (`is_consistent` pas `True`) → bloqué
50
+ 5. Score de confiance sous le seuil (`cfg.business_confidence_threshold`) → bloqué
51
+ 6. Sinon → succès
52
+
53
+ Cet ordre est délibéré : par exemple, une image floue doit toujours
54
+ produire le message "photo floue" même si, par coïncidence, des chiffres
55
+ ont quand même été lus — le pompiste doit reprendre la photo, pas être
56
+ induit en erreur par un résultat qui a l'air valide.
57
+
58
+ ### Score de confiance
59
+
60
+ ```python
61
+ confidence_score = ocr_confidence * 0.6 + quality_score * 0.4
62
+ ```
63
+
64
+ Combine la confiance moyenne du CRNN sur les champs lus et le score de
65
+ qualité image. Simple et explicable (utile pour justifier une décision de
66
+ blocage au pompiste), mais **pas appris** — une piste d'amélioration futur
67
+ serait d'entraîner un petit modèle de calibration sur des données réelles
68
+ de succès/échec, plutôt qu'une pondération fixe choisie à la main.
69
+
70
+ ## Pourquoi ces deux fichiers sont séparés
71
+
72
+ `postprocess.py` ne fait aucune I/O et ne dépend d'aucun modèle : il se
73
+ teste en quelques millisecondes (`tests/test_postprocess.py`, ~11 tests,
74
+ aucun chargement du CRNN). `rules.py` encapsule uniquement la **décision**
75
+ (les seuils métier) — on peut ajuster `config.yaml` sans toucher au calcul,
76
+ et inversement changer une formule de calcul sans re-tester la logique de
77
+ blocage.
docs/PREPROCESSING.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Prétraitement (`app/preprocessing.py`)
2
+
3
+ ## `detect_screen_region(img)`
4
+
5
+ Cherche automatiquement la zone rectangulaire de l'écran LCD dans la photo,
6
+ avant tout traitement.
7
+
8
+ **Méthode** : niveaux de gris → flou gaussien → détection de contours
9
+ (Canny) → fermeture morphologique pour relier les segments → on garde le
10
+ plus grand rectangle candidat dont le ratio largeur/hauteur est plausible
11
+ pour un écran (entre 1.0 et 8.0) et dont l'aire dépasse 2% de l'image.
12
+
13
+ **Limite connue et importante** : cette heuristique cherche "le plus grand
14
+ rectangle contrasté", pas spécifiquement un écran LCD. Sur certaines
15
+ photos, elle capture par erreur le bandeau de marque du carburant (ex.
16
+ "Shell FuelSave Super") au lieu de l'écran numérique, parce que ce bandeau
17
+ forme un rectangle net et contrasté lui aussi. Repéré concrètement pendant
18
+ cette session sur `20260622_112333_023.jpg`/`024.jpg` : la détection
19
+ automatique renvoyait le bandeau jaune, pas l'écran noir avec les chiffres.
20
+
21
+ Si le pipeline ne trouve aucune donnée exploitable sur une photo qui en
22
+ contient pourtant, **c'est le premier endroit à vérifier** — visualiser le
23
+ crop retourné par cette fonction avant d'aller chercher plus loin.
24
+
25
+ Si aucun rectangle plausible n'est trouvé, la fonction retourne l'image
26
+ complète inchangée (charge alors au découpage en lignes de s'en sortir).
27
+
28
+ ## `split_lcd_lines(screen_crop, n_lines=3)`
29
+
30
+ Découpe le crop d'écran en `n_lines` bandes horizontales, une par valeur
31
+ affichée.
32
+
33
+ **Méthode principale** : projection horizontale des pixels de texte
34
+ (binarisation Otsu + CLAHE, puis comptage de pixels "actifs" par ligne) pour
35
+ repérer automatiquement où sont les bandes de texte et où sont les espaces
36
+ vides entre elles.
37
+
38
+ **Repli** : si la projection ne trouve pas exactement `n_lines` bandes
39
+ (image bruitée, reflet, contraste insuffisant), découpage en bandes de
40
+ hauteur égale. **Ce repli est peu précis** — voir `docs/LIMITATIONS.md`,
41
+ c'est la cause principale du plafond de précision actuel du modèle (86% du
42
+ jeu d'entraînement est passé par ce repli plutôt que par la détection
43
+ précise).
44
+
45
+ **Pourquoi `n_lines=3` par défaut et pas 2** : la majorité des écrans du
46
+ jeu d'annotation affichent trois valeurs (prix total, volume, prix du
47
+ litre). Un défaut à 2 aurait systématiquement tronqué la troisième ligne —
48
+ piège découvert en inspectant `src/fine_tuned_inference.py` (l'ancienne
49
+ version, dans le prototype racine, avait ce défaut à 2).
50
+
51
+ ## `FIELD_NAMES = ["prix", "volume", "prix_litre"]`
52
+
53
+ Associe chaque ligne découpée (dans l'ordre où elle apparaît, de haut en
54
+ bas) à un nom de champ métier. Cet ordre correspond à la convention
55
+ observée sur les pompes photographiées : **"Prix" en haut = montant total
56
+ payé** (pas le prix unitaire, malgré le nom), **"Volume" au milieu = litres**,
57
+ **"Prix Unitaire"/"Prix du litre" en bas = prix par litre**. Voir
58
+ `docs/POSTPROCESS_RULES.md` pour pourquoi cette distinction a causé un bug
59
+ réel (confusion montant/prix unitaire).
docs/RECOGNIZER.md ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Reconnaissance CRNN (`app/recognizer.py`)
2
+
3
+ ## Le modèle
4
+
5
+ `crnn_vgg16_bn` (bibliothèque [doctr](https://github.com/mindee/doctr)) :
6
+ un CNN (VGG16) qui extrait des features le long de l'image, suivi d'un
7
+ RNN qui prédit une séquence de caractères — architecture standard pour la
8
+ reconnaissance de texte en une ligne (pas besoin de segmenter les
9
+ caractères un par un).
10
+
11
+ Poids de départ : pré-entraînés par Mindee sur des textes imprimés
12
+ génériques, puis **fine-tunés** sur nos propres crops de lignes LCD
13
+ (voir `docs/TRAINING.md`). C'est ce fine-tuning qui constitue le "modèle IA
14
+ développé" attendu par le §8.4 du cahier des charges, par opposition à un
15
+ OCR générique utilisé tel quel.
16
+
17
+ ## `load_model(model_path=None, device=None)`
18
+
19
+ Charge les poids depuis `models/crnn_fuel_pump_best.pt` (chemin par défaut).
20
+ Échoue explicitement (`FileNotFoundError`/`ImportError`) plutôt que de se
21
+ rabattre silencieusement sur autre chose — un module de vérification de
22
+ paiement ne doit pas tourner avec un modèle absent sans que ce soit visible.
23
+
24
+ ## `recognize_screen(model, screen_crop, device, n_lines=3)`
25
+
26
+ 1. Découpe l'écran en lignes (`preprocessing.split_lcd_lines`).
27
+ 2. Pour chaque ligne, dans l'ordre : prétraitement (`preprocess_crop`) puis
28
+ prédiction (`predict_line`).
29
+ 3. Associe chaque ligne à un champ (`prix`, `volume`, `prix_litre`) par sa
30
+ **position** (voir `FIELD_NAMES` dans `preprocessing.py`) — pas par
31
+ analyse du contenu. C'est la différence clé avec l'ancien pipeline OCR
32
+ générique, qui devait deviner quel nombre était quoi.
33
+
34
+ Retourne une liste de `{"field": ..., "text": ..., "confidence": ...}`,
35
+ un élément par ligne détectée.
36
+
37
+ ## `preprocess_crop` / `_resize_preserve_aspect` — le bug corrigé cette session
38
+
39
+ Redimensionne chaque ligne à une hauteur fixe (32px) **en conservant le
40
+ ratio d'aspect**, puis normalise les valeurs de pixels.
41
+
42
+ **Pourquoi c'est écrit deux fois** (ici et dans `train/finetune_doctr.py`) :
43
+ le modèle doit voir, à l'entraînement et à l'inférence, des images
44
+ prétraitées de la **même façon**. Avant correction, l'entraînement étirait
45
+ les images à une largeur fixe (déformant les chiffres), alors que
46
+ l'inférence préservait le ratio — le modèle apprenait donc sur des formes
47
+ différentes de celles qu'il voyait réellement en production. Si l'un des
48
+ deux fichiers est modifié à l'avenir, vérifier que l'autre reste cohérent.
49
+
50
+ ## Limites connues
51
+
52
+ - Le decoder CTC de doctr peut produire des caractères hors du vocabulaire
53
+ numérique attendu (ex. "9AAm" au lieu de "20000" observé pendant les
54
+ tests) quand la ligne d'entrée est trop bruitée/mal découpée — normal vu
55
+ la taille du jeu d'entraînement (voir `docs/LIMITATIONS.md`), pas un bug
56
+ de code.
57
+ - Aucune contrainte n'est imposée sur le vocabulaire de sortie (le modèle
58
+ pourrait techniquement prédire des lettres) — une piste d'amélioration
59
+ serait de contraindre le décodage aux chiffres/virgule/point uniquement.
docs/TRAINING.md ADDED
@@ -0,0 +1,157 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Entraîner, annoter, évaluer le modèle CRNN
2
+
3
+ Ce document explique comment lancer chacune des trois étapes : annoter de
4
+ nouvelles images, ré-entraîner le modèle, évaluer sa précision. Les scripts
5
+ utilisés vivent dans le dépôt d'origine (`annotator/`, `train/` à la racine),
6
+ pas dans `yely_ai_module/` — ce dossier livrable embarque une copie de
7
+ référence de `train/` pour la reproductibilité (voir §"Où ça vit" plus bas).
8
+
9
+ ## Vue d'ensemble du pipeline data → modèle
10
+
11
+ ```
12
+ 1. Annoter annotator/annotate.py
13
+ │ (dessine le rectangle LCD + saisit prix/volume/prix_litre)
14
+ ▼
15
+ annotator/annotations/annotations.json (+ crops dans annotations/crops/)
16
+ │
17
+ 2. Découper en lignes annotator/split_lines.py
18
+ │ (1 image = 3 valeurs → 3 sous-images mono-ligne)
19
+ ▼
20
+ annotator/dataset_lines/{train,val}/
21
+ │
22
+ 3. Convertir au format doctr train/prepare_doctr_dataset.py
23
+ ▼
24
+ annotator/dataset_doctr/{train,val}/ (images/ + labels.json)
25
+ │
26
+ 4. Entraîner train/finetune_doctr.py
27
+ ▼
28
+ train/models/crnn_fuel_pump_best.pt (+ history.json)
29
+ │
30
+ 5. Évaluer train/test_model.py
31
+ ```
32
+
33
+ ## 1. Annoter de nouvelles images
34
+
35
+ ```bash
36
+ cd annotator
37
+ python annotate.py --images ../images/ # nouvelles photos
38
+ python annotate.py --images ../images/ --resume # reprendre où on s'était arrêté
39
+ python annotate.py --review # voir un résumé des annotations existantes
40
+ ```
41
+
42
+ Pour chaque image : dessiner un rectangle autour de l'écran LCD (clic +
43
+ glisser), puis saisir dans le terminal les valeurs affichées (prix, volume,
44
+ prix du litre). Voir `annotator/README.md` pour le détail des raccourcis.
45
+
46
+ **Ce qui compte le plus pour améliorer le modèle** (voir `docs/LIMITATIONS.md`) :
47
+ annoter des photos avec des **valeurs différentes** de celles déjà présentes,
48
+ pas juste plus de photos des mêmes tickets. Utiliser
49
+ `yely_ai_module/tools/check_seen_image.py` pour vérifier qu'une photo
50
+ candidate n'est pas déjà (quasi-)présente dans le jeu annoté :
51
+
52
+ ```bash
53
+ python yely_ai_module/tools/check_seen_image.py chemin/vers/nouvelle_photo.jpg
54
+ ```
55
+
56
+ ## 2. Régénérer le dataset d'entraînement
57
+
58
+ **Toujours dans cet ordre**, et **jamais pendant qu'un entraînement tourne**
59
+ (le chargement des images se fait à la volée pendant l'entraînement — les
60
+ régénérer en même temps fait planter le script avec un `FileNotFoundError`,
61
+ vécu concrètement pendant cette session) :
62
+
63
+ ```bash
64
+ cd annotator
65
+ python split_lines.py # annotations.json -> dataset_lines/
66
+
67
+ cd ../train
68
+ python prepare_doctr_dataset.py # dataset_lines/ -> dataset_doctr/
69
+ ```
70
+
71
+ Les deux scripts nettoient maintenant leur dossier de sortie avant de
72
+ régénérer (corrigé cette session — une même image pouvait sinon se
73
+ retrouver à la fois en train et en val d'un run à l'autre, faussant
74
+ l'évaluation).
75
+
76
+ **Vérification recommandée avant d'entraîner** : ouvrir
77
+ `annotator/dataset_lines/review/review_sheet.jpg`, qui montre un échantillon
78
+ d'images découpées à côté du label attendu — permet de repérer un mauvais
79
+ découpage avant de perdre du temps à entraîner dessus.
80
+
81
+ ## 3. Lancer l'entraînement
82
+
83
+ ```bash
84
+ cd train
85
+ python finetune_doctr.py # 80 epochs par défaut
86
+ python finetune_doctr.py --epochs 60 --patience 12
87
+ python finetune_doctr.py --from-scratch # si pas de connexion internet (pas de poids pré-entraînés)
88
+ ```
89
+
90
+ - Le modèle est sauvegardé dans `train/models/crnn_fuel_pump_best.pt` à
91
+ chaque fois que la perte de validation s'améliore (pas seulement à la fin) —
92
+ s'arrêter en cours de route (Ctrl+C, crash) ne perd donc pas tout.
93
+ - `--patience N` : arrête l'entraînement si la perte de validation ne
94
+ s'améliore plus pendant N epochs (évite de continuer à surapprendre inutilement).
95
+ - Sur CPU (pas de GPU disponible), compter ~1.5-3 min/epoch sur le jeu de
96
+ données actuel (~270 échantillons train). Un entraînement complet peut
97
+ donc prendre plusieurs heures — lancer en arrière-plan
98
+ (`... &` ou un terminal dédié) plutôt qu'en bloquant.
99
+ - À la fin (ou à l'arrêt anticipé), `train/models/history.json` contient
100
+ la courbe complète (perte/accuracy par epoch) — sert de preuve pour le
101
+ rapport de test (§15 du cahier des charges).
102
+
103
+ ## 4. Évaluer le modèle
104
+
105
+ ```bash
106
+ cd train
107
+ python test_model.py # évalue sur tout le set de validation
108
+ python test_model.py --image ../images/pompe.jpg # teste une seule image
109
+ ```
110
+
111
+ `test_model.py` calcule, sur le jeu de validation :
112
+ - **Exact-match accuracy** : proportion de lignes lues à 100% correctement
113
+ (c'est la métrique citée dans `docs/LIMITATIONS.md`).
114
+ - **CER** (Character Error Rate) et **WER** : à quel point une prédiction
115
+ fausse est "proche" de la bonne réponse (ex. "10000" lu "1O000" a un CER
116
+ faible même si l'exact-match échoue) — plus informatif que l'accuracy
117
+ seule pour juger si le modèle progresse.
118
+ - La liste des erreurs (jusqu'à 15 affichées), utile pour repérer des
119
+ motifs d'erreur récurrents (un chiffre systématiquement confondu, une
120
+ ligne toujours mal découpée...).
121
+
122
+ ## Où ça vit (dépôt d'origine vs dossier livrable)
123
+
124
+ - **Annotation et préparation du dataset** (`annotator/`) : uniquement à la
125
+ racine du dépôt d'origine, pas dupliqué dans `yely_ai_module/` (le jeu de
126
+ ~155 images sources n'est volontairement pas inclus dans le livrable).
127
+ - **Entraînement** (`train/`) : présent aux deux endroits. La copie dans
128
+ `yely_ai_module/train/` est une référence pour la reproductibilité
129
+ (documentation technique) ; pour ré-entraîner réellement, utiliser la
130
+ version à la racine du dépôt, qui a accès à `annotator/dataset_doctr/`.
131
+ - **Script `train/infer.py`** : ancien script d'inférence autonome
132
+ (antérieur à `yely_ai_module/app/recognizer.py`), gardé pour test rapide
133
+ en ligne de commande. Avait le même bug de découpage à 2 lignes (au lieu
134
+ de 3) que celui corrigé dans `preprocessing.py` — corrigé aussi. Pour
135
+ tester le pipeline réellement livré (avec règles métier, API), utiliser
136
+ `yely_ai_module/app/`, pas ce script.
137
+
138
+ ## Si le modèle donne de mauvais résultats en test manuel
139
+
140
+ Avant de conclure "le modèle n'est pas bon", vérifier dans l'ordre :
141
+
142
+ 1. **Quel script a été utilisé ?** `train/infer.py` et `yely_ai_module/app/`
143
+ ont des logiques de détection d'écran différentes (`detect_lcd_region`
144
+ vs `detect_screen_region`) — l'un peut réussir là où l'autre échoue sur
145
+ la même photo.
146
+ 2. **La zone détectée est-elle la bonne ?** Les deux scripts peuvent
147
+ confondre le bandeau de marque ("Shell FuelSave...") avec l'écran LCD sur
148
+ certaines photos (voir `docs/PREPROCESSING.md`) — sauvegarder et regarder
149
+ le crop intermédiaire avant d'incriminer le CRNN.
150
+ 3. **La photo est-elle inédite ou proche du jeu d'entraînement ?**
151
+ (`tools/check_seen_image.py`) — le modèle est attendu comme moins bon sur
152
+ des valeurs qu'il n'a jamais vues, c'est documenté et mesuré
153
+ (`docs/LIMITATIONS.md`), pas une surprise.
154
+ 4. **Quelle précision réelle attendre ?** 70.2% exact-match sur les lignes
155
+ de validation (`train/models/v2/history.json`) — donc environ 3 lectures
156
+ sur 10 avec au moins un caractère faux sont *attendues* à ce stade, pas
157
+ un signe que quelque chose est cassé.
docs/WORKFLOW.md ADDED
@@ -0,0 +1,180 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Workflow — finalisation du module IA YELY
2
+
3
+ Organisation recommandée pour arriver à un livrable démontrable : tests de
4
+ type production, suivi des performances, apprentissage continu,
5
+ documentation complète, interface web de démo, déploiement.
6
+
7
+ ## 1. Arborescence cible du dépôt
8
+
9
+ ```
10
+ yely_ai_module/
11
+ ├── app/ # code de production (déjà en place)
12
+ ├── models/ # modèle(s) sérialisé(s) — voir §3 versioning
13
+ ├── train/ # scripts d'entraînement (référence)
14
+ ├── tests/
15
+ │ ├── test_postprocess.py # ✅ fait
16
+ │ ├── test_rules.py # ✅ fait
17
+ │ ├── test_api.py # ✅ fait (mocké)
18
+ │ └── test_integration.py # à faire : vrai modèle, vraies images
19
+ ├── tools/
20
+ │ └── check_seen_image.py # ✅ fait
21
+ ├── monitoring/ # à créer — §2
22
+ │ ├── logger.py # log structuré prédiction + vérité terrain
23
+ │ ├── metrics.py # calcul accuracy glissante, drift
24
+ │ └── feedback.py # collecte des corrections pompiste
25
+ ├── docs/ # à créer — §4
26
+ │ ├── WORKFLOW.md # ce fichier
27
+ │ ├── LIMITATIONS.md # ✅ fait
28
+ │ ├── ARCHITECTURE.md
29
+ │ ├── PREPROCESSING.md
30
+ │ ├── RECOGNIZER.md
31
+ │ ├── POSTPROCESS_RULES.md
32
+ │ ├── API.md
33
+ │ └── MONITORING.md
34
+ ├── web/ # à créer — §5, interface de démo
35
+ │ ├── index.html / app Next.js
36
+ │ └── vercel.json
37
+ └── requirements.txt
38
+ ```
39
+
40
+ ## 2. Tests "comme en production"
41
+
42
+ Objectif : valider le comportement réel, pas seulement la logique unitaire.
43
+
44
+ 1. **Test d'intégration avec le vrai modèle** (`tests/test_integration.py`) :
45
+ envoyer une vraie photo à `recognize_screen` (pas de mock), vérifier que
46
+ la réponse a la bonne forme et un temps de réponse mesuré.
47
+ 2. **Test API bout-en-bout** : lancer `uvicorn`, envoyer une requête HTTP
48
+ réelle via `httpx`/`curl`, sur 3-5 photos couvrant les scénarios du
49
+ cahier des charges (§14) : nette/cohérente, floue, incohérente,
50
+ montant seul, litres seuls.
51
+ 3. **Test de charge léger** : mesurer le temps de réponse sur 10 requêtes
52
+ séquentielles (le modèle doit rester chargé en mémoire entre les
53
+ requêtes — vérifier qu'il n'y a pas de rechargement).
54
+ 4. **Rapport de test** (§15 du cahier des charges) : générer un tableau
55
+ photo → attendu → obtenu → écart, à partir des images du dossier
56
+ `Images datasetdiversifié/` (celles non utilisées à l'entraînement).
57
+
58
+ ## 3. Suivi des performances + apprentissage continu
59
+
60
+ ### Suivi (`monitoring/`)
61
+
62
+ - Chaque appel à `/analyze` log déjà la requête/réponse dans `logs/api.log`.
63
+ À ajouter : un identifiant de version du modèle (`model_version`) dans
64
+ chaque entrée, pour pouvoir comparer les performances entre versions.
65
+ - `monitoring/metrics.py` : script qui parcourt `logs/api.log` et calcule
66
+ périodiquement : taux de succès, distribution des `confidence_score`,
67
+ taux de blocage par cause (floue/incohérence/confiance).
68
+
69
+ ### Apprentissage continu (boucle de feedback)
70
+
71
+ Le principe : chaque photo traitée par l'API est déjà sauvegardée
72
+ (`photos/<uuid>.jpg`). Il manque la boucle qui transforme ces photos en
73
+ nouvelles données d'entraînement :
74
+
75
+ 1. **Endpoint de correction** (`POST /feedback`) : le pompiste (ou un
76
+ contrôle a posteriori côté YELY) envoie `transaction_id` + les valeurs
77
+ réellement correctes. Stocké dans `monitoring/feedback.jsonl`.
78
+ 2. **Script de conversion** (`monitoring/feedback.py`) : transforme les
79
+ entrées corrigées en nouvelles entrées `annotations.json` (même format
80
+ que l'annotation manuelle), en réutilisant `photos/<uuid>.jpg` comme
81
+ image source.
82
+ 3. **Ré-entraînement périodique** : relancer `split_lines.py` →
83
+ `prepare_doctr_dataset.py` → `finetune_doctr.py` quand un nombre
84
+ suffisant de nouvelles corrections est accumulé (ex. tous les 50).
85
+ **Important** (retenu de cette session) : ne jamais modifier le dataset
86
+ pendant qu'un entraînement tourne (lecture disque à la volée) ; toujours
87
+ nettoyer `dataset_lines/`/`dataset_doctr/` avant de régénérer (déjà
88
+ corrigé).
89
+ 4. **Versioning des modèles** : chaque nouveau modèle entraîné va dans
90
+ `models/v<N>/`, avec son propre `history.json`. Le modèle "actif" utilisé
91
+ par l'API est un lien/chemin configurable (`CRNN_MODEL_PATH` en variable
92
+ d'environnement), pas une réécriture du fichier précédent — pour pouvoir
93
+ revenir en arrière si une nouvelle version est pire.
94
+
95
+ ## 4. Documentation (`docs/`)
96
+
97
+ Un fichier par aspect, cible = qu'un lecteur qui n'a pas suivi le
98
+ développement comprenne le rôle et les choix de chaque module :
99
+
100
+ - `ARCHITECTURE.md` : schéma du pipeline complet (image → écran �� lignes →
101
+ CRNN → postprocess → rules → réponse), et pourquoi ce découpage.
102
+ - `PREPROCESSING.md` : détection d'écran, découpage en lignes, limites
103
+ connues (cas où la détection choisit la mauvaise zone — voir l'incident
104
+ du bandeau de marque confondu avec l'écran).
105
+ - `RECOGNIZER.md` : choix du CRNN, format d'entrée/sortie, le bug
106
+ resize corrigé et pourquoi c'était important.
107
+ - `POSTPROCESS_RULES.md` : normalisation numérique, règle de priorité
108
+ `fuel_price` configuré > lu à l'écran, moteur de règles de blocage.
109
+ - `API.md` : contrat de l'endpoint, exemples de requêtes/réponses.
110
+ - `MONITORING.md` : comment lire les logs, comment fonctionne la boucle de
111
+ feedback.
112
+ - `LIMITATIONS.md` : déjà fait — diversité du dataset, précision actuelle.
113
+
114
+ ## 5. Interface web de démo
115
+
116
+ Objectif : une page simple, présentable en soutenance, qui appelle l'API et
117
+ affiche le résultat de façon lisible pour un non-développeur (le formulaire
118
+ technique `test_api_form.html` existant sert de base, mais mérite une
119
+ version "présentation" séparée : moins de champs bruts, plus visuelle).
120
+
121
+ Contenu minimal :
122
+ - Upload/prise de photo (accepte l'appareil photo sur mobile via
123
+ `capture="environment"`).
124
+ - Choix du prix du litre (pré-rempli, modifiable).
125
+ - Affichage : bannière succès/bloqué, valeurs détectées, calculées, statut
126
+ de cohérence — pas le JSON brut par défaut (accessible en option).
127
+ - Appel vers l'URL de l'API configurée (variable d'environnement au build).
128
+
129
+ ## 6. Déploiement
130
+
131
+ **Frontend (`web/`) → Vercel** : parfaitement adapté (statique ou
132
+ Next.js), pas de souci particulier.
133
+
134
+ **API IA → PAS Vercel.** Point d'attention important avant d'aller plus
135
+ loin : Vercel (Serverless Functions) limite la taille du build (~250 Mo
136
+ décompressés) et le temps d'exécution par requête. Cette API embarque
137
+ PyTorch + doctr + OpenCV, qui dépassent déjà largement cette taille à eux
138
+ seuls, et l'inférence CRNN prend actuellement 60-100+ secondes par image
139
+ (bien au-delà des temps d'exécution autorisés par Vercel, même sur les
140
+ plans payants). Déployer tel quel sur Vercel échouera au build ou au
141
+ timeout, pas juste "sera lent".
142
+
143
+ **Décision retenue : Hugging Face Spaces (Docker SDK)** pour l'API.
144
+ Gratuit, pensé pour les démos ML, pas de limite stricte de temps
145
+ d'exécution comme Vercel, `Dockerfile` déjà préparé (`yely_ai_module/Dockerfile`).
146
+
147
+ Étapes de déploiement (à faire manuellement, action externe) :
148
+ 1. Créer un Space sur huggingface.co → SDK "Docker" → visibilité au choix.
149
+ 2. Ajouter en tête de `yely_ai_module/README.md` le bloc de configuration
150
+ attendu par HF Spaces :
151
+ ```yaml
152
+ ---
153
+ title: YELY AI Module
154
+ emoji: ⛽
155
+ colorFrom: blue
156
+ colorTo: green
157
+ sdk: docker
158
+ app_port: 7860
159
+ ---
160
+ ```
161
+ 3. Pousser le contenu de `yely_ai_module/` (avec `models/crnn_fuel_pump_best.pt`
162
+ inclus) vers le dépôt git du Space.
163
+ 4. Noter l'URL publique du Space (`https://<user>-<space>.hf.space`) — c'est
164
+ l'URL que le frontend Vercel appellera pour `/analyze`.
165
+ 5. Ajouter `CORSMiddleware` dans `app/main.py` pour autoriser le domaine
166
+ Vercel du frontend (sinon le navigateur bloquera les réponses).
167
+
168
+ ## 7. Ordre d'exécution recommandé (vu le délai serré)
169
+
170
+ 1. Terminer l'entraînement en cours, figer le modèle livré.
171
+ 2. Tests d'intégration réels (§2.1-2.2) — valide que tout fonctionne
172
+ vraiment avant de documenter/déployer.
173
+ 3. Interface web de démo (§5) branchée sur l'API en local — utilisable pour
174
+ répéter la démo même sans déploiement cloud.
175
+ 4. Documentation (§4) — peut se faire en parallèle du reste.
176
+ 5. Déploiement (§6) — une fois l'hébergement de l'API décidé.
177
+ 6. Monitoring/apprentissage continu (§3) — le plus gros morceau, à
178
+ présenter comme "conçu et partiellement implémenté" si le temps manque
179
+ (c'est un livrable attendu — §8.6/§15 — mais l'essentiel du temps
180
+ restant doit sécuriser une démo qui fonctionne).
models/crnn_fuel_pump_best.pt ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ version https://git-lfs.github.com/spec/v1
2
+ oid sha256:6bdb4de837296488ed28a749cc984a8a11d908348706c4d5961f28ef8878899a
3
+ size 63304482
models/history.json ADDED
@@ -0,0 +1,426 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ {
2
+ "history": [
3
+ {
4
+ "epoch": 1,
5
+ "train_loss": 2.9120928364641525,
6
+ "val_loss": 2.3460725943247476,
7
+ "val_acc": 0.3191489361702128,
8
+ "lr": 5e-05
9
+ },
10
+ {
11
+ "epoch": 2,
12
+ "train_loss": 1.9310398803037756,
13
+ "val_loss": 2.0164391001065574,
14
+ "val_acc": 0.3191489361702128,
15
+ "lr": 5e-05
16
+ },
17
+ {
18
+ "epoch": 3,
19
+ "train_loss": 1.5905358405674206,
20
+ "val_loss": 1.807423174381256,
21
+ "val_acc": 0.3404255319148936,
22
+ "lr": 5e-05
23
+ },
24
+ {
25
+ "epoch": 4,
26
+ "train_loss": 1.3240579349153183,
27
+ "val_loss": 1.5607381065686543,
28
+ "val_acc": 0.40425531914893614,
29
+ "lr": 5e-05
30
+ },
31
+ {
32
+ "epoch": 5,
33
+ "train_loss": 1.1439797177034265,
34
+ "val_loss": 1.4377244412899017,
35
+ "val_acc": 0.425531914893617,
36
+ "lr": 5e-05
37
+ },
38
+ {
39
+ "epoch": 6,
40
+ "train_loss": 0.9370511406484772,
41
+ "val_loss": 1.3487879733244579,
42
+ "val_acc": 0.48936170212765956,
43
+ "lr": 5e-05
44
+ },
45
+ {
46
+ "epoch": 7,
47
+ "train_loss": 0.8480383823899662,
48
+ "val_loss": 1.2953779598077138,
49
+ "val_acc": 0.5106382978723404,
50
+ "lr": 5e-05
51
+ },
52
+ {
53
+ "epoch": 8,
54
+ "train_loss": 0.7292340816382099,
55
+ "val_loss": 1.2790659566720326,
56
+ "val_acc": 0.5319148936170213,
57
+ "lr": 5e-05
58
+ },
59
+ {
60
+ "epoch": 9,
61
+ "train_loss": 0.6626956646933275,
62
+ "val_loss": 1.207442099849383,
63
+ "val_acc": 0.574468085106383,
64
+ "lr": 5e-05
65
+ },
66
+ {
67
+ "epoch": 10,
68
+ "train_loss": 0.6213903804035747,
69
+ "val_loss": 1.1846911311149597,
70
+ "val_acc": 0.5531914893617021,
71
+ "lr": 5e-05
72
+ },
73
+ {
74
+ "epoch": 11,
75
+ "train_loss": 0.5165981354520601,
76
+ "val_loss": 1.1549755483865738,
77
+ "val_acc": 0.6170212765957447,
78
+ "lr": 5e-05
79
+ },
80
+ {
81
+ "epoch": 12,
82
+ "train_loss": 0.5032322286244701,
83
+ "val_loss": 1.1022245561083157,
84
+ "val_acc": 0.6170212765957447,
85
+ "lr": 5e-05
86
+ },
87
+ {
88
+ "epoch": 13,
89
+ "train_loss": 0.4294072784045163,
90
+ "val_loss": 1.1384087776144345,
91
+ "val_acc": 0.6170212765957447,
92
+ "lr": 5e-05
93
+ },
94
+ {
95
+ "epoch": 14,
96
+ "train_loss": 0.39344385529265685,
97
+ "val_loss": 1.0891480594873428,
98
+ "val_acc": 0.6595744680851063,
99
+ "lr": 5e-05
100
+ },
101
+ {
102
+ "epoch": 15,
103
+ "train_loss": 0.3947870174751562,
104
+ "val_loss": 1.0561534091830254,
105
+ "val_acc": 0.6382978723404256,
106
+ "lr": 5e-05
107
+ },
108
+ {
109
+ "epoch": 16,
110
+ "train_loss": 0.3652436829665128,
111
+ "val_loss": 1.0487923547625542,
112
+ "val_acc": 0.6595744680851063,
113
+ "lr": 5e-05
114
+ },
115
+ {
116
+ "epoch": 17,
117
+ "train_loss": 0.3264134029912598,
118
+ "val_loss": 1.015688605606556,
119
+ "val_acc": 0.6595744680851063,
120
+ "lr": 5e-05
121
+ },
122
+ {
123
+ "epoch": 18,
124
+ "train_loss": 0.309357831857222,
125
+ "val_loss": 1.0396239832043648,
126
+ "val_acc": 0.6595744680851063,
127
+ "lr": 5e-05
128
+ },
129
+ {
130
+ "epoch": 19,
131
+ "train_loss": 0.3051291299874292,
132
+ "val_loss": 1.0147636259595554,
133
+ "val_acc": 0.6595744680851063,
134
+ "lr": 5e-05
135
+ },
136
+ {
137
+ "epoch": 20,
138
+ "train_loss": 0.27475875517462983,
139
+ "val_loss": 1.0161741822957993,
140
+ "val_acc": 0.6808510638297872,
141
+ "lr": 5e-05
142
+ },
143
+ {
144
+ "epoch": 21,
145
+ "train_loss": 0.24898530515458653,
146
+ "val_loss": 1.000812940299511,
147
+ "val_acc": 0.6808510638297872,
148
+ "lr": 5e-05
149
+ },
150
+ {
151
+ "epoch": 22,
152
+ "train_loss": 0.22601729357505546,
153
+ "val_loss": 1.01864734167854,
154
+ "val_acc": 0.7021276595744681,
155
+ "lr": 5e-05
156
+ },
157
+ {
158
+ "epoch": 23,
159
+ "train_loss": 0.22106353631790945,
160
+ "val_loss": 0.9843647293746471,
161
+ "val_acc": 0.7021276595744681,
162
+ "lr": 5e-05
163
+ },
164
+ {
165
+ "epoch": 24,
166
+ "train_loss": 0.21067261991693692,
167
+ "val_loss": 0.9799626593788465,
168
+ "val_acc": 0.7021276595744681,
169
+ "lr": 5e-05
170
+ },
171
+ {
172
+ "epoch": 25,
173
+ "train_loss": 0.198403762489119,
174
+ "val_loss": 1.0153791829943657,
175
+ "val_acc": 0.7021276595744681,
176
+ "lr": 5e-05
177
+ },
178
+ {
179
+ "epoch": 26,
180
+ "train_loss": 0.23490384497734554,
181
+ "val_loss": 0.970666674276193,
182
+ "val_acc": 0.7021276595744681,
183
+ "lr": 5e-05
184
+ },
185
+ {
186
+ "epoch": 27,
187
+ "train_loss": 0.2061745562207173,
188
+ "val_loss": 0.9808499614397684,
189
+ "val_acc": 0.7021276595744681,
190
+ "lr": 5e-05
191
+ },
192
+ {
193
+ "epoch": 28,
194
+ "train_loss": 0.17278411294169285,
195
+ "val_loss": 0.9578678918381532,
196
+ "val_acc": 0.7021276595744681,
197
+ "lr": 5e-05
198
+ },
199
+ {
200
+ "epoch": 29,
201
+ "train_loss": 0.17103130540207906,
202
+ "val_loss": 0.975345945606629,
203
+ "val_acc": 0.7021276595744681,
204
+ "lr": 5e-05
205
+ },
206
+ {
207
+ "epoch": 30,
208
+ "train_loss": 0.14729907297912767,
209
+ "val_loss": 0.9681643905738989,
210
+ "val_acc": 0.7021276595744681,
211
+ "lr": 5e-05
212
+ },
213
+ {
214
+ "epoch": 31,
215
+ "train_loss": 0.16524034897413323,
216
+ "val_loss": 0.9656790184477965,
217
+ "val_acc": 0.7021276595744681,
218
+ "lr": 5e-05
219
+ },
220
+ {
221
+ "epoch": 32,
222
+ "train_loss": 0.1521763262229369,
223
+ "val_loss": 0.9372376700242361,
224
+ "val_acc": 0.7021276595744681,
225
+ "lr": 5e-05
226
+ },
227
+ {
228
+ "epoch": 33,
229
+ "train_loss": 0.15467397811110406,
230
+ "val_loss": 0.9560814400513967,
231
+ "val_acc": 0.7021276595744681,
232
+ "lr": 5e-05
233
+ },
234
+ {
235
+ "epoch": 34,
236
+ "train_loss": 0.13949972199385657,
237
+ "val_loss": 0.9447329379618168,
238
+ "val_acc": 0.7021276595744681,
239
+ "lr": 5e-05
240
+ },
241
+ {
242
+ "epoch": 35,
243
+ "train_loss": 0.12902705441229045,
244
+ "val_loss": 0.9566328227519989,
245
+ "val_acc": 0.7021276595744681,
246
+ "lr": 5e-05
247
+ },
248
+ {
249
+ "epoch": 36,
250
+ "train_loss": 0.16284221629886067,
251
+ "val_loss": 0.9468842148780823,
252
+ "val_acc": 0.7021276595744681,
253
+ "lr": 2.5e-05
254
+ },
255
+ {
256
+ "epoch": 37,
257
+ "train_loss": 0.13035948549890342,
258
+ "val_loss": 0.9455590850363175,
259
+ "val_acc": 0.7021276595744681,
260
+ "lr": 2.5e-05
261
+ },
262
+ {
263
+ "epoch": 38,
264
+ "train_loss": 0.1562366568045143,
265
+ "val_loss": 0.9594765727718672,
266
+ "val_acc": 0.7021276595744681,
267
+ "lr": 2.5e-05
268
+ },
269
+ {
270
+ "epoch": 39,
271
+ "train_loss": 0.12504405479001648,
272
+ "val_loss": 0.9333067275583744,
273
+ "val_acc": 0.7021276595744681,
274
+ "lr": 2.5e-05
275
+ },
276
+ {
277
+ "epoch": 40,
278
+ "train_loss": 0.13936584319590645,
279
+ "val_loss": 0.9340925253927708,
280
+ "val_acc": 0.7021276595744681,
281
+ "lr": 2.5e-05
282
+ },
283
+ {
284
+ "epoch": 41,
285
+ "train_loss": 0.12360574594041442,
286
+ "val_loss": 0.9377497670551141,
287
+ "val_acc": 0.7021276595744681,
288
+ "lr": 2.5e-05
289
+ },
290
+ {
291
+ "epoch": 42,
292
+ "train_loss": 0.11103411624208093,
293
+ "val_loss": 0.9450751269857088,
294
+ "val_acc": 0.7021276595744681,
295
+ "lr": 2.5e-05
296
+ },
297
+ {
298
+ "epoch": 43,
299
+ "train_loss": 0.10658511809785576,
300
+ "val_loss": 0.9773282657066981,
301
+ "val_acc": 0.7021276595744681,
302
+ "lr": 1.25e-05
303
+ },
304
+ {
305
+ "epoch": 44,
306
+ "train_loss": 0.10607678348691586,
307
+ "val_loss": 0.9355429125328859,
308
+ "val_acc": 0.7021276595744681,
309
+ "lr": 1.25e-05
310
+ },
311
+ {
312
+ "epoch": 45,
313
+ "train_loss": 0.10428869187393609,
314
+ "val_loss": 0.9588954467326403,
315
+ "val_acc": 0.7021276595744681,
316
+ "lr": 1.25e-05
317
+ },
318
+ {
319
+ "epoch": 46,
320
+ "train_loss": 0.1177324857973658,
321
+ "val_loss": 0.9499378191928068,
322
+ "val_acc": 0.7021276595744681,
323
+ "lr": 1.25e-05
324
+ },
325
+ {
326
+ "epoch": 47,
327
+ "train_loss": 0.0943571002332165,
328
+ "val_loss": 0.9513349328190088,
329
+ "val_acc": 0.7021276595744681,
330
+ "lr": 6.25e-06
331
+ },
332
+ {
333
+ "epoch": 48,
334
+ "train_loss": 0.11425591779270154,
335
+ "val_loss": 0.942640719935298,
336
+ "val_acc": 0.7021276595744681,
337
+ "lr": 6.25e-06
338
+ },
339
+ {
340
+ "epoch": 49,
341
+ "train_loss": 0.11487811471006888,
342
+ "val_loss": 0.932662837828199,
343
+ "val_acc": 0.7021276595744681,
344
+ "lr": 6.25e-06
345
+ },
346
+ {
347
+ "epoch": 50,
348
+ "train_loss": 0.10698410742642249,
349
+ "val_loss": 0.9508347008377314,
350
+ "val_acc": 0.7021276595744681,
351
+ "lr": 6.25e-06
352
+ },
353
+ {
354
+ "epoch": 51,
355
+ "train_loss": 0.0994053436032332,
356
+ "val_loss": 0.9428594596683979,
357
+ "val_acc": 0.7021276595744681,
358
+ "lr": 6.25e-06
359
+ },
360
+ {
361
+ "epoch": 52,
362
+ "train_loss": 0.09451003673979465,
363
+ "val_loss": 0.9704437119265398,
364
+ "val_acc": 0.7021276595744681,
365
+ "lr": 6.25e-06
366
+ },
367
+ {
368
+ "epoch": 53,
369
+ "train_loss": 0.09321472450049922,
370
+ "val_loss": 0.9285884375373522,
371
+ "val_acc": 0.7021276595744681,
372
+ "lr": 6.25e-06
373
+ },
374
+ {
375
+ "epoch": 54,
376
+ "train_loss": 0.10263871913775802,
377
+ "val_loss": 0.9478914358963569,
378
+ "val_acc": 0.7021276595744681,
379
+ "lr": 6.25e-06
380
+ },
381
+ {
382
+ "epoch": 55,
383
+ "train_loss": 0.1034667265793199,
384
+ "val_loss": 0.9571116616328558,
385
+ "val_acc": 0.7021276595744681,
386
+ "lr": 6.25e-06
387
+ },
388
+ {
389
+ "epoch": 56,
390
+ "train_loss": 0.10305118007475839,
391
+ "val_loss": 0.9262590693930784,
392
+ "val_acc": 0.7021276595744681,
393
+ "lr": 6.25e-06
394
+ },
395
+ {
396
+ "epoch": 57,
397
+ "train_loss": 0.09248770906261224,
398
+ "val_loss": 0.9442655543486277,
399
+ "val_acc": 0.7021276595744681,
400
+ "lr": 6.25e-06
401
+ },
402
+ {
403
+ "epoch": 58,
404
+ "train_loss": 0.16216179406653872,
405
+ "val_loss": 0.9420440445343653,
406
+ "val_acc": 0.7021276595744681,
407
+ "lr": 6.25e-06
408
+ },
409
+ {
410
+ "epoch": 59,
411
+ "train_loss": 0.0958185838535428,
412
+ "val_loss": 0.9358861912041903,
413
+ "val_acc": 0.7021276595744681,
414
+ "lr": 6.25e-06
415
+ },
416
+ {
417
+ "epoch": 60,
418
+ "train_loss": 0.10177546147914494,
419
+ "val_loss": 0.9465262399365505,
420
+ "val_acc": 0.7021276595744681,
421
+ "lr": 3.125e-06
422
+ }
423
+ ],
424
+ "best_val_loss": 0.9262590693930784,
425
+ "best_val_acc": 0.7021276595744681
426
+ }
requirements.txt ADDED
@@ -0,0 +1,15 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Vision par ordinateur (détection écran, prétraitement)
2
+ opencv-python-headless>=4.8.0
3
+ numpy>=1.24.0
4
+
5
+ # Modèle IA (CRNN fine-tuné)
6
+ python-doctr[torch]>=1.0.1
7
+
8
+ # API
9
+ fastapi>=0.112.0
10
+ uvicorn[standard]>=0.23.0
11
+ python-multipart>=0.0.6
12
+
13
+ # Configuration & tests
14
+ pyyaml>=6.0
15
+ pytest>=7.4.0
tests/__init__.py ADDED
File without changes
tests/test_api.py ADDED
@@ -0,0 +1,81 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import io
2
+ import unittest
3
+ from unittest.mock import patch
4
+
5
+ import numpy as np
6
+ import cv2
7
+ from fastapi.testclient import TestClient
8
+
9
+ from app import main
10
+
11
+
12
+ def _fake_jpeg_bytes(color=(200, 200, 200), size=(300, 200)):
13
+ img = np.full((size[1], size[0], 3), color, dtype=np.uint8)
14
+ ok, buf = cv2.imencode(".jpg", img)
15
+ assert ok
16
+ return buf.tobytes()
17
+
18
+
19
+ class TestAnalyzeEndpoint(unittest.TestCase):
20
+ def setUp(self):
21
+ self.client = TestClient(main.app)
22
+
23
+ def test_consistent_reading_returns_success(self):
24
+ fake_fields = [
25
+ {"field": "prix", "text": "10000", "confidence": 0.95},
26
+ {"field": "volume", "text": "14.28", "confidence": 0.9},
27
+ {"field": "prix_litre", "text": "700", "confidence": 0.85},
28
+ ]
29
+ fake_quality = {"image_quality": "valid", "quality_score": 0.95,
30
+ "blur_variance": 500.0, "brightness": 120.0}
31
+ with patch.object(main, "_get_model", return_value=(None, None)), \
32
+ patch.object(main, "recognize_screen", return_value=fake_fields), \
33
+ patch.object(main, "estimate_image_quality", return_value=fake_quality):
34
+ resp = self.client.post(
35
+ "/analyze",
36
+ files={"image": ("pompe.jpg", _fake_jpeg_bytes(), "image/jpeg")},
37
+ data={"fuel_price": "700", "driver_id": "chauffeur-42"},
38
+ )
39
+ self.assertEqual(resp.status_code, 200)
40
+ body = resp.json()
41
+ self.assertTrue(body["success"])
42
+ self.assertEqual(body["detected_amount"], 10000.0)
43
+ self.assertAlmostEqual(body["detected_liters"], 14.28, places=2)
44
+ self.assertEqual(body["fuel_price"], 700.0)
45
+ self.assertEqual(body["driver_id"], "chauffeur-42")
46
+ self.assertIn("photo_reference", body)
47
+ # Champs techniques (moteur OCR, variante...) volontairement absents
48
+ # de la réponse — seuls les champs du cahier des charges sont exposés.
49
+ self.assertNotIn("ocr_engine", body)
50
+ self.assertNotIn("best_variant", body)
51
+
52
+ def test_inconsistent_reading_blocks(self):
53
+ fake_fields = [
54
+ {"field": "prix", "text": "10000", "confidence": 0.95},
55
+ {"field": "volume", "text": "20.00", "confidence": 0.9},
56
+ ]
57
+ fake_quality = {"image_quality": "valid", "quality_score": 0.95,
58
+ "blur_variance": 500.0, "brightness": 120.0}
59
+ with patch.object(main, "_get_model", return_value=(None, None)), \
60
+ patch.object(main, "recognize_screen", return_value=fake_fields), \
61
+ patch.object(main, "estimate_image_quality", return_value=fake_quality):
62
+ resp = self.client.post(
63
+ "/analyze",
64
+ files={"image": ("pompe.jpg", _fake_jpeg_bytes(), "image/jpeg")},
65
+ data={"fuel_price": "875"},
66
+ )
67
+ body = resp.json()
68
+ self.assertFalse(body["success"])
69
+ self.assertIn("Incohérence", body["message"])
70
+
71
+ def test_unsupported_content_type_rejected(self):
72
+ resp = self.client.post(
73
+ "/analyze",
74
+ files={"image": ("pompe.txt", b"not an image", "text/plain")},
75
+ data={"fuel_price": "700"},
76
+ )
77
+ self.assertEqual(resp.status_code, 400)
78
+
79
+
80
+ if __name__ == "__main__":
81
+ unittest.main()
tests/test_integration.py ADDED
@@ -0,0 +1,65 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Tests d'intégration : vrai modèle CRNN, vraies images — pas de mock.
2
+
3
+ Plus lents que les tests unitaires (chargement du modèle ~30-90s sur CPU) :
4
+ à lancer séparément avec `pytest tests/test_integration.py -v`, pas dans la
5
+ boucle de développement rapide.
6
+ """
7
+ import os
8
+ import unittest
9
+
10
+ import numpy as np
11
+ from PIL import Image
12
+
13
+ from app.recognizer import load_model, get_device, recognize_screen
14
+ from app.postprocess import process as postprocess_results
15
+ from app.quality import estimate_image_quality
16
+ from app.rules import evaluate as evaluate_rules
17
+ from app.preprocessing import detect_screen_region
18
+
19
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
20
+
21
+ # Photo resserrée sur l'écran (pas besoin de détection d'écran) — voir
22
+ # l'annotation ajoutée cette session : prix=10000, volume=14.28, prix_litre=700
23
+ SAMPLE_IMAGE = os.path.join(
24
+ REPO_ROOT, "Images datasetdiversifié", "Images nettes", "20260622_121734_009.jpg"
25
+ )
26
+
27
+
28
+ def _imread(path):
29
+ img = Image.open(path).convert("RGB")
30
+ arr = np.array(img)
31
+ return arr[:, :, ::-1].copy() # RGB -> BGR pour rester cohérent avec cv2
32
+
33
+
34
+ @unittest.skipUnless(os.path.exists(SAMPLE_IMAGE), "image d'exemple absente de ce clone")
35
+ class TestRealModelIntegration(unittest.TestCase):
36
+ @classmethod
37
+ def setUpClass(cls):
38
+ cls.device = get_device()
39
+ cls.model = load_model(device=cls.device)
40
+
41
+ def test_recognizes_known_screen(self):
42
+ img = _imread(SAMPLE_IMAGE)
43
+ crop, (x, y, w, h) = detect_screen_region(img)
44
+ img_to_process = crop if w < img.shape[1] * 0.95 else img
45
+
46
+ recognized = recognize_screen(self.model, img_to_process, self.device)
47
+ self.assertGreaterEqual(len(recognized), 1, "le CRNN n'a rien renvoyé")
48
+
49
+ parsed = postprocess_results(recognized, fuel_price=700)
50
+ quality = estimate_image_quality(img_to_process, ocr_results=recognized)
51
+ gate = evaluate_rules(parsed, quality, fuel_price=700)
52
+
53
+ print("\nrecognized:", recognized)
54
+ print("parsed:", parsed)
55
+ print("gate:", gate)
56
+
57
+ # On ne verrouille pas une exactitude parfaite (précision du modèle
58
+ # documentée dans docs/LIMITATIONS.md) : on vérifie que le pipeline
59
+ # tourne bout-en-bout et renvoie une structure exploitable.
60
+ self.assertIn(gate["success"], (True, False))
61
+ self.assertIsInstance(gate["confidence_score"], float)
62
+
63
+
64
+ if __name__ == "__main__":
65
+ unittest.main()
tests/test_postprocess.py ADDED
@@ -0,0 +1,60 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from app import postprocess
3
+
4
+
5
+ class TestPostprocess(unittest.TestCase):
6
+ def test_all_three_fields_consistent(self):
7
+ recognized = [
8
+ {"field": "prix", "text": "10000", "confidence": 0.98},
9
+ {"field": "volume", "text": "14,28", "confidence": 0.95},
10
+ {"field": "prix_litre", "text": "700", "confidence": 0.90},
11
+ ]
12
+ res = postprocess.process(recognized, fuel_price=700)
13
+ self.assertEqual(res["detected_amount"], 10000.0)
14
+ self.assertAlmostEqual(res["detected_liters"], 14.28, places=2)
15
+ self.assertEqual(res["fuel_price"], 700.0)
16
+ self.assertTrue(res["is_consistent"])
17
+
18
+ def test_configured_fuel_price_overrides_screen_reading(self):
19
+ # Le prix configuré côté YELY doit toujours l'emporter sur celui lu
20
+ # à l'écran, même si le CRNN lit une valeur différente (mauvaise
21
+ # lecture ou écran mal calibré) — règle métier n°1.
22
+ recognized = [
23
+ {"field": "prix", "text": "10000", "confidence": 0.98},
24
+ {"field": "volume", "text": "14.28", "confidence": 0.95},
25
+ {"field": "prix_litre", "text": "650", "confidence": 0.40},
26
+ ]
27
+ res = postprocess.process(recognized, fuel_price=700)
28
+ self.assertEqual(res["fuel_price"], 700.0)
29
+
30
+ def test_liters_only_computes_amount(self):
31
+ recognized = [{"field": "volume", "text": "20.00", "confidence": 0.9}]
32
+ res = postprocess.process(recognized, fuel_price=875)
33
+ self.assertAlmostEqual(res["detected_liters"], 20.0, places=2)
34
+ self.assertIsNone(res["detected_amount"])
35
+ self.assertEqual(res["calculated_amount"], 17500.0)
36
+
37
+ def test_amount_only_computes_liters(self):
38
+ recognized = [{"field": "prix", "text": "10000", "confidence": 0.9}]
39
+ res = postprocess.process(recognized, fuel_price=875)
40
+ self.assertIsNone(res["detected_liters"])
41
+ self.assertEqual(res["detected_amount"], 10000.0)
42
+ self.assertAlmostEqual(res["calculated_liters"], 11.43, places=2)
43
+
44
+ def test_inconsistent_values_detected(self):
45
+ recognized = [
46
+ {"field": "prix", "text": "10000", "confidence": 0.9},
47
+ {"field": "volume", "text": "20.00", "confidence": 0.9},
48
+ ]
49
+ res = postprocess.process(recognized, fuel_price=875)
50
+ self.assertFalse(res["is_consistent"])
51
+
52
+ def test_no_recognized_fields(self):
53
+ res = postprocess.process([], fuel_price=875)
54
+ self.assertEqual(res["raw_numbers"], [])
55
+ self.assertIsNone(res["detected_liters"])
56
+ self.assertIsNone(res["detected_amount"])
57
+
58
+
59
+ if __name__ == "__main__":
60
+ unittest.main()
tests/test_rules.py ADDED
@@ -0,0 +1,52 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import unittest
2
+ from app import rules
3
+
4
+
5
+ VALID_QUALITY = {"image_quality": "valid", "quality_score": 0.95}
6
+
7
+
8
+ class TestRulesGate(unittest.TestCase):
9
+ def test_consistent_reading_succeeds(self):
10
+ parsed = {
11
+ "detected_liters": 14.28, "detected_amount": 10000.0, "fuel_price": 700.0,
12
+ "is_consistent": True, "ocr_confidence": 0.95, "raw_numbers": [("10000", 10000.0)],
13
+ }
14
+ gate = rules.evaluate(parsed, VALID_QUALITY, fuel_price=700.0)
15
+ self.assertTrue(gate["success"])
16
+
17
+ def test_blurry_image_blocks(self):
18
+ parsed = {"raw_numbers": [("1", 1.0)], "ocr_confidence": 0.9}
19
+ gate = rules.evaluate(parsed, {"image_quality": "blurry", "quality_score": 0.35})
20
+ self.assertFalse(gate["success"])
21
+ self.assertIn("floue", gate["message"])
22
+
23
+ def test_inconsistent_data_blocks(self):
24
+ parsed = {
25
+ "detected_liters": 20.0, "detected_amount": 10000.0, "fuel_price": 875.0,
26
+ "is_consistent": False, "ocr_confidence": 0.95, "raw_numbers": [("20", 20.0)],
27
+ }
28
+ gate = rules.evaluate(parsed, VALID_QUALITY, fuel_price=875.0)
29
+ self.assertFalse(gate["success"])
30
+ self.assertIn("Incohérence", gate["message"])
31
+
32
+ def test_missing_price_blocks(self):
33
+ parsed = {
34
+ "detected_liters": 20.0, "detected_amount": None, "fuel_price": None,
35
+ "is_consistent": None, "ocr_confidence": 0.9, "raw_numbers": [("20", 20.0)],
36
+ }
37
+ gate = rules.evaluate(parsed, VALID_QUALITY)
38
+ self.assertFalse(gate["success"])
39
+ self.assertIn("Prix du litre manquant", gate["message"])
40
+
41
+ def test_low_confidence_blocks(self):
42
+ parsed = {
43
+ "detected_liters": 14.28, "detected_amount": 10000.0, "fuel_price": 700.0,
44
+ "is_consistent": True, "ocr_confidence": 0.05, "raw_numbers": [("10000", 10000.0)],
45
+ }
46
+ gate = rules.evaluate(parsed, {"image_quality": "valid", "quality_score": 0.2}, fuel_price=700.0)
47
+ self.assertFalse(gate["success"])
48
+ self.assertIn("Confiance", gate["message"])
49
+
50
+
51
+ if __name__ == "__main__":
52
+ unittest.main()
tools/check_seen_image.py ADDED
@@ -0,0 +1,117 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """Vérifie si une image a déjà été utilisée pour l'entraînement/validation
2
+ du CRNN (annotator/annotations/annotations.json), afin de choisir des photos
3
+ réellement inédites pour les tests de démonstration.
4
+
5
+ Compare par hash perceptuel (pHash) pour détecter aussi les quasi-doublons
6
+ (même écran re-photographié, recadré, recompressé) — pas seulement les noms
7
+ de fichiers identiques.
8
+
9
+ Usage :
10
+ python tools/check_seen_image.py chemin/vers/photo.jpg [autre.jpg ...]
11
+ """
12
+ import argparse
13
+ import json
14
+ import os
15
+ import sys
16
+
17
+ import numpy as np
18
+ from PIL import Image
19
+
20
+ REPO_ROOT = os.path.dirname(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
21
+ ANNOTATIONS_FILE = os.path.join(REPO_ROOT, "annotator", "annotations", "annotations.json")
22
+
23
+ HASH_SIZE = 16
24
+ SIMILARITY_THRESHOLD = 10 # distance de Hamming ; en dessous = quasi-identique
25
+
26
+
27
+ def phash(image_path: str, hash_size: int = HASH_SIZE) -> np.ndarray:
28
+ img = Image.open(image_path).convert("L").resize(
29
+ (hash_size + 1, hash_size), Image.LANCZOS
30
+ )
31
+ pixels = np.asarray(img, dtype=np.float32)
32
+ diff = pixels[:, 1:] > pixels[:, :-1]
33
+ return diff.flatten()
34
+
35
+
36
+ def hamming_distance(a: np.ndarray, b: np.ndarray) -> int:
37
+ return int(np.count_nonzero(a != b))
38
+
39
+
40
+ def _index_repo_filenames() -> dict:
41
+ """Indexe une fois tous les fichiers image du dépôt par nom de fichier,
42
+ pour retrouver les sources annotées même quand `image_path` ne pointe
43
+ plus vers un chemin valide (dossiers déplacés/renommés)."""
44
+ exts = {".jpg", ".jpeg", ".png", ".bmp", ".webp", ".tiff"}
45
+ index: dict = {}
46
+ skip_dirs = {".git", ".env", "__pycache__", "node_modules", "dataset_lines",
47
+ "dataset_doctr", "crops", "outputs", "outputs_test", "photos"}
48
+ for root, dirs, files in os.walk(REPO_ROOT):
49
+ dirs[:] = [d for d in dirs if d not in skip_dirs]
50
+ for f in files:
51
+ if os.path.splitext(f)[1].lower() in exts:
52
+ index.setdefault(f, os.path.join(root, f))
53
+ return index
54
+
55
+
56
+ def build_reference_hashes() -> dict:
57
+ """Calcule les hash de toutes les images sources annotées (introuvables
58
+ localement sont ignorées silencieusement)."""
59
+ if not os.path.exists(ANNOTATIONS_FILE):
60
+ raise FileNotFoundError(f"Introuvable : {ANNOTATIONS_FILE}")
61
+
62
+ ann = json.load(open(ANNOTATIONS_FILE, encoding="utf-8"))
63
+ filename_index = _index_repo_filenames()
64
+ ann_dir = os.path.dirname(ANNOTATIONS_FILE)
65
+
66
+ refs = {}
67
+ for name, entry in ann.items():
68
+ rel_path = entry.get("image_path", "")
69
+ candidates = [
70
+ os.path.normpath(os.path.join(ann_dir, rel_path)) if rel_path else None,
71
+ os.path.normpath(os.path.join(REPO_ROOT, "images", name)),
72
+ filename_index.get(name),
73
+ ]
74
+ for c in candidates:
75
+ if c and os.path.exists(c):
76
+ try:
77
+ refs[name] = phash(c)
78
+ except Exception:
79
+ pass
80
+ break
81
+ return refs
82
+
83
+
84
+ def check_image(path: str, refs: dict) -> None:
85
+ try:
86
+ h = phash(path)
87
+ except Exception as e:
88
+ print(f"{path} : impossible de lire l'image ({e})")
89
+ return
90
+
91
+ best_name, best_dist = None, None
92
+ for name, ref_hash in refs.items():
93
+ d = hamming_distance(h, ref_hash)
94
+ if best_dist is None or d < best_dist:
95
+ best_dist, best_name = d, name
96
+
97
+ if best_dist is not None and best_dist <= SIMILARITY_THRESHOLD:
98
+ print(f"{os.path.basename(path)} : DEJA VU (proche de '{best_name}', "
99
+ f"distance={best_dist}/{len(h)}) -> ne pas utiliser pour tester la generalisation.")
100
+ else:
101
+ dist_info = f"(plus proche : '{best_name}', distance={best_dist})" if best_name else ""
102
+ print(f"{os.path.basename(path)} : INEDITE {dist_info} -> bon candidat pour le test.")
103
+
104
+
105
+ def main():
106
+ parser = argparse.ArgumentParser(description=__doc__)
107
+ parser.add_argument("images", nargs="+", help="Chemins des images à vérifier")
108
+ args = parser.parse_args()
109
+
110
+ refs = build_reference_hashes()
111
+ print(f"{len(refs)} images de référence indexées (annotations.json).\n")
112
+ for path in args.images:
113
+ check_image(path, refs)
114
+
115
+
116
+ if __name__ == "__main__":
117
+ main()
train/finetune_doctr.py ADDED
@@ -0,0 +1,250 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ finetune_doctr.py
3
+ ------------------
4
+ Fine-tuning de crnn_vgg16_bn (doctr) sur les valeurs LCD
5
+ de pompes à carburant.
6
+
7
+ Copie de référence pour la reproductibilité (documentation technique).
8
+ Ce script attend le dataset généré par `annotator/` à la racine du dépôt
9
+ d'origine (../annotator/dataset_doctr/) — il n'est pas autonome dans ce
10
+ dossier livrable, qui ne contient volontairement pas les ~150 images
11
+ sources annotées. Voir docs/LIMITATIONS.md pour le contexte du dataset.
12
+
13
+ Dataset attendu (généré par prepare_doctr_dataset.py) :
14
+ annotator/dataset_doctr/
15
+ train/images/ + labels.json
16
+ val/images/ + labels.json
17
+
18
+ Usage (depuis le dépôt d'origine, dossier train/) :
19
+ python finetune_doctr.py
20
+ python finetune_doctr.py --epochs 50 --lr 3e-5
21
+ python finetune_doctr.py --from-scratch # si pas de connexion internet
22
+ """
23
+
24
+ import os, json, argparse, time
25
+ import torch
26
+ from torch.utils.data import DataLoader
27
+ from doctr.datasets import RecognitionDataset, VOCABS
28
+ from doctr.models import crnn_vgg16_bn
29
+
30
+ # ─── Chemins (depuis train/) ──────────────────────────────────────────────────
31
+ DATASET_DIR = os.path.join("..", "annotator", "dataset_doctr")
32
+ OUTPUT_DIR = os.path.join("models", "v2") # ne pas écraser models/crnn_fuel_pump_best.pt (baseline)
33
+ VOCAB = VOCABS["french"] # contient 0-9, virgule, point
34
+
35
+
36
+ # ─── Dataloader ───────────────────────────────────────────────────────────────
37
+
38
+ IMG_H = 32
39
+
40
+ IMG_W = 256
41
+
42
+ def _resize_preserve_aspect(img, target_h: int, target_w: int):
43
+ """Redimensionne en conservant le ratio d'aspect (hauteur fixe, largeur au
44
+ prorata) puis complète par du padding noir jusqu'à target_w, au lieu
45
+ d'étirer l'image. C'est ce que fait déjà `preprocess_crop` à l'inférence
46
+ (src/fine_tuned_inference.py) : entraîner avec un étirement forcé alors
47
+ que l'inférence préserve le ratio créait un décalage systématique entre
48
+ les chiffres vus à l'entraînement (déformés) et en production (non
49
+ déformés), ce qui pénalisait la précision du modèle.
50
+ """
51
+ import torch.nn.functional as F
52
+
53
+ c, h, w = img.shape
54
+ new_w = max(1, min(target_w, round(target_h * w / max(h, 1))))
55
+ resized = F.interpolate(img.unsqueeze(0), size=(target_h, new_w),
56
+ mode="bilinear", align_corners=False).squeeze(0)
57
+ if new_w < target_w:
58
+ pad = torch.zeros(c, target_h, target_w - new_w, dtype=resized.dtype)
59
+ resized = torch.cat([resized, pad], dim=2)
60
+ elif new_w > target_w:
61
+ resized = resized[:, :, :target_w]
62
+ return resized
63
+
64
+
65
+ def collate_fn(batch):
66
+ """Redimensionne toutes les images à taille fixe pour le CRNN, en
67
+ préservant le ratio d'aspect (voir _resize_preserve_aspect)."""
68
+
69
+ imgs, targets = zip(*batch)
70
+
71
+ resized = [_resize_preserve_aspect(img, IMG_H, IMG_W) for img in imgs]
72
+
73
+ return torch.stack(resized, 0), list(targets)
74
+
75
+ def build_loaders(batch_size: int):
76
+ paths = {
77
+ "train_img": os.path.join(DATASET_DIR, "train", "images"),
78
+ "train_lbl": os.path.join(DATASET_DIR, "train", "labels.json"),
79
+ "val_img": os.path.join(DATASET_DIR, "val", "images"),
80
+ "val_lbl": os.path.join(DATASET_DIR, "val", "labels.json"),
81
+ }
82
+ for k, p in paths.items():
83
+ if not os.path.exists(p):
84
+ raise FileNotFoundError(
85
+ f"❌ Introuvable : {p}\n"
86
+ f" Lance d'abord : python prepare_doctr_dataset.py")
87
+
88
+ train_ds = RecognitionDataset(
89
+ img_folder=paths["train_img"], labels_path=paths["train_lbl"])
90
+ val_ds = RecognitionDataset(
91
+ img_folder=paths["val_img"], labels_path=paths["val_lbl"])
92
+
93
+ print(f"📦 Dataset : {len(train_ds)} train | {len(val_ds)} val")
94
+
95
+ trn = DataLoader(train_ds, batch_size=batch_size, shuffle=True,
96
+ num_workers=0, collate_fn=collate_fn, drop_last=False)
97
+ val = DataLoader(val_ds, batch_size=batch_size, shuffle=False,
98
+ num_workers=0, collate_fn=collate_fn)
99
+ return trn, val, len(train_ds), len(val_ds)
100
+
101
+
102
+ # ─── Modèle ───────────────────────────────────────────────────────────────────
103
+
104
+ def build_model(from_scratch: bool):
105
+ if from_scratch:
106
+ print("⚠️ Mode from-scratch (poids aléatoires).")
107
+ return crnn_vgg16_bn(
108
+ pretrained=False, pretrained_backbone=False, vocab=VOCAB)
109
+ try:
110
+ print("⬇️ Chargement des poids pré-entraînés Mindee...")
111
+ m = crnn_vgg16_bn(pretrained=True, vocab=VOCAB)
112
+ print("✅ Poids pré-entraînés chargés.")
113
+ return m
114
+ except Exception as e:
115
+ print(f"❌ Téléchargement échoué : {e}")
116
+ print(" Relance avec --from-scratch si pas de connexion internet.")
117
+ raise SystemExit(1)
118
+
119
+
120
+ # ─── Évaluation ───────────────────────────────────────────────────────────────
121
+
122
+ @torch.no_grad()
123
+ def evaluate(model, loader, device):
124
+ model.eval()
125
+ total_loss = n_ok = n_tot = 0
126
+ samples = []
127
+
128
+ for imgs, targets in loader:
129
+ imgs = imgs.to(device)
130
+ out = model(imgs, target=targets, return_preds=True)
131
+ total_loss += out["loss"].item()
132
+ for (pred, _), gt in zip(out["preds"], targets):
133
+ n_tot += 1
134
+ ok = pred.strip() == gt.strip()
135
+ if ok: n_ok += 1
136
+ if len(samples) < 8:
137
+ samples.append((gt, pred, "✅" if ok else "❌"))
138
+
139
+ return total_loss / max(len(loader), 1), n_ok / max(n_tot, 1), samples
140
+
141
+
142
+ # ─── Entraînement ─────────────────────────────────────────────────────────────
143
+
144
+ def train(epochs, lr, batch_size, from_scratch, patience):
145
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
146
+ print(f"🖥️ Device : {device}")
147
+
148
+ trn_loader, val_loader, n_trn, n_val = build_loaders(batch_size)
149
+ model = build_model(from_scratch)
150
+ model.to(device)
151
+
152
+ # LR faible car fine-tuning — on préserve les poids pré-entraînés
153
+ optimizer = torch.optim.Adam(model.parameters(), lr=lr)
154
+ scheduler = torch.optim.lr_scheduler.ReduceLROnPlateau(
155
+ optimizer, mode="min", factor=0.5, patience=3)
156
+
157
+ os.makedirs(OUTPUT_DIR, exist_ok=True)
158
+ best_loss = float("inf")
159
+ best_acc = 0.0
160
+ no_improv = 0
161
+ history = []
162
+
163
+ print(f"\n🚀 Fine-tuning : {epochs} epochs | lr={lr} | batch={batch_size}")
164
+ print("─" * 65)
165
+
166
+ for epoch in range(1, epochs + 1):
167
+ model.train()
168
+ t0 = time.time()
169
+ tr_loss = 0.0
170
+ nb = 0
171
+
172
+ for imgs, targets in trn_loader:
173
+ imgs = imgs.to(device)
174
+ optimizer.zero_grad()
175
+ out = model(imgs, target=targets, return_preds=False)
176
+ loss = out["loss"]
177
+ loss.backward()
178
+ torch.nn.utils.clip_grad_norm_(model.parameters(), 5.0)
179
+ optimizer.step()
180
+ tr_loss += loss.item()
181
+ nb += 1
182
+
183
+ avg_tr = tr_loss / max(nb, 1)
184
+ vl, acc, samples = evaluate(model, val_loader, device)
185
+ scheduler.step(vl)
186
+ cur_lr = optimizer.param_groups[0]["lr"]
187
+
188
+ print(f"Ep {epoch:3d}/{epochs} | "
189
+ f"train={avg_tr:.4f} | val={vl:.4f} | "
190
+ f"acc={acc:.1%} | lr={cur_lr:.1e} | {time.time()-t0:.0f}s")
191
+
192
+ # Afficher des exemples de prédictions toutes les 5 epochs
193
+ if epoch % 5 == 0 or epoch == 1:
194
+ print(" Exemples val :")
195
+ for gt, pred, status in samples:
196
+ print(f" {status} GT='{gt}' → PRED='{pred}'")
197
+
198
+ history.append({"epoch": epoch, "train_loss": avg_tr,
199
+ "val_loss": vl, "val_acc": acc, "lr": cur_lr})
200
+
201
+ # Sauvegarder le meilleur modèle. Écrire dans un fichier temporaire
202
+ # puis renommer (os.replace, atomique) évite un crash Windows déjà
203
+ # observé (RuntimeError code 1224, fichier verrouillé pendant
204
+ # l'écriture directe — probablement un scan antivirus).
205
+ if vl < best_loss:
206
+ best_loss = vl
207
+ best_acc = acc
208
+ no_improv = 0
209
+ best_path = os.path.join(OUTPUT_DIR, "crnn_fuel_pump_best.pt")
210
+ tmp_path = best_path + ".tmp"
211
+ torch.save(model.state_dict(), tmp_path)
212
+ os.replace(tmp_path, best_path)
213
+ print(f" 💾 Meilleur modèle → {best_path}")
214
+ else:
215
+ no_improv += 1
216
+
217
+ if no_improv >= patience:
218
+ print(f"\n⏹ Early stopping ({patience} epochs sans amélioration)")
219
+ break
220
+
221
+ # Sauvegarde finale + historique
222
+ final = os.path.join(OUTPUT_DIR, "crnn_fuel_pump_final.pt")
223
+ torch.save(model.state_dict(), final)
224
+ with open(os.path.join(OUTPUT_DIR, "history.json"), "w") as f:
225
+ json.dump({"history": history, "best_val_loss": best_loss,
226
+ "best_val_acc": best_acc}, f, indent=2)
227
+
228
+ print("\n" + "=" * 65)
229
+ print(f" ✅ Entraînement terminé")
230
+ print(f" Meilleure val_acc : {best_acc:.1%}")
231
+ print(f" Meilleur modèle : {best_path}")
232
+ print(f" Modèle final : {final}")
233
+ print("=" * 65)
234
+ print("\n👉 Prochaine étape : python test_model.py")
235
+
236
+
237
+ # ─── Entrée ───────────────────────────────────────────────────────────────────
238
+
239
+ if __name__ == "__main__":
240
+ parser = argparse.ArgumentParser()
241
+ parser.add_argument("--epochs", type=int, default=80)
242
+ parser.add_argument("--lr", type=float, default=5e-5)
243
+ parser.add_argument("--batch-size", type=int, default=8)
244
+ parser.add_argument("--patience", type=int, default=8)
245
+ parser.add_argument("--from-scratch", action="store_true")
246
+ args = parser.parse_args()
247
+
248
+ os.chdir(os.path.dirname(os.path.abspath(__file__)))
249
+ train(args.epochs, args.lr, args.batch_size,
250
+ args.from_scratch, args.patience)
train/prepare_doctr_dataset.py ADDED
@@ -0,0 +1,89 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ prepare_doctr_dataset.py
3
+ -------------------------
4
+ Convertit dataset_lines/ vers le format doctr :
5
+ dataset_doctr/
6
+ train/images/ + labels.json
7
+ val/images/ + labels.json
8
+
9
+ Copie de référence pour la reproductibilité (documentation technique) —
10
+ attend l'arborescence annotator/ du dépôt d'origine, non incluse ici.
11
+
12
+ Doit être lancé depuis le dossier train/ du dépôt d'origine :
13
+ cd train/
14
+ python prepare_doctr_dataset.py
15
+ """
16
+
17
+ import os
18
+ import json
19
+ import shutil
20
+ from pathlib import Path
21
+
22
+ # Chemins depuis train/ → annotator/
23
+ SOURCE_DIR = os.path.join("..", "annotator", "dataset_lines")
24
+ TARGET_DIR = os.path.join("..", "annotator", "dataset_doctr")
25
+
26
+
27
+ def convert_split(split: str) -> int:
28
+ src_images = os.path.join(SOURCE_DIR, split, "images")
29
+ src_labels = os.path.join(SOURCE_DIR, split, "labels")
30
+ dst_images = os.path.join(TARGET_DIR, split, "images")
31
+ os.makedirs(dst_images, exist_ok=True)
32
+
33
+ if not os.path.isdir(src_images):
34
+ print(f" ⚠️ Introuvable : {src_images}")
35
+ return 0
36
+
37
+ labels_dict = {}
38
+ n_ok = n_skip = 0
39
+
40
+ for img_file in sorted(os.listdir(src_images)):
41
+ if not img_file.lower().endswith((".jpg", ".jpeg", ".png")):
42
+ continue
43
+ stem = Path(img_file).stem
44
+ lbl_f = os.path.join(src_labels, f"{stem}.txt")
45
+ if not os.path.exists(lbl_f):
46
+ n_skip += 1
47
+ continue
48
+ text = open(lbl_f, encoding="utf-8").read().strip()
49
+ if not text:
50
+ n_skip += 1
51
+ continue
52
+ shutil.copy2(os.path.join(src_images, img_file),
53
+ os.path.join(dst_images, img_file))
54
+ labels_dict[img_file] = text
55
+ n_ok += 1
56
+
57
+ out = os.path.join(TARGET_DIR, split, "labels.json")
58
+ with open(out, "w", encoding="utf-8") as f:
59
+ json.dump(labels_dict, f, indent=2, ensure_ascii=False)
60
+
61
+ print(f" {split:6s} : {n_ok} images, {n_skip} ignorées → {out}")
62
+ return n_ok
63
+
64
+
65
+ def show_vocab():
66
+ chars = set()
67
+ for split in ("train", "val"):
68
+ p = os.path.join(TARGET_DIR, split, "labels.json")
69
+ if os.path.exists(p):
70
+ for txt in json.load(open(p)).values():
71
+ chars.update(txt)
72
+ print(f"\n Vocabulaire ({len(chars)} chars) : {''.join(sorted(chars))}")
73
+ print(f" → Le vocab doctr 'french' couvre tous ces caractères ✅")
74
+
75
+
76
+ if __name__ == "__main__":
77
+ if not os.path.isdir(SOURCE_DIR):
78
+ print(f"❌ Introuvable : {SOURCE_DIR}")
79
+ print(f" Lance d'abord depuis annotator/ : python split_lines.py")
80
+ raise SystemExit(1)
81
+
82
+ print(f"🔄 Conversion {SOURCE_DIR} → {TARGET_DIR}\n")
83
+ n_train = convert_split("train")
84
+ n_val = convert_split("val")
85
+ show_vocab()
86
+ print(f"\n✅ {n_train} train + {n_val} val prêts pour le fine-tuning")
87
+ print(f"\n👉 Prochaine étape :")
88
+ print(f" pip install python-doctr[torch]")
89
+ print(f" python finetune_doctr.py")
web/config.js ADDED
@@ -0,0 +1,9 @@
 
 
 
 
 
 
 
 
 
 
1
+ // URL de l'API IA (Hugging Face Spaces une fois déployée).
2
+ //
3
+ // ATTENTION : l'URL "https://huggingface.co/spaces/<user>/<space>" est la
4
+ // page web du Space sur le Hub, PAS l'API elle-même — un fetch() vers cette
5
+ // URL ne touchera jamais le endpoint FastAPI. Les Spaces Docker sont servis
6
+ // sur un sous-domaine dédié : https://<user>-<space-en-minuscules-tirets>.hf.space
7
+ // Vérifier l'URL exacte en ouvrant le Space et en regardant l'adresse dans
8
+ // la barre du navigateur une fois l'app chargée (ou l'onglet réseau).
9
+ window.YELY_API_URL = "https://danielxdata-yely-ai-module.hf.space/analyze";
web/index.html ADDED
@@ -0,0 +1,253 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="fr">
3
+ <head>
4
+ <meta charset="UTF-8" />
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
6
+ <title>YELY | Démo module IA pompiste</title>
7
+ <style>
8
+ :root {
9
+ --bg: #0f172a;
10
+ --panel: #1e293b;
11
+ --panel-2: #273449;
12
+ --border: #334155;
13
+ --text: #e2e8f0;
14
+ --muted: #94a3b8;
15
+ --accent: #f59e0b;
16
+ --ok-bg: #052e1f;
17
+ --ok-border: #10b981;
18
+ --ok-text: #6ee7b7;
19
+ --ko-bg: #3b0d0d;
20
+ --ko-border: #ef4444;
21
+ --ko-text: #fca5a5;
22
+ }
23
+ * { box-sizing: border-box; }
24
+ body {
25
+ margin: 0;
26
+ min-height: 100vh;
27
+ font-family: -apple-system, "Segoe UI", Roboto, Arial, sans-serif;
28
+ background: var(--bg);
29
+ color: var(--text);
30
+ display: flex;
31
+ justify-content: center;
32
+ padding: 2rem 1rem;
33
+ }
34
+ .app { width: 100%; max-width: 480px; }
35
+ .brand { display: flex; align-items: center; gap: 0.6rem; margin-bottom: 0.25rem; }
36
+ .brand .dot { width: 10px; height: 10px; border-radius: 50%; background: var(--accent); }
37
+ .brand h1 { font-size: 1.3rem; margin: 0; }
38
+ .subtitle { color: var(--muted); font-size: 0.9rem; margin: 0 0 1.5rem; }
39
+
40
+ .card {
41
+ background: var(--panel);
42
+ border: 1px solid var(--border);
43
+ border-radius: 14px;
44
+ padding: 1.25rem;
45
+ margin-bottom: 1rem;
46
+ }
47
+
48
+ label { display: block; font-size: 0.85rem; color: var(--muted); margin: 0.9rem 0 0.3rem; }
49
+ label:first-child { margin-top: 0; }
50
+ input, select {
51
+ width: 100%;
52
+ padding: 0.65rem 0.75rem;
53
+ border-radius: 8px;
54
+ border: 1px solid var(--border);
55
+ background: var(--panel-2);
56
+ color: var(--text);
57
+ font-size: 1rem;
58
+ }
59
+ input[type="file"] { padding: 0.5rem; }
60
+
61
+ #preview {
62
+ display: none;
63
+ width: 100%;
64
+ max-height: 260px;
65
+ object-fit: contain;
66
+ border-radius: 10px;
67
+ margin-top: 0.75rem;
68
+ border: 1px solid var(--border);
69
+ }
70
+
71
+ button {
72
+ width: 100%;
73
+ margin-top: 1.25rem;
74
+ padding: 0.85rem;
75
+ border: none;
76
+ border-radius: 10px;
77
+ background: var(--accent);
78
+ color: #1a1200;
79
+ font-size: 1.05rem;
80
+ font-weight: 700;
81
+ cursor: pointer;
82
+ }
83
+ button:disabled { background: #4b5563; color: #9ca3af; cursor: not-allowed; }
84
+
85
+ .status { font-size: 0.85rem; color: var(--muted); text-align: center; margin-top: 0.75rem; min-height: 1.2em; }
86
+
87
+ .banner {
88
+ display: none;
89
+ border-radius: 12px;
90
+ padding: 1rem 1.1rem;
91
+ font-weight: 700;
92
+ font-size: 1.05rem;
93
+ margin-bottom: 1rem;
94
+ border: 1px solid;
95
+ }
96
+ .banner.ok { display: block; background: var(--ok-bg); border-color: var(--ok-border); color: var(--ok-text); }
97
+ .banner.ko { display: block; background: var(--ko-bg); border-color: var(--ko-border); color: var(--ko-text); }
98
+
99
+ .rows { display: flex; flex-direction: column; gap: 0.6rem; }
100
+ .row { display: flex; justify-content: space-between; align-items: baseline; padding: 0.5rem 0; border-bottom: 1px solid var(--border); }
101
+ .row:last-child { border-bottom: none; }
102
+ .row .k { color: var(--muted); font-size: 0.85rem; }
103
+ .row .v { font-weight: 700; font-size: 1.15rem; }
104
+ .row .v.small { font-size: 0.95rem; font-weight: 500; }
105
+
106
+ details { margin-top: 0.5rem; }
107
+ summary { cursor: pointer; color: var(--muted); font-size: 0.8rem; }
108
+ pre { background: #000; color: #a3e635; padding: 0.75rem; border-radius: 8px; overflow-x: auto; font-size: 0.75rem; }
109
+
110
+ .hint { color: var(--muted); font-size: 0.78rem; margin-top: 0.4rem; }
111
+ </style>
112
+ </head>
113
+ <body>
114
+ <div class="app">
115
+ <div class="brand"><span class="dot"></span><h1>YELY | Vérification pompiste</h1></div>
116
+ <p class="subtitle">Démo du module IA : photo du terminal -> lecture automatique -> vérification de cohérence.</p>
117
+
118
+ <div id="banner" class="banner"></div>
119
+ <div id="resultCard" class="card" style="display:none;">
120
+ <div class="rows" id="resultRows"></div>
121
+ <details>
122
+ <summary>Détails techniques</summary>
123
+ <pre id="rawJson"></pre>
124
+ </details>
125
+ </div>
126
+
127
+ <form id="form" class="card">
128
+ <label>Photo du terminal de pompe</label>
129
+ <input type="file" id="image" accept="image/*" capture="environment" required />
130
+ <img id="preview" alt="Aperçu" />
131
+
132
+ <label>Prix du litre (FCFA)</label>
133
+ <input type="number" id="fuelPrice" value="700" step="0.01" />
134
+
135
+ <label>ID chauffeur (optionnel | simulate le scan QR)</label>
136
+ <input type="text" id="driverId" placeholder="chauffeur-42" />
137
+
138
+ <label>ID pompiste / station (optionnel)</label>
139
+ <input type="text" id="pompisteId" placeholder="pompiste-7" />
140
+
141
+ <details style="margin-top: 0.9rem;">
142
+ <summary>Configuration (URL de l'API)</summary>
143
+ <label>URL du service</label>
144
+ <input type="text" id="apiUrl" />
145
+ </details>
146
+
147
+ <button type="submit" id="submitBtn">Analyser la photo</button>
148
+ <div class="status" id="status"></div>
149
+ </form>
150
+ </div>
151
+
152
+ <script src="config.js"></script>
153
+ <script>
154
+ const form = document.getElementById('form');
155
+ const submitBtn = document.getElementById('submitBtn');
156
+ const statusEl = document.getElementById('status');
157
+ const banner = document.getElementById('banner');
158
+ const resultCard = document.getElementById('resultCard');
159
+ const resultRows = document.getElementById('resultRows');
160
+ const rawJson = document.getElementById('rawJson');
161
+ const imageInput = document.getElementById('image');
162
+ const preview = document.getElementById('preview');
163
+ const apiUrlInput = document.getElementById('apiUrl');
164
+
165
+ apiUrlInput.value = localStorage.getItem('yely_api_url') || window.YELY_API_URL || '';
166
+
167
+ imageInput.addEventListener('change', () => {
168
+ if (imageInput.files.length > 0) {
169
+ preview.src = URL.createObjectURL(imageInput.files[0]);
170
+ preview.style.display = 'block';
171
+ } else {
172
+ preview.style.display = 'none';
173
+ }
174
+ });
175
+
176
+ function fmt(v) {
177
+ if (v === null || v === undefined || v === '') return '—';
178
+ if (typeof v === 'boolean') return v ? 'Oui' : 'Non';
179
+ return String(v);
180
+ }
181
+
182
+ function row(label, value, small) {
183
+ const div = document.createElement('div');
184
+ div.className = 'row';
185
+ div.innerHTML = `<span class="k">${label}</span><span class="v${small ? ' small' : ''}">${value}</span>`;
186
+ return div;
187
+ }
188
+
189
+ function renderSuccess(data) {
190
+ resultRows.innerHTML = '';
191
+ resultRows.appendChild(row('Litres détectés', fmt(data.detected_liters)));
192
+ resultRows.appendChild(row('Montant détecté', fmt(data.detected_amount)));
193
+ resultRows.appendChild(row('Prix du litre', fmt(data.fuel_price)));
194
+ resultRows.appendChild(row('Montant calculé', fmt(data.calculated_amount)));
195
+ resultRows.appendChild(row('Litres calculés', fmt(data.calculated_liters)));
196
+ resultRows.appendChild(row('Cohérent', fmt(data.is_consistent)));
197
+ resultRows.appendChild(row('Confiance', fmt(data.confidence_score), true));
198
+ resultRows.appendChild(row('Qualité image', fmt(data.image_quality), true));
199
+ resultCard.style.display = 'block';
200
+ rawJson.textContent = JSON.stringify(data, null, 2);
201
+ }
202
+
203
+ form.addEventListener('submit', async (event) => {
204
+ event.preventDefault();
205
+ if (imageInput.files.length === 0) return;
206
+
207
+ const apiUrl = apiUrlInput.value.trim();
208
+ localStorage.setItem('yely_api_url', apiUrl);
209
+ if (!apiUrl) {
210
+ statusEl.textContent = "Renseigne l'URL de l'API dans « Configuration ».";
211
+ return;
212
+ }
213
+
214
+ const formData = new FormData();
215
+ formData.append('image', imageInput.files[0]);
216
+ formData.append('fuel_price', document.getElementById('fuelPrice').value);
217
+ const driverId = document.getElementById('driverId').value.trim();
218
+ const pompisteId = document.getElementById('pompisteId').value.trim();
219
+ if (driverId) formData.append('driver_id', driverId);
220
+ if (pompisteId) formData.append('pompiste_id', pompisteId);
221
+
222
+ submitBtn.disabled = true;
223
+ submitBtn.textContent = 'Analyse en cours…';
224
+ banner.className = 'banner';
225
+ banner.textContent = '';
226
+ resultCard.style.display = 'none';
227
+ statusEl.textContent = 'Envoi de la photo…';
228
+
229
+ const t0 = performance.now();
230
+ try {
231
+ const res = await fetch(apiUrl, { method: 'POST', body: formData });
232
+ const elapsed = ((performance.now() - t0) / 1000).toFixed(1);
233
+ const data = await res.json();
234
+
235
+ statusEl.textContent = `Réponse en ${elapsed}s (HTTP ${res.status})`;
236
+
237
+ const success = res.ok && data.success === true;
238
+ banner.className = 'banner ' + (success ? 'ok' : 'ko');
239
+ banner.textContent = (success ? '✅ ' : '⛔ ') + (data.message || (success ? 'Validé.' : 'Bloqué.'));
240
+
241
+ renderSuccess(data);
242
+ } catch (err) {
243
+ statusEl.textContent = '';
244
+ banner.className = 'banner ko';
245
+ banner.textContent = "⛔ Impossible de contacter l'API (" + err.message + ").";
246
+ } finally {
247
+ submitBtn.disabled = false;
248
+ submitBtn.textContent = 'Analyser la photo';
249
+ }
250
+ });
251
+ </script>
252
+ </body>
253
+ </html>
web/vercel.json ADDED
@@ -0,0 +1,3 @@
 
 
 
 
1
+ {
2
+ "cleanUrls": true
3
+ }