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

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

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: