diff --git a/backend/data_types.py b/backend/data_types.py index e199387..b06152d 100644 --- a/backend/data_types.py +++ b/backend/data_types.py @@ -31,11 +31,11 @@ CUSTOM_FILE_FONT = "Custom File" PREVIEW_MARKUP_REFERENCE_DIM = 512 -class RecordTable(list): +class DataTable(list): """Tabular rows with a shared schema, e.g. grain statistics.""" -class MeasureTable(list): +class RecordTable(list): """Named scalar measurements, typically rows of quantity/value/unit.""" diff --git a/backend/execution.py b/backend/execution.py index 0365bed..7fbcf12 100644 --- a/backend/execution.py +++ b/backend/execution.py @@ -318,7 +318,7 @@ class ExecutionEngine: def _fingerprint_bytes(self, value: Any) -> bytes: import numpy as np - from backend.data_types import DataField, ImageData, LineData, MeasureTable, MeshModel, RecordTable + from backend.data_types import DataField, ImageData, LineData, RecordTable, MeshModel, DataTable if value is None: return b"null" @@ -381,7 +381,7 @@ class ExecutionEngine: ).encode() return b"|".join([b"ndarray", header, memoryview(array).tobytes()]) - if isinstance(value, (MeasureTable, RecordTable, list)): + if isinstance(value, (RecordTable, DataTable, list)): return b"[" + b",".join(self._fingerprint_bytes(item) for item in value) + b"]" if isinstance(value, tuple): @@ -494,7 +494,7 @@ class ExecutionEngine: on_preview(node_id, preview) return - if type_name in ("TABLE", "MEASURE_TABLE", "RECORD_TABLE") and isinstance(value, list) and on_table: + if type_name in ("TABLE", "RECORD_TABLE", "DATA_TABLE") and isinstance(value, list) and on_table: on_table(node_id, value) return diff --git a/backend/nodes/acf_1d.py b/backend/nodes/acf_1d.py index c866cdb..0fc9961 100644 --- a/backend/nodes/acf_1d.py +++ b/backend/nodes/acf_1d.py @@ -3,7 +3,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node -from backend.data_types import LineData, MeasureTable +from backend.data_types import LineData, RecordTable from backend.nodes.spectral_common import acf_line_from_data @@ -34,7 +34,7 @@ class ACF1D: OUTPUTS = ( ('LINE', 'acf'), - ('MEASURE_TABLE', 'measurement'), + ('RECORD_TABLE', 'measurement'), ) FUNCTION = "process" @@ -58,4 +58,4 @@ class ACF1D: if peak_lag is not None: rows.append({"quantity": "Peak period", "value": peak_lag, "unit": x_unit}) - return (acf_line, MeasureTable(rows)) + return (acf_line, RecordTable(rows)) diff --git a/backend/nodes/angle_measure.py b/backend/nodes/angle_measure.py index 1f01ff7..104e139 100644 --- a/backend/nodes/angle_measure.py +++ b/backend/nodes/angle_measure.py @@ -5,7 +5,7 @@ import numpy as np from backend.data_types import ( DataField, ImageData, - MeasureTable, + RecordTable, _apply_angle_measure_overlay, encode_preview, image_metadata, @@ -114,7 +114,7 @@ class AngleMeasure: OUTPUTS = ( ('ANNOTATION_SOURCE', 'output'), - ('MEASURE_TABLE', 'measurements'), + ('RECORD_TABLE', 'measurements'), ) FUNCTION = "process" @@ -189,7 +189,7 @@ class AngleMeasure: stroke_width=resolved_stroke_width, color=resolved_color, ) - table = MeasureTable([ + table = RecordTable([ {"quantity": "Angle", "value": angle_deg, "unit": "deg"}, {"quantity": "Arm A length", "value": length_a, "unit": length_unit}, {"quantity": "Arm B length", "value": length_b, "unit": length_unit}, diff --git a/backend/nodes/cursors.py b/backend/nodes/cursors.py index c551c81..38e891e 100644 --- a/backend/nodes/cursors.py +++ b/backend/nodes/cursors.py @@ -2,7 +2,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node from backend.execution_context import emit_overlay -from backend.data_types import DataField, LineData, MeasureTable, encode_preview, render_datafield_preview +from backend.data_types import DataField, LineData, RecordTable, encode_preview, render_datafield_preview @register_node(display_name="Cursors") @@ -28,7 +28,7 @@ class Cursors: } OUTPUTS = ( - ('MEASURE_TABLE', 'measurement'), + ('RECORD_TABLE', 'measurement'), ('COORDPAIR', 'coord_pair'), ) FUNCTION = "process" @@ -107,7 +107,7 @@ class Cursors: }) length = float(np.hypot(xb - xa, yb - ya)) - table = MeasureTable([ + table = RecordTable([ {"quantity": "Length", "value": length, "unit": x_unit}, {"quantity": "dx", "value": xb - xa, "unit": x_unit}, {"quantity": "dy", "value": yb - ya, "unit": y_unit}, @@ -159,7 +159,7 @@ class Cursors: "b_locked": locked, }) - table = MeasureTable([ + table = RecordTable([ {"quantity": "A x", "value": ax, "unit": field.si_unit_xy}, {"quantity": "A y", "value": ay, "unit": field.si_unit_xy}, {"quantity": "A z", "value": z1, "unit": field.si_unit_z}, diff --git a/backend/nodes/curvature.py b/backend/nodes/curvature.py index 3bc8e8e..643bdfe 100644 --- a/backend/nodes/curvature.py +++ b/backend/nodes/curvature.py @@ -8,7 +8,7 @@ from scipy.ndimage import map_coordinates from backend.data_types import ( DataField, LineData, - MeasureTable, + RecordTable, _apply_markup_overlay, encode_preview, render_datafield_preview, @@ -289,7 +289,7 @@ class Curvature: OUTPUTS = ( ('ANNOTATION_SOURCE', 'output'), - ('MEASURE_TABLE', 'measurements'), + ('RECORD_TABLE', 'measurements'), ('LINE', 'profile_1'), ('LINE', 'profile_2'), ) @@ -313,7 +313,7 @@ class Curvature: if results is None: emit_warning("Curvature requires at least six usable pixels for the quadratic fit.") - table = MeasureTable([]) + table = RecordTable([]) emit_table(table) emit_preview(encode_preview(render_datafield_preview(field, field.colormap))) empty = _empty_profile(field.si_unit_xy, field.si_unit_z) @@ -345,7 +345,7 @@ class Curvature: markup_spec = _curvature_markup(field, results["x0"], results["y0"], intersections) output = field.replace(overlays=[*field.overlays, markup_spec]) - table = MeasureTable([ + table = RecordTable([ {"quantity": "Center x position", "value": float(results["x0"]), "unit": field.si_unit_xy}, {"quantity": "Center y position", "value": float(results["y0"]), "unit": field.si_unit_xy}, {"quantity": "Center value", "value": float(results["z0"]), "unit": field.si_unit_z}, diff --git a/backend/nodes/fft_1d.py b/backend/nodes/fft_1d.py index 160d9ec..7a29a14 100644 --- a/backend/nodes/fft_1d.py +++ b/backend/nodes/fft_1d.py @@ -3,7 +3,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node -from backend.data_types import LineData, MeasureTable +from backend.data_types import LineData, RecordTable @register_node(display_name="FFT 1D") @@ -21,7 +21,7 @@ class FFT1D: OUTPUTS = ( ("LINE", "frequency_plot"), - ('MEASURE_TABLE', 'measurement'), + ('RECORD_TABLE', 'measurement'), ) FUNCTION = "process" @@ -55,7 +55,7 @@ class FFT1D: peak_period = float(period_axis[np.argmax(spectrum)]) - table = MeasureTable([ + table = RecordTable([ {"quantity": "Peak period", "value": peak_period, "unit": spatial_unit}, ]) diff --git a/backend/nodes/fractal_dimension.py b/backend/nodes/fractal_dimension.py index 4a0bef7..cb0dad9 100644 --- a/backend/nodes/fractal_dimension.py +++ b/backend/nodes/fractal_dimension.py @@ -5,7 +5,7 @@ from dataclasses import dataclass import numpy as np from scipy.ndimage import map_coordinates -from backend.data_types import LineData, MeasureTable +from backend.data_types import LineData, RecordTable from backend.execution_context import emit_overlay, emit_table, emit_warning from backend.node_registry import register_node @@ -307,7 +307,7 @@ class FractalDimension: OUTPUTS = ( ('FLOAT', 'dimension'), ('LINE', 'curve'), - ('MEASURE_TABLE', 'measurements'), + ('RECORD_TABLE', 'measurements'), ) FUNCTION = "process" @@ -348,7 +348,7 @@ class FractalDimension: dimension = float("nan") emit_warning("Fractal fit range contains fewer than two usable points.") - table = MeasureTable([ + table = RecordTable([ {"quantity": "Dimension", "value": float(dimension), "unit": ""}, {"quantity": "Fit slope", "value": float(slope), "unit": ""}, {"quantity": "Fit intercept", "value": float(intercept), "unit": ""}, diff --git a/backend/nodes/grain_analysis.py b/backend/nodes/grain_analysis.py index 39b5361..f91c249 100644 --- a/backend/nodes/grain_analysis.py +++ b/backend/nodes/grain_analysis.py @@ -1,7 +1,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node -from backend.data_types import DataField, RecordTable +from backend.data_types import DataField, DataTable from backend.nodes.helpers import _square_unit @@ -18,7 +18,7 @@ class GrainAnalysis: } OUTPUTS = ( - ('RECORD_TABLE', 'grain_stats'), + ('DATA_TABLE', 'grain_stats'), ) FUNCTION = "process" @@ -38,7 +38,7 @@ class GrainAnalysis: xy_unit = str(field.si_unit_xy or "").strip() z_unit = str(field.si_unit_z or "").strip() - rows = RecordTable() + rows = DataTable() for gid in range(1, n_grains + 1): grain_pixels = labeled == gid area_px = int(grain_pixels.sum()) diff --git a/backend/nodes/histogram.py b/backend/nodes/histogram.py index 1e3191c..4f610c4 100644 --- a/backend/nodes/histogram.py +++ b/backend/nodes/histogram.py @@ -2,7 +2,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node from backend.execution_context import emit_overlay -from backend.data_types import DataField, MeasureTable +from backend.data_types import DataField, RecordTable @register_node(display_name="Histogram") @@ -22,7 +22,7 @@ class Histogram: } OUTPUTS = ( - ('MEASURE_TABLE', 'measurements'), + ('RECORD_TABLE', 'measurements'), ('COORDPAIR', 'marker_pair'), ) FUNCTION = "process" @@ -88,7 +88,7 @@ class Histogram: "b_locked": False, }) - table = MeasureTable([ + table = RecordTable([ {"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}, diff --git a/backend/nodes/ibw_note.py b/backend/nodes/ibw_note.py index e2ac97d..7711be5 100644 --- a/backend/nodes/ibw_note.py +++ b/backend/nodes/ibw_note.py @@ -3,7 +3,7 @@ from __future__ import annotations import re from backend.node_registry import register_node -from backend.data_types import MeasureTable +from backend.data_types import DataTable from backend.nodes.helpers import _resolve_path, _import_ibw_loader @@ -22,14 +22,10 @@ def _parse_ibw_note(note_bytes: bytes) -> list[dict]: if not match: continue key = match.group(1).strip() - raw_val = match.group(2).strip() + value = match.group(2).strip() if not key: continue - try: - value = float(raw_val) - except (ValueError, TypeError): - continue - rows.append({"quantity": key, "value": value, "unit": ""}) + rows.append({"key": key, "value": value}) return rows @@ -48,13 +44,13 @@ class IBWNote: } OUTPUTS = ( - ('MEASURE_TABLE', 'note'), + ('DATA_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." + "Read the Note metadata from an .ibw file and display all entries " + "as a table of key/value pairs." ) def load(self, filename: str = "", path: str | None = None) -> tuple: @@ -73,6 +69,6 @@ class IBWNote: rows = _parse_ibw_note(note_bytes) if not rows: - raise ValueError("No numeric metadata found in the .ibw note.") + raise ValueError("No metadata found in the .ibw note.") - return (MeasureTable(rows),) + return (DataTable(rows),) diff --git a/backend/nodes/image.py b/backend/nodes/image.py index 6bd38ad..a072ba7 100644 --- a/backend/nodes/image.py +++ b/backend/nodes/image.py @@ -2,6 +2,8 @@ from __future__ import annotations from functools import lru_cache import numpy as np from pathlib import Path +import nanonispy as nap +import gwyfile from backend.node_registry import register_node from backend.execution_context import emit_warning @@ -25,6 +27,7 @@ class Image: } OUTPUTS = ( + ("FILE_PATH", 'path'), ('DATA_FIELD', 'field'), ) FUNCTION = "load" @@ -65,7 +68,7 @@ class Image: if ext not in _SPM_EXTENSIONS: self._send_warning("Uncalibrated data — no physical dimensions.") - return fields + return (str(path_obj.resolve()),) + fields def _send_warning(self, message: str): emit_warning(message) @@ -92,11 +95,6 @@ class Image: @staticmethod def _load_gwy_all(path: Path) -> list[DataField]: - try: - import gwyfile - except ImportError: - raise ImportError("Install 'gwyfile' package to load .gwy files: pip install gwyfile") - obj = gwyfile.load(str(path)) channels = gwyfile.util.get_datafields(obj) if not channels: @@ -118,11 +116,6 @@ class Image: @staticmethod def _load_sxm_all(path: Path) -> list[DataField]: - try: - import nanonispy as nap - except ImportError: - raise ImportError("Install 'nanonispy' package to load .sxm files: pip install nanonispy") - sxm = nap.read.Scan(str(path)) signals = sxm.signals if not signals: diff --git a/backend/nodes/preview_image.py b/backend/nodes/preview_image.py index 1ab5c0c..bfcd9c4 100644 --- a/backend/nodes/preview_image.py +++ b/backend/nodes/preview_image.py @@ -45,8 +45,20 @@ class PreviewImage: input=None, colormap_map=None, ) -> tuple: - field = input if isinstance(input, DataField) else None - image = None if field is not None else input + if isinstance(input, DataField): + field = input + image = None + elif isinstance(input, np.ndarray): + field = None + image = input + elif input is not None: + raise TypeError( + f"Preview expects an IMAGE or DATA_FIELD — got {type(input).__name__}. " + "Check that you are connected to the DATA_FIELD output, not the path socket." + ) + else: + field = None + image = None resolved_colormap = resolve_colormap_input( colormap, diff --git a/backend/nodes/print_table.py b/backend/nodes/print_table.py index 15ebfa2..e6caf53 100644 --- a/backend/nodes/print_table.py +++ b/backend/nodes/print_table.py @@ -9,8 +9,8 @@ class PrintTable: def INPUT_TYPES(cls): return { "required": { - "table": ("MEASURE_TABLE", { - "accepted_types": ["RECORD_TABLE"], + "table": ("RECORD_TABLE", { + "accepted_types": ["DATA_TABLE"], }), } } diff --git a/backend/nodes/save.py b/backend/nodes/save.py index 4257ce7..be13b14 100644 --- a/backend/nodes/save.py +++ b/backend/nodes/save.py @@ -35,8 +35,8 @@ class Save: "IMAGE", "ANNOTATION_SOURCE", "LINE", - "MEASURE_TABLE", "RECORD_TABLE", + "DATA_TABLE", "MESH_MODEL", "FLOAT", ], @@ -48,8 +48,8 @@ class Save: "IMAGE": ["PNG", "TIFF", "NPZ"], "ANNOTATION_SOURCE": ["PNG", "TIFF", "NPZ"], "LINE": ["CSV", "NPZ", "JSON"], - "MEASURE_TABLE": ["CSV", "JSON"], "RECORD_TABLE": ["CSV", "JSON"], + "DATA_TABLE": ["CSV", "JSON"], "FLOAT": ["TXT", "JSON"], "MESH_MODEL": ["OBJ", "STL"], }, diff --git a/backend/nodes/statistics_node.py b/backend/nodes/statistics_node.py index c89e18d..2e26059 100644 --- a/backend/nodes/statistics_node.py +++ b/backend/nodes/statistics_node.py @@ -1,7 +1,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node -from backend.data_types import DataField, MeasureTable +from backend.data_types import DataField, RecordTable @register_node(display_name="Statistics") @@ -15,7 +15,7 @@ class Statistics: } OUTPUTS = ( - ('MEASURE_TABLE', 'stats'), + ('RECORD_TABLE', 'stats'), ) FUNCTION = "process" @@ -31,7 +31,7 @@ class Statistics: skewness = float(np.mean(((d - mean) / rms) ** 3)) if rms > 0 else 0.0 kurtosis = float(np.mean(((d - mean) / rms) ** 4)) if rms > 0 else 0.0 - table = MeasureTable([ + table = RecordTable([ {"quantity": "min", "value": float(d.min()), "unit": field.si_unit_z}, {"quantity": "max", "value": float(d.max()), "unit": field.si_unit_z}, {"quantity": "mean", "value": mean, "unit": field.si_unit_z}, diff --git a/backend/nodes/stats.py b/backend/nodes/stats.py index 77e5850..05c83c1 100644 --- a/backend/nodes/stats.py +++ b/backend/nodes/stats.py @@ -2,7 +2,7 @@ from __future__ import annotations import numpy as np from backend.node_registry import register_node from backend.execution_context import emit_value -from backend.data_types import DataField, LineData, MeasureTable +from backend.data_types import DataField, LineData, RecordTable from backend.nodes.helpers import ( LINE_OPS, TABLE_OPS, @@ -17,7 +17,7 @@ from backend.nodes.helpers import ( @register_node(display_name="Stats") class Stats: - """Polymorphic scalar stats node for LINE, RECORD_TABLE, DATA_FIELD, or IMAGE inputs.""" + """Polymorphic scalar stats node for LINE, DATA_TABLE, DATA_FIELD, or IMAGE inputs.""" _broadcast_value_fn = None _current_node_id: str = "" @@ -27,20 +27,20 @@ class Stats: return { "required": { "input": ("DATA_FIELD", { - "accepted_types": ["IMAGE", "LINE", "RECORD_TABLE"], + "accepted_types": ["IMAGE", "LINE", "DATA_TABLE"], }), "column": ("STRING", { "default": "value", "choices_from_table_input": "input", "show_when_source_type": { - "input": ["RECORD_TABLE"], + "input": ["DATA_TABLE"], }, }), "operation": ("STRING", { "default": "mean", "choices_by_source_type": { "LINE": list(LINE_OPS.keys()), - "RECORD_TABLE": list(TABLE_OPS.keys()), + "DATA_TABLE": list(TABLE_OPS.keys()), "DATA_FIELD": list(ARRAY_OPS.keys()), "IMAGE": list(ARRAY_OPS.keys()), }, @@ -62,7 +62,7 @@ class Stats: def process(self, input, operation: str, column: str = "value") -> tuple: source_type, values, resolved_column = self._resolve_input_values(input, column) - if source_type == "RECORD_TABLE": + if source_type == "DATA_TABLE": ops = TABLE_OPS elif source_type == "LINE": ops = LINE_OPS @@ -93,7 +93,7 @@ class Stats: return _apply_scalar_unit(input_value.y_unit, operation) return "" - if source_type == "RECORD_TABLE" and isinstance(input_value, list) and column: + if source_type == "DATA_TABLE" and isinstance(input_value, list) and column: return _apply_scalar_unit(_common_table_unit(input_value, column), operation) return "" @@ -103,7 +103,7 @@ class Stats: values = np.asarray(input_value.data, dtype=np.float64) return ("DATA_FIELD", values.ravel(), None) - if isinstance(input_value, MeasureTable): + if isinstance(input_value, RecordTable): raise ValueError("Stats only accepts record tables, not measurement tables.") if isinstance(input_value, list): @@ -113,7 +113,7 @@ class Stats: values = extract_numeric_table_values(input_value, column_name) if not values: raise ValueError(f"Column '{column_name}' has no numeric values.") - return ("RECORD_TABLE", np.asarray(values, dtype=np.float64), column_name) + return ("DATA_TABLE", np.asarray(values, dtype=np.float64), column_name) if isinstance(input_value, LineData): values = np.asarray(input_value.data, dtype=np.float64) diff --git a/backend/nodes/value_display.py b/backend/nodes/value_display.py index e8fccee..2eecaa7 100644 --- a/backend/nodes/value_display.py +++ b/backend/nodes/value_display.py @@ -1,7 +1,7 @@ from __future__ import annotations from backend.node_registry import register_node from backend.execution_context import emit_value -from backend.data_types import MeasureTable +from backend.data_types import RecordTable from backend.nodes.helpers import _measurement_entry, _measurement_value, _scalar_payload @@ -12,13 +12,13 @@ class ValueDisplay: return { "required": { "value": ("FLOAT", { - "accepted_types": ["MEASURE_TABLE"], + "accepted_types": ["RECORD_TABLE"], }), "measurement": ("STRING", { "default": "", "choices_from_measure_input": "value", "show_when_source_type": { - "value": ["MEASURE_TABLE"], + "value": ["RECORD_TABLE"], }, }), } @@ -36,7 +36,7 @@ class ValueDisplay: def display_value(self, value, measurement: str = "") -> tuple: unit = "" - if isinstance(value, MeasureTable): + if isinstance(value, RecordTable): row = _measurement_entry(value, measurement) numeric = _measurement_value(value, measurement) unit = row.get("unit", "") if isinstance(row.get("unit"), str) else "" diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 0732bc7..06a2f87 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1271,7 +1271,7 @@ function Flow() { if (!isTrackedNodeRequestCurrent(loadNodeOutputRequestVersionsRef.current, nodeId, requestVersion)) { return; } - setNodeOutputs(nodeId, ['DATA_FIELD'], ['field'], { output_paths: [] }); + setNodeOutputs(nodeId, ['FILE_PATH', 'DATA_FIELD'], ['path', 'field'], { output_paths: [] }); return; } @@ -1281,8 +1281,8 @@ function Flow() { } setNodeOutputs( nodeId, - channels.map((channel) => channel.type), - channels.map((channel) => channel.name), + ['FILE_PATH', ...channels.map((channel) => channel.type)], + ['path', ...channels.map((channel) => channel.name)], { output_paths: [] }, ); }, [getResolvedPathInput, reactFlow, setNodeOutputs]); diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index 8e8ecf9..d97adc5 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -786,6 +786,17 @@ function ColorMapStopsEditor({ nodeId, name, value, onChange }) { } function NodeTable({ rows }) { + const [query, setQuery] = useState(''); + const scrollRef = useRef(null); + + useEffect(() => { + const el = scrollRef.current; + if (!el) return; + const handler = (e) => e.stopPropagation(); + el.addEventListener('wheel', handler, { passive: false }); + return () => el.removeEventListener('wheel', handler); + }, []); + const columns = getTableColumns(rows); if (columns.length === 0) return null; const lowerColumns = columns.map((column) => String(column).toLowerCase()); @@ -804,9 +815,32 @@ function NodeTable({ rows }) { return ''; }; + const filteredRows = query.trim() + ? rows.filter((row) => + columns.some((col) => { + const cell = formatTableRowCell(row, col); + return String(cell).toLowerCase().includes(query.toLowerCase()); + }) + ) + : rows; + return (