DEV Community

Cover image for Comment détecter les changements de plan avec ffmpeg et découper la vidéo exactement sur les coupes

Comment détecter les changements de plan avec ffmpeg et découper la vidéo exactement sur les coupes

ffmpeg sait trouver les coupes d'une vidéo montée avec un seul filtre, et la plupart des guides s'arrêtent là : select='gt(scene,0.4)',showinfo, on lit les horodatages, terminé. Sur un vrai montage, deux questions restent ouvertes. Quel seuil sépare les coupes des secousses de caméra, et comment transformer les horodatages en fichiers qui commencent vraiment sur la coupe ? L'éditeur vidéo de l'application de bureau osFoundry traite la première moitié en Rust (canvas_scene_split dans canvas_operations.rs) et évite complètement la seconde. On reconstruit ici les deux moitiés en Python et on les mesure sur une vidéo, avec les chiffres qui tranchent chaque choix.

Le fichier de test est le résumé du lancement du vol d'essai Artemis II publié par la NASA dans le domaine public : 92 secondes de H.264 en 640×360, 30 fps, 2 760 images, montées à partir de 19 plans. Tous les temps ont été mesurés sur un Apple M1 avec ffmpeg 7.1 et Python 3.9.

Détecter les coupes

La commande est celle que montrent tous les guides. Le détail qui fait perdre un après-midi, c'est l'endroit où part la sortie : showinfo écrit dans le journal de ffmpeg, c'est-à-dire sur stderr. Si on lit stdout, on obtient une liste vide pour une exécution qui a pourtant fonctionné.

import re, subprocess

def detect(video, threshold):
    vf = f"select='gt(scene,{threshold})',showinfo"
    p = subprocess.run(["ffmpeg", "-hide_banner", "-i", video, "-an",
                        "-filter_complex", vf, "-f", "null", "-"],
                       capture_output=True, text=True)
    return [round(float(t) * 1000)
            for t in re.findall(r"pts_time:([0-9.]+)", p.stderr)]
Enter fullscreen mode Exit fullscreen mode

Avec 0.27, la valeur par défaut de l'application, la fonction renvoie 18 temps de coupe en millisecondes, de 3 133 à 73 033. Le passage a pris 0,80 s (médiane de 5 exécutions), donc la détection est assez peu coûteuse pour la relancer avec un autre seuil aussi souvent qu'on veut.

Ce que mesure le score de scène

Le seuil est plus facile à choisir quand on sait ce que représente ce nombre. Dans libavfilter/f_select.c, get_scene_score calcule la différence absolue moyenne entre l'image courante et la précédente (mafd), puis soustrait la mafd de l'image précédente :

score = min(mafd, |mafd - prev_mafd|) / 100
Enter fullscreen mode Exit fullscreen mode

Pour une entrée YUV, seul le plan de luminance est comparé. Le score se reconstruit donc en quelques lignes de Python, ce qui est le moyen le plus rapide de comprendre une valeur surprenante :

def luma(video, n):
    p = subprocess.run(["ffmpeg", "-v", "error", "-i", video,
                        "-vf", f"select='eq(n,{n})'", "-frames:v", "1",
                        "-f", "rawvideo", "-pix_fmt", "yuv420p", "-"],
                       capture_output=True)
    return p.stdout[:640 * 360]   # Y plane only

def mafd(a, b):
    return sum(abs(x - y) for x, y in zip(a, b)) / len(a)

f0, f1, f2 = (luma(VIDEO, k) for k in (n - 2, n - 1, n))
prev, cur = mafd(f0, f1), mafd(f1, f2)
score = min(cur, abs(cur - prev)) / 100
Enter fullscreen mode Exit fullscreen mode

Prendre le plan Y depuis yuv420p plutôt que demander -pix_fmt gray a son importance : la conversion en niveaux de gris étire la plage de valeurs, et les scores reconstruits sortaient environ 16 % trop hauts. Avec le plan Y brut, quatre images de cette vidéo correspondent aux scores de ffmpeg lui-même :

image ce qui se passe mafd mafd précédente score
33.300 s coupe franche sur la foule 57.12 1.31 0.558
33.333 s l'image qui suit cette coupe 13.75 57.12 0.138
58.567 s secousse de caméra à l'allumage des moteurs 27.03 10.88 0.161
63.667 s coupe du pas de tir vers un plan large du ciel 34.64 0.90 0.337

En haut : deux images consécutives à l'allumage, score 0.161. En bas : la coupe du pas de tir vers le plan large, score 0.337

C'est la soustraction qui empêche les plans en mouvement de déclencher une coupe. L'image de l'allumage diffère de la précédente de 27 niveaux de luminosité en moyenne, deux fois plus que l'image qui suit la coupe sur la foule. Mais l'image d'avant avait déjà changé de 10,9, donc seule l'augmentation compte, et le score est de 0,16. La coupe du pas de tir vers le ciel change moins que beaucoup d'autres coupes, mais elle suit une image presque immobile, donc son score est de 0,34. Une coupe, c'est un saut dans le changement, pas simplement beaucoup de changement.

Choisir le seuil à partir des données

Plutôt que de deviner, on extrait une fois le score de chaque image et on regarde où se trouve l'écart :

ffmpeg -i launch.mp4 -an -vf "select='gte(scene,0)',metadata=print:file=-" -f null - \
  | grep -o 'scene_score=[0-9.]*'
Enter fullscreen mode Exit fullscreen mode

L'image médiane de cette vidéo a un score de 0,0015. Dix-sept images dépassent 0,5, une est à 0,337, et la suivante est celle de l'allumage, à 0,161. N'importe quel seuil entre 0,162 et 0,337 renvoie les mêmes 18 coupes. Le balayage, avec et sans écart minimal de 500 ms entre les coupes :

