Laboratoire

Compter ce que personne ne peut regarder : des semaines de vidéo aux passages à niveau

Projet démarré en 2019, plusieurs sites et plusieurs campagnes. Le détail de la chaîne YOLO plus SORT construite pour Kantar et SNCF Réseau, les contraintes de l'époque, et ce qui a vieilli depuis.

Publié le
Compter ce que personne ne peut regarder : des semaines de vidéo aux passages à niveau

Note de lecture

Ce travail a démarré en 2019 : conception du dispositif de captation et premiers développements algorithmiques la même année, premières captations en juin 2019. La chaîne a ensuite été reprise et industrialisée jusqu'à mi-2020, et c'est cette version de production que décrit l'article. Le programme s'est déroulé en trois phases, sur plusieurs passages à niveau. Nous le publions tel qu'il a été fait, sans le réécrire à la lumière de ce qui existe aujourd'hui.

Le rappeler n'est pas une précaution d'usage. En 2019, les briques que l'on assemble aujourd'hui en quelques lignes n'existaient pas, ou pas sous une forme utilisable en production. YOLOv3 avait un an, les suiveurs à signature d'apparence n'étaient pas encore des outils courants, et les transformers de vision n'existaient pas. Une partie des décisions décrites ici sont des contournements de limites qui ont depuis disparu, une autre partie tient toujours. La dernière section fait le tri.


Pourquoi observer un passage à niveau

Il existe environ 15 000 passages à niveau en France, traversés chaque jour par 16 millions de véhicules. La quasi-totalité des accidents qui s'y produisent tient à un comportement d'usager, pas à une défaillance technique.

Supprimer un passage à niveau coûte le prix d'un pont ou d'un tunnel, et il y en a 15 000. SNCF Réseau a donc exploré une autre voie : agir sur le comportement par la signalisation elle-même. Encore faut-il savoir quelles indications fonctionnent, et pour cela mesurer ce que font réellement les usagers, avant et après.

Le comportement qui intéresse est justement celui qui est rare : le piéton qui contourne la barrière, le véhicule qui s'immobilise sur la zone de dégagement, le deux-roues qui s'engage après le début de la sonnerie. Ces événements ne se commandent pas, ils s'attendent. L'observation humaine ne tient pas cette durée : un observateur fatigue, note approximativement, et modifie par sa présence ce qu'il vient mesurer.

Le dispositif de captation a été développé par Pryntec et déployé sur plusieurs passages à niveau d'Île-de-France, dont celui de Deuil-Montmagny. Les caméras ont enregistré vingt-quatre heures sur vingt-quatre, en plusieurs campagnes successives étalées sur bien plus d'un mois de captation cumulée.

Chaque campagne était découpée en tranches de six heures traitées indépendamment. Les extraits de code et les volumes cités dans cet article proviennent de l'une d'elles : 32 jours consécutifs, du 14 juin au 15 juillet 2019, soit 128 tranches. C'est l'ordre de grandeur d'une campagne, pas celui du programme.

Le caisson bicaméra fixé au mât d'éclairage public, au-dessus du passage à niveau
Le dispositif de captation développé par Pryntec : un caisson bicaméra fixé au mât d'éclairage. La position en hauteur donne la vue plongeante qui rend le comptage possible, et impose de prendre le point de contact au sol comme référence.

La chaîne de traitement

Quatre étages, chacun consommant la sortie du précédent.

Détection : YOLOv3 sur Darknet

Le détecteur est un YOLOv3 chargé via le module DNN d'OpenCV, à partir des poids et de la configuration Darknet :

net = cv2.dnn.readNetFromDarknet(configPath, weightsPath)
net.setPreferableBackend(cv2.dnn.DNN_BACKEND_CUDA)
net.setPreferableTarget(cv2.dnn.DNN_TARGET_CUDA)

Le passage en backend CUDA n'est pas cosmétique : à ces volumes de vidéo, l'écart entre inférence CPU et GPU est la différence entre un traitement qui se termine et un traitement qu'on abandonne.

Chaque image est transformée en blob à 832 par 832, avec normalisation à 1/255 et permutation des canaux BGR vers RGB :

blob = cv2.dnn.blobFromImage(frame, 1/255.0, (832, 832), swapRB=True, crop=False)

Le choix de 832 plutôt que la valeur usuelle de 416 est délibéré. YOLO redimensionne toute l'image à la taille du réseau : un piéton lointain qui occupe 30 pixels de haut dans l'image d'origine en occupe 12 après réduction à 416, ce qui le fait passer sous le seuil de détection. Doubler la résolution d'entrée quadruple le coût de calcul par image, et c'est le prix à payer pour voir les usagers du fond de scène. Sur un carrefour proche, 416 aurait suffi.

