aboutsummaryrefslogtreecommitdiffstats
path: root/trackcrop.py
diff options
context:
space:
mode:
authorDanilo M. <danix@danix.xyz>2026-10-02 20:59:46 +0200
committerDanilo M. <danix@danix.xyz>2026-10-02 20:59:46 +0200
commit69cc958d714b7cae798aa0b1389c92a308313e0c (patch)
tree66a2d99bbcf4fb6683c003cef94ed7de37c15eb0 /trackcrop.py
downloadtrackcrop-master.tar.gz
trackcrop-master.zip
Add trackcrop: crop a video to a square around a tracked objectHEADmaster
Single-file Python script. The user boxes an object on the first frame, a CSRT tracker follows it, the path is smoothed over ~0.5 s and the square crop is encoded with h264_vaapi, audio copied. --ak820 FPS limits the output to the AJAZZ AK820 Pro screen's 255 frames at FPS, prompting for which window to keep (start, end or a start second) when the clip is longer. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Diffstat (limited to 'trackcrop.py')
-rwxr-xr-xtrackcrop.py107
1 files changed, 107 insertions, 0 deletions
diff --git a/trackcrop.py b/trackcrop.py
new file mode 100755
index 0000000..9a29270
--- /dev/null
+++ b/trackcrop.py
@@ -0,0 +1,107 @@
+#!/usr/bin/env python3
+# Copyright (C) 2026 Danilo M. <danix@danix.xyz>
+#
+# This program is free software; you can redistribute it and/or modify
+# it under the terms of the GNU General Public License version 2 as
+# published by the Free Software Foundation.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# Crop a vertical video to 1:1, keeping a tracked object centered.
+# usage: trackcrop.py [--no-audio] [--ak820 FPS] in.mp4 out.mp4 ; draw box around object on first frame, press Enter
+# needs: opencv-contrib-python (not -headless), ffmpeg with h264_vaapi
+import sys, subprocess, cv2, numpy as np
+
+AK820_FRAMES = 255 # AJAZZ AK820 Pro screen holds at most 255 frames (see ak820-upload)
+USAGE = "usage: trackcrop.py [--no-audio] [--ak820 FPS] in.mp4 out.mp4"
+
+args = sys.argv[1:]
+mute = "--no-audio" in args
+if mute:
+ args.remove("--no-audio")
+kfps = None
+if "--ak820" in args:
+ i = args.index("--ak820")
+ try:
+ kfps = float(args[i + 1])
+ except (IndexError, ValueError):
+ sys.exit(USAGE)
+ del args[i:i + 2]
+if len(args) != 2:
+ sys.exit(USAGE)
+src, dst = args
+
+def open_at(first):
+ """Open src positioned at frame `first` (grab, not seek: exact on any codec)."""
+ cap = cv2.VideoCapture(src)
+ for _ in range(first):
+ cap.grab()
+ return cap
+
+cap = cv2.VideoCapture(src)
+fps = cap.get(cv2.CAP_PROP_FPS)
+total = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) # container estimate
+cap.release()
+
+# --ak820: keep a window that fits the keyboard's frame limit at FPS
+first, count = 0, 10**9
+if kfps and total / fps > AK820_FRAMES / kfps:
+ dur, keep = total / fps, AK820_FRAMES / kfps
+ ans = input(f"{dur:.1f}s clip, the keyboard holds {keep:.1f}s at {kfps:g} fps. "
+ f"Keep [s]tart, [e]nd, or start at N seconds (0-{dur - keep:.1f}): ").strip().lower()
+ try:
+ t0 = 0.0 if ans in ("", "s") else dur - keep if ans == "e" else float(ans)
+ except ValueError:
+ sys.exit(f"not s, e or a number: {ans}")
+ t0 = min(max(t0, 0.0), dur - keep)
+ first, count = round(t0 * fps), int(keep * fps)
+ print(f"keeping {t0:.2f}s to {t0 + keep:.2f}s")
+
+cap = open_at(first)
+ok, frame = cap.read()
+if not ok:
+ sys.exit(f"cannot read {src}")
+h, w = frame.shape[:2]
+s = min(w, h)
+tr = cv2.TrackerCSRT_create()
+cv2.namedWindow("select object", cv2.WINDOW_NORMAL) # resizable, tall frames fit the screen
+tr.init(frame, cv2.selectROI("select object", frame))
+cv2.destroyAllWindows()
+
+# pass 1: track centers (reuse last position when tracker loses it)
+cx, cy = [], []
+last = (w / 2, h / 2)
+while ok and len(cx) < count:
+ found, (x, y, bw, bh) = tr.update(frame)
+ if found:
+ last = (x + bw / 2, y + bh / 2)
+ cx.append(last[0]); cy.append(last[1])
+ ok, frame = cap.read()
+
+# smooth path to kill jitter (~0.5s moving average)
+k = max(int(fps // 2) | 1, 1)
+smooth = lambda a: np.convolve(np.pad(a, k // 2, mode="edge"), np.ones(k) / k, "valid")
+cx, cy = smooth(np.array(cx)), smooth(np.array(cy))
+
+# pass 2: crop and pipe to ffmpeg, keep original audio
+# ponytail: local ffmpeg has no libx264, encodes on GPU via VA-API; device path is hardcoded
+ff = subprocess.Popen(["ffmpeg", "-y", "-vaapi_device", "/dev/dri/renderD128",
+ "-f", "rawvideo", "-pix_fmt", "bgr24",
+ "-s", f"{s}x{s}", "-r", str(fps), "-i", "-",
+ *([] if mute else ["-ss", f"{first / fps}", "-t", f"{len(cx) / fps}", "-i", src,
+ "-map", "0:v", "-map", "1:a?", "-c:a", "copy"]),
+ "-vf", "format=nv12,hwupload", "-c:v", "h264_vaapi", "-qp", "20", dst],
+ stdin=subprocess.PIPE)
+cap = open_at(first)
+for x, y in zip(cx, cy):
+ ok, frame = cap.read()
+ if not ok:
+ break
+ x0 = int(np.clip(x - s / 2, 0, w - s))
+ y0 = int(np.clip(y - s / 2, 0, h - s))
+ ff.stdin.write(frame[y0:y0 + s, x0:x0 + s].tobytes())
+ff.stdin.close()
+ff.wait()