make cursors polymorphic

This commit is contained in:
2026-03-25 23:03:14 -07:00
parent cc3af8e929
commit 8e16f9f0b4
8 changed files with 170 additions and 56 deletions

View File

@@ -219,7 +219,7 @@ class ExecutionEngine:
) -> None:
"""Wire up broadcast callbacks on display node classes."""
from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay, Markup
from backend.nodes.analysis import CrossSection, LineCursors, Stats, Histogram
from backend.nodes.analysis import CrossSection, Cursors, Stats, Histogram
from backend.nodes.modify import CropResizeField, RotateField
from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask
from backend.nodes.io import SaveImage, Image, ImageDemo
@@ -236,7 +236,7 @@ class ExecutionEngine:
Stats._broadcast_value_fn = on_value
Histogram._broadcast_overlay_fn = on_overlay
CrossSection._broadcast_overlay_fn = on_overlay
LineCursors._broadcast_overlay_fn = on_overlay
Cursors._broadcast_overlay_fn = on_overlay
CropResizeField._broadcast_overlay_fn = on_overlay
RotateField._broadcast_warning_fn = on_warning
Markup._broadcast_overlay_fn = on_overlay
@@ -247,11 +247,11 @@ class ExecutionEngine:
def _set_node_id_on_display(self, cls: type, node_id: str) -> None:
"""Inform display nodes of their current node_id for WS tagging."""
from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay, Markup
from backend.nodes.analysis import CrossSection, LineCursors, Stats, Histogram
from backend.nodes.analysis import CrossSection, Cursors, Stats, Histogram
from backend.nodes.modify import CropResizeField, RotateField
from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask
from backend.nodes.io import Image, ImageDemo, SaveImage
if cls in (PreviewImage, PrintTable, View3D, ValueDisplay, Stats, Histogram, CrossSection, LineCursors, CropResizeField, RotateField, Markup,
if cls in (PreviewImage, PrintTable, View3D, ValueDisplay, Stats, Histogram, CrossSection, Cursors, CropResizeField, RotateField, Markup,
ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask,
Image, ImageDemo, SaveImage):
cls._current_node_id = node_id

View File

@@ -57,8 +57,8 @@ MENU_LAYOUT: dict[str, list[str]] = {
"Measure": [
"Statistics",
"Histogram",
"LineCursors",
"CrossSection",
"Cursors",
"Stats",
],
"Mask": [

View File

@@ -12,7 +12,7 @@ from __future__ import annotations
import numpy as np
from typing import Callable
from backend.node_registry import register_node
from backend.data_types import DataField, MeasureTable, RecordTable, datafield_to_uint8, encode_preview
from backend.data_types import DataField, MeasureTable, RecordTable, datafield_to_uint8, encode_preview, render_datafield_preview
# ---------------------------------------------------------------------------
@@ -159,18 +159,18 @@ class Histogram:
# ---------------------------------------------------------------------------
# LineCursors — interactive measurement cursors on any LINE plot
# Cursors — interactive measurement cursors on lines or fields
# ---------------------------------------------------------------------------
@register_node(display_name="Line Cursors")
class LineCursors:
"""Place two draggable cursors on any LINE plot to measure values and deltas."""
@register_node(display_name="Cursors")
class Cursors:
"""Place two draggable cursors on a line plot or field to measure deltas."""
@classmethod
def INPUT_TYPES(cls):
return {
"required": {
"line": ("LINE",),
"line": ("CURSOR_SOURCE", {"label": "input"}),
"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}),
@@ -186,8 +186,9 @@ class LineCursors:
FUNCTION = "process"
CATEGORY = "analysis"
DESCRIPTION = (
"Place two cursors on any line plot (histogram, cross section, profile) "
"to measure positions, values, and deltas. Drag the markers to reposition."
"Place two cursors on a line plot or 2D field. "
"On lines it reports x/y positions and dx/dy. "
"On fields it reports x/y/z at both markers plus dx/dy/dz."
)
_broadcast_overlay_fn = None
@@ -196,6 +197,20 @@ class LineCursors:
def process(
self, line, x1: float, y1: float, x2: float, y2: float,
x_axis=None,
) -> tuple:
if isinstance(line, DataField):
return self._process_field(line, x1=x1, y1=y1, x2=x2, y2=y2)
return self._process_line(line, x1=x1, y1=y1, x2=x2, y2=y2, x_axis=x_axis)
def _process_line(
self,
line,
x1: float,
y1: float,
x2: float,
y2: float,
x_axis=None,
) -> tuple:
y = np.asarray(line, dtype=np.float64).ravel()
n = len(y)
@@ -224,12 +239,12 @@ class LineCursors:
xb, yb = float(x[idx_b]), float(y[idx_b])
# --- Broadcast overlay ---
if LineCursors._broadcast_overlay_fn is not None:
LineCursors._broadcast_overlay_fn(
LineCursors._current_node_id,
if Cursors._broadcast_overlay_fn is not None:
Cursors._broadcast_overlay_fn(
Cursors._current_node_id,
{
"kind": "line_plot",
"section_title": "Line Cursors",
"section_title": "Cursors",
"line": y.tolist(),
"x_axis": x.tolist(),
"x1": x1,
@@ -243,12 +258,69 @@ class LineCursors:
# --- Output table ---
table = MeasureTable([
{"quantity": "A position", "value": xa, "unit": ""},
{"quantity": "A value", "value": ya, "unit": ""},
{"quantity": "B position", "value": xb, "unit": ""},
{"quantity": "B value", "value": yb, "unit": ""},
{"quantity": "delta X", "value": xb - xa, "unit": ""},
{"quantity": "delta Y", "value": yb - ya, "unit": ""},
{"quantity": "A x", "value": xa, "unit": ""},
{"quantity": "A y", "value": ya, "unit": ""},
{"quantity": "B x", "value": xb, "unit": ""},
{"quantity": "B y", "value": yb, "unit": ""},
{"quantity": "dx", "value": xb - xa, "unit": ""},
{"quantity": "dy", "value": yb - ya, "unit": ""},
])
return (table,)
def _process_field(
self,
field: DataField,
x1: float,
y1: float,
x2: float,
y2: float,
) -> tuple:
from scipy.ndimage import map_coordinates
x1 = float(np.clip(x1, 0.0, 1.0))
y1 = float(np.clip(y1, 0.0, 1.0))
x2 = float(np.clip(x2, 0.0, 1.0))
y2 = float(np.clip(y2, 0.0, 1.0))
px1 = x1 * max(field.xres - 1, 0)
py1 = y1 * max(field.yres - 1, 0)
px2 = x2 * max(field.xres - 1, 0)
py2 = y2 * max(field.yres - 1, 0)
z1 = float(map_coordinates(field.data, [[py1], [px1]], order=1, mode="nearest")[0])
z2 = float(map_coordinates(field.data, [[py2], [px2]], order=1, mode="nearest")[0])
ax = float(field.xoff + x1 * field.xreal)
ay = float(field.yoff + y1 * field.yreal)
bx = float(field.xoff + x2 * field.xreal)
by = float(field.yoff + y2 * field.yreal)
if Cursors._broadcast_overlay_fn is not None:
Cursors._broadcast_overlay_fn(
Cursors._current_node_id,
{
"kind": "cursor_points",
"section_title": "Cursors",
"image": encode_preview(render_datafield_preview(field, field.colormap)),
"x1": x1,
"y1": y1,
"x2": x2,
"y2": y2,
"a_locked": False,
"b_locked": False,
},
)
table = MeasureTable([
{"quantity": "A x", "value": ax, "unit": field.si_unit_xy},
{"quantity": "A y", "value": ay, "unit": field.si_unit_xy},
{"quantity": "A z", "value": z1, "unit": field.si_unit_z},
{"quantity": "B x", "value": bx, "unit": field.si_unit_xy},
{"quantity": "B y", "value": by, "unit": field.si_unit_xy},
{"quantity": "B z", "value": z2, "unit": field.si_unit_z},
{"quantity": "dx", "value": bx - ax, "unit": field.si_unit_xy},
{"quantity": "dy", "value": by - ay, "unit": field.si_unit_xy},
{"quantity": "dz", "value": z2 - z1, "unit": field.si_unit_z},
])
return (table,)
@@ -570,8 +642,8 @@ class CrossSection:
"n_samples": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
},
"optional": {
"point_a": ("COORD",),
"point_b": ("COORD",),
"marker_A": ("COORD",),
"marker_B": ("COORD",),
},
}
@@ -592,15 +664,15 @@ class CrossSection:
self, field: DataField,
x1: float, y1: float, x2: float, y2: float,
extend: str, n_samples: int,
point_a=None, point_b=None,
marker_A=None, marker_B=None,
) -> tuple:
from scipy.ndimage import map_coordinates
# COORD inputs override widget values
if point_a is not None:
x1, y1 = float(point_a[0]), float(point_a[1])
if point_b is not None:
x2, y2 = float(point_b[0]), float(point_b[1])
if marker_A is not None:
x1, y1 = float(marker_A[0]), float(marker_A[1])
if marker_B is not None:
x2, y2 = float(marker_B[0]), float(marker_B[1])
# Remember marker positions (before extend)
marker_x1, marker_y1 = float(x1), float(y1)
@@ -642,8 +714,8 @@ class CrossSection:
"image": image_uri,
"x1": marker_x1, "y1": marker_y1,
"x2": marker_x2, "y2": marker_y2,
"a_locked": point_a is not None,
"b_locked": point_b is not None,
"a_locked": marker_A is not None,
"b_locked": marker_B is not None,
},
)

View File

@@ -27,11 +27,12 @@ import {
const DATA_TYPES = new Set([
'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE',
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
'COORD', 'STATS_SOURCE', 'CURSOR_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
]);
const SOCKET_COMPATIBILITY = {
STATS_SOURCE: new Set(['DATA_FIELD', 'IMAGE', 'LINE', 'RECORD_TABLE']),
CURSOR_SOURCE: new Set(['DATA_FIELD', 'LINE']),
ANY_TABLE: new Set(['MEASURE_TABLE', 'RECORD_TABLE']),
VALUE_SOURCE: new Set(['FLOAT', 'MEASURE_TABLE']),
SAVE_LAYER: new Set(['DATA_FIELD', 'IMAGE']),
@@ -50,6 +51,7 @@ const TYPE_COLORS = {
FLOAT: '#7dd3fc',
INT: '#38bdf8',
STATS_SOURCE:'#c084fc',
CURSOR_SOURCE:'#a78bfa',
VALUE_SOURCE:'#60a5fa',
COLORMAP: '#f472b6',
SAVE_LAYER: '#22c55e',

View File

@@ -12,6 +12,7 @@ export default function CrossSectionOverlay({
image, x1, y1, x2, y2,
aLocked, bLocked,
nodeId, onWidgetChange,
showLine = true,
}) {
const containerRef = useRef(null);
const [dragging, setDragging] = useState(null); // 'p1' or 'p2'
@@ -62,13 +63,15 @@ export default function CrossSectionOverlay({
<img src={image} alt="field" draggable={false} className="cs-image" />
{/* Line connecting the two markers */}
<svg className="cs-svg">
<line
x1={`${x1 * 100}%`} y1={`${y1 * 100}%`}
x2={`${x2 * 100}%`} y2={`${y2 * 100}%`}
stroke="#ffd700" strokeWidth="2" strokeDasharray="6 3"
/>
</svg>
{showLine && (
<svg className="cs-svg">
<line
x1={`${x1 * 100}%`} y1={`${y1 * 100}%`}
x2={`${x2 * 100}%`} y2={`${y2 * 100}%`}
stroke="#ffd700" strokeWidth="2" strokeDasharray="6 3"
/>
</svg>
)}
{/* Endpoint markers — locked markers get a different style */}
<div

View File

@@ -12,7 +12,7 @@ const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
const DATA_TYPES = new Set([
'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE',
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
'COORD', 'STATS_SOURCE', 'CURSOR_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
]);
const SOCKET_WIDGET_TYPES = new Set(['FLOAT', 'INT']);
@@ -27,6 +27,7 @@ const TYPE_COLORS = {
FLOAT: '#7dd3fc',
INT: '#38bdf8',
STATS_SOURCE:'#c084fc',
CURSOR_SOURCE:'#a78bfa',
VALUE_SOURCE:'#60a5fa',
COLORMAP: '#f472b6',
SAVE_LAYER: '#22c55e',
@@ -861,6 +862,8 @@ function CustomNode({ id, data }) {
? 'Markup'
: data.overlay?.kind === 'crop_box'
? 'Crop'
: data.overlay?.kind === 'cursor_points'
? 'Cursors'
: data.overlay?.kind === 'line_plot'
? 'Line Plot'
: 'Cross Section');
@@ -1088,6 +1091,19 @@ function CustomNode({ id, data }) {
nodeId={id}
onWidgetChange={ctx.onWidgetChange}
/>
) : data.overlay.kind === 'cursor_points' ? (
<CrossSectionOverlay
image={data.overlay.image}
x1={data.overlay.a_locked ? data.overlay.x1 : (data.widgetValues.x1 ?? data.overlay.x1)}
y1={data.overlay.a_locked ? data.overlay.y1 : (data.widgetValues.y1 ?? data.overlay.y1)}
x2={data.overlay.b_locked ? data.overlay.x2 : (data.widgetValues.x2 ?? data.overlay.x2)}
y2={data.overlay.b_locked ? data.overlay.y2 : (data.widgetValues.y2 ?? data.overlay.y2)}
aLocked={data.overlay.a_locked}
bLocked={data.overlay.b_locked}
nodeId={id}
onWidgetChange={ctx.onWidgetChange}
showLine={false}
/>
) : data.overlay.kind === 'mask_paint' ? (
<MaskPaintOverlay
image={data.overlay.image}

View File

@@ -1,6 +1,6 @@
const DATA_TYPES = new Set([
'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE',
'COORD', 'STATS_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
'COORD', 'STATS_SOURCE', 'CURSOR_SOURCE', 'VALUE_SOURCE', 'COLORMAP', 'SAVE_LAYER', 'FONT', 'FILE_PATH', 'DIRECTORY',
]);
function getInputName(handleId) {

View File

@@ -1612,40 +1612,40 @@ def test_execution_engine_numeric_socket_coercion():
# =========================================================================
# Analysis — LineCursors
# Analysis — Cursors
# =========================================================================
def test_line_cursors():
print("=== Test: LineCursors ===")
from backend.nodes.analysis import LineCursors
print("=== Test: Cursors ===")
from backend.nodes.analysis import Cursors
node = LineCursors()
node = Cursors()
# Create a simple linear ramp
line = np.linspace(0, 10, 100).astype(np.float64)
# Capture overlay
overlays = []
LineCursors._broadcast_overlay_fn = lambda nid, data: overlays.append(data)
LineCursors._current_node_id = "test"
Cursors._broadcast_overlay_fn = lambda nid, data: overlays.append(data)
Cursors._current_node_id = "test"
table, = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5)
# Should produce a 6-row table
assert len(table) == 6
quantities = {row["quantity"] for row in table}
assert "A position" in quantities
assert "B position" in quantities
assert "delta X" in quantities
assert "delta Y" in quantities
assert "A x" in quantities
assert "B x" in quantities
assert "dx" in quantities
assert "dy" in quantities
# B should be at a later position than A
a_pos = next(r["value"] for r in table if r["quantity"] == "A position")
b_pos = next(r["value"] for r in table if r["quantity"] == "B position")
a_pos = next(r["value"] for r in table if r["quantity"] == "A x")
b_pos = next(r["value"] for r in table if r["quantity"] == "B x")
assert b_pos > a_pos
# Delta Y should reflect the height difference along the ramp
dy = next(r["value"] for r in table if r["quantity"] == "delta Y")
dy = next(r["value"] for r in table if r["quantity"] == "dy")
assert dy > 0 # ramp goes upward
# Overlay should have been broadcast
@@ -1661,7 +1661,28 @@ def test_line_cursors():
table2, = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5, x_axis=x_axis)
assert len(table2) == 6
LineCursors._broadcast_overlay_fn = None
# Field input should report dx/dy/dz and broadcast an image overlay
field = DataField(
data=np.arange(100, dtype=np.float64).reshape(10, 10),
xreal=2.0,
yreal=4.0,
si_unit_xy="um",
si_unit_z="nm",
)
overlays.clear()
table3, = node.process(field, x1=0.2, y1=0.25, x2=0.7, y2=0.75)
assert len(table3) == 9
field_rows = {row["quantity"]: row for row in table3}
assert field_rows["dx"]["unit"] == "um"
assert field_rows["dy"]["unit"] == "um"
assert field_rows["dz"]["unit"] == "nm"
assert np.isclose(field_rows["dx"]["value"], 1.0)
assert np.isclose(field_rows["dy"]["value"], 2.0)
assert len(overlays) == 1
assert overlays[0]["kind"] == "cursor_points"
assert overlays[0]["image"].startswith("data:image/png;base64,")
Cursors._broadcast_overlay_fn = None
print(" PASS\n")