Le recadrage automatique des logiciels de montage est souvent vendu comme du suivi de sujet par IA. Pour le cas le plus courant, une personne qui bouge devant un fond immobile, une méthode bien plus simple trouve le même cadrage : comparer chaque image à la précédente et regarder où l'image a changé. L'outil de recadrage de l'application de bureau osFoundry fonctionne ainsi, en Rust et ffmpeg. Voici la même méthode en une soixantaine de lignes de Python, appliquée à un clip de 7.5 secondes en 1920×1080 composé de trois plans. Elle donne les mêmes recadrages que l'application, au pixel près.

1. Réduire la vidéo en images grises de 64×36

Inutile d'examiner les images en taille réelle pour trouver ce qui a bougé. ffmpeg peut fournir la vidéo en octets bruts, à quatre images par seconde, réduite à 64×36 et convertie en un octet de gris par pixel :

import math, re, subprocess, sys FFMPEG, SRC = sys.argv[1], sys.argv[2] W, H, FPS = 64, 36, 4 raw = subprocess.run( [FFMPEG, "-loglevel", "error", "-i", SRC, "-vf", f"fps={FPS},scale={W}:{H}", "-pix_fmt", "gray", "-f", "rawvideo", "-"], capture_output=True, check=True).stdout frames = [raw[i:i + W * H] for i in range(0, len(raw) - W * H + 1, W * H)] 

Chaque image fait 2304 octets. Le clip de 7.5 secondes donne 30 images, donc 29 paires à comparer. La réduction moyenne aussi la plus grande partie du grain, ce qui compte pour l'étape suivante.

2. Trouver le centre de ce qui a changé

Pour chaque paire d'images, on calcule la différence de chaque cellule, on ignore les petites différences et on calcule le centre de masse du reste, pondéré par l'ampleur du changement de chaque cellule :

