Reconnaissance de caractères sur des reçus à l’aide du Faster R-CNN

En tant qu’êtres humains nous avons la capacité de transférer les connaissances acquises pour une tâche spécifique dans une autre tâche. Plus cette tâche est facile, plus il est facile d’utiliser ses connaissances. Par exemple : savoir faire du vélo peut être utilisé comme base pour apprendre à rouler une moto.

C’est ce principe qu’utilise l’apprentissage par transfert qui consiste à utiliser un modèle déjà développé sur une tâche comme base d’un modèle d’une nouvelle tâche.

Cette approche est très populaire en deep learning, dans la mesure où des modèles pré-entraînés sont utilisés comme point de départ pour les tâches de vision par ordinateur et de traitement du langage naturel étant donné les vastes ressources de calcul et de temps nécessaires pour développer des modèles sur ces problèmes.

C’est ce que j’ai appliqué dans le projet présenté aujourd’hui en utilisant un modèle Fast R-CNN entrainé sur le dataset  COCO (ensemble de données de détection d’objets et de segmentation)  comme point de départ pour ma tâche de détection de reçus (tickets de caisse) et de leur zone de total sur une image. 

L’objectif à long terme ici est de développer un système robuste d’extraction d’informations pertinentes de reçus. 

Contexte 

 J’ai réalisé ce projet dans le cadre d’un test de recrutement proposé par une entreprise spécialisée en computer vision. Ce test consistait  à extraire le total de reçus non annotés (200 images) disponibles ici.

Alors comment j’ai procédé?

La tâche s’annonçant difficile sans données annotées, j’ai effectué des recherches et par chance je suis tombé sur un dataset open source de reçus semblables déjà annotés (zone délimitant les reçus, menu, total, etc..).  Bingo! J’ai donc décidé d’entraîner le modèle sur ces données déjà annotées et d’appliquer les résultats sur mes 200 images de base non annotées.

 Je suis même allé plus loin en prédisant aussi la zone délimitant un reçu dans une image en plus de la zone de total.

Dans cet article, j’expliquerai en détails les points essentiels de l’implémentation de Faster R-CNN avec Pytorch sur un dataset personnalisé. Le code complet de ce projet est disponible sous la forme d’un  Notebook sur mon GitHub

Faster R-CNN                                           

Faster R-CNN est l’une des méthodes de détection d’objets les plus populaires. Elle fait partie de la série R-CNN , développée par Ross Girshick et al en 2014, améliorée avec Fast R-CNN pour enfin obtenir Faster R-CNN. Il faudrait un article entier pour expliquer Faster R-CNN au vu de sa complexité, mais en résumé comment fonctionne-t-il ?

  1. L’image en entrée est passée dans un réseau neuronal convolutif (CNN) pour obtenir une carte des caractéristiques des objets présents sur l’image. Cette partie de l’architecture de Faster R-CNN est appelée réseau «backbone». Pour notre projet on utilisera un réseau  ResNet50 avec FPN (Feature Pyramid Network) comme  backbone.
  1. Cette carte des caractéristiques est ensuite utilisée par un réseau de proposition de région (RPN) pour générer des propositions de régions (cadres de délimitation qui contiennent les objets pertinents de l’image) en utilisant des ancres (boîtes de référence de taille fixe placées uniformément dans l’image originale afin de détecter les objets). Ces régions sont par la suite filtrées par NMS (Non-Maximum Suppression) avec un tenshold de 0.7. NMS est une méthode qui permet de passer au crible les régions proposées et choisir uniquement celles qui sont intéressantes. [1]
  1. La carte caractéristique extraite par le CNN en 1 et les cadres de délimitation des objets pertinents sont utilisés pour générer une nouvelle carte caractéristique grâce à la mise en commun (pooling) des régions d’intérêt (RoI) déterminées dans l’étape 2.
  1. Les régions regroupées passent ensuite par des couches entièrement connectées pour la prédiction des coordonnées des zones des objets et les classes de sortie. Cette  partie de l’architecture de Faster R-CNN est appelée réseau d’en-tête.

Pour en savoir plus sur Faster R-CNN ainsi que les principes mathématiques derrière toutes les notions présentées précédemment, n’hésitez pas à consulter cette page qui l’explique dans les moindres détails.

Pipeline de détection d’objet Faster R-CNN
Pipeline de détection d’objet Faster R-CNN [2]

Évaluation d’un modèle Faster R-CNN

L’évaluation est effectuée en utilisant la précision moyenne (mAP) à un certain seuil IoU (Intersection over Union) spécifique (par exemple mAP@0,5).

mAP  est utilisée pour déterminer la précision d’un ensemble de détections d’objets à partir d’un modèle par rapport aux vraies annotations d’un ensemble de données.

 L’IoU est utilisée lors du calcul de mAP. Il s’agit d’un nombre compris entre 0 et 1 qui spécifie la quantité de chevauchement entre les boîtes des coordonnées prédites d’un objet et les vraies coordonnées dans les annotations. 

  • Ainsi un IoU de 1 signifie que les boîtes des coordonnées prédites et les vraies coordonnées sont identiques, donc se chevauchent complètement.
  • Par contre un IoU de 0 signifie qu’il n’existe aucun chevauchement entre les coordonnées des boîtes prédites et les vraies coordonnées.
Définition IoU[3]

Dataset

Nous disposons de 1000 échantillons de données de reçus indonésiens annotés en cinq superclasses (menu, void menu, sous total, void total et total) qui contiennent en tout 42 sous classes telles que la position du reçu, du nom des restaurants, des commandes et du montant total. Ils ont été annotés et mis à disposition de tous spécialement pour les tâches d’analyse post-OCR. A chaque image est associée un fichier json d’annotation contenant les coordonnées de position de chaque zone de texte dans l’image. Vous trouverez le lien du dataset ici et le  lien de sa documentation ici.

Illustration d’un reçu du dataset et son fichier d’annotation[3]


Structure du répertoire de travail

Les fichiers coco_eval.pycoco_utils.py, engine.py, util.py sont issus du répertoire  GitHub officiel de Pytorch pour la segmentation et détection d’image COCO.

Exploration du dataset avant et après filtrage des coordonnées de reçus

Dataset original
Dataset original
Dataset avec coordonnées de reçus
Dataset avec coordonnées de reçus
Distribution de résolution
Distribution de résolution

Le dataset d’origine contient 800 images de train, 100 de validation et test. Après sélection des images ayant des coordonnés délimitant les reçus, on se retrouve avec 269 données de train, 50 en validation et 30 en test.

De plus, la distribution de résolution des images ayant des coordonnés délimitant les reçus varie fortement. J’ai donc décidé de redimensionner toutes les images à la taille 817*1230 (moyenne des longueurs et largeurs des 4 premières résolutions apparaissant le plus dans le dataset) afin d’éviter les distorsions le plus possible et permettre la prise en charge de plusieurs images par lots par le GPU.

Implémentation

Nous allons maintenant voir comment implémenter un détecteur d’objets personnalisé en utilisant Faster R-CNN avec pytorch.

Import des bibliothèques requises et visualisation des coordonnées extraites des fichiers JSON :

%matplotlib inline
import os
import pandas as pd
import json
import os
from PIL import Image as Im
import numpy as np
import json
import matplotlib.pyplot as plt
from matplotlib.patches import Rectangle
from matplotlib.collections import PatchCollection
from matplotlib.patches import Patch
import torch
import torch.nn.functional as F
from torch.utils.data import Dataset
from torch.utils.data import DataLoader
import torchvision
from torchvision import transforms
from torchvision.models.detection.faster_rcnn import FastRCNNPredictor



from google.colab import drive
drive.mount('/content/drive/')
Visualisation des reçus
Visualisation des reçus

Ensuite, nous définissons notre ensemble de données d’entraînement. Pour ce faire, nous héritons de la classe Dataset de PyTorch et créons notre propre classe ReceiptDataset et une classe RoiRescale pour mettre les coordonnées à prédire à l’échelle après redimensionnement des images. Selon la documentation PyTorch, notre classe ReceiptDataset doit implémenter les méthodes  __len__ (retourne la taille des du dataset), __getitem__ (permet l’indexation des données) et les méthodes __init__ et __call__ pour la classe RoiRescale.

