aditya-rAj19 commited on
Commit
f008eb7
·
1 Parent(s): 0ab4ea6

Refuse a head swap when the two heads face different ways, and say so

Browse files

full_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.

Files changed (3) hide show
  1. core/head_swap.py +48 -0
  2. frontend/src/pages/AppPage.jsx +19 -0
  3. web_app.py +16 -0
core/head_swap.py CHANGED
@@ -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(
frontend/src/pages/AppPage.jsx CHANGED
@@ -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}
web_app.py CHANGED
@@ -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