dreamlessx commited on
Commit
0f5dd41
·
verified ·
1 Parent(s): 9f3acc0

Add facial symmetry analysis tab

Browse files
Files changed (1) hide show
  1. app.py +542 -32
app.py CHANGED
@@ -12,7 +12,7 @@ import gradio as gr
12
  import numpy as np
13
 
14
  from landmarkdiff.conditioning import render_wireframe
15
- from landmarkdiff.landmarks import extract_landmarks
16
  from landmarkdiff.manipulation import PROCEDURE_LANDMARKS, apply_procedure_preset
17
  from landmarkdiff.masking import generate_surgical_mask
18
 
@@ -33,6 +33,433 @@ PROCEDURE_INFO = {
33
  "mentoplasty": "Chin surgery (projection, vertical height)",
34
  }
35
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
36
 
37
  def warp_image_tps(image, src_pts, dst_pts):
38
  """Thin-plate spline warp (CPU only)."""
@@ -65,7 +492,7 @@ def _blank():
65
 
66
 
67
  def process_image(image_rgb, procedure, intensity):
68
- """Run the TPS pipeline on a single image."""
69
  if image_rgb is None:
70
  b = _blank()
71
  return b, b, b, b, "Upload a face photo to begin."
@@ -73,22 +500,16 @@ def process_image(image_rgb, procedure, intensity):
73
  t0 = time.monotonic()
74
 
75
  try:
76
- image_bgr = cv2.cvtColor(np.asarray(image_rgb, dtype=np.uint8), cv2.COLOR_RGB2BGR)
77
- image_bgr = resize_preserve_aspect(image_bgr, 512)
78
- image_rgb_512 = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
79
  except Exception as exc:
80
  logger.error("Image conversion failed: %s", exc)
81
  b = _blank()
82
  return b, b, b, b, f"Image conversion failed: {exc}"
83
 
84
- try:
85
- face = extract_landmarks(image_bgr)
86
- except Exception as exc:
87
- logger.error("Landmark extraction failed: %s\n%s", exc, traceback.format_exc())
88
- b = _blank()
89
- return b, b, b, b, f"Landmark extraction error: {exc}"
90
-
91
  if face is None:
 
 
92
  return (
93
  image_rgb_512, image_rgb_512, image_rgb_512, image_rgb_512,
94
  "No face detected. Try a clearer, well-lit frontal photo.",
@@ -111,14 +532,24 @@ def process_image(image_rgb, procedure, intensity):
111
  )
112
  elapsed = time.monotonic() - t0
113
 
114
- info = (
115
- f"Procedure: {procedure.replace('_', ' ').title()}\n"
116
- f"Intensity: {intensity:.0f}%\n"
117
- f"Landmarks: {len(face.landmarks)}\n"
118
- f"Avg displacement: {displacement:.1f} px\n"
119
- f"Confidence: {face.confidence:.2f}\n"
120
- f"Time: {elapsed:.2f}s | Mode: TPS (CPU)"
121
- )
 
 
 
 
 
 
 
 
 
 
122
  return wireframe_rgb, mask_vis, composited_rgb, image_rgb_512, info
123
 
124
  except Exception as exc:
@@ -133,9 +564,8 @@ def compare_procedures(image_rgb, intensity):
133
  return [_blank()] * len(PROCEDURES)
134
 
135
  try:
136
- image_bgr = cv2.cvtColor(np.asarray(image_rgb, dtype=np.uint8), cv2.COLOR_RGB2BGR)
137
- image_bgr = resize_preserve_aspect(image_bgr, 512)
138
- face = extract_landmarks(image_bgr)
139
  if face is None:
140
  rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
141
  return [rgb] * len(PROCEDURES)
@@ -159,9 +589,8 @@ def intensity_sweep(image_rgb, procedure):
159
  return []
160
 
161
  try:
162
- image_bgr = cv2.cvtColor(np.asarray(image_rgb, dtype=np.uint8), cv2.COLOR_RGB2BGR)
163
- image_bgr = resize_preserve_aspect(image_bgr, 512)
164
- face = extract_landmarks(image_bgr)
165
  if face is None:
166
  return []
167
 
@@ -217,7 +646,7 @@ with gr.Blocks(
217
  info="0 = no change, 100 = maximum",
218
  )
219
  run_btn = gr.Button("Generate", variant="primary", size="lg")
