historgram measurements

This commit is contained in:
2026-03-25 00:33:56 -07:00
parent a65b7c5642
commit d03590e326
5 changed files with 430 additions and 77 deletions

View File

@@ -182,7 +182,7 @@ 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 from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay
from backend.nodes.analysis import CrossSection, LineCursors, TableMath from backend.nodes.analysis import CrossSection, LineCursors, TableMath, Stats, HeightHistogram
from backend.nodes.modify import CropResizeField from backend.nodes.modify import CropResizeField
from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine
from backend.nodes.io import SaveImage, LoadFile from backend.nodes.io import SaveImage, LoadFile
@@ -196,6 +196,8 @@ class ExecutionEngine:
PrintTable._broadcast_table_fn = on_table PrintTable._broadcast_table_fn = on_table
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
HeightHistogram._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
@@ -205,11 +207,11 @@ class ExecutionEngine:
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 from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay
from backend.nodes.analysis import CrossSection, LineCursors, TableMath from backend.nodes.analysis import CrossSection, LineCursors, TableMath, Stats, HeightHistogram
from backend.nodes.modify import CropResizeField from backend.nodes.modify import CropResizeField
from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine
from backend.nodes.io import LoadFile, SaveImage from backend.nodes.io import LoadFile, SaveImage
if cls in (PreviewImage, PrintTable, View3D, ValueDisplay, TableMath, CrossSection, LineCursors, CropResizeField, if cls in (PreviewImage, PrintTable, View3D, ValueDisplay, TableMath, Stats, HeightHistogram, CrossSection, LineCursors, CropResizeField,
ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, ThresholdMask, MaskMorphology, MaskInvert, MaskCombine,
LoadFile, SaveImage): LoadFile, SaveImage):
cls._current_node_id = node_id cls._current_node_id = node_id

View File

