|
|
""" |
|
|
Advanced hair segmentation pipeline for BackgroundFX Pro. |
|
|
Specialized module for accurate hair detection and segmentation. |
|
|
""" |
|
|
|
|
|
import numpy as np |
|
|
import cv2 |
|
|
import torch |
|
|
import torch.nn as nn |
|
|
import torch.nn.functional as F |
|
|
from typing import Dict, List, Optional, Tuple, Any |
|
|
from dataclasses import dataclass |
|
|
import logging |
|
|
from scipy import ndimage |
|
|
|
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
@dataclass |
|
|
class HairConfig: |
|
|
"""Configuration for hair segmentation.""" |
|
|
min_hair_confidence: float = 0.6 |
|
|
edge_sensitivity: float = 0.8 |
|
|
strand_detection: bool = True |
|
|
strand_thickness: int = 2 |
|
|
asymmetry_correction: bool = True |
|
|
max_asymmetry_ratio: float = 1.5 |
|
|
use_deep_features: bool = False |
|
|
refinement_iterations: int = 3 |
|
|
alpha_matting: bool = True |
|
|
preserve_details: bool = True |
|
|
smoothing_sigma: float = 1.0 |
|
|
|
|
|
|
|
|
class HairSegmentationPipeline: |
|
|
"""Complete hair segmentation pipeline.""" |
|
|
|
|
|
def __init__(self, config: Optional[HairConfig] = None): |
|
|
self.config = config or HairConfig() |
|
|
self.mask_refiner = HairMaskRefiner(config) |
|
|
self.asymmetry_detector = AsymmetryDetector(config) |
|
|
self.edge_enhancer = HairEdgeEnhancer(config) |
|
|
|
|
|
|
|
|
self.deep_model = None |
|
|
if self.config.use_deep_features: |
|
|
self.deep_model = HairNet() |
|
|
|
|
|
def segment(self, image: np.ndarray, |
|
|
initial_mask: Optional[np.ndarray] = None, |
|
|
prompts: Optional[Dict] = None) -> Dict[str, np.ndarray]: |
|
|
""" |
|
|
Perform complete hair segmentation. |
|
|
|
|
|
Returns: |
|
|
Dictionary containing: |
|
|
- 'mask': Final hair mask |
|
|
- 'confidence': Confidence map |
|
|
- 'strands': Fine hair strands mask |
|
|
- 'edges': Hair edge map |
|
|
""" |
|
|
h, w = image.shape[:2] |
|
|
|
|
|
|
|
|
hair_regions = self._detect_hair_regions(image, initial_mask) |
|
|
|
|
|
|
|
|
if self.deep_model and self.config.use_deep_features: |
|
|
deep_features = self._extract_deep_features(image) |
|
|
hair_regions = self._enhance_with_deep_features(hair_regions, deep_features) |
|
|
|
|
|
|
|
|
if self.config.asymmetry_correction: |
|
|
asymmetry_info = self.asymmetry_detector.detect(hair_regions, image) |
|
|
if asymmetry_info['is_asymmetric']: |
|
|
logger.info(f"Correcting hair asymmetry: {asymmetry_info['score']:.3f}") |
|
|
hair_regions = self.asymmetry_detector.correct( |
|
|
hair_regions, asymmetry_info |
|
|
) |
|
|
|
|
|
|
|
|
strands_mask = None |
|
|
if self.config.strand_detection: |
|
|
strands_mask = self._detect_hair_strands(image, hair_regions) |
|
|
|
|
|
hair_regions = self._integrate_strands(hair_regions, strands_mask) |
|
|
|
|
|
|
|
|
refined_mask = self.mask_refiner.refine(image, hair_regions) |
|
|
|
|
|
|
|
|
edges = self.edge_enhancer.enhance(refined_mask, image) |
|
|
refined_mask = self._apply_edge_enhancement(refined_mask, edges) |
|
|
|
|
|
|
|
|
if self.config.alpha_matting: |
|
|
refined_mask = self._apply_alpha_matting(image, refined_mask) |
|
|
|
|
|
|
|
|
final_mask = self._final_smoothing(refined_mask) |
|
|
|
|
|
|
|
|
confidence = self._compute_confidence(final_mask, initial_mask) |
|
|
|
|
|
return { |
|
|
'mask': final_mask, |
|
|
'confidence': confidence, |
|
|
'strands': strands_mask, |
|
|
'edges': edges |
|
|
} |
|
|
|
|
|
def _detect_hair_regions(self, image: np.ndarray, |
|
|
initial_mask: Optional[np.ndarray]) -> np.ndarray: |
|
|
"""Detect hair regions using multiple cues.""" |
|
|
|
|
|
color_mask = self._detect_by_color(image) |
|
|
|
|
|
|
|
|
texture_mask = self._detect_by_texture(image) |
|
|
|
|
|
|
|
|
hair_probability = 0.6 * color_mask + 0.4 * texture_mask |
|
|
|
|
|
|
|
|
if initial_mask is not None: |
|
|
|
|
|
kernel = np.ones((15, 15), np.uint8) |
|
|
dilated_initial = cv2.dilate(initial_mask, kernel, iterations=2) |
|
|
hair_probability *= dilated_initial |
|
|
|
|
|
|
|
|
hair_mask = (hair_probability > self.config.min_hair_confidence).astype(np.float32) |
|
|
|
|
|
|
|
|
hair_mask = self._remove_small_regions(hair_mask) |
|
|
|
|
|
return hair_mask |
|
|
|
|
|
def _detect_by_color(self, image: np.ndarray) -> np.ndarray: |
|
|
"""Detect hair by color characteristics.""" |
|
|
|
|
|
hsv = cv2.cvtColor(image, cv2.COLOR_BGR2HSV) |
|
|
lab = cv2.cvtColor(image, cv2.COLOR_BGR2LAB) |
|
|
ycrcb = cv2.cvtColor(image, cv2.COLOR_BGR2YCrCb) |
|
|
|
|
|
masks = [] |
|
|
|
|
|
|
|
|
black_mask = cv2.inRange(hsv, (0, 0, 0), (180, 255, 30)) |
|
|
masks.append(black_mask) |
|
|
|
|
|
|
|
|
brown_mask = cv2.inRange(hsv, (10, 20, 20), (20, 255, 100)) |
|
|
masks.append(brown_mask) |
|
|
|
|
|
|
|
|
blonde_mask = cv2.inRange(hsv, (15, 30, 50), (25, 255, 200)) |
|
|
masks.append(blonde_mask) |
|
|
|
|
|
|
|
|
red_mask = cv2.inRange(hsv, (0, 50, 50), (10, 255, 150)) |
|
|
auburn_mask = cv2.inRange(hsv, (160, 50, 50), (180, 255, 150)) |
|
|
masks.append(cv2.bitwise_or(red_mask, auburn_mask)) |
|
|
|
|
|
|
|
|
gray_mask = cv2.inRange(hsv, (0, 0, 50), (180, 30, 200)) |
|
|
masks.append(gray_mask) |
|
|
|
|
|
|
|
|
combined = np.zeros_like(masks[0], dtype=np.float32) |
|
|
for mask in masks: |
|
|
combined = np.maximum(combined, mask.astype(np.float32) / 255.0) |
|
|
|
|
|
|
|
|
combined = cv2.GaussianBlur(combined, (7, 7), 2.0) |
|
|
|
|
|
return combined |
|
|
|
|
|
def _detect_by_texture(self, image: np.ndarray) -> np.ndarray: |
|
|
"""Detect hair by texture characteristics.""" |
|
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image |
|
|
|
|
|
|
|
|
texture_responses = [] |
|
|
|
|
|
|
|
|
for scale in [3, 5, 7]: |
|
|
for angle in [0, 30, 60, 90, 120, 150]: |
|
|
theta = np.deg2rad(angle) |
|
|
kernel = cv2.getGaborKernel( |
|
|
(21, 21), scale, theta, 10.0, 0.5, 0, ktype=cv2.CV_32F |
|
|
) |
|
|
response = cv2.filter2D(gray, cv2.CV_32F, kernel) |
|
|
texture_responses.append(np.abs(response)) |
|
|
|
|
|
|
|
|
texture_map = np.mean(texture_responses, axis=0) |
|
|
|
|
|
|
|
|
texture_map = (texture_map - np.min(texture_map)) / (np.max(texture_map) - np.min(texture_map) + 1e-6) |
|
|
|
|
|
|
|
|
|
|
|
coherence = self._compute_texture_coherence(texture_responses) |
|
|
|
|
|
|
|
|
hair_texture = texture_map * coherence |
|
|
|
|
|
return hair_texture |
|
|
|
|
|
def _compute_texture_coherence(self, responses: List[np.ndarray]) -> np.ndarray: |
|
|
"""Compute texture coherence (consistency of orientation).""" |
|
|
if len(responses) < 2: |
|
|
return np.ones_like(responses[0]) |
|
|
|
|
|
|
|
|
response_stack = np.stack(responses, axis=0) |
|
|
variance = np.var(response_stack, axis=0) |
|
|
mean = np.mean(response_stack, axis=0) + 1e-6 |
|
|
|
|
|
|
|
|
coherence = 1.0 - np.minimum(variance / mean, 1.0) |
|
|
|
|
|
return coherence |
|
|
|
|
|
def _detect_hair_strands(self, image: np.ndarray, |
|
|
hair_mask: np.ndarray) -> np.ndarray: |
|
|
"""Detect fine hair strands.""" |
|
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image |
|
|
|
|
|
|
|
|
edges = cv2.Canny(gray, 10, 30) |
|
|
|
|
|
|
|
|
lines = cv2.HoughLinesP( |
|
|
edges, 1, np.pi/180, threshold=20, |
|
|
minLineLength=10, maxLineGap=5 |
|
|
) |
|
|
|
|
|
|
|
|
strand_mask = np.zeros_like(gray, dtype=np.float32) |
|
|
|
|
|
if lines is not None: |
|
|
for line in lines: |
|
|
x1, y1, x2, y2 = line[0] |
|
|
|
|
|
|
|
|
mid_x, mid_y = (x1 + x2) // 2, (y1 + y2) // 2 |
|
|
|
|
|
|
|
|
kernel = np.ones((15, 15), np.uint8) |
|
|
dilated_hair = cv2.dilate(hair_mask, kernel, iterations=1) |
|
|
|
|
|
if dilated_hair[mid_y, mid_x] > 0: |
|
|
|
|
|
cv2.line(strand_mask, (x1, y1), (x2, y2), 1.0, self.config.strand_thickness) |
|
|
|
|
|
|
|
|
ridges = filters.frangi(gray, sigmas=range(1, 4)) |
|
|
ridges = (ridges - np.min(ridges)) / (np.max(ridges) - np.min(ridges) + 1e-6) |
|
|
|
|
|
|
|
|
strand_mask = np.maximum(strand_mask, ridges * dilated_hair) |
|
|
|
|
|
|
|
|
strand_mask = (strand_mask > 0.3).astype(np.float32) |
|
|
strand_mask = cv2.morphologyEx(strand_mask, cv2.MORPH_CLOSE, np.ones((3, 3))) |
|
|
|
|
|
return strand_mask |
|
|
|
|
|
def _integrate_strands(self, hair_mask: np.ndarray, |
|
|
strands_mask: np.ndarray) -> np.ndarray: |
|
|
"""Integrate detected strands into main hair mask.""" |
|
|
if strands_mask is None: |
|
|
return hair_mask |
|
|
|
|
|
|
|
|
integrated = np.maximum(hair_mask, strands_mask * 0.8) |
|
|
|
|
|
|
|
|
integrated = cv2.GaussianBlur(integrated, (5, 5), 1.0) |
|
|
|
|
|
return np.clip(integrated, 0, 1) |
|
|
|
|
|
def _extract_deep_features(self, image: np.ndarray) -> torch.Tensor: |
|
|
"""Extract deep features using neural network.""" |
|
|
if not self.deep_model: |
|
|
return None |
|
|
|
|
|
|
|
|
input_tensor = torch.from_numpy(image).permute(2, 0, 1).unsqueeze(0).float() / 255.0 |
|
|
|
|
|
|
|
|
with torch.no_grad(): |
|
|
features = self.deep_model.extract_features(input_tensor) |
|
|
|
|
|
return features |
|
|
|
|
|
def _enhance_with_deep_features(self, mask: np.ndarray, |
|
|
features: torch.Tensor) -> np.ndarray: |
|
|
"""Enhance mask using deep features.""" |
|
|
if features is None: |
|
|
return mask |
|
|
|
|
|
|
|
|
hair_prob = self.deep_model.process_features(features) |
|
|
hair_prob = hair_prob.squeeze().cpu().numpy() |
|
|
|
|
|
|
|
|
hair_prob = cv2.resize(hair_prob, (mask.shape[1], mask.shape[0])) |
|
|
|
|
|
|
|
|
enhanced = 0.7 * mask + 0.3 * hair_prob |
|
|
|
|
|
return np.clip(enhanced, 0, 1) |
|
|
|
|
|
def _apply_alpha_matting(self, image: np.ndarray, |
|
|
mask: np.ndarray) -> np.ndarray: |
|
|
"""Apply alpha matting for refined transparency.""" |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image |
|
|
gray = gray.astype(np.float32) / 255.0 |
|
|
|
|
|
|
|
|
radius = 20 |
|
|
epsilon = 0.01 |
|
|
|
|
|
alpha = self._guided_filter(mask, gray, radius, epsilon) |
|
|
|
|
|
return np.clip(alpha, 0, 1) |
|
|
|
|
|
def _guided_filter(self, p: np.ndarray, I: np.ndarray, |
|
|
radius: int, epsilon: float) -> np.ndarray: |
|
|
"""Guided filter implementation.""" |
|
|
mean_I = cv2.boxFilter(I, cv2.CV_32F, (radius, radius)) |
|
|
mean_p = cv2.boxFilter(p, cv2.CV_32F, (radius, radius)) |
|
|
mean_Ip = cv2.boxFilter(I * p, cv2.CV_32F, (radius, radius)) |
|
|
cov_Ip = mean_Ip - mean_I * mean_p |
|
|
|
|
|
mean_II = cv2.boxFilter(I * I, cv2.CV_32F, (radius, radius)) |
|
|
var_I = mean_II - mean_I * mean_I |
|
|
|
|
|
a = cov_Ip / (var_I + epsilon) |
|
|
b = mean_p - a * mean_I |
|
|
|
|
|
mean_a = cv2.boxFilter(a, cv2.CV_32F, (radius, radius)) |
|
|
mean_b = cv2.boxFilter(b, cv2.CV_32F, (radius, radius)) |
|
|
|
|
|
q = mean_a * I + mean_b |
|
|
|
|
|
return q |
|
|
|
|
|
def _apply_edge_enhancement(self, mask: np.ndarray, |
|
|
edges: np.ndarray) -> np.ndarray: |
|
|
"""Apply edge enhancement to mask.""" |
|
|
|
|
|
edge_weight = 0.3 |
|
|
enhanced = mask + edge_weight * edges |
|
|
|
|
|
return np.clip(enhanced, 0, 1) |
|
|
|
|
|
def _final_smoothing(self, mask: np.ndarray) -> np.ndarray: |
|
|
"""Apply final smoothing while preserving details.""" |
|
|
if self.config.preserve_details: |
|
|
|
|
|
smoothed = cv2.bilateralFilter( |
|
|
(mask * 255).astype(np.uint8), 9, 75, 75 |
|
|
) / 255.0 |
|
|
else: |
|
|
|
|
|
smoothed = cv2.GaussianBlur( |
|
|
mask, (5, 5), self.config.smoothing_sigma |
|
|
) |
|
|
|
|
|
return smoothed |
|
|
|
|
|
def _compute_confidence(self, mask: np.ndarray, |
|
|
initial_mask: Optional[np.ndarray]) -> np.ndarray: |
|
|
"""Compute confidence map for the segmentation.""" |
|
|
|
|
|
|
|
|
distance_from_middle = np.abs(mask - 0.5) * 2 |
|
|
confidence = distance_from_middle |
|
|
|
|
|
|
|
|
if initial_mask is not None: |
|
|
agreement = 1 - np.abs(mask - initial_mask) |
|
|
confidence = 0.7 * confidence + 0.3 * agreement |
|
|
|
|
|
return np.clip(confidence, 0, 1) |
|
|
|
|
|
def _remove_small_regions(self, mask: np.ndarray, |
|
|
min_size: int = 100) -> np.ndarray: |
|
|
"""Remove small disconnected regions.""" |
|
|
|
|
|
binary = (mask > 0.5).astype(np.uint8) |
|
|
|
|
|
|
|
|
num_labels, labels, stats, _ = cv2.connectedComponentsWithStats(binary) |
|
|
|
|
|
|
|
|
cleaned = np.zeros_like(mask) |
|
|
for i in range(1, num_labels): |
|
|
if stats[i, cv2.CC_STAT_AREA] >= min_size: |
|
|
cleaned[labels == i] = mask[labels == i] |
|
|
|
|
|
return cleaned |
|
|
|
|
|
|
|
|
class HairMaskRefiner: |
|
|
"""Refines hair masks for better quality.""" |
|
|
|
|
|
def __init__(self, config: HairConfig): |
|
|
self.config = config |
|
|
|
|
|
def refine(self, image: np.ndarray, mask: np.ndarray) -> np.ndarray: |
|
|
"""Refine hair mask through multiple iterations.""" |
|
|
refined = mask.copy() |
|
|
|
|
|
for iteration in range(self.config.refinement_iterations): |
|
|
|
|
|
refined = self._refine_iteration(image, refined, iteration) |
|
|
|
|
|
return refined |
|
|
|
|
|
def _refine_iteration(self, image: np.ndarray, mask: np.ndarray, |
|
|
iteration: int) -> np.ndarray: |
|
|
"""Single refinement iteration.""" |
|
|
|
|
|
kernel_size = 5 - iteration |
|
|
kernel = cv2.getStructuringElement( |
|
|
cv2.MORPH_ELLIPSE, (kernel_size, kernel_size) |
|
|
) |
|
|
|
|
|
|
|
|
refined = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, kernel) |
|
|
|
|
|
|
|
|
refined = cv2.morphologyEx(refined, cv2.MORPH_OPEN, kernel) |
|
|
|
|
|
|
|
|
refined = cv2.GaussianBlur(refined, (3, 3), 0.5) |
|
|
|
|
|
return refined |
|
|
|
|
|
|
|
|
class AsymmetryDetector: |
|
|
"""Detects and corrects asymmetry in hair masks.""" |
|
|
|
|
|
def __init__(self, config: HairConfig): |
|
|
self.config = config |
|
|
|
|
|
def detect(self, mask: np.ndarray, image: np.ndarray) -> Dict[str, Any]: |
|
|
"""Detect asymmetry in hair mask.""" |
|
|
h, w = mask.shape[:2] |
|
|
|
|
|
|
|
|
center_x = self._find_center_line(mask) |
|
|
|
|
|
|
|
|
left_mask = mask[:, :center_x] |
|
|
right_mask = mask[:, center_x:] |
|
|
|
|
|
|
|
|
min_width = min(left_mask.shape[1], right_mask.shape[1]) |
|
|
left_mask = left_mask[:, -min_width:] if left_mask.shape[1] > min_width else left_mask |
|
|
right_mask = right_mask[:, :min_width] if right_mask.shape[1] > min_width else right_mask |
|
|
|
|
|
|
|
|
right_flipped = np.fliplr(right_mask) |
|
|
|
|
|
|
|
|
pixel_diff = np.mean(np.abs(left_mask - right_flipped)) |
|
|
|
|
|
|
|
|
left_area = np.sum(left_mask > 0.5) |
|
|
right_area = np.sum(right_mask > 0.5) |
|
|
area_ratio = max(left_area, right_area) / (min(left_area, right_area) + 1e-6) |
|
|
|
|
|
|
|
|
left_edges = cv2.Canny((left_mask * 255).astype(np.uint8), 50, 150) |
|
|
right_edges = cv2.Canny((right_mask * 255).astype(np.uint8), 50, 150) |
|
|
right_edges_flipped = np.fliplr(right_edges) |
|
|
edge_diff = np.mean(np.abs(left_edges - right_edges_flipped)) / 255.0 |
|
|
|
|
|
|
|
|
asymmetry_score = 0.4 * pixel_diff + 0.3 * (area_ratio - 1.0) / 2.0 + 0.3 * edge_diff |
|
|
|
|
|
is_asymmetric = (asymmetry_score > self.config.symmetry_threshold or |
|
|
area_ratio > self.config.max_asymmetry_ratio) |
|
|
|
|
|
return { |
|
|
'is_asymmetric': is_asymmetric, |
|
|
'score': asymmetry_score, |
|
|
'center_x': center_x, |
|
|
'area_ratio': area_ratio, |
|
|
'pixel_diff': pixel_diff, |
|
|
'edge_diff': edge_diff |
|
|
} |
|
|
|
|
|
def correct(self, mask: np.ndarray, asymmetry_info: Dict[str, Any]) -> np.ndarray: |
|
|
"""Correct detected asymmetry.""" |
|
|
center_x = asymmetry_info['center_x'] |
|
|
h, w = mask.shape[:2] |
|
|
|
|
|
|
|
|
left_mask = mask[:, :center_x] |
|
|
right_mask = mask[:, center_x:] |
|
|
|
|
|
|
|
|
left_density = np.mean(left_mask > 0.5) |
|
|
right_density = np.mean(right_mask > 0.5) |
|
|
|
|
|
|
|
|
if left_density > right_density: |
|
|
|
|
|
reference = left_mask |
|
|
mirrored = np.fliplr(reference) |
|
|
|
|
|
|
|
|
corrected_right = 0.7 * mirrored[:, :right_mask.shape[1]] + 0.3 * right_mask |
|
|
|
|
|
|
|
|
corrected = np.zeros_like(mask) |
|
|
corrected[:, :center_x] = left_mask |
|
|
corrected[:, center_x:center_x + corrected_right.shape[1]] = corrected_right |
|
|
else: |
|
|
|
|
|
reference = right_mask |
|
|
mirrored = np.fliplr(reference) |
|
|
|
|
|
|
|
|
corrected_left = 0.7 * mirrored[:, -left_mask.shape[1]:] + 0.3 * left_mask |
|
|
|
|
|
|
|
|
corrected = np.zeros_like(mask) |
|
|
corrected[:, :center_x] = corrected_left |
|
|
corrected[:, center_x:] = right_mask |
|
|
|
|
|
|
|
|
seam_width = 10 |
|
|
seam_start = max(0, center_x - seam_width) |
|
|
seam_end = min(w, center_x + seam_width) |
|
|
corrected[:, seam_start:seam_end] = cv2.GaussianBlur( |
|
|
corrected[:, seam_start:seam_end], (7, 1), 2.0 |
|
|
) |
|
|
|
|
|
return corrected |
|
|
|
|
|
def _find_center_line(self, mask: np.ndarray) -> int: |
|
|
"""Find the vertical center line of the object.""" |
|
|
|
|
|
mask_binary = (mask > 0.5).astype(np.uint8) |
|
|
moments = cv2.moments(mask_binary) |
|
|
|
|
|
if moments['m00'] > 0: |
|
|
cx = int(moments['m10'] / moments['m00']) |
|
|
else: |
|
|
|
|
|
cx = mask.shape[1] // 2 |
|
|
|
|
|
return cx |
|
|
|
|
|
|
|
|
class HairEdgeEnhancer: |
|
|
"""Enhances edges in hair masks.""" |
|
|
|
|
|
def __init__(self, config: HairConfig): |
|
|
self.config = config |
|
|
|
|
|
def enhance(self, mask: np.ndarray, image: np.ndarray) -> np.ndarray: |
|
|
"""Enhance hair edges for better quality.""" |
|
|
|
|
|
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY) if len(image.shape) == 3 else image |
|
|
|
|
|
|
|
|
edges = self._multi_scale_edges(gray) |
|
|
|
|
|
|
|
|
mask_edges = cv2.Canny((mask * 255).astype(np.uint8), 30, 100) / 255.0 |
|
|
|
|
|
|
|
|
hair_edges = self._detect_hair_edges(gray, mask) |
|
|
|
|
|
|
|
|
combined_edges = np.maximum(edges * 0.3, np.maximum(mask_edges * 0.3, hair_edges * 0.4)) |
|
|
|
|
|
|
|
|
combined_edges = self._non_max_suppression(combined_edges) |
|
|
|
|
|
return combined_edges |
|
|
|
|
|
def _multi_scale_edges(self, gray: np.ndarray) -> np.ndarray: |
|
|
"""Detect edges at multiple scales.""" |
|
|
edges_list = [] |
|
|
|
|
|
for scale in [1, 2, 3]: |
|
|
|
|
|
if scale > 1: |
|
|
scaled = cv2.resize(gray, None, fx=1/scale, fy=1/scale) |
|
|
else: |
|
|
scaled = gray |
|
|
|
|
|
|
|
|
edges = cv2.Canny(scaled, 30 * scale, 80 * scale) |
|
|
|
|
|
|
|
|
if scale > 1: |
|
|
edges = cv2.resize(edges, (gray.shape[1], gray.shape[0])) |
|
|
|
|
|
edges_list.append(edges / 255.0) |
|
|
|
|
|
|
|
|
combined = np.mean(edges_list, axis=0) |
|
|
|
|
|
return combined |
|
|
|
|
|
def _detect_hair_edges(self, gray: np.ndarray, mask: np.ndarray) -> np.ndarray: |
|
|
"""Detect edges specific to hair texture.""" |
|
|
|
|
|
hair_edges = np.zeros_like(gray, dtype=np.float32) |
|
|
|
|
|
|
|
|
for angle in range(0, 180, 30): |
|
|
theta = np.deg2rad(angle) |
|
|
kernel = cv2.getGaborKernel( |
|
|
(11, 11), 3.0, theta, 8.0, 0.5, 0, ktype=cv2.CV_32F |
|
|
) |
|
|
|
|
|
filtered = cv2.filter2D(gray, cv2.CV_32F, kernel) |
|
|
hair_edges = np.maximum(hair_edges, np.abs(filtered)) |
|
|
|
|
|
|
|
|
hair_edges = hair_edges / (np.max(hair_edges) + 1e-6) |
|
|
|
|
|
|
|
|
hair_edges *= mask |
|
|
|
|
|
|
|
|
hair_edges = (hair_edges > self.config.edge_sensitivity * 0.5).astype(np.float32) |
|
|
|
|
|
return hair_edges |
|
|
|
|
|
def _non_max_suppression(self, edges: np.ndarray) -> np.ndarray: |
|
|
"""Apply non-maximum suppression to edges.""" |
|
|
|
|
|
dx = cv2.Sobel(edges, cv2.CV_32F, 1, 0, ksize=3) |
|
|
dy = cv2.Sobel(edges, cv2.CV_32F, 0, 1, ksize=3) |
|
|
|
|
|
|
|
|
magnitude = np.sqrt(dx**2 + dy**2) |
|
|
direction = np.arctan2(dy, dx) |
|
|
|
|
|
|
|
|
direction = np.rad2deg(direction) |
|
|
direction[direction < 0] += 180 |
|
|
|
|
|
|
|
|
suppressed = np.zeros_like(magnitude) |
|
|
|
|
|
for i in range(1, magnitude.shape[0] - 1): |
|
|
for j in range(1, magnitude.shape[1] - 1): |
|
|
angle = direction[i, j] |
|
|
mag = magnitude[i, j] |
|
|
|
|
|
|
|
|
if (0 <= angle < 22.5) or (157.5 <= angle <= 180): |
|
|
|
|
|
neighbors = [magnitude[i, j-1], magnitude[i, j+1]] |
|
|
elif 22.5 <= angle < 67.5: |
|
|
|
|
|
neighbors = [magnitude[i-1, j+1], magnitude[i+1, j-1]] |
|
|
elif 67.5 <= angle < 112.5: |
|
|
|
|
|
neighbors = [magnitude[i-1, j], magnitude[i+1, j]] |
|
|
else: |
|
|
|
|
|
neighbors = [magnitude[i-1, j-1], magnitude[i+1, j+1]] |
|
|
|
|
|
|
|
|
if mag >= max(neighbors): |
|
|
suppressed[i, j] = mag |
|
|
|
|
|
|
|
|
suppressed = suppressed / (np.max(suppressed) + 1e-6) |
|
|
|
|
|
return suppressed |
|
|
|
|
|
|
|
|
class HairNet(nn.Module): |
|
|
"""Simple neural network for hair feature extraction (placeholder).""" |
|
|
|
|
|
def __init__(self): |
|
|
super().__init__() |
|
|
|
|
|
self.encoder = nn.Sequential( |
|
|
nn.Conv2d(3, 32, 3, padding=1), |
|
|
nn.ReLU(), |
|
|
nn.MaxPool2d(2), |
|
|
nn.Conv2d(32, 64, 3, padding=1), |
|
|
nn.ReLU(), |
|
|
nn.MaxPool2d(2), |
|
|
nn.Conv2d(64, 128, 3, padding=1), |
|
|
nn.ReLU(), |
|
|
) |
|
|
|
|
|
self.decoder = nn.Sequential( |
|
|
nn.Conv2d(128, 64, 3, padding=1), |
|
|
nn.ReLU(), |
|
|
nn.Upsample(scale_factor=2), |
|
|
nn.Conv2d(64, 32, 3, padding=1), |
|
|
nn.ReLU(), |
|
|
nn.Upsample(scale_factor=2), |
|
|
nn.Conv2d(32, 1, 3, padding=1), |
|
|
nn.Sigmoid() |
|
|
) |
|
|
|
|
|
def extract_features(self, x: torch.Tensor) -> torch.Tensor: |
|
|
"""Extract features from input image.""" |
|
|
return self.encoder(x) |
|
|
|
|
|
def process_features(self, features: torch.Tensor) -> torch.Tensor: |
|
|
"""Process features to get hair probability.""" |
|
|
return self.decoder(features) |
|
|
|
|
|
def forward(self, x: torch.Tensor) -> torch.Tensor: |
|
|
"""Forward pass.""" |
|
|
features = self.extract_features(x) |
|
|
output = self.process_features(features) |
|
|
return output |
|
|
|
|
|
|
|
|
|
|
|
def visualize_hair_segmentation(image: np.ndarray, |
|
|
results: Dict[str, np.ndarray], |
|
|
save_path: Optional[str] = None) -> np.ndarray: |
|
|
"""Visualize hair segmentation results.""" |
|
|
h, w = image.shape[:2] |
|
|
|
|
|
|
|
|
viz = np.zeros((h * 2, w * 2, 3), dtype=np.uint8) |
|
|
|
|
|
|
|
|
viz[:h, :w] = image |
|
|
|
|
|
|
|
|
mask_colored = np.zeros_like(image) |
|
|
mask_colored[:, :, 1] = (results['mask'] * 255).astype(np.uint8) |
|
|
overlay = cv2.addWeighted(image, 0.7, mask_colored, 0.3, 0) |
|
|
viz[:h, w:] = overlay |
|
|
|
|
|
|
|
|
if 'confidence' in results: |
|
|
confidence_colored = cv2.applyColorMap( |
|
|
(results['confidence'] * 255).astype(np.uint8), |
|
|
cv2.COLORMAP_JET |
|
|
) |
|
|
viz[h:, :w] = confidence_colored |
|
|
|
|
|
|
|
|
if 'edges' in results and 'strands' in results: |
|
|
edges_viz = np.zeros_like(image) |
|
|
edges_viz[:, :, 2] = (results['edges'] * 255).astype(np.uint8) |
|
|
|
|
|
if results['strands'] is not None: |
|
|
edges_viz[:, :, 0] = (results['strands'] * 255).astype(np.uint8) |
|
|
|
|
|
viz[h:, w:] = edges_viz |
|
|
|
|
|
|
|
|
cv2.putText(viz, "Original", (10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2) |
|
|
cv2.putText(viz, "Hair Mask", (w + 10, 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2) |
|
|
cv2.putText(viz, "Confidence", (10, h + 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2) |
|
|
cv2.putText(viz, "Edges/Strands", (w + 10, h + 30), cv2.FONT_HERSHEY_SIMPLEX, 1, (255, 255, 255), 2) |
|
|
|
|
|
if save_path: |
|
|
cv2.imwrite(save_path, viz) |
|
|
|
|
|
return viz |
|
|
|
|
|
|
|
|
|
|
|
__all__ = [ |
|
|
'HairSegmentationPipeline', |
|
|
'HairConfig', |
|
|
'HairMaskRefiner', |
|
|
'AsymmetryDetector', |
|
|
'HairEdgeEnhancer', |
|
|
'HairNet', |
|
|
'visualize_hair_segmentation' |
|
|
] |