rename grains to particle, add colormap adjust, table math
This commit is contained in:
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -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:
|
||||
Reference in New Issue
Block a user