performance buff

This commit is contained in:
2026-03-25 19:26:20 -07:00
parent ca59bac478
commit 61b68c142b
5 changed files with 113 additions and 68 deletions

View File

@@ -13,6 +13,7 @@ DataField mirrors Gwyddion's GwyDataField structure:
from __future__ import annotations from __future__ import annotations
from dataclasses import dataclass, field from dataclasses import dataclass, field
from functools import lru_cache
import numpy as np import numpy as np
@@ -141,14 +142,21 @@ def datafield_to_uint8(df: DataField, colormap: str = "gray") -> np.ndarray:
Normalize a DataField to a uint8 (H, W, 3) RGB array using matplotlib colormap. Normalize a DataField to a uint8 (H, W, 3) RGB array using matplotlib colormap.
Returns shape (H, W, 3) uint8. Returns shape (H, W, 3) uint8.
""" """
import matplotlib.cm as cm
normalized = normalize_for_colormap( normalized = normalize_for_colormap(
df.data, df.data,
offset=df.display_offset, offset=df.display_offset,
scale=df.display_scale, scale=df.display_scale,
) )
cmap = cm.get_cmap(colormap) if colormap == "gray":
grey = np.rint(normalized * 255.0).astype(np.uint8)
rgb = np.empty(grey.shape + (3,), dtype=np.uint8)
rgb[..., 0] = grey
rgb[..., 1] = grey
rgb[..., 2] = grey
return rgb
cmap = _get_colormap(colormap)
rgba = cmap(normalized) # (H, W, 4) float [0,1] rgba = cmap(normalized) # (H, W, 4) float [0,1]
rgb = (rgba[:, :, :3] * 255).astype(np.uint8) rgb = (rgba[:, :, :3] * 255).astype(np.uint8)
return rgb return rgb
@@ -180,6 +188,12 @@ def encode_preview(arr: np.ndarray) -> str:
img = Image.fromarray(arr) img = Image.fromarray(arr)
buf = io.BytesIO() buf = io.BytesIO()
img.save(buf, format="PNG") img.save(buf, format="PNG", compress_level=1, optimize=False)
b64 = base64.b64encode(buf.getvalue()).decode() b64 = base64.b64encode(buf.getvalue()).decode()
return f"data:image/png;base64,{b64}" return f"data:image/png;base64,{b64}"
@lru_cache(maxsize=len(COLORMAPS))
def _get_colormap(colormap: str):
import matplotlib.cm as cm
return cm.get_cmap(colormap)

View File

@@ -4,4 +4,4 @@ from . import io, filters, modify, level, analysis, mask, display
try: try:
from . import particle from . import particle
except ImportError: except ImportError:
from . import grains from . import particless

View File

