add nodes, fft acf 1d
This commit is contained in:
@@ -224,14 +224,20 @@ class ExecutionEngine:
|
|||||||
return value
|
return value
|
||||||
|
|
||||||
if input_type == "INT":
|
if input_type == "INT":
|
||||||
numeric = float(value)
|
try:
|
||||||
|
numeric = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return value
|
||||||
if not isfinite(numeric):
|
if not isfinite(numeric):
|
||||||
raise ValueError(f"Expected a finite numeric value for INT input, got {value!r}")
|
raise ValueError(f"Expected a finite numeric value for INT input, got {value!r}")
|
||||||
rounded = int(abs(numeric) + 0.5)
|
rounded = int(abs(numeric) + 0.5)
|
||||||
return rounded if numeric >= 0 else -rounded
|
return rounded if numeric >= 0 else -rounded
|
||||||
|
|
||||||
if input_type == "FLOAT":
|
if input_type == "FLOAT":
|
||||||
numeric = float(value)
|
try:
|
||||||
|
numeric = float(value)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return value
|
||||||
if not isfinite(numeric):
|
if not isfinite(numeric):
|
||||||
raise ValueError(f"Expected a finite numeric value for FLOAT input, got {value!r}")
|
raise ValueError(f"Expected a finite numeric value for FLOAT input, got {value!r}")
|
||||||
return numeric
|
return numeric
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ from typing import Any
|
|||||||
MENU_LAYOUT: dict[str, list[str]] = {
|
MENU_LAYOUT: dict[str, list[str]] = {
|
||||||
"Input": [
|
"Input": [
|
||||||
"Image",
|
"Image",
|
||||||
|
"IBWNote",
|
||||||
"ImageDemo",
|
"ImageDemo",
|
||||||
"Folder",
|
"Folder",
|
||||||
"Number",
|
"Number",
|
||||||
@@ -55,7 +56,8 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"InverseFFT2D",
|
"InverseFFT2D",
|
||||||
"FFTFilter1D",
|
"FFTFilter1D",
|
||||||
"FFTFilter2D",
|
"FFTFilter2D",
|
||||||
"ACF",
|
"ACF2D",
|
||||||
|
"ACF1D",
|
||||||
"PSDF",
|
"PSDF",
|
||||||
],
|
],
|
||||||
"Level & Correct": [
|
"Level & Correct": [
|
||||||
@@ -67,13 +69,15 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
|||||||
"ScarRemoval",
|
"ScarRemoval",
|
||||||
],
|
],
|
||||||
"Measure": [
|
"Measure": [
|
||||||
|
"FFT1D",
|
||||||
"AngleMeasure",
|
"AngleMeasure",
|
||||||
"CrossSection",
|
"CrossSection",
|
||||||
"Histogram",
|
"Histogram",
|
||||||
"Cursors",
|
"Cursors",
|
||||||
"Curvature",
|
"Curvature",
|
||||||
"FractalDimension",
|
"FractalDimension",
|
||||||
"ACF",
|
"ACF2D",
|
||||||
|
"ACF1D",
|
||||||
"PSDF",
|
"PSDF",
|
||||||
"Statistics",
|
"Statistics",
|
||||||
"Stats",
|
"Stats",
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
from backend.nodes import (
|
from backend.nodes import (
|
||||||
# IO
|
# IO
|
||||||
image,
|
image,
|
||||||
|
ibw_note,
|
||||||
image_demo,
|
image_demo,
|
||||||
folder,
|
folder,
|
||||||
coordinate,
|
coordinate,
|
||||||
@@ -51,7 +52,8 @@ from backend.nodes import (
|
|||||||
fractal_dimension,
|
fractal_dimension,
|
||||||
statistics_node,
|
statistics_node,
|
||||||
histogram,
|
histogram,
|
||||||
acf,
|
acf_2d,
|
||||||
|
acf_1d,
|
||||||
cursors,
|
cursors,
|
||||||
fft_2d,
|
fft_2d,
|
||||||
psdf,
|
psdf,
|
||||||
@@ -60,4 +62,5 @@ from backend.nodes import (
|
|||||||
stats,
|
stats,
|
||||||
watershed_segmentation,
|
watershed_segmentation,
|
||||||
grain_analysis,
|
grain_analysis,
|
||||||
|
fft_1d,
|
||||||
)
|
)
|
||||||
|
|||||||
61
backend/nodes/acf_1d.py
Normal file
61
backend/nodes/acf_1d.py
Normal file
@@ -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))
|
||||||
@@ -5,8 +5,8 @@ from backend.data_types import DataField
|
|||||||
from backend.nodes.spectral_common import acf_field_from_data, preprocess_spectral_data
|
from backend.nodes.spectral_common import acf_field_from_data, preprocess_spectral_data
|
||||||
|
|
||||||
|
|
||||||
@register_node(display_name="ACF")
|
@register_node(display_name="ACF 2D")
|
||||||
class ACF:
|
class ACF2D:
|
||||||
@classmethod
|
@classmethod
|
||||||
def INPUT_TYPES(cls):
|
def INPUT_TYPES(cls):
|
||||||
return {
|
return {
|
||||||
@@ -106,8 +106,10 @@ class Cursors:
|
|||||||
"b_locked": locked,
|
"b_locked": locked,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
length = float(np.hypot(xb - xa, yb - ya))
|
||||||
table = MeasureTable([
|
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": "dy", "value": yb - ya, "unit": y_unit},
|
||||||
{"quantity": "A x", "value": xa, "unit": x_unit},
|
{"quantity": "A x", "value": xa, "unit": x_unit},
|
||||||
{"quantity": "A y", "value": ya, "unit": y_unit},
|
{"quantity": "A y", "value": ya, "unit": y_unit},
|
||||||
|
|||||||
70
backend/nodes/fft_1d.py
Normal file
70
backend/nodes/fft_1d.py
Normal file
@@ -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,
|
||||||
|
)
|
||||||
@@ -89,8 +89,8 @@ class Histogram:
|
|||||||
})
|
})
|
||||||
|
|
||||||
table = MeasureTable([
|
table = MeasureTable([
|
||||||
{"quantity": "delta X", "value": xb - xa, "unit": field.si_unit_z},
|
|
||||||
{"quantity": "delta Y", "value": yb - ya, "unit": count_unit},
|
{"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 position", "value": xa, "unit": field.si_unit_z},
|
||||||
{"quantity": "A count", "value": ya, "unit": count_unit},
|
{"quantity": "A count", "value": ya, "unit": count_unit},
|
||||||
{"quantity": "B position", "value": xb, "unit": field.si_unit_z},
|
{"quantity": "B position", "value": xb, "unit": field.si_unit_z},
|
||||||
|
|||||||
78
backend/nodes/ibw_note.py
Normal file
78
backend/nodes/ibw_note.py
Normal file
@@ -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),)
|
||||||
@@ -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:
|
def acf_field_from_data(field: DataField, data: np.ndarray, *, xrange: int = 0, yrange: int = 0) -> DataField:
|
||||||
from scipy.signal import fftconvolve
|
from scipy.signal import fftconvolve
|
||||||
|
|
||||||
|
|||||||
@@ -905,6 +905,7 @@ function Flow() {
|
|||||||
const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false);
|
const [isCanvasRightZooming, setIsCanvasRightZooming] = useState(false);
|
||||||
|
|
||||||
const flowContainerRef = useRef(null);
|
const flowContainerRef = useRef(null);
|
||||||
|
const panTimerRef = useRef(null);
|
||||||
const nodeDefsRef = useRef({});
|
const nodeDefsRef = useRef({});
|
||||||
const nextIdRef = useRef(1);
|
const nextIdRef = useRef(1);
|
||||||
const autoRunTimer = useRef(null);
|
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(() => {
|
useEffect(() => {
|
||||||
const handlePointerMove = (event) => {
|
const handlePointerMove = (event) => {
|
||||||
const zoomState = canvasRightZoomRef.current;
|
const zoomState = canvasRightZoomRef.current;
|
||||||
@@ -2868,6 +2879,7 @@ function Flow() {
|
|||||||
className={`flow-container${isCanvasRightZooming ? ' canvas-right-zooming' : ''}`}
|
className={`flow-container${isCanvasRightZooming ? ' canvas-right-zooming' : ''}`}
|
||||||
onDrop={onDropFile}
|
onDrop={onDropFile}
|
||||||
onDragOver={onDragOver}
|
onDragOver={onDragOver}
|
||||||
|
onWheel={onFlowContainerWheel}
|
||||||
onPointerDownCapture={onFlowContainerPointerDown}
|
onPointerDownCapture={onFlowContainerPointerDown}
|
||||||
onContextMenuCapture={onFlowContainerContextMenuCapture}
|
onContextMenuCapture={onFlowContainerContextMenuCapture}
|
||||||
>
|
>
|
||||||
|
|||||||
@@ -1164,6 +1164,15 @@ html, body, #root {
|
|||||||
z-index: 2;
|
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 {
|
.markup-overlay {
|
||||||
position: relative;
|
position: relative;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
|
|||||||
Reference in New Issue
Block a user