LucaFranceschi commited on
Commit
b71d9de
·
unverified ·
1 Parent(s): 08c141a

Upload code for example videos modification

Browse files
Files changed (1) hide show
  1. utils/transform_videos.py +546 -0
utils/transform_videos.py ADDED
@@ -0,0 +1,546 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/usr/bin/env python3
2
+ """
3
+ Video Audio Transformation Script
4
+
5
+ This script duplicates videos from data/examples/original/ with modified audio:
6
+ - silence/: Same frames with zero audio
7
+ - noise/: Same frames with Gaussian noise audio
8
+ - offscreen/: Same frames with swapped audio from another video (round-robin)
9
+
10
+ Usage:
11
+ python transform_video_audio.py
12
+ """
13
+
14
+ import os
15
+ import cv2
16
+ import torch
17
+ import torchaudio
18
+ import numpy as np
19
+ import subprocess
20
+ import uuid
21
+ from pathlib import Path
22
+ from typing import Tuple, List, Optional
23
+ from tqdm import tqdm
24
+
25
+
26
+ # =========================================== CONSTANTS ===========================================
27
+
28
+ SAMPLE_RATE = 16000
29
+ INPUT_DIR = 'data/examples/original'
30
+ OUTPUT_DIRS = {
31
+ 'silence': 'data/examples/silence',
32
+ 'noise': 'data/examples/noise',
33
+ 'offscreen': 'data/examples/offscreen'
34
+ }
35
+
36
+ USE_CUDA = torch.cuda.is_available()
37
+ DEVICE = torch.device('cuda' if USE_CUDA else 'cpu')
38
+
39
+
40
+ # =========================================== UTILITY FUNCTIONS ===================================
41
+
42
+ def add_noise(
43
+ waveform: torch.Tensor,
44
+ noise: torch.Tensor,
45
+ snr: torch.Tensor,
46
+ lengths: Optional[torch.Tensor] = None
47
+ ) -> torch.Tensor:
48
+ """
49
+ Scales and adds noise to waveform per signal-to-noise ratio.
50
+ Backported from TorchAudio functional.
51
+
52
+ Args:
53
+ waveform: Input waveform with shape (..., L)
54
+ noise: Noise tensor with same shape as waveform
55
+ snr: Signal-to-noise ratio in dB
56
+ lengths: Valid lengths of signals (optional)
57
+
58
+ Returns:
59
+ torch.Tensor: Waveform with added noise
60
+ """
61
+ # Compute power of waveform and noise
62
+ power_waveform = (waveform ** 2).mean(dim=-1)
63
+ power_noise = (noise ** 2).mean(dim=-1)
64
+
65
+ # Avoid division by zero
66
+ power_noise = torch.where(
67
+ power_noise == 0,
68
+ torch.ones_like(power_noise),
69
+ power_noise
70
+ )
71
+
72
+ # Calculate scaling factor
73
+ snr_db = snr.reshape(power_waveform.shape)
74
+ snr_linear = 10.0 ** (snr_db / 10.0)
75
+
76
+ # Compute scaling factor for noise
77
+ scale = torch.sqrt(power_waveform / power_noise / snr_linear)
78
+
79
+ # Reshape scale for broadcasting
80
+ while len(scale.shape) < len(noise.shape):
81
+ scale = scale.unsqueeze(-1)
82
+
83
+ # Add scaled noise to waveform
84
+ return waveform + scale * noise
85
+
86
+
87
+ class AddRandomNoise(torch.nn.Module):
88
+ """Add Gaussian noise to audio with SNR control"""
89
+
90
+ def __init__(self, snr: float = None):
91
+ """
92
+ Args:
93
+ snr: Signal-to-noise ratio in dB. If None, uses high value (minimal noise)
94
+ """
95
+ super().__init__()
96
+ if snr is not None:
97
+ self.snr = torch.Tensor([snr])
98
+ else:
99
+ self.snr = torch.Tensor([1000.0]) # High value = no noise
100
+
101
+ def forward(self, waveform: torch.Tensor) -> torch.Tensor:
102
+ """
103
+ Args:
104
+ waveform: Input audio tensor with shape (L,) or (C, L)
105
+
106
+ Returns:
107
+ torch.Tensor: Audio with added noise
108
+ """
109
+ if len(waveform.shape) == 1:
110
+ waveform = waveform.unsqueeze(0)
111
+
112
+ noise = torch.clip(torch.randn(waveform.shape), min=-1., max=1.)
113
+ noisy_waveform = add_noise(waveform, noise, self.snr, None)
114
+
115
+ return noisy_waveform.squeeze(0) if noisy_waveform.shape[0] == 1 else noisy_waveform
116
+
117
+
118
+ # =========================================== VIDEO/AUDIO FUNCTIONS ==============================
119
+
120
+ def extract_video_frames(
121
+ video_path: str,
122
+ resolution: Optional[Tuple[int, int]] = None
123
+ ) -> Tuple[List[np.ndarray], Tuple[int, int], float]:
124
+ """
125
+ Extract frames from video file.
126
+
127
+ Args:
128
+ video_path: Path to video file
129
+ resolution: Target resolution (width, height). If None, uses original
130
+
131
+ Returns:
132
+ Tuple of (frames_list, original_resolution, fps)
133
+ """
134
+ video = cv2.VideoCapture(video_path)
135
+
136
+ if not video.isOpened():
137
+ raise ValueError(f"Could not open video: {video_path}")
138
+
139
+ original_resolution = (
140
+ int(video.get(cv2.CAP_PROP_FRAME_WIDTH)),
141
+ int(video.get(cv2.CAP_PROP_FRAME_HEIGHT))
142
+ )
143
+ fps = video.get(cv2.CAP_PROP_FPS)
144
+
145
+ frames = []
146
+ while True:
147
+ ret, frame = video.read()
148
+ if not ret:
149
+ break
150
+
151
+ if resolution and resolution != original_resolution:
152
+ frame = cv2.resize(frame, resolution)
153
+
154
+ frames.append(frame)
155
+
156
+ video.release()
157
+
158
+ return frames, original_resolution, fps
159
+
160
+
161
+ def extract_audio_from_video(
162
+ video_path: str,
163
+ output_audio_path: str,
164
+ sample_rate: int = SAMPLE_RATE
165
+ ) -> str:
166
+ """
167
+ Extract audio from video using ffmpeg.
168
+
169
+ Args:
170
+ video_path: Path to video file
171
+ output_audio_path: Path to save extracted audio
172
+ sample_rate: Target sample rate in Hz
173
+
174
+ Returns:
175
+ Path to extracted audio file
176
+ """
177
+ try:
178
+ subprocess.run(
179
+ [
180
+ 'ffmpeg', '-i', video_path, '-vn', '-acodec', 'pcm_s16le',
181
+ '-ar', str(sample_rate), '-ac', '1', '-y', output_audio_path
182
+ ],
183
+ capture_output=True,
184
+ check=True,
185
+ timeout=300
186
+ )
187
+ except subprocess.CalledProcessError as e:
188
+ raise RuntimeError(f"FFmpeg error for {video_path}: {e.stderr.decode()}")
189
+ except FileNotFoundError:
190
+ raise RuntimeError("FFmpeg not found. Please install ffmpeg.")
191
+
192
+ return output_audio_path
193
+
194
+
195
+ def load_audio(audio_path: str, sample_rate: int = SAMPLE_RATE) -> torch.Tensor:
196
+ """
197
+ Load audio from file and resample to target sample rate.
198
+
199
+ Args:
200
+ audio_path: Path to audio file
201
+ sample_rate: Target sample rate in Hz
202
+
203
+ Returns:
204
+ torch.Tensor: Audio tensor with shape (num_samples,)
205
+ """
206
+ audio, sr = torchaudio.load(audio_path)
207
+
208
+ # Resample if needed
209
+ if sr != sample_rate:
210
+ resampler = torchaudio.transforms.Resample(sr, sample_rate)
211
+ audio = resampler(audio)
212
+
213
+ # Convert to mono if stereo
214
+ if audio.shape[0] > 1:
215
+ audio = audio.mean(dim=0)
216
+
217
+ return audio.squeeze(0)
218
+
219
+
220
+ def save_audio(audio: torch.Tensor, output_path: str, sample_rate: int = SAMPLE_RATE) -> str:
221
+ """
222
+ Save audio tensor to file.
223
+
224
+ Args:
225
+ audio: torch.Tensor with shape (num_samples,)
226
+ output_path: Path to save audio
227
+ sample_rate: Sample rate in Hz
228
+
229
+ Returns:
230
+ Path to saved audio file
231
+ """
232
+ # Ensure audio is in correct format
233
+ if len(audio.shape) == 1:
234
+ audio = audio.unsqueeze(0)
235
+
236
+ # Clip values to valid range
237
+ audio = torch.clamp(audio, -1.0, 1.0)
238
+
239
+ torchaudio.save(output_path, audio, sample_rate)
240
+ return output_path
241
+
242
+
243
+ def save_video(
244
+ frames: List[np.ndarray],
245
+ audio_path: str,
246
+ output_video_path: str,
247
+ fps: float = 30.0
248
+ ) -> str:
249
+ """
250
+ Save video frames with audio using ffmpeg.
251
+
252
+ Args:
253
+ frames: List of frames (numpy arrays)
254
+ audio_path: Path to audio file
255
+ output_video_path: Path to save output video
256
+ fps: Frames per second
257
+
258
+ Returns:
259
+ Path to saved video file
260
+ """
261
+ if not frames:
262
+ raise ValueError("No frames to save")
263
+
264
+ # Get frame dimensions
265
+ height, width = frames[0].shape[:2]
266
+
267
+ # Create temporary AVI file
268
+ temp_video_path = os.path.join(
269
+ os.path.dirname(output_video_path),
270
+ f"temp_{uuid.uuid4()}.avi"
271
+ )
272
+
273
+ # Write frames to AVI
274
+ fourcc = cv2.VideoWriter.fourcc(*'MJPG')
275
+ video_writer = cv2.VideoWriter(temp_video_path, fourcc, fps, (width, height))
276
+
277
+ if not video_writer.isOpened():
278
+ raise RuntimeError(f"Could not create video writer at {temp_video_path}")
279
+
280
+ for frame in frames:
281
+ # Ensure frame is in BGR format
282
+ if len(frame.shape) == 2: # Grayscale
283
+ frame = cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
284
+ elif frame.shape[2] == 3: # RGB
285
+ # frame = cv2.cvtColor(frame, cv2.COLOR_RGB2BGR)
286
+ pass
287
+
288
+ video_writer.write(frame)
289
+
290
+ video_writer.release()
291
+
292
+ # Merge video and audio using ffmpeg
293
+ os.makedirs(os.path.dirname(output_video_path), exist_ok=True)
294
+
295
+ try:
296
+ subprocess.run(
297
+ [
298
+ 'ffmpeg', '-y', '-i', temp_video_path, '-i', audio_path,
299
+ '-c:v', 'libx264', '-preset', 'fast', '-crf', '23',
300
+ '-c:a', 'aac', '-map', '0:v:0', '-map', '1:a:0',
301
+ output_video_path
302
+ ],
303
+ capture_output=True,
304
+ check=True,
305
+ timeout=600
306
+ )
307
+ except subprocess.CalledProcessError as e:
308
+ raise RuntimeError(f"FFmpeg error during merge: {e.stderr.decode()}")
309
+ finally:
310
+ # Clean up temporary file
311
+ if os.path.exists(temp_video_path):
312
+ os.remove(temp_video_path)
313
+
314
+ return output_video_path
315
+
316
+
317
+ # =========================================== AUDIO MODIFICATION FUNCTIONS =======================
318
+
319
+ def create_silence_audio(duration_samples: int, sample_rate: int = SAMPLE_RATE) -> torch.Tensor:
320
+ """
321
+ Create silence (zero) audio.
322
+
323
+ Args:
324
+ duration_samples: Number of samples
325
+ sample_rate: Sample rate in Hz (for reference)
326
+
327
+ Returns:
328
+ torch.Tensor: Silent audio
329
+ """
330
+ return torch.zeros(duration_samples)
331
+
332
+
333
+ def create_noise_audio(
334
+ duration_samples: int,
335
+ snr_db: float = 10.0,
336
+ sample_rate: int = SAMPLE_RATE
337
+ ) -> torch.Tensor:
338
+ """
339
+ Create Gaussian noise audio.
340
+
341
+ Args:
342
+ duration_samples: Number of samples
343
+ snr_db: Signal-to-noise ratio in dB (for reference, not used for pure noise)
344
+ sample_rate: Sample rate in Hz (for reference)
345
+
346
+ Returns:
347
+ torch.Tensor: Noise audio
348
+ """
349
+ # Generate pure Gaussian noise
350
+ noise = torch.randn(duration_samples)
351
+
352
+ # Normalize to reasonable amplitude
353
+ noise = noise / (torch.std(noise) + 1e-8)
354
+ noise = torch.clamp(noise * 0.1, -1.0, 1.0) # Scale to reasonable amplitude
355
+
356
+ return noise
357
+
358
+
359
+ def swap_audio_round_robin(
360
+ audio_list: List[torch.Tensor],
361
+ video_indices: List[int]
362
+ ) -> List[torch.Tensor]:
363
+ """
364
+ Swap audio between videos in round-robin fashion.
365
+ Video i gets audio from video (i+1) % n_videos.
366
+
367
+ Args:
368
+ audio_list: List of audio tensors
369
+ video_indices: Original indices of videos
370
+
371
+ Returns:
372
+ List[torch.Tensor]: Swapped audio list
373
+ """
374
+ n_videos = len(audio_list)
375
+ swapped_audio = [None] * n_videos
376
+
377
+ for i in range(n_videos):
378
+ # Video i gets audio from video (i+1) % n_videos
379
+ source_idx = (i + 1) % n_videos
380
+
381
+ # Pad/trim audio to match duration if needed
382
+ target_duration = audio_list[i].shape[0]
383
+ source_audio = audio_list[source_idx]
384
+
385
+ if source_audio.shape[0] < target_duration:
386
+ # Pad with silence
387
+ padding = target_duration - source_audio.shape[0]
388
+ source_audio = torch.cat([
389
+ source_audio,
390
+ torch.zeros(padding)
391
+ ])
392
+ elif source_audio.shape[0] > target_duration:
393
+ # Trim
394
+ source_audio = source_audio[:target_duration]
395
+
396
+ swapped_audio[i] = source_audio
397
+
398
+ return swapped_audio
399
+
400
+
401
+ # =========================================== MAIN PROCESSING FUNCTION ===========================
402
+
403
+ def process_videos():
404
+ """
405
+ Main function to process all videos in input directory.
406
+ Creates modified versions with silence, noise, and swapped audio.
407
+ """
408
+ # Create output directories
409
+ for output_dir in OUTPUT_DIRS.values():
410
+ os.makedirs(output_dir, exist_ok=True)
411
+
412
+ # Find all video files
413
+ input_path = Path(INPUT_DIR)
414
+ video_files = sorted([
415
+ f for f in input_path.glob('*')
416
+ if f.is_file() and f.suffix.lower() in ['.mp4', '.avi', '.mov', '.mkv']
417
+ ])
418
+
419
+ if not video_files:
420
+ print(f"No video files found in {INPUT_DIR}")
421
+ return
422
+
423
+ print(f"Found {len(video_files)} video(s) to process")
424
+ print(f"Output directories:")
425
+ for mode, path in OUTPUT_DIRS.items():
426
+ print(f" - {mode}: {path}")
427
+ print()
428
+
429
+ # Load all videos and audio
430
+ print("Loading videos and audio...")
431
+ video_data = []
432
+ audio_list = []
433
+
434
+ for idx, video_path in enumerate(tqdm(video_files, desc="Loading videos")):
435
+ try:
436
+ # Extract frames and metadata
437
+ frames, original_resolution, fps = extract_video_frames(str(video_path))
438
+
439
+ # Extract audio
440
+ temp_audio_path = f"/tmp/temp_audio_{uuid.uuid4()}.wav"
441
+ extract_audio_from_video(str(video_path), temp_audio_path)
442
+ audio = load_audio(temp_audio_path)
443
+
444
+ video_data.append({
445
+ 'path': video_path,
446
+ 'filename': video_path.stem,
447
+ 'frames': frames,
448
+ 'resolution': original_resolution,
449
+ 'fps': fps
450
+ })
451
+ audio_list.append(audio)
452
+
453
+ # Clean up temporary audio
454
+ if os.path.exists(temp_audio_path):
455
+ os.remove(temp_audio_path)
456
+
457
+ except Exception as e:
458
+ print(f"Error processing {video_path}: {e}")
459
+ continue
460
+
461
+ if not video_data:
462
+ print("No videos were successfully loaded")
463
+ return
464
+
465
+ print(f"Successfully loaded {len(video_data)} video(s)\n")
466
+
467
+ # Get duration for all audio files (in samples)
468
+ audio_durations = [audio.shape[0] for audio in audio_list]
469
+
470
+ # Process each video
471
+ print("Processing videos with audio modifications...")
472
+
473
+ for idx, (data, original_audio) in enumerate(tqdm(
474
+ zip(video_data, audio_list),
475
+ total=len(video_data),
476
+ desc="Processing"
477
+ )):
478
+ filename = data['filename']
479
+ output_ext = '.mp4'
480
+
481
+ # Create temporary directory for intermediate files
482
+ temp_dir = f"/tmp/video_processing_{uuid.uuid4()}"
483
+ os.makedirs(temp_dir, exist_ok=True)
484
+
485
+ try:
486
+ # ============= SILENCE MODE =============
487
+ silence_audio = create_silence_audio(original_audio.shape[0])
488
+ temp_silence_audio = os.path.join(temp_dir, 'silence_audio.wav')
489
+ save_audio(silence_audio, temp_silence_audio)
490
+
491
+ silence_output = os.path.join(
492
+ OUTPUT_DIRS['silence'],
493
+ f"{filename}{output_ext}"
494
+ )
495
+ save_video(data['frames'], temp_silence_audio, silence_output, data['fps'])
496
+
497
+ # ============= NOISE MODE =============
498
+ noise_audio = create_noise_audio(original_audio.shape[0])
499
+ temp_noise_audio = os.path.join(temp_dir, 'noise_audio.wav')
500
+ save_audio(noise_audio, temp_noise_audio)
501
+
502
+ noise_output = os.path.join(
503
+ OUTPUT_DIRS['noise'],
504
+ f"{filename}{output_ext}"
505
+ )
506
+ save_video(data['frames'], temp_noise_audio, noise_output, data['fps'])
507
+
508
+ # ============= OFFSCREEN MODE (SWAPPED AUDIO) =============
509
+ # Prepare audio list for swapping
510
+ swapped_audios = swap_audio_round_robin(audio_list, list(range(len(audio_list))))
511
+ swapped_audio = swapped_audios[idx]
512
+
513
+ temp_swapped_audio = os.path.join(temp_dir, 'swapped_audio.wav')
514
+ save_audio(swapped_audio, temp_swapped_audio)
515
+
516
+ offscreen_output = os.path.join(
517
+ OUTPUT_DIRS['offscreen'],
518
+ f"{filename}{output_ext}"
519
+ )
520
+ save_video(data['frames'], temp_swapped_audio, offscreen_output, data['fps'])
521
+
522
+ except Exception as e:
523
+ print(f"Error processing {filename}: {e}")
524
+
525
+ finally:
526
+ # Clean up temporary files
527
+ if os.path.exists(temp_dir):
528
+ import shutil
529
+ shutil.rmtree(temp_dir)
530
+
531
+ print("\n✓ Video processing complete!")
532
+ print(f"\nOutput summary:")
533
+ for mode, output_dir in OUTPUT_DIRS.items():
534
+ output_count = len(list(Path(output_dir).glob('*')))
535
+ print(f" - {mode}: {output_count} video(s)")
536
+
537
+
538
+ # =========================================== ENTRY POINT ========================================
539
+
540
+ if __name__ == '__main__':
541
+ print("=" * 60)
542
+ print("Video Audio Transformation Script")
543
+ print("=" * 60)
544
+ print()
545
+
546
+ process_videos()