tidy up old code

This commit is contained in:
2026-04-02 00:40:09 -07:00
parent 6bfa295d25
commit 7de9bddec7
39 changed files with 219 additions and 533 deletions

View File

@@ -8,6 +8,7 @@ tono is a node-based SPM image processing and analysis tool. The main focus is o
It is heavily inspired by [Gwyddion](https://gwyddion.net/), one of my favorite scientific FOSS programs on the web.
<img src="frontend/public/default-workflow.png" width="600">
## Quick start
Install a local binary from the Releases section, or run locally:

View File

@@ -2,7 +2,6 @@ from __future__ import annotations
from contextlib import contextmanager
from contextvars import ContextVar
import inspect
from typing import Any, Callable
Callback = Callable[[str, Any], None]
@@ -13,16 +12,6 @@ _callbacks_var: ContextVar[dict[str, Callback | None]] = ContextVar(
)
_node_id_var: ContextVar[str | None] = ContextVar("tono_execution_node_id", default=None)
_LEGACY_CALLBACK_ATTRS = {
"preview": "_broadcast_fn",
"table": "_broadcast_table_fn",
"mesh": "_broadcast_mesh_fn",
"overlay": "_broadcast_overlay_fn",
"value": "_broadcast_value_fn",
"warning": "_broadcast_warning_fn",
"file_download": "_broadcast_file_download_fn",
}
@contextmanager
def execution_callbacks(
@@ -63,42 +52,12 @@ def current_node_id() -> str | None:
return _node_id_var.get()
def _legacy_emit(kind: str, payload: Any) -> bool:
callback_attr = _LEGACY_CALLBACK_ATTRS.get(kind)
if not callback_attr:
return False
frame = inspect.currentframe()
try:
frame = frame.f_back
while frame is not None:
for owner_name in ("self", "cls"):
owner = frame.f_locals.get(owner_name)
if owner is None:
continue
candidate = owner if isinstance(owner, type) else owner.__class__
callback = getattr(candidate, callback_attr, None)
node_id = getattr(candidate, "_current_node_id", None)
if callback is not None and node_id:
callback(str(node_id), payload)
return True
frame = frame.f_back
finally:
del frame
return False
def _emit(kind: str, payload: Any) -> None:
callbacks = _callbacks_var.get()
callback = callbacks.get(kind)
node_id = current_node_id()
if callback is not None and node_id:
callback(node_id, payload)
return
_legacy_emit(kind, payload)
def emit_preview(payload: Any) -> None:

View File

@@ -16,9 +16,6 @@ from backend.data_types import (
@register_node(display_name="Annotations")
class Annotations:
_broadcast_warning_fn = None
_current_node_id: str = ""
@classmethod
def INPUT_TYPES(cls):
return {

View File

@@ -37,9 +37,6 @@ class CropResizeField:
"resizing preserves the cropped physical size."
)
_broadcast_overlay_fn = None
_current_node_id: str = ""
def process(
self,
field: DataField,

View File

@@ -39,9 +39,6 @@ class CrossSection:
"Equivalent to gwy_data_field_get_profile."
)
_broadcast_overlay_fn = None
_current_node_id: str = ""
def process(
self, field: DataField,
x1: float, y1: float, x2: float, y2: float,

View File

@@ -39,9 +39,6 @@ class Cursors:
"On fields it reports x/y/z at both markers plus dx/dy/dz."
)
_broadcast_overlay_fn = None
_current_node_id: str = ""
def process(
self, line, x1: float, y1: float, x2: float, y2: float,
coord_pair=None,

View File

@@ -30,9 +30,6 @@ class FFT1D:
"Returns the FFT spectrum of the line, and identifies peaks."
)
_broadcast_overlay_fn = None
_current_node_id: str = ""
def process(
self, profile,
) -> tuple:

View File

@@ -122,84 +122,6 @@ def _measurement_value(table: list, selection: str) -> float:
raise ValueError(f"Measurement '{row.get('quantity', selection)}' does not have a numeric value.")
# ---------------------------------------------------------------------------
# SI formatting helpers (from display.py — used by Annotations)
# ---------------------------------------------------------------------------
_SI_PREFIXES = [
(1e24, "Y"), (1e21, "Z"), (1e18, "E"), (1e15, "P"), (1e12, "T"),
(1e9, "G"), (1e6, "M"), (1e3, "k"), (1.0, ""), (1e-3, "m"),
(1e-6, "u"), (1e-9, "n"), (1e-12, "p"), (1e-15, "f"),
(1e-18, "a"), (1e-21, "z"), (1e-24, "y"),
]
_PREFIXABLE_UNITS = {"m", "s", "A", "V", "W", "Hz", "F", "C", "J", "N", "Pa", "T", "H", "S", "g", "K", "Ohm", "ohm", "\u03a9"}
def _format_numeric(value: float) -> str:
if not np.isfinite(value):
return str(value)
abs_value = abs(value)
if abs_value == 0:
return "0"
if abs_value >= 1e4 or abs_value < 1e-3:
return f"{value:.3e}"
return f"{value:.4g}"
def _format_with_unit(value: float, unit: str) -> str:
unit = (unit or "").strip()
if not unit:
return _format_numeric(value)
if unit in _PREFIXABLE_UNITS and np.isfinite(value) and value != 0:
abs_value = abs(value)
for scale, prefix in _SI_PREFIXES:
scaled = abs_value / scale
if 1 <= scaled < 1000:
signed = value / scale
return f"{_format_numeric(signed)} {prefix}{unit}"
return f"{_format_numeric(value)} {unit}"
def _nice_length(target: float) -> float:
if not np.isfinite(target) or target <= 0:
return 0.0
exponent = np.floor(np.log10(target))
base = 10.0 ** exponent
for step in (5.0, 2.0, 1.0):
candidate = step * base
if candidate <= target:
return candidate
return base
def _display_value_range(field) -> tuple[float, float, float]:
data = np.asarray(field.data, dtype=np.float64)
dmin = float(data.min())
dmax = float(data.max())
if not np.isfinite(dmin) or not np.isfinite(dmax) or dmax <= dmin:
return dmin, dmin, dmin
offset = float(field.display_offset)
scale = float(field.display_scale)
if not np.isfinite(offset):
offset = 0.0
if not np.isfinite(scale) or scale <= 0.0:
scale = 1.0
low_norm = float(np.clip(offset, 0.0, 1.0))
high_norm = float(np.clip(offset + scale, 0.0, 1.0))
if high_norm < low_norm:
low_norm, high_norm = high_norm, low_norm
mid_norm = 0.5 * (low_norm + high_norm)
span = dmax - dmin
return (
dmin + low_norm * span,
dmin + mid_norm * span,
dmin + high_norm * span,
)
def _render_annotation_text(text: str, size_px: int, color: tuple[int, int, int]):
from PIL import Image, ImageDraw, ImageFont

View File

@@ -34,9 +34,6 @@ class Histogram:
"Equivalent to gwy_data_field_dh."
)
_broadcast_overlay_fn = None
_current_node_id: str = ""
def process(
self,
field: DataField,

View File

@@ -36,9 +36,6 @@ class Image:
"Images (.png, .tiff, .jpg) and arrays (.npy, .npz) are loaded as uncalibrated fields."
)
_broadcast_warning_fn = None
_current_node_id = None
def load(self, filename: str = "", colormap: str = "viridis", colormap_map=None, path: str | None = None):
selected_path = str(path).strip() if path is not None else str(filename).strip()
if not selected_path:

View File

@@ -26,9 +26,6 @@ class ImageDemo:
DESCRIPTION = "Load a bundled demo file so you can try the app without providing your own data."
_broadcast_warning_fn = None
_current_node_id = None
def load(self, name: str = "", colormap: str = "viridis", colormap_map=None):
from backend.nodes.image import Image
loader = Image()

View File

@@ -43,9 +43,6 @@ class Markup:
"or rasterize markup directly onto an IMAGE."
)
_broadcast_overlay_fn = None
_current_node_id: str = ""
def process(
self,
input,

View File

@@ -33,9 +33,6 @@ class DrawMask:
"and invert flips the final binary output."
)
_broadcast_overlay_fn = None
_current_node_id: str = ""
def process(self, field: DataField, pen_size: int, invert: bool, mask_paths: str) -> tuple:
strokes = _parse_mask_strokes(mask_paths)
mask = _rasterize_mask(field.xres, field.yres, strokes, pen_size)

View File

@@ -28,9 +28,6 @@ class MaskInvert:
DESCRIPTION = "Invert a binary mask — swap masked and unmasked regions."
_broadcast_fn = None
_current_node_id: str = ""
def process(self, mask: np.ndarray, field: DataField | None = None) -> tuple:
out = np.where(mask > 127, np.uint8(0), np.uint8(255))

View File

@@ -41,9 +41,6 @@ class MaskMorphology:
"Equivalent to Gwyddion mask_morph."
)
_broadcast_fn = None
_current_node_id: str = ""
def process(self, mask: np.ndarray, operation: str, radius: int, shape: str,
field: DataField | None = None) -> tuple:
from scipy.ndimage import binary_closing, binary_dilation, binary_erosion, binary_opening

