| 1 | #!/usr/bin/env python3 |
| 2 | |
| 3 | import cv2 |
| 4 | import numpy as np |
| 5 | import subprocess |
| 6 | import os |
| 7 | import random |
| 8 | import sys |
| 9 | from pathlib import Path |
| 10 | |
| 11 | VID1 = os.path.expanduser("VID00003.mov") |
| 12 | VID2 = os.path.expanduser("VID00004.mov") |
| 13 | AUDIO = os.path.expanduser("background.mp3") |
| 14 | OUTPUT = os.path.expanduser("output.mp4") |
| 15 | |
| 16 | TARGET_DURATION = 2*60+41 # 2:41 |
| 17 | VID1_START = 9.0 |
| 18 | VID1_END = 209.0 |
| 19 | VID2_START = 126.0 |
| 20 | VID2_END = None |
| 21 | |
| 22 | MIN_CLIP_LEN = 1.5 # floor to avoid zero/negative-length clips after bounds clamping |
| 23 | CHUNK_LENGTH = 10.0 # analysis window size, also used to keep clips out of the intro/outro |
| 24 | FADE_DURATION = 0.5 # seconds, fade-to-black between clips |
| 25 | |
| 26 | |
| 27 | def 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 | |
| 124 | def 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 | |
| 129 | def 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 | |
| 258 | if __name__ == "__main__": |
| 259 | main() |
sixtyfive / fpv-flight-section-selector.py
Last active 1 month ago
Revision 07f870853925a0379ba7fba47f033812b6831eb3