remove linemath and tablemath

This commit is contained in:
2026-03-25 22:39:21 -07:00
parent 7f3dfa8fdf
commit 6de239caa1
11 changed files with 251 additions and 195 deletions

View File

@@ -219,10 +219,10 @@ class ExecutionEngine:
) -> None: ) -> None:
"""Wire up broadcast callbacks on display node classes.""" """Wire up broadcast callbacks on display node classes."""
from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay, Markup from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay, Markup
from backend.nodes.analysis import CrossSection, LineCursors, TableMath, Stats, HeightHistogram from backend.nodes.analysis import CrossSection, LineCursors, TableMath, Stats, Histogram
from backend.nodes.modify import CropResizeField, RotateField from backend.nodes.modify import CropResizeField, RotateField
from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask
from backend.nodes.io import SaveImage, LoadFile, LoadDemo from backend.nodes.io import SaveImage, Image, ImageDemo
PreviewImage._broadcast_fn = on_preview PreviewImage._broadcast_fn = on_preview
ThresholdMask._broadcast_fn = on_preview ThresholdMask._broadcast_fn = on_preview
@@ -235,26 +235,26 @@ class ExecutionEngine:
ValueDisplay._broadcast_value_fn = on_value ValueDisplay._broadcast_value_fn = on_value
TableMath._broadcast_value_fn = on_value TableMath._broadcast_value_fn = on_value
Stats._broadcast_value_fn = on_value Stats._broadcast_value_fn = on_value
HeightHistogram._broadcast_overlay_fn = on_overlay Histogram._broadcast_overlay_fn = on_overlay
CrossSection._broadcast_overlay_fn = on_overlay CrossSection._broadcast_overlay_fn = on_overlay
LineCursors._broadcast_overlay_fn = on_overlay LineCursors._broadcast_overlay_fn = on_overlay
CropResizeField._broadcast_overlay_fn = on_overlay CropResizeField._broadcast_overlay_fn = on_overlay
RotateField._broadcast_warning_fn = on_warning RotateField._broadcast_warning_fn = on_warning
Markup._broadcast_overlay_fn = on_overlay Markup._broadcast_overlay_fn = on_overlay
LoadFile._broadcast_warning_fn = on_warning Image._broadcast_warning_fn = on_warning
LoadDemo._broadcast_warning_fn = on_warning ImageDemo._broadcast_warning_fn = on_warning
SaveImage._broadcast_warning_fn = on_warning SaveImage._broadcast_warning_fn = on_warning
def _set_node_id_on_display(self, cls: type, node_id: str) -> None: def _set_node_id_on_display(self, cls: type, node_id: str) -> None:
"""Inform display nodes of their current node_id for WS tagging.""" """Inform display nodes of their current node_id for WS tagging."""
from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay, Markup from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay, Markup
from backend.nodes.analysis import CrossSection, LineCursors, TableMath, Stats, HeightHistogram from backend.nodes.analysis import CrossSection, LineCursors, TableMath, Stats, Histogram
from backend.nodes.modify import CropResizeField, RotateField from backend.nodes.modify import CropResizeField, RotateField
from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask
from backend.nodes.io import LoadFile, LoadDemo, SaveImage from backend.nodes.io import Image, ImageDemo, SaveImage
if cls in (PreviewImage, PrintTable, View3D, ValueDisplay, TableMath, Stats, HeightHistogram, CrossSection, LineCursors, CropResizeField, RotateField, Markup, if cls in (PreviewImage, PrintTable, View3D, ValueDisplay, TableMath, Stats, Histogram, CrossSection, LineCursors, CropResizeField, RotateField, Markup,
ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask, ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask,
LoadFile, LoadDemo, SaveImage): Image, ImageDemo, SaveImage):
cls._current_node_id = node_id cls._current_node_id = node_id
def _auto_preview( def _auto_preview(
@@ -275,12 +275,12 @@ class ExecutionEngine:
from backend.data_types import ( from backend.data_types import (
DataField, image_to_uint8, encode_preview, render_datafield_preview, DataField, image_to_uint8, encode_preview, render_datafield_preview,
) )
from backend.nodes.io import LoadFile, LoadDemo from backend.nodes.io import Image, ImageDemo
if getattr(cls, "_CUSTOM_PREVIEW", False): if getattr(cls, "_CUSTOM_PREVIEW", False):
return return
if cls in (LoadFile, LoadDemo) and on_preview: if cls in (Image, ImageDemo) and on_preview:
preview = self._render_load_node_preview(result, inputs or {}) preview = self._render_load_node_preview(result, inputs or {})
if preview: if preview:
on_preview(node_id, preview) on_preview(node_id, preview)

98
backend/node_menu.py Normal file
View File

@@ -0,0 +1,98 @@
"""
Central Add Node menu manifest.
Edit MENU_LAYOUT to rearrange which nodes appear under each menu leaf and
their order within that leaf. Node classes not listed here fall back to their
class CATEGORY.
"""
from __future__ import annotations
from typing import Any
MENU_LAYOUT: dict[str, list[str]] = {
"Add": [
"Image",
"ImageDemo",
"Folder",
"ColorMap",
"Number",
"RangeSlider",
"Coordinate",
"Font",
],
"Output": [
"PreviewImage",
"SaveImage",
"View3D",
"PrintTable",
"ValueDisplay",
],
"Overlay": [
"Markup",
"Annotations",
],
"Modify": [
"ColormapAdjust",
"CropResizeField",
"RotateField",
],
"Filter": [
"GaussianFilter",
"MedianFilter",
"EdgeDetect",
"FFTFilter1D",
"FFTFilter2D",
],
"Frequency": [
"FFT2D",
"InverseFFT2D",
],
"Flatten": [
"PlaneLevelField",
"PolyLevelField",
"FixZero",
],
"Measure": [
"Statistics",
"Histogram",
"LineCursors",
"CrossSection",
"Stats",
],
"Mask": [
"DrawMask",
"ThresholdMask",
"MaskMorphology",
"MaskInvert",
"MaskCombine",
],
"Particles": [
"ParticleAnalysis",
],
}
_CATEGORY_ORDER = {category: index for index, category in enumerate(MENU_LAYOUT)}
_NODE_METADATA: dict[str, dict[str, Any]] = {}
for category, class_names in MENU_LAYOUT.items():
for node_order, class_name in enumerate(class_names):
_NODE_METADATA[class_name] = {
"category": category,
"category_order": _CATEGORY_ORDER[category],
"menu_order": node_order,
}
def get_menu_metadata(class_name: str, fallback_category: str = "uncategorized") -> dict[str, Any]:
metadata = _NODE_METADATA.get(class_name)
if metadata is not None:
return dict(metadata)
fallback_order = _CATEGORY_ORDER.get(fallback_category, len(_CATEGORY_ORDER))
return {
"category": fallback_category,
"category_order": fallback_order,
"menu_order": 10_000,
}

View File

@@ -9,6 +9,8 @@ the execution engine and the /nodes REST endpoint.
from __future__ import annotations from __future__ import annotations
from typing import Any from typing import Any
from backend.node_menu import get_menu_metadata
NODE_CLASS_MAPPINGS: dict[str, type] = {} NODE_CLASS_MAPPINGS: dict[str, type] = {}
NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {} NODE_DISPLAY_NAME_MAPPINGS: dict[str, str] = {}
@@ -37,11 +39,14 @@ def get_node_info(class_name: str) -> dict[str, Any]:
""" """
cls = NODE_CLASS_MAPPINGS[class_name] cls = NODE_CLASS_MAPPINGS[class_name]
input_types: dict = cls.INPUT_TYPES() input_types: dict = cls.INPUT_TYPES()
menu_metadata = get_menu_metadata(class_name, getattr(cls, "CATEGORY", "uncategorized"))
return { return {
"name": class_name, "name": class_name,
"display_name": NODE_DISPLAY_NAME_MAPPINGS.get(class_name, class_name), "display_name": NODE_DISPLAY_NAME_MAPPINGS.get(class_name, class_name),
"category": getattr(cls, "CATEGORY", "uncategorized"), "category": menu_metadata["category"],
"category_order": menu_metadata["category_order"],
"menu_order": menu_metadata["menu_order"],
"input": input_types, "input": input_types,
"input_order": {k: list(v.keys()) for k, v in input_types.items()}, "input_order": {k: list(v.keys()) for k, v in input_types.items()},
"output": list(cls.RETURN_TYPES), "output": list(cls.RETURN_TYPES),

View File

@@ -2,8 +2,8 @@
Analysis nodes — statistics, histograms, FFT, cross sections. Analysis nodes — statistics, histograms, FFT, cross sections.
Gwyddion equivalents: Gwyddion equivalents:
StatisticsNode → gwy_data_field_get_min/max/avg/rms (libprocess/stats.h) Statistics → gwy_data_field_get_min/max/avg/rms (libprocess/stats.h)
HeightHistogram → DH (height distribution), gwy_data_field_dh Histogram → DH (height distribution), gwy_data_field_dh
FFT2D → gwy_data_field_2dfft + gwy_data_field_2dpsdf FFT2D → gwy_data_field_2dfft + gwy_data_field_2dpsdf
CrossSection → gwy_data_field_get_profile (libprocess/datafield.c) CrossSection → gwy_data_field_get_profile (libprocess/datafield.c)
""" """
@@ -16,11 +16,11 @@ from backend.data_types import DataField, MeasureTable, RecordTable, datafield_t
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# StatisticsNode # Statistics
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@register_node(display_name="Statistics") @register_node(display_name="Statistics")
class StatisticsNode: class Statistics:
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
@@ -59,11 +59,11 @@ class StatisticsNode:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# HeightHistogram # Histogram
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@register_node(display_name="Height Histogram") @register_node(display_name="Height Histogram")
class HeightHistogram: class Histogram:
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
@@ -130,9 +130,9 @@ class HeightHistogram:
yb = float(counts[idx_b]) if len(counts) else 0.0 yb = float(counts[idx_b]) if len(counts) else 0.0
count_unit = "count" if y_scale == "linear" else "log10(1+count)" count_unit = "count" if y_scale == "linear" else "log10(1+count)"
if HeightHistogram._broadcast_overlay_fn is not None: if Histogram._broadcast_overlay_fn is not None:
HeightHistogram._broadcast_overlay_fn( Histogram._broadcast_overlay_fn(
HeightHistogram._current_node_id, Histogram._current_node_id,
{ {
"kind": "line_plot", "kind": "line_plot",
"section_title": "Histogram", "section_title": "Histogram",
@@ -754,36 +754,6 @@ def _op_da(z):
return float(np.mean(np.abs(np.diff(z)))) return float(np.mean(np.abs(np.diff(z))))
@register_node(display_name="Line Math")
class LineMath:
"""Compute a single scalar value from a LINE profile."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"line": ("LINE",),
"operation": (list(LINE_OPS.keys()),),
}
}
RETURN_TYPES = ("MEASURE_TABLE",)
RETURN_NAMES = ("result",)
FUNCTION = "process"
CATEGORY = "analysis"
DESCRIPTION = (
"Compute a single scalar measurement from a LINE profile. "
"Includes basic stats and Gwyddion-convention roughness parameters."
)
def process(self, line, operation: str) -> tuple:
z = np.asarray(line, dtype=np.float64).ravel()
fn, unit = LINE_OPS[operation]
value = fn(z)
table = MeasureTable([{"quantity": operation, "value": value, "unit": unit}])
return (table,)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# TableMath — scalar measurement from a numeric record-table column # TableMath — scalar measurement from a numeric record-table column
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -869,56 +839,6 @@ def _scalar_payload(value: float, unit: str = "") -> dict:
return payload return payload
@register_node(display_name="Table Math")
class TableMath:
"""Compute a scalar reduction over one numeric column in a record table."""
_broadcast_value_fn = None
_current_node_id: str = ""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"table": ("RECORD_TABLE",),
"column": ("STRING", {
"default": "value",
"choices_from_table_input": "table",
}),
"operation": (list(TABLE_OPS.keys()),),
}
}
RETURN_TYPES = ("FLOAT",)
RETURN_NAMES = ("value",)
FUNCTION = "process"
CATEGORY = "analysis"
DESCRIPTION = (
"Compute a scalar reduction over one numeric record-table column. "
"Useful for max, min, avg, median, sum, range, std, variance, and count."
)
def process(self, table: list, column: str, operation: str) -> tuple:
if isinstance(table, MeasureTable):
raise ValueError("Table Math only accepts record tables, not measurement tables.")
if not isinstance(table, list) or not table:
raise ValueError("Table Math requires a non-empty record table input.")
column_name = resolve_table_column_name(table, column)
values = extract_numeric_table_values(table, column_name)
if not values:
raise ValueError(f"Column '{column_name}' has no numeric values.")
op = TABLE_OPS.get(operation)
if op is None:
raise ValueError(f"Unsupported table operation: {operation}")
result = op(np.asarray(values, dtype=np.float64))
if TableMath._broadcast_value_fn is not None:
TableMath._broadcast_value_fn(TableMath._current_node_id, result)
return (result,)
def extract_numeric_table_values(table: list, column: str) -> list[float]: def extract_numeric_table_values(table: list, column: str) -> list[float]:
values = [] values = []
for row in table: for row in table:

View File

@@ -125,11 +125,11 @@ def list_folder_paths(folderpath: str) -> list[dict]:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# LoadFile (unified loader — replaces LoadImage + LoadSPM) # Image (unified loader — replaces LoadImage + LoadSPM)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@register_node(display_name="Load File") @register_node(display_name="Image")
class LoadFile: class Image:
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
@@ -185,8 +185,8 @@ class LoadFile:
return (field,) return (field,)
def _send_warning(self, message: str): def _send_warning(self, message: str):
fn = LoadFile._broadcast_warning_fn fn = Image._broadcast_warning_fn
nid = LoadFile._current_node_id nid = Image._current_node_id
if fn and nid: if fn and nid:
fn(nid, message) fn(nid, message)
@@ -353,7 +353,7 @@ class LoadFile:
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# LoadDemo # ImageDemo
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
def _list_demo_files() -> list[str]: def _list_demo_files() -> list[str]:
@@ -366,8 +366,8 @@ def _list_demo_files() -> list[str]:
) )
@register_node(display_name="Load Demo File") @register_node(display_name="Image (Demo)")
class LoadDemo: class ImageDemo:
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
choices = _list_demo_files() or ["(no demo files found)"] choices = _list_demo_files() or ["(no demo files found)"]
@@ -388,7 +388,7 @@ class LoadDemo:
DESCRIPTION = "Load a bundled demo file so you can try the app without providing your own data." DESCRIPTION = "Load a bundled demo file so you can try the app without providing your own data."
def load(self, name: str = "", colormap: str = "viridis", colormap_map=None): def load(self, name: str = "", colormap: str = "viridis", colormap_map=None):
loader = LoadFile() loader = Image()
demo_path = DEMO_DIR / name demo_path = DEMO_DIR / name
if not demo_path.exists(): if not demo_path.exists():
raise FileNotFoundError(f"Demo file not found: {name}") raise FileNotFoundError(f"Demo file not found: {name}")

View File

@@ -80,6 +80,23 @@ function sameStringArray(a = [], b = []) {
return a.every((item, index) => item === b[index]); return a.every((item, index) => item === b[index]);
} }
function compareMenuNodes(a, b) {
const orderA = Number.isFinite(a?.def?.menu_order) ? a.def.menu_order : Number.MAX_SAFE_INTEGER;
const orderB = Number.isFinite(b?.def?.menu_order) ? b.def.menu_order : Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
const nameA = (a?.def?.display_name || a?.className || '').toLowerCase();
const nameB = (b?.def?.display_name || b?.className || '').toLowerCase();
return nameA.localeCompare(nameB);
}
function compareMenuCategories(a, b) {
const orderA = Number.isFinite(a?.order) ? a.order : Number.MAX_SAFE_INTEGER;
const orderB = Number.isFinite(b?.order) ? b.order : Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
return String(a?.name || '').localeCompare(String(b?.name || ''));
}
function socketTypesCompatible(sourceType, targetType) { function socketTypesCompatible(sourceType, targetType) {
if (sourceType === targetType) return true; if (sourceType === targetType) return true;
const accepted = SOCKET_COMPATIBILITY[targetType]; const accepted = SOCKET_COMPATIBILITY[targetType];
@@ -272,10 +289,25 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti
} }
} }
const cat = def.category || 'uncategorized'; const cat = def.category || 'uncategorized';
if (!cats[cat]) cats[cat] = []; if (!cats[cat]) {
cats[cat].push({ className, def }); cats[cat] = {
name: cat,
order: Number.isFinite(def.category_order) ? def.category_order : Number.MAX_SAFE_INTEGER,
items: [],
};
}
cats[cat].order = Math.min(
cats[cat].order,
Number.isFinite(def.category_order) ? def.category_order : Number.MAX_SAFE_INTEGER,
);
cats[cat].items.push({ className, def });
} }
return cats; return Object.values(cats)
.map((category) => ({
...category,
items: [...category.items].sort(compareMenuNodes),
}))
.sort(compareMenuCategories);
}, [nodeDefs, filterType, filterDirection]); }, [nodeDefs, filterType, filterDirection]);
// Flat filtered list for search // Flat filtered list for search
@@ -283,8 +315,8 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti
if (!search.trim()) return null; if (!search.trim()) return null;
const q = search.toLowerCase(); const q = search.toLowerCase();
const results = []; const results = [];
for (const items of Object.values(categories)) { for (const category of categories) {
for (const { className, def } of items) { for (const { className, def } of category.items) {
const name = (def.display_name || className).toLowerCase(); const name = (def.display_name || className).toLowerCase();
if (name.includes(q)) results.push({ className, def }); if (name.includes(q)) results.push({ className, def });
} }
@@ -341,7 +373,7 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti
setOpenCat(cat); setOpenCat(cat);
}, []); }, []);
if (Object.keys(categories).length === 0) { if (categories.length === 0) {
return ( return (
<div className="context-menu" ref={menuRef} style={{ left: menuPos.x, top: menuPos.y }} onClick={(e) => e.stopPropagation()}> <div className="context-menu" ref={menuRef} style={{ left: menuPos.x, top: menuPos.y }} onClick={(e) => e.stopPropagation()}>
<div className="context-item" style={{ color: '#64748b' }}>No compatible nodes</div> <div className="context-item" style={{ color: '#64748b' }}>No compatible nodes</div>
@@ -349,7 +381,8 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti
); );
} }
const catNames = Object.keys(categories).sort(); const catNames = categories.map((category) => category.name);
const categoryMap = Object.fromEntries(categories.map((category) => [category.name, category.items]));
return ( return (
<> <>
@@ -411,7 +444,7 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti
</div> </div>
{/* Submenu rendered as a sibling, positioned at computed screen coords */} {/* Submenu rendered as a sibling, positioned at computed screen coords */}
{openCat && categories[openCat] && ( {openCat && categoryMap[openCat] && (
<div <div
className="context-menu ctx-submenu" className="context-menu ctx-submenu"
ref={subMenuRef} ref={subMenuRef}
@@ -423,7 +456,7 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti
setOpenCat(null); setOpenCat(null);
}} }}
> >
{categories[openCat].map(({ className, def }) => ( {categoryMap[openCat].map(({ className, def }) => (
<div <div
key={className} key={className}
className="context-item" className="context-item"
@@ -512,9 +545,9 @@ function Flow() {
resolvedPath = getResolvedPathInput(nodeId); resolvedPath = getResolvedPathInput(nodeId);
} }
if (!resolvedPath) { if (!resolvedPath) {
if (node.data.className === 'LoadFile') { if (node.data.className === 'Image') {
resolvedPath = node.data.widgetValues?.filename || ''; resolvedPath = node.data.widgetValues?.filename || '';
} else if (node.data.className === 'LoadDemo') { } else if (node.data.className === 'ImageDemo') {
resolvedPath = node.data.widgetValues?.name || ''; resolvedPath = node.data.widgetValues?.name || '';
} }
} }
@@ -707,7 +740,7 @@ function Flow() {
refreshFolderNodeOutputs(nodeId, value); refreshFolderNodeOutputs(nodeId, value);
} }
if (node && (node.data.className === 'LoadFile' || node.data.className === 'LoadDemo') && (name === 'filename' || name === 'name')) { if (node && (node.data.className === 'Image' || node.data.className === 'ImageDemo') && (name === 'filename' || name === 'name')) {
refreshLoadNodeOutputs(nodeId, value); refreshLoadNodeOutputs(nodeId, value);
} }
@@ -803,11 +836,11 @@ function Flow() {
refreshFolderNodeOutputs(newNodeId, widgetValues.folder); refreshFolderNodeOutputs(newNodeId, widgetValues.folder);
} }
// For LoadFile/LoadDemo, auto-fetch channels for the default value // For Image/ImageDemo, auto-fetch channels for the default value
if (className === 'LoadDemo' && widgetValues.name) { if (className === 'ImageDemo' && widgetValues.name) {
refreshLoadNodeOutputs(newNodeId, widgetValues.name); refreshLoadNodeOutputs(newNodeId, widgetValues.name);
} }
if (className === 'LoadFile' && widgetValues.filename) { if (className === 'Image' && widgetValues.filename) {
refreshLoadNodeOutputs(newNodeId, widgetValues.filename); refreshLoadNodeOutputs(newNodeId, widgetValues.filename);
} }
@@ -924,7 +957,7 @@ function Flow() {
} }
}); });
hydrated.nodes.forEach((node) => { hydrated.nodes.forEach((node) => {
if (node.data.className === 'LoadFile' || node.data.className === 'LoadDemo') { if (node.data.className === 'Image' || node.data.className === 'ImageDemo') {
refreshLoadNodeOutputs(node.id); refreshLoadNodeOutputs(node.id);
} }
}); });

View File

@@ -868,10 +868,10 @@ function CustomNode({ id, data }) {
if (data.className === 'Folder') { if (data.className === 'Folder') {
return getBasename(data.widgetValues?.folder); return getBasename(data.widgetValues?.folder);
} }
if (data.className === 'LoadFile') { if (data.className === 'Image') {
return getBasename(connectedPathInfo?.path || data.widgetValues?.filename); return getBasename(connectedPathInfo?.path || data.widgetValues?.filename);
} }
if (data.className === 'LoadDemo') { if (data.className === 'ImageDemo') {
return getBasename(data.widgetValues?.name); return getBasename(data.widgetValues?.name);
} }
return ''; return '';

View File

@@ -21,14 +21,14 @@ export function getConnectedNodeIds(edges) {
} }
function isPreviewLoadNode(node) { function isPreviewLoadNode(node) {
return ['LoadFile', 'LoadDemo'].includes(node?.data?.className); return ['Image', 'ImageDemo'].includes(node?.data?.className);
} }
function hasPreviewLoadSelection(node) { function hasPreviewLoadSelection(node) {
if (node?.data?.className === 'LoadFile') { if (node?.data?.className === 'Image') {
return !!String(node.data?.widgetValues?.filename || '').trim(); return !!String(node.data?.widgetValues?.filename || '').trim();
} }
if (node?.data?.className === 'LoadDemo') { if (node?.data?.className === 'ImageDemo') {
return !!String(node.data?.widgetValues?.name || '').trim(); return !!String(node.data?.widgetValues?.name || '').trim();
} }
return false; return false;

View File

@@ -12,7 +12,7 @@ test('serializeExecutionGraph excludes isolated nodes from the backend prompt',
{ {
id: '1', id: '1',
data: { data: {
className: 'LoadFile', className: 'Image',
definition: { definition: {
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} }, input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
manual_trigger: false, manual_trigger: false,
@@ -34,7 +34,7 @@ test('serializeExecutionGraph excludes isolated nodes from the backend prompt',
{ {
id: '3', id: '3',
data: { data: {
className: 'LoadFile', className: 'Image',
definition: { definition: {
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} }, input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
manual_trigger: false, manual_trigger: false,
@@ -56,7 +56,7 @@ test('serializeExecutionGraph excludes isolated nodes from the backend prompt',
assert.deepEqual(prompt, { assert.deepEqual(prompt, {
'1': { '1': {
class_type: 'LoadFile', class_type: 'Image',
inputs: { filename: 'scan.gwy' }, inputs: { filename: 'scan.gwy' },
}, },
'2': { '2': {
@@ -72,7 +72,7 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con
{ {
id: '1', id: '1',
data: { data: {
className: 'LoadFile', className: 'Image',
definition: { definition: {
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} }, input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
manual_trigger: false, manual_trigger: false,
@@ -94,7 +94,7 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con
{ {
id: '3', id: '3',
data: { data: {
className: 'LoadDemo', className: 'ImageDemo',
definition: { definition: {
input: { required: { name: [['demo.npy'], {}] }, optional: {} }, input: { required: { name: [['demo.npy'], {}] }, optional: {} },
manual_trigger: false, manual_trigger: false,
@@ -105,7 +105,7 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con
{ {
id: '4', id: '4',
data: { data: {
className: 'LoadFile', className: 'Image',
definition: { definition: {
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} }, input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
manual_trigger: false, manual_trigger: false,
@@ -127,7 +127,7 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con
assert.deepEqual(prompt, { assert.deepEqual(prompt, {
'1': { '1': {
class_type: 'LoadFile', class_type: 'Image',
inputs: { filename: 'first.gwy' }, inputs: { filename: 'first.gwy' },
}, },
'2': { '2': {
@@ -135,19 +135,19 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con
inputs: { field: ['1', 0] }, inputs: { field: ['1', 0] },
}, },
'3': { '3': {
class_type: 'LoadDemo', class_type: 'ImageDemo',
inputs: { name: 'demo.npy' }, inputs: { name: 'demo.npy' },
}, },
}); });
assert.equal('4' in prompt, false); assert.equal('4' in prompt, false);
}); });
test('serializeExecutionGraph allows a singleton LoadFile graph so previews can run', () => { test('serializeExecutionGraph allows a singleton Image graph so previews can run', () => {
const nodes = [ const nodes = [
{ {
id: '1', id: '1',
data: { data: {
className: 'LoadFile', className: 'Image',
definition: { definition: {
input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} }, input: { required: { filename: ['FILE_PICKER', {}] }, optional: {} },
manual_trigger: false, manual_trigger: false,
@@ -161,18 +161,18 @@ test('serializeExecutionGraph allows a singleton LoadFile graph so previews can
assert.deepEqual(prompt, { assert.deepEqual(prompt, {
'1': { '1': {
class_type: 'LoadFile', class_type: 'Image',
inputs: { filename: 'scan.gwy' }, inputs: { filename: 'scan.gwy' },
}, },
}); });
}); });
test('serializeExecutionGraph allows a singleton LoadDemo graph so previews can run', () => { test('serializeExecutionGraph allows a singleton ImageDemo graph so previews can run', () => {
const nodes = [ const nodes = [
{ {
id: '1', id: '1',
data: { data: {
className: 'LoadDemo', className: 'ImageDemo',
definition: { definition: {
input: { required: { name: [['demo.npy'], {}] }, optional: {} }, input: { required: { name: [['demo.npy'], {}] }, optional: {} },
manual_trigger: false, manual_trigger: false,
@@ -186,7 +186,7 @@ test('serializeExecutionGraph allows a singleton LoadDemo graph so previews can
assert.deepEqual(prompt, { assert.deepEqual(prompt, {
'1': { '1': {
class_type: 'LoadDemo', class_type: 'ImageDemo',
inputs: { name: 'demo.npy' }, inputs: { name: 'demo.npy' },
}, },
}); });
@@ -214,10 +214,10 @@ test('getAutoRunnableNodes ignores disconnected nodes when deciding what can aut
test('getAutoRunnableNodes includes isolated preview-load nodes with selections', () => { test('getAutoRunnableNodes includes isolated preview-load nodes with selections', () => {
const nodes = [ const nodes = [
{ id: '1', data: { className: 'LoadFile', definition: {}, widgetValues: { filename: 'first.gwy' } } }, { id: '1', data: { className: 'Image', definition: {}, widgetValues: { filename: 'first.gwy' } } },
{ id: '2', data: { className: 'PreviewImage', definition: {}, widgetValues: {} } }, { id: '2', data: { className: 'PreviewImage', definition: {}, widgetValues: {} } },
{ id: '3', data: { className: 'LoadDemo', definition: {}, widgetValues: { name: 'demo.npy' } } }, { id: '3', data: { className: 'ImageDemo', definition: {}, widgetValues: { name: 'demo.npy' } } },
{ id: '4', data: { className: 'LoadFile', definition: {}, widgetValues: { filename: '' } } }, { id: '4', data: { className: 'Image', definition: {}, widgetValues: { filename: '' } } },
]; ];
const edges = [ const edges = [
{ {
@@ -233,12 +233,12 @@ test('getAutoRunnableNodes includes isolated preview-load nodes with selections'
assert.deepEqual(runnable.map((node) => node.id), ['1', '2', '3']); assert.deepEqual(runnable.map((node) => node.id), ['1', '2', '3']);
}); });
test('getAutoRunnableNodes allows a singleton LoadFile graph', () => { test('getAutoRunnableNodes allows a singleton Image graph', () => {
const nodes = [ const nodes = [
{ {
id: '1', id: '1',
data: { data: {
className: 'LoadFile', className: 'Image',
definition: {}, definition: {},
widgetValues: { filename: 'scan.gwy' }, widgetValues: { filename: 'scan.gwy' },
}, },
@@ -250,12 +250,12 @@ test('getAutoRunnableNodes allows a singleton LoadFile graph', () => {
assert.deepEqual(runnable.map((node) => node.id), ['1']); assert.deepEqual(runnable.map((node) => node.id), ['1']);
}); });
test('getAutoRunnableNodes allows a singleton LoadDemo graph', () => { test('getAutoRunnableNodes allows a singleton ImageDemo graph', () => {
const nodes = [ const nodes = [
{ {
id: '1', id: '1',
data: { data: {
className: 'LoadDemo', className: 'ImageDemo',
definition: {}, definition: {},
widgetValues: { name: 'demo.npy' }, widgetValues: { name: 'demo.npy' },
}, },

View File

@@ -103,7 +103,7 @@ test('hydrateWorkflowState clears shared path widgets while restoring saved dyna
id: '12', id: '12',
position: { x: 40, y: 80 }, position: { x: 40, y: 80 },
data: { data: {
className: 'LoadFile', className: 'Image',
widgetValues: { filename: 'scan.ibw', colormap: 'viridis' }, widgetValues: { filename: 'scan.ibw', colormap: 'viridis' },
output: ['DATA_FIELD', 'DATA_FIELD'], output: ['DATA_FIELD', 'DATA_FIELD'],
output_name: ['Height', 'Phase'], output_name: ['Height', 'Phase'],
@@ -123,7 +123,7 @@ test('hydrateWorkflowState clears shared path widgets while restoring saved dyna
}; };
const defs = { const defs = {
LoadFile: { Image: {
category: 'io', category: 'io',
input: { required: { filename: ['FILE_PICKER', {}], colormap: [['viridis', 'gray'], {}] } }, input: { required: { filename: ['FILE_PICKER', {}], colormap: [['viridis', 'gray'], {}] } },
output: ['DATA_FIELD'], output: ['DATA_FIELD'],
@@ -138,13 +138,13 @@ test('hydrateWorkflowState clears shared path widgets while restoring saved dyna
assert.deepEqual(hydrated.edges, saved.edges); assert.deepEqual(hydrated.edges, saved.edges);
assert.equal(hydrated.nodes[0].type, 'custom'); assert.equal(hydrated.nodes[0].type, 'custom');
assert.equal(hydrated.nodes[0].dragHandle, '.drag-handle'); assert.equal(hydrated.nodes[0].dragHandle, '.drag-handle');
assert.equal(hydrated.nodes[0].data.label, 'LoadFile'); assert.equal(hydrated.nodes[0].data.label, 'Image');
assert.equal(hydrated.nodes[0].data.previewImage, null); assert.equal(hydrated.nodes[0].data.previewImage, null);
assert.equal(hydrated.nodes[0].data.widgetValues.filename, ''); assert.equal(hydrated.nodes[0].data.widgetValues.filename, '');
assert.equal(hydrated.nodes[0].data.widgetValues.colormap, 'viridis'); assert.equal(hydrated.nodes[0].data.widgetValues.colormap, 'viridis');
assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD', 'DATA_FIELD']); assert.deepEqual(hydrated.nodes[0].data.definition.output, ['DATA_FIELD', 'DATA_FIELD']);
assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['Height', 'Phase']); assert.deepEqual(hydrated.nodes[0].data.definition.output_name, ['Height', 'Phase']);
assert.deepEqual(hydrated.nodes[0].data.definition.input, defs.LoadFile.input); assert.deepEqual(hydrated.nodes[0].data.definition.input, defs.Image.input);
}); });
test('serializeWorkflowState and hydrateWorkflowState clear path-like widgets but preserve other metadata', () => { test('serializeWorkflowState and hydrateWorkflowState clear path-like widgets but preserve other metadata', () => {
@@ -153,8 +153,8 @@ test('serializeWorkflowState and hydrateWorkflowState clear path-like widgets bu
id: '7', id: '7',
position: { x: 10, y: 20 }, position: { x: 10, y: 20 },
data: { data: {
label: 'Load File', label: 'Image',
className: 'LoadFile', className: 'Image',
widgetValues: { filename: 'scan.gwy', colormap: 'gray' }, widgetValues: { filename: 'scan.gwy', colormap: 'gray' },
definition: { definition: {
category: 'io', category: 'io',
@@ -176,7 +176,7 @@ test('serializeWorkflowState and hydrateWorkflowState clear path-like widgets bu
}, },
]; ];
const defs = { const defs = {
LoadFile: { Image: {
category: 'io', category: 'io',
input: { required: { filename: ['FILE_PICKER', {}], colormap: [['gray', 'viridis'], {}] } }, input: { required: { filename: ['FILE_PICKER', {}], colormap: [['gray', 'viridis'], {}] } },
output: ['DATA_FIELD'], output: ['DATA_FIELD'],

View File

@@ -476,9 +476,9 @@ def test_fix_zero():
# ========================================================================= # =========================================================================
def test_statistics(): def test_statistics():
print("=== Test: StatisticsNode ===") print("=== Test: Statistics ===")
from backend.nodes.analysis import StatisticsNode from backend.nodes.analysis import Statistics
node = StatisticsNode() node = Statistics()
data = np.array([[1, 2], [3, 4]], dtype=np.float64) data = np.array([[1, 2], [3, 4]], dtype=np.float64)
field = make_field(data=data) field = make_field(data=data)
@@ -506,17 +506,17 @@ def test_statistics():
def test_height_histogram(): def test_height_histogram():
print("=== Test: HeightHistogram ===") print("=== Test: Histogram ===")
from backend.nodes.analysis import HeightHistogram from backend.nodes.analysis import Histogram
node = HeightHistogram() node = Histogram()
# Uniform data should give a roughly flat histogram # Uniform data should give a roughly flat histogram
data = np.linspace(0, 1, 1000).reshape(25, 40) data = np.linspace(0, 1, 1000).reshape(25, 40)
field = make_field(data=data) field = make_field(data=data)
overlays = [] overlays = []
HeightHistogram._broadcast_overlay_fn = lambda nid, data: overlays.append(data) Histogram._broadcast_overlay_fn = lambda nid, data: overlays.append(data)
HeightHistogram._current_node_id = "test" Histogram._current_node_id = "test"
table, = node.process( table, = node.process(
field, field,
@@ -549,7 +549,7 @@ def test_height_histogram():
measurements["B count"]["value"] - measurements["A count"]["value"], measurements["B count"]["value"] - measurements["A count"]["value"],
) )
HeightHistogram._broadcast_overlay_fn = None Histogram._broadcast_overlay_fn = None
print(" PASS\n") print(" PASS\n")
@@ -829,10 +829,10 @@ def test_particle_analysis():
# ========================================================================= # =========================================================================
def test_load_file(): def test_load_file():
print("=== Test: LoadFile ===") print("=== Test: Image ===")
from backend.nodes.io import LoadFile from backend.nodes.io import Image
from PIL import Image from PIL import Image
node = LoadFile() node = Image()
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
# Test loading a grayscale PNG → single DataField output # Test loading a grayscale PNG → single DataField output
@@ -1247,10 +1247,10 @@ def test_value_display():
# ========================================================================= # =========================================================================
def test_load_file_ibw(): def test_load_file_ibw():
print("=== Test: LoadFile IBW multi-channel ===") print("=== Test: Image IBW multi-channel ===")
from backend.nodes.io import LoadFile from backend.nodes.io import Image
node = LoadFile() node = Image()
ibw_path = os.path.join(os.path.dirname(__file__), "..", "demo", "BR_New20012.ibw") ibw_path = os.path.join(os.path.dirname(__file__), "..", "demo", "BR_New20012.ibw")
ibw_path = os.path.abspath(ibw_path) ibw_path = os.path.abspath(ibw_path)
if not os.path.exists(ibw_path): if not os.path.exists(ibw_path):
@@ -1283,10 +1283,10 @@ def test_load_file_ibw():
def test_load_file_npz(): def test_load_file_npz():
print("=== Test: LoadFile .npz ===") print("=== Test: Image .npz ===")
from backend.nodes.io import LoadFile from backend.nodes.io import Image
node = LoadFile() node = Image()
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
data = np.random.default_rng(99).standard_normal((30, 40)) data = np.random.default_rng(99).standard_normal((30, 40))
path = os.path.join(tmpdir, "test.npz") path = os.path.join(tmpdir, "test.npz")
@@ -1300,10 +1300,10 @@ def test_load_file_npz():
def test_load_file_not_found(): def test_load_file_not_found():
print("=== Test: LoadFile not found ===") print("=== Test: Image not found ===")
from backend.nodes.io import LoadFile from backend.nodes.io import Image
node = LoadFile() node = Image()
try: try:
node.load(filename="/nonexistent/path/file.png") node.load(filename="/nonexistent/path/file.png")
assert False, "Should have raised FileNotFoundError" assert False, "Should have raised FileNotFoundError"
@@ -1314,10 +1314,10 @@ def test_load_file_not_found():
def test_load_file_unsupported(): def test_load_file_unsupported():
print("=== Test: LoadFile unsupported format ===") print("=== Test: Image unsupported format ===")
from backend.nodes.io import LoadFile from backend.nodes.io import Image
node = LoadFile() node = Image()
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
path = os.path.join(tmpdir, "test.xyz") path = os.path.join(tmpdir, "test.xyz")
with open(path, "w") as f: with open(path, "w") as f:
@@ -1332,14 +1332,14 @@ def test_load_file_unsupported():
def test_load_file_warning(): def test_load_file_warning():
print("=== Test: LoadFile warning for uncalibrated data ===") print("=== Test: Image warning for uncalibrated data ===")
from backend.nodes.io import LoadFile from backend.nodes.io import Image
from PIL import Image from PIL import Image
node = LoadFile() node = Image()
warnings = [] warnings = []
LoadFile._broadcast_warning_fn = lambda nid, msg: warnings.append(msg) Image._broadcast_warning_fn = lambda nid, msg: warnings.append(msg)
LoadFile._current_node_id = "test" Image._current_node_id = "test"
with tempfile.TemporaryDirectory() as tmpdir: with tempfile.TemporaryDirectory() as tmpdir:
arr = np.random.default_rng(10).integers(0, 256, (16, 16), dtype=np.uint8) arr = np.random.default_rng(10).integers(0, 256, (16, 16), dtype=np.uint8)
@@ -1352,7 +1352,7 @@ def test_load_file_warning():
assert len(warnings) == 1 assert len(warnings) == 1
assert "Uncalibrated" in warnings[0] assert "Uncalibrated" in warnings[0]
LoadFile._broadcast_warning_fn = None Image._broadcast_warning_fn = None
print(" PASS\n") print(" PASS\n")
@@ -1428,14 +1428,14 @@ def test_list_channels():
# ========================================================================= # =========================================================================
# I/O — LoadDemo # I/O — ImageDemo
# ========================================================================= # =========================================================================
def test_load_demo(): def test_load_demo():
print("=== Test: LoadDemo ===") print("=== Test: ImageDemo ===")
from backend.nodes.io import LoadDemo from backend.nodes.io import ImageDemo
node = LoadDemo() node = ImageDemo()
# Should be able to load a demo file by name # Should be able to load a demo file by name
result = node.load(name="nanoparticles.npy") result = node.load(name="nanoparticles.npy")
@@ -1460,14 +1460,14 @@ def test_load_demo():
def test_load_demo_multi_layer_preview_payload(): def test_load_demo_multi_layer_preview_payload():
print("=== Test: LoadDemo multi-layer preview payload ===") print("=== Test: ImageDemo multi-layer preview payload ===")
from backend.execution import ExecutionEngine from backend.execution import ExecutionEngine
import backend.nodes # noqa: F401 import backend.nodes # noqa: F401
previews = [] previews = []
prompt = { prompt = {
"1": { "1": {
"class_type": "LoadDemo", "class_type": "ImageDemo",
"inputs": { "inputs": {
"name": "whiskers.ibw", "name": "whiskers.ibw",
"colormap": "viridis", "colormap": "viridis",