seuil images au-dessus coupes après l'écart de 500 ms
0.08 27 20
0.10 24 20
0.12 20 19
0.15 19 19
0.20 18 18
0.27 18 18
0.35 17 17
0.40 17 17
0.50 17 17

L'essentiel de la plage 0,3–0,5 que proposent la liste de diffusion de ffmpeg et de nombreux tutoriels est trop haut pour cette vidéo : à partir de 0,35, la coupe du pas de tir vers le ciel disparaît. À 0,15 et en dessous, l'allumage commence à produire des coupes. Sur des images avec beaucoup de mouvement rapide, la zone sûre peut être étroite, donc mieux vaut jeter un œil à la distribution avant de se fier à un chiffre tiré de la vidéo de quelqu'un d'autre.

L'écart minimal

Les lignes de 0,08 à 0,12 montrent pourquoi l'application ignore les détections qui arrivent trop tôt après la précédente (parse_scene_times, 500 ms par défaut). À 0,12, la coupe à 33,300 s se déclenche une deuxième fois sur l'image suivante, à 33,333 s, parce que cette image diffère encore de sa voisine de 13,75 alors que le saut d'avant était énorme. À 0,08, l'allumage produit huit détections entre 57,7 s et 59,0 s. Un écart d'une demi-seconde réduit chaque rafale à un seul marqueur :

def min_gap(hits, gap_ms=500):
    out = []
    for ms in hits:
        if not out or ms - out[-1] >= gap_ms:
            out.append(ms)
    return out
Enter fullscreen mode Exit fullscreen mode

Cela ne corrige pas un seuil trop bas, seulement les doublons. À 0,08, l'écart laisse encore deux fausses coupes dans l'allumage.

Découper avec -c copy ne tombe pas sur les coupes

L'étape suivante évidente, c'est le muxer segment de ffmpeg avec copie de flux :

ffmpeg -i launch.mp4 -c copy -f segment -segment_times 3.133,6.333,... \
  -reset_timestamps 1 scene%02d.mp4
Enter fullscreen mode Exit fullscreen mode

Cela a pris 0,05 s et produit 18 fichiers, pas 19, tous d'une durée comprise entre 3,3 et 6,0 secondes. Les scènes elles-mêmes durent de 1,5 à 19,0 secondes. Une copie de flux ne peut démarrer un fichier que sur une image clé, et cet encodage en compte 18, espacées de 6 secondes au plus. ffprobe -skip_frame nokey les liste :

ffprobe -v error -select_streams v -skip_frame nokey \
  -show_entries frame=pts_time -of csv=p=0 launch.mp4
Enter fullscreen mode Exit fullscreen mode

Seules 5 des 18 coupes tombent sur une image clé, là où l'encodeur d'origine en avait placé une par hasard. Toutes les autres limites de fichier arrivent en retard, la pire d'environ 4 secondes, et quand deux coupes partagent la même image clé suivante, elles se confondent en une seule limite.

Réencoder, et le piège de la demi-image

Réencoder en forçant une image clé à chaque coupe permet aux limites de tomber n'importe où :

ffmpeg -i launch.mp4 -c:v libx264 -crf 18 -preset fast -c:a aac \
  -force_key_frames "$T" -f segment -segment_times "$T" \
  -reset_timestamps 1 scene%02d.mp4
Enter fullscreen mode Exit fullscreen mode

Avec $T réglé sur les temps détectés arrondis à la milliseconde, on obtient 19 fichiers, et 4 des 18 coupes arrivaient encore une image trop tard. Une coupe a été complètement manquée, ce qui a fusionné deux scènes dans un fichier de 12,3 secondes, et un fichier parasite ne contenait qu'une seule image. La cause, c'est l'arrondi. Une coupe à l'image 269 se situe à 8,9666… s, et « 8.967 » tombe juste après, donc la comparaison retient l'image suivante. Les quatre coupes en retard sont toutes sur des temps qui finissent par un 6 périodique, tout comme l'image parasite à 44,067 s.

Avancer chaque temps d'une demi-image (1/60 s à 30 fps) le place sans ambiguïté avant l'image de la coupe :

T = ",".join(f"{ms / 1000 - 0.5 / 30:.4f}" for ms in cuts)
Enter fullscreen mode Exit fullscreen mode

Avec ce changement, les 18 limites sont tombées sur l'image de la coupe et les durées des 19 fichiers correspondaient à celles des scènes à l'image près. Le réencodage a pris 6,9 s pour le clip de 92 secondes et a écrit 15,2 Mo, contre 18,2 Mo pour la source, en CRF 18.

Ou ne pas écrire de fichiers du tout

L'application prend la troisième voie. Ses boutons Split at appellent canvas_split_clip dans canvas_layers.rs, qui raccourcit le clip sélectionné et insère un nouveau clip sur le même média avec un point d'entrée plus tardif. Rien n'est décodé ni écrit, donc pas de problème d'images clés ni de perte de génération, et l'encodage n'a lieu qu'une fois, à l'export du montage terminé. Si le but est de remonter la vidéo plutôt que de remettre 19 fichiers à quelqu'un, c'est cette version qu'il faut reprendre : garder la liste des coupes sous forme de points d'entrée et de sortie, et laisser le rendu final faire le travail précis à l'image.

Le script complet, avec toutes les mesures ci-dessus, fait environ 120 lignes de Python en bibliothèque standard autour de ffmpeg et ffprobe. Dans l'application de bureau osFoundry, la même détection correspond au preset Find the cuts de l'éditeur vidéo, et elle tourne sur votre propre machine, comme le décrit la page sur l'approche local-first. L'application est gratuite depuis la page de téléchargement d'osFoundry.

Top comments (0)