Il riquadro automatico degli editor video viene di solito venduto come tracciamento del soggetto con l'IA. Per il caso più comune, una persona che si muove davanti a uno sfondo fermo, basta un metodo molto più semplice: confrontare ogni fotogramma con il precedente e vedere dove l'immagine è cambiata. Lo strumento di reframe dell'app desktop osFoundry funziona così, in Rust e ffmpeg. Qui sotto c'è lo stesso metodo in circa 60 righe di Python, eseguito su una clip 1920×1080 di 7.5 secondi con tre inquadrature. Ottiene gli stessi ritagli dell'app, al pixel.
1. Ridurre il video a fotogrammi grigi 64×36
Per trovare dove qualcosa si è mosso non serve guardare i fotogrammi a dimensione piena. ffmpeg può restituire il video come byte grezzi, quattro fotogrammi al secondo, ridotti a 64×36 e convertiti in un byte di grigio per 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)] Ogni fotogramma pesa 2304 byte. La clip di 7.5 secondi dà 30 fotogrammi e quindi 29 coppie da confrontare. La riduzione elimina anche gran parte della grana, e questo conta nel passo successivo.
2. Trovare il centro di ciò che è cambiato
Per ogni coppia di fotogrammi si calcola la differenza di ogni cella, si ignorano le differenze piccole e si calcola il baricentro del resto, pesato su quanto è cambiata ogni cella:
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:])] Sono le due costanti a rendere il metodo utilizzabile. La soglia di rumore di 12 (su 255) scarta i cambiamenti dovuti al sensore e alla compressione. In questa clip, con la soglia a 12, in media 381 celle su 2304 contano come cambiate. Con la soglia a 1 sono 1855, quasi tutto il fotogramma, e il centro viene tirato verso la metà dell'immagine: il primo campione cade a 0.44 della larghezza invece che a 0.40.
Il secondo controllo restituisce None quando il cambiamento totale è minimo. Se non si è mosso nulla, il "centro del cambiamento" è il centro del rumore, e seguirlo fa scivolare l'inquadratura senza motivo. None significa "mantieni la posizione precedente".
3. Limitare la velocità dell'inquadratura
Un baricentro grezzo salta ogni volta che entra una seconda persona. Limitare il passo al 5% del fotogramma per campione evita che schizzi da una parte all'altra:
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) Parte dalla prima osservazione reale, così una clip che si apre su un'immagine ferma non fa una panoramica dal centro.
4. Trovare i tagli con lo scene score di ffmpeg
Il filtro select di ffmpeg assegna a ogni fotogramma un punteggio di scena tra 0 e 1, e showinfo stampa i fotogrammi che lo superano. I timestamp vanno su stderr, non su 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)] In questa clip trova tagli a 1767 ms e 4733 ms. Stampando il punteggio di ogni fotogramma (select='gte(scene,0)',metadata=print) si vede quanto è ampio il margine: i due tagli ottengono 0.616 e 0.432, e nessun altro dei 225 fotogrammi supera 0.068.
5. Un ritaglio per inquadratura
Infine si fa la media del tracciamento dentro ogni inquadratura e si mette una finestra 9:16 attorno a quel punto. 1080 × 9/16 fa 607.5; la larghezza viene arrotondata per difetto a 606, pari, perché H.264 richiede dimensioni pari.
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 Sono gli stessi tre ritagli prodotti dall'app per questa clip. L'intero script ha impiegato 3.5 secondi su questo Mac, quasi tutti nel ciclo in Python puro.
I tagli contano più di quanto sembri. Mediato su tutta la clip, il soggetto sta a x = 707, a soli 50 pixel da un semplice ritaglio centrale (657), perché la donna cammina a sinistra in un'inquadratura e a destra in un'altra. Solo per singola inquadratura la media ha un senso.
Rimettere insieme le inquadrature
I ritagli diventano un unico filter_complex: si taglia ogni inquadratura, la si ritaglia e si uniscono le parti. È la forma che costruisce l'app:
[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] (Togli le interruzioni di riga quando lo passi a -filter_complex.) L'uscita è 606×1080 e dura quanto l'ingresso.
Il Rust dell'app spiega perché non anima il ritaglio dentro un'inquadratura, anche se ffmpeg potrebbe:
/// 🔴 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> { Dove la differenza tra fotogrammi fallisce
Il metodo trova ciò che si muove, non chi conta. Funziona quando il soggetto è la cosa principale che si muove e la camera sta ferma. Va male quando:
- la camera fa una panoramica: cambia ogni cella e il centro finisce vicino alla metà;
- lo sfondo è affollato, come il traffico o una folla;
- due persone si muovono ai lati opposti e la media finisce in mezzo a loro.
I riflessi sono un caso più lieve. Nella prima inquadratura di questa clip anche il riflesso della donna in una finestra si muove, ma resta vicino a lei, quindi l'inquadratura la trova comunque. Un rilevatore di volti o di persone gestisce meglio i casi difficili, al prezzo di un modello. La risposta dell'app per questi casi è una correzione manuale: l'utente trascina un punto sul soggetto e l'analisi viene saltata.
L'app desktop usata qui è gratuita dalla pagina di download di osFoundry.



