Source code for playnano.processing.masked_filters
"""Module for filtering AFM data in NumPy arrays with a boolean mask."""
import logging
import numpy as np
from sklearn.linear_model import LinearRegression
from sklearn.preprocessing import PolynomialFeatures
from playnano.utils.versioning import versioned_filter
logger = logging.getLogger(__name__)
[docs]
@versioned_filter("0.1.0")
def remove_plane_masked(data: np.ndarray, mask: np.ndarray) -> np.ndarray:
"""
Fit a 2D plane on background only and subtract it from the full image.
Parameters
----------
data : np.ndarray
2D AFM image.
mask : np.ndarray
Boolean mask of same shape; True=foreground (excluded),
False=background (used to fit).
Returns
-------
np.ndarray
Plane-removed image.
Raises
------
ValueError
If mask.shape != data.shape.
"""
if mask.shape != data.shape:
raise ValueError("Mask must have same shape as data.")
bg_idx = ~mask.ravel()
if np.sum(bg_idx) < 3:
raise ValueError("Not enough background pixels to fit a plane.")
h, w = data.shape
X, Y = np.meshgrid(np.arange(w), np.arange(h))
Z = data.astype(np.float64)
features = np.vstack((X.ravel(), Y.ravel())).T
Zf = Z.ravel()
model = LinearRegression()
model.fit(features[bg_idx], Zf[bg_idx])
plane = model.predict(features).reshape(h, w)
return data - plane
[docs]
@versioned_filter("0.1.0")
def polynomial_flatten_masked(
data: np.ndarray,
mask: np.ndarray,
order: int = 2,
) -> np.ndarray:
"""
Fit a 2D polynomial using background (mask==False) and subtract it.
Parameters
----------
data : np.ndarray
2D AFM image.
order : int
Polynomial order. Default order=2.
mask : np.ndarray
Boolean mask of same shape; True=foreground, False=background.
Returns
-------
np.ndarray
Polynomial-flattened image.
Raises
------
ValueError
If mask.shape != data.shape or order is not a positive integer.
"""
if mask.shape != data.shape:
raise ValueError("Mask must have same shape as data.")
if not isinstance(order, int) or order < 1:
raise ValueError("Polynomial order must be a positive integer.")
h, w = data.shape
# Generate coordinate grid for surface fitting
X, Y = np.meshgrid(np.arange(w), np.arange(h))
Z = data.astype(np.float64)
# Prepare design matrix with all polynomial terms up to the given order
coords = np.stack([X.ravel(), Y.ravel()], axis=1)
try:
poly = PolynomialFeatures(order)
A = poly.fit_transform(coords)
except Exception as e:
raise RuntimeError(f"Failed to generate polynomial features: {e}") from e
bg_idx = ~mask.ravel()
if np.count_nonzero(bg_idx) < A.shape[1]:
raise ValueError("Not enough background pixels to perform polynomial fit.")
# Solve for least-squares polynomial surface
Zf = Z.ravel()
try:
coeff, _, _, _ = np.linalg.lstsq(A[bg_idx], Zf[bg_idx], rcond=None)
except np.linalg.LinAlgError as e:
raise RuntimeError(f"Least squares fitting failed: {e}") from e
# Reconstruct the fitted surface and subtract it
Z_fit = A @ coeff
flattened = data.astype(np.float64) - Z_fit.reshape(h, w)
return flattened
[docs]
@versioned_filter("0.1.0")
def zero_mean_masked(data: np.ndarray, mask: np.ndarray) -> np.ndarray:
"""
Subtract the mean of background pixels (mask==False) from the entire image.
Parameters
----------
data : np.ndarray
2D AFM image.
mask : np.ndarray
Boolean mask of same shape; True=foreground, False=background.
Returns
-------
np.ndarray
Image with zero-mean background.
"""
if mask.shape != data.shape:
raise ValueError("Mask must have same shape as data.")
bg_idx = ~mask
if np.count_nonzero(bg_idx) == 0:
raise ValueError("No background pixels to compute mean.")
mean_val = np.mean(data[bg_idx])
return data.astype(np.float64) - mean_val
[docs]
def register_mask_filters():
"""Return list of masking options."""
return {
"remove_plane": remove_plane_masked,
"polynomial_flatten": polynomial_flatten_masked,
"row_median_align": row_median_align_masked,
"zero_mean": zero_mean_masked,
}