Spaces:
Running
Running
| """Clinical edge case handling for pathological conditions. | |
| Implements special-case logic for: | |
| - Vitiligo: preserve depigmented patches (don't blend over them) | |
| - Bell's palsy: disable bilateral symmetry in deformation vectors | |
| - Keloid: flag keloid-prone areas to reduce aggressive compositing | |
| - Ehlers-Danlos: wider influence radii for hypermobile tissue | |
| """ | |
| from __future__ import annotations | |
| from dataclasses import dataclass, field | |
| import cv2 | |
| import numpy as np | |
| from landmarkdiff.landmarks import FaceLandmarks | |
| class ClinicalFlags: | |
| """Clinical condition flags that modify pipeline behavior. | |
| Set flags to True to enable condition-specific handling. | |
| """ | |
| vitiligo: bool = False | |
| bells_palsy: bool = False | |
| bells_palsy_side: str = "left" # affected side: "left" or "right" | |
| keloid_prone: bool = False | |
| keloid_regions: list[str] = field(default_factory=list) # e.g. ["jawline", "nose"] | |
| ehlers_danlos: bool = False | |
| def has_any(self) -> bool: | |
| return self.vitiligo or self.bells_palsy or self.keloid_prone or self.ehlers_danlos | |
| def detect_vitiligo_patches( | |
| image: np.ndarray, | |
| face: FaceLandmarks, | |
| l_threshold: float = 85.0, | |
| min_patch_area: int = 200, | |
| ) -> np.ndarray: | |
| """Detect depigmented (vitiligo) patches on face using LAB luminance. | |
| Vitiligo patches appear as high-L, low-saturation regions that deviate | |
| significantly from surrounding skin tone. | |
| Args: | |
| image: BGR face image. | |
| face: Extracted landmarks for face ROI. | |
| l_threshold: Luminance threshold (patches brighter than surrounding skin). | |
| min_patch_area: Minimum contour area in pixels to count as a patch. | |
| Returns: | |
| Binary mask (uint8, 0/255) of detected vitiligo patches. | |
| """ | |
| h, w = image.shape[:2] | |
| lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB).astype(np.float32) | |
| # Create face ROI mask from landmarks | |
| coords = face.pixel_coords.astype(np.int32) | |
| hull = cv2.convexHull(coords) | |
| face_mask = np.zeros((h, w), dtype=np.uint8) | |
| cv2.fillConvexPoly(face_mask, hull, 255) | |
| # Get face-region luminance statistics | |
| l_channel = lab[:, :, 0] | |
| face_pixels = l_channel[face_mask > 0] | |
| if len(face_pixels) == 0: | |
| return np.zeros((h, w), dtype=np.uint8) | |
| l_mean = np.mean(face_pixels) | |
| l_std = np.std(face_pixels) | |
| # Vitiligo patches: significantly brighter than mean skin | |
| threshold = min(l_threshold, l_mean + 2.0 * l_std) | |
| bright_mask = ((l_channel > threshold) & (face_mask > 0)).astype(np.uint8) * 255 | |
| # Also check for low saturation (a,b channels close to 128) | |
| a_channel = lab[:, :, 1] | |
| b_channel = lab[:, :, 2] | |
| low_sat = ( | |
| (np.abs(a_channel - 128) < 15) & (np.abs(b_channel - 128) < 15) | |
| ).astype(np.uint8) * 255 | |
| # Combined: bright AND low-saturation within face | |
| vitiligo_raw = cv2.bitwise_and(bright_mask, low_sat) | |
| # Filter small noise patches | |
| contours, _ = cv2.findContours(vitiligo_raw, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) | |
| result = np.zeros((h, w), dtype=np.uint8) | |
| for cnt in contours: | |
| if cv2.contourArea(cnt) >= min_patch_area: | |
| cv2.fillPoly(result, [cnt], 255) | |
| return result | |
| def adjust_mask_for_vitiligo( | |
| mask: np.ndarray, | |
| vitiligo_patches: np.ndarray, | |
| preservation_factor: float = 0.3, | |
| ) -> np.ndarray: | |
| """Reduce mask intensity over vitiligo patches to preserve them. | |
| Instead of full blending over depigmented patches, we reduce the | |
| mask weight so the original vitiligo pattern shows through. | |
| Args: | |
| mask: Float32 surgical mask [0-1]. | |
| vitiligo_patches: Binary mask of vitiligo regions (0/255 uint8). | |
| preservation_factor: How much to reduce blending (0=full blend, 1=fully preserve). | |
| Returns: | |
| Modified mask with reduced intensity over vitiligo patches. | |
| """ | |
| patches_f = vitiligo_patches.astype(np.float32) / 255.0 | |
| reduction = patches_f * preservation_factor | |
| return np.clip(mask - reduction, 0.0, 1.0) | |
| def get_bells_palsy_side_indices( | |
| side: str, | |
| ) -> dict[str, list[int]]: | |
| """Get landmark indices for the affected side in Bell's palsy. | |
| In Bell's palsy, one side of the face is paralyzed. We should NOT | |
| apply bilateral symmetric deformations — only deform the healthy side. | |
| Returns: | |
| Dict mapping region names to landmark indices on the affected side. | |
| """ | |
| if side == "left": | |
| return { | |
| "eye": [33, 7, 163, 144, 145, 153, 154, 155, 133, 173, 157, 158, 159, 160, 161, 246], | |
| "eyebrow": [70, 63, 105, 66, 107, 55, 65, 52, 53, 46], | |
| "mouth_corner": [61, 146, 91, 181, 84], | |
| "jawline": [132, 136, 172, 58, 150, 176, 148, 149], | |
| } | |
| else: | |
| return { | |
| "eye": [362, 382, 381, 380, 374, 373, 390, 249, 263, 466, 388, 387, 386, 385, 384, 398], | |
| "eyebrow": [300, 293, 334, 296, 336, 285, 295, 282, 283, 276], | |
| "mouth_corner": [291, 308, 324, 318, 402], | |
| "jawline": [361, 365, 397, 288, 379, 400, 377, 378], | |
| } | |
| def get_keloid_exclusion_mask( | |
| face: FaceLandmarks, | |
| regions: list[str], | |
| width: int, | |
| height: int, | |
| margin_px: int = 10, | |
| ) -> np.ndarray: | |
| """Generate mask of keloid-prone regions to exclude from aggressive compositing. | |
| Keloid patients should have reduced blending intensity and no sharp | |
| boundary transitions in prone areas (typically jawline, ears, chest). | |
| Args: | |
| face: Extracted landmarks. | |
| regions: List of region names prone to keloids. | |
| width: Image width. | |
| height: Image height. | |
| margin_px: Extra margin around keloid regions. | |
| Returns: | |
| Float32 mask [0-1] where 1 = keloid-prone area. | |
| """ | |
| from landmarkdiff.landmarks import LANDMARK_REGIONS | |
| mask = np.zeros((height, width), dtype=np.float32) | |
| coords = face.pixel_coords.astype(np.int32) | |
| for region in regions: | |
| indices = LANDMARK_REGIONS.get(region, []) | |
| if not indices: | |
| continue | |
| pts = coords[indices] | |
| hull = cv2.convexHull(pts) | |
| cv2.fillConvexPoly(mask, hull, 1.0) | |
| # Dilate by margin | |
| if margin_px > 0: | |
| kernel = cv2.getStructuringElement( | |
| cv2.MORPH_ELLIPSE, (2 * margin_px + 1, 2 * margin_px + 1) | |
| ) | |
| mask = cv2.dilate(mask, kernel) | |
| return np.clip(mask, 0.0, 1.0) | |
| def adjust_mask_for_keloid( | |
| mask: np.ndarray, | |
| keloid_mask: np.ndarray, | |
| reduction_factor: float = 0.5, | |
| ) -> np.ndarray: | |
| """Soften mask transitions in keloid-prone areas. | |
| Reduces the mask gradient steepness to prevent hard boundaries | |
| that could trigger keloid formation in real surgical planning. | |
| Args: | |
| mask: Float32 surgical mask [0-1]. | |
| keloid_mask: Float32 keloid region mask [0-1]. | |
| reduction_factor: How much to reduce mask intensity in keloid areas. | |
| Returns: | |
| Modified mask with gentler transitions in keloid regions. | |
| """ | |
| # Reduce mask intensity in keloid-prone areas | |
| keloid_reduction = keloid_mask * reduction_factor | |
| modified = mask * (1.0 - keloid_reduction) | |
| # Extra Gaussian blur in keloid regions for softer transitions | |
| blur_kernel = 31 | |
| blurred = cv2.GaussianBlur(modified, (blur_kernel, blur_kernel), 10.0) | |
| # Use blurred version only in keloid regions | |
| result = modified * (1.0 - keloid_mask) + blurred * keloid_mask | |
| return np.clip(result, 0.0, 1.0) | |