220
- info_box = gr.Textbox(label="Info", lines=6, interactive=False)
221
 
222
  with gr.Column(scale=2):
223
  with gr.Row():
@@ -235,9 +664,10 @@ with gr.Blocks(
235
  )
236
 
237
  outputs = [out_wireframe, out_mask, out_result, out_original, info_box]
238
- run_btn.click(fn=process_image, inputs=[input_image, procedure, intensity], outputs=outputs)
239
- for trigger in [input_image, procedure, intensity]:
240
- trigger.change(fn=process_image, inputs=[input_image, procedure, intensity], outputs=outputs)
 
241
 
242
  # -- Tab 2: Compare Procedures --
243
  with gr.Tab("Compare All"):
@@ -286,11 +716,91 @@ with gr.Blocks(
286
  inputs=[sweep_image], label="Examples",
287
  )
288
 
289
- sweep_btn.click(fn=intensity_sweep, inputs=[sweep_image, sweep_proc], outputs=[sweep_gallery])
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
290
 
291
  gr.Markdown(
292
  f"<div style='text-align:center;color:#999;font-size:0.8em;padding:8px'>"
293
- f"LandmarkDiff v0.2.2 | TPS on CPU | MediaPipe 478-point mesh | "
294
  f"<a href='{GITHUB_URL}'>GitHub</a> | MIT License</div>"
295
  )
296
 
 
12
  import numpy as np
13
 
14
  from landmarkdiff.conditioning import render_wireframe
15
+ from landmarkdiff.landmarks import FaceLandmarks, extract_landmarks
16
  from landmarkdiff.manipulation import PROCEDURE_LANDMARKS, apply_procedure_preset
17
  from landmarkdiff.masking import generate_surgical_mask
18
 
 
33
  "mentoplasty": "Chin surgery (projection, vertical height)",
34
  }
35
 
36
+ # ---------------------------------------------------------------------------
37
+ # Bilateral symmetry landmark pairs (MediaPipe face mesh indices)
38
+ # ---------------------------------------------------------------------------
39
+ SYMMETRY_PAIRS: dict[str, list[tuple[int, int]]] = {
40
+ "eyes": [
41
+ (33, 263),
42
+ (133, 362),
43
+ (159, 386),
44
+ (145, 374),
45
+ ],
46
+ "brows": [
47
+ (70, 300),
48
+ (63, 293),
49
+ (105, 334),
50
+ (66, 296),
51
+ (107, 336),
52
+ ],
53
+ "cheeks": [
54
+ (116, 345),
55
+ (123, 352),
56
+ (147, 376),
57
+ (187, 411),
58
+ (205, 425),
59
+ ],
60
+ "mouth": [
61
+ (61, 291),
62
+ (78, 308),
63
+ (95, 324),
64
+ ],
65
+ "jaw": [
66
+ (172, 397),
67
+ (136, 365),
68
+ (150, 379),
69
+ (149, 378),
70
+ (176, 400),
71
+ ],
72
+ }
73
+
74
+ # Midline landmarks: forehead top and chin bottom
75
+ MIDLINE_TOP = 10
76
+ MIDLINE_BOTTOM = 152
77
+
78
+
79
+ # ---------------------------------------------------------------------------
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:
86
+ raise ValueError("No image provided")
87
+
88
+ img = np.asarray(image)
89
+
90
+ # Handle float images (0-1 range)
91
+ if img.dtype in (np.float32, np.float64):
92
+ img = (np.clip(img, 0.0, 1.0) * 255).astype(np.uint8)
93
+
94
+ # Ensure uint8
95
+ if img.dtype != np.uint8:
96
+ img = img.astype(np.uint8)
97
+
98
+ if img.ndim == 2:
99
+ # Grayscale -> BGR
100
+ return cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
101
+ elif img.ndim == 3:
102
+ channels = img.shape[2]
103
+ if channels == 4:
104
+ # RGBA -> BGR (drop alpha)
105
+ return cv2.cvtColor(img, cv2.COLOR_RGBA2BGR)
106
+ elif channels == 3:
107
+ # RGB -> BGR (Gradio sends RGB)
108
+ return cv2.cvtColor(img, cv2.COLOR_RGB2BGR)
109
+ elif channels == 1:
110
+ return cv2.cvtColor(img.squeeze(-1), cv2.COLOR_GRAY2BGR)
111
+ raise ValueError(f"Unsupported image shape: {img.shape}")
112
+
113
+
114
+ def _auto_adjust_brightness(image_bgr: np.ndarray) -> np.ndarray:
115
+ """Auto-adjust brightness/contrast if the image is too dark or washed out.
116
+
117
+ Uses CLAHE on the L channel of LAB color space for adaptive histogram
118
+ equalization that preserves color balance.
119
+ """
120
+ lab = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2LAB)
121
+ l_channel = lab[:, :, 0]
122
+ mean_l = float(np.mean(l_channel))
123
+
124
+ # Only adjust if clearly too dark (<60) or washed out (>200)
125
+ if mean_l < 60 or mean_l > 200:
126
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
127
+ lab[:, :, 0] = clahe.apply(l_channel)
128
+ return cv2.cvtColor(lab, cv2.COLOR_LAB2BGR)
129
+
130
+ return image_bgr
131
+
132
+
133
+ def _prepare_image(image_rgb: np.ndarray, size: int = 512) -> tuple[np.ndarray, np.ndarray]:
134
+ """Full preprocessing pipeline: normalize, resize, auto-adjust.
135
+
136
+ Returns (image_bgr_512, image_rgb_512).
137
+ """
138
+ image_bgr = _normalize_to_bgr(image_rgb)
139
+ image_bgr = resize_preserve_aspect(image_bgr, size)
140
+ image_bgr = _auto_adjust_brightness(image_bgr)
141
+ image_rgb_out = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
142
+ return image_bgr, image_rgb_out
143
+
144
+
145
+ def _detect_face_with_hints(image_bgr: np.ndarray) -> tuple[FaceLandmarks | None, str]:
146
+ """Extract landmarks with better error messages for common failure modes.
147
+
148
+ Returns (face_or_None, error_hint_string).
149
+ """
150
+ try:
151
+ face = extract_landmarks(image_bgr)
152
+ except Exception as exc:
153
+ logger.error("Landmark extraction failed: %s\n%s", exc, traceback.format_exc())
154
+ return None, f"Landmark extraction error: {exc}"
155
+
156
+ if face is not None:
157
+ return face, ""
158
+
159
+ # Try to give a more useful hint about why detection failed.
160
+ gray = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2GRAY)
161
+ h, w = gray.shape[:2]
162
+
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)
171
+ if float(np.std(gray)) < 15:
172
+ return None, (
173
+ "No face detected -- the image has very low contrast. "
174
+ "Try a photo with more natural lighting."
175
+ )
176
+
177
+ # Check aspect ratio -- extremely tall/wide may indicate a side profile crop
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, (
186
+ "No face detected. Make sure the photo shows a clear, "
187
+ "well-lit frontal face. Side profiles and heavily occluded "
188
+ "faces are not supported."
189
+ )
190
+
191
+
192
+ # ---------------------------------------------------------------------------
193
+ # Symmetry analysis
194
+ # ---------------------------------------------------------------------------
195
+
196
+ def compute_symmetry_score(
197
+ face: FaceLandmarks,
198
+ ) -> tuple[float, dict[str, float]]:
199
+ """Compute bilateral facial symmetry from a FaceLandmarks object.
200
+
201
+ Reflects left-side landmarks across the facial midline and measures the
202
+ Euclidean distance to their right-side counterparts. Distances are
203
+ normalized by the inter-pupil distance to make the score scale-invariant.
204
+
205
+ Args:
206
+ face: FaceLandmarks with .pixel_coords property returning (478, 2).
207
+
208
+ Returns:
209
+ (overall_score, region_scores) where scores are 0-100
210
+ (100 = perfectly symmetric).
211
+ """
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
219
+ midline_dir = mid_bot - mid_top
220
+ midline_len = np.linalg.norm(midline_dir)
221
+ if midline_len < 1e-6:
222
+ # Degenerate case -- landmarks are stacked
223
+ return 0.0, {region: 0.0 for region in SYMMETRY_PAIRS}
224
+
225
+ midline_unit = midline_dir / midline_len
226
+ # Normal to midline (pointing right)
227
+ midline_normal = np.array([midline_unit[1], -midline_unit[0]])
228
+
229
+ # Normalization factor: use inter-eye distance (outer corners 33 <-> 263)
230
+ # for scale-invariant scoring
231
+ inter_eye = float(np.linalg.norm(coords[33] - coords[263]))
232
+ if inter_eye < 1e-6:
233
+ inter_eye = midline_len * 0.4 # fallback
234
+
235
+ region_scores: dict[str, float] = {}
236
+ all_distances: list[float] = []
237
+
238
+ for region, pairs in SYMMETRY_PAIRS.items():
239
+ region_dists: list[float] = []
240
+ for left_idx, right_idx in pairs:
241
+ left_pt = coords[left_idx]
242
+ right_pt = coords[right_idx]
243
+
244
+ # Reflect left point across the midline:
245
+ # 1. Vector from midline top to the point
246
+ v = left_pt - mid_top
247
+ # 2. Component along the midline normal
248
+ normal_component = np.dot(v, midline_normal)
249
+ # 3. Reflected point: subtract twice the normal component
250
+ reflected = left_pt - 2.0 * normal_component * midline_normal
251
+
252
+ # Distance between reflected-left and actual-right
253
+ dist = float(np.linalg.norm(reflected - right_pt))
254
+ region_dists.append(dist)
255
+
256
+ # Normalize by inter-eye distance and convert to 0-100 score
257
+ if region_dists:
258
+ mean_dist = float(np.mean(region_dists))
259
+ # Normalized distance as fraction of inter-eye distance
260
+ norm_dist = mean_dist / inter_eye
261
+ # Convert to score: 0 distance = 100, large distance = 0
262
+ # Use exponential decay so small asymmetries are penalized gently
263
+ score = 100.0 * np.exp(-3.0 * norm_dist)
264
+ region_scores[region] = round(max(0.0, min(100.0, score)), 1)
265
+ all_distances.extend(region_dists)
266
+ else:
267
+ region_scores[region] = 0.0
268
+
269
+ # Overall score: weighted mean of all pair distances
270
+ if all_distances:
271
+ overall_norm = float(np.mean(all_distances)) / inter_eye
272
+ overall = 100.0 * np.exp(-3.0 * overall_norm)
273
+ overall = round(max(0.0, min(100.0, overall)), 1)
274
+ else:
275
+ overall = 0.0
276
+
277
+ return overall, region_scores
278
+
279
+
280
+ def render_symmetry_overlay(
281
+ image_bgr: np.ndarray,
282
+ face: FaceLandmarks,
283
+ region_scores: dict[str, float],
284
+ ) -> np.ndarray:
285
+ """Draw a symmetry visualization overlay on the image.
286
+
287
+ Draws the facial midline and color-codes bilateral landmark pairs by
288
+ their region symmetry score: green (>80), yellow (50-80), red (<50).
289
+ """
290
+ canvas = image_bgr.copy()
291
+ coords = face.pixel_coords
292
+
293
+ # Draw midline
294
+ mid_top = coords[MIDLINE_TOP].astype(int)
295
+ mid_bot = coords[MIDLINE_BOTTOM].astype(int)
296
+ cv2.line(canvas, tuple(mid_top), tuple(mid_bot), (255, 200, 0), 2, cv2.LINE_AA)
297
+
298
+ # Small label at midline top
299
+ cv2.putText(
300
+ canvas, "midline", (int(mid_top[0]) + 5, int(mid_top[1]) - 5),
301
+ cv2.FONT_HERSHEY_SIMPLEX, 0.4, (255, 200, 0), 1, cv2.LINE_AA,
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) # green
308
+ elif score >= 50:
309
+ return (0, 200, 220) # yellow (BGR)
310
+ else:
311
+ return (0, 0, 220) # red
312
+
313
+ for region, pairs in SYMMETRY_PAIRS.items():
314
+ score = region_scores.get(region, 0.0)
315
+ color = _score_color(score)
316
+
317
+ for left_idx, right_idx in pairs:
318
+ lp = coords[left_idx].astype(int)
319
+ rp = coords[right_idx].astype(int)
320
+
321
+ # Draw landmark dots
322
+ cv2.circle(canvas, tuple(lp), 3, color, -1, cv2.LINE_AA)
323
+ cv2.circle(canvas, tuple(rp), 3, color, -1, cv2.LINE_AA)
324
+
325
+ # Draw thin connecting line across midline
326
+ cv2.line(canvas, tuple(lp), tuple(rp), color, 1, cv2.LINE_AA)
327
+
328
+ # Draw region labels with scores
329
+ # Position labels near each region's centroid
330
+ region_label_offsets: dict[str, tuple[int, int]] = {
331
+ "eyes": (0, -15),
332
+ "brows": (0, -10),
333
+ "cheeks": (15, 0),
334
+ "mouth": (0, 10),
335
+ "jaw": (0, 15),
336
+ }
337
+
338
+ for region, pairs in SYMMETRY_PAIRS.items():
339
+ score = region_scores.get(region, 0.0)
340
+ color = _score_color(score)
341
+
342
+ # Compute centroid of the region landmarks
343
+ region_pts = []
344
+ for left_idx, right_idx in pairs:
345
+ region_pts.append(coords[left_idx])
346
+ region_pts.append(coords[right_idx])
347
+ centroid = np.mean(region_pts, axis=0).astype(int)
348
+
349
+ ox, oy = region_label_offsets.get(region, (0, 0))
350
+ label_pos = (int(centroid[0]) + ox, int(centroid[1]) + oy)
351
+
352
+ label = f"{region}: {score:.0f}"
353
+ cv2.putText(
354
+ canvas, label, label_pos,
355
+ cv2.FONT_HERSHEY_SIMPLEX, 0.4, color, 1, cv2.LINE_AA,
356
+ )
357
+
358
+ return canvas
359
+
360
+
361
+ def _format_symmetry_text(
362
+ overall: float,
363
+ region_scores: dict[str, float],
364
+ prefix: str = "",
365
+ ) -> str:
366
+ """Format symmetry scores into a readable text block."""
367
+ lines = []
368
+ if prefix:
369
+ lines.append(prefix)
370
+ lines.append(f"Overall symmetry: {overall:.1f}/100")
371
+ for region, score in region_scores.items():
372
+ bar_len = int(score / 5)
373
+ bar = "|" * bar_len + "." * (20 - bar_len)
374
+ lines.append(f" {region:>6s}: {score:5.1f} [{bar}]")
375
+ return "\n".join(lines)
376
+
377
+
378
+ # ---------------------------------------------------------------------------
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:
385
+ b = _blank()
386
+ return b, "Upload a face photo to analyze symmetry."
387
+
388
+ try:
389
+ image_bgr, image_rgb_512 = _prepare_image(image_rgb, 512)
390
+ except Exception as exc:
391
+ logger.error("Image conversion failed: %s", exc)
392
+ b = _blank()
393
+ return b, f"Image conversion failed: {exc}"
394
+
395
+ face, hint = _detect_face_with_hints(image_bgr)
396
+ if face is None:
397
+ return image_rgb_512, hint
398
+
399
+ overall, region_scores = compute_symmetry_score(face)
400
+ overlay_bgr = render_symmetry_overlay(image_bgr, face, region_scores)
401
+ overlay_rgb = cv2.cvtColor(overlay_bgr, cv2.COLOR_BGR2RGB)
402
+
403
+ text = _format_symmetry_text(overall, region_scores)
404
+ return overlay_rgb, text
405
+
406
+
407
+ def analyze_symmetry_comparison(
408
+ pre_image_rgb: np.ndarray,
409
+ post_image_rgb: np.ndarray,
410
+ ):
411
+ """Compare symmetry between pre- and post-procedure photos."""
412
+ b = _blank()
413
+ empty = (b, b, "Upload both pre and post photos to compare.")
414
+
415
+ if pre_image_rgb is None or post_image_rgb is None:
416
+ return empty
417
+
418
+ try:
419
+ pre_bgr, _ = _prepare_image(pre_image_rgb, 512)
420
+ post_bgr, _ = _prepare_image(post_image_rgb, 512)
421
+ except Exception as exc:
422
+ logger.error("Image conversion failed: %s", exc)
423
+ return b, b, f"Image conversion failed: {exc}"
424
+
425
+ pre_face, pre_hint = _detect_face_with_hints(pre_bgr)
426
+ if pre_face is None:
427
+ return b, b, f"Pre-procedure: {pre_hint}"
428
+
429
+ post_face, post_hint = _detect_face_with_hints(post_bgr)
430
+ if post_face is None:
431
+ return b, b, f"Post-procedure: {post_hint}"
432
+
433
+ pre_overall, pre_regions = compute_symmetry_score(pre_face)
434
+ post_overall, post_regions = compute_symmetry_score(post_face)
435
+
436
+ pre_overlay = render_symmetry_overlay(pre_bgr, pre_face, pre_regions)
437
+ post_overlay = render_symmetry_overlay(post_bgr, post_face, post_regions)
438
+
439
+ pre_rgb = cv2.cvtColor(pre_overlay, cv2.COLOR_BGR2RGB)
440
+ post_rgb = cv2.cvtColor(post_overlay, cv2.COLOR_BGR2RGB)
441
+
442
+ lines = []
443
+ lines.append(_format_symmetry_text(pre_overall, pre_regions, prefix="-- Pre-procedure --"))
444
+ lines.append("")
445
+ lines.append(_format_symmetry_text(post_overall, post_regions, prefix="-- Post-procedure --"))
446
+ lines.append("")
447
+
448
+ delta = post_overall - pre_overall
449
+ direction = "improved" if delta > 0 else "decreased"
450
+ lines.append(f"Change: {delta:+.1f} ({direction})")
451
+
452
+ # Per-region deltas
453
+ for region in pre_regions:
454
+ d = post_regions.get(region, 0.0) - pre_regions[region]
455
+ lines.append(f" {region:>6s}: {d:+.1f}")
456
+
457
+ return pre_rgb, post_rgb, "\n".join(lines)
458
+
459
+
460
+ # ---------------------------------------------------------------------------
461
+ # Core pipeline functions
462
+ # ---------------------------------------------------------------------------
463
 
464
  def warp_image_tps(image, src_pts, dst_pts):
465
  """Thin-plate spline warp (CPU only)."""
 
492
 
493
 
494
  def process_image(image_rgb, procedure, intensity):
495
+ """Run the TPS pipeline on a single image, including symmetry scores."""
496
  if image_rgb is None:
497
  b = _blank()
498
  return b, b, b, b, "Upload a face photo to begin."
 
500
  t0 = time.monotonic()
501
 
502
  try:
503
+ image_bgr, image_rgb_512 = _prepare_image(image_rgb, 512)
 
 
504
  except Exception as exc:
505
  logger.error("Image conversion failed: %s", exc)
506
  b = _blank()
507
  return b, b, b, b, f"Image conversion failed: {exc}"
508
 
509
+ face, hint = _detect_face_with_hints(image_bgr)
 
 
 
 
 
 
510
  if face is None:
511
+ if hint:
512
+ return image_rgb_512, image_rgb_512, image_rgb_512, image_rgb_512, hint
513
  return (
514
  image_rgb_512, image_rgb_512, image_rgb_512, image_rgb_512,
515
  "No face detected. Try a clearer, well-lit frontal photo.",
 
532
  )
533
  elapsed = time.monotonic() - t0
534
 
535
+ # Compute symmetry for original and predicted result
536
+ pre_overall, pre_regions = compute_symmetry_score(face)
537
+ post_overall, post_regions = compute_symmetry_score(manipulated)
538
+ sym_delta = post_overall - pre_overall
539
+
540
+ info_lines = [
541
+ f"Procedure: {procedure.replace('_', ' ').title()}",
542
+ f"Intensity: {intensity:.0f}%",
543
+ f"Landmarks: {len(face.landmarks)}",
544
+ f"Avg displacement: {displacement:.1f} px",
545
+ f"Confidence: {face.confidence:.2f}",
546
+ f"Time: {elapsed:.2f}s | Mode: TPS (CPU)",
547
+ "",
548
+ f"Symmetry (before): {pre_overall:.1f}/100",
549
+ f"Symmetry (after): {post_overall:.1f}/100",
550
+ f"Symmetry change: {sym_delta:+.1f}",
551
+ ]
552
+ info = "\n".join(info_lines)
553
  return wireframe_rgb, mask_vis, composited_rgb, image_rgb_512, info
554
 
555
  except Exception as exc:
 
564
  return [_blank()] * len(PROCEDURES)
565
 
566
  try:
567
+ image_bgr, _ = _prepare_image(image_rgb, 512)
568
+ face, _ = _detect_face_with_hints(image_bgr)
 
569
  if face is None:
570
  rgb = cv2.cvtColor(image_bgr, cv2.COLOR_BGR2RGB)
571
  return [rgb] * len(PROCEDURES)
 
589
  return []
590
 
591
  try:
592
+ image_bgr, _ = _prepare_image(image_rgb, 512)
593
+ face, _ = _detect_face_with_hints(image_bgr)
 
594
  if face is None:
595
  return []
596
 
 
646
  info="0 = no change, 100 = maximum",
647
  )
648
  run_btn = gr.Button("Generate", variant="primary", size="lg")
649
+ info_box = gr.Textbox(label="Info", lines=10, interactive=False)
650
 
651
  with gr.Column(scale=2):
652
  with gr.Row():
 
664
  )
665
 
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
+ for trigger in _inputs:
670
+ trigger.change(fn=process_image, inputs=_inputs, outputs=outputs)
671
 
672
  # -- Tab 2: Compare Procedures --
673
  with gr.Tab("Compare All"):
 
716
  inputs=[sweep_image], label="Examples",
717
  )
718
 
719
+ sweep_btn.click(
720
+ fn=intensity_sweep,
721
+ inputs=[sweep_image, sweep_proc],
722
+ outputs=[sweep_gallery],
723
+ )
724
+
725
+ # -- Tab 4: Symmetry Analysis --
726
+ with gr.Tab("Symmetry Analysis"):
727
+ gr.Markdown(
728
+ "### Bilateral Facial Symmetry\n\n"
729
+ "Analyzes left-right symmetry across five facial regions "
730
+ "(eyes, brows, cheeks, mouth, jaw) using MediaPipe 478-point "
731
+ "face mesh landmark pairs. The midline is computed from the "
732
+ "forehead apex to the chin, and left landmarks are reflected "
733
+ "across it to measure deviation from the right side.\n\n"
734
+ "**Score interpretation:** 90-100 = highly symmetric, "
735
+ "70-89 = mild asymmetry, <70 = notable asymmetry."
736
+ )
737
+
738
+ with gr.Tabs():
739
+ # Sub-tab: Single image analysis
740
+ with gr.Tab("Single Photo"):
741
+ with gr.Row():
742
+ with gr.Column(scale=1):
743
+ sym_image = gr.Image(
744
+ label="Face Photo", type="numpy", height=350,
745
+ )
746
+ sym_btn = gr.Button(
747
+ "Analyze Symmetry", variant="primary", size="lg",
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", lines=8, interactive=False,
754
+ )
755
+
756
+ if EXAMPLE_IMAGES:
757
+ gr.Examples(
758
+ examples=[[str(p)] for p in EXAMPLE_IMAGES],
759
+ inputs=[sym_image], label="Examples",
760
+ )
761
+
762
+ sym_btn.click(
763
+ fn=analyze_symmetry,
764
+ inputs=[sym_image],
765
+ outputs=[sym_overlay, sym_scores_box],
766
+ )
767
+
768
+ # Sub-tab: Pre vs post comparison
769
+ with gr.Tab("Pre vs Post Comparison"):
770
+ gr.Markdown(
771
+ "Upload a pre-procedure and post-procedure photo to compare "
772
+ "how symmetry changed."
773
+ )
774
+ with gr.Row():
775
+ sym_pre_image = gr.Image(
776
+ label="Pre-Procedure", type="numpy", height=300,
777
+ )
778
+ sym_post_image = gr.Image(
779
+ label="Post-Procedure", type="numpy", height=300,
780
+ )
781
+ sym_cmp_btn = gr.Button(
782
+ "Compare Symmetry", variant="primary", size="lg",
783
+ )
784
+ with gr.Row():
785
+ sym_pre_overlay = gr.Image(
786
+ label="Pre Symmetry Overlay", height=300,
787
+ )
788
+ sym_post_overlay = gr.Image(
789
+ label="Post Symmetry Overlay", height=300,
790
+ )
791
+ sym_cmp_box = gr.Textbox(
792
+ label="Comparison", lines=14, interactive=False,
793
+ )
794
+
795
+ sym_cmp_btn.click(
796
+ fn=analyze_symmetry_comparison,
797
+ inputs=[sym_pre_image, sym_post_image],
798
+ outputs=[sym_pre_overlay, sym_post_overlay, sym_cmp_box],
799
+ )
800
 
801
  gr.Markdown(
802
  f"<div style='text-align:center;color:#999;font-size:0.8em;padding:8px'>"
803
+ f"LandmarkDiff v0.2.3 | TPS on CPU | MediaPipe 478-point mesh | "
804
  f"<a href='{GITHUB_URL}'>GitHub</a> | MIT License</div>"
805
  )
806