class ReceiptDataset(Dataset):
    """ On hérite ici de la classe dataset pour sa modification """

    def __init__(self, dataframe, resize_img=None, resize_roi=None):
    
        self.dataframe=dataframe
        #initialisation des images-ids,on utilise le nom des fichiers comme id_unique
        self.image_ids=dataframe.img_name.unique()
        #initialisation des fonctions de redimentionnement
        self.resize_img = resize_img
        self.resize_roi = resize_roi

    def __len__(self) -> int:
        return self.image_ids.shape[0]

    def __getitem__(self, index):
        
         #lecture des images (on recupère la ligne associée à chaque index(coordonnées reçu et total)
        image_id = self.image_ids[index]
        row = self.dataframe[self.dataframe['img_name'] == image_id]
        total_box=row.coord_total.values[0]
        receipt_box=row.roi.values[0]
        image = Im.open(row.img_path.values[0])

        if self.resize_roi:
           #Si une fonction de redimentionnement est fournie, on transforme l'image dans la nouvelle taille définie
            total_box = self.resize_roi(total_box, original_shape=(image.size[1], image.size[0]))
            receipt_box = self.resize_roi(receipt_box, original_shape=(image.size[1], image.size[0]))
            
        if self.resize_img:
            #Si une fonction de redimentionnement est fournie, on met à l'échelle l'image
            image = self.resize_img(image)
        #concaténation des coodonnées du reçu et du total    
        boxes=[receipt_box,total_box]

        # création de dictionnaire cible et formats appropriés de données pour tensorflow
        target = {}
        target['boxes']= torch.as_tensor(boxes,dtype=torch.float32)
        #on a ici 2 classes
        target['labels'] =torch.as_tensor([1,2],dtype=torch.int64)
        target['image_id'] = torch.tensor([index])
        target['area'] = torch.tensor([(receipt_box[3] - receipt_box[1]) * (receipt_box[2] - receipt_box[0]), (total_box[3] - total_box[1]) * (total_box[2] - total_box[0])])
        target['iscrowd'] = torch.zeros((2,), dtype=torch.int64)

        return image, target


class RoiRescale(object):
    """Class de redimensionnement des images """
    
    def __init__(self, new_shape):
        assert isinstance(new_shape, tuple)
        self.new_shape = new_shape

    def __call__(self, sample, original_shape):
        w_ratio = new_shape[0] / original_shape[0]
        h_ratio = new_shape[1] / original_shape[1]
      
        #     xmin,                ymin,              xmax,              ymax
        return [sample[0]*h_ratio, sample[2]*w_ratio, sample[1]*h_ratio, sample[3]*w_ratio]

Pour notre classe ReceiptDataset, nous avons en entrée  le dataframe contenant les coordonnées, image_id (nom des images), chemins de chaque image ainsi que les fonctions de redimensionnement de l’image. Toutes nos images de formation se trouvent dans le dossier «Train». Dans le constructeur, nous initialisons notre dataframe, nos fonctions de redimensionnement et obtenons tous les image_ids uniques pour un traitement ultérieur. Dans la méthode __getitem__, nous pouvons lire l’image en utilisant l’image_id que nous avons dans le dataframe, et nous pouvons également obtenir les coordonnées de toutes  les boîtes englobantes associées à cette image.

Nous initialisons ensuite un dictionnaire appelé target, qui sera transmis au modèle pour l’entraînement. Cette cible contiendra les métadonnées de l’annotation comme les coordonnées réelles de la boîte englobante des reçus et leur total, leurs étiquettes correspondantes, image_id, la zone des boîtes englobantes (area). Le paramètre de zone est utilisé lors de l’évaluation avec la métrique COCO, pour séparer les scores de métrique entre les petites, moyennes et grandes zones. Si nous définissons iscrowd sur True, ces instances seront ignorées lors de l’évaluation. 

Chargement des données (avec les data loaders)

