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. 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 ## Quick start
Install a local binary from the Releases section, or run locally: 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 contextlib import contextmanager
from contextvars import ContextVar from contextvars import ContextVar
import inspect
from typing import Any, Callable from typing import Any, Callable
Callback = Callable[[str, Any], None] 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) _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 @contextmanager
def execution_callbacks( def execution_callbacks(
@@ -63,42 +52,12 @@ def current_node_id() -> str | None:
return _node_id_var.get() 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: def _emit(kind: str, payload: Any) -> None:
callbacks = _callbacks_var.get() callbacks = _callbacks_var.get()
callback = callbacks.get(kind) callback = callbacks.get(kind)
node_id = current_node_id() node_id = current_node_id()
if callback is not None and node_id: if callback is not None and node_id:
callback(node_id, payload) callback(node_id, payload)
return
_legacy_emit(kind, payload)
def emit_preview(payload: Any) -> None: def emit_preview(payload: Any) -> None:

View File

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

View File

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

View File

@@ -39,9 +39,6 @@ class CrossSection:
"Equivalent to gwy_data_field_get_profile." "Equivalent to gwy_data_field_get_profile."
) )
_broadcast_overlay_fn = None
_current_node_id: str = ""
def process( def process(
self, field: DataField, self, field: DataField,
x1: float, y1: float, x2: float, y2: float, 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." "On fields it reports x/y/z at both markers plus dx/dy/dz."
) )
_broadcast_overlay_fn = None
_current_node_id: str = ""
def process( def process(
self, line, x1: float, y1: float, x2: float, y2: float, self, line, x1: float, y1: float, x2: float, y2: float,
coord_pair=None, coord_pair=None,

View File

@@ -30,9 +30,6 @@ class FFT1D:
"Returns the FFT spectrum of the line, and identifies peaks." "Returns the FFT spectrum of the line, and identifies peaks."
) )
_broadcast_overlay_fn = None
_current_node_id: str = ""
def process( def process(
self, profile, self, profile,
) -> tuple: ) -> 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.") 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]): def _render_annotation_text(text: str, size_px: int, color: tuple[int, int, int]):
from PIL import Image, ImageDraw, ImageFont from PIL import Image, ImageDraw, ImageFont

View File

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

View File

@@ -36,9 +36,6 @@ class Image:
"Images (.png, .tiff, .jpg) and arrays (.npy, .npz) are loaded as uncalibrated fields." "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): 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() selected_path = str(path).strip() if path is not None else str(filename).strip()
if not selected_path: 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." 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): def load(self, name: str = "", colormap: str = "viridis", colormap_map=None):
from backend.nodes.image import Image from backend.nodes.image import Image
loader = Image() loader = Image()

View File

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

View File

@@ -33,9 +33,6 @@ class DrawMask:
"and invert flips the final binary output." "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: def process(self, field: DataField, pen_size: int, invert: bool, mask_paths: str) -> tuple:
strokes = _parse_mask_strokes(mask_paths) strokes = _parse_mask_strokes(mask_paths)
mask = _rasterize_mask(field.xres, field.yres, strokes, pen_size) 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." 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: def process(self, mask: np.ndarray, field: DataField | None = None) -> tuple:
out = np.where(mask > 127, np.uint8(0), np.uint8(255)) out = np.where(mask > 127, np.uint8(0), np.uint8(255))

View File

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

View File

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

View File

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

View File

