diff --git a/backend/data_types.py b/backend/data_types.py index 38a3ddb..9a94e74 100644 --- a/backend/data_types.py +++ b/backend/data_types.py @@ -711,14 +711,6 @@ def _apply_annotation_overlay_from_context( if current.ndim == 2: current = np.repeat(current[:, :, np.newaxis], 3, axis=2) - height, current_width = current.shape[:2] - legend_width = max(72, int(round(current_width * 0.18))) if show_color_map else 0 - canvas_width = current_width + legend_width - canvas = np.full((height, canvas_width, 3), 255, dtype=np.uint8) - canvas[:, :current_width] = current - - pil_image = Image.fromarray(canvas) - draw = ImageDraw.Draw(pil_image) base_font_px = max(6, int(round(text_size))) xreal_raw = context.get("xreal") @@ -739,6 +731,35 @@ def _apply_annotation_overlay_from_context( and np.isfinite(legend_max) and bool(legend_unit) ) + height, current_width = current.shape[:2] + + legend_pad_x = max(8, int(round(base_font_px * 0.45))) + legend_gap_x = max(8, int(round(base_font_px * 0.35))) + legend_gradient_width = max(12, int(round(max(current_width * 0.05, base_font_px * 0.75)))) + legend_label_images: list[Image.Image] = [] + if show_color_map and has_color_legend: + legend_label_images = [ + _render_overlay_text( + _format_with_unit(value, legend_unit), + base_font_px, + (20, 20, 20), + font_spec=font_spec, + ) + for value in (legend_max, legend_mid, legend_min) + ] + max_legend_label_width = max((label.size[0] for label in legend_label_images), default=0) + default_legend_width = max(72, int(round(current_width * 0.18))) if show_color_map else 0 + legend_width = max( + default_legend_width, + legend_pad_x * 2 + legend_gradient_width + legend_gap_x + max_legend_label_width, + ) if show_color_map else 0 + + canvas_width = current_width + legend_width + canvas = np.full((height, canvas_width, 3), 255, dtype=np.uint8) + canvas[:, :current_width] = current + + pil_image = Image.fromarray(canvas) + draw = ImageDraw.Draw(pil_image) if show_scale_bar and has_scale_bar and current_width > 0: target_real = xreal / 5.0 @@ -757,21 +778,24 @@ def _apply_annotation_overlay_from_context( text = _format_with_unit(bar_real, si_unit_xy) text_image = _render_overlay_text(text, base_font_px, (255, 255, 255), font_spec=font_spec) text_w, text_h = text_image.size - label_pad = 2 - bg_left = max(0, x0 - 4) - bg_top = max(0, y0 - text_h - label_pad * 3) - bg_right = min(canvas_width, max(x1 + 4, x0 + text_w + 8)) - bg_bottom = min(height, y1 + 4) + box_pad_x = max(4, int(round(base_font_px * 0.35))) + box_pad_y = max(3, int(round(base_font_px * 0.22))) + label_gap_y = max(2, int(round(base_font_px * 0.18))) + bar_pad_y = max(4, int(round(base_font_px * 0.25))) + bg_left = max(0, x0 - box_pad_x) + bg_top = max(0, y0 - text_h - label_gap_y - box_pad_y * 2) + bg_right = min(canvas_width, max(x1 + box_pad_x, bg_left + text_w + box_pad_x * 2)) + bg_bottom = min(height, y1 + bar_pad_y) draw.rectangle((bg_left, bg_top, bg_right, bg_bottom), fill=(0, 0, 0)) draw.rectangle((x0, y0, x1, y1), fill=(255, 255, 255)) - pil_image.paste(text_image, (x0, bg_top + label_pad), text_image) + pil_image.paste(text_image, (bg_left + box_pad_x, bg_top + box_pad_y), text_image) if show_color_map and has_color_legend and legend_width > 0: panel_x0 = current_width draw.rectangle((panel_x0, 0, canvas_width, height), fill=(245, 245, 245)) - grad_x0 = panel_x0 + max(8, legend_width // 7) - grad_w = max(12, legend_width // 5) - grad_y0 = max(10, height // 18) + grad_x0 = panel_x0 + legend_pad_x + grad_w = legend_gradient_width + grad_y0 = max(10, max(height // 18, int(round(base_font_px * 0.5)))) grad_y1 = max(grad_y0 + 10, height - grad_y0) grad_h = grad_y1 - grad_y0 gradient = np.linspace(1.0, 0.0, grad_h, dtype=np.float64)[:, np.newaxis] @@ -780,19 +804,9 @@ def _apply_annotation_overlay_from_context( pil_image.paste(Image.fromarray(gradient_rgb), (grad_x0, grad_y0)) draw.rectangle((grad_x0, grad_y0, grad_x0 + grad_w, grad_y1), outline=(40, 40, 40), width=1) - labels = [ - (legend_max, grad_y0), - (legend_mid, grad_y0 + grad_h // 2), - (legend_min, grad_y1), - ] - text_x = grad_x0 + grad_w + 8 - for value, y_center in labels: - text_image = _render_overlay_text( - _format_with_unit(value, legend_unit), - base_font_px, - (20, 20, 20), - font_spec=font_spec, - ) + label_centers = [grad_y0, grad_y0 + grad_h // 2, grad_y1] + text_x = grad_x0 + grad_w + legend_gap_x + for text_image, y_center in zip(legend_label_images, label_centers): text_y = int(round(y_center - text_image.size[1] / 2)) text_y = max(0, min(height - text_image.size[1], text_y)) pil_image.paste(text_image, (text_x, text_y), text_image) diff --git a/backend/nodes/markup.py b/backend/nodes/markup.py index f85ee25..3813571 100644 --- a/backend/nodes/markup.py +++ b/backend/nodes/markup.py @@ -22,8 +22,8 @@ class Markup: return { "required": { "input": ("ANNOTATION_SOURCE", {"label": "Input"}), - "shape": (["line", "rectangle", "circle", "arrow"], {"default": "line"}), - "stroke_color": ("STRING", {"default": "#ffd54f", "color_picker": True}), + "shape": (["line", "rectangle", "circle", "arrow"], {"default": "arrow"}), + "stroke_color": ("STRING", {"default": "#ff0000", "color_picker": True}), "stroke_width": ("INT", {"default": 3, "min": 1, "max": 64, "step": 1}), "clear_shapes": ("BUTTON", {"label": "Clear Shapes", "set_widgets": {"markup_shapes": "[]"}}), "markup_shapes": ("STRING", {"default": "[]", "hidden": True}), diff --git a/backend/nodes/preview_image.py b/backend/nodes/preview_image.py index ad4d268..f860177 100644 --- a/backend/nodes/preview_image.py +++ b/backend/nodes/preview_image.py @@ -4,6 +4,7 @@ from backend.node_registry import register_node from backend.execution_context import emit_preview from backend.data_types import ( COLORMAPS, + DataField, colormap_to_uint8, encode_preview, image_to_uint8, @@ -21,9 +22,8 @@ class PreviewImage: "colormap": (["auto"] + list(COLORMAPS), {"hide_when_input_connected": "colormap_map"}), }, "optional": { + "input": ("ANNOTATION_SOURCE", {"label": "Input"}), "colormap_map": ("COLORMAP", {"label": "colormap"}), - "image": ("IMAGE",), - "field": ("DATA_FIELD",), } } @@ -31,7 +31,7 @@ class PreviewImage: FUNCTION = "preview" OUTPUT_NODE = True - DESCRIPTION = "Display an IMAGE or DATA_FIELD as a coloured thumbnail. Connect either input." + DESCRIPTION = "Display an IMAGE or DATA_FIELD as a coloured thumbnail." _broadcast_fn = None _current_node_id: str = "" @@ -39,10 +39,12 @@ class PreviewImage: def preview( self, colormap: str, - image: np.ndarray | None = None, - field=None, + input=None, colormap_map=None, ) -> tuple: + field = input if isinstance(input, DataField) else None + image = None if field is not None else input + resolved_colormap = resolve_colormap_input( colormap, colormap_input=colormap_map, @@ -65,7 +67,7 @@ class PreviewImage: normalized = np.zeros_like(image, dtype=np.float64) arr_u8 = colormap_to_uint8(normalized, resolved_colormap) else: - raise ValueError("Connect either an IMAGE or DATA_FIELD input to Preview.") + raise ValueError("Connect an IMAGE or DATA_FIELD input to Preview.") data_uri = encode_preview(arr_u8) diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 7884a75..bd15a00 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -35,6 +35,7 @@ import { isTrackedNodeRequestCurrent, resolveLoadNodeChannelPath, } from './loadNodeOutputs.js'; +import { buildDefaultWidgetValues } from './nodeWidgetDefaults.js'; import { DATA_TYPES, SOCKET_COMPATIBILITY, TYPE_COLORS, CAT_COLORS, CANVAS_COLORS, @@ -1593,19 +1594,7 @@ function Flow() { y: contextMenu.y, }); - // Build default widget values - const widgetValues = {}; - const required = def.input.required || {}; - for (const [name, spec] of Object.entries(required)) { - const [type, opts] = Array.isArray(spec) ? spec : [spec, {}]; - if (DATA_TYPES.has(type)) continue; - if (type === 'BUTTON') continue; - if (Array.isArray(type)) { - widgetValues[name] = type[0]; // combo default = first option - } else { - widgetValues[name] = opts?.default ?? ''; - } - } + const widgetValues = buildDefaultWidgetValues(def); const newNodeId = String(nextIdRef.current++); const newNode = { diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx index 854c8da..cb9e11a 100644 --- a/frontend/src/CustomNode.jsx +++ b/frontend/src/CustomNode.jsx @@ -1533,7 +1533,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile if (type === 'STRING' && opts?.color_picker) { const normalized = typeof val === 'string' && /^#[0-9a-fA-F]{6}$/.test(val) ? val - : 'var(--shape-default)'; + : '#ff0000'; return ( <> {!hideLabel && } diff --git a/frontend/src/MarkupOverlay.jsx b/frontend/src/MarkupOverlay.jsx index f8e27ce..5870a14 100644 --- a/frontend/src/MarkupOverlay.jsx +++ b/frontend/src/MarkupOverlay.jsx @@ -1,4 +1,13 @@ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { + getArrowGeometry, + MARKUP_DEFAULT_COLOR, + MARKUP_DEFAULT_SHAPE, + getMarkupPreviewStrokeWidth, + parseMarkupShapes, + sanitizeMarkupColor, + sanitizeMarkupShape, +} from './markupShapeGeometry.js'; function clampFraction(value) { const numeric = Number(value); @@ -6,83 +15,6 @@ function clampFraction(value) { return Math.max(0, Math.min(1, numeric)); } -const SHAPE_DEFAULT_COLOR = '#ffd54f'; - -function sanitizeColor(color, fallback = SHAPE_DEFAULT_COLOR) { - if (typeof color !== 'string') return fallback; - const value = color.trim(); - return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback; -} - -function sanitizeShape(shape, fallbackShape, fallbackColor, fallbackWidth) { - if (!shape || typeof shape !== 'object') return null; - const kind = ['line', 'rectangle', 'circle', 'arrow'].includes(shape.kind) ? shape.kind : fallbackShape; - const x1 = clampFraction(shape.x1); - const y1 = clampFraction(shape.y1); - const x2 = clampFraction(shape.x2); - const y2 = clampFraction(shape.y2); - const width = Math.max(1, Math.min(64, Math.round(Number(shape.width) || fallbackWidth || 1))); - return { - kind, - x1: Number(x1.toFixed(4)), - y1: Number(y1.toFixed(4)), - x2: Number(x2.toFixed(4)), - y2: Number(y2.toFixed(4)), - width, - color: sanitizeColor(shape.color, fallbackColor), - }; -} - -function parseMarkupShapes(markupShapes, fallbackShape, fallbackColor, fallbackWidth) { - if (Array.isArray(markupShapes)) { - return markupShapes - .map((shape) => sanitizeShape(shape, fallbackShape, fallbackColor, fallbackWidth)) - .filter(Boolean); - } - - if (typeof markupShapes !== 'string' || !markupShapes.trim()) return []; - - try { - const parsed = JSON.parse(markupShapes); - if (!Array.isArray(parsed)) return []; - return parsed - .map((shape) => sanitizeShape(shape, fallbackShape, fallbackColor, fallbackWidth)) - .filter(Boolean); - } catch { - return []; - } -} - -function arrowPoints(shape, imageWidth, imageHeight) { - const x1 = shape.x1 * imageWidth; - const y1 = shape.y1 * imageHeight; - const x2 = shape.x2 * imageWidth; - const y2 = shape.y2 * imageHeight; - const dx = x2 - x1; - const dy = y2 - y1; - const length = Math.hypot(dx, dy) || 1; - const ux = dx / length; - const uy = dy / length; - const strokeWidth = Math.max(1, shape.width); - const headLength = Math.max(10, strokeWidth * 4); - const headWidth = Math.max(8, strokeWidth * 3); - const overlap = Math.max(1, strokeWidth * 0.75); - const shaftX = x2 - ux * Math.max(0, headLength - overlap); - const shaftY = y2 - uy * Math.max(0, headLength - overlap); - const headBaseX = x2 - ux * headLength; - const headBaseY = y2 - uy * headLength; - const px = -uy; - const py = ux; - const leftX = headBaseX + px * headWidth * 0.5; - const leftY = headBaseY + py * headWidth * 0.5; - const rightX = headBaseX - px * headWidth * 0.5; - const rightY = headBaseY - py * headWidth * 0.5; - return { - line: `${x1},${y1} ${shaftX},${shaftY}`, - head: `${x2},${y2} ${leftX},${leftY} ${rightX},${rightY}`, - }; -} - function ShapeElement({ shape, imageWidth, imageHeight }) { const x1 = shape.x1 * imageWidth; const y1 = shape.y1 * imageHeight; @@ -92,14 +24,14 @@ function ShapeElement({ shape, imageWidth, imageHeight }) { const top = Math.min(y1, y2); const width = Math.abs(x2 - x1); const height = Math.abs(y2 - y1); - const strokeWidth = Math.max(1, shape.width); + const strokeWidth = getMarkupPreviewStrokeWidth(shape.width, imageWidth, imageHeight); + const renderShape = { ...shape, width: strokeWidth }; const common = { fill: 'none', stroke: shape.color, strokeWidth, - strokeLinecap: 'round', + strokeLinecap: shape.kind === 'arrow' ? 'square' : 'round', strokeLinejoin: 'round', - vectorEffect: 'non-scaling-stroke', }; if (shape.kind === 'line') { @@ -122,7 +54,7 @@ function ShapeElement({ shape, imageWidth, imageHeight }) { ); } - const arrow = arrowPoints(shape, imageWidth, imageHeight); + const arrow = getArrowGeometry(renderShape, imageWidth, imageHeight); return ( <> @@ -151,10 +83,13 @@ export default function MarkupOverlay({ const [imageSize, setImageSize] = useState({ width: 1, height: 1 }); const normalizedShape = useMemo( - () => (['line', 'rectangle', 'circle', 'arrow'].includes(shape) ? shape : 'line'), + () => (['line', 'rectangle', 'circle', 'arrow'].includes(shape) ? shape : MARKUP_DEFAULT_SHAPE), [shape], ); - const normalizedColor = useMemo(() => sanitizeColor(strokeColor, '#ffd54f'), [strokeColor]); + const normalizedColor = useMemo( + () => sanitizeMarkupColor(strokeColor, MARKUP_DEFAULT_COLOR), + [strokeColor], + ); const normalizedWidth = useMemo( () => Math.max(1, Math.min(64, Math.round(Number(strokeWidth) || 3))), [strokeWidth], @@ -231,7 +166,7 @@ export default function MarkupOverlay({ setDrawing(false); return; } - const nextShape = sanitizeShape(draftShape, normalizedShape, normalizedColor, normalizedWidth); + const nextShape = sanitizeMarkupShape(draftShape, normalizedShape, normalizedColor, normalizedWidth); setDraftShape(null); setDrawing(false); if (!nextShape) return; diff --git a/frontend/src/markupShapeGeometry.js b/frontend/src/markupShapeGeometry.js new file mode 100644 index 0000000..24699de --- /dev/null +++ b/frontend/src/markupShapeGeometry.js @@ -0,0 +1,98 @@ +export const MARKUP_DEFAULT_SHAPE = 'arrow'; +export const MARKUP_DEFAULT_COLOR = '#ff0000'; +export const MARKUP_PREVIEW_REFERENCE_DIM = 512; + +function clampFraction(value) { + const numeric = Number(value); + if (!Number.isFinite(numeric)) return 0; + return Math.max(0, Math.min(1, numeric)); +} + +export function sanitizeMarkupColor(color, fallback = MARKUP_DEFAULT_COLOR) { + if (typeof color !== 'string') return fallback; + const value = color.trim(); + return /^#[0-9a-fA-F]{6}$/.test(value) ? value.toLowerCase() : fallback; +} + +export function sanitizeMarkupShape( + shape, + fallbackShape = MARKUP_DEFAULT_SHAPE, + fallbackColor = MARKUP_DEFAULT_COLOR, + fallbackWidth = 3, +) { + if (!shape || typeof shape !== 'object') return null; + const kind = ['line', 'rectangle', 'circle', 'arrow'].includes(shape.kind) ? shape.kind : fallbackShape; + const x1 = clampFraction(shape.x1); + const y1 = clampFraction(shape.y1); + const x2 = clampFraction(shape.x2); + const y2 = clampFraction(shape.y2); + const width = Math.max(1, Math.min(64, Math.round(Number(shape.width) || fallbackWidth || 1))); + return { + kind, + x1: Number(x1.toFixed(4)), + y1: Number(y1.toFixed(4)), + x2: Number(x2.toFixed(4)), + y2: Number(y2.toFixed(4)), + width, + color: sanitizeMarkupColor(shape.color, fallbackColor), + }; +} + +export function parseMarkupShapes( + markupShapes, + fallbackShape = MARKUP_DEFAULT_SHAPE, + fallbackColor = MARKUP_DEFAULT_COLOR, + fallbackWidth = 3, +) { + if (Array.isArray(markupShapes)) { + return markupShapes + .map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth)) + .filter(Boolean); + } + + if (typeof markupShapes !== 'string' || !markupShapes.trim()) return []; + + try { + const parsed = JSON.parse(markupShapes); + if (!Array.isArray(parsed)) return []; + return parsed + .map((shape) => sanitizeMarkupShape(shape, fallbackShape, fallbackColor, fallbackWidth)) + .filter(Boolean); + } catch { + return []; + } +} + +export function getArrowGeometry(shape, imageWidth, imageHeight) { + const x1 = shape.x1 * imageWidth; + const y1 = shape.y1 * imageHeight; + const x2 = shape.x2 * imageWidth; + const y2 = shape.y2 * imageHeight; + const dx = x2 - x1; + const dy = y2 - y1; + const length = Math.hypot(dx, dy) || 1; + const ux = dx / length; + const uy = dy / length; + const strokeWidth = Math.max(1, shape.width); + const headLength = Math.max(10, strokeWidth * 4); + const headWidth = Math.max(8, strokeWidth * 3); + const shaftX = x2 - ux * headLength; + const shaftY = y2 - uy * headLength; + const px = -uy; + const py = ux; + const leftX = shaftX + px * headWidth * 0.5; + const leftY = shaftY + py * headWidth * 0.5; + const rightX = shaftX - px * headWidth * 0.5; + const rightY = shaftY - py * headWidth * 0.5; + return { + line: `${x1},${y1} ${shaftX},${shaftY}`, + head: `${x2},${y2} ${leftX},${leftY} ${rightX},${rightY}`, + }; +} + +export function getMarkupPreviewStrokeWidth(width, imageWidth, imageHeight) { + const normalizedWidth = Math.max(1, Math.round(Number(width) || 1)); + const longestDim = Math.max(1, Number(imageWidth) || 0, Number(imageHeight) || 0); + const scale = Math.max(1, longestDim / MARKUP_PREVIEW_REFERENCE_DIM); + return Math.max(1, Math.round(normalizedWidth * scale)); +} diff --git a/frontend/src/nodeWidgetDefaults.js b/frontend/src/nodeWidgetDefaults.js new file mode 100644 index 0000000..c06458c --- /dev/null +++ b/frontend/src/nodeWidgetDefaults.js @@ -0,0 +1,26 @@ +import { DATA_TYPES } from './constants.js'; + +export function getDefaultWidgetValue(spec) { + const [type, opts] = Array.isArray(spec) ? spec : [spec, {}]; + if (DATA_TYPES.has(type)) return undefined; + if (type === 'BUTTON') return undefined; + if (Array.isArray(type)) { + if (typeof opts?.default === 'string' && type.includes(opts.default)) { + return opts.default; + } + return type[0]; + } + return opts?.default ?? ''; +} + +export function buildDefaultWidgetValues(definition) { + const widgetValues = {}; + const required = definition?.input?.required || {}; + for (const [name, spec] of Object.entries(required)) { + const value = getDefaultWidgetValue(spec); + if (value !== undefined) { + widgetValues[name] = value; + } + } + return widgetValues; +} diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 4736d1d..43cc1fc 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -115,7 +115,7 @@ --crop-inset: rgba(255, 255, 255, 0.22); /* Shape default */ - --shape-default: #ffd54f; + --shape-default: #ff0000; /* Dynamic-lookup fallbacks */ --fallback-type: #999; diff --git a/frontend/tests/executionGraph.test.mjs b/frontend/tests/executionGraph.test.mjs index 29e35ab..ae1061d 100644 --- a/frontend/tests/executionGraph.test.mjs +++ b/frontend/tests/executionGraph.test.mjs @@ -25,7 +25,7 @@ test('serializeExecutionGraph excludes isolated nodes from the backend prompt', data: { className: 'PreviewImage', definition: { - input: { required: { field: ['DATA_FIELD', {}] }, optional: {} }, + input: { required: {}, optional: { input: ['ANNOTATION_SOURCE', {}] } }, manual_trigger: false, }, widgetValues: {}, @@ -48,7 +48,7 @@ test('serializeExecutionGraph excludes isolated nodes from the backend prompt', source: '1', sourceHandle: 'output::0::DATA_FIELD', target: '2', - targetHandle: 'input::field::DATA_FIELD', + targetHandle: 'input::input::ANNOTATION_SOURCE', }, ]; @@ -61,7 +61,7 @@ test('serializeExecutionGraph excludes isolated nodes from the backend prompt', }, '2': { class_type: 'PreviewImage', - inputs: { field: ['1', 0] }, + inputs: { input: ['1', 0] }, }, }); assert.equal('3' in prompt, false); @@ -85,7 +85,7 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con data: { className: 'PreviewImage', definition: { - input: { required: { field: ['DATA_FIELD', {}] }, optional: {} }, + input: { required: {}, optional: { input: ['ANNOTATION_SOURCE', {}] } }, manual_trigger: false, }, widgetValues: {}, @@ -119,7 +119,7 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con source: '1', sourceHandle: 'output::0::DATA_FIELD', target: '2', - targetHandle: 'input::field::DATA_FIELD', + targetHandle: 'input::input::ANNOTATION_SOURCE', }, ]; @@ -132,7 +132,7 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con }, '2': { class_type: 'PreviewImage', - inputs: { field: ['1', 0] }, + inputs: { input: ['1', 0] }, }, '3': { class_type: 'ImageDemo', @@ -220,7 +220,7 @@ test('serializeExecutionGraph ignores group shells and resolves collapsed proxy data: { className: 'PreviewImage', definition: { - input: { required: { field: ['DATA_FIELD', {}] }, optional: {} }, + input: { required: {}, optional: { input: ['ANNOTATION_SOURCE', {}] } }, manual_trigger: false, }, widgetValues: {}, @@ -233,12 +233,12 @@ test('serializeExecutionGraph ignores group shells and resolves collapsed proxy source: '1', sourceHandle: 'output::0::DATA_FIELD', target: '10', - targetHandle: 'group-proxy::in::2::DATA_FIELD::input%3A%3Afield%3A%3ADATA_FIELD', + targetHandle: 'group-proxy::in::2::ANNOTATION_SOURCE::input%3A%3Ainput%3A%3AANNOTATION_SOURCE', data: { groupProxyOwner: '10', groupProxyOriginal: { target: '2', - targetHandle: 'input::field::DATA_FIELD', + targetHandle: 'input::input::ANNOTATION_SOURCE', }, }, }, @@ -253,7 +253,7 @@ test('serializeExecutionGraph ignores group shells and resolves collapsed proxy }, '2': { class_type: 'PreviewImage', - inputs: { field: ['1', 0] }, + inputs: { input: ['1', 0] }, }, }); assert.equal('10' in prompt, false); @@ -365,7 +365,7 @@ test('getAutoRunnableNodes includes isolated preview-load nodes with selections' source: '1', sourceHandle: 'output::0::DATA_FIELD', target: '2', - targetHandle: 'input::field::DATA_FIELD', + targetHandle: 'input::input::ANNOTATION_SOURCE', }, ]; diff --git a/frontend/tests/markupShapeGeometry.test.mjs b/frontend/tests/markupShapeGeometry.test.mjs new file mode 100644 index 0000000..b0fb1bf --- /dev/null +++ b/frontend/tests/markupShapeGeometry.test.mjs @@ -0,0 +1,67 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { + MARKUP_DEFAULT_COLOR, + MARKUP_DEFAULT_SHAPE, + getArrowGeometry, + getMarkupPreviewStrokeWidth, + sanitizeMarkupColor, + sanitizeMarkupShape, +} from '../src/markupShapeGeometry.js'; + +test('markup defaults use arrow and red', () => { + assert.equal(MARKUP_DEFAULT_SHAPE, 'arrow'); + assert.equal(MARKUP_DEFAULT_COLOR, '#ff0000'); + assert.equal(sanitizeMarkupColor(undefined), '#ff0000'); +}); + +test('sanitizeMarkupShape falls back to arrow and red', () => { + assert.deepEqual( + sanitizeMarkupShape( + { + kind: 'triangle', + x1: 0.1, + y1: 0.2, + x2: 0.9, + y2: 0.8, + width: 5, + color: 'not-a-color', + }, + MARKUP_DEFAULT_SHAPE, + MARKUP_DEFAULT_COLOR, + 3, + ), + { + kind: 'arrow', + x1: 0.1, + y1: 0.2, + x2: 0.9, + y2: 0.8, + width: 5, + color: '#ff0000', + }, + ); +}); + +test('getArrowGeometry keeps the shaft at the head base with no rounded overlap', () => { + const arrow = getArrowGeometry( + { + x1: 0, + y1: 0, + x2: 1, + y2: 0, + width: 4, + }, + 100, + 100, + ); + + assert.equal(arrow.line, '0,0 84,0'); + assert.equal(arrow.head, '100,0 84,6 84,-6'); +}); + +test('getMarkupPreviewStrokeWidth matches backend preview scaling', () => { + assert.equal(getMarkupPreviewStrokeWidth(10, 256, 256), 10); + assert.equal(getMarkupPreviewStrokeWidth(10, 1024, 768), 20); +}); diff --git a/frontend/tests/nodeWidgetDefaults.test.mjs b/frontend/tests/nodeWidgetDefaults.test.mjs new file mode 100644 index 0000000..5a48117 --- /dev/null +++ b/frontend/tests/nodeWidgetDefaults.test.mjs @@ -0,0 +1,31 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; + +import { buildDefaultWidgetValues, getDefaultWidgetValue } from '../src/nodeWidgetDefaults.js'; + +test('enum widget defaults honor opts.default instead of the first option', () => { + assert.equal( + getDefaultWidgetValue([['line', 'rectangle', 'circle', 'arrow'], { default: 'arrow' }]), + 'arrow', + ); +}); + +test('buildDefaultWidgetValues keeps non-data required widget defaults', () => { + assert.deepEqual( + buildDefaultWidgetValues({ + input: { + required: { + input: ['ANNOTATION_SOURCE', { label: 'Input' }], + shape: [['line', 'rectangle', 'circle', 'arrow'], { default: 'arrow' }], + stroke_color: ['STRING', { default: '#ff0000', color_picker: true }], + stroke_width: ['INT', { default: 3 }], + }, + }, + }), + { + shape: 'arrow', + stroke_color: '#ff0000', + stroke_width: 3, + }, + ); +}); diff --git a/tests/test_nodes.py b/tests/test_nodes.py index 6b835c5..366f311 100644 --- a/tests/test_nodes.py +++ b/tests/test_nodes.py @@ -1088,48 +1088,62 @@ def test_font_node(): def test_preview_image(): print("=== Test: PreviewImage ===") from backend.nodes.preview_image import PreviewImage + from backend.data_types import ImageData + from backend.execution_context import active_node, execution_callbacks node = PreviewImage() # Set up a capture for the broadcast captured = [] - PreviewImage._broadcast_fn = lambda node_id, data_uri: captured.append(data_uri) - PreviewImage._current_node_id = "test" + with execution_callbacks(preview=lambda nid, data_uri: captured.append(data_uri)), active_node("test"): + # Preview with a DataField + field = make_field() + node.preview(colormap="viridis", input=field) + assert len(captured) == 1 + assert captured[0].startswith("data:image/png;base64,") - # Preview with a DataField - field = make_field() - node.preview(colormap="viridis", field=field) - assert len(captured) == 1 - assert captured[0].startswith("data:image/png;base64,") + # Preview with field overlay metadata + captured.clear() + field_with_overlay = field.replace(overlays=[{"kind": "annotation", "show_scale_bar": True, "show_color_map": False, "text_size": 14.0}]) + node.preview(colormap="viridis", input=field_with_overlay) + assert len(captured) == 1 + assert captured[0].startswith("data:image/png;base64,") - # Preview with field overlay metadata - captured.clear() - field_with_overlay = field.replace(overlays=[{"kind": "annotation", "show_scale_bar": True, "show_color_map": False, "text_size": 14.0}]) - node.preview(colormap="viridis", field=field_with_overlay) - assert len(captured) == 1 - assert captured[0].startswith("data:image/png;base64,") + # Preview with a custom colormap input + captured.clear() + custom_colormap = { + "mode": "custom", + "stops": [ + {"position": 0.0, "color": "#000000"}, + {"position": 0.5, "color": "#ff0000"}, + {"position": 1.0, "color": "#ffffff"}, + ], + } + node.preview(colormap="auto", input=field, colormap_map=custom_colormap) + assert len(captured) == 1 + assert captured[0].startswith("data:image/png;base64,") - # Preview with a custom colormap input - captured.clear() - custom_colormap = { - "mode": "custom", - "stops": [ - {"position": 0.0, "color": "#000000"}, - {"position": 0.5, "color": "#ff0000"}, - {"position": 1.0, "color": "#ffffff"}, - ], - } - node.preview(colormap="auto", field=field, colormap_map=custom_colormap) - assert len(captured) == 1 - assert captured[0].startswith("data:image/png;base64,") + # Preview with an IMAGE array + captured.clear() + arr = np.random.default_rng(5).integers(0, 256, (32, 32), dtype=np.uint8) + node.preview(colormap="gray", input=arr) + assert len(captured) == 1 - # Preview with an IMAGE array - captured.clear() - arr = np.random.default_rng(5).integers(0, 256, (32, 32), dtype=np.uint8) - node.preview(colormap="gray", image=arr) - assert len(captured) == 1 + # Preview with an ANNOTATION_SOURCE carrying a DataField + captured.clear() + node.preview(colormap="auto", input=field_with_overlay) + assert len(captured) == 1 + assert captured[0].startswith("data:image/png;base64,") + + # Preview with an ANNOTATION_SOURCE carrying an ImageData + captured.clear() + annotated_image = ImageData( + np.zeros((24, 24, 3), dtype=np.uint8), + metadata={"annotation_context": {"xreal": 1e-6, "si_unit_xy": "m"}}, + ) + node.preview(colormap="auto", input=annotated_image) + assert len(captured) == 1 + assert captured[0].startswith("data:image/png;base64,") - # Clean up - PreviewImage._broadcast_fn = None print(" PASS\n") @@ -1138,12 +1152,11 @@ def test_annotations(): from backend.nodes.annotations import Annotations from backend.nodes.font_node import Font from backend.data_types import ImageData + from backend.execution_context import active_node, execution_callbacks node = Annotations() font_node = Font() warnings = [] - Annotations._broadcast_warning_fn = lambda nid, msg: warnings.append(msg) - Annotations._current_node_id = "test" field = DataField( data=np.linspace(0.0, 1.0, 64 * 64, dtype=np.float64).reshape(64, 64), xreal=1e-6, @@ -1157,98 +1170,101 @@ def test_annotations(): plain_preview = render_datafield_preview(field, "viridis") assert np.array_equal(plain_preview, base) - plain_field, = node.render(input=field, colormap="auto", show_scale_bar=False, show_color_map=False) - assert isinstance(plain_field, DataField) - assert np.array_equal(plain_field.data, field.data) - assert plain_field.colormap == "viridis" - assert plain_field.overlays[-1]["kind"] == "annotation" - plain = render_datafield_preview(plain_field, plain_field.colormap) - assert plain.shape == base.shape - assert np.array_equal(plain, base) + with execution_callbacks(warning=lambda nid, msg: warnings.append(msg)), active_node("test"): + plain_field, = node.render(input=field, colormap="auto", show_scale_bar=False, show_color_map=False) + assert isinstance(plain_field, DataField) + assert np.array_equal(plain_field.data, field.data) + assert plain_field.colormap == "viridis" + assert plain_field.overlays[-1]["kind"] == "annotation" + plain = render_datafield_preview(plain_field, plain_field.colormap) + assert plain.shape == base.shape + assert np.array_equal(plain, base) - with_scale_field, = node.render(input=field, colormap="auto", show_scale_bar=True, show_color_map=False) - with_scale = render_datafield_preview(with_scale_field, with_scale_field.colormap) - assert with_scale.shape == base.shape - assert not np.array_equal(with_scale, base) + with_scale_field, = node.render(input=field, colormap="auto", show_scale_bar=True, show_color_map=False) + with_scale = render_datafield_preview(with_scale_field, with_scale_field.colormap) + assert with_scale.shape == base.shape + assert not np.array_equal(with_scale, base) - with_legend_field, = node.render(input=field, colormap="auto", show_scale_bar=False, show_color_map=True) - with_legend = render_datafield_preview(with_legend_field, with_legend_field.colormap) - assert with_legend.shape[0] == base.shape[0] - assert with_legend.shape[1] > base.shape[1] - assert with_legend.shape[2] == 3 + with_legend_field, = node.render(input=field, colormap="auto", show_scale_bar=False, show_color_map=True) + with_legend = render_datafield_preview(with_legend_field, with_legend_field.colormap) + assert with_legend.shape[0] == base.shape[0] + assert with_legend.shape[1] > base.shape[1] + assert with_legend.shape[2] == 3 - larger_legend_field, = node.render( - input=field, - colormap="auto", - show_scale_bar=False, - show_color_map=True, - text_size=28.0, - ) - larger_legend_text = render_datafield_preview(larger_legend_field, larger_legend_field.colormap) - assert larger_legend_text.shape == with_legend.shape - assert not np.array_equal(larger_legend_text, with_legend) + larger_legend_field, = node.render( + input=field, + colormap="auto", + show_scale_bar=False, + show_color_map=True, + text_size=28.0, + ) + larger_legend_text = render_datafield_preview(larger_legend_field, larger_legend_field.colormap) + assert larger_legend_text.shape[0] == with_legend.shape[0] + assert larger_legend_text.shape[1] > with_legend.shape[1] + assert larger_legend_text.shape[2] == with_legend.shape[2] + assert not np.array_equal(larger_legend_text, with_legend) - annotation_font, = font_node.build("Arial") - with_font_field, = node.render( - input=field, - colormap="auto", - show_scale_bar=False, - show_color_map=True, - text_size=28.0, - font=annotation_font, - ) - assert with_font_field.overlays[-1]["font"] == {"family": "Arial", "path": ""} - with_font = render_datafield_preview(with_font_field, with_font_field.colormap) - assert with_font.shape == with_legend.shape + annotation_font, = font_node.build("Arial") + with_font_field, = node.render( + input=field, + colormap="auto", + show_scale_bar=False, + show_color_map=True, + text_size=28.0, + font=annotation_font, + ) + assert with_font_field.overlays[-1]["font"] == {"family": "Arial", "path": ""} + with_font = render_datafield_preview(with_font_field, with_font_field.colormap) + assert with_font.shape[0] == with_legend.shape[0] + assert with_font.shape[1] > with_legend.shape[1] + assert with_font.shape[2] == with_legend.shape[2] - with_both_field, = node.render(input=field, colormap="auto", show_scale_bar=True, show_color_map=True) - with_both = render_datafield_preview(with_both_field, with_both_field.colormap) - assert with_both.shape == with_legend.shape - assert not np.array_equal(with_both[:, :base.shape[1]], base) + with_both_field, = node.render(input=field, colormap="auto", show_scale_bar=True, show_color_map=True) + with_both = render_datafield_preview(with_both_field, with_both_field.colormap) + assert with_both.shape == with_legend.shape + assert not np.array_equal(with_both[:, :base.shape[1]], base) - viewport_image = ImageData( - np.zeros((48, 64, 3), dtype=np.uint8), - metadata={ - "annotation_context": { - "xreal": 2e-6, - "si_unit_xy": "m", - "legend_min": -1.5, - "legend_mid": 0.0, - "legend_max": 1.5, - "legend_unit": "V", - "colormap": "viridis", + viewport_image = ImageData( + np.zeros((48, 64, 3), dtype=np.uint8), + metadata={ + "annotation_context": { + "xreal": 2e-6, + "si_unit_xy": "m", + "legend_min": -1.5, + "legend_mid": 0.0, + "legend_max": 1.5, + "legend_unit": "V", + "colormap": "viridis", + }, }, - }, - ) - annotated_image, = node.render( - input=viewport_image, - colormap="auto", - show_scale_bar=True, - show_color_map=True, - text_size=18.0, - ) - assert isinstance(annotated_image, ImageData) - assert annotated_image.shape[0] == viewport_image.shape[0] - assert annotated_image.shape[1] > viewport_image.shape[1] - assert annotated_image.metadata["annotation_context"]["legend_unit"] == "V" - assert not np.array_equal(np.asarray(annotated_image)[:, :viewport_image.shape[1]], np.asarray(viewport_image)) - assert warnings == [] + ) + annotated_image, = node.render( + input=viewport_image, + colormap="auto", + show_scale_bar=True, + show_color_map=True, + text_size=18.0, + ) + assert isinstance(annotated_image, ImageData) + assert annotated_image.shape[0] == viewport_image.shape[0] + assert annotated_image.shape[1] > viewport_image.shape[1] + assert annotated_image.metadata["annotation_context"]["legend_unit"] == "V" + assert not np.array_equal(np.asarray(annotated_image)[:, :viewport_image.shape[1]], np.asarray(viewport_image)) + assert warnings == [] - plain_image = ImageData(np.zeros((32, 40, 3), dtype=np.uint8)) - passthrough_image, = node.render( - input=plain_image, - colormap="auto", - show_scale_bar=True, - show_color_map=True, - text_size=18.0, - ) - assert isinstance(passthrough_image, ImageData) - assert passthrough_image.shape == plain_image.shape - assert np.array_equal(np.asarray(passthrough_image), np.asarray(plain_image)) - assert len(warnings) == 1 - assert "no scale metadata" in warnings[0] - - Annotations._broadcast_warning_fn = None + plain_image = ImageData(np.zeros((32, 40, 3), dtype=np.uint8)) + passthrough_image, = node.render( + input=plain_image, + colormap="auto", + show_scale_bar=True, + show_color_map=True, + text_size=18.0, + ) + assert isinstance(passthrough_image, ImageData) + assert passthrough_image.shape == plain_image.shape + assert np.array_equal(np.asarray(passthrough_image), np.asarray(plain_image)) + assert len(warnings) == 1 + assert "no scale metadata" in warnings[0] print(" PASS\n") @@ -1257,67 +1273,74 @@ def test_markup(): print("=== Test: Markup ===") from backend.nodes.markup import Markup from backend.data_types import ImageData, _preview_markup_stroke_width + from backend.execution_context import active_node, execution_callbacks node = Markup() field = make_field(data=np.linspace(0.0, 1.0, 48 * 48, dtype=np.float64).reshape(48, 48)) base = render_datafield_preview(field, field.colormap) + required_inputs = Markup.INPUT_TYPES()["required"] assert _preview_markup_stroke_width(5, 128, 128) == 5 assert _preview_markup_stroke_width(5, 2048, 2048) > 5 + assert required_inputs["shape"][1]["default"] == "arrow" + assert required_inputs["stroke_color"][1]["default"] == "#ff0000" overlays = [] - Markup._broadcast_overlay_fn = lambda nid, data: overlays.append(data) - Markup._current_node_id = "test" + with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"): + plain_field, = node.process( + input=field, + shape="line", + stroke_color="#ffd54f", + stroke_width=3, + markup_shapes="[]", + ) + assert isinstance(plain_field, DataField) + assert plain_field.overlays[-1]["kind"] == "markup" + plain = render_datafield_preview(plain_field, plain_field.colormap) + assert np.array_equal(plain, base) + assert overlays[-1]["kind"] == "markup" + assert overlays[-1]["shape"] == "line" + assert overlays[-1]["stroke_color"] == "#ffd54f" + assert overlays[-1]["stroke_width"] == 3 + assert overlays[-1]["image"].startswith("data:image/png;base64,") - plain_field, = node.process( - input=field, - shape="line", - stroke_color="#ffd54f", - stroke_width=3, - markup_shapes="[]", - ) - assert isinstance(plain_field, DataField) - assert plain_field.overlays[-1]["kind"] == "markup" - plain = render_datafield_preview(plain_field, plain_field.colormap) - assert np.array_equal(plain, base) - assert overlays[-1]["kind"] == "markup" - assert overlays[-1]["image"].startswith("data:image/png;base64,") + shapes = json.dumps([ + {"kind": "line", "x1": 0.1, "y1": 0.1, "x2": 0.9, "y2": 0.9, "width": 3, "color": "#ff0000"}, + {"kind": "rectangle", "x1": 0.2, "y1": 0.2, "x2": 0.8, "y2": 0.5, "width": 2, "color": "#00ff00"}, + {"kind": "circle", "x1": 0.25, "y1": 0.55, "x2": 0.55, "y2": 0.85, "width": 2, "color": "#4fc3f7"}, + {"kind": "arrow", "x1": 0.15, "y1": 0.85, "x2": 0.85, "y2": 0.2, "width": 4, "color": "#ffffff"}, + ]) + marked_field, = node.process( + input=field, + shape="arrow", + stroke_color="#ffffff", + stroke_width=4, + markup_shapes=shapes, + ) + marked = render_datafield_preview(marked_field, marked_field.colormap) + assert marked.shape == base.shape + assert not np.array_equal(marked, base) + assert overlays[-1]["shape"] == "arrow" + assert overlays[-1]["stroke_color"] == "#ffffff" + assert overlays[-1]["stroke_width"] == 4 - shapes = json.dumps([ - {"kind": "line", "x1": 0.1, "y1": 0.1, "x2": 0.9, "y2": 0.9, "width": 3, "color": "#ff0000"}, - {"kind": "rectangle", "x1": 0.2, "y1": 0.2, "x2": 0.8, "y2": 0.5, "width": 2, "color": "#00ff00"}, - {"kind": "circle", "x1": 0.25, "y1": 0.55, "x2": 0.55, "y2": 0.85, "width": 2, "color": "#4fc3f7"}, - {"kind": "arrow", "x1": 0.15, "y1": 0.85, "x2": 0.85, "y2": 0.2, "width": 4, "color": "#ffffff"}, - ]) - marked_field, = node.process( - input=field, - shape="arrow", - stroke_color="#ffffff", - stroke_width=4, - markup_shapes=shapes, - ) - marked = render_datafield_preview(marked_field, marked_field.colormap) - assert marked.shape == base.shape - assert not np.array_equal(marked, base) + viewport_image = ImageData( + np.zeros((48, 48, 3), dtype=np.uint8), + metadata={"annotation_context": {"xreal": 1e-6, "si_unit_xy": "m"}}, + ) + image_markup, = node.process( + input=viewport_image, + shape="line", + stroke_color="#ff0000", + stroke_width=4, + markup_shapes=json.dumps([ + {"kind": "line", "x1": 0.1, "y1": 0.2, "x2": 0.9, "y2": 0.8, "width": 4, "color": "#ff0000"}, + ]), + ) + assert isinstance(image_markup, ImageData) + assert image_markup.metadata["annotation_context"]["si_unit_xy"] == "m" + assert not np.array_equal(np.asarray(image_markup), np.asarray(viewport_image)) - viewport_image = ImageData( - np.zeros((48, 48, 3), dtype=np.uint8), - metadata={"annotation_context": {"xreal": 1e-6, "si_unit_xy": "m"}}, - ) - image_markup, = node.process( - input=viewport_image, - shape="line", - stroke_color="#ff0000", - stroke_width=4, - markup_shapes=json.dumps([ - {"kind": "line", "x1": 0.1, "y1": 0.2, "x2": 0.9, "y2": 0.8, "width": 4, "color": "#ff0000"}, - ]), - ) - assert isinstance(image_markup, ImageData) - assert image_markup.metadata["annotation_context"]["si_unit_xy"] == "m" - assert not np.array_equal(np.asarray(image_markup), np.asarray(viewport_image)) - - Markup._broadcast_overlay_fn = None print(" PASS\n")