Files
tono/backend/nodes/synthetic_surface.py
2026-04-03 22:09:19 -07:00

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,)