Files
Vase-Matcher/pipeline/texture_match.py
2026-08-04 17:09:29 +05:30

135 lines
5.1 KiB
Python

"""
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)