Add project files
This commit is contained in:
134
pipeline/texture_match.py
Normal file
134
pipeline/texture_match.py
Normal file
@@ -0,0 +1,134 @@
|
||||
"""
|
||||
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)
|
||||
Reference in New Issue
Block a user