From 8e16f9f0b48cf53aca580d75ef842e3be1db6a60 Mon Sep 17 00:00:00 2001 From: matei jordache Date: Wed, 25 Mar 2026 23:03:14 -0700 Subject: [PATCH] make cursors polymorphic --- backend/execution.py | 8 +- backend/node_menu.py | 2 +- backend/nodes/analysis.py | 126 +++++++++++++++++++++------ frontend/src/App.jsx | 4 +- frontend/src/CrossSectionOverlay.jsx | 17 ++-- frontend/src/CustomNode.jsx | 18 +++- frontend/src/executionGraph.js | 2 +- tests/test_nodes.py | 49 ++++++++--- 8 files changed, 170 insertions(+), 56 deletions(-) diff --git a/backend/execution.py b/backend/execution.py index 8f48555..7c5bde3 100644 --- a/backend/execution.py +++ b/backend/execution.py @@ -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 diff --git a/backend/node_menu.py b/backend/node_menu.py index 71da1f2..c74bd50 100644 --- a/backend/node_menu.py +++ b/backend/node_menu.py @@ -57,8 +57,8 @@ MENU_LAYOUT: dict[str, list[str]] = { "Measure": [ "Statistics", "Histogram", - "LineCursors", "CrossSection", + "Cursors", "Stats", ], "Mask": [ diff --git a/backend/nodes/analysis.py b/backend/nodes/analysis.py index 01b6bb3..dfb54f1 100644 --- a/backend/nodes/analysis.py +++ b/backend/nodes/analysis.py @@ -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, }, ) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index f2cd427..8afbb32 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -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', diff --git a/frontend/src/CrossSectionOverlay.jsx b/frontend/src/CrossSectionOverlay.jsx index 576e165..d061add 100644 --- a/frontend/src/CrossSectionOverlay.jsx +++ b/frontend/src/CrossSectionOverlay.jsx @@ -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({ field {/* Line connecting the two markers */} - - - + {showLine && ( + + + + )} {/* Endpoint markers — locked markers get a different style */}
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' ? ( + ) : data.overlay.kind === 'mask_paint' ? ( 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")