mshassanali11 commited on
Commit
892179e
Β·
verified Β·
1 Parent(s): d5f3205

Upload 3 files

Browse files
Files changed (3) hide show
  1. app.py +363 -763
  2. packages.txt +6 -0
  3. requirements .txt +1 -1
app.py CHANGED
@@ -1,13 +1,16 @@
1
  """
2
- SmileAI Pro v4 β€” Refined Dental Smile Simulation
3
- ==================================================
4
- Improvements over v3:
5
- 1. MediaPipe Face Mesh β†’ pixel-perfect lip/teeth landmark mask
6
- 2. Mouth-crop inpainting β†’ full 512px resolution on just the mouth
7
- 3. Poisson seamless clone β†’ zero-seam skin blending
8
- 4. Multi-seed best-pick β†’ auto-selects sharpest result
9
- 5. Skin-tone adaptive prompt injection β†’ matches patient complexion
10
- 6. Post-blend micro-sharpening β†’ crisp tooth detail
 
 
 
11
  """
12
 
13
  import gradio as gr
@@ -18,12 +21,8 @@ import io
18
  import os
19
  from datetime import datetime
20
 
21
- # ──────────────────────────────────────────────────────────────────────────────
22
- # GLOBALS
23
- # ──────────────────────────────────────────────────────────────────────────────
24
-
25
- _pipe = None # SD inpainting singleton
26
- _mp_fm = None # MediaPipe FaceMesh singleton
27
 
28
 
29
  def get_pipe():
@@ -31,23 +30,21 @@ def get_pipe():
31
  if _pipe is not None:
32
  return _pipe
33
  import torch
34
- from diffusers import StableDiffusionInpaintPipeline
35
  device = "cuda" if torch.cuda.is_available() else "cpu"
36
  dtype = torch.float16 if device == "cuda" else torch.float32
37
  print(f"[SmileAI] Loading pipeline on {device}…")
38
  _pipe = StableDiffusionInpaintPipeline.from_pretrained(
39
  "stable-diffusion-v1-5/stable-diffusion-inpainting",
40
- torch_dtype=dtype,
41
- safety_checker=None,
42
- requires_safety_checker=False,
43
  ).to(device)
44
- if device == "cpu":
45
- _pipe.enable_attention_slicing()
46
- else:
47
- try:
48
- _pipe.enable_xformers_memory_efficient_attention()
49
- except Exception:
50
- pass
51
  print(f"[SmileAI] Pipeline ready on {device}.")
52
  return _pipe
53
 
@@ -58,847 +55,450 @@ def get_face_mesh():
58
  return _mp_fm
59
  import mediapipe as mp
60
  _mp_fm = mp.solutions.face_mesh.FaceMesh(
61
- static_image_mode=True,
62
- max_num_faces=1,
63
- refine_landmarks=True,
64
- min_detection_confidence=0.5,
65
- )
66
  return _mp_fm
67
 
68
 
69
- # ──────────────────────────────────────────────────────────────────────────────
70
- # TREATMENT STYLES
71
- # ──────────────────────────────────────────────────────────────────────────────
72
-
73
  STYLES = {
74
  "Full Smile Reconstruction": {
75
- "prompt": (
76
- "perfect complete smile, natural white straight teeth, healthy pink gums, "
77
- "no gaps, no missing teeth, seamless lip integration, natural tooth shape, "
78
- "ultra-photorealistic dental portrait, soft diffused studio lighting, "
79
- "sharp teeth detail, 8k, canon 5d"
80
- ),
81
- "negative": (
82
- "missing teeth, dark gaps, broken, yellow, stained, decayed, "
83
- "cartoon, painting, blurry, distorted, extra teeth, plastic fake look, "
84
- "watermark, over-processed, saturated"
85
- ),
86
- "strength": 0.97,
87
- "steps": 45,
88
- "guidance": 9.5,
89
- "seeds": [42, 123, 7],
90
  },
91
  "Hollywood Smile": {
92
- "prompt": (
93
- "Hollywood celebrity smile, ultra-white bright porcelain teeth, "
94
- "broad symmetrical arch, no gaps, full lip support, "
95
- "cosmetic dentistry perfection, professional headshot lighting, "
96
- "photorealistic, 8k, sharp"
97
- ),
98
- "negative": (
99
- "yellow, stained, gap, missing, broken, crooked, blurry, "
100
- "unnatural plastic, cartoon, dark shadows"
101
- ),
102
- "strength": 0.96,
103
- "steps": 42,
104
- "guidance": 9.5,
105
- "seeds": [42, 99],
106
  },
107
  "Natural White": {
108
- "prompt": (
109
- "natural healthy slightly white teeth, clean aligned smile, no gaps, "
110
- "healthy gums, warm tone match, realistic cosmetic dentistry, "
111
- "soft natural window lighting, photorealistic portrait"
112
- ),
113
- "negative": (
114
- "missing, gap, yellow, heavy stain, dark shadow, cartoon, blurry, "
115
- "over-whitened, fake, plastic"
116
- ),
117
- "strength": 0.93,
118
- "steps": 38,
119
- "guidance": 8.5,
120
- "seeds": [42],
121
  },
122
  "Porcelain Veneers": {
123
- "prompt": (
124
- "porcelain dental veneers result, smooth uniform white teeth, "
125
- "slightly lengthened symmetrical smile, translucent enamel surface, "
126
- "perfect contact points, professional cosmetic dentistry, 8k photorealistic"
127
- ),
128
- "negative": (
129
- "gap, missing, stained, yellow, chip, crowded, dark, blurry, fake, overly shiny"
130
- ),
131
- "strength": 0.95,
132
- "steps": 40,
133
- "guidance": 9.0,
134
- "seeds": [42, 77],
135
  },
136
  "Gap Closure (Diastema Fix)": {
137
- "prompt": (
138
- "closed front teeth gap, seamless contact between central incisors, "
139
- "white straight teeth, natural aligned smile, no diastema, "
140
- "dental bonding result, photorealistic portrait, sharp detail"
141
- ),
142
  "negative": "gap between teeth, dark space, separated teeth, missing",
143
- "strength": 0.93,
144
- "steps": 38,
145
- "guidance": 8.5,
146
- "seeds": [42],
147
  },
148
  "Crowding / Alignment Fix": {
149
- "prompt": (
150
- "perfectly aligned straight teeth, orthodontic after result, even arch, "
151
- "no overlapping, clean white teeth, healthy gums, post-braces smile, "
152
- "professional dental photo, photorealistic, sharp"
153
- ),
154
  "negative": "crowded, overlapping, rotated, crooked, misaligned, gaps, blurry",
155
- "strength": 0.95,
156
- "steps": 40,
157
- "guidance": 9.0,
158
- "seeds": [42, 55],
159
  },
160
  "Subtle Refresh": {
161
- "prompt": (
162
- "slightly whiter cleaner teeth, minimal stain removal, "
163
- "healthy natural smile, conservative result, photorealistic dental portrait"
164
- ),
165
  "negative": "heavy whitening, fake, plastic, missing, gaps",
166
- "strength": 0.78,
167
- "steps": 30,
168
- "guidance": 7.5,
169
- "seeds": [42],
170
  },
171
  }
172
 
 
173
 
174
- # ──────────────────────────────────────────────────────────────────────────────
175
- # MASK GENERATION β€” MediaPipe landmarks + fallback color method
176
- # ──────────────────────────────────────────────────────────────────────────────
177
-
178
- # MediaPipe lip landmark indices (outer + inner lips + teeth zone)
179
- _OUTER_LIPS = [61,185,40,39,37,0,267,269,270,409,291,375,321,405,314,17,84,181,91,146]
180
- _INNER_LIPS = [78,191,80,81,82,13,312,311,310,415,308,324,318,402,317,14,87,178,88,95]
181
- _TEETH_ZONE = [13,312,311,310,415,308,324,318,402,317,14,87,178,88,95,78,191,80,81,82]
182
 
183
-
184
- def make_mouth_mask_mediapipe(img_array: np.ndarray, padding: int = 20):
185
- """
186
- Use MediaPipe FaceMesh to create a precise mouth mask covering:
187
- - Full lip region (outer hull)
188
- - Expanded downward to include lower gums
189
- Returns (mask_np, bbox) where bbox = (x1,y1,x2,y2) of the mouth crop.
190
- """
191
  h, w = img_array.shape[:2]
192
  mask = np.zeros((h, w), dtype=np.uint8)
193
-
194
  try:
195
  fm = get_face_mesh()
196
  results = fm.process(cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR))
197
-
198
  if results.multi_face_landmarks:
199
- lm = results.multi_face_landmarks[0].landmark
200
-
201
- # Full lip hull (outer lips)
202
- outer_pts = np.array(
203
- [[int(lm[i].x * w), int(lm[i].y * h)] for i in _OUTER_LIPS],
204
- dtype=np.int32
205
- )
206
-
207
- # Compute bounding box of mouth
208
- mx1 = max(0, outer_pts[:, 0].min() - padding * 2)
209
- my1 = max(0, outer_pts[:, 1].min() - padding)
210
- mx2 = min(w, outer_pts[:, 0].max() + padding * 2)
211
- my2 = min(h, outer_pts[:, 1].max() + int(padding * 1.5))
212
-
213
- # Draw filled convex hull
214
- hull = cv2.convexHull(outer_pts)
215
- cv2.fillPoly(mask, [hull], 255)
216
-
217
- # Dilate to include gum margins
218
  k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (padding, padding))
219
  mask = cv2.dilate(mask, k, iterations=1)
220
- mask = cv2.GaussianBlur(mask, (11, 11), 0)
221
- _, mask = cv2.threshold(mask, 80, 255, cv2.THRESH_BINARY)
222
-
223
  return mask, (mx1, my1, mx2, my2), True
224
-
225
  except Exception as e:
226
- print(f"[SmileAI] MediaPipe failed: {e}, falling back to color method")
 
 
227
 
228
- # ── Fallback: color-based detection ──────────────────────────────────────
229
- mask, bbox = make_mouth_mask_color(img_array, padding)
230
- return mask, bbox, False
231
 
232
-
233
- def make_mouth_mask_color(img_array: np.ndarray, padding: int = 22):
234
- """Color-based mouth mask fallback."""
235
  h, w = img_array.shape[:2]
236
- hsv = cv2.cvtColor(img_array, cv2.COLOR_RGB2HSV)
237
- ycr = cv2.cvtColor(img_array, cv2.COLOR_RGB2YCrCb)
238
-
239
- teeth_mask = cv2.inRange(hsv, np.array([0, 0, 140]), np.array([40, 90, 255]))
240
- lip_mask = cv2.inRange(ycr, np.array([60, 140, 110]), np.array([200, 175, 145]))
241
- dark_mask = cv2.inRange(hsv, np.array([0, 0, 0]), np.array([180, 255, 80]))
242
-
243
- combined = cv2.bitwise_or(teeth_mask, lip_mask)
244
- combined = cv2.bitwise_or(combined, dark_mask)
245
-
246
  region = np.zeros_like(combined)
247
- y1, y2 = int(h * 0.45), int(h * 0.82)
248
- x1, x2 = int(w * 0.12), int(w * 0.88)
249
- region[y1:y2, x1:x2] = 255
250
  combined = cv2.bitwise_and(combined, region)
251
-
252
- k_close = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15))
253
- k_open = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (7, 7))
254
- combined = cv2.morphologyEx(combined, cv2.MORPH_CLOSE, k_close)
255
- combined = cv2.morphologyEx(combined, cv2.MORPH_OPEN, k_open)
256
-
257
- k_dilate = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (padding, padding))
258
- combined = cv2.dilate(combined, k_dilate, iterations=1)
259
-
260
- num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(combined, connectivity=8)
261
- if num_labels > 1:
262
- largest = 1 + np.argmax(stats[1:, cv2.CC_STAT_AREA])
263
- combined = np.where(labels == largest, 255, 0).astype(np.uint8)
264
-
265
- combined = cv2.GaussianBlur(combined, (21, 21), 0)
266
- _, combined = cv2.threshold(combined, 60, 255, cv2.THRESH_BINARY)
267
-
268
- # Estimate bbox from mask
269
- ys, xs = np.where(combined > 0)
270
- if len(xs) > 0:
271
- bx1 = max(0, xs.min() - padding)
272
- by1 = max(0, ys.min() - padding)
273
- bx2 = min(w, xs.max() + padding)
274
- by2 = min(h, ys.max() + padding)
275
  else:
276
- bx1, by1, bx2, by2 = int(w*0.2), int(h*0.55), int(w*0.8), int(h*0.82)
 
277
 
278
- return combined, (bx1, by1, bx2, by2)
279
 
 
280
 
281
- def mask_coverage(mask: np.ndarray) -> float:
282
- return mask.sum() / (255 * mask.size)
283
 
 
 
 
 
 
 
 
 
 
 
 
 
 
284
 
285
- # ──────────────────────────────────────────────────────────────────────────────
286
- # SKIN-TONE ADAPTIVE PROMPT INJECTION
287
- # ──────────────────────────────────────────────────────────────────────────────
288
 
289
- def detect_skin_tone(img_array: np.ndarray, bbox) -> str:
290
- """Sample skin pixels near the mouth, return tone descriptor for prompt."""
291
- x1, y1, x2, y2 = bbox
292
  h, w = img_array.shape[:2]
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
293
 
294
- # Sample cheek region (right of mouth area)
295
- sx1 = min(w - 1, x2 + 10)
296
- sx2 = min(w - 1, x2 + 60)
297
- sy1 = max(0, y1)
298
- sy2 = min(h - 1, y2)
 
 
 
 
299
 
300
- if sx1 >= sx2 or sy1 >= sy2:
301
- return ""
302
 
303
- patch = img_array[sy1:sy2, sx1:sx2]
304
- if patch.size == 0:
305
- return ""
306
-
307
- mean_rgb = patch.reshape(-1, 3).mean(axis=0)
308
- r, g, b = mean_rgb
309
-
310
- # ITA (Individual Typology Angle) approximation
311
- L = 0.2126 * r + 0.7152 * g + 0.0722 * b
312
- if L > 200:
313
- return "fair skin tone, light complexion"
314
- elif L > 155:
315
- return "medium skin tone"
316
- elif L > 100:
317
- return "medium-dark olive skin tone"
318
- else:
319
- return "dark brown skin tone"
320
-
321
-
322
- # ──────────────────────────────────────────────────────────────────────────────
323
- # AI INPAINTING β€” MOUTH-CROP STRATEGY
324
- # ──────────────────────────────────────────────────────────────────────────────
325
-
326
- def sharpness_score(img: Image.Image) -> float:
327
- """Laplacian variance β€” higher = sharper."""
328
- gray = np.array(img.convert("L"))
329
- return cv2.Laplacian(gray, cv2.CV_64F).var()
330
-
331
-
332
- def run_inpainting(
333
- original_pil: Image.Image,
334
- mask_pil: Image.Image,
335
- bbox: tuple,
336
- style_cfg: dict,
337
- skin_tone: str = "",
338
- progress_cb=None,
339
- ) -> Image.Image:
340
- """
341
- Improved inpainting strategy:
342
- 1. Crop mouth region from original + mask
343
- 2. Upscale crop to 512Γ—512 (full SD resolution on just the mouth)
344
- 3. Run inpainting with multiple seeds, pick sharpest
345
- 4. Downscale result back, Poisson-blend onto original face
346
- """
347
- import torch
348
 
 
 
349
  pipe = get_pipe()
350
- device = next(pipe.unet.parameters()).device
351
-
352
- orig_arr = np.array(original_pil.convert("RGB"))
353
- mask_arr = np.array(mask_pil.convert("L"))
354
  orig_w, orig_h = original_pil.size
355
-
356
- x1, y1, x2, y2 = bbox
357
- # Add generous padding so SD has face context around mouth
358
- PAD = max(80, int((y2 - y1) * 0.9))
359
- cx1 = max(0, x1 - PAD)
360
- cy1 = max(0, y1 - PAD)
361
- cx2 = min(orig_w, x2 + PAD)
362
- cy2 = min(orig_h, y2 + PAD)
363
-
364
- # Crop face patch centered on mouth
365
- crop_img = original_pil.crop((cx1, cy1, cx2, cy2)).convert("RGB")
366
- crop_mask = mask_pil.crop((cx1, cy1, cx2, cy2)).convert("L")
367
-
368
- # Ensure mask has content β€” dilate slightly inside crop
369
  cm_arr = np.array(crop_mask)
370
- if cm_arr.max() < 128:
371
- # Create a centered ellipse fallback
372
- ch, cw = cm_arr.shape
373
- center = (cw // 2, ch // 2)
374
- axes = (cw // 4, ch // 5)
375
- cv2.ellipse(cm_arr, center, axes, 0, 0, 360, 255, -1)
376
  crop_mask = Image.fromarray(cm_arr)
377
-
378
- # Upscale crop to 512Γ—512 for full SD resolution
379
- TARGET = 512
380
- cw_orig, ch_orig = crop_img.size
381
- crop_512 = crop_img.resize((TARGET, TARGET), Image.LANCZOS)
382
- cmask_512 = crop_mask.resize((TARGET, TARGET), Image.NEAREST)
383
- cmask_rgb = Image.fromarray(np.stack([np.array(cmask_512)] * 3, axis=-1))
384
-
385
- # Build skin-aware prompt
386
- tone_hint = f", {skin_tone}" if skin_tone else ""
387
- prompt = style_cfg["prompt"].replace(
388
- "photorealistic", f"{skin_tone} skin, photorealistic" if skin_tone else "photorealistic"
389
- )
390
-
391
- if progress_cb:
392
- progress_cb(0.40, f"πŸ€– Inpainting mouth crop ({style_cfg['steps']} steps)…")
393
-
394
- seeds = style_cfg.get("seeds", [42])
395
- results = []
396
- for seed in seeds:
397
- gen = torch.Generator(device=str(device)).manual_seed(seed)
398
- out = pipe(
399
- prompt = prompt,
400
- negative_prompt = style_cfg["negative"],
401
- image = crop_512,
402
- mask_image = cmask_rgb,
403
- strength = style_cfg["strength"],
404
- guidance_scale = style_cfg["guidance"],
405
- num_inference_steps = style_cfg["steps"],
406
- generator = gen,
407
- ).images[0]
408
- results.append(out)
409
-
410
- if progress_cb:
411
- progress_cb(0.72, "πŸ” Selecting best result…")
412
-
413
- # Pick sharpest result (most tooth detail)
414
- best = max(results, key=sharpness_score)
415
-
416
- if progress_cb:
417
- progress_cb(0.78, "πŸ–Ό Compositing…")
418
-
419
- # Downscale result back to crop size
420
- best_crop = best.resize((cw_orig, ch_orig), Image.LANCZOS)
421
-
422
- # ── COMPOSITING: Poisson seamless clone for zero-seam blending ────────────
423
- result_arr = np.array(original_pil.convert("RGB")).copy()
424
- best_arr = np.array(best_crop.convert("RGB"))
425
-
426
- # Crop-region mask at original resolution
427
- local_mask = np.array(mask_pil.crop((cx1, cy1, cx2, cy2)).convert("L"))
428
-
429
- # Dilate mask slightly for Poisson (needs solid center)
430
- k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (9, 9))
431
- local_mask_dilated = cv2.dilate(local_mask, k, iterations=2)
432
-
433
- # Resize mask to crop size
434
- local_mask_dilated = cv2.resize(local_mask_dilated, (cw_orig, ch_orig), interpolation=cv2.INTER_NEAREST)
435
-
436
- # Create 3-channel mask for Poisson
437
- poi_mask = np.zeros((ch_orig, cw_orig), dtype=np.uint8)
438
- poi_mask[local_mask_dilated > 60] = 255
439
-
440
- # Use Poisson clone if mask is valid
441
  try:
442
- if poi_mask.max() > 0 and best_arr.shape[:2] == (ch_orig, cw_orig):
443
- # Center of the crop region in original coordinates
444
- center_x = (cx1 + cx2) // 2
445
- center_y = (cy1 + cy2) // 2
446
-
447
- # Convert RGB β†’ BGR for OpenCV
448
- src_bgr = cv2.cvtColor(best_arr, cv2.COLOR_RGB2BGR)
449
- result_bgr = cv2.cvtColor(result_arr, cv2.COLOR_RGB2BGR)
450
-
451
- blended_bgr = cv2.seamlessClone(
452
- src_bgr, result_bgr, poi_mask,
453
- (center_x, center_y),
454
- cv2.NORMAL_CLONE
455
- )
456
- result_arr = cv2.cvtColor(blended_bgr, cv2.COLOR_BGR2RGB)
457
- else:
458
- raise ValueError("mask empty")
459
- except Exception:
460
- # Fallback: feathered alpha composite
461
- feathered = cv2.GaussianBlur(local_mask_dilated.astype(np.float32), (25, 25), 0) / 255.0
462
- feathered = np.stack([feathered] * 3, axis=-1)
463
- region = result_arr[cy1:cy2, cx1:cx2].astype(np.float32)
464
- blended_region = best_arr.astype(np.float32) * feathered + region * (1 - feathered)
465
- result_arr[cy1:cy2, cx1:cx2] = np.clip(blended_region, 0, 255).astype(np.uint8)
466
-
467
- # ── MICRO-SHARPEN the composite (only the tooth region) ──────────────────
468
- result_pil = Image.fromarray(result_arr)
469
- sharp_pil = result_pil.filter(ImageFilter.UnsharpMask(radius=1.5, percent=120, threshold=2))
470
-
471
- # Blend sharpening only within mouth area
472
- full_mask_float = np.array(mask_pil.convert("L")).astype(np.float32) / 255.0
473
- full_mask_blurred = cv2.GaussianBlur(full_mask_float, (31, 31), 0)
474
- m3 = np.stack([full_mask_blurred] * 3, axis=-1)
475
-
476
- final_arr = (
477
- np.array(sharp_pil).astype(np.float32) * m3
478
- + np.array(result_pil).astype(np.float32) * (1 - m3)
479
- )
480
- return Image.fromarray(np.clip(final_arr, 0, 255).astype(np.uint8))
481
-
482
-
483
- # ──────────────────────────────────────────────────────────────────────────────
484
- # CLASSIC FALLBACK
485
- # ──────────────────────────────────────────────────────────────────────────────
486
-
487
- def run_classic(img_array: np.ndarray, mask_np: np.ndarray, style_name: str) -> np.ndarray:
488
- mf = cv2.GaussianBlur(mask_np.astype(np.float32) / 255.0, (25, 25), 0)
489
- m3 = np.stack([mf] * 3, axis=-1)
490
- res = img_array.copy().astype(np.float32)
491
-
492
- boosts = {
493
- "Full Smile Reconstruction": np.array([30, 28, 25], np.float32),
494
- "Hollywood Smile": np.array([22, 28, 38], np.float32),
495
- "Natural White": np.array([26, 23, 19], np.float32),
496
- "Porcelain Veneers": np.array([35, 33, 30], np.float32),
497
- "Gap Closure (Diastema Fix)": np.array([28, 26, 24], np.float32),
498
- "Crowding / Alignment Fix": np.array([24, 22, 20], np.float32),
499
- "Subtle Refresh": np.array([12, 10, 8], np.float32),
500
- }
501
- boost = boosts.get(style_name, np.array([20, 18, 16], np.float32))
502
-
503
- if style_name == "Porcelain Veneers":
504
- blurred = cv2.GaussianBlur(img_array, (5, 5), 0).astype(np.float32)
505
- res = res * (1 - 0.35 * m3) + blurred * (0.35 * m3)
506
-
507
- res = np.clip(res + boost * m3, 0, 255)
508
- lab = cv2.cvtColor(res.astype(np.uint8), cv2.COLOR_RGB2LAB).astype(np.float32)
509
- l, a, b = cv2.split(lab)
510
- l = np.clip(l + 45 * mf, 0, 255)
511
- b = np.clip(b - 12 * mf, 0, 255)
512
- a = np.clip(a - 5 * mf, 0, 255)
513
- return cv2.cvtColor(cv2.merge([l, a, b]).astype(np.uint8), cv2.COLOR_LAB2RGB)
514
-
515
-
516
- # ──────────────────────────────────────────────────────────────────────────────
517
- # OUTPUT HELPERS
518
- # ──────────────────────────────────────────────────────────────────────────────
519
-
520
- def add_watermark(img: Image.Image, practice: str) -> Image.Image:
521
- out = img.copy().convert("RGBA")
522
- draw = ImageDraw.Draw(out)
523
- w, h = out.size
524
- text = f"Β© {practice} | SmileAI Pro | {datetime.now().strftime('%Y')}"
525
- try:
526
- font = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf", max(11, h // 48))
527
- except Exception:
528
- font = ImageFont.load_default()
529
- bb = draw.textbbox((0, 0), text, font=font)
530
- tw, th = bb[2] - bb[0], bb[3] - bb[1]
531
- x, y = w - tw - 14, h - th - 10
532
- draw.text((x + 1, y + 1), text, fill=(0, 0, 0, 140), font=font)
533
- draw.text((x, y ), text, fill=(255, 255, 255, 200), font=font)
534
  return out.convert("RGB")
535
 
536
 
537
- def create_comparison(before: Image.Image, after: Image.Image,
538
- style: str, ai_used: bool) -> Image.Image:
539
- W = 960
540
- scale = (W // 2) / before.width
541
- new_h = int(before.height * scale)
542
- b = before.resize((W // 2, new_h), Image.LANCZOS)
543
- a = after.resize( (W // 2, new_h), Image.LANCZOS)
544
-
545
- HDR = 60
546
- canvas = Image.new("RGB", (W, new_h + HDR), (10, 10, 16))
547
- draw = ImageDraw.Draw(canvas)
548
-
549
- try:
550
- fh = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 18)
551
- except Exception:
552
- fh = ImageFont.load_default()
553
-
554
- draw.rectangle([0, 0, W, HDR], fill=(16, 16, 26))
555
- draw.text((W // 4, 22), "BEFORE",
556
- fill=(160, 160, 175), font=fh, anchor="mm")
557
- tag = "πŸ€– AI" if ai_used else "✨"
558
- draw.text((3 * W // 4, 22), f"AFTER Β· {style} {tag}",
559
- fill=(80, 220, 155), font=fh, anchor="mm")
560
- canvas.paste(b, (0, HDR))
561
- canvas.paste(a, (W // 2, HDR))
562
- draw.rectangle([W // 2 - 2, HDR, W // 2 + 2, new_h + HDR], fill=(200, 200, 200, 80))
563
  return canvas
564
 
565
 
566
- def export_pdf(before, after, comparison, patient_name, style, practice_name, ai_used):
567
  try:
568
  from reportlab.lib.pagesizes import letter
569
  from reportlab.lib import colors
570
  from reportlab.lib.units import inch
571
- from reportlab.platypus import (SimpleDocTemplate, Image as RLImage,
572
- Paragraph, Spacer, Table, TableStyle)
573
- from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle
574
- except ImportError:
575
- return None
576
-
577
- path = f"/tmp/smile_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
578
- doc = SimpleDocTemplate(path, pagesize=letter,
579
- topMargin=.5 * inch, bottomMargin=.5 * inch,
580
- leftMargin=.75 * inch, rightMargin=.75 * inch)
581
- styles = getSampleStyleSheet()
582
- def S(name, **kw):
583
- return ParagraphStyle(name, parent=styles["Normal"], **kw)
584
-
585
- mode = "AI Inpainting (SD v1.5 + MediaPipe)" if ai_used else "Classic Enhancement"
586
- story = [
587
- Paragraph(practice_name or "SmileAI Pro",
588
- S("T", fontSize=22, textColor=colors.HexColor("#0d9e6e"), spaceAfter=4)),
589
- Paragraph("Smile Design Simulation Report",
590
- S("S", fontSize=11, textColor=colors.HexColor("#555"), spaceAfter=12)),
591
- ]
592
- info = [
593
- ["Patient:", patient_name or "β€”", "Date:", datetime.now().strftime("%B %d, %Y")],
594
- ["Style:", style, "Method:", mode],
595
- ]
596
- t = Table(info, colWidths=[1.2 * inch, 2.3 * inch, 1.2 * inch, 2.3 * inch])
597
- t.setStyle(TableStyle([
598
- ("FONTNAME", (0, 0), (0, -1), "Helvetica-Bold"),
599
- ("FONTNAME", (2, 0), (2, -1), "Helvetica-Bold"),
600
- ("FONTSIZE", (0, 0), (-1, -1), 9),
601
- ("BOTTOMPADDING", (0, 0), (-1, -1), 5),
602
- ("LINEBELOW", (0, -1), (-1, -1), .5, colors.HexColor("#ddd")),
603
- ]))
604
- story += [t, Spacer(1, .2 * inch)]
605
-
606
- buf = io.BytesIO()
607
- comparison.save(buf, format="PNG")
608
- buf.seek(0)
609
- story.append(RLImage(buf, width=7 * inch,
610
- height=7 * inch * comparison.height / comparison.width))
611
- story.append(Spacer(1, .15 * inch))
612
- story.append(Paragraph(
613
- "⚠ Simulation only β€” not a medical diagnosis or treatment guarantee.",
614
- S("D", fontSize=8, textColor=colors.HexColor("#999"))
615
- ))
616
  doc.build(story)
617
  return path
618
 
619
 
620
- # ──────────────────────────────────────────────────────────────────────────────
621
- # MAIN PIPELINE
622
- # ──────────────────────────────────────────────────────────────────────────────
623
-
624
- def process_smile(
625
- input_image,
626
- smile_style: str,
627
- use_ai: bool,
628
- mask_padding: int,
629
- face_brightness: float,
630
- face_contrast: float,
631
- patient_name: str,
632
- practice_name: str,
633
- add_branding: bool,
634
- progress=gr.Progress(track_tqdm=True),
635
- ):
636
  if input_image is None:
637
  raise gr.Error("Please upload a patient photo first.")
638
-
639
- progress(0.04, "πŸ“Έ Loading image…")
640
- arr = input_image.copy() if isinstance(input_image, np.ndarray) else np.array(input_image.convert("RGB"))
641
- pil_orig = Image.fromarray(arr)
642
-
643
- # Cap size β€” keep quality high (1280 so mouth crop still has plenty of pixels)
644
- MAX = 1280
645
- if max(arr.shape[:2]) > MAX:
646
- s = MAX / max(arr.shape[:2])
647
- arr = cv2.resize(arr, (int(arr.shape[1] * s), int(arr.shape[0] * s)), interpolation=cv2.INTER_AREA)
648
- pil_orig = Image.fromarray(arr)
649
-
650
- progress(0.10, "πŸ” Detecting mouth with MediaPipe landmarks…")
651
- mask_np, bbox, mp_ok = make_mouth_mask_mediapipe(arr, padding=mask_padding)
652
- coverage = mask_coverage(mask_np)
653
- mask_pil = Image.fromarray(mask_np)
654
-
655
- # Detect skin tone for adaptive prompt
656
- skin_tone = detect_skin_tone(arr, bbox)
657
-
658
- style_cfg = STYLES[smile_style]
659
- ai_used = False
660
- ai_error = ""
661
- pil_after = None
662
-
663
- # ── AI PATH ───────────────────────────────────────────────────────────────
664
  if use_ai:
665
  try:
666
- import torch
667
  from diffusers import StableDiffusionInpaintPipeline # noqa
668
- progress(0.18, "⏳ Loading AI model (first run: ~60s download)…")
669
- pil_after = run_inpainting(
670
- pil_orig, mask_pil, bbox, style_cfg,
671
- skin_tone=skin_tone,
672
- progress_cb=lambda v, d: progress(v, d),
673
- )
674
- ai_used = True
675
- progress(0.84, "βœ… AI inpainting complete!")
676
- except ImportError:
677
- ai_error = "⚠ torch/diffusers not installed β€” falling back to classic mode."
678
- except Exception as e:
679
- ai_error = f"⚠ AI error: {str(e)[:120]} β€” falling back to classic."
680
-
681
- # ── CLASSIC FALLBACK ───────────────────────────────────���──────────────────
682
  if not ai_used:
683
- progress(0.28, "🎨 Classic enhancement…")
684
- pil_after = Image.fromarray(run_classic(arr, mask_np, smile_style))
685
-
686
- # ── FACE POLISH ───────────────────────────────────────────────────────────
687
- progress(0.88, "πŸ’… Final polish…")
688
- pil_after = ImageEnhance.Brightness(pil_after).enhance(1 + face_brightness * 0.25)
689
- pil_after = ImageEnhance.Contrast(pil_after).enhance(1 + face_contrast * 0.20)
690
- pil_after = ImageEnhance.Sharpness(pil_after).enhance(1.08)
691
-
692
- if add_branding:
693
- pil_after = add_watermark(pil_after, practice_name or "SmileAI Pro")
694
-
695
- progress(0.92, "πŸ–Ό Building comparison…")
696
- comparison = create_comparison(pil_orig, pil_after, smile_style, ai_used)
697
-
698
- progress(0.96, "πŸ“„ Generating PDF…")
699
- pdf_path = export_pdf(pil_orig, pil_after, comparison,
700
- patient_name, smile_style,
701
- practice_name or "SmileAI Pro", ai_used)
702
-
703
- mp_tag = "MediaPipe landmarks" if mp_ok else "color detection (MediaPipe unavailable)"
704
- mode_tag = "πŸ€– AI Inpainting (SD v1.5)" if ai_used else "✨ Classic"
705
- mask_warn = "\n⚠ Mouth region not clearly detected β€” try a frontal, well-lit photo." if coverage < 0.01 else ""
706
- status = (
707
- f"βœ… Done! Mode: {mode_tag}\n"
708
- f"Mask: {mp_tag} | Coverage: {coverage*100:.1f}%\n"
709
- f"Style: {smile_style} | Skin tone: {skin_tone or 'auto'}"
710
- f"{mask_warn}"
711
- f"{chr(10) + ai_error if ai_error else ''}"
712
- )
713
-
714
  progress(1.0)
715
- return pil_after, comparison, pdf_path, status
716
 
717
 
718
- # ──────────────────────────────────────────────────────────────────────────────
719
- # ENV CHECK
720
- # ──────────────────────────────────────────────────────────────────────────────
721
-
722
  def _check_env():
723
- results = {"diffusers": False, "gpu": False, "mediapipe": False}
724
  try:
725
  import torch
726
  from diffusers import StableDiffusionInpaintPipeline # noqa
727
- results["diffusers"] = True
728
- results["gpu"] = torch.cuda.is_available()
729
- except ImportError:
730
- pass
731
  try:
732
- import mediapipe # noqa
733
- results["mediapipe"] = True
734
- except ImportError:
735
- pass
736
- return results
737
-
738
- ENV = _check_env()
739
 
 
740
 
741
- # ──────────────────────────────────────────────────���───────────────────────────
742
- # GRADIO UI
743
- # ──────────────────────────────────────────────────────────────────────────────
744
-
745
- CSS = """
746
  @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500;600&display=swap');
747
- :root {
748
- --mint: #0fba7c; --mint2: #09d48f;
749
- --dark: #0b0d14; --card: #13161f; --border: #1e2232;
750
- --text: #dde2f0; --muted: #7a82a0;
751
- }
752
- body, .gradio-container {
753
- background: var(--dark) !important;
754
- color: var(--text) !important;
755
- font-family: 'DM Sans', sans-serif !important;
756
- }
757
- .hero {
758
- background: linear-gradient(135deg, #0fba7c14, #09d48f08 40%, #0b0d14);
759
- border: 1px solid #0fba7c30; border-radius: 20px;
760
- padding: 30px 40px; margin-bottom: 20px;
761
- }
762
- .hero h1 { font-family: 'DM Serif Display', serif; color: var(--mint2); font-size: 2rem; margin: 0 0 6px; }
763
- .hero p { color: var(--muted); margin: 0; font-size: .93rem; }
764
- .badge {
765
- display: inline-block; background: linear-gradient(90deg, #0fba7c, #09d48f);
766
- color: #000; font-size: .7rem; font-weight: 700; letter-spacing: .08em;
767
- padding: 3px 10px; border-radius: 20px; margin-left: 8px; vertical-align: middle;
768
- }
769
- .pill {
770
- display: inline-block; border: 1px solid #0fba7c60;
771
- color: #0fba7c; font-size: .72rem; font-weight: 600;
772
- padding: 2px 8px; border-radius: 20px; margin: 2px;
773
- }
774
- .gr-panel, .gr-box, .gr-form, .gr-block {
775
- background: var(--card) !important; border: 1px solid var(--border) !important; border-radius: 14px !important;
776
- }
777
- .gr-button-primary {
778
- background: linear-gradient(135deg, #0fba7c, #07a36c) !important;
779
- border: none !important; border-radius: 10px !important;
780
- font-weight: 600 !important; font-size: 1rem !important;
781
- padding: 13px 28px !important; color: #fff !important;
782
- box-shadow: 0 4px 20px #0fba7c30 !important; transition: all .18s !important;
783
- }
784
- .gr-button-primary:hover {
785
- background: linear-gradient(135deg, #10cc88, #0fba7c) !important;
786
- transform: translateY(-2px) !important; box-shadow: 0 8px 28px #0fba7c50 !important;
787
- }
788
- label { color: var(--muted) !important; font-size: .82rem !important; }
789
- .ai-box { background: #0fba7c10 !important; border: 1px solid #0fba7c35 !important; }
790
  """
791
 
792
- # Build status pills
793
- pills = []
794
- if ENV["mediapipe"]:
795
- pills.append('<span class="pill">βœ… MediaPipe Landmarks</span>')
796
- else:
797
- pills.append('<span class="pill" style="border-color:#f0704060;color:#f07040">⚠ MediaPipe not installed</span>')
798
- if ENV["gpu"]:
799
- pills.append('<span class="pill">βœ… GPU Ready (~25s)</span>')
800
- elif ENV["diffusers"]:
801
- pills.append('<span class="pill" style="border-color:#f0c04060;color:#f0c040">⚑ CPU (~3min)</span>')
802
- else:
803
- pills.append('<span class="pill" style="border-color:#f0704060;color:#f07040">⚠ Classic mode only</span>')
804
-
805
- pills_html = " ".join(pills)
806
-
807
- with gr.Blocks(css=CSS, title="SmileAI Pro v4") as demo:
808
 
 
809
  gr.HTML(f"""
810
  <div class="hero">
811
- <h1>🦷 SmileAI Pro <span class="badge">v4 · REFINED</span></h1>
812
- <p>MediaPipe landmark masking Β· Mouth-crop inpainting Β· Poisson seamless blending Β· Skin-tone adaptive prompts</p>
813
- <p style="margin-top:10px">{pills_html}</p>
814
- </div>
815
- """)
816
-
817
  with gr.Row(equal_height=False):
818
-
819
- with gr.Column(scale=1, min_width=300):
820
-
821
  gr.Markdown("### πŸ“€ Patient Photo")
822
- input_img = gr.Image(
823
- label="Upload frontal smiling photo (min 800Γ—600, good lighting)",
824
- type="numpy", height=280,
825
- )
826
-
827
  gr.Markdown("### 🎨 Treatment Style")
828
- style_dd = gr.Dropdown(
829
- list(STYLES.keys()),
830
- value="Full Smile Reconstruction",
831
- label="Select treatment",
832
- )
833
-
834
  with gr.Group(elem_classes="ai-box"):
835
  gr.Markdown("**πŸ€– AI Settings**")
836
- use_ai_cb = gr.Checkbox(
837
- label="Enable AI Inpainting (Stable Diffusion)",
838
- value=ENV["diffusers"],
839
- info="Uses mouth-crop strategy + Poisson blending for best results."
840
- )
841
- mask_pad = gr.Slider(
842
- 8, 40, value=18, step=2,
843
- label="Landmark Mask Padding (px)",
844
- )
845
-
846
  gr.Markdown("### βš™οΈ Final Polish")
847
- brightness = gr.Slider(-0.5, 0.5, value=0.06, step=0.02, label="Face Brightness")
848
- contrast = gr.Slider(-0.5, 0.5, value=0.08, step=0.02, label="Contrast")
849
-
850
  gr.Markdown("### πŸ₯ Practice Info")
851
- patient_name = gr.Textbox(label="Patient Name", placeholder="Jane Doe")
852
- practice_name = gr.Textbox(label="Practice Name", value="SmileAI Pro")
853
- branding_cb = gr.Checkbox(label="Add watermark", value=True)
854
-
855
- run_btn = gr.Button("✨ Generate Smile Simulation", variant="primary")
856
-
857
  with gr.Column(scale=2):
858
- status_box = gr.Textbox(label="Status", lines=4, interactive=False)
859
-
860
  with gr.Tabs():
861
- with gr.TabItem("🦷 After"):
862
- after_out = gr.Image(label="Simulated Result", type="pil", height=450)
863
- with gr.TabItem("↔ Before / After"):
864
- compare_out = gr.Image(label="Side-by-Side", type="pil", height=450)
865
- with gr.TabItem("πŸ“„ PDF"):
866
- pdf_out = gr.File(label="Download PDF Report")
867
-
868
- with gr.Accordion("πŸ“Έ Tips for Best Results", open=False):
869
  gr.Markdown("""
870
- **For the most realistic results:**
871
- - Use a **straight-on frontal photo** β€” slight smiling or open mouth
872
- - **Good even lighting** β€” avoid harsh shadows under the nose
873
- - Minimum **1000Γ—800px** β€” higher res = sharper tooth detail
874
- - For missing teeth: ensure the dark gap is **clearly visible**, not hidden by lips
875
- - If mask misses teeth, **increase Mask Padding** slider
876
-
877
- **What's new in v4:**
878
- - 🎯 **MediaPipe landmarks** β€” pixel-precise lip boundary mask
879
- - πŸ”¬ **Mouth-crop inpainting** β€” SD runs at full 512px on just the mouth (3Γ— more detail)
880
- - 🎨 **Poisson blending** β€” zero-seam edge integration
881
- - 🌍 **Skin-tone detection** β€” prompt adapts to patient complexion
882
- - πŸ† **Multi-seed best-pick** β€” generates 2-3 variants, selects sharpest
883
-
884
- **requirements.txt (GPU Space on HuggingFace):**
885
- ```
886
- gradio>=4.0.0
887
- numpy pillow opencv-python-headless reportlab mediapipe
888
- torch torchvision --index-url https://download.pytorch.org/whl/cu118
889
- diffusers>=0.27.0 transformers accelerate xformers
890
- ```
 
 
 
 
891
  """)
892
 
893
  run_btn.click(
894
  fn=process_smile,
895
- inputs=[
896
- input_img, style_dd, use_ai_cb, mask_pad,
897
- brightness, contrast,
898
- patient_name, practice_name, branding_cb,
899
- ],
900
- outputs=[after_out, compare_out, pdf_out, status_box],
901
- )
902
-
903
- if __name__ == "__main__":
904
- demo.launch(share=False, server_name="0.0.0.0", server_port=7860)
 
1
  """
2
+ SmileAI Pro v4 β€” CPU-Optimised Dental Smile Simulation
3
+ =======================================================
4
+ Optimised for CPU-only HuggingFace Spaces:
5
+ - DPMSolverMultistepScheduler β†’ cuts inference from ~3min to ~60s on CPU
6
+ - Smaller 384px crop target on CPU for speed
7
+ - Single seed on CPU (no multi-seed overhead)
8
+ - MASSIVELY improved Classic mode (no SD needed) β€” instant results
9
+ * Realistic tooth texture synthesis
10
+ * LAB-space whitening with gum preservation
11
+ * Edge-aware Poisson composite
12
+ - MediaPipe landmark mask (precise lip boundary)
13
+ - Skin-tone adaptive prompts
14
  """
15
 
16
  import gradio as gr
 
21
  import os
22
  from datetime import datetime
23
 
24
+ _pipe = None
25
+ _mp_fm = None
 
 
 
 
26
 
27
 
28
  def get_pipe():
 
30
  if _pipe is not None:
31
  return _pipe
32
  import torch
33
+ from diffusers import StableDiffusionInpaintPipeline, DPMSolverMultistepScheduler
34
  device = "cuda" if torch.cuda.is_available() else "cpu"
35
  dtype = torch.float16 if device == "cuda" else torch.float32
36
  print(f"[SmileAI] Loading pipeline on {device}…")
37
  _pipe = StableDiffusionInpaintPipeline.from_pretrained(
38
  "stable-diffusion-v1-5/stable-diffusion-inpainting",
39
+ torch_dtype=dtype, safety_checker=None, requires_safety_checker=False,
 
 
40
  ).to(device)
41
+ # DPM++ scheduler β€” same quality in ~20 steps vs 40 with old DDPM
42
+ _pipe.scheduler = DPMSolverMultistepScheduler.from_config(
43
+ _pipe.scheduler.config, algorithm_type="dpmsolver++", use_karras_sigmas=True)
44
+ _pipe.enable_attention_slicing()
45
+ if device != "cpu":
46
+ try: _pipe.enable_xformers_memory_efficient_attention()
47
+ except: pass
48
  print(f"[SmileAI] Pipeline ready on {device}.")
49
  return _pipe
50
 
 
55
  return _mp_fm
56
  import mediapipe as mp
57
  _mp_fm = mp.solutions.face_mesh.FaceMesh(
58
+ static_image_mode=True, max_num_faces=1,
59
+ refine_landmarks=True, min_detection_confidence=0.5)
 
 
 
60
  return _mp_fm
61
 
62
 
 
 
 
 
63
  STYLES = {
64
  "Full Smile Reconstruction": {
65
+ "prompt": ("perfect complete smile, natural white straight teeth, healthy pink gums, "
66
+ "no gaps, no missing teeth, natural tooth shape, ultra-photorealistic dental portrait, "
67
+ "soft studio lighting, 8k, sharp"),
68
+ "negative": ("missing teeth, dark gaps, broken, yellow, stained, decayed, "
69
+ "cartoon, painting, blurry, distorted, plastic fake, watermark"),
70
+ "strength": 0.97, "steps_gpu": 25, "steps_cpu": 20, "guidance": 9.5,
 
 
 
 
 
 
 
 
 
71
  },
72
  "Hollywood Smile": {
73
+ "prompt": ("Hollywood celebrity smile, ultra-white porcelain teeth, broad symmetrical arch, "
74
+ "no gaps, professional headshot lighting, photorealistic, 8k, sharp"),
75
+ "negative": "yellow, stained, gap, missing, broken, crooked, blurry, cartoon, dark shadows",
76
+ "strength": 0.96, "steps_gpu": 25, "steps_cpu": 20, "guidance": 9.5,
 
 
 
 
 
 
 
 
 
 
77
  },
78
  "Natural White": {
79
+ "prompt": ("natural healthy white teeth, clean aligned smile, no gaps, healthy gums, "
80
+ "warm tone, realistic cosmetic dentistry, soft natural lighting, photorealistic"),
81
+ "negative": "missing, gap, yellow, heavy stain, dark shadow, cartoon, blurry, over-whitened",
82
+ "strength": 0.90, "steps_gpu": 22, "steps_cpu": 18, "guidance": 8.5,
 
 
 
 
 
 
 
 
 
83
  },
84
  "Porcelain Veneers": {
85
+ "prompt": ("porcelain dental veneers, smooth uniform white teeth, slightly lengthened "
86
+ "symmetrical smile, translucent enamel, perfect cosmetic dentistry, 8k photorealistic"),
87
+ "negative": "gap, missing, stained, yellow, chip, crowded, blurry, fake, overly shiny",
88
+ "strength": 0.94, "steps_gpu": 25, "steps_cpu": 20, "guidance": 9.0,
 
 
 
 
 
 
 
 
89
  },
90
  "Gap Closure (Diastema Fix)": {
91
+ "prompt": ("closed front teeth gap, seamless contact between central incisors, "
92
+ "white straight teeth, no diastema, dental bonding result, photorealistic"),
 
 
 
93
  "negative": "gap between teeth, dark space, separated teeth, missing",
94
+ "strength": 0.90, "steps_gpu": 22, "steps_cpu": 18, "guidance": 8.5,
 
 
 
95
  },
96
  "Crowding / Alignment Fix": {
97
+ "prompt": ("perfectly aligned straight teeth, orthodontic result, even arch, "
98
+ "no overlapping, clean white teeth, post-braces smile, photorealistic"),
 
 
 
99
  "negative": "crowded, overlapping, rotated, crooked, misaligned, gaps, blurry",
100
+ "strength": 0.93, "steps_gpu": 25, "steps_cpu": 20, "guidance": 9.0,
 
 
 
101
  },
102
  "Subtle Refresh": {
103
+ "prompt": ("slightly whiter cleaner teeth, minimal stain removal, "
104
+ "healthy natural smile, conservative result, photorealistic dental portrait"),
 
 
105
  "negative": "heavy whitening, fake, plastic, missing, gaps",
106
+ "strength": 0.75, "steps_gpu": 18, "steps_cpu": 15, "guidance": 7.5,
 
 
 
107
  },
108
  }
109
 
110
+ _OUTER_LIPS = [61,185,40,39,37,0,267,269,270,409,291,375,321,405,314,17,84,181,91,146]
111
 
 
 
 
 
 
 
 
 
112
 
113
+ def make_mouth_mask_mediapipe(img_array, padding=20):
 
 
 
 
 
 
 
114
  h, w = img_array.shape[:2]
115
  mask = np.zeros((h, w), dtype=np.uint8)
 
116
  try:
117
  fm = get_face_mesh()
118
  results = fm.process(cv2.cvtColor(img_array, cv2.COLOR_RGB2BGR))
 
119
  if results.multi_face_landmarks:
120
+ lm = results.multi_face_landmarks[0].landmark
121
+ pts = np.array([[int(lm[i].x*w), int(lm[i].y*h)] for i in _OUTER_LIPS], dtype=np.int32)
122
+ cv2.fillPoly(mask, [cv2.convexHull(pts)], 255)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
123
  k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (padding, padding))
124
  mask = cv2.dilate(mask, k, iterations=1)
125
+ _, mask = cv2.threshold(cv2.GaussianBlur(mask, (11,11), 0), 80, 255, cv2.THRESH_BINARY)
126
+ mx1 = max(0, pts[:,0].min()-padding*2); my1 = max(0, pts[:,1].min()-padding)
127
+ mx2 = min(w, pts[:,0].max()+padding*2); my2 = min(h, pts[:,1].max()+int(padding*1.5))
128
  return mask, (mx1, my1, mx2, my2), True
 
129
  except Exception as e:
130
+ print(f"[SmileAI] MediaPipe failed: {e}")
131
+ mask2, bbox = make_mouth_mask_color(img_array, padding)
132
+ return mask2, bbox, False
133
 
 
 
 
134
 
135
+ def make_mouth_mask_color(img_array, padding=22):
 
 
136
  h, w = img_array.shape[:2]
137
+ hsv = cv2.cvtColor(img_array, cv2.COLOR_RGB2HSV)
138
+ ycr = cv2.cvtColor(img_array, cv2.COLOR_RGB2YCrCb)
139
+ teeth = cv2.inRange(hsv, np.array([0,0,140]), np.array([40,90,255]))
140
+ lip = cv2.inRange(ycr, np.array([60,140,110]), np.array([200,175,145]))
141
+ dark = cv2.inRange(hsv, np.array([0,0,0]), np.array([180,255,80]))
142
+ combined = cv2.bitwise_or(cv2.bitwise_or(teeth, lip), dark)
 
 
 
 
143
  region = np.zeros_like(combined)
144
+ region[int(h*.45):int(h*.82), int(w*.12):int(w*.88)] = 255
 
 
145
  combined = cv2.bitwise_and(combined, region)
146
+ combined = cv2.morphologyEx(combined, cv2.MORPH_CLOSE, cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(15,15)))
147
+ combined = cv2.morphologyEx(combined, cv2.MORPH_OPEN, cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(7,7)))
148
+ combined = cv2.dilate(combined, cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(padding,padding)))
149
+ nl, lbl, stats, _ = cv2.connectedComponentsWithStats(combined, connectivity=8)
150
+ if nl > 1:
151
+ combined = np.where(lbl==(1+np.argmax(stats[1:,cv2.CC_STAT_AREA])),255,0).astype(np.uint8)
152
+ _, combined = cv2.threshold(cv2.GaussianBlur(combined,(21,21),0),60,255,cv2.THRESH_BINARY)
153
+ ys, xs = np.where(combined>0)
154
+ if len(xs):
155
+ bbox = (max(0,xs.min()-padding),max(0,ys.min()-padding),min(w,xs.max()+padding),min(h,ys.max()+padding))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
156
  else:
157
+ bbox = (int(w*.2),int(h*.55),int(w*.8),int(h*.82))
158
+ return combined, bbox
159
 
 
160
 
161
+ def mask_coverage(mask): return mask.sum()/(255*mask.size)
162
 
 
 
163
 
164
+ def detect_skin_tone(img_array, bbox):
165
+ x1,y1,x2,y2 = bbox
166
+ h,w = img_array.shape[:2]
167
+ sx1,sx2 = min(w-1,x2+10), min(w-1,x2+60)
168
+ sy1,sy2 = max(0,y1), min(h-1,y2)
169
+ if sx1>=sx2 or sy1>=sy2: return ""
170
+ patch = img_array[sy1:sy2, sx1:sx2]
171
+ if patch.size==0: return ""
172
+ L = np.dot(patch.reshape(-1,3).mean(0),[0.2126,0.7152,0.0722])
173
+ if L>200: return "fair skin tone, light complexion"
174
+ elif L>155: return "medium skin tone"
175
+ elif L>100: return "medium-dark olive skin tone"
176
+ else: return "dark brown skin tone"
177
 
 
 
 
178
 
179
+ def run_classic_enhanced(img_array, mask_np, bbox, style_name):
180
+ """Instant CPU mode β€” tooth texture fill + LAB whitening + Poisson blend."""
 
181
  h, w = img_array.shape[:2]
182
+ result = img_array.copy().astype(np.float32)
183
+ mf = cv2.GaussianBlur(mask_np.astype(np.float32)/255., (31,31), 0)
184
+ m3 = np.stack([mf]*3, axis=-1)
185
+
186
+ gray = cv2.cvtColor(img_array, cv2.COLOR_RGB2GRAY)
187
+ tooth_px = (gray > 100) & (mask_np > 60)
188
+ dark_px = (gray < 80) & (mask_np > 60)
189
+
190
+ if tooth_px.any() and dark_px.any():
191
+ tooth_color = img_array[tooth_px].mean(axis=0)
192
+ noise = np.random.RandomState(42).normal(0, 6, img_array.shape).astype(np.float32)
193
+ filled = np.clip(tooth_color + noise, 0, 255)
194
+ dark3 = np.stack([dark_px.astype(np.float32)]*3, axis=-1)
195
+ result = result*(1-dark3) + filled*dark3
196
+
197
+ whitening = {
198
+ "Full Smile Reconstruction": (55,-6,-4),
199
+ "Hollywood Smile": (65,-8,-6),
200
+ "Natural White": (40,-4,-3),
201
+ "Porcelain Veneers": (58,-7,-5),
202
+ "Gap Closure (Diastema Fix)": (45,-5,-3),
203
+ "Crowding / Alignment Fix": (48,-5,-4),
204
+ "Subtle Refresh": (20,-2,-2),
205
+ }
206
+ dL,da,db = whitening.get(style_name, (40,-5,-3))
207
+ tooth_mf = np.clip(cv2.GaussianBlur(tooth_px.astype(np.float32),(15,15),0)*2.5, 0,1)
208
+ lab = cv2.cvtColor(np.clip(result,0,255).astype(np.uint8),cv2.COLOR_RGB2LAB).astype(np.float32)
209
+ l,a,b = cv2.split(lab)
210
+ l = np.clip(l+dL*tooth_mf, 0,255)
211
+ a = np.clip(a+da*tooth_mf, 0,255)
212
+ b = np.clip(b+db*tooth_mf, 0,255)
213
+ result_white = cv2.cvtColor(cv2.merge([l,a,b]).astype(np.uint8),cv2.COLOR_LAB2RGB).astype(np.float32)
214
+ result_final = result_white*m3 + img_array.astype(np.float32)*(1-m3)
215
+
216
+ sharp = cv2.filter2D(result_final.astype(np.uint8), -1,
217
+ np.array([[-1,-1,-1],[-1,9,-1],[-1,-1,-1]])/9.*0.4 + np.eye(3).reshape(1,1,3)*0.6)
218
+ tooth_m3 = np.stack([tooth_mf]*3, axis=-1)
219
+ result_final = sharp.astype(np.float32)*tooth_m3 + result_final*(1-tooth_m3)
220
 
221
+ try:
222
+ x1,y1,x2,y2 = bbox
223
+ poi_mask = np.zeros((h,w),dtype=np.uint8)
224
+ poi_mask[mask_np>60] = 255
225
+ src_bgr = cv2.cvtColor(np.clip(result_final,0,255).astype(np.uint8),cv2.COLOR_RGB2BGR)
226
+ dst_bgr = cv2.cvtColor(img_array,cv2.COLOR_RGB2BGR)
227
+ blended = cv2.seamlessClone(src_bgr, dst_bgr, poi_mask, ((x1+x2)//2,(y1+y2)//2), cv2.NORMAL_CLONE)
228
+ result_final = cv2.cvtColor(blended,cv2.COLOR_BGR2RGB).astype(np.float32)
229
+ except: pass
230
 
231
+ return np.clip(result_final,0,255).astype(np.uint8)
 
232
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
233
 
234
+ def run_inpainting(original_pil, mask_pil, bbox, style_cfg, skin_tone="", is_cpu=True, progress_cb=None):
235
+ import torch
236
  pipe = get_pipe()
237
+ device = str(next(pipe.unet.parameters()).device)
238
+ TARGET = 384 if is_cpu else 512
 
 
239
  orig_w, orig_h = original_pil.size
240
+ x1,y1,x2,y2 = bbox
241
+ PAD = max(70,int((y2-y1)*.85))
242
+ cx1 = max(0,x1-PAD); cy1 = max(0,y1-PAD)
243
+ cx2 = min(orig_w,x2+PAD); cy2 = min(orig_h,y2+PAD)
244
+ crop_img = original_pil.crop((cx1,cy1,cx2,cy2)).convert("RGB")
245
+ crop_mask = mask_pil.crop((cx1,cy1,cx2,cy2)).convert("L")
246
+ cw_orig,ch_orig = crop_img.size
 
 
 
 
 
 
 
247
  cm_arr = np.array(crop_mask)
248
+ if cm_arr.max()<128:
249
+ ch2,cw2=cm_arr.shape
250
+ cv2.ellipse(cm_arr,(cw2//2,ch2//2),(cw2//4,ch2//5),0,0,360,255,-1)
 
 
 
251
  crop_mask = Image.fromarray(cm_arr)
252
+ crop_t = crop_img.resize((TARGET,TARGET),Image.LANCZOS)
253
+ cmask_t = crop_mask.resize((TARGET,TARGET),Image.NEAREST)
254
+ cmask_rgb = Image.fromarray(np.stack([np.array(cmask_t)]*3,axis=-1))
255
+ prompt = style_cfg["prompt"]
256
+ if skin_tone:
257
+ prompt = prompt.replace("photorealistic",f"{skin_tone}, photorealistic")
258
+ steps = style_cfg["steps_cpu"] if is_cpu else style_cfg["steps_gpu"]
259
+ if progress_cb: progress_cb(0.40,f"πŸ€– AI inpainting ({steps} steps, ~{'60s' if is_cpu else '25s'})…")
260
+ gen = torch.Generator(device=device).manual_seed(42)
261
+ result = pipe(prompt=prompt, negative_prompt=style_cfg["negative"],
262
+ image=crop_t, mask_image=cmask_rgb,
263
+ strength=style_cfg["strength"], guidance_scale=style_cfg["guidance"],
264
+ num_inference_steps=steps, generator=gen).images[0]
265
+ if progress_cb: progress_cb(0.76,"πŸ–Ό Compositing result…")
266
+ best_crop = result.resize((cw_orig,ch_orig),Image.LANCZOS)
267
+ result_arr = np.array(original_pil.convert("RGB")).copy()
268
+ best_arr = np.array(best_crop)
269
+ local_mask = np.array(mask_pil.crop((cx1,cy1,cx2,cy2)).convert("L"))
270
+ local_mask = cv2.resize(
271
+ cv2.dilate(local_mask,cv2.getStructuringElement(cv2.MORPH_ELLIPSE,(9,9)),iterations=2),
272
+ (cw_orig,ch_orig),interpolation=cv2.INTER_NEAREST)
273
+ poi_mask = np.zeros((ch_orig,cw_orig),dtype=np.uint8)
274
+ poi_mask[local_mask>60] = 255
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
275
  try:
276
+ if poi_mask.max()>0:
277
+ blended = cv2.seamlessClone(
278
+ cv2.cvtColor(best_arr,cv2.COLOR_RGB2BGR),
279
+ cv2.cvtColor(result_arr,cv2.COLOR_RGB2BGR),
280
+ poi_mask,((cx1+cx2)//2,(cy1+cy2)//2),cv2.NORMAL_CLONE)
281
+ result_arr = cv2.cvtColor(blended,cv2.COLOR_BGR2RGB)
282
+ except:
283
+ feathered = np.stack([cv2.GaussianBlur(local_mask.astype(np.float32)/255.,(25,25),0)]*3,-1)
284
+ region = result_arr[cy1:cy2,cx1:cx2].astype(np.float32)
285
+ result_arr[cy1:cy2,cx1:cx2] = np.clip(best_arr.astype(np.float32)*feathered+region*(1-feathered),0,255).astype(np.uint8)
286
+ result_pil = Image.fromarray(result_arr)
287
+ sharp_pil = result_pil.filter(ImageFilter.UnsharpMask(radius=1.5,percent=110,threshold=2))
288
+ full_mf = np.stack([cv2.GaussianBlur(np.array(mask_pil.convert("L")).astype(np.float32)/255.,(31,31),0)]*3,-1)
289
+ final_arr = np.array(sharp_pil)*full_mf + np.array(result_pil)*(1-full_mf)
290
+ return Image.fromarray(np.clip(final_arr,0,255).astype(np.uint8))
291
+
292
+
293
+ def add_watermark(img, practice):
294
+ out=img.copy().convert("RGBA"); draw=ImageDraw.Draw(out); w,h=out.size
295
+ text=f"Β© {practice} | SmileAI Pro | {datetime.now().strftime('%Y')}"
296
+ try: font=ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans.ttf",max(11,h//48))
297
+ except: font=ImageFont.load_default()
298
+ bb=draw.textbbox((0,0),text,font=font); tw,th=bb[2]-bb[0],bb[3]-bb[1]; x,y=w-tw-14,h-th-10
299
+ draw.text((x+1,y+1),text,fill=(0,0,0,140),font=font)
300
+ draw.text((x,y),text,fill=(255,255,255,200),font=font)
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
301
  return out.convert("RGB")
302
 
303
 
304
+ def create_comparison(before, after, style, ai_used):
305
+ W=960; scale=(W//2)/before.width; new_h=int(before.height*scale)
306
+ b=before.resize((W//2,new_h),Image.LANCZOS); a=after.resize((W//2,new_h),Image.LANCZOS)
307
+ HDR=60; canvas=Image.new("RGB",(W,new_h+HDR),(10,10,16)); draw=ImageDraw.Draw(canvas)
308
+ try: fh=ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf",18)
309
+ except: fh=ImageFont.load_default()
310
+ draw.rectangle([0,0,W,HDR],fill=(16,16,26))
311
+ draw.text((W//4,22),"BEFORE",fill=(160,160,175),font=fh,anchor="mm")
312
+ draw.text((3*W//4,22),f"AFTER Β· {style} {'πŸ€–' if ai_used else '✨'}",fill=(80,220,155),font=fh,anchor="mm")
313
+ canvas.paste(b,(0,HDR)); canvas.paste(a,(W//2,HDR))
314
+ draw.rectangle([W//2-2,HDR,W//2+2,new_h+HDR],fill=(200,200,200,80))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
315
  return canvas
316
 
317
 
318
+ def export_pdf(before,after,comparison,patient_name,style,practice_name,ai_used):
319
  try:
320
  from reportlab.lib.pagesizes import letter
321
  from reportlab.lib import colors
322
  from reportlab.lib.units import inch
323
+ from reportlab.platypus import SimpleDocTemplate,Image as RLImage,Paragraph,Spacer,Table,TableStyle
324
+ from reportlab.lib.styles import getSampleStyleSheet,ParagraphStyle
325
+ except: return None
326
+ path=f"/tmp/smile_{datetime.now().strftime('%Y%m%d_%H%M%S')}.pdf"
327
+ doc=SimpleDocTemplate(path,pagesize=letter,topMargin=.5*inch,bottomMargin=.5*inch,leftMargin=.75*inch,rightMargin=.75*inch)
328
+ styles=getSampleStyleSheet()
329
+ def S(n,**kw): return ParagraphStyle(n,parent=styles["Normal"],**kw)
330
+ mode="AI Inpainting (DPM++ CPU)" if ai_used else "Classic Enhanced"
331
+ story=[Paragraph(practice_name or "SmileAI Pro",S("T",fontSize=22,textColor=colors.HexColor("#0d9e6e"),spaceAfter=4)),
332
+ Paragraph("Smile Design Simulation Report",S("S",fontSize=11,textColor=colors.HexColor("#555"),spaceAfter=12))]
333
+ info=[["Patient:",patient_name or "β€”","Date:",datetime.now().strftime("%B %d, %Y")],["Style:",style,"Method:",mode]]
334
+ t=Table(info,colWidths=[1.2*inch,2.3*inch,1.2*inch,2.3*inch])
335
+ t.setStyle(TableStyle([("FONTNAME",(0,0),(0,-1),"Helvetica-Bold"),("FONTNAME",(2,0),(2,-1),"Helvetica-Bold"),
336
+ ("FONTSIZE",(0,0),(-1,-1),9),("BOTTOMPADDING",(0,0),(-1,-1),5),("LINEBELOW",(0,-1),(-1,-1),.5,colors.HexColor("#ddd"))]))
337
+ story+=[t,Spacer(1,.2*inch)]
338
+ buf=io.BytesIO(); comparison.save(buf,format="PNG"); buf.seek(0)
339
+ story.append(RLImage(buf,width=7*inch,height=7*inch*comparison.height/comparison.width))
340
+ story.append(Spacer(1,.15*inch))
341
+ story.append(Paragraph("⚠ Simulation only β€” not a medical diagnosis or treatment guarantee.",S("D",fontSize=8,textColor=colors.HexColor("#999"))))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
342
  doc.build(story)
343
  return path
344
 
345
 
346
+ def process_smile(input_image,smile_style,use_ai,mask_padding,face_brightness,face_contrast,
347
+ patient_name,practice_name,add_branding,progress=gr.Progress(track_tqdm=True)):
 
 
 
 
 
 
 
 
 
 
 
 
 
 
348
  if input_image is None:
349
  raise gr.Error("Please upload a patient photo first.")
350
+ progress(0.04,"πŸ“Έ Loading image…")
351
+ arr=input_image.copy() if isinstance(input_image,np.ndarray) else np.array(input_image.convert("RGB"))
352
+ pil_orig=Image.fromarray(arr)
353
+ MAX=1024
354
+ if max(arr.shape[:2])>MAX:
355
+ s=MAX/max(arr.shape[:2])
356
+ arr=cv2.resize(arr,(int(arr.shape[1]*s),int(arr.shape[0]*s)),interpolation=cv2.INTER_AREA)
357
+ pil_orig=Image.fromarray(arr)
358
+ progress(0.10,"πŸ” Detecting mouth landmarks…")
359
+ mask_np,bbox,mp_ok=make_mouth_mask_mediapipe(arr,padding=mask_padding)
360
+ coverage=mask_coverage(mask_np)
361
+ mask_pil=Image.fromarray(mask_np)
362
+ skin_tone=detect_skin_tone(arr,bbox)
363
+ style_cfg=STYLES[smile_style]
364
+ ai_used=False; ai_error=""; pil_after=None
365
+ import torch
366
+ is_cpu=not torch.cuda.is_available()
 
 
 
 
 
 
 
 
 
367
  if use_ai:
368
  try:
 
369
  from diffusers import StableDiffusionInpaintPipeline # noqa
370
+ progress(0.18,"⏳ Loading AI model (first run ~60s download)…")
371
+ pil_after=run_inpainting(pil_orig,mask_pil,bbox,style_cfg,
372
+ skin_tone=skin_tone,is_cpu=is_cpu,
373
+ progress_cb=lambda v,d:progress(v,d))
374
+ ai_used=True; progress(0.84,"βœ… AI complete!")
375
+ except ImportError: ai_error="⚠ diffusers/torch not installed β€” using classic mode."
376
+ except Exception as e: ai_error=f"⚠ AI error: {str(e)[:100]} β€” using classic mode."
 
 
 
 
 
 
 
377
  if not ai_used:
378
+ progress(0.25,"🎨 Enhanced classic mode…")
379
+ pil_after=Image.fromarray(run_classic_enhanced(arr,mask_np,bbox,smile_style))
380
+ progress(0.88,"πŸ’… Final polish…")
381
+ pil_after=ImageEnhance.Brightness(pil_after).enhance(1+face_brightness*.25)
382
+ pil_after=ImageEnhance.Contrast(pil_after).enhance(1+face_contrast*.20)
383
+ pil_after=ImageEnhance.Sharpness(pil_after).enhance(1.06)
384
+ if add_branding: pil_after=add_watermark(pil_after,practice_name or "SmileAI Pro")
385
+ progress(0.92,"πŸ–Ό Building comparison…")
386
+ comparison=create_comparison(pil_orig,pil_after,smile_style,ai_used)
387
+ progress(0.96,"πŸ“„ Generating PDF…")
388
+ pdf_path=export_pdf(pil_orig,pil_after,comparison,patient_name,smile_style,practice_name or "SmileAI Pro",ai_used)
389
+ mp_tag="MediaPipe landmarks βœ…" if mp_ok else "color detection (install mediapipe)"
390
+ mode_tag=f"πŸ€– AI β€” DPM++ {'CPU ~60s' if is_cpu else 'GPU ~25s'}" if ai_used else "✨ Classic Enhanced (instant)"
391
+ mask_warn="\n⚠ Mouth not detected β€” use a frontal well-lit photo." if coverage<0.01 else ""
392
+ status=(f"βœ… Done! Mode: {mode_tag}\n"
393
+ f"Mask: {mp_tag} | Coverage: {coverage*100:.1f}%\n"
394
+ f"Skin tone: {skin_tone or 'auto'}"
395
+ f"{mask_warn}{chr(10)+ai_error if ai_error else ''}")
 
 
 
 
 
 
 
 
 
 
 
 
 
396
  progress(1.0)
397
+ return pil_after,comparison,pdf_path,status
398
 
399
 
 
 
 
 
400
  def _check_env():
401
+ r={"diffusers":False,"gpu":False,"mediapipe":False}
402
  try:
403
  import torch
404
  from diffusers import StableDiffusionInpaintPipeline # noqa
405
+ r["diffusers"]=True; r["gpu"]=torch.cuda.is_available()
406
+ except: pass
 
 
407
  try:
408
+ import mediapipe; r["mediapipe"]=True # noqa
409
+ except: pass
410
+ return r
 
 
 
 
411
 
412
+ ENV=_check_env()
413
 
414
+ CSS="""
 
 
 
 
415
  @import url('https://fonts.googleapis.com/css2?family=DM+Serif+Display&family=DM+Sans:wght@300;400;500;600&display=swap');
416
+ :root{--mint:#0fba7c;--mint2:#09d48f;--dark:#0b0d14;--card:#13161f;--border:#1e2232;--text:#dde2f0;--muted:#7a82a0;}
417
+ body,.gradio-container{background:var(--dark)!important;color:var(--text)!important;font-family:'DM Sans',sans-serif!important;}
418
+ .hero{background:linear-gradient(135deg,#0fba7c14,#09d48f08 40%,#0b0d14);border:1px solid #0fba7c30;border-radius:20px;padding:30px 40px;margin-bottom:20px;}
419
+ .hero h1{font-family:'DM Serif Display',serif;color:var(--mint2);font-size:2rem;margin:0 0 6px;}
420
+ .hero p{color:var(--muted);margin:0;font-size:.93rem;}
421
+ .badge{display:inline-block;background:linear-gradient(90deg,#0fba7c,#09d48f);color:#000;font-size:.7rem;font-weight:700;letter-spacing:.08em;padding:3px 10px;border-radius:20px;margin-left:8px;vertical-align:middle;}
422
+ .pill{display:inline-block;border:1px solid #0fba7c60;color:#0fba7c;font-size:.72rem;font-weight:600;padding:2px 8px;border-radius:20px;margin:2px;}
423
+ .gr-panel,.gr-box,.gr-form,.gr-block{background:var(--card)!important;border:1px solid var(--border)!important;border-radius:14px!important;}
424
+ .gr-button-primary{background:linear-gradient(135deg,#0fba7c,#07a36c)!important;border:none!important;border-radius:10px!important;font-weight:600!important;font-size:1rem!important;padding:13px 28px!important;color:#fff!important;box-shadow:0 4px 20px #0fba7c30!important;transition:all .18s!important;}
425
+ .gr-button-primary:hover{background:linear-gradient(135deg,#10cc88,#0fba7c)!important;transform:translateY(-2px)!important;box-shadow:0 8px 28px #0fba7c50!important;}
426
+ label{color:var(--muted)!important;font-size:.82rem!important;}
427
+ .ai-box{background:#0fba7c10!important;border:1px solid #0fba7c35!important;}
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
428
  """
429
 
430
+ pills=[]
431
+ pills.append('<span class="pill">βœ… MediaPipe Landmarks</span>' if ENV["mediapipe"] else '<span class="pill" style="border-color:#f0704060;color:#f07040">⚠ MediaPipe not installed</span>')
432
+ pills.append('<span class="pill">βœ… GPU Ready (~25s)</span>' if ENV["gpu"] else
433
+ '<span class="pill" style="border-color:#f0c04060;color:#f0c040">⚑ CPU β€” AI ~60s Β· Classic: instant</span>' if ENV["diffusers"] else
434
+ '<span class="pill" style="border-color:#aaa;color:#aaa">✨ Classic mode (instant)</span>')
 
 
 
 
 
 
 
 
 
 
 
435
 
436
+ with gr.Blocks(css=CSS,title="SmileAI Pro v4") as demo:
437
  gr.HTML(f"""
438
  <div class="hero">
439
+ <h1>🦷 SmileAI Pro <span class="badge">v4 · CPU OPTIMISED</span></h1>
440
+ <p>MediaPipe landmark masking Β· DPM++ fast scheduler Β· Enhanced classic mode (instant) Β· Poisson seamless blending</p>
441
+ <p style="margin-top:10px">{"".join(pills)}</p>
442
+ </div>""")
 
 
443
  with gr.Row(equal_height=False):
444
+ with gr.Column(scale=1,min_width=300):
 
 
445
  gr.Markdown("### πŸ“€ Patient Photo")
446
+ input_img=gr.Image(label="Upload frontal smiling photo",type="numpy",height=280)
 
 
 
 
447
  gr.Markdown("### 🎨 Treatment Style")
448
+ style_dd=gr.Dropdown(list(STYLES.keys()),value="Full Smile Reconstruction",label="Select treatment")
 
 
 
 
 
449
  with gr.Group(elem_classes="ai-box"):
450
  gr.Markdown("**πŸ€– AI Settings**")
451
+ use_ai_cb=gr.Checkbox(label="Enable AI Inpainting (Stable Diffusion)",value=ENV["diffusers"],
452
+ info="CPU: ~60s with DPM++ scheduler. Uncheck for instant classic mode.")
453
+ mask_pad=gr.Slider(8,40,value=18,step=2,label="Landmark Mask Padding (px)")
 
 
 
 
 
 
 
454
  gr.Markdown("### βš™οΈ Final Polish")
455
+ brightness=gr.Slider(-0.5,0.5,value=0.06,step=0.02,label="Face Brightness")
456
+ contrast=gr.Slider(-0.5,0.5,value=0.08,step=0.02,label="Contrast")
 
457
  gr.Markdown("### πŸ₯ Practice Info")
458
+ patient_name=gr.Textbox(label="Patient Name",placeholder="Jane Doe")
459
+ practice_name=gr.Textbox(label="Practice Name",value="SmileAI Pro")
460
+ branding_cb=gr.Checkbox(label="Add watermark",value=True)
461
+ run_btn=gr.Button("✨ Generate Smile Simulation",variant="primary")
 
 
462
  with gr.Column(scale=2):
463
+ status_box=gr.Textbox(label="Status",lines=4,interactive=False)
 
464
  with gr.Tabs():
465
+ with gr.TabItem("🦷 After"): after_out=gr.Image(label="Simulated Result",type="pil",height=450)
466
+ with gr.TabItem("↔ Before / After"): compare_out=gr.Image(label="Side-by-Side",type="pil",height=450)
467
+ with gr.TabItem("πŸ“„ PDF"): pdf_out=gr.File(label="Download PDF Report")
468
+
469
+ with gr.Accordion("πŸ“Έ Tips & CPU Speed Guide",open=False):
 
 
 
470
  gr.Markdown("""
471
+ **For CPU (what you have now):**
472
+
473
+ | Mode | Time | Best for |
474
+ |------|------|----------|
475
+ | ✨ Classic Enhanced (uncheck AI) | ~3 seconds | Whitening, stain removal, gap fill |
476
+ | πŸ€– AI DPM++ 20 steps | ~60 seconds | Full reconstruction, missing teeth |
477
+
478
+ **To fix "MediaPipe not installed" β€” add `packages.txt` to your Space root:**
479
+ ```
480
+ libgl1
481
+ libglib2.0-0
482
+ libsm6
483
+ libxrender1
484
+ libxext6
485
+ ffmpeg
486
+ ```
487
+
488
+ **requirements.txt:**
489
+ ```
490
+ gradio>=4.0.0
491
+ numpy pillow opencv-python-headless reportlab
492
+ mediapipe==0.10.9
493
+ torch torchvision
494
+ diffusers>=0.27.0 transformers>=4.38.0 accelerate>=0.27.0
495
+ ```
496
  """)
497
 
498
  run_btn.click(
499
  fn=process_smile,
500
+ inputs=[input_img,style_dd,use_ai_cb,mask_pad,brightness,contrast,patient_name,practice_name,branding_cb],
501
+ outputs=[after_out,compare_out,pdf_out,status_box])
502
+
503
+ if __name__=="__main__":
504
+ demo.launch(share=False,server_name="0.0.0.0",server_port=7860)
 
 
 
 
 
packages.txt ADDED
@@ -0,0 +1,6 @@
 
 
 
 
 
 
 
1
+ libgl1
2
+ libglib2.0-0
3
+ libsm6
4
+ libxrender1
5
+ libxext6
6
+ ffmpeg
requirements .txt CHANGED
@@ -14,7 +14,7 @@ pillow
14
  opencv-python-headless
15
 
16
  # Face landmark detection (mouth mask precision)
17
- mediapipe
18
 
19
  # PDF export
20
  reportlab
 
14
  opencv-python-headless
15
 
16
  # Face landmark detection (mouth mask precision)
17
+ mediapipe==0.10.9
18
 
19
  # PDF export
20
  reportlab