tushar310 commited on
Commit
1ce4e4b
·
verified ·
1 Parent(s): 744837f
Files changed (4) hide show
  1. README.md +26 -6
  2. app.py +139 -0
  3. pipeline.py +263 -0
  4. requirements.txt +6 -0
README.md CHANGED
@@ -1,12 +1,32 @@
1
  ---
2
- title: Face Coordinates Extraction
3
- emoji: 👀
4
- colorFrom: red
5
- colorTo: blue
6
  sdk: gradio
7
- sdk_version: 6.6.0
8
  app_file: app.py
 
9
  pinned: false
10
  ---
11
 
12
- Check out the configuration reference at https://huggingface.co/docs/hub/spaces-config-reference
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  ---
2
+ title: Dub Module Step1-Step3 App
 
 
 
3
  sdk: gradio
 
4
  app_file: app.py
5
+ python_version: "3.10"
6
  pinned: false
7
  ---
8
 
9
+ # Dub Module Gradio App (Step 1 + Step 3)
10
+
11
+ This folder creates a new Gradio app based on the workflow described in [`how_to.txt`](../how_to.txt).
12
+
13
+ Implemented workflow:
14
+ - Step 1 (in app): Upload main video, extract cropped face video, save `face_coords_avg.pkl`, and download both outputs.
15
+ - Step 2 (manual): Not part of app.
16
+ - Step 3 (in app): Upload original video + synced face video + `face_coords_avg.pkl` to generate final output video.
17
+
18
+ Notes:
19
+ - Audio upload is intentionally removed. The app attempts to use audio from the synced face video.
20
+ - Generated runtime files are stored under `work/`.
21
+
22
+ ## Files
23
+ - `app.py`: Gradio UI for Step 1 and Step 3.
24
+ - `pipeline.py`: Face extraction, coordinate generation, merging, and audio muxing.
25
+ - `requirements.txt`: Package versions known to work.
26
+
27
+ ## Run
28
+
29
+ ```bash
30
+ pip install -r requirements.txt
31
+ python app.py
32
+ ```
app.py ADDED
@@ -0,0 +1,139 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+
3
+ import gradio as gr
4
+
5
+ from pipeline import (
6
+ copy_file_to_dir,
7
+ extract_face_and_coords,
8
+ make_run_dir,
9
+ merge_synced_face,
10
+ )
11
+
12
+ BASE_DIR = Path(__file__).resolve().parent
13
+ WORK_DIR = BASE_DIR / "work"
14
+ WORK_DIR.mkdir(parents=True, exist_ok=True)
15
+
16
+
17
+ def _normalize_upload_path(file_obj):
18
+ if file_obj is None:
19
+ return None
20
+ if isinstance(file_obj, str):
21
+ return file_obj
22
+ return str(file_obj)
23
+
24
+
25
+ def run_step1(main_video):
26
+ try:
27
+ main_path = _normalize_upload_path(main_video)
28
+ if not main_path:
29
+ raise ValueError("Please upload the main/original video.")
30
+
31
+ run_dir = make_run_dir(WORK_DIR, "step1")
32
+ local_main = copy_file_to_dir(main_path, run_dir, "main_video.mp4")
33
+
34
+ coords_path, cropped_face_path, bbox = extract_face_and_coords(
35
+ video_path=str(local_main),
36
+ output_dir=str(run_dir),
37
+ coords_name="face_coords_avg.pkl",
38
+ cropped_name="cropped_face.mp4",
39
+ )
40
+
41
+ status = f"Step 1 completed. Face bbox saved: {bbox}"
42
+ return status, cropped_face_path, cropped_face_path, coords_path
43
+ except Exception as exc:
44
+ return f"Step 1 failed: {exc}", None, None, None
45
+
46
+
47
+ def run_step3(main_video, synced_face_video, face_coords):
48
+ try:
49
+ main_path = _normalize_upload_path(main_video)
50
+ synced_path = _normalize_upload_path(synced_face_video)
51
+ coords_path = _normalize_upload_path(face_coords)
52
+
53
+ if not main_path:
54
+ raise ValueError("Please upload the original/main video.")
55
+ if not synced_path:
56
+ raise ValueError("Please upload the synced face video from manual Step 2.")
57
+ if not coords_path:
58
+ raise ValueError("Please upload face coordinates (.pkl) from Step 1.")
59
+
60
+ run_dir = make_run_dir(WORK_DIR, "step3")
61
+ local_main = copy_file_to_dir(main_path, run_dir, "original_video.mp4")
62
+ local_synced = copy_file_to_dir(synced_path, run_dir, "synced_face_video.mp4")
63
+ local_coords = copy_file_to_dir(coords_path, run_dir, "face_coords_avg.pkl")
64
+
65
+ final_path = run_dir / "final_output_with_audio.mp4"
66
+ output_path, audio_used = merge_synced_face(
67
+ original_video_path=str(local_main),
68
+ synced_face_video_path=str(local_synced),
69
+ face_coords_path=str(local_coords),
70
+ final_output_path=str(final_path),
71
+ )
72
+
73
+ if audio_used == "synced_face_video":
74
+ status = "Step 3 completed. Final video generated with audio from synced face video."
75
+ else:
76
+ status = "Step 3 completed. Final video generated without muxed audio (audio track not found)."
77
+
78
+ return status, output_path, output_path
79
+ except Exception as exc:
80
+ return f"Step 3 failed: {exc}", None, None
81
+
82
+
83
+ with gr.Blocks(title="Dub Module - Step 1 and Step 3") as demo:
84
+ gr.Markdown(
85
+ """
86
+ # Dub Module Gradio App (Step 1 + Step 3)
87
+ Workflow follows `how_to.txt` in this repo with these app boundaries:
88
+ - Step 1 is in-app: extract cropped face + `face_coords_avg.pkl`.
89
+ - Step 2 is manual and outside the app.
90
+ - Step 3 is in-app: merge synced face video back to original and produce final video.
91
+ - Separate audio upload is skipped because synced face video audio is used.
92
+ """
93
+ )
94
+
95
+ with gr.Tab("Step 1 - Extract Face + Coordinates"):
96
+ gr.Markdown("Upload the main video to generate cropped face video and face coordinates.")
97
+ s1_video = gr.File(label="Main Video", file_types=["video"], type="filepath")
98
+ s1_run = gr.Button("Run Step 1")
99
+ s1_status = gr.Textbox(label="Status", interactive=False)
100
+ s1_preview = gr.Video(label="Cropped Face Preview")
101
+ s1_face_file = gr.File(label="Download Cropped Face Video")
102
+ s1_coords_file = gr.File(label="Download Face Coordinates (.pkl)")
103
+
104
+ s1_run.click(
105
+ fn=run_step1,
106
+ inputs=[s1_video],
107
+ outputs=[s1_status, s1_preview, s1_face_file, s1_coords_file],
108
+ )
109
+
110
+ with gr.Tab("Step 2 - Manual (Outside App)"):
111
+ gr.Markdown(
112
+ """
113
+ Do manual lip-sync generation outside this app using the Step 1 cropped face video.
114
+ Then return to Step 3 tab with:
115
+ 1. Original main video
116
+ 2. Synced face video (with audio)
117
+ 3. `face_coords_avg.pkl`
118
+ """
119
+ )
120
+
121
+ with gr.Tab("Step 3 - Merge and Final Video"):
122
+ gr.Markdown("Upload inputs from Step 1 and manual Step 2 to generate final output video.")
123
+ s3_main_video = gr.File(label="Original Main Video", file_types=["video"], type="filepath")
124
+ s3_synced_video = gr.File(label="Synced Face Video", file_types=["video"], type="filepath")
125
+ s3_coords = gr.File(label="Face Coordinates (.pkl)", file_types=[".pkl"], type="filepath")
126
+ s3_run = gr.Button("Run Step 3")
127
+ s3_status = gr.Textbox(label="Status", interactive=False)
128
+ s3_preview = gr.Video(label="Final Output Preview")
129
+ s3_file = gr.File(label="Download Final Video")
130
+
131
+ s3_run.click(
132
+ fn=run_step3,
133
+ inputs=[s3_main_video, s3_synced_video, s3_coords],
134
+ outputs=[s3_status, s3_preview, s3_file],
135
+ )
136
+
137
+
138
+ if __name__ == "__main__":
139
+ demo.launch()
pipeline.py ADDED
@@ -0,0 +1,263 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import pickle
2
+ import shutil
3
+ import subprocess
4
+ import uuid
5
+ from pathlib import Path
6
+ from typing import Optional, Sequence, Tuple
7
+
8
+ import cv2
9
+ import imageio_ffmpeg
10
+ import numpy as np
11
+
12
+
13
+ def _create_face_mesh():
14
+ try:
15
+ from mediapipe.python.solutions.face_mesh import FaceMesh
16
+ except Exception:
17
+ import mediapipe as mp
18
+
19
+ FaceMesh = mp.solutions.face_mesh.FaceMesh
20
+
21
+ return FaceMesh(
22
+ static_image_mode=False,
23
+ max_num_faces=1,
24
+ refine_landmarks=True,
25
+ min_detection_confidence=0.8,
26
+ )
27
+
28
+
29
+ def ensure_dir(path: Path) -> Path:
30
+ path.mkdir(parents=True, exist_ok=True)
31
+ return path
32
+
33
+
34
+ def make_run_dir(base_dir: Path, prefix: str) -> Path:
35
+ run_dir = ensure_dir(base_dir) / f"{prefix}_{uuid.uuid4().hex}"
36
+ return ensure_dir(run_dir)
37
+
38
+
39
+ def copy_file_to_dir(source_path: str, target_dir: Path, target_name: Optional[str] = None) -> Path:
40
+ source = Path(source_path)
41
+ if not source.exists():
42
+ raise FileNotFoundError(f"Input file not found: {source_path}")
43
+
44
+ if target_name is None:
45
+ target_name = source.name
46
+
47
+ target_path = target_dir / target_name
48
+ shutil.copy2(source, target_path)
49
+ return target_path
50
+
51
+
52
+ def get_bbox(
53
+ landmarks,
54
+ indices: Sequence[int],
55
+ iw: int,
56
+ ih: int,
57
+ scale_w: float = 1.2,
58
+ scale_h: float = 1.2,
59
+ ) -> Tuple[int, int, int, int]:
60
+ coords = [(landmarks[i].x * iw, landmarks[i].y * ih) for i in indices]
61
+ x_min, y_min = np.min(coords, axis=0)
62
+ x_max, y_max = np.max(coords, axis=0)
63
+
64
+ w = x_max - x_min
65
+ h = y_max - y_min
66
+ new_w = int(w * scale_w)
67
+ new_h = int(h * scale_h)
68
+
69
+ x = max(0, int(x_min - (new_w - w) // 2))
70
+ y = max(0, int(y_min - (new_h - h) // 2))
71
+ new_w = min(new_w, iw - x)
72
+ new_h = min(new_h, ih - y)
73
+
74
+ return (x, y, new_w, new_h)
75
+
76
+
77
+ def _load_coords(coords_path: str) -> Tuple[int, int, int, int]:
78
+ with open(coords_path, "rb") as handle:
79
+ coords = pickle.load(handle)
80
+
81
+ if len(coords) != 4:
82
+ raise ValueError(f"Invalid coordinates in {coords_path}: expected 4 values, got {len(coords)}")
83
+
84
+ return tuple(int(v) for v in coords)
85
+
86
+
87
+ def extract_face_and_coords(
88
+ video_path: str,
89
+ output_dir: str,
90
+ coords_name: str = "face_coords_avg.pkl",
91
+ cropped_name: str = "cropped_face.mp4",
92
+ ) -> Tuple[str, str, Tuple[int, int, int, int]]:
93
+ output_root = ensure_dir(Path(output_dir))
94
+ coords_out = output_root / coords_name
95
+ cropped_out = output_root / cropped_name
96
+
97
+ cap = cv2.VideoCapture(video_path)
98
+ if not cap.isOpened():
99
+ raise ValueError(f"Could not open video: {video_path}")
100
+
101
+ face_mesh = _create_face_mesh()
102
+ face_bbox_list = []
103
+
104
+ frame_w = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH))
105
+ frame_h = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
106
+ fps = cap.get(cv2.CAP_PROP_FPS)
107
+ if fps <= 0:
108
+ fps = 25.0
109
+
110
+ while cap.isOpened():
111
+ ret, frame = cap.read()
112
+ if not ret:
113
+ break
114
+
115
+ image_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB)
116
+ results = face_mesh.process(image_rgb)
117
+
118
+ if results.multi_face_landmarks:
119
+ for face_landmarks in results.multi_face_landmarks:
120
+ ih, iw, _ = frame.shape
121
+ face_bbox = get_bbox(
122
+ face_landmarks.landmark,
123
+ range(len(face_landmarks.landmark)),
124
+ iw,
125
+ ih,
126
+ scale_w=1.2,
127
+ scale_h=1.2,
128
+ )
129
+ face_bbox_list.append(face_bbox)
130
+
131
+ cap.release()
132
+ face_mesh.close()
133
+
134
+ if not face_bbox_list:
135
+ raise ValueError("No faces detected in the video. Check framing and quality.")
136
+
137
+ avg_face_bbox = np.mean(np.array(face_bbox_list), axis=0).astype(int)
138
+ x, y, w, h = (int(v) for v in avg_face_bbox)
139
+
140
+ x = max(0, min(x, frame_w - 1))
141
+ y = max(0, min(y, frame_h - 1))
142
+ w = max(1, min(w, frame_w - x))
143
+ h = max(1, min(h, frame_h - y))
144
+ final_bbox = (x, y, w, h)
145
+
146
+ with open(coords_out, "wb") as handle:
147
+ pickle.dump(final_bbox, handle)
148
+
149
+ cap = cv2.VideoCapture(video_path)
150
+ if not cap.isOpened():
151
+ raise ValueError(f"Could not reopen video for cropping: {video_path}")
152
+
153
+ out = cv2.VideoWriter(
154
+ str(cropped_out),
155
+ cv2.VideoWriter_fourcc(*"mp4v"),
156
+ fps,
157
+ (w, h),
158
+ )
159
+
160
+ frames_written = 0
161
+ while cap.isOpened():
162
+ ret, frame = cap.read()
163
+ if not ret:
164
+ break
165
+ face_img = frame[y:y + h, x:x + w]
166
+ out.write(face_img)
167
+ frames_written += 1
168
+
169
+ cap.release()
170
+ out.release()
171
+
172
+ if frames_written == 0:
173
+ raise ValueError("No frames were written for cropped face output.")
174
+
175
+ return str(coords_out), str(cropped_out), final_bbox
176
+
177
+
178
+ def _mux_audio(video_no_audio: str, audio_source: str, output_path: str) -> bool:
179
+ ffmpeg_exe = imageio_ffmpeg.get_ffmpeg_exe()
180
+ cmd = [
181
+ ffmpeg_exe,
182
+ "-y",
183
+ "-i",
184
+ video_no_audio,
185
+ "-i",
186
+ audio_source,
187
+ "-map",
188
+ "0:v:0",
189
+ "-map",
190
+ "1:a:0",
191
+ "-c:v",
192
+ "copy",
193
+ "-c:a",
194
+ "aac",
195
+ "-shortest",
196
+ output_path,
197
+ ]
198
+ result = subprocess.run(cmd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True)
199
+ return result.returncode == 0 and Path(output_path).exists()
200
+
201
+
202
+ def merge_synced_face(
203
+ original_video_path: str,
204
+ synced_face_video_path: str,
205
+ face_coords_path: str,
206
+ final_output_path: str,
207
+ ) -> Tuple[str, str]:
208
+ x, y, w, h = _load_coords(face_coords_path)
209
+
210
+ original_cap = cv2.VideoCapture(original_video_path)
211
+ synced_cap = cv2.VideoCapture(synced_face_video_path)
212
+
213
+ if not original_cap.isOpened():
214
+ raise ValueError(f"Could not open original video: {original_video_path}")
215
+ if not synced_cap.isOpened():
216
+ raise ValueError(f"Could not open synced face video: {synced_face_video_path}")
217
+
218
+ fps = original_cap.get(cv2.CAP_PROP_FPS)
219
+ if fps <= 0:
220
+ fps = 25.0
221
+
222
+ frame_w = int(original_cap.get(cv2.CAP_PROP_FRAME_WIDTH))
223
+ frame_h = int(original_cap.get(cv2.CAP_PROP_FRAME_HEIGHT))
224
+
225
+ x = max(0, min(x, frame_w - 1))
226
+ y = max(0, min(y, frame_h - 1))
227
+ w = max(1, min(w, frame_w - x))
228
+ h = max(1, min(h, frame_h - y))
229
+
230
+ intermediate_path = str(Path(final_output_path).with_name("merged_no_audio.mp4"))
231
+ out = cv2.VideoWriter(
232
+ intermediate_path,
233
+ cv2.VideoWriter_fourcc(*"mp4v"),
234
+ fps,
235
+ (frame_w, frame_h),
236
+ )
237
+
238
+ frames_written = 0
239
+ while original_cap.isOpened():
240
+ ret_o, original_frame = original_cap.read()
241
+ if not ret_o:
242
+ break
243
+
244
+ ret_s, synced_frame = synced_cap.read()
245
+ if ret_s:
246
+ synced_resized = cv2.resize(synced_frame, (w, h))
247
+ original_frame[y:y + h, x:x + w] = synced_resized
248
+
249
+ out.write(original_frame)
250
+ frames_written += 1
251
+
252
+ original_cap.release()
253
+ synced_cap.release()
254
+ out.release()
255
+
256
+ if frames_written == 0:
257
+ raise ValueError("No frames written while creating final video.")
258
+
259
+ if _mux_audio(intermediate_path, synced_face_video_path, final_output_path):
260
+ return final_output_path, "synced_face_video"
261
+
262
+ shutil.copy2(intermediate_path, final_output_path)
263
+ return final_output_path, "none"
requirements.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ gradio>=5.0.0
2
+ opencv-python-headless==4.10.0.84
3
+ mediapipe==0.10.14
4
+ numpy>=1.24.0,<2.1
5
+ imageio-ffmpeg>=0.4.9
6
+ protobuf==4.25.3