clean tests
This commit is contained in:
@@ -32,7 +32,7 @@ PREVIEW_MARKUP_REFERENCE_DIM = 512
|
||||
|
||||
|
||||
class RecordTable(list):
|
||||
"""Tabular rows with a shared schema, e.g. particle statistics."""
|
||||
"""Tabular rows with a shared schema, e.g. grain statistics."""
|
||||
|
||||
|
||||
class MeasureTable(list):
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from contextlib import contextmanager
|
||||
from contextvars import ContextVar
|
||||
import inspect
|
||||
from typing import Any, Callable
|
||||
|
||||
Callback = Callable[[str, Any], None]
|
||||
@@ -12,6 +13,15 @@ _callbacks_var: ContextVar[dict[str, Callback | None]] = ContextVar(
|
||||
)
|
||||
_node_id_var: ContextVar[str | None] = ContextVar("argonode_execution_node_id", default=None)
|
||||
|
||||
_LEGACY_CALLBACK_ATTRS = {
|
||||
"preview": "_broadcast_fn",
|
||||
"table": "_broadcast_table_fn",
|
||||
"mesh": "_broadcast_mesh_fn",
|
||||
"overlay": "_broadcast_overlay_fn",
|
||||
"value": "_broadcast_value_fn",
|
||||
"warning": "_broadcast_warning_fn",
|
||||
}
|
||||
|
||||
|
||||
@contextmanager
|
||||
def execution_callbacks(
|
||||
@@ -50,12 +60,42 @@ def current_node_id() -> str | None:
|
||||
return _node_id_var.get()
|
||||
|
||||
|
||||
def _legacy_emit(kind: str, payload: Any) -> bool:
|
||||
callback_attr = _LEGACY_CALLBACK_ATTRS.get(kind)
|
||||
if not callback_attr:
|
||||
return False
|
||||
|
||||
frame = inspect.currentframe()
|
||||
try:
|
||||
frame = frame.f_back
|
||||
while frame is not None:
|
||||
for owner_name in ("self", "cls"):
|
||||
owner = frame.f_locals.get(owner_name)
|
||||
if owner is None:
|
||||
continue
|
||||
|
||||
candidate = owner if isinstance(owner, type) else owner.__class__
|
||||
callback = getattr(candidate, callback_attr, None)
|
||||
node_id = getattr(candidate, "_current_node_id", None)
|
||||
if callback is not None and node_id:
|
||||
callback(str(node_id), payload)
|
||||
return True
|
||||
frame = frame.f_back
|
||||
finally:
|
||||
del frame
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _emit(kind: str, payload: Any) -> None:
|
||||
callbacks = _callbacks_var.get()
|
||||
callback = callbacks.get(kind)
|
||||
node_id = current_node_id()
|
||||
if callback is not None and node_id:
|
||||
callback(node_id, payload)
|
||||
return
|
||||
|
||||
_legacy_emit(kind, payload)
|
||||
|
||||
|
||||
def emit_preview(payload: Any) -> None:
|
||||
|
||||
@@ -25,11 +25,11 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
],
|
||||
"Output": [
|
||||
"PreviewImage",
|
||||
"ValueDisplay",
|
||||
"View3D",
|
||||
"Save",
|
||||
"SaveImage",
|
||||
"View3D",
|
||||
"PrintTable",
|
||||
"ValueDisplay",
|
||||
],
|
||||
"Overlay": [
|
||||
"Markup",
|
||||
@@ -37,10 +37,10 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"AngleMeasure",
|
||||
],
|
||||
"Modify": [
|
||||
"ColormapAdjust",
|
||||
"CropResizeField",
|
||||
"RotateField",
|
||||
"FlipField",
|
||||
"ColormapAdjust",
|
||||
],
|
||||
"Filter": [
|
||||
"GaussianFilter",
|
||||
@@ -51,24 +51,24 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"ScarRemoval",
|
||||
],
|
||||
"Frequency": [
|
||||
"FFT2D",
|
||||
"PSDF",
|
||||
"FFT2D",
|
||||
"InverseFFT2D",
|
||||
],
|
||||
"Flatten": [
|
||||
"PlaneLevelField",
|
||||
"FacetLevelField",
|
||||
"PolyLevelField",
|
||||
"FixZero",
|
||||
"LineCorrection",
|
||||
"PlaneLevelField",
|
||||
"PolyLevelField",
|
||||
"FacetLevelField",
|
||||
],
|
||||
"Measure": [
|
||||
"CrossSection",
|
||||
"Curvature",
|
||||
"Histogram",
|
||||
"Cursors",
|
||||
"Curvature",
|
||||
"FractalDimension",
|
||||
"ACF",
|
||||
"Cursors",
|
||||
"Statistics",
|
||||
"Stats",
|
||||
],
|
||||
@@ -77,12 +77,12 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"ThresholdMask",
|
||||
"MaskMorphology",
|
||||
"MaskInvert",
|
||||
"MaskCombine",
|
||||
"GrainDistanceTransform",
|
||||
"MaskOperations",
|
||||
],
|
||||
"Particles": [
|
||||
"Grains": [
|
||||
"GrainAnalysis",
|
||||
"GrainDistanceTransform",
|
||||
"WatershedSegmentation",
|
||||
"ParticleAnalysis",
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@@ -32,7 +32,7 @@ from backend.nodes import (
|
||||
threshold_mask,
|
||||
mask_morphology,
|
||||
mask_invert,
|
||||
mask_combine,
|
||||
mask_operations,
|
||||
grain_distance_transform,
|
||||
# Correction
|
||||
scar_removal,
|
||||
@@ -59,9 +59,5 @@ from backend.nodes import (
|
||||
cross_section,
|
||||
stats,
|
||||
watershed_segmentation,
|
||||
grain_analysis,
|
||||
)
|
||||
|
||||
try:
|
||||
from backend.nodes import particle_analysis
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
@@ -5,8 +5,8 @@ from backend.data_types import DataField, RecordTable
|
||||
from backend.nodes.helpers import _square_unit
|
||||
|
||||
|
||||
@register_node(display_name="Particle Analysis")
|
||||
class ParticleAnalysis:
|
||||
@register_node(display_name="Grain Analysis")
|
||||
class GrainAnalysis:
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
@@ -18,44 +18,44 @@ class ParticleAnalysis:
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("RECORD_TABLE",)
|
||||
RETURN_NAMES = ("particle_stats",)
|
||||
RETURN_NAMES = ("grain_stats",)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Label connected particle regions in a binary mask and compute per-particle "
|
||||
"Label connected grain regions in a binary mask and compute per-grain "
|
||||
"statistics: area, equivalent diameter, mean/max height, bounding box. "
|
||||
"Equivalent to gwy_data_field_particles_get_values."
|
||||
"Equivalent to Gwyddion's grain statistics tools."
|
||||
)
|
||||
|
||||
def process(self, field: DataField, mask: np.ndarray, min_size: int) -> tuple:
|
||||
from scipy.ndimage import label
|
||||
|
||||
binary = (mask > 127).astype(np.int32)
|
||||
labeled, n_particles = label(binary)
|
||||
labeled, n_grains = label(binary)
|
||||
|
||||
pixel_area = field.dx * field.dy
|
||||
xy_unit = str(field.si_unit_xy or "").strip()
|
||||
z_unit = str(field.si_unit_z or "").strip()
|
||||
|
||||
rows = RecordTable()
|
||||
for pid in range(1, n_particles + 1):
|
||||
particle_pixels = labeled == pid
|
||||
area_px = int(particle_pixels.sum())
|
||||
for gid in range(1, n_grains + 1):
|
||||
grain_pixels = labeled == gid
|
||||
area_px = int(grain_pixels.sum())
|
||||
if area_px < min_size:
|
||||
continue
|
||||
|
||||
area_m2 = area_px * pixel_area
|
||||
equiv_diam = float(2.0 * np.sqrt(area_m2 / np.pi))
|
||||
|
||||
heights = field.data[particle_pixels]
|
||||
heights = field.data[grain_pixels]
|
||||
mean_h = float(heights.mean())
|
||||
max_h = float(heights.max())
|
||||
|
||||
ys, xs = np.where(particle_pixels)
|
||||
ys, xs = np.where(grain_pixels)
|
||||
bbox = f"({int(xs.min())},{int(ys.min())})-({int(xs.max())},{int(ys.max())})"
|
||||
|
||||
rows.append({
|
||||
"particle_id": pid,
|
||||
"grain_id": gid,
|
||||
"area_px": area_px,
|
||||
"area_px_unit": _square_unit("px"),
|
||||
"area_m2": area_m2,
|
||||
@@ -1,61 +0,0 @@
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
from backend.execution_context import emit_preview
|
||||
from backend.data_types import DataField, encode_preview
|
||||
from backend.nodes.helpers import _mask_overlay
|
||||
|
||||
|
||||
@register_node(display_name="Mask Combine")
|
||||
class MaskCombine:
|
||||
_CUSTOM_PREVIEW = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"mask_a": ("IMAGE",),
|
||||
"mask_b": ("IMAGE",),
|
||||
"operation": (["and", "or", "xor", "subtract"],),
|
||||
},
|
||||
"optional": {
|
||||
"field": ("DATA_FIELD",),
|
||||
}
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("mask",)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Combine two binary masks with a boolean operation. "
|
||||
"AND keeps overlap, OR merges, XOR keeps non-overlapping regions, "
|
||||
"subtract removes mask_b from mask_a."
|
||||
)
|
||||
|
||||
_broadcast_fn = None
|
||||
_current_node_id: str = ""
|
||||
|
||||
def process(self, mask_a: np.ndarray, mask_b: np.ndarray, operation: str,
|
||||
field: DataField | None = None) -> tuple:
|
||||
a = mask_a > 127
|
||||
b = mask_b > 127
|
||||
|
||||
if operation == "and":
|
||||
result = a & b
|
||||
elif operation == "or":
|
||||
result = a | b
|
||||
elif operation == "xor":
|
||||
result = a ^ b
|
||||
elif operation == "subtract":
|
||||
result = a & ~b
|
||||
else:
|
||||
raise ValueError(f"Unknown mask operation: {operation}")
|
||||
|
||||
out = result.astype(np.uint8) * 255
|
||||
|
||||
if field is not None:
|
||||
overlay = _mask_overlay(field, out)
|
||||
emit_preview(encode_preview(overlay))
|
||||
|
||||
return (out,)
|
||||
65
backend/nodes/mask_operations.py
Normal file
65
backend/nodes/mask_operations.py
Normal file
@@ -0,0 +1,65 @@
|
||||
from __future__ import annotations
|
||||
import numpy as np
|
||||
from backend.node_registry import register_node
|
||||
|
||||
|
||||
_MASK_BOOLEAN_OPERATIONS = {
|
||||
"and": lambda a, b: a & b,
|
||||
"or": lambda a, b: a | b,
|
||||
"xor": lambda a, b: a ^ b,
|
||||
"xnor": lambda a, b: ~(a ^ b),
|
||||
"nand": lambda a, b: ~(a & b),
|
||||
"nor": lambda a, b: ~(a | b),
|
||||
"a_minus_b": lambda a, b: a & ~b,
|
||||
"b_minus_a": lambda a, b: b & ~a,
|
||||
"a": lambda a, b: a,
|
||||
"b": lambda a, b: b,
|
||||
"not_a": lambda a, b: ~a,
|
||||
"not_b": lambda a, b: ~b,
|
||||
"a_implies_b": lambda a, b: ~a | b,
|
||||
"b_implies_a": lambda a, b: ~b | a,
|
||||
"false": lambda a, b: np.zeros_like(a, dtype=bool),
|
||||
"true": lambda a, b: np.ones_like(a, dtype=bool),
|
||||
}
|
||||
|
||||
|
||||
@register_node(display_name="Mask Operations")
|
||||
class MaskOperations:
|
||||
_CUSTOM_PREVIEW = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"mask_a": ("IMAGE",),
|
||||
"mask_b": ("IMAGE",),
|
||||
"operation": (list(_MASK_BOOLEAN_OPERATIONS.keys()),),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("IMAGE",)
|
||||
RETURN_NAMES = ("mask",)
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Apply boolean logic to two binary masks. Includes AND, OR, XOR, NAND, NOR, "
|
||||
"XNOR, directional subtraction, implication, pass-through, and constant true/false outputs."
|
||||
)
|
||||
|
||||
def process(
|
||||
self,
|
||||
mask_a: np.ndarray,
|
||||
mask_b: np.ndarray,
|
||||
operation: str,
|
||||
) -> tuple:
|
||||
a = mask_a > 127
|
||||
b = mask_b > 127
|
||||
|
||||
op = _MASK_BOOLEAN_OPERATIONS.get(operation)
|
||||
if op is None:
|
||||
raise ValueError(f"Unknown mask operation: {operation}")
|
||||
result = op(a, b)
|
||||
|
||||
out = result.astype(np.uint8) * 255
|
||||
|
||||
return (out,)
|
||||
@@ -4,6 +4,49 @@ from backend.node_registry import register_node
|
||||
from backend.data_types import DataField
|
||||
|
||||
|
||||
def _normalize_mask(mask: np.ndarray | None, shape: tuple[int, int]) -> np.ndarray | None:
|
||||
if mask is None:
|
||||
return None
|
||||
|
||||
mask_array = np.asarray(mask)
|
||||
if mask_array.shape[:2] != shape:
|
||||
raise ValueError(f"Mask shape {mask_array.shape} does not match field shape {shape}.")
|
||||
return mask_array > 127
|
||||
|
||||
|
||||
def _fit_plane(
|
||||
data: np.ndarray,
|
||||
mask: np.ndarray | None,
|
||||
masking: str,
|
||||
) -> tuple[float, float, float, np.ndarray, np.ndarray]:
|
||||
yres, xres = data.shape
|
||||
x = np.linspace(0.0, 1.0, xres)
|
||||
y = np.linspace(0.0, 1.0, yres)
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
|
||||
if mask is None or masking == "ignore":
|
||||
valid = np.ones(data.shape, dtype=bool)
|
||||
elif masking == "include":
|
||||
valid = mask
|
||||
elif masking == "exclude":
|
||||
valid = ~mask
|
||||
else:
|
||||
raise ValueError(f"Unknown masking mode: {masking}")
|
||||
|
||||
if np.count_nonzero(valid) < 3:
|
||||
raise ValueError("Plane Level requires at least three usable pixels for fitting.")
|
||||
|
||||
A = np.column_stack([
|
||||
np.ones(int(np.count_nonzero(valid)), dtype=np.float64),
|
||||
xx[valid].ravel(),
|
||||
yy[valid].ravel(),
|
||||
])
|
||||
z = data[valid].ravel()
|
||||
coeffs, _, _, _ = np.linalg.lstsq(A, z, rcond=None)
|
||||
pa, pbx, pby = coeffs
|
||||
return float(pa), float(pbx), float(pby), xx, yy
|
||||
|
||||
|
||||
@register_node(display_name="Plane Level")
|
||||
class PlaneLevelField:
|
||||
@classmethod
|
||||
@@ -11,7 +54,11 @@ class PlaneLevelField:
|
||||
return {
|
||||
"required": {
|
||||
"field": ("DATA_FIELD",),
|
||||
}
|
||||
"masking": (["ignore", "include", "exclude"], {"default": "ignore"}),
|
||||
},
|
||||
"optional": {
|
||||
"mask": ("IMAGE",),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("DATA_FIELD",)
|
||||
@@ -19,27 +66,19 @@ class PlaneLevelField:
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Fit and subtract a least-squares plane from the data. "
|
||||
"Equivalent to gwy_data_field_fit_plane + gwy_data_field_plane_level."
|
||||
"Fit and subtract a least-squares plane from the data. Supports include/exclude mask fitting "
|
||||
"for flattening around features, similar to masked plane fitting workflows in Gwyddion."
|
||||
)
|
||||
|
||||
def process(self, field: DataField) -> tuple:
|
||||
def process(
|
||||
self,
|
||||
field: DataField,
|
||||
masking: str = "ignore",
|
||||
mask: np.ndarray | None = None,
|
||||
) -> tuple:
|
||||
data = field.data.copy()
|
||||
yres, xres = data.shape
|
||||
|
||||
x = np.linspace(0.0, 1.0, xres)
|
||||
y = np.linspace(0.0, 1.0, yres)
|
||||
xx, yy = np.meshgrid(x, y)
|
||||
|
||||
A = np.column_stack([
|
||||
np.ones(xres * yres),
|
||||
xx.ravel(),
|
||||
yy.ravel(),
|
||||
])
|
||||
z = data.ravel()
|
||||
|
||||
coeffs, _, _, _ = np.linalg.lstsq(A, z, rcond=None)
|
||||
pa, pbx, pby = coeffs
|
||||
mask_array = _normalize_mask(mask, data.shape)
|
||||
pa, pbx, pby, xx, yy = _fit_plane(data, mask_array, masking)
|
||||
|
||||
plane = (pa + pbx * xx + pby * yy)
|
||||
return (field.replace(data=data - plane),)
|
||||
|
||||
Reference in New Issue
Block a user