La sortie est filtrée sur les identifiants de classe COCO correspondant aux usagers de la voirie, puis passée en suppression des non-maxima :

idxs = cv2.dnn.NMSBoxes(boxes, confidences, args["confidence"], args["threshold"])

Avec un seuil de confiance à 0,2 et un seuil de NMS à 0,1. Le seuil de confiance bas est assumé : il vaut mieux laisser passer des détections douteuses et les faire trancher par le suivi, qui dispose de la cohérence temporelle, que de les écarter définitivement image par image. Un piéton partiellement masqué descend souvent sous 0,5.

Suivi : SORT et filtre de Kalman

La détection ne connaît pas la persistance. Nous avons utilisé SORT, qui modélise chaque objet par un filtre de Kalman à vecteur d'état de dimension 7 pour 4 observations :

self.kf = KalmanFilter(dim_x=7, dim_z=4)

L'état est le quadruplet position, aire et rapport d'aspect, augmenté de trois dérivées :

def convert_bbox_to_z(bbox):
    """ [x1,y1,x2,y2] -> [x, y, s, r] : centre, aire, rapport d'aspect """
    w = bbox[2] - bbox[0]
    h = bbox[3] - bbox[1]
    x = bbox[0] + w / 2.0
    y = bbox[1] + h / 2.0
    s = w * h
    r = w / float(h)

Paramétrer l'échelle par l'aire plutôt que par la largeur et la hauteur séparément est ce qui rend le modèle robuste au changement de taille : un véhicule qui approche grandit dans les deux dimensions à la fois, et l'aire capture ce mouvement avec un seul terme de vitesse. Le rapport d'aspect, lui, est traité comme constant, sans dérivée associée : un objet rigide ne change pas de proportions.

Le modèle de transition est à vitesse constante. Les covariances initiales sont réglées pour refléter ce qu'on ignore au premier instant :

self.kf.P[4:, 4:] *= 1000.0  # forte incertitude sur les vitesses initiales
self.kf.R[2:, 2:] *= 10.0    # aire et rapport d'aspect moins fiables que la position

L'association entre pistes prédites et nouvelles détections se fait par recouvrement, résolu de manière globale :

from scipy.optimize import linear_sum_assignment

L'assignation hongroise plutôt qu'un appariement glouton : sur une scène chargée, où plusieurs objets proches se disputent les mêmes détections, un glouton fige un mauvais choix précoce et propage l'erreur sur toutes les pistes suivantes.

Comptage : intersection de segments

Un franchissement n'est pas testé par appartenance à une zone, mais par croisement de deux segments : la trajectoire de l'objet entre deux images, et la ligne de comptage.

def intersect(A, B, C, D):
    return ccw(A,C,D) != ccw(B,C,D) and ccw(A,B,C) != ccw(A,B,D)

def ccw(A, B, C):
    return (C[1]-A[1]) * (B[0]-A[0]) > (B[1]-A[1]) * (C[0]-A[0])

Le test repose sur l'orientation de triplets de points : deux segments se croisent si chacun sépare les extrémités de l'autre. Quatre produits en croix, aucune division, aucune racine, aucun epsilon à régler. C'est exact et c'est constant en temps.

Surtout, ce test raisonne sur le chemin parcouru et non sur les positions occupées. Entre deux images à 25 images par seconde, un véhicule à 50 km/h avance de 55 centimètres, ce qui peut représenter plusieurs dizaines de pixels selon la focale. Un test d'appartenance à une bande étroite le manquerait complètement. Le comportement à risque étant souvent le comportement rapide, c'est précisément le cas qu'il ne faut pas rater.

Le point de référence de l'objet

Pour construire le segment de trajectoire, il faut réduire la boîte à un point. Le code retient le milieu du bord inférieur :

Point0 = (int(x + (w-x)/2), int(box[3]))       # image courante
Point1 = (int(x2 + (w2-x2)/2), int(previous_box[3]))  # image précédente

box[3] est l'ordonnée basse de la boîte, autrement dit le point de contact avec le sol.

Prendre le centre de la boîte serait une erreur en vue plongeante. Le centre d'une voiture flotte à un mètre au-dessus de la chaussée, et sa projection dans l'image se déplace quand la boîte grandit à l'approche de la caméra, sans que le véhicule ait bougé. Une ligne tracée au sol serait franchie trop tôt, parfois deux fois. Le point de contact est le seul dont le déplacement dans l'image corresponde à un déplacement réel sur la chaussée.

Anti-doublon