View File

@@ -33,9 +33,6 @@ class ThresholdMask:
"Equivalent to Gwyddion's threshold and otsu_threshold modules."
)
_broadcast_fn = None
_current_node_id: str = ""
def process(self, field: DataField, method: str, threshold: float, direction: str) -> tuple:
data = field.data

View File

@@ -36,9 +36,6 @@ class PreviewImage:
OUTPUT_NODE = True
DESCRIPTION = "Display an IMAGE or DATA_FIELD as a coloured thumbnail."
_broadcast_fn = None
_current_node_id: str = ""
def preview(
self,
colormap: str,

View File

@@ -21,9 +21,6 @@ class PrintTable:
OUTPUT_NODE = True
DESCRIPTION = "Send a measurement or record table to the browser as a WebSocket message for display."
_broadcast_table_fn = None
_current_node_id: str = ""
def print_table(self, table: list) -> tuple:
emit_table(table)
return ()

View File

@@ -28,9 +28,6 @@ class RotateField:
"Optionally expand the canvas to keep the full rotated field while preserving the field center."
)
_broadcast_warning_fn = None
_current_node_id: str = ""
def process(
self,
field: DataField,

View File

@@ -74,9 +74,6 @@ class Save:
"Save a single graph value to disk. Supports fields, images, lines, tables, scalars, and 3D meshes."
)
_broadcast_warning_fn = None
_current_node_id = None
def save(
self,
filename: str,

View File

@@ -63,9 +63,6 @@ class SaveImage:
"Click Save to write (does not auto-run)."
)
_broadcast_warning_fn = None
_current_node_id = None
def save(
self,
filename: str,
@@ -187,5 +184,3 @@ class SaveImage:
def _send_warning(self, message: str):
emit_warning(message)
return ()

View File

@@ -19,9 +19,6 @@ from backend.nodes.helpers import (
class Stats:
"""Polymorphic scalar stats node for LINE, DATA_TABLE, DATA_FIELD, or IMAGE inputs."""
_broadcast_value_fn = None
_current_node_id: str = ""
@classmethod
def INPUT_TYPES(cls):
return {

View File

@@ -41,9 +41,6 @@ class ValueIO:
DESCRIPTION = "Display a FLOAT, or a selected numeric row from a measurement table, and pass the value through unchanged."
_broadcast_value_fn = None
_current_node_id: str = ""
def display_value(self, number_input: str = "0", value=None, measurement: str = "") -> tuple:
unit = ""
if isinstance(value, RecordTable):

View File

@@ -148,9 +148,6 @@ class View3D:
"Drag to rotate, middle-drag to pan, and right-drag or scroll to zoom. z_scale exaggerates height."
)
_broadcast_mesh_fn = None
_current_node_id: str = ""
def render(
self, field: DataField,
colormap: str, z_scale: float, resolution: int, make_solid: bool = False,

View File

@@ -1,50 +0,0 @@
const EXCLUDED_CANVAS_TARGETS = '.context-menu, .react-flow__node, .react-flow__edge, .react-flow__controls, .react-flow__minimap, .surface-view-container';
const CANVAS_AREA_TARGETS = '.react-flow, .react-flow__renderer, .react-flow__viewport, .react-flow__pane, .react-flow__background, .react-flow__selectionpane';
function getTargetElement(target: EventTarget | null): Element | null {
if (!target) return null;
if (typeof (target as Element).closest === 'function') return target as Element;
const parent = (target as Node).parentElement;
if (parent && typeof parent.closest === 'function') {
return parent;
}
return null;
}
function supportsClosest(target: EventTarget | null): boolean {
return !!getTargetElement(target);
}
function matchesClosest(target: EventTarget | null, selector: string): boolean {
const element = getTargetElement(target);
return !!element && element.closest(selector) !== null;
}
export function isEditableInteractionTarget(target: EventTarget | null): boolean {
if (!supportsClosest(target)) return false;
if (matchesClosest(target, 'input, textarea, select')) return true;
return matchesClosest(target, '[contenteditable="true"]');
}
export function canStartCanvasRightDragZoomTarget(target: EventTarget | null): boolean {
if (!supportsClosest(target)) return false;
if (isEditableInteractionTarget(target)) return false;
if (matchesClosest(target, EXCLUDED_CANVAS_TARGETS)) {
return false;
}
return matchesClosest(target, CANVAS_AREA_TARGETS);
}
export function canOpenCanvasContextMenuTarget(target: EventTarget | null): boolean {
if (!supportsClosest(target)) return false;
if (isEditableInteractionTarget(target)) return false;
if (matchesClosest(target, EXCLUDED_CANVAS_TARGETS)) {
return false;
}
return matchesClosest(target, CANVAS_AREA_TARGETS);
}
export function isSecondaryCanvasContextEvent(event: MouseEvent | null): boolean {
if (!event || typeof event.button !== 'number') return false;
return event.button === 2 || (event.button === 0 && !!event.ctrlKey);
}

View File

@@ -1,49 +0,0 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import {
canOpenCanvasContextMenuTarget,
canStartCanvasRightDragZoomTarget,
isEditableInteractionTarget,
isSecondaryCanvasContextEvent,
} from '../src/canvasInteractionTargets.ts';
function makeTarget(activeSelectors = []) {
const selectorSet = new Set(activeSelectors);
return {
closest(selector) {
const parts = String(selector).split(',').map((part) => part.trim());
return parts.some((part) => selectorSet.has(part)) ? {} : null;
},
};
}
test('editable canvas targets stay editable', () => {
const inputTarget = makeTarget(['input']);
assert.equal(isEditableInteractionTarget(inputTarget), true);
assert.equal(canOpenCanvasContextMenuTarget(inputTarget), false);
assert.equal(canStartCanvasRightDragZoomTarget(inputTarget), false);
});
test('empty pane targets allow the custom canvas context menu', () => {
const paneTarget = makeTarget(['.react-flow__pane']);
assert.equal(canOpenCanvasContextMenuTarget(paneTarget), true);
assert.equal(canStartCanvasRightDragZoomTarget(paneTarget), true);
});
test('viewport-level targets also allow the custom canvas context menu', () => {
const viewportTarget = makeTarget(['.react-flow__viewport']);
assert.equal(canOpenCanvasContextMenuTarget(viewportTarget), true);
assert.equal(canStartCanvasRightDragZoomTarget(viewportTarget), true);
});
test('node and surface-view targets do not open the canvas context menu', () => {
assert.equal(canOpenCanvasContextMenuTarget(makeTarget(['.react-flow__node', '.react-flow__pane'])), false);
assert.equal(canOpenCanvasContextMenuTarget(makeTarget(['.surface-view-container', '.react-flow__pane'])), false);
});
test('secondary canvas context detection includes macOS ctrl-click', () => {
assert.equal(isSecondaryCanvasContextEvent({ button: 2, ctrlKey: false }), true);
assert.equal(isSecondaryCanvasContextEvent({ button: 0, ctrlKey: true }), true);
assert.equal(isSecondaryCanvasContextEvent({ button: 0, ctrlKey: false }), false);
});

View File

@@ -5,10 +5,6 @@ export default defineConfig({
plugins: [react()],
server: {
host: true,
allowedHosts: ["bronchial-lorita-gorgeously.ngrok-free.dev"],
hmr:{
clientPort: 80,
},
port: 5173,
proxy: {
'/nodes': 'http://127.0.0.1:8188',

View File

@@ -1,5 +1,6 @@
import numpy as np
from backend.data_types import DataField
from backend.execution_context import execution_callbacks, active_node
def test_crop_resize_field():
@@ -19,42 +20,38 @@ def test_crop_resize_field():
)
overlays = []
CropResizeField._broadcast_overlay_fn = lambda nid, data: overlays.append(data)
CropResizeField._current_node_id = "test"
with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"):
cropped, = node.process(field, x1=0.25, y1=0.25, x2=0.75, y2=1.0, target_width=0, target_height=0, interpolation="bilinear")
assert cropped.data.shape == (3, 4)
assert np.array_equal(cropped.data, data[1:4, 2:6])
assert cropped.xreal == 4.0
assert cropped.yreal == 3.0
assert cropped.xoff == 12.0
assert cropped.yoff == 21.0
assert cropped.si_unit_xy == field.si_unit_xy
assert cropped.si_unit_z == field.si_unit_z
assert cropped.overlays == []
assert len(overlays) == 1
assert overlays[0]["kind"] == "crop_box"
assert overlays[0]["image"].startswith("data:image/png;base64,")
assert overlays[0]["a_locked"] is False
assert overlays[0]["b_locked"] is False
cropped, = node.process(field, x1=0.25, y1=0.25, x2=0.75, y2=1.0, target_width=0, target_height=0, interpolation="bilinear")
assert cropped.data.shape == (3, 4)
assert np.array_equal(cropped.data, data[1:4, 2:6])
assert cropped.xreal == 4.0
assert cropped.yreal == 3.0
assert cropped.xoff == 12.0
assert cropped.yoff == 21.0
assert cropped.si_unit_xy == field.si_unit_xy
assert cropped.si_unit_z == field.si_unit_z
assert cropped.overlays == []
assert len(overlays) == 1
assert overlays[0]["kind"] == "crop_box"
assert overlays[0]["image"].startswith("data:image/png;base64,")
assert overlays[0]["a_locked"] is False
assert overlays[0]["b_locked"] is False
resized, = node.process(field, x1=0.0, y1=0.0, x2=1.0, y2=1.0, target_width=8, target_height=0, interpolation="bilinear", corner_a=(0.25, 0.25), corner_b=(0.75, 1.0))
assert resized.data.shape == (6, 8)
assert resized.xreal == cropped.xreal
assert resized.yreal == cropped.yreal
assert resized.xoff == cropped.xoff
assert resized.yoff == cropped.yoff
assert resized.domain == field.domain
assert overlays[-1]["a_locked"] is True
assert overlays[-1]["b_locked"] is True
resized, = node.process(field, x1=0.0, y1=0.0, x2=1.0, y2=1.0, target_width=8, target_height=0, interpolation="bilinear", corner_a=(0.25, 0.25), corner_b=(0.75, 1.0))
assert resized.data.shape == (6, 8)
assert resized.xreal == cropped.xreal
assert resized.yreal == cropped.yreal
assert resized.xoff == cropped.xoff
assert resized.yoff == cropped.yoff
assert resized.domain == field.domain
assert overlays[-1]["a_locked"] is True
assert overlays[-1]["b_locked"] is True
reversed_crop, = node.process(field, x1=0.75, y1=1.0, x2=0.25, y2=0.25, target_width=0, target_height=0, interpolation="nearest")
assert np.array_equal(reversed_crop.data, cropped.data)
reversed_crop, = node.process(field, x1=0.75, y1=1.0, x2=0.25, y2=0.25, target_width=0, target_height=0, interpolation="nearest")
assert np.array_equal(reversed_crop.data, cropped.data)
try:
node.process(field, x1=0.9, y1=0.0, x2=0.9, y2=1.0, target_width=0, target_height=0, interpolation="nearest")
raise AssertionError("Expected invalid crop bounds to raise ValueError")
except ValueError:
pass
CropResizeField._broadcast_overlay_fn = None
try:
node.process(field, x1=0.9, y1=0.0, x2=0.9, y2=1.0, target_width=0, target_height=0, interpolation="nearest")
raise AssertionError("Expected invalid crop bounds to raise ValueError")
except ValueError:
pass

View File

@@ -42,11 +42,10 @@ def test_cross_section():
assert rows["dx"]["unit"] == field.si_unit_xy
assert rows["dy"]["unit"] == field.si_unit_z
from backend.execution_context import execution_callbacks, active_node
captured = []
Stats._broadcast_value_fn = lambda nid, payload: captured.append(payload)
Stats._current_node_id = "test"
stats = Stats()
mean_value, = stats.process(profile, operation="mean", column="value")
assert mean_value > 0
assert captured[-1]["unit"] == field.si_unit_z
Stats._broadcast_value_fn = None
with execution_callbacks(value=lambda nid, payload: captured.append(payload)), active_node("test"):
stats = Stats()
mean_value, = stats.process(profile, operation="mean", column="value")
assert mean_value > 0
assert captured[-1]["unit"] == field.si_unit_z

View File

@@ -12,53 +12,50 @@ def test_line_cursors():
line = np.linspace(0, 10, 100).astype(np.float64)
from backend.execution_context import execution_callbacks, active_node
overlays = []
Cursors._broadcast_overlay_fn = lambda nid, data: overlays.append(data)
Cursors._current_node_id = "test"
with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"):
table, coord_pair = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5)
assert isinstance(coord_pair, tuple) and len(coord_pair) == 2
assert len(table) == 7
quantities = {row["quantity"] for row in table}
assert "Length" in quantities
assert "A x" in quantities
assert "B x" in quantities
assert "dx" in quantities
assert "dy" in quantities
table, coord_pair = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5)
assert isinstance(coord_pair, tuple) and len(coord_pair) == 2
assert len(table) == 7
quantities = {row["quantity"] for row in table}
assert "Length" in quantities
assert "A x" in quantities
assert "B x" in quantities
assert "dx" in quantities
assert "dy" in quantities
a_pos = next(r["value"] for r in table if r["quantity"] == "A x")
b_pos = next(r["value"] for r in table if r["quantity"] == "B x")
assert b_pos > a_pos
a_pos = next(r["value"] for r in table if r["quantity"] == "A x")
b_pos = next(r["value"] for r in table if r["quantity"] == "B x")
assert b_pos > a_pos
dy = next(r["value"] for r in table if r["quantity"] == "dy")
assert dy > 0
dy = next(r["value"] for r in table if r["quantity"] == "dy")
assert dy > 0
assert len(overlays) == 1
assert overlays[0]["kind"] == "line_plot"
assert len(overlays[0]["line"]) == len(line)
assert len(overlays[0]["x_axis"]) == len(line)
assert 0.0 <= overlays[0]["x1"] <= 1.0
assert 0.0 <= overlays[0]["x2"] <= 1.0
assert len(overlays) == 1
assert overlays[0]["kind"] == "line_plot"
assert len(overlays[0]["line"]) == len(line)
assert len(overlays[0]["x_axis"]) == len(line)
assert 0.0 <= overlays[0]["x1"] <= 1.0
assert 0.0 <= overlays[0]["x2"] <= 1.0
line_data = LineData(data=line, x_axis=np.linspace(0, 1, 100))
table2, _ = node.process(line_data, x1=0.25, y1=0.5, x2=0.75, y2=0.5)
assert len(table2) == 7
line_data = LineData(data=line, x_axis=np.linspace(0, 1, 100))
table2, _ = node.process(line_data, x1=0.25, y1=0.5, x2=0.75, y2=0.5)
assert len(table2) == 7
field = DataField(
data=np.arange(100, dtype=np.float64).reshape(10, 10),
xreal=2.0, yreal=4.0, si_unit_xy="um", si_unit_z="nm",
)
overlays.clear()
table3, _ = node.process(field, x1=0.2, y1=0.25, x2=0.7, y2=0.75)
assert len(table3) == 9
field_rows = {row["quantity"]: row for row in table3}
assert field_rows["dx"]["unit"] == "um"
assert field_rows["dy"]["unit"] == "um"
assert field_rows["dz"]["unit"] == "nm"
assert np.isclose(field_rows["dx"]["value"], 1.0)
assert np.isclose(field_rows["dy"]["value"], 2.0)
assert len(overlays) == 1
assert overlays[0]["kind"] == "cursor_points"
assert overlays[0]["image"].startswith("data:image/png;base64,")
Cursors._broadcast_overlay_fn = None
field = DataField(
data=np.arange(100, dtype=np.float64).reshape(10, 10),
xreal=2.0, yreal=4.0, si_unit_xy="um", si_unit_z="nm",
)
overlays.clear()
table3, _ = node.process(field, x1=0.2, y1=0.25, x2=0.7, y2=0.75)
assert len(table3) == 9
field_rows = {row["quantity"]: row for row in table3}
assert field_rows["dx"]["unit"] == "um"
assert field_rows["dy"]["unit"] == "um"
assert field_rows["dz"]["unit"] == "nm"
assert np.isclose(field_rows["dx"]["value"], 1.0)
assert np.isclose(field_rows["dy"]["value"], 2.0)
assert len(overlays) == 1
assert overlays[0]["kind"] == "cursor_points"
assert overlays[0]["image"].startswith("data:image/png;base64,")

View File

@@ -109,7 +109,7 @@ def test_measurement_value_errors():
def test_format_with_unit():
from backend.nodes.helpers import _format_with_unit, _format_numeric
from backend.data_types import _format_with_unit, _format_numeric
assert _format_numeric(0.0) == "0"
assert not np.isfinite(float('inf')) or _format_numeric(float('inf')) is not None
@@ -182,7 +182,7 @@ def test_square_unit_and_apply():
def test_nice_length():
from backend.nodes.helpers import _nice_length
from backend.data_types import _nice_length
assert _nice_length(0.0) == 0.0
assert _nice_length(float('inf')) == 0.0

View File

@@ -9,32 +9,29 @@ def test_height_histogram():
data = np.linspace(0, 1, 1000).reshape(25, 40)
field = make_field(data=data)
from backend.execution_context import execution_callbacks, active_node
overlays = []
Histogram._broadcast_overlay_fn = lambda nid, data: overlays.append(data)
Histogram._current_node_id = "test"
table, coord_pair = node.process(field, n_bins=10, y_scale="linear", x1=0.2, y1=0.5, x2=0.8, y2=0.5)
assert isinstance(coord_pair, tuple) and len(coord_pair) == 2
measurements = {row["quantity"]: row for row in table}
assert "A position" in measurements
assert "A count" in measurements
assert "B position" in measurements
assert "B count" in measurements
assert "delta X" in measurements
assert "delta Y" in measurements
assert measurements["A count"]["unit"] == "count"
assert measurements["B count"]["unit"] == "count"
assert measurements["B position"]["value"] > measurements["A position"]["value"]
assert len(overlays) == 1
assert overlays[0]["kind"] == "line_plot"
assert overlays[0]["section_title"] == "Histogram"
assert len(overlays[0]["line"]) == 10
assert len(overlays[0]["x_axis"]) == 10
assert np.isclose(overlays[0]["x1"], 0.2)
assert np.isclose(overlays[0]["x2"], 0.8)
assert np.isclose(
measurements["delta Y"]["value"],
measurements["B count"]["value"] - measurements["A count"]["value"],
)
Histogram._broadcast_overlay_fn = None
with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"):
table, coord_pair = node.process(field, n_bins=10, y_scale="linear", x1=0.2, y1=0.5, x2=0.8, y2=0.5)
assert isinstance(coord_pair, tuple) and len(coord_pair) == 2
measurements = {row["quantity"]: row for row in table}
assert "A position" in measurements
assert "A count" in measurements
assert "B position" in measurements
assert "B count" in measurements
assert "delta X" in measurements
assert "delta Y" in measurements
assert measurements["A count"]["unit"] == "count"
assert measurements["B count"]["unit"] == "count"
assert measurements["B position"]["value"] > measurements["A position"]["value"]
assert len(overlays) == 1
assert overlays[0]["kind"] == "line_plot"
assert overlays[0]["section_title"] == "Histogram"
assert len(overlays[0]["line"]) == 10
assert len(overlays[0]["x_axis"]) == 10
assert np.isclose(overlays[0]["x1"], 0.2)
assert np.isclose(overlays[0]["x2"], 0.8)
assert np.isclose(
measurements["delta Y"]["value"],
measurements["B count"]["value"] - measurements["A count"]["value"],
)

View File

@@ -126,12 +126,13 @@ def test_load_file_unsupported():
def test_load_file_warning():
from backend.nodes.image import Image as ImageNode
from backend.execution_context import execution_callbacks, active_node
node = ImageNode()
warnings = []
ImageNode._broadcast_warning_fn = lambda nid, msg: warnings.append(msg)
ImageNode._current_node_id = "test"
with tempfile.TemporaryDirectory() as tmpdir:
with tempfile.TemporaryDirectory() as tmpdir, \
execution_callbacks(warning=lambda nid, msg: warnings.append(msg)), \
active_node("test"):
arr = np.random.default_rng(10).integers(0, 256, (16, 16), dtype=np.uint8)
img = PILImage.fromarray(arr)
path = os.path.join(tmpdir, "test.png")
@@ -142,8 +143,6 @@ def test_load_file_warning():
assert len(warnings) == 1
assert "Uncalibrated" in warnings[0]
ImageNode._broadcast_warning_fn = None
def test_load_file_ibw():
from backend.nodes.image import Image

View File

@@ -8,33 +8,30 @@ def test_draw_mask():
node = DrawMask()
field = make_field(data=np.zeros((32, 32), dtype=np.float64))
from backend.execution_context import execution_callbacks, active_node
overlays = []
DrawMask._broadcast_overlay_fn = lambda nid, data: overlays.append(data)
DrawMask._current_node_id = "test"
with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"):
mask_paths = [{"size": 5, "points": [{"x": 0.2, "y": 0.5}, {"x": 0.8, "y": 0.5}]}]
mask_paths = [{"size": 5, "points": [{"x": 0.2, "y": 0.5}, {"x": 0.8, "y": 0.5}]}]
mask, = node.process(field, pen_size=2, invert=False, mask_paths=json.dumps(mask_paths))
assert mask.dtype == np.uint8
assert mask.shape == (32, 32)
assert mask[16, 16] == 255
assert mask[14, 16] == 255
assert mask[0, 0] == 0
mask, = node.process(field, pen_size=2, invert=False, mask_paths=json.dumps(mask_paths))
assert mask.dtype == np.uint8
assert mask.shape == (32, 32)
assert mask[16, 16] == 255
assert mask[14, 16] == 255
assert mask[0, 0] == 0
assert len(overlays) == 1
assert overlays[0]["kind"] == "mask_paint"
assert overlays[0]["section_title"] == "Mask"
assert overlays[0]["image"].startswith("data:image/png;base64,")
assert overlays[0]["image_width"] == field.xres
assert overlays[0]["image_height"] == field.yres
assert overlays[0]["invert"] is False
assert len(overlays) == 1
assert overlays[0]["kind"] == "mask_paint"
assert overlays[0]["section_title"] == "Mask"
assert overlays[0]["image"].startswith("data:image/png;base64,")
assert overlays[0]["image_width"] == field.xres
assert overlays[0]["image_height"] == field.yres
assert overlays[0]["invert"] is False
inverted, = node.process(field, pen_size=2, invert=True, mask_paths=json.dumps(mask_paths))
assert inverted[16, 16] == 0
assert inverted[0, 0] == 255
assert overlays[-1]["invert"] is True
inverted, = node.process(field, pen_size=2, invert=True, mask_paths=json.dumps(mask_paths))
assert inverted[16, 16] == 0
assert inverted[0, 0] == 255
assert overlays[-1]["invert"] is True
cleared, = node.process(field, pen_size=12, invert=False, mask_paths="[]")
assert np.count_nonzero(cleared) == 0
DrawMask._broadcast_overlay_fn = None
cleared, = node.process(field, pen_size=12, invert=False, mask_paths="[]")
assert np.count_nonzero(cleared) == 0

View File

@@ -10,30 +10,27 @@ def test_threshold_mask():
data[:, 32:] = 1.0
field = make_field(data=data)
from backend.execution_context import execution_callbacks, active_node
previews = []
ThresholdMask._broadcast_fn = lambda nid, uri: previews.append(uri)
ThresholdMask._current_node_id = "test"
with execution_callbacks(preview=lambda nid, uri: previews.append(uri)), active_node("test"):
mask, table = node.process(field, method="absolute", threshold=0.5, direction="above")
assert mask.dtype == np.uint8
assert mask.shape == (64, 64)
assert np.all(mask[:, :32] == 0)
assert np.all(mask[:, 32:] == 255)
mask, table = node.process(field, method="absolute", threshold=0.5, direction="above")
assert mask.dtype == np.uint8
assert mask.shape == (64, 64)
assert np.all(mask[:, :32] == 0)
assert np.all(mask[:, 32:] == 255)
assert len(previews) == 1
assert previews[0].startswith("data:image/png;base64,")
assert len(previews) == 1
assert previews[0].startswith("data:image/png;base64,")
mask_below, _ = node.process(field, method="absolute", threshold=0.5, direction="below")
assert np.all(mask_below[:, :32] == 255)
assert np.all(mask_below[:, 32:] == 0)
mask_below, _ = node.process(field, method="absolute", threshold=0.5, direction="below")
assert np.all(mask_below[:, :32] == 255)
assert np.all(mask_below[:, 32:] == 0)
mask_rel, _ = node.process(field, method="relative", threshold=0.5, direction="above")
assert np.all(mask_rel[:, 32:] == 255)
mask_rel, _ = node.process(field, method="relative", threshold=0.5, direction="above")
assert np.all(mask_rel[:, 32:] == 255)
mask_otsu, _ = node.process(field, method="otsu", threshold=0.0, direction="above")
assert mask_otsu[:, 32:].sum() > mask_otsu[:, :32].sum()
ThresholdMask._broadcast_fn = None
mask_otsu, _ = node.process(field, method="otsu", threshold=0.0, direction="above")
assert mask_otsu[:, 32:].sum() > mask_otsu[:, :32].sum()
def test_threshold_mask_unknown_method():

View File

@@ -1,5 +1,6 @@
def test_print_table():
from backend.nodes.print_table import PrintTable
from backend.execution_context import execution_callbacks, active_node
node = PrintTable()
table_spec = PrintTable.INPUT_TYPES()["required"]["table"]
@@ -7,12 +8,8 @@ def test_print_table():
assert table_spec[1]["accepted_types"] == ["DATA_TABLE"]
captured = []
PrintTable._broadcast_table_fn = lambda node_id, rows: captured.append(rows)
PrintTable._current_node_id = "test"
table = [{"quantity": "test", "value": 42.0, "unit": "m"}]
node.print_table(table=table)
assert len(captured) == 1
assert captured[0] == table
PrintTable._broadcast_table_fn = None
with execution_callbacks(table=lambda nid, rows: captured.append(rows)), active_node("test"):
table = [{"quantity": "test", "value": 42.0, "unit": "m"}]
node.print_table(table=table)
assert len(captured) == 1
assert captured[0] == table

View File

@@ -42,22 +42,20 @@ def test_rotate_field():
def test_rotate_field_overlay_warning():
from backend.nodes.rotate import RotateField
from backend.execution_context import execution_callbacks, active_node
node = RotateField()
warnings = []
RotateField._broadcast_warning_fn = lambda nid, msg: warnings.append(msg)
RotateField._current_node_id = "test"
field = DataField(
data=np.arange(16, dtype=np.float64).reshape(4, 4),
overlays=[{"kind": "markup", "shapes": [{"kind": "line", "x1": 0.1, "y1": 0.1, "x2": 0.9, "y2": 0.9, "width": 2, "color": "#ffffff"}]}],
)
rotated, = node.process(field, angle=30.0, interpolation="bilinear", expand_canvas=True)
assert rotated.overlays == []
assert len(warnings) == 1
assert "clears annotation/markup overlays" in warnings[0]
RotateField._broadcast_warning_fn = None
with execution_callbacks(warning=lambda nid, msg: warnings.append(msg)), active_node("test"):
rotated, = node.process(field, angle=30.0, interpolation="bilinear", expand_canvas=True)
assert rotated.overlays == []
assert len(warnings) == 1
assert "clears annotation/markup overlays" in warnings[0]
def test_rotate_unknown_interpolation():

View File

@@ -11,61 +11,58 @@ def test_stats():
assert input_spec[0] == "DATA_FIELD"
assert input_spec[1]["accepted_types"] == ["IMAGE", "LINE", "DATA_TABLE"]
from backend.execution_context import execution_callbacks, active_node
captured = []
Stats._broadcast_value_fn = lambda node_id, payload: captured.append((node_id, payload))
Stats._current_node_id = "test"
with execution_callbacks(value=lambda nid, payload: captured.append((nid, payload))), active_node("test"):
line = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64)
result, = node.process(line, operation="mean", column="value")
assert np.isclose(result, 2.5)
assert captured[-1] == ("test", {"value": result})
roughness, = node.process(line, operation="Rq", column="value")
assert np.isclose(roughness, np.sqrt(np.mean((line - line.mean()) ** 2)))
line = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64)
result, = node.process(line, operation="mean", column="value")
assert np.isclose(result, 2.5)
assert captured[-1] == ("test", {"value": result})
roughness, = node.process(line, operation="Rq", column="value")
assert np.isclose(roughness, np.sqrt(np.mean((line - line.mean()) ** 2)))
table = DataTable([
{"name": "a", "value": 3.0, "unit": "m", "other": 10.0},
{"name": "b", "value": 7.0, "unit": "m", "other": 20.0},
])
result, = node.process(table, operation="max", column="value")
assert result == 7.0
assert captured[-1] == ("test", {"value": 7.0, "unit": "m"})
count, = node.process(table, operation="count", column="other")
assert count == 2.0
auto_column_range, = node.process(table, operation="range", column="")
assert auto_column_range == 4.0
table = DataTable([
{"name": "a", "value": 3.0, "unit": "m", "other": 10.0},
{"name": "b", "value": 7.0, "unit": "m", "other": 20.0},
])
result, = node.process(table, operation="max", column="value")
assert result == 7.0
assert captured[-1] == ("test", {"value": 7.0, "unit": "m"})
count, = node.process(table, operation="count", column="other")
assert count == 2.0
auto_column_range, = node.process(table, operation="range", column="")
assert auto_column_range == 4.0
field = make_field(data=np.array([[1.0, 5.0], [2.0, 4.0]], dtype=np.float64))
result, = node.process(field, operation="range", column="value")
assert result == 4.0
assert captured[-1] == ("test", {"value": 4.0, "unit": "m"})
field = make_field(data=np.array([[1.0, 5.0], [2.0, 4.0]], dtype=np.float64))
result, = node.process(field, operation="range", column="value")
assert result == 4.0
assert captured[-1] == ("test", {"value": 4.0, "unit": "m"})
image = np.array([[0, 10], [20, 30]], dtype=np.uint8)
result, = node.process(image, operation="avg", column="value")
assert np.isclose(result, 15.0)
assert captured[-1] == ("test", {"value": 15.0})
image = np.array([[0, 10], [20, 30]], dtype=np.uint8)
result, = node.process(image, operation="avg", column="value")
assert np.isclose(result, 15.0)
assert captured[-1] == ("test", {"value": 15.0})
try:
node.process(table, operation="Rq", column="value")
raise AssertionError("Expected invalid TABLE operation to raise ValueError")
except ValueError:
pass
try:
node.process(table, operation="Rq", column="value")
raise AssertionError("Expected invalid TABLE operation to raise ValueError")
except ValueError:
pass
try:
node.process([{"label": "only text"}], operation="max", column="label")
raise AssertionError("Expected non-numeric record-table input to raise ValueError")
except ValueError:
pass
try:
node.process([{"label": "only text"}], operation="max", column="label")
raise AssertionError("Expected non-numeric record-table input to raise ValueError")
except ValueError:
pass
try:
node.process(
RecordTable([{"quantity": "min", "value": 1.0, "unit": "m"}]),
operation="max", column="value",
)
raise AssertionError("Expected measurement table input to raise ValueError")
except ValueError:
pass
Stats._broadcast_value_fn = None
try:
node.process(
RecordTable([{"quantity": "min", "value": 1.0, "unit": "m"}]),
operation="max", column="value",
)
raise AssertionError("Expected measurement table input to raise ValueError")
except ValueError:
pass
def test_stats_empty_inputs():

View File

@@ -11,23 +11,20 @@ def test_value_display():
assert value_spec[0] == "FLOAT"
assert value_spec[1]["accepted_types"] == ["RECORD_TABLE"]
from backend.execution_context import execution_callbacks, active_node
captured = []
ValueIO._broadcast_value_fn = lambda node_id, payload: captured.append((node_id, payload))
ValueIO._current_node_id = "test"
with execution_callbacks(value=lambda nid, payload: captured.append((nid, payload))), active_node("test"):
result = node.display_value(value=3.25)
assert result == (3.25,)
assert captured == [("test", {"value": 3.25})]
result = node.display_value(value=3.25)
assert result == (3.25,)
assert captured == [("test", {"value": 3.25})]
measurements = RecordTable([
{"quantity": "delta X", "value": 1.7e-7, "unit": "m"},
{"quantity": "delta Y", "value": 463, "unit": "count"},
])
result = node.display_value(value=measurements, measurement="delta X")
assert result == (1.7e-7,)
assert captured[-1] == ("test", {"value": 1.7e-7, "unit": "m"})
ValueIO._broadcast_value_fn = None
measurements = RecordTable([
{"quantity": "delta X", "value": 1.7e-7, "unit": "m"},
{"quantity": "delta Y", "value": 463, "unit": "count"},
])
result = node.display_value(value=measurements, measurement="delta X")
assert result == (1.7e-7,)
assert captured[-1] == ("test", {"value": 1.7e-7, "unit": "m"})
def test_value_display_string_input():