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: ) -> None:
"""Wire up broadcast callbacks on display node classes.""" """Wire up broadcast callbacks on display node classes."""
from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay, Markup from backend.nodes.display import PreviewImage, PrintTable, View3D, ValueDisplay, Markup
from backend.nodes.analysis import CrossSection, LineCursors, Stats, Histogram from backend.nodes.analysis import CrossSection, Cursors, Stats, Histogram
from backend.nodes.modify import CropResizeField, RotateField from backend.nodes.modify import CropResizeField, RotateField
from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask
from backend.nodes.io import SaveImage, Image, ImageDemo from backend.nodes.io import SaveImage, Image, ImageDemo
@@ -236,7 +236,7 @@ class ExecutionEngine:
Stats._broadcast_value_fn = on_value Stats._broadcast_value_fn = on_value
Histogram._broadcast_overlay_fn = on_overlay Histogram._broadcast_overlay_fn = on_overlay
CrossSection._broadcast_overlay_fn = on_overlay CrossSection._broadcast_overlay_fn = on_overlay
LineCursors._broadcast_overlay_fn = on_overlay Cursors._broadcast_overlay_fn = on_overlay
CropResizeField._broadcast_overlay_fn = on_overlay CropResizeField._broadcast_overlay_fn = on_overlay
RotateField._broadcast_warning_fn = on_warning RotateField._broadcast_warning_fn = on_warning
Markup._broadcast_overlay_fn = on_overlay Markup._broadcast_overlay_fn = on_overlay
@@ -247,11 +247,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, Markup 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.modify import CropResizeField, RotateField
from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask from backend.nodes.mask import ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask
from backend.nodes.io import Image, ImageDemo, SaveImage 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, ThresholdMask, MaskMorphology, MaskInvert, MaskCombine, DrawMask,
Image, ImageDemo, SaveImage): Image, ImageDemo, SaveImage):
cls._current_node_id = node_id cls._current_node_id = node_id

View File

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

View File