Chaque compteur mémorise l'identifiant du dernier objet qui l'a incrémenté :

if intersect(Point0, Point1, ligne[0], ligne[1]):
    if indexA1 != indexIDs[i]:
        counterA1 += 1
        indexA1 = indexIDs[i]
        statusA1 = 1

Sans cette garde, un usager qui hésite sur le marquage ou longe une ligne l'incrémente à chaque image. Les scènes congestionnées, précisément les plus intéressantes, deviendraient les moins fiables.

La géométrie du site a demandé 608 lignes de comptage regroupées en 20 compteurs, chacun correspondant à une zone porteuse de sens : entrée de la zone de dégagement, passage piéton, axe de voie, contour de barrière.

Ces lignes sont des coordonnées pixel, liées à une position de caméra donnée :

lineA1_01 = [(843,328),(884,346)]
lineA1_02 = [(852,324),(894,342)]

C'est le vrai coût de passage à l'échelle du dispositif. Un modèle de détection se transporte d'un site à l'autre sans rien changer ; la géométrie de comptage, non. Changer de passage à niveau, ou seulement déplacer la caméra sur son mât, invalide les 608 lignes et impose de tout retracer sur le nouveau cadrage. Sur un programme couvrant plusieurs sites, ce travail de calibrage se répète intégralement à chaque fois.

Avec le recul, c'est le point qui aurait le plus gagné à être outillé : définir les zones dans un référentiel lié au terrain plutôt qu'à l'image, et projeter, aurait rendu le calibrage transposable. Nous ne l'avons pas fait, et chaque site a été retracé à la main.

Persistance

Chaque événement produit une ligne à 51 colonnes : identifiant d'objet, classe, confiance, timecode, position, et l'état plus la valeur de chacun des 20 compteurs à cet instant. Le résultat n'est pas un total mais une chronologie interrogeable, ce qui a permis en aval les 20 millions de comptages et le million de scénarios de l'analyse comportementale.


Les paramètres du suivi, déplacés à dessein

Les valeurs par défaut de SORT sont calibrées sur les jeux de données de la littérature. Aucune n'a été conservée, et le code documente les valeurs d'origine en commentaire :

# value default iou_threshold 0.3
def associate_detections_to_trackers(detections, trackers, iou_threshold=0.2):

# value defaut 3 for min_hits and 10 for max_age
def __init__(self, max_age=10, min_hits=2):

Seuil de recouvrement, de 0,3 à 0,2. Deux détections successives du même objet se recouvrent moins que prévu quand il se déplace vite ou change de taille en approchant de la caméra. Un seuil trop strict casse la piste, SORT crée un nouvel identifiant, et l'anti-doublon ne joue plus : le même véhicule est compté deux fois.

Confirmations avant publication, de 3 à 2. SORT ne publie une piste qu'après plusieurs détections consécutives, pour filtrer le bruit. Attendre 3 images fait manquer les objets rapides, qui traversent une zone de comptage en peu d'images.

Durée de survie d'une piste : 10 images. Un passage à niveau est encombré de mobilier : poteaux, barrières, feux, autres véhicules. Une occultation de quelques images est un cas courant, pas un cas limite. Laisser la piste vivre permet de retrouver l'objet de l'autre côté avec le même identifiant.

Ces trois réglages arbitrent tous entre perdre un objet et le compter deux fois. Il n'existe pas de valeur juste dans l'absolu : elle dépend de la hauteur de caméra, de la focale, de la vitesse des usagers et de l'encombrement. C'est un réglage de site, pas un réglage de modèle.


Réentraîner pour voir l'infrastructure

Le modèle COCO générique reconnaît les usagers, et c'est insuffisant. Un franchissement n'a de sens qu'au regard de l'état de l'installation : passer barrière levée est banal, passer pendant la descente ne l'est pas.

L'image en tête de cet article montre le cas : barrières baissées, un piéton immobile entre la barrière et la voie, et au fond des motos, un vélo et des voitures arrêtés. Sans la barrière parmi les classes détectées, cette scène se réduit à un comptage d'usagers ; avec elle, elle devient qualifiable.

Nous avons donc réentraîné YOLOv3 sur neuf classes propres au métier :

pieton, voiture, camion, bus, moto, velo, train, barriere, signal

Les deux dernières n'existent dans aucun jeu de données généraliste. Il a fallu constituer et annoter les exemples.

La configuration passe de 80 à 9 classes aux trois couches de détection, avec le nombre de filtres de la convolution précédente ajusté en conséquence, et bascule en mode entraînement :

batch=64
subdivisions=16
classes=9

contre batch=1, subdivisions=1 pour l'inférence.

