| """ |
| features.py β 120-dimensional handcrafted CV feature extraction. |
| |
| Pipeline (matches training notebook exactly): |
| preprocess(img) β get_mask(img) β extract_features(img, mask) |
| |
| Feature vector layout: |
| [0:24] GLCM texture (6 props Γ 4 angles) |
| [24:50] LBP texture (26-bin uniform histogram, P=24 R=3) |
| [50:74] Gabor texture (4 freqs Γ 3 orientations, mean+std) |
| [74:106] Colour histogram (16H + 8S + 8V, normalised, within mask) |
| [106:115] Colour moments (mean, std, skewness per HSV channel) |
| [115:120] ABCD morphology (asymmetry, compactness, cvar, diam, elong) |
| Total: 120 dimensions |
| """ |
|
|
| import numpy as np |
| import cv2 |
| from skimage.feature import graycomatrix, graycoprops, local_binary_pattern |
|
|
|
|
| |
|
|
| def preprocess(img_bgr: np.ndarray, size: tuple = (256, 256)) -> np.ndarray: |
| """Hair removal (black-hat) β Gaussian blur β CLAHE β resize.""" |
| gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) |
| kernel = cv2.getStructuringElement(cv2.MORPH_RECT, (17, 17)) |
| bhat = cv2.morphologyEx(gray, cv2.MORPH_BLACKHAT, kernel) |
| _, hmask = cv2.threshold(bhat, 10, 255, cv2.THRESH_BINARY) |
| img_bgr = cv2.inpaint(img_bgr, hmask, 3, cv2.INPAINT_TELEA) |
| img_bgr = cv2.GaussianBlur(img_bgr, (5, 5), 0) |
| lab = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2LAB) |
| clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8)) |
| lab[:, :, 0] = clahe.apply(lab[:, :, 0]) |
| img_bgr = cv2.cvtColor(lab, cv2.COLOR_LAB2BGR) |
| return cv2.resize(img_bgr, size, interpolation=cv2.INTER_AREA) |
|
|
|
|
| def get_mask(img_bgr: np.ndarray) -> np.ndarray: |
| """Otsu thresholding + morphological refinement to isolate lesion.""" |
| gray = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2GRAY) |
| blur = cv2.GaussianBlur(gray, (7, 7), 0) |
| _, mask = cv2.threshold(blur, 0, 255, cv2.THRESH_BINARY_INV + cv2.THRESH_OTSU) |
| k = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (15, 15)) |
| mask = cv2.morphologyEx(mask, cv2.MORPH_CLOSE, k) |
| mask = cv2.morphologyEx( |
| mask, cv2.MORPH_OPEN, |
| cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (5, 5)) |
| ) |
| return mask |
|
|
|
|
| |
|
|
| def feat_glcm(gray: np.ndarray) -> np.ndarray: |
| """24-dim GLCM at 4 angles Γ 6 properties.""" |
| glcm = graycomatrix( |
| gray, distances=[1], |
| angles=[0, np.pi / 4, np.pi / 2, 3 * np.pi / 4], |
| levels=256, symmetric=True, normed=True |
| ) |
| props = ["contrast", "correlation", "energy", "homogeneity", "dissimilarity", "ASM"] |
| return np.concatenate([graycoprops(glcm, p).flatten() for p in props]) |
|
|
|
|
| def feat_lbp(gray: np.ndarray) -> np.ndarray: |
| """26-dim LBP histogram β rotation-invariant uniform, P=24 R=3.""" |
| lbp = local_binary_pattern(gray, P=24, R=3, method="uniform") |
| hist, _ = np.histogram(lbp.ravel(), bins=26, range=(0, 26), density=True) |
| return hist.astype(np.float32) |
|
|
|
|
| def feat_gabor(gray: np.ndarray) -> np.ndarray: |
| """24-dim Gabor responses at 4 scales Γ 3 orientations (mean + std each).""" |
| feats = [] |
| for freq in [0.1, 0.2, 0.3, 0.4]: |
| for theta in [0, np.pi / 3, 2 * np.pi / 3]: |
| kernel = cv2.getGaborKernel( |
| (21, 21), sigma=4.0, theta=theta, |
| lambd=1.0 / freq, gamma=0.5, psi=0 |
| ) |
| resp = cv2.filter2D(gray.astype(np.float32), cv2.CV_32F, kernel) |
| feats.extend([resp.mean(), resp.std()]) |
| return np.array(feats, dtype=np.float32) |
|
|
|
|
| def feat_colour(img_bgr: np.ndarray, mask: np.ndarray) -> np.ndarray: |
| """32-dim normalised HSV histogram within lesion mask (16H + 8S + 8V).""" |
| hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) |
| m = (mask > 0).astype(np.uint8) * 255 |
| h = cv2.calcHist([hsv], [0], m, [16], [0, 180]).flatten() |
| s = cv2.calcHist([hsv], [1], m, [8], [0, 256]).flatten() |
| v = cv2.calcHist([hsv], [2], m, [8], [0, 256]).flatten() |
| norm = lambda x: x / (x.sum() + 1e-8) |
| return np.concatenate([norm(h), norm(s), norm(v)]).astype(np.float32) |
|
|
|
|
| def feat_colour_moments(img_bgr: np.ndarray, mask: np.ndarray) -> np.ndarray: |
| """9-dim colour moments (mean, std, skewness) per HSV channel.""" |
| hsv = cv2.cvtColor(img_bgr, cv2.COLOR_BGR2HSV) |
| feats = [] |
| for ch in range(3): |
| pixels = hsv[:, :, ch][mask > 0].astype(float) |
| if pixels.size == 0: |
| feats.extend([0.0, 0.0, 0.0]) |
| continue |
| mu = pixels.mean() |
| sigma = pixels.std() + 1e-8 |
| skew = float(np.mean(((pixels - mu) / sigma) ** 3)) |
| feats.extend([mu, sigma, skew]) |
| return np.array(feats, dtype=np.float32) |
|
|
|
|
| def feat_abcd(mask: np.ndarray, hsv: np.ndarray) -> np.ndarray: |
| """5-dim ABCD dermoscopy features (asymmetry, compactness, cvar, diam, elong).""" |
| cnts, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) |
| if not cnts: |
| return np.zeros(5, dtype=np.float32) |
| cnt = max(cnts, key=cv2.contourArea) |
| area = cv2.contourArea(cnt) + 1e-6 |
| perim = cv2.arcLength(cnt, True) + 1e-6 |
| if len(cnt) >= 5: |
| axes_sorted = sorted(cv2.fitEllipse(cnt)[1]) |
| asym = axes_sorted[0] / (axes_sorted[1] + 1e-6) |
| else: |
| asym = 1.0 |
| comp = (4 * np.pi * area) / (perim ** 2) |
| hue_pixels = hsv[:, :, 0][mask > 0] |
| cvar = float(hue_pixels.std()) if hue_pixels.size else 0.0 |
| diam = np.sqrt(4 * area / np.pi) |
| elong = perim / (2 * np.sqrt(np.pi * area) + 1e-6) |
| return np.array([asym, comp, cvar, diam, elong], dtype=np.float32) |
|
|
|
|
| |
|
|
| def extract_features(img_bgr: np.ndarray) -> np.ndarray: |
| """ |
| Full pipeline: preprocess β mask β 120D feature vector. |
| |
| Input : BGR image (any size) |
| Output: float32 ndarray of shape (120,) |
| """ |
| proc = preprocess(img_bgr) |
| mask = get_mask(proc) |
| gray = cv2.cvtColor(proc, cv2.COLOR_BGR2GRAY) |
| hsv = cv2.cvtColor(proc, cv2.COLOR_BGR2HSV) |
|
|
| vec = np.concatenate([ |
| feat_glcm(gray), |
| feat_lbp(gray), |
| feat_gabor(gray), |
| feat_colour(proc, mask), |
| feat_colour_moments(proc, mask), |
| feat_abcd(mask, hsv), |
| ]).astype(np.float32) |
|
|
| assert vec.shape == (120,), f"Expected 120 dims, got {vec.shape}" |
| return vec |
|
|