Los editores de vídeo suelen vender el reencuadre automático como seguimiento de sujetos con IA. Para el caso más habitual, una persona que se mueve delante de un fondo que no cambia, basta un método mucho más simple: comparar cada fotograma con el anterior y ver dónde ha cambiado la imagen. La herramienta de reencuadre de la aplicación de escritorio osFoundry funciona así, en Rust y ffmpeg. Abajo está el mismo método en unas 60 líneas de Python, aplicado a un clip de 7.5 segundos en 1920×1080 con tres planos. Da los mismos recortes que la aplicación, al píxel.
1. Reducir el vídeo a fotogramas grises de 64×36
No hace falta mirar los fotogramas a tamaño completo para encontrar qué se ha movido. ffmpeg puede entregar el vídeo como bytes en bruto, cuatro fotogramas por segundo, reducido a 64×36 y convertido a un byte de gris por píxel:
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)] Cada fotograma ocupa 2304 bytes. El clip de 7.5 segundos da 30 fotogramas y por tanto 29 pares que comparar. Al reducir el tamaño también se promedia casi todo el grano, y eso importa en el paso siguiente.
2. Encontrar el centro de lo que cambió
Para cada par de fotogramas, se calcula la diferencia de cada celda, se ignoran las diferencias pequeñas y se obtiene el centro de masa del resto, ponderado por cuánto cambió cada celda:
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:])] Las dos constantes son lo que lo hace utilizable. El umbral de ruido de 12 (sobre 255) descarta los cambios que vienen del sensor y de la compresión. En este clip, con el umbral en 12 cuentan como cambiadas una media de 381 de las 2304 celdas. Con el umbral en 1 son 1855, casi todo el fotograma, y el centro se desplaza hacia la mitad de la imagen: la primera muestra cae en 0.44 del ancho en lugar de 0.40.
La segunda comprobación devuelve None cuando el cambio total es mínimo. Si nada se movió, el "centro del cambio" es el centro del ruido, y seguirlo hace que el encuadre se desplace sin motivo. None significa "mantén la posición anterior".
3. Limitar la velocidad del encuadre
Un centroide sin filtrar da un salto cada vez que entra una segunda persona en el plano. Limitar el paso al 5% del fotograma por muestra evita que el encuadre pegue latigazos:
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 de la primera observación real, así que un clip que empieza con una imagen fija no entra con un paneo desde el centro.
4. Encontrar los cortes con la puntuación de escena de ffmpeg
El filtro select de ffmpeg asigna a cada fotograma una puntuación de escena entre 0 y 1, y showinfo imprime los fotogramas que la superan. Las marcas de tiempo salen por stderr, no por 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)] En este clip encuentra cortes en 1767 ms y 4733 ms. Si se imprime la puntuación de cada fotograma (select='gte(scene,0)',metadata=print), se ve lo ancho que es el margen: los dos cortes puntúan 0.616 y 0.432, y ningún otro de los 225 fotogramas pasa de 0.068.
5. Un recorte por plano
Por último, se promedia la trayectoria dentro de cada plano y se coloca una ventana 9:16 alrededor de ese punto. 1080 × 9/16 es 607.5; el ancho se redondea hacia abajo a 606 porque H.264 necesita dimensiones pares.
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 Son los mismos tres recortes que produjo la aplicación para este clip. El script entero tardó 3.5 segundos en este Mac, la mayor parte en el bucle de Python puro.
Los cortes importan más de lo que parece. Promediado sobre todo el clip, el sujeto queda en x = 707, a solo 50 píxeles de un recorte centrado simple (657), porque camina por la izquierda en un plano y por la derecha en otro. Solo por planos tiene sentido el promedio.
Volver a unir los planos
Los recortes se convierten en un único filter_complex: se recorta cada plano en el tiempo, se le aplica su crop y se unen las partes. Esta es la forma que construye la aplicación:
[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] (Quita los saltos de línea al pasarlo a -filter_complex.) La salida es de 606×1080 y dura lo mismo que la entrada.
El Rust de la aplicación explica por qué no anima el recorte dentro de un plano, aunque ffmpeg podría hacerlo:
/// 🔴 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> { Dónde falla la diferencia de fotogramas
El método encuentra lo que se mueve, no a quién le importa. Funciona cuando el sujeto es lo principal que se mueve y la cámara está quieta. Falla cuando:
- la cámara hace un paneo, así que cambian todas las celdas y el centro cae cerca del medio;
- el fondo está cargado, como el tráfico o una multitud;
- dos personas se mueven en lados opuestos y el promedio queda entre ellas.
Los reflejos son un caso más suave. En el primer plano de este clip, el reflejo de la mujer en una ventana también se mueve, pero queda cerca de ella, así que el encuadre la sigue encontrando. Un detector de caras o de personas resuelve mejor los casos difíciles, a costa de un modelo. La respuesta de la aplicación para esos casos es un ajuste manual: el usuario arrastra un punto sobre el sujeto y se omite el análisis.
La aplicación de escritorio usada aquí es gratuita en la página de descarga de osFoundry.

