Spaces:
Paused
Paused
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 filesThe 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>
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)
|
| 434 |
-
|
| 435 |
-
|
|
|
|
| 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
|
| 446 |
-
|
| 447 |
-
|
| 448 |
-
|
| 449 |
-
|
| 450 |
-
|
| 451 |
-
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
| 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)}
|
| 510 |
-
f"{out_size}x{out_size}"
|
|
|
|
| 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 |
|