Spaces:
Sleeping
Sleeping
Commit ·
b510add
0
Parent(s):
Module IA YELY - CRNN fine-tune, API FastAPI, interface demo
Browse files- .dockerignore +9 -0
- .gitattributes +35 -0
- .gitignore +6 -0
- Dockerfile +20 -0
- README.md +119 -0
- app/__init__.py +0 -0
- app/config.py +48 -0
- app/main.py +159 -0
- app/postprocess.py +107 -0
- app/preprocessing.py +109 -0
- app/quality.py +58 -0
- app/recognizer.py +107 -0
- app/rules.py +98 -0
- docs/API.md +61 -0
- docs/ARCHITECTURE.md +73 -0
- docs/LIMITATIONS.md +79 -0
- docs/POSTPROCESS_RULES.md +77 -0
- docs/PREPROCESSING.md +59 -0
- docs/RECOGNIZER.md +59 -0
- docs/TRAINING.md +157 -0
- docs/WORKFLOW.md +180 -0
- models/crnn_fuel_pump_best.pt +3 -0
- models/history.json +426 -0
- requirements.txt +15 -0
- tests/__init__.py +0 -0
- tests/test_api.py +81 -0
- tests/test_integration.py +65 -0
- tests/test_postprocess.py +60 -0
- tests/test_rules.py +52 -0
- tools/check_seen_image.py +117 -0
- train/finetune_doctr.py +250 -0
- train/prepare_doctr_dataset.py +89 -0
- web/config.js +9 -0
- web/index.html +253 -0
- web/vercel.json +3 -0
.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 |
+
}
|