add a few more nodes
Some checks failed
Build / Build (Linux) (push) Has been cancelled
Build / Build (macOS) (push) Has been cancelled
Build / Build (Windows) (push) Has been cancelled
Deploy / test (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Tests / test (push) Has been cancelled
Some checks failed
Build / Build (Linux) (push) Has been cancelled
Build / Build (macOS) (push) Has been cancelled
Build / Build (Windows) (push) Has been cancelled
Deploy / test (push) Has been cancelled
Deploy / deploy (push) Has been cancelled
Tests / test (push) Has been cancelled
This commit is contained in:
88
backend/nodes/arc_revolve.py
Normal file
88
backend/nodes/arc_revolve.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Arc Revolve — subtract a cylindrical arc background."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
def _arc_kernel(radius: int) -> np.ndarray:
|
||||
"""Build a 1D arc kernel: z = 1 - sqrt(1 - (i/radius)^2)."""
|
||||
half = min(radius, 4096)
|
||||
i = np.arange(-half, half + 1, dtype=np.float64)
|
||||
t = np.clip((i / radius) ** 2, 0.0, 1.0)
|
||||
return 1.0 - np.sqrt(1.0 - t)
|
||||
|
||||
|
||||
def _arc_revolve_1d(data: np.ndarray, radius: int) -> np.ndarray:
|
||||
"""Compute arc-revolve background for each row independently."""
|
||||
yres, xres = data.shape
|
||||
kernel = _arc_kernel(radius)
|
||||
half = len(kernel) // 2
|
||||
bg = np.empty_like(data)
|
||||
|
||||
for row in range(yres):
|
||||
line = data[row].copy()
|
||||
# Suppress deep outliers before fitting
|
||||
window = min(half, xres // 2)
|
||||
if window > 0:
|
||||
from scipy.ndimage import uniform_filter1d
|
||||
local_mean = uniform_filter1d(line, size=2 * window + 1, mode='nearest')
|
||||
local_sq = uniform_filter1d(line ** 2, size=2 * window + 1, mode='nearest')
|
||||
local_rms = np.sqrt(np.maximum(local_sq - local_mean ** 2, 0.0))
|
||||
threshold = local_mean - 2.5 * np.maximum(local_rms, 1e-30)
|
||||
line = np.maximum(line, threshold)
|
||||
|
||||
# For each pixel, find the lowest position the arc can sit
|
||||
padded = np.pad(line, half, mode='edge')
|
||||
row_bg = np.full(xres, np.inf)
|
||||
for k in range(len(kernel)):
|
||||
shifted = padded[k:k + xres] - kernel[k]
|
||||
row_bg = np.minimum(row_bg, shifted)
|
||||
bg[row] = row_bg
|
||||
|
||||
return bg
|
||||
|
||||
|
||||
@register_node(display_name="Arc Revolve")
|
||||
class ArcRevolve:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"radius": ("INT", {"default": 20, "min": 1, "max": 1000, "step": 1}),
|
||||
"direction": (["horizontal", "vertical", "both"], {"default": "horizontal"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'leveled'),
|
||||
('DATA_FIELD', 'background'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Subtract a cylindrical arc background. A circular arc of the given "
|
||||
"radius is rolled under each row (or column), and the envelope it "
|
||||
"traces out is subtracted as the background."
|
||||
)
|
||||
|
||||
KEYWORDS = ("arc", "revolve", "cylindrical", "background", "level")
|
||||
|
||||
def process(self, field: DataField, radius: int = 20,
|
||||
direction: str = "horizontal") -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
if direction == "horizontal":
|
||||
bg = _arc_revolve_1d(data, radius)
|
||||
elif direction == "vertical":
|
||||
bg = _arc_revolve_1d(data.T, radius).T
|
||||
else:
|
||||
bg_h = _arc_revolve_1d(data, radius)
|
||||
bg_v = _arc_revolve_1d(data.T, radius).T
|
||||
bg = np.minimum(bg_h, bg_v)
|
||||
|
||||
return (field.replace(data=data - bg), field.replace(data=bg))
|
||||
75
backend/nodes/level_rotate.py
Normal file
75
backend/nodes/level_rotate.py
Normal file
@@ -0,0 +1,75 @@
|
||||
"""Level Rotate — level by physically rotating the data plane."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import map_coordinates
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Level Rotate")
|
||||
class LevelRotate:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'leveled'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Level by physically rotating the data plane. Fits a best-fit plane, "
|
||||
"converts its slopes to tilt angles, then rotates the surface by "
|
||||
"those angles using interpolation rather than algebraic subtraction."
|
||||
)
|
||||
|
||||
KEYWORDS = ("rotate", "tilt", "level", "plane")
|
||||
|
||||
def process(self, field: DataField) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
# Fit plane: z = a + bx*x + by*y (x,y in pixel coords)
|
||||
yy, xx = np.mgrid[:yres, :xres].astype(np.float64)
|
||||
A = np.column_stack([np.ones(yres * xres), xx.ravel(), yy.ravel()])
|
||||
coeffs, _, _, _ = np.linalg.lstsq(A, data.ravel(), rcond=None)
|
||||
_, bx, by = coeffs
|
||||
|
||||
# Convert pixel slopes to tilt angles
|
||||
alpha_x = np.arctan(bx)
|
||||
alpha_y = np.arctan(by)
|
||||
|
||||
# Build rotation: for each output pixel, find where it came from
|
||||
cx = (xres - 1) / 2.0
|
||||
cy = (yres - 1) / 2.0
|
||||
|
||||
cos_x = np.cos(alpha_x)
|
||||
cos_y = np.cos(alpha_y)
|
||||
|
||||
# Source coordinates after removing tilt
|
||||
src_x = xx.copy()
|
||||
src_y = yy.copy()
|
||||
src_z = data.copy()
|
||||
|
||||
# Rotate about x-axis (corrects y-tilt)
|
||||
dy = yy - cy
|
||||
src_y_rot = cy + dy * cos_y
|
||||
src_z = src_z - dy * np.sin(alpha_y)
|
||||
|
||||
# Rotate about y-axis (corrects x-tilt)
|
||||
dx = xx - cx
|
||||
src_x_rot = cx + dx * cos_x
|
||||
src_z = src_z - dx * np.sin(alpha_x)
|
||||
|
||||
# Resample with the adjusted z values
|
||||
result = map_coordinates(src_z, [src_y_rot, src_x_rot], order=1,
|
||||
mode='nearest')
|
||||
|
||||
return (field.replace(data=result),)
|
||||
74
backend/nodes/sphere_revolve.py
Normal file
74
backend/nodes/sphere_revolve.py
Normal file
@@ -0,0 +1,74 @@
|
||||
"""Sphere Revolve — subtract a spherical cap background."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import uniform_filter
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
def _sphere_kernel(radius: int) -> np.ndarray:
|
||||
"""Build a 2D spherical cap kernel."""
|
||||
half = min(radius, 512)
|
||||
i = np.arange(-half, half + 1, dtype=np.float64)
|
||||
ii, jj = np.meshgrid(i, i)
|
||||
r2 = (ii ** 2 + jj ** 2) / (radius ** 2)
|
||||
r2 = np.clip(r2, 0.0, 1.0)
|
||||
return 1.0 - np.sqrt(1.0 - r2)
|
||||
|
||||
|
||||
@register_node(display_name="Sphere Revolve")
|
||||
class SphereRevolve:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"radius": ("INT", {"default": 20, "min": 1, "max": 500, "step": 1}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'leveled'),
|
||||
('DATA_FIELD', 'background'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Subtract a spherical cap background. A sphere of the given radius "
|
||||
"is rolled under the surface, and the envelope it traces is "
|
||||
"subtracted as the background."
|
||||
)
|
||||
|
||||
KEYWORDS = ("sphere", "revolve", "spherical", "background", "level")
|
||||
|
||||
def process(self, field: DataField, radius: int = 20) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
yres, xres = data.shape
|
||||
|
||||
kernel = _sphere_kernel(radius)
|
||||
half = kernel.shape[0] // 2
|
||||
|
||||
# Suppress deep outliers
|
||||
window = max(1, half // 2)
|
||||
local_mean = uniform_filter(data, size=2 * window + 1, mode='nearest')
|
||||
local_sq = uniform_filter(data ** 2, size=2 * window + 1, mode='nearest')
|
||||
local_rms = np.sqrt(np.maximum(local_sq - local_mean ** 2, 0.0))
|
||||
threshold = local_mean - 2.5 * np.maximum(local_rms, 1e-30)
|
||||
clipped = np.maximum(data, threshold)
|
||||
|
||||
padded = np.pad(clipped, half, mode='edge')
|
||||
bg = np.full_like(data, np.inf)
|
||||
|
||||
ks = kernel.shape[0]
|
||||
for di in range(ks):
|
||||
for dj in range(ks):
|
||||
k_val = kernel[di, dj]
|
||||
if k_val >= 1.0:
|
||||
continue
|
||||
shifted = padded[di:di + yres, dj:dj + xres] - k_val
|
||||
bg = np.minimum(bg, shifted)
|
||||
|
||||
return (field.replace(data=data - bg), field.replace(data=bg))
|
||||
88
backend/nodes/unrotate.py
Normal file
88
backend/nodes/unrotate.py
Normal file
@@ -0,0 +1,88 @@
|
||||
"""Unrotate — auto-detect and correct in-plane scan rotation."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
from scipy.ndimage import rotate as ndimage_rotate
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
def _slope_angle_histogram(data: np.ndarray, n_bins: int = 3600) -> np.ndarray:
|
||||
"""Compute histogram of local slope angles over [0, 2*pi)."""
|
||||
dy = np.diff(data, axis=0)[:, :-1]
|
||||
dx = np.diff(data, axis=1)[:-1, :]
|
||||
angles = np.arctan2(dy, dx) % (2 * np.pi)
|
||||
hist, _ = np.histogram(angles.ravel(), bins=n_bins, range=(0, 2 * np.pi))
|
||||
return hist.astype(np.float64)
|
||||
|
||||
|
||||
def _find_dominant_angle(hist: np.ndarray, symmetry: int) -> float:
|
||||
"""Find the rotation correction angle for a given symmetry order.
|
||||
|
||||
Folds the histogram into one symmetry sector, finds the peak, and
|
||||
returns the offset to the nearest axis.
|
||||
"""
|
||||
n_bins = len(hist)
|
||||
sector = n_bins // symmetry
|
||||
folded = np.zeros(sector, dtype=np.float64)
|
||||
for k in range(symmetry):
|
||||
start = k * sector
|
||||
end = start + sector
|
||||
if end <= n_bins:
|
||||
folded += hist[start:end]
|
||||
|
||||
peak_bin = int(np.argmax(folded))
|
||||
bin_angle = (2 * np.pi / symmetry) / sector
|
||||
|
||||
# The angle of the peak
|
||||
peak_angle = peak_bin * bin_angle
|
||||
|
||||
# The nearest axis is at multiples of pi/symmetry
|
||||
axis_spacing = np.pi / symmetry
|
||||
nearest_axis = round(peak_angle / axis_spacing) * axis_spacing
|
||||
correction = nearest_axis - peak_angle
|
||||
|
||||
return float(correction)
|
||||
|
||||
|
||||
@register_node(display_name="Unrotate")
|
||||
class Unrotate:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"symmetry": (["2-fold", "3-fold", "4-fold", "6-fold"], {"default": "4-fold"}),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'leveled'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Auto-detect and correct in-plane scan rotation. Computes a slope "
|
||||
"angle histogram, finds the dominant feature direction for the given "
|
||||
"symmetry, and rotates the image to align features with the axes."
|
||||
)
|
||||
|
||||
KEYWORDS = ("rotation", "alignment", "angle", "symmetry", "crystal")
|
||||
|
||||
def process(self, field: DataField, symmetry: str = "4-fold") -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
|
||||
sym_order = int(symmetry[0])
|
||||
hist = _slope_angle_histogram(data)
|
||||
angle_rad = _find_dominant_angle(hist, sym_order)
|
||||
angle_deg = float(np.degrees(angle_rad))
|
||||
|
||||
if abs(angle_deg) < 0.01:
|
||||
return (field,)
|
||||
|
||||
rotated = ndimage_rotate(data, angle_deg, reshape=False, order=1,
|
||||
mode='nearest')
|
||||
|
||||
return (field.replace(data=rotated),)
|
||||
56
backend/nodes/zero_value.py
Normal file
56
backend/nodes/zero_value.py
Normal file
@@ -0,0 +1,56 @@
|
||||
"""Zero Value — shift data so the mean or maximum equals zero."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
@register_node(display_name="Zero Mean")
|
||||
class ZeroMean:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'leveled'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = "Shift all values so the mean is exactly zero."
|
||||
|
||||
KEYWORDS = ("offset", "center", "level", "mean")
|
||||
|
||||
def process(self, field: DataField) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
return (field.replace(data=data - data.mean()),)
|
||||
|
||||
|
||||
@register_node(display_name="Zero Maximum")
|
||||
class ZeroMaximum:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('DATA_FIELD', 'leveled'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = "Shift all values so the maximum is exactly zero."
|
||||
|
||||
KEYWORDS = ("offset", "level", "maximum")
|
||||
|
||||
def process(self, field: DataField) -> tuple:
|
||||
data = np.asarray(field.data, dtype=np.float64)
|
||||
return (field.replace(data=data - data.max()),)
|
||||
Reference in New Issue
Block a user