@@ -71,26 +71,91 @@ class HeightHistogram:
"field": ("DATA_FIELD",), "field": ("DATA_FIELD",),
"n_bins": ("INT", {"default": 256, "min": 10, "max": 1000, "step": 1}), "n_bins": ("INT", {"default": 256, "min": 10, "max": 1000, "step": 1}),
"y_scale": (["linear", "log"],), "y_scale": (["linear", "log"],),
"x1": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"y1": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"x2": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
"y2": ("FLOAT", {"default": 0.5, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
} }
} }
RETURN_TYPES = ("LINE", "LINE") RETURN_TYPES = ("TABLE",)
RETURN_NAMES = ("counts", "bin_centers") RETURN_NAMES = ("measurements",)
FUNCTION = "process" FUNCTION = "process"
CATEGORY = "analysis" CATEGORY = "analysis"
DESCRIPTION = ( DESCRIPTION = (
"Compute the height distribution histogram (DH). " "Compute the height distribution histogram (DH). "
"Use log scale to reveal small peaks next to a dominant background. " "Use log scale to reveal small peaks next to a dominant background. "
"Outputs marker measurements while showing the histogram interactively in-node. "
"Equivalent to gwy_data_field_dh." "Equivalent to gwy_data_field_dh."
) )
def process(self, field: DataField, n_bins: int, y_scale: str = "linear") -> tuple: _broadcast_overlay_fn = None
counts, bin_edges = np.histogram(field.data.ravel(), bins=int(n_bins)) _current_node_id: str = ""
def process(
self,
field: DataField,
n_bins: int,
y_scale: str = "linear",
x1: float = 0.25,
y1: float = 0.5,
x2: float = 0.75,
y2: float = 0.5,
) -> tuple:
raw_counts, bin_edges = np.histogram(field.data.ravel(), bins=int(n_bins))
bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:]) bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
counts = counts.astype(np.float64) counts = raw_counts.astype(np.float64)
if y_scale == "log": if y_scale == "log":
counts = np.log10(1.0 + counts) counts = np.log10(1.0 + counts)
return (counts, bin_centers)
x1 = float(np.clip(x1, 0.0, 1.0))
x2 = float(np.clip(x2, 0.0, 0.0 + 1.0))
xmin = float(np.min(bin_centers)) if len(bin_centers) else 0.0
xmax = float(np.max(bin_centers)) if len(bin_centers) else 1.0
def x_frac_to_idx(frac):
if len(bin_centers) <= 1:
return 0
if xmax == xmin:
return 0
target_x = xmin + frac * (xmax - xmin)
return int(np.argmin(np.abs(bin_centers - target_x)))
idx_a = x_frac_to_idx(x1)
idx_b = x_frac_to_idx(x2)
xa = float(bin_centers[idx_a]) if len(bin_centers) else 0.0
xb = float(bin_centers[idx_b]) if len(bin_centers) else 0.0
ya = float(counts[idx_a]) 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)"
if HeightHistogram._broadcast_overlay_fn is not None:
HeightHistogram._broadcast_overlay_fn(
HeightHistogram._current_node_id,
{
"kind": "line_plot",
"section_title": "Histogram",
"line": counts.tolist(),
"x_axis": bin_centers.astype(np.float64).tolist(),
"x1": float(np.clip(x1, 0.0, 1.0)),
"x2": float(np.clip(x2, 0.0, 1.0)),
"y1": float(y1),
"y2": float(y2),
"a_locked": False,
"b_locked": False,
},
)
table = [
{"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},
{"quantity": "B count", "value": yb, "unit": count_unit},
{"quantity": "delta X", "value": xb - xa, "unit": field.si_unit_z},
{"quantity": "delta Y", "value": yb - ya, "unit": count_unit},
]
return (table,)
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -164,6 +229,7 @@ class LineCursors:
LineCursors._current_node_id, LineCursors._current_node_id,
{ {
"kind": "line_plot", "kind": "line_plot",
"section_title": "Line Cursors",
"line": y.tolist(), "line": y.tolist(),
"x_axis": x.tolist(), "x_axis": x.tolist(),
"x1": x1, "x1": x1,
@@ -582,6 +648,20 @@ TABLE_OPS: dict[str, Callable[[np.ndarray], float]] = {
"count": lambda values: float(len(values)), "count": lambda values: float(len(values)),
} }
ARRAY_OPS: dict[str, Callable[[np.ndarray], float]] = {
"min": lambda values: float(np.min(values)),
"max": lambda values: float(np.max(values)),
"avg": lambda values: float(np.mean(values)),
"mean": lambda values: float(np.mean(values)),
"median": lambda values: float(np.median(values)),
"sum": lambda values: float(np.sum(values)),
"range": lambda values: float(np.max(values) - np.min(values)),
"std": lambda values: float(np.std(values)),
"variance": lambda values: float(np.var(values)),
"rms": lambda values: float(np.sqrt(np.mean(values * values))),
"count": lambda values: float(values.size),
}
@register_node(display_name="Table Math") @register_node(display_name="Table Math")
class TableMath: class TableMath:
@@ -616,8 +696,8 @@ class TableMath:
if not isinstance(table, list) or not table: if not isinstance(table, list) or not table:
raise ValueError("Table Math requires a non-empty TABLE input.") raise ValueError("Table Math requires a non-empty TABLE input.")
column_name = self._resolve_column_name(table, column) column_name = resolve_table_column_name(table, column)
values = self._extract_numeric_values(table, column_name) values = extract_numeric_table_values(table, column_name)
if not values: if not values:
raise ValueError(f"Column '{column_name}' has no numeric values.") raise ValueError(f"Column '{column_name}' has no numeric values.")
@@ -630,46 +710,134 @@ class TableMath:
TableMath._broadcast_value_fn(TableMath._current_node_id, result) TableMath._broadcast_value_fn(TableMath._current_node_id, result)
return (result,) return (result,)
def _resolve_column_name(self, table: list, column: str) -> str:
requested = str(column or "").strip()
if requested:
return requested
if self._extract_numeric_values(table, "value"): def extract_numeric_table_values(table: list, column: str) -> list[float]:
return "value" values = []
for row in table:
if not isinstance(row, dict) or column not in row:
continue
value = row[column]
if isinstance(value, bool):
continue
try:
numeric = float(value)
except (TypeError, ValueError):
continue
if np.isfinite(numeric):
values.append(numeric)
return values
numeric_columns = []
seen = set()
for row in table:
if not isinstance(row, dict):
continue
for key in row.keys():
if key in seen:
continue
seen.add(key)
if self._extract_numeric_values(table, key):
numeric_columns.append(key)
if len(numeric_columns) == 1: def resolve_table_column_name(table: list, column: str) -> str:
return numeric_columns[0] requested = str(column or "").strip()
if not numeric_columns: if requested:
raise ValueError("Table Math could not find any numeric columns in the input table.") return requested
raise ValueError(
"Table Math found multiple numeric columns; set the column name explicitly."
)
def _extract_numeric_values(self, table: list, column: str) -> list[float]: if extract_numeric_table_values(table, "value"):
values = [] return "value"
for row in table:
if not isinstance(row, dict) or column not in row: numeric_columns = []
seen = set()
for row in table:
if not isinstance(row, dict):
continue
for key in row.keys():
if key in seen:
continue continue
value = row[column] seen.add(key)
if isinstance(value, bool): if extract_numeric_table_values(table, key):
continue numeric_columns.append(key)
try:
numeric = float(value) if len(numeric_columns) == 1:
except (TypeError, ValueError): return numeric_columns[0]
continue if not numeric_columns:
if np.isfinite(numeric): raise ValueError("Table Math could not find any numeric columns in the input table.")
values.append(numeric) raise ValueError(
return values "Table Math found multiple numeric columns; set the column name explicitly."
)
@register_node(display_name="Stats")
class Stats:
"""Polymorphic scalar stats node for LINE, TABLE, DATA_FIELD, or IMAGE inputs."""
_broadcast_value_fn = None
_current_node_id: str = ""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"input": ("STATS_SOURCE",),
"column": ("STRING", {
"default": "value",
"choices_from_table_input": "input",
"show_when_source_type": {
"input": ["TABLE"],
},
}),
"operation": ("STRING", {
"default": "mean",
"choices_by_source_type": {
"LINE": list(LINE_OPS.keys()),
"TABLE": list(TABLE_OPS.keys()),
"DATA_FIELD": list(ARRAY_OPS.keys()),
"IMAGE": list(ARRAY_OPS.keys()),
},
"source_type_input": "input",
}),
}
}
RETURN_TYPES = ("FLOAT",)
RETURN_NAMES = ("value",)
FUNCTION = "process"
CATEGORY = "analysis"
DESCRIPTION = (
"Compute a contextual scalar statistic from a LINE, TABLE, DATA_FIELD, or IMAGE. "
"The available operations adapt to the connected input type."
)
def process(self, input, operation: str, column: str = "value") -> tuple:
source_type, values = self._resolve_input_values(input, column)
if source_type == "TABLE":
ops = TABLE_OPS
elif source_type == "LINE":
ops = LINE_OPS
else:
ops = ARRAY_OPS
if operation not in ops:
raise ValueError(f"Operation '{operation}' is not valid for {source_type} input.")
op_entry = ops[operation]
fn = op_entry[0] if isinstance(op_entry, tuple) else op_entry
result = fn(values)
if Stats._broadcast_value_fn is not None:
Stats._broadcast_value_fn(Stats._current_node_id, result)
return (result,)
def _resolve_input_values(self, input_value, column: str) -> tuple[str, np.ndarray]:
if isinstance(input_value, DataField):
values = np.asarray(input_value.data, dtype=np.float64)
return ("DATA_FIELD", values.ravel())
if isinstance(input_value, list):
if not input_value:
raise ValueError("Stats requires a non-empty TABLE input.")
column_name = resolve_table_column_name(input_value, column)
values = extract_numeric_table_values(input_value, column_name)
if not values:
raise ValueError(f"Column '{column_name}' has no numeric values.")
return ("TABLE", np.asarray(values, dtype=np.float64))
if isinstance(input_value, np.ndarray):
values = np.asarray(input_value, dtype=np.float64)
if values.size == 0:
raise ValueError("Stats requires a non-empty input.")
if values.ndim == 1:
return ("LINE", values.ravel())
return ("IMAGE", values.ravel())
raise ValueError(f"Unsupported Stats input type: {type(input_value).__name__}")

