diff --git a/GWYDDION_FEATURE_GAP.md b/GWYDDION_FEATURE_GAP.md
index 762e41a..dcc1974 100644
--- a/GWYDDION_FEATURE_GAP.md
+++ b/GWYDDION_FEATURE_GAP.md
@@ -29,7 +29,7 @@ Reference for future implementation. Grouped by value to typical SPM workflows.
|---|---------|---------------|-------------|
| 15 | Correlation / Pattern Matching | crosscor.c, maskcor.c | Find repeated features or align images via cross-correlation. |
| 16 | Slope Distribution | slope_dist.c | Angular histogram of surface slopes. Characterizes surface texture directionality. |
-| 17 | Grain Filtering | grain_filter.c | Remove grains by size, height, or border contact. Refine grain masks post-detection. |
+| 17 | Grain Filtering | grain_filter.c | Remove particles by size, height, or border contact. Refine grain masks post-detection. |
| 18 | Field Arithmetic | arithmetic.c | Add/subtract/multiply/divide two DATA_FIELDs. Useful for difference maps, normalization. |
| 19 | Spot Removal | spotremove.c | Interpolate over selected point defects (dust, spikes). |
| 20 | Tip Modeling / Deconvolution | tip_blind.c, tip_model.c | Estimate tip shape from image, deconvolve to recover true surface. |
@@ -88,5 +88,5 @@ For reference, these Gwyddion equivalents are already covered:
| Mask Morphology | mask | mask_morph.c (erode, dilate, open, close) |
| Mask Invert | mask | — |
| Mask Combine | mask | — (boolean AND, OR, XOR, subtract) |
-| Particle Analysis | grains | grain_stat.c |
+| Particle Analysis | particles | grain_stat.c |
| Preview / 3D View / Print Table | display | Presentation, 3D view |
diff --git a/backend/data_types.py b/backend/data_types.py
index 1fff322..a24fb2c 100644
--- a/backend/data_types.py
+++ b/backend/data_types.py
@@ -32,6 +32,8 @@ class DataField:
si_unit_z: str = "m"
domain: str = "spatial" # "spatial" or "frequency"
colormap: str = "viridis"
+ display_offset: float = 0.0
+ display_scale: float = 1.0
def __post_init__(self) -> None:
self.data = np.asarray(self.data, dtype=np.float64)
@@ -53,6 +55,8 @@ class DataField:
si_unit_z=self.si_unit_z,
domain=self.domain,
colormap=self.colormap,
+ display_offset=self.display_offset,
+ display_scale=self.display_scale,
)
def replace(self, **kwargs) -> "DataField":
@@ -69,6 +73,8 @@ class DataField:
"si_unit_z": self.si_unit_z,
"domain": self.domain,
"colormap": self.colormap,
+ "display_offset": self.display_offset,
+ "display_scale": self.display_scale,
}
base.update(kwargs)
return DataField(**base)
@@ -88,20 +94,51 @@ class DataField:
# Utility helpers shared across nodes
# ---------------------------------------------------------------------------
+def normalize_for_colormap(
+ data: np.ndarray,
+ *,
+ offset: float = 0.0,
+ scale: float = 1.0,
+ data_min: float | None = None,
+ data_max: float | None = None,
+) -> np.ndarray:
+ """
+ Normalize an array to [0, 1] for colormap lookup, then apply a display window.
+
+ offset/scale operate in normalized data coordinates:
+ output = clip((base_norm - offset) / scale, 0, 1)
+ So offset=0, scale=1 maps the full data range 1:1 into the colormap.
+ """
+ data = np.asarray(data, dtype=np.float64)
+ dmin = float(data.min()) if data_min is None else float(data_min)
+ dmax = float(data.max()) if data_max is None else float(data_max)
+
+ if dmax > dmin:
+ base_norm = (data - dmin) / (dmax - dmin)
+ else:
+ base_norm = np.zeros_like(data)
+
+ offset = float(offset)
+ scale = float(scale)
+ if not np.isfinite(offset):
+ offset = 0.0
+ if not np.isfinite(scale) or scale <= 0.0:
+ scale = 1.0
+
+ return np.clip((base_norm - offset) / scale, 0.0, 1.0)
+
+
def datafield_to_uint8(df: DataField, colormap: str = "gray") -> np.ndarray:
"""
Normalize a DataField to a uint8 (H, W, 3) RGB array using matplotlib colormap.
Returns shape (H, W, 3) uint8.
"""
import matplotlib.cm as cm
- import matplotlib.colors as mcolors
-
- data = df.data
- dmin, dmax = data.min(), data.max()
- if dmax > dmin:
- normalized = (data - dmin) / (dmax - dmin)
- else:
- normalized = np.zeros_like(data)
+ normalized = normalize_for_colormap(
+ df.data,
+ offset=df.display_offset,
+ scale=df.display_scale,
+ )
cmap = cm.get_cmap(colormap)
rgba = cmap(normalized) # (H, W, 4) float [0,1]
diff --git a/backend/nodes/__init__.py b/backend/nodes/__init__.py
index 2515999..b38760b 100644
--- a/backend/nodes/__init__.py
+++ b/backend/nodes/__init__.py
@@ -1,2 +1,7 @@
# Import all node modules to trigger @register_node decorators.
-from . import io, filters, modify, level, analysis, grains, mask, display
+from . import io, filters, modify, level, analysis, mask, display
+
+try:
+ from . import particle
+except ImportError:
+ from . import grains
diff --git a/backend/nodes/analysis.py b/backend/nodes/analysis.py
index 7d9ce13..37f2dfc 100644
--- a/backend/nodes/analysis.py
+++ b/backend/nodes/analysis.py
@@ -10,6 +10,7 @@ Gwyddion equivalents:
from __future__ import annotations
import numpy as np
+from typing import Callable
from backend.node_registry import register_node
from backend.data_types import DataField, datafield_to_uint8, encode_preview
@@ -562,3 +563,103 @@ class LineMath:
value = fn(z)
table = [{"quantity": operation, "value": value, "unit": unit}]
return (table,)
+
+
+# ---------------------------------------------------------------------------
+# TableMath — scalar measurement from a numeric TABLE column
+# ---------------------------------------------------------------------------
+
+TABLE_OPS: dict[str, Callable[[np.ndarray], float]] = {
+ "min": lambda values: float(np.min(values)),
+ "max": lambda values: float(np.max(values)),
+ "avg": lambda values: float(np.mean(values)),
+ "mean": lambda values: float(np.mean(values)),
+ "median": lambda values: float(np.median(values)),
+ "sum": lambda values: float(np.sum(values)),
+ "range": lambda values: float(np.max(values) - np.min(values)),
+ "std": lambda values: float(np.std(values)),
+ "variance": lambda values: float(np.var(values)),
+ "count": lambda values: float(len(values)),
+}
+
+
+@register_node(display_name="Table Math")
+class TableMath:
+ """Compute a scalar reduction over one numeric column in a TABLE."""
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "table": ("TABLE",),
+ "column": ("STRING", {"default": "value"}),
+ "operation": (list(TABLE_OPS.keys()),),
+ }
+ }
+
+ RETURN_TYPES = ("FLOAT",)
+ RETURN_NAMES = ("value",)
+ FUNCTION = "process"
+ CATEGORY = "analysis"
+ DESCRIPTION = (
+ "Compute a scalar reduction over one numeric TABLE column. "
+ "Useful for max, min, avg, median, sum, range, std, variance, and count."
+ )
+
+ def process(self, table: list, column: str, operation: str) -> tuple:
+ if not isinstance(table, list) or not table:
+ raise ValueError("Table Math requires a non-empty TABLE input.")
+
+ column_name = self._resolve_column_name(table, column)
+ values = self._extract_numeric_values(table, column_name)
+ if not values:
+ raise ValueError(f"Column '{column_name}' has no numeric values.")
+
+ op = TABLE_OPS.get(operation)
+ if op is None:
+ raise ValueError(f"Unsupported table operation: {operation}")
+ return (op(np.asarray(values, dtype=np.float64)),)
+
+ def _resolve_column_name(self, table: list, column: str) -> str:
+ requested = str(column or "").strip()
+ if requested:
+ return requested
+
+ if self._extract_numeric_values(table, "value"):
+ return "value"
+
+ numeric_columns = []
+ seen = set()
+ for row in table:
+ if not isinstance(row, dict):
+ continue
+ for key in row.keys():
+ if key in seen:
+ continue
+ seen.add(key)
+ if self._extract_numeric_values(table, key):
+ numeric_columns.append(key)
+
+ if len(numeric_columns) == 1:
+ return numeric_columns[0]
+ if not numeric_columns:
+ raise ValueError("Table Math could not find any numeric columns in the input table.")
+ raise ValueError(
+ "Table Math found multiple numeric columns; set the column name explicitly."
+ )
+
+ def _extract_numeric_values(self, table: list, column: str) -> list[float]:
+ values = []
+ for row in table:
+ if not isinstance(row, dict) or column not in row:
+ continue
+ value = row[column]
+ if isinstance(value, bool):
+ continue
+ try:
+ numeric = float(value)
+ except (TypeError, ValueError):
+ continue
+ if np.isfinite(numeric):
+ values.append(numeric)
+ return values
diff --git a/backend/nodes/display.py b/backend/nodes/display.py
index 59340ff..e5e06f7 100644
--- a/backend/nodes/display.py
+++ b/backend/nodes/display.py
@@ -9,7 +9,9 @@ before execution begins.
from __future__ import annotations
import numpy as np
from backend.node_registry import register_node
-from backend.data_types import DataField, COLORMAPS, datafield_to_uint8, image_to_uint8, encode_preview
+from backend.data_types import (
+ DataField, COLORMAPS, datafield_to_uint8, image_to_uint8, encode_preview, normalize_for_colormap,
+)
@register_node(display_name="Preview")
@@ -113,10 +115,13 @@ class View3D:
# Normalize for colormap
zmin, zmax = float(z.min()), float(z.max())
- if zmax > zmin:
- z_norm = (z - zmin) / (zmax - zmin)
- else:
- z_norm = np.zeros_like(z)
+ z_norm = normalize_for_colormap(
+ z,
+ offset=field.display_offset,
+ scale=field.display_scale,
+ data_min=float(field.data.min()),
+ data_max=float(field.data.max()),
+ )
cmap_name = field.colormap if colormap == "auto" else colormap
cmap = cm.get_cmap(cmap_name)
diff --git a/backend/nodes/modify.py b/backend/nodes/modify.py
index cebc1aa..42f0976 100644
--- a/backend/nodes/modify.py
+++ b/backend/nodes/modify.py
@@ -10,6 +10,39 @@ from backend.node_registry import register_node
from backend.data_types import DataField, datafield_to_uint8, encode_preview
+# ---------------------------------------------------------------------------
+# ColormapAdjust
+# ---------------------------------------------------------------------------
+
+@register_node(display_name="Colormap Adjust")
+class ColormapAdjust:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "field": ("DATA_FIELD",),
+ "offset": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01}),
+ "scale": ("FLOAT", {"default": 1.0, "min": 0.05, "max": 4.0, "step": 0.01}),
+ "auto": ("BUTTON", {"label": "Auto", "set_widgets": {"offset": 0.0, "scale": 1.0}}),
+ }
+ }
+
+ RETURN_TYPES = ("DATA_FIELD",)
+ RETURN_NAMES = ("field",)
+ FUNCTION = "process"
+ CATEGORY = "modify"
+ DESCRIPTION = (
+ "Adjust how a DATA_FIELD maps into its colormap without changing the underlying data. "
+ "offset and scale operate in normalized display coordinates; Auto resets to the full data range."
+ )
+
+ def process(self, field: DataField, offset: float, scale: float) -> tuple:
+ scale = float(scale)
+ if not np.isfinite(scale) or scale <= 0.0:
+ raise ValueError("Scale must be a positive number.")
+ return (field.replace(display_offset=float(offset), display_scale=scale),)
+
+
# ---------------------------------------------------------------------------
# CropResizeField
# ---------------------------------------------------------------------------
diff --git a/backend/nodes/grains.py b/backend/nodes/particle.py
similarity index 93%
rename from backend/nodes/grains.py
rename to backend/nodes/particle.py
index 7ef5219..a346565 100644
--- a/backend/nodes/grains.py
+++ b/backend/nodes/particle.py
@@ -2,7 +2,7 @@
Particle detection nodes.
Gwyddion equivalents:
- ParticleAnalysis → gwy_data_field_grains_get_values (grains-values.c)
+ ParticleAnalysis → gwy_data_field_particles_get_values (particles-values.c)
"""
from __future__ import annotations
@@ -30,11 +30,11 @@ class ParticleAnalysis:
RETURN_TYPES = ("TABLE",)
RETURN_NAMES = ("particle_stats",)
FUNCTION = "process"
- CATEGORY = "grains"
+ CATEGORY = "particles"
DESCRIPTION = (
"Label connected particle regions in a binary mask and compute per-particle "
"statistics: area, equivalent diameter, mean/max height, bounding box. "
- "Equivalent to gwy_data_field_grains_get_values."
+ "Equivalent to gwy_data_field_particles_get_values."
)
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:
diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx
index 2880ba6..c959a17 100644
--- a/frontend/src/App.jsx
+++ b/frontend/src/App.jsx
@@ -178,6 +178,7 @@ function serializeGraph(nodes, edges, { excludeManualTrigger = false } = {}) {
for (const [name, spec] of Object.entries(required)) {
const [type] = Array.isArray(spec) ? spec : [spec];
if (DATA_TYPES.has(type)) continue; // socket, handled via edges
+ if (type === 'BUTTON') continue; // UI-only widget, not a backend input
if (widgetValues[name] !== undefined) {
inputs[name] = widgetValues[name];
}
@@ -604,6 +605,7 @@ function Flow() {
for (const [name, spec] of Object.entries(required)) {
const [type, opts] = Array.isArray(spec) ? spec : [spec, {}];
if (DATA_TYPES.has(type)) continue;
+ if (type === 'BUTTON') continue;
if (Array.isArray(type)) {
widgetValues[name] = type[0]; // combo default = first option
} else {
@@ -1026,7 +1028,7 @@ function Flow() {
const cat = n.data?.definition?.category;
const colors = {
io: '#37474f', filters: '#1a237e', level: '#1b5e20',
- analysis: '#4a148c', grains: '#bf360c', display: '#212121',
+ analysis: '#4a148c', particles: '#bf360c', display: '#212121',
};
return colors[cat] || '#333';
}}
diff --git a/frontend/src/CustomNode.jsx b/frontend/src/CustomNode.jsx
index e2571b1..316f5e9 100644
--- a/frontend/src/CustomNode.jsx
+++ b/frontend/src/CustomNode.jsx
@@ -26,7 +26,7 @@ const CAT_COLORS = {
modify: '#0f766e',
level: '#1b5e20',
analysis: '#4a148c',
- grains: '#bf360c',
+ particles:'#bf360c',
display: '#212121',
};
@@ -171,6 +171,69 @@ function CollapsibleSection({ title, defaultOpen, children }) {
);
}
+function getTableColumns(rows) {
+ const columns = [];
+ for (const row of rows) {
+ if (!row || typeof row !== 'object') continue;
+ for (const key of Object.keys(row)) {
+ if (!columns.includes(key)) columns.push(key);
+ }
+ }
+ return columns;
+}
+
+function formatTableCell(value) {
+ if (value == null) return '';
+ if (typeof value === 'number') {
+ if (!Number.isFinite(value)) return String(value);
+ const abs = Math.abs(value);
+ if (Number.isInteger(value) && abs < 1e6) return String(value);
+ if ((abs > 0 && abs < 1e-3) || abs >= 1e4) return value.toExponential(3);
+ return value.toFixed(4).replace(/\.?0+$/, '');
+ }
+ if (Array.isArray(value)) return value.join(', ');
+ return String(value);
+}
+
+function NodeTable({ rows }) {
+ const columns = getTableColumns(rows);
+ if (columns.length === 0) return null;
+
+ return (
+
+
+
+
+
+ {columns.map((column) => (
+ | {column} |
+ ))}
+
+
+
+ {rows.map((row, rowIndex) => (
+
+ {columns.map((column) => {
+ const value = row?.[column];
+ return (
+ |
+ {formatTableCell(value)}
+ |
+ );
+ })}
+
+ ))}
+
+
+
+
+ );
+}
+
// ── CustomNode component ──────────────────────────────────────────────
function CustomNode({ id, data }) {
@@ -411,21 +474,7 @@ function CustomNode({ id, data }) {
{/* Collapsible table data */}
{data.tableRows && data.tableRows.length > 0 && (
-
- {data.tableRows.map((row, i) => {
- let line;
- if (row.quantity !== undefined) {
- const val = typeof row.value === 'number' ? row.value.toExponential(3) : row.value;
- line = `${row.quantity}: ${val} ${row.unit || ''}`;
- } else {
- line = Object.entries(row)
- .slice(0, 3)
- .map(([k, v]) => `${k}: ${typeof v === 'number' ? v.toExponential(2) : v}`)
- .join(' ');
- }
- return
{line}
;
- })}
-
+
)}
@@ -480,6 +529,26 @@ function WidgetControl({ widget, nodeId, value, widgetValues, onChange, openFile
);
}
+ if (type === 'BUTTON') {
+ const updates = opts?.set_widgets && typeof opts.set_widgets === 'object'
+ ? Object.entries(opts.set_widgets)
+ : [];
+
+ return (
+
+ );
+ }
+
if (type === 'FLOAT') {
if (opts?.slider) {
const rawMin = opts?.min_widget ? widgetValues?.[opts.min_widget] : opts?.min;
diff --git a/frontend/src/styles.css b/frontend/src/styles.css
index a98fc90..ffe36e7 100644
--- a/frontend/src/styles.css
+++ b/frontend/src/styles.css
@@ -227,6 +227,23 @@ html, body, #root {
accent-color: #3a7abf;
}
+.widget-button {
+ flex: 1;
+ min-width: 0;
+ background: #0f3460;
+ color: #e0e0e0;
+ border: 1px solid #334155;
+ border-radius: 3px;
+ padding: 4px 8px;
+ font-size: 11px;
+ cursor: pointer;
+}
+
+.widget-button:hover {
+ background: #1a4a8a;
+ border-color: #3a7abf;
+}
+
.slider-control {
display: flex;
align-items: center;
@@ -496,18 +513,56 @@ html, body, #root {
}
/* ── Node table ────────────────────────────────────────────────────── */
-.node-table {
- padding: 4px 10px;
+.node-table-wrap {
+ padding: 4px 10px 8px;
+}
+
+.node-table-scroll {
+ max-height: 220px;
+ overflow: auto;
+ border: 1px solid #334155;
+ border-radius: 6px;
+ background: #0f172a;
+}
+
+.node-table-grid {
+ width: 100%;
+ border-collapse: collapse;
font-family: "SF Mono", "Fira Code", monospace;
font-size: 10px;
color: #cbd5e1;
}
-.table-line {
+.node-table-grid th,
+.node-table-grid td {
+ padding: 6px 8px;
+ border-bottom: 1px solid rgba(51, 65, 85, 0.75);
white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- line-height: 1.5;
+ text-align: left;
+ vertical-align: top;
+}
+
+.node-table-grid thead th {
+ position: sticky;
+ top: 0;
+ z-index: 1;
+ background: #16213e;
+ color: #94a3b8;
+ font-size: 9px;
+ letter-spacing: 0.04em;
+ text-transform: uppercase;
+}
+
+.node-table-grid tbody tr:nth-child(even) {
+ background: rgba(30, 41, 59, 0.38);
+}
+
+.node-table-grid tbody tr:last-child td {
+ border-bottom: none;
+}
+
+.node-table-num {
+ text-align: right !important;
}
/* ── Node resize handles ───────────────────────────────────────────── */
diff --git a/tests/test_grains.py b/tests/test_grains.py
index 7b94f85..30899b6 100644
--- a/tests/test_grains.py
+++ b/tests/test_grains.py
@@ -1,12 +1,12 @@
"""
-Thorough tests for the grain/particle analysis pipeline:
+Thorough tests for the particles/particle analysis pipeline:
ThresholdMask → GrainAnalysis
Covers synthetic geometry (known answers), the demo nanoparticles image,
edge cases, and physical-unit correctness.
Run from project root:
- .venv/bin/python -m tests.test_grains
+ .venv/bin/python -m tests.test_particles
"""
import sys
@@ -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.grains import ThresholdMask
+ from backend.nodes.particle 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.grains import ThresholdMask
+ from backend.nodes.particle 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.grains import ThresholdMask
+ from backend.nodes.particle 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.grains import ThresholdMask
+ from backend.nodes.particle import ThresholdMask
node = ThresholdMask()
data = np.ones((64, 64)) * 5.0
@@ -100,7 +100,7 @@ def test_threshold_full_mask():
def test_single_circle_area():
"""A single filled circle — verify pixel count and physical area."""
print("=== Test: Single circle area ===")
- from backend.nodes.grains import GrainAnalysis
+ from backend.nodes.particle import GrainAnalysis
node = GrainAnalysis()
N = 200
@@ -118,35 +118,35 @@ def test_single_circle_area():
field = make_field(data, xreal=XREAL, yreal=XREAL)
table, = node.process(field, mask=mask, min_size=1)
- assert len(table) == 1, f"Expected 1 grain, got {len(table)}"
- grain = table[0]
+ assert len(table) == 1, f"Expected 1 particles, got {len(table)}"
+ particles = table[0]
# Pixel area of a discrete circle: should be close to π r²
expected_px = np.pi * r ** 2
- assert abs(grain["area_px"] - expected_px) / expected_px < 0.02, \
- f"area_px={grain['area_px']}, expected≈{expected_px:.0f}"
+ assert abs(particles["area_px"] - expected_px) / expected_px < 0.02, \
+ f"area_px={particles['area_px']}, expected≈{expected_px:.0f}"
# Physical area
pixel_area = (XREAL / N) ** 2
- expected_m2 = grain["area_px"] * pixel_area
- assert abs(grain["area_m2"] - expected_m2) < 1e-20, \
- f"area_m2 mismatch: {grain['area_m2']} vs {expected_m2}"
+ expected_m2 = particles["area_px"] * pixel_area
+ assert abs(particles["area_m2"] - expected_m2) < 1e-20, \
+ f"area_m2 mismatch: {particles['area_m2']} vs {expected_m2}"
# Equivalent diameter should be close to 2r in physical units
expected_diam = 2 * r * (XREAL / N)
- assert abs(grain["equiv_diam_m"] - expected_diam) / expected_diam < 0.02, \
- f"equiv_diam={grain['equiv_diam_m']:.3e}, expected≈{expected_diam:.3e}"
+ assert abs(particles["equiv_diam_m"] - expected_diam) / expected_diam < 0.02, \
+ f"equiv_diam={particles['equiv_diam_m']:.3e}, expected≈{expected_diam:.3e}"
# Heights
- assert abs(grain["mean_height"] - 5.0) < 1e-10
- assert abs(grain["max_height"] - 5.0) < 1e-10
+ assert abs(particles["mean_height"] - 5.0) < 1e-10
+ assert abs(particles["max_height"] - 5.0) < 1e-10
print(" PASS\n")
-def test_multiple_grains_separation():
- """Three well-separated grains of different sizes — check each is reported."""
- print("=== Test: Multiple grain separation ===")
- from backend.nodes.grains import GrainAnalysis
+def test_multiple_particles_separation():
+ """Three well-separated particles of different sizes — check each is reported."""
+ print("=== Test: Multiple particles separation ===")
+ from backend.nodes.particle import GrainAnalysis
node = GrainAnalysis()
N = 128
@@ -168,7 +168,7 @@ def test_multiple_grains_separation():
field = make_field(data)
table, = node.process(field, mask=mask, min_size=1)
- assert len(table) == 3, f"Expected 3 grains, got {len(table)}"
+ assert len(table) == 3, f"Expected 3 particles, got {len(table)}"
table.sort(key=lambda r: r["area_px"], reverse=True)
assert table[0]["area_px"] == 400 # 20×20
@@ -182,24 +182,24 @@ def test_multiple_grains_separation():
def test_min_size_filtering():
- """min_size should exclude grains smaller than the threshold."""
+ """min_size should exclude particles smaller than the threshold."""
print("=== Test: min_size filtering ===")
- from backend.nodes.grains import GrainAnalysis
+ from backend.nodes.particle import GrainAnalysis
node = GrainAnalysis()
N = 64
data = np.zeros((N, N))
mask = np.zeros((N, N), dtype=np.uint8)
- # Large grain: 15×15 = 225 px
+ # Large particles: 15×15 = 225 px
data[5:20, 5:20] = 1.0
mask[5:20, 5:20] = 255
- # Medium grain: 8×8 = 64 px
+ # Medium particles: 8×8 = 64 px
data[30:38, 30:38] = 1.0
mask[30:38, 30:38] = 255
- # Tiny grain: 3×3 = 9 px
+ # Tiny particles: 3×3 = 9 px
data[50:53, 50:53] = 1.0
mask[50:53, 50:53] = 255
@@ -224,16 +224,16 @@ def test_min_size_filtering():
print(" PASS\n")
-def test_grain_bounding_box():
- """Bounding box should match the grain extents."""
+def test_particles_bounding_box():
+ """Bounding box should match the particles extents."""
print("=== Test: Grain bounding box ===")
- from backend.nodes.grains import GrainAnalysis
+ from backend.nodes.particle import GrainAnalysis
node = GrainAnalysis()
N = 64
data = np.zeros((N, N))
mask = np.zeros((N, N), dtype=np.uint8)
- # Place a grain at rows 20:35, cols 10:45
+ # Place a particles at rows 20:35, cols 10:45
data[20:35, 10:45] = 2.0
mask[20:35, 10:45] = 255
@@ -247,10 +247,10 @@ def test_grain_bounding_box():
print(" PASS\n")
-def test_empty_mask_produces_no_grains():
- """An all-zero mask should yield zero grains."""
- print("=== Test: Empty mask → no grains ===")
- from backend.nodes.grains import GrainAnalysis
+def test_empty_mask_produces_no_particles():
+ """An all-zero mask should yield zero particles."""
+ print("=== Test: Empty mask → no particles ===")
+ from backend.nodes.particle import GrainAnalysis
node = GrainAnalysis()
field = make_field(np.ones((64, 64)))
@@ -261,10 +261,10 @@ def test_empty_mask_produces_no_grains():
print(" PASS\n")
-def test_grain_at_image_edge():
- """A grain touching the image border should still be detected."""
+def test_particles_at_image_edge():
+ """A particles touching the image border should still be detected."""
print("=== Test: Grain at image edge ===")
- from backend.nodes.grains import GrainAnalysis
+ from backend.nodes.particle import GrainAnalysis
node = GrainAnalysis()
N = 64
@@ -282,11 +282,11 @@ def test_grain_at_image_edge():
print(" PASS\n")
-def test_adjacent_grains_connectivity():
- """Two diagonally-touching blocks should be separate grains
+def test_adjacent_particles_connectivity():
+ """Two diagonally-touching blocks should be separate particles
(scipy.ndimage.label uses 4-connectivity by default)."""
- print("=== Test: Diagonal adjacency → separate grains ===")
- from backend.nodes.grains import GrainAnalysis
+ print("=== Test: Diagonal adjacency → separate particles ===")
+ from backend.nodes.particle import GrainAnalysis
node = GrainAnalysis()
N = 32
@@ -305,7 +305,7 @@ def test_adjacent_grains_connectivity():
table, = node.process(field, mask=mask, min_size=1)
# Default label() uses structure that connects diagonals? Let's verify.
# scipy.ndimage.label default is cross-shaped (no diagonals) for 2D
- assert len(table) == 2, f"Expected 2 separate grains, got {len(table)}"
+ assert len(table) == 2, f"Expected 2 separate particles, got {len(table)}"
print(" PASS\n")
@@ -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 particles ===")
- from backend.nodes.grains import ThresholdMask, GrainAnalysis
+ from backend.nodes.particle import ThresholdMask, GrainAnalysis
N = 200
XREAL = 10e-6 # 10 µm
@@ -349,20 +349,20 @@ def test_pipeline_synthetic():
# Particles are well above noise, so mask should capture all 5
assert mask.max() == 255, "No particles detected"
- # Step 2: grain analysis
+ # Step 2: particles analysis
ga = GrainAnalysis()
table, = ga.process(field, mask=mask, min_size=5)
- assert len(table) == 5, f"Expected 5 grains, got {len(table)}"
+ assert len(table) == 5, f"Expected 5 particles, got {len(table)}"
# Verify that detected areas are in the right ballpark
table.sort(key=lambda r: r["area_px"], reverse=True)
expected_areas = sorted([np.pi * r ** 2 for _, _, r, _ in specs], reverse=True)
- for grain, expected_px in zip(table, expected_areas):
- ratio = grain["area_px"] / expected_px
+ for particles, expected_px in zip(table, expected_areas):
+ ratio = particles["area_px"] / expected_px
assert 0.85 < ratio < 1.15, \
- f"grain area_px={grain['area_px']}, expected≈{expected_px:.0f}, ratio={ratio:.2f}"
+ f"particles area_px={particles['area_px']}, expected≈{expected_px:.0f}, ratio={ratio:.2f}"
print(" PASS\n")
@@ -371,7 +371,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.grains import ThresholdMask, GrainAnalysis
+ from backend.nodes.particle import ThresholdMask, GrainAnalysis
from backend.runtime_paths import demo_dir
npy_path = demo_dir() / "nanoparticles.npy"
@@ -398,16 +398,16 @@ def test_pipeline_demo_image():
ga = GrainAnalysis()
table, = ga.process(field, mask=mask, min_size=20)
- assert len(table) > 0, "No grains detected"
- print(f" Found {len(table)} grains (min_size=20)")
+ assert len(table) > 0, "No particles detected"
+ print(f" Found {len(table)} particles (min_size=20)")
- # Sanity checks on grain properties
- for grain in table:
- assert grain["area_px"] >= 20
- assert grain["area_m2"] > 0
- assert grain["equiv_diam_m"] > 0
- assert grain["max_height"] >= grain["mean_height"]
- assert grain["mean_height"] > 0
+ # Sanity checks on particles properties
+ for particles in table:
+ assert particles["area_px"] >= 20
+ assert particles["area_m2"] > 0
+ assert particles["equiv_diam_m"] > 0
+ assert particles["max_height"] >= particles["mean_height"]
+ assert particles["mean_height"] > 0
# Physical size sanity: equivalent diameters should be in the nm–µm range
diams_nm = [g["equiv_diam_m"] * 1e9 for g in table]
@@ -431,15 +431,15 @@ if __name__ == "__main__":
# GrainAnalysis
test_single_circle_area()
- test_multiple_grains_separation()
+ test_multiple_particles_separation()
test_min_size_filtering()
- test_grain_bounding_box()
- test_empty_mask_produces_no_grains()
- test_grain_at_image_edge()
- test_adjacent_grains_connectivity()
+ test_particles_bounding_box()
+ test_empty_mask_produces_no_particles()
+ test_particles_at_image_edge()
+ test_adjacent_particles_connectivity()
# End-to-end pipeline
test_pipeline_synthetic()
test_pipeline_demo_image()
- print("All grain tests passed!")
+ print("All particles tests passed!")
diff --git a/tests/test_nodes.py b/tests/test_nodes.py
index a0d1ce1..24bcab0 100644
--- a/tests/test_nodes.py
+++ b/tests/test_nodes.py
@@ -10,7 +10,7 @@ import tempfile
import numpy as np
sys.path.insert(0, ".")
-from backend.data_types import DataField
+from backend.data_types import DataField, datafield_to_uint8
def make_field(data=None, shape=(64, 64), xreal=1e-6, yreal=1e-6):
@@ -223,6 +223,47 @@ def test_rotate_field():
print(" PASS\n")
+def test_colormap_adjust():
+ print("=== Test: ColormapAdjust ===")
+ from backend.nodes.modify import ColormapAdjust
+
+ node = ColormapAdjust()
+ field = DataField(
+ data=np.array([[0.0, 0.25, 0.5, 0.75, 1.0]], dtype=np.float64),
+ xreal=5.0,
+ yreal=1.0,
+ colormap="gray",
+ )
+
+ adjusted, = node.process(field, offset=0.25, scale=0.5)
+ assert np.array_equal(adjusted.data, field.data)
+ assert adjusted.display_offset == 0.25
+ assert adjusted.display_scale == 0.5
+ assert adjusted.colormap == field.colormap
+
+ rgb = datafield_to_uint8(adjusted, "gray")
+ intensities = rgb[0, :, 0]
+ assert intensities[0] == 0
+ assert intensities[1] == 0
+ assert 110 <= intensities[2] <= 145
+ assert intensities[3] == 255
+ assert intensities[4] == 255
+
+ auto_like, = node.process(field, offset=0.0, scale=1.0)
+ auto_rgb = datafield_to_uint8(auto_like, "gray")
+ auto_intensities = auto_rgb[0, :, 0]
+ assert auto_intensities[0] == 0
+ assert auto_intensities[-1] == 255
+
+ try:
+ node.process(field, offset=0.0, scale=0.0)
+ raise AssertionError("Expected non-positive scale to raise ValueError")
+ except ValueError:
+ pass
+
+ print(" PASS\n")
+
+
def test_edge_detect():
print("=== Test: EdgeDetect ===")
from backend.nodes.filters import EdgeDetect
@@ -1263,6 +1304,59 @@ def test_line_math():
print(" PASS\n")
+# =========================================================================
+# Analysis — TableMath
+# =========================================================================
+
+def test_table_math():
+ print("=== Test: TableMath ===")
+ from backend.nodes.analysis import TableMath
+
+ node = TableMath()
+ table = [
+ {"label": "a", "value": 1.0, "other": 10},
+ {"label": "b", "value": 5.0, "other": 20},
+ {"label": "c", "value": "3.0", "other": 30},
+ {"label": "d", "value": "bad", "other": 40},
+ ]
+
+ result, = node.process(table, column="value", operation="max")
+ assert result == 5.0
+
+ result, = node.process(table, column="value", operation="min")
+ assert result == 1.0
+
+ result, = node.process(table, column="value", operation="avg")
+ assert np.isclose(result, 3.0)
+
+ result, = node.process(table, column="value", operation="median")
+ assert np.isclose(result, 3.0)
+
+ result, = node.process(table, column="other", operation="sum")
+ assert result == 100.0
+
+ result, = node.process(table, column="other", operation="count")
+ assert result == 4.0
+
+ # Blank column name should fall back to the common "value" column.
+ result, = node.process(table, column="", operation="range")
+ assert result == 4.0
+
+ try:
+ node.process(table, column="missing", operation="max")
+ raise AssertionError("Expected missing numeric column to raise ValueError")
+ except ValueError:
+ pass
+
+ try:
+ node.process([{"label": "only text"}], column="label", operation="max")
+ raise AssertionError("Expected non-numeric column to raise ValueError")
+ except ValueError:
+ pass
+
+ print(" PASS\n")
+
+
# =========================================================================
# Display — View3D
# =========================================================================
@@ -1322,6 +1416,7 @@ if __name__ == "__main__":
test_median_filter()
test_crop_resize_field()
test_rotate_field()
+ test_colormap_adjust()
test_edge_detect()
test_fft_filter_1d()
test_fft_filter_2d()
@@ -1338,6 +1433,7 @@ if __name__ == "__main__":
test_line_cursors()
test_fft2d()
test_line_math()
+ test_table_math()
# Mask
test_threshold_mask()