Détection de visages sur vidéo: Raspberry Pi et Neural Compute Stick

Il y a environ un an, Intel Movidius a publié un appareil permettant une inférence efficace des réseaux de neurones convolutifs - Movidius Neural Compute Stick (NCS). Ce dispositif permet l'utilisation de réseaux de neurones pour la reconnaissance ou la détection d'objets dans des conditions de consommation énergétique limitée, y compris dans des tâches de robotique. NCS a une interface USB et ne consomme pas plus de 1 watt. Dans cet article, je parlerai de l'expérience de l'utilisation de NCS avec le Raspberry Pi pour détecter des visages en vidéo, y compris à la fois former le détecteur Mobilenet-SSD et le lancer sur Raspberry.

Tout le code se trouve dans mes deux référentiels: formation aux détecteurs et démos de détection de visages .



Dans mon premier article, j'avais déjà écrit sur la détection des visages à l'aide de NCS: on parlait alors d'un détecteur YOLOv2 , que j'ai converti du format Darknet au format Caffe , puis lancé sur NCS. Le processus de conversion s'est avéré non trivial: étant donné que ces deux formats définissent la dernière couche du détecteur de différentes manières, la sortie du réseau neuronal a dû être analysée séparément, sur le CPU, à l'aide d'un morceau de code de Darknet. De plus, ce détecteur ne m'a pas satisfait à la fois en vitesse (jusqu'à 5,1 FPS sur mon ordinateur portable) et en précision - plus tard, j'ai été convaincu qu'en raison de sa sensibilité à la qualité d'image, il était difficile d'obtenir un bon résultat sur Raspberry Pi.

Finalement, j'ai décidé de former mon détecteur. Le choix s'est porté sur le détecteur SSD avec encodeur Mobilenet : la convolution légère de Mobilenet vous permet d'atteindre une vitesse élevée sans aucune perte de qualité, et le détecteur SSD n'est pas inférieur à YOLO et fonctionne sur le NCS dès le départ.

Fonctionnement du détecteur Mobilenet-SSD
Commençons par Mobilenet. Dans cette architecture est complète 3 fois3la convolution (sur tous les canaux) est remplacée par deux convolutions légères: d'abord 3 fois3séparément pour chaque canal, puis terminer 1 fois1convolution. Après chaque convolution, BatchNorm et Non- linearity (ReLU) sont utilisés. La toute première convolution du réseau recevant une image en entrée est généralement laissée complète. Cette architecture peut réduire considérablement la complexité des calculs en raison d'une légère diminution de la qualité des prévisions. Il existe une option plus avancée , mais je ne l'ai pas encore essayée.

Le SSD (Single Shot Detector) fonctionne comme ceci: sur les sorties de plusieurs encodeurs à convolution sont accrochés en deux 1 fois1couche convolutionnelle: l'une prédit les probabilités des classes, l'autre - les coordonnées de la boîte englobante. Il y a un troisième calque qui donne les coordonnées et les positions des images par défaut au niveau actuel. La signification est: la sortie de n'importe quelle couche est naturellement divisée en cellules; plus près de la fin du réseau neuronal, ils deviennent plus petits (dans ce cas, en raison de la convolution avec stride=2 ), et le champ de vision de chaque cellule augmente. Pour chaque cellule de chacune des couches sélectionnées, nous définissons plusieurs images par défaut de différentes tailles et avec différents rapports d'aspect, et utilisons des couches convolutionnelles supplémentaires pour corriger les coordonnées et prédire les probabilités de classe pour chacune de ces images. Par conséquent, un détecteur SSD (comme YOLO) considère toujours le même nombre de trames. Le même objet peut être détecté sur différentes couches: pendant l'entraînement, le signal est envoyé à toutes les trames qui se croisent assez fortement avec l'objet, et pendant l'application, les détections sont combinées en utilisant la suppression non maximale (NMS). La couche finale combine les détections de toutes les couches, considère leurs coordonnées complètes, coupe le seuil de probabilité et produit le NMS.

Formation des détecteurs


L'architecture


Le code pour la formation du détecteur se trouve ici .

J'ai décidé d'utiliser le détecteur Mobilenet-SSD prêt à l' emploi formé sur le PASCAL VOC0712 et de l'entraîner à détecter les visages. Premièrement, cela aide à entraîner le filet beaucoup plus rapidement, et deuxièmement, vous n’avez pas à réinventer la roue.

Le projet d'origine comprenait le script gen.py , qui collectait littéralement le fichier de modèle .prototxt , en remplaçant les paramètres d'entrée. Je l'ai transféré à mon projet, étendant un peu la fonctionnalité. Ce script vous permet de générer quatre types de fichiers de configuration:

  • train : à l'entrée - une base de formation LMDB, à la sortie - une couche avec le calcul de la fonction de perte et de ses gradients, il y a BatchNorm
  • test : en entrée - la base de test LMDB, en sortie - la couche avec le calcul de qualité (précision moyenne moyenne), il y a BatchNorm
  • déployer : à l'entrée - l'image, à la sortie - la couche avec prédictions, BatchNorm est manquant
  • deploy_bn : à l'entrée - l'image, à la sortie - la couche avec prédictions, il y a BatchNorm

J'ai ajouté cette dernière option plus tard, afin que dans les scripts, vous puissiez charger et convertir la grille à partir de BatchNorm sans toucher à la base de données LMDB - sinon, en l'absence de la base de données, rien ne fonctionnerait. (En général, il me semble étrange que dans Caffe, la source de données soit définie dans l'architecture du réseau - ce n'est au moins pas très pratique).

À quoi ressemble l'architecture de réseau (courte)
  • Identifiant: 300 $ \ fois 300 \ fois 3 $
  • Conv conv conv conv : 32 canaux, stride=2
  • Mobilenet convolution conv1 - conv11 : 64, 128, 128, 256, 256, 512 ... 512 canaux, certains ont une stride=2
  • Couche de détection: 19 $ \ fois 19 \ fois 512 $
  • Mobilenet convolution conv12, conv13 : 1024 canaux, conv12 a stride=2
  • Couche de détection: 10 $ \ fois 10 \ fois 1024 $
  • Convolution complète conv14_1, conv14_2 : 256, 512 canaux, le premier kernel_size=1 , le deuxième stride=2
  • Couche de détection: 5 $ \ fois 5 \ fois 512 $
  • Convolution complète conv15_1, conv15_2 : 128, 256 canaux, le premier kernel_size=1 , le deuxième stride=2
  • Couche de détection: 3 fois3 fois256
  • Conv16_1 complètes , conv16_2 convolutions : 128, 256 canaux, le premier kernel_size=1 , le deuxième stride=2
  • Couche de détection: 2 fois2 fois256
  • Convolution complète conv17_1, conv17_2 : 64, 128 canaux, le premier kernel_size=1 , le deuxième stride=2
  • Couche de détection: 1 fois1 fois128
  • Sortie de détection de la couche finale


J'ai légèrement corrigé l'architecture du réseau. Liste des changements:

  • Évidemment, le nombre de classes est passé à 1 (sans compter l'arrière-plan).
  • Limitations du rapport d'aspect des patchs coupés pendant l'entraînement: changé de [0,5,2,0] $ sur [0,7,1.4](J'ai décidé de simplifier un peu la tâche et de ne pas apprendre des images trop étirées).
  • Des cadres par défaut, il ne restait que des carrés, deux pour chaque cellule. J'ai considérablement réduit leur taille, car les visages sont nettement plus petits que les objets dans le problème classique de détection d'objets.

Caffe calcule les tailles d'image par défaut comme suit: avoir une taille d'image minimale set maximum L, il crée un petit et grand cadre aux dimensions set  sqrtsL. Comme je voulais détecter le plus petit visage possible, j'ai calculé la stride complète pour chaque couche de détection et lui ai égalé la taille d'image minimale. Avec ces paramètres, les petits cadres par défaut seront situés les uns à côté des autres et ne se couperont pas. Nous avons donc au moins une garantie que l'intersection avec l'objet existera pour une sorte de cadre. J'ai défini la taille maximale deux fois plus. Pour les couches conv16_2, conv17_2, j'ai défini les dimensions de l'œil pour qu'elles soient identiques. De cette façon s,Lpour toutes les couches étaient: (16,32), (32,64), (64 128), (128 214), (214 300), (214 300) $

A quoi ressemblent certaines images par défaut (bruit pour plus de clarté)


Les données


J'ai utilisé deux jeux de données : WIDER Face et FDDB . WIDER contient de nombreuses images avec des visages très petits et flous, et FDDB est plus enclin à de grandes images de visages (et un ordre de grandeur plus petit que WIDER). Le format d'annotation y est légèrement différent, mais ce sont déjà des détails.

Je n'ai pas utilisé toutes les données pour la formation: j'ai jeté des visages trop petits (moins de six pixels ou moins de 2% de la largeur de l'image), jeté toutes les images avec un rapport d'aspect inférieur à 0,5 ou plus de 2, jeté toutes les images marquées comme "floues" dans le jeu de données WIDER, car ils correspondaient pour la plupart à de très petites personnes, et je devais au moins en quelque sorte aligner le rapport des petits et des grands visages. Après cela, j'ai fait tous les cadres carrés, en élargissant le plus petit côté: j'ai décidé que je n'étais pas très intéressé par les proportions du visage, et la tâche pour le réseau neuronal était un peu simplifiée. J'ai également jeté toutes les images en noir et blanc, dont il y avait peu, et sur lesquelles le script de construction de la base de données se bloque.

Pour les utiliser pour la formation et les tests, vous devez assembler une base LMDB à partir d'eux. Comment faire:

  • Pour chaque image, le balisage est créé au format .xml .
  • Un fichier train.txt est train.txt avec des lignes de la forme "path/to/image.png path/to/labels.xml" , le même est créé pour le test.
  • Un fichier test_name_size.txt est test_name_size.txt avec des lignes de la forme "test_image_name height width"
  • Crée un fichier labelmap.prototxt avec des correspondances d' labelmap.prototxt numériques


Le ssd-caffe/scripts/create_annoset.py (exemple du Makefile):

 python3 /opt/movidius/ssd-caffe/scripts/create_annoset.py --anno-type=detection \ --label-map-file=$(wider_dir)/labelmap.prototxt --min-dim=0 --max-dim=0 \ --resize-width=0 --resize-height=0 --check-label --encode-type=jpg --encoded \ --redo $(wider_dir) \ $(wider_dir)/trainval.txt $(wider_dir)/WIDER_train/lmdb/wider_train_lmdb ./data 

labelmap.prototxt
 item { name: "none_of_the_above" label: 0 display_name: "background" } item { name: "face" label: 1 display_name: "face" } 



Exemple de balisage .xml
 <?xml version="1.0" ?> <annotation> <size> <width>348</width> <height>450</height> <depth>3</depth> </size> <object> <name>face</name> <bndbox> <xmin>161</xmin> <ymin>43</ymin> <xmax>241</xmax> <ymax>123</ymax> </bndbox> </object> </annotation> 


L'utilisation de deux jeux de données en même temps signifie uniquement que vous devez soigneusement fusionner les fichiers correspondants par paires, sans oublier d'enregistrer correctement les chemins, ainsi que de mélanger le fichier pour la formation.

Après cela, vous pouvez commencer la formation.

La formation


Le code pour la formation des modèles se trouve dans mon carnet Colab .

J'ai fait la formation au Google Colaboratory, parce que mon ordinateur portable a à peine fait les tests de la grille, et a généralement raccroché sur la formation. Le colaboratoire m'a permis de former le filet assez rapidement et gratuitement. Le seul hic, c'est que j'ai dû écrire un script de compilation SSD-Caffe pour le Colaboratory (y compris des choses étranges comme la recompilation du boost et l'édition de la source), ce qui prend environ 40 minutes. Plus de détails peuvent être trouvés dans ma publication précédente .

Le Colaboratoire a une autre fonctionnalité: après 12 heures, la voiture meurt, effaçant définitivement toutes les données. La meilleure façon d'éviter la perte de données consiste à monter votre disque Google dans le système et à y enregistrer des poids réseau toutes les 500 à 1 000 itérations de formation.

Quant à mon détecteur, en une session au Colaboratoire, il a réussi à désapprendre 4500 itérations, et a été entièrement formé en deux sessions.

La qualité des prédictions (précision moyenne moyenne) sur l'ensemble de données de test que j'ai mis en évidence (fusion de WIDER et FDDB avec les restrictions énumérées ci-dessus) était d'environ 0,87 pour le meilleur modèle. Pour mesurer mAP sur les échelles enregistrées lors de la formation, il existe un script scripts/plot_map.py .

Le détecteur fonctionne sur un exemple (très étrange) d'un ensemble de données:


Lancement sur NCS


Une démo de détection de visage est ici .

Pour compiler un réseau neuronal pour le Neural Compute Stick, vous avez besoin du Movidius NCSDK : il contient des utilitaires pour compiler et profiler des réseaux neuronaux, ainsi que les API C ++ et Python. Il convient de noter que la deuxième version a été récemment publiée, ce qui n'est pas compatible avec la première: toutes les fonctions de l'API ont été renommées pour une raison quelconque, le format interne des réseaux de neurones a été modifié, FIFO a été ajouté pour interagir avec NCS et (enfin) la conversion automatique de float 32 bit en float 16 bit, qui manquait tellement en C ++. J'ai mis à jour tous mes projets vers la deuxième version, mais j'ai laissé quelques béquilles pour la compatibilité avec la première.

Après avoir entraîné le détecteur, il vaut la peine de fusionner les couches BatchNorm avec des convolutions adjacentes pour accélérer le réseau neuronal. Le script merge_bn.py cela à partir d'ici , que j'ai également emprunté au projet Mobilenet-SSD.

Ensuite, vous devez appeler l'utilitaire mvNCCompile , par exemple:

 mvNCCompile -s 12 -o graph_ssd -w ssd-face.caffemodel ssd-face.prototxt 

Il existe une cible graph_ssd pour cela dans le Makefile du projet. Le fichier graph_ssd résultant est une description du réseau de neurones dans un format compris par NCS.

Maintenant, comment interagir avec l'appareil lui-même. Le processus n'est pas très compliqué, mais nécessite une quantité de code assez importante. La séquence d'actions est approximativement la suivante:

  • Obtenez le descripteur de l'appareil par numéro de série
  • Ouvrir l'appareil
  • Lire le fichier de réseau neuronal compilé dans le tampon (sous forme de fichier binaire)
  • Créer un graphique de calcul vide pour NCS
  • Placez le graphique sur l'appareil en utilisant les données du fichier et sélectionnez FIFO pour celui-ci en entrée / sortie; le tampon avec le contenu du fichier peut maintenant être libéré
  • Démarrage du détecteur:
    • Obtenez l'image de la caméra (ou de toute autre source)
    • Traitez-le: redimensionnez-le à la taille souhaitée, convertissez-le en float32 et convertissez-le dans la plage [-1,1]
    • Téléchargez l'image sur l'appareil et demandez l'inférence
    • Demander un résultat (le programme sera bloqué jusqu'à réception du résultat)
    • Analyser le résultat, sélectionner les cadres des objets (sur le format - plus loin)
    • Afficher les prédictions

  • Libérez toutes les ressources: supprimez FIFO et le graphique de calcul, fermez l'appareil et retirez sa poignée

Presque chaque action avec NCS a sa propre fonction distincte, et en C ++, elle semble très lourde, et vous devez surveiller attentivement la libération de toutes les ressources. Afin de ne pas surcharger le code, j'ai créé une classe wrapper pour travailler avec NCS . Dans ce document, tout le travail d'initialisation est caché dans le constructeur et la fonction load_file , et lors de la libération des ressources - dans le destructeur, et travailler avec NCS est réduit à appeler des méthodes de classe 2-3. De plus, il existe une fonction pratique pour expliquer les erreurs qui se sont produites.

Créez un wrapper en transmettant la taille d'entrée et la taille de sortie (nombre d'éléments) au constructeur:

 NCSWrapper NCS(NETWORK_INPUT_SIZE*NETWORK_INPUT_SIZE*3, NETWORK_OUTPUT_SIZE); 

Nous chargeons le fichier compilé avec le réseau de neurones, initialisant simultanément tout ce dont nous avons besoin:

 if (!NCS.load_file("./models/face/graph_ssd")) { NCS.print_error_code(); return 0; } 

Nous convertissons l'image en float32 (l' image est cv::Mat au format CV_32FC3 ) et la téléchargeons sur l'appareil:

 if(!NCS.load_tensor_nowait((float*)image.data)) { NCS.print_error_code(); break; } 

Nous obtenons le résultat (le result est un pointeur float , le tampon de résultat est pris en charge par le wrapper); jusqu'à la fin des calculs, le programme est bloqué:

 if(!NCS.get_result(result)) { NCS.print_error_code(); break; } 

En fait, l'encapsuleur a également une méthode qui vous permet de charger des données et d'obtenir le résultat en même temps: load_tensor((float*)image.data, result) . J'ai refusé de l'utiliser pour une raison: en utilisant des méthodes distinctes, vous pouvez légèrement accélérer l'exécution du code. Après le chargement de l'image, le CPU restera inactif jusqu'à ce que le résultat de l'exécution avec NCS arrive (dans ce cas, il s'agit d'environ 100 ms), et à ce stade, vous pouvez faire un travail utile: lire une nouvelle trame et la convertir, ainsi que afficher les détections précédentes . C'est exactement ainsi que le programme de démonstration est implémenté, dans mon cas, il augmente légèrement les FPS. Vous pouvez aller plus loin et démarrer le traitement d'image et le détecteur de visage de manière asynchrone dans deux flux différents - cela fonctionne vraiment et vous permet d'accélérer un peu plus, mais il n'est pas implémenté dans le programme de démonstration.

En conséquence, le détecteur renvoie un tableau flottant de taille 7*(keep_top_k+1) . Ici, keep_top_k est le paramètre spécifié dans le fichier .prototxt du modèle et indiquant le nombre de détections (dans l'ordre de confiance décroissante) à renvoyer. Ce paramètre, ainsi que le paramètre responsable du filtrage des détections par la valeur de confiance minimale et les paramètres de suppression non maximale peuvent être configurés dans le fichier .prototxt du modèle dans la toute dernière couche. Il convient de noter que si Caffe retourne autant de détections qu'il en a été trouvé dans l'image, NCS renvoie toujours keep_top_k détections afin que la taille du tableau soit constante.

Le tableau de résultats lui-même est keep_top_k+1 comme suit: si nous le considérons comme une matrice avec keep_top_k+1 lignes et 7 colonnes, alors dans la première ligne, dans le premier élément il y aura le nombre de détections, et à partir de la deuxième ligne il y aura les détections elles-mêmes au format "garbage, class_index, class_probability, x_min, y_min, x_max, y_max" . Les coordonnées sont spécifiées dans la plage [0,1], elles devront donc être multipliées par la hauteur / largeur de l'image. Les éléments restants du tableau seront des ordures. Dans ce cas, la suppression non maximale est effectuée automatiquement, avant même que le résultat ne soit obtenu (il semble, à droite sur NCS).

Analyse du détecteur
 void get_detection_boxes(float* predictions, int w, int h, float thresh, std::vector<float>& probs, std::vector<cv::Rect>& boxes) { int num = predictions[0]; float score = 0; float cls = 0; for (int i=1; i<num+1; i++) { score = predictions[i*7+2]; cls = predictions[i*7+1]; if (score>thresh && cls<=1) { probs.push_back(score); boxes.push_back(Rect(predictions[i*7+3]*w, predictions[i*7+4]*h, (predictions[i*7+5]-predictions[i*7+3])*w, (predictions[i*7+6]-predictions[i*7+4])*h)); } } } 


Fonctionnalités de lancement de Raspberry Pi


Le programme de démonstration lui-même peut être exécuté sur un ordinateur ou un ordinateur portable ordinaire avec Ubuntu, ou sur un Raspberry Pi avec un Raspbian Stretch. J'utilise un Raspberry Pi 2 modèle B, mais la démo devrait également fonctionner sur d'autres modèles. Le makefile du projet contient deux objectifs pour les modes de commutation: make switch_desk pour l'ordinateur / ordinateur portable et make switch_rpi pour le Raspberry Pi. La différence fondamentale dans le code du programme est que dans le premier cas, OpenCV est utilisé pour lire les données de la caméra et dans le second cas, la bibliothèque RaspiCam . Pour exécuter la démo sur Raspberry, vous devez le compiler et l'installer.

Maintenant, un point très important: l'installation de NCSDK. Si vous suivez les instructions d'installation standard sur le Raspberry Pi, cela ne se terminera par rien de bon: le programme d'installation essaiera de faire glisser et de compiler SSD-Caffe et Tensorflow. Au lieu de cela, le NCSDK doit être compilé en mode API uniquement . Dans ce mode, seules les API C ++ et Python seront disponibles (c'est-à-dire qu'il ne sera pas possible de compiler et de profiler des graphiques de réseau neuronal). Cela signifie que le graphique du réseau neuronal doit d'abord être compilé sur un ordinateur ordinaire, puis copié sur Raspberry. Pour plus de commodité, j'ai ajouté deux fichiers compilés au référentiel, pour YOLO et pour SSD.

Un autre point intéressant est la connexion purement physique de NCS à Raspberry. Il semblerait qu'il ne soit pas difficile de le connecter à un connecteur USB, mais vous devez vous rappeler que son boîtier bloquera les trois autres connecteurs (il est assez sain, car il agit comme un radiateur). Le moyen le plus simple est de le connecter via un câble USB.

Il convient également de garder à l'esprit que la vitesse d'exécution variera pour différentes versions d'USB (pour ce réseau de neurones particulier: 102 ms pour USB 3.0, 92 ms pour USB 2.0).

Maintenant sur la puissance du NCS. Selon la documentation, il consomme jusqu'à 1 watt (à 5 volts sur le connecteur USB ce sera jusqu'à 200 ma; à titre de comparaison: la caméra Raspberry consomme jusqu'à 250 ma). Lorsqu'il est alimenté par un chargeur ordinaire de 5 volts, 2 ampères, tout fonctionne très bien. Cependant, essayer de connecter deux ou plusieurs NCS à Raspberry peut provoquer des problèmes. Dans ce cas, il est recommandé d'utiliser un répartiteur USB avec possibilité d'alimentation externe.

Sur Raspberry, la démo est plus lente que sur un ordinateur / portable: 7,2 FPS contre 10,4 FPS. Cela est dû à plusieurs facteurs: premièrement, il est impossible de se débarrasser des calculs sur le CPU, mais ils sont effectués beaucoup plus lentement; deuxièmement, la vitesse de transfert de données affecte (pour USB 2.0).

De plus, à titre de comparaison, j'ai essayé d'exécuter un détecteur de visage sur Raspberry YOLOv2 à partir de mon premier article, mais cela a très mal fonctionné: à une vitesse de 3,6 FPS, il manque beaucoup de visages même sur des images simples. Apparemment, il est très sensible aux paramètres de l'image d'entrée, dont la qualité dans le cas de la caméra Raspberry est loin d'être idéale. Le SSD fonctionne beaucoup plus stable, même si j'ai dû modifier légèrement les paramètres vidéo dans les paramètres de la RapiCam. il lui manque aussi parfois les visages dans le cadre, mais il le fait assez rarement. Pour augmenter la stabilité dans les applications réelles, vous pouvez ajouter un simple tracker centroïde .

Soit dit en passant: la même chose peut être reproduite en Python, il existe un tutoriel sur PyImageSearch (Mobilenet-SSD est utilisé pour la tâche de détection d'objet).

Autres idées


J'ai également testé quelques idées pour accélérer le réseau neuronal lui-même:

Première idée: vous ne pouvez laisser que la détection des calques conv11 et conv13 , et supprimer tous les calques supplémentaires. Vous obtiendrez un détecteur qui ne détecte que les petits visages et fonctionne un peu plus rapidement. Dans l'ensemble, cela n'en vaut pas la peine.

La deuxième idée était intéressante, mais n'a pas fonctionné: j'ai essayé de lancer des convolutions du réseau neuronal avec des poids proches de zéro, en espérant que cela deviendrait plus rapide. Cependant, il y avait peu de telles circonvolutions, et leur suppression n'a que légèrement ralenti le réseau neuronal (le seul pressentiment: cela est dû au fait que le nombre de canaux a cessé d'être une puissance de deux).

Conclusion


J'ai pensé à détecter des visages sur Raspberry pendant longtemps, comme une sous-tâche de mon projet robotique. Je n'aimais pas les détecteurs classiques en termes de vitesse et de qualité, et j'ai décidé d'essayer les méthodes de réseau neuronal, en testant en même temps le Neural Compute Stick, à la suite de quoi il y avait deux projets sur GitHub et trois articles sur Habré (dont l'actuel). En général, le résultat me convient - très probablement, j'utiliserai ce détecteur dans mon robot (il y aura peut-être un autre article à ce sujet). Il convient de noter que ma solution n'est peut-être pas optimale - néanmoins, il s'agit d'un projet de formation, fait en partie par curiosité pour le NCS. J'espère néanmoins que cet article sera utile à quelqu'un.

Source: https://habr.com/ru/post/fr424973/


All Articles