CaVid - Caviardage vidéo par ffmpeg

PUBLISHED ON DEC 11, 2020

Préambule

À l’Université de Genève, Covid-19 oblige, les cours et exercices ont désormais lieu à distance, principalement via Zoom.

Certaines séances d’exercices sont en outre enregistrées et mises à la disposition de la communauté universitaire pendant le reste de l’année. Problème : étant des enregistrements Zoom, on y voit le visage des étudiant-e-s qui interviennent, ce qui en gêne certain-e-s et contribue à une faible participation durant les séances (les étudiant-e-s préférant écouter en silence plutôt qu’intervenir, de peur d’apparaître ensuite dans une vidéo).

Le bien-fondé d’une telle peur peut faire débat, mais toujours est-il qu’il fallait trouver une solution. J’ai donc entrepris de trouver un moyen de “caviarder”, le plus simplement possible, les visages des étudiant-e-s dans les enregistrements Zoom.

Pour info, l’enregistrement présente toujours, en plein écran, l’image de la webcam de la personne en train de parler. Le concept de personne “en train de parler” est évidemment subtil, et varie d’un logiciel de vidéoconférence à l’autre. La personne chargée de la séance d’exercices est en dialogue avec les étudiant-e-s, si bien que l’image représente tour à tour l’enseignant-e, et un-e étudiant-e. S’il aurait été possible de tout simplement supprimer l’image et ne publier que le son, ç’aurait été dommage, car disposer du visage et de la gestuelle de l’enseignant-e aide sans aucun doute à comprendre le propos, tout en évitant que la vidéo ne soit trop rébarbative.

L’idée est donc de conserver à l’identique les portions qui présentent l’enseignant-e, et de remplacer, dans les autres portions, l’image par une image fixe, par exemple un carton expliquant que l’image a été supprimée volontairement.

Première solution envisagée : Kdenlive

Kdenlive est sans doute l’un des meilleurs outils de montage vidéo disponible sur Linux. Il dispose en outre d’une option de détection automatique des scènes (clic droit sur le clip -> Clip Jobs -> Automatic scene split), mais ce procédé n’est pas idéal pour plusieurs raisons. Tout d’abord, du moins au moment où j’ai tenté de le faire, Kdenlive ne procédait pas au découpage, mais se contentait de placer des marqueurs, à charge pour l’utilisateur de sauter ensuite d’un marqueur au suivant pour effectuer une coupe à chacun d’eux. Il semble que ce problème ait été résolu en octobre, mais je n’ai pas vérifié. Autre “problème”, il faut rajouter une piste vidéo “sous” la piste vidéo principale, et y placer l’image fixe que l’on souhaite utiliser ; ça n’est pas très compliqué, mais automatiser cela serait confortable. Enfin, et c’est peut-être le problème principal, le rendu final peut être très long (l’encodage en x264+aac en qualité par défaut durait plus d’une heure sur ma machine, pour une heure et demie de contenu).

Solution(s) par ffmpeg

Utiliser ffmpeg directement semble résoudre tous les problèmes cités plus haut. Cette solution n’est pas sans ses inconvénients, bien sûr : en particulier, il est très difficile de détecter un problème avant de disposer du résultat final.

Voici un aperçu de quelques commandes qui se sont avérées utiles.

Note 1 : le projet en lui-même regroupe ces commandes dans un script python, qui se charge également du parallélisme ; cf. en fin d’article.

Note 2 : le fichier original.mp4 est un fichier récupéré directement depuis le cloud Zoom, après avoir requis un enregistrement dans le cloud.

Extraction des timecodes de changement de scène

ffmpeg -i original.mp4 -filter:v "select='gt(scene,0.2)',showinfo" -f null - 2> vidinfo

Cette commande montre les infos (notamment temporelles), mais uniquement lors d’un changement de scène, c’est-à-dire lorsque la différence entre la scène actuelle et la précédente est supérieure à un seuil, en l’occurrence fixé à 0.2. Plus le seuil est bas, et plus ffmpeg considérera facilement que l’on a changé de scène (donc en l’espèce, que la webcam affichée a changé). Dans le doute, mieux vaut un seuil trop bas que trop haut. -f null indique que l’on ne souhaite aucun format de sortie : on ne cherche pas à ce stade à transcoder.

Les marqueurs temporels qui nous intéressent sont de la forme pts_time:7770.72, avec d’autres valeurs (en secondes) évidemment. Un coup de grep permet de ne conserver que les timestamps en secondes, auquel il faut encore ajouter les bornes du fichier : 0 (tout début), et la durée du fichier en secondes (toute fin). Ainsi, toute paire consécutive de valeurs représente une fenêtre de temps durant laquelle une certaine webcam est affichée.

La durée totale peut être obtenue avec la commande suivante :

ffprobe -show_entries format=duration -of default=noprint_wrappers=1:nokey=1 original.mp4

