rename grains to particle, add colormap adjust, table math

This commit is contained in:
2026-03-24 23:48:03 -07:00
parent edfdead4c1
commit 44de72d31b
12 changed files with 512 additions and 109 deletions

View File

@@ -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

View File

@@ -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

View File

@@ -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)

View File

@@ -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
# ---------------------------------------------------------------------------

View File

@@ -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: