Spaces:
Running
Running
Upload landmarkdiff/masking.py with huggingface_hub
Browse files- landmarkdiff/masking.py +137 -0
landmarkdiff/masking.py
ADDED
|
@@ -0,0 +1,137 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Surgical mask gen - morphological dilation + Gaussian feather.
|
| 2 |
+
|
| 3 |
+
Procedural (not SAM2), deterministic, no model dependency.
|
| 4 |
+
Feathered edges prevent visible seams during inpainting.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from __future__ import annotations
|
| 8 |
+
|
| 9 |
+
from typing import TYPE_CHECKING
|
| 10 |
+
|
| 11 |
+
import cv2
|
| 12 |
+
import numpy as np
|
| 13 |
+
|
| 14 |
+
from landmarkdiff.landmarks import FaceLandmarks, LANDMARK_REGIONS
|
| 15 |
+
|
| 16 |
+
if TYPE_CHECKING:
|
| 17 |
+
from landmarkdiff.clinical import ClinicalFlags
|
| 18 |
+
|
| 19 |
+
# Procedure-specific mask parameters
|
| 20 |
+
MASK_CONFIG: dict[str, dict] = {
|
| 21 |
+
"rhinoplasty": {
|
| 22 |
+
"landmark_indices": [
|
| 23 |
+
1, 2, 4, 5, 6, 19, 94, 141, 168, 195, 197, 236, 240,
|
| 24 |
+
274, 275, 278, 279, 294, 326, 327, 360, 363, 370, 456, 460,
|
| 25 |
+
],
|
| 26 |
+
"dilation_px": 30,
|
| 27 |
+
"feather_sigma": 15.0,
|
| 28 |
+
},
|
| 29 |
+
"blepharoplasty": {
|
| 30 |
+
"landmark_indices": [
|
| 31 |
+
33, 7, 163, 144, 145, 153, 154, 155, 157, 158, 159, 160, 161, 246,
|
| 32 |
+
362, 382, 381, 380, 374, 373, 390, 249, 263, 466, 388, 387, 386,
|
| 33 |
+
385, 384, 398,
|
| 34 |
+
],
|
| 35 |
+
"dilation_px": 15,
|
| 36 |
+
"feather_sigma": 10.0,
|
| 37 |
+
},
|
| 38 |
+
"rhytidectomy": {
|
| 39 |
+
"landmark_indices": [
|
| 40 |
+
10, 21, 54, 58, 67, 93, 103, 109, 127, 132, 136, 150, 162, 172,
|
| 41 |
+
176, 187, 207, 213, 234, 284, 297, 323, 332, 338, 356, 361, 365,
|
| 42 |
+
379, 389, 397, 400, 427, 454,
|
| 43 |
+
],
|
| 44 |
+
"dilation_px": 40,
|
| 45 |
+
"feather_sigma": 20.0,
|
| 46 |
+
},
|
| 47 |
+
"orthognathic": {
|
| 48 |
+
"landmark_indices": [
|
| 49 |
+
0, 17, 18, 36, 37, 39, 40, 57, 61, 78, 80, 81, 82, 84, 87, 88,
|
| 50 |
+
91, 95, 146, 167, 169, 170, 175, 181, 191, 200, 201, 202, 204,
|
| 51 |
+
208, 211, 212, 214,
|
| 52 |
+
],
|
| 53 |
+
"dilation_px": 35,
|
| 54 |
+
"feather_sigma": 18.0,
|
| 55 |
+
},
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
|
| 59 |
+
def generate_surgical_mask(
|
| 60 |
+
face: FaceLandmarks,
|
| 61 |
+
procedure: str,
|
| 62 |
+
width: int | None = None,
|
| 63 |
+
height: int | None = None,
|
| 64 |
+
clinical_flags: "ClinicalFlags | None" = None,
|
| 65 |
+
image: np.ndarray | None = None,
|
| 66 |
+
) -> np.ndarray:
|
| 67 |
+
"""Convex hull -> dilate -> noise at boundary -> Gaussian feather. Returns float32 [0-1]."""
|
| 68 |
+
if procedure not in MASK_CONFIG:
|
| 69 |
+
raise ValueError(f"Unknown procedure: {procedure}. Choose from {list(MASK_CONFIG)}")
|
| 70 |
+
|
| 71 |
+
config = MASK_CONFIG[procedure]
|
| 72 |
+
w = width or face.image_width
|
| 73 |
+
h = height or face.image_height
|
| 74 |
+
|
| 75 |
+
# Get pixel coordinates of procedure landmarks
|
| 76 |
+
coords = face.landmarks[:, :2].copy()
|
| 77 |
+
coords[:, 0] *= w
|
| 78 |
+
coords[:, 1] *= h
|
| 79 |
+
pts = coords[config["landmark_indices"]].astype(np.int32)
|
| 80 |
+
|
| 81 |
+
# Create binary mask from convex hull
|
| 82 |
+
binary = np.zeros((h, w), dtype=np.uint8)
|
| 83 |
+
hull = cv2.convexHull(pts)
|
| 84 |
+
cv2.fillConvexPoly(binary, hull, 255)
|
| 85 |
+
|
| 86 |
+
# Morphological dilation
|
| 87 |
+
dilation = config["dilation_px"]
|
| 88 |
+
kernel = cv2.getStructuringElement(
|
| 89 |
+
cv2.MORPH_ELLIPSE,
|
| 90 |
+
(2 * dilation + 1, 2 * dilation + 1),
|
| 91 |
+
)
|
| 92 |
+
dilated = cv2.dilate(binary, kernel)
|
| 93 |
+
|
| 94 |
+
# Add slight boundary noise to prevent clean-edge seams
|
| 95 |
+
# (Spec: Perlin noise 2-4px on boundary before feathering)
|
| 96 |
+
boundary = cv2.subtract(
|
| 97 |
+
cv2.dilate(dilated, np.ones((5, 5), np.uint8)),
|
| 98 |
+
cv2.erode(dilated, np.ones((5, 5), np.uint8)),
|
| 99 |
+
)
|
| 100 |
+
noise = np.random.default_rng().integers(0, 4, size=(h, w), dtype=np.uint8)
|
| 101 |
+
noise_boundary = cv2.bitwise_and(boundary, noise.astype(np.uint8) * 64)
|
| 102 |
+
dilated = cv2.add(dilated, noise_boundary)
|
| 103 |
+
dilated = np.clip(dilated, 0, 255).astype(np.uint8)
|
| 104 |
+
|
| 105 |
+
# Gaussian feathering
|
| 106 |
+
sigma = config["feather_sigma"]
|
| 107 |
+
ksize = int(6 * sigma) | 1 # ensure odd
|
| 108 |
+
feathered = cv2.GaussianBlur(
|
| 109 |
+
dilated.astype(np.float32) / 255.0,
|
| 110 |
+
(ksize, ksize),
|
| 111 |
+
sigma,
|
| 112 |
+
)
|
| 113 |
+
|
| 114 |
+
mask = np.clip(feathered, 0.0, 1.0)
|
| 115 |
+
|
| 116 |
+
# Clinical edge case adjustments
|
| 117 |
+
if clinical_flags is not None:
|
| 118 |
+
# Vitiligo: reduce mask over depigmented patches to preserve them
|
| 119 |
+
if clinical_flags.vitiligo and image is not None:
|
| 120 |
+
from landmarkdiff.clinical import detect_vitiligo_patches, adjust_mask_for_vitiligo
|
| 121 |
+
patches = detect_vitiligo_patches(image, face)
|
| 122 |
+
mask = adjust_mask_for_vitiligo(mask, patches)
|
| 123 |
+
|
| 124 |
+
# Keloid: soften transitions in keloid-prone regions
|
| 125 |
+
if clinical_flags.keloid_prone and clinical_flags.keloid_regions:
|
| 126 |
+
from landmarkdiff.clinical import get_keloid_exclusion_mask, adjust_mask_for_keloid
|
| 127 |
+
keloid_mask = get_keloid_exclusion_mask(
|
| 128 |
+
face, clinical_flags.keloid_regions, w, h,
|
| 129 |
+
)
|
| 130 |
+
mask = adjust_mask_for_keloid(mask, keloid_mask)
|
| 131 |
+
|
| 132 |
+
return mask
|
| 133 |
+
|
| 134 |
+
|
| 135 |
+
def mask_to_3channel(mask: np.ndarray) -> np.ndarray:
|
| 136 |
+
"""Convert single-channel mask to 3-channel for compositing."""
|
| 137 |
+
return np.stack([mask, mask, mask], axis=-1)
|