L'entraînement a été mené en conservant les poids tous les quelques dizaines d'itérations plutôt que de garder seulement le dernier état. C'est une précaution que l'on néglige souvent : un réseau plus entraîné n'est pas nécessairement meilleur sur les classes rares, et barriere comme signal sont sous-représentées par construction, puisque l'installation est immobile alors que les usagers défilent. Comparer plusieurs états permet de choisir celui qui tient sur les objets qui comptent, et non celui qui minimise la perte globale.

Plusieurs générations de détecteurs ont coexisté, du YOLOv3 générique au modèle spécialisé puis à YOLOv4. Elles ont été conservées côte à côte plutôt que remplacées, pour pouvoir rejouer une même séquence avec chacune.

Ce qui résiste

La lumière. Une captation continue de plusieurs semaines traverse toutes les conditions : contre-jour rasant, ombres dures de midi, crépuscule, éclairage public nocturne. Les statistiques d'image de la même scène changent plusieurs fois par jour, et un modèle entraîné sur des vues diurnes se dégrade à la tombée du jour.

La météo. Pluie sur l'optique, reflets sur chaussée mouillée, gouttes produisant des détections fantômes que le seuil de confiance bas laisse passer et que le suivi doit éliminer.

Le suivi des piétons. Nettement plus difficile que celui des véhicules, et pour une raison structurelle : le filtre de Kalman suppose un mouvement à vitesse localement constante. Un piéton s'arrête, repart, change de direction sans inertie, revient sur ses pas. C'est exactement le modèle de mouvement que le filtre ne sait pas prédire. Chaque écart fait diverger la prédiction, réduit le recouvrement avec la détection suivante, et menace la continuité de la piste. C'est là que l'allongement de la durée de survie a compté le plus.


L'échelle, résolue sans élégance

Trente-deux jours de vidéo à 25 images par seconde représentent environ 69 millions d'images, et ce n'était qu'une campagne parmi plusieurs. Avec une inférence YOLOv3 à 832 par 832 sur chacune, le traitement séquentiel se compte en semaines.

La parallélisation a été obtenue par duplication du fichier de traitement. Quatre copies, treatment1.py à treatment4.py, identiques à 164 lignes près. Ces 164 lignes ne diffèrent que par un mot :

INSERT INTO kantar_timecode_v1 (...)   -- treatment1.py
INSERT INTO kantar_timecode_v2 (...)   -- treatment2.py

Quatre processus, quatre tables, aucune contention à l'écriture.

Ce n'est pas une architecture dont on est fier. Un paramètre de ligne de commande et une file de tâches auraient fait le même travail sans dupliquer 128 000 lignes de code. Mais le calcul a tourné, les résultats sont arrivés, et le coût de la solution propre aurait été payé sur le seul budget qui manquait. Nous le documentons tel quel : dans un projet contraint, la dette qu'on assume en la connaissant vaut mieux que l'élégance qu'on n'a pas eu le temps d'écrire.

Un point valait en revanche l'effort. L'orchestration attribue à chaque tranche un identifiant de vidéo et un identifiant de départ pour les compteurs :

python3 treatment3.py --input videos/phase1/29062019_00-06.mp4 \
                      --output output/29062019_00-06_output.m4v \
                      -p configs -v 61 -id 6100000

Les plages d'identifiants ne se recouvrent pas d'une tranche à l'autre. Un traitement interrompu se relance sur sa seule tranche, sans rejouer les autres ni produire de collision d'identifiants à la fusion. Sur des séries de plusieurs jours, cette propriété n'est pas un confort, c'est la condition pour finir.


Ce que nous en retenons

YOLO est le composant le plus visible de la chaîne et le moins déterminant. C'est une brique documentée, remplaçable, et nous l'avons effectivement remplacée trois fois. Ce qui a fait la qualité du résultat tient dans des décisions plus discrètes :

  • entrée réseau à 832 plutôt que 416, pour voir le fond de scène, au prix d'un facteur quatre sur le calcul ;
  • seuil de confiance délibérément bas, en laissant le suivi trancher avec la cohérence temporelle ;
  • point de contact au sol comme référence, jamais le centre de la boîte ;
  • test d'intersection de segments plutôt qu'appartenance à une zone ;
  • mémorisation de l'identifiant déjà compté sur chaque compteur ;
  • les trois paramètres de SORT recalibrés pour le site.

Aucune n'est spectaculaire. Prises ensemble, elles font la différence entre une démonstration qui impressionne trente secondes et une mesure sur laquelle on accepte de fonder une décision d'aménagement.


Ce qui a vieilli, ce qui tient

