clean up node naming
This commit is contained in:
@@ -585,13 +585,16 @@ class ExecutionEngine:
|
||||
plt.close(fig)
|
||||
fallback_image = f"data:image/png;base64,{base64.b64encode(buf.getvalue()).decode()}"
|
||||
|
||||
return {
|
||||
result_dict = {
|
||||
"kind": "line_plot",
|
||||
"line": y.tolist(),
|
||||
"x_axis": x.tolist(),
|
||||
"interactive": False,
|
||||
"fallback_image": fallback_image,
|
||||
}
|
||||
if y_meta is not None and y_meta.x_unit:
|
||||
result_dict["x_unit"] = y_meta.x_unit
|
||||
return result_dict
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
|
||||
@@ -1,63 +1,60 @@
|
||||
# Import all node modules to trigger @register_node decorators.
|
||||
from backend.nodes import (
|
||||
# IO
|
||||
colormap,
|
||||
crop_resize,
|
||||
fft_2d_invert,
|
||||
filter_fft_1d,
|
||||
filter_fft_2d,
|
||||
filter_gaussian,
|
||||
filter_median,
|
||||
flip,
|
||||
font,
|
||||
image,
|
||||
ibw_note,
|
||||
image_demo,
|
||||
folder,
|
||||
coordinate,
|
||||
coordinate_pair,
|
||||
level_facet,
|
||||
level_plane,
|
||||
level_poly,
|
||||
mask_draw,
|
||||
mask_threshold,
|
||||
note,
|
||||
number,
|
||||
range_slider,
|
||||
rotate,
|
||||
save,
|
||||
save_image,
|
||||
# Filters
|
||||
gaussian_filter,
|
||||
median_filter,
|
||||
edge_detect,
|
||||
fft_filter_1d,
|
||||
fft_filter_2d,
|
||||
# Modify
|
||||
colormap_adjust,
|
||||
crop_resize_field,
|
||||
rotate_field,
|
||||
flip_field,
|
||||
# Level
|
||||
plane_level_field,
|
||||
facet_level_field,
|
||||
poly_level_field,
|
||||
fix_zero,
|
||||
line_correction,
|
||||
# Mask
|
||||
draw_mask,
|
||||
threshold_mask,
|
||||
mask_morphology,
|
||||
mask_invert,
|
||||
mask_operations,
|
||||
grain_distance_transform,
|
||||
save_layers,
|
||||
# Correction
|
||||
scar_removal,
|
||||
# Display
|
||||
color_map,
|
||||
font_node,
|
||||
annotations,
|
||||
angle_measure,
|
||||
markup,
|
||||
preview_image,
|
||||
statistics,
|
||||
view_3d,
|
||||
print_table,
|
||||
value_display,
|
||||
# Analysis
|
||||
curvature,
|
||||
fractal_dimension,
|
||||
statistics_node,
|
||||
histogram,
|
||||
acf_2d,
|
||||
acf_1d,
|
||||
cursors,
|
||||
fft_2d,
|
||||
psdf,
|
||||
inverse_fft_2d,
|
||||
cross_section,
|
||||
stats,
|
||||
watershed_segmentation,
|
||||
|
||||
@@ -98,6 +98,7 @@ class Cursors:
|
||||
"section_title": "Cursors",
|
||||
"line": y.tolist(),
|
||||
"x_axis": x.tolist(),
|
||||
"x_unit": x_unit,
|
||||
"x1": x1,
|
||||
"x2": x2,
|
||||
"y1": float(y1),
|
||||
|
||||
@@ -21,7 +21,7 @@ class FFT1D:
|
||||
|
||||
OUTPUTS = (
|
||||
("LINE", "frequency_plot"),
|
||||
('RECORD_TABLE', 'measurement'),
|
||||
('RECORD_TABLE', 'max'),
|
||||
)
|
||||
|
||||
FUNCTION = "process"
|
||||
|
||||
@@ -9,7 +9,7 @@ from backend.nodes.spectral_common import (
|
||||
)
|
||||
|
||||
|
||||
@register_node(display_name="2D FFT")
|
||||
@register_node(display_name="FFT 2D")
|
||||
class FFT2D:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
|
||||
@@ -5,7 +5,7 @@ from backend.data_types import LineData
|
||||
from backend.nodes.helpers import _cached_1d_transfer
|
||||
|
||||
|
||||
@register_node(display_name="1D FFT Filter")
|
||||
@register_node(display_name="FFT Filter 1D")
|
||||
class FFTFilter1D:
|
||||
"""Bandpass / lowpass / highpass / notch filtering of 1-D line profiles.
|
||||
|
||||
@@ -5,7 +5,7 @@ from backend.data_types import DataField
|
||||
from backend.nodes.helpers import _cached_2d_transfer
|
||||
|
||||
|
||||
@register_node(display_name="2D FFT Filter")
|
||||
@register_node(display_name="FFT Filter 2D")
|
||||
class FFTFilter2D:
|
||||
"""Frequency-domain filtering of 2-D data fields (images).
|
||||
|
||||
@@ -80,6 +80,7 @@ class Histogram:
|
||||
"section_title": "Histogram",
|
||||
"line": counts.tolist(),
|
||||
"x_axis": bin_centers.astype(np.float64).tolist(),
|
||||
"x_unit": field.si_unit_z,
|
||||
"x1": float(np.clip(x1, 0.0, 1.0)),
|
||||
"x2": float(np.clip(x2, 0.0, 1.0)),
|
||||
"y1": float(y1),
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.execution_context import emit_preview
|
||||
from backend.data_types import DataField, encode_preview
|
||||
from backend.execution_context import emit_preview, emit_overlay
|
||||
from backend.data_types import DataField, encode_preview, RecordTable
|
||||
from backend.nodes.helpers import _mask_overlay
|
||||
|
||||
|
||||
@@ -15,14 +15,15 @@ class ThresholdMask:
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
"method": (["otsu", "absolute", "relative"],),
|
||||
"threshold": ("FLOAT", {"default": 0.0, "min": -1e9, "max": 1e9, "step": 0.001}),
|
||||
"method": (["absolute", "relative", "otsu"],),
|
||||
"threshold": ("FLOAT", {"default": 0.0, "min": -1e9, "max": 1e9, "step": 0.001, "socket_only": True}),
|
||||
"direction": (["above", "below"],),
|
||||
}
|
||||
}
|
||||
|
||||
OUTPUTS = (
|
||||
('IMAGE', 'mask'),
|
||||
('RECORD_TABLE', 'threshold'),
|
||||
)
|
||||
FUNCTION = "process"
|
||||
|
||||
@@ -38,6 +39,12 @@ class ThresholdMask:
|
||||
def process(self, field: DataField, method: str, threshold: float, direction: str) -> tuple:
|
||||
data = field.data
|
||||
|
||||
raw_counts, bin_edges = np.histogram(data.ravel(), bins=256)
|
||||
bin_centers = 0.5 * (bin_edges[:-1] + bin_edges[1:])
|
||||
counts = raw_counts.astype(np.float64)
|
||||
xmin = float(bin_centers[0]) if len(bin_centers) else 0.0
|
||||
xmax = float(bin_centers[-1]) if len(bin_centers) else 1.0
|
||||
|
||||
if method == "otsu":
|
||||
from skimage.filters import threshold_otsu
|
||||
t = threshold_otsu(data)
|
||||
@@ -49,12 +56,31 @@ class ThresholdMask:
|
||||
else:
|
||||
raise ValueError(f"Unknown threshold method: {method}")
|
||||
|
||||
span = xmax - xmin if xmax != xmin else 1.0
|
||||
threshold_frac = float(np.clip((t - xmin) / span, 0.0, 1.0))
|
||||
|
||||
emit_overlay({
|
||||
"kind": "threshold_histogram",
|
||||
"section_title": "Histogram",
|
||||
"line": counts.tolist(),
|
||||
"x_axis": bin_centers.tolist(),
|
||||
"x_unit": field.si_unit_z,
|
||||
"threshold_frac": threshold_frac,
|
||||
"x_min": xmin,
|
||||
"x_max": xmax,
|
||||
"method": method,
|
||||
"locked": method == "otsu",
|
||||
})
|
||||
|
||||
if direction == "above":
|
||||
mask = (data >= t).astype(np.uint8) * 255
|
||||
else:
|
||||
mask = (data < t).astype(np.uint8) * 255
|
||||
|
||||
overlay = _mask_overlay(field, mask)
|
||||
emit_preview(encode_preview(overlay))
|
||||
emit_preview(encode_preview(_mask_overlay(field, mask)))
|
||||
|
||||
return (mask,)
|
||||
table = RecordTable([
|
||||
{"quantity": "threshold", "value": threshold, "unit": field.si_unit_xy},
|
||||
])
|
||||
|
||||
return (mask, table)
|
||||
2
demo
2
demo
Submodule demo updated: 124b84ca7c...0e24a1eb54
@@ -8,6 +8,7 @@ const CropBoxOverlay = lazy(() => import('./CropBoxOverlay'));
|
||||
const MaskPaintOverlay = lazy(() => import('./MaskPaintOverlay'));
|
||||
const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
|
||||
const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay'));
|
||||
const ThresholdHistogram = lazy(() => import('./ThresholdHistogram'));
|
||||
|
||||
import {
|
||||
getSpecTypeAndOptions, isDataSocketSpec, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
|
||||
@@ -971,6 +972,9 @@ function CustomNode({ id, data }) {
|
||||
visibleInputNames.add(name);
|
||||
} else if (opts?.hidden) {
|
||||
hiddenWidgets.add(name);
|
||||
} else if (opts?.socket_only) {
|
||||
dataInputs.push({ name, type, label: formatUiLabel(opts?.label || name) });
|
||||
visibleInputNames.add(name);
|
||||
} else {
|
||||
widgets.push({ name, type, opts: opts || {}, socketType: SOCKET_WIDGET_TYPES.has(type) ? type : null });
|
||||
}
|
||||
@@ -1079,6 +1083,7 @@ function CustomNode({ id, data }) {
|
||||
hiddenWidgets.has('x1')
|
||||
|| data.overlay.kind === 'mask_paint'
|
||||
|| data.overlay.kind === 'markup'
|
||||
|| data.overlay.kind === 'threshold_histogram'
|
||||
);
|
||||
const hidePreviewForInteractiveMask = data.overlay?.kind === 'mask_paint' || data.overlay?.kind === 'markup';
|
||||
const overlayTitle = data.overlay?.section_title
|
||||
@@ -1286,6 +1291,21 @@ function CustomNode({ id, data }) {
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Threshold histogram — rendered before preview so it sits above the mask image */}
|
||||
{data.overlay?.kind === 'threshold_histogram' && (
|
||||
<CollapsibleSection title={overlayTitle} defaultOpen={true}>
|
||||
<Suspense fallback={<div className="node-preview" style={{color:'var(--text-muted)',padding:4}}>Loading...</div>}>
|
||||
<ThresholdHistogram
|
||||
overlay={data.overlay}
|
||||
threshold={data.widgetValues.threshold}
|
||||
thresholdConnected={connectedInputs?.has('threshold')}
|
||||
nodeId={id}
|
||||
onWidgetChange={ctx.onWidgetChange}
|
||||
/>
|
||||
</Suspense>
|
||||
</CollapsibleSection>
|
||||
)}
|
||||
|
||||
{/* Collapsible preview image */}
|
||||
{data.previewImage
|
||||
&& !hidePreviewForInteractiveMask
|
||||
@@ -1313,7 +1333,7 @@ function CustomNode({ id, data }) {
|
||||
)}
|
||||
|
||||
{/* Interactive cross-section overlay */}
|
||||
{hasInteractiveOverlay && (
|
||||
{hasInteractiveOverlay && data.overlay?.kind !== 'threshold_histogram' && (
|
||||
<CollapsibleSection title={overlayTitle} defaultOpen={true}>
|
||||
<Suspense fallback={<div className="node-preview" style={{color:'var(--text-muted)',padding:4}}>Loading...</div>}>
|
||||
{data.overlay.kind === 'line_plot' ? (
|
||||
@@ -1371,6 +1391,13 @@ function CustomNode({ id, data }) {
|
||||
nodeId={id}
|
||||
onWidgetChange={ctx.onWidgetChange}
|
||||
/>
|
||||
) : data.overlay.kind === 'threshold_histogram' ? (
|
||||
<ThresholdHistogram
|
||||
overlay={data.overlay}
|
||||
threshold={data.widgetValues.threshold}
|
||||
nodeId={id}
|
||||
onWidgetChange={ctx.onWidgetChange}
|
||||
/>
|
||||
) : data.overlay.kind === 'angle_measure' ? (
|
||||
<AngleMeasureOverlay
|
||||
image={data.overlay.image}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { getAxisScale } from './valueFormatting';
|
||||
|
||||
const ASPECT_RATIO = 3.2 / 2.2;
|
||||
const MARGINS = { top: 18, right: 16, bottom: 34, left: 56 };
|
||||
@@ -168,6 +169,8 @@ export default function LinePlotOverlay({
|
||||
const yTickCount = Math.max(2, Math.min(5, Math.floor(plotHeight / 40)));
|
||||
const xTicks = makeTicks(xMin, xMax, xTickCount);
|
||||
const yTicks = makeTicks(yMin, yMax, yTickCount);
|
||||
const xRepresentative = Math.max(Math.abs(xMin), Math.abs(xMax));
|
||||
const { scale: xScale, unitLabel: xUnitLabel } = getAxisScale(xRepresentative, overlay?.x_unit);
|
||||
const plotStroke = clamp(plotWidth / 240, 1.4, 2.6);
|
||||
const gridStroke = clamp(plotWidth / 900, 0.6, 1.1);
|
||||
const cursorStroke = clamp(plotWidth / 220, 1.4, 2.2);
|
||||
@@ -225,11 +228,14 @@ export default function LinePlotOverlay({
|
||||
<g key={`x-${tick}`}>
|
||||
<line x1={x} y1={plotTop} x2={x} y2={plotTop + plotHeight} stroke="var(--border-default)" strokeWidth={gridStroke} opacity="0.45" />
|
||||
<text x={x} y={height - 10} textAnchor="middle" fontSize="11" fill="var(--text-secondary)">
|
||||
{formatTick(tick)}
|
||||
{formatTick(tick / xScale)}
|
||||
</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{xUnitLabel && (
|
||||
<text x={plotLeft + plotWidth} y={height - 1} textAnchor="end" fontSize="10" fill="var(--text-muted)">{xUnitLabel}</text>
|
||||
)}
|
||||
|
||||
{yTicks.map((tick) => {
|
||||
const y = scaleY(tick);
|
||||
|
||||
@@ -462,9 +462,59 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
}
|
||||
}, [applyCameraState, decode, meshData, scheduleViewportSync, updateDiagnostics]);
|
||||
|
||||
// Prevent scroll events from propagating to React Flow
|
||||
const onWheel = useCallback((e) => {
|
||||
e.stopPropagation();
|
||||
// Gesture-aware wheel handling: only capture scroll when it started inside the view.
|
||||
// Uses capture phase to disable OrbitControls zoom before it fires when gesture started outside.
|
||||
useEffect(() => {
|
||||
const container = containerRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const onEnter = () => {
|
||||
isInsideRef.current = true;
|
||||
pointerEnteredAtRef.current = Date.now();
|
||||
};
|
||||
const onLeave = () => {
|
||||
isInsideRef.current = false;
|
||||
};
|
||||
|
||||
// Capture phase: fires before OrbitControls on renderer.domElement
|
||||
const onWheelCapture = () => {
|
||||
const now = Date.now();
|
||||
const msSinceLastWheel = now - lastWheelAtRef.current;
|
||||
const msSinceEnter = now - pointerEnteredAtRef.current;
|
||||
lastWheelAtRef.current = now;
|
||||
|
||||
if (msSinceLastWheel > 300) {
|
||||
gestureStartedInsideRef.current = isInsideRef.current && msSinceEnter > 100;
|
||||
}
|
||||
|
||||
// Gesture started outside — disable OrbitControls zoom so it doesn't intercept
|
||||
if (!gestureStartedInsideRef.current && threeRef.current) {
|
||||
threeRef.current.controls.enableZoom = false;
|
||||
}
|
||||
};
|
||||
|
||||
// Bubble phase: fires after OrbitControls has already run (or skipped due to enableZoom=false)
|
||||
const onWheelBubble = (e) => {
|
||||
if (threeRef.current) {
|
||||
threeRef.current.controls.enableZoom = true;
|
||||
}
|
||||
if (gestureStartedInsideRef.current) {
|
||||
e.stopPropagation(); // prevent React Flow from panning when interacting with the 3D view
|
||||
}
|
||||
// else: let event propagate to React Flow so canvas panning continues
|
||||
};
|
||||
|
||||
container.addEventListener('wheel', onWheelCapture, { capture: true, passive: true });
|
||||
container.addEventListener('wheel', onWheelBubble, { passive: false });
|
||||
container.addEventListener('pointerenter', onEnter);
|
||||
container.addEventListener('pointerleave', onLeave);
|
||||
|
||||
return () => {
|
||||
container.removeEventListener('wheel', onWheelCapture, { capture: true });
|
||||
container.removeEventListener('wheel', onWheelBubble);
|
||||
container.removeEventListener('pointerenter', onEnter);
|
||||
container.removeEventListener('pointerleave', onLeave);
|
||||
};
|
||||
}, []);
|
||||
|
||||
const onContextMenu = useCallback((e) => {
|
||||
@@ -476,8 +526,7 @@ export default function SurfaceView({ meshData, nodeId, widgetValues, runtimeVal
|
||||
<div className="surface-view-shell">
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="nodrag nowheel surface-view-container"
|
||||
onWheel={onWheel}
|
||||
className="nodrag surface-view-container"
|
||||
onContextMenu={onContextMenu}
|
||||
/>
|
||||
{showDiagnostics ? (
|
||||
|
||||
220
frontend/src/ThresholdHistogram.jsx
Normal file
220
frontend/src/ThresholdHistogram.jsx
Normal file
@@ -0,0 +1,220 @@
|
||||
import React, { useEffect, useRef, useState, useCallback } from 'react';
|
||||
import { getAxisScale } from './valueFormatting';
|
||||
|
||||
const ASPECT_RATIO = 3.2 / 2.2;
|
||||
const MARGINS = { top: 18, right: 16, bottom: 34, left: 56 };
|
||||
|
||||
function clamp(v, min, max) { return Math.max(min, Math.min(max, v)); }
|
||||
function round4(v) { return parseFloat(v.toFixed(4)); }
|
||||
function trimZeros(t) { return t.replace(/(?:\.0+|(\.\d+?)0+)$/, '$1'); }
|
||||
|
||||
function formatTick(value) {
|
||||
const abs = Math.abs(value);
|
||||
if (abs === 0) return '0';
|
||||
if (abs >= 1e4 || abs < 1e-3) return value.toExponential(1).replace('e+', 'e');
|
||||
if (abs >= 100) return trimZeros(value.toFixed(0));
|
||||
if (abs >= 10) return trimZeros(value.toFixed(1));
|
||||
if (abs >= 1) return trimZeros(value.toFixed(2));
|
||||
return trimZeros(value.toFixed(3));
|
||||
}
|
||||
|
||||
function makeTicks(min, max, count = 5) {
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max) || min === max) return [min];
|
||||
return Array.from({ length: count }, (_, i) => min + (max - min) * i / (count - 1));
|
||||
}
|
||||
|
||||
function getExtent(values, fallbackMin = 0, fallbackMax = 1) {
|
||||
if (!Array.isArray(values) || !values.length) return [fallbackMin, fallbackMax];
|
||||
let min = Infinity, max = -Infinity;
|
||||
for (const v of values) { if (Number.isFinite(v)) { if (v < min) min = v; if (v > max) max = v; } }
|
||||
return (Number.isFinite(min) && Number.isFinite(max)) ? [min, max] : [fallbackMin, fallbackMax];
|
||||
}
|
||||
|
||||
export default function ThresholdHistogram({ overlay, threshold, thresholdConnected, nodeId, onWidgetChange }) {
|
||||
const containerRef = useRef(null);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [size, setSize] = useState({ width: 0 });
|
||||
|
||||
useEffect(() => {
|
||||
if (!containerRef.current) return undefined;
|
||||
const update = () => {
|
||||
if (!containerRef.current) return;
|
||||
setSize({ width: Math.max(1, Math.round(containerRef.current.clientWidth || 320)) });
|
||||
};
|
||||
update();
|
||||
if (typeof ResizeObserver === 'function') {
|
||||
const ro = new ResizeObserver((entries) => {
|
||||
const e = entries[0];
|
||||
if (e) setSize({ width: Math.max(1, Math.round(e.contentRect.width)) });
|
||||
});
|
||||
ro.observe(containerRef.current);
|
||||
return () => ro.disconnect();
|
||||
}
|
||||
window.addEventListener('resize', update);
|
||||
return () => window.removeEventListener('resize', update);
|
||||
}, []);
|
||||
|
||||
const xValues = Array.isArray(overlay?.x_axis) && overlay.x_axis.length === overlay.line?.length
|
||||
? overlay.x_axis : overlay?.line?.map((_, i) => i) || [];
|
||||
const yValues = Array.isArray(overlay?.line) ? overlay.line : [];
|
||||
const method = overlay?.method ?? 'absolute';
|
||||
const locked = (overlay?.locked ?? false) || !!thresholdConnected;
|
||||
const xMin = overlay?.x_min ?? 0;
|
||||
const xMax = overlay?.x_max ?? 1;
|
||||
|
||||
const width = size.width || 320;
|
||||
const height = Math.round(width / ASPECT_RATIO);
|
||||
const plotLeft = MARGINS.left;
|
||||
const plotTop = MARGINS.top;
|
||||
const plotWidth = Math.max(1, width - MARGINS.left - MARGINS.right);
|
||||
const plotHeight = Math.max(1, height - MARGINS.top - MARGINS.bottom);
|
||||
|
||||
const [xExtMin, xExtMax] = getExtent(xValues, 0, 1);
|
||||
const [yMinRaw, yMaxRaw] = getExtent(yValues, 0, 1);
|
||||
const yPad = yMinRaw === yMaxRaw ? 1 : (yMaxRaw - yMinRaw) * 0.08;
|
||||
const yMin = yMinRaw - yPad;
|
||||
const yMax = yMaxRaw + yPad;
|
||||
|
||||
const scaleX = useCallback((v) => {
|
||||
if (xExtMax === xExtMin) return plotLeft + plotWidth / 2;
|
||||
return plotLeft + (v - xExtMin) / (xExtMax - xExtMin) * plotWidth;
|
||||
}, [plotLeft, plotWidth, xExtMin, xExtMax]);
|
||||
|
||||
const scaleY = useCallback((v) => {
|
||||
if (yMax === yMin) return plotTop + plotHeight / 2;
|
||||
return plotTop + (1 - (v - yMin) / (yMax - yMin)) * plotHeight;
|
||||
}, [plotTop, plotHeight, yMin, yMax]);
|
||||
|
||||
// Compute marker x-fraction from current threshold widget value
|
||||
const markerFrac = (() => {
|
||||
if (locked) return clamp(overlay?.threshold_frac ?? 0.5, 0, 1);
|
||||
const t = threshold ?? 0;
|
||||
if (method === 'relative') return clamp(t, 0, 1);
|
||||
return (xMax === xMin) ? 0.5 : clamp((t - xMin) / (xMax - xMin), 0, 1);
|
||||
})();
|
||||
|
||||
const markerX = plotLeft + markerFrac * plotWidth;
|
||||
|
||||
// Snap marker circle to histogram line height
|
||||
const markerY = (() => {
|
||||
if (!xValues.length || !yValues.length) return plotTop + plotHeight / 2;
|
||||
const targetX = xExtMin + markerFrac * (xExtMax - xExtMin);
|
||||
let best = 0, bestDist = Infinity;
|
||||
for (let i = 0; i < xValues.length; i++) {
|
||||
const d = Math.abs(xValues[i] - targetX);
|
||||
if (d < bestDist) { bestDist = d; best = i; }
|
||||
}
|
||||
return scaleY(yValues[best]);
|
||||
})();
|
||||
|
||||
const handleDrag = useCallback((e) => {
|
||||
if (!onWidgetChange || !nodeId || locked || !containerRef.current) return;
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
const frac = clamp((e.clientX - rect.left - plotLeft) / plotWidth, 0, 1);
|
||||
// Relative threshold is a 0-1 fraction — round to 4 dp is fine.
|
||||
// Absolute threshold is in SI units (could be nm/m scale) — keep full float precision.
|
||||
const newThreshold = method === 'relative'
|
||||
? round4(frac)
|
||||
: xMin + frac * (xMax - xMin);
|
||||
onWidgetChange(nodeId, 'threshold', newThreshold);
|
||||
}, [onWidgetChange, nodeId, locked, plotLeft, plotWidth, method, xMin, xMax]);
|
||||
|
||||
const onPointerDown = useCallback((e) => {
|
||||
if (locked) return;
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.currentTarget.setPointerCapture(e.pointerId);
|
||||
setDragging(true);
|
||||
}, [locked]);
|
||||
|
||||
const onPointerMove = useCallback((e) => {
|
||||
if (dragging) handleDrag(e);
|
||||
}, [dragging, handleDrag]);
|
||||
|
||||
const onPointerUp = useCallback(() => setDragging(false), []);
|
||||
|
||||
const path = yValues.map((y, i) => `${i === 0 ? 'M' : 'L'} ${scaleX(xValues[i])} ${scaleY(y)}`).join(' ');
|
||||
const xTickCount = Math.max(2, Math.min(5, Math.floor(plotWidth / 70)));
|
||||
const yTickCount = Math.max(2, Math.min(5, Math.floor(plotHeight / 40)));
|
||||
const xTicks = makeTicks(xExtMin, xExtMax, xTickCount);
|
||||
const yTicks = makeTicks(yMin, yMax, yTickCount);
|
||||
const xRepresentative = Math.max(Math.abs(xExtMin), Math.abs(xExtMax));
|
||||
const { scale: xScale, unitLabel: xUnitLabel } = getAxisScale(xRepresentative, overlay?.x_unit);
|
||||
const plotStroke = clamp(plotWidth / 240, 1.4, 2.6);
|
||||
const gridStroke = clamp(plotWidth / 900, 0.6, 1.1);
|
||||
const cursorStroke = clamp(plotWidth / 220, 1.4, 2.2);
|
||||
const markerRadius = clamp(plotWidth / 42, 5.5, 9);
|
||||
const markerLabelSize = clamp(plotWidth / 34, 8, 11);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="nodrag nowheel lineplot-overlay"
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onLostPointerCapture={onPointerUp}
|
||||
>
|
||||
{locked && (
|
||||
<div style={{ fontSize: 10, color: 'var(--danger-locked)', padding: '0 4px 3px', textAlign: 'right', letterSpacing: '0.04em' }}>
|
||||
{thresholdConnected ? 'Locked — driven by socket' : 'Locked — Otsu auto-threshold'}
|
||||
</div>
|
||||
)}
|
||||
<svg width={width} height={height} viewBox={`0 0 ${width} ${height}`} className="lineplot-svg">
|
||||
<rect x="0" y="0" width={width} height={height} fill="var(--bg-deep)" />
|
||||
|
||||
{xTicks.map((tick) => {
|
||||
const x = scaleX(tick);
|
||||
return (
|
||||
<g key={`x-${tick}`}>
|
||||
<line x1={x} y1={plotTop} x2={x} y2={plotTop + plotHeight} stroke="var(--border-default)" strokeWidth={gridStroke} opacity="0.45" />
|
||||
<text x={x} y={height - 10} textAnchor="middle" fontSize="11" fill="var(--text-secondary)">{formatTick(tick / xScale)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
{xUnitLabel && (
|
||||
<text x={plotLeft + plotWidth} y={height - 1} textAnchor="end" fontSize="10" fill="var(--text-muted)">{xUnitLabel}</text>
|
||||
)}
|
||||
|
||||
{yTicks.map((tick) => {
|
||||
const y = scaleY(tick);
|
||||
return (
|
||||
<g key={`y-${tick}`}>
|
||||
<line x1={plotLeft} y1={y} x2={plotLeft + plotWidth} y2={y} stroke="var(--border-default)" strokeWidth={gridStroke} opacity="0.45" />
|
||||
<text x={plotLeft - 10} y={y + 4} textAnchor="end" fontSize="11" fill="var(--text-secondary)">{formatTick(tick)}</text>
|
||||
</g>
|
||||
);
|
||||
})}
|
||||
|
||||
<rect x={plotLeft} y={plotTop} width={plotWidth} height={plotHeight} fill="none" stroke="var(--border-default)" strokeWidth={gridStroke + 0.3} />
|
||||
<path d={path} fill="none" stroke="var(--plot-line)" strokeWidth={plotStroke} strokeLinecap="round" strokeLinejoin="round" />
|
||||
|
||||
{/* Threshold marker line */}
|
||||
<line
|
||||
x1={markerX} y1={plotTop} x2={markerX} y2={plotTop + plotHeight}
|
||||
stroke="var(--marker)" strokeWidth={cursorStroke} strokeDasharray="10 6" opacity="0.95"
|
||||
/>
|
||||
|
||||
{/* Threshold marker circle */}
|
||||
<g onPointerDown={onPointerDown} style={{ cursor: locked ? 'default' : 'ew-resize' }}>
|
||||
<circle
|
||||
cx={markerX}
|
||||
cy={markerY}
|
||||
r={markerRadius}
|
||||
className={`lineplot-marker ${locked ? 'lineplot-marker-locked' : ''}`}
|
||||
/>
|
||||
<text
|
||||
x={markerX}
|
||||
y={markerY}
|
||||
textAnchor="middle"
|
||||
dominantBaseline="middle"
|
||||
fontSize={markerLabelSize}
|
||||
className="lineplot-marker-label"
|
||||
pointerEvents="none"
|
||||
>
|
||||
T
|
||||
</text>
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -104,6 +104,21 @@ function choosePrefixExponent(value, power) {
|
||||
return candidates.reduce((best, candidate) => (candidate.absScaled > best.absScaled ? candidate : best));
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a representative axis value and a unit string, returns the scale factor
|
||||
* and prefixed unit label to use for a whole axis.
|
||||
* All tick values should be divided by `scale` before display, and `unitLabel` shown once.
|
||||
*/
|
||||
export function getAxisScale(representativeValue, unit) {
|
||||
if (!unit || typeof representativeValue !== 'number' || !Number.isFinite(representativeValue) || representativeValue === 0) {
|
||||
return { scale: 1, unitLabel: unit || '' };
|
||||
}
|
||||
const { valueText, unitText } = applySIPrefix(representativeValue, unit);
|
||||
const scaled = parseFloat(valueText);
|
||||
if (!Number.isFinite(scaled) || scaled === 0) return { scale: 1, unitLabel: unit };
|
||||
return { scale: representativeValue / scaled, unitLabel: unitText };
|
||||
}
|
||||
|
||||
export function applySIPrefix(value, unit) {
|
||||
const formattedUnit = formatDisplayUnit(unit);
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) {
|
||||
|
||||
@@ -10,7 +10,7 @@ import numpy as np
|
||||
sys.path.insert(0, ".")
|
||||
from backend.data_types import DataField
|
||||
from backend.nodes.fft_2d import FFT2D
|
||||
from backend.nodes.inverse_fft_2d import InverseFFT2D
|
||||
from backend.nodes.fft_2d_invert import InverseFFT2D
|
||||
|
||||
|
||||
def make_field(data, xreal=1e-6, yreal=1e-6):
|
||||
|
||||
@@ -28,7 +28,7 @@ def make_field(data, xreal=1e-6, yreal=1e-6):
|
||||
def test_threshold_otsu_bimodal():
|
||||
"""Otsu on a clean bimodal image should separate the two populations."""
|
||||
print("=== Test: Otsu on bimodal image ===")
|
||||
from backend.nodes.threshold_mask import ThresholdMask
|
||||
from backend.nodes.mask_threshold import ThresholdMask
|
||||
node = ThresholdMask()
|
||||
|
||||
data = np.zeros((128, 128))
|
||||
@@ -50,7 +50,7 @@ def test_threshold_otsu_bimodal():
|
||||
def test_threshold_relative_range():
|
||||
"""Relative threshold at 0.5 should be the midpoint of [min, max]."""
|
||||
print("=== Test: Relative threshold at midpoint ===")
|
||||
from backend.nodes.threshold_mask import ThresholdMask
|
||||
from backend.nodes.mask_threshold import ThresholdMask
|
||||
node = ThresholdMask()
|
||||
|
||||
data = np.full((64, 64), 2.0)
|
||||
@@ -68,7 +68,7 @@ def test_threshold_relative_range():
|
||||
def test_threshold_empty_mask():
|
||||
"""Very high absolute threshold on low data should produce an empty mask."""
|
||||
print("=== Test: Empty mask from high threshold ===")
|
||||
from backend.nodes.threshold_mask import ThresholdMask
|
||||
from backend.nodes.mask_threshold import ThresholdMask
|
||||
node = ThresholdMask()
|
||||
|
||||
data = np.ones((64, 64))
|
||||
@@ -82,7 +82,7 @@ def test_threshold_empty_mask():
|
||||
def test_threshold_full_mask():
|
||||
"""Very low absolute threshold should produce an all-white mask."""
|
||||
print("=== Test: Full mask from low threshold ===")
|
||||
from backend.nodes.threshold_mask import ThresholdMask
|
||||
from backend.nodes.mask_threshold import ThresholdMask
|
||||
node = ThresholdMask()
|
||||
|
||||
data = np.ones((64, 64)) * 5.0
|
||||
@@ -316,7 +316,7 @@ def test_adjacent_grains_connectivity():
|
||||
def test_pipeline_synthetic():
|
||||
"""Full pipeline on a synthetic image with known geometry."""
|
||||
print("=== Test: Full pipeline on synthetic grains ===")
|
||||
from backend.nodes.threshold_mask import ThresholdMask
|
||||
from backend.nodes.mask_threshold import ThresholdMask
|
||||
from backend.nodes.grain_analysis import GrainAnalysis
|
||||
|
||||
N = 200
|
||||
@@ -372,7 +372,7 @@ def test_pipeline_demo_image():
|
||||
"""Run the full pipeline on the bundled demo nanoparticles image."""
|
||||
print("=== Test: Full pipeline on demo nanoparticles.npy ===")
|
||||
from pathlib import Path
|
||||
from backend.nodes.threshold_mask import ThresholdMask
|
||||
from backend.nodes.mask_threshold import ThresholdMask
|
||||
from backend.nodes.grain_analysis import GrainAnalysis
|
||||
from backend.runtime_paths import demo_dir
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ def make_field(data=None, shape=(64, 64), xreal=1e-6, yreal=1e-6):
|
||||
|
||||
def test_gaussian_filter():
|
||||
print("=== Test: GaussianFilter ===")
|
||||
from backend.nodes.gaussian_filter import GaussianFilter
|
||||
from backend.nodes.filter_gaussian import GaussianFilter
|
||||
node = GaussianFilter()
|
||||
field = make_field()
|
||||
|
||||
@@ -46,7 +46,7 @@ def test_gaussian_filter():
|
||||
|
||||
def test_median_filter():
|
||||
print("=== Test: MedianFilter ===")
|
||||
from backend.nodes.median_filter import MedianFilter
|
||||
from backend.nodes.filter_median import MedianFilter
|
||||
node = MedianFilter()
|
||||
|
||||
# Median filter should remove salt-and-pepper noise
|
||||
@@ -68,7 +68,7 @@ def test_median_filter():
|
||||
|
||||
def test_crop_resize_field():
|
||||
print("=== Test: CropResizeField ===")
|
||||
from backend.nodes.crop_resize_field import CropResizeField
|
||||
from backend.nodes.crop_resize import CropResizeField
|
||||
node = CropResizeField()
|
||||
|
||||
data = np.arange(32, dtype=np.float64).reshape(4, 8)
|
||||
@@ -167,7 +167,7 @@ def test_crop_resize_field():
|
||||
|
||||
def test_rotate_field():
|
||||
print("=== Test: RotateField ===")
|
||||
from backend.nodes.rotate_field import RotateField
|
||||
from backend.nodes.rotate import RotateField
|
||||
node = RotateField()
|
||||
|
||||
data = np.array([[1, 2, 3], [4, 5, 6]], dtype=np.float64)
|
||||
@@ -230,7 +230,7 @@ def test_rotate_field():
|
||||
|
||||
def test_rotate_field_overlay_warning():
|
||||
print("=== Test: RotateField overlay warning ===")
|
||||
from backend.nodes.rotate_field import RotateField
|
||||
from backend.nodes.rotate import RotateField
|
||||
|
||||
node = RotateField()
|
||||
warnings = []
|
||||
@@ -258,7 +258,7 @@ def test_rotate_field_overlay_warning():
|
||||
|
||||
def test_flip_field():
|
||||
print("=== Test: FlipField ===")
|
||||
from backend.nodes.flip_field import FlipField
|
||||
from backend.nodes.flip import FlipField
|
||||
from backend.node_registry import get_node_info
|
||||
|
||||
node = FlipField()
|
||||
@@ -420,7 +420,7 @@ def test_edge_detect():
|
||||
|
||||
def test_fft_filter_1d():
|
||||
print("=== Test: FFTFilter1D ===")
|
||||
from backend.nodes.fft_filter_1d import FFTFilter1D
|
||||
from backend.nodes.filter_fft_1d import FFTFilter1D
|
||||
node = FFTFilter1D()
|
||||
|
||||
# Signal: low-frequency sine + high-frequency sine
|
||||
@@ -464,7 +464,7 @@ def test_fft_filter_1d():
|
||||
|
||||
def test_fft_filter_2d():
|
||||
print("=== Test: FFTFilter2D ===")
|
||||
from backend.nodes.fft_filter_2d import FFTFilter2D
|
||||
from backend.nodes.filter_fft_2d import FFTFilter2D
|
||||
node = FFTFilter2D()
|
||||
|
||||
N = 128
|
||||
@@ -506,7 +506,7 @@ def test_fft_filter_2d():
|
||||
|
||||
def test_plane_level():
|
||||
print("=== Test: PlaneLevelField ===")
|
||||
from backend.nodes.plane_level_field import PlaneLevelField
|
||||
from backend.nodes.level_plane import PlaneLevelField
|
||||
node = PlaneLevelField()
|
||||
|
||||
# Create a tilted plane + small signal
|
||||
@@ -554,8 +554,8 @@ def test_plane_level():
|
||||
def test_facet_level():
|
||||
print("=== Test: FacetLevelField ===")
|
||||
from backend.node_registry import get_node_info
|
||||
from backend.nodes.facet_level_field import FacetLevelField
|
||||
from backend.nodes.plane_level_field import PlaneLevelField
|
||||
from backend.nodes.level_facet import FacetLevelField
|
||||
from backend.nodes.level_plane import PlaneLevelField
|
||||
|
||||
def fit_pixel_plane(data: np.ndarray, region: np.ndarray) -> tuple[float, float, float]:
|
||||
yy, xx = np.mgrid[0:data.shape[0], 0:data.shape[1]]
|
||||
@@ -628,7 +628,7 @@ def test_facet_level():
|
||||
|
||||
def test_poly_level():
|
||||
print("=== Test: PolyLevelField ===")
|
||||
from backend.nodes.poly_level_field import PolyLevelField
|
||||
from backend.nodes.level_poly import PolyLevelField
|
||||
node = PolyLevelField()
|
||||
|
||||
N = 64
|
||||
@@ -966,7 +966,7 @@ def test_angle_measure():
|
||||
|
||||
def test_statistics():
|
||||
print("=== Test: Statistics ===")
|
||||
from backend.nodes.statistics_node import Statistics
|
||||
from backend.nodes.statistics import Statistics
|
||||
node = Statistics()
|
||||
|
||||
data = np.array([[1, 2], [3, 4]], dtype=np.float64)
|
||||
@@ -1194,7 +1194,7 @@ def test_cross_section():
|
||||
|
||||
def test_threshold_mask():
|
||||
print("=== Test: ThresholdMask ===")
|
||||
from backend.nodes.threshold_mask import ThresholdMask
|
||||
from backend.nodes.mask_threshold import ThresholdMask
|
||||
node = ThresholdMask()
|
||||
|
||||
# Clear bimodal data: left half = 0, right half = 1
|
||||
@@ -1346,7 +1346,7 @@ def test_mask_operations():
|
||||
|
||||
def test_draw_mask():
|
||||
print("=== Test: DrawMask ===")
|
||||
from backend.nodes.draw_mask import DrawMask
|
||||
from backend.nodes.mask_draw import DrawMask
|
||||
node = DrawMask()
|
||||
|
||||
field = make_field(data=np.zeros((32, 32), dtype=np.float64))
|
||||
@@ -1582,7 +1582,7 @@ def test_load_file():
|
||||
|
||||
def test_save_image():
|
||||
print("=== Test: SaveImage (Save Layers) ===")
|
||||
from backend.nodes.save_image import SaveImage
|
||||
from backend.nodes.save_layers import SaveImage
|
||||
import tifffile
|
||||
node = SaveImage()
|
||||
input_types = SaveImage.INPUT_TYPES()
|
||||
@@ -1686,7 +1686,7 @@ def test_save_image():
|
||||
|
||||
def test_color_map_node():
|
||||
print("=== Test: ColorMap ===")
|
||||
from backend.nodes.color_map import ColorMap
|
||||
from backend.nodes.colormap import ColorMap
|
||||
|
||||
node = ColorMap()
|
||||
|
||||
@@ -1712,7 +1712,7 @@ def test_color_map_node():
|
||||
|
||||
def test_font_node():
|
||||
print("=== Test: Font ===")
|
||||
from backend.nodes.font_node import Font
|
||||
from backend.nodes.font import Font
|
||||
from backend.data_types import CUSTOM_FILE_FONT, SYSTEM_DEFAULT_FONT
|
||||
|
||||
node = Font()
|
||||
@@ -1796,7 +1796,7 @@ def test_preview_image():
|
||||
def test_annotations():
|
||||
print("=== Test: Annotations ===")
|
||||
from backend.nodes.annotations import Annotations
|
||||
from backend.nodes.font_node import Font
|
||||
from backend.nodes.font import Font
|
||||
from backend.data_types import ImageData
|
||||
from backend.execution_context import active_node, execution_callbacks
|
||||
|
||||
|
||||
Reference in New Issue
Block a user