Ces options permettent de n’obtenir que la durée, sans rien d’autre (de nombreuses informations sont fournies sur la sortie d’erreur). L’option -of permet de définir le format, ici une absence des en-têtes et pieds de sections, ainsi que des clés de chaque champ. Bref, seule la valeur sélectionnée (ici, duration) est renvoyée.

Découpage en extraits

ffmpeg -i original.mp4 -ss start -to end -c copy output.mp4

Fondamentale, cette commande permet d’extraire une portion du fichier original, délimitée par deux timestamps (en secondes, même format que pts_time plus haut), un pour le début et un pour la fin. Boucler sur les paires de valeurs mentionnées plus haut permet, avec un nommage dynamique, d’obtenir un fichier par extrait, donc un fichier par “moment où une webcam donnée était visible sans interruption à l’écran”. Si l’enseignant-e pose 10 questions et que chaque question fait l’objet d’une réponse ininterrompue par un-e seul-e étudiant-e, cela représentera 20 extraits (dont 10 devront être caviardés).

Caviardage de certains extraits

ffmpeg -i extract.mp4 -i mask.png -filter_complex "[1][0]scale2ref[i][v];[v][i]overlay" -c:a copy -max_muxing_queue_size 1024 censored.mp4

mask.png est l’image à afficher à la place de la webcam, typiquement un carton noir porteur d’un texte blanc “Webcam supprimée de l’enregistrement”. Le filtre complexe scale2ref permet de redimensionner cette image automatiquement, et overlay permet de surimposer l’image sur la vidéo. Plus d’infos dans la documentation officielle : scale2ref, overlay.

Le codec audio est repris à l’identique. La taille maximale de la mémoire tampon doit en outre être augmentée, 1024 fonctionne en général.

Note : il arrive que l’extrait que l’on cherche à caviarder ne comprenne que du son (pas de flux vidéo). Je ne sais pas trop ce qui conduit à ça, mais ça m’est arrivé, sauf erreur lorsque la webcam distante était entièrement noire en raison d’un problème quelconque. L’on pourrait penser que le travail est du coup “déjà fait”, mais ça n’est pas très élégant d’avoir tantôt des extraits avec un carton textuel qui explique que la webcam a été supprimée volontairement, et tantôt un simple écran noir. En outre, les traitements suivants risquent de poser problème si on tente de les appliquer à des extraits qui peuvent ou non contenir un flux vidéo. Pour éviter les problèmes, je préfère détecter si un extrait ne comprend pas de vidéo, pour ensuite venir rajouter l’encart (l’image fixe) sous forme de flux vidéo “neuf”.

Pour détecter la présence d’un flux vidéo :

ffprobe -show_streams extract.mp4, suivi d’une recherche de codec_type=video dans la sortie.

En cas d’extrait sans flux vidéo, plutôt que la commande ci-dessus (avec -filter_complex), on utilisera la commande suivante :

ffmpeg -i extract.mp4 -i mask.png -c:a copy -c:v h264 -max_muxing_queue_size 1024 censored.mp4

Ainsi, ffmpeg comprend tout seul que l’on souhaite utiliser l’entrée mask.png comme flux vidéo, puisque l’on spécifie un codec vidéo (h264), et que l’entrée extract.mp4 ne comprend aucun flux vidéo. Le codec audio est là encore repris tel quel.

Normalisation des extraits, caviardés ou non

Concaténer immédiatement les extraits dont on dispose à ce stade (dont certains ont été caviardés alors que d’autres ont été laissés tels quels après leur extraction) ne fonctionne pas, et produit un fichier créature de Frankenstein dont les timecodes sont complètement aberrants, et dont le seeking est incohérent. Il convient donc, comme avant-dernière étape, de normaliser tous les extraits dont on dispose. Chaque extrait se normalise ainsi :

ffmpeg -i extract.mp4 -q 0 -max_muxing_queue_size 1024 normalized.mp4

On utilise les paramètres par défaut de ffmpeg, et une qualité haute (0).

Concaténation finale

Enfin, les extraits peuvent être concaténés, et cette ultime opération est quasi instantanée. Pour concaténer des fichiers nombreux, il est peu commode d’écrire leur nom sur la ligne de commande ; ffmpeg permet d’utiliser un fichier texte de référence, au format suivant :

file 'extract1.mp4'
file 'extract2.mp4'
file 'extract2.mp4'
...

Puis :

ffmpeg -f concat -i concat_file.txt -c copy final.mp4

Le résultat est presque parfait, avec toutes les vidéos issues de Zoom pour lesquelles j’aie testé. Il arrive que l’on entende un minuscule “pop” au passage d’un extrait à l’autre, mais c’est presque imperceptible.

Un python pour les gouverner tous

