LandmarkDiff / landmarkdiff /clinical.py
dreamlessx's picture
Update landmarkdiff/clinical.py to v0.3.2
bc21184 verified
"""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
@dataclass
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)