""" Texture matching: compares surface/material texture -- independent of color and of overall silhouette shape. Two standard, complementary classical texture descriptors (no deep learning needed): - Local Binary Patterns (LBP): encodes each pixel's local micro-pattern relative to its neighbors, compared as a histogram (same convention as pipeline/color.py's hue/saturation histogram) -- good at catching fine, repetitive patterns like fabric weave or petal grain. - GLCM (gray-level co-occurrence matrix) / Haralick features (contrast, homogeneity, energy, correlation) -- coarser statistical texture properties, good at catching smooth-vs-rough, uniform-vs-busy material differences that a local pattern histogram alone can miss. Purely informational, its own section: never feeds into any score. """ import logging import cv2 import numpy as np from skimage.feature import graycomatrix, graycoprops, local_binary_pattern import config logger = logging.getLogger(__name__) _LBP_BINS = config.TEXTURE_LBP_POINTS + 2 # "uniform" LBP yields P+2 distinct codes _template_texture_data = {} # name -> {"lbp_hist", "glcm_features", "lbp_image", "mask_bool"} def _masked_gray(bgr, mask): gray = cv2.cvtColor(bgr, cv2.COLOR_BGR2GRAY) return gray, mask > 0 def _compute_lbp(gray, mask_bool): lbp_image = local_binary_pattern(gray, config.TEXTURE_LBP_POINTS, config.TEXTURE_LBP_RADIUS, method="uniform") values = lbp_image[mask_bool] if values.size == 0: return lbp_image, np.zeros(_LBP_BINS, dtype=np.float64) hist, _ = np.histogram(values, bins=_LBP_BINS, range=(0, _LBP_BINS), density=True) return lbp_image, hist def _compute_glcm_features(gray, mask_bool): ys, xs = np.where(mask_bool) if len(ys) == 0: return {p: 0.0 for p in config.TEXTURE_GLCM_PROPS} y0, y1, x0, x1 = ys.min(), ys.max() + 1, xs.min(), xs.max() + 1 crop_gray = gray[y0:y1, x0:x1].copy() crop_mask = mask_bool[y0:y1, x0:x1] levels = config.TEXTURE_GLCM_LEVELS quantized = (crop_gray.astype(np.float32) / 256 * levels).astype(np.uint8) quantized[~crop_mask] = 0 # background -> level 0, excluded from GLCM below angles = (0, np.pi / 4, np.pi / 2, 3 * np.pi / 4) glcm = graycomatrix(quantized, distances=list(config.TEXTURE_GLCM_DISTANCES), angles=list(angles), levels=levels, symmetric=True, normed=True) # Exclude any co-occurrence touching the masked-out background level. glcm[0, :, :, :] = 0 glcm[:, 0, :, :] = 0 total = glcm.sum() if total > 0: glcm = glcm / total return {p: float(np.mean(graycoprops(glcm, p))) for p in config.TEXTURE_GLCM_PROPS} def compute_texture_data(bgr, mask): gray, mask_bool = _masked_gray(bgr, mask) lbp_image, lbp_hist = _compute_lbp(gray, mask_bool) glcm_features = _compute_glcm_features(gray, mask_bool) return { "lbp_hist": lbp_hist, "glcm_features": glcm_features, "lbp_image": lbp_image, "mask_bool": mask_bool, } def set_template_texture_data(name, bgr, mask): _template_texture_data[name] = compute_texture_data(bgr, mask) def _hist_similarity_pct(hist_a, hist_b): # Histogram intersection, same convention as pipeline/color.py. return round(float(np.minimum(hist_a, hist_b).sum()) * 100, 1) def _glcm_similarity_pct(features_a, features_b): sims = [] for prop in config.TEXTURE_GLCM_PROPS: a, b = features_a[prop], features_b[prop] scale = max(abs(a), abs(b), 1e-9) sims.append(max(0.0, 1 - abs(a - b) / scale)) return round(float(np.mean(sims)) * 100, 1) def compare_to_templates(bgr, mask): """Returns (input_texture_data, ranked_results) -- results sorted by match_pct descending, one entry per template with the LBP-based and GLCM-based sub-scores (plus the raw GLCM features) broken out too.""" input_data = compute_texture_data(bgr, mask) results = [] for name, tdata in _template_texture_data.items(): lbp_sim = _hist_similarity_pct(input_data["lbp_hist"], tdata["lbp_hist"]) glcm_sim = _glcm_similarity_pct(input_data["glcm_features"], tdata["glcm_features"]) match_pct = round((lbp_sim + glcm_sim) / 2, 1) results.append({ "template": name, "match_pct": match_pct, "lbp_similarity_pct": lbp_sim, "glcm_similarity_pct": glcm_sim, "input_glcm_features": {p: round(v, 3) for p, v in input_data["glcm_features"].items()}, "template_glcm_features": {p: round(v, 3) for p, v in tdata["glcm_features"].items()}, }) results.sort(key=lambda r: r["match_pct"], reverse=True) return input_data, results def render_lbp_visual(lbp_image, mask_bool): """Normalizes the LBP code map to a viewable grayscale image, masked to the foreground only, for the side-by-side visual comparison.""" norm = cv2.normalize(lbp_image, None, 0, 255, cv2.NORM_MINMAX).astype(np.uint8) out = np.zeros_like(norm) out[mask_bool] = norm[mask_bool] return cv2.cvtColor(out, cv2.COLOR_GRAY2BGR)