viddub / data /s02_make_clips.py
multimodalart's picture
multimodalart HF Staff
Upload folder using huggingface_hub
87f0da8 verified
Raw History Blame Contribute Delete
8.71 kB
"""Filter s01 results and cut training clips.
Per clip (data/clips/<sub>/<clip_id>/):
video.mp4 512x512, 81 frames, 24fps (face-centred crop, fixed box per clip)
mask.png 512x512 uint8, 255 = region to generate (lower face, union over the clip)
ref.png 512x512 face frame from outside the clip window (same crop box)
audio.wav 16kHz mono, aligned to the clip (SyncNet offset corrected)
Rows -> data/manifest/s02/shard_XXXX.jsonl
"""
import argparse
import json
import re
import subprocess
from pathlib import Path
import cv2
import decord
import imageio_ffmpeg
import numpy as np
from scipy.io import wavfile
from .common import DATA, MANIFEST, read_jsonl, resolve_video_path, shard
NUM_FRAMES, SIZE, CROP_SCALE = 81, 512, 1.8
LM_NOSE_BOTTOM, LM_CHEEK_R, LM_CHEEK_L, LM_CHIN, LM_LIP_UP, LM_LIP_LOW, LM_FOREHEAD = 2, 234, 454, 152, 13, 14, 10
FILTERS = { # decided from the s01 distribution, see STATE.md
"face_missing_max": 0.02, "multi_face_max": 0.02, "face_h_min": 150, "yaw_ok_min": 0.7,
"sync_conf_min": 6.0, "sync_offset_range": (-4, 2), "mouth_std_min": 0.01,
}
def keep(r):
f = FILTERS
return ("error" not in r and "sync_conf" in r and r["face_missing"] <= f["face_missing_max"]
and r["multi_face"] <= f["multi_face_max"] and r["face_h"] >= f["face_h_min"] and r["yaw_ok"] >= f["yaw_ok_min"]
and r["sync_conf"] >= f["sync_conf_min"] and f["sync_offset_range"][0] <= r["sync_offset"] <= f["sync_offset_range"][1]
and r["mouth_std"] >= f["mouth_std_min"] and r["n_frames"] >= NUM_FRAMES)
def clean_prompt(prompt, speech_text):
"""Drop sentences that carry the spoken line (it would conflict with new audio at dubbing time)."""
prompt = prompt.replace(speech_text, "")
parts = re.split(r"(?<=[。!?.!?])\s*", prompt)
bad = re.compile(r"台词|口型|说道|说:|lip|speak|says|saying|dialogue|words", re.I)
return " ".join(p for p in parts if p.strip() and not bad.search(p)).strip()
def crop_box(lm, H, W):
cx = (np.nanmin(lm[:, :, 0]) + np.nanmax(lm[:, :, 0])) / 2
cy = (np.nanmin(lm[:, :, 1]) + np.nanmax(lm[:, :, 1])) / 2
face = np.nanmedian(np.maximum(np.ptp(lm[:, :, 0], 1), np.ptp(lm[:, :, 1], 1)))
side = int(min(face * CROP_SCALE, H, W))
x0 = int(np.clip(cx - side / 2, 0, W - side))
y0 = int(np.clip(cy - side / 2, 0, H - side))
return x0, y0, side
def lower_face_mask(lm, box):
x0, y0, side = box
s = SIZE / side
face_h = np.nanmedian(lm[:, LM_CHIN, 1] - lm[:, LM_FOREHEAD, 1])
top = np.nanmin(lm[:, LM_NOSE_BOTTOM, 1])
bottom = np.nanmax(lm[:, LM_CHIN, 1]) + 0.1 * face_h
left, right = np.nanmin(lm[:, LM_CHEEK_R, 0]), np.nanmax(lm[:, LM_CHEEK_L, 0])
mask = np.zeros((SIZE, SIZE), np.uint8)
pts = np.array([[left, top], [right, bottom]])
(l, t), (r, b) = ((pts - [x0, y0]) * s).round().astype(int)
mask[max(t, 0):min(b, SIZE), max(l, 0):min(r, SIZE)] = 255
return mask
def write_video(path, frames):
proc = subprocess.Popen([imageio_ffmpeg.get_ffmpeg_exe(), "-loglevel", "error", "-y", "-f", "rawvideo", "-pix_fmt", "rgb24", "-s", f"{SIZE}x{SIZE}",
"-r", "24", "-i", "-", "-c:v", "libx264", "-crf", "15", "-pix_fmt", "yuv420p", str(path)], stdin=subprocess.PIPE)
proc.stdin.write(np.ascontiguousarray(frames).tobytes())
proc.stdin.close()
assert proc.wait() == 0
def load_selection(path):
"""Return source_id -> selected clip_ids from JSON, JSONL, or text."""
path = Path(path)
if path.suffix == ".json":
value = json.loads(path.read_text())
values = value.keys() if isinstance(value, dict) else value
else:
values = []
with path.open() as handle:
for line in handle:
if not line.strip():
continue
values.append(json.loads(line) if line.lstrip().startswith("{") else line.strip())
selected = {}
for value in values:
clip_id = value["clip_id"] if isinstance(value, dict) else str(value)
source_id = value.get("id") if isinstance(value, dict) else None
source_id = source_id or clip_id.rsplit("_", 1)[0]
selected.setdefault(source_id, set()).add(clip_id)
if not selected:
raise ValueError(f"Empty selection: {path}")
return selected
def make_clips(row, s01, selected=None, done=None):
vid = row["id"]
sub = vid[-6:-3]
face = np.load(DATA / "face" / sub / f"{vid}.npz")
lm = face["lm"].astype(np.float32)
T = len(lm)
vr = decord.VideoReader(str(resolve_video_path(row)), num_threads=2)
frames = vr.get_batch(list(range(T))).asnumpy()
H, W = frames.shape[1:3]
_, audio = wavfile.read(DATA / "audio" / sub / f"{vid}.wav")
mouth = (lm[:, LM_LIP_LOW, 1] - lm[:, LM_LIP_UP, 1]) / (lm[:, LM_CHIN, 1] - lm[:, LM_FOREHEAD, 1])
mouth = np.nan_to_num(mouth, nan=-1)
# best-matching audio for video time t is at t - offset/25 s (offset < 0: audio is late)
audio_shift = -s01["sync_offset"] / 25.0
starts = [0] if T - NUM_FRAMES < 16 else [0, T - NUM_FRAMES]
out = []
for start in starts:
clip_id = f"{vid}_{start:03d}"
if selected is not None and clip_id not in selected:
continue
if done is not None and clip_id in done:
continue
win = slice(start, start + NUM_FRAMES)
box = crop_box(lm[win], H, W)
x0, y0, side = box
outside = np.r_[0:start, start + NUM_FRAMES:T]
ref_idx = int(outside[np.argmax(mouth[outside])]) if len(outside) else (T - 1 if start == 0 else 0)
crop = lambda f: cv2.resize(f[y0:y0 + side, x0:x0 + side], (SIZE, SIZE), interpolation=cv2.INTER_AREA)
d = DATA / "clips" / sub / clip_id
d.mkdir(parents=True, exist_ok=True)
write_video(d / "video.mp4", np.stack([crop(f) for f in frames[win]]))
cv2.imwrite(str(d / "mask.png"), lower_face_mask(lm[win], box))
cv2.imwrite(str(d / "ref.png"), cv2.cvtColor(crop(frames[ref_idx]), cv2.COLOR_RGB2BGR))
a0 = int(round((start / 24.0 + audio_shift) * 16000))
n = int(round(NUM_FRAMES / 24.0 * 16000))
seg = np.zeros(n, np.int16)
src = audio[max(a0, 0):max(a0 + n, 0)]
seg[max(-a0, 0):max(-a0, 0) + len(src)] = src[:n - max(-a0, 0)]
wavfile.write(d / "audio.wav", 16000, seg)
out.append({"clip_id": clip_id, "id": vid, "dir": str(d.relative_to(DATA / "clips")), "start": start, "ref_idx": ref_idx,
"crop_box": box, "sync_conf": s01["sync_conf"], "sync_offset": s01["sync_offset"],
"lang": row.get("speech_language", row.get("language", "")),
"prompt": clean_prompt(row.get("prompt", ""), row.get("speech_text", ""))})
return out
def main():
ap = argparse.ArgumentParser()
ap.add_argument("--shard", type=int, default=0)
ap.add_argument("--num_shards", type=int, default=1)
ap.add_argument("--limit", type=int, default=None)
ap.add_argument(
"--selection",
help="Optional JSON/JSONL/text clip selection; unlisted windows are not materialized",
)
args = ap.parse_args()
selection = load_selection(args.selection) if args.selection else None
s01 = {}
for p in sorted((MANIFEST / "s01").glob("shard_*.jsonl")):
s01.update({r["id"]: r for r in read_jsonl(p)})
rows = [r for r in read_jsonl(MANIFEST / "s00_manifest.jsonl") if r["id"] in s01 and keep(s01[r["id"]])]
if selection is not None:
rows = [r for r in rows if r["id"] in selection]
if args.limit:
rows = rows[:args.limit]
rows = shard(rows, args.shard, args.num_shards)
out_path = MANIFEST / "s02" / f"shard_{args.shard:04d}.jsonl"
out_path.parent.mkdir(parents=True, exist_ok=True)
done = {
r["clip_id"] for r in read_jsonl(out_path) if "clip_id" in r
} if out_path.exists() else set()
with open(out_path, "a") as f:
for i, row in enumerate(rows):
try:
clips = make_clips(
row,
s01[row["id"]],
selected=selection.get(row["id"]) if selection is not None else None,
done=done,
)
except Exception as e:
clips = [{"id": row["id"], "error": repr(e)[:300]}]
for c in clips:
f.write(json.dumps(c, ensure_ascii=False) + "\n")
if "clip_id" in c:
done.add(c["clip_id"])
f.flush()
if i % 50 == 0:
print(f"[shard {args.shard}] {i}/{len(rows)}", flush=True)
if __name__ == "__main__":
main()