Spaces:
Sleeping
Refuse a head swap when the two heads face different ways, and say so
Browse filesfull_head_swap fits a 2D similarity transform, so it can slide, spin and scale
the source head but cannot turn it. Transplanting a head that is nodded down
onto a body with its chin up lands visibly crooked however well the seam is
blended, and nothing downstream can correct it.
Measured against the Library target (pitch +14, yaw 0): of four test photos,
one differed by 26 degrees of pitch, one by 27 of yaw, one by 30 and 20. Those
are exactly the results that come out misaligned.
The pose delta is now checked before transplanting. Past 25 degrees of pitch or
yaw the head swap is skipped and the clean face swap is used instead; between 15
and 25 it runs but says the alignment may be imperfect. Roll is excluded on
purpose — it is in-plane and the transform already corrects it. A 20-degree case
was checked by eye and still looks right, so the cut sits above it.
The frontend never rendered the warnings array at all, so this explanation — and
the pre-existing "face is very small" notes — would have gone nowhere, and a
skipped head swap would just look like the toggle did nothing. Results now show
them.
14 tests pass; frontend builds.
- core/head_swap.py +48 -0
- frontend/src/pages/AppPage.jsx +19 -0
- web_app.py +16 -0
|
@@ -642,6 +642,39 @@ def _match_lighting(src_region, dst, mask):
|
|
| 642 |
return cv2.cvtColor(np.clip(out, 0, 255).astype(np.uint8), cv2.COLOR_LAB2BGR)
|
| 643 |
|
| 644 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 645 |
def full_head_swap(source: np.ndarray, target: np.ndarray,
|
| 646 |
feather: float = 0.012):
|
| 647 |
"""
|
|
@@ -673,6 +706,21 @@ def full_head_swap(source: np.ndarray, target: np.ndarray,
|
|
| 673 |
sf = max(src_faces, key=area)
|
| 674 |
tf = max(tgt_faces, key=area)
|
| 675 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 676 |
# Similarity (no shear) keeps the source face SHAPE; aligns it to the
|
| 677 |
# target's eyes/nose/mouth position, scale and rotation.
|
| 678 |
M, _ = cv2.estimateAffinePartial2D(
|
|
|
|
| 642 |
return cv2.cvtColor(np.clip(out, 0, 255).astype(np.uint8), cv2.COLOR_LAB2BGR)
|
| 643 |
|
| 644 |
|
| 645 |
+
#: Out-of-plane mismatch (degrees) past which a transplanted head reads as
|
| 646 |
+
#: crooked. Warn above the first, refuse above the second.
|
| 647 |
+
HEAD_POSE_WARN = 15.0
|
| 648 |
+
HEAD_POSE_MAX = 25.0
|
| 649 |
+
|
| 650 |
+
|
| 651 |
+
def head_pose_delta(source: np.ndarray, target: np.ndarray):
|
| 652 |
+
"""
|
| 653 |
+
(|d_pitch|, |d_yaw|) between the largest face in each image, or None if the
|
| 654 |
+
detector doesn't report pose.
|
| 655 |
+
|
| 656 |
+
Roll is deliberately excluded: it is in-plane, and the similarity transform
|
| 657 |
+
full_head_swap fits already corrects it. Pitch and yaw are out-of-plane and
|
| 658 |
+
it cannot — a head nodded down cannot be turned to face up by translating,
|
| 659 |
+
rotating and scaling a flat image.
|
| 660 |
+
"""
|
| 661 |
+
app = _get_insightface()
|
| 662 |
+
if app is None:
|
| 663 |
+
return None
|
| 664 |
+
try:
|
| 665 |
+
area = lambda f: (f.bbox[2] - f.bbox[0]) * (f.bbox[3] - f.bbox[1])
|
| 666 |
+
sfs, tfs = app.get(source), app.get(target)
|
| 667 |
+
if not sfs or not tfs:
|
| 668 |
+
return None
|
| 669 |
+
sp = getattr(max(sfs, key=area), "pose", None)
|
| 670 |
+
tp = getattr(max(tfs, key=area), "pose", None)
|
| 671 |
+
if sp is None or tp is None:
|
| 672 |
+
return None
|
| 673 |
+
return abs(float(sp[0]) - float(tp[0])), abs(float(sp[1]) - float(tp[1]))
|
| 674 |
+
except Exception:
|
| 675 |
+
return None
|
| 676 |
+
|
| 677 |
+
|
| 678 |
def full_head_swap(source: np.ndarray, target: np.ndarray,
|
| 679 |
feather: float = 0.012):
|
| 680 |
"""
|
|
|
|
| 706 |
sf = max(src_faces, key=area)
|
| 707 |
tf = max(tgt_faces, key=area)
|
| 708 |
|
| 709 |
+
# Refuse when the two heads face different ways. The transform below is
|
| 710 |
+
# a 2D similarity, so it can slide, spin and scale the source head but
|
| 711 |
+
# cannot turn it: transplanting a head nodded down onto a body with its
|
| 712 |
+
# chin up lands visibly crooked no matter how well the seam is blended.
|
| 713 |
+
# Better to fall back to the clean face swap than to paste that.
|
| 714 |
+
sp, tp = getattr(sf, "pose", None), getattr(tf, "pose", None)
|
| 715 |
+
if sp is not None and tp is not None:
|
| 716 |
+
d_pitch = abs(float(sp[0]) - float(tp[0]))
|
| 717 |
+
d_yaw = abs(float(sp[1]) - float(tp[1]))
|
| 718 |
+
if max(d_pitch, d_yaw) > HEAD_POSE_MAX:
|
| 719 |
+
print(f"[head_swap] head angles too different "
|
| 720 |
+
f"(pitch {d_pitch:.0f}deg, yaw {d_yaw:.0f}deg > {HEAD_POSE_MAX:.0f}) "
|
| 721 |
+
f"— skipping head swap")
|
| 722 |
+
return None
|
| 723 |
+
|
| 724 |
# Similarity (no shear) keeps the source face SHAPE; aligns it to the
|
| 725 |
# target's eyes/nose/mouth position, scale and rotation.
|
| 726 |
M, _ = cv2.estimateAffinePartial2D(
|
|
@@ -614,6 +614,25 @@ export default function AppPage() {
|
|
| 614 |
</TiltedCard>
|
| 615 |
</div>
|
| 616 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 617 |
{/* download row */}
|
| 618 |
<div className="flex flex-col sm:flex-row gap-3 justify-center mt-2">
|
| 619 |
<a href={result.download_image || result.result_image}
|
|
|
|
| 614 |
</TiltedCard>
|
| 615 |
</div>
|
| 616 |
|
| 617 |
+
{/* Server-side notes about this result — why the head swap was
|
| 618 |
+
skipped, a face too small to swap well, and so on. Without
|
| 619 |
+
these the pipeline silently falls back and the result just
|
| 620 |
+
looks like nothing happened. */}
|
| 621 |
+
{Array.isArray(result.warnings) && result.warnings.length > 0 && (
|
| 622 |
+
<div className="max-w-md w-full mx-auto mt-3 flex flex-col gap-2">
|
| 623 |
+
{result.warnings.map((w, i) => (
|
| 624 |
+
<p key={i}
|
| 625 |
+
className="flex items-start gap-2 text-[11px] leading-relaxed font-medium text-amber-800 bg-amber-50 border border-amber-200 rounded-lg px-3 py-2">
|
| 626 |
+
<svg className="w-3.5 h-3.5 shrink-0 mt-px" fill="none" stroke="currentColor" strokeWidth={2} viewBox="0 0 24 24">
|
| 627 |
+
<path d="M10.29 3.86 1.82 18a2 2 0 0 0 1.71 3h16.94a2 2 0 0 0 1.71-3L13.71 3.86a2 2 0 0 0-3.42 0z"/>
|
| 628 |
+
<line x1="12" y1="9" x2="12" y2="13"/><line x1="12" y1="17" x2="12.01" y2="17"/>
|
| 629 |
+
</svg>
|
| 630 |
+
<span>{w}</span>
|
| 631 |
+
</p>
|
| 632 |
+
))}
|
| 633 |
+
</div>
|
| 634 |
+
)}
|
| 635 |
+
|
| 636 |
{/* download row */}
|
| 637 |
<div className="flex flex-col sm:flex-row gap-3 justify-center mt-2">
|
| 638 |
<a href={result.download_image || result.result_image}
|
|
@@ -871,9 +871,25 @@ def _perform_swap(source, target, opts, progress=None):
|
|
| 871 |
base = target
|
| 872 |
if opts.get("head_swap", "0") in ("1", "true", "on"):
|
| 873 |
try:
|
|
|
|
|
|
|
|
|
|
| 874 |
hs = full_head_swap(source, target)
|
| 875 |
if hs is not None:
|
| 876 |
base = hs
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 877 |
except Exception as e:
|
| 878 |
print(f"[swap] head swap error: {e}")
|
| 879 |
|
|
|
|
| 871 |
base = target
|
| 872 |
if opts.get("head_swap", "0") in ("1", "true", "on"):
|
| 873 |
try:
|
| 874 |
+
from core.head_swap import (head_pose_delta, HEAD_POSE_WARN,
|
| 875 |
+
HEAD_POSE_MAX)
|
| 876 |
+
_pose = head_pose_delta(source, target)
|
| 877 |
hs = full_head_swap(source, target)
|
| 878 |
if hs is not None:
|
| 879 |
base = hs
|
| 880 |
+
if _pose and max(_pose) > HEAD_POSE_WARN:
|
| 881 |
+
warnings.append(
|
| 882 |
+
"Your head angle differs from the location photo "
|
| 883 |
+
f"(about {max(_pose):.0f}°), so the head swap may not line up "
|
| 884 |
+
"perfectly. A photo taken from the same angle works best.")
|
| 885 |
+
elif _pose and max(_pose) > HEAD_POSE_MAX:
|
| 886 |
+
# full_head_swap refused: the heads face different ways and a
|
| 887 |
+
# 2D transform cannot turn one to match the other.
|
| 888 |
+
warnings.append(
|
| 889 |
+
f"Head swap was skipped — your head angle is about "
|
| 890 |
+
f"{max(_pose):.0f}° away from the location photo, which would have "
|
| 891 |
+
"looked crooked. The face swap was used instead. Try a photo "
|
| 892 |
+
"facing the same way as the person in the location.")
|
| 893 |
except Exception as e:
|
| 894 |
print(f"[swap] head swap error: {e}")
|
| 895 |
|