sixtyfive

sixtyfive / fpv-flight-section-selector.py

Last active 1 month ago

Like 0

Revision 07f870853925a0379ba7fba47f033812b6831eb3

fpv-flight-section-selector.py Raw
1#!/usr/bin/env python3
2
3import cv2
4import numpy as np
5import subprocess
6import os
7import random
8import sys
9from pathlib import Path
10
11VID1 = os.path.expanduser("VID00003.mov")
12VID2 = os.path.expanduser("VID00004.mov")
13AUDIO = os.path.expanduser("background.mp3")
14OUTPUT = os.path.expanduser("output.mp4")
15
16TARGET_DURATION = 2*60+41 # 2:41
17VID1_START = 9.0
18VID1_END = 209.0
19VID2_START = 126.0
20VID2_END = None
21
22MIN_CLIP_LEN = 1.5 # floor to avoid zero/negative-length clips after bounds clamping
23CHUNK_LENGTH = 10.0 # analysis window size, also used to keep clips out of the intro/outro
24FADE_DURATION = 0.5 # seconds, fade-to-black between clips
25
26
27def analyze_fpv_motion(video_path, start_offset, end_offset, chunk_length=CHUNK_LENGTH,
28 sample_stride=5, flow_size=(480, 270), max_corners=200):
29 """
30 Scores chunks by residual motion after compensating for global camera
31 motion (a RANSAC-fit affine transform between tracked features between
32 consecutive sampled frames). Steady forward flight produces large but
33 coherent flow that gets cancelled out; close obstacle passes, erratic
34 maneuvers, and parallax against nearby objects produce large residual
35 flow and score higher.
36 """
37 print(f"Analyzing motion in {Path(video_path).name} (range: {start_offset}s to {end_offset if end_offset else 'EOF'})...")
38 cap = cv2.VideoCapture(video_path)
39 fps = cap.get(cv2.CAP_PROP_FPS)
40 if not fps or fps <= 0:
41 fps = 30.0
42
43 cap.set(cv2.CAP_PROP_POS_MSEC, start_offset * 1000)
44 actual_start = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0
45 print(f" requested {start_offset}s, got {actual_start:.2f}s")
46
47 frames_per_chunk = max(1, int(fps * chunk_length))
48 feature_params = dict(maxCorners=max_corners, qualityLevel=0.01, minDistance=7, blockSize=7)
49 lk_params = dict(winSize=(21, 21), maxLevel=3,
50 criteria=(cv2.TERM_CRITERIA_EPS | cv2.TERM_CRITERIA_COUNT, 30, 0.01))
51
52 chunks = []
53 current_start = actual_start
54 residual_sum = 0.0
55 samples_in_chunk = 0
56 frame_count = 0
57
58 ret, prev_frame = cap.read()
59 if not ret:
60 cap.release()
61 return chunks
62
63 prev_gray = cv2.cvtColor(cv2.resize(prev_frame, flow_size), cv2.COLOR_BGR2GRAY)
64
65 def close_chunk():
66 if samples_in_chunk > 0:
67 chunks.append({
68 'start': current_start,
69 'score': residual_sum / samples_in_chunk, # normalized, not summed
70 })
71
72 while True:
73 current_pos_sec = cap.get(cv2.CAP_PROP_POS_MSEC) / 1000.0
74 if end_offset and current_pos_sec >= end_offset:
75 break
76
77 ret, frame = cap.read()
78 if not ret:
79 break
80
81 if frame_count % sample_stride == 0:
82 gray = cv2.cvtColor(cv2.resize(frame, flow_size), cv2.COLOR_BGR2GRAY)
83 residual = None
84
85 prev_pts = cv2.goodFeaturesToTrack(prev_gray, mask=None, **feature_params)
86 if prev_pts is not None and len(prev_pts) >= 8:
87 curr_pts, status, _ = cv2.calcOpticalFlowPyrLK(prev_gray, gray, prev_pts, None, **lk_params)
88 status = status.reshape(-1).astype(bool)
89 good_prev, good_curr = prev_pts[status], curr_pts[status]
90
91 if len(good_prev) >= 8:
92 M, _ = cv2.estimateAffinePartial2D(good_prev, good_curr, method=cv2.RANSAC,
93 ransacReprojThreshold=3.0)
94 if M is not None:
95 warped_prev = cv2.warpAffine(prev_gray, M, flow_size)
96 b = 12 # ignore border pixels warpAffine drags in from outside the frame
97 diff = cv2.absdiff(warped_prev[b:-b, b:-b], gray[b:-b, b:-b])
98 residual = float(np.mean(diff))
99
100 if residual is None:
101 # too few trackable features (e.g. featureless sky/whiteout) -
102 # fall back to raw frame diff for this sample
103 residual = float(np.mean(cv2.absdiff(prev_gray, gray)))
104
105 residual_sum += residual
106 samples_in_chunk += 1
107 prev_gray = gray
108
109 frame_count += 1
110
111 if frame_count >= frames_per_chunk or (end_offset and current_pos_sec >= end_offset):
112 close_chunk()
113 current_start = current_pos_sec
114 residual_sum = 0.0
115 samples_in_chunk = 0
116 frame_count = 0
117 if end_offset and current_pos_sec >= end_offset:
118 break
119
120 cap.release()
121 return sorted(chunks, key=lambda x: x['score'], reverse=True)
122
123
124def per_clip_fade(duration, base=FADE_DURATION):
125 # keep the fade from swallowing very short clips entirely
126 return max(0.05, min(base, duration / 4.0))
127
128
129def main():
130 vid1_chunks = analyze_fpv_motion(VID1, VID1_START, VID1_END)
131 vid2_chunks = analyze_fpv_motion(VID2, VID2_START, VID2_END)
132
133 if not vid1_chunks or not vid2_chunks:
134 print("Error: Could not extract valid chunks within the specified time bounds.")
135 sys.exit(1)
136
137 # --- reserve the intro (start of VID2) and outro (end of VID1) up front ---
138 intro_duration = max(MIN_CLIP_LEN, random.uniform(8.0, 12.0))
139 if VID2_END:
140 intro_duration = min(intro_duration, VID2_END - VID2_START)
141 intro_clip = {'file': VID2, 'start': VID2_START, 'duration': intro_duration, 'video': 2}
142 intro_end = VID2_START + intro_duration
143
144 outro_duration = max(MIN_CLIP_LEN, min(random.uniform(8.0, 12.0), VID1_END - VID1_START))
145 outro_start = VID1_END - outro_duration
146 outro_clip = {'file': VID1, 'start': outro_start, 'duration': outro_duration, 'video': 1}
147
148 # keep the general selection pool from reusing that same footage elsewhere:
149 # VID1 chunks are only usable up to outro_start, VID2 chunks only from intro_end onward
150 vid1_middle_end = outro_start
151 vid1_chunks = [c for c in vid1_chunks if c['start'] + CHUNK_LENGTH <= vid1_middle_end]
152 vid2_chunks = [c for c in vid2_chunks if c['start'] >= intro_end]
153
154 if not vid1_chunks or not vid2_chunks:
155 print("Error: no chunks left after excluding the intro/outro regions "
156 "(try a shorter intro/outro or a wider VID1_START/VID1_END/VID2_START/VID2_END range).")
157 sys.exit(1)
158
159 vid1_pool = list(vid1_chunks)
160 vid2_pool = list(vid2_chunks)
161
162 middle_target = max(0.0, TARGET_DURATION - intro_duration - outro_duration)
163 playlist = []
164 current_time = 0.0
165 turn = 1
166 stall_guard = 0
167
168 while current_time < middle_target:
169 clip_len = random.uniform(8.0, 12.0)
170 if current_time + clip_len > middle_target:
171 clip_len = middle_target - current_time
172
173 source_vid = VID1 if turn == 1 else VID2
174 active_pool = vid1_pool if turn == 1 else vid2_pool
175 backup_pool = vid1_chunks if turn == 1 else vid2_chunks
176 end_bound = vid1_middle_end if turn == 1 else VID2_END
177
178 if not active_pool:
179 active_pool.extend(backup_pool)
180
181 best_chunk = active_pool.pop(0)
182
183 if end_bound:
184 clip_len = min(clip_len, end_bound - best_chunk['start'])
185 if clip_len <= MIN_CLIP_LEN:
186 stall_guard += 1
187 if stall_guard > 500:
188 print("Warning: couldn't fill target duration within bounds, stopping early.")
189 break
190 continue
191 stall_guard = 0
192
193 playlist.append({
194 'file': source_vid,
195 'start': best_chunk['start'],
196 'duration': clip_len,
197 'video': turn,
198 })
199
200 current_time += clip_len
201 turn = 2 if turn == 1 else 1
202
203 # reorder each video's clips chronologically, preserving alternating slots
204 for vid_id, vid_start, vid_end in ((1, VID1_START, vid1_middle_end), (2, VID2_START, VID2_END)):
205 idxs = [i for i, c in enumerate(playlist) if c['video'] == vid_id]
206 for i, s in zip(idxs, sorted(playlist[i]['start'] for i in idxs)):
207 playlist[i]['start'] = max(vid_start, s)
208 if vid_end:
209 playlist[i]['duration'] = max(MIN_CLIP_LEN, min(playlist[i]['duration'], vid_end - playlist[i]['start']))
210
211 playlist = [intro_clip] + playlist + [outro_clip]
212
213 print("Building filtergraph with crossfade-to-black transitions...")
214 input_args = []
215 filter_parts = []
216 concat_labels = []
217
218 for idx, clip in enumerate(playlist):
219 input_args += ["-ss", f"{clip['start']:.3f}", "-t", f"{clip['duration']:.3f}", "-i", clip['file']]
220 fd = per_clip_fade(clip['duration'])
221 label = f"v{idx}"
222 filter_parts.append(
223 f"[{idx}:v]setpts=PTS-STARTPTS,"
224 f"fade=t=in:st=0:d={fd:.3f}:c=black,"
225 f"fade=t=out:st={max(0.0, clip['duration'] - fd):.3f}:d={fd:.3f}:c=black[{label}]"
226 )
227 concat_labels.append(f"[{label}]")
228
229 audio_input_index = len(playlist)
230 input_args += ["-i", AUDIO]
231
232 filter_parts.append(f"{''.join(concat_labels)}concat=n={len(playlist)}:v=1:a=0[vcat]")
233 filter_parts.append("[vcat]format=nv12,hwupload[vout]")
234 filter_complex = ";".join(filter_parts)
235
236 print("Rendering final video via AMD VAAPI...")
237 ffmpeg_cmd = [
238 "ffmpeg",
239 "-init_hw_device", "vaapi=foo:/dev/dri/renderD128",
240 "-filter_hw_device", "foo",
241 *input_args,
242 "-filter_complex", filter_complex,
243 "-map", "[vout]",
244 "-map", f"{audio_input_index}:a",
245 "-c:v", "h264_vaapi",
246 "-qp", "20",
247 "-c:a", "aac",
248 "-b:a", "192k",
249 "-shortest",
250 "-movflags", "+faststart",
251 "-y", OUTPUT
252 ]
253
254 subprocess.run(ffmpeg_cmd, check=True)
255 print(f"Done! Saved to {OUTPUT}")
256
257
258if __name__ == "__main__":
259 main()