Auto Reframe in Videoeditoren wird meist als KI-Subjekt-Tracking verkauft. Für den häufigsten Fall, eine Person, die sich vor einem ruhigen Hintergrund bewegt, findet eine viel einfachere Methode denselben Bildausschnitt: jedes Frame mit dem vorherigen vergleichen und schauen, wo sich das Bild verändert hat. Das Reframe-Tool der Desktop-App osFoundry arbeitet so, in Rust und ffmpeg. Unten steht dieselbe Methode in etwa 60 Zeilen Python, angewendet auf einen 7.5 Sekunden langen 1920×1080-Clip aus drei Einstellungen. Sie kommt auf dieselben Crops wie die App, pixelgenau.
1. Das Video auf graue 64×36-Frames verkleinern
Um zu finden, wo sich etwas bewegt hat, braucht man die Frames nicht in voller Größe. ffmpeg kann das Video als Rohbytes liefern, vier Frames pro Sekunde, auf 64×36 skaliert und in ein Graubyte pro Pixel umgewandelt:
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)] Jedes Frame hat 2304 Bytes. Der 7.5-Sekunden-Clip ergibt 30 Frames und damit 29 Paare zum Vergleichen. Das Verkleinern mittelt außerdem das meiste Rauschen weg, was im nächsten Schritt wichtig ist.
2. Den Schwerpunkt der Veränderung finden
Für jedes Framepaar bildet man die Differenz jeder Zelle, ignoriert kleine Unterschiede und berechnet den Massenschwerpunkt des Rests, gewichtet danach, wie stark sich jede Zelle verändert hat:
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:])] Die beiden Konstanten machen das Ganze erst brauchbar. Die Rauschschwelle von 12 (von 255) verwirft Änderungen, die vom Sensor und von der Kompression kommen. Bei diesem Clip zählen mit der Schwelle 12 im Schnitt 381 der 2304 Zellen als verändert. Mit der Schwelle 1 sind es 1855, also fast das ganze Bild, und der Schwerpunkt wird zur Bildmitte gezogen: Der erste Messwert landet bei 0.44 der Breite statt bei 0.40.
Die zweite Prüfung gibt None zurück, wenn die Gesamtänderung winzig ist. Hat sich nichts bewegt, ist der "Schwerpunkt der Änderung" der Schwerpunkt des Rauschens, und ihm zu folgen lässt den Ausschnitt ohne Grund wandern. None heißt: vorherige Position beibehalten.
3. Begrenzen, wie schnell der Ausschnitt wandern darf
Ein roher Schwerpunkt springt, sobald eine zweite Person ins Bild kommt. Wird der Schritt auf 5% des Bildes pro Messwert begrenzt, schlägt er nicht quer durchs Bild:
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) Die Funktion startet mit der ersten echten Beobachtung, damit ein Clip, der mit einem ruhigen Bild beginnt, nicht aus der Mitte heranschwenkt.
4. Schnitte mit dem Scene-Score von ffmpeg finden
Der select-Filter von ffmpeg gibt jedem Frame einen Scene-Score zwischen 0 und 1, und showinfo gibt die Frames aus, die ihn überschreiten. Die Zeitstempel landen auf stderr, nicht auf 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 diesem Clip findet das Schnitte bei 1767 ms und 4733 ms. Gibt man den Score jedes Frames aus (select='gte(scene,0)',metadata=print), sieht man, wie groß der Abstand ist: Die beiden Schnitte erreichen 0.616 und 0.432, und kein anderes der 225 Frames liegt über 0.068.
5. Ein Crop pro Einstellung
Zum Schluss mittelt man den Verlauf innerhalb jeder Einstellung und legt ein 9:16-Fenster um diesen Punkt. 1080 × 9/16 ist 607.5; die Breite wird auf gerade 606 abgerundet, weil H.264 gerade Maße braucht.
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 Das sind dieselben drei Crops, die die App für diesen Clip berechnet hat. Das ganze Skript brauchte auf diesem Mac 3.5 Sekunden, das meiste davon in der reinen Python-Schleife.
Die Schnitte sind wichtiger, als es aussieht. Über den ganzen Clip gemittelt sitzt das Subjekt bei x = 707, nur 50 Pixel von einem einfachen zentrierten Crop (657) entfernt, weil die Frau in einer Einstellung links und in einer anderen rechts läuft. Erst pro Einstellung hat der Mittelwert eine Bedeutung.
Die Einstellungen wieder zusammensetzen
Aus den Crops wird ein einziger filter_complex: jede Einstellung zuschneiden, croppen und die Teile zusammenfügen. In dieser Form baut ihn die 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] (Die Zeilenumbrüche weglassen, wenn man ihn an -filter_complex übergibt.) Die Ausgabe ist 606×1080 und genauso lang wie die Eingabe.
Das Rust der App zeigt, warum sie den Crop innerhalb einer Einstellung nicht animiert, obwohl ffmpeg das könnte:
/// 🔴 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> { Wo Frame-Differenzen scheitern
Die Methode findet, was sich bewegt, nicht, wer wichtig ist. Sie funktioniert, wenn das Subjekt das Hauptbewegte ist und die Kamera ruhig steht. Sie versagt, wenn:
- die Kamera schwenkt, sodass sich jede Zelle ändert und der Schwerpunkt nahe der Mitte landet;
- der Hintergrund unruhig ist, etwa Verkehr oder eine Menschenmenge;
- zwei Personen sich auf gegenüberliegenden Seiten bewegen und der Mittelwert zwischen ihnen landet.
Spiegelungen sind ein milderer Fall. In der ersten Einstellung dieses Clips bewegt sich auch das Spiegelbild der Frau in einem Fenster, bleibt aber nah bei ihr, sodass der Ausschnitt sie weiter findet. Ein Gesichts- oder Personendetektor löst die schwierigen Fälle besser, dafür braucht man ein Modell. Die Antwort der App darauf ist eine manuelle Korrektur: Der Nutzer zieht einen Punkt auf das Subjekt, und die Analyse wird übersprungen.
Die hier verwendete Desktop-App ist auf der osFoundry-Downloadseite kostenlos.