La prochaine étape est la définition d’un chargeur de données pour l’apprentissage qui chargera les données d’entraînement, validation et test par lots dans le modèle pour l’entraînement. Pour cela, nous utilisons l’utilitaire DataLoader de PyTorch avec une fonction callback pour changer le format de nos images en batch au format liste de tuple  et nos targets au format liste de dictionnaire :

  • images = [tensor([C,H,W]), tensor([C,H,W]), tensor([C,H,W])]
  • targets = [{'boxes': tensor([2,4]), 'labels': tensor([1,2])}, 'boxes': tensor([2,4]), 'labels': tensor([1,2])},'boxes': tensor([2,4]), 'labels': tensor([1,2])}]
# fonction de callback pour changer le format de nos images en batch(tuple) au format liste et nos targets au format liste de dictionnaire
# images = [tensor([C,H,W]), tensor([C,H,W]), tensor([C,H,W])]
# list of dictionary targets = [{'boxes': tensor([2,4]), 'labels': tensor([1,2])}, 'boxes': tensor([2,4]), 'labels': tensor([1,2])},'boxes': tensor([2,4]), 'labels': tensor([1,2])}]
def collate_fn(batch):
    return tuple(zip(*batch))

#Chargement de nos datasets
dataset =  ReceiptDataset(df_train, resize_img=resize_img, resize_roi=resize_roi)
dataset_val=  ReceiptDataset(df_val,resize_img=resize_img, resize_roi=resize_roi)
dataset_test =  ReceiptDataset(df_test,resize_img=resize_img, resize_roi=resize_roi )

# definition des data_loader de train et de validation
train_data_loader = torch.utils.data.DataLoader(dataset, batch_size=8, shuffle=True, num_workers=4, collate_fn=collate_fn)
data_loader_test = torch.utils.data.DataLoader(dataset_test, batch_size=8, shuffle=False, num_workers=4, collate_fn=collate_fn)
data_loader_val = torch.utils.data.DataLoader(dataset_val, batch_size=8, shuffle=False, num_workers=4, collate_fn=collate_fn)

Ensuite étant donné que nous avons le GPU (Tesla K80 de Google Colab avec 11 Go de mémoire autorisant 8 images maximum en batch) nous utilisons cuda comme device :

# Entrainement sur le gpu si le cpu n'est ps disponible
device = torch.device('cuda') if torch.cuda.is_available() else torch.device('cpu')

Initialisation du modèle

# Chargement de l'architechture model ResNet-50 FPN préformé sur le dataset  COCO
model = torchvision.models.detection.fasterrcnn_resnet50_fpn(pretrained=True)
num_classes = 3  # background , zone délimitant les reçus et leur total
#Obtention du nombre d'entrées pour le classifieur Resnet
in_features = model.roi_heads.box_predictor.cls_score.in_features
# modification de l'en-tête du modèle préformé avec de nouveaux pramètres(in_features,num_classes)
model.roi_heads.box_predictor = FastRCNNPredictor(in_features, num_classes)
# Utilisation du GPU pour l'entrainement du modèle
model.to(device)

J’utilise ici Faster RCNN de torchvision avec un backbone resnet50. Je définis pretrained = True, donc la fonction retournera un modèle pré-entraîné sur le dataset COCO. Je définis aussi num_classes = 3, en considérant l’arrière-plan, la zone délimitant le reçu et son total comme classes. En somme je modifie ici juste l’en-tête du modèle en remplaçant le nombre de classes COCO par défaut par mes 3 nouvelles classes pour l’entraînement.

Epochs, optimiseur et planificateur de taux d’apprentissage

# Optimiseur SGD
params = [p for p in model.parameters() if p.requires_grad]
optimizer = torch.optim.SGD(params, lr=0.005,
                            momentum=0.9, weight_decay=0.0005)

# definition de l'optimiseur SGD et du taux d'apprentissage
lr_scheduler = torch.optim.lr_scheduler.StepLR(optimizer, step_size=3, gamma=0.1)

# train for 30 epochs
num_epochs = 30