@@ -12,7 +12,7 @@ from __future__ import annotations
import numpy as np import numpy as np
from typing import Callable from typing import Callable
from backend.node_registry import register_node 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") @register_node(display_name="Cursors")
class LineCursors: class Cursors:
"""Place two draggable cursors on any LINE plot to measure values and deltas.""" """Place two draggable cursors on a line plot or field to measure deltas."""
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { return {
"required": { "required": {
"line": ("LINE",), "line": ("CURSOR_SOURCE", {"label": "input"}),
"x1": ("FLOAT", {"default": 0.25, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}), "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}), "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}), "x2": ("FLOAT", {"default": 0.75, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
@@ -186,8 +186,9 @@ class LineCursors:
FUNCTION = "process" FUNCTION = "process"
CATEGORY = "analysis" CATEGORY = "analysis"
DESCRIPTION = ( DESCRIPTION = (
"Place two cursors on any line plot (histogram, cross section, profile) " "Place two cursors on a line plot or 2D field. "
"to measure positions, values, and deltas. Drag the markers to reposition." "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 _broadcast_overlay_fn = None
@@ -196,6 +197,20 @@ class LineCursors:
def process( def process(
self, line, x1: float, y1: float, x2: float, y2: float, self, line, x1: float, y1: float, x2: float, y2: float,
x_axis=None, 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: ) -> tuple:
y = np.asarray(line, dtype=np.float64).ravel() y = np.asarray(line, dtype=np.float64).ravel()
n = len(y) n = len(y)
@@ -224,12 +239,12 @@ class LineCursors:
xb, yb = float(x[idx_b]), float(y[idx_b]) xb, yb = float(x[idx_b]), float(y[idx_b])
# --- Broadcast overlay --- # --- Broadcast overlay ---
if LineCursors._broadcast_overlay_fn is not None: if Cursors._broadcast_overlay_fn is not None:
LineCursors._broadcast_overlay_fn( Cursors._broadcast_overlay_fn(
LineCursors._current_node_id, Cursors._current_node_id,
{ {
"kind": "line_plot", "kind": "line_plot",
"section_title": "Line Cursors", "section_title": "Cursors",
"line": y.tolist(), "line": y.tolist(),
"x_axis": x.tolist(), "x_axis": x.tolist(),
"x1": x1, "x1": x1,
@@ -243,12 +258,69 @@ class LineCursors:
# --- Output table --- # --- Output table ---
table = MeasureTable([ table = MeasureTable([
{"quantity": "A position", "value": xa, "unit": ""}, {"quantity": "A x", "value": xa, "unit": ""},
{"quantity": "A value", "value": ya, "unit": ""}, {"quantity": "A y", "value": ya, "unit": ""},
{"quantity": "B position", "value": xb, "unit": ""}, {"quantity": "B x", "value": xb, "unit": ""},
{"quantity": "B value", "value": yb, "unit": ""}, {"quantity": "B y", "value": yb, "unit": ""},
{"quantity": "delta X", "value": xb - xa, "unit": ""}, {"quantity": "dx", "value": xb - xa, "unit": ""},
{"quantity": "delta Y", "value": yb - ya, "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,) return (table,)
@@ -570,8 +642,8 @@ class CrossSection:
"n_samples": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}), "n_samples": ("INT", {"default": 0, "min": 0, "max": 4096, "step": 1}),
}, },
"optional": { "optional": {
"point_a": ("COORD",), "marker_A": ("COORD",),
"point_b": ("COORD",), "marker_B": ("COORD",),
}, },
} }
@@ -592,15 +664,15 @@ class CrossSection:
self, field: DataField, self, field: DataField,
x1: float, y1: float, x2: float, y2: float, x1: float, y1: float, x2: float, y2: float,
extend: str, n_samples: int, extend: str, n_samples: int,
point_a=None, point_b=None, marker_A=None, marker_B=None,
) -> tuple: ) -> tuple:
from scipy.ndimage import map_coordinates from scipy.ndimage import map_coordinates
# COORD inputs override widget values # COORD inputs override widget values
if point_a is not None: if marker_A is not None:
x1, y1 = float(point_a[0]), float(point_a[1]) x1, y1 = float(marker_A[0]), float(marker_A[1])
if point_b is not None: if marker_B is not None:
x2, y2 = float(point_b[0]), float(point_b[1]) x2, y2 = float(marker_B[0]), float(marker_B[1])
# Remember marker positions (before extend) # Remember marker positions (before extend)
marker_x1, marker_y1 = float(x1), float(y1) marker_x1, marker_y1 = float(x1), float(y1)
@@ -642,8 +714,8 @@ class CrossSection:
"image": image_uri, "image": image_uri,
"x1": marker_x1, "y1": marker_y1, "x1": marker_x1, "y1": marker_y1,
"x2": marker_x2, "y2": marker_y2, "x2": marker_x2, "y2": marker_y2,
"a_locked": point_a is not None, "a_locked": marker_A is not None,
"b_locked": point_b is not None, "b_locked": marker_B is not None,
}, },
) )

View File

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

View File

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

View File

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

View File

@@ -1,6 +1,6 @@
const DATA_TYPES = new Set([ const DATA_TYPES = new Set([
'DATA_FIELD', 'IMAGE', 'LINE', 'MEASURE_TABLE', 'RECORD_TABLE', 'ANY_TABLE', '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) { function getInputName(handleId) {

View File

@@ -1612,40 +1612,40 @@ def test_execution_engine_numeric_socket_coercion():
# ========================================================================= # =========================================================================
# Analysis — LineCursors # Analysis — Cursors
# ========================================================================= # =========================================================================
def test_line_cursors(): def test_line_cursors():
print("=== Test: LineCursors ===") print("=== Test: Cursors ===")
from backend.nodes.analysis import LineCursors from backend.nodes.analysis import Cursors
node = LineCursors() node = Cursors()
# Create a simple linear ramp # Create a simple linear ramp
line = np.linspace(0, 10, 100).astype(np.float64) line = np.linspace(0, 10, 100).astype(np.float64)
# Capture overlay # Capture overlay
overlays = [] overlays = []
LineCursors._broadcast_overlay_fn = lambda nid, data: overlays.append(data) Cursors._broadcast_overlay_fn = lambda nid, data: overlays.append(data)
LineCursors._current_node_id = "test" Cursors._current_node_id = "test"
table, = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5) table, = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5)
# Should produce a 6-row table # Should produce a 6-row table
assert len(table) == 6 assert len(table) == 6
quantities = {row["quantity"] for row in table} quantities = {row["quantity"] for row in table}
assert "A position" in quantities assert "A x" in quantities
assert "B position" in quantities assert "B x" in quantities
assert "delta X" in quantities assert "dx" in quantities
assert "delta Y" in quantities assert "dy" in quantities
# B should be at a later position than A # B should be at a later position than A
a_pos = next(r["value"] for r in table if r["quantity"] == "A 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 position") b_pos = next(r["value"] for r in table if r["quantity"] == "B x")
assert b_pos > a_pos assert b_pos > a_pos
# Delta Y should reflect the height difference along the ramp # 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 assert dy > 0 # ramp goes upward
# Overlay should have been broadcast # 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) table2, = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5, x_axis=x_axis)
assert len(table2) == 6 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") print(" PASS\n")