Spaces:
Running on Zero
Running on Zero
| """FaceAnything — Gradio demo (Hugging Face Space). | |
| Upload up to 40 face images (a short clip, in order). The model reconstructs the | |
| clip in a single feed-forward pass and the app returns: | |
| * canonical 2D video — per-frame canonical facial-coordinate map (original | map) | |
| * depth 2D video — per-frame JET depth map | |
| * normals 2D video — per-frame surface-normal map (from depth) | |
| * a colorful 3D point-track point cloud (.ply) you can orbit in the 3D viewer, | |
| with a frame slider to scrub through the sequence, plus a downloadable .zip | |
| of every frame's track point cloud. | |
| Two inference modes are exposed (the repo's `--process-mode`): | |
| * Joint (all-at-once) — all frames processed together: more 3D-consistent. | |
| * One-by-one — each frame independently: more surface detail, less | |
| memory (pairs well with a higher processing resolution). | |
| The heavy lifting reuses the published `faceanything` package unchanged; this app | |
| only orchestrates it and renders the requested outputs. The expensive Open3D | |
| orbit-video renderer is intentionally NOT used — the canonical/depth/normals | |
| videos and the track point clouds are produced from cheap NumPy ops. | |
| """ | |
| from __future__ import annotations | |
| import os | |
| import sys | |
| import glob | |
| import shutil | |
| import tempfile | |
| import traceback | |
| import numpy as np | |
| # --------------------------------------------------------------------------- # | |
| # Locate the published FaceAnything source. | |
| # | |
| # The model code (`src/faceanything`, `src/depth_anything_3`) is vendored into | |
| # this Space. For local testing against a source checkout, point FACEANYTHING_ROOT | |
| # at it instead: | |
| # export FACEANYTHING_ROOT=/cluster/eriador/ukocasari/projects/FaceAnything | |
| # --------------------------------------------------------------------------- # | |
| APP_DIR = os.path.dirname(os.path.abspath(__file__)) | |
| FA_ROOT = os.environ.get("FACEANYTHING_ROOT", APP_DIR) | |
| def _ensure_faceanything_importable(): | |
| """Make `import faceanything` work, trying a few sensible source locations.""" | |
| try: | |
| import faceanything # noqa: F401 (already installed / vendored) | |
| return | |
| except Exception: | |
| pass | |
| for cand in (os.path.join(APP_DIR, "src"), os.path.join(FA_ROOT, "src")): | |
| if os.path.isdir(os.path.join(cand, "faceanything")) and cand not in sys.path: | |
| sys.path.insert(0, cand) | |
| _ensure_faceanything_importable() | |
| BASE_MODEL = os.environ.get("FACEANYTHING_BASE_MODEL", "depth-anything/DA3-GIANT-1.1") | |
| GPU_DURATION = int(os.environ.get("FACEANYTHING_GPU_DURATION", "120")) | |
| MAX_IMAGES = int(os.environ.get("FACEANYTHING_MAX_IMAGES", "40")) | |
| # --------------------------------------------------------------------------- # | |
| # Checkpoint (~15 GB). Recommended storage: a separate HF *model* repo, pulled | |
| # once with `hf_hub_download` and cached (point HF_HOME at persistent storage, | |
| # e.g. /data/.huggingface, so it survives restarts). Resolution order: | |
| # 1. FACEANYTHING_CHECKPOINT — an explicit local file (used if it exists) | |
| # 2. FACEANYTHING_CHECKPOINT_REPO — download <FILE> from this HF repo | |
| # (private repos: set the HF_TOKEN secret) | |
| # 3. checkpoints/checkpoint.pt next to the app (e.g. committed via Git LFS) | |
| # --------------------------------------------------------------------------- # | |
| def _resolve_checkpoint(): | |
| explicit = os.environ.get("FACEANYTHING_CHECKPOINT") | |
| if explicit and os.path.exists(explicit): | |
| return explicit | |
| repo = os.environ.get("FACEANYTHING_CHECKPOINT_REPO") | |
| if repo: | |
| from huggingface_hub import hf_hub_download | |
| return hf_hub_download( | |
| repo_id=repo, | |
| filename=os.environ.get("FACEANYTHING_CHECKPOINT_FILE", "checkpoint.pt"), | |
| repo_type=os.environ.get("FACEANYTHING_CHECKPOINT_REPO_TYPE", "model"), | |
| revision=os.environ.get("FACEANYTHING_CHECKPOINT_REVISION") or None, | |
| token=os.environ.get("HF_TOKEN") or None, | |
| ) | |
| default = os.path.join(FA_ROOT, "checkpoints", "checkpoint.pt") | |
| if os.path.exists(default): | |
| return default | |
| raise FileNotFoundError( | |
| "No checkpoint found. Set FACEANYTHING_CHECKPOINT to a local file, or " | |
| "FACEANYTHING_CHECKPOINT_REPO to a Hugging Face repo id (add the HF_TOKEN " | |
| "secret if it is private), or place checkpoint.pt under checkpoints/." | |
| ) | |
| # Resolve (and download, if from a repo) at startup — on the CPU node, so the | |
| # 15 GB transfer never counts against ZeroGPU compute time. A failure here is | |
| # non-fatal: the UI still builds and the clear error surfaces on first run. | |
| try: | |
| CHECKPOINT = _resolve_checkpoint() | |
| print(f"[faceanything] checkpoint ready: {CHECKPOINT}", flush=True) | |
| except Exception as _ckpt_err: # noqa: BLE001 | |
| CHECKPOINT = None | |
| print(f"[faceanything] checkpoint not ready yet: {_ckpt_err}", flush=True) | |
| # --------------------------------------------------------------------------- # | |
| # ZeroGPU decorator — falls back to a no-op when `spaces` is unavailable | |
| # (e.g. running on a plain GPU box / cluster), so the same file runs anywhere. | |
| # --------------------------------------------------------------------------- # | |
| try: | |
| import spaces | |
| GPU = spaces.GPU | |
| except Exception: # pragma: no cover - only on non-Spaces hosts | |
| def GPU(func=None, **_kwargs): | |
| if callable(func): | |
| return func | |
| def _deco(f): | |
| return f | |
| return _deco | |
| import gradio as gr | |
| # --------------------------------------------------------------------------- # | |
| # Model (loaded once, lazily, inside the GPU context and cached across calls) | |
| # --------------------------------------------------------------------------- # | |
| _MODEL = None | |
| _MODEL_DEVICE = None | |
| def _get_model(device: str): | |
| global _MODEL, _MODEL_DEVICE, CHECKPOINT | |
| if _MODEL is not None and _MODEL_DEVICE == device: | |
| return _MODEL | |
| from faceanything.model import load_model | |
| # Re-resolve if the startup attempt failed (e.g. env was set afterwards). | |
| ckpt = CHECKPOINT or _resolve_checkpoint() | |
| CHECKPOINT = ckpt | |
| _MODEL = load_model(ckpt, base_model=BASE_MODEL, device=device) | |
| _MODEL_DEVICE = device | |
| return _MODEL | |
| # --------------------------------------------------------------------------- # | |
| # Helpers | |
| # --------------------------------------------------------------------------- # | |
| _IMAGE_EXTS = (".png", ".jpg", ".jpeg", ".bmp", ".webp", ".tif", ".tiff", ".gif") | |
| def _natural_key(name): | |
| """Sort key that orders frame_2 before frame_10 (numeric-aware).""" | |
| import re | |
| return [int(c) if c.isdigit() else c.lower() for c in re.split(r"(\d+)", name)] | |
| def _to_entry(f): | |
| """Normalize a Gradio file value (str / dict / FileData-like) to | |
| ``(temp_path, original_name)``. The original name drives ordering + extension; | |
| the temp path is what we actually copy from.""" | |
| if isinstance(f, str): | |
| return f, os.path.basename(f) | |
| if isinstance(f, dict): | |
| path = f.get("path") or f.get("name") | |
| orig = f.get("orig_name") or (os.path.basename(path) if path else None) | |
| return path, orig | |
| path = getattr(f, "path", None) or getattr(f, "name", None) | |
| orig = getattr(f, "orig_name", None) or (os.path.basename(path) if path else None) | |
| return path, orig | |
| def _sniff_ext(path): | |
| """Detect an image extension from file content (Gradio temp files often have | |
| no usable extension). Returns a safe default of .png if undetectable.""" | |
| try: | |
| from PIL import Image | |
| with Image.open(path) as im: | |
| fmt = (im.format or "").lower() | |
| return {"jpeg": ".jpg", "png": ".png", "webp": ".webp", "bmp": ".bmp", | |
| "gif": ".gif", "tiff": ".tif", "mpo": ".jpg"}.get(fmt, ".png") | |
| except Exception: | |
| return ".png" | |
| def _extract_video(video_path, max_frames, out_dir): | |
| """Decode the first ``max_frames`` frames of a video. Uses cv2.VideoCapture, | |
| which (unlike imageio's extension-based plugin pick) robustly decodes webcam | |
| recordings — those were yielding only a single frame otherwise.""" | |
| import cv2 | |
| os.makedirs(out_dir, exist_ok=True) | |
| paths = [] | |
| cap = cv2.VideoCapture(video_path) | |
| try: | |
| while len(paths) < int(max_frames): | |
| ok, frame = cap.read() | |
| if not ok: | |
| break | |
| p = os.path.join(out_dir, f"frame_{len(paths):04d}.png") | |
| cv2.imwrite(p, frame) # BGR ndarray -> correct-RGB PNG on disk | |
| paths.append(p) | |
| finally: | |
| cap.release() | |
| return paths | |
| def _prepare_inputs(files, video, max_frames, workdir): | |
| """Normalize the upload (an image set OR a video) into an ordered list of | |
| frame paths. | |
| A video takes precedence: it is decoded and its first ``max_frames`` frames | |
| are used. For images we don't glob by extension (Gradio temp files often lack | |
| one): files are natural-sorted by their original name (temporal order), | |
| capped, then copied as ``frame_XXXX.<ext>`` with a content-sniffed extension. | |
| """ | |
| if video: | |
| vpath = video if isinstance(video, str) else _to_entry(video)[0] | |
| if vpath and os.path.exists(vpath): | |
| paths = _extract_video(vpath, max_frames, | |
| os.path.join(workdir, "video_frames")) | |
| if len(paths) <= 1: # fall back to imageio if cv2 read too few frames | |
| from faceanything.io_utils import load_frame_paths | |
| try: | |
| alt, _ = load_frame_paths( | |
| vpath, max_frames=int(max_frames), stride=1, | |
| work_dir=os.path.join(workdir, "video_frames_io")) | |
| if len(alt) > len(paths): | |
| paths = alt | |
| except Exception: | |
| pass | |
| if paths: | |
| return paths | |
| if not files: | |
| raise gr.Error("Please upload images or a video.") | |
| entries = [] | |
| for f in files: | |
| path, orig = _to_entry(f) | |
| if path and os.path.exists(path): | |
| entries.append((path, orig or os.path.basename(path))) | |
| if not entries: | |
| raise gr.Error("Could not read the uploaded files — please re-upload your images.") | |
| entries.sort(key=lambda e: _natural_key(e[1])) | |
| entries = entries[:int(max_frames)] | |
| img_dir = os.path.join(workdir, "images") | |
| os.makedirs(img_dir, exist_ok=True) | |
| out = [] | |
| for i, (path, orig) in enumerate(entries): | |
| ext = os.path.splitext(orig)[1].lower() | |
| if ext not in _IMAGE_EXTS: | |
| ext = _sniff_ext(path) | |
| dst = os.path.join(img_dir, f"frame_{i:04d}{ext}") | |
| shutil.copy(path, dst) | |
| out.append(dst) | |
| if not out: | |
| raise gr.Error("No valid images found in the upload.") | |
| return out | |
| def _srgb_to_linear(cols_u8): | |
| """sRGB uint8 (0-255) -> linear uint8. glTF COLOR_0 vertex colors are | |
| interpreted as *linear* and the viewer re-applies the display gamma, so our | |
| sRGB image colors must be linearized first or the points render washed-out.""" | |
| c = np.asarray(cols_u8, np.float32) / 255.0 | |
| lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4) | |
| return np.clip(lin * 255.0, 0, 255).astype(np.uint8) | |
| def _points_to_glb(path, points, colors, max_points=1_000_000): | |
| """Write a colored point cloud as a ``.glb`` — the format gradio's Model3D | |
| renders as points (a vertex-only ``.ply`` is treated as an empty solid mesh). | |
| ``points`` must already be in glTF axes; sRGB colors are linearized for glTF's | |
| linear color space. The full cloud is kept (``max_points`` is only an | |
| extreme-size safety cap, matching DA3's default) so it renders dense, not | |
| sparse.""" | |
| import trimesh | |
| pts = np.asarray(points, np.float32) | |
| cols = np.asarray(colors) | |
| finite = np.isfinite(pts).all(axis=1) | |
| pts, cols = pts[finite], cols[finite] | |
| if pts.shape[0] == 0: # keep the viewer from erroring on an empty frame | |
| pts = np.zeros((1, 3), np.float32) | |
| cols = np.full((1, 3), 200, np.uint8) | |
| if pts.shape[0] > max_points: | |
| idx = np.random.default_rng(0).choice(pts.shape[0], max_points, replace=False) | |
| pts, cols = pts[idx], cols[idx] | |
| if cols.dtype != np.uint8: | |
| cols = np.clip(cols, 0, 255).astype(np.uint8) | |
| rgb = _srgb_to_linear(cols[:, :3]) | |
| rgba = np.concatenate( | |
| [rgb, np.full((rgb.shape[0], 1), 255, np.uint8)], axis=1) | |
| scene = trimesh.Scene() | |
| scene.add_geometry(trimesh.points.PointCloud(vertices=pts, colors=rgba)) | |
| scene.export(path) | |
| return path | |
| # --------------------------------------------------------------------------- # | |
| # Face + hair segmentation (FacePerceiver/facer). | |
| # | |
| # The colorful tracks should land only on the facial area and hair, not on the | |
| # neck / shoulders / clothing. facer's face parser (CelebAMask-HQ classes) gives | |
| # us exactly that: we keep every class except background / neck / necklace / | |
| # cloth / hat and use it to restrict the track seeds and recoloring. | |
| # --------------------------------------------------------------------------- # | |
| _FACER = {} | |
| def _get_face_detector(device): | |
| """Lazily build & cache facer's RetinaFace detector (used for the face crop).""" | |
| if not _FACER.get("detector"): | |
| import facer | |
| _FACER["detector"] = facer.face_detector("retinaface/mobilenet", device=device) | |
| return _FACER["detector"] | |
| def _get_face_parser(device): | |
| """Lazily build & cache facer's face detector + parser.""" | |
| if not _FACER.get("parser"): | |
| import facer | |
| _FACER["parser"] = facer.face_parser("farl/celebm/448", device=device) | |
| return _get_face_detector(device), _FACER["parser"] | |
| def _face_hair_masks(images, device, log): | |
| """Per-frame boolean (H,W) mask of the facial area + hair via facer. | |
| Returns a list aligned with ``images`` (an entry is ``None`` when no face was | |
| detected for that frame), or ``None`` entirely when facer is unavailable — | |
| the caller then falls back to unrestricted tracks.""" | |
| try: | |
| import torch | |
| import facer | |
| except Exception as e: # facer / its deps not installed | |
| log.append(f"WARNING: facer unavailable ({e}); colorful tracks are not " | |
| f"restricted to face + hair.") | |
| return None | |
| try: | |
| detector, parser = _get_face_parser(device) | |
| except Exception as e: | |
| log.append(f"WARNING: could not load facer models ({e}); colorful tracks " | |
| f"are not restricted to face + hair.") | |
| return None | |
| def _is_excluded(name): | |
| n = name.lower() | |
| return any(b in n for b in ("background", "neck", "cloth", "hat")) | |
| masks, n_ok = [], 0 | |
| for img in images: | |
| try: | |
| t = facer.hwc2bchw(torch.from_numpy(np.ascontiguousarray(img))).to(device) | |
| with torch.inference_mode(): | |
| faces = detector(t) | |
| rects = faces.get("rects") if faces else None | |
| if rects is None or len(rects) == 0: | |
| masks.append(None) | |
| continue | |
| faces = parser(t, faces) | |
| seg = faces["seg"] | |
| labels = seg["label_names"] | |
| argmax = seg["logits"].softmax(dim=1).argmax(dim=1) # (nfaces, H, W) | |
| keep = [ci for ci, nm in enumerate(labels) if not _is_excluded(nm)] | |
| m = torch.zeros(argmax.shape[-2:], dtype=torch.bool, device=argmax.device) | |
| for f in range(argmax.shape[0]): | |
| for ci in keep: | |
| m |= (argmax[f] == ci) | |
| masks.append(m.cpu().numpy()) | |
| n_ok += 1 | |
| except Exception: | |
| masks.append(None) | |
| if n_ok == 0: | |
| log.append("WARNING: facer detected no faces; colorful tracks are not " | |
| "restricted to face + hair.") | |
| return None | |
| log.append(f"Face + hair segmentation (facer): {n_ok}/{len(images)} frame(s).") | |
| return masks | |
| # --------------------------------------------------------------------------- # | |
| # Face-centric cropping (pixel3dmm-style). | |
| # | |
| # Mirrors SimonGiebenhain/pixel3dmm `get_cstm_crop` (scripts/run_cropping.py + | |
| # preprocessing/pipnet_utils.py): detect a face box, square it, expand it ~1.42x | |
| # (or 1.1x of the clip's union box when the face moves a lot), clamp to the image, | |
| # crop and resize. For a clip we compute ONE static box (mean + union over frames) | |
| # so the crop is temporally stable. We reuse facer's RetinaFace detector for the | |
| # box (no extra PIPNet/FaceBoxes weights). Cropping focuses the model's pixels on | |
| # the face instead of the background / body. | |
| # --------------------------------------------------------------------------- # | |
| def _cstm_crop_box(mean_b, max_b, img_h, img_w, scale=1.42): | |
| """pixel3dmm get_cstm_crop → (ymin, ymax, xmin, xmax). Boxes are (x, y, w, h).""" | |
| det = list(mean_b); s = scale | |
| if det[2] * scale * det[3] * scale < max_b[2] * 1.1 * max_b[3] * 1.1: | |
| det = list(max_b); s = 1.1 | |
| xmin, ymin, dw, dh = det | |
| if dw > dh: # square it: grow the shorter side symmetrically | |
| ymin -= (dw - dh) / 2.0; dh = dw | |
| elif dw < dh: | |
| xmin -= (dh - dw) / 2.0; dw = dh | |
| xmax = xmin + dw - 1; ymax = ymin + dh - 1 | |
| xmin -= dw * (s - 1) / 2.0; ymin -= dh * (s - 1) / 2.0 # expand by the scale | |
| xmax += dw * (s - 1) / 2.0; ymax += dh * (s - 1) / 2.0 | |
| if xmin < 0 or ymin < 0: # shift inside the image, preserving the square | |
| o = min(xmin, ymin); xmin -= o; ymin -= o | |
| if xmax > img_w - 1 or ymax > img_h - 1: | |
| o = max(xmax - (img_w - 1), ymax - (img_h - 1)); xmax -= o; ymax -= o | |
| xmin = max(int(round(xmin)), 0); ymin = max(int(round(ymin)), 0) | |
| xmax = min(int(round(xmax)), img_w - 1); ymax = min(int(round(ymax)), img_h - 1) | |
| return ymin, ymax, xmin, xmax | |
| def _combine_face_hair_box(face_box, hair_bbox, img_h, img_w, | |
| pad_top=0.06, pad_side=0.03, pad_bot=0.03, | |
| max_aspect=1.5): | |
| """Square crop around the face + hair segmentation bbox. The model performs | |
| better on square inputs, so we square the box — but base it on the *tight* | |
| face+hair mask (not the expanded detection box) to keep the inherent | |
| left/right background (a head is taller than wide) to a minimum. | |
| side = larger padded box dim, but capped at ``max_aspect`` * head width so a | |
| very tall head doesn't produce huge side margins; the box is centered | |
| horizontally and anchored at the bottom, so the chin/face is always kept and | |
| only a little hair-top is dropped when the cap bites ("hair is mostly in"). | |
| Falls back to the face box when no hair bbox is available. | |
| face_box: (ymin, ymax, xmin, xmax). hair_bbox: (x0, y0, x1, y1) or None.""" | |
| if hair_bbox is None: | |
| return face_box | |
| hx0, hy0, hx1, hy1 = hair_bbox | |
| bw = max(hx1 - hx0, 1.0); bh = max(hy1 - hy0, 1.0) | |
| x0 = hx0 - pad_side * bw; x1 = hx1 + pad_side * bw | |
| y0 = hy0 - pad_top * bh; y1 = hy1 + pad_bot * bh | |
| bw_p, bh_p = x1 - x0, y1 - y0 | |
| side = min(max(bw_p, bh_p), max_aspect * bw_p, float(img_w), float(img_h)) | |
| nx0 = (x0 + x1) / 2.0 - side / 2.0 # centered horizontally on the head | |
| ny0 = y1 - side # anchored at the bottom (keep the chin) | |
| nx0 = min(max(nx0, 0.0), img_w - side) | |
| ny0 = min(max(ny0, 0.0), img_h - side) | |
| xmin = int(round(nx0)); ymin = int(round(ny0)); side = int(round(side)) | |
| return ymin, min(ymin + side, img_h - 1), xmin, min(xmin + side, img_w - 1) | |
| def _face_crop_frames(frame_paths, device, out_dir, log, process_res): | |
| """Crop every frame to a face-centric square (pixel3dmm-style), grown to also | |
| cover the hair (top of head + long hair on the sides) via the face+hair | |
| segmentation. Returns new frame paths; on any failure (facer missing / no face) | |
| returns the originals so the run never breaks.""" | |
| import cv2 | |
| try: | |
| import torch | |
| import facer | |
| detector = _get_face_detector(device) | |
| except Exception as e: | |
| log.append(f"WARNING: face crop unavailable ({e}); using full frames.") | |
| return frame_paths | |
| imgs = [cv2.imread(fp) for fp in frame_paths] | |
| rgb_imgs = [cv2.cvtColor(im, cv2.COLOR_BGR2RGB) if im is not None else None | |
| for im in imgs] | |
| sizes = [im.shape[:2] if im is not None else None for im in imgs] | |
| # face detection boxes (x, y, w, h) | |
| boxes = [] | |
| for rgb in rgb_imgs: | |
| if rgb is None: | |
| boxes.append(None); continue | |
| try: | |
| t = facer.hwc2bchw(torch.from_numpy(np.ascontiguousarray(rgb))).to(device) | |
| with torch.inference_mode(): | |
| faces = detector(t) | |
| rects = faces.get("rects") if faces else None | |
| if rects is None or len(rects) == 0: | |
| boxes.append(None); continue | |
| scores = faces.get("scores") | |
| bi = int(scores.argmax()) if scores is not None and len(scores) else 0 | |
| x1, y1, x2, y2 = [float(v) for v in rects[bi].tolist()] | |
| boxes.append([x1, y1, x2 - x1, y2 - y1]) # x, y, w, h | |
| except Exception: | |
| boxes.append(None) | |
| valid = [b for b in boxes if b is not None] | |
| if not valid: | |
| log.append("WARNING: face crop found no faces; using full frames.") | |
| return frame_paths | |
| # face + hair mask → bbox per frame, so the crop encloses the hair, not just | |
| # the face detection box (which starts around the hairline). | |
| hair_masks = _face_hair_masks(rgb_imgs, device, log) | |
| def _mask_bbox(m): | |
| if m is None: | |
| return None | |
| ys, xs = np.nonzero(m) | |
| if not len(xs): | |
| return None | |
| return [int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())] | |
| hair_bboxes = ([_mask_bbox(m) for m in hair_masks] | |
| if hair_masks is not None else [None] * len(frame_paths)) | |
| # One static box for the whole clip when every frame shares a resolution. | |
| uniq = set(s for s in sizes if s is not None) | |
| static_box = None | |
| if len(uniq) == 1: | |
| H, W = next(iter(uniq)) | |
| xs = np.array([b[0] for b in valid]); ys = np.array([b[1] for b in valid]) | |
| ws = np.array([b[2] for b in valid]); hs = np.array([b[3] for b in valid]) | |
| x0, y0 = xs.min(), ys.min() | |
| mean_b = [xs.mean(), ys.mean(), ws.mean(), hs.mean()] | |
| max_b = [x0, y0, (xs + ws - x0).max(), (ys + hs - y0).max()] # union box | |
| face_static = _cstm_crop_box(mean_b, max_b, H, W) | |
| hb = [b for b in hair_bboxes if b is not None] | |
| hair_static = ([min(b[0] for b in hb), min(b[1] for b in hb), | |
| max(b[2] for b in hb), max(b[3] for b in hb)] if hb else None) | |
| static_box = _combine_face_hair_box(face_static, hair_static, H, W) | |
| out_size = int(min(1024, max(512, int(process_res)))) | |
| crop_dir = os.path.join(out_dir, "cropped") | |
| os.makedirs(crop_dir, exist_ok=True) | |
| fallback = [np.mean([b[0] for b in valid]), np.mean([b[1] for b in valid]), | |
| np.mean([b[2] for b in valid]), np.mean([b[3] for b in valid])] | |
| new_paths, n_cropped = [], 0 | |
| for i, fp in enumerate(frame_paths): | |
| im = imgs[i] | |
| if im is None: | |
| new_paths.append(fp); continue | |
| h, w = im.shape[:2] | |
| if static_box is not None: | |
| ymin, ymax, xmin, xmax = static_box | |
| else: | |
| b = boxes[i] if boxes[i] is not None else fallback | |
| face_box = _cstm_crop_box(b, b, h, w) | |
| ymin, ymax, xmin, xmax = _combine_face_hair_box( | |
| face_box, hair_bboxes[i], h, w) | |
| crop = im[ymin:ymax, xmin:xmax] | |
| out_fp = os.path.join(crop_dir, f"frame_{i:04d}.png") | |
| if crop.size == 0: | |
| cv2.imwrite(out_fp, im) | |
| else: | |
| # keep aspect ratio (the model resizes the longest side itself); only | |
| # downscale if the crop is larger than we need. | |
| ch, cw = crop.shape[:2] | |
| longest = max(ch, cw) | |
| if longest > out_size: | |
| sc = out_size / float(longest) | |
| crop = cv2.resize(crop, (max(1, round(cw * sc)), max(1, round(ch * sc)))) | |
| cv2.imwrite(out_fp, crop) | |
| n_cropped += 1 | |
| new_paths.append(out_fp) | |
| log.append( | |
| f"Face crop (pixel3dmm-style, hair-aware): {n_cropped}/{len(frame_paths)} " | |
| f"frame(s) → {out_size}x{out_size}" | |
| + (" static box." if static_box is not None else " per-frame.")) | |
| return new_paths | |
| def run( | |
| files, | |
| video, | |
| mode, | |
| process_res, | |
| remove_bg, | |
| face_crop, | |
| conf_percentile, | |
| n_tracks, | |
| track_k, | |
| track_threshold, | |
| fps, | |
| max_frames, | |
| progress=gr.Progress(), | |
| ): | |
| """End-to-end inference + visualization. Returns the 5 outputs + viewer state.""" | |
| import torch | |
| # Imported here so the UI still builds even if heavy deps are missing. | |
| from faceanything.predict import run_inference | |
| from faceanything.geometry import ( | |
| point_cloud_from_depth, | |
| unproject_depth, | |
| pointmap_to_normals, | |
| ) | |
| from faceanything.colorize import ( | |
| depth_to_jet, | |
| normals_to_rgb, | |
| canonical_to_rgb, | |
| ) | |
| from faceanything.tracking import compute_track_colors | |
| from faceanything.export import save_ply | |
| from faceanything.render import side_by_side, write_video | |
| device = "cuda" if torch.cuda.is_available() else "cpu" | |
| workdir = tempfile.mkdtemp(prefix="faceanything_demo_") | |
| log = [] | |
| def _say(frac, msg): | |
| log.append(msg) | |
| progress(frac, desc=msg) | |
| try: | |
| _say(0.02, "Preparing inputs…") | |
| frame_paths = _prepare_inputs( | |
| files, video, min(int(max_frames), MAX_IMAGES), workdir) | |
| n_in = len(frame_paths) | |
| if n_in == 0: | |
| raise gr.Error("No valid images found in the upload.") | |
| log.append(f"{n_in} frame(s) | mode: {mode} | process_res: {int(process_res)}") | |
| # ---- face-centric crop (optional, pixel3dmm-style) ---- | |
| # Done before background removal so the masks align with the cropped frames. | |
| if face_crop: | |
| _say(0.05, "Cropping to the face (pixel3dmm-style)…") | |
| frame_paths = _face_crop_frames(frame_paths, device, workdir, log, | |
| process_res) | |
| # ---- background removal (optional) ---- | |
| mask_paths = None | |
| if remove_bg: | |
| _say(0.08, "Removing background (Robust Video Matting)…") | |
| from faceanything.background import generate_masks | |
| try: | |
| mask_paths = generate_masks( | |
| frame_paths, os.path.join(workdir, "masks"), device=device | |
| ) | |
| except Exception as bg_err: # don't let RVM take down the whole run | |
| mask_paths = None | |
| log.append(f"WARNING: background removal failed ({bg_err}); " | |
| f"reconstructing the full frame instead.") | |
| # ---- model + inference ---- | |
| _say(0.15, "Loading model (first run downloads/loads the checkpoint)…") | |
| model = _get_model(device) | |
| _say(0.30, f"Running inference on {n_in} frame(s)…") | |
| pred = run_inference( | |
| model, | |
| frame_paths, | |
| mask_paths=mask_paths, | |
| process_res=int(process_res), | |
| monocular=False, # always use predicted camera poses (world frame) | |
| conf_percentile=float(conf_percentile), | |
| per_frame=(mode == "One-by-one"), | |
| ) | |
| N = int(pred.depth.shape[0]) | |
| has_canon = pred.canonical is not None | |
| log.append(f"Inference done: {N} frame(s), depth {tuple(pred.depth.shape[1:])}, " | |
| f"canonical: {has_canon}") | |
| # ---- per-frame clouds + global color ranges (mirrors run_inference.py) ---- | |
| _say(0.55, "Building point clouds and color maps…") | |
| clouds = [] | |
| for i in range(N): | |
| pts, rgb, canon, pix = point_cloud_from_depth( | |
| pred.depth[i], pred.images[i], pred.intrinsics[i], | |
| extrinsics=pred.extrinsics[i], valid_mask=pred.valid[i], | |
| deformation=pred.canonical[i] if has_canon else None, | |
| ) | |
| depth_vals = pred.depth[i][pix[:, 0], pix[:, 1]] | |
| clouds.append(dict(points=pts, rgb=rgb, canonical=canon, | |
| depth_vals=depth_vals, pix=pix)) | |
| all_depth = (np.concatenate([c["depth_vals"] for c in clouds]) | |
| if N else np.zeros(1)) | |
| dmin, dmax = (np.percentile(all_depth, [2, 98]) if all_depth.size else (0.0, 1.0)) | |
| if dmax <= dmin: | |
| dmax = dmin + 1e-6 | |
| canon_ranges = None | |
| if has_canon: | |
| allc = np.concatenate([c["canonical"] for c in clouds | |
| if c["canonical"] is not None]) | |
| _, canon_ranges = canonical_to_rgb(allc.reshape(-1, 1, 3), None) | |
| # ---- 2D maps (image space) ---- | |
| def frame2d(modality, i): | |
| v = pred.valid[i] | |
| if modality == "depth": | |
| return depth_to_jet(pred.depth[i], v, dmin, dmax) | |
| if modality == "normals": | |
| nmap = pointmap_to_normals( | |
| unproject_depth(pred.depth[i], pred.intrinsics[i], None)[0]) | |
| img = normals_to_rgb(nmap) | |
| img[~v] = 255 | |
| return img | |
| if modality == "canonical": | |
| img, _ = canonical_to_rgb(pred.canonical[i], v, ranges=canon_ranges) | |
| return img | |
| raise ValueError(modality) | |
| vids_dir = os.path.join(workdir, "videos") | |
| os.makedirs(vids_dir, exist_ok=True) | |
| def make_2d_video(modality): | |
| seq = [side_by_side(pred.images[i], frame2d(modality, i)) for i in range(N)] | |
| seq = seq * 30 if len(seq) == 1 else seq # avoid 1-frame videos | |
| out = os.path.join(vids_dir, f"{modality}_2d.mp4") | |
| write_video(seq, out, fps=int(fps)) | |
| return out | |
| _say(0.65, "Rendering depth 2D video…") | |
| depth_vid = make_2d_video("depth") | |
| _say(0.72, "Rendering normals 2D video…") | |
| normals_vid = make_2d_video("normals") | |
| canonical_vid = None | |
| if has_canon: | |
| _say(0.79, "Rendering canonical 2D video…") | |
| canonical_vid = make_2d_video("canonical") | |
| # ---- colorful point tracks (canonical NN matching) ---- | |
| tracks_zip = None | |
| tracks2d_vid = None | |
| view_glbs = [] | |
| if has_canon: | |
| # face + hair mask (facer) → restrict the colorful tracks to the face | |
| # and hair, never the neck / shoulders / clothing. | |
| _say(0.83, "Segmenting face + hair (facer)…") | |
| face_masks = _face_hair_masks(pred.images, device, log) | |
| regions, seed_frame = None, 0 | |
| if face_masks is not None: | |
| regions = [] | |
| for i, c in enumerate(clouds): | |
| pix = c["pix"] | |
| fm = face_masks[i] | |
| if fm is None: | |
| regions.append(np.zeros(pix.shape[0], bool)) | |
| else: | |
| regions.append(fm[pix[:, 0], pix[:, 1]]) | |
| sizes = [int(r.sum()) for r in regions] | |
| if sizes and max(sizes) > 0: | |
| seed_frame = int(np.argmax(sizes)) # seed where the face is biggest | |
| else: | |
| regions = None # nothing usable — don't restrict | |
| _say(0.86, f"Computing {int(n_tracks)} colorful point tracks…") | |
| track_colors, track_overlay = compute_track_colors( | |
| [dict(canonical=c["canonical"], rgb=c["rgb"], pix=c["pix"]) | |
| for c in clouds], | |
| n_tracks=int(n_tracks), k=int(track_k), | |
| threshold=float(track_threshold), | |
| regions=regions, seed_frame=seed_frame, | |
| ) | |
| import trimesh | |
| # glTF alignment, shared across frames so the slider view stays stable: | |
| # orient to the first camera, flip Y/Z (OpenCV -> glTF), center by median. | |
| w2c0 = pred.extrinsics[0].astype(np.float64) | |
| A = np.diag([1.0, -1.0, -1.0, 1.0]) @ w2c0 | |
| all_pts = (np.concatenate([c["points"] for c in clouds]) | |
| if N else np.zeros((1, 3))) | |
| center = np.median(trimesh.transform_points(all_pts, A), axis=0) | |
| T = np.eye(4); T[:3, 3] = -center | |
| A = T @ A | |
| # Downloadable .ply (repo coords): two colorings in the same zip. | |
| # Viewer .glb (glTF-aligned): one set track-colored, one set plain RGB | |
| # — the viewer toggles between them client-side (≤ 2·N ≈ 80 files). | |
| tracks_dir = os.path.join(workdir, "pointclouds", "tracks") | |
| points_dir = os.path.join(workdir, "pointclouds", "points") | |
| view_dir = os.path.join(workdir, "anim_glb") | |
| for d in (tracks_dir, points_dir, view_dir): | |
| os.makedirs(d, exist_ok=True) | |
| track_glbs, rgb_glbs = [], [] | |
| for i in range(N): | |
| pts = clouds[i]["points"] | |
| save_ply(os.path.join(tracks_dir, f"frame_{i:04d}.ply"), | |
| pts, track_colors[i]) # colorful tracks | |
| save_ply(os.path.join(points_dir, f"frame_{i:04d}.ply"), | |
| pts, clouds[i]["rgb"]) # plain colored points | |
| aligned = trimesh.transform_points(pts, A) | |
| tg = os.path.join(view_dir, f"track_{i:04d}.glb") | |
| rg = os.path.join(view_dir, f"rgb_{i:04d}.glb") | |
| _points_to_glb(tg, aligned, track_colors[i]) # colorful tracks | |
| _points_to_glb(rg, aligned, clouds[i]["rgb"]) # image RGB colors | |
| track_glbs.append(tg) | |
| rgb_glbs.append(rg) | |
| view_glbs = track_glbs + rgb_glbs | |
| tracks_zip = shutil.make_archive( | |
| os.path.join(workdir, "pointclouds"), "zip", | |
| os.path.join(workdir, "pointclouds")) | |
| # bonus: 2D track overlay video (colorful seeds on the original frames) | |
| _say(0.93, "Rendering 2D track overlay video…") | |
| def _paint(img, pix, col, radius): | |
| H, W = img.shape[:2] | |
| for dr in range(-radius, radius + 1): | |
| for dc in range(-radius, radius + 1): | |
| rr = np.clip(pix[:, 0] + dr, 0, H - 1) | |
| cc = np.clip(pix[:, 1] + dc, 0, W - 1) | |
| img[rr, cc] = col | |
| t_seq = [] | |
| for i in range(N): | |
| img = pred.images[i].copy() | |
| img[~pred.valid[i]] = 255 | |
| pix, col = track_overlay[i] | |
| if pix.shape[0]: | |
| _paint(img, pix, col, radius=max(2, round(img.shape[0] / 160))) | |
| t_seq.append(side_by_side(pred.images[i], img)) | |
| t_seq = t_seq * 30 if len(t_seq) == 1 else t_seq | |
| tracks2d_vid = os.path.join(vids_dir, "tracks_2d.mp4") | |
| write_video(t_seq, tracks2d_vid, fps=int(fps)) | |
| _say(1.0, "Done.") | |
| if not has_canon: | |
| log.append("WARNING: model produced no canonical output — canonical " | |
| "video and point tracks were skipped (check the checkpoint).") | |
| status = "\n".join(f"• {m}" for m in log) | |
| # view_glbs is the per-frame track point cloud (.glb), glTF-aligned and | |
| # track-colored. They go to the hidden file list, whose URLs the | |
| # client-side three.js player preloads and animates (see VIEWER_JS). | |
| return ( | |
| view_glbs or None, | |
| canonical_vid, | |
| depth_vid, | |
| normals_vid, | |
| tracks2d_vid, | |
| tracks_zip, | |
| status, | |
| ) | |
| except gr.Error: | |
| raise | |
| except Exception as e: # surface the traceback in the UI instead of a blank fail | |
| tb = traceback.format_exc() | |
| raise gr.Error(f"Inference failed: {e}\n\n{tb[-1500:]}") | |
| # --------------------------------------------------------------------------- # | |
| # UI | |
| # --------------------------------------------------------------------------- # | |
| DESCRIPTION = """ | |
| # Face Anything: 4D Face Reconstruction from Any Image Sequence | |
| Upload **up to 40 face images** (a short clip, named so they sort in order). | |
| The model jointly predicts depth and **canonical facial coordinates** in a single | |
| feed-forward pass, from which we derive canonical / depth / normal maps and dense, | |
| temporally-consistent **3D point tracks**. | |
| [Project page](https://kocasariumut.github.io/FaceAnything/) · | |
| [arXiv](https://arxiv.org/abs/2604.19702) · | |
| [Code](https://github.com/kocasariumut/FaceAnything) | |
| """ | |
| # --------------------------------------------------------------------------- # | |
| # Custom 3D viewer (client-side three.js). | |
| # | |
| # gradio's Model3D re-fetches and re-parses a .glb from the server on every | |
| # frame, so animating it flashes white (the next cloud isn't on the client yet). | |
| # Instead we load *every* frame's .glb once into a three.js scene and animate by | |
| # toggling which frame is visible — no per-frame network/parse, the points stay | |
| # on screen the whole time, and the full (un-subsampled) cloud is kept. | |
| # --------------------------------------------------------------------------- # | |
| THREE_HEAD = """ | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.137.0/build/three.min.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.137.0/examples/js/loaders/GLTFLoader.js"></script> | |
| <script src="https://cdn.jsdelivr.net/npm/three@0.137.0/examples/js/controls/OrbitControls.js"></script> | |
| """ | |
| VIEWER_MARKUP = """ | |
| <div class="fa-viewer-root" style="width:100%;"> | |
| <div class="fa-canvas-wrap" style="position:relative;width:100%;height:420px;background:#ffffff;border:1px solid #e5e7eb;border-radius:8px;overflow:hidden;"> | |
| <div class="fa-overlay" style="position:absolute;inset:0;display:flex;align-items:center;justify-content:center;color:#6b7280;font-family:sans-serif;font-size:14px;text-align:center;padding:0 16px;">Run a reconstruction to view the 3D point tracks here.</div> | |
| </div> | |
| <div class="fa-controls" style="display:none;gap:10px;align-items:center;padding:8px 4px 2px;font-family:sans-serif;font-size:13px;flex-wrap:wrap;"> | |
| <button class="fa-play" type="button" style="cursor:pointer;padding:4px 12px;border:1px solid #d1d5db;border-radius:6px;background:#f9fafb;">▶ Play</button> | |
| <label style="display:inline-flex;align-items:center;gap:5px;cursor:pointer;" title="On: colorful tracks on face + hair. Off: image RGB colors."><input class="fa-tracks" type="checkbox"> Colorful tracks</label> | |
| <span style="display:inline-flex;align-items:center;gap:6px;">Speed <input class="fa-speed" type="range" min="1" max="30" step="1" value="12" style="width:90px;vertical-align:middle;"><span class="fa-fps">12 fps</span></span> | |
| <input class="fa-scrub" type="range" min="0" max="0" step="1" value="0" style="flex:1;min-width:120px;vertical-align:middle;"> | |
| <span class="fa-frame" style="min-width:64px;text-align:right;color:#374151;">– / –</span> | |
| </div> | |
| </div> | |
| """ | |
| # Runs once when the HTML component mounts; sets up the three.js scene and | |
| # exposes window.faViewer.load(items) for the file bridge below to call. It holds | |
| # two parallel per-frame sets — colorful tracks and image-RGB — switched instantly | |
| # client-side by the "Colorful tracks" checkbox. | |
| VIEWER_JS = """ | |
| (function(){ | |
| if (element.__faInit) return; | |
| element.__faInit = true; | |
| var wrap = element.querySelector('.fa-canvas-wrap'); | |
| var overlay = element.querySelector('.fa-overlay'); | |
| var controls = element.querySelector('.fa-controls'); | |
| var playBtn = element.querySelector('.fa-play'); | |
| var tracksEl = element.querySelector('.fa-tracks'); | |
| var speedEl = element.querySelector('.fa-speed'); | |
| var fpsEl = element.querySelector('.fa-fps'); | |
| var scrubEl = element.querySelector('.fa-scrub'); | |
| var frameEl = element.querySelector('.fa-frame'); | |
| if (!wrap) return; | |
| var renderer, scene, camera, orbit, group; | |
| var setTracks = [], setRgb = [], mode = 'rgb'; // default: plain image RGB | |
| var cur = 0, playing = false, fps = 12, acc = 0, last = 0, loadToken = 0; | |
| function activeSet(){ | |
| var a = (mode === 'rgb') ? setRgb : setTracks; | |
| if (!a.length) a = (mode === 'rgb') ? setTracks : setRgb; // fall back if empty | |
| return a; | |
| } | |
| function waitThree(cb, tries){ | |
| tries = tries || 0; | |
| if (window.THREE && THREE.GLTFLoader && THREE.OrbitControls) { cb(); } | |
| else if (tries > 200) { setOverlay('Could not load the 3D viewer (three.js) \\u2014 check your network / ad-blocker.'); } | |
| else { setTimeout(function(){ waitThree(cb, tries + 1); }, 60); } | |
| } | |
| function setOverlay(msg){ | |
| if (!overlay) return; | |
| if (msg) { overlay.textContent = msg; overlay.style.display = 'flex'; } | |
| else { overlay.style.display = 'none'; } | |
| } | |
| function resize(){ | |
| if (!renderer) return; | |
| var w = wrap.clientWidth || 1, h = wrap.clientHeight || 1; | |
| renderer.setSize(w, h, false); | |
| camera.aspect = w / h; camera.updateProjectionMatrix(); | |
| } | |
| function applyStyle(root){ | |
| root.traverse(function(o){ | |
| if (o.isPoints && o.material){ | |
| o.material.size = 2.5; | |
| o.material.sizeAttenuation = false; | |
| o.material.vertexColors = true; | |
| o.material.needsUpdate = true; | |
| } | |
| }); | |
| } | |
| function firstPoints(root){ | |
| var found = null; | |
| root.traverse(function(o){ if (!found && o.isPoints) found = o; }); | |
| return found; | |
| } | |
| function hideAll(){ | |
| var k; | |
| for (k = 0; k < setTracks.length; k++){ if (setTracks[k]) setTracks[k].visible = false; } | |
| for (k = 0; k < setRgb.length; k++){ if (setRgb[k]) setRgb[k].visible = false; } | |
| } | |
| function showFrame(i){ | |
| var arr = activeSet(); | |
| if (!arr.length) return; | |
| if (i < 0) i = 0; | |
| if (i > arr.length - 1) i = arr.length - 1; | |
| hideAll(); | |
| if (arr[i]) arr[i].visible = true; | |
| cur = i; | |
| if (scrubEl) scrubEl.value = String(i); | |
| if (frameEl) frameEl.textContent = (i + 1) + ' / ' + arr.length; | |
| } | |
| function fitCamera(){ | |
| var ref = setTracks[0] || setRgb[0]; | |
| if (!ref) return; | |
| var box = new THREE.Box3().setFromObject(ref); | |
| if (box.isEmpty()) return; | |
| var c = box.getCenter(new THREE.Vector3()); | |
| var s = box.getSize(new THREE.Vector3()); | |
| var r = Math.max(s.x, s.y, s.z) * 0.5 || 0.5; | |
| var d = (r / Math.tan(camera.fov * Math.PI / 360)) * 1.15; | |
| orbit.target.copy(c); | |
| camera.near = Math.max(d / 200, 0.0005); | |
| camera.far = d * 50 + r * 20; | |
| camera.position.set(c.x, c.y, c.z + d); | |
| camera.updateProjectionMatrix(); | |
| orbit.update(); | |
| } | |
| function clearFrames(){ | |
| var arrs = [setTracks, setRgb], a, k, o; | |
| for (a = 0; a < arrs.length; a++){ | |
| for (k = 0; k < arrs[a].length; k++){ | |
| o = arrs[a][k]; | |
| if (!o) continue; | |
| group.remove(o); | |
| if (o.geometry) o.geometry.dispose(); | |
| if (o.material) o.material.dispose(); | |
| } | |
| } | |
| setTracks = []; setRgb = []; cur = 0; | |
| } | |
| function play(){ if (activeSet().length < 2) return; playing = true; last = 0; acc = 0; if (playBtn) playBtn.innerHTML = '\\u23F8 Pause'; } | |
| function pause(){ playing = false; if (playBtn) playBtn.innerHTML = '\\u25B6 Play'; } | |
| function toggle(){ if (playing) pause(); else play(); } | |
| function animate(ts){ | |
| requestAnimationFrame(animate); | |
| if (orbit) orbit.update(); | |
| var arr = activeSet(); | |
| if (playing && arr.length > 1){ | |
| if (!last) last = ts; | |
| acc += (ts - last); last = ts; | |
| var interval = 1000 / Math.max(1, fps); | |
| if (acc >= interval){ | |
| var steps = Math.floor(acc / interval); | |
| acc -= steps * interval; | |
| showFrame((cur + steps) % arr.length); | |
| } | |
| } else { last = ts; } | |
| if (renderer && scene && camera) renderer.render(scene, camera); | |
| } | |
| function initThree(){ | |
| if (renderer) return; | |
| renderer = new THREE.WebGLRenderer({ antialias: true, alpha: false }); | |
| renderer.setPixelRatio(1); | |
| renderer.setClearColor(0xffffff, 1); | |
| if (THREE.sRGBEncoding) renderer.outputEncoding = THREE.sRGBEncoding; | |
| renderer.domElement.style.display = 'block'; | |
| renderer.domElement.style.width = '100%'; | |
| renderer.domElement.style.height = '100%'; | |
| wrap.appendChild(renderer.domElement); | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0xffffff); | |
| camera = new THREE.PerspectiveCamera(50, 1, 0.001, 1000); | |
| camera.position.set(0, 0, 2); | |
| orbit = new THREE.OrbitControls(camera, renderer.domElement); | |
| orbit.enableDamping = true; orbit.dampingFactor = 0.1; | |
| group = new THREE.Group(); | |
| scene.add(group); | |
| resize(); | |
| if (window.ResizeObserver) { new ResizeObserver(resize).observe(wrap); } | |
| else { window.addEventListener('resize', resize); } | |
| requestAnimationFrame(animate); | |
| } | |
| function classify(name){ | |
| var lower = (name || '').toLowerCase(); | |
| var kind = (lower.indexOf('rgb') !== -1) ? 'rgb' : 'tracks'; | |
| var d = (lower.match(/[0-9]+/g) || []).join(''); | |
| return { kind: kind, idx: d ? parseInt(d, 10) : 0 }; | |
| } | |
| function compact(map){ | |
| var keys = Object.keys(map).map(Number).sort(function(a, b){ return a - b; }); | |
| var out = []; | |
| for (var i = 0; i < keys.length; i++){ if (map[keys[i]]) out.push(map[keys[i]]); } | |
| return out; | |
| } | |
| function finishLoad(token, byTracks, byRgb){ | |
| if (token !== loadToken) return; | |
| setTracks = compact(byTracks); | |
| setRgb = compact(byRgb); | |
| if (!setTracks.length && !setRgb.length){ setOverlay('Failed to load the 3D point clouds.'); return; } | |
| var all = setTracks.concat(setRgb), k; | |
| for (k = 0; k < all.length; k++){ all[k].visible = false; group.add(all[k]); } | |
| if (tracksEl){ | |
| if (!setRgb.length && setTracks.length) tracksEl.checked = true; | |
| else if (!setTracks.length) tracksEl.checked = false; | |
| mode = tracksEl.checked ? 'tracks' : 'rgb'; | |
| } | |
| setOverlay(''); | |
| if (controls) controls.style.display = 'flex'; | |
| if (scrubEl){ scrubEl.min = '0'; scrubEl.max = String(Math.max(0, activeSet().length - 1)); scrubEl.value = '0'; } | |
| fitCamera(); | |
| showFrame(0); | |
| if (activeSet().length > 1) play(); | |
| } | |
| function load(items){ | |
| waitThree(function(){ | |
| initThree(); | |
| var token = ++loadToken; | |
| pause(); | |
| clearFrames(); | |
| if (controls) controls.style.display = 'none'; | |
| if (!items || !items.length){ setOverlay('No 3D point tracks for this run.'); return; } | |
| setOverlay('Loading 3D sequence\\u2026 0 / ' + items.length); | |
| var loader = new THREE.GLTFLoader(); | |
| var byTracks = {}, byRgb = {}; | |
| var done = 0, total = items.length; | |
| function tick(){ | |
| done++; | |
| setOverlay('Loading 3D sequence\\u2026 ' + done + ' / ' + total); | |
| if (done === total) finishLoad(token, byTracks, byRgb); | |
| } | |
| items.forEach(function(it){ | |
| var url = (it && it.url) ? it.url : it; | |
| var meta = classify((it && it.name) ? it.name : String(url)); | |
| loader.load(url, function(gltf){ | |
| if (token !== loadToken) return; | |
| var pts = firstPoints(gltf.scene); | |
| if (pts){ applyStyle(pts); (meta.kind === 'rgb' ? byRgb : byTracks)[meta.idx] = pts; } | |
| tick(); | |
| }, undefined, function(){ if (token === loadToken){ tick(); } }); | |
| }); | |
| }); | |
| } | |
| if (playBtn) playBtn.addEventListener('click', toggle); | |
| if (tracksEl) tracksEl.addEventListener('change', function(){ mode = tracksEl.checked ? 'tracks' : 'rgb'; showFrame(cur); }); | |
| if (speedEl) speedEl.addEventListener('input', function(){ fps = parseInt(speedEl.value, 10) || 12; if (fpsEl) fpsEl.textContent = fps + ' fps'; }); | |
| if (scrubEl) scrubEl.addEventListener('input', function(){ pause(); showFrame(parseInt(scrubEl.value, 10) || 0); }); | |
| window.faViewer = { load: load, play: play, pause: pause, setFrame: showFrame }; | |
| waitThree(function(){ initThree(); }); | |
| })(); | |
| """ | |
| # Bridge: when the hidden file list (served .glb URLs) changes, hand the URL + | |
| # filename of each to the three.js viewer (filename selects tracks vs RGB and the | |
| # frame index). Runs purely client-side (no server round-trip). | |
| ANIM_BRIDGE_JS = """ | |
| (files) => { | |
| try { | |
| var list = (files || []).map(function(f){ | |
| if (!f) return null; | |
| var url = f.url || f.path; | |
| if (!url) return null; | |
| var name = f.orig_name || String(url).split('/').pop().split('?')[0]; | |
| return { url: url, name: name }; | |
| }).filter(Boolean); | |
| if (window.faViewer) { window.faViewer.load(list); } | |
| } catch (e) { console.error('faViewer load error', e); } | |
| } | |
| """ | |
| def build_demo(): | |
| with gr.Blocks(title="Face Anything") as demo: | |
| gr.Markdown(DESCRIPTION) | |
| with gr.Row(): | |
| # ---------------- inputs ---------------- | |
| with gr.Column(scale=1): | |
| files = gr.File( | |
| label=f"Input images (up to {MAX_IMAGES}, in temporal order)", | |
| file_count="multiple", | |
| file_types=["image"], | |
| type="filepath", | |
| ) | |
| gallery = gr.Gallery( | |
| label="Preview", columns=6, height=180, show_label=True, | |
| object_fit="contain", | |
| ) | |
| video = gr.Video( | |
| label=f"…or upload a video (its first {MAX_IMAGES} frames are used)", | |
| ) | |
| mode = gr.Radio( | |
| choices=["Joint", "One-by-one"], | |
| value="One-by-one", | |
| label="Inference mode", | |
| info="One-by-one: more surface detail, lower memory. " | |
| "Joint (all-at-once): more 3D-consistent across frames.", | |
| ) | |
| face_crop = gr.Checkbox( | |
| value=True, label="Face crop", | |
| info="Crop each frame to a face-centred square (pixel3dmm-style) " | |
| "so the model focuses on the face. Uncheck for full frames.", | |
| ) | |
| remove_bg = gr.Checkbox( | |
| value=True, label="Remove background", | |
| info="Robust Video Matting (recommended).", | |
| ) | |
| process_res = gr.Slider( | |
| 252, 1036, value=504, step=14, | |
| label="Processing resolution", | |
| info="Higher = more detail (and more memory). Multiples of 14.", | |
| ) | |
| with gr.Accordion("Point-track settings", open=False): | |
| n_tracks = gr.Slider(10, 500, value=100, step=10, | |
| label="Number of tracks (seeds)") | |
| track_k = gr.Slider(1, 100, value=25, step=1, | |
| label="Neighbours recolored per track (k)") | |
| track_threshold = gr.Slider( | |
| 0.001, 0.1, value=0.01, step=0.001, | |
| label="Canonical match threshold") | |
| with gr.Accordion("Advanced", open=False): | |
| conf_percentile = gr.Slider( | |
| 0, 95, value=0, step=5, | |
| label="Confidence percentile cut", | |
| info="Drop the least-confident depth pixels (0 = keep all).") | |
| fps = gr.Slider(1, 30, value=10, step=1, label="Output video FPS") | |
| max_frames = gr.Slider( | |
| 1, MAX_IMAGES, value=MAX_IMAGES, step=1, | |
| label="Max frames to use") | |
| run_btn = gr.Button("Reconstruct", variant="primary") | |
| # ---------------- outputs ---------------- | |
| with gr.Column(scale=1): | |
| gr.Markdown("**3D point cloud with colorful tracks** · " | |
| "loads the whole sequence, then plays smoothly") | |
| # client-side three.js player: all frames preloaded once, then | |
| # animated by visibility toggle (no per-frame reload → no white | |
| # flashes; full, un-subsampled cloud). See VIEWER_JS above. | |
| viewer = gr.HTML( | |
| value=VIEWER_MARKUP, head=THREE_HEAD, js_on_load=VIEWER_JS, | |
| show_label=False, | |
| ) | |
| # hidden: run() puts the per-frame .glb files here so gradio | |
| # serves them; ANIM_BRIDGE_JS hands their URLs to the viewer. | |
| anim_files = gr.File(file_count="multiple", visible=False) | |
| tracks_zip = gr.File( | |
| label="Download point clouds (.zip: tracks/ + points/)") | |
| with gr.Tab("Normals (2D)"): | |
| normals_vid = gr.Video(label="Surface-normal map") | |
| with gr.Tab("Depth (2D)"): | |
| depth_vid = gr.Video(label="Depth map") | |
| with gr.Tab("Canonical (2D)"): | |
| canonical_vid = gr.Video(label="Canonical facial-coordinate map") | |
| with gr.Tab("Tracks (2D)"): | |
| tracks2d_vid = gr.Video(label="2D point-track overlay") | |
| status = gr.Textbox(label="Log", lines=6, interactive=False) | |
| # preview uploaded files in the gallery | |
| files.change(lambda fs: fs or [], inputs=files, outputs=gallery) | |
| run_btn.click( | |
| run, | |
| inputs=[files, video, mode, process_res, remove_bg, face_crop, | |
| conf_percentile, n_tracks, track_k, track_threshold, | |
| fps, max_frames], | |
| outputs=[anim_files, canonical_vid, depth_vid, normals_vid, | |
| tracks2d_vid, tracks_zip, status], | |
| concurrency_limit=1, | |
| ) | |
| # when the served .glb list changes, push the URLs to the three.js player | |
| anim_files.change(None, inputs=anim_files, outputs=None, js=ANIM_BRIDGE_JS) | |
| # ---------------- examples (thumbnail shown, click to load + run) ---------------- | |
| ex40 = sorted(glob.glob(os.path.join(APP_DIR, "examples", "seq40", "*.png")))[:MAX_IMAGES] | |
| if ex40: | |
| run_inputs = [files, video, mode, process_res, remove_bg, face_crop, | |
| conf_percentile, n_tracks, track_k, track_threshold, | |
| fps, max_frames] | |
| run_outputs = [anim_files, canonical_vid, depth_vid, normals_vid, | |
| tracks2d_vid, tracks_zip, status] | |
| def _thumb(): | |
| return gr.Image(value=ex40[0], height=150, show_label=False, | |
| interactive=False, container=False) | |
| gr.Markdown("### Examples") | |
| with gr.Row(): | |
| with gr.Column(scale=1, min_width=150): | |
| _thumb() | |
| gr.Markdown("**NeRSemble** 40 images") | |
| btn40 = gr.Button("Load & run", size="sm") | |
| with gr.Column(scale=1, min_width=150): | |
| _thumb() | |
| gr.Markdown("**NeRSemble** 1 image") | |
| btn1 = gr.Button("Load & run", size="sm") | |
| with gr.Column(scale=3): # spacer so the thumbnails stay small | |
| pass | |
| # set the inputs, then run the pipeline (which reads the just-set values) | |
| btn40.click(lambda: (ex40, None, MAX_IMAGES), | |
| outputs=[files, video, max_frames]).then( | |
| run, inputs=run_inputs, outputs=run_outputs, concurrency_limit=1) | |
| btn1.click(lambda: ([ex40[0]], None, 1), | |
| outputs=[files, video, max_frames]).then( | |
| run, inputs=run_inputs, outputs=run_outputs, concurrency_limit=1) | |
| return demo | |
| if __name__ == "__main__": | |
| demo = build_demo() | |
| demo.queue(max_size=8).launch() | |