275 lines
9.2 KiB
Python
275 lines
9.2 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Utility functions for image processing GUI.
|
|
"""
|
|
|
|
from PyQt5.QtGui import QPixmap
|
|
from PyQt5.QtCore import Qt
|
|
from matplotlib.backends.backend_qt5agg import FigureCanvasQTAgg as FigureCanvas
|
|
from matplotlib.figure import Figure
|
|
from PIL import Image
|
|
import numpy as np
|
|
from concurrent.futures import ThreadPoolExecutor
|
|
|
|
def qpixmap_from_path(p: str, max_size=(480, 360)) -> QPixmap:
|
|
"""
|
|
Load an image from a path into a QPixmap, scaled to a maximum size.
|
|
|
|
Parameters:
|
|
- p (str): The file path of the image.
|
|
- max_size (tuple): A tuple (width, height) for the maximum dimensions.
|
|
|
|
Returns:
|
|
- QPixmap: The scaled pixmap. Returns an empty QPixmap if the path is invalid.
|
|
"""
|
|
pix = QPixmap(p)
|
|
if pix.isNull():
|
|
return QPixmap()
|
|
w, h = max_size
|
|
return pix.scaled(w, h, Qt.KeepAspectRatio, Qt.SmoothTransformation)
|
|
|
|
def make_canvas(width=4, height=3, dpi=100):
|
|
"""
|
|
Create a Matplotlib canvas and axes for embedding in a PyQt GUI.
|
|
|
|
Parameters:
|
|
- width (int): The width of the figure in inches.
|
|
- height (int): The height of the figure in inches.
|
|
- dpi (int): The resolution of the figure in dots per inch.
|
|
|
|
Returns:
|
|
- tuple: A tuple containing the FigureCanvas and the Axes object (canvas, ax).
|
|
"""
|
|
fig = Figure(figsize=(width, height), dpi=dpi)
|
|
canvas = FigureCanvas(fig)
|
|
ax = fig.add_subplot(111)
|
|
fig.tight_layout()
|
|
return canvas, ax
|
|
|
|
def compute_gray_array(path):
|
|
"""
|
|
Open an image, convert it to grayscale, and return it as a NumPy array.
|
|
|
|
The conversion uses the luminosity method: Y = 0.299*R + 0.587*G + 0.114*B.
|
|
|
|
Parameters:
|
|
- path (str): The file path of the image.
|
|
|
|
Returns:
|
|
- np.ndarray: A 2D NumPy array of type float32 representing the grayscale image.
|
|
"""
|
|
img = Image.open(path).convert('RGB')
|
|
arr = np.array(img)
|
|
gray = (0.299 * arr[:, :, 0] + 0.587 * arr[:, :, 1] + 0.114 * arr[:, :, 2]).astype(np.float32)
|
|
return gray
|
|
|
|
def compute_fft_magnitude(gray_arr, eps=1e-8):
|
|
"""
|
|
Compute the 2D FFT magnitude spectrum of a grayscale image.
|
|
|
|
This function calculates the Fast Fourier Transform, shifts the zero-frequency
|
|
component to the center, and returns both the absolute magnitude and a
|
|
log-scaled version for better visualization.
|
|
|
|
Parameters:
|
|
- gray_arr (np.ndarray): The input 2D grayscale image array.
|
|
- eps (float): A small epsilon value (currently unused in the implementation).
|
|
|
|
Returns:
|
|
- tuple: A tuple containing:
|
|
- mag (np.ndarray): The centered FFT magnitude spectrum.
|
|
- mag_log (np.ndarray): The log-scaled magnitude spectrum (log(1 + mag)).
|
|
"""
|
|
f = np.fft.fft2(gray_arr)
|
|
fshift = np.fft.fftshift(f)
|
|
mag = np.abs(fshift)
|
|
mag_log = np.log1p(mag)
|
|
return mag, mag_log
|
|
|
|
def radial_profile(mag, center=None, nbins=100):
|
|
"""
|
|
Compute the radially averaged profile of a 2D array (e.g., FFT magnitude).
|
|
|
|
This calculates the average value in concentric rings starting from a center point.
|
|
|
|
Parameters:
|
|
- mag (np.ndarray): The 2D input array.
|
|
- center (tuple, optional): The (y, x) coordinates of the center. If None,
|
|
the geometric center of the array is used.
|
|
- nbins (int): The number of radial bins to use.
|
|
|
|
Returns:
|
|
- tuple: A tuple containing:
|
|
- centers (np.ndarray): The normalized radial distance for each bin (0 to 1).
|
|
- radial_mean (np.ndarray): The mean value for each radial bin.
|
|
"""
|
|
h, w = mag.shape
|
|
if center is None:
|
|
center = (int(h / 2), int(w / 2))
|
|
y, x = np.indices((h, w))
|
|
r = np.sqrt((x - center[1]) ** 2 + (y - center[0]) ** 2)
|
|
r_flat = r.ravel()
|
|
mag_flat = mag.ravel()
|
|
max_r = np.max(r_flat)
|
|
if max_r <= 0:
|
|
return np.linspace(0, 1, nbins), np.zeros(nbins)
|
|
bins = np.linspace(0, max_r, nbins + 1)
|
|
inds = np.digitize(r_flat, bins) - 1
|
|
radial_mean = np.zeros(nbins)
|
|
for i in range(nbins):
|
|
sel = inds == i
|
|
if np.any(sel):
|
|
radial_mean[i] = mag_flat[sel].mean()
|
|
else:
|
|
radial_mean[i] = 0.0
|
|
centers = 0.5 * (bins[:-1] + bins[1:]) / max_r
|
|
return centers, radial_mean
|
|
|
|
|
|
def compute_glcm(gray_arr, levels=8, offsets=[(0, 1), (1, 0), (1, 1), (-1, 1)], eps=1e-8):
|
|
"""
|
|
Vectorized Gray-Level Co-occurrence Matrix (GLCM) computation.
|
|
|
|
Replaces per-pixel Python loops with NumPy-indexed operations and
|
|
np.bincount to accumulate co-occurrence counts. This yields large
|
|
speedups for typical image sizes.
|
|
"""
|
|
# Quantize grayscale image to 'levels' bins
|
|
gray = gray_arr.astype(np.float32)
|
|
gray_min, gray_max = gray.min(), gray.max()
|
|
if gray_max > gray_min:
|
|
gray_quant = np.floor((gray - gray_min) / (gray_max - gray_min + eps) * (levels - 1)).astype(np.int32)
|
|
else:
|
|
gray_quant = np.zeros_like(gray, dtype=np.int32)
|
|
|
|
h, w = gray_quant.shape
|
|
glcm = np.zeros((levels, levels, len(offsets)), dtype=np.float32)
|
|
features = {
|
|
'contrast': np.zeros(len(offsets), dtype=np.float32),
|
|
'correlation': np.zeros(len(offsets), dtype=np.float32),
|
|
'energy': np.zeros(len(offsets), dtype=np.float32),
|
|
'homogeneity': np.zeros(len(offsets), dtype=np.float32)
|
|
}
|
|
|
|
# Precompute index grids
|
|
rows, cols = np.indices((h, w))
|
|
|
|
# Precompute i,j matrices for feature computation
|
|
i_idx = np.arange(levels).reshape(levels, 1)
|
|
j_idx = np.arange(levels).reshape(1, levels)
|
|
|
|
for k, (dy, dx) in enumerate(offsets):
|
|
r2 = rows + dy
|
|
c2 = cols + dx
|
|
valid = (r2 >= 0) & (r2 < h) & (c2 >= 0) & (c2 < w)
|
|
if not np.any(valid):
|
|
continue
|
|
|
|
q1 = gray_quant[rows[valid], cols[valid]]
|
|
q2 = gray_quant[r2[valid], c2[valid]]
|
|
|
|
# Flatten pair indices to accumulate with bincount (fast C loop)
|
|
pairs = q1 * levels + q2
|
|
counts = np.bincount(pairs, minlength=levels * levels).astype(np.float32)
|
|
glcm_k = counts.reshape((levels, levels))
|
|
|
|
# Normalize
|
|
total = glcm_k.sum()
|
|
if total > 0:
|
|
glcm_k /= total
|
|
|
|
# Features
|
|
contrast = np.sum((i_idx - j_idx) ** 2 * glcm_k)
|
|
mu_i = np.sum(i_idx * glcm_k)
|
|
mu_j = np.sum(j_idx * glcm_k)
|
|
sigma_i = np.sqrt(np.sum((i_idx - mu_i) ** 2 * glcm_k) + eps)
|
|
sigma_j = np.sqrt(np.sum((j_idx - mu_j) ** 2 * glcm_k) + eps)
|
|
if sigma_i > 0 and sigma_j > 0:
|
|
correlation = np.sum((i_idx - mu_i) * (j_idx - mu_j) * glcm_k) / (sigma_i * sigma_j)
|
|
else:
|
|
correlation = 0.0
|
|
energy = np.sum(glcm_k ** 2)
|
|
homogeneity = np.sum(glcm_k / (1.0 + np.abs(i_idx - j_idx) + eps))
|
|
|
|
glcm[:, :, k] = glcm_k
|
|
features['contrast'][k] = contrast
|
|
features['correlation'][k] = correlation
|
|
features['energy'][k] = energy
|
|
features['homogeneity'][k] = homogeneity
|
|
|
|
return glcm, features
|
|
|
|
|
|
# ---------------------- Optimized LBP ----------------------
|
|
|
|
def compute_lbp(gray_arr, radius=1, n_points=8, eps=1e-8):
|
|
"""
|
|
Vectorized Local Binary Pattern (uniform, rotation-invariant).
|
|
|
|
Uses NumPy operations to sample all neighbor points at once and
|
|
build LBP codes without explicit per-pixel Python loops.
|
|
"""
|
|
gray = gray_arr.astype(np.float32)
|
|
h, w = gray.shape
|
|
|
|
# Pad to avoid boundary checks during interpolation
|
|
pad = int(np.ceil(radius)) + 1
|
|
padded = np.pad(gray, pad_width=pad, mode='edge')
|
|
|
|
# Center coordinates in padded image
|
|
Y, X = np.indices((h, w))
|
|
Y = Y + pad
|
|
X = X + pad
|
|
|
|
angles = np.linspace(0, 2 * np.pi, n_points, endpoint=False)
|
|
lbp = np.zeros((h, w), dtype=np.uint32)
|
|
|
|
for k, theta in enumerate(angles):
|
|
# Note: y increases downward in image coordinates
|
|
dy = -radius * np.sin(theta)
|
|
dx = radius * np.cos(theta)
|
|
|
|
sample_y = Y.astype(np.float32) + dy
|
|
sample_x = X.astype(np.float32) + dx
|
|
|
|
# Bilinear interpolation indices and weights
|
|
y0 = np.floor(sample_y).astype(np.int32)
|
|
x0 = np.floor(sample_x).astype(np.int32)
|
|
y1 = y0 + 1
|
|
x1 = x0 + 1
|
|
|
|
wy = sample_y - y0
|
|
wx = sample_x - x0
|
|
|
|
# Gather values
|
|
v00 = padded[y0, x0]
|
|
v10 = padded[y1, x0]
|
|
v01 = padded[y0, x1]
|
|
v11 = padded[y1, x1]
|
|
|
|
vals = (1 - wy) * (1 - wx) * v00 + wy * (1 - wx) * v10 + (1 - wy) * wx * v01 + wy * wx * v11
|
|
|
|
# Compare with center
|
|
center = padded[Y, X]
|
|
bit = (vals >= center).astype(np.uint32)
|
|
lbp |= (bit << k)
|
|
|
|
# Map to rotation-invariant uniform patterns
|
|
n_codes = 1 << n_points
|
|
codes = np.arange(n_codes, dtype=np.uint32)
|
|
bits = ((codes[:, None] >> np.arange(n_points)) & 1).astype(np.uint8)
|
|
transitions = np.sum(bits != np.roll(bits, -1, axis=1), axis=1)
|
|
uniform_mask = transitions <= 2
|
|
|
|
lbp_map = np.full(n_codes, n_points + 1, dtype=np.int32)
|
|
lbp_map[uniform_mask] = np.arange(np.count_nonzero(uniform_mask), dtype=np.int32)[np.argsort(np.nonzero(uniform_mask)[0])]
|
|
# The above ensures uniform codes get unique indices from 0..(num_uniform-1)
|
|
|
|
lbp_mapped = lbp_map[lbp]
|
|
|
|
n_bins = lbp_map.max() + 1
|
|
hist = np.bincount(lbp_mapped.ravel(), minlength=n_bins).astype(np.float32)
|
|
hist /= (hist.sum() + eps)
|
|
|
|
return lbp_mapped, hist
|