L’optimiseur que j’utilise ici est SGD (Stochastic Gradient Descent). Le planificateur de taux d’apprentissage aide à ajuster le taux d’apprentissage au cours de la formation pour obtenir plus de précision et accélérer la convergence.  StepLR  est un ordonnanceur qui décompose le taux d’apprentissage de chaque groupe de paramètres par gamma à toutes les époques step_size. Les hyper paramètres gamma et step_size décideront de la décroissance lr. Enfin, le modèle sera entraîné sur 30 epochs.

Entrainement

#liste pour contennir l'erreur à chaque époque
train_loss_list=[]
for epoch in range(num_epochs):
    dict_train_loss={}

    # Entrainement d'une epoch , avec affichage chaque 100 itérations(fonction mmodifiée pour récupérer la perte d'entrainement)
    train_metrics, loss =train_one_epoch(model, optimizer, train_data_loader, device, epoch, print_freq=34)
    #On recupére la valeur d'epoch et de perte pour le tracé à la fin de l'entrainement
    dict_train_loss['epoch']=epoch
    dict_train_loss['loss']=loss.item()
    train_loss_list.append(dict_train_loss)

    # Mise à jour du taux d'apprentissage
    lr_scheduler.step()

    # evalution sur le dataset de validation
    evaluate(model, data_loader_val ,device=device)

#torch.save(model.state_dict(), 'fasterrcnn_resnet50_fpn.pth')
torch.save(model,'model')

L’entrainement à chaque époque se fait ici avec la fonction train_one_epoch et la validation se fait avec la méthode evaluate provenant du répertoire github de pytorch.

Par défaut la fonction train_one_epoch renvoie un objet de type metric et ne retourne donc pas de perte de validation. Pour l’obtenir, un ajustement du code est nécessaire (voir mon répertoire github ). Quant à la fonction evaluate elle retourne un objet de type Cocoevaluator résumant la précision moyenne et recall moyen des données de validation .

 Perte (loss) d’entrainement et précision moyenne sur les données de validation

Loss par epoch
Loss par epoch
Précision moyenne pour differents IoU

On obtient une précision moyenne de 82.4%  pour un IoU de 0.5 et 69.1% pour un IoU de 0.75 ce  qui est un très bon résultat pour un entraînement sur si peu de données.

Visualisation des prédictions sur nos images de base non annotées (de tailles variables) et un reçu personnel de Brest  (voir github pour le code complet).

Validation du modèle
Validation du modèle
Reçu

Même si les prédictions de certaines zones de reçu et de total du modèle ne cadrent pas parfaitement avec les vraies zones, celles-ci restent néanmoins très proches. De plus, le modèle arrive à prédire la zone de reçu et de total indépendamment de la langue (reçu personnel) et de la résolution de ceux-ci qui varie fortement.

On voit bien que le modèle à bien appris de la forme des reçus et de la zone de total. 

 Conclusion

Nous avons ici mis en place un modèle de deep learning capable de prédire la zone délimitant un reçu et sa zone de total. Le modèle fonctionne relativement bien sur les images de bonnes qualités avec des background différents de la couleur des reçus.

Ces prédictions peuvent permettre non seulement d’extraire les reçus d’une image grâce aux prédictions des coordonnées les délimitant. Mais aussi de les traiter plus facilement en extrayant des informations pertinentes.

Des améliorations sont possibles en récoltant plus de données et en appliquant une augmentation de données par des transformations d’images telles que des rotations et zoom afin de pouvoir mieux traiter les images de mauvaise qualité.

Quelle est la suite ?

Utilisation d’OCR pour extraire les informations de total en zoomant sur le reçu dans un premier temps grâce aux coordonnées de celui-ci afin que l’OCR puisse mieux détecter les caractères.

Vous voulez publier sur ledatascientist.com ? C’est par ici

Laisser un commentaire

Votre adresse email ne sera pas publiée.

Ce site utilise Akismet pour réduire les indésirables. En savoir plus sur comment les données de vos commentaires sont utilisées.

Voulez-vous en savoir plus sur la Data Science ?

Inscrivez-vous alors à notre newsletter et vous receverez gratuitement nos derniers articles et actualités ! 
S'INSCRIRE MAINTENANT 
close-link