153 lines
5.3 KiB
Python
153 lines
5.3 KiB
Python
"""Synthetic surface generation — create test surfaces for development and calibration."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import numpy as np
|
|
|
|
from backend.node_registry import register_node
|
|
from backend.data_types import DataField
|
|
|
|
|
|
def _fbm_surface(shape, rng, H=0.7):
|
|
"""Fractional Brownian motion surface via spectral synthesis."""
|
|
yres, xres = shape
|
|
kx = np.fft.fftfreq(xres)
|
|
ky = np.fft.fftfreq(yres)
|
|
KX, KY = np.meshgrid(kx, ky)
|
|
K = np.sqrt(KX**2 + KY**2)
|
|
K[0, 0] = 1.0 # avoid division by zero
|
|
power = K ** (-(H + 1.0))
|
|
power[0, 0] = 0.0
|
|
|
|
phases = rng.uniform(0, 2 * np.pi, shape)
|
|
amplitudes = rng.standard_normal(shape)
|
|
fft_data = amplitudes * np.sqrt(power) * np.exp(1j * phases)
|
|
surface = np.real(np.fft.ifft2(fft_data))
|
|
return surface
|
|
|
|
|
|
def _lattice_surface(shape, xreal, yreal, spacing, angle_deg):
|
|
"""Periodic lattice (sinusoidal grid)."""
|
|
yres, xres = shape
|
|
x = np.linspace(0, xreal, xres, endpoint=False)
|
|
y = np.linspace(0, yreal, yres, endpoint=False)
|
|
X, Y = np.meshgrid(x, y)
|
|
|
|
theta = np.radians(angle_deg)
|
|
k = 2 * np.pi / spacing
|
|
surface = np.cos(k * X) + np.cos(k * (X * np.cos(theta) + Y * np.sin(theta)))
|
|
return surface
|
|
|
|
|
|
def _steps_surface(shape, n_steps):
|
|
"""Terraced step structure."""
|
|
yres, xres = shape
|
|
ramp = np.linspace(0, n_steps, xres, endpoint=False)
|
|
steps = np.floor(ramp)
|
|
return np.tile(steps, (yres, 1)).astype(np.float64)
|
|
|
|
|
|
def _particles_surface(shape, rng, n_particles, radius_px):
|
|
"""Random spherical particles on a flat background."""
|
|
yres, xres = shape
|
|
surface = np.zeros(shape)
|
|
yy, xx = np.ogrid[:yres, :xres]
|
|
for _ in range(n_particles):
|
|
cy = rng.integers(0, yres)
|
|
cx = rng.integers(0, xres)
|
|
r2 = (yy - cy)**2 + (xx - cx)**2
|
|
height = np.sqrt(np.maximum(radius_px**2 - r2, 0.0))
|
|
surface = np.maximum(surface, height)
|
|
return surface
|
|
|
|
|
|
@register_node(display_name="Synthetic Surface")
|
|
class SyntheticSurface:
|
|
@classmethod
|
|
def INPUT_TYPES(cls):
|
|
return {
|
|
"required": {
|
|
"pattern": (["fbm", "white_noise", "lattice", "steps", "particles", "flat"],
|
|
{"default": "fbm"}),
|
|
"xres": ("INT", {"default": 256, "min": 16, "max": 2048}),
|
|
"yres": ("INT", {"default": 256, "min": 16, "max": 2048}),
|
|
"xreal": ("FLOAT", {"default": 1e-6, "min": 1e-9, "max": 1.0, "step": 1e-9}),
|
|
"yreal": ("FLOAT", {"default": 1e-6, "min": 1e-9, "max": 1.0, "step": 1e-9}),
|
|
"amplitude": ("FLOAT", {"default": 1e-9, "min": 0.0, "max": 1e-3, "step": 1e-10}),
|
|
"seed": ("INT", {"default": 42, "min": 0, "max": 999999}),
|
|
},
|
|
"optional": {
|
|
"hurst_exponent": ("FLOAT", {"default": 0.7, "min": 0.0, "max": 1.0, "step": 0.05}),
|
|
"lattice_spacing": ("FLOAT", {
|
|
"default": 100e-9, "min": 1e-9, "max": 1e-3, "step": 1e-9,
|
|
}),
|
|
"lattice_angle": ("FLOAT", {"default": 90.0, "min": 0.0, "max": 180.0, "step": 1.0}),
|
|
"n_steps": ("INT", {"default": 5, "min": 1, "max": 100}),
|
|
"n_particles": ("INT", {"default": 20, "min": 1, "max": 500}),
|
|
"particle_radius_px": ("INT", {"default": 10, "min": 2, "max": 100}),
|
|
}
|
|
}
|
|
|
|
OUTPUTS = (
|
|
('DATA_FIELD', 'surface'),
|
|
)
|
|
FUNCTION = "process"
|
|
|
|
DESCRIPTION = (
|
|
"Generate synthetic test surfaces for development, calibration, and "
|
|
"algorithm testing. Patterns: fbm (fractional Brownian motion), "
|
|
"white_noise, lattice (periodic grid), steps (terraced), "
|
|
"particles (spherical bumps on flat), flat (zero surface). "
|
|
"Equivalent to Gwyddion's *_synth.c modules."
|
|
)
|
|
|
|
def process(
|
|
self,
|
|
pattern: str,
|
|
xres: int,
|
|
yres: int,
|
|
xreal: float,
|
|
yreal: float,
|
|
amplitude: float,
|
|
seed: int,
|
|
hurst_exponent: float = 0.7,
|
|
lattice_spacing: float = 100e-9,
|
|
lattice_angle: float = 90.0,
|
|
n_steps: int = 5,
|
|
n_particles: int = 20,
|
|
particle_radius_px: int = 10,
|
|
) -> tuple:
|
|
shape = (yres, xres)
|
|
rng = np.random.default_rng(seed)
|
|
|
|
if pattern == "fbm":
|
|
data = _fbm_surface(shape, rng, H=hurst_exponent)
|
|
elif pattern == "white_noise":
|
|
data = rng.standard_normal(shape)
|
|
elif pattern == "lattice":
|
|
data = _lattice_surface(shape, xreal, yreal, lattice_spacing, lattice_angle)
|
|
elif pattern == "steps":
|
|
data = _steps_surface(shape, n_steps)
|
|
elif pattern == "particles":
|
|
data = _particles_surface(shape, rng, n_particles, particle_radius_px)
|
|
elif pattern == "flat":
|
|
data = np.zeros(shape)
|
|
else:
|
|
raise ValueError(f"Unknown pattern: {pattern!r}")
|
|
|
|
# Normalise and scale by amplitude
|
|
drange = data.max() - data.min()
|
|
if drange > 0:
|
|
data = (data - data.min()) / drange * amplitude
|
|
else:
|
|
data = np.zeros(shape)
|
|
|
|
field = DataField(
|
|
data=data,
|
|
xreal=xreal,
|
|
yreal=yreal,
|
|
si_unit_xy="m",
|
|
si_unit_z="m",
|
|
)
|
|
return (field,)
|