Spaces:
Running
Running
Fix slider performance and version string
Browse files
app.py
CHANGED
|
@@ -80,6 +80,7 @@ MIDLINE_BOTTOM = 152
|
|
| 80 |
# Image preprocessing helpers
|
| 81 |
# ---------------------------------------------------------------------------
|
| 82 |
|
|
|
|
| 83 |
def _normalize_to_bgr(image: np.ndarray) -> np.ndarray:
|
| 84 |
"""Convert any input image format (RGBA, grayscale, etc.) to BGR uint8."""
|
| 85 |
if image is None:
|
|
@@ -163,8 +164,7 @@ def _detect_face_with_hints(image_bgr: np.ndarray) -> tuple[FaceLandmarks | None
|
|
| 163 |
# Check if image is mostly black (too dark even after auto-adjust)
|
| 164 |
if float(np.mean(gray)) < 30:
|
| 165 |
return None, (
|
| 166 |
-
"No face detected -- the image appears very dark. "
|
| 167 |
-
"Try a photo with better lighting."
|
| 168 |
)
|
| 169 |
|
| 170 |
# Check for very low contrast (washed out)
|
|
@@ -178,8 +178,7 @@ def _detect_face_with_hints(image_bgr: np.ndarray) -> tuple[FaceLandmarks | None
|
|
| 178 |
aspect = w / max(h, 1)
|
| 179 |
if aspect > 2.5 or aspect < 0.4:
|
| 180 |
return None, (
|
| 181 |
-
"No face detected -- unusual aspect ratio. "
|
| 182 |
-
"Use a standard portrait or headshot photo."
|
| 183 |
)
|
| 184 |
|
| 185 |
return None, (
|
|
@@ -193,6 +192,7 @@ def _detect_face_with_hints(image_bgr: np.ndarray) -> tuple[FaceLandmarks | None
|
|
| 193 |
# Symmetry analysis
|
| 194 |
# ---------------------------------------------------------------------------
|
| 195 |
|
|
|
|
| 196 |
def compute_symmetry_score(
|
| 197 |
face: FaceLandmarks,
|
| 198 |
) -> tuple[float, dict[str, float]]:
|
|
@@ -212,7 +212,7 @@ def compute_symmetry_score(
|
|
| 212 |
coords = face.pixel_coords # (478, 2) -- property, not method
|
| 213 |
|
| 214 |
# Compute facial midline from forehead top (10) and chin bottom (152)
|
| 215 |
-
mid_top = coords[MIDLINE_TOP]
|
| 216 |
mid_bot = coords[MIDLINE_BOTTOM] # (2,)
|
| 217 |
|
| 218 |
# Midline direction vector and unit normal
|
|
@@ -297,18 +297,24 @@ def render_symmetry_overlay(
|
|
| 297 |
|
| 298 |
# Small label at midline top
|
| 299 |
cv2.putText(
|
| 300 |
-
canvas,
|
| 301 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 302 |
)
|
| 303 |
|
| 304 |
def _score_color(score: float) -> tuple[int, int, int]:
|
| 305 |
"""BGR color based on symmetry score."""
|
| 306 |
if score >= 80:
|
| 307 |
-
return (0, 200, 0)
|
| 308 |
elif score >= 50:
|
| 309 |
return (0, 200, 220) # yellow (BGR)
|
| 310 |
else:
|
| 311 |
-
return (0, 0, 220)
|
| 312 |
|
| 313 |
for region, pairs in SYMMETRY_PAIRS.items():
|
| 314 |
score = region_scores.get(region, 0.0)
|
|
@@ -351,8 +357,14 @@ def render_symmetry_overlay(
|
|
| 351 |
|
| 352 |
label = f"{region}: {score:.0f}"
|
| 353 |
cv2.putText(
|
| 354 |
-
canvas,
|
| 355 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 356 |
)
|
| 357 |
|
| 358 |
return canvas
|
|
@@ -379,6 +391,7 @@ def _format_symmetry_text(
|
|
| 379 |
# Symmetry tab callbacks
|
| 380 |
# ---------------------------------------------------------------------------
|
| 381 |
|
|
|
|
| 382 |
def analyze_symmetry(image_rgb: np.ndarray):
|
| 383 |
"""Analyze facial symmetry from an uploaded photo."""
|
| 384 |
if image_rgb is None:
|
|
@@ -461,6 +474,7 @@ def analyze_symmetry_comparison(
|
|
| 461 |
# Core pipeline functions
|
| 462 |
# ---------------------------------------------------------------------------
|
| 463 |
|
|
|
|
| 464 |
def warp_image_tps(image, src_pts, dst_pts):
|
| 465 |
"""Thin-plate spline warp (CPU only)."""
|
| 466 |
from landmarkdiff.synthetic.tps_warp import warp_image_tps as _warp
|
|
@@ -511,7 +525,10 @@ def process_image(image_rgb, procedure, intensity):
|
|
| 511 |
if hint:
|
| 512 |
return image_rgb_512, image_rgb_512, image_rgb_512, image_rgb_512, hint
|
| 513 |
return (
|
| 514 |
-
image_rgb_512,
|
|
|
|
|
|
|
|
|
|
| 515 |
"No face detected. Try a clearer, well-lit frontal photo.",
|
| 516 |
)
|
| 517 |
|
|
@@ -527,9 +544,7 @@ def process_image(image_rgb, procedure, intensity):
|
|
| 527 |
composited = mask_composite(warped, image_bgr, mask)
|
| 528 |
composited_rgb = cv2.cvtColor(composited, cv2.COLOR_BGR2RGB)
|
| 529 |
|
| 530 |
-
displacement = np.mean(
|
| 531 |
-
np.linalg.norm(manipulated.pixel_coords - face.pixel_coords, axis=1)
|
| 532 |
-
)
|
| 533 |
elapsed = time.monotonic() - t0
|
| 534 |
|
| 535 |
# Compute symmetry for original and predicted result
|
|
@@ -615,8 +630,7 @@ def intensity_sweep(image_rgb, procedure):
|
|
| 615 |
# ---------------------------------------------------------------------------
|
| 616 |
|
| 617 |
_proc_table = "\n".join(
|
| 618 |
-
f"| {name.replace('_', ' ').title()} | {desc} |"
|
| 619 |
-
for name, desc in PROCEDURE_INFO.items()
|
| 620 |
)
|
| 621 |
|
| 622 |
with gr.Blocks(
|
|
@@ -639,10 +653,16 @@ with gr.Blocks(
|
|
| 639 |
with gr.Column(scale=1):
|
| 640 |
input_image = gr.Image(label="Face Photo", type="numpy", height=350)
|
| 641 |
procedure = gr.Radio(
|
| 642 |
-
choices=PROCEDURES,
|
|
|
|
|
|
|
| 643 |
)
|
| 644 |
intensity = gr.Slider(
|
| 645 |
-
0,
|
|
|
|
|
|
|
|
|
|
|
|
|
| 646 |
info="0 = no change, 100 = maximum",
|
| 647 |
)
|
| 648 |
run_btn = gr.Button("Generate", variant="primary", size="lg")
|
|
@@ -666,8 +686,12 @@ with gr.Blocks(
|
|
| 666 |
outputs = [out_wireframe, out_mask, out_result, out_original, info_box]
|
| 667 |
_inputs = [input_image, procedure, intensity]
|
| 668 |
run_btn.click(fn=process_image, inputs=_inputs, outputs=outputs)
|
| 669 |
-
|
| 670 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 671 |
|
| 672 |
# -- Tab 2: Compare Procedures --
|
| 673 |
with gr.Tab("Compare All"):
|
|
@@ -694,7 +718,8 @@ with gr.Blocks(
|
|
| 694 |
if EXAMPLE_IMAGES:
|
| 695 |
gr.Examples(
|
| 696 |
examples=[[str(p)] for p in EXAMPLE_IMAGES],
|
| 697 |
-
inputs=[cmp_image],
|
|
|
|
| 698 |
)
|
| 699 |
|
| 700 |
cmp_btn.click(fn=compare_procedures, inputs=[cmp_image, cmp_intensity], outputs=cmp_outputs)
|
|
@@ -713,7 +738,8 @@ with gr.Blocks(
|
|
| 713 |
if EXAMPLE_IMAGES:
|
| 714 |
gr.Examples(
|
| 715 |
examples=[[str(p)] for p in EXAMPLE_IMAGES],
|
| 716 |
-
inputs=[sweep_image],
|
|
|
|
| 717 |
)
|
| 718 |
|
| 719 |
sweep_btn.click(
|
|
@@ -741,22 +767,29 @@ with gr.Blocks(
|
|
| 741 |
with gr.Row():
|
| 742 |
with gr.Column(scale=1):
|
| 743 |
sym_image = gr.Image(
|
| 744 |
-
label="Face Photo",
|
|
|
|
|
|
|
| 745 |
)
|
| 746 |
sym_btn = gr.Button(
|
| 747 |
-
"Analyze Symmetry",
|
|
|
|
|
|
|
| 748 |
)
|
| 749 |
with gr.Column(scale=1):
|
| 750 |
sym_overlay = gr.Image(label="Symmetry Overlay", height=350)
|
| 751 |
|
| 752 |
sym_scores_box = gr.Textbox(
|
| 753 |
-
label="Symmetry Scores",
|
|
|
|
|
|
|
| 754 |
)
|
| 755 |
|
| 756 |
if EXAMPLE_IMAGES:
|
| 757 |
gr.Examples(
|
| 758 |
examples=[[str(p)] for p in EXAMPLE_IMAGES],
|
| 759 |
-
inputs=[sym_image],
|
|
|
|
| 760 |
)
|
| 761 |
|
| 762 |
sym_btn.click(
|
|
@@ -773,23 +806,33 @@ with gr.Blocks(
|
|
| 773 |
)
|
| 774 |
with gr.Row():
|
| 775 |
sym_pre_image = gr.Image(
|
| 776 |
-
label="Pre-Procedure",
|
|
|
|
|
|
|
| 777 |
)
|
| 778 |
sym_post_image = gr.Image(
|
| 779 |
-
label="Post-Procedure",
|
|
|
|
|
|
|
| 780 |
)
|
| 781 |
sym_cmp_btn = gr.Button(
|
| 782 |
-
"Compare Symmetry",
|
|
|
|
|
|
|
| 783 |
)
|
| 784 |
with gr.Row():
|
| 785 |
sym_pre_overlay = gr.Image(
|
| 786 |
-
label="Pre Symmetry Overlay",
|
|
|
|
| 787 |
)
|
| 788 |
sym_post_overlay = gr.Image(
|
| 789 |
-
label="Post Symmetry Overlay",
|
|
|
|
| 790 |
)
|
| 791 |
sym_cmp_box = gr.Textbox(
|
| 792 |
-
label="Comparison",
|
|
|
|
|
|
|
| 793 |
)
|
| 794 |
|
| 795 |
sym_cmp_btn.click(
|
|
@@ -800,7 +843,7 @@ with gr.Blocks(
|
|
| 800 |
|
| 801 |
gr.Markdown(
|
| 802 |
f"<div style='text-align:center;color:#999;font-size:0.8em;padding:8px'>"
|
| 803 |
-
f"LandmarkDiff v0.2.
|
| 804 |
f"<a href='{GITHUB_URL}'>GitHub</a> | MIT License</div>"
|
| 805 |
)
|
| 806 |
|
|
|
|
| 80 |
# Image preprocessing helpers
|
| 81 |
# ---------------------------------------------------------------------------
|
| 82 |
|
| 83 |
+
|
| 84 |
def _normalize_to_bgr(image: np.ndarray) -> np.ndarray:
|
| 85 |
"""Convert any input image format (RGBA, grayscale, etc.) to BGR uint8."""
|
| 86 |
if image is None:
|
|
|
|
| 164 |
# Check if image is mostly black (too dark even after auto-adjust)
|
| 165 |
if float(np.mean(gray)) < 30:
|
| 166 |
return None, (
|
| 167 |
+
"No face detected -- the image appears very dark. Try a photo with better lighting."
|
|
|
|
| 168 |
)
|
| 169 |
|
| 170 |
# Check for very low contrast (washed out)
|
|
|
|
| 178 |
aspect = w / max(h, 1)
|
| 179 |
if aspect > 2.5 or aspect < 0.4:
|
| 180 |
return None, (
|
| 181 |
+
"No face detected -- unusual aspect ratio. Use a standard portrait or headshot photo."
|
|
|
|
| 182 |
)
|
| 183 |
|
| 184 |
return None, (
|
|
|
|
| 192 |
# Symmetry analysis
|
| 193 |
# ---------------------------------------------------------------------------
|
| 194 |
|
| 195 |
+
|
| 196 |
def compute_symmetry_score(
|
| 197 |
face: FaceLandmarks,
|
| 198 |
) -> tuple[float, dict[str, float]]:
|
|
|
|
| 212 |
coords = face.pixel_coords # (478, 2) -- property, not method
|
| 213 |
|
| 214 |
# Compute facial midline from forehead top (10) and chin bottom (152)
|
| 215 |
+
mid_top = coords[MIDLINE_TOP] # (2,)
|
| 216 |
mid_bot = coords[MIDLINE_BOTTOM] # (2,)
|
| 217 |
|
| 218 |
# Midline direction vector and unit normal
|
|
|
|
| 297 |
|
| 298 |
# Small label at midline top
|
| 299 |
cv2.putText(
|
| 300 |
+
canvas,
|
| 301 |
+
"midline",
|
| 302 |
+
(int(mid_top[0]) + 5, int(mid_top[1]) - 5),
|
| 303 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 304 |
+
0.4,
|
| 305 |
+
(255, 200, 0),
|
| 306 |
+
1,
|
| 307 |
+
cv2.LINE_AA,
|
| 308 |
)
|
| 309 |
|
| 310 |
def _score_color(score: float) -> tuple[int, int, int]:
|
| 311 |
"""BGR color based on symmetry score."""
|
| 312 |
if score >= 80:
|
| 313 |
+
return (0, 200, 0) # green
|
| 314 |
elif score >= 50:
|
| 315 |
return (0, 200, 220) # yellow (BGR)
|
| 316 |
else:
|
| 317 |
+
return (0, 0, 220) # red
|
| 318 |
|
| 319 |
for region, pairs in SYMMETRY_PAIRS.items():
|
| 320 |
score = region_scores.get(region, 0.0)
|
|
|
|
| 357 |
|
| 358 |
label = f"{region}: {score:.0f}"
|
| 359 |
cv2.putText(
|
| 360 |
+
canvas,
|
| 361 |
+
label,
|
| 362 |
+
label_pos,
|
| 363 |
+
cv2.FONT_HERSHEY_SIMPLEX,
|
| 364 |
+
0.4,
|
| 365 |
+
color,
|
| 366 |
+
1,
|
| 367 |
+
cv2.LINE_AA,
|
| 368 |
)
|
| 369 |
|
| 370 |
return canvas
|
|
|
|
| 391 |
# Symmetry tab callbacks
|
| 392 |
# ---------------------------------------------------------------------------
|
| 393 |
|
| 394 |
+
|
| 395 |
def analyze_symmetry(image_rgb: np.ndarray):
|
| 396 |
"""Analyze facial symmetry from an uploaded photo."""
|
| 397 |
if image_rgb is None:
|
|
|
|
| 474 |
# Core pipeline functions
|
| 475 |
# ---------------------------------------------------------------------------
|
| 476 |
|
| 477 |
+
|
| 478 |
def warp_image_tps(image, src_pts, dst_pts):
|
| 479 |
"""Thin-plate spline warp (CPU only)."""
|
| 480 |
from landmarkdiff.synthetic.tps_warp import warp_image_tps as _warp
|
|
|
|
| 525 |
if hint:
|
| 526 |
return image_rgb_512, image_rgb_512, image_rgb_512, image_rgb_512, hint
|
| 527 |
return (
|
| 528 |
+
image_rgb_512,
|
| 529 |
+
image_rgb_512,
|
| 530 |
+
image_rgb_512,
|
| 531 |
+
image_rgb_512,
|
| 532 |
"No face detected. Try a clearer, well-lit frontal photo.",
|
| 533 |
)
|
| 534 |
|
|
|
|
| 544 |
composited = mask_composite(warped, image_bgr, mask)
|
| 545 |
composited_rgb = cv2.cvtColor(composited, cv2.COLOR_BGR2RGB)
|
| 546 |
|
| 547 |
+
displacement = np.mean(np.linalg.norm(manipulated.pixel_coords - face.pixel_coords, axis=1))
|
|
|
|
|
|
|
| 548 |
elapsed = time.monotonic() - t0
|
| 549 |
|
| 550 |
# Compute symmetry for original and predicted result
|
|
|
|
| 630 |
# ---------------------------------------------------------------------------
|
| 631 |
|
| 632 |
_proc_table = "\n".join(
|
| 633 |
+
f"| {name.replace('_', ' ').title()} | {desc} |" for name, desc in PROCEDURE_INFO.items()
|
|
|
|
| 634 |
)
|
| 635 |
|
| 636 |
with gr.Blocks(
|
|
|
|
| 653 |
with gr.Column(scale=1):
|
| 654 |
input_image = gr.Image(label="Face Photo", type="numpy", height=350)
|
| 655 |
procedure = gr.Radio(
|
| 656 |
+
choices=PROCEDURES,
|
| 657 |
+
value="rhinoplasty",
|
| 658 |
+
label="Procedure",
|
| 659 |
)
|
| 660 |
intensity = gr.Slider(
|
| 661 |
+
0,
|
| 662 |
+
100,
|
| 663 |
+
50,
|
| 664 |
+
step=1,
|
| 665 |
+
label="Intensity (%)",
|
| 666 |
info="0 = no change, 100 = maximum",
|
| 667 |
)
|
| 668 |
run_btn = gr.Button("Generate", variant="primary", size="lg")
|
|
|
|
| 686 |
outputs = [out_wireframe, out_mask, out_result, out_original, info_box]
|
| 687 |
_inputs = [input_image, procedure, intensity]
|
| 688 |
run_btn.click(fn=process_image, inputs=_inputs, outputs=outputs)
|
| 689 |
+
# Auto-trigger on image upload and procedure change, but not on every
|
| 690 |
+
# slider tick during drag (each tick would re-run TPS on free CPU,
|
| 691 |
+
# causing severe lag). Use .release so it fires once on mouse-up.
|
| 692 |
+
input_image.change(fn=process_image, inputs=_inputs, outputs=outputs)
|
| 693 |
+
procedure.change(fn=process_image, inputs=_inputs, outputs=outputs)
|
| 694 |
+
intensity.release(fn=process_image, inputs=_inputs, outputs=outputs)
|
| 695 |
|
| 696 |
# -- Tab 2: Compare Procedures --
|
| 697 |
with gr.Tab("Compare All"):
|
|
|
|
| 718 |
if EXAMPLE_IMAGES:
|
| 719 |
gr.Examples(
|
| 720 |
examples=[[str(p)] for p in EXAMPLE_IMAGES],
|
| 721 |
+
inputs=[cmp_image],
|
| 722 |
+
label="Examples",
|
| 723 |
)
|
| 724 |
|
| 725 |
cmp_btn.click(fn=compare_procedures, inputs=[cmp_image, cmp_intensity], outputs=cmp_outputs)
|
|
|
|
| 738 |
if EXAMPLE_IMAGES:
|
| 739 |
gr.Examples(
|
| 740 |
examples=[[str(p)] for p in EXAMPLE_IMAGES],
|
| 741 |
+
inputs=[sweep_image],
|
| 742 |
+
label="Examples",
|
| 743 |
)
|
| 744 |
|
| 745 |
sweep_btn.click(
|
|
|
|
| 767 |
with gr.Row():
|
| 768 |
with gr.Column(scale=1):
|
| 769 |
sym_image = gr.Image(
|
| 770 |
+
label="Face Photo",
|
| 771 |
+
type="numpy",
|
| 772 |
+
height=350,
|
| 773 |
)
|
| 774 |
sym_btn = gr.Button(
|
| 775 |
+
"Analyze Symmetry",
|
| 776 |
+
variant="primary",
|
| 777 |
+
size="lg",
|
| 778 |
)
|
| 779 |
with gr.Column(scale=1):
|
| 780 |
sym_overlay = gr.Image(label="Symmetry Overlay", height=350)
|
| 781 |
|
| 782 |
sym_scores_box = gr.Textbox(
|
| 783 |
+
label="Symmetry Scores",
|
| 784 |
+
lines=8,
|
| 785 |
+
interactive=False,
|
| 786 |
)
|
| 787 |
|
| 788 |
if EXAMPLE_IMAGES:
|
| 789 |
gr.Examples(
|
| 790 |
examples=[[str(p)] for p in EXAMPLE_IMAGES],
|
| 791 |
+
inputs=[sym_image],
|
| 792 |
+
label="Examples",
|
| 793 |
)
|
| 794 |
|
| 795 |
sym_btn.click(
|
|
|
|
| 806 |
)
|
| 807 |
with gr.Row():
|
| 808 |
sym_pre_image = gr.Image(
|
| 809 |
+
label="Pre-Procedure",
|
| 810 |
+
type="numpy",
|
| 811 |
+
height=300,
|
| 812 |
)
|
| 813 |
sym_post_image = gr.Image(
|
| 814 |
+
label="Post-Procedure",
|
| 815 |
+
type="numpy",
|
| 816 |
+
height=300,
|
| 817 |
)
|
| 818 |
sym_cmp_btn = gr.Button(
|
| 819 |
+
"Compare Symmetry",
|
| 820 |
+
variant="primary",
|
| 821 |
+
size="lg",
|
| 822 |
)
|
| 823 |
with gr.Row():
|
| 824 |
sym_pre_overlay = gr.Image(
|
| 825 |
+
label="Pre Symmetry Overlay",
|
| 826 |
+
height=300,
|
| 827 |
)
|
| 828 |
sym_post_overlay = gr.Image(
|
| 829 |
+
label="Post Symmetry Overlay",
|
| 830 |
+
height=300,
|
| 831 |
)
|
| 832 |
sym_cmp_box = gr.Textbox(
|
| 833 |
+
label="Comparison",
|
| 834 |
+
lines=14,
|
| 835 |
+
interactive=False,
|
| 836 |
)
|
| 837 |
|
| 838 |
sym_cmp_btn.click(
|
|
|
|
| 843 |
|
| 844 |
gr.Markdown(
|
| 845 |
f"<div style='text-align:center;color:#999;font-size:0.8em;padding:8px'>"
|
| 846 |
+
f"LandmarkDiff v0.2.2 | TPS on CPU | MediaPipe 478-point mesh | "
|
| 847 |
f"<a href='{GITHUB_URL}'>GitHub</a> | MIT License</div>"
|
| 848 |
)
|
| 849 |
|