Cinq ans plus tard, une partie de ces choix ne se justifie plus. Une autre partie n'a pas bougé, et c'est la plus instructive.

Ce que l'on ferait autrement

Le détecteur. YOLOv3 venait de paraître quand le projet a démarré, et restait le meilleur compromis disponible entre précision et vitesse sur GPU grand public. Aujourd'hui les générations suivantes de la famille, ou un détecteur à base de transformers, donnent une meilleure précision sur les petits objets pour un coût comparable. Le passage à 832 en entrée, qui nous coûtait un facteur quatre en calcul, serait moins nécessaire : les architectures récentes gèrent mieux les échelles multiples.

Le suivi. SORT associe les détections sur la seule géométrie des boîtes. Les successeurs immédiats y ajoutent une signature d'apparence, ce qui permet de retrouver un objet après une occultation longue plutôt que de lui attribuer un nouvel identifiant. Sur une scène encombrée de poteaux et de barrières, c'est exactement le point qui nous a demandé le plus de réglages. Nous aurions moins bataillé sur la durée de survie des pistes.

Le suivi des piétons. Le filtre de Kalman à vitesse constante est structurellement inadapté à la marche. Les approches actuelles, qui apprennent le modèle de mouvement au lieu de le postuler, traitent nativement ce que nous compensions par un réglage.

La parallélisation. Dupliquer quatre fois un fichier de 128 000 lignes pour changer un nom de table ne se défend pas, et ne se défendait déjà pas à l'époque. C'est une dette de circonstance, pas une contrainte technique.

Le calibrage en coordonnées pixel. Définir les zones de comptage directement dans le plan de l'image lie le paramétrage à un cadrage précis. Passer par une homographie, en posant les zones sur un plan de la chaussée puis en les projetant, aurait rendu le calibrage transposable d'un site à l'autre et robuste à un déplacement de caméra. Les outils existaient déjà : c'est un choix que nous n'avons pas fait, pas une limite de l'époque.

Ce qui n'a pas bougé

Le point de contact au sol. C'est de la géométrie projective, pas de l'apprentissage. Le centre d'une boîte flottera toujours au-dessus de la chaussée en vue plongeante, quel que soit le détecteur qui produit la boîte.

Le test d'intersection de segments. Raisonner sur le chemin parcouru plutôt que sur les positions occupées reste la seule façon de ne pas manquer un objet rapide entre deux images. Aucun progrès sur les détecteurs ne change ce fait : il tient à la fréquence d'échantillonnage, pas à la qualité de la détection.

La mémorisation de l'identifiant déjà compté. Tant qu'un suivi attribue des identifiants, il faut se souvenir de qui a franchi quoi. C'est de la logique de comptage, indépendante de la technique de détection.

Le réglage pour le site. Les valeurs par défaut d'un algorithme de suivi sont calibrées sur des jeux de données de référence, dont la géométrie n'est pas celle de votre caméra. Ce constat vaut encore, et vaudra pour les méthodes qui viendront.

Apprendre au modèle à voir l'infrastructure. Aucun jeu de données généraliste ne contient les objets qui font le sens métier d'une scène. Il a fallu annoter barriere et signal à la main ; il faudrait le faire aujourd'hui, avec des outils plus rapides mais la même exigence.

Le vrai enseignement

Les cinq points qui ont tenu sont tous du même ordre : ce sont des décisions sur la façon de poser le problème, pas sur la technologie qui le résout. Parmi ceux qui ont vieilli, trois sont des choix de briques que le temps a rendus obsolètes, et deux sont des décisions que nous aurions pu mieux prendre dès le départ.

C'est le partage que nous retenons de ce travail, et la raison pour laquelle il ouvre ce laboratoire. Les briques changent tous les dix-huit mois. La question de savoir où se trouve l'objet, ce qu'on compte, et comment on évite de le compter deux fois, elle, ne change pas.


Références

Travail réalisé pour Kantar et SNCF Réseau, sur plusieurs passages à niveau d'Île-de-France dont celui de Deuil-Montmagny. Dispositif de captation développé par Pryntec, chaîne de vision par ordinateur par Converway. Projet démarré en 2019 : conception du dispositif de captation et développement des algorithmes la même année, premières captations en juin 2019. Programme mené en trois phases, industrialisation de la chaîne jusqu'à mi-2020. Kantar, SNCF Réseau et Converway ont reçu la mention Impact social, sociétal et environnemental au Grand Prix Syntec Conseil 2020.

Les volumes cités proviennent de ces publications. Les résultats détaillés de l'étude comportementale appartiennent aux commanditaires et ne sont pas reproduits ici.