SCAIL-Pose / app.py
fffiloni's picture
Update app.py
d1bb7db verified
Raw
History Blame Contribute Delete
40.8 kB
import json
import logging
import os
import re
import shutil
import subprocess
import sys
import tempfile
import traceback
import uuid
import zipfile
from pathlib import Path
from dataclasses import dataclass
import gradio as gr
import spaces
from huggingface_hub import hf_hub_download
from PIL import Image
logging.basicConfig(level=logging.INFO, format="[%(asctime)s] %(levelname)s: %(message)s")
ROOT = Path(__file__).resolve().parent
JOB_ROOT = Path(os.getenv("SCAIL_POSE_JOB_ROOT", str(Path(tempfile.gettempdir()) / "scail_pose_jobs")))
JOB_ROOT.mkdir(parents=True, exist_ok=True)
SAM3_REPO_ID = os.getenv("SCAIL_POSE_SAM3_REPO_ID", "facebook/sam3")
SAM3_FILENAME = os.getenv("SCAIL_POSE_SAM3_FILENAME", "sam3.pt")
WEIGHTS_DIR = Path(os.getenv("SCAIL_POSE_WEIGHTS_DIR", str(ROOT / "pretrained_weights")))
SAM3_MODEL_PATH = Path(os.getenv("SCAIL_POSE_SAM3_MODEL", str(WEIGHTS_DIR / SAM3_FILENAME)))
AUTO_DOWNLOAD_SAM3 = os.getenv("SCAIL_POSE_AUTO_DOWNLOAD_SAM3", "1") == "1"
GPU_SIZE = os.getenv("SCAIL_POSE_ZEROGPU_SIZE", "xlarge")
GPU_DURATION = int(os.getenv("SCAIL_POSE_GPU_DURATION", "90"))
IMAGE_EXTS = {".png", ".jpg", ".jpeg", ".webp"}
VIDEO_EXTS = {".mp4", ".mov", ".webm", ".mkv"}
SCAIL_COLORS = [
(0, 0, 255),
(255, 0, 0),
(0, 255, 0),
(255, 255, 0),
(255, 0, 255),
(0, 255, 255),
]
_SAM3_VIDEO_PREDICTOR = None
_SAM3_IMAGE_PREDICTOR = None
_SAM3_PREDICTOR_KEY = None
@dataclass(frozen=True)
class CharacterRef:
character: str
view: str
path: Path
def _repo_status() -> str:
required = [
"NLFPoseExtract/process_animation_aio.py",
"NLFPoseExtract/process_replacement.py",
"NLFPoseExtract/v2_helper.py",
"TrackSam3/track.py",
]
missing = [rel for rel in required if not (ROOT / rel).exists()]
if missing:
return (
"SCAIL-Pose repo layout was not found. Put this app.py at the root of "
"the SCAIL-Pose checkout.\n\nMissing:\n" + "\n".join(f"- {rel}" for rel in missing)
)
weight_status = (
f"SAM3 weights found: {SAM3_MODEL_PATH}"
if SAM3_MODEL_PATH.exists()
else f"SAM3 weights not found yet: {SAM3_MODEL_PATH}"
)
return (
"Ready. SCAIL-Pose repo layout detected.\n\n"
f"Job root: {JOB_ROOT}\n"
f"{weight_status}\n\n"
"This Space exports SCAIL-2-compatible input packs. Outputs are temporary."
)
def _require_repo_layout():
missing = []
for rel in (
"NLFPoseExtract/process_animation_aio.py",
"NLFPoseExtract/process_replacement.py",
"NLFPoseExtract/v2_helper.py",
"TrackSam3/track.py",
):
if not (ROOT / rel).exists():
missing.append(rel)
if missing:
raise RuntimeError(
"This app.py must live at the root of the SCAIL-Pose repository. "
f"Missing: {', '.join(missing)}"
)
def _ensure_sam3_weights() -> Path:
if SAM3_MODEL_PATH.exists():
return SAM3_MODEL_PATH
if not AUTO_DOWNLOAD_SAM3:
raise RuntimeError(
f"SAM3 weights were not found at {SAM3_MODEL_PATH}. "
"Set SCAIL_POSE_SAM3_MODEL or enable SCAIL_POSE_AUTO_DOWNLOAD_SAM3=1."
)
WEIGHTS_DIR.mkdir(parents=True, exist_ok=True)
logging.info("Downloading SAM3 weights from %s/%s", SAM3_REPO_ID, SAM3_FILENAME)
downloaded = hf_hub_download(
repo_id=SAM3_REPO_ID,
filename=SAM3_FILENAME,
local_dir=str(WEIGHTS_DIR),
token=os.getenv("HF_TOKEN") or None,
)
return Path(downloaded)
def _new_job_dir(mode: str) -> Path:
job_dir = JOB_ROOT / f"{mode}_{uuid.uuid4().hex}"
job_dir.mkdir(parents=True, exist_ok=False)
return job_dir
def _as_path(upload, label: str) -> Path:
if upload is None:
raise RuntimeError(f"Missing {label}.")
if isinstance(upload, dict):
upload = upload.get("path") or upload.get("name")
elif hasattr(upload, "path"):
upload = upload.path
elif hasattr(upload, "name") and not isinstance(upload, (str, os.PathLike)):
upload = upload.name
path = Path(upload)
if not path.exists():
raise RuntimeError(f"{label} does not exist: {path}")
return path
def _save_reference_image(upload, dest: Path) -> Path:
source = _as_path(upload, "reference image")
try:
image = Image.open(source).convert("RGB")
image.save(dest)
except Exception as exc:
raise RuntimeError(f"Could not read reference image: {source}") from exc
return dest
def _copy_video(upload, dest: Path, label: str) -> Path:
source = _as_path(upload, label)
if source.suffix.lower() not in VIDEO_EXTS:
raise RuntimeError(f"{label} should be a video file. Got: {source.name}")
shutil.copy2(source, dest)
return dest
def _copy_reference_to_png(source: Path, dest: Path) -> Path:
dest.parent.mkdir(parents=True, exist_ok=True)
try:
image = Image.open(source).convert("RGB")
image.save(dest)
except Exception as exc:
raise RuntimeError(f"Could not read reference image: {source}") from exc
return dest
def _safe_name(value: str) -> str:
value = value.strip().replace(" ", "_")
value = re.sub(r"[^A-Za-z0-9_.-]+", "_", value)
value = value.strip("._-")
return value or "view"
def _character_sort_key(character: str) -> tuple[int, str]:
match = re.fullmatch(r"character_(\d+)", character)
if match:
return int(match.group(1)), character
return 9999, character
def _parse_character_ref_name(path: Path) -> tuple[str, str] | None:
if path.suffix.lower() not in IMAGE_EXTS:
return None
if path.stem.endswith("_mask"):
return None
parent = path.parent.name
if re.fullmatch(r"character_\d+", parent):
return parent, _safe_name(path.stem)
stem = path.stem
match = re.match(r"^(character_\d+)(?:__|--|_)(.+)$", stem)
if not match:
return None
return match.group(1), _safe_name(match.group(2))
def _normalize_upload_list(files) -> list[Path]:
if not files:
return []
if not isinstance(files, list):
files = [files]
return [_as_path(file, "reference file") for file in files]
def _safe_extract_zip(zip_path: str | Path, label: str) -> Path:
zip_path = _as_path(zip_path, label)
if zip_path.suffix.lower() != ".zip":
raise RuntimeError(f"{label} must be a .zip file.")
extract_root = JOB_ROOT / "uploads" / uuid.uuid4().hex
extract_root.mkdir(parents=True, exist_ok=True)
with zipfile.ZipFile(zip_path) as zf:
for item in zf.infolist():
item_path = Path(item.filename)
if item_path.is_absolute() or ".." in item_path.parts:
raise RuntimeError(f"Unsafe path in zip: {item.filename}")
zf.extractall(extract_root)
visible = [p for p in extract_root.iterdir() if p.name not in {".DS_Store", "__MACOSX"}]
if len(visible) == 1 and visible[0].is_dir():
return visible[0]
return extract_root
def _collect_character_refs(reference_files, reference_zip) -> list[CharacterRef]:
paths = []
if reference_zip is not None:
root = _safe_extract_zip(reference_zip, "reference zip")
paths.extend(p for p in root.rglob("*") if p.is_file())
paths.extend(_normalize_upload_list(reference_files))
refs = []
ignored = []
for path in paths:
parsed = _parse_character_ref_name(path)
if parsed is None:
if path.suffix.lower() in IMAGE_EXTS:
ignored.append(path.name)
continue
character, view = parsed
refs.append(CharacterRef(character=character, view=view, path=path))
if not refs:
raise RuntimeError(
"No character references found. Use names like `character_0__front.png`, "
"`character_0__back.png`, `character_1__front.png`, or upload a zip with "
"`characters/character_0/front.png`."
)
seen = set()
duplicates = []
for ref in refs:
key = (ref.character, ref.view)
if key in seen:
duplicates.append(f"{ref.character}/{ref.view}")
seen.add(key)
if duplicates:
raise RuntimeError("Duplicate character view(s): " + ", ".join(sorted(duplicates)))
if ignored:
logging.info("Ignored reference image(s) without character naming: %s", ", ".join(ignored))
return sorted(refs, key=lambda ref: (_character_sort_key(ref.character), ref.view.lower()))
def _text_args(text_prompt: str) -> list[str]:
if not text_prompt or not text_prompt.strip():
return ["human character"]
parts = [part.strip() for part in re.split(r"[\n,]+", text_prompt) if part.strip()]
return parts or ["human character"]
def _run_command(command: list[str], progress=None) -> str:
logging.info("Running command: %s", " ".join(command))
output_lines = []
proc = subprocess.Popen(
command,
cwd=str(ROOT),
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
text=True,
bufsize=1,
env=os.environ.copy(),
)
assert proc.stdout is not None
for line in proc.stdout:
line = line.rstrip()
if not line:
continue
logging.info("[SCAIL-Pose] %s", line)
output_lines.append(line)
if progress is not None:
progress(None, desc=line[:120])
ret = proc.wait()
output = "\n".join(output_lines)
if ret != 0:
raise RuntimeError(f"SCAIL-Pose command failed with exit code {ret}.\n\n{output}")
return output
def _write_metadata(job_dir: Path, mode: str, prompt: str, extra: dict | None = None) -> None:
metadata = {
"mode": mode,
"source": "scail-pose-gradio-pack-builder",
}
if extra:
metadata.update(extra)
(job_dir / "metadata.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
(job_dir / "prompt.txt").write_text(prompt or "", encoding="utf-8")
def _zip_pack(job_dir: Path, mode: str) -> Path:
zip_path = job_dir / f"scail2_{mode}_pack.zip"
include = [
"ref.png",
"ref_mask.png",
"ref_mask.jpg",
"rendered_v2.mp4",
"rendered_mask_v2.mp4",
"replace_mask.mp4",
"prompt.txt",
"metadata.json",
]
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for name in include:
path = job_dir / name
if path.exists():
zf.write(path, arcname=name)
return zip_path
def _require_outputs(job_dir: Path, names: list[str]) -> None:
missing = [name for name in names if not (job_dir / name).exists()]
if missing:
raise RuntimeError("SCAIL-Pose did not produce expected output(s): " + ", ".join(missing))
def _load_sam3_predictors(sam3_model: Path):
global _SAM3_VIDEO_PREDICTOR, _SAM3_IMAGE_PREDICTOR, _SAM3_PREDICTOR_KEY
key = str(sam3_model)
if _SAM3_VIDEO_PREDICTOR is not None and _SAM3_PREDICTOR_KEY == key:
return _SAM3_VIDEO_PREDICTOR, _SAM3_IMAGE_PREDICTOR
from ultralytics.models.sam import SAM3SemanticPredictor, SAM3VideoSemanticPredictor
overrides = dict(
conf=0.25,
task="segment",
mode="predict",
imgsz=640,
model=str(sam3_model),
half=True,
save=False,
verbose=False,
)
_SAM3_VIDEO_PREDICTOR = SAM3VideoSemanticPredictor(overrides=overrides, new_det_thresh=1.0)
_SAM3_IMAGE_PREDICTOR = SAM3SemanticPredictor(overrides=overrides)
_SAM3_PREDICTOR_KEY = key
return _SAM3_VIDEO_PREDICTOR, _SAM3_IMAGE_PREDICTOR
def _write_canonical_pack_zip(pack_root: Path, mode: str) -> Path:
zip_path = pack_root.parent / f"scail2_{mode}_advanced_pack.zip"
with zipfile.ZipFile(zip_path, "w", compression=zipfile.ZIP_DEFLATED) as zf:
for path in sorted(pack_root.rglob("*")):
if path.is_file():
zf.write(path, arcname=path.relative_to(pack_root).as_posix())
return zip_path
def _advanced_gallery(pack_root: Path):
items = []
for image_path in sorted((pack_root / "characters").rglob("*")):
if image_path.is_file() and image_path.suffix.lower() in IMAGE_EXTS:
items.append((str(image_path), image_path.relative_to(pack_root).as_posix()))
return items
def _build_advanced_metadata(
pack_root: Path,
mode: str,
prompt: str,
primary_ref: CharacterRef,
character_names: list[str],
text_prompt: str,
) -> None:
primary_image = f"characters/{primary_ref.character}/{primary_ref.view}.png"
primary_mask = f"characters/{primary_ref.character}/{primary_ref.view}_mask.png"
metadata = {
"mode": mode,
"source": "scail-pose-gradio-advanced-pack-builder",
"primary": {
"image": primary_image,
"mask": primary_mask,
},
"driving": {
"video": "rendered_v2.mp4",
"mask_video": "rendered_mask_v2.mp4" if mode == "animation" else "replace_mask.mp4",
},
"characters": character_names,
"sam3_text": _text_args(text_prompt),
}
(pack_root / "metadata.json").write_text(json.dumps(metadata, indent=2), encoding="utf-8")
(pack_root / "prompt.txt").write_text(prompt or "", encoding="utf-8")
def _prepare_advanced_refs(refs: list[CharacterRef], pack_root: Path) -> list[CharacterRef]:
prepared = []
for ref in refs:
dest = pack_root / "characters" / ref.character / f"{ref.view}.png"
_copy_reference_to_png(ref.path, dest)
prepared.append(CharacterRef(character=ref.character, view=ref.view, path=dest))
return prepared
def _animation_command(
job_dir: Path,
sam3_model: Path,
max_persons: int,
text_prompt: str,
crop_mode: str,
) -> list[str]:
command = [
sys.executable,
str(ROOT / "NLFPoseExtract" / "process_animation_aio.py"),
"--subdir",
str(job_dir),
"--video_name",
"driving.mp4",
"--e2e_mode",
"--max_persons",
str(int(max_persons)),
"--sam3_model",
str(sam3_model),
"--text",
*_text_args(text_prompt),
]
if crop_mode == "mask silhouette":
command.append("--crop_e2e_mask")
elif crop_mode == "moving bbox":
command.append("--crop_e2e_bbox")
elif crop_mode == "steady bbox":
command.append("--crop_e2e_steady_bbox")
return command
def _replacement_command(
job_dir: Path,
sam3_model: Path,
text_prompt: str,
matchnearest: bool,
egocentric: bool,
) -> list[str]:
command = [
sys.executable,
str(ROOT / "NLFPoseExtract" / "process_replacement.py"),
"--subdir",
str(job_dir),
"--video_name",
"driving.mp4",
"--sam3_model",
str(sam3_model),
"--text",
*_text_args(text_prompt),
]
if matchnearest:
command.append("--matchnearest")
if egocentric:
command.append("--egocentric")
return command
@spaces.GPU(duration=GPU_DURATION, size=GPU_SIZE)
def build_simple_pack(
mode,
ref_image,
driving_video,
prompt,
sam3_text,
max_persons,
crop_mode,
matchnearest,
egocentric,
progress=gr.Progress(track_tqdm=True),
):
try:
mode = str(mode or "Animation").lower()
if mode not in {"animation", "replacement"}:
raise RuntimeError(f"Unsupported mode: {mode}")
if mode == "replacement" and matchnearest and egocentric:
raise RuntimeError("matchnearest and egocentric are mutually exclusive.")
progress(0.0, desc="Checking SCAIL-Pose repo")
_require_repo_layout()
progress(0.04, desc="Preparing SAM3 weights")
sam3_model = _ensure_sam3_weights()
if mode == "animation":
job_dir = _new_job_dir("animation")
progress(0.08, desc="Preparing animation inputs")
_save_reference_image(ref_image, job_dir / "ref.png")
_copy_video(driving_video, job_dir / "driving.mp4", "driving video")
_write_metadata(
job_dir,
"animation",
prompt,
{
"driving": {
"video": "rendered_v2.mp4",
"mask_video": "rendered_mask_v2.mp4",
},
"primary": {
"image": "ref.png",
"mask": "ref_mask.jpg",
},
"sam3_text": _text_args(sam3_text),
"max_persons": int(max_persons),
"crop_mode": crop_mode,
},
)
progress(0.12, desc="Generating animation masks")
logs = _run_command(
_animation_command(job_dir, sam3_model, int(max_persons), sam3_text, crop_mode),
progress=progress,
)
_require_outputs(job_dir, ["ref_mask.jpg", "rendered_v2.mp4", "rendered_mask_v2.mp4"])
progress(0.92, desc="Packaging SCAIL-2 input pack")
zip_path = _zip_pack(job_dir, "animation")
progress(1.0, desc="Done")
status = f"Done. Animation pack created at {zip_path}\n\n{logs}"
return (
str(job_dir / "ref_mask.jpg"),
str(job_dir / "rendered_v2.mp4"),
str(job_dir / "rendered_mask_v2.mp4"),
str(zip_path),
status,
)
job_dir = _new_job_dir("replacement")
progress(0.08, desc="Preparing replacement inputs")
_save_reference_image(ref_image, job_dir / "ref.png")
_copy_video(driving_video, job_dir / "driving.mp4", "source video")
_write_metadata(
job_dir,
"replacement",
prompt,
{
"driving": {
"video": "rendered_v2.mp4",
"mask_video": "replace_mask.mp4",
},
"primary": {
"image": "ref.png",
"mask": "ref_mask.png",
},
"sam3_text": _text_args(sam3_text),
"matchnearest": bool(matchnearest),
"egocentric": bool(egocentric),
},
)
progress(0.12, desc="Generating replacement masks")
logs = _run_command(
_replacement_command(job_dir, sam3_model, sam3_text, bool(matchnearest), bool(egocentric)),
progress=progress,
)
_require_outputs(job_dir, ["ref_mask.png", "rendered_v2.mp4", "replace_mask.mp4"])
progress(0.92, desc="Packaging SCAIL-2 input pack")
zip_path = _zip_pack(job_dir, "replacement")
progress(1.0, desc="Done")
status = f"Done. Replacement pack created at {zip_path}\n\n{logs}"
return (
str(job_dir / "ref_mask.png"),
str(job_dir / "rendered_v2.mp4"),
str(job_dir / "replace_mask.mp4"),
str(zip_path),
status,
)
except Exception:
logging.exception("Simple pack generation failed")
return None, None, None, None, traceback.format_exc()
@spaces.GPU(duration=GPU_DURATION, size=GPU_SIZE)
def build_advanced_character_pack(
reference_files,
reference_zip,
driving_video,
prompt,
sam3_text,
max_subjects,
progress=gr.Progress(track_tqdm=True),
):
try:
progress(0.0, desc="Checking SCAIL-Pose repo")
_require_repo_layout()
progress(0.04, desc="Preparing SAM3 weights")
sam3_model = _ensure_sam3_weights()
refs = _collect_character_refs(reference_files, reference_zip)
character_names = sorted({ref.character for ref in refs}, key=_character_sort_key)
if len(character_names) > len(SCAIL_COLORS):
raise RuntimeError(f"Too many characters: {len(character_names)}. Limit is {len(SCAIL_COLORS)}.")
max_targets = max(int(max_subjects or len(character_names)), len(character_names))
max_targets = max(1, min(max_targets, len(SCAIL_COLORS)))
colors = SCAIL_COLORS[:max_targets]
job_dir = _new_job_dir("advanced_animation")
pack_root = job_dir / "scail2_input_pack"
pack_root.mkdir(parents=True, exist_ok=True)
progress(0.08, desc="Preparing references and driving video")
work_driving = job_dir / "driving.mp4"
_copy_video(driving_video, work_driving, "driving video")
shutil.copy2(work_driving, pack_root / "rendered_v2.mp4")
prepared_refs = _prepare_advanced_refs(refs, pack_root)
primary = sorted(
prepared_refs,
key=lambda ref: (
0 if ref.character == "character_0" else 1,
0 if ref.view in {"front", "main", "ref", "reference"} else 1,
_character_sort_key(ref.character),
ref.view,
),
)[0]
_build_advanced_metadata(pack_root, "animation", prompt, primary, character_names, sam3_text)
progress(0.12, desc="Loading SAM3 predictors")
video_predictor, image_predictor = _load_sam3_predictors(sam3_model)
from TrackSam3.track import get_mask_from_image, get_mask_from_image_via_video, get_mask_from_video
from NLFPoseExtract.v2_helper import save_colored_mask_image, write_colored_mask_video
text = _text_args(sam3_text)
progress(0.20, desc="Tracking subjects in driving video")
drv_masks, drv_colors = get_mask_from_video(
str(work_driving),
video_predictor,
max_targets=max_targets,
sort_by="x",
fixed_colors=colors,
text=text,
)
if len(drv_masks) == 0:
raise RuntimeError("SAM3 did not detect any valid subject in the driving video.")
import cv2
cap = cv2.VideoCapture(str(work_driving))
fps = cap.get(cv2.CAP_PROP_FPS)
cap.release()
fps_int = max(1, int(round(fps or 24)))
write_colored_mask_video(
drv_masks,
drv_colors,
str(pack_root / "rendered_mask_v2.mp4"),
fps_int,
bg_color=(255, 255, 255),
)
progress(0.52, desc="Generating reference masks")
logs = [
f"Characters: {', '.join(character_names)}",
f"Reference views: {len(prepared_refs)}",
f"Driving tracks detected: {len(drv_masks)}",
f"Primary: {primary.character}/{primary.view}",
]
for idx, ref in enumerate(prepared_refs):
character_idx = character_names.index(ref.character)
color = drv_colors[character_idx] if character_idx < len(drv_colors) else SCAIL_COLORS[character_idx]
mask_path = ref.path.with_name(f"{ref.view}_mask.png")
progress(None, desc=f"Masking {ref.character}/{ref.view}")
try:
ref_masks, _ = get_mask_from_image_via_video(
str(ref.path),
video_predictor,
max_targets=1,
sort_by="x",
fixed_colors=[color],
text=text,
)
except Exception as exc:
logging.warning("Video-based image masking failed for %s, trying image predictor: %s", ref.path, exc)
ref_masks, _ = get_mask_from_image(
str(ref.path),
image_predictor,
max_targets=1,
sort_by="x",
fixed_colors=[color],
text=text,
)
if len(ref_masks) == 0:
raise RuntimeError(f"SAM3 did not detect a subject in {ref.character}/{ref.view}.")
save_colored_mask_image([ref_masks[0]], [color], str(mask_path), bg_color=(255, 255, 255))
logs.append(f"- {ref.character}/{ref.view}: {mask_path.relative_to(pack_root).as_posix()}")
progress(0.92, desc="Packaging canonical Advanced Pack")
zip_path = _write_canonical_pack_zip(pack_root, "animation")
gallery = _advanced_gallery(pack_root)
progress(1.0, desc="Done")
status = f"Done. Advanced pack created at {zip_path}\n\n" + "\n".join(logs)
return gallery, str(pack_root / "rendered_v2.mp4"), str(pack_root / "rendered_mask_v2.mp4"), str(zip_path), status
except Exception:
logging.exception("Advanced character pack generation failed")
return [], None, None, None, traceback.format_exc()
@spaces.GPU(duration=GPU_DURATION, size=GPU_SIZE)
def build_two_character_pack(
character_0_ref,
character_1_ref,
driving_video,
prompt,
sam3_text,
progress=gr.Progress(track_tqdm=True),
):
try:
progress(0.0, desc="Checking SCAIL-Pose repo")
_require_repo_layout()
progress(0.04, desc="Preparing SAM3 weights")
sam3_model = _ensure_sam3_weights()
job_dir = _new_job_dir("two_character_animation")
pack_root = job_dir / "scail2_input_pack"
pack_root.mkdir(parents=True, exist_ok=True)
progress(0.08, desc="Preparing two-character inputs")
work_driving = job_dir / "driving.mp4"
_copy_video(driving_video, work_driving, "driving video")
shutil.copy2(work_driving, pack_root / "rendered_v2.mp4")
prepared_refs = [
CharacterRef(
character="character_0",
view="front",
path=_copy_reference_to_png(_as_path(character_0_ref, "character 0 reference"), pack_root / "characters" / "character_0" / "front.png"),
),
CharacterRef(
character="character_1",
view="front",
path=_copy_reference_to_png(_as_path(character_1_ref, "character 1 reference"), pack_root / "characters" / "character_1" / "front.png"),
),
]
character_names = ["character_0", "character_1"]
primary = prepared_refs[0]
_build_advanced_metadata(pack_root, "animation", prompt, primary, character_names, sam3_text)
progress(0.12, desc="Loading SAM3 predictors")
video_predictor, image_predictor = _load_sam3_predictors(sam3_model)
from TrackSam3.track import get_mask_from_image, get_mask_from_image_via_video, get_mask_from_video
from NLFPoseExtract.v2_helper import save_colored_mask_image, write_colored_mask_video
text = _text_args(sam3_text)
colors = SCAIL_COLORS[:2]
progress(0.20, desc="Tracking two subjects left-to-right")
drv_masks, drv_colors = get_mask_from_video(
str(work_driving),
video_predictor,
max_targets=2,
sort_by="x",
fixed_colors=colors,
text=text,
)
if len(drv_masks) < 2:
raise RuntimeError(
f"SAM3 detected {len(drv_masks)} subject(s), but this mode expects two. "
"Try a clearer driving video or use the Advanced Character Pack."
)
import cv2
cap = cv2.VideoCapture(str(work_driving))
fps = cap.get(cv2.CAP_PROP_FPS)
cap.release()
fps_int = max(1, int(round(fps or 24)))
write_colored_mask_video(
drv_masks[:2],
drv_colors[:2],
str(pack_root / "rendered_mask_v2.mp4"),
fps_int,
bg_color=(255, 255, 255),
)
progress(0.55, desc="Generating reference masks")
logs = [
"Mode: two-character animation",
"Mapping: left-most driving subject -> character_0; next subject to the right -> character_1",
f"Driving tracks detected: {len(drv_masks)}",
]
for ref in prepared_refs:
character_idx = character_names.index(ref.character)
color = drv_colors[character_idx]
mask_path = ref.path.with_name(f"{ref.view}_mask.png")
progress(None, desc=f"Masking {ref.character}")
try:
ref_masks, _ = get_mask_from_image_via_video(
str(ref.path),
video_predictor,
max_targets=1,
sort_by="x",
fixed_colors=[color],
text=text,
)
except Exception as exc:
logging.warning("Video-based image masking failed for %s, trying image predictor: %s", ref.path, exc)
ref_masks, _ = get_mask_from_image(
str(ref.path),
image_predictor,
max_targets=1,
sort_by="x",
fixed_colors=[color],
text=text,
)
if len(ref_masks) == 0:
raise RuntimeError(f"SAM3 did not detect a subject in {ref.character}.")
save_colored_mask_image([ref_masks[0]], [color], str(mask_path), bg_color=(255, 255, 255))
logs.append(f"- {ref.character}: {mask_path.relative_to(pack_root).as_posix()}")
progress(0.92, desc="Packaging two-character pack")
zip_path = _write_canonical_pack_zip(pack_root, "two_character_animation")
gallery = _advanced_gallery(pack_root)
progress(1.0, desc="Done")
status = f"Done. Two-character pack created at {zip_path}\n\n" + "\n".join(logs)
return gallery, str(pack_root / "rendered_v2.mp4"), str(pack_root / "rendered_mask_v2.mp4"), str(zip_path), status
except Exception:
logging.exception("Two-character pack generation failed")
return [], None, None, None, traceback.format_exc()
def build_ui():
with gr.Blocks(title="SCAIL-Pose Pack Builder") as demo:
gr.Markdown(
"# SCAIL-Pose Pack Builder\n"
"Generate SCAIL-2-ready masks and export them as an input pack. "
"Use the downloaded `.zip` in the SCAIL-2 demo Advanced Pack tab."
)
gr.Textbox(value=_repo_status(), label="Startup status", interactive=False, lines=7)
with gr.Tab("Simple Pack"):
gr.Markdown(
"Create a standard SCAIL-2 pack from one reference image and one video. "
"Choose Animation to follow motion from the driving video, or Replacement to replace "
"a target region in the source video."
)
simple_mode = gr.Radio(
["Animation", "Replacement"],
value="Animation",
label="Mode",
)
with gr.Row():
simple_ref = gr.Image(type="filepath", label="Reference image")
simple_driving = gr.Video(label="Driving / source video")
simple_prompt = gr.Textbox(label="Prompt for SCAIL-2", lines=3)
simple_sam3_text = gr.Textbox(
value="human character",
label="SAM3 text prompt",
info="Use one or more comma/newline-separated prompts, e.g. `human character` or `woman in red dress, man in blue jacket`.",
)
with gr.Accordion("Animation options", open=True):
simple_max_persons = gr.Number(value=2, precision=0, label="Max tracked subjects")
simple_crop = gr.Dropdown(
["none", "mask silhouette", "moving bbox", "steady bbox"],
value="none",
label="Driving crop mode",
)
with gr.Accordion("Replacement options", open=False):
simple_matchnearest = gr.Checkbox(
value=False,
label="Match nearest target when two people are detected",
)
simple_egocentric = gr.Checkbox(
value=False,
label="Egocentric: union two detected parts into one actor",
)
simple_run = gr.Button("Generate pack", variant="primary")
with gr.Row():
simple_ref_mask = gr.Image(label="Reference mask", interactive=False)
simple_mask_video = gr.Video(label="Mask video")
simple_rendered = gr.Video(label="Rendered / source video")
simple_zip = gr.File(label="Download SCAIL-2 pack")
simple_status = gr.Textbox(label="Run logs", lines=14)
simple_run.click(
build_simple_pack,
inputs=[
simple_mode,
simple_ref,
simple_driving,
simple_prompt,
simple_sam3_text,
simple_max_persons,
simple_crop,
simple_matchnearest,
simple_egocentric,
],
outputs=[simple_ref_mask, simple_rendered, simple_mask_video, simple_zip, simple_status],
)
with gr.Tab("Two Characters"):
gr.Markdown(
"Fast path for the common two-character case. Upload two reference images and one driving video. "
"`Character 0` should match the left-most subject in the driving video; `Character 1` should match "
"the next subject to the right."
)
with gr.Row():
two_char0 = gr.Image(type="filepath", label="Character 0 reference")
two_char1 = gr.Image(type="filepath", label="Character 1 reference")
two_driving = gr.Video(label="Driving video")
with gr.Accordion("Optional settings", open=False):
two_prompt = gr.Textbox(label="Prompt for SCAIL-2", lines=3)
two_sam3_text = gr.Textbox(
value="human character",
label="SAM3 text prompt",
info="Use a broad prompt such as `human character`, or comma/newline-separated prompts for distinct subjects.",
)
two_run = gr.Button("Generate two-character pack", variant="primary")
two_gallery = gr.Gallery(
label="References and generated masks",
columns=4,
height=320,
selected_index=0,
preview=True,
)
with gr.Row():
two_rendered = gr.Video(label="Rendered / driving video")
two_mask_video = gr.Video(label="Driving mask video")
two_zip = gr.File(label="Download two-character SCAIL-2 pack")
two_status = gr.Textbox(label="Run logs", lines=14)
two_run.click(
build_two_character_pack,
inputs=[
two_char0,
two_char1,
two_driving,
two_prompt,
two_sam3_text,
],
outputs=[two_gallery, two_rendered, two_mask_video, two_zip, two_status],
)
with gr.Tab("Advanced Character Pack"):
gr.Markdown(
"Create a canonical SCAIL-2 animation pack for multi-reference or multi-character cases. "
"Use this when one character has several views, or when several characters need separate reference slots."
)
with gr.Accordion("Reference naming", open=True):
gr.Markdown(
"The Advanced Character Pack uses a simple identity convention: "
"`character_0` must match the left-most tracked subject in the driving video, "
"`character_1` the next subject to the right, and so on. "
"Name your reference files according to that left-to-right order.\n\n"
"Example: if the driving video starts with the woman on the left and the man on "
"the right, use `character_0` for the woman and `character_1` for the man.\n\n"
"Upload reference images with names like:\n\n"
"```text\n"
"character_0__front.png\n"
"character_0__back.png\n"
"character_0__closeup.png\n"
"character_1__front.png\n"
"```\n\n"
"Or upload a zip with this structure:\n\n"
"```text\n"
"characters/\n"
" character_0/\n"
" front.png\n"
" back.png\n"
" character_1/\n"
" front.png\n"
"```\n\n"
"All views of the same character should use the same `character_N` prefix. "
"If the detected order is ambiguous or characters cross over, use a simple case first; "
"manual track assignment is not part of this V1."
)
with gr.Row():
adv_refs = gr.Files(
label="Reference images",
file_types=[".png", ".jpg", ".jpeg", ".webp"],
type="filepath",
)
adv_zip_in = gr.File(
label="Reference zip instead of files",
file_types=[".zip"],
type="filepath",
)
adv_driving = gr.Video(label="Driving video")
adv_prompt = gr.Textbox(label="Prompt for SCAIL-2", lines=3)
with gr.Row():
adv_sam3_text = gr.Textbox(
value="human character",
label="SAM3 text prompt",
info="Use broad prompts to detect all subjects, or comma/newline-separated prompts for distinct subjects.",
)
adv_max_subjects = gr.Number(value=2, precision=0, label="Max tracked subjects")
adv_run = gr.Button("Generate advanced character pack", variant="primary")
adv_gallery = gr.Gallery(
label="Parsed references and generated masks",
columns=4,
height=320,
selected_index=0,
preview=True,
)
with gr.Row():
adv_rendered = gr.Video(label="Rendered / driving video")
adv_mask_video = gr.Video(label="Driving mask video")
adv_zip = gr.File(label="Download canonical SCAIL-2 Advanced Pack")
adv_status = gr.Textbox(label="Run logs", lines=14)
adv_run.click(
build_advanced_character_pack,
inputs=[
adv_refs,
adv_zip_in,
adv_driving,
adv_prompt,
adv_sam3_text,
adv_max_subjects,
],
outputs=[adv_gallery, adv_rendered, adv_mask_video, adv_zip, adv_status],
)
with gr.Tab("Pack Format"):
gr.Markdown(
"The Simple Pack tab exports flat packs. The Two Characters and Advanced Character Pack tabs "
"export canonical character packs with explicit character folders.\n\n"
"Simple animation pack:\n"
"```text\n"
"ref.png\n"
"ref_mask.jpg\n"
"rendered_v2.mp4\n"
"rendered_mask_v2.mp4\n"
"prompt.txt\n"
"metadata.json\n"
"```\n\n"
"Replacement pack:\n"
"```text\n"
"ref.png\n"
"ref_mask.png\n"
"rendered_v2.mp4\n"
"replace_mask.mp4\n"
"prompt.txt\n"
"metadata.json\n"
"```\n\n"
"Advanced character pack:\n"
"```text\n"
"rendered_v2.mp4\n"
"rendered_mask_v2.mp4\n"
"prompt.txt\n"
"metadata.json\n"
"characters/\n"
" character_0/\n"
" front.png\n"
" front_mask.png\n"
" back.png\n"
" back_mask.png\n"
" character_1/\n"
" front.png\n"
" front_mask.png\n"
"```\n\n"
"For multi-reference, use several views under the same `character_N`. "
"For multi-character, use one folder per identity slot."
)
return demo
if __name__ == "__main__":
build_ui().queue(max_size=4).launch(show_error=True)