@@ -10,6 +10,7 @@ Gwyddion equivalents:
""" """
from __future__ import annotations from __future__ import annotations
from functools import lru_cache
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
from backend.data_types import DataField from backend.data_types import DataField
@@ -38,7 +39,7 @@ class GaussianFilter:
def process(self, field: DataField, sigma: float) -> tuple: def process(self, field: DataField, sigma: float) -> tuple:
from scipy.ndimage import gaussian_filter from scipy.ndimage import gaussian_filter
data = gaussian_filter(field.data.copy(), sigma=float(sigma)) data = gaussian_filter(field.data, sigma=float(sigma))
return (field.replace(data=data),) return (field.replace(data=data),)
@@ -66,7 +67,7 @@ class MedianFilter:
def process(self, field: DataField, size: int) -> tuple: def process(self, field: DataField, size: int) -> tuple:
from scipy.ndimage import median_filter from scipy.ndimage import median_filter
size = max(1, int(size)) size = max(1, int(size))
data = median_filter(field.data.copy(), size=size) data = median_filter(field.data, size=size)
return (field.replace(data=data),) return (field.replace(data=data),)
@@ -97,7 +98,7 @@ class EdgeDetect:
def process(self, field: DataField, method: str, sigma: float) -> tuple: def process(self, field: DataField, method: str, sigma: float) -> tuple:
from scipy.ndimage import sobel, prewitt, gaussian_laplace, laplace from scipy.ndimage import sobel, prewitt, gaussian_laplace, laplace
data = field.data.copy() data = field.data
if method == "sobel": if method == "sobel":
sx = sobel(data, axis=1) sx = sobel(data, axis=1)
@@ -158,6 +159,45 @@ def _build_1d_transfer(n: int, filter_type: str, cutoff: float,
return H return H
@lru_cache(maxsize=64)
def _cached_1d_transfer(n: int, filter_type: str, cutoff: float,
cutoff_high: float, order: int) -> np.ndarray:
transfer = _build_1d_transfer(n, filter_type, cutoff, cutoff_high, order)
transfer.setflags(write=False)
return transfer
@lru_cache(maxsize=32)
def _fft_radius_grid(yres: int, xres: int) -> np.ndarray:
fy = np.fft.fftfreq(yres)[:, np.newaxis] * 2.0
fx = np.fft.rfftfreq(xres)[np.newaxis, :] * 2.0
radius = np.sqrt(fx * fx + fy * fy) / np.sqrt(2.0)
np.clip(radius, 0.0, 1.0, out=radius)
radius.setflags(write=False)
return radius
@lru_cache(maxsize=128)
def _cached_2d_transfer(yres: int, xres: int, filter_type: str,
cutoff: float, cutoff_high: float, order: int) -> np.ndarray:
radius = _fft_radius_grid(yres, xres)
if filter_type == "lowpass":
transfer = _butterworth_lp(radius, cutoff, order)
elif filter_type == "highpass":
transfer = _butterworth_hp(radius, cutoff, order)
elif filter_type == "bandpass":
transfer = _butterworth_hp(radius, cutoff, order) * _butterworth_lp(radius, cutoff_high, order)
elif filter_type == "notch":
band = _butterworth_hp(radius, cutoff, order) * _butterworth_lp(radius, cutoff_high, order)
transfer = 1.0 - band
else:
transfer = np.ones_like(radius)
transfer.setflags(write=False)
return transfer
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# FFTFilter1D — frequency-domain filtering of LINE profiles # FFTFilter1D — frequency-domain filtering of LINE profiles
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -206,7 +246,7 @@ class FFTFilter1D:
Z = np.fft.rfft(z) Z = np.fft.rfft(z)
# Build and apply transfer function # Build and apply transfer function
H = _build_1d_transfer(n, filter_type, cutoff, cutoff_high, order) H = _cached_1d_transfer(n, filter_type, float(cutoff), float(cutoff_high), int(order))
Z *= H Z *= H
# Inverse FFT # Inverse FFT
@@ -258,45 +298,24 @@ class FFTFilter2D:
def process(self, field: DataField, filter_type: str, cutoff: float, def process(self, field: DataField, filter_type: str, cutoff: float,
cutoff_high: float, order: int) -> tuple: cutoff_high: float, order: int) -> tuple:
data = field.data.copy() data = field.data
yres, xres = data.shape yres, xres = data.shape
# Subtract mean to avoid DC leakage artefacts # Subtract mean to avoid DC leakage artefacts.
mean_val = data.mean() mean_val = float(data.mean())
data -= mean_val centered = data - mean_val
# Forward 2D FFT # Real-valued FFT keeps only the unique half-plane and avoids shift copies.
F = np.fft.fft2(data) spectrum = np.fft.rfft2(centered)
F = np.fft.fftshift(F) transfer = _cached_2d_transfer(
yres,
# Build radial frequency grid normalised to [0, 1] (1 = Nyquist) xres,
fy = np.fft.fftshift(np.fft.fftfreq(yres)) # range [-0.5, 0.5) filter_type,
fx = np.fft.fftshift(np.fft.fftfreq(xres)) float(cutoff),
FX, FY = np.meshgrid(fx, fy) float(cutoff_high),
# Normalise so that corner = 1 in each axis independently, int(order),
# then take Euclidean norm; max radial value = 1.0 at Nyquist. )
R = np.sqrt((FX / 0.5) ** 2 + (FY / 0.5) ** 2) result = np.fft.irfft2(spectrum * transfer, s=(yres, xres))
R = np.clip(R / R.max(), 0, 1) if R.max() > 0 else R
# Build transfer function
if filter_type == "lowpass":
H = _butterworth_lp(R, cutoff, order)
elif filter_type == "highpass":
H = _butterworth_hp(R, cutoff, order)
elif filter_type == "bandpass":
H = _butterworth_hp(R, cutoff, order) * _butterworth_lp(R, cutoff_high, order)
elif filter_type == "notch":
bp = _butterworth_hp(R, cutoff, order) * _butterworth_lp(R, cutoff_high, order)
H = 1.0 - bp
else:
H = np.ones_like(R)
# Apply filter
F *= H
# Inverse FFT
F = np.fft.ifftshift(F)
result = np.fft.ifft2(F).real
# Restore DC # Restore DC
result += mean_val result += mean_val

View File

@@ -9,6 +9,7 @@ Gwyddion equivalents:
""" """
from __future__ import annotations from __future__ import annotations
from functools import lru_cache
import json import json
import numpy as np import numpy as np
from backend.node_registry import register_node from backend.node_registry import register_node
@@ -21,13 +22,36 @@ def _mask_overlay(field: DataField, mask: np.ndarray) -> np.ndarray:
Returns (H, W, 3) uint8 array. Returns (H, W, 3) uint8 array.
""" """
grey = datafield_to_uint8(field, "gray") # (H, W, 3) uint8 grey = datafield_to_uint8(field, "gray") # (H, W, 3) uint8
overlay = grey.astype(np.float64) mask_bool = mask > 127
mask_bool = mask == 255 if not np.any(mask_bool):
alpha = 0.45 return grey
overlay[mask_bool, 0] = overlay[mask_bool, 0] * (1 - alpha) + 255 * alpha
overlay[mask_bool, 1] = overlay[mask_bool, 1] * (1 - alpha) overlay = grey.copy()
overlay[mask_bool, 2] = overlay[mask_bool, 2] * (1 - alpha) red = overlay[..., 0]
return np.clip(overlay, 0, 255).astype(np.uint8) green = overlay[..., 1]
blue = overlay[..., 2]
# Integer alpha blend equivalent to a 45% red overlay, without float64 work.
red_vals = red[mask_bool].astype(np.uint16)
green_vals = green[mask_bool].astype(np.uint16)
blue_vals = blue[mask_bool].astype(np.uint16)
red[mask_bool] = ((red_vals * 55) + (255 * 45) + 50) // 100
green[mask_bool] = ((green_vals * 55) + 50) // 100
blue[mask_bool] = ((blue_vals * 55) + 50) // 100
return overlay
@lru_cache(maxsize=128)
def _mask_structure(radius: int, shape: str) -> np.ndarray:
radius = max(1, int(radius))
if shape == "disk":
y, x = np.ogrid[-radius:radius + 1, -radius:radius + 1]
struct = (x * x + y * y) <= radius * radius
else:
size = 2 * radius + 1
struct = np.ones((size, size), dtype=bool)
struct.setflags(write=False)
return struct
def _clamp_fraction(value) -> float: def _clamp_fraction(value) -> float:
@@ -285,31 +309,19 @@ class MaskMorphology:
def process(self, mask: np.ndarray, operation: str, radius: int, shape: str, def process(self, mask: np.ndarray, operation: str, radius: int, shape: str,
field: DataField | None = None) -> tuple: field: DataField | None = None) -> tuple:
from scipy.ndimage import binary_dilation, binary_erosion from scipy.ndimage import binary_closing, binary_dilation, binary_erosion, binary_opening
binary = mask > 127 binary = mask > 127
struct = _mask_structure(radius, shape)
if shape == "disk":
y, x = np.ogrid[-radius:radius + 1, -radius:radius + 1]
struct = (x * x + y * y) <= radius * radius
else:
size = 2 * radius + 1
struct = np.ones((size, size), dtype=bool)
if operation == "dilate": if operation == "dilate":
result = binary_dilation(binary, structure=struct) result = binary_dilation(binary, structure=struct)
elif operation == "erode": elif operation == "erode":
result = binary_erosion(binary, structure=struct) result = binary_erosion(binary, structure=struct)
elif operation == "open": elif operation == "open":
result = binary_dilation( result = binary_opening(binary, structure=struct)
binary_erosion(binary, structure=struct),
structure=struct,
)
elif operation == "close": elif operation == "close":
result = binary_erosion( result = binary_closing(binary, structure=struct)
binary_dilation(binary, structure=struct),
structure=struct,
)
else: else:
raise ValueError(f"Unknown morphological operation: {operation}") raise ValueError(f"Unknown morphological operation: {operation}")

View File

@@ -758,7 +758,7 @@ def test_draw_mask():
def test_particle_analysis(): def test_particle_analysis():
print("=== Test: ParticleAnalysis ===") print("=== Test: ParticleAnalysis ===")
from backend.nodes.grains import ParticleAnalysis from backend.nodes.particless import ParticleAnalysis
node = ParticleAnalysis() node = ParticleAnalysis()
# Create a field with two distinct particles # Create a field with two distinct particles