From ce74cf0a3e2d4ea8757cae7a7550b6d154e086cb Mon Sep 17 00:00:00 2001 From: matei jordache Date: Sat, 28 Mar 2026 18:10:50 -0700 Subject: [PATCH] add nodes, fft acf 1d --- backend/execution.py | 10 +++- backend/node_menu.py | 8 ++- backend/nodes/__init__.py | 5 +- backend/nodes/acf_1d.py | 61 ++++++++++++++++++++++ backend/nodes/{acf.py => acf_2d.py} | 4 +- backend/nodes/cursors.py | 4 +- backend/nodes/fft_1d.py | 70 ++++++++++++++++++++++++++ backend/nodes/histogram.py | 2 +- backend/nodes/ibw_note.py | 78 +++++++++++++++++++++++++++++ backend/nodes/spectral_common.py | 29 +++++++++++ frontend/src/App.jsx | 12 +++++ frontend/src/styles.css | 9 ++++ 12 files changed, 283 insertions(+), 9 deletions(-) create mode 100644 backend/nodes/acf_1d.py rename backend/nodes/{acf.py => acf_2d.py} (95%) create mode 100644 backend/nodes/fft_1d.py create mode 100644 backend/nodes/ibw_note.py diff --git a/backend/execution.py b/backend/execution.py index 3c50a5f..0365bed 100644 --- a/backend/execution.py +++ b/backend/execution.py @@ -224,14 +224,20 @@ class ExecutionEngine: return value if input_type == "INT": - numeric = float(value) + try: + numeric = float(value) + except (TypeError, ValueError): + return value if not isfinite(numeric): raise ValueError(f"Expected a finite numeric value for INT input, got {value!r}") rounded = int(abs(numeric) + 0.5) return rounded if numeric >= 0 else -rounded if input_type == "FLOAT": - numeric = float(value) + try: + numeric = float(value) + except (TypeError, ValueError): + return value if not isfinite(numeric): raise ValueError(f"Expected a finite numeric value for FLOAT input, got {value!r}") return numeric diff --git a/backend/node_menu.py b/backend/node_menu.py index 0ac9274..865ea60 100644 --- a/backend/node_menu.py +++ b/backend/node_menu.py @@ -14,6 +14,7 @@ from typing import Any MENU_LAYOUT: dict[str, list[str]] = { "Input": [ "Image", + "IBWNote", "ImageDemo", "Folder", "Number", @@ -55,7 +56,8 @@ MENU_LAYOUT: dict[str, list[str]] = { "InverseFFT2D", "FFTFilter1D", "FFTFilter2D", - "ACF", + "ACF2D", + "ACF1D", "PSDF", ], "Level & Correct": [ @@ -67,13 +69,15 @@ MENU_LAYOUT: dict[str, list[str]] = { "ScarRemoval", ], "Measure": [ + "FFT1D", "AngleMeasure", "CrossSection", "Histogram", "Cursors", "Curvature", "FractalDimension", - "ACF", + "ACF2D", + "ACF1D", "PSDF", "Statistics", "Stats", diff --git a/backend/nodes/__init__.py b/backend/nodes/__init__.py index 2016cb5..0773ba0 100644 --- a/backend/nodes/__init__.py +++ b/backend/nodes/__init__.py @@ -2,6 +2,7 @@ from backend.nodes import ( # IO image, + ibw_note, image_demo, folder, coordinate, @@ -51,7 +52,8 @@ from backend.nodes import ( fractal_dimension, statistics_node, histogram, - acf, + acf_2d, + acf_1d, cursors, fft_2d, psdf, @@ -60,4 +62,5 @@ from backend.nodes import ( stats, watershed_segmentation, grain_analysis, + fft_1d, ) diff --git a/backend/nodes/acf_1d.py b/backend/nodes/acf_1d.py new file mode 100644 index 0000000..c866cdb --- /dev/null +++ b/backend/nodes/acf_1d.py @@ -0,0 +1,61 @@ +from __future__ import annotations + +import numpy as np + +from backend.node_registry import register_node +from backend.data_types import LineData, MeasureTable +from backend.nodes.spectral_common import acf_line_from_data + + +def _first_positive_peak(acf: np.ndarray, lag_axis: np.ndarray) -> float | None: + """Return the lag of the first local maximum in the positive-lag half of the ACF.""" + center = len(acf) // 2 + pos_acf = acf[center + 1:] + pos_lags = lag_axis[center + 1:] + for i in range(1, len(pos_acf) - 1): + if pos_acf[i] >= pos_acf[i - 1] and pos_acf[i] > pos_acf[i + 1]: + return float(pos_lags[i]) + return None + + +@register_node(display_name="ACF 1D") +class ACF1D: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "profile": ("LINE", { + "label": "input", + "accepted_types": ["LINE"], + }), + "level": (["mean", "none"], {"default": "mean"}), + } + } + + OUTPUTS = ( + ('LINE', 'acf'), + ('MEASURE_TABLE', 'measurement'), + ) + FUNCTION = "process" + + DESCRIPTION = ( + "Compute the one-dimensional autocorrelation function of a line profile. " + "The output is symmetric about zero lag with the lag on the x-axis. " + "The measurement table reports the dominant period from the first positive peak." + ) + + def process(self, profile: LineData, level: str) -> tuple: + z = np.asarray(profile, dtype=np.float64) + if level == "mean": + z = z - z.mean() + + acf_line = acf_line_from_data(profile, z) + + x_unit = profile.x_unit if isinstance(profile, LineData) else "" + peak_lag = _first_positive_peak(acf_line.data, acf_line.x_axis) + + rows = [] + if peak_lag is not None: + rows.append({"quantity": "Peak period", "value": peak_lag, "unit": x_unit}) + + return (acf_line, MeasureTable(rows)) diff --git a/backend/nodes/acf.py b/backend/nodes/acf_2d.py similarity index 95% rename from backend/nodes/acf.py rename to backend/nodes/acf_2d.py index f031f16..92cd191 100644 --- a/backend/nodes/acf.py +++ b/backend/nodes/acf_2d.py @@ -5,8 +5,8 @@ from backend.data_types import DataField from backend.nodes.spectral_common import acf_field_from_data, preprocess_spectral_data -@register_node(display_name="ACF") -class ACF: +@register_node(display_name="ACF 2D") +class ACF2D: @classmethod def INPUT_TYPES(cls): return { diff --git a/backend/nodes/cursors.py b/backend/nodes/cursors.py index 661432c..c551c81 100644 --- a/backend/nodes/cursors.py +++ b/backend/nodes/cursors.py @@ -106,8 +106,10 @@ class Cursors: "b_locked": locked, }) + length = float(np.hypot(xb - xa, yb - ya)) table = MeasureTable([ - {"quantity": "dx", "value": xb - xa, "unit": x_unit}, + {"quantity": "Length", "value": length, "unit": x_unit}, + {"quantity": "dx", "value": xb - xa, "unit": x_unit}, {"quantity": "dy", "value": yb - ya, "unit": y_unit}, {"quantity": "A x", "value": xa, "unit": x_unit}, {"quantity": "A y", "value": ya, "unit": y_unit}, diff --git a/backend/nodes/fft_1d.py b/backend/nodes/fft_1d.py new file mode 100644 index 0000000..160d9ec --- /dev/null +++ b/backend/nodes/fft_1d.py @@ -0,0 +1,70 @@ +from __future__ import annotations + +import numpy as np + +from backend.node_registry import register_node +from backend.data_types import LineData, MeasureTable + + +@register_node(display_name="FFT 1D") +class FFT1D: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "profile": ("LINE", { + "label": "input", + "accepted_types": ["LINE"], + }), + } + } + + OUTPUTS = ( + ("LINE", "frequency_plot"), + ('MEASURE_TABLE', 'measurement'), + ) + + FUNCTION = "process" + + DESCRIPTION = ( + "Returns the FFT spectrum of the line, and identifies peaks." + ) + + _broadcast_overlay_fn = None + _current_node_id: str = "" + + def process( + self, profile, + ) -> tuple: + line_data = np.asarray(profile, dtype=np.float64) + n = len(line_data) + + if isinstance(profile, LineData) and profile.x_axis is not None and len(profile.x_axis) > 1: + d = float(profile.x_axis[1] - profile.x_axis[0]) + spatial_unit = profile.x_unit or "m" + else: + d = 1.0 + spatial_unit = "m" + + spectrum = np.abs(np.fft.rfft(line_data)) + freq_axis = np.fft.rfftfreq(n, d) + + # Exclude DC component, convert to period, sort short→long + spectrum = spectrum[1:][::-1] + period_axis = (1.0 / freq_axis[1:])[::-1] + + peak_period = float(period_axis[np.argmax(spectrum)]) + + table = MeasureTable([ + {"quantity": "Peak period", "value": peak_period, "unit": spatial_unit}, + ]) + + return ( + LineData( + data=spectrum, + x_axis=period_axis, + x_unit=spatial_unit, + y_unit=profile.y_unit if isinstance(profile, LineData) else "", + ), + table, + ) diff --git a/backend/nodes/histogram.py b/backend/nodes/histogram.py index c8ee79e..1e3191c 100644 --- a/backend/nodes/histogram.py +++ b/backend/nodes/histogram.py @@ -89,8 +89,8 @@ class Histogram: }) table = MeasureTable([ - {"quantity": "delta X", "value": xb - xa, "unit": field.si_unit_z}, {"quantity": "delta Y", "value": yb - ya, "unit": count_unit}, + {"quantity": "delta X", "value": xb - xa, "unit": field.si_unit_z}, {"quantity": "A position", "value": xa, "unit": field.si_unit_z}, {"quantity": "A count", "value": ya, "unit": count_unit}, {"quantity": "B position", "value": xb, "unit": field.si_unit_z}, diff --git a/backend/nodes/ibw_note.py b/backend/nodes/ibw_note.py new file mode 100644 index 0000000..e2ac97d --- /dev/null +++ b/backend/nodes/ibw_note.py @@ -0,0 +1,78 @@ +from __future__ import annotations + +import re + +from backend.node_registry import register_node +from backend.data_types import MeasureTable +from backend.nodes.helpers import _resolve_path, _import_ibw_loader + + +def _parse_ibw_note(note_bytes: bytes) -> list[dict]: + try: + text = note_bytes.decode("utf-8", errors="replace") + except Exception: + return [] + + rows = [] + for line in text.splitlines(): + line = line.strip() + if not line: + continue + match = re.match(r'^([^:=]+)[=:](.+)$', line) + if not match: + continue + key = match.group(1).strip() + raw_val = match.group(2).strip() + if not key: + continue + try: + value = float(raw_val) + except (ValueError, TypeError): + continue + rows.append({"quantity": key, "value": value, "unit": ""}) + + return rows + + +@register_node(display_name="IBW Note") +class IBWNote: + @classmethod + def INPUT_TYPES(cls): + return { + "required": { + "filename": ("FILE_PICKER", {"default": "", "hide_when_input_connected": "path"}), + }, + "optional": { + "path": ("FILE_PATH", {"label": "path"}), + }, + } + + OUTPUTS = ( + ('MEASURE_TABLE', 'note'), + ) + FUNCTION = "load" + + DESCRIPTION = ( + "Read the Note metadata from an .ibw file and display numeric entries " + "as a measurement table. Non-numeric note entries are skipped." + ) + + def load(self, filename: str = "", path: str | None = None) -> tuple: + selected = str(path).strip() if path is not None else str(filename).strip() + if not selected: + raise ValueError("No file selected.") + path_obj = _resolve_path(selected) + if not path_obj.exists(): + raise FileNotFoundError(f"File not found: {path_obj}") + if path_obj.suffix.lower() != ".ibw": + raise ValueError(f"Expected an .ibw file, got: {path_obj.suffix}") + + load_ibw = _import_ibw_loader() + wave = load_ibw(str(path_obj)) + note_bytes = wave["wave"].get("note", b"") or b"" + + rows = _parse_ibw_note(note_bytes) + if not rows: + raise ValueError("No numeric metadata found in the .ibw note.") + + return (MeasureTable(rows),) diff --git a/backend/nodes/spectral_common.py b/backend/nodes/spectral_common.py index cb84e19..96223b0 100644 --- a/backend/nodes/spectral_common.py +++ b/backend/nodes/spectral_common.py @@ -127,6 +127,35 @@ def psdf_field_from_data(field: DataField, data: np.ndarray) -> DataField: ) +def acf_line_from_data(profile, data: np.ndarray, *, nrange: int = 0): + from scipy.signal import fftconvolve + from backend.data_types import LineData + + z = np.asarray(data, dtype=np.float64).ravel() + n = len(z) + nrange = int(nrange) if nrange else max(1, n // 2) + nrange = max(1, min(nrange, n)) + + corr_full = fftconvolve(z, z[::-1], mode="full") + center = n - 1 + corr = corr_full[center - (nrange - 1):center + nrange] + + counts = np.array([n - abs(lag) for lag in range(-(nrange - 1), nrange)], dtype=np.float64) + acf = corr / counts + + x_unit = profile.x_unit if hasattr(profile, "x_unit") else "" + y_unit = _square_unit(profile.y_unit) if hasattr(profile, "y_unit") and profile.y_unit else "" + + if hasattr(profile, "x_axis") and profile.x_axis is not None and len(profile.x_axis) > 1: + d = float(profile.x_axis[1] - profile.x_axis[0]) + else: + d = 1.0 + + lag_axis = np.arange(-(nrange - 1), nrange, dtype=np.float64) * d + + return LineData(data=acf, x_axis=lag_axis, x_unit=x_unit, y_unit=y_unit) + + def acf_field_from_data(field: DataField, data: np.ndarray, *, xrange: int = 0, yrange: int = 0) -> DataField: from scipy.signal import fftconvolve diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 75f1a48..0732bc7 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -905,6 +905,7 @@ function Flow() { const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false); const flowContainerRef = useRef(null); + const panTimerRef = useRef(null); const nodeDefsRef = useRef({}); const nextIdRef = useRef(1); const autoRunTimer = useRef(null); @@ -2753,6 +2754,16 @@ function Flow() { } }, []); + const onFlowContainerWheel = useCallback(() => { + const container = flowContainerRef.current; + if (!container) return; + container.classList.add('is-panning'); + clearTimeout(panTimerRef.current); + panTimerRef.current = setTimeout(() => { + container.classList.remove('is-panning'); + }, 150); + }, []); + useEffect(() => { const handlePointerMove = (event) => { const zoomState = canvasRightZoomRef.current; @@ -2868,6 +2879,7 @@ function Flow() { className={`flow-container${isCanvasRightZooming ? ' canvas-right-zooming' : ''}`} onDrop={onDropFile} onDragOver={onDragOver} + onWheel={onFlowContainerWheel} onPointerDownCapture={onFlowContainerPointerDown} onContextMenuCapture={onFlowContainerContextMenuCapture} > diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 25e3e0f..e1b5a1a 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -1164,6 +1164,15 @@ html, body, #root { z-index: 2; } +.is-panning .cs-overlay, +.is-panning .angle-overlay, +.is-panning .lineplot-overlay, +.is-panning .crop-overlay, +.is-panning .mask-paint-overlay, +.is-panning .markup-overlay { + pointer-events: none; +} + .markup-overlay { position: relative; overflow: hidden;