135 lines
5.1 KiB
Python
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)
|