fix preview inputs and markup preview
This commit is contained in:
@@ -711,14 +711,6 @@ def _apply_annotation_overlay_from_context(
|
|||||||
if current.ndim == 2:
|
if current.ndim == 2:
|
||||||
current = np.repeat(current[:, :, np.newaxis], 3, axis=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)))
|
base_font_px = max(6, int(round(text_size)))
|
||||||
|
|
||||||
xreal_raw = context.get("xreal")
|
xreal_raw = context.get("xreal")
|
||||||
@@ -739,6 +731,35 @@ def _apply_annotation_overlay_from_context(
|
|||||||
and np.isfinite(legend_max)
|
and np.isfinite(legend_max)
|
||||||
and bool(legend_unit)
|
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:
|
if show_scale_bar and has_scale_bar and current_width > 0:
|
||||||
target_real = xreal / 5.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 = _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_image = _render_overlay_text(text, base_font_px, (255, 255, 255), font_spec=font_spec)
|
||||||
text_w, text_h = text_image.size
|
text_w, text_h = text_image.size
|
||||||
label_pad = 2
|
box_pad_x = max(4, int(round(base_font_px * 0.35)))
|
||||||
bg_left = max(0, x0 - 4)
|
box_pad_y = max(3, int(round(base_font_px * 0.22)))
|
||||||
bg_top = max(0, y0 - text_h - label_pad * 3)
|
label_gap_y = max(2, int(round(base_font_px * 0.18)))
|
||||||
bg_right = min(canvas_width, max(x1 + 4, x0 + text_w + 8))
|
bar_pad_y = max(4, int(round(base_font_px * 0.25)))
|
||||||
bg_bottom = min(height, y1 + 4)
|
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((bg_left, bg_top, bg_right, bg_bottom), fill=(0, 0, 0))
|
||||||
draw.rectangle((x0, y0, x1, y1), fill=(255, 255, 255))
|
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:
|
if show_color_map and has_color_legend and legend_width > 0:
|
||||||
panel_x0 = current_width
|
panel_x0 = current_width
|
||||||
draw.rectangle((panel_x0, 0, canvas_width, height), fill=(245, 245, 245))
|
draw.rectangle((panel_x0, 0, canvas_width, height), fill=(245, 245, 245))
|
||||||
grad_x0 = panel_x0 + max(8, legend_width // 7)
|
grad_x0 = panel_x0 + legend_pad_x
|
||||||
grad_w = max(12, legend_width // 5)
|
grad_w = legend_gradient_width
|
||||||
grad_y0 = max(10, height // 18)
|
grad_y0 = max(10, max(height // 18, int(round(base_font_px * 0.5))))
|
||||||
grad_y1 = max(grad_y0 + 10, height - grad_y0)
|
grad_y1 = max(grad_y0 + 10, height - grad_y0)
|
||||||
grad_h = grad_y1 - grad_y0
|
grad_h = grad_y1 - grad_y0
|
||||||
gradient = np.linspace(1.0, 0.0, grad_h, dtype=np.float64)[:, np.newaxis]
|
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))
|
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)
|
draw.rectangle((grad_x0, grad_y0, grad_x0 + grad_w, grad_y1), outline=(40, 40, 40), width=1)
|
||||||
|
|
||||||
labels = [
|
label_centers = [grad_y0, grad_y0 + grad_h // 2, grad_y1]
|
||||||
(legend_max, grad_y0),
|
text_x = grad_x0 + grad_w + legend_gap_x
|
||||||
(legend_mid, grad_y0 + grad_h // 2),
|
for text_image, y_center in zip(legend_label_images, label_centers):
|
||||||
(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,
|
|
||||||
)
|
|
||||||
text_y = int(round(y_center - text_image.size[1] / 2))
|
text_y = int(round(y_center - text_image.size[1] / 2))
|
||||||
text_y = max(0, min(height - text_image.size[1], text_y))
|
text_y = max(0, min(height - text_image.size[1], text_y))
|
||||||
pil_image.paste(text_image, (text_x, text_y), text_image)
|
pil_image.paste(text_image, (text_x, text_y), text_image)
|
||||||
|
|||||||
@@ -22,8 +22,8 @@ class Markup:
|
|||||||
return {
|
return {
|
||||||
"required": {
|
"required": {
|
||||||
"input": ("ANNOTATION_SOURCE", {"label": "Input"}),
|
"input": ("ANNOTATION_SOURCE", {"label": "Input"}),
|
||||||
"shape": (["line", "rectangle", "circle", "arrow"], {"default": "line"}),
|
"shape": (["line", "rectangle", "circle", "arrow"], {"default": "arrow"}),
|
||||||
"stroke_color": ("STRING", {"default": "#ffd54f", "color_picker": True}),
|
"stroke_color": ("STRING", {"default": "#ff0000", "color_picker": True}),
|
||||||
"stroke_width": ("INT", {"default": 3, "min": 1, "max": 64, "step": 1}),
|
"stroke_width": ("INT", {"default": 3, "min": 1, "max": 64, "step": 1}),
|
||||||
"clear_shapes": ("BUTTON", {"label": "Clear Shapes", "set_widgets": {"markup_shapes": "[]"}}),
|
"clear_shapes": ("BUTTON", {"label": "Clear Shapes", "set_widgets": {"markup_shapes": "[]"}}),
|
||||||
"markup_shapes": ("STRING", {"default": "[]", "hidden": True}),
|
"markup_shapes": ("STRING", {"default": "[]", "hidden": True}),
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from backend.node_registry import register_node
|
|||||||
from backend.execution_context import emit_preview
|
from backend.execution_context import emit_preview
|
||||||
from backend.data_types import (
|
from backend.data_types import (
|
||||||
COLORMAPS,
|
COLORMAPS,
|
||||||
|
DataField,
|
||||||
colormap_to_uint8,
|
colormap_to_uint8,
|
||||||
encode_preview,
|
encode_preview,
|
||||||
image_to_uint8,
|
image_to_uint8,
|
||||||
@@ -21,9 +22,8 @@ class PreviewImage:
|
|||||||
"colormap": (["auto"] + list(COLORMAPS), {"hide_when_input_connected": "colormap_map"}),
|
"colormap": (["auto"] + list(COLORMAPS), {"hide_when_input_connected": "colormap_map"}),
|
||||||
},
|
},
|
||||||
"optional": {
|
"optional": {
|
||||||
|
"input": ("ANNOTATION_SOURCE", {"label": "Input"}),
|
||||||
"colormap_map": ("COLORMAP", {"label": "colormap"}),
|
"colormap_map": ("COLORMAP", {"label": "colormap"}),
|
||||||
"image": ("IMAGE",),
|
|
||||||
"field": ("DATA_FIELD",),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -31,7 +31,7 @@ class PreviewImage:
|
|||||||
FUNCTION = "preview"
|
FUNCTION = "preview"
|
||||||
|
|
||||||
OUTPUT_NODE = True
|
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
|
_broadcast_fn = None
|
||||||
_current_node_id: str = ""
|
_current_node_id: str = ""
|
||||||
@@ -39,10 +39,12 @@ class PreviewImage:
|
|||||||
def preview(
|
def preview(
|
||||||
self,
|
self,
|
||||||
colormap: str,
|
colormap: str,
|
||||||
image: np.ndarray | None = None,
|
input=None,
|
||||||
field=None,
|
|
||||||
colormap_map=None,
|
colormap_map=None,
|
||||||
) -> tuple:
|
) -> tuple:
|
||||||
|
field = input if isinstance(input, DataField) else None
|
||||||
|
image = None if field is not None else input
|
||||||
|
|
||||||
resolved_colormap = resolve_colormap_input(
|
resolved_colormap = resolve_colormap_input(
|
||||||
colormap,
|
colormap,
|
||||||
colormap_input=colormap_map,
|
colormap_input=colormap_map,
|
||||||
@@ -65,7 +67,7 @@ class PreviewImage:
|
|||||||
normalized = np.zeros_like(image, dtype=np.float64)
|
normalized = np.zeros_like(image, dtype=np.float64)
|
||||||
arr_u8 = colormap_to_uint8(normalized, resolved_colormap)
|
arr_u8 = colormap_to_uint8(normalized, resolved_colormap)
|
||||||
else:
|
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)
|
data_uri = encode_preview(arr_u8)
|
||||||
|
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ import {
|
|||||||
isTrackedNodeRequestCurrent,
|
isTrackedNodeRequestCurrent,
|
||||||
resolveLoadNodeChannelPath,
|
resolveLoadNodeChannelPath,
|
||||||
} from './loadNodeOutputs.js';
|
} from './loadNodeOutputs.js';
|
||||||
|
import { buildDefaultWidgetValues } from './nodeWidgetDefaults.js';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
DATA_TYPES, SOCKET_COMPATIBILITY, TYPE_COLORS, CAT_COLORS, CANVAS_COLORS,
|
DATA_TYPES, SOCKET_COMPATIBILITY, TYPE_COLORS, CAT_COLORS, CANVAS_COLORS,
|
||||||
@@ -1593,19 +1594,7 @@ function Flow() {
|
|||||||
y: contextMenu.y,
|
y: contextMenu.y,
|
||||||
});
|
});
|
||||||
|
|
||||||
// Build default widget values
|
const widgetValues = buildDefaultWidgetValues(def);
|
||||||
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 newNodeId = String(nextIdRef.current++);
|
const newNodeId = String(nextIdRef.current++);
|
||||||
const newNode = {
|
const newNode = {
|
||||||
|
|||||||
@@ -1533,7 +1533,7 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
|
|||||||
if (type === 'STRING' && opts?.color_picker) {
|
if (type === 'STRING' && opts?.color_picker) {
|
||||||
const normalized = typeof val === 'string' && /^#[0-9a-fA-F]{6}$/.test(val)
|
const normalized = typeof val === 'string' && /^#[0-9a-fA-F]{6}$/.test(val)
|
||||||
? val
|
? val
|
||||||
: 'var(--shape-default)';
|
: '#ff0000';
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{!hideLabel && <label>{label}</label>}
|
{!hideLabel && <label>{label}</label>}
|
||||||
|
|||||||
@@ -1,4 +1,13 @@
|
|||||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
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) {
|
function clampFraction(value) {
|
||||||
const numeric = Number(value);
|
const numeric = Number(value);
|
||||||
@@ -6,83 +15,6 @@ function clampFraction(value) {
|
|||||||
return Math.max(0, Math.min(1, numeric));
|
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 }) {
|
function ShapeElement({ shape, imageWidth, imageHeight }) {
|
||||||
const x1 = shape.x1 * imageWidth;
|
const x1 = shape.x1 * imageWidth;
|
||||||
const y1 = shape.y1 * imageHeight;
|
const y1 = shape.y1 * imageHeight;
|
||||||
@@ -92,14 +24,14 @@ function ShapeElement({ shape, imageWidth, imageHeight }) {
|
|||||||
const top = Math.min(y1, y2);
|
const top = Math.min(y1, y2);
|
||||||
const width = Math.abs(x2 - x1);
|
const width = Math.abs(x2 - x1);
|
||||||
const height = Math.abs(y2 - y1);
|
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 = {
|
const common = {
|
||||||
fill: 'none',
|
fill: 'none',
|
||||||
stroke: shape.color,
|
stroke: shape.color,
|
||||||
strokeWidth,
|
strokeWidth,
|
||||||
strokeLinecap: 'round',
|
strokeLinecap: shape.kind === 'arrow' ? 'square' : 'round',
|
||||||
strokeLinejoin: 'round',
|
strokeLinejoin: 'round',
|
||||||
vectorEffect: 'non-scaling-stroke',
|
|
||||||
};
|
};
|
||||||
|
|
||||||
if (shape.kind === 'line') {
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<polyline points={arrow.line} {...common} />
|
<polyline points={arrow.line} {...common} />
|
||||||
@@ -151,10 +83,13 @@ export default function MarkupOverlay({
|
|||||||
const [imageSize, setImageSize] = useState({ width: 1, height: 1 });
|
const [imageSize, setImageSize] = useState({ width: 1, height: 1 });
|
||||||
|
|
||||||
const normalizedShape = useMemo(
|
const normalizedShape = useMemo(
|
||||||
() => (['line', 'rectangle', 'circle', 'arrow'].includes(shape) ? shape : 'line'),
|
() => (['line', 'rectangle', 'circle', 'arrow'].includes(shape) ? shape : MARKUP_DEFAULT_SHAPE),
|
||||||
[shape],
|
[shape],
|
||||||
);
|
);
|
||||||
const normalizedColor = useMemo(() => sanitizeColor(strokeColor, '#ffd54f'), [strokeColor]);
|
const normalizedColor = useMemo(
|
||||||
|
() => sanitizeMarkupColor(strokeColor, MARKUP_DEFAULT_COLOR),
|
||||||
|
[strokeColor],
|
||||||
|
);
|
||||||
const normalizedWidth = useMemo(
|
const normalizedWidth = useMemo(
|
||||||
() => Math.max(1, Math.min(64, Math.round(Number(strokeWidth) || 3))),
|
() => Math.max(1, Math.min(64, Math.round(Number(strokeWidth) || 3))),
|
||||||
[strokeWidth],
|
[strokeWidth],
|
||||||
@@ -231,7 +166,7 @@ export default function MarkupOverlay({
|
|||||||
setDrawing(false);
|
setDrawing(false);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const nextShape = sanitizeShape(draftShape, normalizedShape, normalizedColor, normalizedWidth);
|
const nextShape = sanitizeMarkupShape(draftShape, normalizedShape, normalizedColor, normalizedWidth);
|
||||||
setDraftShape(null);
|
setDraftShape(null);
|
||||||
setDrawing(false);
|
setDrawing(false);
|
||||||
if (!nextShape) return;
|
if (!nextShape) return;
|
||||||
|
|||||||
98
frontend/src/markupShapeGeometry.js
Normal file
98
frontend/src/markupShapeGeometry.js
Normal file
@@ -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));
|
||||||
|
}
|
||||||
26
frontend/src/nodeWidgetDefaults.js
Normal file
26
frontend/src/nodeWidgetDefaults.js
Normal file
@@ -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;
|
||||||
|
}
|
||||||
@@ -115,7 +115,7 @@
|
|||||||
--crop-inset: rgba(255, 255, 255, 0.22);
|
--crop-inset: rgba(255, 255, 255, 0.22);
|
||||||
|
|
||||||
/* Shape default */
|
/* Shape default */
|
||||||
--shape-default: #ffd54f;
|
--shape-default: #ff0000;
|
||||||
|
|
||||||
/* Dynamic-lookup fallbacks */
|
/* Dynamic-lookup fallbacks */
|
||||||
--fallback-type: #999;
|
--fallback-type: #999;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ test('serializeExecutionGraph excludes isolated nodes from the backend prompt',
|
|||||||
data: {
|
data: {
|
||||||
className: 'PreviewImage',
|
className: 'PreviewImage',
|
||||||
definition: {
|
definition: {
|
||||||
input: { required: { field: ['DATA_FIELD', {}] }, optional: {} },
|
input: { required: {}, optional: { input: ['ANNOTATION_SOURCE', {}] } },
|
||||||
manual_trigger: false,
|
manual_trigger: false,
|
||||||
},
|
},
|
||||||
widgetValues: {},
|
widgetValues: {},
|
||||||
@@ -48,7 +48,7 @@ test('serializeExecutionGraph excludes isolated nodes from the backend prompt',
|
|||||||
source: '1',
|
source: '1',
|
||||||
sourceHandle: 'output::0::DATA_FIELD',
|
sourceHandle: 'output::0::DATA_FIELD',
|
||||||
target: '2',
|
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': {
|
'2': {
|
||||||
class_type: 'PreviewImage',
|
class_type: 'PreviewImage',
|
||||||
inputs: { field: ['1', 0] },
|
inputs: { input: ['1', 0] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal('3' in prompt, false);
|
assert.equal('3' in prompt, false);
|
||||||
@@ -85,7 +85,7 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con
|
|||||||
data: {
|
data: {
|
||||||
className: 'PreviewImage',
|
className: 'PreviewImage',
|
||||||
definition: {
|
definition: {
|
||||||
input: { required: { field: ['DATA_FIELD', {}] }, optional: {} },
|
input: { required: {}, optional: { input: ['ANNOTATION_SOURCE', {}] } },
|
||||||
manual_trigger: false,
|
manual_trigger: false,
|
||||||
},
|
},
|
||||||
widgetValues: {},
|
widgetValues: {},
|
||||||
@@ -119,7 +119,7 @@ test('serializeExecutionGraph includes isolated preview-load nodes alongside con
|
|||||||
source: '1',
|
source: '1',
|
||||||
sourceHandle: 'output::0::DATA_FIELD',
|
sourceHandle: 'output::0::DATA_FIELD',
|
||||||
target: '2',
|
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': {
|
'2': {
|
||||||
class_type: 'PreviewImage',
|
class_type: 'PreviewImage',
|
||||||
inputs: { field: ['1', 0] },
|
inputs: { input: ['1', 0] },
|
||||||
},
|
},
|
||||||
'3': {
|
'3': {
|
||||||
class_type: 'ImageDemo',
|
class_type: 'ImageDemo',
|
||||||
@@ -220,7 +220,7 @@ test('serializeExecutionGraph ignores group shells and resolves collapsed proxy
|
|||||||
data: {
|
data: {
|
||||||
className: 'PreviewImage',
|
className: 'PreviewImage',
|
||||||
definition: {
|
definition: {
|
||||||
input: { required: { field: ['DATA_FIELD', {}] }, optional: {} },
|
input: { required: {}, optional: { input: ['ANNOTATION_SOURCE', {}] } },
|
||||||
manual_trigger: false,
|
manual_trigger: false,
|
||||||
},
|
},
|
||||||
widgetValues: {},
|
widgetValues: {},
|
||||||
@@ -233,12 +233,12 @@ test('serializeExecutionGraph ignores group shells and resolves collapsed proxy
|
|||||||
source: '1',
|
source: '1',
|
||||||
sourceHandle: 'output::0::DATA_FIELD',
|
sourceHandle: 'output::0::DATA_FIELD',
|
||||||
target: '10',
|
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: {
|
data: {
|
||||||
groupProxyOwner: '10',
|
groupProxyOwner: '10',
|
||||||
groupProxyOriginal: {
|
groupProxyOriginal: {
|
||||||
target: '2',
|
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': {
|
'2': {
|
||||||
class_type: 'PreviewImage',
|
class_type: 'PreviewImage',
|
||||||
inputs: { field: ['1', 0] },
|
inputs: { input: ['1', 0] },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal('10' in prompt, false);
|
assert.equal('10' in prompt, false);
|
||||||
@@ -365,7 +365,7 @@ test('getAutoRunnableNodes includes isolated preview-load nodes with selections'
|
|||||||
source: '1',
|
source: '1',
|
||||||
sourceHandle: 'output::0::DATA_FIELD',
|
sourceHandle: 'output::0::DATA_FIELD',
|
||||||
target: '2',
|
target: '2',
|
||||||
targetHandle: 'input::field::DATA_FIELD',
|
targetHandle: 'input::input::ANNOTATION_SOURCE',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
|
|||||||
67
frontend/tests/markupShapeGeometry.test.mjs
Normal file
67
frontend/tests/markupShapeGeometry.test.mjs
Normal file
@@ -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);
|
||||||
|
});
|
||||||
31
frontend/tests/nodeWidgetDefaults.test.mjs
Normal file
31
frontend/tests/nodeWidgetDefaults.test.mjs
Normal file
@@ -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,
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
@@ -1088,48 +1088,62 @@ def test_font_node():
|
|||||||
def test_preview_image():
|
def test_preview_image():
|
||||||
print("=== Test: PreviewImage ===")
|
print("=== Test: PreviewImage ===")
|
||||||
from backend.nodes.preview_image import 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()
|
node = PreviewImage()
|
||||||
|
|
||||||
# Set up a capture for the broadcast
|
# Set up a capture for the broadcast
|
||||||
captured = []
|
captured = []
|
||||||
PreviewImage._broadcast_fn = lambda node_id, data_uri: captured.append(data_uri)
|
with execution_callbacks(preview=lambda nid, data_uri: captured.append(data_uri)), active_node("test"):
|
||||||
PreviewImage._current_node_id = "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
|
# Preview with field overlay metadata
|
||||||
field = make_field()
|
captured.clear()
|
||||||
node.preview(colormap="viridis", field=field)
|
field_with_overlay = field.replace(overlays=[{"kind": "annotation", "show_scale_bar": True, "show_color_map": False, "text_size": 14.0}])
|
||||||
assert len(captured) == 1
|
node.preview(colormap="viridis", input=field_with_overlay)
|
||||||
assert captured[0].startswith("data:image/png;base64,")
|
assert len(captured) == 1
|
||||||
|
assert captured[0].startswith("data:image/png;base64,")
|
||||||
|
|
||||||
# Preview with field overlay metadata
|
# Preview with a custom colormap input
|
||||||
captured.clear()
|
captured.clear()
|
||||||
field_with_overlay = field.replace(overlays=[{"kind": "annotation", "show_scale_bar": True, "show_color_map": False, "text_size": 14.0}])
|
custom_colormap = {
|
||||||
node.preview(colormap="viridis", field=field_with_overlay)
|
"mode": "custom",
|
||||||
assert len(captured) == 1
|
"stops": [
|
||||||
assert captured[0].startswith("data:image/png;base64,")
|
{"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
|
# Preview with an IMAGE array
|
||||||
captured.clear()
|
captured.clear()
|
||||||
custom_colormap = {
|
arr = np.random.default_rng(5).integers(0, 256, (32, 32), dtype=np.uint8)
|
||||||
"mode": "custom",
|
node.preview(colormap="gray", input=arr)
|
||||||
"stops": [
|
assert len(captured) == 1
|
||||||
{"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
|
# Preview with an ANNOTATION_SOURCE carrying a DataField
|
||||||
captured.clear()
|
captured.clear()
|
||||||
arr = np.random.default_rng(5).integers(0, 256, (32, 32), dtype=np.uint8)
|
node.preview(colormap="auto", input=field_with_overlay)
|
||||||
node.preview(colormap="gray", image=arr)
|
assert len(captured) == 1
|
||||||
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")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
@@ -1138,12 +1152,11 @@ def test_annotations():
|
|||||||
from backend.nodes.annotations import Annotations
|
from backend.nodes.annotations import Annotations
|
||||||
from backend.nodes.font_node import Font
|
from backend.nodes.font_node import Font
|
||||||
from backend.data_types import ImageData
|
from backend.data_types import ImageData
|
||||||
|
from backend.execution_context import active_node, execution_callbacks
|
||||||
|
|
||||||
node = Annotations()
|
node = Annotations()
|
||||||
font_node = Font()
|
font_node = Font()
|
||||||
warnings = []
|
warnings = []
|
||||||
Annotations._broadcast_warning_fn = lambda nid, msg: warnings.append(msg)
|
|
||||||
Annotations._current_node_id = "test"
|
|
||||||
field = DataField(
|
field = DataField(
|
||||||
data=np.linspace(0.0, 1.0, 64 * 64, dtype=np.float64).reshape(64, 64),
|
data=np.linspace(0.0, 1.0, 64 * 64, dtype=np.float64).reshape(64, 64),
|
||||||
xreal=1e-6,
|
xreal=1e-6,
|
||||||
@@ -1157,98 +1170,101 @@ def test_annotations():
|
|||||||
plain_preview = render_datafield_preview(field, "viridis")
|
plain_preview = render_datafield_preview(field, "viridis")
|
||||||
assert np.array_equal(plain_preview, base)
|
assert np.array_equal(plain_preview, base)
|
||||||
|
|
||||||
plain_field, = node.render(input=field, colormap="auto", show_scale_bar=False, show_color_map=False)
|
with execution_callbacks(warning=lambda nid, msg: warnings.append(msg)), active_node("test"):
|
||||||
assert isinstance(plain_field, DataField)
|
plain_field, = node.render(input=field, colormap="auto", show_scale_bar=False, show_color_map=False)
|
||||||
assert np.array_equal(plain_field.data, field.data)
|
assert isinstance(plain_field, DataField)
|
||||||
assert plain_field.colormap == "viridis"
|
assert np.array_equal(plain_field.data, field.data)
|
||||||
assert plain_field.overlays[-1]["kind"] == "annotation"
|
assert plain_field.colormap == "viridis"
|
||||||
plain = render_datafield_preview(plain_field, plain_field.colormap)
|
assert plain_field.overlays[-1]["kind"] == "annotation"
|
||||||
assert plain.shape == base.shape
|
plain = render_datafield_preview(plain_field, plain_field.colormap)
|
||||||
assert np.array_equal(plain, base)
|
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_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)
|
with_scale = render_datafield_preview(with_scale_field, with_scale_field.colormap)
|
||||||
assert with_scale.shape == base.shape
|
assert with_scale.shape == base.shape
|
||||||
assert not np.array_equal(with_scale, base)
|
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_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)
|
with_legend = render_datafield_preview(with_legend_field, with_legend_field.colormap)
|
||||||
assert with_legend.shape[0] == base.shape[0]
|
assert with_legend.shape[0] == base.shape[0]
|
||||||
assert with_legend.shape[1] > base.shape[1]
|
assert with_legend.shape[1] > base.shape[1]
|
||||||
assert with_legend.shape[2] == 3
|
assert with_legend.shape[2] == 3
|
||||||
|
|
||||||
larger_legend_field, = node.render(
|
larger_legend_field, = node.render(
|
||||||
input=field,
|
input=field,
|
||||||
colormap="auto",
|
colormap="auto",
|
||||||
show_scale_bar=False,
|
show_scale_bar=False,
|
||||||
show_color_map=True,
|
show_color_map=True,
|
||||||
text_size=28.0,
|
text_size=28.0,
|
||||||
)
|
)
|
||||||
larger_legend_text = render_datafield_preview(larger_legend_field, larger_legend_field.colormap)
|
larger_legend_text = render_datafield_preview(larger_legend_field, larger_legend_field.colormap)
|
||||||
assert larger_legend_text.shape == with_legend.shape
|
assert larger_legend_text.shape[0] == with_legend.shape[0]
|
||||||
assert not np.array_equal(larger_legend_text, with_legend)
|
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")
|
annotation_font, = font_node.build("Arial")
|
||||||
with_font_field, = node.render(
|
with_font_field, = node.render(
|
||||||
input=field,
|
input=field,
|
||||||
colormap="auto",
|
colormap="auto",
|
||||||
show_scale_bar=False,
|
show_scale_bar=False,
|
||||||
show_color_map=True,
|
show_color_map=True,
|
||||||
text_size=28.0,
|
text_size=28.0,
|
||||||
font=annotation_font,
|
font=annotation_font,
|
||||||
)
|
)
|
||||||
assert with_font_field.overlays[-1]["font"] == {"family": "Arial", "path": ""}
|
assert with_font_field.overlays[-1]["font"] == {"family": "Arial", "path": ""}
|
||||||
with_font = render_datafield_preview(with_font_field, with_font_field.colormap)
|
with_font = render_datafield_preview(with_font_field, with_font_field.colormap)
|
||||||
assert with_font.shape == with_legend.shape
|
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_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)
|
with_both = render_datafield_preview(with_both_field, with_both_field.colormap)
|
||||||
assert with_both.shape == with_legend.shape
|
assert with_both.shape == with_legend.shape
|
||||||
assert not np.array_equal(with_both[:, :base.shape[1]], base)
|
assert not np.array_equal(with_both[:, :base.shape[1]], base)
|
||||||
|
|
||||||
viewport_image = ImageData(
|
viewport_image = ImageData(
|
||||||
np.zeros((48, 64, 3), dtype=np.uint8),
|
np.zeros((48, 64, 3), dtype=np.uint8),
|
||||||
metadata={
|
metadata={
|
||||||
"annotation_context": {
|
"annotation_context": {
|
||||||
"xreal": 2e-6,
|
"xreal": 2e-6,
|
||||||
"si_unit_xy": "m",
|
"si_unit_xy": "m",
|
||||||
"legend_min": -1.5,
|
"legend_min": -1.5,
|
||||||
"legend_mid": 0.0,
|
"legend_mid": 0.0,
|
||||||
"legend_max": 1.5,
|
"legend_max": 1.5,
|
||||||
"legend_unit": "V",
|
"legend_unit": "V",
|
||||||
"colormap": "viridis",
|
"colormap": "viridis",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
},
|
)
|
||||||
)
|
annotated_image, = node.render(
|
||||||
annotated_image, = node.render(
|
input=viewport_image,
|
||||||
input=viewport_image,
|
colormap="auto",
|
||||||
colormap="auto",
|
show_scale_bar=True,
|
||||||
show_scale_bar=True,
|
show_color_map=True,
|
||||||
show_color_map=True,
|
text_size=18.0,
|
||||||
text_size=18.0,
|
)
|
||||||
)
|
assert isinstance(annotated_image, ImageData)
|
||||||
assert isinstance(annotated_image, ImageData)
|
assert annotated_image.shape[0] == viewport_image.shape[0]
|
||||||
assert annotated_image.shape[0] == viewport_image.shape[0]
|
assert annotated_image.shape[1] > viewport_image.shape[1]
|
||||||
assert annotated_image.shape[1] > viewport_image.shape[1]
|
assert annotated_image.metadata["annotation_context"]["legend_unit"] == "V"
|
||||||
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 not np.array_equal(np.asarray(annotated_image)[:, :viewport_image.shape[1]], np.asarray(viewport_image))
|
assert warnings == []
|
||||||
assert warnings == []
|
|
||||||
|
|
||||||
plain_image = ImageData(np.zeros((32, 40, 3), dtype=np.uint8))
|
plain_image = ImageData(np.zeros((32, 40, 3), dtype=np.uint8))
|
||||||
passthrough_image, = node.render(
|
passthrough_image, = node.render(
|
||||||
input=plain_image,
|
input=plain_image,
|
||||||
colormap="auto",
|
colormap="auto",
|
||||||
show_scale_bar=True,
|
show_scale_bar=True,
|
||||||
show_color_map=True,
|
show_color_map=True,
|
||||||
text_size=18.0,
|
text_size=18.0,
|
||||||
)
|
)
|
||||||
assert isinstance(passthrough_image, ImageData)
|
assert isinstance(passthrough_image, ImageData)
|
||||||
assert passthrough_image.shape == plain_image.shape
|
assert passthrough_image.shape == plain_image.shape
|
||||||
assert np.array_equal(np.asarray(passthrough_image), np.asarray(plain_image))
|
assert np.array_equal(np.asarray(passthrough_image), np.asarray(plain_image))
|
||||||
assert len(warnings) == 1
|
assert len(warnings) == 1
|
||||||
assert "no scale metadata" in warnings[0]
|
assert "no scale metadata" in warnings[0]
|
||||||
|
|
||||||
Annotations._broadcast_warning_fn = None
|
|
||||||
|
|
||||||
print(" PASS\n")
|
print(" PASS\n")
|
||||||
|
|
||||||
@@ -1257,67 +1273,74 @@ def test_markup():
|
|||||||
print("=== Test: Markup ===")
|
print("=== Test: Markup ===")
|
||||||
from backend.nodes.markup import Markup
|
from backend.nodes.markup import Markup
|
||||||
from backend.data_types import ImageData, _preview_markup_stroke_width
|
from backend.data_types import ImageData, _preview_markup_stroke_width
|
||||||
|
from backend.execution_context import active_node, execution_callbacks
|
||||||
|
|
||||||
node = Markup()
|
node = Markup()
|
||||||
field = make_field(data=np.linspace(0.0, 1.0, 48 * 48, dtype=np.float64).reshape(48, 48))
|
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)
|
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, 128, 128) == 5
|
||||||
assert _preview_markup_stroke_width(5, 2048, 2048) > 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 = []
|
overlays = []
|
||||||
Markup._broadcast_overlay_fn = lambda nid, data: overlays.append(data)
|
with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"):
|
||||||
Markup._current_node_id = "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(
|
shapes = json.dumps([
|
||||||
input=field,
|
{"kind": "line", "x1": 0.1, "y1": 0.1, "x2": 0.9, "y2": 0.9, "width": 3, "color": "#ff0000"},
|
||||||
shape="line",
|
{"kind": "rectangle", "x1": 0.2, "y1": 0.2, "x2": 0.8, "y2": 0.5, "width": 2, "color": "#00ff00"},
|
||||||
stroke_color="#ffd54f",
|
{"kind": "circle", "x1": 0.25, "y1": 0.55, "x2": 0.55, "y2": 0.85, "width": 2, "color": "#4fc3f7"},
|
||||||
stroke_width=3,
|
{"kind": "arrow", "x1": 0.15, "y1": 0.85, "x2": 0.85, "y2": 0.2, "width": 4, "color": "#ffffff"},
|
||||||
markup_shapes="[]",
|
])
|
||||||
)
|
marked_field, = node.process(
|
||||||
assert isinstance(plain_field, DataField)
|
input=field,
|
||||||
assert plain_field.overlays[-1]["kind"] == "markup"
|
shape="arrow",
|
||||||
plain = render_datafield_preview(plain_field, plain_field.colormap)
|
stroke_color="#ffffff",
|
||||||
assert np.array_equal(plain, base)
|
stroke_width=4,
|
||||||
assert overlays[-1]["kind"] == "markup"
|
markup_shapes=shapes,
|
||||||
assert overlays[-1]["image"].startswith("data:image/png;base64,")
|
)
|
||||||
|
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([
|
viewport_image = ImageData(
|
||||||
{"kind": "line", "x1": 0.1, "y1": 0.1, "x2": 0.9, "y2": 0.9, "width": 3, "color": "#ff0000"},
|
np.zeros((48, 48, 3), dtype=np.uint8),
|
||||||
{"kind": "rectangle", "x1": 0.2, "y1": 0.2, "x2": 0.8, "y2": 0.5, "width": 2, "color": "#00ff00"},
|
metadata={"annotation_context": {"xreal": 1e-6, "si_unit_xy": "m"}},
|
||||||
{"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"},
|
image_markup, = node.process(
|
||||||
])
|
input=viewport_image,
|
||||||
marked_field, = node.process(
|
shape="line",
|
||||||
input=field,
|
stroke_color="#ff0000",
|
||||||
shape="arrow",
|
stroke_width=4,
|
||||||
stroke_color="#ffffff",
|
markup_shapes=json.dumps([
|
||||||
stroke_width=4,
|
{"kind": "line", "x1": 0.1, "y1": 0.2, "x2": 0.9, "y2": 0.8, "width": 4, "color": "#ff0000"},
|
||||||
markup_shapes=shapes,
|
]),
|
||||||
)
|
)
|
||||||
marked = render_datafield_preview(marked_field, marked_field.colormap)
|
assert isinstance(image_markup, ImageData)
|
||||||
assert marked.shape == base.shape
|
assert image_markup.metadata["annotation_context"]["si_unit_xy"] == "m"
|
||||||
assert not np.array_equal(marked, base)
|
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")
|
print(" PASS\n")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user