fffiloni commited on
Commit
f4d1aa0
·
verified ·
1 Parent(s): 808721f

Create app.py

Browse files
Files changed (1) hide show
  1. app.py +509 -0
app.py ADDED
@@ -0,0 +1,509 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ import json
2
+ import logging
3
+ import os
4
+ import shutil
5
+ import subprocess
6
+ import sys
7
+ import tempfile
8
+ import traceback
9
+ import uuid
10
+ import zipfile
11
+ from pathlib import Path
12
+
13
+ import gradio as gr
14
+ import spaces
15
+ from huggingface_hub import hf_hub_download
16
+ from PIL import Image
17
+
18
+
19
+ logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s")
20
+
21
+ ROOT = Path(__file__).resolve().parent
22
+ JOB_ROOT = Path(os.getenv("SCAIL_POSE_JOB_ROOT", str(Path(tempfile.gettempdir()) / "scail_pose_jobs")))
23
+ JOB_ROOT.mkdir(parents=True, exist_ok=True)
24
+
25
+ SAM3_REPO_ID = os.getenv("SCAIL_POSE_SAM3_REPO_ID", "facebook/sam3")
26
+ SAM3_FILENAME = os.getenv("SCAIL_POSE_SAM3_FILENAME", "sam3.pt")
27
+ WEIGHTS_DIR = Path(os.getenv("SCAIL_POSE_WEIGHTS_DIR", str(ROOT / "pretrained_weights")))
28
+ SAM3_MODEL_PATH = Path(os.getenv("SCAIL_POSE_SAM3_MODEL", str(WEIGHTS_DIR / SAM3_FILENAME)))
29
+ AUTO_DOWNLOAD_SAM3 = os.getenv("SCAIL_POSE_AUTO_DOWNLOAD_SAM3", "1") == "1"
30
+
31
+ GPU_SIZE = os.getenv("SCAIL_POSE_ZEROGPU_SIZE", "xlarge")
32
+ GPU_DURATION = int(os.getenv("SCAIL_POSE_GPU_DURATION", "600"))
33
+
34
+ IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
35
+ VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv"}
36
+
37
+
38
+ def _repo_status() -> str:
39
+ required = [
40
+ "NLFPoseExtract/process_animation_aio.py",
41
+ "NLFPoseExtract/process_replacement.py",
42
+ "NLFPoseExtract/v2_helper.py",
43
+ "TrackSam3/track.py",
44
+ ]
45
+ missing = [rel for rel in required if not (ROOT / rel).exists()]
46
+ if missing:
47
+ return (
48
+ "SCAIL-Pose repo layout was not found. Put this app.py at the root of "
49
+ "the SCAIL-Pose checkout.\n\nMissing:\n" + "\n".join(f"- {rel}" for rel in missing)
50
+ )
51
+
52
+ weight_status = (
53
+ f"SAM3 weights found: {SAM3_MODEL_PATH}"
54
+ if SAM3_MODEL_PATH.exists()
55
+ else f"SAM3 weights not found yet: {SAM3_MODEL_PATH}"
56
+ )
57
+ return (
58
+ "Ready. SCAIL-Pose repo layout detected.\n\n"
59
+ f"Job root: {JOB_ROOT}\n"
60
+ f"{weight_status}\n\n"
61
+ "This Space exports SCAIL-2-compatible input packs. Outputs are temporary."
62
+ )
63
+
64
+
65
+ def _require_repo_layout():
66
+ missing = []
67
+ for rel in (
68
+ "NLFPoseExtract/process_animation_aio.py",
69
+ "NLFPoseExtract/process_replacement.py",
70
+ "NLFPoseExtract/v2_helper.py",
71
+ "TrackSam3/track.py",
72
+ ):
73
+ if not (ROOT / rel).exists():
74
+ missing.append(rel)
75
+ if missing:
76
+ raise RuntimeError(
77
+ "This app.py must live at the root of the SCAIL-Pose repository. "
78
+ f"Missing: {', '.join(missing)}"
79
+ )
80
+
81
+
82
+ def _ensure_sam3_weights() -> Path:
83
+ if SAM3_MODEL_PATH.exists():
84
+ return SAM3_MODEL_PATH
85
+
86
+ if not AUTO_DOWNLOAD_SAM3:
87
+ raise RuntimeError(
88
+ f"SAM3 weights were not found at {SAM3_MODEL_PATH}. "
89
+ "Set SCAIL_POSE_SAM3_MODEL or enable SCAIL_POSE_AUTO_DOWNLOAD_SAM3=1."
90
+ )
91
+
92
+ WEIGHTS_DIR.mkdir(parents=True, exist_ok=True)
93
+ logging.info("Downloading SAM3 weights from %s/%s", SAM3_REPO_ID, SAM3_FILENAME)
94
+ downloaded = hf_hub_download(
95
+ repo_id=SAM3_REPO_ID,
96
+ filename=SAM3_FILENAME,
97
+ local_dir=str(WEIGHTS_DIR),
98
+ token=os.getenv("HF_TOKEN") or None,
99
+ )
100
+ return Path(downloaded)
101
+
102
+
103
+ def _new_job_dir(mode: str) -> Path:
104
+ job_dir = JOB_ROOT / f"{mode}_{uuid.uuid4().hex}"
105
+ job_dir.mkdir(parents=True, exist_ok=False)
106
+ return job_dir
107
+
108
+
109
+ def _as_path(upload, label: str) -> Path:
110
+ if upload is None:
111
+ raise RuntimeError(f"Missing {label}.")
112
+
113
+ if isinstance(upload, dict):
114
+ upload = upload.get("path") or upload.get("name")
115
+ elif hasattr(upload, "path"):
116
+ upload = upload.path
117
+ elif hasattr(upload, "name") and not isinstance(upload, (str, os.PathLike)):
118
+ upload = upload.name
119
+
120
+ path = Path(upload)
121
+ if not path.exists():
122
+ raise RuntimeError(f"{label} does not exist: {path}")
123
+ return path
124
+
125
+
126
+ def _save_reference_image(upload, dest: Path) -> Path:
127
+ source = _as_path(upload, "reference image")
128
+ try:
129
+ image = Image.open(source).convert("RGB")
130
+ image.save(dest)
131
+ except Exception as exc:
132
+ raise RuntimeError(f"Could not read reference image: {source}") from exc
133
+ return dest
134
+
135
+
136
+ def _copy_video(upload, dest: Path, label: str) -> Path:
137
+ source = _as_path(upload, label)
138
+ if source.suffix.lower() not in VIDEO_EXTS:
139
+ raise RuntimeError(f"{label} should be a video file. Got: {source.name}")
140
+ shutil.copy2(source, dest)
141
+ return dest
142
+
143
+
144
+ def _text_args(text_prompt: str) -> list[str]:
145
+ words = [part.strip() for part in (text_prompt or "").split() if part.strip()]
146
+ return words or ["human", "character"]
147
+
148
+
149
+ def _run_command(command: list[str], progress=None) -> str:
150
+ logging.info("Running command: %s", " ".join(command))
151
+ output_lines = []
152
+ proc = subprocess.Popen(
153
+ command,
154
+ cwd=str(ROOT),
155
+ stdout=subprocess.PIPE,
156
+ stderr=subprocess.STDOUT,
157
+ text=True,
158
+ bufsize=1,
159
+ env=os.environ.copy(),
160
+ )
161
+ assert proc.stdout is not None
162
+ for line in proc.stdout:
163
+ line = line.rstrip()
164
+ if not line:
165
+ continue
166
+ logging.info("[SCAIL-Pose] %s", line)
167
+ output_lines.append(line)
168
+ if progress is not None:
169
+ progress(None, desc=line[:120])
170
+
171
+ ret = proc.wait()
172
+ output = "\n".join(output_lines)
173
+ if ret != 0:
174
+ raise RuntimeError(f"SCAIL-Pose command failed with exit code {ret}.\n\n{output}")
175
+ return output
176
+
177
+
178
+ def _write_metadata(job_dir: Path, mode: str, prompt: str, extra: dict | None = None) -> None:
179
+ metadata = {
180
+ "mode": mode,
181
+ "source": "scail-pose-gradio-pack-builder",
182
+ }
183
+ if extra:
184
+ metadata.update(extra)
185
+ (job_dir / "metadata.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
186
+ (job_dir / "prompt.txt").write_text(prompt or "", encoding="utf-8")
187
+
188
+
189
+ def _zip_pack(job_dir: Path, mode: str) -> Path:
190
+ zip_path = job_dir / f"scail2_{mode}_pack.zip"
191
+ include = [
192
+ "ref.png",
193
+ "ref_mask.png",
194
+ "ref_mask.jpg",
195
+ "rendered_v2.mp4",
196
+ "rendered_mask_v2.mp4",
197
+ "replace_mask.mp4",
198
+ "prompt.txt",
199
+ "metadata.json",
200
+ ]
201
+ with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
202
+ for name in include:
203
+ path = job_dir / name
204
+ if path.exists():
205
+ zf.write(path, arcname=name)
206
+ return zip_path
207
+
208
+
209
+ def _require_outputs(job_dir: Path, names: list[str]) -> None:
210
+ missing = [name for name in names if not (job_dir / name).exists()]
211
+ if missing:
212
+ raise RuntimeError("SCAIL-Pose did not produce expected output(s): " + ", ".join(missing))
213
+
214
+
215
+ def _animation_command(
216
+ job_dir: Path,
217
+ sam3_model: Path,
218
+ max_persons: int,
219
+ text_prompt: str,
220
+ crop_mode: str,
221
+ ) -> list[str]:
222
+ command = [
223
+ sys.executable,
224
+ str(ROOT / "NLFPoseExtract" / "process_animation_aio.py"),
225
+ "--subdir",
226
+ str(job_dir),
227
+ "--video_name",
228
+ "driving.mp4",
229
+ "--e2e_mode",
230
+ "--max_persons",
231
+ str(int(max_persons)),
232
+ "--sam3_model",
233
+ str(sam3_model),
234
+ "--text",
235
+ *_text_args(text_prompt),
236
+ ]
237
+ if crop_mode == "mask silhouette":
238
+ command.append("--crop_e2e_mask")
239
+ elif crop_mode == "moving bbox":
240
+ command.append("--crop_e2e_bbox")
241
+ elif crop_mode == "steady bbox":
242
+ command.append("--crop_e2e_steady_bbox")
243
+ return command
244
+
245
+
246
+ def _replacement_command(
247
+ job_dir: Path,
248
+ sam3_model: Path,
249
+ text_prompt: str,
250
+ matchnearest: bool,
251
+ egocentric: bool,
252
+ ) -> list[str]:
253
+ command = [
254
+ sys.executable,
255
+ str(ROOT / "NLFPoseExtract" / "process_replacement.py"),
256
+ "--subdir",
257
+ str(job_dir),
258
+ "--video_name",
259
+ "driving.mp4",
260
+ "--sam3_model",
261
+ str(sam3_model),
262
+ "--text",
263
+ *_text_args(text_prompt),
264
+ ]
265
+ if matchnearest:
266
+ command.append("--matchnearest")
267
+ if egocentric:
268
+ command.append("--egocentric")
269
+ return command
270
+
271
+
272
+ @spaces.GPU(duration=GPU_DURATION, size=GPU_SIZE)
273
+ def build_animation_pack(
274
+ ref_image,
275
+ driving_video,
276
+ prompt,
277
+ sam3_text,
278
+ max_persons,
279
+ crop_mode,
280
+ progress=gr.Progress(track_tqdm=True),
281
+ ):
282
+ try:
283
+ progress(0.0, desc="Checking SCAIL-Pose repo")
284
+ _require_repo_layout()
285
+ progress(0.04, desc="Preparing SAM3 weights")
286
+ sam3_model = _ensure_sam3_weights()
287
+
288
+ job_dir = _new_job_dir("animation")
289
+ progress(0.08, desc="Preparing inputs")
290
+ _save_reference_image(ref_image, job_dir / "ref.png")
291
+ _copy_video(driving_video, job_dir / "driving.mp4", "driving video")
292
+ _write_metadata(
293
+ job_dir,
294
+ "animation",
295
+ prompt,
296
+ {
297
+ "driving": {
298
+ "video": "rendered_v2.mp4",
299
+ "mask_video": "rendered_mask_v2.mp4",
300
+ },
301
+ "primary": {
302
+ "image": "ref.png",
303
+ "mask": "ref_mask.jpg",
304
+ },
305
+ "sam3_text": _text_args(sam3_text),
306
+ "max_persons": int(max_persons),
307
+ "crop_mode": crop_mode,
308
+ },
309
+ )
310
+
311
+ progress(0.12, desc="Generating animation masks")
312
+ logs = _run_command(
313
+ _animation_command(job_dir, sam3_model, int(max_persons), sam3_text, crop_mode),
314
+ progress=progress,
315
+ )
316
+ _require_outputs(job_dir, ["ref_mask.jpg", "rendered_v2.mp4", "rendered_mask_v2.mp4"])
317
+
318
+ progress(0.92, desc="Packaging SCAIL-2 input pack")
319
+ zip_path = _zip_pack(job_dir, "animation")
320
+ progress(1.0, desc="Done")
321
+ status = f"Done. Pack created at {zip_path}\n\n{logs}"
322
+ return (
323
+ str(job_dir / "ref_mask.jpg"),
324
+ str(job_dir / "rendered_v2.mp4"),
325
+ str(job_dir / "rendered_mask_v2.mp4"),
326
+ str(zip_path),
327
+ status,
328
+ )
329
+ except Exception:
330
+ logging.exception("Animation pack generation failed")
331
+ return None, None, None, None, traceback.format_exc()
332
+
333
+
334
+ @spaces.GPU(duration=GPU_DURATION, size=GPU_SIZE)
335
+ def build_replacement_pack(
336
+ ref_image,
337
+ driving_video,
338
+ prompt,
339
+ sam3_text,
340
+ matchnearest,
341
+ egocentric,
342
+ progress=gr.Progress(track_tqdm=True),
343
+ ):
344
+ try:
345
+ if matchnearest and egocentric:
346
+ raise RuntimeError("matchnearest and egocentric are mutually exclusive.")
347
+
348
+ progress(0.0, desc="Checking SCAIL-Pose repo")
349
+ _require_repo_layout()
350
+ progress(0.04, desc="Preparing SAM3 weights")
351
+ sam3_model = _ensure_sam3_weights()
352
+
353
+ job_dir = _new_job_dir("replacement")
354
+ progress(0.08, desc="Preparing inputs")
355
+ _save_reference_image(ref_image, job_dir / "ref.png")
356
+ _copy_video(driving_video, job_dir / "driving.mp4", "source video")
357
+ _write_metadata(
358
+ job_dir,
359
+ "replacement",
360
+ prompt,
361
+ {
362
+ "driving": {
363
+ "video": "rendered_v2.mp4",
364
+ "mask_video": "replace_mask.mp4",
365
+ },
366
+ "primary": {
367
+ "image": "ref.png",
368
+ "mask": "ref_mask.png",
369
+ },
370
+ "sam3_text": _text_args(sam3_text),
371
+ "matchnearest": bool(matchnearest),
372
+ "egocentric": bool(egocentric),
373
+ },
374
+ )
375
+
376
+ progress(0.12, desc="Generating replacement masks")
377
+ logs = _run_command(
378
+ _replacement_command(job_dir, sam3_model, sam3_text, bool(matchnearest), bool(egocentric)),
379
+ progress=progress,
380
+ )
381
+ _require_outputs(job_dir, ["ref_mask.png", "rendered_v2.mp4", "replace_mask.mp4"])
382
+
383
+ progress(0.92, desc="Packaging SCAIL-2 input pack")
384
+ zip_path = _zip_pack(job_dir, "replacement")
385
+ progress(1.0, desc="Done")
386
+ status = f"Done. Pack created at {zip_path}\n\n{logs}"
387
+ return (
388
+ str(job_dir / "ref_mask.png"),
389
+ str(job_dir / "rendered_v2.mp4"),
390
+ str(job_dir / "replace_mask.mp4"),
391
+ str(zip_path),
392
+ status,
393
+ )
394
+ except Exception:
395
+ logging.exception("Replacement pack generation failed")
396
+ return None, None, None, None, traceback.format_exc()
397
+
398
+
399
+ def build_ui():
400
+ with gr.Blocks(title="SCAIL-Pose Pack Builder") as demo:
401
+ gr.Markdown(
402
+ "# SCAIL-Pose Pack Builder\n"
403
+ "Generate SCAIL-2-ready masks and export them as an input pack. "
404
+ "Use the downloaded `.zip` in the SCAIL-2 demo Advanced Pack tab."
405
+ )
406
+ gr.Textbox(value=_repo_status(), label="Startup status", interactive=False, lines=7)
407
+
408
+ with gr.Tab("Animation Pack"):
409
+ gr.Markdown(
410
+ "Create an animation pack from one reference image and one driving video. "
411
+ "This uses SCAIL-Pose end-to-end mode: the driving video becomes `rendered_v2.mp4`, "
412
+ "and SAM3 produces the colored driving mask video."
413
+ )
414
+ with gr.Row():
415
+ anim_ref = gr.Image(type="filepath", label="Reference image")
416
+ anim_driving = gr.Video(label="Driving video")
417
+ anim_prompt = gr.Textbox(label="Prompt for SCAIL-2", lines=3)
418
+ with gr.Row():
419
+ anim_sam3_text = gr.Textbox(value="human character", label="SAM3 text prompt")
420
+ anim_max_persons = gr.Number(value=2, precision=0, label="Max tracked subjects")
421
+ anim_crop = gr.Dropdown(
422
+ ["none", "mask silhouette", "moving bbox", "steady bbox"],
423
+ value="none",
424
+ label="Driving crop mode",
425
+ )
426
+ anim_run = gr.Button("Generate animation pack", variant="primary")
427
+ with gr.Row():
428
+ anim_ref_mask = gr.Image(label="Reference mask", interactive=False)
429
+ anim_mask_video = gr.Video(label="Driving mask video")
430
+ anim_rendered = gr.Video(label="Rendered / driving video")
431
+ anim_zip = gr.File(label="Download SCAIL-2 animation pack")
432
+ anim_status = gr.Textbox(label="Run logs", lines=14)
433
+ anim_run.click(
434
+ build_animation_pack,
435
+ inputs=[
436
+ anim_ref,
437
+ anim_driving,
438
+ anim_prompt,
439
+ anim_sam3_text,
440
+ anim_max_persons,
441
+ anim_crop,
442
+ ],
443
+ outputs=[anim_ref_mask, anim_rendered, anim_mask_video, anim_zip, anim_status],
444
+ )
445
+
446
+ with gr.Tab("Replacement Pack"):
447
+ gr.Markdown(
448
+ "Create a replacement pack from a replacement reference image and a source video. "
449
+ "The source video becomes `rendered_v2.mp4`, and SAM3 produces `replace_mask.mp4`."
450
+ )
451
+ with gr.Row():
452
+ repl_ref = gr.Image(type="filepath", label="Replacement reference image")
453
+ repl_driving = gr.Video(label="Source / driving video")
454
+ repl_prompt = gr.Textbox(label="Prompt for SCAIL-2", lines=3)
455
+ with gr.Row():
456
+ repl_sam3_text = gr.Textbox(value="human character", label="SAM3 text prompt")
457
+ repl_matchnearest = gr.Checkbox(value=False, label="Match nearest target when two people are detected")
458
+ repl_egocentric = gr.Checkbox(value=False, label="Egocentric: union two detected parts into one actor")
459
+ repl_run = gr.Button("Generate replacement pack", variant="primary")
460
+ with gr.Row():
461
+ repl_ref_mask = gr.Image(label="Reference mask", interactive=False)
462
+ repl_mask_video = gr.Video(label="Replacement mask video")
463
+ repl_rendered = gr.Video(label="Rendered / source video")
464
+ repl_zip = gr.File(label="Download SCAIL-2 replacement pack")
465
+ repl_status = gr.Textbox(label="Run logs", lines=14)
466
+ repl_run.click(
467
+ build_replacement_pack,
468
+ inputs=[
469
+ repl_ref,
470
+ repl_driving,
471
+ repl_prompt,
472
+ repl_sam3_text,
473
+ repl_matchnearest,
474
+ repl_egocentric,
475
+ ],
476
+ outputs=[repl_ref_mask, repl_rendered, repl_mask_video, repl_zip, repl_status],
477
+ )
478
+
479
+ with gr.Tab("Pack Format"):
480
+ gr.Markdown(
481
+ "The exported zip is intentionally flat for V1, so it is easy to inspect and "
482
+ "compatible with the SCAIL-2 Advanced Pack parser.\n\n"
483
+ "Animation pack:\n"
484
+ "```text\n"
485
+ "ref.png\n"
486
+ "ref_mask.jpg\n"
487
+ "rendered_v2.mp4\n"
488
+ "rendered_mask_v2.mp4\n"
489
+ "prompt.txt\n"
490
+ "metadata.json\n"
491
+ "```\n\n"
492
+ "Replacement pack:\n"
493
+ "```text\n"
494
+ "ref.png\n"
495
+ "ref_mask.png\n"
496
+ "rendered_v2.mp4\n"
497
+ "replace_mask.mp4\n"
498
+ "prompt.txt\n"
499
+ "metadata.json\n"
500
+ "```\n\n"
501
+ "For multi-character curation, run several focused jobs first, inspect the masks, "
502
+ "then combine the selected references into a canonical Advanced Pack."
503
+ )
504
+
505
+ return demo
506
+
507
+
508
+ if __name__ == "__main__":
509
+ build_ui().queue(max_size=4).launch(show_error=True)