comvis / features.py
Jennigwen's picture
deploy BE ke huggingface
5c22e5c
Raw
History Blame Contribute Delete
6.99 kB
"""
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