106 lines
4.2 KiB
Python
106 lines
4.2 KiB
Python
import numpy as np
|
|
from skimage.feature import local_binary_pattern
|
|
from PIL import Image
|
|
|
|
def lbp_normalize(img_arr: np.ndarray,
|
|
ref_img_arr: np.ndarray = None,
|
|
radius: int = 3,
|
|
n_points: int = 24,
|
|
method: str = 'uniform',
|
|
strength: float = 0.9,
|
|
seed: int = None,
|
|
eps: float = 1e-8):
|
|
"""
|
|
Optimized LBP histogram normalization.
|
|
|
|
Key optimizations:
|
|
- compute LBP and its histogram once (not per channel)
|
|
- use np.bincount (faster) instead of np.histogram for integer LBP
|
|
- compute mapping & per-bin operations once, then apply vectorized indexing
|
|
- generate noise once for all channels
|
|
- fewer temporaries, consistent dtypes
|
|
"""
|
|
if seed is not None:
|
|
rng = np.random.default_rng(seed)
|
|
else:
|
|
rng = np.random.default_rng()
|
|
|
|
img = np.asarray(img_arr)
|
|
h, w = img.shape[:2]
|
|
n_bins = n_points + 2 if method == 'uniform' else 2 ** n_points
|
|
|
|
# Grayscale conversion (float32)
|
|
img_gray = np.mean(img.astype(np.float32), axis=2) if img.ndim == 3 else img.astype(np.float32)
|
|
|
|
# Compute LBP for input (float or int result)
|
|
lbp_img = local_binary_pattern(img_gray, n_points, radius, method=method)
|
|
# Convert LBP to integer indices for bincount (safe cast)
|
|
lbp_int = np.rint(lbp_img).astype(np.int32)
|
|
# Use bincount for integer labels which is faster than histogram
|
|
lbp_counts = np.bincount(lbp_int.ravel(), minlength=n_bins).astype(np.float64)
|
|
lbp_hist = lbp_counts / (lbp_counts.sum() + eps)
|
|
|
|
ref_lbp_hist = None
|
|
if ref_img_arr is not None:
|
|
ref = np.asarray(ref_img_arr)
|
|
# Resize reference only once if needed
|
|
if ref.shape[0] != h or ref.shape[1] != w:
|
|
ref_img = Image.fromarray(ref).resize((w, h), resample=Image.BICUBIC)
|
|
ref = np.array(ref_img)
|
|
ref_gray = np.mean(ref.astype(np.float32), axis=2) if ref.ndim == 3 else ref.astype(np.float32)
|
|
ref_lbp = local_binary_pattern(ref_gray, n_points, radius, method=method)
|
|
ref_int = np.rint(ref_lbp).astype(np.int32)
|
|
ref_counts = np.bincount(ref_int.ravel(), minlength=n_bins).astype(np.float64)
|
|
ref_lbp_hist = ref_counts / (ref_counts.sum() + eps)
|
|
|
|
out = np.empty_like(img, dtype=np.float32)
|
|
channels = img.shape[2] if img.ndim == 3 else 1
|
|
|
|
# Precompute mapping and scale-image-level arrays only once
|
|
if ref_lbp_hist is not None:
|
|
cdf_img = np.cumsum(lbp_hist)
|
|
cdf_ref = np.cumsum(ref_lbp_hist)
|
|
# Vectorized mapping: for each possible lbp bin value find target bin
|
|
mapping = np.searchsorted(cdf_ref, cdf_img, side='left')
|
|
mapping = np.clip(mapping, 0, n_bins - 1).astype(np.float32)
|
|
# mapping_per_pixel (h,w)
|
|
mapping_per_pixel = mapping[lbp_int]
|
|
# denom per pixel (avoid divide by zero)
|
|
denom = (lbp_int.astype(np.float32) + eps)
|
|
# precompute scale per pixel
|
|
scale_per_pixel = mapping_per_pixel / denom
|
|
else:
|
|
# Unused but create placeholders to keep code simpler
|
|
scale_per_pixel = None
|
|
|
|
# Prepare noise for all channels at once (if needed)
|
|
if strength > 0.0:
|
|
noise_all = rng.normal(loc=0.0, scale=0.02 * strength, size=(h, w, channels)).astype(np.float32) * 255.0
|
|
else:
|
|
noise_all = np.zeros((h, w, channels), dtype=np.float32)
|
|
|
|
# Process channels: mostly vectorized
|
|
if channels == 1:
|
|
channel = img.astype(np.float32)
|
|
if scale_per_pixel is not None:
|
|
adjusted = channel * scale_per_pixel
|
|
blended = (1.0 - strength) * channel + strength * adjusted
|
|
else:
|
|
blended = channel
|
|
blended += noise_all[:, :, 0]
|
|
out[:, :] = blended
|
|
else:
|
|
for c in range(channels):
|
|
channel = img[:, :, c].astype(np.float32)
|
|
if scale_per_pixel is not None:
|
|
# vectorized multiply
|
|
adjusted = channel * scale_per_pixel
|
|
blended = (1.0 - strength) * channel + strength * adjusted
|
|
else:
|
|
blended = channel
|
|
blended += noise_all[:, :, c]
|
|
out[:, :, c] = blended
|
|
|
|
out = np.clip(out, 0, 255).astype(np.uint8)
|
|
return out
|