|
|
""" |
|
|
Texture feature extraction for the Sorghum Pipeline. |
|
|
|
|
|
This module handles extraction of texture features including: |
|
|
- Local Binary Patterns (LBP) |
|
|
- Histogram of Oriented Gradients (HOG) |
|
|
- Lacunarity features |
|
|
- Edge Histogram Descriptor (EHD) |
|
|
""" |
|
|
|
|
|
import numpy as np |
|
|
import cv2 |
|
|
import torch |
|
|
import torch.nn.functional as F |
|
|
from skimage.feature import local_binary_pattern, hog |
|
|
from skimage import exposure |
|
|
from scipy import ndimage, signal |
|
|
from sklearn.decomposition import PCA |
|
|
from typing import Dict, Tuple, Optional, Any |
|
|
import logging |
|
|
|
|
|
logger = logging.getLogger(__name__) |
|
|
|
|
|
|
|
|
class TextureExtractor: |
|
|
"""Extracts texture features from images.""" |
|
|
|
|
|
def __init__(self, |
|
|
lbp_points: int = 8, |
|
|
lbp_radius: int = 1, |
|
|
hog_orientations: int = 9, |
|
|
hog_pixels_per_cell: Tuple[int, int] = (8, 8), |
|
|
hog_cells_per_block: Tuple[int, int] = (2, 2), |
|
|
lacunarity_window: int = 15, |
|
|
ehd_threshold: float = 0.3, |
|
|
angle_resolution: int = 45): |
|
|
""" |
|
|
Initialize texture extractor. |
|
|
|
|
|
Args: |
|
|
lbp_points: Number of points for LBP |
|
|
lbp_radius: Radius for LBP |
|
|
hog_orientations: Number of orientations for HOG |
|
|
hog_pixels_per_cell: Pixels per cell for HOG |
|
|
hog_cells_per_block: Cells per block for HOG |
|
|
lacunarity_window: Window size for lacunarity |
|
|
ehd_threshold: Threshold for EHD |
|
|
angle_resolution: Angle resolution for EHD |
|
|
""" |
|
|
self.lbp_points = lbp_points |
|
|
self.lbp_radius = lbp_radius |
|
|
self.hog_orientations = hog_orientations |
|
|
self.hog_pixels_per_cell = hog_pixels_per_cell |
|
|
self.hog_cells_per_block = hog_cells_per_block |
|
|
self.lacunarity_window = lacunarity_window |
|
|
self.ehd_threshold = ehd_threshold |
|
|
self.angle_resolution = angle_resolution |
|
|
|
|
|
def extract_lbp(self, gray_image: np.ndarray) -> np.ndarray: |
|
|
""" |
|
|
Extract Local Binary Pattern features. |
|
|
|
|
|
Args: |
|
|
gray_image: Grayscale input image |
|
|
|
|
|
Returns: |
|
|
LBP feature map |
|
|
""" |
|
|
try: |
|
|
lbp = local_binary_pattern( |
|
|
gray_image, |
|
|
self.lbp_points, |
|
|
self.lbp_radius, |
|
|
method='uniform' |
|
|
) |
|
|
return self._convert_to_uint8(lbp) |
|
|
except Exception as e: |
|
|
logger.error(f"LBP extraction failed: {e}") |
|
|
return np.zeros_like(gray_image, dtype=np.uint8) |
|
|
|
|
|
def extract_hog(self, gray_image: np.ndarray) -> np.ndarray: |
|
|
""" |
|
|
Extract Histogram of Oriented Gradients features. |
|
|
|
|
|
Args: |
|
|
gray_image: Grayscale input image |
|
|
|
|
|
Returns: |
|
|
HOG feature map |
|
|
""" |
|
|
try: |
|
|
_, vis = hog( |
|
|
gray_image, |
|
|
orientations=self.hog_orientations, |
|
|
pixels_per_cell=self.hog_pixels_per_cell, |
|
|
cells_per_block=self.hog_cells_per_block, |
|
|
visualize=True, |
|
|
feature_vector=True |
|
|
) |
|
|
return exposure.rescale_intensity(vis, out_range=(0, 255)).astype(np.uint8) |
|
|
except Exception as e: |
|
|
logger.error(f"HOG extraction failed: {e}") |
|
|
return np.zeros_like(gray_image, dtype=np.uint8) |
|
|
|
|
|
def compute_local_lacunarity(self, gray_image: np.ndarray, window_size: int) -> np.ndarray: |
|
|
""" |
|
|
Compute local lacunarity. |
|
|
|
|
|
Args: |
|
|
gray_image: Grayscale input image |
|
|
window_size: Size of the sliding window |
|
|
|
|
|
Returns: |
|
|
Local lacunarity map |
|
|
""" |
|
|
try: |
|
|
arr = gray_image.astype(np.float32) |
|
|
m1 = ndimage.uniform_filter(arr, size=window_size) |
|
|
m2 = ndimage.uniform_filter(arr * arr, size=window_size) |
|
|
var = m2 - m1 * m1 |
|
|
eps = 1e-6 |
|
|
lac = var / (m1 * m1 + eps) + 1 |
|
|
lac[m1 <= eps] = 0 |
|
|
return lac |
|
|
except Exception as e: |
|
|
logger.error(f"Local lacunarity computation failed: {e}") |
|
|
return np.zeros_like(gray_image, dtype=np.float32) |
|
|
|
|
|
def compute_lacunarity_features(self, gray_image: np.ndarray) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: |
|
|
""" |
|
|
Compute three types of lacunarity features. |
|
|
|
|
|
Args: |
|
|
gray_image: Grayscale input image |
|
|
|
|
|
Returns: |
|
|
Tuple of (lac1, lac2, lac3) lacunarity maps |
|
|
""" |
|
|
try: |
|
|
|
|
|
lac1 = self.compute_local_lacunarity(gray_image, self.lacunarity_window) |
|
|
|
|
|
|
|
|
scales = [max(3, self.lacunarity_window//2), self.lacunarity_window, self.lacunarity_window*2] |
|
|
lac2 = np.mean([ |
|
|
self.compute_local_lacunarity(gray_image, s) for s in scales |
|
|
], axis=0) |
|
|
|
|
|
|
|
|
try: |
|
|
from ..models.dbc_lacunarity import DBC_Lacunarity |
|
|
x = torch.from_numpy(gray_image.astype(np.float32)/255.0)[None, None] |
|
|
layer = DBC_Lacunarity(window_size=self.lacunarity_window).eval() |
|
|
with torch.no_grad(): |
|
|
lac3 = layer(x).squeeze().cpu().numpy() |
|
|
except ImportError: |
|
|
logger.warning("DBC Lacunarity not available, using L2 as L3") |
|
|
lac3 = lac2.copy() |
|
|
|
|
|
return ( |
|
|
self._convert_to_uint8(lac1), |
|
|
self._convert_to_uint8(lac2), |
|
|
self._convert_to_uint8(lac3) |
|
|
) |
|
|
except Exception as e: |
|
|
logger.error(f"Lacunarity features computation failed: {e}") |
|
|
empty = np.zeros_like(gray_image, dtype=np.uint8) |
|
|
return empty, empty, empty |
|
|
|
|
|
def generate_ehd_masks(self, mask_size: int = 3) -> np.ndarray: |
|
|
""" |
|
|
Generate masks for Edge Histogram Descriptor. |
|
|
|
|
|
Args: |
|
|
mask_size: Size of the mask |
|
|
|
|
|
Returns: |
|
|
Array of EHD masks |
|
|
""" |
|
|
if mask_size < 3: |
|
|
mask_size = 3 |
|
|
if mask_size % 2 == 0: |
|
|
mask_size += 1 |
|
|
|
|
|
|
|
|
Gy = np.outer([1, 0, -1], [1, 2, 1]) |
|
|
|
|
|
|
|
|
if mask_size > 3: |
|
|
expd = np.outer([1, 2, 1], [1, 2, 1]) |
|
|
for _ in range((mask_size - 3) // 2): |
|
|
Gy = signal.convolve2d(expd, Gy, mode='full') |
|
|
|
|
|
|
|
|
angles = np.arange(0, 360, self.angle_resolution) |
|
|
masks = np.zeros((len(angles), mask_size, mask_size), dtype=np.float32) |
|
|
|
|
|
for i, angle in enumerate(angles): |
|
|
masks[i] = ndimage.rotate(Gy, angle, reshape=False, mode='nearest') |
|
|
|
|
|
return masks |
|
|
|
|
|
def extract_ehd_features(self, gray_image: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: |
|
|
""" |
|
|
Extract Edge Histogram Descriptor features. |
|
|
|
|
|
Args: |
|
|
gray_image: Grayscale input image |
|
|
|
|
|
Returns: |
|
|
Tuple of (ehd_features, ehd_map) |
|
|
""" |
|
|
try: |
|
|
|
|
|
masks = self.generate_ehd_masks() |
|
|
|
|
|
|
|
|
X = torch.from_numpy(gray_image.astype(np.float32)/255.0).unsqueeze(0).unsqueeze(0) |
|
|
masks_tensor = torch.tensor(masks).unsqueeze(1).float() |
|
|
|
|
|
|
|
|
edge_responses = F.conv2d(X, masks_tensor, dilation=7) |
|
|
|
|
|
|
|
|
values, indices = torch.max(edge_responses, dim=1) |
|
|
indices[values < self.ehd_threshold] = masks.shape[0] |
|
|
|
|
|
|
|
|
feat_vect = [] |
|
|
for edge in range(masks.shape[0] + 1): |
|
|
pooled = F.avg_pool2d( |
|
|
(indices == edge).unsqueeze(1).float(), |
|
|
kernel_size=5, stride=1, padding=2 |
|
|
) |
|
|
feat_vect.append(pooled.squeeze(1)) |
|
|
|
|
|
ehd_features = torch.stack(feat_vect, dim=1).squeeze(0).cpu().numpy() |
|
|
ehd_map = np.argmax(ehd_features, axis=0).astype(np.uint8) |
|
|
|
|
|
return ehd_features, ehd_map |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"EHD features extraction failed: {e}") |
|
|
empty_features = np.zeros((9, gray_image.shape[0]-4, gray_image.shape[1]-4), dtype=np.float32) |
|
|
empty_map = np.zeros_like(gray_image, dtype=np.uint8) |
|
|
return empty_features, empty_map |
|
|
|
|
|
def extract_all_texture_features(self, gray_image: np.ndarray) -> Dict[str, np.ndarray]: |
|
|
""" |
|
|
Extract all texture features from a grayscale image. |
|
|
|
|
|
Args: |
|
|
gray_image: Grayscale input image |
|
|
|
|
|
Returns: |
|
|
Dictionary of texture features |
|
|
""" |
|
|
features = {} |
|
|
|
|
|
try: |
|
|
|
|
|
features['lbp'] = self.extract_lbp(gray_image) |
|
|
|
|
|
|
|
|
features['hog'] = self.extract_hog(gray_image) |
|
|
|
|
|
|
|
|
lac1, lac2, lac3 = self.compute_lacunarity_features(gray_image) |
|
|
features['lac1'] = lac1 |
|
|
features['lac2'] = lac2 |
|
|
features['lac3'] = lac3 |
|
|
|
|
|
|
|
|
ehd_features, ehd_map = self.extract_ehd_features(gray_image) |
|
|
features['ehd_features'] = ehd_features |
|
|
features['ehd_map'] = ehd_map |
|
|
|
|
|
logger.debug("All texture features extracted successfully") |
|
|
|
|
|
except Exception as e: |
|
|
logger.error(f"Texture feature extraction failed: {e}") |
|
|
|
|
|
features = { |
|
|
'lbp': np.zeros_like(gray_image, dtype=np.uint8), |
|
|
'hog': np.zeros_like(gray_image, dtype=np.uint8), |
|
|
'lac1': np.zeros_like(gray_image, dtype=np.uint8), |
|
|
'lac2': np.zeros_like(gray_image, dtype=np.uint8), |
|
|
'lac3': np.zeros_like(gray_image, dtype=np.uint8), |
|
|
'ehd_features': np.zeros((9, gray_image.shape[0]-4, gray_image.shape[1]-4), dtype=np.float32), |
|
|
'ehd_map': np.zeros_like(gray_image, dtype=np.uint8) |
|
|
} |
|
|
|
|
|
return features |
|
|
|
|
|
def _convert_to_uint8(self, arr: np.ndarray) -> np.ndarray: |
|
|
"""Convert array to uint8 with proper normalization.""" |
|
|
arr = np.nan_to_num(arr, nan=0.0, posinf=0.0, neginf=0.0) |
|
|
if arr.ptp() > 0: |
|
|
normalized = (arr - arr.min()) / (arr.ptp() + 1e-6) * 255 |
|
|
else: |
|
|
normalized = np.zeros_like(arr) |
|
|
return np.clip(normalized, 0, 255).astype(np.uint8) |
|
|
|
|
|
def compute_texture_statistics(self, features: Dict[str, np.ndarray], |
|
|
mask: Optional[np.ndarray] = None) -> Dict[str, Dict[str, float]]: |
|
|
""" |
|
|
Compute statistics for texture features. |
|
|
|
|
|
Args: |
|
|
features: Dictionary of texture features |
|
|
mask: Optional mask to apply |
|
|
|
|
|
Returns: |
|
|
Dictionary of feature statistics |
|
|
""" |
|
|
stats = {} |
|
|
|
|
|
for feature_name, feature_data in features.items(): |
|
|
if feature_name == 'ehd_features': |
|
|
|
|
|
if mask is not None: |
|
|
|
|
|
masked_features = [] |
|
|
for i in range(feature_data.shape[0]): |
|
|
channel = feature_data[i] |
|
|
if mask.shape != channel.shape: |
|
|
|
|
|
mask_resized = cv2.resize(mask, (channel.shape[1], channel.shape[0]), |
|
|
interpolation=cv2.INTER_NEAREST) |
|
|
masked_channel = np.where(mask_resized > 0, channel, np.nan) |
|
|
else: |
|
|
masked_channel = np.where(mask > 0, channel, np.nan) |
|
|
masked_features.append(masked_channel) |
|
|
feature_data = np.stack(masked_features, axis=0) |
|
|
else: |
|
|
feature_data = feature_data |
|
|
|
|
|
|
|
|
channel_stats = {} |
|
|
for i in range(feature_data.shape[0]): |
|
|
channel = feature_data[i] |
|
|
valid_data = channel[~np.isnan(channel)] |
|
|
if len(valid_data) > 0: |
|
|
channel_stats[f'channel_{i}'] = { |
|
|
'mean': float(np.mean(valid_data)), |
|
|
'std': float(np.std(valid_data)), |
|
|
'min': float(np.min(valid_data)), |
|
|
'max': float(np.max(valid_data)), |
|
|
'median': float(np.median(valid_data)) |
|
|
} |
|
|
stats[feature_name] = channel_stats |
|
|
else: |
|
|
|
|
|
if mask is not None and mask.shape == feature_data.shape: |
|
|
masked_data = np.where(mask > 0, feature_data, np.nan) |
|
|
else: |
|
|
masked_data = feature_data |
|
|
|
|
|
valid_data = masked_data[~np.isnan(masked_data)] |
|
|
if len(valid_data) > 0: |
|
|
stats[feature_name] = { |
|
|
'mean': float(np.mean(valid_data)), |
|
|
'std': float(np.std(valid_data)), |
|
|
'min': float(np.min(valid_data)), |
|
|
'max': float(np.max(valid_data)), |
|
|
'median': float(np.median(valid_data)) |
|
|
} |
|
|
else: |
|
|
stats[feature_name] = { |
|
|
'mean': 0.0, 'std': 0.0, 'min': 0.0, 'max': 0.0, 'median': 0.0 |
|
|
} |
|
|
|
|
|
return stats |
|
|
|