Mon script python se charge d’exécuter toutes les commandes ci-dessus, via subprocess. Il gère le renommage des fichiers, le nettoyage final, etc. Il n’est pas extrêmement souple, et place les fichiers de travail dans le répertoire source, ce qui est sans doute une hérésie mais fonctionne bien la plupart du temps. Il se charge en outre de la sortie (en couleurs !) pour informer de l’état du traitement, mais aussi de deux autres points sur lesquels je vais revenir : la sélection des extraits à caviarder ou non (parce que je trouve ma technique astucieuse), et la parallélisation des tâches lourdes (parce que c’est une bonne introduction à ce concept).

Sélection des extraits à conserver

Problème : comment choisir facilement les extraits qui représentent l’enseignant-e, et ceux qui représentent un-e étudiant-e ?

Solution : utiliser un gestionnaire de fichiers graphique et tirer parti de sa capacité à afficher une miniature pour chaque extrait, sélectionner tous les extraits représentant l’enseignant-e, copier dans le presse-papiers, puis laisser le script python lire le presse-papiers et en déduire quels extraits ont été sélectionnés.

J’aime bien cette solution, elle est facile d’utilisation et d’implémentation. Il n’est évidemment pas garanti qu’elle fonctionne pour tous les gestionnaires de fichiers, mais en même temps elle est assez large : elle recherche, dans le contenu brut du presse-papiers, toutes les occurrences correspondant à l’expression régulière suivante : une suite de chiffres puis “.mp4”. Cela présuppose évidemment que les extraits soient au format .mp4, et qu’ils portent un nom purement numérique ; c’est le cas avec les étapes précédentes du script.

log(f"Ouvrir un navigateur de fichiers (Nautilus ou Thunar conseillés), sélectionner tous les extraits à laisser intouchés, puis copier dans le presse-papiers (ctrl+c).", LOG_INFO)

while True:
    log("Est-ce que c'est fait ? [o = oui, q = quitter] ", LOG_INPUT, end="")
    choice = input().lower()
    if choice == "q":
        sys.exit()
    elif choice == "o":
        clipboard = pyperclip.paste()
        untouched = re.findall(r"[0-9]+\.mp4", clipboard)
        if len(untouched) == 0:
            log("Aucun fichier valide trouvé dans le presse-papiers !", LOG_WARNING)
            continue
        else:
            log(f"{len(untouched)} fichiers vidéo valides trouvés dans le presse-papiers.", LOG_SUCCESS)
            break
    else:
        continue

Note : log() est une simple fonction de log en couleur.

Parallélisation

Traiter de nombreux fichiers vidéo, c’est le cas d’école d’un problème qui peut être parallélisé. La lenteur ne vient pas d’un accès différé aux données (IO), mais tout simplement de la complexité des calculs à effectuer. Autant dans ce cas tirer parti des différents coeurs de la machine, d’où l’emploi de la parallélisation.

Trois tâches, dans mon script, sont parallélisées : la création des extraits délimités par les timecodes, le caviardage des extraits, et enfin la normalisation des extraits. D’autres tâches auraient pu être parallélisées (par exemple, on aurait pu séparer les extraits en un lot par coeur, puis concaténer les lots en parallèle, et enfin concaténer ces blocs ensemble), mais les autres opérations (notamment la concaténation) sont si rapides que cela ne valait pas vraiment la peine de complexifier le code.

La parallélisation se fait, les trois fois, au moyen d’un Pool, abstraction bien pratique de la bibliothèque multiprocessing qui répartit toute seule les tâches sur tous les coeurs disponibles (c’est du moins le comportement par défaut). Ces Pool se voient ensuite attribuer les tâches, via l’une des deux méthodes suivantes : map ou starmap. La dernière fonctionne comme la première, mais permet de fournir plusieurs arguments ; on l’utilise donc pour la création des extraits, puisque chaque job doit nécessairement recevoir des arguments en plus du fichier à traiter (le fichier original, à chaque fois) : les timestamps de début et de fin de l’extrait à créer par le job en question.

Dernier point intéressant : comment garder le compte de ce qui a été fait, par exemple du nombre d’extraits créés, afin de l’afficher au fur et à mesure ? On ne peut pas naïvement utiliser une variable entière que les différents travailleurs incrémenteraient, car rien ne garantirait la cohérence de l’état de cette variable ; les accès quasi simultanés lui donneraient une valeur aberrante (note : ce paragraphe montre les limites de ma compréhension profonde de la parallélisation). Il faut donc utiliser une Value (de type “entier”), et un Lock (un verrou) sur celle-ci :

counter = Value(c_int)
counter_lock = Lock()

On utilise l’instruction with pour modifier de façon sûre la valeur :

with counter_lock:
    counter.value += 1
    log(f"Extrait créé ({counter.value}/{len(timecodes)-1})", LOG_SUCCESS) 

Pourquoi “Cavid” ?

Parce que “CAViardage VIDéo”, et parce que Cavid ça ressemble à Covid et que sans le (ou la) Covid, je n’aurais pas eu besoin de faire tout ça.

Voilà voilà.

Sur GitHub

https://github.com/Biganon/cavid/

Icône par DinosoftLabs sur Flaticon

comments powered by Disqus