File size: 6,986 Bytes
5c22e5c
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
"""
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


# ── Preprocessing ──────────────────────────────────────────────────────────────

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


# ── Feature groups ─────────────────────────────────────────────────────────────

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)


# ── Full 120D extraction ───────────────────────────────────────────────────────

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)                         # 256Γ—256 BGR
    mask = get_mask(proc)                              # binary mask
    gray = cv2.cvtColor(proc, cv2.COLOR_BGR2GRAY)
    hsv  = cv2.cvtColor(proc, cv2.COLOR_BGR2HSV)

    vec = np.concatenate([
        feat_glcm(gray),                               # 24
        feat_lbp(gray),                                # 26
        feat_gabor(gray),                              # 24
        feat_colour(proc, mask),                       # 32
        feat_colour_moments(proc, mask),               # 9
        feat_abcd(mask, hsv),                          # 5
    ]).astype(np.float32)

    assert vec.shape == (120,), f"Expected 120 dims, got {vec.shape}"
    return vec