import cv2 import numpy as np from scipy.stats import skew from . import config def get_feature_names(): """ Returns the list of feature names in the exact order they are extracted by the pipeline. Used for Explainable AI plots. """ names = [] # 1. Color Stats (Mean, Std, Skew for B, G, R) # OpenCV loads images as BGR for c in ['Blue', 'Green', 'Red']: names.extend([f'{c}_Mean', f'{c}_Std', f'{c}_Skew']) # 2. Histogram (Bins for B, G, R) for c in ['Blue', 'Green', 'Red']: for i in range(config.HIST_BINS): names.append(f'{c}_Hist_Bin_{i}') # 3. Shape names.extend(['Area', 'Perimeter', 'Compactness']) # 4. Texture names.append('Texture_EdgeDensity') return names def center_crop_and_resize(img, target_size=224): """ Take the largest possible center square from the image (no distortion) Resize that square to (target_size x target_size) """ h, w = img.shape[:2] # Determine the size of the largest possible center square min_side = min(h, w) # Starting points for center crop start_x = (w - min_side) // 2 start_y = (h - min_side) // 2 # Perform center crop img_cropped = img[start_y:start_y + min_side, start_x:start_x + min_side] # Resize the center crop to target_size x target_size img_resized = cv2.resize( img_cropped, (target_size, target_size), interpolation=cv2.INTER_AREA if min_side > target_size else cv2.INTER_CUBIC ) return img_resized def preprocess_image(img): """Standardizes, Grayscale, CLAHE, and Blur.""" if img is None: return None, None, None, None img_resized = center_crop_and_resize(img, target_size=config.IMG_SIZE) img_gray = cv2.cvtColor(img_resized, cv2.COLOR_BGR2GRAY) clahe = cv2.createCLAHE(clipLimit=config.CLAHE_CLIP, tileGridSize=config.CLAHE_GRID) img_eq = clahe.apply(img_gray) img_blur = cv2.GaussianBlur(img_eq, config.BLUR_KERNEL, 0) return img_resized, img_gray, img_eq, img_blur def extract_color_stats(img, mask=None): """Calculates Mean, Std, Skew for R, G, B.""" stats = [] for i in range(3): channel = img[:, :, i] if mask is not None: pixels = channel[mask > 0] else: pixels = channel.flatten() if len(pixels) == 0: stats.extend([0, 0, 0]) else: stats.append(np.mean(pixels)) stats.append(np.std(pixels)) stats.append(skew(pixels)) return stats def extract_histogram_features(img, mask=None): """Calculates Color Histogram for lesion area.""" hist_features = [] for i in range(3): hist = cv2.calcHist([img], [i], mask, [config.HIST_BINS], [0, 256]) cv2.normalize(hist, hist) hist_features.extend(hist.flatten()) return hist_features def segment_lesion(img_blur): """Pipeline: Otsu Thresholding -> Open (Clean) -> Dilate (Connect).""" _, mask_raw = cv2.threshold(img_blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) kernel_open = cv2.getStructuringElement(cv2.MORPH_RECT, config.MORPH_OPEN_KERNEL) mask_clean = cv2.morphologyEx(mask_raw, cv2.MORPH_OPEN, kernel_open, iterations=2) kernel_dilate = cv2.getStructuringElement(cv2.MORPH_RECT, config.MORPH_DILATE_KERNEL) mask_connected = cv2.dilate(mask_clean, kernel_dilate, iterations=2) return mask_raw, mask_clean, mask_connected def isolate_largest_component(mask): """Filters all blobs except the largest one.""" contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) final_mask = np.zeros_like(mask) area, perimeter, compactness = 0, 0, 0 if contours: sorted_contours = sorted(contours, key=cv2.contourArea, reverse=True) cnt = sorted_contours[0] area = cv2.contourArea(cnt) img_area = mask.shape[0] * mask.shape[1] if 50 < area < (img_area * 0.95): cv2.drawContours(final_mask, [cnt], -1, 255, -1) perimeter = cv2.arcLength(cnt, True) if perimeter > 0: compactness = (4 * np.pi * area) / (perimeter ** 2) elif len(sorted_contours) > 1: cnt2 = sorted_contours[1] area2 = cv2.contourArea(cnt2) if area2 > 50: cv2.drawContours(final_mask, [cnt2], -1, 255, -1) area = area2 perimeter = cv2.arcLength(cnt2, True) if perimeter > 0: compactness = (4 * np.pi * area) / (perimeter ** 2) return final_mask, area, perimeter, compactness def compute_texture_canny(img_gray, mask=None): """Calculates texture score using Canny Edge Detection.""" edges = cv2.Canny(img_gray, 100, 200) if mask is not None: lesion_edges = edges[mask > 0] if len(lesion_edges) > 0: texture_score = np.mean(lesion_edges) else: texture_score = 0 else: texture_score = np.mean(edges) edges_vis = edges.copy() if mask is not None: edges_vis = cv2.bitwise_and(edges_vis, edges_vis, mask=mask) return texture_score, edges_vis def extract_all_features_pipeline(image_path_or_array): """Master Orchestrator.""" if isinstance(image_path_or_array, str): img = cv2.imread(image_path_or_array) else: img = image_path_or_array if img is None: return None # Preprocess img_resized, img_gray, img_eq, img_blur = preprocess_image(img) # Segmentation _, _, mask_connected = segment_lesion(img_blur) mask_final, area, perimeter, compactness = isolate_largest_component(mask_connected) # Texture texture_score, _ = compute_texture_canny(img_gray, mask=mask_final) features = [] # Color Analysis features.extend(extract_color_stats(img_resized, mask=mask_final)) features.extend(extract_histogram_features(img_resized, mask=mask_final)) # Shape features.extend([area, perimeter, compactness]) features.append(texture_score) return np.array(features)