{showDiagnostics ? (
diff --git a/frontend/src/ThresholdHistogram.jsx b/frontend/src/ThresholdHistogram.jsx
new file mode 100644
index 0000000..29e254b
--- /dev/null
+++ b/frontend/src/ThresholdHistogram.jsx
@@ -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 (
+
+ {locked && (
+
+ {thresholdConnected ? 'Locked — driven by socket' : 'Locked — Otsu auto-threshold'}
+
+ )}
+
+
+ );
+}
diff --git a/frontend/src/valueFormatting.js b/frontend/src/valueFormatting.js
index e1356e8..2c84aec 100644
--- a/frontend/src/valueFormatting.js
+++ b/frontend/src/valueFormatting.js
@@ -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)) {
diff --git a/tests/test_fft.py b/tests/test_fft.py
index c69d593..406a8e6 100644
--- a/tests/test_fft.py
+++ b/tests/test_fft.py
@@ -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):
diff --git a/tests/test_grains.py b/tests/test_grains.py
index 8aca46f..39c3c75 100644
--- a/tests/test_grains.py
+++ b/tests/test_grains.py
@@ -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
diff --git a/tests/test_nodes.py b/tests/test_nodes.py
index 945258a..e48e4a0 100644
--- a/tests/test_nodes.py
+++ b/tests/test_nodes.py
@@ -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