Umut Kocasari Claude Opus 4.8 commited on
Commit
fb2f19a
·
1 Parent(s): 3d9392a

Face crop: make it hair-aware (don't clip top hair; cover long hair)

Browse files

The RetinaFace box starts ~at the hairline, so the pixel3dmm crop clipped the
top of the head. Grow the crop to also enclose the face+hair segmentation bbox
(reusing the facer parser already used for tracks), with extra headroom on top.
Square the box anchored at the TOP so, when it can't fit everything in a narrow
portrait, the hair is kept and the bottom rides up to the jaw (still excludes
shoulders). Width now follows the hair extent, so long hair on the sides is
covered too. Verified on the example: top raised 143->61 (full headroom), hair
fully inside.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Files changed (1) hide show
  1. app.py +68 -15
app.py CHANGED
@@ -429,10 +429,39 @@ def _cstm_crop_box(mean_b, max_b, img_h, img_w, scale=1.42):
429
  return ymin, ymax, xmin, xmax
430
 
431
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
432
  def _face_crop_frames(frame_paths, device, out_dir, log, process_res):
433
- """Crop every frame to a face-centric square (pixel3dmm-style). Returns new
434
- frame paths; on any failure (facer missing / no face) returns the originals so
435
- the run never breaks."""
 
436
  import cv2
437
  try:
438
  import torch
@@ -442,15 +471,17 @@ def _face_crop_frames(frame_paths, device, out_dir, log, process_res):
442
  log.append(f"WARNING: face crop unavailable ({e}); using full frames.")
443
  return frame_paths
444
 
445
- imgs, sizes, boxes = [], [], []
446
- for fp in frame_paths:
447
- im = cv2.imread(fp)
448
- imgs.append(im)
449
- if im is None:
450
- sizes.append(None); boxes.append(None); continue
451
- sizes.append(im.shape[:2])
 
 
 
452
  try:
453
- rgb = cv2.cvtColor(im, cv2.COLOR_BGR2RGB)
454
  t = facer.hwc2bchw(torch.from_numpy(np.ascontiguousarray(rgb))).to(device)
455
  with torch.inference_mode():
456
  faces = detector(t)
@@ -469,6 +500,21 @@ def _face_crop_frames(frame_paths, device, out_dir, log, process_res):
469
  log.append("WARNING: face crop found no faces; using full frames.")
470
  return frame_paths
471
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
472
  # One static box for the whole clip when every frame shares a resolution.
473
  uniq = set(s for s in sizes if s is not None)
474
  static_box = None
@@ -479,7 +525,11 @@ def _face_crop_frames(frame_paths, device, out_dir, log, process_res):
479
  x0, y0 = xs.min(), ys.min()
480
  mean_b = [xs.mean(), ys.mean(), ws.mean(), hs.mean()]
481
  max_b = [x0, y0, (xs + ws - x0).max(), (ys + hs - y0).max()] # union box
482
- static_box = _cstm_crop_box(mean_b, max_b, H, W)
 
 
 
 
483
 
484
  out_size = int(min(1024, max(512, int(process_res))))
485
  crop_dir = os.path.join(out_dir, "cropped")
@@ -496,7 +546,9 @@ def _face_crop_frames(frame_paths, device, out_dir, log, process_res):
496
  ymin, ymax, xmin, xmax = static_box
497
  else:
498
  b = boxes[i] if boxes[i] is not None else fallback
499
- ymin, ymax, xmin, xmax = _cstm_crop_box(b, b, h, w)
 
 
500
  crop = im[ymin:ymax, xmin:xmax]
501
  out_fp = os.path.join(crop_dir, f"frame_{i:04d}.png")
502
  if crop.size == 0:
@@ -506,8 +558,9 @@ def _face_crop_frames(frame_paths, device, out_dir, log, process_res):
506
  n_cropped += 1
507
  new_paths.append(out_fp)
508
  log.append(
509
- f"Face crop (pixel3dmm-style): {n_cropped}/{len(frame_paths)} frame(s) → "
510
- f"{out_size}x{out_size}" + (" static box." if static_box is not None else " per-frame."))
 
511
  return new_paths
512
 
513
 
 
429
  return ymin, ymax, xmin, xmax
430
 
431
 
432
+ def _combine_face_hair_box(face_box, hair_bbox, img_h, img_w,
433
+ pad_top=0.10, pad_side=0.05, pad_bot=0.03):
434
+ """Grow the pixel3dmm face box so it also encloses the hair (the face+hair
435
+ segmentation bbox), with extra headroom on top — fixes hair being clipped at
436
+ the top of the head and covers long hair on the sides. Squares the box; when
437
+ it can't fit everything (narrow portrait), it keeps the TOP (hair) and lets the
438
+ bottom ride up rather than clipping the hair. Falls back to the face box if no
439
+ hair bbox is available.
440
+
441
+ face_box: (ymin, ymax, xmin, xmax). hair_bbox: (x0, y0, x1, y1) or None."""
442
+ fy0, fy1, fx0, fx1 = face_box
443
+ if hair_bbox is None:
444
+ return face_box
445
+ hx0, hy0, hx1, hy1 = hair_bbox
446
+ x0 = min(fx0, hx0); y0 = min(fy0, hy0)
447
+ x1 = max(fx1, hx1); y1 = max(fy1, hy1)
448
+ bw = max(x1 - x0, 1.0); bh = max(y1 - y0, 1.0)
449
+ x0 -= pad_side * bw; x1 += pad_side * bw
450
+ y0 -= pad_top * bh; y1 += pad_bot * bh
451
+ side = min(max(x1 - x0, y1 - y0), float(img_w), float(img_h))
452
+ nx0 = (x0 + x1) / 2.0 - side / 2.0 # centered horizontally
453
+ ny0 = y0 # anchored at the top (keep hair)
454
+ nx0 = min(max(nx0, 0.0), img_w - side)
455
+ ny0 = min(max(ny0, 0.0), img_h - side)
456
+ xmin = int(round(nx0)); ymin = int(round(ny0)); side = int(round(side))
457
+ return ymin, min(ymin + side, img_h - 1), xmin, min(xmin + side, img_w - 1)
458
+
459
+
460
  def _face_crop_frames(frame_paths, device, out_dir, log, process_res):
461
+ """Crop every frame to a face-centric square (pixel3dmm-style), grown to also
462
+ cover the hair (top of head + long hair on the sides) via the face+hair
463
+ segmentation. Returns new frame paths; on any failure (facer missing / no face)
464
+ returns the originals so the run never breaks."""
465
  import cv2
466
  try:
467
  import torch
 
471
  log.append(f"WARNING: face crop unavailable ({e}); using full frames.")
472
  return frame_paths
473
 
474
+ imgs = [cv2.imread(fp) for fp in frame_paths]
475
+ rgb_imgs = [cv2.cvtColor(im, cv2.COLOR_BGR2RGB) if im is not None else None
476
+ for im in imgs]
477
+ sizes = [im.shape[:2] if im is not None else None for im in imgs]
478
+
479
+ # face detection boxes (x, y, w, h)
480
+ boxes = []
481
+ for rgb in rgb_imgs:
482
+ if rgb is None:
483
+ boxes.append(None); continue
484
  try:
 
485
  t = facer.hwc2bchw(torch.from_numpy(np.ascontiguousarray(rgb))).to(device)
486
  with torch.inference_mode():
487
  faces = detector(t)
 
500
  log.append("WARNING: face crop found no faces; using full frames.")
501
  return frame_paths
502
 
503
+ # face + hair mask → bbox per frame, so the crop encloses the hair, not just
504
+ # the face detection box (which starts around the hairline).
505
+ hair_masks = _face_hair_masks(rgb_imgs, device, log)
506
+
507
+ def _mask_bbox(m):
508
+ if m is None:
509
+ return None
510
+ ys, xs = np.nonzero(m)
511
+ if not len(xs):
512
+ return None
513
+ return [int(xs.min()), int(ys.min()), int(xs.max()), int(ys.max())]
514
+
515
+ hair_bboxes = ([_mask_bbox(m) for m in hair_masks]
516
+ if hair_masks is not None else [None] * len(frame_paths))
517
+
518
  # One static box for the whole clip when every frame shares a resolution.
519
  uniq = set(s for s in sizes if s is not None)
520
  static_box = None
 
525
  x0, y0 = xs.min(), ys.min()
526
  mean_b = [xs.mean(), ys.mean(), ws.mean(), hs.mean()]
527
  max_b = [x0, y0, (xs + ws - x0).max(), (ys + hs - y0).max()] # union box
528
+ face_static = _cstm_crop_box(mean_b, max_b, H, W)
529
+ hb = [b for b in hair_bboxes if b is not None]
530
+ hair_static = ([min(b[0] for b in hb), min(b[1] for b in hb),
531
+ max(b[2] for b in hb), max(b[3] for b in hb)] if hb else None)
532
+ static_box = _combine_face_hair_box(face_static, hair_static, H, W)
533
 
534
  out_size = int(min(1024, max(512, int(process_res))))
535
  crop_dir = os.path.join(out_dir, "cropped")
 
546
  ymin, ymax, xmin, xmax = static_box
547
  else:
548
  b = boxes[i] if boxes[i] is not None else fallback
549
+ face_box = _cstm_crop_box(b, b, h, w)
550
+ ymin, ymax, xmin, xmax = _combine_face_hair_box(
551
+ face_box, hair_bboxes[i], h, w)
552
  crop = im[ymin:ymax, xmin:xmax]
553
  out_fp = os.path.join(crop_dir, f"frame_{i:04d}.png")
554
  if crop.size == 0:
 
558
  n_cropped += 1
559
  new_paths.append(out_fp)
560
  log.append(
561
+ f"Face crop (pixel3dmm-style, hair-aware): {n_cropped}/{len(frame_paths)} "
562
+ f"frame(s) → {out_size}x{out_size}"
563
+ + (" static box." if static_box is not None else " per-frame."))
564
  return new_paths
565
 
566