Spaces:
Running on Zero
Running on Zero
Umut Kocasari Claude Opus 4.8 commited on
Commit ·
35598db
1
Parent(s): 434c817
Demo feedback: video upload, fix washed-out points, smoother scrub, rename folder
Browse files- Accept a video upload (gr.Video); its first MAX_FRAMES frames are decoded and
used (via io_utils.load_frame_paths). Images or video both work.
- Fix washed-out viewer colors: glTF COLOR_0 is linear, so convert our sRGB point
colors to linear before writing the .glb (the viewer re-applies display gamma).
- Smoother frame scrubbing: bind the slider's .release (reload on let-go, not on
every drag tick) and shrink the viewer point cloud to <=60k points per frame.
- Rename the download subfolder colored_points/ -> points/.
Verified on a real run incl. a video input: first-N sampling, linearized GLB
colors, <=60k pts, zip has tracks/ + points/; UI builds on gradio 6.19.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
README.md
CHANGED
|
@@ -22,9 +22,10 @@ tags:
|
|
| 22 |
|
| 23 |
# Face Anything — Gradio demo
|
| 24 |
|
| 25 |
-
4D face reconstruction and tracking from **any image sequence**
|
| 26 |
-
|
| 27 |
-
facial coordinates**, from
|
|
|
|
| 28 |
|
| 29 |
- **Canonical 2D video** — per-frame canonical facial-coordinate map
|
| 30 |
- **Depth 2D video** — per-frame depth map (JET)
|
|
@@ -32,7 +33,7 @@ facial coordinates**, from which the demo derives:
|
|
| 32 |
- **3D point cloud with colorful tracks** — viewable/orbitable in the 3D viewer
|
| 33 |
(rendered as a `.glb` on a white background), with a frame slider to scrub the
|
| 34 |
sequence and a downloadable `.zip` containing both the track-colored point
|
| 35 |
-
clouds (`tracks/`) and the plain colored point clouds (`
|
| 36 |
- *(bonus)* a 2D point-track overlay video
|
| 37 |
|
| 38 |
Reconstruction always uses the model's **predicted camera poses** (a multi-view
|
|
|
|
| 22 |
|
| 23 |
# Face Anything — Gradio demo
|
| 24 |
|
| 25 |
+
4D face reconstruction and tracking from **any image sequence** — upload up to 40
|
| 26 |
+
images **or a video** (its first 40 frames are used) — in a single feed-forward
|
| 27 |
+
pass. The model jointly predicts depth and **canonical facial coordinates**, from
|
| 28 |
+
which the demo derives:
|
| 29 |
|
| 30 |
- **Canonical 2D video** — per-frame canonical facial-coordinate map
|
| 31 |
- **Depth 2D video** — per-frame depth map (JET)
|
|
|
|
| 33 |
- **3D point cloud with colorful tracks** — viewable/orbitable in the 3D viewer
|
| 34 |
(rendered as a `.glb` on a white background), with a frame slider to scrub the
|
| 35 |
sequence and a downloadable `.zip` containing both the track-colored point
|
| 36 |
+
clouds (`tracks/`) and the plain colored point clouds (`points/`)
|
| 37 |
- *(bonus)* a 2D point-track overlay video
|
| 38 |
|
| 39 |
Reconstruction always uses the model's **predicted camera poses** (a multi-view
|
app.py
CHANGED
|
@@ -197,15 +197,28 @@ def _sniff_ext(path):
|
|
| 197 |
return ".png"
|
| 198 |
|
| 199 |
|
| 200 |
-
def _prepare_inputs(files, max_frames, workdir):
|
| 201 |
-
"""Normalize
|
|
|
|
| 202 |
|
| 203 |
-
|
| 204 |
-
|
|
|
|
| 205 |
capped, then copied as ``frame_XXXX.<ext>`` with a content-sniffed extension.
|
| 206 |
"""
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 207 |
if not files:
|
| 208 |
-
raise gr.Error("Please upload
|
| 209 |
|
| 210 |
entries = []
|
| 211 |
for f in files:
|
|
@@ -233,11 +246,22 @@ def _prepare_inputs(files, max_frames, workdir):
|
|
| 233 |
return out
|
| 234 |
|
| 235 |
|
| 236 |
-
def
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 237 |
"""Write a colored point cloud as a ``.glb`` — the format gradio's Model3D
|
| 238 |
actually renders as points (a vertex-only ``.ply`` is treated as an empty
|
| 239 |
solid mesh, so nothing shows). ``points`` must already be in glTF axes
|
| 240 |
-
(X-right, Y-up, Z-back). Colors are (N,3/4) uint8.
|
|
|
|
|
|
|
| 241 |
import trimesh
|
| 242 |
|
| 243 |
pts = np.asarray(points, np.float32)
|
|
@@ -252,8 +276,9 @@ def _points_to_glb(path, points, colors, max_points=200000):
|
|
| 252 |
pts, cols = pts[idx], cols[idx]
|
| 253 |
if cols.dtype != np.uint8:
|
| 254 |
cols = np.clip(cols, 0, 255).astype(np.uint8)
|
|
|
|
| 255 |
rgba = np.concatenate(
|
| 256 |
-
[
|
| 257 |
scene = trimesh.Scene()
|
| 258 |
scene.add_geometry(trimesh.points.PointCloud(vertices=pts, colors=rgba))
|
| 259 |
scene.export(path)
|
|
@@ -263,6 +288,7 @@ def _points_to_glb(path, points, colors, max_points=200000):
|
|
| 263 |
@GPU(duration=GPU_DURATION)
|
| 264 |
def run(
|
| 265 |
files,
|
|
|
|
| 266 |
mode,
|
| 267 |
process_res,
|
| 268 |
remove_bg,
|
|
@@ -303,7 +329,8 @@ def run(
|
|
| 303 |
|
| 304 |
try:
|
| 305 |
_say(0.02, "Preparing inputs…")
|
| 306 |
-
frame_paths = _prepare_inputs(
|
|
|
|
| 307 |
n_in = len(frame_paths)
|
| 308 |
if n_in == 0:
|
| 309 |
raise gr.Error("No valid images found in the upload.")
|
|
@@ -427,7 +454,7 @@ def run(
|
|
| 427 |
|
| 428 |
# Downloadable .ply (repo coords): two colorings in the same zip.
|
| 429 |
tracks_dir = os.path.join(workdir, "pointclouds", "tracks")
|
| 430 |
-
points_dir = os.path.join(workdir, "pointclouds", "
|
| 431 |
view_dir = os.path.join(workdir, "view_glb")
|
| 432 |
for d in (tracks_dir, points_dir, view_dir):
|
| 433 |
os.makedirs(d, exist_ok=True)
|
|
@@ -544,6 +571,9 @@ def build_demo():
|
|
| 544 |
label="Preview", columns=6, height=180, show_label=True,
|
| 545 |
object_fit="contain",
|
| 546 |
)
|
|
|
|
|
|
|
|
|
|
| 547 |
mode = gr.Radio(
|
| 548 |
choices=["Joint", "One-by-one"],
|
| 549 |
value="Joint",
|
|
@@ -593,7 +623,7 @@ def build_demo():
|
|
| 593 |
0, 1, value=0, step=1, visible=False,
|
| 594 |
label="Track point-cloud frame")
|
| 595 |
tracks_zip = gr.File(
|
| 596 |
-
label="Download point clouds (.zip: tracks/ +
|
| 597 |
with gr.Tab("Canonical (2D)"):
|
| 598 |
canonical_vid = gr.Video(label="Canonical facial-coordinate map")
|
| 599 |
with gr.Tab("Depth (2D)"):
|
|
@@ -609,15 +639,17 @@ def build_demo():
|
|
| 609 |
|
| 610 |
run_btn.click(
|
| 611 |
run,
|
| 612 |
-
inputs=[files, mode, process_res, remove_bg,
|
| 613 |
conf_percentile, n_tracks, track_k, track_threshold,
|
| 614 |
fps, max_frames],
|
| 615 |
outputs=[model3d, canonical_vid, depth_vid, normals_vid, tracks2d_vid,
|
| 616 |
tracks_zip, view_state, frame_slider, status],
|
| 617 |
concurrency_limit=1,
|
| 618 |
)
|
| 619 |
-
|
| 620 |
-
|
|
|
|
|
|
|
| 621 |
return demo
|
| 622 |
|
| 623 |
|
|
|
|
| 197 |
return ".png"
|
| 198 |
|
| 199 |
|
| 200 |
+
def _prepare_inputs(files, video, max_frames, workdir):
|
| 201 |
+
"""Normalize the upload (an image set OR a video) into an ordered list of
|
| 202 |
+
frame paths.
|
| 203 |
|
| 204 |
+
A video takes precedence: it is decoded and its first ``max_frames`` frames
|
| 205 |
+
are used. For images we don't glob by extension (Gradio temp files often lack
|
| 206 |
+
one): files are natural-sorted by their original name (temporal order),
|
| 207 |
capped, then copied as ``frame_XXXX.<ext>`` with a content-sniffed extension.
|
| 208 |
"""
|
| 209 |
+
from faceanything.io_utils import load_frame_paths
|
| 210 |
+
|
| 211 |
+
if video:
|
| 212 |
+
vpath = video if isinstance(video, str) else _to_entry(video)[0]
|
| 213 |
+
if vpath and os.path.exists(vpath):
|
| 214 |
+
paths, _ = load_frame_paths(
|
| 215 |
+
vpath, max_frames=int(max_frames), stride=1,
|
| 216 |
+
work_dir=os.path.join(workdir, "video_frames"))
|
| 217 |
+
if paths:
|
| 218 |
+
return paths
|
| 219 |
+
|
| 220 |
if not files:
|
| 221 |
+
raise gr.Error("Please upload images or a video.")
|
| 222 |
|
| 223 |
entries = []
|
| 224 |
for f in files:
|
|
|
|
| 246 |
return out
|
| 247 |
|
| 248 |
|
| 249 |
+
def _srgb_to_linear(cols_u8):
|
| 250 |
+
"""sRGB uint8 (0-255) -> linear uint8. glTF COLOR_0 vertex colors are
|
| 251 |
+
interpreted as *linear* and the viewer re-applies the display gamma, so our
|
| 252 |
+
sRGB image colors must be linearized first or the points render washed-out."""
|
| 253 |
+
c = np.asarray(cols_u8, np.float32) / 255.0
|
| 254 |
+
lin = np.where(c <= 0.04045, c / 12.92, ((c + 0.055) / 1.055) ** 2.4)
|
| 255 |
+
return np.clip(lin * 255.0, 0, 255).astype(np.uint8)
|
| 256 |
+
|
| 257 |
+
|
| 258 |
+
def _points_to_glb(path, points, colors, max_points=60000):
|
| 259 |
"""Write a colored point cloud as a ``.glb`` — the format gradio's Model3D
|
| 260 |
actually renders as points (a vertex-only ``.ply`` is treated as an empty
|
| 261 |
solid mesh, so nothing shows). ``points`` must already be in glTF axes
|
| 262 |
+
(X-right, Y-up, Z-back). Colors are (N,3/4) uint8 in sRGB.
|
| 263 |
+
|
| 264 |
+
Kept small (``max_points``) so each frame loads quickly when scrubbing."""
|
| 265 |
import trimesh
|
| 266 |
|
| 267 |
pts = np.asarray(points, np.float32)
|
|
|
|
| 276 |
pts, cols = pts[idx], cols[idx]
|
| 277 |
if cols.dtype != np.uint8:
|
| 278 |
cols = np.clip(cols, 0, 255).astype(np.uint8)
|
| 279 |
+
rgb = _srgb_to_linear(cols[:, :3])
|
| 280 |
rgba = np.concatenate(
|
| 281 |
+
[rgb, np.full((rgb.shape[0], 1), 255, np.uint8)], axis=1)
|
| 282 |
scene = trimesh.Scene()
|
| 283 |
scene.add_geometry(trimesh.points.PointCloud(vertices=pts, colors=rgba))
|
| 284 |
scene.export(path)
|
|
|
|
| 288 |
@GPU(duration=GPU_DURATION)
|
| 289 |
def run(
|
| 290 |
files,
|
| 291 |
+
video,
|
| 292 |
mode,
|
| 293 |
process_res,
|
| 294 |
remove_bg,
|
|
|
|
| 329 |
|
| 330 |
try:
|
| 331 |
_say(0.02, "Preparing inputs…")
|
| 332 |
+
frame_paths = _prepare_inputs(
|
| 333 |
+
files, video, min(int(max_frames), MAX_IMAGES), workdir)
|
| 334 |
n_in = len(frame_paths)
|
| 335 |
if n_in == 0:
|
| 336 |
raise gr.Error("No valid images found in the upload.")
|
|
|
|
| 454 |
|
| 455 |
# Downloadable .ply (repo coords): two colorings in the same zip.
|
| 456 |
tracks_dir = os.path.join(workdir, "pointclouds", "tracks")
|
| 457 |
+
points_dir = os.path.join(workdir, "pointclouds", "points")
|
| 458 |
view_dir = os.path.join(workdir, "view_glb")
|
| 459 |
for d in (tracks_dir, points_dir, view_dir):
|
| 460 |
os.makedirs(d, exist_ok=True)
|
|
|
|
| 571 |
label="Preview", columns=6, height=180, show_label=True,
|
| 572 |
object_fit="contain",
|
| 573 |
)
|
| 574 |
+
video = gr.Video(
|
| 575 |
+
label=f"…or upload a video (its first {MAX_IMAGES} frames are used)",
|
| 576 |
+
)
|
| 577 |
mode = gr.Radio(
|
| 578 |
choices=["Joint", "One-by-one"],
|
| 579 |
value="Joint",
|
|
|
|
| 623 |
0, 1, value=0, step=1, visible=False,
|
| 624 |
label="Track point-cloud frame")
|
| 625 |
tracks_zip = gr.File(
|
| 626 |
+
label="Download point clouds (.zip: tracks/ + points/)")
|
| 627 |
with gr.Tab("Canonical (2D)"):
|
| 628 |
canonical_vid = gr.Video(label="Canonical facial-coordinate map")
|
| 629 |
with gr.Tab("Depth (2D)"):
|
|
|
|
| 639 |
|
| 640 |
run_btn.click(
|
| 641 |
run,
|
| 642 |
+
inputs=[files, video, mode, process_res, remove_bg,
|
| 643 |
conf_percentile, n_tracks, track_k, track_threshold,
|
| 644 |
fps, max_frames],
|
| 645 |
outputs=[model3d, canonical_vid, depth_vid, normals_vid, tracks2d_vid,
|
| 646 |
tracks_zip, view_state, frame_slider, status],
|
| 647 |
concurrency_limit=1,
|
| 648 |
)
|
| 649 |
+
# .release (not .change) so the viewer reloads only when you let go of the
|
| 650 |
+
# slider — smoother than reloading a glb on every drag tick.
|
| 651 |
+
frame_slider.release(pick_frame, inputs=[frame_slider, view_state],
|
| 652 |
+
outputs=model3d)
|
| 653 |
return demo
|
| 654 |
|
| 655 |
|