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>

Files changed (2) hide show
  1. README.md +5 -4
  2. app.py +46 -14
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** (up to 40 frames),
26
- in a single feed-forward pass. The model jointly predicts depth and **canonical
27
- facial coordinates**, from which the demo derives:
 
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 (`colored_points/`)
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 uploaded images into a clean, ordered folder of frames.
 
202
 
203
- Robust to Gradio temp files that lack a usable extension: we don't glob by
204
- extension. Files are natural-sorted by their original name (temporal order),
 
205
  capped, then copied as ``frame_XXXX.<ext>`` with a content-sniffed extension.
206
  """
 
 
 
 
 
 
 
 
 
 
 
207
  if not files:
208
- raise gr.Error("Please upload at least one image.")
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 _points_to_glb(path, points, colors, max_points=200000):
 
 
 
 
 
 
 
 
 
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
- [cols[:, :3], np.full((cols.shape[0], 1), 255, np.uint8)], axis=1)
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(files, min(int(max_frames), MAX_IMAGES), workdir)
 
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", "colored_points")
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/ + colored_points/)")
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
- frame_slider.change(pick_frame, inputs=[frame_slider, view_state],
620
- outputs=model3d)
 
 
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