def centroid(prev, curr, floor=12): total = sx = sy = 0 for i, (a, b) in enumerate(zip(prev, curr)): d = abs(b - a) if d < floor: continue total += d sx += d * (i % W) sy += d * (i // W) if total < W * H * 0.5: return None return sx / total / W, sy / total / H samples = [centroid(a, b) for a, b in zip(frames, frames[1:])] 

Une image grise 64×36 du troisième plan (à gauche) et sa différence avec l'image précédente (à droite) : seule la femme qui marche s'allume

Ces deux constantes rendent la méthode utilisable. Le seuil de bruit de 12 (sur 255) écarte les changements dus au capteur et à la compression. Sur ce clip, avec le seuil à 12, 381 cellules sur 2304 en moyenne comptent comme modifiées. Avec le seuil à 1, il y en a 1855, soit presque toute l'image, et le centre est attiré vers le milieu : le premier échantillon tombe à 0.44 de la largeur au lieu de 0.40.

La seconde vérification renvoie None quand le changement total est minuscule. Si rien n'a bougé, le « centre du changement » est le centre du bruit, et le suivre fait dériver le cadre sans raison. None veut dire « garde la position précédente ».

3. Limiter la vitesse du cadre

Un centroïde brut saute dès qu'une deuxième personne entre dans le plan. Plafonner le pas à 5% de l'image par échantillon l'empêche de traverser l'écran d'un coup :

def smooth(samples, step=0.05): cur = next((s for s in samples if s), (0.5, 0.5)) out = [] for s in samples: if s: cur = tuple(min(1, max(0, c + max(-step, min(step, t - c)))) for c, t in zip(cur, s)) out.append(cur) return out track = smooth(samples) 

Elle part de la première vraie observation, si bien qu'un clip qui s'ouvre sur une image fixe ne fait pas de panoramique depuis le centre.

4. Trouver les coupes avec le score de scène de ffmpeg

Le filtre select de ffmpeg attribue à chaque image un score de scène entre 0 et 1, et showinfo affiche les images qui passent le seuil. Les horodatages vont sur stderr, pas sur stdout :

log = subprocess.run( [FFMPEG, "-hide_banner", "-i", SRC, "-filter_complex", "select='gt(scene,0.35)',showinfo", "-f", "null", "-"], capture_output=True, text=True).stderr cuts = [round(float(t) * 1000) for t in re.findall(r"pts_time:([\d.]+)", log)] 

Sur ce clip, cela trouve des coupes à 1767 ms et 4733 ms. Afficher le score de chaque image (select='gte(scene,0)',metadata=print) montre la marge : les deux coupes obtiennent 0.616 et 0.432, et aucune autre des 225 images ne dépasse 0.068.

5. Un recadrage par plan

Enfin, on fait la moyenne de la trajectoire à l'intérieur de chaque plan et on place une fenêtre 9:16 autour de ce point. 1080 × 9/16 donne 607.5 ; la largeur est arrondie à 606, un nombre pair, car H.264 exige des dimensions paires.

duration = len(frames) * 1000 // FPS per = duration / len(track) bounds = [0] + [c for c in cuts if 0 < c < duration] + [duration] cw = int(1080 * 9 / 16) // 2 * 2 for start, end in zip(bounds, bounds[1:]): a = int(start // per) part = track[a:max(a + 1, math.ceil(end / per))] bx = sum(p[0] for p in part) / len(part) x = min(max(round(1920 * bx - cw / 2), 0), 1920 - cw) print(f"{start / 1000:.2f}-{end / 1000:.2f}s crop={cw}:1080:{x}:0") 
0.00-1.77s crop=606:1080:369:0 1.77-4.73s crop=606:1080:668:0 4.73-7.50s crop=606:1080:932:0 

Ce sont les trois mêmes recadrages que ceux de l'application pour ce clip. Le script entier a pris 3.5 secondes sur ce Mac, presque tout dans la boucle en Python pur.

Les coupes comptent plus qu'il n'y paraît. Moyenné sur tout le clip, le sujet se trouve à x = 707, à seulement 50 pixels d'un recadrage centré simple (657), parce que la femme marche à gauche dans un plan et à droite dans un autre. Ce n'est qu'à l'échelle du plan que la moyenne a un sens.

Remettre les plans bout à bout

Les recadrages deviennent un seul filter_complex : on découpe chaque plan, on le recadre et on assemble les morceaux. C'est la forme que construit l'application :

[0:v]trim=start=0.000:end=1.767,setpts=PTS-STARTPTS,crop=606:1080:369:0[v0]; [0:v]trim=start=1.767:end=4.733,setpts=PTS-STARTPTS,crop=606:1080:668:0[v1]; [0:v]trim=start=4.733:end=7.500,setpts=PTS-STARTPTS,crop=606:1080:932:0[v2]; [v0][v1][v2]concat=n=3:v=1:a=0[out] 

(Supprimez les retours à la ligne en le passant à -filter_complex.) La sortie fait 606×1080 et dure autant que l'entrée.

Rangée du haut : recadrage centré de chaque plan. Rangée du bas : les recadrages trouvés ci-dessus

Le Rust de l'application explique pourquoi elle n'anime pas le recadrage à l'intérieur d'un plan, alors que ffmpeg le pourrait :

/// 🔴 Per scene, not per frame. ffmpeg can animate a crop, but a crop that moves continuously is /// a pan the editor never asked for; cutting between fixed framings is what an editor would /// actually do, and it is also what stays honest when the tracking is wrong — a bad framing /// lasts one scene rather than smearing across the whole clip. pub fn reframe_segments( cuts: &[i64], duration_ms: i64, track: &[(f64, f64)], width: u32, height: u32, target: f64, ) -> Vec<ReframeSegment> { 

Où la différence d'images échoue

La méthode trouve ce qui bouge, pas qui compte. Elle marche quand le sujet est l'élément principal qui bouge et que la caméra reste fixe. Elle échoue quand :

  • la caméra fait un panoramique : toutes les cellules changent et le centre tombe près du milieu ;
  • le fond est chargé, comme la circulation ou une foule ;
  • deux personnes bougent de part et d'autre et la moyenne se retrouve entre elles.

Les reflets sont un cas plus léger. Dans le premier plan de ce clip, le reflet de la femme dans une vitre bouge aussi, mais il reste proche d'elle, donc le cadre la trouve quand même. Un détecteur de visages ou de personnes gère mieux les cas difficiles, au prix d'un modèle. La réponse de l'application à ces cas est un réglage manuel : l'utilisateur glisse un point sur le sujet et l'analyse est ignorée.

L'application de bureau utilisée ici est gratuite sur la page de téléchargement d'osFoundry.