@@ -28,9 +28,6 @@ class RotateField:
"Optionally expand the canvas to keep the full rotated field while preserving the field center." "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( def process(
self, self,
field: DataField, 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." "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( def save(
self, self,
filename: str, filename: str,

View File

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

View File

@@ -19,9 +19,6 @@ from backend.nodes.helpers import (
class Stats: class Stats:
"""Polymorphic scalar stats node for LINE, DATA_TABLE, DATA_FIELD, or IMAGE inputs.""" """Polymorphic scalar stats node for LINE, DATA_TABLE, DATA_FIELD, or IMAGE inputs."""
_broadcast_value_fn = None
_current_node_id: str = ""
@classmethod @classmethod
def INPUT_TYPES(cls): def INPUT_TYPES(cls):
return { 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." 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: def display_value(self, number_input: str = "0", value=None, measurement: str = "") -> tuple:
unit = "" unit = ""
if isinstance(value, RecordTable): 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." "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( def render(
self, field: DataField, self, field: DataField,
colormap: str, z_scale: float, resolution: int, make_solid: bool = False, 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()], plugins: [react()],
server: { server: {
host: true, host: true,
allowedHosts: ["bronchial-lorita-gorgeously.ngrok-free.dev"],
hmr:{
clientPort: 80,
},
port: 5173, port: 5173,
proxy: { proxy: {
'/nodes': 'http://127.0.0.1:8188', '/nodes': 'http://127.0.0.1:8188',

View File

@@ -1,5 +1,6 @@
import numpy as np import numpy as np
from backend.data_types import DataField from backend.data_types import DataField
from backend.execution_context import execution_callbacks, active_node
def test_crop_resize_field(): def test_crop_resize_field():
@@ -19,9 +20,7 @@ def test_crop_resize_field():
) )
overlays = [] overlays = []
CropResizeField._broadcast_overlay_fn = lambda nid, data: overlays.append(data) with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"):
CropResizeField._current_node_id = "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") 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 cropped.data.shape == (3, 4)
assert np.array_equal(cropped.data, data[1:4, 2:6]) assert np.array_equal(cropped.data, data[1:4, 2:6])
@@ -56,5 +55,3 @@ def test_crop_resize_field():
raise AssertionError("Expected invalid crop bounds to raise ValueError") raise AssertionError("Expected invalid crop bounds to raise ValueError")
except ValueError: except ValueError:
pass pass
CropResizeField._broadcast_overlay_fn = None

View File

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

View File

@@ -12,10 +12,9 @@ def test_line_cursors():
line = np.linspace(0, 10, 100).astype(np.float64) line = np.linspace(0, 10, 100).astype(np.float64)
from backend.execution_context import execution_callbacks, active_node
overlays = [] overlays = []
Cursors._broadcast_overlay_fn = lambda nid, data: overlays.append(data) with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"):
Cursors._current_node_id = "test"
table, coord_pair = node.process(line, x1=0.25, y1=0.5, x2=0.75, y2=0.5) 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 isinstance(coord_pair, tuple) and len(coord_pair) == 2
assert len(table) == 7 assert len(table) == 7
@@ -60,5 +59,3 @@ def test_line_cursors():
assert len(overlays) == 1 assert len(overlays) == 1
assert overlays[0]["kind"] == "cursor_points" assert overlays[0]["kind"] == "cursor_points"
assert overlays[0]["image"].startswith("data:image/png;base64,") assert overlays[0]["image"].startswith("data:image/png;base64,")
Cursors._broadcast_overlay_fn = None

View File

@@ -109,7 +109,7 @@ def test_measurement_value_errors():
def test_format_with_unit(): 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 _format_numeric(0.0) == "0"
assert not np.isfinite(float('inf')) or _format_numeric(float('inf')) is not None 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(): 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(0.0) == 0.0
assert _nice_length(float('inf')) == 0.0 assert _nice_length(float('inf')) == 0.0

View File

@@ -9,10 +9,9 @@ def test_height_histogram():
data = np.linspace(0, 1, 1000).reshape(25, 40) data = np.linspace(0, 1, 1000).reshape(25, 40)
field = make_field(data=data) field = make_field(data=data)
from backend.execution_context import execution_callbacks, active_node
overlays = [] overlays = []
Histogram._broadcast_overlay_fn = lambda nid, data: overlays.append(data) with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"):
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) 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 assert isinstance(coord_pair, tuple) and len(coord_pair) == 2
measurements = {row["quantity"]: row for row in table} measurements = {row["quantity"]: row for row in table}
@@ -36,5 +35,3 @@ def test_height_histogram():
measurements["delta Y"]["value"], measurements["delta Y"]["value"],
measurements["B count"]["value"] - measurements["A count"]["value"], measurements["B count"]["value"] - measurements["A count"]["value"],
) )
Histogram._broadcast_overlay_fn = None

View File

@@ -126,12 +126,13 @@ def test_load_file_unsupported():
def test_load_file_warning(): def test_load_file_warning():
from backend.nodes.image import Image as ImageNode from backend.nodes.image import Image as ImageNode
from backend.execution_context import execution_callbacks, active_node
node = ImageNode() node = ImageNode()
warnings = [] 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) arr = np.random.default_rng(10).integers(0, 256, (16, 16), dtype=np.uint8)
img = PILImage.fromarray(arr) img = PILImage.fromarray(arr)
path = os.path.join(tmpdir, "test.png") path = os.path.join(tmpdir, "test.png")
@@ -142,8 +143,6 @@ def test_load_file_warning():
assert len(warnings) == 1 assert len(warnings) == 1
assert "Uncalibrated" in warnings[0] assert "Uncalibrated" in warnings[0]
ImageNode._broadcast_warning_fn = None
def test_load_file_ibw(): def test_load_file_ibw():
from backend.nodes.image import Image from backend.nodes.image import Image

View File

@@ -8,10 +8,9 @@ def test_draw_mask():
node = DrawMask() node = DrawMask()
field = make_field(data=np.zeros((32, 32), dtype=np.float64)) field = make_field(data=np.zeros((32, 32), dtype=np.float64))
from backend.execution_context import execution_callbacks, active_node
overlays = [] overlays = []
DrawMask._broadcast_overlay_fn = lambda nid, data: overlays.append(data) with execution_callbacks(overlay=lambda nid, data: overlays.append(data)), active_node("test"):
DrawMask._current_node_id = "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)) mask, = node.process(field, pen_size=2, invert=False, mask_paths=json.dumps(mask_paths))
@@ -36,5 +35,3 @@ def test_draw_mask():
cleared, = node.process(field, pen_size=12, invert=False, mask_paths="[]") cleared, = node.process(field, pen_size=12, invert=False, mask_paths="[]")
assert np.count_nonzero(cleared) == 0 assert np.count_nonzero(cleared) == 0
DrawMask._broadcast_overlay_fn = None

View File

@@ -10,10 +10,9 @@ def test_threshold_mask():
data[:, 32:] = 1.0 data[:, 32:] = 1.0
field = make_field(data=data) field = make_field(data=data)
from backend.execution_context import execution_callbacks, active_node
previews = [] previews = []
ThresholdMask._broadcast_fn = lambda nid, uri: previews.append(uri) with execution_callbacks(preview=lambda nid, uri: previews.append(uri)), active_node("test"):
ThresholdMask._current_node_id = "test"
mask, table = node.process(field, method="absolute", threshold=0.5, direction="above") mask, table = node.process(field, method="absolute", threshold=0.5, direction="above")
assert mask.dtype == np.uint8 assert mask.dtype == np.uint8
assert mask.shape == (64, 64) assert mask.shape == (64, 64)
@@ -33,8 +32,6 @@ def test_threshold_mask():
mask_otsu, _ = node.process(field, method="otsu", threshold=0.0, direction="above") mask_otsu, _ = node.process(field, method="otsu", threshold=0.0, direction="above")
assert mask_otsu[:, 32:].sum() > mask_otsu[:, :32].sum() assert mask_otsu[:, 32:].sum() > mask_otsu[:, :32].sum()
ThresholdMask._broadcast_fn = None
def test_threshold_mask_unknown_method(): def test_threshold_mask_unknown_method():
from backend.nodes.mask_threshold import ThresholdMask from backend.nodes.mask_threshold import ThresholdMask

View File

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

View File

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

View File

@@ -11,10 +11,9 @@ def test_stats():
assert input_spec[0] == "DATA_FIELD" assert input_spec[0] == "DATA_FIELD"
assert input_spec[1]["accepted_types"] == ["IMAGE", "LINE", "DATA_TABLE"] assert input_spec[1]["accepted_types"] == ["IMAGE", "LINE", "DATA_TABLE"]
from backend.execution_context import execution_callbacks, active_node
captured = [] captured = []
Stats._broadcast_value_fn = lambda node_id, payload: captured.append((node_id, payload)) with execution_callbacks(value=lambda nid, payload: captured.append((nid, payload))), active_node("test"):
Stats._current_node_id = "test"
line = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64) line = np.array([1.0, 2.0, 3.0, 4.0], dtype=np.float64)
result, = node.process(line, operation="mean", column="value") result, = node.process(line, operation="mean", column="value")
assert np.isclose(result, 2.5) assert np.isclose(result, 2.5)
@@ -65,8 +64,6 @@ def test_stats():
except ValueError: except ValueError:
pass pass
Stats._broadcast_value_fn = None
def test_stats_empty_inputs(): def test_stats_empty_inputs():
from backend.nodes.stats import Stats from backend.nodes.stats import Stats

View File

@@ -11,10 +11,9 @@ def test_value_display():
assert value_spec[0] == "FLOAT" assert value_spec[0] == "FLOAT"
assert value_spec[1]["accepted_types"] == ["RECORD_TABLE"] assert value_spec[1]["accepted_types"] == ["RECORD_TABLE"]
from backend.execution_context import execution_callbacks, active_node
captured = [] captured = []
ValueIO._broadcast_value_fn = lambda node_id, payload: captured.append((node_id, payload)) with execution_callbacks(value=lambda nid, payload: captured.append((nid, payload))), active_node("test"):
ValueIO._current_node_id = "test"
result = node.display_value(value=3.25) result = node.display_value(value=3.25)
assert result == (3.25,) assert result == (3.25,)
assert captured == [("test", {"value": 3.25})] assert captured == [("test", {"value": 3.25})]
@@ -27,8 +26,6 @@ def test_value_display():
assert result == (1.7e-7,) assert result == (1.7e-7,)
assert captured[-1] == ("test", {"value": 1.7e-7, "unit": "m"}) assert captured[-1] == ("test", {"value": 1.7e-7, "unit": "m"})
ValueIO._broadcast_value_fn = None
def test_value_display_string_input(): def test_value_display_string_input():
from backend.nodes.value_io import ValueIO from backend.nodes.value_io import ValueIO