View File

@@ -18,7 +18,11 @@ import { serializeWorkflowState } from './workflowSerialization';
// ── Constants ───────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────
const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD']); const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD', 'STATS_SOURCE']);
const SOCKET_COMPATIBILITY = {
STATS_SOURCE: new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE']),
};
const TYPE_COLORS = { const TYPE_COLORS = {
DATA_FIELD: '#ff002f', DATA_FIELD: '#ff002f',
@@ -27,6 +31,7 @@ const TYPE_COLORS = {
TABLE: '#35e2fd', TABLE: '#35e2fd',
COORD: '#e91ed1', COORD: '#e91ed1',
FLOAT: '#7dd3fc', FLOAT: '#7dd3fc',
STATS_SOURCE:'#c084fc',
}; };
const NODE_TYPES = { custom: CustomNode }; const NODE_TYPES = { custom: CustomNode };
@@ -45,6 +50,12 @@ function getOutputSlot(handleId) {
return parseInt(handleId.split('::')[1], 10); return parseInt(handleId.split('::')[1], 10);
} }
function socketTypesCompatible(sourceType, targetType) {
if (sourceType === targetType) return true;
const accepted = SOCKET_COMPATIBILITY[targetType];
return !!accepted?.has(sourceType);
}
async function waitForImageElement(img) { async function waitForImageElement(img) {
if (img.complete && img.naturalWidth > 0) return; if (img.complete && img.naturalWidth > 0) return;
if (typeof img.decode === 'function') { if (typeof img.decode === 'function') {
@@ -220,11 +231,11 @@ function ContextMenu({ x, y, nodeDefs, onAdd, onClose, filterType, filterDirecti
const allInputs = { ...req, ...opt }; const allInputs = { ...req, ...opt };
const hasMatch = Object.values(allInputs).some((spec) => { const hasMatch = Object.values(allInputs).some((spec) => {
const [type] = Array.isArray(spec) ? spec : [spec]; const [type] = Array.isArray(spec) ? spec : [spec];
return type === filterType; return socketTypesCompatible(filterType, type);
}); });
if (!hasMatch) continue; if (!hasMatch) continue;
} else { } else {
if (!def.output.includes(filterType)) continue; if (!def.output.some((type) => socketTypesCompatible(type, filterType))) continue;
} }
} }
const cat = def.category || 'uncategorized'; const cat = def.category || 'uncategorized';
@@ -474,7 +485,7 @@ function Flow() {
const isValidConnection = useCallback((connection) => { const isValidConnection = useCallback((connection) => {
const srcType = getHandleType(connection.sourceHandle); const srcType = getHandleType(connection.sourceHandle);
const tgtType = getHandleType(connection.targetHandle); const tgtType = getHandleType(connection.targetHandle);
return srcType === tgtType; return socketTypesCompatible(srcType, tgtType);
}, []); }, []);
const onConnect = useCallback((params) => { const onConnect = useCallback((params) => {
@@ -667,10 +678,15 @@ function Flow() {
const allInputs = { ...(def.input.required || {}), ...(def.input.optional || {}) }; const allInputs = { ...(def.input.required || {}), ...(def.input.optional || {}) };
const inputName = Object.entries(allInputs).find(([, spec]) => { const inputName = Object.entries(allInputs).find(([, spec]) => {
const [type] = Array.isArray(spec) ? spec : [spec]; const [type] = Array.isArray(spec) ? spec : [spec];
return type === filterType; return socketTypesCompatible(filterType, type);
})?.[0]; })?.[0];
if (inputName) { if (inputName) {
const targetHandle = `input::${inputName}::${filterType}`; const targetType = (() => {
const spec = allInputs[inputName];
const [type] = Array.isArray(spec) ? spec : [spec];
return type;
})();
const targetHandle = `input::${inputName}::${targetType}`;
const color = TYPE_COLORS[filterType] || '#999'; const color = TYPE_COLORS[filterType] || '#999';
setEdges((eds) => addEdge({ setEdges((eds) => addEdge({
source: contextMenu.pendingNodeId, source: contextMenu.pendingNodeId,
@@ -682,10 +698,11 @@ function Flow() {
} }
} else { } else {
// Dragged from an input → connect from the first matching output on the new node // Dragged from an input → connect from the first matching output on the new node
const outputIdx = def.output.indexOf(filterType); const outputIdx = def.output.findIndex((type) => socketTypesCompatible(type, filterType));
if (outputIdx !== -1) { if (outputIdx !== -1) {
const sourceHandle = `output::${outputIdx}::${filterType}`; const outputType = def.output[outputIdx];
const color = TYPE_COLORS[filterType] || '#999'; const sourceHandle = `output::${outputIdx}::${outputType}`;
const color = TYPE_COLORS[outputType] || '#999';
setEdges((eds) => addEdge({ setEdges((eds) => addEdge({
source: newNodeId, source: newNodeId,
sourceHandle, sourceHandle,

View File

@@ -8,7 +8,7 @@ const CropBoxOverlay = lazy(() => import('./CropBoxOverlay'));
// ── Constants ───────────────────────────────────────────────────────── // ── Constants ─────────────────────────────────────────────────────────
const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD']); const DATA_TYPES = new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'TABLE', 'COORD', 'STATS_SOURCE']);
const SOCKET_WIDGET_TYPES = new Set(['FLOAT']); const SOCKET_WIDGET_TYPES = new Set(['FLOAT']);
const TYPE_COLORS = { const TYPE_COLORS = {
@@ -18,6 +18,7 @@ const TYPE_COLORS = {
TABLE: '#fdd835', TABLE: '#fdd835',
COORD: '#e91e63', COORD: '#e91e63',
FLOAT: '#7dd3fc', FLOAT: '#7dd3fc',
STATS_SOURCE:'#c084fc',
}; };
const CAT_COLORS = { const CAT_COLORS = {
@@ -205,6 +206,30 @@ function formatScalarValue(value) {
return numeric.toFixed(abs >= 100 ? 2 : 4).replace(/\.?0+$/, ''); return numeric.toFixed(abs >= 100 ? 2 : 4).replace(/\.?0+$/, '');
} }
function getSourceTypeForInput(store, nodeId, inputName) {
const targetHandle = `input::${inputName}::`;
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
if (!edge?.sourceHandle) return null;
const parts = edge.sourceHandle.split('::');
return parts[2] || null;
}
function getSourceNodeForInput(store, nodeId, inputName) {
const targetHandle = `input::${inputName}::`;
const edge = store.edges?.find((e) => e.target === nodeId && e.targetHandle?.startsWith(targetHandle));
if (!edge) return null;
return store.nodeLookup?.get(edge.source) || store.nodes?.find((n) => n.id === edge.source) || null;
}
function widgetVisibleForSourceType(widget, sourceType) {
const rules = widget?.opts?.show_when_source_type;
if (!rules || typeof rules !== 'object') return true;
const inputName = Object.keys(rules)[0];
const allowed = Array.isArray(rules[inputName]) ? rules[inputName] : [];
if (allowed.length === 0) return true;
return allowed.includes(sourceType);
}
function NodeTable({ rows }) { function NodeTable({ rows }) {
const columns = getTableColumns(rows); const columns = getTableColumns(rows);
if (columns.length === 0) return null; if (columns.length === 0) return null;
@@ -290,6 +315,20 @@ function CustomNode({ id, data }) {
), ),
); );
const connectedSourceTypes = useStore(
useCallback(
(s) => {
const sourceTypes = {};
const allInputs = { ...required, ...optional };
for (const name of Object.keys(allInputs)) {
sourceTypes[name] = getSourceTypeForInput(s, id, name);
}
return sourceTypes;
},
[id, required, optional],
),
);
for (const [name, spec] of Object.entries(optional)) { for (const [name, spec] of Object.entries(optional)) {
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}]; const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
if (isProgressive && DATA_TYPES.has(type)) { if (isProgressive && DATA_TYPES.has(type)) {
@@ -320,6 +359,13 @@ function CustomNode({ id, data }) {
const catColor = CAT_COLORS[def.category] || '#333'; const catColor = CAT_COLORS[def.category] || '#333';
const maxIORows = Math.max(dataInputs.length, outputs.length); const maxIORows = Math.max(dataInputs.length, outputs.length);
const hasInteractiveLineOverlay = data.overlay?.kind === 'line_plot' && hiddenWidgets.has('x1');
const overlayTitle = data.overlay?.section_title
|| (data.overlay?.kind === 'crop_box'
? 'Crop'
: data.overlay?.kind === 'line_plot'
? 'Line Plot'
: 'Cross Section');
return ( return (
<div className="custom-node"> <div className="custom-node">
@@ -380,7 +426,7 @@ function CustomNode({ id, data }) {
)} )}
{/* Widget rows */} {/* Widget rows */}
{widgets.map((w) => ( {widgets.filter((w) => widgetVisibleForSourceType(w, connectedSourceTypes?.[w.opts?.source_type_input || w.opts?.choices_from_table_input || Object.keys(w.opts?.show_when_source_type || {})[0]])).map((w) => (
<div className={`widget-row${w.socketType ? ' widget-row-socket' : ''}`} key={w.name}> <div className={`widget-row${w.socketType ? ' widget-row-socket' : ''}`} key={w.name}>
{w.socketType && ( {w.socketType && (
<Handle <Handle
@@ -425,7 +471,7 @@ function CustomNode({ id, data }) {
)} )}
{/* Collapsible preview image */} {/* Collapsible preview image */}
{data.previewImage && ( {data.previewImage && !(hasInteractiveLineOverlay && typeof data.previewImage === 'object' && data.previewImage.kind === 'line_plot') && (
<CollapsibleSection title="Preview" defaultOpen={true}> <CollapsibleSection title="Preview" defaultOpen={true}>
<PreviewBoundary <PreviewBoundary
resetKey={typeof data.previewImage === 'string' ? data.previewImage : JSON.stringify({ resetKey={typeof data.previewImage === 'string' ? data.previewImage : JSON.stringify({
@@ -447,7 +493,7 @@ function CustomNode({ id, data }) {
{/* Interactive cross-section overlay */} {/* Interactive cross-section overlay */}
{data.overlay && hiddenWidgets.has('x1') && ( {data.overlay && hiddenWidgets.has('x1') && (
<CollapsibleSection title={data.overlay.kind === 'crop_box' ? 'Crop' : 'Cross Section'} defaultOpen={true}> <CollapsibleSection title={overlayTitle} defaultOpen={true}>
<Suspense fallback={<div className="node-preview" style={{color:'#64748b',padding:4}}>Loading...</div>}> <Suspense fallback={<div className="node-preview" style={{color:'#64748b',padding:4}}>Loading...</div>}>
{data.overlay.kind === 'line_plot' ? ( {data.overlay.kind === 'line_plot' ? (
<LinePlotOverlay <LinePlotOverlay
@@ -504,21 +550,47 @@ function CustomNode({ id, data }) {
function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser }) { function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFileBrowser }) {
const { name, type, opts } = widget; const { name, type, opts } = widget;
const val = value ?? opts?.default ?? ''; const val = value ?? opts?.default ?? '';
const dynamicSourceType = useStore(
useCallback(
(s) => {
const inputName = opts?.source_type_input
|| opts?.choices_from_table_input
|| Object.keys(opts?.show_when_source_type || {})[0];
if (!inputName) return null;
return getSourceTypeForInput(s, nodeId, inputName);
},
[nodeId, opts],
),
);
const dynamicTableColumns = useStore( const dynamicTableColumns = useStore(
useCallback( useCallback(
(s) => { (s) => {
const tableInputName = opts?.choices_from_table_input; const tableInputName = opts?.choices_from_table_input;
if (!tableInputName) return []; if (!tableInputName) return [];
const targetHandle = `input::${tableInputName}::TABLE`; const sourceType = getSourceTypeForInput(s, nodeId, tableInputName);
const edge = s.edges?.find((e) => e.target === nodeId && e.targetHandle === targetHandle); if (sourceType !== 'TABLE') return [];
if (!edge) return []; const sourceNode = getSourceNodeForInput(s, nodeId, tableInputName);
const sourceNode = s.nodeLookup?.get(edge.source) || s.nodes?.find((n) => n.id === edge.source);
const rows = sourceNode?.data?.tableRows; const rows = sourceNode?.data?.tableRows;
return Array.isArray(rows) ? getTableColumns(rows) : []; return Array.isArray(rows) ? getTableColumns(rows) : [];
}, },
[nodeId, opts?.choices_from_table_input], [nodeId, opts?.choices_from_table_input],
), ),
); );
const dynamicTypeChoices = (() => {
const byType = opts?.choices_by_source_type;
if (!byType) return [];
if (dynamicSourceType) {
return Array.isArray(byType[dynamicSourceType]) ? byType[dynamicSourceType] : [];
}
const merged = [];
for (const choices of Object.values(byType)) {
if (!Array.isArray(choices)) continue;
for (const choice of choices) {
if (!merged.includes(choice)) merged.push(choice);
}
}
return merged;
})();
useEffect(() => { useEffect(() => {
if (!opts?.choices_from_table_input || dynamicTableColumns.length === 0) return; if (!opts?.choices_from_table_input || dynamicTableColumns.length === 0) return;
@@ -528,6 +600,13 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
if (preferred != null) onChange(nodeId, name, preferred); if (preferred != null) onChange(nodeId, name, preferred);
}, [dynamicTableColumns, name, nodeId, onChange, opts?.choices_from_table_input, val]); }, [dynamicTableColumns, name, nodeId, onChange, opts?.choices_from_table_input, val]);
useEffect(() => {
if (dynamicTypeChoices.length === 0) return;
const current = String(val ?? '');
if (dynamicTypeChoices.includes(current)) return;
onChange(nodeId, name, dynamicTypeChoices[0]);
}, [dynamicTypeChoices, name, nodeId, onChange, val]);
// Combo / enum — type itself is the array of options // Combo / enum — type itself is the array of options
if (Array.isArray(type)) { if (Array.isArray(type)) {
return ( return (
@@ -546,6 +625,24 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
); );
} }
if (type === 'STRING' && dynamicTypeChoices.length > 0) {
const selected = dynamicTypeChoices.includes(String(val)) ? String(val) : dynamicTypeChoices[0];
return (
<>
<label>{name}</label>
<select
className="nodrag"
value={selected}
onChange={(e) => onChange(nodeId, name, e.target.value)}
>
{dynamicTypeChoices.map((choice) => (
<option key={choice} value={choice}>{choice}</option>
))}
</select>
</>
);
}
if (type === 'STRING' && opts?.choices_from_table_input && dynamicTableColumns.length > 0) { if (type === 'STRING' && opts?.choices_from_table_input && dynamicTableColumns.length > 0) {
const selected = dynamicTableColumns.includes(String(val)) ? String(val) : dynamicTableColumns[0]; const selected = dynamicTableColumns.includes(String(val)) ? String(val) : dynamicTableColumns[0];
return ( return (

View File

@@ -481,17 +481,42 @@ def test_height_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)
counts, bin_centers = node.process(field, n_bins=10, y_scale="linear") overlays = []
assert len(counts) == 10 HeightHistogram._broadcast_overlay_fn = lambda nid, data: overlays.append(data)
assert len(bin_centers) == 10 HeightHistogram._current_node_id = "test"
assert counts.dtype == np.float64
# Total counts should equal number of pixels table, = node.process(
assert counts.sum() == 1000 field,
# For uniform data, each bin should have ~100 counts n_bins=10,
assert np.std(counts) < 10, f"Histogram not flat enough: std={np.std(counts)}" y_scale="linear",
# Bin centers should span the data range x1=0.2,
assert bin_centers[0] > 0.0 y1=0.5,
assert bin_centers[-1] < 1.0 x2=0.8,
y2=0.5,
)
measurements = {row["quantity"]: row for row in table}
assert "A position" in measurements
assert "A count" in measurements
assert "B position" in measurements
assert "B count" in measurements
assert "delta X" in measurements
assert "delta Y" in measurements
assert measurements["A count"]["unit"] == "count"
assert measurements["B count"]["unit"] == "count"
assert measurements["B position"]["value"] > measurements["A position"]["value"]
assert len(overlays) == 1
assert overlays[0]["kind"] == "line_plot"
assert overlays[0]["section_title"] == "Histogram"
assert len(overlays[0]["line"]) == 10
assert len(overlays[0]["x_axis"]) == 10
assert np.isclose(overlays[0]["x1"], 0.2)
assert np.isclose(overlays[0]["x2"], 0.8)
assert np.isclose(
measurements["delta Y"]["value"],
measurements["B count"]["value"] - measurements["A count"]["value"],
)
HeightHistogram._broadcast_overlay_fn = None
print(" PASS\n") print(" PASS\n")
@@ -1380,6 +1405,49 @@ def test_table_math():
print(" PASS\n") print(" PASS\n")
# =========================================================================
# Analysis — Stats
# =========================================================================
def test_stats():
print("=== Test: Stats ===")
from backend.nodes.analysis import Stats
node = Stats()
captured = []
Stats._broadcast_value_fn = lambda node_id, value: captured.append((node_id, value))
Stats._current_node_id = "test"
line = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64)
result, = node.process(line, operation="mean", column="value")
assert np.isclose(result, 2.5)
assert captured[-1] == ("test", result)
table = [
{"name": "a", "value": 3.0, "other": 10.0},
{"name": "b", "value": 7.0, "other": 20.0},
]
result, = node.process(table, operation="max", column="value")
assert result == 7.0
field = make_field(data=np.array([[1.0, 5.0], [2.0, 4.0]], dtype=np.float64))
result, = node.process(field, operation="range", column="value")
assert result == 4.0
image = np.array([[0, 10], [20, 30]], dtype=np.uint8)
result, = node.process(image, operation="avg", column="value")
assert np.isclose(result, 15.0)
try:
node.process(table, operation="Rq", column="value")
raise AssertionError("Expected invalid TABLE operation to raise ValueError")
except ValueError:
pass
Stats._broadcast_value_fn = None
print(" PASS\n")
# ========================================================================= # =========================================================================
# Display — View3D # Display — View3D
# ========================================================================= # =========================================================================
@@ -1457,6 +1525,7 @@ if __name__ == "__main__":
test_fft2d() test_fft2d()
test_line_math() test_line_math()
test_table_math() test_table_math()
test_stats()
# Mask # Mask
test_threshold_mask() test_threshold_mask()