historgram measurements
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -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,35 +710,8 @@ 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"
|
|
||||||
|
|
||||||
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:
|
|
||||||
return numeric_columns[0]
|
|
||||||
if not numeric_columns:
|
|
||||||
raise ValueError("Table Math could not find any numeric columns in the input table.")
|
|
||||||
raise ValueError(
|
|
||||||
"Table Math found multiple numeric columns; set the column name explicitly."
|
|
||||||
)
|
|
||||||
|
|
||||||
def _extract_numeric_values(self, table: list, column: str) -> list[float]:
|
|
||||||
values = []
|
values = []
|
||||||
for row in table:
|
for row in table:
|
||||||
if not isinstance(row, dict) or column not in row:
|
if not isinstance(row, dict) or column not in row:
|
||||||
@@ -673,3 +726,118 @@ class TableMath:
|
|||||||
if np.isfinite(numeric):
|
if np.isfinite(numeric):
|
||||||
values.append(numeric)
|
values.append(numeric)
|
||||||
return values
|
return values
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_table_column_name(table: list, column: str) -> str:
|
||||||
|
requested = str(column or "").strip()
|
||||||
|
if requested:
|
||||||
|
return requested
|
||||||
|
|
||||||
|
if extract_numeric_table_values(table, "value"):
|
||||||
|
return "value"
|
||||||
|
|
||||||
|
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 extract_numeric_table_values(table, key):
|
||||||
|
numeric_columns.append(key)
|
||||||
|
|
||||||
|
if len(numeric_columns) == 1:
|
||||||
|
return numeric_columns[0]
|
||||||
|
if not numeric_columns:
|
||||||
|
raise ValueError("Table Math could not find any numeric columns in the input table.")
|
||||||
|
raise ValueError(
|
||||||
|
"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__}")
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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 (
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
Reference in New Issue
Block a user